安全关键嵌入式单元测试:可重放、隔离打桩与分类树用例设计

发布时间:2026/11/3 22:42:10
安全关键嵌入式单元测试:可重放、隔离打桩与分类树用例设计

简介:PDF白皮书《Tessy单元测试提高软件质量》面向嵌入式软件测试工程师、开发人员与质量保证人员,讨论安全关键软件中单元测试的定位、标准要求与实践方法,可帮助读者对照IEC 61508、ISO 26262、Automotive SPICE和IEC 62304梳理测试策略。压缩包仅含1个PDF文件,约1.87MB,篇幅精炼,便于查阅。已有4876人学习下载。正文先说明单元测试的定义、适用时机、执行角色与自动化必要性,再以Tessy为例展示被测单元、接口、初始测试用例、执行、故障排查和回归测试流程;随后系统讲解分类树方法,从识别测试相关方面、逐层分类、重复分类到在组合表中指定测试用例,并附示例与工具支持说明。读者可借此掌握借助Tessy设计、执行和复盘单元测试的思路,理解代码覆盖率与测试文档要求,适合作为嵌入式单元测试入门与进阶参考。

1. 单元测试的真正价值:可重复重放

很多人第一次接触单元测试工具时,都默认它是一个用来查找缺陷的工具。但在安全关键嵌入式项目里,单元测试真正交付的成果是一份能被反复重放的证据:改了实现之后,昨天的用例今天还能原样跑一遍,并给出同样的结论。功能安全标准把“可重复、可回归、可追溯”写进测试要求,却从不把“发现了多少缺陷”写进去。这就解释了一个常见现象:手工执行出来的单元测试往往活不过第二次迭代,被测函数改一行,没人愿意把几十个用例重新喂一遍,测试也就没人愿意再跑了。TESSY 这类工具的价值,本质上就是把“重放”的成本压到接近零;分类树方法解决的是另一个问题:这些用例到底该从哪儿来。两件事凑在一起,才谈得上提高软件质量。

2. 隔离与桩:单元测试为什么必须把被调函数换掉

2.1 单元边界与接口:测试用例的结构从哪来

不同标准用词并不统一:通用功能安全标准称“模块测试”,汽车功能安全标准称“单元测试”,机载适航标准谈“低级需求测试”。但如果把定义落到 C 语言上,术语差异就消失了——一个单元等价于一个 C 函数,也就是那个能脱离其余代码被独立测试的最小对象。这个“最小”不是抽象口号,它直接决定了测试用例长什么样。

单元的接口由三样东西构成:函数读取的变量(输入)、函数写入的变量(输出)、以及返回值。三者可以重叠,比如某个计数变量在函数里被+= 1,它同时是输入也是输出。更广义地说,被测单元调用的那些函数也属于接口的一部分,因为它们决定了被测单元在真实环境中的行为。接口一旦确定,测试用例的结构就确定了:一个用例 = 一组输入变量的具体值 + 一组期望的输出变量值。

这也说明了为什么单元测试是黑盒的。被测单元内部用 if 还是查表、有没有提前 return,都不该影响用例设计——判断依据只有规格:给定这组输入,接口上应该呈现什么结果。这种“只认接口”的立场看着朴素,却是后面所有机制的地基:只有接口是稳定的,用例才可能被重放;只有用例可重放,回归才有意义。

2.2 原二进制与测试应用:两条执行路径的代价差在哪

同一个被测函数,可以有两种跑法,代价完全不同。

第一种是原二进制测试:把整个应用按最终形态编译、链接、分配好,放进调试器,在被测函数入口打断点,让程序跑到断点,然后喂入测试数据、单步或继续执行、读取结果,再回到断点执行下一个用例。它的好处很实在——被测单元运行在它将来真正的地址上,不需要为测试额外做一次编译和链接;甚至目标板上已经是固件的场景也能直接测,只要有个调试器,连编译器都不必装。

代价也很实在。第一,被测函数在正常业务流程里不一定被调用,或者调用它的时机不可控,你得靠翻开关、触发中断这类特殊手段把它逼出来。第二,没法打桩,被测函数调用谁就真的调用谁,于是别的单元改了一行,这个单元的回归就红了,看起来像是本单元退化,其实是下游行为变化串了进来。第三,输入数据的自由度受应用本身限制:如果被测函数通过指针接收数据,你能塞进去的数据量上限就是应用给这块缓冲区预留的大小。

第二种是测试应用:只从原工程里拿出被测函数,另外配一个main()、启动代码,以及被测函数所调用的那些函数的替身(桩),单独链接出一个专供测试的可执行程序。每个被测单元对应一份自己的测试应用。它对存储的处理有两种做法——把测试数据全部烧进测试应用里,简单,但用例数量一多,测试应用可能塞不进目标板的存储;或者把测试数据留在宿主机上,跑哪个用例就下发哪个,代价是通信开销略高,好处是用例再多也不会撑爆目标存储。

维度原二进制测试测试应用测试
被测函数运行位置最终产品的真实地址测试专用程序
额外编译链接不需要每个单元一份,需要
能否打桩基本不能可以,且推荐
输入数据自由度受应用预留缓冲区限制由测试程序自行分配
用例间相互干扰下游改动会造成假失败被隔离
目标机存储压力无额外压力数据烧录式方案压力大

一条实用的判断是:如果目标是一个已经烧在板子上、源码都不一定齐全的历史固件,原二进制是唯一选择;如果是正在开发的工程,测试应用方案几乎总是更划算,因为你买到的是隔离性和可复现性。

2.3 桩函数:可复现性的真正来源

桩不是“随便返回一个值”的空函数。它的职责是让被测单元在与下游实现解耦的前提下,仍然表现出正确的接口行为。一个写坏了的桩,会让整轮测试的结论都不可信——这也是实际使用单元测试工具时最常见的问题。

/* stub_sensor.c —— 传感器读取函数的桩 * 返回值通过测试夹具设定,同时记录调用次数,供用例断言 */ static int g_stub_value; /* 由用例在执行前设定 */ static int g_stub_call_cnt; /* 调用计数,用例结束后检查 */ void stub_reset(int fixed_value) { g_stub_value = fixed_value; /* 每个用例开始前重置,避免用例间串味 */ g_stub_call_cnt = 0; } int sensor_read(int channel) { (void)channel; /* 桩不关心参数,但保留签名与真实函数一致 */ g_stub_call_cnt++; return g_stub_value; } int stub_call_count(void) { return g_stub_call_cnt; }

这段代码里有三个容易被忽略的点。stub_reset在每个用例执行前重置状态,否则上一个用例的残留值会让结果出现漂移;桩的函数签名必须和真实函数完全一致,包括参数类型和返回类型,否则链接期替换会失败或者行为未定义;调用计数让“被测单元是否按预期次数调用了下游”变成一个可断言的输出,这在验证错误处理和重试逻辑时尤其有用。

桩的粒度是个取舍:桩太粗,比如让所有下游函数都返回同一个默认值,被测单元走的分支就单一,测不出东西;桩太细,为每个下游函数写一套可配置的行为模型,维护成本会超过收益。经验做法是只对影响被测单元控制流的下游函数做可配置,其余一律用最简单的不变返回值。

2.4 第一个用例:区间判断函数怎么翻车

原文给的例子很小,但把“接口决定用例”这句话演示得很清楚:判断一个整数是否落在由起点和长度确定的区间内,即start <= value < start + length,只考虑整数。

/* in_range.c —— 判断整数值是否落在 [start, start+len) 区间内 * 返回 1:在区间内;返回 0:不在区间内 */ int in_range(int start, int len, int value) { int end = start + len; /* 先算上界;注意 start + len 可能溢出 */ if (value < start) { return 0; /* 小于起点,直接判否 */ } if (value <= end) { /* 缺陷点:上界应为开区间,<= 把 end 本身也算进来了 */ return 1; } return 0; }

参数含义:start是区间起点,len是区间长度(按契约应为正整数),value是待判定值;返回值表示判定结果。按规格,start = 5、len = 2时合法集合是{5, 6}。

对应的用例执行结果是这样:

用例startlenvalue期望返回实际返回结论
TC152511通过
TC252611通过
TC352701失败
TC452400通过

作为工具侧的演示,TESSY 会从源码里自动识别出这个函数的接口——三个入参和返回值,外加它读写的全局变量——然后据此生成一份测试应用,把用例数据下发到目标板执行,再把输出变量的实际值回读上来做比对。识别接口这一步如果靠人工,一个中等规模的工程跑一遍就是几天的工作量,而且每改一次函数签名就要重来一次。这就是自动化的真实意义:它的价值在于让“改完代码重跑一遍”从一项需要排期的任务,变成一次点击。

3. 分类树方法:把想不全的用例变成可枚举的组合

3.1 三个层次:测试方面、分类、类

上面那个例子里,四个用例是怎么想出来的?如果答案是“凭经验”,那换个人、换个模块就不可复现了。分类树方法要解决的就是这个问题:给出一套可重复的推导步骤,让用例规格变成“枚举出来的”而不是“想到的”。

它的结构只有三层。最上面是测试方面,即与测试相关的关注点,比如“区间是否有效”“待判定值相对区间的位置”“数值边界是否溢出”。每个方面向下拆成若干分类,分类下面再列出具体的类——类就是取值的等价分组。如果一个类下面还有值得区分的子情况,就继续往下拆,于是整棵树是递归生长的。

以区间判断函数为例,树大致长这样:区间有效性下有len > 0、len == 0、len < 0三个类;值位置下有“小于 start”“等于 start”“区间内部”“等于 end”“大于 end”五个类;数值边界下有“普通值”和“INT_MAX 附近”两个类。这个结构的好处是每一步拆分都要求回答“为什么这几类要分开”——分开的标准是它们的处理逻辑不同,或者它们的期望结果不同。凡是拆不出差异的方面,就不该出现在树上,否则只会增加噪声。

3.2 组合表与组合爆炸:怎么从笛卡尔积里挑行

树建好之后,把每个分类作为一列、每个类作为该列的可选值,逐行填入组合,就得到组合表。全组合是笛卡尔积:3 × 5 × 2 = 30 行,真跑起来成本不低。所以关键动作不是“全填”,而是“挑行”。挑行有两条基本规则:每个类至少被覆盖一次,边界类和异常类必须出现;在此之上再考虑类与类之间是否存在真实的耦合关系,只对存在耦合的列做交叉。

组合区间有效性值位置数值边界期望
C1len > 0等于 start普通值在区间内
C2len > 0区间内部普通值在区间内
C3len > 0等于 end普通值不在区间内
C4len > 0大于 end普通值不在区间内
C5len == 0等于 start普通值不在区间内
C6len < 0区间内部普通值规格未定义,应报错
C7len > 0等于 endINT_MAX 附近不在区间内且不溢出

这份表的直接产物就是 C3 和 C7 两个用例。C3 恰好命中了上一节那个<=缺陷;C7 则暴露了另一个问题——start + len在接近整数上限时会溢出,end变成负数,判定结果完全失真。这类缺陷靠“随手写几个用例”基本碰不到,靠分类树里“数值边界”这个分类却几乎是必然产物。方法的价值就体现在这里:它把“测得出”从运气问题变成了流程问题。

3.3 覆盖率:结构指标的下限,不是质量的上限

用例跑完之后,工具通常会给出语句覆盖、分支覆盖、条件覆盖乃至更严格的修正条件判定覆盖等指标,安全等级越高,要求越严。这些数字有用,但很容易被误读。

覆盖率是结构指标:它衡量的是“你的用例走过了代码里多少条路径”。它看不见规格。如果开发者漏实现了一个需求,那段代码在源码里根本不存在,任何覆盖率工具都不会报警——覆盖率 100% 的函数照样可能是功能残缺的。这就是为什么分类树这类从规格侧推导用例的方法不能省:规格侧负责“该测什么”,覆盖率负责“还有哪些代码没被走到”,两者互补而不替代。

另一个误读是把覆盖率高当成测试充分的证据。语句覆盖 100% 只说明每行都执行过,不说明每行的每种结果都验证过;分支覆盖能补上后半句,但循环边界、数据组合的遗漏依然存在。在实际项目里,我的习惯是把覆盖率当作下限门槛——低于阈值说明用例明显不足,必须补;达到阈值绝不等于可以停下来,那只说明用例的数量够了,充分性还得回到规格和分类树上判断。

4. 谁写用例、谁执行:单元测试的组织方式

4.1 开发者测自己代码的盲区在哪

让写代码的人测自己的代码,问题不在态度,而在认知。如果开发者漏实现了规格里的某段逻辑,他在设计用例时也大概率想不到去测那段逻辑——因为在他的心智模型里那功能已经在了。这种盲区靠“更仔细一点”解决不了,它属于结构性缺陷。

第二个盲区在“意外输入”。开发者写代码时通常假设调用方守规矩,于是用例也围绕“正常输入”展开,对空指针、超出范围的枚举、长度为负这类情况天然不敏感。而这类输入恰恰是安全关键软件出问题的主要来源。

4.2 外部测试者接手有哪些成本

既然开发者不适合测自己的代码,换个人来测行不行?理论上更合理,实践中有一个很一般的成本:外部测试者要先花大量时间搞懂接口。他得弄清楚一个表示颜色的输入变量,其取值究竟是 RGB 三个分量还是枚举序号,还得判断某个结果变量的取值到底算对还是算错。这些知识原本存在于开发者的脑子里。

于是大部分测试时间花在“读懂接口”上,而不是“设计用例”上。这不能算浪费,但确实是一笔不小的开销,尤其是在模块数量多、接口风格不统一的项目里。

4.3 分工方式:规格侧写抽象用例,实现侧做具体映射

一种折中的做法是把测试活动切开。由没有参与编码的人基于规格设计用例,并且直接写出期望结果,用例里用抽象数据表达——比如“输入颜色为绿色”。这套抽象用例交给开发者,由他负责把抽象值映射成具体值(比如某个 RGB 三元组)并执行。如果某个用例的结果不符合期望,开发者当场就能进入调试和修改,因为他最清楚实现细节。

这个做法的巧妙之处在于,它把“该测什么”和“怎么喂进去”分给了各自最有信息优势的一方。代价也很清楚:它并不满足部分标准对开发与测试组织分离的要求;而且让开发者承担大量测试执行工作,在项目排期上往往很难被接受。所以这种形态在实践中并不常见,通常是团队里有明确的测试角色、且测试工具能把执行成本压得很低时才会采用。

无论人选怎么分,有一件事是所有标准都咬死不放的:测试必须可重复。被测对象或开发环境一改,之前的测试就得能重跑。手工执行做不到这一点,因为代价太高。所以至少要把执行环节自动化,交给工具去完成接口识别、测试数据管理、测试应用生成、执行、结果评估、覆盖率统计和测试文档生成。这六件事串起来,一次回归才可能真的变成“一次点击”。

5. 避坑与排查:单元测试落地时最容易翻车的五个地方

5.1 构建与执行类翻车

现象一:明明没动过被测函数,回归却红了。原因大多是没有打桩,被测单元真实调用了下游函数,下游的行为变化通过副作用传了进来;也可能是某个桩在两个用例之间没有复位,前一个用例的残留状态影响了后一个。解决方法是先确认测试应用里被调函数是否已被替换,再检查每个用例执行前是否都有一次夹具重置,把用例的执行顺序打乱跑一遍,如果结果随之变化,基本可以确定是用例间状态没有隔离。

现象二:用例数量一多,测试应用加载失败或者跑不起来。原因是把全部用例的测试数据都烧进了测试应用,目标板存储放不下。解决办法是把测试数据留在宿主机,执行哪个用例就下发哪个;通信开销会增加一些,但用例规模从此不再受目标存储限制。

现象三:通过指针传参的用例,数据只能塞进去一点点。这是原二进制方案的固有约束——缓冲区大小由应用本身预留,测试侧无法越界。如果被测函数需要大量输入数据,就得换到测试应用方案,由测试程序自己分配缓冲区。顺带说一句,选用原二进制方案前,最好先确认被测函数在正常业务流程里到底能不能被触发,否则你会为了让它跑起来去改产品代码,那真是自己给自己制造麻烦。

5.2 用例设计类翻车

现象四:覆盖率刷到 100%,交付后仍然出问题。原因很典型:覆盖率是结构指标,它不会告诉你规格里有一条需求根本没实现,也不会告诉你两个输入参数的组合从未被同时取到边界值。解决办法是双轨并行——用例来源以分类树为准,从规格侧保证“该测的都测了”;覆盖率只作为补齐下限的检查手段,低于阈值必须补用例,达到阈值则回头再看一遍分类树有没有漏掉的类。

现象五:把时序问题、多单元交互问题塞进单元测试。单元测试的边界就是单个函数在接口上的行为,它答不了“两个单元之间的时序是否满足”这类问题,硬塞进来只会得到一批结果不稳定、时好时坏的用例。正确做法是把这类验证上移到集成测试,单元测试只负责守住“这个函数在给定输入下输出对不对”这一条线。守住这条线,才是它提高软件质量的真实贡献。

6. 取舍:覆盖率阈值、桩的粒度与工具的边界

单元测试做到什么程度算够,没有一个放之四海皆准的数字。安全等级高的模块,覆盖率门槛定得高是合理的,因为它出错代价大;但把同样阈值套到全部模块上,只会催生一批为了凑数而写的用例。更实际的做法是按风险分层:先用圈复杂度、调用频次、功能安全等级这几个维度给单元排个优先级,复杂且被频繁调用的先测透,简单的 getter 之类的走个语句覆盖即可。这个排序动作花不了多少时间,却能决定有限测试资源投向哪里。

桩的粒度同样是个成本问题。我倾向于把桩分成两类:影响被测单元控制流的,做成可配置,能返回不同值、能模拟失败;不影响控制流的,一律用最简返回值。前者决定你能测出多少分支,后者只为了保证链接能过。把两者混为一谈,要么测不透,要么维护成本失控。

至于工具的边界,得说清楚:单元测试工具能自动识别接口、生成测试应用、管理用例、统计覆盖、导出文档,这些都是“重放成本”的组成部分,压得越低,回归就越可能真的被执行。但它不会替你判断规格是否完整,也不会告诉你某个分类该拆成几类。分类树那部分工作依然要靠人对规格的理解——工具负责执行,人负责定义“什么叫做对了”。

我刚接手安全关键模块那会儿,曾经因为嫌麻烦,跳过了给下游函数打桩这一步,直接拿原二进制跑了一轮,全部通过,还挺得意。两周后下游一个函数改了默认返回值,同一批用例集体标红,我花了半天才确认被测函数一行没动。从那以后我每次建测试工程,第一件事就是把桩和夹具复位写进去,宁可多写二十行,也不让回归结果里混进别人的变更。希望帮到你。

本文还有配套的精品资源,点击获取