嵌入式开发里,我最常被问到的一句话就是:“代码逻辑不对,但我不知道是哪儿不对。”传统做法是下板子、接串口、打断点、看波形,一个电机控制模块改七八次,每次验证半小时,人还容易看花眼。后来我把业务模块从工程里拆出来,用Keil搭了一套自动化单元测试框架,把Unity跑在软件仿真环境里,几十个测试用例一键执行,几秒钟出结果,哪一行断言挂了、哪个边界值漏了,清清楚楚。这篇文章就把这套方案完整拆给你——从为什么选Unity,到Keil里怎么配、怎么写用例、怎么排查坑,一步不落。
1. 为什么嵌入式工程需要单元测试,以及为什么选Unity
1.1 嵌入式软件测试的现状与痛点
很多嵌入式项目的“测试”其实停留在集成测试阶段:全部代码写完,编译烧录到板卡,然后通过串口打印、逻辑分析仪波形、甚至人肉观察来判断功能是否正确。这种模式有几个很普遍的问题:
- 问题定位慢。一个复杂的交互逻辑,现象可能出现在A模块,根源却在B模块。等整机跑起来再定位,中间隔着初始化、中断、调度,甚至硬件时序,很难第一时间锁定是哪一行代码导致的。
- 回归成本高。改了一个Bug,怎么保证没把其他功能改坏?手工测试只能覆盖主流程,边界输入、异常分支往往被忽略。等产品发布后出问题,代价就大了。
- 环境依赖重。有些团队没有专门的硬件测试台,开发工程师要排队用板子;还有的模块依赖传感器、通信设备,没有这些外设根本没法验证。
单元测试解决的就是这个层次的问题:只测一个函数、一个模块、一条逻辑分支,在不需要其他模块配合的情况下,输入一组数据,断言输出是否符合预期。它像组装一台机器之前先单独检验每个零件,而不是等整机装好再通电,通电冒烟了才到处找原因。对嵌入式软件来说,这个思路尤其重要——底层驱动和算法逻辑的Bug如果拖到系统联调阶段才暴露,排查需要翻越中断、时序、引脚复用这些额外复杂度,成本会成倍上升。
1.2 常用的嵌入式单元测试方案对比
做嵌入式单元测试,市面上常用的方案大概有几种:Unity、CppUTest、Ceedling,还有一些团队自己写的极简断言宏。我整理了一个对比表,方便你按项目情况选型:
| 方案 | 语言 | 资源占用 | Keil集成难度 | 特点 |
|---|---|---|---|---|
| Unity(ThrowTheSwitch) | C | 极低(RAM几十字节级别) | 低,直接加源文件即可 | 纯C、无依赖、源码清晰 |
| CppUTest | C/C++ | 中等 | 中等,C++工程需额外配置 | 功能丰富,适合C++项目 |
| Ceedling | C | 取决于底层,通常搭配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框架工作原理与核心API
2.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。
- 点击全速运行(F8),Unity测试结果就会打印出来。
如果发现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)); /* 这时head=tail=0,重新写入,验证绕回后仍正常 */ 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.原因很简单:测试工程里有测试文件的main,Keil自动生成的启动文件或产品业务代码里也有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 UV4=C:\Keil_v5\UV4\UV4.exe set PROJECT=test_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之后,就再也回不去那种“手工改参数、烧录、看串口”的循环了。建议你找一个逻辑复杂度适中、跟硬件耦合不深的模块,按照这篇文章的步骤,把第一个测试跑起来,然后逐步扩大覆盖范围——这就是嵌入式工程走向高质量、可维护之路最实在的一步。