ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32移植CAN-J1939协议栈:从ID解析到DM1故障码处理

STM32移植CAN-J1939协议栈:从ID解析到DM1故障码处理 简介基于STM32单片机的汽车CAN-J1939协议测试源码面向嵌入式硬件与汽车电子学习者可用于理解J1939协议栈在STM32平台上的移植与验证。代码围绕CAN收发主流程展开涵盖系统时钟、按键、LED、CAN引脚与中断初始化并提供J1939初始化及轮询处理框架便于对照学习地址声明、报文收发等关键环节。压缩包共127个文件约1.88MB以C源文件、H头文件为主辅以uvproj/uvopt工程文件、hex/axf烧录与调试文件以及map、lst等编译产物能够满足从工程编译到板级调试的基本需要。目前已有1873人学习下载说明该测试源码在同类资源中具有一定参考价值。通过对源码结构和调试文件的分析读者可较快掌握在STM32上搭建J1939测试环境的方法并在此基础上扩展ECU模拟、多节点通信等实验是入门汽车CAN总线应用开发的实用资料。1. 为什么汽车电子要用 J1939而不是直接读 CAN 帧在商用车、农机和工程机械的研发测试里CAN 总线只是物理层和数据链路层真正决定“这条报文是发动机转速还是刹车压力”的是 J1939 协议。常见测试源码包名里的“J1939”指的是 SAE J1939 在 CAN 2.0B 29 位扩展帧的基础上定义的一套应用协议。它把 29 位 ID 拆成优先级、PGN参数组号和源地址把数据字节对应到 SPN可疑参数编号还额外定义了多包传输、地址请求、故障码上报等机制。很多人拿到“基于 STM32 单片机的汽车 CAN-J1939 协议测试源码”时第一反应是直接上板跑一下却不知道如何把源码包里的协议栈和 STM32 的硬件 CAN 外设对接起来更不知道怎样验证收到的报文是否解析正确。我按移植这类工程的习惯从 29 位 ID 的拆解讲起再给出 STM32 的 CAN 初始化、常用 PGN 收发、USB-CAN 抓包对比和故障码解析。适合正在做车载网关、发动机测试台架或后装远程监测设备的嵌入式开发人员。2. 从 CAN 控制器到 J1939 协议栈的映射2.1 J1939 的 29 位标识符优先级、PGN 和源地址J1939 使用 CAN 扩展帧的 29 位标识符从高到低分别是3 位优先级、1 位保留位、1 位数据页、8 位 PF、8 位 PS、8 位源地址。保留位和数据页在绝大多数量产报文里是 0所以平时解析时可以忽略。PGN 是把 PF 和 PS 合并后得到的 24 位值但合并规则和 PF 有关当 PF 小于 0xF0 时PS 是目标地址PGN 等于 DP16 或 PF8当 PF 大于等于 0xF0 时PS 是组扩展PGN 要包含整个 PS 字段。例如 EEC1 报文的 PGN 是 0xF004PF 是 0xF0所以 PS 0x04 属于组扩展。我在移植源码时会先把 J1939 ID 拆解函数放到独立文件里因为后面的报文过滤和发送都依赖它。核心代码如下#define J1939_PRIO_SHIFT 26u #define J1939_DP_SHIFT 24u #define J1939_PF_SHIFT 16u #define J1939_PS_SHIFT 8u uint32_t j1939_get_pgn(uint32_t ext_id) { uint32_t dp (ext_id J1939_DP_SHIFT) 0x01u; uint32_t pf (ext_id J1939_PF_SHIFT) 0xFFu; uint32_t ps (ext_id J1939_PS_SHIFT) 0xFFu; if (pf 0xF0u) { return (dp 16u) | (pf 8u); } return (dp 16u) | (pf 8u) | ps; }这段代码没有用位域而是用移位掩码因为编译器对位域的内存布局依赖架构换工具链时容易踩坑。传入ext_id时要用 HAL 接收结构体里的ExtId而不是StdId。当 PF 小于 0xF0 时PS 是目标地址生成 PGN 时把它丢掉这样广播和单播可以统一到同一个 PGN 上。J1939 优先级范围是 0 到 7数值越小优先级越高控制报文一般用优先级 3数据报文用 6。比如 EEC1 的扩展 ID 是 0x18F00400按 29 位展开后优先级是 6PF 是 0xF0PS 是 0x04源地址是 0x00。用上面的函数算出来就是 PGN 0xF004。如果实际抓包看到 ID 对不上先查库函数是否对 ID 做了字节序转换。2.2 STM32 CAN 外设初始化位时序、SJW 和滤波器STM32F103 的 bxCAN 外设虽然寄存器不多时序配置却直接影响 J1939 通信稳定性。J1939 最常见的波特率是 250 kbps少数车载平台会用到 500 kbps。我一般用 HAL 库在MX_CAN1_Init里配置核心是预分频、BS1、BS2 和同步跳跃宽度 SJW。对于系统主频 72 MHz、APB1 时钟 36 MHz 的 STM32F103C8T6预分频设为 8则时间量子 tq 为 4.5 MHz每位需要 18 tq才能得到 250 kbps。BS1 取 13BS2 取 4加上固定 SYNC 段 1 tq正好 18 tq。SJW 先取 1后续多节点测试时如果错误帧较多再把 SJW 调到 2 或 3让控制器对总线边沿漂移更宽容。CAN_HandleTypeDef hcan1; hcan1.Instance CAN1; hcan1.Init.Prescaler 8; hcan1.Init.Mode CAN_MODE_NORMAL; hcan1.Init.SyncJumpWidth CAN_SJW_1TQ; hcan1.Init.TimeSeg1 CAN_BS1_13TQ; hcan1.Init.TimeSeg2 CAN_BS2_4TQ; hcan1.Init.TimeTriggeredMode DISABLE; hcan1.Init.AutoBusOff ENABLE; hcan1.Init.AutoWakeUp ENABLE; hcan1.Init.AutoRetransmission DISABLE; hcan1.Init.ReceiveFifoLocked DISABLE; hcan1.Init.TransmitFifoPriority ENABLE; if (HAL_CAN_Init(hcan1) ! HAL_OK) { Error_Handler(); }AutoBusOff置为 ENABLE 很关键测试台架上如果总线短路或对端波特率不匹配CAN 控制器会进入 Bus-Off不自动恢复的话后续报文全部发不出去。AutoRetransmission我设成 DISABLE是为了让发送超时尽快返回避免错误帧一直在重发。TransmitFifoPriority置 ENABLE 后发送邮箱按 FIFO 顺序申请CAN 本身再按 ID 优先级仲裁适合做多节点冲突测试。滤波器方面J1939 全部是扩展帧所以我用 32 位宽度的屏蔽模式先全收由协议层做 PGN 过滤CAN_FilterTypeDef filter; filter.FilterIdHigh 0; filter.FilterIdLow 0; filter.FilterMaskIdHigh 0; filter.FilterMaskIdLow 0; filter.FilterScale CAN_FILTERSCALE_32BIT; filter.FilterMode CAN_FILTERMODE_IDMASK; filter.FilterBank 0; filter.FilterActivation ENABLE; HAL_CAN_ConfigFilter(hcan1, filter);如果只关心某个 PGN可以把屏蔽字对应位写成不关心让硬件只放行目标帧。但调试阶段我一般先全收用 USB-CAN 抓包确认 PGN 解析正确后再裁剪滤波器避免“过滤器挡掉了目标报文”这种隐蔽问题。2.3 裸机轮询还是中断接收我常用的双缓冲处理源码包里很多是裸机循环直接在主循环里调用HAL_CAN_Receive会丢报文尤其是多个 ECU 同时发周期报文时。我常用的做法是CAN 接收中断把报文拷到环形缓冲区主循环再逐条做协议解析解析完把消息交给应用层回调。这样中断里只做快速拷贝耗时控制在几微秒。#define RX_FIFO_SIZE 64u typedef struct { uint32_t id; uint8_t data[8]; uint8_t dlc; } can_msg_t; static can_msg_t rx_fifo[RX_FIFO_SIZE]; static volatile uint8_t rx_head 0; static volatile uint8_t rx_tail 0; void CAN1_RX0_IRQHandler(void) { HAL_CAN_IRQHandler(hcan1); } void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef hdr; can_msg_t msg; if (hcan-Instance ! CAN1) { return; } if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, hdr, msg.data) ! HAL_OK) { return; } msg.id hdr.ExtId; msg.dlc hdr.DLC; uint8_t next (rx_head 1) % RX_FIFO_SIZE; if (next ! rx_tail) { rx_fifo[rx_head] msg; rx_head next; } }主循环消费时只需要比较rx_tail和rx_head因为rx_tail只有主循环写rx_head只有中断写。环形缓冲区大小选 64是因为 J1939 诊断多帧报文在解码前多条 TP.DT 可能同时到达64 条足够支撑源地址切换测试。如果缓冲区丢帧排查方向是主循环里解析耗时太长而不是中断响应不够快。3. 用源码包快速搭一个 J1939 报文收发测试台3.1 先看懂源码包的目录结构和编译入口拿到源码包后不要急着打开 Keil5 点编译先看目录组织。常见结构分四层Core放启动文件和主循环CAN_Driver放 STM32 的 CAN 外设驱动J1939_Stack放 PGN 解析、TP 传输和诊断上层App放测试主程序。有的源码包还把 PGN 定义做成 Excel 或 CSV 表配合脚本生成 C 数组这种情况下要重点看生成脚本不要手工去改数组否则升级协议版本时很容易漏。编译入口一般是 Keil5 工程文件或者 Makefile。用 Keil5 时我会先检查两件事一是芯片型号是否选了 STM32F103C8T6二是 C/C 编译选项里是否定义了USE_HAL_DRIVER和STM32F103xB。如果解压后编译报错找不到stm32f1xx_hal_conf.h通常是 STM32F1 系列芯片包没装。你可以在 Keil5 的 Pack Installer 里安装如果之前只装过 C51 版本Keil5 本身也支持同时装 ARM 和 C51 的组件。源码包里有时会有一个J1939_cfg.h或j1939_config.c这是协议层的总开关。我移植时先打开该文件把J1939_ENABLE_TP和J1939_ENABLE_DM打开因为后面测试多帧报文和 DM1 故障码都要用。如果这些宏默认关闭会出现“单帧报文正常、长报文全部丢弃”的问题。3.2 三个必调参数源地址、目标地址、PGN 过滤J1939 协议栈不是装完就能跑至少有三个参数需要按环境调整。第一个是源地址它决定这条报文由哪个控制器发出。SAE J1939-81 里发动机是 0x00变速箱是 0x03ABS 是 0x0B诊断仪是 0xFE广播地址是 0xFF。我模拟发动机台架时会把 STM32 配成 0x00做网关时配成 0xFE。第二个是目标地址。单播报文的 PS 字段放目标地址广播报文的 PS 固定为 0xFF。源码包通常会提供类似j1939_set_local_address的函数调用后协议栈会自动更新源地址相关报文。第三个是 PGN 过滤协议栈内部维护回调表注册你想处理的 PGNj1939_init(0x00u); j1939_register_pgn(0xF004u, on_eec1_received); j1939_register_pgn(0xFEF1u, on_ccvs_received);j1939_init的第一个参数是本地源地址0x00表示模拟发动机控制器。0xF004是 EEC1 发动机转速和扭矩0xFEF1是 CCVS 车速。回调函数里拿到msg-data就可以去解析 SPN。这里不要在回调里直接做 printf 或浮点运算J1939 周期报文可能 10 ms 就来一条串口打印会卡住主循环。3.3 发送一条 EEC1 报文的完整流程我用一个最简单场景验证协议栈是否通让 STM32 每 100 ms 发一条 EEC1 报文其他设备能收到并解析出转速。首先把 PGN、源地址和优先级拼成 29 位扩展 IDuint32_t make_j1939_ext_id(uint8_t prio, uint32_t pgn, uint8_t src) { uint32_t pf (pgn 8u) 0xFFu; uint32_t ps pgn 0xFFu; return ((uint32_t)prio 26u) | (pf 16u) | (ps 8u) | (uint32_t)src; }这个函数把 PGN 0xF004 拆成 PF 0xF0、PS 0x04放到对应位置。注意ps在解析 PGN 时被并入了 PGN但发送时必须补回去否则对端设备收到的 PGN 会变成 0xF000。拼好 ID 后填充发送描述符CAN_TxHeaderTypeDef tx_header; uint32_t mailbox; uint8_t eec1_data[8] {0}; tx_header.ExtId make_j1939_ext_id(6u, 0xF004u, 0x00u); tx_header.IDE CAN_ID_EXT; tx_header.RTR CAN_RTR_DATA; tx_header.DLC 8; eec1_data[2] 0x8F; // 低字节 eec1_data[3] 0x50; // 高字节合起来转速约 2577 rpm eec1_data[4] 0x00; eec1_data[5] 0x00; eec1_data[6] 0x00; eec1_data[7] 0x00; HAL_CAN_AddTxMessage(hcan1, tx_header, eec1_data, mailbox);EEC1 报文的第 3、4 字节是发动机转速小端序分辨率 0.125 rpm/bit。示例中0x50 0x8F组合起来是 0x508F乘以 0.125 得到 2577 rpm。mailbox是发送邮箱号可以用来判断是否发送成功如果返回值不是HAL_OK优先检查总线是否空闲以及是否只剩一个节点导致无 ACK。4. 实测一条 J1939 报文用 USB-CAN 抓包对比4.1 选一款趁手的 USB-CAN 工具并解决端口打不开的问题台架测试中最快的验证方式不是看 STM32 内部寄存器而是用 USB-CAN 分析仪挂在同一条总线上抓包。常见工具包括周立功系列、PCAN-USB、CANable 等上位机软件不同但抓到的扩展帧 ID 和数据场格式一致。如果是虚拟串口型的 USB-CAN 转接器第一次插入后设备管理器可能报“can not open com port”。这个报错通常不是驱动缺失而是上位机软件里的通讯波特率没有和转接器匹配。常见解决方法是先在设备管理器确认 COM 口号再在上位机工作模式里把接口参数改成 250 kbps。如果依然打不开把 USB-CAN 重新插拔一次并检查是否被其他程序占用。抓包时把软件过滤器设为“只显示扩展帧”或直接输入 ID 掩码 0x18FF0000这样能看到所有 J1939 报文。抓包软件显示的 ID 是 29 位扩展 ID不是 J1939 PGN。STM32 发出去的 EEC1 显示为 0x18F00400要手动解析出 PGN 0xF004。4.2 抓包后如何判断报文 ID 解析正确拿到抓包结果后先对照 J1939 常用 PGN 表。下面是我测试时常用的参考报文名PGN默认优先级周期数据内容EEC10xF004610ms发动机转速、扭矩CCVS0xFEF1650ms车速、刹车状态VD0xFEA56100ms车辆识别号DM10xFECA6故障时故障指示灯、SPN/FMI比如 USB-CAN 上看到 ID 0x18F00400先按 29 位 ID 拆出 PF0xF0、PS0x04再查表知道这是 EEC1源地址 0x00 表示发送节点是发动机。如果拆出来 PS 不是 0x00就要检查是不是在抓包软件里做了 ID 转换或者总线上有其他节点同时使用相同源地址。抓包对比时容易忽略的是周期报文的抖动。J1939 周期报文允许一定抖动但请求响应类报文要求在 20 ms 内回复。测试源码里一般有j1939_process_request函数收到地址请求或 PGN 请求后要尽快调用j1939_send_response。如果抓包看到请求帧但没有响应先检查两张 CAN 卡是否接到同一路总线上导致收发逻辑混乱。4.3 CAN 总线仲裁和错误帧怎么排查J1939 多节点通信靠 CAN 总线仲裁优先看 ID 高低。EEC1 和 CCVS 如果都是优先级 6就继续比较后面的 PGN 和源地址ID 数值小的先发送。如果两个节点同时从地址 0x00 发同 PGN 报文总线会产生错误帧因为显性位覆盖了隐性位对端节点不承认这个 ID。排查错误帧要看 STM32 的 CAN 错误状态。HAL 库里可以这样读uint32_t can_err HAL_CAN_GetError(hcan1); if (can_err HAL_CAN_ERROR_ACK) { // 总线上至少要有另一个节点回 ACK否则发送失败 } if (can_err HAL_CAN_ERROR_BOF) { // 已进入 Bus-Off需要重新恢复 }HAL_CAN_ERROR_ACK出现时说明总线上只有一个 STM32 而没有 USB-CAN这是正常现象。HAL_CAN_ERROR_BOF出现时要重点检查 120 欧终端电阻。J1939 要求总线两端各一个 120 欧电阻如果只有一端有信号反射会表现为 CRC 错误一帧收到一帧丢失。SJW 也是一个因素STM32F103 的 SJW 可以配 1 到 4 tq多节点环境建议从 1 调到 2再观察错误帧计数是否下降。5. 把测试源码改成诊断仪多帧传输和 DM1 解析5.1 用 BAM 接收多帧报文J1939 的很多诊断数据超过 8 字节比如故障码快照必须走传输协议。广播式多帧 BAM 的发送流程是先发一条 TP.CM 连接管理帧声明总长度和包数然后再发一组 TP.DT 数据包帧。测试源码里通常已经实现了 BAM 接收状态机核心变量是tp_total_bytes、tp_total_packet和tp_rx_index。收到 TP.CM 时把长度和包数存下来后续每收到 TP.DT 就往缓冲区里拷贝。void on_tp_cm_received(uint8_t *data, uint8_t len) { if (len 8 || data[0] ! 0x20u) { // 0x20 表示广播多帧 return; } tp_total_bytes ((uint16_t)data[2] 8) | data[1]; tp_total_packet data[3]; tp_rx_index 0; }这里data[1]和data[2]是小端字节长度data[3]是总包数。收到 TP.DT 时包号放在第一字节后面 7 字节按顺序写入缓冲区。比较容易踩的坑是 TP.CM 和 TP.DT 的 PGN 不同不能用同一个回调函数处理否则包号永远对不上。5.2 解析 DM1 故障码DM1 是诊断仪最常用的一帧PGN 0xFECA数据场里是故障码列表。我写的解析逻辑如下typedef struct { uint32_t spn; uint8_t fmi; uint8_t oc; } j1939_fault_t; int parse_dm1(uint8_t *data, uint8_t len, j1939_fault_t *faults, int max_faults) { if (len 6) return 0; int count 0; for (int i 0; i (len - 2) / 4 count max_faults; i) { uint8_t *raw data[2 i * 4]; faults[i].spn ((uint32_t)raw[0] | ((uint32_t)raw[1] 8) | ((uint32_t)raw[2] 16)) 0x7FFFFu; faults[i].fmi (raw[2] 5) | ((raw[3] 0x03u) 3); faults[i].oc (raw[3] 2) 0x7Fu; count; } return count; }参数说明DM1 前 2 字节是故障灯状态和故障总数之后每 4 字节是一条 DTC。SPN 占 19 位横跨raw[0]、raw[1]和raw[2]的低 3 位FMI 占 5 位是raw[2]的高 3 位加上raw[3]的低 2 位OC 占 7 位是raw[3]的中间位单位是发生次数。5.3 验证源码可用的三个指标在把源码包移植到自己的板子之前我用三个指标快速验收。第一是连续 24 小时运行观察 CAN 错误寄存器里的 ACK 和 CRC 错误计数是否为零。第二是周期报文抖动用逻辑分析仪抓 EEC1 的相邻帧间隔100 ms 周期的报文抖动要小于 1 ms若抖动过大优先检查主循环里有没有阻塞式 printf。第三是总线负载率250 kbps 下多节点测试时不要超过 30%超过后发送缓冲排队会导致丢帧。这三个指标都通过了再把这个 STM32 节点接到真实整车或台架上做故障注入测试。本文还有配套的精品资源点击获取
返回列表