做自主导航小车的时候,我第一个想吐槽的是:明明ROS和导航算法都已经很成熟了,真正让我熬夜的,却是底盘那根不起眼的CAN总线。标题里写的“自主导航--4.CAN通信”,说白了就是整个系统里最底层、最容易被忽略、但一坏就全车瘫痪的环节。导航算法再漂亮,最后还是要通过它把速度指令送到电机轮子上去。这篇文章,我就围绕自己在ROS小车上做CAN通信的完整经历来写,从为什么非用CAN不可,到数据帧怎么定义、代码怎么走读、踩了哪些坑,一次讲透。
如果你正在做ROS小车自主导航仿真,或者准备把仿真代码搬到真车底盘,又恰好被CAN通信折腾过,那这篇内容应该能帮你省下不少查资料和试错的时间。
1. 为什么自主导航要选CAN,而不是串口或以太网
1.1 从整车架构看CAN的位置
自主导航系统通常分三块:感知层(雷达、相机)、决策层(工控机或树莓派跑ROS)、执行层(电机驱动板、舵机、IMU等)。ROS负责做SLAM建图、路径规划、避障,但它算出来的结果不是直接驱动电机的信号,而是“线速度 + 角速度”这样的抽象指令,比如cmd_vel。
真正把这些指令变成物理运动,需要一个可靠的通信通道,连接主控和底盘执行器。CAN就是在这个位置出现的。可以说,CAN是决策层和执行层之间的“神经束”。
1.2 CAN相比串口和以太网的几个关键优势
第一是多主结构。串口是主从模式,一问一答,主控忙不过来或者某个传感器没回话,整条链路就等。CAN是多主总线,任何一个节点都能主动发数据,主控不用轮询,这在实时控制场景下太重要了。
第二是仲裁机制。CAN本身的CSMA/CA机制,能保证多个节点同时发数据时,优先级最高的先发,而且不会破坏数据。这个在底盘的场景里很实用:比如电机驱动器要上报“过流保护”,这种紧急故障优先级高,可以随时打断正常的指令帧。
第三是抗干扰和线束成本。CAN用差分信号传输,CAN_H和CAN_L两根线拧在一起,抗共模干扰能力远超TTL串口。小车上电机多,电磁环境差,我试过用串口在电机启动瞬间出现毛刺乱码,CAN就稳定得多。另外,总线式拓扑意味着所有传感器只需要就近挂在两根线上,不用每个设备都拉一堆线到主控。
第四是实时性确定。CAN的帧长度有限(数据最多8字节),波特率可以做到1Mbps,在500kbps下总线负载不高的情况下,一个数据帧从发出到被接收的延迟非常稳定,这在50Hz甚至100Hz的控制周期下很友好。
注意:不是说CAN能替代一切。它数据长度有限,传图像、传点云完全不行。但在底盘控制、传感器状态上报这类短报文场景,它几乎是工业界和机器人领域都在用的默认答案。
2. CAN数据帧到底长什么样,如何拆解
2.1 数据帧的完整结构
我把标准数据帧按位拆开来说,别被那个“标准/扩展”的概念吓到。
一个标准数据帧的报文,从开始到结束依次是:
| 字段 | 位数 | 作用 |
|---|---|---|
| SOF(帧起始) | 1 | 同步信号,由高到低的跳变 |
| 仲裁段 | 12 | 11位ID + 1位RTR |
| 控制段 | 6 | IDE位 + 保留位 + 4位DLC(数据长度) |
| 数据段 | 0~64位 | 真正要传的数据,0到8字节 |
| CRC段 | 16 | 15位CRC校验码 + 分隔符 |
| ACK段 | 2 | 接收确认 |
| EOF | 7 | 帧结束标志 |
最核心的是仲裁段里的ID(11位标准帧 / 29位扩展帧)和数据段。ID决定了这个帧是谁发的、优先级有多高,数据段里才是具体的物理量,比如速度、转角、轮速。
2.2 仲裁机制和ID优先级的选择
仲裁机制很容易理解:多个节点同时往总线发数据时,每个节点一位一位地把自己的ID发出去,谁在比较时先输出显性电平(逻辑0),谁就赢了。所以ID的数值越小,优先级越高。
这点在定义底盘协议时特别关键。我的做法是:
- 故障上报类帧用最小的ID,比如
0x000~0x00F,因为故障信息必须第一时间发出去; - 控制指令帧居中,比如
0x010~0x0FF; - 周期状态上报帧优先级最低,用大一点的ID,比如
0x100之后。
一开始我把状态上报帧的ID设成了0x001,结果发现每次电机驱动器急停,发送速度就会被低优先级的状态帧卡住,晚了几毫秒到执行器,按那个速度跑车是刹不住的。后来反过来了,故障最优先,状态上报让路。
2.3 波特率和采样点的设置
CAN通信波特率设置不对,最常见的结果就是总线全报错,一个帧都收不到。我们小车用的波特率一般是500kbps,也就是每秒传输500k个位。
波特率不是像给串口设115200那样随便写个数字就完的,底层要换算成位时间。波特率由四段组成:同步段(SYNC_SEG)、传播段(PROP_SEG)、相位缓冲段1(PHASE_SEG1)、相位缓冲段2(PHASE_SEG2),加起来就是一个位时间。
拿STM32的bxCAN举例,如果CAN外设时钟是36MHz:
位时间 = 1 / 500kbps = 2us = tq * (1 + BS1 + BS2)预设tq = 250ns,则位时间有8个tq。取BS1 = 6个tq,BS2 = 1个tq,同步段1个tq。采样点位置就落在(1 + 6) / 8 = 82.5%处,标准CAN的理想采样点一般在75%到87.5%,这个值就比较稳。
经验:采样点太靠后或太靠前,长距离总线容易采到跳变边沿附近的数据,误码率飙升。如果手头有示波器,直接看CAN_H和CAN_L的差分波形,能直观评估上升沿和下降沿的畸变程度。
3. CAN通信代码走读:从SocketCAN到ROS的cmd_vel
3.1 Linux环境下SocketCAN的基本操作
在树莓派、工控机这类Linux系统上,最常用的CAN操作接口叫SocketCAN,它把CAN总线的节点抽象成了一个虚拟网络接口,比如can0,操作方式跟操作socket差不多。
先把CAN接口启动起来,波特率设成500k:
# 设置波特率并启动 sudo ip link set can0 up type can bitrate 500000 # 查看CAN设备是否正常 sudo ip -details link show can0如果手头有USB转CAN适配器,插上去之后在/sys/class/net/下面通常会自动出现can0。没有硬件的时候,可以用系统自带的功能创建虚拟CAN接口,后面专门讲。
启动之后,用candump监听总线上的帧,用cansend从命令行发送测试帧:
# 监听所有报文 candump can0 # 发送一个标准帧,ID=0x010,data=01 02 03 04 05 06 07 08 cansend can0 010#0102030405060708candump的输出格式是接口 帧ID#数据,比如:
can0 010 [8] 01 02 03 04 05 06 07 08这个基础操作别看简单,调试通信时最先用它验证物理链路和驱动程序是否正常,非常有用。
3.2 ROS端CAN节点整体设计
在ROS小车自主导航仿真的项目里,CAN通信不是裸操作控制器,而是要把CAN收发逻辑封装成一个ROS节点。这个节点负责两件事:
- 订阅
cmd_vel(线速度/角速度),把速度指令转换成CAN数据帧,发到底盘。 - 接收底盘通过CAN发回来的轮速/里程计状态,解析成
odom话题输出。
可以理解成ROS世界和物理世界之间的翻译官。导航栈只认cmd_vel和odom,底盘只认CAN报文,CAN节点把两者拼起来。
我用Python写过一版快速原型,核心部分是这样的:
import can import rospy from std_msgs.msg import Float64 from geometry_msgs.msg import Twist from sensor_msgs.msg import JointState class CanNode: def __init__(self): rospy.init_node('can_bridge') self.bus = can.Bus(interface='socketcan', channel='can0', bitrate=500000) rospy.Subscriber('/cmd_vel', Twist, self.cmd_vel_callback) self.odom_pub = rospy.Publisher('/odom_raw', JointState, queue_size=1) rospy.Timer(rospy.Duration(1/50), self.timer_callback) def cmd_vel_callback(self, msg): # 线速度和角速度转成16位整型 linear = int(msg.linear.x * 1000) angular = int(msg.angular.z * 1000) # 组帧: ID=0x010, 数据段4字节 data = [ (linear >> 8) & 0xFF, linear & 0xFF, (angular >> 8) & 0xFF, angular & 0xFF, ] frame = can.Message( arbitration_id=0x010, dlc=4, data=data ) self.bus.send(frame) def timer_callback(self, duration=None): # 从总线上读取帧,这里简化为轮询 # 实际场景可以放在单独线程阻塞读取 frame = self.bus.recv(timeout=0.01) if frame is None: return if frame.arbitration_id == 0x110: linear_speed = ((frame.data[0] << 8) | frame.data[1]) / 1000.0 # 然后封装成 odom 发出去代码里要特别留意量化因子。ROS的cmd_vel是浮点数,底盘的CAN数据是整数,二者之间需要一个统一的比例系数。我用的是0.001,也就是说发送的时候乘1000,收回来的时候除以1000。如果你用1.0的因子,精度会很差。
3.3 帧ID和数据栏定义:先把协议定清楚
代码写来写去,最终落地靠的是一张协议表。我在做小车底盘时定义了下面这套规则,你自己做项目也可以直接参考:
| 帧ID | 方向 | 名称 | DLC | 数据定义 |
|---|---|---|---|---|
| 0x010 | 主控 → 底盘 | 速度指令 | 4 | 字节0~1:线速度(int16,单位0.001m/s),字节2~3:角速度(int16,单位0.001rad/s) |
| 0x011 | 主控 → 底盘 | 转向指令 | 2 | 字节0~1:目标转向角度(int16,单位0.01°) |
| 0x100 | 底盘 → 主控 | 状态信息 | 6 | 字节0:电源电压,字节1:控制器温度,字节2~3:错误码 |
| 0x110 | 底盘 → 主控 | 轮速反馈 | 8 | 字节0~1:左轮速度,字节2~3:右轮速度,字节4~5:左轮编码器累计值,字节6~7:右轮编码器累计值 |
| 0x001 | 底盘 → 主控 | 故障上报 | 2 | 字节0:故障码,字节1:故障级别 |
这里分享一个我的惨痛教训:一开始我把“主控发给底盘”和“底盘发给主控”的帧都随手排了ID,结果调试的时候用candump一看,全混在一起,光靠眼睛根本分不清哪条指令被底盘执行了、哪条状态是底盘发上来的。后来我强制规定:所有主控到底盘的帧ID,范围固定从0x010开始;所有底盘到主控的帧,从0x100开始。中间隔开一个量级,用candump肉眼过滤就能省下很多事。
3.4 数据段校验:别把校验位抠掉
很多自己做的小项目,CAN数据帧不带校验位,因为CAN硬件本身有CRC15校验,应用层就免了。但这个认识有代价——CAN的CRC只能防“总线传输错误”,防不了“应用层数据定义错乱”,比如底盘发回一个帧的ID是对的,但数据段里左右轮速写反了。
我在协议里加了一个简单的校验字节,放在最后一字节,算法采用和校验:把前面所有字节加起来取低8位。
def append_checksum(data): return data + [sum(data) & 0xFF]接收端收到帧之后先算一遍校验,对不上就直接丢弃,而不是把错误数据拿去算里程计。否则可能造成ROS里的odom突然跳变,导航规划直接觉得定位崩了。
4. 导航联调实战:CAN帧和ROS数据如何相互转换
4.1 从CAN轮速到里程计odom的推算
做自主导航,ROS里move_base(导航规划器)依赖一个可靠的话题,叫odom(里程计)。里程计数据从哪里来?在真车上就是从CAN回传的轮速和编码器数据,自己算出来。
已知:左轮线速度v_left,右轮线速度v_right,轮距d(左右轮中心距离),那么车体的线速度和角速度用下面的公式:
v = (v_left + v_right) / 2 ω = (v_right - v_left) / d有了v和ω,再按时间dt累加,就能得到车体在全局坐标系下的位姿变化:
delta_x = v * cos(theta) * dt delta_y = v * sin(theta) * dt delta_theta = ω * dt这段逻辑可以直接写在CAN节点里,把CAN帧解析出来的左右轮速换算成odom消息发出去。要注意CAN帧里的轮速单位一般不是m/s,而是“编码器脉冲数/秒”或“mm/s”,需要按轮径比例换算。比如底盘发回来的是“毫米每秒”,你就得除以1000转成米每秒,否则导航算法会认为车速放大了一千倍,路径跟踪立刻发疯。
4.2 控制频率的匹配问题
我调试时经常发现,ROS导航栈发送cmd_vel的频率是10Hz,但底盘电机驱动器的期望控制频率是100Hz。如果直接把cmd_vel简单转发给电机驱动器,车子会一顿一顿的,因为10Hz实在不够平滑。
正确做法是在CAN节点里加一个平滑缓冲。接收cmd_vel到本节点的回调后,不是立刻发到总线上,而是先把目标速度缓存下来,在另一个高频循环(比如100Hz)里,用简易的斜坡函数把当前速度逐步逼近目标速度,每一拍再发CAN帧:
rate = rospy.Rate(100) while not rospy.is_shutdown(): self.current_linear += (self.target_linear - self.current_linear) * 0.2 self.current_angular += (self.target_angular - self.current_angular) * 0.2 send_can_velocity(self.current_linear, self.current_angular) rate.sleep()这里0.2就是平滑系数,系数越大越激进,越接近直接追踪,系数越小越柔和,但跟踪越滞后。工程师需要根据自己的车体和执行器的响应速度去调。我做的是一个载重较小的底盘,0.15~0.25之间比较合适,电机不会抖,转弯也不会太突兀。
4.3 没有真车时怎么仿真CAN通信
ROS小车自主导航仿真一般都在Gazebo里做,Gazebo里没有真CAN硬件,但通信节点又需要真实的底层数据路径。这里我的办法是用虚拟CAN接口 vcan。
创建vcan:
sudo modprobe vcan sudo ip link add dev vcan0 type vcan sudo ip link set up vcan0关键点来了:vcan和can0的设备名一样,操作方式一样,candump、cansend都能用,但它是内存里模拟的,没有真实电气信号。你在ROS容器或者宿主机上跑CAN节点,把channel参数从can0改成vcan0,逻辑一模一样。
有vcan之后,Gazebo仿真的底盘控制完全可以通过“ROS节点 → vcan0 → 另一个模拟底盘节点 → odom”的路径打通。这样既能测试协议帧定义、ID规划、解析逻辑,又不用每次编译都动用真电机,开发效率高好几倍。我建议所有没有真车条件的同学,都用这套流程把CAN通信逻辑先跑顺。
5. CAN通信常见故障与排查技巧实录
5.1 总线上一片错误帧,什么有效报文都收不到
现象:candump can0时输出连续的错误帧,或者用ip -details link show can0看到error-active变成error-passive甚至bus-off。
排查顺序:
查终端电阻。CAN总线两端需要各接一个120Ω电阻。我有一个偷懒经历:直接拿一根杜邦线短接CAN_H和CAN_L当测试,没有接电阻,结果总线上到处是错误帧。拿万用表量CAN_H和CAN_L之间的阻抗,正常情况下应该在60Ω左右(两个120Ω并联),如果量出来是无穷大,说明有一端电阻没接。
查波特率一致性。两个节点波特率一个是500k,一个是250k,谁能收谁的?都收不到。很多USB转CAN适配器默认是500k,如果底盘控制器是250k,挂上去立刻全是错帧。这个最先确认,最简单的办法是发一帧测试报文,用另一种波特率听,看有没有ACK回显。
查CAN_H和CAN_L有没有接反。这个错误最蠢,但最容易犯。特别是不同厂家的线颜色不一样,不统一。接反后错误帧数量巨大,而且不分主从。
查地线是否共地。CAN总线虽然靠差分信号传输,但是各个节点最好仍然共用一个参考地。如果节点间地电位差太大,即使差分信号也无法正常工作。我在车上接了两个隔离电源模块,就出现了间歇性丢帧,后来把主控底和底盘底连到一起解决。
5.2 报文能收到,但周期忽快忽慢
现象:接收底盘电机反馈帧,用candump看,有时候10ms,有时候50ms才来一帧。
优先怀疑两个原因:
第一,CAN节点接收线程被阻塞了。在ROS节点里,如果recv()在一个线程里处理,而主线程被其他同步调用卡住,接收缓冲就会积压,导致“伪周期抖动”。解决办法是单独开线程接收CAN帧,或者用select()加超时处理。
第二,总线上有其他高优先级帧占用了带宽。如果你车上的故障诊断帧设计得太频繁,比如每1ms发一次,而后续命令帧要等它走完,那低优先级帧自然要排队,看起来就是周期被拖慢。用candump -ta can0 -l抓一秒钟的日志,统计每个ID的出现次数,就能看出是谁在抢占总线。
5.3 BUS OFF 了怎么办
CAN控制器检测到发送错误计数器超过256,会进入BUS OFF状态,此节点彻底不参与通信。真车调试中出现BUS OFF,意味着不是某个软件bug,而是硬件层面出了大问题。
恢复方法是在代码里加自动恢复指令,比如用STM32时调用CAN_CancelAutoRetransmit或相应的bxCAN测离线恢复寄存器,在检测到BUS OFF后延时一段时间重新初始化CAN控制器。
但这里要提醒一点:别完全依赖自动恢复,要解决根源。BUS OFF八成是CAN_H/CAN_L短路、地环流过大、线束长度过长导致信号质量太差。如果代码里只是把错误清零然后重新初始化,问题还会复现,而且复现周期可能越来越短。
我调试中见过最无语的一次:电机驱动器的接插件进水短路,导致CAN_H和电源地轻微短路,系统跑几分钟就BUS OFF一次。最后是拿万用表一个线束一个线束排查才找到。
5.4 帧内容一直不对,比如收到的速度和实际差两倍
这种问题一般是“单位换算”或“字节序”问题。
单位换算:比如协议里定义速度单位是0.01 m/s,但发送端按0.001 m/s计算,那收到的数值永远偏小。我习惯在节点启动时把“标定系数”做成一个参数,启动时从launch文件里读,调试时不必重新编译代码。
字节序:CAN数据段本身没有规定大小端,完全看协议怎么写。假如发送端按大端(高字节在前)组帧,接收端按小端(低字节在前)解析,那数据就会错得离谱。比如 0x1234 发过来,两种解析结果:
大端解析: 0x1234 = 4660 小端解析: 0x3412 = 13330我实际踩过这个坑,当时左右轮速都差了好几倍,导航程序直接判断为打滑,把车速限制到几乎为零。排查方法是发一个固定值帧,比如发01 02 03 04,然后用Pythonint.from_bytes(data, 'little')和'big'各解一遍,看哪个结果对得上。
最后一个小技巧:调试CAN通信时,我习惯在ROS节点里加一个debug_flag,开启后把每个CAN帧同时保存成CSV文件,帧ID、DLC、原始数据、时间戳都存下来。真车调试跑一圈,回来用Pandas一拉数据,所有协议问题都能直观看到,比现场盲猜高效太多。这个小习惯帮我扛过了无数个“莫名其妙”的调车下午。