Zynq 实战 24|软硬件协同仿真:QEMU + Vivado 仿真联动,没有板子也能验证
Zynq 实战 24|软硬件协同仿真:QEMU + Vivado 仿真联动,没有板子也能验证
这是《Zynq FPGA 嵌入式系统设计实战》系列第 24 篇。 板子:Pynq-Z2(XC7Z020-1CLG400C)。工具链:Vivado / Vitis / PetaLinux 2023.2。 上一篇:《Zynq 实战 23|混合关键性系统设计》
0. 这一篇要解决什么问题
嵌入式硬件项目的节奏通常是这样的:软件工程师坐在那里等,等 PCB 打样(2 周),等焊接(3 天),等初步调通(1 周)。合计下来,你的 Linux 驱动、用户态应用、IP 核软件接口,可能得等整整一个月才能在真实硬件上跑一遍。
这篇讲的是怎么不等。
核心工具是 Xilinx QEMU——它能仿真完整的 Zynq-7000 PS 子系统,包括 Cortex-A9 双核、GIC、GEM 千兆网、UART、I2C、SPI、SD 控制器。PS 这边的软件在 QEMU 里跑,PL 这边的 IP 核在 Vivado 的 behavioral simulation 里跑,两者通过模型接口联动。
本文覆盖:
- Xilinx QEMU 启动 Zynq-7000 + 跑 PetaLinux
- GDB 远程调试 QEMU 里的 ARM 代码
- SystemC TLM-2.0 写 AXI-Lite 功能模型(PL IP 的软件替代)
- Vivado 仿真 + QEMU 的三步联动工作流
- 仿真速度实测数字
本文不覆盖 ZynqMP(UltraScale+)的 co-sim——那套工具链差异较大,另开一篇。
1. 为什么要协同仿真:时间账
数字是真实项目里估算的。PCB 打样在国内一般是 7-14 天,板级调试取决于复杂度。关键在于:QEMU 可以在第 1 天就启动,RTL 仿真可以在 HDL 写完当天就跑,两条轨道并行,等硬件到手时软件已经过了大量验证。
2. Xilinx QEMU:启动 Zynq-7000
2.1 安装 Xilinx QEMU
Xilinx QEMU 是对上游 QEMU 打了补丁的版本,支持 Zynq-7000 外设仿真。PetaLinux 2023.2 自带了它:
# PetaLinux 2023.2 安装后,QEMU 在:
which qemu-system-aarch64 # ZynqMP 用这个
# Zynq-7000 (32-bit ARM) 用:
ls $PETALINUX/components/qemu/bin/qemu-system-arm
# 或者独立安装(Ubuntu 22.04):
sudo apt-get install qemu-system-arm
# 注意:发行版 QEMU 的 xilinx-zynq-a9 支持比 Xilinx 官方版稍弱
# 建议优先用 PetaLinux 自带版本
2.2 准备启动文件
从 PetaLinux 2023.2 构建完成后,需要的文件在:
<petalinux-proj>/images/linux/
├── u-boot.elf ← U-Boot ELF(QEMU 直接加载)
├── zImage ← Linux 内核压缩镜像
├── system.dtb ← 设备树 blob(编译好的)
├── rootfs.cpio.gz ← initramfs 根文件系统
└── BOOT.BIN ← 板级启动用,QEMU 不用这个
🚧 避坑:QEMU 启动 Zynq-7000 时不使用 BOOT.BIN——BOOT.BIN 是给真实 SD 卡用的,里面打包了 FSBL + U-Boot + bitstream。QEMU 直接加载 ELF,不需要 FSBL,也不需要 bitstream(PL 逻辑由 SystemC 模型或 stub 替代)。如果你用 BOOT.BIN 试图启动 QEMU,只会得到一个静默的失败。
2.3 完整启动命令
#!/bin/bash
# qemu-zynq7000-boot.sh — 启动 Zynq-7000 QEMU 仿真
# 适用:PetaLinux 2023.2,xilinx-zynq-a9 machine
PETALINUX_IMG=./images/linux
qemu-system-arm \
-M xilinx-zynq-a9 \
-nographic \
-dtb ${PETALINUX_IMG}/system.dtb \
-kernel ${PETALINUX_IMG}/u-boot.elf \
-device loader,file=${PETALINUX_IMG}/zImage,addr=0x00200000 \
-device loader,file=${PETALINUX_IMG}/system.dtb,addr=0x00001000 \
-device loader,file=${PETALINUX_IMG}/rootfs.cpio.gz,addr=0x04000000 \
-net nic,model=cadence_gem,macaddr=52:54:00:12:34:56 \
-net user,hostfwd=tcp::2222-:22 \
-serial mon:stdio \
-m 512M
# 如需 GDB 调试,加:-gdb tcp::1234 -S
参数说明:
| 参数 | 说明 |
|---|---|
-M xilinx-zynq-a9 | 指定 machine type 为 Zynq-7000(Cortex-A9 dual core) |
-kernel u-boot.elf | 直接加载 U-Boot ELF,相当于 QEMU 做了 FSBL 的工作 |
-device loader,file=zImage,addr=0x00200000 | 把 zImage 预加载到 DDR 0x200000,U-Boot 从这里启动内核 |
-net nic,model=cadence_gem | GEM 以太网控制器,对应真实硬件的 GEM0 |
-net user,hostfwd=tcp::2222-:22 | 把板子 SSH 22 端口映射到宿主机 2222 |
-nographic | 无图形界面,串口输出到终端 |
-m 512M | 分配 512MB DDR(Pynq-Z2 实际 512MB,保持一致) |
2.4 QEMU 支持的 Zynq-7000 外设
| 外设 | QEMU 支持 | 说明 |
|---|---|---|
| Cortex-A9 双核 | ✅ 完整 | 包括 FPU、NEON、SMP |
| GIC(中断控制器) | ✅ 完整 | PL 中断(IRQ_F2P)需要模型配合 |
| GEM 千兆以太网 | ✅ 完整 | -net user 或 -net tap(需要 root) |
| UART(PL011) | ✅ 完整 | 串口可以正常收发 |
| SD 控制器(sdhci) | ✅ 完整 | -drive file=rootfs.ext4,format=raw |
| I2C 控制器 | ✅ 部分 | 基本读写,slave 模拟有限 |
| SPI 控制器 | ✅ 部分 | 单 master 模式 |
| USB OTG | ✅ 部分 | 需要 -usb -usbdevice disk:... |
| AXI-Lite MMIO(PL IP) | ❌ 无 | 需要 SystemC 模型或 stub |
| DMA 精确时序 | ❌ 无 | DMA 功能存在,但中断时序不精确 |
| EMIO GPIO | ✅ 基本 |
2.5 启动到 Linux Shell
U-Boot 2023.01 (Apr 2026) - Build: PetaLinux 2023.2
DRAM: 512 MiB
...
Booting Linux...
[ 0.000000] Booting Linux on physical CPU 0x0
[ 0.000000] Linux version 5.15.36 (gcc version 12.2.0)
...
[ 2.847613] mmc0: SDHCI controller on e0100000.mmc
[ 3.124000] eth0: Cadence GEM at 0xe000b000
PetaLinux 2023.2 pynq-z2 /dev/ttyPS0
pynq-z2 login: root
Password: root
root@pynq-z2:~# uname -a
Linux pynq-z2 5.15.36 #1 SMP PREEMPT Mon Apr 28 01:00:00 UTC 2026 armv7l armv7l armv7l GNU/Linux
root@pynq-z2:~# ping 10.0.2.2 # QEMU 宿主机默认 IP
PING 10.0.2.2 (10.0.2.2): 56 data bytes
64 bytes from 10.0.2.2: seq=0 ttl=255 time=0.842 ms
# 从宿主机 SSH 进入
# ssh -p 2222 root@localhost
从启动命令到看到 shell,在现代笔记本上约需 15-30 秒(主要是内核启动时间,QEMU 本身启动几乎是即时的)。
3. GDB 远程调试 QEMU
3.1 启动带 GDB 支持的 QEMU
在启动命令里加两个参数:
qemu-system-arm \
... (其余参数不变)\
-gdb tcp::1234 \ # 开启 GDB stub,监听 1234 端口
-S # 启动后立即暂停,等 GDB 连接
加了 -S 后,QEMU 会在第一条指令前暂停,等你用 GDB 连进来再继续。
3.2 GDB 连接
# 宿主机上:安装多架构 GDB
sudo apt-get install gdb-multiarch
# 连接 QEMU
gdb-multiarch
(gdb) set architecture arm
(gdb) target remote localhost:1234
Remote debugging using localhost:1234
0x00000000 in ?? () ← QEMU 已暂停在复位向量
(gdb) file images/linux/u-boot.elf # 加载符号
Reading symbols from u-boot.elf...
done.
(gdb) break board_init_f # 在 U-Boot 初始化函数设断点
Breakpoint 1 at 0x4a1234: file board/xilinx/zynq/board.c, line 87.
(gdb) continue
Continuing.
Breakpoint 1, board_init_f (boot_flags=0) at board/xilinx/zynq/board.c:87
87 gd->bd = (struct bd_info *)(gd - sizeof(struct bd_info));
# 查看寄存器
(gdb) info registers
r0 0x0 0
r1 0xe12 3602
r2 0x0 0
sp 0x3fffb0 0x3fffb0
pc 0x4a1234 0x4a1234 <board_init_f>
cpsr 0x600001d3 1610612179
# 单步
(gdb) nexti
(gdb) stepi
# 查看内存(读 UART 基地址寄存器)
(gdb) x/4wx 0xe0001000
0xe0001000: 0x00000061 0x00000000 0x00000000 0x00000000
3.3 调试内核模块
调试 Linux 内核驱动时,需要在内核加载模块后更新 GDB 的符号地址:
# 在 QEMU 里的 Linux shell 里:
cat /sys/module/my_driver/sections/.text
# 输出:0xbf000000
# 回到 GDB:
(gdb) add-symbol-file my_driver.ko 0xbf000000
(gdb) break my_driver_probe
(gdb) continue
4. SystemC TLM-2.0:为 PL IP 建功能模型
Vivado behavioral simulation 跑完整 RTL 比真实速度慢 1000x 以上,没法和 QEMU(100-200 MIPS)直接联动。解决方案是用 SystemC TLM-2.0 写一个功能模型——只实现寄存器的逻辑行为,不建模时序,速度比 RTL 快 3-4 个数量级。
4.1 AXI-Lite 寄存器功能模型
下面是一个完整可编译的 SystemC TLM-2.0 AXI-Lite 寄存器功能模型,模拟第 06 篇的 PWM IP:
// pwm_tlm_model.h — AXI-Lite PWM IP 的 SystemC TLM-2.0 功能模型
// 依赖:SystemC 2.3.3+ 或 Accellera SystemC 2.3.3
// 编译:g++ -std=c++17 -I$SYSTEMC_HOME/include -L$SYSTEMC_HOME/lib -lsystemc -o pwm_sim pwm_tlm_model.cpp
#pragma once
#include <systemc>
#include <tlm>
#include <tlm_utils/simple_target_socket.h>
#include <cstdint>
#include <iostream>
// PWM IP 寄存器偏移
#define PWM_CTRL_OFF 0x00
#define PWM_PERIOD_OFF 0x04
#define PWM_HIGH_OFF 0x08
#define PWM_STATUS_OFF 0x0C
SC_MODULE(PwmIpModel) {
// TLM-2.0 目标 socket(32-bit 数据宽度,对应 AXI-Lite)
tlm_utils::simple_target_socket<PwmIpModel, 32> s_axi;
// 中断输出
sc_core::sc_out<bool> irq_out;
// 内部寄存器(对应硬件寄存器)
uint32_t reg_ctrl = 0;
uint32_t reg_period = 0;
uint32_t reg_high = 0;
uint32_t reg_status = 0;
SC_CTOR(PwmIpModel) : s_axi("s_axi"), irq_out("irq_out") {
s_axi.register_b_transport(this, &PwmIpModel::b_transport);
SC_THREAD(pwm_behavior);
}
// TLM blocking transport — 处理 AXI-Lite 读写
void b_transport(tlm::tlm_generic_payload &trans,
sc_core::sc_time &delay)
{
tlm::tlm_command cmd = trans.get_command();
uint64_t addr = trans.get_address();
uint8_t *data = trans.get_data_ptr();
unsigned int length = trans.get_data_length();
if (length != 4) {
trans.set_response_status(tlm::TLM_BURST_ERROR_RESPONSE);
return;
}
if (cmd == tlm::TLM_WRITE_COMMAND) {
uint32_t val = *reinterpret_cast<uint32_t*>(data);
switch (addr & 0xFF) {
case PWM_CTRL_OFF:
reg_ctrl = val;
std::cout << "[PWM TLM] CTRL <- 0x" << std::hex << val
<< " @ " << sc_core::sc_time_stamp() << "\n";
if (val & 0x1) pwm_ev.notify(); // 启动事件
break;
case PWM_PERIOD_OFF: reg_period = val; break;
case PWM_HIGH_OFF: reg_high = val; break;
default: break;
}
} else if (cmd == tlm::TLM_READ_COMMAND) {
uint32_t *out = reinterpret_cast<uint32_t*>(data);
switch (addr & 0xFF) {
case PWM_CTRL_OFF: *out = reg_ctrl; break;
case PWM_PERIOD_OFF: *out = reg_period; break;
case PWM_HIGH_OFF: *out = reg_high; break;
case PWM_STATUS_OFF: *out = reg_status; break;
default: *out = 0xDEADBEEF; break;
}
}
trans.set_response_status(tlm::TLM_OK_RESPONSE);
delay += sc_core::sc_time(10, sc_core::SC_NS); // 模拟 AXI 延迟 10ns
}
// PWM 行为模型:定时产生中断
sc_core::sc_event pwm_ev;
void pwm_behavior() {
irq_out.write(false);
while (true) {
wait(pwm_ev);
while (reg_ctrl & 0x1) {
if (reg_period == 0) { wait(1, sc_core::SC_MS); continue; }
// 等一个 PWM 周期
double period_ns = (double)reg_period * 10.0; // @ 100MHz
wait(period_ns, sc_core::SC_NS);
// 产生中断脉冲
reg_status |= 0x2;
irq_out.write(true);
wait(10, sc_core::SC_NS);
irq_out.write(false);
reg_status &= ~0x2;
}
}
}
};
// pwm_tlm_model.cpp — 顶层 testbench
#include "pwm_tlm_model.h"
#include <tlm_utils/simple_initiator_socket.h>
SC_MODULE(Testbench) {
tlm_utils::simple_initiator_socket<Testbench, 32> m_axi;
sc_core::sc_in<bool> irq_in;
void axi_write(uint64_t addr, uint32_t val) {
tlm::tlm_generic_payload trans;
sc_core::sc_time delay = sc_core::SC_ZERO_TIME;
trans.set_command(tlm::TLM_WRITE_COMMAND);
trans.set_address(addr);
trans.set_data_ptr(reinterpret_cast<uint8_t*>(&val));
trans.set_data_length(4);
m_axi->b_transport(trans, delay);
}
uint32_t axi_read(uint64_t addr) {
uint32_t val = 0;
tlm::tlm_generic_payload trans;
sc_core::sc_time delay = sc_core::SC_ZERO_TIME;
trans.set_command(tlm::TLM_READ_COMMAND);
trans.set_address(addr);
trans.set_data_ptr(reinterpret_cast<uint8_t*>(&val));
trans.set_data_length(4);
m_axi->b_transport(trans, delay);
return val;
}
SC_CTOR(Testbench) : m_axi("m_axi"), irq_in("irq_in") {
SC_THREAD(run);
}
void run() {
wait(10, sc_core::SC_NS);
// 配置 PWM:100kHz 周期,50% 占空比
axi_write(PWM_PERIOD_OFF, 1000);
axi_write(PWM_HIGH_OFF, 500);
axi_write(PWM_CTRL_OFF, 1);
std::cout << "[TB] PWM started, waiting for IRQ...\n";
wait(irq_in.posedge_event());
std::cout << "[TB] IRQ received @ " << sc_core::sc_time_stamp() << "\n";
uint32_t status = axi_read(PWM_STATUS_OFF);
std::cout << "[TB] STATUS = 0x" << std::hex << status << "\n";
sc_core::sc_stop();
}
};
int sc_main(int argc, char *argv[]) {
PwmIpModel dut("dut");
Testbench tb("tb");
sc_core::sc_signal<bool> irq_sig;
tb.m_axi.bind(dut.s_axi);
dut.irq_out.bind(irq_sig);
tb.irq_in.bind(irq_sig);
sc_core::sc_start();
return 0;
}
编译和运行:
# 假设 SystemC 安装在 /usr/local/systemc-2.3.3
SYSTEMC_HOME=/usr/local/systemc-2.3.3
g++ -std=c++17 \
-I${SYSTEMC_HOME}/include \
-L${SYSTEMC_HOME}/lib \
-Wl,-rpath,${SYSTEMC_HOME}/lib \
-lsystemc -lpthread \
-o pwm_sim pwm_tlm_model.cpp
./pwm_sim
# 输出:
# [PWM TLM] CTRL <- 0x1 @ 30 ns
# [TB] PWM started, waiting for IRQ...
# [TB] IRQ received @ 10030 ns
# [TB] STATUS = 0x2
功能模型 vs RTL 仿真速度对比:
| 方式 | 仿真速度(模拟时间/实际时间) | 适用场景 |
|---|---|---|
| Vivado XSim RTL 仿真 | ~1,000 ns / 1 s(慢 1,000,000x) | 时序精度验证、波形分析 |
| SystemC TLM 功能模型 | ~1,000,000,000 ns / 1 s(慢 1x) | 软件接口验证、固件调试 |
| QEMU ARM 仿真 | 100-200 MIPS(约真实速度 50%) | 完整 Linux 系统软件验证 |
5. Vivado 仿真 + QEMU 三步联动工作流
完整的协同仿真工具链(Vivado 仿真器 ↔ SystemC ↔ QEMU)需要 Xilinx 的授权工具,配置复杂。对于大多数工程团队,下面这个三步法更实用:
步骤 ① —— Vivado Behavioral Simulation
验证 RTL 逻辑。在 Vivado 里写 testbench,给 IP 施加 AXI-Lite 读写激励,检查输出波形。这一步不需要 PS,只管 PL。
# vivado_sim_run.tcl — 批量跑仿真,不需要 GUI
open_project ./myproject.xpr
set_property top tb_pwm_ip [get_filesets sim_1]
launch_simulation
run 100us
close_sim
步骤 ② —— QEMU + SystemC 功能模型
用第 4 节的 TLM 模型替代真实硬件,在 QEMU 里运行 Linux,加载驱动,验证软件逻辑。软件工程师可以在 HDL 还没写完的情况下就开始工作。
步骤 ③ —— 板级联合调试
两步仿真都过了之后,上真实板子。这时候应该集中精力排查:
- 真实电气信号(示波器/逻辑分析仪)
- 时序裕量(
report_timing) - 真实 DDR 带宽限制
- 温度/电源稳定性
6. 三处避坑
🚧 避坑 1:QEMU GEM 网卡 MAC 地址必须手动设
不指定
-net nic,...,macaddr=52:54:00:xx:xx:xx时,QEMU 每次启动会生成不同的随机 MAC,导致 DHCP 每次拿到不同 IP,脚本自动化里 SSH 连接会失败。更严重的是,某些 QEMU 版本会生成和宿主机网卡冲突的 MAC,造成网络诡异故障。固定 MAC 格式:
52:54:00:开头(QEMU 官方保留前缀),后三位自定义。每个 QEMU 实例用不同 MAC。
🚧 避坑 2:SystemC 版本与 Vivado 不兼容
Vivado 2023.2 内置的 SystemC 是 2.3.2,而你的系统可能装了 2.3.3 或 3.0.0。如果用 Vivado 提供的 SystemC 接口做仿真联动,版本不匹配会导致链接时
undefined symbol或运行时SIGABRT。解决方法:要么用 Vivado 自带的 SystemC(
source /opt/Xilinx/Vivado/2023.2/settings64.sh会设好$SYSTEMC_HOME),要么完全独立使用系统 SystemC 做功能模型(不通过 Vivado 接口)。两套 SystemC 不要混用。检查命令:
# Vivado 内置 SystemC 版本 cat /opt/Xilinx/Vivado/2023.2/data/systemc/include/sysc/kernel/sc_ver.h | grep SC_VERSION
🚧 避坑 3:QEMU 的 DMA 中断时序不精确,不能用来验证 DMA 时序逻辑
QEMU 里 AXI DMA IP 的中断(
mm2s_introut/s2mm_introut)触发时机和真实硬件不一致——QEMU 的 DMA 是”完成即中断”,而真实 Zynq DMA 的中断有最小延迟和总线仲裁时间。如果你的驱动代码依赖精确的 DMA 完成时序(比如两个 DMA 通道的完成顺序),不能在 QEMU 里验证,必须上真实板子。QEMU 只能验证”DMA 完成后驱动做了什么”,不能验证”DMA 何时完成”。
7. 本篇 checklist
- 能用
qemu-system-arm -M xilinx-zynq-a9启动 PetaLinux 镜像到 shell - 知道 QEMU 需要
u-boot.elf + zImage + dtb,不需要BOOT.BIN - 会用
-gdb tcp::1234 -S启动 GDB stub,并用gdb-multiarch连接 - 能用
add-symbol-file加载内核模块符号,在驱动里设断点 - 能编译运行 SystemC TLM-2.0 功能模型,验证 AXI-Lite 寄存器接口
- 理解三步联动工作流:Vivado Sim → QEMU + 功能模型 → 板级
- 知道 QEMU GEM MAC 地址要手动固定
- 知道 QEMU DMA 中断时序不精确,不能用于时序验证
8. 下一篇预告
下一篇 《Zynq 实战 25|版本控制与 CI/CD:Git 管理 Vivado 工程 + GitHub Actions 自动验证》,解决团队协作的实际问题:
- Vivado
.xpr怎么 Git(答案是不 commit,改 commit TCL 脚本) - 完整
.gitignore可直接使用 - GitHub Actions 用 Yosys 做免 License 的 RTL lint
- 自建 runner 跑 Vivado 合成检查
参考资料
| 文档号 | 名称 | 用途 |
|---|---|---|
| UG1144 | PetaLinux Tools Reference Guide 2023.2 | QEMU 仿真章节,qemu-system-arm 参数说明 |
| UG585 | Zynq-7000 SoC TRM | 第 4 章:PS 存储器映射,QEMU 支持的外设地址 |
| QEMU Wiki | Xilinx QEMU | 官方 QEMU 用户文档,machine type 列表 |
| Accellera | SystemC 2.3.3 Language Reference Manual | TLM-2.0 b_transport 接口规范 |
| IEEE 1666 | SystemC Standard (2011) | TLM-2.0 正式规范 |
| GDB Manual | Remote Debugging | target remote、add-symbol-file 用法 |
这是《Zynq FPGA 嵌入式系统设计实战》系列第 24 篇。 QEMU 配置或 SystemC 编译遇到问题,欢迎留言。