Skip to content

进程

这一章主要记录我在操作系统中实现进程管理的过程。

在之前的章节里,我有过描述对进程概念的简单理解。在当时那个阶段,进程可以理解为一个运行中的程序副本。现在进入了具体的实现阶段,我需要把这个模糊的概念落实到代码上。

我的实现路线是循序渐进的:我决定先实现内核线程,因为它们运行在内核态,共享地址空间,实现起来相对简单。等我把线程的调度、切换以及同步机制(锁和信号量)都跑通了,再在此基础上引入独立的页表,从而实现真正的用户进程。这样可以避免一开始就陷入用户态特权级切换的泥潭中。

概述:从线程到进程

在第二章里,我已经区分过进程和线程这两个概念。虽然很多理论书籍习惯直接从进程讲起,但在编写代码的过程中,我发现先实现线程是一个更符合工程直觉的选择。

这主要是因为线程是调度的基本单位,而进程则是资源分配的基本单位。无论是内核线程还是用户进程,在调度器眼里都是一样的执行流,它们共用同一个核心数据结构——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,写汇编切换代码时会方便很多。紧接着是 pidstu(状态),用于标识和管理任务生命周期。

为了区分线程和进程,我设计了 pgdir 字段。如果它是空指针,说明这是个内核线程,直接使用内核的页表;如果它指向一个有效的页目录表地址,那就说明这是个用户进程,有自己独立的地址空间。此外,结构体末尾还有一个 stack_magic,这是一个魔数,用来检测内核栈在大深度调用时有没有溢出覆盖到 PCB 数据。

栈中栈:thread_stack 与 intr_stack

在这一页 4KB 的空间里,除了底部的 PCB,剩下的空间全都是内核栈。但这个栈在使用时其实被划分成了两个逻辑区域,分别应对不同的场景。

PCB 页内布局: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 约定,我只需要保存 esiediebxebp 这四个寄存器。

保存完寄存器后,栈顶指针 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 指针还在这个函数里,但我们脚下的栈已经换成了下一个线程的内核栈。

紧接着,我依次弹出 ebpebxediesi。注意,这时候弹出的已经是下一个线程之前保存的值了。

最后执行 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 里读取 SS0ESP0

更新 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 AThread BMain 的打印,每个线程各自的计数器递增,说明调度器工作正常。注意 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 成功拦截了用户态的越权操作,特权级保护生效了。

特权级保护测试结果

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