CAN 总线与嵌入式通信

6.3k 词

CAN 在汽车和机器人领域统治了三十年,靠的不是”快”,而是多主、非破坏性仲裁、强抗干扰、自愈错误处理这四个特性。本文从物理层讲起,把”为什么”讲透:差分信号为什么抗干扰 → 仲裁为什么是非破坏的 → 错误状态机怎么自愈 → SocketCAN 生产级代码 → RoboMaster M3508/C620 协议逐字节解析 → 1kHz 全车控制的带宽预算。读完你能自己算总线负载、自己写驱动、自己排故障。

物理层:差分信号与位时序

为什么差分信号抗干扰

CAN 用两根线 CAN_H / CAN_L 传输差分信号:

  • 显性位(dominant,逻辑 0):CAN_H ≈ 3.5V,CAN_L ≈ 1.5V,差分 ≈ 2V
  • 隐性位(recessive,逻辑 1):两线都 ≈ 2.5V,差分 ≈ 0V
  • 显性覆盖隐性——这是仲裁机制的物理基础

接收端只关心 $V_{diff} = V_{CAN_H} - V_{CAN_L}$。外部电磁干扰(电机大电流、功率线)通常以共模方式同时叠加到两根线上,差分相减后抵消。这就是 CAN 敢跟功率线一起走线、在电机旁边跑 1Mbps 的原因。代价:双绞线(绞距越小抗干扰越强)+ 屏蔽。

波特率与总线长度:物理极限

波特率 最大总线长度(ISO 11898 经验值)
1 Mbps 40 m
500 kbps 100 m
250 kbps 250 m
125 kbps 500 m

为什么波特率越高线越短:位时间变短后,信号在线上往返的传播延迟(约 5ns/m)占位时间的比例变大,采样点采到的电平可能是”上一位的余波”。1Mbps 时位时间 1µs,40m 线往返延迟 ~400ns,占 40%——已经是极限。

位时序与采样点

一个位时间由 4 段组成:SYNC_SEG(1 tq)+ PROP_SEG + PHASE_SEG1 + PHASE_SEG2,采样点在 PHASE_SEG1/PHASE_SEG2 交界处,**典型 75%~87.5%**:

1
2
|<-SYNC->|<-PROP->|<-SEG1->|<-SEG2->|
| | | ^采样点(80%) |

工程含义:同一总线上所有节点的波特率必须一致,采样点最好也一致(不同控制器默认采样点可能差 5%,长总线+高温下就是偶发丢帧的根源)。STM32 配置:CAN_BS1=8, CAN_BS2=1, Prescaler=... 时采样点 = (1+8)/(1+8+1) = 90%;要压到 80% 就 BS1=7, BS2=2(采样点 (1+7)/10 = 80%)。

终端电阻:为什么是 120Ω×2

双绞线特征阻抗约 120Ω。总线两端各接一个 120Ω 电阻(中间节点不接),是为了阻抗匹配:信号到线端不反射。缺终端电阻的表现:短总线、低速时”好像能跑”,一拉长或提速就偶发丢帧/错误帧风暴。判断方法:总线断电时用万用表量 CAN_H-CAN_L,应该读到 ≈ 60Ω(两个 120Ω 并联);读到 120Ω = 只有一端有;读到无穷 = 两端都没有。

帧格式与仲裁

标准数据帧逐段拆解

1
SOF(1) | 仲裁场(12) | 控制场(6) | 数据场(0~64) | CRC(16) | ACK(2) | EOF(7) | IFS(3)
  • 仲裁场:11 位 ID + RTR(远程帧请求位)
  • 控制场:IDE(1=扩展帧)+ r0 + DLC(4 位数据长度)
  • 数据场:0~8 字节。CAN 一帧最多 8 字节——这是 1991 年定的,够传状态但传不了大数据,所以点云/图像永远不走 CAN
  • CRC:15 位 CRC + 1 位定界符
  • 位填充:从 SOF 到 CRC 前,连续 5 个相同位后强制插入 1 个反相位——保证有足够跳变沿供接收方锁相环同步

标准帧(11 位 ID)8 字节数据的基本帧长:1+12+6+64+16+2+7+3 = 111 bit,加位填充后最多 ~131 bit。这个数后面算带宽要用。

非破坏性仲裁:为什么 ID 越小优先级越高

多主模式下多个节点同时发帧怎么办?CAN 的答案是边发边听(发送节点同时监听总线):

  1. 每个发送节点从 SOF 开始逐位发送自己的 ID
  2. 发送隐性位(1)时如果监听到总线是显性(0)→ 说明有更高优先级节点在发 → 立即退出发送转为接收,不产生任何冲突破坏
  3. 因为显性覆盖隐性,ID 数值越小,前面的 0 越多,越晚被”挤掉” → 优先级越高

这就是”非破坏性”:仲裁输了的节点自动等下一轮,总线上的有效帧一个都不丢。注意仲裁只发生在仲裁场(ID+RTR),进入数据场后没有仲裁。工程结论:把关键帧(控制帧、急停帧)的 ID 配小,状态帧配大;ID 规划要留余量,别把 0x200~0x210 全占了不留扩展位。

帧类型速查

类型 用途 关键点
数据帧 传数据 标准帧 11 位 ID / 扩展帧 29 位 ID
远程帧 请求对方发数据 RTR=1;同 ID 远程帧优先级低于数据帧(数据帧 RTR=0 显性)
错误帧 报错 6 个显性位,由检测到错误的节点发出
过载帧 请求延迟 接收方忙时发

错误处理:总线为什么”死不了”

CAN 的错误检测有五重:位错误、填充错误、CRC 错误、形式错误、ACK 错误。检测到错误的节点发错误帧(6 个显性位)打断当前帧,让所有节点丢弃这帧数据,然后重传。不丢帧是不可能的,但绝不会把坏帧当好的用——这是 CAN 和串口最本质的区别。

每个节点维护两个计数器 TEC(发送错误)/ REC(接收错误),状态机三级:

1
2
3
4
5
6
7
8
9
错误主动 (TEC<128, REC<128)  ──TEC>127──>  错误被动 (TEC>127)
│ 能主动发错误帧 │ 只能发被动错误帧(隐性6位)
│ │ 发送前必须等 8 个隐性位
└────────── 错误恢复(计数递减) <────────┘
│ TEC>255
▼
总线关闭 (bus-off)
完全与总线隔离,需检测到 128 次
11 个连续隐性位(总线空闲)才恢复

工程含义:某个节点硬件故障(比如线缆短路)会不断发错误帧拖垮整条总线,但 CAN 的自愈机制保证其他节点不会死,只是通信质量下降。排查时看 ip -s link show can0 的错误计数:TEC 持续上涨 = 本节点发送有问题;REC 上涨 = 接收有问题(多半是对方/线路)。

SocketCAN 编程实战

生产级收发:过滤 + 超时 + 错误帧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <linux/can.h>
#include <linux/can/raw.h>
#include <sys/socket.h>
#include <net/if.h>
#include <sys/ioctl.h>
#include <poll.h>
#include <cstring>

// 打开并绑定 can0
int s = socket(PF_CAN, SOCK_RAW, CAN_RAW);
struct sockaddr_can addr;
struct ifreq ifr;
strcpy(ifr.ifr_name, "can0");
ioctl(s, SIOCGIFINDEX, &ifr);
addr.can_family = AF_CAN;
addr.can_ifindex = ifr.ifr_ifindex;
bind(s, (struct sockaddr*)&addr, sizeof(addr));

// 只接收 0x201~0x204 的反馈帧(过滤掉无关帧,降低 CPU 占用)
struct can_filter rfilter[1];
rfilter[0].can_id = 0x201;
rfilter[0].can_mask = 0x7FC; // 保留高 9 位:0x201~0x204 都收
setsockopt(s, SOL_CAN_RAW, CAN_RAW_FILTER, &rfilter, sizeof(rfilter));

// 接收:poll 超时 + 错误帧识别(生产级必须有)
struct pollfd pfd = {s, POLLIN, 0};
while (true) {
if (poll(&pfd, 1, 100) <= 0) { // 100ms 超时 → 上层看门狗
handle_timeout(); // 反馈丢失!进安全模式
continue;
}
struct can_frame frame;
int n = read(s, &frame, sizeof(frame));
if (n < 0) continue;
if (frame.can_id & CAN_ERR_FLAG) { // 错误帧:统计并告警,别当数据用
err_count_++;
continue;
}
handle_feedback(frame);
}

发送端的两个坑

  1. write 不保证发送成功:CAN 发送缓冲区满(总线忙)时 write 返回 -1 且 errno=ENOBUFS。必须检查返回值,失败要重试/计数,不能静默丢弃——控制帧丢了电机就停
  2. 发送频率要限速:没有流控,你 10kHz 狂发,总线 1Mbps 顶不住,错误帧风暴把全车干趴
1
2
3
4
5
6
7
8
// 限速发送:控制帧严格 1kHz
static auto last = std::chrono::steady_clock::now();
auto now = std::chrono::steady_clock::now();
if (now - last < std::chrono::milliseconds(1)) return; // 未到周期,丢弃本拍
last = now;
if (write(s, &frame, sizeof(frame)) < 0) {
tx_error_count_++; // 计数供监控,别在循环里打日志
}

RoboMaster 电机协议解析(逐字节)

C620 电调:0x200 控制帧

一帧控制 4 个电机,8 字节 = 4 个 int16 电流指令(大端,高字节在前):

1
2
3
4
5
6
7
8
// 0x200: [M1电流][M2电流][M3电流][M4电流],int16 大端
void pack_ctrl(uint8_t data[8], int16_t i1, int16_t i2, int16_t i3, int16_t i4) {
data[0] = i1 >> 8; data[1] = i1 & 0xFF;
data[2] = i2 >> 8; data[3] = i2 & 0xFF;
data[4] = i3 >> 8; data[5] = i3 & 0xFF;
data[6] = i4 >> 8; data[7] = i4 & 0xFF;
}
// 电流换算:16384 ≈ 20A(峰值),M3508 连续额定 10A → 指令别超过 ~8192 长期跑

C620 反馈帧:0x201 + 电机 ID(0x201~0x204)

1
2
3
4
5
// 反馈帧解析:机械角度(13bit)/转速(rpm)/实际电流/温度
float mech_angle = (frame.data[0] << 8 | frame.data[1]) / 8192.0f * 360.0f; // 0~8191 → 度
int16_t rpm = frame.data[2] << 8 | frame.data[3]; // 转子转速,有符号
int16_t cur = frame.data[4] << 8 | frame.data[5]; // 实际电流,16384≈20A
uint8_t temp = frame.data[6]; // 温度,>80°C 报警

三个实战要点:

  1. 机械角度 0~8191 对应转子一圈,断电即丢(增量式)。位置控制必须上电回中,且关节角要除减速比:M3508 减速比 3591:187 ≈ 19.2:1
  2. rpm 是转子转速:反馈转速 = 转子转速,换算成输出轴要除 19.2;做速度环时搞混转子/输出轴,Kp 直接差 19 倍,表现就是”参数怎么调都怪”
  3. 温度字节要监控:M3508 连续大电流跑几分钟温度就上 80°C,比赛打一半电机过热保护 = 白给

GM6020 云台电机:电压控制

GM6020 内置驱动器,控制量是电压不是电流:0x1FF 控制帧一帧管 3 个电机,每个 int16(30000 ≈ 30V);反馈帧 0x20A(电机 13)/ 0x20B(电机 46),同样含角度/转速/电流/温度。

坑:GM6020 是电压环,位置环要自己包——云台”点头”(pitch 往下掉)通常不是参数问题,是重力矩没有前馈补偿:u = PID(e) + m·g·L·cos(θ) / K_t,电压里必须叠加重力前馈项,否则 Kp 再大也顶不住静态下垂。

总线负载与带宽预算:为什么全车 CAN 要精打细算

算一算:1kHz 下 8 电机还能不能跑

标准帧 8 字节 ≈ 111 bit(不含填充)~ 131 bit(含填充,取 125 保守平均)。RoboMaster 步兵典型配置:底盘 4×M3508 + 云台 2×GM6020:

  • 控制帧:0x200(4 电机 1 帧)+ 0x1FF(3 电机 1 帧,GM6020 只需 1 帧)= 2 帧/ms
  • 反馈帧:6 个电机各 1 帧 = 6 帧/ms
  • 合计 8 帧/ms × 125 bit ≈ 1000 bit/ms = 1Mbps 满载 100%

结论:8 电机全 1kHz 反馈,1Mbps 总线已经贴满。实战建议:

  • 负载率控制在 80% 以下(留余量给错误重传、远程帧、调试帧)
  • 方案 A:反馈降频(500Hz 反馈 + 1kHz 控制,靠插值)
  • 方案 B:分两路 CAN(底盘一路、云台一路)——RoboMaster 强队标准做法
  • 方案 C:控制帧合并(0x200 一帧 4 电机已经是合并的)

这个预算意识很重要:协议设计阶段就要算负载,不然装机后才发现带宽不够,只能推倒重来。

踩坑清单(实战汇总)

  1. 波特率不一致:双方都”配置成功”,但互相收不到,且没有任何报错——上电先 candump 验证
  2. 终端电阻缺失:短距离能用,拉长/提速就偶发丢帧;断电量 CAN_H-CAN_L 应为 ~60Ω
  3. CAN_H/CAN_L 接反:完全不通,但万用表量不出问题;线序在两端都要核对(尤其是自制线)
  4. 接口没 up:candump can0 报 “No such device” / 收不到数据,先 ip link set can0 up type can bitrate 1000000
  5. 错误帧风暴:ip -s link show can0 看错误计数,找 TEC/REC 上涨的节点;bus-off 后需 128 次总线空闲才恢复,表现为”莫名断连几分钟又自己好”
  6. write 返回值不检查:ENOBUFS 静默丢控制帧 → 电机偶发停转,排查半天
  7. 转子/输出轴转速混淆:忘除减速比 19.2,速度环参数全部失真
  8. GM6020 无重力前馈:pitch 静态下垂,加 m·g·L·cos(θ)/K_t 前馈
  9. ID 规划不留余量:全车 ID 挤在一起,后期加设备只能重配;关键帧(急停)ID 必须最小
  10. 供电与信号共地:电调/电机功率地与控制器地不共,干扰直接进总线;共地 + 双绞 + 屏蔽层单端接地

速查:物理层三件套(波特率一致、双终端电阻、线序正确)→ 数据链路看错误计数 → 应用层看 ID/协议/负载率。CAN 的问题 90% 出在物理层,先量电阻、再看计数、最后才怀疑代码。