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 往后挪,重复。它能直接操作的只有寄存器和内存。

寄存器和内存

寄存器在 CPU 里面,只有几十个,一条指令就能读写;内存在 CPU 外面,很大,但要用 load/store 指令专门去取。C 里的 x = y + 1 大致会变成:从内存把 y 取进寄存器,在寄存器里加 1,再存回 x 的地址。

详细:寄存器 · 设备地址(MMIO)

2. 通电之后

QEMU 加载 ELFpc = 0x80000000 entry.S设置 sp start.c配置 CSR,mret main.c初始化、打印 M-modeM-modeS-mode

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
cpu 0 的栈 4KB cpu 1 的栈 4KB CPU_stack(低地址) ↓ cpu 0 的 sp↓ cpu 1 的 sp 栈向左(低地址)增长

为什么要 +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 的异常处理返回。它做两件事:

  1. 把权限切换到 mstatus.MPP 字段记录的模式(“异常发生前我在哪个模式”)
  2. 把 pc 设成 mepc(“异常发生前我在哪条指令”)
异常是什么

程序出错(比如访问了不该访问的地址)或外设发来信号时,CPU 会停下当前代码,自动切到更高权限,跳去一段固定的处理代码。处理完再用 mret(或 S-mode 的 sret)回到原处。Lab3 会细讲,这里只需要知道 mret 是“回去”。

详细:异常与中断 · 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。

为什么要先把 hartid 存进 tp

mhartid 是 M-mode 的 CSR,到了 S-mode 再读会触发异常。所以 start.c 趁还在 M-mode,把它复制到通用寄存器 tp。之后 mycpuid() 读的就是 tp。

常见的坑:mret 之后卡住或乱跳

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 里的指针

ReadReg(r) 展开后大致是 *(volatile unsigned char *)(0x10000000 + r):把一个整数地址转换成“指向字节的指针”,再用 * 读那个地址上的一个字节。

详细:地址转换成指针 · volatile

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 和 %x 与标准 C 相反

框架规定:%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 自己等自己,死锁。