简介:基于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的位分配关系如下表所示:
| 位段 | 长度 | 位置 | 说明 |
|---|---|---|---|
| Priority | 3 bit | bit28 ~ bit26 | 报文优先级,0最高,7最低 |
| EDP | 1 bit | bit25 | 扩展数据页,J1939常用0 |
| DP | 1 bit | bit24 | 数据页,常用0 |
| PDU Format(PF) | 8 bit | bit23 ~ bit16 | 决定PDU1还是PDU2格式 |
| PDU Specific(PS) | 8 bit | bit15 ~ bit8 | PDU1为目标地址,PDU2为组扩展 |
| Source Address(SA) | 8 bit | bit7 ~ bit0 | 发送节点地址 |
J1939的报文默认是250 kbps,但物理层依然是CAN,所以总线上抓到的每个扩展帧都可以用上面这张表拆开。测试源码里第一步要做的事情,就是写一个把ExtId拆成结构体的函数,为后面的PGN过滤和SPN提取打底。
2.2 PGN、SPN与源地址:测试源码里的三个关键概念
PGN(参数组编号)不等于CAN ID,它是把DP、PF、PS按规则组合出来的18位编号。PGN的意义在于描述“这一帧在说什么”,而SPN描述“帧里的某个字节或某段字节在说什么”。比如发动机转速是SPN 190,但它的载体是PGN 61444(EEC1),也就是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测试几乎必用的PGN:
| PGN | 名称 | 常见SPN |
|---|---|---|
| 0x00EA00 | 请求PGN(Request) | 无 |
| 0x00EE00 | 地址声明 | 无 |
| 0x00F004 | EEC1,电子发动机控制器1 | SPN 190发动机转速 |
| 0x00FEF1 | CCVS,车速与巡航控制 | SPN 84车轮速度 |
| 0x00FF00 | 诊断故障报文DM1 | SPN 1213等 |
2.3 可直接复用的29位ID拆分函数
在STM32工程里,HAL库的回调函数拿到的是CAN_RxHeaderTypeDef中的ExtId,直接对它做位移和掩码即可。下面这个函数是我做J1939测试工具时一直沿用的版本:
typedef struct { uint8_t priority; /* 优先级 0~7 */ uint8_t ext_data_page; /* EDP,J1939通常为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; /* PF<240时为目标地址,否则为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_t,PDU1格式下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; /* APB1=36MHz时得到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位ID:
uint8_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是0x00F004,CAN 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是源地址,充当“测试工具SA=0”的角色。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解放和仪表可靠性验证时,比单纯看抓包工具的时间戳更直接,因为以太网抓包时间戳经过协议栈转换,精度往往不够。把这一条做到源码里,你的测试工具就不再只是“能收能发”,而是一台能说清“总线健不健康”的检测设备。
本文还有配套的精品资源,点击获取