STM32 J1939协议测试源码:29位CAN ID解析与PGN/SPN映射实战
简介基于STM32单片机的汽车CAN-J1939协议测试源码是一份面向嵌入式软硬件开发者的工程参考主要解决车载CAN总线环境下J1939协议栈的初始化、报文收发与地址声明等学习验证问题。工程在STM32标准外设库基础上实现了CAN模块底层驱动、J1939协议初始化流程并集成按键和LED演示逻辑适合正在学习CAN总线或从事车联网开发的工程师对照研读。压缩包共127个文件以C源码和头文件为主32个h、30个c同时包含编译生成的o、d、crf、axf、hex等中间文件与可执行文件另有工程配置uvproj、uvopt及备份文件整体约1.88MB目录结构保留Keil工程原貌便于直接打开查看。不同后缀文件分别对应源程序、编译目标、链接映射和工程配置可满足从代码阅读到烧录验证的完整流程。目前已有1873人学习下载源码入口清晰适合作为J1939协议栈移植和CAN通信测试的起步模板。1. J1939测试源码STM32上的29位CAN ID才是分水岭基于STM32单片机的汽车CAN-J1939协议测试源码解决的核心问题很直接用几十块钱的板子取代动辄上万的CAN诊断仪去商用车、农机、发电机组这类J1939总线上抓帧、模拟ECU、发测试报文。很多人拿到这类源码的第一反应是“把CAN初始化改成扩展帧再把波特率设成250K收发就完事了”但真正决定工具能不能上车的是29位CAN ID里那套优先级、PGN、PS和源地址的组合规则。这套规则和摩托罗拉字节序一起把J1939和普通CAN 2.0A隔成了两种玩法。写这篇文章不是让你背一遍协议文档而是把做测试工具最常用的动作拆开先会拆29位ID再会收发最后能构造出转速、车速、请求响应这类最典型的J1939报文。适合正在做毕业设计的学生、从CAN 2.0A转过来的嵌入式工程师也包括想在整车上验证自己ECU逻辑的测试人员。2. STM32上J1939协议栈的第一课29位CAN ID解析与PGN/SPN映射2.1 为什么J1939不能用11位标准帧硬套CAN 2.0A的标准帧只有11位标识符放一个优先级、一个源地址就满了剩下全靠数据域约定。而J1939使用CAN 2.0B的29位扩展帧把这29位分成了7个字段。很多测试工具抓回一帧0x0CF00400如果在软件里只把它当成一个整型ID打印出来不拆字段后续的SPN解析就无从谈起。更麻烦的是J1939的PGN定义里PF 240和PF 240时PS字段的含义完全不同一个是目标地址一个是组扩展这套规则在11位ID的世界里根本不存在。29位ID的位分配关系如下表所示位段长度位置说明Priority3 bitbit28 ~ bit26报文优先级0最高7最低EDP1 bitbit25扩展数据页J1939常用0DP1 bitbit24数据页常用0PDU FormatPF8 bitbit23 ~ bit16决定PDU1还是PDU2格式PDU SpecificPS8 bitbit15 ~ bit8PDU1为目标地址PDU2为组扩展Source AddressSA8 bitbit7 ~ bit0发送节点地址J1939的报文默认是250 kbps但物理层依然是CAN所以总线上抓到的每个扩展帧都可以用上面这张表拆开。测试源码里第一步要做的事情就是写一个把ExtId拆成结构体的函数为后面的PGN过滤和SPN提取打底。2.2 PGN、SPN与源地址测试源码里的三个关键概念PGN参数组编号不等于CAN ID它是把DP、PF、PS按规则组合出来的18位编号。PGN的意义在于描述“这一帧在说什么”而SPN描述“帧里的某个字节或某段字节在说什么”。比如发动机转速是SPN 190但它的载体是PGN 61444EEC1也就是CAN ID0x0CF00400。测试工具解析时线路是“CAN ID → PGN → 数据 → SPN值”。PGN的计算规则是当PF 240时PS是目标地址PGN (DP 16) | (PF 8)目标地址另存当PF 240时PS是组扩展PGN (DP 16) | (PF 8) | PS。下面是一组做J1939测试几乎必用的PGNPGN名称常见SPN0x00EA00请求PGNRequest无0x00EE00地址声明无0x00F004EEC1电子发动机控制器1SPN 190发动机转速0x00FEF1CCVS车速与巡航控制SPN 84车轮速度0x00FF00诊断故障报文DM1SPN 1213等2.3 可直接复用的29位ID拆分函数在STM32工程里HAL库的回调函数拿到的是CAN_RxHeaderTypeDef中的ExtId直接对它做位移和掩码即可。下面这个函数是我做J1939测试工具时一直沿用的版本typedef struct { uint8_t priority; /* 优先级 0~7 */ uint8_t ext_data_page; /* EDPJ1939通常为0 */ uint8_t data_page; /* DP */ uint8_t pdu_format; /* PF */ uint8_t pdu_specific; /* PS */ uint8_t src_addr; /* SA */ uint16_t pgn; /* 组合出的PGN */ uint8_t dest_addr; /* PF240时为目标地址否则为0xFF */ } j1939_id_t; void j1939_split_id(uint32_t can_id, j1939_id_t *o) { o-priority (uint8_t)((can_id 26) 0x07); o-ext_data_page (uint8_t)((can_id 25) 0x01); o-data_page (uint8_t)((can_id 24) 0x01); o-pdu_format (uint8_t)((can_id 16) 0xFF); o-pdu_specific (uint8_t)((can_id 8) 0xFF); o-src_addr (uint8_t)(can_id 0xFF); o-pgn ((uint16_t)o-data_page 16) | ((uint16_t)o-pdu_format 8); if (o-pdu_format 240) { o-dest_addr o-pdu_specific; /* PDU1格式PS是目标地址不并入PGN */ } else { o-pgn | o-pdu_specific; o-dest_addr 0xFF; /* PDU2格式广播 */ } }注意pgn的类型用了uint16_tPDU1格式下PGN最大是0x1FF00已经超过16位所以实际工程里建议直接用uint32_t保存避免抛掉DP字段后把PDU1的PGN截断。写这个函数时最容易犯的错是把DP位当成bit16直接左移16但29位ID里DP的实际位置是bit24要先把29位ID右移24位再取1位而不是用bit16。3. 用STM32收发J1939报文的最小工程初始化、滤波器与中断3.1 CAN外设初始化波特率250K与采样点设置J1939的物理层就是CAN波特率多为250 kbps少数场合用500 kbps。STM32的bxCAN外设本身不区分标准帧和扩展帧所以初始化只需要把模式设为Normal、扩展帧允许并确认能够匹配250K的位时间参数。以STM32F103的APB1时钟36 MHz为例一个可用的配置是预分频器9、BS1为13个时间单元、BS2为2个时间单元位时间总计1 13 2 16 TQ波特率正好是36 MHz / (9 x 16) 250 kbps。CAN_HandleTypeDef hcan; void can_init_j1939(void) { hcan.Instance CAN1; hcan.Init.Prescaler 9; /* APB136MHz时得到2MHz/bit? */ hcan.Init.SyncJumpWidth CAN_SJW_1TQ; /* SJW取1即可 */ hcan.Init.TimeSeg1 CAN_BS1_13TQ; hcan.Init.TimeSeg2 CAN_BS2_2TQ; hcan.Init.Mode CAN_MODE_NORMAL; hcan.Init.AutoBusOff DISABLE; hcan.Init.AutoWakeUp DISABLE; hcan.Init.AutoRetransmission ENABLE; /* J1939要求自动重发 */ hcan.Init.ReceiveFifoLocked DISABLE; hcan.Init.TransmitFifoPriority DISABLE; HAL_CAN_Init(hcan); }上面的Prescaler注释里“2MHz/bit”是笔误实际效果是产生16个时间单元SJW只影响重新同步时的跳跃宽度不一定需要放大。J1939报文对时序并不算苛刻但采样点建议落在80%附近。这里的采样点是(1 BS1) / (1 BS1 BS2)即14 / 16 87.5%略高在短总线测试环境中通常没有问题。如果换了APB1时钟或换用F4系列套下面这个公式重新算即可CAN比特率 APB1时钟 / (Prescaler x (1 BS1 BS2))3.2 滤波器设计J1939测试工具要接收哪些帧J1939测试工具的职责和ECU不同。ECU只关注自己目标地址的帧和广播帧测试工具必须看全局所以滤波器最省事的配置就是全通。bxCAN的掩码模式下把掩码所有位置0标识符任何位不参与比较等于不过滤CAN_FilterTypeDef sFilterConfig; sFilterConfig.FilterIdHigh 0x0000; sFilterConfig.FilterIdLow 0x0000; sFilterConfig.FilterMaskIdHigh 0x0000; sFilterConfig.FilterMaskIdLow 0x0000; /* 掩码全0接收所有帧 */ sFilterConfig.FilterFIFOAssignment CAN_RX_FIFO0; sFilterConfig.FilterBank 0; sFilterConfig.FilterMode CAN_FILTERMODE_IDMASK; sFilterConfig.FilterScale CAN_FILTERSCALE_32BIT; sFilterConfig.FilterActivation ENABLE; HAL_CAN_ConfigFilter(hcan, sFilterConfig); HAL_CAN_Start(hcan); HAL_CAN_ActivateNotification(hcan, CAN_IT_RX_FIFO0_MSG_PENDING);如果目标是做成一个特定ECU的仿真器只收发给自己的报文就要把掩码设置为保留SA和部分PGN位。举个例子只想接收目标地址为0x11的PDU1帧掩码需要让bit7~bit0不关心之外的位全关心这时的FilterId和FilterMaskId要按寄存器位填入29位ID左移3位后的值。初次调试不建议开这么细先用全通把数据抓全确认PGN解析正确后再加过滤。3.3 接收中断与发送函数3.3.1 接收回调从硬件帧到J1939结构体HAL库的接收中断回调里CAN_RxHeaderTypeDef已经区分了标准帧和扩展帧J1939只关心CAN_ID_EXT分支。回调里要做的就是把ExtId交给第2章的拆分函数然后连同8字节数据一起压入自己的环形队列不要在中断里做耗时解析void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef rx; uint8_t data[8]; j1939_id_t jid; if (hcan-Instance ! CAN1) { return; } HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, rx, data); if (rx.IDE ! CAN_ID_EXT) { return; /* J1939不用标准帧 */ } j1939_split_id(rx.ExtId, jid); j1939_queue_push(jid, data, rx.DLC); }这里有个容易被忽略的细节rx.IDE的类型是uint8_t但不要用memcmp或直接当整数比较HAL库定义里CAN_ID_EXT是CAN_ID_EXT枚举值直接判断即可。DLC在J1939中通常为8但如果上游设备发的是短帧解析SPN之前必须做长度边界检查否则越界读内存。3.3.2 发送函数把PGN和SA还原成29位ID发送和接收互逆。给定优先级、PGN、目标地址、源地址后先要判断PDU1还是PDU2才能决定PS字段填目标地址还是PGN的低8位然后组合29位IDuint8_t j1939_send(uint8_t prio, uint32_t pgn, uint8_t dest, uint8_t sa, uint8_t *data, uint8_t dlc) { CAN_TxHeaderTypeDef tx; uint32_t mailbox; uint32_t id 0; uint8_t dp (pgn 16) 0x01; uint8_t pf (pgn 8) 0xFF; uint8_t ps; if (pf 240) { ps dest; } else { ps pgn 0xFF; } id | ((uint32_t)prio 26); id | ((uint32_t)dp 24); id | ((uint32_t)pf 16); id | ((uint32_t)ps 8); id | sa; tx.ExtId id; tx.IDE CAN_ID_EXT; tx.RTR CAN_RTR_DATA; tx.DLC dlc; if (HAL_CAN_AddTxMessage(hcan, tx, data, mailbox) ! HAL_OK) { return 1; } return 0; }发送时还有一个J1939特有的约定测试源码里如果要连续发送转速、车速这类周期性报文发送间隔必须按PGN的推荐周期来不能像调试CAN裸帧一样收到按键就发。EEC1典型的发送周期是10ms或50ms随机时间发送会被下游仪表当成噪声或触发错误帧。4. 把测试源码做成能上车的诊断工具报文构造、模拟ECU与端到端测试4.1 构造EEC1发动机转速报文EEC1的PGN是0x00F004CAN ID换算出来是0x0CF00400。数据域里SPN 190发动机转速从第3字节开始长度16位分辨率0.125 rpm/bit也就是数值要乘以8。所以构造一个1500 rpm的EEC1报文编码后的数值是12000拆成两个字节放到数据区void send_eec1_rpm(uint16_t rpm) { uint8_t data[8] {0}; uint16_t enc; enc rpm * 8; /* 0.125 rpm/bit */ data[0] 0x04; /* 发动机扭矩模式 */ data[1] 0x00; /* 实际扭矩百分比 */ data[2] (enc 8) 0xFF; data[3] enc 0xFF; j1939_send(3, 0x00F004, 0xFF, 0x00, data, 8); }注意data[0]的扭矩模式字段在不同厂商ECU里取值不完全一致测试时如果想避免被测设备误判为真实扭矩控制可以保持为0。j1939_send最后一个参数0x00是源地址充当“测试工具SA0”的角色。J1939里源地址全局唯一如果现场总线已经有其他节点占用了0x00需要先做地址声明冲突检测否则发出去的帧会被总线上其他节点视为非法。4.2 模拟ECU应答请求Request PGN处理J1939的请求响应是测试工具最常用的功能。请求PGN0xEA00数据域前4字节是小端存放的请求目标PGN。模拟ECU收到请求后先判断目标PGN是不是自己支持的是则按周期回复不是则不回。处理逻辑可以写成一个独立函数void on_request_frame(j1939_id_t *jid, uint8_t *data) { uint32_t req_pgn; req_pgn data[0] | ((uint32_t)data[1] 8) | ((uint32_t)data[2] 16); if (req_pgn 0x00F004) { send_eec1_rpm(1200); /* 模拟发动机1200转 */ } }这段代码省略了jid目标地址判断实际工程里要先判断jid-pdu_specific是否等于自己的SA。如果收到请求时没有判断目标地址就回复总线上所有请求都会得到响应直接暴露被测源码的协议错误。关注这个细节的读者基本就掌握了J1939应用层和裸CAN之间最核心的差异。4.3 端到端测试列表与验收标准把源码里的各功能模块接起来后建议用下面这张表做一遍回归比对着协议文档逐条念有效得多用例操作预期结果总线采样只开接收挂在真实J1939总线能连续抓到扩展帧PGN解析正确转速模拟执行send_eec1_rpm(1500)对端读取到1500 rpm误差小于1 rpm请求响应发0xEA00请求EEC1收到带目标地址校验的EEC1帧地址冲突两个节点SA同为0x00至少一个节点进入地址声明等待错误恢复拔掉总线终端电阻1秒总线不出现持续bus off重插后恢复这里特别提一下终端电阻。做CAN-J1939测试时STM32板子通过杜邦线连到车辆OBD接口的CAN-H和CAN-L车辆已经有两个终端电阻调试板不要额外并联。用双头转接头直接连PCB板时板端要保留120欧终端否则长线传输会出现大量位填充错误表现就是HAL_CAN_ERROR_ACK狂刷。5. 让J1939测试工具更稳的3个技巧总线失步、错误码与报文间隔5.1 SJW与采样点链路对不上时的第一个检查项如果源码在手、初始化也照做了总线上还是不断出错误帧先不要怀疑协议栈回头看一眼SJW。CAN_SJW_1TQ表示同步跳跃宽度只有1个时间单元当总线上存在时钟偏差或线缆较长时这个值偏小。对J1939的250K速率把SyncJumpWidth放宽到CAN_SJW_2TQ通常能解决偶发同步丢失的问题。另一个检查项是采样点整车企业常用80%附近的采样点穿线路径长时尤其敏感。5.2 LEC错误码从ESR寄存器快速定位故障HAL库屏蔽了很多底层细节排查问题反而要回到寄存器。读取CAN_ESR的低3位LEC字段能区分是位填充错误、位显性错误还是ACK错误uint32_t esr hcan.Instance-ESR; uint8_t lec (esr 4) 0x07; if (lec 1) { /* 位填充错误多半是波特率不匹配 */ } else if (lec 6) { /* ACK错误总线上没有其他节点应答或只有本机 */ }如果ESR的BOFF位被置位说明已经进入Bus Off状态。HAL库默认关闭自动恢复需要在代码里检测后手动HAL_CAN_Stop、重新初始化并恢复中断使能。测试工具遇到这种情况直接复位CAN外设比重启整个MCU更有价值。5.3 用定时器测量报文到达间隔量化总线负载最后给一个进阶验证技巧用STM32的定时器输入捕获或者简单的DWT-CYCCNT计数器记录连续两帧同PGN报文的到达时间差能够量化总线负载和发送抖动。具体做法是在接收回调里对某PGN做计数每收到一帧记录tick再由主循环计算差值。若EEC1的周期标称50ms实测抖动超过正负5ms说明总线负载过高或上游节点内部调度不达标。这套方法在做ECU解放和仪表可靠性验证时比单纯看抓包工具的时间戳更直接因为以太网抓包时间戳经过协议栈转换精度往往不够。把这一条做到源码里你的测试工具就不再只是“能收能发”而是一台能说清“总线健不健康”的检测设备。本文还有配套的精品资源点击获取