
做自主导航这些年底盘通信一直是我觉着最“容易翻车”又最不被人重视的一环。很多人在仿真里把路径规划调得很顺一放到实车上就各种原地打转、轮速跳变、电机抖动大概率罪魁祸首都藏在通信链路里。这个系列写到第4篇专门把CAN通信拎出来说透——它是ROS小车从“大脑”到“四肢”之间的那条神经系统跑通了它SLAM建图、自主导航里那些跟里程计相关的算法才有发挥的空间。这篇文章适合两类人一类是自己搭ROS小车、底盘还没完全调通的极客和工程师另一类是看过不少ROS教程但一直没搞明白cmd_vel话题到底是怎么变成车轮转动的入门玩家。我尽量用实际项目里能直接用上的代码和命令讲少讲虚的。1. 为什么自主导航的底盘通信绕不开CAN1.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、怎么约定数据段格式直接决定后期调试的复杂度。有些现成协议比如CANopenID分配和对象字典都规范化了但很多做机器人底盘的人还是喜欢自定义一套简单协议因为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最高36MHzSYSCLK72MHz时APB1预分频2。CAN波特率由三个参数决定预分频器BRP、时间段BS1、时间段BS2外加一个固定的同步段SJW。公式是波特率 PCLK1 / (BRP * (1 BS1 BS2))我常用500kbps配置是BRP4BS19BS28。代入算一下波特率 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帧变成电机PWMMCU侧我以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给底盘发速度指令。如果这一步能稳定控制底盘问题就出在上层如果这一步都做不通那就是底层硬件和配置的锅。用这种“切层排查法”代替满系统瞎猜定位问题的速度快十倍。做自主导航这几年越来越觉得很多玄学问题到最后都是物理和配置问题——先把通信这层夯实了导航算法才有底气在实车上跑起来。