做自主导航小车,很多人把注意力全放在上层的SLAM建图、路径规划算法上,结果车子一上电,轮子不动,电机乱抖,甚至底盘直接“失联”。我跟你讲,八成问题都出在底层通信,尤其是CAN这一环。这期就以“自主导航—CAN通信”为主线,把从硬件接线、协议设计到ROS端联调、故障排查的完整链路捋一遍。这套方案是我在真实小车项目里反复验证过的,跟着走能少踩很多坑。
1. 底层通信方案选型:为什么必须是CAN
1.1 先搞清楚底盘要传什么数据
自主导航小车的底盘,本质是一个分布式实时控制系统。STM32作为下位机,要同时管电机驱动器、编码器、陀螺仪/IMU(如果接在CAN上)、转向舵机等好几个节点。每个节点都在持续产生数据,比如电机转速反馈、电流环状态、IMU的角速度和加速度、电池电压等。同时,上位机(通常是装了ROS的树莓派或Jetson)还要实时下发速度指令、转向指令、急停指令。
这个场景有3个硬性要求:
- 实时性:速度指令从ROS发出到电机响应,整个链路最好控制在10ms以内,否则导航算法算得再准,底盘执行不到位,车子就会画龙。
- 可靠性:底盘工作在电机换向、PWM调速的强干扰环境,总线电平稍不稳定就会导致数据错乱。
- 多点互联:电机、编码器、IMU、舵机分属不同节点,用点对点通信(比如UART直连)根本接不过来。
CAN总线就是为此设计的。它属于多主总线,任何节点都可以主动发消息,自带优先级仲裁和错误检测机制。我们用的经典CAN 2.0A,标准帧11位ID,数据段最长8字节,1Mbps速率下,单条报文传输时间不到100微秒,实时性绰绰有余。
1.2 CAN和UART、I2C、SPI到底差在哪
很多初学者会惯性思维:直接用STM32的UART连蓝牙模块或者无线串口,不也能控制小车吗?确实能,但分场景。
| 通信方式 | 传输距离 | 抗干扰能力 | 多节点支持 | 实时性 | 适用场景 |
|---|---|---|---|---|---|
| UART | 15米以内 | 弱 | 点对点 | 中 | 短距离调试、蓝牙透传 |
| I2C | 1米以内 | 弱 | 有限(受地址限制) | 低 | 板载传感器连接 |
| SPI | 10米以内 | 弱 | 有限(靠片选) | 高 | 板载高速Flash、SD卡 |
| CAN | 40米以上 | 强(差分信号) | 理论110个,实际几十个 | 高 | 车载、机器人底盘、工业控制 |
I2C和SPI是芯片间通信用的,根本没考虑长线传输和多点互联,不适合电机这种会抖动的负载。UART虽然简单,但两个UART口只能接一个设备,而且它是单端信号,电机启动瞬间的压降和浪涌很容易把数据打飞。CAN是差分信号,CAN_H和CAN_L一对双绞线,信号以电压差形式传输,共模干扰被直接抵消掉,抗干扰能力天生就强。这也是为什么汽车OBD诊断、工业PLC总线几乎默认CAN。
1.3 这套小车的通信链路拓扑
我们小车的实际拓扑并不复杂,但设计思路值得参考。整条链路分成两层:
- 上层(ROS侧):Jetson或者树莓派,跑ROS的SLAM和导航栈。它没有原生CAN控制器,所以通过USB-CAN适配器接入CAN网络(相当于CANopen主站)。
- 下层(执行侧):STM32作为底盘主控,同时承担IMU数据采集、电机控制、编码器计数的任务,以CAN节点身份挂在总线上。如果电机驱动器本身支持CAN控制,那STM32就只做管理节点,指令仲裁后发给驱动器。
数据流向是这个样子的:
ROS导航栈发出几何速度指令(/cmd_vel)→ USB-CAN适配器把指令打包成CAN报文 → 总线广播 → STM32接收解析 → 通过内部PWM/GPIO控制电机驱动器 → 编码器计数反馈 → 打包成CAN报文回传 → ROS订阅(/odom)→ 里程计数据喂给AMCL和move_base。
这整个过程在10ms周期内跑完,CAN就是那条“通信血管”。
2. 硬件接线与基础配置:别小看这些细节
2.1 需要的硬件清单
这套方案里,我用的典型硬件是:
- STM32F103C8T6(或者F407,F407自带两个CAN外设,更宽裕)
- TJA1050 CAN收发器芯片(如果是F407板载了,就省事)
- USB-CAN分析仪(我用的是USBCAN-II型,兼容SocketCAN)
- 120Ω终端电阻两个(CAN网络两端各一个,这是标配)
- 双绞线若干,或直接用屏蔽双绞线
硬件连接的核心是STM32的CAN控制器引脚(CAN_RX、CAN_TX)接到TJA1050,TJA1050再以差分线形式接出CAN_H和CAN_L。USB-CAN适配器一端接USB到上位机,一端同样接CAN_H和CAN_L。
2.2 终端电阻与波特率:两个最容易犯错的地方
CAN总线两端必须接120Ω终端电阻,这不是可选项,是硬性要求。它的作用是在高速信号传输时吸收反射波,防止信号在总线末端形成振铃。如果缺失,最典型的故障就是通信时好时坏,低速波特率可能还凑合,一上500kbps或1Mbps就直接失联。
我踩过的坑是这样的:早期图省事,只在下位机这边接了一个120Ω,想着分析仪那边也许自带,结果实测CAN报文偶尔能收到,偶尔全是错误帧。拿示波器一看,CAN_H/CAN_L的差分波形在末端出现了明显的过冲和回沟,信号质量一塌糊涂。后来在分析仪端又补了一个120Ω电阻,通信立刻稳定,错误帧归零。
波特率选择上,我建议统一用500kbps或者1Mbps。不管上层跑的是SocketCAN还是直接裸收发,两边的波特率必须一致。STM32的波特率由APB1外设时钟、预分频器和BS1/BS2时间段共同决定。
以F103为例,CAN外设挂在APB1上,时钟频率36MHz。要配置成500kbps,可以按这个思路算:
CAN总线的位时间 = 1 / 波特率 = 1 / 500000 = 2微秒。如果预分频器设为9(即把36MHz除以9得到4MHz的CAN时钟,CAN时钟周期0.25微秒),那么位时间就是2微秒 / 0.25微秒 = 8个时间量子。这8个时间量子可以分配为:SYNC_SEG占1个、BS1占5个、BS2占2个。实际在STM32CubeMX里直接选500kbps,它会自动帮你算,但理解这个计算过程有助于排查问题。
2.3 CAN ID规划:让报文结构一眼可读
CAN 2.0A标准帧的ID是11位,也就是说最多可以定义2048种不同的消息ID。但对于小车来说,完全不需要铺满,反而要精打细算。
我推荐一套ID分配方案,直接抄作业:
| CAN ID | 方向 | 含义 | 数据内容 |
|---|---|---|---|
| 0x101 | 上位机→STM32 | 速度控制指令 | 线速度、角速度 |
| 0x102 | 上位机→STM32 | 急停灯/状态控制 | 刹车标志、灯控 |
| 0x201 | STM32→上位机 | 电机编码器反馈 | 左轮速度、右轮速度 |
| 0x202 | STM32→上位机 | IMU姿态数据 | Yaw角、角速度 |
| 0x203 | STM32→上位机 | 电池电压/系统状态 | 电压值、故障码 |
ID有两个考虑维度:一是优先级,ID值越小,总线仲裁优先级越高。车速指令和IMU姿态属于关键数据,应该用小ID(0x101、0x201),保证它们在总线拥塞时也能第一时间被发出。二是可读性,按照功能划分ID段,上位机和下位机写代码时一目了然。
3. 协议设计与DBC文件:从裸数据到结构化消息
3.1 为什么必须设计“数据帧结构”
刚上手的朋友常常直接在CAN发送函数里填8个字节,比如“0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00”,想当然地认为第一个字节就是线速度。这种写法在单机调试时能用,但等整套系统跑起来,多个节点互相发数据,再打日志调试时会非常痛苦,因为你对着十六进制裸数据根本不知道哪个字节代表哪个物理量。
所以需要一套协议规范——本质上就是把原始字节映射成有意义的物理量。DBC文件(CAN Database),就是这套映射的标准描述。
3.2 DBC文件里到底写了什么
DBC文件是Vector公司定义的CAN协议描述格式,可以用文本打开,核心是“BO_”开头定义报文,“SG_”开头定义信号。比如,我们定义速度控制指令这条报文:
BO_ 257 VehicleSpeedControl: 8 Vector__XXX SG_ LinearSpeed : 0|16@1- (0.001,0) [0|6.5535] "m/s" Receiver SG_ AngularSpeed : 16|16@1- (0.001,0) [-3.2768|3.2767] "rad/s" Receiver我来逐块拆解,让新手也能看懂:
BO_ 257:257是十进制ID,对应十六进制的0x101。VehicleSpeedControl:报文名,方便人读。8:报文数据长度,8字节。SG_:一个信号的开始。LinearSpeed:信号的名字。0|16@1-:这句是关键。0表示这个信号在数据场中的起始位(bit 0),16表示信号长度16位(即2个字节),@1表示采用Intel字节序(小端序),-表示数据是有符号的。(0.001,0):因子和偏移量。物理值 = 原始值 × 因子 + 偏移量。也就是说,原始值12345表示的实际线速度是12.345 m/s。[0|6.5535]:物理值的取值范围。"m/s":物理单位。Receiver:接收方,可以留空或者写节点名。
同一行格式也适用于角速度,只是起始位变成了16,占16位,有符号。
有了DBC文件,上层工具可以自动把解析后的物理量显示出来,或者直接用canmatrix库在Python里解析。我自己调试时,常常先把所有报文写进DBC,再用candump配合can-utils抓包,把数据喂给一个解析脚本,打印出来的就是“LinearSpeed=0.532m/s, AngularSpeed=0.01rad/s”这种可读信息。
3.3 数据打包与.CPP里的实际对应
DBC定义好了,STM32端发送时要按这个格式打包。比如发送IMU姿态数据,yaw角(偏航角)是有符号数:
// STM32端发送0x202报文 uint8_t can_tx_data[8]; int16_t yaw_raw = (int16_t)(yaw_deg * 100.0f); // 扩大100倍变成整数 can_tx_data[0] = yaw_raw & 0xFF; can_tx_data[1] = (yaw_raw >> 8) & 0xFF;注意这里用了小端序,低字节在前,和高位字节在后,正好对应DBC里的@1。
3.4 周期与心跳机制设计
CAN是广播式总线,每个节点都得想清楚自己多久发一次数据。我们的设计原则是:
- 速度控制指令:5ms或者10ms周期,越短控制越平滑。
- IMU姿态:20ms周期,50Hz,导航算法够用。
- 编码器反馈:10ms周期,作为里程计来源。
- 电池电压/系统状态:500ms周期,慢速上报即可。
心跳机制的加入是关键优化。STM32端每100ms发送一条在线指示报文(比如ID 0x205),上位机一旦超过200ms没收到,就判定下位机离线,立即触发急停逻辑。这个机制在真机调试时救过我一次。当时小车跑到一半,电机驱动器过热保护停机,但CAN总线上没有明确错误报文,ROS还在傻乎乎地继续发布速度指令。有了心跳检测,上位机立刻察觉,停止了指令下发,总算没有造成更严重的硬件事故。
4. 上下位机联调:从SocketCAN到STM32收发关键步骤
4.1 上位机侧:让Linux识别CAN接口
树莓派或Jetson跑了Ubuntu + ROS后,插入USB-CAN适配器,系统通常会识别成一个网络接口。使用SocketCAN标准接口,配置步骤是:
# 查看适配器对应的网络接口名,一般是can0 ip link show # 配置波特率并启动 sudo ip link set can0 type can bitrate 500000 sudo ip link set can0 up # 验证接口状态 ip -details link show can0看到state UP就表示CAN物理层已经正常了。此时可以用candump先裸抓一下总线上的报文,确认环境配置正确:
candump can0正常情况下,STM32端已经在持续发送心跳包和数据包,candump会不断刷出原始数据帧。如果什么都抓不到或者全是错误帧,先别急着查代码,回头检查终端电阻和波特率,八成是物理层的问题。
4.2 ROS端:写一个CAN转ROS话题的节点
ROS端需要一个节点,把CAN报文翻译成ROS话题,同时反过来把/cmd_vel速度指令打包发到CAN总线上。我写过一个精简版的示例,核心逻辑如下:
import can import rospy from geometry_msgs.msg import Twist bus = can.interface.Bus(channel='can0', bustype='socketcan') def cmd_vel_callback(msg): linear_raw = int(msg.linear.x * 1000) angular_raw = int(msg.angular.z * 1000) data = [ linear_raw & 0xFF, (linear_raw >> 8) & 0xFF, angular_raw & 0xFF, (angular_raw >> 8) & 0xFF, 0, 0, 0, 0 ] frame = can.Message(arbitration_id=0x101, data=data, is_extended_id=False) bus.send(frame) rospy.init_node('can_bridge') rospy.Subscriber('/cmd_vel', Twist, cmd_vel_callback) rospy.spin()这里的核心逻辑就是把ROS的线速度和角速度转成CAN数据,按我们DBC里定义的因子(0.001)打包。注意数据格式,线速度判断符号、取绝对值、再扩展成无符号整数存储,细节不要出错,错了整车方向都会反。
接收方向也是同理,读取0x201报文,解析出左右轮速度,组成nav_msgs/Odometry消息发布。注意里程计要用轮速积分,还要处理航向角融合,这些后续讲SLAM时再展开。
4.3 STM32端:CAN外设初始化与收发中断
STM32端重点看CAN外设的配置。我以STM32F103为例,说明关键点。
先看初始化。用STM32CubeMX直接生成配置即可,注意这几项:
- 波特率选500kbps,对应预分频、BS1、BS2在代码里自动生成。
- 开启CAN接收中断(FIFO0消息挂起中断)。
- 开启CAN发送邮箱空中断(如果希望发送反馈)。
接收中断处理的核心逻辑要精简,不能在里面做复杂计算:
void CAN1_RX0_IRQHandler(void) { CAN_Receive(CAN1, CAN_FIFO0, &rxMsg); switch (rxMsg.StdId) { case 0x101: // 速度控制指令 // 解析线速度、角速度,更新目标速度变量 target_linear = (int16_t)(rxMsg.Data[0] | (rxMsg.Data[1] << 8)); target_angular = (int16_t)(rxMsg.Data[2] | (rxMsg.Data[3] << 8)); break; case 0x102: // 急停控制 emergency_stop = (rxMsg.Data[0] == 0x01); break; default: break; } }发送侧也不要直接在主循环里盲目发送。推荐的做法是采用“定时发送”策略,配合一个10ms定时器中断:
// 在TIM中断回调或定时任务里,每10ms执行一次 static void send_motor_feedback(void) { uint8_t data[8]; int16_t left_speed_raw = (int16_t)(left_speed_mps * 1000.0f); int16_t right_speed_raw = (int16_t)(right_speed_mps * 1000.0f); data[0] = left_speed_raw & 0xFF; data[1] = (left_speed_raw >> 8) & 0xFF; data[2] = right_speed_raw & 0xFF; data[3] = (right_speed_raw >> 8) & 0xFF; CAN_TxHeaderTypeDef txHeader; txHeader.StdId = 0x201; txHeader.DLC = 8; txHeader.IDE = CAN_ID_STD; txHeader.RTR = CAN_RTR_DATA; // 发送到邮箱,等待发送完成 // ... }实测下来,STM32F103的CAN外设配合标准库或者HAL库都很稳定,重点是保证中断服务函数尽量短,别在里面做打印、延时之类的操作,否则会直接导致CAN消息丢失。
5. 实战故障排查:CAN突然连不上的排查全路径
5.1 故障类型一:突然一个报文都收不到
这是最常遇见的问题。热词里“stm32 can通信突然连不上”基本就是这类。排查路线要从物理层往上层走,顺序不要乱:
第一步,检查物理层。
- CAN_H和CAN_L是否接反。接反后接收端会一直收到错误帧,严重时总线直接关闭。这点可以通过示波器查看波形极性判断,如果没有示波器,就先把收发线对调试试。
- 终端电阻是否在位。用万用表测量CAN_H和CAN_L之间的电阻,正常情况应该在60Ω左右(两个120Ω并联)。如果量出来是120Ω,说明有一端没接;量出来是开路或者0Ω,说明线路有问题。
第二步,检查波特率。
总线上一旦有多个节点且波特率不一致,总线的错误计数器会很快飙升,节点进入Bus-Off状态,表现为完全无法通信。把两边都配置成统一的500kbps,然后重启所有节点。
第三步,检查软件配置。
- STM32端CAN过滤器是否配置正确。过滤器配置太严格,比如只接收特定ID的报文,会导致其他报文全被硬件过滤掉。
- CAN总线是否被初始化成静默模式或环回模式。很多人配置CAN外设时没注意,选了Loopback模式调试,之后忘了切回Normal模式,结果上位机永远收不到数据。
5.2 故障类型二:通信时好时坏,时不时的错误帧
这种比彻底失联更折磨人。我遇到过的一种典型情况是:CAN报文正常运行一段时间后突然出现大段错误帧,随后自动恢复。
排查重点放在CAN_H/CAN_L的线缆质量和走线方式上。电机驱动器的PWM大电流线如果和CAN双绞线平行走线,会产生强烈的电磁干扰,直接影响CAN收发器的差分信号。解决办法是把CAN线用屏蔽双绞线,屏蔽层单端接地,同时尽量远离电机电源线。
还有一种是CAN收发器供电不稳。TJA1050的VCC如果挂着电机电源上,电机启动瞬间电压跌落,收发器的电平阈值就不稳定了,自然会产生错误帧。要给CAN收发器单独用稳压芯片供电,并且加退耦电容。
5.3 排查清单速查表
把整个排查过程整理成一张表,方便现场对照:
| 故障现象 | 优先排查项 | 检查方法 | 解决措施 |
|---|---|---|---|
| 完全收不到数据 | 终端电阻缺失 | 万用表测CAN_H/CAN_L间阻值,应约60Ω | 两端各接120Ω |
| 完全收不到数据 | 波特率不一致 | 逐节点核对配置 | 统一为500kbps |
| 完全收不到数据 | CAN_H/CAN_L接反 | 观察错误帧计数 | 对调两根线 |
| 完全收不到数据 | 过滤器设置错误 | 检查CAN过滤器配置 | 改为接收所有ID |
| 间歇性通信失败 | 线缆受干扰 | 示波器观察波形毛刺 | 换屏蔽双绞线,远离动力线 |
| 间歇性通信失败 | 收发器供电不足 | 万用表量收发器VCC波动 | 独立稳压供电 |
| 通信正常但全是错误帧 | 终端电阻阻值漂移 | 万用表精确测量 | 更换精密电阻 |
| 发送后无反馈 | 发送邮箱满 | 检查发送完成标志 | 使用中断发送并在失败时重传 |
5.4 数据分析现场:用candump抓包定位
有一回小车在导航测试时频繁出现车轮顿挫,我原以为是电机PID参数有问题,但在上位机用candump一抓,发现CAN总线上出现了间隔性的错误帧,每隔几百毫秒就有一次。顺着时间戳比对电机电流波形,发现错误帧总是出现在电机换向瞬间。这基本坐实了干扰源是电机电磁噪声。后来把CAN线换成屏蔽双绞线,屏蔽层单端接地,并且让CAN线走远离电机的路径,顿挫立即消失。
所以说,排查CAN问题不能只盯代码。逻辑层面的错误(比如ID不匹配、数据格式错)反而不难找,难的是物理层的“隐性故障”。如果手头有逻辑分析仪或者示波器,优先量波形,比盲改代码高效十倍。
6. 个人经验补充:真正让通信系统稳定的几个习惯
手头这个自主导航项目做到现在,踩的CAN相关的坑比SLAM调参还多。这里把最底层的几条经验写出来,算是给后来人的一点提醒。
第一,硬件设计阶段就留好CAN调试口。PCB上无论如何都要预留一个独立的CAN调试排针,方便直接接CAN分析仪。不要想着靠软件打印日志,CAN总线上的错误帧、ACK错误、Bus-Off事件,普通日志根本反映不出来。
第二,协议设计越早越省事。小车刚搭起来就赶紧把DBC文件定了,哪怕是初版也会让后面省不少事。后面每加一个报文,就更新DBC,并同步给上位机和下位机,严禁出现“先写代码后补文档”的状态。
第三,CAN的失败重传机制一定要做。尤其在总线上节点较多时,发送邮箱满、仲裁失败都是家常便饭。在STM32端的发送函数里加上返回状态判断,发送失败就重试,不要默默丢弃数据。一次速度指令丢了可能感知不到,十次丢了小车就会抖动甚至失控。
第四,整车测试前先做一个CAN压力测试。用candump连续记录10分钟总线报文,同时让电机在最大负载下正反转切换。这个测试能暴露大部分物理层的连接隐患。如果通车测试时再发现通信问题,定位难度会翻好几倍。
后续这个自主导航项目我还会继续分享里程计融合、move_base参数整定、SLAM建图实操这些内容。CAN这部分是底盘的地基,地基稳了,上层才能跑得踏实。