3、可测试性设计(DFT)基础:什么是DFT?为什么嵌入式代码需要DFT?

好,咱们今天聊一个很实在的话题——可测试性设计,英文叫 Design for Testability,圈里人习惯简称 DFT。

说实话,我早年做嵌入式开发的时候,根本没这个概念。那时候写代码就是奔着功能去的,功能跑通了就算完事。直到有一次,一个电机控制项目在现场出了问题,机器一上电就抖,我拿着示波器和调试器蹲在机柜旁边,整整三天没找到根因。代码里全是全局变量、状态机嵌套、中断里还调了延时函数……根本没法隔离测试。

那次之后我才真正明白:代码能不能测,不是测试阶段的事,是设计阶段的事。

到底什么是DFT?

DFT 不是一种测试方法,而是一套设计思想。说白了,就是你在写代码的时候,就要想着“这玩意儿以后怎么测”。

我习惯这样定义:DFT 是让系统内部状态变得可观测、可控制的设计方法集合。

  • 可观测:我能看到模块内部的关键变量、状态、错误码。
  • 可控制:我能绕过正常流程,直接给模块注入测试数据或强制状态。

你想想看,如果一段代码跑起来之后,你只能看到最终输出,中间发生了什么完全是个黑盒子——那出了问题你怎么定位?

DFT 的核心目标:把黑盒变成灰盒,甚至白盒。让测试者(包括未来的你自己)能“看进去”、“伸进去”。

嵌入式代码为什么特别需要DFT?

这个问题我问过不少刚入行的工程师。有人回答“为了做单元测试”,有人回答“为了通过验收”。都对,但不够本质。

嵌入式代码和普通应用软件有个巨大的区别:它跑在真实硬件上,而且往往没有操作系统帮你隔离。

我列几个实际痛点,你看看是不是深有体会:

痛点 没有DFT的后果 有DFT之后
硬件依赖强 必须接真实传感器才能测逻辑 注入模拟数据,脱离硬件也能测
时序敏感 中断一来,状态全乱,没法复现 通过测试桩冻结时间,逐帧回放
资源受限 没有printf,没有日志系统 预留测试接口,用GPIO或共享内存输出
耦合度高 改一个模块,三个模块跟着崩 模块间通过测试接口解耦,独立验证
现场问题难复现 客户现场出bug,拿不到日志 内置诊断模式,远程触发数据采集

我曾经在一个汽车电子项目里吃过亏。那个项目用的是裸机,没有RTOS。一个CAN通信模块和电机控制模块共用同一个定时器中断。测试的时候功能都正常,一到实车路试,偶尔会出现电机抖动。我们怀疑是CAN数据被中断打乱了,但没法验证——因为代码里根本没有办法单独观测CAN接收缓冲区的状态。

后来我们花了两个星期,给每个模块加了测试桩和状态导出接口。再复现问题的时候,直接抓到了CAN帧的丢失记录。嗯,问题出在中断优先级配置上。但如果没有DFT,这个bug可能永远找不到根因。

我的经验:嵌入式项目里,越早引入DFT,后期调试成本越低。我现在的习惯是——写功能代码之前,先把测试接口的API定好。哪怕一开始是空的,也要留出位置。

DFT的三个层次

DFT不是一刀切的东西。根据我的实践,可以分成三个层次:

  1. 接口层DFT:给每个模块暴露测试入口和观测出口。比如加一个 module_get_status() 函数。
  2. 数据层DFT:让内部关键变量可读可写。比如通过共享内存或回调函数暴露内部状态。
  3. 行为层DFT:能模拟外部事件、注入故障、强制跳转状态机。这是最高级的DFT,也是最难做到的。

我建议你从接口层开始。别一上来就想搞行为层,容易把架构搞复杂。

一个简单的DFT示例

咱们看一段代码。这是一个温度传感器的驱动模块,没有DFT的写法:

// 没有DFT的版本
static int temperature_raw;

void temp_sensor_init(void) {
    // 初始化硬件
    adc_init();
}

int temp_sensor_read(void) {
    temperature_raw = adc_get_value();
    return temperature_raw;
}

这段代码有什么问题?你没法单独测试 temp_sensor_read 的逻辑,因为它必须依赖真实的ADC硬件。如果ADC坏了,你连驱动代码是对是错都不知道。

加上DFT之后:

// 有DFT的版本
static int temperature_raw;

// 测试接口:允许外部注入ADC值
#ifdef DFT_ENABLE
void temp_sensor_inject_raw(int value) {
    temperature_raw = value;
}
#endif

void temp_sensor_init(void) {
    adc_init();
}

int temp_sensor_read(void) {
#ifdef DFT_ENABLE
    // 如果测试模式开启,跳过真实ADC读取
    if (dft_is_test_mode()) {
        return temperature_raw;
    }
#endif
    temperature_raw = adc_get_value();
    return temperature_raw;
}

你看,改动很小。加了一个编译宏 DFT_ENABLE,一个注入函数 temp_sensor_inject_raw,一个条件判断。但效果完全不同——现在你可以在没有硬件的情况下,测试温度转换逻辑、边界值处理、甚至故障恢复流程。

注意:DFT代码不要影响生产环境的性能。我习惯用编译开关控制,测试版本打开,发布版本关闭。另外,测试接口要设计成“只读或可控但不可滥用”,防止测试代码在生产环境被误触发。

DFT与高内聚的关系

这里我要提一个关键点:DFT和高内聚是天然搭档。

为什么?因为高内聚的模块,职责单一、接口清晰。你给它加测试接口,成本很低。反过来,一个低内聚的模块,里面什么都有,你连从哪里下手测试都不知道。

我举个例子。一个模块如果既做数据采集,又做滤波算法,还负责通信协议打包——那它的测试接口该怎么设计?你得同时控制传感器输入、算法参数、协议状态,复杂度直接爆炸。

但如果按高内聚拆成三个模块:采集模块只管读值,滤波模块只管算数,协议模块只管组包。每个模块的测试接口都很简单,一两行代码就能搞定。

所以我的建议是:先按高内聚把模块拆好,再给每个模块配上DFT接口。顺序别搞反了。

DFT的常见误区

这些年我带过不少项目,也踩过一些坑。总结几个常见的误区:

  • 误区一:DFT就是加printf——printf只能输出,不能控制。真正的DFT要能“伸进去”改变行为。
  • 误区二:DFT是测试工程师的事——大错特错。DFT是架构设计的一部分,开发阶段不做好,测试阶段补不回来。
  • 误区三:DFT会增加代码体积——确实会,但通过编译开关控制,生产版本可以完全去掉。用空间换可测试性,值。
  • 误区四:小项目不需要DFT——我见过最惨的bug就出在“小项目”里。项目越小,越容易忽视测试,越需要DFT兜底。

一句话总结:DFT不是锦上添花,是雪中送炭。你可以在设计阶段花1小时加测试接口,也可以在调试阶段花3天追一个bug。选哪个,你自己掂量。

DFT知识体系总览

下面这张图,是我自己整理的一个DFT知识框架。它把DFT的核心概念、层次、收益和常见误区串在了一起。你可以把它当作本章的思维导图。

DFT 可测试性设计知识框架 DFT 核心思想 可观测 + 可控制 三个层次 接口层DFT 数据层DFT 行为层DFT 脱离硬件测试 快速定位bug 模块解耦验证 常见误区:DFT = printf / 测试工程师的事 / 增加体积 / 小项目不需要 核心原则:先按高内聚拆模块,再给每个模块配DFT接口 用编译开关控制,生产版本完全去除测试代码

这张图把DFT的核心思想、三个层次、实际收益和常见误区都串起来了。你可以把它贴在工位旁边,写代码的时候瞄一眼,提醒自己“这个模块的测试接口留了吗”。

好,关于DFT的基础概念和必要性,咱们就聊到这儿。记住一句话:可测试性不是测出来的,是设计出来的。