J1939协议栈C源码解析:CAN多帧报文与TP层传输的实现

发布时间:2026/10/21 12:23:21
J1939协议栈C源码解析:CAN多帧报文与TP层传输的实现
简介一份基于C语言实现的SAE J1939协议栈源码包面向车载网络与嵌入式CAN总线开发者旨在帮助快速理解J1939报文结构与通信流程可用于构建兼容重型车辆及工程机械的通信节点。压缩包共5个文件包含C源文件、头文件、PDF说明文档、示例压缩包及cls辅助文件整体约270KB源码覆盖CAN接口初始化、报文解析与构建、地址管理、错误处理等核心模块。目前已有420人学习。配套示例与说明文档有助于对照学习PGN、SPN、PDU、SA等关键协议概念通过学习源码可掌握CAN消息收发、J1939报文解析、地址分配及错误恢复等核心流程也可支撑诊断工具、车辆监控系统或ECU通信功能的二次开发。适合嵌入式开发者作为从CAN到J1939协议落地的进阶参考资料。1. J1939协议栈源码解析一个让多帧报文不再翻车的CAN通信基础件做商用车仪表和ECU联调时最让人头疼的不是CAN帧怎么收发而是J1939协议里超过8字节的多帧报文——发动机参数、故障日志、标定数据都得靠传输协议拆包、组包一旦状态机处理不对丢帧、乱序、超时全来了。这套J1939 C源码就是干这个的它把SAE J1939协议栈完整地实现成了C语言代码包含传输层TP层、网络层地址管理和应用层接口收发链路从CAN帧到业务消息一次打通。适合三类人要开发J1939 ECU节点的嵌入式工程师、做总线模拟器和诊断工具的测试开发、以及想快速理解J1939协议落地细节的学生。源码不依赖特定硬件换个CAN驱动就能跑。2. 分层架构与文件地图从CAN帧到J1939消息只经过三层2.1 为什么说J1939的关键在协议分层而不是CAN本身J1939跑在CAN 2.0B扩展帧上29位ID里同时塞了优先级、数据页、PF、PS、源地址好几组信息。很多新手直接拿原始CAN帧去解析代码写到一半就被PGN计算搞晕。其实J1939的复杂度不在CAN收发而在协议栈的层次拆解。内核里的29位ID布局是固定的bit 26~28是优先级bit 24是数据页bit 16~23是PFPDU格式bit 8~15是PSPDU特定bit 0~7是源地址SA。J1939消息类型靠PGN区分PGN就是从PF和PS算出来的当PF小于等于239时走PDU1格式PS字段直接就是目标地址DAPGN PF 8当PF在240到255之间时走PDU2格式PS是PGN的扩展字节PGN (PF 8) | PS。这套源码把这三层分得很清楚CAN驱动抽象层负责把不同控制器统一成三个函数J1939核心层负责ID解析、PGN路由、TP层多帧重组和网络层地址管理应用接口层只面对j1939_msg_t消息结构体业务代码不用关心底层是单帧还是多帧。分层的收益在移植时特别明显换一个MCU平台通常只动CAN驱动抽象层那一个文件。2.2 目录结构与文件职责拿到源码包先别急着编译把文件铺开看一遍整体结构一般长这样j1939/ ├── src/ │ ├── j1939.c # 协议栈核心消息收发主流程 │ ├── j1939_tp.c # 传输协议多帧拆包组包状态机 │ ├── j1939_net.c # 网络层地址声明与冲突处理 │ ├── j1939_app.c # 应用层入口PGN分发回调 │ ├── j1939_msg.c # 消息工具PGN提取、字节序转换 │ └── can_drv.c # CAN驱动适配示例vcan/Linux ├── include/ │ ├── j1939.h # 对外头文件业务代码只需包含它 │ ├── j1939_tp.h │ ├── j1939_net.h │ └── can_drv.h ├── example/ │ ├── main.c # 示例节点程序 │ └── Makefile └── README.md各文件的职责边界很明确j1939.c是整个协议栈的总调度收到CAN帧后先解出ID里的PGN判断是单帧还是TP层报文再决定自己处理还是交给j1939_tp.cj1939_net.c管的是节点地址上电时发地址声明、收到冲突时比优先级j1939_app.c是业务代码的落脚点所有应用层PGN都在这里挂回调。can_drv.c是唯一和硬件相关的文件源码包里通常提供Linux SocketCAN版本换平台时主要改这个文件。2.3 核心数据结构消息体与传输连接控制块协议栈里最核心的数据结构就是消息体j1939_msg_t和传输连接控制块j1939_tp_con_t。前者是应用层和协议栈之间的统一载体后者是TP层多帧传输的状态记录类似TCP协议栈里的PCB控制块。typedef struct { uint32_t pgn; /* 参数组编号消息的唯一标识 */ uint8_t prio; /* 优先级 0~7越小优先级越高 */ uint8_t src; /* 源地址 SA */ uint8_t dst; /* 目标地址 DA0xFF 表示广播 */ uint8_t data[256]; /* 数据缓冲区多帧重组后也存这里 */ uint16_t len; /* 有效数据长度单位字节 */ } j1939_msg_t; typedef struct { uint8_t state; /* 连接状态空闲/等CTS/传输中/超时 */ uint8_t dst; /* 对端地址 */ uint16_t total_len; /* 消息总长度 */ uint8_t total_pkt; /* 总包数 */ uint8_t rx_next; /* 下一个期望收到的包序号 */ uint16_t timeout_cnt; /* 超时计数器由定时器驱动 */ uint8_t buf[1785]; /* 重组缓冲区TP最大长度 */ } j1939_tp_con_t;这里有两个设计值得注意。一是data用256字节而不是8字节因为J1939的TP层最大能传1785字节255包 × 7字节缓冲区太小的话多帧重组没法做。二是j1939_tp_con_t按连接维度管理状态每个远端地址都有自己的缓冲区总线上同时有多个节点发多帧时不会互相踩内存。2.4 设计取舍协议栈如何做到硬件无关这套源码的移植性来自一个简单的驱动接口抽象can_drv_t结构体定义了三个函数指针typedef struct { int (*init)(void); int (*send)(uint32_t can_id, uint8_t *data, uint8_t len); int (*recv)(uint32_t *can_id, uint8_t *data, uint8_t *len); } can_drv_t;协议栈核心完全不直接调用硬件API只认这个结构体。init负责初始化CAN控制器send发一帧扩展帧recv收一帧。至于底层用的是哪家MCU的CAN外设、哪个厂家的USB-CAN适配器、还是Linux的SocketCAN上层一概不关心。在某个国产车规MCU平台上移植时我只改了can_drv.c里的三个函数和硬件定时器初始化协议栈核心代码一行没动半天就调通了。3. 核心模块拆解TP层组包拆包与地址声明的完整链路3.1 多帧入口什么时候走TP层什么时候直接发J1939规定单帧消息数据不超过8字节时直接放到一个CAN帧里发超过8字节就必须走传输协议TP层把消息拆成多个数据包DT传输。源码的发送入口逻辑很简单int j1939_send(j1939_msg_t *msg) { if (msg-len 8) { return j1939_send_single(msg); /* 单帧直接发送 */ } else { return j1939_tp_send(msg); /* 多帧走TP层 */ } }这个长度判断是J1939协议栈的通用门槛8字节以内是普通CAN数据帧9字节以上就要进TP层状态机。调用方不需要自己判断协议栈内部自动切分业务代码只需要设置pgn、src、dst、data、len这几个字段。3.2 发送侧拆解TP.CM_BAM构造与数据分包TP层发送多帧消息时广播场景用BAM广播公告方式点对点场景用RTS/CTS握手方式。BAM模式下发送方先发一条TP.CM_BAM控制帧公告“我要发一条多长的消息”然后连续发送多个TP.DT数据帧接收方不需要逐包确认。构造这条控制帧的代码如下static void j1939_tp_fill_cm_bam(const j1939_msg_t *msg, uint8_t *data) { uint8_t pkt_cnt (msg-len 6) / 7; /* 每帧7字节有效数据 */ data[0] 0x20; /* 控制字0x20 BAM广播 */ data[1] (msg-len 8) 0xFF; /* 总长度高字节大端 */ data[2] msg-len 0xFF; /* 总长度低字节 */ data[3] pkt_cnt; /* 总包数 */ data[4] 0xFF; /* 保留字节 */ data[5] (msg-pgn 16) 0xFF; /* PGN高字节 */ data[6] (msg-pgn 8) 0xFF; /* PGN中字节 */ data[7] msg-pgn 0xFF; /* PGN低字节 */ }这里最容易看错的就是字节序。data[1]和data[2]是消息总长度的大端表示比如50字节的消息data[1]0x00、data[2]0x32而不是反过来PGN的三字节同样按高位在前排。控制字0x20是BAM广播的标志如果是RTS请求发送控制字就是0x10。控制帧之后是数据帧。TP.DT帧每个CAN帧带一个包序号1~255和7字节数据发送循环把缓冲区分段塞进去static int j1939_tp_build_dt(j1939_tp_con_t *con, uint8_t *out) { uint8_t seq con-tx_next; /* 从1开始递增 */ out[0] seq; memcpy(out[1], con-buf[(seq - 1) * 7], 7); return 8; }J1939的BAM模式要求数据包连续发送包序号从1开始不能跳号。每个DT帧最多7字节有效数据是因为第0字节被包序号占了。如果最后一包不足7字节驱动层按实际长度设置DLC或者补0xFF对齐总线上一般补0xFF更常见。3.3 接收侧状态机序号校验与完整消息提交接收侧的逻辑比发送侧复杂因为发送方可能中途断掉、重复启动、或者两个连接共用缓冲区。源码里的TP接收状态机按“等待CM_BAM → 接收数据包 → 校验完成”三个阶段推进static int j1939_tp_on_dt(j1939_tp_con_t *con, uint8_t *data, uint8_t len) { uint8_t seq data[0]; if (seq ! con-rx_next) { con-err_count; return -1; /* 序号不连续返回错误 */ } memcpy(con-buf[(seq - 1) * 7], data[1], len - 1); con-rx_next; if (con-rx_next con-total_pkt) { j1939_msg_t msg {0}; msg.pgn con-pgn; msg.len con-total_len; msg.src con-src; msg.dst 0xFF; memcpy(msg.data, con-buf, con-total_len); j1939_app_on_message(msg); /* 整条消息交给应用层 */ return 1; /* 返回1表示一条消息完成 */ } return 0; }接收状态机的核心是序号连续性检查——如果收到的DT包序号和期望的不一致说明中间有帧丢失直接放弃这条连接比强行拼接更安全。完整消息组装完成后通过j1939_app_on_message回调提交给应用层应用层拿到的已经是完整的、按序排列的J1939消息不用关心它原来分成了多少帧。3.4 网络层地址声明上电250ms内的三次广播J1939网络层规定每个节点上电后延迟250ms然后广播自己的地址声明PGN 60928这样做是为了让总线上其他节点知道这个地址已经被占用。声明里带的NAME是64位节点身份标识源地址冲突时靠它决定谁保留地址。源码实现大致如下#define PGN_ADDR_CLAIM 0x00EE00u static void j1939_net_claim_address(uint8_t sa) { j1939_msg_t msg {0}; msg.pgn PGN_ADDR_CLAIM; msg.src sa; msg.dst 0xFF; /* 全局广播 */ msg.len 8; j1939_encode_name(msg.data[0]); /* 把64位NAME写入数据域 */ j1939_send(msg); }地址冲突的处理逻辑藏在NAME比较里。当自己的地址声明被别人占用时节点会比较两份NAME的数值数值较小的节点保留地址另一个切换到备用地址重新声明。这个“延迟250ms 三次广播 NAME比较”的流程是J1939节点能够动态组网的基石很多应用层异常其实都跟地址冲突没处理好有关。4. 编译与验证用VCAN虚拟总线跑通收发全流程4.1 环境准备Linux内核模块与CAN接口这套源码在Linux下验证最方便因为内核自带的SocketCAN和VCAN虚拟总线不需要真实CAN硬件就能模拟一条完整的总线适合先把协议逻辑跑通。创建虚拟CAN接口的命令如下# 加载内核CAN和VCAN模块 sudo modprobe can sudo modprobe vcan # 创建虚拟CAN接口 vcan0 sudo ip link add dev vcan0 type vcan sudo ip link set vcan0 up # 查看接口状态确认链路是 UNKNOWN 或 UP ip link show vcan0为什么用VCAN而不是直接上真实CAN卡因为VCAN把一个程序发出去的帧原样转发给总线上所有监听者但完全不关心时序、波特率和物理层最适合先验证协议栈逻辑是否正确。等逻辑验证过再换真实CAN适配器调物理层参数。4.2 编译源码包与示例程序源码包一般自带Makefile编译产物是示例节点程序。命令很简单# 在源码包根目录执行 make clean make # 编译产物通常是 example/j1939_example_tx 这类可执行文件 ls example/示例程序是一个最小可运行的J1939节点核心逻辑是构造一条超过8字节的广播消息并发送#include j1939.h int main(int argc, char *argv[]) { can_drv_t drv { .init vcan_init, .send vcan_send, .recv vcan_recv, }; j1939_init(drv, 0x80); /* 注册驱动本节点源地址 0x80 */ j1939_msg_t msg {0}; msg.pgn 0x00F100; /* 自定义PGN */ msg.prio 6; /* 常规优先级 */ msg.src 0x80; msg.dst 0xFF; /* 广播 */ msg.len 50; /* 超过8字节自动走TP层 */ for (int i 0; i 50; i) msg.data[i] 0x5A; /* 填充测试数据 */ j1939_send(msg); return 0; }这段代码把协议栈初始化和消息发送各做了一个最小示范。j1939_init接收驱动结构体和本节点源地址源地址0x80是一个常规的J1939节点地址msg.len设置为50保证了这条消息一定会走到TP层。如果编译报错多半是头文件路径不对检查Makefile里CFLAGS的-I参数是否指向include目录。4.3 跑通BAM多帧收发candump验证帧序列编译完成后开两个终端一个跑candump监听总线一个运行示例程序发送消息。监听端会看到这样的帧序列vcan0 18EEFF80 [8] 80 00 00 00 00 00 00 00 - 地址声明 vcan0 18ECFF80 [8] 20 00 32 08 FF 00 F1 00 - TP.CM_BAM vcan0 18EBFF80 [8] 01 5A 5A 5A 5A 5A 5A 5A - TP.DT 序号1 vcan0 18EBFF80 [8] 02 5A 5A 5A 5A 5A 5A 5A vcan0 18EBFF80 [8] 03 5A 5A 5A 5A 5A 5A 5A vcan0 18EBFF80 [8] 04 5A 5A 5A 5A 5A 5A 5A vcan0 18EBFF80 [8] 05 5A 5A 5A 5A 5A 5A 5A vcan0 18EBFF80 [8] 06 5A 5A 5A 5A 5A 5A 5A vcan0 18EBFF80 [8] 07 5A 5A 5A 5A 5A 5A 5A vcan0 18EBFF80 [8] 08 5A FF FF FF FF FF FF - 最后一包有效1字节这串输出就是一次标准的BAM多帧传输。第一条18EEFF80是节点上电后的地址声明第二条18ECFF80是TP.CM_BAM控制帧数据域里能看到总长度0x003250字节、总包数0x088包和PGN 0x00F100后面8条18EBFF80是数据帧包序号从1递增到8。抓到这个序列就说明协议栈的发送链路完全正常。验证接收链路的办法是对着跑把示例程序改造成接收模式监听端用cansend手动发一组BAM帧观察接收节点是否能在最后一个DT帧到达后打印出完整消息。推荐用两条vcan0通道成对验证一条模拟发送节点一条模拟接收节点消息能完整还原就说明收发状态机都是好的。4.4 单帧与多帧的门槛按长度自动切换前面main.c里msg.len 50触发了TP层如果把len改成8协议栈就会直接走单帧发送。单帧消息的CAN帧ID不经过TP层转换直接按PGN组装数据域就是消息本体。这个阈值判断是协议栈内部的默认行为不需要应用层干预。调试时的一个实用技巧是准备两条完全相同的消息一条len8、一条len50分别在总线上跑一遍对比两种模式的帧结构和时序。单帧模式只有一条CAN帧多帧模式有一条CM_BAM加多条DT。这能帮助直观理解“什么时候走TP层”的分界也能在排查问题时快速判断是协议层问题还是应用层问题。5. 避坑与排查CAN总线调试时的五个深坑实录5.1 地址冲突换上备用节点后原来的节点下线现象新板子上电后原有的节点ECU突然在总线上消失了candump里看不到它的周期消息。原因新旧两个节点的源地址SA都配置成了同一个值新节点上电时发出的地址声明被旧节点识别为冲突旧节点的NAME数值更大按规则让位后切到备用地址但新节点没有正确处理后续的地址协商。解决检查j1939_net.c里的地址冲突处理NAME比较的方向要对——“数值小的保留地址”示例逻辑如下static int name_less(const uint8_t *a, const uint8_t *b) { /* 按64位NAME数值比较小的拥有地址保留权 */ for (int i 7; i 0; i--) { if (a[i] ! b[i]) return a[i] b[i]; } return 0; /* 完全相等保留当前地址 */ }5.2 BAM多帧超时接收方总是收到残缺数据现象candump能看到CM_BAM和前面几个DT包但接收节点一直不提交完整消息也就是应用层回调没触发。原因TP接收状态机的连接控制块没有超时复位机制如果发送方中途停了接收方还在傻等后面的DT包状态卡死在“传输中”后续新消息的CM_BAM也无法启动新连接。解决给j1939_tp_con_t加一个超时定时器周期调用超时检查发现超时就把连接状态复位为空闲void j1939_tp_tick(j1939_tp_con_t *con) { if (con-state ! J1939_TP_IDLE) { if (con-timeout_cnt 100) { con-state J1939_TP_IDLE; /* 超时复位 */ con-rx_next 0; con-err_count; } } }这个100的数值就是超时阈值具体大小取决于定时器的调度周期200ms到500ms之间比较合理。5.3 字节序反转数据在仪表上显示为乱码现象用总线分析工具抓到的原始帧数据没问题但应用层读出来的转速、扭矩值是负数或者数值差很多。原因J1939数据字段是大端字节序而多数MCU是小端直接用结构体指针强转数据域字节顺序就反了。解决写大端读取函数所有多字节信号统一走这两个工具函数uint16_t j1939_get_be16(const uint8_t *p) { return (uint16_t)((p[0] 8) | p[1]); } int32_t j1939_get_be32(const uint8_t *p) { return (int32_t)(((uint32_t)p[0] 24) | ((uint32_t)p[1] 16) | ((uint32_t)p[2] 8) | p[3]); }j1939_msg.c里通常已经提供了这类字节序工具函数建议业务代码统一调用不要自己写宏。5.4 接收滤波器配置错误导致收不到广播现象总线上明明有TP.CM_BAM帧但接收节点的应用层就是收不到这条多帧消息。原因CAN控制器的验收滤波器把0x18ECFF80这条TP.CM帧过滤掉了协议栈根本看不到它自然没法进入TP接收状态机。解决滤波器不能只配业务PGN的ID要把所有TP层相关IDTP.CM、TP.DT、地址声明都放开或者在驱动层直接关闭硬件滤波、全部接收后由软件根据PGN过滤。广播模式下TP.CM的PS字段是0xFF帧ID是0x18ECFFxx这个地址段一定要在滤波范围内。5.5 阻塞式调试打印抢占总线窗口现象代码里加了调试打印后BAM多帧传输经常断在一半接收端只能收到前几包。原因调试串口是阻塞输出printf耗时太长把DT包之间的发送间隔拉到了几百毫秒超过接收方的超时判断连接被复位。解决发送路径上不要放任何阻塞调用尤其是BAM这种连续发送的流程。调试信息放进环形缓冲区由后台任务负责输出或者直接关掉调试打印再测协议时序。6. 二次开发实战新增自定义PGN与移植到RTOS的改造思路6.1 新增一个自定义PGN处理业务上最常见的改动是加一条自定义PGN。只需要两步定义一个PGN号在应用层回调里加一个case分支。假设要新增一条状态上报消息#define PGN_MY_STATUS 0x00F200u /* 自定义状态PGN */ static void on_my_status(const uint8_t *data, uint8_t len) { if (len 4) return; /* 长度校验 */ g_status.temp j1939_get_be16(data[0]); g_status.volt j1939_get_be16(data[2]); } void j1939_app_on_message(j1939_msg_t *msg) { switch (msg-pgn) { case PGN_MY_STATUS: on_my_status(msg-data, msg-len); break; default: break; } }改完这几行新PGN的消息就能从总线进到业务逻辑。如果需要周期发送在main循环或RTOS定时任务里调用j1939_send填充这个PGN即可。这个模式适合80%以上的应用层扩展场景。6.2 换CAN驱动从一个平台到另一个平台的改动量协议栈和硬件唯一的耦合点是can_drv_t的三个函数。换平台时把新平台的CAN发送API填进send函数接收API填进recv函数init里做外设初始化。改造量几乎全在这个文件里static int mcu_can_send(uint32_t can_id, uint8_t *data, uint8_t len) { /* 调用当前MCU的CAN外设驱动发送扩展帧 */ return can_hal_send_ext(can_id, data, len); } static can_drv_t g_can_drv { .init mcu_can_init, .send mcu_can_send, .recv mcu_can_recv, };我一般会把协议栈源码和平台驱动放在不同目录协议栈目录只留标准C代码平台驱动单独维护。这样后续升级协议栈时diff起来一目了然。6.3 从轮询改成中断驱动时的注意点源码默认的recv是轮询模式在RTOS场景下应该改成“中断收帧进队列协议栈任务消费队列”的结构。核心原则是中断里只做拷贝和入队不做任何协议解析void can_rx_isr(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; can_frame_t f; can_hal_get_rx_frame(f); /* 硬件帧拷贝到局部变量 */ xQueueSendFromISR(g_rx_queue, f, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } void j1939_rx_task(void *arg) { can_frame_t f; while (1) { xQueueReceive(g_rx_queue, f, portMAX_DELAY); j1939_on_can_frame(f); /* 协议栈入口 */ } }这样改动的好处是协议栈的j1939_on_can_frame在独立任务里执行不会被CAN中断打断状态机的数据一致性有保障。从那以后我每次做J1939节点都会强制把三类用例跑完才上总线单帧直发、BAM多帧、地址冲突切换。三段没过关不发板。这套流程帮我省掉了很多总线联调夜班希望帮到你。本文还有配套的精品资源点击获取