做车载诊断的同行,基本都会被一个问题问过:8 字节的 CAN 帧,怎么传 4KB 的诊断数据?第一次接触 UDS 网络层(TP 层)时,我也盯着 ISO 15765-2 的帧格式看了很久。明明应用层一个0x36传输数据请求就能带几十上百字节,可底层 CAN 一次只能发 8 个字节,这中间的鸿沟就是传输层协议要解决的。这篇文章就从 ISO 15765-2 的四种帧、流控参数、寻址方式、抓包案例和落地实现几个角度,把这层窗户纸捅破,适合正在做 Bootloader、UDS 诊断仪或者 CAN 节点协议栈的人参考。
网上关于 ISO 15765-2 的资料不少,但大部分只放几张 PCI 字节的截图,真正把它讲透的很少。我这里不打算复制标准原文,而是按我实际开发时理解它的顺序来写:先搞清楚为什么会有这一层,再看每种帧长什么样,最后带着大家手拆一次 4KB 的传输流程。看完你应该能自己写一个最小可用 TP 层,也能用总线分析工具排查常见的丢帧和超时问题。
1. 为什么 UDS 要单独分一层网络层:一条 4095 字节的报文如何塞进 CAN
1.1 CAN 数据场只有 8 字节,诊断报文却可能几十 KB
CAN 标准帧的数据场长度由 DLC 决定,普通 CAN 最多 8 个字节,这是一条硬约束。可 UDS 诊断里的很多服务天生就是大块头:0x22按标识符读数据,VIN 这类 17 字节的请求很常见;0x2E写数据,可能要写几百字节的配置;0x36传输数据,在 OTA 刷写场景下,一次会话的载荷经常以 KB 计。哪怕一条简单的0x31例程控制,参数稍微多点也能超过 7 字节。
如果只是把大报文简单拆成多个 CAN 帧发出去,接收端会遇到一串灵魂拷问:这些帧属于哪条消息?整条消息一共多长?帧的顺序怎么确定?哪些帧已经发完了?接收方处理不过来时,怎么让发送方等一下?这些,恰恰就是网络层要回答的问题。
这里的分层关系是:UDS 应用层(ISO 14229)负责服务语义,比如“请求下载”“传输数据”;ISO 15765-2 传输协议(TP 层)负责把一条完整的 UDS 报文拆分、重组和流控;CAN(ISO 11898)负责最底层的位传输。TP 层对 UDS 层是透明的,UDS 层只需要说“我要发这条长报文”,剩下的分包、加序号、等流控,都由 TP 层完成。
1.2 ISO 15765-2 在网络层做的事
ISO 15765-2 的工作可以概括成三件事:分段、重组、流控。
- 分段:发送端把一条长报文切成长度适合 CAN 帧的若干块,并在每个 CAN 帧的头部加 PCI(Protocol Control Information)字段,用来声明“这是什么帧”以及“这条消息总长多少”。
- 重组:接收端按照首帧里声明的长度和后续连续帧里的序列号,把散落的 CAN 帧重新拼成一条完整报文,再交给上层 UDS 处理。
- 流控:接收端根据自身缓冲区、Flash 擦写速度、总线负载等情况,通过流控帧告诉发送端“你最多一次发几帧”和“帧和帧之间隔多久发一次”。
从工程角度看,TP 层最大的价值是让应用层不必关心 CAN 的 8 字节限制,同时给底层留了节奏控制手段。你可以把它想象成快递分箱:一本书有 4000 页,不能一个包裹寄出去,就分成几十个小箱,每个箱子上写明总件数和页码,收件人按页码装订。流控帧则像收件人告诉快递员“你每送 15 箱歇一下再继续”。
1.3 4KB 的边界:12 位长度字段与 4095 上限
标题里说 4KB,精确说是 4095 字节。这个上限来自首帧(First Frame)的长度字段:首帧有两个字节的 PCI,第一个字节的高 4 位是帧类型标识,低 4 位存总长度的高 4 位,第二个字节存总长度的低 8 位。合起来就是 12 位长度字段,最大 0xFFF,也就是 4095。
为什么只给 12 位?因为早期设计这套协议时,CAN 网络主要做排放诊断和常规 UDS 服务,4KB 已经覆盖绝大多数请求和响应。实际刷写固件时,数据量可能几 MB,解决方案是靠应用层分块,比如把固件切成多个 4KB 以内的块,每块用一次0x36请求传输,再切下一块。CAN FD 普及后,传输能力扩大了,那是 ISO 15765-2 的后续扩展版本,长度字段和帧格式都做了调整,不在本文展开。
注意:4095 是“一条 UDS 消息”的最大长度,不是“一次诊断会话”的最大长度。底层帧再多,接收端也是以“一条消息”为单位重组。想在单条消息里塞 10KB,不属于 TP 层的能力边界,而是 UDS 应用层的分块边界。
2. 四种帧类型:SF、FF、CF、FC 的帧格式与识别
2.1 PCI 字节:每个 CAN 帧帧头后面那 1~2 个字节
ISO 15765-2 把每一帧 CAN 数据场的前 1 到 3 个字节叫作 PCI 字段。PCI 的高 4 位决定帧类型,这是识别一切的基础。收到的第一件事就是看这个半个字节:
| 高4位 | 帧类型 | 英文缩写 | 作用 |
|---|---|---|---|
| 0x0 | 单帧 | SF | 一条报文能塞进一帧时使用 |
| 0x1 | 首帧 | FF | 多帧传输的第一帧,声明总长度 |
| 0x2 | 连续帧 | CF | 后续数据帧,带序列号 |
| 0x3 | 流控帧 | FC | 接收端控制发送端节奏 |
PCI 之外才是真正的 UDS 载荷。要注意的是,单帧和连续帧各占 1 个字节 PCI,首帧占 2 个字节,流控帧占 3 个字节。PCI 占得越多,留给数据的位置就越少。
2.2 单帧 SF:短报文的直接表达
单帧的 PCI 字节格式是0x0n,低 4 位n表示当前帧携带的 UDS 数据长度。如果是正常寻址,SF_DL 最大是 7,因为 8 字节数据场里要留 1 字节给 PCI。如果走了扩展寻址,数据场最前面还要放一个目标地址字节,那 SF_DL 最大只能到 6。
举个例子,UDS 会话控制请求10 02:
- CAN ID 假设是
0x7E0 - 数据场第一字节:
0x02,表示后面有 2 字节数据 - 数据场后续字节:
0x10 0x02 - 数据场剩余字节:填充,常见是
0x00或0xFF,接收端必须忽略
所以你在总线上看到的可能是02 10 02 CC CC CC CC CC,其中CC是填充,不是协议数据。这一点后面踩坑清单里再说。
2.3 首帧 FF:声明“我这次要传多长”
当一条 UDS 报文超过 7 字节(正常寻址)时,发送端先发一个首帧。首帧 PCI 是高 4 位为0x1,低 4 位存总长度高 4 位,第二个字节存总长度低 8 位。比如总长度是 4095,那高 4 位是0xF,低字节是0xFF,所以首帧前两个字节就是0x1F 0xFF。
首帧除了声明长度,还要尽量携带数据。正常寻址下,首帧有两个字节的 PCI,所以它能带 8 - 2 = 6 字节的 UDS 数据;扩展寻址下,加一个目标地址字节,就只能带 5 字节。首帧携带的数据就是整条消息的最开始一段。
实际抓包看到的首帧大概是:1F FF 36 01 AA BB CC DD。这里36是 UDS 服务 ID,01是块序列号,AA BB CC DD是数据开头,后面的载荷会放在连续帧里。
2.4 连续帧 CF:带着序列号的车厢
首帧之后的所有数据帧都叫连续帧。连续帧 PCI 的高 4 位是0x2,低 4 位是序列号 SN(Sequence Number)。序列号从 1 开始,每发一帧加 1,到 15 之后回到 0,再继续循环,本质是 4 位模 16 计数器。正常寻址下,连续帧 1 字节 PCI,后面 7 字节都是数据。
所以连续帧的 PCI 依次是0x21、0x22、0x23……到0x2F后下一个是0x20,再下一个0x21。这不是写错,而是模 16 运算。接收端靠序列号判断有没有丢帧:如果期望 SN=5,收到 SN=6,说明跳了一帧,整个多帧连接就该中止并报错。
2.5 流控帧 FC:接收方在方向盘上的手
多帧传输中间,接收端会发一个流控帧来“踩油门”或“踩刹车”。流控帧 PCI 有三个字节:
- 第一个字节高 4 位是
0x3,低 4 位是 BlockSize(BS) - 第二个字节是 STmin(Separation Time Minimum)
- 第三个字节是保留位,通常填
0x00或0xFF
比如接收端发30 00 00,含义是:BS=0 表示不限块数,STmin=0 表示不要求帧间最小间隔,翻译成大白话就是“你尽管发,连发就行”。如果发31 0A 00,含义是:BS=1,每发 1 帧就要等一次流控;STmin=10ms,每帧之间至少隔 10ms。这是比较保守的配置。
流控帧本身不带应用层数据,它只负责告诉发送端“下一步怎么走”。
3. 流控不是摆设:BlockSize、STmin 和传输效率的关系
3.1 BS 管数量,STmin 管时间
很多人把流控帧当成一个“确认帧”,其实它的两个参数各管一摊:
| 参数 | 所在位置 | 含义 |
|---|---|---|
| BS (BlockSize) | FC 第一字节低 4 位 | 一个块内允许发多少个连续帧;0 表示不限 |
| STmin | FC 第二字节 | 连续帧之间的最小间隔时间 |
BS 的作用是把连续帧“分块”。如果 BS=15,发送端最多连续发 15 帧,之后必须停下来等一个新的流控帧,收到后再发下一块。如果 BS=0,发送端可以一直发到结束,不需要中间确认。STmin 的作用是限制帧与帧之间的最小时间间隔,接收端可以用它防止发送端像一个无情的机关枪一样把缓冲区打穿。
这两者的取舍很现实。BS=0 + STmin=0 是极限速度,适合 Bootloader 刷写这种所有参与者都已经准备好、缓冲区足够大的场景。BS=15 给了接收端每 15 帧检查一次的机会,适合 Flash 写入时间不稳定、DMA 忙、抢占严重的老 MCU。STmin 是更精细的节流,它不一定和 BS 同时使用,但很多协议栈要求发流控时至少把 STmin 填成一个有意义的值。
3.2 一次 4KB 传输的耗时测算:STmin 设错要多等数秒
我把账算给你看。假设要传 4095 字节,正常寻址下首帧带 6 字节,剩下 4089 字节由连续帧各带 7 字节,需要 585 个连续帧,总帧数 586。在 500 kbps 总线上,一个 8 字节数据场标准 CAN 帧大约占 110 位,算下来单帧耗时约 0.22ms。586 帧纯总线传输大约 129ms。
如果 STmin=0,整个传输大约是 130ms 级别,体感很快。如果 STmin=10ms,585 个连续帧之间至少要有 584 个 10ms 间隔,光等待时间就是 5.84 秒,加上总线传输,快 6 秒。这个差距在 Bootloader 刷写场景里非常明显:一个 4KB 数据块就慢 6 秒,整包固件可能几百 MB,那用户体验完全不可接受。
BS 的影响也要算进去。以 BS=15 为例,585 个连续帧分成 39 个块,每个块结束都要一个新流控帧。流控帧本身消耗 CAN 时间很小,39 帧也就增加 9ms 左右,但如果接收端处理流控要经历一次大循环或调度切换,实际等待可能是几毫秒到几十毫秒,累积起来也很可观。
3.3 常见 ECU 的推荐配置
不同 ECU 对 BS 和 STmin 的要求千差万别,没有“万能最优解”。我做过几个量产项目,大致经验是:
| 场景 | BS | STmin | 原因 |
|---|---|---|---|
| 高速 Bootloader 刷写 | 0 | 0x00 | 充分发挥总线带宽 |
| 普通诊断仪读写 | 0 | 0x00 或 0x0A | 兼容性好,10ms 更稳 |
| 老 ECU / 低速网关转发 | 15 | 0x14 或更大 | 给接收端留足处理时间 |
具体用哪个值,必须以目标 ECU 的协议规范和实测为准。我踩过最典型的坑是:协议栈默认 BS=0、STmin=0,发送方一口气把几百帧全塞进 CAN 发送邮箱,结果接收端还没来得及消化,导致总线上出现大量重传。后来改成 BS=15、STmin=0x05,同样一块 4KB,慢了一点点,但稳定多了。
4. 寻址模式也会改帧结构:标准、扩展、混合
4.1 正常寻址:靠 CAN ID 认人
最常用的是正常寻址(也称标准寻址)。在这种模式下,CAN ID 本身就携带了诊断地址信息。比如车上常见的物理请求 ID0x7E0、物理响应 ID0x7E8、功能寻址 ID0x7DF。收到一个 ID 为0x7E8的帧,接收方就能判断这是 ECU 发给测试仪的响应。
正常寻址的帧结构最简单:PCI 后面就是 UDS 数据。因此单帧最多 7 字节数据,首帧最多 6 字节数据,连续帧最多 7 字节数据。后面我会给一个对比表,你可以一眼看清不同寻址模式对有效载荷的影响。
4.2 扩展寻址:数据场里再塞一个地址字节
扩展寻址用于“多个逻辑节点共享一个 CAN ID”的场景。这种情况下,单靠 CAN ID 没法区分目标节点,所以数据场最前面要放一个目标地址字节,PCI 和 UDS 数据整体往后挪一位。CAN ID 可以用来过滤物理总线上的节点,但真正决定这一帧属于哪个逻辑节点的,是数据场里的地址字节。
扩展寻址的单帧数据场排布是:目标地址 + PCI + UDS 数据。这样每帧就多占 1 字节,单帧数据从 7 字节降到 6 字节,首帧从 6 字节降到 5 字节,连续帧从 7 字节降到 6 字节。很多 ECU 的网关诊断、多分区 Bootloader 会用到这个模式,因为一个 CAN ID 后面挂好几个逻辑模块。
4.3 混合寻址:29 位 ID 场景下的选择
ISO 15765-2 还定义了混合寻址,它主要在 29 位 CAN 标识符的场景下使用,把一部分源地址和目标地址信息放到扩展帧 ID 里。正常寻址是 11 位 ID,扩展寻址是 11 位 ID + 数据场字节,混合寻址则是 29 位 ID 的变体。你在做 OBD 这一类标准诊断时基本用不到混合寻址,但在一些重卡、特殊商用车网络里会遇到,建议至少知道有这个概念,遇到 29 位 ID 时不要盲目套用正常寻址的帧格式。
4.4 不同寻址模式下的每帧有效负载
| 寻址模式 | 单帧 SF 有效负载 | 首帧 FF 有效负载 | 连续帧 CF 有效负载 |
|---|---|---|---|
| 正常寻址 | 7 字节 | 6 字节 | 7 字节 |
| 扩展寻址 | 6 字节 | 5 字节 | 6 字节 |
| 混合寻址 | 按标准定义处理,通常与正常寻址一致 | 同左 | 同左 |
这个表直接决定你计算“4KB 要多发多少帧”的分母。扩展寻址下 4095 字节要 1 个首帧 + 682 个连续帧(首帧带 5 字节,(4095-5)/6 = 682),总帧数比正常寻址多 100 多帧,总线负载也随之上涨。
5. 一帧一帧看:抓一个 4KB 下载流程并解析
5.1 场景定义:0x34 + 0x36 固件下载
假设我们正在用 UDS 做一段固件刷写:先用0x34请求下载申请地址和长度,然后反复用0x36传输数据。这一节我挑其中一次 4095 字节的0x36请求做逐帧解析,看 TP 层在总线上到底留下什么痕迹。
为了方便观察,假设这次要传的负载开头是0x36 0x01 0xAA 0xBB 0xCC 0xDD ...(0x36是服务 ID,0x01是块序列号,后续是数据),总长 4095 字节。正常寻址下测试仪发到0x7E0,ECU 回流控和响应用0x7E8。
5.2 抓包数据逐帧拆解
| 序号 | 方向 | CAN ID | 数据场 | 说明 |
|---|---|---|---|---|
| 1 | 测试仪→ECU | 0x7E0 | 1F FF 36 01 AA BB CC DD | 首帧,总长度 0x0FFF=4095,已带 6 字节 UDS 负载 |
| 2 | ECU→测试仪 | 0x7E8 | 30 00 00 00 00 00 00 00 | 流控帧,BS=0,STmin=0,允许连续发 |
| 3 | 测试仪→ECU | 0x7E0 | 21 xx xx xx xx xx xx xx | 连续帧 SN=1,带 7 字节负载 |
| 4 | 测试仪→ECU | 0x7E0 | 22 xx xx xx xx xx xx xx | 连续帧 SN=2 |
| ... | 测试仪→ECU | 0x7E0 | 2F ... | 连续帧 SN=15 |
| ... | 测试仪→ECU | 0x7E0 | 20 ... | 连续帧 SN=0,模 16 回绕 |
| ... | 测试仪→ECU | 0x7E0 | 2X ... | 直到 585 个连续帧发完 |
| 最后 | ECU→测试仪 | 0x7E8 | 02 76 01 FF FF FF FF FF | UDS 单帧响应,正响应0x76 0x01 |
注意,首帧1F FF后面跟的36 01 AA BB CC DD是 UDS 层的第一段数据,总长 4095 字节把这段也算进去了。之后的 585 个连续帧每帧 7 字节,刚好补满剩余 4089 字节。最后一帧不一定满 8 字节数据场,如果剩余数据不足 7 字节,协议栈一般会把数据场补满再发,接收端要按照首帧声明的长度,而不是按照 CAN DLC,来决定到底保留几个字节。
5.3 时间线里的微妙变化
如果 STmin=0 且 BS=0,这个流程在总线上看起来几乎是连续轰炸:首帧之后紧接着就是流控帧,然后 585 个连续帧一个接一个,中间没有间隔。这种模式传输效率最高,但对发送端驱动和接收端处理能力要求也最高。如果配置了 STmin=10ms,时间线上就能看到明显的“一帧、等 10ms、再一帧”,585 帧的间隔加起来就是 5.8 秒多的空等。
真正的下载流程里,测试仪发完 4KB 数据后,ECU 可能要擦除 Flash、校验数据、并返回0x76响应。对于外部测试仪来说,这就是一个单帧响应,不超过 7 字节。但如果在某些 ECU 上0x36响应里要带更多状态信息,响应也可能超过 8 字节,那响应方向同样会走一次多帧流程——方向反过来,原厂的0x7E8发首帧,测试仪发流控帧。所以 TP 层是双向的,发送和接收的能力都要实现。
5.4 怎么用工具快速定位丢帧和超时
排查实际问题时,我一般按三步走:
- 先过滤出目标 CAN ID,看是否出现了首帧。如果只看到孤立连续帧,说明首帧丢了,多半是接收过滤配置问题。
- 检查序列号是否连续。用工具自带的 TP 解析功能,能直接看到重组后的消息,它内部会校验 SN 和总长度。如果提示“序列号跳变”,说明中间丢帧。
- 检查流控帧和超时。如果发送端已经发首帧,但一段时间内没有收到流控帧,就要查接收端有没有进入 WAIT_FC 超时;如果发了流控帧但始终等不到后续连续帧,就要查发送端是不是被总线占用卡死了。
在开源抓包工具里,可以加载 DBC 或直接启用 ISO-TP 解析插件,把多帧报文自动还原成一条应用层消息,比人肉看 CAN 帧的数据场舒服得多。但我始终建议:理解底层帧结构再去看工具输出,否则遇到工具解析失败时,你根本不知道是协议错误还是工具配置错误。
6. 做一个自己能用的 TP 层:状态机与实现要点
6.1 发送端状态机:从 IDLE 到传输完成
TP 层发送端的核心是状态机。发送短报文时,状态很简单:从 IDLE 发一个单帧就回到 IDLE。发送长报文时,状态要多两个:等流控、发连续帧。
| 状态 | 进入条件 | 主要动作 | 退出条件 |
|---|---|---|---|
| IDLE | 空闲无任务 | 等待上层发送消息 | 上层调用发送服务 |
| 单帧发送 | 消息长度 ≤ 7 | 组单帧并发送 | 发送完成 |
| 等 FC(WAIT_FC) | 已发首帧 | 启动 N_Bs 定时器 | 收到合法流控帧或超时 |
| 发 CF(SEND_CF) | 收到流控帧 | 按 STmin 控制节奏,发一个连续帧 | 所有字节发完,或达到 BS 限制回到等 FC |
状态机里最容易出错的是“达到 BS 限制后,下一个触发是新的流控帧”,而不是继续发。很多人在 BS=15 时写成了“每 15 帧自动发一个 FC”,把角色搞反了。记住:FC 永远由接收端发,发送端只能等。
6.2 接收端状态机:从 FF 到重组完成
接收端相对被动态一些,但也需要状态管理。收到单帧直接上交;收到首帧后要解析总长度,组装缓冲区,并立即回一个流控帧,然后进入等连续帧状态;每收到一个连续帧要校验序列号,判断是否到达块尾,决定要不要再发流控帧。
| 状态 | 进入条件 | 主要动作 | 退出条件 |
|---|---|---|---|
| IDLE | 空闲 | 等首帧 | 收到首帧或单帧 |
| 等首帧后载荷 | 已收到首帧 | 存首帧数据,发 FC,等连续帧 | 收到一个 CF 且 SN 符合 |
| 等连续帧 | 等待后续数据 | 校验 SN,追加数据 | 总长度收满,或超时 |
接收端超时通常用 N_Br(收到首帧到发出流控帧)和 N_Cr(发出流控帧到收到连续帧)来约束。如果发出了流控帧但一直没等到对应连续帧,说明发送方可能死机或总线断连,这时必须超时中止,释放缓冲区,否则自身资源会被一直占着。
6.3 定时器参数:N_As/N_Bs/N_Cr 的落地
ISO 15765-2 里有一组网络定时器参数,常用的几个是:
- N_As:发送端从上层请求到实际发出首帧/单帧的最大时间
- N_Bs:发送端发完首帧后等待流控帧的最大时间
- N_Br:接收端收到首帧后到发出流控帧的最大时间
- N_Cr:接收端发出流控帧后等待下一个连续帧的最大时间
具体数值标准里有默认推荐,但量产项目里不能无脑照抄。我常用的参考值是 N_As=100ms、N_Bs/N_Cr=1000ms、N_Br=500ms。如果你的 ECU 有复杂的调度周期,Flash 擦写时 CPU 可能几十毫秒无法响应,N_Br 太小会导致测试仪误报超时。反过来,N_Cr 太大会让故障现场拖很久才暴露,诊断效率低。调参前最好用逻辑分析仪抓一轮真实传输,量出最差情况下的实际间隔,再加 30%~50% 余量。
6.4 简化的 C 代码骨架
下面是一段发送端状态机的极简伪代码,只表达核心思路,不涉及具体 CAN 驱动接口:
typedef enum { TP_IDLE, TP_SF_SENT, TP_WAIT_FC, TP_SEND_CF } TpState; TpState tpState = TP_IDLE; uint32_t tpRemain; /* 剩余总字节 */ uint16_t tpSn; /* 期望 SN */ uint16_t tpBlockCounter; /* 当前块计数 */ uint8_t tpStmin; /* 最小帧间隔 */ void Tp_TxMainLoop(void) { if (tpState == TP_WAIT_FC) { /* 收到并校验 FC 后,初始化块计数和 SN */ tpBlockCounter = fc_bs; /* 如果 BS=0,计数器不再限制 */ tpSn = 1; tpState = TP_SEND_CF; } if (tpState == TP_SEND_CF) { if (/* 距上一帧满足 STmin */) { Tp_SendCf(tpSn); /* 组帧并发送 */ tpSn = (tpSn + 1) & 0x0F; tpRemain -= 7; if (tpRemain <= 0) { tpState = TP_IDLE; } else if (tpBlockCounter > 0 && --tpBlockCounter == 0) { tpState = TP_WAIT_FC; /* 本块结束,等新 FC */ } } } }真正的代码里还要处理 CAN 发送邮箱 busy、重传、定时器超时、接收端重组校验,但骨架就是上面这个样子。先把状态切分清楚,再填驱动细节,写出来不容易乱。
7. 踩坑清单:ISO 15765-2 最常见的误区和实测心得
7.1 序列号从 0x2F 回到 0x20,别惯性写 0x30
连续帧 SN 是 4 位,模 16 循环。15 的下一个不是 16,而是 0。如果你用sn++然后把结果拼进 PCI,15+1=16,16 的二进制是0x10,但 SN 字段只有 4 位,PCI 高 4 位是帧类型0x2,所以 PCI 字节算出来就是0x30。接收端会把0x30当成流控帧,整个多帧连接立刻毁掉。
正确写法是sn = (sn + 1) & 0x0F,这样 15 之后自然回到 0。做接收端校验时也一样,期望 SN 要用模 16 比较,不要用== seq++这种线性判断。
7.2 STmin 第二段含义别记反:0x80 不是 128ms
STmin 的编码第一段很直观:0x00~0x7F直接对应 0~127ms。第二段0x80~0xF0是微秒级扩展区间,很多人看到0x80就当 128ms,大错特错。它的具体映射要到标准表格里查,而不是十进制直读。实际开发中,除非你的 CAN 帧间隔需要微秒级控制,否则建议老老实实用第一段毫秒值,0x00代表不限制,0x0A代表 10ms,简单可靠。看到协议栈配置里出现0x85这类值,先查文档,别拍脑袋。
7.3 填充字节不要参与数据拼接
CAN 驱动如果要发 8 字节,但最后一帧有效载荷只有 3 字节,很多协议栈会补齐到 8 字节再发。补齐的内容可能是0x00、0xFF、0xCC,各不相同。接收端要根据首帧声明的总长度,只取前面 n 个字节作为 UDS 数据,不能按 DLC 全收。我见过一个刚转诊断的同学,把每一帧的 8 个字节全部拼起来,结果消息尾部多出一堆0xCC,上层校验直接失败。
7.4 超时值别照抄默认:大报文场景建议调大 N_Bs
标准默认的定时器值是给“标准工况”准备的,但刷写场景经常打破“标准”。比如 ECU 在做 Flash 擦除时,中断响应和总线收发都可能被拖慢,此时首帧发出去后,接收端可能超过默认的 1000ms 才回流控帧。把 N_Bs 设成 1000ms 就很容易误报。建议在集成阶段用分析工具抓完整流程,统计最坏间隔,再决定超时阈值。
反过来,N_Cr 不能给得太大。如果接收端发出流控帧后 1 秒都没等到下一帧,很可能是发送端掉线了,继续等只会占住缓冲区。理想的 N_Cr 是“略大于总线最坏间隔”,而不是“越大越安全”。
7.5 流控帧与多连接管理
TP 层一次只处理一条消息的状态,但诊断场景里经常出现多条 TP 消息交叉。比如测试仪同时收到多个 ECU 的响应,或者功能寻址广播引来了多个节点共同回复。此时如果协议栈不加区分,收到 A 节点的 FF 后又用 B 节点的 FF 来覆盖旧状态,重组就会错乱。成熟协议栈会以“源地址 + 目标地址”为 key 维护多个传输连接,这也是为什么寻址模式不仅是帧格式问题,还是连接管理问题。如果你在自己写协议栈,一定要预留多连接索引,至少按 CAN ID 和地址字节做区分。
最后一点建议
ISO 15765-2 这个协议本身不算复杂,把四种帧和流控逻辑理解透之后,剩下的都是工程细节。我做诊断协议栈移植时,最花时间的反而是各种“小概率”场景:序列号回绕、填充字节、超时边界、多连接冲突。建议你在开发阶段就做个抓包用例集,把单帧、多帧、流控中断、序列号跳变、超时这些情况全跑一遍,留着当回归测试用例。后面每次改驱动或者换平台,跑一遍就知道有没有把协议栈改坏。这样比出了问题再去抄协议栈代码高效得多。