ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用Testbed做覆盖率分析:插桩、MC/DC与报告解读

用Testbed做覆盖率分析:插桩、MC/DC与报告解读 我做嵌入式软件测试这几年被问得最多的一个问题是“覆盖率数据呢”。尤其到了安全评审、版本发布、客户验收这些节点覆盖率数字几乎和代码审查记录一样成了必交的材料。当时我们团队用的就是 LDRA Testbed 来做覆盖率分析从配工程、插桩、跑用例到出报告整条链路我反反复复摸了好多遍也踩过不少坑。这篇文章就把我实际用 Testbed 做覆盖率分析的完整经验拆开讲一讲——它到底怎么工作、报告里的指标怎么解读、实操中哪些问题一定会遇到。如果你正准备在项目里引入覆盖率分析或者正被一份覆盖率报告逼得焦头烂额这篇应该能帮你少走几段弯路。1. 覆盖率为什么是“硬指标”从一次安全评审说起1.1 覆盖率不是“数量指标”而是测试充分性的证据先讲一个我经历过的场景。那是一次面向客户的内部评审会对方是航空电子领域的一级供应商对我们的一个飞控相关模块做过程审计。前面聊功能测试、性能测试都还算顺畅到了提问环节对方负责人很平静地翻着我们的测试记录问了一句“MC/DC 覆盖率数据在哪儿能提供到子条件级别的分析结果吗”当时会议室安静了几秒。我们确实做了单元测试、集成测试传统语句覆盖率也统计过但“到子条件级别的 MC/DC 覆盖率”这个要求意味着用户要求的不只是“哪些语句执行了”而是“每一个影响判定结果的布尔条件是否都独立地证明过它对输出结果的影响”。这一下就把覆盖率分析从“统计工具”提升到了“证据链”的层面。这也是 Testbed 的覆盖率分析真正值钱的地方。它不是在测完以后给你一个“跑了 80% 代码”的虚荣数字而是通过插桩采集执行路径告诉你每一行、每一个分支、每一个条件的真实状态。语句覆盖回答的是“代码有没有被执行”分支覆盖回答的是“每个判断的真假两个出口有没有都走过”而 MC/DC 回答的是“每个条件是否单独影响过判定结果”。后者在 DO-178C 的 Level A 软件中属于必需项在 ISO 26262 的 ASIL D 等级下同样是重点考察对象。所以我在带项目时反复跟团队成员强调一句话覆盖率数据不是给测试人员自嗨的它是给评审方看的“测试充分性证据”。没有覆盖率数据你说“我测过了”是没有底气的有了覆盖率分析你至少能精确地告诉对方——哪些代码确实是测过的哪些代码还没测到没测到的那部分是因为死代码、防御性代码还是单纯缺少一个测试用例。1.2 普通嵌入式项目同样需要覆盖率思维的三个理由有人可能会说我又不做航空航天也不做功能安全认证覆盖率分析有必要吗我的看法是即便你的产品不需要过任何标准覆盖率思维仍然能实打实地提升工程质量。第一个理由是它能暴露“看起来测了但实际上没测到”的假完整性。我见过不少项目功能测试用例写得满满当当但跑完覆盖率分析才发现某个分支的错误处理代码压根没进去过——因为测试数据里从来没有构造过触发错误分支的输入。这类问题在查 bug 的时候特别隐蔽你可能反复看代码也发现不了而覆盖率报告会直接把它标红给你看。第二个理由是回归测试的效率。嵌入式项目最怕改一处引入三处隐患覆盖率分析可以作为回归测试的“对照尺”这次版本发布前和上次版本发布前关键模块的覆盖率有没有掉掉了就说明新增或修改的代码缺少对应测试用例这比靠经验拍脑袋判断“应该测哪几个模块”要可靠得多。第三个理由是团队责任边界清晰化。当测试人员和开发人员拿到同一份覆盖率报告哪些代码因为缺少测试环境而覆盖不到、哪些是实实在在地没人测这些都能直接摊开来说。我在项目里最反感的就是“测试没测出来是因为代码有问题”这种互相甩锅覆盖率数据摆出来问题在谁的环节基本一目了然。2. Testbed 做覆盖率分析的整体链路从工程导入到报告生成2.1 LDRA Testbed 工具链里谁负责哪一块先说一个基本认知行业内说“用 Testbed 做覆盖率分析”默认指的是 LDRA Testbed 这套工具链。它不是一个单一的可执行程序而是一组围绕“静态分析 动态测试 覆盖率采集 需求追溯”的工具集合。我在实际项目里经常打交道的几个组成部分大致是这样的工具模块主要职责我在项目里的用法TBvision图形化总控界面管理和展示整个项目创建工程、配置环境、集中查看报告入口TBview静态分析、代码质量度量在插桩前先做一轮静态扫描查未初始化变量、空指针等缺陷TBrun单元测试与动态分析自动生成测试驱动、执行插桩后的程序、采集覆盖率TBw覆盖率数据浏览与结果分析查看哪一行、哪个分支没跑到定位非常直接TBreq需求追溯管理把测试用例和需求条目关联起来出追溯矩阵这套工具链最核心的思路是静态分析和动态分析分开做。静态分析不运行代码靠对源码的遍历和建模发现潜在缺陷动态分析需要真正编译执行而覆盖率分析就属于动态分析的一部分。你可以在 Testbed 里先做静态分析把代码层面的问题清理一遍再做动态覆盖率分析这样两种手段各司其职也不会因为静态缺陷干扰动态覆盖率数据。2.2 一次完整的覆盖率分析流程拆解结合我的使用习惯用 Testbed 做覆盖率分析大概分五步走。第一步是工程导入和代码解析。把被测模块的源文件导入 TBvisionTestbed 会对 C/C、Ada 这些语言进行语法解析建立代码模型。这一步看着简单实际上很关键——源码里如果有语法错误、编译器特有的扩展语法、或者头文件路径配置不对后面插桩和编译都会连环出错。我一般会在导入后先跑一次静态分析相当于给代码做个体检确认解析正常再往下走。第二步是配置覆盖率级别。TBrun 里可以选择你需要的覆盖率测量级别常见的有语句覆盖Statement Coverage、分支覆盖Decision Coverage、条件覆盖Condition Coverage和 MC/DC 覆盖。不同级别的插桩策略和运行开销差别很大如果只是为了日常回归测试跑语句覆盖和分支覆盖足够如果是为了满足安全认证就要按评审要求配置到 MC/DC。第三步是插桩与生成测试驱动。Testbed 会在源代码的判定点、条件点、语句起始点插入探针代码然后自动生成测试驱动程序Test Harness把被测函数和驱动代码一起交给目标平台的交叉编译器去编译链接。这一步是整个流程里最考验环境配置的环节编译器路径、链接脚本、运行库版本只要有一个不匹配插桩后的代码就编不过。第四步是在目标环境执行测试用例。测试用例的来源可以是 TBrun 自动生成的也可以是你手动在测试驱动里编写的。程序在目标板或者宿主机模拟器上运行每执行一次探针就会记录对应位置的执行次数把这些信息写进覆盖率数据文件。第五步是生成覆盖率报告并分析结果。测试跑完后Testbed 会把数据文件汇总生成分析报告。在 TBw 界面里你可以直接看到源码上每行被标成绿色或红色标记——绿色代表已执行红色代表未覆盖双击红色代码还能直接调出对应的测试数据。整个链路走完一次一份模块的覆盖率分析就出来了。2.3 静态分析和动态分析为什么必须分清先后很多新手第一次用 Testbed 容易混淆静态分析和动态分析以为这两个是一回事。实际上它们的执行机制完全不同静态分析不需要运行程序Testbed 靠语法树和数据流分析直接扫描源码动态覆盖率分析则一定要把程序编译出来、跑起来才能拿到数据。我建议的顺序是先静态后动态。原因有两点一是静态分析能提前暴露一些“即使覆盖率 100% 也测不出来”的问题比如未初始化变量、数组越界隐患、死代码等这类问题靠动态测试很难稳定复现二是插桩和编译会改变代码的体积和结构如果源码本身还有基础性缺陷插桩之后的排查难度会明显提升。在 Testbed 里先跑一轮 TBview 的静态分析通常几分钟就能出结果这时间花得非常值。3. 插桩实现Testbed 是怎么知道每一行代码跑没跑的3.1 源码插桩与探针的基本原理覆盖率分析听起来挺玄核心原理其实可以打个比方你想知道一条物流路线的每个站点有没有被送到货物就在每个站点装一个计数器货车每过一站刷一次卡。Testbed 做覆盖率分析也是这个思路——在代码的关键位置插入“探针”程序运行到这些位置时探针会记录一次执行事件。具体来说Testbed 支持两大类插桩方式。一类是源码插桩Source Code Instrumentation它直接在源代码的语句块入口、分支判定点、条件表达式处插入探针代码然后再编译另一类是目标码插桩Object Code Instrumentation在编译生成的汇编或目标文件层面插入探针主要用于没有源码或者无法改源码的场景。两类方式各有适用场景常规的嵌入式项目大多用源码插桩因为它和代码行、分支、条件的对应关系最直接覆盖率报告看起来最直观。插桩点选在哪儿直接决定覆盖率能做到什么精度。比如语句覆盖只需要在每条可执行语句前放一个探针分支覆盖则要在判定语句的真/假两个出口都埋点MC/DC 更复杂它要求在条件表达式里的每个子条件处埋点并且要记录子条件在真值组合变化时对判定结果的影响。这也是为什么 MC/DC 对运行开销和存储空间的要求比语句覆盖高出几个量级。3.2 SC、DC、MC/DC 不同级别的插桩策略把几种覆盖率级别的差异放在一起看会清晰很多覆盖率级别回答什么问题插桩位置典型应用场景语句覆盖 SC每条语句是否执行过可执行语句起始点日常测试、冒烟测试分支覆盖 DC每个判定真假出口是否都走过判定语句两个分支点大多数嵌入式项目的标准要求条件覆盖 CC每个布尔条件的是真/是假两种取值是否都出现条件表达式内的每个子条件安全相关模块的增强验证MC/DC每个子条件是否独立影响过判定结果子条件 判定结果的联合记录DO-178C Level A、ISO 26262 ASIL D这里有个容易混淆的点条件覆盖和 MC/DC 不是一回事。条件覆盖检查的是“每个条件的真/假取值是否都出现过”存在一个死角——所有条件都为它取过不同值但判定结果可能永远被其中一个条件主导其他条件是否真正独立影响过输出并无从考证。MC/DC 逻辑上比条件覆盖严格得多它的标准说法是“每个条件都必须独立地影响判定结果且每个判定结果都至少出现一次真和假”。我最早理解 MC/DC 时卡了很久最后是用汽车空调的例子想明白的。假设空调启动条件是“门关闭 且 安全带系好”条件覆盖只要求测过“门关/门开”和“安全带系/没系”的所有组合但如果一次测试里门关着、安全带系着判定结果是真另一次测试里门开着、安全带没系判定结果是假——这个过程里安全带条件从系到没系判定结果确实变了但门条件呢门条件为真时判定是真门条件为假时判定也为假门条件并没有独立证明过自己能影响判定结果。MC/DC 恰恰要求这种“单个条件变化、其他条件固定、判定结果随之变化”的独立影响证据这就是它在安全关键系统里被视为黄金标准的原因。3.3 插桩代码对真实工程的侵入性处理老实说插桩并不是没有代价的。插桩代码会增大程序的体积增加运行时的开销这一点在资源受限的单片机项目里尤其敏感。一个 64KB Flash 的 MCU插桩后的代码可能膨胀 30% 甚至更多如果目标平台 Flash 本来就紧张直接在真机目标板上跑覆盖率很可能装不下。我遇到过的另一种情况是插桩影响实时性。飞控、电机控制这类对时序有硬要求的模块插桩探针会带来微秒级的额外执行时间这在外设中断、定时器驱动里可能产生肉眼可见的波形变化。处理方式通常有两种一种是在宿主机模拟器上跑覆盖率分析避免对目标板时序的干扰另一种是硬着头皮在目标板跑但只对纯逻辑函数和状态机代码插桩对外设驱动和中断服务函数不做插桩——当然这个折衷要写清楚在覆盖率报告里因为这意味着驱动部分没有被纳入覆盖率统计。最后还有一个绕不开的点插桩后的代码不应该直接变成产品代码。项目里要严格区分两个版本带插桩的 Testbed 测量版本以及不带插桩的正式发布版本。我们的做法是让测试脚本自动维护两套构建配置基于同一份源码生成两类工程避免手动修改源码导致插桩探针残留在交付代码里。4. 覆盖率报告解读那些数字背后的问题4.1 语句覆盖与分支覆盖的落差说明什么覆盖率报告拿到手先看两个数字语句覆盖率是多少分支覆盖率是多少。这两个数字之间的差距往往比单个数字本身更有信息量。我见到过一种典型情况语句覆盖率 95%分支覆盖率只有 60%。这说明绝大部分代码语句确实执行了但很多判定结构只走了一个方向的出口。出现这种局面最常见的原因是测试用例的输入值太“温和”——只覆盖了正常路径没有构造异常路径、边界路径。举个例子一个函数里有if (error_code ! NO_ERROR)如果测试用例永远传 NO_ERROR那这行 if 的真分支根本不会走语句覆盖率可能因为 if 语句本身被顺序执行而统计为已覆盖但分支覆盖率就会如实标记这个真分支为未覆盖。所以我在项目里一般把分支覆盖率当作“测试用例质量”的晴雨表。语句覆盖率再高如果分支覆盖率长期偏低说明测试用例的多样性不够你只是在证明“代码写出来了”而不是在证明“代码的每一种行为都被验证过了”。4.2 未覆盖代码的三种典型来源覆盖率报告里标红的代码逐行看过去绝大多数可以归到三类来源。第一类是防御性代码。像if (pData NULL)这种上层永远不该触发的保护判断在正常业务流里很难覆盖到。第二类是死代码或冗余代码——历史遗留的分支、某个版本之后不再调用的函数、为了兼容旧协议保留下来的无效逻辑这类代码从工程维护角度其实应该删除或标记而不是死磕覆盖率。第三类是环境相关代码比如只在特定硬件配置下执行的初始化分支、只有断电重启才会进入的错误恢复路径常规测试环境根本造不出触发条件。这三类未覆盖代码的处理方式完全不同。防御性代码可以通过在单元测试里显式传入异常参数来覆盖死代码应该走代码评审流程确认是否可以删除环境相关代码则需要评估是否值得为它搭专门的测试环境还是接受其未覆盖状态并记录在案。覆盖率分析的真正价值恰恰是逼着你把每一块红色代码都问一个“为什么不覆盖”而不是简单地以命中率论英雄。4.3 关于“100%覆盖率”的理性认知接着说一个容易被误解的事覆盖率 100% 不等于软件没有缺陷。覆盖率衡量的只是“测试活动是否充分”它不承诺“功能是否正确”。一段代码被完整执行过只能说明它在某种输入下没有崩溃并不代表它的输出就是需求期望的结果——输出正确性要靠断言和预期值来验证。所以我在评审会上通常会这样表述覆盖率是必要不充分条件。它更像测试的“边界线”告诉我们哪些地方还没有被测试触及而覆盖率之外的测试设计质量、需求覆盖完整度、断言有效性这些维度同样决定最终交付质量。5. 实战避坑做 Testbed 覆盖率分析这几关必须过5.1 插桩后编译失败的常见原因先说编译这一关。插桩本身只是给源码加探针它并不改变语法规则但插桩后的代码体积变大、数据结构增多最容易在三类环节出错。第一类是交叉编译器的路径和参数配置不对。嵌入式项目的编译环境千差万别GCC 版本、平台头文件目录、链接脚本稍微有差异插桩代码就可能报出一堆莫名其妙的错误。我的习惯是先在工程里单独建立一个“编译探针”的最小模块只插桩一个函数编译过了再整批插桩这样能把环境问题隔离在最小范围里。第二类是插桩数据存储空间不足。Testbed 需要用一块内存区域保存探针采集到的执行计数在资源紧张的单片机上如果链接脚本没有为探针数据段预留空间链接阶段会直接失败或者在运行阶段内存越界。解决的办法一般是在链接脚本里为覆盖率数据单独划分区域并把它放到不冲突的 RAM 段。第三类是编译器优化级别与插桩的兼容问题。高优化级别比如 -O2、-O3会把代码重新排布、内联、删除无用分支这会导致插桩探针的位置和源码行号的对应关系变得不稳定报告里的覆盖位置对不上源码。我在需要精确覆盖率分析的模块上会让测试版本使用 -O0 编译虽然性能差一点但源码与探针的对应关系最可靠正式版本按正常优化级别单独构建两者互不影响。5.2 覆盖率数据归并增量测试必须掌握的技巧覆盖率数据归并是很多人会忽略、但实际项目里绕不开的一个环节。覆盖率测试通常不是一次跑完的你今天写了十个用例跑了一批数据明天又补了五个用例跑了另一批数据两天合在一起模块的完整覆盖率应该是“并集”而不是“最后一次的结果”。Testbed 支持把多次运行的数据文件汇总合并这个操作叫 Merge 或者 Coverage Merge。我踩过的坑是在不同时间点跑了同一模块的覆盖率但源码版本已经变了新旧数据直接合并会得到一份错乱且毫无意义的报告因为探针位置和行号都已经对不上。正确的做法是固定被测模块的代码版本只把不同测试用例批次的数据文件做合并一旦源码有改动必须先清理掉旧的覆盖率数据文件重新从基线状态开始累积。另外即使在同一个代码版本上合并数据我也建议把每次合并的文件按日期和测试批次命名这样一旦数据异常能迅速追查到具体是哪个批次的用例导致覆盖率异常波动。5.3 补覆盖率的三板斧桩函数、数据驱动、需求锚点最后一个部分聊怎么补覆盖率。很多团队发现覆盖率不达标时的第一反应是疯狂加测试用例。但有些代码比如依赖外部硬件状态的函数单靠常规测试用例根本达不到覆盖率目标这时候需要用更聪明的办法。第一种手段是桩函数Stub。当被测函数依赖外部接口传感器读取函数、CAN 收发函数、Flash 驱动等时用桩函数模拟这些外部行为强制被测函数走遍各个分支。比如有一个温度校准函数正常运行时温度值总是 25℃ 左右你要覆盖高温、低温、超范围的分支就得靠桩函数注入不同的温度读数而不是真的去把整个设备放进冰箱和烤箱。第二种手段是数据驱动测试。把测试参数从测试代码里抽离出来放到一个数据表格里Testbed 的 TBrun 支持通过参数组合自动生成一批测试用例。我们把边界值、等价类、异常值这些经典的测试设计思路整理成参数表一次性批量运行通常几个典型模块的覆盖率就能明显改观。第三种手段是需求追溯锚点。覆盖率分析不只是跟代码相关还应该跟需求关联起来。用 TBreq 把每一个测试用例对应到需求条目上评审时除了看覆盖率数字还能直接回答“这条需求有没有对应的测试证明”。把需求、用例、覆盖率三者锚定成一个闭环之后覆盖率数据的说服力会强很多。我在实际项目里的体会是Testbed 覆盖率分析这套东西难度从来不在于工具本身怎么点而在于能不能踏踏实实地把源码解析、插桩配置、环境验证、数据归并、报告解读这一整条链路都跑通并且把每个环节的边界条件搞清楚。刚上手的时候别急着追求 100% 的数字先让覆盖率报告如实地反映现状——哪怕它显示只有 50%只要它真实你的测试改进就有了精确的起点。而一旦你习惯了每次测试改动后都回来看一眼覆盖率对比覆盖率分析就会慢慢从“评审前的应付差事”变成你在项目里做风险判断时最依赖的参照物。
返回列表