第一个进程的创建 —— 手动构造
概要:讲解 xv6 通过 userinit、allocproc 手动构造第一个进程的完整流程:分配进程槽位与内核栈、映射用户内存、伪造 trapframe,最终由调度器调度运行 initcode。
1 为什么要手动构造
架构一致性(Uniformity):操作系统调度器(Scheduler)的代码逻辑是通用的。它期望每一个进程在被切换(Context Switch)时,其内核栈上都预留了相同的寄存器结构。通过手动构造第一个进程的栈,可以避免在调度器中为“第一个进程”编写特殊的 if-else 分支。
2 xv6 创建第一个进程的具体步骤
main 通过调用 userinit建立第一个进程,userinit建立第一个进程的流程如下:
2.1 进程控制块与内核栈分配 (allocproc)
内核首先调用 allocproc 在页表中分配一个槽(即结构体 struct proc),并初始化进程的状态。
- 寻找槽位:allocproc 会在 proc 的表中找到一个标记为 UNUSED 的槽位
- 标记使用:allocproc 将其状态设置为 EMBRYO,使其被标记为被使用的并给这个进程一个独有的 pid,此时进程状态设为
EMBRYO(胚胎态),防止它在未准备好时被调度器选中 - 分配内核栈(Kernel Stack):接下来,allocproc尝试为进程的内核线程分配内核栈
2.2 allocproc 设置新进程的内核栈和一系列内核寄存器
allocproc 为新进程设置好一个特别准备的内核栈和一系列内核寄存器,使得进程第一次运行时会“返回”到用户空间。
这种巧妙的方式,使其既能在创建第一个进程时被使用,又能在 fork 操作时被使用,避免在调度器中为“第一个进程”编写特殊的 if-else 分支。
内核栈被分配成这样:
| 用户寄存器保存区 | <-- 栈顶,存放用户寄存器初始值
| trapret地址 | <-- 在上下文上方放置trapret返回地址
| p->context | <-- 进程的内核上下文
分为3步: forkret
- 设置指令指针p->context->eip = forkret,所以内核线程从forkret开始执行
- forkret主要做内核清理工作,然后执行ret指令,会跳到esp指针指向的栈位置
- 而esp指针被设置为指向context结尾,而上方是trapret地址
- 所以ret会跳转到trapret
trapret
- 从栈顶恢复所有用户寄存器
- 使用iret,中断返回,跳转到用户空间
process 通过iret指令,CPU会:
- 从栈中恢复用户态的eip、cs、eflags、esp、ss
- 跳转到用户进程的入口点执行
跳转示意图:
高地址
+----------------+
| 用户栈空间 |
| ... |
+----------------+
| 用户代码入口 | <-- iret后跳转到这里 (3027)
| 用户态ss |
| 用户态esp |
| eflags |
| 用户态cs |
| 用户态eip |
+----------------+
| 其他用户寄存器 | <-- trapret从这里恢复
+----------------+
| trapret地址 | <-- forkret返回到这里
+----------------+
| p->context | <-- 内核上下文,eip=forkret
+----------------+
低地址
2.2.1 补充1:映射用户空间内存 (inituvm)
第一个进程需要运行的代码是 initcode.S,所以进程需要找到物理内存来保存这段程序。程序不仅需要被拷贝到内存中,还需要页表来指向那段内存。
- 页表创建:调用
setupkvm创建该进程专属的页表(Page Table)。 - 加载代码:调用
inituvm分配一页物理内存,并将链接在内核里的initcode.S编译后的二进制数据拷贝到虚拟地址0处。
2.2.2 补充2:中断现场 (trapframe)的伪造
Trapframe (中断帧):这是一个特殊的结构。当进程从用户态(Ring 3)因为中断或系统调用掉进内核态(Ring 0)时,硬件和内核会自动把用户态所有的寄存器状态“打包”存在这里。它是用户态和内核态的转换接口。
在内核栈的顶部(高地址处),userinit 手动构造了一个 struct trapframe。
- 模拟中断压栈:在
trapframe中填入用户态的段选择子(SEG_UCODE,SEG_UDATA)和特权级标记(DPL_USER)。 - 设置用户入口:将
trapframe->eip设为0(用户态程序的起点),将trapframe->esp设为最大虚拟地址(用户态栈顶)。
2.3 状态转换与调度准备
最后,userinit 完成剩余的初始化工作:
- 设置工作目录:将
cwd(当前工作目录)指向根目录。 - 激活进程:将
p->state设为RUNNABLE(就绪态)。 - 触发运行:一旦
RUNNABLE标记完成,全局调度器在下次扫描进程表时就会发现这个进程。
2.4 准备完成的内核栈结构

3 initcode.S 最终做了什么
initcode 实际上只做了一件事:发起 exec(“/init”) 系统调用。这样,第一个进程就从一段手写的汇编代码正式转变成了执行磁盘上的 /init 二进制程序,从而开启了整个用户空间。
4 流程图
[内核启动: main()]
|
v
[调用 userinit()]
|
+---> 1. 调用 allocproc():
| - 找空位 (UNUSED -> EMBRYO)
| - 分配 PID
| - 分配内核栈 (kstack)
| - 在栈底放置 context,设置 eip = forkret
|
+---> 2. 设置用户内存 (setupkvm & inituvm):
| - 创建页表
| - 将 initcode.S 拷贝到虚拟地址 0
|
+---> 3. 伪造 Trapframe (核心逻辑):
| - 手动填入用户态寄存器初始值 (eip=0, esp=PGSIZE)
| - 设置段选择子 (CS = SEG_UCODE | DPL_USER, etc.)
|
+---> 4. 设置状态:
| - p->state = RUNNABLE
|
[调度器: scheduler()] <--- 发现 RUNNABLE 进程
|
v
[上下文切换: swtch()]
|
+---> 弹栈 context -> 跳转到 forkret
| |
| v
+---> forkret: 释放 ptable.lock -> 执行 ret 指令
|
v
+---> trapret: 按照伪造的 trapframe 恢复用户寄存器 -> 执行 iret
|
v
[用户态: 执行 initcode.S] -> 发起 exec("/init") 系统调用
留下评论