ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32嵌入式开发:Unity单元测试框架移植与实践指南

STM32嵌入式开发:Unity单元测试框架移植与实践指南 1. 项目概述为什么要在STM32上搞单元测试做嵌入式开发尤其是基于STM32这类MCU的项目时间长了你肯定遇到过这种场景代码越堆越多功能模块之间耦合得像一团乱麻。今天改了一个串口驱动明天发现ADC采集的数据不对了或者在某个角落里修了一个小bug结果引发了另一个八竿子打不着的模块崩溃。每次修改都像在走钢丝全凭经验和祈祷回归测试全靠手动一遍遍刷板子效率低不说心里还没底。这就是单元测试要解决的问题。它不是什么高深的理论核心思想很简单把你的代码切成一个个最小可测试的“单元”通常是一个函数或一个类然后像做实验一样单独给这个单元输入各种数据包括正常值、边界值、异常值看它的输出是否符合预期。在PC上我们有Google Test、CppUTest等成熟的框架但在资源受限的STM32上事情就变得棘手了。内存只有几十KB没有文件系统没有标准输出怎么跑测试怎么报告结果Unity就是为了解决这个问题而生的。它是一个纯C语言编写的、极其轻量级的单元测试框架专门为嵌入式环境设计。它的核心代码就几个文件不依赖任何操作系统或标准库可以轻松地移植到像STM32这样甚至没有操作系统的裸机环境里。把Unity移植到你的STM32工程中意味着你可以在开发早期就为关键算法、驱动接口编写测试用例每次编译后都能自动运行快速验证代码的正确性极大地提升代码质量和开发信心。这不仅仅是“写测试”更是一种保障项目稳健推进的工程实践。2. Unity框架核心思想与STM32适配性分析2.1 Unity的设计哲学极简与自足在决定移植之前我们必须先吃透Unity的设计。它和那些运行在Linux或Windows上的大型测试框架有本质区别。它的设计遵循了嵌入式开发的“生存法则”零外部依赖Unity不调用printf、malloc、fopen等标准库函数。所有输出测试结果、失败信息都通过一个你需要自己实现的putchar风格函数来完成。这意味着你可以把它输出到串口、SEGGER RTT、甚至点亮点LED完全由你掌控。内存静态分配框架内部几乎不使用动态内存所有需要的缓冲区都在编译期确定避免了在资源紧张环境下因内存碎片导致的不确定性。测试用例组织简单它使用一组简单的宏如TEST_ASSERT_EQUAL_INT,TEST_ASSERT_TRUE来封装断言。你的测试函数就是普通的C函数然后用另一个宏RUN_TEST来调用它。最后所有测试在一个main函数里通过UNITY_BEGIN()和UNITY_END()包裹起来执行。结果可机读Unity默认的输出格式非常规整易于被脚本解析。这对于集成到CI/CD持续集成/持续部署流水线中至关重要可以实现自动化测试和报告。2.2 为什么是Unity而不是其他你可能听说过CppUTest或者甚至想自己手写几个宏来凑合。但对于STM32项目Unity的优势是决定性的尺寸极小核心文件unity.c,unity.h,unity_internals.h加起来通常不到100KB源码编译后占用的ROM和RAM极小几乎不影响你的主程序。移植成本极低你只需要实现一个输出函数unity_output_char和一个可能需要的测试用例文件遍历方法如果使用自动化脚本生成。剩下的就是调用它的API。与硬件解耦测试框架本身不关心你用的是STM32F103还是STM32H743不关心你用的是HAL库还是标准库。它只关心你的业务逻辑代码。这使得测试代码可以最大程度地复用。社区与生态Unity是ThrowTheSwitch组织下的项目与Ceedling一个优秀的C项目构建与测试工具紧密集成。这意味着你可以在PC上先用Ceedling快速搭建测试环境开发测试用例然后再无缝移植到STM32上运行实现“宿主机测试”与“目标机测试”的结合。注意虽然Unity很轻量但它不是银弹。它主要适用于逻辑、算法、状态机、数据处理的单元测试。对于高度依赖硬件时序、中断、外设寄存器的代码如精确的PWM输出、ADC DMA采集单元测试往往力不从心需要结合硬件在环HIL测试或集成测试。3. 将Unity移植到STM32开发环境的详细步骤理论说再多不如动手做一遍。下面我以一个典型的STM32CubeIDE工程使用HAL库为例展示完整的移植过程。假设你的工程目录结构如下MyStm32Project/ ├── Core/ │ ├── Inc/ │ ├── Src/ │ └── Startup/ ├── Drivers/ ├── Middlewares/ └── Test/ -- 我们将在这里组织测试代码3.1 获取与引入Unity源码首先你需要获取Unity的源代码。最推荐的方式是从其GitHub仓库ThrowTheSwitch/Unity下载Release版本或者直接复制src目录下的unity.c,unity.h,unity_internals.h这三个文件。在你的项目根目录下创建一个Test/Unity文件夹将这三个文件放进去。MyStm32Project/ └── Test/ └── Unity/ ├── unity.c ├── unity.h └── unity_internals.h接下来需要在你的IDE如STM32CubeIDE、Keil MDK中将这个路径添加到包含路径Include Paths和源文件参与编译。在STM32CubeIDE中右键项目 - Properties - C/C Build - Settings - Tool Settings - MCU GCC Compiler - Includes。添加../Test/Unity相对路径取决于你的设置。同样在Settings - MCU GCC Linker - Libraries一般不需要额外设置。在Project Explorer中右键Test/Unity文件夹选择Add/Remove include path...确保其被识别。最关键的一步你需要决定如何编译测试代码。一种常见方法是创建另一个独立的“测试目标”。右键项目 - New - Target可以复制一份现有的配置重命名为“MyStm32Project_Test”。在这个目标的src文件组中添加unity.c和你写的测试用例文件。而主应用程序的src组则排除这些文件。这样可以通过切换构建目标来分别编译应用和测试。在Keil MDK中在Project窗口右键你的Target或一个文件组例如新建一个“Test”组 - Add Existing Files...将unity.c添加进去。右键项目 - Options for Target - C/C - Include Paths添加.\Test\Unity。3.2 实现输出通道重定向unity_output_charUnity内部调用一个名为unity_output_char的函数来输出每一个字符。默认这是一个弱定义的空函数。我们必须实现它将字符发送到我们能看见的地方通常是串口。在Test文件夹下创建一个test_runner.c文件内容如下#include “unity.h” // 包含你的串口驱动头文件例如STM32 HAL库的 #include “usart.h” // 实现Unity所需的输出函数 void unity_output_char(int c) { // 假设你使用USART1作为调试输出 // 注意这里使用阻塞式发送对于测试框架通常可接受。 // 如果测试输出量巨大可能需要实现非阻塞或DMA方式但会复杂化。 HAL_UART_Transmit(huart1, (uint8_t*)c, 1, HAL_MAX_DELAY); } // 如果你的平台需要特殊的初始化可以放在这里 void setUp(void) { // 每次测试开始前运行的函数。可以用来初始化测试用的全局变量、重置硬件模拟状态等。 // 对于纯逻辑测试可能什么都不需要做。 } void tearDown(void) { // 每次测试结束后运行的函数。用来清理资源。 }setUp和tearDown是Unity支持的夹具Fixture函数会在每个TEST函数执行前后自动调用非常适合用来初始化和清理测试环境。3.3 编写你的第一个测试用例现在我们来测试一个简单的函数。假设你有一个计算两个整数最大公约数GCD的函数int gcd(int a, int b)定义在Core/Src/math_utils.c中。在Test文件夹下创建test_math_utils.c#include “unity.h” #include “math_utils.h” // 包含被测试模块的头文件 // 声明被测试函数如果头文件已包含则不需要 // extern int gcd(int a, int b); void test_gcd_positive_numbers(void) { TEST_ASSERT_EQUAL_INT(6, gcd(12, 18)); TEST_ASSERT_EQUAL_INT(1, gcd(7, 13)); TEST_ASSERT_EQUAL_INT(5, gcd(5, 0)); // 注意0的边界情况取决于你的函数如何定义 } void test_gcd_negative_numbers(void) { // 根据你的函数设计测试对负数的处理例如始终返回正数 TEST_ASSERT_EQUAL_INT(6, gcd(-12, 18)); TEST_ASSERT_EQUAL_INT(6, gcd(12, -18)); }这里使用了TEST_ASSERT_EQUAL_INT断言宏它是Unity最常用的宏之一用于比较两个整型是否相等。如果不等Unity会打印出详细错误信息包括预期值和实际值。3.4 创建测试运行器Test Runner测试运行器是所有测试用例的调度中心。我们在test_runner.c中继续补充// ... 前面 unity_output_char, setUp, tearDown 的定义 ... // 声明所有测试函数 extern void test_gcd_positive_numbers(void); extern void test_gcd_negative_numbers(void); // ... 其他模块的测试函数声明 int main(void) { // 硬件初始化必须 HAL_Init(); SystemClock_Config(); // 你的系统时钟配置函数 MX_USART1_UART_Init(); // 初始化调试串口 // Unity测试开始 UNITY_BEGIN(); // 运行测试套件 RUN_TEST(test_gcd_positive_numbers); RUN_TEST(test_gcd_negative_numbers); // ... 运行其他所有测试 // 结束测试并返回测试结果失败用例数 return UNITY_END(); }3.5 配置链接与调试现在你需要配置工程使得编译这个测试运行器时只包含必要的模块。排除主应用代码在测试目标的源文件列表中确保排除了main.c你现在有test_runner.c作为入口以及可能包含无限循环或硬件初始化冲突的源文件。包含被测试代码确保math_utils.c等被测试的源文件被包含在编译中。解决硬件依赖如果你的被测试代码直接调用了HAL_Delay或读写某个具体外设寄存器这会在单元测试中造成问题。这就是为什么强调要分层设计将业务逻辑与硬件驱动分离。对于无法分离的简单依赖在测试环境中可以提供“桩函数”Stub或“模拟对象”Mock。例如提供一个假的HAL_Delay函数它什么都不做或者只是递增一个计数器。// 在 test_runner.c 或专门的 mock 文件中 __weak void HAL_Delay(uint32_t Delay) { // 测试环境下可以记录延时调用次数或直接跳过 (void)Delay; // 防止编译器警告 // g_delay_call_count; // 可用于验证行为 }调试与观察编译测试目标下载到STM32板子。通过串口调试助手如Putty、Tera Term连接对应的串口波特率匹配。复位板子你将在串口终端看到Unity的输出test_math_utils.c:10:test_gcd_positive_numbers:PASS test_math_utils.c:15:test_gcd_negative_numbers:PASS -------------------------------- 2 Tests 0 Failures 0 Ignored OK如果测试失败它会清晰地告诉你哪个文件的哪一行失败了预期是什么实际是什么。4. 高级应用模拟Mock、桩Stub与测试驱动开发TDD当你的模块依赖于其他模块或硬件时直接测试会变得困难。例如一个温度读取模块temperature.c依赖于adc.c来获取原始值。在单元测试temperature.c时我们不应该也无法依赖真实的ADC硬件。这时就需要用到模拟Mock。4.1 使用CMock自动生成Mock进阶Unity项目组提供了一个配套工具叫CMock。它可以解析你的头文件自动生成依赖模块的Mock代码。Mock不仅能替代真实函数还能记录函数被调用的次数、参数并允许你预设返回值或指定回调函数。使用流程通常是在PC上安装Ruby和Ceedling一个集成了Unity、CMock和CException的构建系统。用Ceedling初始化项目它会创建好目录结构。在测试文件中#include “mock_adc.h”。在测试用例中你可以这样写void test_get_temperature(void) { // 预设当 adc_read_channel(1) 被调用时返回 2048对应某个电压值 adc_read_channel_ExpectAndReturn(1, 2048); // 预设温度转换系数 float expected_temp 25.0f; // 执行被测试函数它会内部调用 adc_read_channel float actual_temp temperature_get(1); // 验证结果 TEST_ASSERT_FLOAT_WITHIN(0.1f, expected_temp, actual_temp); }CMock生成的mock_adc.c会确保adc_read_channel被以参数1调用并返回2048。如果调用不符合预期测试会立刻失败。这对于测试模块间的交互逻辑非常强大。虽然将CMock集成到STM32的编译链中稍显复杂但一个可行的策略是在PC上用Ceedling完成测试开发和大部分运行确保逻辑正确然后将通过测试的代码和Unity测试框架不含CMock一起移植到STM32进行最终的集成与硬件相关测试。这实现了开发效率与最终验证的平衡。4.2 手动打桩Stub的实用技巧在不使用CMock的情况下手动打桩是更直接的方法。关键在于利用链接器的“弱符号”Weak Symbol特性或条件编译。弱符号覆盖在STM32的HAL库中很多回调函数被定义为__weak。你可以直接在测试文件中重新定义一个同名函数链接时你的“强符号”版本会覆盖库中的“弱符号”版本。// 在 test_runner.c 中 // 覆盖HAL库中弱定义的超时回调避免测试时进入错误处理 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { // 测试中可以什么都不做或者记录回调发生 (void)htim; }头文件控制与条件编译// 在 temperature.c 中 #include “adc.h” float temperature_get(int channel) { #ifdef UNIT_TEST // 单元测试时使用一个模拟的获取值 return simulated_adc_value[channel]; #else // 实际产品代码调用真实的ADC驱动 return adc_read(channel) * conversion_factor; #endif }然后在测试构建目标中在编译器预定义宏里添加UNIT_TEST。这种方式侵入性较强需谨慎使用。4.3 在STM32项目中实践测试驱动开发TDDTDD的节奏是“红-绿-重构”。在嵌入式领域可以这样操作红在PC上的Ceedling环境中为新功能如filter_median中值滤波函数先写一个失败的测试。编译运行Ceedling测试看到失败红。绿在filter.c中实现最简单的代码让这个测试通过。在PC上运行测试通过绿。重构优化filter.c中的代码结构同时保持测试一直通过。移植验证将已经通过PC端测试的filter.c和对应的test_filter.c一起放入STM32工程。在STM32上运行单元测试确保在目标硬件上也能通过。这一步主要验证环境兼容性比如字节序、数据类型大小等。这种方法将大部分调试工作转移到了功能强大的PC端极大提升了开发效率。5. 工程集成、自动化与常见问题排查5.1 将测试集成到构建系统让测试自动化是发挥其价值的关键。你可以使用Makefile/CMake为测试目标编写独立的构建规则。例如一个make test命令可以编译测试目标。使用OpenOCD或ST-LINK命令行工具将固件下载到开发板。通过pySerial等Python脚本自动打开串口捕获输出。解析Unity的输出结果判断测试成败并生成报告如JUnit格式。集成到CI/CD在GitLab CI、Jenkins等平台上上述make test可以作为其中一个构建阶段。每次代码推送自动在连接的实体板卡或硬件模拟器上运行测试套件。5.2 常见问题与解决技巧实录问题1测试输出乱码或没有输出。排查首先检查unity_output_char函数是否正确实现串口初始化波特率、停止位等是否与终端软件设置一致。确保在调用UNITY_BEGIN()之前串口硬件已经完成初始化HAL_UART_Init。技巧可以在unity_output_char函数开头加一个简单的固定字符串发送如HAL_UART_Transmit(huart1, (uint8_t*)Start\n, 6, 1000);验证最基本的串口通路是否正常。问题2测试程序下载后板子无反应好像没运行。排查检查测试程序的main函数是否被正确调用。确保中断向量表已正确指向新的main函数通常由启动文件处理如果你替换了main.c需要确认启动文件调用的是main还是app_main。检查堆栈大小是否足够Unity内部需要一些栈空间。技巧在main函数最开始先点亮一个LED或产生一个特定的脉冲用示波器查看确认程序已进入。这能区分是程序没跑起来还是卡死在某个初始化环节。问题3测试某个涉及硬件定时的函数时测试超时或失败。排查单元测试应避免测试硬实时性。对于包含HAL_Delay、等待标志位循环的函数在测试环境中需要提供模拟版本。例如模拟的HAL_GetTick可以返回一个可控的、递增的值而不是依赖SysTick。技巧建立“虚拟时钟”概念。在测试夹具中设置一个全局变量s_fake_tick让模拟的HAL_GetTick返回这个变量。在测试用例中你可以通过增加s_fake_tick的值来“模拟”时间的流逝。static uint32_t s_fake_tick 0; uint32_t HAL_GetTick(void) { return s_fake_tick; } void test_timeout_behavior(void) { s_fake_tick 0; // 执行一个应该超时的操作 // ... s_fake_tick 1000; // 模拟时间过去了1000ms // 验证超时后状态 // ... }问题4测试用例太多main函数里RUN_TEST行数爆炸。解决使用Unity的“测试套件”自动注册功能。你需要将每个测试文件中的测试函数声明在同一个头文件中或者使用更高级的脚本如Ceedling使用的TestRunnerGenerator自动生成test_runner.c。手动维护时可以创建多个test_runner_xxx.c文件分别管理不同模块的测试然后通过条件编译决定运行哪一套。问题5浮点数比较失败即使看起来值相等。原因浮点数存在精度误差直接使用TEST_ASSERT_EQUAL_FLOAT比较两个计算产生的浮点数很可能失败。解决使用TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual)宏。它允许你在一个误差范围内delta进行比较这是比较浮点数的正确方式。TEST_ASSERT_FLOAT_WITHIN(1e-6f, 3.14159f, calculated_pi);在我经手的多个STM32项目中引入Unity单元测试后最直观的感受是“敢改了”。尤其是对那些底层算法库和状态机逻辑修改后跑一遍测试几分钟内就能获得反馈比手动在板子上验证快得多也全面得多。初期搭建环境会花点时间但一旦跑通它对项目长期稳定性的贡献远超那点投入。对于资源紧张的MCU测试代码本身不一定要留在最终产品固件里可以通过条件编译在发布版本中移除因此几乎没有任何运行时开销。这本质上是一种用极小的存储空间和编译时间换取代码质量和开发效率大幅提升的工程实践对于严肃的嵌入式产品开发我认为是必不可少的。
返回列表