1. 项目概述与核心价值最近在做一个移动机器人项目底盘控制器用的是带CAN接口的嵌入式板卡而上位机选型时我们直接上了Nvidia Jetson AGX Orin。原因很简单Orin的算力足够跑复杂的SLAM和视觉感知模型但随之而来就是一个很实际的问题怎么让这台“大脑”和下面的“手脚”底盘说上话答案就是CAN总线。这不是一个简单的串口通信涉及到硬件连接、驱动适配、协议解析还要无缝接入ROS 2生态。网上关于Jetson上CAN的资料比较零散尤其是结合ROS 2 C节点开发的完整流程。我把自己从硬件接线到软件调试跑通的整个过程梳理出来包括踩过的坑和优化技巧希望能给遇到同样问题的朋友一个清晰的参考。简单来说这个项目就是在Jetson AGX Orin上利用其自带的CAN控制器编写一个ROS 2 C节点实现与机器人底盘控制器之间稳定、实时的CAN通信。它解决了高性能计算平台与底层执行机构之间的可靠数据交换问题是自动驾驶小车、移动机器人、特种车辆等项目的关键一环。无论你是机器人方向的工程师、自动驾驶领域的研究者还是正在做相关课程项目的学生只要你的项目涉及Jetson与CAN总线这篇内容都能帮你省下大量摸索的时间。2. 硬件准备与系统环境搭建2.1 硬件连接与引脚定义Jetson AGX Orin的40针扩展接头也叫Jetson GPIO Header上集成了CAN控制器具体来说是CAN0和CAN1两个通道。我们项目里用的是CAN0。接线前务必先关机断电。引脚对应关系如下以Jetson AGX Orin Developer Kit为例CAN0_D (TX):引脚29CAN0_D (RX):引脚31GND:引脚30或任意其他接地引脚如引脚6, 9, 14, 20, 25, 34, 39注意CAN总线是差分信号需要连接CAN_H和CAN_L。但Jetson的GPIO引脚直接输出的是CAN控制器的TX和RX可以理解为单端信号。因此你不能直接把Jetson的引脚接到另一个CAN设备的CAN_H/CAN_L上。中间必须加一个CAN收发器模块如常见的TJA1050或SN65HVD230由收发器来完成单端信号与差分信号的转换。标准连接方案Jetson引脚29 (CAN0_TX) - CAN收发器模块的TXD引脚。Jetson引脚31 (CAN0_RX) - CAN收发器模块的RXD引脚。Jetson引脚30 (GND) - CAN收发器模块的GND引脚。CAN收发器模块的CAN_H引脚 - 底盘控制器CAN接口的CAN_H。CAN收发器模块的CAN_L引脚 - 底盘控制器CAN接口的CAN_L。必须在CAN_H和CAN_L之间并联一个120欧姆的终端电阻且整条总线两端各一个。如果你的底盘控制器内部已经集成了终端电阻那么Jetson这边的收发器模块上可能就不需要再接了具体要看总线测量电阻值是否为60欧姆左右。实操心得强烈建议使用带隔离功能的CAN收发器模块。移动机器人环境复杂电机驱动器等产生的噪声很容易通过地线耦合进通信系统导致CAN通信误码甚至控制器重启。隔离模块能有效避免共地噪声问题。我们一开始用的非隔离模块在电机大电流启停时偶尔会出现报文错误换成隔离模块后问题消失。2.2 系统与ROS 2环境配置我们项目使用的是JetPack 5.1.2对应Ubuntu 20.04和ROS 2 Humble。以下步骤也适用于其他JetPack 5.x版本和对应的ROS 2发行版。1. 启用CAN内核模块Jetson Linux已经包含了CAN驱动但默认可能没有加载。首先检查并加载can和can_raw内核模块。sudo modprobe can sudo modprobe can_raw为了让系统启动时自动加载可以将它们加入/etc/modules文件。echo -e can\ncan_raw | sudo tee -a /etc/modules2. 安装CAN工具套件我们需要can-utils这个工具包来配置、测试和监控CAN总线。sudo apt update sudo apt install can-utils net-toolscan-utils提供了candump,cansend,canplayer,canbusload等非常实用的命令行工具。3. 配置CAN接口使用ip命令来配置CAN接口。这里我们配置CAN0设置比特率为500k这是工业与车载领域非常常见的速率请根据你的底盘协议手册修改。sudo ip link set can0 down # 先关闭接口 sudo ip link set can0 type can bitrate 500000 # 设置比特率 sudo ip link set can0 up # 启动接口配置完成后用ifconfig或ip link show can0检查应该能看到can0接口状态为UP。4. 设置开机自启动可选但推荐创建systemd服务或写入rc.local。这里介绍一个简单方法在/etc/network/interfaces.d/下创建配置文件。sudo vim /etc/network/interfaces.d/can0文件内容auto can0 iface can0 inet manual pre-up /sbin/ip link set can0 type can bitrate 500000 up /sbin/ip link set can0 up down /sbin/ip link set can0 down这样系统网络服务启动时就会自动配置CAN0。5. ROS 2工作空间创建与依赖安装创建一个ROS 2工作空间并安装可能需要的依赖。source /opt/ros/humble/setup.bash mkdir -p ~/ros2_can_ws/src cd ~/ros2_can_ws/src我们的驱动节点主要依赖rclcpp和std_msgs等基础包通常不需要额外安装。但如果你计划使用geometry_msgs来发布速度指令可以确保已安装。sudo apt install ros-humble-geometry-msgs3. CAN通信原理与ROS 2驱动设计思路3.1 CAN协议核心概念与数据帧解析在写代码之前必须理解你将要处理的数据。CAN通信的基本单位是“帧”。我们主要和数据帧打交道。一个标准数据帧Standard Frame包含以下关键部分仲裁场Arbitration Field: 包含11位的标识符CAN ID。它决定了报文的优先级ID值越小优先级越高也用于标识报文的含义例如0x101代表速度指令0x201代表电池状态。控制场Control Field: 包含一个4位的数据长度码DLC表示后面数据场有多少个字节0-8字节。数据场Data Field: 实际传输的数据最多8个字节。这是我们需要解析或组装的“有效载荷”。CRC场、ACK场等: 用于错误校验和确认通常由硬件处理我们编程时不太关心。对我们编程最重要的就是CAN ID 和 数据场Data Field。与底盘通信的协议本质上就是定义了一系列的CAN ID及其对应的8字节数据的解析规则。例如底盘可能规定发送给底盘的速度指令帧ID0x100数据场前4字节为左轮速度int32后4字节为右轮速度int32。接收到底盘的里程计反馈帧ID0x200数据场前4字节为x方向位移float后4字节为y方向位移float。实操心得一定要拿到底盘厂商提供的《CAN通信协议手册》。没有它你看到的只是一堆十六进制数字毫无意义。协议手册会明确每个ID的定义、数据格式字节序是大端还是小端、物理量转换公式比如原始值*0.001 实际速度。3.2 ROS 2节点软件架构设计我们的驱动节点核心任务是在ROS 2世界和CAN总线世界之间架起桥梁。架构设计上采用典型的发布-订阅模型并包含一个主循环用于读写CAN总线。核心组件CAN接口读写器: 负责用SocketCAN接口打开can0并以非阻塞或阻塞方式读取/写入原始CAN帧。协议解析器反序列化: 将接收到的原始CAN帧ID8字节数据根据协议手册解析成有物理意义的ROS消息如nav_msgs::Odometry。协议封装器序列化: 将ROS消息如geometry_msgs::Twist速度指令按照协议手册封装成原始的CAN帧。ROS 2接口:订阅者Subscriber: 订阅来自导航、遥控等节点的速度指令话题如/cmd_vel。发布者Publisher: 发布解析后的底盘状态话题如里程计/odom、电池状态/battery等。主循环Timer或Spin: 以固定频率如100Hz运行不断检查CAN接口是否有新数据到达并及时读取处理同时可能还需要定时发送一些心跳或查询帧到底盘。设计考量为什么选择C而不是Python虽然Python有python-can库更易上手但在Jetson Orin这种资源虽强但也要处理多传感器融合的场景下C能提供更高的实时性和确定性对内存和CPU的控制也更精细更适合作为底层稳定通信的驱动。此外ROS 2的rclcpp在C中的性能表现也通常优于rclpy。4. ROS 2 C驱动节点核心实现4.1 创建功能包与节点框架首先在工作空间的src目录下创建功能包。cd ~/ros2_can_ws/src ros2 pkg create jetson_can_driver --build-type ament_cmake --dependencies rclcpp std_msgs geometry_msgs nav_msgs sensor_msgs--dependencies指定了依赖rclcppC客户端库、std_msgs标准消息、geometry_msgs速度指令Twist、nav_msgs里程计Odometry、sensor_msgs未来可能发布电池状态等。进入功能包创建主要的源文件。cd jetson_can_driver/src touch jetson_can_driver.cpp4.2 CAN底层通信类封装我们首先封装一个CanBus类负责与SocketCAN的底层交互。这提高了代码的模块化和可重用性。jetson_can_driver.cpp头部和CanBus类#include linux/can.h #include linux/can/raw.h #include net/if.h #include sys/ioctl.h #include sys/socket.h #include unistd.h #include cstring #include string #include iostream #include rclcpp/rclcpp.hpp #include geometry_msgs/msg/twist.hpp #include nav_msgs/msg/odometry.hpp class CanBus { public: CanBus(const std::string interface can0) : interface_(interface), socket_fd_(-1) {} bool connect() { socket_fd_ socket(PF_CAN, SOCK_RAW, CAN_RAW); if (socket_fd_ 0) { std::cerr Failed to create CAN socket. std::endl; return false; } struct ifreq ifr; std::strcpy(ifr.ifr_name, interface_.c_str()); if (ioctl(socket_fd_, SIOCGIFINDEX, ifr) 0) { std::cerr Failed to get interface index for interface_ std::endl; close(socket_fd_); return false; } struct sockaddr_can addr; addr.can_family AF_CAN; addr.can_ifindex ifr.ifr_ifindex; if (bind(socket_fd_, (struct sockaddr*)addr, sizeof(addr)) 0) { std::cerr Failed to bind to CAN interface interface_ std::endl; close(socket_fd_); return false; } // 设置非阻塞模式避免read卡住整个ROS节点 int flags fcntl(socket_fd_, F_GETFL, 0); fcntl(socket_fd_, F_SETFL, flags | O_NONBLOCK); std::cout Successfully connected to interface_ std::endl; return true; } bool sendFrame(const struct can_frame frame) { if (socket_fd_ 0) return false; int nbytes write(socket_fd_, frame, sizeof(frame)); if (nbytes ! sizeof(frame)) { std::cerr CAN send error. std::endl; return false; } return true; } bool receiveFrame(struct can_frame frame) { if (socket_fd_ 0) return false; int nbytes read(socket_fd_, frame, sizeof(frame)); if (nbytes 0) { // 非阻塞模式下没有数据是正常情况 return false; } if (nbytes ! sizeof(frame)) { std::cerr Incomplete CAN frame read. std::endl; return false; } return true; } ~CanBus() { if (socket_fd_ 0) { close(socket_fd_); } } private: std::string interface_; int socket_fd_; };这个类封装了SocketCAN的创建、绑定、发送和接收。关键点使用SOCK_RAW和CAN_RAW协议族。ioctl获取网络接口索引。bind将socket绑定到特定的CAN接口。设置了非阻塞模式这非常重要。ROS节点的spin或定时器回调不能被一个阻塞的read调用卡住否则会影响其他回调函数的执行破坏ROS的实时性。4.3 ROS 2节点主类与协议实现接下来创建ROS 2节点主类JetsonCanDriverNode它继承自rclcpp::Node并集成CanBus。1. 节点类定义与成员变量class JetsonCanDriverNode : public rclcpp::Node { public: JetsonCanDriverNode() : Node(jetson_can_driver) { // 1. 初始化CAN总线 can_bus_ std::make_uniqueCanBus(can0); if (!can_bus_-connect()) { RCLCPP_ERROR(this-get_logger(), Could not connect to CAN bus. Exiting.); rclcpp::shutdown(); return; } // 2. 创建ROS 2发布者和订阅者 // 订阅速度指令 cmd_vel_sub_ this-create_subscriptiongeometry_msgs::msg::Twist( /cmd_vel, 10, std::bind(JetsonCanDriverNode::cmdVelCallback, this, std::placeholders::_1)); // 发布里程计 odom_pub_ this-create_publishernav_msgs::msg::Odometry(/odom, 10); // 3. 创建定时器用于定期读取CAN总线并发布数据 // 100Hz即10ms周期 timer_ this-create_wall_timer( std::chrono::milliseconds(10), std::bind(JetsonCanDriverNode::timerCallback, this)); RCLCPP_INFO(this-get_logger(), Jetson CAN Driver Node has started.); } private: std::unique_ptrCanBus can_bus_; rclcpp::Subscriptiongeometry_msgs::msg::Twist::SharedPtr cmd_vel_sub_; rclcpp::Publishernav_msgs::msg::Odometry::SharedPtr odom_pub_; rclcpp::TimerBase::SharedPtr timer_; // 协议相关的变量例如轮距、轮胎半径等转换参数 double wheel_base_ 0.5; // 示例值单位米 double wheel_radius_ 0.1; // 示例值单位米 // 可以添加更多状态变量如上一次的编码器值 };2. 速度指令回调函数序列化当导航栈或其他节点发布/cmd_vel话题时此函数被触发。我们需要将ROS的线速度和角速度转换为左右轮速再按照底盘协议封装成CAN帧。void cmdVelCallback(const geometry_msgs::msg::Twist::SharedPtr msg) { // 1. 速度转换差分底盘模型 // 线速度 v (v_left v_right) / 2 // 角速度 w (v_right - v_left) / wheel_base // 推导出 double v msg-linear.x; double w msg-angular.z; double left_wheel_speed v - (w * wheel_base_ / 2.0); double right_wheel_speed v (w * wheel_base_ / 2.0); // 2. 转换为底盘控制器期望的单位和精度例如转/分 RPM // 假设我们协议要求发送的是 int16_t 类型的RPM值 int16_t left_rpm static_castint16_t(left_wheel_speed * 60.0 / (2 * M_PI * wheel_radius_)); int16_t right_rpm static_castint16_t(right_wheel_speed * 60.0 / (2 * M_PI * wheel_radius_)); // 3. 封装CAN帧假设协议ID为0x100数据为两个int16 struct can_frame frame; frame.can_id 0x100; // 目标CAN ID frame.can_dlc 4; // 2个int16 4字节 std::memcpy(frame.data[0], left_rpm, 2); std::memcpy(frame.data[2], right_rpm, 2); // 4. 发送 if (!can_bus_-sendFrame(frame)) { RCLCPP_WARN(this-get_logger(), Failed to send speed command via CAN.); } else { RCLCPP_DEBUG(this-get_logger(), Sent CAN frame ID: 0x%03X, Data: ..., frame.can_id); } }注意这里的转换公式和协议ID0x100是示例你必须根据自己底盘的实际运动模型和通信协议手册进行修改。协议可能是直接发送PWM值、速度百分比或者使用更复杂的多帧传输。3. 定时器回调函数反序列化与发布这个函数以固定频率运行检查CAN总线上是否有来自底盘的新数据并进行解析。void timerCallback() { struct can_frame frame; while (can_bus_-receiveFrame(frame)) { // 使用while循环清空接收缓冲区 // 根据CAN ID进行分发处理 switch (frame.can_id) { case 0x200: { // 假设0x200是里程计反馈帧 processOdomFrame(frame); break; } case 0x201: { // 假设0x201是电池状态帧 processBatteryFrame(frame); break; } // ... 处理其他ID default: RCLCPP_DEBUG(this-get_logger(), Received unhandled CAN frame ID: 0x%03X, frame.can_id); break; } } } void processOdomFrame(const struct can_frame frame) { // 1. 解析数据示例前4字节为左轮累计脉冲数后4字节为右轮累计脉冲数int32类型 int32_t left_ticks, right_ticks; // 注意字节序Jetson是小端你的协议可能是大端。这里假设协议数据也是小端。 std::memcpy(left_ticks, frame.data[0], 4); std::memcpy(right_ticks, frame.data[4], 4); // 2. 计算位移和航向需要上一次的脉冲数进行差分 static int32_t last_left_ticks 0, last_right_ticks 0; double ticks_to_meter 0.001; // 每个脉冲对应的米数根据编码器分辨率计算 double delta_left (left_ticks - last_left_ticks) * ticks_to_meter; double delta_right (right_ticks - last_right_ticks) * ticks_to_meter; last_left_ticks left_ticks; last_right_ticks right_ticks; double delta_distance (delta_left delta_right) / 2.0; double delta_theta (delta_right - delta_left) / wheel_base_; // 3. 更新机器人位姿简单积分实际应用需考虑时间戳和更精确的模型 static double x 0.0, y 0.0, theta 0.0; theta delta_theta; x delta_distance * std::cos(theta); y delta_distance * std::sin(theta); // 4. 构造并发布ROS Odometry消息 auto odom_msg std::make_uniquenav_msgs::msg::Odometry(); odom_msg-header.stamp this-now(); odom_msg-header.frame_id odom; odom_msg-child_frame_id base_link; odom_msg-pose.pose.position.x x; odom_msg-pose.pose.position.y y; // 将theta转换为四元数 tf2::Quaternion q; q.setRPY(0, 0, theta); odom_msg-pose.pose.orientation.x q.x(); odom_msg-pose.pose.orientation.y q.y(); odom_msg-pose.pose.orientation.z q.z(); odom_msg-pose.pose.orientation.w q.w(); // 填充速度信息可选可以从当前脉冲差计算瞬时速度 odom_msg-twist.twist.linear.x delta_distance / 0.01; // 假设定时器周期10dt为10ms odom_msg-twist.twist.angular.z delta_theta / 0.01; odom_pub_-publish(std::move(odom_msg)); }字节序问题详解这是CAN通信编程中最常见的坑之一。Jetsonx86_64或ARM通常是小端字节序Little-Endian即低地址存放低位字节。你的底盘控制器可能是大端如某些PowerPC架构或小端。如果协议手册规定数据是大端那么你不能直接用memcpy。你需要进行字节序转换。例如对于一个在大端帧中的int32_t值你需要这样解析int32_t big_endian_value; std::memcpy(big_endian_value, frame.data[0], 4); // 转换为小端主机字节序 int32_t host_value __builtin_bswap32(big_endian_value); // GCC/Clang内置函数 // 或者使用标准库函数需要包含arpa/inet.h // int32_t host_value ntohl(big_endian_value); // ntohl将网络字节序大端转为主机序务必在协议解析部分仔细处理4.4 编译与运行1. 修改CMakeLists.txt:打开~/ros2_can_ws/src/jetson_can_driver/CMakeLists.txt在find_package部分确保依赖正确并添加可执行文件。find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) find_package(std_msgs REQUIRED) find_package(geometry_msgs REQUIRED) find_package(nav_msgs REQUIRED) find_package(sensor_msgs REQUIRED) add_executable(jetson_can_driver src/jetson_can_driver.cpp) ament_target_dependencies(jetson_can_driver rclcpp std_msgs geometry_msgs nav_msgs sensor_msgs ) install(TARGETS jetson_can_driver DESTINATION lib/${PROJECT_NAME})2. 编译cd ~/ros2_can_ws colcon build --packages-select jetson_can_driver source install/setup.bash3. 运行节点首先确保CAN接口can0已经按照第2.2节配置好并UP。# 第一个终端运行驱动节点 ros2 run jetson_can_driver jetson_can_driver如果一切正常你应该能看到节点启动的日志信息。4. 测试发送与接收测试发送在新的终端发布一个速度指令。source /opt/ros/humble/setup.bash ros2 topic pub /cmd_vel geometry_msgs/msg/Twist {linear: {x: 0.2}, angular: {z: 0.1}} -1此时用candump工具监听can0应该能看到ID为0x100根据你的代码的CAN帧被发送出去。candump can0测试接收如果你的底盘控制器正在发送数据例如上电后周期性发送里程计你的节点应该会自动解析并发布/odom话题。可以用ros2 topic echo /odom来查看。5. 调试技巧、常见问题与性能优化5.1 调试工具与技巧candump是你的眼睛这是最关键的调试工具。运行candump can0可以查看总线上所有的原始CAN帧。结合协议手册你可以验证发送的帧是否正确以及是否收到了期望的帧。cansend手动发送用于模拟发送特定CAN帧测试底盘响应。例如cansend can0 100#11223344发送一个ID为0x100数据为0x11,0x22,0x33,0x44的帧。canbusload监控负载canbusload can0 500000可以估算当前CAN总线的负载率对于评估通信压力很有帮助。ifconfig can0查看状态查看接口是否UP以及RX/TX包计数和错误计数。如果errors或dropped持续增长说明通信有问题。Wireshark抓包在Linux上Wireshark可以直接抓取SocketCAN接口的包提供更强大的过滤和分析功能。ROS 2工具链ros2 topic listros2 topic echoros2 topic hzrqt_graph等用于调试ROS节点间的数据流。5.2 常见问题与解决方案问题1CAN接口无法打开或绑定失败connect()返回false。可能原因1CAN内核模块未加载。解决执行sudo modprobe can can_raw。可能原因2接口名错误或不存在。解决用ip link show检查是否有can0。可能是can1或其他。可能原因3权限不足。解决使用sudo运行节点或者将当前用户加入dialout组sudo usermod -a -G dialout $USER然后注销重新登录。问题2能发送但接收不到任何数据。可能原因1硬件连接问题特别是CAN_H和CAN_L接反或终端电阻缺失。解决用万用表测量CAN_H和CAN_L之间的电阻在总线两端都接入终端电阻时应为60欧姆左右。检查接线。可能原因2比特率不匹配。这是最常见的原因解决确保Jetson上ip link set can0 type can bitrate xxx设置的比特率与底盘控制器完全一致如125k, 250k, 500k, 1M。可能原因3协议ID过滤。解决用candump can0看是否能收到任何数据。如果candump能收到而你的程序收不到可能是你的程序逻辑有问题比如receiveFrame只在特定条件下调用。确保定时器回调或读取循环在工作。问题3接收到的数据解析出来是乱码或极大/极小的值。可能原因1字节序错误。解决这是极高发问题仔细核对协议手册的字节序说明并在代码中做相应的转换使用__builtin_bswapXX或ntoh系列函数。可能原因2数据格式理解错误。解决确认数据长度DLC和每个字段的数据类型int8_t,uint16_t,int32_t,float等。float类型在内存中的表示需要特别注意。可能原因3符号位处理错误。解决确认协议中的数据是带符号还是无符号整数。问题4通信不稳定偶尔丢帧或出现错误帧。可能原因1总线负载过高。解决用canbusload检查。优化通信频率减少不必要的数据发送。提高比特率如从125k提升到500k可以增加带宽。可能原因2电气干扰。解决检查布线远离电机、电源等强干扰源。使用带屏蔽的CAN线并确保屏蔽层单点接地。使用隔离型CAN收发器模块。可能原因3软件读取不及时缓冲区溢出。解决确保你的读取循环timerCallback中的while频率足够高能及时清空SocketCAN的接收缓冲区。可以适当提高定时器频率。5.3 性能优化与进阶建议使用多线程如果协议解析非常耗时或者有多个CAN通道需要处理可以考虑使用ROS 2的MultiThreadedExecutor将CAN读取、协议解析、ROS发布放在不同的线程中避免一个环节阻塞整体。降低延迟对于高实时性要求可以将定时器回调改为自旋循环并在循环中直接调用receiveFrame避免定时器本身的调度抖动。但要注意控制CPU占用率。错误恢复机制增加对CAN总线错误如总线关闭的检测。可以通过ioctl(socket_fd_, SIOCGIFSTAT, ...)获取接口统计信息并在检测到严重错误时尝试重新初始化CAN接口。协议抽象层将协议解析部分抽象成独立的类或库便于维护和移植。可以为不同的底盘协议实现不同的解析器插件。与robot_localization等包融合发布的/odom话题可以作为robot_localization扩展卡尔曼滤波器的输入之一与IMU、GPS等进行融合得到更精确的机器人位姿估计。使用lifecycle_node对于需要严格状态管理的系统可以考虑将驱动节点实现为ROS 2的生命周期节点便于系统的启动、关闭和错误处理。整个项目从硬件连接到软件调试最耗时的部分往往是协议对接和排错。耐心分析candump的输出对照协议手册逐位解析是解决问题的唯一捷径。当看到/odom话题稳定输出机器人能精准响应/cmd_vel指令时你会觉得这一切都是值得的。这个驱动节点将成为你机器人系统稳定、可靠的“神经末梢”。