2. CAN硬件的坑,比想象中多:接线、终端电阻和波特率
CAN通信说到底是物理层和数据链路层的事情,硬件没搞对,后面全是白搭。我做自主导航小车踩过最大的坑之一,就是终端电阻。
2.1 终端电阻到底要不要接,怎么接
CAN总线两端必须各接一个120欧姆终端电阻,作用是匹配传输线阻抗,防止信号反射。这个很多教程提过,但实际做的时候有两点容易忽略:
第一,如果你的底盘电调、CAN分析仪、STM32板子上已经有焊接好的120欧姆电阻,那你外接的时候得算清楚一共几个120欧姆在总线上。两个120欧姆并在一起是60欧姆,如果再接一个就变成40欧姆,CAN收发器会过载。用万用表量CANH和CANL之间的电阻是最直接的判断方法——正常应该是60欧姆左右(如果只有两个节点且都在两端)。我在调试时就遇到过因为三个120欧姆并联导致通信时好时坏的情况,后来用万用表一量只有40欧姆,拆掉一个才恢复正常。
第二,终端电阻不是接上就行,它必须接在总线物理位置的两端,不是电调和板子各自一端那么简单。如果你的CAN网络里串了多个节点,中间节点的终端电阻反而不该有。我习惯的做法是:调试阶段在CAN分析仪那一端接120欧姆,底盘电调内部如果自带电阻就不用额外加,这样保证整个调试链路只有两个端点有匹配电阻。
提示:用万用表量CANH-CANL之间的直流电阻,如果只有60欧姆左右说明总线终端匹配正常;如果120欧姆左右说明总线只在一端接好了电阻;如果是0或接近0,很可能CANH和CANL短路了,赶紧断电排查。
2.2 波特率一致性,不是设个参数那么简单
CAN的波特率在代码里就是一个参数,但不同设备之间的时钟源、采样点设置会导致实际波特率有偏差。特别是国产STM32板子和某些电调之间,如果通讯报错率很高,先别怀疑线接错了,看看波特率是不是完全一致。
ROS侧通过SocketCAN配置波特率的命令是:
sudo ip link set can0 up type can bitrate 500000 sudo ip link set can0 type can restart-ms 100然后改用了500k,两边都对了,问题就消失了。后来我一查,有两个原因:一是电调内部有滤波,对长数据帧处理时间变长;二是200Hz发送频率下总线负载偏高,偶发错误帧被电调丢掉。
所以,这里的关键是:和底盘电调之间的通信频率不要拍脑袋定,要根据实际数据量反推。底盘数据(轮速、电流、状态位)通常几十个字节,500k波特率下一帧大约130微秒(按标准帧计算),实际预留总线负载在30%以下比较稳。
2.3 CAN_H和CAN_L不要接反,但这不算最坑的
CANH接CANL这种低级错误反而不容易出现,因为接反了完全不通。最坑的是接插件接触不良,特别是JST-GH这种小端子,插上去看着紧了,实际上弹簧片没到位,导致总线时通时断。我在调试时候遇到过一次特别诡异的现场:小板车的CAN波形用示波器看信号正常,但一跑起来就走一会儿停一会儿,排查到最后发现是电机端的CAN接口里有一根线缩针了,重新压线才解决。
另外,CAN总线的线材也很关键。我最初为了省事用了普通的杜邦线飞线调试,结果在电机启动瞬间总线波形严重畸变。后来换成双绞屏蔽线,屏蔽层单点接地,通信立刻稳定了。CAN和RS485一样,属于差分信号,双绞线的抗共模干扰能力是实打实的。
3. CAN数据帧的“拆包”功夫:从位级解析到联合体
CAN通信在我这个项目里走的是标准帧,ID从0x200到0x2FF分配,平时查下来发现市面上大部分底盘电调的标准帧都用小端字节序。数据段最多8个字节,对于传感器数据到底怎么塞进去,我一开始也纠结过。
3.1 具体怎么解析一帧数据:以底盘反馈为例
我用的电机电调每10ms回一帧标准CAN报文,ID是0x2B1,数据段8字节,其中包含电机转速、电流、母线电压和控制模式标志位。
比如收到的一帧原始数据是:
ID: 0x2B1 Data: 0x01 0x00 0x34 0x12 0x56 0x00 0xDC 0x05解析规则如下:
- 字节0(0x01):当前控制模式,1表示速度模式
- 字节1-2(0x00 0x34):保留字段,本项目未使用
- 字节3-4(0x12 0x34):电机转速,小端序,即0x3412 = 13330,单位是0.1 RPM,也就是1333.0 RPM
- 字节5(0x56):电流值,符号数,0x56 = 86对应实际电流8.6A
- 字节6-7(0xDC 0x05):母线电压,小端序,0x05DC = 1500,单位0.1V,即150.0V
在STM32的代码里直接用一个联合体来解析这8字节,比用移位运算一个个拼要清爽得多:
typedef union { uint8_t data[8]; struct { uint8_t mode; uint8_t reserved[2]; int16_t speed; int8_t current; uint16_t bus_voltage; } __attribute__((packed)) fields; } can_frame_data_t;这个联合体的好处很明显:CAN数据到了,直接memcpy进联合体,然后通过fields.speed、fields.bus_voltage等字段名访问,不会出现字节序搞反的问题。需要注意在Keil里要加__packed(GCC下用__attribute__((packed))),因为结构体默认是有对齐的,不加packed的话int16_t后面可能被填充2字节空白,整个结构体解析就错乱了。
注意:联合体的字节序依赖平台,ARM Cortex-M系列是小端模式,ST官方的HAL库默认也是小端,但如果换到某些大端平台,这种解析方式就翻车了。先确认平台字节序再决定能不能用联合体。
3.2 发出去的指令帧怎么打包:校验位要不要自己算
底盘控制指令我用的ID是0x200,8字节格式如下:
- 字节0:控制模式
- 字节1-4:左轮目标转速(int32_t,单位0.1 RPM)
- 字节5-8:右轮目标转速(int32_t,单位0.1 RPM)
这里有个很多人会犯的错——只关注数据字节本身,忽略了CAN的数据长度和填充规则。CAN标准帧数据段最多8字节,如果你的电机指令恰好是8字节整,没问题。但如果你加了校验位超过8字节,就得自己拆成两帧或者缩短数据宽度。
项目里我见的更多做法是控制帧不带校验位,靠CAN自身的CRC和ACK机制保证传输正确性,这是CAN协议自带的优势。所以我的控制指令就没额外加累加和校验,靠底层CRC保底。如果是自定义的上位机协议在CAN之上,那就需要自己加校验,常见做法是加一个字节的异或校验或者CRC8,比如把前7个字节异或之后放在第8个字节。
3.3 收发模式:查询还是中断还是定时器
底盘反馈这种周期性的数据,用CAN接收中断处理是最合理的。STM32的CAN外设FIFO里会有接收挂起中断,配合FIFO溢出中断,可以避免数据丢失。但要注意中断服务函数里不要做太多数据处理,收到一帧数据就丢到一个环形缓冲区,再由主循环里的底盘解析任务去处理。
而我发送控制指令时用的是定时器。因为底盘控制周期要稳定,例如20ms发一次指令,放在一个20ms的周期定时器中断里发送最靠谱。千万不要把CAN发送放到阻塞式的while循环延时里,延时不准会导致控制周期抖动,小车跑起来会一顿一顿的。
发送的代码我一般封装成这个样子的函数:
void chassis_send_speed(int16_t left_speed, int16_t right_speed) { can_frame_tx_t tx_frame; tx_frame.id = 0x200; tx_frame.dlc = 8; tx_frame.data[0] = 0x01; // 速度模式 tx_frame.data[1] = (uint8_t)(left_speed & 0xFF); tx_frame.data[2] = (uint8_t)((left_speed >> 8) & 0xFF); tx_frame.data[3] = (uint8_t)(right_speed & 0xFF); tx_frame.data[4] = (uint8_t)((right_speed >> 8) & 0xFF); tx_frame.data[5] = 0; tx_frame.data[6] = 0; tx_frame.data[7] = 0; HAL_CAN_AddTxMessage(&hcan, &tx_frame.header, tx_frame.data, &tx_mailbox); }这里要说一下:CAN控制器的发送邮箱有3个,如果上一帧还没发出去就再调用AddTxMessage,新的数据可能被排队也可能被覆盖,取决于你用的HAL版本和邮箱选择策略。我建议在速度模式下尽量保证发帧节奏比底盘反馈慢或者相等,避免邮箱溢出。
4. 代码走读:从ROS到STM32的完整CAN链路
代码走读这块,很多人一开始不知道怎么下手,因为整条链路比较长:move_base算出来的/cmd_vel话题,经过serial或者ros_control,再到socketcan的can0接口,最后到STM32的CAN外设。我把自己项目的代码链路梳理了一遍,参考价值应该比较高。
4.1 拿到一个CAN通信代码,先从哪里读
我一般按这个顺序读CAN通信代码:
- 找main函数或者初始化入口,看CAN外设初始化参数:波特率、工作模式(正常/环回/静默)、过滤器配置
- 看发送函数,关注ID怎么设置、数据段字节怎么拼接、有没有加校验
- 看接收处理入口,是中断、轮询还是DMA
- 再看协议层,电调或者传感器那边怎么定义各个ID的用途
以STM32的CAN初始化为例,HAL库的初始化代码通常长这样:
hcan.Instance = CAN1; hcan.Init.Prescaler = 6; hcan.Init.Mode = CAN_MODE_NORMAL; hcan.Init.SyncJumpWidth = CAN_SJW_1TQ; hcan.Init.TimeSeg1 = CAN_BS1_13TQ; hcan.Init.TimeSeg2 = CAN_BS2_2TQ; hcan.Init.TimeTriggeredMode = DISABLE; hcan.Init.AutoBusOff = ENABLE; hcan.Init.AutoWakeUp = DISABLE; hcan.Init.AutoRetransmission = ENABLE; hcan.Init.ReceiveFifoLocked = DISABLE; hcan.Init.TransmitFifoPriority = DISABLE;这里的分频器和时间段参数决定了最终的波特率。假设APB1外设时钟是42MHz(常见的STM32F405配置),Prescaler = 6,则CAN时钟是7MHz。然后一个位时间由SyncSeg + BS1 + BS2组成,通常SyncSeg固定1TQ,BS1 = 13TQ,BS2 = 2TQ,加起来是1 + 13 + 2 = 16TQ。所以波特率 = 7MHz / 16 = 437.5kHz,这显然不是一个常用波特率。
实际上,为了保证500k波特率,正确做法是把Prescaler设为6,BS1设为13TQ、BS2设为2TQ,这样总TQ数16个,CAN时钟7MHz除以16是437.5k,不对。得重新算:
假设总线时钟42MHz,目标500kbps,位时间 = 42MHz / 500k = 84TQ,这个TQ数太多了不现实。应该把APB1外设时钟配置为42MHz,CAN分频器Prescaler调成6,CAN内核时钟 = 42MHz / 6 = 7MHz,位时间 = 7MHz / 500kbps = 14TQ。然后SyncSeg基本是1TQ,BS1和BS2加起来要等于13TQ。比如BS1 = 12TQ、BS2 = 1TQ就是14TQ。可我在代码里看到的却是BS1=13TQ、BS2=2TQ,总TQ数变成16,波特率算下来437.5k,和电调的500k对不上,通信当然不稳定。
当时和电调厂商那边确认后,改为Prescaler=5,BS1=12TQ、BS2=1TQ(总共14TQ),42MHz/5=8.4MHz,8.4MHz/14=600k,又不对;最终采用的是Prescaler=6、BS1=12TQ、BS2=1TQ,总TQ=14,7MHz/14=500k。注意波特率配置要结合芯片实际的APB1时钟来计算,不同板子时钟树配置不一样,直接用网上模板的概率很高会翻车。
提示:STM32CubeMX生成的CAN配置默认往往能用,但如果你改了系统时钟,一定回头看一眼CAN波特率是否跟着变了。很多人改了外部晶振频率,CAN无声无息就不通了,查了很久发现是波特率错的。
4.2 过滤器配置:为什么车动起来CAN接收丢帧
CAN控制器里的过滤器是很多人容易忽略的地方。尤其是我这种一帧ID对应一个数据源的场景,如果过滤器配置成接收所有帧,然后在上层代码里手动过滤,会增加中断频率和CPU负载。
我项目里把过滤器配置成掩码模式,只接收0x200到0x2FF范围内的底盘和传感器ID。掩码模式的思路是:遮罩位为1的位必须匹配,为0的位任意外部输入。比如只关心ID.8到ID.11(CAN标准帧的ID位),对应的掩码就是0x7FF。
我实际用STM32F405的CAN1,配置成只接收ID的第8至第11位等于1的帧:
CAN_FilterTypeDef filter_config; filter_config.FilterBank = 0; filter_config.FilterMode = CAN_FILTERMODE_IDMASK; filter_config.FilterScale = CAN_FILTERSCALE_32BIT; filter_config.FilterIdHigh = (0x200 << 5) >> 16; filter_config.FilterIdLow = 0; filter_config.FilterMaskIdHigh = (0x7FF << 5) >> 16; filter_config.FilterMaskIdLow = 0; filter_config.FilterFIFOAssignment = CAN_RX_FIFO0; filter_config.FilterActivation = ENABLE; HAL_CAN_ConfigFilter(&hcan, &filter_config);如果不用过滤器,让所有CAN帧都进FIFO,当总线上还有其他传感器(比如IMU模块也是CAN接口)时,中断频率会翻好几倍。底盘控制周期一抖,导航精度就下降。过滤器配置不是小事。
4.3 从ROS侧发指令到底层,怎么确认每个环节正确
ROS侧的CAN通信基于SocketCAN,本质上是把CAN网卡作为一个Linux网络接口进行读写。在ROS里常用的方式有两种:
一种是用can_msgs/Frame消息直接收发CAN报文。订阅/cmd_vel并在回调里打包成CAN帧写入can0,这一步可以用cansend命令快速验证,也可以用python的python-can库。
另一种是用现成的ROS驱动包,比如ros_canopen,通过CANopen协议来驱动底盘电机。但CANopen协议栈比较复杂,对码农不够友好,调试成本高。我项目用的是第一种,简单直接,掌控力强。
ROS侧发包的Python代码逻辑大概如下,速度指令转换记得注意单位:
import can import rospy from geometry_msgs.msg import Twist bus = can.interface.Bus(channel='can0', bustype='socketcan') def cmd_vel_callback(msg): left_speed = (msg.linear.x - msg.angular.z * WHEEL_BASE / 2.0) / WHEEL_RADIUS right_speed = (msg.linear.x + msg.angular.z * WHEEL_BASE / 2.0) / WHEEL_RADIUS # 转换为电调需要的0.1RPM单位 left_cmd = int(left_speed * 10) right_cmd = int(right_speed * 10) data = [0x01] data += int(left_cmd).to_bytes(4, 'little', signed=True) data += int(right_cmd).to_bytes(4, 'little', signed=True) msg = can.Message(arbitration_id=0x200, data=data, is_extended_id=False) bus.send(msg) rospy.init_node('chassis_can_node') rospy.Subscriber('/cmd_vel', Twist, cmd_vel_callback) rospy.spin()从上到下,整条链路是:/cmd_vel话题 -> Twist回调 -> 数据拼包 -> SocketCAN发送 -> CAN控制器发送邮箱 -> CAN收发器 -> 差分信号 -> 电调侧接收。哪一环掉了,都能用工具定位。
5. 排障实录:我在CAN通信上踩过的那些“坑”
CAN通信排障其实是有一套方法论在的,我把项目里真实遇到的问题梳理成了一份速查手册,每一个都是我自己实际碰过的,遇到类似情况可以按这个思路排查。
5.1 通信时好时坏,先量终端电阻再说
现象:小车静止时手动发指令都正常,一跑起来就偶发失控,停一会儿又自己好了。
排查过程:
- 先看错误帧计数器,SocketCAN里用
ip -details link show can0查看bus-error计数,发现错帧率随电机转速升高而升高 - 用万用表量CANH-CANL电阻,只有40欧姆,说明总线上挂了3个120欧姆电阻
- 去掉中间节点多余的120欧姆电阻,电阻恢复到60欧姆,再跑测试,错误帧归零
根因:终端电阻过多导致信号反射叠加,在电机大电流干扰下表现为偶发通信故障。
5.2 一上电就bus-off,CAN控制器把自己关起来了
现象:底盘上电瞬间,STM32的CAN外设进入Bus Off状态,之后完全不通信。看代码里HAL_CAN_GetError返回HAL_CAN_ERROR_BUS_OFF。
原因:电机启动瞬间电流突变,CAN总线上的共模干扰超过收发器容忍范围,导致连续错误帧,CAN控制器自动离线。解决办法:
- 开启自动离线恢复:
hcan.Init.AutoBusOff = ENABLE - 如果干扰特别严重,需要降低干扰源或加磁珠、共模电感
- 延迟底盘上电和CAN初始化的时序,等电机驱动器稳定后再初始化CAN
5.3 同一帧ID,两个设备都在发,数据互相打架
现象:底盘的IMU模块和电调都往总线上发数据,结果IMU和轮速反馈相互干扰,导航定位乱跳。
排查过程:
- 用CAN分析仪抓包,发现IMU的帧ID是0x301,电调的轮速反馈帧ID正好也是0x301
- 两个节点用相同ID发送,CAN协议本身不能区分来源,上层数据就混了
- 把IMU的ID改成0x310(如果IMU支持设置ID),或者改电调的反馈ID,问题解决
经验:在自主导航系统里,谁负责什么ID,提前画一张ID分配表,不要等接上再改。比如0x200-0x21F用于底盘控制,0x220-0x23F用于底盘反馈,0x240-0x25F用于传感器,这样一眼就能从ID判断帧来源。
5.4 USB-CAN分析仪驱动导致整条链路卡顿
现象:用USB-CAN分析仪调试的时候,ROS端can0偶尔报错,帧接收延迟不稳定。
原因:这个USB-CAN分析仪在Linux下走的是gs_usb驱动,同一时刻只能一个进程访问接口。如果跑着candump或者canopen调试工具,ROS节点那边的收发就堵住了。
解决办法:调试和正式运行分时进行,或者用支持多客户端同时访问的CAN卡。如果是纯软件方案,用candump加参数:
candump can0 -L然后开另一个终端让ROS节点跑起来,发现能同时收,说明驱动支持多进程;如果不行,就得错开。
5.5 底盘速度乱跳,但CAN波形看起来正常
现象:小车在导航过程中,轮速反馈偶尔跳变到最大值,但用示波器量CAN总线波形,电平看起来挺标准。
排查思路:
- 这问题大概率不是CAN物理层,而是协议层
- 检查电调的反馈帧字节序和符号处理。比如轮速反馈是int16_t,但你用uint16_t去读,负转速会变成巨大的正数,导致速度跳变
- 检查是否有两个地方在写同一个控制变量,导致RMW(读改写)竞争
这种问题比较隐蔽,我建议在解析帧时把收到的原始字节打印出来,对比实际转速,能快速确定是解析错了还是协议错了。
为了减少这类问题,我现在在代码里加了一个调试接口,底层CAN帧原样上报到ROS的/can_raw话题,方便随时检查:
rostopic echo /can_raw加上时间戳,就能判断是哪一帧导致的跳变。这一步在调试自主导航时非常有帮助,强烈建议加上。
6. 自主导航与CAN通信的融合:从定位数据到控制指令
CAN通信在自主导航系统里不只是底盘控制这一条线。GNSS/IMU、激光雷达、底盘反馈,这些数据如果都走CAN总线,你需要整一套完整的数据规划。
6.1 导航系统里CAN数据的优先层级
我在项目里把CAN总线上传输的报文大致分成四个优先级:
- 最高优先级:底盘急停和安全相关指令,比如碰撞传感器触发、急停按钮状态
- 高优先级:底盘速度控制指令,这是实时性要求最高的,控制周期20ms
- 中优先级:底盘轮速和状态反馈,虽然也是周期性20ms左右,但丢一两帧影响不大
- 低优先级:IMU姿态数据、电池电量、温度等,这些慢速周期性报文优先级最低
在做代码走读的时候,如果代码里没有优先级设计,所有报文都一个优先级,一旦总线拥堵,安全相关的帧可能被延时,这是很危险的事。我见过有的电调支持不同ID的优先级配置,这时就应当给急停帧分配一个ID超前的地址,比如0x100,利用CAN的仲裁机制保证优先级。
6.2 底盘控制周期和导航算法周期怎么匹配
导航算法(比如move_base)输出/cmd_vel的频率一般是10Hz到50Hz,底盘控制需要更高的频率来保证运动平滑,一般是50Hz。所以中间需要一个频率适配层。
我在ROS侧加了一个频率转换节点,把/cmd_vel的Twist消息按时间戳做线性插值,在20ms控制周期里输出平滑的目标速度:
class SpeedInterpolator: def __init__(self): self.prev_cmd = None self.curr_cmd = None self.prev_time = None self.curr_time = None这种做法比直接拿最新的/cmd_vel指令去发CAN要平滑很多,特别是导航算法路径规划不太平滑的时候,小车不会一顿一顿的。没有这个插值层,小车跑起来会有明显的机械冲击感,对底盘寿命和定位精度都不利。
6.3 ROS仿真和真机之间的“同一套代码”
做ROS小车自主导航仿真时,很多人会在Gazebo里直接给个虚拟/cmd_vel到差分驱动插件,不需要走CAN。但这样测试的只是导航算法,底盘通信那部分完全没覆盖,真机上去就会踩坑。
我的做法是:在仿真和真机之间抽出一个通信抽象层。仿真时把CAN收发这部分替换成模拟器插件,真机时用SocketCAN后端。控制指令生成、频率插值、状态反馈解析逻辑共用。这样在Gazebo里跑的导航流程,到真机上只换底层驱动,不需要改导航逻辑。这个抽象层虽然初期写起来繁琐,但后面真机联调省了大把时间。
7. 工具与方法:没有CAN分析仪,怎么调车
说到工具,强烈建议买一个USB-CAN分析仪,不贵,但能让调试效率翻倍。没有分析仪的时候调试CAN,等于盲人摸象,每一步都只能靠猜。
7.1 我常用的CAN调试命令清单
在Linux下,一套核心命令可以覆盖90%的调试需求:
# 查看can0接口状态 ip -details link show can0 # 打开can0并设置波特率 sudo ip link set can0 up type can bitrate 500000 # 接收总线上所有帧并带时间戳打印 candump can0 -L # 发送一帧测试帧 cansend can0 200#01000000 # 以十六进制输出到文件 candump can0 -L can0.log在STM32端,如果条件有限没有分析仪,可以用串口导出调试信息,但要比不上CAN分析仪直观。
7.2 用逻辑分析仪凑合看的经验
手头没有CAN分析仪也是有办法的。很多逻辑分析仪支持CAN协议解码,把CANH接到逻辑分析仪的通道上,设置好采样率,就能在软件里看到帧ID和数据。我这样调过一个晚上,足够排查大部分数据解析问题。不过逻辑分析仪看不到总线错误帧,CAN物理层问题还是得靠示波器或者分析仪。
7.3 波形检查要点:采样点位置
如果你手头有示波器,看CAN波形时重点看采样点的位置。CAN有规定的采样点,一般来说在75%左右的位时间位置。很多芯片的默认采样点设置在80%附近,和电调不一致时,长距离或者干扰场景下就会出问题。
STM32的采样点通过BS1和BS2的比例来设置,BS1/(BS1+BS2)就是采样点位置。常见配置是:BS1=12TQ、BS2=1TQ,总TQ=14,采样点 = (1+12)/14 ≈ 92.8%,这个太高了,对长线缆不友好;BS1=9TQ、BS2=2TQ,总TQ=12,采样点 = (1+9)/12 = 83.3%,属于比较常见的设置。
我建议采样点设置在75%到85%之间,能兼容大多数CAN收发器。如果你和某个特定电调一直存在偶发错误,试着调一下采样点位置,往往立竿见影。
最后再说一个我自己养成的习惯:在CAN通信里,物理层和协议层的问题一定要分开排查,不要混在一起猜。协议层直接用CAN分析仪抓包看数据是否完整,物理层用万用表和示波器量波形和电阻,两层都过关了,再谈算法和逻辑,不然越调越乱。CAN通信是整个自主导航系统的底子,这块稳了,后面的SLAM建图和导航控制才有得聊。把这些东西整理成文档,比临时翻代码效率高得多。