Appearance
进程
这一章主要记录我在操作系统中实现进程管理的过程。
在之前的章节里,我有过描述对进程概念的简单理解。在当时那个阶段,进程可以理解为一个运行中的程序副本。现在进入了具体的实现阶段,我需要把这个模糊的概念落实到代码上。
我的实现路线是循序渐进的:我决定先实现内核线程,因为它们运行在内核态,共享地址空间,实现起来相对简单。等我把线程的调度、切换以及同步机制(锁和信号量)都跑通了,再在此基础上引入独立的页表,从而实现真正的用户进程。这样可以避免一开始就陷入用户态特权级切换的泥潭中。
概述:从线程到进程
在第二章里,我已经区分过进程和线程这两个概念。虽然很多理论书籍习惯直接从进程讲起,但在编写代码的过程中,我发现先实现线程是一个更符合工程直觉的选择。
这主要是因为线程是调度的基本单位,而进程则是资源分配的基本单位。无论是内核线程还是用户进程,在调度器眼里都是一样的执行流,它们共用同一个核心数据结构——PCB。如果我能先造出“内核线程”,让它们在内核里并发跑起来,那么后面的用户进程其实就是给这些线程加上了独立的页表和三环(Ring 3)的运行环境。
所以我把整个实现过程拆解成了两个大阶段。第一阶段只关注内核线程的实现,重点解决上下文切换、调度算法和同步锁的问题,这时候所有任务都跑在内核空间,不需要考虑页表切换,调试起来比较容易。等这一部分稳定了,第二阶段再给线程穿上“马甲”,分配独立的用户地址空间,把它升级成真正的用户进程。这样可以最大程度地复用代码,也让问题的排查范围更集中。
PCB:进程的大脑
为了管理每一个任务,我定义了进程控制块(PCB),在代码中是 task 结构体。它相当于进程的大脑,记录了所有运行时的关键信息。
神奇的一页内存
在内存分配上,我借用了一个成熟的设计思路:把 PCB 和内核栈放在同一页内存(4KB)里。我向内存分配器申请一页物理页,通过强制类型转换,把这一页的低地址部分用作 PCB,而把高地址部分当作该任务的内核栈,栈底指向页面的最高端。
这样做有一个非常实用的好处。当程序在内核态运行时,当前栈指针 esp 一定在这个 4KB 的范围内。我只需要把 esp 的低 12 位清零(也就是按 4KB 向下取整),就能直接得到当前任务的 PCB 起始地址。这比维护一个全局变量来记录“当前运行的是谁”要高效得多,而且不管怎么切换,esp 里的信息永远是可信的。
代码中的 running_thread 函数就是利用了这个原理,通过一条汇编指令获取 esp,然后位与运算拿到 PCB 指针。
PCB 字段详解
在 kernel/module/thread/sync/task.cpp 文件中,我定义了 task 结构体。
c++
export struct task
{
u32* self_kstack; // 各个内核线程都用自己的内核栈
pid_t pid;
thread_status stu; // 状态
char name[16];
u8 priority; // 优先级
u8 ticks; // 每次在处理器上执行的时间嘀嗒数
u32 elapsed_ticks; // 此任务上cpu运行后至今占用了多少cpu嘀嗒数
std::array<i32,MAX_FILES_OPEN_PER_PROC> fd_table; // 文件描述符表
list::node general_tag; // 用于线程在一般的队列中的节点
list::node all_list_tag;// 用于线程队列thread_all_list中的节点
u32* pgdir; // 进程自己页目录表的虚拟地址
virtual_addr userprog_vaddr; // 用户进程的虚拟地址
mem_block_desc u_block_desc[DESC_CNT];
u32 cwd_inode_no; // 进程所在的工作目录的inode编号
i16 parent_pid; // 父进程的pid
i8 exit_status; // 进程结束时自己调用exit传入的参数
u32 stack_magic; // 栈的边界标记,防止溢出
};结构体的第一个成员是 self_kstack,它记录了任务被换出 CPU 时栈顶指针的位置。把它放在第一个位置是因为偏移量为 0,写汇编切换代码时会方便很多。紧接着是 pid 和 stu(状态),用于标识和管理任务生命周期。
为了区分线程和进程,我设计了 pgdir 字段。如果它是空指针,说明这是个内核线程,直接使用内核的页表;如果它指向一个有效的页目录表地址,那就说明这是个用户进程,有自己独立的地址空间。此外,结构体末尾还有一个 stack_magic,这是一个魔数,用来检测内核栈在大深度调用时有没有溢出覆盖到 PCB 数据。
栈中栈:thread_stack 与 intr_stack
在这一页 4KB 的空间里,除了底部的 PCB,剩下的空间全都是内核栈。但这个栈在使用时其实被划分成了两个逻辑区域,分别应对不同的场景。

位于页面最顶端的是 intr_stack(中断栈)。这是硬件或者特权级切换时使用的保护现场区域。每当任务从用户态进入内核态(比如系统调用或中断),或者在内核态发生中断时,CPU 会自动把上下文压入这里。我严格按照 x86 硬件压栈的顺序定义了这个结构体。
c++
export struct intr_stack
{
u32 vec_no; // 中断向量号
u32 edi; u32 esi; u32 ebp; u32 esp_dummy;
u32 ebx; u32 edx; u32 ecx; u32 eax;
u32 gs; u32 fs; u32 es; u32 ds;
// 当从低特权级陷入高特权级时压入
u32 err_code; // err_code 被压入在 eip 之后
void (*eip)();
u32 cs;
u32 eflags;
void* esp;
u32 ss;
};紧挨着中断栈下方的是 thread_stack(线程栈)。这个区域主要用于任务之间的上下文切换。当调度器决定暂停当前任务通过 switch_to 函数切换到下一个任务时,这两个任务必须要保存属于函数调用约定(ABI)的寄存器。
c++
export struct thread_stack
{
u32 ebp; // 这四个寄存器归主调函数所有
u32 ebx; // 为第一次 switch_to的时候的pop 做对齐处理
u32 edi;
u32 esi;
// 线程第一次执行时,刚好eip指向待调用的函数
void (*eip) (function* func,void* func_arg);
// 以下仅在第一次被调度上cpu时会使用
void (*unused_retaddr);
function* func;
void* func_arg;
};线程创建
把 PCB 的结构定下来之后,接下来的任务就是如何初始化它,让一个静态的结构体变成一个能被调度器识别的“活”线程。这部分逻辑我写在 kernel/module/thread/exec.cppm 文件里。
初始化 PCB
创建一个线程的第一步是搞定它的“身份证”。我在 init_thread 函数里完成这件事。
c++
export auto init_thread(task* pthread,char const* name,u8 prio) -> void
{
memset(pthread,0,sizeof(*pthread));
pthread->pid = pid_pool.allocate();
if(pthread == main_thread) {
pthread->stu = status::running;
} else {
pthread->stu = status::ready;
}
pthread->self_kstack = reinterpret_cast<u32*>(reinterpret_cast<u32>(pthread) + PG_SIZE);
pthread->priority = prio;
pthread->ticks = prio;
pthread->elapsed_ticks = 0;
pthread->fd_table = { 0,1,2 };
pthread->fd_table[3,MAX_FILES_OPEN_PER_PROC] | std::fill[-1];
pthread->pgdir = nullptr;
pthread->stack_magic = 0x19870916;
strcpy(pthread->name,name);
}这里的重点是把状态设置为 ready(就绪态),初始化优先级和时间片,以及最关键的——初始化栈顶指针。刚申请下来的 PCB 页是空的,我把 self_kstack 指针指向这页内存的最高地址。这时候栈是空的,什么都没有。
伪造上下文
为了让新创建的线程能被调度器“恢复”执行,我必须在它的内核栈里预先埋好数据。这就像是制造一个“案发现场”,让 CPU 以为这个线程之前就在这里运行过,只是被中断暂停了而已。
c++
export auto thread_create(task* pthread,function func,void* func_arg) -> void
{
// 内核栈 预留 中断栈与线程栈的空间
pthread->self_kstack = reinterpret_cast<u32*>(
reinterpret_cast<char*>(pthread->self_kstack) - sizeof(intr_stack) - sizeof(thread_stack));
auto kthread_stack = reinterpret_cast<thread_stack*>(pthread->self_kstack);
kthread_stack->eip = kernel_thread;
kthread_stack->func = func;
kthread_stack->func_arg = func_arg;
kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0;
}我在 thread_create 函数里做了这件事。首先,我在栈顶预留了中断栈(intr_stack)的空间。接着,我在下面填充了一个线程栈(thread_stack)。
在这个线程栈里,我把 eip 设定为 kernel_thread 函数的地址。这样一来,当该线程第一次被调度上 CPU 时,ret 指令会把这个地址弹出来赋给 EIP 寄存器,CPU 就会直接跳转到 kernel_thread 去执行。而 kernel_thread 函数只需要做一件事:开启中断,然后调用用户真正想执行的函数。
c++
auto kernel_thread(function* func,void* func_arg) -> void
{
intr_enable(); // 开中断,避免后面的时钟中断被屏蔽,无法调度其他线程
func(func_arg);
}主线程的特殊处理
系统中有一个特殊的线程是不需要“创建”的,那就是当前正在运行的初始化代码本身。
c++
// 将 kernel 的执行流完善为主线程
export auto make_main_thread() -> void
{
// loader.asm 载入内核时 mov esp, 0xc009f000
// 则 pcb 地址为 0xc009e000, 不需要额外分配一页
main_thread = running_thread();
init_thread(main_thread,"main",31);
// main是当前运行的线程 当前线程当然不在就绪队列中,只加入all_list
ASSERT(not thread_all_list.contains(&main_thread->all_list_tag));
thread_all_list.push_back(&main_thread->all_list_tag);
}我写了一个 make_main_thread 函数来给它补办手续。因为它已经在运行了,而且用的就是现在的栈(0xc009f000),所以我不需要给它分配新页,只需要通过 running_thread 拿到当前页底的地址作为 PCB,然后把它初始化为 running 状态,并加入到全局任务队列中。
调度器的实现
只要系统中同时存在的线程超过一个,就必须要有调度器来决定下一刻谁占有 CPU。我在 kernel/module/thread/sync/schedule.cpp 中实现了一个简单但有效的时间片轮转调度算法。
就绪队列与全局队列
我维护了两个双向链表。
c++
export thread_list thread_ready_list; // 线程就绪队列
export thread_list thread_all_list; // 所有任务队列一个是 thread_all_list,用来记录系统里所有存在的任务,不管它是死是活,只要没被回收都在这里,方便调试和统计。另一个是 thread_ready_list,这里面只放那些状态是 READY、随时准备上 CPU 的任务。
调度核心逻辑
调度的核心入口是 schedule 函数。它的逻辑非常直观:先看看当前运行的线程是因为什么原因下来的。
c++
// 实现任务调度
auto schedule() -> void
{
ASSERT(intr_get_status() == INTR_OFF);
auto cur = running_thread();
if(cur->stu == thread_status::running) {
// 表示该线程是 cpu 时间片到了
ASSERT(not thread_ready_list.contains(&cur->general_tag));
thread_ready_list.push_back(&cur->general_tag);
cur->ticks = cur->priority;
cur->stu = thread_status::ready;
}
// ...
auto thread_tag = thread_ready_list.front();
thread_ready_list.pop_front();
auto next = find_task_by_general(thread_tag);
next->stu = thread_status::running;
process_activate(next); // 激活新进程的页表
switch_to(cur,next); // 调度
}如果它是因为时间片用完了(状态还是 RUNNING),那说明它还想跑,只是被强制剥夺了 CPU。这时候我就把它追加到就绪队列的队尾,并重置它的时间片 ticks,把状态改为 READY。
如果它是自己主动让出 CPU(比如调用了 thread_block 等待锁,或者线程运行结束退出了),那它的状态肯定已经变成了 BLOCKED 或者 DIED,这种情况下我就不需要把它放回就绪队列了。
处理完旧人,接下来就是迎新人。我从就绪队列的头部弹出一个可用的线程节点,通过 general_tag 找到它对应的 PCB,把它的状态改成 RUNNING。
最后一步是调用 process_activate 激活新任务的页表(如果是用户进程的话),并调用汇编函数 switch_to 进行真正的寄存器切换。

IDLE 线程
这里有个特殊情况:万一就绪队列空了怎么办?比如所有线程都在等待硬盘 I/O。CPU 是不能停下来的,必须要有代码给它跑。
c++
// 系统空闲时,运行的线程
auto idle(void* arg) -> void
{
while(true) {
thread_block(thread_status::blocked);
// 执行 hlt 要保证处于开中断下
asm volatile("sti; hlt" : : : "memory");
}
}所以我专门设计了一个 idle 线程。它在系统初始化时就被创建好,平时就阻塞着。一旦 schedule 发现就绪队列空了,就会把 idle 线程唤醒。
上下文切换
如果说调度器是决策者,那 switch_to 函数就是执行者。这是整个多任务系统中最底层、最核心的代码,必须用汇编写成。我在 kernel/module/thread/sync/switch.asm 中实现了它。
精妙的栈切换
switch_to 接收两个参数:当前线程的 PCB 指针(cur)和下一个线程的 PCB 指针(next)。
text
switch_to:
; 栈中 此处为返回地址
push esi
push edi
push ebx
push ebp ; 保存内核上下文
mov eax, [esp + 20] ; 得到栈中的参数cur
mov [eax], esp ; task->self_kstack = esp 保存栈顶指针进入函数后,我首先要把当前线程的上下文保存起来。根据 ABI 约定,我只需要保存 esi、edi、ebx 和 ebp 这四个寄存器。
保存完寄存器后,栈顶指针 esp 指向的位置就是当前线程所有上下文的存放点。我把这个 esp 的值填入 cur->self_kstack 字段中。这相当于给当前线程拍了个“快照”。

接下来就是见证奇迹的时刻。我从 next->self_kstack 中读出下一个线程之前保存的 esp 值,然后直接用 mov esp, [eax]把它加载到 CPU 的 ESP 寄存器里。
text
; 上面备份好线程环境,下面恢复下一个线程环境
mov eax, [esp + 24] ; 得到栈中的参数next, next = [esp + 24]
mov esp, [eax] ; pcb的第一个成员是 self_kstack
pop ebp
pop ebx
pop edi
pop esi
ret偷天换日
就在这一条 mov esp, [eax] 指令执行完的瞬间,CPU 的视角已经变了。虽然 PC 指针还在这个函数里,但我们脚下的栈已经换成了下一个线程的内核栈。
紧接着,我依次弹出 ebp、ebx、edi 和 esi。注意,这时候弹出的已经是下一个线程之前保存的值了。
最后执行 ret 指令。因为栈换了,栈顶弹出的返回地址自然也是下一个线程之前被打断时的地址(或者是我们伪造的 kernel_thread 地址)。CPU 跳转过去,新的线程就这样不知不觉地“恢复”运行了。
同步机制
多线程并发虽然提高了效率,但也带来了资源竞争的问题。为了解决这个问题,我实现了两种基础的同步原语:信号量和互斥锁。
信号量的设计
信号量(Semaphore)本质上就是一个计数器。在 kernel/module/thread/sync/semaphore.cppm 中,我定义了它的结构:包含一个整数 value 和一个等待队列 waiters。
c++
export struct semaphore
{
// ...
auto acquire() -> void
{
auto old_status = intr_disable();
while(value == 0) {
// 唤醒且无信号量 那么这时候阻塞队列里不应该存在自己
auto cur = running_thread();
ASSERT(not waiters.contains(&cur->general_tag));
waiters.push_back(&cur->general_tag);
thread_block(thread_status::blocked);
}
// 直到或一开始存在可获得的信号
--value;
intr_set_status(old_status);
}
auto release() -> void
{
auto old_status = intr_disable();
if(not waiters.empty()) {
auto thread_tag = waiters.front();
waiters.pop_front();
auto thread_blocked = find_task_by_general(thread_tag);
thread_unblock(thread_blocked);
}
++value;
intr_set_status(old_status);
}
u8 value;
list waiters{};
};acquire(P操作)的逻辑是:只要 value 为 0,说明资源没了,我就把自己加入等待队列,然后调用 thread_block 阻塞自己。等醒来后(或者一开始资源就够),就把 value 减 1,拿走资源。
release(V操作)则相反:先把 value 加 1,然后检查等待队列。如果不为空,就从里面唤醒一个倒霉蛋(调用 thread_unblock),告诉它资源有了,可以起来干活了。
这里必须要注意的是,value 的检查和修改必须是原子操作。因为我是单核系统,最简单的办法就是在操作前后关中断。
互斥锁的实现
互斥锁(Mutex)其实就是初值为 1 的二元信号量,但我给它增加了一个“持有者”字段 holder 和一个“重复获取次数”字段。
c++
export struct mutex
{
auto lock() -> void
{
auto cur = running_thread();
if(holder != cur) {
sema.acquire();
holder = cur;
holder_repeat_nr = 1;
return;
}
++holder_repeat_nr;
}
auto unlock() -> void
{
auto cur = running_thread();
ASSERT(holder == cur);
if(holder_repeat_nr > 1) {
--holder_repeat_nr;
return;
}
ASSERT(holder_repeat_nr == 1);
holder = nullptr;
holder_repeat_nr = 0;
sema.release();
}
task* holder; // 锁的持有者
semaphore sema; // 利用二元信号量实现
u32 holder_repeat_nr; // 锁的持有者重复申请锁的次数
};为什么要加这个?因为有时候一个线程已经拿到了锁,但在它调用的子函数里又试图去拿这把锁。如果是普通的信号量,这直接就死锁了(自己等自己)。为了支持这种递归上锁的需求,我在 lock 函数里判断:如果当前的锁持有者就是我自己,那就直接把计数加 1,不阻塞;如果是别人持有着,那我才去申请那个底层的信号量。
解锁的时候也是一样,只有当引用计数减到 0 了,才真正释放底层的信号量。这样写起来,代码的健壮性就强多了。
用户进程
把内核线程跑通之后,最后一步就是实现用户进程。这部分代码主要集中在 kernel/module/thread/process.cpp 中。
用户进程和内核线程最大的区别有两点: 1. 拥有独立的虚拟地址空间(自己的页表)。 2. 运行在特权级 3(Ring 3),受到 CPU 的保护。
创建页表
在创建进程的 PCB 时,我必须为它分配一个新的页目录表。
c++
// 创建用户进程
auto process_execute(void* filename,char const* name) -> void
{
// pcb内核数据结构,由内核维护进程信息,故在内核内存池中申请
auto thread = (task*)get_kernel_pages(1);
init_thread(thread,name,default_prio);
create_user_vaddr_bitmap(thread);
thread_create(thread,start_process,filename);
thread->pgdir = create_page_dir(); // 创建新的页目录表
block_desc_init(thread->u_block_desc);
// ... 加入队列 ...
}这个页目录表不能是空的,因为它需要把内核空间的映射(高 1GB)先复制过去,保证进程陷入内核时能正常工作。至于低 3GB 的用户空间,那是它自己独享的操场,刚开始是空的。
特权级欺骗
怎么让一个程序在 Ring 3 运行呢?唯一的办法是利用 CPU 的“中断返回”机制。
我们知道,当 CPU 从中断处理程序返回时,会从栈里弹出 CS(代码段)和 EIP。如果弹出的 CS 的低 2 位是 3(代表 RPL=3),CPU 就会切换到用户态。
c++
// 构建用户进程上下文
auto start_process(void* filename) -> void
{
auto func = filename;
auto cur = running_thread();
cur->self_kstack = (u32*)((char*)cur->self_kstack + sizeof(thread_stack));
auto proc_stack = (intr_stack*)cur->self_kstack;
*proc_stack = (intr_stack) {
.fs = SELECTOR_U_DATA,
.es = SELECTOR_U_DATA,
.ds = SELECTOR_U_DATA,
.eip = (void(*)())func,
.cs = SELECTOR_U_CODE,
.eflags = EFLAGS_IOPL_0 | EFLAGS_MBS | EFLAGS_IF_1,
.esp = (void*)((u32)get_a_page(pool_flags::USER,USER_STACK3_VADDR) + PG_SIZE),
.ss = SELECTOR_U_DATA
};
asm volatile("movl %0, %%esp; jmp intr_exit" : : "g"(proc_stack) : "memory");
}我利用这个特性,人为构造了一个中断栈(intr_stack)。在这个栈里,我把 CS 填成用户代码段选择子 SELECTOR_U_CODE,把 DS/ES/FS 填成用户数据段选择子 SELECTOR_U_DATA,把 EFLAGS 里的 IF 位置 1(允许中断),把 ESP 填成我们为用户进程分配的栈底 0xc0000000。
当这个构造好的线程被调度上 CPU 后,它执行 iret(通过 intr_exit 宏),CPU 就会误以为它是从一次用户态中断中返回的,于是乖乖地切换特权级,跳转到我指定的入口地址去执行代码——这时候,我们就在 Ring 3 了。
TSS 与特权级切换
在实现用户进程时,遭遇的一个最大拦路虎就是 TSS(Task State Segment)。
为什么需要 TSS
在 x86 架构下,CPU 从低特权级(Ring 3)进入高特权级(Ring 0)时——比如发生中断或系统调用,它需要切换栈。因为用户态的栈是不安全的,内核不能用。那内核该去哪里找属于这个任务的内核栈指针(SS0:ESP0)呢?
答案就是 TSS。CPU 会自动去 TSS 里读取 SS0 和 ESP0。
更新 ESP0
虽然 Intel 设计 TSS 的初衷是想让每个任务都有一个独立的 TSS,实现硬件级的任务切换,但这套机制效率太低了(类似我们把 PCB 里的东西都在硬件层面做了一遍)。现代操作系统(包括 Linux 和我写的这个)都只使用一个全局的 TSS。
c++
export struct tss
{
// 更新 tss 中 esp0 字段的值为pthread的0级栈
auto update(task const* pthread) -> void
{
esp0 = (u32*)((u32)pthread + PG_SIZE);
}
// ...
u32* esp0; // 特权级0 的栈指针
u32 ss0; // 特权级0 的栈段选择子
// ...
};这就带来一个问题:所有任务共用一个 TSS,那 TSS 里的 ESP0 到底应该填谁的栈地址?
答案是:谁在跑就填谁的。
c++
// 激活线程或者进程的页表,更新tss中的esp0为进程的0特权级栈
auto process_activate(task const* pthread) -> void
{
ASSERT(pthread != nullptr);
page_dir_active(pthread); // 激活页表
// 内核线程特权级本身是0,处理器进入中断不会从tss获取0特权级栈地址,不需要更新esp0
if(pthread->pgdir) { // 如果是用户进程(线程)
tss.update(pthread);
// 更新esp0 用于进程被中断时保留上下文
}
}每当调度器通过 switch_to 切换到一个新任务时,我会调用 process_activate,进而调用 tss.update(pthread) 函数。这个函数会把新任务 PCB 所在页的顶端地址(也就是该任务的空内核栈底)填入 TSS 的 ESP0 字段。
这样,一旦这个新任务在用户态浪着浪着被时钟中断打断了,CPU 去查 TSS,就能准确找到属于它自己的内核栈位置,安全地把环境保存下来。如果这一步没做对,用户进程一旦发生中断,CPU 就找不到内核栈,直接触发双重错误(Double Fault)甚至三重错误重启。
上机测试
代码写完了,必须得跑起来验证一下。我在 kernel/main.cpp 里准备了几个测试函数,可以直接调用。
内核线程并发测试
测试内核线程的并发调度,我写了一个 test_kernel_threads() 函数。为了让调度效果可见,每个线程打印后都会用延时循环消耗一段时间:
c++
// 延时函数:空循环消耗时间
auto delay(volatile u32 count) -> void
{
while(count-- > 0) {}
}
// 内核线程A:打印编号并延时
auto k_thread_a(void* arg) -> void
{
auto cnt = 0;
while(true) {
console::println("Thread A: {}", cnt++);
delay(10000000);
}
}
// 内核线程B:打印编号并延时
auto k_thread_b(void* arg) -> void
{
auto cnt = 0;
while(true) {
console::println("Thread B: {}", cnt++);
delay(10000000);
}
}
// 测试内核线程并发调度
auto test_kernel_threads() -> void
{
console::println("=== Testing Kernel Threads ===");
thread_start("k_thread_a", 31, k_thread_a, nullptr);
thread_start("k_thread_b", 8, k_thread_b, nullptr);
auto cnt = 0;
while(true) {
console::println("Main: {}", cnt++);
delay(10000000);
}
}在 main() 函数里调用 test_kernel_threads(),然后跑 Bochs。屏幕上会交替出现 Thread A、Thread B、Main 的打印,每个线程各自的计数器递增,说明调度器工作正常。注意 k_thread_a 的优先级是 31,k_thread_b 是 8,所以 B 打印的频率会比 A 低。

用户进程测试
这个测试验证用户进程能否正常创建和调度。用户进程有自己独立的页表,运行在 Ring 3:
c++
// 用户进程函数
auto u_prog_a() -> void
{
while(true) {
// 用户态进程循环
}
}
// 测试用户进程
auto test_user_process() -> void
{
console::println("=== Testing User Process ===");
process_execute((void*)u_prog_a, "user_prog_a");
console::println("User process created, entering main loop");
auto cnt = 0;
while(true) {
console::println("Main: {}", cnt++);
delay(10000000);
}
}在 main() 里调用 test_user_process(),如果能看到 Main 不断打印计数器递增,说明用户进程被成功创建,并且和 main 线程一起被调度。用户进程虽然在死循环,但它消耗的是自己的时间片,不影响内核线程的运行。

特权级保护测试
这个测试验证用户态进程是否真的被限制在 Ring 3。我让用户进程尝试执行特权指令 cli(关中断),看 CPU 会不会拦截:
c++
// 用户进程测试特权级
auto u_prog_privilege_test() -> void
{
// 尝试执行特权指令 cli(关中断)
// 在 Ring 3 下应触发 #GP 异常
asm volatile("cli");
while(true) {}
}
// 测试用户进程特权级保护
auto test_user_privilege() -> void
{
console::println("=== Testing User Privilege Protection ===");
console::println("Creating user process that will try 'cli' instruction...");
console::println("Expected: #GP General Protection Exception");
process_execute((void*)u_prog_privilege_test, "privilege_test");
while(true) {}
}在 main() 里调用 test_user_privilege(),预期结果是屏幕上会显示 #GP General Protection Exception。这说明 CPU 成功拦截了用户态的越权操作,特权级保护生效了。

至此,我们的进程管理模块就搭建起了最核心的骨架。