Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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两件事:

  1. 我准备好了——可以开始发送后续CF帧(CTS = Clear To Send)
  2. 发送节奏——块大小(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;
        }
    }
}

本篇小结

  1. ISO 15765-2的多帧传输是FF→FC→CF三段式流水线——发送端用FF宣告总长度→接收端用FC回应CTS并指定速率→发送端用CF流循环递送数据直至传输完毕。
  2. SN序列号在4位(0~15)中循环——序列号不匹配时接收端发送FC CTS请求整块重传——整块重传(而非单帧选择性重传)是因为CF帧PCI字节仅有4位留给SN,无额外字节编码确认号/Ack——这是CAN帧8字节硬限制下的简化选择。
  3. 功能寻址只能用SF单帧——多帧传输仅在物理寻址生效——因为多个ECU的FF/FC帧会相互干扰,诊断仪无法在功能寻址下将FC关联到具体的FF发送者。
  4. 时序参数N_As/N_Ar/N_Bs/N_Cr/N_Cs定义了严格的通信时间窗口——ECU和诊断仪的CanTp实现必须在这些窗口约束内完成收发——超时意味着传输失败。
  5. 接收端通过FC中的BS和STmin参数精确控制发送端的速率——因为接收端是瓶颈,设计哲学与TCP流控同源:接收端掌控节奏。

【下集预告】:CAN带宽有限——最多500kbps或1Mbps——传一个4MB固件要花几分钟。但一辆用100Mbps以太网的现代车呢?ISO 13400 DoIP取代CAN——诊断仪的请求和ECU的响应包裹在TCP连接里发出去——从云端一路到OBD口,全部走IP。诊断速度巨幅提升。