犀牛派 A1 的 CAN0/CAN1 到底哪个是真的?一次错位排查与 ROS 2 自动探测实战

2501_94385906 2026-09-21 21:49:42

本文记录一次犀牛派 A1 上位机 CAN 口排查过程,并给出一个 ROS 2 下的被动自动探测节点。文章中的截图是现场记录,不是程序指令;上传到 CSDN 时,请把 images/ 目录中的图片一并上传到图床。

1. 问题背景 数据链路卡在 CAN 口

这次的目标很明确:让上位机接收底盘四个 M3508 电机(C620 电调)的反馈,以及达妙 IMU 的数据;在犀牛派 A1 内部完成电机和 IMU 数据融合;最后通过 USB 虚拟串口,把底盘速度和绕 Z 轴角速度发送给下位机。

听起来是一个循序渐进的链路:

M3508/C620 + 达妙 IMU
          ↓ CAN
       犀牛派 A1
          ↓ USB 虚拟串口
         下位机

真正测试时,问题却出现在最前面:犀牛派 A1 的 CAN 口收不到 C620 发来的报文。

电控组先用犀牛派 A1 自发自收,能够看到报文;再把 M3508 电机和 C620 接上,却收不到反馈。换成 RM A 板做对照,结果仍然一样。反过来,3508 和 RM A 板之间的通信是正常的。接线、设备和测试方式都换过一遍,现象依旧。

2. 现场现象 can0 和 can1 像是“对调”了

先看开发板上的接口丝印:

图 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”。

3. 先排除两个容易误判的问题

 

3.1 自发自收不等于外部总线已打通

SocketCAN 默认可能开启软件回环。执行 cansend can0 ... 后,本机的 candump can0 能看到帧,并不能证明报文已经经过 CAN 收发器到达外部总线。

真正的跨设备验证至少需要满足下面一种情况:

  • 另一个 CAN 节点能看到本机发出的帧;
  • 本机关闭自回环后,仍然能收到外部节点发来的帧;
  • 断电测量和示波器检查都能证明物理层正常。

3.2 can0、can1 是 Linux 逻辑名

can0 和 can1 是 Linux 注册出来的网络接口名称,不是 PCB 丝印的强约束。它们可能受到设备树、驱动探测顺序和 udev 规则影响。换系统镜像、换 BSP 或换内核后,逻辑名也可能改变。

所以排查时应同时记录三件事:

项目

要确认的内容

物理接口

板上哪个连接器、哪组 CAN_H/CAN_L

Linux 名称

系统中对应的是 `can0` 还是 `can1`

总线状态

波特率、错误计数、是否 bus-off、是否有 RX 报文

 

4. 用命令行复现并确认映射

先确认接口是否存在,并查看控制器状态:

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。

5. 工程化方案 不要把 can0 写死

既然实测发现物理接口和逻辑名称可能对调,底盘节点就不应把 can0 写死。更稳妥的流程是:

同时打开 can0、can1
        ↓
被动监听一段时间
        ↓
统计符合协议的有效帧
        ↓
选择有效帧最多、最近仍有报文的一路
        ↓
只从选中的接口发布数据
        ↓
连续一段时间没有有效帧时重新探测

这里有一个关键原则:自动探测默认采用被动监听,不向 C620 发送“试探帧”。 如果总线上没有任何报文,程序不能凭空判断哪个接口正确,应报告“没有有效报文”,而不是向电机发送未知控制命令。

C620 的反馈 ID 常见为 0x201~0x204,但最终仍应以电控组使用的协议为准。达妙 IMU 的 CAN ID 也可能因型号和配置不同而变化,因此代码把有效 ID 做成 ROS 2 参数。

6. ROS 2 Python 自动探测节点

6.1 安装依赖

下面的示例使用 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

6.2 节点代码

#!/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()

代码做了三件事:

  1. 同时打开 can0 和 can1,并关闭本机自发自收,避免把软件回环当作真实总线报文。
  2. 在探测窗口内统计 expected_ids 中的有效帧,默认使用 C620 常见的 0x201~0x204。
  3. 选中后只发布该接口的报文;如果连续 lost_timeout_sec 没有有效帧,则回到探测状态。

如果已知达妙 IMU 的 ID,把它们一起加入 expected_ids。例如 IMU 使用 0x301、0x302 时:

-p expected_ids:="[513,514,515,516,769,770]"

这里使用十进制是因为 ROS 2 参数命令行对数组的解析更稳定:0x201 对应十进制 513,0x301 对应十进制 769。

6.3 配置入口并运行

编辑包中的 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。

7. 使用这段程序时的边界

这段节点只负责“监听、选择和发布”,不负责修改 CAN 波特率,也不负责向电机发送控制命令。建议在启动 ROS 2 前由脚本或 systemd 完成 ip link 配置;这样可以避免在 Python 节点里调用需要 root 权限的 sudo。

如果 can0 和 can1 同时接到了同一条物理总线,两路都收到相同报文是正常的。程序会按有效帧数量选择接口;数量相同则按 interfaces 参数的顺序优先。后续如果加入底盘控制发送逻辑,应只允许选中的接口发送,不能让两路同时发送同一条电机命令。

如果总线完全静默,任何被动探测程序都无法判断哪一路正确。此时应先让 C620 或 IMU 产生周期反馈,再启动节点,或者直接通过参数手动指定接口。

8. 结论

这次问题表面上是“CAN 收不到”,最后暴露的是三个名称没有建立映射:板卡丝印、Linux 逻辑接口和实际收发器通道。自发自收只能证明本机控制器能工作,不能证明外部物理通道正确;candump 看到报文后,还要继续核对 ID、DLC、帧率和数据内容。

在 ROS 2 节点中把 CAN 接口写死为 can0,一旦换板、换 BSP 或遇到丝印错误,系统就会再次卡住。启动时同时监听候选接口、按协议帧自动选择,并在断流后重新探测,可以把这类硬件映射问题变成可观察、可恢复的软件行为。

 

 

 

...全文
133 回复 打赏 收藏 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

7,720

社区成员

发帖
与我相关
我的任务
社区描述
本论坛以AI、IoT、PC 、XR、Auto等核心板块组成,为开发者提供便捷及高效的学习和交流平台。 高通开发者专区主页:https://qualcomm.csdn.net/
物联网人工智能开源 企业社区 北京·东城区
社区管理员
  • csdnsqst0050
  • chipseeker
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧