J1939 DM1报文详解:单帧与多包故障传输机制及工程实现
发布时间:2026/9/25 7:13:54来源:尧图网络
做车载ECU诊断和总线测试这些年J1939 DM1报文是我遇到最多、也最容易被新同事问晕的一块。无论你是在做发动机、变速箱、整车控制器还是后处理系统只要涉及故障上报基本都绕不开DM1。很多人拿着CAN抓包工具看到一帧18FECA11数据是03 00 00 7B 00 03 00 00第一反应往往是“这串十六进制到底什么意思”。更麻烦的是当ECU同时报出七八个故障码时单帧8字节CAN装不下了总线上就出现了18ECFF11、18EBFF11这类传输层帧直接把人看晕。这篇就把J1939 DM1的报文结构、多包故障传输机制和工程实现逻辑完整梳理一遍尤其适合刚接触J1939协议栈、正在做J1939报文解读或准备做J1939协议栈移植的工程师参考。1. DM1报文结构拆解灯状态、闪烁计数与故障码编码1.1 先看一个真实场景故障灯亮了总线上发生了什么想象一台工程机械在工地上跑发动机冷却液温度高、车速信号丢失、喷油器驱动异常同时发生。仪表盘上的红色停止灯和琥珀色警告灯都亮了驾驶员打电话给服务工程师工程师接上诊断仪读到的不是一堆文字而是一串CAN帧。这件事的本质是ECU通过J1939协议把当前的故障状态广播到总线上仪表、远程终端、诊断仪都在等这帧数据。DM1是J1939-73里明确定义的诊断消息PGN是65226按十六进制写就是0xFECA。它是一帧周期性广播报文负责把“当前有哪些激活故障”“这些故障的严重程度有多高”分享给整条总线。故障灯亮不亮、亮哪个灯其实不是ECU直接点亮仪表灯的物理信号而是DM1报文里的灯状态位在起作用。仪表收到DM1后解析灯状态再决定点亮哪个指示灯。所以DM1翻译成人话就是ECU的“自述病情单”。它既要告诉你怎么显示灯也要告诉你具体是哪个部位坏了、坏到什么程度、这个故障出现了多少次。理解了这一点后面解析的每个字节就都有实际含义了。1.2 DM1报文到底含哪些东西DM1报文的长度是变的不是每个版本都固定8字节。标准结构是3字节的灯状态和闪烁计数区后面跟着若干个DTC每个DTC占4字节。一个故障码时总共7字节单帧CAN刚好放得下故障码一多报文就超过8字节必须走多包传输。前3个字节按通用定义来看第一个字节是灯状态行业内最常用的解释是Bit0代表红色停止灯、Bit1代表琥珀色警告灯、Bit2代表保护灯第二个字节通常作为MIL故障指示灯的灯状态部分厂商也用它做其他灯状态的补充第三个字节是红色停止灯闪烁计数表示红色停止灯闪了几下。这里必须提醒一句不同ECU厂商对灯状态位会有自己的映射方式解析时优先看该ECU配套的1939-73文档或厂商规格书。从第4个字节开始就是DTC列表。每个DTC固定4字节按顺序排列。比如6E 00 04 01就是一个DTC的完整编码它代表“发动机冷却液温度传感器电压过低故障发生1次”。DTC在DM1里和诊断仪显示的那个“SPN 110 FMI 4”是对应的只不过报文里把它压成了4字节的紧凑格式。1.3 SPN、FMI、OC故障码的编码规则DM1里最核心的就是SPN、FMI、OC这三个量。SPN是可疑参数编号用来标识是哪个部件或参数出问题比如SPN 110是发动机冷却液温度、SPN 84是车速、SPN 190是发动机转速FMI是故障模式标识表示故障的类型比如电压过高、电压过低、信号丢失、数据异常等OC是故障发生次数ECU用7位二进制记录这个故障累计出现多少回最大126次超过就饱和在126。这三者被塞进4字节DTC里布局非常有规律。假设取DTC的4个字节为byte0~byte3那么SPN的低8位在byte0、次8位在byte1、第17到第19位在byte2的高3位byte2的低5位是FMIbyte3是OC的低7位。换算关系是SPN byte0 | (byte1 8) | (((byte2 0xE0) 5) 16) FMI byte2 0x1F OC byte3 0x7F拿6E 00 04 01来算0x6E是110byte1是0byte2是0x04也就是二进制00000100高3位是0低5位是4所以SPN110、FMI4byte3是0x01OC1。对应的故障描述就是“发动机冷却液温度传感器电压过低发生1次”。这里提到了FMI顺便放几个常见的FMI对照FMI 0表示数据有效但高于正常范围、FMI 1表示低于正常范围、FMI 2表示数据不稳定/漂移、FMI 3表示电压过高、FMI 4表示电压过低、FMI 5表示电流过低、FMI 9表示通讯异常、FMI 15表示供电电压过高、FMI 31表示未定义或未知故障。SPN的编号范围很大实际工作中也不用背解析工具里通常带数据库。但要注意一点标准DTC里SPN默认按19位处理如果厂商使用了21位扩展SPN协议栈解析时必须按扩展方式做否则高位被丢掉故障码会直接对不上。2. 为什么DM1会触发多包传输2.1 8字节CAN帧的硬限制与DM1可变长度CAN总线的经典帧数据段最长就是8字节这是CAN协议从设计时就定死的。J1939跑在CAN上自然继承了这个限制。DM1报文的结构是3字节头加N个4字节DTC所以报文长度可以算出DM1长度 3 4 × DTC数量一个故障码时长度是7字节CAN单帧刚好能塞进去。两个故障码时长度变成11字节超过了8字节的物理限制单帧就发不下了。这在整车实际运行中并不少见因为现代ECU往往同时监控几十上百个信号任何一个信号异常都可能产生DTC几个故障同时激活是很常见的。多包传输就是J1939为这种“用户数据超过8字节”的场景专门设计的传输机制学术一点叫传输协议层简称TP层。它负责把一大包数据拆成若干个CAN帧发出去接收方再按顺序拼回去。对于DM1这种周期性广播的诊断报文当故障码多到单帧放不下时总线上的表现形式就会从一帧18FECA11变成一组18ECFF11加若干18EBFF11。2.2 单帧、多包的切换判断很多人抓包时有个困惑同一台车同一个ECU今天看到的是单帧DM1明天抓到的却是多包帧。原因就是激活故障数在变。一个故障码时ECU走单帧直接把7字节放一帧两个及以上故障码时ECU自动切换成TP多包模式。这个切换是J1939-73协议栈内部自动完成的对上层应用来说发送方只负责把DM1数据交给传输层由传输层决定怎么拆分接收方则要根据收到的帧类型自动判断当前是单帧还是多包。具体在协议栈实现上发送侧会判断待发送数据长度小于等于8字节就走普通CAN帧大于8字节就调用TP层组包发送。接收侧相反先把CAN帧按PGN分类如果是TP.CM和TP.DT的PGN就进TP组包流程组完包后再解出原始DM1数据。实际调试中最容易出的问题是解析器只处理了单帧DM1当ECU突然切到多包模式上层应用直接卡住。这个问题很隐蔽因为故障码少的时候一切正常故障一多就开始丢数据。所以做J1939协议栈移植时一定要把DM1的单帧和多包两条路径都打通并且用测试工具反复切换故障数量来验证。2.3 为什么DM1多包选BAM而不是RTS/CTSJ1939的传输层提供了好几种连接管理模式常见的有BAM、RTS/CTS和Abort。DM1多包传输选的是BAM也就是广播公告方式。这不是拍脑袋定的跟DM1本身的通信特征有直接关系。DM1是广播报文发送方发给总线上所有节点没有固定的目标地址。BAM的设计刚好适合这种一对多场景发送方发一帧广播公告告诉所有人“我要发一个多包总字节数多少、分几包”然后直接连续发数据包不需要等待任何节点确认。RTS/CTS则是一对一的握手方式发送方发RTS请求接收方回CTS应答之后发送方再发数据适合诊断请求和响应这种点对点传输。如果DM1用RTS/CTS多个接收节点都要回CTS很容易造成总线冲突而且每周期都要握手效率太低。所以DM1多包用BAM是协议设计的必然选择。这也解释了为什么你在总线上抓DM1多包时看到的公告帧总是以18ECFF开头而不是点到点的18ECEC。控制字节0x20表示BAM0x10表示RTS0x11表示CTS0xFF表示Abort这几个值需要记清楚。3. 多包故障传输机制核心细节3.1 TP.CM_BAM连接管理帧里装了哪些信息多包传输的第一步是发送TP.CM_BAM帧也就是连接管理帧。它的PGN是60416十六进制0xEC00CAN ID格式是18ECFFxx其中xx是发送方源地址。这帧8字节数据是有严格格式的可以对照看第一个字节是控制字符BAM固定填0x20。第二个和第三个字节是待传输用户数据的总字节数低字节在前比如总长39字节就填27 00。第四个字节是总包数表示后面要跟着多少个TP.DT数据包按公式总包数 ceil(总字节数 / 7)计算。第五个字节是保留位固定填0xFF。第六到第八字节是目标PGN也就是原始报文的PGN低字节在前。DM1的PGN是0xFECA所以在BAM帧里就表现为CA FE 00。看一个实际例子某ECU源地址是0x11要发送总长度39字节的DM1数据分6包传输那么这帧TP.CM_BAM就是18ECFF11 20 27 00 06 FF CA FE 00这个帧很容易理解它就像是快递面单写着“包裹总重39字节分6个箱子每个箱子最多装7字节货物类型是DM1”。3.2 TP.DT数据包与包序号的约定TP.DT是真正的数据包PGN是60160十六进制0xEB00CAN ID格式是18EBFFxx。每个TP.DT数据段的第一个字节是包序号从1开始递增这个序号非常重要。后面的7个字节是用户数据的切片也就是DM1真实数据的某一段。比如总长39字节的DM1第一包放用户数据的第1到第7字节第二包放第8到第14字节依此类推。最后一包如果不够7字节剩余位置用0xFF填充。这里有个关键点接收方必须严格按照序号1、2、3、4、5、6的顺序接收如果中间丢了一包整个DM1就无法还原只能等下一个广播周期重传。包序号最大可以到255对DM1这种几十字节的报文来说完全够用。但实际总线环境中如果总线上有其他多包报文在抢时间片TP.DT包之间的间隔可能被拉长。接收方一般会设置一个超时时间比如200到500毫秒没等到下一包就判定接收失败清空缓冲区等待下一次BAM。这个超时值可以在协议栈里配置具体看项目要求。3.3 完整实例9个故障码如何通过6个包传到总线上为了把整个流程讲透我构造一个完整例子。假设某ECU源地址是0x11当前同时激活9个故障码灯状态是红色停止灯和琥珀色警告灯都亮。9个故障码的信息如下表序号故障描述SPNFMIOCDTC1燃油泵故障12330DTC2喷油器驱动异常15720DTC3发动机转速信号不正常19000DTC4车速信号丢失8490DTC5冷却液温度传感器电压过低11041DTC6进气温度信号异常10500DTC7后处理DPF压差异常723150DTC8油门踏板位置传感器电压过高13230DTC9差速器故障62720总共9个DTC每个4字节加上3字节头DM1数据总长度是3 9 × 4 39字节。39除以7向上取整共6个TP.DT包。按SPN/FMI/OC编码规则9个DTC依次编码为7B 00 03 00 9D 00 02 00 BE 00 00 00 54 00 09 00 6E 00 04 01 69 00 00 00 D3 02 0F 00 84 00 03 00 73 02 02 00灯状态部分红色停止灯和琥珀色警告灯都亮第一个灯状态字节填0x03第二个灯状态字节填0x00闪烁计数填0x00。完整39字节的DM1数据是03 00 00 7B 00 03 00 9D 00 02 00 BE 00 00 00 54 00 09 00 6E 00 04 01 69 00 00 00 D3 02 0F 00 84 00 03 00 73 02 02 00接下来按7字节切片。切片时不关心DTC边界纯粹按字节顺序切。各TP.DT帧实测抓出来就是这样阶段CAN IDCAN数据公告18ECFF1120 27 00 06 FF CA FE 00数据包118EBFF1101 03 00 00 7B 00 03数据包218EBFF1102 9D 00 02 00 BE 00数据包318EBFF1103 00 54 00 09 00 6E数据包418EBFF1104 04 01 69 00 00 00数据包518EBFF1105 02 0F 00 84 00 03数据包618EBFF1106 73 02 02 00 FF FF FF接收方先把6个TP.DT的序号剥掉按顺序拼出39字节再根据BAM帧里的总字节数截断末尾的0xFF填充就拿到完整的DM1原始数据可以进入DTC解析了。这张表建议收藏自己在CANoe或PCAN里抓抓看对上了就说明你对多包传输机制的理解已经到位了。4. 工程实践接收组包、发送组包与解析代码4.1 接收侧状态机设计与C语言实现接收多包DM1本质上是一个状态机。最简版本有三个状态空闲、收包中、完成。空闲状态下收到TP.CM_BAM并且目标PGN是0xFECA就记录总字节数、总包数初始化缓冲区进入收包中状态。收包中状态下收到TP.DT先检查包序号是否等于当前期望序号对上了就把7字节写入缓冲区对应位置序号加一如果收满了总包数就进入完成状态调用上层解析函数解析DM1。任何一步出错或者超时没等到下一包都回到空闲状态等待下一周期BAM。我用C语言写一个精简的接收处理函数方便对照#include string.h #include stdint.h #define DM1_PGN 0xFECA #define TP_CM_PGN 0xEC00 #define TP_DT_PGN 0xEB00 #define TP_CTRL_BAM 0x20 static uint8_t tp_buf[256]; static uint16_t tp_total_len; static uint8_t tp_total_pkt; static uint8_t tp_rx_cnt; static uint8_t tp_rx_expect; void j1939_rx(uint32_t pgn, uint8_t sa, const uint8_t *data, uint8_t len) { if (pgn TP_CM_PGN len 8 data[0] TP_CTRL_BAM) { /* 校验目标PGN确认是DM1 */ uint32_t target_pgn data[5] | (data[6] 8) | (data[7] 16); if (target_pgn ! DM1_PGN) { return; } tp_total_len data[1] | (data[2] 8); tp_total_pkt data[3]; tp_rx_cnt 0; tp_rx_expect 1; memset(tp_buf, 0, sizeof(tp_buf)); return; } if (pgn TP_DT_PGN len 8 tp_rx_expect ! 0) { uint8_t seq data[0]; if (seq ! tp_rx_expect) { return; /* 序号不对直接丢弃等下一周期 */ } memcpy(tp_buf[tp_rx_cnt * 7], data[1], 7); tp_rx_cnt; tp_rx_expect; if (tp_rx_cnt tp_total_pkt) { parse_dm1(tp_buf, tp_total_len); tp_rx_cnt 0; tp_rx_expect 0; /* 回到空闲 */ } } }这段代码把BAM校验、DT序号校验、组包、截断都覆盖了。工程上还要再加一个软件定时器周期检查收包过程中是否超过规定时间没收到下一包超时就清空状态。超时时间一般取200到500毫秒具体要看你项目里DM1的发送周期。DM1的标准发送周期通常是1秒但也有部分ECU异常时会加速到100毫秒这个要根据实测调。4.2 发送侧BAM组包流程发送侧逻辑相对简单主要分四步。第一步是把上层给的DM1数据拷贝到发送缓冲区同时计算总字节数和总包数。第二步发送一帧TP.CM_BAM控制字节填0x20后面跟着总字节数低字节、总字节数高字节、总包数、0xFF、目标PGNCA FE 00。第三步循环发送TP.DT每包首字节是包序号从1开始后面跟上7字节数据。第四步是末尾不足7字节时补0xFF然后结束本次发送。有个工程细节特别重要BAM模式下的TP.DT包必须连续发送中间不能夹着其他长报文否则接收方可能因为等待超时而丢掉整组包。在某些多任务调度系统里如果发送任务被其他高优先级任务抢占时间间隔就容易拉大。一个稳妥做法是在发送TP.DT期间临时提高发送任务优先级或者把整组包放到一个不可抢占的临界区内一次性发完。发送侧伪代码大致是这样void j1939_send_dm1(const uint8_t *dm1_data, uint16_t len) { uint8_t pkt_num (len 6) / 7; uint8_t cm[8]; cm[0] 0x20; cm[1] len 0xFF; cm[2] (len 8) 0xFF; cm[3] pkt_num; cm[4] 0xFF; cm[5] 0xCA; cm[6] 0xFE; cm[7] 0x00; can_send(0x18ECFF00 | SA, cm, 8); for (uint8_t i 0; i pkt_num; i) { uint8_t dt[8]; dt[0] i 1; memset(dt[1], 0xFF, 7); uint16_t offset i * 7; for (uint8_t j 0; j 7; j) { if (offset j len) { dt[1 j] dm1_data[offset j]; } } can_send(0x18EBFF00 | SA, dt, 8); } }总包数用(len 6) / 7这种整数向上取整方式比调ceil函数更高效嵌入式里常用。4.3 用Python快速验证DM1解析调试DM1不一定非得用商业软件。我习惯抓包后用一个小Python脚本快速验证解析结果判断手里的数据是不是按预期解出SPN/FMI/OC。把抓到的TP组包后的原始DM1数据喂进去就行def parse_dm1(data: bytes): lamp_status data[0] mil_status data[1] flash_count data[2] dtc_list [] for i in range(3, len(data) - 3, 4): b0 data[i] b1 data[i 1] b2 data[i 2] b3 data[i 3] spn b0 | (b1 8) | (((b2 0xE0) 5) 16) fmi b2 0x1F oc b3 0x7F dtc_list.append((spn, fmi, oc)) return lamp_status, mil_status, flash_count, dtc_list raw bytes.fromhex( 03 00 00 7B 00 03 00 9D 00 02 00 BE 00 00 00 54 00 09 00 6E 00 04 01 69 00 00 00 D3 02 0F 00 84 00 03 00 73 02 02 00 ) lamp, mil, flash, dtcs parse_dm1(raw) print(灯状态:, hex(lamp), MIL:, hex(mil), 闪烁:, flash) for spn, fmi, oc in dtcs: print(fSPN{spn}, FMI{fmi}, OC{oc})拿前面9个故障码的例子跑一下输出应该和预期完全一致。这个脚本可以当做一个随手用的“验算器”遇到可疑的DTC编码人工不算直接丢进去跑一遍很快就能定位是编码问题还是组包问题。4.4 移植J1939协议栈时最容易踩的坑移植J1939协议栈表面上是把代码从一个MCU搬到另一个MCU实际搬的是对总线和时序的理解。我在移植过程中踩过不少坑挑几个最典型的说。CAN控制器过滤配置是重灾区。很多以太网背景的工程师第一次用CAN协议栈下意识以为只要使能接收中断就能收到所有帧实际CAN硬件有验收过滤器默认过滤表可能只放行少数PGN。如果只放行了0xFECA而没放行0xEC00和0xEB00那DM1单帧能收到一旦ECU切到多包模式BAM和DT全被硬件过滤掉了上层怎么等都等不到。排查方法很简单抓包软件能看到但MCU侧收不到先关掉全部过滤再一只一只加白名单。时序问题也常被忽视。BAM模式下发送端连续发送TP.DT接收端组包是线性写入处理时间极短。但如果在RTOS里收包中断只做了FIFO入队实际组包逻辑在低优先级任务里执行而该任务又被其他任务阻塞就容易出现缓冲区已收到新数据但解析任务迟迟不跑的情况。此时用户观感就是“故障码出得很慢”或者“偶发丢故障”。解决办法是把J1939接收处理放到中断下半部或较高优先级任务确保一个DM1周期内处理完。另一个坑是填充字节的过滤。TP.DT最后一包不足7字节时补0xFF解析时如果没按BAM帧里的总字节数截断会把0xFF当成DTC数据解析。比如39字节的DM1最后一包只有4字节有效补了三个0xFF若不截断解析器可能多出一个SPN为0xFFFFF的非法故障码。所以正确做法是以BAM里记录的总字节数为准解析DTC时用len(data) - 3来计算DTC个数而不是用TP.DT的包数乘以7。还有一个小坑是发送侧改了SPN的字节顺序。不同厂商对DTC内SPN位数理解不一致有的按19位有的强行塞到21位。移植协议栈时SPN位宽必须作为一个可配置项留出来不要写死成19位。否则遇到扩展SPN的ECU解析出来的SPN会整体偏移排查起来非常痛苦。5. 常见问题速查与排查建议5.1 多包DM1常见故障现象与对策把平时调试遇到的典型问题整理成一张速查表按“现象、可能原因、排查思路”三个维度去看能少走很多弯路。现象可能原因排查思路只看到单帧DM1看不到多包激活故障码只有1个DM1长度≤8字节不需要TP用诊断仪或测试工具同时触发多个故障再观察收到TP.CM_BAM后不见TP.DT发送任务被阻塞、总线错误导致连续发送失败查CAN错误帧和总线占用率检查发送周期和调度TP.DT包序号乱序或丢帧总线干扰、接收缓冲区溢出检查终端电阻、CAN位时间配置抓包统计错误帧拼包后DTC数量莫名其妙多出几个没按BAM总字节数截断0xFF填充解析前先按BAM里记录的总长度裁剪数据只收到TP.DT没收到TP.CM接收过滤表屏蔽了0xEC00检查CAN硬件过滤器和白名单配置DM1单帧时正常故障多时解析失败状态机没实现单帧/多帧切换统一入口先判断PGN和长度再决定走单帧还是TP路径解析出的SPN与诊断仪不一致SPN位宽配置错误或厂商自定义确认ECU文档中的SPN位数调整宏定义这个表里的问题我在不同项目里几乎全遇到过。其中最多的是第一行和最后一行第一行是因为测试时没制造足够故障最后一行则是厂商兼容性问题都需要在项目早期就确认清楚。5.2 验证多包传输稳定性的几个思路多包DM1的稳定性验证不能只在干净总线上测要主动制造干扰。我个人的做法是分三步走。第一步是功能验证用CANoe或PCAN模拟一个ECU发送多包DM1验证接收端能否正确解析出所有DTC。第二步是边界验证把DM1的长度从7字节慢慢加到50字节覆盖单帧到多包的临界点确认切换过程没有丢包。第三步是抗干扰验证在总线上周期插入错误帧、拉高总线负载到70%以上观察接收端是否能在1到2个周期内自动恢复正常。如果连续丢包超过3个周期说明状态机恢复能力不足要回查超时重置逻辑。另外建议在接收端打一组统计日志记录BAM接收次数、DT成功次数、DT丢弃次数、超时次数。量产后的故障定位很大程度依赖这些统计量。没有统计日志的协议栈一旦出现偶发丢故障基本只能靠猜。多说一个测试中的小细节测试ECU从单帧切到多包时可以先用诊断仪清除故障码再故意制造两个故障观察CANoe里是否能立刻看到BAM帧。不要直接在故障码满屏的状态下测那样可能同时触发多个ECU发多包总线上的帧交错在一起对新手来说很难辨别是哪台ECU发的。一台一台来问题会清楚很多。我自己在实际调试中的体会是多包DM1的难点从来不在协议本身而在于测试环境和状态机细节。只要把BAM公告、DT序号、填充截断这三件事想明白再配合一个带过滤配置的CAN驱动大部分问题都能快速定位。遇到解析对不上的故障码也不要急着怀疑协议栈先用Python脚本徒手解一遍往往能发现是字节序或者SPN位宽的问题。这个习惯帮我省了大量时间。
网站建设高端定制企业官网