1 分钟阅读 次阅读

概要:讲解 xv6 通过 userinitallocproc 手动构造第一个进程的完整流程:分配进程槽位与内核栈、映射用户内存、伪造 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 准备完成的内核栈结构

alt text

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") 系统调用

留下评论