Skip to content

进程管理

进程是操作系统中"正在运行的程序"的抽象。进程管理模块负责进程的创建、调度、切换和销毁,是操作系统实现并发和资源隔离的核心。

进程的本质

进程 vs 程序

程序是静态的代码文件,进程是动态的执行实例。一个程序可以对应多个进程:

程序:/bin/ls(磁盘上的文件)

进程 A:执行 ls -l
  - 独立的内存空间
  - 独立的打开文件列表
  - 独立的执行状态

进程 B:执行 ls -la
  - 另一块独立的内存空间
  - 另一个打开文件列表
  - 另一个执行状态

进程包含什么?

一个进程拥有:

  1. 代码和数据:存放在进程的地址空间中
  2. 执行状态:寄存器值、程序计数器
  3. 内核资源:打开的文件、工作目录
  4. 元数据:进程 ID、父进程、状态

进程的生命周期

创建 → 运行 ⟷ 就绪
         ↓
       等待
         ↓
       退出
  • 创建:分配进程结构、地址空间
  • 就绪:可以运行,等待 CPU
  • 运行:正在 CPU 上执行
  • 等待:等待 I/O 或其他事件
  • 退出:释放资源

进程结构:内核如何"记住"进程?

proc 结构的核心字段

内核为每个进程维护一个 proc 结构,记录所有相关信息:

身份信息: - pid:进程 ID,唯一标识 - tgid:线程组 ID(用于线程支持) - comm:进程名(如 "init", "shell")

内存信息: - pagetable:进程的页表 - kstack:内核栈指针 - vma:虚拟内存区域链表

调度信息: - state:进程状态(就绪/运行/等待/僵尸) - ctx:上下文(保存的寄存器) - runq:运行队列节点

资源信息: - fd_table:文件描述符表 - pwd:当前工作目录

关系信息: - parent:父进程指针 - children:子进程链表 - sibling:兄弟进程链表

为什么需要内核栈?

每个进程有独立的内核栈,原因:

  1. 安全性:防止进程间栈数据泄露
  2. 独立性:进程阻塞时,栈状态保留
  3. 中断处理:中断发生时使用当前进程的内核栈

内核栈大小通常为 4KB(一页),必须足够容纳最深的函数调用链。

context 结构:切换的关键

进程切换时需要保存什么?答案是 callee-saved 寄存器

ra: 返回地址
sp: 栈指针
gp: 全局指针
s0-s11: callee-saved 寄存器
sstatus: 状态寄存器

为什么只保存这些?因为根据调用约定,函数调用时调用者负责保存 caller-saved 寄存器(t0-t6, a0-a7),被调用者负责保存 callee-saved 寄存器。

进程切换本质上是一个"函数调用":context_switch 是被调用者,它只需保存 callee-saved 寄存器,caller-saved 寄存器由调用者(sched_yield 等)负责。

CPU 结构:CPU 知道什么?

每个 CPU 核维护自己的状态:

struct cpu {
    struct proc *proc;      // 当前运行的进程
    struct proc idle;       // idle 进程
    u64 intr_state;         // 中断状态
    int intr_depth;         // 中断嵌套深度
    bool need_resched;      // 需要调度标志
};

idle 进程:最后的归宿

当没有进程可运行时,CPU 执行 idle 进程。idle 进程的特点:

  1. 永远存在:每个 CPU 核内置一个
  2. 不参与调度:不在运行队列中
  3. 极简行为:执行 wfi 指令等待中断
void idle_loop(void) {
    while (1) {
        wfi();         // 等待中断(省电)
        sched_yield(); // 尝试调度
    }
}

wfi(Wait For Interrupt)让 CPU 进入低功耗状态,直到中断到来。

CPU 与进程的关系

单核场景:
CPU 0
  └── 当前进程:进程 A
  └── idle 进程:备用

多核场景:
CPU 0                    CPU 1
  └── 当前进程:进程 A     └── 当前进程:进程 B
  └── idle 进程           └── idle 进程

每个 CPU 独立调度,有自己的运行队列。

调度:谁来运行?

调度的本质

调度是做出"下一个运行谁"的决策。调度器维护一个运行队列,存储所有就绪进程。

FIFO 调度

NoobKernel 使用最简单的 FIFO(先进先出)调度:

运行队列:[进程 A] → [进程 B] → [进程 C]

时间线:
A 运行 → A 让出 → B 运行 → B 让出 → C 运行 → C 让出 → A 运行 → ...

特点: - 公平:先来先服务 - 简单:不需要优先级判断 - 缺点:不区分重要程度

调度时机

什么时候触发调度?

  1. 定时器中断:时间片用完(当前为软性触发)
  2. 进程阻塞:等待 I/O
  3. 进程退出:变成僵尸
  4. 进程唤醒:从等待变为就绪

sched_yield 的逻辑

void sched_yield(void) {
    // 1. 当前进程重新入队
    if (当前进程正在运行) {
        当前进程状态 = 就绪;
        加入运行队列;
    }

    // 2. 取出下一个进程
    下一个进程 = 从运行队列取出;

    if (有下一个进程) {
        切换到下一个进程;
    } else {
        切换到 idle 进程;
    }
}

协作式调度

当前实现是协作式调度:进程主动让出 CPU。优点是实现简单,缺点是一个死循环进程会卡住整个系统。

未来可以改为抢占式调度:定时器中断强制切换,保证公平性。

进程切换:如何切换?

切换的本质

进程切换需要做三件事:

  1. 保存旧进程状态:寄存器、栈指针
  2. 选择新进程:调度决策
  3. 恢复新进程状态:加载寄存器、切换页表

context_switch 的实现

context_switch(old, new):
    # 保存旧进程的寄存器
    sd ra, 0(old)
    sd sp, 8(old)
    sd gp, 16(old)
    ...

    # 加载新进程的寄存器
    ld ra, 0(new)
    ld sp, 8(new)
    ld gp, 16(new)
    ...

    # 返回(到新进程的代码)
    ret

关键点:当 ret 执行时,ra 已经是新进程的返回地址,所以"返回"到了新进程的代码。

切换后发生了什么?

假设从进程 A 切换到进程 B:

进程 A 调用 sched_yield()
    │ 保存 A 的 context
    │ A 状态变为就绪
    │ A 加入运行队列
    │ 取出进程 B
    │ context_switch(A, B)
    │ 加载 B 的 context
    ▼
返回到进程 B 的代码(B 之前调用 sched_yield 的地方)
    │ B 继续执行
    ▼
sched_yield() 返回
    │ B 继续正常执行

进程 B 不知道自己曾经被切换出去,只是 sched_yield() 调用"花费了很长时间"才返回。

栈的切换

切换 sp 寄存器就切换了栈。每个进程有自己的内核栈,切换后:

  • 函数返回地址在新进程的栈上
  • 局部变量在新进程的栈上
  • 旧进程的栈保持原状,等待恢复

这就是为什么每个进程需要独立的内核栈。

PID 分配:如何保证唯一?

问题:多核环境下的竞争

如果多个 CPU 同时分配 PID,如何保证不重复?

原子操作解决方案

static atomic64_t next_pid = ATOMIC64_INIT(PID_MIN);

pid_t alloc_pid(void) {
    return atomic64_inc(&next_pid);  // 原子递增
}

atomic64_inc 使用硬件原子指令(amoadd),保证递增操作不会被其他核打断。

PID 回收问题

当前设计不回收 PID,只是递增。理论上会溢出,但 32 位 PID 需要分配 42 亿次才会耗尽。在内核运行的生命周期内,几乎不可能。

如果需要回收,可以用位图标记已用 PID,但要处理并发问题。

内核线程:没有用户态的进程

什么是内核线程?

内核线程是只运行在内核态的进程,没有用户地址空间。用于执行后台任务:

  • 磁盘刷新线程
  • 内存回收线程
  • 网络处理线程

如何创建内核线程?

struct proc *kthread_create(void (*func)(void *), void *arg) {
    struct proc *p = alloc_proc();
    p->kstack = kmalloc(KSTACK_SIZE);
    p->pagetable = kpagetable;  // 共享内核页表
    p->ctx.ra = (u64)func;       // 返回地址 = 线程函数
    p->ctx.sp = (u64)p->kstack + KSTACK_SIZE;  // 栈顶
    p->state = PROC_RUNNABLE;
    return p;
}

关键点: - ra 设为线程函数入口,ret 时会跳转到函数 - sp 设为栈顶,函数可以用栈 - 共享内核页表,不需要独立的地址空间

进程与文件系统

每个进程有自己的文件描述符表和当前工作目录:

进程 A:
  fd_table: [stdin, stdout, stderr, /home/user/file.txt]
  pwd: /home/user

进程 B:
  fd_table: [stdin, stdout, stderr]
  pwd: /tmp

这实现了进程间的文件隔离。一个进程打开的文件,其他进程默认不可见。

进程状态转换

                    创建
                     │
                     ▼
              ┌── 就绪 ──┐
              │          │
         被调度     让出 CPU
              │          │
              ▼          │
              运行 ──────┘
              │
         等待事件/退出
              │
              ▼
             等待
              │
         事件发生
              │
              ▼
             就绪

僵尸进程

进程退出后,不立即释放资源,而是变成僵尸状态。父进程需要"收尸"(调用 wait)才能真正释放。

为什么需要僵尸状态?因为父进程可能需要获取子进程的退出状态。

设计权衡

为什么用简单的 FIFO 调度?

现代操作系统使用复杂的多级反馈队列调度。NoobKernel 选择 FIFO:

  1. 易于理解:调度逻辑清晰
  2. 实现简单:不需要复杂的优先级判断
  3. 调试友好:行为可预测

未来可以扩展为优先级调度。

为什么每个进程只有 4KB 内核栈?

内核栈必须驻留在物理内存(不能换页),是宝贵资源。4KB 对大多数内核调用链够用。

如果不够,会导致栈溢出,破坏相邻内存。可以增加栈溢出检测(使用 guard page)。

为什么不用用户态?

当前只支持内核线程,没有用户态进程。这是因为:

  1. 系统调用接口未实现
  2. 用户态页表管理更复杂
  3. 需要用户态中断处理(trampoline)

实现用户态是下一步的主要工作。

思考题

  1. 如果进程切换时只保存通用寄存器,不保存 CSR(如 sstatus),会发生什么?
  2. 为什么 idle 进程不加入运行队列?
  3. 多核调度时,进程可能在多个 CPU 的运行队列中,如何避免?
  4. 如果内核栈溢出,会如何检测?会发生什么?

下一步