从零写OS内核 | 进程与调度——从fork到schedule,一个进程是怎么诞生的
你敲下 ./a.out 回车,一个进程就运行起来了。这个进程是怎么被操作系统”创造”出来的?fork 做了什么?exec 又做了什么?调度器是怎么决定让哪个进程先跑的?
这是操作系统最核心的概念之一:进程管理。从你在终端敲下命令,到程序真正在 CPU 上运行,这中间操作系统做了大量的工作。今天,我们来完整走一遍这个过程。
1. 进程是什么:task_struct
在 Linux 里,进程是一个 task_struct 对象——一个描述进程所有状态的结构体。它包含:
task_struct 核心字段(简化):
进程身份:
pid_t pid; // 进程 ID,唯一标识
pid_t ppid; // 父进程 ID
char name[16]; // 进程名
状态:
volatile long state; // 进程状态(R/T/S/Z)
unsigned int flags; // 进程标志
调度相关:
int static_prio; // 静态优先级
int dynamic_prio; // 动态优先级
unsigned int weight;// 调度权重(cfs_rq 用)
struct sched_entity se; // 调度实体(红黑树节点)
内存:
struct mm_struct *mm; // 内存描述符(虚拟地址空间)
unsigned long rss; // 物理内存占用
文件:
struct files_struct *files; // 打开的文件描述符表
时间:
unsigned long start_time; // 进程启动时间
unsigned long utime; // 用户态时间(jiffies)
unsigned long stime; // 内核态时间(jiffies)
线程:
struct thread_struct thread; // CPU 寄存器上下文(栈、IP 等)进程和线程的区别:在 Linux 里,线程只是一个共享部分 task_struct 字段的轻量进程。线程有自己的 thread(栈和寄存器),但共享 mm、files、信号处理 等。
2. fork:创建一个新进程
fork 是 Unix 最优雅的设计之一——它用一次系统调用创建了一个进程的两个视图:
fork 的本质:
父进程调用 fork()
↓
内核做三件事:
1. 分配一个新的 task_struct
2. 复制父进程的地址空间(COW,Copy-On-Write)
3. 把新进程加入调度器
↓
返回:在父进程里返回子进程 PID,在子进程里返回 0
结果:两个几乎完全相同的进程在跑,只是返回值不同2.1 fork 的内核实现(do_fork)
// Linux fork 核心函数(简化版)
// 源码:kernel/fork.c
int do_fork(unsigned long clone_flags,
unsigned long stack_start,
struct pt_regs *regs,
unsigned long stack_size) {
// Step 1: 分配 task_struct(copy_process)
struct task_struct *p = copy_process(clone_flags,
stack_start,
regs,
stack_size);
// Step 2: 分配 PID
p->pid = alloc_pid();
// Step 3: 分配内核栈(每个进程独立的栈)
p->stack = alloc_stack();
// Step 4: 初始化调度实体
sched_cgroup_fork(p);
sched_fork(p);
// Step 5: 加入调度器(把 task_struct 加入红黑树)
wake_up_new_task(p);
// Step 6: 返回
return p->pid;
}2.2 copy_process:关键的进程复制
struct task_struct *copy_process(unsigned long clone_flags, ...) {
// 分配新的 task_struct(用 SLAB 分配器)
struct task_struct *p = alloc_task_struct();
// 复制父进程的 mm(地址空间)
if (!(clone_flags & CLONE_VM)) {
// 不是线程(线程共享父的 mm)
p->mm = dup_mm(current->mm); // COW 复制
}
// 复制文件描述符表(线程共享)
if (clone_flags & CLONE_FILES) {
atomic_inc(&current->files->count);
p->files = current->files;
} else {
p->files = dup_fd(current->files);
}
// 复制信号处理(线程共享)
if (clone_flags & CLONE_SIGHAND) {
atomic_inc(&current->sighand->count);
} else {
p->sighand = dup_sighand(current->sighand);
}
// 复制命名空间(CLONE_NEWNS 等)
...
// 设置返回值(fork 在子进程返回 0)
p->set_child_tid = (clone_flags & CLONE_CHILD_SETTID) ?
child_tidptr : NULL;
clear_tsk_thread_flag(p, TIF_NOTIFY_RESUME);
// 关键:fork 返回值在两个地方不同
// 子进程:p->thread.rax = 0
// 父进程:do_fork 返回子 PID(通过寄存器带回去)
p->thread.rax = 0;
return p;
}2.3 COW:fork 为什么这么快
fork 最大的开销是复制地址空间——但 Linux 实际上根本不复制物理页。
COW (Copy-On-Write) 原理:
fork 时:
→ 父子进程共享同一个物理页,标记为只读
→ 任何一个进程尝试写入 → 触发 #PF
→ PF handler 发现是 COW 页 → 分配新物理页,复制内容,更新 PTE
→ 两个进程各有自己的物理页,写入互不干扰
结果:fork 本身只复制页表(4KB),不复制任何物理页
只有在实际写入时才复制,速度极快3. exec:加载新程序
fork 只是复制了当前进程,exec 才是真正加载新程序的东西:
exec 的本质:
→ 用新程序替换当前进程的地址空间
→ PID 不变(还是同一个进程)
→ 代码段、数据段、堆栈全部重新初始化
→ 从新程序的 main() 开始执行// exec 系统调用(简化版)
int sys_execve(const char *filename,
char *const argv[],
char *const envp[]) {
struct linux_binprm bprm;
// Step 1: 读取可执行文件头部(检测格式:ELF/binary/script)
bprm.file = open_exec(filename);
// Step 2: 检查权限(CAP_SETPCAP 等)
// Step 3: 搜索解释器(#! 脚本的 /bin/sh)
search_binary_handler(&bprm);
return 0;
}
// ELF 加载器(search_binary_handler 的其中一种 handler)
int load_elf_binary(struct linux_binprm *bprm) {
// 读取 ELF header
struct elfhdr *elf = bprm->buf;
// 映射段(代码段、数据段)
for (i = 0; i < elf->e_phnum; i++) {
if (elf->phdr[i].p_type == PT_LOAD) {
// 建立虚拟地址到物理文件的映射(mmap)
elf_map(bprm->file, elf->phdr[i].p_vaddr,
elf->phdr[i].p_filesz,
elf->phdr[i].p_offset);
}
}
// 设置 entry point
current->mm->start_code = elf->e_entry;
// 初始化栈(压入 argc, argv, envp)
create_elf_tables(bprm);
// 设置 EIP 到入口点
// 下次调度到这个进程时,从 entry 开始执行
start_thread(regs, elf->e_entry, ...);
return 0;
}4. 调度器:CFS 完全公平调度
Linux 的默认调度器是 CFS(Completely Fair Scheduler),核心思想:每个进程按其”虚拟运行时间”在红黑树上排序,最少虚拟时间的进程先跑。
CFS 原理:
每个进程有一个"vruntime"(虚拟运行时间)
→ 进程跑 1ms,vruntime += 1 * ( NICE_0_LOAD / 进程权重 )
→ 高优先级进程:权重更大 → 同样的墙上时间 → vruntime 增长更慢
→ 所以高优先级进程的 vruntime 总是更小 → 更容易被调度到
调度器总是选红黑树最左边的节点(最小 vruntime)执行
调度周期 = 所有可运行进程各跑一个"最小时间片"的总和
→ 如果有 10 个进程,调度周期 = 10 × min_timeslice
→ CFS 保证在这个周期内,每个进程都至少被调度一次("完全公平")// CFS 调度实体(红黑树节点)
struct sched_entity {
unsigned long vruntime; // 虚拟运行时间(排序 key)
unsigned long sum_exec_runtime; // 累计实际运行时间
unsigned long prev_runtime; // 上次调度时的 vruntime(恢复时用)
struct rb_node run_node; // 红黑树节点
struct list_head group_node; // 组调度用
unsigned int on_rq; // 是否在就绪队列上
};
// CFS 选下一个进程
struct task_struct *pick_next_task_fair(struct rq *rq) {
struct sched_entity *se = __pick_next_entity(cfs_rq);
return container_of(se, struct task_struct, se);
}
// 红黑树插入(CFS enqueue)
void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se) {
// 更新 vruntime
se->vruntime += calc_delta_fair(se->load.weight);
// 按 vruntime 插入红黑树
__enqueue_entity(cfs_rq, se);
// 设置 on_rq 标志
se->on_rq = 1;
}4.1 调度时机:什么时候会触发调度
调度不是随意发生的,它只在特定时间点被触发(调度点):
触发调度的时机:
1. 进程主动放弃 CPU:
- 等待 I/O(进入阻塞状态)→ schedule()
- 等待信号 → schedule()
2. 时间片用完:
- 时钟中断(scheduler_tick)发现当前进程时间片耗尽
- 设置 TIF_NEED_RESCHED 标志
- 中断返回时检查此标志,触发 schedule()
3. 新进程加入就绪队列:
- 高优先级进程就绪
- wake_up_new_task → 如果新进程比当前进程更该跑,立即调度
4. 调度器被显式调用:
- schedule() 被直接调用(wait_event, mutex_lock 等)4.2 schedule() 的核心逻辑
// Linux schedule()(简化版)
void schedule(void) {
struct task_struct *prev = current;
struct task_struct *next;
// 关闭内核抢占(保护调度过程)
raw_spin_lock(&rq->lock);
// 清除当前进程的就绪标志
prev->on_rq = 0;
// 更新运行队列
update_rq_clock(rq);
// 切换调度策略
switch (prev->state) {
case TASK_RUNNING:
// 主动让出(时间片耗尽),重新加入红黑树
enqueue_task(prev);
break;
case TASK_INTERRUPTIBLE:
case TASK_UNINTERRUPTIBLE:
// 阻塞,不加入就绪队列
break;
}
// pick_next_task:选 vruntime 最小的进程
next = pick_next_task(rq, prev);
// 切换上下文
switch_mm(prev->mm, next->mm);
switch_to(prev, next); // 关键:这里做寄存器切换
// 后续:next 继续从上次断点执行
}5. Linux 实践:观察进程和调度
# 查看当前进程的详细信息
cat /proc/self/status
# 查看进程运行时间
cat /proc/$$/stat
# 观察进程的调度信息
chrt -p $$ # 查看当前进程调度策略
chrt -f 10 -p $$ # 设为 FIFO 策略,优先级 10
# 观察进程的父子关系
pstree -p $$# 用 top 观察调度
top -n 1
# NI 列 = Nice 值(影响优先级)
# PR 列 = 调度优先级(RT = 实时)
# S 列 = 状态(R=运行,S=睡眠,Z=僵尸)
# 实时观察进程切换(每 1 秒打印运行中的进程)
vmstat 1# 观察 CFS 红黑树状态(需要 root)
cat /proc/sched_debug
# 查看某个进程的调度延迟
perf sched record -a sleep 5
perf sched latency6. 从零实现:wandos 的进程管理
⚠️ wandos 当前状态:kernel/process/ 目录下有进程管理代码,包含 PCB(进程控制块)结构和基础调度逻辑。
6.1 PCB 结构(kernel/process/process.cpp)
// wandos 进程控制块(PCB)
// 源码:kernel/process/process.cpp
struct process_control_block {
uint32_t pid; // 进程 ID
char name[64]; // 进程名
enum process_state state; // NEW/RUNNING/BLOCKED/EXIT
uint32_t priority; // 优先级(0=最高)
// 寄存器上下文(调度时保存/恢复)
uint64_t rip; // Instruction pointer
uint64_t rsp; // Stack pointer
uint64_t rax, rbx, rcx, rdx; // 通用寄存器
uint64_t rsi, rdi, rbp;
uint64_t r8, r9, r10, r11, r12, r13, r14, r15;
uint64_t rflags;
uint64_t cs, ss;
// 内存管理
void *page_table; // CR3 的值(页表指针)
uint64_t memory_base; // 进程基址
uint64_t memory_size; // 进程内存大小
// 文件描述符表
struct file *fd_table[64]; // 最多 64 个 fd
// 时间统计
uint64_t cpu_time_used; // 已用 CPU 时间
uint64_t last_scheduled; // 上次调度时间
};
static struct process_control_block processes[MAX_PROCESSES];
static struct process_control_block *current_process = NULL;
static uint32_t next_pid = 1;6.2 fork 实现
// wandos fork(简化版)
uint32_t process_fork(void) {
// 找空闲 PCB
int pid = -1;
for (int i = 0; i < MAX_PROCESSES; i++) {
if (processes[i].state == NONE) {
pid = i;
break;
}
}
if (pid < 0) return -1; // 没有空槽
// 复制当前进程 PCB
struct process_control_block *child = &processes[pid];
*child = *current_process; // 浅拷贝
// 分配新 PID
child->pid = next_pid++;
// 复制页表(COW)
child->page_table = copy_page_table(current_process->page_table);
// 分配新内核栈
child->rsp = alloc_stack();
// 关键:fork 在子进程返回 0
child->rax = 0;
// 加入就绪队列
scheduler_add_runnable(child);
return child->pid; // 父进程返回子 PID
}6.3 调度循环
// wandos 调度器
void scheduler(void) {
while (1) {
// 找最高优先级就绪进程
struct process_control_block *next = NULL;
uint32_t best_priority = 0xFFFFFFFF;
for (int i = 0; i < MAX_PROCESSES; i++) {
if (processes[i].state == RUNNING &&
processes[i].priority < best_priority) {
next = &processes[i];
best_priority = processes[i].priority;
}
}
if (next == NULL) {
// 没有就绪进程,执行 idle
cpu_idle();
continue;
}
// 切换到选中的进程
switch_to(current_process, next);
}
}6.4 wandos 和 Linux 的主要差异
- 没有 CFS:wandos 用简单优先级调度(priority 值小的先跑),没有 vruntime 和红黑树
- 没有时间片:wandos 没有 RR(时间片轮转),一个进程会一直跑到阻塞或被高优先级进程抢占
- 没有 COW 实现:fork 时复制完整的页表,没有 Copy-On-Write,性能差
- 没有 CFS 虚拟时间:无法根据 Nice 值动态调整调度权重
7. 动手环节:实现一个简化的时间片轮转调度
今天的目标:在 os-kernel-from-scratch 里实现一个简单的时间片轮转(Round Robin)调度器。
任务 1:实现进程 PCB 和就绪队列
用数组或链表管理就绪进程,支持 enqueue 和 dequeue 操作。
任务 2:实现时间片检查
在时钟中断(IRQ0)中检查当前进程的时间片是否耗尽。如果耗尽,重新加入就绪队列末尾,然后触发调度。
任务 3:实现 context_switch
保存当前进程的寄存器到 PCB,恢复下一个进程的寄存器到 CPU。
验收标准
- 三个进程
fork后,依次各跑 1 个时间片,循环轮转 ps命令能看到三个进程都处于 RUNNING 状态- 用
perf sched能看到调度延迟(应该小于 10ms)
8. 踩坑与注意事项
坑 1:fork 之后父子进程共享fd
fork 后,父子进程共享同一个 files_struct,文件描述符的引用计数加 1。如果子进程先退出,要检查 files 是否需要释放——除非显式关闭,父子都会持有fd,直到进程退出时才会关闭。正确做法:fork 后通常立即 exec 新程序,如果父进程不需要子进程的 fd,要先 close(fd)。
坑 2:Zombie 进程不回收
子进程退出后,父进程如果没有调用 wait(),子进程会变成 Zombie(僵死进程)——它的 task_struct 还保留着,PID 还在。直到父进程调用 wait() 回收,或者父进程退出(init 回收),否则 Zombie 进程不会消失。ps 看到状态为 Z 的进程就是 Zombie。
坑 3:调度器递归调用
在 schedule() 里调用 schedule() 是危险的(可能导致调度嵌套)。Linux 用 might_sleep() 检测这种情况,并报错。正确做法:调度是单向的,触发后一直执行到进程切换,不在切换过程中再次调度。
坑 4:进程优先级和 Nice 值混淆
Nice 值(-20 到 +19)影响调度权重,但不直接影响优先级。Nice 值越低(负数),权重越大,vruntime 增长越慢,进程跑得越频繁。chrt 设置的是实时优先级(1-99),和普通 Nice 值是完全独立的体系。
写在最后
进程是操作系统最核心的抽象——它把”正在运行的程序”封装成一个数据结构,让操作系统能管理、能调度、能隔离。fork 和 exec 是 Unix 进程管理的两个基本操作:fork 创造进程,exec 装入程序。调度器决定哪个进程先跑,切换时保存和恢复上下文。
理解这一套,你才能真正理解为什么服务器能同时服务成百上千个用户(每个用户一个进程,调度器快速轮转),以及为什么一个程序的 bug 不会让整台机器崩溃(进程隔离,地址空间独立)。
下篇预告:上下文切换——CPU 是怎么换场的。
相关阅读
- Linux Kernel Source:
kernel/fork.c(fork 实现) - Linux Kernel Source:
kernel/sched/core.c(CFS 调度器) - Linux Kernel Source:
kernel/sched/fair.c(CFS 具体算法) - wandos:
kernel/process/process.cpp - wandos:
kernel/core/kernel_main.cpp - 本文 Demo: https://github.com/golang12306/os-kernel-from-scratch (demos/scheduler/)
- https://github.com/zhangfuwen/wandos — Linux 内核教程
- 下一篇:《从零写OS内核 | 上下文切换——CPU是怎么换场的》
动手环节
想深入理解本文内容?动手实践是最好的方式:
今天的目标:下载 wandos 代码仓库,添加时间片轮转调度,理解 Linux vs wandos 的差异。
-
下载 wandos:
git clone https://github.com/zhangfuwen/wandos.git cd wandos -
找到对应模块:查看
kernel/process/process.cpp -
实现作业:根据文中”动手环节”章节的要求,完成代码编写
-
提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request
评论