4. 单元测试框架选型与搭建:Ceedling/Unity/CMock在嵌入式环境中的移植与配置

说到嵌入式单元测试,很多人第一反应是:“我的代码跑在STM32上,怎么在PC上测?”

这个问题我十年前也问过自己。那时候项目组还在用手动打桩的方式做测试,每次改完一个模块,光搭测试环境就要半天。后来接触了Ceedling这套工具链,才算是真正把测试效率提起来了。

这一章,我就带你把这套东西跑起来。

4.1 为什么是Ceedling + Unity + CMock

先说说这三兄弟的分工:

  • Unity:轻量级测试框架,纯C实现,专门为嵌入式设计。它不依赖标准库,不依赖操作系统,一个头文件加一个C文件就能跑。
  • CMock:自动生成Mock桩代码的工具。你给它一个头文件,它自动生成对应的模拟函数,省去手动写桩的体力活。
  • Ceedling:胶水工具,把Unity和CMock粘在一起,还集成了构建系统、测试运行器、覆盖率统计。

说白了,Ceedling就是帮你把“写测试、生成桩、编译运行、看结果”这一整套流程自动化了。

核心优势:这套工具链完全用Ruby + C实现,不依赖任何嵌入式IDE,可以在Windows/Linux/macOS上统一运行。你只需要一个交叉编译器,就能在PC上测试目标板代码。

4.2 工具链安装与环境搭建

我习惯在Linux环境下做开发,但Windows下用WSL或者Cygwin也一样。下面以Ubuntu为例:

# 安装Ruby(Ceedling基于Ruby)
sudo apt-get install ruby-full build-essential

# 安装Ceedling
gem install ceedling

# 验证安装
ceedling version

安装完成后,创建一个新项目:

ceedling new my_project
cd my_project

你会看到这样的目录结构:

my_project/
├── src/              # 你的源代码
├── test/             # 测试用例
├── lib/              # 第三方库
├── vendor/           # Unity/CMock源码
├── build/            # 编译输出
└── project.yml       # 配置文件

嗯,这里要注意:vendor/目录下已经自带了Unity和CMock的源码,你不需要手动下载。Ceedling在创建项目时自动拉取了最新版本。

4.3 在嵌入式环境中的移植与配置

嵌入式环境最大的问题是:你的代码依赖硬件寄存器、外设库、RTOS。这些在PC上都没有。

怎么办?我的做法是——分层隔离。

4.3.1 配置交叉编译器

打开project.yml,告诉Ceedling用哪个编译器:

:tools:
  :test_compiler:
    :executable: arm-none-eabi-gcc
    :arguments:
      - -mcpu=cortex-m4
      - -mthumb
      - -specs=nano.specs
      - -I"src/"
      - -I"test/"
      - ${1}
  :test_linker:
    :executable: arm-none-eabi-gcc
    :arguments:
      - ${1}
      - -o ${2}

我曾经在一个项目中,因为忘了加-specs=nano.specs,链接时疯狂报错,折腾了半天才发现是newlib的浮点支持问题。所以,交叉编译器的参数一定要和你的目标芯片完全匹配。

4.3.2 处理硬件依赖

对于直接操作寄存器的代码,我通常用两种方式处理:

  1. 函数指针注入:把硬件操作封装成函数指针,测试时替换成Mock。
  2. 条件编译:在测试环境下,用模拟的寄存器地址替换真实地址。

举个例子,假设你的代码里有这样的宏:

#define GPIOA_ODR (*(volatile uint32_t *)0x40020014)

在测试时,可以改成:

#ifdef UNIT_TESTING
    extern uint32_t mock_GPIOA_ODR;
    #define GPIOA_ODR mock_GPIOA_ODR
#else
    #define GPIOA_ODR (*(volatile uint32_t *)0x40020014)
#endif

小技巧:在project.yml中加上:defines: UNIT_TESTING,这样所有测试编译都会自动定义这个宏,不需要手动加。

4.4 测试用例编写规范

工具搭好了,怎么写测试?我总结了一套规范,团队里用了三年,效果不错。

4.4.1 文件命名与组织

源文件 测试文件 说明
src/temperature.c test/test_temperature.c 测试文件统一加test_前缀
src/driver/uart.c test/driver/test_uart.c 目录结构保持一致
src/hal/timer.c test/hal/test_timer.c 方便查找对应关系

4.4.2 测试用例结构模板

每个测试文件的基本骨架:

#include "unity.h"
#include "temperature.h"

// 全局变量,用于测试前后状态管理
static int setup_flag = 0;

// 每个测试用例执行前调用
void setUp(void)
{
    setup_flag = 1;
    // 初始化被测模块
    temperature_init();
}

// 每个测试用例执行后调用
void tearDown(void)
{
    setup_flag = 0;
    // 清理资源
    temperature_deinit();
}

// 测试用例:正常温度转换
void test_temperature_celsius_to_fahrenheit(void)
{
    // Arrange
    int celsius = 100;

    // Act
    int fahrenheit = celsius_to_fahrenheit(celsius);

    // Assert
    TEST_ASSERT_EQUAL_INT(212, fahrenheit);
}

// 测试用例:边界值
void test_temperature_celsius_to_fahrenheit_zero(void)
{
    TEST_ASSERT_EQUAL_INT(32, celsius_to_fahrenheit(0));
}

// 测试用例:负数
void test_temperature_celsius_to_fahrenheit_negative(void)
{
    TEST_ASSERT_EQUAL_INT(-4, celsius_to_fahrenheit(-20));
}

注意:setUp()和tearDown()是Unity框架的约定函数名。每个测试用例运行前后都会自动调用它们,确保测试之间互不干扰。我曾经见过有人把全局变量初始化写在测试用例里,结果用例顺序一变就全挂了。

4.4.3 断言使用规范

Unity提供了丰富的断言宏,我常用的几个:

断言宏 用途 示例
TEST_ASSERT_EQUAL_INT 比较整型值 TEST_ASSERT_EQUAL_INT(42, result);
TEST_ASSERT_EQUAL_FLOAT 比较浮点数(带精度) TEST_ASSERT_EQUAL_FLOAT(3.14, pi);
TEST_ASSERT_TRUE 判断是否为真 TEST_ASSERT_TRUE(is_ready);
TEST_ASSERT_NULL 判断指针是否为空 TEST_ASSERT_NULL(ptr);
TEST_ASSERT_EQUAL_STRING 比较字符串 TEST_ASSERT_EQUAL_STRING("OK", msg);

4.4.4 使用CMock处理外部依赖

假设你的温度模块调用了硬件I2C驱动:

// temperature.c
#include "i2c_driver.h"

int read_temperature_sensor(void)
{
    uint8_t data[2];
    if (i2c_read(0x48, 0x00, data, 2) != I2C_OK) {
        return -1;
    }
    return (data[0] << 8) | data[1];
}

在project.yml中配置CMock:

:cmock:
  :mock_path: test/mocks
  :includes:
    - i2c_driver.h
  :treat_externs: :include

然后在测试文件中:

#include "unity.h"
#include "temperature.h"
#include "mock_i2c_driver.h"

void test_read_temperature_sensor_success(void)
{
    // 设置Mock行为:i2c_read返回成功
    i2c_read_ExpectAndReturn(0x48, 0x00, NULL, 2, I2C_OK);
    // 设置Mock输出参数
    i2c_read_ExpectArrayAnyArgs(0x48, 0x00, NULL, 2, I2C_OK);

    int result = read_temperature_sensor();

    TEST_ASSERT_TRUE(result >= 0);
}

void test_read_temperature_sensor_failure(void)
{
    i2c_read_ExpectAndReturn(0x48, 0x00, NULL, 2, I2C_ERROR);

    int result = read_temperature_sensor();

    TEST_ASSERT_EQUAL_INT(-1, result);
}

关键点:CMock生成的Mock函数名规则是原函数名_ExpectAndReturn。它会自动检查参数是否匹配,如果不匹配,测试会直接失败。这比手动写桩要可靠得多。

4.5 运行测试与查看结果

一切就绪后,运行测试只需要一条命令:

ceedling test:all

输出示例:

Test 'test_temperature.c'
-------------------------------
  PASS: test_temperature_celsius_to_fahrenheit
  PASS: test_temperature_celsius_to_fahrenheit_zero
  PASS: test_temperature_celsius_to_fahrenheit_negative
  PASS: test_read_temperature_sensor_success
  PASS: test_read_temperature_sensor_failure
-------------------------------
5 tests, 5 passed, 0 failed, 0 ignored

OVERALL: PASS

如果只想跑单个文件:

ceedling test:test_temperature

4.6 知识体系总览

下面这张图概括了本章的核心逻辑:

单元测试框架选型与搭建知识体系 Ceedling + Unity + CMock 工具链 Unity 测试框架 CMock 自动Mock生成 Ceedling 构建管理 嵌入式移植配置 交叉编译器设置 硬件依赖隔离 测试用例规范 setUp/tearDown结构 断言使用规范 Mock依赖管理 ExpectAndReturn 参数匹配检查 ceedling test:all → 自动化编译 → 运行 → 报告 测试通过/失败报告 + 覆盖率统计 核心原则:测试与目标硬件解耦,通过Mock隔离外部依赖,确保测试可重复、可自动化

4.7 常见问题与避坑

  • 链接错误:undefined reference to 'main'——Ceedling会自动生成main函数,你不需要自己写。如果出现这个错误,检查是否在src/目录下放了包含main函数的文件。
  • Mock函数不匹配——CMock要求Mock函数的调用顺序和参数必须与Expect完全一致。我曾经因为调换了两个Mock调用的顺序,查了整整一个下午。
  • 浮点数比较失败——Unity的TEST_ASSERT_EQUAL_FLOAT默认精度是小数点后4位。如果精度不够,可以用TEST_ASSERT_FLOAT_WITHIN指定容忍范围。

我的习惯:每次提交代码前,跑一遍ceedling test:all。如果测试全绿,再提交。这个习惯帮我拦截了至少80%的回归问题。