聊嵌入式C++测试框架之前,先说一段我自己的经历。早年在做一个物联网网关项目时,代码量从两三千行C++一路涨到快两万行。那时候我写代码基本不写测试,全靠串口打印和逻辑分析仪“点灯式”调试:哪里不对打一条log,跑一把,再打一条,再看波形。程序小的时候这套办法还能糊弄过去,可当状态机多了、外设抽象层开始堆叠之后,改一个模块炸三个功能的场面开始频繁出现。最崩溃的一次,我在凌晨一点为了查一个环形缓冲区溢出,对着示波器看了三个小时,最后发现是我前一天改的CRC校验函数挪了一个边界条件。从那天起,我下决心给嵌入式C++代码认真搭一套自动化测试体系,也就是真正意义上的测试框架。
这篇内容,我想聊聊嵌入式C++项目里测试框架的选型、落地方式和真实踩坑过程:为什么普通的桌面测试框架不能用,嵌入式对框架有哪些硬约束,怎么把硬件依赖剥离开让代码在PC上先跑起来,以及最终如何让测试在目标板上作为固件运行并把结果传回来。适合正在做嵌入式Linux应用层、裸机或RTOS项目,并且开始觉得“只能靠手测”的C++开发者参考。如果你想给自己的项目加上可靠的回归测试,这篇文章应该能帮你少走很多弯路。
1. 为什么嵌入式C++项目会走到“必须引入测试框架”这一步
1.1 从“板子能跑”到“代码可信”,测试不能只靠点灯
嵌入式开发有个很普遍的幻觉:代码能编译、烧进去不跑飞、串口能打印出预期数据,就算“验证过了”。但严格来说,这只能说明“这个用例能跑”,并不能说明“这个模块是对的”。一旦项目进入维护阶段,痛点会在三个层面集中爆发。
第一是回归成本。嵌入式项目里相邻模块的耦合往往比很多人想象得严重:一个结构体字段被改了,驱动层、协议层、应用层全要跟着动。没有自动化测试的时候,验证一遍“改动没破坏原有功能”可能要手工操作十分钟,涉及按键、串口脚本、示波器抓波形,回归十次就大半天,员工会本能地选择“只测我改的那部分”,于是隐藏的连带故障越攒越多。
第二是边界条件。手工测试很难覆盖缓冲区满、时序超时、状态机非法跳转这类边界。我曾经在UART驱动里栽过的“最后一字节丢失”,正是只在特定波特率和特定帧长下出现,手工怎么点都复现不出来。自动化测试可以在一秒钟内跑完成千上万种参数组合,这是“点灯调试”永远无法做到的。
第三是协作。团队里多个人同时改代码时,没有测试就相当于没有“裁判”:谁改坏了谁负责,最后变成互相扯皮。有了测试框架,至少能明确“哪个commit把测试打红了”,这是项目管理层面的价值,甚至比技术价值更大。
1.2 嵌入式测试的两种运行空间:宿主机测试与目标板测试
接触嵌入式测试的人第一次容易犯的错,是把“测试框架”和“板子上跑测试”完全对等。实际上嵌入式领域通常把自动化测试拆成两个运行空间,对应两套完全不同的策略。
宿主机构建(Host Build)是指把被测代码交叉编译成PC可执行程序,在开发机上直接跑单元测试。优点非常明显:编译快、运行快、可以断点调试、可以接Valgrind或AddressSanitizer查内存问题,还能轻松接入CI流水线。缺点是基于实际硬件的行为(中断时序、寄存器读写、外设响应)不在覆盖范围。
目标板测试(Target Test)是把测试代码编译成固件,烧到板子上执行。它贴近真实环境,能覆盖驱动时序和寄存器行为,但运行频率低、结果回收麻烦、自动化程度差。
成熟的嵌入式C++测试框架,往往两边都能用:宿主机侧重逻辑验证和快速回归,目标板侧重驱动适配和系统集成验证。这个“两条腿走路”的思路,也是后面所有选型和架构决策的总前提。
2. 嵌入式环境对测试框架的硬约束:没OS、没标准输出、没大堆内存
2.1 资源边界:Flash/RAM和libc依赖决定框架下限
测试框架本身也是代码,也要吃Flash和RAM。桌面端开发者可能觉得这根本不算事,但在32KB Flash、8KB RAM的单片机上,这个成本直接决定框架能不能用。
举个例子,Google Test对底层有比较强的依赖,需要pthread、需要较为完整的C++标准库支持,这在RTOS或裸机环境里几乎无法直接编译。Unity测试框架用纯C写成,整个核心只有几个文件,设计目标之一就是能在资源受限的8位MCU上编译。CppUTest的源码量也不算大,默认情况下可以关闭动态内存依赖,能在裸机环境中跑。
选框架之前,必须先确认项目的最低平台资源:Flash还剩多少、RAM还剩多少、目标架构有没有浮点单元、用什么版本的交叉编译器。这些参数决定了你能用“重”框架还是只能用“轻”框架。我的一般经验是:Flash剩余小于64KB、RAM剩余小于16KB的项目,直接跳过Google Test,重点看Unity或CppUTest;如果是运行Linux或带MMU的应用处理器,选择面就宽很多。
2.2 工具链与标准库裁剪的影响
嵌入式C++测试框架的兼容性,很大程度取决于它怎么适配交叉工具链。最常见的裸机工具链是arm-none-eabi-g++,默认情况下C++的标准库支持可能不完整,很多项目编译时还会加上-fno-exceptions和-fno-rtti来缩减镜像体积。
这两条编译选项会直接干掉很多测试框架的依赖能力。比如某些依赖异常机制做断言失败跳转的框架,在-fno-exceptions下就编译不过。CppUTest早期版本不使用异常和RTTI,这是它能在嵌入式圈子里流行起来的关键原因之一;Unity本身就是C语言,就更不存在这类问题。
如果你的项目跑在嵌入式Linux上,工具链通常使用arm-linux-gnueabihf-g++,标准库是比较完整的,Google Test或doctest基本可以直接跑。所以“用什么框架”这个问题,本质上先要回答“我的目标平台属于哪个资源等级”。
2.3 架构差异和时序问题:字节序、对齐、volatile
嵌入式平台上还经常遇到桌面开发完全无感的差异,这些差异会反过来限制测试框架自身的设计。
第一个是字节序。ARM默认为小端,但不少网络协议栈代码里会做大端/小端转换,单元测试里如果直接比较内存字节,在PC上跑通不代表在目标板上跑通。所以框架应该允许你在宿主机构建里指定字节序相关的宏,或者在测试代码里显式构造大端/小端数据。
第二个是volatile。嵌入式代码访问外设寄存器时几乎都离不开volatile关键字。在宿主机上测试时,平台相关代码通常会被Mock或剥离,但总有漏网之鱼。如果被测模块里混入了直接读寄存器的代码,宿主机一编译就是一堆段错误。这个问题我放在第4章详细说,它是嵌入式测试落地的真正分水岭。
第三个是堆栈。目标板上的任务栈往往只有1KB到4KB,测试框架一旦创建过深的调用栈或过大的局部对象,直接硬件异常。CppUTest在目标板上的实践秘诀之一就是把单个测试组里的用例拆小,避免在一个测试函数里堆太多的局部状态。
3. 四个主流候选逐个过:Unity、CppUTest、Google Test、doctest
3.1 Unity:C语言的保守派
Unity是老牌测试框架,本质是纯C实现,官方定位就是嵌入式领域单元测试。它的API极简,核心就是TEST_ASSERT_EQUAL_INT这类断言宏。没有复杂的语法糖,没有mock库,也不强制要求你用什么测试组织方式。裸机移植时,只需要实现putchar或者提供串口输出,就能把测试结果打到上位机终端上。
如果你负责的是纯C项目,或者团队对C++的接受度不高,Unity会是非常稳妥的选择。缺点也明显:没有自带的Mock框架,依赖隔离要自己写Stub,比较原始;对C++11以上的特性支持基本为零,测试代码写起来会显得啰嗦。
3.2 CppUTest:嵌入式C++的默认选择
CppUTest是嵌入式C++项目里我最常选的一个,原因是它的设计目标几乎就是冲着“单片机上的C++单元测试”去的。它用C++写成,但刻意避开了异常、RTTI这些高级特性,能在裸机上跑。它自带的CppUMock功能虽然谈不上好用,但至少解决了嵌入式测试最大的痛点:硬件依赖隔离。它还有一个杀手锏级别的能力——内存泄漏检测,在PC上跑宿主机构建时,能自动报告new和malloc后没有释放的位置,这对嵌入式C++开发极其有价值。
CppUTest的测试写法跟Google Test很像,从Java的JUnit传统衍生出来,用TEST_GROUP和TEST宏组织用例。批量注册和分组功能都在宏里自动完成,上手成本很低。
3.3 Google Test:适合嵌入式Linux,不适合裸机
Google Test(gtest)是C++桌面/服务端领域的事实标准,断言丰富、社区强大、生态成熟。它的问题在于依赖有点重:默认需要pthread,对标准库的依赖也比较完整。在嵌入式Linux用户态或者带完整C++运行时的RTOS上,gtest可以跑得很愉快;在裸机Cortex-M上,基本就别想了。
如果你的项目是“嵌入式Linux + 用户态C++应用”,选gtest完全没问题,毕竟开发机上也用同一套框架,调试体验一致。但我个人还是会在资源偏紧的板子上再评估一下,优先选轻量方案。
3.4 doctest:header-only的轻量选项
doctest是单头文件测试框架,一个#include "doctest.h"就能干活,第三方依赖几乎为零,编译负担极小。它支持现代C++特性,断言风格接近Catch2,对交叉编译的适配也相对轻松。如果你的嵌入式C++项目使用较新的编译器,并且不想引入大体积的测试框架源码,doctest是个相当不错的折中方案。
3.5 对比表与选型建议
我把这四个框架的主要差异整理成一张表,方便快速比对:
| 框架 | 语言 | 资源占用 | 裸机支持 | Mock支持 | 内存检测 | 适合场景 |
|---|---|---|---|---|---|---|
| Unity | C | 很小 | 很好 | 需自写 | 无 | 纯C裸机项目 |
| CppUTest | C++ | 小到中 | 很好 | 内置CppUMock | 自带 | 嵌入式C++裸机/RTOS |
| Google Test | C++ | 较大 | 差 | 依赖gmock | 可配合工具 | 嵌入式Linux用户态 |
| doctest | C++11 | 极小 | 可移植 | 需自配 | 可配合工具 | 现代C++的嵌入式项目 |
日常选型,我的思路是:裸机或RTOS,优先CppUTest;纯C项目,选Unity;嵌入式Linux用户态,直接用Google Test,方便跟团队全局统一;如果追求极简和单一头文件,就上doctest。这个顺序这些年帮我在不同项目里都成功落地过,没出过大偏差。
4. 测试跑起来之前,先解决“代码怎么在PC上编译”的问题
4.1 可测试代码的三条设计纪律
很多人搭好测试框架后第一件事就是碰壁:被测模块根本编译不过,因为里面有大量对寄存器、中断、外设的直接访问。要让代码在宿主机上编得过去、跑得起来,核心不是框架,而是被测代码的“可测试性设计”。我在实践中总结出三条必须遵守的纪律。
第一条,任何外设访问都隔一层接口。比如UART发送,不要在你的协议层代码里直接写UART0->THR = data,而是定义一个uart_send_byte(uint8_t)函数,协议层只调用这个函数。宿主机测试时,用Fake版本的uart_send_byte把字节收进数组,断言协议发出去的字节序列。这个改造不复杂,却能让模块测试性发生质变。
第二条,中断服务函数尽量薄。ISR里只做置标志、唤醒任务、拷贝到环形缓冲区这类最小操作,真正的业务逻辑挪到线程或主循环里处理。中断本身是异步的,在宿主机构建里几乎没法直接模拟,把逻辑从ISR里抽出来之后,才能让被测函数变成同步调用,单元测试才写得下去。
第三条,全局状态通过配置结构体传入。很多嵌入式模块喜欢搞全局变量,比如保存系统状态的g_sys_status。这类全局变量在测试用例之间会互相污染,是最讨厌的问题。正确的做法是引入一个上下文结构体,比如sys_status_t ctx,所有操作都通过指针传入。这样每个测试用例都可以构造独立的上下文,互不干扰。
4.2 依赖隔离:Mock、Fake、Stub各自怎么用
依赖隔离是嵌入式测试里最常被问起的部分,很多概念容易混。简单区分一下。
Stub是“替身”。被测模块调用某个底层函数时,你提供一个空实现,只保证链接通过,不关心它做什么。适合那些和测试目标不直接相关的外部依赖。
Fake是“功能仿冒”。用一个简化但可用的实现替换真实依赖,比如用内存数组模拟EEPROM读写,用环形缓冲区模拟UART发送。Fake的功能不是假的,只是硬件换成软件。
Mock是“行为验证”。用一个可以记录调用参数、次数和顺序的对象替代真实依赖,然后在断言里验证被测模块是否按预期调用了外部接口。CppUMock和gmock都属于这一类。
实际项目里我的比例大概是:底层芯片外设用Fake,协议栈对外接口用Mock,纯内部函数用Stub。建议优先Fake,因为Fake可以复用到多个测试组里,Mock写多了维护成本很高。
4.3 CppUTest + CMake最小工程示例
说一百遍原理不如给一个能跑的最小工程。下面这个例子是我常用的骨架,用CMake组织,宿主机直接编译运行。
被测文件先假设是一个CRC校验模块,本身没有任何硬件依赖:
// crc.h #pragma once #include <cstdint> uint16_t crc16_update(uint16_t crc, uint8_t byte);// crc.cpp #include "crc.h" uint16_t crc16_update(uint16_t crc, uint8_t byte) { crc ^= static_cast<uint16_t>(byte) << 8; for (int i = 0; i < 8; i++) { crc = (crc & 0x8000) ? (crc << 1) ^ 0x1021 : (crc << 1); } return crc & 0xFFFF; }对应的CppUTest测试文件:
// test_crc.cpp #include "CppUTest/TestHarness.h" #include "crc.h" TEST_GROUP(CRC16Group) {}; TEST(CRC16Group, SingleByte_MatchesKnownValue) { uint16_t result = crc16_update(0xFFFF, 0x41); LONGS_EQUAL(0xAFD1, result); } TEST(CRC16Group, TwoBytes_UpdatesIncrementally) { uint16_t crc = crc16_update(0xFFFF, 0x41); crc = crc16_update(crc, 0x42); // 预期数值可以先用参考实现算好,再固化成断言 LONGS_EQUAL(0x2CA8, crc); }CMake配置:
cmake_minimum_required(VERSION 3.16) project(crc_unit_tests C CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) include(FetchContent) FetchContent_Declare( CppUTest GIT_REPOSITORY https://github.com/cpputest/cpputest.git GIT_TAG master ) FetchContent_MakeAvailable(CppUTest) add_library(production_code STATIC crc.cpp ) target_include_directories(production_code PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) add_executable(run_tests test_crc.cpp ) target_link_libraries(run_tests PRIVATE production_code CppUTest CppUTestExt)然后在开发机上执行:
cmake -B build cmake --build build ./build/run_tests跑完输出类似:
Test run: 2, Failure count: 0这个最小骨架非常重要的意义在于:它验证了“被测模块不碰硬件就能在PC上跑”,这是嵌入式C++测试落地的一半。剩下的另一半,就是目标板上的运行。
5. 目标板上的测试:编译成固件、跑完把结果传回上位机
5.1 测试框架在板子上的打包方式
宿主机构建解决了90%的逻辑回归问题,但驱动层的验证终究绕不开目标板。在我的项目里,目标板测试的形态通常是“一个特殊的固件”:这个固件不跑真实业务任务,而是把整个测试Runner链接进去,系统上电后自动执行全部测试用例,然后把结果打印到串口。
以CppUTest为例,裸机上打包时需要注意两点。
第一,跑测试的上下文中默认没有RTOS调度,你在测试用例里不能发生阻塞等待,否则整个测试就卡死了。所有与硬件相关的操作要么通过轮询 + 超时,要么通过Fake设备来模拟。第二,要为测试框架提供堆内存。CppUTest默认会通过new创建测试对象,裸机环境下需要确保堆空间够用,通常就是拉大启动文件的堆区,或改为使用静态内存池。
我通常在Makefile里加一个test_target目标,编译时额外定义RUN_HOST这个宏,并链接板级启动文件和链接脚本。同一个测试源码既能跑宿主机,又能烧到板子上,只是通过宏切换输出方式。缺点是一套代码两套编译配置,构建脚本相对复杂,但换来的是真硬件验证能力,这笔投入值得。
5.2 测试结果怎么从板子上传回来
目标板上测试结果回收,最常见也最靠谱的方式是串口输出。CppUTest支持用户自定义输出实现,只要继承UtestPlatform相关接口,把打印函数重定向到UART,就能把每一行测试结果发给上位机串口。在测试固件里,我是这样做的:
- 在初始化阶段配置UART,速率固定为115200 8N1;
- 给测试框架注册自定义输出器,所有
printf输出同时转发到串口; - 上位机用Python脚本持续读取串口数据,当读到
Test run: x, Failure count: y时解析退出码。
串口方案最可靠,因为它不依赖文件系统、网络协议栈,在裸机上也能跑。如果板子上有网络或SD卡,也可以把结果写到文件里再回收,但那是锦上添花,不属于第一版必须搞的东西。
结果解析脚本是很简单的Python,一般用serial库就能搞定。这里借用一下pytest生态里的习惯:测试用例跑到板子上之后,由上位机脚本统一收集、生成报告、决定CI是否通过。这部分我在第6章会再仔细说。
5.3 板级内存泄漏检测与覆盖率的思路
目标板上最值得做的两类检查,第一是内存泄漏,第二是覆盖率。
内存泄漏在裸机上经常表现为“堆被吃光然后系统死机”。虽然CppUTest自带内存泄漏检测,但在PC上的检测原理是拦截new、delete、malloc、free,裸机上如果你的工程重载了全局new,会发生冲突。我踩过这个坑,后面我在目标板上选择不完全依赖框架自带的检测,而是依赖宿主机构建阶段的内存泄漏检测:每次提交代码时,先在PC上跑一遍带有完整内存检测的测试,再上板。在板子上只做一项精简检查:记录测试前后的空闲堆大小,测试结束后打印差异,如果差异超过阈值就报错。
覆盖率在板子上相对难做,因为代码插桩会增大镜像体积。我的经验是,覆盖率当作“宿主机专属指标”,用gcov在PC上统计;目标板上只关注功能正确性,不把覆盖率作为硬性门槛。这样做既避免了插桩导致的时序失真,又能保证团队在逻辑层面的覆盖率有保障。
6. 把测试框架接进日常开发流程:CTest、CI与pytest分工
6.1 CMake/CTest集成
写好的测试工程,如果不接进构建系统,用不了两周就会被遗忘。我习惯用CTest来统一管理测试程序的注册和运行。
在CMake里加一行:
enable_testing() add_test(NAME unit_tests COMMAND run_tests)之后在开发机上执行:
ctest --output-on-failureCTest的好处是可以同时管理多个测试程序:单元测试跑CppUTest,集成测试跑Python脚本,板级测试跑远程部署脚本。全部汇总到同一个命令下,开发体验非常顺滑。CI流水线里也只要调用ctest,不用为每个测试程序单独写调用逻辑。
6.2 CI只跑宿主机构建,板级测试放到专门的硬件环境
我在搭建CI的时候,认真吃过一次亏:盲目让CI编译裸机测试固件并尝试烧板,结果既没有硬件服务器,也没有统一的烧录环境,流水线隔三差五就卡死。后来我把CI拆成两条线。
第一条线是“快速线”:每次push代码,都在CI里执行宿主机构建,编译生产代码和CppUTest测试,跑ctest,再附加一次Valgrind内存检查。这条线耗时一般在3到5分钟,能拦截绝大多数逻辑错误和内存错误,是团队效率的基础。
第二条线是“硬件回归线”:只在特定分支上触发,把测试固件编译好,在专门的裸机服务器上通过JTAG或串口烧录,收集串口打印的测试结果。这条线跑得慢、依赖硬件环境,但定期执行能发现驱层问题。两条线分开,开发体验和硬件验证都被兼顾。
6.3 嵌入式测试和pytest / 上位机测试的分工
热搜词里总把pytest跟嵌入式放在一起聊,很多嵌入式新人会困惑:是不是学了pytest就能替代嵌入式测试框架了?我明确说一下,两者不是替代,而是分工。
pytest主战场是上位机、脚本、集成测试、硬件在环(HIL)测试。比如测试一个设备管理器,上位机通过串口或网络发指令,验证嵌入式设备状态,这种就适合用pytest。它处理的是“外部视角”的系统级测试。
嵌入式C++测试框架处理的是“内部视角”:单个模块、函数、状态机,在没有真实硬件的情况下快速验证逻辑正确性。CppUTest/doctest/Unity跑的是进程内部的断言,pytest跑的是进程间的交互验证。一个完整的嵌入式测试体系,应当是内部单元测试 + 上位机集成测试双管齐下。
我自己目前的习惯是:CppUTest负责每个Nightly的单元回归,pytest负责每周的硬件系统回归,两个结果都汇总到同一个CI看板里。这样既保证单点正确,又能验证整体互动效果。
7. 实测过程中踩过的坑和现在的用法
7.1 几个典型的坑
我整理几个真实踩过的坑,每个都可能让嵌入式测试项目延期甚至流产,提前知道能省去大量调试时间。
第一个坑是在目标板上跑Google Test导致堆栈爆掉。当时我把一个PC上跑得好好的gtest测试直接交叉编译到开发板上,测试固件一启动就进HardFault。查了半个下午,发现是每个测试用例的临时对象+断言堆栈远超默认任务栈。后来换用CppUTest并拆小测试用例才解决。这个教训告诉我:裸机上选框架不是看功能多不多,而是看“吃栈”厉害不厉害。
第二个坑是CppUTest的全局new重载跟既有内存池冲突。我的工程本来重载了全局new做内存统计,链上CppUTest后链接冲突,编译死活不过。后来查文档发现CppUTest也重载了全局new,必须在编译时定义CPPUTEST_USE_MEM_LEAK_DETECTION=0或调整链接顺序。这个坑典型的“不读到release note永远不会知道”。
第三个坑是断言浮点值时精度问题。嵌入式代码里大量使用浮点运算,直接用DOUBLES_EQUAL(expected, actual, tolerance)没问题,但很多人会随手用LONGS_EQUAL去比较浮点位模式,结果同一个计算在PC和老旧ARM上精度不同,测试莫名失败。后来我把所有浮点断言统一规范,比较时始终带容差,这类失败就消失了。
第四个坑是volatile变量导致的测试不稳定。在宿主机构建里,被测代码如果仍然访问真实寄存器映射地址,跑起来全是段错误;而如果为了测试把volatile去掉,优化器又会改变行为。最终解决方案还是第4章强调的:把寄存器访问封装成接口,测试时用Fake变量替身,而不是在测试里强行凑编译通过。
第五个坑是测试用例之间状态污染。早期CppUTest测试代码里,有人在TEST_GROUP的静态成员里保存了全局状态,结果一个用例改了值,另一个用例就跑挂了。后来严格执行“每个测试组自己setup/teardown重建状态”这条规则,全局状态一律放进被测模块的上下文结构体,测试稳定性大幅提升。
7.2 我现在的建议配置
做了一系列项目之后,我现在的默认配置已经相对固定:
- 裸机/RTOS C++项目:CppUTest + CMake/CTest + 宿主机CI,板级测试用串口回收结果;
- 纯C项目:Unity + CMake/CTest;
- 嵌入式Linux用户态项目:Google Test,配合pytest做系统级回归;
- 非常小的C++库或组件:doctest单头文件,快速插入现有工程。
这套配置不是最优的,也不追求最优,重要的是“可执行、可持续、可回归”。测试框架带来的最大价值不是“写了多少条测试用例”,而是让我每次改动代码后,能在一分钟内确定哪些模块还活着、哪些模块被打红了。对于嵌入式C++这种“改动成本极高、故障定位极难”的领域来说,这种反馈回路比任何炫酷的调试工具都更值钱。
我个人这几年的体会是:嵌入式C++测试框架选型真的不用纠结太久,挑一个生态活跃、能从宿主机一路跑到目标板的框架,先把最小闭环跑通,再逐步补覆盖率、内存检测和CI流水线。行动比讨论重要,一套能跑的冒烟测试,比十页完美的测试规划文档更有意义。