1. 嵌入式软件测试到底在测什么
很多人第一次接触嵌入式测试,脑子里浮现的画面是拿个万用表戳来戳去,或者接上串口助手看一堆乱码。这个印象不算错,但只看到了冰山一角。嵌入式软件测试的核心,是在资源受限、硬件耦合、实时性要求高这三重约束下,验证软件行为是否符合预期。它跟纯应用层软件测试最大的区别在于:你测的不只是代码逻辑,还有代码跟真实物理世界打交道的那条通路。
我做了十多年嵌入式项目,从消费电子到工业控制都趟过。说句实在话,嵌入式测试最难的地方不是写测试用例,而是构建一个可控、可观测、可复现的测试环境。PC 上的单元测试,你 mock 掉一个函数就完事了;嵌入式里你想 mock 一个 SPI 从设备,得考虑时序、电平、时钟极性,稍有不慎测出来的结果全是假的。
这篇文章面向的是已经能看懂原理图、能烧录固件、但测试流程还比较零散的工程师。我会从板级接口测试讲起,一路讲到自动化回归体系的搭建,把每个环节的为什么这么做和具体怎么做都掰开说清楚。你不需要有很深的测试背景,但至少要能独立完成一次固件编译和烧录,这样读起来才不会有断层。
先给一个全局认知:嵌入式软件测试通常分四个层次,从下往上依次是硬件接口层、驱动层、业务逻辑层、系统集成层。层次越低,越依赖真实硬件;层次越高,越容易用仿真和自动化手段覆盖。很多团队的问题在于,把大量精力花在最上层的功能测试上,结果底层接口的偶发问题一直漏到现场,返修成本高得吓人。所以我的建议永远是:先把板级接口测试做扎实,再往上堆自动化。
2. 板级接口测试:一切的地基
2.1 为什么板级接口测试不能跳过
板级接口测试,指的是针对芯片外设接口的电气与协议层验证,包括 UART、SPI、I2C、CAN、USB、GPIO、ADC 等。这些接口是软件跟外部世界沟通的物理通道。通道本身不可靠,上层逻辑写得再漂亮也是空中楼阁。
我见过太多项目,驱动代码写完直接跑业务,结果现场出现“偶尔读不到传感器数据”的问题。排查半天发现是 I2C 总线上拉电阻选大了,在高温环境下上升沿变缓,导致通信失败。这种问题如果早期做了板级接口测试,用示波器抓一下波形就能发现,根本不会流到现场。
板级接口测试要验证的东西,我总结成三类:
- 电气特性:电平幅值、上升/下降时间、时钟频率、占空比是否在器件手册规定范围内。
- 协议时序:建立时间、保持时间、片选有效窗口、应答位位置是否符合协议规范。
- 边界条件:总线冲突、从设备无响应、数据长度极值、时钟拉伸等异常场景下的行为。
2.2 板级接口测试的实操方法
先说工具。最基础的配置是一台数字示波器(带宽至少是被测信号频率的 5 倍)、一个逻辑分析仪(抓协议解码特别方便)、一个可编程电源(模拟不同供电条件)。如果预算有限,逻辑分析仪优先买,因为它能直接解码 SPI/I2C/UART,排查协议问题效率极高。
具体操作上,我拿 I2C 举例,因为它是嵌入式里最容易出问题的总线之一。测试步骤如下:
- 静态检查:断电状态下用万用表测 SDA、SCL 对地电阻,确认没有短路;测上拉电阻阻值,常见是 4.7kΩ,但高速模式下可能要降到 2.2kΩ 甚至 1kΩ。
- 上电抓波形:用示波器双通道同时抓 SDA 和 SCL,触发条件设为 SCL 下降沿。观察起始条件(SCL 高时 SDA 由高变低)是否干净,有没有毛刺。
- 协议解码:把逻辑分析仪的探头接到 SDA、SCL、GND,设置 I2C 解码,地址位选 7 位模式。跑一次读写操作,看解码结果里的地址、寄存器、数据是否跟代码里写的一致。
- 异常注入:用镊子短接 SDA 到地,模拟总线被拉死,观察驱动是否有超时恢复机制。很多驱动在这里会死循环,必须重点测。
注意:抓 I2C 波形时,探头地线一定要尽量短,最好用弹簧地针。地线长了会引入振铃,你会看到一堆假毛刺,白白浪费时间排查。
SPI 的测试重点不太一样。SPI 是全双工,时钟频率高,最容易出问题的是时钟极性和相位(CPOL/CPHA)配置错误。测试时先确认模式,然后用逻辑分析仪解码,重点看 MISO 上的数据在哪个时钟沿被采样。如果数据整体偏移一位,基本就是 CPHA 配反了。
UART 相对简单,但要注意波特率误差。晶振精度不够时,累计误差超过 3% 就可能丢帧。测试方法是连续发送 0x55(二进制 01010101),用示波器测一个位的宽度,反推实际波特率,跟设定值对比。
2.3 板级测试的自动化改造
手工抓波形适合调试阶段,但回归测试必须自动化。我的做法是:用一块带可编程 IO 的辅助板(比如另一块 MCU 开发板)作为测试夹具,通过它来模拟从设备、注入异常、采集响应。
举个例子,测 I2C 从设备驱动时,我用一块 STM32 配置成 I2C 从机,地址设成跟真实传感器一样。测试脚本通过串口控制这块辅助板,让它返回预设的数据序列,或者故意拉低 SDA 模拟总线故障。主控板跑完一轮后,把日志通过串口传回 PC,PC 端脚本自动比对结果。
这套方案的好处是成本低、可控性强。一块几十块钱的开发板就能替代很多昂贵的专用测试设备。缺点是搭建初期要花点时间写辅助板的固件,但一次投入长期受益,后面所有回归测试都能复用。
3. 驱动层与业务逻辑层的测试策略
3.1 驱动层测试:在真实与仿真之间找平衡
驱动层是硬件和业务之间的翻译官。它的测试难点在于:完全脱离硬件测不了,完全依赖硬件又测不快。我的策略是分层处理。
对于寄存器操作类的驱动,比如配置 GPIO 方向、设置时钟分频,这类代码逻辑简单但容易写错位。测试方法是写一个寄存器回读校验:配置完寄存器后立刻读回来,跟预期值比对。这个可以在目标板上跑,速度很快,适合放进自动化回归。
对于数据流类的驱动,比如 DMA 搬运、环形缓冲区管理,这类代码逻辑复杂,适合用单元测试框架在 PC 上跑。具体做法是把硬件相关的部分抽象成接口,PC 上提供 mock 实现。比如读 ADC 的函数,在 PC 上就返回一个预设的数组。这样可以用 gcov 统计覆盖率,用断言验证边界条件。
这里有个坑要提醒:PC 上跑通的代码,不代表目标板上没问题。因为编译器优化、内存对齐、字节序这些因素在两边可能不一样。我的经验是,PC 单元测试覆盖逻辑分支,目标板测试覆盖集成行为,两者缺一不可。
3.2 业务逻辑层测试:用状态机思维设计用例
业务逻辑层通常是嵌入式软件里代码量最大的部分。测试它的核心思路是把业务行为抽象成状态机,然后针对状态迁移设计用例。
比如一个温控器的业务逻辑:空闲态、加热态、保温态、故障态。每个状态有进入条件、退出条件、执行动作。测试用例要覆盖:
- 正常迁移:空闲→加热→保温→空闲
- 异常迁移:加热态下传感器断线→故障态
- 边界条件:温度刚好等于阈值时的行为
- 并发事件:加热过程中用户按下关机键
设计用例时,我习惯用判定表来梳理条件组合。把输入条件(温度高低、按键状态、传感器有效性)作为列,输出动作作为行,逐条填充。这样不容易漏掉组合情况。
实操心得:业务逻辑测试最容易漏的是时序相关的 bug。比如两个事件几乎同时发生,代码里的处理顺序可能导致状态错乱。测试时要有意识地构造“事件间隔极短”的场景,用定时器精确控制触发时刻。
3.3 仿真测试环境的搭建
不是所有测试都能在真实硬件上做,尤其是涉及破坏性场景(比如过压、过流、通信中断)时,仿真环境更安全也更高效。
我常用的仿真方案有两种:
第一种是 QEMU 类全系统仿真。适合跑 RTOS 上的任务调度、内存管理这类跟硬件耦合不深的代码。优点是快,可以跑几千个用例;缺点是无法验证真实外设行为。
第二种是硬件在环(HIL)仿真。用真实主控板,但外设信号由仿真板提供。比如测电机控制算法时,用仿真板产生编码器脉冲和电流采样信号,主控板以为自己在驱动真实电机。这种方案能覆盖大部分集成场景,是量产前回归测试的主力。
搭建 HIL 环境的关键是信号同步。仿真板输出的信号必须跟主控板的采样时刻对齐,否则测出来的数据没有意义。我的做法是用一个共同的触发信号,主控板在触发后固定延时开始采样,仿真板在触发后同样延时输出信号,两边用同一个晶振源,误差可以控制在微秒级。
4. 自动化回归体系的搭建
4.1 回归测试的触发机制
自动化回归的价值在于每次代码提交后快速发现回归问题。触发机制设计得好不好,直接决定这套体系能不能长期跑下去。
我的建议是分三级触发:
- 提交级:每次 git push 触发,只跑核心用例集,控制在 5 分钟内出结果。用例集要精选,覆盖最关键的接口和业务路径。
- 每日级:每天凌晨跑全量用例,包括耗时长的 HIL 测试和边界场景,结果发邮件给团队。
- 发布级:版本发布前手动触发,跑完整回归加压力测试,生成测试报告存档。
提交级回归最考验设计功力。用例太多跑得慢,开发人员会绕过;用例太少漏 bug,失去意义。我的经验是按代码变更范围动态选用例:如果这次提交只改了 UART 驱动,就只跑 UART 相关用例加核心业务用例。这需要在测试框架里维护代码模块和用例的映射关系,前期投入大,但后期收益极高。
4.2 测试框架选型与脚本编写
嵌入式自动化测试框架,我用过几种,各有适用场景:
| 框架 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| Robot Framework | 系统级测试 | 关键字驱动,易读易维护 | 需要自己写底层库 |
| pytest + 串口 | 驱动/业务测试 | Python 生态丰富,灵活 | 需要自己搭硬件抽象层 |
| Ceedling | 单元测试 | 跟 C 项目集成好 | 只适合 PC 端 |
| 自研框架 | 特殊需求 | 完全可控 | 维护成本高 |
我目前主力用pytest + pyserial。原因很简单:Python 写测试脚本快,pytest 的 fixture 机制特别适合管理硬件资源(比如一个 fixture 负责上电,一个负责烧录,一个负责串口连接)。下面是一个简化的示例结构:
import pytest import serial import time @pytest.fixture(scope="module") def dut(): # 上电 power_on() # 烧录固件 flash_firmware("build/firmware.bin") # 打开串口 ser = serial.Serial("COM3", 115200, timeout=1) time.sleep(2) # 等待启动 yield ser ser.close() power_off() def test_uart_echo(dut): dut.write(b"AT\r\n") resp = dut.readline() assert b"OK" in resp def test_adc_reading(dut): dut.write(b"READ_ADC\r\n") resp = dut.readline() value = int(resp.strip()) assert 0 <= value <= 4095这个结构的好处是资源管理清晰,每个测试函数只关心业务逻辑,不用管硬件初始化。fixture 的 scope 控制也很灵活,module 级别表示整个模块共用一个连接,function 级别表示每个用例重新初始化。
4.3 测试结果的分析与反馈
自动化跑完不是终点,结果分析才是。我见过很多团队搭了自动化,但没人看报告,最后沦为摆设。
我的做法是:测试结果必须自动分类。通过的分析,失败的自动抓取日志和波形截图,附在报告里。报告通过即时通讯工具推送到项目群,失败用例直接 @ 相关责任人。
对于偶发失败(flaky test),要单独标记。偶发失败的原因通常是时序竞争、硬件不稳定、测试环境干扰。我的处理原则是:偶发失败必须查清原因,不能简单重跑。因为现场出现的偶发问题,往往就是这类测试环境里暴露出来的。
还有一个细节:测试用例本身也要版本管理。用例跟代码一样,会随着需求变化而修改。每次修改要记录原因,避免出现“用例改了但不知道为啥改”的情况。
5. 常见问题与排查技巧实录
5.1 板级接口类问题速查
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| I2C 通信偶发失败 | 上拉电阻过大、总线电容过高 | 示波器看上升沿,测总线电容 |
| SPI 数据错位 | CPOL/CPHA 配置错误 | 逻辑分析仪解码,对比手册时序图 |
| UART 丢帧 | 波特率误差大、中断优先级低 | 测位宽反推波特率,检查中断嵌套 |
| CAN 总线错误帧 | 终端电阻缺失、波特率不匹配 | 测终端电阻,示波器看差分信号 |
| ADC 采样值跳动 | 参考电压不稳、采样时间不足 | 测参考电压纹波,增加采样保持时间 |
5.2 自动化回归类问题速查
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 用例在本地过、CI 上失败 | 环境差异、时序不同 | 统一硬件版本,增加等待时间 |
| 偶发失败率高 | 硬件不稳定、资源竞争 | 加日志,定位失败时刻的状态 |
| 回归跑太久 | 用例冗余、串行执行 | 并行化,按模块拆分用例集 |
| 烧录失败 | 复位电路问题、Boot 模式错误 | 检查复位引脚,确认 Boot 配置 |
5.3 几个踩过的坑
坑一:忽略电源时序。有一次测试一块新板子,所有接口都不通。查了半天发现是核心电压上电比 IO 电压慢,导致 IO 口在核心没起来时就被拉高,触发了闩锁。后来在测试流程里加了电源时序检查,用示波器四通道同时抓几路电源,确认上电顺序符合手册要求。
坑二:测试夹具本身有 bug。用辅助板模拟传感器时,辅助板的响应时间比真实传感器慢,导致主控超时。排查时一直怀疑主控代码,最后才发现是夹具的问题。教训是:夹具本身也要做验证,用真实器件对比测试结果。
坑三:自动化脚本硬编码太多。早期写的脚本里,串口号、文件路径、延时时间全是写死的。换一台电脑就跑不起来。后来改成配置文件加环境变量,可移植性好了很多。这个改造花了两天,但后面省下的时间远超投入。
坑四:忘记测异常恢复。正常路径测得很顺,但现场反馈设备偶尔死机。查代码发现是通信中断后没有重连机制。后来在回归用例里专门加了“拔掉通信线再插上”的场景,用继电器自动控制通断,才把这个漏洞补上。
6. 测试体系持续演进的几个方向
测试体系不是搭完就一劳永逸的。随着项目迭代,用例会越来越多,硬件会换版本,需求会变。我一般从三个方向持续优化。
第一是测试左移。把更多测试提前到开发阶段。比如在代码提交前跑静态检查,用 PC-lint 或 clang-tidy 扫一遍,把低级错误挡在编译阶段。再比如驱动代码写完先跑 PC 单元测试,通过了再上板。这样能大幅减少后期集成测试的压力。
第二是测试数据积累。每次现场返回的问题,都尽量在实验室复现,然后转化成回归用例。我维护了一个“现场问题用例库”,每次回归都跑一遍。这个库是用例集里最有价值的部分,因为它直接对应真实故障。
第三是测试环境容器化。把 PC 端的测试工具链打包成 Docker 镜像,新同事入职当天就能跑通全套测试。硬件相关的部分用 USB 转串口和网络控制,尽量标准化。这样团队规模扩大时,测试能力可以快速复制。
我个人在实际操作中的体会是:嵌入式测试没有银弹,板级接口测试是根基,自动化回归是放大器。根基不牢,自动化跑得再快也是白跑;没有自动化,板级测试做得再细也覆盖不了快速迭代的需求。两者结合,再加上持续积累的用例库,才能形成一个真正能挡住问题的测试体系。最后再分享一个小技巧:每次测试失败时,别急着改代码,先花五分钟确认测试环境本身没问题。我踩过的坑里,至少有三成是测试环境自己出了状况,白白浪费了排查时间。