嵌入式C语言单元测试实战:Unity框架入门与工程实践

发布时间:2026/5/19 23:35:36

嵌入式C语言单元测试实战:Unity框架入门与工程实践 1. 项目概述为什么嵌入式开发也需要单元测试在嵌入式开发领域尤其是使用C语言进行单片机、RTOS或裸机程序开发时我们常常陷入一种“烧录-看灯-调串口”的循环。代码逻辑稍微复杂一点比如一个状态机或者一个协议解析器一旦出了问题定位起来就非常痛苦。你可能需要反复插拔调试器在关键位置打上断点或者通过串口打印一堆日志效率低下不说还容易引入新的问题。更头疼的是当项目需要迭代或者有新人接手时如何保证你修改的代码没有破坏原有的功能这时候单元测试的价值就凸显出来了。“嵌入式C单元测试框架unity-初体验”这个标题指向的就是一个在嵌入式C开发圈子里颇有名气的轻量级测试框架——Unity。它不是什么高深的理论而是一套实实在在的工具能让你像写普通C函数一样为你的模块比如一个驱动、一个算法函数编写测试用例然后一键运行快速验证其行为是否符合预期。对于习惯了“硬件思维”的嵌入式工程师来说这相当于给你的软件逻辑上了一道“保险”让你在代码真正跑在目标板上前就能在PC上或模拟器上发现大部分逻辑错误。我最初接触Unity也是因为一个血泪教训一个看似简单的CRC校验函数在某种边界条件下出了错导致整个通信链路在特定场景下不稳定这个问题在集成测试阶段花了将近一周才定位到。如果当时为这个函数写了单元测试可能半小时就发现问题了。所以这次“初体验”不仅仅是尝试一个新工具更是对嵌入式开发流程的一种优化和反思。无论你是刚入行的新手还是经验丰富的老手花点时间掌握单元测试都能让你的代码更健壮项目交付更安心。2. Unity框架核心设计与思路拆解2.1 Unity是什么为什么选择它Unity并不是一个庞大的IDE或者复杂的系统它本质上就是一组纯C语言编写的头文件.h和源文件.c。它的设计哲学非常“嵌入式友好”极简、可移植、零外部依赖。你不需要安装什么复杂的运行时环境只需要把unity.c和unity.h、unity_internals.h这几个文件拷贝到你的项目里就能开始写测试了。在嵌入式C的测试框架领域除了Unity你可能还听说过CppUTest、CMocka等。我选择从Unity开始体验主要基于以下几点考量极致轻量Unity的源码就几千行编译后体积很小甚至可以把它和你的测试代码一起放到资源受限的单片机上运行当然更常见的做法是在PC上运行测试。这种轻量级特性非常契合嵌入式开发对资源敏感的特点。学习曲线平缓它的API设计直观提供的断言Assert宏非常类似其他语言测试框架如Java的JUnit对于有编程基础的人来说很容易上手。你不需要先学一套复杂的Mock模拟框架才能开始。强大的断言系统这是测试框架的核心。Unity提供了丰富的断言宏来验证各种条件比如TEST_ASSERT_EQUAL_INT判断整型相等、TEST_ASSERT_LESS_THAN_FLOAT判断浮点数小于、TEST_ASSERT_EQUAL_STRING判断字符串相等等等。这些宏在测试失败时会给出清晰的错误信息包括期望值和实际值极大方便了问题定位。与CMock天然搭配Unity来自ThrowTheSwitch组织同一个组织下还有CMock用于生成Mock代码和CException轻量级异常处理。这意味着当你后续需要测试那些依赖外部硬件如I2C、SPI或复杂模块的函数时可以平滑地引入CMock来模拟这些依赖形成一套完整的测试工具链。简单来说Unity就像一把专门为C语言打造的“手术刀”精准、小巧能帮你快速解剖和验证代码逻辑而不需要动用“重型机床”。2.2 嵌入式单元测试的独特挑战与Unity的应对在PC或服务器上做单元测试相对直接但在嵌入式环境中我们会面临一些特殊挑战Unity的设计也考虑到了这些点硬件依赖你的代码里可能充满了read_gpio()、send_uart()这样的硬件操作函数。直接在目标板上测试环境难以复现速度慢。Unity鼓励的是**“宿主测试”** 或“仿真测试”即在PC上编译和运行测试代码。这就要求我们把硬件相关的代码通过一层抽象如硬件抽象层HAL隔离开在测试时用“桩函数”或后续用CMock生成的Mock函数来替代。Unity本身不解决硬件隔离问题但它能与这种架构很好地协同工作。编译器与标准库差异不同的嵌入式编译器如GCC for ARM, IAR, Keil MDK对C标准的支持度不同。Unity的代码编写时充分考虑了可移植性尽量避免使用编译器特有的扩展或行为不确定的标准库函数。对于像malloc或printf这类可能不可用或行为不一致的函数Unity允许你自定义实现通过重定义宏比如将测试结果输出到串口或者一个内存缓冲区。测试结果输出在无操作系统的裸机环境没有printf到控制台。Unity提供了UNITY_OUTPUT_CHAR宏你可以把它重定向到任何你想要的输出通道比如串口、SEGGER RTT、或者只是设置一个标志位。这样你就能在调试器里或者通过日志工具看到测试结果。理解了这些我们就明白使用Unity不仅仅是写几个测试函数它更推动我们思考如何写出“可测试的”嵌入式代码——即代码结构清晰、模块间耦合度低、硬件依赖被良好封装。这本身就是一个非常好的工程实践。3. 核心细节解析与实操要点3.1 Unity工程的文件结构组织开始写测试前一个清晰的文件结构能让后续工作事半功倍。我推荐的组织方式如下你的项目根目录/ ├── src/ # 你的产品源代码 │ ├── driver/ # 驱动程序 │ ├── module/ # 业务逻辑模块 │ └── hal/ # 硬件抽象层 ├── test/ # 测试专用目录 │ ├── unity/ # 放置Unity框架源码unity.c, unity.h, unity_internals.h │ ├── src/ # 针对产品源码的测试文件 │ │ ├── test_module_a.c # 对module_a.c的测试 │ │ └── test_driver_b.c # 对driver_b.c的测试 │ ├── runner/ # 测试运行器Test Runner生成目录可自动生成 │ ├── mocks/ # 存放CMock生成的Mock文件后续使用 │ └── CMakeLists.txt # 或 Makefile用于构建测试程序 └── CMakeLists.txt # 主项目构建文件关键点解析隔离测试代码将test/目录与产品src/目录完全分开保证发布产品时不会包含任何测试代码。集中管理Unity把Unity框架文件放在test/unity/下方便管理和版本升级。测试文件命名我习惯用test_前缀加上被测试模块的名字来命名测试文件例如test_crc.c对应测试crc.c。一目了然。构建系统在PC上运行测试意味着你需要一个PC端的构建系统。CMake是跨平台的好选择简单的项目用Makefile也行。这个构建系统只编译你的被测代码、测试代码和Unity框架并链接成一个可在PC上运行的可执行文件。3.2 理解测试固件Test Fixture与测试运行器Test Runner这是Unity工作流程中的两个核心概念。测试固件Test Fixture 这可不是硬件里的那个“固件”。在这里它指的是一组相关的测试用例的集合。通常一个测试文件如test_crc.c就是一个测试固件。这个文件里包含了两类东西setUp()和tearDown()函数可选。setUp在每个测试用例运行前被调用用于初始化测试环境如初始化结构体、分配内存。tearDown在每个测试用例运行后被调用用于清理资源如释放内存、复位状态。如果你的测试用例彼此独立不需要特殊的准备和清理可以不实现它们。多个测试用例函数。这些函数名必须以test_或spec_开头这是Unity识别它们的约定。测试运行器Test Runner 这是一个main函数所在的文件它的职责是调用UNITY_BEGIN()开始测试。依次调用所有你想运行的测试固件中的测试用例函数。调用UNITY_END()结束测试并输出总结报告。手动编写Test Runner很繁琐尤其是当测试用例很多时。幸运的是Unity社区提供了Ruby脚本generate_test_runner.rb可以自动扫描你的测试文件生成对应的Test Runner源文件。这是非常关键的一个自动化步骤。3.3 丰富的断言宏你验证逻辑的武器库断言是测试的灵魂。Unity的断言宏定义在unity.h中数量众多但掌握几个最常用的就能覆盖80%的场景。关键在于根据数据类型选择正确的断言宏否则可能得到错误的结果或编译警告。常用断言宏分类宏名称用途示例注意事项基本判断TEST_ASSERT(condition)判断条件为真TEST_ASSERT(ptr ! NULL)相等判断TEST_ASSERT_EQUAL(expected, actual)判断两个基本类型相等TEST_ASSERT_EQUAL(5, result)TEST_ASSERT_EQUAL_INT(expected, actual)判断整型相等TEST_ASSERT_EQUAL_INT(100, calculate())TEST_ASSERT_EQUAL_HEX(expected, actual)以十六进制格式判断整型相等TEST_ASSERT_EQUAL_HEX(0xAA, reg_value)TEST_ASSERT_EQUAL_STRING(expected, actual)判断字符串内容相等TEST_ASSERT_EQUAL_STRING(OK, status)TEST_ASSERT_EQUAL_MEMORY(expected, actual, len)比较指定长度内存是否一致TEST_ASSERT_EQUAL_MEMORY(ref_data, buffer, 256)浮点数判断TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual)判断浮点数在误差范围内相等TEST_ASSERT_FLOAT_WITHIN(0.001, 3.14159, pi)TEST_ASSERT_EQUAL_FLOAT(expected, actual)内部也是用_WITHIN使用默认精度UNITY_FLOAT_PRECISION大小判断TEST_ASSERT_GREATER_THAN(threshold, actual)判断实际值大于阈值TEST_ASSERT_GREATER_THAN(0, sensor_reading)TEST_ASSERT_LESS_THAN(threshold, actual)判断实际值小于阈值TEST_ASSERT_LESS_THAN(100, temperature)真假判断TEST_ASSERT_TRUE(condition)判断条件为真TEST_ASSERT_TRUE(is_valid(input))TEST_ASSERT_FALSE(condition)判断条件为假TEST_ASSERT_FALSE(error_flag)重要经验对于浮点数比较务必使用TEST_ASSERT_FLOAT_WITHIN并指定一个可接受的误差范围如0.00001。这是新手最容易踩的坑之一因为浮点数在计算机中的表示和运算存在精度损失直接比较“相等”几乎总是失败。4. 实操过程从零搭建第一个测试让我们用一个具体的例子来走通整个流程。假设我们有一个非常简单的项目里面有一个计算校验和的源文件checksum.c。4.1 准备被测代码与Unity框架首先创建项目结构。我们有一个简单的checksum.c// src/checksum.c #include “checksum.h” uint8_t calculate_checksum(const uint8_t *data, uint16_t length) { if (data NULL || length 0) { return 0; } uint32_t sum 0; for (uint16_t i 0; i length; i) { sum data[i]; } return (uint8_t)(sum 0xFF); // 返回低8位作为校验和 }对应的头文件checksum.h// src/checksum.h #ifndef CHECKSUM_H #define CHECKSUM_H #include stdint.h uint8_t calculate_checksum(const uint8_t *data, uint16_t length); #endif接下来从Unity的GitHub仓库https://github.com/ThrowTheSwitch/Unity下载或克隆其源码。我们只需要src/目录下的unity.c、unity.h和unity_internals.h这三个文件。将它们拷贝到我们项目的test/unity/目录下。4.2 编写第一个测试用例在test/src/目录下创建测试文件test_checksum.c// test/src/test_checksum.c #include “unity.h” // 必须包含Unity头文件 #include “checksum.h” // 包含被测模块的头文件 // 可选如果所有测试用例都需要一些初始化/清理可以定义setUp和tearDown void setUp(void) { // 可以在这里初始化一些全局变量或状态 // 本例中不需要所以函数体为空 } void tearDown(void) { // 清理setUp中分配的资源 // 本例中不需要 } // 测试用例1正常数据计算 void test_calculate_checksum_normal(void) { uint8_t data[] {0x01, 0x02, 0x03, 0x04}; uint8_t result calculate_checksum(data, 4); // 1234 10 (0x0A) TEST_ASSERT_EQUAL_HEX(0x0A, result); } // 测试用例2空指针输入 void test_calculate_checksum_null_pointer(void) { uint8_t result calculate_checksum(NULL, 5); // 根据我们的设计输入空指针应返回0 TEST_ASSERT_EQUAL(0, result); } // 测试用例3长度为零 void test_calculate_checksum_zero_length(void) { uint8_t data[] {0xFF}; uint8_t result calculate_checksum(data, 0); // 长度为零也应返回0 TEST_ASSERT_EQUAL(0, result); } // 测试用例4数据溢出求和超过255 void test_calculate_checksum_overflow(void) { uint8_t data[] {0xFF, 0x01}; // 255 1 256 低8位为0 uint8_t result calculate_checksum(data, 2); TEST_ASSERT_EQUAL(0, result); }代码解读每个测试用例都是一个返回void且无参数的函数名称以test_开头。setUp/tearDown在本例中为空但保留它们是一个好习惯未来扩展测试时会用到。我们设计了四个测试用例分别覆盖了正常功能、边界条件空指针、零长度和特殊情况溢出。这就是单元测试的核心用各种输入去“冲击”你的函数验证其行为。断言宏选择了TEST_ASSERT_EQUAL_HEX和TEST_ASSERT_EQUAL因为结果是整数用_HEX可以方便地查看十六进制结果。4.3 生成并理解测试运行器Test Runner手动写一个main函数来调用所有这些test_xxx函数很麻烦。我们使用Unity自带的Ruby脚本来自动生成。确保系统有Ruby环境Windows可安装RubyInstallerMac/Linux通常自带。将Unity源码中的auto/目录包含generate_test_runner.rb脚本也拷贝到test/目录下或者记住脚本路径。打开终端进入项目test/目录执行命令ruby auto/generate_test_runner.rb src/test_checksum.c这会在当前目录生成一个test_checksum_runner.c文件。让我们看一下生成的关键部分// test_checksum_runner.c (部分) #include “unity.h” #include “test_checksum.c” // 注意这里直接包含了测试文件 extern void setUp(void); extern void tearDown(void); extern void test_calculate_checksum_normal(void); extern void test_calculate_checksum_null_pointer(void); extern void test_calculate_checksum_zero_length(void); extern void test_calculate_checksum_overflow(void); int main(void) { UNITY_BEGIN(); // 测试开始 RUN_TEST(test_calculate_checksum_normal, 1); // 第二个参数是行号用于定位 RUN_TEST(test_calculate_checksum_null_pointer, 8); RUN_TEST(test_calculate_checksum_zero_length, 15); RUN_TEST(test_calculate_checksum_overflow, 22); return UNITY_END(); // 测试结束并返回结果 }生成逻辑解析脚本会解析test_checksum.c找出所有以test_或spec_开头的函数声明。生成一个main函数其中按顺序调用RUN_TEST来执行每个测试用例。RUN_TEST的第二个参数是该测试用例在源文件中的起始行号当测试失败时Unity会用这个行号来报告是哪个测试出错了非常贴心。注意运行器直接#include “test_checksum.c”这是一种常见的做法避免了单独编译测试文件再链接的麻烦。这意味着你的测试文件test_checksum.c不能包含main函数。4.4 编写构建脚本并运行测试现在我们需要一个CMakeLists.txt来告诉编译器如何构建我们的测试程序。# test/CMakeLists.txt cmake_minimum_required(VERSION 3.10) project(EmbeddedUnitTest LANGUAGES C) # 设置包含路径 include_directories( ${CMAKE_CURRENT_SOURCE_DIR}/unity ${CMAKE_CURRENT_SOURCE_DIR}/../src # 指向产品源码目录 ) # 添加Unity框架源文件 add_library(unity STATIC unity/unity.c) # 添加被测源码 add_library(checksum_src STATIC ../src/checksum.c) # 创建测试可执行文件 add_executable(test_checksum src/test_checksum.c runner/test_checksum_runner.c # 假设生成的runner放在runner/目录 ) # 链接Unity库和被测代码库 target_link_libraries(test_checksum unity checksum_src)然后在test/目录下执行经典的CMake构建命令mkdir build cd build cmake .. make编译成功后运行生成的可执行文件./test_checksum。4.5 解读测试输出运行测试程序你会在终端看到类似这样的输出test_checksum.c:10:test_calculate_checksum_normal:PASS test_checksum.c:17:test_calculate_checksum_null_pointer:PASS test_checksum.c:24:test_calculate_checksum_zero_length:PASS test_checksum.c:31:test_calculate_checksum_overflow:PASS ---------------------- 4 Tests 0 Failures 0 Ignored OK输出解读每一行对应一个测试用例的执行结果格式为文件名:行号:测试函数名:结果。PASS表示通过。最后是总结共4个测试0个失败0个被忽略总体OK。如果某个测试失败了比如我们故意把第一个测试的期望值改成0x0B输出会变成test_checksum.c:10:test_calculate_checksum_normal:FAIL: Expected 0x0B Was 0x0A ... ---------------------- 4 Tests 1 Failures 0 Ignored FAIL错误信息非常清晰在test_checksum.c的第10行test_calculate_checksum_normal测试失败期望值是0x0B但实际得到的是0x0A。这能让你立刻定位到问题所在。5. 常见问题与排查技巧实录在实际使用Unity的过程中你肯定会遇到一些坑。下面是我总结的一些常见问题及其解决方法。5.1 编译与链接问题问题1编译时提示找不到UNITY_BEGIN,RUN_TEST等符号。原因没有正确链接unity.c。确保你的构建系统CMake/Makefile将unity.c编译并链接进了最终的可执行文件。检查在test_runner.c生成的main函数里是否包含了#include “unity.h”你的构建脚本是否将unity.c加入到了源文件列表问题2链接时出现重复的main函数定义。原因你的测试文件test_xxx.c或者被测源码中意外地包含了一个main函数。记住整个测试程序只能有一个main函数就是由Test Runner生成的那个。解决检查你的.c文件确保只有test_xxx_runner.c或你手动编写的统一运行器里有main函数。问题3测试代码需要调用标准库函数如malloc,printf但在交叉编译或裸机环境下没有。原因Unity内部某些功能如UNITY_PRINT_EOL可能默认依赖标准库。在嵌入式环境中这些可能不可用。解决通过定义宏来提供自定义实现。最常见的是重定向输出// 在你的测试运行器或某个公共头文件中在include unity.h之前定义 #ifdef TARGET_EMBEDDED #define UNITY_OUTPUT_CHAR(c) my_putchar(c) // 你的串口发送函数 #define UNITY_OUTPUT_FLUSH() my_uart_flush() #define UNITY_PRINT_EOL() do { UNITY_OUTPUT_CHAR(‘\r’); UNITY_OUTPUT_CHAR(‘\n’); } while (0) #endif #include “unity.h”同样如果需要malloc/free你可以通过UNITY_MALLOC和UNITY_FREE宏来定义自己的内存管理函数。5.2 测试执行与断言问题问题4浮点数测试总是失败。原因这是最经典的新手坑。使用了TEST_ASSERT_EQUAL或TEST_ASSERT_EQUAL_FLOAT未自定义精度来比较浮点数。解决永远使用TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual)。根据你的精度要求选择合适的delta例如0.00001f。float expected 3.14159f; float actual calculate_pi(); TEST_ASSERT_FLOAT_WITHIN(0.0001f, expected, actual); // 允许万分之一的误差问题5测试数组或结构体时如何方便地比较场景一个函数返回了一个结构体或一个数组你想验证其内容。解决使用TEST_ASSERT_EQUAL_MEMORY。你需要一个已知正确的“预期”数据块。typedef struct { int id; float value; } SensorData; SensorData expected {.id 1, .value 3.14f}; SensorData actual read_sensor(); TEST_ASSERT_EQUAL_MEMORY(expected, actual, sizeof(SensorData));注意TEST_ASSERT_EQUAL_MEMORY是逐字节比较要求内存布局完全一致。如果结构体包含指针指向动态内存比较的是指针地址本身而不是指针指向的内容。问题6有些测试用例想暂时跳过不执行怎么办解决Unity提供了TEST_IGNORE()宏。你可以把它放在测试用例函数体的开头。void test_some_experimental_feature(void) { TEST_IGNORE(); // 这个测试不会被执行 TEST_ASSERT_EQUAL(42, experimental_func()); }在测试报告中这个测试会被标记为IGNORE不计入失败。5.3 测试设计与组织问题问题7测试用例之间相互干扰没有完全独立。现象单独运行测试A能过但连续运行A和BB就失败。或者顺序调换结果又不同。原因测试用例依赖了全局变量或外部状态且上一个测试修改了该状态没有清理。解决使用setUp和tearDown将每个测试用例所需的初始化和清理工作放在这两个函数里。确保每个测试开始前环境都是一致的。避免使用全局变量如果被测函数必须依赖某个全局状态考虑将其作为参数传入这样在测试中更容易控制。遵循“单元”测试原则一个测试只测一个函数或一个很小的功能点目标明确不依赖其他未测试的部分。问题8如何测试静态函数static function挑战C语言的static函数只在当前文件内可见测试代码无法直接调用。常见方案条件编译在源文件中通过宏在测试时改变函数的链接属性。不推荐污染产品代码// checksum.c #ifdef UNIT_TEST #define STATIC_TESTABLE #else #define STATIC_TESTABLE static #endif STATIC_TESTABLE uint8_t helper_function(void) { ... }然后在测试构建时定义UNIT_TEST宏。将测试代码放在同一个文件将测试代码直接写在源文件里用#ifdef UNIT_TEST包裹。更不推荐混合严重最佳实践不直接测试静态函数。静态函数通常是公有函数的内部辅助函数。你应该通过测试公有函数来间接覆盖静态函数的行为。如果静态函数复杂到需要独立测试那它可能应该被提取出来成为一个独立的、非静态的函数。这促使你思考更好的模块划分。问题9测试输出太多想只看到失败信息。解决Unity默认输出所有测试结果。你可以通过定义UNITY_OUTPUT_COLOR宏如果终端支持来获得彩色输出但无法直接关闭成功信息。一个变通方法是在验证通过后将测试结果重定向到一个文件然后用脚本分析。或者你可以修改Unity的源码unity.c中的UnityPrint相关函数但一般不推荐。5.4 进阶技巧测试驱动开发TDD与持续集成当你熟悉了基础测试后可以尝试更高效的开发模式测试驱动开发在写产品代码之前先写测试用例。比如你要实现一个filter_data函数先想好它的接口和预期行为然后在test_filter.c里写下test_filter_should_remove_negative_values等测试用例。此时运行测试肯定是失败的红。然后你再开始写filter_data的实现直到所有测试通过绿。这个过程能帮你厘清需求设计出更合理的接口。与CI/CD集成将你的测试套件集成到像Jenkins、GitLab CI这样的持续集成工具中。每次代码提交后自动在服务器上拉取代码、编译、运行所有单元测试。任何导致测试失败的提交都会被立即发现保证主分支代码的稳定性。对于嵌入式项目这通常意味着CI服务器上需要安装对应的交叉编译工具链。Unity的初体验之旅到这里就差不多了。它就像一把钥匙打开了一扇通往更稳健、更可维护的嵌入式软件开发的大门。一开始可能会觉得写测试代码有点繁琐但当你第一次因为它而避免了一个深夜的调试或者自信地重构了一段复杂代码而所有测试依然绿灯时你就会觉得这一切都是值得的。

相关新闻