进程:用户态、内核、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 | 可执行文件的格式。文件头写着"这是什么 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):
- 读文件开头,检查魔数是不是
ELF_MAGIC(exec.c:53),不是就拒绝。这就是为什么exec("README")会失败。 - 遍历程序头,只处理类型为
LOAD的段(exec.c:63):在新页表里分配这段地址,再用loadseg把文件里对应的字节拷进去(exec.c:76)。MemSiz比FileSiz多出的部分填 0。 - 把 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)
namei("sleep")在当前目录里查到这个文件的 inode,读出 ELF 头,检查魔数,确认是可执行文件。- 新建一张页表(新的地址空间),按 ELF 程序头把各 LOAD 段(代码、全局变量)从磁盘读进内存。见上面的 ELF。
- 分配用户栈,把
"sleep"、"10"两个字符串和指向它们的指针数组拷到栈上(exec.c:102-117)。 - 设置"回到用户态时"的寄存器:
epc = elf.entry(exec.c:136):PC 指向startsp= 新栈顶a1 = argv的地址(exec.c:124),a0 = argc(作为 exec 系统调用的返回值放进 a0,exec.c:140)
- 释放旧的页表。旧程序就此消失,所以
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。