☰
CAN总线在自主导航系统中的应用与调试实战
2026/10/1 1:52:56 网站建设 项目流程

CAN 总线在自主导航系统里扮演的角色,有点像城市里的内环高架——平时你感觉不到它的存在,可一旦它堵了或者断了,整条链路都得跟着瘫。我做过好几个移动机器人底盘的项目,从差速轮到阿克曼,从 ROS1 到 ROS2,几乎每一个都绕不开 CAN 通信这一关。很多刚上手的朋友一听到"CAN 通信"四个字就头大,觉得协议复杂、接线玄学、调试全靠运气。其实真把底层逻辑捋清楚之后,你会发现它比串口还省心,比以太网还抗造。这篇内容就是把我这些年踩过的坑、调过的参数、写过的代码整理出来,从收发器硬件到 Python 上位机,从 STM32 固件到 DBC 文件解析,尽量讲透。不管你是刚拿到一块 CAN 收发器模块的新手,还是正在被"STM32 CAN 突然连不上"折磨的老手,应该都能从里面找到点有用的东西。

1. 先搞清楚 CAN 在自主导航里到底传什么

1.1 为什么导航系统偏爱 CAN 而不是串口

自主导航系统里,传感器、控制器、执行器之间的数据流其实挺复杂的。激光雷达走以太网,摄像头走 MIPI 或者 USB,IMU 走 SPI 或者 I2C,那底盘电机、电池管理、遥控接收这些为什么偏偏选 CAN?我一开始也纳闷,后来自己画了一块板子把电机驱动器、BMS、IMU 全挂到一条总线上,才真正体会到它的好处。

第一是抗干扰。CAN 用的是差分信号,CAN_H 和 CAN_L 两根线绞在一起,外界电磁干扰对两根线的影响几乎相同,接收端做差之后干扰就被抵消掉了。机器人底盘上电机 PWM 一开,那电磁环境相当恶劣,串口在这种场景下经常丢包,CAN 却稳如老狗。

第二是多主架构。CAN 总线上没有主从之分,任何节点想发就发,靠 ID 仲裁决定优先级。这意味着我的主控、遥控接收机、调试工具可以同时挂在总线上,谁都不用等谁。串口你得轮询或者分时复用,麻烦得很。

第三是错误检测和自动重发。CAN 控制器硬件层面就带了 CRC 校验、位填充检查、应答检查,一旦发现错误自动重发,应用层几乎不用管。我实测过,在电机干扰最严重的时候,串口误码率能到千分之几,CAN 基本是零误码。

第四是成本低、布线简单。两根线走天下,所有节点并联上去就行,不像以太网还得搞交换机。对于中小型移动机器人来说,CAN 的性价比是最高的。

1.2 一条典型导航底盘的 CAN 网络拓扑

我拿自己最近做的一个差速底盘举例。整条总线上挂了这么几个节点:

  • 主控 STM32F407:负责跑导航算法、路径规划,同时通过 CAN 给电机发速度指令,从 BMS 读电池状态。
  • 左电机驱动器:接收速度指令,回传编码器计数、电流、温度。
  • 右电机驱动器:同上。
  • BMS 电池管理板:上报总电压、电流、SOC、单体电压。
  • 遥控接收模块:把遥控器的油门、转向信号转成 CAN 帧发出来。
  • USB-CAN 调试盒:插在电脑上,用来抓包、发测试帧、跑 Python 脚本。

整条总线两端各接一个 120 欧姆终端电阻,波特率设 500kbps。为什么选 500k 而不是 1M?因为我的总线长度大概 3 米左右,节点数 6 个,500k 在抗干扰和速率之间平衡得最好。1M 虽然快,但对线材和终端电阻要求更高,稍微有点阻抗不匹配就出问题。500k 对我来说够用了,电机控制周期 1ms,一帧 8 字节,带宽占用不到 20%。

拓扑上有个细节要注意:总线必须是一条直线,不能搞星型或者树型分支。我见过有人把六个节点从一个中心点放射状接出去,结果通信时好时坏。原因是分支会产生信号反射,破坏差分信号的完整性。正确的做法是节点依次串联,每个节点的 CAN_H 接上一节点的 CAN_H,CAN_L 接上一节点的 CAN_L,像串珠子一样。

1.3 标准帧还是扩展帧,11 位 ID 怎么分配

CAN 2.0B 支持两种帧格式:标准帧 11 位 ID,扩展帧 29 位 ID。自主导航系统里,我一般用标准帧就够了。11 位 ID 能表示 2048 个不同的标识符,对于一台机器人来说绰绰有余。

ID 分配我习惯按功能划分区间:

ID 范围用途举例
0x000-0x0FF紧急/高优先级急停、故障报警
0x100-0x1FF运动控制电机速度指令、编码器反馈
0x200-0x2FF电源管理BMS 电压、电流、SOC
0x300-0x3FF传感器数据IMU、超声波
0x400-0x4FF调试/配置参数读写、固件升级
0x500-0x7FF预留扩展用

这样分配的好处是,抓包的时候一眼就能看出这帧是干什么的。而且 ID 越小优先级越高,急停帧用 0x001,保证它在总线拥塞时也能第一时间发出去。

注意:CAN 的 ID 不表示目标地址,而是表示消息内容。也就是说,电机速度指令这个 ID,左右两个电机驱动器都会收到,各自根据帧里的数据字段判断是给谁的。我一般会在数据第一个字节放一个节点地址,或者干脆用两个不同的 ID 分别发给左右电机。

2. CAN 收发器硬件:从原理图到实际接线

2.1 收发器芯片到底在干什么

很多人分不清 CAN 控制器和 CAN 收发器。简单说,CAN 控制器是协议引擎,收发器是物理层驱动。STM32 内部集成的那个叫 bxCAN,是控制器,它输出的是 TTL 电平的 CAN_TX 和 CAN_RX 信号。这两个信号不能直接接到总线上去,必须经过收发器芯片转换成差分信号。

收发器芯片干的活就三件:把控制器的单端 TX 信号转成 CAN_H/CAN_L 差分输出;把总线上的差分信号转成单端 RX 信号给控制器;提供总线故障保护。常见的芯片有 TJA1050、SN65HVD230、MCP2551 等。TJA1050 是 5V 供电,SN65HVD230 是 3.3V 供电,选型的时候要跟你的 MCU 电平匹配。

我踩过一个坑:用 3.3V 的 STM32 直接接 TJA1050,结果通信不稳定。原因是 TJA1050 的 TX 引脚高电平阈值是 2.0V 以上,3.3V 勉强能识别,但 margin 很小,电机一干扰就出错。后来换成 SN65HVD230,3.3V 供电,问题直接消失。所以电平匹配这件事,别将就。

2.2 一张能直接抄的收发器原理图

下面这个电路是我用了很多次的 SN65HVD230 方案,稳定可靠:

STM32_CAN_TX ----+---- SN65HVD230 TXD (pin 1) | +---- 10k 上拉至 3.3V STM32_CAN_RX ----+---- SN65HVD230 RXD (pin 4) SN65HVD230 VCC (pin 3) ---- 3.3V + 100nF 去耦电容到 GND SN65HVD230 GND (pin 2) ---- GND SN65HVD230 CANH (pin 7) ---- 总线 CAN_H SN65HVD230 CANL (pin 6) ---- 总线 CAN_L SN65HVD230 RS (pin 8) ---- GND (高速模式)

几个关键点解释一下:

  • TXD 上拉 10k 到 3.3V:这是为了防止 MCU 复位期间 TXD 悬空导致收发器误发数据。STM32 复位时 GPIO 是浮空输入,TXD 没电平,收发器可能把总线拉低。加上拉之后,TXD 默认高电平,收发器保持 recessive 状态,不影响总线。
  • RS 引脚接 GND:SN65HVD230 的 RS 引脚控制斜率模式。接 GND 是高速模式,接电阻可以降低斜率减少 EMI。我一般直接接 GND,500k 波特率下 EMI 完全可接受。
  • VCC 去耦电容:100nF 必须靠近芯片引脚,否则电源噪声会串到总线上。
  • 终端电阻:120 欧姆,接在总线最远的两端,不是每个节点都接。我见过有人在每个节点都焊 120 欧姆,结果总线负载变成 20 欧姆,收发器驱动能力不够,通信距离急剧缩短。

2.3 终端电阻:接不对,通信就废一半

终端电阻的作用是匹配总线特性阻抗,吸收信号反射。CAN 总线的特性阻抗标称 120 欧姆,所以两端各接一个 120 欧姆电阻,并联之后总线负载是 60 欧姆。

怎么判断终端电阻接对了?断电情况下,用万用表量 CAN_H 和 CAN_L 之间的电阻,应该是 60 欧姆左右。如果量出来是 120 欧姆,说明只接了一端;如果是 40 欧姆,说明接了三端;如果是 20 欧姆,说明每个节点都接了。这些情况都会导致通信异常。

我遇到过一个特别隐蔽的问题:总线短距离通信正常,一超过 1 米就丢包。查了半天,发现是终端电阻焊在了中间节点上,而不是两端。信号从一端传到中间被吸收,再传到另一端又反射回来,波形乱成一团。把电阻挪到两端之后,5 米线缆跑 500k 毫无压力。

提示:有些 USB-CAN 调试盒内部已经集成了 120 欧姆终端电阻,可以通过跳线或者软件开关控制。用的时候要确认一下,如果调试盒已经接了终端电阻,总线另一端就不用再接了,否则负载变成 40 欧姆。

3. STM32 端 CAN 初始化的那些门道

3.1 波特率计算:别抄别人的参数

STM32 的 bxCAN 波特率由三个参数决定:BRP(波特率预分频)、TS1(时间段 1)、TS2(时间段 2)。公式是:

波特率 = APB1时钟 / (BRP * (1 + TS1 + TS2))

假设 APB1 时钟是 42MHz,目标波特率 500kbps:

500000 = 42000000 / (BRP * (1 + TS1 + TS2)) BRP * (1 + TS1 + TS2) = 84

取 BRP = 6,则 1 + TS1 + TS2 = 14,即 TS1 + TS2 = 13。通常取 TS1 = 10,TS2 = 3,采样点位置 = (1 + TS1) / (1 + TS1 + TS2) = 11/14 ≈ 78.6%。这个采样点位置很理想,CAN 规范推荐采样点在 75% 到 87.5% 之间。

我见过有人直接抄网上的代码,BRP 和 TS1/TS2 不匹配自己的时钟,结果波特率算出来是 480k 或者 520k,跟总线上其他节点对不上,通信时断时续。所以初始化之前,先确认你的 APB1 时钟是多少。STM32F407 默认 42MHz,STM32F103 默认 36MHz,不同型号不一样。

3.2 过滤器配置:别让无关帧打断你的 CPU

CAN 控制器收到一帧之后,会先过过滤器,只有匹配的帧才会存进 FIFO 并触发中断。如果过滤器全开,总线上所有帧都往 CPU 送,中断频率高了之后 CPU 根本忙不过来。

我一般这样配过滤器:

  • FIFO0:接收运动控制相关帧,ID 范围 0x100-0x1FF。
  • FIFO1:接收电源和传感器帧,ID 范围 0x200-0x3FF。
  • 调试帧:如果不需要,直接过滤掉,不占 FIFO。

STM32 的过滤器有掩码模式和列表模式。掩码模式适合接收一个 ID 范围,列表模式适合接收几个特定 ID。比如我要接收 0x101 和 0x102 两帧,用列表模式;要接收 0x100-0x1FF 所有帧,用掩码模式,掩码值设 0x700,ID 设 0x100。

// 掩码模式示例:接收 0x100-0x1FF CAN_FilterInitStructure.CAN_FilterIdHigh = 0x100 << 5; CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0x700 << 5; CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask; CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_FIFO0; CAN_FilterInitStructure.CAN_FilterActivation = ENABLE;

注意那个左移 5 位,因为 STM32 的过滤器寄存器里 ID 是存在高 11 位的,低 5 位是保留位。这个坑我踩过,忘了移位,结果过滤器完全不工作。

3.3 中断接收与环形缓冲区

CAN 接收我强烈建议用中断加环形缓冲区的方式,不要在中断里直接处理业务逻辑。中断里只做一件事:把帧从 FIFO 读出来,塞进环形缓冲区,然后退出。主循环再从缓冲区里取帧处理。

为什么?因为 CAN 帧到达是异步的,如果中断里处理业务,遇到耗时操作(比如写 Flash、算 PID),中断响应就会延迟,FIFO 溢出,丢帧。我实测过,中断里直接跑浮点运算,连续收 100 帧能丢 5 帧。改成环形缓冲区之后,零丢帧。

环形缓冲区的大小我一般设 64 或者 128 帧。每帧占 16 字节(ID 4 字节 + DLC 1 字节 + 数据 8 字节 + 时间戳 4 字节),128 帧就是 2KB RAM,对 STM32F407 来说毫无压力。

typedef struct { uint32_t id; uint8_t dlc; uint8_t data[8]; uint32_t timestamp; } CanFrame_t; CanFrame_t can_rx_buffer[128]; volatile uint16_t can_rx_head = 0; volatile uint16_t can_rx_tail = 0; void CAN1_RX0_IRQHandler(void) { CanRxMsg rx_msg; CAN_Receive(CAN1, CAN_FIFO0, &rx_msg); uint16_t next = (can_rx_head + 1) % 128; if (next != can_rx_tail) { can_rx_buffer[can_rx_head].id = rx_msg.StdId; can_rx_buffer[can_rx_head].dlc = rx_msg.DLC; memcpy(can_rx_buffer[can_rx_head].data, rx_msg.Data, 8); can_rx_buffer[can_rx_head].timestamp = HAL_GetTick(); can_rx_head = next; } CAN_FIFORelease(CAN1, CAN_FIFO0); }

3.4 发送:别用阻塞模式

CAN 发送有三种模式:阻塞发送、中断发送、DMA 发送。新手最容易用阻塞发送,就是调CAN_Transmit之后死等发送完成。这在单帧发送时没问题,但如果连续发多帧,总线仲裁失败或者总线忙的时候,CPU 就卡在那里了。

我的做法是:发送也用环形缓冲区加中断。主循环把要发的帧塞进发送缓冲区,然后触发发送中断,中断里从缓冲区取帧发出去。这样主循环永远不会被 CAN 发送阻塞。

STM32 的 CAN 有三个发送邮箱,可以连续发三帧不用等。我一般用邮箱 0 发高优先级帧,邮箱 1 和 2 发普通帧。发送完成中断里检查缓冲区还有没有帧,有就继续发。

注意:如果总线断了(比如 CAN_H 和 CAN_L 短路),发送邮箱会一直失败,STM32 的 CAN 控制器会进入错误被动状态,最后 bus-off。这时候需要软件检测 bus-off 状态并重新初始化 CAN。我一般会在 1 秒内检测到 bus-off 就自动恢复,避免整个系统卡死。

4. USB-CAN 调试:从抓包到 Python 脚本

4.1 选一个趁手的 USB-CAN 盒子

市面上的 USB-CAN 调试盒五花八门,价格从几十到几百不等。我前后用过五六种,总结下来选型看三点:

  • 驱动兼容性:Windows 上要免驱或者驱动稳定,Linux 上要能识别成 socketcan 设备或者 ttyUSB。有些便宜盒子用的是 CH340 或者 CP2102 转串口,再配一个单片机做 CAN 协议转换,这种在 Linux 下就是普通串口,得自己写协议解析。好一点的用原生 USB 接口芯片,Linux 下直接支持 socketcan。
  • Python 库支持:能不能用 python-can 直接调用。python-can 支持很多种接口,包括 socketcan、slcan、ixxat、pcan 等。如果你的盒子支持其中一种,那 Python 脚本就很好写。
  • 终端电阻可控:最好有跳线或者软件开关控制内置 120 欧姆终端电阻,方便适配不同总线。

我现在主力用的是支持 socketcan 的盒子,Linux 下插上就能用,Python 脚本直接调,非常省心。

4.2 Linux 下 socketcan 的配置

Linux 内核自带 socketcan 驱动,配置起来很简单:

# 查看 CAN 接口 ip link show # 设置波特率 500k 并启动 sudo ip link set can0 type can bitrate 500000 sudo ip link set can0 up # 查看状态 ip -details link show can0 # 关闭 sudo ip link set can0 down

启动之后,可以用candump抓包:

candump can0

输出格式是(时间戳) 接口 ID#数据,比如:

(1234.567890) can0 101#0102030405060708

也可以用cansend发帧:

cansend can0 101#0102030405060708

这些命令属于 can-utils 工具包,Ubuntu 下sudo apt install can-utils就能装。

4.3 python-can 实战:收发、过滤、记录

python-can 是我用得最多的库,安装很简单:

pip install python-can

基本收发示例:

import can import time # 创建总线 bus = can.interface.Bus(channel='can0', bustype='socketcan') # 发送一帧 msg = can.Message(arbitration_id=0x101, data=[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08], is_extended_id=False) bus.send(msg) # 接收 for i in range(10): rx = bus.recv(timeout=1.0) if rx: print(f"ID: 0x{rx.arbitration_id:03X}, Data: {rx.data.hex()}") else: print("超时")

带过滤的接收:

filters = [ {"can_id": 0x100, "can_mask": 0x700, "extended": False}, ] bus = can.interface.Bus(channel='can0', bustype='socketcan', filters=filters)

记录到文件(支持 blf、asc、csv 等格式):

logger = can.Logger("logfile.asc") notifier = can.Notifier(bus, [logger]) time.sleep(10) notifier.stop()

回放:

for msg in can.LogReader("logfile.asc"): bus.send(msg) time.sleep(0.01)

我经常用 python-can 写自动化测试脚本。比如给电机发一条速度指令,然后监听编码器反馈,验证电机是否按预期转动。这种脚本用 Python 写比用 C 写快多了,改起来也方便。

4.4 一个实用的调试脚本:总线负载监控

总线负载率是判断 CAN 网络健康度的重要指标。负载率太高会导致帧延迟增加,甚至丢帧。我写了一个简单的监控脚本:

import can import time bus = can.interface.Bus(channel='can0', bustype='socketcan') frame_count = 0 byte_count = 0 start_time = time.time() while True: msg = bus.recv(timeout=1.0) if msg: frame_count += 1 # 标准帧开销约 47 位 + 数据位 byte_count += 47 + msg.dlc * 8 elapsed = time.time() - start_time if elapsed >= 1.0: # 500kbps 下每秒最多传 500000 位 load = (byte_count / 500000) * 100 print(f"帧率: {frame_count} fps, 负载率: {load:.2f}%") frame_count = 0 byte_count = 0 start_time = time.time()

跑起来之后,如果负载率长期超过 70%,就得考虑优化了:要么减少发送频率,要么提高波特率,要么把一些非关键数据挪到别的总线上。

5. DBC 文件:让 CAN 数据说人话

5.1 DBC 到底是什么

CAN 帧本身只包含 ID 和 8 字节数据,没有任何语义信息。0x101 帧里的01 02 03 04 05 06 07 08到底是什么意思?是速度还是电流?是整数还是浮点?大端还是小端?这些信息必须另外定义。DBC 文件就是干这个的,它描述了每一帧的 ID、名称、长度,以及每个信号在数据字节中的起始位、长度、字节序、缩放因子、偏移量、单位、取值范围。

没有 DBC 的时候,我调试全靠猜。收到一帧101#000003E8...,得翻代码看这 4 个字节是什么。有了 DBC,用 canalyze 或者 Wireshark 一加载,直接显示"左轮速度:1000(单位:mm/s)",一目了然。

5.2 DBC 文件结构拆解

一个典型的 DBC 文件长这样:

VERSION "" NS_ : NS_DESC_ CM_ BA_DEF_ BA_ VAL_ ... BS_: BU_: MotorLeft MotorRight BMS MainCtrl BO_ 257 MotorLeftSpeed: 8 MotorLeft SG_ Speed : 0|32@1- (1,0) [0|5000] "mm/s" MainCtrl SG_ Current : 32|16@1- (0.1,0) [-300|300] "A" MainCtrl BO_ 258 MotorRightSpeed: 8 MotorRight SG_ Speed : 0|32@1- (1,0) [0|5000] "mm/s" MainCtrl SG_ Current : 32|16@1- (0.1,0) [-300|300] "A" MainCtrl BO_ 512 BMSStatus: 8 BMS SG_ Voltage : 0|16@1+ (0.1,0) [0|600] "V" MainCtrl SG_ Current : 16|16@1- (0.1,0) [-500|500] "A" MainCtrl SG_ SOC : 32|8@1+ (0.5,0) [0|100] "%" MainCtrl

逐行解释:

  • BO_ 257 MotorLeftSpeed: 8 MotorLeft:定义一帧,ID 257(0x101),名称 MotorLeftSpeed,长度 8 字节,发送节点 MotorLeft。
  • SG_ Speed : 0|32@1- (1,0) [0|5000] "mm/s" MainCtrl:定义一个信号,名称 Speed,起始位 0,长度 32 位,字节序@1表示小端(Intel),-表示有符号,缩放因子 1,偏移 0,范围 0 到 5000,单位 mm/s,接收节点 MainCtrl。

字节序那个@1和@0特别容易搞混。@1是 Intel 格式(小端),@0是 Motorola 格式(大端)。STM32 是小端芯片,所以我一般都用@1。但有些厂商的驱动器用大端,这时候就得用@0,否则解析出来的数值完全不对。

5.3 用 cantools 解析 DBC

Python 的 cantools 库可以加载 DBC 文件,自动解析和打包 CAN 帧:

pip install cantools
import cantools import can db = cantools.database.load_file('robot.dbc') # 解析接收到的帧 msg = can.Message(arbitration_id=0x101, data=bytes([0xE8, 0x03, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00]), is_extended_id=False) decoded = db.decode_message(msg.arbitration_id, msg.data) print(decoded) # 输出: {'Speed': 1000, 'Current': 10.0} # 打包发送帧 data = db.encode_message('MotorLeftSpeed', {'Speed': 1500, 'Current': 5.0}) tx_msg = can.Message(arbitration_id=0x101, data=data, is_extended_id=False) bus.send(tx_msg)

用了 cantools 之后,我再也不用记每个字节的含义了。改协议的时候只改 DBC 文件,Python 脚本一行不用动。这个工作流强烈推荐。

5.4 DBC 文件维护的坑

DBC 文件最怕的就是版本不一致。我遇到过好几次:固件升级改了信号定义,但 DBC 文件没更新,结果上位机解析出来的数据全是错的。电机明明在正转,显示速度是负的;电池电压 48V,显示成 480V。

我的做法是:DBC 文件跟固件代码放在同一个 Git 仓库里,每次改协议必须同时提交 DBC 更新。而且 DBC 文件里加上版本号和修改日期注释:

CM_ "DBC Version: 1.2.3, Date: 2024-01-15, Author: xxx";

这样出问题的时候,一看版本号就知道对不对得上。

还有一个坑是信号重叠。DBC 里两个信号的起始位和长度如果有重叠,cantools 加载的时候会报错或者解析出奇怪的值。画 DBC 的时候最好用工具(比如 Vector DBC Editor 或者开源的 canmatrix)可视化检查一下,确保每个 bit 只被一个信号占用。

6. 那些年我遇到的 CAN 通信故障

6.1 STM32 CAN 突然连不上:排查链路

"STM32 CAN 通信突然连不上"这个问题,我在论坛上看到太多次了。根据我的经验,原因大概有这么几类,按出现频率排序:

第一类:硬件问题(占 60%)

  • 终端电阻不对。量一下总线电阻,不是 60 欧姆就查。
  • CAN_H 和 CAN_L 接反了。这个特别常见,尤其是自己压线的时候。接反了通信完全不通,但芯片不会烧,就是没数据。
  • 收发器供电掉了。量一下收发器 VCC,3.3V 或 5V 是否正常。
  • 共地问题。如果两个节点用不同的电源供电,GND 没连在一起,差分信号没有参考地,通信会时好时坏。CAN 总线的 GND 必须连在一起,虽然 CAN 是差分信号,但共模电压范围有限,不共地容易超出范围。

第二类:软件配置问题(占 30%)

  • 波特率不匹配。用示波器量一下 CAN_H 的位宽,500k 的话一位是 2 微秒。对不上就查 BRP 和 TS1/TS2。
  • 过滤器配置错误。所有帧都被过滤掉了,自然收不到。先把过滤器全开(掩码全 0)测试一下。
  • CAN 控制器没进入正常模式。STM32 上电后默认是睡眠模式,必须调CAN_WakeUp或者初始化时设CAN_Mode_Normal。
  • bus-off 状态没恢复。总线错误太多会进入 bus-off,这时候控制器停止收发,必须软件检测并重新初始化。

第三类:总线负载或干扰(占 10%)

  • 负载率太高,帧延迟严重。用前面那个监控脚本看一下。
  • 电机干扰太强。检查屏蔽线是否接地,收发器附近有没有加滤波电容。
  • 总线太长。500k 波特率下理论最长 100 米,但实际有干扰的话 10 米就可能出问题。缩短线缆或者降低波特率。

排查的时候我一般按这个顺序:先量电阻,再看波形,然后查配置,最后抓包分析。这个顺序能覆盖 90% 的问题。

6.2 一个真实的排查案例

有一次客户反馈,机器人跑着跑着 CAN 就断了,重启之后又能跑一会儿。我过去之后,先量终端电阻,60 欧姆正常。然后用示波器看波形,发现 CAN_H 和 CAN_L 的差分波形在电机启动瞬间有明显的振铃。

振铃说明阻抗不匹配。我检查了接线,发现电机驱动器的 CAN 线是从主控板经过一个接插件转接过去的,接插件那里线缆分叉了,形成了一个短分支。虽然只有 5 厘米,但在 500k 波特率下,5 厘米分支足以产生反射。

把接插件去掉,CAN 线直接焊到驱动器上,问题解决。这个案例告诉我,CAN 总线对分支的容忍度极低,能不分叉就不分叉。

6.3 错误帧和 bus-off 的处理

CAN 控制器有错误计数器,发送错误或接收错误都会累加。当发送错误计数器超过 255,控制器进入 bus-off 状态,完全停止收发。

bus-off 的常见原因:

  • 总线上只有自己一个节点,发出去的帧没人应答,错误计数器一直涨。
  • 波特率不匹配,发的帧别人听不懂,一直报错。
  • 总线短路或者断路。

STM32 的 CAN 控制器在 bus-off 之后,可以通过软件恢复:先进入初始化模式,再退出,错误计数器清零。我一般会在主循环里检测CAN_GetFlagStatus(CAN1, CAN_FLAG_BOF),如果置位就执行恢复流程。

if (CAN_GetFlagStatus(CAN1, CAN_FLAG_BOF) != RESET) { CAN_DeInit(CAN1); CAN_Init(CAN1, &CAN_InitStructure); CAN_FilterInit(&CAN_FilterInitStructure); // 重新使能中断 }

恢复之后最好记录一下日志,看看 bus-off 发生的频率。如果频繁发生,说明硬件或者配置有问题,得从根本上解决。

7. 把 CAN 通信做成一个可复用的模块

7.1 分层设计:硬件层、协议层、应用层

做了几个项目之后,我把 CAN 通信代码抽成了一个独立模块,分成三层:

  • 硬件层:负责 STM32 CAN 外设的初始化、中断处理、收发寄存器操作。这一层跟具体 MCU 相关,换芯片的时候只改这一层。
  • 协议层:负责帧的打包和解包,跟 DBC 定义对应。这一层跟具体协议相关,换协议的时候只改这一层。
  • 应用层:业务逻辑,比如速度控制、状态机。这一层跟具体应用相关。

分层的好处是,换 MCU 的时候不用动业务代码,换协议的时候不用动硬件代码。我后来从 STM32F103 换到 F407,硬件层改了几行,协议层和应用层一行没动。

7.2 心跳与超时检测

CAN 总线上某个节点挂了,如果不做检测,主控可能还在傻傻地等它的数据。我一般会加心跳机制:每个节点周期性发一帧心跳,主控记录每个节点最后一次心跳的时间,超过阈值就判定节点离线,触发安全策略。

typedef struct { uint32_t last_heartbeat; uint8_t online; } NodeStatus_t; NodeStatus_t nodes[8]; void check_nodes(void) { uint32_t now = HAL_GetTick(); for (int i = 0; i < 8; i++) { if (now - nodes[i].last_heartbeat > 500) { nodes[i].online = 0; } else { nodes[i].online = 1; } } }

心跳周期我一般设 100ms,超时阈值 500ms。这样节点掉线后最多 500ms 就能检测到,对于移动机器人来说足够快了。

7.3 参数在线配置

调试的时候经常需要改参数,比如 PID 系数、速度上限。如果每次都要重新烧固件,效率太低了。我通过 CAN 做了一个参数读写协议:

  • 主机发一帧0x400#参数ID 数据...,写参数。
  • 主机发一帧0x401#参数ID,读参数。
  • 从机收到后,从 Flash 读写,然后回一帧0x402#参数ID 数据...。

这样用 Python 脚本就能在线调参,改完立即生效,不用断电重启。这个功能在调试 PID 的时候特别有用,我一边看机器人跑,一边在电脑上改参数,几分钟就能调好。

注意:Flash 写入有寿命限制,一般 10 万次左右。参数不要频繁写 Flash,我一般先在 RAM 里改,确认没问题了再存 Flash。或者加一个"保存"命令,只有收到保存命令才写 Flash。

8. 写在最后的一些个人体会

CAN 通信这个东西,入门的时候觉得复杂,用熟了之后觉得真香。我这些年最大的体会是:硬件是基础,配置是关键,调试工具是眼睛。硬件没接对,软件写得再好也没用;配置错了,通信时好时坏,查起来要命;没有好的调试工具,出了问题只能瞎猜。

如果你刚开始做自主导航的 CAN 通信,我的建议是:先买一个靠谱的 USB-CAN 盒子,把 python-can 跑通,能抓包能发帧。然后拿两块 STM32 板子,一块发一块收,把波特率、过滤器、中断都调通。最后再上真实的电机和 BMS,一步步来。别一上来就搞整个系统,出了问题你都不知道是哪一层的事。

还有一点,DBC 文件一定要维护好。我见过太多项目,代码写得漂亮,但 DBC 文件乱七八糟,换个人接手根本看不懂。DBC 是团队协作的桥梁,值得花时间把它做规范。

最后分享一个我常用的小技巧:在总线上留一个专门的调试节点,ID 用 0x7FF,专门用来发测试帧和接收诊断信息。这个节点不参与业务逻辑,只做调试用。这样调试的时候不用动业务代码,直接往总线上发帧就能测试各个节点的响应。这个习惯帮我省了很多时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询