4. UDS协议栈架构:分层设计与状态机

好,咱们今天聊聊UDS协议栈的架构。说实话,很多初学者一上来就扎进代码里,结果越看越懵。我个人习惯,先搞清楚分层,再动手写代码。你想想看,一个完整的UDS协议栈,如果没分层,那维护起来简直是噩梦。

我在项目中遇到过好几次,客户要求增加一个新功能,如果协议栈分层清晰,我只需要在应用层加个处理函数就行。要是没分层,那得从底层翻到顶层,改得心惊胆战。

4.1 协议栈为什么要分层?

说白了,分层就是为了解耦。每一层只管自己的事,不越界。UDS协议栈通常分为四层:

  • 传输层:负责数据收发,比如CAN驱动、CAN FD驱动
  • 网络层:处理多帧传输、流控制、时间参数
  • 会话层:管理诊断会话、安全访问、服务状态
  • 应用层:解析UDS服务,比如0x22读数据、0x2E写数据

嗯,这里要注意,ISO 14229-1定义的是应用层和会话层,ISO 15765-2定义的是网络层。传输层其实跟硬件相关,STM32上我们通常用CAN外设或者CAN FD外设。

4.2 传输层与网络层

传输层,说白了就是最底层的硬件驱动。在STM32上,我一般用HAL库的CAN驱动,或者直接用LL库,效率更高。传输层只做一件事:把数据发出去,把数据收进来。

网络层就复杂一些了。它要处理单帧、多帧、流控制帧。我记得第一次做多帧传输时,踩了个坑——流控制帧的超时时间没算对,导致ECU一直重发,总线都快堵死了。

网络层核心功能:

  • 单帧(SF):最多7字节数据(CAN标准帧)
  • 首帧(FF):多帧传输的第一帧,包含总长度
  • 连续帧(CF):后续数据帧
  • 流控制帧(FC):接收方告诉发送方“慢点发”或“继续发”

这里我贴一段网络层状态机的伪代码,大家感受一下:

// 网络层接收状态机
typedef enum {
    NW_IDLE,        // 空闲
    NW_WAIT_FF,     // 等待首帧
    NW_WAIT_CF,     // 等待连续帧
    NW_WAIT_FC      // 等待流控制
} NwState_t;

void Nw_ReceiveFrame(CAN_Frame_t *frame) {
    switch (nwState) {
        case NW_IDLE:
            if (frame->type == SINGLE_FRAME) {
                // 直接交给上层
                App_ProcessSingleFrame(frame->data);
            } else if (frame->type == FIRST_FRAME) {
                // 记录总长度,准备接收连续帧
                nwState = NW_WAIT_CF;
                Nw_SendFlowControl(FC_CONTINUE);
            }
            break;
        case NW_WAIT_CF:
            // 接收连续帧,拼装数据
            // 拼完了就交给上层
            break;
        // 其他状态...
    }
}

我的经验:网络层的超时处理一定要做。我曾经遇到过ECU发送首帧后,工具端没及时回复流控制,结果ECU一直卡在等待状态。加个超时定时器,超时了就复位状态机,这是保底操作。

4.3 会话层与应用层

会话层,管理的是诊断会话。UDS定义了三种默认会话:

会话类型 会话ID 说明
默认会话 0x01 上电后的初始状态,功能受限
编程会话 0x02 用于刷写程序,通常需要安全访问
扩展会话 0x03 用于诊断、标定,功能最全

应用层呢,就是处理具体的UDS服务。比如0x22(读取数据标识符),0x2E(写入数据标识符),0x31(例程控制)等等。每个服务都有固定的请求格式和响应格式。

我建议把应用层的服务处理做成函数指针表。这样加一个新服务,只需要在表里注册一个函数就行,不用改主流程。我在项目里就是这么干的,维护起来特别爽。

4.4 状态机设计

状态机是UDS协议栈的灵魂。没有状态机,协议栈就是一盘散沙。UDS的状态机分好几个维度:

  • 会话状态机:默认会话 ↔ 编程会话 ↔ 扩展会话
  • 安全访问状态机:锁定 → 请求种子 → 发送密钥 → 解锁
  • 服务处理状态机:空闲 → 处理中 → 等待响应 → 完成

我个人习惯用枚举+switch来实现状态机,简单直观,调试也方便。别整那些花里胡哨的状态模式,嵌入式系统讲究的是确定性。

注意:状态机一定要考虑异常情况。比如客户端发送了0x10 0x02(切换到编程会话),但ECU正在执行一个耗时操作,这时候怎么办?我的做法是:先回复待处理响应(0x78),等操作完成再切换会话。

这里给一个会话状态机的简化示例:

typedef enum {
    SESSION_DEFAULT = 0x01,
    SESSION_PROGRAM = 0x02,
    SESSION_EXTENDED = 0x03
} Session_t;

Session_t currentSession = SESSION_DEFAULT;

void Session_HandleRequest(uint8_t sessionId) {
    switch (currentSession) {
        case SESSION_DEFAULT:
            if (sessionId == SESSION_PROGRAM) {
                // 检查条件,比如是否允许切换
                currentSession = SESSION_PROGRAM;
                SendPositiveResponse(0x10, sessionId);
            }
            break;
        case SESSION_PROGRAM:
            // 编程会话下,只能切回默认或扩展
            if (sessionId == SESSION_DEFAULT || sessionId == SESSION_EXTENDED) {
                currentSession = sessionId;
                SendPositiveResponse(0x10, sessionId);
            }
            break;
        // 扩展会话类似...
    }
}

嗯,这里要注意,会话切换时,有些资源要重新初始化。比如在编程会话下,你可能禁用了某些通信,切回默认会话时要恢复。

4.5 我的架构建议

说了这么多,总结一下我的个人建议:

  1. 分层要彻底:每一层只通过接口函数通信,不要跨层调用。比如网络层不要直接调用CAN驱动,而是通过回调函数。
  2. 状态机要完整:每个状态都要考虑超时、错误、异常。我曾经漏掉了“等待响应超时”这个状态,结果ECU死锁了,只能断电重启。
  3. 日志要详细:每个状态切换、每帧收发,都打日志。调试的时候你就知道这有多重要了。
  4. 预留扩展点:比如在应用层预留一个“自定义服务”的钩子函数,方便客户加私有协议。

核心要点:UDS协议栈的架构设计,说白了就是“分而治之”。传输层管收发,网络层管拼装,会话层管状态,应用层管业务。各司其职,出了问题也好定位。

好了,这一章就到这里。下一章咱们聊聊具体的UDS服务实现,比如0x22读数据、0x2E写数据,我会带着大家一步步写代码。到时候我会分享一些我在实际项目中踩过的坑,保证让你少走弯路。