3. 单元测试框架:CUnit/Unity框架搭建、桩函数与模拟技术、覆盖率分析(gcov/lcov)

单元测试,说白了就是给代码做「体检」。你写了一个函数,它能不能按预期跑?边界情况处理了没?我见过太多工程师,代码写完直接集成,结果联调时花三天找 bug。其实,很多问题在单元测试阶段就能暴露。

这一章,我们聚焦嵌入式 C 语言的单元测试。我会带你搭两个主流框架——CUnit 和 Unity,然后聊聊桩函数和覆盖率。嗯,都是实战中绕不开的东西。

3.1 为什么嵌入式需要单元测试框架?

你可能会想:「我直接写个 main 函数调用一下不就行了?」

可以,但效率太低。手动测试有几个硬伤:

  • 重复劳动:每次改代码都要重新编译、下载、跑一遍,浪费时间。
  • 难以覆盖边界:手动测试往往只测正常路径,异常分支容易被忽略。
  • 没有回归能力:代码改了,旧功能是否受影响?手动测很难保证。

框架能帮你自动化这些事。你写一次测试用例,后续每次改完代码,跑一下测试集就行。我在项目中吃过亏——有一次改了一个驱动层的延时函数,结果上层协议栈超时,排查了两天。后来加了单元测试,这种问题再也没出现过。

3.2 CUnit 框架搭建与使用

CUnit 是一个轻量级的 C 语言单元测试框架。它不依赖操作系统,非常适合嵌入式环境。

3.2.1 安装与集成

CUnit 的源码可以从官网下载,或者直接用包管理器:

sudo apt-get install libcunit1-dev   # Ubuntu/Debian

如果你的目标平台是裸机或 RTOS,建议把 CUnit 源码直接加入工程,编译时带上。

3.2.2 基本结构

一个典型的 CUnit 测试用例长这样:

#include <CUnit/CUnit.h>
#include <CUnit/Basic.h>

/* 被测试函数 */
int add(int a, int b) {
    return a + b;
}

/* 测试用例 */
void test_add_positive(void) {
    CU_ASSERT(add(2, 3) == 5);
}

void test_add_negative(void) {
    CU_ASSERT(add(-1, -2) == -3);
}

int main() {
    CU_initialize_registry();
    CU_pSuite suite = CU_add_suite("Suite_Add", NULL, NULL);
    CU_add_test(suite, "test_add_positive", test_add_positive);
    CU_add_test(suite, "test_add_negative", test_add_negative);
    CU_basic_run_tests();
    CU_cleanup_registry();
    return 0;
}

运行后,你会看到每个用例的通过/失败状态。我个人习惯把测试用例按模块分组,比如「驱动层」「协议层」「算法层」,这样定位问题更快。

小技巧: 在嵌入式环境中,如果标准输出不可用,可以用 CUnit 的 Console 模式,或者把结果通过串口打印出来。

3.3 Unity 框架:更适合资源受限的 MCU

Unity 是另一个流行的 C 单元测试框架。它比 CUnit 更轻量,全部代码只有两个文件(unity.c 和 unity.h),非常适合 RAM 和 Flash 紧张的 MCU。

3.3.1 快速上手

#include "unity.h"

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

void test_add(void) {
    TEST_ASSERT_EQUAL_INT(5, add(2, 3));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_add);
    return UNITY_END();
}

Unity 的断言更丰富:TEST_ASSERT_EQUAL、TEST_ASSERT_TRUE、TEST_ASSERT_WITHIN(允许误差范围)等。我在做传感器驱动测试时,经常用 TEST_ASSERT_WITHIN 来验证 ADC 采样值是否在合理区间。

3.3.2 CUnit vs Unity 对比

特性 CUnit Unity
代码体积 较大(约 50KB) 极小(约 10KB)
依赖 需要标准库 几乎无依赖
断言种类 基础类型 丰富(含浮点、字符串、数组)
适用场景 Linux/桌面原型 MCU/裸机/RTOS

我个人建议:如果是在 PC 上做原型验证,用 CUnit;如果目标就是 STM32、ESP32 这类 MCU,直接上 Unity。

3.4 桩函数与模拟技术

单元测试最头疼的是什么?依赖。你的函数可能调用了硬件寄存器、外设驱动、或者另一个模块的函数。测试时,这些依赖往往不可用或不可控。

这时候就需要「桩函数」(Stub)和「模拟」(Mock)。

3.4.1 什么是桩函数?

桩函数是一个「假」函数,用来替换真实的依赖。它返回一个预设值,或者记录调用信息。

举个例子,你的代码里调用了 HAL_GPIO_ReadPin(),测试时你不能真的去按按钮。那就写个桩:

/* 桩函数:模拟 GPIO 读取 */
uint8_t HAL_GPIO_ReadPin_stub(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin) {
    (void)GPIOx;
    (void)GPIO_Pin;
    return 1;  /* 模拟高电平 */
}

然后通过宏或链接器替换掉原函数:

#define HAL_GPIO_ReadPin HAL_GPIO_ReadPin_stub

3.4.2 模拟技术:不止是返回值

有时候,你需要验证函数是否被正确调用,或者传入了什么参数。这时候要用 Mock。

Unity 有一个扩展叫 Unity Mock,或者你可以用 CMock 自动生成 Mock 代码。手动写一个简单的 Mock 也不难:

static int call_count = 0;
static uint16_t last_pin = 0;

uint8_t HAL_GPIO_ReadPin_mock(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin) {
    call_count++;
    last_pin = GPIO_Pin;
    return (GPIO_Pin == 5) ? 1 : 0;
}

void test_gpio_read(void) {
    call_count = 0;
    uint8_t val = read_button(5);
    TEST_ASSERT_EQUAL_INT(1, val);
    TEST_ASSERT_EQUAL_INT(1, call_count);
    TEST_ASSERT_EQUAL_INT(5, last_pin);
}
注意: 桩函数和 Mock 不要滥用。只对「外部依赖」打桩,比如硬件、网络、文件系统。你自己的业务逻辑函数,应该直接测试。

3.5 覆盖率分析:gcov 与 lcov

测试用例写完了,怎么知道测得全不全?覆盖率分析就是干这个的。

3.5.1 gcov:GCC 自带的覆盖率工具

gcov 是 GCC 工具链的一部分。编译时加上特殊选项,运行程序后就会生成覆盖率数据。

步骤很简单:

  1. 编译时加选项:-fprofile-arcs -ftest-coverage
  2. 链接时加选项:-lgcov
  3. 运行程序:生成 .gcda 和 .gcno 文件
  4. 查看结果:gcov main.c 生成 main.c.gcov

打开 .gcov 文件,你会看到每行代码的执行次数:

        -:    0:Source:main.c
        -:    1:#include <stdio.h>
        1:    2:int add(int a, int b) {
        1:    3:    return a + b;
        -:    4:}
    #####:    5:int sub(int a, int b) {  /* 从未执行 */
        0:    6:    return a - b;
        -:    7:}

##### 表示该行从未被执行。这就是你需要补充测试用例的地方。

3.5.2 lcov:可视化覆盖率报告

gcov 的输出是文本,看起来不够直观。lcov 可以把数据转成 HTML 报告,带颜色和图表。

# 生成覆盖率数据
lcov --capture --directory . --output-file coverage.info

# 生成 HTML 报告
genhtml coverage.info --output-directory coverage_report

打开 coverage_report/index.html,你会看到:

  • 每个文件的覆盖率百分比
  • 行覆盖率、分支覆盖率、函数覆盖率
  • 未覆盖的代码行高亮显示

我记得有一次,我自认为测试写得很全了,结果 lcov 报告显示有一个错误处理分支完全没跑到。补上用例后,果然发现了一个隐藏的数组越界 bug。

3.5.3 覆盖率指标参考

指标 含义 建议目标
行覆盖率 代码行被执行的比例 ≥ 80%
分支覆盖率 if/else、switch 分支被覆盖的比例 ≥ 70%
函数覆盖率 所有函数是否都被调用过 100%
核心观点: 覆盖率不是越高越好,但低于 60% 的测试基本等于没测。优先覆盖核心逻辑和边界条件。

3.6 本章知识体系

下面这张图总结了单元测试的核心流程和工具链关系:

单元测试知识体系 测试框架 桩函数 / Mock 覆盖率分析 CUnit Unity 手动桩 CMock gcov lcov 测试报告 + 覆盖率报告 核心流程 编写测试用例 → 打桩隔离依赖 → 运行测试 → 分析覆盖率 → 补充用例
我的建议: 刚开始做单元测试时,别追求一步到位。先挑一个核心模块,搭好框架,写 5-10 个用例,跑通覆盖率。有了正向反馈,再逐步推广到整个项目。

好了,这一章的内容就到这里。框架选型、桩函数写法、覆盖率工具,都是实战中每天要用的东西。你可以在自己的项目里试试,从一个小模块开始,感受一下「测试先行」带来的安全感。