Hang Zhengyang

进程:用户态、内核、fork/exec 与程序加载

实践:util lab 的 sleep(syscall 调用链)、find -exec 和 sh 选做(fork/exec/wait)、forkdemo 实验。

名词速查

本篇会用到很多"突然冒出来"的词,先在这里各用一两句话交代。带 → 的在后文有详细展开。

CPU 与执行

名词 一句话
寄存器 CPU 内部的几十个小格子,每个存一个 64 位数。CPU 所有计算都在寄存器上做,内存里的数据要先读进寄存器
PC(程序计数器) 一个特殊寄存器,存"下一条要执行的指令的地址"。CPU 永远在做:取 PC 指向的指令 → 执行 → PC 前进
SP(栈指针) 指向当前栈顶的寄存器。函数的局部变量、返回地址都放在栈上
a0–a7 RISC-V 里用来传函数参数的 8 个寄存器:第 1 个参数放 a0,第 2 个放 a1……返回值也放 a0
调用约定 调用方和被调用方事先约好的规则:参数放哪些寄存器、返回值放哪、哪些寄存器要帮对方保存。编译器按它生成代码,所以不同 .o 里的函数能互相调用
用户态 / 内核态 CPU 的两种特权级。用户态下有些指令不能执行、内核的内存不能访问;内核态什么都能做。普通程序跑在用户态,内核跑在内核态
ecall / 陷入(trap) 用户态程序执行 ecall 指令,CPU 就切到内核态,跳到内核预先设好的入口。这是系统调用唯一的入口。异常(访问非法地址等)和中断(时钟、键盘)也会让 CPU 这样跳进内核,统称 trap
tick 时钟中断的间隔,xv6 里约 0.1 秒。内核每个 tick 把计数器 ticks 加一

内存

名词 一句话
地址空间 一个进程"看得到的"全部内存地址范围。每个进程都有自己的一份,互相看不见
虚拟地址 / 页表 程序里用的地址是虚拟地址;CPU 每次访问内存时,都按这个进程的页表把它翻译成真实的物理内存地址。每个进程一张页表,所以两个进程的地址 0 指向不同的物理内存,这就是隔离的来源(详见 页表)
缺页异常 访问的虚拟地址在页表里没有对应(或权限不对),CPU 报异常陷入内核;xv6 的做法是直接杀掉进程
写时复制(COW) fork 时先不真复制内存,父子共享并标成只读,谁先写再复制谁那一页(cow lab 实现)

构建(在宿主机上把 C 变成 xv6 能跑的程序) → 见 两个世界

名词 一句话
宿主机 / xv6 机器 宿主机是你的 Ubuntu(x86_64);xv6 跑在 QEMU 模拟出来的一台 RISC-V 机器里。两者是不同的"电脑"
交叉编译 在 x86_64 上编译出 RISC-V 的机器码,用 riscv64-linux-gnu-gcc
.o(目标文件) 单个 .c 编译出的机器码。里面调用的别处的函数(printf、pause)还只是名字,没有地址
符号 "名字 → 地址"的对应,比如 main → 0x0、start → 0x32。user/sleep.sym 就是符号表
链接(ld) 把多个 .o 拼成一个可执行文件:给每个函数排好地址,把所有"按名字调用"填成真正的地址
链接脚本 告诉链接器各部分放在什么地址,xv6 的是 user/user.ld(代码从地址 0 开始)
libc C 标准库(printf、malloc、strcpy……)。Linux 上是 glibc;xv6 没有,用 ulib.o 等四个 .o 顶替
汇编桩(stub) 几行汇编写的小函数,比如 pause: 只做"编号放 a7,ecall,返回"。让系统调用在 C 里看起来像普通函数

文件与磁盘 → 见 ELF、磁盘镜像

名词 一句话
ELF 可执行文件的格式。文件头写着"这是什么 CPU 的程序、入口在哪",后面写着"文件的哪几段字节要放到内存的哪个地址"
魔数 文件开头几个固定字节,用来识别格式。ELF 是 7f 45 4c 46(\x7fELF)
入口地址 程序开始执行的第一条指令的地址,记在 ELF 头里
磁盘镜像 一个普通文件,内容是一整块硬盘的逐字节拷贝。xv6 的硬盘就是宿主机上的 fs.img
mkfs "make file system":宿主机上的一个小程序,生成 fs.img,并把文件按 xv6 文件系统的格式写进去
inode 文件系统里代表"一个文件"的结构:类型、大小、数据在哪些磁盘块。文件名不在 inode 里,在目录里(见 02)
namei 内核函数:把路径(如 "sleep")一级级查目录,找到对应的 inode

进程

名词 一句话
pid 进程编号。xv6 从 1 开始只增不减(init 是 1,sh 是 2)
僵尸进程(zombie) 已经 exit、内存都释放了,但还留着一个进程表项等父进程 wait 来取退出状态。父进程不 wait,它就一直占着表项
PATH Linux shell 找命令的目录列表(/usr/bin 等)。xv6 没有,exec("ls") 只在当前目录找 ls

用户态与内核的边界

核心模型:内核是服务端,进程是客户端

  • 进程只拥有自己的内存。除此之外的一切(打开的文件、子进程、当前目录、时间……)都存在内核里,进程手里只有一个编号(fd、pid)。
  • 进程想做任何"自己内存以外"的事,只能发一个请求:系统调用。内核检查请求合不合法,替它办,再把结果(通常是一个整数,失败是 -1)还回来。
  • 类比交易所:你手里是订单号,订单簿在交易所;你不能直接改订单簿,只能发"撤单 #123",交易所校验这个号是不是你的。fd 就是订单号,内核的表就是订单簿,系统调用就是发消息。

syscall 不是普通函数

pause(10) 看起来像普通函数调用,实际走的是这条链:

user/sleep.c        pause(10)
user/user.h         int pause(int);               ← 只是声明
user/usys.S:99      li a7, SYS_pause; ecall       ← 由 usys.pl 生成:把编号放进 a7,ecall 陷入内核
  ---- 用户态 / 内核态分界 ----
kernel/trap.c       usertrap() 发现是 ecall → syscall()
kernel/syscall.c    syscalls[a7] 查表 → sys_pause   (syscall.c:123,编号见 syscall.h:14)
kernel/sysproc.c:68 sys_pause(): argint(0, &n) 取参数,在 ticks 上睡眠,直到过了 n 个 tick
  • 参数怎么传过去:用户态把参数放在寄存器 a0、a1…,内核用 argint/argaddr 从保存下来的寄存器里取。返回值放回 a0。
  • util lab 只是用这条链;syscall lab 要你自己加一个新的 syscall,就是把上面每一层都补一遍。
  • 别搞混:内核里还有一个 sleep(),那是让进程阻塞的内部原语(sleep/wakeup),不是 syscall。用户程序叫 sleep,它调的 syscall 叫 pause。

内核保护的是"别人",不保护你自己

  • 用户态没有 libc、没有 PATH(exec("grep") 只在当前目录找),内存安全全靠自己。
  • 程序读了非法地址(比如对 stdin 数据用 memdump 的 s,把字符当指针解引用),会触发缺页异常,内核在 usertrap 里直接杀掉这个进程,但不会影响内核和其他进程。这就是隔离:每个进程有自己的地址空间。
  • 后面在哪实现:pgtbl lab(页表,地址空间)、traps lab(异常处理)。
  • 从门的另一侧看(特权级、陷入时状态怎么切换、内核怎么检查用户输入):状态切换、权限与安全。

一句话总结

用户程序能做的一切,都要经过 syscall 这扇门;门后面的四大件:进程、fd、文件系统、地址空间,就是后面每个 lab 要亲手造的东西。util lab 先在门外把它们都用一遍。

fork 与 exec

实验程序是 code/forkdemo.c(不在 lab 分支里)。要跑的话:拷到 user/forkdemo.c,在 Makefile 的 UPROGS 里加 $U/_forkdemo,make qemu 后运行 forkdemo 1 到 forkdemo 4。建议先预测输出,再运行。

先想清楚要解决的问题:sh 是一个正在运行的程序。你敲 echo hello,sh 要让 echo 跑起来,并且跑完之后 sh 自己还在,能接着等你输入下一条命令。Unix 把这件事拆成两个最小的动作:

调用 一句话 比喻
fork() 把自己复制一份:同样的代码、同样的变量值、同样执行到这一行、同样的打开文件 复印一个自己
exec(prog) 把自己换成另一个程序:还是这个进程(pid 不变、fd 不变),但内存里的代码和数据全换成 prog 的 换掉剧本,演员还是同一个人
wait() 等一个子进程结束 等复印件干完活

实验 1:fork 后,一个进程变成两个

$ forkdemo 1
[pid 3] before fork, x = 1
[pid 4] I am the child, fork returned 0, x = 100
[pid 3] I am the parent, fork returned 4, x = 1
  • "before fork" 只打印了一次,fork 之后的代码却跑了两次,两次的 pid 不同。fork 之后,两个进程都从 fork 返回的那一刻继续往下跑。
  • 怎么分辨自己是谁?只能看 fork 的返回值:子进程拿到 0,父进程拿到子进程的 pid(4)。所以 fork 的代码永远是 if (pid == 0) { 子进程干的事 } else { 父进程干的事 }。
  • 子进程把 x 改成 100,父进程的 x 还是 1:两边的内存是各自独立的拷贝,互不影响。

实验 2:exec 后,同一个进程换了一个程序

$ forkdemo 2
[pid 5] running forkdemo's demo2, about to exec
[pid 5] a brand-new main() started; old variables are gone
  • pid 前后都是 5:没有产生新进程。
  • exec 后面那句 printf("this line is never printed") 确实没打印:exec 成功后,原来的代码已经不在内存里了,没有地方可以"返回"。
  • 新程序从自己的 main 重新开始,之前的变量全都没了。

光有其中一个为什么不够:

  • 只有 fork:只能复制出更多个 sh,永远跑不了 echo。
  • 只有 exec:sh 把自己换成了 echo,echo 跑完就退出了,sh 也没了。你只能执行一条命令。
  • fork + exec:先复印一个自己,让复印件变成 echo,原件留着。这就是 sh 执行每一条命令的方式。

实验 3:forkdemo 照着 sh 的做法去运行 echo(fork + exec + wait)

$ forkdemo 3
[pid 3] forkdemo: I will run echo the way sh does
[pid 4] child: turning myself into echo      ← 子进程此时还在跑 forkdemo 的代码
hello from echo                              ← exec 之后,pid 4 变成了 echo
[pid 3] forkdemo: child 4 finished, I am still here   ← 原件还在

注意 pid 3 不是 shell,它就是 forkdemo(真正的 sh 是 pid 2)。这个实验让 forkdemo 模仿 sh 的做法,整个进程树有三层:

pid 2  sh        ── 你敲 "forkdemo 3":fork 出 3,3 再 exec 成 forkdemo,然后 sh wait(3)
  └─ pid 3  forkdemo ── fork 出 4,然后 wait(4)
       └─ pid 4  先跑 forkdemo 的代码,exec 后变成 echo

pid 2 → 3 这一层,和 pid 3 → 4 这一层,做的是同一件事:fork、子进程 exec、父进程 wait。这正是 user/sh.c 执行每一条命令的方式。(pid 编号取决于开机后跑了多少条命令:xv6 的 pid 只增不减,每 fork 一次就加一。)

为什么不干脆设计成一个 spawn("echo") 调用? 关键在 fork 和 exec 之间那段时间:子进程已经是独立的进程了,改它的东西不会影响 sh;但它跑的还是我们自己写的代码,所以想对它做什么准备都可以。

实验 4:利用这个间隙做重定向

$ forkdemo 4
[pid 8] parent: my fd 1 is still the screen
$ cat demo.out
this went to a file

子进程在 exec 之前执行了 close(1); open("demo.out", ...),把自己的 1 号 fd 接到了文件上。然后 exec echo,exec 换掉程序但保留 fd 表。echo 照常往 fd 1 写,内容就进了文件,echo 自己完全不知道。父进程的 fd 1 没被动过,所以还能往屏幕打印。

如果只有一个 spawn,所有这些准备(重定向、管道、换目录……)都得变成 spawn 的参数,接口会越来越复杂。分成 fork + exec,任意准备工作都可以写成普通代码,放在两者中间。

一句话:fork 负责"多出一个进程",exec 负责"让它跑别的程序",两者中间的空隙负责"给它布置环境",wait 负责"等它干完"。

设计要点

  • fork:复制出一个几乎一样的子进程(内存、fd 表都复制)。exec:把当前进程的内存换成另一个程序,但 fd 表保留。
  • 正因为分开、并且 exec 保留 fd,shell 才能在两者之间动手脚:fork 之后在子进程里改 fd 0/1(重定向、接管道),再 exec,新程序自动就用上了。
  • wait 让父进程同步等子进程,并回收它的资源(不 wait 就会留下僵尸进程)。
  • 在 util lab 里的体现:find -exec 就是一个迷你 shell,跟 user/sh.c 的 runcmd 是同一个套路。
  • 后面在哪实现:kernel/proc.c 的 fork/exit/wait、kernel/exec.c;cow lab 会把 fork 的"复制内存"改成写时复制。

程序是怎么跑起来的:从 main.c 到进程

系统并不认识 main。 内核只认识两样东西:文件系统里的一个 ELF 可执行文件,以及文件头里记的一个入口地址。main 只是 C 语言的约定,是用户态库把它接上的。在讲五个步骤之前,先把三样背景交代清楚。

两个世界:宿主机和 xv6 机器

整个过程横跨两台"电脑",很多困惑都来自没分清楚它们:

宿主机(你的 Ubuntu,x86_64 CPU)                    QEMU 模拟出的 RISC-V 机器(xv6 在这里跑)
────────────────────────────────                     ──────────────────────────────────────
riscv64-linux-gnu-gcc 编 kernel/*.c → kernel/kernel  ── QEMU -kernel:直接放进它的内存 ──▶ 内核开始运行
riscv64-linux-gnu-gcc 编 user/*.c   → user/_sleep …
gcc(宿主机自己的)编 mkfs/mkfs.c   → mkfs/mkfs
mkfs/mkfs 把 README、_sleep… 写进   → fs.img         ── QEMU -drive:当成一块硬盘插上 ───▶ /README、/sleep …
  • user/_sleep 是 RISC-V 机器码,宿主机的 x86 CPU 根本跑不了它,只有 QEMU 模拟的 RISC-V CPU 能跑。所以要用交叉编译器。
  • mkfs/mkfs 是宿主机程序(Makefile:176 用普通的 gcc 编译),它在你的 Ubuntu 上运行,作用是生成 fs.img。
  • make qemu 就是:在宿主机上把上面这些都准备好,然后启动 QEMU,把 kernel/kernel 和 fs.img 交给它(Makefile:328-331 的 QEMUOPTS)。

ELF:可执行文件的"装箱单"

ELF(Executable and Linkable Format)是 Linux 和 xv6 用的可执行文件格式。你可以把它理解成一张装箱单:告诉内核"这个文件里的哪些字节,要放到内存的哪个地址,给什么权限,最后从哪里开始执行"。

用 readelf 看 user/_sleep(实测):

$ riscv64-linux-gnu-readelf -h user/_sleep        # 文件头
  Magic:   7f 45 4c 46 02 01 01 ...               ← 魔数 "\x7fELF";02 = 64 位,01 = 小端
  Type:    EXEC (Executable file)
  Machine: RISC-V                                 ← 只能在 RISC-V 上跑
  Entry point address: 0x32                       ← 入口:start 函数的地址

$ riscv64-linux-gnu-readelf -l user/_sleep        # 程序头:要装进内存的段
  Type  Offset   VirtAddr  FileSiz  MemSiz   Flg
  LOAD  0x001000 0x0000    0x000d7c 0x000d7c R E  ← 代码和常量:文件偏移 0x1000 起 0xd7c 字节 → 放到地址 0,可读可执行
  LOAD  0x002000 0x1000    0x000000 0x000020 RW   ← 全局变量:放到地址 0x1000,可读写;文件里占 0 字节、内存里要 0x20 字节(全是 0 的变量不用存在文件里)

内核的 exec 就是照着这张单子干活(kernel/exec.c):

  1. 读文件开头,检查魔数是不是 ELF_MAGIC(exec.c:53),不是就拒绝。这就是为什么 exec("README") 会失败。
  2. 遍历程序头,只处理类型为 LOAD 的段(exec.c:63):在新页表里分配这段地址,再用 loadseg 把文件里对应的字节拷进去(exec.c:76)。MemSiz 比 FileSiz 多出的部分填 0。
  3. 把 PC 设成文件头里的入口地址(exec.c:136)。

顺便分清两种 ELF(file 命令实测):

  • user/sleep.o:ELF ... relocatable(可重定位)。还没链接,printf、pause 这些调用只是名字,地址待填。
  • user/_sleep:ELF ... executable, statically linked(可执行)。链接器已经把 ulib.o 等拼进来,所有地址都定好了。

_sleep 文件有 35 KB,但真正装进内存的只有约 3.5 KB(0xd7c + 0x20),其余是调试信息(给 gdb 用),内核不会去读。内核本身 kernel/kernel 也是一个 ELF,只不过是 QEMU 而不是 exec 去装载它。

磁盘镜像 fs.img:xv6 的硬盘就是宿主机上的一个文件

真实电脑有一块硬盘,本质上是一长串编号的块(block),每块固定大小。磁盘镜像就是把"一整块硬盘的全部内容"逐字节存成一个普通文件。U 盘镜像、光盘 ISO 都是这个东西。

xv6 的硬盘就是项目根目录下的 fs.img:

  • 大小 2,048,000 字节 = 2000 块 × 每块 1024 字节(FSSIZE = 2000,BSIZE = 1024)。
  • QEMU 启动时用 -drive file=fs.img ... -device virtio-blk-device(Makefile:330-331)把它当成一块 virtio 硬盘插到虚拟机上。xv6 内核通过 kernel/virtio_disk.c 读写"硬盘"的某一块,QEMU 实际去读写的就是 fs.img 里对应的 1024 字节。

fs.img 里面不是乱放的,而是按 xv6 文件系统的格式排好的(mkfs/mkfs.c:96):

块号: 0      1       2 …          …            …          … 1999
     [boot][super][  log  ][ inode 块 ][ bitmap ][      数据块       ]
             │                  │           │            │
         总体参数          每个文件一个      哪些块      文件内容本身
       (共几块、各区     inode:类型、大小、 已被占用    (_sleep 的 ELF 字节
         从哪开始)       数据在哪些块                    就躺在这里)

mkfs 做的事:在宿主机上创建这个 2 MB 的文件,写好 superblock,建根目录,然后对命令行上的每个文件(README、user/_sleep……)分配 inode、把内容写进数据块、在根目录里加一项(名字去掉 _)。

几个由此得出的事实:

  • xv6 里"磁盘上的文件 /sleep" = 宿主机 fs.img 里某些块上的字节。两边是同一份数据。
  • 在 xv6 里新建的文件(比如 forkdemo 4 的 demo.out)会被写回 fs.img,下次 make qemu 时还在。直到 fs.img 被重建:它依赖 mkfs、README 和所有 UPROGS(Makefile:293),其中任何一个变了就会重新生成,之前建的文件就没了。
  • 内核本身不在 fs.img 里:QEMU 用 -kernel 直接把它放进内存。fs.img 只装用户程序和数据。

五个步骤

① 编译 + 链接(在宿主机上,Makefile)

user/sleep.c ──gcc──▶ sleep.o ──ld──▶ user/_sleep        (Makefile:159 的 _% 规则)
                                 ▲
                ULIB = ulib.o usys.o printf.o umalloc.o   (Makefile:153)
  • ulib.o 提供 start()、strcpy、atoi 等;usys.o 提供每个 syscall 的汇编桩(pause: → li a7, SYS_pause; ecall);printf.o 提供 printf;umalloc.o 提供 malloc。xv6 没有 libc,这四个 .o 就是它的"libc"。
  • user/user.ld 是链接脚本,规定代码从虚拟地址 0 开始放。
  • 入口地址:链接脚本没写 ENTRY,命令行也没给 -e,GNU ld 的规则是"如果有叫 start 的符号,就用它"。所以入口是 ulib.c 里的 start。验证:readelf -h user/_sleep 显示 Entry point address: 0x32,而 user/sleep.sym 里 start 正好在 0x32(main 在 0)。

② 打包进磁盘镜像(mkfs)

mkfs/mkfs fs.img README ... user/_sleep ...(Makefile:294)把每个文件写进 fs.img 里 xv6 文件系统的根目录,并且去掉开头的 _(mkfs/mkfs.c:147),于是 /sleep 就成了 xv6 硬盘上的一个普通文件。这就是"必须加到 UPROGS"的原因:不在列表里,就不会进 fs.img,xv6 里也就没有这个命令。

③ 你在 sh 里敲 sleep 10

sh 把这一行拆成 argv = {"sleep", "10", 0},fork 出子进程,子进程调用 exec("sleep", argv)。xv6 没有 PATH,exec 就是按路径 sleep 去找文件,相对于当前目录,所以只在 / 下才能找到。

④ 内核的 exec(kernel/exec.c)

  1. namei("sleep") 在当前目录里查到这个文件的 inode,读出 ELF 头,检查魔数,确认是可执行文件。
  2. 新建一张页表(新的地址空间),按 ELF 程序头把各 LOAD 段(代码、全局变量)从磁盘读进内存。见上面的 ELF。
  3. 分配用户栈,把 "sleep"、"10" 两个字符串和指向它们的指针数组拷到栈上(exec.c:102-117)。
  4. 设置"回到用户态时"的寄存器:
    • epc = elf.entry(exec.c:136):PC 指向 start
    • sp = 新栈顶
    • a1 = argv 的地址(exec.c:124),a0 = argc(作为 exec 系统调用的返回值放进 a0,exec.c:140)
  5. 释放旧的页表。旧程序就此消失,所以 exec 成功时不会返回。

⑤ 回到用户态

CPU 从 start 开始执行。RISC-V 调用约定里,前两个参数放在 a0、a1,所以 start 一进来就"收到了"argc 和 argv:

void start(int argc, char **argv) {   // user/ulib.c
  int r = main(argc, argv);           // ← 这里才调用你的 main
  exit(r);                            // main return 了也会正常 exit
}

总结:main 能跑起来,是"链接器把 start 设成入口 → 内核 exec 把 PC 设成入口、把参数放进 a0/a1 → start 调 main"三方配合的结果。真实 Linux 也是同一套结构:入口叫 _start,在 glibc 的 crt1.o 里,它调 __libc_start_main,再调 main。