嵌入式C++测试框架落地指南:从选型到CI实践

嵌入式C++测试框架落地指南:从选型到CI实践
干了这么多年嵌入式我早就习惯了公司里的一种诡异分工写应用层的人天天聊mock、覆盖率写嵌入式的人却还在用jlink打断点、串口printf甚至眼睛瞪代码。你要是提议上测试框架得到最多的回复基本是“板子都没有怎么测”“Flash不够大跑不动”之类的话。但等你真正经历几次版本迭代里被改坏的老逻辑坑到半夜就会明白嵌入式C测试框架不是改进体验的奢侈品而是把项目从“靠经验和运气”拉到“靠规则和反馈”的那条最短路径。这篇文章想聊的就是围绕嵌入式C测试框架的一整套落地经验。聊清楚选型、搭建、写用例、处理硬件依赖、跑在板子上和CI里会遇到哪些问题还有哪些坑是我自己踩过之后才摸索出解的。文章不是给纯做服务端测试的人看的是给真正写MCU、写RTOS、写嵌入式Linux逻辑的人准备的。即便你手头的测试环境现在就只有一个串口调试工具也能从这里找到一条能跑起来的路线。1. 嵌入式项目为什么需要测试框架这个问题的答案不是“测一下比较安全”很多团队不重视嵌入式测试一个很重要的原因是“测试”这个词被理解窄了。大家想到测试就想到功能测试、整机测试、验收测试那确实得等硬件基本稳定、外设基本调通之后才能做。但嵌入式C项目里的单元测试和模块级测试完全不需要等硬件出来它测的是你的逻辑转换、状态迁移、协议处理、数据计算和边界输入这些跟CPU跑在哪颗芯片上几乎没有关系。换句话说测试框架真正解决的痛点是让代码作者在提交模块前先拿到一份“这个逻辑符合我预期的快速证明”。我见过太多稳定性事故最后查出来都不是什么高深bug而是条件判断反了、阈值设错了、事件顺序没考虑清楚。这类问题单纯靠代码评审看不出来因为几个小时后你自己再看自己的代码也会自动脑补成正确版本。只有用测试把行为锁住才能让这些低级问题在合入主线之前暴露。1.1 没有测试的嵌入式代码改起来有多心虚嵌入式开发者普遍有个习惯能用就再也不动。不是懒是怕。怕改坏一个状态机之后整个设备在现场抽搐。早期我做的某个通信网关产品就是这样模块间的调用链特别长测试靠手工发报文和示波器看波形一个边界条件漏掉到现场才会偶发重启。后来跨部门合作时对方要求我们提供自动测试结果我们第一次认真审视代码才发现很多核心函数严重依赖全局变量和寄存器地址想在PC上跑起来得先模拟一大堆外设。那段经历让我意识到一件事真正的债务不是代码乱而是代码没有任何自动校验网兜住它。一旦行为是玄学谁也不敢优化、不敢重构、不敢加需求项目会像踩在沼泽里往前走。而测试框架最大的价值是它逼你给代码修出一条可以反复自动验证的路每次改动后跑一遍红了就知道哪里炸了。1.2 测试框架能测什么不能测什么需要说清楚嵌入式C测试框架不是万能的。它最擅长的是验证那些纯逻辑、可确定性输入输出的东西协议解析、PID参数计算、状态机跳转、策略选择、数据滤波、命令解析、超时管理。只要你能把输入构造出来把输出捕获到就能测试。这类代码通常占项目总代码量一半以上而它们恰好是缺陷高发区。它不擅长测的是外部中断的微秒级时序、特定寄存器的读改写冲突、真实传感器的噪声特性、电机驱动的硬件反应。这些事情依赖真实硅片和物理环境不是把测试跑通就能保证。所以我在做架构时通常有一条不成文的规矩把定时器、中断、寄存器操作、底层收发这些薄薄的一层隔离在外面核心业务全部顺着数据流收拢这样测试框架可以覆盖最大面积的业务逻辑硬件层靠板级测试补齐。两者不互相替代但加起来能让信心高出很多。2. 选框架别盲目跟风Unity、CppUTest、GoogleTest 该这么定位嵌入式C测试框架的选项和热词搜索出来的结果高度重合就那么几个Unity、CppUTest、GoogleTest。外加很多底层C项目会基于Unity配合Ceedling在用。很多人在评测时只比较断言语法好不好看、报告颜色炫不炫忽略了嵌入式场景真正要命的几个维度依赖体积、能否跑在模拟环境、能否无缝处理C/C混合、对内存泄漏和崩溃的检测能力。2.1 三款框架的核心差异对比项CppUTestUnityGoogleTest主要语言C为主可测C函数C为主也支持C但风格偏CC资源占用很小适合MCU侧和PC侧极小Flash按KB计算偏大一般跑在PC/服务器自带Mock能力有CppUMock可做接口打桩无通常要接CMock有Google Mock但整体较重内存泄漏检测自带可重载new/delete无有部分能力但非核心交叉编译友好度高设计上就是给嵌入式用高一般更适合宿主环境报告输出文本、JUnit XML文本文本、XML等从表格能看出来CppUTest和Unity是真正为嵌入式催生出来的。CppUTest的出身就是嵌入式单元测试它对交叉编译器、C标准比较保守的场景做得很细还在框架内部默认帮你追踪new/delete配对情况。Unity则更激进地轻量核心只有几个文件和宏放MCU里的自测套件也能跑前提是你得用串口或LED把结果带出来。GoogleTest在嵌入式行业并非不能用它有丰富的匹配器和断言写起来舒服但代价是引入庞大的代码体积和较重的构建依赖。我一般只会用它来测那些最终会运行在嵌入式Linux或者上位机工具里的逻辑模块。那些跑在裸机或RTOS上的模块尽量不用它。2.2 我自己的选型优先级建议如果项目以C代码为主、同时在开发板上做完自检Unity比较合理。它的宏非常接近底层风格很多写单片机的老工程师上手几乎没有门槛。但如果你和我一样主力是C或者项目里C11之后的标准库用得比较多我会优先推荐CppUTest。原因很直接CppUTest的一整套运行环境可以用CMake轻松拉起来跑测试时能直接在PC的native编译器上验证大部分业务逻辑同时它的测试宏足够简单团队成员学过一遍就不会再说“学框架成本高”。它内置的Mock支持在测C接口或者类依赖时也够用不需要额外装一堆插件。还有一个实际选型细节测试框架和业务代码要分离编译。不要把测试框架直接塞进正式固件target除非你打算做板载自检这种特殊需求。常规做法是把被测源文件连同测试代码编成一个独立可执行文件在PC上先跑再用交叉编译器编出能在板子上跑的版本。这样一来框架轻不轻就不太敏感真正影响体验的是框架是否方便集成、依赖是否稳定。3. 从零搭起嵌入式C测试工程的一步步实战纸上谈兵没意义下面是我常用的工程结构和配置思路。以CppUTest加CMake为例这个套路在STM32、ESP32、部分嵌入式Linux环境里我反复用过通用性很强。3.1 最小工程结构先立起来我习惯按被测模块拆目录而不是按“src和test”粗分两个大文件夹。被测代码保持独立测试代码散落在对应模块旁边能让内容更容易跟踪。下面是一个简化版结构embedded-robot-project/ ├── CMakeLists.txt ├── lib/ │ └── cpputest/ # FetchContent拉取或手动放进来 ├── src/ │ ├── motion/ │ │ ├── TrajectoryPlanner.cpp │ │ └── TrajectoryPlanner.h │ ├── protocol/ │ │ ├── FrameParser.cpp │ │ └── FrameParser.h │ └── hal/ │ ├── MotorHal.cpp │ └── MotorHal.h ├── test/ │ ├── CMakeLists.txt │ ├── AllTests.cpp │ ├── motion/ │ │ └── TrajectoryPlannerTest.cpp │ └── protocol/ │ └── FrameParserTest.cppAllTests.cpp是测试框架的入口。CppUTest通常要求文件里包含下面这段内容#include CppUTest/CommandLineTestRunner.h int main(int argc, char** argv) { return CommandLineTestRunner::RunAllTests(argc, argv); }如果你不想写自己的main也可以在链接时选择框架自带的main入口只是我习惯显示写出来这样将来想在测试启动时做点环境初始化或打印版本号都会方便。3.2 CMake构建脚本里的关键配置点根CMakeLists.txt可以先不复杂把工程、语言标准、子目录引入就好cmake_minimum_required(VERSION 3.16) project(embedded_robot) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Debug) endif() add_subdirectory(src) add_subdirectory(test)src目录下用add_library把被测对象集中输出add_library(embedded_core motion/TrajectoryPlanner.cpp protocol/FrameParser.cpp ) target_include_directories(embedded_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})test目录下的CMakeLists是集成框架的核心位置include(FetchContent) FetchContent_Declare( cpputest GIT_REPOSITORY https://github.com/cpputest/cpputest.git GIT_TAG v4.0 ) set(TESTS_ENABLE_MEMORY_LEAK_CHECKING ON) FetchContent_MakeAvailable(cpputest) add_executable(unit_tests AllTests.cpp motion/TrajectoryPlannerTest.cpp protocol/FrameParserTest.cpp ) target_link_libraries(unit_tests PRIVATE embedded_core CppUTest CppUTestExt) enable_testing() add_test(NAME unit_tests COMMAND unit_tests)这里有几个容易出问题的地方需要单独说明。第一不要在target_link_libraries里漏掉CppUTestExt。很多人在测试里用了MockSupport却忘了链这个库然后报出一堆看不懂的undefined reference。第二内存泄漏检测开关要放到FetchContent之前设置因为CppUTest的CMake逻辑在配置阶段就读取这个选项。顺序写错选项不会生效你也不一定发现。第三链接顺序要保证被测库在框架库前面这是CMake静态库链接的老规矩顺序错会直接串出一堆符号找不到后面排查起来很折磨人。3.3 同一套源码怎样区分PC测试和交叉编译嵌入式项目逃不开交叉编译器。我见过不少团队刚开始用测试框架时不管三七二十一一律用arm-none-eabi-g去编译测试代码结果发现字符串、STL容器、new/delete这些运行时特性在裸机工程里全部跟启动文件和链接脚本纠缠不清入门成本立刻抬高。更顺手的策略是双轨制在开发机上直接用系统g编一版运行速度极快的单元测试。凡是能用标准C表达、不触碰寄存器指令的逻辑都在这层测试里覆盖。另外再准备一个交叉编译的测试target。它编译出的可执行文件下载到开发板或通过模拟器执行重点验证与编译器、字节对齐、特定CPU位数相关的行为同时做板级冒烟。实际操作时我在CMake里通常用一个变量切分。比如set(CMAKE_TOOLCHAIN_FILE xxx-arm-toolchain.cmake) set(BUILD_FOR_TARGET ON)但两个构建目录分开存放host目录里不开toolchain文件target目录里才开。也就是build-host/ build-arm/编译命令分别指向两个目录。这样就能在同一份代码基础上维护两套测试产物。src目录里的源码必须干净不能在底册hardcode了某个寄存器地址否则这套双轨方案很难成立这也反向逼迫你做好硬件抽象。3.4 裸机目标上跑测试要注意什么如果你确实要把CppUTest编到裸机目标里跑会面临几个很现实的问题。CppUTest依赖标准的new/delete、printf和某些临时文件机制但运行在RTOS或裸机时你得自己保证heap空间足够console输出已经被重定向到串口否则测试执行到一半会死在堆分配上。我的做法是给每个测试套件用独立的hex区域预留足够空间并在启动文件中调大堆尺寸。同时在测试init里把CppUTest的控制台输出回调重定向到UART这样板上跑完一遍就能在串口工具里看到绿色或红色的汇总。由于Flash和RAM有限真机上我不会一次性跑所有模块测试而是按功能域拆成多个测试可执行文件哪个模块改动就烧哪个测试固件速度和可诊断性都能提升。4. 真正写好嵌入式C测试用例关键是处理依赖对很多刚开始用嵌入式C测试框架的人来说最难的不是框架语法而是被测代码离不开硬件。这一节不讨论纯语法而是讲怎么通过适当的接口设计让硬件依赖不再挡路以及在C里常见的三种解除依赖方式。4.1 为什么不建议直接在测试里访问寄存器如果被测函数内部直接调用*(volatile uint32_t*)0x40021000 0x01这类寄存器操作测试只能在带真实寄存器的环境跑。你在PC上模拟这个地址要么靠gdb脚本要么靠QEMU维护成本非常高而且极容易模拟错。我一般在架构上规定一条边界业务代码不允许碰寄存器。所有寄存器访问收敛到hal层函数然后业务代码通过接口或回调去调用hal层。这样一来业务代码可以完全避开硬件映射测试时把hal层替换成内存里的变量即可根本不用理解具体芯片手册。4.2 方法一构造函数注入函数对象这是最轻量、最C的解除依赖方式。拿一个简单过温保护逻辑举例。未改造前代码可能是bool over_temperature() { return read_adc_voltage() 3.0f; }这个read_adc_voltage()来自某个抽象层想测试就必须让它跑在真ADC通道上。改成回调注入#include functional class OverTemperatureDetector { public: using Source std::functionfloat(); OverTemperatureDetector(float threshold, Source source) : threshold_(threshold), source_(source) {} bool isHot() const { return source_() threshold_; } private: float threshold_; Source source_; };测试代码就干净了#include CppUTest/TestHarness.h #include OverTemperatureDetector.h TEST_GROUP(OverTemperatureDetectorGroup) {}; TEST(OverTemperatureDetectorGroup, TriggerWhenSourceAboveThreshold) { OverTemperatureDetector detector(3.0f, []() { return 3.5f; }); CHECK_TRUE(detector.isHot()); } TEST(OverTemperatureDetectorGroup, NotTriggerWhenSourceBelowThreshold) { OverTemperatureDetector detector(3.0f, []() { return 2.4f; }); CHECK_FALSE(detector.isHot()); }不需要Mock库、不需要硬件模拟一个lambda就能完成测试。这种写法适用于绝大多数靠读传感器判断策略的场景而且它让被测对象对“数据来自哪里”完全不关心后续接真传感器时也不会修改这个类。4.3 方法二抽象接口加手工桩如果项目里已经用纯虚类定义了硬件端口那C的方式通常是把依赖作为接口指针或引用传给被测类。假设有一个外部Flash读写器class FlashDriver { public: virtual ~FlashDriver() default; virtual bool write(uint32_t addr, const uint8_t* data, size_t len) 0; virtual bool read(uint32_t addr, uint8_t* out, size_t len) 0; };被测的配置管理类需要调用FlashDriver存储参数class ConfigManager { public: ConfigManager(FlashDriver flash) : flash_(flash) {} bool saveVersion(uint32_t version); bool loadVersion(uint32_t version); private: FlashDriver flash_; };测试时写一个桩类在内存数组里模拟Flash行为不需要真实芯片class FakeFlashDriver : public FlashDriver { public: uint8_t buf[1024]; bool write(uint32_t addr, const uint8_t* data, size_t len) override { memcpy(buf addr, data, len); return true; } bool read(uint32_t addr, uint8_t* out, size_t len) override { memcpy(out, buf addr, len); return true; } };接着测试保存和读取的往返一致性就非常直接TEST_GROUP(ConfigManagerTest) { FakeFlashDriver fake; ConfigManager* cfg; void setup() override { memset(fake.buf, 0, sizeof(fake.buf)); cfg new ConfigManager(fake); } void teardown() override { delete cfg; } }; TEST(ConfigManagerTest, SaveAndLoadVersionRoundTrip) { LONGS_EQUAL(true, cfg-saveVersion(0x1020u)); uint32_t version 0; LONGS_EQUAL(true, cfg-loadVersion(version)); LONGS_EQUAL(0x1020u, version); }你会发现手工桩特别好理解不需要额外学Mock框架的匹配器语法。项目里如果就十来个底层模块手工桩的代码量完全可以接受。只有桩的数量膨胀到几十上百再来考虑CppUMock或Google Mock的自动化打桩这才是务实的升级路线。4.4 状态机怎么测才最划算嵌入式里最常见、最难查的就是状态机。协议栈有状态、充电管理有状态、按键红外部件也有状态。状态机测试的核心是把“状态迁移”和“动作触发”拆开验证。一般的状态机如果写成if/else连击测试确实不好下针。比较推荐的是把转移条件收敛成如下纯函数enum class ChargerState { Idle, Charging, Fault }; struct ChargerEvent { bool input_detected; bool over_voltage; bool charging_done; }; ChargerState next_charger_state(ChargerState current, const ChargerEvent ev) { switch (current) { case ChargerState::Idle: if (ev.input_detected) return ChargerState::Charging; return ChargerState::Idle; case ChargerState::Charging: if (ev.over_voltage) return ChargerState::Fault; if (ev.charging_done) return ChargerState::Idle; return ChargerState::Charging; case ChargerState::Fault: return ChargerState::Fault; } return ChargerState::Fault; }测试这种函数的写法是直接枚举路径TEST_GROUP(ChargerStateMachineTest) {}; TEST(ChargerStateMachineTest, IdleToChargingWhenInputDetected) { ChargerEvent ev; ev.input_detected true; ev.over_voltage false; ev.charging_done false; LONGS_EQUAL((int)ChargerState::Charging, (int)next_charger_state(ChargerState::Idle, ev)); }一次状态转换一个测试例每个用例的名字就是“从哪个状态、遇到什么事件、迁移到哪个状态”。长期维护下来这套测试用例本身就成了状态机需求的活文档。一旦有人改了迁移规则测试会立刻红掉代码评审时可以直接指着测试问原因比扯半天强得多。4.5 测试用例命名和断言的几条心得写嵌入式测试不能跟写普通业务测试一样稀里糊涂。项目里常量命名、方式也要保持一致。我常用的命名格式是被测类_行为_期望结果例如TrajectoryPlanner_WithZeroAcceleration_ShouldReturnStoppedSpeed。这样测试输出里一眼能看出失败在哪。断言时尽量使用框架提供的LONGS_EQUAL、STRCMP_EQUAL、DOUBLES_EQUAL等专用宏不要全用CHECK_TRUE代替。因为专用宏失败打印的信息更精确能告诉你实际值和期望值分别是什么CHECK_TRUE只会输出一个表达式为false排查效率天差地别。浮点数比较时严禁直接一般用DOUBLES_EQUAL并传入合适的精度参数固件里常见的有用数据类型还有带小数点的定点数此时要按定点数的精度写比较函数。还有一个细节测试要快快到每次保存完代码就能自动跑一遍。如果测试需要几十秒人就会下意识不想跑最后测试变成CI里才跑的摆设。所以我在CppUTest默认设置下会把所有测试控制在秒级以内。有些用例涉及长时间延时应通过手动控制虚拟时钟或者让源代码的时间函数可以注入来实现。5. 真机和CI里的实践杂谈以及常见问题排查实录框架在身边跑通只能算第一步。实际项目里怎么让测试持续发挥价值才是关键。这一节整理几条我自己的工程实践还有一套高频问题速查。5.1 板级测试里的输出限制怎么处理开发机上的测试可以直接用标准输出看结果但板子上不行。裸机环境建议把CppUTest的console输出重定向到UART。具体做法是在工程初始化阶段把printf重定向到自己的串口发送函数或者实现CppUTest的TestResult打印接口把结果用串口发送出去。CppUTest的测试代码会以固定格式输出你用串口工具看到的结果和PC上类似。如果板子上屏幕或串口资源都没有那至少留两个GPIO接LED一个负责红灯亮起表示测试失败另一个绿灯表示通过。这种极简模式我真的在某个成本敏感的小家电项目里用过虽然粗糙但能快速判断板上测试套件状态。5.2 持续集成里怎么跑嵌入式测试测试框架真正能倒逼代码质量是因为它能嵌进CI让每次合并前自动跑。GitLab CI或Jenkins里我通常搞两个阶段第一次阶段在开发机上跑native测试覆盖大部分业务逻辑。第二次阶段如果有专门的上位机模拟库或板卡把交叉编译后的测试固件烧录到硬件平台并执行抓串口日志判断通过与否。很多小型团队可能没有复杂的硬件测试环境那至少把第一阶段的native测试跑上。哪怕只覆盖纯算法模块它对回归的保障也远超没有。5.3 常见问题与排查方法现象可能原因处理办法编译通过但测试所有用例都不执行也没有报错链接了框架自己的main或者main入口被skip检查是否定义了EXCLUDE_MAIN显式写自己的AllTests.cpp入口MockSupport相关报undefined reference没链CppUTestExt库target_link_libraries里补CppUTestExt运行时报很多内存泄漏却没改动被测代码CppUTest内存泄漏检测与RTOS堆管理冲突关闭泄漏检测选项或配置自定义全局new/delete浮点断言经常不稳定直接比等值或精度设太小用DOUBLES_EQUAL传入合理精度测试在PC上全过板子上部分崩栈空间或堆空间不足扩大链接脚本里的stack/heap分段运行测试被测代码用了静态全局变量测试顺序会影响结果测试之间缺少reset机制在TEST_GROUP的setup里重建状态避免依赖静态状态交叉编译时自动生成的文件路径导致编译失败工具链配置或路径包含空格使用独立构建目录路径不要有中文和空格5.4 不要把测试代码混进正式固件理论上很多测试框架支持把测试代码宏排除在正式固件外但实际项目里仍然会出现不小心把测试代码或桩代码编进固件的情况。我见过一个案例因为测试桩里的FakeFlash结构体命名跟真实驱动不冲突但在链接时被正式代码引用进去导致某个型号的Flash始终读不到数据排查了整整两天。建议在目录结构上把test和src彻底分开src编译时禁止include test目录。CMake里可以对src的target做target_include_directories限制不要让test路径出现在源码的搜索目录中。测试桩文件统一命名带Fake或Mock前缀方便在ide里扫一眼就知道哪些文件不该出现在release链接里。5.5 再分享一个我后期才彻底改掉的坏习惯早期我总觉得自己写测试用例要覆盖所有输入组合。后来发现状态机加判定条件组合爆炸后测试用例数量高得离谱维护成本反而拖垮了迭代节奏。现在我的判断标准是优先覆盖等价类边界、导致状态迁移的输入、异常恢复路径未必需要100%覆盖率。行覆盖率能做到70%以上关键分支100%对多数嵌入式模块就足够了。剩下的时间应该留给架构改造和更高质量的需求评审把问题挡在写代码之前。做了几年之后我最大的体会是嵌入式C测试框架的引入本质上不是买一个工具而是逼着自己把系统里那些纠缠在一起的硬件因素、逻辑因素、时序因素重新拆开。拆得越干净测试越好写系统越容易维护。如果你手头正好负责一个越改越谨慎的嵌入式模块别犹豫先把几个顽固逻辑函数摘出来用CppUTest把行为焊死。等跑完第一轮绿色测试你会突然觉得这块代码终于开始听你的话了。

最新新闻

日新闻

周新闻

月新闻