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服务)的状态机相对简单:

  1. 锁定态:收到27 01/03/05...请求种子
  2. 种子已发送态:等待密钥
  3. 验证态:收到密钥,校验
  4. 解锁态:校验通过,允许执行安全相关服务

超时或连续三次密钥错误,回到锁定态。我建议把失败计数器放在非易失存储里,防止断电绕过。

2.3.3 DTC状态机

DTC状态用1个字节的StatusOfDTC表示,每一位代表一个状态:

位含义说明
bit0testFailed当前测试失败
bit1testFailedThisOperationCycle本操作循环内失败
bit2pendingDTC待确认故障
bit3confirmedDTC已确认故障
bit4testNotCompletedSinceLastClear上次清除后未完成测试
bit5testFailedSinceLastClear上次清除后出现过失败
bit6testNotCompletedThisOperationCycle本操作循环未完成测试
bit7warningIndicatorRequested请求点亮警告灯

状态转换逻辑:故障发生→testFailed置1→持续一定时间→pendingDTC置1→再次确认→confirmedDTC置1。清除操作清掉所有位。

2.4 模块解耦与依赖倒置

这是架构设计的核心。我见过很多协议栈,应用层直接调用CAN驱动,换总线类型就得改所有服务。正确的做法是依赖倒置——高层定义接口,低层实现接口。

具体做法:

  1. 每一层只定义接口头文件(.h),不包含实现
  2. 实现层通过函数指针或回调注册到接口层
  3. 层与层之间通过结构体传递数据,不共享全局变量
// 依赖倒置示例:传输层接口
// 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 整体架构图

下面这张图展示了完整的六层架构和它们之间的调用关系:

UDS诊断协议栈分层架构 应用层 UDS服务实现(10/22/2E/31/19/14...) 表示层 数据格式转换、字节序、物理值映射 会话层 会话状态机、安全状态机、DTC状态机 传输层 流控制、超时管理、连续帧重传 网络层 ISO 15765-2 CAN TP / DoIP 报文拆装 数据链路层 CAN/LIN/以太网帧收发 接口原则 • 只依赖下一层 • 不跨层调用 • 接口头文件定义 • 实现可替换 • 无全局变量 • 回调注册机制 • 依赖倒置 • 状态机独立 • 超时统一管理 • 错误码分层

2.6 接口设计要点总结

最后,我把接口设计的关键点列出来,你在实际开发时可以对照检查:

  • 单向依赖:上层调用下层接口,下层通过回调通知上层
  • 数据封装:层间传递结构体,不暴露内部实现细节
  • 错误码分层:每层定义自己的错误码,不混用
  • 可测试性:每一层都可以单独mock测试
  • 配置分离:超时参数、ID列表、会话时间等放在配置文件中

个人建议:刚开始做分层设计时,先画好接口图再写代码。我习惯用UML组件图把每一层的接口和依赖画清楚,评审通过再动手。这样后期返工的概率会小很多。

嗯,分层架构就讲到这里。记住一句话:好的架构是改出来的,但好的分层是设计出来的。下一层接口稳了,上面的服务怎么写都不会乱。