1. 为什么需要TP层:从ISO 15765-1到ISO 15765-2
1.1 只有8字节的CAN帧,怎么装得下诊断数据
做过汽车诊断或者CAN总线开发的工程师,应该都有过这样的经历:用诊断仪读取一个DTC故障码快照,拿到手的原始数据可能有好几百字节;给ECU刷写升级文件,更是动不动就上万字节。但CAN总线上跑的每一帧报文,经典CAN协议下最多只能塞8个字节的数据域。就算你开CAN FD,数据域也就是64字节。面对几千字节的诊断数据,如果让应用层直接把一大坨数据扔给CAN驱动,第一批8字节发完后,剩下的数据在总线上根本没法表达“我还没说完”的信息。接收端只能收到8字节,并不知道后面还有没有数据,更不知道怎么把零散的帧拼回一个完整的大报文。
这就是TP层要解决的核心问题:一个8字节的CAN帧,如何可靠地、有序地传输长达4KB,甚至更大体积的诊断数据?所谓TP层,就是Transport Protocol Layer,即传输协议层,在汽车诊断标准体系里,具体指ISO 15765-2。它位于UDS(ISO 14229)应用层和CAN数据链路层之间,相当于一个“翻译官兼快递分拣员”,把应用层的大数据块切割成适合CAN帧承载的小段,加上编号、流控信息,再在接收端把这些小段重新组装回完整的数据块。
1.2 从ISO 15765-1到15765-2:协议家族的分工
ISO 15765系列标准是专门为CAN总线上的诊断通信设计的。整个家族分好几个部分,但日常开发中接触最多、也最容易被搞混的就是ISO 15765-1和ISO 15765-2。
- ISO 15765-1:定义的是整体架构和一般要求,包括诊断系统的通信场景、寻址方式、时序要求。可以把它看作整个协议栈的“总纲”,告诉你数据从哪来、到哪去。
- ISO 15765-2:定义了网络层协议,也就是TP层。它规定了如何将诊断数据分割成报文,如何发送、如何接收、如何做流控、如何检测错误,以及各种超时参数。
- ISO 15765-3:定义的是诊断服务在CAN上的应用层,通常和UDS配合使用,但在实际工程中更常见的是把ISO 14229直接跑在TP层之上。
很多工程师喜欢把ISO 15765-2直接叫“TP层标准”或者“DoCAN”,从实践角度说,它就是那层“透明的传输管道”。UDS应用层不用关心数据被切成了多少个CAN帧,它只需要调用TP层的服务,把大块数据交给TP层,等TP层回一个“发送完成”或者“接收完成”的通知就行。
打个比方:UDS应用层像一个寄快递的客户,他手里有一个很大的包裹要寄到外地。TP层就像快递公司,会把大包裹拆成多个小纸箱,每个小纸箱上贴上序号,然后分批送上货车(CAN帧),到达目的地后再根据序号重新装回一个大包裹交给收件人。如果没有TP层,客户就得自己手动去拆箱子、装车、逐个确认,这在实时性要求很高的ECU诊断场景里几乎不可想象。
1.3 TP层到底解决了哪些痛点
深入一点看,TP层需要同时解决四个问题。
第一个是分割与重组。发送端要把应用层的NDatal(网络层数据,即一整块诊断数据)分割成多个CAN帧,接收端要把收到的多个CAN帧重新拼接。分割的单位是报文段,每段都要能放进CAN帧的数据域。
第二个是流控。因为接收端的缓冲区大小和处理能力是有限的,如果发送端一股脑把几百个连续帧全丢到总线上,接收端很可能来不及处理,导致缓冲区溢出。TP层通过流控帧来控制发送端的节奏,有点像买票时停车场入口的计数员,每隔几个车放行一次,防止收费站瘫痪。
第三个是传输可靠性。TP层要能检测数据传输过程中发生的错误,比如帧丢失、顺序错乱、超时。一旦检测到异常,要能及时上报给上层,或者主动中止当前传输。
第四个是多路复用。总线上可能有多个ECU、多个诊断请求同时进行,TP层需要保证不同连接之间的数据不混淆。在实际实现中,通常依赖CAN报文ID(CAN ID)来区分不同的诊断连接。
这四个问题如果靠应用层自己处理,开发难度会陡增,而且每个ECU的软件都要单独维护一套拆分、重组的逻辑,代码复用性极差。有了ISO 15765-2这个统一标准,无论是自己写协议栈还是买商业协议栈,大家都能在同一套规则下工作,互操作性问题大大减少。
2. 核心机制:四种帧类型与PCI编码
2.1 单帧、首帧、连续帧、流控帧:各司其职
TP层传输一个完整数据块时,最基本的处理逻辑是这样的:如果数据块长度较小,能塞进一个CAN帧,就直接用单帧(SF,Single Frame)发出去。如果数据块太大,一帧装不下,就先用一帧作为首帧(FF,First Frame),告诉接收方“我后面要发来一大块数据,这块数据总共有多长”。接收方收到首帧后,计算一下自己的缓冲区够不够用,然后回一帧流控帧(FC,Flow Control)。发送方收到流控帧后,根据流控帧给出的参数,开始连续发送数据帧(CF,Consecutive Frame)。接收方在收到所有连续帧后,把首帧里的数据加上所有连续帧里的数据拼起来,得到完整数据块,再交给上层。
这四种帧的用途可以这么理解:
- 单帧SF:一次CAN通信就能搞定的小数据传输,比如读取VIN码这种短响应。
- 首帧FF:一个“通知信”,里面带着总数据长度的消息头,以及一部分数据内容。
- 连续帧CF:真正的大批数据“运输车厢”,每次运一小段,车厢编号循环使用。
- 流控帧FC:接收方用来告诉发送方“你可以继续发”、“你要等一会儿再发”或者“我处理不了了你赶紧停”。
在实际CAN报文里,这四种帧的区别体现在数据域的第一个字节(也可能是第二个字节,如果使用扩展寻址)。这个字节叫协议控制信息(PCI,Protocol Control Information)。PCI的低四位或高四位编码了帧类型和长度信息,详细规则如下表:
| 帧类型 | PCI高4位 | 低4位/后续字节 | 典型的数据域组成 |
|---|---|---|---|
| 单帧SF | 0x0 | 低4位为数据长度(0-7) | [PCI] [地址?] [数据...] |
| 首帧FF | 0x1 | 低4位为长度高4位,第二字节为长度低8位 | [PCI1] [PCI2] [地址?] [数据...] |
| 连续帧CF | 0x2 | 低4位为序号SN | [PCI] [地址?] [数据...] |
| 流控帧FC | 0x3 | 低4位为流控状态FS | [PCI] [BS] [STmin] [地址?] |
注意:上表中的“地址?”是当使用扩展寻址时才有的额外地址字节。正常寻址模式下没有这个字节。
2.2 单帧与首帧的具体编码规则
先看单帧SF。经典CAN数据域8字节,PCI占1字节,因此SF最大能携带7字节的应用层数据。PCI的格式是:高4位固定为0,表示这是单帧;低4位表示数据长度,但这里要注意,低4位的值只能表示1-7,0表示什么?在约定中,SF_DL表示真正携带的数据长度,因为低4位最大表示15,但单帧最多只能有7字节数据,所以低4位一般是发送的实际数据长度,范围1-7。如果数据长度刚好是0,也就是没有上层数据,那这种帧其实没有意义,一般不会出现。有些协议栈会用SF_DL=0表示一些特殊含义,但从ISO 15765-2标准来说,SF_DL的取值范围是1到7(经典CAN)。假设我们需要发送一个包含VIN码的响应,总长17字节,用一个CAN帧装不下,就必须走多帧路径,不能再用SF。
再看首帧FF。FF的PCI占用两个字节:第一个字节高4位固定为1,低4位是12位数据长度的高4位;第二个字节是12位长度的低8位。也就是说,FF_DL这个12位字段的最大值是0xFFF,也就是4095。注意,这个长度是指数据块中所有应用层数据的长度,不包含PCI本身,也不包含扩展地址。也就是说,TP层单次能够传输的最大数据块为4095字节。标题里说“4KB”,其实4096字节已经超过了4095,严格说TP层一次最多只能传4095字节,如果你想传4096字节,要么拆分成两个大数据块,要么用CAN FD的扩展长度。这是很多人在做协议栈时容易忽略的细节,后面我会专门展开讲。
FF的数据域中,除了两字节PCI,剩下的6字节(经典CAN)全部是首帧携带的数据,属于整个数据块的最开始部分。接收方拿到首帧后,第一件事就是把FF_DL记录为该次传输的总长度,然后判断缓冲区是否足够。如果不够,回一个溢出流控帧;如果够,回一个允许发送的流控帧。
2.3 连续帧与流控帧的细节
连续帧CF的PCI只有一个字节,高4位固定为2,低4位是序列号SN(Sequence Number)。SN只有4位,范围0-15,从0开始,每发送一个CF,SN加一,到15之后回绕到0。接收方靠这个SN来检测是否丢帧。比如接收方收到SN=3的CF,再下一帧的SN必须是4,如果是5,说明漏了一帧;如果是3,说明重复发送了。造成SN错乱的原因很多,比如总线干扰、接收缓冲区溢出、发送超时等,协议栈接收到错误SN时,需要按标准的要求丢弃当前传输,并向上层上报错误。
流控帧FC的PCI第一个字节高4位固定为3,低4位是流控状态FS(Flow Status)。FS有3个值:
| FS值 | 含义 | 说明 |
|---|---|---|
| 0(CTS) | 允许发送 | Continue To Send,接收方表示可以继续发连续帧 |
| 1(WT) | 等待 | Wait,接收方还没准备好,让发送方等待 |
| 2(OVFLW) | 溢出 | Overflow,接收方缓冲区溢出,无法接收,传输中止 |
FC帧除了PCI字节外,后面还必须跟两个字节:BlockSize(BS)和SeparationTime(STmin)。所以一个FC帧的数据域至少是3字节,即PCI+BS+STmin,即使后面没有实际数据。BS和STmin详解见4.1节。
2.4 CAN FD对TP层的影响
前面说的都是经典CAN下8字节数据域的TP层行为。CAN FD可以支持最多64字节数据域,所以ISO 15765-2也有专门针对CAN FD的适配。在CAN FD下,SF最大可传63字节(64-1),FF可以携带62字节数据,CF可以携带63字节数据。TTCAN?实际开发中,很多新车型已经在用CAN FD做诊断,TP层的收发逻辑与经典CAN类似,只是长度参数要相应调整。但无论经典CAN还是CAN FD,TP层的分段思想是完全一致的,理解了8字节版本,换到64字节版本只是把“每帧可携带数据长度”从7变成63,把FF长度上限从4095变成更大的值(CAN FD TP的长度字段依然是12位还是更长?标准中有扩展)。但用经典CAN来讲解机制最清晰。
3. 用例子拆解:4KB数据的分段传输全过程
3.1 一个典型场景:通过0x36服务写入4KB固件数据
汽车诊断中非常常见的操作是刷ECU固件。刷写流程大致是:先通过会话控制(0x10)切换到编程会话,再通过安全访问(0x27)解锁,然后利用请求下载(0x34)告诉ECU“我准备写入一个地址范围,数据总大小是多少”,得到肯定响应后,用传输数据(0x36)一包一包地发送实际固件内容,最后用请求传输退出(0x37)结束。
其中0x36的请求格式是:第一个字节是服务ID(0x36),第二个字节是块序列号,从1开始递增,后面跟的就是用户要写入ECU的数据。假设我们要写入4096字节的固件数据,一次0x36请求的缓冲区根本不可能装下,所以应用层会把这个大数据块再切分成多个小请求,每个小请求比如512字节或者1KB,这样每个小请求经过TP层时,长度都不会超过4095字节的限制。但即使应用层把数据切成1KB的块,对TP层来说,1KB依然超过8字节CAN帧的承载能力,所以TP层仍然要对该1KB块做分段。
下面我们用一个更聚焦的视角来看:应用层向TP层提交了4096字节(或者4000字节,以满足最大限制)的数据块,TP层如何把它发送到总线上。
3.2 完整时序:首帧、流控、连续帧、再流控、再连续帧
假设我们使用普通寻址(无扩展地址),经典CAN 8字节数据域,数据块长度为4000字节(为了不超过4095,我们用4000做例子)。发送端TP层首先发出首帧FF,FF的数据域结构为:
| 字节位置 | 内容 | 说明 |
|---|---|---|
| Byte0 | 0x1F | 高4位=1表示FF,低4位=0xF(长度4000的高4位,4000=0x0FA0,高4位就是0x0,低4位是0x0?等等)。 |
这里需要认真计算。4000的十六进制是多少?4000 = 0x0FA0。12位长度字段的二进制表示是111110100000?不对,0xFA0是12位?0xFA0是十六进制三位,0xFA0 = 4000?0xFA0 = 4000,确实12位二进制是111110100000。那么高4位是0xF?不对,12位二进制1111 1010 0000,高4位是0xF(1111),低8位是0xA0。所以首帧PCI第一个字节的高4位是1,低4位是长度高4位0xF,组成0x1F;第二个字节是长度低8位0xA0。所以首帧数据域前两个字节为 0x1F 0xA0,后面6个字节是数据块最开始6个字节,即数据字节0-5。
接收方收到FF后,解析出FF_DL=4000,表示这个数据块总共4000字节。接方TP层检查接收缓冲区,如果够,就发一个FC(流控帧),数据域一般为 0x30 0x08 0x20?具体值由配置决定。这里BS=8表示“允许你连续发8个连续帧,然后必须停下来等我下一个流控”,STmin=0x20表示每个连续帧之间的最小间隔为32×0.1ms=3.2ms。有了这个FC,发送方开始发连续帧。
第一个CF的PCI为0x21?不对,CF的PCI高4位=2,低4位=SN=1,所以是0x21,但SN从0还是1开始?标准中CF的SN第一个值为0。第一种说法是SN从0开始,连续帧的第一个SN=0,第二个=1。实际上ISO 15765-2规定CF的SN计数器从0开始,第一个CF的SN=0,之后每次+1。有很多协议栈实现时从0开始。那么第一个CF的PCI是0x20。后续CF的SN依次为1、2、3...15、0、1...。
CF每帧携带7字节数据。第一个CF尽量把上一帧没装下的数据及时发出去?实际上首帧已经带走了6字节数据,那么连续帧的数据承载应该是从第6字节开始。不过在某些实现中,发送方会先把数据的前13字节分割为“FF带6字节+第一个CF带7字节”,CF序号0对应数据字节6-12,CF序号1对应数据字节13-19,以此类推。接收方在重组时,就是按“首帧数据 + 连续帧数据按SN顺序”拼接。
发送方每发完8个连续帧,如果BS配置为8,就暂时停下来,等待接收方再次发送FC。接收方缓冲区又腾出来了,再发一个FC,发送方继续发。这样循环,直到发送完所有数据。
整个时序用文字描述就是:
- 发送方发送FF(首帧),携带长度信息和前6字节。
- 发送方等待FC(流控帧),此等待有时间上限,即N_Br超时。
- 接收方解析FF,确认缓冲区足够,发送FC,FS=0(CTS),BS=8,STmin=20ms。
- 发送方收到FC,开始发送8个CF,SN从0到7,帧间间隔不小于STmin。
- 发送方在发送完SN=7的CF后,停止发送,等待新的FC。
- 接收方收完8个CF,处理缓冲区,再次发送FC,允许下一批8个CF。
- 循环直到所有4000字节发完。
3.3 计算具体需要的帧数
来算一笔账。如果数据块长度4000字节,首帧携带6字节,那么剩余数据量为3994字节。每个连续帧最多携带7字节(经典CAN无扩展地址情况下),则需要的连续帧数量为 ceil(3994 / 7) = ceil(570.571) = 571帧。加上1个首帧,发送方总共发送572帧,期间接收方会发送若干流控帧。流控帧的数量取决于BS:如果BS=0,表示没有块限制,发送方可以一口气发完所有CF,中途不需要再等FC,只需满足STmin;这样接收方只发一个FC。如果BS=8,则发送方每发8个CF就要等一次FC,571个CF需要等待多少次?571/8=71.375,向上取整为72次,也就是说除了第一次FC外,还会额外再需要71次FC(因为首批8个后,后续每批8个前都要FC),总共72个FC。所以总帧数为572+72=644帧。
看到没,4000字节的数据,最后底层的CAN帧达到了六百多帧。在500kbps波特率下,每帧大概占用约0.26ms?实际算下来,即使STM持续最优,整个传输过程也是毫秒级的。但如果你把STmin设得很大,比如50ms,那刷写速度会拖慢到难以忍受。所以很多针对刷写的工程标定会把STmin压缩到最小,同时用CAN FD来大幅减少帧数。
3.4 接收方向的重组过程
接收方的TP层不是把所有数据一股脑堆进一个临时数组就算完成,它需要一个状态机。收到首帧后,进入“多帧接收”状态,创建接收缓冲区,并把FF携带的6字节拷贝到缓冲区起始处。然后每收到一个连续帧,根据SN值判断是否合法,把该CF携带的7字节数据拷贝到缓冲区中偏移为“6 + N7”的位置(N为该CF所在整体序号)。比如第一个CF(SN=0)的数据拷贝到偏移6+07=6处,第二个CF(SN=1)拷贝到偏移13处。当缓冲区填充的字节数达到FF_DL声明长度时,接收完成,将整个4000字节的数据块上交给应用层,并退出多帧接收状态。
注意这里有一个关键点:接收方必须知道传输何时结束。由于FF_DL是明确的总长度,接收方只要统计收到的有效数据字节数达到FF_DL,就自动认为传输完成。如果接收方还收到了额外帧,就会认为是错误并丢弃。另外,如果FF_DL是4095,但实际收到的连续帧总数据超过了这个长度,说明有重复帧,也要做错误处理。
4. 关键参数与配置:BS、STmin、寻址和超时
4.1 BlockSize和STmin:发送节奏的核心拧动器
流控帧中的BS和STmin是决定传输效率和稳定性的关键参数,下面解释它们的具体含义和控制逻辑。
BS(BlockSize)是一个8位无符号数,范围0-255,它表示在本次连续发送中,允许连续发送的连续帧最大数量。在发送方收到一个FC后,可以连续发送CF,但最多发送BS个,之后必须暂停,等待下一个FC。如果BS=0,表示没有块大小限制,发送方收到FC后可以连续发送所有剩余CF,除非发完或者发生错误。这个机制的目的,是让接收方不因长期占用总线而失去对缓存的更新控制。但BS=0时,接收方必须保证自己的缓冲区能装下全部数据,否则在软件层面需要做“边收边处理”,典型场景是刷写时,ECU把收到的数据写入Flash后即可腾出缓冲区,从而允许BS=0。
STmin(SeparationTime)也是一个8位数,它表示两个连续帧(这里指的是连续帧之间)的最小时间间隔。STmin的值域有两个区段:0x00到0x7F表示0ms到127ms,每单位0.1ms?这里有坑。很多人都会记混。让我准确地讲:STmin编码规则如下:
| 编码范围 | 含义 |
|---|---|
| 0x00 - 0x7F | 0 ms 到 127 ms,每1个单位1ms?不对,查标准:STmin范围是0x00到0x7F时,时间=编码值 ms,不是0.1ms。0x00表示无间隔,0x01表示1ms,0x7F表示127ms。 |
| 0x80 - 0xF0 | 保留(或用于CAN FD扩展) |
| 0xF1 - 0xF9 | 100us到900us,以100us为步长。0xF1表示100us,0xF9表示900us。 |
是的,标准ISO 15765-2中STmin在0x00-0x7F区间表示0-127ms,步长1ms;0xF1-0xF9区间表示100us-900us,步长100us。有些资料把0x00-0x7F说成0.1ms步长,那是错误记忆。实际标准TC8测试里也会专门检查STmin的取值和实现是否正确。发送方必须满足至少等待STmin后再发下一帧CF,如果小于这个间隔,接收方可能因为还没来得及把上一帧从硬件FIFO搬到软件缓冲区而造成丢帧。
BS和STmin通常由接收方在FC中指定,也就是说,接收方掌握了传输节奏的控制权。发送方必须绝对服从接收方给出的BS和STmin,这也是一种“被动流控”。与此对应的是,发送方自己不需要在FC里设置任何参数,因为它不接收对方的大数据(除非是双方互传)。
4.2 正常寻址与扩展寻址:TP层数据域里的隐藏头
ISO 15765-2支持两种寻址方式:正常寻址(Normal Addressing)和扩展寻址(Extended Addressing)。正常寻址下,CAN报文的数据域直接以PCI字节开始。扩展寻址下,CAN报文数据域的第一个字节是一个额外的地址字节(N_AE,Network Address Extension),PCI从第二个字节开始。
为什么要扩展寻址?在物理寻址的直接点对点通信中,目标ECU由CAN ID唯一确定,不需要额外地址。但在功能寻址(比如发广播诊断请求)中,多个ECU可能共享同一个CAN ID,此时用N_AE来区分具体要访问的ECU内部的某个逻辑实体(比如不同的应用模块)。还有一种场景是诊断仪和ECU之间存在网关,网关需要额外地址来路由。总之,如果使能扩展寻址,每一帧实际可传输的数据长度都会减一:单帧最多6字节,首帧数据5字节,连续帧6字节。因为每个帧中都要多带一个地址字节。
这里要特别提醒,很多诊断项目同时使用功能寻址和物理寻址,但功能寻址通常只用于单帧请求(比如3E 00),真正的大数据传输(0x36刷写)几乎总是走物理寻址点对点,所以大多数项目实际上用不到扩展寻址。但协议栈要支持,因为某些OEM要求ECU作为网关时,用N_AE区分不同功能台架。
4.3 网络层定时参数:别让超时坏了好事
TP协议里定义了一组时间参数,用来说明不同状态下的最大等待时间,具体如下:
| 参数 | 含义 | 典型值(参考) | 计时起点、终点 |
|---|---|---|---|
| N_As | 发送方发出一个帧(SF/FF/CF/FC)的最长时间 | 未约束?通常50ms | 应用层发送请求到帧真正发出 |
| N_Ar | 接收方接收一个帧的最长时间 | 50ms? | 帧开始接收到应用层接收指示 |
| N_Bs | 发送方等待接收方FC的最长时间 | 1000ms | 发送方发出FF后到收到FC |
| N_Br | 接收方等待发送方发来FF的最长时间 | 1000ms | 接收方开始多帧接收(比如从等待首帧状态)到收到FF |
| N_Cs | 发送方等待一个新的FC(在BS块之间)的最长时间 | 1000ms | 发送方发完一个块后等待下一个FC |
| N_Cr | 发送方发送连续帧之间的最大间隔?以及接收方接收连续帧之间的超时 | 1000ms | 两个连续帧之间的间隔,或接收方收到一个CF后到下一个CF |
需要特别注意的是,在ISO 15765-2标准中,这些时间参数的具体值不是强制的,标准只是定义了默认值范围,一般量产ECU会定义得更严格。我做过的一个项目里,把N_Bs设为500ms,N_Cs设为500ms,N_Br和N_Cr设为1000ms,测试时为了验证超时处理,故意用工具抑制FC回包,让发送方触发了N_Bs超时,上层收到了网络层错误指示,这是很典型的协议栈测试用例。实际标定中,如果总线负载较重、报文被调度延迟,需要留足余量,但也不能太大,否则会让诊断故障潜伏很久才暴露。
4.4 错误处理:溢出、超时和序列号异常
TP层除了正常传输,还要处理各种异常。一条完整的错误处理清单包括:
- 接收方缓冲区溢出:当FF_DL大于接收缓冲区可用空间时,接收方发送FC状态=2(OVFLW),然后丢弃当前传输。
- 接收方接收连续帧超时:如果在一个多帧接收过程中,超过N_Cr时间没收到下一个CF,接收方判定超时,中止接收并向上层报告。
- 发送方等待FC超时:发送方发出FF后,超过N_Bs没收到FC,或者发出一个块后超过N_Cs没收到下一个FC,发送方中止发送并报告错误。
- 序列号错误:接收方收到的CF序号与期望不符,可能是丢帧或者重复帧,此时接收方应当中止当前接收,并可以视条件向应用层报错,协议栈一般不发送FC,因为已经无法挽回。
- 未知PCI类型:如果PCI高4位是4-F,标准未定义,接收方应立即丢弃该帧。
对这些错误的处理机制,会直接影响诊断通信的可靠性。一个成熟的协议栈,应该把错误事件用统一的网络层原语上报给上层,比如N_USData.ind(有数据到的指示)和N_USData.error.conf(发送错误确认)。而不是只靠日志输出。
5. 实操中的协议栈开发要点
5.1 发送状态机设计:从空闲到完成
我做过一个自研的UDS栈,TP层发送状态机大概有这几个状态:TP_IDLE、TP_SF_SEND、TP_FF_SEND、TP_WAIT_FC、TP_CF_SEND、TP_WAIT_NEXT_FC、TP_SEND_DONE。伪代码大致如下:
void sender_tick(void) { switch (sender_state) { case TP_IDLE: if (req_pending) { if (req_len <= 7) { // 单帧条件 build_sf(req_len); state = TP_SF_SEND; } else { build_ff(req_len); state = TP_FF_SEND; start_N_Bs_timer(); } } break; case TP_FF_SEND: // 发送FF后,状态切换为等待FC state = TP_WAIT_FC; break; case TP_WAIT_FC: if (received_fc) { if (fc_fs == 0) { // CTS bs_remaining = pdu.fc_bs; stmin = pdu.fc_stmin; state = TP_CF_SEND; } else if (fc_fs == 1) { reset_N_Bs_timer(); // 继续等 } else { set_error(TX_OVERFLOW); // 接收方说溢出了 state = TP_IDLE; notify_upper(); } } else if (N_Bs_expired()) { set_error(TX_TIMEOUT_BS); state = TP_IDLE; notify_upper(); } break; case TP_CF_SEND: // 发送一个CF,SN递增,检查块剩余,控制STmin if (bs_remaining > 0) { send_cf(sn++); bs_remaining--; start_N_Cr_timer(); enfore_stmin_delay(); } if (all_data_sent) { state = TP_IDLE; notify_complete(); // 等上层读取发送确认 } else if (bs_remaining == 0) { state = TP_WAIT_NEXT_FC; start_N_Cs_timer(); } else { // 同一块内继续发送 } break; case TP_WAIT_NEXT_FC: if (received_fc) { bs_remaining = pdu.fc_bs; state = TP_CF_SEND; } else if (N_Cs_expired()) { set_error(TX_TIMEOUT_CS); state = TP_IDLE; notify_upper(); } break; } }这个状态机最关键的一点是“发完BS个CF后必须等待下一个FC”的强制约束。如果BS=0,bs_remaining初始化为一个大数,比如0xFFFE,这样发送方就可以连续发完全部,但实际上协议栈内部还是要在每个CF之间检查STmin。
5.2 接收状态机设计:缓存和重组
接收端同样是一个状态机,状态包括:IDLE、WAIT_FF、WAIT_CF、DONE、ERROR。收到SF时直接拷贝数据并上报,收到FF时解析长度、检查缓冲区,进入WAIT_CF状态并回复FC,收到CF时判断SN并拷贝数据。需要注意,SN计数器是逐帧递增的,判断是否等于期望值(也可以用一个变量expected_sn)。如果收到的CF的SN等于expected_sn,正常处理;否则丢弃或者中止传输。
接收缓冲区设计是另一个常见难点。最简单的是静态分配一个缓冲数组,大小等于最大接收长度,比如4096字节。但项目中有多个PDU(协议数据单元)同时收发,如果每个连接都分配4096字节,内存开销巨大。多数做法是分配合并内存池,或者用链表式缓冲区。我当时的做法是:每个连接分配一个4096字节的静态缓冲,并把连接数量限制在4个内。对于ECU来说,一般同时只有1-2个诊断连接,所以内存可控。
关键细节:在接收多帧时,如果应用层处理比较慢,而缓冲区已经收到了一半数据,再来新数据可能会写入同一个缓冲区,如果应用层正在读取,就会产生竞争。所以在协议栈中,常用“双缓冲”机制,即一个缓冲区在接收,另一个缓冲区等待上层读取。当接收完成,交换两个缓冲区的角色,从而避免数据被覆盖。很多入门工程师忽略这一点,导致高负载下出现随机数据错乱。
5.3 与上层UDS服务的接口实现
TP层和UDS层之间的接口通常按ISO标准定义的服务原语来抽象。发送数据叫N_USData.req,接收数据叫N_USData.ind,发送结果确认叫N_USData.conf。伪代码:
// 上层发送 void uds_send(uint8_t *data, uint16_t len) { n_usdata_req(data, len); // 当TP层发送完成或出错时,调用 n_usdata_conf(error_code); } // TP层接收完成 void tp_receiver_on_data(uint8_t *data, uint16_t len) { n_usdata_ind(data, len); // 通知UDS层处理 }在实际工程中,TP层和UDS层还会共享一些信息,比如当前工作模式(是否处于刷写流程)。这不是协议要求,但常常有帮助。例如刷写时,接收方可以设置BS=0,原因是ECU收到一个CF后立即写Flash,腾出缓冲区,于是允许发送方不停发送,提高了吞吐量。这种情况下,接收方的TP层要支持“边收边处理”模式,即每收一帧CF就把7字节搬运到Flash缓存区,而不是全部收完再让UDS层处理。这个能力直接影响刷写速度。
5.4 实战案例:Bootloader刷写中的TP层优化
某项目里,ECU需要刷写500KB的固件。如果用经典CAN,每帧最多7字节,那么总帧数大约7万帧,即使在500kbps下按最低延迟发送,也需要几秒。实际刷写还要考虑Flash写入时间、STmin限制和流控开销,往往需要几十秒。我们当时做的一项优化是:从默认的BS=32、STmin=10ms调整为BS=0、STmin=0x00(经典CAN中0x00表示无间隔)。但直接在总线上把STmin设为0,接收端硬件FIFO可能应付不过来,因为ECU的CAN控制器FIFO深度有限,CPU中断响应需要时间。后来我们做了测试,将STmin设为0x00,同时提高接收中断优先级、减少钩子函数阻塞,才成功把刷写时间从20秒压缩到9秒。可见TP层参数的标定不是死记硬背,而是要结合硬件能力和实时性。
另一个坑是首帧中的长度字段。当用工具发送大于4095字节的单块数据时,FF长度的高4位和低8位必然溢出,一些协议栈实现得不够严谨,就会截断。比如有工程师试图用0x36一次传输4096字节,结果长度字段写入4096,12位无法表达,实际变成了0。接收方收到长度为0的数据块,整个协议栈崩溃。正确做法是应用层必须把数据切成小于等于4095字节的块。ISO标准这个边界是硬性约束,任何协议栈都不会违反。
6. 常见问题与排查技巧实录
6.1 连续帧序号跳变:先看硬件FIFO,再看软件缓存
有一次在实验室用多路CAN工具监听刷写过程,发现ECU收了几十个连续帧后,突然上报7F服务错误。抓包分析发现,中间有一帧CAN报文在物理层被仲裁后丢失?其实看总线负载并不高。后来检查代码发现,接收中断中读取CAN控制器FIFO时,由于疏忽读满了8个报文后没有及时清掉溢出标志,导致寄存器锁死,后续报文无法读取,于是SN跳变。解决方法是完善FIFO管理的临界区保护,以及调整接收中断的优先级。再次提醒:接收方的内存和FIFO是TP层最大的敌人。
6.2 流控帧迟迟不来,发送方超时退出
在某台架上测试时,诊断仪向ECU发大数据块时总是报超时。排查过程:先看CAN总线上有没有ECU的流控帧。发现ECU的TP层根本没有发FC,说明接收端状态机没进入多帧接收模式。进一步查看代码,发现首帧中的地址匹配条件写错了,用的屏蔽位把首帧的PCI也拿去匹配了,导致首帧被当成无效报文丢弃。修复匹配条件后,流控帧正常发出。这个问题提醒:不要忽略TP层帧的地址过滤逻辑,尤其是带有扩展地址的项目。
6.3 如何快速抓包分析TP层问题
对TP层排障,CANoe或者PCAN等工具都非常方便。要会设置过滤器:抓包时同时勾选物理寻址ID和功能寻址ID。对于多帧传输,建议将显示列增加“PCI类型”“帧类型”“数据长度”等信号,或者直接在Trace窗口用文本模式观察。我常用的方法是写一个CAPL脚本,在收到帧时解析数据域第一个字节,打印出“FF: len=xxxx”或者“CF: SN=x”,这样一眼就能看出哪一帧出了问题。自己实现过的功能,排查起来往往事半功倍。
6.4 超过4095字节的终极解法
如果确实需要一次传输超过4095字节,有几种办法。第一种,应用层主动切片,比如每片4KB或更小,分多个0x36子请求传完;第二种,切换到CAN FD的ISO 15765-2,长度字段支持的更大(CAN FD TP的FF长度可达2^28-1?实际标准中CAN FD支持的传输大小大大增加,但也是有限制的);第三种,使用ISO-TP之上的分包传输协议,比如刷写协议层面定义块号和数据块长度。在实际ECU刷写中,通常就是将固件分成多个数据块,每个数据块经由一次0x36请求,每次请求不超过4095字节,这是行业最常见的做法。
6.5 一个容易被忽略的小坑:STmin的默认配置
不少入门工程师在写协议栈时会忘记在FC中设置STmin,或者把STmin默认值设成0x7F(127ms),结果导致刷写速度惨不忍睹。但反过来,如果全面放开到0x00,又容易打爆接收方缓冲区。更稳妥的做法是根据接收方实际处理能力动态设定,比如在刷写时减小STmin,在普通诊断时保持一个折中值。可以在产品化的协议栈中暴露配置接口,方便标定工程师根据车型调整。
7. 最后想说的话
做TP层协议栈,最需要注意的还是把ISO 15765-2的标准细节吃透,不要只看示例代码。尤其是BS和STmin的语义、SN的回绕处理、多帧传输中超时参数的选择,这些是测试工程师最爱出难题的地方。我个人的经验是:先把单帧跑通,再把多帧接收跑通,最后再优化时序参数。边界条件测试一定要覆盖:长度刚好为1、7、8、4095、4096,接收端缓冲区刚好比数据块小1字节,FC一直发WT,SN回绕……把这些场景统统用例化,协议栈的健壮性才有保障。
如果时间允许,建议自己从零写一个最小可用的TP层,不用很大,几十个函数就够了。这个过程会让你对CAN报文的理解上一个台阶。后续无论是调试量产问题还是做CAN FD适配,都会顺手很多。