QEMU 跑 RISC-V Linux:OpenSBI、U-Boot、GRUB 四级固件链启动 Ubuntu 实录
让 QEMU 跑 RISC-V Linux 有两条路线。省事的一条是把内核镜像直接挂在 QEMU 命令行上,跳过所有固件,开机即进内核;真实的一条则是让虚拟机器像物理板子一样经历完整的加电引导序列——M 态固件先接管 CPU,逐级把执行权交给下一级,直到引导加载器从磁盘读出内核。本篇走第二条路:在 qemu-system-riscv64 的 virt 虚拟平台上,用 OpenSBI、U-Boot、GRUB、Linux 四级组件把一个 Ubuntu 24.04 RISC-V 云镜像启动到 ubuntu login: 登录提示符,登录验证架构与内核版本后干净关机。
直挂内核的最小启动适合快速验证内核与根文件系统,它的命令形态与取舍在 QEMU 直挂内核与 initramfs:跳过固件的最小 RISC-V Linux 启动 里完整展开;本篇是它的对照面。如果本机还没有 QEMU,先照 Linux 安装 QEMU:apt 与 dnf 两系发行版装齐 system、user 态与工具链 装齐 qemu-system-misc 与 qemu-utils。对虚拟机、磁盘镜像这些基本概念还陌生的读者,可以先读 认识 QEMU:从零看懂虚拟机、镜像与快照 建立图景。
下面这张数据卡汇总本次运行的关键事实,四个数字全部来自同一份 511 行的串口启动实录,后文每一处都会回到具体行号核对。
读这张卡抓住两件事:一是「4 级」是本篇的主线,四个组件各有明确的特权级与交接对象,不是可有可无的堆叠;二是「0 次 emergency」是完整链路成立的验收信号——系统没有在任何一级失败后掉进紧急 shell,而是一路走到登录提示符,再由人工主动关机。
本文要回答的问题
- 一块 RISC-V 虚拟板子加电后,OpenSBI、U-Boot、GRUB、Linux 各自负责什么,交接时递给下一级的是什么?
- 同样是
-kernel参数,喂给 U-Boot 和直接喂 Linux 内核有什么本质差别? - U-Boot 没有配置启动项时,凭什么能从一块空白数据盘里找到 GRUB?
- 云镜像的分区号到了 16,
qemu-nbd挂载为什么会缺设备节点,max_part又解决了什么? - cloud-init 的 no-cloud 种子由哪两个文件构成,它和直接改
/etc/shadow是什么关系?
一、两种启动姿势:完整固件链与直挂内核的差别
RISC-V 特权架构把执行环境分成 M、S、U 三级。M 态(Machine mode)权限最高,能触碰全部硬件寄存器;S 态(Supervisor mode)是操作系统内核所处的级别;U 态是普通用户程序。上电瞬间 CPU 必然落在 M 态,问题只是谁在 M 态接住第一棒,以及后面要不要保留这些中间环节。
直挂内核的路线里,QEMU 内置的 -bios default 提供一份 OpenSBI,-kernel 直接指向 Linux 内核镜像,OpenSBI 做完最小初始化就跳进内核,磁盘上的 U-Boot、GRUB、EFI 分区全部不参与。它快、参数少,适合内核开发与 initramfs 实验,但启动路径与真实发行版磁盘脱节——内核是从命令行「喂」进去的,不是从盘上「找」出来的。
完整固件链则让磁盘上的一切真正参与引导。QEMU 同样先加载 OpenSBI,但 OpenSBI 的下一棒变成 U-Boot;U-Boot 以 EFI 方式从 virtio 磁盘的 EFI 分区读出 GRUB;GRUB 弹出菜单、读配置,再从 BOOT 分区装载内核与 initramfs。两种姿势的差别可以收成一张表。
| 对比维度 | 直挂内核最小启动 | 完整固件链(本篇) |
|---|---|---|
| M 态固件 | OpenSBI(QEMU 内置 default) | OpenSBI v1.3,外部 fw_jump.bin |
| 引导加载器 | 无,OpenSBI 直接跳内核 | U-Boot 2022.01 先行,再链式加载 GRUB 2.12 |
| 内核来源 | QEMU -kernel 命令行参数 | GRUB 从磁盘 BOOT 分区读 vmlinuz |
| EFI 分区与启动项 | 不参与 | U-Boot 按 EFI 可移动介质约定发现 bootriscv64.efi |
| 镜像要求 | 内核与 initramfs 单独取出 | 发行版云镜像原样可引导 |
| 贴近真机程度 | 低,属于开发捷径 | 高,与物理 RISC-V 服务器的引导顺序一致 |
| 典型用途 | 内核调试、initramfs 验证 | 发行版验收、固件排障、引导链学习 |
这张表也是两篇姊妹文的分工:想弄清跳过固件省掉了什么,去读直挂内核篇;本篇只回答「不跳过时每一级发生了什么」。
二、启动命令:两个固件参数与一个自动化驱动
本次运行由 expect 脚本驱动,真正执行的 QEMU 命令在实录第一行完整可见,拆开看只有四个核心参数。
qemu-system-riscv64 \
-machine virt \
-m 2048 -smp 2 -nographic \
-bios /usr/lib/riscv64-linux-gnu/opensbi/generic/fw_jump.bin \
-kernel /usr/lib/u-boot/qemu-riscv64_smode/u-boot.bin \
-drive file=noble-riscv64.img,if=virtio,format=qcow2-machine virt 选择 QEMU 的 RISC-V 通用虚拟平台,它自带 CLINT 定时器、virtio 总线、16550 兼容串口等一组固定设备,也是 OpenSBI 日志里 Platform Name : riscv-virtio,qemu 的由来。-m 2048 -smp 2 给出 2 GiB 内存与两个 HART(RISC-V 对硬件线程的称呼)。-nographic 把虚拟串口接到当前终端,所以整条固件链的输出都涌进同一个日志文件,不需要图形界面。
最容易误读的是后两个参数的搭配。-bios 指向 OpenSBI 的 fw_jump.bin,它在 M 态运行;这里的 -kernel 虽然名字叫 kernel,喂进去的却不是 Linux,而是 S 态的 U-Boot 二进制 u-boot.bin。OpenSBI 完成 M 态初始化后,会跳到这块载荷的入口——也就是说,-kernel 在这条路线里只是「OpenSBI 的下一棒」的投递通道,真正的 Linux 内核此刻还安静地躺在磁盘上。最后一个 -drive 把 qcow2 镜像挂成 virtio 块设备,U-Boot 与 GRUB 后续要找的全部东西都在这块盘里。
因为 GRUB 菜单有 10 秒倒计时、登录还需要输入口令,手工操作既慢又无法留全量证据,本次用 expect 把整个过程自动化:等待 ubuntu login: 出现后发送用户名,等待 Password: 后发送口令,登录成功再执行一组探测命令,最后 sudo poweroff 收尾,超时 540 秒。脚本把整条串口流原样 tee 进日志,本篇引用的所有行号都出自这份记录。
三、一张图看懂四级启动链
在钻进每一级日志之前,先把完整链路画成一张分层图。读图时沿着自上而下的交接边看:每一条边都标注了上一级递给下一级的具体对象,特权级从 M 态一路降到用户态。
图中蓝色是 M 态固件,紫色是 S 态引导加载器,橙色是 EFI 启动管理器,绿色是内核与用户态,灰色是被各层共享的硬件与磁盘。注意两条从磁盘出发的边:U-Boot 只是扫描磁盘确定引导介质,真正读出 EFI 文件与内核的动作横跨 U-Boot 与 GRUB 两级。还有一个图上没有画出来的细节——OpenSBI 跳进 S 态后并不会消失,它作为 SBI 运行时常驻 M 态,内核日后需要关机、读定时器、核间打断时,通过 ecall 陷入 M 态调用它。日志末尾的 reboot: Power down 能让 QEMU 真的断电,靠的就是这条 SBI 调用链。
四、第一级 OpenSBI:M 态固件与 SBI 交接
实录第 3 行出现的第一个组件是 OpenSBI v1.3。它是 RISC-V 世界的 SBI(Supervisor Binary Interface)参考实现,角色相当于 ARM 平台上的 ATF 或 x86 上的 BIOS/UEFI 固件:在最高特权级做最早的硬件初始化,再为下层操作系统提供一组标准化的运行时服务。
这段输出值得逐行读。Platform HART Count : 2 与命令行的 -smp 2 对上,OpenSBI 识别到两个硬件线程;Platform Timer Device : aclint-mtimer @ 10000000Hz 说明 M 态定时器以 10 MHz 驱动,这是后面内核时钟的源头;Runtime SBI Version : 1.0 标明它对外提供 SBI 1.0 规范的服务集。再往下的域信息里,Domain0 Next Address : 0x0000000080200000 与 Domain0 Next Mode : S-mode 是整条交接的关键——OpenSBI 明确宣告:下一棒从物理地址 0x80200000 开始,以 S 态运行,那个地址上放的正是 QEMU 通过 -kernel 投递的 U-Boot。
Firmware Base : 0x80000000、Firmware Size : 204 KB 给出固件自身的驻留位置与体积,它和下一棒的地址空间互不重叠。Boot HART Priv Version : v1.10、Boot HART Base ISA : rv64imafdc 则交代了 CPU 的特权架构版本与基础指令集——i/m/a/f/d/c 六个字母分别对应整数乘除、原子、单双精度浮点与压缩指令,这也是后续 U-Boot 打印 rv64imafdcsu 的基础。到这里 M 态的工作结束,执行权交给 S 态。
还要理解 fw_jump.bin 这个文件名里的「jump」。OpenSBI 有多种固件形态:fw_payload 把下一棒程序和固件打包成一个镜像,跳转地址在编译期写死;fw_jump 则是纯固件,只负责在固定地址等待下一级载荷,载荷由 QEMU 的 -kernel 单独投递,两者在启动时才拼合。本篇用的正是 jump 形态,所以换 U-Boot 版本时不必重编 OpenSBI,命令行替换文件即可。另外注意 Domain0 Region03 那条覆盖全地址空间的授权记录——M 态把 S/U 态对内存的访问权显式下放,这正是 S 态的 U-Boot 随后能够扫描 virtio 磁盘、读取 EFI 文件的特权基础。
五、第二级 U-Boot:S 态引导与 EFI 可移动介质发现
实录第 60 行,U-Boot 2022.01 接棒。这个版本号带着 Ubuntu 的发行版后缀 +dfsg-2ubuntu2.9,说明它不是自己编译的裸 U-Boot,而是从 u-boot-qemu 软件包安装的发行版构建。
CPU: rv64imafdcsu 比 OpenSBI 报告的 ISA 多了 s 与 u 两个字母,对应 S 态与 U 态扩展——此刻运行级别已经切换。DRAM: 2 GiB 与 -m 2048 对上,Flash: 32 MiB 是 virt 平台模拟的固件闪存窗口。三行 In/Out/Err: uart@10000000 表明标准输入输出全部挂在这个串口上,这也是 -nographic 能看到全部日志的原因。Net: No ethernet found. 不是故障:本次命令本来就没挂虚拟网卡,U-Boot 阶段无需网络。
真正的好戏在磁盘扫描。Device 0: 1af4 VirtIO Block Device 认出了 -drive 挂上的 virtio-blk 盘,容量 4608.0 MB = 4.5 GB,与云镜像尺寸一致。随后两组报错信息很有教学价值:Failed to load EFI variables、BootOrder not defined、EFI boot manager: Cannot load any image 接连出现,意思是这块盘没有预置任何 EFI 启动变量,U-Boot 不知道该从哪个文件启动。换一块配置齐全的物理机硬盘,这里会按 NVRAM 里的 BootOrder 直接命中。
U-Boot 没有因此放弃,而是回退到 EFI 规范里的「可移动介质」约定:在 EFI 分区的固定路径 efi/boot/ 下寻找按架构命名的兜底文件。RISC-V 64 位的约定文件名就是 bootriscv64.efi,而 Ubuntu 云镜像恰好按这个约定布置了 GRUB。于是日志出现 Found EFI removable media binary efi/boot/bootriscv64.efi,159744 bytes read in 2 ms,紧接着 Booting /efi\boot\bootriscv64.efi——S 态引导加载器把自己变成了 EFI 宿主,把执行权交给这个 EFI 应用。理解了这个回退机制,就理解了为什么一块「零配置」的盘照样能引导:约定优于配置。
六、第三级 GRUB 与第四级 Linux:从菜单到内核接管
bootriscv64.efi 就是 GRUB。实录第 96 行打印 GNU GRUB version 2.12,随后画出文本菜单,高亮项是 Ubuntu,并有 10 秒自动倒计时。倒计时归零后菜单记录 Booting 'Ubuntu',进入内核装载。
GRUB 在这条链里的职责与 U-Boot 不同:U-Boot 解决「从哪块盘、按什么约定找引导程序」,GRUB 解决「盘上有多个内核或多个启动项时选哪个、传什么内核参数」。它读取 BOOT 分区里的 GRUB 配置与 extlinux.conf,把菜单渲染出来,再把选中的内核与 initramfs 读进内存。实录紧接着的两行没有任何报错:Loading Linux 7.0.0-38-generic ... 与 Loading initial ramdisk ...。这两行就是第四级交接的回执——vmlinuz 与 initramfs 均已就位,GRUB 的使命到此结束,CPU 开始在内核语境下运行。
之后串口输出切换成 systemd 的服务启动清单:systemd-journald.service、systemd-fsck-root.service、lvm2-monitor.service 等单元依次拉起,Reached target sysinit.target、Reached target sockets.target、Reached target basic.target 一行行推进。内核自身的环形缓冲区横幅在串口抓录起点之前已经滚过,但内核版本有两处更硬的证据:GRUB 的装载行写明 7.0.0-38-generic,登录后的欢迎信息与 uname -r 还会再确认一次。
七、登录、系统状态与干净关机
实录第 385 行出现 Ubuntu 24.04.5 LTS ubuntu ttyS0,第 387 行是期待已久的 ubuntu login:。输入用户名与口令后,第 389 行的欢迎横幅把整套软件栈一次交代清楚:Welcome to Ubuntu 24.04.5 LTS (GNU/Linux 7.0.0-38-generic riscv64)——发行版版本、内核版本、CPU 架构三个字段同框,这是「完整链路走到用户态」最直接的证据。
上面的终端实录从上到下覆盖了整条链路的可见痕迹:顶部是 OpenSBI 的平台信息与 HART 计数,中段是 U-Boot 的 DRAM 识别、virtio 磁盘扫描与 EFI 兜底发现,接着是 GRUB 菜单与 Loading Linux 两行装载回执,下半段进入 systemd 目标推进,最终落在 ubuntu login: 与登录后的 Ubuntu 欢迎横幅。读的时候把每一段的首行当作组件分界线,能清楚看到执行权四次易手、输出风格也随之变化。
登录后执行的探测命令给出三行关键回执,确认的是运行中的系统而不只是日志文本:
echo CHAIN_PROBE_START; uname -m; uname -r; systemctl is-system-running; echo CHAIN_PROBE_DONE
# 回执:
# riscv64
# 7.0.0-38-generic
# degradeduname -m 返回 riscv64,证明当前用户态确实是 RISC-V 64 位原生环境,不是任何形式的交叉或转译;uname -r 与 GRUB 装载行的版本完全咬合。systemctl is-system-running 返回 degraded 而不是 running,这里必须如实说明:实录第 365 行有一个明确失败的单元 systemd-networkd-wait-online.service,它等待网络配置超时(作业跑了 2 分 37 秒)后失败。本次启动没有挂网卡,这个单元的等待条件天然无法满足,属于最小虚拟环境下的预期现象;它拖慢了开机,但没有阻断登录路径。区分两个概念很重要:degraded 表示有个别单元失败、系统整体仍在正常运行,emergency 则是根文件系统或关键挂载失败后落入的紧急 shell。对整份实录计数,emergency 出现次数为 0,登录提示符正常到达,验收结论成立。
关机环节同样干净。第 428 行执行 sudo poweroff,root 通过广播通知所有会话,systemd 依次停止 systemd-networkd、systemd-resolved、cron 等服务,Reached target umount.target、Reached target shutdown.target、Finished systemd-poweroff.service 逐级完成,最后一行是内核借 SBI 调用让 QEMU 断电的回执:
[ 247.740588] reboot: Power down方括号里的 247.740588 是内核启动后的秒数,也就是数据卡上「247.7 秒」的出处。QEMU 进程随之退出,expect 脚本收到 EOF,没有残留进程,也没有需要强制 kill 的虚拟机。
八、镜像工程:qemu-nbd 改造与 no-cloud 种子
固件链能跑通的前提,是那块 qcow2 镜像在开机前已经被改造过。Ubuntu RISC-V 云镜像自带三个分区:p1 是 ext4 根文件系统(卷标 cloudimg-rootfs),p15 是 FAT32 的 UEFI 分区,p16 是 ext4 的 BOOT 分区,内核、initramfs、GRUB 资源与 extlinux.conf 都在 p16 上。在主机上离线修改这块盘,标准工具是 qemu-nbd——把 qcow2 挂成网络块设备,再像普通磁盘一样 mount 分区。
第一个坑来自分区号与内核默认参数的错位。nbd 模块默认 max_part=8,只允许每个 nbd 设备暴露 8 个分区节点,而这块盘的分区号到了 16,p16 根本不会生成设备文件。现场日志留下了明确的报错:
### 1. 加载 nbd 并连接镜像
Error: Partition(s) 16 on /dev/nbd0 have been written, but we have been unable to inform
the kernel of the change, probably because it/they are in use. As a result, the old
partition(s) will remain in use. You should reboot now before making further changes.解决办法是连接前显式抬高上限:sudo modprobe nbd max_part=32,再重新 qemu-nbd --connect=/dev/nbd0 noble-riscv64.img,nbd0p1、nbd0p15、nbd0p16 三个节点才会齐全。之后挂载 p1 与 p16,完成四件改造:从 BOOT 分区取出 vmlinuz 与 initrd.img 供直挂内核实验复用;把预置口令的哈希写进根分区的 /etc/shadow,让串口登录不依赖云端元数据;给原本为空的 /etc/netplan/ 补一份匹配 en* 网卡的 DHCP 配置;给 extlinux.conf 的内核参数补上 console=ttyS0,并 mask 掉无 EFI 分区场景下会失败的 boot-efi.mount 与 systemd-networkd-wait-online.service——后者正是实录里那个超时单元的预防性处理。
另一条更「云原生」的初始化路线是 cloud-init 的 no-cloud 种子。它不需要挂载或修改根文件系统,只需要两个 YAML 文件,随一个卷标为 CIDATA 的小磁盘提供给虚拟机,cloud-init 首次启动时自动读取。制作这个种子盘有两种常见做法:Debian/Ubuntu 上可以用 cloud-image-utils 包自带的 cloud-localds seed.iso user-data meta-data 一条命令生成;没有该工具时,genisoimage -output seed.iso -volid CIDATA -joliet -rock user-data meta-data 等价,关键都是卷标必须叫 CIDATA,因为 NoCloud 数据源靠这个标签识别种子盘,文件名反而不做硬性要求。使用时把种子盘作为第二块 -drive 挂上即可,它只读不写,实例初始化完成后可以随时摘除。本次准备的两个文件内容如下。
#cloud-config
hostname: noble-riscv
chpasswd:
expire: false
users:
- name: ubuntu
password: $6$s3f4vbSf8e1haBj8$5YXl4LJDmFromZIkEvPTOaXrYmKXhD6u8KQdotc23xNyhBUQJHluYOZPojhqbV.eScVsmjKxC/RD5NdEg64H81
ssh_pwauth: true
bootcmd:
- echo "cloud-init seed applied on riscv64"instance-id: b2-q015-001
local-hostname: noble-riscvmeta-data 提供实例身份:instance-id 决定 cloud-init 是否把这次开机当作「新实例首次启动」,local-hostname 设置主机名。user-data 以 #cloud-config 开头声明格式,chpasswd 段直接给出 ubuntu 用户的 SHA-512 口令哈希(与 /etc/shadow 里的哈希同格式),ssh_pwauth: true 放开口令方式的 SSH 登录,bootcmd 注册一条开机执行的标记命令。两条路线解决的是同一组问题——身份、凭据、主机名——区别只在实施层面:nbd 直改是离线刻进镜像,效果确定、不依赖客户机里的服务;no-cloud 种子是开机时由 cloud-init 声明式应用,更贴近公有云的真实用法,同一份种子可以批量喂给任意多台新实例。本次实录的登录凭据由 nbd 直写 /etc/shadow 保证,种子文件作为等价的 cloud-init 原生方案一并留存,两种做法在工程上互为备份。
实验环境
- 机器/系统:云主机 2C8G,Ubuntu 22.04.5 宿主;客户机为 Ubuntu 24.04.5 LTS(RISC-V 64 位)云镜像
- 工具版本:QEMU(宿主发行版仓库
qemu-system-riscv64);客户机侧 OpenSBI v1.3、U-Boot 2022.01+dfsg-2ubuntu2.9、GNU GRUB 2.12、Linux 7.0.0-38-generic - 日期:2026-11-08
- 完整命令:expect 脚本驱动
qemu-system-riscv64 -machine virt -m 2048 -smp 2 -nographic -bios .../fw_jump.bin -kernel .../u-boot.bin -drive file=noble-riscv64.img,if=virtio,format=qcow2,串口全量输出落run1.log(511 行),关机路径为登录后sudo poweroff
代码获取
本次实验的 expect 驱动脚本、镜像准备脚本与原始启动日志已同步到公开仓库,目录:
https://github.com/derekzhuo/geek-doc.cn/tree/main/experiments/qemu-riscv-linux-distro
克隆后先按 README 用 prepare-image.sh 完成 nbd 挂载与镜像改造,再执行 ./boot-uboot.exp | tee run.new.log 即可复现从 OpenSBI 到登录提示符再到 poweroff 的完整链路;脚本与日志均为纯文本,可直接对照本篇引用的行号。
这一篇在知识体系里的位置
本篇是 SoC 与 QEMU 频道里「真实引导链」方向的枢纽文,向上承接虚拟机与镜像的基本概念,向下与三篇文档构成对照和互补。认识 QEMU:从零看懂虚拟机、镜像与快照 给出虚拟机、qcow2、快照的底层图景,本篇的磁盘与固件参数都建立在这些概念之上。QEMU 直挂内核与 initramfs:跳过固件的最小 RISC-V Linux 启动 是本篇的直接对照实验,两篇对读能看清「省掉固件」到底省掉了哪些职责。Linux 安装 QEMU:apt 与 dnf 两系发行版装齐 system、user 态与工具链 解决工具从哪来的问题,其中 qemu-utils 提供的 qemu-nbd 正是本篇镜像改造的依赖。串口为什么能承接整条固件链的输出,则由 QEMU 串口控制台接线:nographic、serial 与虚拟终端的分工 展开。
总结
QEMU 跑 RISC-V Linux 的完整固件链是四次职责清晰的交接:OpenSBI v1.3 在 M 态完成最早的硬件初始化并以 S 态跳进 U-Boot 2022.01;U-Boot 在没有任何预置启动项的情况下,靠 EFI 可移动介质约定从 virtio 磁盘发现 bootriscv64.efi;这个 EFI 应用就是 GRUB 2.12,它渲染菜单、装载 Linux 7.0.0-38 内核与 initramfs;内核接管后 systemd 一路推进到串口登录提示符,uname 确认 riscv64 原生用户态,最后经 SBI 调用在 247.7 秒处干净 Power down,全程零 emergency。支撑这条链路的工程细节同样关键:qemu-nbd 需要 max_part=32 才能暴露 16 号分区,cloud-init no-cloud 种子用 user-data 与 meta-data 两个文件提供声明式初始化,与离线改写 /etc/shadow 互为备份。