第2章:QEMU RISC-V 平台基础

这一章,我们来聊聊 QEMU 的 RISC-V 平台到底长什么样。说白了,就是搞清楚 QEMU 模拟出来的那块「虚拟开发板」——它叫 virt。我刚开始接触 QEMU 时,也纳闷过:为什么非得用 virt?它跟真实硬件有啥区别?别急,我们一步步拆开看。

2.1 QEMU 机器模型:virt

QEMU 为 RISC-V 提供了多种机器模型,但最常用、最通用的就是 virt。你可以把它理解成一块「参考设计板」——它不绑定任何具体厂商的 SoC,而是把 RISC-V 标准规定的外设都挂上去。

核心特点:

  • 纯虚拟平台,不模拟真实芯片的引脚时序
  • 外设通过 MMIO(内存映射 I/O)访问
  • 支持 OpenSBI + U-Boot 或直接加载 Linux 内核
  • 设备树自动生成,无需手动编写 DTS

我在项目中遇到过一个问题:用 virt 跑 Linux 内核,结果串口死活没输出。后来发现是 QEMU 版本升级后,默认的串口基地址变了。嗯,这就是虚拟平台的「坑」——它虽然方便,但版本之间可能有细微差异。

机器模型 适用场景 外设丰富度 启动方式
virt 通用开发、驱动验证 高(UART、GPIO、RTC、PCIe等) OpenSBI + 内核 / 直接加载
sifive_u SiFive Unleashed 板模拟 中 FSBL + U-Boot
spike 裸机测试、指令集验证 低(仅 HTIF) 直接加载 ELF

我的建议:做驱动开发,优先选 virt。它外设齐全,调试方便。等驱动在 virt 上跑通了,再移植到真实板卡上。

2.2 设备树基础:DTS 与 DTB

设备树(Device Tree)是 RISC-V 平台上描述硬件信息的标准方式。你想想看,Linux 内核怎么知道板子上有哪些外设?它们的地址是多少?中断号是多少?这些信息就写在设备树里。

设备树的处理流程很简单:

  1. 编写 .dts 源文件(人类可读)
  2. 用 dtc 编译成 .dtb 二进制文件
  3. QEMU 启动时加载 .dtb,传递给内核

QEMU 的 virt 平台有个好处:它可以在启动时自动生成设备树。你不需要自己写 DTS,除非你想定制外设。我个人习惯先用 -M virt,dumpdtb= 参数把生成的 DTB 导出来,再用 dtc 反编译看看里面到底有什么。

# 导出 QEMU 自动生成的设备树
qemu-system-riscv64 -M virt,dumpdtb=qemu-virt.dtb -m 2G -nographic

# 反编译为 DTS 文件
dtc -I dtb -O dts -o qemu-virt.dts qemu-virt.dtb

打开生成的 qemu-virt.dts,你会看到类似这样的结构:

/dts-v1/;

/ {
    #address-cells = <2>;
    #size-cells = <2>;
    compatible = "riscv-virtio";
    model = "riscv-virtio,qemu";

    chosen {
        bootargs = "console=ttyS0";
    };

    memory@80000000 {
        device_type = "memory";
        reg = <0x0 0x80000000 0x0 0x80000000>;
    };

    soc {
        #address-cells = <2>;
        #size-cells = <2>;
        ranges;

        uart@10000000 {
            compatible = "ns16550a";
            reg = <0x0 0x10000000 0x0 0x100>;
            interrupts = <10>;
            interrupt-parent = <&plic>;
            clock-frequency = <3686400>;
        };

        gpio@10100000 {
            compatible = "virtio,mmio";
            reg = <0x0 0x10100000 0x0 0x200>;
            interrupts = <1>;
            interrupt-parent = <&plic>;
        };
    };

    plic@c000000 {
        compatible = "riscv,plic0";
        reg = <0x0 0xc000000 0x0 0x4000000>;
        interrupts-extended = <&cpu0 11 &cpu0 9>;
        riscv,ndev = <127>;
    };
};

我曾经踩过的坑:设备树里的 reg 属性,地址和大小都是 2 个 32 位值组成的 64 位数。写驱动时如果只取低 32 位,高地址外设就会访问失败。记得用 of_iomap() 或 platform_get_resource() 来获取完整地址。

2.3 启动流程分析

QEMU RISC-V virt 平台的启动流程,我把它分成三个阶段。你想想看,从按下「电源」到 Linux 内核跑起来,中间发生了什么?

QEMU RISC-V virt 启动流程 阶段1:QEMU 初始化 加载固件 / 内核到内存 阶段2:OpenSBI 启动 M 模式初始化,跳转到 S 模式 阶段3:Linux 内核 S 模式运行,挂载根文件系统 各阶段关键动作: 1. QEMU 解析 -M virt 参数,创建 CPU、内存、外设实例 2. 加载 OpenSBI(fw_jump.bin)到 0x80000000,加载内核到 0x80200000 3. OpenSBI 在 M 模式完成初始化,通过 ecall 跳转到 S 模式内核入口 4. Linux 内核解压自身,解析设备树,初始化驱动,挂载根文件系统 QEMU virt 默认内存布局(2GB RAM): 0x80000000 - 0x8001FFFF : OpenSBI (128KB) 0x80200000 - 0x80FFFFFF : Linux 内核 (约15MB) 0x81000000 - 0xFFFFFFFF : 可用内存 / 设备树 / initrd 0x10000000 - 0x100000FF : UART 串口 (NS16550) 0xC000000 - 0x10000000 : PLIC 中断控制器

启动时,QEMU 会按以下顺序加载:

  1. OpenSBI:运行在机器模式(M-mode),负责初始化硬件、提供 SBI 服务
  2. 设备树:QEMU 自动生成,放在内存中,通过 a1 寄存器传递给内核
  3. Linux 内核:运行在监管模式(S-mode),解析设备树,初始化驱动

调试小技巧:启动时加 -d in_asm -D trace.log 可以记录每条指令的执行。我曾经用这个方法定位过一个驱动初始化顺序的问题——内核在访问外设时,PLIC 还没配置好,导致中断丢失。

2.4 实战:启动一个最小系统

光说不练假把式。我们来实际跑一下 QEMU RISC-V virt 平台。你需要准备:

  • QEMU 7.0+(支持 RISC-V 64位)
  • OpenSBI 预编译固件(fw_jump.bin)
  • RISC-V 64位 Linux 内核(Image)
  • 根文件系统(rootfs.ext4)
# 启动命令
qemu-system-riscv64 \
  -M virt \
  -m 2G \
  -smp 2 \
  -bios fw_jump.bin \
  -kernel Image \
  -append "root=/dev/vda rw console=ttyS0" \
  -drive file=rootfs.ext4,format=raw,id=hd0 \
  -device virtio-blk-device,drive=hd0 \
  -nographic

启动后,你会看到 OpenSBI 的打印信息,然后是 Linux 内核的启动日志。如果一切顺利,最后会出现一个 shell 提示符。

注意:如果串口没有输出,检查两点:一是 -nographic 参数是否加上了,二是内核配置里是否开启了 CONFIG_SERIAL_8250 和 CONFIG_SERIAL_OF_PLATFORM。我遇到过好几次,内核编译时忘了加串口驱动,结果只能干瞪眼。

2.5 本章小结

这一章我们覆盖了三个核心知识点:

  • virt 机器模型:QEMU 的通用 RISC-V 虚拟平台,外设丰富,适合驱动开发
  • 设备树:描述硬件信息的标准方式,QEMU 自动生成,但你需要读懂它
  • 启动流程:OpenSBI → 设备树 → Linux 内核,三个阶段环环相扣