ROS2 Humble + C++ 自定义消息接口 + Qt 可视化系统监控完整教程(0 基础保姆级)

ROS2 Humble + C++ 自定义消息接口 + Qt 可视化系统监控完整教程(0 基础保姆级)

整体项目说明

实现功能

  1. 自定义 ROS2 消息接口,封装本机系统状态(CPU 使用率、内存占用、磁盘、运行时间)
  2. ROS2 C++ 发布节点:采集本机系统信息,以自定义话题持续发布
  3. ROS2 C++ 订阅节点 + Qt5 可视化界面:订阅话题,实时绘图展示系统数据
  4. 全程分原理讲解、分步实操、代码逐行注释、编译运行、排错指南

环境前置

系统:Ubuntu 22.04 + ROS2 Humble

依赖:Qt5、sysstat(系统采集库)、rclcpp、rosidl_default_generators

整体架构原理

c 复制代码
[系统采集发布端(publisher)]
采集CPU/内存/磁盘数据 → 填充自定义Msg → 发布话题 /sys_status
        ↓ ROS2 DDS 分布式通信
[Qt可视化订阅端(subscriber+GUI)]
ROS2回调接收消息 → Qt信号槽跨线程转发数据 → Qt界面文本+实时曲线绘图展示

核心关键点:

  1. ROS2 回调函数运行在独立线程 ,Qt UI 只能在主线程更新,必须用信号槽线程隔离,否则界面崩溃
  2. 自定义接口需要单独msg包,供发布、订阅工程共用
  3. 系统信息读取使用标准 Linux API,跨进程读取 /proc 文件系统获取硬件状态

第一部分:项目目录结构规划(工作空间)

创建 ros2_qt_sys_monitor 工作空间

工作空间:存放、编译、运行所有 ROS2 功能包的独立文件夹,是管理 ROS2 代码的顶层容器,项目文件夹

c 复制代码
mkdir -p ~/ros2_qt_sys_ws/src #加上-p 递归调用创建
cd ~/ros2_qt_sys_ws/src  #进入工作目录src源码文件夹进行创建

创建 3 个包:

1. 创建自定义消息接口包 sys_interfaces

复制代码
# --build-type ament_cmake:C++/消息专用构建类型
ros2 pkg create --build-type ament_cmake sys_interfaces

说明

  • 只用来存放 .msg 自定义消息,无可执行节点;
  • 构建类型必须 ament_cmake,只有它支持 rosidl 消息生成工具。

2. 创建 C++ 发布节点包 sys_publisher

复制代码
# --dependencies 提前声明依赖:rclcpp(ROS2 C++库)、sys_interfaces(自定义消息)
ros2 pkg create --build-type ament_cmake sys_publisher --dependencies rclcpp sys_interfaces

说明

  • rclcpp:ROS2 C++ 节点核心库,创建发布器、定时器、节点都需要;
  • sys_interfaces:依赖我们自己写的消息包,才能使用 SysStatus 消息类型;
  • 构建类型 ament_cmake,用于编写 C++ 发布逻辑。

3. 创建 Qt + ROS2 可视化订阅包 sys_qt_gui

复制代码
# 依赖:rclcpp、自定义消息、Qt5核心界面组件
ros2 pkg create --build-type ament_cmake sys_qt_gui --dependencies rclcpp sys_interfaces Qt5Core Qt5Gui Qt5Widgets

依赖解释

  • Qt5Core:Qt 基础核心功能;
  • Qt5Gui:图形底层;
  • Qt5Widgets:窗口、按钮、标签、绘图控件等 UI 组件;
  • 后续 CMake 里还要额外配置 QCustomPlot 绘图库,命令创建仅预填基础 Qt 依赖。

目录总结构

c 复制代码
ros2_qt_sys_ws/
└── src/
    ├── sys_interfaces/    # 自定义消息
    ├── sys_publisher/     # 系统数据发布器
    └── sys_qt_gui/        # Qt可视化订阅界面

第二部分:步骤 1 自定义 ROS2 消息接口 sys_interfaces

2.1 创建接口包

工作空间 Workspace > 功能包 Package > 节点 Node

  1. 一个工作空间可以包含无数个功能包;

  2. 一个功能包内部可以编译生成多个可执行程序,每个程序运行后就是一个节点;

  3. 节点是 ROS2 最小运行单元,负责通信、采集数据、界面显示等业务逻辑。

    cd ~/ros2_qt_sys_ws/src
    cd sys_interfaces
    mkdir msg

2.2 新建自定义消息 SysStatus.msg

apl 复制代码
src/sys_interfaces/msg/SysStatus.msg #遵循驼峰命名和后缀名.msg msg是话题通信规定后缀
c 复制代码
# 系统状态自定义消息
float32 cpu_usage       # CPU使用率 0~100
float32 mem_total       # 总内存 GB
float32 mem_used        # 已用内存 GB
float32 mem_usage       # 内存使用率
float32 disk_total      # 磁盘总容量 GB
float32 disk_used       # 磁盘已用 GB
int32 uptime_sec        # 系统开机总秒数

2.3 配置 CMakeLists.txt(sys_interfaces中的)

1. CMake 基础作用

CMake 不是编译器(gcc/g++ 才是编译 C++ 的),它是跨平台构建配置工具

  1. 你写 CMakeLists.txt 描述:项目名、C++ 标准、依赖库、源码文件、怎么编译、输出文件放哪;
  2. CMake 读取该文件,自动生成对应平台的构建脚本(Ubuntu 下生成 Makefile);
  3. 再通过 make / colcon build 调用 gcc 完成编译、链接、安装。

2. 在 ROS2 ament_cmake 中的特殊定位

ROS2 封装了一套叫 ament_cmake 的工具,基于原生 CMake 扩展:

  • 自动处理 ROS 依赖(rclcpp、消息生成器、Qt 等);
  • 自动生成 ROS 环境文件、消息头文件;
  • 自动配置 install 安装规则,配合 colcon 工具链。

简单说:

CMakeLists.txt = 当前功能包的编译说明书,告诉 colcon 怎么处理这个包。

apl 复制代码
# 设置本项目支持的最低CMake版本为3.8
cmake_minimum_required(VERSION 3.8)
# 定义当前项目名称,变量 ${PROJECT_NAME} 自动等于 sys_interfaces
project(sys_interfaces)

# 判断编译器:如果是gcc/g++ 或者 clang编译器
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  # 添加编译警告参数
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

# 查找依赖包,REQUIRED 代表找不到就直接终止编译、报错
# 依赖1:ament_cmake ROS2 CMake底层工具
find_package(ament_cmake REQUIRED)
# 依赖2:rosidl_default_generators 消息代码生成工具(核心)
find_package(rosidl_default_generators REQUIRED)

# 生成消息代码
rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/SysStatus.msg"
)

# 导出依赖,其他包可以调用
ament_export_dependencies(rosidl_default_runtime)
# ament包收尾标记,必须放在文件最后一行
ament_package()

2.4 配置 package.xml

c 复制代码
<?xml version="1.0"?>
<!-- ROS包规范3版本校验地址 -->
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<!-- 包格式版本3,Humble统一使用 -->
<package format="3">
  <!-- 功能包名称,和project名称保持一致 -->
  <name>sys_interfaces</name>
  <!-- 包版本号,自定义消息包默认0.0.0 -->
  <version>0.0.0</version>
  <!-- 包描述:自定义系统状态消息接口包 -->
  <description>Custom ros2 msg package for system hardware status data</description>
  <!-- 维护人邮箱和名称,可自定义修改 -->
  <maintainer email="rookie@todo.todo">rookie</maintainer>
  <!-- 开源协议,ROS官方推荐Apache-2.0 -->
  <license>Apache-2.0</license>

  <!-- 编译工具依赖1:ament_cmake C++构建工具 -->
  <buildtool_depend>ament_cmake</buildtool_depend>
  <!-- 编译工具依赖2:消息代码生成器,解析.msg文件 -->
  <buildtool_depend>rosidl_default_generators</buildtool_depend>

  <!-- 运行时依赖:消息运行库,程序运行时加载消息类型 -->
  <depend>rosidl_default_runtime</depend>

  <!-- 测试相关依赖,创建包时自动生成,保留即可 -->
  <test_depend>ament_lint_auto</test_depend>
  <test_depend>ament_lint_common</test_depend>

  <!-- 导出配置区域,ROS工具读取该区域识别包类型 -->
  <export>
    <!-- 声明当前包构建类型为ament_cmake -->
    <build_type>ament_cmake</build_type>
  </export>
    <member_of_group>rosidl_interface_packages</member_of_group> 
</package>

单独编译自定义消息接口包 sys_interfaces 完整操作

1. 切换到工作空间根目录

复制代码
cd ~/ros2_qt_sys_ws

2. 单独编译指定包命令

复制代码
colcon build --packages-select sys_interfaces

参数解释

  • --packages-select:只编译后面指定的功能包,忽略 src 下其他包(sys_publisher、sys_qt_gui 不会参与编译)
  • 编译逻辑:仅执行 sys_interfaces 内 CMakeLists.txt,解析 .msg 生成消息头文件

3. 编译完成后必须刷新环境变量

新开终端或当前终端都要执行,否则其他包找不到自定义消息:

复制代码
source install/setup.bash

4. 验证消息是否生成成功

c 复制代码
# 查看所有接口,能看到 sys_interfaces/msg/SysStatus 即成功
ros2 interface list | grep SysStatus
# 查看消息结构
ros2 interface show sys_interfaces/msg/SysStatus

2.5 原理讲解:自定义消息编译逻辑

  1. .msg是 ROS2 消息描述文件,定义字段类型与名称
  2. rosidl_generate_interfaces工具会自动生成:
    • C++ 头文件(install/include/sys_interfaces/)
    • Python 接口
  3. 其他包只需要依赖sys_interfaces,即可直接导入sys_interfaces::msg::SysStatus

第三部分:步骤 2 C++ 系统信息发布节点 sys_publisher

功能:读取 Linux /proc 文件系统,定时 100ms 发布自定义 SysStatus 话题

3.1 创建包,添加依赖

c 复制代码
cd ~/ros2_qt_sys_ws/src
ros2 pkg create --build-type ament_cmake sys_publisher --dependencies rclcpp sys_interfaces   #(前面创建不用管这个了)

3.2 核心源码 src/sys_pub_node.cpp

apl 复制代码
/**
 * @file sys_status_publisher.cpp
 * @brief ROS2 系统状态发布节点
 *
 * 功能说明:
 *   本节点周期性地采集 Linux 系统的 CPU 使用率、内存用量、磁盘用量和开机时长,
 *   将这些数据封装为自定义消息 sys_interfaces::msg::SysStatus,
 *   然后通过 /system_status 话题以 10Hz(1秒10次) 频率对外发布。
 *
 * 数据来源:
 *   - CPU 使用率  → /proc/stat(内核统计文件)
 *   - 内存信息    → /proc/meminfo(内存统计文件)
 *   - 磁盘信息    → statvfs() 系统调用(读取根分区 "/")
 *   - 开机时长    → /proc/uptime(内核启动计时)
 *
 * 依赖:
 *   - rclcpp:ROS2 C++ 客户端库
 *   - sys_interfaces:自定义消息包(需先编译通过)
 */

// ========================== 头文件引入 ==========================

#include "rclcpp/rclcpp.hpp"
#include "sys_interfaces/msg/sys_status.hpp"
#include <cstdio>
#include <fstream>
#include <sstream>   // 用于更可靠的字符串解析(替代 sscanf)
#include <string>
#include <unistd.h>
#include <sys/statvfs.h>
#include <cmath>

using namespace std::chrono_literals;

// ========================== 节点类定义 ==========================

class SysStatusPublisher : public rclcpp::Node
{
public:
    SysStatusPublisher() : Node("sys_status_publisher")
    {
        publisher_ = this->create_publisher<sys_interfaces::msg::SysStatus>("/system_status", 10);
        timer_ = this->create_wall_timer(100ms, std::bind(&SysStatusPublisher::timer_callback, this));
        RCLCPP_INFO(this->get_logger(), "系统状态发布节点启动成功");
    }

private:
    // ========================== 成员变量 ==========================

    rclcpp::Publisher<sys_interfaces::msg::SysStatus>::SharedPtr publisher_;
    rclcpp::TimerBase::SharedPtr timer_;

    // CPU 瞬时采样用的历史值(用于计算两次采样的差值)
    long prev_total_ = 0;  // 上一次采样的 CPU 总时间片
    long prev_work_  = 0;  // 上一次采样的 CPU 工作时间片

    // ========================== 核心回调函数 ==========================

    void timer_callback()
    {
        auto msg = sys_interfaces::msg::SysStatus();

        // ==================== 1. 采集 CPU 使用率 ====================
        msg.cpu_usage = get_cpu_usage();

        // ==================== 2. 采集内存信息 ====================
        // 使用 std::istringstream 替代 sscanf,更可靠地解析 /proc/meminfo
        // /proc/meminfo 格式示例:
        //   MemTotal:       32660036 kB
        //   MemAvailable:   27755160 kB

        float mem_total_gb = 0.0f;
        float mem_available_gb = 0.0f;

        std::ifstream mem_file("/proc/meminfo");
        std::string line;
        while (std::getline(mem_file, line))
        {
            std::istringstream iss(line);
            std::string key;
            long value_kb;

            // 解析格式: "字段名: 数值 kB"
            // iss >> key 读取字段名(含冒号),>> value_kb 读取数值
            iss >> key >> value_kb;

            if (key == "MemTotal:")
            {
                // kB → GB:除以 1024 两次
                mem_total_gb = value_kb / 1024.0f / 1024.0f;
            }
            else if (key == "MemAvailable:")
            {
                mem_available_gb = value_kb / 1024.0f / 1024.0f;
            }
        }

        msg.mem_total = mem_total_gb;
        msg.mem_used = mem_total_gb - mem_available_gb;
        // 防止除以零(极端情况下 mem_total 可能为 0)
        if (msg.mem_total > 0.0f)
            msg.mem_usage = (msg.mem_used / msg.mem_total) * 100.0f;
        else
            msg.mem_usage = 0.0f;

        // ==================== 3. 采集磁盘信息 ====================
        struct statvfs disk_info;
        statvfs("/", &disk_info);

        // 块大小换算:字节 → MB
        float block_size_mb = disk_info.f_frsize / 1024.0f / 1024.0f;
        msg.disk_total = disk_info.f_blocks * block_size_mb;
        msg.disk_used = (disk_info.f_blocks - disk_info.f_bfree) * block_size_mb;

        // ==================== 4. 采集系统开机时长 ====================
        std::ifstream up_file("/proc/uptime");
        double up_sec;
        up_file >> up_sec;
        msg.uptime_sec = (int)up_sec;

        // ==================== 5. 发布消息 ====================
        publisher_->publish(msg);
    }

    // ========================== CPU 瞬时使用率 ==========================

    /**
     * @brief 获取 CPU 瞬时使用率(差值法)
     *
     * 原理:
     *   每次调用读取 /proc/stat,与上一次采样的值做差:
     *   delta_total = 本次总时间 - 上次总时间
     *   delta_work  = 本次工作时间 - 上次工作时间
     *   瞬时使用率 = delta_work / delta_total × 100
     *
     *   第一次调用时没有历史值,返回 0(因为没有差值可算)
     *   从第二次调用开始就能得到真实的瞬时 CPU 使用率
     *
     * @return float CPU 使用率百分比(0~100)
     */
    float get_cpu_usage()
    {
        std::ifstream stat_file("/proc/stat");
        std::string cpu_line;
        std::getline(stat_file, cpu_line);

        // 解析全部 8 个字段,确保总时间计算完整
        // cpu  user nice system idle iowait irq softirq steal
        long user, nice, system, idle, iowait, irq, softirq, steal;
        sscanf(cpu_line.c_str(), "cpu %ld %ld %ld %ld %ld %ld %ld %ld",
               &user, &nice, &system, &idle, &iowait, &irq, &softirq, &steal);

        // 总时间片 = 所有字段之和
        long total = user + nice + system + idle + iowait + irq + softirq + steal;
        // 工作时间 = 用户态 + 内核态(不含空闲和等待)
        long work = user + nice + system;

        float usage = 0.0f;
        if (prev_total_ > 0)
        {
            // 计算两次采样的差值
            long delta_total = total - prev_total_;
            long delta_work  = work  - prev_work_;

            // 防止除以零(理论上 delta_total 不会为 0,但保险起见)
            if (delta_total > 0)
                usage = (float)delta_work / delta_total * 100.0f;
        }

        // 保存本次采样值,供下次计算差值用
        prev_total_ = total;
        prev_work_  = work;

        return usage;
    }
};

// ========================== 程序入口 ==========================

int main(int argc, char * argv[])
{
    rclcpp::init(argc, argv);
    auto node = std::make_shared<SysStatusPublisher>();
    rclcpp::spin(node);
    rclcpp::shutdown();
    return 0;
}

3.3 CMakeLists.txt 配置(sys_publisher)

c 复制代码
cmake_minimum_required(VERSION 3.8)
project(sys_publisher)

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(sys_interfaces REQUIRED)

# 编译可执行文件
add_executable(pub_node src/sys_pub_node.cpp)
ament_target_dependencies(pub_node rclcpp sys_interfaces)

# 安装二进制文件
install(TARGETS
  pub_node
  DESTINATION lib/${PROJECT_NAME}
)

ament_package()

3.4 package.xml 依赖

复制代码
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>rclcpp</depend>
<depend>sys_interfaces</depend>

3.5 发布节点核心原理讲解

  1. rclcpp::Node:ROS2 所有节点基类,管理话题、定时器、日志
  2. create_publisher<消息类型>(话题名, 队列长度):创建发布对象,DDS 底层完成消息分发
  3. create_wall_timer:定时回调函数,周期性采集硬件数据
  4. /proc虚拟文件系统:Linux 内核导出硬件实时数据,无需第三方库读取 CPU / 内存
  5. rclcpp::spin(node):阻塞循环,处理节点所有事件(定时器、回调)

第四部分:步骤 3 Qt + ROS2 C++ 可视化订阅界面 sys_qt_gui

核心难点原理先行

  1. 线程冲突问题

    ROS2 订阅回调运行在 rclcpp 内部子线程,Qt UI 控件

    只能在主线程操作,直接在回调修改界面会闪退、卡死。

    解决方案:Qt 信号槽机制,子线程发送自定义信号,主线程槽函数更新 UI,线程安全。

  2. Qt 绘图 :使用QCustomPlot开源绘图组件绘制 CPU / 内存实时曲线

  3. ROS2 订阅逻辑:创建订阅器,收到SysStatus消息触发回调,转发数据给 Qt 界面

4.1 安装 Qt 依赖与 QCustomPlot

复制代码
sudo apt install qtbase5-dev libqt5charts5-dev
# QCustomPlot 绘图库安装
mkdir -p ~/QCustomPlot
# 从官网下载完整版 2.1.1 并解压
wget https://www.qcustomplot.com/release/2.1.1/QCustomPlot-source.tar.gz -O - | tar -xz -C ~/QCustomPlot
#验证
find ~/QCustomPlot -name "qcustomplot.*"

4.2 创建 Qt+ROS 包

复制代码
cd ~/ros2_qt_sys_ws/src
#
ros2 pkg create --build-type ament_cmake sys_qt_gui --dependencies rclcpp sys_interfaces Qt5Core Qt5Gui Qt5Widgets

cd sys_qt_gui
mkdir  ui

4.3 界面 UI 文件 ui/mainwindow.ui(Qt Designer 格式)

复制代码
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>MainWindow</class>
 <widget class="QMainWindow" name="MainWindow">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>1000</width>
    <height>700</height>
   </rect>
  </property>
  <widget class="QWidget" name="centralwidget">
   <layout class="QVBoxLayout" name="verticalLayout">
    <item>
     <widget class="QGroupBox" name="groupInfo">
      <property name="title">
       <string>系统状态文本信息</string>
      </property>
      <layout class="QFormLayout" name="formLayout">
       <item row="0" column="0">
        <widget class="QLabel" name="labCpu"/>
       </item>
       <item row="0" column="1">
        <widget class="QLabel" name="valCpu"/>
       </item>
       <item row="1" column="0">
        <widget class="QLabel" name="labMem"/>
       </item>
       <item row="1" column="1">
        <widget class="QLabel" name="valMem"/>
       </item>
       <item row="2" column="0">
        <widget class="QLabel" name="labDisk"/>
       </item>
       <item row="2" column="1">
        <widget class="QLabel" name="valDisk"/>
       </item>
       <item row="3" column="0">
        <widget class="QLabel" name="labUp"/>
       </item>
       <item row="3" column="1">
        <widget class="QLabel" name="valUp"/>
       </item>
      </layout>
     </widget>
    </item>
    <item>
     <widget class="QWidget" name="plotWidget"/>
    </item>
   </layout>
  </widget>
 </widget>
 <resources/>
 <connections/>
</ui>

4.4 头文件 src/mainwindow.h

复制代码
/**
 * @file MainWindow.h
 * @brief 主窗口头文件 --- ROS2 系统状态监控界面
 *
 * 功能说明:
 *   本窗口作为 ROS2 + Qt 融合应用的主界面,负责:
 *     1. 订阅 /system_status 话题,接收 SysStatus 消息
 *     2. 使用 QCustomPlot 实时绘制 CPU、内存使用率曲线
 *     3. 处理 ROS2 子线程与 Qt 主线程之间的跨线程通信
 *
 * 架构要点(跨线程通信):
 *   ROS2 的 spin 运行在独立子线程中,订阅回调也在子线程触发。
 *   Qt 的 UI 操作必须在主线程(GUI线程)执行,直接跨线程操作 UI 会导致崩溃。
 *   因此采用「信号-槽」机制做线程安全的数据传递:
 *
 *     子线程(ROS回调)                    主线程(Qt GUI)
 *     ┌─────────────┐     Qt::QueuedConnection     ┌──────────────┐
 *     │ sys_callback │ ──emit sig_update_ui()──→   │ slot_refresh │
 *     │  接收消息    │     自动排队,跨线程安全      │  更新曲线UI  │
 *     └─────────────┘                              └──────────────┘
 */

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

// ========================== 头文件引入 ==========================

// Qt 主窗口基类,提供菜单栏、状态栏、工具栏等标准窗口组件
#include <QMainWindow>
// Qt 定时器,用于周期性触发绘图刷新(如每 100ms 刷新一次曲线)
#include <QTimer>
// Qt 动态数组容器,存储曲线历史数据点(类似 std::vector,但与 Qt API 兼容更好)
#include <QVector>
// QCustomPlot 第三方绘图库头文件,提供高性能 2D 图表绑定能力
// 注意:该文件需要从官网或 GitLab 获取后放入项目目录
#include "qcustomplot.h"
// 自定义 ROS2 消息类型头文件(由 sys_interfaces 包中的 SysStatus.msg 自动生成)
#include "sys_interfaces/msg/sys_status.hpp"

// ROS2 C++ 客户端库,提供 Node、Subscription 等核心通信能力
#include "rclcpp/rclcpp.hpp"

// ========================== Qt UI 命名空间 ==========================

// QT_BEGIN_NAMESPACE / QT_END_NAMESPACE 是 Qt 的宏,
// 将 Ui 命名空间包裹在 QT_BEGIN_NAMESPACE 和 QT_END_NAMESPACE 之间,
// 确保在 Qt 内部命名空间中正确声明。
// Ui::MainWindow 由 Qt Designer 的 .ui 文件自动生成(ui_mainwindow.h),
// 包含界面上所有控件的指针(如 QLabel、QCustomPlot 等)。
QT_BEGIN_NAMESPACE
namespace Ui { class MainWindow; }
QT_END_NAMESPACE

// ========================== 主窗口类定义 ==========================

/**
 * @class MainWindow
 * @brief 系统状态监控主窗口
 *
 * 继承自 QMainWindow,整合了 ROS2 通信和 Qt GUI:
 *   - ROS2 侧:创建节点、订阅话题、接收系统状态数据
 *   - Qt 侧:使用 QCustomPlot 绘制实时曲线、定时刷新界面
 *
 * Q_OBJECT 宏是 Qt 元对象系统(MOC)的入口标记:
 *   - 凡是有信号(signals)、槽(slots)的类都必须包含此宏
 *   - 编译时 moc 工具会扫描它,生成信号-槽连接所需的元数据代码
 *   - 缺少此宏 → 信号槽不生效,connect() 静默失败
 */
class MainWindow : public QMainWindow
{
    Q_OBJECT  // ← 必须有,否则信号槽无法工作

public:
    /**
     * @brief 构造函数
     * @param parent 父窗口指针,默认为 nullptr(顶层窗口)
     *
     * 在构造函数中需要完成:
     *   1. 初始化 Qt UI(ui->setupUi)
     *   2. 创建 ROS2 节点和订阅器
     *   3. 初始化 QCustomPlot 图表(坐标轴、图例、曲线)
     *   4. 连接跨线程信号-槽
     *   5. 启动定时器周期刷新
     */
    MainWindow(QWidget *parent = nullptr);

    /**
     * @brief 析构函数
     *
     * 需要清理的资源:
     *   - 停止 ROS2 spin 线程
     *   - 释放 UI 对象(delete ui)
     *   - 释放 QCustomPlot 对象
     */
    ~MainWindow();

    // ========================== ROS2 成员变量(public) ==========================
    // 放在 public 区域是因为构造时需要在外部初始化,
    // 实际项目中也可以封装为 private + getter,这里为简洁直接公开。

    /**
     * @brief ROS2 节点共享指针
     *
     * 每个 ROS2 程序至少需要一个 Node 实例,它是通信的入口:
     *   - 订阅话题、发布话题、创建服务、创建定时器都通过 Node 完成
     *   - SharedPtr = std::shared_ptr,自动管理生命周期
     *   - 本节点用于订阅 /system_status 话题
     */
    rclcpp::Node::SharedPtr ros_node_;

    /**
     * @brief 话题订阅器共享指针
     *
     * 模板参数:订阅的消息类型 sys_interfaces::msg::SysStatus
     * 订阅 /system_status 话题后,每当有新消息到达,
     * ROS2 会在 spin 线程中调用绑定的回调函数 sys_callback()。
     *
     * 注意:必须持有该指针,否则订阅器被析构后订阅立即失效。
     */
    rclcpp::Subscription<sys_interfaces::msg::SysStatus>::SharedPtr sub_;

    // ========================== 曲线数据存储 ==========================
    // 使用 QVector<double> 存储历史数据点,传给 QCustomPlot 绘图。
    // QCustomPlot 的 setData() 接口接收 QVector<double> 类型的 x/y 数组。

    /** CPU 使用率历史数据(y 轴值),与 x_time 一一对应 */
    QVector<double> cpu_data;

    /** 内存使用率历史数据(y 轴值),与 x_time 一一对应 */
    QVector<double> mem_data;

    /** 时间轴数据(x 轴值),每次回调 +1 作为横坐标刻度 */
    QVector<double> x_time;

    /**
     * @brief 时间计数器
     *
     * 每收到一帧消息 +1,作为 x 轴坐标值。
     * 也可以改为用实际时间戳(秒),这样 x 轴就代表真实时间。
     * 当前用计数器是为了简单,x 轴单位 = "帧数"。
     */
    double time_cnt = 0;

private:
    // ========================== UI 与绘图组件 ==========================

    /**
     * @brief Qt Designer 生成的 UI 指针
     *
     * 指向 ui_mainwindow.h 中自动生成的 Ui::MainWindow 实例,
     * 通过 ui->xxx 可以访问 .ui 文件中拖拽放置的所有控件。
     * 在构造函数中 setupUi(this) 初始化,析构函数中 delete。
     */
    Ui::MainWindow *ui;

    /**
     * @brief QCustomPlot 绘图对象指针
     *
     * QCustomPlot 是一个 QWidget 子类,可以直接嵌入 Qt 布局。
     * 它支持多条曲线、坐标轴自定义、图例、鼠标交互、导出图片等功能。
     * 本项目中用于实时绘制 CPU 和内存使用率的时间序列曲线。
     *
     * 典型用法:
     *   plot->addGraph();                          // 添加一条曲线
     *   plot->graph(0)->setData(x_time, cpu_data); // 设置数据
     *   plot->replot();                            // 刷新绘制
     */
    QCustomPlot *plot;

    // ========================== 内部函数 ==========================

    /**
     * @brief ROS2 订阅回调函数(在子线程中执行!)
     *
     * @param msg 接收到的 SysStatus 消息共享指针
     *
     * 调用时机:每当 /system_status 话题有新消息时,
     *           ROS2 spin 线程自动调用此函数。
     *
     * 线程安全警告:
     *   此函数运行在 ROS2 的 spin 子线程中,
     *   绝对不能在这里直接操作 Qt UI 控件(如 setText、addGraph 等),
     *   否则会触发 Qt 的跨线程断言甚至段错误崩溃。
     *
     * 正确做法:
     *   通过 emit sig_update_ui(msg) 发射信号,
     *   Qt 的信号-槽机制会自动将调用排队到主线程执行。
     */
    void sys_callback(const sys_interfaces::msg::SysStatus::SharedPtr msg);

    /**
     * @brief 绘图刷新函数(在主线程中调用)
     *
     * 将 QVector 中的历史数据设置到 QCustomPlot 曲线对象,
     * 调整坐标轴范围,然后调用 replot() 重绘画面。
     * 由定时器或槽函数触发。
     */
    void update_plot();

// ========================== 信号声明区 ==========================
// signals 是 Qt 扩展的 C++ 关键字(由 Q_OBJECT 宏激活),
// 信号只需要声明,不需要实现(MOC 工具自动生成实现代码)。

signals:
    /**
     * @brief 跨线程更新信号
     *
     * @param data 接收到的系统状态消息(共享指针,线程安全传递)
     *
     * 发射方:sys_callback()(子线程)
     * 接收方:slot_refresh_ui()(主线程)
     *
     * 连接方式:Qt::QueuedConnection(自动选择,因为跨线程)
     *   - 信号发射后,槽函数不会立即执行
     *   - 而是放入主线程的事件队列,等主线程空闲时执行
     *   - 这就保证了槽函数始终在主线程运行,可以安全操作 UI
     */
    void sig_update_ui(sys_interfaces::msg::SysStatus::SharedPtr data);

// ========================== 槽函数声明区 ==========================
// private slots = 只能在类内部被触发的槽(外部不能直接调用),
// 但通过信号 connect 后,信号发射时会自动调用。

private slots:
    /**
     * @brief 主线程 UI 刷新槽函数
     *
     * @param data 从子线程传递过来的系统状态消息
     *
     * 执行流程:
     *   1. 从 data 中提取 cpu_usage、mem_usage 等字段
     *   2. 将数据追加到 cpu_data、mem_data、x_time 容器中
     *   3. 可选:限制容器长度(如只保留最近 100 个点,防止内存无限增长)
     *   4. 调用 update_plot() 刷新 QCustomPlot 曲线
     *   5. 可选:更新界面上的数值标签(如 "CPU: 35.2%")
     *
     * 线程安全保证:
     *   由于通过 QueuedConnection 连接,此函数始终在主线程执行,
     *   可以放心操作所有 Qt UI 控件和 QCustomPlot 对象。
     */
    void slot_refresh_ui(sys_interfaces::msg::SysStatus::SharedPtr data);
};

// 告诉 Qt 元对象系统这个类型存在
Q_DECLARE_METATYPE(sys_interfaces::msg::SysStatus::SharedPtr)

#endif // MAINWINDOW_H

4.5 实现文件 src/mainwindow.cpp

复制代码
#include "mainwindow.h"
#include "ui_mainwindow.h"
#include <QDateTime>
#include <QThread>

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent), ui(new Ui::MainWindow)
{
    ui->setupUi(this);
    this->setWindowTitle("ROS2 系统监控可视化工具");

    // 1. 初始化绘图控件
    plot = new QCustomPlot(this);
    plot->setParent(ui->plotWidget);
    QVBoxLayout *lay = new QVBoxLayout(ui->plotWidget);
    lay->addWidget(plot);

    // 创建两条曲线:CPU、内存
    plot->addGraph();
    plot->graph(0)->setName("CPU使用率 %");
    plot->graph(0)->setPen(QPen(Qt::red));

    plot->addGraph();
    plot->graph(1)->setName("内存使用率 %");
    plot->graph(1)->setPen(QPen(Qt::blue));

    plot->legend->setVisible(true);
    plot->xAxis->setLabel("时间");
    plot->yAxis->setLabel("使用率 %");
    plot->yAxis->setRange(0, 100);
    // 注册自定义类型,让 QueuedConnection 能拷贝这个参数
    qRegisterMetaType<sys_interfaces::msg::SysStatus::SharedPtr>(
        "sys_interfaces::msg::SysStatus::SharedPtr");

    // 然后再 connect
    connect(this, &MainWindow::sig_update_ui,
            this, &MainWindow::slot_refresh_ui,
            Qt::QueuedConnection);
    // 2. 绑定信号槽:ROS子线程信号 → UI主线程更新
    connect(this, &MainWindow::sig_update_ui,
            this, &MainWindow::slot_refresh_ui,
            Qt::QueuedConnection); // 队列连接,跨线程安全

    // 3. 创建ROS2节点 + 订阅器
    ros_node_ = rclcpp::Node::make_shared("sys_qt_sub_node");
    sub_ = ros_node_->create_subscription<sys_interfaces::msg::SysStatus>(
        "/system_status",
        10,
        std::bind(&MainWindow::sys_callback, this, std::placeholders::_1));

    // 新开独立线程循环处理ROS回调,不阻塞Qt界面
    std::thread ros_thread([this]()
                           { rclcpp::spin(this->ros_node_); });
    // detach线程,主窗口析构时ROS节点会自动析构,线程结束
    ros_thread.detach();
}

MainWindow::~MainWindow()
{
    delete ui;
}

// ROS订阅回调:运行在ROS子线程,只发信号,不操作UI
void MainWindow::sys_callback(const sys_interfaces::msg::SysStatus::SharedPtr msg)
{
    emit sig_update_ui(msg); // 发送信号交给主线程处理UI
}

// 主线程槽函数:安全更新所有UI文本与曲线
void MainWindow::slot_refresh_ui(sys_interfaces::msg::SysStatus::SharedPtr data)
{
    // 更新文本标签
    ui->labCpu->setText("CPU使用率:");
    ui->valCpu->setText(QString("%1 %").arg(data->cpu_usage, 0, 'f', 2));

    ui->labMem->setText(QString("内存 %1GB / %2GB").arg(data->mem_used, 0, 'f', 2).arg(data->mem_total, 0, 'f', 2));
    ui->valMem->setText(QString("%1 %").arg(data->mem_usage, 0, 'f', 2));

    ui->labDisk->setText(QString("磁盘 %1GB / %2GB").arg(data->disk_used, 0, 'f', 2).arg(data->disk_total, 0, 'f', 2));
    ui->valDisk->setText("");

    // 开机时间换算时分秒
    int sec = data->uptime_sec;
    int h = sec / 3600;
    int m = (sec % 3600) / 60;
    int s = sec % 60;
    ui->labUp->setText("系统运行时长");
    ui->valUp->setText(QString("%1时%2分%3秒").arg(h).arg(m).arg(s));

    // 填充曲线数据
    time_cnt += 0.1;
    x_time.append(time_cnt);
    cpu_data.append(data->cpu_usage);
    mem_data.append(data->mem_usage);

    // 限制曲线长度,最多100个点
    if (x_time.size() > 100)
    {
        x_time.pop_front();
        cpu_data.pop_front();
        mem_data.pop_front();
    }
    update_plot();
}

// 刷新绘图曲线
void MainWindow::update_plot()
{
    plot->graph(0)->setData(x_time, cpu_data);
    plot->graph(1)->setData(x_time, mem_data);
    plot->xAxis->rescale();
    plot->replot();
}

4.6 程序入口 src/main.cpp

复制代码
#include <QApplication>
#include "mainwindow.h"
#include "rclcpp/rclcpp.hpp"

int main(int argc, char *argv[])
{
    rclcpp::init(argc, argv);
    QApplication a(argc, argv);
    MainWindow w;
    w.show();
    int ret = a.exec(); // Qt主线程事件循环
    rclcpp::shutdown();
    return ret;
}

4.7 CMakeLists.txt(关键 Qt 编译配置)

复制代码
cmake_minimum_required(VERSION 3.8)
project(sys_qt_gui)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

# Qt 启用 MOC、UIC、RCC
set(CMAKE_INCLUDE_CURRENT_DIR ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)

# Qt 依赖
find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets PrintSupport)
# ROS 依赖
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(sys_interfaces REQUIRED)

# QCustomPlot 源码路径(统一用变量管理,后续改路径只改这一处)
set(QCUSTOMPLOT_DIR $ENV{HOME}/QCustomPlot/qcustomplot-source)


set(CMAKE_AUTOUIC_SEARCH_PATHS ${CMAKE_CURRENT_SOURCE_DIR}/ui)
# QCustomPlot 头文件搜索路径
include_directories(${QCUSTOMPLOT_DIR})

# 收集项目自身的源码和 UI 文件
file(GLOB UI_FILES ui/*.ui)
file(GLOB SRC_FILES src/*.cpp src/*.h)

# 编译可执行程序
add_executable(sys_gui_node
  ${SRC_FILES}
  ${UI_FILES}
  ${QCUSTOMPLOT_DIR}/qcustomplot.cpp
  ${QCUSTOMPLOT_DIR}/qcustomplot.h
)

# 链接 Qt 库
target_link_libraries(sys_gui_node Qt5::Core Qt5::Gui Qt5::Widgets Qt5::PrintSupport)
# ROS 依赖(自动处理头文件路径和链接)
ament_target_dependencies(sys_gui_node rclcpp sys_interfaces)

# 安装节点到 ROS2 包的 lib 目录
install(TARGETS
  sys_gui_node
  DESTINATION lib/${PROJECT_NAME}
)

ament_package()

4.8 package.xml

复制代码
<buildtool_depend>ament_cmake</buildtool_depend>
<buildtool_depend>qtbase5-dev</buildtool_depend>
<depend>rclcpp</depend>
<depend>sys_interfaces</depend>
<depend>Qt5Core</depend>
<depend>Qt5Gui</depend>
<depend>Qt5Widgets</depend>

4.9 Qt 订阅模块核心原理详解

  1. 双线程模型

    • Qt 主线程:a.exec() 处理界面刷新、鼠标绘图

    • 独立 std::thread:执行rclcpp::spin,接收 ROS 话题消息

      分离后 ROS 消息接收不会卡顿界面

  2. 信号槽跨线程安全

    复制代码
    Qt::QueuedConnection

    :子线程发送信号时,数据存入队列,主线程空闲时执行 UI 更新,杜绝多线程争抢 UI 控件崩溃

  3. QCustomPlot 绘图原理:

    维护双数组存储时序 CPU、内存数据,每次收到消息追加数据,超过长度自动丢弃旧数据,调用

    复制代码
    replot()

    刷新画布实现实时动态曲线

  4. ROS 订阅流程:

    订阅器绑定回调 → DDS 收到话题数据包 → 触发回调 → 发送 UI 更新信号

第五部分:整体编译与运行完整步骤

5.1 编译工作空间

复制代码
cd ~/ros2_qt_sys_ws
colcon build --packages-select sys_interfaces sys_publisher sys_qt_gui
source install/setup.bash

5.2 新开终端 1:启动发布节点(采集系统数据)

复制代码
source ~/ros2_qt_sys_ws/install/setup.bash
ros2 run sys_publisher pub_node

5.3 新开终端 2:启动 Qt 可视化界面

复制代码
source ~/ros2_qt_sys_ws/install/setup.bash
ros2 run sys_qt_gui sys_gui_node

弹出 Qt 窗口:左侧文本展示实时 CPU / 内存 / 磁盘 / 开机时间,下方红色 CPU 曲线、蓝色内存曲线实时滚动刷新

5.4 调试验证命令(可选)

查看话题是否正常发布

复制代码
# 查看话题列表
ros2 topic list
# 打印话题原始消息
ros2 topic echo /system_status

第六部分:常见 0 基础踩坑排错

  1. 自定义消息找不到头文件

    复制代码
    sys_interfaces
    
    source install/setup.bash
  2. Qt 界面闪退、段错误

    直接在 ROS 回调修改 UI 控件,没有使用信号槽跨线程转发,改为信号槽模式

  3. QCustomPlot 绘图空白

    检查头文件路径是否正确、ui 布局是否挂载 plot 控件

  4. colcon 编译 Qt 报错 MOC 未开启

    CMakeLists.txt 必须开启

    复制代码
    CMAKE_AUTOMOC ON
  5. ROS spin 阻塞 Qt 界面

    rclcpp::spin 不能放在 Qt 主线程,必须创建独立 std::thread 运行 spin

第七部分:整体流程总结及github上传

  1. 定义.msg 自定义消息,编译生成 ROS2 通信数据结构
  2. 发布节点读取 Linux /proc 硬件数据,封装消息定时发布话题
  3. Qt 程序创建独立线程运行 ROS 订阅,收到消息通过信号槽转发主线程
  4. Qt 主线程更新文本标签 + QCustomPlot 动态绘制 CPU 内存趋势图
  5. 底层通信基于 ROS2 DDS,发布订阅完全解耦,可分离部署在多台机器监控远程设备

先把 README.md 和 .gitignore 复制到你的 Ubuntu 机器上(比如放到 ~/ros2_qt_sys_ws/src/ 下),然后在 Ubuntu 终端执行以下命令:1. 初始化仓库并提交:cd ~/ros2_qt_sys_ws/src

初始化 git(如果还没 init 过)

git init

配置你的 git 信息(只需执行一次)

git config user.name "Tang Shuyang"

git config user.email "3196263280@qq.com"

添加所有文件

git add .

首次提交

git commit -m "init: ROS2 Humble 系统状态监控项目

  • sys_interfaces: 自定义 SysStatus 消息接口
  • sys_publisher: CPU/内存/磁盘数据采集与发布节点
  • sys_qt_gui: Qt5 + QCustomPlot 实时监控界面"
    1. 关联远程仓库并推送:# 添加远程仓库(先在 GitHub 上创建好 ros2_sys_monitor 空仓库)
      git remote add origin https://github.com/XtangUser/ros2_sys_monitor.git

推送到远程

git branch -M main

git push -u origin main

输入密码的是token,需要自己去创建!

闪退、段错误

直接在 ROS 回调修改 UI 控件,没有使用信号槽跨线程转发,改为信号槽模式

  1. QCustomPlot 绘图空白

    检查头文件路径是否正确、ui 布局是否挂载 plot 控件

  2. colcon 编译 Qt 报错 MOC 未开启

    CMakeLists.txt 必须开启

    复制代码
    CMAKE_AUTOMOC ON
  3. ROS spin 阻塞 Qt 界面

    rclcpp::spin 不能放在 Qt 主线程,必须创建独立 std::thread 运行 spin

第七部分:整体流程总结及github上传

  1. 定义.msg 自定义消息,编译生成 ROS2 通信数据结构
  2. 发布节点读取 Linux /proc 硬件数据,封装消息定时发布话题
  3. Qt 程序创建独立线程运行 ROS 订阅,收到消息通过信号槽转发主线程
  4. Qt 主线程更新文本标签 + QCustomPlot 动态绘制 CPU 内存趋势图
  5. 底层通信基于 ROS2 DDS,发布订阅完全解耦,可分离部署在多台机器监控远程设备

先把 README.md 和 .gitignore 复制到你的 Ubuntu 机器上(比如放到 ~/ros2_qt_sys_ws/src/ 下),然后在 Ubuntu 终端执行以下命令:1. 初始化仓库并提交:cd ~/ros2_qt_sys_ws/src

初始化 git(如果还没 init 过)

git init

配置你的 git 信息(只需执行一次)

git config user.name "Tang Shuyang"

git config user.email "3196263280@qq.com"

添加所有文件

git add .

首次提交

git commit -m "init: ROS2 Humble 系统状态监控项目

  • sys_interfaces: 自定义 SysStatus 消息接口
  • sys_publisher: CPU/内存/磁盘数据采集与发布节点
  • sys_qt_gui: Qt5 + QCustomPlot 实时监控界面"
    1. 关联远程仓库并推送:# 添加远程仓库(先在 GitHub 上创建好 ros2_sys_monitor 空仓库)
      git remote add origin https://github.com/XtangUser/ros2_sys_monitor.git

推送到远程

git branch -M main

git push -u origin main

输入密码的是token,需要自己去创建!

注意: 先在浏览器里到 GitHub 创建一个名为 ros2_sys_monitor 的空仓库(不要勾选 README/gitignore 初始化),然后再执行上面的 push 命令。