罗峰 徐金鹏
(同济大学,上海 201804)
主题词:单元测试 时钟同步协议 开源单元测试框架 软件质量
随着微控制器技术的发展,软件开发在车载应用中的重要性逐渐提高[1]。基于控制器的功能和安全等级等要求,不同控制器的软件规模、开发难度和测试方法存在较大差异。传统嵌入式软件质量受开发人员水平制约,其可靠性难以验证。同时,受开发人员的流动性影响,软件在交接过程中可能引入更多漏洞,从而不能满足要求或有缺陷[2]。为了解决以上问题,汽车开放系统架构(AUTOmotive Open System Architecture,AUTO⁃SAR)[3]组织提出了开放的软件架构,汽车工业软件可靠性联会(the Motor Industry Software Reliability Associa⁃tion,MIRSA)和HIS(Hersteller Initiative Software)等组织提出的规范提供了更严格的代码检查规则。开发者可以采用商用软件进行代码生成、检查和测试,从而减少或避免开发过程中软件带来的问题。
商用软件既带来了开发上的优势,也带来了成本上的增加,因此难以适用于按键、开关等低成本控制器。由于整车厂的设计要求也可能存在变化,因此部分零部件制造商依然采用传统嵌入式软件开发方法以便于灵活修改软件。为了保证零部件质量,整车厂针对零部件进行测试并反馈存在的问题。由于制造商在软件修改时可能引入新的漏洞,因此增加了整车厂的测试成本。同时由于整车厂测试环境与制造商开发环境不同,开发人员可能难以复现存在的问题,从而带来软件维护的困难。
为了减少以上问题,本文引入开源单元测试框架Cpputest[4-5],并以时间同步协议代码为例提出针对车载控制器软件的开发方法。通过对软件模块引入特定的测试用例,可以有效规范软件模块接口、减少缺陷出现几率并提高多个开发者之间合作与交接的效率,从而催生出设计优良和结构紧凑的代码。尽管测试用例的编写增加了开发阶段工作量,该方法提高了软件质量,减少了后期维护难度,从而降低了软件实际开发成本。
嵌入式开发过程中底层与应用程序边界不清晰甚至混编导致软件和硬件设计过程需相互协调和反馈。此时软件功能与硬件紧密相连,因此缺乏继承性并需随着硬件升级重新开发,开发完成后还需整体联调才能确认功能是否满足要求[6]。如图1a所示,常规开发流程中,开发人员参考设计需求和接口开展代码编写工作,受硬件功能和数量的限制,代码中存在部分问题难以在调试和测试过程发现,从而造成交付风险。交付后的漏洞通常采用打补丁的方法修复,此时设计人员必须在有限的时间内修改代码并搭建测试环境进行完整测试,而人工测试存在回归性、效率、覆盖率、数据的重用性等问题[7],因此修改过程中又可能引入新的漏洞,造成后期维护难度与成本增加。
图1 开发流程对比
与常规开发流程相比,基于单元测试框架的代码编写过程中增加了测试用例的开发内容,如图1b所示,该开发流程中测试用例的制定总是执行在代码编写之前,代码更新和重构后均可通过测试,从而保障输出代码总是满足预定义的逻辑要求。测试用例的运行借助了编程语言的特性。嵌入式代码采用C语言编写,由于C语言是跨平台的,软件模块可以在主机上编译运行。通过特定参数输入,观察模块输出行为,可以测试软件行为是否达到期望要求。由于测试在主机平台上运行,开发前期不需要硬件介入,因此可以有效减少与其他开发人员的资源矛盾[8]。
Cpputest是基于C++的测试框架,可以运行在Visual Studio、eclipse和MinGW等环境中,被测模块作为C代码模块链接到C++工程。如图2所示,被测示例代码的目的是将add的值加到p指针所指向的数据中。其开发过程为:
a.为了使整个C++工程完成编译,将被测函数作为空函数加入工程中。
b.编写并执行测试用例1,由于空函数并不对指针p进行操作,因此通过测试。
c.编写测试用例2并执行测试,由于空函数中p指向的数据没有更新,测试失败,此时程序显示如图3所示,测试结果显示当前2号用例第33行判断出错。
d.添加赋值代码,再运行测试,测试用例2通过而测试用例1由于存在空指针访问失败。
e.被测代码增加空指针保护,并重新运行测试用例,通过测试。
f.继续添加测试用例和修改被测代码。
图2 单元测试示例
图3 空函数运行结果
被测代码在每一次修改后均可快速执行所有测试用例,因此开发者可以立即发现新的代码带来的漏洞并及时修改,从而避免了函数复杂度提高后调试带来的额外工作量。由于测试用例在主机上运行,因此测试具有以下优点:
a.速度快。如图3所示,两条测试用例的运行时间小于1 ms,测试过程由CPU执行,其速度远高于人工手动测试。
b.可重复性高。每一次代码修改后均可完整运行所有测试用例,不会出现人为失误导致的错误结果。
c.无须硬件支持。当实际硬件数量较少或者调试环境复杂时,单元测试可以有效减少实际调试时间,从而降低调试成本。
d.测试更全面。对于部分偶发性或者由于器件老化带来的故障(例如FLASH驱动写入数据失败),实物测试很难达到实际故障条件,而软件可以模拟任意故障。
尽管Cpputest提供了良好的开发框架,实际开发过程中依然存在例如文件依赖、接口定义、寄存器访问和中断处理等问题。这些问题可能导致代码处于不可测状态,从而使得基于单元测试的开发工作无法进行。本节以车载时钟同步协议软件模块为例,描述解决方法。
在主机上运行代码时,首要条件即是编译成功,这要求被测代码支持在嵌入式和主机端均能编译通过。尽管C语言具有一致性,代码文件的依赖性可能导致编译失败。如图4a所示,大部分驱动代码会引用交叉编译器提供的寄存器描述文件,而主机编译器并不提供该文件,从而导致文件缺失。
图4 文件依赖关系
单片机在制造过程中普遍将寄存器映射到部分内存地址,因此,对于软件代码而言,内存和寄存器并没有差别。如图4b所示,通过将描述硬件模块寄存器的结构体存入模块私有头文件中,再在模块初始化时传入寄存器基地址,可以有效减少对交叉编译器的依赖。同时,在单元测试过程中,测试代码可以引用该私有头文件,即可任意操作寄存器行为,从而模拟实际工作条件下难以出现的故障。
除编译器依赖外,软件模块常调用其他组件,因此,测试时需依据测试用例需求使用或替换被依赖的组件。如图5所示,被测时钟同步模块有多个依赖组件,而测试用例通过输入带有不同时间戳的报文,查看同步模块是否通过正确参数将本地时钟频率和相位的误差传递给钟修正模块。为了截取函数调用参数,采用仿冒的方法替换部分实际模块,从而可以在每条测试用例中检查实际时钟修正值。
图5 同时使用实际和仿冒模块
通过合理的依赖关系设计,被测代码可以尽可能独立于其他模块,从而增加代码可测性并减少自动化测试成本。这将花费设计人员更多时间,但可以使得依赖关系更加明确并增加代码可复用性。同时由于被测代码可以独立编译,因此更容易采用PC-Lint等工具执行静态检查。
在引入单元测试框架后,代码接口定义的合理性变得更加重要。如果被测模块所有函数均没有参数,则该模块可测性降低,而代码编写过程中部分函数名称、参数、返回值和功能定义发生变化,则需修改所有与该部分相关的测试用例,从而导致工作量增加。
被测时钟同步模块的接口定义如表1所示,其中,接口分别与主程序、底层报文收发驱动和时钟模块交互,从而达到校准本地时钟的目的。尽管全局变量也可用于模块间交互并进行单元测试,但实际使用中导致程序的状态不可预测,因此,时钟同步的接口均为函数或函数指针,防止模块变量被意外访问。
表1 时间同步模块函数接口
函数指针的另一个优势在于模块内部无需引用固定头文件。例如,不同硬件平台有不同的本地时钟模块,其头文件名称、函数定义均存在区别,如果直接引用则会出现文件依赖现象,导致移植时出现困难。函数指针可以有效减少依赖关系、降低耦合度并使接口与实现分开,因此更适合需要单元测试的代码模块。
完成接口设计后,首先制定测试用例。测试用例的具体内容与函数定义、模块功能需求有关,一般涉及接口、局部数据结构、边界条件、独立路径和错误处理路径等5类[9]。通过总结时间同步模块的测试用例,可以得到如表2所示的用例类型。其中,测试对象可以是单个函数或整个模块,通过正确或错误的调用检查模块行为是否满足预期。由于每一次代码改动均会运行所有用例,因此可以保障模块行为总是满足预期要求。
表2 测试用例类型
尽管测试用例可以有效约束模块行为,但应避免滥用。例如,测试过程中可以通过自定义函数返回模块内部变量进一步监视模块状态。然而代码迭代开发过程中,内部状态逻辑可能发生变化,因此该测试用例反而限制了代码的进一步开发。通常,模块对外接口不变,而模块使用者也只关心接口,因此,测试用例应当类似于黑盒测试,其测试对象为实际使用中涉及到的真实接口,从而避免测试对开发的约束。良好的测试用例易于发现程序的错误和缺陷,也易于实现代码测试的完全覆盖,因此其优劣对软件质量的保证起着关键作用[10]。
尽管单元测试为代码带来好处,其工作模式依然存在问题,例如平台差异和中断处理。前者可以通过更具通用性的编码方式解决,而后者则需要开发人员人工判断。
3.4.1 编译平台差异
单元测试运行在主机环境中,因此,其编译平台与嵌入式工作平台存在差别,例如支持的语言特性、基本数据类型大小、字节序、数据对齐和中断标志位处理等,因此可能导致测试通过的代码在实际运行中出现问题。然而,该问题也说明了代码通用性不足,模块在移植到其它平台时也可能遇到。因此,在初次实际调试中需注意平台相关问题并尽可能解决,例如:对于数据类型长度,可以采用固定长度类型;对于大小端模式,可以加入宏定义判断等。尽管代码工作效率和内存利用率可能降低,但修改后的代码更适于运行在多个平台上,从而提高代码可移植性,延长使用寿命。
3.4.2 无法覆盖的用例
尽管测试用例可以测试正常和故障情况下模块的行为,它并不能解决所有问题。最常见的不可测用例来源于中断或者嵌入式操作系统任务调度。以表1中的函数为例,底层收发模块调用P8021AS_rx_frame函数汇报收到的报文,而主函数周期调用P8021AS_tick函数处理收到的报文。两个函数均会对接收缓冲区进行操作,如果两者运行在不同优先级,则可能出现抢占行为,从而导致函数间公有的变量工作不正常,造成报文丢失等偶发故障。在模块设计过程中并不能预测实际使用情况,因此图5中引入队列管理模块,通过受到保护的先入先出队列保证两个函数之间不会出现抢占问题。除调度抢占外,对于同一函数,由于操作系统调度也可能出现函数重入现象,如果代码不可重入,则会导致工作异常。
Cpputest并不支持测试中断抢占,因此测试用例运行时并不会出现函数中断和重入问题,而实际运行过程中该问题出现的时机可能是随机的,从而进一步增加了调试难度。设计人员必须在设计阶段减少模块内全局变量,分析每一个全局变量是否存在抢占的风险,并对存在重入风险的代码进行保护以避免不可测问题带来的影响。
时钟同步模块完成后单元测试结果如图6所示,整个工程存在41条测试用例,而执行一次的时间为2.29 s。整个执行过程中有1 000 842次逻辑判断,因此手动测试难以覆盖所有的测试需求。
由于测试时间较短,因此每一次修改完成后均可执行所有测试用例。如图7所示,假设代码修改过程中开发人员偶然引入漏洞,将报文格式不正确时返回值AS_NOK改为了AS_OK,运行时可以立即发现该漏洞并在输出报告中指出失败测试用例以及实际判断代码地址。由于有2条测试用例均会检查该返回值,因此两者均测试失败。通过分析代码、单步调试等手段可以很快定位到错误点并及时修正。随着被测模块代码量的增长,开发人员可能在被测模块中加入代码后却没有在任何测试用例中执行。此时,所有测试依然可以通过但未执行代码可靠性存在风险。此时可借助OpenCppCoverage等第三方覆盖率检查工具查看被测模块在整个测试过程中没有执行的代码。如果存在此类代码,则可以通过删除代码或者增加测试用例的方式修改开发工程,从而保障单元测试的完整性。
图6 时间同步模块运行测试
图7 错误修改后立即报错
本文为车载嵌入式控制器软件开发引入开源单元测试框架Cpputest,从而使模块开发过程与代码单元测试相结合。通过合理的文件依赖关系和可测的代码接口,软件模块可以脱离实际硬件平台运行。在测试用例的帮助下,每一次代码改动均可完整验证其可靠性,从而避免漏洞引入。实际硬件调试过程中遇到的问题也可转化为测试用例,从而避免已经修改的漏洞反复出现并提高了代码质量。尽管开发过程中初期工作量更大,该方法强制开发者使用更合理的软件架构,减少了后期维护难度与成本。而测试用例亦可用于形成文档化的软件说明,从而减少模块的交接、移植等工作带来的影响,延长了软件寿命。