7,720
社区成员
发帖
与我相关
我的任务
分享本文记录一次犀牛派 A1 上位机 CAN 口排查过程,并给出一个 ROS 2 下的被动自动探测节点。文章中的截图是现场记录,不是程序指令;上传到 CSDN 时,请把 images/ 目录中的图片一并上传到图床。
这次的目标很明确:让上位机接收底盘四个 M3508 电机(C620 电调)的反馈,以及达妙 IMU 的数据;在犀牛派 A1 内部完成电机和 IMU 数据融合;最后通过 USB 虚拟串口,把底盘速度和绕 Z 轴角速度发送给下位机。
听起来是一个循序渐进的链路:
M3508/C620 + 达妙 IMU
↓ CAN
犀牛派 A1
↓ USB 虚拟串口
下位机
真正测试时,问题却出现在最前面:犀牛派 A1 的 CAN 口收不到 C620 发来的报文。
电控组先用犀牛派 A1 自发自收,能够看到报文;再把 M3508 电机和 C620 接上,却收不到反馈。换成 RM A 板做对照,结果仍然一样。反过来,3508 和 RM A 板之间的通信是正常的。接线、设备和测试方式都换过一遍,现象依旧。
先看开发板上的接口丝印:

图 1 犀牛派 A1 板卡上的 CAN0/CAN1 接口标注。
我们按照板上的 CAN0、CAN1 标注分别接线,并在 Linux 中配置 1 Mbit/s。一次抓包记录如下:
sudo ip link set can1 down
sudo ip link set can1 type can bitrate 1000000 restart-ms 100
sudo ip link set can1 up
candump can1

图 2 can1 上抓到的测试报文。截图中的 ID 为 `0x200`,它是现场发送的测试/控制帧,不应直接当作 C620 的电机反馈帧解析。
实物测试环境如下:

图 3 电机、RM A 板和犀牛派 A1 的实物连接。
真正有意思的地方在交叉验证:原来把线接在板上标注的 CAN1,Linux 中监听 can1 收不到;同时监听 can0 却收到了。随后把接线改到板上标注的 CAN0,再监听 can1,反而收到了;监听 can0 又收不到。

图 4 交叉使用 can0、can1 发送和监听后的现场记录。
这个现象说明:Linux 逻辑接口名与板卡上的物理接口标注,很可能发生了错位。 可能的原因包括 PCB 丝印标错、设备树 alias 配反、驱动注册顺序与厂商文档不一致,或者连接器实际走线与标注不一致。仅凭截图不能区分这几种原因,但它足以说明不能再把“板上写着 CAN1”直接等同于“Linux 里的 can1”。
SocketCAN 默认可能开启软件回环。执行 cansend can0 ... 后,本机的 candump can0 能看到帧,并不能证明报文已经经过 CAN 收发器到达外部总线。
真正的跨设备验证至少需要满足下面一种情况:
can0 和 can1 是 Linux 注册出来的网络接口名称,不是 PCB 丝印的强约束。它们可能受到设备树、驱动探测顺序和 udev 规则影响。换系统镜像、换 BSP 或换内核后,逻辑名也可能改变。
所以排查时应同时记录三件事:
|
项目 |
要确认的内容 |
|
物理接口 |
板上哪个连接器、哪组 CAN_H/CAN_L |
|
Linux 名称 |
系统中对应的是 `can0` 还是 `can1` |
|
总线状态 |
波特率、错误计数、是否 bus-off、是否有 RX 报文 |
先确认接口是否存在,并查看控制器状态:
ip -br link show can0 can1
ip -details link show can0
ip -details link show can1
ip -s -d link show can0
ip -s -d link show can1
配置两路接口时,示例波特率为 1 Mbit/s;实际值必须与 C620、RM A 板和 IMU 所在总线一致:
for c in can0 can1; do
sudo ip link set "$c" down
sudo ip link set "$c" type can bitrate 1000000 restart-ms 100
sudo ip link set "$c" up
done
然后分别监听:
candump -tz can0
candump -tz can1
也可以同时监听两路:
candump -tz can0 can1
如果只是做隔离台架上的通道交叉测试,可以使用一个不会被电机协议占用的测试 ID:
# 只在隔离测试总线上执行
cansend can0 123#1122334455667788
# 另一个终端监听
candump can1
不要在带电的实车 C620 总线上随意发送 `0x200`、`0x201` 等 ID。 C620 常见协议中,0x200 是电机控制帧,0x201~0x204 常用于电机反馈;误发控制帧可能让电机动作。本文截图中的 cansend 0x200 只作为现场记录,不能当作通用探测命令。
物理层也要一起确认:CAN_H 对 CAN_H、CAN_L 对 CAN_L、节点共地、总线两端各一个 120 Ω 终端电阻。断电测量时,如果总线两端终端都在,CAN_H 与 CAN_L 之间通常约为 60 Ω。支线过长、终端过多、波特率不一致,都可能造成“偶尔能收到”或直接 bus-off。
既然实测发现物理接口和逻辑名称可能对调,底盘节点就不应把 can0 写死。更稳妥的流程是:
同时打开 can0、can1
↓
被动监听一段时间
↓
统计符合协议的有效帧
↓
选择有效帧最多、最近仍有报文的一路
↓
只从选中的接口发布数据
↓
连续一段时间没有有效帧时重新探测
这里有一个关键原则:自动探测默认采用被动监听,不向 C620 发送“试探帧”。 如果总线上没有任何报文,程序不能凭空判断哪个接口正确,应报告“没有有效报文”,而不是向电机发送未知控制命令。
C620 的反馈 ID 常见为 0x201~0x204,但最终仍应以电控组使用的协议为准。达妙 IMU 的 CAN ID 也可能因型号和配置不同而变化,因此代码把有效 ID 做成 ROS 2 参数。
下面的示例使用 python-can 访问 SocketCAN,并使用 can_msgs/msg/Frame 发布原始 CAN 帧:
温馨提示:实际工程中笔者用的是C++,这里为了快速给大家展示解决办法,就用的Python,实际工程中建议用C++做。
sudo apt update
sudo apt install python3-can can-utils ros-humble-can-msgs
创建一个 Python ROS 2 包:
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python can_auto_selector \
--dependencies rclpy std_msgs can_msgs
将下面的代码保存为:
can_auto_selector/can_auto_selector/can_auto_selector_node.py
#!/usr/bin/env python3
import time
from typing import Dict, Optional
import can
import rclpy
from can_msgs.msg import Frame
from rclpy.node import Node
from std_msgs.msg import String
class CanAutoSelector(Node):
"""被动监听 can0/can1,选择能收到协议帧的接口。"""
def __init__(self) -> None:
super().__init__("can_auto_selector")
self.interfaces = list(
self.declare_parameter("interfaces", ["can0", "can1"]).value
)
self.expected_ids = {
int(value)
for value in self.declare_parameter(
"expected_ids", [0x201, 0x202, 0x203, 0x204]
).value
}
self.accept_unknown_ids = bool(
self.declare_parameter("accept_unknown_ids", False).value
)
self.detect_window_sec = float(
self.declare_parameter("detect_window_sec", 1.5).value
)
self.lost_timeout_sec = float(
self.declare_parameter("lost_timeout_sec", 2.0).value
)
self.frame_pub = self.create_publisher(Frame, "~/rx", 100)
self.active_pub = self.create_publisher(String, "~/active_interface", 10)
self.buses: Dict[str, object] = {}
self.stats: Dict[str, Dict[str, float]] = {}
for name in self.interfaces:
self.stats[name] = {"total": 0, "valid": 0, "last_valid": 0.0}
try:
# 关闭 python-can 的本机自发自收,避免把回环帧当成外部总线帧。
self.buses[name] = can.Bus(
interface="socketcan",
channel=name,
receive_own_messages=False,
)
self.get_logger().info(f"已打开 {name}")
except Exception as exc: # 接口不存在、权限不足或驱动未加载
self.get_logger().warning(f"打开 {name} 失败:{exc}")
if not self.buses:
raise RuntimeError("can0/can1 都无法打开,请先检查 SocketCAN 配置")
self.active: Optional[str] = None
self.probe_started = time.monotonic()
self.last_warning = 0.0
self.timer = self.create_timer(0.01, self.poll_buses)
def is_valid_frame(self, message: can.Message) -> bool:
if message.is_error_frame:
return False
if self.accept_unknown_ids:
return True
return int(message.arbitration_id) in self.expected_ids
def poll_buses(self) -> None:
now = time.monotonic()
for name, bus in self.buses.items():
while True:
try:
message = bus.recv(timeout=0.0)
except can.CanError as exc:
self.get_logger().error(f"读取 {name} 失败:{exc}")
break
if message is None:
break
self.stats[name]["total"] += 1
if not self.is_valid_frame(message):
continue
self.stats[name]["valid"] += 1
self.stats[name]["last_valid"] = now
# 探测阶段只统计;选中接口后才向 ROS 2 发布。
if self.active == name:
self.publish_frame(message)
self.update_active_interface(now)
def update_active_interface(self, now: float) -> None:
if self.active is None:
if now - self.probe_started < self.detect_window_sec:
return
candidates = [
name
for name in self.buses
if self.stats[name]["valid"] > 0
and now - self.stats[name]["last_valid"] <= self.lost_timeout_sec
]
if not candidates:
if now - self.last_warning > 5.0:
self.get_logger().warning(
"探测窗口内没有收到有效 CAN 帧;请检查波特率、接线、终端电阻和反馈 ID"
)
self.last_warning = now
self.probe_started = now
return
# valid 数量最多者优先;数量相同则保持 interfaces 参数中的顺序。
selected = max(candidates, key=lambda name: self.stats[name]["valid"])
self.set_active(selected, "初次探测")
return
last_valid = self.stats[self.active]["last_valid"]
if now - last_valid > self.lost_timeout_sec:
self.get_logger().warning(
f"{self.active} 已连续 {self.lost_timeout_sec:.1f} s 没有有效帧,重新探测"
)
self.active = None
self.probe_started = now
def set_active(self, name: str, reason: str) -> None:
self.active = name
message = String()
message.data = name
self.active_pub.publish(message)
stat = self.stats[name]
self.get_logger().info(
f"选择 {name}({reason},有效帧 {int(stat['valid'])},总帧 {int(stat['total'])})"
)
def publish_frame(self, message: can.Message) -> None:
frame = Frame()
frame.id = int(message.arbitration_id)
frame.dlc = min(int(message.dlc), 8)
frame.is_extended = bool(message.is_extended_id)
frame.is_rtr = bool(message.is_remote_frame)
frame.is_error = bool(message.is_error_frame)
for index in range(8):
frame.data[index] = (
int(message.data[index]) if index < len(message.data) else 0
)
self.frame_pub.publish(frame)
def destroy_node(self) -> None:
for bus in self.buses.values():
bus.shutdown()
super().destroy_node()
def main(args=None) -> None:
rclpy.init(args=args)
node = None
try:
node = CanAutoSelector()
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
if node is not None:
node.destroy_node()
rclpy.shutdown()
if __name__ == "__main__":
main()
代码做了三件事:
如果已知达妙 IMU 的 ID,把它们一起加入 expected_ids。例如 IMU 使用 0x301、0x302 时:
-p expected_ids:="[513,514,515,516,769,770]"
这里使用十进制是因为 ROS 2 参数命令行对数组的解析更稳定:0x201 对应十进制 513,0x301 对应十进制 769。
编辑包中的 setup.py,在 entry_points 里加入:
entry_points={
"console_scripts": [
"can_auto_selector_node = can_auto_selector.can_auto_selector_node:main",
],
},
然后编译运行:
cd ~/ros2_ws
colcon build --packages-select can_auto_selector
source install/setup.bash
ros2 run can_auto_selector can_auto_selector_node --ros-args \
-p interfaces:="['can0','can1']" \
-p expected_ids:="[513,514,515,516]" \
-p detect_window_sec:=1.5 \
-p lost_timeout_sec:=2.0
观察选中了哪一路:
ros2 topic echo /can_auto_selector/active_interface
ros2 topic echo /can_auto_selector/rx
如果只是排查通道、暂时不知道 IMU 的 ID,可以临时使用:
-p accept_unknown_ids:=true
但生产运行不建议长期接受任意 ID,否则总线上的其他报文也可能被当成“有效接口”的依据。确定协议后,应把电机和 IMU 的 ID 填入 expected_ids。
这段节点只负责“监听、选择和发布”,不负责修改 CAN 波特率,也不负责向电机发送控制命令。建议在启动 ROS 2 前由脚本或 systemd 完成 ip link 配置;这样可以避免在 Python 节点里调用需要 root 权限的 sudo。
如果 can0 和 can1 同时接到了同一条物理总线,两路都收到相同报文是正常的。程序会按有效帧数量选择接口;数量相同则按 interfaces 参数的顺序优先。后续如果加入底盘控制发送逻辑,应只允许选中的接口发送,不能让两路同时发送同一条电机命令。
如果总线完全静默,任何被动探测程序都无法判断哪一路正确。此时应先让 C620 或 IMU 产生周期反馈,再启动节点,或者直接通过参数手动指定接口。
这次问题表面上是“CAN 收不到”,最后暴露的是三个名称没有建立映射:板卡丝印、Linux 逻辑接口和实际收发器通道。自发自收只能证明本机控制器能工作,不能证明外部物理通道正确;candump 看到报文后,还要继续核对 ID、DLC、帧率和数据内容。
在 ROS 2 节点中把 CAN 接口写死为 can0,一旦换板、换 BSP 或遇到丝印错误,系统就会再次卡住。启动时同时监听候选接口、按协议帧自动选择,并在断流后重新探测,可以把这类硬件映射问题变成可观察、可恢复的软件行为。