做嵌入式开发的朋友应该都有过这种体验:一个固件动辄编译一分钟起步,烧录、连串口、看日志,一套流程跑下来十几分钟就没了。在这种节奏下,“测试”在嵌入式项目里经常是被牺牲的那一环,大家默认只要编译能过,行为靠手点、bug靠肉眼。可问题是,嵌入式代码恰恰是对错误最不宽容的——资源受限、并发、中断、时序,任何一个小改动都可能让系统表现完全不一样。这篇文章想聊聊我在一个嵌入式C++项目里搭建测试框架的完整过程:从框架选型、可测试性改造、构建脚本,到把宿主机构建与目标板集成测试串起来,基本覆盖了一套可落地的方案。如果你也维护着裸机或实时系统上的C++代码,或者正在头疼“老项目不敢动”,这篇应该能给你一些可以直接参考的思路。
1. 嵌入式环境下做测试到底难在哪
1.1 先“编译过再说”的开发惯性
嵌入式项目的测试缺失不是没有原因的。我做过的很多项目里,开发者对“测试”的理解基本就是“编译能过、烧录能启动、主要功能点一下没问题”。这种工作方式短期内看着效率不差,因为编译一个固件可能只要几十秒,烧录加启动也就几分钟,手动验证几个关键路径似乎也不费事。
但问题出在回归上。假设你给一个通信模块加了一种新的帧类型,改动了状态机的某个分支,你手动测了新增的那条路径,发现正常。可模块里还有几十条其他路径,你大概率没有精力全跑一遍。结果就是,某个很老的边界条件被改坏了,你自己并不知道。等设备在现场跑起来,偶发异常出现,工程师只能拿着串口日志反复分析,一查就是大半天。
我在一个长期维护的项目里就吃过这个亏。当时给某通信模块做适配,接口改动很小,结果把另一条解析路径带偏了,设备连上上位机后偶尔收发错乱,查了一整个下午才发现是一个边界条件被破坏。那次之后我就明白,嵌入式项目里没有自动化测试,代码就是越改越脆,最后谁都不敢乱动。
1.2 难点的系统拆解
为什么桌面端的测试方法不能直接搬到嵌入式?原因要拆开看:
| 难点 | 具体影响 | 典型表现 |
|---|---|---|
| 交叉编译环境 | 测试框架需要在目标工具链下编译通过,宿主机构建和目标构建差异大 | 桌面端能用的框架,一交叉编译就报依赖缺失 |
| 资源受限 | Flash/RAM 有限,跑不了重量级的测试运行时和断言库 | 栈上分配过多导致测试进程崩溃 |
| 硬件耦合 | 代码直接操作寄存器、调用厂商外设库,测试环境无法访问真实硬件 | 链接时 undefined reference,或板外环境直接跑飞 |
| 时序与并发 | 中断、定时器、RTOS 任务调度在宿主机上难以模拟 | 偶发问题复现不了,测试结果不稳定 |
| 构建碎片化 | make、CMake、厂商 IDE 混用,测试构建难统一 | 加了测试代码后主工程构建被破坏 |
这几个问题叠加在一起,就形成了嵌入式测试的典型困境:不是不想测,而是没有一套能低成本跑起来的测试方案。桌面项目里有现成的 JUnit、pytest、GTest,拿过来就能用;嵌入式项目里,连“怎么让测试代码链接通过”都是个坎。
1.3 为什么越早搭测试框架越划算
很多人觉得测试框架是“锦上添花”,等代码稳定了再补也不迟。我的看法恰恰相反,嵌入式项目最需要测试的时机,就是代码还不稳定、还在频繁改动的时候。
原因很简单。嵌入式代码的调试成本太高了:改一行代码,重新编译、烧录、触发场景、观察输出,一个循环下来十分钟起步。如果每次改动都能先在宿主机上跑一遍自动化测试,几百个断言在几秒内给出结果,这个反馈速度省下的时间是以小时计的。
而且,测试框架不只是用来防回归的,它还能逼着你把代码结构调整得更好。为了让模块可以被独立测试,你必须拆解依赖、隔离硬件、明确接口,这些改造本身就是代码质量的提升。等到代码已经被改得千丝万缕、谁都不敢动的时候,再想一次性抽出测试,成本就高得吓人了。
所以我的建议是:别等,从下一个要改的模块开始,先把测试框架搭起来。
2. 测试框架选型:别被“功能多”带偏
2.1 主流的嵌入式测试框架盘点
嵌入式 C++ 可选的测试框架其实不少,但每个的定位差别很大。我把常用的几个放一起比较过:
| 框架 | 语言支持 | 资源占用 | 交叉编译友好度 | Mock 支持 | 适合场景 |
|---|---|---|---|---|---|
| CppUTest | C/C++ | 低 | 高 | 内建 CppUMock | 裸机、实时系统、资源受限 |
| Unity | C 为主,C++ 可用 | 极低 | 高 | 配合 CMock | C 代码为主的嵌入式项目 |
| GTest | C++ | 较高 | 一般 | 配合 GMock | Linux 用户态嵌入式进程 |
| doctest | C++ | 中 | 一般 | 无内建 | 工具链齐全的现代 C++ 项目 |
CppUTest 是我用得最多的一个。它的设计目标就是嵌入式环境:源码体积小,编译产物不大,不依赖异常和运行时类型信息,交叉编译基本没有障碍。而且它有一个很实用的特性——内存泄漏检测,测试用例结束时如果发现有未释放的内存,直接报错。这个功能在裸机项目里尤其有用,很多堆内存问题在宿主机上就能提前暴露。
Unity 更适合纯 C 项目。它的核心就是一个极简的断言宏集合,配合 CMock 可以做 mock,但如果你主要写 C++,用起来会觉得不够顺手,很多断言语义需要自己封一层。
GTest 的优点是功能全、断言语义丰富、参数化测试舒服,缺点也很明显:依赖比较重,对裸机和极小资源的目标板不友好。我一般在网关、边缘设备这类能跑用户态进程的项目里才用它。
2.2 我最终怎么选的
选型不能只看“哪个功能多”,要看你手头项目的真实约束。
我手里这个项目是跑在微控制器上的,资源极其有限,代码以 C++ 为主,但没法用完整的 C++ 标准库,异常和动态内存都受限制。这种情况下 GTest 基本不用考虑,光是把它的运行时塞进 Flash 就很吃力。Unity 能用,但 C++ 项目里写起来不够顺手。
最终我选了 CppUTest,核心原因有三个:第一,它对 C++ 支持完整,可以舒服地写测试类和断言;第二,内建 mock 支持,省得再引一套 CMock;第三,内存泄漏检测在宿主机构建里就能跑,对查找目标板上的堆问题帮助很大。
选型的时候还有一个容易踩的坑:不要只看框架的 README 吹了什么,一定要先在一个最小例子上试一下交叉编译。我当时就差点被一个看起来“很轻量”的框架坑了,它源码确实小,但依赖了一个宿主机上的特定工具链,交叉编译折腾了半天才通过。所以说,选型的判断标准永远是你的目标编译器和目标硬件,不是框架主页上的宣传文字。
3. 落地方案:把测试嵌入进嵌入式项目
3.1 工程结构怎么调
想把测试加进一个已有的嵌入式工程,第一个问题就是代码目录怎么组织。我的做法是保持产品代码和测试代码分离,但放在同一个仓库里,共用同一份源码。
一个比较顺手的结构是这样的:
project/ ├── CMakeLists.txt ├── src/ │ ├── comm/ │ │ ├── comm_protocol.cpp │ │ └── comm_protocol.h │ └── drivers/ │ ├── uart_driver.cpp │ └── uart_driver.h ├── tests/ │ ├── CMakeLists.txt │ ├── main.cpp │ └── test_comm_protocol.cpp └── test_doubles/ ├── fake_uart.cpp └── fake_uart.h几个关键点:
- src 下的产品代码不包含任何测试代码,也不依赖测试框架的头文件,这样交叉编译产品固件时不会引入额外负担。
- tests 目录只放测试用例和测试入口。
- test_doubles 目录放桩实现和 mock —— 这层是嵌入式测试特有的,后面会细说。
有人习惯把测试代码直接塞进 src 目录,觉得这样省事。我不推荐,因为厂商 IDE 或者交叉编译脚本在编译固件时,很容易不小心把测试代码也编进去,最后固件体积暴增不说,还可能把测试函数的符号带进产品镜像。
3.2 让待测代码变得“可测”:拔出硬件依赖
嵌入式代码最难测的部分就是硬件耦合。一个模块如果直接操作寄存器、直接调用厂商外设驱动,在宿主机上编译就是死路一条。
我在这个项目里第一个要测的是通信协议解析模块。一开始它的代码长这样:
bool CommProtocol::parseFrame(const uint8_t* data, size_t len) { // 解析帧... if (checksum(data, len) != 0) { // 直接操作UART寄存器发送错误码 HW_UART->DR = ErrorCode::ChecksumMismatch; return false; } // 正常处理... }这个代码在目标板上没问题,但测试时链接器根本找不到HW_UART,宿主机上也不可能有个真实的串口外设。改造的思路很简单:把“做什么”和“怎么做”拆开。协议模块只负责判断数据对不对,至于怎么上报错误,交给下层接口。
class CommProtocol { public: void setErrorSink(ErrorSink* sink) { m_errorSink = sink; } bool parseFrame(const uint8_t* data, size_t len) { if (checksum(data, len) != 0) { if (m_errorSink != nullptr) m_errorSink->report(ErrorCode::ChecksumMismatch); return false; } // 正常处理... return true; } private: ErrorSink* m_errorSink = nullptr; };测试的时候只需要注入一个桩ErrorSink,断言它收到了预期的错误码就行。
这类改造我给三条建议:
- 纯逻辑(协议解析、状态机、算法)和硬件操作(读引脚、发字节、配置寄存器)严格分离,这是最基础也是收益最大的一步。
- 依赖注入优先于直接调用全局函数,构造函数里不要读硬件,也不要在初始化阶段就做实际 IO。
- 全局状态越少越好。嵌入式代码特别喜欢用全局变量做模块间通信,但这对测试是灾难——用例之间相互污染,测试顺序不同结果就不同。
3.3 构建配置:CMake + CppUTest 实例
工程结构定了之后,构建脚本是关键。我们项目本来就用 CMake,所以测试工程的搭建还算顺利。下面是一个能跑的 CMake 配置核心片段:
cmake_minimum_required(VERSION 3.16) project(embedded_cpp_tests) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 把被测产品代码编成静态库,这是关键一步 add_library(product_code STATIC ${PROJECT_SOURCE_DIR}/src/comm/comm_protocol.cpp ${PROJECT_SOURCE_DIR}/src/drivers/uart_driver.cpp ) # 测试构建中使用适配宿主机的配置 target_compile_definitions(product_code PUBLIC HOST_TEST=1) target_include_directories(product_code PUBLIC ${PROJECT_SOURCE_DIR}/src ) # 引入 CppUTest find_package(CppUTest REQUIRED) add_executable(unit_tests ${PROJECT_SOURCE_DIR}/tests/main.cpp ${PROJECT_SOURCE_DIR}/tests/test_comm_protocol.cpp ${PROJECT_SOURCE_DIR}/test_doubles/fake_uart.cpp ) target_link_libraries(unit_tests product_code CppUTest::CppUTest CppUTest::CppUTestExt ) enable_testing() add_test(NAME unit_tests COMMAND unit_tests)这里有个很重要的点:测试工程里编译的product_code用的是宿主编译器,不是目标板交叉编译器。也就是说,测试构建其实是一个独立的“宿主机版本的产品代码”,通过HOST_TEST这个宏,让代码里的硬件头文件和寄存器定义替换成宿主机可编译的模拟版本。
实际配置的时候,我一般把产品代码里硬件相关的部分都用条件编译包一层:
#ifdef HOST_TEST #include "host_hal_uart.h" #else #include "target_chip_hal.h" #endif这样产品代码只有一份,测试构建和目标构建共用源码,不会出现“测试的代码和实际运行的代码长得不一样”这种最危险的情况。
构建和运行测试也很直接:
cmake -S . -B build-host cmake --build build-host ctest --test-dir build-host目标是让宿主机构建在秒级内完成。如果测试编译时间太长,开发者会懒得跑。
3.4 测试用例怎么写:一个简单的状态机测试
测试框架搭起来以后,最需要考虑的是“测什么”和“怎么测”。以我那个通信协议模块为例,里面有一个核心状态机:接收帧头、校验长度、等待数据、校验 CRC、输出帧。
状态机的分支逻辑正好是测试最容易出价值的地方。在真实板子上,构造“帧头对了但长度超了”这种异常输入很麻烦,你得拿串口工具精心构造数据包;但在宿主机测试里,这就是一个函数调用的事。
我写了一个简单的测试组:
TEST_GROUP(CommProtocol) { CommProtocol protocol; FakeErrorSink errorSink; void setup() override { protocol.setErrorSink(&errorSink); } }; TEST(CommProtocol, FrameWithBadChecksumShouldReportError) { uint8_t frame[] = {0xAA, 0x05, 0x01, 0x02, 0xA0, 0x01}; CHECK_FALSE(protocol.parseFrame(frame, sizeof(frame))); LONGS_EQUAL(ErrorCode::ChecksumMismatch, errorSink.lastError); } TEST(CommProtocol, ShortFrameShouldBeRejected) { uint8_t frame[] = {0xAA, 0x05}; CHECK_FALSE(protocol.parseFrame(frame, sizeof(frame))); LONGS_EQUAL(ErrorCode::FrameTooShort, errorSink.lastError); }这种测试写起来一点不难,难的是在代码设计阶段就想着“这个东西以后怎么测”。如果你等到代码全写完了再回来补测试,大概率发现根本补不了——依赖太多、耦合太重,只能硬着头皮 mock 掉半个模块。
3.5 分层测试与覆盖率
嵌入式测试不能只靠一套方案打天下,我的做法是分三层:
- 宿主机构建跑单元测试:覆盖协议解析、状态机、算法、数据管理这些与硬件无关的逻辑,反馈要快,开发者每次改动后本地就能跑。
- 目标板集成测试:交叉编译后烧录到板子上,跑冒烟链路和关键外设流程,验证真实硬件环境下的行为。
- 手工验证:只留少量用例,比如特殊外设的现场调优、电磁干扰相关的行为,这些自动化不太好模拟。
覆盖率数据我主要看宿主机这一层。在 CMake 里加上--coverage编译选项,跑完测试后用 gcov/lcov 生成报告,就能看到纯逻辑模块的覆盖率。目标板上的全代码覆盖率不太现实,主要受限于 Flash/RAM,而且板子上的真实运行路径很难精确统计,不要在这个方向上死磕。
CI 流水线里的阶段也遵循同样的逻辑:
# 阶段1:宿主机构建并跑测试 cmake -S . -B build-host -DCMAKE_BUILD_TYPE=Debug cmake --build build-host ctest --test-dir build-host --output-on-failure # 阶段2:交叉编译目标固件,验证可编译、可链接 cmake -S . -B build-target -DCMAKE_TOOLCHAIN_FILE=toolchain-arm.cmake cmake --build build-target目标固件的构建不能省,因为它能发现宿主机测试发现不了的问题:有些代码只在目标编译器下才会报错,或者目标板上内存布局和宿主机不同,编译时才能暴露出来。
4. 实操中踩过的坑与排查实录
4.1 链接阶段的外设库魔咒
搭测试工程最常见的坑,就是被测模块引用了一堆外设函数,宿主机链接器报undefined reference to hw_uart_send这类错误。第一次遇到时我头都大了,因为报错的地方在很深的一个底层调用链里。
解决的办法有三种,按推荐程度排序:
- 接口注入:这是最干净的方式。把硬件操作抽象成接口,测试时注入桩实现。
- weak 符号:给外设函数提供一个弱符号默认实现,测试桩里定义强符号覆盖它。适合不想大改的遗留代码。
- 链接器
--wrap:让链接器把所有对某个符号的引用都重定向到测试函数上。适合完全不想动源码的临时方案。
weak 符号的写法我自己常用在快速验证的场景里:
// 产品代码中给硬件函数一个弱符号默认实现 __attribute__((weak)) void hw_uart_send(uint8_t byte) { // 真实硬件寄存器操作 HW_UART->DR = byte; }理论上它长期用也行,就是容易把“测试桩”和“真实实现”的边界搞模糊,代码库里全局搜索时容易被忽略。接口注入还是最靠谱的长期方案。
4.2 测试挂了但不知道断在哪
宿主机测试最让人头疼的不是断言失败,而是一整个测试进程直接崩溃或者挂死。断言失败至少能告诉你哪一行没通过,崩溃经常什么日志都没有。
我遇到过一个内存越界的问题,症状是测试跑到某个用例时进程段错误,换个顺序跑又可能不死。这种情况我直接在宿主机测试构建里开了地址消毒器:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address -fno-omit-frame-pointer") set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")开了之后,越界访问会直接报出访问的位置和调用栈,定位快了很多。这个工具在宿主机测试里能跑,但目标板上是用不了的——资源不够,所以宿主机测试阶段提前把这类问题暴露出来格外重要。
挂死的问题不太一样。测试进程卡住往往是因为被测代码里有死循环,或者是在等待一个永远不会发生的中断。我在 CI 脚本里给 ctest 加了超时参数,防止某个用例把整个流水线卡死:
ctest --test-dir build-host --timeout 30如果某个用例经常超时,基本可以断定是被测逻辑里有未预期的等待或死循环,值得好好查一下。
4.3 浮点与编译器优化不一致
宿主机测试通过、目标板上计算跑偏,这大概是嵌入式测试最让人崩溃的场景之一。我开发一个简单的滤波算法时,宿主机上跑了一堆测试全部通过,上板之后输出偶尔会出现明显的异常跳变。
后来排查发现,问题出在目标板编译器默认开启了一些浮点优化,导致特定输入下中间结果的舍入行为变了。宿主机这边因为是 x86 架构,浮点模型跟微控制器的编译器并不是一回事。
对策分几步:
- 数值算法测试里不要用精确相等,用
DOUBLES_EQUAL这种带容差的断言。 - 宿主机构建时尽量模拟目标板的浮点模型,比如目标板用软浮点时,在宿主机上也配置相应的编译选项。
- 最稳妥的方法是收集目标板上的真实输入数据,把这些数据固化到宿主机测试里做离线回归,这就是所谓的“golden test”。以后改算法,只要宿主机测试跑一遍真实数据,基本能还原大部分目标板问题。
4.4 Mock 太多,测试“失真”
这是我踩得最重的一个坑。项目初期为了追求“完全隔离”,我把被测模块外部依赖全部 mock 掉了,结果所有测试都绿得发亮,但每次上板实测还是会挂。
原因很简单:mock 的行为跟真实对象根本不是一回事。比如我 mock 了一个缓冲区,觉得它的容量永远足够,可是真实硬件环境里缓冲区经常满,错误路径根本没被覆盖到。
后来我调整了策略:mock 只放在真正的边界上,比如硬件寄存器、系统时间、外部芯片的 I2C 读写;模块内部的协作对象尽量用真实实现。并且,每一个 mock 行为都要加上调用次数和参数的断言,宁可严格到测试烦,不能宽松到测试失真:
MockSupport().expectOneCall("hw_uart_send") .withParameter("byte", 0xA5);这样 mock 不是随便替身,而是真的帮你验证了交互行为。
4.5 测试跑得太慢
宿主机测试虽然比烧板子快得多,但如果你把每个用例都设计成启动一个新进程,整体速度也快不起来。我第一次搭完测试工程,跑一遍要四十多秒,CI 里加上之后整个流水线明显变慢,同事都开始抱怨。
优化的方向比较简单:
- 所有用例跑在同一个测试 runner 进程里,避免反复启动进程的开销。
- 用例之间共享一个被测对象的实例,前提是状态能控制好。
- 并行执行多个测试分片,比如
ctest -j 4。 - 把最慢的板级集成测试单独放到一个流水线阶段,不阻塞日常的单元测试反馈。
调整以后,同一批测试从四十多秒降到了两秒左右,这个差距直接影响开发者的使用意愿——两秒的测试跑起来毫无心理负担,四十秒的测试很多人就会想“要不先别跑了吧”。
4.6 常见问题速查表
| 症状 | 可能原因 | 快速对策 |
|---|---|---|
| 链接报 undefined symbol | 被测代码引用外设/系统函数 | 接口注入、weak 符号或链接器 --wrap |
| 宿主机测试通过、板子跑挂 | 浮点、内存布局、编译选项差异 | 模拟目标环境、固化真实数据做 golden test |
| 测试进程崩溃无输出 | 内存越界、栈溢出 | 宿主机开启地址消毒器,缩小用例范围 |
| 测试挂起不结束 | 死循环、等待中断 | ctest 加超时,检查同步逻辑 |
| 外部依赖全部 mock 后仍挂 | mock 行为失真 | 减少 mock 范围,增加调用次数和参数断言 |
| 覆盖率一直很低 | 测试集中在硬件交互路径 | 先抽取纯逻辑模块补测试 |
5. 一些更深的体会
做完这套嵌入式 C++ 测试框架,我最深的感受是:它解决的问题不只是“测试覆盖率”,而是让“改代码”这件事在嵌入式项目里变得可逆、可验证。
以前改一个模块,心里总绷着一根弦——不知道会动到哪条隐藏的依赖。现在每次改动后,先在宿主机上跑一遍测试,断言绿了,再烧板子做集成验证,心态完全不一样。那种“手上没有安全网”的焦虑感,比任何测试数量指标都真实。
如果你的项目也长期处于“不敢重构、不敢加需求、排查靠肉眼”的状态,我建议你别想着一步到位建一个覆盖所有模块的大平台,先挑一个最痛、改动最频繁的模块,把测试框架的最小闭环跑通。先从最简单的逻辑模块开始,跑出一个红灯变绿灯的结果,再慢慢扩展到边界。
很多年前我也觉得嵌入式代码测试是件“性价比低”的事,直到自己真正在一个迭代了快三年的 C++ 项目里把测试体系搭起来,才意识到只要度过最初那两周“补测试很痛苦”的阶段,后面每一次改动、每一个回归 bug 的提前暴露,都在给当初的投入付利息。
如果你正在做类似的事情,建议从今天要改的那个函数开始,哪怕只有三条测试,先去跑一次红灯变绿灯的循环。剩下的路,等你的代码自己给出答案。