进程管理¶
进程是操作系统中"正在运行的程序"的抽象。进程管理模块负责进程的创建、调度、切换和销毁,是操作系统实现并发和资源隔离的核心。
进程的本质¶
进程 vs 程序¶
程序是静态的代码文件,进程是动态的执行实例。一个程序可以对应多个进程:
程序:/bin/ls(磁盘上的文件)
进程 A:执行 ls -l
- 独立的内存空间
- 独立的打开文件列表
- 独立的执行状态
进程 B:执行 ls -la
- 另一块独立的内存空间
- 另一个打开文件列表
- 另一个执行状态
进程包含什么?¶
一个进程拥有:
- 代码和数据:存放在进程的地址空间中
- 执行状态:寄存器值、程序计数器
- 内核资源:打开的文件、工作目录
- 元数据:进程 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:兄弟进程链表
为什么需要内核栈?¶
每个进程有独立的内核栈,原因:
- 安全性:防止进程间栈数据泄露
- 独立性:进程阻塞时,栈状态保留
- 中断处理:中断发生时使用当前进程的内核栈
内核栈大小通常为 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 进程的特点:
- 永远存在:每个 CPU 核内置一个
- 不参与调度:不在运行队列中
- 极简行为:执行
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 运行 → ...
特点: - 公平:先来先服务 - 简单:不需要优先级判断 - 缺点:不区分重要程度
调度时机¶
什么时候触发调度?
- 定时器中断:时间片用完(当前为软性触发)
- 进程阻塞:等待 I/O
- 进程退出:变成僵尸
- 进程唤醒:从等待变为就绪
sched_yield 的逻辑¶
void sched_yield(void) {
// 1. 当前进程重新入队
if (当前进程正在运行) {
当前进程状态 = 就绪;
加入运行队列;
}
// 2. 取出下一个进程
下一个进程 = 从运行队列取出;
if (有下一个进程) {
切换到下一个进程;
} else {
切换到 idle 进程;
}
}
协作式调度¶
当前实现是协作式调度:进程主动让出 CPU。优点是实现简单,缺点是一个死循环进程会卡住整个系统。
未来可以改为抢占式调度:定时器中断强制切换,保证公平性。
进程切换:如何切换?¶
切换的本质¶
进程切换需要做三件事:
- 保存旧进程状态:寄存器、栈指针
- 选择新进程:调度决策
- 恢复新进程状态:加载寄存器、切换页表
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:
- 易于理解:调度逻辑清晰
- 实现简单:不需要复杂的优先级判断
- 调试友好:行为可预测
未来可以扩展为优先级调度。
为什么每个进程只有 4KB 内核栈?¶
内核栈必须驻留在物理内存(不能换页),是宝贵资源。4KB 对大多数内核调用链够用。
如果不够,会导致栈溢出,破坏相邻内存。可以增加栈溢出检测(使用 guard page)。
为什么不用用户态?¶
当前只支持内核线程,没有用户态进程。这是因为:
- 系统调用接口未实现
- 用户态页表管理更复杂
- 需要用户态中断处理(trampoline)
实现用户态是下一步的主要工作。
思考题¶
- 如果进程切换时只保存通用寄存器,不保存 CSR(如 sstatus),会发生什么?
- 为什么 idle 进程不加入运行队列?
- 多核调度时,进程可能在多个 CPU 的运行队列中,如何避免?
- 如果内核栈溢出,会如何检测?会发生什么?