本文以实际自主研发的机器人底盘项目为例,讲解如何将自定义串口通信协议的底盘接入ROS1导航系统。

一、底盘通信协议分析

1.1 协议帧结构

底盘采用自定义串口协议,通用帧格式如下:

字节序号 字段名称 固定值 / 说明 字节数 核心作用
0 帧头 1 0x3A 1 数据帧起始标识,固定不变,用于帧同步
1 帧头 2 0xAA 1 数据帧起始标识,固定不变,双帧头提升抗干扰性
2 长度 (LEN) 整帧总字节数(1 字节,范围 0x00~0xFF) 1 标识当前帧的总长度,数据负载长度 = LEN - 6
3 命令 (CMD) 功能指令码(1 字节,如0x01为速度控制) 1 标识当前帧的功能类型,下位机根据 CMD 执行对应操作
4 ~ (LEN-3) 数据负载 (LEN-6) 字节,可变长度 可变 存储具体指令参数(如速度、角度、传感器数据等)
LEN-2 CRC 校验 1 字节校验值 1 数据完整性校验,防止传输错误
LEN-1 帧尾 0xBB 1 数据帧结束标识,固定不变

关键特征:

帧头固定:0x3A 0xAA

帧尾固定:0xBB

第3字节为总长度(包含帧头帧尾)

第4字节为命令字

多字节数据采用大端格式

校验方式:0xFF - sum(LEN到payload末尾)

下面以速度控制为第四字节为0x01为例(速度控制)通过ROS下发指令给底盘控制器从而实现键盘控制小车运动进一步封装成ROS Navigation的控制包(本文只做了键盘控制),也可以按照这个格式发送底盘数据给ROS如电量信息、其他控制指令等

 1.2 速度控制帧

帧格式:

字节序号 字段名称 固定值 / 说明 字节数 详细说明
0 帧头 1 0x3A 1 固定起始
1 帧头 2 0xAA 1 固定起始
2 长度 (LEN) 0x0C(十进制 12) 1 整帧总字节数固定为 12,因此数据负载长度 = 12-6=6 字节
3 命令 (CMD) 0x01 1 速度控制指令专属命令码
4-5 Vx(前后速度) int16_t 大端格式,正数前进、负数后退 2 单位为最大速度的千分比(0~1000 对应 0%~100%)
6-7 Vy(左右速度) 固定为0x00 0x00 2 多数差速 / 阿克曼底盘不支持 Y 方向平移,固定为 0
8-9 Vw(角速度) int16_t 大端格式,正数左转、负数右转 2 控制底盘旋转,0 为直行,数值大小对应旋转速度
10 CRC 校验 按算法计算的 1 字节校验值 1 校验规则:CRC = 0xFF - (LEN + CMD + 数据负载所有字节) & 0xFF
11 帧尾 0xBB 1 固定结束

单位换算:

 ROS: linear.x (m/s) → 底盘:x * 1000(mm/s)

 ROS: angular.z(rad/s) → 底盘: z * 100 (0.01rad/s)

示例:

ROS发送: linear.x=0.5, angular.z=0.0(50%的最大速度)

串口帧: 3A AA 0C 01 01 F4 00 00 00 00 FD BB

二、串口助手测试(SSCOM和cutecom)

2.1 SSCOM测试

打开SSCOM 多字符串,填前进后退指令(按照通信协议),关循环发送,点对应按钮发串口指令,若可以正常执行前进后退等动作则说明通信成功,关闭循环发送是为了避免参数一下给太大容易受伤或受损。

2.2 cutecom测试

在Linux系统下,用户可以通过安装cutecom来进行机器人底盘的串口收发数据。

首先打开终端(ctrl+alt+T 键),输入安装指令:

sudo apt-get insatll cutecom

接下来我们可以通过终端来打开cutecom,输入以下指令:

sduo cutecom

一定要sudo,不然没有权限

这样通过ubuntu串口也可以做控制。

三、代码实现

需创建main.cpp,下载串口库 serialib。具体看4.1 目录结构

3.1 串口库(serialib.h)

使用开源串口库 serialib,提供跨平台串口操作:

class serialib {
public:
    char openDevice(const char *Device, const unsigned int Bauds);
    bool isDeviceOpen();
    void closeDevice();
    int writeBytes(const void *Buffer, const unsigned int NbBytes);
    int readBytes(void *buffer, unsigned int maxNbBytes, 
                  unsigned int timeOut_ms, unsigned int sleepDuration_us);
    int available();
};

 3.2 主节点类(main.cpp 完整部分)

下面是main.cpp,其核心功能是接收 ROS 导航栈的速度指令,按照底盘通信协议,通过串口发送运动指令,最后接收底盘回传的里程计/IMU 数据并发布。

#include <ros/ros.h>
#include <geometry_msgs/Twist.h>
#include <geometry_msgs/TransformStamped.h>
#include <nav_msgs/Odometry.h>
#include <sensor_msgs/Imu.h>
#include <tf/transform_broadcaster.h>
#include <tf/transform_datatypes.h>
#include <thread>
#include <chrono>
#include <cmath>
#include <cstring>
#include <string>
#include <cstdio>
#include <cstdint>
#include "serialib.h"

#define PKG_MIN_LEN 6

class AMRRcu {
    enum CmdID { CMD_VEL = 0x01, CMD_ODOM = 0x81, CMD_IMU = 0x82, CMD_GPS = 0x83 };
    enum { PKG_LEN_IDX = 2, PKG_CMD_IDX = 3 };
public:
    AMRRcu() : pnh_("~"), rx_pkg_len_(PKG_MIN_LEN), prx_(rdata_), last_rev_ts_(0.0) {
        pnh_.param<std::string>("tty", port_, "/dev/ttyUSB0");
        pnh_.param<int>("baudrate", baud_, 115200);
        pnh_.param<bool>("publish_tf", publish_tf_, true);
        pnh_.param<std::string>("cmd_vel_topic", cmdvel_topic_, "cmd_vel");
        pnh_.param<std::string>("odom_topic", odom_topic_, "odom");
        pnh_.param<std::string>("imu_topic", imu_topic_, "imu/data");
        cmdvel_sub_ = nh_.subscribe(cmdvel_topic_, 5, &AMRRcu::cmdvelCallback, this);
        odom_pub_ = nh_.advertise<nav_msgs::Odometry>(odom_topic_, 50);
        imu_pub_ = nh_.advertise<sensor_msgs::Imu>(imu_topic_, 50);
        ROS_INFO("串口:%s, 波特率:%d, 接收主题:%s, 里程发布:%s, TF发布:%s", port_.c_str(), baud_, cmdvel_topic_.c_str(), odom_topic_.c_str(), publish_tf_ ? "是" : "否");
    }

    void spinOnce() {
        if (!serial_.isDeviceOpen()) {
            if (!connect()) { std::this_thread::sleep_for(std::chrono::milliseconds(500)); return; }
            last_rev_ts_ = ros::Time::now().toSec();
        }
        readUartOnce();
        if (ros::Time::now().toSec() - last_rev_ts_ > 5.0) {
            serial_.closeDevice();
            ROS_WARN("数据丢失超过5秒,尝试重新开启串口:%s", port_.c_str());
        }
    }

private:
    ros::NodeHandle nh_, pnh_;
    ros::Subscriber cmdvel_sub_;
    ros::Publisher odom_pub_, imu_pub_;
    tf::TransformBroadcaster tf_br_;
    bool publish_tf_;
    std::string port_, cmdvel_topic_, odom_topic_, imu_topic_;
    int baud_;
    serialib serial_;
    uint8_t rdata_[256], rx_pkg_len_, *prx_;
    nav_msgs::Odometry odo_;
    double last_rev_ts_;

    static int32_t be32(const uint8_t *p) { return (int32_t)((uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | uint32_t(p[3])); }
    static int16_t be16(const uint8_t *p) { return (int16_t)((uint16_t(p[0]) << 8) | uint16_t(p[1])); }
    static std::string buf2str(const uint8_t *buf, int len) { std::string s; char t[5] = {0}; for (int i = 0; i < len; ++i) { std::snprintf(t, sizeof(t), " %02X", buf[i]); s += t; } return s; }

    bool connect() {
        char ret = serial_.openDevice(port_.c_str(), baud_);
        if (ret != 1) { ROS_WARN("打开串口%s失败:%d", port_.c_str(), ret); return false; }
        ROS_INFO("打开串口:%s @ %d", port_.c_str(), baud_); return true;
    }

    void cmdvelCallback(const geometry_msgs::Twist::ConstPtr &msg) {
        sendSpdCmd(int(msg->linear.x * 1000.0), int(msg->linear.y * 1000.0), int(msg->angular.z * 100.0));
    }

    void pubOdom(double x, double y, double w, double vx, double vy, double vw) {
        ros::Time now = ros::Time::now();
        odo_.header.stamp = now; odo_.header.frame_id = "odom"; odo_.child_frame_id = "base_link";
        odo_.pose.pose.position.x = x; odo_.pose.pose.position.y = y; odo_.pose.pose.position.z = 0.0;
        odo_.pose.pose.orientation = tf::createQuaternionMsgFromYaw(w);
        odo_.twist.twist.linear.x = vx; odo_.twist.twist.linear.y = vy; odo_.twist.twist.linear.z = 0.0; odo_.twist.twist.angular.z = vw;
        bool still = std::fabs(vx) < 1e-5 && std::fabs(vy) < 1e-5 && std::fabs(vw) < 1e-5;
        for (int i = 0; i < 36; ++i) { odo_.pose.covariance[i] = 0.0; odo_.twist.covariance[i] = 0.0; }
        for (int i = 0; i < 6; ++i) { odo_.pose.covariance[i * 6 + i] = 0.01; odo_.twist.covariance[i * 6 + i] = still ? 1e-6 : 0.01; }
        odom_pub_.publish(odo_);
        if (publish_tf_) {
            geometry_msgs::TransformStamped tfm;
            tfm.header.stamp = now; tfm.header.frame_id = "odom"; tfm.child_frame_id = "base_link";
            tfm.transform.translation.x = x; tfm.transform.translation.y = y; tfm.transform.translation.z = 0.0; tfm.transform.rotation = odo_.pose.pose.orientation;
            tf_br_.sendTransform(tfm);
        }
    }

    void pubImu(float ax, float ay, float az, float gx, float gy, float gz, float qw, float qx, float qy, float qz) {
        sensor_msgs::Imu imu; imu.header.stamp = ros::Time::now(); imu.header.frame_id = "imu";
        imu.orientation.w = qw; imu.orientation.x = qx; imu.orientation.y = qy; imu.orientation.z = qz;
        imu.angular_velocity.x = gx; imu.angular_velocity.y = gy; imu.angular_velocity.z = gz;
        imu.linear_acceleration.x = ax; imu.linear_acceleration.y = ay; imu.linear_acceleration.z = az;
        for (int i = 0; i < 3; ++i) { imu.orientation_covariance[3 * i + i] = 1.0; imu.linear_acceleration_covariance[3 * i + i] = 3.0; imu.angular_velocity_covariance[3 * i + i] = 0.9; }
        imu_pub_.publish(imu);
    }

    void sendSpdCmd(int x, int y, int w) {
        if (!serial_.isDeviceOpen()) return;
        uint8_t buf[12] = {0x3A, 0xAA, 12, CMD_VEL, 0, 0, 0, 0, 0, 0, 0, 0xBB};
        buf[4] = (x >> 8) & 0xFF; buf[5] = x & 0xFF; buf[6] = (y >> 8) & 0xFF; buf[7] = y & 0xFF; buf[8] = (w >> 8) & 0xFF; buf[9] = w & 0xFF;
        uint8_t sum = 0; for (int i = 2; i < 10; ++i) sum += buf[i]; buf[10] = uint8_t(0xFF - sum); serial_.writeBytes(buf, 12);
    }

    void rxReset() { rx_pkg_len_ = PKG_MIN_LEN; prx_ = rdata_; }

    void decode() {
        if (rdata_[PKG_CMD_IDX] == CMD_ODOM && rx_pkg_len_ >= 25) {
            pubOdom(be32(rdata_ + 4) / 1000.0, be32(rdata_ + 8) / 1000.0, be16(rdata_ + 12) / 100.0, be16(rdata_ + 14) / 1000.0, be16(rdata_ + 16) / 1000.0, be16(rdata_ + 18) / 100.0);
        } else if (rdata_[PKG_CMD_IDX] == CMD_IMU && rx_pkg_len_ >= 46) {
            float d[10]; std::memcpy(d, rdata_ + 4, sizeof(d)); pubImu(d[0], d[1], d[2], d[3], d[4], d[5], d[9], d[6], d[7], d[8]);
        }
    }

    void readUartOnce() {
        while (ros::ok()) {
            int need = rx_pkg_len_ - int(prx_ - rdata_); if (serial_.available() < need) return;
            last_rev_ts_ = ros::Time::now().toSec(); serial_.readBytes(prx_, need, 1); prx_ += need;
            if (rx_pkg_len_ == PKG_MIN_LEN) {
                if (rdata_[0] != 0x3A || rdata_[1] != 0xAA) {
                    int i = 1; for (; i < rx_pkg_len_; ++i) if (rdata_[i] == 0x3A) { ROS_WARN("数据包同步:%s", buf2str(rdata_, rx_pkg_len_).c_str()); std::memmove(rdata_, rdata_ + i, rx_pkg_len_ - i); prx_ = rdata_ + (rx_pkg_len_ - i); break; }
                    if (i >= rx_pkg_len_) { ROS_WARN("数据包头错误:%s", buf2str(rdata_, rx_pkg_len_).c_str()); rxReset(); }
                } else { rx_pkg_len_ = rdata_[PKG_LEN_IDX]; if (rx_pkg_len_ < PKG_MIN_LEN || rx_pkg_len_ > sizeof(rdata_)) { ROS_WARN("包长错误:%d", rx_pkg_len_); rxReset(); } }
                continue;
            }
            if (rdata_[0] != 0x3A || rdata_[1] != 0xAA || rdata_[rx_pkg_len_ - 1] != 0xBB) { ROS_WARN("数据包格式不正确:%s", buf2str(rdata_, rx_pkg_len_).c_str()); rxReset(); continue; }
            uint8_t sum = 0; for (int i = 2; i < rx_pkg_len_ - 2; ++i) sum += rdata_[i]; if (uint8_t(0xFF - sum) != rdata_[rx_pkg_len_ - 2]) { ROS_WARN("数据校验错误:%s", buf2str(rdata_, rx_pkg_len_).c_str()); rxReset(); continue; }
            decode(); rxReset();
        }
    }
};

int main(int argc, char **argv) {
    ros::init(argc, argv, "amr_rcu_cmd");
    AMRRcu node; ROS_INFO("amr_rcu启动"); ros::Rate rate(200);
    while (ros::ok()) { node.spinOnce(); ros::spinOnce(); rate.sleep(); }
    ROS_INFO("amr_rcu退出"); return 0;
}

四、工程配置

4.1 目录结构

将创建好的main.cpp以及serialib放到对应的文件位置(需先创建工作空间)

catkin_ws/src/fireman_robot/
├── CMakeLists.txt
├── package.xml
├── src/
│   ├── main.cpp
│   └── serialib.cpp
└── include/fireman_robot/
    └── serialib.h

4.2 修改CMakeLists.txt

cmake_minimum_required(VERSION 3.0.2)

project(fireman_robot)

find_package(catkin REQUIRED COMPONENTS
  roscpp
  geometry_msgs
  nav_msgs
  sensor_msgs
  tf
)

catkin_package()
include_directories(
  include
  ${catkin_INCLUDE_DIRS}
)

add_executable(amr_rcu_cmd
  src/main_ros1.cpp
  src/serialib.cpp
)

target_link_libraries(amr_rcu_cmd
  ${catkin_LIBRARIES}
)

4.3 修改package.xml 

<build_depend>roscpp</build_depend>
<build_depend>geometry_msgs</build_depend>
<build_depend>nav_msgs</build_depend>
<build_depend>sensor_msgs</build_depend>
<build_depend>tf</build_depend>

<exec_depend>roscpp</exec_depend>
<exec_depend>geometry_msgs</exec_depend>
<exec_depend>nav_msgs</exec_depend>
<exec_depend>sensor_msgs</exec_depend>
<exec_depend>tf</exec_depend>

4.4 编译与运行

用rosrun需先开roscore,_tty:=/dev/ttyUSB0需临时设置端口号

bash
cd ~/catkin_ws
catkin_make
source devel/setup.bash
rosrun fireman_robot amr_rcu_cmd _tty:=/dev/ttyUSB0

Linux下必须配置串口权限,临时方案:

bash
sudo chmod 666 /dev/ttyUSB0

五、测试与验证

5.1 查看话题

运行4.4编译与运行

rostopic list

应看到:

 /cmd_vel
 /odom
 /imu/data

5.2 查看里程计

rostopic echo /odom

5.3 键盘发送速度

新开一个窗口,可能需先安装

sudo apt update
sudo apt install ros-noetic-teleop-twist-keyboard

安装完直接用

rosrun teleop_twist_keyboard teleop_twist_keyboard.py

可实现键盘控制控制

六、总结

核心要点

  1. 协议解析:帧同步、字节序、校验和
  2. 单位换算:ROS标准单位 ↔ 底盘协议单位
  3. 接口封装:cmd_vel订阅、odom发布、TF广播
  4. 异常处理:半包粘包、校验失败、串口重连
  5. 工程配置:CMakeLists.txt、package.xml、串口权限

适用场景

适用于:

  •  自定义串口协议底盘
  • 差速/全向/麦克纳姆轮底盘
  • ROS1 Melodic/Noetic
  • 需要接入move_base导航栈

按照本文步骤配置后即可直接编译运行。

参考

轮趣科技的串口通信控制与反馈部分

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐