ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

在Keil中用Unity搭建嵌入式单元测试框架的完整指南

2026/10/5 1:28:57 拓冰建站 浏览量
在Keil中用Unity搭建嵌入式单元测试框架的完整指南 嵌入式开发里我最常被问到的一句话就是“代码逻辑不对但我不知道是哪儿不对。”传统做法是下板子、接串口、打断点、看波形一个电机控制模块改七八次每次验证半小时人还容易看花眼。后来我把业务模块从工程里拆出来用Keil搭了一套自动化单元测试框架把Unity跑在软件仿真环境里几十个测试用例一键执行几秒钟出结果哪一行断言挂了、哪个边界值漏了清清楚楚。这篇文章就把这套方案完整拆给你——从为什么选Unity到Keil里怎么配、怎么写用例、怎么排查坑一步不落。1. 为什么嵌入式工程需要单元测试以及为什么选Unity1.1 嵌入式软件测试的现状与痛点很多嵌入式项目的“测试”其实停留在集成测试阶段全部代码写完编译烧录到板卡然后通过串口打印、逻辑分析仪波形、甚至人肉观察来判断功能是否正确。这种模式有几个很普遍的问题问题定位慢。一个复杂的交互逻辑现象可能出现在A模块根源却在B模块。等整机跑起来再定位中间隔着初始化、中断、调度甚至硬件时序很难第一时间锁定是哪一行代码导致的。回归成本高。改了一个Bug怎么保证没把其他功能改坏手工测试只能覆盖主流程边界输入、异常分支往往被忽略。等产品发布后出问题代价就大了。环境依赖重。有些团队没有专门的硬件测试台开发工程师要排队用板子还有的模块依赖传感器、通信设备没有这些外设根本没法验证。单元测试解决的就是这个层次的问题只测一个函数、一个模块、一条逻辑分支在不需要其他模块配合的情况下输入一组数据断言输出是否符合预期。它像组装一台机器之前先单独检验每个零件而不是等整机装好再通电通电冒烟了才到处找原因。对嵌入式软件来说这个思路尤其重要——底层驱动和算法逻辑的Bug如果拖到系统联调阶段才暴露排查需要翻越中断、时序、引脚复用这些额外复杂度成本会成倍上升。1.2 常用的嵌入式单元测试方案对比做嵌入式单元测试市面上常用的方案大概有几种Unity、CppUTest、Ceedling还有一些团队自己写的极简断言宏。我整理了一个对比表方便你按项目情况选型方案语言资源占用Keil集成难度特点UnityThrowTheSwitchC极低RAM几十字节级别低直接加源文件即可纯C、无依赖、源码清晰CppUTestC/C中等中等C工程需额外配置功能丰富适合C项目CeedlingC取决于底层通常搭配Unity中高需要Ruby环境和GCC工具链自动化构建、Mock生成自研断言宏C极低低简单但功能有限无框架支撑对大多数Keil用户来说我首选Unity理由很直接第一纯C语言零依赖。Unity核心实现只有三个文件unity.c、unity.h、unity_internals.h不依赖任何第三方库直接拖进Keil工程就能编译。很多嵌入式项目还在用C90风格但Keil MDK开了C99模式后编译Unity毫无压力这对老旧的STM32、C51工程非常友好。第二资源占用极低。Unity本身在内存占用上非常克制跑在Cortex-M0这种小资源芯片上也没有问题。它不需要操作系统支持不需要堆甚至不需要标准库完整实现非常适合裸机环境。第三灵活的输出路由。它默认用putchar输出测试结果但可以通过宏UNITY_OUTPUT_CHAR重定向到任意通道比如重定向到串口、SEGGER RTT、ITM或者干脆在软件仿真里用Keil的Debug(printf) Viewer查看完全不需要额外硬件。第四源码透明可读性好。Unity的内部实现非常清晰遇到特殊需求可以直接改源码去适配。我就在一个老项目里改过它的超时打印逻辑几百行代码翻一遍心里就有数了。这里提醒一个容易犯迷糊的点Unity既是嵌入式测试框架也是游戏引擎Unity的名字两者完全没有关系。搜资料的时候记得加关键词“ThrowTheSwitch Unity”或者“Unity C test framework”不然很容易搜到一堆游戏开发内容。2. Unity框架工作原理与核心API2.1 Unity的源码结构与执行流程Unity的运行机制并不复杂核心就是“宏函数指针”的组合。整个框架的思路是开发者注册若干测试函数框架按顺序执行每执行一个函数就检查断言结果最后汇总统计。从源码角度Unity的三个文件分工明确unity.h对外的头文件声明了所有断言宏和公共接口测试代码只需要包含这个头文件。unity.c框架实现负责管理测试状态、执行测试用例、输出结果。UnityBegin、UnityEnd、UnityConcludeTest这些核心函数都在里面。unity_internals.h内部数据结构定义包括测试状态结构体、结果枚举、输出函数等。正常情况下不需要修改但源码级定制时会用到。一次完整的测试执行流程是这样的调用UNITY_BEGIN()初始化Unity内部状态清空用例计数、失败计数、忽略计数。按顺序调用RUN_TEST(函数名)每个RUN_TEST执行一个测试函数。执行前框架自动调用setUp()执行后自动调用tearDown()。这两个函数不需要时可以在测试文件里定义成空的。测试函数内部调用TEST_ASSERT_XXX系列断言断言失败会记录失败信息并通过longjmp跳过当前测试函数剩余部分继续执行下一条用例不会卡死整个测试流程。所有测试执行完后调用UNITY_END()框架打印汇总信息并返回失败用例个数可以交给自动化脚本判断测试是否通过。这种“一个测试函数代表一个用例”的设计非常直观比某些重量级框架的注解、标签机制好理解得多。在Keil里调试的时候你甚至可以在断言宏处打断点直接看是哪个函数、哪一行断言失败了定位速度非常快。2.2 断言宏速查从布尔到字符串Unity的断言宏覆盖了常见的数据类型日常使用基本不用自己写条件判断。常用的几类我列出来分类断言宏说明布尔TEST_ASSERT_TRUE(cond)/TEST_ASSERT_FALSE(cond)验证条件为真/假整型TEST_ASSERT_EQUAL_INT(expected, actual)验证两个int相等无符号整型TEST_ASSERT_EQUAL_UINT(expected, actual)类似上面无符号比较十六进制TEST_ASSERT_EQUAL_HEX(expected, actual)打印结果以十六进制显示非常适合寄存器、帧数据比较浮点TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual)允差范围内的浮点比较因为浮点有精度误差直接用EQUAL很容易挂双精度TEST_ASSERT_DOUBLE_WITHIN(delta, expected, actual)双精度版字符串TEST_ASSERT_EQUAL_STRING(str1, str2)比较字符串内容不是比较指针内存TEST_ASSERT_EQUAL_MEMORY(expected, actual, len)比较内存区域的每个字节空指针TEST_ASSERT_NULL(ptr)/TEST_ASSERT_NOT_NULL(ptr)验证指针每个宏都有对应的_MESSAGE版本比如TEST_ASSERT_TRUE_MESSAGE(cond, msg)断言失败时在输出里附带一段你自定义的说明文字调试时强烈建议用这个能省去对照代码猜含义的时间TEST_ASSERT_TRUE_MESSAGE(ring_buffer_is_empty(rb), buffer should be empty after init);还有一个在嵌入式场景下很关键的点Unity支持用TEST_ASSERT_BITS和TEST_ASSERT_BITS_HIGH、TEST_ASSERT_BITS_LOW来验证寄存器的某几位是否符合预期。比如检查状态寄存器第3位是否为1用TEST_ASSERT_BITS_HIGH(0x08, regValue)输出信息会精确到哪一位不匹配。处理外设寄存器时这个宏比直接比较整个寄存器值直观得多。2.3 setUp与tearDown的生命周期Unity里setUp和tearDown的设计借鉴了经典的xUnit模式目的很简单每个测试用例执行前后都重新初始化环境让用例之间完全隔离。比如要测试一个环形缓冲区每条用例开始前都需要一个干净的缓冲区实例。你可以把它放在setUp里static ring_buffer_t rb; static uint8_t buffer_storage[64]; void setUp(void) { ring_buffer_init(rb, buffer_storage, sizeof(buffer_storage)); } void tearDown(void) { // 一般用于释放资源裸机环境下通常为空 }这样每条测试用例拿到的rb都是刚初始化好的状态第一条用例写入的数据不会影响到第二条用例。这条规则对保持测试稳定性非常重要尤其是用例增多以后数据残留导致的“偶发失败”是最难排查的问题之一。tearDown在裸机环境下用途相对少但如果你测试的是带动态内存分配、或者需要关闭外设的模块在这里做清理就很合适。3. 在Keil中从零搭建一个Unity测试工程3.1 准备Unity源码与工程骨架首先从GitHub下载Unity源码搜“ThrowTheSwitch Unity”即可。仓库目录下的src文件夹里就是我们需要的三个核心文件unity.cunity.hunity_internals.h在Keil里做单测我建议单独建一个测试工程而不是直接塞进现有产品工程。原因是测试工程有自己的main函数这个main负责跑测试用例跟业务代码里的main冲突另外测试工程加了很多测试文件混在一起久了会把业务工程搞乱。目录结构我一般这么组织project_root/ ├── app/ # 产品业务代码模块 │ ├── src/ │ │ ├── ring_buffer.c │ │ └── ring_buffer.h │ └── test/ # 本模块的测试目录 │ ├── unity/ │ │ ├── unity.c │ │ ├── unity.h │ │ └── unity_internals.h │ ├── test_ring_buffer.c │ └── test_project.uvprojx把被测模块的源码放进测试工程里和测试文件一起编译。这样既能直接测试真实业务代码又不会影响原产品工程。3.2 新建Keil工程并配置编译选项下面以STM32F103C8T6为例演示怎么建工程。新硬件平台也能套用同样的步骤。打开Keil μVision5点击Project - New μVision Project给工程起名test_project.uvprojx保存到测试目录。选择芯片型号。STM32F103C8T6在STMicroelectronics目录下能找到。弹出的Manage Run-Time Environment窗口里可以都不勾选直接OK。这里不需要Device相关的组件Unity测试跑的是PC仿真模拟环境不需要初始化时钟树和外设驱动。左侧Project窗口里右键Target 1 - Manage Project Items添加源文件Unity的unity.c、被测模块的ring_buffer.c、测试文件test_ring_buffer.c。头文件路径记得在Options for Target - C/C - Include Paths里配置。关键一步Options for Target - C/C - Language / Code Generation勾选C99 Mode。Unity源码用到了C99特性不开启可能报变量声明位置、for循环内声明变量的编译错误。如果不使用系统默认的启动文件也可以添加一个简单的启动文件或在Device选项里选“Use Memory Layout from Target Dialog”。实际上仿真模式下只要芯片型号选对Keil会自动带上必要的启动代码不勾选组件也能跑。但如果编译报找不到SystemInit或__main之类的问题你就补一个最精简的启动文件。在Debug选项卡里选择Use Simulator这一步很关键——它决定了测试能不能在没有板卡的情况下直接跑起来。下图这种配置就是典型做法仿真器选择ULINK2/3或ST-Link都无所谓关键是先勾选Use Simulator。配置完成后工程就能编译了。这里有一点要说明我们是把测试工程单纯作为一个“可执行程序”来跑的跑的平台是ARM指令集模拟器不是真实硬件。Unity测试用例本身输出纯字符由fputc重定向到调试通道我们就能在电脑屏幕上直接看到结果。3.3 输出路由仿真模式下用Debug(printf) Viewer查看结果测试跑起来了结果怎么看最简单的办法是用Keil自带的软件仿真加上ITM通道把printf输出直接打到Debug(printf) Viewer窗口。这个办法不需要任何硬件连接打开仿真、全速运行测试结果就印在屏幕上了。要做这一步测试代码里需要实现一个重定向函数/* 重定向printf输出到ITM调试通道用于软件仿真 */ int fputc(int ch, FILE *f) { ITM_SendChar(ch); return ch; }ITM_SendChar是Cortex-M内核调试模块提供的函数在仿真模式下由Keil模拟器实现不需要真实芯片也能工作。如果不确定当前环境是否支持ITM_SendChar也可以在fputc里直接调用UART外设寄存器发送但软件仿真模式没有实际引脚输出只有ITM或串口模拟窗口能直观看到数据。运行步骤编译通过后点击Debug - Start/Stop Debug Session进入调试模式。打开View - Serial Windows - Debug (printf) Viewer。点击全速运行F8Unity测试结果就会打印出来。如果发现Viewer什么都没显示先检查两个地方Options for Target - Debug - Use Simulator是否勾选。fputc重定向是否正确尤其注意不要同时使用MicroLIB又手动重定向fputc。用MicroLIB时fputc重定向逻辑有时候编译过了但不生效建议不勾选MicroLIB直接标准C库加重定向最省心。还有一种方案是接真实板子把输出重定向到串口用串口助手看结果。这个适用于需要测试真实外设读写的场景但从单元测试“快速反馈”的角度仿真模式才是效率最高的。测纯逻辑模块我几乎都用仿真。3.4 跑通第一个测试完整代码示例有了工程骨架和输出路由再来一个最小可运行的测试文件。为了让你能直接复制我拿一个很简单的整数加法函数来做第一个示例。先写被测模块/* math_util.h */ #ifndef MATH_UTIL_H #define MATH_UTIL_H int add_with_limit(int a, int b, int limit); #endif /* math_util.c */ #include math_util.h int add_with_limit(int a, int b, int limit) { int sum a b; if (sum limit) { return limit; } return sum; }再写测试文件/* test_math_util.c */ #include unity.h #include math_util.h void setUp(void) { } void tearDown(void) { } void test_add_with_limit_normal(void) { TEST_ASSERT_EQUAL_INT(5, add_with_limit(2, 3, 10)); } void test_add_with_limit_overflow(void) { TEST_ASSERT_EQUAL_INT(10, add_with_limit(7, 8, 10)); } void test_add_with_limit_negative(void) { TEST_ASSERT_EQUAL_INT(-2, add_with_limit(-5, 3, 10)); } int main(void) { UNITY_BEGIN(); RUN_TEST(test_add_with_limit_normal); RUN_TEST(test_add_with_limit_overflow); RUN_TEST(test_add_with_limit_negative); return UNITY_END(); }编译、进入仿真、全速运行后Debug(printf) Viewer会输出Unity test run 1 of 1 test_add_with_limit_normal PASS test_add_with_limit_overflow PASS test_add_with_limit_negative PASS ----------------------- 3 Tests 0 Failures 0 Ignored OK看到这个结果说明整个框架已经跑通了。后面要做的就是把你真正的业务模块加进来开始写有意义的测试用例。4. 实战给环形缓冲区写一组可自动化运行的测试4.1 被测代码的可测试性设计只跑一个加法函数有点太简单真正能体现单元测试价值的是那种有状态、有边界条件的模块。这里我选嵌入式开发里很常见的环形缓冲区它的状态逻辑多稍不留神就会写错非常适合做测试演示。先强调一个原则想让代码好测写的时候就要考虑可测试性。嵌入式代码最难测的地方就是跟硬件绑得太死一个函数里既有业务逻辑又直接操作寄存器、调用延时那就没法在仿真环境下做隔离。把纯逻辑跟硬件解耦是嵌入式单元测试能不能落地的关键一步。我用一个不依赖任何硬件的环形缓冲区示例/* ring_buffer.h */ #ifndef RING_BUFFER_H #define RING_BUFFER_H #include stdint.h #include stdbool.h typedef struct { uint8_t *buffer; uint32_t capacity; uint32_t head; uint32_t tail; uint32_t count; } ring_buffer_t; void ring_buffer_init(ring_buffer_t *rb, uint8_t *storage, uint32_t capacity); bool ring_buffer_is_empty(ring_buffer_t *rb); bool ring_buffer_is_full(ring_buffer_t *rb); bool ring_buffer_write(ring_buffer_t *rb, uint8_t data); bool ring_buffer_read(ring_buffer_t *rb, uint8_t *data); #endif/* ring_buffer.c */ #include ring_buffer.h void ring_buffer_init(ring_buffer_t *rb, uint8_t *storage, uint32_t capacity) { rb-buffer storage; rb-capacity capacity; rb-head 0; rb-tail 0; rb-count 0; } bool ring_buffer_is_empty(ring_buffer_t *rb) { return (rb-count 0); } bool ring_buffer_is_full(ring_buffer_t *rb) { return (rb-count rb-capacity); } bool ring_buffer_write(ring_buffer_t *rb, uint8_t data) { if (ring_buffer_is_full(rb)) { return false; } rb-buffer[rb-head] data; rb-head (rb-head 1) % rb-capacity; rb-count; return true; } bool ring_buffer_read(ring_buffer_t *rb, uint8_t *data) { if (ring_buffer_is_empty(rb)) { return false; } *data rb-buffer[rb-tail]; rb-tail (rb-tail 1) % rb-capacity; rb-count--; return true; }这个设计把存储区作为参数传入不依赖任何外部内存分配和硬件外设所以可以直接被Unity测试工程编译运行。真实项目中UART接收中断、DMA回调用到的缓冲区完全可以用这同一个模块。4.2 测试用例设计与覆盖点测试用例怎么写直接决定了单测能不能发现Bug。我的习惯是先把模块的行为边界列出来再针对每一个边界写断言。对环形缓冲区至少要考虑这些场景初始化后缓冲区为空且不为满。写入一个字节后缓冲区不为空。写入一个字节后再读出来值一致。缓冲区填满后is_full返回真。缓冲区满了继续写返回失败且数据不丢失。缓冲区空了继续读返回失败。写入N个字节后全部读出顺序保持不变验证先进先出。绕回wrap-around场景写入满一圈后head回到0继续读写依然正确。把这些场景翻译成测试代码/* test_ring_buffer.c */ #include unity.h #include ring_buffer.h #define BUFFER_SIZE 4 static ring_buffer_t rb; static uint8_t storage[BUFFER_SIZE]; void setUp(void) { ring_buffer_init(rb, storage, BUFFER_SIZE); } void tearDown(void) { } void test_init_should_be_empty_and_not_full(void) { TEST_ASSERT_TRUE(ring_buffer_is_empty(rb)); TEST_ASSERT_FALSE(ring_buffer_is_full(rb)); } void test_write_one_byte_should_not_be_empty(void) { TEST_ASSERT_TRUE(ring_buffer_write(rb, 0xAB)); TEST_ASSERT_FALSE(ring_buffer_is_empty(rb)); TEST_ASSERT_FALSE(ring_buffer_is_full(rb)); } void test_write_read_one_byte_should_match(void) { uint8_t data 0; TEST_ASSERT_TRUE(ring_buffer_write(rb, 0xAB)); TEST_ASSERT_TRUE(ring_buffer_read(rb, data)); TEST_ASSERT_EQUAL_HEX(0xAB, data); TEST_ASSERT_TRUE(ring_buffer_is_empty(rb)); } void test_write_until_full_should_report_full(void) { for (int i 0; i BUFFER_SIZE; i) { TEST_ASSERT_TRUE(ring_buffer_write(rb, (uint8_t)i)); } TEST_ASSERT_TRUE(ring_buffer_is_full(rb)); TEST_ASSERT_FALSE(ring_buffer_write(rb, 0xFF)); } void test_write_when_full_should_keep_old_data(void) { uint8_t data 0; for (int i 0; i BUFFER_SIZE; i) { ring_buffer_write(rb, (uint8_t)(i 1)); } /* 满了以后再写应当失败 */ TEST_ASSERT_FALSE(ring_buffer_write(rb, 0x99)); /* 旧数据不能丢 */ TEST_ASSERT_TRUE(ring_buffer_read(rb, data)); TEST_ASSERT_EQUAL_HEX(1, data); } void test_read_when_empty_should_fail(void) { uint8_t data 0; TEST_ASSERT_FALSE(ring_buffer_read(rb, data)); } void test_fifo_order_should_be_preserved(void) { uint8_t data 0; ring_buffer_write(rb, 0x10); ring_buffer_write(rb, 0x20); ring_buffer_write(rb, 0x30); TEST_ASSERT_TRUE(ring_buffer_read(rb, data)); TEST_ASSERT_EQUAL_HEX(0x10, data); TEST_ASSERT_TRUE(ring_buffer_read(rb, data)); TEST_ASSERT_EQUAL_HEX(0x20, data); TEST_ASSERT_TRUE(ring_buffer_read(rb, data)); TEST_ASSERT_EQUAL_HEX(0x30, data); } void test_wrap_around_should_still_work(void) { uint8_t data 0; /* 先写满再全部读出 */ for (int i 0; i BUFFER_SIZE; i) { ring_buffer_write(rb, (uint8_t)(i 1)); } for (int i 0; i BUFFER_SIZE; i) { ring_buffer_read(rb, data); } TEST_ASSERT_TRUE(ring_buffer_is_empty(rb)); /* 这时headtail0重新写入验证绕回后仍正常 */ TEST_ASSERT_TRUE(ring_buffer_write(rb, 0xAA)); TEST_ASSERT_TRUE(ring_buffer_read(rb, data)); TEST_ASSERT_EQUAL_HEX(0xAA, data); TEST_ASSERT_TRUE(ring_buffer_is_empty(rb)); } int main(void) { UNITY_BEGIN(); RUN_TEST(test_init_should_be_empty_and_not_full); RUN_TEST(test_write_one_byte_should_not_be_empty); RUN_TEST(test_write_read_one_byte_should_match); RUN_TEST(test_write_until_full_should_report_full); RUN_TEST(test_write_when_full_should_keep_old_data); RUN_TEST(test_read_when_empty_should_fail); RUN_TEST(test_fifo_order_should_be_preserved); RUN_TEST(test_wrap_around_should_still_work); return UNITY_END(); }把这些代码加入工程编译进入仿真全速运行Debug(printf) Viewer会输出类似Unity test run 1 of 1 test_init_should_be_empty_and_not_full PASS test_write_one_byte_should_not_be_empty PASS test_write_read_one_byte_should_match PASS test_write_until_full_should_report_full PASS test_write_when_full_should_keep_old_data PASS test_read_when_empty_should_fail PASS test_fifo_order_should_be_preserved PASS test_wrap_around_should_still_work PASS ----------------------- 8 Tests 0 Failures 0 Ignored OK如果你在写环形缓冲区的时候把head和tail的更新逻辑搞错比如取模运算写错对应的测试用例立刻就会报FAIL并且Keil会精确告诉你哪个测试函数挂掉了。这比把代码烧到板子上通过串口输出慢慢分析要快得多。4.3 仿真模式下查看覆盖率测试用例写得好不好覆盖率是一个重要指标。Keil软件仿真模式自带代码覆盖率统计功能虽然不像专业的覆盖率工具那么强大但对嵌入式项目来说够用了。使用方式进入调试模式点击Debug - Function Coverage或者从View菜单打开覆盖窗口。全速运行测试用例之后打开Coverage窗口可以看到每个函数的执行次数和覆盖比例。还可以查看具体哪些代码行没有被执行从而补充缺失的测试用例。比如ring_buffer_write里的if (ring_buffer_is_full(rb))这条分支如果没写“写满后继续写返回失败”的用例覆盖率统计里就会显示该分支未被覆盖提醒你补用例。这个反馈闭环正是单测的核心价值不是为写而写而是通过统计发现测试盲区让测试真正保护代码逻辑。5. 常见问题与排查技巧速查5.1 输出乱码或无输出这是Keil Unity最常见的首坑。现象就是Debug(printf) Viewer没有输出或者输出一堆乱码。排查顺序确认Options for Target - Debug里勾选了Use Simulator这是仿真模式下printf输出的前提。确认fputc重定向写的是ITM_SendChar(ch)并且包含了正确的头文件。ITM相关函数在Cortex-M内核设备头文件里有定义工程里通常已经包含了。不要同时启用MicroLIB和自己的fputc重定向某些情况下MicroLIB会绕过fputc导致printf输出不到Viewer。建议统一用标准C库。检查Viewer窗口是否打开View - Serial Windows - Debug (printf) Viewer。如果输出乱码多半是字符编码或打印格式问题。Unity输出纯ASCII字符正常情况不会乱码乱码时要看是不是ITM的时钟配置和仿真器不匹配。如果是真实板卡用串口输出还要额外检查波特率、串口引脚初始化、外部晶振频率是否跟代码配置一致。但单测阶段我强烈建议优先用仿真模式省下的时间远不止调串口那半小时。5.2 main函数冲突与链接错误最常见的链接错误是Error: L6200E: Symbol main multiply defined.原因很简单测试工程里有测试文件的mainKeil自动生成的启动文件或产品业务代码里也有main。解决思路是测试工程只编译Unity源文件、被测模块源文件和测试源文件不要拖入产品工程里的main.c和启动初始化代码。如果被测模块里有强符号跟现有工程冲突可以尝试用条件编译把业务main屏蔽掉或者把测试工程单独放在一个干净目录里。如果报SystemInit未定义这类错误说明工程缺少启动文件或芯片配置。可以给测试工程添加对应芯片的Startup文件也可以简化操作在Options for Target - Device里重新选一次芯片型号让Keil自动补全启动代码。5.3 断言失败后进入HardFault或死机单元测试应该在断言失败后自动跳转下一条用例但有时候会直接卡死进入HardFault。这个现象在Keil仿真或真实板上都有可能出现。主要原因一般是栈溢出。Unity的longjmp跳转需要一定栈空间如果任务栈或系统栈分配太小长跳转时栈指针越界就会进入硬件异常。解决方法是调大栈空间在启动文件里修改Stack_Size比如从0x400改成0x1000仿真模式下同样生效。另一个原因是测试代码访问了非法内存。比如ring_buffer_read的入参是空指针没有判空直接写*data断言失败后继续执行可能二次触发异常。这种场景下建议被测代码本身对公共接口做必要的防御测试用例也尽量传入合法参数把重点放在业务逻辑上。5.4 浮点断言总是失败嵌入式里浮点比较是很常见的坑直接比较两个浮点数是否相等往往因为精度问题导致“该过的测试过不了”。比如TEST_ASSERT_EQUAL_FLOAT(0.1f 0.2f, 0.3f);这个断言很可能失败因为0.1f 0.2f在二进制浮点表示里并不精确等于0.3f。解决办法是使用带容差的断言宏TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.3f, 0.1f 0.2f);WITHIN版本会计算两个值的差只要在指定容差范围内就算通过。实际项目中控制算法的浮点输出、PID参数计算都建议用这种方式。别以为“既然误差小就直接比较”浮点二进制的舍入误差在某些边界值上会被放大测试的时候多留一点容差是明智的。5.5 命令行自动化构建与CI集成单元测试的优势在于可以重复执行。如果你想把测试融入日常开发流程每次改动代码都能自动跑一遍那就要用命令行方式调用Keil编译。Keil MDK自带命令行编译接口路径一般是C:\Keil_v5\UV4\UV4.exe命令行编译工程C:\Keil_v5\UV4\UV4.exe -b test_project.uvprojx -t Target 1 -o build_log.txt其中-b表示构建build-t指定目标名-o输出日志。构建成功后再启动仿真运行测试可以通过批处理或CI脚本把输出重定向到文件然后检查日志里是否有Failures: 0。Unity还支持输出JUnit XML格式只要在unity_internals.h里定义UNITY_OUTPUT_JUNIT宏或者通过命令行参数如果支持指定输出格式。这样CI系统比如Jenkins、GitLab CI就能直接解析测试报告在网页上直观展示测试通过率。一个简单的批处理示例echo off set UV4C:\Keil_v5\UV4\UV4.exe set PROJECTtest_project.uvprojx rem 1. Build %UV4% -b %PROJECT% -t Target 1 -o build_log.txt if errorlevel 1 ( echo Build Failed type build_log.txt exit /b 1 ) rem 2. Run tests in simulation mode is a bit more complex, rem but you can also build a host runnable version with Unity output. echo Build OK, please run simulation manually or integrate with a host runner实际上要把仿真测试也彻底自动化更进阶的做法是在PC上用GCC编译同样的测试代码直接跑一个Windows/Linux可执行文件输出Unity结果。这样CI里不需要安装Keil也能跑测试且速度更快。我一般两个环境都保留本地用Keil仿真看输出CI用GCC跑宿主可执行程序。这套模式的详细做法后面有机会再单独写一篇。6. 从能跑到跑好测试工程结构建议最后聊一点实战工程经验。很多团队单测跑不起来不是不会用Unity而是工程结构没有组织好。我踩过坑之后形成了一套自己的组织方式供你参考。按模块划分测试目录。每个业务模块一个测试文件比如test_ring_buffer.c、test_pid_controller.c放在模块目录下的test子目录里。模块多了以后再用一个tests汇总目录配置多个测试工程或者引入Ceedling统一管理。但初期不必搞得太复杂一个测试工程放所有测试文件就行编译运行都方便。区分“可测代码”和“硬件相关代码”。编写业务模块时尽量把纯逻辑独立成函数不包含寄存器操作和延时。比如传感器模块底层I2C读写封装成i2c_read_reg上层把原始数据转成温度值的temperature_from_raw就是纯逻辑可以独立测试。写单测时重点测temperature_from_raw这类函数底层的I2C时序靠硬件调试解决。测试用例命名要能表达场景。我用test_模块_行为_条件的格式比如test_buffer_init_should_be_empty。一旦测试失败看名字就能知道是哪个模块、预期什么行为、在什么条件下失败不用点进代码逐行分析。把测试纳入日常开发流程。每改一个功能点跑一遍相关模块的测试每次提交代码前完整跑一遍全部测试。坚持一段时间你会明显感觉到回归Bug变少了改动代码的信心也足了——这比任何代码评审工具都更直接。我在实际项目里的体会是把Unity集成进Keil这件事本身并不难核心障碍往往是“习惯”二字。嵌入式开发者习惯了板级调试一开始觉得单测“太绕”。但等你第一次看到几十个用例在几秒钟内全部PASS并且确实拦住了一个边界条件导致的隐藏Bug之后就再也回不去那种“手工改参数、烧录、看串口”的循环了。建议你找一个逻辑复杂度适中、跟硬件耦合不深的模块按照这篇文章的步骤把第一个测试跑起来然后逐步扩大覆盖范围——这就是嵌入式工程走向高质量、可维护之路最实在的一步。