❄️ 我的个人专栏:
《智能软件工程AI4SE》
《嵌入式面试总结》
《嵌入式处理器架构解析》
《嵌入式与虚拟化》
《嵌入式软件测试》
🌟 Simplicity is the ultimate sophistication
摘要:本文面向嵌入式白盒测试入门,系统梳理语句覆盖率、分支覆盖率、条件覆盖率、路径覆盖率与 MC/DC 五个核心指标的定义、示例与局限,并给出对比表和按项目类型选用的实用建议;最后以 GCC 插桩 + gcov + lcov 为例,完整演示从编译插桩、运行用例生成 .gcda 到生成 HTML 报告的落地流程,附 Makefile 片段与目标板路径映射、gcda 未生成等常见坑的排查方法。
一、为什么嵌入式白盒测试要先谈覆盖率
做嵌入式软件测试,黑盒测试能验证功能是否符合需求,但很难回答一个问题:代码里到底有多少逻辑被真正执行过?尤其是 MCU 上的驱动、状态机、中断服务程序、通信协议解析这类代码,分支多、边界条件多,仅靠功能测试很容易漏掉异常路径。
白盒测试的核心思路是“看着代码设计用例”,而覆盖率就是衡量白盒测试充分性最直观的指标。它把“测没测到”从主观判断变成了可统计的数字,让测试工作有了一把可度量的尺子。
不过在嵌入式场景里,覆盖率并不是越高越好,也不是所有项目都必须做到 100%。先搞懂下面 5 个覆盖率概念,才能判断自己的项目该测到什么程度、该用哪种工具、该关注哪些指标。
二、概念一:语句覆盖率(Statement Coverage)
语句覆盖率是最基础、最容易理解的指标,它统计的是可执行语句中被执行过的比例。
int calc_checksum(uint8_t *buf, uint16_t len)
{
uint32_t sum = 0; /* 语句 1 */
for (uint16_t i = 0; i < len; i++) {
sum += buf[i]; /* 语句 2 */
}
return (int)(sum & 0xFF); /* 语句 3 */
}
如果测试用例只调用了一次 calc_checksum(buf, 0),那么语句 2 一次都没有执行,语句覆盖率就是 2/3 ≈ 66.7%。
它的局限很明显:语句覆盖率无法发现“条件判断只走了一个方向”的问题。比如 if (a && b) 中 a 为假时短路,b 的取值组合根本没被验证,但语句覆盖率可能已经 100%。
在嵌入式项目里,语句覆盖率通常作为入门门槛,一般要求达到 90% 以上,但绝不能把它当成唯一标准。
三、概念二:分支覆盖率(Branch Coverage)
分支覆盖率也叫判定覆盖率,统计的是每个判断的真假两个分支是否都被执行过。它比语句覆盖率更严格,因为一个判断即使只走一个方向,语句也可能全部被执行。
bool is_valid_frame(const frame_t *f)
{
if (f == NULL) {
return false; /* 分支 A */
}
if (f->len > MAX_LEN) {
return false; /* 分支 B */
}
return true; /* 分支 C */
}
只测“正常帧”时,分支 A 和分支 B 的“真”方向都没走到,分支覆盖率只有 50%。要补齐,就必须构造空指针和超长帧两个异常用例。
嵌入式代码里大量存在“参数校验 + 错误返回”的写法,这类分支恰恰是现场故障的高发区。因此分支覆盖率往往比语句覆盖率更有实际意义,很多安全相关标准(如汽车、医疗、工业控制领域)都会把分支覆盖率作为强制指标。
四、概念三:条件覆盖率(Condition Coverage)
条件覆盖率关注的是复合判断中每个原子条件的真假取值是否都被覆盖。它比分支覆盖率更细一层。
if ((temp > 80) && (voltage < 3.0)) {
enter_protect_mode();
}
分支覆盖率只要求这个 if 整体走过真和假各一次;而条件覆盖率要求 temp > 80 为真、为假各一次,voltage < 3.0 为真、为假各一次。
注意一个容易踩的坑:由于 C 语言的短路求值,当 temp > 80 为假时,voltage < 3.0 根本不会被求值。所以只靠普通用例,条件覆盖率很难自然达标,需要专门设计组合用例。
在嵌入式保护逻辑、故障判定逻辑中,条件覆盖率能有效暴露“某个条件永远为真/永远为假”的隐藏缺陷,比如传感器量程配置错误导致判断恒成立。
五、概念四:路径覆盖率(Path Coverage)
路径覆盖率统计的是函数中所有可能执行路径被覆盖的比例。它是最严格的覆盖率指标,但也是最难做全的。
一个函数里如果有 3 个连续 if,理论上就有 2³ = 8 条路径;如果有循环,路径数量甚至可能是无限的。实际工程中,路径覆盖率通常只对关键小函数使用,比如状态机跳转、协议解析、安全校验。
switch (state) {
case IDLE:
if (event == EV_START) state = RUN;
break;
case RUN:
if (event == EV_STOP) state = IDLE;
if (event == EV_ERROR) state = FAULT;
break;
case FAULT:
if (event == EV_RESET) state = IDLE;
break;
default:
state = FAULT;
break;
}
对这类状态机,路径覆盖率能帮助我们发现“某个状态跳转组合从未被测试”的问题。但要注意,路径爆炸是客观存在的,工程上更常见的做法是用分支覆盖率保证整体,用路径覆盖率重点保障核心模块。
六、概念五:MC/DC(修正条件/判定覆盖率)
MC/DC 全称 Modified Condition/Decision Coverage,是航空、汽车等高安全等级软件中最常被引用的覆盖率指标。它的要求是:每个原子条件都能独立影响一次判断结果。
简单说,对 (A && B) 这样的判断,MC/DC 要求证明“只改变 A 就能改变整体结果”和“只改变 B 就能改变整体结果”各至少一次。相比条件覆盖率,它排除了“条件变了但结果没变”的无效覆盖;相比路径覆盖率,它又避免了组合爆炸。
| 1 | 真 | 真 | 真 | 基准 |
| 2 | 假 | 真 | 假 | A 独立影响结果 |
| 3 | 真 | 假 | 假 | B 独立影响结果 |
上表 3 个用例即可满足 (A && B) 的 MC/DC 要求。可以看到,MC/DC 的用例数量随条件数线性增长,而不是指数增长,这正是它在工程上可落地的原因。
在嵌入式领域,MC/DC 常见于符合 ISO 26262、DO-178C 等标准的项目。对普通项目而言,理解它的思想比强行追求指标更有价值:每个条件都要有能单独改变结果的用例。
七、5 个概念的对比与选用建议
| 语句覆盖率 | 可执行语句 | 低 | 入门门槛、快速摸底 |
| 分支覆盖率 | 判断的真假分支 | 中 | 通用白盒测试主指标 |
| 条件覆盖率 | 原子条件取值 | 中高 | 复杂保护逻辑、故障判定 |
| 路径覆盖率 | 执行路径组合 | 高 | 状态机、协议解析等核心小函数 |
| MC/DC | 条件独立影响 | 高 | 高安全等级认证项目 |
给嵌入式项目的实用建议:
- 普通产品项目:以分支覆盖率为主指标,语句覆盖率作为辅助,核心模块适当补充条件覆盖率。
- 安全相关项目:按对应标准要求执行,通常需要分支覆盖率加 MC/DC,并保留完整的覆盖率报告和未覆盖原因说明。
- 不要盲目追求 100%:防御性代码、硬件异常分支、编译器生成的代码往往难以覆盖,应逐条分析并记录豁免理由,而不是为了数字去写无意义的用例。
九、覆盖率工具链落地示例
概念讲清楚之后,最关键的一步是把它跑起来。在 Linux 主机上做嵌入式白盒测试,最常用的一套组合是 GCC 插桩 + gcov + lcov:编译时加 -fprofile-arcs -ftest-coverage,运行用例后生成 .gcda 数据文件,再用 gcov 或 lcov 汇总成可读报告。下面按“编译插桩 → 运行用例 → 生成数据 → 生成报告”的顺序给出完整命令序列。
9.1 编译插桩:生成 .gcno
插桩的本质是让编译器在每条语句、每个分支处埋点。加上 –coverage(等价于 -fprofile-arcs -ftest-coverage)后,编译阶段会为每个源文件生成一个 .gcno 文件,记录代码结构与插桩点位置。
# 1. 准备干净的构建目录,避免旧数据干扰
rm -rf build && mkdir -p build && cd build
2. 编译插桩:–coverage 等价于 -fprofile-arcs -ftest-coverage
-O0 -g 便于报告定位到具体行,调试阶段不建议开高优化
gcc –coverage -O0 -g -I../include
-c ../src/checksum.c -o checksum.o
gcc –coverage -O0 -g -I../include
-c ../src/frame.c -o frame.o
3. 链接时同样需要 –coverage,否则缺少 gcov 运行时支持
gcc –coverage -O0 -g checksum.o frame.o ../test/test_main.c
-o coverage_demo
4. 确认 .gcno 已生成(每个参与插桩的源文件对应一个)
ls *.gcno
此时目录下应该能看到 checksum.gcno、frame.gcno 等文件。只有 .gcno 而没有 .gcda,说明程序还没跑过或数据没写出来,这是后面排查问题的关键线索。
9.2 运行用例:生成 .gcda
.gcda 是运行时计数数据,程序正常退出时由 gcov 运行时库写入,路径与 .gcno 保持一致。
# 5. 运行测试用例,正常退出后才会写出 .gcda
./coverage_demo
6. 确认 .gcda 已生成
ls *.gcda
7. 可选:先用 gcov 快速看单文件的行覆盖情况
gcov -b -c checksum.c
输出 checksum.c.gcov,其中 ##### 表示未执行行,
branch 0 taken 0% 表示该分支从未走到
如果用例是分批跑的,可以多次执行程序,.gcda 会自动累加计数,不需要每次删除重建。
9.3 用 lcov 生成 HTML 报告
gcov 的输出偏原始,工程上更常用 lcov 汇总成带颜色标注的 HTML 报告,方便定位未覆盖代码。
# 8. 采集覆盖率数据,生成 info 文件
lcov –capture \\
–directory . \\
–output-file coverage.info
9. 剔除系统头文件、第三方库等无关代码
lcov –remove coverage.info
'/usr/'
'/third_party/*'
–output-file coverage.filtered.info
10. 生成 HTML 报告到 report/ 目录
genhtml coverage.filtered.info
–output-directory report
–legend –title "Embedded Coverage Report"
11. 查看汇总数据(行覆盖率、函数覆盖率、分支覆盖率)
lcov –summary coverage.filtered.info
打开 report/index.html 即可看到按目录、按文件、按行三级下钻的报告。未执行的行会标红,部分执行的分支会标黄,配合前面的分支覆盖率概念,能快速判断该补哪类用例。
9.4 Makefile 片段
把上面的流程固化到 Makefile 里,日常只需 make coverage 一条命令:
CC := gcc
CFLAGS := -O0 -g -Iinclude
COVFLAGS:= –coverage
LDFLAGS := –coverage
SRCS := src/checksum.c src/frame.c
OBJS := $(SRCS:src/%.c=build/%.o)
TARGET := build/coverage_demo
all: $(TARGET)
build/%.o: src/%.c
@mkdir -p build
$(CC) $(CFLAGS) $(COVFLAGS) -c $< -o $@
$(TARGET): $(OBJS) test/test_main.c
$(CC) $(CFLAGS) $(COVFLAGS) $(OBJS) test/test_main.c -o $@ $(LDFLAGS)
一键跑覆盖率:清理旧数据 -> 编译 -> 运行 -> 生成报告
coverage: clean
$(MAKE) all
./$(TARGET)
lcov –capture –directory build –output-file build/coverage.info
lcov –remove build/coverage.info '/usr/*' –output-file build/coverage.filtered.info
genhtml build/coverage.filtered.info –output-directory build/report –legend
lcov –summary build/coverage.filtered.info
clean:
rm -rf build
find . -name '.gcda' -delete
find . -name '.gcno' -delete
.PHONY: all coverage clean
注意 clean 里同时删除 .gcda 和 .gcno:只删 .gcda 会保留旧的插桩结构,若源码已改动,可能出现行号错位。
9.5 常见坑与排查
| 只有 .gcno,没有 .gcda | 程序未运行、异常退出(如 abort、段错误)、或运行目录与编译目录不一致 | 确认程序正常返回 0;用 find / -name "*.gcda" 全盘查找;必要时在 main 返回前调用 __gcov_flush() 强制落盘 |
| lcov 报 “no .gcda files found” | –directory 指向的目录不对,或数据在目标板上没回传 | 用 –directory 指向 .gcda 实际所在目录;交叉编译场景先回传数据再采集 |
| 报告里源码路径全是绝对路径、点不开 | 编译时源文件用了绝对路径,或目标板路径与主机不一致 | 用 lcov –rc geninfo_unexecuted_blocks=1 配合 –prefix 重写路径;或在 genhtml 前用 sed 把 SF: 行替换为主机路径 |
| 覆盖率数据与源码行号对不上 | 源码改动后未重新编译,残留旧 .gcno | 执行 make clean 后全量重编,确保 .gcno 与当前源码一致 |
| 目标板上无法写文件 | 根文件系统只读,或 .gcda 写入路径不可写 | 设置环境变量 GCOV_PREFIX 和 GCOV_PREFIX_STRIP,把数据重定向到可写目录(如 /tmp/cov),再回传到主机 |
其中目标板路径映射是嵌入式场景最容易卡住的一环。交叉编译时,.gcno 里记录的是编译机上的源码绝对路径,而 .gcda 是在目标板上生成的。推荐做法是:
# 在目标板上运行前设置,把覆盖率数据写到可写目录
export GCOV_PREFIX=/tmp/cov
export GCOV_PREFIX_STRIP=3 # 去掉路径前 3 级,避免主机绝对路径
./coverage_demo
回传到主机后,在主机上按相同目录结构采集
scp -r root@target:/tmp/cov ./cov_data
lcov –capture –directory ./cov_data –output-file coverage.info
如果报告里仍然出现 ##### 但代码明明跑过,优先检查 .gcda 与 .gcno 是否来自同一次编译、同一份源码。覆盖率工具链的坑,八成都能归结为“数据与插桩结构不匹配”。
八、小结
这 5 个覆盖率概念从粗到细,构成了嵌入式白盒测试的度量体系:语句覆盖率看“有没有执行”,分支覆盖率看“判断有没有走全”,条件覆盖率看“每个条件有没有取到真假”,路径覆盖率看“组合有没有测到”,MC/DC 看“条件能不能独立影响结果”。
理解它们的差异,才能在实际项目里选对指标、设计出真正有效的用例。下一篇将进入实操环节,介绍如何在嵌入式环境中搭建覆盖率统计工具链,包括编译插桩、目标板数据回传和报告生成的具体做法。




