1. 为什么"测试先行"在嵌入式里总是绕不过去
做嵌入式软件开发的人,几乎都听过"Test-First"(测试先行)这个说法。但在真实的MCU项目里,它往往停留在一句口号上。我入行前几年,对TDD的态度就是"理论上很美好,实际上没时间",直到连续几个项目被回归bug折腾到加班到凌晨,才下决心认真审视这套方法论。今天想聊的,就是Test-First这套思路在嵌入式软件领域到底走到哪一步了,以及未来它会怎么演化。
先说清楚一个事实:嵌入式软件和桌面软件开发有本质差异。桌面应用可以随时启动、调试、观察输出,而嵌入式软件跑在资源受限的芯片上,依赖外部传感器、执行器、通信总线,很多逻辑错误要等到硬件联调阶段才会暴露。如果这时候才发现架构设计有问题,返工成本高得吓人。Test-First的核心价值就在于,它把"验证"这件事从联调阶段前移到编码阶段,让错误在最早的时刻暴露。
但问题来了:嵌入式软件怎么做到"先写测试再写实现"?代码要跑起来得有硬件,而硬件往往在软件启动时才刚打样回来,这是一个长期存在的矛盾。过去十年,行业主流的解法是"硬件在环"(HIL)或者"模拟器"(Simulator),这两者都解决了部分问题,但都没能真正让TDD在嵌入式领域流行起来。直到过去几年,工具链、抽象层设计、云仿真平台逐渐成熟,情况才开始发生变化。
这篇文章我打算从自己做过的几个真实项目出发,讲讲Test-First Embedded Software目前的落地状态、核心障碍、以及我判断的未来几个关键演进方向。不管你是刚接触嵌入式的新人,还是已经被硬件依赖折磨多年的老手,这篇文章都会给你一些实际可用的参考思路。
2. 传统嵌入式开发里,测试为什么被当成"事后补救"
很多团队嘴上说"测试很重要",实际流程却是:需求评审 -> 硬件设计 -> 驱动开发 -> 应用开发 -> 联调 -> 修bug -> 交付 -> 维护期间继续修bug。测试被压缩在联调之后,本质上是"验证"而不是"设计辅助"。这种模式有一个很难察觉的隐患:它会逐渐侵蚀代码架构。
2.1 没有测试约束的代码,长成了什么样
我接手过一个电机控制项目,前任工程师写了一个1200行的.c文件,里面全局变量、状态机、中断回调、PID调节逻辑全搅在一起。单看每一行都能看懂,但整体来看,没人敢动它——改一个参数,可能要重新仿真一整轮。这正是"先写代码、后补测试"模式的典型产物。代码没有清晰的模块边界,没有依赖注入的接口设计,自然也就无法测试。
TDD不是一种"附加动作",它是一种设计约束。当你必须先写测试时,你会天然地要求代码可被实例化、可被注入依赖、可被观测输出状态。这种约束会反向推动你写出更利于测试的架构。比如你会把传感器读取逻辑从业务逻辑里剥离,把硬件寄存器操作隐藏到驱动层后面,把算法逻辑抽成纯函数。这些设计原则,做嵌入式的人其实都明白,但如果没有测试先行这个"外力",很少有人能持续贯彻。
2.2 硬件依赖是TDD的第一大障碍
嵌入式软件测试一个最大的难题:代码一跑就涉及寄存器操作,没有硬件什么都验证不了。早期我尝试过用硬件开发板做单元测试——把测试用例烧录到板子上,通过串口输出结果。问题是,这太慢了。一次烧录、运行、收集结果的过程,需要几分钟甚至十几分钟。而TDD要求的节奏是"每次修改代码后几十秒内得到反馈",两者完全不匹配。
更麻烦的是持续集成。桌面软件开发已经有很成熟的CI/CD流水线,代码提交后自动跑测试、发报告。嵌入式想这样做,要么得维护一大排硬件开发板,要么得在虚拟机上跑模拟器,每次构建还要交叉编译到目标平台。这种基础设施成本,很多小团队根本扛不住。
2.3 来自团队的隐性阻力
我见过不少团队尝试引入TDD,失败原因往往不是技术,而是"运动式推行"。管理层要求大家写测试,开发人员不知道该测什么、测到什么程度,于是出现了大量低价值测试:为了凑覆盖率而测、为了交差而测。这些测试没能帮助重构,反而成了维护负担。测试代码也是代码,它需要持续维护、更新。如果它没有在第一时间给开发者带来"安全感",很快就会被团队抛弃。
我个人的体会是:TDD能不能在嵌入式团队落地,首先取决于能不能找到一条足够轻量的"反馈回路"。如果写一个测试要等两分钟才有结果,人就会下意识不想写测试;如果能做到"修改代码 -> 自动编译 -> 几秒内出结果",写测试就会变成一种自然而然的行为。
3. 让"测试先行"真正跑起来的三个关键基础设施
从"知道"到"做到",中间隔着一条工具链的鸿沟。我踩过坑、走过弯路之后,发现要让Test-First在嵌入式开发里真正生效,至少需要三样基础设施:可移植的硬件抽象层、宿主机构建环境、以及模拟器或仿真器支撑。
3.1 硬件抽象层:测试先行的架构地基
没有硬件抽象层(HAL),一切免谈。这里说的HAL不是芯片厂商提供的那个HAL库,而是你自己代码里用来隔离硬件细节的接口层。比如你要读取温度传感器温度,业务逻辑不应该直接调用i2c_read_reg(addr, reg),而应该调用temperature_sensor_get_value()。前者被I2C总线的时序、寄存器地址、转换公式绑定,后者只是一个纯粹的接口。
设计好这个抽象层之后,测试时就有一个"桩件"(Stub)或者模拟器(Mock)来接替真实硬件。举个具体例子:一个常见的需要测试的逻辑——"当温度超阈值时,关断加热器"。如果代码直接操作I2C和GPIO,在宿主机上根本无法运行。但有了抽象层,你可以注入一个假的温度传感器,返回150度,然后检查加热器的状态有没有变成"关闭"。整个测试过程不涉及任何物理硬件,几毫秒就能完成。
这个抽象层不能等测试时再补。在写第一行业务代码之前,先定义好边界接口,这是TDD的"设计先行"本质。它不是测试的副产品,而是支撑测试的地基。
3.2 宿主机构建:让嵌入式代码在PC上跑起来
第二个基础设施是构建系统。传统的嵌入式交叉编译链都指向目标硬件,工具链、头文件、链接脚本都是面向MCU的。要跑单元测试,必须让代码也能在PC(宿主机)上编译运行。
我目前用的方案是这样的:项目核心代码分成"平台无关层"和"平台相关层"。平台无关层包括算法、状态机、业务逻辑、控制策略,它们只用标准C(或者C++),不包含任何寄存器操作。平台相关层是具体的驱动实现,比如stm32_i2c.c、nrf52_ble.c。构建系统里配置两套目标:一套是正常的固件构建(交叉编译器),另一套是宿主机原生构建(gcc/g++),后者链接单元测试框架,运行测试。两套目标都是用CMake管理,通过工具链文件切换。
这一步做好之后,你会得到一个非常舒服的开发循环:
- 写一个失败的测试(描述期望行为)
- 在宿主机构建并运行测试,确认它失败
- 写最小实现代码,让测试通过
- 重构代码,每次修改后都跑测试确认没有破坏已有功能
- 定期做固件编译验证,确保交叉编译依然通过
这个循环里,每一步的反馈时间都在几十秒以内。当测试运行速度足够快时,TDD的"红-绿-重构"节奏才能真正建立起来。
3.3 模拟器与远程硬件:最后一道防线
宿主机构建能覆盖大部分逻辑层测试,但有些代码确实无法完全脱离硬件,比如某些极端时序相关的代码、中断优先级导致的竞态条件、或者特定芯片勘误表才有的行为。这类问题的验证,传统上靠的是把代码烧到真板上。但真板测试的反馈太慢,不适合作为TDD的主循环。
所以对这几年的趋势是:模拟器(QEMU、Renode)越来越多地参与到嵌入式测试流程。Renode可以模拟Cortex-M系列的MCU,支持外设仿真和脚本化控制。它的启动时间是毫秒级,可以跑在CI服务器上。我在一个基于Cortex-M4的项目中,用Renode做外设交互的回归测试,虽然没有JTAG调试那么精细,但验证"串口数据是否正确发出""GPIO状态是否按预期翻转"完全够用。
此外,远程硬件实验室(比如有些云平台提供的开发板远程租用服务)也是一个补充手段。把测试固件烧到云端开发板,用脚本自动执行并回收日志。它比本地真板更方便,但依然比宿主机单体测试慢两个数量级。所以我的策略是分层:大部分测试跑宿主机构建,少量关键集成测试跑模拟器,极少数硬件特性测试跑远程真板。
4. 嵌入式Test-First的实战:从零写一个状态机
理论铺垫差不多了,直接上实战。我以嵌入式开发中最常见的"状态机"为例,走一遍完整的Test-First开发流程。这个例子的目的是让大家看清,测试先行的每个步骤具体是什么样。
4.1 需求定义:一个按键去抖状态机
需求很简单:一个按钮被按下时,系统需要检测到"稳定按下"状态,并触发一次事件。由于物理按键存在抖动,不能直接把GPIO电平变化当作有效操作。过去很多人用延时函数做去抖,比如检测到变化后延迟20ms再读一次。但延时函数在嵌入式里是一个很讨厌的东西——它会阻塞CPU,导致其他任务无法执行。
所以我打算实现一个基于状态机的去抖逻辑,它不阻塞CPU,而是每次被周期性调用时根据当前电平变化更新状态。状态定义:
IDLE:空闲状态,未检测到按下DEBOUNCE_PRESS:检测到按下,但还在确认抖动期PRESSED:确认按下,触发一次"按下事件"DEBOUNCE_RELEASE:检测到释放,确认释放抖动期
调用方式是系统每1ms调用一次button_debounce_tick(level),输入当前GPIO电平。返回一个事件标志,可能是NO_EVENT或PRESS_EVENT。
4.2 第一个测试:从IDLE检测到稳定按下
按照TDD的节奏,第一步是写一个会失败的测试。测试内容:初始状态为IDLE,连续输入5次高电平(中间没有变化),断言应该在第5次调用时返回PRESS_EVENT。
void test_button_debounce_press_event(void) { button_debounce_init(); for (int i = 0; i < 4; i++) { TEST_ASSERT_EQUAL(NO_EVENT, button_debounce_tick(1)); } TEST_ASSERT_EQUAL(PRESS_EVENT, button_debounce_tick(1)); }此时button_debounce_tick还不存在,编译失败。这也是TDD的一部分——先确认"API设计是否合理"。编译失败在告诉你:接口还没定义,你先想清楚调用方式。其实这个阶段花点时间设计接口非常值得,接口设计清晰了,后面的实现基本上就是填肉。
4.3 最小实现:让第一个测试通过
为了让测试通过,我写一个最简实现:
static uint8_t press_count = 0; void button_debounce_init(void) { press_count = 0; } uint8_t button_debounce_tick(uint8_t level) { if (level == 1) { press_count++; if (press_count >= 5) { press_count = 0; // reset for next press return PRESS_EVENT; } } else { press_count = 0; } return NO_EVENT; }这个实现只做了最核心的"连续高电平5次产生按下事件",没有考虑任何状态机的优雅性。但它让测试通过了。TDD里有一个原则:用最少的代码让当前所有测试通过,不要提前做多余的抽象。等到下一个测试暴露新的需求时,再重构设计。
4.4 添加新测试:抖动过程中的电平翻转
现在需要测抖动场景。真实按键在按下瞬间,电平会经历多次快速翻转,比如高-低-高-低-高,这时不应该触发事件。只有连续5次读到高电平,才判定为有效按下。
void test_button_debounce_jitter_ignored(void) { button_debounce_init(); // 模拟抖动:高、低、高、低、高、低 button_debounce_tick(1); button_debounce_tick(0); button_debounce_tick(1); button_debounce_tick(0); button_debounce_tick(1); button_debounce_tick(0); // 连续5次高电平 for (int i = 0; i < 4; i++) { TEST_ASSERT_EQUAL(NO_EVENT, button_debounce_tick(1)); } TEST_ASSERT_EQUAL(PRESS_EVENT, button_debounce_tick(1)); }跑这个测试,当前的实现会失败。因为在第一次输入高电平时press_count变成了1,然后输入低电平清0,最后一次高电平时press_count又是1,最终连续5次后计数器没有达到5次。不对,实际上最后一次循环从0开始累加,4次后为4,第5次才为5,应该还是能通过。好的,当前实现确实能通过这个测试。但这暴露了一个设计问题:当前实现没有区分"抖动期"和"稳定期",它只是单纯计数。
4.5 扩展测试:释放操作与重复触发
接下来测试释放事件。需求定义是:按钮稳定释放后,才能进行下一次按下检测。也就是说,如果你按住按钮不放,不应该反复触发PRESS_EVENT。于是写测试:
void test_button_debounce_no_repeat_while_held(void) { button_debounce_init(); for (int i = 0; i < 5; i++) { button_debounce_tick(1); } TEST_ASSERT_EQUAL(PRESS_EVENT, button_debounce_tick(1)); // 按钮持续按住 for (int i = 0; i < 10; i++) { TEST_ASSERT_EQUAL(NO_EVENT, button_debounce_tick(1)); } }当前实现跑这个测试会失败:因为press_count在返回PRESS_EVENT后被清零了,再输入高电平又会重新累加,10次输入会产生一个额外的PRESS_EVENT。这就促使我引入真正的状态机结构——增加一个状态变量,使其在PRESSED状态下不再响应高电平输入。
4.6 重构:引入状态变量
重构后的实现:
typedef enum { STATE_IDLE, STATE_DEBOUNCE_PRESS, STATE_PRESSED, STATE_DEBOUNCE_RELEASE } button_state_t; static button_state_t state = STATE_IDLE; static uint8_t sample_count = 0; #define DEBOUNCE_THRESHOLD 5 void button_debounce_init(void) { state = STATE_IDLE; sample_count = 0; } uint8_t button_debounce_tick(uint8_t level) { uint8_t event = NO_EVENT; switch (state) { case STATE_IDLE: if (level == 1) { sample_count = 1; state = STATE_DEBOUNCE_PRESS; } break; case STATE_DEBOUNCE_PRESS: if (level == 1) { sample_count++; if (sample_count >= DEBOUNCE_THRESHOLD) { sample_count = 0; state = STATE_PRESSED; event = PRESS_EVENT; } } else { state = STATE_IDLE; } break; case STATE_PRESSED: if (level == 0) { sample_count = 1; state = STATE_DEBOUNCE_RELEASE; } break; case STATE_DEBOUNCE_RELEASE: if (level == 0) { sample_count++; if (sample_count >= DEBOUNCE_THRESHOLD) { sample_count = 0; state = STATE_IDLE; } } else { state = STATE_PRESSED; } break; } return event; }重构后重新运行之前所有测试,全部通过。这个过程中,TDD的价值体现得很明显:每次重构都是"有测试兜底"的,你不用小心翼翼担心改坏某个隐藏逻辑。整个开发过程没有打开过一次硬件调试器,没有烧录过任何固件。状态机的核心逻辑是在PC上通过单测验证的。硬件联调时,我只需要集中精力检查GPIO初始化和中断调用频率是否符合预期。
4.7 边界条件:release状态和再次按压
你以为测试到这就完了?还早。边界条件往往藏在"按下和释放的边缘"。我再加两个测试:一个是"按下后,还没稳定就释放,应该回到IDLE,不触发事件";另一个是"释放过程中出现抖动,不应该破坏状态"。
void test_button_debounce_interrupted_press_returns_to_idle(void) { button_debounce_init(); // 2次高,3次低(模拟按下后立刻松开) button_debounce_tick(1); button_debounce_tick(1); button_debounce_tick(0); button_debounce_tick(0); button_debounce_tick(0); for (int i = 0; i < 5; i++) { TEST_ASSERT_EQUAL(NO_EVENT, button_debounce_tick(1)); } TEST_ASSERT_EQUAL(PRESS_EVENT, button_debounce_tick(1)); }这个测试验证的是:打断的按压不会污染下一次按压状态。跑一遍,状态机实现可以正常通过。这类边界测试在传统开发模式里很容易被忽略,但在TDD模式下,你会习惯性地把"异常流程"作为一等公民来测试。
5. 嵌入式Test-First的实践边界:哪些测试不值得写
测试先行不等于无脑追求覆盖率。我见过有同事把覆盖率从60%硬推到95%,结果测试代码量是业务代码的三倍,每次改需求都要改一大片测试,团队苦不堪言。我个人的体会是,嵌入式里有些代码不值得写测试,或者说,测试的价值密度太低。
5.1 不值得测的代码:粘合代码和寄存器位操作
纯粹的寄存器位操作,比如配置一个GPIO口的模式为复用推挽输出,这类代码不值得写单元测试。它不是逻辑,只是"配置"。测试它本质上是在测试芯片手册是不是写错了。这应该交给硬件验证阶段,或者靠代码审查保证。类似地,直接调用芯片厂商库的初始化序列也属于粘合代码,没有测试价值,但值得用代码审查和静态分析工具检查参数配置。
5.2 高价值测试区:控制逻辑、状态机、协议解析
真正值得测试的是这些部分:
- 状态机:嵌入式里到处都是状态机,通信协议、按键扫描、电源管理、充电流程。状态机的每个状态转移都值得测。
- 协议解析:Modbus、CAN、私有协议栈。收到一帧数据后如何解析、异常帧怎么处理、CRC校验失败怎么反馈,这些逻辑如果靠手工测试,很难穷举边界。
- 控制算法:PID、滤波、校准算法。这些是数值密集型逻辑,单元测试可以用来锁定算法行为,防止后续改动时参数被误调。
- 错误处理策略:超时、重试、异常恢复。这些分支在正常运行时几乎不触发,但在真实工况中一旦触发就是大问题。
判断标准很简单:如果一个函数包含条件分支、循环、赋值之间的逻辑关系,并且你能通过参数控制其行为,那它大概率值得测试。反之,如果一个函数只是把寄存器A的值搬到变量B,那就别浪费精力了。
5.3 测试先行的"成本预算"
写测试不是没有成本的。一个有效率的团队,测试代码和业务代码的比例大概在1:1到2:1之间。如果写测试的时间超过了实现时间的2倍,那你得考虑是不是过度设计了。TDD并不要求所有代码都严格遵循"先测试后实现"的节奏。对于探索性代码、原型验证代码、一行就能改完的映射表,直接写实现完全没有问题。TDD的精髓在于,对于复杂逻辑和有回归风险的核心模块,你要有测试兜底;对于简单逻辑,保持代码整洁即可。
我给团队的基线是这样的:
| 代码类型 | 是否需要Test-First | 原因 |
|---|---|---|
| 状态机 | 是 | 状态转移多,手工回归成本高 |
| 通信协议解析 | 是 | 帧格式多变,边界情况多 |
| 控制算法 | 是 | 数值精度变化可能引发问题 |
| 硬件寄存器配置 | 否 | 无逻辑,测试价值低 |
| 初始化序列 | 否 | 跟着厂商手册走,代码审查即可 |
| 中断处理函数 | 视情况 | ISR本身难测,但它的核心逻辑应抽成可测函数 |
6. 从单元测试到集成测试:如何保证"测了真东西"
很多人有个误解,觉得有单元测试就够了。但嵌入式软件的bug往往不是出在单元内部,而是出在模块交互之间——你单独测每个模块都对,连在一起就出错。所以Test-First不能只停留在单元层面,得向上延伸到集成测试。
6.1 链接期测试:编译时的模块桩件
模块之间通过头文件、全局变量、函数指针交互。要测两个模块的交互逻辑,在宿主机构建里,我会给不被关心的模块注入桩件。比如测试电源管理模块时,传感器读取模块不是真实实现,而是一个返回固定值的桩件。
这种做法的好处是测试隔离性极好,失败时能精确定位到是哪一层的逻辑出了问题。坏处是,如果桩件行为写错了,测试可能跑了但什么都没验证。所以我一般会在桩件里加上"调用次数校验"——不只检查返回值,还会检查模块A是否以预期参数调用了模块B。
6.2 模拟器上的集成测试
当单元测试和链接期测试都通过后,下一个层级是模拟器集成测试。我用的工具是Renode。它能模拟Cortex-M芯片,并且允许你用Python脚本控制模拟的外设——比如给GPIO引脚注入一个高电平脉冲,然后观察串口输出。
举一个实战案例。一个蓝牙门锁项目里,有一块逻辑是"手机下发消息 -> MCU解析 -> 电机驱动 -> 门锁状态回读"。每个模块都单独测过,但联调时发现,门锁到位信号偶尔会被噪声干扰导致误判。这个问题的根因是:消息解析完成到电机驱动之间,没有加入"状态确认"等待机制。在Renode里,我用脚本模拟了"电机转动 -> 中断信号延迟到达 -> 噪声干扰",复现了这个时序问题,然后在代码中加入重试机制,用模拟器验证修复有效。
这种测试的价值在于,它不需要真实硬件就能验证复杂的时序交互,而且可以在CI里自动化执行。每次提交代码后自动跑一遍,如果模拟器环境能复现真机时序,那回归风险就能压到非常低。
6.3 硬件在环与真机冒烟测试
模拟器再强,也无法完全等同于硅片行为。所以最后一层是硬件在环测试(HIL)或者真机冒烟测试。这部分的定位是"确认模拟器没骗你",而不是"发现所有bug"。我通常在正式发布前跑一轮真机冒烟,覆盖:启动、关键外设初始化、主要业务链路、掉电重启。这轮测试不需要100%覆盖,但必须保证核心链路不挂。
一个比较实用的小技巧:在固件里留一个测试专用的串口命令解析器,只在调试构建中启用。这样在真机上可以通过串口注入测试数据、读取内部状态变量,让"真机测试"不再是黑盒。这个设计在开发前期就把测试钩子留好了,后面做HIL测试会省很多力。
7. 未来三年,我觉得嵌入式Test-First会往这几个方向走
关于"未来",我不敢说自己能精准预测,但基于目前的工具链演化和行业趋势,有三个方向是我比较确信的。
7.1 云上和容器化的交叉编译与测试会变成标配
过去嵌入式构建依赖本地安装的一大堆工具链,版本不统一、环境配置复杂。现在Docker容器已经可以很好地解决这个问题——把交叉编译工具链、库依赖、测试框架都封装进容器里,开发者本地只需要一个Docker环境。CI流水线上跑测试,不再是配一个笨重的物理机,而是拉起几个轻量容器。
这个变化对Test-First的推动是决定性的。当构建和测试环境变得标准化,嵌入式项目也能享受到和Web开发一样的CI/CD体验。代码推上去,几分钟内自动跑完单元测试、模拟器集成测试,输出报告。团队不需要在本地维护复杂的测试环境,接入门槛大幅降低。
7.2 AI辅助的测试用例生成
AI生成测试代码这件事,目前在一些领域已经比较成熟了。在嵌入式领域,它完全可以帮开发者做一类事情:根据函数签名和行为描述,自动生成边界条件测试。比如你写了一个battery_get_level_percent()函数,AI能自动生成"满电、空电、电量跳变、异常电压、连续读值"这些场景的测试用例。
有人担心AI生成测试会让人更不思考。但从我的角度看,AI在这里扮演的是一个"勤快的助手"角色:它帮忙生成测试雏形,开发者只需要审查、修改、补充关键场景。这比面对一个空白测试文件要轻松得多。更实际的是,AI还能帮你在重构代码后自动更新测试中的桩件参数——这块本来是最烦琐的维护负担。
7.3 测试资产变成产品的一部分
过去"测试代码"是内部资产,客户和用户都看不到。但现在的趋势是,嵌入式软件的可靠性越来越被重视,行业标准(比如功能安全相关的认证标准)对测试的覆盖度和可追溯性都有明确要求。Test-First产出的测试用例、测试报告、覆盖率数据,本身就是合规审计的重要证据。
我判断,未来嵌入式测试资产会"产品化":测试用例集可以作为模块交付物的一部分,客户能看到"你的模块经过了哪些测试、覆盖了哪些风险"。这套体系在汽车电子、医疗设备领域已经在落地了。对于普通嵌入式开发者来说,现在养成Test-First的习惯,长远看也是在积累自己在功能安全、可靠性工程方面的能力资本。
8. 关于未来的个人思考
回忆起真正让我开始坚持Test-First的转折点,是一个量产设备的现场崩溃。问题根源是一个压测条件下才能复现的定时器溢出竞态,查了整整一周。从那之后,我给自己定了规矩:凡是涉及状态、计数、时序的逻辑,必须先用测试把行为固定下来,再写实现。这个习惯帮我省下了无数个"排查问题到怀疑人生"的夜晚。
我不认为所有嵌入式开发者都必须立刻全面转型TDD。每个项目的资源、周期、团队能力都不一样。但有一点是确定性的:可测试的代码架构,永远比不可测试的代码架构更健壮。即使你不写测试,仅仅是为了"让代码将来能测",你也会自然地把硬件依赖隔离、把接口设计得更清晰、把状态管理更集中。这些收益和是否跑测试用例无关,而是来自"以测试视角审视设计"这个过程。
如果你现在正准备启动一个新项目,我建议不妨从第一天就开始设计HAL接口,搭好宿主机构建环境,写第一个"几乎没营养"的单元测试。跨过最开始的那点别扭感,你会慢慢体会到,测试先行不是一种负担,而是一种让你敢改代码、敢重构、敢交付的底气。嵌入式软件的未来,必然是"可验证性"越来越重要的未来,而Test-First正是通往这个未来的那条路。