2. xv6 源码结构:目录布局与核心文件解析

在深入 fork 与 exec 的底层实现之前,必须首先熟悉 xv6 的源码布局。本章将剖析 xv6 的目录结构,并重点介绍与进程管理直接相关的四个关键文件:proc.c、syscall.c、sysproc.c 和 exec.c。理解这些文件的位置与职责,是后续分析系统调用流程的基础。

2.1 源码目录布局总览

xv6 的源码采用扁平化设计,所有核心文件均位于根目录下。这种结构虽然简单,但功能划分清晰。下表列出了与进程管理相关的主要目录和文件:

目录/文件 作用 与进程管理的关系
kernel/ 内核核心代码,包含进程、内存、文件系统、设备驱动等 进程管理的全部实现均在此目录下
kernel/proc.c 进程管理核心实现:创建、调度、销毁进程 实现 fork、scheduler、wait 等关键函数
kernel/exec.c 可执行文件加载器 实现 exec 系统调用,负责加载 ELF 格式程序
kernel/syscall.c 系统调用分发框架 统一入口,将系统调用号映射到具体处理函数
kernel/sysproc.c 进程相关的系统调用实现 包含 sys_fork、sys_exit、sys_wait 等包装函数
kernel/defs.h 全局函数声明 声明所有内核函数,供各文件交叉引用
kernel/param.h 内核参数常量 定义 NPROC(最大进程数)、FSSIZE 等关键常量
学习提示: xv6 的代码量约 8000 行,建议在阅读本章时同时打开源码文件对照。重点关注 proc.c 中的 struct proc 定义和 allocproc 函数,它们是理解进程生命周期的起点。

2.2 关键文件深度解析

2.2.1 proc.c — 进程管理的核心引擎

proc.c 是 xv6 进程管理的“心脏”。它负责进程的整个生命周期:创建、调度、睡眠、唤醒和销毁。该文件约 400 行,是理解进程管理最重要的文件。

核心数据结构与函数:

  • struct proc:进程控制块(PCB),包含进程状态、上下文、内存映射、打开文件等所有信息。
  • allocproc():在进程表中分配一个空闲的 struct proc 槽位,并初始化关键字段。
  • fork():创建子进程,复制父进程的地址空间、文件描述符等资源。
  • scheduler():核心调度循环,轮询所有 RUNNABLE 状态的进程并切换执行。
  • wait():父进程等待子进程退出,回收子进程资源。
关键代码片段: struct proc 的定义是理解所有进程操作的基础。
// kernel/proc.c
struct proc {
  enum procstate state;        // 进程状态:UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE
  struct proc *parent;         // 父进程
  void *chan;                  // 如果非0,表示进程在chan上睡眠
  int killed;                  // 如果非0,表示进程已被标记为终止
  struct file *ofile[NOFILE];  // 打开的文件表
  struct inode *cwd;           // 当前工作目录
  pagetable_t pagetable;       // 用户页表
  uint64 sz;                   // 进程地址空间大小
  uint64 pid;                  // 进程ID
  struct trapframe *trapframe; // 陷阱帧,保存用户态寄存器
  struct context context;      // 内核上下文,用于切换内核栈
  struct spinlock lock;        // 进程自旋锁
};

下图展示了 proc.c 中进程状态转换的核心流程:

proc.c 进程状态转换图 UNUSED USED RUNNABLE RUNNING SLEEPING ZOMBIE allocproc() fork() 就绪 scheduler() sleep() wakeup() exit() wait() 回收 说明:fork() 创建子进程后进入 RUNNABLE;scheduler() 选择 RUNNABLE 进程切换为 RUNNING;exit() 使进程变为 ZOMBIE 等待父进程回收

2.2.2 syscall.c — 系统调用的统一入口

syscall.c 实现了系统调用的分发机制。当用户程序执行 ecall 指令陷入内核后,syscall() 函数被调用,它从 trapframe 中读取系统调用号,然后通过函数指针数组 syscalls[] 跳转到对应的处理函数。

核心机制:

  • syscalls[]:一个函数指针数组,索引为系统调用号,值为对应的处理函数(如 sys_fork、sys_exec)。
  • syscall():从 trapframe->a7 读取系统调用号,调用 syscalls[num](),并将返回值写入 trapframe->a0。
  • argint() / argaddr() / argstr():一组辅助函数,用于从用户栈中提取系统调用的参数。
关键代码片段: 系统调用分发核心逻辑。
// kernel/syscall.c
static uint64 (*syscalls[])(void) = {
  [SYS_fork]    sys_fork,
  [SYS_exit]    sys_exit,
  [SYS_wait]    sys_wait,
  [SYS_exec]    sys_exec,
  // ... 其他系统调用
};

void syscall(void) {
  int num;
  struct proc *p = myproc();
  num = p->trapframe->a7;  // 从陷阱帧读取系统调用号
  if (num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    p->trapframe->a0 = syscalls[num]();  // 调用处理函数,返回值存入a0
  } else {
    printf("%d %s: unknown sys call %d\n", p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

下图展示了系统调用从用户态到内核处理函数的完整调用链:

系统调用分发流程 用户程序 (ecall) trap.c: usertrap() syscall.c: syscall() sysproc.c: sys_fork() proc.c: fork() 陷入内核 读取a7 syscalls[num]() 调用 说明:用户程序通过 ecall 陷入内核 → usertrap 识别为系统调用 → syscall 根据系统调用号分发 → 最终执行具体实现

2.2.3 sysproc.c — 进程系统调用的包装层

sysproc.c 是进程相关系统调用的“包装层”。它负责从用户空间提取参数,进行合法性检查,然后调用 proc.c 中的核心函数。这种分层设计使得参数解析与核心逻辑分离,提高了代码的可维护性。

主要函数及其职责:

函数 对应系统调用 主要工作
sys_fork() fork 调用 fork(),返回子进程 PID 给父进程,返回 0 给子进程
sys_exit() exit 调用 exit(),终止当前进程并转换为 ZOMBIE 状态
sys_wait() wait 调用 wait(),等待子进程退出并回收资源
sys_exec() exec 调用 exec(),加载新程序替换当前进程的地址空间
sys_getpid() getpid 返回当前进程的 PID
设计模式观察: sysproc.c 与 proc.c 的分层类似于“门面模式”。sysproc.c 处理用户态交互细节(参数提取、错误码转换),proc.c 专注于内核核心逻辑。这种分离使得核心调度算法不依赖于具体的参数传递方式。

2.2.4 exec.c — 可执行文件加载器

exec.c 实现了 exec 系统调用的核心逻辑。它负责读取 ELF 格式的可执行文件,解析程序头,为新的程序分配内存并加载代码段和数据段,最后设置用户栈和陷阱帧,使进程开始执行新程序。

核心工作流程:

  1. 打开文件:通过 namei() 和 iget() 获取可执行文件的 inode。
  2. 读取 ELF 头:验证魔数(ELF_MAGIC),确保是合法的 ELF 文件。
  3. 解析程序头:遍历 ELF 程序头表,找到 LOAD 类型的段。
  4. 分配内存:调用 uvmalloc() 为新的程序分配地址空间。
  5. 加载段数据:将文件中的代码和数据复制到新分配的内存中。
  6. 分配用户栈:为新的程序分配一页(4096 字节)的用户栈。
  7. 设置陷阱帧:将入口点(e_entry)写入 trapframe->epc,设置栈指针 trapframe->sp。
  8. 释放旧资源:释放旧进程的地址空间和文件资源。
易错点提醒: exec 与 fork 的关键区别在于:fork 创建新进程,exec 替换当前进程的地址空间。exec 成功后,原进程的代码、数据、堆、栈全部被替换,但 PID、打开的文件描述符(除非设置了 FD_CLOEXEC)等内核资源保持不变。

下图展示了 exec 加载 ELF 文件时地址空间的布局变化:

exec 地址空间替换示意图 旧进程地址空间 代码段 (旧程序) 数据段 (旧程序) 堆 (旧程序) 用户栈 (旧程序) 内核部分 (不变) exec() 新进程地址空间 代码段 (新程序) 数据段 (新程序) 堆 (新程序) 用户栈 (新程序) 内核部分 (不变) ELF 可执行文件 ELF 头 (e_entry) 程序头表 代码段 (.text) 数据段 (.data) 读取 说明:exec 读取 ELF 文件,解析程序头,为新程序分配地址空间并加载段数据,然后释放旧地址空间。内核部分(页表、陷阱帧等)保持不变

2.3 文件间协作关系总结

四个关键文件在 fork 和 exec 系统调用中的协作关系可以概括为以下流程:

  1. 用户程序调用 fork() 或 exec():通过 ecall 指令陷入内核。
  2. syscall.c 分发:syscall() 从 trapframe 读取系统调用号,调用 syscalls[] 数组中对应的函数。
  3. sysproc.c 包装:sys_fork() 或 sys_exec() 提取用户参数,进行合法性检查。
  4. proc.c 或 exec.c 核心实现:fork() 在 proc.c 中创建子进程;exec() 在 exec.c 中加载新程序。
  5. 返回用户态:处理完成后,返回值通过 trapframe->a0 传递回用户程序。
总结: 理解这四个文件的职责与协作关系,是掌握 xv6 进程管理的第一步。proc.c 是“执行者”,exec.c 是“加载者”,syscall.c 是“调度员”,sysproc.c 是“接线员”。在后续章节中,我们将深入 fork 和 exec 的每一行代码,揭示它们如何协同工作,完成进程的创建与程序替换。