Lab0:环境与工具
Lab0 不写内核代码,它要你准备好工具,并弄懂“一段 C 代码是怎么变成机器能跑的东西的”。后面每个 lab 都建立在这套流程上。
1. 操作系统到底是什么
你平时写的 C 程序调用 printf,屏幕上就出现了字。中间发生了什么?
printf是 C 标准库里的函数,它把字符串整理好,然后请求操作系统“帮我把这些字节写到终端”。这个请求叫系统调用。- 操作系统收到请求,找到终端对应的设备,用设备驱动(专门负责操作某种硬件的那部分代码)把字节送出去。
- 操作系统同时还在管:哪个程序现在用 CPU、每个程序能用哪块内存、文件放在磁盘哪里。
所以操作系统是管理硬件、并把硬件安全地分给多个程序用的那个程序。它自己也是一个程序,只是运行时拥有最高权限,而且下面没有别的操作系统可以依靠。
写内核时没有标准库。没有 printf、没有 malloc、没有 pthread。Lab1 要自己写 printf,Lab2 要自己写内存分配器。你用到的每个功能,都得先自己造出来。
2. 我们的内核跑在哪
真机调试内核很麻烦(出错了只能重启),所以课程用 QEMU 模拟一台 RISC-V 电脑。内核在它里面跑,出错了关掉 QEMU 就行,还可以用 GDB 一步一步地看。
CPU 能直接执行的只有机器码:一条条编码成字节的简单指令,比如“把两个数相加”“从某个地址读一个数”。一种 CPU 认哪些指令、怎么编码,叫它的指令集。RISC-V 是一种指令集,和你电脑的 x86 不一样,x86 的机器码它看不懂。所以我们需要交叉编译器:在 x86 电脑上运行,但生成 RISC-V 的机器码。
图里的串口是一种很老很简单的设备,一次收发一个字节。QEMU 把它接到你的终端上:内核往串口写一个字节,终端里就出现一个字符。它是我们内核唯一的输出方式。
需要三样:RISC-V 交叉编译器(含 binutils,也就是 objdump、readelf 这些查看工具)、qemu-system-riscv64、支持 RISC-V 的 GDB。下一节的动手就要用到编译器。
# Arch Linux sudo pacman -S riscv64-elf-gcc riscv64-elf-binutils qemu-system-riscv riscv64-elf-gdb # Debian / Ubuntu(编译器前缀是 riscv64-linux-gnu-) sudo apt install gcc-riscv64-linux-gnu qemu-system-misc gdb-multiarch # macOS(Homebrew) brew tap riscv-software-src/riscv && brew install riscv-tools qemu
装完用 riscv64-elf-gcc --version(换成你的前缀)确认能运行。
不同发行版的编译器前缀不一样:Arch 是 riscv64-elf-,Debian/Ubuntu 是 riscv64-linux-gnu-,Homebrew 是 riscv64-unknown-elf-。下文命令都写 riscv64-elf-,请换成你的。课程框架的 common.mk 默认 TOOLPREFIX = riscv64-linux-gnu-,前缀不同就在命令行覆盖,例如 make TOOLPREFIX=riscv64-elf- run。本讲义示例的 Makefile 用的变量叫 CROSS,用法相同。
讲义里的练习只需要普通的 gcc,工具链没装好也能先做练习。
3. 从 .c 到可执行文件
写 gcc hello.c 时,其实经过了三步:
| 步骤 | 做什么 | 产物 |
|---|---|---|
| 编译 | 把一个 .c 翻译成机器码。它只看这一个文件,用到别的文件的函数时先留个“空” | .o 目标文件 |
| 链接 | 把所有 .o 拼在一起,填上那些“空”,决定每段代码放在哪个地址 | 可执行文件(ELF 格式) |
| 加载 | 把 ELF 里的内容搬进内存,跳到入口开始执行 | 运行中的程序 |
内存可以看成一长排格子,每格存 1 个字节,每格有一个编号,这个编号就叫地址。“放到地址 X”就是“放进编号为 X 的那格以及后面的格子”。
0x 开头的是十六进制数:逢 16 进 1,用 0–9 和 a–f 表示 0 到 15。0x10 是 16,0xff 是 255。地址习惯用十六进制写,因为每一位十六进制正好对应 4 个二进制位。0x80000000 就是 2147483648,也就是 2 GB 处。
详细:内存与地址
平时第三步由操作系统完成。我们的内核没人加载,由 QEMU 直接把 ELF 放到内存地址 0x80000000,然后让 CPU 从那里开始执行。
ELF 里的“段”
编译器会把不同性质的东西分开放:
.text:函数的机器码.rodata:只读常量,比如字符串字面量.data:有初值的全局变量.bss:没有初值的全局变量(启动时要清零,ELF 里只记大小不占空间)
课程框架里的 kernel.ld 是链接脚本,它告诉链接器:从 0x80000000 开始,依次摆放 .text、.rodata、.data、.bss。Lab2 管理内存时,会用到这里定义的地址标记。
动手 1:看一个 .o 里有什么
cd examples riscv64-elf-gcc -O1 -c add.c -o add.o # 前缀按你的工具链替换,见第 2 节;-c 表示只编译不链接 riscv64-elf-objdump -d add.o # 反汇编:看 add 函数变成了哪几条 RISC-V 指令 riscv64-elf-readelf -S add.o # 看有哪些段
观察:counter 在哪个段?zeros 呢?你会看到 .sdata、.srodata,s 是 small,小变量被单独放在一起,意义和 .data/.rodata 相同。
反汇编里 lw a5,0(a5) 前面的 lui a5,0x0 地址是 0,因为编译时还不知道 counter 会放在哪,要等链接时再填上。这就是“留空”。
反汇编是把机器码翻回人能读的汇编,一行一条指令。a5、a0 这些是寄存器:CPU 内部的几十个小存储格,CPU 只能在寄存器里做运算,内存里的数要先读进寄存器。指令格式大多是“操作 目标, 源”:
addw a0,a0,a1 # a0 = a0 + a1。参数 a 在 a0,b 在 a1 lui a5,0x0 # a5 = counter 的地址(还没链接,先是 0) lw a5,0(a5) # load word:从地址 a5+0 读 4 字节(counter 的值)到 a5 addw a0,a0,a5 # a0 = a0 + a5 ret # 函数返回,返回值在 a0
这正好是 return a + b + counter;。现在能认出“读内存、做运算、返回”就够了。详细:RISC-V 汇编最小集
4. Makefile
项目有几十个 .c,每次手敲编译命令不现实。make 读 Makefile,按规则决定哪些文件需要重新编译。
目标: 依赖
命令 # 必须用 Tab 缩进
如果目标文件不存在,或者比某个依赖旧,就执行命令。$@ 代表目标,$< 代表第一个依赖,%.o: %.c 是“任意 .o 由同名 .c 生成”的模式规则。
有个漏洞:main.c 里 #include "greet.h",改了 greet.h,main.o 也该重新编译,但规则里只写了依赖 main.c。常见的解决办法是编译时加 -MD:编译器顺便生成一个 .d 文件,里面是一条“main.o 依赖 main.c 和它 include 的所有头文件”的规则,再在 Makefile 里用 -include 把这些 .d 文件包含进来。课程框架的 common.mk 加了 -MD,但 Makefile 没有包含 .d 文件,所以只改头文件时要先 make clean,否则可能跑的是旧代码。
动手 2:多文件项目
cd examples/make-demo make run # 编译 main.c、greet.c,链接,运行 make run # 再来一次:什么都没重新编译,为什么? touch greet.c && make run # 只有 greet.o 被重新编译 make clean
然后打开课程框架的 Makefile,对照着找:它怎么收集所有 .c 和 .S?.o 放在哪?make run 实际执行了什么 qemu 命令?
5. Git 与课程分支
Git 记录项目的历史。提交(commit)是一个存档点:某一刻所有文件的快照加一句说明,改了文件要 git add 再 git commit 才会存下。分支是一条独立的提交线,在一条分支上提交不影响别的分支。远端是放在 Gitee 等服务器上的同一个仓库,git push 发上去,git pull 拿下来。
详细:Git 基础
课程仓库 ecnu-oslab-2026-task 每个 lab 一个分支:lab1、lab2……每个分支是“做这个 lab 需要的框架代码 + 指导书 README”,空着的函数就是你要填的。
你自己(或小组)的仓库也建议按 lab 开分支,做完一个 lab 再合并下一个 lab 的新框架。检查时老师会看提交记录,所以:
- 小步提交,每次提交说清楚做了什么,比如
lab1: implement uart printf。 - 不要把编译产物(
target/、.o)提交进去。 - 多人协作时先
git pull再改,避免分叉。
git switch lab1 # 切到 lab1 分支 git log --oneline --graph --all # 看所有分支的关系 git status # 哪些文件改了 git diff # 具体改了什么 git add 文件 && git commit -m "说明" # 存一个提交 git push # 发到远端
6. QEMU 与 GDB
make run # 编译内核并在 QEMU 里跑,输出直接出现在终端。退出:Ctrl-A 然后按 X make debug # QEMU 启动后暂停,等 GDB 连上
GDB 是调试器:能让程序停在某一行,看此刻变量和寄存器的值,再一步一步往下走。另开一个终端运行 riscv64-elf-gdb(Debian/Ubuntu 是 gdb-multiarch),执行 target remote :1234 连上 QEMU,就能 b main 在 main 处打断点、c 继续运行到断点、si 单步执行一条指令、info reg 看所有寄存器。Lab1 调试启动流程时会大量用到。
7. 自检
能回答这些,Lab0 就过了:
为什么内核不能调用标准库的 printf?
标准库的 printf 最终要靠系统调用让操作系统输出。内核就是操作系统本身,下面没有别人可以求助,只能直接操作硬件(串口)。
.o 文件为什么不能直接运行?
它引用的外部函数和变量的地址还是空的,各段也还没决定放在哪个地址。要链接后才完整。
.bss 为什么不占 ELF 文件的空间?
它的内容全是 0,只需要记录大小。但这也意味着启动时必须有人把这块内存清零,否则全局变量的初值是随机的。
make 怎么知道哪些文件不用重新编译?
比较修改时间:目标比所有依赖都新,就跳过。它只知道规则里写出的依赖,头文件要靠 -MD 生成的 .d 文件并 include 进 Makefile 才会被追踪。课程框架没 include,所以改了头文件记得 make clean。