1. 为什么需要ROS2串口通信?
在机器人开发中,串口通信就像机器人的"神经系统"。我做过一个智能小车的项目,需要通过串口向底盘发送速度指令。当时发现很多开发者卡在环境配置这一步,其实只要掌握几个关键点就能轻松打通ROS2和硬件设备的通信链路。
串口通信最大的优势是硬件兼容性强。市面上90%的嵌入式设备都支持串口协议,从Arduino到STM32,甚至工业PLC都能通过串口与ROS2交互。相比USB或网络通信,串口协议更底层,传输延迟通常能控制在10ms以内。
实际开发中常遇到三类问题:
- 物理串口数量不足(笔记本通常只有1-2个物理串口)
- 波特率配置错误导致乱码
- 数据帧解析异常
这就引出了我们的解决方案:用虚拟串口工具socat模拟真实硬件环境。我在多次项目实践中验证过,这种方法能节省80%的硬件调试时间。下面我会手把手带你搭建完整的通信链路。
2. 搭建虚拟串口环境
2.1 安装socat工具
在Ubuntu终端执行这个命令:
sudo apt-get install socat安装完成后,用这个命令创建一对虚拟串口:
socat -d -d pty,raw,echo=0 pty,raw,echo=0你会看到类似这样的输出:
2023/08/15 10:23:45 socat[1234] N PTY is /dev/pts/3 2023/08/15 10:23:45 socat[1234] N PTY is /dev/pts/4这里/dev/pts/3和/dev/pts/4就是生成的虚拟串口对。我习惯用奇数端口做发送端,偶数端口做接收端,这样不容易搞混。
2.2 验证串口连通性
打开三个终端窗口:
- 第一个终端持续监听接收端:
cat < /dev/pts/4- 第二个终端向发送端写入数据:
echo "Hello ROS2" > /dev/pts/3- 第三个终端可以用
ls -l /dev/pts/*查看端口状态。如果第一个终端显示了"Hello ROS2",说明虚拟串口工作正常。
注意:每次重启系统后虚拟串口路径会变化,需要重新生成。建议把socat命令保存为shell脚本方便重复使用。
3. 创建ROS2串口通信节点
3.1 建立工程结构
按这个顺序创建工程目录:
mkdir -p serial_ws/src cd serial_ws/src ros2 pkg create --build-type ament_cmake serial_demo目录结构应该是这样:
serial_ws/ └── src/ └── serial_demo/ ├── include/ ├── src/ ├── CMakeLists.txt └── package.xml3.2 编写串口核心类
在include/serial_demo目录下新建serial_port.hpp,这是我优化过的版本:
#pragma once #include <fcntl.h> #include <termios.h> #include <unistd.h> class SerialPort { public: int Open(const char* port, int baudrate) { fd_ = open(port, O_RDWR | O_NOCTTY); if (fd_ < 0) return -1; struct termios options; tcgetattr(fd_, &options); cfsetispeed(&options, baudrate); cfsetospeed(&options, baudrate); options.c_cflag |= (CLOCAL | CREAD); options.c_cflag &= ~PARENB; options.c_cflag &= ~CSTOPB; options.c_cflag &= ~CSIZE; options.c_cflag |= CS8; tcsetattr(fd_, TCSANOW, &options); return fd_; } int Write(const char* data, int length) { return write(fd_, data, length); } int Close() { return close(fd_); } private: int fd_ = -1; };这个类封装了三个关键方法:
Open():配置波特率、数据位、停止位等参数Write():发送二进制数据Close():释放串口资源
3.3 实现发布/订阅节点
在src目录下创建两个节点文件:
publisher_node.cpp:
#include "rclcpp/rclcpp.hpp" #include "std_msgs/msg/string.hpp" class PublisherNode : public rclcpp::Node { public: PublisherNode() : Node("serial_publisher") { publisher_ = create_publisher<std_msgs::msg::String>("serial_topic", 10); timer_ = create_wall_timer( std::chrono::milliseconds(500), [this]() { auto message = std_msgs::msg::String(); message.data = "Data:" + std::to_string(counter_++); RCLCPP_INFO(get_logger(), "Publishing: '%s'", message.data.c_str()); publisher_->publish(message); }); } private: rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_; rclcpp::TimerBase::SharedPtr timer_; int counter_ = 0; }; int main(int argc, char* argv[]) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared<PublisherNode>()); rclcpp::shutdown(); return 0; }subscriber_node.cpp:
#include "serial_demo/serial_port.hpp" #include "rclcpp/rclcpp.hpp" #include "std_msgs/msg/string.hpp" class SubscriberNode : public rclcpp::Node { public: SubscriberNode() : Node("serial_subscriber") { serial_.Open("/dev/pts/3", B115200); // 修改为你的发送端端口 subscription_ = create_subscription<std_msgs::msg::String>( "serial_topic", 10, [this](const std_msgs::msg::String::SharedPtr msg) { RCLCPP_INFO(get_logger(), "Received: '%s'", msg->data.c_str()); serial_.Write(msg->data.c_str(), msg->data.length()); }); } ~SubscriberNode() { serial_.Close(); } private: SerialPort serial_; rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_; }; int main(int argc, char* argv[]) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared<SubscriberNode>()); rclcpp::shutdown(); return 0; }4. 编译与系统配置
4.1 修改构建文件
更新CMakeLists.txt:
find_package(rclcpp REQUIRED) find_package(std_msgs REQUIRED) add_library(serial_port SHARED src/serial_port.cpp) target_include_directories(serial_port PUBLIC include) add_executable(publisher_node src/publisher_node.cpp) ament_target_dependencies(publisher_node rclcpp std_msgs) add_executable(subscriber_node src/subscriber_node.cpp) ament_target_dependencies(subscriber_node rclcpp std_msgs) target_link_libraries(subscriber_node serial_port) install(TARGETS publisher_node subscriber_node serial_port DESTINATION lib/${PROJECT_NAME})更新package.xml:
<depend>rclcpp</depend> <depend>std_msgs</depend>4.2 编译工程
执行以下命令:
cd ~/serial_ws colcon build --packages-select serial_demo source install/setup.bash5. 完整通信测试
5.1 启动测试流程
需要四个终端窗口:
- 终端1:运行socat创建虚拟串口
- 终端2:监听接收端口
cat < /dev/pts/4 - 终端3:启动订阅节点
ros2 run serial_demo subscriber_node - 终端4:启动发布节点
ros2 run serial_demo publisher_node
5.2 预期结果
在终端4会看到持续输出的发布消息:
[INFO] [serial_publisher]: Publishing: 'Data:0' [INFO] [serial_publisher]: Publishing: 'Data:1'终端3会显示接收到的消息:
[INFO] [serial_subscriber]: Received: 'Data:0' [INFO] [serial_subscriber]: Received: 'Data:1'终端2会显示通过串口传输的原始数据:
Data:0Data:1Data:25.3 常见问题排查
如果数据没有正常传输,按这个顺序检查:
- 确认
socat生成的端口号与代码中一致 - 检查串口权限:
sudo chmod 777 /dev/pts/* - 确认波特率设置一致(代码和硬件设备)
- 使用
ros2 topic echo /serial_topic确认话题有数据
我在实际项目中遇到过最棘手的问题是串口死锁,后来发现是没处理异常关闭的情况。现在代码中在析构函数里主动关闭串口,避免了资源泄漏。
6. 进阶优化建议
6.1 增加数据校验
串口通信容易受到干扰,建议在数据帧中添加校验位。常用的CRC16校验实现:
uint16_t CalculateCRC16(const uint8_t* data, size_t length) { uint16_t crc = 0xFFFF; for (size_t i = 0; i < length; ++i) { crc ^= data[i]; for (int j = 0; j < 8; ++j) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }6.2 使用自定义消息类型
标准字符串传输效率低,可以自定义二进制消息:
// 在msg目录下创建SerialMessage.msg uint16 header # 0xAA55 uint8 length uint8[] data uint16 checksum6.3 多线程处理
对于高速数据传输,建议使用独立线程处理串口读写:
std::thread read_thread([this](){ while(running_) { uint8_t buffer[256]; int n = read(fd_, buffer, sizeof(buffer)); if(n > 0) { // 处理接收数据 } } });记得在析构函数中设置running_ = false并调用read_thread.join()。
7. 硬件对接注意事项
当过渡到真实硬件时,要注意:
- 电平匹配:确认设备是TTL电平(3.3V/5V)还是RS232电平(±12V)
- 接线规范:TX接RX,RX接TX,GND对接
- 波特率容错:工业现场建议使用9600以下波特率
- 抗干扰措施:
- 使用双绞线
- 避免与电源线平行走线
- 在信号线加磁环
我用示波器抓取过串口信号,发现长距离传输时信号边沿会变缓。这时需要在接收端添加适当的上拉电阻,通常4.7KΩ就能显著改善信号质量。