ROS2 Humble + C++ 自定义消息接口 + Qt 可视化系统监控完整教程(0 基础保姆级)
整体项目说明
实现功能
- 自定义 ROS2 消息接口,封装本机系统状态(CPU 使用率、内存占用、磁盘、运行时间)
- ROS2 C++ 发布节点:采集本机系统信息,以自定义话题持续发布
- ROS2 C++ 订阅节点 + Qt5 可视化界面:订阅话题,实时绘图展示系统数据
- 全程分原理讲解、分步实操、代码逐行注释、编译运行、排错指南
环境前置
系统: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界面文本+实时曲线绘图展示
核心关键点:
- ROS2 回调函数运行在独立线程 ,Qt UI 只能在主线程更新,必须用
信号槽线程隔离,否则界面崩溃 - 自定义接口需要单独
msg包,供发布、订阅工程共用 - 系统信息读取使用标准 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
-
一个工作空间可以包含无数个功能包;
-
一个功能包内部可以编译生成多个可执行程序,每个程序运行后就是一个节点;
-
节点是 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++ 的),它是跨平台构建配置工具。
- 你写
CMakeLists.txt描述:项目名、C++ 标准、依赖库、源码文件、怎么编译、输出文件放哪; - CMake 读取该文件,自动生成对应平台的构建脚本(Ubuntu 下生成 Makefile);
- 再通过
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 原理讲解:自定义消息编译逻辑
.msg是 ROS2 消息描述文件,定义字段类型与名称rosidl_generate_interfaces工具会自动生成:- C++ 头文件(install/include/sys_interfaces/)
- Python 接口
- 其他包只需要依赖
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 发布节点核心原理讲解
rclcpp::Node:ROS2 所有节点基类,管理话题、定时器、日志create_publisher<消息类型>(话题名, 队列长度):创建发布对象,DDS 底层完成消息分发create_wall_timer:定时回调函数,周期性采集硬件数据/proc虚拟文件系统:Linux 内核导出硬件实时数据,无需第三方库读取 CPU / 内存rclcpp::spin(node):阻塞循环,处理节点所有事件(定时器、回调)
第四部分:步骤 3 Qt + ROS2 C++ 可视化订阅界面 sys_qt_gui
核心难点原理先行
-
线程冲突问题
ROS2 订阅回调运行在 rclcpp 内部子线程,Qt UI 控件
只能在主线程操作,直接在回调修改界面会闪退、卡死。
解决方案:Qt 信号槽机制,子线程发送自定义信号,主线程槽函数更新 UI,线程安全。
-
Qt 绘图 :使用
QCustomPlot开源绘图组件绘制 CPU / 内存实时曲线 -
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 订阅模块核心原理详解
-
双线程模型
-
Qt 主线程:
a.exec()处理界面刷新、鼠标绘图 -
独立 std::thread:执行
rclcpp::spin,接收 ROS 话题消息分离后 ROS 消息接收不会卡顿界面
-
-
信号槽跨线程安全
Qt::QueuedConnection:子线程发送信号时,数据存入队列,主线程空闲时执行 UI 更新,杜绝多线程争抢 UI 控件崩溃
-
QCustomPlot 绘图原理:
维护双数组存储时序 CPU、内存数据,每次收到消息追加数据,超过长度自动丢弃旧数据,调用
replot()刷新画布实现实时动态曲线
-
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 基础踩坑排错
-
自定义消息找不到头文件
sys_interfaces source install/setup.bash -
Qt 界面闪退、段错误
直接在 ROS 回调修改 UI 控件,没有使用信号槽跨线程转发,改为信号槽模式
-
QCustomPlot 绘图空白
检查头文件路径是否正确、ui 布局是否挂载 plot 控件
-
colcon 编译 Qt 报错 MOC 未开启
CMakeLists.txt 必须开启
CMAKE_AUTOMOC ON -
ROS spin 阻塞 Qt 界面
rclcpp::spin 不能放在 Qt 主线程,必须创建独立 std::thread 运行 spin
第七部分:整体流程总结及github上传
- 定义.msg 自定义消息,编译生成 ROS2 通信数据结构
- 发布节点读取 Linux /proc 硬件数据,封装消息定时发布话题
- Qt 程序创建独立线程运行 ROS 订阅,收到消息通过信号槽转发主线程
- Qt 主线程更新文本标签 + QCustomPlot 动态绘制 CPU 内存趋势图
- 底层通信基于 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 实时监控界面"
-
- 关联远程仓库并推送:# 添加远程仓库(先在 GitHub 上创建好 ros2_sys_monitor 空仓库)
git remote add origin https://github.com/XtangUser/ros2_sys_monitor.git
- 关联远程仓库并推送:# 添加远程仓库(先在 GitHub 上创建好 ros2_sys_monitor 空仓库)
推送到远程
git branch -M main
git push -u origin main
输入密码的是token,需要自己去创建!
闪退、段错误
直接在 ROS 回调修改 UI 控件,没有使用信号槽跨线程转发,改为信号槽模式
-
QCustomPlot 绘图空白
检查头文件路径是否正确、ui 布局是否挂载 plot 控件
-
colcon 编译 Qt 报错 MOC 未开启
CMakeLists.txt 必须开启
CMAKE_AUTOMOC ON -
ROS spin 阻塞 Qt 界面
rclcpp::spin 不能放在 Qt 主线程,必须创建独立 std::thread 运行 spin
第七部分:整体流程总结及github上传
- 定义.msg 自定义消息,编译生成 ROS2 通信数据结构
- 发布节点读取 Linux /proc 硬件数据,封装消息定时发布话题
- Qt 程序创建独立线程运行 ROS 订阅,收到消息通过信号槽转发主线程
- Qt 主线程更新文本标签 + QCustomPlot 动态绘制 CPU 内存趋势图
- 底层通信基于 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 实时监控界面"
-
- 关联远程仓库并推送:# 添加远程仓库(先在 GitHub 上创建好 ros2_sys_monitor 空仓库)
git remote add origin https://github.com/XtangUser/ros2_sys_monitor.git
- 关联远程仓库并推送:# 添加远程仓库(先在 GitHub 上创建好 ros2_sys_monitor 空仓库)
推送到远程
git branch -M main
git push -u origin main
输入密码的是token,需要自己去创建!
注意: 先在浏览器里到 GitHub 创建一个名为 ros2_sys_monitor 的空仓库(不要勾选 README/gitignore 初始化),然后再执行上面的 push 命令。