基于QEMU与AGENT的RISC-V AI芯片PCIe实验台搭建指南 1. 为什么我要用 QEMU 给 RISC-V AI 芯片搭一个实验台第一次接触 RISC-V AI 芯片验证的时候我踩过一个很典型的坑板子还没到驱动和应用团队已经在催着要跑模型了。硬件流片周期动辄几个月等真板子到手再开始调软件整个项目节奏会被拖得很难受。后来我养成了一个习惯任何一颗新芯片先在 QEMU 里把实验台搭起来把能提前验证的东西全部提前跑通。这次要聊的就是这么一件事用 AGENT 的思路把 QEMU 上的 RISC-V AI 芯片实验环境从零拉起来。先说清楚这个标题里的几个关键词到底指什么。QEMU是一套开源的机器模拟器它能模拟 CPU 指令集、内存、外设总线让你在没有物理芯片的情况下跑完整的操作系统和驱动。RISC-V是一套开放指令集架构这几年在 AI 加速、边缘计算领域铺得很快很多自研 AI 芯片的内核控制单元都用它。AI 芯片在这里指的是带矩阵计算单元、通过 PCIe 挂到主机上的加速卡形态设备。PCIe是它和主机通信的主干道枚举、BAR 空间映射、DMA 这些都得在这条总线上跑通。AGENT在这里不是指某个具体产品而是一种工作方式把环境搭建、配置生成、验证执行拆成可编排的步骤让一个自动化代理去驱动整个流程而不是我手动一条条敲命令。这套实验台能解决什么问题最直接的价值是把硬件依赖前置解耦。芯片还没回来我就能在 QEMU 里验证 RISC-V 固件的启动流程、PCIe 设备的枚举逻辑、驱动加载顺序、甚至上层推理框架的调用链路。等真板子到了软件侧已经跑通大半剩下的就是对接真实时序和性能调优。适合谁来参考做 AI 加速卡固件和驱动的工程师、做 RISC-V 裸机或 RTOS 开发的同行、以及想入门芯片验证但手头没有硬件的学生和爱好者。哪怕你只是想在本地跑一个 RISC-V Linux 玩玩这套流程也能直接抄。我下面会按“整体设计思路 → 核心细节 → 实操落地 → 问题排查”的顺序展开中间会穿插我自己踩过的坑和参数选择依据。你不需要有很深的 QEMU 经验但最好对 Linux 命令行和基本的编译流程不陌生。2. 整体设计思路为什么是 QEMU RISC-V PCIe 这套组合2.1 实验台要模拟的真实场景是什么一颗典型的 RISC-V AI 芯片在系统里的角色通常是一个 PCIe 端点设备Endpoint。主机侧是 x86 或者 ARM 服务器通过 PCIe 根复合体Root Complex枚举到这张卡给它分配 BAR 空间加载驱动然后驱动通过 MMIO 寄存器控制卡上的 RISC-V 核把推理任务下发下去。卡内部可能还有独立的 DDR、片上 SRAM、DMA 引擎和矩阵计算单元。要在 QEMU 里复现这个场景我需要三样东西同时到位一个能跑 RISC-V 指令的 CPU 模型、一条能挂 PCIe 设备的虚拟总线、以及一套能把这两者串起来的固件和驱动。QEMU 恰好都支持。它的virt机器类型支持 RISC-V并且可以通过-device参数挂载各种 PCIe 设备模型包括自定义的 edu 设备、nvme、以及可以自己扩展的 PCIe endpoint。提示QEMU 的 RISC-V virt 机器默认带 PCIe 支持但需要确认你的 QEMU 版本在 7.0 以上早期版本对 RISC-V 的 PCIe 中断路由支持不完整会出现设备枚举到了但中断收不到的情况。2.2 为什么用 AGENT 方式驱动而不是手动敲命令手动搭一次环境命令大概有几十条编译 QEMU、准备 rootfs、写启动脚本、配置网络、挂载设备、启动后还要在 guest 里 insmod、检查 lspci、跑测试。第一次搭完过两周换个参数重来你会发现根本记不住当时改了哪里。AGENT 方式的核心价值是把这些步骤固化成可重复执行的编排。我自己的做法是把整个流程拆成几个阶段环境准备、镜像构建、设备配置生成、启动执行、验证采集。每个阶段写成一个独立的脚本或者配置片段由一个主控脚本按顺序调用。这个主控脚本就是最朴素的 AGENT——它根据当前状态决定下一步做什么失败了能回滚成功了能记录。你不需要一上来就上复杂的 agent 框架一个带状态判断的 bash 或者 Python 脚本就够了。等流程稳定了再考虑接入更复杂的编排逻辑比如根据 lspci 的输出自动判断设备是否枚举成功失败就重新生成配置。这样做的另一个好处是参数可追溯。QEMU 的启动参数里内存大小、CPU 核数、设备地址、内核命令行任何一个变了都可能导致行为差异。把这些参数集中放在一个配置文件里由 AGENT 读取生成最终命令比散落在各个脚本里靠谱得多。2.3 方案选型为什么不用其他模拟器市面上能模拟 RISC-V 的工具有不少Spike、Renode、以及各种 FPGA 原型。Spike 是官方参考模拟器指令级精度高但它不模拟 PCIe 总线也没有完整的外设模型跑裸机测试可以跑带 PCIe 的 Linux 驱动就力不从心。Renode 外设建模能力强但 RISC-V 的 PCIe 生态不如 QEMU 成熟社区资料也少。FPGA 原型最接近真实但成本高、迭代慢不适合日常快速验证。QEMU 的优势在于平衡指令模拟够用、外设模型丰富、PCIe 支持完整、社区活跃、调试工具链齐全。它还能和 GDB 无缝对接单步调试 guest 内核。对于“提前把软件链路跑通”这个目标QEMU 是性价比最高的选择。性能上它当然不能和真芯片比但验证功能正确性足够了。2.4 整体架构分层我把这个实验台分成四层从下到上依次是模拟层QEMU 提供的 RISC-V CPU、内存、PCIe 主机桥、以及挂载的 AI 加速卡设备模型。固件层OpenSBI 负责 M 模式到 S 模式的切换U-Boot 负责引导内核这一层决定了设备树怎么描述 PCIe 拓扑。系统层Linux 内核包含 PCIe 子系统、设备驱动、以及 RISC-V 相关的架构支持。应用层用户态的测试程序模拟推理任务的下发和结果回收。AGENT 编排贯穿这四层负责把每层的产物串起来。理解这个分层后面看具体操作时就不会迷路。3. 核心细节解析RISC-V 与 PCIe 在 QEMU 里的关键机制3.1 RISC-V 在 QEMU 里的启动链路RISC-V 的启动和 x86 很不一样。上电后第一个执行的是 M 模式Machine Mode的固件通常是 OpenSBI。OpenSBI 初始化底层硬件设置好中断控制器和定时器然后跳转到 S 模式Supervisor Mode的引导程序也就是 U-Boot 或者直接是内核。这个 M 到 S 的切换是通过mret指令完成的切换前要配置好mstatus、mepc等 CSR 寄存器。在 QEMU 里这条链路是-bios参数指定 OpenSBI 固件-kernel指定内核或者 U-Boot。如果你用-kernel直接加载 Linux 内核QEMU 会按照 RISC-V Linux 的启动协议把设备树地址放在a1寄存器内核入口地址放在a0。这个细节很重要因为设备树里描述了 PCIe 控制器的地址和中断号如果设备树不对PCIe 根本枚举不起来。我实测下来最省事的组合是OpenSBI 用预编译的fw_jump.binU-Boot 用qemu-riscv64_smode_defconfig编译出来的版本内核用主线 6.x。这样三层都是标准配置出问题容易定位。3.2 PCIe 枚举过程在 QEMU 里怎么走PCIe 枚举是主机侧固件或者内核干的活。在 QEMU 的 virt 机器里PCIe 主机桥是一个pcie-host-generic设备它模拟了 ECAMEnhanced Configuration Access Mechanism空间。内核通过访问 ECAM 来读取每个 PCIe 设备的配置空间拿到 Vendor ID、Device ID、Class Code然后给设备的 BAR 分配地址空间。枚举的典型流程是这样的内核扫描总线 0发现根端口然后逐级扫描下游设备。每发现一个设备读取它的配置头判断是桥还是端点。如果是端点就根据 BAR 的大小请求分配 MMIO 空间。QEMU 会响应这些配置读写请求返回设备模型预设的值。这里有个容易忽略的点QEMU 里挂载的 PCIe 设备它的 Vendor ID 和 Device ID 是设备模型里写死的。如果你要模拟自己的 AI 芯片需要改 QEMU 源码里的设备模型或者用-device参数覆盖。我一般会在设备模型里把 Vendor ID 设成一个自定义值这样在 guest 里lspci一眼就能认出来不会和真实设备混淆。3.3 设备树里 PCIe 节点的写法设备树是 RISC-V Linux 启动的关键。PCIe 控制器节点需要描述regECAM 地址和大小、interrupt-map中断路由、bus-range总线号范围。QEMU 生成的设备树默认已经包含了这些但如果你要定制比如改中断号或者加自己的设备节点就得手动改。一个典型的 PCIe 节点长这样pci30000000 { compatible pci-host-ecam-generic; device_type pci; reg 0x0 0x30000000 0x0 0x10000000; bus-range 0x00 0xff; #address-cells 3; #size-cells 2; ranges 0x01000000 0x0 0x00000000 0x0 0x40000000 0x0 0x00010000, 0x02000000 0x0 0x40010000 0x0 0x40010000 0x0 0x3fef0000; interrupt-map-mask 0x1800 0x0 0x0 0x7; interrupt-map 0x0 0x0 0x0 0x1 plic 0x20 0x4, 0x0 0x0 0x0 0x2 plic 0x21 0x4, 0x0 0x0 0x0 0x3 plic 0x22 0x4, 0x0 0x0 0x0 0x4 plic 0x23 0x4; };ranges里的第一段是 IO 空间第二段是 MMIO 空间。interrupt-map把 PCIe 的四个中断引脚映射到 PLIC 的中断号上。如果这里写错了设备能枚举到但中断收不到驱动会卡在等待中断的地方。我踩过一次这个坑lspci能看到设备但dmesg里一直报中断超时查了半天才发现是interrupt-map里的 PLIC 中断号和实际不符。3.4 AI 芯片设备模型的关键寄存器设计模拟一颗 AI 芯片核心是设计它的 MMIO 寄存器布局。真实芯片的寄存器手册通常有几百页但在 QEMU 里做功能验证只需要实现最关键的几个设备 ID 寄存器、控制寄存器、状态寄存器、DMA 描述符基地址寄存器、以及中断状态和清除寄存器。我的做法是定义一个结构体把寄存器偏移和含义固定下来#define AI_REG_DEVICE_ID 0x00 #define AI_REG_CONTROL 0x04 #define AI_REG_STATUS 0x08 #define AI_REG_DMA_BASE 0x10 #define AI_REG_IRQ_STATUS 0x20 #define AI_REG_IRQ_CLEAR 0x24控制寄存器里bit0 是启动位bit1 是复位位。状态寄存器里bit0 表示空闲bit1 表示忙bit2 表示完成。DMA 基地址寄存器存放描述符链表的物理地址。驱动写控制寄存器启动任务设备模型在 QEMU 里模拟计算过程可以就是简单的延时或者内存拷贝完成后置位状态寄存器的完成位并触发中断。这套寄存器设计不需要和真实芯片完全一致但语义要对。因为驱动是照着真实芯片写的如果模拟设备的寄存器语义和真实芯片差太远驱动改来改去等真板子到了又得重写就失去了提前验证的意义。我的原则是寄存器偏移可以不同但读写时序和状态机必须一致。4. 实操过程从零拉起实验台的完整步骤4.1 环境准备与依赖安装先确认你的宿主机环境。我用的是 Ubuntu 22.04内核 5.15。需要的包不多但版本要对sudo apt update sudo apt install -y build-essential git libglib2.0-dev libfdt-dev \ libpixman-1-dev zlib1g-dev ninja-build meson \ gcc-riscv64-linux-gnu gdb-multiarchgcc-riscv64-linux-gnu是交叉编译器用来编译 OpenSBI、U-Boot 和内核。gdb-multiarch用来调试 RISC-V 目标。如果你只跑预编译镜像交叉编译器可以省但自己编译内核时必须要。QEMU 我建议从源码编译因为发行版自带的版本可能缺 RISC-V PCIe 的某些特性。编译命令git clone https://gitlab.com/qemu-project/qemu.git cd qemu git checkout v8.2.0 ./configure --target-listriscv64-softmmu --enable-debug make -j$(nproc)--enable-debug会打开调试符号方便后面用 GDB 跟。编译完的二进制在build/qemu-system-riscv64。注意不要用--enable-debug跑性能测试调试版本会慢很多。功能验证用调试版性能摸底用 release 版两个版本分开编译。4.2 编译 OpenSBI 和 U-BootOpenSBI 是 RISC-V 的 M 模式固件负责最底层的初始化和 S 模式切换。编译git clone https://github.com/riscv-software-src/opensbi.git cd opensbi make CROSS_COMPILEriscv64-linux-gnu- PLATFORMgeneric产物在build/platform/generic/firmware/fw_jump.bin。fw_jump是跳转模式适合配合 U-Boot 使用。U-Boot 负责引导内核并且它自己也能枚举 PCIe。编译git clone https://source.denx.de/u-boot/u-boot.git cd u-boot make qemu-riscv64_smode_defconfig make CROSS_COMPILEriscv64-linux-gnu- -j$(nproc)产物是u-boot.bin。这个配置默认支持 PCIe 枚举启动时你会看到它打印扫描到的设备。4.3 编译 Linux 内核并配置 PCIe 支持内核用主线版本配置里要确保打开这些选项CONFIG_PCIy CONFIG_PCI_HOST_GENERICy CONFIG_PCIE_XILINXy CONFIG_RISCVy CONFIG_SOC_VIRTy CONFIG_SERIAL_8250y CONFIG_SERIAL_8250_CONSOLEy编译git clone https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git cd linux make ARCHriscv CROSS_COMPILEriscv64-linux-gnu- defconfig make ARCHriscv CROSS_COMPILEriscv64-linux-gnu- menuconfig make ARCHriscv CROSS_COMPILEriscv64-linux-gnu- -j$(nproc)产物是arch/riscv/boot/Image。如果你要用 initramfs还需要准备一个 rootfs。我一般用 BusyBox 做一个最小 rootfs够跑lspci和insmod就行。4.4 生成 QEMU 启动脚本与设备配置这是 AGENT 编排的核心部分。我把所有参数放在一个config.env里QEMU_BIN./qemu/build/qemu-system-riscv64 OPENSBI./opensbi/build/platform/generic/firmware/fw_jump.bin KERNEL./linux/arch/riscv/boot/Image ROOTFS./rootfs.cpio.gz MEMORY2G SMP4 AI_DEVICEai-accel然后主控脚本读取这个配置生成启动命令#!/bin/bash source config.env $QEMU_BIN \ -M virt,pcieon \ -m $MEMORY \ -smp $SMP \ -bios $OPENSBI \ -kernel $KERNEL \ -initrd $ROOTFS \ -append consolettyS0 root/dev/ram rdinit/init \ -device $AI_DEVICE,buspcie.0,addr0x1 \ -nographic \ -serial mon:stdio-M virt,pcieon打开 PCIe 支持。-device ai-accel挂载自定义的 AI 加速卡模型buspcie.0指定挂在根总线上addr0x1是设备号。这个 addr 会影响设备在总线上的位置进而影响 BAR 分配一般从 0x1 开始0x0 留给主机桥。4.5 启动验证与设备枚举检查启动后在 guest 里执行lspci -vvv你应该能看到类似这样的输出00:01.0 Processing accelerators: Custom Vendor AI Accelerator (rev 01) Control: I/O- Mem BusMaster SpecCycle- MemWINV- VGASnoop- Status: Cap 66MHz- UDF- FastB2B- ParErr- DEVSELfast Latency: 0 Interrupt: pin A routed to IRQ 32 Region 0: Memory at 40010000 (32-bit, non-prefetchable) [size4K] Capabilities: [40] Power Management version 3 Capabilities: [50] MSI: Enable- Count1/1 Maskable- 64bit重点看三处Region 0的地址是否分配成功Interrupt是否路由到了正确的 IRQCapabilities里 MSI 是否可用。如果 Region 0 显示[disabled]说明 BAR 分配失败通常是设备树里的ranges空间不够。然后加载驱动insmod ai_accel.ko dmesg | tail -20驱动 probe 成功的话dmesg里会打印设备 ID 和 BAR 地址。如果卡在 probe多半是寄存器读写不对用devmem手动读一下设备 ID 寄存器验证devmem 0x40010000 32应该返回你设定的设备 ID 值。4.6 用 AGENT 编排自动化验证流程手动跑通一次之后把验证步骤写成脚本让 AGENT 自动执行。我的验证脚本逻辑是这样的import subprocess import time def run_qemu(config): cmd build_command(config) proc subprocess.Popen(cmd, stdoutsubprocess.PIPE, stderrsubprocess.STDOUT) return proc def wait_for_boot(proc, timeout60): start time.time() while time.time() - start timeout: line proc.stdout.readline().decode() if login: in line or ~ # in line: return True return False def check_pcie(proc): proc.stdin.write(blspci -vvv\n) proc.stdin.flush() time.sleep(2) output proc.stdout.read(4096).decode() return AI Accelerator in output def check_driver(proc): proc.stdin.write(binsmod ai_accel.ko\n) proc.stdin.flush() time.sleep(2) proc.stdin.write(bdmesg | tail -20\n) proc.stdin.flush() time.sleep(2) output proc.stdout.read(4096).decode() return probe success in output这个脚本就是最朴素的 AGENT它按顺序执行启动、等待、检查、加载、再检查每一步都有明确的成功判据。失败时把 QEMU 的输出保存下来方便事后分析。等这套稳定了你可以接入更复杂的编排比如根据lspci的输出自动决定加载哪个驱动或者根据dmesg的错误码自动调整设备树参数重新启动。5. 常见问题与排查技巧实录5.1 PCIe 设备枚举不到怎么办这是最常见的问题。排查顺序我总结成一张表现象可能原因排查方法lspci 完全看不到设备QEMU 启动参数没加 -device检查命令行是否有 -device 参数lspci 看到设备但 BAR 未分配设备树 ranges 空间不足检查 dmesg 里 BAR 分配失败日志设备枚举到但驱动 probe 失败寄存器读写不匹配用 devmem 手动读设备 ID中断收不到interrupt-map 配置错误检查 /proc/interrupts 里 IRQ 计数我遇到最多的是第二种。QEMU 默认的 PCIe MMIO 空间是 0x40000000 到 0x7fffffff如果你挂的设备 BAR 要求 64 位地址或者大空间可能分配不下。解决办法是在设备树里扩大ranges的 MMIO 段或者减小设备的 BAR 大小。5.2 中断路由的坑RISC-V 的中断控制器是 PLICPCIe 的四个中断引脚通过interrupt-map映射到 PLIC 的中断号。QEMU 的 virt 机器默认把 PCIe 中断映射到 PLIC 的 32 到 35 号。如果你在设备树里改成了别的号但 QEMU 的设备模型还是按默认号触发就会出现中断永远收不到的情况。验证方法在 guest 里cat /proc/interrupts看对应 IRQ 的计数是否增长。如果一直是 0说明中断没触发或者路由错了。我一般会在设备模型里加一句打印确认中断触发时 QEMU 侧的日志两边对照就能定位。5.3 驱动加载顺序与依赖问题AI 加速卡的驱动可能依赖 PCIe 子系统、DMA 引擎、以及一些通用的字符设备框架。如果驱动编译成模块加载顺序不对会报Unknown symbol。解决办法是在驱动里正确声明依赖或者用modprobe而不是insmod让内核自动处理依赖。另一个坑是驱动 probe 的时机。如果驱动在 PCIe 枚举完成之前就尝试 probe会找不到设备。内核的 PCIe 驱动框架通常能处理这个但如果你自己写了 early init就要注意用late_initcall而不是module_init。5.4 QEMU 性能与调试的平衡QEMU 的 TCG 模式纯软件模拟跑 RISC-V Linux 启动大概要几十秒跑推理任务会更慢。如果你只是验证功能这个速度可以接受。但如果要跑性能相关的测试就得考虑用 KVM 加速——不过 KVM 只支持同架构虚拟化x86 宿主机上跑 RISC-V 用不了。这时候可以考虑用 QEMU 的-icount参数做确定性执行或者干脆把性能测试留到真板子上。调试的时候-s -S参数会让 QEMU 启动时暂停并监听 GDB 端口。用gdb-multiarch连接gdb-multiarch vmlinux (gdb) target remote :1234 (gdb) b pci_scan_root_bus_bridge (gdb) c这样可以在 PCIe 枚举的关键函数上下断点单步跟寄存器读写。我调设备树的时候经常这么干比看日志快得多。5.5 几个我踩过的具体坑第一个坑OpenSBI 版本和 QEMU 版本不匹配。有次我用 QEMU 8.0 配 OpenSBI 1.2启动直接卡在 M 模式没有任何输出。换成 OpenSBI 1.3 就好了。RISC-V 生态版本兼容性还在完善中建议 QEMU 和 OpenSBI 都用较新的稳定版。第二个坑rootfs 里缺 lspci。BusyBox 默认不带lspci需要单独编译 pciutils 或者用完整发行版的 rootfs。我一开始用最小 BusyBox进去发现没有lspci又得重新做 rootfs。后来我固定用一个带 pciutils 和 kmod 的 rootfs 模板省事很多。第三个坑设备模型的 DMA 地址映射。QEMU 里设备发起的 DMA 用的是 guest 物理地址但设备模型里如果直接当宿主虚拟地址用会访问到错误的内存。正确做法是用pci_dma_read和pci_dma_write接口让 QEMU 帮你做地址转换。我一开始自己算地址结果数据全错查了两天才发现是这个问题。第四个坑MSI 和 INTx 的切换。有些驱动默认用 MSI但 QEMU 的设备模型如果没实现 MSI 能力驱动会 fallback 到 INTx。如果两个都没实现好中断就彻底没了。我的建议是设备模型里至少实现 INTxMSI 作为可选增强。验证时先用 INTx 跑通再切 MSI。6. 这套实验台还能怎么扩展跑通基本流程之后我在这套实验台上做了几件事效果不错。一是加多设备在 PCIe 总线上挂两张 AI 卡验证驱动是否支持多实例以及 BAR 空间分配会不会冲突。二是模拟热插拔QEMU 支持通过 QMP 接口动态添加和移除 PCIe 设备可以测试驱动的热插拔处理逻辑。三是接入真实模型在 guest 里跑一个量化后的小模型通过驱动下发到模拟设备验证端到端的推理链路。热插拔这块值得多说一句。QEMU 的 QMP 接口可以发送device_add和device_del命令动态操作 PCIe 设备。配合内核的 PCIe 热插拔支持可以验证驱动在设备突然消失时的资源释放逻辑。这个在真板子上不好测因为物理插拔有风险在 QEMU 里就安全多了。我个人在实际操作中的体会是这套实验台最大的价值不是替代真硬件而是把软件问题提前暴露。真板子到了之后你面对的应该是时序和性能问题而不是“驱动为什么加载不了”这种基础问题。把基础问题在 QEMU 里解决掉真板子上的调试效率会高很多。另外AGENT 编排不要一开始就追求大而全先把最核心的启动和验证流程固化下来跑顺了再逐步加功能。我见过太多人一上来就搭复杂框架结果框架还没搭完项目已经不需要了。