Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.4 单元测试——每一块砖都要拉过

杭州湾大桥的混凝土试块

杭州湾跨海大桥使用的每一批混凝土,在到达施工现场之前,都必须经过一个流程:

搅拌站的试验室里,技术人员从搅拌车中取出样品,填入150mm见方的标准钢模,振实、抹平、标养。28天后,把硬化试块放入压力机。液压活塞缓缓上升,加载速率精确控制在每秒0.5MPa。数字显示屏上的力值曲线稳步攀升,直到试块发出沉闷的碎裂声,峰值力被记录下来:47.2MPa。

C50混凝土的设计强度是50MPa。47.2MPa,不合格。

这一批混凝土被整批退回给搅拌站。它不会被浇进任何一根桥墩,因为它在进入结构之前,就已经在试验室里被证明不达标。

这就是单元测试的核心意义:在集成到大系统之前,可重复地、可量化地验证每一个独立单元。不是等桥建好了做静载试验,那是集成测试的事。混凝土进场时就该压碎试块,函数写完时就该跑单元测试。

核心洞察:单元测试的核心意义,是在集成之前可重复、可量化地验证每个独立单元。 47.2MPa 的试块不合格就被挡在结构之外,而不是等桥建好再做静载试验。函数写完就该跑单元测试,把发现错误的时点尽量往左移——移得越早,代价越小。


为什么嵌入式开发者讨厌单元测试(以及为什么他们的理由都是错的)

你大概率听过这些论调,甚至自己说过:

“我的代码直接操作硬件寄存器,怎么测试?”

回答:你把“操作硬件“和“业务逻辑“混在一起了。控制 ADC 采样的代码确实需要硬件,但“从 10 个采样值里剔除两个最大值和两个最小值,取剩余 6 个的平均值“这段逻辑不需要硅片,它只需要一个数组。剥离硬件依赖,剩下的就是纯逻辑,纯逻辑是可测试的。

“我们时间太紧,写测试来不及。”

回答:你确定?计算一下:一段没有测试的代码被集成为系统的一部分后,如果出了问题,你排查的时间是多长?在嵌入式系统里,一个隐蔽的 Bug 从发现现象到定位根因,平均耗时是以“天“为单位的。而一个写好的单元测试告诉你“这个模块没问题,问题在集成层“,排查范围瞬间缩小 90%。

“我们的硬件环境太复杂,搭建测试环境工作量大。”

回答:这是双目标测试的问题:你不需要在每个测试里都用真实的硬件。用 Mock(模拟对象)替代 SPI 驱动、ADC 驱动、CAN 驱动。单元测试验证的是你的代码对硬件接口的正确使用,而不是硬件本身的功能正确性。硬件测试是 HIL(硬件在环)的职责,不是单元测试的职责。

剥离硬件:嵌入式单元测试的第一课

假设你有这样一段代码:

uint16_t Adc_ReadFiltered(uint8_t channel)
{
    uint16_t samples[10];

    for (uint8_t i = 0; i < 10; i++)
    {
        samples[i] = Adc_ReadRaw(channel);  /* 硬件相关 */
    }

    /* 以下逻辑与硬件无关 */
    uint32_t sum = 0;
    uint16_t min = 0xFFFF, max = 0;

    for (uint8_t i = 0; i < 10; i++)
    {
        sum += samples[i];
        if (samples[i] < min) min = samples[i];
        if (samples[i] > max) max = samples[i];
    }

    return (uint16_t)((sum - min - max) / 8); /* 去掉最大最小值后取平均 */
}

你需要测试的是“去极值平均“这部分逻辑:它对任何 10 个 uint16_t 值都应该正确工作。把滤波逻辑抽取为独立函数:

uint16_t Filter_TrimMean(const uint16_t *samples, uint8_t count)
{
    uint32_t sum = 0;
    uint16_t min = 0xFFFFU, max = 0U;

    for (uint8_t i = 0U; i < count; i++)
    {
        sum += samples[i];
        if (samples[i] < min) { min = samples[i]; }
        if (samples[i] > max) { max = samples[i]; }
    }

    return (uint16_t)((sum - min - max) / (count - 2U));
}

uint16_t Adc_ReadFiltered(uint8_t channel)
{
    uint16_t samples[10];
    for (uint8_t i = 0U; i < 10U; i++)
    {
        samples[i] = Adc_ReadRaw(channel);
    }
    return Filter_TrimMean(samples, 10U);
}

现在 Filter_TrimMean 是纯逻辑函数,没有硬件依赖,可以在任何平台上测试。Adc_ReadFiltered 只包含硬件驱动的调用逻辑,它的正确性依赖 Adc_ReadRaw 本身的正确性(这个由 HIL 测试保证)。

核心原则:把“做什么“和“怎么做“分开。“去极值平均“是做什么,“通过 SPI 总线读 ADS1115 的寄存器“是怎么做。前者可单元测试,后者需集成测试。

核心洞察:“操作硬件“和“业务逻辑“必须分离,纯逻辑才可单元测试。 借口“直接操作寄存器“是把两者混为一谈:去极值平均是做什么,SPI 读寄存器是怎么做,前者不需要硅片。用 Mock 替代硬件驱动,验证的是“你对硬件接口的正确使用”;硬件本身的功能正确性是 HIL 的职责,不是单元测试的职责。


单元测试的搭建:Ceedling / Unity / CMock 三件套

对于 C 语言的嵌入式项目,Ceedling 是当前最广泛使用的单元测试框架。它整合了三个核心组件:

组件职责建筑类比
Unity测试断言框架(TEST_ASSERT_EQUAL 等)压力机的力值传感器,给出“通过/不通过“的判断
CMock自动生成 Mock 函数试验用的替代材料,模拟桩基周围的地质条件
Ceedling构建系统+测试运行器+报告生成器整个试验室的自动化管理系统

一个典型的 Ceedling 测试文件:

#include "unity.h"
#include "filter.h"    /* 被测试模块 */
#include "mock_adc.h"  /* CMock 自动生成的 ADC Mock */

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

void test_Filter_TrimMean_normal_data(void)
{
    uint16_t samples[] = {100, 200, 300, 400, 500, 600, 700, 800, 900, 1000};

    uint16_t result = Filter_TrimMean(samples, 10);

    /* 去掉最大值 1000 和最小值 100,剩余 8 个值的平均 = (200+...+900)/8 = 550 */
    TEST_ASSERT_EQUAL_UINT16(550, result);
}

void test_Filter_TrimMean_all_same_values(void)
{
    uint16_t samples[] = {500, 500, 500, 500, 500, 500, 500, 500, 500, 500};

    uint16_t result = Filter_TrimMean(samples, 10);

    TEST_ASSERT_EQUAL_UINT16(500, result);
}

void test_Filter_TrimMean_extreme_values(void)
{
    uint16_t samples[] = {0, 0, 100, 200, 300, 400, 500, 600, 700, 65535};

    uint16_t result = Filter_TrimMean(samples, 10);

    /* 去掉 65535 和 0 中的一个,剩余 8 个的平均 = (0+100+...+700)/8 = 275 */
    TEST_ASSERT_EQUAL_UINT16(275, result);
}

注意测试用例的设计:正常数据、全相等、极值,覆盖了你算法的三个关键场景。这背后是一个说简单也不简单的工程问题:你到底应该测什么?

核心洞察:Ceedling 三件套把“断言、Mock、构建运行报告“整合成自动化试验室。 Unity 是压力机的力值传感器,CMock 自动生成替代材料的 Mock,Ceedling 是管理整个试验室的系统。工具只是外壳,真正的难题是背后的工程问题:你到底应该测什么?


测试用例设计:等价类划分与边界值分析

你不能测试“所有可能的输入“。对于 Filter_TrimMean,输入是一个长度为 10 的 uint16_t 数组,总共有 (65536)¹⁰ 种可能的输入组合。宇宙的年龄都不够你跑完这些测试。

所以你需要系统性削减测试空间的方法。

等价类划分

把输入空间划分成若干个“等价类“,同一类中的任何值对于被测试逻辑而言行为上等价。你只需要为每个等价类挑选一个代表值进行测试。

对于 Filter_TrimMean:

等价类代表值验证点
正常离散数据{100, 200, ..., 1000}基本算法正确
所有值相等{500, 500, ..., 500}去除极值后分母为 count-2 仍然正确
最小值多个相同{0, 0, 100, ..., 800}程序是否只去掉一个最小值(设计要求如此)
最大值多个相同{100, ..., 800, 65535, 65535}同理
两个元素{100, 65535}去除后分母为 0:你的函数处理了这个边界吗?

最后一个等价类暴露了一个问题:如果输入只有 2 个元素,count - 2 = 0,除法崩溃。这是等价类划分发现的边界条件,你的函数需要前置条件检查。

边界值分析

Bug 最爱蹲在边界处。对于数值输入,边界值分析的黄金法则是:测边界上、边界内、边界外。

以 CAN 报文 DLC(数据长度码)检查函数为例:

bool Can_IsValidDlc(uint8_t dlc)
{
    return (dlc <= 8U);
}
测试点值预期
边界内0true
边界内4true
边界上8true(最大允许值)
边界外9false(第一个非法值)
边界外15false
边界外255false(uint8_t 最大值)

这 6 个测试用例覆盖了所有有意义的边界条件。8 个用例恰好“测对了“:每一个用例验证了一个有区分度的等价类或边界值。

核心洞察:你测不完所有输入,系统性削减测试空间靠等价类划分与边界值分析。 同一等价类只取代表值;Bug 最爱蹲在边界上,所以边界上、边界内、边界外都要测。一张 6 个用例的 DLC 检查表恰好覆盖了所有有区分度的边界条件——这才叫“测对了“,而不是“测得多“。


测试状态机:转移覆盖与错误路径

汽车嵌入式软件中充斥着状态机:通信管理、诊断会话、NVM 写入流程…状态机测试的核心是每一个状态-事件组合都有定义好的行为。“每个状态都被访问过“只是小学生水平。

考虑一个简化的 CAN 通信控制器状态机:

         ┌─────────┐
    ┌───→│ COMM_OFF │←──── 网络释放请求 ────┐
    │    └────┬─────┘                       │
    │         │ 网络请求                     │
    │    ┌────▼─────┐   通信模式请求         │
    │    │ COMM_READY├──────────────┐        │
    │    └────┬─────┘              │        │
    │         │ 超时               │        │
    │    ┌────▼─────┐         ┌───▼──────┐  │
    │    │COMM_ERROR │         │COMM_FULL │  │
    │    └──────────┘         └───┬──────┘  │
    │                             │         │
    └─────────────────────────────┘         │
           通信释放请求                      │
                            通信释放请求 ────┘

测试这个状态机的思路是:在转移表的每个单元格中填入测试用例:

当前状态 / 事件网络请求通信模式请求超时释放网络错误恢复
COMM_OFF→COMM_READY忽略(定义行为!)忽略忽略忽略
COMM_READY忽略→COMM_FULL→COMM_ERROR→COMM_OFFN/A
COMM_FULL忽略忽略→COMM_ERROR→COMM_READYN/A
COMM_ERROR→COMM_READYN/AN/A→COMM_OFF→COMM_OFF

你以为“忽略“的状态-事件组合不需要测试?恰恰相反,它们最需要测试。忽略一个事件意味着“收到这个事件后,状态不变,没有副作用“。你需要用测试证明:确实没有副作用,而不是“我还没写处理代码所以没副作用“。

核心洞察:状态机测试是给转移表的每个单元格填测试用例,包括“应该被忽略“的。 “每个状态都被访问过“只是小学生水平。忽略一个事件意味着“状态不变、无副作用”,这必须被测试证明,而不是“我还没写处理代码所以没副作用“。错误路径就是防火楼梯:平时不用,火灾时是唯一的生路。


覆盖率的真相:它回答的是另一个问题

“覆盖率 90% 了!我们可以停止测试了!”

这是一个危险的安全幻觉。覆盖率回答的是“哪些代码被执行过“,不是“哪些行为被验证过“。

一段代码被“执行过“和被“验证过“是两回事。考虑这段:

int16_t Compute_Throttle(int16_t pedal_pos, int16_t vehicle_speed)
{
    int16_t throttle;
    throttle = pedal_pos * 100 / (vehicle_speed + 1);  /* 分母永远不为零 */
    if (throttle > 100) { throttle = 100; }
    if (throttle < 0)   { throttle = 0; }
    return throttle;
}

如果你只用一个测试用例 Compute_Throttle(50, 50),你会得到 100% 的行覆盖和 100% 的分支覆盖,但你没有验证边界钳位逻辑在实际极值输入下的行为。覆盖率工具说“所有行都被跑到了“,但这不意味着“所有行为都被验证了“。

汽车行业要求的覆盖率等级

ISO 26262 对不同 ASIL 等级的覆盖率有明确要求:

ASIL 等级语句覆盖率分支覆盖率MC/DC 覆盖率
A推荐推荐—
B强烈推荐强烈推荐—
C强烈推荐强烈推荐推荐
D强烈推荐强烈推荐强烈推荐

MC/DC(Modified Condition/Decision Coverage) 是最高等级的覆盖率标准。它要求:

  1. 每一个判定(decision)的每一个条件(condition)都被证明能够独立影响判定的结果。
  2. 每一个判定的所有可能结果都至少被测试过一次。

以一个复合条件为例:

if ((engine_speed > 1000) && (throttle_position > 20)) {
    enable_fuel_injection();
}

MC/DC 要求以下测试用例组合:

用例engine_speed > 1000throttle_position > 20结果证明
1TTT—
2TFFthrottle_position 独立影响结果
3FTFengine_speed 独立影响结果

ASIL D 要求 MC/DC 覆盖率:这意味着你不仅要测试正常路径,还要系统性地构造测试用例来证明每一个条件都能独立翻转判定的结果。这不是一件可以靠“感觉差不多测测“来完成的工作。

核心洞察:覆盖率回答“哪些代码被执行过“,不是“哪些行为被验证过“,它只是必要不充分的底线。 一个用例就能拿到 100% 行覆盖,却漏掉边界钳位逻辑。ASIL D 要求的 MC/DC 要系统性地证明每个条件都能独立影响判定结果——这不是“感觉差不多测测“能完成的工作,更不能成为“可以停止测试“的信号。


ASPICE SWE.4 映射:单元测试的认证位置

ASPICE(Automotive SPICE)的 SWE.4 过程是“软件单元验证“。它是 V 模型左侧的第一个验证环节:

SWE.1 软件需求分析         ↕  SWE.6 软件合格性测试
SWE.2 软件架构设计         ↕  SWE.5 软件集成测试
SWE.3 软件详细设计  ←──→  SWE.4 软件单元验证  ← 你现在在这里
                       (单元测试)

SWE.4 的核心要求:

Base Practice要求
BP1制定单元验证策略(包括覆盖率目标)
BP2制定单元验证准则(测试用例选择方法)
BP3执行静态验证(MISRA 检查、静态分析)← 这部分属于第 3.3 章
BP4执行单元测试
BP5记录结果并建立双向追溯(测试用例 ↔ 详细设计 ↔ 需求)
BP6确保单元验证结果的一致性

注意:SWE.4 包含静态验证和单元测试两部分。这正是为什么上一章(静态分析)和本章(单元测试)密不可分。

核心洞察:SWE.4 是 V 模型左侧的第一个验证环节,且同时包含静态验证与单元测试。 这解释了为什么上一章的静态分析与本章单元测试密不可分。认证不是事后补材料:BP3 的静态验证与 BP4 的单元测试要在开发中同步建立,BP5 还要求与详细设计、需求建立双向追溯。


本篇小结

  • 单元测试 = 材料试验。在集成到大楼之前,独立验证每一个单元的正确性。
  • 剥离硬件依赖:把“做什么“(业务逻辑)和“怎么做“(硬件驱动)分开,前者单元测试,后者集成测试。
  • Ceedling / Unity / CMock:嵌入式 C 单元测试的标准三件套。Unity 做断言,CMock 生成 Mock,Ceedling 统一管理。
  • 测试用例设计方法:等价类划分削减测试空间,边界值分析覆盖 Bug 高发区。
  • 状态机测试:为转移表的每一个单元格设计测试用例。包括“应该被忽略“的事件组合。
  • 覆盖率 ≠ 验证充分性。行覆盖不代表行为被验证。ASIL D 要求 MC/DC:每个条件独立影响判定。
  • ASPICE SWE.4 定义了单元验证的完整要求,包含静态验证和单元测试两部分。
  • 在集成之前发现缺陷,比在整车上排查问题节省百倍成本。

【下集预告】:做好了材料试验,合格的砖块、钢筋、混凝土运进了工地。但运进工地不等于浇进地基,还有一道工序叫“施工流水线“:材料进场,扫码登记,取样化验,合格报告,准许浇注。如果任何一环卡住,下一步就停下来。软件工程里,这道工序叫 CI/CD。下一节,我们走进 CI 流水线,软件工地的自动化施工系统。

你的 Filter_TrimMean 函数通过了所有单元测试,边界值、等价类、全相等,全绿。但当你把它合并进主分支的那一刻,你知道它会和 Adc_ReadRaw 的 DMA 缓冲一起工作吗?你知道编译优化等级从 O0 改为 O2 后定时行为会变吗?你需要一条流水线来自动化验证这些。下一节,CI 流水线的传送带开始转动。