6.1 诊断的世界:UDS
会说话的机器
1965年,底特律。一辆V8引擎的轿车因间歇性动力丧失被拖进维修车间。维修技师打开引擎盖,面对的不是故障码,而是一个完全沉默的金属疙瘩。换分电器盖——不行。调化油器混合比——不行。拆到第三天,把进气系统全部解下来,才在歧管密封垫片上找到一条只有热胀时才显现的发丝裂纹。
诊断耗时:两周。唯一的方法是拆、看、试、猜。
五十年后。一辆车在高速上检测到失火。ECU在几个监控周期内确认了故障,存储了DTC P0302,附带完整的freeze frame数据。诊断仪接入OBD口,发出一条请求。车回答:“2号缸失火。发生时的条件:车速87km/h,转速2450rpm,冷却液温度92°C,长期燃油修正+3.1%。”
诊断耗时:三秒钟。
这不是工具的事。是哲学的事。1965年的车是一个会做的系统——它执行功能,但从不解释自身。今天的车是一个会说也会做的系统——它内置了自我描述的能力。UDS(Unified Diagnostic Services,ISO 14229)就是这种语言的语法。
诊断的姿态:不是“拆“,是“问“
所有复杂系统都有两种存在方式。
第一种:只能做,不能说。它执行功能,但从不解释状态。当它出问题时,你只能从外部观察、猜测、逐层拆解。你像一个考古学家面对古代器物——根据外部痕迹推断内部构造。
第二种:不仅能做,还能说。它内置了自我监测的回路。每一个异常条件,它主动记录。每一个内部状态,它愿意公开。当它出问题时,它不需要被解剖——它自己告诉你哪里不对、什么时候开始不对、附带什么上下文数据。
UDS为第二种系统定义了语言。
在这个语言里,诊断仪问ECU“你的软件版本是什么“,ECU回答版本字节。诊断仪问“你把当前检测到的所有故障码报出来“,ECU逐一返回每个DTC及其状态。诊断仪对制动ECU说“激活你的ABS泵两秒,我要测液压回路“,制动ECU泵转了两秒后回复“已执行,液压正常。“
这背后有一整条协议栈在支撑——UDS应用层定义“问什么“和“怎么问“,ISO 15765-2传输层负责把长回复拆成CAN帧并在多帧之间协调收发,CAN数据链路层和差分物理层负责比特的实际传输。那套细节在《UDS诊断书》里已经穷尽了。
在这里你只需要看见为什么。为什么人类创造了诊断协议?因为系统越复杂,它就越不能靠“拆开看看“来维修。当一辆车有100个ECU,你不可能把100个ECU都拆出来单独测试每一个——工时是天价,而且拆过之后的ECU要重新标定、重新配置、重新安全校验。解决这个问题的唯一方法,是让每个ECU自己学会说话。
诊断协议栈的核心:DCM 如何分发请求
UDS 请求到达 ECU 后,第一个处理它的是 DCM(Diagnostic Communication Manager)。DCM 的角色类似于网络的 HTTP 服务器——它接收请求、解析服务 ID、分发到对应的处理函数。
一个极简的 DCM 分发器实现:
/* DCM_ServiceDispatcher.c — 简化的诊断通信管理器 */
#define UDS_SID_DIAGNOSTIC_SESSION_CONTROL 0x10
#define UDS_SID_ECU_RESET 0x11
#define UDS_SID_READ_DATA_BY_IDENTIFIER 0x22
#define UDS_SID_READ_DTC_INFORMATION 0x19
#define UDS_SID_SECURITY_ACCESS 0x27
#define UDS_SID_CLEAR_DIAGNOSTIC_INFORMATION 0x14
#define UDS_SID_ROUTINE_CONTROL 0x31
#define UDS_SID_TESTER_PRESENT 0x3E
typedef enum {
DCM_SESSION_DEFAULT = 0x01,
DCM_SESSION_PROGRAMMING = 0x02,
DCM_SESSION_EXTENDED = 0x03,
} Dcm_SessionType;
static Dcm_SessionType g_current_session = DCM_SESSION_DEFAULT;
static uint8_t g_security_level = 0; /* 0=锁定, 1=解锁 */
/* UDS负响应码 */
#define NRC_SERVICE_NOT_SUPPORTED 0x11
#define NRC_CONDITIONS_NOT_CORRECT 0x22
#define NRC_SECURITY_ACCESS_DENIED 0x33
void Dcm_ProcessRequest(const uint8_t *req, uint16_t req_len,
uint8_t *resp, uint16_t *resp_len)
{
uint8_t sid = req[0];
/* 首先检查诊断仪是否"活着"——不插会话超时 */
Dcm_ResetSessionTimer();
switch (sid) {
case UDS_SID_DIAGNOSTIC_SESSION_CONTROL:
Dcm_HandleSessionControl(req, req_len, resp, resp_len);
break;
case UDS_SID_READ_DATA_BY_IDENTIFIER:
Dcm_HandleReadDataById(req, req_len, resp, resp_len);
break;
case UDS_SID_READ_DTC_INFORMATION:
Dcm_HandleReadDTC(req, req_len, resp, resp_len);
break;
case UDS_SID_SECURITY_ACCESS:
Dcm_HandleSecurityAccess(req, req_len, resp, resp_len);
break;
case UDS_SID_CLEAR_DIAGNOSTIC_INFORMATION:
Dcm_HandleClearDTC(req, req_len, resp, resp_len);
break;
case UDS_SID_ROUTINE_CONTROL:
Dcm_HandleRoutineControl(req, req_len, resp, resp_len);
break;
case UDS_SID_TESTER_PRESENT:
Dcm_HandleTesterPresent(req, req_len, resp, resp_len);
break;
case UDS_SID_ECU_RESET:
Dcm_HandleEcuReset(req, req_len, resp, resp_len);
break;
default:
/* 不支持的服务——返回负响应 */
resp[0] = 0x7F; /* 负响应SID */
resp[1] = sid;
resp[2] = NRC_SERVICE_NOT_SUPPORTED;
*resp_len = 3;
break;
}
}
void Dcm_HandleSessionControl(const uint8_t *req, uint16_t req_len,
uint8_t *resp, uint16_t *resp_len)
{
uint8_t new_session = req[1];
switch (new_session) {
case DCM_SESSION_DEFAULT:
g_current_session = DCM_SESSION_DEFAULT;
g_security_level = 0; /* 回到默认会话 → 自动锁定安全访问 */
resp[0] = 0x50; /* 正响应SID = 请求SID + 0x40 */
resp[1] = new_session;
*resp_len = 2;
break;
case DCM_SESSION_EXTENDED:
g_current_session = new_session;
resp[0] = 0x50;
resp[1] = new_session;
*resp_len = 2;
break;
// 在生产代码中,此处应检查 g_security_level >= 1 后才允许进入编程会话
case DCM_SESSION_PROGRAMMING:
g_current_session = new_session;
resp[0] = 0x50;
resp[1] = new_session;
*resp_len = 2;
break;
default:
resp[0] = 0x7F;
resp[1] = UDS_SID_DIAGNOSTIC_SESSION_CONTROL;
resp[2] = NRC_CONDITIONS_NOT_CORRECT;
*resp_len = 3;
break;
}
}
/* 会话超时定时器——如果5秒内没有收到TesterPresent,自动退回默认会话 */
void Dcm_ResetSessionTimer(void)
{
g_session_timer = DCM_SESSION_TIMEOUT_MS;
}
void Dcm_SessionTimer_Tick(void)
{
if (g_session_timer > 0) {
g_session_timer--;
if (g_session_timer == 0) {
/* 会话超时——退回到默认 */
g_current_session = DCM_SESSION_DEFAULT;
g_security_level = 0;
}
}
}
DCM 不复杂。它就是一张查找表——把服务 ID 映射到处理函数。整个 UDS 协议的复杂性不在 DCM 本身,而在各个服务处理函数内部——以及这些处理函数调用的下层模块(如 DEM 对 DTC 的管理)。
诊断的三个层次:看、动、改
诊断不是单一操作。它有三个层次。
读取。 诊断仪可以读ECU的各种信息——软件版本、硬件编号、故障码、传感器实时值、通信计数器。这是“让系统报告自己“。不需要任何权限。任何诊断仪都可以问任何一个ECU“你是谁“。对应的 UDS 服务是 0x22(ReadDataByIdentifier)和 0x19(ReadDTCInformation)。
控制。 诊断仪可以激活ECU的执行器——让ABS泵短暂运转、让空调风扇启动、让某个喷油器喷射。这是“让系统按你的要求动作“。对应的 UDS 服务是 0x31(RoutineControl)——诊断仪说“执行例程 0x0201(激活ABS泵100ms)“,ECU执行后返回结果。
写入。 诊断仪可以向ECU写入标定数据——更新发动机map、修改传感器阈值、刷写新的固件。这是“改变系统的行为“。对应的服务是 0x2E(WriteDataByIdentifier)、0x34/0x36/0x37(RequestDownload/TransferData/RequestTransferExit)。
前两个层次的哲学是:系统主动暴露自己,帮助维修者更快定位故障。 第三个层次是另一回事:你赋予了维修者修改系统的权力。这需要一个锁。
安全访问:谁给了你写的权力?
ECU出厂时,它的HSM内部存储了一个密钥。这个密钥永远不离开HSM——主CPU可以请求HSM用这个密钥做加密运算,但主CPU自己读不出密钥的值。
当诊断仪需要执行写操作时,ECU不会直接同意。它要求诊断仪证明自己知道这个密钥:
Tester (诊断仪) ECU
| |
| Request Seed (0x27 0x01) |
| ─────────────────────────────────> |
| | 生成随机数 Seed (4字节)
| Seed: 0x5A8F_3C21 |
| <───────────────────────────────── |
| |
| Key = AES(Seed, SecretKey) |
| 截取前4字节 |
| |
| Send Key (0x27 0x02, key) |
| ─────────────────────────────────> |
| | HSM重新计算 Key'
| | Key == Key' ?
| | 匹配 → 解锁安全级别1
| Positive Response |
| <───────────────────────────────── |
质询—响应(Challenge-Response)的核心设计原则:密钥永不在总线上传输。 每次的seed是随机数,每次的key也不同。攻击者录制了全部通信,也无法提取密钥,也无法用录制的key重放——因为下一次的seed不同。
如果连续发送错误的key——比如三次——ECU进入“安全访问延迟“状态,每次错误后等待时间指数增长(1秒、2秒、4秒……),拒绝进一步的解锁尝试。这是抗暴力破解的措施。
解锁成功后,诊断仪进入编程会话或扩展会话,获得写权限。执行完毕后退出到默认会话——写权限关闭。如果诊断仪在规定时间内没有任何通信(通常5秒),ECU自动退回到默认会话。这是“人走关门“的策略。
会话是门。安全访问是钥匙。 诊断仪想进门,先用钥匙证明身份,然后在门关闭之前把事做完。
诊断会话状态机
诊断会话不是简单的“默认/编程“二选一。它是一个完整的状态机:
┌──────────────┐
上电/复位 │ │
┌──────────────────→ │ 默认会话 │←──────────────┐
│ │ (0x01) │ │
│ │ │ │
│ └──────┬───────┘ │
│ │ │
│ 0x10 0x03 │ 0x10 0x01
│ │ │ (返回默认)
│ ↓ │
│ ┌──────────────┐ │
│ │ 扩展会话 │───────────────┤
│ │ (0x03) │ │
│ │ │ │
│ └──────┬───────┘ │
│ │ │
│ 0x10 0x02 │ 0x10 0x01
│ │ │
│ ↓ │
│ ┌──────────────┐ │
│ │ 编程会话 │───────────────┘
│ │ (0x02) │
│ │ │
│ └──────────────┘
│ │
└────────────────────────────┘
ECU复位 (0x11 0x01)
- 默认会话(0x01):只读权限。读取 DID、读取 DTC、TesterPresent。没有写权限。
- 扩展会话(0x03):读写权限。可以写入标定数据、执行例程控制。需要安全访问解锁才能写。
- 编程会话(0x02):最高权限。允许固件刷写(RequestDownload)。必须在扩展会话中通过安全访问后才能进入。
在许多OEM的诊断策略中,你不能直接从默认会话跳到编程会话。典型的做法是:默认会话→扩展会话→安全访问解锁→编程会话。这不是ISO 14229-1的强制要求,而是OEM通过会话安全策略实现的额外保护——确保攻击者不能在跳过安全访问的情况下获得刷写权限。
DTC的生命:它不是一个布尔值
初学者以为DTC就是“有故障/没故障“的标志位。大错。
一个DTC有一整套生命周期。它不是“检测到了所以亮灯“,而是一个由 DEM(Diagnostic Event Manager)管理的有穷状态机。
让我们看 DEM 如何追踪一个 DTC 的状态:
/* DEM_DTCStatus.c — 诊断事件管理器:DTC状态字节管理 */
/* DTC状态字节中的位定义 (UDS bit numbering) */
#define DTC_BIT_TEST_FAILED 0x01 /* bit 0: 本次测试失败 */
#define DTC_BIT_TEST_FAILED_THIS_OP 0x02 /* bit 1: 本操作周期失败 */
#define DTC_BIT_PENDING_DTC 0x04 /* bit 2: 待确认DTC */
#define DTC_BIT_CONFIRMED_DTC 0x08 /* bit 3: 已确认DTC */
#define DTC_BIT_TEST_NOT_COMPLETE_SINCE_CLEAR 0x10 /* bit 4 */
#define DTC_BIT_TEST_FAILED_SINCE_CLEAR 0x20 /* bit 5 */
#define DTC_BIT_TEST_NOT_COMPLETE_THIS_OP 0x40 /* bit 6 */
#define DTC_BIT_WARNING_INDICATOR_REQUESTED 0x80 /* bit 7 */
typedef struct {
uint32_t dtc_code; /* 如 0xC00202 = P0202 */
uint8_t status; /* DTC状态字节 */
uint8_t fault_counter; /* 故障确认计数器 */
uint8_t aging_counter; /* 老化计数器 */
uint8_t healing_counter; /* 自愈计数器 */
uint32_t occurrence_count; /* 累计发生次数 */
uint32_t freeze_frame_data[4]; /* freeze frame快照 */
} Dem_DTCRecord_t;
static Dem_DTCRecord_t g_dtc_table[MAX_DTC_COUNT];
/* SWC 监测函数调用此API报告故障事件 */
void Dem_SetEventStatus(uint32_t dtc_code, uint8_t event_status)
{
Dem_DTCRecord_t *dtc = Dem_FindOrCreateDTC(dtc_code);
if (!dtc) return;
/* 本次测试结果 */
if (event_status == DEM_EVENT_FAILED) {
dtc->status |= DTC_BIT_TEST_FAILED;
dtc->status |= DTC_BIT_TEST_FAILED_THIS_OP;
dtc->status |= DTC_BIT_TEST_FAILED_SINCE_CLEAR;
dtc->fault_counter++;
/* 确认逻辑:连续N个操作周期检测到故障 → 确认DTC */
if (dtc->fault_counter >= DEM_CONFIRMATION_THRESHOLD) {
dtc->status |= DTC_BIT_CONFIRMED_DTC;
dtc->status &= ~DTC_BIT_PENDING_DTC;
dtc->aging_counter = 0;
/* 写入非易失存储——DTC被永久记录 */
NvM_WriteBlock(NVM_DTC_BLOCK_ID, g_dtc_table);
/* 如果需要点亮MIL灯 */
if (Dem_IsEmissionRelated(dtc_code)) {
dtc->status |= DTC_BIT_WARNING_INDICATOR_REQUESTED;
}
/* 保存freeze frame——首次确认时的环境快照 */
if (dtc->occurrence_count == 0) {
Dem_CaptureFreezeFrame(dtc);
}
dtc->occurrence_count++;
} else if (dtc->fault_counter >= DEM_PENDING_THRESHOLD) {
/* 尚未确认——标记为pending */
dtc->status |= DTC_BIT_PENDING_DTC;
}
} else if (event_status == DEM_EVENT_PASSED) {
dtc->status &= ~DTC_BIT_TEST_FAILED;
dtc->status &= ~DTC_BIT_PENDING_DTC;
/* 老化逻辑:连续N个周期通过 → 清除确认状态 */
if (dtc->status & DTC_BIT_CONFIRMED_DTC) {
dtc->aging_counter++;
if (dtc->aging_counter >= DEM_AGING_THRESHOLD) {
dtc->status &= ~DTC_BIT_CONFIRMED_DTC;
}
}
/* 自愈 */
if (dtc->fault_counter > 0) {
dtc->healing_counter++;
if (dtc->healing_counter >= DEM_HEALING_THRESHOLD) {
dtc->fault_counter = 0;
dtc->healing_counter = 0;
dtc->status &= ~DTC_BIT_TEST_FAILED_THIS_OP;
}
}
}
}
这个 DTC 状态机的完整时间线是这样的:
操作周期 1: 检测到故障 → TestFailed=1, TestFailedThisOp=1, faultCounter=1
操作周期 2: 检测到故障 → faultCounter=2, PendingDTC=1
操作周期 3: 检测到故障 → faultCounter=3, ConfirmedDTC=1, PendingDTC=0
→ 写入NVM, 捕获freeze frame, MIL灯可能亮
操作周期 4: 检测到通过 → TestFailed=0, agingCounter=1
操作周期 5: 检测到通过 → agingCounter=2
...
操作周期 42: 检测到通过 → agingCounter=40 (达到阈值)
→ ConfirmedDTC=0 (DTC从"已确认"变为"老化清除")
→ 但occurrenceCount不为0——维修技师仍可见"这个DTC历史发生过"
一个故障出现后,它需要连续 3 个监控周期被确认才成为“真实故障“——这是为了过滤偶发的电压毛刺、接触不良、瞬态 EMI。 一个故障消失后,它需要连续 40 个监控周期才被自动清除——这是为了防止间歇性故障被认为“已经好了“。3 个周期确认、40 个周期清除——这些数字不是随意定的,是 OEM 在多年售后数据的基础上权衡“不漏报“和“不误报“的结果。
穿透:从一个 DTC 状态位到 Flash 中的电子
一个 DTC 状态字节的第 3 位(ConfirmedDTC)从 0 翻到 1——在物理上,这是 Flash 中的一个 bit 在几十微秒内完成了编程操作。
让我们穿透这一瞬间。
最上层:dtc->status |= DTC_BIT_CONFIRMED_DTC——C 语言代码中一次按位或。
往下一层:编译器生成了 LDRB R0,[R1]; ORR R0,#0x08; STRB R0,[R1]——一个字节从 SRAM 加载,OR 上 0x08,写回。
往下一层:NvM_WriteBlock() 把整个 DTC 表写入 Flash。Flash 控制器收到命令——“扇区 12,地址 0x0801E000,写入 64 字节”。
往下一层:Flash 控制器启动内部状态机。电荷泵把 VDD(3.3V)升压到编程电压(约 10V)。NOR Flash cell 的沟道热电子在高压下被注入浮栅。浮栅中积累的负电荷改变了 cell 晶体管的阈值电压——从“擦除态“(读取为 1)变为“编程态“(读取为 0)。
往下一层:扇区中的几个 cell 在第 3 位对应的物理位置完成了编程。这个 0 就是 ConfirmedDTC 在非易失存储中的物理存在——不是软件记录的逻辑值,而是一组被囚禁在二氧化硅层中、影响着晶体管开关行为的电子。
DTC 之所以能在断电后仍然存在,不是因为有软件帮你保存——而是因为这些电子被物理地困在浮栅中。 浮栅是二氧化硅层包围的多晶硅岛——电子无处可逃。除非你在足够高的温度下让它自然泄漏(数据保持 20 年),或者你施加一个高压擦除脉冲把它们通过 Fowler-Nordheim 隧穿拉走(扇区擦除)。
从仪表盘上的一盏黄色 MIL 灯,到 NOR Flash 浮栅中被困住的电子。诊断连接了这辆车从人机界面到硅晶片物理结构的每一层。这就是“穿透“——你不是只看表面的故障现象,你沿着因果关系链一路追溯到物理根源。
工程师留给未来的信
一个好的诊断系统设计者,不是在写代码。他是在写一封信。
收信人是十年后面对这辆车束手无策的维修技师。是下一代接手维护这段代码的工程师。也是未来某天已经忘记了自己当初设计思路的自己。
每定义一个 DID——“这个数据标识符 0xF190 读取冷却液温度”——你是在告诉未来的人:“当系统在这里表现异常,想一想温度传感器。“每配置一个 DTC 的确认阈值——“连续 3 个监控周期失败才确认为真实故障”——你是在平衡“不漏报“和“不误报“,是为未来的人做了一次取舍决定。每编写一个 RoutineControl 的测试例程——“激活 ABS 泵 2 秒然后读取压力传感器”——你是在为未来的人准备了一条自动化的排查路径。
1965 年的维修技师只能拆开引擎——因为那台车在设计时就从未考虑过“让机器描述自己“。那条歧管裂纹是一个工程缺陷,但沉默的金属无法自述。
诊断,是工程师递出的接力棒。
你写的每一个DTC、每一行故障处理代码,都是一根递向未来的接力棒——接棒的可能是10年后某个4S店里的维修技师。他从未见过你,但你留下的诊断逻辑会在他接通OBD口的那一瞬间开口说话。你不在场,但你的工程判断在场。
但诊断能发现问题。不能阻止有人故意制造问题。
诊断仪可以读故障码,也可以写标定数据。如果攻击者能通过安全访问——或绕过安全访问——他就能改写 ECU 的行为。2015 年,两位安全研究员在高速公路上远程控制了一辆 Jeep Cherokee。他们没有走诊断协议。他们走了信息娱乐系统的互联网入口,进入了车内 CAN 网络——一个没有认证机制的广播网络。他们直接伪造了“刹车释放““油门全开”“方向盘锁死“的 CAN 帧。
诊断是你留给未来维修者的信。安全是你不让攻击者拆开这封信。
本篇小结
今天我们做了一件事:理解诊断不是“读取故障码“——而是工程师跨越时间写给未来的一封信,穿透从人机界面到硅晶片物理结构的每一层抽象。
关键结论:
- DTC不是一个布尔值——它是一个有生命周期的状态机:从“当前操作周期检测到故障“到“连续N个周期确认“到“写入非易失存储“到被清除——每个状态转换都有明确的工程取舍(灵敏度 vs 误报率)。
- 诊断是工程师的遗产:你写的每一个DID定义、每一个确认阈值、每一个RoutineControl测试例程——都是在为10年后面对这辆车的维修技师铺设一条自动化的排查路径。你不在场,但你的工程判断在场。
- 从仪表盘MIL灯到NOR Flash浮栅中被困的电子——诊断连接了这条物理因果链的每一层:
dtc->status |= DTC_BIT_CONFIRMED_DTC最终变成浮栅中被囚禁的电荷。断电不丢——不是因为软件,是因为物理。
下一节,诊断能发现问题,但不能阻止有人故意制造问题。硬件安全——HSM。信任不在代码里,在硅里。
【下集预告】
Jeep 事件之后,汽车信息安全从一个“未来可能发生的问题“变成了“立法强制解决的问题“。UNECE R155 法规和 ISO/SAE 21434 标准相继出台。
信息安全的基础不是“把代码写好“——再好的代码也有 bug。信息安全的基础是物理隔离和密码学——具体来说,是一块嵌在 MCU 硅晶片内部的独立安全处理器。它的密钥永不可读出。它在主 CPU 启动之前就已完成验签。
这个安全处理器叫 HSM。它的信任不来自任何一行代码——代码本身是被验证的对象。信任来自硅——具体来说,来自一块一次性编程的熔丝阵列。编程时,一个高压脉冲物理地烧断一根金属连线。这根连线永远不可能再接回去。这就是安全的物理根基——不是数学保证,是热力学保证。
下一节:硬件安全——HSM。信任不在代码里,在硅里。