news 2026/9/13 5:27:05

CAN总线G代码传输实战:用C语言和SocketCAN实现稳定运动控制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CAN总线G代码传输实战:用C语言和SocketCAN实现稳定运动控制

简介:这是一份基于C语言的CAN通信协议实现源码包,适合嵌入式开发者、汽车电子或工业控制领域的初学者学习参考。资源围绕DSP2833x平台搭建,覆盖ECan模块驱动、报文收发、中断服务、过滤器配置及错误处理等关键环节,并附带完整工程文件与编译输出,便于直接阅读或烧录验证。压缩包共74个文件,以C源码、头文件为主,同时包含汇编启动文件、链接命令文件以及调试生成的目标文件,整体体积仅481KB,结构非常紧凑。目前已有173人下载学习。通过研读源码,可以系统掌握CAN报文结构定义、库接口调用方式、硬件寄存器操作与实时调试思路,是一份将C语言与现场总线技术结合起来的实用案例,对入门嵌入式通信开发很有帮助。

1. CAN 总线与 G 代码源码:把一条运动指令拆给控制器

一块板卡要驱动多个伺服轴,最常见的分工是主控里跑一段 C 语言写的 G 代码解析器,把“G01 X10.5 Y20.3 F800”这种文本行换算成坐标,再通过 CAN 总线分发到各个执行节点。有意思的是,CAN 帧数据域只有 8 字节,跟串口那种整串搬运的写法完全不同;ID、掩码、仲裁、字节序任何一处配置错,都会出现“上位机发了、轴板收不到”的静默故障。这篇要讲的核心,就是用 C 语言写一套在 CAN 总线上可靠传输 G 代码的源码:先把协议模型扣清楚,再基于 SocketCAN 实现收发,最后把位时序、仲裁和错误恢复收拢到一起。适合在做 3D 打印、CNC 控制卡,以及准备把运动指令从串口迁到 CAN 总线的开发人员读。

2. CAN 帧结构与 C 语言数据模型:ID、字节序与掩码筛选

2.1 标准帧与扩展帧:G 代码场景为什么常选 29 位扩展帧

先明确底层模型。CAN 2.0A 的标准帧只有 11 位标识符,CAN 2.0B 的扩展帧提供 29 位标识符,数据域都固定是 8 字节。两者的实时性差异来自仲裁场长度:扩展帧每帧要多仲裁 18 位,在总线繁忙时高优先级帧等待时间也会略长,但换来的是巨大的 ID 扩展空间。

G 代码运动控制里,同一时刻一条总线上既要有运动指令本身的坐标数据,又要传急停、轴状态、参数读写,用 11 位 ID 把“通道号 + 帧类型 + 包序号”全部编码进去非常紧张。工程上普遍选用扩展帧,并按字节段规划 ID 位段,这也是我建议的默认做法:

特性标准帧扩展帧
ID 位数11 位29 位
仲裁长度短,总线占用低长,多 18 位
ID 规划空间2048 个标识符可分段规划
G 代码场景节点少、单轴采集多轴、多通道、按 ID 过滤

比较稳妥的 ID 分配是:高 8 位做通道号(0xA1 表示 1 号轴),中 8 位做帧类型(0x01 文件头、0x02 数据包、0x03 急停、0x04 应答),低 13 位做包序号或附加参数。这样接收侧能直接靠硬件 filter 把无关帧滤掉,不需要每帧都解数据域判断归属,CPU 占用会低很多,多轴扩展时也不用改协议框架。

2.2 用 struct can_frame 描述帧:C 语言里的标志位与字节序

在 Linux 用户空间做 CAN 通信,不必自己定义帧头,内核源码里的 linux/can.h 已经给出了标准结构体:

#include <linux/can.h> struct can_frame { canid_t can_id; /* 32 位,包含 EFF/RTR/ERR 三个标志 */ union { __u8 len; __u8 can_dlc; }; __u8 __pad; __u8 __res0; __u8 __res1; __u8 data[8]; };

can_id 不是普通的 29 位编号。bit31 是错误帧标志,bit30 是 RTR 远程请求帧,bit29 是 EFF 扩展帧标志。很多人直接把 ID 值赋给 can_id,忘记置位 CAN_EFF_FLAG,内核就会把帧当标准帧处理,接收侧自然收不到。这个坑在总线上极其隐蔽,因为物理层是通的,错误计数器也不涨,纯粹是 ID 解析不一致。

接着是“大端小端”问题。CAN 协议本身不规定字节序,它只约定字节在线上按序传;麻烦的是 C 语言里 int、float 与逐字节发送之间的映射。如果坐标不是以 ASCII 文本而是以二进制 int 形式发送,发送端小端、接收端大端,数值就会错乱。我一般统一在协议层规定“数值字段一律大端序”,手工移位填进 data:

int32_t pos = 12345; frame.data[0] = (uint8_t)((pos >> 24) & 0xFF); frame.data[1] = (uint8_t)((pos >> 16) & 0xFF); frame.data[2] = (uint8_t)((pos >> 8) & 0xFF); frame.data[3] = (uint8_t)(pos & 0xFF);

四个字节固定按高到低次序存放,接收端无论跑在 x86 还是 ARM 上,都按同一份代码反向组装。这条规则在 G 代码源码里越早定越省事,否则后加一个 float 速度值时就是新一轮排查。

2.3 用 can_filter 做掩码筛选:只收这个节点需要的帧

CAN 是广播总线,没有点对点寻址。每个节点都会物理收到总线上所有帧,能过滤多少效率就提高多少。Linux raw socket 用 can_filter 数组设置过滤规则:掩码位为 1 的位必须匹配,掩码位为 0 的位忽略。轴板想要只收 0xA1 通道的运动帧,配置如下:

struct can_filter rfilter; rfilter.can_id = 0xA1000000 | CAN_EFF_FLAG; rfilter.can_mask = 0xFF000000 | CAN_EFF_FLAG; setsockopt(s, SOL_CAN_RAW, CAN_RAW_FILTER, &rfilter, sizeof(rfilter));

高 8 位固定匹配 0xA1,低 21 位全部忽略,0xA1xxxxxx 扩展帧都会进入接收队列。写过滤器时要特别注意 can_mask 里也拼上 CAN_EFF_FLAG。只给 can_id 设 EFF、mask 不设,内核比对时仍会把 ID 当标准帧,匹配永远不命中。G 代码行被拆成多个连续 ID 的包后,把掩码开放为一个 ID 区间,放行整段,接收端再用包序号重组,这样 CPU 的无效比较最少。

3. 用 SocketCAN 和 C 语言实现 G 代码帧的发送与接收

3.1 用 vcan 在本地起一条可调试的通道

第一步先在 Linux 上建一条虚拟 CAN 通道,不用接硬件也能把收发逻辑全部跑通:

sudo modprobe vcan sudo ip link add dev vcan0 type vcan sudo ip link set up vcan0

modprobe 加载内核虚拟 CAN 模块;ip link add 建立名为 vcan0 的网络设备;ip link set up 把它置为 up。vcan0 不产生物理位流,但 SocketCAN 对用户态暴露的接口和真实 CAN 完全一致,帧收发、过滤器、超时行为都能在它上面验证。真机上只需要把接口名换成 can0,再用 ip link set can0 type can bitrate 500000 配好波特率,应用层代码不用改。调试时我习惯在 vcan0 上挂一个 candump,程序跑起来第一时间看帧是否真的发出去了。

3.2 最小收发代码:socket、bind、send、read

C 语言访问 CAN 的最小骨架如下。先初始化套接字:

#include <string.h> #include <sys/socket.h> #include <net/if.h> #include <sys/ioctl.h> #include <linux/can.h> #include <linux/can/raw.h> int can_open(const char *ifname) { int s = socket(PF_CAN, SOCK_RAW, CAN_RAW); struct sockaddr_can addr; struct ifreq ifr; strncpy(ifr.ifr_name, ifname, IFNAMSIZ - 1); ioctl(s, SIOCGIFINDEX, &ifr); /* 把接口名换成内核索引号 */ addr.can_family = AF_CAN; addr.can_ifindex = ifr.ifr_ifindex; bind(s, (struct sockaddr *)&addr, sizeof(addr)); return s; }

socket(PF_CAN, SOCK_RAW, CAN_RAW) 表示打开一个裸 CAN 套接字,不经过 TCP/IP 协议栈;ioctl 根据网卡名查出 ifr_ifindex,这是 bind 需要的接口索引。绑定之后这个套接字只收 vcan0 的帧,发送走哪个套接字,总线上的节点不会区分。实际工程里我习惯一个套接字专管发送、另一个专管接收,两个线程各持一个 fd,滤波配置互不干扰。

发送一帧的封装函数如下:

int can_send(int s, uint32_t id, const uint8_t *buf, uint8_t len) { struct can_frame frame; memset(&frame, 0, sizeof(frame)); frame.can_id = id | CAN_EFF_FLAG; frame.can_dlc = len; memcpy(frame.data, buf, len); return write(s, &frame, sizeof(frame)); }

write 的返回值必须检查,等于 sizeof(struct can_frame) 才代表整个帧进入了内核发送缓冲区。socket 发送缓冲满、总线处于错误态都可能导致返回异常,而 CAN 帧不存在合法“部分写入”,返回值不对就应重试或触发错误处理。接收侧再配一个超时,避免 read 无限期阻塞:

struct timeval tv = { .tv_sec = 0, .tv_usec = 200000 }; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));

200ms 后 read 返回 -1 且 errno 为 EAGAIN,说明在约定时间内没等到下一帧。这个超时只约束单次 read,整行 G 代码的重组超时还需要在应用层单独实现。

3.3 一行 G 代码拆成多个 8 字节帧:组包与重组

CAN 帧数据域只有 8 字节。像“G01 X10.5 Y20.3 F1200”这种长度在 23 个字符左右,按“每帧 2 字节协议头 + 6 字节 ASCII 正文”拆分是最常见的做法:

#define PAYLOAD 6 int gcode_send_line(int s, uint32_t base_id, const char *line) { int len = strnlen(line, 63); /* 单行最大 63 字节 */ int n = (len + PAYLOAD - 1) / PAYLOAD; struct can_frame frame; for (int i = 0; i < n; i++) { int seg = len - i * PAYLOAD; if (seg > PAYLOAD) seg = PAYLOAD; memset(&frame, 0, sizeof(frame)); frame.can_id = (base_id + i) | CAN_EFF_FLAG; frame.can_dlc = 8; frame.data[0] = (uint8_t)n; /* 总包数 */ frame.data[1] = (uint8_t)(i + 1); /* 包序号,从 1 开始 */ memcpy(frame.data + 2, line + i * PAYLOAD, seg); can_send(s, frame.can_id, frame.data, 8); } return n; }

data[2..7] 最多装 6 个 ASCII 字符,不足 6 字节的位置用 memset 置零,接收端不能反过来把补零误当成 '\0'。同一行按 base_id + i 分配连续 ID,这一行的包在总线上就有明确次序,接收端掩码放行整段即可。

接收侧按序号重组的代码逻辑:

char line_buf[64]; int expect_total = 0, expect_seq = 1; void on_can_frame(const struct can_frame *f) { uint8_t total = f->data[0]; uint8_t seq = f->data[1]; if (seq == 1) { /* 新一轮的第一个包 */ expect_total = total; expect_seq = 1; memset(line_buf, 0, sizeof(line_buf)); } if (total != expect_total || seq != expect_seq) { return; /* 丢包或乱序,申请重发 */ } memcpy(line_buf + (seq - 1) * PAYLOAD, f->data + 2, PAYLOAD); expect_seq++; if (expect_seq > expect_total) { printf("整行 G 代码: %s\n", line_buf); expect_total = 0; } }

重组里最容易出问题的地方,是把“乱序包”误当成“新的一行”。seq 不连续就必须整行丢弃申请重发,不能跳号填位。如果接收端盲目把错序包写进 line_buf,后续几行全部会错位,而且在调试期很难肉眼看出原因。

3.4 阻塞读与行超时:不能只靠 read 返回

有人会问,既然 read 能设超时,为什么还要 poll。区别在于 poll/select 只告诉你“有数据可读”,CAN 是逐帧到的,poll 返回一次并不能代表一整行 G 代码到齐。SO_RCVTIMEO 保证了单帧读取不会无限挂起,而行级完整性还得靠应用层跟踪。我一般收到 seq==1 时记录一个时间戳,在 500kbps 下预计 n * 300μs 能收完,把超时放宽到 10ms。超过这个阈值就认为这一行丢了,主动向发送端发重传请求帧。这样不依赖总线错误处理,纯应用层就能兜住瞬态干扰。

4. CAN 总线仲裁、位时序与错误恢复:G 代码连续传输不丢帧的关键

4.1 仲裁与优先级设计:让急停帧永远插队

CAN 是多主总线,多个节点同时发送时靠逐位仲裁决定谁能继续占用总线:显性电平覆盖隐性电平,ID 数值越小,仲裁优先级越高。这个过程在硬件内部完成,C 语言应用程序只负责把 ID 设对。

这个特性对 G 代码运动控制的启发很直接:文件传输的帧和急停帧同时出现在总线上的概率虽然不高,但一旦发生,必须保证急停插队成功。ID 规划时把优先级按如下分段:

ID 段用途优先级
0x001 ~ 0x00F急停、错误、心跳帧最高
0x101 ~ 0x1FF轴位置与速度控制帧
0x600 ~ 0x6FFG 代码数据帧
0x700 ~ 0x7FF文件管理、参数读写最低

急停帧虽然每帧只占 1KB/秒级别的流量,但仲裁时 ID 最小,正常 G 代码数据流即使正在满速发送,急停帧也会在下一位仲裁中获胜。注意不要让紧急类帧抢占过多带宽,否则低优先级帧会出现“活锁”般的持续等待,在线传输长时间无法完成。

4.2 波特率、采样点与 BS1/BS2:寄存器怎么配

位时序决定一帧的位周期划分,直接关系抗干扰能力。CAN 位时间由同步段(固定 1 tq)、传播段、相位缓冲段 BS1、相位缓冲段 BS2 组成,采样点位于 BS1 与 BS2 交界处。BS1/BS2 取值直接决定采样点:太靠前,信号边沿和导线传输延迟还没稳定;太靠后,时钟容差不足,总线噪声下误码率上升。

以 36MHz 时钟、500kbps 波特率为例,一组常用参数如下:

配置项取值说明
时钟源频率36 MHz常见于 STM32F103 APB1
预分频 BRP4tq = 111 ns
BS113 tq采样点前保持 13 个时间单元
BS24 tq采样点后保持 4 个时间单元
位时间总和1 + 13 + 4 = 18 tq500 kbps
采样点77.8%(1+13)/(1+13+4)

波特率由“时钟源频率 / 预分频 / 位时间总和”决定,三者必须凑成精确整除。采样点 75%~80% 是业界常用区间。如果总线上有“延迟和早到”问题,通常先把采样点往 80% 方向调,同时检查总线末端是否接了 120Ω 终端电阻。换一个经验化的判断:总线长度每增加 10 米,建议把采样点下调约 1%,给信号建立留出余量。

4.3 错误帧与总线掉线:从错误计数入手

CAN 节点内部有发送错误计数器和接收错误计数器。错误计数超 127,节点进入错误被动,只能被动接收不能主动发。累计严重错误触发 bus-off 时,节点会直接退出总线,不再参与通信。C 语言侧一般通过读外设 ESR(错误状态寄存器)或者周期调用 ioctl 拿到统计值,Linux 上可以直接看:

ip -details -statistics link show can0

输出里的 rx-errors、tx-errors 会逐项累加。错误帧过多时优先排查三件事:波特率是否一致、终端电阻是否缺失、总线上是否有多余的占空比。G 代码连续传输场景里,如果某轴板反复 bus-off,它会把整段总线的流量拉低,其他节点的重传也会跟着变多。这时我一般加一条周期心跳帧,例如 0x005 每 50ms 发一次,主控侧 150ms 没收到心跳就判定链路断开,而不是干等下一行 G 代码超时。

4.4 带宽预算:500kbps 下一行 G 代码花多少时间

扩展帧 8 字节数据帧线上大约 130 位,加上位填充后按 140 位估算,500kbps 下每帧约 280 微秒。一行 23 字符的 G 代码拆 4 帧,约 1.1ms。如果运动控制周期是 1ms,同时只够传一行指令;反过来,如果打印任务里同时有几百行待发送队列,64 帧长的机械臂程序在总线上要跑 70ms 以上,主机侧不能无限快发。

所以带宽预算要按最坏情况留余量。我习惯把 G 代码数据帧的占用控制在总线总带宽的 50% 以内,分别给错误重发、心跳帧和实时轴控帧留出空间。计算时建议直接把每帧按 140 位估算,别按理论 108 位算,现场总线时有位填充和应答位,两者差距在低速低负载时看不出来,高负载下就是丢帧的分界线。

5. 验证方法:用 candump、cangen 和 CRC 校验收口

5.1 先用 candump 观察帧级行为,别直接上整机

调试第一件事是开 candump,确认总线上实际流通的帧长什么样:

candump vcan0

candump 会把每个帧的 ID、DLC 和数据域完整打印出来。vcan0 上自己发出去的帧它也能显示,这就够了。验证 G 代码源码时,我会把 candump 输出和接收端打印的“整行重组”日志对照着看,重点确认三件事:ID 是否符合分段规划、包序号是否连续、数据域里的 ASCII 有没有被补零干扰。

5.2 用 cangen 做压力测试,验证丢包率

验证接收线程在高负载下的处理能力,用 cangen 持续灌帧:

cangen vcan0 -g 1 -n 100000 -x candump vcan0 | wc -l

-g 1 表示每隔 1ms 生成一帧,-n 100000 是总数,-x 打开随机扩展帧模式。两边同时跑,最后看收到的行数是否等于 100000。如果系统丢帧,优先排查接收线程优先级、每帧处理里有没有多余的 memcpy、socket 接收缓冲区是否过小。用这个压力测试调完一轮,再用真实 G 代码行去跑,链路的可靠性就有底了。

5.3 给 G 代码数据帧加 CRC8 校验

CAN 自带 CRC15 是物理层校验,它保证帧在电磁干扰下不损坏;但应用层的组包和拆包可能引入新的错误,所以应用层再做一层 CRC 是代价较低的收口手段。常见做法是把每帧负载从 6 字节压到 5 字节,最后一位放 CRC8:

uint8_t crc8(const uint8_t *data, int len) { uint8_t crc = 0x00; for (int i = 0; i < len; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { if (crc & 0x80) crc = (crc << 1) ^ 0x07; else crc <<= 1; } } return crc; }

发送时对帧头加负载,即对 data[0..6] 计算 CRC,把结果写入 data[7]。接收端对同区间算一次 CRC,比对不一致就丢弃并申请重传。CRC8 计算量极小,单帧开销在 500kbps 总线上几乎可忽略。配合第 4 章的心跳检测,整套 CAN + C 语言 + G 代码源码链路就从“能通信”收敛到“可验证的稳定通信”。接收端做这一帧的 CRC 计算只有几微秒开销,不会成为总线的瓶颈。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 5:24:20

Shottr:macOS原生截图标注与OCR一体化工具

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 5:24:18

达梦数据库与MySQL的MERGE语法对比及迁移踩坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 5:23:20

GPT Image 2 实测:从提示词工程到业务工作流

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 5:22:57

GrapesJS 组件脚本中如何使用外部 JS 库依赖?

GrapesJS 组件脚本中如何使用外部 JS 库依赖&#xff1f; 【免费下载链接】grapesjs Free and Open source Web Builder Framework. Next generation tool for building templates without coding 项目地址: https://gitcode.com/GitHub_Trending/gr/grapesjs 在 Grapes…

作者头像 李华