第2章:UDS协议栈架构:协议栈分层设计

好,咱们直接进入正题。上一章聊了UDS是什么,这一章我带你看看它的内部结构。

很多人一上来就啃协议栈源码,结果越看越懵。为什么?因为没搞懂分层。说白了,UDS协议栈就像一栋楼,每一层各司其职。你想想看,如果所有功能都堆在一起,那调试起来得多痛苦?

2.1 为什么需要分层设计?

我在项目中遇到过一件事。有一次,客户反馈说诊断通信偶尔超时。我排查了半天,发现不是应用层的问题,而是底层CAN驱动在某个波特率下丢帧了。如果当时没有分层,我得把整个协议栈翻一遍才能定位。嗯,这就是分层的价值——每一层只关心自己的事

分层设计的好处,我总结了几点:

  • 模块化:哪层出问题,就查哪层。不用从头到尾看一遍。
  • 可移植性:换底层硬件?只需要改数据链路层。应用层代码基本不用动。
  • 可测试性:每一层都可以单独做单元测试。我习惯先测传输层,再往上堆。

核心观点:UDS协议栈的分层,本质上是在做「关注点分离」。每一层只暴露接口,内部实现随便你怎么折腾。

2.2 五层架构总览

按照ISO 14229和ISO 15765的标准,UDS协议栈通常分为五层。我画了个简表,你一看就明白:

层级 名称 主要职责 对应标准
第5层 应用层 诊断服务定义、数据解析 ISO 14229-1
第4层 会话层 会话管理、定时控制 ISO 14229-2
第3层 传输层 数据分包、重组、流控制 ISO 15765-2
第2层 网络层 网络地址、路由、多帧传输 ISO 15765-2
第1层 数据链路层 CAN帧收发、错误检测 ISO 11898

注意,这张表里传输层和网络层在ISO 15765-2里是合在一起讲的。但我个人习惯把它们拆开,因为在实际编码中,这两层的关注点确实不一样。

2.3 应用层——最接近人的一层

应用层是咱们写诊断逻辑的地方。比如你要实现一个「读取故障码」的功能,那就在这一层写。

它的核心工作就两件:

  • 服务请求解析:收到0x19(读取故障码),知道该干什么。
  • 数据封装:把故障码数据打包成响应报文。

举个例子,一个简单的诊断服务处理流程:

// 伪代码,展示应用层逻辑
void AppLayer_HandleRequest(uint8_t serviceId, uint8_t* data, uint16_t len) {
    switch(serviceId) {
        case 0x10: // 诊断会话控制
            SessionControl_Handler(data, len);
            break;
        case 0x19: // 读取故障码
            ReadDTCInfo_Handler(data, len);
            break;
        case 0x22: // 读取数据
            ReadDataByIdentifier_Handler(data, len);
            break;
        default:
            // 发送否定响应
            SendNegativeResponse(serviceId, NRC_SERVICE_NOT_SUPPORTED);
            break;
    }
}

这里要注意,应用层不关心数据是怎么发出去的。它只管「收到什么指令,返回什么数据」。我曾经见过有人把CAN发送代码直接写在应用层里,结果换了个CAN控制器,整个应用层都得重写。嗯,千万别这么干。

2.4 会话层——管状态的

会话层,说白了就是管理「当前处于什么诊断模式」。UDS有三种默认会话:

  • 默认会话(Default Session):上电后的初始状态,功能受限。
  • 编程会话(Programming Session):刷写固件时用。
  • 扩展会话(Extended Session):解锁更多诊断功能。

会话层还负责一件事——定时器管理。比如S3定时器,如果一段时间没收到诊断请求,就自动跳回默认会话。我调试时经常遇到一个问题:明明发了诊断指令,但ECU没响应。查了半天,发现是会话超时了,ECU已经回到了默认会话,不支持当前服务。

避坑指南:我曾经在项目里遇到过,测试人员连续发诊断请求,但中间间隔超过了S3超时时间。结果ECU反复在默认会话和扩展会话之间跳转,导致响应不稳定。后来我加了个「会话保持」机制,每次收到请求都重置定时器,问题就解决了。

2.5 传输层——拆包与重组

传输层解决一个实际问题:CAN帧一次只能发8字节(CAN 2.0),但诊断数据可能很长。比如你要读取100个故障码,数据量远超8字节。怎么办?

传输层负责把大数据拆成多个CAN帧发出去,接收方再拼回来。这个过程叫「多帧传输」。

多帧传输有四种帧类型:

  • 单帧(SF):数据长度 ≤ 7字节,一帧搞定。
  • 首帧(FF):数据长度 > 7字节,第一帧告诉对方总长度。
  • 连续帧(CF):后续的数据帧。
  • 流控制帧(FC):接收方告诉发送方「慢点发,我处理不过来」。

我习惯用一张图来理解:

发送方                    接收方
  |                         |
  |--- 首帧 (FF) --------->|   "我要发200字节"
  |<--- 流控制帧 (FC) -----|   "好,一次发8帧,间隔5ms"
  |--- 连续帧1 ----------->|
  |--- 连续帧2 ----------->|
  |--- ...                >|
  |--- 连续帧N ----------->|   "收完了,拼起来"

这里有个坑:流控制帧的「块大小(BlockSize)」和「最小间隔时间(STmin)」如果设置不合理,会导致通信效率极低。我曾经调过一个项目,STmin设成了100ms,结果传一个200字节的数据包花了将近3秒。后来改成10ms,瞬间就快了。

2.6 网络层——找对地方

网络层负责一件事:把数据送到正确的节点。在CAN网络中,每个ECU都有唯一的物理地址(如0x701)。网络层根据目标地址,决定是直接发送还是需要路由。

网关场景下,网络层特别重要。比如你从CAN1发诊断请求,目标ECU在CAN2上。网络层就要做路由转发:

  • 收到CAN1的请求,解析目标地址。
  • 查路由表,发现目标在CAN2。
  • 把数据原封不动搬到CAN2上发出去。

网络层还处理「网络地址」和「功能地址」的区别:

  • 物理地址:点对点通信,只发给一个ECU。
  • 功能地址:广播,发给总线上所有ECU。

我建议你在设计网络层时,一定要把路由表做成可配置的。因为不同项目的网络拓扑不一样,写死在代码里,换项目就得重来。

2.7 数据链路层——最底层的苦力

数据链路层,就是CAN控制器驱动。它负责:

  • 把网络层的数据打包成CAN帧。
  • 发送到CAN总线上。
  • 从总线上接收CAN帧。
  • 处理错误帧、总线仲裁等。

这一层通常由芯片厂商提供,比如NXP的S32K系列、Infineon的TC3xx系列,都有现成的CAN驱动库。你一般不需要自己写,但需要理解它的接口。

举个例子,数据链路层提供的接口大概长这样:

// 数据链路层接口示例
typedef struct {
    uint32_t id;      // CAN ID
    uint8_t  dlc;     // 数据长度
    uint8_t  data[8]; // 数据
    uint8_t  isExtended; // 是否扩展帧
} CanFrame_t;

// 发送一帧
bool CanDrv_Send(CanFrame_t* frame);
// 接收一帧(回调方式)
void CanDrv_RegisterRxCallback(void (*callback)(CanFrame_t* frame));

这里有个细节:CAN ID的分配。UDS诊断通常使用11位标准帧,诊断请求ID一般是0x7DF(功能地址),响应ID是0x7E8之类的。但不同OEM有自己的定义。我建议你一开始就把CAN ID做成宏定义,方便后期修改。

注意:数据链路层最容易出问题的地方是「总线错误」。比如CAN_H和CAN_L短路,或者终端电阻没接好,会导致总线进入被动错误状态。我曾经在实车测试时,因为一个接插件松动,诊断通信时断时续。排查了整整两天,最后发现是CAN线虚焊了。嗯,硬件问题有时候比软件更坑。

2.8 各层之间的协作

最后,我带你看看这五层是怎么配合工作的。假设你要发送一个「读取故障码」的请求:

  1. 应用层:构造0x19服务请求,数据长度50字节。
  2. 会话层:检查当前会话是否支持0x19服务。支持,继续。
  3. 传输层:50字节 > 7字节,需要多帧传输。打包成1个首帧 + 若干连续帧。
  4. 网络层:查路由表,目标地址是CAN2上的ECU,标记路由信息。
  5. 数据链路层:把每一帧通过CAN控制器发出去。

接收方反过来:数据链路层收帧 → 网络层判断是否本节点 → 传输层拼数据 → 会话层检查状态 → 应用层处理服务。

你看,每一层各司其职,互不干扰。这就是分层设计的精髓。

我的建议:刚开始做UDS协议栈时,不要急着写代码。先把这五层的接口定义清楚。接口定了,各层之间的耦合就解开了。我习惯先写一个「接口文档」,把每一层的输入输出、回调函数都列出来。代码反而是后面的事。

好了,这一章就到这里。下一章我们深入应用层,聊聊具体的诊断服务实现。到时候我会拿一个实际项目中的例子,带你手写一个诊断服务处理器。