嵌入式软件如何做单元测试

AI助手已提取文章相关产品:

嵌入式软件如何做单元测试?从“测不了”到“天天跑”的实战之路 💡

你有没有过这样的经历?

深夜调试板子,串口打印一堆乱码,变量值莫名其妙变成 0xFFFF
烧了第十遍固件,发现还是那个早在三天前就埋下的逻辑错误;
换了个芯片平台,原来好好的驱动代码直接崩在初始化阶段……

而最扎心的是—— 这些问题,本可以在写完代码的5分钟内就被发现。

这,就是我们今天要聊的话题: 嵌入式软件到底能不能做单元测试?怎么才能真正落地?

别急着摇头说“不可能”。我曾经也这么认为。直到亲眼看到一个团队用 Ceedling 在 PC 上跑通了对电机控制算法、CAN 通信状态机、甚至中断回调函数的完整验证—— 没有一块开发板,没有一次烧录,每天自动执行上千个测试用例。

是的, 现代嵌入式开发,早就不该靠“上电看灯”来验证正确性了。


我们为什么长期“测不了”?

先坦白讲,嵌入式软件做单元测试确实难,但难点从来不在技术本身,而在思维惯性和工程条件。

传统模式下,我们习惯于:

  • 把所有逻辑塞进 .c 文件里,直接调用 HAL_GPIO_WritePin()
  • 初始化代码一上来就配置时钟、使能外设;
  • 中断服务程序(ISR)里写一堆业务处理;
  • 依赖 RTOS 的任务调度才能看到功能表现……

于是问题来了:
👉 没有硬件,怎么测?
👉 依赖操作系统,怎么独立运行?
👉 函数之间紧耦合,改一处全崩?

结果就是: 测试只能等到硬件到位、系统集成之后才开始。

可这时候发现问题,代价已经太大了。据 NASA 统计,缺陷越晚被发现,修复成本呈指数级上升—— 后期修复的成本可能是编码阶段的100倍以上!

所以,不是“不想测”,而是我们的代码结构和工具链没准备好。

那怎么办?

答案是: 把“不可测”的代码,变成“可测”的模块。

而这背后的核心思想,只有八个字:

关注逻辑,而非物理实现。

什么意思?举个例子。

你想测试“按下按钮后是否点亮LED”,真正需要验证的是:
- 当输入为“按钮按下”时,
- 输出是否为“设置GPIO高电平”。

至于这个“按钮”是真实引脚读取,还是模拟信号;
这个“点亮”是真的驱动电流流过LED,还是只是记录一次函数调用——
根本不重要。

只要逻辑是对的,移植到任何平台上都会工作。

这就是单元测试的本质: 隔离外部依赖,专注行为验证。


工具选型:为什么是 Ceedling + Unity + CMock?

市面上有不少测试框架,比如 Google Test(适合C++)、CppUTest,甚至有人用 Python 写自动化脚本去串口发指令。

但在纯 C 环境、资源受限、跨平台需求强烈的嵌入式领域, Ceedling 是目前最接地气的选择。

它不是一个单一工具,而是一套组合拳:

组件 角色
Unity 轻量级断言库,负责判断“对不对”
CMock 自动桩生成器,负责“假装调用”
Ceedling 构建系统,把一切串起来

它们都来自 ThrowTheSwitch 社区,专为嵌入式 C 开发打造, 零依赖、易集成、学习曲线平缓 ,特别适合原本不做测试的传统团队“冷启动”。

而且最关键的一点:
✅ 它允许你在 x86 主机上编译运行嵌入式代码逻辑 ,完全脱离目标硬件!

听起来有点魔幻?别急,下面一步步拆解它是怎么做到的。


Unity:让 C 语言也能优雅地写断言 🧪

很多人以为 C 语言没法好好写测试,因为没有异常机制、没有模板、甚至连字符串都不方便处理。

但 Unity 用一组宏解决了这个问题。

来看一段最简单的测试代码:

#include "unity.h"

void setUp(void) { }
void tearDown(void) { }

void test_should_add_two_numbers_correctly(void) {
    int result = add(2, 3);
    TEST_ASSERT_EQUAL_INT(5, result);
}

就这么几行,就已经是一个完整的测试用例了。

当你运行它时,如果 add(2,3) 返回不是 5,你会看到类似这样的输出:

test_add.c:12:test_should_add_two_numbers_correctly:FAIL: Expected 5 Was 6

清晰明了,定位精准。

Unity 提供了哪些实用的断言?

断言宏 用途
TEST_ASSERT_TRUE(expr) 判断表达式为真
TEST_ASSERT_FALSE(expr) 判断表达式为假
TEST_ASSERT_NULL(ptr) 检查指针为空
TEST_ASSERT_EQUAL_INT(a,b) 整数相等比较
TEST_ASSERT_EQUAL_FLOAT(a,b) 浮点数比较(自动容差)
TEST_ASSERT_EQUAL_HEX(a,b) 十六进制比较,常用于寄存器值
TEST_ASSERT_MEMORY_EQUAL(expected, actual, len) 内存块比较
TEST_IGNORE() 跳过某个测试

特别是浮点数比较,Unity 支持带误差范围的对比:

TEST_ASSERT_FLOAT_WITHIN(0.001, 3.1415926, computed_pi);

这对传感器数据处理、数学运算类模块非常友好。

更进一步:能在目标板上跑吗?

当然可以!

Unity 只依赖 <stdio.h> <setjmp.h> ,这意味着哪怕是在 Cortex-M3 上,只要有基本的半主机(semihosting)支持或串口重定向 printf ,就能把测试结果打出来。

不过实际项目中,我们更推荐的做法是:
➡️ 日常开发在 PC 上跑测试(快!)
➡️ 定期在目标板上回归验证一次(稳!)

这样既保证效率,又不失真实性。


CMock:告别“硬编码依赖”,轻松 mock 外设 🎭

如果说 Unity 是“检测员”,那 CMock 就是“演员”——它能帮你扮演任何你不想真的调用的函数。

比如你的代码长这样:

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

float read_temperature(void) {
    uint8_t data[2];
    if (i2c_read(TEMP_SENSOR_ADDR, data, 2) != I2C_OK) {
        return -100.0f; // 错误标志
    }
    return ((data[0] << 8) | data[1]) * 0.0625f;
}

这段代码依赖 i2c_read() ,而这个函数会操作真实的 I2C 控制器。
想在 PC 上测试?不行,除非你模拟整个总线协议。

但我们关心的是逻辑分支:
- 如果 I2C 成功 → 正确解析温度
- 如果 I2C 失败 → 返回默认错误值

这些根本不需要真实通信!

这时候,CMock 就派上用场了。

第一步:给 i2c_driver.h 写个头文件接口

// i2c_driver.h
#ifndef I2C_DRIVER_H
#define I2C_DRIVER_H

#include <stdint.h>

typedef enum {
    I2C_OK,
    I2C_ERROR,
    I2C_TIMEOUT
} i2c_status_t;

i2c_status_t i2c_read(uint8_t addr, uint8_t* buffer, uint8_t len);

#endif

第二步:让 CMock 自动生成 mock 版本

只需要在 project.yml 里声明:

:mocks:
  :plugins:
    - :ignore
    - :expect_any_args

然后在测试文件中 include:

#include "unity.h"
#include "mock_i2c_driver.h"  // ← 不是你写的!是 CMock 自动生成的
#include "sensor_control.h"

第三步:编写测试,设定期望行为

void test_read_temperature_should_return_25_degrees_when_i2c_returns_valid_data(void) {
    uint8_t fake_raw[] = {0x19, 0x00}; // 25°C
    i2c_read_IgnoreAndReturn(I2C_OK);   // 告诉 mock:无论参数是什么,都返回 OK

    float temp = read_temperature();
    TEST_ASSERT_FLOAT_WITHIN(0.01, 25.0, temp);
}

void test_read_temperature_should_return_error_value_on_i2c_failure(void) {
    i2c_read_ExpectAndReturn(TEMP_SENSOR_ADDR, NULL, 2, I2C_TIMEOUT);

    float temp = read_temperature();
    TEST_ASSERT_EQUAL_FLOAT(-100.0f, temp);
}

注意这里用了两个不同的方式:

  • i2c_read_IgnoreAndReturn() :忽略参数,直接返回指定值
  • i2c_read_ExpectAndReturn(...) :明确指定参数,验证是否按预期调用

后者还能检查函数有没有被正确调用,传参对不对,次数是不是一次。

这就叫 行为验证(Behavior Verification) ,比单纯的返回值检查更强。


如何处理最难搞的三大场景?🔥

很多工程师一听“单元测试”,第一反应就是:“这些东西你怎么测?”

下面我们挑三个最典型的“痛点”来说清楚。


场景一:硬件寄存器访问(如 STM32 HAL)

常见代码:

void led_init(void) {
    __HAL_RCC_GPIOA_CLK_ENABLE();
    GPIO_InitTypeDef init;
    init.Pin = GPIO_PIN_5;
    init.Mode = GPIO_MODE_OUTPUT_PP;
    HAL_GPIO_Init(GPIOA, &init);
}

问题来了: HAL_GPIO_Init() 会直接写内存映射寄存器。
在 PC 上运行?直接段错误。

解法:抽象 + 依赖注入 + Mock

不要在业务代码里直接调用 HAL!

改成这样:

// driver/gpio_wrapper.h
typedef struct {
    void (*init)(int pin);
    void (*write)(int pin, int value);
    int  (*read)(int pin);
} gpio_driver_t;

void set_gpio_driver(const gpio_driver_t* driver); // 注入接口
void control_led(int state); // 使用抽象接口

实现层再绑定具体 HAL:

// platform/stm32_driver.c
static const gpio_driver_t stm32_gpio = {
    .init = hal_gpio_init,
    .write = hal_gpio_write,
    .read = hal_gpio_read
};

void board_init(void) {
    set_gpio_driver(&stm32_gpio);
}

测试时注入一个“假”驱动:

// test/mock_driver.c
static int expected_pin;
static int last_value;

void mock_init(int pin) { expected_pin = pin; }
void mock_write(int pin, int value) { last_value = value; }

const gpio_driver_t test_gpio_driver = {
    .init = mock_init,
    .write = mock_write
};

测试用例:

void test_led_turn_on_should_set_pin_high(void) {
    set_gpio_driver(&test_gpio_driver);
    control_led(1);
    TEST_ASSERT_EQUAL(1, last_value);
}

你看, 完全没有碰到底层 HAL ,却完成了对“控制逻辑”的验证。

这种模式叫做 依赖注入(Dependency Injection) ,是提升可测试性的关键设计原则。


场景二:RTOS API(如 FreeRTOS)

代码里到处都是 xTaskCreate , vQueueSend , xSemaphoreTake ……

这些函数内部涉及任务调度、堆栈分配,在 PC 上无法原生运行。

解法:统一 mock 掉 RTOS 接口

使用 CMock 自动生成 mock_FreeRTOS.h

#include "mock_FreeRTOS.h"

void test_message_queue_should_forward_data_to_handler(void) {
    QueueHandle_t fake_queue = (QueueHandle_t)0x1234;
    get_message_queue_ExpectAndReturn(fake_queue);

    uint32_t received;
    xQueueReceive_ExpectAndReturn(fake_queue, &received, portMAX_DELAY, pdTRUE);
    xQueueReceive_IgnoreArg_pvBuffer(); // 忽略缓冲区指针

    bool result = wait_for_message(&received);
    TEST_ASSERT_TRUE(result);
}

通过这种方式,你可以验证:
- 是否成功获取了队列句柄
- 是否以正确的超时时间等待
- 是否处理了返回状态

而不用真的启动 FreeRTOS 内核。

💡 小技巧:可以把常用的 RTOS mock 配置封装成插件,一键启用。


场景三:中断服务程序(ISR)

ISR 通常是裸函数,没有参数,不能直接调用。

void EXTI0_IRQHandler(void) {
    if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0)) {
        log_event(EVENT_BUTTON_PRESSED); // 这部分才是重点!
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
    }
}

ISR 本身很难测,但里面的逻辑可以抽出来!

重构为:

void handle_button_interrupt(void) {
    log_event(EVENT_BUTTON_PRESSED);
}

void EXTI0_IRQHandler(void) {
    if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0)) {
        handle_button_interrupt(); // 只保留必要的寄存器操作
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
    }
}

现在你可以单独测试 handle_button_interrupt()

甚至可以 mock log_event() 来验证它是否被调用:

void test_handle_button_interrupt_should_log_event(void) {
    log_event_Expect(EVENT_BUTTON_PRESSED);
    handle_button_interrupt();
}

完美绕开中断上下文限制。


实际工程架构该怎么搭?🏗️

光会写测试还不够,得有一套可持续维护的工程结构。

这是我见过最高效的组织方式:

project-root/
├── src/
│   ├── app/               # 应用逻辑
│   ├── middleware/        # 协议栈、状态机等
│   └── driver/             # 驱动封装(非 HAL 直接调用)
│
├── test/
│   ├── test_app/
│   ├── test_middleware/
│   └── test_driver/
│
├── vendor/
│   └── unity_cmock/       # 第三方库
│
├── project.yml            # Ceedling 配置
└── ceedling_override.sh   # 自定义构建脚本(可选)

关键配置: project.yml 怎么写?

:project:
  :use_exceptions: FALSE
  :use_test_preprocessor: TRUE
  :use_auxiliary_dependencies: TRUE
  :build_root: build
  :release_build:
    :output: MyApp.elf
    :use_assembly: FALSE

:defines:
  :common: &common_defines
    - UNIT_TESTING

:files:
  :source:
    - src/app/**
    - src/middleware/**

:tools:
  :test_compiler:
    :executable: gcc
    :arguments:
      - -c
      - -g
      - -Wall
      - -Werror
      - -D$(DEFINES)
      - -I"vendor/unity/src"
      - -I"src"

:plugins:
  :load_paths:
    - vendor/ceedling/plugins
  :enabled:
    - stdout_pretty_tests_report
    - module_generator
    - gcov
    - coverage

几个要点说明:

  • UNIT_TESTING 宏可用于条件编译某些调试代码;
  • 所有源码路径必须包含;
  • 启用 gcov 插件可生成覆盖率报告;
  • 使用 stdout_pretty_tests_report 让输出更美观;

运行命令也很简单:

ceedling test:all                    # 运行所有测试
ceedling test:test_sensor_control   # 只跑某个测试
ceedling coverage:all               # 生成覆盖率 HTML 报告
ceedling gcov                       # 查看详细覆盖情况

CI/CD 集成:让测试成为提交门槛 🚦

真正的工程化,不是“偶尔跑一下测试”,而是“每次提交都自动验证”。

GitLab CI 示例 .gitlab-ci.yml

stages:
  - test
  - coverage

before_script:
  - ruby --version
  - gem install ceedling

unit_tests:
  stage: test
  script:
    - ceedling test:all
  artifacts:
    when: on_failure
    paths:
      - build/logs/

coverage_report:
  stage: coverage
  script:
    - ceedling coverage:all
  artifacts:
    paths:
      - build/artifacts/coverage/
    reports:
      cobertura: build/artifacts/coverage/cobertura.xml

GitHub Actions 也一样,加个 workflow 文件就行。

一旦接入 CI,效果立竿见影:

  • 新人提交代码前会自觉跑测试;
  • PR 必须通过测试才能合并;
  • 覆盖率趋势一目了然;
  • 回归问题几乎不再发生。

这才是“质量内建”(Built-in Quality)的真实体现。


设计建议:怎样写出更容易测试的代码?✍️

最后分享一些我在多个项目中总结出的“可测试性设计法则”:

✅ 推荐做法

原则 示例
函数职责单一 一个函数只做一件事,比如“解析报文”或“校验CRC”
避免全局变量 改用参数传递或上下文结构体
减少静态状态 静态变量会导致测试间污染
接口抽象化 用函数指针代替直接调用 HAL/RTOS
分层清晰 应用层不直接依赖底层驱动

❌ 应避免的反模式

// ❌ 反模式1:直接调用 HAL
void process_sensor(void) {
    if (HAL_ADC_Read(...) > THRESHOLD) {
        HAL_UART_Transmit(...);  // 紧耦合,无法 mock
    }
}

// ✅ 正确做法:通过接口调用
void process_sensor(const driver_if_t* drv) {
    if (drv->adc_read() > THRESHOLD) {
        drv->uart_send(...);
    }
}
// ❌ 反模式2:全局状态
static int initialized = 0;
void init_module(void) {
    if (!initialized) { /* do init */ }
    initialized = 1;  // 下次测试会跳过初始化!
}

// ✅ 正确做法:显式管理状态
typedef struct {
    int is_init;
    int config_val;
} module_ctx_t;

void module_init(module_ctx_t* ctx) {
    ctx->is_init = 1;
}

一点真心话 💬

我知道,很多嵌入式工程师听到“单元测试”会觉得这是“学院派”的东西,觉得“我们产品交付都来不及,哪有空写测试?”

我也曾这么想。

但后来我发现, 真正拖慢进度的,从来不是写测试的时间,而是反复调试、现场返修、客户投诉带来的隐形成本。

而单元测试,恰恰是最便宜的“保险”。

它不能防止所有问题,但它能:

  • 让你更有信心地重构老旧代码;
  • 让新人快速理解模块行为;
  • 让每次变更都有迹可循;
  • 让软件不再是“一次性艺术品”,而是可持续演进的工程资产。

更重要的是——
当你每天早上打开电脑,看到那一串绿色的 PASS ,你会有一种踏实感:

“我知道这部分代码是可靠的。”

这,就是工程化的魅力所在。


所以,不妨从今天开始,试试看:

  1. 给你最近写的模块写第一个测试;
  2. mock_xxx.h 替掉一个真实的驱动调用;
  3. 在 CI 上看到第一次自动运行的结果。

也许一开始很慢,甚至觉得麻烦。
但坚持两周,你会发现:
原来写测试,比查 bug 还快。 😄

🚀 就像当年学会用 Git 一样,一旦跨过那个门槛,再也回不去了。

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

您可能感兴趣的与本文相关内容

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值