如果你第一次在CANoe的Trace窗口里看到0x7E8这条ID连续蹦出好几帧,第一帧是10 14 62 F1 90 4C 53 56,第二帧是21 41 41 34 31 4A 30 41,第三帧是22 31 32 33 34 35 36 37,中间还夹了一条30 00 00 00 00 00 00 00,大概率会一头雾水:这到底是几条报文?为什么要分这么多帧?那串21、22开头的又是什么?
其实这几帧就是ISO 15765定义的传输层(Transport Protocol,TP)在干活。它解决的是一个非常朴素的矛盾:CAN单帧数据场最多8个字节,而UDS诊断服务一次要传的数据经常超过8个字节。比如读VIN码(0x22服务)的响应有20个字节,一条CAN帧根本装不下,那就得拆。怎么拆、怎么告诉对方“我拆了”、对方怎么回“我知道了,你继续发”,全是ISO 15765-2定的规矩。
这篇文章就是带你把这条规矩彻底看明白。我会用CANoe里实际抓到的报文,从帧结构、逐字节拆解、Trace配置到常见的坑,手把手捋一遍。适合刚接触UDS诊断、被多帧搞得晕头转向的测试工程师、嵌入式软件工程师,也适合那些已经会用CANoe的Diagnostic Console点按钮、但还想知道底层到底发生了什么的人。
1. 为什么多帧传输总让人“懵圈”
1.1 名字很直观,行为却不像想象中那样
ISO 15765-2里有四种帧:Single Frame(单帧)、First Frame(首帧)、Flow Control(流控帧)、Consecutive Frame(连续帧)。名字本身都不难懂,但一旦落到Trace里,很多人就乱了,原因在于它们出现的顺序不是按字母排的。
一次正常的“长响应”交互顺序是这样的:
- 接收方先收到一个首帧(FF),它相当于一个“开场白”:我要发的完整数据一共N个字节,这里先给你前6个字节。
- 发送方(确切说是请求方,也就是诊断仪侧)收到FF后,回一个流控帧(FC),相当于说:收到,你可以继续发,或者按我要求的节奏发。
- 接收方随后收到一串连续帧(CF),每一帧带7个字节数据,还有一个从1开始递增的序列号。
我在带新人时常用一个快递的类比:FF是快递员打电话说“这个包裹一共20公斤,我先搬了6公斤到门口”,FC是你说“行,你继续搬”,CF就是后面一趟一趟搬上来的包裹,而序列号就是每趟的编号,防止中途丢件。
1.2 CAN帧和UDS消息不是一回事,这是懵圈的根源
很多人犯迷糊,是因为脑子里把“一条CAN帧”和“一条诊断消息”画了等号。实际上两者之间隔着传输层:UDS服务(比如0x22读数据、0x2E写数据、0x19读故障码)是应用层的东西;ISO 15765-2是传输层;CAN控制器只负责把一条一条独立的数据帧发到总线上。
这三层在CANoe里对应的观察窗口完全不同:
| 层级 | 协议 | 在CANoe里怎么看 |
|---|---|---|
| 应用层 | UDS服务 | Diagnostic Console里看到的“Response” |
| 传输层 | ISO 15765-2 PCI | Trace里的10/21/30开头字节 |
| 数据链路层 | CAN帧 | Trace里的ID、DLC、时间戳 |
如果你只看Diagnostic Console,CANoe会帮你在后台把多帧重组好,你看到的是一条完整的UDS响应,底层拆了几帧、中间有没有流控,全都看不见。而一旦你打开Trace看原始报文,传输层的“拆装现场”就暴露出来了,没有提前建立这套分层概念的人,自然就懵了。
所以我的建议是:学ISO 15765多帧,千万别只用Diagnostic Console,一定要开着Trace去对应着看。只有看到“高层的完整响应”和“底层的多帧拆解”是同一件事,你才算真正理解了它。
2. 四类帧的字节结构:把N_PCI背下来,后面就顺了
2.1 单帧SF和首帧FF:长度字段最容易搞混
ISO 15765-2里,每个CAN数据场的第一个字节(或多个字节)叫N_PCI(Protocol Control Information),专门用来标记帧类型和数据长度。经典CAN(8字节数据场、普通寻址)下,四种帧的布局如下:
| 帧类型 | 第一个字节 | 第二字节 | 数据载荷 |
|---|---|---|---|
| SF单帧 | 0x0n(n=数据长度,最大7) | 无 | 最多7字节UDS数据 |
| FF首帧 | 0x1m + 0xll(m和ll组成12位长度) | 见左 | 最多6字节UDS数据 |
| CF连续帧 | 0x2s(s=序列号) | 见左 | 最多7字节UDS数据 |
| FC流控帧 | 0x3f(f=流控状态) | BS | STmin |
注意看SF和FF的用法差异,这是初学者最容易栽跟头的地方。
SF的第一个字节,高四位固定是0,低四位是这帧里携带的UDS数据长度。比如诊断请求22 F1 90是3个字节,那么CAN帧数据就是03 22 F1 90,03就是SF的N_PCI,表示后面3个字节是有效数据。
FF则复杂一些,因为长度可能超过15,4位装不下了。FF用两个字节表示长度:第一字节的高四位固定是1,低四位是12位数据长度的最高4位;第二字节是整个长度的低8位。所以10 14表示的是:这是一个FF,数据总长度是0x014,也就是20个字节。
这里有个极其容易搞混的点:FF_DL = 0x14 = 20,指的是这条UDS完整消息一共20个字节,而首帧本身只携带了6个字节。20和8、20和6,这些数字在脑子里对不上,人就乱了。记住一句话:FF_DL是“总账”,不是“当前帧账”。
2.2 连续帧CF和流控帧FC:序列号与红绿灯
CF的N_PCI是一个字节,高四位固定是2,低四位是序列号SN。协议规定:首帧之后的第一条CF,SN必须是1,之后每帧加1,到15之后回绕到0。为什么不是0开头?因为FF在逻辑上已经隐含了一个“第0帧”的位置,CF从1开始接续。
FC的N_PCI也是关键。第一个字节高四位固定是3,低四位是流控状态FS:
| FS值 | 含义 | 说明 |
|---|---|---|
| 0 (0x30) | CTS——继续发送 | 接收方准备好了,发吧 |
| 1 (0x31) | WAIT——等待 | 接收方忙于其他事,稍后再给我FC |
| 2 (0x32) | OVFLW——溢出/中止 | 接收方缓冲不够,这次传输作废 |
FC的第二个字节是BS(Block Size),表示允许连续发多少条CF后才需要再等下一个FC。BS=0是一个特殊值,表示“不限制block大小,剩下的CF你一口气发完”。第三个字节是STmin(Separation Time minimum),表示两条CF之间最小间隔时间,0x00-0x7F表示0到127毫秒,0xF1-0xF9表示100到900微秒。
把这三字节连起来看,30 00 00的意思就是:继续发,不限制块大小,也不要求间隔。
2.3 寻址方式不同,能装的数据量也不同
上面说的7字节、6字节、7字节,都有一个前提:用的是普通寻址(Normal Addressing),也就是CAN ID本身已经区分了请求和响应,不需要在数据场里再加目标地址字节。
但还有一种常见方式叫扩展寻址(Extended Addressing),数据场第一个字节固定放目标/源地址字节(比如物理寻址时放ECU地址),这个字节不算在UDS消息里,所以留给PCI和数据的位置就少了一个:
| 帧类型 | 普通寻址可用数据 | 扩展寻址可用数据 |
|---|---|---|
| SF | 7字节 | 6字节 |
| FF | 6字节 | 5字节 |
| CF | 7字节 | 6字节 |
很多OEM的诊断规范会明确规定用哪种寻址方式。如果配置错了,比如接收方按扩展寻址解析,发送方却按普通寻址发,那么所有字段都会错位一位,看起来就是“每个字节都对不上号”。一旦在工程里遇到这种情况,先别急着怀疑协议栈,把CANoe Trace里的第一字节拆出来看,确认是不是地址字节把PCI顶到后面去了。
还有一种29位CAN ID的情况,ID里包含了源地址和目标地址,数据场里通常不加地址字节,装载能力和普通寻址一样。具体用哪种,以协议规范为准。
3. CANoe实战拆解:一次完整的读VIN多帧交互
3.1 动手前的环境和配置
为了把原理落到实际,我在CANoe里搭了一个最简单的诊断测试环境:一个Vector硬件接口接驳到ECU所在的总线上(或者直接用CANoe的虚拟CAN口做纯软件仿真),总线波特率500 kbit/s,物理寻址请求ID是0x7E0,响应ID是0x7E8。
操作上分几步:
- 打开CANoe,配置一个CAN通道,波特率选500 kbit/s,采样点按OEM规范设置。
- 在“Simulation Setup”里添加一个网络节点,有现成的诊断描述(CDD/ODX)就直接加载,没有的话用IG(Interactive Generator)或CAPL节点手动发请求。
- 打开Trace窗口,先添加“Protocol”列和“DLC”列,这样后面解码TP帧时能直接看到帧类型。
- 如果你只是想看数据内容,不想让CANoe自动做TP高亮,直接用Raw模式看也可以,因为这篇文章就是要逐字节自己拆。
我用一个CAPL节点发了一条22 F1 90的UDS请求。22是ReadDataByIdentifier服务ID,F1 90是DID,这里对应的是VIN码。
3.2 请求阶段:为什么3个字节就敢用单帧
请求只有3个字节,加上1个字节的SF N_PCI,一个CAN帧完全装得下,所以Trace里看到的就是一条单帧:
| 序号 | 时间 | 方向 | ID | DLC | Data |
|---|---|---|---|---|---|
| 1 | 00:01.234 | Tx | 0x7E0 | 8 | 03 22 F1 90 00 00 00 00 |
这里03是SF的PCI,表示这条帧携带的UDS数据是3个字节:22 F1 90。后面的00都是填充字节,不是有效数据。有些ECU喜欢填AA或CC,填充值不是协议规定死的,所以解析时一定要以PCI里的长度为准,不要看到DLC=8就全读进去。
3.3 响应阶段:FF/FC/CF逐字节分析
ECU收到请求后,返回的VIN有20个字节,这是一个典型的多帧响应。CANoe Trace里抓到的完整过程如下:
| 序号 | 时间 | 方向 | ID | DLC | Data | 帧类型 |
|---|---|---|---|---|---|---|
| 2 | 00:01.256 | Rx | 0x7E8 | 8 | 10 14 62 F1 90 4C 53 56 | FF |
| 3 | 00:01.258 | Tx | 0x7E0 | 8 | 30 00 00 00 00 00 00 00 | FC |
| 4 | 00:01.260 | Rx | 0x7E8 | 8 | 21 41 41 34 31 4A 30 41 | CF 1 |
| 5 | 00:01.262 | Rx | 0x7E8 | 8 | 22 31 32 33 34 35 36 37 | CF 2 |
第二帧10 14:
- 高四位
1,低四位0,第二字节14,拼起来是12位长度0x014 = 20。 - 后面6个字节是UDS响应数据的前6字节:
62 F1 90 4C 53 56。
注意,这帧的DLC是8,但有效数据只有6个字节,因为它要留2字节给PCI。有些ECU会在这里做填充,有的不填,DLC照常拉满8,解析时必须跳过前2字节。
第三帧30 00 00:
30表示CTS,继续发送。00表示BS=0,不限制连续帧数量。00表示STmin=0,不强制帧间隔。
这帧本身只有3个字节是协议有效的,后面的00全是填充。
第四帧21开头是第一条CF,SN=1,后面7字节是第7到第13个UDS数据字节:41 41 34 31 4A 30 41。
第五帧22开头是第二条CF,SN=2,后面7字节是最后7个数据字节:31 32 33 34 35 36 37。
把三部分拼起来:首帧的6字节62 F1 90 4C 53 56,加上CF1的7字节41 41 34 31 4A 30 41,再加上CF2的7字节31 32 33 34 35 36 37,得到完整的响应:
62 F1 90 4C 53 56 41 41 34 31 4A 30 41 31 32 33 34 35 36 37这是20个字节,和FF_DL里声明的20完全一致。拆开看:62是0x22服务的正响应SID,F1 90是DID,后面17个字节就是ASCII码的VIN字符串“LSVAA41J0A1234567”。校验一下长度:17个字符,正好。
3.4 时间戳里藏着的协议参数
把上面几张表的时间列连起来看,你会发现FF和CF之间的时间差只有2毫秒上下,这是因为FC里STmin=0。如果ECU在FC里写了10(16ms),那么你会在Trace里看到CF帧之间至少间隔16ms;如果写了32(50ms),那间隔会更明显。
这种时间上的观察,对排查“为什么我的分帧传输老是超时”很有帮助。我曾经遇到过一个项目,ECU在FC里要求STmin=0xF1(100微秒),但是诊断仪端的协议栈因为内部任务调度,实际发CF间隔只有50微秒,结果ECU那边缓冲区溢出,直接回了一条32把传输掐断。这种问题光看数据内容根本找不到,必须盯时间戳。
另外,协议里还有几个时间参数也值得了解:N_Bs是发完FF后等待FC的超时时间,N_Cr是两条CF之间允许的最大间隔。CANoe的TP统计信息里能看到这些超时是否发生过,后面排查超时NRC时会很有用。
4. 让CANoe帮你把TP层“翻译”成人话
4.1 Trace窗口的协议解码配置
在CANoe默认情况下,如果你在Trace里看到的是原始数据,很可能是因为工程没有加载诊断描述,或者通道没有启用ISO TP解码。多数场景下只需要做两件事:
- 确认通道的CAN设置里加载了包含ISO 15765传输层属性的数据库(.dbc或CDD/ODX),或者添加了诊断通道。
- Trace窗口的列头右键,把“Protocol”列显示出来。如果配置正确,这一列会显示
ISO-TP或者Diag相关的协议名,FF、CF、FC也会被识别。
实际测试中,我会同时打开三个窗口:Trace看原始帧、Diagnostic Console看UDS层、CAN Statistics看总线整体负载。尤其是在做压力测试时,如果总线上有大量其他报文,CF帧可能会被高优先级报文延迟,导致N_Cr超时,这时CAN Statistics窗口里的总线负载率就是第一手线索。
4.2 用Diagnostic Console和HexView配合验证
Diagnostic Console的价值在于,它把TP层的拆装完全封装了,你选中“ReadDataByIdentifier”,填上DID,它会自动帮你发请求、收响应,然后把重组的完整响应直接显示出来。这对日常测试非常高效,但也很容易让人忽略底层逻辑。
所以我建议的做法是:先用Diagnostic Console确认“功能上是通的”,再用Trace去验证“底层是怎么通的”。比如Console里看到响应是62 F1 90加VIN,你再去Trace里数一数,应该是1个FF加2个CF,CF序列号是1和2。两边对得上,说明协议栈工作正常;对不上,才是你需要深入源码或者CAPL脚本的时候。
HexView这个工具主要是用来查看和编辑二进制文件的,在诊断领域经常用来做Flash数据、标定数据的比对。它不直接参与TP解析,但如果你在做Bootloader刷写测试,常常需要把待刷写的.s19或.hex文件用HexView打开,对照诊断仪实际发送的分帧内容,确认数据分块、校验和逻辑是否符合预期。
4.3 用CAPL写一个多帧自动拼装小工具
有的测试场景需要在CANoe里自动验证TP组包是否正确,比如做自动化回归测试。这时候写一个几十行的CAPL脚本比人眼盯着Trace靠谱得多。下面是一个简化版的示例,监听0x7E8上的响应,把FF和CF自动拼成完整数据,并在完成后输出:
variables { byte rxData[5000]; int rxLen = 0; int totalLen = 0; } on message 0x7E8 { int i; int pciType; pciType = this.byte(0) >> 4; if (pciType == 1) // First Frame { totalLen = ((this.byte(0) & 0x0F) << 8) | this.byte(1); rxLen = 0; for (i = 2; i < 8; i++) rxData[rxLen++] = this.byte(i); } else if (pciType == 2) // Consecutive Frame { for (i = 1; i < 8; i++) rxData[rxLen++] = this.byte(i); if (rxLen >= totalLen) { write("完整响应, 长度=%d", totalLen); for (i = 0; i < totalLen; i++) write(" %02X", rxData[i]); rxLen = 0; totalLen = 0; } } }这段脚本只处理了普通寻址、经典CAN的情况,没有判断FC,也没有处理扩展寻址和地址字节,但在多数读VIN、读数据块的测试场景里足够验证组包逻辑了。实际工程中建议把输出拼成一个字符串写到Write窗口或者面板文本控件里,这样清洗得多。
5. 多帧传输的翻车现场:这些坑我基本都踩过
5.1 CF序号走到F之后绕回0,代码里最容易漏
前面说过,CF的SN是4位,从1走到15之后,下一帧会变0。很多人写接收组包逻辑时,会潜意识认为SN应该一直是1、2、3……顺序递增,一旦看到2F后面跟着20,第一反应是“协议错了吧”。
举个例子,一条UDS响应如果总长度是130字节,FF先带6字节,剩下124字节,每帧CF带7字节,一共需要18条CF。SN会是这样一段序列:1、2、3……14、15、0、1、2、3。也就是说,第16帧的SN是0,而不是16。如果你的判断代码写的是“收到SN=0就认为非法”,那这条长响应就永远组不完整。
正确的做法是按模16来比较,哪怕SN回绕了,也能判断连续性。CANoe的TP层已经处理了这个问题,但如果是自写协议栈或者写CAPL辅助脚本,十有八九会在这里翻车。
5.2 FC里的BS含义,很多人一开始理解反了
我第一次带项目时,看到FC的BS=1以为是“完整传输完成”,后来才发现完全不是这回事。BS=1的意思是:发送方最多只能发1条CF,然后必须停下等接收方再发一个FC,才能继续发下一条。这相当于每传一帧都要握一次手,效率很低,但接收方缓冲区很小或处理速度慢时,这是必要的保护。
理解BS后,再看刷写场景就清楚为什么有些Bootloader的刷写速度上不去了——要么BS=1导致频繁握手,要么FC里STmin设得很大。通常刷写阶段ECU会要求一个较小的BS和STmin,比如BS=1、STmin=1ms,以保证Flash写入不溢出;而普通的诊断读数据,ECU一般给BS=0、STmin=0,尽量跑满带宽。
如果发送方无视BS约束,一口气把几十条CF全发出去,接收方的接收缓冲很快就会爆,随后一条32(OVFLW)直接让这次的传输中止。在Trace里看到这种情况,先检查FC的第二个字节,再检查发送方有没有按约定的块大小执行。
5.3 STmin被忽略,低速端根本来不及处理
STmin看似只是一个毫秒数,但它直接决定接收端“能不能喘过气”。高速诊断场景下,STmin=0很正常,总线速率500k时,理论上两条背靠背的CF之间只有几十微秒间隔,接收端的CPU如果只是简单搬运,可能处理不过来。
在测试中我发现,很多ECU在FC里写的STmin并不是0,比如0x10(16ms)、0x32(50ms),这些数值通常和ECU的接收任务周期有关。如果诊断仪侧为了追求速度强行忽略STmin,后续大概率会出现CF丢失或接收超时,最终表现为诊断无响应或响应异常。
排查思路也简单:在Trace里量两条CF的时间差,如果ECU要求STmin=50ms,但实际发CF的间隔只有1ms,那问题一定在发送方。反之,如果发送方已经按50ms发了,ECU还是超时,那就要怀疑ECU内部的软件问题了。
5.4 环境相关的坑:虚拟CAN口、采样点、软件版本
关注CANoe的人应该都搜过“虚拟CAN口”“采样点”“运行后自动退出”这类问题。这几个确实都和实际测试环境强相关。
虚拟CAN口(CANoe Virtual Bus)非常适合做纯软件的协议学习:没有真实电路,不会有错误帧,采样点设置也不会影响数据接收,你可以放心的在里面模拟ECU和诊断仪交互。但虚拟CAN口有个副作用——过于理想。真实总线上有报文仲裁、位定时偏差、线束干扰,有些TP问题在虚拟环境下根本复现不出来。所以我一般建议:学协议用虚拟CAN,验证可靠性一定要上真实硬件和真实总线。
采样点的问题,常见于高波特率总线。比如500 kbit/s下,如果采样点设置得太后(比如90%),线缆稍长一点就可能采样到边沿附近,导致数据错误进而产生错误帧。很多人在CANoe里看到Error Frame就怀疑ECU坏了,其实先看一下通道配置里的采样点是否在80%左右,往往能省下半天排查时间。
至于CANoe 17 SP3运行后自动退出的情况,多数和授权服务、驱动版本或者.NET组件有关。真遇到的话,别急着怀疑工程文件,先试管理员权限运行、检查Vector工具链是否完整,再去事件查看器里看崩溃模块,基本都能定位。
6. 一点自己的排查心法:先看PCI,再谈诊断
6.1 拿到一条长报文,我从来不会先读数据
不管是自己做测试,还是帮同事看问题,我拿到一帧带10或21开头的报文,第一件事永远是看PCI,不是看后面的业务数据。因为业务数据再花哨,如果PCI不对,后面全是废的。
具体我会按这个顺序撸一遍:
- 看第一字节高四位:是1(FF)、2(CF)还是3(FC)。
- FF的话,立刻算一下12位总长度,心里大概有数后面会来几条CF。
- CF的话,看低四位SN,确认序列号连续,特别是要留意F之后有没有正确回到0。
- FC的话,重点看BS和STmin,判断发送方后面应该用什么节奏发。
- 最后才拼数据,再去UDS层做语义解析。
这个顺序看起来机械,但效率极高。因为PCI就是传输层的“骨架”,只要骨架是对的,数据就算解析错了,也只是业务层的问题;骨架错了,一切免谈。
6.2 测试报告里真正有价值的,永远是原始Trace
最后分享一个习惯:每次出一个诊断相关的测试结论,我都会把完整的原始Trace一起附上,而不是只贴Diagnostic Console的截图。因为Console截图是“加工过的结果”,丢了传输层的所有过程信息;而原始Trace里包含了时间戳、ID、DLC、每个字节,甚至总线错误帧,这些才是跨部门沟通时最有说服力的东西。
对正在学CANoe和ISO 15765的朋友,我的建议很朴素:不要满足于会点几个按钮、会看Console显示“Response”。花一个下午,把读VIN、读DID这些场景在Trace里逐帧扒一遍,自己动手算一次FF_DL、CF数量、序列号回绕,这个协议你就真的掌握了。以后再看到10 14开头的帧,不会再懵,而是会心一笑:哦,这是个20字节的长响应啊。