4. 嵌入式开发环境搭建

单元测试框架,说白了就是给嵌入式代码做「体检」的工具。你写了一个函数,它到底跑没跑对?边界情况处理了没?内存有没有越界?这些问题,靠人眼review很难全覆盖。

我刚开始做嵌入式测试那会儿,团队还在用printf大法。每次改完代码,串口打印一堆log,肉眼盯着看。后来项目复杂度上来了,一个模块上千行,printf根本盯不住。嗯,从那以后我就开始研究怎么把单元测试框架搬进嵌入式环境。

今天咱们先聊两个主流框架:CUnit和Unity。它们都能做单元测试,但设计思路和适用场景差别挺大。

4.1 CUnit vs Unity:核心差异

先看一张对比表,心里有个底:

对比维度 CUnit Unity
设计哲学 桌面级测试框架,功能全面 嵌入式原生,极简轻量
依赖库 需要标准C库、动态内存分配 纯C实现,无外部依赖
内存占用 较大(注册表+运行队列) 极小(静态数组+宏展开)
断言类型 30+种,支持自定义 20+种,覆盖常用场景
测试组织 Suite → Test → Assert 三层 Group → Test → Assert 两层
输出格式 XML/HTML/Console Console(可扩展)
移植难度 中等(需适配malloc/free) 低(只需实现putchar)
典型场景 Linux/Windows上的嵌入式模拟测试 MCU裸机/RTOS上的目标板测试

我个人习惯这样选:如果代码能在PC上编译运行(比如用gcc模拟),我优先用CUnit,功能全、报告漂亮。如果测试要烧到STM32、ESP32这类MCU上跑,Unity是更稳妥的选择。

4.2 框架移植:把测试环境搬进嵌入式世界

移植框架,说白了就是让测试代码能在目标平台上跑起来。这里有两个典型路径。

4.2.1 CUnit移植要点

CUnit依赖标准C库的malloc/free。在嵌入式环境里,这俩函数可能被重定向到自定义内存池。我遇到过一个问题:某RTOS的malloc实现不支持多线程安全,CUnit跑多Suite时随机崩溃。解决方案是给CUnit加一个互斥锁包装。

移植步骤大致如下:

  1. 下载源码:从SourceForge获取CUnit 2.1-3版本。
  2. 裁剪文件:只保留CUnit/Sources/下的C源文件,去掉Automake相关。
  3. 配置头文件:修改CUnit.h中的CU_MAX_TEST_NAME_LENGTH(默认256,嵌入式建议改64)。
  4. 实现输出回调:CUnit默认用printf输出,你需要重定向到串口或日志模块。
  5. 链接测试:把被测模块的.c文件一起编译。
注意:CUnit的自动注册机制依赖全局构造函数(GCC的__attribute__((constructor))),在IAR/Keil上可能不支持。我建议手动注册测试Suite,避免移植坑。

4.2.2 Unity移植:三分钟搞定

Unity的移植简单到让人感动。它只需要你提供一个putchar函数,把字符输出到串口或LCD。

具体做法:

  1. 把unity.c和unity.h拷进工程。
  2. 在unity_config.h中定义UNITY_OUTPUT_CHAR(c)宏,指向你的串口发送函数。
  3. 编译,搞定。
小技巧:Unity默认使用静态数组存储测试结果。如果你的MCU RAM紧张,可以把UNITY_FIXTURE_NO_EXTRAS宏打开,能省下几百字节。

4.3 编写第一个测试用例:实战演示

纸上谈兵没意思,咱们直接上手写代码。假设被测函数是一个计算环形缓冲区剩余空间的函数:

// ring_buffer.h
typedef struct {
    uint8_t *buffer;
    uint16_t head;
    uint16_t tail;
    uint16_t max_len;
} ring_buffer_t;

uint16_t rb_available(ring_buffer_t *rb);

4.3.1 用CUnit写测试

#include "CUnit/CUnit.h"
#include "ring_buffer.h"

static ring_buffer_t rb;

void test_rb_available_empty(void) {
    rb.head = 0;
    rb.tail = 0;
    rb.max_len = 64;
    CU_ASSERT_EQUAL(rb_available(&rb), 64);
}

void test_rb_available_full(void) {
    rb.head = 0;
    rb.tail = 0;
    rb.max_len = 64;
    // 模拟写满
    rb.head = 63;
    CU_ASSERT_EQUAL(rb_available(&rb), 1);
}

void test_rb_available_wrap(void) {
    rb.head = 5;
    rb.tail = 60;
    rb.max_len = 64;
    CU_ASSERT_EQUAL(rb_available(&rb), 9);
}

int main(void) {
    CU_initialize_registry();
    CU_pSuite suite = CU_add_suite("RingBuffer Suite", NULL, NULL);
    CU_add_test(suite, "test empty", test_rb_available_empty);
    CU_add_test(suite, "test full", test_rb_available_full);
    CU_add_test(suite, "test wrap", test_rb_available_wrap);
    CU_run_all_tests();
    CU_cleanup_registry();
    return 0;
}

4.3.2 用Unity写测试

#include "unity.h"
#include "ring_buffer.h"

static ring_buffer_t rb;

void setUp(void) {
    rb.buffer = NULL;
    rb.head = 0;
    rb.tail = 0;
    rb.max_len = 64;
}

void tearDown(void) {
    // 清理资源(如果有)
}

void test_rb_available_empty(void) {
    TEST_ASSERT_EQUAL_UINT16(64, rb_available(&rb));
}

void test_rb_available_full(void) {
    rb.head = 63;
    TEST_ASSERT_EQUAL_UINT16(1, rb_available(&rb));
}

void test_rb_available_wrap(void) {
    rb.head = 5;
    rb.tail = 60;
    TEST_ASSERT_EQUAL_UINT16(9, rb_available(&rb));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_rb_available_empty);
    RUN_TEST(test_rb_available_full);
    RUN_TEST(test_rb_available_wrap);
    return UNITY_END();
}

你看,两个框架的测试逻辑几乎一样。区别在于:CUnit需要手动注册Suite和Test,Unity直接用RUN_TEST宏展开。Unity的setUp/tearDown机制对嵌入式测试特别友好——每次测试前自动重置状态,避免用例间污染。

4.4 知识结构图:单元测试框架选型决策

下面这张图帮你快速决策:

单元测试框架选型决策流程 选择单元测试框架 在MCU上运行? 是 Unity 否 需要丰富报告? 是 CUnit 否 Unity(够用) 说明: Unity在PC上也能用,但报告功能弱。CUnit移植到MCU需要额外适配内存管理。选型核心看「运行环境」和「报告需求」。

4.5 避坑指南:我踩过的三个坑

  • 坑一:断言宏的参数副作用。我曾经写过CU_ASSERT_EQUAL(++a, b++),结果断言本身改变了变量值,后续测试全乱套。记住:断言宏的参数不要带副作用。
  • 坑二:Unity的TEST_ASSERT_EQUAL宏默认比较int。如果你传uint16_t,会发生隐式类型转换。我建议显式使用TEST_ASSERT_EQUAL_UINT16,避免精度丢失。
  • 坑三:CUnit在裸机上跑不起来。CUnit内部用了信号处理(SIGFPE、SIGSEGV),裸机环境没有信号机制。如果你非要在MCU上用CUnit,得把信号处理部分注释掉,自己处理异常。

4.6 小结

今天咱们把CUnit和Unity的底牌都翻了一遍。CUnit像瑞士军刀,功能全但重;Unity像手术刀,轻巧精准。移植方面,Unity三分钟搞定,CUnit需要半小时适配。第一个测试用例写下来,你会发现单元测试没那么玄乎——就是把你手动测试的步骤,用代码固化下来。

下节课咱们继续深入:怎么组织测试Suite、怎么用Mock做模块隔离、怎么把测试集成进构建系统。这些才是实战中真正出效率的地方。