☰
ROS自主导航底盘通信:CAN总线原理与实车调试实战
2026/9/30 0:59:57 网站建设 项目流程

做自主导航这些年,底盘通信一直是我觉着最“容易翻车”又最不被人重视的一环。很多人在仿真里把路径规划调得很顺,一放到实车上就各种原地打转、轮速跳变、电机抖动,大概率罪魁祸首都藏在通信链路里。这个系列写到第4篇,专门把CAN通信拎出来说透——它是ROS小车从“大脑”到“四肢”之间的那条神经系统,跑通了它,SLAM建图、自主导航里那些跟里程计相关的算法才有发挥的空间。

这篇文章适合两类人:一类是自己搭ROS小车、底盘还没完全调通的极客和工程师;另一类是看过不少ROS教程,但一直没搞明白cmd_vel话题到底是怎么变成车轮转动的入门玩家。我尽量用实际项目里能直接用上的代码和命令讲,少讲虚的。

1. 为什么自主导航的底盘通信,绕不开CAN

1.1 导航链路里,CAN到底站在哪一层

先捋一下自主导航系统的数据流:传感器(激光雷达、IMU、相机)把环境信息送给SLAM定位模块,定位结果送给全局/局部路径规划器,规划器算出速度指令,也就是ROS里的geometry_msgs/Twist消息。这个速度指令会经过底盘驱动节点,最终变成电机控制信号。

CAN通信就承担了“底盘驱动节点到电机驱动器/编码器”之间的物理链路。换句话说,导航算法的每个决策,最后都要压进一帧CAN报文里发出去,再从另一帧CAN报文里收回来做反馈。整个系统里,这一层离硬件最近,也最容易出幺蛾子。

我以前见过不少搞视觉导航的团队,算法层面跑得很好,但底盘通信用的是劣质USB串口线加简陋的私有协议,跑几分钟就死一次机,最后还怀疑是算法参数没调好。实际上问题根本不在导航,而在底层通信的可靠性。

1.2 和串口比,CAN赢在哪,输在哪

现在DIY底盘最常用的通信方案就两种:串口UART和CAN总线。串口布线简单,代码也简单,但有几个硬伤:

  • 半双工通信(大部分情况)或者主从一问一答的模式,带宽浪费严重;
  • 抗干扰能力弱,电机大电流启动时,波形乱跳,数据就花掉;
  • 没有错误检测和自动重发机制(如果不自己实现协议层的话),丢一帧数据根本不知道。

CAN总线就不一样,它是真正的多主机总线,任何一个节点都可以主动发消息;硬件层面自带CRC校验、位填充、错误识别和自动重发,一帧数据在总线上传歪了,控制器会自动处理,应用层基本无感。这对实时控制来说是质的差别。

但CAN也不是没短板。它最高速率也就1Mbps(标准帧),跟动辄千兆的以太网没法比;而且收发器、终端电阻、线束要求比串口讲究,调试门槛略高。这些代价换来的可靠性,在实际机器人项目里是完全值得的。

1.3 选型结论与适用边界

我的建议很明确:凡是带电机驱动、编码器反馈的移动底盘,只要控制器支持CAN外设,首选CAN;如果是摄像头云台、机械臂关节这种数据量不大但实时性高的场合,CAN照样合适;只有极短距离、简单低速场景才考虑串口凑合。

后面讲的所有内容,默认的硬件组合是:主控(比如Jetson或者x86工控机)通过USB转CAN适配器接一条1米以内的CAN总线,总线上挂着底盘STM32控制板、电机驱动器、编码器节点。操作系统是Ubuntu 20.04/22.04 + ROS1/ROS2,内核自带SocketCAN驱动。这套组合是社区里最常见、也最容易被默认配置坑到的场景,我会按真实翻车路径来写。

2. 硬件准备:从USB到总线的一点经验

2.1 工具选型:USB转CAN适配器怎么挑

USB转CAN模块,市面上各种各样的都有,几十块的、几百块的、上千块的。我的经验是:调试阶段可以买便宜的,实车跑起来之后建议换带隔离的。

便宜的模块多用CH340串口转CAN芯片方案,功能上没啥问题,但抗干扰和隔离性能弱,电流大的时候容易把USB口都带崩。实车跑起来之后,电机驱动和电池电源的干扰是持续存在的,没有隔离的话,轻则掉帧,重则烧USB接口。

我自己用的是CANable这种开源方案的板子,固件切到slcan模式之后,在Linux下会被识别成串口设备,然后通过slcand命令挂载成can0接口;如果固件切到gs_usb模式,内核会直接识别成一个原生SocketCAN设备。两种模式我都用过,实际效果差别不大,但gs_usb模式更省心,因为不需要额外的daemon。

另一个容易被忽略的点是总线供电。CAN收发器需要3.3V或者5V供电,有的USB适配器从USB取电,有的需要外接电源,一定要看说明书确认。我踩过一次坑,模块只接了USB线,总线上的从设备供电倒是正常,但收发器电平不对,导致怎么调都收不到ACK,折腾了两天才发现是供电问题。

2.2 Linux下把CAN接口“拉起来”

不管用什么USB转CAN模块,在Linux下最终要做的事都是把它注册成一个网络接口(比如can0),然后配置波特率、拉起来。这里用到的就是内核的SocketCAN协议栈,一套专门为CAN总线设计的网络层实现。

以gs_usb模式为例,插上设备后先看看内核有没有识别:

lsusb # 应该能看到类似 Gs_usb 的设备 dmesg | tail -20 # 能看到创建了新的网络接口的日志,比如 can0

如果设备被识别成了ttyUSB,那就是slcan模式,需要先把固件切到gs_usb模式,或者直接用slcand挂载:

sudo slcand -o -c -s6 0x01 -d /dev/ttyUSB0 can0

-s6表示波特率500kbps,不同的s参数对应不同的速率,具体看slcand的文档。

原生SocketCAN接口的话,配置命令是:

# 设置500kbps波特率 sudo ip link set can0 down sudo ip link set can0 type can bitrate 500000 sudo ip link set can0 up

注意顺序,先down再改参数再up,改参数之前如果接口没down会直接报错。另外每次重启之后,这些配置都会丢,所以建议把这几条命令写进一个systemd service或者启动脚本里,别手动敲,因为每次开机都手动配置太容易忘。

检查接口状态可以用:

ip -details -statistics link show can0

这个命令能看到波特率、环回模式、错误计数、发送接收统计,排查问题的时候特别好用。如果看到state UP说明接口正常拉起,如果看到state DOWN或者BUS-OFF就要排查了。

2.3 终端电阻和线缆布线的老生常谈

CAN总线规范要求在总线两端各接一个120欧姆终端电阻。这个细节我在项目里帮人排查过无数次,十次里有七次通信异常其实是终端电阻的问题。

具体来说,如果总线上只有两个节点(比如一个USB适配器、一个底盘控制板),那就在两边各开一个120欧;如果总线上有三个以上节点,只在物理最远的两个节点上各接一个120欧,中间的节点不要接。判断方法很简单:断电状态下,用万用表量CAN_H和CAN_L之间的电阻,正常应该是60欧左右(两个120欧并联),如果量到120欧说明只接了一端,如果量到40欧以下说明某个节点重复接了终端电阻。

线缆方面,CAN总线用双绞线,不要太细,至少24AWG以上,绞距尽量均匀。布线要远离电机电源线和PWM信号线,别图省事捆在一起。总线长度超过几米的话,绞合和质量就更关键,不然反射会严重影响波形质量,高速率下尤其明显。

3. 通信协议设计,直接决定调试效率

3.1 一帧CAN报文到底长什么样

搞CAN通信,第一步就是理解帧结构。标准CAN 2.0A帧由这么几部分组成:

  • 11位标识符(ID),决定帧的优先级和用途;
  • 数据长度码(DLC),表示数据段有多少字节,最多8字节;
  • 最多8字节的数据段;
  • 各种校验位和位填充机制,由硬件自动处理,但我们要知道它能自动发现错误。

11位ID是个很宝贵的设计资源,怎么分配ID、怎么约定数据段格式,直接决定后期调试的复杂度。有些现成协议比如CANopen,ID分配和对象字典都规范化了,但很多做机器人底盘的人还是喜欢自定义一套简单协议,因为CANopen的学习成本和使用复杂度对DIY项目来说有点过重。

我自己是自定义协议派,不是说CANopen不好,而是很多底盘就那么几个电机、几个传感器,自定义协议加上简单的校验代码,比套CANopen框架清爽得多,调试时出问题了也更好定位。

3.2 我们用的报文分配与ID规划

我常用的一套底盘协议分配方式,供参考:

方向帧ID名称数据段内容
上位机→底盘0x101速度控制指令[0xAA] [使能] [左轮速度高8位] [左轮速度低8位] [右轮速度高8位] [右轮速度低8位] [校验] [0x55]
底盘→上位机0x181底盘状态反馈[模式] [左轮编码器高8位] [左轮编码器低8位] [右轮高8位] [右轮低8位] [电压] [校验] [0x55]
底盘→上位机0x182里程计原始数据[左轮累计脉冲高16位...] [右轮累计脉冲...] [...]

帧ID选0x101、0x181这类数字,是因为它们在11位ID范围内,数值上二进制干扰少,调试时一眼能看明白。0x101控制帧用0xAA 0x55做帧头和帧尾,中间第7字节做校验,可以过滤掉大部分杂波。校验算法我习惯用最简单的异或,把第0到第5字节逐字节异或,结果放到第6字节。数据段长度固定8字节,虽然浪费了一两个字节,但解析简单、不需要动态长度判断,对MCU代码来说少很多分支。

速度值用有符号int16表示,单位是毫米每秒。比如要让左轮以0.5米/秒的速度转,那就LeftSpeed = 500,高字节是0x01,低字节是0xF4。有符号的好处是正负天然表示正反转,不用额外定义方向位。

3.3 波特率计算:从时钟树算到总线

波特率是CAN通信最容易出问题的地方,两边不一致,表现就是总线上Error Frame满天飞,数据完全不通。所以这块要彻底讲清楚。

以STM32F103为例,CAN外设挂在APB1总线上,PCLK1最高36MHz(SYSCLK=72MHz时,APB1预分频=2)。CAN波特率由三个参数决定:预分频器BRP、时间段BS1、时间段BS2,外加一个固定的同步段SJW。公式是:

波特率 = PCLK1 / (BRP * (1 + BS1 + BS2))

我常用500kbps,配置是BRP=4,BS1=9,BS2=8。代入算一下:

波特率 = 36000000 / (4 * (1 + 9 + 8)) = 36000000 / 72 = 500000Hz

这个配置的采样点位置大约是(1 + 9) / (1 + 9 + 8) = 10 / 18 ≈ 83.3%,在CAN总线里是比较推荐的采样点位置,能容忍一定程度的线缆传播延迟和节点时钟偏差。

Linux侧SocketCAN设置波特率就是开头写的bitrate 500000。两边必须严格一致。有些USB转CAN模块的默认波特率是250kbps或者1Mbps,如果底盘STM32侧烧的是500kbps固件,两边对不上,一上电就是一堆错误帧。排查这个问题最快的办法是用示波器看CAN_H和CAN_L之间的波形,但没示波器的话,就先确认两侧配置数值,别想当然。

3.4 协议定义写在哪,DBC还是头文件

协议定义的管理方式,不同项目做法不一样。汽车行业标准做法是写DBC文件,然后用工具生成代码;机器人开源社区更常见的是直接在代码里用结构体和宏定义。

我自己的项目两种都试过:DBC文件适合有现成工具链的情况,比如用PCAN的CANdb++,或者用Python的cantools库解析DBC,开发和验证协议时很直观;但DBC的语法本身也是学习成本,而且很多MCU侧的自动生成代码质量一般,最后还是手工微调。个人项目我更推荐直接在头文件里定义清晰的结构体,然后用Python脚本在PC端做同样结构体的解析,两边共用同一份协议文档(哪怕就是Markdown表格)就行。

关键在于文档要写清楚:每个帧的ID、方向、DLC、每个字节的含义、单位、范围、校验方式。不然过两个月回来,自己都看不懂自己写的协议。

4. 代码走读:从Twist到轮速,再把状态传回来

4.1 ROS侧节点:话题数据变成CAN帧

在ROS侧,底盘驱动节点的核心工作就是订阅cmd_vel,把线速度和角速度拆分成左右轮速度,然后打包成CAN帧发出去。先看一个简化的拆分公式:

// 双轮差速底盘,轮距W,轮半径R v_left = v - w * W / 2 v_right = v + w * W / 2 left_rpm = v_left / (2 * PI * R) * 60 right_rpm = v_right / (2 * PI * R) * 60

把左右轮的速度值转换成毫米每秒,填进0x101帧的数据段,用SocketCAN的send接口发出去。Linux下发送CAN帧的标准方法是创建RAW socket:

#include <linux/can.h> #include <linux/can/raw.h> #include <sys/socket.h> #include <net/if.h> int s; struct sockaddr_can addr; struct can_frame frame; s = socket(PF_CAN, SOCK_RAW, CAN_RAW); 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)); // 填充一帧 frame.can_id = 0x101; frame.can_dlc = 8; frame.data[0] = 0xAA; frame.data[1] = 0x01; // enable frame.data[2] = (left_speed_mm >> 8) & 0xFF; frame.data[3] = left_speed_mm & 0xFF; frame.data[4] = (right_speed_mm >> 8) & 0xFF; frame.data[5] = right_speed_mm & 0xFF; frame.data[6] = check_sum; frame.data[7] = 0x55; write(s, &frame, sizeof(frame));

注意在ROS2里,cmd_vel的发布频率通常由上层规划器决定,但底盘驱动节点最好自己做频率控制。我的做法是单独起一个控制线程,固定20ms周期发送,即使没有新的cmd_vel消息,也重复发送最后一帧指令(或者发送零速),保证底盘不因为收不到指令而失控。

4.2 底盘侧:CAN帧变成电机PWM

MCU侧(我以STM32为例)的核心逻辑写在CAN接收中断里:

void CAN1_RX0_IRQHandler(void) { CanRxMsg RxMsg; CAN_Receive(CAN1, CAN_FIFO0, &RxMsg); if (RxMsg.StdId == 0x101) { if (RxMsg.Data[0] == 0xAA && RxMsg.Data[7] == 0x55) { // 校验 uint8_t sum = 0; for (int i = 0; i < 6; i++) sum ^= RxMsg.Data[i]; if (sum == RxMsg.Data[6]) { int16_t left = (RxMsg.Data[2] << 8) | RxMsg.Data[3]; int16_t right = (RxMsg.Data[4] << 8) | RxMsg.Data[5]; // 调用PID控制器,输出PWM占空比 } } } }

收到的速度指令是目标速度,要让轮子真的转到这个速度,必须在MCU里做闭环控制。我用的是增量式PID,周期10ms。底盘驱动板从编码器读到的实际转速作为反馈,和目标速度做差,然后PID输出叠加到PWM占空比上。扭矩大、负载变化剧烈的场景,限幅和积分分离非常关键,不然起步会猛冲、停机会有顿挫。

4.3 闭环反馈:轮速怎么回到导航节点

导航算法需要里程计,里程计原始数据是轮速。所以底盘MCU要把编码器数据通过CAN帧发回上位机,上位机驱动节点再计算位姿变化,发布odom话题。

MCU侧定时器每50ms读一次编码器累计脉冲,左右轮各4字节int32,连同电压、故障状态一起打包,用0x182帧发给上位机。上位机解析之后,结合上一帧的时间间隔,计算瞬时线速度和角速度:

v = (left_delta + right_delta) * wheel_circumference / 2 / dt w = (right_delta - left_delta) / wheel_base / dt

然后调用tf变换发布odom→base_link的位姿。这里有个关键点:里程计角速度积分对轮距误差极其敏感。轮距标定不准,小车在导航模式下就会走着走着偏航,循环打转。我一般用“右转5圈、左转5圈回到起点”的办法实测标定轮距,误差能控制在毫米级。

4.4 超时保护和掉线处理

总线通信不是永远可靠的,所以协议里一定要有超时保护。我的方案是:MCU侧设置一个软看门狗,每隔100ms检查是否收到0x101帧,如果连续10个周期没收到,就把目标速度清零,电机急停。上位机侧也是同理,如果200ms没收到0x181或0x182帧,就把底盘标记为lost,在车体面板亮警告灯,导航节点直接进入STOP状态。

别小看这个设计。实车运行时,USB线松动、USB转CAN模块固件卡死、上位机负载过高导致调度延迟,都可能让几帧数据迟迟到不了。如果没有超时保护,小车会继续执行最后的指令,直接撞墙。我自己吃过这个亏,所以才把超时保护放在协议里,而不是放在导航算法里。

5. 调试实录:那几次让人想砸键盘的故障

5.1 现象一:can0起不来,或者起来之后马上被内核拉黑

表现是执行sudo ip link set can0 up type can bitrate 500000之后立刻报错,或者起来之后ip -details link show can0里能看到大量error帧,再过一会接口自己变成BUS-OFF。

排查思路:先看dmesg有没有内核报错,比如“failed to restart device”之类的日志。最常见的两个原因:一是波特率不匹配,总线上其他节点已经以别的速率在跑,新节点一上去就把总线带乱了;二是终端电阻缺失,信号反射严重,错误帧率居高不下。解决方式就是对照第2.3节和第3.3节的内容逐项检查。

另一个容易踩的坑是某些USB转CAN适配器在电脑休眠唤醒之后会掉线,can0接口消失。如果机器人是长时间自动运行,建议在驱动节点里做个心跳监测,发现can0不存在就把整个适配器重新初始化。

5.2 现象二:电机抖动,速度忽大忽小

车速在低速时一顿一顿的,像有人在一下一下踩油门。这个现象我排查过一次,最后发现是PID周期和CAN指令周期“打架”了。

底盘MCU的PID控制周期是10ms,但上位机的控制指令周期是50ms(因为规划频率低)。PID每10ms跑一次,但目标值只有每50ms才更新一次,于是目标值断崖式跳跃,每次跳变PID都会超调,表现出来就是抖动。

解决办法有三个任选:一是上位机把指令周期缩短到和PID周期一致(20ms以内);二是MCU侧做速度指令平滑滤波,比如一阶低通,把阶跃变化抹平;三是PID参数调松一点,降低超调。我实际用的是第二种,一阶滤波系数0.6左右,起步和刹车都柔和很多。

5.3 现象三:里程计漂移,导航时原地转圈

导航模式下小车会不断偏航,最后绕圈或者直接撞墙。除了轮距标定的问题,还有一个隐蔽原因是编码器脉冲解析有误。

比如MCU侧用的是定时器正交编码器模式,但代码里配置了错误的计数方向,左右轮都往一个方向偏,里程积分出来就是一个大弧线。排查办法是用手推车走一条直线,打印左右轮累计脉冲数,如果两个数差异很大,说明编码器安装或解析有问题。

还有一个容易被忽视的点:编码器脉冲数除以轮速采样周期换算出来的线速度,在低转速时因为量化误差大,会非常毛糙。低速时里程计不准是正常的,但导航算法不认这个说法,所以上位机侧在发布odom时,最好对速度做一次滤波,或者干脆用高分辨率编码器,别为了省钱用那种每圈只有几十个脉冲的霍尔编码器。

5.4 现象四:高负载丢帧,导航突然原地转圈

有一次我在Jetson上同时跑激光雷达建图、目标检测算法和导航规划,结果CAN通信频繁丢帧,底盘偶发抽搐。用candump一看,总线上几乎每隔几秒就有一个Error Frame。

这是典型的USB传输延迟和CPU调度延迟叠加导致的问题。SocketCAN的驱动在应用层收不到数据时会丢包,而Jetson上实时性本来就不强,高负载时用户态程序迟迟拿不到数据,CAN控制器FIFO就溢出了。

对症手段:把底盘驱动节点设为高优先级线程,减少其他进程争抢;USB转CAN适配器换用实时性能更好的方案;如果真的需要超大负载并行,考虑把底盘通信挪到一个专门的小控制板(比如ESP32或者STM32)上,通过以太网和上位机通信,让CAN总线只在小控制板下面转,这就属性能架构调整的范畴了。

5.5 问题排查速查表

现象优先检查项大概率原因
can0无法updmesg、内核对设备识别状态USB适配器固件异常或供电不足
总线上大量error帧波特率一致性、终端电阻速率不匹配或120欧电阻缺失
电机抖动PID周期与指令周期指令频率和PID频率差距过大
里程计漂移编码器方向、脉冲数计数方向配置错误或轮距不准
高负载丢帧CPU负载、USB调度驱动线程优先级过低或USB链路瓶颈
CAN控制器BUS-OFF总线短路或强干扰线缆布线问题或隔离缺失

这套表里的每一项,我都在实车上踩过坑,有些甚至反复踩了好几次。最后分享一个最不起眼但最有效的排查技巧:调试CAN通信时,先把上位机算法全停掉,只留一个candump,手动用cansend给底盘发速度指令。如果这一步能稳定控制底盘,问题就出在上层;如果这一步都做不通,那就是底层硬件和配置的锅。用这种“切层排查法”代替满系统瞎猜,定位问题的速度快十倍。做自主导航这几年,越来越觉得很多玄学问题,到最后都是物理和配置问题——先把通信这层夯实了,导航算法才有底气在实车上跑起来。

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

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

立即咨询