← ブログ一覧へ
FPGAZynqQEMU仿真SystemCTLMGDBPetaLinux协同仿真

Zynq 实战 24|软硬件协同仿真:QEMU + Vivado 仿真联动,没有板子也能验证

この記事は中国語で書かれ、Google 翻訳で自動翻訳されています。
中国語の原文を見る →

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. 为什么要协同仿真:时间账

传统流程 vs 协同仿真流程 — 时间对比 传统流程(等硬件) HDL 设计 (3d) PCB 打样等待 (14d) 板级调试 (7d) 软件开发才能开始 ← 总计 ≥ 24 天才能跑第一行驱动代码 协同仿真流程(并行) 硬件轨 HDL 设计 (3d) Vivado Sim (1d) PCB 打样 (14d) 软件轨 QEMU + SystemC 建模 驱动 / 应用开发验证 板级合并调试 结论:软件开发可以在 HDL 设计完成后第 1 天就并行启动 硬件等待期(~21 天)全部转化为有效的软件开发时间,板级联调时驱动已基本成熟 QEMU 仿真速度:100–200 MIPS(约真实速度的 50-100%) RTL 仿真速度:比真实速度慢约 1000×(Vivado XSim)
图 1. 传统串行开发 vs 协同仿真并行开发的时间对比

数字是真实项目里估算的。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_gemGEM 以太网控制器,对应真实硬件的 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 Sim 验证 PL IP 的 RTL 逻辑 • 波形正确 • AXI 协议合规 • 中断时序验证 产出:RTL 功能正确性确认 并行 ② QEMU + SystemC 模型 验证 PS 软件接口 • 驱动 probe / ioremap • 寄存器读写序列 • 中断处理逻辑 产出:驱动和应用代码成熟 硬件到货 ③ 板级联合调试 真实板子验证 • 时序 / 电气验证 • 驱动直接上板 • 精力集中在真实 Bug 产出:产品就绪 步骤 ① 和 ② 完全并行:HDL 工程师跑 Vivado Sim 的同时,软件工程师在 QEMU 里写驱动 步骤 ③ 到达时,大部分 Bug 已经在仿真阶段消灭,板级调试只剩真实硬件相关问题
图 2. 新 IP 开发的三步联动工作流

步骤 ① —— 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.33.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 合成检查

参考资料

文档号名称用途
UG1144PetaLinux Tools Reference Guide 2023.2QEMU 仿真章节,qemu-system-arm 参数说明
UG585Zynq-7000 SoC TRM第 4 章:PS 存储器映射,QEMU 支持的外设地址
QEMU WikiXilinx QEMU官方 QEMU 用户文档,machine type 列表
AccelleraSystemC 2.3.3 Language Reference ManualTLM-2.0 b_transport 接口规范
IEEE 1666SystemC Standard (2011)TLM-2.0 正式规范
GDB ManualRemote Debuggingtarget remoteadd-symbol-file 用法

这是《Zynq FPGA 嵌入式系统设计实战》系列第 24 篇。 QEMU 配置或 SystemC 编译遇到问题,欢迎留言。