第4章:单元测试与静态分析

各位工程师朋友,今天我们来聊聊嵌入式软件测试里最基础、也最容易被忽视的两个环节——单元测试和静态分析。说实话,我刚入行那会儿,觉得写代码嘛,能跑就行。直到有一次,一个逆变器项目在高温老化测试时炸了IGBT模块,排查了三天才发现是一个全局变量被中断服务程序意外修改了。从那以后,我再也不敢小看这些“基础工作”了。

4.1 为什么需要单元测试?

你想想看,一个逆变器控制程序,少说也有几万行C代码。如果等到整机联调时才发现问题,定位成本至少是单元测试阶段的10倍以上。我个人的习惯是:每写完一个函数,先写单元测试再集成。说白了,这就是“小步快跑”的思路。

单元测试的核心价值有三点:

  • 早期发现缺陷:在代码提交前就暴露逻辑错误
  • 回归保护:修改代码后,跑一遍测试就知道有没有引入新bug
  • 文档作用:好的测试用例本身就是一份可执行的规格说明

重要提醒:逆变器软件中,PWM生成、ADC采样、保护逻辑这些核心模块,必须做到100%单元测试覆盖。这不是可选项,是必选项。

4.2 Unity/CMock框架实战

在嵌入式C语言领域,Unity和CMock是我最常用的测试框架组合。Unity负责测试执行和断言,CMock负责生成桩函数和模拟对象。为什么选它们?因为轻量、无依赖、适合资源受限的嵌入式环境。

4.2.1 Unity基础用法

先看一个最简单的例子。假设我们要测试一个计算正弦波查表索引的函数:

// 被测函数
uint16_t calc_sine_index(uint16_t angle_deg) {
    if (angle_deg >= 360) {
        angle_deg %= 360;
    }
    return (uint16_t)(angle_deg * 1024 / 360);
}

// 单元测试
#include "unity.h"

void test_calc_sine_index_normal(void) {
    TEST_ASSERT_EQUAL_UINT16(0, calc_sine_index(0));
    TEST_ASSERT_EQUAL_UINT16(512, calc_sine_index(180));
    TEST_ASSERT_EQUAL_UINT16(1023, calc_sine_index(359));
}

void test_calc_sine_index_wrap(void) {
    TEST_ASSERT_EQUAL_UINT16(0, calc_sine_index(360));
    TEST_ASSERT_EQUAL_UINT16(512, calc_sine_index(540));
}

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

嗯,这里要注意:测试用例的命名要清晰,让人一眼就能看出测的是什么场景。我习惯用“被测函数_测试场景”的命名方式。

4.2.2 CMock模拟外部依赖

逆变器软件中,很多函数会依赖硬件寄存器或外部芯片。这时候就需要CMock来模拟了。举个例子,假设我们要测试过流保护逻辑,它需要读取ADC值:

// 被测函数
bool overcurrent_protection(void) {
    uint16_t adc_val = read_adc_channel(ADC_CURRENT_SENSE);
    if (adc_val > OVERCURRENT_THRESHOLD) {
        disable_pwm_output();
        set_error_flag(ERROR_OVERCURRENT);
        return true;
    }
    return false;
}

// 使用CMock生成的桩函数
void test_overcurrent_protection_trip(void) {
    read_adc_channel_ExpectAndReturn(ADC_CURRENT_SENSE, 3500);
    disable_pwm_output_Expect();
    set_error_flag_Expect(ERROR_OVERCURRENT);
    
    TEST_ASSERT_TRUE(overcurrent_protection());
}

void test_overcurrent_protection_normal(void) {
    read_adc_channel_ExpectAndReturn(ADC_CURRENT_SENSE, 2000);
    
    TEST_ASSERT_FALSE(overcurrent_protection());
}

个人经验:我在项目中遇到过最坑的情况是,ADC读取函数在测试环境中返回了正确值,但在实际硬件上因为噪声导致误触发。所以,单元测试通过后,我还会加一些边界值和异常值的测试用例,比如ADC返回0xFFFF或0x0000的情况。

4.3 MISRA-C规范检查

MISRA-C,说白了就是一套C语言的安全编码标准。在汽车电子和工业控制领域,这几乎是强制要求。逆变器作为电力电子设备,一旦软件出问题,轻则停机,重则炸机。所以,我建议所有逆变器软件项目都启用MISRA-C检查。

常见的MISRA-C违规类型:

规则编号 违规示例 正确写法
Rule 10.1 uint16_t a = b + c; (隐式类型转换) uint16_t a = (uint16_t)((uint32_t)b + c);
Rule 12.1 if (x = 5) (赋值当条件用) if (5 == x)
Rule 14.3 for(;;) (无条件循环) while(1) 或明确退出条件
Rule 17.4 数组索引越界风险 增加边界检查

避坑指南:我曾经在一个项目中,因为赶进度关闭了MISRA-C检查,结果代码里混入了大量隐式类型转换。在某个极端工况下,一个uint16_t变量溢出导致PWM占空比计算错误,直接烧了三个MOSFET管。从那以后,MISRA-C检查被我写进了CI流程,不通过不准合并代码。

4.4 代码覆盖率分析

覆盖率分析是衡量测试充分性的重要手段。很多团队只关注语句覆盖率,但我觉得这远远不够。对于逆变器这种安全关键系统,至少要做到分支覆盖,最好做到MCDC覆盖。

4.4.1 三种覆盖率的区别

覆盖率类型 含义 逆变器场景举例
语句覆盖 每行代码至少执行一次 检查过流保护函数中的每一行是否都跑过
分支覆盖 每个if/else分支都走到 过流保护中,触发和不触发两个分支都要测
MCDC覆盖 每个条件独立影响结果 过流保护中,ADC值、使能标志、延时标志各自独立验证

你想想看,如果只做语句覆盖,一个if语句里的条件表达式即使永远为假,只要if体里的代码被执行过,就算覆盖了。这显然不够。MCDC要求每个条件都能独立地改变判定结果,这才是真正的“充分测试”。

4.4.2 如何生成覆盖率报告

在嵌入式C项目中,我常用gcov和lcov工具链。配合Unity测试框架,流程是这样的:

  1. 编译时加上 -fprofile-arcs -ftest-coverage 选项
  2. 运行单元测试,生成 .gcda 和 .gcno 文件
  3. 使用 gcov 生成 .gcov 文件
  4. 使用 lcov 生成HTML格式的覆盖率报告
# 编译
gcc -fprofile-arcs -ftest-coverage -o test_suite test_suite.c source.c

# 运行测试
./test_suite

# 生成覆盖率报告
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_report

目标设定:对于逆变器软件,我建议的覆盖率目标是:语句覆盖≥90%,分支覆盖≥85%,关键模块(保护逻辑、PWM控制)MCDC覆盖≥80%。达不到?那就补测试用例,直到达标为止。

4.5 实战中的常见问题

最后,分享几个我在项目中踩过的坑:

  • 桩函数写得太简单:比如模拟ADC读取,永远返回固定值。这测不出边界情况。我后来改成用随机数生成器,模拟真实噪声。
  • 忽略时序问题:单元测试是串行执行的,但实际系统是并发的。中断、DMA这些异步事件,单元测试很难覆盖。我的做法是:单元测试保证逻辑正确,集成测试再验证时序。
  • 覆盖率数字好看,但质量不高:有些团队为了达标,写一堆无意义的测试用例。比如测一个加法函数,只测1+1=2,不测溢出情况。这其实是在自欺欺人。

好了,关于单元测试和静态分析,今天就聊到这里。下一章我们会讲集成测试和硬件在环测试,到时候再聊聊怎么把单元测试的结果和系统级测试串联起来。记住,测试不是负担,是保护你项目成功的铠甲。