❄️ 我的个人专栏: 
《智能软件工程AI4SE》
《嵌入式面试总结》
《嵌入式处理器架构解析》
《嵌入式与虚拟化》
《嵌入式软件测试》
🌟 Simplicity is the ultimate sophistication

摘要:本文面向资源受限的 MCU 平台,介绍一套不依赖操作系统的轻量级覆盖率测试方案。文章先分析裸机环境下存储紧张、无文件系统、无 OS 和实时性约束等现实问题,再给出「编译期插桩、运行期计数、退出时回传」的整体思路;随后分别讲解语句覆盖与分支覆盖的插桩实现,包括计数器数组设计、COV_HIT 与 COV_BRANCH 宏的用法及示例;最后说明如何通过串口、SWO 或调试器导出覆盖率数据,并用 Python 脚本自动生成「计数器索引 → 源文件:行号」映射表,结合串口数据生成覆盖率报告,同时总结工程实践中的注意事项。

一、为什么资源受限 MCU 需要轻量级覆盖率方案

在前面的系列文章中,我们讨论了基于 gcov、lcov 以及各类插桩工具的覆盖率测试方法。这些方案在 Linux 或带操作系统的嵌入式平台上运行良好,但一旦迁移到资源受限的 MCU(如 Cortex-M0/M3、8 位或 16 位单片机),就会遇到几个现实问题:

  • 存储空间紧张:Flash 往往只有几十 KB 到几百 KB,RAM 只有几 KB 到几十 KB,无法容纳完整的覆盖率运行时库。
  • 无文件系统:覆盖率数据无法像 PC 端那样直接写入文件,需要另找落盘或回传通道。
  • 无操作系统:没有进程、线程和动态内存管理,覆盖率数据的收集与输出必须由裸机代码自行完成。
  • 实时性约束:插桩代码不能显著影响中断响应和任务时序。

因此,我们需要一套轻量级、可裁剪、无 OS 依赖的覆盖率方案,在保证语句覆盖与分支覆盖能力的前提下,把运行时开销压到最低。

二、轻量级覆盖率方案的整体思路

核心思路可以概括为三点:编译期插桩、运行期计数、退出时回传。

flowchart TD
    A[源码] --> B[编译器插桩]
    B --> C[生成覆盖率计数器数组]
    C --> D[目标板运行测试用例]
    D --> E[计数器累加]
    E --> F[通过串口/SWD/内存导出]
    F --> G[PC 端解析生成报告]

与 gcov 相比,轻量级方案做了如下取舍:

对比项gcov 方案轻量级方案
运行时库体积较大(数十 KB)极小(几百字节到几 KB)
数据存储文件系统RAM 计数器数组
OS 依赖需要无
数据导出直接写文件串口 / SWO / 调试器读取
分支覆盖支持通过插桩实现

三、语句覆盖的插桩实现

语句覆盖的目标是确认每条可执行语句至少被执行一次。最直接的做法是在每个基本块入口插入一个计数器自增操作。

3.1 计数器数组设计

为每个插桩点分配一个固定索引,所有计数器集中放在一个全局数组中,便于统一导出:

#include <stdint.h>
#define COV_MAX_POINTS 512
/* 覆盖率计数器数组,放在 .noinit 段避免启动时清零 */
attribute((section(".noinit")))
volatile uint16_t g_cov_counters[COV_MAX_POINTS];
/* 插桩宏:记录某点被执行 */
#define COV_HIT(id)  do { 
if ((id) < COV_MAX_POINTS) {
g_cov_counters[(id)]++;
}
} while (0)

使用 uint16_t 而非 uint32_t 可以把数组体积减半;对于绝大多数测试用例,16 位计数已经足够,溢出风险很低。

3.2 插桩示例

int calc_checksum(const uint8_t *buf, uint32_t len)
{
    COV_HIT(0);                 /* 函数入口 */
    uint32_t sum = 0;
    for (uint32_t i = 0; i < len; i++) {
        COV_HIT(1);             /* 循环体 */
        sum += buf[i];
    }
    COV_HIT(2);                 /* 循环结束 */
    return (int)(sum & 0xFFFF);
}

每个 COV_HIT 对应源码中的一个语句位置,测试结束后只要检查对应计数器是否大于 0,即可判断该语句是否被覆盖。

四、分支覆盖的插桩实现

分支覆盖要求每个判断的真假两个方向都至少被执行一次。实现方式是为每个判断分配两个计数器,分别记录真分支和假分支的命中次数。

4.1 分支插桩宏

/* 分支插桩:id 为分支编号,cond 为判断条件 */
#define COV_BRANCH(id, cond) \
    do { \
        if (cond) { \
            g_cov_counters[2 * (id)]++; \
        } else { \
            g_cov_counters[2 * (id) + 1]++; \
        } \
    } while (0)

4.2 分支插桩示例

int parse_frame(const uint8_t *frame, uint32_t len)
{
    COV_HIT(10);
    if (len < 4) {
        COV_BRANCH(5, 1);       /* 长度不足分支 */
        return -1;
    }
    COV_BRANCH(5, 0);           /* 长度正常分支 */
if (frame[0] != 0xAA) {
    COV_BRANCH(6, 1);       /* 帧头错误 */
    return -2;
}
COV_BRANCH(6, 0);           /* 帧头正确 */
return 0;
}

这里 COV_BRANCH(5, ...) 的两个计数器分别对应 len < 4 为真和为假的情况。测试完成后,若两个计数器都大于 0,说明该判断的两个分支均被覆盖。

五、覆盖率数据的导出与解析

裸机环境下没有文件系统,覆盖率数据需要借助调试通道导出。常见方式有三种:

  1. 串口输出:测试结束后遍历计数器数组,通过 UART 以文本或二进制格式打印。
  2. SWO / ITM:Cortex-M 平台可通过 SWO 引脚输出,不占用串口资源。
  3. 调试器直接读取:通过 J-Link / ST-Link 直接读取 RAM 中的计数器数组,无需额外代码。

5.1 串口导出示例

void cov_dump_uart(void (*putc_fn)(char))
{
    for (uint32_t i = 0; i < COV_MAX_POINTS; i++) {
        if (g_cov_counters[i] != 0) {
            /* 输出格式:索引:计数\n */
            char buf[16];
            int n = snprintf(buf, sizeof(buf), "%lu:%u\n",
                             (unsigned long)i, g_cov_counters[i]);
            for (int k = 0; k < n; k++) {
                putc_fn(buf[k]);
            }
        }
    }
}

PC 端接收后,将索引映射回源码位置,即可生成覆盖率报告。索引与源码的映射关系可以在编译期通过脚本自动生成,避免手工维护。

5.2 用 Python 脚本自动生成索引映射表

手工维护「计数器索引 → 源文件:行号」的映射既繁琐又容易出错。更可靠的做法是在插桩阶段就把映射关系记录下来,编译后再用脚本解析成一张映射表。下面这个 Python 脚本演示了两种常见输入:

  • 插桩索引表:插桩脚本在生成 COV_HIT(id) / COV_BRANCH(id, ...) 时同步写出的 CSV,形如 id,type,file,line。
  • .map 文件:链接器生成的映射文件,用于补充函数地址与符号信息,便于交叉核对。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""从插桩索引表 / .map 文件生成覆盖率索引映射表。
用法:
python gen_cov_map.py --index cov_index.csv --map firmware.map -o cov_map.json
"""
import argparse
import csv
import json
import re
from pathlib import Path
匹配 .map 文件中的符号行,例如:
0x08001234   calc_checksum   0x00000040  app.o
MAP_SYMBOL_RE = re.compile(
r"^\s*(0x[0-9a-fA-F]+)\s+(\S+)\s+(0x[0-9a-fA-F]+)\s+(\S+)\s*$"
)
def load_index_table(csv_path):
"""读取插桩阶段生成的索引表,返回 {id: {type, file, line}}。"""
mapping = {}
with open(csv_path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
# id 统一转成整数,避免 "01" 与 "1" 不一致
idx = int(row["id"])
mapping[idx] = {
"type": row.get("type", "stmt"),   # stmt / branch
"file": row["file"],
"line": int(row["line"]),
}
return mapping
def load_map_symbols(map_path):
"""解析 .map 文件,返回 {符号名: 地址},用于交叉核对。"""
symbols = {}
with open(map_path, encoding="utf-8", errors="ignore") as f:
for line in f:
m = MAP_SYMBOL_RE.match(line)
if m:
addr, name = m.group(1), m.group(2)
symbols[name] = int(addr, 16)
return symbols
def build_report(mapping, counters):
"""把串口导出的 {索引: 计数} 与映射表结合,生成覆盖率报告。"""
total = len(mapping)
hit = 0
details = []
for idx, info in sorted(mapping.items()):
count = counters.get(idx, 0)
covered = count > 0
if covered:
hit += 1
details.append({
"index": idx,
"type": info["type"],
"location": f'{info["file"]}:{info["line"]}',
"count": count,
"covered": covered,
})
rate = (hit / total * 100.0) if total else 0.0
return {"total": total, "hit": hit, "rate": round(rate, 2), "details": details}
def parse_uart_dump(text):
"""解析串口导出的 "索引:计数" 文本,返回 {索引: 计数}。"""
counters = {}
for line in text.splitlines():
line = line.strip()
if not line or ":" not in line:
continue
idx_str, cnt_str = line.split(":", 1)
try:
counters[int(idx_str)] = int(cnt_str)
except ValueError:
continue  # 跳过非数据行(如启动日志)
return counters
def main():
parser = argparse.ArgumentParser(description="生成覆盖率索引映射表")
parser.add_argument("--index", required=True, help="插桩索引表 CSV")
parser.add_argument("--map", dest="map_file", help="链接器 .map 文件(可选)")
parser.add_argument("--dump", help="串口导出的计数文本(可选)")
parser.add_argument("-o", "--output", default="cov_map.json", help="输出文件")
args = parser.parse_args()
mapping = load_index_table(args.index)
.map 文件仅用于交叉核对,缺失不影响主流程
if args.map_file and Path(args.map_file).exists():
symbols = load_map_symbols(args.map_file)
print(f"[info] 从 .map 解析到 {len(symbols)} 个符号")
result = {"mapping": mapping}
若提供了串口导出数据,直接生成覆盖率报告
if args.dump and Path(args.dump).exists():
counters = parse_uart_dump(Path(args.dump).read_text(encoding="utf-8"))
result["report"] = build_report(mapping, counters)
print(f"[info] 覆盖率: {result['report']['rate']}% "
f"({result['report']['hit']}/{result['report']['total']})")
Path(args.output).write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"[info] 映射表已写入 {args.output}")
if name == "main":
main()

脚本输出的 cov_map.json 中,mapping 字段就是「计数器索引 → 源文件:行号」的映射表,report 字段则是结合串口数据后的覆盖率统计结果。整个流程可以概括为:

  1. 插桩阶段:插桩脚本为每个 COV_HIT / COV_BRANCH 分配唯一索引,并写入 cov_index.csv。
  2. 编译阶段:链接器生成 firmware.map,用于核对符号与地址。
  3. 测试阶段:目标板运行测试用例,通过串口输出 索引:计数 文本。
  4. 解析阶段:运行上述脚本,把索引表与串口数据合并,得到带源码位置的覆盖率报告。

这样,即使插桩点数量达到数百个,也无需手工维护映射关系;只要插桩脚本与解析脚本使用同一套索引规则,覆盖率报告就能自动生成并保持与源码同步。

六、工程实践中的注意事项

  • 计数器数组放在 .noinit 段:避免启动代码清零,同时减少初始化开销。
  • 插桩点数量要可控:通过宏开关按模块裁剪,只对关键模块开启插桩。
  • 避免在中断中插桩:中断服务程序中的插桩会显著增加响应时间,必要时单独统计。
  • 注意计数器溢出:长时间运行的测试用例可改用饱和计数或定期清零。
  • 与优化等级配合:高优化等级可能合并或删除语句,建议覆盖率测试使用 -O0 或 -Og。

七、小结

本文介绍了一套不依赖操作系统的轻量级覆盖率方案:通过编译期插桩在源码中埋入计数器,运行期在 RAM 中累加,测试结束后通过串口、SWO 或调试器导出数据,最终在 PC 端生成语句覆盖与分支覆盖报告。该方案运行时开销小、可裁剪性强,适合资源受限的 MCU 平台。下一篇我们将讨论如何用脚本自动完成插桩点与源码位置的映射,进一步降低维护成本。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐