
今天想认真聊聊 Virtual Software Development 这件事尤其是它对我们嵌入式开发者的意义。五年前我刚接触虚拟化开发这个词时第一反应是单片机还能虚拟灯光、电机、传感器都不是真的跑得再好看有什么用但这些年实践下来我自己的态度已经变成如果新项目没有在硬件回来之前就把虚拟目标跑起来我反而会觉得项目排期少了很重要的缓冲层。所谓虚拟软件开发往直白里说就是在电脑里用指令集模拟器、外设仿真模型或者整机虚拟平台先把嵌入式软件跑起来不依赖真实芯片和板卡。它能做的不是替代真机而是解决嵌入式开发里最头疼的那几件事——硬件没到、硬件不够、远程无法碰板子、CI 里没法做回归。适合的人群也很明确正在等回板的固件工程师、需要在没有开发板的情况下做自动化测试的团队、以及想把故障注入和异常场景做进测试流程的人。下面从原理到实操再到我踩过的坑一次讲清楚。1. 嵌入式里的虚拟到底虚的是什么1.1 为什么嵌入式开发者也急需要虚拟环境很多人觉得嵌入式开发离不开硬件这是刻板印象。实际项目里我至少见过四种情况逼着你必须走虚拟化这条路。第一硬件交付周期太长。芯片交期、PCB 打样、贴片焊接随便一环出问题就是一两个月。而嵌入式软件往往占整个项目一半以上的研发周期等硬件回来再动手项目必然延期。第二硬件资源不够分。团队里十几个人开发板总共五六块谁用谁先排队尤其那块带有点问题的 demo 板根本不敢拿去跑破坏性测试。第三远程开发和跨地域协作。这两年远程办公成了常态板子在实验室锁着你在家改完一个 bug 想验证必须靠值班同事帮忙烧录来回一次至少半天。第四回归测试成本太高。真实板卡上跑一遍完整测试集要人盯那就更没人愿意天天晚上巡检了。虚拟环境最能解决的恰恰是这几件事。软件代码可以提前并行开发CI 可以每天半夜定时跑回归故障注入可以像做实验一样随意制造完全不用担心把板子弄坏。1.2 四种仿真层次先分清再选型虚拟开发并不是某一个工具而是一整套分层结构。我习惯把它们分成四层指令级模拟器ISS只模拟 CPU 指令执行比如 QEMU 的 user-mode、Unicorn速度快但外设基本没有。全系统模拟器模拟 CPU 内存 部分外设能启动操作系统比如 QEMU system-mode、Renode。虚拟原型/外设级仿真平台包含完整的 SoC 外设模型和总线模型比如 Arm Fast Models、Synopsys Virtualizer、Imperas OVP、Intel Simics。工具链与构建环境虚拟化用 Docker/容器把编译工具链、构建环境固定下来保证我这能编、你那也能编。工具开源/商业擅长场景上手难度QEMU开源Linux 级别全系统模拟、大数据量模拟中Renode开源MCU 外设模拟、多节点互联、自动化测试中Arm Fast Models商业/部分免费芯片验证、早期软件移植高Imperas OVP商业指令集与多核模拟高Synopsys Virtualizer / Simics商业大型 SoC 虚拟原型、故障注入很高对大多数嵌入式工程师来说最常碰的是 QEMU 和 Renode 这两款开源工具。它们各有强项后面我会专门讲怎么选。1.3 虚拟开发能干什么、不能干什么很多文章把虚拟环境吹得无所不能我踩过坑之后必须把边界讲清楚。能做的是功能逻辑验证、外设驱动行为验证、协议栈联调、RTOS 任务调度检查、CI 回归、覆盖率统计、性能粗估、故障路径测试。这些在开发流程里占七八成的日常工作量虚拟目标都能覆盖。不能做的是精确时序行为、功耗分析、模拟前端ADC 的噪声、电源纹波、射频和无线链路以及任何依赖真实物理量的功能。换句话说虚拟环境验证的是代码逻辑对不对而不是这段代码在真实硅片上跑多快、耗多少电。2. 最快跑通一套虚拟目标QEMU 和 Renode 实操笔记2.1 选哪个QEMU 还是 Renode这两者我用下来定位差别很大。QEMU 的强项是大:它能模拟 ARM64、RISC-V、x86 这些目标跑得起完整的 Linux、U-Boot社区生态强大性能也快。但它对 MCU 的板级外设支持很随意你很难找到一个精确模拟某型号 MCU 所有外设的 QEMU 版本。Renode 正好补足这个短板。它是面向嵌入式开发的仿真器把外设建模、板级描述、脚本驱动作为一等公民来设计支持 Cortex-M/RISC-V 等核心还能把所有外设的交互复现出来。最重要的一点是Renode 本身就是奔着自动化测试去的它可以跑脚本、做断言、在 CI 里用这是 QEMU 传统玩法里比较费劲的部分。所以我现在的组合是上层 Linux/大系统用 QEMU裸机/MCU 固件用 Renode。如果是 Zephyr 生态两个都可以直接用官方 target 启动省事很多。2.2 Renode 上跑通一个 Cortex-M 固件我拿一个典型场景示范把编译好的固件 ELF 加载到 Renode 的虚拟开发板上跑起来并抓取串口输出。先装 RenodeLinux 下从官方仓库下载压缩包解压即用Windows 和 macOS 也有对应安装包。假设你已经用 arm-none-eabi-gcc 或 Zephyr 的 west 编译得到一个zephyr.elf启动 Renode(monitor) machine Create (monitor) machine LoadPlatformDescription platforms/boards/stm32f4_discovery.repl (monitor) sysbus LoadELF /home/dev/workspace/build/zephyr/zephyr.elf (monitor) showAnalyzer sysbus.uart1 (monitor) startLoadPlatformDescription是把板子的外设配置加载进来sysbus LoadELF等价于烧录showAnalyzer会在屏幕上打开一个虚拟串口终端start开始执行。看到这个串口里打出 Hello World就说明虚拟目标已经通了。如果你用的是 QEMU跑同样一个 Zephyr 的 hello_world 则是这样$ qemu-system-arm -machine mps2-an385 -cpu cortex-m3 -kernel build/zephyr/zephyr.elf -nographic两条命令指向同一个 ELF这就是一码双跑的雏形。后面我会专门讲如何让代码在虚拟目标和真实目标之间平滑切换。2.3 调试与自动测试和真实硬件几乎一样带 GDB虚拟环境最大的好处之一是调试体验甚至比真实硬件还舒服。QEMU 启动时加-s -S然后用 GDB 远程连接(gdb) target remote :1234 (gdb) load (gdb) break main (gdb) continueRenode 更简单在 monitor 里启动 GDB 服务(monitor) machine StartGdbServer 3333然后在 GDB 里target remote :3333。断点、单步、看寄存器、改内存操作和真机一模一样而且没有调试器干扰刷写时序的问题。自动化方面Renode 自带测试框架可以直接在命令行里写断言。比如启动后检查某个字符串是否出现它支持renode-test跑.robot测试文件。这在后面接 CI 的时候会非常顺手。3. 虚拟开发里最容易翻车的四个现场3.1 时序失真带来的假死和假活这是虚拟开发最大的坑没有之一。模拟器里运行一个for循环延时实际耗时往往和真实芯片差一个数量级。我见过最典型的问题某个固件在真机上 10 毫秒的 Task A 抢占 Task B在模拟器里因为执行速度不同调度顺序整个变了结果单元测试断言怎么都不过。更糟的是看门狗。很多项目里 WDT 是不能随便关的但模拟器里的时钟模型和真实芯片有偏差看门狗要么在模拟环境里疯狂复位假死要么怎么都不触发假活程序跑到一个卡死状态你也察觉不到。我的处理办法是在板级配置文件里加一个编译期宏比如VIRTUAL_TARGET虚拟环境下强制关闭 WDT同时把延时逻辑统一封装成接口不在业务代码里散落裸的 for 循环。这样时序相关的失真至少能被隔离在模拟层而不是污染业务逻辑的验证结果。3.2 外设模拟得像与不像是一道分水岭串口是最容易模拟的外设Renode 里甚至能直接开一个虚拟串口终端。但越往深处越不一样UART 在真实硬件上有波特率、有停止位、有 FIFO 溢出模拟器往往直接瞬间送达;DMA 在真机上要和 CPU 竞争总线模拟器里常常是理想状态;ADC 更是没办法模拟真实的采样噪声。这类差异导致一个问题驱动代码里那些处理 FIFO 溢出、DMA 半传输中断、电压波动相关的分支在虚拟环境永远走不到。这不是虚拟环境的错而是它天生就不适合验证这类与物理时序强相关的逻辑。我能给的建议是把外设访问封装在驱动层让上层逻辑跑在虚拟环境里底层物理相关的分支留到 HIL 或真机阶段去覆盖。3.3 Eclipse 系 IDE 的 JCEF 环境坑写嵌入式的人对 Eclipse 系 IDE 应该不陌生比如 GD32 Embedded Builder、STM32CubeIDE、以及一些厂商基于 Eclipse 魔改的集成开发环境。这类 IDE 有一个特别经典的启动报错Missing JCEF runtime。JCEF 全称 Java Chromium Embedded Framework是 Eclipse 内嵌浏览器和部分现代 UI 的基础组件。很多嵌入式 IDE 安装包默认包含 JCEF但一旦安装路径带中文、JDK 版本不对或者安装包本身不完整启动时就会报这个错。我遇到过的场景是在国产 GD32 的 IDE 上个别电脑装完一启动就弹这个错代码全写在里面取不出来特别耽误事。排查思路其实不复杂。先确认 JDK 版本和 IDE 要求的对不对得上然后用命令行加参数强制指定 JCEF 的用户目录./gd32_embedded_builder -Djcef.user.data.dirC:/tmp/jcefLinux 下如果还不行检查系统里有没有libwebkit2gtk很多 Eclipse 系 IDE 在 Linux 上缺少这个库也会报类似的 browser 初始化错误。这个坑虽然听起来和虚拟开发无关但它往往出现在你兴致勃勃搭建虚拟环境的第一天先解决它才不会卡在门口。3.4 存储服务怎么虚构嵌入式数据库的用法有些项目在 MCU 之外还带了存储服务比如 IoT 网关、边缘设备上的数据管理模块。这类场景里一个容易被忽视的问题是虚拟目标里没有真正的 Flash也没有 SD 卡那些依赖持久化存储的功能怎么测我在虚拟环境里常做的是用内存版数据库顶替真实存储比较常用的是 H2、HSQLDB 或 Derby 这组 Java 系嵌入式数据库特别是 H2 的 in-memory 模式。把数据访问层抽象成一个接口真实硬件上走 Flash 文件系统虚拟环境里走 H2 内存库SQL 逻辑照常跑事务和索引行为基本一致。等代码回到真机只要把存储实现切换回去上层业务代码一行都不用改。顺便说一句H2、HSQLDB 和 Derby 常被统称为嵌入式数据库但这个嵌入式指的是嵌入到 Java 应用里和我们说的嵌入式系统是两码事。工具选型时别把它们搞混了我见过有人拿着 H2 去和 SQLite 比嵌入式存储性能实际完全不在一个维度上。4. 把虚拟目标真正用起来CI、回归和处理器在环4.1 SIL、PIL、HIL 到底怎么分工做虚拟开发绕不开这三个缩写。说得简单一点SILSoftware-in-the-Loop把算法或业务代码编译成宿主机原生程序来跑验证逻辑是否正确速度最快。PILProcessor-in-the-Loop把代码编译到目标处理器指令集上在处理器模拟器或真实处理器里跑验证代码在目标 CPU 上是否正确比 SIL 更接近真实。HILHardware-in-the-Loop把真实控制器接入实时仿真环境验证硬件 软件 被控对象的闭环是最后一道门。虚拟开发主力覆盖前两层。SIL 适合在 CI 里大规模跑PIL 适合在模拟器上跑那些与编译器、字节序、位操作强相关的代码。HIL 依然需要真实硬件但 HIL 之前有虚拟开发这层缓冲整体测试效率会明显提升。4.2 一套可落地的 CI 回归配置我对 CI 的一个执念是凡是能在虚拟环境里跑通的用例绝不留到真机阶段去蹭硬件时间。下面是一份很简化的 CI 流程示意编译、跑虚拟目标、断言输出三步走jobs: build-and-sim: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - run: west build -b qemu_cortex_m3 samples/hello_world - run: qemu-system-arm -machine mps2-an385 -cpu cortex-m3 -kernel build/zephyr/zephyr.elf -nographic -serial stdio | grep Hello World如果是 Renode把最后一步换成renode-test tests/hello.robot里面写好启动后检查串口输出的断言。这样每天凌晨 CI 自动跑一遍固件什么时候把某段核心逻辑改崩了第二天一早就能看到。这套流程的价值不在于替真机而在于把回归成本几乎降到零。真实硬件回归要人值守虚拟 CI 回归只要看一眼有没有红。4.3 用 Embedded Coder 给 TI C2000 做没有硬件的在环验证TI C2000 系列在电机控制、数字电源领域用得非常多很多算法是在 Simulink 里建的模型用 Embedded Coder 生成 C 代码。这个场景里Embedded Coder 有专门的 support package for TI C2000 processors支持把生成代码直接烧到 C2000 上做 PIL 测试。没有硬件的时候怎么验证我用的路线是先做 SIL把生成的代码在宿主机上跑一遍验证算法逻辑;再用 TI 的 CCS 内置模拟器替代真实 C2000把生成代码加载到模拟器里做 PIL。CCS 的 CPU 模拟器对 C28x 指令集支持比较完整和真实芯片的差异主要在外设和实时性上但算法结果已经可以可信地验证了。等真实 C2000 板卡回来PIL 配置只需要把运行目标从模拟器换成真实设备模型和代码不用动。4.4 故障注入虚拟环境独有的优势真实硬件上做故障注入很难要么得改板子要么得用贵重的故障注入设备。但在虚拟环境里这是家常便饭。Renode 的 C# 插件能灵活地在总线上注入错误QEMU 也能通过监控接口修改内存内容。我常做的几类场景是注入位翻转测试校验逻辑、模拟外设挂死让驱动走超时分支、随机丢包测试协议栈重传。这些用例在真实硬件上几乎不会触发但往往是产品可靠性最薄弱的环节。我最推荐把故障注入用在错误处理路径的覆盖率上。真实项目里驱动代码的错误处理分支大多常年没被跑到等真出问题时才发现逻辑是错的。拿虚拟目标把这些分支跑一遍成本极低收益却非常直接。5. 按芯片生态选型GD32、STM32、Zynq、C2000 的虚拟化方案5.1 国产 GD32 生态与 GD32 Embedded BuilderGD32 在国产替代的浪潮里用得越来越多官方 IDE 是 GD32 Embedded Builder基于 Eclipse 封装免费且对 GD32F10x/F30x 等系列支持直接。但有个现实问题官方 IDE 本身不带成熟的虚拟目标模拟器想提前开发固件就得自己把 GD32 的 Cortex-M 内核映射到 QEMU 或 Renode 上。好在 GD32 的内核是标准的 ARM Cortex-M3/M4/M33QEMU 和 Renode 都能直接模拟这些核心。我的做法是把 GD32 的固件库和 BSP 抽象出来虚拟环境里用通用的 Cortex-M 平台顶替先在虚拟目标上把协议栈、FATFS、加密算法、业务逻辑全部调通等芯片回板再切换到真实 GD32 BSP。这样虽然外设寄存器有所不同但上层的核心逻辑已经验证过一轮省出的时间非常可观。5.2 AMD/Xilinx Vitis 自带的 QEMU 虚拟平台如果你用 Xilinx Zynq 或 Zynq UltraScale 这类带硬核 ARM 的 FPGA SoC那虚拟开发的待遇比普通 MCU 好得多。AMD/Xilinx 的 Vitis 集成开发环境内置了一套 QEMU 虚拟平台能直接启动 Linux 环境或 bare-metal 程序。用 Vitis 创建 platform 工程时可以在运行配置里选择 QEMU 作为运行目标命令行里也提供了定制过的qemu-system-aarch64。Vitis 的虚拟平台不只是跑一个 hello world它还能模拟部分 Zynq 外设和中断控制器我经常用它来做Linux 驱动在虚拟平台上先验证的流程。对于 FPGA 里还没做出来的逻辑虚拟平台的价值尤其突出——软硬件还没联调软件可以先在虚拟平台上白盒测试。5.3 STM32、TI、NXP 怎么接STM32 的官方 STM32CubeIDE 没有内置 QEMU 目标但 STM32 基于 Cortex-M 的型号可以很轻松用 Renode 跑社区也有不少把 STM32CubeMX 生成的代码移植到 Renode 的先例。TI 的 CCS 自带模拟器配合 Embedded Coder 做 PIL 我前面已经讲了。NXP 的 MCUXpresso 生态里很多评估板也都能在 QEMU 上找到对应 target尤其 i.MX RT 系列的 ARM Cortex-M 内核用通用 Cortex-M 模拟再改一版 BSP 基本可行。5.4 选型对照表按项目阶段找最合适的虚拟方案目标芯片官方 IDE/工具链可用的虚拟目标方案最适合虚拟开发的场景GD32 GD32F303/F103GD32 Embedded BuilderQEMU/Renode 模拟 Cortex-M 内核协议栈、算法、BSP 前期验证STM32F4/F7/H7STM32CubeIDERenode 板级仿真、QEMU 通用 M4 目标驱动逻辑、CI 回归TI C2000 系列CCS Embedded CoderCCS 内置模拟器做 SIL/PIL控制算法在环验证Xilinx Zynq-7000/ZynqMPVitisVitis 自带 QEMU 虚拟平台Linux 驱动、裸机 Linux 协同NXP i.MX RTMCUXpressoQEMU 通用 Cortex-M 目标 自研 BSP网络协议、GUI 逻辑这张表不是绝对的但可以作为选型时的起点。核心原则是先看芯片的内核是不是主流 ARM/RISC-V是的话虚拟开发就有比较成熟的路可走;如果是冷门内核就得靠厂商自带模拟器或者自研模型成本要提前预估。6. 让固件一码双跑给虚拟开发预留的代码结构6.1 BSP 与 HAL 分离是前提我在接手过的项目里见过太多寄存器满天飞的裸机代码main 函数里直接操作 GPIOA、TIM2想加虚拟目标只能大面积重写。虚拟开发要想顺利第一件事就是分层业务逻辑不碰寄存器只调用抽象接口;具体实现分两套一套面向真实芯片一套面向虚拟目标。一个最简单的串口抽象长这样typedef struct { int (*init)(void); void (*write)(const uint8_t *buf, uint32_t len); int (*read)(uint8_t *buf, uint32_t len); } uart_driver_t;业务代码里只持有uart_driver_t指针真实板上它指向stm32_uart_ops虚拟目标上它指向virt_uart_ops。这样做的好处是虚拟环境里可以非常容易地模拟各种异常让read永远返回超时、让write随机丢弃数据业务代码的错误处理分支全都暴露出来了。6.2 编译期开关把差异隔离在构建层除了接口抽象我习惯在构建系统里加一个统一的编译选项。CMake 里大致是这样if(VIRTUAL_TARGET) target_compile_definitions(${APP} PRIVATE VIRTUAL_TARGET1) target_sources(${APP} PRIVATE virt_uart.c) else() target_sources(${APP} PRIVATE stm32_uart.c) endif()代码里只在驱动实现文件里面区分业务代码完全不用写#ifdef。这样能保证虚拟目标和真实目标走的是同一份业务源码避免虚拟环境跑一套代码、真机跑另一套代码的假验证。6.3 真实硬件阶段如何回切很多团队虚拟开发做得风生水起真机阶段却翻车原因往往是切换成本太高。我的建议是从第一天起就把双目标构建作为默认流程CI 里同时跑虚拟目标回归和真机冒烟测试如果硬件已经存在。虚拟目标过了不代表真机一定过但反过来真机上发现的逻辑性问题多数都能在虚拟目标里复现并先修掉只把那些和时序、电气特性相关的 bug 留给真机阶段去解决。我自己的习惯是每次提交代码前先在虚拟目标上把单元测试和集成测试跑一遍再烧到真机做冒烟。两轮下来真机阶段基本不会出现改一个初始化顺序导致系统崩溃这种低级问题因为初始化顺序在虚拟环境里已经被验证过一版了。回到开始那句话虚拟开发不是替代真实硬件而是把硬件的时间留给真正需要硬件的验证环节。每次新项目启动我都会先问一个问题在芯片回板之前固件能先跑起来的那部分今天能不能就搭好问完这个问题再回看项目排期你大概就会同意我的结论——虚拟软件开发的投入可能是嵌入式软件研发里性价比最高的一笔开销。