做商用车电控和诊断这几年,我发现很多人卡在SAE J1939上不是因为它难,而是没人把PGN计算、ID拆解、多包传输、报文解析这几件事串起来讲。你拿着CANalyzer或者周立功盒子抓一屏报文,满眼都是0x18FEF100、0x0CF00400、0x18ECFF00这种29位ID,想找车速、转速、温度却无从下手,更别说从一堆TP.DT里把几十个字节的数据重新拼回来。这篇我就按实际调车时摸索出来的顺序,把这四块一次讲透,公式、流程、坑都摆出来。适合刚接手商用车、农机、工程机械CAN通讯的嵌入式工程师、测试工程师,也适合做故障诊断想看懂原始报文的朋友。
1. 认识J1939与CAN的关系:29位ID是怎么搭起来的
1.1 29位扩展ID:比标准CAN多了什么
J1939本质上是跑在CAN物理层之上的一套应用层协议,底层依然是ISO 11898那套差分电压、仲裁、错误处理机制。普通CAN报文用的是11位标准ID,商用车现场几乎不会去用,因为J1939需要在一个ID里同时表达三件事:这次传输的优先级、这条报文属于哪个参数组、以及这条报文是从哪个ECU发出来的。11位根本不够塞,所以J1939固定使用29位扩展帧。
29位ID的每一位都有明确分工,从高位到低位依次是:3位优先级(Priority)、1位EDP(保留扩展数据页位,基本恒为0)、1位DP(数据页位,基本恒为0)、8位PF(PDU格式)、8位PS(PDU特定)、8位SA(源地址)。
用一个实际报文举例,0x18FEF100是最常见的一条车速相关报文。把它解出来:优先级6,EDP=0,DP=0,PF=0xFE,PS=0xF1,SA=0x00。也就是说,这条报文是地址0x00的ECU发出的,优先级6,参数组编号是后面的0xFEF1。很多新手看到ID里的F1会以为是什么目标地址,其实在J1939里SA在最后一位,PS字段是不是目标地址要看PF的数值,这一点后面专门讲。
1.2 一位一位拆:P/EDP/DP/PF/PS/SA到底表示什么
把29位ID拆开看,每个字段作用如下:
| 字段 | 位数 | 作用说明 |
|---|---|---|
| Priority | 3bit | 0-7,数值越小优先级越高。周期报文常见3,诊断/请求类常见6,TP传输协议相关常见7 |
| EDP | 1bit | 扩展数据页位,J1939常规报文固定为0 |
| DP | 1bit | 数据页位,常规报文固定为0。需要扩展PGN范围时才用到 |
| PF | 8bit | PDU格式,决定后面PS字段的含义。PF取值0-239时走PDU1格式,240-255时走PDU2格式 |
| PS | 8bit | 当PF<240时是目标地址DA,当PF>=240时是组扩展GE,属于PGN的一部分 |
| SA | 8bit | 源地址,发送这条报文的ECU地址。0-247可用,255是全局广播地址 |
这里最容易绕晕的就是PS这个字段。PF=0xEA的请求报文,PS=0xFF,表示请求发给总线上所有ECU;PF=0xEC的TP.CM报文,PS在广播模式下是0xFF,在点对点传输时则是目标节点地址。而像0x18FEF100这种PF=0xFE>=0xF0的报文,PS=0xF1就不再是地址了,是PGN的一部分。
所以拿到一条29位ID,先不要急着用工具去翻译,自己心里要能默算一遍:优先级多少、PF多少、PS是地址还是组扩展、源地址是谁。这一步熟练了,后面所有解析都是顺理成章的事。
2. PGN计算与ID拆解:从一串HEX里快速定位报文身份
2.1 PGN不是地址,是“参数组编号”
PGN全称Parameter Group Number,参数组编号。你可以把它理解成一条报文的“身份证号”。J1939标准里,整车控制器、发动机、变速箱、刹车、仪表等所有ECU之间要交换的数据,都被组织成一个一个参数组。比如61444是发动机电子控制器1(EEC1),里面装着发动机转速、燃油消耗率、油门踏板位置这些数据;65265是车速与巡航控制报文(CCVS),装着车速、离合器开关、巡航开关状态。
很多从CANopen转过来的人会把PGN理解成“对象字典地址”或者“命令码”,其实不太一样。PGN描述的是“这一包数据里装着什么”,而不是“要发给谁”。目标地址由PS字段承载,源地址由SA字段承载,这两个信息都在CAN ID里,PGN本身不关心通信双方是谁。
PGN只用了18位,由EDP+DP两位、PF八位、PS八位组成。常规道路上车辆EDP和DP都是0,所以很多简化文档里直接说“PGN高8位是PF,低8位是PS的规则”,但严谨一点,PGN的完整结构是:2个高位控制位 + PF + PS。日常计算时,EDP和DP不参与,PGN就等于PF左移8位后拼上PS的某个值。
2.2 从ID反推PGN的计算方法(附实例)
从29位ID反推PGN,判断逻辑只有三步:
第一步,取出PF。PF就是ID第23到16位。
第二步,判断PF大小。PF<240(0xF0)时,走PDU1格式,PS字段是目标地址,PGN低字节固定填0;PF>=240时,走PDU2格式,PS字段是组扩展GE,PGN低字节直接取PS。
第三步,把DP放到PGN高位(EDP恒为0时忽略),组合成18位数值。
写成公式就是:
if PF < 0xF0: PGN = (DP << 16) | (PF << 8) | 0x00 else: PGN = (DP << 16) | (PF << 8) | PS用0x18FEF100实际算一遍:PF=0xFE,0xFE大于等于0xF0,所以PGN=(0<<16)|(0xFE<<8)|0xF1=0x00FEF1=65265。查到65265就是CCVS,一条典型的车速报文。
再用0x0CF00400验证一遍:PF=0xF0,同样大于等于0xF0,PS=0x04,PGN=(0<<16)|(0xF0<<8)|0x04=0x00F004=61444。这是EEC1,发动机电子控制器1,发动机转速就在这包里。
反过来,如果收到一条PGN=59904的请求报文,PF=0xEA,0xEA小于0xF0,所以PGN=(0<<16)|(0xEA<<8)|0x00=0x00EA00,ID末尾PS=0xFF、SA=0x00,最后拼出来就是0x18EAFF00。
2.3 常见PGN与报文ID速查表
开发时经常打交道的这几个PGN,建议直接背下来:
| PGN(十六进制) | PGN(十进制) | 名称 | 常见报文ID | 说明 |
|---|---|---|---|---|
| 0xEA00 | 59904 | Request | 0x18EAFF00 | 请求某ECU发送指定PGN的数据 |
| 0xEB00 | 60160 | TP.DT | 0x18EBFF00 | 传输协议数据帧,承载多包数据 |
| 0xEC00 | 60416 | TP.CM | 0x18ECFF00 | 传输协议连接管理,BAM/RTS/CTS都在这里 |
| 0xEE00 | 60928 | Address Claimed | 0x18EEFF00 | 地址声明,ECU上线时广播自己的NAME |
| 0xF004 | 61444 | EEC1 | 0x0CF00400 | 发动机电子控制器1,发动机转速等 |
| 0xFEF1 | 65265 | CCVS | 0x18FEF100 | 车速与巡航控制,车轮速度等 |
注意TP.CM和TP.DT的优先级在不同实现里可能不一样,有的用7,有的用6,所以ID开头可能是0x1C也可能是0x18,但PF和PS字段的含义不变。速查表里写的0x18开头的ID只是最常见形式,不要把第一个字节当死值去记。
2.4 特殊用途PGN:请求、地址声明和传输协议
请求报文值得单独说明。想主动问某个ECU要数据,就发PGN 59904的请求帧,ID一般是0x18EAFF00。它的数据场只有前三个字节有意义,内容是目标PGN的低字节、高字节、扩展页,顺序是小端。比如要请求61444(EEC1),数据场就是04 F0 00 FF FF FF FF FF。接收方看到这包请求后,如果允许,就直接回一帧EEC1报文。
地址声明PGN 60928也很关键。J1939网络里每个ECU上线都要广播自己的64位NAME,包含车辆类型、功能、ECU实例号、制造商代码等信息。这个东西的作用是防止两个ECU抢同一个SA。如果发生冲突,双方会比较NAME的优先级字段,优先级低的退让,重新选地址。实际调试时,如果发现某条报文时有时无,或者ECU不响应,第一个要查的就是地址声明有没有成功。
TP.CM和TP.DT这两个PGN就是多包传输的控制和数据通道,下一章专门拆。
3. 多包传输(TP)深度拆解:大数据怎么跨帧传输
3.1 什么情况下需要多包传输
CAN数据场只有8个字节,这是硬约束。J1939里很多参数组超过8字节,比如诊断故障快照、Bootloader刷写数据、带大量标定参数的下发报文,动辄几十上百字节。超过8字节的内容,就必须拆成多个CAN帧传输,这就是传输协议(Transport Protocol,TP)存在的意义。
注意触发条件很简单:一个PGN的数据长度超过8字节,就需要TP。9字节到21字节需要2个TP.DT帧,22字节到28字节需要3帧,依此类推。每个TP.DT帧最多携带7字节有效数据,因为第1字节被用作包序号。
标准还区分了两种TP场景:广播传输(BAM)和点对点传输(RTS/CTS)。前者用于一个节点给全网发数据,不需要确认;后者用于两个节点之间的定向传输,带完整的握手和确认流程,保障可靠交付。
3.2 BAM广播模式:简单但不可靠
BAM全称Broadcast Announce Message,广播宣告消息。流程分两步:
第一步,发送方先发一帧TP.CM_BAM,ID里的PGN是60416,数据场格式如下:
| 字节 | 内容 |
|---|---|
| 第1字节 | 0x20,表示这是BAM |
| 第2-3字节 | 消息总字节数,16位小端 |
| 第4字节 | 总包数 |
| 第5-6字节 | 被拆分消息的PGN,16位小端 |
| 第7-8字节 | 保留,通常为0xFF |
第二步,发送方连续发送TP.DT数据帧,ID里的PGN是60160,每个TP.DT数据场第1字节是包序号(从1开始),后面7字节是有效数据。最后一包不足7字节时填充0xFF。
BAM的特点就是没有接收确认,也没有重传机制,接收端只能被动收、自己拼。好处是简单,适合广播给多个节点;坏处是总线上负载高或抓包工具过滤不当时,丢一帧就拼不完整,只能等下一轮广播。
3.3 RTS/CTS点对点模式:窗口确认与重传
点对点传输比BAM复杂得多,但可靠。适用场景是一个ECU向另一个ECU请求诊断数据、刷写标定数据等。
流程是这样走的:
- 发送方发TP.RTS(连接管理帧,字节1=0x10),告诉接收方:我要发这么多字节、这么多包,PGN是什么。
- 接收方回TP.CTS(字节1=0x11),允许发送方发若干包,并指定从哪个序号开始。这个“若干包”就是块大小,类似滑动窗口,接收方根据自己的缓冲区能力决定一次放行多少包。
- 发送方按顺序发TP.DT数据帧。
- 一个块发完,接收方再回CTS放行下一块,或者全部收完就直接发TP.EndOfMsg(字节1=0x13)确认完成。
- 任何一方发现异常,可以发TP.Abort(字节1=0xFF)终止传输。
CTS帧里有一个关键字段:允许发送的包数。发送方一次最多发这么多包就必须停下来等下一个CTS,这个机制本质是流量控制,防止接收方缓冲区溢出。标准还给每个环节定义了严格的超时,比如发送方拿到CTS后要在N_TA(常见750ms)内发出第一个DT帧,接收方等待下一帧的超时N_TB常见在1250ms左右。具体数值不同厂商会标定差异,调试时以协议版本和整车厂规范为准。
3.4 实测一个多包报文如何被拆分和重组
用个例子把整个过程走通。假设地址0x01的ECU要广播一条14字节的厂商自定义消息,PGN定义为0xFF00。
第一步,发TP.CM_BAM,ID=0x1CECFF01,数据场:
20 0E 00 02 00 FF FF FF第1字节0x20是BAM标识;第2-3字节0x000E=14,总字节数;第4字节0x02,总共2个TP.DT包;第5-6字节0x00FF,注意小端,实际PGN是0xFF00。
第二步,发两个TP.DT数据帧:
TP.DT_1: 01 11 22 33 44 55 66 77 TP.DT_2: 02 88 99 AA BB CC DD EE第一帧序号1,携带11 22 33 44 55 66 77;第二帧序号2,携带88 99 AA BB CC DD EE。
接收端重组逻辑很简单:先等TP.CM_BAM,解析出总字节数14、总包数2、PGN 0xFF00,然后收两个TP.DT,去掉每帧第1字节序号,按序号拼接,得到:
11 22 33 44 55 66 77 88 99 AA BB CC DD EE这就是那14字节的原始消息。实际调试中很多重组失败,都是因为抓包工具没抓到TP.CM_BAM,或者TP.DT之间丢了帧,导致只看到一堆序号断档的数据帧。
4. 报文解析实战:从原始字节到物理量的标准套路
4.1 解析前先做三件事:抓帧、列ID、查表
拿到CANoe、PCAN或者周立功抓回的一堆报文,不要急着看数据,按照下面的顺序来:
第一,把所有29位ID按PGN归类。先把每个ID的PF、PS、SA算出来,PGN相同的归到一起。这一步能立刻看出哪些报文是周期性发送、哪些是事件触发、哪些来自哪个ECU。
第二,找参考表。J1939标准里每个PGN的数据布局在J1939-71文档里有定义,但手头最方便的还是DBC文件或者Vector的CANdb数据库。整车厂一般都会提供DBC,里面已经把PGN、SPN、起始位、长度、缩放因子、偏移量都定义好了,直接用工具加载就行。
第三,从简单报文下手验证。先找一个已知长度短、字节定义明确的报文(比如车速CCVS),用工具里显示的物理量和你手算的值对照,确认整条解析链路没问题,再去解析复杂报文。
4.2 SPN换算公式:raw乘缩放加偏移
PGN解决的是“这一包是什么”,SPN解决的是“包里的每个参数是什么”。每个物理量或状态量都有唯一SPN编号,比如SPN 190是发动机转速,SPN 110是发动机冷却液温度,SPN 84是车轮速度。
SPN换算物理量就一个公式:
物理值 = 原始值 × 分辨率 + 偏移量原始值是从数据场按SPN定义取出的无符号整数。举个例子,某个PGN里定义了一个16位转速参数,起始于第1字节,小端排列,分辨率0.125 rpm/bit,偏移量0。如果收到的数据场前两个字节是40 1F,小端拼起来是0x1F40=8000,那么转速=8000×0.125=1000rpm。
再比如一个8位温度参数,分辨率1℃/bit,偏移量-40。数据场字节读到0x5A=90,那么温度=90×1-40=50℃。这就是为什么有些标准里温度原始值80对应40℃,90对应50℃,因为偏移量是负的。
状态量更简单,通常就是一个位或几个位,解码出来是0或1,查SPN定义就知道含义。有些SPN是ASCII字符串,比如车辆识别号、故障码诊断信息,这时候就不是乘缩放因子,而是按字符编码直接读。
4.3 Intel还是Motorola:最常见的字节序坑
同样两个字节40 1F,如果按Intel格式(小端)拼,是0x1F40=8000;如果按Motorola格式(大端)拼,是0x401F=16415。同样是转速参数,原始值差了整整一倍多,物理量自然完全不同。
J1939的大多数物理量采用Intel格式,也就是低字节在前。但这不是绝对的,一些老车型或者OEM私有报文会用Motorola格式,尤其是某些美国厂商的历史遗留协议。更麻烦的是,很多SPN并不是刚好按字节对齐的,会跨字节、跨位,这时候字节序的影响更隐蔽。
我遇到过一个典型案例:一块国产T-BOX上报的发动机转速,在测试台上数值时不时出现异常跳变。排查到最后就是解析代码里用了大端去拼转速的两个字节,但实际上DBC里定义的是小端,导致数值在特定转速区间出现灾难性错误。所以拿到新车型的DBC,第一件事就是把所有多字节SPN的字节序逐个核对一遍,不要默认全按一种格式。
4.4 用Python手写一个J1939解析器
不用重型工具,Python加can库就能处理大部分离线报文解析。下面这段是教学用的简化代码,处理字节对齐的SPN已经够用,重点演示ID解码和SPN换算逻辑:
def decode_id(can_id): """解析29位CAN ID,返回J1939各字段""" can_id &= 0x1FFFFFFF priority = (can_id >> 26) & 0x7 edp = (can_id >> 25) & 0x1 dp = (can_id >> 24) & 0x1 pf = (can_id >> 16) & 0xFF ps = (can_id >> 8) & 0xFF sa = can_id & 0xFF if pf < 0xF0: pgn = (dp << 16) | (pf << 8) | 0x00 else: pgn = (dp << 16) | (pf << 8) | ps return { 'id': can_id, 'priority': priority, 'dp': dp, 'pf': pf, 'ps': ps, 'sa': sa, 'pgn': pgn, } def get_raw(data, start_byte, num_bytes, is_little=True): """按J1939习惯提取原始值,start_byte从1开始计数""" chunk = data[start_byte - 1:start_byte - 1 + num_bytes] if len(chunk) < num_bytes: return None return int.from_bytes(chunk, 'little' if is_little else 'big') # 示例:解析一帧ID=0x18FEF100,数据场为车速相关 frame_id = 0x18FEF100 data = bytes([0x06, 0x00, 0x00, 0x32, 0x00, 0xFF, 0xFF, 0xFF]) info = decode_id(frame_id) print('PGN:', hex(info['pgn']), info['pgn']) print('SA:', hex(info['sa'])) # 假设某SPN定义:起始字节4,长度1字节,分辨率1km/h,偏移0 speed_raw = get_raw(data, 4, 1, is_little=True) speed = speed_raw * 1.0 + 0.0 print('speed:', speed, 'km/h')实际项目里我会把SPN定义做成一个配置表,每个SPN包含PGN、起始字节、位长度、分辨率、偏移量、字节序这些字段,解析时统一查表计算。这样新增车型只需要改配置,不用改代码。
5. 现场调试常见问题与排查经验
5.1 典型故障与排查速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 仪表收不到某ECU数据 | ECU地址声明失败,地址冲突 | 抓地址声明PGN 60928,核对NAME仲裁结果 |
| 多包报文重组不出来 | TP.CM_BAM没抓到,或BAM/DT之间丢帧 | 加过滤只抓TP相关PGN,确认总包数与实际DT数量一致 |
| 转速、车速数值异常跳变 | 字节序解析错误 | 核对DBC里SPN的Intel/Motorola定义 |
| 数值变成超大正数 | 有符号数按无符号解析,或空闲字节0xFF被当成有效值 | 查SPN是否有符号标志,先掩码再解析 |
| 请求报文没有响应 | 请求PGN填错、目标地址不是0xFF、对方地址声明未完成 | 核对数据场前3字节PGN和请求ID的DA字段 |
| 周期报文频率不对 | 实际传输周期与J1939-71推荐不一致 | 查该PGN在标准里的传输速率,询问OEM规范 |
5.2 调试时我反复踩过的几个细节
先说地址声明的坑。很多ECU上电后并不是立刻发周期报文,而是先做地址声明,等声明成功后才开始正常通信。如果调试时把ECU单独挂在总线上,没有接其他节点,有些ECU会因为没有收到“全局地址声明响应”而卡在等待状态。这时候用CANoe周期性地发一条PGN 60928的地址声明广播帧,常能让ECU恢复正常通信,这是现场排查地址问题时的经典手段。
再说多包抓帧。BAM广播发送很快,如果抓包工具过滤条件设得太窄,或者总线负载高,很容易漏掉中间的TP.DT。建议抓多包时把过滤条件放宽到整个TP协议范围,或者只保留TP.CM和TP.DT两个PGN,不要按更细的PGN过滤。另外DT包的序号从1开始,不是从0开始,排序时不要搞错,否则重组出来的数据会错位一整块。
还有一个非常容易忽视的问题:J1939标准里数据场字节编号是从1开始的,很多从通用CAN开发转过来的人习惯从0开始编号,导致DBC里定义的起始位和代码里的索引永远差一。这个错一次就可能浪费半天。建议在代码里明确注释“start_byte按J1939从1计数”,并在解析函数里统一减一处理。
5.3 关于DBC和工具使用的几句实话
工具链方面,Vector CANoe功能最全,J1939 IL插件能直接把PGN/SPN翻译出来,适合做完整ECU开发验证。PCAN-Explorer相对轻量,配合PCAN-USB调试小项目很顺手。国产的周立功CANTest和ZCANPRO在现场调试、售后诊断里出现频率很高,优点是上手快,但J1939支持程度要看版本,有的需要自己导入DBC。
如果项目预算有限或者想自动化处理,靠cantools加Python也能玩得很顺。把J1939报文封装成DBC文件,cantools就能直接解码CAN帧,把PGN和SPN都翻译出来。唯一需要注意的是,有些DBC是从别人手里转过来的,字节序、起始位定义可能被改动过,导入工具前最好先拿一条已知报文验证一遍。
我个人在实际操作中的体会是,J1939这个协议看着表格多、PGN多、SPN更多,但真正吃透之后,日常调车做解析用到的核心工具就那么几个:能算PGN的ID拆解能力、能查SPN的DBC、能抓多包的分析仪。只要把29位ID这条主线抓住,剩下的都是查表、乘系数、对字节序的重复劳动。
最后再分享一个小技巧:每拿到一个新项目,我会先做一页“J1939快查表”,把自己关心的PGN、ID、SPN、缩放因子、偏移量、周期都列上去,贴在工位上。调试时遇到不认识的报文,先按ID拆解算PGN,再对照快查表判断是标准报文还是厂商私有报文。私有的就申请DBC定义,标准报文的SPN布局可以找J1939-71核对。这套方法帮我少走了很多弯路,也把新同事的入门时间从几周压缩到了两三天。