前阵子在调一块商用车控制器,总线分析软件里突然涌进一大串相同PGN的帧,每帧的第一个数据字节按1、2、3……递增。我第一反应是某个报文在刷屏,冷静下来才发现,这是J1939-21里定义的传输层协议正在工作——它把一条远超CAN单帧8字节上限的消息拆成了一帧帧发出去。刚接触J1939协议族的人,很容易被“数据链路层里的传输层”这个概念绕晕;又觉得它不过是个简单分包器。实际上,J1939-21传输层承担着多包消息的拆分、重组、流控和会话管理,是ECU之间交换诊断、标定、广播类大块数据的基础设施。这篇文章就按我从读标准到写协议栈、再到抓包验证的完整路线,把这份经验详细拆一遍,适合正在做整车通信、控制器诊断或J1939协议栈开发的工程师参考。
1. 为什么J1939还要单独搞一个传输层:CAN 8字节硬壳的破局思路
1.1 CAN一帧只能装8个用户字节
CAN协议从诞生起,数据场最长的有效载荷就是8字节。J1939虽然使用CAN 2.0B的29位标识符,把寻址能力做得很丰富,但单帧能携带的用户数据上限依然是8字节。PDU格式再怎么换,传输层以下的空间就只有这么大。
然而ECU之间真正要交换的东西,远不止8字节:软件名称、诊断记录、标定数据、Bootloader固件块,短的几十字节,长的上千字节。如果每个应用都自己定义一套“第几块、共几块、校验码”之类的私有分包规则,总线上不同厂商的设备根本没法互通。J1939标准体系里就专门规定了J1939-21这一层,把分包、编号、重组、流控这些事统一做掉。以后任何一个ECU要和另一个ECU交换大块数据,都按同一套规则来,兼容性才有保障。
1.2 传输层到底解决哪几件事
可以把传输层的工作拆成四个问题来看:
- 拆分:把一条超过8字节的消息,切成多帧。每帧TP.DT实际只携带7字节有效数据,剩下的1字节用来放包序号。
- 重组:接收方根据启动帧里的总长度和总包数,把收到的数据块按包序号拼回完整的消息。
- 确认与流控:点对点模式下,接收方通过请求发送/清除发送的交互来控制发送方的节奏,并在收完后发确认;广播模式下不做确认。
- 会话管理:同一时间可能有多个多包消息同时在总线上传输,TP层需要区分它们分别属于哪个会话,避免数据互相串扰。
这四个问题解决之后,上层应用就可以像读写一条完整消息那样使用TP,完全不用关心底层被拆成了多少帧。这也是为什么业内习惯把J1939-21的这套机制直接叫“传输层”。不过它和OSI模型里的传输层并不完全是一回事,它没有端口的概念,没有复杂的连接复用,更接近一种“大消息分包与恢复协议”。
提示:J1939-21传输协议定义在数据链路层相关文档里,日常交流中提到的“J1939-21传输层”“J1939传输协议”,指的就是TP.CM与TP.DT这一整套多包机制。理解这一点,后续读代码和抓包都不会跑偏。
1.3 一个字节序号和一个PGN决定了上限
很多资料会直接告诉你:J1939-21传输层的最大消息长度是1785字节。这个数其实可以自己推出来。TP.DT帧的第一个数据字节是包序号,范围是1到255,所以最多能发255包;每包除包序号外最多7字节有效数据;255乘以7正好是1785字节。
TP.DT对应的PGN是0xEC00,TP.CM对应的PGN是0xEB00,这两个值是我反复核对过的。搞懂这个推导之后,很多边界判断就能直接写对:如果上层丢来一条2000字节的消息,就不能指望TP层帮你“聪明地”无限分包,应用层必须自己把它拆成多条TP消息,一条条发。总长度字段在RTS/BAM里虽然占两个字节,但受包序号一字节限制,上限依然是1785。
2. 广播传输协议:不握手也能把长消息发出去
2.1 BAM的启动帧与数据帧
BAM全称是Broadcast Transport Message,目标地址固定为全局地址0xFF,也就是对总线上所有节点广播。整个传输分两步。
发送方先发一条TP.CM帧(PGN 0xEB00),它的第一个数据字节是控制字节0x20,代表TP.CM_BAM。控制字节后面依次是原始消息的PGN(2字节)、消息总长度(2字节)、总包数(1字节),最后两个字节保留,一般填0xFF。这条启动帧相当于广播宣言:“我现在要开始广播一条长度为多少、分成多少包的某PGN消息了,大家各自准备好接收缓冲区。”
随后发送方连续发送TP.DT帧(PGN 0xEC00),第一字节是包序号,从1开始每帧加1;后7字节是有效数据。如果最后一帧的有效数据不足7字节,剩余的位置按标准习惯用0xFF补齐。所有愿意接收的节点都可以独立根据启动帧里的总长度和总包数完成重组。
2.2 BAM为什么不需要确认
广播场景的目标是“让所有节点都看到”,而不是“确认某个节点确实收到”。如果每个接收方都回一条确认帧,发送方又用全局地址发数据,一堆确认帧会在总线上互相冲突,根本无法收敛。所以标准把BAM设计成纯单向传输,接收方不回复任何传输层确认。
代价当然也有:发送方不知道哪些节点成功拼出了完整消息,消息丢了也不会自动重发。但对于地址声明、软件识别这类信息,即使这次没收到,上层还可以通过再次请求把它重新拿回来,丢失的成本很低。因此BAM在工程中特别适合做“低价值但需要全车可见”的长消息广播。
2.3 BAM接收侧与超时管理
接收方收到TP.CM_BAM后,会以(源地址 + 原始PGN)为标识建立一个接收会话,然后按包序号从1开始顺序接收TP.DT。这里有几个容易被忽略的点。
第一是多个BAM并发。同一个源地址连续发两个BAM时,新启动帧来了,旧会话往往还没收完。标准没有强制规定该怎么办,工程上最常见的做法是释放旧缓冲区,按新启动帧重新开始。这个策略实现简单,也不会出现悬挂会话占着缓冲区不放的局面。
第二是帧间隔。BAM允许发送方自己决定发送节奏,有些ECU背靠背一口气发完,有些会在帧间插入几个毫秒的间隔。接收方不能假设帧间隔一定很短。尤其是经过网关转发后,帧间间隔可能被明显拉长,如果超时按“相邻两帧间隔”来算,误杀率会非常高。正确做法是把重组超时设定在整条消息级别,比如从收到启动帧开始算,几百毫秒到几秒内没有接收完成就清空会话,并上报给上层。
3. 连接模式传输:RTS、CTS、DT、EndOfMsgACK完整拆解
3.1 四次交互的完整会话
CMDT全称是Connection Mode Data Transfer,目标地址是一个具体ECU地址,不是0xFF。一次成功会话涉及四类消息:
- 发送方发TP.CM_RTS(控制字节0x10),里面带上原始消息的PGN、总长度、总包数。
- 接收方回TP.CM_CTS(控制字节0x11),里面包含“允许发多少包”和“下一个期望包序号”。
- 发送方按CTS要求,发若干帧TP.DT数据。
- 接收方收齐全部包后,发TP.CM_EndOfMsgACK(控制字节0x13),确认整条消息已收到并重组完成。
如果中途出现任何异常,任意一方都可以发TP.CM_Abort(控制字节0xFF)直接终止会话,并携带一个原因字节告诉对方为什么中止。
这段交互可以类比成货运物流:发货方先打电话问“我有一批货,总共X件,什么时候能送?”收货方回“仓库现在空着,你先送Y件过来”;送完一批,收货方再放行下一批;全部送齐,收货方签收确认。RTS就是问,CTS就是放行,DT就是送货,EndOfMsgACK就是签收单。
3.2 CTS窗口和“下一个期望包号”的流控逻辑
CTS里最关键的两个字段,一个是“允许包数”,一个是“下一个期望包序号”。“允许包数”决定发送方接下来最多能发多少帧;发送方必须严格遵守,发完这一批就停下,等新的CTS,不能自作主张继续发。常见实现把窗口设为16,因为16帧对应112字节有效数据,在双缓冲结构下接收方可以一边收数据一边搬运,不容易溢出。窗口也可以动态变化,第一批放行16,第二批放行8,完全由接收方当前的缓冲和处理速度决定。
“下一个期望包序号”则为跳号恢复留了口子:接收方如果发现中间缺了几包,可以在下一个CTS里要求发送方从缺失位置继续发。但很多设备并不支持这种中途跳号恢复,一旦发现包号不连续,直接按错误处理,要么Abort,要么等到超时后把会话丢弃。做兼容性测试时,我建议代码按“顺序到达”为基准设计,同时预留处理跳号的逻辑,这样遇到特殊设备时不至于直接崩。
3.3 四种TP.CM控制帧的字节布局
在8字节数据区里,TP.CM的所有控制帧都以Data[0]区分类型:
- RTS:Data[0]=0x10,Data[1..2]为目标PGN,Data[3..4]为消息总长度,Data[5]为总包数,Data[6..7]保留;
- CTS:Data[0]=0x11,Data[1]为允许发送包数,Data[2..3]为下一个期望包序号,Data[4..7]保留;
- EndOfMsgACK:Data[0]=0x13,Data[1..2]为目标PGN,Data[3..4]为消息总长度,Data[5]为总包数,Data[6..7]保留;
- Abort:Data[0]=0xFF,Data[1..2]为被中止PGN,Data[3]为中止原因,Data[4..7]保留。
这里有两个容易混的点:RTS和EndOfMsgACK里都带PGN、总长度、总包数,但含义不同,RTS是发送前预告,ACK是收完后确认;Abort的PGN表示被中止的会话所对应的PGN,原因字节则让对端快速判断是缓冲不足还是忙、重复请求之类的状态。整理成表格会更直观:
| 控制帧 | 控制字节 | 关键内容 |
|---|---|---|
| RTS | 0x10 | PGN、总长度、总包数 |
| CTS | 0x11 | 允许包数、下一个包序号 |
| EndOfMsgACK | 0x13 | PGN、总长度、总包数 |
| Abort | 0xFF | 被中止PGN、中止原因 |
3.4 我用状态机实现CMDT的简化思路
我自己的实现里,发送端状态机大概长这样:
typedef enum { TP_IDLE, TP_WAIT_CTS, TP_SEND_DT, TP_WAIT_ACK, TP_ABORTED } tp_state_t;从IDLE收到上层发送请求,发RTS进入WAIT_CTS;收到CTS后根据“允许包数”和“下一个期望包序号”进入SEND_DT;发完本批包后,如果没收到EndOfMsgACK就退回WAIT_CTS等下一个CTS,如果收到ACK就回到IDLE;任何超时或Abort都进入ABORTED并通知上层。
接收端则反过来:收到RTS先检查缓冲区够不够,能收就回CTS,不能收就回Abort;然后等DT,按包号写入缓冲区,到达总包数后发EndOfMsgACK并通知上层取数据。这里有个容易忽略的点:发送端发出RTS后,每个到来的CTS都要检查“期望包序号”是否等于自己刚发完的包号加1,如果对不上,说明接收方跳号或者自己漏发了,必须按协议约定的方式恢复或中止,不能无脑从0重新开始。
4. 从总线抓包看一次完整的TP会话
4.1 场景与时间线
用一个虚构场景把上面的流程串起来:A节点源地址0x12,B节点源地址0x34。A向B请求一段长度32字节的参数组,假设该参数组PGN是0xFECA。整个抓包时间线如下:
| 时刻 | 帧方向 | 帧类型 | 关键内容 |
|---|---|---|---|
| t0 | A→B | 请求PGN(单帧,PGN 0xEA00) | 请求0xFECA |
| t1 | B→A | TP.CM_RTS(PGN 0xEB00) | 控制字节0x10,总长度32,总包数5 |
| t2 | A→B | TP.CM_CTS(PGN 0xEB00) | 控制字节0x11,允许包数5,下一包号1 |
| t3~t7 | B→A | TP.DT(PGN 0xEC00) | 包号1~5,每帧7字节有效数据 |
| t8 | A→B | TP.CM_EndOfMsgACK(PGN 0xEB00) | 控制字节0x13,确认完成 |
注意t2的CTS允许包数是5,因为总包数只有5,接收方一次放行全部;如果总包数是50,常见做法是先放行16、16、16、2这样的分批放行。整个会话期间,A和B之间可能还有其他单帧消息穿插,TP.DT帧不要求在时间上绝对连续,只要包序号顺序正确,接收方就能正确重组。
4.2 字节级展开
把t1的RTS帧按字节拆开看:
| 字节位 | 值 | 含义 |
|---|---|---|
| Data[0] | 0x10 | 控制字节,表示RTS |
| Data[1..2] | 0xCA 0xFE | 目标PGN 0xFECA,低字节在前 |
| Data[3..4] | 0x20 0x00 | 总长度32字节,低字节在前 |
| Data[5] | 0x05 | 总包数 |
| Data[6..7] | 0xFF 0xFF | 保留位 |
CTS帧的字节布局类似:Data[0]=0x11,Data[1]=允许包数0x05,Data[2..3]=下一包序号0x0001,低字节在前,后面全是0xFF。
TP.DT第一帧:Data[0]=0x01,Data[1..7]是原始数据的前7字节;第二帧Data[0]=0x02,Data[1..7]是第8到第14字节;依此类推。第五帧Data[0]=0x05,Data[1..7]按长度32字节算,只有第29到第32字节是有效数据,第33到第35字节填0xFF。接收方拿到这5帧后,把数据字段按包序拼接起来,按总长度32字节截断,就还原出完整消息。
4.3 抓包里最容易看走眼的地方
新手最容易把一长串TP.DT帧(同一PGN、仅有包序号在变)当成“同一个报文重复发送”。判断方法很简单:看数据第一个字节是不是从1开始递增,如果递增,那是在TP传输,不是刷屏。
另一个坑是工具自动重组。很多总线分析软件会自动把多帧TP重组为一条完整消息,导致切换“原始CAN帧”和“解析后消息”视图时数量对不上。我建议调试初期把自动重组功能关掉,先看裸帧,等理解了每一帧的样子,再打开自动重组做校验。否则出了问题,很难分清楚是协议栈的bug还是工具显示的问题。
还有一个细节和寻址有关:BAM的TP.CM和TP.DT帧,目标地址都是0xFF;而CMDT的这些帧,目标地址是接收方的具体地址。同样一个PGN 0xEB00的TP.CM帧,因为目标地址不同,含义可能完全不同:DA=0xFF时控制字节0x20是BAM,DA为具体地址时控制字节0x10是RTS。在过滤器里如果只看PGN不分目标地址,统计结果会错乱,这一点实测中经常遇到。
5. 写传输层代码时最容易踩的坑
5.1 包序号从1开始,不是从0开始
这个坑埋得很深。协议里TP.DT的包序号从1开始,最大255。很多工程师按编程习惯从0递增,结果接收方明明收到了5包,却一直在等“第6包”,因为0被当成了非法值或保留值。而且跳号问题在实车上特别隐蔽,因为总线上并发消息多,单看一两次抓包根本发现不了。等上了台架或整车,偶发性的重组失败才会冒出来。处理办法就是在发送路径上加断言:第一帧的包序号必须是1,不是1就直接报错。这个小检查能省掉很多抓包排错的时间。
5.2 最后一帧填充字节要统一用0xFF
原始消息长度不一定是7的倍数,最后一帧的7字节不一定能填满。标准里惯用0xFF填充,因为0xFF在J1939中常表示“空/无效/暂无数据”,和0x00有语义差别。有些实现图省事填0x00,单独看好像没毛病,可一旦对端固件里存在“看到0x00就当作提前结束”的特殊处理,就会出现解析错位。发送侧统一填0xFF,接收侧严格按照总长度字段截断,不要依赖填充字节判断消息是否结束,这样兼容性最好。
5.3 超时参数不能照抄,要自己标定
TP层至少涉及三个超时:发RTS后等第一个CTS;发完一批DT后等下一个CTS;等待EndOfMsgACK。很多参考代码把三者统一设成500ms,实验室里能用,放到实车上不一定行。不同ECU的任务调度周期、网关转发延迟都不一样。
我见过最夸张的情况是某个网关设备转发一条TP会话用时接近1秒,如果用500ms超时,几乎每回都会判定超时中止。处理办法是在开发阶段把每个超时单独做成可配置项,用多组设备实测后再定默认值。不要怕调参数麻烦,这一步做扎实了,实车联调会省很多事。
5.4 缓冲区不够时尽早Abort,不要硬收
接收方收到RTS时就能看到总长度,立刻能判断自己到底装不装得下。如果装不下,正确做法是回Abort并带上原因,比如缓冲不足,让发送方尽早结束。有的实现不管三七二十一先回CTS,结果收到一半缓冲区溢出,后面只能靠超时清理,又占总线又浪费时间。
同理,收到启动帧时还要校验总长度和总包数的关系:长度应该落在1到1785之间,总包数应该等于向上取整(总长度/7)。如果对不上,说明帧被破坏或实现有bug,直接Abort比硬着头皮收更安全。
5.5 同一会话键的并发冲突
理想状态下,一个源地址、一个目标地址、一个PGN的组合,同一时刻只会有一个活动会话。但真实总线上,同一个PGN既可能被周期性BAM广播,又可能被某个节点的点对点请求触发CMDT。
我建议接收方的会话表这样建:BAM按(源地址 + PGN)区分,CMDT按(源地址 + 目标地址 + PGN)区分。新会话启动时如果和旧会话冲突,优先用新启动帧重建缓冲,同时向上层报告“旧会话被抢占”,避免上层拿着拼了一半的消息去解析。这个策略实现简单,也不会出现悬挂会话占着缓冲区不放的局面。
5.6 别把TP状态机放进CAN接收中断里做大动作
TP重组必然涉及数据拷贝和缓冲区管理。如果直接在CAN接收中断里做,会拉长中断处理时间,严重时直接丢其他报文。参考做法是中断里只做“帧类型识别、包序号记录、待处理标志置位”,把实际的数据搬运和状态转移放到优先级较低的任务里。
这样设计的好处是顺带给TP层做了超时管理:任务每隔10ms扫描一次会话表,把超时会话清理掉。发送侧的窗口控制也一样,发完一批DT后要停下来等CTS,不能靠延时函数硬等,要用定时器驱动状态机。否则一个节点卡住,可能拖垮整个发送任务。
6. BAM与CMDT怎么选:选型逻辑与实际工程体会
6.1 选型判断表
| 维度 | BAM | CMDT |
|---|---|---|
| 目标 | 全局广播(0xFF) | 单个目标地址 |
| 是否确认 | 无确认 | 有EndOfMsgACK确认 |
| 流控 | 无,发送方自行决定节奏 | 有CTS窗口流控 |
| 可靠性 | 不保证每个节点收到 | 可确认接收完成 |
| 典型用途 | 地址声明、软件识别、周期广播 | 点对点请求响应、标定、固件下载 |
| 实现复杂度 | 低 | 高 |
如果应用不关心某个接收者是否真的收到了完整消息,只要“发出去”就行,BAM是最省事的方案;如果双方需要可靠交付,而且目标地址明确,CMDT是标准答案。选型时还要考虑总线负载:BAM一次广播会占用相当数量的连续帧,如果多个ECU同时广播,更容易造成短时总线拥塞;CMDT因为有点对点确认,会话之间可以更有序地交错。
6.2 响应方式要跟着请求走
实际工程里经常出现同一个PGN既可能被单播请求,也可能被全局请求。标准上主流的处理方式是:如果请求帧的目标地址是一个具体ECU,多包响应走CMDT;如果请求帧是全局请求(目标地址0xFF),多包响应走BAM。
但总线上确实存在一些设备不管请求方式,统一用BAM回。做兼容性测试时,我会把单播和全局请求两种情况都覆盖,并把响应方的“请求类型”作为判断依据,而不是默认所有设备都遵守同一条路径。写上层应用时也需要注意:不要因为上一台设备用BAM回,就认定另一台设备也会用BAM回;同一套逻辑里两条路径都要支持。
6.3 什么时候根本不需要传输层
能用单帧装下的消息,就不要动用TP。判断标准很直接:消息总长度小于等于8字节,直接用普通CAN帧发送;超过8字节,才走TP。协议栈的API入口建议加一层保护:长度非法、长度超过1785、目标地址非法时,直接返回错误,而不是尝试发送。
请求和确认类控制消息本身几乎总是单帧,不需要TP;只有被请求的“长数据实体”才可能跨入TP。这个边界如果写错,明明可以单帧完成的消息会被拆成两帧TP,平白增加总线负载,还会让对方协议栈多一层解析开销。
6.4 一点个人体会
说实话,J1939-21传输层是我接触过的最“拧巴”但又最实用的协议之一。它把数据链路层原本做不到的“大块数据可靠交换”补了回来,虽然单看每一帧都简简单单,组合起来的状态机却有不少细节。我自己从完全看不懂抓包,到写出能在多节点实车上稳定跑的TP状态机,大概花了两周。
如果让我给刚起步的人一个建议,我会说:不要只看标准文档,一定要把RTS、CTS、DT、EndOfMsgACK、Abort五种帧的字节布局背下来,再找两块开发板或虚拟节点,自己动手跑一遍握手、传数据、拆包、Abort的完整过程。跑通之后,理解J1939-21传输层就只是时间问题了。
最后再分享一个小技巧:调试时在抓包工具过滤器里加两条规则,一条放TP.CM(0xEB00),一条放TP.DT(0xEC00),分开统计帧数。一个完整CMDT会话里,TP.CM帧数应该是3(RTS、CTS、EndOfMsgACK),如果出现Abort再另算;TP.DT帧数应该等于总包数。对不上数的时候,基本可以断定状态机或窗口管理出了问题,优先查超时时间和缓冲区设计准没错。