3.2 多帧传输实战——CAN上的完整对话
一条命令,260字节的回音
场景:诊断仪通过CAN向发动机ECU发送0x22 0x01 0x0C 0x01 0x10 0x01 0x11——一次读取三个DID:发动机转速(0x010C)、MAF传感器(0x0110)、节气门位置(0x0111)。这条请求长度=1(SID)+6(三个DID各2字节)=7字节——刚好塞进一个CAN单帧SF。
ECU处理完毕。要返回的数据总量:260字节。而一个CAN单帧只能携带7字节有效载荷(8字节数据减去1字节PCI头)。260字节需要怎么拆?拆成多少段?每一段怎么编号?如果其中一段在总线上丢失了怎么办?
这正是ISO 15765-2多帧传输要解决的全部问题。让我们把260字节的传输过程逐帧拆解——你看到的不仅是协议规范,更是一个为8字节硬件窗口量身定做的流控流水线。
Step 1: 第一帧FF——宣告总长、起跑
ECU的CanTp模块收到UDS层的260字节正响应数据,开始组装第一帧——FF(First Frame):
┌─────────────────── CAN ID (11或29bit) ───────────────────┐
│ ...诊断CAN ID... │ 0x11 │ 0x04 │ 0x62 0xF1 0x90 0x00 0x01 <剩余数据>│
│ │ PCI │ FF_DL=0x0104=260 │ (6字节UDS数据) │
│ │ Type=1│ │
└───────────────────┴───────┴───────────────────────────────────────────────┘
CAN帧总长:8字节数据 + CAN ID + CRC + ACK。
PCI(Protocol Control Information,协议控制信息)字节高4位 0x1 = FF帧类型,低4位与下一个字节共同组成12位的FF_DL表示总长度——0x104 = 260字节。
- 低4位
0x0+ 字节2的0x01+ 字节3的低4位 =0x0104= 260(12位总长度)
为什么是12位? ISO 15765-2设计FF的DL(Data Length)字段时需要在1个PCI字节里容纳类型标记和长度的一部分。最终方案:PCI高4位编码帧类型(0=SF, 1=FF, 2=CF, 3=FC),低4位+下一字节组成12位长度——最大可表达4095。这刚好覆盖一个UDS报文的最大长度。再多就需要DoIP了。
FF携带了6字节实际数据——PCI占2字节(0x10 0xNN),剩下6字节是正响应的开头:0x62 0xF1 0x90 0x00 0x01——SID+0x40、第一个DID、数据起始。
260字节的余额:260 - 6 = 254字节留给CF。
Step 2: 接收端的FC——“你可以开始了,不限速
诊断仪的CanTp收到FF,解析出总长度=260。它要告诉ECU两件事:
- 我准备好了——可以开始发送后续CF帧(CTS = Clear To Send)
- 发送节奏——块大小(BlockSize)和帧间隔(STmin)
FC(Flow Control)帧结构:
┌─────────────────────── CAN ID ───────────────────────┐
│ ...诊断CAN ID... │ 0x30 │ 0x00 │ 0x00 │ …… (剩余字节填充) │
│ │ PCI │ BlockSize│Separation│ │
│ │Type=3 │ (BS) │ Time Min │ │
│ │ FS=0 │ 0 │ (STmin) │ │
│ │ (CTS) │ │ 0 │ │
└───────────────────┴───────┴──────────┴──────────┴────────────────────┘
- FS = 0(CTS):清除发送——接收端已就绪,放手发送
- BS = 0:块大小无限制——连续发送所有CF帧,不需要等待第二个FC
- STmin = 0:帧间最小间隔为0——连续发送,不插入强制延迟
为什么FC由接收端决定速度? 这是ISO 15765-2最核心的设计决策——接收端是瓶颈。发送端(ECU)可能很快,但接收端(诊断仪)的RX FIFO可能有限。如果发送端以全速涌入CF帧,接收端来不及处理——数据丢失。FC机制让接收端告诉发送端“我能吃多快“——而不是发送端猜测。这是和TCP流控共同的哲学——接收端窗口控制。
Step 3: CF舰队——37个连续帧接力
ECU收到FC=CTS后开始连续发送CF帧:
CF 帧 PCI 字节编码规则:
PCI高4位 = 0x2 = CF(Consecutive Frame)
PCI低4位 = 序列号 SN (0~15, 循环)
发送序列(抽象时间线):
T=0ms ECU → 诊断仪: FF DL=260 [6B数据]
T=50ms 诊断仪 → ECU: FC CTS BS=0 STmin=0 ← 50ms是N_Ar响应时间
─── CF流开始 ───
T=60ms ECU → 诊断仪: CF SN=1 [7B数据] 字节索引: 6~12
T=61ms ECU → 诊断仪: CF SN=2 [7B数据] 字节索引: 13~19
T=62ms ECU → 诊断仪: CF SN=3 [7B数据] 字节索引: 20~26
...
T=75ms ECU → 诊断仪: CF SN=15 [7B数据] 字节索引: 104~110 (SN=15, 即0x2F)
SN溢出,从0继续——
T=76ms ECU → 诊断仪: CF SN=0 [7B数据] 字节索引: 111~117 (SN=0, 即0x20)
T=77ms ECU → 诊断仪: CF SN=1 [7B数据] 字节索引: 118~124 (SN=1, 即0x21)
...
T=~96ms 最后一条CF [最后几字节] 字节索引: 255~259
传输完成——诊断仪CanTp向上层触发 N_USData.indication
计算验证:
- 总数据 260 字节
- FF 携带 6 字节 → 剩余 254 字节
- 每CF携带 7 字节 → 需要 ⌈254/7⌉ = 37 个 CF 帧
- 每个CF占 CAN 帧(~130μs at 500kbps)= 37 × 0.13ms ≈ 4.8ms 纯传输时间
- 加上帧间隔和响应时间 → 总传输约 10~15ms(STmin=0时)
37个CF帧——如果STmin=1ms(保守设置),总时长变为约 50ms。如果STmin=5ms(极限保守),总时长约 200ms。
序列号SN丢失——为什么整块重传而不是单帧重传
在现实CAN总线上,帧丢失是常见现象——电磁干扰、总线过载导致的仲裁丢失、接收FIFO溢出。如果CF SN=3 丢失了:
正常序列: SN=1 → SN=2 → SN=3 → SN=4 → SN=5 ...
丢失场景: SN=1 ✓ SN=2 ✓ SN=3 ✗ SN=4 ✓
诊断仪本地预期: next_expected_sn = 3
诊断仪收到: CF with SN=4
判断: SN不匹配!预期3,收到4 → 发生了帧丢失
诊断仪的动作: 立即发送FC CTS——请求整个块从头重传:
诊断仪 → ECU: FC 0x30 0x00 0x00 (CTS, BS=0)
ECU → 诊断仪: CF SN=1 [重传开始]
ECU → 诊断仪: CF SN=2 [重传]
ECU → 诊断仪: CF SN=3 [重传——这次应该收到了]
...
核心洞察:ISO 15765-2选择整块重传而不是单帧选择性重传——不是设计者没想到单帧NACK,而是CAN帧的PCI字节预算不允许。CF帧的PCI字节只有4位给序列号——没有位留给确认号、没有NACK标记。引入“单个CF的ACK/NACK“机制需要第二个PCI字节——但这会挤占数据字节(CF本来就只有7字节payload)。设计者在这做了取舍:“丢一帧就整块重来——在CAN带宽和延迟允许的范围内——比在PCI里挤进2字节编码复杂逻辑要简单得多。
这个取舍在当时的CAN总线上是合理的——因为传输块通常只有几百字节——重传几十个CF帧就是重新传输几百微秒的数据。但在今天的1Mbps+ CAN FD上,这个“整块重传“策略确实显得浪费——这也是CAN FD定义了更大的帧(64字节payload)的部分动机。
功能寻址为什么不能用多帧
在3.1节提到了功能寻址——诊断仪通过一个广播式CAN ID同时向总线上多个ECU发命令。现在我们可以理解为什么ISO 15765-2限定功能寻址只能用SF单帧:
问题根源——FC帧的碰撞混乱:
诊断仪 → 功能CAN ID → [ECU_A, ECU_B, ECU_C] 三个ECU同时收到
如果允许FF:
诊断仪: FF (功能ID) → 三个ECU同时收到→各自准备回复
三个ECU同时想回FC...但是FC是从各自ECU的物理CAN ID发出的
即使ECU_A成功发出了FC、ECU_B在仲裁中输了——
诊断仪不知道发来的FC是哪个ECU的(因为ECU_A和ECU_B的物理ID不同)
更糟的是——诊断仪收到的FF可能来自多个ECU的拼合
ECU_A的FF和ECU_B的FF在时间窗口里交织到达——
诊断仪无法把"某个FC"关联到"某个FF
结论:功能寻址下的多帧传输不具备ECU会话隔离能力 —— ISO 15765-2一刀切禁掉了。
所以功能寻址的UDS请求必须控制在7字节以内——也就是SID+最多6字节参数。对于大多数“全局切换会话“、“全局读取少量DID”——这足够了。如果要传大量数据——你必须切换到物理寻址。
关键时序参数——N_As, N_Ar, N_Bs, N_Cr
ISO 15765-2定义了严格的通信时序参数——这些不是你“选不选“的问题——是协议强制要求:
N_As (发送端时间) ── FF发出后,发送端启动N_As定时器——等待FC帧
N_Ar (接收端时间) ── FF收到后,接收端必须在N_Ar时间内回复FC帧
N_Bs (发送端时间) ── FC收到后,发送端启动N_Bs——在此时间内收到下一条FC
(用于多块传输——每个块的开始前都要收到FC)
N_Cr (接收端时间) ── FC发出后,接收端启动N_Cr——等待下一个CF帧
N_Br (接收端时间) ── 接收端在发送FC后等待新的FF帧
N_Cs (发送端时间) ── CF帧间的发送间隔(受STmin参数控制)
典型值(CAN 500kbps):
N_As = 100ms (发送端等待FC的超时)
N_Ar = 100ms (接收端回复FC的最长时间——通常几ms就够了)
N_Bs = 100ms
N_Cr = 150ms (接收端等待下一个CF的超时)
N_Cs = STmin (发送端最小帧间隔——由接收端在FC中指定)
时序图(完整传输中的时间窗口):
ECU(发送端) 诊断仪(接收端)
──────────────── ──────────────
FF ──────────────────→
├─ N_As启动
│
│ ←─────────────── FC ← N_Ar窗口内返回
├─ N_As停止
├─ N_Bs启动
│
├─ CF SN=1 ────────→ ← N_Cr启动(等待CF)
├─ CF SN=2 ────────→
├─ CF SN=3 ────────→ ✗(丢失)
├─ CF SN=4 ────────→
│ ← 接收端检测SN跳变 → 发CTS
│ ←─────────────── FC CTS
├─ N_Bs重启
├─ CF SN=1 ────────→ (重传)
...
├─ 最后一个CF ──────→
└─ 传输完成
一个简化但完整的CanTp发送端实现
/* ISO 15765-2 发送端——从UDS层接收数据,拆包发送 */
typedef enum {
TP_IDLE,
TP_WAIT_FC,
TP_SENDING_CF,
TP_WAIT_CF_ACK
} TpState;
static TpState tp_state = TP_IDLE;
static uint8 sn; /* 序列号 0~15 */
static uint16 total_len; /* 总长度 */
static uint16 sent_bytes; /* 已发送字节数 */
static uint8 tp_buffer[4096]; /* 待发送的完整数据 */
FUNC(void, CANTP_CODE) CanTp_Send(uint8 dest_addr,
P2CONST(uint8, AUTOMATIC, CANTP_APPL_DATA) pData, uint16 len)
{
uint16 ff_dl;
uint8 ff_data[8];
/* 存下完整数据 */
(void)MemCpy(tp_buffer, pData, len);
total_len = len;
sent_bytes = 0;
if (len <= 7) {
/* 单帧——直接发送 */
uint8 sf_data[8];
sf_data[0] = (uint8)len; /* SF PCI: 低4位=长度 */
(void)MemCpy(&sf_data[1], tp_buffer, len);
CanIf_Transmit(dest_addr, sf_data, 8);
tp_state = TP_IDLE;
} else {
/* 需要多帧——发送FF */
ff_dl = len & 0xFFF;
ff_data[0] = 0x10 | ((uint8)(ff_dl >> 8) & 0x0F); /* PCI类型+长度高4位 */
ff_data[1] = (uint8)(ff_dl & 0xFF); /* 长度低8位 */
(void)MemCpy(&ff_data[2], tp_buffer, 6); /* 6字节开头数据 */
sent_bytes = 6;
sn = 1; /* 从SN=1开始 */
CanIf_Transmit(dest_addr, ff_data, 8);
tp_state = TP_WAIT_FC;
StartTimer(N_As); /* 启动FC等待超时 */
}
}
FUNC(void, CANTP_CODE) CanTp_RxIndication(uint8 src_addr,
P2CONST(uint8, AUTOMATIC, CANTP_APPL_DATA) pData, uint8 len)
{
uint8 pci_type = pData[0] >> 4;
if (tp_state == TP_WAIT_FC && pci_type == 0x3) {
/* 收到FC */
uint8 fs = pData[0] & 0x0F;
if (fs == 0) { /* CTS = 继续发送 */
StopTimer(N_As);
tp_state = TP_SENDING_CF;
/* 开始发送CF流 */
while (sent_bytes < total_len) {
uint8 cf_data[8];
uint16 remaining = total_len - sent_bytes;
uint8 this_cf_len = (remaining > 7) ? 7 : (uint8)remaining;
cf_data[0] = 0x20 | (sn & 0x0F); /* CF PCI */
(void)MemCpy(&cf_data[1], &tp_buffer[sent_bytes], this_cf_len);
CanIf_Transmit(src_addr, cf_data, 8);
sent_bytes += this_cf_len;
sn = (sn + 1) % 16; /* SN循环 */
}
tp_state = TP_IDLE; /* 传输完成 */
} else if (fs == 1) {
/* WT = 等待——暂停发送 */
tp_state = TP_WAIT_CF_ACK;
} else if (fs == 2) {
/* OVFLW = 溢出——取消传输 */
tp_state = TP_IDLE;
}
}
}
本篇小结
- ISO 15765-2的多帧传输是FF→FC→CF三段式流水线——发送端用FF宣告总长度→接收端用FC回应CTS并指定速率→发送端用CF流循环递送数据直至传输完毕。
- SN序列号在4位(0~15)中循环——序列号不匹配时接收端发送FC CTS请求整块重传——整块重传(而非单帧选择性重传)是因为CF帧PCI字节仅有4位留给SN,无额外字节编码确认号/Ack——这是CAN帧8字节硬限制下的简化选择。
- 功能寻址只能用SF单帧——多帧传输仅在物理寻址生效——因为多个ECU的FF/FC帧会相互干扰,诊断仪无法在功能寻址下将FC关联到具体的FF发送者。
- 时序参数N_As/N_Ar/N_Bs/N_Cr/N_Cs定义了严格的通信时间窗口——ECU和诊断仪的CanTp实现必须在这些窗口约束内完成收发——超时意味着传输失败。
- 接收端通过FC中的BS和STmin参数精确控制发送端的速率——因为接收端是瓶颈,设计哲学与TCP流控同源:接收端掌控节奏。
【下集预告】:CAN带宽有限——最多500kbps或1Mbps——传一个4MB固件要花几分钟。但一辆用100Mbps以太网的现代车呢?ISO 13400 DoIP取代CAN——诊断仪的请求和ECU的响应包裹在TCP连接里发出去——从云端一路到OBD口,全部走IP。诊断速度巨幅提升。