Lab1:机器启动
目标:让两个 CPU 从通电开始,一路走到 main(),并各自打印一句 cpu N is booting!。要做到这一点,你得补完四个地方。
| 文件 | 要做什么 | 本讲义对应 |
|---|---|---|
boot/start.c | 设置返回地址,执行 mret 切换到 S-mode | §3、§4 |
lib/print.c | 实现 printf 和 assert | §5 · 练习 2 |
lock/spinlock.c | 实现自旋锁的四个函数 | §6 · 练习 3 |
main.c | 两个 CPU 分别初始化、打印 | §7 |
1. CPU 眼里的世界
CPU 只会做一件事:从 pc(程序计数器)指向的地址取一条指令,执行,pc 往后挪,重复。它能直接操作的只有寄存器和内存。
- 通用寄存器:RISC-V 有 32 个,每个 64 位。C 代码里的局部变量、函数参数,大都在这里。常用的几个:
sp栈指针,ra返回地址,a0–a7函数参数和返回值,tp线程指针(我们拿它存 CPU 编号)。 - CSR(控制与状态寄存器):控制 CPU 本身行为的开关,比如当前权限、中断开没开、出错时跳到哪。只能用专门的
csrr/csrw指令读写。框架的arch/method.h把它们包成了r_mstatus()、w_mepc()这样的函数。 - 内存:一个巨大的字节数组。QEMU 模拟的机器,内存从
0x80000000开始。有些地址不是内存,而是设备,比如0x10000000是串口,往那里写一个字节,终端上就出现一个字符。
寄存器在 CPU 里面,只有几十个,一条指令就能读写;内存在 CPU 外面,很大,但要用 load/store 指令专门去取。C 里的 x = y + 1 大致会变成:从内存把 y 取进寄存器,在寄存器里加 1,再存回 x 的地址。
详细:寄存器 · 设备地址(MMIO)
2. 通电之后
QEMU 带 -bios none 启动,意思是没有固件,CPU 一通电 pc 就是 0x80000000。kernel.ld 把 _entry 放在这里,所以第一条执行的指令就在 entry.S。
两个 CPU 是同时通电的,它们都从 _entry 开始,执行完全相同的代码。区分它们的唯一办法是读 CSR mhartid,cpu 0 读到 0,cpu 1 读到 1。
3. entry.S:先有栈,才有 C
C 函数一被调用,就要往栈上存东西:局部变量、要保存的寄存器、返回地址 ra。栈就是内存里的一块区域,sp 指向它的顶部,向低地址增长。
刚通电时 sp 是随机值,直接调用 C 函数会把数据写到不知道哪里去。所以 entry.S 用汇编做的第一件事就是给每个 CPU 分一块栈:
la sp, CPU_stack # sp = CPU_stack 数组的起始地址 li a0, 4096 csrr a1, mhartid # a1 = 我是几号 CPU addi a1, a1, 1 mul a0, a0, a1 # a0 = 4096 * (hartid + 1) add sp, sp, a0 # sp 指向“我的那 4KB”的顶端 call start
为什么要 +1?因为栈向下增长,sp 必须从一块的顶端(高地址)开始。
每行一条指令,第一个词是操作,后面是操作数,结果写进第一个寄存器。la 取地址,li 放一个常数,csrr 读 CSR,addi/add/mul 是加法和乘法,call 调用函数。
动手 1:看函数调用怎么用栈
cd examples && make stack
对比 leaf 和 caller 的反汇编。leaf 不调用别人,一条栈操作都没有。caller 一开头 addi sp,sp,-48 给自己开空间,然后 sd ra,40(sp) 把返回地址存起来,因为接下来调用 leaf 会覆盖 ra(反汇编里的 auipc ra + jalr ra 就是 call leaf,地址等链接时再填)。结尾 ld ra 恢复、addi sp,sp,48 还空间、ret 跳回 ra。
这就是为什么没设 sp 就不能进 C:连第一条 sd ra 都会写坏内存。
4. start.c:从 M-mode 降到 S-mode
特权级
RISC-V 有三个权限等级。权限越高能碰的 CSR 和内存越多:
| 模式 | 谁在这里跑 | 这门课里 |
|---|---|---|
| M-mode(机器) | 固件,权限最高,什么都能做 | 只在 start.c 里短暂停留 |
| S-mode(监管者) | 操作系统内核 | 内核大部分时间在这 |
| U-mode(用户) | 普通程序 | Lab4 起才有 |
通电时在 M-mode。内核应该运行在 S-mode,因为后面的页表、系统调用等机制是围绕 S-mode 设计的。
怎么“降级”
RISC-V 没有“切换到 S-mode”这条指令。只有 mret:从 M-mode 的异常处理返回。它做两件事:
- 把权限切换到
mstatus.MPP字段记录的模式(“异常发生前我在哪个模式”) - 把
pc设成mepc(“异常发生前我在哪条指令”)
程序出错(比如访问了不该访问的地址)或外设发来信号时,CPU 会停下当前代码,自动切到更高权限,跳去一段固定的处理代码。处理完再用 mret(或 S-mode 的 sret)回到原处。Lab3 会细讲,这里只需要知道 mret 是“回去”。
我们根本没发生过异常,但可以伪造现场:把 MPP 改成 S,把 mepc 改成 main 的地址,然后 mret。CPU 就会“返回”到 S-mode 的 main。
uint64 status = r_mstatus(); status &= ~MSTATUS_MPP_MASK; // 清掉第 11–12 位 status |= MSTATUS_MPP_S; // 写成 01(S-mode) w_mstatus(status); // TODO:mepc 设为 main 的地址 // TODO:执行 mret(asm volatile("mret");)
练习 1:位运算 exercise/bits/
没接触过 &、|、~、<< 的话,先读 位运算,最后一节就是拿 mstatus 举例。
框架里改 MPP 的那两行,就是“只改某几位,其他位不动”。这种写法在内核里无处不在。补完 set_mpp_s 和 get_mpp,make 看到 4 个 PASS。
mhartid 是 M-mode 的 CSR,到了 S-mode 再读会触发异常。所以 start.c 趁还在 M-mode,把它复制到通用寄存器 tp。之后 mycpuid() 读的就是 tp。
xv6 的 start.c 在 mret 前还做了几件事:w_medeleg/w_mideleg(把异常和中断交给 S-mode 处理)、w_pmpaddr0/w_pmpcfg0(允许 S-mode 访问全部物理内存)。新版 QEMU 默认不给 S-mode 访问权限,没设 PMP 的话,mret 后取第一条指令就会出错。框架里如果没这几行,碰到卡住时先想到这里。
5. 串口与 printf
UART:往一个地址写字节
QEMU 模拟了一个 16550 串口芯片,它的寄存器映射在 0x10000000 开始的几个字节上。这种“设备寄存器看起来像内存”的方式叫 MMIO(内存映射 I/O)。
uart.c 已经写好了,读一下 uart_putc_sync:
while ((ReadReg(LSR) & LSR_TX_IDLE) == 0) // 等串口说“我空闲了” ; WriteReg(THR, c); // 把字节写进发送寄存器
串口很慢,发一个字节要时间,所以每次都得先等上一个发完。ReadReg 用了 volatile,告诉编译器“每次都真的去读这个地址”,否则编译器可能把循环优化成只读一次。
ReadReg(r) 展开后大致是 *(volatile unsigned char *)(0x10000000 + r):把一个整数地址转换成“指向字节的指针”,再用 * 读那个地址上的一个字节。
printf:把格式串翻译成一串 putc
你要写的 printf 只做一件事:扫描格式串,普通字符直接输出,遇到 % 就根据下一个字符,从参数里取一个值并按格式输出。框架已经给了 printint(输出整数)和 printptr(输出 64 位十六进制)。
难点是可变参数:printf 不知道调用者传了几个参数、什么类型。C 用 <stdarg.h> 解决:
void printf(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt); // ap 指向 fmt 后面的第一个参数
int n = va_arg(ap, int); // 按 int 取一个,ap 自动后移
char *s = va_arg(ap, char *);
va_end(ap);
}
类型得你自己告诉 va_arg,这就是格式串存在的意义:%d 说“下一个是 int”,%s 说“下一个是 char *”。取错类型,读出来的就是垃圾。
<stdarg.h> 不是标准库吗?
它是编译器自带的头文件,只是几个宏,展开成 GCC 内建操作,不需要链接任何库。所以带 -ffreestanding -nostdlib 的内核也能用。
框架规定:%p 是 32 位无符号十六进制,不带前缀;%x 是 64 位、带 0x、固定 16 位宽。按框架注释来,不要按平时的习惯来。
练习 2:printf exercise/printf/
在本机实现 kprintf。kputc 在测试里会把字符收集起来,和期望结果比对。12 个用例全部 PASS 后,把函数体搬到内核的 print.c,把 kputc 换回 uart_putc_sync,再加上 §6 的锁。
顺手把 assert 也写了:条件为假就 panic(warning),一行。
6. 两个 CPU 抢一个串口:自旋锁
问题长什么样
printf("hello") 其实是 5 次 uart_putc_sync。两个 CPU 同时 printf,这些调用会交错,输出变成 hcpeullo...。同样的问题也出现在任何共享变量上:sum++ 看起来是一步,实际是三条指令:
lw a5, sum # 读 addi a5, a5, 1 # 加 sw a5, sum # 写回
cpu 0 读到 100,cpu 1 也读到 100,两人各加 1 写回 101。加了两次,只涨了 1。这叫竞态条件(race condition)。
动手 2:亲眼看到丢失的加法
cd examples && make race ./race none # 两个线程各加 100 万次,结果远小于 2000000 ./race fine # 每次 ++ 前后加锁:正确,但慢 ./race coarse # 整个循环加一次锁:正确且快,但两个线程变成了串行
两个线程就是两个 CPU 的替身。这正是课程 README §4.1 “并行加法”要你在内核里做的实验。
锁:一个原子的“检查并占住”
最朴素的想法:
while (lk->locked) ; // 等别人放手 lk->locked = 1; // 我占住
行不通。两个 CPU 可能同时看到 locked == 0,同时跳出循环,同时占住。“检查”和“占住”之间有缝,缝里就会出竞态。
解决办法是硬件提供的原子指令。__sync_lock_test_and_set(&x, 1) 在 RISC-V 上编译成 amoswap.w.aq:把 1 写进 x,同时返回 x 原来的值,这一整步不可能被打断。返回 0 说明是我把它从 0 变成了 1,抢到了;返回 1 说明本来就被占着,继续转:
while (__sync_lock_test_and_set(&lk->locked, 1) != 0)
;
一直原地转圈等待,所以叫自旋锁。
内存屏障
编译器和 CPU 为了快,可能重排内存读写的顺序。如果临界区里的 sum++ 被挪到了抢锁之前,锁就白加了。__sync_synchronize() 是一道屏障:它之前的读写必须在它之后的读写开始前完成。acquire 之后放一道,release 之前放一道,临界区就被“夹”住了。
死锁的第二种来源:中断
第一种死锁很好懂:同一个 CPU 对同一把锁 acquire 两次,第二次会永远等自己放手。所以 acquire 要先检查 spinlock_holding,发现是自己持有就 panic。
第二种更隐蔽。cpu 0 拿着 printf 的锁正在输出,这时来了一个中断(Lab3 会有时钟中断),CPU 暂停当前代码去跑中断处理函数。如果中断处理函数里也调用 printf,它会去抢同一把锁。锁在 cpu 0 自己手里,而持有者要等中断处理完才能继续,于是永远等下去。
办法是:持有锁期间关中断。acquire 第一步 push_off(),release 最后一步 pop_off()。框架已经写好了这两个函数,它们用计数器支持嵌套:拿两把锁就关两层,全放掉才恢复原状。
练习 3:自旋锁 exercise/spinlock/
测试用两个线程模拟两个 CPU,mycpuid() 返回线程编号,push_off/pop_off 是打桩函数,会检查你调用的次数是否配对。全部 PASS 后,代码基本可以原样搬进内核的 spinlock.c。
最后在 print.c 里用上它:print_init 已经初始化了 print_lk,你在 printf 开头 acquire、结尾 release。注意 panic 里也调用了 printf,如果 panic 发生时本 CPU 正持有 print_lk,就会触发重复上锁。xv6 的做法是用一个 locking 标志,panic 时关掉 printf 的加锁。
7. main.c:谁先谁后
两个 CPU 同时进入 main。有些初始化只能做一次(初始化串口、初始化锁),必须由一个 CPU 做完,另一个才能开始用。常见写法:
volatile static int started = 0;
int main()
{
int cpuid = mycpuid();
if (cpuid == 0) {
print_init(); // 只做一次
printf("cpu %d is booting!\n", cpuid);
__sync_synchronize();
started = 1; // 通知 cpu 1:可以了
} else {
while (started == 0) ; // 等 cpu 0 初始化完
__sync_synchronize();
printf("cpu %d is booting!\n", cpuid);
}
while (1) ;
}
started 必须是 volatile,否则编译器看到循环里没人改它,会把 while (started == 0) 优化成只读一次的死循环。
做完后运行 make run,应该看到:
cpu 0 is booting! cpu 1 is booting!
README §4.1:把 examples/race.c 的实验搬进内核,两个 CPU 各加 100 万次共享 sum,先不加锁看结果,再用你的自旋锁修正。
§4.2:去掉 printf 里的锁,想办法让两个 CPU 的输出交错(提示:让两个 CPU 同时反复打印长字符串)。
8. 自检
为什么 entry.S 必须用汇编写,不能直接写 C?
C 函数一开头就要用栈,而这时 sp 还没设。设置 sp 这件事本身只能用汇编。
mret 之后 CPU 跑到哪里、在什么模式?
跑到 mepc 里的地址(我们设成 main),模式变成 mstatus.MPP 记录的模式(我们设成 S)。
为什么 cpu 1 不能也调用 print_init?
print_init 会初始化 print_lk。如果 cpu 0 正持有这把锁时 cpu 1 把它重置成未锁,锁就失效了。串口初始化也同理,做两次可能打断正在进行的输出。
持有自旋锁时为什么不能开中断?
中断处理函数可能去抢同一把锁,而持有者要等中断处理完才能继续,同一个 CPU 自己等自己,死锁。