
1. 项目概述为什么要在STM32上搞单元测试做嵌入式开发尤其是基于STM32这类MCU的项目代码写久了你是不是也有过这样的经历改了一个看似无关紧要的驱动函数结果整个系统跑飞了排查半天才发现是某个全局状态被意外修改或者为了测试一个通信协议解析函数不得不把整个硬件平台搭起来用串口打印、点灯大法来验证效率低得令人发指。更头疼的是随着项目迭代代码越来越臃肿你敢不敢保证三年前写的那个ADC采样滤波算法在今天的新需求下依然工作正常不敢重构因为心里没底——这就是缺乏自动化测试带来的典型困境。单元测试这个在桌面和服务器端开发中早已成为标配的工程实践在嵌入式领域却常常因为“资源受限”、“与硬件耦合紧密”、“太麻烦”等理由被搁置。但事实上对于STM32这类已经具备相当计算和存储资源的ARM Cortex-M芯片引入轻量级的单元测试框架不仅可行而且必要。它能将测试代码与硬件解耦让你在开发机上就能验证核心逻辑的正确性极大提升代码质量和开发信心。Unity正是一个为C语言量身定制的、极其轻量且纯粹的单测框架。它不依赖任何平台特性所有输出通过简单的宏定义重定向这使得将其移植到STM32平台无论是直接在芯片上运行还是在仿真环境下变得异常简单。今天我就结合自己多次在STM32F1/F4系列项目中的实战经验详细拆解Unity的移植过程、应用技巧以及如何将其融入你的开发工作流让你也能享受到“测试驱动开发”带来的踏实感。2. Unity框架核心思想与STM32适配性分析2.1 Unity是什么为什么选它Unity不是一个游戏引擎在这里它是一个单元测试框架Unit Test Framework。它的设计哲学是“简单到极致”。整个框架的核心就是一个头文件unity.h和一个源文件unity.c通常加起来不到2000行代码。它提供了一系列形如TEST_ASSERT_EQUAL_INT、TEST_ASSERT_EQUAL_STRING的断言宏用于验证测试结果。选择Unity在STM32上做单元测试主要基于以下几点考量极致轻量其代码量和内存占用极小非常适合资源受限的嵌入式环境。即使是在RAM只有几十KB的STM32F103上运行Unity测试套件也毫无压力。零外部依赖Unity只依赖标准C库stdio.h,stdlib.h,string.h等。在嵌入式环境中我们完全可以通过实现putchar等函数来替代这些依赖或者直接重定向输出。可移植性极强框架本身与硬件和操作系统无关。你需要实现的只是一个输出函数例如通过串口打印测试结果框架就能工作。与C语言生态无缝集成特别适合测试用C语言编写的硬件驱动、中间件如FreeRTOS队列操作、算法模块等。2.2 STM32单元测试的两种模式片上测试与Native测试在STM32项目中应用Unity通常有两种模式适用于不同场景模式一片上测试 (On-Target Testing)顾名思义将Unity测试代码编译后直接下载到STM32芯片中运行。测试结果通过串口UART输出到PC端的串口调试助手如Putty、SecureCRT或IDE的控制台。优点最真实的环境。可以测试与硬件直接相关的代码例如GPIO读写、SPI通信驱动、ADC采样等。能发现一些只有在真实硬件上才会出现的问题如时序、中断冲突。缺点测试执行速度受芯片频率限制需要硬件连接如果测试导致硬件异常如访问非法地址可能需要重启芯片。模式二Native测试 (On-Host Testing)在PC开发机上使用本地编译器如GCC、MSVC编译并运行测试代码。被测的STM32业务代码纯C逻辑被直接编译到PC可执行程序中。优点执行速度极快秒级完成数千个测试用例不依赖硬件方便在CI/CD持续集成流水线中自动运行调试异常方便可直接使用GDB、Visual Studio等强大调试器。缺点无法测试直接操作硬件的代码。需要将被测代码中与硬件相关的部分如HAL_GPIO_WritePin通过“桩函数Stub”或“模拟Mock”替换掉。对于大多数项目我推荐采用混合策略算法、数据结构、协议解析等纯逻辑模块使用Native测试快速迭代驱动层、与硬件强相关的模块使用片上测试作为最终验证。下面我们将重点讲解更通用、也更复杂的片上测试移植。3. 将Unity移植到STM32工程详细步骤与配置这里以使用STM32CubeIDE和HAL库为例展示完整的移植过程。假设我们已有了一个基础的STM32工程。3.1 获取与集成Unity源码首先需要获取Unity框架代码。最直接的方式是从其官方GitHub仓库下载或者使用Ceedling等测试工具集它包含了Unity。为了简化我们可以直接获取核心文件。获取文件你需要unity.h、unity_internals.h和unity.c。通常可以在开源项目或Unity的发布包中找到。放入工程在你的STM32工程目录下创建一个新文件夹例如Tests/Unity将这三个文件放入。添加到编译链在IDE如STM32CubeIDE中将Tests/Unity目录添加到项目的头文件包含路径Include Paths。并将unity.c添加到项目的源文件列表中参与编译。3.2 实现输出重定向链接硬件与框架Unity默认使用printf来输出测试结果如“PASS”或“FAIL”以及详细信息。在STM32上我们需要将printf重定向到串口。这是移植的关键一步。步骤1启用串口并实现_write函数假设我们使用USART2作为调试输出口。在CubeMX中配置USART2为异步模式并启用全局中断可选。在生成的代码中你需要实现一个低层IO函数。对于使用newlib或newlib-nanoGCC ARM工具链默认的工程需要重写_write系统调用。// 在 main.c 或专门的 syscalls.c 文件中 #include sys/stat.h #include errno.h // 声明 HAL 发送函数 extern UART_HandleTypeDef huart2; int _write(int file, char *ptr, int len) { if (file ! STDOUT_FILENO file ! STDERR_FILENO) { errno EBADF; return -1; } // 使用 HAL 库的阻塞式发送 HAL_UART_Transmit(huart2, (uint8_t*)ptr, len, HAL_MAX_DELAY); return len; }注意这里使用了HAL_MAX_DELAY进行阻塞发送。在简单的测试场景中可以接受但如果你的测试代码运行在中断或实时操作系统中阻塞发送可能会影响系统时序。此时可以考虑使用带超时的非阻塞发送或者使用DMA并配合信号量来异步输出。这是片上测试需要权衡的一个点。步骤2配置Unity使用自定义输出可选但推荐Unity允许你自定义输出函数这比依赖printf更灵活也更容易控制格式。你需要定义UNITY_OUTPUT_CHAR宏。在你的测试运行主文件例如test_runner.c中在包含unity.h之前添加如下代码// 自定义字符输出函数通过串口发送一个字符 void unity_output_char(int c) { if (c \n) { // 如果需要自动添加回车换行 uint8_t crlf[] {\r, \n}; HAL_UART_Transmit(huart2, crlf, 2, 10); } else { HAL_UART_Transmit(huart2, (uint8_t*)c, 1, 10); } } // 告诉Unity使用我们的自定义输出函数 #define UNITY_OUTPUT_CHAR(a) unity_output_char(a) #include unity.h3.3 组织测试代码结构保持工程整洁一个清晰的测试代码结构至关重要。建议采用如下目录结构Your_STM32_Project/ ├── Core/ │ ├── Inc/ # 产品代码头文件 │ └── Src/ # 产品代码源文件 ├── Drivers/ ├── Tests/ │ ├── Unity/ # Unity框架文件 │ ├── src/ # 测试用例源文件 (e.g., test_adc.c, test_filter.c) │ ├── mocks/ # 模拟函数文件如果需要 │ └── test_runner.c # 测试运行主入口 └── ...test_runner.c是测试的总调度中心它的典型结构如下#include “stm32f4xx_hal.h” // 根据你的芯片修改 // ... 其他必要的头文件 ... // 声明各个测试组的函数 extern void Run_All_ADC_Tests(void); extern void Run_All_Filter_Tests(void); extern void Run_All_Protocol_Tests(void); int main(void) { // HAL初始化时钟配置串口初始化等 HAL_Init(); SystemClock_Config(); MX_USART2_UART_Init(); // 初始化调试串口 // 等待串口稳定或用户按键开始可选 HAL_Delay(100); // 运行所有测试组 Run_All_ADC_Tests(); Run_All_Filter_Tests(); Run_All_Protocol_Tests(); // 测试完毕可以闪烁LED或进入低功耗 while (1) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); HAL_Delay(500); } } // 每个测试组函数由对应的测试文件定义3.4 编写你的第一个测试用例假设我们要测试一个简单的限幅函数limit_int(int value, int min, int max)它位于Core/Src/utils.c中。首先创建测试文件Tests/src/test_utils.c:#include “unity.h” #include “utils.h” // 包含被测模块的头文件 void setUp(void) { // 在每个测试用例运行前执行用于初始化环境 // 例如重置全局变量、初始化硬件模拟状态 } void tearDown(void) { // 在每个测试用例运行后执行用于清理环境 } // 具体的测试用例 void test_limit_int_should_return_value_within_range(void) { TEST_ASSERT_EQUAL_INT(5, limit_int(5, 0, 10)); TEST_ASSERT_EQUAL_INT(0, limit_int(-1, 0, 10)); TEST_ASSERT_EQUAL_INT(10, limit_int(15, 0, 10)); } void test_limit_int_should_handle_swapped_min_max(void) { // 测试边界情况如果minmax函数应如何定义这里假设会纠正或返回错误。 // 这促使你思考并完善被测函数的设计。 TEST_ASSERT_EQUAL_INT(10, limit_int(15, 10, 0)); // 假设函数内部会交换min/max }然后在test_runner.c中声明并调用这个测试组// 在文件顶部声明 void Run_All_Utils_Tests(void); // 在main函数中调用 Run_All_Utils_Tests();最后在test_utils.c末尾定义测试组运行函数void Run_All_Utils_Tests(void) { UNITY_BEGIN(); // 开始测试会话 RUN_TEST(test_limit_int_should_return_value_within_range); RUN_TEST(test_limit_int_should_handle_swapped_min_max); UNITY_END(); // 结束测试会话输出总结报告 }编译整个工程下载到STM32打开串口调试助手你就能看到如下的输出test_utils.c:10:test_limit_int_should_return_value_within_range:PASS test_utils.c:15:test_limit_int_should_handle_swapped_min_max:PASS ----------------------- 2 Tests 0 Failures 0 Ignored OK4. 进阶技巧测试驱动开发、模拟与桩函数4.1 在STM32上实践测试驱动开发TDD测试驱动开发的循环是“红-绿-重构”。在嵌入式环境下可以这样操作红针对一个尚未实现的功能例如“解析NMEA协议的GPGGA语句”先编写测试用例。编译测试工程因为功能不存在测试自然会失败红。绿以实现最简单功能、让测试通过为目标编写最简陋的实现代码。在STM32上这可能意味着先写一个能解析固定字符串的“硬编码”版本。重构在测试通过的保护下放心地重构代码优化结构、提高效率同时确保测试一直保持绿色。这个过程能迫使你从调用者测试的角度思考接口设计往往能得到更清晰、耦合度更低的模块。4.2 处理硬件依赖模拟与桩函数测试一个读取温度传感器通过I2C的函数是困难的因为你不可能在每次测试时都连接真实的传感器。这时就需要“模拟”或“打桩”。桩函数用一个简单的、返回预定值的函数替换掉底层的硬件操作函数。// 原始函数在驱动层 HAL_StatusTypeDef I2C_Read_Temperature(uint16_t *temp) { // 复杂的I2C时序操作 return HAL_OK; } // 测试用的桩函数在测试文件中 HAL_StatusTypeDef I2C_Read_Temperature_Stub(uint16_t *temp) { *temp 2500; // 模拟返回25.00度 return HAL_OK; }在测试时通过链接器选项或条件编译让测试代码链接到桩函数而不是真实驱动。模拟对象更高级的做法是使用模拟框架如CMock常与Unity搭配使用。它可以验证函数是否被以预期的参数调用、调用了几次并动态配置返回值。这对于测试状态机或协议栈非常有用。例如测试一个通过CAN总线发送消息的函数// 我们希望验证当错误码为ERROR_OVERHEAT时函数会以特定ID和格式发送CAN告警帧。 void test_should_send_can_alert_on_overheat(void) { // 1. 预期CAN_SendMsg函数会被调用一次 // 2. 预期调用参数中的ID为0x123数据前两个字节为0x01告警类型 CAN_SendMsg_ExpectAndReturn(0x123, CheckDataFunc, sizeof(alert_data), HAL_OK); // 执行被测函数 handle_error(ERROR_OVERHEAT); // CMock会在内部验证预期是否满足 }4.3 集成到构建系统与CI/CD对于Native测试可以轻松地用Makefile或CMake来组织并在每次git push后由Jenkins、GitLab CI等工具自动运行。对于片上测试自动化稍复杂但依然可行自动编译与烧录使用make或脚本调用ARM GCC和OpenOCD自动完成编译和烧录。自动捕获结果使用Python或Expect脚本通过串口监听测试输出并解析最终的“OK”或“FAILED”总结行。结果反馈将测试结果报告到CI系统的Dashboard上。如果测试失败可以自动触发邮件通知。5. 实战避坑指南与经验总结5.1 常见问题与排查技巧测试输出乱码或没有输出检查串口配置波特率、数据位、停止位、校验位是否与PC端调试助手设置一致STM32的时钟配置是否正确特别是APB总线时钟它决定了串口波特率生成检查重定向函数_write或自定义的unity_output_char函数是否确实被调用可以在函数入口加一个GPIO翻转语句用示波器或逻辑分析仪查看。检查链接是否因为优化等级过高导致测试输出函数被编译器优化掉了尝试在调试模式-O0下编译。测试卡住或进入HardFault堆栈溢出Unity内部和测试用例会使用栈空间。如果测试函数内定义了大型数组或递归很深可能导致栈溢出。在启动文件或链接脚本中适当增大堆栈大小。断言失败导致死循环默认情况下Unity的断言失败会调用TEST_ASSERT_MESSAGE并可能陷入循环。确保UNITY_EXIT、UNITY_BEGIN和UNITY_END宏被正确使用它们共同管理测试的生命周期。硬件访问冲突测试代码意外访问了未初始化或受保护的外设寄存器。确保setUp函数正确初始化了测试环境隔离了硬件。测试结果不一致时好时坏未初始化的变量这是C语言程序的通病。在setUp函数中确保所有测试相关的全局或静态变量都被重置。中断干扰如果测试运行期间系统中断如SysTick是开启的可能会影响某些对时序敏感的测试如软件延时测试。可以考虑在运行关键断言前暂时关闭全局中断__disable_irq()测试后立即开启__enable_irq()但要非常小心。依赖外部状态测试函数可能隐式依赖了其他模块的状态。确保每个测试用例都是独立的不依赖于其他测试用例的执行顺序。5.2 性能与资源考量ROM/Flash占用Unity框架本身很小几KB但每个测试用例的字符串函数名、断言信息会占用Flash。如果空间极其紧张可以考虑在发布版本中完全不链接测试代码或者使用编译宏UNITY_EXCLUDE_DETAILS来省略详细的失败信息。RAM占用主要考虑栈空间。为测试任务分配足够的栈。测试时间片上测试较慢。避免在测试中使用HAL_Delay进行长时间等待。对于超时检测可以使用芯片的定时器来模拟更精确的时间基准而不是忙等待。5.3 我个人的最佳实践心得测试命名要像文档测试函数名test_limit_int_should_return_max_when_input_exceeds_max本身就是一个清晰的规约比任何注释都管用。一个断言一个用例尽量让每个测试用例只验证一个逻辑点。这样当测试失败时你能立刻定位到具体问题。从最容易测试的模块开始不要一开始就去啃“电机驱动”这种硬骨头。先从“数学工具函数”、“数据结构”、“协议解析”这些纯逻辑模块入手建立测试基础设施和团队信心。测试代码也是代码需要维护当产品代码变更时记得更新对应的测试。陈旧的、失败的测试会迅速失去团队的信任。片上测试作为“冒烟测试”可以将一组核心的、与硬件交互的测试编译成一个固件作为生产线上硬件质检的“冒烟测试”程序快速验证硬件基本功能是否正常。移植Unity到STM32初期会花费一些时间搭建环境但一旦跑通它所带来的代码质量提升和开发效率的加成是指数级的。它让你在修改代码时有了“安全网”敢于重构优化在集成模块时能快速定位问题是出在逻辑层还是硬件层。对于追求可靠性和可维护性的嵌入式项目来说这是一项值得投资的工程实践。