如果你以为自主导航的核心就是SLAM建图和路径规划,那你大概率会在第一次整车联调时被底盘通信按在地上摩擦。我做过几台基于ROS的自主导航小车,从早期的串口直连到后来全面切换CAN总线,踩过的坑足够写满一页调试记录。今天这篇是自主导航系列的第四篇,专门聊CAN通信。仿真里跑得飞快的导航算法,为什么上了真车就各种抽风?多半问题不在算法,而在你根本没认真对待的通信链路上。这篇东西适合正在做ROS小车自主导航仿真、准备把方案往真车上搬、或者已经被CAN帧折腾到失眠的开发者。
1. 为什么导航小车离不开CAN总线:先搞清楚通信选型这件事
自主导航小车本质上是一个典型的分布式实时控制系统:激光雷达或深度相机负责感知,工控机或树莓派跑SLAM建图和路径规划,底盘控制板负责电机驱动和编码器采样,还可能挂着电池管理、急停继电器、超声波传感器这些外设。这么多节点之间要频繁交换数据,通信链路就是导航系统的"神经系统"。
早期我做第一台小车的时候图省事,直接用串口(UART)把底盘控制板和上位机连起来,波特率设到115200,一帧指令十几个字节,上电那一刻跑得挺欢。等真正开始跑导航算法、频繁下发速度指令的时候,问题就来了:串口是一对一通信,你没法挂第三个、第四个节点;RS232电平抗干扰能力弱,电机启动瞬间电压跌落就丢字节;更难受的是串口没有优先级概念,底盘反馈和传感器数据挤在同一条线上,谁先谁后全看代码心情。后来换成RS485,长距离抗干扰确实好一些,但RS485是半双工主从结构,所有节点都得等着主机一个个点名才能发言,实时性和扩展性依然是硬伤。
CAN总线在这几个维度上几乎是碾压级的。它是多主总线,任何节点都可以主动发消息,不需要主机轮询;硬件层面用差分信号传输,CAN_H和CAN_L两根线的电压差来表达逻辑状态,抗共模干扰能力比单端信号强太多,电机驱动器在旁边疯狂斩波也不怕;总线仲裁机制让高优先级帧可以"打断"低优先级帧,比如急停命令永远能插队;而且一条CAN总线最多挂110个节点,扩展底盘外围设备完全够用。
还有个特别容易被忽视的点:CAN帧的传输是由硬件完成的,一帧数据发出去,发送控制器会处理位填充、CRC校验、应答确认这些脏活累活,MCU核心只需要往邮箱里丢数据就行。对导航这种对实时性敏感的场景,硬件级别的确定性带来的安心感,是软件轮询完全给不了的。
所以我的结论很直接:要跑自主导航,正经方案就是CAN,没有之一。仿真里你可以用topic直连糊弄过去,但真车从底盘电机到上位机的链路,必须是一条实时、可靠、可扩展的总线。这也是为什么ROS生态里会有socketcan_canopen、ros_control的can_interface这些现成工具,厂家做底盘驱动板也会标配CAN口——不是你运气好遇上了,是行业已经默认了这套玩法。
2. CAN数据帧的底层逻辑:从位填充到仲裁,搞懂之后再设计ID分配
很多教程会把CAN帧结构画成一张满是箭头的图,然后让你记住SOF、仲裁场、控制场这些名词就完事了。但我的体会是,你不理解每个字段为什么存在,后面遇到帧错误、仲裁异常、总线挂死,就只能靠玄学排查。所以这里用一次真实发送走一遍完整的数据帧。
一个标准CAN数据帧长这样:SOF(帧起始,1个显性位)→ 仲裁场(11位标准ID或29位扩展ID,加1位RTR)→ 控制场(IDE、r0、DLC共6位)→ 数据场(0~8字节)→ CRC场(15位CRC加1位CRC界定符)→ ACK场(2位)→ EOF(7个隐性位)→ IFS(帧间隔至少3个隐性位)。
其中决定无数人命运的,是仲裁场和位填充机制。CAN总线用电平区分逻辑值:显性电平对应逻辑0,隐性电平对应逻辑1。如果两个节点同时往总线上发数据,一个发显性一个发隐性,总线电平最终是显性(也就是0)。仲裁的规则就是:ID值越小的帧,优先级越高,因为它更早让总线进入显性状态。
打比方说,总线就是一条单车道,所有节点都在喊自己要去的方向,每个ID代表一个"车号"。ID开头是0的车,第一个bit就把总线拉到显性,ID开头是1的车看到总线已经被别人拉低了,知道自己让位,立刻停止发送转为接收。所以急停这种救命帧,ID必须设计得最小,比如0x001到0x00F这个区间,永远比0x1xx的速度指令先通行。这个优先级设计不是谁的代码写的牛,而是CAN协议物理层面保证的。
位填充(bit stuffing)值得单独讲,因为很多帧错误问题都出在这里。CAN协议规定,同一电平持续超过5个bit时,发送方必须自动插入一个反相位的bit,防止接收方失去同步。比如你连续发了5个隐性位,硬件会在第5位后面强制塞一个显性位进去,接收方收到后再把这个填充位删掉。注意,这个操作是CAN控制器硬件自动完成的,你在应用层设计的8字节数据能不能原样出现在总线上,取决于数据里有没有连续超过5位的相同电平——你根本不用管,硬件都处理了。但反过来,如果你用逻辑分析仪抓总线波形做协议分析,看到数据里莫名其妙多出来的位,第一反应应该是"这是位填充",而不是怀疑自己解码错了。
控制场里的DLC字段是Data Length Code,表示后续数据场有几个字节。注意DLC范围是0到8,超过8怎么办?那是CAN FD(灵活数据速率)的事。CAN FD在机器人领域用得还不多,因为大量底盘驱动板还是传统CAN 2.0,你先别急着追新,把标准帧玩明白再说。
CRC场是15位CRC校验,覆盖从SOF到数据场所有bit,接收方算出来对不上就会丢帧并请求重发。这里有个容易踩的坑:CRC校验是硬件做的,但你如果报错帧(Error Frame)处理不当,节点会不停重发错误帧,把整条总线拖入"总线关闭"状态。我刚入行时调一块驱动板,接上CAN后整车所有节点全部掉线,查了半天是某块板子上拉电阻虚焊导致电平漂移,接收方一直校验失败,疯狂发错误帧淹没总线。这种问题如果你不懂错误帧机制,绝对排查不出来。
理解了帧结构和仲裁,再回头看ID分配就顺理成章了。我在实际项目中,给一台导航小车规划的11位标准ID通信矩阵大概长这样:
| ID范围 | 用途 | 数据载荷示例 | 优先级说明 |
|---|---|---|---|
| 0x001~0x00F | 急停、安全控制 | 急停状态、刹车指令 | 最高,必须插队 |
| 0x100~0x10F | 速度指令下发 | 线速度、角速度 | 高,导航实时性依赖 |
| 0x200~0x20F | 底盘编码器反馈 | 左右轮里程、速度 | 中高,里程计来源 |
| 0x300~0x30F | 电池/电源管理 | 电压、电流、电量 | 低,状态监视 |
| 0x400~0x40F | 外围传感器 | 超声波、红外等 | 低,非实时性数据 |
数据帧里的字节排列也要提前定好,比如速度指令统一用小端序:Byte0~3是线速度float,Byte4~7是角速度float。别小看这个约定,如果电机驱动板厂家按大端序算,你自己按小端序解,那导航跑起来小车就会画龙,排查一整天发现是字节序问题,血压直接拉满。所以通信矩阵文档必须在硬件设计阶段就敲定,所有节点共用一份,谁也不能随意改单个字段的排列。
3. 硬件选型与电路设计:收发器、终端电阻、隔离策略一次说清
CAN的软件和协议是通用的,但硬件设计五花八门,选错或者画错,上层代码写得再好也是白搭。我从三块板子的迭代经验里总结出这套选型思路。
第一是CAN收发器芯片。这是MCU的CAN控制器和物理总线之间的"翻译官",把控制器输出的逻辑电平转换成CAN_H和CAN_L的差分信号。最常见的型号包括NXP的TJA1050、TI的SN65HVD230、以及带隔离的ISO1050。TJA1050是经典中的经典,兼容性最好,但它是5V供电的。SN65HVD230是3.3V供电,适合直接和3.3V的MCU搭配,注意它工作电压范围窄,供电纹波必须控制好。如果你的底盘电机功率大、干扰强,或者车上多个节点间距拉得比较开,强烈建议直接用带隔离的收发器,比如ISO1050或者CTM1051模块,内部把信号和电源都隔离了,地环路干扰直接断掉。
第二是终端电阻。CAN总线规范要求在物理链路最远端的两端各接一个120欧姆电阻,匹配特性阻抗,抑制信号反射。这里有个很多人都犯过的错误:以为在每个节点上都焊一个120欧电阻就行。我见过四块板子每块都焊了120欧,结果总线等效阻抗变成30欧,CAN_H和CAN_L的差分信号幅度被拉低到接近判定阈值,偶尔能通偶尔不通,比不接还惨。正确的做法是:如果总线上只有两块板子(上位机+底盘驱动板),那就两头各一个;如果有三块以上节点,必须确认哪两块是物理链路的最远端,只在这两个位置接。实际工程中,不少驱动板会预留跳线帽或者焊盘,让你自己决定是否启用这个120欧,就是怕你到处乱接。我自己的习惯是:先不接任何终端电阻,用示波器看波形,等确认了物理拓扑之后,在链路两端再补上。
第三是外围保护电路。电机驱动板会产生很高的尖峰电压和地弹噪声,所以CAN收发器旁边一定要加共模扼流圈和TVS二极管。共模扼流圈有阻抗的是共模信号,差分信号能正常通过,这能在很大程度上滤掉电机斩波带来的共模干扰。TVS则负责吸收浪涌尖峰,防止ESD或者感性负载拉电弧时把收发器打坏。我有一块早期设计就没加TVS,结果某次电池接反,啪的一声,成本几块钱的收发器瞬间报废,后面所有板子都学乖了,保护电路绝不省。
第四是MCU端的CAN控制器资源。STM32F103系列的bxCAN有三个发送邮箱、两个接收FIFO,对于底盘控制这种角色完全够用。如果要做相对复杂的运动控制,可以用STM32F405/407,同样是bxCAN但主频更高,中断响应更快。再往上是带FDCAN的G4系列或者H7系列,支持CAN FD但说实话在导航小车场景中用不太上。我的选择是:底盘驱动用STM32F103C8T6级别就够了,上位机侧如果是工控机直接用USB-CAN适配器或者PCIe-CAN卡,如果是树莓派就可以用RS485转CAN模块或者SPI接口的MCP2515。这里建议,能原生支持SocketCAN的设备优先选原生支持的,比如树莓派加MCP2515内核驱动很成熟,USB-CAN适配器只要芯片是国芯或者江智的,大多数也都有Linux驱动。
在电源设计上还有个小细节:CAN收发器的供电最好单独用DC-DC隔离出来,或者在总线入口加稳压和滤波。原因很简单,电机启动瞬间电池电压可能被拉低到10V以下,如果收发器和MCU共用同一路电源,逻辑电平就会波动,很容易出现链路误判。我用过一个来自国产驱动板厂家的方案,把收发器供电单独用一颗隔离DC-DC模块,效果立竿见影——总线错误率从千分之几直接降到零。
4. MCU端CAN驱动代码走读:从初始化到收发中断,我这样设计
代码走读是这个系列的重头戏。很多开发者从Arduino或者仿真环境转过来,一看STM32的CAN驱动就头疼,因为寄存器多、回调和中断嵌套又多又绕。我这里给你一份可以直接落地的思路,基于STM32 HAL库,但在关键地方说明为什么要这么做。
先看初始化。CAN外设初始化分几步:开时钟、配引脚、配CAN参数、配滤波器、注册中断。引脚配置就是把PA11和PA12(CAN_RX和CAN_TX)复用为AF模式。CAN参数里最重要的是波特率计算,HAL库用三个时间量子字段:时间段1(BS1)、时间段2(BS2)、同步跳转宽度(SJW)。STM32的位时间 = 1(同步段) + BS1 + BS2,再乘以时间量子TQ。比如APB1外设时钟36MHz,你要500kbps,那每位时长就是72个TQ。我常用一套参数:Prescaler = 4、BS1 = 13、BS2 = 2,采样点大约在87.5%。采样点靠后一点,对总线长线传输更有利,采样到的电平更稳定,这是很多老工程师的经验。
static void MX_CAN1_Init(void) { // 假设APB1外设时钟为36MHz // 目标波特率500kbps: 36M / 4 / (1 + 13 + 2) = 500k hcan.Instance = CAN1; hcan.Init.Prescaler = 4; 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; if (HAL_CAN_Init(&hcan) != HAL_OK) { Error_Handler(); } }几个配置项单独说。AutoRetransmission(自动重传)开启后,如果发送失败,硬件会自动重发该帧,直到成功或触发BusOff,这对速度指令的下发非常友好,代码里不用写重传逻辑。但注意如果使能了自动重传,高优先级帧会不停抢占总线,可能饿死低优先级帧,所以和ID分配策略要配合好。接收FIFO锁定(ReceiveFifoLocked)我建议关闭,这样新帧会覆盖旧帧而不是直接丢弃,底盘反馈数据偶发丢掉一帧对导航来说可以接受,但反馈堵死导致里程计跳变才是灾难。
滤波器是CAN的硬件收件箱过滤机制。滤波器的意义是:只有匹配规则的帧才进到FIFO,不匹配的帧由硬件直接丢弃,不给MCU增加负担。对底盘控制板来说,比如只需要接收0x100~0x10F的速度指令和0x001~0x00F的急停帧,可以把滤波器设为标识符掩码模式,只匹配高8位ID对应的地址段。HAL库配置如下:
void CAN_FilterConfig(void) { CAN_FilterTypeDef filter; filter.FilterBank = 0; filter.FilterMode = CAN_FILTERMODE_IDMASK; filter.FilterScale = CAN_FILTERSCALE_32BIT; filter.FilterIdHigh = (0x100 << 5); // 标准ID左移5位存入寄存器 filter.FilterIdLow = 0; filter.FilterMaskIdHigh = (0x7F0 << 5); // 掩码: 只匹配ID高7位 filter.FilterMaskIdLow = 0; filter.FilterFIFOAssignment = CAN_RX_FIFO0; filter.FilterActivation = ENABLE; HAL_CAN_ConfigFilter(&hcan, &filter); }掩码模式里,掩码位为1表示该位必须匹配,为0表示任意。上面这个配置匹配的是ID从0x100到0x10F的所有帧,正好对应速度指令区间。如果想只匹配某一帧,把FilterMaskIdHigh改成精确的ID即可。这个设计能有效降低中断频率,底盘板只需响应和自身相关的通讯,其余流量不打扰。
发送部分,我用一个带超时保护的接口封装HAL库的HAL_CAN_AddTxMessage。因为HAL库的发送接口只负责把帧拷贝到发送邮箱,真正发出总线是硬件的事,如果邮箱满了返回HAL_BUSY,代码需要做重试或超时处理。实践中,速度指令下发频率在50Hz,而CAN发送邮箱只有3个,同一时刻最多3帧排队,超过就要等。给发送加超时是为了保护控制周期不因为总线拥堵而被打乱。
typedef struct { uint32_t id; uint8_t data[8]; uint8_t len; } CanFrame_t; uint8_t CAN_SendFrame(uint32_t id, uint8_t *data, uint8_t len) { CAN_TxHeaderTypeDef header; uint32_t mailbox = 0; uint32_t tickstart = HAL_GetTick(); header.ExtId = 0; header.StdId = id; header.IDE = CAN_ID_STD; header.RTR = CAN_RTR_DATA; header.DLC = len; while (HAL_CAN_GetTxMailboxesFreeLevel(&hcan) == 0) { if ((HAL_GetTick() - tickstart) > 10) return 0; // 10ms超时,避免死等 } if (HAL_CAN_AddTxMessage(&hcan, &header, data, &mailbox) != HAL_OK) return 0; return 1; }接收部分用中断+FIFO。HAL库的回调机制是:在HAL_CAN_RxFifo0MsgPendingCallback里取帧,然后立刻把下一轮接收使能恢复。很多人第一次写这里会漏掉一句话:HAL_CAN_ActivateNotification,导致只收到第一帧就再也没后续了。我贴一下自己的处理方式:
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef header; uint8_t data[8]; uint32_t fifo = CAN_RX_FIFO0; if (HAL_CAN_GetRxMessage(hcan, fifo, &header, data) != HAL_OK) return; // 根据header.StdId分发到速度指令/急停处理函数 ProcessCanFrame(header.StdId, data, header.DLC); // 重新使能接收中断,保证下一帧还能进回调 HAL_CAN_ActivateNotification(hcan, HAL_CAN_RX_FIFO0_MSG_PENDING_IT); }接收帧帧进中断回调的速度可能非常快,如果你在回调里做复杂的数学运算或者printf打印,分分钟把CAN接收FIFO占满,导致新帧覆盖或者溢出。我的做法是:回调里只做解析和缓存,把帧内容拷贝到环形缓冲区,然后在主循环或控制定时器里统一处理。用一个简单的环形队列即可:
#define RX_QUEUE_SIZE 32 static CanFrame_t rx_queue[RX_QUEUE_SIZE]; static volatile uint8_t rx_head = 0, rx_tail = 0; void ProcessCanFrame(uint32_t id, uint8_t *data, uint8_t len) { uint8_t next = (rx_head + 1) % RX_QUEUE_SIZE; if (next != rx_tail) { // 队列未满 rx_queue[rx_head].id = id; rx_queue[rx_head].len = len; memcpy(rx_queue[rx_head].data, data, len); rx_head = next; } }主循环里定期取队列,按ID分发处理。这个队列缓冲的好处是:即使CAN总线上瞬间涌入大量帧,处理端也不会因为单帧耗时过长而丢帧,它攒着等你有空慢慢处理。代价是队列溢出时旧帧会被丢弃——但导航场景里,真的不需要每一帧都精确处理,最新的一帧才有效,旧的丢弃反而是最优解。
对了,还有一个很多人忽略的细节:CAN的HAL库默认不会开启时间戳功能,但如果你需要把编码器反馈帧和激光雷达时间戳对齐做融合,可以在HAL_CAN_ActivateNotification里同时打开HAL_CAN_TX_MAILBOX_EMPTY_IT和HAL_CAN_RX_FIFO0_MSG_PENDING_IT,然后读CAN硬件时间戳寄存器。这块我后面在里程计处理里讲,这里先记个印象。
5. 机器人操作系统侧集成:SocketCAN、canopen框架与底盘驱动节点
MCU端的驱动写完了,上位机侧怎么办?如果你用的是Linux工控机或者树莓派跑ROS,最常见也最推荐的做法是走SocketCAN。SocketCAN是Linux内核原生的CAN协议栈实现,把CAN接口抽象成一个网络接口,叫做can0。所有基于SocketCAN的应用程序都用标准的socket接口收发数据,工具链非常成熟,和ethercat/udp这些网络协议的开发体验类似。
接入流程分两步:硬件识别和接口配置。以树莓派 + MCP2515 SPI模块为例,内核已经内置mcp251x驱动,设备树里把spi0.0配成can接口后,启动系统就能看到can0。USB-CAN适配器(比如常见国产的CANalyst-II)一般是字符设备,需要厂商驱动或者使用自带库,我倾向于用can_utils加gscan这些工具测试通信。如果驱动器支持SocketCAN原生驱动,那就最省心,直接用can0操作。
接口配置通常这三条命令:
sudo ip link set can0 type can bitrate 500000 sudo ip link set can0 up candump can0第一条设置波特率,第二条启动接口,第三条是监听总线上所有帧。用了SocketCAN,你就可以在命令行直接调试,不用反复动MCU代码,效率提升巨大。我自己的调试习惯是开三个终端:一个跑candump看原始帧,一个用cansend手动发测试帧,一个跑底盘驱动节点看日志。三个窗口一对照,问题在哪一层立刻清楚。
在ROS层面,集成CAN有两条路线:一是用现成的canopen_master这类包,二是自己写一个底盘驱动节点。我两种都试过,这里说说取舍。
canopen是CAN应用层协议,定义了对象字典、PDO(过程数据对象)、SDO(服务数据对象)这些概念。如果你用支持的电机驱动板,比如很多国产伺服驱动器都支持CiA 301协议栈,那直接用canopen_master去配置节点、配置PDO映射,是最省事的方式。比如我可以把电机驱动器映射成看门狗守护的PDO通道,PDO每10ms自动上传编码器值,无需MCU端发指令请求。这套方案的优点是不用自己解析底层帧格式,缺点是要学习canopen那套概念,而且一旦驱动板厂家对CanOpen的实现不规范,调试同样崩溃。
但如果你用的是自己设计的底盘驱动板,或者厂家给的CAN协议是自定义的,那自己写一个窄而薄的驱动节点反而是最快路径。ROS里的做法是一个底盘节点订阅cmd_vel,发布odom。底盘节点内部维护一个CAN帧的发送线程和接收线程。
#!/usr/bin/env python3 import can import rospy from geometry_msgs.msg import Twist, TwistStamped from nav_msgs.msg import Odometry import struct class CanChassisNode: def __init__(self): rospy.init_node('can_chassis_node') bus = can.interface.Bus(channel='can0', bustype='socketcan') self.cmd_sub = rospy.Subscriber('cmd_vel', Twist, self.cmd_callback) self.odom_pub = rospy.Publisher('odom', Odometry, queue_size=10) def cmd_callback(self, msg): # 线速度/角速度打包成8字节: 前4字节线速度float, 后4字节角速度float data = struct.pack('<ff', msg.linear.x, msg.angular.z) frame = can.Message(arbitration_id=0x101, data=data, is_extended_id=False) try: self.bus.send(frame) except can.CanError: rospy.logwarn_throttle(1.0, 'CAN send timeout') def rx_loop(self): for msg in self.bus: if msg.arbitration_id == 0x201: # 编码器反馈帧: 前4字节左轮累计里程float, 后4字节右轮累计里程float left, right = struct.unpack('<ff', bytes(msg.data[:8])) self.publish_odom(left, right) if __name__ == '__main__': node = CanChassisNode()真实的驱动节点比这复杂不少,要处理里程计坐标系换算、协方差矩阵、帧丢失超时保护、心跳监控,但骨架就是这个思路。Python版本方便快速验证,生产级的我建议用C++重写,减少GC抖动。还有一个常见的坑:can.interface.Bus在底层重新打开的时候,偶尔会把已经配置好的can0波特率重置,导致原本正常的总线突然通信失败。我建议在节点启动代码里先检查一次ip link show can0,确认UP状态,必要时重新执行一次配置命令再开始收发。
在Gazebo仿真里做自主导航时,通讯链路完全被topic替代,你根本不用关心底层。正因为如此,从仿真搬到真车才会被CAN这个"新东西"绊倒。我建议在仿真阶段就把速度指令和里程计的接口抽象成统一的数据结构,真车节点只需要替换send/receive的实现层,上层导航算法完全不动。这也是为什么我坚持底盘驱动节点独立成包的意义——它把物理世界和算法世界彻底隔离。
6. 联调现场:帧丢失、总线繁忙、奇偶校验异常的完整排查链路
写到这里,放下代码,聊聊我最想分享的实战排查过程。因为我做这个系列的意义就在于此:让读到的人少熬夜调CAN。
第一次整车联调,接好电源,打开底盘驱动节点,上位机发了条cmd_vel,小车纹丝不动。candump can0看总线,毛都没有——上位机的CAN帧根本没发出来。这种最安静的现象,往往不会是底层疑难,而是初始化顺序问题,八成是波特率配置错误导致发送超时,或者是can0根本没起来。我登到Ubuntu终端一看,果然can0状态是DOWN。用上文的三条命令重新配置一番,can0起来了,candump能看到0x101速度帧了,但小车还是不动。再查底盘驱动板,MCU日志显示一个帧都没有收到,主板上CAN收发器的TX/CLK灯纹丝不动。
我怀疑是收发器没工作,用示波器点在CAN_H和CAN_L上,发现差分电平只有不到0.3V,明显低于显性电平的阈值。查了一块驱动板的原理图,赫然发现设计上把CAN收发器的VREF脚短路到地了,那个脚需要接一个分压电阻网络才能输出参考电压。这个坑属于硬件设计错误,不是软件问题,但它告诉你:CAN联调必须带着示波器,否则很容易在软件排查里钻牛角尖。
第二类经典症状是"时好时坏":反复发送速度指令,小车偶尔动一下,更多时候不动,用candump抓帧,发现上位机发出的帧旁边跟着大量错误帧。我第一步是检查波特率是否全体统一:上位机SocketCAN配的500kbps,底盘驱动板配的250kbps,两边都在发送,因为没有节点能正确解码对方,全都进入错误帧状态,总线被错误帧占满。这个案例里,CAN的CRC校验机制就起了反向作用——它太可靠了,任何一位电平错误都会整帧报废,如果双方波特率不一致,那就是一发错误帧洪水。解决办法就是把所有节点波特率统一,并且在上位机侧用canbusload看总线负载率,负载率长期超过60%就得考虑降频或拆分ID区间了。
第三类问题最阴间:光纤般的"幽灵帧"。底盘驱动板明明没有任何人给它发指令,它却会自动改变速度。我一度以为是代码逻辑有bug,后来用candump挂了一整晚,发现总线上偶发出现一段乱码帧,ID是0x555,内容是0xAAAAAAAAAAA。查遍通信矩阵,没有这种帧。最后用示波器看总线条纹,发现确实有周期性的毛刺——是电机PWM斩波通过线束耦合到CAN线上,形成持续几百纳秒的干扰脉冲,被某个节点误认为SOF,导致它解析出一堆假帧。解决办法就是在CAN接口末端加共模扼流圈,并把CAN线缆改成双绞线,和电机动力线在物理上拉开距离。从那以后,我特别强调一条布线原则:CAN线走独立线槽,绝不和电机线绑在一起走。
第四类是总线繁忙导致的"饿死"。我设计ID的时候,给编码器反馈留了0x201,给速度指令留了0x101,给急停留了0x001。理论上急停是ID最小优先级最高。但在实际联调中发现,因为编码器反馈帧的频率被设成1kHz,而急停帧是低频事件,只有当急停按键按下那一刻它才发一次。平时总线被编码器帧占用了90%的负载,急停帧能挤进去吗?能,但是要等当前帧发完,最坏情况延迟是帧间隔加上本帧时间,按1kHz频率算最长延迟大约1ms左右,这在紧急停车场景里其实是可以接受的。但如果我所追求的是万不得已的极限安全性,就得在驱动板设计上把急停做成硬件直接切断电机PWM,而不是依赖CAN帧仲裁。这个思路提示:CAN的优先级可以帮你缩短高级别帧的等待时间,不能替代独立的硬件安全回路。
排查完这么一圈,我把经验收敛成一张排查速查表,贴在工作台旁边:
| 现象 | 首要怀疑点 | 验证手段 |
|---|---|---|
| 完全无帧 | can0未UP、波特率不对 | ip link show can0;cansend测试 |
| 大量错误帧 | 波特率不一致、终端电阻缺失 | candump统计error;示波器看波形 |
| 时好时坏 | 接地问题、共模干扰 | 示波器看CAN_H对地噪声 |
| 偶发幽灵帧 | 线缆耦合干扰、接触不良 | 拆分线束,加共模扼流圈 |
| 低优先级帧延迟 | 总线负载率高 | canbusload看占用率,调帧率 |
这张表不是我编的,是翻了四五个项目的调试记录后总结的真实规律。里面每一项都有人跳进去过,包括我自己。
7. 导航工况下的CAN通信优化:控制频率、心跳机制与平滑处理
最后聊一个容易被忽略但极其重要的环节:当导航算法真正闭环跑起来之后,CAN通信就不再是"有没有帧"的问题,而是"帧来得及不及时、稳不稳定"的问题。
第一,控制频率的选择要匹配CAN帧周期。ROS里cmd_vel默认频率是10Hz或20Hz,但底盘电机闭环控制通常需要更细粒度的速度变化。我见过一种配置:导航算法以20Hz下发目标速度,底盘驱动板内部的PID以200Hz运行,两者之间通过CAN帧同步数据。如果CAN帧周期和PID周期不匹配,比如PID周期是5ms,CAN帧到达间隔是50ms,那PID在两次帧之间会用旧目标值持续控制,速度波动明显。我的做法是:驱动板内部把速度指令做成一个"目标值+斜坡限制器",当新的CAN帧到来时,斜坡限制器逐渐逼近目标,而不是直接跳变。这样即使CAN延迟抖动,电机速度变化也不会突然蹿跳。
第二,心跳机制是保证安全冗余的关键。CAN没有像以太网那样的连接状态概念,节点掉了总线都不知道。所以必须约定一个心跳帧:底盘驱动板每50ms发一帧带递增序列号的0x000心跳帧,上位机持续刷新计数值。如果上位机连续500ms没收到心跳,就认为底盘板失联,立即执行安全停车指令。反过来,底盘板也要监控上位机心跳,2s没收到新指令就自动进入刹车状态。这个双向看门狗机制我见过太多小车没做,结果导航节点崩溃后小车原地撒野跑没电的。
static void HeartbeatCheckTask(void *arg) { uint32_t last_recv_cnt = 0; while (1) { uint32_t now_cnt = heartbeat_rx_cnt; if (now_cnt == last_recv_cnt) { if (++lost_cnt > 10) { // 连续10个50ms周期无更新 MotorEmergencyStop(); // 停电刹车 lost_cnt = 0; } } else { last_recv_cnt = now_cnt; lost_cnt = 0; } HAL_Delay(50); } }第三,帧内容设计上要尽量精简化。编码器反馈一帧8个字节,可以写成左右轮紧跟的4字节里程值。但你如果要把角速度、速度、电流都塞进去,8个字节不够就容易拆帧。拆帧的坏处是接收端必须攒齐两帧才能还原,中途任一帧丢了数据就不完整。我的建议是:宁愿多开几个ID,比如0x201传里程,0x202传速度和电流,也绝不一个ID传两帧,因为分片会让解析逻辑复杂且脆弱。
第四,CAN帧里的数据要在合理范围内做限幅。速度指令限幅主要保护电机电路和机械结构,但如果驱动板对超出范围的数据没有钳位逻辑,一个异常帧就能把小车推向全速前进。所以在上位机发送之前,驱动板接收之后,都要做一次限幅钳位。我一般在每块板子的can_rx回调里直接调用一个clamp函数,把线速度限制到±3.0m/s,角速度限制到±2.0rad/s,超出全部截断。这个成本几乎为零,但极大减少了因通信异常导致的"飞车"事故。
我在实际运行中最满意的一套参数是:CAN波特率500kbps,速度指令10Hz~20Hz,编码器反馈50Hz,心跳50Hz,急停硬件独立回路,里程计用编码器积分但每次启动时用雷达/IMU做一次校准。整套系统跑下来,总线负载率大概在15%上下,余量很大,即使在急停高频插入的时候也不会明显延迟。
写在最后的小经验
CAN通信这份钱没法省。很多做自主导航的初学者在Gazebo里跑得飞起,以为真车上电就能复现,结果第一个晚上就趴在CAN线缆旁边用示波器查幽灵帧。我的经验是:仿真阶段就把communication layer抽象清楚,真车阶段从一开始就严格按照通信矩阵、波特率、终端电阻、布线规则去做,大部分连锁问题都可以避免。
另一个小技巧是,代码里务必保留一档"调试模式",让底盘驱动板可以实时打印收发帧的统计信息(发送成功/失败数、接收中断次数、FIFO溢出次数),这些数字是定位通信问题的重要突破口。别等联调时再回头加日志,那时候往往已经晚了。
如果你正在折腾ROS小车自主导航仿真,或者已经把导航算法搬上了真车,欢迎拿这篇文章当你的CAN通信自查清单。真车不比仿真,它是由无数物理颗粒构成的,而CAN总线的每一帧,都是你和物理世界之间最直接的一次对话。把这条对话通道搞扎实,自主导航这辆车,才真正算立住了。