2. 汽车诊断基础
做UDS协议栈,第一件事不是写代码,而是想清楚怎么分层。我见过太多人上来就怼代码,结果做到一半发现会话层和传输层耦合在一起,改一个bug牵出一堆问题。说白了,分层架构就是给协议栈画好边界,让每一层只干自己的事。
2.1 为什么一定要分层?
ISO 14229定义了UDS的应用层,但它没规定你怎么实现传输、怎么管理网络。实际项目中,诊断协议栈跑在CAN、LIN、以太网甚至FlexRay上。如果所有逻辑揉在一起,换一个物理层等于重写整个栈。
我个人习惯把协议栈拆成六层,从下往上依次是:
- 数据链路层:处理CAN/LIN/以太网帧收发
- 网络层:实现ISO 15765-2(CAN TP)或DoIP
- 传输层:管理多帧传输、流控制
- 会话层:维护诊断会话状态
- 表示层:处理A2L/ODX描述、字节序转换
- 应用层:实现UDS服务(10/22/2E/31等)
核心原则:每一层只依赖它的下一层,绝不跨层调用。上层不知道下层用的是什么物理总线,下层不知道上层在做什么诊断服务。
2.2 各层职责与接口定义
我们一层一层往下看。我习惯从中间开始设计——先定好会话层和传输层的接口,再往两头扩展。
2.2.1 数据链路层
这一层最底层,直接跟硬件打交道。它的职责很简单:收发原始帧,不解析任何内容。
| 接口 | 方向 | 说明 |
|---|---|---|
DL_SendFrame(id, data, len) | 上层→下层 | 发送一帧CAN/LIN/以太网报文 |
DL_ReceiveFrame(id, data, len) | 下层→上层 | 回调通知收到一帧 |
DL_SetBaudrate(rate) | 配置 | 设置波特率/速率 |
嗯,这里要注意:数据链路层不要做任何过滤。我曾经在一个项目里看到有人把诊断ID过滤写在这里,结果换了一个OEM需求,改得头皮发麻。过滤是网络层的事。
2.2.2 网络层
对于CAN总线,网络层就是ISO 15765-2(CAN TP)。它负责把超过8字节的诊断消息拆成多帧,接收端再拼回去。
核心接口就两个:
N_DataRequest(SA, TA, data, len):请求发送一段完整诊断数据N_DataIndication(SA, TA, data, len):通知上层收到一段完整数据
避坑指南:我曾经遇到过网络层超时时间设得太短,导致多帧传输频繁重传。CAN TP的N_Ar和N_Bs时间要根据总线负载动态调整,别用死值。
2.2.3 传输层
这一层很多人会忽略,但它其实很关键。传输层管理的是“一次诊断对话”的生命周期——从收到第一帧开始,到最后一个响应发完结束。
它做的事情包括:
- 流控制管理(CTS/BS/STmin)
- 超时监控(P2、P2*)
- 连续帧重传
接口定义:
// 传输层接口
typedef struct {
uint8_t sa; // 源地址
uint8_t ta; // 目标地址
uint8_t *data; // 数据指针
uint16_t len; // 数据长度
uint8_t ta_type; // 物理/功能寻址
} T_Message_t;
T_Status T_SendRequest(T_Message_t *msg);
T_Status T_ReceiveIndication(T_Message_t *msg);
2.2.4 会话层
会话层是状态机的大本营。它维护三个核心状态:
- 会话状态:默认会话、扩展会话、编程会话等
- 安全状态:锁定、解锁、种子已发送
- DTC状态:待处理、已确认、历史故障等
接口设计上,会话层只暴露状态查询和事件通知:
// 会话层接口
S_SessionType S_GetCurrentSession(void);
S_SecurityLevel S_GetSecurityLevel(void);
S_Status S_OnServiceReceived(uint8_t sid);
2.2.5 表示层
这一层处理数据格式转换。比如OEM用Intel字节序,供应商用Motorola字节序,或者诊断数据需要从A2L描述中解析物理值。
我建议把表示层做成可插拔的。不同项目只需要换一个描述文件,不用改代码。
2.2.6 应用层
最上层,实现具体的UDS服务。每个服务是一个独立模块:
// 应用层服务注册
typedef struct {
uint8_t sid;
T_Status (*handler)(uint8_t *req, uint16_t req_len,
uint8_t *resp, uint16_t *resp_len);
} UDS_Service_t;
UDS_Service_t g_services[] = {
{0x10, SID10_Handler}, // 诊断会话控制
{0x22, SID22_Handler}, // 读取数据
{0x2E, SID2E_Handler}, // 写入数据
{0x31, SID31_Handler}, // 例程控制
// ...
};
2.3 状态机设计
状态机是诊断协议栈的灵魂。我见过最惨的bug就是状态机跑飞,导致ECU无法进入编程会话,只能拆壳刷写。
2.3.1 会话状态机
标准UDS定义了三种会话:默认(0x01)、扩展(0x02)、编程(0x03)。状态转换规则:
- 默认会话可以切换到任何会话
- 扩展会话可以切到编程会话
- 任何会话收到10 01都回到默认会话
- 超时(S3)自动回到默认会话
注意:编程会话通常不允许直接切到扩展会话,必须先回默认。有些OEM会加自定义会话,比如安全会话(0x04),这时候状态机要扩展,但转换逻辑必须清晰。
2.3.2 安全状态机
安全访问(27服务)的状态机相对简单:
- 锁定态:收到27 01/03/05...请求种子
- 种子已发送态:等待密钥
- 验证态:收到密钥,校验
- 解锁态:校验通过,允许执行安全相关服务
超时或连续三次密钥错误,回到锁定态。我建议把失败计数器放在非易失存储里,防止断电绕过。
2.3.3 DTC状态机
DTC状态用1个字节的StatusOfDTC表示,每一位代表一个状态:
| 位 | 含义 | 说明 |
|---|---|---|
| bit0 | testFailed | 当前测试失败 |
| bit1 | testFailedThisOperationCycle | 本操作循环内失败 |
| bit2 | pendingDTC | 待确认故障 |
| bit3 | confirmedDTC | 已确认故障 |
| bit4 | testNotCompletedSinceLastClear | 上次清除后未完成测试 |
| bit5 | testFailedSinceLastClear | 上次清除后出现过失败 |
| bit6 | testNotCompletedThisOperationCycle | 本操作循环未完成测试 |
| bit7 | warningIndicatorRequested | 请求点亮警告灯 |
状态转换逻辑:故障发生→testFailed置1→持续一定时间→pendingDTC置1→再次确认→confirmedDTC置1。清除操作清掉所有位。
2.4 模块解耦与依赖倒置
这是架构设计的核心。我见过很多协议栈,应用层直接调用CAN驱动,换总线类型就得改所有服务。正确的做法是依赖倒置——高层定义接口,低层实现接口。
具体做法:
- 每一层只定义接口头文件(.h),不包含实现
- 实现层通过函数指针或回调注册到接口层
- 层与层之间通过结构体传递数据,不共享全局变量
// 依赖倒置示例:传输层接口
// transport_if.h - 只定义接口,不依赖具体实现
typedef struct {
T_Status (*send)(T_Message_t *msg);
T_Status (*register_rx_cb)(void (*cb)(T_Message_t *));
T_Status (*set_timeout)(uint32_t p2, uint32_t p2_star);
} Transport_Interface_t;
// 具体实现:CAN TP
Transport_Interface_t g_can_tp = {
.send = CanTp_Send,
.register_rx_cb = CanTp_RegisterRxCallback,
.set_timeout = CanTp_SetTimeout,
};
// 具体实现:DoIP
Transport_Interface_t g_doip = {
.send = DoIP_Send,
.register_rx_cb = DoIP_RegisterRxCallback,
.set_timeout = DoIP_SetTimeout,
};
这样,上层代码根本不知道底层是CAN还是以太网。换总线?只需要换一个接口实例。
我的经验:模块解耦做得好不好,看一个指标——换一个物理层需要改几行代码。好的架构,0行。差的架构,全栈重写。
2.5 整体架构图
下面这张图展示了完整的六层架构和它们之间的调用关系:
2.6 接口设计要点总结
最后,我把接口设计的关键点列出来,你在实际开发时可以对照检查:
- 单向依赖:上层调用下层接口,下层通过回调通知上层
- 数据封装:层间传递结构体,不暴露内部实现细节
- 错误码分层:每层定义自己的错误码,不混用
- 可测试性:每一层都可以单独mock测试
- 配置分离:超时参数、ID列表、会话时间等放在配置文件中
个人建议:刚开始做分层设计时,先画好接口图再写代码。我习惯用UML组件图把每一层的接口和依赖画清楚,评审通过再动手。这样后期返工的概率会小很多。
嗯,分层架构就讲到这里。记住一句话:好的架构是改出来的,但好的分层是设计出来的。下一层接口稳了,上面的服务怎么写都不会乱。