☰
CAN通信实战:从终端电阻到数据解析的自主导航避坑指南
2026/9/28 1:15:22 网站建设 项目流程

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建图和导航控制才有得聊。把这些东西整理成文档,比临时翻代码效率高得多。

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

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

立即咨询