十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

嵌入式TDD实战:如何让固件代码变得可测试

嵌入式TDD实战:如何让固件代码变得可测试 接手过一个固件项目代码量不大但逻辑极绕一个通信协议解析模块十几层if嵌套改一版要对着示波器量一晚上信号。后来我用Test-Driven Development重构了这套代码才意识到一件事——嵌入式系统里推行TDD难点从来不在“会不会写测试”而在“怎么把代码设计成能被测试的样子”。这篇文章不聊空泛方法论。我会直接拆解我在嵌入式项目里落地TDD时真正用到的招式怎么解决硬件依赖、怎么搭测试工程、驱动模块的红绿重构流程长什么样、中断和时序这些“不可测”的东西怎么处理。1. 嵌入式TDD推行难难在“目标板思维”1.1 先搞清楚大家抗拒TDD的真实原因我在技术交流群里问过一个问题“你们为什么不在固件项目里写单元测试”答案高度集中代码要跑在开发板上PC上根本编译不过一个寄存器操作、一个中断回调全是硬件行为没法断言交叉编译链配起来麻烦跑一次测试要烧录、复位、看串口老项目代码耦合严重想测就得先重构重构又怕破坏功能这些理由单独看都有道理但合在一起暴露了一个核心问题大多数人把“测试”等同于“在目标板上做验证”于是TDD被理解成了一种极其昂贵的测试方式。实际上TDD的测试单元级测试99%应该跑在开发主机上用本地编译器完成。目标板只负责最后的集成验证和极少部分的硬件在环测试。这个认知转换是整个嵌入式TDD的基石。1.2 目标板验证与主机测试的分工边界我提倡的分工是验证层次运行环境验证内容运行频率单元测试开发主机PC算法、状态机、协议解析、控制逻辑每次代码修改后模块集成测试开发主机模块间接口、数据流、依赖关系每日CI硬件在环测试目标板寄存器、中断、时序、外设行为每日/每次提测前系统测试目标板整机业务场景版本发布前这里的关键点在于你想通过TDD获得的快速反馈绝大多数场景下和具体芯片型号无关。通信协议状态转移、PID控制算法、消息队列满/空逻辑、Flash磨损均衡策略……这些代码跑在PC上和跑在MCU上行为完全一致。真正依赖芯片的是寄存器读写、中断向量、DMA描述符这类极靠近硬件的代码。而这部分代码在合理架构下会被压缩到很薄的层次。1.3 为什么说“先烧板再验证”是低效路径我不否认目标板验证的价值但它不适合作为开发期的主要反馈环路。原因有三第一烧录-复位-观察的循环太慢。一次完整的Flash烧写加启动输出即使是方案里最快的也要几十秒而单元测试跑一个用例是毫秒级。开发人员对反馈速度极其敏感反馈越慢越倾向于“先写一大坨代码再一次性排错”这正是bug堆积的温床。第二目标板上的调试信息粒度不够。你可以用printf看日志但很难快速验证“这个状态机在非法输入下5步内必须回到初始状态”这类具体约束。断言不通过就是测试失败比人眼对日志可靠得多也快得多。第三硬件环境容易成为借口。当代码出问题时开发者的第一反应往往是“是不是时钟没配好”“是不是寄存器冲突了”而不是“我的状态转移逻辑写错了”。主机测试把硬件因素完全隔离后逻辑错误就无处遁形了。所以我的结论是嵌入式TDD推行难本质上是团队还停留在“目标板思维”——把最昂贵的环境用在最频繁的验证上。真正成熟的嵌入式开发流程应该让80%的逻辑测试跑在主机上目标板只做最后把关。2. 先把工程拆成“可测试的外壳”与“业务内核”2.1 没有架构就写测试等于把测试写死一开始我在一个老项目里尝试TDD时碰了一鼻子灰。那个项目所有模块都直接include芯片厂商的寄存器定义头文件函数里直接操作硬件寄存器。想把一个函数拉出来做单元测试编译时发现它牵一发动全身——依赖了定时器、串口、DMA、中断优先级甚至还有另一个业务模块的全局变量。后来我意识到一个道理TDD不只是一个测试动作它本质上是一种设计约束。你必须在架构上预留“可测试性”的位置。实践经验总结下来最重要的两条设计原则是硬件相关代码与业务逻辑代码物理分层模块间通过接口交互而不是直接操作全局硬件状态具体来说我习惯把每个驱动/业务模块拆成两层/* 硬件适配层直接操作寄存器薄且不包含业务逻辑 */ void uart_hw_send_byte(uint8_t byte) { while (!(UART-SR UART_SR_TXE)); UART-DR byte; } /* 业务逻辑层处理协议、状态、缓存不直接碰寄存器 */ typedef struct { uint8_t buffer[64]; uint8_t head; uint8_t tail; } uart_tx_queue_t; void uart_tx_enqueue(uart_tx_queue_t *q, uint8_t byte) { uint8_t next (q-head 1) % sizeof(q-buffer); if (next q-tail) { /* 队列满丢弃或置错误标志 */ return; } q-buffer[q-head] byte; q-head next; }看这个例子uart_tx_enqueue这个函数不碰任何寄存器输入是一个内存结构体输出是结构体内存的变化。你在PC上直接调用它传入一个普通的结构体变量就能断言队列满/空/正常入队等所有行为。这就是“可测试代码”的样子。2.2 依赖注入给硬件依赖“拔插头”但光分层还不够。很多代码的问题是即使业务逻辑层不直接操作寄存器它也会通过硬编码调用硬件适配层比如直接调用uart_hw_send_byte()。测试的时候这个函数仍然会去访问不存在的寄存器程序直接崩。解决方案是依赖注入。最实用的是函数指针或weak符号两种方式。函数指针方式更干净适合新代码typedef struct { void (*send_byte)(uint8_t byte); void (*start_timer)(uint32_t ms); } uart_io_t; uart_io_t uart_io; void uart_module_init(uart_io_t *io) { uart_io *io; } void handle_uart_byte(uint8_t byte) { /* 业务逻辑 */ uart_io.send_byte(byte); }在目标板程序里这样注入static void board_uart_send(uint8_t byte) { uart_hw_send_byte(byte); } void board_init(void) { uart_io_t io { .send_byte board_uart_send, .start_timer board_timer_start, }; uart_module_init(io); }在测试代码里注入一个桩函数static uint8_t sent_bytes[16]; static int sent_count 0; static void fake_send_byte(uint8_t byte) { sent_bytes[sent_count] byte; } void test_handle_uart_byte_enqueues_data(void) { uart_io_t io { .send_byte fake_send_byte }; uart_module_init(io); handle_uart_byte(0x55); TEST_ASSERT_EQUAL(1, sent_count); TEST_ASSERT_EQUAL(0x55, sent_bytes[0]); }这样原本直接操作UART寄存器的底层函数在测试环境中被替换成一个内存数组。整个测试跑下来进程不会崩溃断言也能精确到“发出去的那个字节”。weak符号方式更适合老代码改造硬件适配层函数用__attribute__((weak))定义测试工程里强符号重写一个同名函数链接器会自动选择强符号。优点是调用方代码不用改缺点是不够直观且容易藏着没被察觉的替换。我的建议是新代码用依赖注入老代码平滑过渡可以用weak符号但长期计划还是迁到依赖注入。2.3 哪些代码值得写单元测试哪些不值得写测试也需要投入产出比。我在项目里划定了一个明确的测试范围不值得测寄存器读写封装、时钟树配置、引脚复用配置、芯片初始化序列。这些代码就是“寄存器手册翻译”逻辑复杂度低出错源是手册参数而非算法写测试的收益很低。集成测试或硬件在环测试用示波器、逻辑分析仪验证更直接。必须测状态机、协议解析/组帧、校验和计算、环形缓冲区、PID控制器、滤波算法、错误处理分支、任务状态调度。可以测模块间接口适配层、配置解析。核心判断标准是这段代码是否包含“决策逻辑”如果有if、switch、for、算术运算它就有逻辑复杂度就值得测。如果只是“写这个寄存器为0x3F”那测试意义不大。3. 用Ceedling五分钟搭出第一套可跑的单元测试3.1 为什么选Ceedling这套工具链嵌入式C语言项目的单元测试框架我接触过几套框架语言特点适合场景UnityC极轻量断言宏完善编译部署简单C项目首选CMockC基于Unity的自动Mock生成工具需要大量模拟外设依赖时CeedlingCUnity CMock Ruby构建系统的整合一站式测试工程管理GoogleTestC功能强大断言丰富C嵌入式项目自制轻量框架C几十行宏适合极简单场景最小依赖项目我最后选择的是 Ceedling理由很实际它把Unity和CMock打包好还提供了Ruby写的构建脚本不需要自己手写Makefile管理测试用例自动生成Mock如果你的模块依赖外部函数Ceedling可以一键生成mock文件省去手写一堆桩函数的工作自带mocking规则模拟函数返回值、参数校验、调用次数校验都很方便代价是需要Ruby环境。不过现在CI系统基本都是Docker把Ruby环境放容器里不是问题。如果公司环境实在装不了Ruby退而求其次用Unity加手写桩也完全可行只是少了一点自动化便利。3.2 工程目录与最小配置我用Ceedling管理测试工程时典型的目录结构长这样project_root/ ├── src/ # 生产代码 │ ├── app_led.c │ ├── app_led.h │ └── uart_protocol.c ├── test/ # 测试代码 │ ├── test_app_led.c │ └── test_uart_protocol.c ├── project.yml # Ceedling配置文件 └── build/ # 生成的构建临时文件project.yml的最小配置:project: :use_exceptions: FALSE :use_test_preprocessor: TRUE :use_auxiliary_dependencies: TRUE :build_root: build/ :release_build: TRUE :paths: :test: - test/** :source: - src/** :tools: :test_compiler: :executable: gcc :arguments: - -c - ${1} - -o ${2} - -I$: COLLECTION_PATHS_TEST_TOOLCHAIN_INCLUDE - -I$: COLLECTION_PATHS_TEST_SUPPORT_SOURCE_INCLUDE :test_linker: :executable: gcc :arguments: - ${1} - -o ${2} - -lm编译器和链接器的executable都直接写gcc意味着测试工程完全跑在PC上不需要交叉编译链。这一点很重要——测试要的就是快速迭代用gcc编译比用arm-none-eabi-gcc快不少而且生成的测试程序能直接跑。3.3 跑通第一个测试用例在test/目录下新建test_app_led.c#include unity.h #include app_led.h void setUp(void) { app_led_reset(); } void tearDown(void) { } void test_led_state_initial_off(void) { TEST_ASSERT_FALSE(app_led_is_on()); } void test_led_turn_on_sets_state_on(void) { app_led_on(); TEST_ASSERT_TRUE(app_led_is_on()); }先写一个编译不过的app_led.h头文件声明这些函数但还没实现。运行ceedling test:app_ledCeedling会自动生成测试入口main函数编译所有源文件跑测试并输出结果。加上源码实现后测试变绿。整个过程不到一分钟这就是嵌入式TDD的“快速反馈环”。4. 第一个驱动模块的完整红绿重构过程4.1 需求定义一个防抖按键驱动光谈理论容易飘。我用一个实际做过的模块来演示完整流程防抖按键驱动。需求很常见读取一个GPIO引脚电平但机械按键按下/释放时会产生抖动需要在状态机层面做消抖最终输出稳定的按键按下/释放事件。这是典型的嵌入式业务逻辑有状态、有时序、有边界情况。最糟糕的原始实现是直接在GPIO中断里做delay_ms(20)消抖——我见过无数项目这么干既浪费CPU又导致中断处理时间过长。用TDD可以逼着我们把它写成一个清晰的状态机。4.2 红灯先写测试按TDD原则我先不写任何实现代码直接写测试#include unity.h #include button_driver.h static button_event_t last_event; static uint32_t current_time_ms; void setUp(void) { last_event BUTTON_EVENT_NONE; current_time_ms 0; button_driver_init(); } void tearDown(void) { } static void on_button_event(button_event_t event) { last_event event; } void test_initial_state_no_event(void) { TEST_ASSERT_EQUAL(BUTTON_EVENT_NONE, last_event); } void test_press_debounced_after_stable_20ms(void) { /* 模拟引脚电平变化按下 */ button_driver_tick(current_time_ms); button_driver_update(BUTTON_PIN_LEVEL_LOW); /* 假设低电平表示按下 */ current_time_ms 5; button_driver_tick(current_time_ms); button_driver_update(BUTTON_PIN_LEVEL_LOW); current_time_ms 5; button_driver_tick(current_time_ms); button_driver_update(BUTTON_PIN_LEVEL_LOW); current_time_ms 5; button_driver_tick(current_time_ms); button_driver_update(BUTTON_PIN_LEVEL_LOW); current_time_ms 5; button_driver_tick(current_time_ms); button_driver_update(BUTTON_PIN_LEVEL_LOW); /* 到这里已经连续5个tick都是低电平超过20ms应该触发按下事件 */ button_driver_process(); TEST_ASSERT_EQUAL(BUTTON_EVENT_PRESSED, last_event); } void test_glitch_does_not_trigger_event(void) { /* 电平只持续10ms就弹回应该不产生事件 */ button_driver_tick(current_time_ms); button_driver_update(BUTTON_PIN_LEVEL_LOW); current_time_ms 5; button_driver_tick(current_time_ms); button_driver_update(BUTTON_PIN_LEVEL_LOW); current_time_ms 5; button_driver_tick(current_time_ms); button_driver_update(BUTTON_PIN_LEVEL_HIGH); /* 抖动又弹回 */ button_driver_process(); TEST_ASSERT_EQUAL(BUTTON_EVENT_NONE, last_event); }这里有几个关键设计决策按键电平检测由外部GPIO扫描函数调用button_driver_update()注入时间基准由定时器中断调用button_driver_tick(ms)注入事件回调函数on_button_event由使用者注册测试里直接注册成一个记录变量这样设计后整个驱动函数不碰任何具体的GPIO寄存器或硬件定时器测试可以完全在PC上模拟任意时序。这就是我前面强调的“依赖注入”在实际模块中的落地。4.3 绿灯写实现跑到测试通过实现button_driver.ctypedef enum { BUTTON_STATE_IDLE, BUTTON_STATE_DEBOUNCE_PRESS, BUTTON_STATE_PRESSED, BUTTON_STATE_DEBOUNCE_RELEASE, } button_state_t; static button_state_t state BUTTON_STATE_IDLE; static button_event_callback_t callback 0; static uint32_t debounce_start_ms 0; static uint32_t current_ms 0; static button_pin_level_t last_level; static const uint32_t DEBOUNCE_MS 20; void button_driver_init(void) { state BUTTON_STATE_IDLE; debounce_start_ms 0; } void button_driver_register_callback(button_event_callback_t cb) { callback cb; } void button_driver_tick(uint32_t ms) { current_ms ms; } void button_driver_update(button_pin_level_t level) { last_level level; switch (state) { case BUTTON_STATE_IDLE: if (level BUTTON_PIN_LEVEL_LOW) { debounce_start_ms current_ms; state BUTTON_STATE_DEBOUNCE_PRESS; } break; case BUTTON_STATE_DEBOUNCE_PRESS: if (level BUTTON_PIN_LEVEL_HIGH) { /* 消抖期间电平弹回重置 */ state BUTTON_STATE_IDLE; } else if ((current_ms - debounce_start_ms) DEBOUNCE_MS) { state BUTTON_STATE_PRESSED; if (callback) callback(BUTTON_EVENT_PRESSED); } break; case BUTTON_STATE_PRESSED: if (level BUTTON_PIN_LEVEL_HIGH) { debounce_start_ms current_ms; state BUTTON_STATE_DEBOUNCE_RELEASE; } break; case BUTTON_STATE_DEBOUNCE_RELEASE: if (level BUTTON_PIN_LEVEL_LOW) { /* 释放过程抖动回到PRESSED状态 */ state BUTTON_STATE_PRESSED; } else if ((current_ms - debounce_start_ms) DEBOUNCE_MS) { state BUTTON_STATE_IDLE; if (callback) callback(BUTTON_EVENT_RELEASED); } break; } } void button_driver_process(void) { /* 这里可以做一些周期性的状态处理比如超时检测 */ }跑测试看到所有用例通过这一步叫“绿灯”。注意这个阶段不要优化代码能用最直白的方式把事情做对就行。4.4 重构清理实现测试仍然通过绿灯之后我回头审视实现发现button_driver_process()函数名容易让人误解——它目前什么都不做但未来可能加入长按检测。另外状态机五个状态的命名不够一致。我会做一次重构把这些调整好然后重新跑测试。只要测试全绿就证明重构没有破坏行为。这就是TDD给的“安全网”。红-绿-重构这个循环我建议在嵌入式项目里保持小步快跑一次只加一个测试、只写一个功能点。很多团队推行TDD失败就是因为步子迈得太大——先写了一堆测试再写一大堆实现结果测试和实现的行为对不上调试成本反而更高。TDD的意义是把开发节奏切碎让每一步的验证成本最小化。5. 中断、时序与异步逻辑TDD最容易被质疑的三座山5.1 中断处理函数的拆分套路“中断没法测”是我听到最多的反驳。每次有人这么说我都会反问一句“你的中断处理函数里到底写了多少行”如果答案超过20行那本身就是设计问题而不是测试问题。标准做法是把中断处理函数拆成两部分——中断入口和业务处理。/* 中断入口只做最紧急的事尽量短 */ void EXTI0_IRQHandler(void) { if (EXTI-PR EXTI_PR_PR0) { EXTI-PR EXTI_PR_PR0; /* 清除中断标志 */ button_driver_update(BUTTON_PIN_LEVEL_LOW); /* 通知业务层 */ } } /* 业务层完全可测试 */ void button_driver_update(button_pin_level_t level);在这里EXTI0_IRQHandler是唯一不可测的代码但它只有几行逻辑就是“清标志转发”。真正的状态机、消抖、事件产生全在button_driver_update里这一部分我们用单元测试充分覆盖。这个模式我几乎用于所有外设UART收发中断、定时器中断、DMA中断、外部IO中断全部是“中断入口极薄业务逻辑下沉”。5.2 时间依赖的处理注入时钟而不是sleep嵌入式代码另一个常见问题是因为“它跑在真实时间上”导致无法在测试中复现时序。比如“100ms后超时重启”这个逻辑你不能在测试里真的等100ms。解决思路是引入虚拟时钟。我在代码里很少直接调用HAL_GetTick()或操作系统API而是定义自己的时间接口/* time_provider.h */ typedef uint32_t (*time_get_tick_fn)(void); void time_provider_set_tick_fn(time_get_tick_fn fn); uint32_t time_provider_get_tick(void);生产环境注入HAL_GetTick测试环境注入一个可控的虚拟时间变量static uint32_t fake_tick 0; uint32_t fake_time_get_tick(void) { return fake_tick; } void test_timeout_after_100ms(void) { time_provider_set_tick_fn(fake_time_get_tick); fake_tick 0; start_operation(); fake_tick 99; TEST_ASSERT_FALSE(is_timed_out()); fake_tick 100; TEST_ASSERT_TRUE(is_timed_out()); }这样“时间”本身变成了一个输入变量测试想怎么控制就怎么控制。不需要线程、不需要实时等待、不需要sleep。我在做通信协议超时重传、看门狗喂狗策略、省电模式唤醒延时等模块时全靠这套虚拟时钟机制。5.3 异步事件队列让并发逻辑变线性中断里“先判断标志再读数据再处理”这类逻辑天然有先后顺序。但多中断嵌套时顺序就变得不确定测试很难复现那种竞态。我的策略是引入一个事件队列中断入口只负责“把事件塞进队列”业务主循环或专用任务负责“从队列取事件并处理”。这样异步的、并发的硬件事件被转成了同步的、顺序的事件流测试时只要按任意顺序往队列里塞事件就能验证系统在那种事件顺序下的行为。typedef struct { uint8_t events[16]; uint8_t head; uint8_t tail; } event_queue_t; /* 中断入口调用 */ event_queue_push(q, EVENT_UART_RX_BYTE); /* 业务循环调用 */ event_t evt event_queue_pop(q); handle_event(evt);测试代码可以这样模拟任意复杂的事件顺序event_queue_push(q, EVENT_UART_RX_BYTE); event_queue_push(q, EVENT_UART_FRAMING_ERROR); event_queue_push(q, EVENT_BUTTON_PRESSED); handle_next_event(); handle_next_event(); handle_next_event();这种设计让原本不可复现的异步问题变得完全可复现。测试跑一百遍行为永远一致这比在目标板上祈祷“稳定复现”要可靠一个数量级。5.4 仍然无法避免硬件相关的代码怎么测即使做了上述拆分有些代码还是无法在主机上跑比如DMA描述符链的配置、Flash编程时序、特定外设的寄存器序列。这些代码我的处理方式是用静态分析工具如Cppcheck、Coverity检查寄存器配置的常见错误把寄存器访问宏定义独立成头文件在测试工程里替换为内存读写模型保留一小部分硬件在环测试放在每日CI的板卡测试工位或硬件仿真环境里执行我见过一些团队为了让“覆盖率达标”强行给寄存器操作代码写单元测试最后mock了一堆寄存器地址测试实际上验证的是“mock是否正确”意义不大。这类代码该靠硬件在环测试兜底而不是硬塞给单元测试。6. 把TDD变成团队习惯CI、覆盖率与代码评审的协同6.1 先在CI里跑测试哪怕每天只跑一次很多嵌入式团队没有“持续集成”这个概念开发流程是“各自在自己电脑上编译提交到仓库到发布前再集成”。这种模式对TDD非常不友好——没有自动化的测试守护开发者很快就会“忘了写测试”或者“测试崩了但没人发现”。我的建议是在推行TDD的初期不必让每位开发者都在本地跑全套测试但CI上必须挂一条“编译所有单元测试并运行”的任务。只要有人推代码CI自动编译测试工程、运行全部用例任何一个用例挂了就阻止合并。这样即使团队里有人不习惯本地跑测试CI也会强制兜底。CI任务的配置很轻量Docker镜像装好gcc、Ruby、ceedling跑一条命令就行ceedling test:all配合GitLab CI或GitHub Actions都是现成的。关键是把它设为合并请求的必要检查项。6.2 覆盖率不是KPI是设计反馈推行TDD一段时间后团队自然会关注覆盖率。这里我有一个强烈的建议别把覆盖率设成硬性KPI。一旦覆盖率变成考核指标人的行为就会走样——要么给寄存器操作代码堆mock要么写一堆“测试测试本身”的空测试。正确的用法是把覆盖率当设计反馈如果某个模块的覆盖率明显低于其他模块大概率说明它的可测试性出了问题比如依赖了太多硬件、或者没有做好依赖注入。这时候应该去重构这个模块的接口让逻辑更容易被测试而不是硬凑覆盖数字。我建议的合理覆盖分层是业务逻辑层状态机、协议、算法行覆盖率90%以上模块接口层适配、转换分支覆盖率80%左右硬件适配层不做单元测试靠硬件在环测试6.3 代码评审里强制测试先行代码评审是推行TDD的另一个关键抓手。我在团队里立了一条规则评审任何拉请求时先看测试再看实现。测试代码写得不清晰实现代码即使功能正确也要求打回重写。这条规则刚开始会让大家不习惯因为很多嵌入式工程师从来没写过测试代码写出来的测试风格也很混乱。半年之后效果非常明显提交的代码因为缺少测试而被要求补测的次数越来越少新写驱动模块的bug数量明显下降跨模块重构不再需要“提心吊胆”——依赖测试可以放心改新同事接手模块时通过测试代码理解模块行为比读实现代码快得多6.4 从零开始在存量项目里推广TDD的经验顺序如果公司或团队已经有一套存量固件代码我建议不要一上来就推“所有模块全部TDD”那会引发强烈反弹。我个人经验是可以按这个顺序渐进选一个业务逻辑密集、未来改动频繁的模块比如通信协议、状态机、配置解析搭好Ceedling测试工程先把这个模块的测试补起来不用完整覆盖关键分支即可在CI里挂上测试任务让全团队看到测试的存在新代码强制测试先行存量代码利用重构的机会逐步补测试等到团队里两三个成员已经非常熟练再让TDD成为默认开发方式这个过程需要一到两个月。期间目标不是“覆盖率多少”而是“大家不排斥写测试、测试能稳定跑在CI上”。等这个基础打牢“测试先行”的观念就水到渠成。6.5 推行过程中的常见阻力与对策“老板不重视不给我时间写测试” → 用数据说话推行TDD后缺陷数量、调试时间、回归时间的变化。我在一个无线通信项目上推行半年后产品缺陷减少了一半以上单次回归测试时间从两天缩短到两小时。“写测试太慢了赶进度来不及” → 习惯之后写测试和写实现的时间差不多。而且省下的调试时间远大于前期多花的测试时间。一开始觉得慢是因为整个工作流程在换挡。“项目用的芯片太老没有现代仿真器” → 正好说明更需要TDD硬件调试手段越匮乏越需要靠主机测试兜住逻辑正确性。“我们用的不是C是汇编” → 这个场景确实复杂一些。可以考虑在汇编层做薄薄的功能包装上层逻辑仍然用C写用TDD覆盖。我在多个项目里重复过这套流程最重要的体会就是TDD在嵌入式里推不推得动技术从来不是瓶颈真正的问题是团队愿不愿意把代码改成可测试的样子。最后再分享一个细节我在代码里写测试的时候习惯给每个测试用例起一个“行为描述式”的名字比如test_press_debounced_after_stable_20ms而不是test_button_1。这样测试代码本身就变成了文档。半年之后你回头看最省心的不是测试帮你抓了多少bug而是你几乎不需要靠回忆来理解模块原来的设计意图——读测试就知道这个模块应该怎么工作。这大概就是TDD在嵌入式系统里最被低估的价值。
返回列表