Lab0:环境与工具

Lab0 不写内核代码,它要你准备好工具,并弄懂“一段 C 代码是怎么变成机器能跑的东西的”。后面每个 lab 都建立在这套流程上。

1. 操作系统到底是什么

你平时写的 C 程序调用 printf,屏幕上就出现了字。中间发生了什么?

所以操作系统是管理硬件、并把硬件安全地分给多个程序用的那个程序。它自己也是一个程序,只是运行时拥有最高权限,而且下面没有别的操作系统可以依靠。

这门课最不一样的地方

写内核时没有标准库。没有 printf、没有 malloc、没有 pthread。Lab1 要自己写 printf,Lab2 要自己写内存分配器。你用到的每个功能,都得先自己造出来。

2. 我们的内核跑在哪

你的电脑(x86-64 CPU) Linux(你正在用的操作系统) QEMU:一个模拟出来的 RISC-V 电脑(2 个 CPU、128 MB 内存、串口) 我们的内核 kernel-qemu.elf

真机调试内核很麻烦(出错了只能重启),所以课程用 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 里的内容搬进内存,跳到入口开始执行运行中的程序
内存、地址和 0x

内存可以看成一长排格子,每格存 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 里的“段”

编译器会把不同性质的东西分开放:

课程框架里的 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 的新框架。检查时老师会看提交记录,所以:

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。