本篇对应代码:xueliangliang/riscv-hello 的 lesson-03 标签,固定提交 dec100abc65349d3806129671aea917c7c8613e2。它包含前两课的累计代码;复现时请检出该标签。

为什么还要自己读盘

前两课把一个 ELF 交给 QEMU 的 -kernel,由模拟器把它加载到内存。实际系统的启动链通常还需要代码从某种存储设备找到后续镜像、搬到 RAM,再把控制权交出去。

本篇让 QEMU 继续负责加载第一阶段 bootloader ELF,但 kernel 和 shell payload 不再由 -kernel 加载:它们被放进一张 raw 磁盘镜像,bootloader 通过 virtio block 读取,再交给 kernel。启动链变为:

QEMU reset stub
    ↓
0x80000000 的 bootloader ELF
    ↓ 读取磁盘 LBA 0 的启动记录
    ↓ virtio-blk 读取 kernel 和 shell payload
RAM 0x80200000 / 0x80400000
    ↓
kernel entry → shell payload

这里的 “shell payload” 只是一个单独链接的 C 函数,用于证明第二个镜像也确实来自磁盘并能执行;它不是交互式命令行 shell。真正的 shell 留到后续课程。

环境与依赖

本篇实测基线与前两课相同,另外需要 Python 3 的标准库来生成磁盘镜像:

项目实测版本/配置
主机x86-64,Ubuntu 26.04.1 LTS
Linux kernel7.0.0-38-generic
RISC-V GNU cross GCC15.2.0
RISC-V GNU binutils2.46
GNU Make4.4.1
QEMU system emulator10.2.1
Python3.14.7,仅用标准库
GDB17.1,可选,用于检查 LBA、RAM 地址和跳转
虚拟机QEMU virt,单 hart,128 MiB RAM,M-mode
目标 ISA/ABIrv64imac_zicsr_zifencei / lp64,不启用浮点扩展

安装依赖:

sudo apt update
sudo apt install \
  make bash coreutils grep python3 \
  gcc-riscv64-linux-gnu \
  binutils-riscv64-linux-gnu \
  qemu-system-misc

# 可选:跟踪 bootloader 和 kernel 之间的跳转
sudo apt install gdb-multiarch

这些是作者实测的版本基线,不代表其他 Ubuntu 版本一定带有相同发行版修订号。

取得对应代码并构建

git clone https://github.com/xueliangliang/riscv-hello.git
cd riscv-hello
git checkout lesson-03
git rev-parse HEAD

最后一条应得到:

dec100abc65349d3806129671aea917c7c8613e2

构建并查看 ELF:

make clean all
make inspect

本次构建的入口地址为:

文件ELF entry用途
build/lesson03/bootloader.elf0x80000000QEMU -kernel 加载的第一阶段
build/lesson03/kernel.elf0x80200000bootloader 从磁盘读入的 kernel
build/lesson03/shell.elf0x80400000从磁盘读入、由 kernel 调用的 shell payload

磁盘里放的是后两个 ELF 经 objcopy -O binary 得到的 raw 字节;raw binary 不带 ELF 入口和程序段元数据,所以启动记录会携带固定的加载地址和入口地址。

磁盘和 RAM 地图

镜像是一个专用 raw 文件,不包含分区表或文件系统:

磁盘范围内容本次构建的实测大小
LBA 080 字节启动记录,其余填零512 字节扇区
LBA 1–9kernel raw image4360 字节,9 个扇区
LBA 10–18shell raw payload4312 字节,9 个扇区
合计1 个记录扇区 + 18 个镜像扇区9728 字节

RAM 中的三个固定位置:

0x80000000  bootloader ELF,由 QEMU -kernel 装载
0x80200000  kernel,bootloader 从 LBA 1 装载
0x80400000  shell payload,bootloader 从 LBA 10 装载

镜像生成器 tools/make-disk-image.py 使用 little-endian 格式写启动记录。布局是 8s + 2×u32 + 8×u64 = 80 字节,包含 magic、版本、记录大小、kernel 的 LBA/字节数/加载地址/入口,以及 shell 的同一组字段。启动记录没有 CRC;bootloader 会检查 magic、版本、字段范围、固定 RAM 地址、镜像扇区是否连续,以及这些扇区是否超出设备容量。

加载地址刻意固定在 kernel 和 shell 的链接地址。bootloader 读取每个镜像覆盖的完整扇区,最后一个扇区的 padding 不属于逻辑文件大小;kernel 自己的入口会清理它的 BSS。

为什么需要选择 modern virtio-mmio

本课 driver 实现的是 virtio-mmio Version 2:它扫描 QEMU virt 的 MMIO transport,匹配 magic/version/block device ID,协商 VIRTIO_F_VERSION_1,配置一个 8-entry split virtqueue,然后轮询完成状态。

QEMU 的 virtio-mmio transport 在本地配置下默认会暴露 legacy Version 1。作者用 GDB 直接读取 MMIO 寄存器时,未加参数看到设备版本为 1,因此测试命令显式使用:

-global virtio-mmio.force-legacy=false

这让 QEMU 暴露现代 transport;省略它时,本课 driver 会按设计拒绝版本不匹配的设备,而不是继续用错误协议读盘。这个参数是本课 QEMU 虚拟平台的配置,不是所有硬件 bootloader 的通用选项。

每次读一个 512 字节 sector,driver 提交三段描述符:

  1. 设备读取的请求头:type = VIRTIO_BLK_T_IN 和 sector 编号;
  2. 设备写入 guest RAM 的 512 字节数据缓冲区;
  3. 设备写回的请求状态字节。

CPU 把描述符链放入 available ring,执行内存 fence,更新 ring index 并通知 queue 0;之后轮询 used ring,检查完成项和状态码。没有中断,也没有并行队列。两张 payload 共 18 个 sector,超过 8 项 queue 的长度,因此这次运行也实际经过了队列 ring 回绕。

运行正常和错误路径

make test-lesson03

测试脚本生成正常镜像和一个只破坏 magic 的镜像。正常路径的关键输出:

Lesson 03: M-mode bootloader started.
Boot record validated; loading kernel and shell from disk.
Bootloader: both images loaded; jumping to kernel at 0x80200000.
Kernel: entered from the virtio block image.
Shell payload: loaded from disk and called by the kernel.
PASS: boot record, kernel/shell reads, and handoff verified.

然后脚本用坏 magic 的镜像再次启动,预期看到:

ERROR: boot record magic does not match.
PASS: disk boot handoff works and a bad boot record is rejected.

第一段 PASS 是 guest kernel 输出;最后一段由宿主脚本在确认坏镜像以预期原因被拒绝后输出。QEMU 以非零状态结束坏镜像运行是这个负向测试的预期结果。

直接 QEMU 命令等价于:

qemu-system-riscv64 \
  -machine virt \
  -global virtio-mmio.force-legacy=false \
  -smp 1 -m 128M \
  -display none -serial stdio -monitor none \
  -bios none \
  -kernel build/lesson03/bootloader.elf \
  -drive file=build/lesson03/disk.img,if=none,format=raw,id=lesson_disk,readonly=on \
  -device virtio-blk-device,drive=lesson_disk \
  -no-reboot

注意:QEMU 的 -kernel 只负责把 bootloader ELF 放到 RAM。kernel 和 shell 的输出证明 guest driver 从只读磁盘设备读取了它们,再由 kernel 调用了 shell payload;不是 QEMU 直接装载了这两个镜像。

用 GDB 对照 LBA、RAM 地址和控制流

暂停运行:

qemu-system-riscv64 \
  -machine virt -global virtio-mmio.force-legacy=false \
  -smp 1 -m 128M -display none -serial null -monitor none \
  -bios none -kernel build/lesson03/bootloader.elf \
  -drive file=build/lesson03/disk.img,if=none,format=raw,id=lesson_disk,readonly=on \
  -device virtio-blk-device,drive=lesson_disk \
  -S -gdb tcp::1234 -no-reboot

另开终端:

gdb-multiarch build/lesson03/bootloader.elf

用 break load_image 停在两次装载调用,再继续到 kernel 入口:

target remote :1234
break load_image
break *0x80200000
continue
info registers a0 a1 a2
continue
info registers a0 a1 a2
continue
info registers pc

本次实测的三个断点分别显示:

kernel: a0/LBA=1,  a1/bytes=4360, a2/destination=0x80200000
shell:  a0/LBA=10, a1/bytes=4312, a2/destination=0x80400000
kernel entry: PC=0x80200000

这把启动记录中的字段、磁盘扇区、RAM 目标和最后的 PC 连成一条可检查的数据流。

验收与边界

本篇实际验证了:

  • 第一阶段从 ELF 入口 0x80000000 启动;
  • QEMU modern virtio-mmio 的版本/设备识别、VIRTIO_F_VERSION_1 协商、queue 配置和轮询读完成;
  • 18 个 sector 的两张 payload 被分别放到 linker script 指定的 RAM 地址;
  • kernel 接手后调用磁盘中的 shell payload;
  • 损坏的启动记录 magic 被拒绝;
  • 终端 PTY 下完整 make test 不挂起。

这不是生产级 bootloader:它只支持本课固定布局、固定地址和 QEMU virtio-mmio;没有分区表、文件系统、校验和、重定位、超时恢复或中断驱动,也没有进入 S/U-mode。启动记录边界错误、virtio 初始化错误等拒绝分支存在于实现中,但本篇自动化负向测试只专门验证了坏 magic。

代码版本

参考资料