4、激光雷达驱动开发:编写ROS2驱动节点、配置CMakeLists.txt、发布原始点云数据

好,咱们进入第四章节。前面几章我们把ROS2的基础环境搭好了,也聊了聊激光雷达的选型。今天要动真格的了——写一个真正的ROS2驱动节点,把雷达的原始点云数据发出来。

说实话,我第一次写激光雷达驱动的时候,踩了不少坑。那时候还是ROS1,现在换成ROS2,很多习惯都得改。但核心逻辑没变:读数据、解析数据、发数据。就这么简单。

本章核心目标:从零编写一个ROS2激光雷达驱动节点,完成CMakeLists.txt配置,最终在Rviz2中看到实时点云。

4.1 驱动节点的整体架构

先画个图,让你对整个流程有个直观印象。我个人习惯在写代码前先画架构图,这样思路清晰,不容易写偏。

激光雷达ROS2驱动节点架构 激光雷达硬件 (串口/以太网) ROS2驱动节点 (C++ / Python) 数据解析 + 发布 /scan 话题 sensor_msgs/LaserScan 原始点云数据 Rviz2 / 其他节点 订阅并可视化 数据解析流程 校验 → 解包 → 转换 关键说明 1. 驱动节点负责与硬件通信,读取原始字节流 2. 解析后的数据转换为ROS2标准消息类型(LaserScan) 3. 发布到/scan话题,供导航、建图等模块使用 4. 支持多雷达时,可通过命名空间区分不同话题

嗯,这张图基本说清楚了。驱动节点就是中间的桥梁,一边连硬件,一边连ROS2生态。

4.2 创建ROS2功能包

动手之前,先创建功能包。我习惯用C++写驱动,性能好,控制力强。当然Python也行,但遇到高频雷达时容易丢包。

# 进入工作空间
cd ~/ros2_ws/src

# 创建功能包
ros2 pkg create --build-type ament_cmake lidar_driver \
  --dependencies rclcpp sensor_msgs std_msgs

# 查看生成的结构
tree lidar_driver/

创建完成后,你会看到这样的目录结构:

文件/目录 作用
CMakeLists.txt 编译配置文件,核心中的核心
package.xml 包描述文件,声明依赖
src/ 存放C++源文件
include/lidar_driver/ 存放头文件
launch/ 启动文件目录(手动创建)

我的经验:记得手动创建launch目录。很多新手忘了这步,后面启动节点时手忙脚乱。我早期就吃过这个亏,每次都要敲一长串命令行。

4.3 编写驱动节点代码

好,开始写核心代码。我们以常见的单线激光雷达为例,假设它通过串口输出数据,格式是:起始字节 + 角度 + 距离 + 强度。

先写头文件,声明我们的驱动类:

// include/lidar_driver/lidar_driver_node.hpp
#ifndef LIDAR_DRIVER_NODE_HPP
#define LIDAR_DRIVER_NODE_HPP

#include <rclcpp/rclcpp.hpp>
#include <sensor_msgs/msg/laser_scan.hpp>
#include <string>
#include <vector>

class LidarDriverNode : public rclcpp::Node
{
public:
  explicit LidarDriverNode(const std::string &node_name);
  ~LidarDriverNode();

private:
  // 串口通信相关
  int serial_fd_;
  std::string port_;
  int baudrate_;

  // 数据解析
  void parseRawData(const std::vector<uint8_t> &buffer);
  void publishScan(const std::vector<float> &ranges,
                   const std::vector<float> &intensities);

  // 定时器驱动
  rclcpp::TimerBase::SharedPtr timer_;
  void timerCallback();

  // 发布器
  rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr scan_pub_;
};

#endif

嗯,这里要注意:LaserScan消息是ROS2里专门为单线激光雷达设计的。它包含角度范围、距离数组、强度数组等字段。说白了,就是一圈扫描点的集合。

接下来是实现文件。我挑重点说:

// src/lidar_driver_node.cpp
#include "lidar_driver/lidar_driver_node.hpp"
#include <fcntl.h>
#include <unistd.h>
#include <termios.h>

LidarDriverNode::LidarDriverNode(const std::string &node_name)
  : Node(node_name)
{
  // 声明参数
  this->declare_parameter<std::string>("port", "/dev/ttyUSB0");
  this->declare_parameter<int>("baudrate", 115200);

  port_ = this->get_parameter("port").as_string();
  baudrate_ = this->get_parameter("baudrate").as_int();

  // 创建发布器
  scan_pub_ = this->create_publisher<sensor_msgs::msg::LaserScan>(
    "scan", rclcpp::QoS(10));

  // 打开串口
  serial_fd_ = open(port_.c_str(), O_RDWR | O_NOCTTY);
  if (serial_fd_ < 0) {
    RCLCPP_ERROR(this->get_logger(), "无法打开串口: %s", port_.c_str());
    return;
  }

  // 配置串口参数(省略具体设置)
  // ...

  // 启动定时器,10ms读取一次
  timer_ = this->create_wall_timer(
    std::chrono::milliseconds(10),
    std::bind(&LidarDriverNode::timerCallback, this));

  RCLCPP_INFO(this->get_logger(), "激光雷达驱动节点已启动");
}

注意:串口权限问题。我曾经在调试时花了整整一下午,才发现是/dev/ttyUSB0没有读写权限。解决办法:sudo chmod 666 /dev/ttyUSB0,或者把用户加入dialout组。

定时器回调函数里做数据读取和解析:

void LidarDriverNode::timerCallback()
{
  std::vector<uint8_t> buffer(1024);
  int len = read(serial_fd_, buffer.data(), buffer.size());

  if (len > 0) {
    buffer.resize(len);
    parseRawData(buffer);
  }
}

void LidarDriverNode::parseRawData(const std::vector<uint8_t> &buffer)
{
  // 这里根据雷达协议解析
  // 假设协议:0xAA 0x55 + 角度(2字节) + 距离(2字节) + 强度(1字节)
  // 实际项目中请替换为真实协议

  std::vector<float> ranges;
  std::vector<float> intensities;

  for (size_t i = 0; i + 5 < buffer.size(); i += 6) {
    if (buffer[i] == 0xAA && buffer[i+1] == 0x55) {
      // 解析角度(单位:0.01度)
      uint16_t angle_raw = (buffer[i+2] << 8) | buffer[i+3];
      float angle = angle_raw * 0.01f;

      // 解析距离(单位:毫米)
      uint16_t dist_raw = (buffer[i+4] << 8) | buffer[i+5];
      float distance = dist_raw / 1000.0f;  // 转为米

      // 这里简化处理,实际需要按角度索引填充
      ranges.push_back(distance);
      intensities.push_back(0.0f);
    }
  }

  publishScan(ranges, intensities);
}

最后是发布函数,把解析好的数据包装成LaserScan消息:

void LidarDriverNode::publishScan(
    const std::vector<float> &ranges,
    const std::vector<float> &intensities)
{
  auto scan_msg = sensor_msgs::msg::LaserScan();

  // 填充消息头
  scan_msg.header.stamp = this->now();
  scan_msg.header.frame_id = "laser_frame";

  // 角度参数(根据雷达实际参数设置)
  scan_msg.angle_min = -M_PI;      // -180度
  scan_msg.angle_max = M_PI;       // 180度
  scan_msg.angle_increment = 2 * M_PI / 360;  // 1度分辨率
  scan_msg.time_increment = 1.0f / 360.0f;    // 每点时间间隔
  scan_msg.scan_time = 1.0f / 10.0f;          // 10Hz扫描频率
  scan_msg.range_min = 0.1;        // 最小距离0.1米
  scan_msg.range_max = 10.0;       // 最大距离10米

  // 填充距离和强度数据
  scan_msg.ranges = ranges;
  scan_msg.intensities = intensities;

  // 发布
  scan_pub_->publish(scan_msg);
}

关键点:frame_id一定要和后续的TF树对应。我见过有人把frame_id写成"laser",但TF里发布的是"laser_frame",结果Rviz2里死活看不到点云。这种低级错误,排查起来很费时间。

4.4 配置CMakeLists.txt

代码写完了,但编译不过去就尴尬了。CMakeLists.txt是ROS2 C++项目的命脉,配置不对,一切白搭。

我直接给你一个可用的模板:

cmake_minimum_required(VERSION 3.8)
project(lidar_driver)

# 使用C++17标准
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

# 查找依赖
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(sensor_msgs REQUIRED)
find_package(std_msgs REQUIRED)

# 包含头文件目录
include_directories(include)

# 添加可执行文件
add_executable(lidar_driver_node
  src/lidar_driver_node.cpp
)

# 链接库
ament_target_dependencies(lidar_driver_node
  rclcpp
  sensor_msgs
  std_msgs
)

# 安装可执行文件
install(TARGETS
  lidar_driver_node
  DESTINATION lib/${PROJECT_NAME}
)

# 安装启动文件(如果有)
install(DIRECTORY
  launch
  DESTINATION share/${PROJECT_NAME}
)

# 安装配置文件(如果有)
install(DIRECTORY
  config
  DESTINATION share/${PROJECT_NAME}
)

if(BUILD_TESTING)
  find_package(ament_lint_auto REQUIRED)
  ament_lint_auto_find_test_dependencies()
endif()

ament_package()

这里有几个坑,我当年都踩过:

常见错误 后果 解决办法
忘记find_package(sensor_msgs) 编译报错:找不到LaserScan类型 检查package.xml和CMakeLists.txt的依赖声明
头文件路径不对 找不到头文件 用include_directories(include)指定路径
忘记install TARGETS ros2 run找不到节点 必须显式安装可执行文件
ament_target_dependencies拼错 链接失败 注意是ament_target_dependencies,不是target_link_libraries

我的习惯:每次修改CMakeLists.txt后,先跑colcon build --packages-select lidar_driver快速验证。不要等全部包一起编译,那样太慢了。发现问题及时改,效率高很多。

4.5 编译与测试

代码和配置都准备好了,开始编译:

# 回到工作空间根目录
cd ~/ros2_ws

# 编译
colcon build --packages-select lidar_driver

# 如果编译成功,你会看到类似输出:
# Summary: 1 package finished [XX.Xs]

# 配置环境
source install/setup.bash

# 运行节点(假设雷达已连接)
ros2 run lidar_driver lidar_driver_node

运行后,用以下命令验证:

# 查看话题列表
ros2 topic list
# 应该能看到 /scan

# 查看话题数据
ros2 topic echo /scan
# 会输出LaserScan消息内容

# 查看发布频率
ros2 topic hz /scan
# 正常应该在10Hz左右

避坑指南:我曾经遇到一个问题——节点运行了,/scan话题也有数据,但Rviz2里就是看不到点云。排查了半天,发现是frame_id设置成了"laser",但Rviz2的Fixed Frame默认是"map"。把Fixed Frame改成"laser_frame"后,点云瞬间出来了。这种问题,说白了就是坐标系没对上。

4.6 在Rviz2中可视化

看到数据还不够,得在Rviz2里亲眼看到点云才踏实:

# 启动Rviz2
rviz2

# 在Rviz2中:
# 1. 将Fixed Frame设置为 "laser_frame"
# 2. 添加显示类型:Add -> LaserScan
# 3. 在LaserScan属性中,选择话题 /scan

如果一切正常,你会看到一圈点云,代表雷达扫描到的障碍物。嗯,看到这个画面,说明你的驱动节点已经跑通了。

总结一下:这一章我们完成了从零编写ROS2激光雷达驱动节点的全过程。核心就是三步:读数据、解析数据、发数据。CMakeLists.txt配置是很多人的痛点,记住那个模板,以后改改就能用。

驱动节点开发好了,原始点云数据也能发布了。接下来,这些数据会流向哪里?嗯,那就是后面章节要聊的内容了。