3. 嵌入式测试基础

说到嵌入式C语言的单元测试,绕不开三个名字:Unity、CMock、Ceedling。很多初学者一上来就被这三个东西搞晕了——它们到底是什么关系?我该用哪个?

我当年第一次接触这套工具链时,也花了整整两天才理清楚。说白了,Unity是测试框架本身,CMock是自动生成Mock的工具,Ceedling则是把它们打包好的构建管理工具。你可以把Ceedling理解成一个"项目经理",Unity和CMock是它手下的两个"技术骨干"。

3.1 框架选型:为什么是这套组合?

嵌入式领域其实有不少测试框架可选。我列个对比表,你一看就明白为什么我推荐这套组合了。

框架 适用场景 优点 缺点
Unity + CMock + Ceedling 嵌入式C项目(MCU、RTOS) 轻量、无依赖、自动Mock生成 不支持C++
CUnit 桌面C应用 功能全面 依赖libc、不适合交叉编译
Check Linux用户态程序 支持fork模式 嵌入式环境移植困难
Google Test C++项目 社区活跃、功能强大 太重、不适合MCU

我个人习惯在嵌入式项目里首选Unity这套组合。为什么?因为Unity的源码就两个文件——unity.c和unity.h,加起来不到200KB。你想想看,在资源受限的MCU上,这几乎不占什么空间。而且它完全用标准C写成,没有任何外部依赖,交叉编译时特别省心。

核心结论:Unity负责"断言和报告",CMock负责"模拟外部依赖",Ceedling负责"自动化构建"。三者配合,覆盖了单元测试的完整工作流。

3.2 工程初始化:从零搭建测试项目

我记得第一次手动搭建这套环境时,光是配置路径就折腾了半小时。后来发现Ceedling提供了自动初始化命令,一步到位。

先确保你的机器上安装了Ruby(Ceedling基于Ruby开发)。然后执行:

gem install ceedling
ceedling new my_project_test
cd my_project_test

执行完后,你会看到这样的目录结构:

my_project_test/
├── src/              # 存放被测源码
├── test/             # 存放测试用例
├── lib/              # 存放第三方库(含Unity/CMock)
├── build/            # 构建输出目录
└── project.yml       # Ceedling配置文件

这里有个坑,我当初踩过——src/目录默认是空的,你得把被测的C文件放进去。test/目录里放测试文件,命名规则是test_xxx.c或test_xxx.c。Ceedling会自动扫描这些文件并编译执行。

小技巧:如果你想把被测代码和测试代码分开仓库管理,可以在project.yml里通过:paths:字段指定外部源码路径。我一般把业务代码放在../src/,测试工程单独一个目录。

3.3 第一个测试用例:让LED闪烁函数通过测试

光说不练假把式。我们写一个真实的例子——测试一个LED控制函数。

假设被测代码src/led_controller.c:

#include "led_controller.h"

void led_init(void) {
    /* 假设这里操作了硬件寄存器 */
    GPIO_DIR |= (1 << 5);
}

void led_toggle(void) {
    static uint8_t state = 0;
    state = !state;
    if (state) {
        GPIO_OUT |= (1 << 5);
    } else {
        GPIO_OUT &= ~(1 << 5);
    }
}

对应的头文件src/led_controller.h:

#ifndef LED_CONTROLLER_H
#define LED_CONTROLLER_H

#include <stdint.h>

#define GPIO_DIR  (*(volatile uint32_t*)0x40020C00)
#define GPIO_OUT  (*(volatile uint32_t*)0x40020C14)

void led_init(void);
void led_toggle(void);

#endif

现在写测试用例test/test_led_controller.c:

#include "unity.h"
#include "led_controller.h"

void setUp(void) {
    /* 每个测试用例执行前调用 */
}

void tearDown(void) {
    /* 每个测试用例执行后调用 */
}

void test_led_init_should_set_direction_bit(void) {
    /* 先清零,模拟复位状态 */
    GPIO_DIR = 0;
    led_init();
    TEST_ASSERT_EQUAL_HEX32(0x00000020, GPIO_DIR);
}

void test_led_toggle_should_change_output_state(void) {
    GPIO_OUT = 0;
    led_toggle();
    TEST_ASSERT_EQUAL_HEX32(0x00000020, GPIO_OUT);
    
    led_toggle();
    TEST_ASSERT_EQUAL_HEX32(0x00000000, GPIO_OUT);
}

然后在项目根目录运行:

ceedling test:all

你会看到类似这样的输出:

Test 'test_led_controller.c'
  - test_led_init_should_set_direction_bit ... PASS
  - test_led_toggle_should_change_output_state ... PASS

-----------------------
2 tests, 0 failures
-----------------------

看到"PASS"两个字,心里踏实了吧?

注意:上面的例子直接操作了硬件地址。在实际项目中,我强烈建议把硬件访问层抽象出来,用CMock生成Mock对象来模拟寄存器操作。否则你的测试只能在特定硬件上跑,失去了单元测试"脱离硬件"的意义。下一章我们会详细讲Mock。

3.4 知识结构总览

为了让你对整个框架体系有个直观认识,我画了张结构图:

C语言单元测试框架体系 Ceedling(构建管理/自动化) Unity(断言/测试执行) CMock(Mock生成) 被测代码(src/目录) 工作流程说明: 1. Ceedling 解析 project.yml,确定源码和测试路径 2. CMock 根据头文件自动生成 Mock 函数 3. Unity 执行测试用例,输出 PASS/FAIL 报告

3.5 常用断言速查

Unity提供了丰富的断言宏。我整理了一份常用清单,写测试时直接查:

断言宏 用途 示例
TEST_ASSERT_EQUAL 比较两个整数是否相等 TEST_ASSERT_EQUAL(42, result);
TEST_ASSERT_EQUAL_HEX32 比较32位十六进制值 TEST_ASSERT_EQUAL_HEX32(0xFF, reg);
TEST_ASSERT_TRUE 判断表达式是否为真 TEST_ASSERT_TRUE(flag);
TEST_ASSERT_FALSE 判断表达式是否为假 TEST_ASSERT_FALSE(error);
TEST_ASSERT_NULL 判断指针是否为空 TEST_ASSERT_NULL(ptr);
TEST_ASSERT_EQUAL_STRING 比较两个字符串 TEST_ASSERT_EQUAL_STRING("OK", msg);
TEST_ASSERT_EQUAL_FLOAT 比较浮点数(带精度) TEST_ASSERT_EQUAL_FLOAT(3.14, pi);

我的习惯:比较寄存器值时,优先用TEST_ASSERT_EQUAL_HEX32而不是TEST_ASSERT_EQUAL。因为十六进制输出在调试时一眼就能看出哪个bit不对,十进制反而要换算半天。

3.6 避坑指南

做嵌入式单元测试,有几个坑我替你们先踩了:

  • 不要直接测试硬件寄存器——就像上面LED的例子,虽然能跑通,但换一块MCU就废了。正确的做法是用Mock把硬件访问层替换掉。
  • 注意setUp和tearDown的执行时机——每个测试用例执行前都会调用setUp,执行后调用tearDown。如果你在setUp里分配了内存,记得在tearDown里释放,否则跑几百个用例后内存就爆了。
  • 测试文件命名必须符合规则——Ceedling默认只识别test_*.c或*_test.c格式的文件。我见过有人把测试文件命名为led_test_code.c,结果死活跑不起来,排查了半天才发现是命名问题。

我曾经在一个项目里,因为没注意setUp里的全局变量初始化,导致测试结果时好时坏。排查了整整一个下午,最后发现是上一个测试用例改了全局状态,setUp没重置干净。从那以后,我养成了一个习惯:setUp里把所有全局变量都显式初始化一遍,绝不依赖默认值。