2. OBD-II 协议栈架构:从标准到报文
做嵌入式开发这么多年,我接触过的通信协议少说也有几十种。但 OBD-II 这套东西,说实话,刚上手时真有点绕。它不是一个单一的协议,而是一整套标准体系的组合。你想想看,要让全世界不同厂家、不同年份的车都能用一个诊断仪读出数据,背后得有多少约定?
这一章,我们就来拆解 OBD-II 的协议栈架构。我会从标准体系讲起,然后深入到 ISO 15765-4 这个核心协议,最后带你看看 SID、PID 和报文封装格式。嗯,这些都是后面移植和实战的基础,绕不开。
2.1 OBD-II 标准体系:一张分层图说清楚
OBD-II 的标准体系,说白了就是三层结构。我习惯把它想象成一个「通信管道」:最底层管物理连接,中间层管数据怎么传,最上层管传什么内容。
核心要点:OBD-II 标准体系分为物理层、数据链路层和应用层。物理层定义接口和电气特性,数据链路层定义通信协议,应用层定义诊断服务。
下面这张图,是我自己画的标准体系分层结构。你看一眼就能明白它们之间的关系。
这张图里,我特意把 CAN FD 的兼容性写在了底部说明框。为什么?因为很多初学者以为 CAN FD 是另一套东西,其实它只是在物理层和数据链路层做了升级,应用层的诊断服务完全兼容。这一点,我在做第一个 CAN FD 项目时就踩过坑——当时以为要重写整个协议栈,后来才发现只需要改底层驱动。
2.2 ISO 15765-4 协议:CAN 总线上的诊断规范
ISO 15765-4 这个标准,是 OBD-II 在 CAN 总线上的具体实现规范。说白了,它规定了「诊断报文在 CAN 总线上怎么传」。我个人觉得,这是整个协议栈里最需要吃透的部分。
2.2.1 协议数据单元(N_PDU)
ISO 15765-4 定义了四种协议数据单元(N_PDU),每种都有特定用途:
| N_PDU 类型 | 缩写 | 用途 | 典型场景 |
|---|---|---|---|
| 单帧 | SF | 传输 ≤7 字节数据 | 读取单个 PID |
| 首帧 | FF | 多帧传输的第一个帧 | 读取大量数据 |
| 连续帧 | CF | 多帧传输的后续帧 | 大数据量传输 |
| 流控帧 | FC | 接收方控制发送节奏 | 防止接收缓冲区溢出 |
经验之谈:我曾经在一个项目中,因为没处理好流控帧的超时,导致多帧传输经常失败。后来加了一个 50ms 的超时重传机制,问题就解决了。记住,流控帧不是可选项,是必须实现的。
2.2.2 CAN ID 分配
ISO 15765-4 对 CAN ID 有严格规定。在 11 位 ID 模式下,诊断请求和响应的 ID 是固定的:
- 功能寻址请求:0x7DF(广播到所有 ECU)
- 物理寻址请求:0x7E0 ~ 0x7E7(对应不同 ECU)
- 功能寻址响应:0x7E8 ~ 0x7EF(ECU 回复)
你想想看,为什么要有功能寻址和物理寻址?功能寻址是一次性问所有 ECU,适合广播请求;物理寻址是点对点通信,适合读取特定 ECU 的数据。我在调试时经常先用功能寻址确认总线上的 ECU 都在线,再用物理寻址逐个读取数据。
2.3 OBD-II 服务 ID(SID)与 PID
SID 和 PID,是 OBD-II 应用层的核心概念。SID 定义「做什么」,PID 定义「读什么」。
2.3.1 常用服务 ID(SID)
OBD-II 定义了 10 个服务(Service),每个服务对应一个 SID。最常用的几个:
| SID | 服务名称 | 功能描述 |
|---|---|---|
| 0x01 | 请求当前动力总成数据 | 读取实时传感器数据 |
| 0x02 | 请求冻结帧数据 | 读取故障发生时的快照 |
| 0x03 | 请求排放相关 DTC | 读取已存储的故障码 |
| 0x04 | 清除/复位排放相关 DTC | 清除故障码和冻结帧 |
| 0x05 | 请求氧传感器监测测试结果 | 读取氧传感器数据 |
| 0x09 | 请求车辆信息 | 读取 VIN、CALID 等 |
2.3.2 参数 ID(PID)
PID 是跟在 SID 后面的具体参数编号。比如 SID 0x01 后面跟 PID 0x0C,就是请求发动机转速。每个 PID 都有固定的数据长度和计算公式。
举个例子:请求发动机转速(SID=0x01, PID=0x0C),ECU 返回 2 字节数据。假设返回 0x0A 0x0B,转速计算公式为:(A*256 + B) / 4。计算结果:(10*256 + 11) / 4 = 642.75 RPM。
我记得刚开始做 OBD-II 时,最头疼的就是这些计算公式。后来我整理了一个 PID 计算表,每次查表就行。这里我列出几个最常用的:
| PID | 名称 | 数据长度 | 计算公式 | 单位 |
|---|---|---|---|---|
| 0x04 | 发动机负荷 | 1 字节 | A * 100 / 255 | % |
| 0x05 | 冷却液温度 | 1 字节 | A - 40 | °C |
| 0x0C | 发动机转速 | 2 字节 | (A*256 + B) / 4 | RPM |
| 0x0D | 车速 | 1 字节 | A | km/h |
| 0x11 | 节气门位置 | 1 字节 | A * 100 / 255 | % |
2.4 诊断报文封装格式
报文封装,是协议栈里最「工程化」的部分。ISO 15765-4 定义了两种报文格式:单帧和多帧。我建议你先理解单帧,再攻克多帧。
2.4.1 单帧(SF)格式
单帧用于传输 ≤7 字节的数据。CAN 报文的数据场有 8 字节,第一个字节是协议控制信息(PCI),剩下 7 字节是数据。
// 单帧报文格式
// 字节0: PCI + 数据长度
// 字节1-7: 数据
// 示例:请求发动机转速 (SID=0x01, PID=0x0C)
// 字节0: 0x02 (PCI=0, 数据长度=2)
// 字节1: 0x01 (SID)
// 字节2: 0x0C (PID)
// 字节3-7: 填充0
// 实际CAN报文数据场: 02 01 0C 00 00 00 00 00
2.4.2 多帧(FF + CF + FC)格式
当数据超过 7 字节时,就需要多帧传输。流程是这样的:发送方先发首帧(FF),接收方回复流控帧(FC),然后发送方发连续帧(CF),直到数据传完。
避坑指南:我曾经在一个项目中,因为连续帧的序列号没处理好,导致接收方丢帧。ISO 15765-4 规定连续帧的序列号从 1 开始,每帧加 1,到 0xF 后回到 0。这个循环计数很容易写错,建议用模 16 运算。
// 多帧传输示例(读取VIN,SID=0x09, PID=0x02)
// VIN码通常17字节,需要多帧传输
// 步骤1: 发送请求 (单帧)
// 发送: 02 09 02 00 00 00 00 00
// 步骤2: ECU回复首帧 (FF)
// 接收: 10 14 49 02 01 4C 46 56
// 字节0: 0x10 (PCI=1, 表示首帧)
// 字节1: 0x14 (数据总长度=20字节)
// 字节2-7: 前6字节数据
// 步骤3: 发送流控帧 (FC)
// 发送: 30 00 00 00 00 00 00 00
// 字节0: 0x30 (PCI=3, 表示流控帧)
// 字节1: 0x00 (流控状态=继续发送)
// 字节2: 0x00 (块大小=0, 无限制)
// 步骤4: 接收连续帧 (CF)
// 接收: 21 56 39 38 4B 45 34 35
// 字节0: 0x21 (PCI=2, 序列号=1)
// 字节1-7: 后续7字节数据
// 接收: 22 36 37 38 39 30 00 00
// 字节0: 0x22 (PCI=2, 序列号=2)
// 字节1-7: 最后6字节数据 + 填充
2.4.3 报文封装流程图
下面这张流程图,展示了单帧和多帧的完整处理流程。我建议你把它保存下来,写代码时对照着看。
2.5 本章小结
这一章的内容,说白了就是 OBD-II 协议栈的「骨架」。标准体系让你知道每一层做什么,ISO 15765-4 告诉你 CAN 总线上怎么传,SID/PID 定义了具体读什么,报文封装格式则是实际编码的模板。
我个人觉得,初学者最容易犯的错误是「跳过标准直接写代码」。嗯,我当年也干过这事,结果调试时发现报文格式不对,回头翻标准才知道漏了 PCI 字节。所以我的建议是:先把这一章的标准和格式吃透,再动手写代码。后面几章,我们会把这些知识用到实际的协议栈移植和 CAN FD 高速通信中。