从零写OS内核 | 进程与调度——从fork到schedule,一个进程是怎么诞生的

你敲下 ./a.out 回车,一个进程就运行起来了。这个进程是怎么被操作系统”创造”出来的?fork 做了什么?exec 又做了什么?调度器是怎么决定让哪个进程先跑的?

这是操作系统最核心的概念之一:进程管理。从你在终端敲下命令,到程序真正在 CPU 上运行,这中间操作系统做了大量的工作。今天,我们来完整走一遍这个过程。


1. 进程是什么:task_struct

在 Linux 里,进程是一个 task_struct 对象——一个描述进程所有状态的结构体。它包含:

Bash
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(栈和寄存器),但共享 mmfiles信号处理 等。


2. fork:创建一个新进程

fork 是 Unix 最优雅的设计之一——它用一次系统调用创建了一个进程的两个视图

Bash
fork 的本质:

父进程调用 fork()
    ↓
内核做三件事:
    1. 分配一个新的 task_struct
    2. 复制父进程的地址空间(COW,Copy-On-Write)
    3. 把新进程加入调度器
    ↓
返回:在父进程里返回子进程 PID,在子进程里返回 0

结果:两个几乎完全相同的进程在跑,只是返回值不同

2.1 fork 的内核实现(do_fork)

C
// 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:关键的进程复制

C
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 实际上根本不复制物理页

Bash
COW (Copy-On-Write) 原理:

fork 时:
  → 父子进程共享同一个物理页,标记为只读
  → 任何一个进程尝试写入 → 触发 #PF
  → PF handler 发现是 COW 页 → 分配新物理页,复制内容,更新 PTE
  → 两个进程各有自己的物理页,写入互不干扰

结果:fork 本身只复制页表(4KB),不复制任何物理页
      只有在实际写入时才复制,速度极快

3. exec:加载新程序

fork 只是复制了当前进程,exec 才是真正加载新程序的东西:

Bash
exec 的本质:
  → 用新程序替换当前进程的地址空间
  → PID 不变(还是同一个进程)
  → 代码段、数据段、堆栈全部重新初始化
  → 从新程序的 main() 开始执行

C
// 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),核心思想:每个进程按其”虚拟运行时间”在红黑树上排序,最少虚拟时间的进程先跑

Bash
CFS 原理:

每个进程有一个"vruntime"(虚拟运行时间)
  → 进程跑 1ms,vruntime += 1 * ( NICE_0_LOAD / 进程权重 )
  → 高优先级进程:权重更大 → 同样的墙上时间 → vruntime 增长更慢
  → 所以高优先级进程的 vruntime 总是更小 → 更容易被调度到

调度器总是选红黑树最左边的节点(最小 vruntime)执行

调度周期 = 所有可运行进程各跑一个"最小时间片"的总和
  → 如果有 10 个进程,调度周期 = 10 × min_timeslice
  → CFS 保证在这个周期内,每个进程都至少被调度一次("完全公平")

C
// 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 调度时机:什么时候会触发调度

调度不是随意发生的,它只在特定时间点被触发(调度点):

Bash
触发调度的时机:

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() 的核心逻辑

C
// 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 实践:观察进程和调度

Bash
# 查看当前进程的详细信息
cat /proc/self/status

# 查看进程运行时间
cat /proc/$$/stat

# 观察进程的调度信息
chrt -p $$     # 查看当前进程调度策略
chrt -f 10 -p $$  # 设为 FIFO 策略,优先级 10

# 观察进程的父子关系
pstree -p $$

Bash
# 用 top 观察调度
top -n 1
# NI 列 = Nice 值(影响优先级)
# PR 列 = 调度优先级(RT = 实时)
# S 列 = 状态(R=运行,S=睡眠,Z=僵尸)

# 实时观察进程切换(每 1 秒打印运行中的进程)
vmstat 1

Bash
# 观察 CFS 红黑树状态(需要 root)
cat /proc/sched_debug

# 查看某个进程的调度延迟
perf sched record -a sleep 5
perf sched latency

6. 从零实现:wandos 的进程管理

⚠️ wandos 当前状态kernel/process/ 目录下有进程管理代码,包含 PCB(进程控制块)结构和基础调度逻辑。

6.1 PCB 结构(kernel/process/process.cpp)

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 实现

Cpp
// 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 调度循环

Cpp
// 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 的主要差异

  1. 没有 CFS:wandos 用简单优先级调度(priority 值小的先跑),没有 vruntime 和红黑树
  2. 没有时间片:wandos 没有 RR(时间片轮转),一个进程会一直跑到阻塞或被高优先级进程抢占
  3. 没有 COW 实现:fork 时复制完整的页表,没有 Copy-On-Write,性能差
  4. 没有 CFS 虚拟时间:无法根据 Nice 值动态调整调度权重

7. 动手环节:实现一个简化的时间片轮转调度

今天的目标:在 os-kernel-from-scratch 里实现一个简单的时间片轮转(Round Robin)调度器。

任务 1:实现进程 PCB 和就绪队列

用数组或链表管理就绪进程,支持 enqueuedequeue 操作。

任务 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 值是完全独立的体系。


写在最后

进程是操作系统最核心的抽象——它把”正在运行的程序”封装成一个数据结构,让操作系统能管理、能调度、能隔离。forkexec 是 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 的差异。

  1. 下载 wandos

    Bash
    git clone https://github.com/zhangfuwen/wandos.git
    cd wandos
  2. 找到对应模块:查看 kernel/process/process.cpp

  3. 实现作业:根据文中”动手环节”章节的要求,完成代码编写

  4. 提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request


仓库:https://github.com/golang12306/os-kernel-from-scratch

最后修改: 2024年3月27日

作者

评论

发表评论

您的邮箱地址不会被公开。