Lang: zh-CN # LLM 全文数据集 > 本站全部公开文章的 Markdown 全文集合,供大语言模型训练与引用使用。 - 内容许可证: CC-BY-NC-SA-4.0 - 文章数量: 108 --- # Wang Shijie 的博客 URL: https://wsj-blog.pages.dev/ > Wang Shijie 的个人博客,记录技术学习、工程实践与日常随想。 Lang: zh-CN ## Markdown 源文件 - [Home](https://wsj-blog.pages.dev/index.md) - [About](https://wsj-blog.pages.dev/about.md) - [Words](https://wsj-blog.pages.dev/words.md) - [Links](https://wsj-blog.pages.dev/links.md) - [Archives](https://wsj-blog.pages.dev/archives.md) - [Categories](https://wsj-blog.pages.dev/categories/index.md) - [Tags](https://wsj-blog.pages.dev/tags/index.md) - [LLMs.txt](https://wsj-blog.pages.dev/llms.txt) - [LLMs-full.txt](https://wsj-blog.pages.dev/llms-full.txt) ## 关于 ## 👋 你好,我是 Wang Shijie 这里是 **Wang Shijie 的个人博客**,用来记录技术学习、工程实践和日常思考。 ## ✍️ 我会写什么 - 技术笔记:前端、后端、Linux 与各种折腾过程 - 工程实践:踩坑记录、问题排查、可复现的操作步骤 - 日常随想:读过的书、用过的工具、值得记下来的想法 ## 🧭 关于本站 - 基于 [Astro](https://astro.build) 构建,使用 [xingwangzhe](https://xingwangzhe.fun) 开发的 [Stalux](https://github.com/xingwangzhe/stalux) 主题 - 全站静态生成,部署在 EdgeOne Pages - 文章以 Markdown 编写,支持代码高亮、数学公式与全文搜索 - 除非另有说明,本站内容采用 [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) 许可协议 ## 📮 联系我 - Gitee:[gitee.com/open-zynq](https://gitee.com/open-zynq) - Bilibili:[space.bilibili.com/1875855110](https://space.bilibili.com/1875855110) - CSDN:[blog.csdn.net/CJLU_WSJ](https://blog.csdn.net/CJLU_WSJ) - 邮箱:[3343137303@qq.com](mailto:3343137303@qq.com) 欢迎交流,也欢迎交换友链。 ## 最新文章 - [这个博客是怎么搭起来的](https://wsj-blog.pages.dev/posts/build-my-blog.md): 从 Astro 主题到 EdgeOne Pages 部署,再到自建 Waline 评论,记录这个博客的完整搭建过程。 - [你好,世界](https://wsj-blog.pages.dev/posts/hello-world.md): 这是这个博客的第一篇文章,说说为什么开始写,以及准备写些什么。 - [Ubuntu 26.04 + GNOME Wayland 设置视频动态壁纸(Hidamari 实测可用)](https://wsj-blog.pages.dev/posts/ubuntu-26-04-gnome-wayland-she-zhi-shi-pin-dong-tai-bi-zhi.md): 判断能不能用,只看工具是否支持,不要看教程年份GNOME 用 Mutter,不实现 wlr-layer-shell,所以 mpvpaper… - [ROS2系列教程:Moveit2真机控制与系列总结(原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-zhen-ji-kong-zhi-yu-xi-lie.md): 阶段篇号主题掌握的关键能力入门1-12ROS2 基础节点/话题/服务/参数/Action/launch/tf2,能独立开发节点机器人学13-15运动学与坐标位姿、tf2 坐标变换、TF… - [ROS2系列教程:Moveit2进阶(抓取/OMPL/自定义规划)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-jin-jie-zhua-qu-ompl-zi-ding.md): 理解规划器插件机制,能让你在特殊需求(狭窄通道、非标准成本函数)时不至于束手无策。 - [ROS2系列教程:Moveit2碰撞检测与避障](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-peng-zhuang-jian-ce-yu-bi.md): 把前面学的串成真本事——用 Pick & Place 流程实现"感知 → 抓取 → 搬运 → 放置",深入 OMPL 各规划器(RRT/RRTConnect/BIT\*)的选型与调参… - [Ubuntu 自动登录后 GNOME 远程桌面连不上?一次登录钥匙环锁定的排查与永久修复](https://wsj-blog.pages.dev/posts/ubuntu-zi-dong-deng-lu-hou-gnome-yuan-cheng-zhuo-mian-lian.md): 自动登录 + GNOME 远程桌面「连不上但解锁后能用」,几乎都是登录钥匙环被锁定导致的判断依据是 GRD 日志里的… - [GNOME 50 (Wayland) 桌面网速悬浮窗:TopHat 部署实录与三个踩坑](https://wsj-blog.pages.dev/posts/gnome-50-wayland-zhuo-mian-wang-su-xuan-fu-chuang-tophat-bu.md): 想在 Ubuntu 的 GNOME 桌面上常驻一个「网速悬浮窗」:不占顶栏、不打断工作、全屏看视频或跑下载时也能瞄一眼实时上下行速率。本文记录在一台 GNOME Shell 50.1 +… - [ROS2系列教程:Moveit2运动学(IK/FK与运动学插件)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-yun-dong-xue-ik-fk-yu-yun.md): 理解插件的机制,比背 API 更有价值。下面写一个"最小可用"的自定义 IK 求解器插件。 - [ROS2系列教程:Moveit2仿真与运动规划(基础)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-fang-zhen-yu-yun-dong-gui.md): 本文是 ROS2 系列教程的第 36 篇:MoveIt2 仿真与运动规划(基础)。第 35 篇我们装好了 MoveIt2、写了一个两连杆机械臂的 URDF,并用… --- # 关于 URL: https://wsj-blog.pages.dev/about/ ## 👋 你好,我是 Wang Shijie 这里是 **Wang Shijie 的个人博客**,用来记录技术学习、工程实践和日常思考。 ## ✍️ 我会写什么 - 技术笔记:前端、后端、Linux 与各种折腾过程 - 工程实践:踩坑记录、问题排查、可复现的操作步骤 - 日常随想:读过的书、用过的工具、值得记下来的想法 ## 🧭 关于本站 - 基于 [Astro](https://astro.build) 构建,使用 [xingwangzhe](https://xingwangzhe.fun) 开发的 [Stalux](https://github.com/xingwangzhe/stalux) 主题 - 全站静态生成,部署在 EdgeOne Pages - 文章以 Markdown 编写,支持代码高亮、数学公式与全文搜索 - 除非另有说明,本站内容采用 [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) 许可协议 ## 📮 联系我 - Gitee:[gitee.com/open-zynq](https://gitee.com/open-zynq) - Bilibili:[space.bilibili.com/1875855110](https://space.bilibili.com/1875855110) - CSDN:[blog.csdn.net/CJLU_WSJ](https://blog.csdn.net/CJLU_WSJ) - 邮箱:[3343137303@qq.com](mailto:3343137303@qq.com) 欢迎交流,也欢迎交换友链。 --- # Words URL: /words/ ## 写下来,才算真的弄懂 能把一件事讲明白,才算真的弄懂了。所以先写下来,哪怕只是半成品。 --- # 友情链接 URL: https://wsj-blog.pages.dev/links/ - [Astro](https://astro.build/): 现代化的静态站点生成器,本站的构建基础。 - [MDN](https://developer.mozilla.org/): 开放、易懂的 Web 标准文档,前端开发必备。 - [Feather Icons](https://feathericons.com/): 简洁优雅的开源图标库,本站导航图标来源。 - [Simple Icons](https://simpleicons.org/): 收录数千个品牌图标,品牌标识随取随用。 - [anime.js](https://animejs.com/): 轻量易用的 JavaScript 动画库。 --- # 文章归档 URL: https://wsj-blog.pages.dev/archives/ - [这个博客是怎么搭起来的](https://wsj-blog.pages.dev/posts/build-my-blog.md): 从 Astro 主题到 EdgeOne Pages 部署,再到自建 Waline 评论,记录这个博客的完整搭建过程。 - [你好,世界](https://wsj-blog.pages.dev/posts/hello-world.md): 这是这个博客的第一篇文章,说说为什么开始写,以及准备写些什么。 - [Ubuntu 26.04 + GNOME Wayland 设置视频动态壁纸(Hidamari 实测可用)](https://wsj-blog.pages.dev/posts/ubuntu-26-04-gnome-wayland-she-zhi-shi-pin-dong-tai-bi-zhi.md): 判断能不能用,只看工具是否支持,不要看教程年份GNOME 用 Mutter,不实现 wlr-layer-shell,所以 mpvpaper… - [ROS2系列教程:Moveit2真机控制与系列总结(原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-zhen-ji-kong-zhi-yu-xi-lie.md): 阶段篇号主题掌握的关键能力入门1-12ROS2 基础节点/话题/服务/参数/Action/launch/tf2,能独立开发节点机器人学13-15运动学与坐标位姿、tf2 坐标变换、TF… - [ROS2系列教程:Moveit2进阶(抓取/OMPL/自定义规划)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-jin-jie-zhua-qu-ompl-zi-ding.md): 理解规划器插件机制,能让你在特殊需求(狭窄通道、非标准成本函数)时不至于束手无策。 - [ROS2系列教程:Moveit2碰撞检测与避障](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-peng-zhuang-jian-ce-yu-bi.md): 把前面学的串成真本事——用 Pick & Place 流程实现"感知 → 抓取 → 搬运 → 放置",深入 OMPL 各规划器(RRT/RRTConnect/BIT\*)的选型与调参… - [Ubuntu 自动登录后 GNOME 远程桌面连不上?一次登录钥匙环锁定的排查与永久修复](https://wsj-blog.pages.dev/posts/ubuntu-zi-dong-deng-lu-hou-gnome-yuan-cheng-zhuo-mian-lian.md): 自动登录 + GNOME 远程桌面「连不上但解锁后能用」,几乎都是登录钥匙环被锁定导致的判断依据是 GRD 日志里的… - [GNOME 50 (Wayland) 桌面网速悬浮窗:TopHat 部署实录与三个踩坑](https://wsj-blog.pages.dev/posts/gnome-50-wayland-zhuo-mian-wang-su-xuan-fu-chuang-tophat-bu.md): 想在 Ubuntu 的 GNOME 桌面上常驻一个「网速悬浮窗」:不占顶栏、不打断工作、全屏看视频或跑下载时也能瞄一眼实时上下行速率。本文记录在一台 GNOME Shell 50.1 +… - [ROS2系列教程:Moveit2运动学(IK/FK与运动学插件)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-yun-dong-xue-ik-fk-yu-yun.md): 理解插件的机制,比背 API 更有价值。下面写一个"最小可用"的自定义 IK 求解器插件。 - [ROS2系列教程:Moveit2仿真与运动规划(基础)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-fang-zhen-yu-yun-dong-gui.md): 本文是 ROS2 系列教程的第 36 篇:MoveIt2 仿真与运动规划(基础)。第 35 篇我们装好了 MoveIt2、写了一个两连杆机械臂的 URDF,并用… - [ROS2系列教程:Moveit2环境与机械臂建模准备](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-huan-jing-yu-ji-xie-bi-jian.md): 本文是 ROS2 系列教程的第 35 篇:MoveIt2 环境与机械臂建模准备。第 31~34 篇我们完成了移动底盘机器人的完整实战(建图、导航、任务、安全)。。第 35… - [ROS2系列教程:上位机建图与导航(原理综合)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-shang-wei-ji-jian-tu-yu-dao-hang.md): 本文是 ROS2 系列教程的第 33 篇:上位机建图与导航(原理综合)。第 32 篇我们把下位机的数据(里程计、IMU、传感器)接进了 ROS2,实现了"软硬结合"。但机器人拿到数据之后要干什么?… - [ROS2系列教程:完整实体机器人项目实战(软硬协同)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-wan-zheng-shi-ti-ji-qi-ren-xiang-mu.md): 本文是 ROS2 系列教程的第 34 篇:完整实体机器人项目实战(软硬协同)。第 31~33 篇分别完成了硬件搭建、软硬通信、建图导航三件大事… - [ROS2系列教程:嵌入式控制与通信开发(原理+仿真)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-qian-ru-shi-kong-zhi-yu-tong-xin-kai.md): 本文是 ROS2 系列教程的第 32 篇:嵌入式控制与通信开发(原理+仿真)。第 31 篇我们搭好了实体机器人的硬件骨架。但硬件只是"尸体"… - [ROS2系列教程:实体机器人硬件搭建(原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-shi-ti-ji-qi-ren-ying-jian-da-jian.md): 本文是 ROS2 系列教程的第 31 篇:实体机器人硬件搭建(原理)。第 26-30 篇我们打通了硬件控制全链路:电机、舵机、传感器、MicroROS、ROS2-Control… - [ROS2系列教程:控制硬件实战(舵机与传感器,原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-kong-zhi-ying-jian-shi-zhan-duo-ji.md): 本阶段(第 26-30 篇)我们打通了机器人硬件全链路篇主题核心收获26嵌入式点灯GPIO 本质,嵌入式开发流程27MicroROSMCU 直接成为 ROS2… - [ROS2系列教程:控制硬件实战(电机驱动,原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-kong-zhi-ying-jian-shi-zhan-dian-ji.md): / real\_motor\_hw.cpp:真实电机硬件接口(串口/MicroROS 连接下位机)public:// 声明关节及其接口// 基类解析 URDF 的 配置//… - [ROS2系列教程:ROS2-Control与控制器(原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-ros2-control-yu-kong-zhi-qi-yuan-li.md): 官方控制器不够用时,可以写自定义控制器。 - [ROS2系列教程:MicroROS接入(原理+仿真)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-microros-jie-ru-yuan-li-fang-zhen.md): 本文详解ROS2 MicroROS接入原理与仿真实践,解决嵌入式设备与上位机通信的通用性难题。通过MicroROS架构(Client+Agent+传输层),实现MCU作为ROS2节点无缝接入系统… - [ROS2系列教程:嵌入式开发从点灯开始(原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-qian-ru-shi-kai-fa-cong-dian-deng.md): 本文是 ROS2 系列教程的第 26 篇:嵌入式开发从点灯开始(原理)。第 25 篇我们完成了 Nav2 导航阶段(21-25)的全部内容,前面的教程全都是在仿真环境里运行:Gazebo… - [ROS2系列教程:Nav2进阶实践(自定义算法、插件与行为树)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-nav2-jin-jie-shi-jian-zi-ding-yi.md): / 继承 nav2\_core::Controller,实现 5 个纯虚接口public:// 1. 配置:从参数获取配置// 2. 清理与激活// 3. 核心:计算速度指令(每 control… - [ROS2系列教程:Nav2导航仿真实战(全局+局部规划)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-nav2-dao-hang-fang-zhen-shi-zhan.md): 本文是 ROS2 系列教程的第 24 篇:Nav2 导航仿真实战(全局+局部规划)。至此我们已有地图(第 21/22 篇)、有定位(第 23 篇),就差"自己走路"… - [ROS2系列教程:AMCL定位与地图](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-amcl-ding-wei-yu-di-tu.md): 本文是 ROS2 系列教程的第 23 篇:AMCL 定位与地图。前两篇我们学会了建图——slam\_toolbox 和 Cartographer 都能产出地图… - [ROS2系列教程:Cartographer建图进阶](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-cartographer-jian-tu-jin-jie.md): 本文是 ROS2 系列教程的第 22 篇:Cartographer 建图进阶。上一篇用 slam\_toolbox 建出了第一张地图,但它的回环能力在长廊、大场地、复杂环境里会吃力——累积误差难以消除… - [ROS2系列教程:SLAM建图(slam_toolbox)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-slam-jian-tu-slam-toolbox.md): 本文是 ROS2 系列教程的第 21 篇:SLAM 建图(slam\_toolbox)。前 20 篇我们完成了建模仿真阶段——小车有模型、有物理、有传感器,但"它不知道自己在哪里… - [ROS2系列教程:机器人模型导入仿真实战](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-ji-qi-ren-mo-xing-dao-ru-fang-zhen.md): 大型 ROS2 工程通常拆成两个功能包:分工原则:description 只管"机器人长什么样",bringup 管"系统怎么跑"。导航、SLAM 等后续阶段都在 bringup 里加 launch… - [ROS2系列教程:Gazebo插件(关节控制/IMU/激光雷达)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-gazebo-cha-jian-guan-jie-kong-zhi.md): 本文是 ROS2 系列教程的第 19 篇:Gazebo 插件(关节控制/IMU/激光雷达)。上一篇小车能在 Gazebo 里跑动了,但它还是个"瞎子"——没有传感器,不知道周围有什么… - [ROS2系列教程:Gazebo仿真基础与世界搭建](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-gazebo-fang-zhen-ji-chu-yu-shi-jie.md): 内容是关于ROS2第18篇,Gazebo仿真基础与世界搭建。摘要需简洁概括核心内容。注意字数限制。本文是ROS2系列第18篇,讲解Gazebo仿真基础与世界搭建… - [ROS2系列教程:Xacro参数化建模](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-xacro-can-shu-hua-jian-mo.md): 回顾上一篇的差速小车,你实际感受到了:Xacro 是 URDF 的超集:一个合法的 Xacro 文件经过 命令展开后,得到的是标准 URDF。它新增的能力:ROS2 中 由同名包提供,启动时用… - [ROS2系列教程:URDF机器人建模(link与joint)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-urdf-ji-qi-ren-jian-mo-link-yu-joint.md): URDF 是一种 XML 格式的机器人描述文件,用树状结构描述机器人的物理组成:刚体(link)和关节(joint)。它回答三个问题:URDF 是 ROS1 时代的产物,被 ROS2 完整继承… - [ROS2系列教程:TF树与多坐标系管理(C++/Python)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-tf-shu-yu-duo-zuo-biao-xi-guan-li-c.md): 上一篇文章里,每条变换是"父 → 子"单向的。当你把所有变换按父子关系连起来,就得到一棵TF 树(TF Tree)map│odom│base\_link/… - [ROS2系列教程:tf2坐标变换详解(C++/Python)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-tf2-zuo-biao-bian-huan-xiang-jie-c.md): 查询” = 给定两个坐标系,返回它们之间的变换。例如:激光雷达检测到前方 1 米的墙(在laser坐标系),要换算到map坐标系,就需要的变换。 - [ROS2系列教程:运动学基础(位姿与坐标变换)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-yun-dong-xue-ji-chu-wei-zi-yu-zuo.md): ",即从参考系出发依次施加 yaw → pitch → roll 的。 - [ROS2系列教程:常用工具详解(ros2 CLI、rqt、RViz、ros2 bag、tf2基础)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-chang-yong-gong-ju-xiang-jie-ros2.md): tf2是 ROS2 的坐标变换库:维护"坐标系树",提供任意两个坐标系间的位姿变换。机器人上每个部件都有一个坐标系(base\_link、laser、camera、map),tf2 让"激光点坐标 →… - [ROS2系列教程:launch文件与多节点启动(参数/条件/复用)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-launch-wen-jian-yu-duo-jie-dian-qi.md): 本篇文章系统掌握了 launch:最简多节点启动、参数传递(yaml/字典/launch 参数)、话题重映射、命名空间、条件控制、子 launch 包含、组与外部进程、事件处理器,以及调试技巧… - [ROS2系列教程:Action动作通信(带反馈长任务)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-action-dong-zuo-tong-xin-dai-fan-kui.md): 动作接口用.action文件定义,三部分用---\# action/CountUntil.action —— 计数到目标值int32 target\_number \#… - [ROS2系列教程:参数Parameter与QoS策略](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-can-shu-parameter-yu-qos-ce-l-e.md): 参数(Parameter)是节点的配置项:一个带类型的键值对,运行时可查询、可修改。参数让"代码与配置分离"——同样的节点,通过不同参数启动,就能适配不同机器人、不同环境。参数名(parameter… - [ROS2系列教程:服务Service通信(下)同步异步与回调组](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-fu-wu-service-tong-xin-xia-tong-bu.md): 本篇文章把服务通信推向工程级:理解了同步/异步的底层机制(伪同步本质)、并发请求与多请求处理、回调组(互斥/可重入)与多线程执行器、超时与指数退避重试、服务与 QoS 的关系… - [ROS2系列教程:服务Service通信(上)客户端与服务端](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-fu-wu-service-tong-xin-shang-ke-hu.md): 沿用第 4 篇的包,添加验证接口可用本篇文章完成了服务通信的完整入门:理解了 Request/Response 模型与适用场景,用 Python 和 C++ 分别实现了服务端、同步客户端、异步客户端… - [ROS2系列教程:话题Topic通信(下)自定义消息与周期](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-hua-ti-topic-tong-xin-xia-zi-ding-yi.md): 定义 .msg/.srv 文件(接口包)→ colcon build 生成类型代码→ 发布者/订阅者 include/import 使用→ ros2 run 运行验证第 4 篇我们定义了(含… - [ROS2系列教程:话题Topic通信(上)发布者与订阅者](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-hua-ti-topic-tong-xin-shang-fa-bu.md): 本篇文章完成了话题通信的入门:理解了 Publish/Subscribe 模型的三大解耦优势、DDS 去中心化发现机制,用 Python 与 C++ 分别实现了发布者/订阅者并验证了跨语言通信… - [ROS2系列教程:package结构(CMake与Python两种包)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-package-jie-gou-cmake-yu-python.md): ROS2 内置的std\_msgs覆盖常见场景,但你的业务数据(比如"机器人位姿 + 置信度 + 时间戳")往往是组合结构。这时需要自定义消息类型——定义一个.msg文件,rosidl… - [ROS2系列教程:工作空间与colcon构建](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-gong-zuo-kong-jian-yu-colcon-gou-jian.md): 本篇文章把 ROS2 从"脚本"提升到了"工程":理解了工作空间四目录结构、package 的概念与两种构建类型(ament\_python / ament\_cmake),掌握了 colcon… - [ROS2系列教程:认识节点与ROS图](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-ren-shi-jie-dian-yu-ros-tu.md): 在 ROS2 中,节点(Node)是一个负责执行特定任务的独立进程或进程内模块。一个机器人系统通常由几十个节点组成:有的节点负责读取激光雷达数据,有的负责做定位,有的负责规划路径,有的负责驱动电机… - [ROS2系列教程:Humble环境搭建与Hello World](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-humble-huan-jing-da-jian-yu-hello.md): 本节我们完成了 ROS2 的入门:理解 ROS2 与 ROS1 的架构区别、DDS 与 QoS 的概念、搭建 Humble 开发环境的基本流程,并用 C++ 与 Python 两种语言写出了第一个… - [C++综合实战:数据结构与算法实现及系列总结](https://wsj-blog.pages.dev/posts/c-zong-he-shi-zhan-shu-ju-jie-gou-yu-suan-fa-shi-xian-ji-xi.md): 阶段篇目核心内容入门1-5语法基础、控制流、函数、数组/指针、类初探进阶6-10类与对象、运算符重载、继承多态、内存管理、文件流STL11-15容器、迭代器、算法库、函数对象/Lambda、适配器模板… - [C++综合实战:网络编程入门与HTTP客户端](https://wsj-blog.pages.dev/posts/c-zong-he-shi-zhan-wang-luo-bian-cheng-ru-men-yu-http-ke-hu.md): Socket 基础(客户端/服务器模型与 TCP 通信四步)、HTTP 协议(请求/响应格式、方法、状态码)、迷你 HTTP 客户端(构造 GET 请求、解析响应头与… - [C++综合实战:面向对象图书管理系统](https://wsj-blog.pages.dev/posts/c-zong-he-shi-zhan-mian-xiang-dui-xiang-tu-shu-guan-li-xi.md): 本篇用图书管理系统BookReader类的封装(数据 + 行为)、EBook派生类的继承与多态(virtual 重写)、Library管理类的STL 容器(vector +… - [C++工程化:编译链接、调试与性能优化](https://wsj-blog.pages.dev/posts/c-gong-cheng-hua-bian-yi-lian-jie-tiao-shi-yu-xing-neng-you.md): 四阶段编译链接(预处理/编译/汇编/链接)与常见错误排查、GDB 调试(断点/单步/打印变量/调用栈)、内存检测(ASan 与 Valgrind… - [C++工程化:CMake构建与多文件项目管理](https://wsj-blog.pages.dev/posts/c-gong-cheng-hua-cmake-gou-jian-yu-duo-wen-jian-xiang-mu.md): 本篇讲解了 C++ 工程化核心的CMake 构建系统:最小项目与构建流程、多文件项目的目录组织与静态库与动态库的构建与选型(含对比表)、条件编译/option/查找与自动拉取依赖(find\_packa… - [C++并发编程:异步任务与线程池实战](https://wsj-blog.pages.dev/posts/c-bing-fa-bian-cheng-yi-bu-ren-wu-yu-xian-cheng-chi-shi-zhan.md): 一行启动异步任务并获取结果(含异常传递与 wait\_for 超时)、手动生产-消费单次结果、把任务与 future 打包、线程池的核心实现(条件变量任务队列 + packaged\_task 封装 +… - [C++并发编程:条件变量与原子操作](https://wsj-blog.pages.dev/posts/c-bing-fa-bian-cheng-tiao-jian-bian-liang-yu-yuan-zi-cao-zuo.md): 实现阻塞等待与通知(含谓词重载防虚假唤醒)、生产者消费者模型的完整实现、atomic 原子类型(fetch\_add/CAS… - [C++并发编程:线程与互斥锁](https://wsj-blog.pages.dev/posts/c-bing-fa-bian-cheng-xian-cheng-yu-hu-chi-suo.md): 的创建/join/detach 与参数传递、数据竞争的危害、std::mutex与 RAII 封装(lock\_guard / unique\_lock /… - [C++20新特性精讲:概念、范围与三路比较](https://wsj-blog.pages.dev/posts/c-20-xin-te-xing-jing-jiang-gai-nian-fan-wei-yu-san-lu-bi.md): concepts让模板约束可读、可复用;ranges以惰性视图与管道风格重构数据流;三路比较一次操作自动生成全套比较运算符;协程与模块开启语言新范式;std::span与constexpr扩展让代码更… - [C++17新特性精讲:结构化绑定、if constexpr与实用类型](https://wsj-blog.pages.dev/posts/c-17-xin-te-xing-jing-jiang-jie-gou-hua-bang-ding-if.md): 本篇讲解了 if/switch 初始化语句、结构化绑定、if constexpr 编译期分支、std::optional/variant/any 三种实用类型、string\_view… - [C++11/14新特性精讲:移动语义与智能指针实战](https://wsj-blog.pages.dev/posts/c-11-14-xin-te-xing-jing-jiang-yi-dong-yu-yi-yu-zhi-neng.md): 内容是关于C++现代特性:左值右值、移动语义、=default/=delete等。需概括核心。确保字数。本文介绍C++现代特性:五类值分类与右值引用生命周期延长… - [C++模板教程:类型推导与完美转发](https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-lei-xing-tui-dao-yu-wan-mei-zhuan-fa.md): 完美转发是保留参数左右值属性// 接收函数:区分左值右值cout << "左值处理: " << value << endl;cout << "右值处理: " << value << endl;//… - [C++模板教程:变参模板、折叠表达式与SFINAE](https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-bian-can-mu-ban-zhe-die-biao-da-shi-yu.md): 变参模板(Variadic Templates)允许模板接受任意数量的参数// 参数包:typename... ArgsArgs...是类型参数包。args...是函数参数包。获取参数个数… - [C++模板教程:特化、偏特化与类型萃取](https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-te-hua-pian-te-hua-yu-lei-xing-cui-qu.md): 模板是「通用配方」,但某些特殊类型需要专门的实现。// 通用模板:处理一般类型a : b;// 全特化:为特定类型定制(如 const char\* 比较字符串而非指针)a : b;类型萃取(Type… - [C++模板教程:函数模板与类模板入门](https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-han-shu-mu-ban-yu-lei-mu-ban-ru-men.md): 普通函数精确匹配优先于模板。模板可以匹配任何类型3. 用<>可以强制调用模板版本。public:// 类外定义:需要重复 template 前缀// 类外定义:需要重复 template… - [C++STL教程:容器适配器与实用工具](https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-rong-qi-shi-pei-qi-yu-shi-yong-gong-ju.md): 容器适配器是基于其他容器包装出的受限接口适配器底层容器(默认)特性stackdeque后进先出 LIFOqueuedeque先进先出 FIFOvector按优先级出队// 病情严重程度//… - [C++STL教程:函数对象与Lambda表达式](https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-han-shu-dui-xiang-yu-lambda-biao-da-shi.md): 函数对象(Functor)是重载了operator()的类对象,可以像函数一样调用。与普通函数相比,它可以保存状态。// 函数对象:重载 operator()private:int base;//… - [C++STL教程:迭代器与算法库](https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-die-dai-qi-yu-suan-fa-ku.md): 迭代器是统一访问容器元素的接口,让算法不关心容器类型。可以把迭代器理解为「智能指针」,支持->++--等操作。int score;// 默认升序// 降序int());//… - [C++STL教程:关联容器与无序容器](https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-guan-lian-rong-qi-yu-wu-xu-rong-qi.md): int age;// 自定义比较器(按年龄排序)people.insert({"张三", 30});people.insert({"李四", 25});people.insert({"王五",… - [C++STL教程:容器总览与序列容器](https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-rong-qi-zong-lan-yu-xu-lie-rong-qi.md): STL(Standard Template Library,标准模板库)是 C++ 标准库的核心,由容器、迭代器、算法、函数对象、适配器五大部分组成。它把「数据存储」与「数据处理」解耦,实现高效复用… - [C++进阶教程:文件流与字符串流](https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-wen-jian-liu-yu-zi-fu-chuan-liu.md): 1.3 读取文件1.4 打开模式| 模式 | 含义 || — | — | — || | 读模式(ifstream 默认) || | 写模式(ofstream 默认,截断) || | 追加(不截断)… - [C++进阶教程:动态内存管理与智能指针](https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-dong-tai-nei-cun-guan-li-yu-zhi-neng.md): RAII(Resource Acquisition Is Initialization,资源获取即初始化):把资源(内存、文件、锁)的获取放在构造函数中,释放放在析构函数中… - [C++进阶教程:继承与多态](https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-ji-cheng-yu-duo-tai.md): 多态(Polymorphism)指同一个调用在不同对象上表现出不同行为。通过基类指针/引用调用虚函数,实际执行的是派生类的实现。多态三要素基类中定义虚函数。派生类重写虚函数。通过基类指针/引用调用… - [C++进阶教程:运算符重载与类型转换](https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-yun-suan-fu-zhong-zai-yu-lei-xing.md): 运算符重载是让==<<等运算符可以作用于自定义类型,使代码更自然、更直观。例如c1 + c2让复数相加,直接打印对象… - [C++进阶教程:类与对象深入](https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-lei-yu-dui-xiang-shen-ru.md): 本篇深入讲解了构造函数的种类与初始化列表、构造析构顺序、深拷贝与浅拷贝的区别及三法则、static 成员、const 成员函数、this 指针和友元,并用完整 String 类串联实战… - [C++入门教程:结构体、枚举与类初探](https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-jie-gou-ti-mei-ju-yu-lei-chu-tan.md): 结构体是不同类型数据的集合// 定义学生结构体int age;// 定义并初始化Student s1;// 默认构造s1.name = "张三";// 列表初始化(C++11)Student s2… - [C++入门教程:数组、字符串与指针入门](https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-shu-zu-zi-fu-chuan-yu-zhi-zhen-ru-men.md): 本文是C++系列教程的第4篇,重点讲解数组、字符串和指针的核心概念。主要内容包括:1) 一维/多维数组的声明、初始化和遍历,强调数组越界的危险性;2)… - [C++入门教程:函数与作用域详解](https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-han-shu-yu-zuo-yong-yu-xiang-jie.md): / 函数定义:返回类型 函数名(参数列表) { 函数体 }// 无返回值函数// 调用无参函数// 调用带参函数,接收返回值return 0;当函数定义在使用之后时,需要先声明//… - [C++入门教程:运算符、表达式与控制流程](https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-yun-suan-fu-biao-da-shi-yu-kong-zhi-liu.md): 本文是C++系列教程第二篇,重点讲解运算符、表达式和程序控制结构。主要内容包括: 运算符详解:分类介绍算术/关系/逻辑/位运算等8类运算符,特别说明整数除法截断、自增自减顺序、短路求值特性… - [C语言入门教程:从零开始学C语言编程](https://wsj-blog.pages.dev/posts/c-yu-yan-ru-men-jiao-cheng-cong-ling-kai-shi-xue-c-yu-yan.md): 本篇介绍了 C 语言的环境搭建、基础语法、运算符、条件循环,并用猜数字游戏串联实战。下一篇将深入讲解 C 语言的精髓——指针与内存管理,敬请期待! - [C++入门教程:从零开始学C++编程](https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-cong-ling-kai-shi-xue-c-bian-cheng.md): 本文是C++入门教程的第一篇,主要介绍了C++的基本概念、开发环境搭建、基础语法和实战练习… - [Python综合实战:从零开发一个命令行工具](https://wsj-blog.pages.dev/posts/python-zong-he-shi-zhan-cong-ling-kai-fa-yi-ge-ming-ling.md): 的收官实战篇(第 20 篇)。前 19 篇学习了语法、库、工程化等知识,本篇把它们串起来,从零开发一个。 - [Python进阶教程:Git版本控制与团队协作](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-git-ban-ben-kong-zhi-yu-tuan-dui.md): 本文是Python进阶教程中的Git版本控制指南,介绍了Git的核心概念与团队协作技巧… - [Python进阶教程:算法与数据结构入门](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-suan-fa-yu-shu-ju-jie-gou-ru-men.md): 本篇介绍了时间复杂度、常用数据结构(栈、队列)、排序与查找算法、递归和动态规划入门,并用 LRU 缓存串联实战。刷题建议:从 LeetCode 简单题开始,每天 1-2 题,坚持就是胜利。 - [Python进阶教程:机器学习入门(Scikit-learn)](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-ji-qi-xue-xi-ru-men-scikit-learn.md): 本篇介绍了机器学习的基本流程、分类/回归/聚类三大经典任务、交叉验证和特征工程,并用鸢尾花数据集跑通了完整实战。机器学习重在实践,建议用 UCI 或 Kaggle 的数据集多做练习。 - [Python进阶教程:自动化办公实战](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-zi-dong-hua-ban-gong-shi-zhan.md): 这篇Python办公自动化教程介绍了几个常用库的实战应用,包括:openpyxl处理Excel(读取/写入/合并)、python-docx操作Word文档、pdfplumber提取PDF文本… - [Python进阶教程:项目工程化与虚拟环境](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-xiang-mu-gong-cheng-hua-yu-xu-ni.md): 本篇介绍了虚拟环境、依赖管理、项目目录结构、.gitignore 规范等工程化基础。良好的工程习惯能让项目更易维护、协作和部署。下一篇将介绍自动化办公,用 Python 解放重复劳动。 - [Python进阶教程:单元测试与代码质量](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-dan-yuan-ce-shi-yu-dai-ma-zhi.md): 本篇介绍了 unittest 与 pytest 两大测试框架、Mock 模拟外部依赖、以及 flake8/black/mypy/coverage 等代码质量工具,并为计算器类编写了完整测试… - [Python进阶教程:装饰器与生成器深入](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-zhuang-shi-qi-yu-sheng-cheng-qi.md): 本篇深入讲解了装饰器(基础、带参数、内置装饰器)和生成器(基础、表达式、无限序列),并用手写缓存装饰器串联实战。理解这两个特性,能写出更简洁、更 Pythonic 的代码。 - [Python进阶教程:异步编程(asyncio)](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-yi-bu-bian-cheng-asyncio.md): 异步编程让程序在等待 I/O 时不阻塞,而是继续执行其他任务。单线程内实现并发,无锁竞争上下文切换开销极小适合大量 I/O 等待场景(网络请求、数据库)Python 3.7+… - [Python进阶教程:正则表达式进阶与文本处理](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-zheng-ze-biao-da-shi-jin-jie-yu.md): 本篇深入讲解了正则的分组捕获、贪婪匹配、前后断言、预编译等进阶技巧,并提供了日志解析和文本清理两个实战工具。正则表达式是文本处理的瑞士军刀,值得花时间熟练掌握。 - [Python进阶教程:调试技巧与性能优化](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-tiao-shi-ji-qiao-yu-xing-neng-you.md): 恭喜你完成Python 入门教程系列的全部 10 篇文章!篇目主题1Python 入门:基础语法2面向对象编程(OOP)3文件操作与异常处理4常用标准库与实用技巧5网络编程与数据抓取6多线程与多进程7… - [Python进阶教程:Web开发入门(Flask)](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-web-kai-fa-ru-men-flask.md): 简单:一个文件即可启动 Web 服务灵活:可以自由选择组件生态好:文档丰富,社区庞大本篇介绍了 Flask 的路由、模板、表单处理和 JSON API,并用待办清单应用串联实战… - [Python进阶教程:数据分析入门(NumPy与Pandas)](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-shu-ju-fen-xi-ru-men-numpy-yu.md): 本篇介绍了 NumPy 数组运算和 Pandas 的 Series、DataFrame、数据清洗、分组聚合,并用销售数据分析串联实战。下一篇将介绍Web 开发入门(Flask),敬请期待! - [Python进阶教程:数据库操作入门](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-shu-ju-ku-cao-zuo-ru-men.md): 数据库(Database)是有组织存储数据的仓库,支持高效增删改查(CRUD)。SQLite:轻量级,单文件… - [Python进阶教程:多线程与多进程](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-duo-xian-cheng-yu-duo-jin-cheng.md): 本文介绍了Python中多线程与多进程的使用方法。主要内容包括:1)多线程的创建方式(直接调用和继承Thread类)及线程池使用;2)线程安全与锁机制;3)多进程的适用场景(CPU密集型任务)及进程池… - [Python进阶教程:网络编程与数据抓取](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-wang-luo-bian-cheng-yu-shu-ju.md): 本文介绍了Python网络编程与数据抓取的基础知识,涵盖HTTP协议、urllib和requests库的使用、BeautifulSoup解析HTML以及爬虫规范… - [Python进阶教程:常用标准库与实用技巧](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-chang-yong-biao-zhun-ku-yu-shi.md): 摘要: 本文是Python入门系列第4篇,重点讲解常用标准库与实用技巧。涵盖os/sys(系统操作)、datetime(日期处理)、json(数据交换)、re(正则表达式)、random/collec… - [Python进阶教程:文件操作与异常处理](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-wen-jian-cao-zuo-yu-yi-chang-chu.md): 本文是Python入门教程系列的第三篇,主要讲解文件操作和异常处理。内容包括:文件打开与关闭(推荐使用with语句)、不同文件模式(r/w/a等)、读写文件方法(read/readline/for循环… - [Python进阶教程:面向对象编程(OOP)](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-mian-xiang-dui-xiang-bian-cheng.md): 面向对象编程(Object-Oriented Programming,简称 OOP)是一种编程思想,它把数据和对数据的操作封装成对象… - [Python入门教程:从零开始学Python编程](https://wsj-blog.pages.dev/posts/python-ru-men-jiao-cheng-cong-ling-kai-shi-xue-python-bian.md): 本文介绍了 Python 的安装、基础语法和一个小实战项目。学习编程最重要的是多敲代码,建议每天坚持练习。后续我会继续更新 Python 进阶教程(面向对象、文件操作、数据处理等),欢迎关注! - [Ubuntu IBus 中文输入法终极优化指南:告别切换烦恼,提升输入效率](https://wsj-blog.pages.dev/posts/ubuntu-ibus-zhong-wen-shu-ru-fa-zhong-ji-you-hua-zhi-nan.md): 本文提供了Ubuntu 20.04/22.04系统下优化IBus中文输入法切换的完整指南… - [Orange Pi USB Device 模式设置指南](https://wsj-blog.pages.dev/posts/orange-pi-usb-device-mo-shi-she-zhi-zhi-nan.md): 本文介绍了在Orange Pi开发板上设置USB为设备模式的两种方法。推荐使用set\_device.sh脚本,执行简单命令即可完成设置;也可手动操作,通过echo命令写入mode文件并重启服务… - [数据精度和运算速度的中庸之道](https://wsj-blog.pages.dev/posts/shu-ju-jing-du-he-yun-suan-su-du-de-zhong-yong-zhi-dao.md): 最近在写步进电机的S型加速算法,遇到了这个问题,由于要计算s型曲线,使用exp函数一次全部计算完大概需要1.2s,单片机的实时性得不到保证,每次在pwm中断回调中计算也是耗费算力… - [freertos vTaskDelay卡死](https://wsj-blog.pages.dev/posts/freertos-vtaskdelay-ka-si.md): 解决办法,改成在main方法中创建任务,调用vTaskDelay工作正常,我用的是nuvoton的库,所以可能与sdk移植有关系,反正问题就是这么解决的,希望能帮到同路者… - [stm32高级定时器和基本定时器的踩坑指南](https://wsj-blog.pages.dev/posts/stm32-gao-ji-ding-shi-qi-he-ji-ben-ding-shi-qi-de-cai-keng.md): 高级定理器tim1和tim8的中断回调函数和通用定时器不相同。基本定时器中只有计数,没有分频。 - [深度计解算板的报错的问题解决(MS5437转串口模块)](https://wsj-blog.pages.dev/posts/shen-du-ji-jie-suan-ban-de-bao-cuo-de-wen-ti-jie-jue-ms5437.md): 做水下机器人控制中用到了深度解算板,写了相应的驱动程序,也遇到了一些问题,现把一些问题的解决方案放出来,供大家参考。驱动被设计为拥有指令器和接收机两个部分,完全被动方式运行,与具体的平台解耦… - [Espressif-IDE中文汉化教程全网首发](https://wsj-blog.pages.dev/posts/espressif-ide-zhong-wen-han-hua-jiao-cheng-quan-wang-shou-fa.md): Espressif-IDE中文汉化教程 - [Win32DiskImage写入123报错](https://wsj-blog.pages.dev/posts/win32diskimage-xie-ru-123-bao-cuo.md): 将SD卡格式化为FAT32,然后向其中写一个img文件。利用读卡器将SD卡连接在主机上,然后使用win32disimager软件向指定盘符写时报错123 - [这个东西让我的Python代码快了100倍](https://wsj-blog.pages.dev/posts/zhe-ge-dong-xi-rang-wo-de-python-dai-ma-kuai-le-100-bei.md): 转载于Python数据挖掘。Python 已经得到了全球程序员的喜爱,连续多期稳坐编程语言排行榜第一把交椅。但是还是遭到一些人的诟病,原因之一就是认为它运行缓慢。… - [关于大越创新的板载USB–TTL通信失败问题的修复](https://wsj-blog.pages.dev/posts/guan-yu-da-yue-chuang-xin-de-ban-zai-usb-ttl-tong-xin-shi.md): 问题描述:这几天用大越创新stm32f103rct6(升级版)进行传感器读取,发现使用板载串口,电脑识别ch340驱动比较慢,而且发送数据会卡死(如果判断没问题的话是单片机卡了),重新启动串口后… - [ROS使用的bug总结](https://wsj-blog.pages.dev/posts/ros-shi-yong-de-bug-zong-jie.md): \[rospack] Error: package 'xxxxxxxxxxx' not found - [ubuntu安装时窗口太小,点不到应用](https://wsj-blog.pages.dev/posts/ubuntu-an-zhuang-shi-chuang-kou-tai-xiao-dian-bu-dao-ying.md): 要注意的是,两数字间是x字母,⽽不是数字键区的\*号。另外此效果,只能在当前运⾏虚拟机下有效,如果关了虚拟机,之后就没⽤了。所以你必需,⽴刻在system setting⾥点击display… --- # 全部分类 URL: https://wsj-blog.pages.dev/categories/ - [ROS2](https://wsj-blog.pages.dev/categories/ROS2/): 40 posts - [C与C++](https://wsj-blog.pages.dev/categories/C%E4%B8%8EC%2B%2B/): 31 posts - [Python](https://wsj-blog.pages.dev/categories/Python/): 20 posts - [嵌入式](https://wsj-blog.pages.dev/categories/%E5%B5%8C%E5%85%A5%E5%BC%8F/): 7 posts - [Linux 桌面](https://wsj-blog.pages.dev/categories/Linux%20%E6%A1%8C%E9%9D%A2/): 5 posts - [杂记](https://wsj-blog.pages.dev/categories/%E6%9D%82%E8%AE%B0/): 3 posts - [技术](https://wsj-blog.pages.dev/categories/%E6%8A%80%E6%9C%AF/): 1 posts - [生活](https://wsj-blog.pages.dev/categories/%E7%94%9F%E6%B4%BB/): 1 posts --- # Category: ROS2 URL: https://wsj-blog.pages.dev/categories/ROS2/ ## 40 posts - [ROS2系列教程:Moveit2真机控制与系列总结(原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-zhen-ji-kong-zhi-yu-xi-lie.md): 阶段篇号主题掌握的关键能力入门1-12ROS2 基础节点/话题/服务/参数/Action/launch/tf2,能独立开发节点机器人学13-15运动学与坐标位姿、tf2 坐标变换、TF… - [ROS2系列教程:Moveit2进阶(抓取/OMPL/自定义规划)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-jin-jie-zhua-qu-ompl-zi-ding.md): 理解规划器插件机制,能让你在特殊需求(狭窄通道、非标准成本函数)时不至于束手无策。 - [ROS2系列教程:Moveit2碰撞检测与避障](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-peng-zhuang-jian-ce-yu-bi.md): 把前面学的串成真本事——用 Pick & Place 流程实现"感知 → 抓取 → 搬运 → 放置",深入 OMPL 各规划器(RRT/RRTConnect/BIT\*)的选型与调参… - [ROS2系列教程:Moveit2运动学(IK/FK与运动学插件)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-yun-dong-xue-ik-fk-yu-yun.md): 理解插件的机制,比背 API 更有价值。下面写一个"最小可用"的自定义 IK 求解器插件。 - [ROS2系列教程:Moveit2仿真与运动规划(基础)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-fang-zhen-yu-yun-dong-gui.md): 本文是 ROS2 系列教程的第 36 篇:MoveIt2 仿真与运动规划(基础)。第 35 篇我们装好了 MoveIt2、写了一个两连杆机械臂的 URDF,并用… - [ROS2系列教程:Moveit2环境与机械臂建模准备](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-huan-jing-yu-ji-xie-bi-jian.md): 本文是 ROS2 系列教程的第 35 篇:MoveIt2 环境与机械臂建模准备。第 31~34 篇我们完成了移动底盘机器人的完整实战(建图、导航、任务、安全)。。第 35… - [ROS2系列教程:上位机建图与导航(原理综合)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-shang-wei-ji-jian-tu-yu-dao-hang.md): 本文是 ROS2 系列教程的第 33 篇:上位机建图与导航(原理综合)。第 32 篇我们把下位机的数据(里程计、IMU、传感器)接进了 ROS2,实现了"软硬结合"。但机器人拿到数据之后要干什么?… - [ROS2系列教程:完整实体机器人项目实战(软硬协同)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-wan-zheng-shi-ti-ji-qi-ren-xiang-mu.md): 本文是 ROS2 系列教程的第 34 篇:完整实体机器人项目实战(软硬协同)。第 31~33 篇分别完成了硬件搭建、软硬通信、建图导航三件大事… - [ROS2系列教程:嵌入式控制与通信开发(原理+仿真)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-qian-ru-shi-kong-zhi-yu-tong-xin-kai.md): 本文是 ROS2 系列教程的第 32 篇:嵌入式控制与通信开发(原理+仿真)。第 31 篇我们搭好了实体机器人的硬件骨架。但硬件只是"尸体"… - [ROS2系列教程:实体机器人硬件搭建(原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-shi-ti-ji-qi-ren-ying-jian-da-jian.md): 本文是 ROS2 系列教程的第 31 篇:实体机器人硬件搭建(原理)。第 26-30 篇我们打通了硬件控制全链路:电机、舵机、传感器、MicroROS、ROS2-Control… - [ROS2系列教程:控制硬件实战(舵机与传感器,原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-kong-zhi-ying-jian-shi-zhan-duo-ji.md): 本阶段(第 26-30 篇)我们打通了机器人硬件全链路篇主题核心收获26嵌入式点灯GPIO 本质,嵌入式开发流程27MicroROSMCU 直接成为 ROS2… - [ROS2系列教程:控制硬件实战(电机驱动,原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-kong-zhi-ying-jian-shi-zhan-dian-ji.md): / real\_motor\_hw.cpp:真实电机硬件接口(串口/MicroROS 连接下位机)public:// 声明关节及其接口// 基类解析 URDF 的 配置//… - [ROS2系列教程:ROS2-Control与控制器(原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-ros2-control-yu-kong-zhi-qi-yuan-li.md): 官方控制器不够用时,可以写自定义控制器。 - [ROS2系列教程:MicroROS接入(原理+仿真)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-microros-jie-ru-yuan-li-fang-zhen.md): 本文详解ROS2 MicroROS接入原理与仿真实践,解决嵌入式设备与上位机通信的通用性难题。通过MicroROS架构(Client+Agent+传输层),实现MCU作为ROS2节点无缝接入系统… - [ROS2系列教程:嵌入式开发从点灯开始(原理)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-qian-ru-shi-kai-fa-cong-dian-deng.md): 本文是 ROS2 系列教程的第 26 篇:嵌入式开发从点灯开始(原理)。第 25 篇我们完成了 Nav2 导航阶段(21-25)的全部内容,前面的教程全都是在仿真环境里运行:Gazebo… - [ROS2系列教程:Nav2进阶实践(自定义算法、插件与行为树)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-nav2-jin-jie-shi-jian-zi-ding-yi.md): / 继承 nav2\_core::Controller,实现 5 个纯虚接口public:// 1. 配置:从参数获取配置// 2. 清理与激活// 3. 核心:计算速度指令(每 control… - [ROS2系列教程:Nav2导航仿真实战(全局+局部规划)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-nav2-dao-hang-fang-zhen-shi-zhan.md): 本文是 ROS2 系列教程的第 24 篇:Nav2 导航仿真实战(全局+局部规划)。至此我们已有地图(第 21/22 篇)、有定位(第 23 篇),就差"自己走路"… - [ROS2系列教程:AMCL定位与地图](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-amcl-ding-wei-yu-di-tu.md): 本文是 ROS2 系列教程的第 23 篇:AMCL 定位与地图。前两篇我们学会了建图——slam\_toolbox 和 Cartographer 都能产出地图… - [ROS2系列教程:Cartographer建图进阶](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-cartographer-jian-tu-jin-jie.md): 本文是 ROS2 系列教程的第 22 篇:Cartographer 建图进阶。上一篇用 slam\_toolbox 建出了第一张地图,但它的回环能力在长廊、大场地、复杂环境里会吃力——累积误差难以消除… - [ROS2系列教程:SLAM建图(slam_toolbox)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-slam-jian-tu-slam-toolbox.md): 本文是 ROS2 系列教程的第 21 篇:SLAM 建图(slam\_toolbox)。前 20 篇我们完成了建模仿真阶段——小车有模型、有物理、有传感器,但"它不知道自己在哪里… - [ROS2系列教程:机器人模型导入仿真实战](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-ji-qi-ren-mo-xing-dao-ru-fang-zhen.md): 大型 ROS2 工程通常拆成两个功能包:分工原则:description 只管"机器人长什么样",bringup 管"系统怎么跑"。导航、SLAM 等后续阶段都在 bringup 里加 launch… - [ROS2系列教程:Gazebo插件(关节控制/IMU/激光雷达)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-gazebo-cha-jian-guan-jie-kong-zhi.md): 本文是 ROS2 系列教程的第 19 篇:Gazebo 插件(关节控制/IMU/激光雷达)。上一篇小车能在 Gazebo 里跑动了,但它还是个"瞎子"——没有传感器,不知道周围有什么… - [ROS2系列教程:Gazebo仿真基础与世界搭建](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-gazebo-fang-zhen-ji-chu-yu-shi-jie.md): 内容是关于ROS2第18篇,Gazebo仿真基础与世界搭建。摘要需简洁概括核心内容。注意字数限制。本文是ROS2系列第18篇,讲解Gazebo仿真基础与世界搭建… - [ROS2系列教程:Xacro参数化建模](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-xacro-can-shu-hua-jian-mo.md): 回顾上一篇的差速小车,你实际感受到了:Xacro 是 URDF 的超集:一个合法的 Xacro 文件经过 命令展开后,得到的是标准 URDF。它新增的能力:ROS2 中 由同名包提供,启动时用… - [ROS2系列教程:URDF机器人建模(link与joint)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-urdf-ji-qi-ren-jian-mo-link-yu-joint.md): URDF 是一种 XML 格式的机器人描述文件,用树状结构描述机器人的物理组成:刚体(link)和关节(joint)。它回答三个问题:URDF 是 ROS1 时代的产物,被 ROS2 完整继承… - [ROS2系列教程:TF树与多坐标系管理(C++/Python)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-tf-shu-yu-duo-zuo-biao-xi-guan-li-c.md): 上一篇文章里,每条变换是"父 → 子"单向的。当你把所有变换按父子关系连起来,就得到一棵TF 树(TF Tree)map│odom│base\_link/… - [ROS2系列教程:tf2坐标变换详解(C++/Python)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-tf2-zuo-biao-bian-huan-xiang-jie-c.md): 查询” = 给定两个坐标系,返回它们之间的变换。例如:激光雷达检测到前方 1 米的墙(在laser坐标系),要换算到map坐标系,就需要的变换。 - [ROS2系列教程:运动学基础(位姿与坐标变换)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-yun-dong-xue-ji-chu-wei-zi-yu-zuo.md): ",即从参考系出发依次施加 yaw → pitch → roll 的。 - [ROS2系列教程:常用工具详解(ros2 CLI、rqt、RViz、ros2 bag、tf2基础)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-chang-yong-gong-ju-xiang-jie-ros2.md): tf2是 ROS2 的坐标变换库:维护"坐标系树",提供任意两个坐标系间的位姿变换。机器人上每个部件都有一个坐标系(base\_link、laser、camera、map),tf2 让"激光点坐标 →… - [ROS2系列教程:launch文件与多节点启动(参数/条件/复用)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-launch-wen-jian-yu-duo-jie-dian-qi.md): 本篇文章系统掌握了 launch:最简多节点启动、参数传递(yaml/字典/launch 参数)、话题重映射、命名空间、条件控制、子 launch 包含、组与外部进程、事件处理器,以及调试技巧… - [ROS2系列教程:Action动作通信(带反馈长任务)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-action-dong-zuo-tong-xin-dai-fan-kui.md): 动作接口用.action文件定义,三部分用---\# action/CountUntil.action —— 计数到目标值int32 target\_number \#… - [ROS2系列教程:参数Parameter与QoS策略](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-can-shu-parameter-yu-qos-ce-l-e.md): 参数(Parameter)是节点的配置项:一个带类型的键值对,运行时可查询、可修改。参数让"代码与配置分离"——同样的节点,通过不同参数启动,就能适配不同机器人、不同环境。参数名(parameter… - [ROS2系列教程:服务Service通信(下)同步异步与回调组](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-fu-wu-service-tong-xin-xia-tong-bu.md): 本篇文章把服务通信推向工程级:理解了同步/异步的底层机制(伪同步本质)、并发请求与多请求处理、回调组(互斥/可重入)与多线程执行器、超时与指数退避重试、服务与 QoS 的关系… - [ROS2系列教程:服务Service通信(上)客户端与服务端](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-fu-wu-service-tong-xin-shang-ke-hu.md): 沿用第 4 篇的包,添加验证接口可用本篇文章完成了服务通信的完整入门:理解了 Request/Response 模型与适用场景,用 Python 和 C++ 分别实现了服务端、同步客户端、异步客户端… - [ROS2系列教程:话题Topic通信(下)自定义消息与周期](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-hua-ti-topic-tong-xin-xia-zi-ding-yi.md): 定义 .msg/.srv 文件(接口包)→ colcon build 生成类型代码→ 发布者/订阅者 include/import 使用→ ros2 run 运行验证第 4 篇我们定义了(含… - [ROS2系列教程:话题Topic通信(上)发布者与订阅者](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-hua-ti-topic-tong-xin-shang-fa-bu.md): 本篇文章完成了话题通信的入门:理解了 Publish/Subscribe 模型的三大解耦优势、DDS 去中心化发现机制,用 Python 与 C++ 分别实现了发布者/订阅者并验证了跨语言通信… - [ROS2系列教程:package结构(CMake与Python两种包)](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-package-jie-gou-cmake-yu-python.md): ROS2 内置的std\_msgs覆盖常见场景,但你的业务数据(比如"机器人位姿 + 置信度 + 时间戳")往往是组合结构。这时需要自定义消息类型——定义一个.msg文件,rosidl… - [ROS2系列教程:工作空间与colcon构建](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-gong-zuo-kong-jian-yu-colcon-gou-jian.md): 本篇文章把 ROS2 从"脚本"提升到了"工程":理解了工作空间四目录结构、package 的概念与两种构建类型(ament\_python / ament\_cmake),掌握了 colcon… - [ROS2系列教程:认识节点与ROS图](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-ren-shi-jie-dian-yu-ros-tu.md): 在 ROS2 中,节点(Node)是一个负责执行特定任务的独立进程或进程内模块。一个机器人系统通常由几十个节点组成:有的节点负责读取激光雷达数据,有的负责做定位,有的负责规划路径,有的负责驱动电机… - [ROS2系列教程:Humble环境搭建与Hello World](https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-humble-huan-jing-da-jian-yu-hello.md): 本节我们完成了 ROS2 的入门:理解 ROS2 与 ROS1 的架构区别、DDS 与 QoS 的概念、搭建 Humble 开发环境的基本流程,并用 C++ 与 Python 两种语言写出了第一个… --- # Category: C与C++ URL: https://wsj-blog.pages.dev/categories/C%E4%B8%8EC%2B%2B/ ## 31 posts - [C++综合实战:数据结构与算法实现及系列总结](https://wsj-blog.pages.dev/posts/c-zong-he-shi-zhan-shu-ju-jie-gou-yu-suan-fa-shi-xian-ji-xi.md): 阶段篇目核心内容入门1-5语法基础、控制流、函数、数组/指针、类初探进阶6-10类与对象、运算符重载、继承多态、内存管理、文件流STL11-15容器、迭代器、算法库、函数对象/Lambda、适配器模板… - [C++综合实战:网络编程入门与HTTP客户端](https://wsj-blog.pages.dev/posts/c-zong-he-shi-zhan-wang-luo-bian-cheng-ru-men-yu-http-ke-hu.md): Socket 基础(客户端/服务器模型与 TCP 通信四步)、HTTP 协议(请求/响应格式、方法、状态码)、迷你 HTTP 客户端(构造 GET 请求、解析响应头与… - [C++综合实战:面向对象图书管理系统](https://wsj-blog.pages.dev/posts/c-zong-he-shi-zhan-mian-xiang-dui-xiang-tu-shu-guan-li-xi.md): 本篇用图书管理系统BookReader类的封装(数据 + 行为)、EBook派生类的继承与多态(virtual 重写)、Library管理类的STL 容器(vector +… - [C++工程化:编译链接、调试与性能优化](https://wsj-blog.pages.dev/posts/c-gong-cheng-hua-bian-yi-lian-jie-tiao-shi-yu-xing-neng-you.md): 四阶段编译链接(预处理/编译/汇编/链接)与常见错误排查、GDB 调试(断点/单步/打印变量/调用栈)、内存检测(ASan 与 Valgrind… - [C++工程化:CMake构建与多文件项目管理](https://wsj-blog.pages.dev/posts/c-gong-cheng-hua-cmake-gou-jian-yu-duo-wen-jian-xiang-mu.md): 本篇讲解了 C++ 工程化核心的CMake 构建系统:最小项目与构建流程、多文件项目的目录组织与静态库与动态库的构建与选型(含对比表)、条件编译/option/查找与自动拉取依赖(find\_packa… - [C++并发编程:异步任务与线程池实战](https://wsj-blog.pages.dev/posts/c-bing-fa-bian-cheng-yi-bu-ren-wu-yu-xian-cheng-chi-shi-zhan.md): 一行启动异步任务并获取结果(含异常传递与 wait\_for 超时)、手动生产-消费单次结果、把任务与 future 打包、线程池的核心实现(条件变量任务队列 + packaged\_task 封装 +… - [C++并发编程:条件变量与原子操作](https://wsj-blog.pages.dev/posts/c-bing-fa-bian-cheng-tiao-jian-bian-liang-yu-yuan-zi-cao-zuo.md): 实现阻塞等待与通知(含谓词重载防虚假唤醒)、生产者消费者模型的完整实现、atomic 原子类型(fetch\_add/CAS… - [C++并发编程:线程与互斥锁](https://wsj-blog.pages.dev/posts/c-bing-fa-bian-cheng-xian-cheng-yu-hu-chi-suo.md): 的创建/join/detach 与参数传递、数据竞争的危害、std::mutex与 RAII 封装(lock\_guard / unique\_lock /… - [C++20新特性精讲:概念、范围与三路比较](https://wsj-blog.pages.dev/posts/c-20-xin-te-xing-jing-jiang-gai-nian-fan-wei-yu-san-lu-bi.md): concepts让模板约束可读、可复用;ranges以惰性视图与管道风格重构数据流;三路比较一次操作自动生成全套比较运算符;协程与模块开启语言新范式;std::span与constexpr扩展让代码更… - [C++17新特性精讲:结构化绑定、if constexpr与实用类型](https://wsj-blog.pages.dev/posts/c-17-xin-te-xing-jing-jiang-jie-gou-hua-bang-ding-if.md): 本篇讲解了 if/switch 初始化语句、结构化绑定、if constexpr 编译期分支、std::optional/variant/any 三种实用类型、string\_view… - [C++11/14新特性精讲:移动语义与智能指针实战](https://wsj-blog.pages.dev/posts/c-11-14-xin-te-xing-jing-jiang-yi-dong-yu-yi-yu-zhi-neng.md): 内容是关于C++现代特性:左值右值、移动语义、=default/=delete等。需概括核心。确保字数。本文介绍C++现代特性:五类值分类与右值引用生命周期延长… - [C++模板教程:类型推导与完美转发](https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-lei-xing-tui-dao-yu-wan-mei-zhuan-fa.md): 完美转发是保留参数左右值属性// 接收函数:区分左值右值cout << "左值处理: " << value << endl;cout << "右值处理: " << value << endl;//… - [C++模板教程:变参模板、折叠表达式与SFINAE](https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-bian-can-mu-ban-zhe-die-biao-da-shi-yu.md): 变参模板(Variadic Templates)允许模板接受任意数量的参数// 参数包:typename... ArgsArgs...是类型参数包。args...是函数参数包。获取参数个数… - [C++模板教程:特化、偏特化与类型萃取](https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-te-hua-pian-te-hua-yu-lei-xing-cui-qu.md): 模板是「通用配方」,但某些特殊类型需要专门的实现。// 通用模板:处理一般类型a : b;// 全特化:为特定类型定制(如 const char\* 比较字符串而非指针)a : b;类型萃取(Type… - [C++模板教程:函数模板与类模板入门](https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-han-shu-mu-ban-yu-lei-mu-ban-ru-men.md): 普通函数精确匹配优先于模板。模板可以匹配任何类型3. 用<>可以强制调用模板版本。public:// 类外定义:需要重复 template 前缀// 类外定义:需要重复 template… - [C++STL教程:容器适配器与实用工具](https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-rong-qi-shi-pei-qi-yu-shi-yong-gong-ju.md): 容器适配器是基于其他容器包装出的受限接口适配器底层容器(默认)特性stackdeque后进先出 LIFOqueuedeque先进先出 FIFOvector按优先级出队// 病情严重程度//… - [C++STL教程:函数对象与Lambda表达式](https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-han-shu-dui-xiang-yu-lambda-biao-da-shi.md): 函数对象(Functor)是重载了operator()的类对象,可以像函数一样调用。与普通函数相比,它可以保存状态。// 函数对象:重载 operator()private:int base;//… - [C++STL教程:迭代器与算法库](https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-die-dai-qi-yu-suan-fa-ku.md): 迭代器是统一访问容器元素的接口,让算法不关心容器类型。可以把迭代器理解为「智能指针」,支持->++--等操作。int score;// 默认升序// 降序int());//… - [C++STL教程:关联容器与无序容器](https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-guan-lian-rong-qi-yu-wu-xu-rong-qi.md): int age;// 自定义比较器(按年龄排序)people.insert({"张三", 30});people.insert({"李四", 25});people.insert({"王五",… - [C++STL教程:容器总览与序列容器](https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-rong-qi-zong-lan-yu-xu-lie-rong-qi.md): STL(Standard Template Library,标准模板库)是 C++ 标准库的核心,由容器、迭代器、算法、函数对象、适配器五大部分组成。它把「数据存储」与「数据处理」解耦,实现高效复用… - [C++进阶教程:文件流与字符串流](https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-wen-jian-liu-yu-zi-fu-chuan-liu.md): 1.3 读取文件1.4 打开模式| 模式 | 含义 || — | — | — || | 读模式(ifstream 默认) || | 写模式(ofstream 默认,截断) || | 追加(不截断)… - [C++进阶教程:动态内存管理与智能指针](https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-dong-tai-nei-cun-guan-li-yu-zhi-neng.md): RAII(Resource Acquisition Is Initialization,资源获取即初始化):把资源(内存、文件、锁)的获取放在构造函数中,释放放在析构函数中… - [C++进阶教程:继承与多态](https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-ji-cheng-yu-duo-tai.md): 多态(Polymorphism)指同一个调用在不同对象上表现出不同行为。通过基类指针/引用调用虚函数,实际执行的是派生类的实现。多态三要素基类中定义虚函数。派生类重写虚函数。通过基类指针/引用调用… - [C++进阶教程:运算符重载与类型转换](https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-yun-suan-fu-zhong-zai-yu-lei-xing.md): 运算符重载是让==<<等运算符可以作用于自定义类型,使代码更自然、更直观。例如c1 + c2让复数相加,直接打印对象… - [C++进阶教程:类与对象深入](https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-lei-yu-dui-xiang-shen-ru.md): 本篇深入讲解了构造函数的种类与初始化列表、构造析构顺序、深拷贝与浅拷贝的区别及三法则、static 成员、const 成员函数、this 指针和友元,并用完整 String 类串联实战… - [C++入门教程:结构体、枚举与类初探](https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-jie-gou-ti-mei-ju-yu-lei-chu-tan.md): 结构体是不同类型数据的集合// 定义学生结构体int age;// 定义并初始化Student s1;// 默认构造s1.name = "张三";// 列表初始化(C++11)Student s2… - [C++入门教程:数组、字符串与指针入门](https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-shu-zu-zi-fu-chuan-yu-zhi-zhen-ru-men.md): 本文是C++系列教程的第4篇,重点讲解数组、字符串和指针的核心概念。主要内容包括:1) 一维/多维数组的声明、初始化和遍历,强调数组越界的危险性;2)… - [C++入门教程:函数与作用域详解](https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-han-shu-yu-zuo-yong-yu-xiang-jie.md): / 函数定义:返回类型 函数名(参数列表) { 函数体 }// 无返回值函数// 调用无参函数// 调用带参函数,接收返回值return 0;当函数定义在使用之后时,需要先声明//… - [C++入门教程:运算符、表达式与控制流程](https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-yun-suan-fu-biao-da-shi-yu-kong-zhi-liu.md): 本文是C++系列教程第二篇,重点讲解运算符、表达式和程序控制结构。主要内容包括: 运算符详解:分类介绍算术/关系/逻辑/位运算等8类运算符,特别说明整数除法截断、自增自减顺序、短路求值特性… - [C语言入门教程:从零开始学C语言编程](https://wsj-blog.pages.dev/posts/c-yu-yan-ru-men-jiao-cheng-cong-ling-kai-shi-xue-c-yu-yan.md): 本篇介绍了 C 语言的环境搭建、基础语法、运算符、条件循环,并用猜数字游戏串联实战。下一篇将深入讲解 C 语言的精髓——指针与内存管理,敬请期待! - [C++入门教程:从零开始学C++编程](https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-cong-ling-kai-shi-xue-c-bian-cheng.md): 本文是C++入门教程的第一篇,主要介绍了C++的基本概念、开发环境搭建、基础语法和实战练习… --- # Category: Python URL: https://wsj-blog.pages.dev/categories/Python/ ## 20 posts - [Python综合实战:从零开发一个命令行工具](https://wsj-blog.pages.dev/posts/python-zong-he-shi-zhan-cong-ling-kai-fa-yi-ge-ming-ling.md): 的收官实战篇(第 20 篇)。前 19 篇学习了语法、库、工程化等知识,本篇把它们串起来,从零开发一个。 - [Python进阶教程:Git版本控制与团队协作](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-git-ban-ben-kong-zhi-yu-tuan-dui.md): 本文是Python进阶教程中的Git版本控制指南,介绍了Git的核心概念与团队协作技巧… - [Python进阶教程:算法与数据结构入门](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-suan-fa-yu-shu-ju-jie-gou-ru-men.md): 本篇介绍了时间复杂度、常用数据结构(栈、队列)、排序与查找算法、递归和动态规划入门,并用 LRU 缓存串联实战。刷题建议:从 LeetCode 简单题开始,每天 1-2 题,坚持就是胜利。 - [Python进阶教程:机器学习入门(Scikit-learn)](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-ji-qi-xue-xi-ru-men-scikit-learn.md): 本篇介绍了机器学习的基本流程、分类/回归/聚类三大经典任务、交叉验证和特征工程,并用鸢尾花数据集跑通了完整实战。机器学习重在实践,建议用 UCI 或 Kaggle 的数据集多做练习。 - [Python进阶教程:自动化办公实战](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-zi-dong-hua-ban-gong-shi-zhan.md): 这篇Python办公自动化教程介绍了几个常用库的实战应用,包括:openpyxl处理Excel(读取/写入/合并)、python-docx操作Word文档、pdfplumber提取PDF文本… - [Python进阶教程:项目工程化与虚拟环境](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-xiang-mu-gong-cheng-hua-yu-xu-ni.md): 本篇介绍了虚拟环境、依赖管理、项目目录结构、.gitignore 规范等工程化基础。良好的工程习惯能让项目更易维护、协作和部署。下一篇将介绍自动化办公,用 Python 解放重复劳动。 - [Python进阶教程:单元测试与代码质量](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-dan-yuan-ce-shi-yu-dai-ma-zhi.md): 本篇介绍了 unittest 与 pytest 两大测试框架、Mock 模拟外部依赖、以及 flake8/black/mypy/coverage 等代码质量工具,并为计算器类编写了完整测试… - [Python进阶教程:装饰器与生成器深入](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-zhuang-shi-qi-yu-sheng-cheng-qi.md): 本篇深入讲解了装饰器(基础、带参数、内置装饰器)和生成器(基础、表达式、无限序列),并用手写缓存装饰器串联实战。理解这两个特性,能写出更简洁、更 Pythonic 的代码。 - [Python进阶教程:异步编程(asyncio)](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-yi-bu-bian-cheng-asyncio.md): 异步编程让程序在等待 I/O 时不阻塞,而是继续执行其他任务。单线程内实现并发,无锁竞争上下文切换开销极小适合大量 I/O 等待场景(网络请求、数据库)Python 3.7+… - [Python进阶教程:正则表达式进阶与文本处理](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-zheng-ze-biao-da-shi-jin-jie-yu.md): 本篇深入讲解了正则的分组捕获、贪婪匹配、前后断言、预编译等进阶技巧,并提供了日志解析和文本清理两个实战工具。正则表达式是文本处理的瑞士军刀,值得花时间熟练掌握。 - [Python进阶教程:调试技巧与性能优化](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-tiao-shi-ji-qiao-yu-xing-neng-you.md): 恭喜你完成Python 入门教程系列的全部 10 篇文章!篇目主题1Python 入门:基础语法2面向对象编程(OOP)3文件操作与异常处理4常用标准库与实用技巧5网络编程与数据抓取6多线程与多进程7… - [Python进阶教程:Web开发入门(Flask)](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-web-kai-fa-ru-men-flask.md): 简单:一个文件即可启动 Web 服务灵活:可以自由选择组件生态好:文档丰富,社区庞大本篇介绍了 Flask 的路由、模板、表单处理和 JSON API,并用待办清单应用串联实战… - [Python进阶教程:数据分析入门(NumPy与Pandas)](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-shu-ju-fen-xi-ru-men-numpy-yu.md): 本篇介绍了 NumPy 数组运算和 Pandas 的 Series、DataFrame、数据清洗、分组聚合,并用销售数据分析串联实战。下一篇将介绍Web 开发入门(Flask),敬请期待! - [Python进阶教程:数据库操作入门](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-shu-ju-ku-cao-zuo-ru-men.md): 数据库(Database)是有组织存储数据的仓库,支持高效增删改查(CRUD)。SQLite:轻量级,单文件… - [Python进阶教程:多线程与多进程](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-duo-xian-cheng-yu-duo-jin-cheng.md): 本文介绍了Python中多线程与多进程的使用方法。主要内容包括:1)多线程的创建方式(直接调用和继承Thread类)及线程池使用;2)线程安全与锁机制;3)多进程的适用场景(CPU密集型任务)及进程池… - [Python进阶教程:网络编程与数据抓取](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-wang-luo-bian-cheng-yu-shu-ju.md): 本文介绍了Python网络编程与数据抓取的基础知识,涵盖HTTP协议、urllib和requests库的使用、BeautifulSoup解析HTML以及爬虫规范… - [Python进阶教程:常用标准库与实用技巧](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-chang-yong-biao-zhun-ku-yu-shi.md): 摘要: 本文是Python入门系列第4篇,重点讲解常用标准库与实用技巧。涵盖os/sys(系统操作)、datetime(日期处理)、json(数据交换)、re(正则表达式)、random/collec… - [Python进阶教程:文件操作与异常处理](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-wen-jian-cao-zuo-yu-yi-chang-chu.md): 本文是Python入门教程系列的第三篇,主要讲解文件操作和异常处理。内容包括:文件打开与关闭(推荐使用with语句)、不同文件模式(r/w/a等)、读写文件方法(read/readline/for循环… - [Python进阶教程:面向对象编程(OOP)](https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-mian-xiang-dui-xiang-bian-cheng.md): 面向对象编程(Object-Oriented Programming,简称 OOP)是一种编程思想,它把数据和对数据的操作封装成对象… - [Python入门教程:从零开始学Python编程](https://wsj-blog.pages.dev/posts/python-ru-men-jiao-cheng-cong-ling-kai-shi-xue-python-bian.md): 本文介绍了 Python 的安装、基础语法和一个小实战项目。学习编程最重要的是多敲代码,建议每天坚持练习。后续我会继续更新 Python 进阶教程(面向对象、文件操作、数据处理等),欢迎关注! --- # Category: 嵌入式 URL: https://wsj-blog.pages.dev/categories/%E5%B5%8C%E5%85%A5%E5%BC%8F/ ## 7 posts - [Orange Pi USB Device 模式设置指南](https://wsj-blog.pages.dev/posts/orange-pi-usb-device-mo-shi-she-zhi-zhi-nan.md): 本文介绍了在Orange Pi开发板上设置USB为设备模式的两种方法。推荐使用set\_device.sh脚本,执行简单命令即可完成设置;也可手动操作,通过echo命令写入mode文件并重启服务… - [freertos vTaskDelay卡死](https://wsj-blog.pages.dev/posts/freertos-vtaskdelay-ka-si.md): 解决办法,改成在main方法中创建任务,调用vTaskDelay工作正常,我用的是nuvoton的库,所以可能与sdk移植有关系,反正问题就是这么解决的,希望能帮到同路者… - [stm32高级定时器和基本定时器的踩坑指南](https://wsj-blog.pages.dev/posts/stm32-gao-ji-ding-shi-qi-he-ji-ben-ding-shi-qi-de-cai-keng.md): 高级定理器tim1和tim8的中断回调函数和通用定时器不相同。基本定时器中只有计数,没有分频。 - [深度计解算板的报错的问题解决(MS5437转串口模块)](https://wsj-blog.pages.dev/posts/shen-du-ji-jie-suan-ban-de-bao-cuo-de-wen-ti-jie-jue-ms5437.md): 做水下机器人控制中用到了深度解算板,写了相应的驱动程序,也遇到了一些问题,现把一些问题的解决方案放出来,供大家参考。驱动被设计为拥有指令器和接收机两个部分,完全被动方式运行,与具体的平台解耦… - [Espressif-IDE中文汉化教程全网首发](https://wsj-blog.pages.dev/posts/espressif-ide-zhong-wen-han-hua-jiao-cheng-quan-wang-shou-fa.md): Espressif-IDE中文汉化教程 - [关于大越创新的板载USB–TTL通信失败问题的修复](https://wsj-blog.pages.dev/posts/guan-yu-da-yue-chuang-xin-de-ban-zai-usb-ttl-tong-xin-shi.md): 问题描述:这几天用大越创新stm32f103rct6(升级版)进行传感器读取,发现使用板载串口,电脑识别ch340驱动比较慢,而且发送数据会卡死(如果判断没问题的话是单片机卡了),重新启动串口后… - [ROS使用的bug总结](https://wsj-blog.pages.dev/posts/ros-shi-yong-de-bug-zong-jie.md): \[rospack] Error: package 'xxxxxxxxxxx' not found --- # Category: Linux 桌面 URL: https://wsj-blog.pages.dev/categories/Linux%20%E6%A1%8C%E9%9D%A2/ ## 5 posts - [Ubuntu 26.04 + GNOME Wayland 设置视频动态壁纸(Hidamari 实测可用)](https://wsj-blog.pages.dev/posts/ubuntu-26-04-gnome-wayland-she-zhi-shi-pin-dong-tai-bi-zhi.md): 判断能不能用,只看工具是否支持,不要看教程年份GNOME 用 Mutter,不实现 wlr-layer-shell,所以 mpvpaper… - [Ubuntu 自动登录后 GNOME 远程桌面连不上?一次登录钥匙环锁定的排查与永久修复](https://wsj-blog.pages.dev/posts/ubuntu-zi-dong-deng-lu-hou-gnome-yuan-cheng-zhuo-mian-lian.md): 自动登录 + GNOME 远程桌面「连不上但解锁后能用」,几乎都是登录钥匙环被锁定导致的判断依据是 GRD 日志里的… - [GNOME 50 (Wayland) 桌面网速悬浮窗:TopHat 部署实录与三个踩坑](https://wsj-blog.pages.dev/posts/gnome-50-wayland-zhuo-mian-wang-su-xuan-fu-chuang-tophat-bu.md): 想在 Ubuntu 的 GNOME 桌面上常驻一个「网速悬浮窗」:不占顶栏、不打断工作、全屏看视频或跑下载时也能瞄一眼实时上下行速率。本文记录在一台 GNOME Shell 50.1 +… - [Ubuntu IBus 中文输入法终极优化指南:告别切换烦恼,提升输入效率](https://wsj-blog.pages.dev/posts/ubuntu-ibus-zhong-wen-shu-ru-fa-zhong-ji-you-hua-zhi-nan.md): 本文提供了Ubuntu 20.04/22.04系统下优化IBus中文输入法切换的完整指南… - [ubuntu安装时窗口太小,点不到应用](https://wsj-blog.pages.dev/posts/ubuntu-an-zhuang-shi-chuang-kou-tai-xiao-dian-bu-dao-ying.md): 要注意的是,两数字间是x字母,⽽不是数字键区的\*号。另外此效果,只能在当前运⾏虚拟机下有效,如果关了虚拟机,之后就没⽤了。所以你必需,⽴刻在system setting⾥点击display… --- # Category: 杂记 URL: https://wsj-blog.pages.dev/categories/%E6%9D%82%E8%AE%B0/ ## 3 posts - [数据精度和运算速度的中庸之道](https://wsj-blog.pages.dev/posts/shu-ju-jing-du-he-yun-suan-su-du-de-zhong-yong-zhi-dao.md): 最近在写步进电机的S型加速算法,遇到了这个问题,由于要计算s型曲线,使用exp函数一次全部计算完大概需要1.2s,单片机的实时性得不到保证,每次在pwm中断回调中计算也是耗费算力… - [Win32DiskImage写入123报错](https://wsj-blog.pages.dev/posts/win32diskimage-xie-ru-123-bao-cuo.md): 将SD卡格式化为FAT32,然后向其中写一个img文件。利用读卡器将SD卡连接在主机上,然后使用win32disimager软件向指定盘符写时报错123 - [这个东西让我的Python代码快了100倍](https://wsj-blog.pages.dev/posts/zhe-ge-dong-xi-rang-wo-de-python-dai-ma-kuai-le-100-bei.md): 转载于Python数据挖掘。Python 已经得到了全球程序员的喜爱,连续多期稳坐编程语言排行榜第一把交椅。但是还是遭到一些人的诟病,原因之一就是认为它运行缓慢。… --- # Category: 技术 URL: https://wsj-blog.pages.dev/categories/%E6%8A%80%E6%9C%AF/ ## 1 posts - [这个博客是怎么搭起来的](https://wsj-blog.pages.dev/posts/build-my-blog.md): 从 Astro 主题到 EdgeOne Pages 部署,再到自建 Waline 评论,记录这个博客的完整搭建过程。 --- # Category: 生活 URL: https://wsj-blog.pages.dev/categories/%E7%94%9F%E6%B4%BB/ ## 1 posts - [你好,世界](https://wsj-blog.pages.dev/posts/hello-world.md): 这是这个博客的第一篇文章,说说为什么开始写,以及准备写些什么。 --- # 全部标签 URL: https://wsj-blog.pages.dev/tags/ - [Astro](https://wsj-blog.pages.dev/tags/Astro/): 1 posts - [EdgeOne](https://wsj-blog.pages.dev/tags/EdgeOne/): 1 posts - [Waline](https://wsj-blog.pages.dev/tags/Waline/): 1 posts - [随笔](https://wsj-blog.pages.dev/tags/%E9%9A%8F%E7%AC%94/): 1 posts --- # Tag: Astro URL: https://wsj-blog.pages.dev/tags/Astro/ ## 1 posts - [这个博客是怎么搭起来的](https://wsj-blog.pages.dev/posts/build-my-blog.md): 从 Astro 主题到 EdgeOne Pages 部署,再到自建 Waline 评论,记录这个博客的完整搭建过程。 --- # Tag: EdgeOne URL: https://wsj-blog.pages.dev/tags/EdgeOne/ ## 1 posts - [这个博客是怎么搭起来的](https://wsj-blog.pages.dev/posts/build-my-blog.md): 从 Astro 主题到 EdgeOne Pages 部署,再到自建 Waline 评论,记录这个博客的完整搭建过程。 --- # Tag: Waline URL: https://wsj-blog.pages.dev/tags/Waline/ ## 1 posts - [这个博客是怎么搭起来的](https://wsj-blog.pages.dev/posts/build-my-blog.md): 从 Astro 主题到 EdgeOne Pages 部署,再到自建 Waline 评论,记录这个博客的完整搭建过程。 --- # Tag: 随笔 URL: https://wsj-blog.pages.dev/tags/%E9%9A%8F%E7%AC%94/ ## 1 posts - [你好,世界](https://wsj-blog.pages.dev/posts/hello-world.md): 这是这个博客的第一篇文章,说说为什么开始写,以及准备写些什么。 --- ## 这个博客是怎么搭起来的 URL: https://wsj-blog.pages.dev/posts/build-my-blog/ License: CC-BY-NC-SA-4.0 ## 整体结构 这个博客是纯静态站点,没有自己的服务器: ```text Markdown 文章 ↓ Astro 构建 静态 HTML(dist/) ↓ Git push EdgeOne Pages 自动构建并部署 ↓ 评论请求 Waline 服务(Vercel) → Neon Postgres ``` 写作只需要新建 Markdown 文件,提交推送后自动上线。 ## 一、用 Astro + Stalux 建站 主题是 [Stalux](https://github.com/xingwangzhe/stalux),基于 Astro 构建,自带暗色玻璃风格、全文搜索、标签分类、RSS 和文章目录。 本地开发用 Bun: ```bash git clone https://gitee.com/open-zynq/personal-blog.git cd personal-blog bun install bun run dev ``` 文章放在 `stalux/posts/`,站点信息在 `stalux/config/` 下的几个 YAML 文件里,改配置不用碰代码。 ## 二、部署到 EdgeOne Pages 在 EdgeOne Makers 里导入 Gitee 仓库,构建配置: | 项 | 值 | | --- | --- | | 框架预设 | Astro | | 根目录 | `./` | | 输出目录 | `dist` | | 安装命令 | `bun install` | | 构建命令 | `bun run styles:build && bunx --bun astro build` | 之后每次推送到 `main`,EdgeOne 都会自动重新构建,几十秒就能上线。 ## 三、评论:Waline + Neon 静态站点自己存不了评论,所以单独部署了一个 Waline 服务: 1. Neon 建一个免费 Postgres 数据库,导入官方 `waline.pgsql` 建表 2. Vercel 用 Waline 的 `example` 模板建项目,配置数据库环境变量 3. 博客的 `comment.yml` 填入服务地址,开启评论 ```yaml id: comment enabled: true waline: serverURL: https://your-waline.vercel.app lang: zh-CN login: "enable" ``` ## 四、日常写作 新增文章只需要一个 Markdown 文件: ```markdown --- title: 文章标题 abbrlink: my-post date: "2026-11-11 21:00:00" tags: [标签] categories: [分类] desc: 一句话摘要 --- 正文…… ``` 日期必须补零,否则时区解析会出问题。写完提交推送即可,构建、搜索索引、RSS 都会自动更新。 ## 小结 整套东西全部在免费额度内:EdgeOne Pages 免费版、Vercel Hobby、Neon 免费数据库。成本是零,维护成本也很低——大部分时间只需要写 Markdown。 --- ## 你好,世界 URL: https://wsj-blog.pages.dev/posts/hello-world/ License: CC-BY-NC-SA-4.0 ## 为什么开始写 一直以来,学到的东西大多是“用完就丢”:命令查过就忘,坑踩过还会再踩,读过的书过几个月只剩一个模糊的印象。 开这个博客,主要是想给自己留一份可检索的记录。写下来的过程本身也会逼着把问题想清楚——能把一件事讲明白,才算真的弄懂了。 ## 准备写些什么 大致会分成三类: - **技术笔记**:前端、后端、Linux,以及各种折腾环境的记录 - **工程实践**:踩过的坑、排查过程、可以复现的操作步骤 - **日常随想**:读过的书、用过的工具、一些零散的想法 不追求每篇都完整成体系,先写下来,之后再慢慢补。 ## 关于这个站点 站点用 [Astro](https://astro.build) 构建,主题是 [Stalux](https://github.com/xingwangzhe/stalux),部署在 EdgeOne Pages,评论用自建的 Waline。 如果你对搭建过程感兴趣,可以看下一篇:[这个博客是怎么搭起来的](/posts/build-my-blog/)。 欢迎在下方留言,也欢迎交换友链。 --- ## Ubuntu 26.04 + GNOME Wayland 设置视频动态壁纸(Hidamari 实测可用) URL: https://wsj-blog.pages.dev/posts/ubuntu-26-04-gnome-wayland-she-zhi-shi-pin-dong-tai-bi-zhi/ License: CC-BY-NC-SA-4.0 ### 一、先说结论 在 Ubuntu 26.04(GNOME Shell 50)+ Wayland 环境下,想要"视频当壁纸",目前**唯一开箱可用**的方案是 **Hidamari**(Flatpak 安装,官方明确支持 GNOME Wayland)。 网上流传的一大堆教程(mpvpaper、Komorebi、Wallpaper Engine 的 linux-wallpaperengine 移植版、swww、hyprpaper)在你这台机器上**全部无效**,原因不是装错了,而是底层协议不支持,见下一节。 我的环境: ``` Ubuntu 26.04.1 LTS GNOME Shell 50.1 XDG_SESSION_TYPE=wayland XDG_CURRENT_DESKTOP=ubuntu:GNOME ``` 可用以下命令确认自己的环境: ```bash cat /etc/os-release | head -n 3 gnome-shell --version echo $XDG_SESSION_TYPE $XDG_CURRENT_DESKTOP ``` ### 二、为什么大部分方案在 GNOME Wayland 上不能用 几乎所有 Linux 视频壁纸工具,本质都是"把视频窗口垫到桌面最底层",它们依赖 Wayland 的 **wlr-layer-shell** 协议来创建背景层。 而 GNOME 使用的窗口管理器是 **Mutter**,Mutter **不实现 wlr-layer-shell**(这是 wlroots 系合成器的协议)。所以: - mpvpaper:只支持 wlroots 合成器(Sway、Hyprland 等) - komorebi:KDE/GNOME 时代的 X11 方案 - hyprpaper:Hyprland 专用 - linux-wallpaperengine(Wallpaper Engine 的 Linux 移植):依赖 layer-shell,GNOME 下不显示 还有个坑:用 X11 时代的 xwinwrap 也不行,因为你跑的就是 Wayland 会话。 这一点是选型的关键,判断方法只有一条:**工具是否声明支持 GNOME Wayland**,而不是看它是不是"能放视频"。 ### 三、方案对比 | 方案 | 效果 | 是否适用 GNOME Wayland | | --- | --- | --- | | Hidamari | mp4 视频循环播放,可静音、可开机自启 | 适用(官方声明支持) | | GNOME 内置 timed wallpaper | 多张图片按时间渐变切换 | 适用(系统原生) | | mpvpaper / hyprpaper | 视频壁纸 | 不适用(需 wlr-layer-shell) | | Komorebi / xwinwrap | 视频壁纸 | 不适用(X11 方案) | | linux-wallpaperengine | 播放 Wallpaper Engine 资源 | 不适用(需 layer-shell) | 如果你只是想让桌面"动起来",用系统自带的定时壁纸就够,零安装零占用;如果要真正的视频效果,用 Hidamari。 ### 四、安装 Hidamari Hidamari 已上架 Flathub,直接用 Flatpak 装。注意一个坑:**系统级 flathub 源不能用 `--user` 安装**,如果没加过用户级源,会报错 `未发现用于 "flathub" 的远程引用`。 ```bash # 1. 添加用户级 Flathub 源(已存在则跳过) flatpak remote-add --user --if-not-exists flathub \ https://dl.flathub.org/repo/flathub.flatpakrepo # 2. 安装(会一并拉取 org.gnome.Platform 运行时,体积较大) flatpak install --user -y flathub io.github.jeffshee.Hidamari # 3. 启动 flatpak run io.github.jeffshee.Hidamari ``` 确认装好了: ```bash flatpak list --app | grep -i hidamari flatpak ps ``` ### 五、使用:选视频、应用、开机自启 Hidamari 是图形界面程序,**命令行没有传视频文件的参数**,它只支持这几个选项: ``` -p, --pause 启动前延迟若干秒 -b, --background 只启动壁纸,不显示主窗口 -d, --debug 输出调试日志 -r, --reset 重置用户配置 ``` 所以视频必须在界面里选: 1. 打开 Hidamari,点 **Open**(也可以直接把视频拖进窗口) 2. 在显示器列表里选中你的屏幕(比如 DP-3) 3. 点 **Apply**,壁纸立即生效 右上角菜单 → **Preferences**,建议开启以下几项: - **Autostart after login**:开机自动启动,不用每次手动开 - **Mute**:壁纸不吃声音 - **Pause when maximized**(默认已开):有窗口最大化时自动暂停解码,笔记本省电 - **Static wallpaper blur**(GNOME 专属):把当前帧模糊后作为静态底图,桌面图标区域更干净 使用要点: - 关掉设置窗口后**壁纸会继续播放**,这是正常行为,不是没关掉 - 想彻底停止:`flatpak kill io.github.jeffshee.Hidamari` - 换视频:重新打开窗口,Open 选新文件,再 Apply ### 六、踩坑记录 **1. 配置文件在哪里** Hidamari 的配置在沙箱内: ``` ~/.var/app/io.github.jeffshee.Hidamari/config/glib-2.0/settings/config.json ``` `data_source` 字段记录每个显示器对应的视频路径,`mode` 为 `MODE_NULL` 表示当前没设置视频。启动时终端会打印完整配置,用来判断到底有没有选中视频很方便。 **2. 启动后 `flatpak ps` 显示多个进程** 主进程加若干辅助进程,属正常现象,不要以为重复启动了。 **3. Flatpak 沙箱与文件访问** 在 GUI 里选文件走的是 portal,没有任何问题;但如果想用命令行 `--file-forwarding` 传文件进去,Hidamari 并不支持。这也是为什么必须走图形界面。 **4. 关于画质与功耗** Hidamari 会把视频拉伸铺满屏幕,所以**没必要用 4K 素材**,1080p 的循环视频完全够用,还能大幅降低解码压力。视频壁纸本身会持续占用 GPU 解码,笔记本建议插电时使用。 **5. 没有 ffprobe 怎么查视频参数** 最小化安装的 Ubuntu 默认没有 `ffprobe`,不要直接拿它判断视频分辨率。可以装 `ffmpeg`,或者干脆在 Hidamari 里看一下实际效果。 ### 七、备选:不用安装的系统原生动态壁纸 如果不想装任何东西,GNOME 原生支持**定时壁纸**:准备多张图片,写一个 XML 描述文件放到 `~/.local/share/backgrounds/`,再用 `gsettings` 把 `org.gnome.desktop.background picture-uri` 指向这个 XML,就能实现按时间自动渐变切换(比如白天一套、夜间一套)。 它不是视频,但胜在零依赖、零功耗、完全稳定,对长时间办公的机器更友好。 ### 八、总结 - 判断能不能用,只看工具是否支持 **GNOME Wayland**,不要看教程年份 - GNOME 用 Mutter,不实现 wlr-layer-shell,所以 mpvpaper 一类工具全部出局 - 视频壁纸选 **Hidamari**(Flathub),安装时注意加用户级 flathub 源 - 必须图形界面选视频,系统设置里记得开 Autostart、Mute、Pause when maximized - 素材 1080p 足够,注意功耗 --- ## ROS2系列教程:Moveit2真机控制与系列总结(原理) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-zhen-ji-kong-zhi-yu-xi-lie/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 40 篇 > 本文是 ROS2 系列教程的第 40 篇(收官篇):MoveIt2 真机控制与系列总结(原理)。第 39 篇我们完成了抓取、OMPL 与自定义规划。现在把机械臂从仿真搬到真机:讲清 MoveIt2 与 ros2_control 的完整对接链路、关节轨迹控制器的配置、真机标定与安全限位、急停与异常处理,最后用一张全景图回顾 40 篇教程的七个阶段,并给出下一步学习建议。到这里,从"环境搭建"到"机械臂抓取"的 ROS2 全链路就打通了。 ### 一、真机控制架构总览 #### 1.1 从 MoveIt2 到电机的完整链路 仿真里,move_group 把轨迹直接发到 joint_states,模型"假动"。真机则多了一条真正的执行链路: ```text MoveIt2 真机控制链路 你的节点(MoveGroupInterface) → move_group(规划轨迹) → ExecuteTrajectory action → 关节轨迹控制器(joint_trajectory_controller,ros2_control 内) → 硬件接口(Hardware Interface) → 电机驱动(CAN/串口/PWM)→ 电机 → 编码器 → 编码器反馈 → 硬件接口 → 控制器(闭环)→ joint_states 发布 ``` 关键点:**move_group 只负责"规划",不负责"执行"**。执行是 ros2_control 的关节轨迹控制器通过 follow_joint_trajectory action 完成的。move_group 的执行端只是这个 action 的客户端。 #### 1.2 关节轨迹控制器是什么 joint_trajectory_controller 是 ros2_control 的轨迹跟踪控制器: ```text 1. 接收 FollowJointTrajectory action 目标(关节轨迹 + 时间戳) 2. 按时间轴插值出每个控制周期的目标位置/速度 3. 通过硬件接口下发到电机 4. 用编码器反馈做闭环(PID 等),把实际值发布到 joint_states ``` 它把"期望轨迹"变成"实际运动",是仿真与真机最大的差别所在。 #### 1.3 为什么仿真跑通不等于真机能动 仿真中三个"理想条件"真机都不成立: ```text 1. 无延迟:仿真里指令瞬间到达;真机有 CAN/串口延迟和伺服周期 2. 无误差:仿真里关节角=命令角;真机有摩擦、重力、负载 3. 无故障:仿真不会掉线;真机有驱动器报错、过热、堵转 ``` 所以从仿真到真机,必须多做三件事:标定、限位、监控。本系列前面学的所有"规划"知识在真机上依然成立,但**控制和安全是新的战场**。 ### 二、ros2_control 与 MoveIt2 对接 #### 2.1 moveit_controllers.yaml MoveIt2 通过 `moveit_controllers.yaml` 声明"move_group 该把轨迹交给哪个控制器": ```yaml # moveit_controllers.yaml moveit_controller_manager: moveit_simple_controller_manager moveit_simple_controller_manager: controller_names: - arm_controller # 与 ros2_control 里的控制器名一致 arm_controller: type: FollowJointTrajectory action_ns: follow_joint_trajectory default: true joints: - link1_joint - link2_joint - link3_joint ``` ```text controller_names # 控制器名字,必须与 ros2_control 配置完全一致 action_ns # 固定 follow_joint_trajectory joints # 该控制器管理的关节,顺序要与 SRDF 一致 ``` #### 2.2 ros2_control 侧配置 ros2_control 通过 URDF 的 `` 标签 + YAML 参数声明控制器: ```xml my_robot_hardware/MyRobotHardware can0 link1_joint link2_joint link3_joint ``` ```yaml # controllers.yaml(ros2_control 加载) arm_controller: type: joint_trajectory_controller/JointTrajectoryController joints: - link1_joint - link2_joint - link3_joint command_interfaces: - position state_interfaces: - position - velocity state_publish_rate: 50.0 action_monitor_rate: 20.0 ``` `state_publish_rate: 50` 表示每 20ms 发布一次关节状态(joint_states),MoveIt2 的 Planning Scene 据此更新当前状态。 #### 2.3 时间同步与轨迹跟随 控制器按轨迹的 `time_from_start` 推进,而不是"等 move_group 指令": ```text t=0.0s joint=[0,0,0] ← 控制器开始执行 t=0.5s joint=[0.1,-0.2,0.3] ← 按时间插值到目标 t=2.1s joint=[0.5,-0.8,1.2] ← 轨迹结束 ``` 如果某时刻实际关节角偏离期望值超过阈值(默认约 0.1~0.2 rad 可配),控制器报 FOLLOW_TRAJECTORY_TOLERANCE_VIOLATED,move_group 的执行端收到错误码并中止。**这个"超差即停"机制是真机安全的第一道防线**。 ### 三、真机标定与安全 #### 3.1 关节零位标定 机械臂的编码器有绝对/增量之分,增量编码器上电时不知道关节在哪,必须先找零位: ```text 1. 手动把每个关节转到机械零位(对齐刻度线) 2. 记录此时编码器读数作为 offset 3. 写入硬件配置(NVM 或 yaml),上电后自动补偿 常见做法:限位开关回零 / 磁编码器绝对值 / 机械挡块 + 力矩检测 ``` 零位标定不准,所有运动学结果都会偏——IK 算出"能到",实际末端却偏了几厘米。这是真机调试最常见也最隐蔽的坑。 #### 3.2 速度与加速度限位 规划器产出的轨迹自带速度(来自 URDF 的 velocity 属性),但真机还要再压一层: ```text 1. URDF: # 理论极限 2. 控制器参数:允许的最大速度/加速度(比 URDF 更保守) 3. 应用层:set_max_velocity_scaling_factor(0.3) # 首次运行先 30% ``` 三层限速的关系:取最严格者生效。第一次真机运行从 0.1~0.3 缩放起步,观察无异常再逐步提高。 #### 3.3 急停与异常处理 真机必备的安全兜底: ```text 1. 硬件急停:断电/抱闸,最高优先级,与软件无关 2. 软件急停:订阅 /emergency_stop 话题,收到即停止轨迹 3. 超差保护:轨迹跟随容差(前述),超差自动中止 4. 驱动器保护:过流/过温/堵转,驱动器自身动作 + 上报 move_group 5. 位置软限位:在关节限位内侧留安全距离(如 ±0.1 rad) ``` 异常处理的工程模板: ```python # 订阅控制器状态,异常即停止 def on_controller_state(msg): if msg.state == 'INACTIVE' and was_active: group.stop() # 中止当前执行 node.get_logger().error('控制器失活,已停止') # 执行前检查 /emergency_stop if not estop_ok(): print('急停按下,拒绝执行') return ``` ### 四、真机调试流程 #### 4.1 从仿真到真机的迁移步骤 ```text 1. 仿真验证:demo 里跑通规划与执行(40 篇前 39 篇已覆盖) 2. 替换控制器:关掉 demo 的假执行,启动 ros2_control + 真控制器 3. 慢速试跑:速度缩放 0.1,手动单关节点动,确认方向/限位 4. 空载示教:沿简单轨迹(home → 点位)低速执行,观察抖动与跟随误差 5. 带载验证:逐步增加速度与负载,验证 IK 精度与轨迹跟随 6. 完整任务:把抓取/搬运流程搬到真机,逐状态验证 ``` 每一步失败都要回到上一步排查,不要跳步。 #### 4.2 常见问题速查 | 现象 | 原因 | 解决 | | --- | --- | --- | | move_group 规划成功但执行报 NO_GOAL_ACCEPTED | 控制器名/action_ns 不匹配 | 核对 moveit_controllers.yaml 与 ros2_control 配置 | | 轨迹执行时关节发抖 | 控制器 PID 未调好/更新率低 | 调 PID,提高 state_publish_rate | | 实际末端位置偏 | 零位标定错 | 重新标定 offset,验证 home 位形 | | 执行中途超差停机 | 速度太快/负载过大 | 降速缩放,检查电机扭矩 | | joint_states 无数据 | 硬件接口没发布状态 | 检查 hardware 插件与 state_interface | | 控制器反复 INACTIVE | 硬件初始化失败/通信断 | 查 CAN 连接、驱动日志 | #### 4.3 实测清单 ```text □ 零位标定完成(各关节对齐刻度) □ URDF 限位与机械限位一致(含软限位安全距离) □ 速度缩放 0.1 慢速点动通过 □ 简单轨迹空载执行无抖动 □ 急停按钮测试通过(硬件 + 软件两层) □ 超差保护触发测试通过 □ 完整抓取任务真机跑通 □ 连续运行 1 小时无异常 ``` ### 五、40 篇系列全景总结 #### 5.1 七个阶段路线图 | 阶段 | 篇号 | 主题 | 掌握的关键能力 | | --- | --- | --- | --- | | 入门 | 1-12 | ROS2 基础 | 节点/话题/服务/参数/Action/launch/tf2,能独立开发节点 | | 机器人学 | 13-15 | 运动学与坐标 | 位姿、tf2 坐标变换、TF 树管理 | | 建模仿真 | 16-20 | URDF/Gazebo | 用 URDF/Xacro 建模,Gazebo 仿真,传感器接入 | | Nav2 导航 | 21-25 | 移动机器人 | SLAM 建图、定位、Nav2 导航、行为树 | | 硬件控制 | 26-30 | 嵌入式与驱动 | MicroROS、ros2_control、电机与传感器驱动 | | 实体机器人 | 31-34 | 整机实战 | 硬件搭建、上下位机通信、建图导航整合 | | MoveIt2 | 35-40 | 机械臂 | 建模、规划、运动学、碰撞、抓取、真机控制 | #### 5.2 关键技术图谱 ```text ROS2 40 篇技术图谱 ├── 通信层:话题 / 服务 / 参数 / Action / QoS ├── 建模层:URDF / Xacro / SRDF / TF2 ├── 仿真层:Gazebo / RViz / slam_toolbox / Cartographer ├── 移动层:Nav2 / AMCL / 行为树 ├── 控制层:ros2_control / MicroROS / PID └── 操作层:MoveIt2(规划 / 运动学 / 碰撞 / 抓取 / 真机) ``` 这套图谱覆盖了 ROS2 机器人开发的主干:会通信、会建模、会仿真、会导航、会控制、会操作。 #### 5.3 下一步学习建议 ```text 1. 深入一个方向:机器人学(URDF 动力学)或控制(ros2_control 源码) 2. 实战项目:把移动底盘 + 机械臂拼成复合机器人(导航 + 抓取) 3. 多机协同:分布式 ROS2(DDS 配置、多机通信) 4. 感知融合:深度相机(RealSense)+ 点云 + 目标检测(YOLO)接入 MoveIt2 5. 工程化:Docker 部署、CI 测试、日志与监控(rqt 全家桶) ``` 记住:教程只能带你入门,真正的能力来自"自己把一个机器人跑起来并修好它"的过程。 ### 六、结语 40 篇,从一行 `sudo apt install ros-humble-desktop` 开始,到机械臂真机抓取结束。这条路覆盖了 ROS2 开发者的完整成长路径:先理解通信,再学会建模与仿真,然后是移动机器人的导航,接着是嵌入式与硬件控制,最后是机械臂的运动规划与操作。 本篇真机部分的核心要点: 1. MoveIt2 只规划、不执行;执行由 ros2_control 的关节轨迹控制器完成。 2. 对接关键在 `moveit_controllers.yaml` 与 ros2_control 配置的控制器名/action_ns/关节列表一致。 3. 真机三件事:标定零位、压限速度、挂好急停与超差保护。 4. 从仿真迁移按"慢速点动 → 空载轨迹 → 带载任务"逐步推进,失败回退排查。 5. 学习闭环:会通信 → 会建模 → 会仿真 → 会导航 → 会控制 → 会操作。 至此,ROS2 系列教程(40 篇)全部完成。从"Hello World 节点"到"机械臂真机控制",你已经拥有了独立开发机器人应用的全链路知识。去把你的第一个机器人跑起来吧——最好的教程,永远是你自己亲手修好的那个机器人。 --- ## ROS2系列教程:Moveit2进阶(抓取/OMPL/自定义规划) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-jin-jie-zhua-qu-ompl-zi-ding/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 39 篇 > 本文是 ROS2 系列教程的第 39 篇:MoveIt2 进阶(抓取 / OMPL / 自定义规划)。第 38 篇我们搞定了碰撞检测与避障。现在把前面学的串成真本事:用 Pick & Place 流程实现"感知 → 抓取 → 搬运 → 放置",深入 OMPL 各规划器(RRT / RRTConnect / BIT*)的选型与调参,手写一个自定义规划器插件,最后给出多目标点连续规划的工程模板。这是 MoveIt2 阶段的"毕业设计",第 40 篇将把机械臂接到真机。 ### 一、Pick & Place:抓取-搬运-放置流程 #### 1.1 任务拆解 抓取任务看起来简单——“把杯子拿过来”——拆开却是五个明确的子步骤: ```text Pick & Place 五步走 1. 感知:确认物体位置(相机/仿真中直接指定) 2. 预抓取:末端移到物体上方安全距离,姿态对准 3. 抓取:末端下探 → 闭合夹爪 → 微抬 4. 搬运:把物体从 A 点移到 B 点(注意始终与物体保持相对关系) 5. 放置:下降到目标高度 → 张开夹爪 → 撤离 ``` 每一步都是一次"规划 + 执行",且状态之间要传递数据(比如抓取位姿、抬升高度)。写成一个线性脚本只能跑通 demo;工程上要包一层状态机。 #### 1.2 MoveIt2 的 Pick/Place 能力 MoveIt2 本身提供了 `pick` / `place` 动作(moveit_msgs 的 PickupAction / PlaceAction),但它假设你有完整的抓取方案(grasp 集合:预抓取位姿、抓取位姿、夹爪开合角度)。很多项目选择**不用它**,而是自己编排——原因: ```text 1. pick/place 动作对夹爪模型和 grasp 数据格式要求严格 2. 自定义逻辑(夹爪传感器反馈、物体识别结果)更难插入 3. 自编排用 set_pose_target + execute 就能覆盖 90% 场景 ``` 所以本篇采用"自编排"路线:用前面学过的原语拼出完整流程,这也是工业项目最常见的做法。 #### 1.3 简化示例:单个抓取点(Python) ```bash # 启动 demo ros2 launch arm_moveit_config demo.launch.py ``` ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/pick_simple.py import rclpy from moveit_commander import MoveGroupCommander def main(): rclpy.init() node = rclpy.create_node('pick_simple') group = MoveGroupCommander('arm_group', node) # 1. 回到 home group.set_named_target('home') group.go(wait=True) # 2. 预抓取:物体在 (0.35, 0.0, 0.15),先到上方 0.08m group.set_pose_target([0.35, 0.0, 0.23, 0, 0, 0], 'tool0') # xyz + rpy(度) group.go(wait=True) # 3. 下探到抓取高度 group.set_pose_target([0.35, 0.0, 0.15, 0, 0, 0], 'tool0') group.go(wait=True) print('到达抓取位置——在这里闭合夹爪(真机)/ 模拟中跳过') # 4. 抬升 group.set_pose_target([0.35, 0.0, 0.23, 0, 0, 0], 'tool0') group.go(wait=True) # 5. 回到 home group.set_named_target('home') group.go(wait=True) print('完成一次简化抓取') node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 注意 `set_pose_target` 支持 `[x, y, z, roll, pitch, yaw]` 列表形式(rpy 用度),比手拼 Pose 对象更紧凑。夹爪在仿真里没有真实夹持,我们用"位移模拟"代替。 ### 二、OMPL 规划器详解 #### 2.1 OMPL 是什么 OMPL(Open Motion Planning Library)是 MoveIt2 默认的**采样式运动规划库**,提供几十种规划算法。MoveIt2 与 OMPL 的关系: ```text move_group → OMPL(采样规划器:RRT/RRTConnect/BIT*...) │ ├── 状态空间(关节空间/SE3/SE2) ├── 碰撞检测回调(连接 FCL) └── 目标判定(到达 goal 约束即成功) ``` OMPL 不关心你的机器人是什么,它只在一个抽象的状态空间里"找路",碰撞检测和运动学由 MoveIt2 注入。 #### 2.2 常用规划器 ```text 1. RRT(Rapidly-exploring Random Tree) 经典随机树:快速探索空间,路径质量一般(曲折、随机) 2. RRTConnect(双向 RRT) 从起点和终点同时生长两棵树,相向连接 速度最快、最常用,MoveIt2 默认就是它 3. BIT*(Batch Informed Trees) 结合图搜索与采样:先快速出解,再用信息集持续优化 路径质量高、时间越长越优,适合离线/准实时 4. PRM / PRM*(概率路线图) 预采样构建路线图,适合多次查询同一空间 ``` #### 2.3 规划器选型与调参 `ompl_planning.yaml` 里为每个规划组配置一组规划器: ```yaml # ompl_planning.yaml(片段) arm_group: planner_configs: - RRTConnect - RRT - BITstar RRTConnect: type: geometric::RRTConnect range: 0.05 # 单步扩展最大距离 goal_bias: 0.05 # 偏向目标的比例 RRT: type: geometric::RRT range: 0.05 goal_bias: 0.05 BITstar: type: geometric::BITstar ``` 选型建议: ```text 1. 实时在线(默认):RRTConnect,range 0.05~0.1 2. 复杂狭窄通道:RRTConnect 调小 range(0.03)+ 增加迭代时间 3. 路径质量优先(搬运慢速场景):BIT* 给足 2~5 秒 4. 同一空间多次规划:PRM 预采样 ``` 换规划器:`group.set_planner_id("RRT")`(Python)或改 demo launch 参数,一行即可切换。 ### 三、自定义规划器插件 理解规划器插件机制,能让你在特殊需求(狭窄通道、非标准成本函数)时不至于束手无策。 #### 3.1 规划器接口 MoveIt2 的规划器插件实现 `planning_interface::PlannerManager`: ```cpp class MyPlanner : public planning_interface::PlannerManager { public: bool initialize(const rclcpp::Node::SharedPtr& node, const planning_scene::PlanningScenePtr& scene, const std::string& name) override; bool solve(const planning_interface::MotionPlanRequest& req, planning_interface::MotionPlanResponse& res) override; // 其余虚函数(getDescription/terminate 等)从略 }; ``` 核心就一个 `solve()`:读请求(起点/终点/约束),算出一条轨迹填进响应。 #### 3.2 实现一个"直线插补"规划器(教学用) ```cpp // src/my_planner/src/my_planner.cpp(最小示意) #include #include namespace my_planner { class MyPlanner : public planning_interface::PlannerManager { public: bool solve(const planning_interface::MotionPlanRequest& req, planning_interface::MotionPlanResponse& res) override { // 1. 取起点与目标关节角 const auto& start = req.start_state.joint_state.position; // (演示:直接用目标值作为终点,插 10 个点) auto& traj = res.trajectory.joint_trajectory; traj.joint_names = req.group_name == "arm_group" ? std::vector{"link1_joint", "link2_joint", "link3_joint"} : std::vector{}; // 2. 线性插值:从 start 到 goal(goal 从 req.goal_constraints 解析,此处略) for (int i = 0; i <= 10; ++i) { trajectory_msgs::msg::JointTrajectoryPoint pt; pt.positions = start; // 简化:这里应写入插值结果 pt.time_from_start.sec = i * 2; traj.points.push_back(pt); } res.error_code.val = moveit_msgs::msg::MoveItErrorCodes::SUCCESS; return true; } }; } // namespace my_planner PLUGINLIB_EXPORT_CLASS(my_planner::MyPlanner, planning_interface::PlannerManager) ``` 真实规划器(如 OMPL)在这里完成采样、碰撞检查、时间参数化,但接口骨架完全一样。 #### 3.3 注册与配置 ```xml 自定义直线规划器示例 ``` ```yaml # ompl_planning.yaml 换成自定义规划器 arm_group: planner_configs: - my_planner/MyPlanner # 或通过 move_group 参数指定 ``` 编译后重启 demo,`group.set_planner_id("my_planner/MyPlanner")` 即可调用。日志里打点确认 `solve()` 被命中。 ### 四、多目标点连续规划 #### 4.1 路径点序列 工业任务(上下料、焊接、喷涂)几乎都是多目标点连续作业。核心是"逐点规划 + 执行 + 校验": ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/waypoints.py import rclpy from moveit_commander import MoveGroupCommander def main(): rclpy.init() node = rclpy.create_node('waypoints') group = MoveGroupCommander('arm_group', node) # 路径点(相对 base_link 的 xyz,姿态默认) points = [ [0.30, 0.0, 0.25], [0.30, 0.20, 0.25], [0.20, 0.20, 0.30], [0.30, 0.0, 0.25], # 回到起点 ] group.set_named_target('home') group.go(wait=True) for i, p in enumerate(points): group.set_position_target(p) ok = group.go(wait=True) if not ok: print(f'第 {i} 个点规划/执行失败,中断') break print(f'已到达第 {i+1} 个点: {p}') node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 4.2 连续轨迹:compute_cartesian_path 如果希望末端**沿直线**走(而不是每个点独立规划导致路径弯曲),用笛卡尔路径: ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/cartesian.py import rclpy from moveit_commander import MoveGroupCommander from geometry_msgs.msg import Pose def main(): rclpy.init() node = rclpy.create_node('cartesian') group = MoveGroupCommander('arm_group', node) # 从当前位姿出发,沿 x 正方向画直线 waypoints = [] for i in range(11): # 11 个插值点 p = group.get_current_pose().pose p.position.x += i * 0.02 # 每步 2cm waypoints.append(p) plan, fraction = group.compute_cartesian_path( waypoints, eef_step=0.01, jump_threshold=0.0) print(f'笛卡尔路径完成度: {fraction:.1%}') if fraction > 0.9: group.execute(plan, wait=True) else: print('路径被阻塞,未执行') node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` `compute_cartesian_path` 返回 `(plan, fraction)`,fraction 表示完成度:1.0 表示整条直线都规划出来了,小于 1 说明某段 IK 失败(被障碍物或奇异点挡住)。 #### 4.3 工程注意事项 ```text 1. 每个点执行前校验:go() 返回 False 就中断,别硬撑 2. 多目标点建议用状态机(见第五节),便于插入夹爪/传感器逻辑 3. 笛卡尔路径注意奇异点:直线经过奇异位形时 fraction 骤降 4. 速度缩放逐段可调:接近目标点放慢,中间提速 5. 记录每个点的实际到达位姿,便于回放与调试 ``` ### 五、抓取实战:完整流程(Python) 把上面所有能力拼成一个带状态机的完整抓取任务。 #### 5.1 示例:带状态机的 Pick & Place ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/pick_place_fsm.py import rclpy from moveit_commander import MoveGroupCommander, PlanningSceneInterface class PickPlaceFSM: """简易状态机:HOME → PRE_GRASP → GRASP → LIFT → PLACE → BACK""" def __init__(self, node): self.group = MoveGroupCommander('arm_group', node) self.scene = PlanningSceneInterface(node) self.state = 'HOME' self.pick_pos = [0.35, 0.0, 0.15] self.place_pos = [0.35, 0.30, 0.15] self.lift_h = 0.08 # 抬升高度 def move_to(self, target, wait=True): if len(target) == 3: self.group.set_position_target(target) else: self.group.set_pose_target(target, 'tool0') return self.group.go(wait=wait) def run(self): while rclpy.ok(): if self.state == 'HOME': self.group.set_named_target('home') self.group.go(wait=True) self.state = 'PRE_GRASP' elif self.state == 'PRE_GRASP': if self.move_to([self.pick_pos[0], self.pick_pos[1], self.pick_pos[2] + self.lift_h]): self.state = 'GRASP' elif self.state == 'GRASP': if self.move_to(self.pick_pos): print('>>> 闭合夹爪(此处接真机/仿真夹爪指令)') self.state = 'LIFT' elif self.state == 'LIFT': if self.move_to([self.pick_pos[0], self.pick_pos[1], self.pick_pos[2] + self.lift_h]): self.state = 'PLACE' elif self.state == 'PLACE': if self.move_to([self.place_pos[0], self.place_pos[1], self.place_pos[2] + self.lift_h]): self.state = 'PUT_DOWN' elif self.state == 'PUT_DOWN': if self.move_to(self.place_pos): print('>>> 张开夹爪') self.state = 'BACK' elif self.state == 'BACK': self.move_to([self.place_pos[0], self.place_pos[1], self.place_pos[2] + self.lift_h]) self.group.set_named_target('home') self.group.go(wait=True) print('任务完成!') break def main(): rclpy.init() node = rclpy.create_node('pick_place_fsm') fsm = PickPlaceFSM(node) fsm.run() node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 这个状态机把"每一拍规划成功才前进"体现得淋漓尽致:任何一步 `go()` 失败,状态就停在那里不前进,不会带病继续——这是机器人任务控制最重要的工程习惯。 #### 5.2 错误处理增强 ```python # 给状态机加"失败重试 + 回退" MAX_RETRY = 3 def run(self): retry = 0 # ... 原状态机逻辑中,每次 move_to 失败: # retry += 1 # if retry >= MAX_RETRY: # print('连续失败,回 home 并报警') # self.group.set_named_target('home') # self.group.go(wait=True) # break # else: # continue # 保持当前状态,重试 ``` 工程版本还要加:执行超时、急停输入、夹爪到位传感器反馈。这些都可以挂在状态机各状态的前后置条件上。 #### 5.3 仿真验证要点 ```text 1. demo 里没有真实夹爪:用 RViz 目视确认 tool0 到达 pick 点 2. 加障碍物模拟桌面:验证 PLACE 阶段不会撞"桌面" 3. 故意把 pick 点放远:观察 GRASP 阶段 go() 失败、状态停住 4. 观察笛卡尔路径 fraction:直线段被打断时及时报告 ``` ### 六、小结与第 40 篇预告 本篇完成了 MoveIt2 进阶三连:Pick & Place 自编排流程、OMPL 规划器选型与调参、自定义规划器插件骨架,最后用状态机把"抓取-搬运-放置"串成完整的可运行工程。 核心要点: 1. Pick & Place 五步走:感知 → 预抓取 → 抓取 → 搬运 → 放置;自编排比 move_group 内置 pick/place 更灵活。 2. OMPL 是采样式规划库:RRTConnect 默认最快,BIT* 路径更优,PRM 适合重复查询。 3. 自定义规划器:继承 `PlannerManager`、实现 `solve()`、注册 pluginlib、改 yaml,一套流程与求解器插件完全对称。 4. 多目标点用状态机编排,每一拍"规划成功才前进",失败重试、超限回退。 5. `compute_cartesian_path` 让末端走直线,fraction 是完成度晴雨表。 下一章(第 40 篇,本系列最后一篇)**「MoveIt2 真机控制与系列总结(原理)」**:把机械臂从仿真搬到真机——ros2_control 与 MoveIt2 的完整对接、关节轨迹控制器配置、真机标定与安全限位、急停与异常处理,最后给出 40 篇 ROS2 系列的全景路线图与下一步学习建议。 --- ## ROS2系列教程:Moveit2碰撞检测与避障 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-peng-zhuang-jian-ce-yu-bi/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 38 篇 > 本文是 ROS2 系列教程的第 38 篇:MoveIt2 碰撞检测与避障。第 37 篇我们搞懂了运动学(IK/FK 与求解器插件)。但规划出一条"能到"的轨迹还不够——它还必须"不撞上":不能自碰撞,也不能撞上桌子、墙面、障碍物。这就是本篇的主角:碰撞检测。我们深入 FCL 库的原理、允许碰撞矩阵 ACM、规划场景里障碍物的增删与动态更新,用 Python/C++ 操作碰撞检测 API,最后讲清楚"碰撞检测如何驱动避障规划"以及性能优化。 ### 一、碰撞检测基础 #### 1.1 为什么要碰撞检测 机械臂规划出的轨迹如果不做碰撞检测,后果是灾难性的: ```text 1. 自碰撞:手臂甩起来打到自己的底座或另一节手臂 2. 环境碰撞:末端扫过桌子边缘、撞进机柜 3. 动态风险:人靠近工作区时轨迹没有反应 ``` MoveIt2 把碰撞检测织进了三个环节: ```text 1. 规划前:检查目标状态是否合法(会不会撞) 2. 规划中:OMPL 随机采样的每一步都做碰撞检查,撞了就丢弃该样本 3. 规划后:执行监控,检测到异常立即停车 ``` 所以说,MoveIt2 的"避障"本质上是**碰撞检测驱动的采样过滤**——规划器只管生成候选轨迹,碰撞检测负责淘汰非法轨迹。 #### 1.2 FCL 库(Flexible Collision Library) MoveIt2 默认的碰撞检测引擎是 FCL(柔性碰撞库),一个专门做几何体碰撞检测的开源库。它解决的核心问题是:**给定两个形状,判断它们是否重叠,重叠了多少**。 FCL 支持的基本形状: ```text 1. 凸多面体(box 盒子、cylinder 圆柱、sphere 球、cone 锥) 2. 凹多面体(mesh 网格,用凸分解处理) 3. 点云(point cloud) 4. 平面(plane) ``` 对两个凸形状,FCL 用 **GJK 算法**(Gilbert-Johnson-Keerthi)判断是否相交,再用 **EPA 算法**(Expanding Polytope Algorithm)求最小穿透深度。这两个算法是碰撞检测领域的经典。 #### 1.3 碰撞检测的类型 MoveIt2 里区分两类碰撞: ```text 1. 自碰撞(self-collision):机器人的 link 与 link 之间 —— 比如 forearm_link 撞上 base_link 2. 环境碰撞(environment collision):机器人与规划场景里的物体之间 —— 比如 tool0 撞上桌子(桌子是一个 CollisionObject) ``` 两类碰撞共用同一套 FCL 引擎,区别只在于"参与检测的几何体集合"不同:自碰撞用机器人自身的 link 形状;环境碰撞在自碰撞基础上叠加场景物体形状。 ### 二、Planning Scene 的碰撞检测机制 #### 2.1 允许碰撞矩阵 ACM(Allowed Collision Matrix) 不是所有 link 对都需要检测碰撞。相邻 link(通过 joint 直接相连)在正常范围内永远不碰撞,检测它们是纯浪费。MoveIt2 用 **ACM** 管理"哪些对允许碰撞": ```text AllowedCollisionMatrix ├── 相邻 link 对:allowed=true(跳过检测,加速) ├── 已知永远不碰的远距 link 对:allowed=true(如 base 与 tool0 间距固定) ├── 其余 link 对:allowed=false(必须检测) └── 场景物体对:默认 allowed=false(都要检测) ``` SRDF 里的 `` 标签(第 35 篇生成配置时自动填入的)就是在初始化 ACM。手写 SRDF 时这段不能省,否则相邻 link 也做碰撞检测,规划慢一个量级。 #### 2.2 碰撞对象 CollisionObject 环境里的障碍物在 MoveIt2 中称为 **CollisionObject**,定义在 `moveit_msgs/msg/CollisionObject`: ```text CollisionObject ├── id # 唯一标识(如 "table") ├── header.frame_id # 所在坐标系(如 "base_link" 或 "world") ├── primitives # 基本形状列表(box/cylinder/sphere/cone) ├── primitive_poses # 每个形状的位姿 ├── meshes # 网格形状列表(从 STL/DAE 加载) ├── mesh_poses # 每个网格的位姿 └── operation # ADD / REMOVE / MOVE / APPEND ``` 同一个 CollisionObject 可以一次挂多个形状(比如一张桌子 = 桌面盒 + 4 条桌腿柱),一次性发布到规划场景。 #### 2.3 检测流程:broadphase + narrowphase MoveIt2 每帧的碰撞检测分两个阶段: ```text 1. Broadphase(粗筛):用包围盒(AABB/OBB)快速剔除明显不相交的物体对 —— 大部分物体对在这一步就被排除,开销小 2. Narrowphase(精检):对剩下的候选对调用 FCL 的 GJK/EPA 精确判断 —— 只有少数对走到这一步 ``` 这个"先粗后细"的两阶段设计,让碰撞检测的性能与物体数量基本成线性关系(而不是平方),是它能实时跑的关键。 ### 三、添加障碍物与动态障碍物 #### 3.1 示例 1:静态障碍物(Python) ```bash # 启动 demo ros2 launch arm_moveit_config demo.launch.py ``` ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/add_table.py import rclpy from moveit_commander import PlanningSceneInterface from geometry_msgs.msg import Pose def main(): rclpy.init() node = rclpy.create_node('add_table') scene = PlanningSceneInterface(node) # 1. 一张桌子:桌面(大扁盒)+ 四条腿(圆柱),都在 base_link 系下 table_top = Pose() table_top.position.x = 0.45 table_top.position.y = 0.0 table_top.position.z = 0.10 # 桌面高度 scene.add_box('table_top', (0.5, 0.5, 0.02), table_top) for i, (dx, dy) in enumerate([(-0.2, -0.2), (-0.2, 0.2), (0.2, -0.2), (0.2, 0.2)]): leg = Pose() leg.position.x = 0.45 + dx leg.position.y = dy leg.position.z = 0.05 scene.add_cylinder(f'table_leg_{i}', height=0.10, radius=0.02, pose=leg) node.get_logger().info('已添加桌子和 4 条腿') rclpy.spin_once(node, timeout_sec=1.0) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 运行后去 RViz 勾选 MotionPlanning → Scene Objects,能看到桌子出现在规划场景里。此时拖 Goal 到桌面上方,规划的轨迹会自动绕过桌面。 #### 3.2 示例 2:动态障碍物(每帧更新位置) 避障的进阶用法是让障碍物"动起来"——比如产线上的料箱缓慢移动。做法:持续发布带 MOVE 操作的 CollisionObject。 ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/moving_obstacle.py import rclpy from moveit_commander import PlanningSceneInterface from geometry_msgs.msg import Pose def main(): rclpy.init() node = rclpy.create_node('moving_obstacle') scene = PlanningSceneInterface(node) rate = node.create_rate(10) # 10Hz 更新 t = 0.0 while rclpy.ok(): # 障碍物沿 x 方向来回移动(正弦摆动) pose = Pose() pose.position.x = 0.30 + 0.15 * __import__('math').sin(t) pose.position.y = 0.0 pose.position.z = 0.25 scene.add_box('moving_box', (0.05, 0.05, 0.05), pose) t += 0.1 rclpy.spin_once(node, timeout_sec=0.05) rate.sleep() node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 注意 `add_box` 对同一个 id 重复调用就是"移动"(内部转成 MOVE 操作)。10Hz 的更新频率对 MoveIt2 完全够用,规划时会自动避让最新位置的障碍物。 #### 3.3 从传感器数据添加(点云/网格) 真实场景的障碍物来自传感器。常见两条路: ```text 1. 点云:把 3D 相机点云降采样后,作为 octomap(八叉树地图)发布, MoveIt2 的 Octomap 插件自动把它变成碰撞几何体 2. 网格:把障碍物的 STL/DAE 网格直接挂到 CollisionObject.meshes (适合已知工件的精确碰撞) ``` 点云路线的配置示例(在 `planning.yaml` 或 demo launch 里加): ```yaml # ompl_planning.yaml 附近,或 planning_scene_monitor 参数 planning_scene_monitor_options: octomap_monitor: octomap_topic: /octomap_full # 点云聚合成的八叉树话题 max_range: 2.0 resolution: 0.02 # 2cm 分辨率 ``` 分辨率调小则碰撞更精确但更慢;0.02m 对机械臂工作区是常用值。 ### 四、碰撞检测 API(Python + C++) #### 4.1 示例 3:查询当前状态是否碰撞(Python) ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/check_collision.py import rclpy from moveit_commander import PlanningSceneInterface, RobotCommander def main(): rclpy.init() node = rclpy.create_node('check_collision') robot = RobotCommander(node) scene = PlanningSceneInterface(node) # 1. 构造一个"可疑"的关节状态(比如让手臂完全收起) joint_values = robot.get_current_state().joint_state.position # 假设规划组关节是前 3 个 joint_values[:3] = [0.0, 0.0, 0.0] # 2. 检查这个状态是否自碰撞/环境碰撞 from moveit_msgs.srv import GetStateValidity cli = node.create_client(GetStateValidity, '/check_state_validity') req = GetStateValidity.Request() req.robot_state = robot.get_current_state() req.robot_state.joint_state.position = joint_values req.group_name = 'arm_group' if not cli.wait_for_service(timeout_sec=2.0): node.get_logger().error('check_state_validity 服务不可用') return fut = cli.call_async(req) rclpy.spin_until_future_complete(node, fut) resp = fut.result() if resp.valid: print('该状态有效(无碰撞)') else: print('该状态碰撞!') # 打印哪些 link 对发生了碰撞 for c in resp.contacts: print(f' 碰撞: {c.contact_body_1} <-> {c.contact_body_2}') node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` `/check_state_validity` 是调试利器:任何"这姿势能不能摆"的问题,调它就能得到权威答案。 #### 4.2 示例 4:主动查询两个物体的碰撞(Python) ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/check_pair.py import rclpy from moveit_commander import PlanningSceneInterface from moveit_msgs.srv import GetPlanningScene def main(): rclpy.init() node = rclpy.create_node('check_pair') cli = node.create_client(GetPlanningScene, '/get_planning_scene') req = GetPlanningScene.Request() req.components.components = [req.components.WORLD_OBJECT_GEOMETRY] if not cli.wait_for_service(timeout_sec=2.0): node.get_logger().error('get_planning_scene 服务不可用') return fut = cli.call_async(req) rclpy.spin_until_future_complete(node, fut) scene = fut.result().scene # 打印当前规划场景里所有物体 for obj in scene.world.collision_objects: print(f'物体 id={obj.id} 帧={obj.header.frame_id} ' f'形状数={len(obj.primitives) + len(obj.meshes)}') node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` `/get_planning_scene` 返回的 `world.collision_objects` 列出了场景里全部障碍物——用它核对"我加的障碍物到底进没进场景"。 #### 4.3 示例 5:C++ 方式 ```cpp // src/check_collision.cpp(在 arm_planner_cpp 包内) #include #include #include #include int main(int argc, char** argv) { rclcpp::init(argc, argv); auto node = std::make_shared("check_collision"); auto executor = std::make_shared(); executor->add_node(node); std::thread spin([&executor]() { executor->spin(); }); // 1. 加一个障碍物 moveit::planning_interface::PlanningSceneInterface psi; moveit_msgs::msg::CollisionObject box; box.id = "obstacle"; box.header.frame_id = "base_link"; box.primitives.resize(1); box.primitives[0].type = shape_msgs::msg::SolidPrimitive::BOX; box.primitives[0].dimensions = {0.1, 0.1, 0.1}; box.primitive_poses.resize(1); box.primitive_poses[0].position.x = 0.30; box.primitive_poses[0].position.z = 0.25; box.operation = moveit_msgs::msg::CollisionObject::ADD; std::vector objs = {box}; psi.applyCollisionObjects(objs); RCLCPP_INFO(node->get_logger(), "已添加障碍物"); // 2. 构造一个 planning scene 并检查碰撞 auto scene = std::make_shared( moveit::core::getSharedRobotModel("arm_moveit_config")); scene->getCurrentStateNonConst().setToDefaultValues(); collision_detection::CollisionRequest req; collision_detection::CollisionResult res; req.group_name = "arm_group"; scene->checkCollision(req, res, scene->getCurrentState()); RCLCPP_INFO(node->get_logger(), "当前状态碰撞=%d", (int)res.collision); spin.join(); rclcpp::shutdown(); return 0; } ``` ```bash # CMakeLists.txt 增加 find_package(moveit_ros_planning_interface REQUIRED) find_package(moveit_core REQUIRED) ament_target_dependencies(check_collision rclcpp moveit_ros_planning_interface moveit_core) install(TARGETS check_collision DESTINATION lib/${PROJECT_NAME}) ``` ### 五、避障规划 #### 5.1 带障碍物规划:完整闭环 把第 36 篇的规划 + 本篇的障碍物合起来: ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/plan_avoid.py import rclpy from moveit_commander import MoveGroupCommander, PlanningSceneInterface from geometry_msgs.msg import Pose def main(): rclpy.init() node = rclpy.create_node('plan_avoid') group = MoveGroupCommander('arm_group', node) scene = PlanningSceneInterface(node) # 1. 在路径中间放一个盒子 pose = Pose() pose.position.x = 0.25 pose.position.y = 0.0 pose.position.z = 0.20 scene.add_box('wall', (0.05, 0.30, 0.30), pose) # 2. 目标在盒子另一侧 group.set_position_target([0.45, 0.0, 0.25]) plan = group.plan() if getattr(plan, 'joint_trajectory', None) and plan.joint_trajectory.points: print(f'规划成功!轨迹点数={len(plan.joint_trajectory.points)}') group.execute(plan, wait=True) print('执行完成——轨迹绕过了盒子') else: print('规划失败:可能被盒子挡住了') # 3. 移除盒子 scene.remove_world_object('wall') node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 这个例子把"障碍物 → 规划 → 避让 → 清除"串成了完整闭环,是抓取/上下料任务的最小骨架。 #### 5.2 碰撞检测与规划的关系 OMPL 的 RRT/RRTConnect 等算法在采样时维护一棵树:每个新采样的节点先做碰撞检查,合法才接入树。所以: ```text 碰撞检测越准确 → 树的节点越"干净" → 轨迹越安全 碰撞检测越慢 → 采样越慢 → 规划越慢 ``` 这就是为什么 ACM 优化、broadphase 粗筛、降低网格复杂度都那么重要——它们在不牺牲安全性的前提下让规划更快。 #### 5.3 性能优化 ```text 1. 简化网格:STL 网格减面(如 10 万 → 1 万面),碰撞结果几乎不变 2. 收紧 ACM:把距离恒定的 link 对设为 allowed 3. 控制更新频率:点云 5~10Hz 足够,不必每帧全量重建 4. 增大 octomap 分辨率:0.02 → 0.05 速度快几倍(精度略降) 5. 缩小检测范围:max_range 限制远处障碍物不进场景 ``` 工程上建议先跑通、再逐步压性能,不要一上来就全开最高精度。 ### 六、常见问题与调试 #### 6.1 常见问题速查 | 现象 | 原因 | 解决 | | --- | --- | --- | | 障碍物加了但轨迹不避让 | 规划场景未同步/障碍物在错误坐标系 | 勾选 Scene Objects 确认物体在,检查 frame_id | | 相邻 link 也被判碰撞 | ACM 没生效 | 检查 SRDF 的 disable_collisions 是否齐全 | | 规划明显变慢 | 碰撞几何体太多/网格太密 | 简化网格、增大 octomap 分辨率、收紧 ACM | | 轨迹穿墙而过 | 碰撞检测没开/距离阈值过大 | 确认 PlanningSceneMonitor 运行,检查 collision 显示 | | 动态障碍物跟不上 | 更新频率太低 | 提高发布频率到 10Hz+,检查网络负载 | | check_state_validity 报错 | 请求的 group 名不存在 | 确认 group_name 与 SRDF 一致 | #### 6.2 调试技巧 ```text 1. RViz 勾选 MotionPlanning → Collision 显示,碰撞的 link 会高亮 2. /check_state_validity 单点验证:任何可疑姿势先问它 3. /get_planning_scene 核对场景内容:确认障碍物真的进去了 4. 打印 ACM:读 AllowedCollisionMatrix 内容,排查误配 5. 先加障碍物再规划:规划前确认 Scene Objects 已刷新 ``` ### 七、小结与第 39 篇预告 本篇把碰撞检测与避障讲透了:FCL 是底层引擎(GJK/EPA + 两阶段检测),ACM 是性能开关,CollisionObject 是环境建模的载体,`/check_state_validity` 是单点验证利器,避障本质是"采样 + 碰撞过滤"。 核心要点: 1. 碰撞分自碰撞与环境碰撞两类,共用一个 FCL 引擎,差别只在参与检测的几何体集合。 2. ACM 标记"允许碰撞对",相邻 link 必须跳过检测,否则性能崩。 3. 障碍物 = CollisionObject(id + frame_id + 形状 + 操作),重复发布同一 id 即移动。 4. `/check_state_validity` 与 `/get_planning_scene` 是调试碰撞问题的两大服务。 5. 性能优化四板斧:简化网格、收紧 ACM、降更新频率、调 octomap 分辨率。 下一章(第 39 篇)**「MoveIt2 进阶(抓取/OMPL/自定义规划)」**:把前面学的串成真本事——用 Pick & Place 流程实现"感知 → 抓取 → 搬运 → 放置",深入 OMPL 各规划器(RRT/RRTConnect/BIT*)的选型与调参,再写一个自定义规划算法插件,最后给出多目标点连续规划的工程模板。 --- ## Ubuntu 自动登录后 GNOME 远程桌面连不上?一次登录钥匙环锁定的排查与永久修复 URL: https://wsj-blog.pages.dev/posts/ubuntu-zi-dong-deng-lu-hou-gnome-yuan-cheng-zhuo-mian-lian/ License: CC-BY-NC-SA-4.0 > 环境:Ubuntu 26.04.1 LTS / GNOME Shell 50.1 / gnome-remote-desktop 50.2(Wayland 会话) > 现象关键词:`[RDP] Credentials are not set, denying client`、设置里「远程桌面」显示「部分设置已锁定 / 解锁」 --- ### 一、问题现象 开启了系统自带的远程桌面: - 设置 → 系统 → 远程桌面 → **桌面共享**:开 - **远程控制**:开 - RDP 端口 3389 正常监听 但从其他设备用 RDP 客户端连接时始终失败。奇怪的是,只要打开一次「设置 → 远程桌面」,在弹出的框里输入密码解锁,之后就能正常连上;重启电脑后又要重复这一操作。 ### 二、排查过程 #### 1. 服务与端口正常 ```bash systemctl --user status gnome-remote-desktop ss -lntp | grep 3389 grdctl status ``` 输出显示用户级 `gnome-remote-desktop.service` 处于 running,3389 也在监听,RDP 后端为 enabled,认证方式为 credentials。说明不是服务没起来,也不是防火墙问题。 #### 2. 日志暴露真正原因 ```bash journalctl --user -u gnome-remote-desktop -b --no-pager ``` 每次客户端连接都会出现: ``` [RDP] Credentials are not set, denying client ``` 也就是说,远程桌面守护进程认为自己「没有设置用户名密码」,于是在认证阶段直接拒绝连接。但从 `grdctl status` 看,凭据明明是配置过的。 #### 3. 锁定状态与设置面板对应 `gnome-control-center` 的远程桌面页面里有一段逻辑: - 登录钥匙环被锁定时,页面顶部会出现信息条「部分设置已锁定(Some settings are locked)」,右侧是「解锁(Unlock)」按钮 - 解锁后,页面才能读写保存的 RDP 凭据 这与现象完全对应:**每次开机后钥匙环是锁定的,GRD 读不到凭据;手动解锁后立刻恢复。** ### 三、根因 三个事实叠加导致: 1. 系统开启了 GDM 自动登录: ```ini # /etc/gdm3/custom.conf AutomaticLoginEnable=True AutomaticLogin=wsj ``` 2. GNOME 远程桌面(GRD 50)把 RDP 的用户名/密码保存在**登录钥匙环**(libsecret,条目 schema 为 `org.gnome.RemoteDesktop.RdpCredentials`),而不是明文配置文件。 3. 自动登录时 PAM 没有密码可传给 `pam_gnome_keyring`,登录钥匙环保持锁定。于是: > 自动登录 → 钥匙环锁定 → GRD 读不到凭据 → RDP 拒绝所有客户端 > 手动解锁钥匙环 → GRD 读到凭据 → 连接恢复(无需重启 GRD) 所以「打开设置输入密码」不是开启了什么开关,而是**解锁了登录钥匙环**。 ### 四、方案选择 | 方案 | 效果 | 代价 | | --- | --- | --- | | 关闭 GDM 自动登录 | 登录时 PAM 自动解锁钥匙环,一劳永逸 | 每次开机必须在登录界面输入密码 | | 保留自动登录 + 开机自动解锁钥匙环 | 随时可远程连接 | 登录密码需以本机可读形式保存 | | 改用系统级「远程登录」 | 不依赖用户钥匙环 | 连的是新会话,不是当前桌面 | 本文采用第二种,并尽量做得「干净」:不修改 PAM、不修改系统自带 unit 文件,只加用户级 systemd drop-in。 ### 五、具体操作 #### 1. 保存登录密码(务必去掉结尾换行) 登录钥匙环的密码一般就是登录密码。先把它保存到仅当前用户可读的文件: ```bash f="$HOME/.config/gnome-keyring-unlock.pass" # 交互输入,不落到 shell 历史 read -rsp '请输入登录密码: ' kp printf '%s' "$kp" > "$f" unset kp chmod 600 "$f" echo ``` > 注意:`printf '%s'` 不追加换行。**如果文件末尾带 `\n`,解锁会失败**(后文踩坑记录有详细说明)。 #### 2. 给钥匙环服务加 drop-in 新建: ``` ~/.config/systemd/user/gnome-keyring-daemon.service.d/unlock-after-autologin.conf ``` 内容: ```ini # 自动登录场景:启动钥匙环守护进程时直接读取保存的密码解锁登录钥匙环 # 若密码文件不存在,则退回系统默认行为(锁定状态启动) [Service] ExecStart= ExecStart=/bin/sh -c 'f=%h/.config/gnome-keyring-unlock.pass; if [ -r "$f" ]; then exec /usr/bin/gnome-keyring-daemon --foreground --components=pkcs11,secrets --control-directory=%t/keyring --unlock < "$f"; else exec /usr/bin/gnome-keyring-daemon --foreground --components=pkcs11,secrets --control-directory=%t/keyring; fi' ``` 要点: - 先写空的 `ExecStart=` 清空原有启动命令,再追加新的 - `%h` = 用户家目录,`%t` = 用户运行时目录(如 `/run/user/1000`) - 用 `exec` 让守护进程成为该 unit 的主进程,保持 systemd 的 socket 激活与生命周期管理 - 密码文件不存在时回退到系统默认行为,避免把钥匙环彻底搞坏 #### 3. 生效 ```bash systemctl --user daemon-reload systemctl --user restart gnome-keyring-daemon.service ``` #### 4. 验证 查看是否还有解锁失败日志: ```bash journalctl --user -u gnome-keyring-daemon.service -b --no-pager | tail ``` 正常时不应出现: ``` failed to unlock login keyring on startup ``` 再用 libsecret 直接查询钥匙环状态: ```bash python3 - <<'PY' import gi gi.require_version('Secret', '1') from gi.repository import Secret s = Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None) for c in s.get_collections(): if c.get_label() == 'Login': print('Login locked =', c.get_locked()) for i in c.get_items(): if 'RemoteDesktop' in i.get_attributes().get('xdg:schema', ''): print('RDP 凭据 locked =', i.get_locked()) PY ``` 期望输出: ``` Login locked = False RDP 凭据 locked = False ``` 为了模拟「重启后的状态」,可以先主动锁定钥匙环再启动服务: ```bash gdbus call --session --dest org.freedesktop.secrets \ --object-path /org/freedesktop/secrets \ --method org.freedesktop.Secret.Service.Lock \ "['/org/freedesktop/secrets/collection/login']" systemctl --user restart gnome-keyring-daemon.service ``` 如果重启后 `Login locked = False`,说明自动解锁链路已经打通。 ### 六、踩坑记录(很有参考价值) #### 1. 密码文件末尾多一个换行就失败 最初的实现是把密码原样写入文件(末尾有 `\n`),结果启动日志报: ``` failed to unlock login keyring on startup ``` `gnome-keyring-daemon --unlock` 从 stdin 读取密码时,会把换行一起当成密码内容。改成 `printf '%s' "$(cat file)"` 去掉换行后立刻成功。 #### 2. `--unlock` 不能作用于已经运行的实例 钥匙环守护进程由 PAM / systemd socket 先启动,此时再执行: ```bash gnome-keyring-daemon --unlock --components=secrets ``` 并不会让已运行的实例去解锁,而是尝试再起一个实例,日志出现: ``` another secret service is running ``` `--replace` 也未必可靠(旧进程仍持有 D-Bus 名称)。真正有效的做法是让 systemd 用带 `--unlock` 的命令直接启动主进程,即本文的 drop-in 方案。 #### 3. D-Bus 服务文件会绕过 systemd unit `/usr/share/dbus-1/services/org.freedesktop.secrets.service` 里写的是: ``` Exec=/usr/bin/gnome-keyring-daemon --start --foreground --components=secrets ``` 如果某个客户端在 unit 启动前先请求 Secret Service,就会绕过 systemd 直接拉起一个「锁定状态」的守护进程,导致你的 drop-in 看起来没生效。 但在**真实登录**过程中,是 PAM 的 `pam_gnome_keyring.so auto_start` 先触发 socket,从而启动 `gnome-keyring-daemon.service`,drop-in 正常生效。手动测试时要注意这一点,不要用「主动访问 Secret Service」来模拟开机登录。 #### 4. 用 `pkill -f` 清理守护进程会误杀自己 排查时曾用: ```bash pkill -f gnome-keyring-daemon ``` 结果连包含这串字符的 shell 命令行进程一起被杀了(工具/脚本自身命令行里也带着这个字符串)。如果要按进程清理,请锚定命令行开头或完整路径,例如: ```bash pkill -u "$(id -u)" -f '^(/usr/bin/)?gnome-keyring-daemon' ``` ### 七、回滚 想恢复系统默认行为: ```bash rm -rf ~/.config/systemd/user/gnome-keyring-daemon.service.d rm -f ~/.config/gnome-keyring-unlock.pass systemctl --user daemon-reload systemctl --user restart gnome-keyring-daemon.service ``` 之后在自动登录场景下又会回到「每次需要手动解锁」的状态。 ### 八、安全提示 该方案本质上把登录密码以可读形式保存在本机(文件权限 600,仅当前用户和 root 可读)。这是保留免密自动登录的必然代价: - 任何以该用户身份运行的进程都能读取该文件 - root 自然也可以读取 如果无法接受这一点,应关闭 GDM 自动登录,让 PAM 在登录时用真实密码解锁钥匙环: ```ini # /etc/gdm3/custom.conf AutomaticLoginEnable=False ``` ### 九、总结 - 自动登录 + GNOME 远程桌面「连不上但解锁后能用」,几乎都是**登录钥匙环被锁定**导致的 - 判断依据是 GRD 日志里的 `Credentials are not set, denying client`,以及设置页面顶部的「部分设置已锁定」 - 修复思路是让钥匙环守护进程在启动时就带着密码解锁,最干净的做法是给 `gnome-keyring-daemon.service` 加用户级 drop-in - 两个关键细节:密码文件不能带结尾换行;`--unlock` 必须作用于「启动瞬间」的守护进程,而不是已运行的实例 --- ## GNOME 50 (Wayland) 桌面网速悬浮窗:TopHat 部署实录与三个踩坑 URL: https://wsj-blog.pages.dev/posts/gnome-50-wayland-zhuo-mian-wang-su-xuan-fu-chuang-tophat-bu/ License: CC-BY-NC-SA-4.0 ### 背景 想在 Ubuntu 的 GNOME 桌面上常驻一个「网速悬浮窗」:不占顶栏、不打断工作、全屏看视频或跑下载时也能瞄一眼实时上下行速率。 本文记录在一台 GNOME Shell 50.1 + Wayland 的机器上部署 TopHat 扩展的完整过程,包括两个真正卡住人的坑:** 缺少 GTop 绑定 ** 和 ** 用户扩展总开关被关闭 ** 。 ### 环境 | 项目 | 值 | | --- | --- | | 发行版 | Ubuntu(GNOME 桌面) | | GNOME Shell | 50.1 | | 会话类型 | Wayland | | 网卡 | enp4s0(有线)/ 无线网卡各一 | 先确认自己的环境: ```bash gnome-shell --version echo $XDG_SESSION_TYPE ``` ### 为什么不直接用 conky / nload - `nload`、`iftop`、`bmon` 是终端工具,想要「悬浮」只能开一个置顶终端窗口,观感差且占地方。 - `conky` 在 Wayland 下**无法置顶**:Wayland 不允许客户端自己设置 always-on-top,透明和置顶效果都会失效。 - GNOME Shell 扩展运行在 Shell 进程内,天然可以叠加在普通窗口和全屏窗口之上,是 Wayland 下做悬浮 HUD 的正路。 ### 选型:TopHat TopHat 是一个可拖动的悬浮面板,显示 CPU、内存、磁盘、网络速率,支持 GNOME 45–50,处于「Active」状态,下载量约 29 万。 它默认就能浮在桌面一角,只保留 Network 项就是纯粹的网速悬浮窗。 ### 部署步骤 #### 1. 下载并安装扩展 扩展页:[https://extensions.gnome.org/extension/5219/tophat/](https://extensions.gnome.org/extension/5219/tophat/) 用命令行安装(注意 URL 里的 `version_tag` 对应版本号,可从扩展页的 download 链接获得): ```bash curl -L -o /tmp/tophat.zip \ "https://extensions.gnome.org/download-extension/tophat@fflewddur.github.io.shell-extension.zip?version_tag=69837" gnome-extensions install --force /tmp/tophat.zip ``` 确认解压位置: ```bash ls ~/.local/share/gnome-shell/extensions/ # tophat@fflewddur.github.io ``` #### 2. 补上 GTop 依赖(坑一) TopHat 通过 GTop 读取系统指标。系统里通常已有 `libgtop-2.0-11`,但**缺少 GObject Introspection 绑定**,GJS 里 `import GTop` 会直接失败,表现是扩展状态一直停在 `INITIALIZED`,`enable` 命令不报错但也不生效。 ```bash sudo apt install gir1.2-gtop-2.0 ``` 验证 typelib 是否到位: ```bash ls /usr/lib/x86_64-linux-gnu/girepository-1.0/ | grep -i gtop # GTop-2.0.typelib ``` #### 3. 启用扩展(坑二) ```bash gnome-extensions enable tophat@fflewddur.github.io ``` 如果这时还是: ``` 已启用: 否 状态: INITIALIZED ``` 先别急着重启,查这个开关: ```bash gsettings get org.gnome.shell disable-user-extensions ``` 我遇到的实际情况是返回 `true` —— **GNOME 的「用户扩展」总开关处于关闭状态**。这个开关一关,所有第三方扩展(不管是否写进 `enabled-extensions`)都会被 Shell 整体跳过,状态永远停在 INITIALIZED。 打开它: ```bash gsettings set org.gnome.shell disable-user-extensions false ``` 打开后无需重启 Shell,扩展会立即被重新评估。验证: ```bash gnome-extensions info tophat@fflewddur.github.io | grep -E "已启用|状态" ``` 期望: ``` 已启用: 是 状态: ACTIVE ``` ### 关于「开机自启动」 GNOME 扩展**不需要**额外的自启动配置,也不要去写 `~/.config/autostart` 或 systemd unit。 - 启用状态保存在 gsettings:`org.gnome.shell enabled-extensions` - 每次登录时 GNOME Shell 启动,会读取该列表并自动加载扩展 - 效果等同于开机自启,且是持久化的 ```bash gsettings get org.gnome.shell enabled-extensions # ['...', 'tophat@fflewddur.github.io'] ``` ### 使用与调优 右键悬浮面板进入设置,可以: - 调整面板位置(左 / 右 / 可拖动) - 调整不透明度,让它更「小部件」化 - 只勾选 Network,关掉 CPU / 内存 / 磁盘,就得到纯网速悬浮窗 ### 排错清单 | 现象 | 原因 | 处理 | | --- | --- | --- | | `gnome-extensions enable` 报「扩展不存在」 | 当前 Shell 会话还没扫描到新装的扩展 | Wayland 下注销重登录一次 | | 状态一直 `INITIALIZED`,`enable` 不报错 | 缺 `gir1.2-gtop-2.0` | `sudo apt install gir1.2-gtop-2.0` | | 装好依赖后仍不启用 | `disable-user-extensions` 为 `true` | `gsettings set org.gnome.shell disable-user-extensions false` | | 所有第三方扩展突然集体消失 | Shell 崩溃后进入安全模式,自动关闭用户扩展 | 同上一条,检查该开关 | | 扩展加载报错 | JS 异常 | `journalctl _COMM=gnome-shell -b -o cat | #### 一个容易误解的点 「挂起后重新登录」不等于「注销重登录」。系统挂起唤醒只是解锁,GNOME Shell 进程并没有重启,新装的扩展依然不会被加载。要真正让 Shell 重新扫描扩展,必须**注销并重新登录**: ```bash gnome-session-quit --logout --no-prompt ``` Wayland 下 `Alt+F2` 输入 `r` 重启 Shell 的方式不可用(仅 X11 有效)。 ### 卸载与回退 ```bash gnome-extensions disable tophat@fflewddur.github.io rm -rf ~/.local/share/gnome-shell/extensions/tophat@fflewddur.github.io ``` 若不需要 GTop 绑定,可一并移除(注意别误删 `libgtop-2.0-11`,部分系统组件可能依赖它): ```bash sudo apt remove gir1.2-gtop-2.0 ``` ### 小结 三个关键点: 1. **TopHat 需要 `gir1.2-gtop-2.0`**,只装 `libgtop` 运行库不够。 2. **`disable-user-extensions` 是总闸**,它为 `true` 时所有第三方扩展静默失效,且不影响扩展列表的展示,极具迷惑性。 3. **GNOME 扩展天生随登录自启**,无需任何额外的自启动配置;改完扩展只需重新登录,不需要重启整机。 ### 参考 - TopHat 扩展页:[https://extensions.gnome.org/extension/5219/tophat/](https://extensions.gnome.org/extension/5219/tophat/) - TopHat 源码:[https://github.com/fflewddur/tophat](https://github.com/fflewddur/tophat) - GNOME Shell 扩展文档:[https://gjs.guide/extensions/](https://gjs.guide/extensions/) --- ## ROS2系列教程:Moveit2运动学(IK/FK与运动学插件) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-yun-dong-xue-ik-fk-yu-yun/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 37 篇 > 本文是 ROS2 系列教程的第 37 篇:MoveIt2 运动学(IK/FK 与运动学插件)。第 36 篇我们把机械臂跑了起来,用 `set_pose_target` 让 move_group 算出关节轨迹。但你有没有想过:move_group 是怎么把"末端要到的位姿"换算成"每个关节该转多少度"的?答案就是本篇的主角——运动学。我们深入 FK(正运动学)与 IK(逆运动学)的原理、KDL 与 TRAC-IK 等求解器的差别、求解器插件的机制,并给出 Python/C++ 调用 IK/FK 的完整示例,最后带你写一个自定义求解器插件。 ### 一、运动学是什么:FK 与 IK #### 1.1 正运动学 FK(Forward Kinematics) 正运动学是从"关节空间"到"笛卡尔空间"的映射:已知每个关节的角度,求末端在空间中的位姿(位置 xyz + 姿态 rpy)。 ```text FK:关节角 [θ1, θ2, θ3] → 末端位姿 (x, y, z, rx, ry, rz) ``` 以第 35 篇的两连杆机械臂为例,末端 tool0 相对 base_link 的位置可以写成显式公式: ```text x = L1·cos(θ1) + L2·cos(θ1 + θ2) y = L1·sin(θ1) + L2·sin(θ1 + θ2) ``` FK 是**唯一确定**的:一组关节角只对应一个末端位姿,而且计算量小(就是一堆三角函数的连乘)。在 MoveIt2 里,FK 由机器人模型(URDF + SRDF)里的 `robot_state` 直接计算,每一帧都在做。 #### 1.2 逆运动学 IK(Inverse Kinematics) 逆运动学正好反过来:已知末端目标位姿,求一组关节角让它到达。 ```text IK:末端位姿 (x, y, z, rx, ry, rz) → 关节角 [θ1, θ2, θ3] ``` IK 是运动学里"难"的部分,难在三个地方: 1. **可能无解**:目标位姿在机械臂工作空间之外(比如离底座 3 米),任何关节角都到不了。 2. **可能多解**:同一个末端位姿,肘部在上或肘部在下(θ2 不同)都能到达,IK 要选一个。 3. **数值迭代**:对 6 自由度机械臂,没有通用的解析公式(除了少数特殊构型),只能迭代逼近。 #### 1.3 用类比理解 把机械臂想象成你的手臂: - **FK**:你告诉我肩、肘、腕各弯了多少度,我能算出你的手在哪——确定、简单。 - **IK**:你告诉我"手要摸到桌上那杯水",我得反算肩肘腕各该动多少——可能有好几种姿势都够得着,也可能够不着,还得避开水杯。 MoveIt2 的规划流程里,IK 被用在两处:一是 `set_pose_target` 时把"末端位姿"变成"关节目标";二是在规划过程中的随机采样阶段,把随机位姿映射回关节空间。 ### 二、MoveIt2 里的运动学求解流程 #### 2.1 move_group 的运动学服务 move_group 暴露了一个服务 `compute_fk`(FK)和 `compute_ik`(IK),但日常开发几乎用不到原始服务——高级接口(MoveGroupCommander / MoveGroupInterface)已经封装好了。 ```text MoveIt2 运动学调用链 你的节点 → MoveGroupCommander.set_pose_target() → move_group 内部 IK 求解 → pluginlib 加载 kinematics.yaml 里指定的求解器 → 求解器算出关节目标 → 运动规划(OMPL)→ 轨迹 ``` 关键点:**求解器是插件**。MoveIt2 不内置唯一的 IK 算法,而是通过 pluginlib 机制加载,你可以在 `kinematics.yaml` 里随时换求解器,代码一行都不用改。 #### 2.2 kinematics.yaml 的作用 `arm_moveit_config/config/kinematics.yaml` 长这样: ```yaml # kinematics.yaml:指定每个规划组用什么 IK 求解器 arm_group: kinematics_solver: kdl_kinematics_plugin/KDLKinematicsPlugin kinematics_solver_search_resolution: 0.005 kinematics_solver_timeout: 0.005 kinematics_solver_attempts: 3 ``` 四个字段: ```text kinematics_solver # 求解器插件(pluginlib 名称) kinematics_solver_search_resolution # 随机重启搜索步长(弧度) kinematics_solver_timeout # 单次求解超时(秒) kinematics_solver_attempts # 最多尝试次数 ``` 把 `kdl_kinematics_plugin` 换成 `trac_ik_kinematics_plugin/TRAC_IKKinematicsPlugin` 就切到了 TRAC-IK。这也是 MoveIt2"接口与实现分离"设计思想的体现。 #### 2.3 求解器插件机制(pluginlib) pluginlib 是 ROS2 的插件加载库:运行时按名字查找并加载动态库。一个 IK 求解器插件需要: ```text 1. 实现 moveit::core::KinematicsBase 接口 2. 在 package.xml 里 export 插件描述 3. 提供插件描述 XML(声明类名/库名) 4. 编译成共享库 ``` MoveIt2 自带 KDL、SRL、IKFast(需自行生成)等插件,社区常用 TRAC-IK。第 6 节我们手写一个最小插件来理解全流程。 ### 三、KDL 求解器详解 #### 3.1 KDL 原理:雅可比迭代 KDL(Kinematics and Dynamics Library)是 Orocos 的运动学库,MoveIt2 默认用它的插件。KDL 的 IK 是**数值迭代法**,核心是雅可比矩阵 J: ```text J = ∂f(θ)/∂θ # 末端速度与关节速度的线性关系 Δx = J · Δθ # 末端小位移 ≈ 雅可比 × 关节小位移 迭代过程: 1. 从初始关节角 θ0 开始(默认当前状态) 2. 算当前末端位姿 f(θ),求误差 Δx = 目标 - 当前 3. 解线性方程 Δθ = J⁻¹·Δx(或伪逆) 4. θ ← θ + Δθ,重复直到误差小于阈值或超时 ``` KDL 默认用阻尼最小二乘(DLS)变体,在奇异点附近通过阻尼因子保持数值稳定,代价是精度略降。 #### 3.2 KDL 的局限 KDL 在工程上有几个已知痛点: ```text 1. 容易陷入局部最优:初始值离解太远时迭代不收敛 2. 对奇异构型敏感:接近奇异时雅可比病态,解抖动 3. 6 自由度机械臂偶尔无解:某些位形下迭代震荡 4. 求解慢:单次求解可能 1~10ms,复杂机械臂更久 ``` 这就是为什么 MoveIt2 默认参数给了 `attempts: 3` 和 `search_resolution: 0.005`——失败后会在附近随机重试,提高成功率。 #### 3.3 求解精度与超时 ```text kinematics_solver_timeout: 0.005 # 5ms 内必须收敛 kinematics_solver_attempts: 3 # 最多 3 次尝试 kinematics_solver_search_resolution: 0.005 # 每次重试偏移量 ``` 调参经验:机械臂构型越复杂、目标越接近工作空间边界,越需要加大 attempts;实时代价是规划变慢。通常先保持默认,遇到"偶发无解"再逐个加大。 ### 四、TRAC-IK 与 IKFast #### 4.1 TRAC-IK:多线程 + 随机重启 TRAC-IK 是 KDL 的"加强版",由 NASA/TRA 实验室开发,核心改进: ```text 1. 同时跑 KDL 变体和 SQP(序列二次规划)两种求解器 2. 多线程并行,先收敛者胜出 3. 失败时自动随机重启,避开局部最优 4. 平均求解成功率显著高于 KDL ``` 安装与启用: ```bash sudo apt install ros-humble-trac-ik-kinematics-plugin ``` ```yaml # kinematics.yaml arm_group: kinematics_solver: trac_ik_kinematics_plugin/TRAC_IKKinematicsPlugin kinematics_solver_timeout: 0.005 kinematics_solver_attempts: 3 ``` TRAC-IK 还有额外的求解模式(速度/距离最优化),在 `kinematics_solver_type` 字段配置。大多数项目直接换 TRAC-IK 就能明显提升 IK 稳定性。 #### 4.2 IKFast:解析解生成器 IKFast 是 OpenRAVE 的工具,对特定机械臂构型**离线生成解析解**代码——不迭代,直接按公式算,速度快且精确。 ```bash # 流程(Humble 上常见做法) python3 openrave_ikfast_gen.py --iktype=transform6d \ --robot=arm.robot.xml --ikframes=tool0 --baselink=base_link \ -o ikfast_arm.cpp ``` 优缺点: ```text 优点:求解快(微秒级)、无局部最优、精度高 缺点:每个构型(6R 球腕等)都要重新生成;代码量大; 非 6 自由度或特殊构型可能无法生成;维护成本高 ``` 适合量产机械臂(构型固定);学习与原型阶段通常先用 KDL/TRAC-IK。 #### 4.3 对比与选型 | 求解器 | 类型 | 速度 | 成功率 | 适用场景 | | --- | --- | --- | --- | --- | | KDL | 数值迭代(DLS) | 中(ms 级) | 中 | 默认入门、原型验证 | | TRAC-IK | 数值+并行+随机重启 | 中(ms 级) | 高 | 大多数工程项目(首选) | | IKFast | 解析解 | 快(μs 级) | 极高 | 量产固定构型机械臂 | | SRL/自定义 | 可定制 | 视实现 | 视实现 | 特殊需求(如冗余自由度) | ### 五、调用 IK/FK(Python + C++) #### 5.1 示例 1:用 move_group 求 IK(Python) `set_pose_target` 内部就调用了 IK,但如果你想"只求 IK 不规划",可以用 `compute_ik` 服务: ```bash # 先启动 demo ros2 launch arm_moveit_config demo.launch.py ``` ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/compute_ik.py import rclpy from moveit_commander import MoveGroupCommander, RobotCommander from geometry_msgs.msg import Pose def main(): rclpy.init() node = rclpy.create_node('compute_ik') robot = RobotCommander(node) group = MoveGroupCommander('arm_group', node) # 1. 目标位姿 target = Pose() target.position.x = 0.30 target.position.y = 0.10 target.position.z = 0.25 target.orientation.w = 1.0 # 2. 调 compute_ik 服务 from moveit_msgs.srv import GetPositionIK from moveit_msgs.msg import PositionIKRequest cli = node.create_client(GetPositionIK, '/compute_ik') req = GetPositionIK.Request() req.ik_request.group_name = 'arm_group' req.ik_request.pose_stamped.header.frame_id = 'base_link' req.ik_request.pose_stamped.pose = target req.ik_request.timeout.sec = 1 if not cli.wait_for_service(timeout_sec=2.0): node.get_logger().error('compute_ik 服务不可用') return fut = cli.call_async(req) rclpy.spin_until_future_complete(node, fut) resp = fut.result() # 3. 打印结果 if resp.error_code.val == resp.error_code.SUCCESS: print('IK 成功,关节角:', list(resp.solution.joint_state.position)) else: print('IK 失败,error_code=', resp.error_code.val) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` `/compute_ik` 服务返回的 `solution.joint_state.position` 就是 IK 求得的关节角,可以直接喂给 `set_joint_value_target` 做规划。 #### 5.2 示例 2:验证 FK(Python) 有了关节角,再用 FK 验证一下末端位姿是否符合预期: ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/check_fk.py import rclpy from moveit_commander import MoveGroupCommander from moveit_msgs.srv import GetPositionFK from moveit_msgs.msg import RobotState def main(): rclpy.init() node = rclpy.create_node('check_fk') group = MoveGroupCommander('arm_group', node) # 1. 构造一个关节状态 joint_angles = [0.5, -0.8, 1.2] # 3 个活动关节 # 2. 调 compute_fk 服务 from geometry_msgs.msg import PoseStamped cli = node.create_client(GetPositionFK, '/compute_fk') req = GetPositionFK.Request() req.fk_link_names = ['tool0'] req.robot_state.joint_state.name = group.get_active_joints() req.robot_state.joint_state.position = joint_angles req.header.frame_id = 'base_link' req.header.stamp = node.get_clock().now().to_msg() if not cli.wait_for_service(timeout_sec=2.0): node.get_logger().error('compute_fk 服务不可用') return fut = cli.call_async(req) rclpy.spin_until_future_complete(node, fut) resp = fut.result() if resp.error_code.val == resp.error_code.SUCCESS: p = resp.pose_stamped[0].pose print(f'FK 结果:x={p.position.x:.3f} y={p.position.y:.3f} z={p.position.z:.3f}') else: print('FK 失败') node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 这个"IK 求角 → FK 验位"的闭环,是排查机械臂标定、URDF 参数错误的最常用手段。 #### 5.3 示例 3:C++ 方式(IK 求解) ```cpp // src/check_ik.cpp(在 arm_planner_cpp 包内) #include #include #include int main(int argc, char** argv) { rclcpp::init(argc, argv); auto node = std::make_shared("check_ik"); auto executor = std::make_shared(); executor->add_node(node); std::thread spin([&executor]() { executor->spin(); }); auto cli = node->create_client("/compute_ik"); if (!cli->wait_for_service(std::chrono::seconds(2))) { RCLCPP_ERROR(node->get_logger(), "compute_ik 服务不可用"); return 1; } auto req = std::make_shared(); req->ik_request.group_name = "arm_group"; req->ik_request.pose_stamped.header.frame_id = "base_link"; req->ik_request.pose_stamped.pose.position.x = 0.30; req->ik_request.pose_stamped.pose.position.y = 0.10; req->ik_request.pose_stamped.pose.position.z = 0.25; req->ik_request.pose_stamped.pose.orientation.w = 1.0; req->ik_request.timeout.sec = 1; auto fut = cli->async_send_request(req); if (executor->spin_until_future_complete(fut) == rclcpp::FutureReturnCode::SUCCESS) { auto resp = fut.get(); if (resp->error_code.val == moveit_msgs::msg::MoveItErrorCodes::SUCCESS) { RCLCPP_INFO(node->get_logger(), "IK 成功"); for (size_t i = 0; i < resp->solution.joint_state.position.size(); ++i) { RCLCPP_INFO(node->get_logger(), " joint[%zu] = %.3f", i, resp->solution.joint_state.position[i]); } } else { RCLCPP_ERROR(node->get_logger(), "IK 失败 code=%d", resp->error_code.val); } } spin.join(); rclcpp::shutdown(); return 0; } ``` ```bash # CMakeLists.txt 增加 find_package(moveit_msgs REQUIRED) ament_target_dependencies(check_ik rclcpp moveit_ros_planning_interface moveit_msgs) install(TARGETS check_ik DESTINATION lib/${PROJECT_NAME}) ``` ### 六、自定义运动学求解器插件 理解插件的机制,比背 API 更有价值。下面写一个"最小可用"的自定义 IK 求解器插件。 #### 6.1 实现 KinematicsBase 接口 ```cpp // src/my_ik_plugin/src/my_ik.cpp(简化示意) #include #include namespace my_ik { class MyIK : public moveit::core::KinematicsBase { public: bool initialize(const rclcpp::Node::SharedPtr& node, const moveit::core::RobotModel& model, const std::string& group, const std::string& base_frame, const std::vector& tip_frames, double search_discretization) override { // 保存模型、规划组、base/tip 帧 // 从 URDF 读关节名、关节限位 return true; } bool getPositionIK(const geometry_msgs::msg::Pose& ik_pose, const std::vector& ik_seed_state, std::vector& solution, moveit_msgs::msg::MoveItErrorCodes& error_code, const kinematics::KinematicsQueryOptions& options = {}) const override { // 这里实现你的 IK 算法(如雅可比迭代) // 成功:error_code.val = MoveItErrorCodes::SUCCESS return true; } bool getPositionFK(const std::vector& link_names, const std::vector& joint_angles, std::vector& poses, moveit_msgs::msg::MoveItErrorCodes& error_code) const override { // 这里实现 FK:按 URDF 链乘算出各 link 位姿 return true; } // 其余纯虚函数(getJointNames/getLinkNames 等)从略 }; } // namespace my_ik PLUGINLIB_EXPORT_CLASS(my_ik::MyIK, moveit::core::KinematicsBase) ``` `KinematicsBase` 的核心就是 `getPositionIK` 和 `getPositionFK` 两个虚函数,其余是一堆 getter。 #### 6.2 注册插件与配置 ```xml 自定义 IK 求解器示例 ``` ```xml ``` ```yaml # kinematics.yaml 指向自定义插件 arm_group: kinematics_solver: my_ik_plugin/MyIK kinematics_solver_timeout: 0.005 kinematics_solver_attempts: 3 ``` 重新编译、重启 demo 后,move_group 就会用你的求解器。可以在 `getPositionIK` 里打印日志,验证确实被调用。 #### 6.3 测试与调试 ```bash colcon build --packages-select my_ik_plugin ros2 launch arm_moveit_config demo.launch.py # 拖拽 Goal 或调用 compute_ik,观察日志 ``` 常见坑: ```text 1. 插件未注册:日志报 "Failed to create instance of my_ik_plugin/MyIK" → 检查 package.xml export 与 XML 描述里的 class name 是否一致 2. 链接错误:编译过但加载失败 → 检查库路径与 PLUGINLIB_EXPORT_CLASS 3. 求解永远失败:先实现 getJointNames/getLinkNames 返回正确顺序 ``` ### 七、奇异点与多解处理 #### 7.1 奇异位形 奇异点(singularity)是机械臂某些关节角组合下"丢失自由度"的位形,典型例子: ```text 1. 手臂完全伸直:肘关节角度 0,无法改变末端径向速度 2. 手腕中心对齐肩轴:末端只能沿一个方向移动 3. 某关节角到达限位:该方向运动被锁死 ``` 奇异点附近雅可比矩阵病态,IK 迭代缓慢、解抖动,轨迹执行时可能突然出现"甩腕"动作。工程上避开方式:规划时给奇异点留安全距离,或用阻尼最小二乘平滑。 #### 7.2 多解选择策略 同一个末端位姿常有多个 IK 解(肘上/肘下、腕翻转)。MoveIt2 默认策略是**就近原则**:从当前关节角出发迭代,收敛到离当前姿态最近的解——这是数值迭代法的天然行为。 ```text 从当前姿态出发 → IK 收敛到最近解 优点:轨迹平滑、不跳变 缺点:可能收敛到局部最优(非全局最近) ``` 需要全局最优时:加大 `kinematics_solver_attempts` + `search_resolution`,用随机重启撒网。 #### 7.3 工程实践建议 ```text 1. 首选 TRAC-IK:把 kinematics.yaml 换成 TRAC-IK 插件,成功率立涨 2. 设置合理超时:0.005s × 3 次起步,不够再加 3. 预校验可达性:规划前先求一次 IK,失败直接提示"目标不可达" 4. 奇异点附近降速:速度缩放调到 0.3 以下,避免执行抖动 5. 记录 IK 失败日志:把失败时的位姿/关节角存下来,便于复现调参 ``` ### 八、小结与第 38 篇预告 本篇把运动学讲透了:FK 是唯一确定的链乘,IK 是可能无解/多解的迭代问题;MoveIt2 用插件机制隔离算法与调用方,`kinematics.yaml` 一行就能换求解器;KDL 够用但易卡局部最优,TRAC-IK 是工程首选,IKFast 是量产利器;我们还写了自定义求解器插件的最小骨架。 核心要点: 1. FK 由 URDF 链乘直接算出,唯一且快;IK 需要迭代,可能无解、多解、奇异。 2. `kinematics.yaml` 的 `kinematics_solver` 指定求解器插件,改一行即可切换 KDL/TRAC-IK/自定义。 3. `/compute_ik` 与 `/compute_fk` 服务可单独调用,IK 求角 → FK 验位是排查标配。 4. 自定义求解器:继承 `KinematicsBase`、实现 `getPositionIK/getPositionFK`、注册 pluginlib、改 kinematics.yaml。 5. 奇异点靠阻尼与降速规避;多解默认就近;工程首选 TRAC-IK。 下一章(第 38 篇)**「MoveIt2 碰撞检测与避障」**:深入 Planning Scene 的碰撞检测机制——FCL 库怎么算两个形状是否重叠、允许碰撞矩阵 ACM 的用法、在规划场景里加动态障碍物、碰撞检测的性能优化,以及"先碰撞检测、后避障规划"的完整工程流程。 --- ## ROS2系列教程:Moveit2仿真与运动规划(基础) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-fang-zhen-yu-yun-dong-gui/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 36 篇 > 本文是 ROS2 系列教程的第 36 篇:MoveIt2 仿真与运动规划(基础)。第 35 篇我们装好了 MoveIt2、写了一个两连杆机械臂的 URDF,并用 moveit_setup_assistant 生成了配置包。今天我们把机械臂"跑起来":从 demo 启动开始,理解规划场景(Planning Scene)、运动规划请求/响应的数据结构,再用 Python 和 C++ 两种方式调用 move_group 完成"规划 + 执行"的完整闭环。本篇是 MoveIt2 阶段承上启下的一篇,第 37 篇将深入运动学。 ### 一、从 demo 开始:把机械臂"跑起来" #### 1.1 demo.launch.py 里发生了什么 上一篇文章生成的配置包 `arm_moveit_config` 里有一个 `launch/demo.launch.py`,一行命令就能把整套 MoveIt2 环境拉起来: ```bash ros2 launch arm_moveit_config demo.launch.py ``` 启动后你会看到:RViz 打开了 MoveIt 的 MotionPlanning 面板、机械臂模型显示在中央、一个半透明的"目标位姿"轴(Goal State)出现在末端。这条命令背后其实依次启动了五个东西: ```text demo.launch.py 启动链 ├── robot_state_publisher # 发布 TF:base_link → tool0 ├── joint_state_publisher_gui # 手动拖动关节滑杆(RViz 面板里) ├── move_group 节点 # MoveIt2 的大脑 ├── RViz + MotionPlanning 插件 └── Planning Scene Monitor # 维护/同步规划场景 ``` 用 `ros2 node list` 能看到这些节点都在运行: ```bash ros2 node list # /joint_state_publisher_gui # /move_group # /robot_state_publisher # /rviz2 ``` 其中 `/move_group` 是核心。它不是"一个程序",而是一个 ROS 节点加一堆服务的集合:规划(plan)、执行(execute)、运动学(kinematics)、碰撞检测(collision)等能力全部以服务/动作接口的形式暴露出来,谁都能调用——包括你自己写的节点。 #### 1.2 RViz 里能做什么 RViz 左侧的 MotionPlanning 面板是这个阶段最重要的调试工具,从上到下是: - **Context**:显示当前使用的规划组(如 `arm_group`)、运动学求解器(KDL)、规划器(OMPL)。 - **Planning**:选择 `arm_group`,点击 `Update` 会按当前 Goal State 重新规划;`Plan` 只规划不执行,`Plan & Execute` 规划并执行。 - **Goal State / Start State**:可以用鼠标拖拽绿色的 Goal 轴到任意位置,再点 Plan。 - **Scene Objects**:往规划场景里加盒子/圆柱等障碍物,MoveIt2 规划时会自动避让。 - **Trajectory**:规划完以后显示路径上的各关节角度随时间变化的曲线。 初次使用建议做三个小实验: ```text 实验 1:把 Goal 轴拖到正前方 → 点 Plan → 看绿色轨迹 实验 2:在 Scene Objects 加一个盒子挡在路径上 → 再 Plan → 轨迹绕开 实验 3:点 Plan & Execute → 看 RViz 里机械臂真实"动"起来 ``` 注意:demo 模式下机械臂是"假动"——move_group 直接把关节轨迹发布到 `joint_states`,让 RViz 里的模型跟着动,底盘真实电机并没有转。真机控制要等第 40 篇对接 ros2_control。 #### 1.3 手动拖拽与碰撞检测 在 RViz 里用鼠标左键按住末端(tool0)附近的轴可以拖拽机械臂。此时 MoveIt2 每帧都在做两件事: 1. **正运动学**:由当前关节角算出末端位姿(FK)。 2. **碰撞检测**:用 FCL 库检查连杆之间、连杆与障碍物之间是否重叠。 拖到奇异点或自碰撞位置时,模型会"卡住"或变红提示。这个交互能力来自 MotionPlanning 插件的 **interactive marker**,它是 MoveIt2 入门阶段体验"规划"最直观的方式——你拖目标、它算路径。 ### 二、运动规划的基础概念 #### 2.1 Planning Scene(规划场景) 规划场景是 MoveIt2 对"机器人周围世界"的完整描述,包含: ```text PlanningScene 组成 ├── 机器人模型(RobotModel:URDF + SRDF) ├── 当前关节状态(JointState) ├── 允许碰撞矩阵 ACM(哪些对允许碰撞) ├── 规划场景对象(障碍物:盒子/圆柱/网格/点云) └── 变换树(世界坐标系 → 各 link) ``` move_group 内部维护一份"权威"规划场景,所有规划请求都基于它。如果你想让机械臂避开某个障碍物,就要先把障碍物"塞进"规划场景——后面 5.2 和 6.2 会给出代码。 #### 2.2 MotionPlanRequest 与 MotionPlanResponse 规划的本质是"给一个请求,返回一条轨迹"。MoveIt2 用两个消息描述这个过程: ```text moveit_msgs/msg/MotionPlanRequest ├── group_name # 规划组:arm_group ├── start_state # 起始状态(默认=当前状态) ├── goal_constraints # 目标约束(位姿约束/关节约束/位置约束) ├── trajectory_constraints # 轨迹级约束(可空) ├── planner_id # 指定规划器(默认 OMPL) ├── max_velocity_scaling_factor # 速度缩放 └── max_acceleration_scaling_factor # 加速度缩放 moveit_msgs/msg/MotionPlanResponse ├── trajectory # 关节空间轨迹(robot_trajectory) ├── planning_time # 实际耗时 ├── error_code # 0=成功,其余为失败码 └── group_name ``` 你可以直接调用 move_group 的 `/plan_kinematic_path` 服务拿到 `MotionPlanResponse`,也可以更简单地用 move_group 的高级接口(`MoveGroupInterface` / `MoveGroupCommander`),它们内部帮你填好了这些字段。 #### 2.3 Start State 与 Goal State 规划有两个端点: - **Start State**:默认取机器人当前关节角。也可以指定任意状态——比如"从某个预先录好的姿态开始规划"。 - **Goal State**:描述"想到哪",常见三种写法: ```text 1. 位姿约束(PoseConstraint):末端 tool0 的 xyz + rpy 目标 2. 关节约束(JointConstraint):每个关节的目标角度 3. 位置约束(PositionConstraint):只约束位置、不管姿态 ``` 高级接口里对应的三个方法:`set_pose_target()`、`set_joint_value_target()`、`set_position_target()`。目标约束给得越"松",规划越容易成功(约束太多容易无解)。 ### 三、用 move_group 规划并执行(Python) Python 侧用的是 `MoveGroupCommander`,封装在 `moveit_py`(Humble 是 `moveit_commander`)里。下面四个示例由浅入深,全部可直接运行。 #### 3.1 示例 1:连接到规划组并打印当前状态 ```bash # 先启动 demo(另开终端) ros2 launch arm_moveit_config demo.launch.py ``` ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/check_group.py import rclpy from moveit_commander import MoveGroupCommander, PlanningSceneInterface def main(): rclpy.init() node = rclpy.create_node('check_group') # 1. 连接规划组 group = MoveGroupCommander('arm_group', node) # 2. 打印当前状态 print('当前关节角度:', group.get_current_joint_values()) print('当前末端位姿:\n', group.get_current_pose().pose) print('规划组关节列表:', group.get_active_joints()) print('支持的运动学求解器:', group.get_interface_description()) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` ```bash # 运行(需要在 moveit_commander 包内或已安装) ros2 run arm_demo check_group ``` 运行后你会看到类似输出:当前关节角度是 `[0, 0, 0]`,末端位姿是 URDF 里定义的 `0.35` 米处的坐标。`get_active_joints()` 打印出规划组里真正能动的关节(不含 fixed joint)。 #### 3.2 示例 2:设置位姿目标并规划 ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/plan_pose.py import rclpy from moveit_commander import MoveGroupCommander from geometry_msgs.msg import Pose def main(): rclpy.init() node = rclpy.create_node('plan_pose') group = MoveGroupCommander('arm_group', node) # 1. 构造目标位姿:正前方 0.3m 处,tool0 竖直向下 target = Pose() target.position.x = 0.30 target.position.y = 0.0 target.position.z = 0.25 target.orientation.w = 1.0 # 单位四元数 = 无旋转 # 2. 设置目标并规划 group.set_pose_target(target, 'tool0') plan = group.plan() # 返回计划对象(含轨迹) # 3. 检查结果 if plan.joint_trajectory.points: print('规划成功!轨迹点数:', len(plan.joint_trajectory.points)) first = plan.joint_trajectory.points[0] last = plan.joint_trajectory.points[-1] print('起点关节角:', first.positions) print('终点关节角:', last.positions) else: print('规划失败:目标不可达或无解') # 4. 显示在 RViz 里(只规划不执行) group.set_goal_tolerance(0.01) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 注意 `plan()` 在新版本 moveit_commander 里返回 `MoveItPyPlan` 对象(含 `joint_trajectory`),旧版本返回 `(success, trajectory_msg)` 元组。如果你跑的是旧版,改成: ```python success, traj_msg = group.plan() if success: print('规划成功') ``` #### 3.3 示例 3:规划并执行(完整闭环) ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/plan_execute.py import rclpy from moveit_commander import MoveGroupCommander def main(): rclpy.init() node = rclpy.create_node('plan_execute') group = MoveGroupCommander('arm_group', node) # 1. 回到 home 位形 group.set_named_target('home') # 来自 SRDF 里的 group_state group.go(wait=True) group.clear_pose_targets() # 2. 指定位姿并规划执行 group.set_position_target([0.30, 0.0, 0.25]) # 只约束位置 plan = group.plan() if getattr(plan, 'joint_trajectory', None) and plan.joint_trajectory.points: print('开始执行……') group.execute(plan, wait=True) print('执行完成!') else: print('规划失败') # 3. 回到 home group.set_named_target('home') group.go(wait=True) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 这里的 `group.go(wait=True)` 是"规划 + 执行"一步到位的快捷方式;`group.execute(plan)` 只执行已规划好的轨迹。`set_position_target` 只约束末端位置,姿态自由,规划成功率更高。 #### 3.4 示例 4:关节空间目标 + 速度缩放 ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/plan_joints.py import rclpy from moveit_commander import MoveGroupCommander def main(): rclpy.init() node = rclpy.create_node('plan_joints') group = MoveGroupCommander('arm_group', node) # 1. 直接在关节空间设目标(3 个活动关节) group.set_joint_value_target([0.5, -0.8, 1.2]) # 2. 慢速规划:速度/加速度都缩到 30% group.set_max_velocity_scaling_factor(0.3) group.set_max_acceleration_scaling_factor(0.3) # 3. 规划并执行 success, traj = group.plan() if hasattr(group.plan(), '__len__') else (True, group.plan()) group.go(wait=True) print('到达关节目标,当前关节角:', group.get_current_joint_values()) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **速度缩放的作用**:规划出来的轨迹自带时间戳,缩放因子直接决定机械臂动得快慢。真机上先设 0.2~0.3 低速试跑,确认无碰撞再提速,是工程惯例。 ### 四、C++ 方式:MoveGroupInterface C++ 侧对应 `MoveGroupInterface` 类,接口名与 Python 高度对称。先建一个包: ```bash ros2 pkg create arm_planner_cpp --build-type ament_cmake ``` #### 4.1 配置 CMakeLists.txt 与 package.xml ```cmake # CMakeLists.txt(片段) find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) find_package(moveit_ros_planning_interface REQUIRED) add_executable(plan_pose src/plan_pose.cpp) target_include_directories(plan_pose PRIVATE ${moveit_ros_planning_interface_INCLUDE_DIRS}) ament_target_dependencies(plan_pose rclcpp moveit_ros_planning_interface) install(TARGETS plan_pose DESTINATION lib/${PROJECT_NAME}) ament_package() ``` ```xml rclcpp moveit_ros_planning_interface moveit_msgs ``` #### 4.2 示例 5:C++ 规划并执行 ```cpp // src/plan_pose.cpp #include #include int main(int argc, char** argv) { rclcpp::init(argc, argv); auto node = std::make_shared("arm_planner"); auto executor = std::make_shared(); executor->add_node(node); std::thread spin([&executor]() { executor->spin(); }); using moveit::planning_interface::MoveGroupInterface; // 1. 连接规划组 MoveGroupInterface group(node, "arm_group"); // 2. 设置目标位姿 geometry_msgs::msg::Pose target; target.position.x = 0.30; target.position.y = 0.0; target.position.z = 0.25; target.orientation.w = 1.0; group.setPoseTarget(target, "tool0"); // 3. 规划 moveit::planning_interface::MoveGroupInterface::Plan plan; bool ok = (group.plan(plan) == moveit::core::MoveItErrorCode::SUCCESS); if (!ok) { RCLCPP_ERROR(node->get_logger(), "规划失败"); } else { RCLCPP_INFO(node->get_logger(), "规划成功,轨迹点数=%zu", plan.trajectory_.joint_trajectory.points.size()); // 4. 执行 group.execute(plan); RCLCPP_INFO(node->get_logger(), "执行完成"); } // 5. 回到 home group.setNamedTarget("home"); group.move(); spin.join(); rclcpp::shutdown(); return 0; } ``` 注意:MoveGroupInterface 需要节点在 spin 状态才能收到规划结果,所以必须用 executor + 独立线程 spin。这是 C++ 写法最常见的坑。 #### 4.3 编译与运行 ```bash cd ~/dev_ws colcon build --packages-select arm_planner_cpp source install/setup.bash ros2 run arm_planner_cpp plan_pose ``` 运行前确保 demo 还在运行(move_group 在线)。输出类似: ```text [INFO] 规划成功,轨迹点数=42 [INFO] 执行完成 ``` ### 五、轨迹与执行监控 #### 5.1 轨迹时间参数化 规划器(OMPL)输出的是"空间路径"——只有各关节角度序列,没有时间。move_group 在返回结果前会做 **Time Parameterization**,把路径加上速度/加速度约束,生成带 `time_from_start` 的轨迹: ```text 路径点(无时间) → 时间参数化 → 带时间轨迹 joint: [0, 0, 0] t=0.0 joint: [0.1, -0.2, 0.3] t=0.5 joint: [0.5, -0.8, 1.2] t=2.1 ``` 时间参数化的参数来自 MoveIt2 配置:`ompl_planning.yaml` 里的 `max_velocity_scaling_factor`、`max_acceleration_scaling_factor`,以及机器人的速度限制(URDF 里 joint 的 `velocity` 属性)。真机上这两组数值直接决定电机的安全上限。 #### 5.2 执行监控:跟随与失败处理 `group.execute(plan, wait=True)` 背后是一个 Action 客户端,连到 move_group 的 `ExecuteTrajectory` action。执行过程中 move_group 会: 1. 把关节轨迹按时间轴逐段发布到 `/joint_trajectory_controller`(真机)或 `joint_states`(仿真)。 2. 用 `follow_joint_trajectory` 的反馈监控"实际到达位置 vs 期望位置"。 3. 偏差超过阈值时报错并中止(error_code 非 0)。 Python 里可以捕获执行状态: ```python result = group.execute(plan, wait=True) # 某些版本返回 MoveItPyPlan 对象,可检查 .error_code ``` #### 5.3 中断、重规划与容错 实践中轨迹执行常被打断(碰触、电机堵转、任务变化)。推荐模式: ```text while 未到达目标: 1. 检查当前状态(关节角/位姿) 2. 调用 set_pose_target 重新规划 3. 执行;若执行失败/超时 → 记录错误 → 返回步骤 1 4. 连续失败 N 次 → 报警并回 home ``` 这个"感知 → 规划 → 执行 → 校验"循环就是机器人任务控制的基本骨架,第 39 篇会把它扩成完整的抓取流程。 ### 六、可视化与调试 #### 6.1 RViz MotionPlanning 面板常见设置 ```text 1. 显示碰撞检测结果:Displays → MotionPlanning → 勾选 Collision 2. 显示规划场景对象:勾选 Scene Objects 3. 显示轨迹:勾选 Planned Path 4. 显示目标状态:勾选 Goal State ``` #### 6.2 向规划场景加障碍物(Python) ```python #!/usr/bin/env python3 # ~/dev_ws/src/arm_demo/arm_demo/add_obstacle.py import rclpy from moveit_commander import PlanningSceneInterface from shape_msgs.msg import SolidPrimitive from geometry_msgs.msg import Pose def main(): rclpy.init() node = rclpy.create_node('add_obstacle') scene = PlanningSceneInterface(node) # 在机械臂正前方 0.35m 放一个 0.1m 的立方体 box = {'box': (0.08, 0.08, 0.08)} scene.add_box('obstacle', box, Pose( position=[0.30, 0.0, 0.15])) node.get_logger().info('已添加障碍物 obstacle') rclpy.spin_once(node, timeout_sec=0.5) rclpy.shutdown() if __name__ == '__main__': main() ``` 加完障碍物后再去 RViz 里拖 Goal 到障碍物另一侧,你会发现规划轨迹自动绕行——这就是规划场景在起作用。 #### 6.3 常见问题速查 | 现象 | 原因 | 解决 | | --- | --- | --- | | plan() 返回空轨迹 | 目标不可达/约束过紧 | 放宽位姿约束、降低目标高度、检查碰撞 | | 规划成功但执行不动 | 速度缩放=0 或控制器未启 | 检查 set_max_velocity_scaling_factor,demo 下看 joint_states | | RViz 里机械臂消失 | TF 未发布 | 检查 robot_state_publisher 是否在跑,`ros2 run tf2_ros tf2_echo base_link tool0` | | execute 一直 wait | move_group 未收到动作 | 确认 demo 正常运行,`ros2 action list` 应有 execute_trajectory | | 拖拽 Goal 时机械臂穿透 | 碰撞检测未生效 | 勾选 Collision 显示,检查 Planning Scene 是否更新 | ### 七、小结与第 37 篇预告 本篇完成了 MoveIt2 的第一个"闭环":从 demo 启动到规划、执行、加障碍物避障。核心要点: 1. `demo.launch.py` 一次拉起 robot_state_publisher、move_group、RViz 全家桶,move_group 是所有能力的入口。 2. 规划 = MotionPlanRequest →(OMPL + 时间参数化)→ MotionPlanResponse,高级接口 `MoveGroupCommander` / `MoveGroupInterface` 帮你封装了这一切。 3. Python 的 `set_pose_target/set_joint_value_target/set_position_target` 与 C++ 的 `setPoseTarget/setJointValueTarget/setPositionTarget` 一一对应,语法几乎相同。 4. 规划场景(Planning Scene)是"机器人 + 世界"的统一描述,往里加障碍物即可实现自动避障。 5. 真机调试惯例:速度缩放先 0.2~0.3,成功后再提速。 下一章(第 37 篇)**「MoveIt2 运动学(IK/FK 与运动学插件)」**:深入 move_group 是怎么把"末端位姿"换算成"关节角度"的——正运动学 FK、逆运动学 IK、KDL 与 TRAC-IK 求解器的差别、奇异点与多解选择,并给出求解器插件的自定义示例,让机械臂规划"知其所以然"。 --- ## ROS2系列教程:Moveit2环境与机械臂建模准备 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-moveit2-huan-jing-yu-ji-xie-bi-jian/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 35 篇 > 本文是 ROS2 系列教程的第 35 篇:MoveIt2 环境与机械臂建模准备。第 31~34 篇我们完成了移动底盘机器人的完整实战(建图、导航、任务、安全)。从本篇开始,系列进入最后一个大阶段:**MoveIt2 机械臂篇**。第 35 篇先解决"地基":MoveIt2 是什么、环境怎么装、机械臂怎么用 URDF/Xacro 描述、怎么用工具自动生成 MoveIt2 配置包,最后用 RViz 可视化验证模型。移动机器人关心"轮子转多少",机械臂关心"关节动多少"——坐标系与运动学思维将从这里焕然一新。 ### 一、MoveIt2 到底是什么 #### 1.1 一句话理解 MoveIt2 是 ROS2 的**机械臂运动规划框架**:你给它一个机械臂的 URDF 模型和一个目标位姿(比如"把手伸到桌上那个杯子"),它负责规划出**一条无碰撞的关节轨迹**,并交给控制器执行。 它不是一个单体程序,而是一组模块的集合: ```text MoveIt2 核心模块 ├── Move Group 节点 # 中枢:接收目标,调度规划 ├── Robot Model / URDF # 机械臂几何与运动学描述 ├── Planning Scene # 场景:障碍物、自碰撞、允许碰撞矩阵 ├── Motion Planner # 规划器:OMPL(默认)/ Pilz / STOMP ├── Kinematics # 运动学:正向 FK / 逆解 IK(KDL / IKFast / TRAC-IK) ├── Collision Checking # 碰撞检测:FCL 库 ├── MoveIt 控制接口 # 与 ros2_control 对接(第 28 篇基础) └── RViz 插件(MotionPlanning) # 可视化与手动拖拽目标 ``` #### 1.2 MoveIt2 vs MoveIt1 | 对比 | MoveIt1(ROS1) | MoveIt2(ROS2) | | --- | --- | --- | | 驱动接口 | 自定义 ControllerManager | ros2_control 统一管理 | | 参数 | YAML + rosparam | YAML + rclcpp 参数节点 | | 规划器 | OMPL | OMPL(C++ 重构版) | | 行为树 | SMACH | Behavior Trees(可选) | | 维护状态 | 维护模式 | 活跃开发 | 学习建议:**直接学 MoveIt2**。教程环境是 Humble 版 MoveIt2,与 ROS2 Humble 一一对应。 ### 二、环境安装(一步到位) #### 2.1 二进制安装(推荐) MoveIt2 官方提供二进制包,比源码编译快得多: ```bash # 1. 确认 ROS2 Humble 已安装 source /opt/ros/humble/setup.bash echo $ROS_DISTRO # 期望输出 humble # 2. 安装 MoveIt2 全部二进制包 sudo apt install ros-humble-moveit # 3. 安装机械臂模型(示例用 Panda 或 UR5) sudo apt install ros-humble-panda-moveit-config # 4. 验证安装 ros2 pkg list | grep moveit # 期望看到:moveit_core moveit_ros_planning moveit_ros_planning_interface 等 ``` 安装完成后用一个小测试确认能跑: ```bash ros2 launch moveit2_tutorials demo.launch.py # 或直接用 Panda 演示 ros2 launch panda_moveit_config demo.launch.py ``` 如果能看到 RViz 里出现一个 Panda 机械臂模型(7 个关节的红色/灰色手臂),环境就绪。 #### 2.2 源码编译(可选,进阶) 二进制满足不了需求(想改 MoveIt2 源码)时再走源码: ```bash mkdir -p ~/moveit2_ws/src && cd ~/moveit2_ws wget https://raw.githubusercontent.com/moveit/moveit2/humble/moveit2.repos vcs import src < moveit2.repos rosdep install -r --from-paths src --ignore-src --rosdistro humble -y colcon build --cmake-args -DCMAKE_BUILD_TYPE=Release ``` 源码编译约需 20~40 分钟(视机器性能),编译失败常见原因是依赖缺失,跑 `rosdep install` 即可。 #### 2.3 安装验证清单 ```bash # 快速自查 5 项 ros2 pkg prefix moveit_core # 1. 核心包存在 ros2 pkg prefix moveit_ros_planning_interface # 2. 接口包存在 ls /opt/ros/humble/share/moveit_ros_visualization # 3. RViz 插件存在 which ros2 # 4. ROS2 可用 echo $ROS_DISTRO # 5. 版本正确 ``` ### 三、机械臂的 URDF 描述(与移动机器人的差异) #### 3.1 机械臂 URDF 的独特之处 第 16 篇我们写过移动底盘的 URDF。机械臂 URDF 有三点明显不同: 1. **关节类型是 revolute(旋转)**:移动底盘的轮子是 continuous(连续旋转),机械臂关节是有限角度旋转,用 `` 限制关节范围。 2. **每个关节一个电机**:不是"两轮差速"一个控制器,而是每个关节一个 joint 控制器。 3. **末端执行器(end effector)**:最末端的 link 是夹爪/吸盘/工具,MoveIt2 要单独知道它。 一个简化版两连杆机械臂 URDF(理解结构用): ```xml ``` 三个关键点: - ``:**必填**,MoveIt2 规划不能越过关节限位。 - ``:旋转轴方向(肩绕 z,肘绕 y),写错轴模型会"乱扭"。 - 末端固定关节:工具坐标系 `tool0` 用固定关节挂在末杆上。 #### 3.2 用 Xacro 参数化(正式项目必用) 真实机械臂(如 Panda、UR5)连杆多、参数繁,全部写死在一个 URDF 里维护困难。用 Xacro 宏定义"连杆 + 关节"模板: ```xml ``` 好处:改长度只改一处参数;加一节手臂只加一行调用。真实机械臂的 xacro 都这么组织(link 定义 + joint 定义分离 + 宏复用)。 #### 3.3 检查模型的必备工具 模型写完先自查,再进 MoveIt2: ```bash # 1. 语法与几何检查 check_urdf arm.urdf # 输出 link/joint 树,报错会指出问题 # 2. Xacro 展开为纯 URDF(MoveIt2 用的是展开后的) xacro arm.xacro > arm_generated.urdf # 3. RViz 可视化 ros2 launch urdf_tutorial display.launch.py model:=arm_generated.urdf # 或手动:robot_state_publisher + RViz 加载 # 4. TF 树确认 ros2 run tf2_ros tf2_echo base_link tool0 ``` `check_urdf` 输出应显示完整父子链:base_link → shoulder → elbow → tool0。有断链说明 joint 的 parent/child 写错了。 ### 四、示例 1:用工具生成 MoveIt2 配置包 手工写 MoveIt2 配置(SRDF、规划组、控制器 YAML、launch)繁琐且易错。官方工具 `moveit_setup_assistant` 一条龙生成。 #### 4.1 启动 Setup Assistant ```bash ros2 run moveit_setup_assistant moveit_setup_assistant ``` 界面操作流程(英文界面,按顺序): 1. **Create New MoveIt Configuration Package** → 选择你的 URDF/Xacro 文件(如 `arm_generated.urdf`)。 2. **Self-Collisions**:点 “Generate Collision Matrix” 自动计算自碰撞矩阵(哪些 link 对永远不会碰,直接排除,省规划时间)。 3. **Virtual Joints**:机械臂装在移动底盘上时,加一个虚拟关节 `virtual_joint`(固定类型,parent 为 world/odom)。纯固定机械臂可跳过。 4. **Planning Groups**:点 “Add Group”,勾选所有关节组成一个运动组(如 `arm_group`,Kinematic Solver 选 `kdl_kinematics_plugin/KDLKinematicsPlugin`)。 5. **Robot Poses**(可选):预设几个常用姿态(home、vertical 等),便于测试。 6. **End Effectors**:把 `tool0` 标记为末端执行器,命名 `eef_group`,parent link 填 `tool0`。 7. **Passive Joints / Controllers**:默认即可,控制器 YAML 由助理生成。 8. **Author Information**:填包名 `arm_moveit_config`。 9. 点 **Generate Package** 选择输出目录,生成完毕。 #### 4.2 生成的包结构 ```text arm_moveit_config/ ├── config/ │ ├── arm.srdf # 语义描述:规划组/虚拟关节/末端 │ ├── joint_limits.yaml # 关节限位(从 URDF 提取,可覆盖) │ ├── kinematics.yaml # 逆解求解器配置(KDL) │ ├── ompl_planning.yaml # OMPL 规划器配置 │ ├── moveit_controllers.yaml # 与 ros2_control 的映射 │ └── move_group.yaml # move_group 节点参数 ├── launch/ │ ├── demo.launch.py # 一键演示:RViz + move_group + 仿真控制器 │ ├── move_group.launch.py │ └── setup_assistant.launch.py ├── package.xml └── CMakeLists.txt ``` #### 4.3 一键测试生成的包 ```bash ros2 launch arm_moveit_config demo.launch.py ``` 成功标准:RViz 出现机械臂模型 + MoveIt MotionPlanning 面板。此时已经可以用"拖拽末端目标"或"规划执行"按钮让虚拟机械臂动起来——**第 36 篇将详细讲运动规划**,这里先确认链路通了。 ### 五、示例 2:手动编写 SRDF(理解原理) Setup Assistant 自动生成 SRDF,但理解它才能改它。SRDF(Semantic Robot Description Format)告诉 MoveIt2 “哪些 link 组成了一个可规划的组”。核心三块: ```xml ``` - **group**:规划的最小单位,MoveIt2 对整个组做运动学计算。 - **group_state**:预设姿态,测试时一键回到某姿态。 - **disable_collisions**:相邻连杆必选(它们本来就固定相连),不相邻的靠 Setup Assistant 自动算。 ### 六、示例 3:kinematics.yaml 与逆解求解器 #### 6.1 逆解(IK)是什么 - **正解 FK**:给定关节角度 → 求末端位置(唯一,好算)。 - **逆解 IK**:给定末端目标位姿 → 求关节角度(可能无解/多解,难)。 MoveIt2 用运动学插件做 IK。`kinematics.yaml` 声明用哪个插件: ```yaml # kinematics.yaml:声明 IK 求解器 arm_group: kinematics_solver: kdl_kinematics_plugin/KDLKinematicsPlugin kinematics_solver_search_resolution: 0.005 kinematics_solver_timeout: 0.005 kinematics_solver_attempts: 3 ``` 常用求解器对比: | 求解器 | 特点 | 适用 | | --- | --- | --- | | KDL(默认) | 通用数值解,够用 | 大多数机械臂 | | TRAC-IK | KDL 加强版,成功率更高 | 复杂构型 / 奇异位形 | | IKFast | 解析解,快且准 | 6 自由度且运动学结构简单 | #### 6.2 手写测试 IK ```bash # 用 moveit 的 IK 命令行工具测试(需先启动 demo) ros2 launch arm_moveit_config demo.launch.py # 在另一个终端调用 IK 服务(服务名随配置可能不同) ros2 service list | grep compute_ik ``` 规划失败先怀疑 IK:把末端目标挪到工作空间内(离基座 0.3~0.5m、不越过肩关节正上方),再试。 ### 七、MoveIt2 与 ros2_control 的对接(真机准备) 第 28 篇讲过 ros2_control。MoveIt2 规划出的轨迹要执行,必须通过 ros2_control 发给关节控制器。对接的"合同"就是 `moveit_controllers.yaml`: ```yaml # moveit_controllers.yaml:MoveIt2 到 ros2_control 的映射 moveit_simple_controller_manager: controller_names: - arm_controller arm_controller: type: FollowJointTrajectory action_ns: follow_joint_trajectory default: true joints: - shoulder_joint - elbow_joint - wrist_joint ``` 配合 ros2_control 端的控制器配置(第 28 篇的 joint_trajectory_controller): ```yaml # arm_controllers.yaml(ros2_control 侧) controller_manager: ros__parameters: update_rate: 100 arm_controller: type: joint_trajectory_controller/JointTrajectoryController arm_controller: ros__parameters: joints: - shoulder_joint - elbow_joint - wrist_joint command_interfaces: [position] state_interfaces: [position] ``` **职责划分**:MoveIt2 负责"想清楚怎么动"(规划),ros2_control 负责"真去动"(发指令给电机)。两者通过 Action 通信:MoveIt2 发 `FollowJointTrajectory` 目标,控制器执行后回报结果。真机篇(第 40 篇)会完整展开。 ### 八、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 安装后 ros2 pkg list 查不到 moveit | 没 source /opt/ros/humble/setup.bash | source 后再查 | | demo.launch.py 报包找不到 | 没 build / 没 source 工作空间 | colcon build + source install/setup.bash | | RViz 无机械臂模型 | robot_description 没发布 | 检查 robot_state_publisher 是否启动 | | 关节乱扭 | URDF 的 axis 写错 | 逐个核对 joint 的 axis 与物理轴一致 | | 逆解无解 | 目标超出工作空间 | 目标移到工作空间内(半径 = 臂长总和) | | 规划失败但没报错 | 目标与自碰撞冲突 | 查看 Planning Scene,检查初始姿态 | | Setup Assistant 打不开 | 缺 GUI 库 / 显示问题 | 确认有桌面环境;检查 DISPLAY 变量 | | 生成的包 demo 报控制器错 | 控制器 YAML 未生成/未 source | 检查 moveit_controllers.yaml 是否被加载 | ### 九、小结与下一篇预告 **本篇核心**: 1. MoveIt2 是"规划框架"不是单个程序:Move Group 中枢 + OMPL 规划 + KDL 逆解 + FCL 碰撞检测 + RViz 可视化。 2. 机械臂 URDF 与移动底盘不同:revolute 关节 + 限位 + 末端执行器,用 Xacro 宏参数化。 3. Setup Assistant 一键生成配置包(SRDF / kinematics / controllers),理解 SRDF 才能手改。 4. MoveIt2 规划、ros2_control 执行,两者靠 Action 对接——真机控制的地基。 至此,MoveIt2 的"地基"已打好:环境装好、模型建好、配置包生成、可视化验证通过。 下一篇(第 36 篇)**「MoveIt2 仿真与运动规划(基础)」**:深入 demo 启动流程,用 MoveIt2 的 Python/C++ 接口写第一个运动规划程序,理解 move_group 的 API(设置目标、规划、执行、轨迹可视化),并动手控制仿真机械臂做点到点运动与笛卡尔路径运动。 --- ## ROS2系列教程:上位机建图与导航(原理综合) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-shang-wei-ji-jian-tu-yu-dao-hang/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 33 篇 > 本文是 ROS2 系列教程的第 33 篇:上位机建图与导航(原理综合)。第 32 篇我们把下位机的数据(里程计、IMU、传感器)接进了 ROS2,实现了"软硬结合"。但机器人拿到数据之后要干什么?答案就是本篇的主题:**建图与导航**。本篇把第 21~25 篇的仿真知识搬到真机场景,讲解仿真到实物的差异、激光雷达接入、slam_toolbox/Cartographer 建图、地图后处理、AMCL 定位、Nav2 导航栈的参数调优,以及用 Python 写任务级导航程序。学完本篇,你的实体机器人就真正"认得路、走得动"了。 ### 一、从仿真到实物的关键差异 很多同学在 Gazebo 里跑建图导航一切正常,一上真机就各种翻车。先讲清楚差异,后面调试才有方向。 #### 1.1 传感器噪声不同 仿真里激光雷达的数据是"理想"的:测距准、无混叠、无跳变。真机激光雷达(如 RPLIDAR A1/A2)会有: - **测量噪声**:同一角度连续几帧距离有小幅抖动,约 ±1~2cm。 - **混叠(aliasing)**:近距离物体被漏掉或测出"伪距离"。 - **黑洞/鬼点**:阳光直射、玻璃、黑色哑光物体上偶尔丢点。 应对办法:建图前先看原始 scan 话题,确认点云干净;地图分辨率设 0.05m 而不是 0.025m,容忍小噪声。 #### 1.2 里程计误差放大 仿真的 /odom 精确到毫米级,真机轮式里程计受打滑、胎压、地面摩擦影响,误差会累积。第 32 篇讲过,**里程计误差直接决定建图质量**。真机建图前先做"直线校准": ```bash # 让车以 0.2m/s 直行 2 米,看 odom 的 x 是否等于 2.0 ros2 topic echo /odom --field pose.pose.position.x ``` 若偏差大于 5%,优先检查:轮半径是否按实测值填、编码器 PPR 是否写对、轮距是否与 URDF 一致(三个参数,第 28 篇的 diff_drive_controller 配置里都有)。 #### 1.3 时间同步 仿真的 TF 时间戳永远正确,真机却有:传感器驱动时间戳、主控时钟漂移、USB 延迟。后果就是 TF 报错(`lookup would require extrapolation`),cartographer 直接罢工。 **统一时间源**是第一步:用一台主机同时跑激光雷达驱动和 Nav2,主机开 NTP 或 chrony 校时;串口桥发布 odom 时用 `self.get_clock().now().to_msg()` 而不是 `time.time()`(两者差一个 epoch,会导致 TF 时间错乱)。 ### 二、建图前准备:激光雷达驱动与 TF 检查 #### 2.1 安装激光雷达驱动 以 RPLIDAR 为例(其他品牌同理,驱动包名不同): ```bash sudo apt install ros-humble-rplidar-ros ros2 run rplidar_ros rplidar_composition --ros-args \ -p serial_port:=/dev/ttyUSB0 -p serial_baudrate:=115200 \ -p frame_id:=laser_link -p angle_compensate:=true ``` 要点:`frame_id` 必须与 URDF 里的激光雷达坐标系名一致;`angle_compensate` 打开可缓解高速旋转时的角度偏差。启动后立刻检查: ```bash ros2 topic hz /scan # 期望 5~10Hz ros2 topic echo /scan --once | head -30 ``` 如果 /scan 不出数据:先 `ls -l /dev/ttyUSB0` 看权限,`sudo usermod -aG dialout $USER` 加组;再 `dmesg | grep -i usb` 看是否被识别。 #### 2.2 TF 树完整性检查 建图前必须确认 TF 树完整:`map → odom → base_link → laser_link`。一条命令查: ```bash ros2 run tf2_ros tf2_echo base_link laser_link ros2 run tf2_ros tf2_echo odom base_link ``` 真机最常见的坑:**odom 和 base_link 之间没有 TF**。仿真里 robot_state_publisher 会帮你算,真机 odom 必须由里程计节点发布(第 32 篇的 odom_bridge.py 里 `transform_broadcaster` 那段)。用 RViz 的 TF 面板可视化检查最直观: ```bash ros2 launch rviz2 rviz2.launch.py # 添加 TF display,展开看有没有红色/黄色断链 ``` #### 2.3 建图前的"热身"流程 正式建图前先做三件事,能省大量返工: 1. **手动遥控一圈**:确认能前进/后退/左右转,最小转弯半径在合理范围(差速车理论上可原地转)。 2. **检查 /odom 与 /scan 同步**:`ros2 run tf2_ros tf2_monitor` 看 odom→laser_link 的延迟,应在几十毫秒内。 3. **设定建图区域**:真机建图速度远慢于仿真(走路速度 vs 贴地飞),选 20~50㎡ 的房间,先规划好行走路线:绕墙一圈 → 中部"8"字 → 重点区域补扫。 ### 三、示例 1:slam_toolbox 在线建图(真机协议对接) #### 3.1 为什么选 slam_toolbox 第 21 篇讲过 slam_toolbox 是 2D 激光 SLAM 的"默认选择":配置少、上手快、有回环检测。真机场景它依然是首选,尤其是小户型。启动参数与仿真略有差别(真机里程计噪声大,要降低对里程计的信任): ```yaml # slam_toolbox_online.yaml(真机调参版) slam_toolbox: ros__parameters: odom_frame: odom map_frame: map base_frame: base_link scan_topic: /scan use_scan_matching: true use_scan_barycenter: true minimum_travel_distance: 0.1 # 移动超过 0.1m 才做匹配 minimum_travel_heading: 0.05 # 转角超过 0.05rad 才做匹配 loop_match_minimum_chain_size: 10 loop_search_maximum_distance: 3.0 loop_match_maximum_variance_coarse: 3.0 loop_match_maximum_variance_fine: 0.5 transform_publish_period: 0.02 map_update_interval: 2.0 # 地图更新间隔(秒),真机可放宽 max_laser_range: 12.0 minimum_time_interval: 0.25 resolution: 0.05 # 真机用 0.05m 分辨率 ``` #### 3.2 一键建图 launch 文件 把驱动、里程计桥、slam_toolbox、RViz 打包进一个 launch,避免手动开一堆终端: ```python # mapping.launch.py:真机一键建图 from launch import LaunchDescription from launch_ros.actions import Node from launch.actions import ExecuteProcess def generate_launch_description(): return LaunchDescription([ # 1. 激光雷达驱动(以 rplidar 为例) Node(package='rplidar_ros', executable='rplidar_composition', parameters=[{'serial_port': '/dev/ttyUSB0', 'serial_baudrate': 115200, 'frame_id': 'laser_link'}], output='screen'), # 2. 串口桥:odom + IMU + 传感器(第 32 篇的节点) Node(package='robot_bringup', executable='serial_bridge', parameters=[{'port': '/dev/ttyUSB1', 'baudrate': 115200}], output='screen'), # 3. slam_toolbox 在线建图 Node(package='slam_toolbox', executable='async_slam_toolbox_node', name='slam_toolbox', parameters=['slam_toolbox_online.yaml'], output='screen'), # 4. RViz 可视化 Node(package='rviz2', executable='rviz2', arguments=['-d', 'src/robot_bringup/rviz/mapping.rviz'], output='screen'), ]) ``` 启动: ```bash ros2 launch robot_bringup mapping.launch.py ``` #### 3.3 遥控建图与保存 启动后用键盘遥控(teleop_twist_keyboard 或手柄)绕场一周。建图质量看 RViz:**地图边界是否闭合、走廊是否笔直、拐角是否成 90°**。出现"地图重影"说明回环没闭环,回到重影起点原地转一圈强制回环。 建图完成保存地图: ```bash ros2 run nav2_map_server map_saver_cli -f ~/maps/room1 # 生成 room1.pgm(图像)和 room1.yaml(元数据) ``` 保存前看一眼地图文件:PGM 是黑白图,黑=障碍物,白=自由空间,灰=未知。如果整体偏灰,说明激光没扫到太多东西(地图覆盖率低),补扫再存。 ### 四、示例 2:Cartographer 建图对比与选择 #### 4.1 Cartographer 的优势与代价 第 22 篇讲过 Cartographer 用子图(submap)+ 回环优化,精度上限更高,尤其适合**大场景、回环多的环境**。但代价是:CPU 占用高(真机主控弱的话会卡)、参数多(lua 配置文件)、失败时难排查。 真机选型建议: | 场景 | 推荐 | 理由 | | --- | --- | --- | | 小房间(<50㎡) | slam_toolbox | 快、稳、配置少 | | 大场景/回环多 | Cartographer | 全局一致性更好 | | 主控是树莓派 4B 及以下 | slam_toolbox | CPU 占用低 | | 室内外混合 | Cartographer | 对里程计误差容忍度高 | #### 4.2 Cartographer 真机配置要点 Cartographer 的 ROS2 版(cartographer_ros)配置在 lua 文件里。真机相比仿真重点改三处: ```lua -- cartographer_robot.lua include "map_builder.lua" include "trajectory_builder.lua" options = { map_builder = MAP_BUILDER, trajectory_builder = TRAJECTORY_BUILDER, map_frame = "map", tracking_frame = "base_link", published_frame = "odom", odom_frame = "odom", provide_odom_frame = true, -- 真机必须 true:由 odom 话题提供 use_odometry = true, -- 真机必须 true:融合里程计 num_laser_scans = 1, num_subdivisions_per_laser_scan = 1, -- 真机里程计噪声大,降低 odometry 权重 TRAJECTORY_BUILDER_2D.odometry_traslation_weight = 1e1, -- 仿真常用 1e2 TRAJECTORY_BUILDER_2D.odometry_rotation_weight = 1e1, -- 仿真常用 1e2 } return options ``` 关键点:`use_odometry = true` 且 `provide_odom_frame = true`,Cartographer 才会融合第 32 篇发布的 /odom。真机里程计不可靠时把 odometry 权重从 1e2 降到 1e1,让激光匹配主导。 #### 4.3 保存与对比 Cartographer 建图完成后同样用 map_saver 保存: ```bash ros2 run nav2_map_server map_saver_cli -f ~/maps/room1_carto ``` 对比 slam_toolbox 的结果:哪个地图的**墙更直、房间比例更接近真实尺寸**就用哪个。不要迷信"越高级越好"——地图能用于后续导航定位才是唯一标准。 ### 五、地图后处理:从"能看"到"能用" 刚存的地图往往有噪点、孤立障碍物、未闭合区域,直接给 AMCL 用会导致定位跳变。后处理三步: #### 5.1 用图像工具修剪(GIMP/画图) PGM 可以直接用 GIMP 打开编辑(注意 GIMP 打开时选"原始数据"格式,灰度值 0-255): - 橡皮擦掉地图边缘的"雪花点"(未建图区域的噪声)。 - 把房间外的杂点涂成黑色(障碍)或灰色(未知)。 - 保持**障碍物边界 1~2 像素**的过渡带,不要用纯黑白硬切。 #### 5.2 用 map_editor 或脚本清理 也可以用 Python + OpenCV 批量清理孤立点: ```python # clean_map.py:清理 PGM 中的孤立噪点 import cv2 import numpy as np img = cv2.imread('room1.pgm', cv2.IMREAD_GRAYSCALE) # 二值化:<205 视为障碍(黑色) mask = img < 205 # 形态学开运算:先腐蚀后膨胀,去掉孤立小点 kernel = np.ones((3, 3), np.uint8) cleaned = cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel, iterations=1) cv2.imwrite('room1_clean.pgm', cleaned) print('清洗完成:', img.shape) ``` 注意:开运算会同时削掉细小的真实障碍(如桌腿),桌腿场景慎用,改用"连通域面积过滤"更精准。 #### 5.3 修改 yaml 元数据 ```yaml # room1_clean.yaml image: room1_clean.pgm resolution: 0.05 origin: [-10.0, -10.0, 0.0] negate: 0 occupied_thresh: 0.65 free_thresh: 0.196 ``` - `resolution`:必须与建图时一致(0.05)。 - `origin`:地图左下角在 map 系的坐标,改图后**位置别动**,否则定位全部错位。 - `occupied_thresh/free_thresh`:调大 occupied 可把灰色区域当障碍,适合"宁可撞墙不可穿墙"的保守策略。 ### 六、示例 3:AMCL 定位与 Nav2 导航启动 #### 6.1 定位为什么必须用 AMCL 建图给了"地图",但机器人在地图哪里?AMCL(自适应蒙特卡洛定位)用粒子滤波回答这个问题:撒一大把粒子 → 用激光匹配打分 → 收敛到真实位姿。真机启动 AMCL 的完整 launch: ```python # navigation.launch.py:AMCL + Nav2 完整导航栈 from launch import LaunchDescription from launch_ros.actions import Node from ament_index_python.packages import get_package_share_directory import os def generate_launch_description(): nav2_dir = get_package_share_directory('nav2_bringup') return LaunchDescription([ # 1. 地图服务:把保存的地图发布为 /map Node(package='nav2_map_server', executable='map_server', parameters=[{'yaml_filename': os.path.expanduser('~/maps/room1_clean.yaml')}], output='screen'), # 2. AMCL 定位 Node(package='nav2_amcl', executable='amcl', parameters=[os.path.join(nav2_dir, 'params', 'nav2_params.yaml')], output='screen'), # 3. 生命周期管理(Map/AMCL 是生命周期节点,需要激活) Node(package='nav2_lifecycle_manager', executable='lifecycle_manager', name='lifecycle_manager_localization', parameters=[{'autostart': True, 'node_names': ['map_server', 'amcl']}], output='screen'), # 4. Nav2 核心:规划器 + 控制器 + 代价地图 + 行为树 Node(package='nav2_planner', executable='planner_server', parameters=[os.path.join(nav2_dir, 'params', 'nav2_params.yaml')], output='screen'), Node(package='nav2_controller', executable='controller_server', parameters=[os.path.join(nav2_dir, 'params', 'nav2_params.yaml')], output='screen'), Node(package='nav2_costmap_2d', executable='nav2_costmap_2d', parameters=[os.path.join(nav2_dir, 'params', 'nav2_params.yaml')], output='screen'), Node(package='nav2_bt_navigator', executable='bt_navigator', parameters=[os.path.join(nav2_dir, 'params', 'nav2_params.yaml')], output='screen'), # 5. Nav2 生命周期管理 Node(package='nav2_lifecycle_manager', executable='lifecycle_manager', name='lifecycle_manager_navigation', parameters=[{'autostart': True, 'node_names': ['planner_server', 'controller_server', 'costmap_global', 'costmap_local', 'bt_navigator']}], output='screen'), ]) ``` 启动: ```bash ros2 launch robot_bringup navigation.launch.py ``` #### 6.2 初始位姿设置 AMCL 不知道车在哪,需要"喂"一个初始位姿。两种方式: **方式一:RViz 手动设置** 点击 RViz 工具栏的 “2D Pose Estimate”,在地图上点一下车的大概位置,拖出朝向箭头。观察粒子云:若 3 秒内粒子收敛成一团 → 成功;若粒子散开不收敛 → 初始位姿给错了,重设。 **方式二:launch 里直接给(适合已知起始点)** ```yaml # nav2_params.yaml 中 amcl 部分 amcl: ros__parameters: initial_pose: x: 0.0 y: 0.0 yaw: 0.0 initial_pose_known: true # 告诉 AMCL 初始位姿可信 ``` #### 6.3 用 Nav2 发目标点 定位收敛后,RViz 点 “2D Goal Pose” 给一个目标点,机器人应当规划路径并走过去。三个验收点: 1. **路径合理**:绿色路径不走"斜穿障碍"的诡异路线。 2. **速度平稳**:起步/拐弯/刹车不抽搐(看 /cmd_vel 输出频率稳定)。 3. **到点停稳**:到达后 /cmd_vel 归零,状态话题报 `NavigationSuccess`。 ### 七、Nav2 参数调优实战 真机跑 Nav2 最常见的三个问题:**路径贴墙太近、速度忽快忽慢、转弯原地打转**。逐一解决。 #### 7.1 代价地图参数(贴墙问题) ```yaml # nav2_params.yaml 中 costmap 部分 global_costmap: global_costmap: robot_radius: 0.22 # 与 URDF 的 footprint 一致(车轮外沿) inflation_radius: 0.35 # 障碍膨胀半径,真机建议 ≥ 车宽/2 cost_scaling_factor: 3.0 # 越大路径越贴墙,真机调小到 2.0~3.0 static_layer: map_subscribe_transient_local: true local_costmap: local_costmap: robot_radius: 0.22 inflation_radius: 0.30 cost_scaling_factor: 3.0 observation_sources: scan scan: topic: /scan max_obstacle_height: 0.5 min_obstacle_height: 0.0 ``` 真机路径"贴墙"是**膨胀半径太小**导致的:车宽 44cm,膨胀只有 20cm,路径规划出来离墙 20cm,实际车宽一半就 22cm,必蹭墙。把 inflation_radius 设到 0.35(车宽/2 + 余量)就安全了。 #### 7.2 控制器参数(速度抖动问题) ```yaml # DWB 控制器(真机推荐用 DWB 而不是 MPPI,参数少、稳定) controller_server: ros__parameters: controller_plugins: ["FollowPath"] FollowPath: plugin: "dwb_core::DWBLocalPlanner" min_vel_x: 0.0 max_vel_x: 0.5 # 真机限速:室内 0.5m/s 足够 min_vel_theta: 0.0 max_vel_theta: 1.0 # 角速度上限(rad/s),别给 2.0 以上 acc_lim_x: 0.5 # 加速度限制,越小越平稳 acc_lim_theta: 0.8 xy_goal_tolerance: 0.1 # 到点容差,真机 0.1~0.15 合理 yaw_goal_tolerance: 0.2 ``` 速度忽快忽慢的根源是**加速度限制太小 + 目标容差太小**:车为了"精确到点"不停修正,看起来就抽搐。真机把 xy_goal_tolerance 放到 0.1m,yaw 容差 0.2rad,车到附近就"承认到位",稳定很多。 #### 7.3 转弯打转问题 差速车转弯打转(原地绕圈)通常是两个原因: 1. **最小转弯半径被规划器忽略**:真机在窄通道里,全局路径要求"原地转向",DWB 频繁切换方向导致振荡。解法:降低 max_vel_theta 到 1.0 以下,并加大 acc_lim_theta。 2. **里程计 yaw 漂移**:第 32 篇说过,融合 IMU 的 yaw 能根治。确认 ekf 配置里 `use_imu: true`,且 IMU 话题有数据(`ros2 topic hz /imu/data`)。 #### 7.4 行为树与重试策略 Nav2 默认行为树是"一条路走到黑":规划失败就报错。真机环境动态变化多,改用带重试的树: ```yaml # nav2_params.yaml bt_navigator: ros__parameters: default_bt_xml_filename: "navigate_to_pose_w_replanning_and_recovery.xml" ``` 这个树会在路径失效时**重新规划**,连续失败才进入恢复行为(旋转 360° 重新定位附近障碍),适合真机。 ### 八、示例 4:任务级导航(Python 导航客户端) 导航栈跑起来后,写程序让机器人**自动完成"去 A 点 → 去 B 点 → 回充电桩"**的任务链。用 nav2_simple_commander 封装: ```python # task_navigation.py:任务级导航(Python) import rclpy from geometry_msgs.msg import PoseStamped from nav2_simple_commander.robot_navigator import BasicNavigator, TaskResult def make_pose(navigator, x, y, yaw=0.0): """构造目标位姿""" pose = PoseStamped() pose.header.frame_id = 'map' pose.header.stamp = navigator.get_clock().now().to_msg() pose.pose.position.x = x pose.pose.position.y = y pose.pose.orientation.z = float(__import__('math').sin(yaw / 2.0)) pose.pose.orientation.w = float(__import__('math').cos(yaw / 2.0)) return pose def main(): rclpy.init() navigator = BasicNavigator() # 依次去三个点(坐标为地图坐标系下的实际位置) waypoints = [make_pose(navigator, 1.0, 1.0, 0.0), make_pose(navigator, 2.0, 2.5, 1.57), make_pose(navigator, 0.0, 0.0, 3.14)] for i, wp in enumerate(waypoints): print(f'=== 前往第 {i+1} 个目标点 ===') navigator.goToPose(wp) while not navigator.isTaskComplete(): feedback = navigator.getFeedback() if feedback: print(f'剩余距离: {feedback.distance_remaining:.2f} m') result = navigator.getResult() if result == TaskResult.SUCCEEDED: print(f'第 {i+1} 点到达') elif result == TaskResult.CANCELED: print('任务被取消') break else: print(f'第 {i+1} 点导航失败,继续下一个') navigator.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 运行: ```bash ros2 run robot_tasks task_navigation.py ``` 注意点: - `frame_id` 必须是 `map`,且要在 AMCL 定位收敛后再跑(粒子未收敛就发目标,导航大概率失败)。 - 反馈轮询用 `isTaskComplete()` 而非固定 sleep,避免阻塞回调。 - 失败不中断(示例里"继续下一个"),真机任务链更稳健;重要点位可加"失败重试 2 次"逻辑。 ### 九、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 建图时地图乱跳 | 里程计不准 / TF 时间不同步 | 校准轮径、轮距;统一时间源;查 tf2_monitor | | /scan 无数据 | 串口权限 / 驱动没起来 | ls -l /dev/ttyUSB*;sudo usermod -aG dialout $USER | | AMCL 粒子不收敛 | 初始位姿给错 / 地图分辨率不一致 | 重设初始位姿;检查 yaml 的 resolution | | 导航路径贴墙 | 膨胀半径太小 | inflation_radius 加大到车宽/2 + 余量 | | 到点后原地抽搐 | 目标容差太小 / 加速度太猛 | xy_goal_tolerance=0.1,acc_lim_x 调小 | | 转弯打转 | 角速度限幅不足 / IMU 没接入 | max_vel_theta ≤ 1.0;确认 ekf use_imu=true | | 导航报 TF extrapolation | 里程计时间戳用错 | 用 get_clock().now() 发 odom 时间戳 | | 地图保存后定位全错 | 改图动了 origin | 后处理只动像素,别动 yaml 的 origin | ### 十、小结与下一篇预告 **本篇核心**: 1. 仿真到实物的三大差异:传感器噪声、里程计误差、时间同步,一切调参围绕这三点展开。 2. 建图选型:小房间用 slam_toolbox,大场景回环多用 Cartographer;地图要后处理(去噪、修边、验元数据)。 3. 导航三板斧:AMCL 定位收敛 → Nav2 规划导航 → 参数调优(膨胀半径、速度限制、行为树重试)。 4. 任务级导航用 nav2_simple_commander,把"点对点"升级为"多目标任务链"。 至此,实体机器人已经能**建图、定位、自主导航**。第 31~33 篇分别完成了硬件搭建、软硬通信、建图导航三件大事。下一篇(第 34 篇)是实体机器人阶段的收官篇:**完整实体机器人项目实战(软硬协同)**,把底盘、通信、建图、导航、传感器全部整合成一个可复用的项目模板,并给出完整的测试与验收流程。 --- ## ROS2系列教程:完整实体机器人项目实战(软硬协同) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-wan-zheng-shi-ti-ji-qi-ren-xiang-mu/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 34 篇 > 本文是 ROS2 系列教程的第 34 篇:完整实体机器人项目实战(软硬协同)。第 31~33 篇分别完成了硬件搭建、软硬通信、建图导航三件大事。本篇把底盘驱动、串口协议、传感器、里程计、建图、导航、行为任务**全部整合成一个可复用的项目模板**,并给出从零到一的分步集成顺序、launch 组织方式、完整的测试与验收清单。学完本篇,你拥有一个"开箱即用"的实体机器人工程,也可以直接作为毕业设计或竞赛项目的底座。 ### 一、项目总览:一个能跑的完整工程长什么样 #### 1.1 项目结构 规范的项目结构是"软硬协同"的地基。推荐用标准 ROS2 工作空间组织: ```text robot_ws/ ├── src/ │ ├── robot_bringup/ # 总启动包:launch + 参数 + RViz 配置 │ │ ├── launch/ │ │ │ ├── robot.launch.py # 一键启动全部 │ │ │ ├── mapping.launch.py # 建图模式 │ │ │ └── navigation.launch.py# 导航模式 │ │ ├── config/ │ │ │ ├── robot.yaml # 机器人描述参数(轮距/轮径/编码器) │ │ │ └── nav2_params.yaml # Nav2 全套参数 │ │ ├── urdf/ │ │ │ └── robot.urdf.xacro # 统一机器人模型 │ │ ├── rviz/ │ │ │ ├── mapping.rviz │ │ │ └── navigation.rviz │ │ ├── package.xml │ │ └── CMakeLists.txt │ ├── robot_description/ # 模型描述(URDF/Xacro/meshes) │ ├── robot_driver/ # 下位机驱动(串口桥 + 传感器聚合) │ │ └── robot_driver/ │ │ ├── serial_bridge.py # 串口收发(第 32 篇) │ │ ├── sensor_aggregator.py # 传感器汇聚(第 32 篇) │ │ ├── imu_node.py # IMU 发布(第 32 篇) │ │ └── battery_monitor.py # 电量监控 │ └── robot_tasks/ # 任务层(导航任务链等) │ └── robot_tasks/ │ ├── task_navigation.py # 多点导航(第 33 篇) │ └── task_mission.py # 综合任务调度 ``` 核心思想:**driver 负责"感知与执行",bringup 负责"组装",tasks 负责"行为"**,三层解耦,任何一层可单独替换。 #### 1.2 数据流总览(软硬协同的"电路图") ```text ┌─────────────────────────────────────────────────────────┐ │ 上位机 (ROS2) │ │ │ │ /cmd_vel ──► serial_bridge ──► 串口(115200) ──► STM32 │ │ ▲ │ │ │ │ ▼ │ │ /odom ◄───────┘ └──► /imu/data ──► robot_localization│ │ /scan (激光雷达) ──► slam_toolbox / AMCL │ │ /battery_voltage ──► battery_monitor ──► 急停话题 │ │ │ │ Nav2: map_server + AMCL + planner + controller + BT │ │ tasks: 多点导航 / 充电任务 / 巡航任务 │ └─────────────────────────────────────────────────────────┘ ``` 三条主线:**指令下行**(Nav2 → /cmd_vel → 串口 → 电机)、**状态上行**(编码器 → 里程计 → 定位/建图)、**传感旁路**(激光雷达/IMU/电量独立接入)。任何一条断了,机器人都会"看起来活着但走不动"。 ### 二、集成顺序:从"能开机"到"能跑任务" 软硬协同最忌讳"一次全上"——出问题根本没法定位。推荐按下面 6 步渐进集成,每步都有明确的验收标准。 #### 2.1 第一步:硬件自检(不启动 ROS) 目标:确认下位机、电机、编码器、传感器硬件本身没问题。 1. 上电后观察 STM32 指示灯:电源灯常亮、状态灯按 1Hz 闪烁。 2. 用串口调试助手(115200, 8N1)手动发指令:`AA 55 01 02 00 64 00`(前进 0.5m/s),听电机声、看轮子转。 3. 转动轮子,观察回传的编码器数据是否变化。 验收标准:能手动控制电机正反转、编码器读数随转动变化。 #### 2.2 第二步:串口链路打通(不启动 ROS) 目标:上位机与下位机的串口通信稳定。 ```bash # 先测裸串口(不经过 ROS) python3 -c " import serial, time s = serial.Serial('/dev/ttyUSB1', 115200, timeout=0.1) s.write(bytes.fromhex('AA 55 01 02 00 64 00')) time.sleep(0.2) print('recv:', s.read(32).hex()) " ``` 验收标准:能收到下位机 50Hz 回传的状态帧,且 CRC8 校验通过(自己写个 Python 校验脚本验证)。 #### 2.3 第三步:驱动节点跑通(启动 serial_bridge) 目标:/odom、/imu、/battery 话题有数据。 ```bash ros2 run robot_driver serial_bridge --ros-args -p port:=/dev/ttyUSB1 -p baudrate:=115200 ros2 topic hz /odom # 期望 50Hz ros2 topic hz /imu/data # 期望 50Hz ros2 topic echo /odom --once ``` 验收标准:三个话题频率稳定,/odom 的坐标随推车移动而变化。 #### 2.4 第四步:模型与 TF 对齐 目标:URDF 里的轮距/轮径与下位机里程计参数一致,TF 树完整。 ```bash ros2 launch robot_bringup robot.launch.py ros2 run tf2_ros tf2_echo base_link laser_link ros2 run tf2_ros tf2_echo odom base_link rviz2 # 打开 TF 面板看有没有断链 ``` **关键检查**:把车推 1 米,RViz 里车模也走约 1 米(误差 <5%);原地转 360°,RViz 里 yaw 也转约 360°。不符就回第 28 篇核对 diff_drive_controller 参数。 #### 2.5 第五步:建图与导航(第 33 篇内容) 目标:能建出可用地图,AMCL 定位收敛,Nav2 能导航到目标点。 ```bash ros2 launch robot_bringup mapping.launch.py # 建图 # 保存地图 → 后处理 → 修改 yaml ros2 launch robot_bringup navigation.launch.py # 导航 ``` 验收标准:建图无重影、定位 3 秒收敛、导航路径合理不贴墙。 #### 2.6 第六步:任务链打通 目标:程序自动完成"巡航 → 回充 → 待机"任务。 ```bash ros2 run robot_tasks task_navigation.py ``` 验收标准:连续 3 次任务循环无失败,断电重启后能恢复初始位姿继续执行。 ### 三、示例 1:一键启动总 launch(软硬协同的"总开关") 把驱动、传感器、生命周期管理全部编排进一个 launch,提供三种运行模式: ```python # robot.launch.py:一键总启动(带模式参数) from launch import LaunchDescription from launch.actions import DeclareLaunchArgument, ExecuteProcess from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node import os def generate_launch_description(): mode = LaunchConfiguration('mode', default='navigation') return LaunchDescription([ DeclareLaunchArgument('mode', default_value='navigation', description='navigation | mapping | manual'), # ===== 基础层:模型 + 驱动(所有模式都需要) ===== Node(package='robot_state_publisher', executable='robot_state_publisher', parameters=[{'robot_description': open( os.path.expanduser('~/robot_ws/src/robot_bringup/urdf/robot.urdf.xacro')).read()}], output='screen'), Node(package='robot_driver', executable='serial_bridge', parameters=[{'port': '/dev/ttyUSB1', 'baudrate': 115200}], output='screen'), Node(package='robot_driver', executable='sensor_aggregator', parameters=[{'low_battery_threshold': 10.8}], output='screen'), # ===== 模式分支 ===== # 模式由 Exec 动态选择,这里以 navigation 为例: Node(package='nav2_map_server', executable='map_server', parameters=[{'yaml_filename': os.path.expanduser('~/maps/room1_clean.yaml')}], output='screen'), Node(package='nav2_amcl', executable='amcl', parameters=['~/robot_ws/src/robot_bringup/config/nav2_params.yaml'], output='screen'), Node(package='nav2_lifecycle_manager', executable='lifecycle_manager', name='lifecycle_manager_localization', parameters=[{'autostart': True, 'node_names': ['map_server', 'amcl']}], output='screen'), Node(package='nav2_planner', executable='planner_server', parameters=['~/robot_ws/src/robot_bringup/config/nav2_params.yaml'], output='screen'), Node(package='nav2_controller', executable='controller_server', parameters=['~/robot_ws/src/robot_bringup/config/nav2_params.yaml'], output='screen'), Node(package='nav2_bt_navigator', executable='bt_navigator', parameters=['~/robot_ws/src/robot_bringup/config/nav2_params.yaml'], output='screen'), Node(package='nav2_lifecycle_manager', executable='lifecycle_manager', name='lifecycle_manager_navigation', parameters=[{'autostart': True, 'node_names': ['planner_server', 'controller_server', 'bt_navigator']}], output='screen'), ]) ``` 启动方式: ```bash ros2 launch robot_bringup robot.launch.py mode:=mapping # 建图模式 ros2 launch robot_bringup robot.launch.py mode:=navigation # 导航模式 ros2 launch robot_bringup robot.launch.py mode:=manual # 手动遥控 ``` 设计要点:**基础层常驻,模式层按需切换**——驱动和模型任何模式下都要跑,只有算法层(slam/amcl/nav2)随模式变化。这样切换模式不需要重启驱动,避免串口被反复占用。 ### 四、示例 2:安全机制(软硬协同的"保命系统") 实体机器人的安全比功能更重要。四个安全层缺一不可: #### 4.1 看门狗(上位机侧) 串口桥里检测下位机断连,超时急停: ```python # watchdog.py:上位机看门狗(在 serial_bridge 内集成) import rclpy from rclpy.node import Node from std_msgs.msg import Float32 class Watchdog(Node): def __init__(self): super().__init__('watchdog') self.last_heartbeat = self.get_clock().now() self.timeout = 0.5 # 500ms 无心跳视为掉线 self.timer = self.create_timer(0.1, self.check) self.pub_stop = self.create_publisher(Float32, 'robot_status/emergency_stop', 10) def heartbeat(self): # 由 serial_bridge 在收到帧时调用 self.last_heartbeat = self.get_clock().now() def check(self): if (self.get_clock().now() - self.last_heartbeat).nanoseconds > self.timeout * 1e9: self.get_logger().error('下位机掉线,发布急停!') msg = Float32(); msg.data = 1.0 self.pub_stop.publish(msg) ``` #### 4.2 低电量保护(下位机 + 上位机双保险) 下位机检测到电压低于阈值(如 10.8V),**直接硬件断电电机**(不依赖上位机);同时上位机 battery_monitor 收到低电量后发布急停话题并拒绝新导航任务。 #### 4.3 障碍物急停(激光雷达联动) 在 Nav2 的局部代价地图里把障碍物膨胀半径调大只是"软避障";更硬核的是在 controller 前加一个**安全过滤器**: ```python # safety_filter.py:订阅 /scan,距离过近直接覆盖 /cmd_vel 为 0 import rclpy from rclpy.node import Node from sensor_msgs.msg import LaserScan from geometry_msgs.msg import Twist class SafetyFilter(Node): def __init__(self): super().__init__('safety_filter') self.min_distance = 0.25 # 25cm 内必须停车 self.sub = self.create_subscription(LaserScan, '/scan', self.on_scan, 10) self.sub_cmd = self.create_subscription(Twist, '/cmd_vel_raw', self.on_cmd, 10) self.pub = self.create_publisher(Twist, '/cmd_vel', 10) # 最终输出 self.current_cmd = Twist() def on_cmd(self, msg): self.current_cmd = msg def on_scan(self, scan): # 只检查前方 ±60° 的障碍(避开侧面正常贴近) danger = False n = len(scan.ranges) for i in range(n): angle = scan.angle_min + i * scan.angle_increment if abs(angle) < 1.047: # 60° 内 if 0.05 < scan.ranges[i] < self.min_distance: danger = True break if danger: twist = Twist() # 全零 = 急停 self.pub.publish(twist) else: self.pub.publish(self.current_cmd) def main(): rclpy.init() node = SafetyFilter() rclpy.spin(node) ``` 要点:安全过滤器发布到**真正的 /cmd_vel**,Nav2 控制器发布到 `/cmd_vel_raw`。这样即使 Nav2 或任务层出错,安全层也能兜底。 #### 4.4 机械限位 软件安全之外,电机控制板要保留硬件限位:电机驱动器使能脚接紧急停止开关,按下直接断使能(下位机 GPIO 或继电器),不依赖任何软件。**软硬结合才是完整的安全**。 ### 五、示例 3:任务层架构(行为级软硬协同) 任务层把"点到点导航"升级为"完整行为": ```python # task_mission.py:综合任务调度(巡航 + 回充 + 待机) import rclpy from rclpy.node import Node from nav2_simple_commander.robot_navigator import BasicNavigator, TaskResult from geometry_msgs.msg import PoseStamped import math class Mission(Node): def __init__(self): super().__init__('mission') self.nav = BasicNavigator() # 巡逻点(真实地图坐标) self.patrol_points = [ self._pose(1.0, 1.0, 0.0), self._pose(2.0, 2.5, 1.57), self._pose(1.5, 3.5, 3.14), ] self.charging_station = self._pose(0.0, 0.0, 0.0) self.battery = 100.0 self.timer = self.create_timer(0.5, self.loop) def _pose(self, x, y, yaw): p = PoseStamped() p.header.frame_id = 'map' p.header.stamp = self.nav.get_clock().now().to_msg() p.pose.position.x = x; p.pose.position.y = y p.pose.orientation.z = math.sin(yaw/2); p.pose.orientation.w = math.cos(yaw/2) return p def loop(self): if self.battery < 20.0: self.get_logger().info('电量低,去充电') self._go(self.charging_station) else: for pt in self.patrol_points: self._go(pt) def _go(self, pose): self.nav.goToPose(pose) while not self.nav.isTaskComplete(): rclpy.spin_once(self, timeout_sec=0.1) if self.nav.getResult() != TaskResult.SUCCEEDED: self.get_logger().warn('导航失败,重试一次') self.nav.goToPose(pose) while not self.nav.isTaskComplete(): rclpy.spin_once(self, timeout_sec=0.1) def main(): rclpy.init() node = Mission() rclpy.spin(node) rclpy.shutdown() if __name__ == '__main__': main() ``` 任务层设计原则: 1. **状态机驱动**而不是"一段到底"的顺序代码:待机 → 巡航 → 低电回充 → 充电完成 → 继续巡航。 2. **失败重试**:单点失败重试 1 次,连续失败才报警(避免任务卡死)。 3. **电量是最高优先级**:任何时候电量低于阈值,中断当前任务回充。 ### 六、示例 4:系统监控与诊断(软硬协同的"体检报告") 实体机器人要能"自报家门"——出问题能快速定位: #### 6.1 监控脚本 ```python # robot_diagnose.py:一键体检 import subprocess checks = [ ('/odom 频率', 'ros2 topic hz /odom --window 10'), ('/scan 频率', 'ros2 topic hz /scan --window 10'), ('TF odom→base_link', 'ros2 run tf2_ros tf2_echo odom base_link'), ('TF base_link→laser', 'ros2 run tf2_ros tf2_echo base_link laser_link'), ('节点列表', 'ros2 node list'), ('串口设备', 'ls -l /dev/ttyUSB*'), ] for name, cmd in checks: print(f'===== {name} =====') try: out = subprocess.run(cmd.split(), capture_output=True, text=True, timeout=15) print(out.stdout[-500:] or out.stderr[-500:]) except Exception as e: print(f'[FAIL] {e}') ``` #### 6.2 日志与 bag 录制 ```bash # 录包:出问题能回放定位(录 /odom /scan /tf /cmd_vel 即可) ros2 bag record /odom /scan /tf /tf_static /cmd_vel -o ~/bags/run_$(date +%m%d_%H%M) # 看日志 ros2 launch robot_bringup robot.launch.py 2>&1 | tee ~/logs/robot_$(date +%m%d).log ``` #### 6.3 常见故障快速定位表 | 现象 | 第一步检查 | 常见根因 | | --- | --- | --- | | 电机不动 | 看 /cmd_vel 有没有输出 | 导航没发指令 / 安全过滤器急停 | | 电机抖动 | 听声音+看 /odom | 编码器接反 / PID 参数不对 | | 建图漂移 | 看 /odom 与真值差 | 轮径/轮距不准 / 打滑 | | 定位跳变 | 看 AMCL 粒子分布 | 地图脏 / 初始位姿错 | | 掉线急停 | 看串口日志 | 线松 / 电磁干扰 / 波特率错 | | 导航绕路 | 看全局路径 | 膨胀半径太大 / 地图未更新 | ### 七、完整验收清单(软硬协同的"结业考试") 按顺序逐项打勾,全部通过才算"项目完成": **基础项** - [ ] 一键启动(robot.launch.py)不报错,节点全部 ACTIVE - [ ] /odom、/imu、/scan 频率达标(50/50/10 Hz) - [ ] TF 树完整无断链(RViz 检查) - [ ] 推车 1m,里程计误差 <5%;原地转 360°,yaw 误差 <5° - [ ] 遥控模式能前进/后退/原地转向 **建图项** - [ ] 20~50㎡ 房间 10 分钟内建完,无重影 - [ ] 地图后处理完成(去噪 + 元数据正确) - [ ] 保存/加载地图成功 **导航项** - [ ] AMCL 初始定位 3 秒内收敛(粒子聚焦) - [ ] 任意目标点导航成功率 ≥ 90%(试 10 次) - [ ] 中途放障碍物能绕行或停车,不碰撞 - [ ] 到达目标后停稳,无原地抽搐 **安全项** - [ ] 下位机掉线 0.5s 内触发急停 - [ ] 电量低于阈值自动回充 - [ ] 25cm 内障碍物硬停车(拔掉 Nav2 也生效) - [ ] 物理急停开关按下立即断电 **任务项** - [ ] 巡航任务连续 3 轮无失败 - [ ] 低电回充 → 充电 → 继续巡航完整闭环 - [ ] 断电重启后能恢复任务(初始位姿重设后继续) ### 八、项目维护与演进 #### 8.1 代码版本管理 ```bash cd ~/robot_ws/src git init && git add -A && git commit -m "v1.0 实体机器人软硬协同初版" git tag v1.0 # 每次改动:小步提交 + 写 changelog,真机调试最容易"改回去" ``` #### 8.2 参数集中管理 把轮径、轮距、编码器 PPR、IMU 安装朝向、电池阈值等**所有可调参数**集中到一个 `robot.yaml`,不要在代码里散落魔法数字。换一台车(不同轮径),只改这一个文件。 #### 8.3 从"能跑"到"跑得好" 后续优化方向(按性价比排序): 1. **里程计标定**(robot_ocalib 等工具自动标定轮径/轮距,精度提升最明显)。 2. **IMU 融合调参**(EKF 协方差权重,第 32 篇已铺垫)。 3. **建图路径规划**(自动巡航建图替代手动遥控)。 4. **多传感器融合**(加 TOF 超声波补盲区,第 30 篇的传感器聚合器已预留接口)。 ### 九、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 一键启动报端口占用 | 上次没关干净 / udev 绑定错 | ps 查 serial_bridge 残留;用固定 udev 规则绑定 /dev/robot | | 节点启动但 topic 无数据 | 生命周期没激活 / 参数路径错 | 检查 lifecycle_manager autostart;用绝对路径给参数 | | 建图好导航差 | 定位与建图地图不一致 | 检查地图 resolution/origin;重新建图 | | 偶尔抽风一下 | 电磁干扰 / 电压波动 | 串口隔离模块 + 电机线远离 + 电容滤波 | | 电池没电关机 | 没有低电量保护 | 补 battery_monitor + 下位机硬件断电 | | 重启后 ROS 时间错乱 | 没有 NTP/chrony | 装 chrony 或手动 date -s 校时 | ### 十、小结与系列预告 **本篇核心**: 1. 三层架构:driver(感知执行)/ bringup(组装)/ tasks(行为),层层解耦、逐层可测。 2. 六步渐进集成:硬件自检 → 串口 → 驱动 → TF → 建图导航 → 任务链,每步有验收标准。 3. 四层安全:看门狗 + 低电量 + 障碍物急停 + 物理限位,软硬结合缺一不可。 4. 完整验收清单:基础/建图/导航/安全/任务五类逐项打分,是项目"完成"的客观标准。 至此,**实体机器人阶段(31~34)全部收官**!你从零搭出了一台能建图、能定位、能自主导航、能执行任务、有完整安全机制的真实机器人——这正是 ROS2 学习的"大满贯"。 下一篇(第 35 篇)开启系列最后一个大阶段:**MoveIt2 机械臂篇**。第 35 篇「MoveIt2 环境与机械臂建模准备」将带你从零配置 MoveIt2 环境、用 URDF 描述机械臂、生成 MoveIt2 配置包,为后续运动规划、运动学、碰撞避障与真机控制打基础。机械臂和移动底盘不同:移动机器人关心"轮子转多少",机械臂关心"关节动多少",坐标系与运动学思维将焕然一新。 --- ## ROS2系列教程:嵌入式控制与通信开发(原理+仿真) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-qian-ru-shi-kong-zhi-yu-tong-xin-kai/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 32 篇 > 本文是 ROS2 系列教程的第 32 篇:嵌入式控制与通信开发(原理+仿真)。第 31 篇我们搭好了实体机器人的硬件骨架。但硬件只是"尸体",让它"活过来"需要软件:下位机的控制与通信程序、上位机的桥接与状态发布。本篇把第 31 篇的框架落实成完整可运行的代码:下位机协议栈、里程计解算、上位机串口桥、传感器数据接入,并用仿真环境验证上/下位机通信的可靠性。这是实体机器人"软硬结合"的关键一篇。 ### 一、上下位机通信协议设计(原理) #### 1.1 协议设计原则 嵌入式串口通信最容易出问题,协议设计要遵循几条原则: 1. **有帧头帧尾**:接收方靠帧头定位一帧的起点。 2. **有长度字段**:明确一帧有多少字节,防止粘包/半包。 3. **有校验**:防止干扰导致的错误数据被执行。 4. **定长优先**:定长帧解析简单可靠,变长帧复杂易错。 #### 1.2 推荐帧格式 采用"帧头(2B) + 命令(1B) + 长度(1B) + 数据(NB) + 校验(1B)"的通用格式: ```text ┌──────┬──────┬──────┬──────┬──────────┬────────┐ │ 0xAA │ 0x55 │ CMD │ LEN │ DATA │ CRC8 │ │ 帧头1│ 帧头2│ 命令 │ 长度 │ 数据区 │ 校验 │ └──────┴──────┴──────┴──────┴──────────┴────────┘ ``` 命令定义(举例): | CMD | 方向 | 数据 | 说明 | | --- | --- | --- | --- | | 0x01 | 上位机→下位机 | float×2 (v, ω) | 设置目标速度 | | 0x02 | 下位机→上位机 | float×2 (v, ω) | 回传实测速度 | | 0x03 | 上位机→下位机 | uint8 (1/0) | 复位 / 使能电机 | | 0x04 | 下位机→上位机 | float×3 (ax, ay, az) | 回传 IMU 数据 | | 0x05 | 上位机→下位机 | uint8 (LED) | 控制指示灯 | #### 1.3 校验算法(CRC8) 用简单的 CRC8 或异或校验。异或最简单,CRC8 更可靠。CRC8 实现: ```c // crc8.c:CRC-8 校验(多项式 0x07,初值 0x00) uint8_t crc8(const uint8_t *data, uint16_t len) { uint8_t crc = 0x00; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x80) { crc = (uint8_t)((crc << 1) ^ 0x07); } else { crc <<= 1; } } } return crc; } ``` ### 二、下位机通信实现(STM32) #### 2.1 串口接收状态机 串口数据是"流"式的,一帧可能分多次到达,必须用**状态机**逐字节解析: ```c // protocol.c:串口接收状态机(STM32) #include "protocol.h" #include "crc8.h" typedef enum { ST_WAIT_HEAD1, // 等待帧头1 0xAA ST_WAIT_HEAD2, // 等待帧头2 0x55 ST_WAIT_CMD, // 等待命令字节 ST_WAIT_LEN, // 等待长度字节 ST_DATA, // 接收数据区 ST_CRC // 等待校验字节 } rx_state_t; static rx_state_t state = ST_WAIT_HEAD1; static protocol_frame_t frame; static uint8_t data_idx = 0; // 串口中断里逐字节喂给状态机 void protocol_rx_byte(uint8_t byte) { switch (state) { case ST_WAIT_HEAD1: if (byte == 0xAA) state = ST_WAIT_HEAD2; break; case ST_WAIT_HEAD2: state = (byte == 0x55) ? ST_WAIT_CMD : ST_WAIT_HEAD1; break; case ST_WAIT_CMD: frame.cmd = byte; state = ST_WAIT_LEN; break; case ST_WAIT_LEN: frame.len = byte; data_idx = 0; state = (byte > 0) ? ST_DATA : ST_CRC; break; case ST_DATA: frame.data[data_idx++] = byte; if (data_idx >= frame.len) state = ST_CRC; break; case ST_CRC: frame.crc = byte; // 校验:CRC = crc8(cmd, len, data) if (frame.crc == compute_frame_crc(&frame)) { protocol_on_frame(&frame); // 帧有效,回调处理 } state = ST_WAIT_HEAD1; break; } } ``` #### 2.2 串口中断接收(HAL) ```c // 串口接收中断回调(HAL 库,接收一个字节触发) void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { protocol_rx_byte(rx_buffer); // 喂给状态机 HAL_UART_Receive_IT(&huart1, &rx_buffer, 1); // 重新开启接收 } } ``` 要点:每次接收完必须重新调用 `HAL_UART_Receive_IT` 开启下一次接收(否则只收一字节)。 #### 2.3 帧处理回调 ```c // 收到有效帧后的处理 void protocol_on_frame(protocol_frame_t *f) { switch (f->cmd) { case CMD_SET_VEL: { // 设置速度:float×2 float v, w; memcpy(&v, &f->data[0], 4); memcpy(&w, &f->data[4], 4); target_v = v; target_w = w; break; } case CMD_ENABLE: { // 使能电机 motor_enable(f->data[0] != 0); break; } default: break; } } ``` ### 三、里程计解算(原理+实现) #### 3.1 里程计原理 里程计(Odometry)根据轮子转过的角度估算机器人位姿(x, y, yaw)。对两轮差速: ```text 左右轮速度:v_l, v_r(由编码器测得) 机器人线速度:v = (v_l + v_r) / 2 机器人角速度:ω = (v_r - v_l) / L (L=轮距) 位姿积分(每 dt 更新一次): x += v × cos(yaw) × dt y += v × sin(yaw) × dt yaw += ω × dt ``` #### 3.2 下位机里程计实现 ```c // odometry.c:两轮差速里程计解算(下位机) #include "odometry.h" #include // 机器人参数(与 URDF 一致!) #define WHEEL_RADIUS 0.0325f // 轮半径 m #define WHEEL_BASE 0.200f // 轮距 m static float pos_x = 0, pos_y = 0, yaw = 0; // 每 10ms 调用一次,输入左右轮角速度(rad/s) void odometry_update(float omega_left, float omega_right) { const float dt = 0.01f; // 左右轮线速度 float v_l = omega_left * WHEEL_RADIUS; float v_r = omega_right * WHEEL_RADIUS; // 机器人线速度与角速度 float v = (v_l + v_r) / 2.0f; float w = (v_r - v_l) / WHEEL_BASE; // 位姿积分 yaw += w * dt; // yaw 归一化到 [-π, π] if (yaw > M_PI) yaw -= 2 * M_PI; if (yaw < -M_PI) yaw += 2 * M_PI; pos_x += v * cosf(yaw) * dt; pos_y += v * sinf(yaw) * dt; } // 供上位机查询 void odometry_get(float *x, float *y, float *yaw_out) { *x = pos_x; *y = pos_y; *yaw_out = yaw; } ``` **关键点**:里程计参数(轮半径、轮距)必须与 URDF/控制器配置一致,否则建图和导航会"漂"。这就是为什么第 28 篇 diff_drive_controller 里要配 `wheel_radius` 和 `wheel_separation`——下位机用它算里程计,上位机用它算控制,两边必须统一。 #### 3.3 上位机里程计发布(完整版) 第 31 篇的串口桥只发了速度,这里补全为完整位姿: ```python # odom_bridge.py:完整里程计发布(位姿 + 协方差) import rclpy import serial import struct from rclpy.node import Node from nav_msgs.msg import Odometry from geometry_msgs.msg import Quaternion from std_msgs.msg import Header import math class OdomBridge(Node): def __init__(self): super().__init__('odom_bridge') self.ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=0.02) self.pub = self.create_publisher(Odometry, '/odom', 10) self.timer = self.create_timer(0.02, self.tick) # 50Hz def quat_from_yaw(self, yaw): # yaw → 四元数(ROS2 消息需要四元数) return Quaternion( x=0.0, y=0.0, z=math.sin(yaw / 2.0), w=math.cos(yaw / 2.0)) def tick(self): # 读下位机里程计帧(CMD=0x02:x, y, yaw 三个 float) buf = self.ser.read(18) if len(buf) != 18: return head, cmd = struct.unpack('>HB', buf[:3]) if head != 0xAA55 or cmd != 0x02: return x, y, yaw = struct.unpack('>3f', buf[3:15]) crc_ok = (buf[15] == (struct.calcsize('>HB3f') and 0)) # 简化为示例 odom = Odometry() odom.header = Header(stamp=self.get_clock().now().to_msg(), frame_id='odom') odom.child_frame_id = 'base_link' odom.pose.pose.position.x = x odom.pose.pose.position.y = y odom.pose.pose.orientation = self.quat_from_yaw(yaw) # 简化协方差(实际应按编码器噪声标定) odom.pose.covariance[0] = 0.001 odom.pose.covariance[7] = 0.001 odom.pose.covariance[35] = 0.01 self.pub.publish(odom) def main(args=None): rclpy.init(args=args) node = OdomBridge() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` ### 四、示例 1:仿真验证通信可靠性(gazebo + 真机协议) 在真机调试之前,先用仿真验证协议正确性。思路:在 Gazebo 仿真底盘上运行与真机**完全相同的协议逻辑**,验证协议解析无 bug。 ```python # fake_mcu.py:仿真下位机(模拟 STM32 的协议行为) # 在 Gazebo 仿真的 /odom 基础上,按协议格式通过虚拟串口回传 import rclpy import struct import math from rclpy.node import Node from nav_msgs.msg import Odometry class FakeMcu(Node): """仿真下位机:订阅仿真 /odom,按协议组帧(不接真实串口,仅演示协议正确性)""" def __init__(self): super().__init__('fake_mcu') self.odom_sub = self.create_subscription( Odometry, '/odom', self.on_odom, 10) def on_odom(self, msg): # 与真实下位机相同的组帧逻辑 yaw = 2 * math.atan2(msg.pose.pose.orientation.z, msg.pose.pose.orientation.w) data = struct.pack('>3f', msg.pose.pose.position.x, msg.pose.pose.position.y, yaw) frame = struct.pack('>HBB', 0xAA55, 0x02, len(data)) + data # 校验(与下位机 crc8 一致,此处用异或简化演示) crc = 0 for b in data: crc ^= b frame += bytes([crc]) # 真实工程:写入串口 self.ser.write(frame) self.get_logger().debug(f'frame: {frame.hex()}') def spin_check(self): pass def main(args=None): rclpy.init(args=args) node = FakeMcu() rclpy.spin(node) rclpy.shutdown() if __name__ == '__main__': main() ``` **验证方法**:在 Gazebo 里让车走一段已知路径,对比 `fake_mcu` 组帧数据与真机下位机输出格式是否一致。一致 → 协议可靠,可放心接真机。 ### 五、示例 2:IMU 数据接入与融合 #### 5.1 下位机回传 IMU ```c // 下位机:定时(50Hz)把 IMU 数据组帧回传 void send_imu_frame(void) { int16_t accel[3], gyro[3]; mpu6050_read(accel, gyro); uint8_t data[12]; // 转成 float 并打包 float ax = accel[0] / 16384.0f * 9.81f; float ay = accel[1] / 16384.0f * 9.81f; float az = accel[2] / 16384.0f * 9.81f; memcpy(&data[0], &ax, 4); memcpy(&data[4], &ay, 4); memcpy(&data[8], &az, 4); protocol_send(CMD_IMU, data, 12); // 组帧 + 串口发送 } ``` #### 5.2 上位机发布 IMU 并融合到里程计 EKF(扩展卡尔曼滤波)融合轮式里程计 + IMU 是标准做法。ROS2 用 `robot_localization` 包: ```yaml # ekf.yaml:robot_localization 配置(融合 odom + imu) ekf_filter_node: ros__parameters: frequency: 50.0 two_d_mode: true # 2D 机器人 publish_tf: true odom0: /odom # 轮式里程计(输入) odom0_config: [true, true, false, # x, y, z false, false, false, # roll, pitch, yaw false, false, false, # vx, vy, vz false, false, false, # wx, wy, wz false, false, false] imu0: /imu/data_raw # IMU(输入) imu0_config: [false, false, false, true, true, true, # roll, pitch, yaw 用 IMU false, false, false, false, false, false, false, false, false] imu0_remove_gravitational_acceleration: true odom0_differential: false ``` ```bash # 启动 EKF 融合 ros2 run robot_localization ekf_node --ros-args \ -r __node:=ekf_filter_node \ --params-file ekf.yaml # 融合后的位姿话题 ros2 topic echo /odometry/filtered ``` 融合效果:轮式里程计提供短期位置,IMU 提供姿态,长期位置不会因打滑而完全丢失。**注意**:IMU 提供 yaw 后,与轮式里程计的 yaw 要统一方向约定(都是逆时针为正)。 ### 六、示例 3:传感器数据汇聚节点 把下位机回传的所有传感器数据汇聚成 ROS2 话题,供上层使用: ```python # sensor_aggregator.py:传感器数据汇聚(超声波 + 电量 + 状态) import rclpy import serial import struct from rclpy.node import Node from sensor_msgs.msg import Range from std_msgs.msg import Float32, Bool class SensorAggregator(Node): def __init__(self): super().__init__('sensor_aggregator') self.ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=0.02) # 发布超声波距离(传感器消息标准格式) self.sonar_pub = self.create_publisher(Range, '/sonar_front', 10) # 发布电池电压 self.volt_pub = self.create_publisher(Float32, '/battery_voltage', 10) self.timer = self.create_timer(0.1, self.tick) # 10Hz def tick(self): # 读下位机传感器帧(简化:CMD=0x06,超声 float + 电压 float) buf = self.ser.read(14) if len(buf) != 14: return head, cmd = struct.unpack('>HB', buf[:3]) if head != 0xAA55 or cmd != 0x06: return dist, voltage = struct.unpack('>2f', buf[3:11]) # 发布超声波(Range 消息:传感器锥形探测范围) r = Range() r.header.stamp = self.get_clock().now().to_msg() r.header.frame_id = 'sonar_link' r.radiation_type = Range.ULTRASOUND r.field_of_view = 0.52 # 30° 锥角(弧度) r.min_range = 0.02 r.max_range = 4.0 r.range = dist self.sonar_pub.publish(r) # 发布电压 v = Float32() v.data = voltage self.volt_pub.publish(v) def main(args=None): rclpy.init(args=args) node = SensorAggregator() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **低电量保护**:上位机订阅 `/battery_voltage`,低于阈值时发布 `robot_status/emergency_stop`,或者直接关断动力——这是实体机器人的安全底线。 ### 七、通信可靠性工程实践 #### 7.1 丢帧与重试 - 上位机发指令默认**不需要应答**(速度指令是"最新值覆盖"语义),但关键指令(复位、使能)需要确认帧。 - 下位机 50Hz 回传状态,上位机若 1 秒没收到任何帧 → 判断下位机掉线 → 停止电机(安全)。 ```python # 掉线保护(在串口桥里加超时判断) class SerialBridge: def __init__(self): ... self.last_frame_time = 0.0 self.watchdog = self.create_timer(0.5, self.watchdog_check) def watchdog_check(self): now = self.get_clock().now().nanoseconds / 1e9 if now - self.last_frame_time > 1.0: self.get_logger().error('MCU offline! stopping motors') # 发布零速度(安全停车) twist = Twist() self.on_cmd_vel(twist) ``` #### 7.2 波特率与缓冲区 - 115200 波特率:约 11500 字节/秒,50Hz × 18 字节 × 2(收发)≈ 1800 字节/秒,余量充足。 - 上位机串口读缓冲区建议 256 字节以上;下位机环形缓冲区 128 字节。 #### 7.3 电磁干扰防护 - 电机线用双绞线,远离串口线。 - 串口加磁环(或换 USB-TTL 隔离模块)。 - 地线星形连接(各模块独立回地,避免地环路)。 ### 八、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 收不到完整帧 | 状态机 bug / 串口配置错误 | 串口调试助手先验证帧格式,再查状态机 | | 偶发校验失败 | 电磁干扰 / 波特率漂移 | 双绞线、磁环、降低波特率试 | | 里程计漂移 | 轮距/轮径参数不准 | 实测轮径(转 N 圈测距离),标定参数 | | 里程计方向反 | 编码器方向约定不一致 | 统一"左轮正转=前进"约定,软件反转 | | IMU yaw 跳变 | 角度归一化错误 | yaw 归一到 [-π, π] | | 电机命令滞后 | 通信频率太低 | 上位机发送频率提到 20-50Hz | | 上位机一启动电机猛转 | 默认命令未初始化 | 启动时先发一次零速度,再进主循环 | ### 九、小结与下一篇预告 **本篇核心**: 1. 通信协议三要素:帧头 + 长度 + 校验(CRC8),用状态机解析字节流。 2. 下位机三件套:串口状态机 + 里程计解算 + IMU 回传,10ms 控制环内完成。 3. 上位机两桥:串口桥(/cmd_vel ↔ 协议帧)+ 传感器汇聚(Range/电压)。 4. 可靠性工程:掉线看门狗、低电量保护、电磁防护、参数标定。 至此实体机器人"软硬结合"完成:能接收指令、能上报状态、能算里程计。下一篇(第 33 篇)进入 **上位机建图与导航(原理综合)**:把第 21-25 篇的 SLAM 和 Nav2 能力部署到实体机器人上——真机建图流程、坐标系校准、Nav2 参数整定、首次自主导航,完成"从仿真到真机"的跨越。敬请期待。 --- ## ROS2系列教程:实体机器人硬件搭建(原理) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-shi-ti-ji-qi-ren-ying-jian-da-jian/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 31 篇 > 本文是 ROS2 系列教程的第 31 篇:实体机器人硬件搭建(原理)。第 26-30 篇我们打通了硬件控制全链路:电机、舵机、传感器、MicroROS、ROS2-Control。但那些都是在"零件"层面。本篇开始进入**实体机器人阶段(31-34)**:把前面所有的零件组装成一台能跑、能感知、能建图导航的真机器人。本篇聚焦硬件搭建的完整原理:总体架构、选型、电气连接、电源管理、车架结构,并给出计算与调试方法,为第 32-34 篇的软件开发打基础。 ### 一、实体机器人总体架构 #### 1.1 经典两轮差速小车的组成 一台能跑 ROS2 的最小实体机器人(两轮差速小车)由以下部分组成: | 模块 | 组成 | 作用 | | --- | --- | --- | | 车架 | 底盘板、连接件、轮子 | 承载所有部件 | | 动力 | 直流减速电机 ×2、编码器、电机驱动(TB6612) | 驱动车轮、测速 | | 下位机 | STM32 / ESP32 | 电机闭环控制、传感器采集 | | 上位机 | 树莓派 / Jetson Nano | 运行 ROS2、建图导航 | | 电源 | 锂电池、稳压模块 | 供电 | | 感知 | 激光雷达、IMU、超声波 | 环境感知 | | 通信 | USB 串口、Wi-Fi | 上/下位机通信 | 经典数据流: ```text 激光雷达 ─┐ IMU ──────┼→ 上位机(ROS2) ──/cmd_vel──→ 下位机(STM32) ──PWM──→ 电机驱动 ──→ 电机 超声波 ───┘ ↑ │ /odom ◄──── 编码器测速 ── 下位机闭环 ────────────────┘ ``` #### 1.2 为什么选两轮差速 两轮差速(2WD)是最适合入门和学习的底盘构型: | 底盘类型 | 优点 | 缺点 | 适合 | | --- | --- | --- | --- | | 两轮差速 | 结构简单、成本低、控制成熟 | 只能原地旋转和前后走 | 入门、室内、教学 | | 四轮差速 | 负载大、越野 | 转向需要滑移 | 室外 | | 麦克纳姆轮 | 全向移动、可横移 | 贵、控制复杂、效率低 | 竞赛、仓储 | | 阿克曼转向 | 接近真车 | 结构复杂、不能原地转 | 室外车模 | 两轮差速的运动学模型(第 13 篇讲过):给线速度 v 和角速度 ω,换算左右轮速: ```text v_left = (v - ω × L/2) / r v_right = (v + ω × L/2) / r 其中 L=轮距,r=轮半径 ``` #### 1.3 上下位机分工再明确 - **上位机(树莓派/Jetson)**:跑 ROS2、SLAM、Nav2。算力强但功耗高(5V 2-3A)。 - **下位机(STM32)**:电机闭环、编码器、超声波等实时性要求高的任务。功耗低、实时性强。 - **分界点**:所有"控制周期 ≤ 10ms"的任务放下位机,其余放上位机。 ### 二、硬件选型(原理与要点) #### 2.1 电机选型 机器人电机选择的核心参数: | 参数 | 说明 | 选择要点 | | --- | --- | --- | | 减速比 | 电机转速 vs 输出转速 | 小车常用 1:30 ~ 1:90 | | 额定电压 | 常见 3.7V/6V/12V | 与电池电压匹配 | | 额定转速 | 输出轴 RPM | 小车 100-300 RPM 合适 | | 扭矩 | 决定爬坡/加速能力 | 越大越有力 | | 编码器 | 每转脉冲数(PPR) | 至少 11-13 PPR,配 4 倍频 | **选型计算示例**:目标最高速度 0.5 m/s,轮径 65mm(半径 0.0325m): ```text 轮子转速 = v / (2πr) = 0.5 / (2 × 3.14 × 0.0325) ≈ 2.45 转/秒 ≈ 147 RPM 电机(减速前)转速 = 147 × 减速比 选 1:30 减速比 → 电机 ≈ 4410 RPM(合理,常见 3000-12000 RPM 电机) 所需扭矩估算(车重 1kg,摩擦系数 0.3,加速度 1m/s²): F = m×a + μ×m×g = 1×1 + 0.3×1×9.8 ≈ 3.94 N 每轮扭矩 = F×r / 2 / 效率 ≈ 3.94×0.0325/2/0.6 ≈ 0.107 N·m ``` 结论:选择 12V、减速比 1:30、额定转速 ~5000RPM、扭矩 ≥ 0.15 N·m、带编码器的直流减速电机。 #### 2.2 上位机选型 | 平台 | 优点 | 缺点 | 建议 | | --- | --- | --- | --- | | 树莓派 4B/5 | 生态好、教程多、便宜 | 算力一般、散热 | 入门首选 | | Jetson Nano/Orin | GPU 算力强 | 贵、功耗高 | 视觉项目 | | 工控机 | 稳定、接口全 | 大、贵 | 工业 | 运行 ROS2 Humble 至少需要:4GB 内存、16GB 存储、支持 USB3.0(接激光雷达)。 #### 2.3 激光雷达选型 | 型号 | 测距 | 频率 | 特点 | | --- | --- | --- | --- | | RPLIDAR A1 | 12m | 5.5Hz | 最经典,性价比高 | | YDLIDAR X4 | 10m | 6-7Hz | 国产,性价比高 | | RPLIDAR A2/A3 | 12-25m | 8-16Hz | 精度更高 | | Livox Mid-360 | 40m | 10Hz | 3D 雷达,贵 | 入门选 RPLIDAR A1 或 YDLIDAR X4 即可满足室内 SLAM。 #### 2.4 电源选型 | 电池 | 电压 | 优点 | 缺点 | | --- | --- | --- | --- | | 18650 锂电(2S/3S) | 7.4/11.1V | 便宜、容量大 | 需要保护板 | | 航模锂电 LiPo | 7.4-11.1V | 放电猛 | 娇贵、怕过放 | | 磷酸铁锂 | 6.4-9.6V | 安全、寿命长 | 电压低 | **电源架构**(关键): ```text 锂电池(11.1V) ├── 降压模块 → 12V/5A → 电机驱动(电机供电) ├── 降压模块 → 5V/3A → 树莓派(上位机) └── 降压模块 → 5V/1A → STM32 + 传感器 ``` 注意:电机启动电流大,**电机供电必须与逻辑供电隔离**(至少分开走线、电容滤波),否则电机一启动上位机就复位。 ### 三、电气连接与布线(原理) #### 3.1 接线规范 实体机器人接线是"细节决定成败"的环节,规范如下: 1. **电源线粗、信号线细**:电机线用 20AWG 以上,信号线 26-28AWG。 2. **共地**:所有模块 GND 必须连在一起(否则通信不稳定)。 3. **上下位机串口交叉**:上位机 TX → 下位机 RX,上位机 RX → 下位机 TX,共地。 4. **屏蔽与隔离**:电机线与传感器线分开走,避免干扰;长线用双绞或屏蔽线。 5. **防反接**:电源入口加防反接二极管或防反接模块。 #### 3.2 完整接线清单(示例) ```text 电源: 11.1V 电池 → 电源开关 → 分线排 ├─→ 5V/5A 降压 → 树莓派 5V/GND ├─→ 5V/2A 降压 → STM32 5V + 传感器 VCC └─→ 12V 直供 → TB6612 VM(电机电源) 下位机(STM32): PA0/PA1 → 左电机编码器 A/B PB6/PB7 → 右电机编码器 A/B(或用定时器编码器模式) PA6/PA7 → TB6612 PWMA/PWMB PA4/PA5 → TB6612 AIN1/AIN2 PB0/PB1 → TB6612 BIN1/BIN2 PA8 → TB6612 STBY PA9/PA10→ 串口1 → 树莓派 UART(115200) 上位机(树莓派): GPIO14/15 → 串口 → STM32 USB0 → RPLIDAR A1 I2C → IMU(可选) 雷达: RPLIDAR → 5V/GND + USB ``` #### 3.3 电流估算(防电源不足) 总电流估算(满载): ```text 树莓派 4B: ~1.2A(峰值 2.5A) STM32 + 传感器: ~0.3A 激光雷达: ~0.4A 电机(2 个堵转): 2 × 1.5A = 3A 合计: ~5A(峰值) 电池容量建议:≥ 5000mAh(2S/3S),续航 1-2 小时 降压模块额定:电机路 ≥ 5A,逻辑路 ≥ 3A ``` ### 四、示例 1:车架与结构设计(原理) #### 4.1 车架结构要点 1. **重心低**:电池放最底层,重心尽量贴近地面,转弯更稳。 2. **重心居中**:电池、上位机尽量居中对称,避免偏载导致跑偏。 3. **轮距与轴距**:轮距越宽越稳但转弯半径越大;两轮小车通常轮距 = 车宽的 60-70%。 4. **驱动轮与万向轮**:两轮差速需要配 1-2 个万向轮(牛眼轮/万向球)做支撑。 5. **模块固定**:减震(雷达、IMU 用硅胶垫),防松动(螺丝胶)。 #### 4.2 建模辅助(可选) 可以用 FreeCAD/Onshape 建 3D 模型验证干涉,也可以用简单画图工具画布线图。实体搭建前先画一张"俯视布局图",标注每个模块位置和走线,能省大量返工。 ### 五、示例 2:下位机固件框架(原理) 下位机 STM32 固件采用"定时中断 + 状态机"结构,主循环处理通信,定时中断处理控制: ```c // main.c 框架:下位机固件主循环(伪代码 + 关键逻辑) #include "main.h" #include "motor_driver.h" #include "encoder.h" #include "pid.h" #include "protocol.h" // 全局变量 volatile float target_v = 0, target_w = 0; // 目标线速度/角速度 volatile float current_v = 0, current_w = 0; // 10ms 控制定时器中断:速度闭环 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { // 1. 编码器测速 → 计算实际线/角速度 float v_left = encoder_speed(ENC_LEFT); // rad/s float v_right = encoder_speed(ENC_RIGHT); current_v = (v_left + v_right) * WHEEL_RADIUS / 2.0; current_w = (v_right - v_left) * WHEEL_RADIUS / WHEEL_BASE; // 2. 逆运动学:目标线/角速度 → 左右轮速 float wl = (target_v - target_w * WHEEL_BASE / 2.0) / WHEEL_RADIUS; float wr = (target_v + target_w * WHEEL_BASE / 2.0) / WHEEL_RADIUS; // 3. PID 闭环 → PWM motor_left_set_speed((int)pid_update(&pid_l, wl, v_left)); motor_right_set_speed((int)pid_update(&pid_r, wr, v_right)); } } // 主循环:处理串口协议帧 int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM6_Init(); // 10ms 控制定时器 MX_USART1_UART_Init(); motor_init(); HAL_TIM_Base_Start_IT(&htim6); // 启动控制定时器 protocol_frame_t frame; while (1) { // 解析串口帧(协议见第 29 篇:帧头+命令+校验) if (protocol_parse(&frame) == PROTOCOL_OK) { switch (frame.cmd) { case CMD_SET_VEL: // 设置目标速度 target_v = frame.f32[0]; target_w = frame.f32[1]; break; case CMD_GET_ODOM: // 上位机查询里程计 protocol_send_odom(current_v, current_w); break; case CMD_RESET: // 复位 __NVIC_SystemReset(); break; } } } } ``` 这个框架体现了下位机的核心原则:**控制(中断)+ 通信(主循环)分离**,保证实时性。 ### 六、示例 3:上位机串口通信节点(Python) 上位机用 ROS2 节点与下位机通信:订阅 `/cmd_vel` 打包发送,读取里程计回传发布 `/odom`: ```python # serial_bridge.py:上位机-下位机串口桥(ROS2 节点) import rclpy import serial import struct from rclpy.node import Node from geometry_msgs.msg import Twist from nav_msgs.msg import Odometry from std_msgs.msg import Header class SerialBridge(Node): FRAME_HEAD = 0xAA55 def __init__(self): super().__init__('serial_bridge') # 串口配置(与下位机一致) self.ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=0.02) # 订阅速度指令 self.cmd_sub = self.create_subscription( Twist, '/cmd_vel', self.on_cmd_vel, 10) # 发布里程计 self.odom_pub = self.create_publisher(Odometry, '/odom', 10) self.timer = self.create_timer(0.02, self.read_odom) # 50Hz def on_cmd_vel(self, msg: Twist): # 打包:帧头(2B) + 命令(1B) + 数据(8B float×2) + 校验(1B) data = struct.pack('>2ff', msg.linear.x, msg.angular.z) checksum = 0 for b in data: checksum ^= b frame = struct.pack('>HB', self.FRAME_HEAD, 0x01) + data + bytes([checksum]) self.ser.write(frame) def read_odom(self): # 读下位机回传帧(简化:固定格式) buf = self.ser.read(15) if len(buf) != 15: return head, cmd = struct.unpack('>HB', buf[:3]) if head != self.FRAME_HEAD or cmd != 0x02: return v, w = struct.unpack('>2f', buf[3:11]) # 发布里程计(简化:仅速度,实际工程应积分位姿) odom = Odometry() odom.header = Header() odom.header.stamp = self.get_clock().now().to_msg() odom.header.frame_id = 'odom' odom.child_frame_id = 'base_link' odom.twist.twist.linear.x = v odom.twist.twist.angular.z = w self.odom_pub.publish(odom) def main(args=None): rclpy.init(args=args) node = SerialBridge() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **验证链路**: ```bash # 终端 1:启动串口桥 ros2 run robot_bringup serial_bridge.py # 终端 2:发速度指令,观察电机是否转 ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \ "{linear: {x: 0.1}, angular: {z: 0.0}}" --once # 终端 3:查看里程计(轮子转起来 /odom 才有数据) ros2 topic echo /odom ``` ### 七、示例 4:整机启动流程与调试(launch) 实体机器人调试要"分层验证":先硬件后软件,先单模块后整机。 #### 7.1 分层调试顺序 ```text 第 1 层 电源:各模块电压正常,指示灯亮 第 2 层 下位机:串口打印正常,电机能转,编码器有读数 第 3 层 通信:上位机 serial_bridge 能收发,ros2 topic 有数据 第 4 层 传感器:雷达 /scan 正常,IMU /imu 正常 第 5 层 建图导航:SLAM 建图、Nav2 导航 ``` #### 7.2 一键启动 launch ```python # robot_bringup.launch.py:整机启动(串口桥 + 雷达 + 底盘状态) from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ # 串口桥(上/下位机通信) Node(package='robot_bringup', executable='serial_bridge.py', output='screen'), # 激光雷达驱动 Node(package='rplidar_ros', executable='rplidar_composition', parameters=[{'serial_port': '/dev/ttyUSB1', 'frame_id': 'laser', 'angle_compensate': True}], output='screen'), # 发布 TF(odom → base_link → laser) Node(package='tf2_ros', executable='static_transform_publisher', arguments=['0', '0', '0.1', '0', '0', '0', 'base_link', 'laser']), # 底盘状态发布(/odom 已在串口桥里) ]) ``` 启动后检查: ```bash ros2 launch robot_bringup robot_bringup.launch.py # 验证话题 ros2 topic list # 应看到:/cmd_vel /odom /scan /tf ... # 验证 TF 树 ros2 run tf2_tools view_frames # 生成 frames.pdf 检查坐标系 ``` ### 八、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 上位机反复重启 | 电源不足/电机干扰 | 电机与逻辑供电隔离,加大电容,单独供电 | | 电机不动但代码正常 | STBY 没拉高/电源没接电机路 | 检查 TB6612 STBY 与 VM 电压 | | 车轮方向相反 | 电机接线/编码器方向不一致 | 软件里反转 IN 引脚或统一方向 | | 车跑偏(直行走弧线) | 左右轮速不等/机械不对称 | 用 PID 速度闭环,检查轮胎气压/磨损 | | 串口乱码 | 波特率不一致/共地缺失 | 统一 115200,检查 GND 连接 | | 雷达扫描有盲区 | 雷达安装位置遮挡 | 雷达装在最高点,避开云台/支架遮挡 | | 上电瞬间 USB 掉设备 | 电流冲击 | 加缓启动(软启动模块),或先上逻辑电再上动力电 | | WiFi 丢包 | 信号干扰/天线位置 | 天线垂直朝上,避开金属遮挡 | ### 九、小结与下一篇预告 **本篇核心**: 1. 实体机器人 = 车架 + 动力(电机/编码器/驱动)+ 下位机 + 上位机 + 电源 + 感知。 2. 选型三要素:电机(减速比/扭矩/编码器)、上位机(算力 vs 功耗)、电源(电流估算与隔离)。 3. 电气规范:共地、电源隔离、信号线分离、防反接——细节决定稳定性。 4. 调试方法论:分层验证(电源→下位机→通信→传感器→整机),用 launch 一键启动。 至此实体机器人的"骨架"搭好了。下一篇(第 32 篇)进入 **嵌入式控制与通信开发(原理+仿真)**:把第 31 篇的硬件框架落实成完整代码——下位机协议栈实现、里程计发布、传感器数据融合,并在仿真环境里验证上/下位机通信的可靠性。敬请期待。 --- ## ROS2系列教程:控制硬件实战(舵机与传感器,原理) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-kong-zhi-ying-jian-shi-zhan-duo-ji/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 30 篇 > 本文是 ROS2 系列教程的第 30 篇:控制硬件实战(舵机与传感器,原理)。第 29 篇我们打通了电机驱动全链路(H 桥、PWM、编码器、PID、ros2_control 硬件接口)。但机器人除了电机还需要"关节"(舵机)和"感知"(传感器)。本篇讲透舵机的控制原理(PWM 与角度映射、总线舵机)和常用传感器的接入原理(IMU、超声波、红外、激光雷达、摄像头),给出代码示例,并完成**硬件控制阶段(26-30)的收官**。 ### 一、舵机控制原理 #### 1.1 舵机是什么 舵机(Servo)是能精确控制角度的执行器,广泛用于机械臂关节、云台、转向机构。它内部集成了:直流电机 + 减速齿轮组 + 电位器(角度反馈)+ 控制电路。给一个 PWM 信号,它就转到指定角度并保持。 ```text PWM 信号 → 控制电路 → 比较角度误差 → 驱动电机 → 减速齿轮 → 输出轴 ↑_________________电位器反馈_______________| ``` 舵机内部是一个天然的闭环系统:你给目标角度,它自己闭环跟踪。这就是为什么舵机只需要一根信号线,而电机需要编码器+PID。 #### 1.2 角度与 PWM 的映射 普通模拟舵机(如 SG90)用 20ms 周期(50Hz)的 PWM 控制,占空比决定角度: ```text 0.5ms → 0°(最小角度) 1.0ms → 45° 1.5ms → 90°(中位) 2.0ms → 135° 2.5ms → 180°(最大角度) ``` 关键点: - 频率固定 **50Hz(周期 20ms)**,变化的是**高电平脉宽**(0.5-2.5ms)。 - 不同舵机脉宽范围不同,需按型号校准。 - **舵机不能长时间堵转**(到极限位置还持续给信号会烧毁)。 #### 1.3 STM32 定时器 PWM 控制舵机 用 STM32 定时器产生 50Hz PWM,通过调节 CCR 值改变脉宽: ```c // servo.c:STM32 HAL 库控制舵机(TIM1 CH1,PE9) #include "main.h" // 初始化:定时器频率 1MHz(计数周期),ARR=19999 → 20ms 周期(50Hz) void servo_init(void) { HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); servo_set_angle(90); // 先回中位 } // 设置舵机角度:0~180 度 → 脉宽 0.5~2.5ms void servo_set_angle(uint8_t angle) { if (angle > 180) angle = 180; // 脉宽(us) = 500 + angle * (2500-500)/180 = 500 + angle * 11.11 // CCR 值 = 脉宽(us)(因为定时器是 1MHz,1 计数 = 1us) uint32_t pulse_us = 500 + (uint32_t)(angle * 11.11f); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, pulse_us); } ``` #### 1.4 ESP32 Arduino 控制舵机 ESP32 用 Servo 库或 LEDC 实现,代码更简洁: ```cpp // ESP32 Arduino:控制舵机(使用内置 Servo 库) #include Servo myservo; void setup() { myservo.attach(13, 500, 2500); // GPIO13,脉宽 0.5~2.5ms myservo.write(90); // 回中位 delay(1000); } void loop() { // 0° → 180° → 0° 来回摆动 for (int angle = 0; angle <= 180; angle += 2) { myservo.write(angle); delay(15); } delay(500); for (int angle = 180; angle >= 0; angle -= 2) { myservo.write(angle); delay(15); } delay(500); } ``` #### 1.5 总线舵机(进阶) 传统舵机每路一个 PWM 引脚,舵机多了引脚不够。**总线舵机**(如串行总线舵机 LX-16A、SCS15)通过串口/半双工总线级联,用 ID 寻址,一根线控制多个舵机: ```text 总线舵机特点: - 串口通信(UART),可级联 253 个 - 反馈角度、温度、电压(还能"感知"自己) - 可设置速度、模式 ``` 这是机械臂(第 35-40 篇 MoveIt2 实战)常用的舵机方案,ROS2 侧通过 `dynamixel_sdk` 类库封装控制。 ### 二、IMU 传感器接入(原理) #### 2.1 IMU 是什么 IMU(惯性测量单元)集成了加速度计 + 陀螺仪(+ 磁力计),测量: | 传感器 | 测量 | 用途 | | --- | --- | --- | | 加速度计 | 三轴加速度 | 计算倾角、检测运动 | | 陀螺仪 | 三轴角速度 | 计算姿态变化率 | | 磁力计(可选) | 三轴磁场 | 航向角(指南针) | 常见芯片:MPU6050(入门)、ICM-20948(高性能)、BMI088(机器人主流)。通信接口:I2C 或 SPI。 #### 2.2 姿态解算原理 陀螺仪积分得角度,但会**漂移**(误差累积);加速度计能直接得倾角但不适合动态(振动干扰大)。所以要用**数据融合**:互补滤波或卡尔曼滤波。 互补滤波(简单有效): ```text 角度 = 0.98 × (上一角度 + 陀螺仪角速度 × dt) + 0.02 × 加速度计角度 ``` 陀螺仪负责"短期准确",加速度计负责"长期纠偏"。 #### 2.3 MPU6050 读取示例(STM32 I2C) ```c // mpu6050.c:读取加速度和陀螺仪原始数据(I2C) #include "main.h" #define MPU6050_ADDR 0xD0 // I2C 地址(AD0=0 时) #define PWR_MGMT_1 0x6B #define ACCEL_XOUT_H 0x3B #define GYRO_XOUT_H 0x43 // 初始化:退出休眠模式 void mpu6050_init(void) { uint8_t data = 0x00; HAL_I2C_Mem_Write(&hi2c1, MPU6050_ADDR, PWR_MGMT_1, 1, &data, 1, 100); } // 读三轴加速度 + 三轴陀螺仪 void mpu6050_read(int16_t *accel, int16_t *gyro) { uint8_t buf[14]; HAL_I2C_Mem_Read(&hi2c1, MPU6050_ADDR, ACCEL_XOUT_H, 1, buf, 14, 100); for (int i = 0; i < 6; i++) { int16_t v = (int16_t)((buf[i*2] << 8) | buf[i*2+1]); if (i < 3) accel[i] = v; else gyro[i-3] = v; } } ``` #### 2.4 ROS2 侧:IMU 数据发布(Python) 下位机通过串口把 IMU 原始数据发给上位机,上位机用 `imu_filter_madgwick` 或直接发布 sensor_msgs/Imu: ```python # imu_publisher.py:把串口 IMU 数据发布为 ROS2 Imu 消息 import rclpy import serial import struct from rclpy.node import Node from sensor_msgs.msg import Imu from std_msgs.msg import Header class ImuPublisher(Node): def __init__(self): super().__init__('imu_publisher') self.pub = self.create_publisher(Imu, '/imu/data_raw', 10) self.ser = serial.Serial('/dev/ttyUSB1', 115200, timeout=0.1) self.timer = self.create_timer(0.02, self.tick) # 50Hz def tick(self): # 读串口帧(简化:固定格式 6 个 int16) line = self.ser.readline() if len(line) < 12: return ax, ay, az, gx, gy, gz = struct.unpack('<6h', line[:12]) msg = Imu() msg.header = Header() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'base_link' # 加速度单位换算:原始值 / 16384 (±2g 量程) msg.linear_acceleration.x = ax / 16384.0 * 9.81 msg.linear_acceleration.y = ay / 16384.0 * 9.81 msg.linear_acceleration.z = az / 16384.0 * 9.81 # 角速度:原始值 / 131 (±250°/s 量程),转 rad/s msg.angular_velocity.x = gx / 131.0 * 3.14159 / 180.0 msg.angular_velocity.y = gy / 131.0 * 3.14159 / 180.0 msg.angular_velocity.z = gz / 131.0 * 3.14159 / 180.0 self.pub.publish(msg) def main(args=None): rclpy.init(args=args) node = ImuPublisher() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` IMU 数据在机器人中的作用:里程计融合(轮式里程计 + IMU 姿态)、机器人直立平衡、AMCL 初始姿态。 ### 三、距离传感器接入(原理) #### 3.1 超声波传感器(HC-SR04) 原理:发射超声波,测回波时间,距离 = 声速 × 时间 / 2。 ```text 触发引脚(TRIG):给 10us 高电平 回波引脚(ECHO):输出高电平,宽度 = 往返时间 距离(cm) = 回波时间(us) / 58 ``` ```c // hc_sr04.c:STM32 读取距离(溢出捕获测回波脉宽) #include "main.h" float hc_sr04_read_distance_cm(void) { // 1. TRIG 给 10us 高电平触发 HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_SET); delay_us(10); HAL_GPIO_WritePin(TRIG_GPIO_Port, TRIG_Pin, GPIO_PIN_RESET); // 2. 测量 ECHO 高电平时间(用定时器输入捕获) // 伪代码:启动捕获,等待上升沿开始计数,下降沿停止 uint32_t pulse_us = measure_echo_pulse_us(); // 输入捕获实现 // 3. 换算距离:1us ≈ 0.017cm(声速 340m/s) return pulse_us / 58.0f; } ``` 局限:测量范围 2-400cm,精度受温度影响,对斜面/软表面不稳定。 #### 3.2 红外传感器(避障) 红外避障传感器(如 TCRT5000、红外光电开关)原理:发射红外光,障碍物反射后接收管检测。输出是**数字开关量**(有/无障碍),适合简单的沿墙避障,不提供距离数值。 ```c // 读取红外避障传感器:返回 1=有障碍,0=无障碍 uint8_t ir_obstacle_detect(void) { // 传感器输出接 GPIO(开漏,需要上拉) return HAL_GPIO_ReadPin(IR_GPIO_Port, IR_Pin) == GPIO_PIN_RESET ? 1 : 0; } ``` #### 3.3 激光雷达(LiDAR) 激光雷达(如 RPLIDAR A1、YDLIDAR X4)通过旋转激光束扫描周围环境,输出极坐标距离点集。它比超声波/红外精确得多,是 SLAM(第 21 篇)和导航(第 24 篇)的核心传感器。 接入方式:USB 串口 + 官方驱动(rplidar_ros),发布 `sensor_msgs/LaserScan`: ```bash # 启动 RPLIDAR 驱动(发布 /scan) ros2 launch rplidar_ros view_rplidar_a1_launch.py # 查看激光数据 ros2 topic echo /scan --once # 输出 angle_min/angle_max/ranges 数组 # RViz 可视化 rviz2 -d rplidar_ros/rviz/view_rplidar_a1.rviz ``` LaserScan 消息核心字段: ```text header.frame_id: laser(坐标系) angle_min / angle_max / angle_increment:角度范围与步进 range_min / range_max:有效距离范围 ranges[]:每个角度的距离值(米) ``` ### 四、摄像头接入(原理) #### 4.1 摄像头在 ROS2 中的角色 摄像头是机器人最重要的感知传感器:目标检测、视觉导航、人机交互。在 ROS2 中通过 `usb_cam`(USB 摄像头)或 `v4l2_camera`(Linux 原生)驱动,发布 `sensor_msgs/Image` 和 `sensor_msgs/CameraInfo`。 ```bash # 启动 USB 摄像头驱动 ros2 run usb_cam usb_cam_node_exe --ros-args \ -p video_device:=/dev/video0 \ -p image_width:=640 -p image_height:=480 \ -p framerate:=30 # 查看图像话题 ros2 topic list | grep camera # /image_raw /camera_info ... # RViz 查看实时画面 rviz2 # 添加 Image 显示,选择 /image_raw ``` #### 4.2 图像话题与 YOLO 视觉示例 订阅图像做目标检测是经典应用: ```python # detect.py:订阅摄像头图像做简单颜色检测(OpenCV) import rclpy from rclpy.node import Node from sensor_msgs.msg import Image from cv_bridge import CvBridge import cv2 import numpy as np class ColorDetect(Node): def __init__(self): super().__init__('color_detect') self.bridge = CvBridge() self.sub = self.create_subscription( Image, '/image_raw', self.on_image, 10) def on_image(self, msg): cv_img = self.bridge.imgmsg_to_cv2(msg, 'bgr8') hsv = cv2.cvtColor(cv_img, cv2.COLOR_BGR2HSV) # 红色范围检测 mask = cv2.inRange(hsv, (0, 100, 100), (10, 255, 255)) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: largest = max(contours, key=cv2.contourArea) x, y, w, h = cv2.boundingRect(largest) self.get_logger().info(f'red target at ({x},{y}) size {w}x{h}') def main(args=None): rclpy.init(args=args) node = ColorDetect() rclpy.spin(node) rclpy.shutdown() if __name__ == '__main__': main() ``` 视觉在实体机器人(第 31-34 篇)和机械臂(第 35-40 篇)中会大量使用。 ### 五、传感器数据汇总与滤波(原理) #### 5.1 传感器融合思想 单传感器都有缺陷:轮式里程计会打滑漂移、IMU 会积分漂移、激光雷达有噪点。机器人用**传感器融合**取长补短: ```text 轮式里程计(短期准)+ IMU(姿态稳)+ 激光雷达(定位准) ↓ 融合算法(EKF / 粒子滤波) 机器人位姿估计(x, y, yaw) ``` 第 23 篇的 AMCL 就是基于粒子滤波的融合定位,第 31-34 篇实体机器人会实战组合这些传感器。 #### 5.2 简单滤波:滑动平均 对传感器原始数据做滑动平均,抑制噪声: ```c // moving_average.c:滑动平均滤波(窗口 10) #define WINDOW 10 float buf[WINDOW]; int idx = 0; float sum = 0; int filled = 0; float moving_average(float sample) { if (filled == WINDOW) sum -= buf[idx]; buf[idx] = sample; sum += sample; idx = (idx + 1) % WINDOW; filled = (filled < WINDOW) ? filled + 1 : WINDOW; return sum / filled; } ``` 滤波后的数据再进 ROS2,能显著提升后续算法稳定性。 ### 六、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 舵机抖动/乱转 | 频率不对 / 供电不足 | 确认 50Hz,舵机独立供电(不能和 MCU 共电源) | | 舵机转不到 180° | 脉宽范围不准 | 按型号调 0.5-2.5ms 范围(用 `attach(pin, 500, 2500)`) | | IMU 数据乱跳 | 量程/换算不对 / 未滤波 | 检查量程配置,加互补滤波 | | IMU 角度缓慢漂移 | 陀螺仪零偏 | 静止校准零偏,定期重置 | | 超声波读数为 0 | 回波测量超时 | 增加超时判断(>38ms 视为无效) | | 摄像头画面卡顿 | USB 带宽 / 分辨率太高 | 降低分辨率或帧率(640×480@30) | | /scan 数据为空 | 雷达被遮挡 / 串口权限 | 检查遮挡、`sudo chmod 666 /dev/ttyUSB*` | ### 七、硬件控制阶段总结(26-30 收官) **本阶段(第 26-30 篇)我们打通了机器人硬件全链路**: | 篇 | 主题 | 核心收获 | | --- | --- | --- | | 26 | 嵌入式点灯 | GPIO 本质,嵌入式开发流程 | | 27 | MicroROS | MCU 直接成为 ROS2 节点 | | 28 | ROS2-Control | 关节控制的标准框架 | | 29 | 电机驱动 | H桥/PWM/编码器/PID/硬件接口 | | 30 | 舵机与传感器 | 舵机、IMU、测距、视觉接入 | 至此,从"电"的层面(GPIO、PWM、串口)到"信息"的层面(ROS2 话题、控制框架、传感器数据),机器人硬件部分全部打通。 ### 八、小结与下一篇预告 **本篇核心**: 1. 舵机:PWM 脉宽映射角度(0.5-2.5ms → 0-180°),内部自带闭环,总线舵机可级联。 2. IMU:加速度计+陀螺仪,互补滤波融合姿态,ROS2 里发布 sensor_msgs/Imu。 3. 测距三件套:超声波(时间测距)、红外(开关量)、激光雷达(极坐标点云,SLAM 核心)。 4. 摄像头:usb_cam 驱动 → /image_raw → OpenCV 视觉处理。 至此 **硬件控制阶段(26-30)全部收官**。下一篇开始进入 **实体机器人阶段(31-34)**:第 31 篇「实体机器人硬件搭建(原理)」——把前面所有的"零件"(底盘、电机、传感器、上位机、电池)组装成一台能跑的真机器人,讲解硬件选型、电气连接、电源管理、车架结构等实战知识。从"纸上谈兵"到"真机实战",最激动人心的阶段即将开始! --- ## ROS2系列教程:控制硬件实战(电机驱动,原理) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-kong-zhi-ying-jian-shi-zhan-dian-ji/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 29 篇 > 本文是 ROS2 系列教程的第 29 篇:控制硬件实战(电机驱动,原理)。第 28 篇我们理解了 ros2_control 的架构,但"关节命令"到"电机转动"之间还有最后一步:硬件驱动。本篇深入电机驱动的原理:直流电机的 H 桥驱动与 PWM 调速、编码器测速原理、PID 闭环控制,并给出完整的代码实现,最后把它们接到 ros2_control 的硬件接口上,让机器人真正"动起来"。 ### 一、直流电机驱动原理 #### 1.1 直流电机怎么转 直流电机(DC Motor)通电就会转,转速与电压成正比:电压越高转越快,反向电压反转。看似简单,但直接控制电机有三个问题: 1. **电流方向**:改变方向需要换向(H 桥)。 2. **调速精度**:电压不能随意连续变化,需要用 PWM。 3. **电流保护**:电机启动电流很大(堵转电流可达额定电流数倍),直接接 GPIO 会烧毁单片机引脚。 所以电机不能直接接 MCU 引脚,必须经过**电机驱动模块**。 #### 1.2 H 桥原理 H 桥(H-Bridge)是控制电机方向的标准电路,名字来自它的形状像字母 H:4 个开关(三极管/MOSFET)控制电机的两个接线端。 ```text VCC / \ S1 S2 | | M+----M-(电机) | | S3 S4 \ / GND ``` - S1+S4 导通:电流从左到右,电机正转。 - S2+S3 导通:电流从右到左,电机反转。 - 全部断开:电机自由停止(滑行)。 - S1+S2 同时导通:刹车(短路电机两端)。 常用的 H 桥驱动芯片:L298N(经典,压降大)、TB6612FNG(效率高,机器人常用)、DRV8833(小型化)。TB6612 是目前两轮小车的主流选择。 #### 1.3 PWM 调速原理 PWM(脉冲宽度调制)通过改变方波的**占空比**控制平均电压,从而控制转速: ```text 占空比 100%:▉▉▉▉▉▉▉▉▉▉ → 全速 占空比 50%:▉▉▉▉▉▉▉▉░░░░ → 半速(平均电压一半) 占空比 20%:▉▉▉▉░░░░░░░░ → 低速 ``` 频率通常 10-20kHz(听不见的频段),STM32 的定时器 PWM 输出或 ESP32 的 LEDC 都支持。电机实际感受到的是平均电压:`V_avg = Vcc × 占空比`。 #### 1.4 完整控制组合 电机控制 = H 桥管方向(2 个引脚:IN1/IN2)+ PWM 管速度(1 个引脚:PWM): ```text MCU GPIO_IN1 ──┐ MCU GPIO_IN2 ──┼── TB6612 ── 电机 MCU PWM_PIN ──┘ ``` | IN1 | IN2 | PWM | 电机行为 | | --- | --- | --- | --- | | 0 | 0 | × | 停止(滑行) | | 1 | 0 | 占空比 | 正转,速度=占空比 | | 0 | 1 | 占空比 | 反转,速度=占空比 | | 1 | 1 | × | 刹车 | ### 二、编码器测速原理 #### 2.1 为什么需要编码器 电机转多快、轮子转了几圈,单片机需要知道——否则就是"开环控制":命令 50% 占空比,实际可能因负载、电池电压而偏快或偏慢。**编码器(Encoder)** 就是给电机装"眼睛",测量真实转速,实现闭环控制。 #### 2.2 增量式编码器原理 机器人最常用的是**霍尔编码器**(增量式):电机轴上带磁环(多个磁极),霍尔传感器检测磁场变化,每转过一个磁极输出一个脉冲。常见编码器:13PPR(每转 13 脉冲)、2 相输出(A/B 相)。 ```text 磁环:N-S-N-S-N-S...(每转 13 对磁极) A 相:▂▅▂▅▂▅▂▅▂▅▂▅▂▅ (方波,13 脉冲/转) B 相:▅▂▅▂▅▂▅▂▅▂▅▂▅▂ (与 A 相差 90°) ``` A/B 两相的作用: - **计数**:统计 A 相脉冲数,乘以每转脉冲数即可得转角。 - **辨向**:根据 A/B 相相位差判断正反转(A 领先 B 为正转,反之反转)。 - **倍频**:4 倍频技术(检测 A/B 的上升沿和下降沿)把分辨率提高 4 倍。 #### 2.3 测速公式 ```text 轮子角速度 ω = 脉冲计数 / (每转脉冲数 × 采样周期) 示例:每转 13 脉冲 × 4 倍频 = 52 计数/转 100ms 采样周期内计数 26: ω = 26 / (52 × 0.1) = 5 转/秒 = 5 × 2π = 31.4 rad/s ``` 若已知轮子半径 r,线速度 `v = ω × r`——这正是 diff_drive_controller 需要的 `wheel/velocity` 状态。 #### 2.4 外部中断计数(STM32 示例) 编码器脉冲用**外部中断**计数(不能用轮询,否则丢脉冲): ```c // 编码器计数(STM32 HAL 库,PA0/PA1 接 A/B 相) volatile int32_t encoder_count = 0; // 定时器/外部中断回调:A 相上升沿触发 void EXTI0_IRQHandler(void) { if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0)) { __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0); // 根据 B 相电平判断方向 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1)) { encoder_count++; // 正转 } else { encoder_count--; // 反转 } } } ``` 更专业的做法是用 STM32 定时器的**编码器模式**(硬件自动计数,不占用 CPU),这里只展示原理。 ### 三、示例 1:TB6612 电机驱动(STM32 HAL) #### 3.1 硬件接线 以两轮差速小车为例,TB6612 驱动两路电机: ```text STM32 TB6612 电机 PA6(PWM) → PWMA → M1A ┐ PA7(PWM) → PWMB M1B ┘ 左电机 PA4(IN1) → AIN1 PA5(IN2) → AIN2 PB0(IN3) → BIN1 PB1(IN4) → BIN2 PA8 → STBY(必须拉高,否则芯片不工作) ``` #### 3.2 驱动代码 ```c // motor_driver.c:TB6612 电机驱动(STM32 HAL) #include "main.h" // 电机引脚宏定义(根据实际接线修改) #define MOTOR_L_IN1 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, ...) #define MOTOR_L_IN2 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, ...) #define MOTOR_R_IN3 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, ...) #define MOTOR_R_IN4 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, ...) // 速度范围 -100 ~ +100(负值反转) void motor_left_set_speed(int speed) { speed = (speed > 100) ? 100 : (speed < -100) ? -100 : speed; if (speed > 0) { MOTOR_L_IN1(1); MOTOR_L_IN2(0); // 正转 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, (uint32_t)(speed * 8.0)); } else if (speed < 0) { MOTOR_L_IN1(0); MOTOR_L_IN2(1); // 反转 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, (uint32_t)((-speed) * 8.0)); } else { MOTOR_L_IN1(0); MOTOR_L_IN2(0); // 停止 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 0); } } void motor_right_set_speed(int speed) { speed = (speed > 100) ? 100 : (speed < -100) ? -100 : speed; if (speed > 0) { MOTOR_R_IN3(1); MOTOR_R_IN4(0); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, (uint32_t)(speed * 8.0)); } else if (speed < 0) { MOTOR_R_IN3(0); MOTOR_R_IN4(1); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, (uint32_t)((-speed) * 8.0)); } else { MOTOR_R_IN3(0); MOTOR_R_IN4(0); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0); } } // 初始化:PWM 定时器 + 方向引脚 void motor_init(void) { // 使能 STBY(TB6612 芯片使能) HAL_GPIO_WritePin(GPIOA, GPIO_PIN_8, GPIO_PIN_SET); // 启动 PWM 通道 HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); motor_left_set_speed(0); motor_right_set_speed(0); } ``` 注意:`speed * 8.0` 是把 -100~100 映射到定时器计数范围(8 位 PWM 是 0-255,100 × 2.55 ≈ 255,取 8 是近似)。实际工程应按 `TIM_Period` 精确映射。 #### 3.3 ESP32 Arduino 版本(对比) ```cpp // ESP32 Arduino:TB6612 驱动(LEDC PWM 通道) #define PWMA 25 #define AIN1 26 #define AIN2 27 #define STBY 14 void motorInit() { pinMode(PWMA, OUTPUT); pinMode(AIN1, OUTPUT); pinMode(AIN2, OUTPUT); pinMode(STBY, OUTPUT); digitalWrite(STBY, HIGH); ledcSetup(0, 20000, 8); // 通道 0,20kHz,8 位分辨率 ledcAttachPin(PWMA, 0); } void motorSetSpeed(int speed) { // -100 ~ 100 if (speed > 0) { digitalWrite(AIN1, HIGH); digitalWrite(AIN2, LOW); } else if (speed < 0) { digitalWrite(AIN1, LOW); digitalWrite(AIN2, HIGH); } else { digitalWrite(AIN1, LOW); digitalWrite(AIN2, LOW); } ledcWrite(0, abs(speed) * 255 / 100); } void setup() { motorInit(); } void loop() { motorSetSpeed(50); // 50% 速度正转 delay(2000); motorSetSpeed(0); // 停止 delay(1000); motorSetSpeed(-50); // 50% 速度反转 delay(2000); } ``` ### 四、示例 2:PID 闭环控制(原理+代码) #### 4.1 为什么需要 PID 开环给 50% 占空比,实际转速可能只有目标的一半(电池电压下降、负载增加)。**PID(比例-积分-微分)控制器**根据误差实时调整输出,让实际转速快速、准确地跟上目标: ```text 误差 e(t) = 目标转速 - 实际转速 输出 u(t) = Kp × e(t) + Ki × ∫e(t)dt + Kd × de(t)/dt ``` - **P(比例)**:误差越大输出越大,快速逼近。但单独用 P 会有稳态误差(到达目标附近后输出不足以维持)。 - **I(积分)**:累加历史误差,消除稳态误差。但过大容易超调振荡。 - **D(微分)**:预测误差变化趋势,抑制超调。但会放大噪声。 #### 4.2 位置式 PID(C 语言实现) ```c // pid.c:位置式 PID 控制器 typedef struct { float kp, ki, kd; float integral; float last_error; float out_min, out_max; // 输出限幅 float integral_limit; // 积分限幅(防积分饱和) } PID_t; void pid_init(PID_t *pid, float kp, float ki, float kd) { pid->kp = kp; pid->ki = ki; pid->kd = kd; pid->integral = 0; pid->last_error = 0; pid->out_min = -100; pid->out_max = 100; pid->integral_limit = 50; } float pid_update(PID_t *pid, float target, float actual) { float error = target - actual; // 积分项(带限幅,防积分饱和) pid->integral += error; if (pid->integral > pid->integral_limit) pid->integral = pid->integral_limit; if (pid->integral < -pid->integral_limit) pid->integral = -pid->integral_limit; // 微分项 float derivative = error - pid->last_error; pid->last_error = error; // 输出 = P + I + D float output = pid->kp * error + pid->ki * pid->integral + pid->kd * derivative; // 输出限幅 if (output > pid->out_max) output = pid->out_max; if (output < pid->out_min) output = pid->out_min; return output; } ``` #### 4.3 速度闭环主循环 把编码器测速和 PID 结合,形成一个 10ms 周期的速度闭环: ```c // speed_control.c:电机速度闭环 #include "pid.h" #include "encoder.h" #include "motor_driver.h" PID_t pid_left, pid_right; volatile float target_speed = 0.0; // 目标速度(rad/s),外部设置 // 定时器中断:每 10ms 执行一次速度闭环 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { // 10ms 定时器 // 1. 读取编码器计算实际速度(每转脉冲数 × 4 倍频) float actual_left = encoder_get_speed_rads(ENC_LEFT); // rad/s float actual_right = encoder_get_speed_rads(ENC_RIGHT); // 2. PID 计算 PWM 输出(-100 ~ 100) int out_left = (int)pid_update(&pid_left, target_speed, actual_left); int out_right = (int)pid_update(&pid_right, target_speed, actual_right); // 3. 写入电机 motor_left_set_speed(out_left); motor_right_set_speed(out_right); } } ``` #### 4.4 调参经验(重要) | 现象 | 处理 | | --- | --- | | 响应慢、误差大 | 增大 Kp | | 震荡、电机嗡嗡响 | 减小 Kp,增大 Kd | | 稳态误差(始终差一点) | 增大 Ki | | 超调大、来回冲 | 减小 Ki,增大 Kd | | 积分饱和(长时间回不来) | 加积分限幅(如上代码) | 常用起步参数:`Kp=1.0, Ki=0.1, Kd=0.0`,再按现象微调。真实工程多使用**增量式 PID**(输出增量而非绝对值)或速度环+电流环双环,原理相同。 ### 五、示例 3:把电机接到 ros2_control 硬件接口 第 28 篇的仿真硬件(mock_components)换成真实电机,只需实现一个**自定义 Hardware Interface**。它继承 `hardware_interface::SystemInterface`,核心实现 `read()`(读编码器)和 `write()`(写 PWM)。 #### 5.1 自定义硬件接口(C++) ```cpp // real_motor_hw.cpp:真实电机硬件接口(串口/MicroROS 连接下位机) #include #include #include namespace my_robot_hw { class RealMotorHW : public hardware_interface::SystemInterface { public: // 声明关节及其接口 hardware_interface::CallbackReturn on_init( const hardware_interface::HardwareInfo &info) override { // 基类解析 URDF 的 配置 if (SystemInterface::on_init(info) != hardware_interface::CallbackReturn::SUCCESS) { return hardware_interface::CallbackReturn::ERROR; } // 打开串口(伪代码,实际用 serial 库) // serial_.open(info_.hardware_parameters["port"], 115200); RCLCPP_INFO(rclcpp::get_logger("RealMotorHW"), "hardware init ok"); return hardware_interface::CallbackReturn::SUCCESS; } // 导出状态接口(编码器读数) std::vector export_state_interfaces() override { std::vector state_interfaces; for (const auto &joint : info_.joints) { state_interfaces.emplace_back(joint.name, "position", &position_[joint.name]); state_interfaces.emplace_back(joint.name, "velocity", &velocity_[joint.name]); } return state_interfaces; } // 导出命令接口(PWM/速度命令) std::vector export_command_interfaces() override { std::vector command_interfaces; for (const auto &joint : info_.joints) { command_interfaces.emplace_back(joint.name, "velocity", &velocity_cmd_[joint.name]); } return command_interfaces; } // 读状态:从串口获取编码器数据 hardware_interface::return_type read( const rclcpp::Time &, const rclcpp::Duration &) override { // 伪代码:解析下位机回传的编码器帧 // frame = serial_read_line(); // position_[joint] = parse_position(frame); // velocity_[joint] = parse_velocity(frame); return hardware_interface::return_type::OK; } // 写命令:把速度命令发给下位机 hardware_interface::return_type write( const rclcpp::Time &, const rclcpp::Duration &) override { // 伪代码:拼接命令帧发送 // char buf[64]; // snprintf(buf, 64, "CMD:VEL:%.3f", velocity_cmd_["wheel_left_joint"]); // serial_write(buf); return hardware_interface::return_type::OK; } private: std::map position_; std::map velocity_; std::map velocity_cmd_; }; } // namespace my_robot_hw #include "pluginlib/class_list_macros.hpp" PLUGINLIB_EXPORT_CLASS(my_robot_hw::RealMotorHW, hardware_interface::SystemInterface) ``` #### 5.2 插件声明与配置 ```xml ``` URDF 中替换仿真插件: ```xml my_robot_hw/RealMotorHW /dev/ttyUSB0 115200 ``` **换硬件只改一处**:把 `` 从 `mock_components/GenericSystem` 换成 `my_robot_hw/RealMotorHW`,控制器和上层导航代码一行都不用改。这就是第 28 篇说的"硬件抽象"的兑现。 ### 六、下位机端串口协议实现(原理) 硬件接口通过串口与下位机通信,需要一个简单的帧协议。推荐"帧头 + 数据 + 校验"格式: ```text 帧格式:0xAA 0x55 | CMD(1B) | DATA_LEN(1B) | DATA(nB) | CHECKSUM(1B) 下位机接收(STM32 串口中断 + 状态机解析): 0xAA 0x55 | 0x01(设速度) | 0x08 | 左轮速度(4B float) | 右轮速度(4B float) | 校验和 ``` 校验和 = 数据区所有字节异或,接收端重算比对,防止干扰帧误执行。这是"上位机大脑 + 下位机肌肉"的标准通信范式。 ### 七、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 电机不转 | STBY 未拉高 / 电源不足 / PWM 未启动 | 检查 STBY、外接电源、HAL_TIM_PWM_Start | | 电机只会一个方向转 | IN1/IN2 逻辑反 | 交换 IN1/IN2 接线或代码 | | 电机嗡嗡响但不转 | 占空比太低 / 频率不合适 | 提高 PWM 频率到 10-20kHz,占空比加死区 | | 编码器读数不动 | 编码器电源未接 / 引脚中断未配置 | 检查编码器 VCC、EXTI 配置 | | PID 一直震荡 | Kp 过大 / Kd 过小 | 减小 Kp 增大 Kd,先 P 后 I 再 D 顺序调参 | | 硬件接口 read 报错 | 串口未打开 / 波特率不匹配 | 检查串口权限 `sudo chmod 666 /dev/ttyUSB0` 与波特率 | ### 八、小结与下一篇预告 **本篇核心**: 1. 电机控制 = H 桥(方向)+ PWM(速度),TB6612 是主流驱动方案。 2. 编码器(A/B 相)是电机的"眼睛":计数 + 辨向 + 4 倍频,实现速度反馈。 3. PID 闭环把开环"盲控"变成"精准跟踪",P 快速逼近、I 消除误差、D 抑制超调。 4. ros2_control 硬件接口 = 读编码器(read)+ 写 PWM(write),换硬件只换 plugin。 至此电机驱动全链路打通。下一篇(第 30 篇)进入 **控制硬件实战(舵机与传感器,原理)**:讲舵机(PWM 控制、SG90/串行总线舵机)、常用传感器(IMU、超声波、红外、激光雷达)的 ROS2 接入原理,并完成硬件控制阶段(26-30)的收官。敬请期待。 --- ## ROS2系列教程:ROS2-Control与控制器(原理) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-ros2-control-yu-kong-zhi-qi-yuan-li/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 28 篇 > 本文是 ROS2 系列教程的第 28 篇:ROS2-Control 与控制器(原理)。第 27 篇我们通过 MicroROS 让单片机变成了 ROS2 节点,实现了"能通信"。但机器人关节怎么被统一管理?电机怎么按标准接口转动?这就是 **ros2_control** 框架的职责。本篇讲解 ros2_control 的架构(Controller Manager / Hardware Interface / Controller)、核心概念(Command Interface / State Interface / Transmission),并用仿真环境跑通一个完整的关节控制流程。这是硬件控制阶段最核心的一篇。 ### 一、为什么需要 ros2_control #### 1.1 没有标准的混乱局面 在 ros2_control 出现之前,每个机器人都有自己的控制代码:有人直接订阅 `/cmd_vel` 驱动电机,有人写自己的 PID 控制器,有人为每种电机写一套驱动。结果就是:**换硬件要重写控制逻辑,换控制器要重写驱动**,代码无法复用。 #### 1.2 ros2_control 的定位 ros2_control 是 ROS2 官方的机器人硬件控制框架,它解决的问题: 1. **硬件抽象**:把"电机驱动"和"控制算法"解耦。上层控制器只面对统一的接口(关节位置/速度/力矩),不关心底层是直流电机还是舵机。 2. **控制器标准化**:官方提供开箱即用的控制器(关节轨迹控制器、差速驱动控制器等),也可自定义。 3. **状态反馈统一**:所有传感器状态(编码器、IMU)通过标准接口上报。 一句话:**ros2_control 是"机器人关节的中间件",上接控制器,下接硬件,中间全是标准接口**。 #### 1.3 与 MicroROS 的分工 第 27 篇的 MicroROS 负责"MCU 如何成为 ROS2 节点",而 ros2_control 负责"关节/电机如何被统一管理"。两者可以结合:ros2_control 的硬件接口(hardware_interface)可以基于串口/MicroROS 与下位机通信。典型链路: ```text /controller_manager(总管家) ├── 控制器:diff_drive_controller(订阅 /cmd_vel → 计算轮速) ├── 控制器:joint_trajectory_controller(执行轨迹) └── 硬件接口:RobotHW(把目标轮速写到电机,读回编码器) ↓ 串口 / MicroROS / CAN 真实电机 + 编码器 ``` ### 二、ros2_control 核心架构(原理) #### 2.1 三大组件 ros2_control 由三个核心组件组成: | 组件 | 角色 | 类比 | | --- | --- | --- | | Controller Manager | 总管所有控制器的生命周期:加载、配置、激活、切换 | 操作系统 | | Controller(控制器) | 订阅目标(如 /cmd_vel),计算关节命令,发布状态 | 应用程序 | | Hardware Interface(硬件接口) | 真正读写电机/传感器,把"关节命令"变成"电流/电压" | 设备驱动 | #### 2.2 硬件接口的两类接口 硬件接口与控制器之间通过**资源接口**交互,分两类: - **Command Interface(命令接口)**:控制器→硬件,比如"关节速度命令"(`wheel_left/velocity`)。 - **State Interface(状态接口)**:硬件→控制器,比如"关节位置反馈"(`wheel_left/position`)。 每个接口用 `资源名/接口名` 标识,例如 `joint_1/position`、`wheel_left/velocity`。控制器通过接口名读写硬件,硬件通过接口名暴露能力。 #### 2.3 控制循环(Control Loop) ros2_control 的核心是一个**周期控制循环**: ```text 读取状态(State Interface:读编码器) ↓ 控制器计算(diff_drive:把 /cmd_vel 换算成轮速) ↓ 写入命令(Command Interface:写目标轮速) ↓ 等待下一个周期(默认 100Hz / 10ms) ``` 这个循环由 Controller Manager 的 `update()` 驱动,周期在 YAML 中配置。 ### 三、环境准备 #### 3.1 安装 ros2_control(Humble) ```bash # 安装 ros2_control 全家桶(Humble 默认已含部分组件,补装控制器) sudo apt update sudo apt install -y ros-humble-ros2-control ros-humble-ros2-controllers # 验证安装 ros2 pkg list | grep control # 应看到: # ros2_control # ros2_controllers # controller_manager # ...(若干 hardware_interface 等) ``` #### 3.2 使用仿真硬件(Mock Hardware) 为了让没有真实电机的学习者能跑通流程,ros2_control 提供 **mock_components**(仿真硬件):它在内存中模拟电机行为,编码器读数会响应命令变化。这样我们可以在纯软件环境里完整测试控制链路。 ```bash # 安装仿真硬件组件 sudo apt install -y ros-humble-ros2-controllers-test-fixtures # 或包含在 ros-humble-ros2-control 中 ``` ### 四、示例 1:最小 ros2_control 系统(仿真单关节) #### 4.1 URDF 中声明硬件接口 ros2_control 的硬件配置写在 URDF 的 `` 标签里。创建一个单关节(joint1)的仿真系统: ```xml mock_components/GenericSystem 1.0 $(find single_joint_robot)/config/ros2_control.yaml ``` 要点: - `mock_components/GenericSystem`:声明用**仿真硬件**,不碰真实电机。 - `` 里声明该关节暴露哪些接口:命令接口(position/velocity)+ 状态接口(position/velocity)。 - 这就是"硬件抽象":换成真实电机驱动,只需改 plugin 名和相关参数,上层完全不变。 #### 4.2 Controller Manager 配置 创建 `config/ros2_control.yaml`,配置 Controller Manager 和控制器: ```yaml controller_manager: ros__parameters: update_rate: 100 # 控制循环 100Hz # 声明要加载的控制器 joint_state_broadcaster: type: joint_state_broadcaster/JointStateBroadcaster joint_trajectory_controller: type: joint_trajectory_controller/JointTrajectoryController joint_state_broadcaster: ros__parameters: interface_names: - joint1/position - joint1/velocity joint_trajectory_controller: ros__parameters: joints: - joint1 command_interfaces: - position state_interfaces: - position - velocity state_publish_rate: 50.0 action_monitor_rate: 20.0 ``` 这里声明了两个控制器: - **joint_state_broadcaster**:负责把关节状态广播成 `/joint_states` 话题(RViz 可视化依赖它)。 - **joint_trajectory_controller**:接收轨迹目标(FollowJointTrajectory Action),逐步执行关节位置命令。 #### 4.3 启动 launch 文件 ```python # launch/control.launch.py:启动整个 ros2_control 系统 from launch import LaunchDescription from launch_ros.actions import Node from launch.actions import ExecuteProcess from launch.substitutions import Command, FindExecutable import os def generate_launch_description(): # 解析 URDF(含 ros2_control 声明) robot_description = Command([ FindExecutable(name='xacro'), ' ', os.path.join('src/single_joint_robot/urdf/robot.urdf.xacro') ]) return LaunchDescription([ # 1. 发布机器人模型 Node( package='robot_state_publisher', executable='robot_state_publisher', parameters=[{'robot_description': robot_description}], output='screen', ), # 2. 启动 Controller Manager(读取 YAML 配置) Node( package='controller_manager', executable='ros2_control_node', parameters=['config/ros2_control.yaml'], output='screen', ), # 3. 启动 RViz(可选,可视化) ExecuteProcess( cmd=['rviz2', '-d', 'config/view.rviz'], output='screen', ), ]) ``` #### 4.4 运行与验证 ```bash # 终端 1:构建并启动 cd ~/single_joint_ws && colcon build && source install/setup.bash ros2 launch single_joint_robot control.launch.py # 终端 2:查看控制器状态 ros2 control list_controllers # 输出示例: # joint_state_broadcaster[joint_state_broadcaster/JointStateBroadcaster] active # joint_trajectory_controller[joint_trajectory_controller/JointTrajectoryController] active # 终端 3:查看关节状态话题 ros2 topic echo /joint_states # 能看到 joint1 的 position/velocity 在不断刷新(仿真硬件在"运行") ``` ### 五、示例 2:控制关节运动(发送轨迹) #### 5.1 用 Action 发送轨迹目标 joint_trajectory_controller 通过 **FollowJointTrajectory Action** 接收目标。用 `ros2 action send_goal` 让 joint1 转到 1.57 弧度(90 度): ```bash # 发送轨迹目标:joint1 在 2 秒内转到 1.57 rad ros2 action send_goal /joint_trajectory_controller/follow_joint_trajectory \ control_msgs/action/FollowJointTrajectory \ "{trajectory: {joint_names: ['joint1'], points: [{positions: [1.57], time_from_start: {sec: 2}}]}}" \ --feedback ``` 运行后可以观察到: - joint1 的 position 从 0 平滑运动到 1.57(RViz 里能看到关节转动)。 - 反馈里有 `desired` 和 `actual`,体现"期望轨迹 vs 实际跟踪"。 #### 5.2 用 Python 控制(Action 客户端) 实际项目中用代码发送轨迹更常见: ```python # send_trajectory.py:发送关节轨迹的 Action 客户端 import rclpy from rclpy.action import ActionClient from rclpy.node import Node from control_msgs.action import FollowJointTrajectory from trajectory_msgs.msg import JointTrajectoryPoint class TrajectoryClient(Node): def __init__(self): super().__init__('trajectory_client') self.client = ActionClient( self, FollowJointTrajectory, '/joint_trajectory_controller/follow_joint_trajectory') self.client.wait_for_server() self.get_logger().info('trajectory server connected') def send(self, positions, seconds=2.0): goal = FollowJointTrajectory.Goal() point = JointTrajectoryPoint() point.positions = list(positions) point.time_from_start.sec = int(seconds) goal.trajectory.joint_names = ['joint1'] goal.trajectory.points = [point] future = self.client.send_goal_async(goal) future.add_done_callback(self._goal_done) def _goal_done(self, future): goal_handle = future.result() if goal_handle.accepted: self.get_logger().info('goal accepted') goal_handle.get_result_async() else: self.get_logger().warn('goal rejected') def main(): rclpy.init() node = TrajectoryClient() node.send([1.57]) # 转到 90 度 rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 运行: ```bash python3 send_trajectory.py # 日志:trajectory server connected → goal accepted ``` #### 5.3 C++ 版本(Action 客户端) ```cpp // send_trajectory.cpp:C++ 发送关节轨迹 #include #include #include #include using FollowJointTrajectory = control_msgs::action::FollowJointTrajectory; class TrajectoryClient : public rclcpp::Node { public: using Client = rclcpp_action::Client; TrajectoryClient() : Node("trajectory_client") { client_ = rclcpp_action::create_client( this, "/joint_trajectory_controller/follow_joint_trajectory"); while (!client_->wait_for_action_server(std::chrono::seconds(1))) { RCLCPP_INFO(get_logger(), "waiting for action server..."); } RCLCPP_INFO(get_logger(), "action server connected"); send(1.57); } void send(double position, double seconds = 2.0) { auto goal = FollowJointTrajectory::Goal(); trajectory_msgs::msg::JointTrajectoryPoint point; point.positions.push_back(position); point.time_from_start = rclcpp::Duration::from_seconds(seconds); goal.trajectory.joint_names = {"joint1"}; goal.trajectory.points.push_back(point); auto send_opt = rclcpp_action::Client::SendGoalOptions(); send_opt.goal_response_callback = [this](auto future) { if (future.get()) RCLCPP_INFO(get_logger(), "goal accepted"); else RCLCPP_WARN(get_logger(), "goal rejected"); }; client_->async_send_goal(goal, send_opt); } private: std::shared_ptr client_; }; int main(int argc, char **argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` CMakeLists.txt 关键配置: ```cmake find_package(control_msgs REQUIRED) find_package(trajectory_msgs REQUIRED) find_package(rclcpp_action REQUIRED) add_executable(send_trajectory src/send_trajectory.cpp) ament_target_dependencies(send_trajectory rclcpp rclcpp_action control_msgs trajectory_msgs) ``` ### 六、示例 3:差速驱动控制器(机器人底盘) 单关节太简单,机器人真正常用的是 **diff_drive_controller**:订阅 `/cmd_vel`(速度指令),自动换算成左右轮速度。这是两轮差速底盘的标配。 #### 6.1 URDF 声明差速底盘硬件 ```xml mock_components/GenericSystem ``` #### 6.2 配置 diff_drive_controller ```yaml controller_manager: ros__parameters: update_rate: 100 diff_drive_controller: type: diff_drive_controller/DiffDriveController joint_state_broadcaster: type: joint_state_broadcaster/JointStateBroadcaster diff_drive_controller: ros__parameters: left_wheel_names: ['wheel_left_joint'] right_wheel_names: ['wheel_right_joint'] wheel_separation: 0.4 # 轮距 0.4m wheels_per_side: 1 wheel_radius: 0.1 # 轮半径 0.1m command_interfaces: ['velocity'] state_interfaces: ['position', 'velocity'] publish_rate: 50.0 odom_frame_id: odom base_frame_id: base_link pose_covariance_diagonal: [0.001, 0.001, 0.0, 0.0, 0.0, 0.01] twist_covariance_diagonal: [0.001, 0.0, 0.0, 0.0, 0.0, 0.01] ``` 关键参数:`wheel_separation`(轮距)、`wheel_radius`(轮半径)——这两项决定了 `/cmd_vel` 到轮速的换算。差速模型: ```text 左轮速度 = (v - ω × wheel_separation / 2) / wheel_radius 右轮速度 = (v + ω × wheel_separation / 2) / wheel_radius 其中 v=线速度,ω=角速度 ``` #### 6.3 启动并控制小车 ```bash # 启动(launch 里加载 diff_drive_controller) ros2 launch diff_drive_robot control.launch.py # 查看控制器 ros2 control list_controllers # diff_drive_controller[DiffDriveController] active # 让小车向前走 1 秒 ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \ "{linear: {x: 0.5}, angular: {z: 0.0}}" --once # 查看里程计 ros2 topic echo /odom # position.x 不断增大,说明底盘在"动"(仿真里程计) # 原地旋转 ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \ "{linear: {x: 0.0}, angular: {z: 0.5}}" --once # /odom 的 yaw 角不断变化 ``` **为什么重要**:`/cmd_vel → /odom` 的闭环,正是建图(第 21 篇)和导航(第 24 篇)的基础。Nav2 发的 `/cmd_vel`,就是被这个 diff_drive_controller 消费的。 ### 七、自定义控制器(原理) 官方控制器不够用时,可以写自定义控制器。核心是继承 `controller_interface::ControllerInterface`,实现四个方法: ```cpp // my_controller.cpp 骨架:自定义控制器 #include #include namespace my_robot { class MyController : public controller_interface::ControllerInterface { public: // 1. 声明参数(如控制增益) controller_interface::CallbackReturn on_init() override { auto_declare>("joints", {}); auto_declare("gain", 1.0); return controller_interface::CallbackReturn::SUCCESS; } // 2. 配置:获取硬件接口引用(命令 + 状态) controller_interface::CallbackReturn on_configure( const rclcpp_lifecycle::State &) override { joint_ = get_node()->get_parameter("joints").as_string_array()[0]; gain_ = get_node()->get_parameter("gain").as_double(); command_itf_ = std::find_if( command_interfaces_.begin(), command_interfaces_.end(), [this](const auto &itf) { return itf.get_interface_name() == "position" && itf.get_prefix_name() == joint_; }); return controller_interface::CallbackReturn::SUCCESS; } // 3. 激活 controller_interface::CallbackReturn on_activate( const rclcpp_lifecycle::State &) override { return controller_interface::CallbackReturn::SUCCESS; } // 4. 控制循环:读取参考,写命令(核心逻辑) controller_interface::return_type update( const rclcpp::Time &, const rclcpp::Duration &) override { // 简化:命令 = 固定值 × 增益(实际应订阅目标并计算) command_itf_->get().set_value(gain_ * 0.5); return controller_interface::return_type::OK; } private: std::string joint_; double gain_; std::vector::iterator command_itf_; }; } // namespace my_robot #include "pluginlib/class_list_macros.hpp" PLUGINLIB_EXPORT_CLASS(my_robot::MyController, controller_interface::ControllerInterface) ``` 配套插件描述文件: ```xml My custom controller ``` 然后在 YAML 中引用: ```yaml controller_manager: ros__parameters: my_controller: type: my_robot/MyController my_controller: ros__parameters: joints: ['joint1'] gain: 2.0 ``` ### 八、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | `controller_manager` 启动报找不到插件 | 插件描述文件未导出 | `pluginlib_export_plugin_description_file(controller_interface my_controller_plugin.xml)` 后重新编译 | | 控制器状态 inactive | 未激活 | `ros2 control load_controller` + `ros2 control switch_controllers --activate` | | 接口名不匹配报错 | URDF 接口与 YAML 接口不一致 | 检查命令/状态接口的名称和类型 | | 关节不动但无报错 | 命令被限制(limit)或控制器未接管 | 检查 joint limit 配置、`ros2 control list_controllers` 状态 | | /odom 无输出 | diff_drive 未激活或参数错误 | 检查 wheel_separation/radius 和命名空间 | | 控制频率太低 | update_rate 太低 | 提高 update_rate(如 200Hz) | ### 九、小结与下一篇预告 **本篇核心**: 1. ros2_control 三大组件:Controller Manager(管家)、Controller(算法)、Hardware Interface(驱动),标准接口解耦硬件与控制。 2. 两类接口:Command Interface(写命令)+ State Interface(读状态),资源接口名格式 `关节/接口`。 3. 控制循环:读状态 → 计算 → 写命令,100Hz 周期驱动。 4. 仿真硬件(mock_components)让无硬件也能跑通:单关节轨迹控制、差速底盘 `/cmd_vel → /odom` 闭环。 至此我们理解了"机器人关节如何被 ROS2 统一管理"。下一篇(第 29 篇)进入 **控制硬件实战(电机驱动,原理)**:从原理到代码,讲透直流电机驱动(H 桥、PWM)、编码器测速、PID 闭环控制,并把它们接到 ros2_control 的硬件接口上,让"纸面上的关节"变成"真正转动的电机"。硬件控制阶段最实战的一篇即将展开。 --- ## ROS2系列教程:MicroROS接入(原理+仿真) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-microros-jie-ru-yuan-li-fang-zhen/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 27 篇 > 本文是 ROS2 系列教程的第 27 篇:MicroROS 接入(原理+仿真)。第 26 篇我们建立了嵌入式开发的基础认知:GPIO、点灯、串口调试。但嵌入式程序是"孤岛"——它怎么和 ROS2 上位机对话?传统做法是自己定义串口协议,麻烦且不通用。ROS2 官方给出的标准答案是 **MicroROS(micro-ROS)**:让单片机直接作为 ROS2 节点接入系统。本篇讲解 MicroROS 的原理、Agent/Client 架构,并用仿真环境完整跑通"单片机节点"与上位机通信的流程,为第 28 篇 ROS2-Control 打基础。 ### 一、为什么需要 MicroROS #### 1.1 传统串口协议的痛点 在第 26 篇我们提到,上位机与下位机最常见的通信方式是**自定义串口协议**:上位机发 `"CMD:FORWARD:SPEED=0.5"`,下位机解析执行。这种方式在简单项目里够用,但有明显问题: | 痛点 | 说明 | | --- | --- | | 协议不通用 | 每家机器人厂商的协议都不同,换硬件就得重写通信层 | | 语义丢失 | 数据帧里没有"话题名、类型、时间戳"等 ROS2 概念 | | 工具链断裂 | 无法用 `ros2 topic`、`rviz2`、`ros2 bag` 直接查看下位机数据 | | 双向开发成本高 | 上位机和下位机各写一套解析代码,容易不一致 | | 无 QoS 支持 | 无法表达"可靠性、易失性"等传输需求 | #### 1.2 MicroROS 的答案:让 MCU 成为 ROS2 节点 MicroROS 是 ROS2 官方的嵌入式方案,核心思想:**在资源极其有限的 MCU 上运行 ROS2 的客户端库(rcl),并通过串口/USB 与上位机上的 micro-ROS Agent 桥接**。对上层 ROS2 系统来说,MCU 就是一个普通节点:能发布话题、订阅话题、调服务、用参数,完全透明。 架构图: ```text ┌─────────────────── 上位机(Linux) ───────────────────┐ │ ROS2 图:/micro_ros_node 发布 /cmd_vel 订阅 /odom │ │ │ │ │ micro-ROS Agent(代理,把串口数据转成 ROS2 DDS) │ └──────────────│────────────────────────────────────────┘ │ 串口 / USB / Wi-Fi(UART/Serial/UDP) ┌──────────────│────────────────────────────────────────┐ │ │ │ │ micro-ROS Client(运行在 MCU 上的 rcl 实现) │ │ MCU:STM32 / ESP32 / Arduino ... │ └───────────────────────────────────────────────────────┘ ``` 关键组件: - **micro-ROS Client**:运行在 MCU 上,是 ROS2 客户端库(rcl)的精简实现(rclc),提供 `rclc_init`、`rclc_node_init`、`rclc_publisher_init` 等 API。代码在 MCU 上编译。 - **micro-ROS Agent**:运行在上位机(或局域网主机),是一个 ROS2 节点,负责与 Client 建立传输通道(串口、USB、Wi-Fi),并把 Client 的请求代理到 ROS2 图。Agent 本质上是"网桥"。 - **传输层**:Client 与 Agent 之间的通信链路,常用 UART(串口)、UDP(Wi-Fi/以太网)。 #### 1.3 MicroROS 与 ROS2 原生节点的关系 MicroROS 节点和原生 ROS2 节点在**图层面完全等价**:`ros2 node list` 能看到它,`ros2 topic echo` 能收它的数据,RViz 能订阅它的消息。区别只在内部实现: | 对比项 | ROS2 原生节点 | MicroROS 节点 | | --- | --- | --- | | 运行环境 | Linux + 完整 DDS 栈 | MCU + 精简 DDS(Micro XRCE-DDS) | | 内存需求 | 数百 MB | 几十 KB | | 实时性 | 一般 | 强 | | 支持的消息类型 | 全部 | 常用类型(可自定义生成) | | 传输 | DDS 网络 | 串口/USB/Wi-Fi 代理 | 理解 MicroROS 的关键一句话:**MicroROS 是"把 ROS2 的接口跑在单片机上的方案",MCU 依然是节点,只是数据通路经过了 Agent 代理**。 ### 二、MicroROS 核心机制(原理) #### 2.1 三层架构 MicroROS 的技术栈从上到下分三层: 1. **应用层**:我们的业务代码,用 rclc API 写,比如 `rclc_publisher_init` 创建一个发布者。 2. **中间层**:rcl(ROS2 客户端库的精简版)→ rmw(ROS2 中间件接口)→ Micro XRCE-DDS(精简 DDS 协议)。 3. **传输层**:XRCE 协议通过 UART/UDP 传输到 Agent。 其中 **Micro XRCE-DDS** 是核心协议:它在 MCU 端维护一个 DDS 客户端的"影子",由 Agent 代为完成 DDS 网络的真正通信。MCU 只管本地逻辑,网络细节全部交给 Agent。 #### 2.2 Agent 的两种模式 Agent 可以运行在: - **本机模式**:Agent 和 ROS2 上位机在同一台机器(如树莓派、Jetson),MCU 通过串口连上位机。最常见。 - **远程模式**:Agent 运行在局域网某台 PC 上,MCU 通过 Wi-Fi(UDP)连到该 PC。适合 MCU 离上位机较远的情况。 无论是哪种模式,对 MCU 代码来说几乎无差别(只是传输参数不同),这正是 MicroROS 设计的巧妙之处。 #### 2.3 生命周期 MicroROS 节点和原生节点一样有生命周期:创建上下文 → 创建节点 → 创建发布者/订阅者 → 进入执行循环(`rclc_executor_spin`)→ 关闭。 ```text rclc_support_init 初始化运行时上下文 ↓ rclc_node_init_default 创建节点 ↓ rclc_publisher_init_* 创建发布者(或订阅者/服务) ↓ rclc_executor_init 创建执行器(绑定节点) ↓ rclc_executor_add_* 注册订阅者回调等 ↓ rclc_executor_spin 循环执行(主循环) ``` ### 三、仿真环境准备 #### 3.1 本教程的仿真思路 在没有真实 MCU 的情况下,怎么学 MicroROS?答案是:**MicroROS 官方提供了"Host 模式"**——在 Linux 上位机上,把 PC 模拟成 MicroROS Client(用 PC 的处理器跑 Client 代码)。这样我们不需要任何硬件,就能: 1. 体验完整的 MicroROS 开发流程(写 Client 代码、编译、运行)。 2. 看到 MCU 节点如何被 Agent 桥接到 ROS2 图。 3. 为后续真机(ESP32/STM32)移植做好代码层面的准备。 #### 3.2 环境要求与安装 仿真需要 Docker(最省事)或手动源码编译。推荐 Docker: ```bash # 1. 拉取 MicroROS Docker 镜像(官方提供 ROS2 Humble 版本) docker pull microros/micro-ros-agent:humble # 2. 安装 micro-ros-agent 到本机(也可在 Docker 内运行) # 方式 A:Docker 运行(推荐,隔离环境) docker run -it --rm -v /dev:/dev --privileged --net=host \ microros/micro-ros-agent:humble serial --dev /dev/ttyUSB0 -b 115200 # 方式 B:本机源码安装 # 建工作空间,clone micro-ros-agent 源码后 colcon build mkdir -p ~/micro_ros_agent_ws/src && cd ~/micro_ros_agent_ws git clone https://github.com/micro-ROS/micro-ROS-Agent.git src/micro-ROS-Agent colcon build source install/setup.bash ``` #### 3.3 准备 micro_ros_agent 工作空间 在 Docker 之外,我们还需要在**本机 ROS2 环境**里安装 micro-ros-agent 的包,这样 `ros2 run micro_ros_agent micro_ros_agent` 命令才可用。完整流程(Humble): ```bash # 安装依赖 sudo apt update sudo apt install -y python3-pip python3-rosdep # 创建工作空间并克隆 mkdir -p ~/micro_ros_agent_ws/src && cd ~/micro_ros_agent_ws git clone -b humble https://github.com/micro-ROS/micro-ROS-Agent.git src/micro-ROS-Agent # 安装依赖并编译(注意 source 环境) source /opt/ros/humble/setup.bash rosdep install --from-paths src --ignore-src -y colcon build source install/setup.bash # 验证 ros2 run micro_ros_agent micro_ros_agent serial --help ``` ### 四、示例 1:启动 micro-ROS Agent Agent 是 MicroROS 通信的"桥梁",必须最先启动。它本质是一个 ROS2 节点,所以可以和其他节点一样用 `ros2 run` 启动。 ```bash # 终端 1:启动 ROS2 环境 source /opt/ros/humble/setup.bash # 启动 Agent(本机模式,串口连接,波特率 115200) ros2 run micro_ros_agent micro_ros_agent serial --dev /dev/ttyUSB0 -b 115200 # 如果使用 UDP 模式(Wi-Fi 连接),用: # ros2 run micro_ros_agent micro_ros_agent udp4 --port 8888 ``` 启动后终端会显示等待 Client 连接的信息。验证 Agent 节点是否出现在 ROS2 图: ```bash # 终端 2:查看节点列表,应能看到 /micro_ros_agent ros2 node list # 预期输出包含: # /micro_ros_agent ``` 小技巧:Agent 支持同时连接多个 Client,每个 Client 有自己的命名空间,方便区分多个下位机。 ### 五、示例 2:Host 模式运行 Client(PC 模拟 MCU) 这是本篇的**核心仿真实验**:在没有硬件的 PC 上,运行一个 MicroROS Client 节点,让它与 Agent 建立连接,成为 ROS2 图里的一员。 #### 5.1 准备 Client 编译环境 MicroROS Client 代码用 C 编写,使用 micro-ROS 提供的构建工具链编译。官方提供 `micro_ros_setup` 或 Docker 镜像: ```bash # 拉取 Client 编译镜像(Humble 版本) docker pull microros/micro-ros-agent:humble # 或者用 micro-ros-setup 脚本(推荐在 Docker 里) docker run -it --rm -v $(pwd):/work --net=host \ microros/micro-ros-agent:humble bash ``` #### 5.2 编写 Client 代码:发布者(心跳节点) 在容器内创建源码文件 `heartbeat_pub.c`,这是一个"心跳发布者":定时向话题 `/heartbeat` 发布一条消息,模拟 MCU 上报状态。 ```c // heartbeat_pub.c:MicroROS 心跳发布者(模拟 MCU 节点) #include #include #include #include #define RCCHECK(fn) { rcl_ret_t temp_rc = fn; \ if ((temp_rc != RCL_RET_OK)) { for (;;) {} } } #define RCSOFTCHECK(fn) { rcl_ret_t temp_rc = fn; \ if ((temp_rc != RCL_RET_OK)) {} } rcl_publisher_t publisher; std_msgs__msg__Int32 msg; // 定时回调:心跳值加 1 void timer_callback(rcl_timer_t *timer, int64_t last_call_time) { (void) last_call_time; if (timer != NULL) { msg.data++; rcl_publish(&publisher, &msg, NULL); printf("heartbeat: %d\n", msg.data); } } int main() { rcl_allocator_t allocator = rcl_get_default_allocator(); rclc_support_t support; rcl_init_options_t init_options = rcl_get_zero_initialized_init_options(); RCCHECK(rcl_init_options_init(&init_options, rcl_get_default_allocator())); RCCHECK(rclc_support_init_with_options(&support, 0, NULL, &init_options, &allocator)); // 创建节点 rcl_node_t node; RCCHECK(rclc_node_init_default(&node, "heartbeat_node", "", &support)); // 创建发布者 RCCHECK(rclc_publisher_init_default( &publisher, &node, ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32), "/heartbeat")); // 创建定时器(500ms 触发一次) rcl_timer_t timer; RCCHECK(rclc_timer_init_default( &timer, &support, RCL_MS_TO_NS(500), timer_callback)); // 创建执行器并注册定时器 rclc_executor_t executor; RCCHECK(rclc_executor_init(&executor, &support.context, 1, &allocator)); RCCHECK(rclc_executor_add_timer(&executor, &timer)); printf("heartbeat_node started, publishing to /heartbeat\n"); // 主循环 for (;;) { rclc_executor_spin_some(&executor, RCL_MS_TO_NS(100)); } RCCHECK(rcl_publisher_fini(&publisher, &node)); RCCHECK(rcl_node_fini(&node)); return 0; } ``` #### 5.3 编译并运行 在 Docker 容器内编译(Host 模式编译成 PC 可执行文件): ```bash # 在容器内,micro_ros_agent 镜像自带构建脚本 # 以 Host 模式构建 source /uros_ws/install/setup.bash colcon build --packages-select micro_ros_agent # 构建 Agent(可选) # 编译上面的 C 文件(需要链接 micro_ros_client 库) # 更简单的方式:使用官方 micro_ros_static_library 构建脚本 # 假设已在 /uros_ws 下创建了 microros 静态库 gcc heartbeat_pub.c -o heartbeat_pub \ -I/uros_ws/install/include \ -L/uros_ws/lib -lmicroros ``` 对于没有 Docker 环境的朋友,官方还提供了 **micro_ros_utilities** 静态库生成脚本,这里不展开,重点是理解流程。 #### 5.4 在 ROS2 侧验证 Host 模式的 Client 运行时,会在本机直接连接 Agent(或通过 UDP 连接远程 Agent): ```bash # 终端 1:启动 Agent(UDP 模式,因为 Host 模式走 UDP) ros2 run micro_ros_agent micro_ros_agent udp4 --port 8888 # 终端 2:运行 Client(在编译出 heartbeat_pub 的容器/目录下) ./heartbeat_pub # 终端 3:验证 ROS2 侧 ros2 node list # 应看到:/micro_ros_agent 和 /heartbeat_node ros2 topic list # 应看到:/heartbeat ros2 topic echo /heartbeat std_msgs/msg/Int32 # 应不断打印:data: 1, data: 2, data: 3 ... ``` **这一刻的意义**:一个"单片机"(其实是 PC 模拟的)通过 Agent 成功接入 ROS2 图,其他节点可以直接订阅它的数据。这就是 MicroROS 的核心价值——换到真实 MCU 上,代码逻辑完全一样,只是编译目标不同。 ### 六、示例 3:订阅者(接收上位机指令) 有发布就要有订阅。MCU 上最常见的场景:订阅上位机发来的控制指令。这里模拟"MCU 收到速度指令后打印执行"。 ```c // cmd_sub.c:MicroROS 订阅者(接收 /cmd_vel 指令,模拟执行) #include #include #include #include #define RCCHECK(fn) { rcl_ret_t temp_rc = fn; \ if ((temp_rc != RCL_RET_OK)) { for (;;) {} } } rcl_subscription_t subscriber; geometry_msgs__msg__Twist msg; // 订阅回调:收到速度指令后"执行"(这里打印) void subscription_callback(const void *msgin) { const geometry_msgs__msg__Twist *twist = msgin; printf("exec cmd_vel: linear=%.2f angular=%.2f\n", twist->linear.x, twist->angular.z); } int main() { rcl_allocator_t allocator = rcl_get_default_allocator(); rclc_support_t support; RCCHECK(rclc_support_init(&support, 0, NULL, &allocator)); rcl_node_t node; RCCHECK(rclc_node_init_default(&node, "cmd_sub_node", "", &support)); RCCHECK(rclc_subscription_init_default( &subscriber, &node, ROSIDL_GET_MSG_TYPE_SUPPORT(geometry_msgs, msg, Twist), "/cmd_vel")); rclc_executor_t executor; RCCHECK(rclc_executor_init(&executor, &support.context, 1, &allocator)); RCCHECK(rclc_executor_add_subscription( &executor, &subscriber, &msg, &subscription_callback, ON_NEW_DATA)); printf("cmd_sub_node started, waiting for /cmd_vel...\n"); for (;;) { rclc_executor_spin_some(&executor, RCL_MS_TO_NS(100)); } RCCHECK(rcl_subscription_fini(&subscriber, &node)); RCCHECK(rcl_node_fini(&node)); return 0; } ``` 验证(注意:`ON_NEW_DATA` 是订阅者回调触发模式,表示有新消息才回调): ```bash # 终端 1:启动 Agent ros2 run micro_ros_agent micro_ros_agent udp4 --port 8888 # 终端 2:运行订阅者 Client ./cmd_sub # 终端 3:用 ros2 发送速度指令(模拟上位机控制) ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \ "{linear: {x: 0.5, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.3}}" \ --once # Client 侧应打印: # exec cmd_vel: linear=0.50 angular=0.30 ``` 这个实验展示了 MCU 最常见的角色:**执行上位机的指令**。后面 ROS2-Control(第 28 篇)的硬件接口,本质就是这个模式——只是把"打印"换成"给电机 PWM"。 ### 七、示例 4:服务端(MCU 提供服务) 有些场景下 MCU 不只是执行者,还要**提供服务**:比如"获取电池电量"“设置 LED 状态”。MicroROS 也支持服务端。 ```c // led_srv.c:MicroROS 服务端(设置 LED 状态的服务) #include #include #include #include #define RCCHECK(fn) { rcl_ret_t temp_rc = fn; \ if ((temp_rc != RCL_RET_OK)) { for (;;) {} } } rcl_service_t service; example_interfaces__srv__SetBool_Request req; example_interfaces__srv__SetBool_Response res; // 服务回调:处理"设置 LED"请求 void service_callback(const void *request, void *response) { const example_interfaces__srv__SetBool_Request *r = request; example_interfaces__srv__SetBool_Response *s = response; s->success = true; if (r->data) { s->message = "led on"; // 真实 MCU 这里调用 HAL_GPIO_WritePin } else { s->message = "led off"; } printf("set led: %s\n", s->message); } int main() { rcl_allocator_t allocator = rcl_get_default_allocator(); rclc_support_t support; RCCHECK(rclc_support_init(&support, 0, NULL, &allocator)); rcl_node_t node; RCCHECK(rclc_node_init_default(&node, "led_srv_node", "", &support)); RCCHECK(rclc_service_init_default( &service, &node, ROSIDL_GET_SRV_TYPE_SUPPORT(example_interfaces, srv, SetBool), "/set_led")); rclc_executor_t executor; RCCHECK(rclc_executor_init(&executor, &support.context, 1, &allocator)); RCCHECK(rclc_executor_add_service( &executor, &service, &req, &res, service_callback)); printf("led_srv_node started, service /set_led ready\n"); for (;;) { rclc_executor_spin_some(&executor, RCL_MS_TO_NS(100)); } return 0; } ``` 验证: ```bash # 终端 2:运行 Client ./led_srv # 终端 3:调用服务(模拟上位机请求) ros2 service call /set_led example_interfaces/srv/SetBool "{data: true}" # 响应:success: true, message: "led on" # 也可以服务发现 ros2 service list # 应包含:/set_led ``` 注意 MicroROS 服务端与原生的一个差异:**请求/响应消息内存需要在代码里静态分配**(`req`、`res` 变量),因为 MCU 没有动态内存管理。这是嵌入式开发的核心思维——一切资源显式管理。 ### 八、MicroROS 与真机的桥接思路 #### 8.1 从 Host 模式到真机 Host 模式跑通后,移植到真机(如 ESP32)只需两步: 1. **换编译目标**:用 micro_ros_espidf 组件或 PlatformIO 的 micro-ROS 支持,把同样的 C 代码交叉编译成 ESP32 固件。 2. **换传输层**:Host 模式用 UDP,真机用 UART(串口)或 Wi-Fi UDP,修改 Agent 启动参数和 Client 的传输配置即可。 ```bash # 真机场景(ESP32 + 串口连接) ros2 run micro_ros_agent micro_ros_agent serial --dev /dev/ttyUSB0 -b 115200 # 真机场景(ESP32 + Wi-Fi UDP) ros2 run micro_ros_agent micro_ros_agent udp4 --port 8888 ``` #### 8.2 消息类型限制与自定义 MicroROS 默认支持常用消息类型(std_msgs、geometry_msgs、sensor_msgs 等)。自定义消息需要先在本机生成 micro-ROS 消息包,再交叉编译进固件。流程: ```bash # 生成自定义消息的 micro-ROS 版本 docker run -it --rm -v $(pwd):/work microros/micro-ros-agent:humble bash # 容器内:用 ros2 的 msg 生成 + micro_ros_msgs 工具链 # 然后 colcon build 生成静态库,链接进固件 ``` #### 8.3 时序与实时性注意事项 MCU 上做 MicroROS 要特别留意: - **回调里禁止阻塞**:`rclc_executor_spin_some` 是单线程轮询,回调里若做耗时操作(如大延时),会阻塞其他订阅/定时器。 - **内存预算**:每个发布者/订阅者的消息缓冲区都是静态分配的,注意栈空间。 - **断线重连**:串口/USB 断开后 Client 需要重新建立会话,生产代码要处理重连逻辑。 ### 九、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | Client 连不上 Agent | 传输参数不匹配(端口/波特率) | 两端统一端口(8888)或波特率(115200) | | `ros2 node list` 看不到 Client | Agent 没启动 / 会话未建立 | 先启动 Agent,看 Agent 日志是否"create session" | | 话题收到了但数据为 0 | 消息初始化未完成 | Client 端先 `msg.data = 0` 初始化再发布 | | 服务调用超时 | 服务端未注册 / 类型不匹配 | 检查服务名、类型、命名空间 | | 编译报 memory 不足 | 静态缓冲过大 | 减小消息队列长度、消息类型换小 | | 断开后无法重连 | Client 未处理会话关闭 | 实现重连逻辑,重新 `rclc_support_init` | ### 十、小结与下一篇预告 **本篇核心**: 1. MicroROS = 让 MCU 直接作为 ROS2 节点,通过 Agent 桥接进 ROS2 图,无需自定义协议。 2. 架构三件套:Client(MCU 端 rcl 精简实现)+ Agent(上位机代理)+ 传输层(串口/UDP)。 3. Host 模式仿真:PC 模拟 MCU,完整跑通 发布/订阅/服务 三种通信,为真机移植铺路。 4. 真机移植 = 换编译目标 + 换传输层,业务代码几乎不变。 至此我们理解了"单片机如何接入 ROS2"。下一篇(第 28 篇)进入 **ROS2-Control 与控制器(原理)**:ROS2 官方如何统一管理机器人关节控制(diff_drive_controller、Joint Trajectory Controller),以及它和 MicroROS 的分工——上位机负责"想",下位机负责"动",中间用标准接口衔接。硬件控制阶段最硬核的部分即将展开。 --- ## ROS2系列教程:嵌入式开发从点灯开始(原理) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-qian-ru-shi-kai-fa-cong-dian-deng/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 26 篇 > 本文是 ROS2 系列教程的第 26 篇:嵌入式开发从点灯开始(原理)。第 25 篇我们完成了 Nav2 导航阶段(21-25)的全部内容,前面的教程全都是在仿真环境里运行:Gazebo 里的小车、RViz 里的模型,都是"虚拟的"。但真实的机器人要动起来,必须有硬件:单片机控制电机、传感器采集数据、串口与上位机通信。从本篇开始进入**硬件控制阶段**(第 26-30 篇),从嵌入式开发的最基础操作——“点灯”(GPIO 控制 LED)讲起,建立嵌入式开发的完整认知,为后续 MicroROS、ROS2-Control 打下基础。 ### 一、为什么机器人需要嵌入式开发 #### 1.1 上位机与下位机的分工 一台完整的机器人,通常由两大部分组成: - **上位机**:性能更强的处理器,通常运行 Linux 和 ROS2,负责感知融合、路径规划、人机交互等"大脑级"工作。常见有树莓派、Jetson Nano、工控机、x86 迷你主机。 - **下位机**:嵌入式微控制器(MCU),负责电机驱动、传感器采集、执行器等"肌肉级"工作。常见有 STM32、ESP32、Arduino。 为什么要分工?因为两者的强项完全不同: | 对比项 | 上位机(Linux + ROS2) | 下位机(MCU) | | --- | --- | --- | | 计算能力 | 强,可跑复杂算法 | 弱,只适合简单逻辑 | | 实时性 | 一般(非实时系统) | 强(可做硬实时控制) | | 功耗 | 高 | 极低 | | 启动时间 | 秒级 | 毫秒级 | | 稳定性 | 依赖系统环境 | 极可靠,不易死机 | | 成本 | 高 | 低 | 真实机器人常见的架构:上位机跑 ROS2,通过串口 / USB / CAN 与下位机通信,下位机直接控制电机和读取传感器。这种"大脑 + 肌肉"的分工,是机器人工程的主流做法。 #### 1.2 为什么从"点灯"开始 几乎所有嵌入式开发教程的第一个程序都是"点灯"(控制 LED 亮灭),原因很朴素: 1. 点灯用到了嵌入式开发最核心的资源:**GPIO(通用输入输出引脚)**。 2. 点灯的程序虽然简单,却覆盖了完整的开发流程:写代码 → 编译 → 下载(烧录)→ 运行 → 调试。 3. 任何复杂的机器人控制(电机 PWM、舵机、传感器读取),本质都是 GPIO 或基于 GPIO 扩展的外设操作。 可以说:**会点灯,就摸到了嵌入式的大门**。 ### 二、认识单片机(MCU) #### 2.1 单片机是什么 单片机(Microcontroller Unit,MCU)是把 CPU、内存、Flash 存储、各种外设(GPIO、定时器、串口、ADC 等)集成在一个芯片上的微型计算机。它不像电脑需要外接内存和硬盘,一个芯片就是一个完整的微型系统。 在 ROS2 机器人领域,最常见的有两类: - **STM32**:ARM Cortex-M 内核,性能和生态都非常优秀,是机器人底盘、机械臂控制器的主流选择。使用 C 语言 + HAL 库或 LL 库开发。 - **ESP32**:集成了 Wi-Fi 和蓝牙,价格便宜,适合做无线通信节点(如遥控手柄、传感器节点)。支持 C/C++(ESP-IDF、Arduino)和 MicroPython。 还有一类特殊的存在:**Arduino**。Arduino 严格说是"单片机 + 开发板 + 简化框架"的完整生态,封装极简,初学者最容易上手,很多 ROS 机器人项目(如 TurtleBot 的电机板)也用 Arduino。 #### 2.2 开发板与芯片的关系 开发板(如 STM32F103C8T6 最小系统板、ESP32-DevKitC)是在芯片周围加上电源电路、晶振、USB 接口、调试接口等,方便我们直接使用。开发时我们面对的是"板子",但编程时操作的是"芯片内部的寄存器与外设"。 一个典型的 STM32 最小系统板包含: | 组成部分 | 作用 | | --- | --- | | STM32 芯片 | 核心处理单元 | | 晶振 | 提供时钟基准 | | USB 接口 | 供电 + 下载程序 + 虚拟串口 | | 复位按键 | 复位程序 | | BOOT 跳线 | 选择启动模式 | | LED(通常 PC13 或 PA1) | 点灯实验的标准对象 | #### 2.3 开发流程总览 不管用哪家芯片,嵌入式开发流程都一样: ```text 编写代码(C/C++/MicroPython) ↓ 编译(把源码变成机器码) ↓ 烧录(把机器码下载进芯片 Flash) ↓ 运行(上电后从 Flash 执行程序) ↓ 调试(串口打印、调试器断点) ``` 对应到工具上:STM32 用 STM32CubeIDE 或 VS Code + arm-none-eabi-gcc,ESP32 用 ESP-IDF 或 Arduino IDE,MicroPython 用 Thonny。 ### 三、GPIO 与点灯原理 #### 3.1 GPIO 是什么 GPIO(General Purpose Input/Output,通用输入输出)是单片机上最基础也最重要的引脚资源。每个 GPIO 引脚都可以配置为: - **输出模式**:引脚输出高电平(如 3.3V)或低电平(0V),用来驱动 LED、继电器、蜂鸣器。 - **输入模式**:读取引脚电平(高或低),用来读取按键、传感器开关信号。 - **复用功能**:引脚被分配给串口、SPI、I2C、PWM 定时器等外设使用。 点灯实验,就是把某个引脚配置为**推挽输出**,然后交替输出高低电平,LED 就闪烁了。 #### 3.2 LED 与限流电阻 LED(发光二极管)是有极性的元件:长脚是阳极(接正极),短脚是阴极(接负极或地)。LED 导通电压约 1.8-2.2V,工作电流约 5-20mA。如果直接接 3.3V 电源,电流过大,LED 会烧毁,所以**必须串联一个限流电阻**(通常 220Ω-1kΩ)。 电路很简单: ```text GPIO 引脚 ----[限流电阻 R]----|>|---- GND LED(阳极朝 GPIO 侧) ``` 电阻阻值计算:`R = (Vcc - Vled) / I`,例如 3.3V 供电、LED 压降 2V、电流 10mA:`R = (3.3 - 2) / 0.01 = 130Ω`,取常见值 220Ω 即可。 #### 3.3 点灯的完整逻辑 ```text 初始化 GPIO(使能时钟、配置模式为输出、设置速度) ↓ 循环: 输出高电平(LED 亮) 延时 500ms 输出低电平(LED 灭) 延时 500ms ``` 这段逻辑,无论是 STM32 的 HAL 库、ESP32 的 Arduino 框架还是 MicroPython,都是一样的,只是 API 不同。 ### 四、示例 1:STM32 寄存器版点灯(原理级) 在理解 HAL 库之前,先看**寄存器版**——直接操作芯片寄存器,这是最底层、最能理解原理的方式。以 STM32F103(Cortex-M3)为例,LED 接在 PC13 引脚(很多最小系统板板载 LED 就是 PC13)。 STM32 的 GPIO 操作分三步:使能 GPIOC 时钟 → 配置 PC13 为输出 → 写数据寄存器。 ```c // 寄存器版点灯:直接操作 STM32F103 寄存器 #include "stm32f10x.h" // 简化的寄存器地址定义(实际工程在头文件中已定义) // RCC 时钟控制寄存器 #define RCC_APB2ENR (*(volatile unsigned int *)0x40021018) // GPIOC 相关寄存器 #define GPIOC_CRH (*(volatile unsigned int *)0x40011004) // 配置寄存器高 #define GPIOC_ODR (*(volatile unsigned int *)0x4001100C) // 输出数据寄存器 // 简易延时(不精确,仅示意) void delay(volatile unsigned int n) { while (n--) { __asm volatile("nop"); } } int main(void) { // 1. 使能 GPIOC 时钟(RCC_APB2ENR 的 bit4 = IOPCEN) RCC_APB2ENR |= (1 << 4); // 2. 配置 PC13 为推挽输出,最大速度 2MHz // PC13 属于 CRH(引脚 8-15),每引脚占 4 位 // 模式位(MODE)=0b10(输出2MHz),配置位(CNF)=0b00(推挽输出) GPIOC_CRH &= ~(0xF << (4 * (13 - 8))); // 先清空 PC13 的 4 位配置 GPIOC_CRH |= (0x2 << (4 * (13 - 8))); // 设为推挽输出 while (1) { GPIOC_ODR |= (1 << 13); // PC13 输出高电平,LED 灭(板载 LED 低电平点亮) delay(500000); GPIOC_ODR &= ~(1 << 13); // PC13 输出低电平,LED 亮 delay(500000); } } ``` 这段代码的价值在于:它展示了 GPIO 控制的**三个本质步骤**——使能时钟、配置模式、读写数据。HAL 库不过是把这些操作封装成了更友好的函数。 ### 五、示例 2:STM32 HAL 库点灯(工程级) 寄存器直接操作虽然原理清晰,但工程开发中我们使用 HAL 库(Hardware Abstraction Layer),它把寄存器操作封装成函数,可读性和可移植性更好。这是目前 STM32 开发的主流方式。 ```c // STM32 HAL 库点灯(CubeMX 生成的工程,仅保留核心逻辑) #include "main.h" // 延时 500ms void delay_ms(uint32_t ms) { HAL_Delay(ms); } int main(void) { // 1. 初始化系统时钟(CubeMX 自动生成 SystemClock_Config) HAL_Init(); SystemClock_Config(); // 2. 初始化 GPIO(CubeMX 生成的 MX_GPIO_Init) MX_GPIO_Init(); // 内部实现大致是: // __HAL_RCC_GPIOC_CLK_ENABLE(); // 使能时钟 // GPIO_InitStruct.Pin = GPIO_PIN_13; // GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出 // GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // HAL_GPIO_Init(GPIOC, &GPIO_InitStruct); while (1) { HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); // 低电平,LED 亮 delay_ms(500); HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); // 高电平,LED 灭 delay_ms(500); } } ``` 对比寄存器版可以看出:HAL 库把"使能时钟 + 配置模式 + 读写数据"封装成了 `HAL_GPIO_Init` 和 `HAL_GPIO_WritePin`,逻辑完全一样,但代码更清晰、跨芯片移植更容易(换芯片只需重新配置,不用重写逻辑)。 ### 六、示例 3:ESP32 Arduino 点灯(C++ 风格) ESP32 是 ROS2 机器人项目中很常见的无线节点。用 Arduino 框架开发时,代码风格是 C++,API 极其简洁,`digitalWrite` 一行就能控制引脚。 ```cpp // ESP32 Arduino 框架点灯(Arduino IDE 或 PlatformIO) // LED 接 GPIO2(很多 ESP32 开发板板载 LED 在 GPIO2) #define LED_PIN 2 void setup() { // 配置 GPIO2 为输出模式 pinMode(LED_PIN, OUTPUT); // 初始化串口,便于调试 Serial.begin(115200); Serial.println("ESP32 LED blink start"); } void loop() { // 输出高电平,LED 亮 digitalWrite(LED_PIN, HIGH); delay(500); // 延时 500ms // 输出低电平,LED 灭 digitalWrite(LED_PIN, LOW); delay(500); } ``` 在 PlatformIO 工程中,还可以用 C++ 类封装 LED,方便复用: ```cpp // led.hpp:可复用的 LED 类 #ifndef LED_HPP #define LED_HPP #include class Led { public: Led(uint8_t pin, bool active_high = true) : pin_(pin), active_high_(active_high) {} void init() { pinMode(pin_, OUTPUT); off(); } void on() { digitalWrite(pin_, active_high_ ? HIGH : LOW); } void off() { digitalWrite(pin_, active_high_ ? LOW : HIGH); } void toggle() { digitalWrite(pin_, !digitalRead(pin_)); } private: uint8_t pin_; bool active_high_; }; // main.cpp Led led(2); // GPIO2 上接 LED,高电平点亮 int state = 0; void setup() { led.init(); } void loop() { state = !state; if (state) led.on(); else led.off(); delay(500); } #endif ``` 这个类的价值在于:`init/on/off/toggle` 的接口设计,和后面 ROS2 驱动里"控制 LED/指示灯的节点"接口天然一致——机器人上层只关心"开/关/翻转",不关心具体引脚。 ### 七、示例 4:MicroPython 点灯(Python 风格) MicroPython 让 Python 直接运行在单片机上,语法简单,非常适合快速原型验证。ESP32 和部分 STM32 都支持 MicroPython。 ```python # MicroPython 点灯(Thonny 编辑器运行,ESP32 为例) from machine import Pin from time import sleep # 配置 GPIO2 为输出模式 led = Pin(2, Pin.OUT) print("MicroPython LED blink start") while True: led.value(1) # 输出高电平,LED 亮 sleep(0.5) # 延时 500ms led.value(0) # 输出低电平,LED 灭 sleep(0.5) ``` MicroPython 还有更"Python 风格"的写法,用定时器和回调: ```python from machine import Pin, Timer led = Pin(2, Pin.OUT) def blink(timer): led.toggle() # 翻转电平 # 创建定时器,每 500ms 触发一次回调 timer = Timer(0) timer.init(period=500, mode=Timer.PERIODIC, callback=blink) ``` 注意:回调里不要做耗时操作(如 sleep),否则会阻塞其他任务。这种"定时器回调"的思想,和 ROS2 里 `create_timer` 定时器极其相似——理解一个,另一个就通了。 对于 ROS2 开发者来说,MicroPython 是理解"Python 在硬件上怎么跑"的最快路径,适合做传感器快速验证。 ### 八、串口与调试(嵌入式开发必备技能) #### 8.1 串口是什么 串口(UART)是嵌入式最常用的调试和通信手段:单片机把调试信息通过串口发送给电脑,电脑用串口助手或 `minicom` 查看。在 ROS2 机器人里,串口也是上位机与下位机通信的主要方式(下一阶段会大量使用)。 串口通信的核心参数: | 参数 | 常见值 | 说明 | | --- | --- | --- | | 波特率 | 115200 | 每秒传输的比特数,两端必须一致 | | 数据位 | 8 | 每个字节 8 位数据 | | 停止位 | 1 | 帧结束标志 | | 校验位 | None | 一般不使用 | #### 8.2 串口打印调试信息 STM32 HAL 库重定向 printf 到串口: ```c // 重定向 printf 到 USART1(需要先初始化 USART1) #include int __io_putchar(int ch) { // 向 USART1 发送一个字节(HAL 库方式) HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); return ch; } // 使用示例 int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE); // 使能接收中断(可选) int count = 0; while (1) { printf("hello from stm32, count=%d\r\n", count++); HAL_Delay(1000); } } ``` MicroPython 就简单得多,直接用 `print`: ```python from machine import Pin, UART import time uart = UART(1, baudrate=115200) # 初始化串口 led = Pin(2, Pin.OUT) while True: led.toggle() uart.write("led toggled\r\n") # 发送到串口 print("count:", time.ticks_ms()) # print 也会走 REPL 串口 time.sleep(1) ``` **调试铁律**:嵌入式程序"看不到运行状态",串口打印是观察程序行为的眼睛。遇到问题先加打印,再定位。 #### 8.3 常见串口问题 - 波特率不匹配:电脑显示乱码。检查两端波特率是否一致。 - 串口号不对:Windows 下需要安装 CH340/CP2102 驱动,查看设备管理器确定串口号。 - TX/RX 接反:下位机 TX 接上位机 RX,下位机 RX 接上位机 TX,交叉连接。 - 共地:两个设备必须共地(GND 相连),否则通信不稳定。 ### 九、嵌入式与 ROS2 的关系 #### 9.1 嵌入式在 ROS2 生态中的位置 ROS2 本身运行在 Linux 上位机上,但 ROS2 机器人离不开嵌入式: ```text ROS2 上位机(大脑) ↕ 串口 / USB / CAN / Wi-Fi 嵌入式 MCU(肌肉) ↕ GPIO / PWM / ADC / 串口 电机、舵机、传感器(肢体) ``` 嵌入式负责最底层的"电"的层面:给电机 PWM 信号、读编码器计数、采集 IMU 数据。ROS2 负责"信息"的层面:状态估计、路径规划、任务调度。 #### 9.2 上位机如何"指挥"下位机 最常见的模式是**串口协议**:上位机通过串口发送命令帧(如 `"CMD:FORWARD:SPEED=0.5"`),下位机解析后执行电机控制;下位机定时回传传感器数据帧(如 `"SENSOR:ENCODER=1234"`)。后面第 29-30 篇会详细实现这套协议。 #### 9.3 MicroROS 预告 ROS2 官方提供了 **MicroROS** 方案:让 MCU 也能直接作为 ROS2 节点(通过串口/USB 与上位机的 micro-ROS Agent 桥接)。这样下位机可以直接发布/订阅话题,不用自己写协议。这是第 27 篇的主题,本篇先打好嵌入式基础。 ### 十、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 程序烧录不进芯片 | 未进 Boot 模式 / 驱动未装 / 接线错误 | 检查 BOOT0 跳线、安装驱动、检查 SWD/USB 接线 | | 下载成功但 LED 不亮 | 引脚配错 / LED 极性接反 / 未使能时钟 | 核对原理图引脚、调换 LED 方向、检查时钟使能代码 | | 串口乱码 | 波特率不一致 | 两端统一 115200 | | 编译报错 undefined reference | 链接库缺失 | 检查 Makefile/CMake 是否正确链接 HAL 库 | | 程序运行不稳定、偶尔复位 | 电源供电不足 / 干扰 | 外部供电(勿只靠 USB)、加滤波电容 | | 寄存器操作没反应 | 时钟未使能就操作外设 | 先使能对应外设时钟再配置寄存器 | ### 十一、小结与下一篇预告 **本篇核心**: 1. 机器人 = 上位机(ROS2)+ 下位机(MCU)分工协作,嵌入式负责最底层的电机、传感器控制。 2. 点灯是嵌入式开发的"Hello World",本质是 GPIO 的时钟使能 → 模式配置 → 电平输出三步。 3. 四种点灯方式:STM32 寄存器版(原理)、STM32 HAL 库(工程)、ESP32 Arduino(C++ 风格)、MicroPython(Python 风格),逻辑完全一致,只是 API 不同。 4. 串口是嵌入式调试的必备手段,也是后续上位机/下位机通信的基础,务必掌握波特率、接线、乱码排查。 至此 **硬件控制阶段(26-30)正式开启**。下一篇(第 27 篇)进入 **MicroROS 接入(原理+仿真)**:让单片机直接变成 ROS2 节点,用 ROS2 的话题和服务去控制 LED、读取传感器。我们会先用仿真环境跑通 MicroROS 完整流程,再为真机接入做准备。嵌入式 + ROS2 的奇妙组合,即将展开。 --- ## ROS2系列教程:Nav2进阶实践(自定义算法、插件与行为树) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-nav2-jin-jie-shi-jian-zi-ding-yi/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 25 篇 > 本文是 ROS2 系列教程的第 25 篇:Nav2 进阶实践(自定义算法、插件与行为树)。第 24 篇 Nav2 已经能带着小车跑起来了,但用的是默认插件(NavFn 全局规划 + DWB 局部规划 + 标准行为树)。真实项目几乎都要定制:换更懂场景的规划器、加自定义恢复行为、改导航流程。Nav2 的插件化架构就是为这个设计的。本篇拆解 Nav2 插件机制、手写一个自定义控制器插件的完整骨架、定制行为树 XML,以及如何把自定义插件注册进 Nav2——从此 Nav2 不再是"黑盒",而是你的工具箱。 ### 一、Nav2 的插件机制 #### 1.1 插件化思想 Nav2 的每个 Server 都通过 **pluginlib** 加载算法插件。你换算法 = 改配置 + 提供插件库,不用动 Nav2 本体: ``` planner_server: GridBased → NavFn / SmacPlanner / 你的算法 controller_server: FollowPath → DWB / RegulatedPurePursuit / 你的算法 behavior_server: spin/back_up/drive_on_heading/wait(可增删) costmap: obstacle/inflation/static(可增删) ``` #### 1.2 插件三要素 一个 Nav2 插件 = 继承基类 + 注册宏 + 插件描述文件(.xml): | 要素 | 说明 | 文件 | | --- | --- | --- | | 继承 | 继承对应算法基类并实现接口 | `controller.hpp` | | 注册 | `PLUGINLIB_EXPORT_CLASS` 导出 | `controller.cpp` | | 描述 | `nav2_my_controller.xml` 声明插件 | `plugin_description.xml` | #### 1.3 pluginlib 原理 - 插件库是动态库(`.so`),运行时按名字加载。 - `nav2_core::Controller` 等基类定义接口,实现者注册类名。 - YAML 里 `plugin: "nav2_my_controller/MyController"` 让 Nav2 找到并实例化。 ### 二、自定义局部控制器(完整骨架) #### 2.1 头文件 controller.hpp ```cpp // controller.hpp #ifndef NAV2_MY_CONTROLLER__CONTROLLER_HPP_ #define NAV2_MY_CONTROLLER__CONTROLLER_HPP_ #include #include "nav2_core/controller.hpp" #include "rclcpp/rclcpp.hpp" #include "geometry_msgs/msg/twist.hpp" namespace nav2_my_controller { // 继承 nav2_core::Controller,实现 5 个纯虚接口 class MyController : public nav2_core::Controller { public: MyController() = default; ~MyController() override = default; // 1. 配置:从参数获取配置 void configure( const rclcpp_lifecycle::LifecycleNode::WeakPtr & parent, std::string name, const std::shared_ptr & tf, const std::shared_ptr & costmap_ros) override; // 2. 清理与激活 void cleanup() override; void activate() override; void deactivate() override; // 3. 核心:计算速度指令(每 control cycle 调用) geometry_msgs::msg::Twist computeVelocityCommands( const geometry_msgs::msg::PoseStamped & pose, const geometry_msgs::msg::Twist & velocity, nav2_core::GoalChecker * goal_checker) override; // 4. 设置/获取规划路径 void setPlan(const nav_msgs::msg::Path & path) override; nav_msgs::msg::Path getPlan() override { return plan_; } private: double max_vel_x_; double max_vel_theta_; double k_linear_; // 比例系数 double k_angular_; nav_msgs::msg::Path plan_; // 保存全局路径 rclcpp::Logger logger_{rclcpp::get_logger("my_controller")}; }; } // namespace nav2_my_controller #endif // NAV2_MY_CONTROLLER__CONTROLLER_HPP_ ``` #### 2.2 实现 controller.cpp ```cpp // controller.cpp #include "nav2_my_controller/controller.hpp" #include "pluginlib/class_list_macros.hpp" #include #include namespace nav2_my_controller { void MyController::configure( const rclcpp_lifecycle::LifecycleNode::WeakPtr & parent, std::string name, const std::shared_ptr & tf, const std::shared_ptr & costmap_ros) { auto node = parent.lock(); nav2_util::declare_parameter_if_not_declared( node, name + ".max_vel_x", rclcpp::ParameterValue(0.5)); nav2_util::declare_parameter_if_not_declared( node, name + ".max_vel_theta", rclcpp::ParameterValue(1.0)); nav2_util::declare_parameter_if_not_declared( node, name + ".k_linear", rclcpp::ParameterValue(0.6)); nav2_util::declare_parameter_if_not_declared( node, name + ".k_angular", rclcpp::ParameterValue(1.5)); max_vel_x_ = node->get_parameter(name + ".max_vel_x").as_double(); max_vel_theta_ = node->get_parameter(name + ".max_vel_theta").as_double(); k_linear_ = node->get_parameter(name + ".k_linear").as_double(); k_angular_ = node->get_parameter(name + ".k_angular").as_double(); logger_ = node->get_logger(); RCLCPP_INFO(logger_, "MyController 已配置"); } void MyController::cleanup() {} void MyController::activate() {} void MyController::deactivate() {} void MyController::setPlan(const nav_msgs::msg::Path & path) { plan_ = path; } geometry_msgs::msg::Twist MyController::computeVelocityCommands( const geometry_msgs::msg::PoseStamped & pose, const geometry_msgs::msg::Twist & velocity, nav2_core::GoalChecker * goal_checker) { geometry_msgs::msg::Twist cmd; if (plan_.poses.empty()) { cmd.linear.x = 0.0; cmd.angular.z = 0.0; return cmd; } // 找到路径上离当前位置最近的点作为"前视目标"(simple pursuit) double min_dist = 1e9; size_t idx = 0; for (size_t i = 0; i < plan_.poses.size(); ++i) { double dx = plan_.poses[i].pose.position.x - pose.pose.position.x; double dy = plan_.poses[i].pose.position.y - pose.pose.position.y; double d = std::hypot(dx, dy); if (d < min_dist) { min_dist = d; idx = i; } } // 前视 3 个点 size_t lookahead = std::min(idx + 3, plan_.poses.size() - 1); double gx = plan_.poses[lookahead].pose.position.x - pose.pose.position.x; double gy = plan_.poses[lookahead].pose.position.y - pose.pose.position.y; // 目标在机器人坐标系下的角度 double goal_angle = std::atan2(gy, gx); // 机器人当前朝向(四元数转 yaw) double qz = pose.pose.orientation.z, qw = pose.pose.orientation.w; double yaw = 2.0 * std::atan2(qz, qw); double ang_diff = goal_angle - yaw; // 归一化到 [-π, π] while (ang_diff > M_PI) ang_diff -= 2 * M_PI; while (ang_diff < -M_PI) ang_diff += 2 * M_PI; // P 控制:直行速度按距离、转角速度按角度差 cmd.linear.x = std::clamp(k_linear_ * std::hypot(gx, gy), 0.0, max_vel_x_); cmd.angular.z = std::clamp(k_angular_ * ang_diff, -max_vel_theta_, max_vel_theta_); return cmd; } } // namespace nav2_my_controller PLUGINLIB_EXPORT_CLASS( nav2_my_controller::MyController, nav2_core::Controller) ``` 这是一个**简化版 Pure Pursuit**(纯追踪):沿全局路径取前视点,用 P 控制输出线速度和角速度。作为理解插件接口的最小实现非常合适——真实 Regulated Pure Pursuit 还加了曲率限制、障碍避让等。 #### 2.3 插件描述文件 controller_plugin.xml ```xml 一个简单的 Pure Pursuit 局部控制器 ``` #### 2.4 package.xml + CMakeLists.txt package.xml 添加: ```xml ament_cmake ``` CMakeLists.txt: ```cmake find_package(nav2_core REQUIRED) find_package(pluginlib REQUIRED) add_library(nav2_my_controller SHARED src/controller.cpp) ament_target_dependencies(nav2_my_controller rclcpp nav2_core nav2_util geometry_msgs nav_msgs tf2_ros pluginlib) pluginlib_export_plugin_description_file(nav2_core controller_plugin.xml) ament_export_dependencies(nav2_core pluginlib) ``` #### 2.5 在 Nav2 配置中使用自定义控制器 ```yaml controller_server: ros__parameters: controller_plugins: ["FollowPath"] FollowPath: plugin: "nav2_my_controller/MyController" # 替换默认 DWB max_vel_x: 0.5 max_vel_theta: 1.0 k_linear: 0.6 k_angular: 1.5 ``` 编译后重启导航,你的控制器就生效了。**这就是 Nav2 插件化的全部秘密**:继承 → 实现 → 注册 → 配置引用。 ### 三、自定义行为树 #### 3.1 行为树是什么 行为树(Behavior Tree, BT)用树状节点编排导航流程:控制节点(Sequence/Selector)+ 行为节点(动作/条件)。Nav2 用 BT 描述"导航到目标"的完整流程,XML 可编辑。 #### 3.2 默认行为树(精简版) ```xml ``` #### 3.3 定制自己的行为树 给导航加"到达前鸣笛/等待"的示例: ```xml ``` 在配置中引用自定义树: ```yaml bt_navigator: ros__parameters: default_nav_to_pose_bt_xml: <你的包路径>/behavior_trees/my_navigate.xml ``` #### 3.4 自定义 BT 节点(C++) 行为树节点继承 `nav2_behavior_tree::BtActionNode` 或 `BT::ActionNodeBase`: ```cpp // custom_wait.cpp:一个简单的自定义节点 #include "behaviortree_cpp/action_node.h" #include #include class CustomWait : public BT::ActionNodeBase { public: CustomWait(const std::string & name, const BT::NodeConfig & config) : BT::ActionNodeBase(name, config) {} static BT::PortsList providedPorts() { return {BT::InputPort("wait_duration", 1.0, "等待秒数")}; } BT::NodeStatus tick() override { double secs; if (!getInput("wait_duration", secs)) { return BT::NodeStatus::FAILURE; } std::this_thread::sleep_for(std::chrono::duration(secs)); return BT::NodeStatus::SUCCESS; } void halt() override {} }; #include "behaviortree_cpp/bt_factory.h" BT_REGISTER_NODES(factory) { factory.registerNodeType("CustomWait"); } ``` ### 四、自定义全局规划器(简介) 与控制器同理,全局规划器继承 `nav2_core::GlobalPlanner`: ```cpp class MyPlanner : public nav2_core::GlobalPlanner { public: // 核心:computePotentialField + createPlan 返回全局路径 nav_msgs::msg::Path createPlan( const geometry_msgs::msg::PoseStamped & start, const geometry_msgs::msg::PoseStamped & goal) override; // ... }; PLUGINLIB_EXPORT_CLASS(nav2_custom_planner::MyPlanner, nav2_core::GlobalPlanner) ``` 配置: ```yaml planner_server: ros__parameters: planner_plugins: ["GridBased"] GridBased: plugin: "nav2_custom_planner/MyPlanner" ``` ### 五、进阶调参实战 #### 5.1 平滑路径(Smac Planner) Nav2 内置更先进的 Smac Planner(混合 A*),路径更平滑: ```yaml planner_server: ros__parameters: planner_plugins: ["GridBased"] GridBased: plugin: "nav2_smac_planner/SmacPlanner2D" tolerance: 0.25 use_astar: true downsample_costmap: false motion_model_for_search: 0 # 0=Dubins 1=Reeds-Shepp ``` #### 5.2 更好的局部规划(Regulated Pure Pursuit) ```yaml controller_server: ros__parameters: FollowPath: plugin: "nav2_regulated_pure_pursuit_controller/RegulatedPurePursuitController" desired_linear_vel: 0.5 lookahead_dist: 0.6 min_lookahead_dist: 0.3 max_lookahead_dist: 0.9 transform_tolerance: 0.2 use_velocity_scaled_lookahead_dist: true use_collision_detection: true max_allowed_time_to_collision_up_to_carrot: 1.0 ``` Regulated Pure Pursuit 是社区公认好用的局部规划器,建议优先于 DWB 使用。 #### 5.3 常用组合推荐 | 场景 | 全局规划 | 局部规划 | | --- | --- | --- | | 入门教学 | NavFn | DWB | | 通用小车 | NavFn | Regulated Pure Pursuit | | 大场景/长距离 | SmacPlanner2D | Regulated Pure Pursuit | | 全向底盘 | NavFn | DWB(支持全向) | ### 六、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | `Failed to load plugin` | 插件描述文件未导出 | `pluginlib_export_plugin_description_file`;重新 colcon build | | 控制器没生效 | 配置里 plugin 名没改对 | 检查 `controller_plugins` 与插件名 | | BT 加载失败 | XML 路径错或节点未注册 | 检查 `default_nav_to_pose_bt_xml`;BT 节点要注册进工厂 | | 自定义节点不执行 | 插件库没链接进 bt_navigator | `bt_navigator` 的 `plugin_lib_names` 加你的库 | | 参数不生效 | 参数名带前缀 | `name + ".k_linear"` 前缀与 YAML 结构对应 | | 编译报 nav2_core 找不到 | 缺依赖 | `find_package(nav2_core REQUIRED)` 前先 `sudo apt install ros-humble-nav2-core` | #### 6.1 验证插件是否被加载 ```bash # 查看插件是否注册成功 ros2 pkg prefix nav2_my_controller ls $(ros2 pkg prefix nav2_my_controller)/share/nav2_my_controller/ # 应有 controller_plugin.xml # 启动导航时 --verbose 看日志 ros2 launch my_robot_bringup nav2.launch.py --verbose # 日志出现 MyController 已配置 = 插件加载成功 ``` ### 七、小结与下一篇预告 **本篇核心**: 1. **插件机制**:继承基类 + PLUGINLIB_EXPORT_CLASS + 描述 XML,配置引用即生效。 2. **自定义控制器**:完整 Pure Pursuit 骨架(configure/computeVelocityCommands/setPlan)。 3. **自定义行为树**:XML 编排流程,RecoveryNode 失败恢复,可加自定义节点。 4. **内置进阶插件**:SmacPlanner(平滑全局)、Regulated Pure Pursuit(好用局部)。 5. **工程验证**:插件描述文件导出、`--verbose` 日志确认加载。 6. Nav2 从"黑盒"变成可自由定制的工具箱。 至此 **Nav2 导航阶段(21-25)全部收官**。下一篇进入 **硬件控制阶段**(第 26 篇):嵌入式开发从点灯开始。前面全部是仿真,真正的机器人要跑在硬件上:单片机(STM32/ESP32)、电机驱动、传感器接线、串口通信。第 26 篇我们从最基础的"点灯"讲起,建立嵌入式开发的完整认知,为 MicroROS(第 27 篇)和 ROS2-Control(第 28 篇)打地基。从虚拟世界走向真实硬件,新的篇章开始。 --- ## ROS2系列教程:Nav2导航仿真实战(全局+局部规划) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-nav2-dao-hang-fang-zhen-shi-zhan/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 24 篇 > 本文是 ROS2 系列教程的第 24 篇:Nav2 导航仿真实战(全局+局部规划)。至此我们已有地图(第 21/22 篇)、有定位(第 23 篇),就差"自己走路"。Nav2(Navigation2)是 ROS2 的官方导航框架:给它一个目标点,它负责全局路径规划(A* 在整张地图找路)、局部避障(DWA 实时躲障碍)、输出速度指令,全程自主。本篇先把 Nav2 的架构与行为树讲清楚,再给出一套能直接跑的仿真配置,最后用 RViz 2D Goal Pose 和命令行两种方式下发目标,亲眼看着小车避开障碍、抵达终点。这是整个导航系列从"会建图"到"会走路"的临门一脚。 ### 一、Nav2 是什么 #### 1.1 定位 Nav2 是 ROS2 官方导航栈(Nav1 的继任者),一个"插件化、可配置"的导航框架。它把导航拆成多个可替换的服务器(server),每个都能换插件: - **Planner Server**(规划):全局路径规划。 - **Controller Server**(控制):局部避障与速度输出。 - **Behavior Server**(行为):旋转恢复、回退、等待。 - **Costmap 2D**(代价地图):把地图 + 障碍物转成可规划的代价图。 - **BT Navigator**(行为树):用行为树编排上述流程。 安装: ```bash sudo apt install ros-humble-nav2-bringup # 验证 ros2 pkg list | grep nav2 ``` #### 1.2 导航流程 ``` 用户目标点 (goal_pose) → BT Navigator(行为树编排) → Planner Server:A* 全局路径(map 上找路) → Costmap:局部代价图(实时障碍) → Controller Server:DWA 局部规划(避开动态障碍) → /cmd_vel(速度指令) → 机器人移动 → odom/scan 反馈 → 循环 ``` #### 1.3 两个规划器的分工 | 规划器 | 名字 | 输入 | 输出 | 频率 | | --- | --- | --- | --- | --- | | 全局规划 | NavFn(A*) | `/map` + 目标点 | 全局路径 `/plan` | 低频(路径变化时) | | 局部规划 | DWB/DWA | 全局路径片段 + 局部代价图 + 激光 | 速度 `cmd_vel` | 高频(10-20Hz) | **比喻**:全局规划是"导航地图选路线",局部规划是"司机看路避坑"。 ### 二、Nav2 配置文件 #### 2.1 nav2_params.yaml 核心结构 ```yaml # nav2_params.yaml bt_navigator: ros__parameters: use_sim_time: True plugin_lib_names: - nav2_compute_path_to_pose_action_bt_node - nav2_compute_path_through_poses_action_bt_node - nav2_follow_path_action_bt_node - nav2_back_up_action_bt_node - nav2_drive_on_heading_bt_node - nav2_wait_action_bt_node - nav2_spin_action_bt_node - nav2_clear_costmap_service_bt_node default_nav_through_poses_bt_xml: /opt/ros/humble/share/nav2_bt_navigator/behavior_trees/navigate_through_poses_w_replanning_and_recovery.xml default_nav_to_pose_bt_xml: /opt/ros/humble/share/nav2_bt_navigator/behavior_trees/navigate_to_pose_w_replanning_and_recovery.xml controller_server: ros__parameters: use_sim_time: True controller_frequency: 15.0 min_x_velocity_threshold: 0.001 min_y_velocity_threshold: 0.001 min_theta_velocity_threshold: 0.001 progress_checker_plugin: "progress_checker" goal_checker_plugin: "general_goal_checker" controller_plugins: ["FollowPath"] progress_checker: plugin: "nav2_controller::SimpleProgressChecker" required_movement_radius: 0.5 movement_time_allowance: 10.0 general_goal_checker: stateful: True xy_goal_tolerance: 0.25 yaw_goal_tolerance: 0.25 FollowPath: plugin: "dwb_core::DWBLocalPlanner" debug_trajectory_details: True min_vel_x: 0.0 max_vel_x: 0.5 min_vel_y: 0.0 max_vel_y: 0.0 max_vel_theta: 1.0 min_speed_xy: 0.0 max_speed_xy: 0.5 min_speed_theta: 0.0 acc_lim_x: 2.5 acc_lim_y: 2.5 acc_lim_theta: 3.2 decel_lim_x: -2.5 decel_lim_y: -2.5 decel_lim_theta: -3.2 vx_samples: 20 vy_samples: 5 vtheta_samples: 20 sim_time: 1.7 linear_granularity: 0.05 angular_granularity: 0.025 transform_tolerance: 0.2 xy_goal_tolerance: 0.25 trans_stopped_velocity: 0.25 short_circuit_trajectory_evaluation: True stateful: True critics: ["RotateToGoal", "Oscillation", "BaseObstacle", "GoalAlign", "PathAlign", "PathDist", "GoalDist"] BaseObstacle.scale: 0.02 PathAlign.scale: 32.0 PathDist.scale: 32.0 GoalDist.scale: 24.0 RotateToGoal.scale: 32.0 planner_server: ros__parameters: use_sim_time: True planner_plugins: ["GridBased"] GridBased: plugin: "nav2_navfn_planner/NavfnPlanner" tolerance: 0.5 use_astar: true allow_unknown: true behavior_server: ros__parameters: use_sim_time: True local_costmap_topic: local_costmap/costmap_raw global_costmap_topic: global_costmap/costmap_raw robot_base_frame: base_link behavior_plugins: ["spin", "back_up", "drive_on_heading", "wait"] spin: plugin: "nav2_behaviors::Spin" simulate_ahead_time: 2.0 max_rotational_vel: 1.0 min_rotational_vel: 0.4 back_up: plugin: "nav2_behaviors::BackUp" simulate_ahead_time: 2.0 max_rotational_vel: 1.0 min_rotational_vel: 0.4 ``` #### 2.2 代价地图(costmap)配置 代价地图把地图 + 传感器障碍融合成"可通行性"图,分全局和局部两张: ```yaml # 全局代价地图 global_costmap: global_costmap: ros__parameters: use_sim_time: True robot_radius: 0.20 obstacle_layer: plugin: "nav2_costmap_2d::ObstacleLayer" enabled: True observation_sources: scan scan: topic: /scan max_obstacle_height: 2.0 clearing: True marking: True data_type: "LaserScan" inflation_layer: plugin: "nav2_costmap_2d::InflationLayer" cost_scaling_factor: 3.0 inflation_radius: 0.55 static_layer: plugin: "nav2_costmap_2d::StaticLayer" map_subscribe_transient_local: True always_send_full_costmap: True robot_radius: 0.20 update_frequency: 1.0 publish_frequency: 1.0 # 局部代价地图 local_costmap: local_costmap: ros__parameters: use_sim_time: True robot_radius: 0.20 obstacle_layer: plugin: "nav2_costmap_2d::ObstacleLayer" enabled: True observation_sources: scan scan: topic: /scan max_obstacle_height: 2.0 clearing: True marking: True data_type: "LaserScan" inflation_layer: plugin: "nav2_costmap_2d::InflationLayer" cost_scaling_factor: 3.0 inflation_radius: 0.55 static_layer: plugin: "nav2_costmap_2d::StaticLayer" always_send_full_costmap: True width: 3 height: 3 resolution: 0.05 update_frequency: 5.0 publish_frequency: 2.0 rolling_window: true ``` **关键参数**: | 参数 | 含义 | 调参 | | --- | --- | --- | | `robot_radius` | 机器人半径(膨胀用) | 与小车尺寸匹配(0.2) | | `inflation_radius` | 障碍膨胀半径 | 太大路径保守、太小贴墙走(0.55) | | `cost_scaling_factor` | 代价衰减速度 | 3.0 适中 | | `rolling_window` | 局部图是否跟随机器人滚动 | 局部必须 true | | `width/height` | 局部图大小 | 3×3m 够用 | **robot_radius 与 inflation 是导航好不好走的关键**:半径设太小会贴墙蹭,设太大窄缝过不去。 ### 三、一键启动导航 #### 3.1 nav2.launch.py ```python from launch import LaunchDescription from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription from launch.launch_description_sources import PythonLaunchDescriptionSource from launch_ros.actions import Node from launch.substitutions import LaunchConfiguration import os from ament_index_python.packages import get_package_share_directory def generate_launch_description(): pkg_bringup = get_package_share_directory('my_robot_bringup') map_yaml = os.path.join(pkg_bringup, 'maps', 'my_map.yaml') nav2_params = os.path.join(pkg_bringup, 'config', 'nav2_params.yaml') nav2_bringup = IncludeLaunchDescription( PythonLaunchDescriptionSource( os.path.join(get_package_share_directory('nav2_bringup'), 'launch', 'bringup_launch.py')), launch_arguments={ 'map': map_yaml, 'params_file': nav2_params, 'use_sim_time': 'true'}.items()) rviz = Node( package='rviz2', executable='rviz2', arguments=['-d', os.path.join(pkg_bringup, 'config', 'nav2.rviz')], output='screen') return LaunchDescription([nav2_bringup, rviz]) ``` #### 3.2 完整导航运行 ```bash # 终端 1:仿真小车 ros2 launch my_robot_bringup gazebo.launch.py # 终端 2:Nav2 导航(地图 + AMCL + 规划器 + 行为树) ros2 launch my_robot_bringup nav2.launch.py ``` 启动后 `nav2_bringup` 会拉起:map_server、amcl、planner_server、controller_server、behavior_server、bt_navigator、costmap 全部节点。 #### 3.3 检查所有节点就绪 ```bash ros2 node list # 应看到 map_server、amcl、planner_server、controller_server、 # behavior_server、bt_navigator、costmap 相关节点 # 检查生命周期状态(全部应为 active) ros2 lifecycle get /bt_navigator ros2 lifecycle get /controller_server ``` ### 四、下发导航目标 #### 4.1 RViz 方式(最直观) 1. 先用 **2D Pose Estimate** 给机器人一个初始位姿(点地图上小车位置并拖动方向)。 2. 用 **2D Goal Pose** 点击目标位置(拖动设定朝向)。 3. 观察:全局路径画出(绿色线),小车沿路径前进,遇到障碍时局部路径避让。 #### 4.2 命令行方式 ```bash # 通过 NavigateToPose 动作下发目标(x=1.5, y=0, θ=0) ros2 action send_goal /navigate_to_pose nav2_msgs/action/NavigateToPose \ "{pose: {header: {frame_id: 'map'}, pose: {position: {x: 1.5, y: 0.0, z: 0.0}, orientation: {w: 1.0}}}}" # 通过话题(更简单,适合脚本) ros2 topic pub /goal_pose geometry_msgs/msg/PoseStamped \ "{header: {frame_id: 'map'}, pose: {position: {x: 1.5, y: 0.0, z: 0.0}, orientation: {w: 1.0}}}" --once ``` #### 4.3 C++ 下发目标(程序化导航) ```cpp // send_goal.cpp:向 /navigate_to_pose 发送目标 #include #include #include class NavClient : public rclcpp::Node { public: using NavigateToPose = nav2_msgs::action::NavigateToPose; using GoalHandle = rclcpp_action::ClientGoalHandle; NavClient() : Node("nav_client") { client_ = rclcpp_action::create_client(this, "/navigate_to_pose"); } void send_goal(double x, double y, double yaw) { if (!client_->wait_for_action_server(std::chrono::seconds(5))) { RCLCPP_ERROR(this->get_logger(), "Nav2 action server 不可用"); return; } auto goal = NavigateToPose::Goal(); goal.pose.header.frame_id = "map"; goal.pose.header.stamp = now(); goal.pose.pose.position.x = x; goal.pose.pose.position.y = y; // yaw -> 四元数(绕 z 轴旋转) goal.pose.pose.orientation.z = std::sin(yaw / 2.0); goal.pose.pose.orientation.w = std::cos(yaw / 2.0); auto send_goal_options = rclcpp_action::Client::SendGoalOptions(); send_goal_options.goal_response_callback = [this](GoalHandle::SharedPtr gh) { if (gh) RCLCPP_INFO(this->get_logger(), "目标被接受"); else RCLCPP_ERROR(this->get_logger(), "目标被拒绝"); }; send_goal_options.result_callback = [this](const GoalHandle::WrappedResult& result) { if (result.code == rclcpp_action::ResultCode::SUCCEEDED) RCLCPP_INFO(this->get_logger(), "导航成功!"); else RCLCPP_ERROR(this->get_logger(), "导航失败,状态码=%d", static_cast(result.code)); }; client_->async_send_goal(goal, send_goal_options); } private: rclcpp_action::Client::SharedPtr client_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); auto node = std::make_shared(); double x = argc > 1 ? std::atof(argv[1]) : 1.5; double y = argc > 2 ? std::atof(argv[2]) : 0.0; node->send_goal(x, y, 0.0); rclcpp::spin(node); rclcpp::shutdown(); return 0; } ``` #### 4.4 Python 下发目标 ```python # send_goal.py import rclpy from rclpy.node import Node from rclpy.action import ActionClient from nav2_msgs.action import NavigateToPose from geometry_msgs.msg import PoseStamped class NavClient(Node): def __init__(self): super().__init__('nav_client') self.client = ActionClient(self, NavigateToPose, '/navigate_to_pose') def send_goal(self, x, y, yaw=0.0): if not self.client.wait_for_server(timeout_sec=5.0): self.get_logger().error('Nav2 action server 不可用') return goal = NavigateToPose.Goal() goal.pose = PoseStamped() goal.pose.header.frame_id = 'map' goal.pose.header.stamp = self.get_clock().now().to_msg() goal.pose.pose.position.x = x goal.pose.pose.position.y = y goal.pose.pose.orientation.z = float(__import__('math').sin(yaw / 2)) goal.pose.pose.orientation.w = float(__import__('math').cos(yaw / 2)) self.client.send_goal_async(goal).add_done_callback(self.goal_cb) self.get_logger().info(f'目标已发送: ({x}, {y})') def goal_cb(self, future): goal_handle = future.result() if not goal_handle.accepted: self.get_logger().error('目标被拒绝') return self.get_logger().info('目标被接受') goal_handle.get_result_async().add_done_callback(self.result_cb) def result_cb(self, future): result = future.result().result self.get_logger().info(f'导航结果: {result}') def main(): rclpy.init() node = NavClient() node.send_goal(1.5, 0.0) rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` ### 五、导航中的常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 目标被拒绝 | Nav2 未就绪或目标无效 | `ros2 lifecycle get /bt_navigator` 应为 active | | 小车原地打转 | 全局路径太长/局部规划无解 | 检查 robot_radius 与障碍距离 | | 不走直线绕远路 | inflation 太大 | 调小 inflation_radius 或 cost_scaling_factor | | 贴墙太近 | robot_radius 太小 | 调大 robot_radius | | 卡在障碍前 | 局部规划器无可行速度 | 检查 acc_lim 是否过小;增加 recovery | | 抖动/振荡 | 控制频率低或速度阈值小 | 调大 controller_frequency 到 15-20Hz | | 到达后不停 | goal_tolerance 太小 | 调大 xy_goal_tolerance 到 0.25 | | 地图与传感器错位 | costmap 的 TF 问题 | 检查 use_sim_time 与 TF 树 | #### 5.1 行为树恢复机制 Nav2 默认行为树带恢复逻辑:检测到"卡住"(SimpleProgressChecker)→ 旋转(Spin)→ 倒退(BackUp)→ 重新规划。**看到小车原地转圈别慌,那可能是它在"自救"**。 ### 六、小结与下一篇预告 **本篇核心**: 1. **Nav2 架构**:BT Navigator 编排 + Planner(全局)+ Controller(局部)+ Costmap + Behavior。 2. **全局 vs 局部**:A* 找路 vs DWA 避障,频率与范围不同。 3. **代价地图**:robot_radius、inflation_radius、rolling_window 三大调参点。 4. **下发目标**:RViz 2D Goal Pose、action 命令、C++/Python 客户端三种方式。 5. **恢复机制**:行为树带旋转/倒退/重规划的自救流程。 6. 一条龙:地图 + AMCL + Nav2 → 小车自主导航到任意可达点。 下一篇进入 **Nav2 进阶实践**(第 25 篇):自定义算法、插件与行为树。本篇用的是默认插件(NavFn/DWB),真实项目中要换自己的规划器、加自定义行为、改行为树。我们会拆解 Nav2 的插件机制、自定义 Controller 的骨架、以及用 behavior tree XML 定制导航行为——这是把 Nav2 从"能跑"推向"好用"的关键一步。导航已通,进阶开始。 --- ## ROS2系列教程:AMCL定位与地图 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-amcl-ding-wei-yu-di-tu/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 23 篇 > 本文是 ROS2 系列教程的第 23 篇:AMCL 定位与地图。前两篇我们学会了建图——slam_toolbox 和 Cartographer 都能产出地图。但建好的地图怎么"用"?机器人每次开机都面临同一个问题:**我在哪?** AMCL(Adaptive Monte Carlo Localization,自适应蒙特卡洛定位)用粒子滤波回答这个问题:在地图里撒几千个"猜测粒子",用激光匹配不断淘汰错误猜测、收敛到真实位姿。本篇讲透 AMCL 原理、nav2_amcl 参数、地图加载(map_server)与完整定位流程,让你重启机器人后 5 秒内"知道自己在哪",为下一篇 Nav2 自主导航完成最后一块拼图。 ### 一、AMCL 是什么 #### 1.1 定位问题的本质 假设地图已经建好(一张栅格图),机器人重启后拿到激光数据,却不知道自己的位置。定位就是:**给定地图 + 激光 + 里程计,估计机器人的位姿(x, y, θ)**。 难点在于: - 激光是"一帧一帧"的,单帧无法直接确定位置(环境可能有重复特征)。 - 里程计会漂移,不能只信它。 - 需要持续更新(机器人一直动,位姿一直变)。 #### 1.2 蒙特卡洛方法(粒子滤波) 核心思想:**用一堆"粒子"表示位姿的概率分布**。 1. **初始化**:在地图上撒 N 个粒子(随机位姿)。 2. **预测**:根据里程计,让所有粒子跟着移动。 3. **更新**:每帧激光到达,给每个粒子打分——“如果我真在这里,激光应该长这样”,分数低的粒子淘汰。 4. **重采样**:按分数重新抽样,粒子向高分区聚拢。 循环 2-4,粒子会从"满地都是"收敛到"一小撮",收敛中心就是机器人位姿。 **自适应(Adaptive)**:粒子数会根据收敛程度动态调整——不确定时多撒、确定时少算,兼顾精度与速度。 #### 1.3 输入与输出 ``` 输入:/scan + /tf(odom→base_link) + 静态地图(/map) 输出:/amcl_pose(位姿估计)+ /particlecloud(粒子可视化)+ TF(map→odom) ``` | 话题 | 消息类型 | 作用 | | --- | --- | --- | | `/scan` | `LaserScan` | 激光匹配打分 | | `/map` | `OccupancyGrid` | 已知地图(由 map_server 提供) | | `/amcl_pose` | `PoseWithCovarianceStamped` | 定位结果 | | `/particlecloud` | `PoseArray` | 粒子分布(可视化) | ### 二、地图加载:nav2_map_server AMCL 不自己建图,它需要 map_server 把 `.pgm + .yaml` 地图发布为 `/map`。 #### 2.1 map_server 启动 ```bash # 加载地图并发布 /map ros2 run nav2_map_server map_server --ros-args \ -p yaml_filename:=~/maps/my_map.yaml \ -p use_sim_time:=true ``` 启动后还需**单独激活生命周期**(Nav2 节点的标准机制): ```bash ros2 lifecycle set /map_server configure ros2 lifecycle set /map_server activate ``` #### 2.2 生命周期管理 Nav2 节点都遵循 ROS2 生命周期:`unconfigured → inactive → active`。launch 里用 `nav2_lifecycle_manager` 统一管理: ```python from launch_ros.actions import Node from launch_ros.actions import LifecycleNode # 生命周期节点(map_server 必须是 LifecycleNode) map_server = LifecycleNode( package='nav2_map_server', executable='map_server', name='map_server', output='screen', parameters=[{'yaml_filename': map_yaml, 'use_sim_time': True}]) # 生命周期管理器(自动 configure + activate) lifecycle_manager = Node( package='nav2_lifecycle_manager', executable='lifecycle_manager', name='lifecycle_manager_map', output='screen', parameters=[{'autostart': True, 'node_names': ['map_server']}]) ``` #### 2.3 验证地图加载 ```bash # 地图话题应持续发布 ros2 topic echo /map --once # 地图元数据(分辨率、尺寸) ros2 topic echo /map_metadata --once ``` ### 三、nav2_amcl 参数详解 #### 3.1 核心参数(amcl_params.yaml) ```yaml amcl: ros__parameters: use_sim_time: True alpha1: 0.2 # 旋转噪声 alpha2: 0.2 # 平移噪声 alpha3: 0.2 # 平移导致旋转的噪声 alpha4: 0.2 # 旋转导致平移的噪声 alpha5: 0.2 # 速度噪声 base_frame_id: "base_link" global_frame_id: "map" odom_frame_id: "odom" max_particles: 2000 # 最大粒子数 min_particles: 500 # 最小粒子数 resample_interval: 1 # 每 N 帧重采样 save_pose_rate: 0.5 # 保存位姿频率 sigma_hit: 0.2 # 激光命中噪声 laser_min_range: 0.1 laser_max_range: 10.0 laser_max_beams: 60 # 每帧激光使用的束数 min_particles_for_kld: 500 transform_tolerance: 1.0 update_min_a: 0.2 # 最小旋转角才更新 update_min_d: 0.25 # 最小平移距离才更新 z_hit: 0.5 # 命中概率权重 z_max: 0.05 # 超距概率权重 z_rand: 0.5 # 随机概率权重 z_short: 0.05 # 短距离概率权重 ``` #### 3.2 参数分组速查 | 分组 | 参数 | 作用 | 调参 | | --- | --- | --- | --- | | 坐标系 | `global_frame_id`/`odom_frame_id`/`base_frame_id` | map/odom/base_link | 必须与 TF 一致 | | 粒子 | `max_particles`/`min_particles` | 粒子上下限 | 精度低调大,性能紧调小 | | 噪声模型 | `alpha1-5` | 运动模型噪声 | 里程计好就调小(0.05-0.2) | | 激光模型 | `sigma_hit`/`z_hit`/`z_rand`/`z_max` | 激光打分权重 | 默认即可,传感器好调小 z_rand | | 性能 | `laser_max_beams`/`update_min_d`/`update_min_a` | 计算量控制 | 卡顿就调小 beams、调大阈值 | #### 3.3 坐标系三件套 AMCL 输出 `map→odom` TF:激光匹配结果与里程计的偏差,就是 map→odom 的变换。**AMCL 必须运行在 map/odom/base_link 三坐标系齐全的 TF 树上**,缺一不可。 ### 四、完整定位流程 #### 4.1 localization.launch.py ```python from launch import LaunchDescription from launch_ros.actions import Node, LifecycleNode from launch_ros.actions import SetParameter import os from ament_index_python.packages import get_package_share_directory def generate_launch_description(): pkg_bringup = get_package_share_directory('my_robot_bringup') map_yaml = os.path.join(pkg_bringup, 'maps', 'my_map.yaml') amcl_params = os.path.join(pkg_bringup, 'config', 'amcl_params.yaml') map_server = LifecycleNode( package='nav2_map_server', executable='map_server', name='map_server', output='screen', parameters=[{'yaml_filename': map_yaml, 'use_sim_time': True}]) amcl = LifecycleNode( package='nav2_amcl', executable='amcl', name='amcl', output='screen', parameters=[amcl_params]) lifecycle_manager = Node( package='nav2_lifecycle_manager', executable='lifecycle_manager', name='lifecycle_manager_localization', output='screen', parameters=[{'autostart': True, 'node_names': ['map_server', 'amcl']}]) rviz = Node(package='rviz2', executable='rviz2', arguments=['-d', os.path.join(pkg_bringup, 'config', 'localization.rviz')], output='screen') return LaunchDescription([ SetParameter(name='use_sim_time', value=True), map_server, amcl, lifecycle_manager, rviz, ]) ``` #### 4.2 运行 ```bash # 终端 1:仿真小车(不开 SLAM,只开 Gazebo) ros2 launch my_robot_bringup gazebo.launch.py # 终端 2:定位(地图 + AMCL + RViz) ros2 launch my_robot_bringup localization.launch.py ``` #### 4.3 观察定位收敛 RViz 添加: 1. **Map**(topic `/map`):加载的地图。 2. **ParticleCloud**(topic `/particlecloud`):粒子分布。 3. **TF**:观察 `map→odom`。 启动瞬间粒子通常**散落一地**(初始化未知位姿),此时机器人不动、粒子不动——因为 AMCL 需要运动 + 激光更新才能收敛。**把机器人原地动一下(teleop 前后左右晃晃),粒子迅速聚拢成一小团**,这就是定位成功。 #### 4.4 初始位姿注入 仿真中机器人 spawn 在已知位置(如 0,0),可以手动告诉 AMCL,收敛更快: ```bash # 用 RViz 2D Pose Estimate 按钮点击地图上的位置 # 或命令行发布初始位姿(x=0, y=0, θ=0) ros2 topic pub /initialpose geometry_msgs/msg/PoseWithCovarianceStamped \ "{header: {frame_id: 'map'}, pose: {pose: {position: {x: 0.0, y: 0.0, z: 0.0}, orientation: {w: 1.0}}}}" \ --once ``` ### 五、定位质量评估 #### 5.1 看粒子集中度 ```bash # 粒子话题 ros2 topic echo /particlecloud --once # 收敛后粒子应集中在小范围内(几十厘米内) ``` #### 5.2 看 amcl_pose 协方差 ```bash ros2 topic echo /amcl_pose --once # pose.covariance 对角线越小越确定 # [0]=x方差 [7]=y方差 [35]=θ方差 ``` #### 5.3 与真实位置对比 ```bash # 真实位置:gazebo 的 /tf world→base_link ros2 run tf2_ros tf2_echo world base_link # AMCL 估计:map→base_link ros2 run tf2_ros tf2_echo map base_link # 两者应基本一致(误差 < 10cm) ``` ### 六、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 粒子不收敛 | 没动起来 / 激光与地图不匹配 | teleop 晃动;检查 use_sim_time | | 粒子全消失 | 地图与真实环境差异大 | 重新建图;或调大 `max_particles` | | 定位跳变 | 激光噪声大 / z_rand 太小 | 调大 z_rand、sigma_hit | | `/map` 空 | map_server 未激活 | `ros2 lifecycle set /map_server activate` | | AMCL 无输出 | TF 树缺 map→odom | 确认 odom_frame/base_frame 参数 | | 初始粒子太少 | 初始化区域小 | 多发几次 /initialpose 或在更大范围初始化 | | 走一段就丢 | 里程计噪声参数太小 | 调大 alpha1-5 | | 地图与激光错位 | TF 时间戳不同步 | 统一 use_sim_time=true | #### 6.1 生命周期排查 ```bash # 查看节点生命周期状态 ros2 lifecycle get /amcl # 应输出 active ros2 lifecycle get /map_server ``` ### 七、小结与下一篇预告 **本篇核心**: 1. **AMCL 原理**:粒子滤波——撒粒子、预测、打分、重采样,收敛出位姿。 2. **地图来源**:`nav2_map_server` 加载 pgm+yaml,发布 `/map`。 3. **生命周期**:Nav2 节点 configure→activate,由 lifecycle_manager 自动管理。 4. **参数**:坐标系三件套、粒子数、噪声模型、激光模型四大组。 5. **收敛技巧**:初始位姿注入 + 原地晃动,粒子快速聚拢。 6. **质量评估**:粒子集中度、amcl_pose 协方差、与真实位置对比。 下一篇开启 **Nav2 导航仿真实战(全局+局部规划)**(第 24 篇):地图有了、定位有了,就差"自己走路"了。Nav2 会整合这一切:目标点下发 → 全局规划(A* 找路)→ 局部规划(DWA 避障)→ 速度输出(cmd_vel),让机器人真正实现"点到点自主导航"。导航三要素(地图、定位、规划)即将集齐。从"知道自己在哪"到"自己走到哪",就差这一篇。 --- ## ROS2系列教程:Cartographer建图进阶 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-cartographer-jian-tu-jin-jie/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 22 篇 > 本文是 ROS2 系列教程的第 22 篇:Cartographer 建图进阶。上一篇用 slam_toolbox 建出了第一张地图,但它的回环能力在长廊、大场地、复杂环境里会吃力——累积误差难以消除,地图容易"飘"。Cartographer 是 Google 开源的 SLAM 库,以**子图(submap)+ 回环闭环**闻名:先把局部帧拼成子图,再全局优化消除漂移,2D/3D 通吃。本篇讲透 Cartographer 的核心概念、ROS2 接口、2D 建图配置与调试,并给你一套能直接跑的建图 launch。想让你导航小车在大场景里"不迷路",Cartographer 是关键一环。 ### 一、Cartographer 是什么 #### 1.1 出身与定位 Cartographer 由 Google 在 2016 年开源,最初用于自家仓库机器人和室内地图测绘。它支持: - **2D 激光 SLAM**(最常用)。 - **3D SLAM**(多线雷达,支持 `cartographer_ros` 的 3D 模式)。 - **子图回环**:局部建图快、全局一致性高。 在 ROS2 Humble 中安装: ```bash sudo apt install ros-humble-cartographer ros-humble-cartographer-ros # 验证 ros2 pkg list | grep cartographer ``` #### 1.2 与 slam_toolbox 的核心差异 | 维度 | slam_toolbox | Cartographer | | --- | --- | --- | | 数学方法 | 图优化(位姿图) | 子图 + 全局优化 | | 回环能力 | 中(依赖配置) | 强(主动闭环检测) | | 计算开销 | 低 | 中高(多线程) | | 大场景 | 一般 | 好 | | 配置复杂度 | 低 | 高(参数多) | | 3D 支持 | 无 | 有 | 一句话:**入门用 slam_toolbox,上强度用 Cartographer**。本篇把 Cartographer 调通,你的建图工具箱就齐了。 ### 二、核心概念:子图与回环 #### 2.1 子图(Submap) Cartographer 建图不是一帧一帧拼整个地图,而是: 1. 每收到一帧激光,先跟**当前子图**匹配,确定位姿。 2. 累积到一定长度(`num_range_data` 参数控制),把这段局部地图"定稿"成一个子图。 3. 继续开新子图。 **子图是 Cartographer 高效的关键**:局部匹配快,且定稿的子图成为后续回环的锚点。 #### 2.2 回环闭环(Loop Closure) - **局部优化**:每帧激光与当前子图匹配(快,但会累积漂移)。 - **全局优化**:当新子图与**老子图**有重叠时,检测到回环,把整条轨迹的位姿重优化,漂移被"拉回来"。 这就是 Cartographer 在长廊、绕圈场景里依然精准的原因:**它会主动寻找"我去过这里"的证据**。 #### 2.3 数据流 ``` /scan + /tf(odom→base_link) + /odom(可选) → 前端(帧→子图匹配,local SLAM) → 后端(回环检测 + 全局优化,global SLAM) → /map + map→odom TF ``` Cartographer 内部分前端(实时建图)与后端(全局优化)两个线程,这就是它配置参数多的原因。 ### 三、ROS2 接口与配置 #### 3.1 启动命令 ```bash ros2 run cartographer_ros cartographer_node \ --ros-args \ -p use_sim_time:=true \ -p configuration_directory:=$(pwd) \ -p configuration_basename:=cartographer.lua ``` `configuration_directory` 与 `configuration_basename` 指向 Lua 配置文件(Cartographer 用 Lua 而非 YAML 写参数)。 #### 3.2 cartographer.lua 核心配置(2D 激光) ```lua -- cartographer.lua include "map_builder.lua" include "trajectory_builder.lua" options = { map_builder = MAP_BUILDER, trajectory_builder = TRAJECTORY_BUILDER, map_frame = "map", tracking_frame = "base_link", -- 机器人本体坐标系 published_frame = "odom", -- 里程计坐标系 odom_frame = "odom", provide_odom_frame = true, publish_frame_projected_to_2d = false, use_odometry = true, -- 使用 /odom use_nav_sat = false, use_landmarks = false, num_laser_scans = 1, -- 1 个 2D 激光 num_multi_echo_laser_scans = 0, num_subdivisions_per_laser_scan = 1, num_point_clouds = 0, lookup_transform_timeout_sec = 0.2, submap_publish_period_sec = 0.3, pose_publish_period_sec = 5e-3, trajectory_builder_2d = TRAJECTORY_BUILDER_2D, } MAP_BUILDER.use_trajectory_builder_2d = true -- 2D 轨迹构建器参数 TRAJECTORY_BUILDER_2D.min_range = 0.1 TRAJECTORY_BUILDER_2D.max_range = 10.0 TRAJECTORY_BUILDER_2D.missing_data_ray_length = 5.0 TRAJECTORY_BUILDER_2D.use_imu_data = false -- 纯 2D 激光可不依赖 IMU TRAJECTORY_BUILDER_2D.use_online_correlative_scan_matching = true TRAJECTORY_BUILDER_2D.motion_filter.max_distance_meters = 0.2 TRAJECTORY_BUILDER_2D.motion_filter.max_angle_radians = 0.1 -- 子图参数 TRAJECTORY_BUILDER_2D.submaps.num_range_data = 90 TRAJECTORY_BUILDER_2D.submaps.grid_options_2d.resolution = 0.05 TRAJECTORY_BUILDER_2D.submaps.range_data_inserter.probability_grid_range_data_inserter.hit_probability = 0.55 TRAJECTORY_BUILDER_2D.submaps.range_data_inserter.probability_grid_range_data_inserter.miss_probability = 0.49 -- 前端扫描匹配 TRAJECTORY_BUILDER_2D.ceres_scan_matcher.translation_weight = 1.0 TRAJECTORY_BUILDER_2D.ceres_scan_matcher.rotation_weight = 1.0 -- 回环参数 POSE_GRAPH.optimize_every_n_nodes = 90 POSE_GRAPH.constraint_builder.min_score = 0.55 POSE_GRAPH.constraint_builder.global_localization_min_score = 0.6 POSE_GRAPH.global_constraint_search_after_n_seconds = 30.0 ``` #### 3.3 关键参数速查 | 参数 | 作用 | 建议 | | --- | --- | --- | | `num_range_data` | 多少帧激光构成一个子图 | 90(约 9 秒@10Hz) | | `optimize_every_n_nodes` | 每 N 个节点做一次全局优化 | 90 | | `min_score` | 回环匹配最低分(低于则拒绝) | 0.55-0.6 | | `motion_filter.max_distance_meters` | 移动多少米才处理新帧 | 0.2 | | `hit_probability`/`miss_probability` | 栅格更新概率 | 0.55/0.49(默认即可) | | `resolution` | 栅格分辨率 | 0.05(米/格) | **调参口诀**:地图模糊 → 调大 `max_range` 或降速;回环不闭合 → 调低 `min_score` 或调大 `global_constraint_search_after_n_seconds`;卡顿 → 调大 `motion_filter` 阈值减少处理帧数。 ### 四、建图 launch(可直接复用) #### 4.1 cartographer.lua + launch 把上面的 lua 保存到 `config/cartographer.lua`,配套 launch: ```python # cartographer_mapping.launch.py from launch import LaunchDescription from launch_ros.actions import Node import os from ament_index_python.packages import get_package_share_directory def generate_launch_description(): pkg_bringup = get_package_share_directory('my_robot_bringup') config_dir = os.path.join(pkg_bringup, 'config') cartographer_node = Node( package='cartographer_ros', executable='cartographer_node', name='cartographer_node', output='screen', parameters=[{'use_sim_time': True}], arguments=[ '-configuration_directory', config_dir, '-configuration_basename', 'cartographer.lua']) occupancy_grid = Node( package='cartographer_ros', executable='cartographer_occupancy_grid_node', name='cartographer_occupancy_grid_node', output='screen', parameters=[{'use_sim_time': True, 'resolution': 0.05}]) rviz = Node( package='rviz2', executable='rviz2', arguments=['-d', os.path.join(pkg_bringup, 'config', 'slam.rviz')], output='screen') return LaunchDescription([cartographer_node, occupancy_grid, rviz]) ``` 注意:Cartographer 不像 slam_toolbox 那样直接发布 `/map`,而是由独立的 **cartographer_occupancy_grid_node** 把内部子图转成 `/map` 话题。 #### 4.2 完整运行流程 ```bash # 终端 1:仿真小车(复用第 20 篇) ros2 launch my_robot_bringup gazebo.launch.py # 终端 2:Cartographer 建图 ros2 launch my_robot_bringup cartographer_mapping.launch.py # 终端 3:遥控 ros2 run teleop_twist_keyboard teleop_twist_keyboard ``` #### 4.3 建图技巧(针对 Cartographer) 1. **起步原地转一圈**:让前端建立初始子图,之后匹配更稳。 2. **匀速慢行**(0.2-0.3 m/s):激光帧重叠充分。 3. **主动回环**:地图走到一半,原路返回起点区域——Cartographer 检测到回环后全局优化,地图会"啪"地收紧。 4. **观察子图边界**:RViz 里 `Submaps` 显示每个子图,定稿的子图不再变化。 ### 五、保存与加载地图 #### 5.1 保存(与 slam_toolbox 相同) ```bash ros2 run nav2_map_server map_saver_cli -f ~/maps/carto_map ``` #### 5.2 质量对比观察 把 slam_toolbox 与 Cartographer 各建一张同样场地的地图: - 绕两圈后:slam_toolbox 地图墙角可能有轻微重影;Cartographer 通常更"干净"。 - 长廊场景差异最明显:Cartographer 全局优化会把长走廊拉直。 **不必神化 Cartographer**:小车室内小场景两者差距不大;选型看场地大小和精度需求。 ### 六、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 启动报 `Failed to parse config` | lua 语法或路径错误 | 检查 `configuration_directory`;lua 用 `include` 的文件要存在 | | 地图空白 | `/scan` 没输入 | `ros2 topic hz /scan`;`num_laser_scans=1` 与话题数匹配 | | 地图旋转/镜像 | TF 方向错 | 核对 `tracking_frame`/`published_frame`/`odom_frame` | | 回环不闭合 | `min_score` 太高或场地对称 | 降到 0.5;制造非对称特征 | | 后端优化卡顿 | 节点太多 | 调大 `optimize_every_n_nodes`(如 180) | | 内存暴涨 | 地图太大太久没回环 | 控制建图时长,及时保存地图 | | `/map` 不发布 | 缺 occupancy_grid 节点 | 单独启动 `cartographer_occupancy_grid_node` | | 与 slam_toolbox 同时跑冲突 | 两个 SLAM 抢 /map 和 TF | 一次只跑一个 SLAM | #### 6.1 调试命令 ```bash # 看 Cartographer 是否在发布 TF ros2 run tf2_ros tf2_echo map base_link # 看子图话题(RViz Submaps 需要) ros2 topic list | grep submap # /submap_list、/tracked_pose、/scan_matched_points2 等 ``` ### 七、小结与下一篇预告 **本篇核心**: 1. **Cartographer 定位**:Google 开源,子图+回环全局优化,2D/3D 通吃,大场景首选。 2. **子图机制**:局部帧→子图→定稿,`num_range_data=90` 控制子图大小。 3. **前后端分离**:前端实时建图、后端回环优化,`optimize_every_n_nodes=90` 控制优化频率。 4. **Lua 配置**:参数用 lua 写,`map_frame`/`tracking_frame`/`published_frame` 三坐标系要对齐。 5. **建图流程**:原地转圈初始化→慢速移动→主动回环→occupancy_grid 转 /map→保存。 6. **选型**:小场景 slam_toolbox 够用,大/复杂场景上 Cartographer。 下一篇进入 **AMCL 定位与地图**(第 23 篇):地图建好了,机器人重启后怎么"知道自己在哪"?AMCL(自适应蒙特卡洛定位)用粒子滤波在地图里估计位姿,把 SLAM 建好的地图变成可复用的"定位导航底图"。从此机器人拿到地图就能自我定位,Nav2 导航正式进入倒计时。地图在手,定位无忧。 --- ## ROS2系列教程:SLAM建图(slam\_toolbox) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-slam-jian-tu-slam-toolbox/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 21 篇 > 本文是 ROS2 系列教程的第 21 篇:SLAM 建图(slam_toolbox)。前 20 篇我们完成了建模仿真阶段——小车有模型、有物理、有传感器,但"它不知道自己在哪里,也不知道周围长什么样"。SLAM(Simultaneous Localization and Mapping,同时定位与建图)解决的就是这个问题:机器人一边运动,一边用激光雷达扫描周围,同时估计自身位姿并拼接出地图。本篇以 ROS2 最主流的 **slam_toolbox** 为例:讲透 SLAM 原理、laser_slam 参数、建图流程与地图保存,让你用手上的仿真小车跑出第一张环境地图。地图出炉之日,就是导航(第 23-25 篇)开工之时。 ### 一、SLAM 是什么 #### 1.1 名字拆解 SLAM = Simultaneous Localization And Mapping: - **Localization(定位)**:机器人在哪?(估计位姿 x, y, θ) - **Mapping(建图)**:周围长什么样?(构建占据栅格地图) - **Simultaneous(同时)**:两者互相依赖——建图需要知道位姿,定位需要知道地图。经典"鸡生蛋"问题,SLAM 算法通过概率方法(粒子滤波、图优化)同时求解。 #### 1.2 输入与输出 ``` 输入:/scan(激光)+ /odom(里程计)+ /tf(odom→base_link) 输出:/map(栅格地图)+ /map_metadata + TF(map→odom) ``` | 话题 | 消息类型 | 作用 | | --- | --- | --- | | `/scan` | `sensor_msgs/msg/LaserScan` | 环境扫描 | | `/odom` | `nav_msgs/msg/Odometry` | 里程计预测 | | `/map` | `nav_msgs/msg/OccupancyGrid` | 输出地图(栅格图) | #### 1.3 为什么用激光 SLAM - **精度高**:激光测距毫米级,室内效果极佳。 - **成熟稳定**:slam_toolbox、Cartographer、Gmapping 都是久经考验的方案。 - **计算可控**:单线激光在普通 CPU 上实时运行。 视觉 SLAM(ORB-SLAM 等)是另一条路线,对算力和光照要求高,机器人导航主流仍是激光。 ### 二、slam_toolbox 快速上手 #### 2.1 安装 ```bash sudo apt install ros-humble-slam-toolbox # 验证 ros2 pkg list | grep slam_toolbox ``` #### 2.2 最简启动 ```bash ros2 run slam_toolbox sync_slam_toolbox_node \ --ros-args \ -p use_sim_time:=true \ -p scan_topic:=scan \ -p odom_frame:=odom \ -p map_frame:=map \ -p base_frame:=base_link \ -p mode:=mapping ``` 启动后节点订阅 `/scan`,结合 TF 树开始建图,并发布 `/map`。 #### 2.3 在 RViz 中看地图 ```bash rviz2 ``` RViz 添加: 1. **Map**(topic `/map`):栅格地图,黑色=占据、白色=空闲、灰色=未知。 2. **TF**:注意多了 `map` 坐标系。 3. **LaserScan**:激光数据与地图实时对齐。 **控制小车移动,地图逐渐"画"出来**——这就是 SLAM 建图的直观体验。 ### 三、laser_slam 配置详解 工程中用 YAML 配置文件管理参数(`slam_params.yaml`): ```yaml slam_toolbox: ros__parameters: # ---- 坐标系 ---- map_frame: map odom_frame: odom base_frame: base_link scan_topic: /scan # ---- 建图模式 ---- mode: mapping # mapping | localization | localization+localization # ---- 激光 ---- max_laser_range: 10.0 min_laser_range: 0.1 transform_tolerance: 0.3 # ---- 图优化(核心) ---- minimum_travel_distance: 0.5 # 移动 0.5m 才加入新帧 minimum_travel_heading: 0.5 # 旋转 0.5rad 才加入新帧 loop_match_minimum_chain_size: 10 loop_search_maximum_distance: 3.0 # ---- 性能 ---- resolution: 0.05 # 栅格分辨率(米/格) throttle_scans: 1 # 每 N 帧处理一帧 update_min_distance: 0.3 ``` #### 3.1 坐标系三件套(必须与你的 TF 树一致) | 参数 | 含义 | 我们的仿真车 | | --- | --- | --- | | `map_frame` | 地图坐标系 | `map` | | `odom_frame` | 里程计坐标系 | `odom` | | `base_frame` | 机器人底盘 | `base_link` | **一致性是 SLAM 能跑通的第一前提**:diff_drive 插件发布 `odom→base_link`,slam_toolbox 需要它。 #### 3.2 mode 的三种模式 | 模式 | 用途 | 说明 | | --- | --- | --- | | `mapping` | 建图 | 无初始地图,从零构建 | | `localization` | 定位 | 加载已有地图,只定位 | | `localization+localization` | 定位增强 | 边定位边小幅更新地图 | 第 23 篇 AMCL 定位前,可以先试 slam_toolbox 的 localization 模式。 #### 3.3 关键图优化参数 slam_toolbox 基于**图优化**:每个位姿是一个"节点",帧间约束和回环约束是"边"。 | 参数 | 作用 | 调参建议 | | --- | --- | --- | | `minimum_travel_distance` | 移动多远才插入新节点 | 太小帧太多、太大地图稀疏;小车 0.3-0.5 | | `minimum_travel_heading` | 旋转多少才插入新节点 | 0.3-0.5 | | `loop_search_maximum_distance` | 回环搜索半径 | 场地大就调大 | | `loop_match_minimum_chain_size` | 回环最小链长 | 默认即可 | **回环(loop closure)** 是 SLAM 消除累积误差的关键:走回原点时把首尾接上,误差瞬间清零。 ### 四、建图完整流程 #### 4.1 一键启动 launch(recommended) `mapping.launch.py`: ```python from launch import LaunchDescription from launch_ros.actions import Node from launch.substitutions import Command import os from ament_index_python.packages import get_package_share_directory def generate_launch_description(): pkg_bringup = get_package_share_directory('my_robot_bringup') slam_params = os.path.join(pkg_bringup, 'config', 'slam_params.yaml') slam_toolbox = Node( parameters=[slam_params], package='slam_toolbox', executable='sync_slam_toolbox_node', name='slam_toolbox', output='screen') rviz = Node( package='rviz2', executable='rviz2', arguments=['-d', os.path.join(pkg_bringup, 'config', 'slam.rviz')], output='screen') return LaunchDescription([slam_toolbox, rviz]) ``` 配合仿真: ```bash # 终端 1:仿真小车 ros2 launch my_robot_bringup gazebo.launch.py # 终端 2:SLAM 建图 + RViz ros2 launch my_robot_bringup mapping.launch.py ``` #### 4.2 遥控小车建图 ```bash ros2 run teleop_twist_keyboard teleop_twist_keyboard ``` **建图口诀:慢速、全覆盖、走回环**。 1. 慢速移动(0.2-0.3 m/s),激光才有足够重叠。 2. 沿墙走,让所有区域被扫到。 3. 每走一段返回起点附近,制造回环,消除漂移。 #### 4.3 边建图边看质量 ```bash # 地图话题频率(应持续更新) ros2 topic hz /map # 当前位姿 ros2 topic echo /tf --once ``` RViz 中观察:地图边界是否清晰、墙是否笔直、是否出现重影(回环没闭合的典型症状)。 #### 4.4 保存地图 建图完成后,用 `map_saver_cli` 保存: ```bash # 保存为 map.pgm(图像)+ map.yaml(元数据) ros2 run nav2_map_server map_saver_cli -f ~/maps/my_map ``` 生成两个文件: - `my_map.pgm`:栅格图像(黑白灰)。 - `my_map.yaml`:元数据(分辨率、原点、占用阈值)。 ```yaml image: my_map.pgm resolution: 0.050000 origin: [-8.000000, -8.000000, 0.000000] negate: 0 occupied_thresh: 0.65 free_thresh: 0.196 ``` `resolution` 必须与 slam_toolbox 的 `resolution` 一致,否则加载地图会失真。 ### 五、地图坐标系与 TF 树 #### 5.1 建图时的 TF 树 ``` map → odom → base_link → lidar_link ``` - `map→odom`:由 **slam_toolbox 维护**(激光匹配修正后的位姿漂移)。 - `odom→base_link`:由 diff_drive 插件发布(里程计)。 - 其他:robot_state_publisher 发布。 #### 5.2 map 与 odom 的区别 | 坐标系 | 由谁发布 | 特性 | | --- | --- | --- | | `odom` | 里程计(diff_drive) | 短期准、长期漂移 | | `map` | SLAM/定位 | 全局一致、修正漂移 | 理解:**odom 是"猜测我走了多远",map 是"我在世界哪里"**。SLAM 用激光匹配不断修正 map→odom 的偏差。 #### 5.3 常见 TF 错误 ```bash ros2 run tf2_ros tf2_echo map base_link # 若报 extrapolation,多半是 use_sim_time 没开或 transform_tolerance 太小 ``` ### 六、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 地图空白 | 激光没数据或 TF 缺 | `ros2 topic hz /scan`;`tf2_echo map base_link` | | 地图歪斜/重影 | 回环没闭合 | 控制小车回原点走一圈 | | 地图漂移严重 | 里程计不准 | 检查轮距/轮径参数与 URDF 是否一致 | | `slam_toolbox` 启动即崩 | 参数错误或依赖缺失 | `--verbose` 看日志;检查 YAML 格式 | | 地图只有一条线 | 车没动或激光角度覆盖不足 | 移动小车;检查 `min_angle/max_angle` | | `/map` 不发布 | 无 scan 输入 | 确认 scan_topic 名字 | | TF 报 extrapolation | 时间戳不一致 | 所有节点 `use_sim_time:=true` | | 地图模糊 | 移动太快 | 降速到 0.2-0.3 m/s 重新建图 | | 保存的 pgm 打开是黑图 | 没有足够空闲区域被扫描 | 多扫几圈再保存 | #### 6.1 排查命令三板斧 ```bash # 1. 激光有没有 ros2 topic hz /scan # 2. TF 树通不通 ros2 run tf2_ros tf2_echo map base_link # 3. 地图在不在变 ros2 topic hz /map ``` ### 七、slam_toolbox 与其他 SLAM 对比 | 方案 | 类型 | 特点 | 适用 | | --- | --- | --- | --- | | slam_toolbox | 图优化 2D | 轻量、可回环、可重定位 | 室内小车(推荐入门) | | Cartographer | 图优化 2D/3D | 精度高、回环强、资源消耗大 | 复杂场景、仓库(下一篇) | | Gmapping | 粒子滤波 | 经典、直观、无回环 | 教学演示 | 入门选 slam_toolbox:参数少、效果好、资料多。进阶需要更高精度或 3D 时再上 Cartographer。 ### 八、小结与下一篇预告 **本篇核心**: 1. **SLAM 本质**:同时定位 + 建图,激光 + 里程计 + TF 三输入,地图一输出。 2. **slam_toolbox**:`sync_slam_toolbox_node`,YAML 参数,mapping/localization 双模式。 3. **坐标系**:map→odom→base_link→lidar_link,一致性是前提。 4. **建图流程**:启动仿真 → 启动 SLAM → 慢速全覆盖移动 → 走回环 → 保存地图。 5. **地图产物**:`map.pgm + map.yaml`,resolution 必须一致。 6. 回环闭合是消除累积误差的核心操作。 下一篇进入 **Cartographer 建图进阶**(第 22 篇):Google 开源的 Cartographer 精度更高、回环更强,适合大场景和复杂环境。我们会对比它与 slam_toolbox 的差异、配置 2D 建图参数、处理子图(submap)概念。地图越建越好,定位与导航才有好底子。第一张地图已经出炉,接下来就是让精度"更上一层楼"。 --- ## ROS2系列教程:机器人模型导入仿真实战 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-ji-qi-ren-mo-xing-dao-ru-fang-zhen/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 20 篇 > 本文是 ROS2 系列教程的第 20 篇:机器人模型导入仿真实战。前四篇我们分别学会了 URDF 建模、Xacro 参数化、Gazebo 世界搭建、传感器插件配置,但都是"点状"知识。本篇把它们串成一条完整生产线:从零构建一个规范的仿真工程(description + bringup 双包结构),把 Xacro 小车 + 激光雷达 + IMU + 差速插件整合,一键 launch 进 Gazebo,并用 RViz 做全链路验收。这是建模仿真阶段的收官之战,做完你将拥有一个"开箱即用"的仿真小车,直接喂给第 21 篇的 SLAM 建图。 ### 一、仿真工程的规范结构 #### 1.1 为什么拆两个包 大型 ROS2 工程通常拆成两个功能包: | 包 | 职责 | 内容 | | --- | --- | --- | | `xxx_description` | 描述机器人 | URDF/Xacro、网格、launch(显示用) | | `xxx_bringup` | 启动系统 | world 文件、launch(仿真/导航/完整系统) | 分工原则:**description 只管"机器人长什么样",bringup 管"系统怎么跑"**。导航、SLAM 等后续阶段都在 bringup 里加 launch,description 不变。 #### 1.2 目录结构 ```text my_robot_ws/src/ ├── my_robot_description/ │ ├── CMakeLists.txt │ ├── package.xml │ ├── urdf/ │ │ └── demo_car.xacro │ ├── meshes/ # 自定义网格(可选) │ └── launch/ │ └── display.launch.py # RViz 显示 └── my_robot_bringup/ ├── CMakeLists.txt ├── package.xml ├── worlds/ │ └── my_world.world └── launch/ └── gazebo.launch.py # 仿真启动 └── sim.launch.py # 仿真 + 显示 ``` #### 1.3 建包命令 ```bash cd ~/my_robot_ws/src ros2 pkg create my_robot_description --build-type ament_cmake --license Apache-2.0 ros2 pkg create my_robot_bringup --build-type ament_cmake --license Apache-2.0 mkdir -p my_robot_description/urdf my_robot_description/launch mkdir -p my_robot_bringup/worlds my_robot_bringup/launch ``` CMakeLists.txt 安装目录(关键,否则 launch/urdf 找不到): ```cmake install(DIRECTORY urdf launch DESTINATION share/${PROJECT_NAME}) # bringup 包同样 install(DIRECTORY worlds launch DESTINATION share/${PROJECT_NAME}) ``` ### 二、整合版 Xacro:小车全家桶 #### 2.1 完整 demo_car.xacro 把前几篇的组件合成一个文件(属性 + 底盘 + 轮子宏 + 激光 + IMU + 差速插件): ```xml / cmd_vel:=cmd_vel 30 base_to_wheel_1 base_to_wheel_-1 0.38 0.16 20 1.0 cmd_vel true true odom base_link joint_states:=joint_states 30 base_to_wheel_1 base_to_wheel_-1 true 10 true scan:=scan lidar_link 0.12 10.0 10 360 1 -3.14159265 3.14159265 0.12 10.0 0.01 true 100 false imu/data:=imu/data false 100 ``` #### 2.2 语法检查 ```bash # 展开并检查 xacro demo_car.xacro | check_urdf # 应输出 root Link: base_link 有 4 个 child:wheel_1、wheel_-1、lidar_link、imu_link ``` ### 三、世界文件:建图专用迷宫 #### 3.1 my_world.world 为 SLAM 建图准备一个有墙的世界: ```xml model://sun model://ground_plane true 4.0 0.2 0.5 4.0 0.2 0.5 0.6 0.6 0.6 1 0 2 0.25 0 0 0 true 4.0 0.2 0.5 4.0 0.2 0.5 0.6 0.6 0.6 1 0 -2 0.25 0 0 0 true 0.2 4.0 0.5 0.2 4.0 0.5 0.6 0.6 0.6 1 2 0 0.25 0 0 0 true 0.2 4.0 0.5 0.2 4.0 0.5 0.6 0.6 0.6 1 -2 0 0.25 0 0 0 true 0.4 0.4 0.5 0.4 0.4 0.5 0.8 0.5 0.2 1 0.8 0.8 0.25 0 0 0 true 0.4 0.4 0.5 0.4 0.4 0.5 0.8 0.5 0.2 1 -0.8 -0.8 0.25 0 0 0 ``` #### 3.2 检查世界 ```bash # 用 gazebo 直接打开验证(不跑 ROS) gazebo my_world.world # 应看到四面墙 + 两个橙色障碍物 ``` ### 四、一键启动 launch #### 4.1 gazebo.launch.py(仿真核心) ```python from launch import LaunchDescription from launch.actions import IncludeLaunchDescription, SetEnvironmentVariable from launch.launch_description_sources import PythonLaunchDescriptionSource from launch_ros.actions import Node from launch.substitutions import Command import os from ament_index_python.packages import get_package_share_directory def generate_launch_description(): pkg_bringup = get_package_share_directory('my_robot_bringup') pkg_desc = get_package_share_directory('my_robot_description') world = os.path.join(pkg_bringup, 'worlds', 'my_world.world') xacro_file = os.path.join(pkg_desc, 'urdf', 'demo_car.xacro') gazebo = IncludeLaunchDescription( PythonLaunchDescriptionSource( os.path.join(get_package_share_directory('gazebo_ros'), 'launch', 'gazebo.launch.py')), launch_arguments={'world': world}.items()) robot_state_publisher = Node( package='robot_state_publisher', executable='robot_state_publisher', parameters=[{'robot_description': Command(['xacro ', xacro_file])}], output='screen') spawn = Node( package='gazebo_ros', executable='spawn_entity.py', arguments=['-topic', 'robot_description', '-entity', 'demo_car', '-x', '0', '-y', '0', '-z', '0.1'], output='screen') return LaunchDescription([ SetEnvironmentVariable('GZ_SIM_RESOURCE_PATH', os.path.join(pkg_desc, 'meshes')), gazebo, robot_state_publisher, spawn, ]) ``` #### 4.2 sim.launch.py(仿真 + RViz 一键启动) ```python from launch import LaunchDescription from launch.actions import IncludeLaunchDescription from launch.launch_description_sources import PythonLaunchDescriptionSource from launch_ros.actions import Node import os from ament_index_python.packages import get_package_share_directory def generate_launch_description(): gazebo_launch = IncludeLaunchDescription( PythonLaunchDescriptionSource( os.path.join(get_package_share_directory('my_robot_bringup'), 'launch', 'gazebo.launch.py'))) rviz = Node( package='rviz2', executable='rviz2', arguments=['-d', os.path.join( get_package_share_directory('my_robot_bringup'), 'config', 'sim.rviz')], output='screen') return LaunchDescription([gazebo_launch, rviz]) ``` #### 4.3 编译并运行 ```bash cd ~/my_robot_ws colcon build --symlink-install source install/setup.bash # 一键启动仿真 + RViz ros2 launch my_robot_bringup sim.launch.py ``` ### 五、全链路验收清单 #### 5.1 话题检查 ```bash ros2 topic list # 预期: # /clock 仿真时钟 # /cmd_vel 速度指令 # /imu/data IMU 数据 # /joint_states 关节状态 # /odom 里程计 # /robot_description # /scan 激光 # /tf /tf_static ``` #### 5.2 数据检查 ```bash # 激光有数据(rate ≈ 10Hz) ros2 topic hz /scan # IMU 有数据(rate ≈ 100Hz) ros2 topic hz /imu/data # TF 树完整:world -> odom -> base_link -> lidar_link/imu_link/wheel_* ros2 run tf2_ros tf2_echo base_link lidar_link ``` #### 5.3 RViz 可视化检查 RViz 添加: 1. **RobotModel**:小车模型(蓝底盘、黑轮、红雷达)。 2. **TF**:坐标系树(确认 odom 在,且随车移动)。 3. **LaserScan**(topic `/scan`):看到墙的轮廓。 4. **PointCloud2 或 Image**(如配置了相机)。 #### 5.4 运动测试 ```bash # 键盘控制小车跑一圈 ros2 run teleop_twist_keyboard teleop_twist_keyboard # 按 i 前进——RViz 里激光轮廓随车移动,odom 位置变化,TF 树更新 ``` ### 六、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | `launch 找不到 urdf/world` | 未安装目录 | CMake `install(DIRECTORY urdf ...)`,重新 colcon build | | `spawn_entity` 找不到模型 | robot_description 空 | 单独 `ros2 param get /robot_state_publisher robot_description` | | 激光 /scan 无数据 | 插件没加载或 reference 错 | `gazebo --verbose` 看日志;核对 `reference="lidar_link"` | | TF 缺 odom | diff_drive 插件没跑 | 检查插件是否在 xacro 里、joint 名是否匹配 | | RViz 里车在抖动 | 物理步长或 odom 频率不匹配 | `max_step_size=0.001`;diff_drive `update_rate=30` | | 车被弹飞 | 模型与地面穿插 | spawn `-z` 抬到 0.1 以上 | | `use_sim_time` 警告 | 部分节点没开仿真时间 | 导航/感知节点统一 `use_sim_time:=true` | | Gazebo 卡死/黑屏 | GPU 或显示问题 | `gazebo --verbose`;或 headless 模式 | #### 6.1 常用调试三板斧 ```bash # 1. 看完整日志 ros2 launch my_robot_bringup sim.launch.py --verbose # 2. 看 TF 树图 ros2 run tf2_tools view_frames # 生成 frames.pdf # 3. 单项验证(跳过 RViz) ros2 launch my_robot_bringup gazebo.launch.py ``` ### 七、小结与下一篇预告 **本篇核心**: 1. **工程规范**:description(模型)+ bringup(系统)双包结构,CMake 安装目录。 2. **整合 Xacro**:属性+宏+传感器+插件全家桶,一个文件搞定整台仿真车。 3. **世界构建**:SDF 迷宫(四面墙+障碍物),为 SLAM 建图备好场景。 4. **一键启动**:`sim.launch.py` = Gazebo + robot_state_publisher + spawn + RViz。 5. **验收清单**:话题、数据频率、TF 树、RViz 显示、运动测试五步闭环。 6. 从此拥有可复用的仿真小车,后续阶段直接复用。 下一篇开启 **Nav2 导航阶段**(第 21 篇):SLAM 建图(slam_toolbox)。现在小车有了模型、物理、传感器、里程计——是时候让它"认识"环境了。用 slam_toolbox 控制小车走一圈,激光+里程计融合生成地图,为 AMCL 定位和 Nav2 自主导航铺路。建模仿真阶段收官,自主移动阶段开启。 --- ## ROS2系列教程:Gazebo插件(关节控制/IMU/激光雷达) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-gazebo-cha-jian-guan-jie-kong-zhi/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 19 篇 > 本文是 ROS2 系列教程的第 19 篇:Gazebo 插件(关节控制/IMU/激光雷达)。上一篇小车能在 Gazebo 里跑动了,但它还是个"瞎子"——没有传感器,不知道周围有什么,也感受不到自身姿态。Gazebo 插件(plugin)就是给仿真模型装上"器官":关节驱动插件让轮子转、IMU 插件报告姿态与角速度、激光雷达插件发出点云、相机插件输出图像。本篇逐个拆解 gazebo_ros 最常用的四类插件,教你正确配置参数、把传感器数据接入 ROS2 话题并在 RViz 中可视化。学完你的仿真小车就"眼明身正",具备了进入 SLAM 建图(第 21 篇)的全部条件。 ### 一、Gazebo 插件机制 #### 1.1 插件是什么 Gazebo 插件是**动态库**(.so),在仿真中被加载进指定实体(世界/模型/传感器),把 Gazebo 内部事件与 ROS2 话题/服务对接。`gazebo_ros` 包预编译了一批官方插件,都在 `libgazebo_ros_*.so`。 插件写在 URDF 的 `` 标签里(放在 `` 内、link/joint 之外): ```xml ... ``` 也可以挂在**指定 link 的传感器**上: ```xml ... ``` **重要**:`` 标签只在 Gazebo 加载时解析,`check_urdf`、RViz、MoveIt2 都会忽略它——所以往 URDF 里加仿真配置不影响其他工具。 #### 1.2 插件分类总览 | 类别 | 插件 | 功能 | 输出话题 | | --- | --- | --- | --- | | 关节控制 | `libgazebo_ros_diff_drive.so` | 差速驱动 | `/odom`、`/tf` | | 关节控制 | `libgazebo_ros_joint_state_publisher.so` | 发布关节状态 | `/joint_states` | | 关节控制 | `libgazebo_ros_joint_pose_trajectory.so` | 关节轨迹控制 | 服务 | | 传感器 | `libgazebo_ros_gpu_laser.so` | 激光雷达(GPU 加速) | `/scan` | | 传感器 | `libgazebo_ros_camera.so` | 相机 | `/image_raw`、`/camera_info` | | 传感器 | `libgazebo_ros_imu.so` | IMU | `/imu/data` | | 传感器 | `libgazebo_ros_ray_sensor.so` | 非 GPU 激光/声纳 | `/scan` | | 传感器 | `libgazebo_ros_depth_camera.so` | 深度相机 | `/depth`、`/points` | #### 1.3 通用 `` 配置块 每个插件都支持 `` 块统一定义命名空间、重映射与参数: ```xml /robot scan:=/robot/scan laser_link ``` | 元素 | 作用 | | --- | --- | | `` | 话题/服务前缀(多机器人隔离用) | | `` | 话题重映射(`旧:=新`) | | `` | 传给插件的参数(如 frame_name) | | `` | 数值参数 | ### 二、关节控制插件:让机器人动起来 #### 2.1 joint_state_publisher:关节状态上报 差速插件自己会驱动轮子,但**关节角度信息**需要单独上报给 `robot_state_publisher` 播 TF: ```xml joint_states:=joint_states 30 base_to_wheel_1 base_to_wheel_-1 ``` 验证: ```bash ros2 topic echo /joint_states --once # 输出两个轮子的 position(弧度),会随运动变化 ``` #### 2.2 差速驱动插件的完整回顾 上篇的 diff_drive 插件已经覆盖:订阅 `cmd_vel`、驱动左右轮、发布 odom 和 odom→base_link TF。本篇补充三点进阶: ```xml / cmd_vel:=cmd_vel odom:=odom 30 base_to_wheel_1 base_to_wheel_-1 0.38 0.16 20 1.0 true true odom base_link ``` **轮距与轮径必须与 URDF 一致**(`wheel_separation = 2 × wheel_base`,`wheel_diameter = 2 × wheel_radius`),否则里程计算错、导航走偏。 #### 2.3 关节轨迹插件(机械臂场景) 机械臂用轨迹控制而不是速度控制: ```xml /arm joint_trajectory:=joint_trajectory 30 joint1 joint2 joint3 0 0 0 ``` 它提供 `follow_joint_trajectory` 动作服务,MoveIt2 直接调用(第 36 篇详解)。 ### 三、IMU 插件:给机器人"内耳" #### 3.1 配置 ```xml true 100 false imu/data:=imu/data false 100 ``` 注意 `reference="base_link"`:传感器挂在 base_link 坐标系上。 #### 3.2 IMU 输出什么 ```bash ros2 topic echo /imu/data --once ``` `sensor_msgs/msg/Imu` 消息包含: | 字段 | 含义 | 单位 | | --- | --- | --- | | `orientation` | 姿态四元数 | 无单位(归一化) | | `angular_velocity` | 角速度 | rad/s | | `linear_acceleration` | 线性加速度(含重力) | m/s² | | `orientation_covariance` | 姿态协方差 | - | **关键认知**:仿真 IMU 默认是"完美"的。想模拟真实噪声,需在 `` 里加噪声模型: ```xml 0 0 0 0.001 0.001 0.001 ``` ### 四、激光雷达插件:机器人的"眼睛" #### 4.1 GPU 激光配置(推荐) ```xml true 10 true 0 0 0 0 0 0 scan:=scan lidar_link 0.12 10.0 10 360 1 -3.14159265 3.14159265 0.12 10.0 0.01 gaussian 0.0 0.01 ``` 关键参数: | 参数 | 含义 | 常用值 | | --- | --- | --- | | `samples` | 扫描点数(分辨率) | 360/720 | | `min_angle`/`max_angle` | 扫描范围 | ±π(360°)或 ±1.57(180°) | | `min`/`max`(range) | 测距范围 | 0.12~10 米 | | `stddev`(noise) | 测距高斯噪声 | 0.01(厘米级) | | `frame_name` | 输出帧名 | 必须与 URDF 的 lidar link 一致 | `gpu_ray` 用 GPU 加速,10Hz/360 点无压力;老式 `ray`(非 GPU)CPU 密集,场景简单时才用。 #### 4.2 数据验证 ```bash ros2 topic hz /scan # average rate: 10.0 ros2 topic echo /scan --once # ranges: [3.12, 3.11, ...] (单位米) # RViz 中可视化 rviz2 # 添加 LaserScan,Fixed Frame 设为 lidar_link ``` **在 RViz 里看激光**:Add → By topic → /scan → LaserScan。周围有墙时能看到一圈点云轮廓;空地则是"外圈大圆"。 #### 4.3 激光数据与 TF 的关系 `/scan` 消息的 `header.frame_id` 是 `lidar_link`,表示"数据是在雷达坐标系测的"。SLAM 建图时,算法通过 TF 树(`odom→base_link→lidar_link`)把激光点变换到全局坐标系,所以 **TF 树不完整 → 激光数据无法使用**,这是建图最常见的报错(`Lookup would require extrapolation`)。 ### 五、相机插件:让机器人"看见" #### 5.1 普通相机 ```xml true 30 true image_raw:=image_raw camera_info:=camera_info camera_link 640 480 RGB8 320 320 1.396 1.396 640 480 R8G8B8 ``` 输出话题: - `/image_raw`(`sensor_msgs/msg/Image`):原始图像。 - `/camera_info`(`sensor_msgs/msg/CameraInfo`):内参矩阵,视觉处理必需。 #### 5.2 查看相机图像 ```bash # 命令行查看(需要 rqt) rqt_image_view /image_raw # 或 RViz 添加 Image rviz2 # Add → By topic → /image_raw → Image ``` #### 5.3 深度相机(RGB-D) ```xml true 30 image_raw:=depth/image_raw camera_info:=depth/camera_info points:=depth/points depth_camera_link ``` 输出: - `/depth/image_raw`:深度图(16 位,毫米)。 - `/depth/points`:点云(`sensor_msgs/msg/PointCloud2`),RViz 中 PointCloud2 可直接显示。 ### 六、传感器参数对照速查表 | 传感器 | 插件 .so | 主要输出 | 关键配置 | | --- | --- | --- | --- | | 差速轮 | diff_drive | `/odom`、`/tf` | 左右关节名、轮距轮径 | | 关节状态 | joint_state_publisher | `/joint_states` | 关节名列表 | | 激光 | gpu_laser | `/scan` | samples、角度范围、max_range | | IMU | imu | `/imu/data` | 挂载 link、噪声 | | 相机 | camera | `/image_raw`、`/camera_info` | 分辨率、焦距、FOV | | 深度相机 | depth_camera | `/depth/points` | frame_name | ### 七、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | 插件话题不存在 | 插件未加载 | `gazebo --verbose` 看加载日志;确认 filename 拼写 | | `/scan` 没数据但话题在 | 传感器没挂对 link 或 TF 缺 | 检查 `reference` 与 `frame_name`;TF 树是否含该 link | | 激光全是一个大值 | 没障碍物或范围超限 | 检查 `max_range` 与世界中是否有墙 | | `/imu/data` 全是 0 | 模型静止且无重力变化 | 属正常;运动后再看 | | 相机黑屏 | 相机朝向地面/墙 | 检查 `pose` 朝向(rpy 通常 0 -1.57 0 朝前) | | 轮子乱转不前进 | 左右轮 joint 配反 | diff_drive 的 left/right 与 URDF 一致 | | RViz 显示 TF 报 extrapolation | use_sim_time 未开 | 所有节点 `use_sim_time:=true` | | Gazebo 卡顿 | 激光 samples 太大或 GUI 重 | 降 samples(如 720→360)、关 visualize | | 传感器延迟大 | update_rate 太低 | 激光 10Hz、IMU 100Hz、相机 30Hz 是常见基线 | #### 7.1 插件加载失败的通用排查 ```bash # 启动时加 --verbose 看完整日志 ros2 launch my_robot_bringup gazebo.launch.py --verbose # 关键日志特征 # [Msg] Loading plugin [libgazebo_ros_gpu_laser.so] → 加载成功 # [Err] ... failed to load plugin ... → 找文件名/路径问题 ``` ### 八、小结与下一篇预告 **本篇核心**: 1. **插件机制**:`` 标签 + `libgazebo_ros_*.so` 动态库,只影响仿真不影响 URDF 解析。 2. **关节控制三件套**:diff_drive(速度控制+odom)、joint_state_publisher(关节状态)、joint_pose_trajectory(机械臂轨迹)。 3. **IMU**:挂 base_link,输出四元数/角速度/加速度,可加高斯噪声模拟真实传感器。 4. **激光雷达**:`gpu_ray` 高效,配置 samples/范围/噪声,输出 `/scan`,SLAM 建图的直接输入。 5. **相机**:RGB 图像 + CameraInfo 内参;深度相机额外输出点云。 6. **TF 是传感器数据的命脉**:所有传感器数据都依赖 TF 树完整才能被下游使用。 下一篇进入 **机器人模型导入仿真实战**:把前几篇的 Xacro 小车、传感器、差速插件整合成完整工程,一键 launch 起仿真实战,并用 RViz 完整验证。这是建模仿真阶段的"大考":从 URDF 建模到传感器数据全链路打通,为 Nav2 导航阶段(第 21-25 篇)铺路。模型、物理、感知三者齐备,机器人准备"出发"。 --- ## ROS2系列教程:Gazebo仿真基础与世界搭建 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-gazebo-fang-zhen-ji-chu-yu-shi-jie/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 18 篇 > 本文是 ROS2 系列教程的第 18 篇:Gazebo 仿真基础与世界搭建。前两篇我们用 URDF/Xacro 描述了机器人的"骨骼",但骨骼自己不会动、不会掉下来、也不会感知。Gazebo 就是那个给机器人注入物理规律的"虚拟世界":重力、摩擦、碰撞、传感器噪声全部真实可感。本篇从零搭建 Gazebo 环境:理解 world 文件结构、掌握 `gazebo_ros` 桥接包、把小车 spawn 进世界、用 ROS2 命令控制它动起来。学完你就有了一套可复用的"仿真闭环",后续 SLAM 建图、Nav2 导航、MoveIt2 全部在此之上进行。 ### 一、Gazebo 是什么 #### 1.1 定位 Gazebo 是一个**高保真 3D 机器人仿真器**,提供: - **物理引擎**(ODE/Bullet 等):重力、摩擦、碰撞、关节约束。 - **传感器仿真**:激光雷达(gpu_laser)、相机、IMU、接触传感器。 - **世界构建**:静态物体、地形、光源、GUI 交互。 - **ROS2 桥接**:`gazebo_ros` 包把仿真世界和 ROS2 话题无缝对接。 在 ROS2 Humble 中,经典 Gazebo(Gazebo Classic 11)仍是教学主流,`ros_gz`(新 Gazebo 与 ROS2 的桥接)正在兴起。本篇以 **Gazebo Classic + gazebo_ros** 为例,概念对新版同样适用。 #### 1.2 安装 ```bash # Ubuntu 22.04 + ROS2 Humble sudo apt install ros-humble-gazebo-ros-pkgs # 验证 ros2 pkg list | grep gazebo # 输出:gazebo_dev、gazebo_msgs、gazebo_plugins、gazebo_ros、gazebo_ros_pkgs ``` 启动测试: ```bash gazebo --verbose ``` 如果打开一个带地板和天空的默认世界,说明安装成功。 #### 1.3 与 RViz 的分工 | 工具 | 作用 | 谁在"算" | | --- | --- | --- | | RViz | 可视化显示 | 只是"看",不产生物理 | | Gazebo | 物理仿真 | 真实计算重力/碰撞/传感器 | | 两者关系 | Gazebo 是"真实世界",RViz 是"仪表盘" | 常同时运行 | 理解:**Gazebo 里掉下来的东西真的会摔,RViz 里不会**。仿真流程中通常 Gazebo 跑物理,ROS2 节点发控制指令,RViz 显示 TF/地图/点云。 ### 二、world 文件:虚拟世界的"地图" #### 2.1 world 文件是什么 world 文件是 Gazebo 的 SDF(Simulation Description Format)文件,描述世界的物理属性、静态物体、光照等。它是 Gazebo 世界的"上帝视角配置"。 最小 world 文件: ```xml 0.001 1 model://sun model://ground_plane ``` #### 2.2 常用元素 | 元素 | 作用 | 关键属性 | | --- | --- | --- | | `` | 物理引擎参数 | `max_step_size`(仿真步长,越小越稳)、`real_time_factor`(实时因子) | | `model://sun` | 太阳光照 | 默认就有 | | `model://ground_plane` | 地面(带摩擦) | 默认就有 | | `` | 静态物体(墙/障碍) | 自己定义 | | `` | 环境背景色/雾 | 可选 | | `` | 重力向量 | 默认 `0 0 -9.8` | #### 2.3 添加障碍物(墙) ```xml true 2.0 0.2 0.5 2.0 0.2 0.5 0.8 0.2 0.2 1 0 0 0.25 0 0 0 ``` `true` 表示不可移动。把墙摆成迷宫,就有了 SLAM 建图的世界。 ### 三、把机器人放进世界:spawn_entity #### 3.1 gazebo_ros 的核心节点 `gazebo_ros` 提供两个关键可执行文件: - `gazebo`:启动仿真服务器与 GUI。 - `spawn_entity.py`:把 URDF/SDF 模型生成到世界中。 spawn 的本质:把 `robot_description` 参数里的 URDF 转成 SDF,插入 Gazebo 世界,并在 world 坐标系的指定位姿处放置。 #### 3.2 命令行手动 spawn ```bash # 1. 启动世界(先手动放好机器人) ros2 run gazebo_ros gazebo --verbose -s libgazebo_ros_factory.so # 2. 另一个终端:spawn 机器人到 (0,0,0.1) ros2 run gazebo_ros spawn_entity.py \ -topic robot_description \ -entity demo_car \ -x 0 -y 0 -z 0.1 ``` - `-topic robot_description`:从话题读 URDF(需要有人发布)。 - `-entity`:模型在 Gazebo 中的名字。 - `-x -y -z`:spawn 位置;`-R -P -Y`:姿态(欧拉角)。 #### 3.3 一步到位:launch 文件 工程实践中从 launch 启动,模板如下: ```python # gazebo.launch.py from launch import LaunchDescription from launch.actions import IncludeLaunchDescription, SetEnvironmentVariable from launch.launch_description_sources import PythonLaunchDescriptionSource from launch_ros.actions import Node from launch.substitutions import Command import os from ament_index_python.packages import get_package_share_directory def generate_launch_description(): # 1. 找到世界文件 world_path = os.path.join( get_package_share_directory('my_robot_bringup'), 'worlds', 'my_world.world') # 2. 启动 Gazebo(加载世界) gazebo = IncludeLaunchDescription( PythonLaunchDescriptionSource( os.path.join(get_package_share_directory('gazebo_ros'), 'launch', 'gazebo.launch.py')), launch_arguments={'world': world_path}.items(), ) # 3. 发布 robot_description(xacro 展开) robot_state_publisher = Node( package='robot_state_publisher', executable='robot_state_publisher', parameters=[{'robot_description': Command( ['xacro ', os.path.join( get_package_share_directory('my_robot_description'), 'urdf', 'demo_car.xacro')])}], output='screen', ) # 4. spawn 机器人 spawn_entity = Node( package='gazebo_ros', executable='spawn_entity.py', arguments=['-topic', 'robot_description', '-entity', 'demo_car', '-x', '0', '-y', '0', '-z', '0.1'], output='screen', ) return LaunchDescription([ SetEnvironmentVariable('GZ_SIM_RESOURCE_PATH', os.path.join( get_package_share_directory('my_robot_description'), 'meshes')), gazebo, robot_state_publisher, spawn_entity, ]) ``` 运行: ```bash ros2 launch my_robot_bringup gazebo.launch.py ``` 如果一切正常:Gazebo 窗口中出现小车,RViz(另开)能看到 TF 树。**此时小车会立刻被重力按在地面上**——因为惯性/碰撞体都配好了。 #### 3.4 常见失败:机器人掉穿地面 小车掉穿地面通常原因: 1. **模型碰撞体缺失**:检查每个 link 都有 ``。 2. **spawn 高度太低**:`-z` 应大于底盘一半高度(0.1 一般够)。 3. **碰撞体与 visual 不一致**:visual 比 collision 大,看起来"悬空"。 排查命令: ```bash # 看模型是否在 /tf 中(base_link 相对 world) ros2 run tf2_ros tf2_echo world base_link ``` ### 四、让小车动起来:差速驱动插件 #### 4.1 差速驱动原理回顾 差速小车通过左右轮速度差控制运动: - 两轮同速同向 → 直行。 - 两轮同速反向 → 原地旋转。 - 速度差 → 转弯。 控制输入是 `cmd_vel`(`geometry_msgs/msg/Twist`:线速度+角速度),gazebo 差速插件把它转成左右轮角速度。 #### 4.2 在 URDF 中加差速插件(gazebo 标签) 在 URDF/Xacro 的 `` 内、link/joint 之外添加: ```xml / cmd_vel:=cmd_vel 30 base_to_wheel_1 base_to_wheel_-1 0.38 0.16 20 1.0 cmd_vel true true odom base_link ``` 关键参数: | 参数 | 说明 | | --- | --- | | `left_joint` / `right_joint` | 左右轮关节名(必须与 URDF 一致) | | `wheel_separation` | 轮距(两轮中心距离,米) | | `wheel_diameter` | 轮径(米) | | `command_topic` | 订阅的速度指令话题,默认 `cmd_vel` | | `publish_odom` | 发布里程计(odom 话题) | | `publish_odom_tf` | 发布 odom→base_link 的 TF | | `odometry_frame` | 里程计坐标系名(`odom`) | | `robot_base_frame` | 机器人底盘坐标系名(`base_link`) | #### 4.3 发送速度指令 ```bash # 直行 0.5 m/s ros2 topic pub -r 10 /cmd_vel geometry_msgs/msg/Twist \ "{linear: {x: 0.5}, angular: {z: 0.0}}" # 原地右转 ros2 topic pub -r 10 /cmd_vel geometry_msgs/msg/Twist \ "{linear: {x: 0.0}, angular: {z: 0.5}}" # 停止 ros2 topic pub -r 10 /cmd_vel geometry_msgs/msg/Twist \ "{linear: {x: 0.0}, angular: {z: 0.0}}" ``` 或者用 `teleop_twist_keyboard` 手动控制: ```bash sudo apt install ros-humble-teleop-twist-keyboard ros2 run teleop_twist_keyboard teleop_twist_keyboard ``` 按 `i`/`j`/`k`/`l` 前后左右移动,小车会在 Gazebo 中真实运动,同时 `odom` 话题输出里程计、`/tf` 广播 `odom→base_link`。 #### 4.4 里程计验证 ```bash ros2 topic echo /odom --once ``` 输出包含 `pose.pose.position`(位置)和 `twist.twist.linear.x`(速度)。**这就是导航系统的输入**:SLAM 建图和 Nav2 都用 odom 做预测。 ### 五、Gazebo 与 RViz 联调 #### 5.1 同时启动 推荐结构(bringup 包统一管理): ```bash # 终端 1:Gazebo + 小车 ros2 launch my_robot_bringup gazebo.launch.py # 终端 2:RViz 显示 rviz2 ``` RViz 添加: - **RobotModel**:显示模型。 - **TF**:查看 world→odom→base_link 树。 - **LaserScan**(下篇配好雷达后):显示激光数据。 #### 5.2 检查话题清单 ```bash ros2 topic list # 应看到: # /cmd_vel # /clock # /odom # /robot_description # /tf # /tf_static ``` `/clock` 是仿真时间(`use_sim_time=true` 时节点使用它),仿真环境必查。 #### 5.3 use_sim_time 必须开 所有"感知时间"的节点(SLAM、导航、传感器处理)在仿真中都要: ```bash ros2 run your_node your_node --ros-args -p use_sim_time:=true ``` 或 launch 里统一设置: ```python Node(..., parameters=[{'use_sim_time': True}]) ``` 不开 sim time 的典型症状:TF 时间戳错乱、传感器回调延迟、导航"觉得"时间不动。 ### 六、常见问题与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | `spawn_entity` 报 `No entity found` | robot_description 话题无数据 | 先确认 `ros2 topic echo /robot_description --once` 有内容 | | 小车掉穿地面 | 碰撞体缺失或 spawn 太低 | 补 collision、抬高 `-z` | | 发 cmd_vel 车不动 | 插件未加载或关节名不匹配 | `ros2 topic list` 查插件话题;核对 joint 名 | | 模型在 Gazebo 可见但 RViz 无 TF | robot_state_publisher 未跑 | 单独启动 rsp 节点 | | 里程计不动 | 车轮未收到力矩 | 检查 `max_wheel_torque` 是否过小 | | Gazebo 卡顿/掉帧 | 物理步长太大或模型网格复杂 | `max_step_size` 调小到 0.001,collision 用简单形状 | | 时间跳跃 | `real_time_factor` 与机器性能不匹配 | 关 GUI(`headless`)或用 `-r 0.5` 降速 | | `/clock` 不存在 | 没走 gazebo_ros 启动方式 | 用 `ros2 run gazebo_ros gazebo` 而非裸 `gazebo` | #### 6.1 headless 模式(无 GUI 仿真) 服务器/CI 场景常用: ```bash # 无 GUI 启动(省资源,适合批量测试) ros2 run gazebo_ros gazebo --verbose -s libgazebo_ros_factory.so --headless ``` #### 6.2 仿真时间加速/减速 ```bash # 启动时指定实时因子(0.5 = 半速仿真) gazebo -r 0.5 ``` ### 七、小结与下一篇预告 **本篇核心**: 1. **Gazebo 是物理仿真器**:重力/摩擦/碰撞/传感器,与 RViz 分工明确(仿真 vs 显示)。 2. **world 文件**:SDF 格式,`physics`/`ground_plane`/`sun`/静态模型,可搭墙做迷宫。 3. **spawn 流程**:`gazebo_ros` 启动世界 + `spawn_entity.py` 插入模型 + `robot_state_publisher` 发布描述。 4. **差速插件**:`libgazebo_ros_diff_drive.so` 订阅 `cmd_vel` → 驱动左右轮 → 发布 odom/TF。 5. **闭环验证**:launch 一键启动 → `teleop_twist_keyboard` 控制 → 话题/TF 检查。 6. `use_sim_time` 与 `/clock` 是仿真与真机最大的区别,必须理解。 下一篇进入 **Gazebo 插件**:给小车装上"眼睛"——激光雷达(gpu_laser)、相机、IMU 插件,让传感器数据真正进入 ROS2 话题。有了传感器,机器人才能"看见"世界,这是 SLAM 建图(第 21 篇)的前提。物理世界已就绪,感知即将开启。 --- ## ROS2系列教程:Xacro参数化建模 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-xacro-can-shu-hua-jian-mo/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 17 篇 > 本文是 ROS2 系列教程的第 17 篇:Xacro 参数化建模。上一篇我们用 URDF 写了一个差速小车,但你一定已经发现痛点:两个驱动轮的 link/joint 代码几乎一模一样,改一个轮径要改五处;机械臂 6 个关节更是噩梦。Xacro(XML Macros,XML 宏)就是为了消灭这种重复而生的。它在 URDF 之上增加宏、属性、数学表达式与条件分支,先"展开"成标准 URDF 再被下游工具消费。本篇讲透 Xacro 的三大法宝(属性/宏/表达式),把小车和六关节机械臂的建模效率提升一个数量级。学完你就能用十几行代码生成几百行 URDF。 ### 一、为什么需要 Xacro #### 1.1 URDF 的三大痛点 回顾上一篇的差速小车,你实际感受到了: 1. **重复**:两个驱动轮各 40 行代码,几乎一样,只差 `name` 和 `origin` 的 y 值。 2. **不可计算**:轮子轴心高度 `-0.06`、雷达高度 `0.12` 是手算的,一旦改底盘厚度,所有位置要重新心算。 3. **不可参数化**:想生成"同一型号不同轮距"的多个变体,只能复制整个文件再手改。 #### 1.2 Xacro 是什么 Xacro 是 URDF 的**超集**:一个合法的 Xacro 文件经过 `xacro` 命令展开后,得到的是标准 URDF。它新增的能力: | 能力 | 关键字 | 作用 | | --- | --- | --- | | 属性 | `` | 定义常量,一处修改全局生效 | | 宏 | `` | 定义可复用代码块,带参数 | | 数学表达式 | `${...}` | 在属性值里做四则运算 | | 条件分支 | `` / `` | 按属性值决定是否输出某段 | ROS2 中 `xacro` 由同名包提供,启动时用 `Command(['xacro ', xacro_file])` 展开(见 5.1 节)。 #### 1.3 展开命令 ```bash # 把 xacro 展开成 urdf xacro demo_car.xacro > demo_car.urdf # 传参展开(后面讲宏参数) xacro robot.xacro wheel_radius:=0.10 # 直接打印到终端检查 xacro demo_car.xacro ``` `xacro` 展开不依赖 ROS 运行时,纯命令行即可调试。 ### 二、属性(property):消灭魔法数字 #### 2.1 基础用法 ```xml ``` `${...}` 里可以直接写表达式: ```xml ``` #### 2.2 属性值类型 Xacro 属性自动按上下文解释,常用类型: | 类型 | 写法 | 示例 | | --- | --- | --- | | 数字 | `value="0.45"` | 直接写 | | 字符串 | `value="red"` | 直接写 | | 布尔 | `value="true"` | true/false | | 数学 | `${a+b}` | 加/减/乘/除/取模,支持括号嵌套 | **注意**:`${}` 里的表达式用 Python 语法求值(Xacro 由 Python 实现),所以可以调用 `pi`、`cos` 等: ```xml ``` ### 三、宏(macro):一次定义,处处复用 宏是 Xacro 的核心。它把一段"模板"封装成带参数的可复用单元。 #### 3.1 宏的基本结构 ```xml ``` #### 3.2 调用宏 ```xml ``` 展开后等价于上一篇手写的两套 link/joint,但代码量从 80 行降到 20 行,且改 `wheel_radius` 一处全局生效。 #### 3.3 宏参数的高级用法 参数可以带默认值、可以是布尔: ```xml ... ``` 调用时按需覆盖: ```xml ``` #### 3.4 块参数(block) 宏还可以接受一段 XML 作为参数(类似函数回调),用于注入不同内容: ```xml ${block} ``` ### 四、条件与循环:让模型"长出来" #### 4.1 xacro:if 条件包含 ```xml ``` `include_lidar=false` 时整段不输出。这在"同一个模型文件,仿真/真机/仅显示"切换时极其有用。 #### 4.2 用宏模拟循环 Xacro 没有原生 for 循环,但可以用"宏 + 参数"递归或直接调用多次。机械臂 6 个关节最实用的写法是**复用同一个关节宏**: ```xml ``` 要生成 10 个关节?复制 4 行即可。这就是宏的威力。 ### 五、完整示例:Xacro 版差速小车 #### 5.1 完整文件(demo_car.xacro) ```xml ``` #### 5.2 展开并验证 ```bash # 1. 展开成 URDF xacro demo_car.xacro > demo_car.urdf # 2. 用 check_urdf 验证结构 check_urdf demo_car.urdf ``` 预期输出: ```text robot name is: demo_car ---------- Successfully Parsed XML --------------- root Link: base_link has 7 child(ren) child(1): wheel_1 child(2): wheel_-1 child(3): lidar child(4): caster_1_1 ... ``` **对比记忆**:手写 URDF 版约 150 行,Xacro 版约 90 行且更易维护;若再加一个相机,只需 10 行。 #### 5.3 在 RViz 中查看(复用 launch) ```python # display.launch.py from launch import LaunchDescription from launch_ros.actions import Node from launch.actions import DeclareLaunchArgument from launch.substitutions import LaunchConfiguration, Command import os from ament_index_python.packages import get_package_share_directory def generate_launch_description(): pkg_share = get_package_share_directory('my_robot_description') xacro_file = os.path.join(pkg_share, 'urdf', 'demo_car.xacro') return LaunchDescription([ DeclareLaunchArgument('use_sim_time', default_value='false'), Node( package='robot_state_publisher', executable='robot_state_publisher', # 关键:用 xacro 命令展开 parameters=[{'robot_description': Command(['xacro ', xacro_file])}], output='screen'), Node( package='joint_state_publisher_gui', executable='joint_state_publisher_gui', output='screen'), ]) ``` ```bash ros2 launch my_robot_description display.launch.py rviz2 # 添加 RobotModel + TF,Fixed Frame = base_link ``` #### 5.4 命令行传参切换配置 把 `include_lidar` 设为 launch 参数,实现"一个文件多种配置": ```python from launch.actions import DeclareLaunchArgument from launch.substitutions import LaunchConfiguration, Command def generate_launch_description(): include_lidar = LaunchConfiguration('include_lidar', default='true') return LaunchDescription([ DeclareLaunchArgument('include_lidar', default_value='true'), Node( package='robot_state_publisher', executable='robot_state_publisher', parameters=[{'robot_description': Command( ['xacro ', os.path.join(pkg_share, 'urdf', 'demo_car.xacro'), ' include_lidar:=', include_lidar])}], output='screen'), ]) ``` 启动: ```bash # 带激光雷达 ros2 launch my_robot_description display.launch.py # 不带(仿真场景切换) ros2 launch my_robot_description display.launch.py include_lidar:=false ``` ### 六、Xacro 常见坑与排障 | 现象 | 原因 | 解决 | | --- | --- | --- | | `xacro` 报 `NameError: name 'x' is not defined` | `${}` 里引用未定义属性 | 检查属性名拼写与定义顺序(先定义后使用) | | `${}` 里写字符串比较出错 | 值被当成数字 | 数字/字符串要类型匹配,布尔用 `${true}` | | 宏参数传不进去 | 调用时属性名写错或没加 `:=` | 默认值参数用 `param:=value` 覆盖 | | 展开后 link 重复 | 宏内写死了 name | name 必须用 `${参数}` 拼接,保证唯一 | | `check_urdf` 报 joint 缺 parent | 宏展开顺序问题 | 检查宏调用时 parent 名称是否已生成 | | 模型显示但关节不动 | joint_state_publisher 未跑 | 启动 GUI 版或手动发 `/joint_states` | | 中文注释在展开时报错 | 编码问题 | 文件用 UTF-8 保存,避免特殊字符 | #### 6.1 调试技巧:局部展开 ```bash # 只展开不写文件,直接看某段 XML 是否正确 xacro demo_car.xacro | grep -A 10 'link name="lidar"' ``` #### 6.2 版本提醒 ROS2 Humble 自带 `xacro`(Python 实现)。若你手动安装过旧版,注意 `xacro --help` 确认命令存在;launch 里 `Command(['xacro ', file])` 前确保 `xacro` 在 PATH。 ### 七、小结与下一篇预告 **本篇核心**: 1. **Xacro 是 URDF 的超集**:先展开成 URDF 再消费,解决重复/不可计算/不可参数化三大痛点。 2. **属性 property**:常量 + `${}` 数学表达式,一处修改全局生效。 3. **宏 macro**:带参数复用代码块,`params` 支持默认值与块参数;机械臂 6 关节用宏写只需 6 行调用。 4. **条件 if**:`include_lidar:=false` 实现同文件多配置。 5. **工程闭环**:`xacro → check_urdf → robot_state_publisher → RViz`,launch 里用 `Command(['xacro ', file])` 展开。 6. 从 URDF 手写 150 行降到 Xacro 90 行,可维护性显著提升。 下一篇进入 **Gazebo 仿真基础与世界搭建**:URDF/Xacro 描述好了"骨骼",Gazebo 提供"物理世界"——重力、摩擦、碰撞、传感器仿真都在这里。我们会把小车放进 Gazebo,搞定 world 文件、`spawn_entity` 与 gazebo_ros 插件,让小车在虚拟世界里跑起来。蓝图已成,物理引擎接管。 --- ## ROS2系列教程:URDF机器人建模(link与joint) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-urdf-ji-qi-ren-jian-mo-link-yu-joint/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 16 篇 > 本文是 ROS2 系列教程的第 16 篇:URDF 机器人建模(link 与 joint)。上一篇我们吃透了 TF 树与多坐标系管理,但 TF 树的变换从哪来?答案是 **URDF**(Unified Robot Description Format,统一机器人描述格式)。URDF 用 XML 描述机器人的"骨骼":每个刚体是 `link`,每个关节是 `joint`,关节即坐标系之间的变换。本篇系统讲解 link 与 joint 的每一个属性、六种关节类型、惯性/碰撞/视觉三件套,最终写出一个能在 RViz 中动起来的差速小车模型。学完你就能用代码描述任何一台机器人,为 Gazebo 仿真和 MoveIt2 机械臂打基础。 ### 一、URDF 是什么 #### 1.1 一句话定义 URDF 是一种 **XML 格式的机器人描述文件**,用树状结构描述机器人的物理组成:刚体(link)和关节(joint)。它回答三个问题: 1. **长什么样**:每个刚体的 3D 网格、颜色、尺寸(视觉 visual)。 2. **撞上去会怎样**:碰撞体(collision),用于 Gazebo 物理引擎和碰撞检测。 3. **怎么动**:关节类型与运动范围(joint),机器人状态发布器据此广播 TF。 URDF 是 **ROS1 时代的产物,被 ROS2 完整继承**。官方标准文档在 [urdf](http://wiki.ros.org/urdf)(ROS 官方 wiki),ROS2 中由 `urdf` 包提供解析能力。注意 URDF **只能描述树状结构**——一个 link 只能有一个父 link,不能有环;复杂并联机构(如四足、并联机械臂)需要 Xacro 或改用 SDF,这是下一篇的内容。 #### 1.2 为什么机器人学先学 URDF 因为 **URDF 是 TF 树的"图纸"**: - `robot_state_publisher` 读取 URDF 中的 joint 值 → 计算每个 link 的位姿 → 发布 TF 和 `/joint_states`。 - 导航、MoveIt2、Gazebo 全都消费 URDF。 - 上一篇学的 TF 树(`map → odom → base_link → laser`)中,`base_link → laser`、`base_link → camera` 这些变换就写在 URDF 里。 用一句话串起前后文:**URDF 是静态蓝图,TF 是运行时的动态结果**。 #### 1.3 最小 URDF 骨架 一个最小的 URDF 文件长这样: ```xml ``` 要点: - 根元素 ``,name 必填。 - link 用 `name` 属性区分,全局唯一。 - joint 的 `parent`/`child` 定义了父子关系,**坐标系变换方向是 parent → child**。 - `` 定义子 link 在父 link 坐标系中的位姿(xyz 平移 + rpy 欧拉角)。 把这段保存为 `minimal.urdf`,后面我们会用工具验证它。 ### 二、link:机器人的刚体 link 是机器人的"骨头",它本身不运动,通过 joint 连接成树。一个完整的 link 包含三大部分:惯性、视觉、碰撞。 #### 2.1 惯性(inertial) 物理引擎(Gazebo)计算动力学时必须知道每个 link 的质量与转动惯量: ```xml ``` - `mass` 单位千克。 - `inertia` 是 3×3 对称矩阵,只需填对角线(ixx/iyy/izz)和上三角(ixy/ixz/iyz)。 - **导航/仿真前必须给每个 link 配惯性**,否则 Gazebo 会报 `No inertia specified` 警告;MoveIt2 的 IK 计算也需要。 - 惯量估算:立方体绕中心 `I = m(a²+b²)/12`;球 `I = 2mr²/5`;圆盘 `I = mr²/2`。不想算就先用接近值,仿真精度要求高时再精算。 #### 2.2 视觉(visual) 视觉只影响"显示",不影响物理: ```xml ``` geometry 支持四种基本形状: | 形状 | 标签 | 参数 | 说明 | | --- | --- | --- | --- | | 长方体 | `` | `size="L W H"` | 长宽高(米) | | 圆柱 | `` | `radius` + `length` | 半径、长度(沿 z 轴) | | 球体 | `` | `radius` | 半径 | | 网格 | `` | `filename` + `scale` | 加载 STL/DAE 文件,仿真中常用 | 材质可以引用预定义的或内联定义: ```xml ``` #### 2.3 碰撞(collision) 碰撞体决定物理引擎做碰撞检测时用哪个形状,**建议用简单形状代替复杂网格**(速度快、稳定): ```xml ``` 工程铁律:**visual 可以精美,collision 必须简单**。Gazebo 里 mesh 碰撞会显著拖慢物理仿真并引发抖动。 #### 2.4 三种子标签的关系 | 子标签 | 用途 | 物理引擎是否使用 | 建议 | | --- | --- | --- | --- | | inertial | 质量与惯量 | 是(动力学) | 必填,宁略大勿漏 | | visual | 显示外观 | 否 | 可精美 | | collision | 碰撞检测 | 是(碰撞) | 用基本形状 | 一个 link 可以不写任何子标签(如纯坐标系 link),但**参与运动的 link 建议三件套齐全**。 ### 三、joint:机器人的关节 joint 连接两个 link 并定义二者之间的变换与运动。URDF 共 **六种关节类型**。 #### 3.1 六种类型速查 | 类型 | 自由度 | 说明 | 典型用途 | | --- | --- | --- | --- | | `fixed` | 0 | 固定连接,坐标系不变 | 传感器挂载、底盘与雷达 | | `revolute` | 1(旋转) | 绕 z 轴旋转,有角度限制 | 轮子(驱动)、机械臂关节 | | `prismatic` | 1(平移) | 沿 z 轴平移,有距离限制 | 伸缩滑台、升降柱 | | `continuous` | 1(旋转) | 绕 z 轴无限旋转,无限制 | 万向轮、雷达转台 | | `floating` | 6 | 六自由度自由运动 | 少见,仿真调试用 | | `planar` | 3 | 平面内平移+旋转 | 少见,轮式平面移动 | 实际工程中 90% 用的是 **fixed + revolute + continuous** 三种。 #### 3.2 关节的标准写法 ```xml ``` 各子标签含义: - `` / ``:两个 link 名,**父 link 在前**。 - ``:child 坐标系在 parent 坐标系中的位姿。**注意**:`origin` 是相对父的,不是相对世界。 - ``:revolute/continuous/prismatic 的旋转/平移轴,默认 z 轴(`0 0 1`)。 - ``:`effort` 最大力矩(N·m)、`velocity` 最大速度(rad/s)、`lower`/`upper` 运动范围(rad 或 m)。**revolute 必填 lower/upper,prismatic 必填,continuous 不需要 limit**。 - ``(可选):`damping` 阻尼、`friction` 摩擦力。 #### 3.3 常见的坐标系方向坑 - 轮子绕 **y 轴**转(`axis="0 1 0"`):车的 y 轴是侧向,轮子自转轴与车身侧向平行,这是差速车最常见配置。 - 机械臂关节绕 **z 轴**转(默认轴):串联机械臂每个关节转轴对齐 child 坐标系的 z 轴。 - 雷达转台用 `continuous`(可 360° 无限转)。 #### 3.4 关节值与 TF 的关系 `robot_state_publisher` 订阅 `/joint_states`(`sensor_msgs/msg/JointState`)中的 `position`,用 `` + 当前关节角计算每个 link 的世界位姿,广播 TF。所以: - **joint 值 = 关节角度/位移**(`/joint_states`)。 - **TF 变换 = 计算出的坐标系关系**(`/tf`)。 - 没有 `/joint_states` 时,固定关节照常广播,运动关节停在默认位置。 ### 四、完整示例:差速小车 URDF 把上面的知识拼起来,写一个完整的差速小车:底盘 + 2 驱动轮 + 2 万向轮 + 激光雷达。 #### 4.1 完整 URDF 文件(demo_car.urdf) ```xml ``` **坐标推导逻辑**(务必理解):底盘厚 0.12 m、中心在原点,所以上表面 z=0.06、下表面 z=-0.06;车轮半径 0.08 m、轴心在 z=-0.06 处恰好贴地;激光雷达装在车头 x=0.1、顶部 z=0.12。 #### 4.2 命令行验证 URDF 把文件保存后,在终端验证: ```bash # 1. 检查语法与结构(会列出所有 link/joint 及警告) check_urdf demo_car.urdf # 2. 生成树状结构图(需要 graphviz) urdf_to_graphiz demo_car.urdf ``` 输出应类似: ```text robot name is: demo_car ---------- Successfully Parsed XML --------------- root Link: base_link has 5 child(ren) child(1): left_wheel child(2): right_wheel child(3): laser child(4): caster_front child(5): caster_back ``` 如果报错(如 missing inertia、joint 找不到 parent),`check_urdf` 会明确指出行号与原因——这是建模阶段最常用的排障工具。 #### 4.3 用 C++ 检查 URDF(可选进阶) ```cpp // urdf_check.cpp:用 urdf 库解析并打印 link/joint #include #include #include #include int main(int argc, char** argv) { if (argc < 2) { std::cerr << "用法: ./urdf_check " << std::endl; return 1; } std::ifstream f(argv[1]); std::stringstream buf; buf << f.rdbuf(); auto model = urdf::parseURDF(buf.str()); if (!model) { std::cerr << "URDF 解析失败!" << std::endl; return 1; } std::cout << "机器人: " << model->getName() << std::endl; for (const auto& j : model->joints_) { auto jt = j.second; std::cout << "关节 " << jt->name << " 类型=" << jt->type << " 父=" << jt->parent_link_name << " 子=" << jt->child_link_name << std::endl; } return 0; } ``` 编译(CMakeLists 添加 `urdf` 依赖): ```cmake find_package(urdf REQUIRED) target_link_libraries(urdf_check urdf::urdf) ``` 运行: ```bash ros2 run your_pkg urdf_check demo_car.urdf ``` ### 五、让模型动起来:robot_state_publisher URDF 只是静态描述,要"看到"并驱动它,需要 `robot_state_publisher` 发布 TF 和 `/joint_states`。 #### 5.1 C++ launch 文件(demo_car_display.launch.py) ```python from launch import LaunchDescription from launch_ros.actions import Node from launch.actions import DeclareLaunchArgument from launch.substitutions import LaunchConfiguration, Command import os from ament_index_python.packages import get_package_share_directory def generate_launch_description(): pkg_share = get_package_share_directory('my_robot_description') urdf_file = os.path.join(pkg_share, 'urdf', 'demo_car.urdf') return LaunchDescription([ DeclareLaunchArgument('use_sim_time', default_value='false'), Node( package='robot_state_publisher', executable='robot_state_publisher', parameters=[{'robot_description': Command(['cat ', urdf_file])}], output='screen'), Node( package='joint_state_publisher_gui', executable='joint_state_publisher_gui', output='screen'), ]) ``` 说明: - `robot_description` 参数用 `Command(['cat ', urdf_file])` 把文件内容读成字符串传给发布器。 - `joint_state_publisher_gui` 提供滑块手动控制关节(revolute/continuous),方便调模型。 - 也可用无 GUI 的 `joint_state_publisher`(读固定值发布)。 #### 5.2 命令行替代(不写 launch 也行) ```bash # 直接把 URDF 内容塞进参数启动 ros2 run robot_state_publisher robot_state_publisher \ --ros-args -p robot_description:="$(cat demo_car.urdf)" # 发布关节值(例如让左轮转到 1.0 rad) ros2 topic pub /joint_states sensor_msgs/msg/JointState \ "{header: {frame_id: ''}, name: [base_to_left_wheel], position: [1.0]}" \ --rate 10 ``` #### 5.3 在 RViz 中查看 ```bash ros2 launch my_robot_description demo_car_display.launch.py rviz2 ``` RViz 中添加: 1. **RobotModel**:加载 URDF 显示 3D 模型。 2. **TF**:查看坐标系(`base_link`、`left_wheel`、`laser`…)。 3. 在左侧 Global Options 把 Fixed Frame 设为 `base_link`。 如果你在 joint_state_publisher_gui 里拖动滑块,RViz 中对应关节会实时转动,TF 树也会变化——这就是"URDF 蓝图 → TF 运行时结果"的完整链路。 ### 六、常见错误与排障清单 | 现象 | 原因 | 解决 | | --- | --- | --- | | `check_urdf` 报 joint 找不到 link | 拼写不一致或父子写反 | 检查 name 精确匹配,parent 是父 link | | RViz 看不到模型 | `robot_description` 参数没传对 | `ros2 param get /robot_state_publisher robot_description` 确认非空 | | 轮子转但车不动 | 只有 joint 值变化,没有驱动 base_link 的变换 | 轮子与底盘的连接正确时,TF 树自动传递;检查树 | | Gazebo 报 No inertia | link 缺 `` | 给所有 link 补 inertia | | 模型上下颠倒 | origin 的 rpy 用错 | 检查各 joint origin 的欧拉角与几何位置 | | 关节反了 | axis 方向相反 | 改 `` 或翻转 limit | | RViz Fixed Frame 找不到 base_link | TF 未发布 | 确认 robot_state_publisher 在跑,`ros2 run tf2_ros tf2_echo base_link laser` 验证 | | 激光在模型内部 | laser 的 origin 高度低于底盘上表面 | 把 laser 的 z 抬高到底盘厚度一半以上 | ### 七、小结与下一篇预告 **本篇核心**: 1. **URDF 是机器人的 XML 蓝图**:link 描述刚体(惯性/视觉/碰撞),joint 描述关节与坐标系变换。 2. **link 三件套**:inertial 必填(质量+惯量)、visual 只管好看、collision 用简单形状。 3. **六种关节**:fixed/revolute/prismatic/continuous/floating/planar,工程常用前三种。 4. **关节坐标系推导**:origin 相对父 link;轮子绕 y 轴、机械臂绕 z 轴。 5. **URDF → TF 链路**:robot_state_publisher 读 URDF + `/joint_states` → 广播 TF。 6. 完整差速小车:5 link + 5 joint,`check_urdf` 验证,RViz 可视化。 下一篇进入 **Xacro 参数化建模**:URDF 写死重复代码多(两个轮子要复制两遍),Xacro 用宏和属性把它们抽象出来,还能用数学表达式计算位置——建模大型机器人(机械臂、六轮底盘)必备。URDF 是蓝图,Xacro 是蓝图生成器,两者结合才能高效建模。 --- ## ROS2系列教程:TF树与多坐标系管理(C++/Python) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-tf-shu-yu-duo-zuo-biao-xi-guan-li-c/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 15 篇 > 本文是 ROS2 系列教程的第 15 篇:TF 树与多坐标系管理。上一篇我们学会了广播与查询单条变换,但真实机器人从不只有一对坐标系:地图、里程计、底盘、雷达、相机、两个轮子、机械臂每个关节……它们共同组成一棵 **TF 树**。本篇系统讲解多坐标系管理:TF 树的结构与规则(不允许环)、经典 `map/odom/base_link` 三件套架构、如何用 `tf2_ros::MessageFilter` 做时间同步、坐标系命名与多机器人隔离,以及批量查询的工程实践。学完你将能设计出规范、可排障的多坐标系系统。 ### 一、TF 树:从一对到一片 #### 1.1 为什么是"树"而不是"网" 上一篇文章里,每条变换是"父 → 子"单向的。当你把**所有**变换按父子关系连起来,就得到一棵 **TF 树(TF Tree)**: ``` map │ odom │ base_link / \ laser camera │ lens ``` **树的规则(必须遵守)**: 1. **每个坐标系只有一个父坐标系**(`base_link` 只能有一个父)。 2. **整棵树的父子关系不能成环**(A→B→C→A 是禁止的)。 3. **树可以有多棵独立子树**,但子树之间无法互查变换(会报 “Could not find transform”)。 > 为什么是树不是网?因为 tf2 要靠"沿树做矩阵链乘法"来推导任意两点间的变换。如果有环或一个点有多个父,推导结果就不唯一,系统无法工作。 #### 1.2 查看 TF 树的方法 ```bash # 文本树 ros2 run tf2_ros view_frames # 实时可视化 rqt_tf_tree # RViz 3D 显示 rviz2 # 添加 TF 显示 ``` `view_frames` 输出会在当前目录生成 `frames.pdf`,顶部还有统计:广播频率、平均延迟、最大延迟。**调试 TF 第一步永远是看树**。 #### 1.3 树与查询的关系 查询 `lookupTransform(target, source)` 时,tf2 做的是: 1. 在树中找到 `target` 和 `source` 的**最近公共祖先**。 2. 从 `source` 沿树向上到祖先(取逆变换),再向下到 `target`。 3. 链式相乘得到最终矩阵(第 13 篇的矩阵乘法)。 所以**树连得通,查询就成功;树断了,查询就报错**。这是所有 TF 排障的总纲。 ### 二、经典三件套:map / odom / base_link #### 2.1 为什么要三个坐标系 很多新手困惑:`map` 和 `odom` 都连到 `base_link`,为什么不直接 `map → base_link`? **因为 `map`(世界地图)和 `odom`(里程计)是两种不同精度的坐标系**: | | odom(里程计坐标系) | map(地图坐标系) | | --- | --- | --- | | 来源 | 轮速积分/IMU 推算 | 定位模块(AMCL 等) | | 精度 | 短时准、长时间漂移 | 全局准、可能有跳变 | | 更新 | 高频连续(100 Hz) | 低频(1-10 Hz),可能跳变 | | 用途 | 控制、局部规划 | 全局规划、导航 | 标准架构: ``` map ──(定位修正)──→ odom ──(里程计)──→ base_link ──→ sensor... ``` - **odom → base_link**:里程计发布,高频平滑,控制依赖它。 - **map → odom**:定位模块发布,低频修正,把"里程计世界"拉回"地图世界"。 - **map → base_link**:下游(如导航)查询时由 tf2 自动链式推导,无需单独发布。 **好处**:定位跳变时只改 `map→odom` 一小段,`odom→base_link` 保持平滑,控制器不会因定位跳变而抖动。 #### 2.2 搭建三件套(可运行) ```bash # 终端 1:map -> odom(静态,定位模块占位) ros2 run tf2_ros static_transform_publisher 0 0 0 0 0 0 map odom # 终端 2:odom -> base_link(动态,里程计占位) ros2 run tf2_ros static_transform_publisher 0 0 0 0 0 0 odom base_link ``` 查看树: ```bash ros2 run tf2_ros view_frames ``` 你会看到 `map → odom → base_link` 三段树。虽然目前都是静态的,但结构已经正确。 #### 2.3 用 C++ 搭建三件套 ```cpp // tf_tree_demo.cpp:同时发布 map->odom 静态 与 odom->base_link 动态 #include #include #include #include #include #include class TfTreeDemo : public rclcpp::Node { public: TfTreeDemo() : Node("tf_tree_demo"), x_(0.0), yaw_(0.0) { static_tf_ = std::make_shared(this); dynamic_tf_ = std::make_shared(this); // map -> odom 静态(定位模块占位:无偏移) geometry_msgs::msg::TransformStamped map_odom; map_odom.header.stamp = this->now(); map_odom.header.frame_id = "map"; map_odom.child_frame_id = "odom"; map_odom.transform.translation.x = 0.0; map_odom.transform.translation.y = 0.0; map_odom.transform.translation.z = 0.0; map_odom.transform.rotation.w = 1.0; static_tf_->sendTransform(map_odom); timer_ = this->create_wall_timer( std::chrono::milliseconds(100), std::bind(&TfTreeDemo::timer_cb, this)); } private: void timer_cb() { x_ += 0.01; yaw_ += 0.005; // 模拟里程计运动 geometry_msgs::msg::TransformStamped odom_base; odom_base.header.stamp = this->now(); odom_base.header.frame_id = "odom"; odom_base.child_frame_id = "base_link"; odom_base.transform.translation.x = x_; odom_base.transform.translation.y = 0.0; odom_base.transform.translation.z = 0.0; tf2::Quaternion q; q.setRPY(0.0, 0.0, yaw_); odom_base.transform.rotation.x = q.x(); odom_base.transform.rotation.y = q.y(); odom_base.transform.rotation.z = q.z(); odom_base.transform.rotation.w = q.w(); dynamic_tf_->sendTransform(odom_base); } std::shared_ptr static_tf_; std::shared_ptr dynamic_tf_; rclcpp::TimerBase::SharedPtr timer_; double x_, yaw_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` #### 2.4 用 Python 搭建三件套 ```python #!/usr/bin/env python3 """tf_tree_demo_py.py:map->odom 静态 + odom->base_link 动态""" import rclpy from rclpy.node import Node from tf2_ros import StaticTransformBroadcaster, TransformBroadcaster from geometry_msgs.msg import TransformStamped from tf_transformations import quaternion_from_euler class TfTreeDemo(Node): def __init__(self): super().__init__('tf_tree_demo_py') self.static_tf = StaticTransformBroadcaster(self) self.dynamic_tf = TransformBroadcaster(self) # map -> odom 静态 map_odom = TransformStamped() map_odom.header.stamp = self.get_clock().now().to_msg() map_odom.header.frame_id = 'map' map_odom.child_frame_id = 'odom' map_odom.transform.translation.x = 0.0 map_odom.transform.translation.y = 0.0 map_odom.transform.translation.z = 0.0 map_odom.transform.rotation.w = 1.0 self.static_tf.sendTransform(map_odom) self.x = 0.0 self.yaw = 0.0 self.timer = self.create_timer(0.1, self.timer_cb) def timer_cb(self): self.x += 0.01 self.yaw += 0.005 odom_base = TransformStamped() odom_base.header.stamp = self.get_clock().now().to_msg() odom_base.header.frame_id = 'odom' odom_base.child_frame_id = 'base_link' odom_base.transform.translation.x = self.x odom_base.transform.translation.y = 0.0 odom_base.transform.translation.z = 0.0 q = quaternion_from_euler(0.0, 0.0, self.yaw) odom_base.transform.rotation.x = q[0] odom_base.transform.rotation.y = q[1] odom_base.transform.rotation.z = q[2] odom_base.transform.rotation.w = q[3] self.dynamic_tf.sendTransform(odom_base) def main(args=None): rclpy.init(args=args) node = TfTreeDemo() try: rclpy.spin(node) finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **关键观察**:把静态广播和动态广播放在**同一个节点**没问题——tf2 不关心谁发布,只关心树结构。但同一对父子**只允许一个发布者**(否则数据互相覆盖,数值跳变)。 ### 三、传感器树的搭建 #### 3.1 挂传感器:静态变换 传感器(激光/相机/IMU)相对底盘固定,用静态变换挂到 `base_link` 下: ```bash # laser 在 base_link 前方 0.15m、高 0.1m,俯仰 0 ros2 run tf2_ros static_transform_publisher 0.15 0 0.1 0 0 0 base_link laser # camera 在 base_link 前方 0.1m、高 0.2m,绕 x 转 90°(朝下看) ros2 run tf2_ros static_transform_publisher 0.1 0 0.2 1.5708 0 0 base_link camera ``` 树变为: ``` map → odom → base_link → laser └→ camera ``` **注意相机俯仰用弧度**:`1.5708 ≈ π/2`。搞错单位是静态变换最常见错误。 #### 3.2 挂关节:动态变换 机械臂/舵机关节运动,用动态变换。每个关节是一对父子: ``` base_link → joint1 → joint2 → joint3 → ... → gripper ``` 每个关节的变换 = 上一个关节的位姿 × 当前关节角度产生的旋转。这是第 35-40 篇机械臂篇章的主线,本篇先建立概念。 #### 3.3 C++ 批量广播多对静态变换 ```cpp // multi_static.cpp:一次性广播 3 对静态变换 #include #include #include class MultiStatic : public rclcpp::Node { public: MultiStatic() : Node("multi_static") { tf_ = std::make_shared(this); std::vector transforms; // base_link -> laser transforms.push_back(make_tf("base_link", "laser", 0.15, 0.0, 0.1, 0, 0, 0)); // base_link -> camera transforms.push_back(make_tf("base_link", "camera", 0.1, 0.0, 0.2, 1.5708, 0, 0)); // base_link -> imu transforms.push_back(make_tf("base_link", "imu", 0.0, 0.0, 0.05, 0, 0, 0)); tf_->sendTransform(transforms); // 批量发送 RCLCPP_INFO(this->get_logger(), "已广播 3 对静态变换"); } private: geometry_msgs::msg::TransformStamped make_tf( const std::string& parent, const std::string& child, double x, double y, double z, double roll, double pitch, double yaw) { geometry_msgs::msg::TransformStamped t; t.header.stamp = this->now(); t.header.frame_id = parent; t.child_frame_id = child; t.transform.translation.x = x; t.transform.translation.y = y; t.transform.translation.z = z; tf2::Quaternion q; q.setRPY(roll, pitch, yaw); t.transform.rotation.x = q.x(); t.transform.rotation.y = q.y(); t.transform.rotation.z = q.z(); t.transform.rotation.w = q.w(); return t; } std::shared_ptr tf_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` **批量 vs 逐个**:`sendTransform(vector)` 一次发多条,比循环逐个发更高效(一次网络消息)。静态变换也可以这样批量。 #### 3.4 Python 批量广播 ```python #!/usr/bin/env python3 """multi_static_py.py:批量广播多对静态变换""" import rclpy from rclpy.node import Node from tf2_ros import StaticTransformBroadcaster from geometry_msgs.msg import TransformStamped from tf_transformations import quaternion_from_euler class MultiStatic(Node): def __init__(self): super().__init__('multi_static_py') self.tf = StaticTransformBroadcaster(self) transforms = [ self.make_tf('base_link', 'laser', 0.15, 0.0, 0.1, 0, 0, 0), self.make_tf('base_link', 'camera', 0.1, 0.0, 0.2, 1.5708, 0, 0), self.make_tf('base_link', 'imu', 0.0, 0.0, 0.05, 0, 0, 0), ] self.tf.sendTransform(transforms) self.get_logger().info('已广播 3 对静态变换') def make_tf(self, parent, child, x, y, z, roll, pitch, yaw): t = TransformStamped() t.header.stamp = self.get_clock().now().to_msg() t.header.frame_id = parent t.child_frame_id = child t.transform.translation.x = x t.transform.translation.y = y t.transform.translation.z = z q = quaternion_from_euler(roll, pitch, yaw) t.transform.rotation.x = q[0] t.transform.rotation.y = q[1] t.transform.rotation.z = q[2] t.transform.rotation.w = q[3] return t def main(args=None): rclpy.init(args=args) node = MultiStatic() try: rclpy.spin(node) finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` ### 四、MessageFilter:带时间戳的同步查询 #### 4.1 问题:数据到达时 TF 可能还没就绪 传感器数据(如激光点云)带 `header.stamp` 时间戳,但处理它的时刻,Buffer 里可能还没有"那个时刻"的 TF(数据先到、TF 后到,或反之)。直接 `lookupTransform` 会抛 extrapolation/timeout 异常。 **解决方案**:`tf2_ros::MessageFilter`——它把"数据消息"和"TF 可用性"绑在一起:**只有当数据时间戳对应的 TF 可用时,才触发回调**。 #### 4.2 C++ 使用 MessageFilter ```cpp // message_filter_demo.cpp:TF 就绪后才处理 LaserScan #include #include #include #include #include #include class MessageFilterDemo : public rclcpp::Node { public: MessageFilterDemo() : Node("message_filter_demo"), tf_buffer_(this->get_clock()) { tf_listener_ = std::make_shared(tf_buffer_); scan_sub_.subscribe(this, "scan"); tf_filter_ = std::make_shared>( scan_sub_, tf_buffer_, "map", 10, this->get_node_base_interface(), this->get_node_logging_interface(), this->get_node_clock_interface()); tf_filter_->registerCallback( std::bind(&MessageFilterDemo::scan_cb, this, std::placeholders::_1)); } private: void scan_cb(const sensor_msgs::msg::LaserScan::SharedPtr scan) { RCLCPP_INFO(this->get_logger(), "收到 scan (stamp=%.3f),TF 已就绪,可以安全查询", scan->header.stamp.sec + scan->header.stamp.nanosec * 1e-9); } tf2_ros::Buffer tf_buffer_; std::shared_ptr tf_listener_; message_filters::Subscriber scan_sub_; std::shared_ptr> tf_filter_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` **MessageFilter 构造参数**:`(订阅器, buffer, target_frame, 队列长度, node 接口...)`。`target_frame` 是最终要变换到的坐标系(这里是 `map`)——filter 会等待"该消息时间戳下、map 方向的 TF 可用"。 #### 4.3 Python 使用 MessageFilter ```python #!/usr/bin/env python3 """message_filter_demo_py.py:TF 就绪后处理 LaserScan""" import rclpy from rclpy.node import Node from tf2_ros import Buffer, TransformListener, MessageFilter from message_filters import Subscriber from sensor_msgs.msg import LaserScan class MessageFilterDemo(Node): def __init__(self): super().__init__('message_filter_demo_py') self.tf_buffer = Buffer() self.tf_listener = TransformListener(self.tf_buffer, self) self.scan_sub = Subscriber(self, LaserScan, 'scan') self.tf_filter = MessageFilter( self.scan_sub, self.tf_buffer, 'map', 10) self.tf_filter.registerCallback(self.scan_cb) def scan_cb(self, scan): self.get_logger().info( f'收到 scan (stamp={scan.header.stamp.sec}.' f'{scan.header.stamp.nanosec:09d}),TF 已就绪') def main(args=None): rclpy.init(args=args) node = MessageFilterDemo() try: rclpy.spin(node) finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **对比直接查询**:无 filter 时你要在回调里 try-catch + 重试;有 filter 后回调触发即保证 TF 可用,代码干净很多。**处理带时间戳的传感器数据(激光/相机/点云)时,MessageFilter 是标准姿势**。 ### 五、坐标系命名与多机器人隔离 #### 5.1 命名规范 - **小写 + 下划线**:`base_link`、`laser`、`camera_link`(ROS 风格)。 - **避免空格和特殊字符**:TF 名字是字符串,但空格会带来解析麻烦。 - **语义清晰**:`camera_link`(安装支架)与 `camera_optical`(光学中心)分开命名是常见的做法。 - **一个坐标系一个语义**:不要复用名字表示不同位置。 #### 5.2 多机器人命名空间 多机器人系统(如双车协作)中,两台机器人各有自己的 TF 树。**直接拼树会导致坐标系名字冲突**。标准方案: 1. **坐标系名加前缀**:`robot1/base_link`、`robot2/base_link`。 2. **各自独立 /tf 话题**:重映射 `tf := robot1/tf`,`tf_static := robot1/tf_static`。 3. **地图合并时**:额外发布 `map → robot1/odom` 与 `map → robot2/odom`,把两棵子树接到同一棵树上。 示例(launch 重映射): ```xml ``` #### 5.3 命名空间与 TF 的常见坑 | 现象 | 原因 | 解决 | | --- | --- | --- | | 帧名带 `robot1/` 但查询没带 | 坐标系名是**纯字符串**,不会自动加命名空间 | 广播和查询必须用完整名 `robot1/base_link` | | 两车都用 `base_link` | 树冲突/查询错乱 | 加前缀或独立话题 | | 重映射了 /tf 但没重映射 /tf_static | 静态树没进来 | 两个话题都要重映射 | ### 六、工程实践:完整的多坐标系节点 把本篇所有知识合成一个实用节点:订阅激光雷达 → 等 TF → 变换到 map → 发布障碍物坐标。 #### 6.1 C++ 完整示例 ```cpp // scan_to_map.cpp:LaserScan + MessageFilter -> 变换到 map -> 发布 PointStamped #include #include #include #include #include #include #include #include class ScanToMap : public rclcpp::Node { public: ScanToMap() : Node("scan_to_map"), tf_buffer_(this->get_clock()) { tf_listener_ = std::make_shared(tf_buffer_); scan_sub_.subscribe(this, "scan"); tf_filter_ = std::make_shared>( scan_sub_, tf_buffer_, "map", 10, this->get_node_base_interface(), this->get_node_logging_interface(), this->get_node_clock_interface()); tf_filter_->registerCallback( std::bind(&ScanToMap::scan_cb, this, std::placeholders::_1)); marker_pub_ = this->create_publisher( "obstacle_in_map", 10); } private: void scan_cb(const sensor_msgs::msg::LaserScan::SharedPtr scan) { // 找最近障碍(最简单:取第一束距离最小的点) size_t i = 0; float min_r = std::numeric_limits::max(); for (size_t j = 0; j < scan->ranges.size(); ++j) { if (scan->ranges[j] > scan->range_min && scan->ranges[j] < scan->range_max && scan->ranges[j] < min_r) { min_r = scan->ranges[j]; i = j; } } double angle = scan->angle_min + i * scan->angle_increment; // laser 坐标系下的障碍点 geometry_msgs::msg::PointStamped laser_pt; laser_pt.header = scan->header; // 沿用数据的时间戳和 frame laser_pt.point.x = min_r * std::cos(angle); laser_pt.point.y = min_r * std::sin(angle); laser_pt.point.z = 0.0; // 变换到 map(MessageFilter 保证此刻 TF 可用) try { auto transform = tf_buffer_.lookupTransform( "map", scan->header.frame_id, scan->header.stamp, std::chrono::seconds(1)); auto map_pt = tf2::doTransform(laser_pt, transform); map_pt.header.frame_id = "map"; map_pt.header.stamp = this->now(); marker_pub_->publish(map_pt); RCLCPP_INFO(this->get_logger(), "最近障碍在 map: (%.2f, %.2f)", map_pt.point.x, map_pt.point.y); } catch (const tf2::TransformException& ex) { RCLCPP_WARN(this->get_logger(), "变换失败: %s", ex.what()); } } tf2_ros::Buffer tf_buffer_; std::shared_ptr tf_listener_; message_filters::Subscriber scan_sub_; std::shared_ptr> tf_filter_; rclcpp::Publisher::SharedPtr marker_pub_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` `package.xml` 需声明:`rclcpp`、`tf2_ros`、`tf2_geometry_msgs`、`message_filters`、`sensor_msgs`、`geometry_msgs`。 #### 6.2 组合运行验证 ```bash # 终端 1:完整 TF 树(三件套 + 传感器) ros2 run tf2_demo tf_tree_demo & ros2 run tf2_demo multi_static & # 终端 2:模拟激光数据(发布一个简单 scan) ros2 topic pub /scan sensor_msgs/msg/LaserScan \ "{angle_min: -1.57, angle_max: 1.57, angle_increment: 0.01, \ range_min: 0.1, range_max: 10.0, \ ranges: [1.0, 0.8, 0.6, 0.4, 0.2, 0.3, 0.5, 0.7, 0.9, 1.0]}" \ --rate 5 # 终端 3:运行处理节点 ros2 run tf2_demo scan_to_map ``` 你会看到 `scan_to_map` 每收到一帧 scan 就输出"最近障碍在 map"的坐标。**这就是建图、导航、避障系统中"感知数据上到全局坐标系"的雏形**。 #### 6.3 观察树 ```bash ros2 run tf2_ros view_frames rqt_tf_tree ``` 树应类似: ``` map → odom → base_link → laser └→ camera └→ imu ``` ### 七、常见问题与排障清单 | 问题 | 原因 | 解决 | | --- | --- | --- | | 两个坐标系能各自查到,但互相查不到 | 它们不在同一棵子树 | 查看树,找出断点,补上缺失的变换 | | 数值在某个时刻跳变 | 两个节点同时广播同一对父子 | 只保留一个发布者 | | `map→base_link` 与 `odom→base_link` 都有人发 | 树结构错误(base_link 双父) | 正确做法是 map→odom→base_link 链式 | | MessageFilter 回调永远不触发 | target_frame 方向 TF 一直不可用 | 检查树里是否有 `map`;确认发布频率 | | 查询历史时刻报 extrapolation | 时间戳太旧(>10s 缓存) | 检查数据延迟;Buffer 缓存时间可配 | | 静态变换改了但看不到 | `/tf_static` 用 transient_local,新订阅者能收到,但旧数据还在 | 重启节点或发新数据 | | 多机器人帧名冲突 | 都用了裸名 `base_link` | 加命名空间前缀或独立话题 | ### 八、小结与下一篇预告 **本篇核心**: 1. **TF 树规则**:单父、无环、子树独立;“树通查询通、树断查询断”。 2. **三件套架构**:`map → odom → base_link`,定位与里程计分离,控制平滑。 3. **传感器/关节挂载**:静态变换挂传感器、动态变换挂关节;`sendTransform(vector)` 批量发送。 4. **MessageFilter**:带时间戳数据与 TF 就绪绑定的标准解法,处理激光/相机数据必备。 5. **命名与隔离**:坐标系命名规范、多机器人前缀/话题隔离。 6. 完整实践:LaserScan → MessageFilter → 变换到 map → 发布障碍点。 下一篇进入 **建模仿真篇**:URDF 机器人建模(link 与 joint)。URDF 是机器人的"骨骼描述",它把 TF 树和 3D 模型绑定:每个 link 是刚体、每个 joint 是坐标系之间的变换。学完你就能用代码描述一台完整机器人,为 Gazebo 仿真和 MoveIt2 机械臂打基础。tf2 是神经,URDF 是骨骼——两者结合,机器人"活"了起来。 --- ## ROS2系列教程:tf2坐标变换详解(C++/Python) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-tf2-zuo-biao-bian-huan-xiang-jie-c/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 14 篇 > 本文是 ROS2 系列教程的第 14 篇:tf2 坐标变换详解。上一篇我们打完了位姿与坐标变换的数学地基(旋转矩阵/欧拉角/四元数/齐次变换矩阵),本篇把这些数学变成 ROS2 里可用的管道:`tf2_ros` 的**广播(broadcast)**与**监听(lookup)**。学完你将能发布静态/动态坐标变换、查询任意两个坐标系之间的变换、看懂 `tf2_echo` 与 `tf2_views_frames` 的输出,并解决 “Unknown frame”、“Could not find transform” 等经典报错。 ### 一、tf2 是什么 #### 1.1 从数学到框架 第 13 篇我们学会用 4×4 齐次变换矩阵描述"一个坐标系相对另一个坐标系"。但真实机器人上有几十个坐标系:`base_link`(底盘)、`laser`(激光雷达)、`camera`(相机)、`map`(地图)、`odom`(里程计)……如果每个模块都自己管理"坐标系之间的变换矩阵",系统会迅速乱成一团。 **tf2 就是 ROS2 的"坐标变换中央管理库"**: - 每个"谁和谁是父子关系、相对位置姿态是多少"的变换,由持有者**广播**出去。 - 所有广播的变换汇聚成一棵 **TF 树**(TF Tree)。 - 任何节点都可以**查询**树上任意两个坐标系之间的变换,不用关心它由谁发布。 一句话:**tf2 = 广播 + 树 + 查询**。 #### 1.2 核心概念 | 概念 | 说明 | 对应 API | | --- | --- | --- | | TransformBroadcaster | 广播变换的节点组件 | `tf2_ros::TransformBroadcaster` / `TransformBroadcaster` | | StaticTransformBroadcaster | 广播**静态**(不变)变换 | `tf2_ros::StaticTransformBroadcaster` | | Buffer | 缓存所有收到的变换(时间戳索引) | `tf2_ros::Buffer` | | TransformListener | 订阅 TF 话题并把数据写入 Buffer | `tf2_ros::TransformListener` | | lookupTransform | 查询两坐标系间的变换 | `buffer_.lookupTransform(...)` | | 帧 / frame | 坐标系的名字,如 `base_link` | `frame_id` / `child_frame_id` | #### 1.3 消息类型:TransformStamped tf2 广播/监听的消息是 `tf2_msgs/msg/TFMessage`,其中包含一个或多个 `geometry_msgs/msg/TransformStamped`: ``` geometry_msgs/msg/TransformStamped ├── Header header │ ├── uint32 seq │ ├── time stamp # 变换生效的时间 │ └── string frame_id # 父坐标系 ├── string child_frame_id # 子坐标系 └── Transform transform ├── Vector3 translation # 平移 x, y, z └── Quaternion rotation # 旋转(四元数) ``` **读法**:“在 `frame_id`(父)坐标系下,`child_frame_id`(子)坐标系位于 translation 处、朝向 rotation”。 **必须理解的约定**:tf2 只存**父→子**方向的变换,即 `T_child ← parent`。查询时你指定"from 哪个、to 哪个",tf2 沿树自动做矩阵链乘法(第 13 篇的 `T_C←A = T_C←B · T_B←A`)。 #### 1.4 TF 话题与时间戳 - 动态变换发布在话题 `/tf`。 - 静态变换发布在话题 `/tf_static`(且一般用**latching/transient_local QoS**,后订阅者也能收到)。 - 每个变换带时间戳 `header.stamp`。Buffer 会按时间缓存,查询时可指定"在某个时刻的变换"。 **常见困惑**:为什么查询报 “time out”?因为 Buffer 默认只保留约 10 秒的历史,且查询 `now()` 时刻的变换要求发布者此刻刚发过数据。机器人刚启动、数据还没到达时查询,就会超时。 ### 二、发布静态变换(最简单起步) 静态变换 = 坐标关系固定不变。典型例子:**激光雷达装在底盘上的固定位置**(不会动)。 #### 2.1 用命令行发布静态变换 ```bash ros2 run tf2_ros static_transform_publisher 0.1 0.0 0.2 0 0 0 base_link laser ``` 参数含义(前 3 个平移,后 3 个旋转欧拉角**弧度**,然后是父子坐标系): - `0.1 0.0 0.2`:laser 相对 base_link 平移 (0.1, 0, 0.2) 米 - `0 0 0`:roll/pitch/yaw 都是 0(朝向一致) - `base_link`:父坐标系 - `laser`:子坐标系 查看是否发布成功: ```bash ros2 topic echo /tf_static --once ros2 run tf2_ros tf2_echo base_link laser ``` `tf2_echo` 输出示例: ``` At time 0.00 - Translation: [0.100, 0.000, 0.200] - Rotation: in Quaternion [0.000, 0.000, 0.000, 1.000] ``` #### 2.2 用 C++ 发布静态变换 创建包 `tf2_demo`(CMake): ```cpp // static_broadcaster.cpp:发布 base_link -> laser 的静态变换 #include #include #include #include class StaticBroadcaster : public rclcpp::Node { public: StaticBroadcaster() : Node("static_broadcaster") { tf_broadcaster_ = std::make_shared(this); geometry_msgs::msg::TransformStamped t; t.header.stamp = this->now(); t.header.frame_id = "base_link"; // 父 t.child_frame_id = "laser"; // 子 t.transform.translation.x = 0.1; t.transform.translation.y = 0.0; t.transform.translation.z = 0.2; tf2::Quaternion q; q.setRPY(0.0, 0.0, 0.0); // 弧度 t.transform.rotation.x = q.x(); t.transform.rotation.y = q.y(); t.transform.rotation.z = q.z(); t.transform.rotation.w = q.w(); tf_broadcaster_->sendTransform(t); RCLCPP_INFO(this->get_logger(), "已发布静态变换 base_link -> laser (0.1, 0, 0.2)"); } private: std::shared_ptr tf_broadcaster_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); auto node = std::make_shared(); rclcpp::spin(node); // 静态广播也要保持节点活着 rclcpp::shutdown(); return 0; } ``` `CMakeLists.txt` 要点: ```cmake find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) find_package(tf2 REQUIRED) find_package(tf2_ros REQUIRED) find_package(geometry_msgs REQUIRED) add_executable(static_broadcaster src/static_broadcaster.cpp) ament_target_dependencies(static_broadcaster rclcpp tf2 tf2_ros geometry_msgs) install(TARGETS static_broadcaster DESTINATION lib/${PROJECT_NAME}) ``` 运行: ```bash colcon build --packages-select tf2_demo source install/setup.bash ros2 run tf2_demo static_broadcaster ``` 另开终端验证: ```bash ros2 run tf2_ros tf2_echo base_link laser ``` #### 2.3 用 Python 发布静态变换 ```python #!/usr/bin/env python3 """static_broadcaster_py.py:发布 base_link -> laser 静态变换""" import rclpy from rclpy.node import Node from tf2_ros import StaticTransformBroadcaster from geometry_msgs.msg import TransformStamped class StaticBroadcaster(Node): def __init__(self): super().__init__('static_broadcaster_py') self.tf_broadcaster = StaticTransformBroadcaster(self) t = TransformStamped() t.header.stamp = self.get_clock().now().to_msg() t.header.frame_id = 'base_link' t.child_frame_id = 'laser' t.transform.translation.x = 0.1 t.transform.translation.y = 0.0 t.transform.translation.z = 0.2 # 零旋转:w=1, x=y=z=0 t.transform.rotation.x = 0.0 t.transform.rotation.y = 0.0 t.transform.rotation.z = 0.0 t.transform.rotation.w = 1.0 self.tf_broadcaster.sendTransform(t) self.get_logger().info('已发布静态变换 base_link -> laser') def main(args=None): rclpy.init(args=args) node = StaticBroadcaster() try: rclpy.spin(node) finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **两个语言的区别**:C++ 用 `tf2::Quaternion` 设姿态后取分量;Python 直接填 `rotation.x/y/z/w`。都遵守"w 在最后、弧度制、单位四元数"。 ### 三、发布动态变换(机器人移动) 静态变换不够——机器人**边跑边变**。典型例子:里程计不断更新 `odom -> base_link` 的变换。 #### 3.1 C++ 动态广播(定时更新) ```cpp // dynamic_broadcaster.cpp:模拟机器人移动,周期性广播 odom -> base_link #include #include #include #include #include class DynamicBroadcaster : public rclcpp::Node { public: DynamicBroadcaster() : Node("dynamic_broadcaster"), x_(0.0), yaw_(0.0) { tf_broadcaster_ = std::make_shared(this); timer_ = this->create_wall_timer( std::chrono::milliseconds(100), // 10 Hz std::bind(&DynamicBroadcaster::timer_cb, this)); } private: void timer_cb() { // 模拟:每 100ms 前进 0.01 m 并转 0.005 rad x_ += 0.01; yaw_ += 0.005; geometry_msgs::msg::TransformStamped t; t.header.stamp = this->now(); // 必须用当前时间! t.header.frame_id = "odom"; t.child_frame_id = "base_link"; t.transform.translation.x = x_; t.transform.translation.y = 0.0; t.transform.translation.z = 0.0; tf2::Quaternion q; q.setRPY(0.0, 0.0, yaw_); t.transform.rotation.x = q.x(); t.transform.rotation.y = q.y(); t.transform.rotation.z = q.z(); t.transform.rotation.w = q.w(); tf_broadcaster_->sendTransform(t); } std::shared_ptr tf_broadcaster_; rclcpp::TimerBase::SharedPtr timer_; double x_, yaw_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` 验证(`tf2_echo` 会持续输出变化的变换): ```bash ros2 run tf2_demo dynamic_broadcaster ros2 run tf2_ros tf2_echo odom base_link ``` #### 3.2 Python 动态广播 ```python #!/usr/bin/env python3 """dynamic_broadcaster_py.py:周期性广播 odom -> base_link""" import rclpy from rclpy.node import Node from tf2_ros import TransformBroadcaster from geometry_msgs.msg import TransformStamped from tf_transformations import quaternion_from_euler class DynamicBroadcaster(Node): def __init__(self): super().__init__('dynamic_broadcaster_py') self.tf_broadcaster = TransformBroadcaster(self) self.timer = self.create_timer(0.1, self.timer_cb) # 10 Hz self.x = 0.0 self.yaw = 0.0 def timer_cb(self): self.x += 0.01 self.yaw += 0.005 t = TransformStamped() t.header.stamp = self.get_clock().now().to_msg() t.header.frame_id = 'odom' t.child_frame_id = 'base_link' t.transform.translation.x = self.x t.transform.translation.y = 0.0 t.transform.translation.z = 0.0 q = quaternion_from_euler(0.0, 0.0, self.yaw) # [x,y,z,w] t.transform.rotation.x = q[0] t.transform.rotation.y = q[1] t.transform.rotation.z = q[2] t.transform.rotation.w = q[3] self.tf_broadcaster.sendTransform(t) def main(args=None): rclpy.init(args=args) node = DynamicBroadcaster() try: rclpy.spin(node) finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 3.3 动态 vs 静态的区别 | | 静态变换 | 动态变换 | | --- | --- | --- | | 话题 | `/tf_static` | `/tf` | | 频率 | 一次(或低频) | 高频(10-100 Hz) | | 典型例子 | 传感器安装位姿 | 里程计/关节运动 | | API | `StaticTransformBroadcaster` | `TransformBroadcaster` | | QoS | transient_local(后订阅者可收到) | volatile(默认) | ### 四、监听与查询变换(核心用法) 发布是为了查询。这一节是最常用、最核心的 API:**TransformListener + Buffer + lookupTransform**。 #### 4.1 什么是"查询" “查询” = 给定两个坐标系,返回它们之间的变换。例如:激光雷达检测到前方 1 米的墙(在 `laser` 坐标系),要换算到 `map` 坐标系,就需要 `map <- laser` 的变换。 #### 4.2 C++ 监听并查询 ```cpp // tf_listener.cpp:监听 TF,周期性查询 map -> laser 变换 #include #include #include #include #include class TfListener : public rclcpp::Node { public: TfListener() : Node("tf_listener"), tf_buffer_(this->get_clock()) { tf_listener_ = std::make_shared(tf_buffer_); timer_ = this->create_wall_timer( std::chrono::seconds(1), std::bind(&TfListener::timer_cb, this)); } private: void timer_cb() { try { // 查询"从 laser 到 map 的变换"(把 laser 下的点变换到 map) auto transform = tf_buffer_.lookupTransform( "map", "laser", tf2::TimePointZero, std::chrono::seconds(1)); RCLCPP_INFO(this->get_logger(), "map <- laser: 平移(%.3f, %.3f, %.3f)", transform.transform.translation.x, transform.transform.translation.y, transform.transform.translation.z); } catch (const tf2::TransformException & ex) { RCLCPP_WARN(this->get_logger(), "查询失败: %s", ex.what()); } } tf2_ros::Buffer tf_buffer_; std::shared_ptr tf_listener_; rclcpp::TimerBase::SharedPtr timer_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` **关键 API 细节**: 1. `tf2_ros::Buffer` 构造要传时钟:`Buffer(this->get_clock())`。 2. `TransformListener` 必须**持有**(存成员),否则析构后收不到数据。 3. `lookupTransform(target, source, time, timeout)`: - `target` = 目标坐标系(变换结果在这个坐标系下表达) - `source` = 源坐标系(要变换的点所在坐标系) - 结果含义:把 source 下的点变换到 target 下需要乘以的矩阵。 4. `tf2::TimePointZero` = 查询"最新"变换;也可传具体时间戳。 5. **必须 try-catch**:坐标系不存在/变换超时都会抛异常,不捕获会崩。 **顺序记忆**:`lookupTransform("目标", "源")`,结果是把"源"变换到"目标"。很多人搞反,记住"**从源到目标**"。 #### 4.3 Python 监听并查询 ```python #!/usr/bin/env python3 """tf_listener_py.py:监听 TF,查询 map -> laser""" import rclpy from rclpy.node import Node from tf2_ros import TransformListener, Buffer class TfListener(Node): def __init__(self): super().__init__('tf_listener_py') self.tf_buffer = Buffer() self.tf_listener = TransformListener(self.tf_buffer, self) self.timer = self.create_timer(1.0, self.timer_cb) def timer_cb(self): try: # 从 laser 变换到 map transform = self.tf_buffer.lookup_transform( 'map', 'laser', rclpy.time.Time()) t = transform.transform.translation self.get_logger().info( 'map <- laser: 平移(%.3f, %.3f, %.3f)', t.x, t.y, t.z) except Exception as e: self.get_logger().warn(f'查询失败: {e}') def main(args=None): rclpy.init(args=args) node = TfListener() try: rclpy.spin(node) finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **Python 注意事项**: - `lookup_transform(target, source, time)` 位置参数顺序同 C++。 - `rclpy.time.Time()` = 最新时刻;也可传 `Time(seconds=...)`。 - Python 的 Buffer 不需要传时钟(默认用节点时钟)。 - 异常基类是 `Exception`(内部是 `tf2_ros.TransformException`)。 #### 4.4 变换一个点:从激光坐标到地图坐标 查询到变换后,实际用途是变换点。假设激光检测到前方 1 米有墙: ```python # 在 tf_listener_py.py 的 timer_cb 中继续: from geometry_msgs.msg import PointStamped from tf2_geometry_msgs import do_transform_point # 构造 laser 坐标系下的点 point = PointStamped() point.header.frame_id = 'laser' point.header.stamp = self.get_clock().now().to_msg() point.point.x = 1.0 point.point.y = 0.0 point.point.z = 0.0 # 变换到 map 坐标系 transformed = do_transform_point(point, transform) self.get_logger().info( '墙在 map 中: (%.3f, %.3f, %.3f)', transformed.point.x, transformed.point.y, transformed.point.z) ``` C++ 对应: ```cpp #include #include geometry_msgs::msg::PointStamped point; point.header.frame_id = "laser"; point.point.x = 1.0; point.point.y = 0.0; point.point.z = 0.0; auto transformed = tf2::doTransform(point, transform); // transformed.point 现在在 map 坐标系下 ``` `tf2::doTransform` / `do_transform_point` 就是第 13 篇矩阵乘法 `p_map = T_map←laser · p_laser` 的封装。 ### 五、TF 树的可视化与排障 #### 5.1 查看 TF 树 ```bash # 文本方式:打印所有坐标系关系 ros2 run tf2_ros tf2_echo base_link laser # 生成树图(PDF 在 /tmp/frames.pdf) ros2 run tf2_ros view_frames ``` `view_frames` 会生成一张树状图:从根(如 `map`)向下展开所有父子关系。**断开的树枝**一眼可见——这就是"为什么查询不到变换"的第一排查点。 #### 5.2 可视化工具 - `rqt_tf_tree`:实时显示 TF 树(推荐,动态更新)。 - RViz2:添加 “TF” 显示,可看到坐标系坐标轴在空间中旋转移动——最直观。 ```bash rqt_tf_tree rviz2 # 添加 TF 显示 ``` #### 5.3 常见错误与解决 | 报错 | 原因 | 解决 | | --- | --- | --- | | `"laser" passed to lookupTransform argument target_frame does not exist` | 坐标系名不存在 | `ros2 run tf2_ros view_frames` 看实际名字,检查拼写(大小写敏感) | | `Could not find transform from laser to map` | 两个坐标系不在同一棵 TF 树 | 检查是否有未连接的子树;静态变换是否发布到 `/tf_static` | | `Lookup would require extrapolation into the past/future` | 查询的时间戳超出缓存范围 | 机器人刚启动等数据;或查最新用 `TimePointZero` | | `Transform timed out` | 数据到达前就查询了 | 用 `lookupTransform(..., timeout)` 等待;或先 `tf_buffer_.canTransform(...)` 轮询 | | 查询到了但数值全 0 | 忘了发布静态变换 | 检查 `/tf_static` 话题是否有数据 | | 数值"跳变" | 多个节点同时广播同一对坐标系 | 只能有一个广播者;检查是否有重复的 static_transform_publisher | #### 5.4 等待变换可用的标准姿势 很多应用启动时需要等 TF 就绪。正确姿势是**轮询 canTransform 或带超时查询**: ```cpp // 等待 map -> laser 可用(最多 5 秒) bool ok = tf_buffer_.canTransform( "map", "laser", tf2::TimePointZero, std::chrono::seconds(5)); ``` ```python ok = self.tf_buffer.can_transform( 'map', 'laser', rclpy.time.Time(), timeout=rclpy.duration.Duration(seconds=5)) ``` **不要**用 `sleep(3)` 硬等——机器人状态不同,硬等既不优雅又不可靠。 ### 六、实战案例:把激光点云变换到地图 把前面所有知识串起来:一个节点接收激光测距(模拟),利用 TF 把它变换到 `map` 坐标系并发布。 #### 6.1 C++ 完整示例 ```cpp // tf_to_map.cpp:查询变换并变换一个点 #include #include #include #include #include #include class TfToMap : public rclcpp::Node { public: TfToMap() : Node("tf_to_map"), tf_buffer_(this->get_clock()) { tf_listener_ = std::make_shared(tf_buffer_); timer_ = this->create_wall_timer( std::chrono::milliseconds(200), std::bind(&TfToMap::timer_cb, this)); } private: void timer_cb() { try { // 1) 查询 laser -> map auto transform = tf_buffer_.lookupTransform( "map", "laser", tf2::TimePointZero, std::chrono::seconds(1)); // 2) 构造 laser 下的障碍点(前方 1 米) geometry_msgs::msg::PointStamped laser_point; laser_point.header.frame_id = "laser"; laser_point.point.x = 1.0; laser_point.point.y = 0.0; laser_point.point.z = 0.0; // 3) 变换到 map auto map_point = tf2::doTransform(laser_point, transform); RCLCPP_INFO(this->get_logger(), "障碍在 map: (%.3f, %.3f, %.3f)", map_point.point.x, map_point.point.y, map_point.point.z); } catch (const tf2::TransformException & ex) { RCLCPP_WARN(this->get_logger(), "TF 不可用: %s", ex.what()); } } tf2_ros::Buffer tf_buffer_; std::shared_ptr tf_listener_; rclcpp::TimerBase::SharedPtr timer_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` #### 6.2 组合运行 ```bash # 终端 1:静态变换(laser 在 base_link 前方 0.1 m) ros2 run tf2_demo static_broadcaster # 终端 2:动态变换(odom -> base_link 移动) ros2 run tf2_demo dynamic_broadcaster # 终端 3:查询并变换 ros2 run tf2_demo tf_to_map ``` 等等——这里 `map` 坐标系还没人发布!需要补一个 `map -> odom` 的静态变换(实际系统中由定位模块发布): ```bash ros2 run tf2_ros static_transform_publisher 0 0 0 0 0 0 map odom ``` 现在 TF 树完整:`map -> odom -> base_link -> laser`。`tf_to_map` 会持续打印"障碍在 map"的坐标,且数值随 `dynamic_broadcaster` 的移动而变化——**这就是 TF 的魅力:每个模块只发布自己知道的那一段,任何两点都能算出来**。 #### 6.3 观察 TF 树 ```bash ros2 run tf2_ros view_frames # 或 rqt_tf_tree ``` 能看到树:`map`(根)→ `odom` → `base_link` → `laser`。 ### 七、进阶话题 #### 7.1 时间旅行查询(Querying the Past) tf2 支持查询"过去某个时刻"的变换(Buffer 保留历史)。适合:处理带延迟的数据(如相机图像在处理时已过去 50ms): ```cpp auto t = tf_buffer_.lookupTransform( "map", "laser", msg.header.stamp, // 用数据自己的时间戳 std::chrono::seconds(1)); ``` 注意:如果数据时间戳太旧(超出 10 秒缓存),会报 extrapolation 错误。 #### 7.2 变换消息头(frame_id)规范 无论发布还是查询,**frame_id 写对是基本功**: - 发布时:`header.frame_id` = 父,`child_frame_id` = 子。 - 查询时:目标在前、源在后。 - 消息带坐标信息时(如 PointStamped),`header.frame_id` 必须写**它所在的坐标系**。 #### 7.3 多机器人/命名空间 多机器人时每个机器人有自己的 TF 树。常见做法:用命名空间区分坐标系名(如 `robot1/base_link`、`robot2/base_link`),或每机器人独立 `/tf` 话题(重映射)。多机 TF 合并是进阶话题,第 31-34 篇实体机器人章节会展开。 ### 八、小结与下一篇预告 **本篇核心**: 1. **tf2 三件套**:广播(broadcast)、TF 树、查询(lookup)。 2. **两类广播**:`StaticTransformBroadcaster`(/tf_static,静态)与 `TransformBroadcaster`(/tf,动态)。 3. **查询 API**:`Buffer.lookupTransform(target, source, time, timeout)`,“从源到目标”;必须 try-catch。 4. **变换点**:`tf2::doTransform` / `do_transform_point` = 矩阵乘法的封装。 5. **排障**:`tf2_echo`、`view_frames`、`rqt_tf_tree`;“Unknown frame” 查拼写,“not found” 查树是否连通,“timeout” 用 canTransform 等待。 6. 实战:静态+动态广播组合,把激光点变换到 map。 下一篇进入 **TF 树与多坐标系管理(C++/Python)**:多坐标系树怎么建、循环依赖(TF 不允许环)、`tf2_ros::MessageFilter` 时间同步、常见树形结构(map/odom/base_link 三件套)、以及如何在自己的包中优雅地组织 TF 广播与查询。tf2 是机器人的"神经系统",本篇已让你掌握它的骨干。 --- ## ROS2系列教程:运动学基础(位姿与坐标变换) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-yun-dong-xue-ji-chu-wei-zi-yu-zuo/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 13 篇 > 本文是 ROS2 系列教程的第 13 篇,也是**机器人学篇**的开篇:运动学基础——位姿与坐标变换。机器人编程绕不开坐标系:机器人在哪里?朝向哪里?传感器测到的点在什么坐标系下?这两个问题的数学回答就是"位姿(Pose)“与"坐标变换(Transform)”。本篇文章从数学原理讲到 ROS2 代码:位姿怎么表示(位置+姿态)、旋转的三种表示(旋转矩阵/欧拉角/四元数)及互转、齐次变换矩阵的串联与求逆、ROS2 中姿态的规范与消息类型。学完你将有能力读懂 tf2、URDF、导航、机械臂所有篇章的数学地基。 ### 一、为什么要先学位姿与坐标变换 #### 1.1 机器人世界里的一切都是"在哪个坐标系下" 先看一个真实场景:你的机器人底盘中心装了一台激光雷达,雷达前方 1.0 米处有一面墙。 - 雷达说:“墙在我 x 轴正方向 1.0 米处。” —— 这是**雷达坐标系**下的测量。 - 但导航模块需要知道:“墙在世界地图的什么位置?” —— 这是**地图坐标系**下的问题。 - 两个答案相差多少?取决于雷达装得偏不偏、机器人当前在哪儿、朝哪儿。 **坐标变换就是把"一个坐标系下的描述"换算成"另一个坐标系下的描述"**。它是所有机器人系统的通用语言:感知、建图、定位、导航、规划、控制,每一环都在做这件事。 #### 1.2 位姿 = 位置 + 姿态 描述一个物体(或坐标系)在空间中的状态,需要 6 个自由度: - **位置(Position)**:3 个自由度,物体原点在参考系中的坐标 `(x, y, z)`。 - **姿态(Orientation)**:3 个自由度,物体坐标系相对参考系"转了多少"。 位姿(Pose)就是二者的合称。ROS2 中最常见的消息 `geometry_msgs/msg/Pose` 就是 `position`(点)+ `orientation`(四元数)两部分: ``` geometry_msgs/msg/Pose ├── Point position # 位置 x, y, z │ ├── float64 x │ ├── float64 y │ └── float64 z └── Quaternion orientation # 姿态(四元数) ├── float64 x ├── float64 y ├── float64 z └── float64 w ``` 注意:**位置和姿态是两回事**,但常被初学者混为一谈。一台机器人可以站在同一个位置 (0, 0, 0),却可以朝向任何方向——位置没变,姿态变了。 #### 1.3 姿态为什么难? 位置就是三个数,人人都会。姿态难在:**旋转不能用"三个数"简单地表示**(至少不能直观地表示)。 - 欧拉角(roll/pitch/yaw)直观但有**万向锁**问题,且结果依赖旋转顺序。 - 旋转矩阵无奇异但 9 个数有冗余(实际只有 3 个自由度),还伴随正交性约束。 - 四元数紧凑、无奇异、便于插值,但不直观,新手容易在 x/y/z/w 上搞混。 ROS2 的规范答案:**内部统一用四元数**。你可以在脑中用欧拉角思考,在代码里必须转成四元数。这就是本篇文章的核心任务:把三种表示都讲透,并给出可运行的互转代码。 ### 二、旋转的三种表示(核心数学) #### 2.1 旋转矩阵 R 绕坐标轴的**基本旋转矩阵**(右手系,逆时针为正): 绕 x 轴转 α: ``` R_x(α) = | 1 0 0 | | 0 cos α -sin α | | 0 sin α cos α | ``` 绕 y 轴转 β: ``` R_y(β) = | cos β 0 sin β | | 0 1 0 | | -sin β 0 cos β | ``` 绕 z 轴转 γ: ``` R_z(γ) = | cos γ -sin γ 0 | | sin γ cos γ 0 | | 0 0 1 | ``` **旋转矩阵的三个性质**(务必记住,后面校验全靠它): 1. **正交**:`R^T = R⁻¹`,即转置等于逆。 2. **行列式 = 1**。 3. 把向量 `v` 旋转得到 `v' = R · v`。 旋转矩阵把"基向量在新坐标系中的表达"写成列向量,所以它天然携带"坐标系"信息:`R` 的第 i 列就是原坐标系第 i 个基向量在新坐标系下的坐标。 #### 2.2 欧拉角(RPY) 欧拉角用"绕三个轴依次转三次"描述姿态。ROS2/机器人领域最常用的是 **RPY 约定**(Roll 横滚、Pitch 俯仰、Yaw 偏航): ``` R = R_z(yaw) · R_y(pitch) · R_x(roll) ``` 注意:**矩阵乘法不满足交换律**,旋转顺序必须固定。ROS2 默认约定是"**先绕 z(yaw)、再绕 y(pitch)、最后绕 x(roll)**",即从参考系出发依次施加 yaw → pitch → roll 的**外旋**(或等效地,对物体自身依次做 roll → pitch → yaw 的**内旋**)。你在代码里写 RPY 转四元数时,用的就是这个约定。 **万向锁(Gimbal Lock)**:当 pitch = ±90° 时,roll 与 yaw 的旋转轴重合,丢失一个自由度——表现为"两个角可以互相抵消,姿态描述不唯一"。这是欧拉角的**固有缺陷**,无解,只能换表示方法。 ```text 飞机类比:roll 是机身侧倾,pitch 是机头抬起,yaw 是机头左右转。 当飞机笔直朝上(pitch=90°)时,侧倾和转向都在绕同一根轴,就"锁死"了。 ``` #### 2.3 四元数(Quaternion) 四元数是一个超复数:`q = w + xi + yj + zk`,其中 `w` 是实部,`(x, y, z)` 是虚部。ROS2 消息里把四个分量按 `x, y, z, w` 排列(注意顺序!w 在最后)。 **旋转轴-角表示**:绕单位向量 `(nx, ny, nz)` 转 θ 角,对应的四元数是: ``` w = cos(θ/2) x = nx · sin(θ/2) y = ny · sin(θ/2) z = nz · sin(θ/2) ``` **四元数的性质**: 1. **单位四元数**:`x² + y² + z² + w² = 1`(旋转四元数必须归一化,否则是非法的)。 2. `q` 与 `-q` 表示**同一个旋转**(这是重要的排障知识点:看似不一样的两个四元数其实一样)。 3. 绕 z 轴转 90°:`w = cos(45°) ≈ 0.7071, z = sin(45°) ≈ 0.7071, x = y = 0`。 4. 零旋转(不转):`w = 1, x = y = z = 0`。**注意不是全 0**——`(0,0,0,0)` 是非法四元数,新手经常犯这个错。 **为什么 ROS2 用它**:无奇异点(没有万向锁)、只占 4 个数(比旋转矩阵少 5 个)、便于球面插值(slerp)做平滑过渡。代价是**不直观**——这也是你必须学会互转的原因。 #### 2.4 三种表示互转公式 **四元数 → 旋转矩阵**(单位四元数 q): ``` R = | 1-2(y²+z²) 2(xy-wz) 2(xz+wy) | | 2(xy+wz) 1-2(x²+z²) 2(yz-wx) | | 2(xz-wy) 2(yz+wx) 1-2(x²+y²)| ``` **旋转矩阵 → 欧拉角**(RPY 约定,`atan2` 保象限): ``` roll = atan2(R[2][1], R[2][2]) pitch = asin(-R[2][0]) yaw = atan2(R[1][0], R[0][0]) ``` **四元数 → 欧拉角**: ``` roll = atan2(2(wx+yz), 1-2(x²+y²)) pitch = asin(2(wy-xz)) yaw = atan2(2(wz+xy), 1-2(y²+z²)) ``` **欧拉角 → 四元数**(yaw → pitch → roll 顺序): ``` cy = cos(yaw/2), sy = sin(yaw/2) cp = cos(pitch/2), sp = sin(pitch/2) cr = cos(roll/2), sr = sin(roll/2) w = cr·cp·cy + sr·sp·sy x = sr·cp·cy - cr·sp·sy y = cr·sp·cy + sr·cp·sy z = cr·cp·sy - sr·sp·cy ``` > 记住这条主线:**欧拉角(人脑)→ 四元数(ROS2 内部)→ 旋转矩阵(数值计算)**。你在 ROS2 里发布姿态用四元数,与 tf2 交互用旋转矩阵或四元数,跟人交流用欧拉角。 ### 三、齐次变换矩阵:把位置和姿态打包 #### 3.1 从"旋转+平移"到 4×4 矩阵 单有旋转矩阵 `R` 只能描述姿态,坐标变换还需要平移 `t`。把两者合成一个 4×4 的**齐次变换矩阵 T**: ``` T = | R t | | 0 1 | ``` 展开: ``` T = | r11 r12 r13 | tx | | r21 r22 r23 | ty | | r31 r32 r33 | tz | | 0 0 0 | 1 | ``` **点变换**:把点 `p`(齐次坐标 `[x, y, z, 1]`)从 A 系变换到 B 系: ``` p_B = T_B←A · p_A ``` **为什么加一行 0 0 0 1**:因为矩阵乘法要求维度匹配,加这一行后平移可以写进矩阵乘法里,实现"旋转和平移统一为一次矩阵乘法"。 #### 3.2 变换的串联:链式相乘 假设三个坐标系 A → B → C,已知 `T_B←A` 和 `T_C←B`,则: ``` T_C←A = T_C←B · T_B←A ``` **顺序不可交换**:`T_C←B · T_B←A ≠ T_B←A · T_C←B`。写代码时务必从右往左读:先经过 B,再经过 C。 这就是 TF 树(第 15 篇详讲)的数学本质:**tf2 把整个机器人所有坐标系之间的变换存成一张树,查询任意两个坐标系之间的变换,就是沿着树做矩阵链乘法**。 #### 3.3 变换的求逆 已知 `T_B←A`,求 `T_A←B`(反向变换): ``` T_A←B = T_B←A⁻¹ = | R^T -R^T·t | | 0 1 | ``` 即:**旋转部分取转置,平移部分取 `-R^T·t`**。这是 tf2 里"反向查询"的实现基础。 #### 3.4 一个手算示例 设 B 系相对 A 系:绕 z 轴转 90°(yaw=90°),再沿 x 轴平移 1 米。则: ``` R = | 0 -1 0 | | 1 0 0 | | 0 0 1 | t = | 1 | | 0 | | 0 | ``` B 系原点 (0,0,0) 在 A 系中的坐标 = t = (1, 0, 0)。B 系中 (1, 0, 0) 的点在 A 系中: ``` p_A = R·(1,0,0) + t = (0, 1, 0) + (1, 0, 0) = (1, 1, 0) ``` 验证直觉:绕 z 转 90° 后,x 轴正向指向原 y 轴正向,所以 (1,0,0) 变成 (0,1,0),再整体平移到 (1,1,0)。✓ ### 四、ROS2 中的姿态规范与消息类型 #### 4.1 关键消息类型 | 消息 | 字段 | 用途 | | --- | --- | --- | | `geometry_msgs/msg/Pose` | position + orientation | 完整位姿(无时间戳) | | `geometry_msgs/msg/PoseStamped` | header + pose | 带时间戳和坐标系 id 的位姿 | | `geometry_msgs/msg/PoseWithCovariance` | pose + covariance | 位姿+协方差(定位输出) | | `geometry_msgs/msg/Transform` | translation + rotation | 坐标变换(无时间戳) | | `geometry_msgs/msg/TransformStamped` | header + child_frame_id + transform | 带父子坐标系名的变换(tf2 专用) | | `geometry_msgs/msg/Point` | x, y, z | 纯位置 | | `geometry_msgs/msg/Quaternion` | x, y, z, w | 纯姿态(四元数) | **必须记住的规范**: 1. **ROS2 内一切姿态用四元数**,禁止直接发布欧拉角。 2. **四元数必须归一化**:`x²+y²+z²+w² = 1`(误差 < 1e-6)。发布前先校验。 3. `PoseStamped.header.frame_id` 必须写清楚"这个位姿是在哪个坐标系下表达的",否则下游无从变换。 4. 常见的坑:把 `Quaternion` 写成全 0(非法)、忘记归一化、把 x/y/z/w 顺序搞错(ROS2 是 x,y,z,w,**w 在最后**)。 #### 4.2 常用工具函数 **C++(tf2 库,随 ros2 自带)**: ```cpp #include #include // 欧拉角 → 四元数 tf2::Quaternion q; q.setRPY(roll, pitch, yaw); // 弧度制! geometry_msgs::msg::Quaternion qmsg = tf2::toMsg(q); // 四元数 → 欧拉角 tf2::Quaternion q2; tf2::fromMsg(qmsg, q2); double r, p, y; tf2::Matrix3x3(q2).getRPY(r, p, y); // 得到弧度 ``` **Python(tf_transformations,随 ros2 自带)**: ```python from tf_transformations import quaternion_from_euler, euler_from_quaternion # 欧拉角 → 四元数(返回 [x, y, z, w]) q = quaternion_from_euler(roll, pitch, yaw) # 弧度制 # 四元数 → 欧拉角 roll, pitch, yaw = euler_from_quaternion([qx, qy, qz, qw]) ``` > 两个库都遵循同一约定:**RPY 顺序,弧度制,结果 w 在最后**。这保证了 C++ 和 Python 代码可以互换结果。 ### 五、可运行示例(4 个) 下面 4 个示例覆盖"理解互转 → 动手发布 → 动手变换"的完整链路。请逐个在你的工作空间中创建、编译、运行。 #### 示例 1:Python 位姿互转计算器(不进 ROS2,纯数学验证) 目的:验证你对互转公式的理解,并熟悉 `tf_transformations`。 ```python #!/usr/bin/env python3 """pose_math.py:欧拉角 <-> 四元数 <-> 旋转矩阵 互转演示""" import math from tf_transformations import quaternion_from_euler, euler_from_quaternion def normalize(q): """归一化四元数""" n = math.sqrt(q[0]**2 + q[1]**2 + q[2]**2 + q[3]**2) return [v / n for v in q] def quat_rotate_point(q, p): """用四元数旋转一个点(罗德里格斯等价实现)""" qx, qy, qz, qw = q # 公式:p' = p + 2w(v×p) + 2(v×(v×p)) v = [qx, qy, qz] cross1 = [v[1]*p[2]-v[2]*p[1], v[2]*p[0]-v[0]*p[2], v[0]*p[1]-v[1]*p[0]] cross2 = [v[1]*cross1[2]-v[2]*cross1[1], v[2]*cross1[0]-v[0]*cross1[2], v[0]*cross1[1]-v[1]*cross1[0]] return [p[i] + 2*qw*cross1[i] + 2*cross2[i] for i in range(3)] if __name__ == '__main__': # 1) 欧拉角 -> 四元数 yaw = math.radians(90.0) pitch = math.radians(0.0) roll = math.radians(0.0) q = quaternion_from_euler(roll, pitch, yaw) print(f"yaw=90° -> q = {[round(v,4) for v in q]}") # 期望约 [0, 0, 0.7071, 0.7071] # 2) 校验归一化 qn = normalize(q) print(f"归一化后模长 = {math.sqrt(sum(v*v for v in qn)):.6f}") # 3) 四元数 -> 欧拉角(验证往返) r, p, y = euler_from_quaternion(q) print(f"q -> 欧拉角 = ({math.degrees(r):.1f}, {math.degrees(p):.1f}, {math.degrees(y):.1f}) deg") # 4) 用四元数旋转点 (1, 0, 0):绕 z 转 90° 应得到 (0, 1, 0) rotated = quat_rotate_point(q, [1.0, 0.0, 0.0]) print(f"绕 z 转 90° 后 (1,0,0) -> {[round(v,4) for v in rotated]}") ``` 运行: ```bash python3 pose_math.py ``` 预期输出(前两行): ``` yaw=90° -> q = [0.0, 0.0, 0.7071, 0.7071] 归一化后模长 = 1.000000 ``` #### 示例 2:C++ 旋转矩阵与四元数互转节点 目的:在 ROS2 节点里用 `tf2` 库做互转,并学会"发布前校验四元数"。 ```cpp // quat_node.cpp:欧拉角<->四元数互转 + 合法性校验 #include #include #include #include class QuatNode : public rclcpp::Node { public: QuatNode() : Node("quat_node") { // 1) 欧拉角(度) -> 四元数 double roll_deg = 30.0, pitch_deg = 20.0, yaw_deg = 45.0; double roll = roll_deg * M_PI / 180.0; double pitch = pitch_deg * M_PI / 180.0; double yaw = yaw_deg * M_PI / 180.0; tf2::Quaternion q; q.setRPY(roll, pitch, yaw); q.normalize(); geometry_msgs::msg::Quaternion qmsg = tf2::toMsg(q); RCLCPP_INFO(this->get_logger(), "RPY(%0.1f, %0.1f, %0.1f) -> quat(x=%0.4f, y=%0.4f, z=%0.4f, w=%0.4f)", roll_deg, pitch_deg, yaw_deg, qmsg.x, qmsg.y, qmsg.z, qmsg.w); // 2) 校验模长是否为 1 double norm = std::sqrt(qmsg.x*qmsg.x + qmsg.y*qmsg.y + qmsg.z*qmsg.z + qmsg.w*qmsg.w); if (std::abs(norm - 1.0) > 1e-6) { RCLCPP_ERROR(this->get_logger(), "四元数未归一化!norm=%f", norm); } else { RCLCPP_INFO(this->get_logger(), "四元数归一化校验通过 (norm=%f)", norm); } // 3) 四元数 -> 欧拉角(验证往返) tf2::Quaternion q2; tf2::fromMsg(qmsg, q2); tf2::Matrix3x3 m(q2); double r, p, y; m.getRPY(r, p, y); RCLCPP_INFO(this->get_logger(), "quat -> RPY(deg) = (%0.1f, %0.1f, %0.1f)", r * 180.0 / M_PI, p * 180.0 / M_PI, y * 180.0 / M_PI); } }; int main(int argc, char** argv) { rclcpp::init(argc, argv); auto node = std::make_shared(); rclcpp::shutdown(); return 0; } ``` `CMakeLists.txt` 要点(`find_package` 加上 `tf2` 和 `tf2_geometry_msgs`): ```cmake find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) find_package(tf2 REQUIRED) find_package(tf2_geometry_msgs REQUIRED) add_executable(quat_node src/quat_node.cpp) ament_target_dependencies(quat_node rclcpp tf2 tf2_geometry_msgs) install(TARGETS quat_node DESTINATION lib/${PROJECT_NAME}) ``` 运行: ```bash colcon build --packages-select quat_demo source install/setup.bash ros2 run quat_demo quat_node ``` 预期日志: ``` RPY(30.0, 20.0, 45.0) -> quat(x=0.2393, y=0.1449, z=0.4277, w=0.8536) 四元数归一化校验通过 (norm=1.000000) quat -> RPY(deg) = (30.0, 20.0, 45.0) ``` #### 示例 3:C++ 发布带姿态的 PoseStamped 话题 目的:掌握发布位姿消息的标准姿势(含 header 与四元数设置)。 ```cpp // pose_pub.cpp:周期性发布机器人位姿 #include #include #include #include #include class PosePub : public rclcpp::Node { public: PosePub() : Node("pose_pub"), yaw_(0.0) { pub_ = this->create_publisher( "/robot_pose", 10); timer_ = this->create_wall_timer( std::chrono::milliseconds(500), std::bind(&PosePub::timer_cb, this)); } private: void timer_cb() { // 模拟机器人沿圆形轨迹运动并不断转向 yaw_ += 0.1; // 每 0.5s 转 0.1 rad double radius = 2.0; double x = radius * std::cos(yaw_); double y = radius * std::sin(yaw_); geometry_msgs::msg::PoseStamped msg; msg.header.stamp = this->now(); msg.header.frame_id = "map"; // 必须写清楚坐标系! msg.pose.position.x = x; msg.pose.position.y = y; msg.pose.position.z = 0.0; tf2::Quaternion q; q.setRPY(0.0, 0.0, yaw_); // 平面运动:只有偏航 msg.pose.orientation = tf2::toMsg(q); pub_->publish(msg); RCLCPP_INFO(this->get_logger(), "pose: (%.2f, %.2f) yaw=%.2f rad", x, y, yaw_); } rclcpp::Publisher::SharedPtr pub_; rclcpp::TimerBase::SharedPtr timer_; double yaw_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` 观察话题: ```bash ros2 run pose_demo pose_pub & ros2 topic echo /robot_pose --once ``` 你会看到 `header.frame_id: map`、位置随时间变化、四元数始终是单位四元数(只有 z/w 分量随 yaw 变化)。 #### 示例 4:Python 发布+订阅位姿并实时转成欧拉角 目的:Python 端完整闭环——发 PoseStamped,订阅端把四元数转回欧拉角显示。 ```python #!/usr/bin/env python3 """pose_demo.py:发布/订阅位姿,订阅端转欧拉角""" import rclpy from rclpy.node import Node from geometry_msgs.msg import PoseStamped from tf_transformations import quaternion_from_euler, euler_from_quaternion import math class PosePublisher(Node): def __init__(self): super().__init__('pose_publisher') self.pub = self.create_publisher(PoseStamped, '/pose_demo', 10) self.timer = self.create_timer(1.0, self.timer_cb) self.yaw = 0.0 def timer_cb(self): self.yaw += 0.3 msg = PoseStamped() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'map' msg.pose.position.x = 1.0 * math.cos(self.yaw) msg.pose.position.y = 1.0 * math.sin(self.yaw) q = quaternion_from_euler(0.0, 0.0, self.yaw) # [x,y,z,w] msg.pose.orientation.x = q[0] msg.pose.orientation.y = q[1] msg.pose.orientation.z = q[2] msg.pose.orientation.w = q[3] self.pub.publish(msg) class PoseSubscriber(Node): def __init__(self): super().__init__('pose_subscriber') self.sub = self.create_subscription( PoseStamped, '/pose_demo', self.cb, 10) def cb(self, msg): q = [msg.pose.orientation.x, msg.pose.orientation.y, msg.pose.orientation.z, msg.pose.orientation.w] roll, pitch, yaw = euler_from_quaternion(q) self.get_logger().info( '收到位姿: pos=(%.2f, %.2f, %.2f) yaw=%.1f deg (frame=%s)', msg.pose.position.x, msg.pose.position.y, msg.pose.position.z, math.degrees(yaw), msg.header.frame_id) def main(args=None): rclpy.init(args=args) pub = PosePublisher() sub = PoseSubscriber() executor = rclpy.executors.MultiThreadedExecutor() executor.add_node(pub) executor.add_node(sub) try: executor.spin() finally: executor.shutdown() pub.destroy_node() sub.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 运行: ```bash python3 pose_demo.py ``` 日志示例: ``` [INFO] 收到位姿: pos=(1.00, 0.00, 0.00) yaw=0.0 deg (frame=map) [INFO] 收到位姿: pos=(0.96, 0.30, 0.00) yaw=17.2 deg (frame=map) [INFO] 收到位姿: pos=(0.83, 0.56, 0.00) yaw=34.4 deg (frame=map) ``` 注意 `pos=(1.00, 0.00)` 时 `yaw=0.0`:机器人朝向 x 轴正方向;当它走到 (0.96, 0.30) 时 yaw≈17.2°,**朝向始终与运动方向一致**——这正是"位姿"中位置与姿态必须配套的直观体现。 ### 六、位姿与坐标变换的实际应用 #### 6.1 底盘位姿估计(odometry) 轮式机器人通过轮速积分得到里程计位姿:每个控制周期更新一次 `(x, y, yaw)`。你会发现它的发布代码和示例 3 几乎一样——**所有里程计话题 `/odom` 的本质就是"不断发布的 PoseStamped"**(外加速度)。 #### 6.2 传感器坐标标定(extrinsic calibration) 激光雷达/相机装到机器人上时,需要标定"传感器坐标系相对机器人基座的变换"。标定结果就是一个 `TransformStamped`(4×4 矩阵)。有了它,雷达扫到的每个点都能变换到机器人坐标系,再变换到地图坐标系——这就是建图的第一步。 #### 6.3 导航与规划中的位姿 - AMCL 定位:输出 `PoseWithCovarianceStamped`——不仅给位姿,还给"多不自信"(协方差)。 - 路径规划:起点和终点都是位姿;规划出的路径是一串位姿点。 - 机械臂(第 35-40 篇):逆运动学求解的"目标"就是一个位姿。 **一句话**:位姿是机器人的"身份证",坐标变换是机器人的"翻译官"。两者是所有上层模块的公共地基。 ### 七、常见错误与排障 | 错误 | 表现 | 原因与解决 | | --- | --- | --- | | 四元数全 0 | rviz 里物体"消失"或姿态乱跳 | `(0,0,0,0)` 非法;零旋转应为 `(0,0,0,1)` | | 四元数未归一化 | tf2 报 “Quaternion is not normalized” | 发布前先 `q.normalize()` 或手算归一化 | | 欧拉角直接塞进 orientation | 姿态完全不对 | ROS2 只认四元数;必须 `setRPY`/`quaternion_from_euler` 转换 | | 忘记写 frame_id | 下游报 “Unknown frame” | 任何带 header 的消息都必须写明坐标系 | | 角度用度数 | 旋转量级明显不对 | ROS2/tf2/数学库全部用**弧度**;入参前统一转弧度 | | 旋转顺序搞错 | 互转后数值对不上 | 全链统一 RPY 顺序:yaw→pitch→roll | | `q` 与 `-q` 困惑 | 明明"一样"的旋转数值却相反 | 单位四元数 q 与 -q 等价;比较姿态不要直接比数值 | | 矩阵乘反 | 变换方向反了 | 从右往左读:`T_C←A = T_C←B · T_B←A` | ### 八、小结与下一篇预告 **本篇核心**: 1. **位姿 = 位置(3) + 姿态(3)**,共 6 自由度。 2. **姿态三种表示**:旋转矩阵(计算)、欧拉角(直观,有万向锁)、四元数(ROS2 内部标准)。 3. **互转公式**:欧拉角↔四元数↔旋转矩阵,全链统一 RPY 顺序 + 弧度制。 4. **齐次变换矩阵 T**:旋转+平移一体,串联相乘、求逆即反向。 5. **ROS2 规范**:姿态一律四元数、必须归一化、必须写 frame_id、w 在最后。 6. 4 个可运行示例覆盖互转、发布、订阅闭环。 下一篇进入 **tf2 坐标变换详解(C++/Python)**:在 ROS2 中如何广播(broadcast)与监听(lookup)坐标变换、`tf2_ros` 的 Buffer 与 TransformListener 用法、如何发布静态变换、以及"变换不可用"的常见排障。位姿是数学,tf2 是把数学变成机器可用的管道。 --- ## ROS2系列教程:常用工具详解(ros2 CLI、rqt、RViz、ros2 bag、tf2基础) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-chang-yong-gong-ju-xiang-jie-ros2/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 12 篇 > 本文是 ROS2 系列教程的第 12 篇:常用工具详解。学到这里,你已经掌握了 ROS2 的核心机制,但"会用"和"会排障"之间还差一套趁手的工具。本篇文章系统梳理 ROS2 开发调试的"眼睛和双手":`ros2` CLI 全家桶、可视化工具 rqt、RViz2 基础、ros2 bag 数据录制回放、tf2 工具基础。学完你将具备独立排障能力,能看懂任何 ROS2 系统的运行状态。 ### 一、ros2 CLI 全家桶 #### 1.1 命令总览 `ros2` 是 ROS2 的统一命令行入口,所有子命令按功能分类: ```bash ros2 node # 节点管理 ros2 topic # 话题管理 ros2 service # 服务管理 ros2 action # 动作管理 ros2 param # 参数管理 ros2 interface # 接口(消息/服务/动作类型)管理 ros2 pkg # 包管理 ros2 launch # launch 文件 ros2 bag # 数据录制回放 ros2 daemon # 后台守护进程管理 ros2 doctor # 系统健康检查 ros2 run # 运行节点 ros2 lifecycle # 生命周期管理 ``` 查看帮助: ```bash ros2 --help # 总帮助 ros2 topic --help # 子命令帮助 ros2 topic hz --help # 具体命令帮助 ``` #### 1.2 node 与 pkg ```bash # 节点 ros2 node list # 列出所有节点 ros2 node list --verbose # 带详细类型信息 ros2 node info /talker # 节点完整信息(话题/服务/参数/动作) # 包 ros2 pkg list # 列出所有已安装包 ros2 pkg list | grep demo # 过滤 ros2 pkg prefix demo_nodes_py # 包安装位置 ros2 pkg executables demo_nodes_py # 包内可执行命令 ros2 pkg xml demo_nodes_py # 查看包元数据 ``` **`ros2 node info` 是排障第一命令**:它能列出节点所有发布/订阅的话题、提供的服务、声明的参数、动作服务器——一眼看出"这个节点到底在干什么"。 #### 1.3 daemon 与缓存 `ros2` 命令依赖后台守护进程(daemon)维护节点信息缓存: ```bash # 查看 daemon 状态 ros2 daemon status # 停止 daemon(节点信息不准时用) ros2 daemon stop # 重新启动 ros2 daemon start ``` **常见坑**:节点明明关了,`ros2 node list` 还显示它——daemon 缓存过期。解决:`ros2 daemon stop && ros2 daemon start`。 #### 1.4 ros2 doctor:系统体检 ```bash ros2 doctor # 检查系统健康 ros2 doctor --report # 生成详细报告(贴到问题帖里很有效) ``` 会检查:环境变量、发现协议、DDS 配置、网络等。**遇到诡异问题时先跑一次 doctor**。 ### 二、rqt:可视化调试工具集 #### 2.1 rqt 总览 **rqt** 是 ROS2 的 GUI 工具集,提供大量可视化插件: ```bash # 启动 rqt 主窗口(插件从菜单添加) rqt # 直接启动指定插件 rqt_graph # ROS 图可视化(最常用) rqt_console # 日志控制台(过滤各级日志) rqt_tf_tree # TF 树可视化(坐标变换关系) rqt_reconfigure # 参数动态配置(滑块改参数) rqt_plot # 数据曲线绘制 rqt_image_view # 图像话题可视化 ``` #### 2.2 rqt_graph:看清系统结构 `rqt_graph` 是最重要的调试工具——把运行中的 ROS 图画出来: ```bash rqt_graph ``` 画面要素: - **椭圆** = 节点 - **箭头** = 话题连接(发布者 → 订阅者) - **话题名**显示在连线上 - 隐藏 `/rosout` 等系统话题(勾选 “Hide debug”) **用途**: 1. 一眼看出"谁在发、谁在收"。 2. 发现"应连未连"的断线(节点没收到数据时先看图)。 3. 确认重映射/命名空间是否生效。 #### 2.3 rqt_console:日志过滤 ```bash rqt_console ``` 按**级别**(Debug/Info/Warn/Error/Fatal)和**节点**过滤日志,是排查运行问题的利器:所有节点的 Warn/Error 集中在一个界面,不用翻几十个终端。 #### 2.4 rqt_reconfigure:在线调参 ```bash rqt_reconfigure ``` 左侧选节点,右侧出现该节点所有参数,**拖动滑块/勾选框实时修改**——比命令行 `ros2 param set` 直观得多。调 PID、调速度的利器。 #### 2.5 rqt_plot:数据可视化 ```bash rqt_plot /odom/pose/pose/position/x /odom/pose/pose/position/y ``` 订阅数值型话题字段画曲线,适合观察里程计轨迹、传感器波形。 ### 三、RViz2:3D 可视化 #### 3.1 RViz2 是什么 **RViz2** 是 ROS2 的 3D 可视化工具,展示机器人的位姿、传感器数据、地图、路径: ```bash rviz2 ``` #### 3.2 核心概念:Display 与 Fixed Frame - **Display(显示项)**:左侧面板的每一项,如 RobotModel、LaserScan、Map、Path。 - **Fixed Frame(固定坐标系)**:3D 视图的参考系,默认 `map`;如果 TF 树里没有这个坐标系,视图会空。 **添加显示**:左下角 “Add” → 选择类型 → 在 Display 面板配置 Topic。 常用 Display: | Display | 显示内容 | 配置要点 | | --- | --- | --- | | RobotModel | 机器人 3D 模型 | 加载 URDF,选 robot_description 话题 | | LaserScan | 激光雷达点 | 选 /scan 话题 | | PointCloud2 | 点云 | 选对应话题 | | Map | 地图 | 选 /map | | Path | 路径 | 选 /plan 等 | | TF | 坐标系 | 显示所有 TF 坐标系与连线 | | Grid | 网格 | 默认有,不用加 | #### 3.3 RViz2 常用操作 ```bash # 启动并加载配置 rviz2 -d config.rviz # 复用保存的配置 # 用 launch 启动(第 11 篇讲过) ``` **保存配置**:调好显示项后 `File → Save Config As`,下次 `-d` 加载——团队共享显示配置的标准做法。 #### 3.4 RViz2 排障经验 | 现象 | 原因 | 解决 | | --- | --- | --- | | 画面空白 | Fixed Frame 没有 TF | 确认 TF 树;改为存在的坐标系(如 base_link) | | 没有 RobotModel | 没发布 robot_description | 启动 robot_state_publisher | | 模型乱飘 | 缺少 joint_states | 启动 joint_state_publisher 或仿真 | | 雷达点不显示 | QoS 不匹配 | RViz 里选 Sensor Data 档(best_effort) | ### 四、ros2 bag 实战 #### 4.1 录制 ```bash # 录制所有话题 ros2 bag record -a # 录制指定话题 ros2 bag record /odom /scan /cmd_vel # 指定目录与覆盖 QoS ros2 bag record /scan -o scan_data \ --qos-profile-overrides "{'sensor_msgs::msg::LaserScan': {reliability: 'best_effort'}}" ``` #### 4.2 查看与回放 ```bash # 查看 bag 信息 ros2 bag info scan_data # Files: ... # Topics: /scan (360 messages), ... # 回放 ros2 bag play scan_data # 指定话题、倍速、循环 ros2 bag play scan_data --topics /scan --rate 2.0 --loop ``` #### 4.3 bag 在调试中的价值 1. **复现问题**:录下现场数据,离线反复回放分析。 2. **算法对比**:同一份数据喂给不同算法版本,公平对比。 3. **仿真回灌**:把真实传感器数据灌进仿真环境测试。 4. **时间旅行**:`--start-offset`、`--duration` 控制播放区间。 ### 五、tf2 工具基础 #### 5.1 什么是 tf2 **tf2** 是 ROS2 的坐标变换库:维护"坐标系树",提供任意两个坐标系间的位姿变换。机器人上每个部件都有一个坐标系(base_link、laser、camera、map),tf2 让"激光点坐标 → 地图坐标"的换算自动完成。 #### 5.2 tf2 命令行工具 ```bash # 查看当前 TF 树(所有坐标系及父子关系) ros2 run tf2_ros tf2_echo base_link laser # At time ...: Translation: [0.1, 0.0, 0.2], Rotation: ... # 查看 TF 树全貌(文本) ros2 run tf2_ros tf2_monitor # 可视化 TF 树(图形) ros2 run tf2_tools view_frames # 生成 frames.pdf # rviz 里查看 TF 树 rqt_tf_tree ``` #### 5.3 静态坐标变换 固定关系的坐标系(如传感器在车体上的安装位置)用静态变换发布: ```bash # 发布 base_link → laser 的静态变换(x=0.1, y=0, z=0.2, 无旋转) ros2 run tf2_ros static_transform_publisher \ 0.1 0.0 0.2 0.0 0.0 0.0 base_link laser ``` ### 六、综合实战:一个完整的排障流程 #### 6.1 场景 假设导航系统"地图显示但机器人不动",按下面的流程排查: #### 6.2 排障流程 ```bash # 第 1 步:系统体检 ros2 doctor # 第 2 步:看节点是否都在 ros2 node list # 第 3 步:看 ROS 图(谁连谁,有没有断线) rqt_graph # 第 4 步:看关键话题数据流 ros2 topic list ros2 topic hz /cmd_vel # 有没有指令输出? ros2 topic hz /odom # 里程计有没有数据? ros2 topic echo /cmd_vel --once # 指令内容是什么? # 第 5 步:看日志(错误集中地) rqt_console # 第 6 步:看 TF 树(坐标是否完整) ros2 run tf2_tools view_frames # 第 7 步:看参数 ros2 param list /nav2_controller ros2 param get /nav2_controller controller_frequency # 第 8 步:录数据离线分析 ros2 bag record -a ``` **排查顺序口诀**:先 doctor 体检 → 再 node list 看存活 → 再 rqt_graph 看连接 → 再 topic 看数据 → 再 console 看错误 → 再 tf 看坐标 → 最后 param 看配置。 #### 6.3 判断矩阵 | 症状 | 可能原因 | 验证命令 | | --- | --- | --- | | 话题无数据 | 发布者没跑 / QoS 不匹配 | `ros2 topic hz` + `--verbose` | | 节点不在列表 | 没启动 / 崩了 | `ros2 node list` + rqt_console | | 有数据但不动 | 控制链断 / 参数错 | rqt_graph + `ros2 param get` | | 坐标错误 | TF 缺失 / 静态变换错 | view_frames + tf2_echo | | 时灵时不灵 | 网络 / 时序 / 缓存 | doctor + daemon restart + 录 bag | ### 七、其他实用技巧 #### 7.1 日志级别控制 ```bash # 全局提高日志级别 ros2 run py_demo sensor_node --ros-args --log-level DEBUG # 只对特定 logger 提高(如只调试话题) ros2 run py_demo sensor_node --ros-args \ --log-level sensor_node:=DEBUG ``` #### 7.2 命令行补全 ```bash # 安装命令补全(输一半按 Tab 补全包名/话题名/类型) # 在 ~/.bashrc 中已由 ROS2 安装脚本配置 ros2 topic pub / # 自动补全话题名 ``` #### 7.3 一键查看所有话题频率 ```bash # 查看所有话题的发布频率概览 ros2 topic list -t # 逐个 hz 不方便时,用 rqt 的 Topic Monitor 插件 ``` ### 八、ros2 bag 进阶与数据工程 #### 8.1 录制过滤与压缩 ```bash # 排除指定话题(如高频图像,避免 bag 巨大) ros2 bag record -a -x "/camera/.*|/debug/.*" # 压缩存储(SQLite3 格式支持压缩) ros2 bag record -a --compression-mode file --compression-format zstd # 多路录制到同一文件 ros2 bag record /odom /scan /cmd_vel -o session_$(date +%Y%m%d_%H%M%S) ``` **实践建议**:默认 `record -a` 会连 `/parameter_events`、`/rosout` 等系统话题一起录,bag 偏大;正式采集用 `-x` 排除无关话题。 #### 8.2 时间操作 ```bash # 只看某个时间窗口 ros2 bag play session_bag --start-offset 10 --duration 20 # 按真实时间播放(默认按录制节奏,也可倍速) ros2 bag play session_bag --rate 0.5 # 慢放 ros2 bag play session_bag --rate 2.0 # 快放 # 播放时重映射话题(换个话题名回放) ros2 bag play session_bag --remap /scan:=/scan_offline ``` #### 8.3 用 bag 做离线算法验证 ```python # offline_test.py —— 读取 bag 数据喂给算法(伪代码) import rclpy from rclpy.node import Node from rclpy.serialization import deserialize_message from rosbags.rosbag2 import Reader from rosbags.typesys import get_types_from_msg, Stores # 用 rosbags 库(pip install rosbags)解析 bag with Reader('session_bag') as reader: for connection, timestamp, rawdata in reader.messages(): if connection.topic == '/scan': msg = deserialize_message(rawdata, connection.msgtype) # 把 msg 喂给你的算法函数 process_scan(msg) ``` 这是"数据驱动开发"的标准姿势:**先录数据,再离线迭代算法,最后上真机**——比每次都在真机上调试安全高效得多。 ### 九、ros2 lifecycle 与安全工具 #### 9.1 生命周期节点 有些节点(如 Nav2 的控制器)需要**受控启停**:先配置、再激活,出错可回退。这就是生命周期(Lifecycle)节点,状态机如下: ```text Unconfigured → Inactive → Active ↑ ↓ ↓ (configure) (activate) (deactivate) └── 任何状态都可 shutdown ``` ```bash # 查看生命周期节点状态 ros2 lifecycle get /nav2_controller # 输出: [1] unconfigured # 触发状态切换(configure → activate) ros2 lifecycle set /nav2_controller configure ros2 lifecycle set /nav2_controller activate # 列出所有生命周期节点 ros2 lifecycle nodes ``` **工程价值**:导航系统启动时先让所有模块进入 Inactive,全部配置好、TF 就绪后再统一 activate——避免"数据没准备好就开跑"的竞态问题。 #### 9.2 SROS2 安全基础 ```bash # 检查安全相关配置 ros2 security list # 生成密钥(启用加密通信的前置步骤) ros2 security create_keystore keystore ros2 security create_enclave keystore /namespace/node # 以安全模式启动 export ROS_SECURITY_KEYSTORE=keystore export ROS_SECURITY_ENABLE=true export ROS_SECURITY_STRATEGY=Enforce ``` SROS2 提供加密(TLS/DTLS)与访问控制(权限文件),是 ROS2 上工业/安全场景的标配,也解释了"为什么 ROS2 比 ROS1 安全"。 #### 9.3 性能分析工具 ```bash # 话题收发延迟概览(内置) ros2 topic delay /odom # 发布-订阅时间差 # 回调耗时统计(rclpy/rclcpp 内置 tracer) # 更细粒度分析用 ros2_tracing(系统级 tracing) # 查看节点资源占用 ros2 topic hz --window 100 /odom # 最近 100 条的消息间隔统计 ``` ### 十、综合实战:多机调试与日志分析 #### 10.1 多机场景的调试 ROS2 天然支持多机,但调试时先确认"是否真的连通": ```bash # 机器 A 上跑节点 ros2 run py_demo sensor_node # 机器 B 上应能看到(同一网络/DDS 域) ros2 node list # 看不到时的排查 export ROS_DOMAIN_ID=42 # 两台机器必须一致 ros2 doctor # 检查网络与发现协议 ``` #### 10.2 日志集中分析 ```bash # 把日志导到文件分析 ros2 run py_demo sensor_node 2>&1 | tee sensor.log # 统计错误出现频率 grep -c "ERROR" sensor.log # 按时间排序查看错误上下文 grep -B 2 -A 2 "ERROR" sensor.log | head -50 ``` #### 10.3 完整工具链实战演练 ```bash # 场景:机器人偶发"丢里程计",需要定位 # 1. 先录 10 分钟数据 ros2 bag record /odom /cmd_vel /scan -o diag_bag & # 2. 同时监控频率抖动 ros2 topic hz /odom --window 100 # 3. 出问题后立即看 daemon 状态与 doctor ros2 doctor --report > doctor.txt # 4. 停止录制,离线分析 ros2 bag info diag_bag ros2 bag play diag_bag --rate 0.5 # 慢放观察 # 5. 结合 rqt_graph 确认拓扑、rqt_console 看错误 ``` 这套流程覆盖"实时监控 → 现场记录 → 离线复现 → 定位根因",是 ROS2 工程排障的标准闭环。 ### 十一、总结 本篇文章建立了完整的工具链:`ros2` CLI 全家桶(含 daemon 与 doctor)、rqt 可视化工具集(graph/console/reconfigure/plot)、RViz2 3D 可视化、ros2 bag 录制回放与离线算法验证、tf2 工具、生命周期与安全工具,并通过综合排障流程演示了"工具组合拳"。 **关键要点回顾**: 1. `ros2 node info` 是了解节点的第一命令;`ros2 daemon stop/start` 解决缓存问题。 2. 排障遇到诡异问题先 `ros2 doctor`。 3. `rqt_graph` 看系统结构、`rqt_console` 集中看错误、`rqt_reconfigure` 在线调参。 4. RViz2 画面空白先查 Fixed Frame 与 TF 树。 5. ros2 bag 是"录现场、离线查"的利器,还能做离线算法验证。 6. tf2 工具(tf2_echo/view_frames)是坐标排障的基础。 7. 生命周期节点用 `ros2 lifecycle set` 受控启停;SROS2 提供加密与访问控制。 8. 综合排障顺序:doctor → node → graph → topic → console → tf → param → bag。 9. 多机调试先确认 `ROS_DOMAIN_ID` 一致与网络连通;日志用 tee 落盘 + grep 分析。 ### 下一篇预告 下一篇开启**机器人学篇**:**运动学基础——位姿与坐标变换**。机器人编程绕不开坐标系:机器人位姿怎么表示(位置+姿态)、旋转的三种表示(欧拉角/旋转矩阵/四元数)、坐标变换的数学本质、ROS2 中姿态的规范。这是后续 tf2、URDF、导航、机械臂所有篇章的数学地基。 --- ## ROS2系列教程:launch文件与多节点启动(参数/条件/复用) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-launch-wen-jian-yu-duo-jie-dian-qi/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 11 篇 > 本文是 ROS2 系列教程的第 11 篇:launch 文件与多节点启动。一个真实的机器人系统有几十个节点,手动开几十个终端逐个 `ros2 run` 既不现实也不可维护。本篇文章系统讲解 launch——用 Python 编写 launch 文件实现**一键启动多节点**、传递参数、设置条件与复用、包含其他 launch 文件,掌握 `ros2 launch` 命令与调试技巧。学完你就能用一条命令启动整个机器人系统。 ### 一、为什么需要 launch #### 1.1 手动启动的痛点 ```bash # 手动启动一个导航系统:要开十几个终端 ros2 run nav2_amcl amcl & ros2 run nav2_bt_navigator bt_navigator & ros2 run nav2_controller controller_server & ... ``` 痛点显而易见: - 命令又多又长,容易出错。 - 节点间的启动顺序、参数传递无法管理。 - 无法"一键启停"、无法复用配置。 #### 1.2 launch 能做什么 **launch 文件**用 Python 编写(ROS2 用 Python 而非 ROS1 的 XML),描述"启动什么、怎么启动、按什么顺序": ```text 启动多个节点 → Node(package=..., executable=...) 设置节点参数 → parameters=[...] 设置命名空间/重映射 → namespace=..., remappings=[...] 条件控制 → IfCondition/UnlessCondition 复用其他 launch → IncludeLaunchDescription 启动外部进程 → ExecuteProcess 事件与回调 → RegisterEventHandler(复杂场景) ``` 一句话:**launch 是 ROS2 的系统编排层**。 ### 二、launch 文件基础 #### 2.1 最简 launch 文件 ```python # launch/demo.launch.py from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node( package='py_demo', executable='py_talker', name='talker', # 覆盖节点名 output='screen', # 输出到屏幕(替代终端日志) ), Node( package='py_demo', executable='py_subscriber', name='listener', output='screen', ), ]) ``` 运行: ```bash ros2 launch py_demo demo.launch.py ``` **注意**: 1. 函数名固定为 `generate_launch_description()`。 2. `package` + `executable` 定位节点(executable 是 entry_points/CMake 里定义的命令名)。 3. `output='screen'` 把节点日志打到终端(不设置则静默)。 4. launch 文件放在包的 `launch/` 目录,并在 `setup.py`/`CMakeLists.txt` 安装。 #### 2.2 launch 文件的安装 **Python 包**(setup.py 里已加过): ```python data_files=[ ... (os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')), ] ``` **C++ 包**(CMakeLists.txt): ```cmake install(DIRECTORY launch DESTINATION share/${PROJECT_NAME}) ``` ### 三、参数与重映射 #### 3.1 传递参数 ```python # launch/params.launch.py —— 参数传递 from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node( package='py_demo', executable='configurable_sensor', name='sensor', output='screen', # 方式一:parameters 传 yaml 文件 parameters=['config/params.yaml'], ), Node( package='py_demo', executable='param_node', name='param_node', output='screen', # 方式二:直接传参数对 parameters=[{'robot_name': 'my_rover', 'max_speed': 3.0}], ), ]) ``` #### 3.2 从命令行传参(launch 参数) ```python # launch/args.launch.py —— 带命令行参数的 launch from launch import LaunchDescription from launch.actions import DeclareLaunchArgument from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node def generate_launch_description(): # 声明 launch 参数(默认值) speed_arg = DeclareLaunchArgument( 'max_speed', default_value='2.5', description='最大速度') # 用 LaunchConfiguration 读取 max_speed = LaunchConfiguration('max_speed') return LaunchDescription([ speed_arg, Node( package='py_demo', executable='param_node', name='param_node', output='screen', parameters=[{'max_speed': max_speed}], # 引用 launch 参数 ), ]) ``` 运行: ```bash # 默认值 2.5 ros2 launch py_demo args.launch.py # 自定义 5.0 ros2 launch py_demo args.launch.py max_speed:=5.0 ``` #### 3.3 话题重映射 重映射解决"同一个包换个话题名用"的场景(如双机器人,一个用 `/robot1/cmd_vel`): ```python Node( package='py_demo', executable='py_talker', name='talker_1', output='screen', remappings=[ ('chatter', 'robot1/chatter'), # 原话题 → 新话题 ], ) ``` 重映射对话题、服务、动作名都生效,是"多实例复用"的关键技术。 ### 四、命名空间 #### 4.1 设置命名空间 ```python # launch/namespace.launch.py —— 命名空间 from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ # 两个机器人,各自命名空间 Node( package='py_demo', executable='py_talker', name='talker', namespace='robot1', output='screen', ), Node( package='py_demo', executable='py_talker', name='talker', namespace='robot2', output='screen', ), ]) ``` 启动后 `ros2 node list` 显示: ```text /robot1/talker /robot2/talker ``` **同名节点 + 不同命名空间**互不冲突——这就是命名空间的价值:一套代码,多机器人实例。 #### 4.2 相对与绝对话题 设置命名空间后,话题名会跟着"前缀": ```text 代码里写 'chatter'(相对名) → 命名空间 robot1 下实际为 /robot1/chatter 代码里写 '/chatter'(绝对名,以 / 开头) → 仍是 /chatter,不受命名空间影响 ``` **实践建议**:默认用相对话题名(随命名空间走),需要跨命名空间共享的数据用绝对名。 ### 五、条件与组合 #### 5.1 条件启动 ```python # launch/conditional.launch.py —— 条件控制 from launch import LaunchDescription from launch.actions import DeclareLaunchArgument from launch.substitutions import LaunchConfiguration from launch.conditions import IfCondition, UnlessCondition from launch_ros.actions import Node def generate_launch_description(): use_sim = LaunchConfiguration('use_sim') # 'true' / 'false' return LaunchDescription([ DeclareLaunchArgument('use_sim', default_value='false'), # use_sim=true 时启动仿真节点 Node( package='gazebo_ros', executable='spawn_entity.py', condition=IfCondition(use_sim), arguments=['-entity', 'robot', '-file', 'robot.urdf'], ), # use_sim=false 时启动实体驱动 Node( package='py_demo', executable='robot_driver', condition=UnlessCondition(use_sim), output='screen', ), ]) ``` 运行: ```bash ros2 launch py_demo conditional.launch.py use_sim:=true # 仿真 ros2 launch py_demo conditional.launch.py use_sim:=false # 实体 ``` #### 5.2 包含其他 launch 文件 大型系统拆分成多个 launch 子文件,用 `IncludeLaunchDescription` 组合: ```python # launch/main.launch.py —— 包含子 launch import os from ament_index_python.packages import get_package_share_directory from launch import LaunchDescription from launch.actions import IncludeLaunchDescription from launch.launch_description_sources import PythonLaunchDescriptionSource from launch_ros.actions import Node def generate_launch_description(): py_demo_dir = get_package_share_directory('py_demo') return LaunchDescription([ # 包含同包内的 sensor.launch.py IncludeLaunchDescription( PythonLaunchDescriptionSource( os.path.join(py_demo_dir, 'launch', 'sensor.launch.py')), launch_arguments={'rate': '20.0'}.items(), # 传参给子 launch ), # 包含其他包的 launch(如 Nav2 官方 launch) IncludeLaunchDescription( PythonLaunchDescriptionSource( os.path.join( get_package_share_directory('nav2_bringup'), 'launch', 'bringup_launch.py')), ), Node( package='py_demo', executable='display_node', output='screen', ), ]) ``` **分层设计模式**:底层每个模块一个 launch(sensor.launch.py、nav.launch.py、arm.launch.py),顶层 main.launch.py 组合全部——类似"模块化 + 总装配"。 #### 5.3 组(GroupAction) 用 `GroupAction` 给一组节点统一设置条件/命名空间: ```python from launch.actions import GroupAction def generate_launch_description(): return LaunchDescription([ GroupAction( # 组内所有节点共享命名空间 scoped=True, namespace='robot1', actions=[ Node(package='py_demo', executable='py_talker', name='talker'), Node(package='py_demo', executable='py_subscriber', name='listener'), ], ), ]) ``` ### 六、外部进程与事件 #### 6.1 启动外部进程 launch 也可以启动非 ROS 命令(如打开 rviz2、启动仿真器): ```python # launch/process.launch.py from launch import LaunchDescription from launch.actions import ExecuteProcess from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node( package='py_demo', executable='sensor_node', output='screen', ), # 启动 rviz2(非 ROS 节点进程) ExecuteProcess( cmd=['rviz2'], output='screen', ), ]) ``` #### 6.2 事件处理器(高级) 用 `RegisterEventHandler` 实现"节点启动后执行动作": ```python from launch import LaunchDescription from launch.actions import RegisterEventHandler, ExecuteProcess from launch.event_handlers import OnProcessStart from launch_ros.actions import Node def generate_launch_description(): sensor = Node(package='py_demo', executable='sensor_node', output='screen') # sensor 启动后,再启动显示节点(保证顺序) start_display_after_sensor = RegisterEventHandler( OnProcessStart( target_action=sensor, on_start=[ Node(package='py_demo', executable='display_node', output='screen'), ], ) ) return LaunchDescription([ sensor, start_display_after_sensor, ]) ``` ### 七、调试 launch #### 7.1 常用命令 ```bash # 打印 launch 文件展开后的完整描述(不实际启动) ros2 launch py_demo demo.launch.py --show-args ros2 launch py_demo demo.launch.py --show-description # 正常启动(Ctrl+C 可优雅关闭所有节点) ros2 launch py_demo demo.launch.py # 打印 launch 日志(debug 级别,排查"节点为什么没启动") ros2 launch py_demo demo.launch.py --log-level debug ``` #### 7.2 常见问题排查 | 现象 | 原因 | 解决 | | --- | --- | --- | | `Package 'xxx' not found` | launch 文件没安装 | 重新 `colcon build` 并 source | | 节点启动但无输出 | 忘了 `output='screen'` | 加上 `output='screen'` | | `LaunchConfiguration` 未定义 | 未先 DeclareLaunchArgument | 先声明再引用 | | 参数没生效 | 参数名/节点名不匹配 | 检查 yaml 顶层节点名与 `name=` 一致 | | launch 报语法错误 | Python 语法或 import 缺失 | 本地 `python3 xxx.launch.py` 先测语法 | | Ctrl+C 后进程残留 | 未优雅退出 | launch 一般自动清理;残留用 `pkill -f 节点名` | #### 7.3 launch 文件写法的工程建议 1. **一个模块一个 launch**,顶层用 IncludeLaunchDescription 组合。 2. **参数全部走 launch 参数**(DeclareLaunchArgument),不硬编码。 3. **命名空间 + 重映射**支持多实例复用。 4. 用 `--show-description` 验证 launch 展开结果再实际启动。 5. 文件命名统一 `xxx.launch.py`,放 `launch/` 目录。 ### 八、实战:一键启动机器人演示系统 #### 8.1 系统组成 模拟一个小型机器人演示系统: ```text sensor_node → 发布 /odom、/scan(模拟传感器) robot_controller → 订阅 /scan,发布 /cmd_vel(简单避障逻辑) display_node → 订阅 /odom、/cmd_vel,打印状态 rviz2 → 可视化(外部进程) ``` #### 8.2 传感器节点 ```python # py_demo/sensor_node.py import rclpy, math from rclpy.node import Node from nav_msgs.msg import Odometry from sensor_msgs.msg import LaserScan class SensorNode(Node): def __init__(self): super().__init__('sensor_node') self.odom_pub = self.create_publisher(Odometry, 'odom', 50) self.scan_pub = self.create_publisher(LaserScan, 'scan', 10) self.create_timer(0.02, self.pub_odom) # 50Hz self.create_timer(0.1, self.pub_scan) # 10Hz self.t = 0.0 def pub_odom(self): self.t += 0.02 msg = Odometry() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'odom' msg.child_frame_id = 'base_link' msg.pose.pose.position.x = math.sin(self.t) msg.pose.pose.position.y = math.cos(self.t) self.odom_pub.publish(msg) def pub_scan(self): msg = LaserScan() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'laser' msg.angle_min = -3.14; msg.angle_max = 3.14 msg.angle_increment = 0.01745 msg.range_min = 0.1; msg.range_max = 10.0 msg.ranges = [1.0 + 0.1*math.sin(i*0.3) for i in range(360)] self.scan_pub.publish(msg) def main(): rclpy.init() node = SensorNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 8.3 控制器节点 ```python # py_demo/robot_controller.py —— 简单避障控制器 import rclpy from rclpy.node import Node from sensor_msgs.msg import LaserScan from geometry_msgs.msg import Twist class RobotController(Node): def __init__(self): super().__init__('robot_controller') self.cmd_pub = self.create_publisher(Twist, 'cmd_vel', 10) self.scan_sub = self.create_subscription( LaserScan, 'scan', self.on_scan, 10) self.declare_parameter('avoid_distance', 0.5) def on_scan(self, msg): # 计算前方最近障碍 n = len(msg.ranges) front = msg.ranges[n//2] if n > 0 else 10.0 avoid = self.get_parameter('avoid_distance').value cmd = Twist() if front < avoid: cmd.linear.x = 0.0 cmd.angular.z = 0.5 # 前方有障碍:原地转弯 else: cmd.linear.x = 0.3 # 安全:直行 self.cmd_pub.publish(cmd) def main(): rclpy.init() node = RobotController() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 8.4 显示节点 ```python # py_demo/display_node.py import rclpy from rclpy.node import Node from nav_msgs.msg import Odometry from geometry_msgs.msg import Twist class DisplayNode(Node): def __init__(self): super().__init__('display_node') self.odom_sub = self.create_subscription(Odometry, 'odom', self.on_odom, 50) self.cmd_sub = self.create_subscription(Twist, 'cmd_vel', self.on_cmd, 10) def on_odom(self, msg): self.get_logger().info( f'位姿: x={msg.pose.pose.position.x:.2f} y={msg.pose.pose.position.y:.2f}', throttle_duration_sec=1.0) # 每秒最多打一条,防刷屏 def on_cmd(self, msg): self.get_logger().info(f'指令: vx={msg.linear.x:.2f} wz={msg.angular.z:.2f}', throttle_duration_sec=1.0) def main(): rclpy.init() node = DisplayNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 8.5 主 launch 文件 ```python # launch/robot_demo.launch.py from launch import LaunchDescription from launch.actions import DeclareLaunchArgument, ExecuteProcess from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node def generate_launch_description(): avoid_distance = LaunchConfiguration('avoid_distance') return LaunchDescription([ DeclareLaunchArgument('avoid_distance', default_value='0.5'), Node( package='py_demo', executable='sensor_node', name='sensor_node', output='screen', ), Node( package='py_demo', executable='robot_controller', name='robot_controller', output='screen', parameters=[{'avoid_distance': avoid_distance}], ), Node( package='py_demo', executable='display_node', name='display_node', output='screen', ), ExecuteProcess(cmd=['rviz2'], output='screen'), ]) ``` #### 8.6 一键启动 ```bash # 一条命令启动整个系统 ros2 launch py_demo robot_demo.launch.py # 调整参数 ros2 launch py_demo robot_demo.launch.py avoid_distance:=1.0 # 另一个终端观察 ros2 node list # 应看到 3 个节点 ros2 topic hz /odom # 50Hz ros2 topic hz /scan # 10Hz ``` **预期结果**:一条命令拉起 3 个 ROS 节点 + rviz2;`Ctrl+C` 一次全部优雅关闭。这就是 launch 的工程价值——系统编排、一键启停、参数化复用。 ### 九、总结 本篇文章系统掌握了 launch:最简多节点启动、参数传递(yaml/字典/launch 参数)、话题重映射、命名空间、条件控制、子 launch 包含、组与外部进程、事件处理器,以及调试技巧。实战中一键启动了完整的"传感器-控制-显示"机器人演示系统。 **关键要点回顾**: 1. launch 文件 = Python 函数 `generate_launch_description()` 返回 `LaunchDescription`。 2. `Node(package, executable, name, output)` 是核心动作;别忘了 `output='screen'`。 3. 参数三方式:parameters=[yaml]、parameters=[dict]、launch 参数(DeclareLaunchArgument + LaunchConfiguration)。 4. 重映射改话题名、命名空间隔离多实例,两者是复用的关键。 5. `IfCondition`/`UnlessCondition` 条件启动;`IncludeLaunchDescription` 组合子 launch。 6. launch 文件要安装(setup.py/CMakeLists),并用 `--show-description` 调试。 ### 下一篇预告 下一篇进入 **常用工具详解**:系统掌握 `ros2` CLI 全家桶(node/topic/service/action/param/interface/pkg/daemon)、可视化工具 rqt(rqt_graph/rqt_console/rqt_tf_tree/rqt_reconfigure)、RViz2 基础使用、ros2 bag 数据录制回放,以及 tf2 工具基础。这些工具是 ROS2 开发调试的"眼睛和双手",学完你将具备独立排障能力。 --- ## ROS2系列教程:Action动作通信(带反馈长任务) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-action-dong-zuo-tong-xin-dai-fan-kui/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 10 篇 > 本文是 ROS2 系列教程的第 10 篇:Action 动作通信——带反馈的长任务。话题是单向数据流,服务是一问一答,而机器人世界里还有一类需求:**“导航到目标点”“机械臂抓取物体”“执行建图”**——它们耗时长、需要进度反馈、还可能要中途取消。这就是动作(Action)的用武之地。本篇文章讲透 Goal/Feedback/Result 三通道模型,用 Python 与 C++ 实现动作服务端与客户端,掌握 `ros2 action` 命令,并完成三种通信方式的选型总结。 ### 一、动作通信模型 #### 1.1 为什么需要 Action 用一个例子理解:让机器人"导航到厨房"。 - **话题方案**:发一个目标点话题,然后就不知道了——没有完成确认,没有进度。 - **服务方案**:调一个服务"导航到(厨房)",服务端算完才返回——调用期间完全阻塞,无法取消,也没有中间反馈。 - **动作方案**:客户端发目标 → 服务端持续回报"已走 30%、还有 5 米"→ 完成时返回最终结果 → 随时可取消。**完美契合**。 动作是为"**长任务 + 进度反馈 + 可取消**"设计的通信原语,是导航(Nav2)、机械臂(MoveIt2)等核心系统的标准接口。 #### 1.2 三通道模型 一个动作由**三个通道**组成(对应三个话题 + 一个服务): ```text 客户端 服务端 │ 1. 发送目标 Goal ──────────────────▶ 接受/拒绝目标 │ 2. ◀────────────── 持续反馈 Feedback 报告进度 │ 3. ◀────────────────── 最终结果 Result 任务完成 │ 4. 发送取消请求 Cancel ────────────▶ 终止任务 ``` | 通道 | 方向 | 内容 | 类比 | | --- | --- | --- | --- | | Goal | 客户端→服务端 | 任务目标(如目标坐标) | 下达任务 | | Feedback | 服务端→客户端 | 进度信息(持续发送) | 汇报进度 | | Result | 服务端→客户端 | 最终结果(一次) | 交付成果 | | Cancel | 客户端→服务端 | 取消请求 | 中止任务 | **实现上**:Goal 用服务传输(保证送达),Feedback/Result 用话题传输,Cancel 用服务——但用户不需要关心底层,API 已封装。 #### 1.3 动作接口定义(.action 文件) 动作接口用 `.action` 文件定义,三部分用 `---` 分隔: ```text # action/CountUntil.action —— 计数到目标值 int32 target_number # 目标(Goal):要数到几 --- int32 current_number # 结果(Result):最终数到几 --- int32 current_number # 反馈(Feedback):当前数到几 ``` 定义在接口包里(如 `my_interfaces`),构建后生成对应语言的类型。 ### 二、Python 动作服务端 #### 2.1 简单计数器动作服务端 用 ROS2 自带的 `example_interfaces/action/Fibonacci`(斐波那契数列)演示: ```python # py_pkg/py_action_server.py —— Python 动作服务端 import rclpy import time from rclpy.node import Node from rclpy.action import ActionServer from example_interfaces.action import Fibonacci class FibonacciServer(Node): def __init__(self): super().__init__('fibonacci_server') # 创建动作服务端:动作类型 + 动作名 + 三个回调 self.action_server = ActionServer( self, Fibonacci, 'fibonacci', self.execute_callback) # 执行回调(核心) self.get_logger().info('动作服务端就绪: /fibonacci') def execute_callback(self, goal_handle): # 1. 接受目标 self.get_logger().info(f'收到目标: {goal_handle.request.order}') feedback_msg = Fibonacci.Feedback() # 2. 计算斐波那契数列,持续发反馈 sequence = [0, 1] for i in range(1, goal_handle.request.order): sequence.append(sequence[i] + sequence[i-1]) feedback_msg.sequence = sequence goal_handle.publish_feedback(feedback_msg) # 发反馈 time.sleep(0.5) # 模拟耗时 # 3. 完成:返回结果 goal_handle.succeed() result = Fibonacci.Result() result.sequence = sequence return result def main(): rclpy.init() node = FibonacciServer() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **服务端三个关键回调**: 1. **execute_callback**:收到目标后执行(可同步写耗时逻辑,执行器会调度)。 2. **publish_feedback**:随时发布进度。 3. **succeed()/abort()**:标记任务成功/失败,然后返回 Result。 #### 2.2 支持取消的动作服务端 ```python # py_pkg/cancellable_server.py —— 可取消的动作服务端 import rclpy, time from rclpy.node import Node from rclpy.action import ActionServer, CancelResponse, GoalResponse from example_interfaces.action import Fibonacci class CancellableServer(Node): def __init__(self): super().__init__('cancellable_server') self.action_server = ActionServer( self, Fibonacci, 'fibonacci', execute_callback=self.execute_callback, goal_callback=self.goal_callback, # 目标接受回调 cancel_callback=self.cancel_callback, # 取消回调 ) def goal_callback(self, goal_request): # 可在此校验目标:拒绝非法目标 self.get_logger().info(f'收到目标 order={goal_request.order}') if goal_request.order <= 0: self.get_logger().warn('拒绝非法目标(order<=0)') return GoalResponse.REJECT return GoalResponse.ACCEPT def cancel_callback(self, goal_handle): self.get_logger().info('收到取消请求,同意取消') return CancelResponse.ACCEPT def execute_callback(self, goal_handle): feedback_msg = Fibonacci.Feedback() sequence = [0, 1] for i in range(1, goal_handle.request.order): # 检查是否被取消 if goal_handle.is_cancel_requested: goal_handle.canceled() # 标记取消 self.get_logger().info('任务已取消') return Fibonacci.Result() # 返回空结果 sequence.append(sequence[i] + sequence[i-1]) feedback_msg.sequence = sequence goal_handle.publish_feedback(feedback_msg) time.sleep(0.5) goal_handle.succeed() result = Fibonacci.Result() result.sequence = sequence return result def main(): rclpy.init() node = CancellableServer() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **取消处理三步**: 1. `cancel_callback` 决定是否同意取消(返回 `CancelResponse.ACCEPT`)。 2. 执行循环里用 `goal_handle.is_cancel_requested` 检查是否被请求取消。 3. 同意后调 `goal_handle.canceled()` 标记状态并返回。 ### 三、Python 动作客户端 #### 3.1 基础客户端 ```python # py_pkg/py_action_client.py —— Python 动作客户端 import rclpy from rclpy.node import Node from rclpy.action import ActionClient from example_interfaces.action import Fibonacci class FibonacciClient(Node): def __init__(self): super().__init__('fibonacci_client') # 创建动作客户端 self.action_client = ActionClient(self, Fibonacci, 'fibonacci') def send_goal(self, order): # 1. 等待服务端 self.action_client.wait_for_server() # 2. 构造目标 goal_msg = Fibonacci.Goal() goal_msg.order = order self.get_logger().info(f'发送目标: order={order}') # 3. 发送目标(异步),注册反馈回调 self.send_goal_future = self.action_client.send_goal_async( goal_msg, feedback_callback=self.feedback_callback) # 4. 目标被接受后,再请求结果 self.send_goal_future.add_done_callback(self.goal_response_callback) def feedback_callback(self, feedback_msg): # 进度反馈(服务端 publish_feedback 时触发) fb = feedback_msg.feedback self.get_logger().info(f'进度: {list(fb.sequence)}') def goal_response_callback(self, future): goal_handle = future.result() if not goal_handle.accepted: self.get_logger().error('目标被拒绝') return self.get_logger().info('目标已接受,等待结果...') # 5. 请求结果(异步) self.get_result_future = goal_handle.get_result_async() self.get_result_future.add_done_callback(self.result_callback) def result_callback(self, future): result = future.result().result self.get_logger().info(f'最终结果: {list(result.sequence)}') # 关闭节点 rclpy.shutdown() def main(): rclpy.init() node = FibonacciClient() node.send_goal(10) # 数到 10 rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **客户端四步流程**: 1. `send_goal_async(目标, feedback_callback)` 发送目标。 2. 目标响应回调里检查 `accepted`(被接受/拒绝)。 3. 接受后 `get_result_async()` 请求最终结果。 4. `result_callback` 拿到最终结果。 #### 3.2 可取消客户端 ```python # py_pkg/cancellable_client.py —— 可取消客户端 import rclpy from rclpy.node import Node from rclpy.action import ActionClient from example_interfaces.action import Fibonacci import threading, time class CancellableClient(Node): def __init__(self): super().__init__('cancellable_client') self.action_client = ActionClient(self, Fibonacci, 'fibonacci') self.goal_handle = None def send_goal(self, order): self.action_client.wait_for_server() goal_msg = Fibonacci.Goal() goal_msg.order = order future = self.action_client.send_goal_async( goal_msg, feedback_callback=self.feedback_callback) future.add_done_callback(self.on_goal_response) def feedback_callback(self, feedback_msg): self.get_logger().info(f'进度: {list(feedback_msg.feedback.sequence)}') def on_goal_response(self, future): self.goal_handle = future.result() if not self.goal_handle.accepted: self.get_logger().error('目标被拒绝') return self.get_logger().info('目标已接受,2 秒后取消...') # 2 秒后发起取消 timer = threading.Timer(2.0, self.cancel_goal) timer.start() def cancel_goal(self): self.get_logger().info('发送取消请求...') cancel_future = self.goal_handle.cancel_goal_async() cancel_future.add_done_callback(self.on_cancel_done) def on_cancel_done(self, future): cancel_response = future.result() if len(cancel_response.goals_canceling) > 0: self.get_logger().info('取消成功') else: self.get_logger().warn('取消未生效') def main(): rclpy.init() node = CancellableClient() node.send_goal(10) rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` ### 四、C++ 动作服务端与客户端 #### 4.1 C++ 动作服务端 ```cpp // cpp_pkg/src/action_server.cpp #include "rclcpp/rclcpp.hpp" #include "rclcpp_action/rclcpp_action.hpp" #include "example_interfaces/action/fibonacci.hpp" #include #include using Fibonacci = example_interfaces::action::Fibonacci; using GoalHandleFibonacci = rclcpp_action::ServerGoalHandle; class FibonacciServer : public rclcpp::Node { public: FibonacciServer() : Node("fibonacci_server_cpp") { action_server_ = rclcpp_action::create_server( this, "fibonacci", std::bind(&FibonacciServer::handle_goal, this, std::placeholders::_1, std::placeholders::_2), std::bind(&FibonacciServer::handle_cancel, this, std::placeholders::_1), std::bind(&FibonacciServer::handle_accepted, this, std::placeholders::_1)); RCLCPP_INFO(this->get_logger(), "动作服务端就绪"); } private: // 目标回调:校验并决定接受/拒绝 rclcpp_action::GoalResponse handle_goal( const rclcpp_action::GoalUUID& uuid, std::shared_ptr goal) { RCLCPP_INFO(this->get_logger(), "收到目标 order=%d", goal->order); if (goal->order <= 0) return rclcpp_action::GoalResponse::REJECT; return rclcpp_action::GoalResponse::ACCEPT; } // 取消回调 rclcpp_action::CancelResponse handle_cancel( const std::shared_ptr goal_handle) { RCLCPP_INFO(this->get_logger(), "收到取消请求"); return rclcpp_action::CancelResponse::ACCEPT; } // 接受后:开线程执行(不能阻塞回调) void handle_accepted(const std::shared_ptr goal_handle) { std::thread(std::bind(&FibonacciServer::execute, this, goal_handle)).detach(); } // 执行逻辑 void execute(const std::shared_ptr goal_handle) { auto feedback = std::make_shared(); auto result = std::make_shared(); std::vector seq{0, 1}; for (int i = 1; i < goal_handle->get_goal()->order; i++) { seq.push_back(seq[i] + seq[i-1]); feedback->sequence = seq; goal_handle->publish_feedback(feedback); std::this_thread::sleep_for(std::chrono::milliseconds(500)); } goal_handle->succeed(); result->sequence = seq; goal_handle->publish_result(result); } rclcpp_action::Server::SharedPtr action_server_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` **C++ 关键差异**:`handle_accepted` 里必须**开新线程**执行任务(不能阻塞执行器回调线程);三个回调分别对应 Goal 接受、取消、执行。 #### 4.2 C++ 动作客户端 ```cpp // cpp_pkg/src/action_client.cpp #include "rclcpp/rclcpp.hpp" #include "rclcpp_action/rclcpp_action.hpp" #include "example_interfaces/action/fibonacci.hpp" using Fibonacci = example_interfaces::action::Fibonacci; class FibonacciClient : public rclcpp::Node { public: FibonacciClient() : Node("fibonacci_client_cpp") { client_ = rclcpp_action::create_client(this, "fibonacci"); } void send_goal(int order) { // 1. 等服务端 if (!client_->wait_for_action_server()) { RCLCPP_ERROR(this->get_logger(), "动作服务端不可用"); return; } // 2. 构造目标 auto goal = Fibonacci::Goal(); goal.order = order; // 3. 发送目标(异步),注册反馈与响应回调 auto send_goal_options = rclcpp_action::Client::SendGoalOptions(); send_goal_options.goal_response_callback = std::bind(&FibonacciClient::goal_response_callback, this, std::placeholders::_1); send_goal_options.feedback_callback = std::bind(&FibonacciClient::feedback_callback, this, std::placeholders::_1, std::placeholders::_2); send_goal_options.result_callback = std::bind(&FibonacciClient::result_callback, this, std::placeholders::_1); client_->async_send_goal(goal, send_goal_options); } private: void goal_response_callback(const GoalHandleFibonacci::SharedPtr& goal_handle) { if (!goal_handle) { RCLCPP_ERROR(this->get_logger(), "目标被拒绝"); } else { RCLCPP_INFO(this->get_logger(), "目标已接受"); } } void feedback_callback(GoalHandleFibonacci::SharedPtr, const std::shared_ptr feedback) { RCLCPP_INFO(this->get_logger(), "进度: %zu 项", feedback->sequence.size()); } void result_callback(const GoalHandleFibonacci::WrappedResult& result) { if (result.code == rclcpp_action::ResultCode::SUCCEEDED) { RCLCPP_INFO(this->get_logger(), "任务成功"); } } rclcpp_action::Client::SharedPtr client_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); auto node = std::make_shared(); node->send_goal(10); rclcpp::spin(node); rclcpp::shutdown(); return 0; } ``` ### 五、自定义动作接口实战 #### 5.1 定义动作接口 在 `my_interfaces` 包加 `action/NavigateTo.action`: ```text # action/NavigateTo.action float32 x # 目标 x 坐标 float32 y # 目标 y 坐标 --- bool reached # 是否到达 float32 final_distance # 最终距离 --- float32 distance_left # 剩余距离 float32 speed # 当前速度 ``` 修改 `CMakeLists.txt`: ```cmake set(action_files "action/NavigateTo.action" ) rosidl_generate_interfaces(${PROJECT_NAME} ${msg_files} ${srv_files} ${action_files} # 加入动作文件 DEPENDENCIES builtin_interfaces ) ``` 重建并验证: ```bash colcon build --packages-select my_interfaces source install/setup.bash ros2 interface show my_interfaces/action/NavigateTo ``` #### 5.2 导航动作服务端(模拟) ```python # py_pkg/nav_action_server.py —— 模拟导航动作 import rclpy, math, time from rclpy.node import Node from rclpy.action import ActionServer from my_interfaces.action import NavigateTo class NavServer(Node): def __init__(self): super().__init__('nav_server') self.action_server = ActionServer( self, NavigateTo, 'navigate_to', self.execute_callback) self.get_logger().info('导航动作服务端就绪') def execute_callback(self, goal_handle): goal = goal_handle.request self.get_logger().info(f'导航到 ({goal.x}, {goal.y})') feedback = NavigateTo.Feedback() # 模拟:机器人从 (0,0) 以 1m/s 直线运动 total = math.sqrt(goal.x**2 + goal.y**2) distance_left = total while distance_left > 0.05: distance_left -= 0.2 feedback.distance_left = max(distance_left, 0.0) feedback.speed = 1.0 goal_handle.publish_feedback(feedback) time.sleep(0.2) goal_handle.succeed() result = NavigateTo.Result() result.reached = True result.final_distance = 0.0 return result def main(): rclpy.init() node = NavServer() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 5.3 命令行调用动作 ```bash # 查看动作列表 ros2 action list ros2 action list -t # 查看动作类型信息 ros2 action info /navigate_to # 发送动作目标(命令行) ros2 action send_goal /navigate_to my_interfaces/action/NavigateTo \ "{x: 5.0, y: 3.0}" # 带反馈显示 ros2 action send_goal /navigate_to my_interfaces/action/NavigateTo \ "{x: 5.0, y: 3.0}" --feedback # 显示动作接口 ros2 interface show my_interfaces/action/NavigateTo ``` **注意**:命令行 `ros2 action send_goal` 会阻塞等待结果;`--feedback` 显示实时反馈;Ctrl+C 可取消。 ### 六、三种通信方式选型总结 | 维度 | 话题 Topic | 服务 Service | 动作 Action | | --- | --- | --- | --- | | 通信模式 | 单向流 | 请求-应答 | 目标-反馈-结果 | | 是否阻塞 | 否 | 客户端等待 | 客户端异步 | | 进度反馈 | 无 | 无 | **有** | | 取消支持 | 不适用 | 无 | **有** | | 适用任务 | 持续数据 | 即时问答 | **长任务** | | 典型场景 | 传感器、指令 | 查询、设置 | 导航、抓取、建图 | | 接口定义 | .msg | .srv | .action(3段) | | 底层实现 | 话题 | 服务 | 话题+服务组合 | **选型口诀**: ```text 持续流动 → 话题 一问一答 → 服务 长任务+进度+可取消 → 动作 ``` **动作的使用误区**: 1. 别用动作做毫秒级操作(开销大,杀鸡用牛刀)。 2. 别在 execute_callback 里做无限循环(要有退出条件与取消检查)。 3. 客户端别忘记注册 feedback_callback 和 result_callback。 ### 七、总结 本篇文章完整覆盖了动作通信:理解了三通道模型与适用场景、动作接口定义(.action 三段式),用 Python 与 C++ 实现了动作服务端(含可取消版本)与客户端(含取消流程),实战了自定义导航动作,掌握了 `ros2 action` 命令行工具,并完成了话题/服务/动作三大通信方式的选型总结。 **关键要点回顾**: 1. 动作 = Goal(目标)+ Feedback(反馈)+ Result(结果)+ Cancel(取消)。 2. `.action` 文件三段式定义接口,放在接口包的 `action/` 目录。 3. 服务端核心:execute_callback + publish_feedback + succeed()/canceled()。 4. 取消三步:cancel_callback 同意 → is_cancel_requested 检查 → canceled() 标记。 5. 客户端四步:send_goal_async → goal_response_callback → get_result_async → result_callback。 6. C++ 服务端要在 handle_accepted 里开线程执行任务,避免阻塞执行器。 7. 长任务带反馈可取消用动作;持续数据用话题;即时问答用服务。 ### 下一篇预告 下一篇进入 **launch 文件与多节点启动**:手动开几十个终端运行节点太原始了。本篇文章讲透 launch——用 Python 编写 launch 文件启动多节点、传递参数、设置条件与复用、包含其他 launch 文件,掌握 `ros2 launch` 命令与 launch 调试技巧。学完你就能一键启动整个机器人系统。 --- ## ROS2系列教程:参数Parameter与QoS策略 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-can-shu-parameter-yu-qos-ce-l-e/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 9 篇 > 本文是 ROS2 系列教程的第 9 篇:参数 Parameter 与 QoS 策略。参数与 QoS 是 ROS2 工程化配置的两大基石:参数让"同一份代码适配不同场景",QoS 让"通信的可靠性、实时性、历史策略可控"。前半篇系统讲透参数(声明/读取/修改/回调/命令行/yaml 加载),后半篇深入 QoS(可靠性/历史/深度/期限/兼容性)。学完你就能写出可配置、可调优的 ROS2 应用。 ### 第一部分:参数 Parameter ### 一、参数系统总览 #### 1.1 什么是参数 **参数(Parameter)** 是节点的配置项:一个带类型的键值对,运行时可查询、可修改。参数让"代码与配置分离"——同样的节点,通过不同参数启动,就能适配不同机器人、不同环境。 参数四要素: ```text 参数名(parameter name):max_speed 参数值(value):2.5 参数类型(type):DOUBLE 所属节点(node):/robot_controller ``` **参数 vs 变量**:变量是代码内部的,参数是节点对外暴露的、可被命令行/其他节点访问的配置接口。 #### 1.2 参数类型 ROS2 支持六种参数类型: | 类型 | Python 对应 | C++ 对应 | 示例值 | | --- | --- | --- | --- | | BOOL | bool | bool | `true` | | INTEGER | int | int64_t | `42` | | DOUBLE | float | double | `3.14` | | STRING | str | std::string | `"hello"` | | BYTE_ARRAY | bytes | std::vector | `[1,2,3]` | | BOOL/INT/DOUBLE/STRING 数组 | list | std::vector | `[1,2,3]` | ### 二、参数的基本使用 #### 2.1 声明参数(Python) ```python # param_declare.py —— 声明参数 import rclpy from rclpy.node import Node class ParamNode(Node): def __init__(self): super().__init__('param_node') # 声明参数:名称 + 默认值 + 描述(描述需 Humble+) self.declare_parameter('robot_name', 'turtle_01', '机器人名称') self.declare_parameter('max_speed', 2.5, '最大线速度') self.declare_parameter('enable_safety', True, '是否启用安全功能') self.declare_parameter('sensor_ids', [1, 2, 3], '传感器ID列表') # 读取参数(返回 Parameter 对象,用 .value 取原始值) self.robot_name = self.get_parameter('robot_name').value self.max_speed = self.get_parameter('max_speed').value self.enable_safety = self.get_parameter('enable_safety').value self.sensor_ids = self.get_parameter('sensor_ids').value self.get_logger().info( f'机器人={self.robot_name} 速度={self.max_speed} ' f'安全={self.enable_safety} 传感器={self.sensor_ids}') def main(): rclpy.init() node = ParamNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **注意**:**未声明就读取会抛异常**(`ParameterNotDeclaredException`)。ROS2 要求先 `declare_parameter` 再 `get_parameter`。 #### 2.2 声明参数(C++) ```cpp // cpp_pkg/src/param_node.cpp #include "rclcpp/rclcpp.hpp" class ParamNode : public rclcpp::Node { public: ParamNode() : Node("param_node_cpp") { // 声明参数 this->declare_parameter("robot_name", "turtle_01"); this->declare_parameter("max_speed", 2.5); this->declare_parameter("enable_safety", true); this->declare_parameter("sensor_ids", std::vector{1, 2, 3}); // 读取参数(按类型取) auto name = this->get_parameter("robot_name").as_string(); auto speed = this->get_parameter("max_speed").as_double(); auto safety = this->get_parameter("enable_safety").as_bool(); auto ids = this->get_parameter("sensor_ids").as_integer_array(); RCLCPP_INFO(this->get_logger(), "机器人=%s 速度=%.2f 安全=%d", name.c_str(), speed, safety); } }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` C++ 读取后要用 `as_string()`、`as_double()`、`as_bool()`、`as_integer_array()` 等方法显式转类型;类型不匹配会抛异常。 ### 三、参数的命令行操作 #### 3.1 启动时设置参数 ```bash # 用 -p 传参数(可多个) ros2 run py_pkg param_node \ --ros-args -p robot_name:=my_rover -p max_speed:=3.0 # 从 yaml 文件加载参数 ros2 run py_pkg param_node --ros-args --params-file config/params.yaml ``` #### 3.2 运行时查看与修改 ```bash # 列出节点的所有参数 ros2 param list /param_node # 获取单个参数 ros2 param get /param_node max_speed # 运行时修改参数(立即生效,如果节点注册了回调) ros2 param set /param_node max_speed 4.0 # 查看参数描述(含类型) ros2 param describe /param_node max_speed # 删除参数(仅未声明为必须的) ros2 param delete /param_node temp_param ``` **注意**:`ros2 param set` 修改的参数**不会**写回 yaml 文件,重启节点后恢复默认值。 #### 3.3 查看所有节点的参数 ```bash # 列出所有节点及其参数(全局视图) ros2 param list ``` ### 四、参数回调(参数变更监听) #### 4.1 为什么需要参数回调 运行时 `ros2 param set` 修改参数后,如果节点不监听变化,新值不会生效。**参数回调**在参数被修改时触发,让节点动态响应配置变化(如在线调 PID 增益、切换模式)。 #### 4.2 Python 参数回调 ```python # param_callback.py —— 参数变更回调 import rclpy from rclpy.node import Node class ParamCallbackNode(Node): def __init__(self): super().__init__('param_callback_node') self.declare_parameter('max_speed', 2.5) # 注册参数回调:返回 SetParametersResult self.add_on_set_parameters_callback(self.on_param_change) self.current_speed = self.get_parameter('max_speed').value self.get_logger().info(f'初始速度: {self.current_speed}') def on_param_change(self, params): for param in params: if param.name == 'max_speed': if param.value > 10.0: self.get_logger().warn(f'速度 {param.value} 过大,拒绝!') return rclpy.parameter.SetParametersResult(successful=False) self.current_speed = param.value self.get_logger().info(f'速度已更新为: {self.current_speed}') return rclpy.parameter.SetParametersResult(successful=True) def main(): rclpy.init() node = ParamCallbackNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **回调返回值决定成败**:返回 `successful=False` 会**拒绝**该次参数修改(命令行会报错)。这是参数校验的官方机制——非法值在这里拦截。 #### 4.3 验证参数回调 ```bash # 运行节点 ros2 run py_pkg param_callback_node # 合法修改 → 成功 ros2 param set /param_callback_node max_speed 5.0 # 非法修改 → 被拒绝 ros2 param set /param_callback_node max_speed 20.0 # Set parameter failed: ... (回调返回 False) ``` #### 4.4 C++ 参数回调 ```cpp // cpp_pkg/src/param_callback.cpp #include "rclcpp/rclcpp.hpp" class ParamCallbackNode : public rclcpp::Node { public: ParamCallbackNode() : Node("param_callback_cpp") { this->declare_parameter("max_speed", 2.5); // 注册回调 auto cb = [this](const std::vector& params) { for (const auto& p : params) { if (p.get_name() == "max_speed") { if (p.as_double() > 10.0) { RCLCPP_WARN(this->get_logger(), "速度过大,拒绝"); return rcl_interfaces::msg::SetParametersResult{false, "too large"}; } RCLCPP_INFO(this->get_logger(), "速度更新为: %.2f", p.as_double()); } } return rcl_interfaces::msg::SetParametersResult{true, ""}; }; this->set_on_parameters_set_callback(cb); } }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` ### 五、YAML 参数文件 #### 5.1 参数文件格式 YAML 参数文件按节点组织: ```yaml # config/params.yaml robot_controller: # 节点名(不带命名空间) ros__parameters: robot_name: "turtle_01" max_speed: 2.5 enable_safety: true sensor_ids: [1, 2, 3] pid_gains: p: 1.0 i: 0.1 d: 0.01 ``` **注意**:节点名要与 `create_node` 的名字**完全一致**(含命名空间时用 `/ns/node` 写法)。顶层是节点名,下一层是 `ros__parameters`。 #### 5.2 加载参数文件 ```bash # 启动时加载 ros2 run py_pkg param_node --ros-args --params-file config/params.yaml # launch 文件里加载(第 11 篇详讲) ``` #### 5.3 导出当前参数为 yaml ```bash # 把节点当前参数导出成 yaml 文件(备份/分享配置) ros2 param dump /param_node > config/dump.yaml ``` #### 5.4 嵌套参数(字典) 嵌套参数用于组织复杂配置(如 PID 增益组): ```python # Python 读取嵌套参数 p_gain = self.get_parameter('pid_gains.p').value # 点号访问 i_gain = self.get_parameter('pid_gains.i').value ``` ### 第二部分:QoS 策略 ### 六、QoS 是什么 #### 6.1 质量服务(Quality of Service) **QoS(服务质量)** 决定 ROS2 通信的**可靠性、实时性与历史策略**。DDS 之所以强大,就是因为它把"传输策略"开放给应用层配置。 QoS 的核心策略(Humble 常用): | 策略 | 取值 | 含义 | | --- | --- | --- | | Reliability | RELIABLE / BEST_EFFORT | 可靠(重传保证送达)/ 尽力(快但可能丢) | | History | KEEP_LAST / KEEP_ALL | 保留最近 N 条 / 保留全部 | | Depth | 整数 | KEEP_LAST 的队列深度 | | Durability | VOLATILE / TRANSIENT_LOCAL | 只收新消息 / 也收历史消息(晚加入的订阅者) | | Deadline | 时长 | 最大允许消息间隔(超时告警) | | Liveliness | AUTOMATIC / MANUAL | 节点存活检测 | #### 6.2 三种预置 QoS 档位 ```python from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy from rclpy.qos import qos_profile_sensor_data, qos_profile_system_default # 1. 传感器数据档(BEST_EFFORT + KEEP_LAST(5)):图像/雷达常用 # 丢几帧没关系,要的是低延迟 qos_profile_sensor_data # 2. 默认档(RELIABLE + KEEP_LAST(10)):控制指令等关键数据 # 保证送达,延迟可接受 # 3. 系统默认档:节点默认行为 qos_profile_system_default ``` ### 七、QoS 配置实战 #### 7.1 自定义 QoS(Python) ```python # qos_demo.py —— 自定义 QoS import rclpy from rclpy.node import Node from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy, DurabilityPolicy from std_msgs.msg import String class QosNode(Node): def __init__(self): super().__init__('qos_node') # 自定义 QoS:可靠 + 保留最近 20 条 + 瞬态本地 qos = QoSProfile( reliability=ReliabilityPolicy.RELIABLE, history=HistoryPolicy.KEEP_LAST, depth=20, durability=DurabilityPolicy.TRANSIENT_LOCAL, ) self.pub = self.create_publisher(String, 'qos_topic', qos) self.sub = self.create_subscription(String, 'qos_topic', self.on_msg, qos) self.timer = self.create_timer(1.0, self.tick) def tick(self): msg = String(); msg.data = 'qos test' self.pub.publish(msg) def on_msg(self, msg): self.get_logger().info(f'收到: {msg.data}') def main(): rclpy.init() node = QosNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 7.2 传感器数据专用 QoS(C++) ```cpp // cpp_pkg/src/qos_sensor.cpp —— 传感器 QoS 订阅 #include "rclcpp/rclcpp.hpp" #include "sensor_msgs/msg/laser_scan.hpp" class ScanSubscriber : public rclcpp::Node { public: ScanSubscriber() : Node("scan_subscriber") { // BEST_EFFORT:激光雷达丢帧可接受,要低延迟 auto qos = rclcpp::QoS(rclcpp::KeepLast(5)) .best_effort() .durability_volatile(); sub_ = this->create_subscription( "scan", qos, std::bind(&ScanSubscriber::on_scan, this, std::placeholders::_1)); } private: void on_scan(const sensor_msgs::msg::LaserScan::SharedPtr msg) { RCLCPP_INFO(this->get_logger(), "收到 %zu 个扫描点", msg->ranges.size()); } rclcpp::Subscription::SharedPtr sub_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` #### 7.3 QoS 兼容性规则(重要!) **发布端与订阅端的 QoS 必须兼容才能通信**。核心规则: | 发布端 \ 订阅端 | RELIABLE | BEST_EFFORT | | --- | --- | --- | | RELIABLE | ✅ 兼容 | ✅ 兼容(订阅端降级) | | BEST_EFFORT | ❌ 不兼容 | ✅ 兼容 | ```text 规则 1:Reliability 要求发布端 >= 订阅端(RELIABLE 发,BEST_EFFORT 收 OK;反向不 OK) 规则 2:Depth 等其余策略取双方协商值(取更宽松) 规则 3:Durability TRANSIENT_LOCAL 的发布者,晚加入订阅者也能收到历史消息 ``` **经典踩坑**:雷达发布端用 BEST_EFFORT,你的订阅端用默认 RELIABLE → **收不到数据**!这正是 `ros2 topic echo /scan` 能收、自己代码收不到的常见原因。 #### 7.4 查看与调试 QoS ```bash # 查看话题的完整 QoS 信息(含发布/订阅端各自的设置) ros2 topic info /scan --verbose # 用命令行按指定 QoS 订阅测试 ros2 topic echo /scan --qos-reliability best_effort ``` ### 八、实战:可配置传感器节点 #### 8.1 设计目标 综合参数 + QoS:一个可配置的传感器发布器,参数控制频率/名称/队列深度,QoS 策略可选。 ```python # py_pkg/configurable_sensor.py import rclpy from rclpy.node import Node from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy from sensor_msgs.msg import LaserScan class ConfigurableSensor(Node): def __init__(self): super().__init__('configurable_sensor') # 参数:频率、名称、QoS 可靠性、队列深度 self.declare_parameter('rate', 10.0) self.declare_parameter('frame_id', 'laser') self.declare_parameter('reliability', 'best_effort') # or 'reliable' self.declare_parameter('queue_depth', 10) rate = self.get_parameter('rate').value frame = self.get_parameter('frame_id').value rel = self.get_parameter('reliability').value depth = self.get_parameter('queue_depth').value # 按参数构建 QoS qos = QoSProfile( reliability=ReliabilityPolicy.BEST_EFFORT if rel == 'best_effort' else ReliabilityPolicy.RELIABLE, history=HistoryPolicy.KEEP_LAST, depth=depth, ) self.pub = self.create_publisher(LaserScan, 'scan', qos) self.timer = self.create_timer(1.0 / rate, self.publish_scan) self.frame_id = frame self.get_logger().info( f'传感器启动: rate={rate}Hz frame={frame} qos={rel} depth={depth}') def publish_scan(self): msg = LaserScan() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = self.frame_id msg.angle_min = -3.14; msg.angle_max = 3.14 msg.angle_increment = 0.01745 msg.range_min = 0.1; msg.range_max = 10.0 msg.ranges = [1.0] * 360 self.pub.publish(msg) def main(): rclpy.init() node = ConfigurableSensor() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 8.2 不同配置启动 ```bash # 默认(10Hz, best_effort) ros2 run py_pkg configurable_sensor # 自定义:20Hz + reliable + 队列 50 ros2 run py_pkg configurable_sensor \ --ros-args \ -p rate:=20.0 \ -p reliability:=reliable \ -p queue_depth:=50 # 查看实际 QoS ros2 topic info /scan --verbose ``` #### 8.3 配置与代码分离的价值 同一个节点二进制,通过参数就能适配:低速低成本场景(5Hz)、高速场景(50Hz)、可靠性敏感场景(RELIABLE)。**发布部署时无需改代码,只需改配置**——这就是参数系统的工程价值。 ### 九、常见问题排查 | 现象 | 原因 | 解决 | | --- | --- | --- | | `ParameterNotDeclaredException` | 未声明就读取 | 先 `declare_parameter` | | `ros2 param set` 无效 | 没注册参数回调 | `add_on_set_parameters_callback` | | 收不到话题数据 | QoS 不兼容(最常见!) | `ros2 topic info --verbose` 对比双方 QoS | | BEST_EFFORT 发 RELIABLE 收 | 发布端尽力、订阅端可靠 | 订阅端改用 best_effort | | 晚加入收不到历史消息 | Durability 为 VOLATILE | 发布端用 TRANSIENT_LOCAL | | 队列丢消息 | Depth 太小 | 增大 KEEP_LAST 深度 | ### 十、总结 本篇文章系统覆盖了两大工程化主题:参数(声明/读取/回调/命令行/yaml/嵌套)与 QoS(四大策略/预置档位/兼容规则/调试)。实战中的可配置传感器节点展示了"配置与代码分离"的完整范式。 **关键要点回顾**: 1. 参数先声明后读取;`declare_parameter(名称, 默认值)` 是标准姿势。 2. 参数回调返回 `SetParametersResult(successful=False)` 可拒绝非法修改。 3. `--ros-args -p 名:=值` 启动时传参;`--params-file` 加载 yaml;`ros2 param dump` 导出。 4. QoS 四大策略:Reliability(可靠/尽力)、History(保留方式)、Depth(深度)、Durability(持久性)。 5. 兼容性核心规则:发布端 Reliability 必须 >= 订阅端;RELIABLE 发可配 BEST_EFFORT 收,反向不行。 6. 收不到数据第一排查项:`ros2 topic info /话题 --verbose` 看 QoS 是否匹配。 ### 下一篇预告 下一篇进入 **Action 动作通信**:话题是单向流、服务是一问一答,而**动作(Action)**解决"长任务 + 进度反馈 + 可取消"——导航到目标点、机械臂抓取这类任务的标准范式。我们将理解 Goal/Feedback/Result 三通道模型,用 Python 与 C++ 实现动作服务端与客户端,掌握 `ros2 action` 命令,并对比三种通信方式的选型。 --- ## ROS2系列教程:服务Service通信(下)同步异步与回调组 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-fu-wu-service-tong-xin-xia-tong-bu/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 8 篇 > 本文是 ROS2 系列教程的第 8 篇:服务 Service 通信(下)——同步异步与回调组。上一篇掌握了服务的基本收发,本篇文章深入工程的"深水区":同步与异步的底层机制与性能差异、多请求并发处理、回调组(CallbackGroup)的两种类型、超时重试策略、服务端多线程执行器,以及服务与 QoS 的关系。学完你能设计出稳健可靠的服务架构。 ### 一、同步与异步:底层机制对比 #### 1.1 概念回顾 - **同步调用**:发起请求后,当前执行流**等待**响应返回才继续。 - **异步调用**:发起请求后立即返回,响应到达时通过**回调**通知。 ROS2 里两种"同步"的实现都基于异步 API + 事件循环,理解这点很重要。 #### 1.2 Python 的 spin_until_future_complete 本质 Python 的"同步调用"其实是**伪同步**: ```python future = self.client.call_async(request) # 立即返回,不等待 rclpy.spin_until_future_complete(self, future) # 原地驱动事件循环直到 future 完成 ``` `spin_until_future_complete` 做的事情:在**当前线程**里不断处理事件循环(包括处理响应回调),直到 future 有结果或超时。所以: - 它**不是**阻塞等待,而是"边等边处理事件"。 - 等待期间,该节点的其他回调(定时器、话题)**也会被处理**。 - 超时后返回 `Timeout`,需要自己判断结果。 #### 1.3 C++ 的 spin_until_future_complete C++ 同理: ```cpp auto future = client_->async_send_request(request); auto status = rclcpp::spin_until_future_complete( this->get_node_base_interface(), future, std::chrono::seconds(5)); // FutureReturnCode: SUCCESS / TIMEOUT / INTERRUPTED if (status == rclcpp::FutureReturnCode::SUCCESS) { auto response = future.get(); } ``` **注意**:`spin_until_future_complete` 要求传入的是 `get_node_base_interface()`(节点的接口指针),不是节点本身。第三个参数可设超时(默认无限等待)。 #### 1.4 真正的异步:回调模式 异步调用注册完成回调,响应到达时自动执行: ```python # 异步调用(Python) future = self.client.call_async(request) future.add_done_callback(self.on_response) # 响应到达时调用 ``` ```cpp // 异步调用(C++) auto future = client_->async_send_request(request); // 用 std::bind 绑定回调,future 传入 std::thread([](rclcpp::Client<...>::SharedFuture f){ auto resp = f.get(); // 处理响应 }, future).detach(); ``` **性能对比**: | 方式 | 等待期间做其他事 | 并发请求 | 复杂度 | 适用场景 | | --- | --- | --- | --- | --- | | 同步 | 否(伪同步也在转事件) | 单 | 低 | 简单查询、初始化检查 | | 异步 | 是 | 多 | 中 | 高频调用、多服务并发 | | 回调组+多线程 | 是 | 多 | 高 | 复杂系统架构 | ### 二、并发处理:多请求与多服务端 #### 2.1 一个客户端多个并发请求 ```python # concurrent_client.py —— 并发请求演示 import rclpy from rclpy.node import Node from std_srvs.srv import Trigger import time class ConcurrentClient(Node): def __init__(self): super().__init__('concurrent_client') self.client = self.create_client(Trigger, 'ping_service') self.pending = 0 def fire_all(self): if not self.client.wait_for_service(timeout_sec=1.0): self.get_logger().error('服务不可用') return # 同时发出 5 个请求(互不阻塞) for i in range(5): request = Trigger.Request() future = self.client.call_async(request) future.add_done_callback(self.on_done) self.pending += 1 self.get_logger().info(f'已发出 {self.pending} 个并发请求') def on_done(self, future): self.pending -= 1 r = future.result() self.get_logger().info(f'收到响应: {r.message},剩余 {self.pending}') def main(): rclpy.init() node = ConcurrentClient() node.fire_all() # 事件循环驱动所有 future 完成 while node.pending > 0: rclpy.spin_once(node, timeout_sec=0.1) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **要点**:`call_async` 可连续多次调用,每个请求独立 future;用 `add_done_callback` 逐个处理。**单线程执行器下回调仍串行**,但"等待"不阻塞。 #### 2.2 服务端并发:默认串行 默认情况下,服务端回调**一次只处理一个请求**(同一回调组内串行)。如果服务端处理很慢,后来的请求会排队。验证方式: ```python # slow_server.py —— 慢服务端(模拟耗时 2 秒) import rclpy, time from rclpy.node import Node from std_srvs.srv import Trigger class SlowServer(Node): def __init__(self): super().__init__('slow_server') self.srv = self.create_service(Trigger, 'slow_service', self.handle) def handle(self, request, response): self.get_logger().info('开始处理(耗时 2s)...') time.sleep(2) # 模拟耗时(实际不要用 sleep 阻塞回调!) response.success = True response.message = 'done' self.get_logger().info('处理完成') return response def main(): rclpy.init() node = SlowServer() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 客户端同时发 3 个请求:默认情况下它们**依次**被处理(总耗时 6 秒)。要让服务端并发处理,需要**回调组 + 多线程执行器**(见下一节)。 #### 2.3 回调组(CallbackGroup) 回调组决定回调的执行策略,是 ROS2 并发的关键机制: - **MutuallyExclusiveCallbackGroup(互斥,默认)**:组内回调**互斥串行**,同一时刻只执行一个。 - **ReentrantCallbackGroup(可重入)**:组内回调**可以并发**执行(多线程执行器下)。 ```python # callback_group_demo.py —— 回调组演示 import rclpy, time from rclpy.node import Node from rclpy.callback_groups import ReentrantCallbackGroup, MutuallyExclusiveCallbackGroup from rclpy.executors import MultiThreadedExecutor from std_srvs.srv import Trigger class CallbackGroupNode(Node): def __init__(self): super().__init__('callback_group_node') # 两个服务,一个用互斥组,一个用可重入组 self.mutex_group = MutuallyExclusiveCallbackGroup() self.reentrant_group = ReentrantCallbackGroup() self.srv_a = self.create_service( Trigger, 'service_a', self.handle_a, callback_group=self.mutex_group) self.srv_b = self.create_service( Trigger, 'service_b', self.handle_b, callback_group=self.reentrant_group) def handle_a(self, request, response): self.get_logger().info('A 开始处理') time.sleep(2) response.success = True; response.message = 'a done' return response def handle_b(self, request, response): self.get_logger().info('B 开始处理') time.sleep(2) response.success = True; response.message = 'b done' return response def main(): rclpy.init() node = CallbackGroupNode() # 必须用多线程执行器,可重入组才能真正并发 executor = MultiThreadedExecutor(num_threads=4) executor.add_node(node) executor.spin() if __name__ == '__main__': main() ``` **实验结果**: - 同时调 `service_a`(互斥)多次:串行(一次一个)。 - 同时调 `service_b`(可重入)多次:并发(多线程同时处理)。 - **可重入组必须配多线程执行器**,否则行为与互斥组无异。 **工程建议**: 1. 默认用互斥组(安全,避免共享数据竞争)。 2. 只有明确"回调间无共享状态"时才用可重入组。 3. 多个**独立**服务用各自互斥组也行——不同组的回调本来就互不阻塞。 ### 三、C++ 回调组与多线程执行器 #### 3.1 C++ 多线程执行器 ```cpp // cpp_pkg/src/multi_executor.cpp —— C++ 多线程执行器 #include "rclcpp/rclcpp.hpp" #include "std_srvs/srv/trigger.hpp" class ServiceServerNode : public rclcpp::Node { public: ServiceServerNode() : Node("service_server_node") { // 创建服务(默认互斥回调组) srv_ = this->create_service( "ping_service", std::bind(&ServiceServerNode::handle, this, std::placeholders::_1, std::placeholders::_2)); } private: void handle( const std::shared_ptr request, std::shared_ptr response) { response->success = true; response->message = "ok"; } rclcpp::Service::SharedPtr srv_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); auto node = std::make_shared(); // 多线程执行器:4 线程 rclcpp::executors::MultiThreadedExecutor executor( rclcpp::ExecutorOptions(), 4); executor.add_node(node); executor.spin(); rclcpp::shutdown(); return 0; } ``` #### 3.2 C++ 可重入回调组 ```cpp #include "rclcpp/callback_group.hpp" // 创建可重入回调组 auto reentrant_group = this->create_callback_group( rclcpp::CallbackGroupType::Reentrant); // 服务绑定到该组 srv_ = this->create_service( "ping_service", std::bind(&ServiceServerNode::handle, this, std::placeholders::_1, std::placeholders::_2), rmw_qos_profile_services_default, // QoS reentrant_group); // 回调组 ``` **多线程 + 可重入组 = 真正并发**;否则即使多线程执行器,互斥组内回调依然串行。 ### 四、超时与重试策略 #### 4.1 为什么需要超时 服务调用可能失败:服务端没启动、网络分区、服务端崩溃、请求超时。没有超时的调用可能**永远等待**,挂死整个节点。 #### 4.2 设置超时(Python) ```python # timeout_client.py —— 带超时的客户端 import rclpy from rclpy.node import Node from std_srvs.srv import Trigger class TimeoutClient(Node): def __init__(self): super().__init__('timeout_client') self.client = self.create_client(Trigger, 'ping_service') def call_with_timeout(self, timeout_sec=3.0): if not self.client.wait_for_service(timeout_sec=1.0): self.get_logger().error('服务端未启动') return None future = self.client.call_async(Trigger.Request()) # 超时 3 秒 rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec) if future.done(): return future.result() self.get_logger().error(f'请求超时({timeout_sec}s)') # 取消请求(可选) future.cancel() return None def main(): rclpy.init() node = TimeoutClient() result = node.call_with_timeout(3.0) if result is None: node.get_logger().error('调用失败') node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 4.3 重试模式 ```python # retry_client.py —— 指数退避重试 import rclpy, time from rclpy.node import Node from std_srvs.srv import Trigger class RetryClient(Node): def __init__(self): super().__init__('retry_client') self.client = self.create_client(Trigger, 'ping_service') def call_with_retry(self, max_retries=3): for attempt in range(1, max_retries + 1): try: if not self.client.wait_for_service(timeout_sec=2.0): raise TimeoutError('服务不可用') future = self.client.call_async(Trigger.Request()) rclpy.spin_until_future_complete(self, future, timeout_sec=5.0) if future.done() and future.result() is not None: return future.result() raise TimeoutError('请求超时') except Exception as e: self.get_logger().warn(f'第 {attempt} 次失败: {e}') if attempt < max_retries: time.sleep(2 ** attempt) # 指数退避:2s, 4s self.get_logger().error(f'重试 {max_retries} 次均失败') return None def main(): rclpy.init() node = RetryClient() result = node.call_with_retry() node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **重试最佳实践**: 1. **指数退避**(1s → 2s → 4s)而不是固定间隔,避免风暴。 2. 只重试"可重试"错误(超时、服务未就绪);“业务拒绝”(如参数非法)不重试。 3. 设置最大重试次数,避免无限循环。 4. 记录每次失败原因,便于排障。 ### 五、服务与 QoS #### 5.1 服务的 QoS 特性 服务通信有**默认 QoS**(`rmw_qos_profile_services_default`):可靠传输(RELIABLE)+ 持久(volatile)。这意味着: - 服务请求**保证送达**(可靠传输),不会丢。 - 服务端与客户端之间默认互相信任,QoS 兼容性一般不成问题。 #### 5.2 何时需要关注服务 QoS - 高延迟网络(跨机器):调整超时时间,而非 QoS。 - 大量并发请求:增大服务端执行器线程数,而非 QoS。 - 服务端需要"排队"策略:默认已有请求队列。 **经验**:服务场景基本用默认 QoS;真正需要精细控制 QoS 的是话题(第 9 篇详讲)。 ### 六、实战:高并发服务系统 #### 6.1 系统设计 模拟"机器人管理器"系统: ```text manager_server(多线程执行器 + 可重入组) ├── /query_status 查询状态(Trigger) ├── /set_mode 切换模式(SetMode,自定义) └── /execute_task 执行任务(耗时,模拟) 多个客户端并发调用 ``` #### 6.2 服务端实现 ```python # py_pkg/manager_server.py import rclpy, time from rclpy.node import Node from rclpy.callback_groups import ReentrantCallbackGroup from rclpy.executors import MultiThreadedExecutor from std_srvs.srv import Trigger from std_msgs.msg import String from rclpy.parameter import Parameter class ManagerServer(Node): def __init__(self): super().__init__('manager_server') self.group = ReentrantCallbackGroup() # 可重入组 self.mode = 'idle' self.srv_status = self.create_service( Trigger, 'query_status', self.on_status, callback_group=self.group) self.srv_mode = self.create_service( Trigger, 'set_mode', self.on_mode, callback_group=self.group) self.srv_task = self.create_service( Trigger, 'execute_task', self.on_task, callback_group=self.group) self.get_logger().info('Manager 服务就绪') def on_status(self, request, response): response.success = True response.message = f'当前模式: {self.mode}' return response def on_mode(self, request, response): # 模拟 1 秒处理 time.sleep(1) self.mode = 'running' if self.mode == 'idle' else 'idle' response.success = True response.message = f'模式切换为: {self.mode}' return response def on_task(self, request, response): time.sleep(3) # 模拟 3 秒任务 response.success = True response.message = '任务完成' return response def main(): rclpy.init() node = ManagerServer() executor = MultiThreadedExecutor(num_threads=8) executor.add_node(node) executor.spin() if __name__ == '__main__': main() ``` #### 6.3 并发压测客户端 ```python # py_pkg/manager_stress.py —— 并发压测 import rclpy from rclpy.node import Node from rclpy.executors import MultiThreadedExecutor from std_srvs.srv import Trigger import time class StressClient(Node): def __init__(self): super().__init__('stress_client') self.clients = { 'status': self.create_client(Trigger, 'query_status'), 'mode': self.create_client(Trigger, 'set_mode'), 'task': self.create_client(Trigger, 'execute_task'), } self.pending = 0 def hammer(self, rounds=3): for _ in range(rounds): for name, client in self.clients.items(): if not client.wait_for_service(timeout_sec=1.0): self.get_logger().error(f'{name} 不可用') continue future = client.call_async(Trigger.Request()) future.add_done_callback(lambda f, n=name: self.on_done(f, n)) self.pending += 1 self.get_logger().info(f'已发出 {self.pending} 个请求') def on_done(self, future, name): self.pending -= 1 r = future.result() self.get_logger().info(f'[{name}] {r.message},剩余 {self.pending}') def main(): rclpy.init() node = StressClient() node.hammer(3) # 3 轮 × 3 服务 = 9 个并发请求 while node.pending > 0: rclpy.spin_once(node, timeout_sec=0.05) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 6.4 运行与观察 ```bash # 终端 1:启动服务端(多线程) ros2 run py_pkg manager_server # 终端 2:压测(9 个并发请求) ros2 run py_pkg manager_stress # 终端 3:监控 ros2 service list ros2 service info /execute_task ``` **观察点**:9 个请求中,3 个 `execute_task`(各 3 秒)在可重入组 + 8 线程下**并发完成**(总耗时约 3 秒而非 9 秒),证明多线程 + 可重入组的并发能力。 #### 6.5 并发安全的注意事项 可重入组让回调并发后,**共享状态要加锁**(如 `self.mode` 被多个回调读写): ```python import threading self.lock = threading.Lock() def on_mode(self, request, response): with self.lock: # 保护共享状态 self.mode = 'running' ``` 不加锁的并发读写会带来数据竞争(Python 因 GIL 部分缓解,C++ 必须加锁)。 ### 七、常见问题排查 | 现象 | 原因 | 解决 | | --- | --- | --- | | 客户端一直等待 | 服务端没启动/没 spin | 启动服务端;确认服务端在 spin | | `wait_for_service` 超时 | 服务名/类型不匹配 | `ros2 service list` 确认服务存在 | | 请求超时(Timeout) | 服务端处理慢/网络慢 | 调大超时;检查服务端负载 | | 并发请求被串行处理 | 用了互斥组或单线程执行器 | 可重入组 + 多线程执行器 | | 回调并发但数据错乱 | 共享状态未加锁 | 用 `lock` 保护共享变量 | | `future.get()` 抛异常 | 请求被取消/服务端异常 | 用 `future.done()` 判断后再取 | | C++ 编译报 `callback_group` 参数错 | create_service 参数顺序错 | 按 `(类型, 服务名, 回调, QoS, 回调组)` 顺序 | ### 八、总结 本篇文章把服务通信推向工程级:理解了同步/异步的底层机制(伪同步本质)、并发请求与多请求处理、回调组(互斥/可重入)与多线程执行器、超时与指数退避重试、服务与 QoS 的关系,并用"高并发服务系统"实战验证了并发能力。 **关键要点回顾**: 1. ROS2 的"同步"= `call_async` + `spin_until_future_complete`,本质是伪同步。 2. 异步调用用 `add_done_callback`,主循环不阻塞,支持多并发。 3. 互斥回调组串行、可重入回调组并发;可重入组必须配多线程执行器。 4. 服务调用必须设超时,配合指数退避重试,避免无限等待。 5. 并发回调访问共享状态必须加锁。 6. 服务默认 QoS 可靠传输,一般无需调整。 ### 下一篇预告 下一篇进入**参数 Parameter 与 QoS 策略**:前半部分全面讲解参数系统——声明、读取、修改、回调、命令行、yaml 配置加载;后半部分深入 QoS——可靠性(RELIABLE/BEST_EFFORT)、历史(KEEP_LAST/KEEP_ALL)、深度、期限,以及跨话题 QoS 兼容性规则。参数与 QoS 是 ROS2 工程化配置的两大基石。 --- ## ROS2系列教程:服务Service通信(上)客户端与服务端 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-fu-wu-service-tong-xin-shang-ke-hu/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 7 篇 > 本文是 ROS2 系列教程的第 7 篇:服务 Service 通信(上)——客户端与服务端。话题解决"单向广播",服务解决"请求-应答":客户端发请求,服务端算好回响应。机器人里的"查询状态"“设置参数”"执行动作"这类一问一答的场景都用服务。本篇文章讲透 Request/Response 模型、同步与异步调用,用 Python 与 C++ 实现服务端与客户端,掌握 `ros2 service` 调试命令,并理清"话题 vs 服务"的选型。 ### 一、服务通信模型 #### 1.1 Request / Response 模型 服务(Service)采用**请求-响应(Request/Response)**模型: ```text 客户端(Client) 服务端(Server) │ ── 发送请求(Request) ──▶ │ │ │ 处理请求 │ ◀── 返回响应(Response) ── │ ``` 核心特点: - **双向**:一问一答,客户端必须等待(或异步等待)服务端响应。 - **一对多**:一个服务可被多个客户端调用;一个服务端可提供多个服务。 - **有状态交互**:适合"查询/设置/触发"类操作,不适合持续数据流。 典型场景:查询电池状态、设置机器人速度、触发一次建图、请求一次路径规划。持续高频的数据(雷达、里程计)请用话题。 #### 1.2 服务名与接口类型 服务由**服务名 + 服务类型**标识: ```text 服务名(service name):/set_speed 服务类型(service type):my_interfaces/srv/SetSpeed ``` 服务类型定义**请求与响应的结构**,用 `.srv` 文件描述(第 4 篇讲过),`---` 分隔: ```text # SetSpeed.srv float64 target_speed # 请求部分 bool is_emergency_stop --- bool success # 响应部分 string message ``` #### 1.3 ROS1 vs ROS2 的服务 | 维度 | ROS1 | ROS2 | | --- | --- | --- | | 客户端查找服务端 | 通过 roscore 注册表 | DDS 自动发现 | | 请求响应类型 | 原生类型为主 | 强类型 `.srv` 接口 | | 客户端多服务端 | 同一服务名只认第一个 | 支持多服务端负载均衡(部分 DDS) | | 加密安全 | 无 | SROS2 支持 | ROS2 的服务与话题一样,都基于 DDS 传输,只是语义从"流"变成"问答"。**服务名与话题名可以相同**(不同命名空间),但为了清晰通常用不同名字。 ### 二、Python 服务端与客户端 #### 2.1 服务端(Server) 用内置的 `std_srvs/srv/Trigger`(空请求,返回 success + message)演示最简服务端: ```python # py_pkg/py_service_server.py —— Python 服务端 import rclpy from rclpy.node import Node from std_srvs.srv import Trigger class PyServiceServer(Node): def __init__(self): super().__init__('py_service_server') # 创建服务:服务类型 + 服务名 + 回调函数 self.srv = self.create_service(Trigger, 'ping_service', self.handle_request) self.get_logger().info('服务已就绪: /ping_service') def handle_request(self, request, response): # 处理请求,填充响应(request 为空请求,但可读取字段) response.success = True response.message = 'pong from py_server' self.get_logger().info('收到请求,返回 pong') return response def main(): rclpy.init() node = PyServiceServer() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **服务端回调关键点**: 1. `create_service(类型, 服务名, 回调)` 注册服务。 2. 回调签名固定为 `(request, response) -> response`。 3. 回调由 `spin()` 驱动——**服务端必须 spin**,否则不响应任何请求。 4. 回调同样不能做耗时操作(阻塞期间其他请求排队)。 #### 2.2 客户端(Client)——同步调用 ```python # py_pkg/py_service_client.py —— Python 客户端(同步) import rclpy from rclpy.node import Node from std_srvs.srv import Trigger class PyServiceClient(Node): def __init__(self): super().__init__('py_service_client') # 创建客户端:服务类型 + 服务名 self.client = self.create_client(Trigger, 'ping_service') def call_sync(self): # 1. 等待服务端上线 while not self.client.wait_for_service(timeout_sec=1.0): self.get_logger().info('等待服务端...') # 2. 构造请求 request = Trigger.Request() # 3. 同步调用(阻塞直到收到响应) future = self.client.call_async(request) # 4. 等待 future 完成 rclpy.spin_until_future_complete(self, future) if future.result() is not None: response = future.result() self.get_logger().info( f'服务端响应: success={response.success} msg={response.message}') else: self.get_logger().error('调用失败') def main(): rclpy.init() node = PyServiceClient() node.call_sync() node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **Python 同步调用的本质**:rclpy 没有真正阻塞的 `call()`,而是用 `call_async()` 发起 + `spin_until_future_complete` 原地处理回调直到完成。这保证了调用期间事件循环仍在转。 #### 2.3 客户端——异步调用 不阻塞主流程的异步调用(适合周期检查、多服务并发): ```python # py_pkg/py_service_client_async.py —— 异步客户端 import rclpy from rclpy.node import Node from std_srvs.srv import Trigger class AsyncClient(Node): def __init__(self): super().__init__('async_client') self.client = self.create_client(Trigger, 'ping_service') self.timer = self.create_timer(2.0, self.periodic_call) self.busy = False def periodic_call(self): if self.busy: return if not self.client.wait_for_service(timeout_sec=0.5): self.get_logger().info('服务端未就绪,跳过本轮') return request = Trigger.Request() future = self.client.call_async(request) # 注册回调:响应到达时自动执行(不阻塞主循环) future.add_done_callback(self.on_response) self.busy = True def on_response(self, future): self.busy = False if future.result() is not None: r = future.result() self.get_logger().info(f'响应: {r.message}') else: self.get_logger().error('请求超时或失败') def main(): rclpy.init() node = AsyncClient() rclpy.spin(node) # 定时器驱动,循环发起异步请求 node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **异步调用的优势**:主循环不阻塞,可以同时"边等响应边干别的";多个并发请求互不干扰。代价是逻辑复杂度上升(回调处理响应)。 ### 三、C++ 服务端与客户端 #### 3.1 C++ 服务端 ```cpp // cpp_pkg/src/service_server.cpp —— C++ 服务端 #include "rclcpp/rclcpp.hpp" #include "std_srvs/srv/trigger.hpp" class ServiceServer : public rclcpp::Node { public: ServiceServer() : Node("service_server") { // 创建服务,回调绑定成员函数 server_ = this->create_service( "ping_service", std::bind(&ServiceServer::handle_request, this, std::placeholders::_1, std::placeholders::_2)); RCLCPP_INFO(this->get_logger(), "服务已就绪: /ping_service"); } private: // 回调签名:请求/响应都是 SharedPtr void handle_request( const std::shared_ptr request, std::shared_ptr response) { response->success = true; response->message = "pong from cpp_server"; RCLCPP_INFO(this->get_logger(), "收到请求,返回 pong"); } rclcpp::Service::SharedPtr server_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` #### 3.2 C++ 客户端——同步调用 ```cpp // cpp_pkg/src/service_client.cpp —— C++ 客户端 #include "rclcpp/rclcpp.hpp" #include "std_srvs/srv/trigger.hpp" #include class ServiceClient : public rclcpp::Node { public: ServiceClient() : Node("service_client") { client_ = this->create_client("ping_service"); timer_ = this->create_wall_timer( std::chrono::seconds(2), std::bind(&ServiceClient::call_service, this)); } private: void call_service() { // 1. 等服务端 if (!client_->wait_for_service(std::chrono::seconds(1))) { RCLCPP_WARN(this->get_logger(), "服务端未就绪"); return; } // 2. 构造请求 auto request = std::make_shared(); // 3. 异步发起,保存 future auto future = client_->async_send_request(request); // 4. 同步等待(spin_until_future_complete 内处理响应) if (rclcpp::spin_until_future_complete(this->get_node_base_interface(), future) == rclcpp::FutureReturnCode::SUCCESS) { auto response = future.get(); RCLCPP_INFO(this->get_logger(), "响应: %s", response->message.c_str()); } else { RCLCPP_ERROR(this->get_logger(), "调用失败"); } } rclcpp::Client::SharedPtr client_; rclcpp::TimerBase::SharedPtr timer_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` **C++ 同步调用要点**:`async_send_request` 返回 `SharedFuture`,配合 `spin_until_future_complete` 等待;不能真的 `future.get()` 死等(会卡死事件循环)。C++ 的"同步"本质与 Python 一致——都是在事件循环里处理响应。 #### 3.3 双语言混调 ```bash # Python 服务端 + C++ 客户端,或反过来,都能工作 ros2 run py_pkg py_service_server ros2 run cpp_pkg service_client ``` 与话题一样,服务也支持跨语言:只要服务名与类型一致,任何语言的客户端都能调任何语言的服务端。 ### 四、自定义服务接口实战 #### 4.1 定义并构建服务接口 沿用第 4 篇的 `my_interfaces` 包,添加 `SetSpeed.srv`(见第 4 篇 4.4),构建: ```bash colcon build --packages-select my_interfaces source install/setup.bash ``` **验证接口可用**: ```bash ros2 interface show my_interfaces/srv/SetSpeed ``` #### 4.2 服务端:速度设置服务 ```python # py_pkg/speed_service.py —— 速度设置服务端 import rclpy from rclpy.node import Node from my_interfaces.srv import SetSpeed class SpeedService(Node): def __init__(self): super().__init__('speed_service') self.srv = self.create_service(SetSpeed, 'set_speed', self.handle) self.current_speed = 0.0 def handle(self, request, response): if request.is_emergency_stop: self.current_speed = 0.0 response.success = True response.message = '急停!速度归零' elif 0.0 <= request.target_speed <= 3.0: self.current_speed = request.target_speed response.success = True response.message = f'速度已设置为 {self.current_speed}' else: response.success = False response.message = f'速度 {request.target_speed} 超出范围 [0, 3]' self.get_logger().info(response.message) return response def main(): rclpy.init() node = SpeedService() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 4.3 客户端:连续请求测试 ```python # py_pkg/speed_client.py —— 速度设置客户端 import rclpy from rclpy.node import Node from my_interfaces.srv import SetSpeed class SpeedClient(Node): def __init__(self): super().__init__('speed_client') self.client = self.create_client(SetSpeed, 'set_speed') def set_speed(self, target, emergency=False): if not self.client.wait_for_service(timeout_sec=1.0): self.get_logger().error('服务不可用') return request = SetSpeed.Request() request.target_speed = target request.is_emergency_stop = emergency future = self.client.call_async(request) rclpy.spin_until_future_complete(self, future) if future.result(): r = future.result() self.get_logger().info(f'success={r.success} msg={r.message}') return future.result() def main(): rclpy.init() node = SpeedClient() node.set_speed(1.5) # 正常设置 node.set_speed(5.0) # 超范围,应被拒绝 node.set_speed(2.0, emergency=True) # 急停 node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 4.4 命令行调用服务 **不写任何代码**,用命令行直接测服务(开发调试利器): ```bash # 运行服务端 ros2 run py_pkg speed_service # 调用服务(命令行客户端) ros2 service call /set_speed my_interfaces/srv/SetSpeed \ "{target_speed: 1.5, is_emergency_stop: false}" # 查看服务信息 ros2 service list # 列出所有服务 ros2 service list -t # 带类型 ros2 service type /set_speed # 查看服务类型 ros2 service find my_interfaces/srv/SetSpeed # 按类型找服务 ros2 service info /set_speed # 服务详情(客户端/服务端数量) ``` 命令行调用是验证服务逻辑最快的方式:先跑服务端,再用 `ros2 service call` 模拟客户端,观察响应是否符合预期。 ### 五、ros2 service 全套命令 ```bash # 列出所有服务 ros2 service list # 列出服务及类型 ros2 service list -t # 查看某服务的类型 ros2 service type /set_speed # 按类型反查服务 ros2 service find std_srvs/srv/Trigger # 查看服务详情(服务端/客户端数量) ros2 service info /set_speed # 手动调用服务 ros2 service call /set_speed my_interfaces/srv/SetSpeed "{target_speed: 2.0, is_emergency_stop: false}" # 调用内置 Trigger 服务 ros2 service call /ping_service std_srvs/srv/Trigger "{}" ``` ### 六、话题 vs 服务:如何选型 | 维度 | 话题 Topic | 服务 Service | | --- | --- | --- | | 通信模式 | 单向广播 | 请求-应答 | | 是否阻塞 | 发布不阻塞 | 客户端等响应(可异步) | | 数据流 | 持续流式 | 一次性问答 | | 典型场景 | 传感器数据、控制指令 | 查询、设置、触发 | | 多接收方 | 天然一对多 | 一对一(一个请求一个响应) | | 反馈 | 无 | 有(响应即反馈) | | 长任务 | 不适合 | 不适合(用 Action,第 10 篇) | | 调试命令 | `ros2 topic *` | `ros2 service *` | **选型口诀**: - 持续流动的数据 → **话题**(雷达、里程计、图像、速度指令) - 偶尔发生的问答 → **服务**(查状态、设参数、触发生成) - 带进度反馈的长任务 → **动作 Action**(导航、机械臂运动,第 10 篇) **反例警示**:别用服务做高频数据(每帧一个请求,服务端成瓶颈);别用话题做"必须等到结果"的操作(收不到确认)。 ### 七、总结 本篇文章完成了服务通信的完整入门:理解了 Request/Response 模型与适用场景,用 Python 和 C++ 分别实现了服务端、同步客户端、异步客户端,实战了自定义服务接口(速度设置),并掌握了 `ros2 service` 命令行调试工具,最后理清了话题与服务的选型边界。 **关键要点回顾**: 1. 服务 = 服务名 + 服务类型(.srv),请求与响应由 `---` 分隔。 2. 服务端必须 `spin()` 才会处理请求;回调签名固定为 `(request, response) -> response`。 3. 客户端先 `wait_for_service`,再 `call_async`;同步语义靠 `spin_until_future_complete` 实现。 4. 异步调用用 `future.add_done_callback`,主循环不阻塞。 5. `ros2 service call/list/type/info` 是服务调试四件套。 6. 持续数据用话题,一问一答用服务,长任务带反馈用 Action。 ### 下一篇预告 下一篇深入**服务 Service 通信(下)——同步异步与回调组**:对比同步/异步的底层机制与性能差异、多请求并发处理、`ReentrantCallbackGroup` 与 `MutuallyExclusiveCallbackGroup` 回调组、服务的超时与重试策略、服务与 QoS 的关系,以及服务端多线程执行器的最佳实践。学完你能设计稳健的服务架构。 --- ## ROS2系列教程:话题Topic通信(下)自定义消息与周期 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-hua-ti-topic-tong-xin-xia-zi-ding-yi/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 6 篇 > 本文是 ROS2 系列教程的第 6 篇:话题 Topic 通信(下)——自定义消息与周期。上一篇掌握了话题的基本收发,本篇文章解决两个工程化问题:**自定义消息类型的实战使用**(引用第 4 篇的接口包)与**发布周期的精细控制**(固定周期、动态周期、高频率话题)。同时介绍 `ros2 topic` 高级用法与 ros2 bag 数据录制回放。学完你就能构建完整的数据流系统。 ### 一、自定义消息实战 #### 1.1 完整链路回顾 自定义消息从定义到使用的完整链路: ```text 定义 .msg/.srv 文件(接口包) → colcon build 生成类型代码 → 发布者/订阅者 include/import 使用 → ros2 run 运行验证 ``` 第 4 篇我们定义了 `my_interfaces/msg/RobotStatus.msg`(含 robot_name、battery_level、status_code、stamp)。本篇文章围绕它展开实战。 #### 1.2 Python 使用自定义消息 ```python # py_pkg/robot_publisher.py —— 发布自定义消息 import rclpy from rclpy.node import Node from my_interfaces.msg import RobotStatus from builtin_interfaces.msg import Time class RobotPublisher(Node): def __init__(self): super().__init__('robot_publisher') # 发布自定义消息类型 self.pub = self.create_publisher(RobotStatus, 'robot_status', 10) self.timer = self.create_timer(1.0, self.publish_status) self.battery = 1.0 def publish_status(self): msg = RobotStatus() msg.robot_name = 'turtle_01' self.battery -= 0.01 msg.battery_level = self.battery msg.status_code = 1 # 时间戳:取当前系统时间(秒+纳秒) msg.stamp = Time() now = self.get_clock().now() msg.stamp.sec = now.seconds_nanoseconds()[0] msg.stamp.nanosec = now.seconds_nanoseconds()[1] self.pub.publish(msg) self.get_logger().info( f'发布: {msg.robot_name} 电量={msg.battery_level:.2f}') def main(): rclpy.init() node = RobotPublisher() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **Python 嵌套消息赋值**:嵌套的 `stamp` 需要单独 `import Time` 并创建实例;也可以用 `from builtin_interfaces.msg import Time` 后直接 `msg.stamp = Time()`。 #### 1.3 C++ 使用自定义消息 ```cpp // cpp_pkg/src/robot_subscriber.cpp —— 订阅自定义消息 #include "rclcpp/rclcpp.hpp" #include "my_interfaces/msg/robot_status.hpp" class RobotSubscriber : public rclcpp::Node { public: RobotSubscriber() : Node("robot_subscriber") { sub_ = this->create_subscription( "robot_status", 10, std::bind(&RobotSubscriber::on_status, this, std::placeholders::_1)); } private: void on_status(const my_interfaces::msg::RobotStatus::SharedPtr msg) { // 访问嵌套的 stamp 字段 auto sec = msg->stamp.sec; RCLCPP_INFO(this->get_logger(), "收到: %s 电量=%.2f 状态=%d sec=%ld", msg->robot_name.c_str(), msg->battery_level, msg->status_code, sec); } rclcpp::Subscription::SharedPtr sub_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` #### 1.4 依赖接口包的声明 **关键**:使用自定义消息的包必须在 `package.xml` 声明依赖接口包: ```xml my_interfaces ``` C++ 包还要在 `CMakeLists.txt` 里 `find_package`: ```cmake find_package(my_interfaces REQUIRED) ament_target_dependencies(robot_subscriber my_interfaces) ``` **构建顺序**:先构建接口包,再构建使用它的包: ```bash colcon build --packages-select my_interfaces colcon build --packages-select py_pkg cpp_pkg ``` #### 1.5 常用内置接口快速上手 除了自定义接口,掌握几个高频内置类型: ```text sensor_msgs/msg/LaserScan # 激光雷达:ranges 数组 + angle_min/max sensor_msgs/msg/Image # 图像:宽高 + 像素编码 + data geometry_msgs/msg/Twist # 速度指令:linear + angular geometry_msgs/msg/PoseStamped # 带时间戳位姿(导航目标) nav_msgs/msg/Odometry # 里程计:位姿 + 速度 + 协方差 std_msgs/msg/Header # 通用头:时间戳 + frame_id ``` `Header` 是几乎所有消息的公共字段,包含 `stamp`(时间戳)和 `frame_id`(坐标系),用于数据的时间同步与坐标关联: ```python # 给消息填 Header 的标准姿势 msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'map' ``` ### 二、发布周期控制 #### 2.1 固定周期发布 固定周期是传感器、控制循环的标配。两种实现方式: **方式一:定时器(推荐)**: ```python # 10Hz 发布(0.1 秒周期) self.timer = self.create_timer(0.1, self.publish_cb) ``` **方式二:定时器周期 + 消息计数**(控制发布频率): ```python # 2Hz 发布:定时器 10Hz 跑,每 5 次发布一次 class ThrottledNode(Node): def __init__(self): super().__init__('throttled_node') self.pub = self.create_publisher(String, 'throttled', 10) self.timer = self.create_timer(0.1, self.tick) # 10Hz self.counter = 0 def tick(self): self.counter += 1 if self.counter % 5 == 0: # 每 5 拍 → 2Hz msg = String() msg.data = 'every 0.5s' self.pub.publish(msg) ``` #### 2.2 动态周期发布 有些场景需要动态调整频率(如依据处理负载、急停时提高频率)。用 `timer.change_interval` 或重建定时器: ```python # dynamic_rate.py —— 动态调整发布周期 import rclpy from rclpy.node import Node from std_msgs.msg import Float64 class DynamicRateNode(Node): def __init__(self): super().__init__('dynamic_rate_node') self.pub = self.create_publisher(Float64, 'speed', 10) self.rate = 1.0 # 当前周期(秒) self.timer = self.create_timer(self.rate, self.tick) self.count = 0 def tick(self): self.count += 1 msg = Float64() msg.data = float(self.count) self.pub.publish(msg) # 每 5 拍把周期在 0.5~2.0 秒之间循环 if self.count % 5 == 0: self.rate = 2.0 if self.rate < 1.5 else 0.5 self.timer.cancel() self.timer = self.create_timer(self.rate, self.tick) self.get_logger().info(f'周期调整为 {self.rate}s') def main(): rclpy.init() node = DynamicRateNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **注意**:`create_timer` 创建的定时器对象在重建前要 `cancel()`,避免旧定时器继续触发。 #### 2.3 用 ros2 topic hz 验证实际频率 ```bash # 终端 1:运行动态周期节点 ros2 run py_pkg dynamic_rate_node # 终端 2:观察实际发布频率(应随周期调整而变化) ros2 topic hz /speed # average rate: 2.000 → 变化为 0.500 → 1.000 ... ``` **实际频率 ≠ 设定频率**:受系统负载、回调阻塞影响,实际频率会波动。`ros2 topic hz` 显示的是实测值,是判断"发布是否正常"的金标准。 ### 三、高频率话题实战 #### 3.1 100Hz 里程计仿真 控制/导航场景常需要高频数据。写一个 100Hz 的里程计发布器: ```python # py_pkg/high_freq_odom.py —— 100Hz 里程计仿真 import rclpy from rclpy.node import Node from nav_msgs.msg import Odometry from geometry_msgs.msg import Point, Quaternion class HighFreqOdom(Node): def __init__(self): super().__init__('high_freq_odom') # 100Hz:0.01 秒周期 self.pub = self.create_publisher(Odometry, 'odom', 50) # 队列深一点 self.timer = self.create_timer(0.01, self.publish_odom) self.x = 0.0 self.t = 0 def publish_odom(self): self.t += 1 self.x += 0.001 # 每拍前进 1mm msg = Odometry() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'odom' msg.child_frame_id = 'base_link' msg.pose.pose.position = Point(x=self.x, y=0.0, z=0.0) msg.pose.pose.orientation = Quaternion(x=0., y=0., z=0., w=1.) # 简单协方差(第 1 个元素为位置方差) msg.pose.covariance[0] = 0.0001 self.pub.publish(msg) def main(): rclpy.init() node = HighFreqOdom() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 运行验证: ```bash ros2 run py_pkg high_freq_odom ros2 topic hz /odom # 应显示 ~100.000 Hz ros2 topic bw /odom # 查看带宽占用 ``` **高频话题的关键配置**:QoS 队列深度要加大(如 50),否则发布快于消费时消息被丢弃;如果消费者处理慢,还要考虑 QoS 的 `history` 策略(第 9 篇详讲)。 #### 3.2 高频下的性能注意点 | 频率 | 注意事项 | | --- | --- | | ≤ 10Hz | 常规,无需特殊处理 | | 10-100Hz | 回调内避免耗时操作;队列深度 ≥ 10 | | 100-1000Hz | 建议 C++;避免消息内大数组拷贝;考虑 `zero-copy`(QoS 类型适配) | | > 1000Hz | 考虑共享内存传输(Iceoryx)或降低采样 | **经验**:Python 在 100Hz 内完全可用;更高频率或大数据量(图像)建议 C++,且回调里只做必要处理,别在回调里写日志(高频日志本身会拖慢系统)。 ### 四、ros2 topic 高级用法 #### 4.1 查看 QoS 兼容性 ```bash # 详细查看话题的 QoS 设置 ros2 topic info /odom --verbose # 输出:Publisher QoS: Reliability: RELIABLE, History: KEEP_LAST, Depth: 50 ... ``` #### 4.2 手动发布复杂类型 ```bash # 发布 Twist 类型(注意嵌套结构写法) ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \ "{linear: {x: 0.5, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.2}}" \ --rate 10 # 发布 Odometry(含 header) ros2 topic pub /odom nav_msgs/msg/Odometry \ "{header: {frame_id: 'odom'}, child_frame_id: 'base_link', pose: {pose: {position: {x: 1.0}}}}" \ --rate 5 ``` #### 4.3 用 --qos-reliability 指定 QoS ```bash # 以 BEST_EFFORT 可靠性订阅(图像/雷达常用) ros2 topic echo /scan --qos-reliability best_effort # 以 RELIABLE 发布 ros2 topic pub /chatter std_msgs/msg/String "{data: 'hi'}" --qos-reliability reliable ``` #### 4.4 过滤与采样 ```bash # 只看数据字段(省略 header 等) ros2 topic echo /odom --field pose.pose.position # 只显示最近 N 条 ros2 topic echo /odom --once ``` ### 五、ros2 bag:数据录制与回放 #### 5.1 录制话题数据 ros2 bag 是 ROS2 的数据记录工具,把话题数据录到磁盘,之后可回放(调试、训练、复现 bug 的利器): ```bash # 录制所有话题到默认目录(bag 目录) ros2 bag record -a # 只录制指定话题 ros2 bag record /odom /scan /cmd_vel # 指定输出目录与名称 ros2 bag record /odom -o odom_recording # 录制时附带 QoS 配置(保证能回放) ros2 bag record /scan --qos-profile-overrides \ "{'sensor_msgs::msg::LaserScan': {reliability: 'best_effort'}}" ``` #### 5.2 查看与回放 ```bash # 查看 bag 信息(话题列表、消息数、时长) ros2 bag info odom_recording # 回放 bag(默认按原时间戳节奏) ros2 bag play odom_recording # 回放指定话题、倍速 ros2 bag play odom_recording --topics /odom --rate 2.0 ``` #### 5.3 bag 的典型用途 1. **离线调试**:录下现场数据,回家慢慢分析。 2. **算法验证**:同一份数据回放,对比不同参数/算法效果。 3. **复现 bug**:把异常场景录下来,反复回放定位问题。 4. **训练数据**:收集传感器数据用于机器学习。 **注意**:bag 回放时会**重新发布**数据(时间戳可能保持原始值),订阅者用 `header.stamp` 时要注意时间可能是过去的。 ### 六、实战:完整数据流系统 #### 6.1 系统设计 搭建一个完整数据流系统,串联本篇所有知识点: ```text sensor_node(100Hz 发 /odom + 10Hz 发 /scan) ↓ logger_node(订阅两者,校验时间戳,周期打印) ↓ ros2 bag record(录制全程) ↓ 回放验证 ``` #### 6.2 传感器节点(双频双话题) ```python # py_pkg/dataflow_sensor.py import rclpy from rclpy.node import Node from nav_msgs.msg import Odometry from sensor_msgs.msg import LaserScan class DataflowSensor(Node): def __init__(self): super().__init__('dataflow_sensor') self.odom_pub = self.create_publisher(Odometry, 'odom', 50) self.scan_pub = self.create_publisher(LaserScan, 'scan', 10) # 100Hz 里程计 + 10Hz 雷达 self.odom_timer = self.create_timer(0.01, self.pub_odom) self.scan_timer = self.create_timer(0.1, self.pub_scan) self.x = 0.0 def pub_odom(self): self.x += 0.001 msg = Odometry() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'odom' msg.child_frame_id = 'base_link' msg.pose.pose.position.x = self.x self.odom_pub.publish(msg) def pub_scan(self): msg = LaserScan() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'laser' msg.angle_min = -3.14159 msg.angle_max = 3.14159 msg.angle_increment = 0.01 msg.range_min = 0.1 msg.range_max = 10.0 # 模拟 360 个点(全部 1.0m,模拟圆形房间) msg.ranges = [1.0] * 360 self.scan_pub.publish(msg) def main(): rclpy.init() node = DataflowSensor() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 6.3 记录节点(双订阅 + 时间戳校验) ```python # py_pkg/dataflow_logger.py import rclpy from rclpy.node import Node from nav_msgs.msg import Odometry from sensor_msgs.msg import LaserScan class DataflowLogger(Node): def __init__(self): super().__init__('dataflow_logger') self.odom_sub = self.create_subscription( Odometry, 'odom', self.on_odom, 50) self.scan_sub = self.create_subscription( LaserScan, 'scan', self.on_scan, 10) self.odom_count = 0 self.scan_count = 0 def on_odom(self, msg): self.odom_count += 1 # 每 500 拍打印一次,避免刷屏 if self.odom_count % 500 == 0: self.get_logger().info( f'odom #{self.odom_count} x={msg.pose.pose.position.x:.2f}') def on_scan(self, msg): self.scan_count += 1 if self.scan_count % 10 == 0: self.get_logger().info( f'scan #{self.scan_count} points={len(msg.ranges)} ' f'first={msg.ranges[0]:.1f}') def main(): rclpy.init() node = DataflowLogger() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 6.4 完整运行流程 ```bash # 终端 1:传感器节点 ros2 run py_pkg dataflow_sensor # 终端 2:记录节点 ros2 run py_pkg dataflow_logger # 终端 3:验证频率 ros2 topic hz /odom # ~100Hz ros2 topic hz /scan # ~10Hz # 终端 4:录制 10 秒 ros2 bag record /odom /scan -o dataflow_bag # 10 秒后 Ctrl+C 停止录制 # 查看录制信息 ros2 bag info dataflow_bag # 回放 ros2 bag play dataflow_bag ``` **预期结果**:logger 每 5 秒打印一次 odom(500 拍 × 0.01s),每 1 秒打印一次 scan;bag 录制后回放,logger 重新收到数据——证明"数据流 + 录制回放"闭环打通。 ### 七、总结 本篇文章把话题通信从"能用"推向"工程可用":自定义消息的完整使用链路、发布周期的三种控制方式(固定/节流/动态)、高频话题的性能要点、`ros2 topic` 高级命令,以及 ros2 bag 的录制回放。实战部分构建了一个 100Hz+10Hz 双频数据流系统并验证闭环。 **关键要点回顾**: 1. 用自定义消息要在 `package.xml`(及 C++ 的 CMakeLists)声明接口包依赖,构建时先接口包后业务包。 2. 定时器驱动周期发布;动态调频要先 `cancel()` 旧定时器。 3. `ros2 topic hz` 看实测频率,实际频率 ≠ 设定频率。 4. 高频话题加大 QoS 队列深度;Python 100Hz 内可用,更高用 C++。 5. `ros2 bag record/play` 录制回放,是离线调试与复现 bug 的利器。 6. `Header` 是消息的公共字段,承载时间戳与坐标系,跨话题数据同步靠它。 ### 下一篇预告 下一篇进入**服务 Service 通信(上)——客户端与服务端**:话题是"单向广播",服务则是"请求-应答"。我们将理解 Request/Response 模型、同步与异步调用方式,用 Python 与 C++ 实现服务端与客户端,掌握 `ros2 service` 调试命令,并对比"话题 vs 服务"的选型场景。 --- ## ROS2系列教程:话题Topic通信(上)发布者与订阅者 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-hua-ti-topic-tong-xin-shang-fa-bu/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 5 篇 > 本文是 ROS2 系列教程的第 5 篇:话题 Topic 通信(上)——发布者与订阅者。话题是 ROS2 最核心、最常用的通信机制,机器人里的传感器数据、控制指令、状态信息几乎都通过话题流转。本篇文章深入话题的 Publish/Subscribe 模型与去中心化发现机制,用 Python 与 C++ 各实现一套发布者/订阅者,掌握 `create_publisher`、`create_subscription` 与回调机制,最后精通 `ros2 topic` 全套调试命令。 ### 一、话题通信的核心思想 #### 1.1 Publish / Subscribe 模型 话题(Topic)采用**发布/订阅(Publish-Subscribe)**模式,核心特点: - **发布者(Publisher)**:只负责往话题上发消息,不关心谁在收。 - **订阅者(Subscriber)**:只负责订阅话题收消息,不关心谁在发。 - **解耦**:双方互相不知道对方的存在,通过话题名"碰头"。 这种模式的三大优点: | 优点 | 说明 | | --- | --- | | 空间解耦 | 发布者和订阅者不需要互相引用,甚至不在同一台机器 | | 时间解耦 | 发布者发完消息即可离开,订阅者后加入也能收到后续消息(配合 QoS) | | 一对多/多对多 | 一个话题可被多个发布者发、多个订阅者收 | 话题通信是**单向**的(数据流从发布者到订阅者);如果需要请求-应答式的双向通信,用服务(第 7 篇)。需要长任务带反馈,用动作(第 10 篇)。 #### 1.2 话题名与消息类型 话题由**话题名 + 消息类型**双重标识: ```text 话题名(topic name):/cmd_vel 消息类型(message type):geometry_msgs/msg/Twist ``` - **话题名**决定"谁和谁通信"——发布者和订阅者的话题名必须完全一致。 - **消息类型**决定"传什么数据结构"——类型不一致时 ROS2 会拒绝匹配(严格类型安全)。 **命名规则**:话题名用小写字母、数字、下划线,以 `/` 开头(绝对话题名),如 `/cmd_vel`、`/scan`、`/odom`。带命名空间时如 `/robot1/cmd_vel`。 查看一个话题的信息: ```bash ros2 topic info /cmd_vel # Type: geometry_msgs/msg/Twist # Publisher count: 1 # Subscriber count: 0 ``` #### 1.3 从 ROS1 到 ROS2:去中心化 ROS1 的话题通信依赖 `roscore`(Master)做**话题匹配**:发布者向 Master 注册"我要发 /cmd_vel",订阅者向 Master 注册"我要收 /cmd_vel",Master 撮合后双方建立 TCP 连接。Master 挂了,整个系统瘫痪。 ROS2 改用 **DDS 自动发现(Discovery)**: ```text 节点启动 → 通过 DDS 组播协议广播自己的存在(含发布/订阅信息) → 其他节点收到后回应 → 双方协商 QoS → 建立 P2P 直连 → 开始传输 ``` 整个流程无需中心节点,新节点随时加入、随时退出,系统天然分布式。这也是 ROS2 支持多机、热插拔、大规模组网的根基。 ### 二、Python 发布者与订阅者 #### 2.1 发布者(Publisher) ```python # py_pkg/py_publisher.py —— Python 发布者 import rclpy from rclpy.node import Node from std_msgs.msg import String class PyPublisher(Node): def __init__(self): super().__init__('py_publisher') # 1. 创建发布者:话题名 + 消息类型 + QoS 队列长度 self.pub = self.create_publisher(String, 'chatter', 10) # 2. 定时器驱动周期性发布 self.timer = self.create_timer(1.0, self.timer_callback) self.count = 0 def timer_callback(self): self.count += 1 msg = String() msg.data = f'Hello from py_publisher #{self.count}' # 3. 发布消息 self.pub.publish(msg) self.get_logger().info(f'发布: {msg.data}') def main(): rclpy.init() node = PyPublisher() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 三个关键步骤: 1. `create_publisher(类型, 话题名, 队列长度)` —— 第三个参数是 **QoS 深度**(消息队列最多缓存几条,第 9 篇详讲)。 2. 定时器定期触发回调,模拟"传感器周期性发数据"。 3. `publish(msg)` 真正把消息发出去。 #### 2.2 订阅者(Subscriber) ```python # py_pkg/py_subscriber.py —— Python 订阅者 import rclpy from rclpy.node import Node from std_msgs.msg import String class PySubscriber(Node): def __init__(self): super().__init__('py_subscriber') # 1. 创建订阅者:话题名 + 消息类型 + 回调函数 + QoS self.sub = self.create_subscription( String, 'chatter', self.listener_callback, 10) def listener_callback(self, msg): # 2. 收到消息时自动调用(spin 的循环里执行) self.get_logger().info(f'收到: {msg.data}') def main(): rclpy.init() node = PySubscriber() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` **订阅者回调**是话题通信的核心机制:`spin()` 循环不断检查是否有新消息,一旦到达就调用 `listener_callback`。回调在单线程里串行执行,所以回调里不要做耗时操作(回顾第 2 篇)。 #### 2.3 运行一对发布/订阅 ```bash # 终端 1:运行发布者 ros2 run py_pkg py_publisher # 终端 2:运行订阅者 ros2 run py_pkg py_subscriber ``` 订阅者终端会持续打印 `收到: Hello from py_publisher #N`。 **重要现象**:先启动订阅者、后启动发布者,或反过来,**都能正常通信**——这就是 DDS 自动发现的威力:新节点加入的瞬间,双方自动建立连接。 ### 三、C++ 发布者与订阅者 #### 3.1 C++ 发布者 ```cpp // cpp_pkg/src/publisher.cpp #include "rclcpp/rclcpp.hpp" #include "std_msgs/msg/string.hpp" #include #include using namespace std::chrono_literals; class CppPublisher : public rclcpp::Node { public: CppPublisher() : Node("cpp_publisher"), count_(0) { // 创建发布者:类型 + 话题名 + QoS publisher_ = this->create_publisher("chatter", 10); timer_ = this->create_wall_timer(1s, std::bind(&CppPublisher::timer_callback, this)); } private: void timer_callback() { auto msg = std_msgs::msg::String(); msg.data = "Hello from cpp_publisher #" + std::to_string(++count_); publisher_->publish(msg); // 发布 RCLCPP_INFO(this->get_logger(), "发布: %s", msg.data.c_str()); } rclcpp::Publisher::SharedPtr publisher_; rclcpp::TimerBase::SharedPtr timer_; int count_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` #### 3.2 C++ 订阅者 ```cpp // cpp_pkg/src/subscriber.cpp #include "rclcpp/rclcpp.hpp" #include "std_msgs/msg/string.hpp" class CppSubscriber : public rclcpp::Node { public: CppSubscriber() : Node("cpp_subscriber") { // 创建订阅者:类型 + 话题名 + 回调 + QoS subscription_ = this->create_subscription( "chatter", 10, std::bind(&CppSubscriber::topic_callback, this, std::placeholders::_1)); } private: void topic_callback(const std_msgs::msg::String::SharedPtr msg) { RCLCPP_INFO(this->get_logger(), "收到: %s", msg->data.c_str()); } rclcpp::Subscription::SharedPtr subscription_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` C++ 回调用 `std::bind` 绑定成员函数,消息以 **`SharedPtr`(共享指针)** 传入,避免拷贝。 #### 3.3 双语言混搭通信 ROS2 最重要的特性之一:**消息类型一致即可跨语言通信**。 ```bash # C++ 发布者 + Python 订阅者,或反过来,都能正常通信 ros2 run cpp_pkg publisher # C++ 发 ros2 run py_pkg py_subscriber # Python 收 ``` 因为话题数据在 DDS 层以二进制序列化(CDR 格式)传输,语言只是外衣。这个特性让团队可以自由选择语言:性能敏感模块用 C++,快速迭代模块用 Python。 ### 四、常见标准消息类型 ROS2 预置了大量标准消息(`std_msgs`、`geometry_msgs`、`sensor_msgs`、`nav_msgs` 等)。先掌握最常用的几个: ```text std_msgs/msg/Header # 时间戳+坐标系id,几乎所有消息都嵌套它 std_msgs/msg/String # 字符串(示例常用) std_msgs/msg/Int32 # 32位整数 std_msgs/msg/Float64 # 64位浮点 geometry_msgs/msg/Twist # 线速度+角速度(/cmd_vel 常用) geometry_msgs/msg/PoseStamped # 带时间的位姿(导航目标点) sensor_msgs/msg/LaserScan # 激光雷达数据 sensor_msgs/msg/Image # 图像 nav_msgs/msg/Odometry # 里程计 ``` 查看消息字段: ```bash ros2 interface show geometry_msgs/msg/Twist # Vector3 linear (x, y, z 线速度) # Vector3 angular (x, y, z 角速度) ``` ### 五、ros2 topic 全套调试命令 `ros2 topic` 是话题排障的瑞士军刀,务必全部掌握: ```bash # 1. 列出所有话题 ros2 topic list ros2 topic list -t # 带类型显示 # 2. 查看话题信息(类型、发布/订阅者数量) ros2 topic info /chatter # 3. 实时回显话题内容(最常用!) ros2 topic echo /chatter # 4. 查看话题发布频率 ros2 topic hz /chatter # 5. 查看话题带宽 ros2 topic bw /chatter # 6. 手动发布消息(调试神器,不用写代码) ros2 topic pub /chatter std_msgs/msg/String "{data: 'hello'}" --rate 1 # --rate 1 每秒发 1 条 # --once 只发 1 条 # --times N 发 N 条 # 7. 查看话题的 QoS 设置 ros2 topic info /chatter --verbose ``` **实战演练**:不开任何节点,用 `ros2 topic pub` 发消息 + 另一个终端 `ros2 topic echo` 收消息,验证话题链路;再 `ros2 topic hz` 看频率是否稳定在 1Hz。这套组合能快速判断"是发布端的问题还是订阅端的问题"。 ### 六、实战:双话题传感器仿真 #### 6.1 场景设计 模拟一个机器人:`sensor_node` 同时发布**里程计**(`/odom`,整数计数)和**速度指令**(`/cmd_vel`,Twist 消息);`display_node` 订阅两者并打印。 #### 6.2 传感器节点(双发布者) ```python # py_pkg/sensor_node.py —— 一个节点两个发布者 import rclpy from rclpy.node import Node from std_msgs.msg import Int32 from geometry_msgs.msg import Twist class SensorNode(Node): def __init__(self): super().__init__('sensor_node') # 两个发布者:不同话题、不同类型 self.odom_pub = self.create_publisher(Int32, 'odom', 10) self.cmd_pub = self.create_publisher(Twist, 'cmd_vel', 10) self.timer = self.create_timer(0.5, self.tick) self.count = 0 def tick(self): self.count += 1 # 发里程计 odom_msg = Int32() odom_msg.data = self.count self.odom_pub.publish(odom_msg) # 发速度指令(每 5 拍换一次方向) cmd = Twist() cmd.linear.x = 0.5 if (self.count % 10 < 5) else -0.5 cmd.angular.z = 0.2 self.cmd_pub.publish(cmd) self.get_logger().info(f'odom={self.count} cmd_vx={cmd.linear.x:.2f}') def main(): rclpy.init() node = SensorNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 6.3 显示节点(双订阅者) ```python # py_pkg/display_node.py —— 一个节点两个订阅者 import rclpy from rclpy.node import Node from std_msgs.msg import Int32 from geometry_msgs.msg import Twist class DisplayNode(Node): def __init__(self): super().__init__('display_node') self.odom_sub = self.create_subscription( Int32, 'odom', self.on_odom, 10) self.cmd_sub = self.create_subscription( Twist, 'cmd_vel', self.on_cmd, 10) def on_odom(self, msg): self.get_logger().info(f'里程计: {msg.data}') def on_cmd(self, msg): self.get_logger().info(f'速度指令: vx={msg.linear.x:.2f} wz={msg.angular.z:.2f}') def main(): rclpy.init() node = DisplayNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 6.4 运行与验证 ```bash # 终端 1:传感器节点 ros2 run py_pkg sensor_node # 终端 2:显示节点 ros2 run py_pkg display_node # 终端 3:可视化整个话题图 ros2 topic list rqt_graph # 图形化显示节点-话题连接关系 ``` `rqt_graph` 会画出 `sensor_node` 和 `display_node` 两个椭圆,中间两条带箭头的线分别指向 `/odom` 和 `/cmd_vel` 话题节点——这就是"ROS 图"的直观呈现。 #### 6.5 一个节点多个话题的意义 本例演示了 ROS2 的重要设计:**一个节点可以拥有任意多个发布者/订阅者**。真实机器人里,`sensor_node` 可能同时发布 `/scan`、`/odom`、`/imu` 多个话题;订阅端同理。话题与节点是多对多关系,而非一对一。 ### 七、常见问题排查 | 现象 | 原因 | 解决 | | --- | --- | --- | | 订阅者收不到消息 | 话题名或类型不匹配 | `ros2 topic info` 对比双方话题名与类型 | | 收不到但 `topic info` 正常 | QoS 不兼容 | 双方 QoS 策略要能匹配(第 9 篇) | | `ros2 topic hz` 无输出 | 发布者没在发 | 检查定时器是否在跑、`publish` 是否被调用 | | 回调不执行 | spin 没跑或回调阻塞 | 确认有 `spin`;回调内勿做耗时操作 | | C++ 编译报类型错误 | include 路径或类型名错 | `#include "包名/msg/类型.hpp"`,类型用小写文件名 | | 消息收发有延迟/抖动 | QoS 深度太小 | 增大队列深度,或调整 QoS(第 9 篇) | ### 八、总结 本篇文章完成了话题通信的入门:理解了 Publish/Subscribe 模型的三大解耦优势、DDS 去中心化发现机制,用 Python 与 C++ 分别实现了发布者/订阅者并验证了跨语言通信,掌握了 `ros2 topic` 全套调试命令,最后通过"传感器-显示"双话题实战巩固了多发布者/多订阅者的能力。 **关键要点回顾**: 1. 话题 = 话题名 + 消息类型,双重标识,类型必须匹配。 2. 发布者 `create_publisher(类型, 话题名, QoS深度)`,订阅者 `create_subscription(类型, 话题名, 回调, QoS深度)`。 3. 订阅者回调由 `spin()` 驱动,回调内勿做耗时操作。 4. 消息类型一致即可跨语言(C++ ↔ Python)通信。 5. `ros2 topic echo/hz/pub/info` 是排障四件套。 6. 一个节点可有多个发布者/订阅者,话题与节点多对多。 ### 下一篇预告 下一篇进阶**话题通信(下)——自定义消息与周期**:发布/订阅的自定义消息类型(引用第 4 篇定义的接口包)、固定周期与动态周期发布、高频率话题的吞吐与延迟、`ros2 topic` 的高级用法(`--qos-reliability`、`--no-arr` 等),以及话题数据的录制与回放(ros2 bag)。学完你将能构建完整的数据流系统。 --- ## ROS2系列教程:package结构(CMake与Python两种包) URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-package-jie-gou-cmake-yu-python/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 4 篇 > 本文是 ROS2 系列教程的第 4 篇:package 结构剖析(CMake 与 Python 两种包)。上一篇我们创建了两个包,但只是"会跑";本篇把包的每一块骨头拆开看:`package.xml` 每个字段的含义、`CMakeLists.txt` 与 `setup.py` 的配置细节、自定义消息/服务接口包(`.msg`/`.srv`)的定义与生成,以及两种包类型的完整对比。学完本篇,你能看懂任何开源 ROS2 包的目录结构,也能正确声明依赖、定义自己的接口。 ### 一、package.xml:包的身份证 #### 1.1 最小完整示例 `package.xml` 是**每个 ROS2 包都必须有**的清单文件,描述包的元数据与依赖。一个完整的 `package.xml` 长这样: ```xml my_pkg 0.1.0 一个示例包 Your Name Apache-2.0 ament_cmake rclcpp sensor_msgs launch_ros ament_lint_auto ament_cmake ``` #### 1.2 五种依赖类型详解 | 标签 | 含义 | 何时使用 | | --- | --- | --- | | `` | 构建工具本身 | 几乎都是 `ament_cmake` 或 `ament_python` | | `` | 构建 + 运行时都需要 | 最常用,如 `rclcpp`、`rclpy`、`std_msgs` | | `` | 仅编译时需要 | 只在头文件/编译期用到的库 | | `` | 仅运行时需要 | 如 launch 文件依赖、Python 包 | | `` | 测试时依赖 | `ament_lint_auto` 等 | **最佳实践**:拿不准时统一用 ``,它等价于同时声明 build + exec 依赖,最保险。 #### 1.3 format 属性:3 与 2 的区别 ```xml ``` - `format="2"`:最常用,支持 ``。 - `format="3"`:额外支持 ``(把包归入组,如 `rosidl_interface_packages`),用于接口包。 - `format="1"`:老格式,只有 build/exec 两种依赖,新包不要用。 **接口包(定义 .msg/.srv 的包)必须用 format 3**,并在 export 里声明组: ```xml ament_cmake rosidl_interface_packages ``` ### 二、Python 包结构详解 #### 2.1 完整目录树 ```text py_pkg/ ├── package.xml # 包清单(build_type = ament_python) ├── setup.py # 构建配置(核心) ├── setup.cfg # 安装路径配置 ├── resource/ │ └── py_pkg # ament 索引标记(内容可空) ├── test/ │ ├── test_copyright.py │ ├── test_flake8.py │ └── test_pep257.py └── py_pkg/ # Python 源码目录(与包同名) ├── __init__.py └── my_node.py # 节点实现 ``` #### 2.2 setup.py 逐字段 ```python from setuptools import setup from glob import glob import os package_name = 'py_pkg' setup( name=package_name, # 包名,必须与目录同名 version='0.0.0', # 版本号 packages=[package_name], # Python 包列表(源码目录) data_files=[ # 数据/资源文件安装位置 ('share/ament_index/resource_index/packages', ['resource/' + package_name]), # ament 索引(必须) ('share/' + package_name, ['package.xml']), # 包清单(必须) (os.path.join('share', package_name, 'launch'), # launch 文件(可选) glob('launch/*.launch.py')), (os.path.join('share', package_name, 'config'), # 配置文件(可选) glob('config/*.yaml')), ], install_requires=['setuptools'], zip_safe=True, maintainer='your_name', maintainer_email='you@example.com', description='Python 示例包', license='Apache-2.0', entry_points={ 'console_scripts': [ 'my_node = py_pkg.my_node:main', # 命令名 = 模块:函数 ], }, ) ``` **四个必须**:`name` 与目录同名、`packages` 含源码目录、`data_files` 含 ament 索引与 package.xml、`entry_points` 定义可执行命令。缺任何一个,`ros2 run` 都可能失败。 #### 2.3 setup.cfg:安装路径 ```ini [develop] script_dir=$base/lib/py_pkg [install] install_scripts=$base/lib/py_pkg ``` 这个文件把脚本安装到 `lib/<包名>/` 目录,`ros2 run` 才能找到。**一般照抄即可,不要乱改**。 ### 三、CMake 包结构详解 #### 3.1 完整目录树 ```text cpp_pkg/ ├── package.xml # 包清单(build_type = ament_cmake) ├── CMakeLists.txt # 构建规则(核心) ├── include/cpp_pkg/ # 公共头文件(按约定与包同名) │ └── my_node.hpp ├── src/ │ ├── my_node.cpp # 节点实现 │ └── main.cpp # 入口 ├── launch/ # launch 文件(需在 CMakeLists 安装) ├── config/ # 配置文件 └── msg/ srv/ action/ # 接口定义目录(本包定义接口时) ``` #### 3.2 CMakeLists.txt 逐段 ```cmake cmake_minimum_required(VERSION 3.8) project(cpp_pkg) # 项目名 = 包名 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(std_msgs REQUIRED) # 用到标准消息时 # 生成库(可选,模块化时用) add_library(cpp_pkg_core src/my_node.cpp ) target_include_directories(cpp_pkg_core PUBLIC $ $) ament_target_dependencies(cpp_pkg_core rclcpp std_msgs) # 生成可执行文件 add_executable(my_node src/main.cpp) target_link_libraries(my_node cpp_pkg_core) # 链接自己的库 ament_target_dependencies(my_node rclcpp std_msgs) # 安装:可执行文件、头文件、launch、config(缺一不可) install(TARGETS my_node cpp_pkg_core EXPORT export_${PROJECT_NAME} ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION lib/${PROJECT_NAME} ) install(DIRECTORY include/ DESTINATION include) install(DIRECTORY launch DESTINATION share/${PROJECT_NAME}) install(DIRECTORY config DESTINATION share/${PROJECT_NAME}) ament_package() ``` **最容易踩的三个坑**: 1. 忘写 `install(TARGETS ...)` → `ros2 run` 找不到命令。 2. 忘写 `ament_target_dependencies` → 编译报"找不到头文件/链接错误"。 3. 忘写 `find_package(std_msgs)` 却在代码里 `#include "std_msgs/msg/string.hpp"` → 编译报错。 #### 3.3 头文件与源文件分离 工程化项目推荐把实现放库、入口放 main: ```cpp // include/cpp_pkg/my_node.hpp #pragma once #include "rclcpp/rclcpp.hpp" class MyNode : public rclcpp::Node { public: MyNode(); private: rclcpp::TimerBase::SharedPtr timer_; }; ``` ```cpp // src/my_node.cpp #include "cpp_pkg/my_node.h pp" #include MyNode::MyNode() : Node("my_node") { timer_ = this->create_wall_timer( std::chrono::seconds(1), [this]() { RCLCPP_INFO(this->get_logger(), "tick"); }); } ``` ```cpp // src/main.cpp #include "cpp_pkg/my_node.hpp" #include "rclcpp/rclcpp.hpp" int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` 这样"库 + 薄入口"的结构便于单元测试与复用,是大型项目的标准组织方式。 ### 四、自定义接口包:msg / srv #### 4.1 为什么要自定义接口 ROS2 内置的 `std_msgs`、`sensor_msgs`、`geometry_msgs` 覆盖常见场景,但你的业务数据(比如"机器人位姿 + 置信度 + 时间戳")往往是组合结构。这时需要**自定义消息类型**——定义一个 `.msg` 文件,rosidl 工具会自动生成对应语言的类型代码。 #### 4.2 创建接口包 接口包通常是独立的纯接口包(不放实现),因为它会被多个包依赖: ```bash ros2 pkg create my_interfaces \ --build-type ament_cmake \ --license Apache-2.0 \ --maintainer-name your_name \ --maintainer-email you@example.com \ --description "Custom interfaces" ``` #### 4.3 定义 .msg 文件 在 `my_interfaces/msg/` 目录创建 `RobotStatus.msg`: ```text # msg/RobotStatus.msg —— 自定义消息 string robot_name # 字符串字段 float32 battery_level # 浮点字段(0.0 ~ 1.0) int32 status_code # 整数状态码 builtin_interfaces/Time stamp # 嵌套内置类型 ``` **消息类型映射表**(.msg 写法 → C++ 类型 → Python 类型): | .msg 写法 | C++ 类型 | Python 类型 | | --- | --- | --- | | `bool` | `bool` | `bool` | | `int8/16/32/64` | `int8_t` 等 | `int` | | `float32/float64` | `float/double` | `float` | | `string` | `std::string` | `str` | | `string[]` | `std::vector` | `list` | | `builtin_interfaces/Time` | `builtin_interfaces::msg::Time` | `Time` | | `geometry_msgs/Pose` | `geometry_msgs::msg::Pose` | `Pose` | **规则**:字段名小写下划线;数组用 `[]` 后缀;嵌套类型写 `包名/类型名`。 #### 4.4 定义 .srv 文件 在 `my_interfaces/srv/` 创建 `SetSpeed.srv`(`---` 分隔请求与响应): ```text # 请求部分 float64 target_speed bool is_emergency_stop --- # 响应部分 bool success string message ``` #### 4.5 修改 CMakeLists.txt 启用接口生成 接口包的 `CMakeLists.txt` 需要添加 rosidl 相关配置: ```cmake find_package(rosidl_default_generators REQUIRED) find_package(builtin_interfaces REQUIRED) # 声明接口文件(msg 与 srv 分开列) set(msg_files "msg/RobotStatus.msg" ) set(srv_files "srv/SetSpeed.srv" ) # 生成接口(核心命令) rosidl_generate_interfaces(${PROJECT_NAME} ${msg_files} ${srv_files} DEPENDENCIES builtin_interfaces ) # 接口包不需要普通可执行文件,直接 ament_package ament_package() ``` package.xml 也要加依赖: ```xml rosidl_default_generators builtin_interfaces rosidl_interface_packages ``` #### 4.6 在代码中使用自定义消息 **C++**: ```cpp #include "my_interfaces/msg/robot_status.hpp" #include "my_interfaces/srv/set_speed.hpp" // 使用消息 my_interfaces::msg::RobotStatus status; status.robot_name = "turtlebot"; status.battery_level = 0.85f; ``` **Python**: ```python from my_interfaces.msg import RobotStatus from my_interfaces.srv import SetSpeed status = RobotStatus() status.robot_name = "turtlebot" status.battery_level = 0.85 ``` #### 4.7 接口命令速查 ```bash # 查看系统所有可用接口类型 ros2 interface list # 查看某个消息/服务的定义 ros2 interface show my_interfaces/msg/RobotStatus ros2 interface show my_interfaces/srv/SetSpeed # 查看消息包含的字段 ros2 interface proto my_interfaces/msg/RobotStatus ``` ### 五、两种包类型全面对比 | 维度 | ament_python | ament_cmake | | --- | --- | --- | | 构建配置 | `setup.py` | `CMakeLists.txt` | | 语言 | Python | C/C++ | | 构建速度 | 快(复制) | 慢(编译) | | 改代码生效 | `--symlink-install` 后免构建 | 必须重新 build | | 性能 | 较低 | 高 | | 适合场景 | 原型、脚本、快速迭代 | 性能敏感、实时控制、复杂算法 | | 接口包支持 | 一般不用于定义接口 | 必须(rosidl 依赖 CMake) | | 单元测试 | pytest | gtest | | 依赖声明 | 同一 package.xml | 同一 package.xml | **关键结论**: 1. **接口包只能用 ament_cmake 定义**(rosidl 生成器依赖 CMake),Python 包可以消费接口但不能定义。 2. 一个工作空间可以混合两种包,colcon 自动识别各自构建方式。 3. 代码要跨语言复用时,接口定义放独立接口包,业务实现再分语言包。 ### 六、实战:接口包 + 双语言消费者 #### 6.1 完整演练 ```bash # 1. 创建接口包 cd ~/ros2_ws/src ros2 pkg create my_interfaces --build-type ament_cmake \ --license Apache-2.0 --maintainer-name you --maintainer-email you@example.com --description "Custom interfaces" # 2. 写入 msg/RobotStatus.msg(见 4.3) # 3. 写入 srv/SetSpeed.srv(见 4.4) # 4. 按 4.5 修改 CMakeLists.txt 与 package.xml # 5. 构建接口包 cd ~/ros2_ws colcon build --packages-select my_interfaces source install/setup.bash # 6. 验证接口可用 ros2 interface show my_interfaces/msg/RobotStatus ``` #### 6.2 用命令行发一个自定义话题(无代码验证) ```bash # 发布自定义消息(需要提供完整字段值) ros2 topic pub /robot_status my_interfaces/msg/RobotStatus \ "{robot_name: 'turtle', battery_level: 0.9, status_code: 1, stamp: {sec: 0, nanosec: 0}}" --rate 1 # 另开终端订阅查看 ros2 topic echo /robot_status ``` 如果终端能正常收发你的自定义消息,说明接口包定义、生成、环境加载全部正确——这是最快的"接口自检"方式。 #### 6.3 常见接口错误排查 | 现象 | 原因 | 解决 | | --- | --- | --- | | `ros2 interface show` 找不到 | 接口包没构建或没 source | `colcon build` + `source install/setup.bash` | | 编译报 `No such file or directory` | include 路径错 | C++ 用 `包名/msg/文件名.hpp` 全路径 | | 运行时 `ImportError: No module named 'my_interfaces'` | Python 环境没加载 | 确认 source 了 install 目录 | | 消息字段名对不上 | .msg 命名规范 | 字段一律小写下划线,重建接口包 | ### 七、总结 本篇文章拆解了 ROS2 包的完整结构:`package.xml` 的五种依赖声明、Python 包 `setup.py` 的四个必须配置、CMake 包 `CMakeLists.txt` 的三个易错点、自定义接口(`.msg`/`.srv`)的定义与生成流程,以及两种包类型的选型对比。 **关键要点回顾**: 1. `package.xml` 是包的必要身份证,依赖不确定时统一用 ``。 2. Python 包四必须:`name`、`packages`、`data_files`(ament 索引+package.xml)、`entry_points`。 3. CMake 包三易错:忘 `install(TARGETS)`、忘 `ament_target_dependencies`、忘 `find_package`。 4. 自定义接口用独立接口包(ament_cmake + format 3 + `rosidl_generate_interfaces`),支持跨语言消费。 5. `.msg`/`.srv` 字段小写下划线,`---` 分隔服务请求响应。 6. 接口自检最快方式:`ros2 interface show` + `ros2 topic pub/echo`。 ### 下一篇预告 下一篇正式进入 ROS2 通信核心:**话题 Topic 通信(上)——发布者与订阅者**。我们将深入话题机制(Publish/Subscribe 模型、去中心化发现、QoS 匹配),用 Python 与 C++ 各写一个发布者/订阅者对,理解 `create_publisher`、`create_subscription` 与回调机制,并掌握 `ros2 topic` 全套调试命令。 --- ## ROS2系列教程:工作空间与colcon构建 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-gong-zuo-kong-jian-yu-colcon-gou-jian/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 3 篇 > 本文是 ROS2 系列教程的第 3 篇:工作空间与 colcon 构建。前两篇我们认识了节点与 ROS 图,并运行了最简节点,但那些脚本还只是"散装"的。本篇文章将建立 ROS2 工程的**标准骨架**:工作空间(Workspace)、功能包(Package)与构建工具 colcon。学完本篇,你就能创建自己的 ROS2 包并用 `ros2 run` 正式运行,为后续话题、服务等通信篇目铺好工程基础。C++ 与 Python 两种包的构建方式都会讲到。 ### 一、为什么要工作空间与 colcon #### 1.1 从散装脚本到工程化 想象你写了 10 个节点脚本,散落在不同目录:有的用绝对路径引用,有的依赖别人手动 `export PYTHONPATH`,有的改了代码却不生效……这就是"散装"开发。ROS2 用三件套解决这个问题: - **工作空间(Workspace)**:所有包的统一存放目录,包含 `src/build/install/log` 四个子目录。 - **功能包(Package)**:ROS2 的最小可复用单元,包含代码、依赖描述(package.xml)、构建规则(CMakeLists.txt 或 setup.py)。 - **colcon**:ROS2 的官方构建工具,负责编译、安装、生成环境变量。 一句话概括:**工作空间管目录,package 管单元,colcon 管构建**。三者配合,让"克隆一个包 → 构建 → 运行"成为标准动作。 #### 1.2 工作空间的四目录结构 ```text ros2_ws/ # 工作空间(名字可自定义,习惯用 ros2_ws) ├── src/ # 源代码目录:放所有 package 源码 ├── build/ # 构建中间产物(自动生成,别手动改) ├── install/ # 安装结果:可执行文件、库、Python 包(source 这里) └── log/ # 构建日志(自动生成) ``` 四个目录的分工极其清晰: | 目录 | 内容 | 是否手工维护 | 说明 | | --- | --- | --- | --- | | `src` | 各 package 源码 | 是 | 你唯一需要写代码的地方 | | `build` | CMake 中间文件、对象文件 | 否 | `colcon build` 自动生成 | | `install` | 编译安装后的产物 | 否 | 必须 `source install/setup.bash` 才能用 | | `log` | 构建与运行日志 | 否 | 排障时查看 | **关键认知**:ROS2 不会自动"看到" `src` 下的包,必须经过 `colcon build` 把包安装到 `install` 目录,再 `source install/setup.bash` 加载环境,`ros2 run` 才能找到你的可执行文件。 #### 1.3 colcon 与旧工具对比 ROS1 时代构建工具是 `catkin_make`/`catkin_tools`,ROS2 官方推荐 **colcon**。三者的核心差异: | 特性 | catkin_make | catkin_tools | colcon(推荐) | | --- | --- | --- | --- | | 增量构建 | 支持 | 支持 | 支持(默认) | | 只构建指定包 | 需 `-DCATKIN_WHITELIST_PACKAGES` | `--pkg` | `--packages-select` | | 多语言混合 | 一般 | 一般 | 好(CMake/Python/setuptools 统一) | | 日志管理 | 单日志 | 分包日志 | 分包日志 | | 与 ROS2 契合 | 否 | 否 | 是(官方推荐) | colcon 是 CMake、Python setuptools、ament 等构建系统的统一前端:它检测每个包的类型,自动选择底层构建工具,对外只暴露一致的命令行接口。 ### 二、创建第一个工作空间 #### 2.1 初始化工作空间 ```bash # 创建工作空间骨架(只需 src 目录,其余自动生成) mkdir -p ~/ros2_ws/src cd ~/ros2_ws # 在工作空间内编译(src 为空也能构建,生成 build/install/log) colcon build # 查看生成的结构 ls -la ``` 此时会看到自动生成的 `build/`、`install/`、`log/` 三个目录。`colcon build` 默认会递归扫描 `src` 下的所有 package 并逐个构建。 #### 2.2 source 环境变量 构建完成后,必须加载安装环境,命令才能找到包: ```bash source /opt/ros/humble/setup.bash # 系统级 ROS2 基础环境 source ~/ros2_ws/install/setup.bash # 工作空间级环境(覆盖/追加) ``` **注意顺序**:先 source 系统基础环境,再 source 自己的工作空间。为了让每次打开终端都自动加载,把这两行写入 `~/.bashrc`: ```bash echo 'source /opt/ros/humble/setup.bash' >> ~/.bashrc echo 'source ~/ros2_ws/install/setup.bash' >> ~/.bashrc source ~/.bashrc ``` `install/setup.bash` 本质上是在 PATH、PYTHONPATH、AMENT_PREFIX_PATH、LD_LIBRARY_PATH 等环境变量中追加本工作空间的路径。**理解这一点对排障至关重要**:如果 `ros2 run` 报"找不到包",十有八九是没 source 或 source 顺序错了。 #### 2.3 环境变量速查 ```bash # 查看当前已加载的所有 ROS2 包路径 echo $AMENT_PREFIX_PATH # 查看可执行文件搜索路径(应包含 install 目录) echo $PATH # 查看 Python 模块搜索路径(应包含 install 下的 Python 包) echo $PYTHONPATH # 查看 ROS2 发行版 echo $ROS_DISTRO # 输出 humble ``` ### 三、创建 Python 功能包 #### 3.1 用 ros2 pkg create 创建 ROS2 提供了标准脚手架命令,不要手动手写包结构(容易出错): ```bash cd ~/ros2_ws/src # 创建 Python 包 ros2 pkg create py_demo \ --build-type ament_python \ --node-name py_talker \ --license Apache-2.0 \ --maintainer-name your_name \ --maintainer-email your@email.com \ --description "A Python demo package" ``` 生成的目录结构: ```text py_demo/ ├── package.xml # 包元数据:名称、依赖、版本、许可 ├── setup.py # Python 包构建配置(entry_points 是关键) ├── setup.cfg # 安装路径配置 ├── resource/py_demo # ament 索引标记文件 ├── test/ # 测试目录(test_copyright.py 等) └── py_demo/ # Python 源码包(与包同名) └── __init__.py ``` #### 3.2 理解 setup.py 的 entry_points Python 包的"可执行入口"由 `setup.py` 的 `entry_points` 定义,这是 `ros2 run` 能找到命令的关键: ```python from setuptools import setup import os from glob import glob package_name = 'py_demo' setup( name=package_name, version='0.0.0', packages=[package_name], data_files=[ # 把 launch 文件、config 安装到共享目录(后面篇章用) ('share/ament_index/resource_index/packages', ['resource/' + package_name]), ('share/' + package_name, ['package.xml']), (os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')), ], install_requires=['setuptools'], zip_safe=True, maintainer='your_name', maintainer_email='your@email.com', description='A Python demo package', license='Apache-2.0', entry_points={ 'console_scripts': [ # 格式:命令名 = 模块路径:函数名 'py_talker = py_demo.py_talker:main', 'py_listener = py_demo.py_listener:main', ], }, ) ``` **核心规则**:`entry_points` 里的每一行 `cmd = module:func` 定义了一个命令行命令。`ros2 run py_demo py_talker` 就是执行 `py_demo.py_talker` 模块里的 `main()` 函数。 #### 3.3 编写节点并构建 在 `py_demo/py_talker.py` 中写入: ```python # py_demo/py_talker.py import rclpy from rclpy.node import Node class PyTalker(Node): def __init__(self): super().__init__('py_talker') self.timer = self.create_timer(1.0, self.tick) self.count = 0 def tick(self): self.count += 1 self.get_logger().info(f'Hello from py_talker #{self.count}') def main(): rclpy.init() node = PyTalker() rclpy.spin(nod e) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 构建与运行: ```bash cd ~/ros2_ws colcon build --packages-select py_demo source install/setup.bash ros2 run py_demo py_talker ``` 看到每秒打印 `Hello from py_talker #N` 即成功。`--packages-select` 只构建指定包,大型工作空间下比全量构建快得多。 ### 四、创建 C++ 功能包 #### 4.1 创建 ament_cmake 包 ```bash cd ~/ros2_ws/src ros2 pkg create cpp_demo \ --build-type ament_cmake \ --node-name cpp_talker \ --license Apache-2.0 \ --maintainer-name your_name \ --maintainer-email your@email.com \ --description "A C++ demo package" ``` C++ 包的结构: ```text cpp_demo/ ├── package.xml # 依赖声明(这里会加 rclcpp) ├── CMakeLists.txt # 构建规则(核心) ├── include/cpp_demo/ # 头文件目录 └── src/ └── cpp_talker.cpp # 源文件 ``` #### 4.2 理解 CMakeLists.txt C++ 包的一切构建规则都在 `CMakeLists.txt` 里,核心四段: ```cmake cmake_minimum_required(VERSION 3.8) project(cpp_demo) # 1. 启用编译器特性 if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic) endif() # 2. 查找依赖(rclcpp 是基础库,必须声明) find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) # 3. 生成可执行文件:目标名 + 源文件 add_executable(cpp_talker src/cpp_talker.cpp) # 4. 链接依赖库 + 安装(缺一不可) ament_target_dependencies(cpp_talker rclcpp) install(TARGETS cpp_talker DESTINATION lib/${PROJECT_NAME}) ament_package() ``` **注意**:如果不写 `install(TARGETS ...)`,即使构建成功,`ros2 run` 也找不到可执行文件——因为产物没被安装到 `install` 目录。 #### 4.3 编写节点并构建 `src/cpp_talker.cpp`: ```cpp // src/cpp_talker.cpp #include "rclcpp/rclcpp.hpp" #include class CppTalker : public rclcpp::Node { public: CppTalker() : Node("cpp_talker"), count_(0) { timer_ = this->create_wall_timer( std::chrono::seconds(1), std::bind(&CppTalker::tick, this)); } private: void tick() { count_++; RCLCPP_INFO(this->get_logger(), "Hello from cpp_talker #%ld", count_); } rclcpp::TimerBase::SharedPtr timer_; long count_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` 构建运行: ```bash cd ~/ros2_ws colcon build --packages-select cpp_demo source install/setup.bash ros2 run cpp_demo cpp_talker ``` 注意 C++ 与 Python 构建的差异:C++ 需要**编译**(耗时几秒到几十秒),Python 是**复制**(几乎瞬时)。所以改 C++ 代码必须重新 build,改 Python 代码同样要 build(虽然快),但可以用 `--symlink-install` 让 Python 包以软链接方式安装。 ### 五、colcon 常用选项精讲 #### 5.1 --packages-select:只构建指定包 ```bash # 只构建两个指定包 colcon build --packages-select py_demo cpp_demo # 忽略某些包(构建除 test_pkg 外的所有包) colcon build --packages-ignore test_pkg ``` 大型工作空间(几十个包)时,全量构建一次可能好几分钟,用 `--packages-select` 精准构建改动的包能大幅提速。 #### 5.2 --symlink-install:Python 开发神器 ```bash colcon build --symlink-install ``` 这个选项让 Python 包以**软链接**方式安装到 `install` 目录:改完代码**不用重新 build**,直接运行即是新代码。C++ 包不受影响(仍要编译)。Python 开发强烈建议加这个选项。 #### 5.3 --cmake-args 与 --event-handlers ```bash # 向底层 CMake 传递参数(比如 Debug 编译) colcon build --cmake-args -DCMAKE_BUILD_TYPE=Debug # 只显示错误信息(减少刷屏) colcon build --event-handlers console_direct+ 2>&1 | grep -i error ``` #### 5.4 常见构建问题速查 | 现象 | 原因 | 解决 | | --- | --- | --- | | `Package 'xxx' not found` | 缺依赖未安装 | `sudo apt install ros-humble-xxx` | | `ros2 run` 找不到包 | 没 source install | `source install/setup.bash` | | `ros2 run` 找不到命令 | entry_points 未配/未重新 build | 检查 setup.py 或 CMakeLists 的 install | | 改了 Python 不生效 | 没重新 build | `colcon build --packages-select 包名` 或加 `--symlink-install` | | `Command 'colcon' not found` | 没装 colcon | `sudo apt install python3-colcon-common-extensions` | | 构建报 CMake 缓存混乱 | 缓存脏 | 删除 `build/` 与 `install/` 对应包目录后重 build | ### 六、实战:一个包含双语言包的工作空间 #### 6.1 完整演练流程 ```bash # 1. 创建并初始化工作空间 mkdir -p ~/ros2_ws/src && cd ~/ros2_ws # 2. 创建 Python 包 + C++ 包 cd src ros2 pkg create py_demo --build-type ament_python --node-name py_talker --license Apache-2.0 --maintainer-name you --maintainer-email you@example.com --description "py demo" ros2 pkg create cpp_demo --build-type ament_cmake --node-name cpp_talker --license Apache-2.0 --maintainer-name you --maintainer-email you@example.com --description "cpp demo" cd .. # 3. 按上文补全 py_talker.py 与 cpp_talker.cpp 内容 # 4. 全量构建(加 symlink 方便 Python 调试) colcon build --symlink-install # 5. source 环境 source install/setup.bash # 6. 双语言节点同时跑 ros2 run py_demo py_talker & # Python 节点 ros2 run cpp_demo cpp_talker # C++ 节点 ``` 运行后 `ros2 node list` 应能看到两个节点同时在线,证明工作空间、双包、双语言全部打通。 #### 6.2 查看构建产物 ```bash # 查看包是否被 ament 索引 ros2 pkg list | grep -E 'py_demo|cpp_demo' # 查看包信息 ros2 pkg prefix py_demo # 包安装位置 ros2 pkg executables py_demo # 包内可执行命令 # 查看包依赖树 ros2 pkg xml py_demo # 打印 package.xml ``` #### 6.3 工作空间的管理建议 1. **每个包只做一件事**:话题包、服务包、工具包分开,方便复用。 2. **package.xml 依赖要写全**:缺依赖在别的机器上构建直接失败。 3. **launch 文件放 `launch/` 目录**:并在 `setup.py`/`CMakeLists.txt` 中安装(第 11 篇详讲)。 4. **版本控制**:`build/`、`install/`、`log/` 加入 `.gitignore`,只提交 `src/`。 ### 七、总结 本篇文章把 ROS2 从"脚本"提升到了"工程":理解了工作空间四目录结构、package 的概念与两种构建类型(ament_python / ament_cmake),掌握了 colcon 的完整用法,并通过一个双语言实战工作空间验证了全链路。 **关键要点回顾**: 1. 工作空间 = `src`(源码)+ `build`/`install`/`log`(自动生成),构建后必须 `source install/setup.bash`。 2. `ros2 pkg create` 是创建包的唯一推荐方式,别手写包结构。 3. Python 包核心在 `setup.py` 的 `entry_points`;C++ 包核心在 `CMakeLists.txt` 的 `add_executable + install(TARGETS)`。 4. `colcon build --packages-select 包名` 精准构建;`--symlink-install` 让 Python 改完即用。 5. 排障第一招:确认 `source` 顺序与环境变量 `AMENT_PREFIX_PATH`。 ### 下一篇预告 下一篇深入 **package 结构剖析**:逐字段讲解 `package.xml` 与 `CMakeLists.txt`/`setup.py` 的每个配置项,介绍自定义消息/服务接口包(`ament_cmake` 的 `.msg`/`.srv` 定义与生成),并对比 CMake 与 Python 两种包的异同。学完你能看懂任何开源 ROS2 包的目录结构,也知道如何正确声明依赖。 --- ## ROS2系列教程:认识节点与ROS图 URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-ren-shi-jie-dian-yu-ros-tu/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 2 篇 > 本文是 ROS2 系列教程的第 2 篇:认识节点与 ROS 图。上一篇文章我们搭建好了 Humble 环境并运行了第一个 Hello World 节点,本篇文章将深入理解 ROS2 的**最小单元——节点(Node)**,以及由节点连接而成的 **ROS 图(ROS Graph)**。我们依然采用 C++(rclcpp)与 Python(rclpy)双语言并讲,每一节都配有可运行的完整示例。 ### 一、节点与 ROS 图:核心概念 #### 1.1 什么是节点 在 ROS2 中,**节点(Node)** 是一个负责执行特定任务的独立进程或进程内模块。一个机器人系统通常由几十个节点组成:有的节点负责读取激光雷达数据,有的负责做定位,有的负责规划路径,有的负责驱动电机。每个节点只干一件事,把复杂系统拆解成大量小而专的单元,这是 ROS 设计哲学的核心——**单一职责 + 松耦合**。 一个节点至少包含以下基本要素: - **节点名称**:在整个 ROS 图中全局唯一(配合命名空间使用),例如 `/sensor/lidar`、`/navigation/nav2_controller`。 - **通信端点**:节点通过话题(Topic)、服务(Service)、动作(Action)、参数(Parameter)与其他节点交互,这些统称为通信端点。 - **执行逻辑**:节点内部的回调函数、定时器、主循环,决定了节点"做什么"。 类比理解:把 ROS2 比作一家公司,节点就是公司里的**员工**。每个员工有明确的岗位(节点名),员工之间通过内部通讯系统(话题/服务)协作,没有人是总管一切的中心(ROS2 去中心化)。 #### 1.2 什么是 ROS 图 **ROS 图(ROS Graph)** 是运行中的节点及其通信关系的抽象描述。它由三部分组成: | 要素 | 说明 | 例子 | | --- | --- | --- | | 节点(Node) | 图的顶点 | `/talker`、`/listener` | | 通信关系 | 图的边 | talker 发布话题 `/chatter` 给 listener | | 通信中间件 | DDS 层 | 负责实际传输、发现、QoS 协商 | 当你运行 `rqt_graph` 或在 RViz 中查看,就能看到一张可视化的 ROS 图:每个椭圆是一个节点,每条箭头是一条话题连接,箭头方向从发布者指向订阅者。这张图就是整个机器人系统的"组织架构图"。 ROS2 与 ROS1 最大的区别之一:ROS1 需要 `roscore`(ROS Master)作为中央注册表,所有节点必须向它注册;ROS2 **没有中心节点**,节点之间通过 DDS 的 P2P 发现协议(Discovery)自动互相发现、直接通信。因此 ROS2 天然支持多机分布式部署——不同机器上的节点只要在同一网络(或配置好 DDS 发现域),就能组成同一张 ROS 图。 #### 1.3 节点、进程与线程 初学者常混淆三个概念: - **进程(Process)**:操作系统层面的运行实例。ROS2 中一个进程可以包含一个节点,也可以包含多个节点(通过 Composable Node / 组件机制)。 - **节点(Node)**:ROS 层面逻辑单元,负责通信。一个进程里可以有多个节点。 - **线程(Thread)**:进程内的执行流。rclpy/rclcpp 的 `spin` 默认在单线程里轮流执行回调。 理解它们的层次:**进程包含节点,节点运行在线程上**。默认情况下每个节点在自己的进程里运行(一个进程 = 一个节点),这样崩溃隔离性最好;但当节点很多、通信频繁时,进程间通信开销大,就可以把多个节点组合进一个进程共享内存,这就是后面会讲的 **launch 与 Composable Node**。 ### 二、用 Python 创建第一个节点 #### 2.1 最简 rclpy 节点 先看一个只创建节点、不做任何通信的最简 Python 节点: ```python # minimal_node.py —— 最简 rclpy 节点 import rclpy from rclpy.node import Node class MyNode(Node): def __init__(self): # 第一个参数是节点名称,第二个参数是命名空间(可省略) super().__init__('my_python_node') self.get_logger().info('Python 节点已启动!') def main(args=None): rclpy.init(args=args) # 1. 初始化 rclpy node = MyNode() # 2. 创建节点实例 rclpy.spin(node) # 3. 进入事件循环(等待回调) node.destroy_node() # 4. 清理节点 rclpy.shutdown() # 5. 关闭 rclpy if __name__ == '__main__': main() ``` 运行方式: ```bash # 直接运行(前提:已 source 环境变量,且当前目录能找到该文件) python3 minimal_node.py ``` 你会看到终端打印出类似这样的日志: ``` [INFO] [1690000000.123456789] [my_python_node]: Python 节点已启动! ``` 由于 `rclpy.spin()` 会一直运行,程序不会退出(除非按 Ctrl+C)。这个节点现在没有任何话题和服务,但你可以在另一个终端用 `ros2 node list` 看到它。 #### 2.2 节点名称与命名空间 节点名称在 ROS 图中必须唯一。命名空间(Namespace)用于给节点分组,类似文件系统的目录结构。创建带命名空间的节点: ```python # namespace_node.py —— 演示命名空间 import rclpy from rclpy.node import Node def main(): rclpy.init() # 节点全名 = 命名空间 + 节点名,即 /robot_arm/controller node = Node('controller', namespace='/robot_arm') node.get_logger().info(f'节点全名: {node.get_name()}') node.get_logger().info(f'命名空间: {node.get_namespace()}') node.get_logger().info(f'带命名空间的全名: {node.get_fully_qualified_name()}') rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 运行后 `ros2 node list` 中会显示 `/robot_arm/controller`。注意:**节点全名(fully qualified name)= 命名空间 + 节点名**,命名空间以 `/` 开头,各级之间也用 `/` 分隔。 命名空间的典型用途:一台机器人上可能有左右两个机械臂,用 `/left_arm` 和 `/right_arm` 两个命名空间隔离,这样每侧内部的节点名可以相同而不冲突。 #### 2.3 定时器:让节点"活"起来 一个只会打印一次日志的节点太单调了。加入**定时器(Timer)**,让节点周期性地执行任务: ```python # timer_node.py —— 定时器节点 import rclpy from rclpy.node import Node class TimerNode(Node): def __init__(self): super().__init__('timer_node') self.count = 0 # 每 1.0 秒触发一次回调(注意单位是秒,浮点数) self.timer = self.create_timer(1.0, self.timer_callback) def timer_callback(self): self.count += 1 self.get_logger().info(f'第 {self.count} 次心跳') def main(): rclpy.init() node = TimerNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 运行后每秒钟打印一次心跳。定时器是 ROS2 节点最常用的"节拍器",后面所有周期发布(如传感器数据、控制指令)都建立在定时器之上。 ### 三、用 C++ 创建节点 #### 3.1 最简 rclcpp 节点 C++ 版本的节点结构更偏向显式生命周期管理: ```cpp // minimal_node.cpp —— 最简 rclcpp 节点 #include "rclcpp/rclcpp.hpp" class MyNode : public rclcpp::Node { public: MyNode() : Node("my_cpp_node") { RCLCPP_INFO(this->get_logger(), "C++ 节点已启动!"); } }; int main(int argc, char** argv) { rclcpp::init(argc, argv); // 1. 初始化 auto node = std::make_shared(); // 2. 创建节点(智能指针) rclcpp::spin(node); // 3. 事件循环 rclcpp::shutdown(); // 4. 关闭 return 0; } ``` 编译与运行(在 package 环境中,参考第 4 篇的 package 结构): ```bash colcon build --packages-select demo_node_cpp source install/setup.bash ros2 run demo_node_cpp minimal_node ``` #### 3.2 C++ 定时器节点 对应 Python 的定时器示例: ```cpp // timer_node.cpp —— C++ 定时器节点 #include "rclcpp/rclcpp.hpp" #include class TimerNode : public rclcpp::Node { public: TimerNode() : Node("timer_node_cpp"), count_(0) { // 1.0 秒周期,单位是 std::chrono::duration timer_ = this->create_wall_timer( std::chrono::seconds(1), std::bind(&TimerNode::timer_callback, this)); } private: void timer_callback() { count_++; RCLCPP_INFO(this->get_logger(), "第 %ld 次心跳", count_); } rclcpp::TimerBase::SharedPtr timer_; long count_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` 注意 C++ 的几个细节: - `create_wall_timer` 的时间参数是 `std::chrono::duration`,比 Python 的秒数更精确。 - 回调函数需要 `std::bind` 绑定到成员函数,或使用 lambda 捕获 `this`。 - 定时器和回调都要保存为成员变量(`timer_`),防止被垃圾回收/析构。 #### 3.3 Python 与 C++ 节点对比 | 维度 | Python (rclpy) | C++ (rclcpp) | | --- | --- | --- | | 初始化 | `rclpy.init()` | `rclcpp::init(argc, argv)` | | 创建节点 | `Node('name')` | `std::make_shared()` 或 `Node("name")` | | 事件循环 | `rclpy.spin(node)` | `rclcpp::spin(node)` | | 日志 | `self.get_logger().info()` | `RCLCPP_INFO(this->get_logger(), ...)` | | 定时器 | `create_timer(1.0, cb)` | `create_wall_timer(std::chrono::seconds(1), cb)` | | 生命周期 | 自动引用计数 | 手动 `init/shutdown` + 智能指针 | | 适用场景 | 原型验证、脚本、快速迭代 | 性能敏感、实时性要求高、生产部署 | **经验法则**:学概念用 Python 快;上真机、做控制用 C++ 稳。两者在 API 设计上高度对称,掌握了其中一个,另一个几乎是"翻译"关系。 ### 四、节点生命周期与执行模型 #### 4.1 生命周期五阶段 一个 ROS2 节点从出生到销毁,经历清晰的阶段(下图即执行流): ```text 初始化(init) → 构造(创建Node实例) → 配置(声明参数/创建端点) → 运行(spin事件循环) → 销毁(destroy/shutdown) ``` - **初始化**:`rclpy.init()` / `rclcpp::init()`,启动底层 DDS、解析命令行参数(如 `--ros-args`)。 - **构造**:调用构造函数,此时创建定时器、话题、服务等一切端点。 - **运行**:`spin()` 阻塞式 地轮询回调队列,有事件就执行,没事件就挂起。注意**构造期间回调不会执行**,所以不要在构造函数里做依赖回调结果的逻辑。 - **销毁**:`destroy_node()` + `shutdown()`,释放 DDS 资源、断开连接。程序退出前一定要走完这一步,否则可能出现"僵尸连接"或端口占用。 #### 4.2 spin 的本质:回调循环 理解 `spin` 是理解 ROS2 执行模型的关键。`spin` 做的事情可以简化成: ```text while (节点存活) { 从回调队列取出一个待执行的回调; // 可能来自话题消息、服务请求、定时器触发 执行该回调; // 默认单线程,一次只执行一个 } ``` 这意味着**回调的执行是串行的**。如果某个回调里写了 `sleep(10)` 或死循环,其他回调(包括定时器)都会卡住。这是一切"节点不响应了"问题的根源。因此: - 回调里**绝不能**做耗时操作(文件 IO、网络请求、复杂计算); - 耗时任务应放子线程,或用 `MultiThreadedExecutor`(多线程执行器)。 #### 4.3 多线程执行器 Python 示例(C++ 的 `rclcpp::executors::MultiThreadedExecutor` 思路相同): ```python # multi_thread.py —— 多线程执行器 import rclpy import threading from rclpy.node import Node from rclpy.executors import MultiThreadedExecutor class BusyNode(Node): def __init__(self): super().__init__('busy_node') self.timer_a = self.create_timer(0.5, self.cb_a) self.timer_b = self.create_timer(0.5, self.cb_b) def cb_a(self): # 模拟耗时 3 秒的阻塞操作 import time; time.sleep(3) self.get_logger().info('A 完成') def cb_b(self): self.get_logger().info('B 执行') def main(): rclpy.init() node = BusyNode() executor = MultiThreadedExecutor(num_threads=4) # 4 线程 executor.add_node(node) # 在子线程中运行执行器 t = threading.Thread(target=executor.spin, daemon=True) t.start() try: import time while True: time.sleep(1) except KeyboardInterrupt: executor.shutdown() rclpy.shutdown() if __name__ == '__main__': main() ``` 对比观察:用 `SingleThreadedExecutor` 时,B 会被 A 的 3 秒阻塞卡住;用 `MultiThreadedExecutor` 后,B 在另一个线程执行、正常响应。**多线程执行器是让"一个节点既能算又能响应"的标准解法**,但要注意线程安全问题(回调间共享数据要加锁)。 ### 五、节点参数与日志 #### 5.1 声明与读取参数 节点参数(Parameter)是节点的"配置项",运行时可动态修改。Python 声明参数: ```python # param_node.py —— 参数演示(Python) import rclpy from rclpy.node import Node from rclpy.parameter import Parameter class ParamNode(Node): def __init__(self): super().__init__('param_node') # 声明参数:名称 + 默认值 + 描述 self.declare_parameter('robot_name', 'turtle_01') self.declare_parameter('max_speed', 2.5) self.declare_parameter('verbose', True) # 读取参数(返回 Parameter 对象,用 .value 取值) self.robot_name = self.get_parameter('robot_name').value self.max_speed = self.get_parameter('max_speed').value self.get_logger().info( f'机器人: {self.robot_name}, 最大速度: {self.max_speed}') def main(): rclpy.init() node = ParamNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 命令行设置参数后运行: ```bash ros2 run param_node --ros-args -p robot_name:=my_rover -p max_speed:=3.0 ``` #### 5.2 C++ 参数(对照) ```cpp // param_node.cpp —— 参数演示(C++) #include "rclcpp/rclcpp.hpp" class ParamNode : public rclcpp::Node { public: ParamNode() : Node("param_node_cpp") { this->declare_parameter("robot_name", "turtle_01"); this->declare_parameter("max_speed", 2.5); auto name = this->get_parameter("robot_name").as_string(); auto speed = this->get_parameter("max_speed").as_double(); RCLCPP_INFO(this->get_logger(), "机器人: %s, 最大速度: %.1f", name.c_str(), speed); } }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` C++ 读取参数后要用 `as_string()`、`as_double()` 等方法转成具体类型。参数系统是"配置与代码分离"的基石:**同一份代码,通过不同参数启动,就能适配不同机器人**。 #### 5.3 日志级别与过滤 ROS2 日志分五个级别(从低到高):`DEBUG < INFO < WARN < ERROR < FATAL`。默认只输出 INFO 及以上的日志。在终端设置日志级别: ```bash ros2 run timer_node --ros-args --log-level DEBUG ``` Python 中控制日志: ```python # 在节点内输出各级日志 node.get_logger().debug('调试信息(默认不显示)') node.get_logger().info('普通信息') node.get_logger().warn('警告信息') node.get_logger().error('错误信息') node.get_logger().fatal('致命错误') ``` **实践建议**:开发期用 DEBUG 记录详细流程,发布前降回 INFO;不要用 `print` 代替 `get_logger`——日志带时间戳、节点名、级别,还能统一过滤和重定向到 ros2 bag,这是 print 做不到的。 ### 六、实战:多节点协作小项目 #### 6.1 项目结构 下面组织一个"心跳-监视"双节点项目:`heartbeat_node` 每秒发布心跳次数,`monitor_node` 每 3 秒查询一次(用参数传递心跳值)。为演示方便,这里用两个 Python 文件 + 一个 launch 文件。 ```text multi_node_demo/ ├── heartbeat.py # 心跳节点(定时器 + 参数) ├── monitor.py # 监视节点(定时器读取参数) └── start.launch.py # launch 文件同时启动两个节点 ``` #### 6.2 心跳节点 ```python # heartbeat.py —— 心跳节点 import rclpy from rclpy.node import Node class HeartbeatNode(Node): def __init__(self): super().__init__('heartbeat') self.declare_parameter('rate', 1.0) # 心跳频率参数 self.declare_parameter('heartbeat', 0) # 心跳计数(共享用) self.count = 0 rate = self.get_parameter('rate').value self.timer = self.create_timer(rate, self.beat) def beat(self): self.count += 1 # 把计数写回参数(同进程内其他节点可读,简单演示用) self.set_parameters([rclpy.parameter.Parameter( 'heartbeat', rclpy.parameter.Parameter.Type.INTEGER, self.count)]) self.get_logger().info(f'心跳 #{self.count}') def main(): rclpy.init() node = HeartbeatNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` #### 6.3 监视节点 ```python # monitor.py —— 监视节点 import rclpy from rclpy.node import Node class MonitorNode(Node): def __init__(self): super().__init__('monitor') self.declare_parameter('heartbeat', 0) # 每 3 秒检查一次心跳值 self.timer = self.create_timer(3.0, self.check) def check(self): hb = self.get_parameter('heartbeat').value self.get_logger().info(f'当前心跳计数: {hb}') def main(): rclpy.init() node = MonitorNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` > 说明:真实项目中节点间共享数据应该用**话题**(第 5 篇详讲),这里用参数只是演示"参数可在进程内共享"的机制。多进程间参数不共享,正式通信请用话题/服务。 #### 6.4 launch 文件同时启动 ```python # start.launch.py —— 同时启动心跳与监视节点 from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node(package='demo_nodes_py', executable='heartbeat', name='heartbeat'), Node(package='demo_nodes_py', executable='monitor', name='monitor'), ]) ``` 运行: ```bash # 把 heartbeat.py / monitor.py 放入 demo_nodes_py 包并 colcon build 后: ros2 launch multi_node_demo start.launch.py # 另开终端查看节点: ros2 node list ``` #### 6.5 ros2 node 指令全览 日常排障最常用的节点相关命令: ```bash ros2 node list # 列出所有节点 ros2 node info /heartbeat # 查看节点详细信息(话题/服务/参数/动作) ros2 node list --verbose # 带类型信息列出 ros2 param list /heartbeat # 查看节点参数 ros2 param get /heartbeat rate # 获取单个参数值 ros2 param set /heartbeat rate 2.0 # 运行时修改参数 ``` 其中 `ros2 node info <节点全名>` 是最重要的诊断命令,它会列出该节点发布/订阅的话题、提供的服务、声明的参数等,排障时第一件事就是看它。 ### 七、总结 本篇文章建立了 ROS2 的"最小世界观":**节点是功能单元,ROS 图是节点组成的协作网络,通信端点(话题/服务/参数/动作)是节点间的连接方式**。我们分别用 Python 与 C++ 创建了最简节点、定时器节点,理解了 `spin` 回调循环的本质与多线程执行器,最后用一个"心跳-监视"双节点项目串起了全部知识。 **关键要点回顾**: 1. 节点全名 = 命名空间 + 节点名,全局唯一。 2. `spin()` 是单线程串行执行回调,耗时操作会阻塞其他回调。 3. 定时器让节点周期工作,是传感器发布、控制循环的"节拍器"。 4. 参数实现配置与代码分离,运行时可动态修改。 5. 用 `get_logger()` 而非 `print`,用 `ros2 node info` 诊断节点状态。 6. ROS2 无中心节点,节点经 DDS 自动发现组成 ROS 图,天然支持多机分布式。 ### 下一篇预告 下一篇我们搭建**工作空间(Workspace)与 colcon 构建体系**:理解 src/build/install/log 四目录结构,学会创建自定义 package、配置 CMakeLists.txt 与 package.xml,并掌握 `colcon build` 的常用选项(`--packages-select`、`--symlink-install`、`--cmake-args` 等)。届时你的每一个节点都能用 `ros2 run` 正式运行,为后续话题、服务等通信篇目打好工程基础。 --- ## ROS2系列教程:Humble环境搭建与Hello World URL: https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-humble-huan-jing-da-jian-yu-hello/ License: CC-BY-NC-SA-4.0 ## 本文是 ROS2 系列教程的第 1 篇 > 本文是 ROS2 系列教程的第 1 篇:Humble 环境搭建与 Hello World。本系列参照「动手学 ROS2」Humble LTS 的知识体系,采用 C++(rclcpp)与 Python(rclpy)双语言并讲的方式,从零开始带你掌握 ROS2 的核心概念与实战技巧。每一篇都会给出可运行的完整示例,建议你在自己的机器上亲自动手实践。 ### 一、ROS2 是什么 ROS(Robot Operating System,机器人操作系统)并不是一个真正的操作系统,而是一套面向机器人与自动化系统的**分布式中间件框架**。它建立在 Linux 等宿主操作系统之上,为机器人软件提供统一的通信、调度、工具链与生态。 ROS2 是 ROS1 的全新重写版本,解决了 ROS1 在实时性、多机通信、安全性、跨平台以及长期支持方面的诸多痛点。本节先建立整体认知,不必着急记细节,后续章节会逐个展开。 #### 1.1 ROS1 与 ROS2 的核心区别 ROS1 面向学术与原型验证,虽成熟但存在先天限制;ROS2 面向工业级与生产部署,做了架构性升级。理解它们的差别,有助于我们理解 ROS2 设计中那些看似「多此一举」的概念。 | 维度 | ROS1 | ROS2 | | --- | --- | --- | | 通信中间件 | 自研 TCPROS / UDPROS | DDS(数据分发服务) | | 实时性 | 较弱,无 QoS 保障 | 强,支持 QoS 服务质量策略 | | 多机/分布式 | 依赖一台 ROS Master | 无需中心节点,DDS 自动发现 | | 跨平台 | 主要 Linux | Linux/Windows/macOS | | 安全性 | 无加密与身份认证 | 支持 SROS2 加密与认证 | | Python 消息分发 | 需要 rospy 全局状态 | 纯 Python + rclpy | | 长期支持 | 无 LTS 概念 | 有 LTS 版(如 Humble) | 最大的架构差异在于**去中心化**。ROS1 必须有一个 `roscore`(ROS Master)来维护节点注册与话题匹配,Master 一旦挂掉整个系统瘫痪;ROS2 基于 DDS 的 P2P 发现机制,节点之间直接协商通信,无需单点故障中心。这也是 ROS2 适合多机、长时间部署机器人的根本原因。 #### 1.2 DDS 与 QoS DDS(Data Distribution Service)是一个由 OMG 组织维护的**发布/订阅式实时通信标准**。DDS 本身蕴含了节点发现、可靠传输、权限控制、QoS 策略等能力,ROS2 选择 DDS 作为通信底座,使自己能直接复用这些工业级能力。 QoS(Quality of Service,服务质量)是 ROS2 通信的核心概念,它决定消息传输的可靠性与实时性。ROS2 常用的 QoS 策略包括: - **Reliability(可靠性)**:`RELIABLE` 尽力可靠送达(可能重传),`BEST_EFFORT` 尽力而为(不重传,适合高带宽传感器)。 - **Durability(持久性)**:`TRANSIENT_LOCAL` 让晚加入的订阅者也能收到最近的「最后一条」消息(常用于参数、静态 TF),`VOLATILE` 只收发布之后的消息。 - **History(历史)**:`KEEP_LAST(N)` 只保留最近 N 条,`KEEP_ALL` 保留全部。 - **Deadline / Lifespan / Liveliness**:用于超时、有效期与活性检测。 同一话题上的发布者与订阅者 QoS **必须兼容**才能通信。例如发布者用 `RELIABLE`,订阅者用 `BEST_EFFORT` 可以兼容(可靠侧向下兼容),但反过来则不行。第 9 篇会专门深入 QoS,这里先记住:**QoS 是 ROS2 中「看似啰嗦、实则关键」的设计**。 #### 1.3 版本与 LTS ROS2 每年发布一个版本(奇数为 LTS,偶数为半年版)。当前主流长期支持版是 **Humble Hawksbill(Humble,H 系列)**,支持到约 2027 年,也是 fishros「动手学 ROS2」教程采用的版本。其余常见版本如 Foxy、Galactic、Iron、Jazzy、Kilted 等。我们统一使用 **Ubuntu 22.04 + Humble** 这套最经典的组合,社区资料最丰富、踩坑最少。 ### 二、环境搭建 搭建 ROS2 Humble 环境的推荐方式是「在 Ubuntu 22.04 上安装」。如果你没有 Ubuntu 物理机,可考虑以下方案(按简单到复杂排列):WSL2、虚拟机(VirtualBox / VMware)、Docker。本教程以 **Windows 上的 WSL2 + Ubuntu 22.04** 为例,因为它既能利用 Windows 硬件,又让开发体验接近原生 Linux。 #### 2.1 WSL2 安装步骤 在 Windows 上打开「管理员 PowerShell」,依次执行: ```bash # 启用 WSL2 并安装 Ubuntu 22.04 wsl --install -d Ubuntu-22.04 ``` 重启计算机后,首次启动 Ubuntu-22.04 会让你设置 Linux 用户名与密码。安装完成后可更新系统软件包: ```bash sudo apt update && sudo apt upgrade -y ``` 如果你的 Windows 版本较老,`wsl --install` 可能不支持,需要手动启用 WSL 功能与虚拟机平台(控制面板 → 启用或关闭 Windows 功能 → 勾选「适用于 Linux 的 Windows 子系统」与「虚拟机平台」)。 #### 2.2 设置 locale 与软件源 ROS2 对 locale 有要求,必须先配置为 UTF-8 编码,否则中文与特殊字符会出问题: ```bash sudo apt install -y locales sudo locale-gen en_US en_US.UTF-8 sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 export LANG=en_US.UTF-8 ``` 然后设置 UTF-8 软源(如需)并安装基础依赖: ```bash sudo apt install -y software-properties-common curl gnupg2 lsb-release ``` #### 2.3 添加 ROS2 apt 源 以 Humble 为例,添加官方软件源: ```bash sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(source /etc/os-release && echo $UBUNTU_CODENAME) main" | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null ``` 国内网络较慢时,可以改用清华镜像源加速: ```bash sudo sh -c 'echo "deb [arch=$(dpkg --print-architecture)] https://mirrors.tuna.tsinghua.edu.cn/ros2/ubuntu $(lsb_release -sc) main" > /etc/apt/sources.list.d/ros2.list' ``` 更新索引后即可安装 ROS2 核心包。选择「ros-base」即可满足大部分教程需求,如需更多工具可安装完整版 `desktop`: ```bash sudo apt update sudo apt install -y ros-humble-desktop ``` `ros-humble-desktop` 包含核心库、Rviz、Gazebo、常用工具等,体积较大但开箱即用。仅学习入门可先装 `ros-humble-ros-base`。 #### 2.4 安装 colcon 与入门工具 构建工具 colcon 是 ROS2 的标配: ```bash sudo apt install -y python3-colcon-common-extensions ``` 常用工具链(视需要安装): ```bash sudo apt install -y ros-humble-ros2bag ros-humble-rviz2 python3-pip ``` #### 2.5 配置环境变量 每次打开新终端都要 `source` 环境脚本很麻烦,建议写进 `.bashrc`: ```bash echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc source ~/.bashrc ``` 如果你在 WSL2 中使用且需要图形界面(Rviz、Gazebo),可能需要启用 WSLg(Windows 11 默认支持)或配置 X11 转发。WSL2 通常已内置 WSLg,直接在终端运行 `rviz2` 即可看到窗口。 #### 2.6 验证安装 打开新终端,依次输入: ```bash # 方法一:打印版本 printenv ROS_DISTRO # 方法二:查看环境变量路径 echo $ROS_DISTRO # 方法三:查看已安装的可用包 ls /opt/ros/humble/ ``` 如果 `printenv ROS_DISTRO` 输出 `humble`,说明环境配置成功。你也可以先跑一个最简单的节点测试(见下节)。 ### 三、第一个 ROS2 程序:Hello World ROS2 程序的最小单元是**节点(Node)**。一个节点就是一个独立运行的进程,它通过话题、服务、动作等方式与其他节点通信。本节我们写一个最基础的节点,它只负责在启动时打印一句话,以此理解节点的最小骨架。 #### 3.1 Python 版 Hello World(rclpy) Python 版最直观,代码量最少。创建一个工作空间并新建一个包(包的创建细节见第 3、4 篇,这里先手动搭建最小结构): ```python # talker.py —— 一个最简的 Python 节点 import rclpy from rclpy.node import Node class HelloNode(Node): def __init__(self): super().__init__('hello_node') self.get_logger().info('Hello, ROS2! 我是 Python 节点') def main(args=None): rclpy.init(args=args) node = HelloNode() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 运行这个脚本前,需要先安装依赖。在已 source 环境的终端中: ```bash python3 -c "import rclpy" # 若报错需安装 python3 依赖 source /opt/ros/humble/setup.bash python3 talker.py ``` 你将看到日志输出 `Hello, ROS2! 我是 Python 节点`,同时进程会一直保持运行(因为 `rclpy.spin()` 让节点不断处理回调)。按 `Ctrl+C` 即可优雅退出。 #### 3.2 C++ 版 Hello World(rclcpp) C++ 版结构更规范,但需要 `CMakeLists.txt` 与 `package.xml`。这里先给出核心源码,完整包结构在第 3、4 篇讲解: ```cpp // hello_node.cpp —— 一个最简的 C++ 节点 #include "rclcpp/rclcpp.hpp" class HelloNode : public rclcpp::Node { public: HelloNode() : Node("hello_node") { RCLCPP_INFO(this->get_logger(), "Hello, ROS2! 我是 C++ 节点"); } }; int main(int argc, char **argv) { rclcpp::init(argc, argv); auto node = std::make_shared(); rclcpp::spin(node); rclcpp::shutdown(); return 0; } ``` 编译与运行(假设已用 colcon 构建并 source): ```bash # 构建 colcon build --symlink-install --packages-select my_pkg source install/setup.bash # 运行 ros2 run my_pkg hello_node ``` 同样,你会看到 `Hello, ROS2! 我是 C++ 节点` 的日志。 #### 3.3 两种语言的关键差异 对比上面两段代码,可以快速抓住 rclcpp 与 rclpy 的编写差异: | 维度 | C++ (rclcpp) | Python (rclpy) | | --- | --- | --- | | 头文件/导入 | `#include "rclcpp/rclcpp.hpp"` | `import rclpy` | | 初始化 | `rclcpp::init(argc, argv)` | `rclpy.init(args=args)` | | 节点创建 | `std::make_shared()` 或继承 `Node` | 继承 `rclpy.node.Node` | | 进入回调循环 | `rclcpp::spin(node)` | `rclpy.spin(node)` | | 退出清理 | `rclcpp::shutdown()` | `rclpy.shutdown()` | | 日志宏/方法 | `RCLCPP_INFO(...)` | `self.get_logger().info(...)` | | 管理方式 | 智能指针,需显式 spin | 对象自动生命周期 | C++ 更强调资源管理(智能指针 `shared_ptr`)与显式控制,性能更高、更适合实时与嵌入场景;Python 上手快、开发效率高、适合原型验证与快速迭代。本系列将始终同时给出两种语言的示例。 #### 3.4 认识 `spin`:回调循环的本质 无论是 `rclcpp::spin(node)` 还是 `rclpy.spin(node)`,其本质都是**进入一个不断处理回调的循环**。ROS2 节点是「事件驱动」的:它平时挂起,一旦有新的消息到达、定时器触发或服务请求进来,就执行对应的回调函数。`spin` 就是这个「等待并分发事件」的循环。 - 如果你不再调用 `spin`,节点的回调永远不会被触发(消息来了也处理不了)。 - `spin` 会一直阻塞,直到收到 `Ctrl+C` 或 `shutdown` 信号。 - 异步场景下也可用 `spin_some()`(轮询一次)或单独管理一个 `SingleThreadedExecutor`,这在第 8 篇回调组章节会深入。 ### 四、理解节点的生命周期与日志 #### 4.1 节点的生命周期 ROS2 节点有一个规范化的生命周期状态机(第 26 篇硬件控制会用到),但普通节点的大致流程是:**构造节点 → 注册功能(话题/服务/定时器)→ 进入 spin 回调 → 关闭清理**。理解「构造期执行一次、回调期反复执行、关闭期清理资源」这个节奏,是学好 ROS2 的关键。 ```python # lifecycle_demo.py —— 演示构造期与回调期的区别 import rclpy from rclpy.node import Node class LifecycleDemo(Node): def __init__(self): super().__init__('lifecycle_demo') # 构造期:只执行一次 self.get_logger().info('构造期执行一次') self.count = 0 # 创建一个定时器,每 1 秒触发一次回调 self.create_timer(1.0, self.on_timer) def on_timer(self): # 回调期:每秒执行一次 self.count += 1 self.get_logger().info(f'回调期执行第 {self.count} 次') def main(args=None): rclpy.init(args=args) node = LifecycleDemo() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 运行后,你会看到「构造期执行一次」只打一遍,而「回调期执行第 N 次」每秒递增。 #### 4.2 日志级别与过滤 ROS2 的日志系统支持分级过滤,常见的日志级别为 `DEBUG、INFO、WARN、ERROR、FATAL`。默认只显示 `INFO` 及以上。在 Python 中: ```python self.get_logger().set_level(rclpy.logging.LoggingSeverity.DEBUG) self.get_logger().debug('调试信息') self.get_logger().info('普通信息') self.get_logger().warn('警告') self.get_logger().error('错误') ``` 在 C++ 中: ```cpp RCLCPP_DEBUG(this->get_logger(), "调试信息"); RCLCPP_INFO(this->get_logger(), "普通信息"); RCLCPP_WARN(this->get_logger(), "警告"); RCLCPP_ERROR(this->get_logger(), "错误"); ``` 你也可以在命令行设置全局日志级别,例如 `--ros-args --log-level DEBUG`。 #### 4.3 命令行运行节点 作为运行节点最常用的一条指令是: ```bash # 运行某个包里的某个可执行节点 ros2 run ``` 例如运行我们上面创建的节点:`ros2 run my_pkg hello_node`。若只想看节点有哪些可执行文件,可用: ```bash ros2 pkg executables ``` ### 五、实战:组织一个小型节点项目 为了让你把前面的零散概念串起来,我们做一个「带定时器 + 命令行动态参数」的完整小项目。它会在定时器里持续输出计数,并允许通过命令行参数覆盖节点名称。 #### 5.1 完整代码(Python) ```python # timer_counter.py —— 定时计数器节点,支持命令行参数 import rclpy from rclpy.node import Node class TimerCounter(Node): def __init__(self): super().__init__('timer_counter') self.count = 0 self.timer = self.create_timer(0.5, self.on_timer) def on_timer(self): self.count += 1 self.get_logger().info(f'计数: {self.count}') def main(args=None): rclpy.init(args=args) node = TimerCounter() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main() ``` 用命令行参数覆盖节点名称: ```bash python3 timer_counter.py --ros-args --remap __node:=my_counter ``` 此时 `ros2 node list` 中该节点会显示为 `/my_counter`。`--remap __node:=xxx` 是 ROS2 中非常常用的重映射技巧。 #### 5.2 完整代码(C++) ```cpp // timer_counter.cpp #include "rclcpp/rclcpp.hpp" #include class TimerCounter : public rclcpp::Node { public: TimerCounter() : Node("timer_counter"), count_(0) { timer_ = this->create_wall_timer( std::chrono::milliseconds(500), std::bind(&TimerCounter::onTimer, this)); } private: void onTimer() { count_++; RCLCPP_INFO(this->get_logger(), "计数: %d", count_); } int count_; rclcpp::TimerBase::SharedPtr timer_; }; int main(int argc, char **argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared()); rclcpp::shutdown(); return 0; } ``` C++ 版同样可用 `--remap __node:=my_counter` 重命名节点,运行效果一致。 #### 5.3 节点管理常用指令 完成本节后,你应该能熟练使用以下命令来查看与操作节点: ```bash # 查看当前所有活动的节点 ros2 node list # 查看某个节点的详细信息(话题、服务、参数等) ros2 node info /timer_counter # 查看所有节点及其关联信息 ros2 node list -t ``` ### 六、总结 本节我们完成了 ROS2 的入门:理解 ROS2 与 ROS1 的架构区别、DDS 与 QoS 的概念、搭建 Humble 开发环境的基本流程,并用 C++ 与 Python 两种语言写出了第一个 Hello World 节点。重点掌握: - **ROS2 = 分布式中间件**,去中心化,DDS 通信底座,QoS 是核心。 - **环境搭建**:Ubuntu 22.04 + Humble,`source /opt/ros/humble/setup.bash`。 - **节点是最小单元**,`rclpy` 与 `rclcpp` 两种写法,`spin` 是回调循环。 - **日志与生命周期**:构造期一次、回调期反复、关闭期清理。 - **常用指令**:`ros2 run`、`ros2 node list/info`、`--remap`。 ### 下一篇预告 下一节我们将正式认识「节点与 ROS 图」,学习如何用 `ros2` 命令行、rqt 图形工具观察节点之间的连接关系,理解节点、话题、服务之间是如何通过 ROS 图组织起来的,并动手创建多个相互通信的节点。 --- ## C++综合实战:数据结构与算法实现及系列总结 URL: https://wsj-blog.pages.dev/posts/c-zong-he-shi-zhan-shu-ju-jie-gou-yu-suan-fa-shi-xian-ji-xi/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 30 篇,也是系列终章。本篇实战数据结构与算法:手写动态数组与链表、排序与查找算法、递归与分治,最后回顾全部 30 篇学习路径,覆盖 9 个完整示例代码。 ## 一、手写动态数组 ### 1.1 为什么手写容器 理解容器内部机制是 C++ 进阶的必经之路。手写一个简化版 `vector,体会内存管理、扩容与拷贝语义: ```cpp // my_vector.h —— 简化版动态数组 #pragma once #include using namespace std; template class MyVector { private: T* data; // 指向堆内存 size_t sz; // 当前元素个数 size_t cap; // 容量 void grow() { // 扩容:翻倍 cap = cap == 0 ? 4 : cap * 2; T* newData = new T[cap]; for (size_t i = 0; i < sz; ++i) newData[i] = data[i]; delete[] data; data = newData; } public: MyVector() : data(nullptr), sz(0), cap(0) {} ~MyVector() { delete[] data; } void push_back(const T& value) { if (sz == cap) grow(); data[sz++] = value; } void pop_back() { if (sz > 0) sz--; } T& oper ator[](size_t i) { return data[i]; } size_t size() const { return sz; } bool empty() const { return sz == 0; } }; int main() { MyVector v; for (int i = 1; i <= 10; ++i) v.push_back(i * i); cout << "size = " << v.size() << endl; for (size_t i = 0; i < v.size(); ++i) cout << v[i] << " "; // 1 4 9 16 25 36 49 64 81 100 cout << endl; return 0; } ``` `grow()` 在容量不足时翻倍扩容,均摊时间复杂度为 O(1)。注意实现析构函数释放堆内存避免泄漏。 ### 1.2 迭代器与范围 for 支持 给容器加上简单的迭代器支持: ```cpp // 在 MyVector 中补充迭代器 public: T* begin() { return data; } T* end() { return data + sz; } const T* begin() const { return data; } const T* end() const { return data + sz; } ``` 有了 `begin()`/`end()`,容器就能配合**范围 for 循环**: ```cpp MyVector nums; for (int i = 0; i < 5; ++i) nums.push_back(i * 3); for (int x : nums) { // 范围 for:自动使用 begin/end cout << x << " "; // 0 3 6 9 12 } cout << endl; ``` ## 二、链表实现 ### 2.1 单链表 ```cpp // linked_list.cpp —— 单链表 #incl ude using namespace std; struct Node { int data; Node* next; Node(int d) : data(d), next(nullptr) {} }; class LinkedList { private: Node* head; public: LinkedList() : head(nullptr) {} ~LinkedList() { Node* cur = head; while (cur) { Node* tmp = cur; cur = cur->next; delete tmp; // 逐个释放,避免泄漏 } } // 头插法 void insertHead(int value) { Node* n = new Node(value); n->next = head; head = n; } // 尾插法 void insertTail(int value) { Node* n = new Node(value); if (!head) { head = n; return; } Node* cur = head; while (cur->next) cur = cur->next; cur->next = n; } bool remove(int value) { if (!head) return false; if (head->data == value) { Node* tmp = head; head = head->next; delete tmp; return true; } Node* cur = head; while (cur->next && cur->next->data != value) cur = cur->next; if (!cur->next) return false; Node* tmp = cur->next; cur->next = tm p = head; head = head->next; delete tmp; return true; } Node* cur = head; while (cur->next && cur->next->data != value) cur = cur->next; if (!cur->next) return false; Node* tmp = cur->next; cur->next = tmp->next; delete tmp; return true; } void show() const { Node* cur = head; while (cur) { cout << cur->data << " -> "; cur = cur->next; } cout << "nullptr" << endl; } }; int main() { LinkedList list; list.insertHead(3); list.insertHead(2); list.insertHead(1); // 1 -> 2 -> 3 list.insertTail(4); // 1 -> 2 -> 3 -> 4 list.show(); list.remove(2); // 1 -> 3 -> 4 list.show(); return 0; } ``` 链表插入删除只需修改指针(O(1)),但随机访问需遍历(O(n));与数组恰好互补。 ### 2.2 链表 vs 动态数组 | 特性 | 动态数组 | 链表 | | --- | --- | --- | | 随机访问 | O(1) | O(n) | | 头部插入 | O(n)(需移动) | O(1) | | 尾部插入 | O(1)(均摊) | O(1) | | 内存 | 连续,缓存友好 | 分散,缓 | | 存不友好 | | | | 额外开销 | 少量(扩容时翻倍) | 每节点一个指针 | ## 三、排序算法 ### 3.1 快速排序 ```cpp // quick_sort.cpp —— 快速排序 #include #include using namespace std; int partition(vector& arr, int low, int high) { int pivot = arr[high]; // 取最后一个元素为基准 int i = low - 1; for (int j = low; j < high; ++j) { if (arr[j] < pivot) { i++; swap(arr[i], arr[j]); } } swap(arr[i + 1], arr[high]); return i + 1; } void quickSort(vector& arr, int low, int high) { if (low < high) { int pi = partition(arr, low, high); quickSort(arr, low, pi - 1); // 递归左半 quickSort(arr, pi + 1, high); // 递归右半 } } int main() { vector arr = {10, 7, 8, 9, 1, 5}; quickSort(arr, 0, (int)arr.size() - 1); for (int x : arr) cout << x << " "; cout << endl; // 1 5 7 8 9 10 return 0; } ``` 快速排序平均 O(n log n),通过**分治**思想每次把基准放到最终位置,两边递归。 ### 3.2 归并排序 ```cpp // merge_sort.cpp —— 归并排序 # include #include using namespace std; void merge(vector& arr, int left, int mid, int right) { vector tmp(right - left + 1); int i = left, j = mid + 1, k = 0; while (i <= mid && j <= right) tmp[k++] = (arr[i] <= arr[j]) ? arr[i++] : arr[j++]; while (i <= mid) tmp[k++] = arr[i++]; while (j <= right) tmp[k++] = arr[j++]; for (int t = 0; t < (int)tmp.size(); ++t) arr[left + t] = tmp[t]; } void mergeSort(vector& arr, int left, int right) { if (left >= right) return; int mid = (left + right) / 2; mergeSort(arr, left, mid); // 分 mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); // 治:合并两个有序段 } int main() { vector arr = {38, 27, 43, 3, 9, 82, 10}; mergeSort(arr, 0, (int)arr.size() - 1); for (int x : arr) cout << x << " "; cout << endl; // 3 9 10 27 38 43 82 return 0; } ``` 归并排序稳定、O(n log n),需要额外 O(n) 空间。快速排序空间 O(log n) 但**不稳定**。 ## 四、查找算法 ### 4.1 二分查找 ```cpp // binary_search.cpp —— 二分查找(要求数组有序) #include #include using namespace std; int binarySearch(const vector& arr, int target) { int left = 0, right = (int)arr.size() - 1; while (left <= right) { int mid = left + (right - left) / 2; // 防止溢出 if (arr[mid] == target) return mid; if (arr[mid] < target) left = mid + 1; else right = mid - 1; } return -1; // 未找到 } int main() { vector arr = {1, 3, 5, 7, 9, 11, 13}; cout << binarySearch(arr, 7) << endl; // 3(下标) cout << binarySearch(arr, 8) << endl; // -1(不存在) return 0; } ``` 二分查找每次排除一半区间,O(log n)。`mid = left + (right - left) / 2` 避免 `(left+right)` 溢出。 ## 五、实战:学生成绩管理系统(综合) 综合运用容器、排序、查找、lambda 与统计的完整案例: ```cpp // student_manager.cpp —— 学生成绩管理 #include #include #include #include #include using namespace std; struct Student { string name; int score; }; void showAll(const vector& students) { for (const auto& s : students) cout << s.name << " " << s.score << endl; } int main() { vector students = { {"张三", 85}, {"李四", 92}, {"王五", 78}, {"赵六", 95} }; // 1. 按分数降序排序(lambda 比较) sort(students.begin(), students.end(), [](const Student& a, const Student& b) { return a.score > b.score; }); cout << "按分数降序:" << endl; showAll(students); // 2. 计算平均分(accumulate 求和) double avg = accumulate(students.begin(), students.end(), 0.0, [](double sum, const Student& s) { return sum + s.score; }) / students.size(); cout << "平均分: " << avg << endl; // 3. 查找最高分 auto best = max_element(students.begin(), students.end(), [](const Student& a, const Student& b) { return a.score < b.score; }); cout << "最高分: " << best->name << " " << best->score << endl; // 4. 统计及格人数(count_if) int passed = count_if(students.begin(), students.end(), [](const Student& s) { return s.score >= 60; }); cout << "及格人数: " << passed << endl; return 0; } ``` ## 六、30 篇系列总结 ### 6.1 学习路径回顾 本系列从零到实战覆盖 C++ 全链路: | 阶段 | 篇目 | 核心内容 | | --- | --- | --- | | 入门 | 1-5 | 语法基础、控制流、函数、数组/指针、类初探 | | 进阶 | 6-10 | 类与对象、运算符重载、继承多态、内存管理、文件流 | | STL | 11-15 | 容器、迭代器、算法库、函数对象/Lambda、适配器 | | 模板 | 16-19 | 函数/类模板、特化与萃取、变参模板、完美转发 | | 现代C++ | 20-22 | C++11/14/17/20 新特性(智能指针、concepts、ranges) | | 并发 | 23-25 | 线程、互斥锁、条件变量、原子操作、线程池 | | 工程化 | 26-27 | CMake 构建、编译链接、GDB 调试、性能优化 | | 实战 | 28-30 | 图书管理系统、HTTP 客户端、数据结构与算法 | ### 6.2 学习建议 1. **动手写代码**:每篇示例都亲手编译运行,改参数观察行为变化。 2. **逐步进阶**:按阶段顺序学习,模板与并发依赖前面基础。 3. **结合工程**:用 CMake 组织项目,用 GDB/ASan 排查问题。 4. **阅读源码**:理解 `vector`/`string` 等标准库内部实现。 5. **持续练习**:LeetCode 算法题、开源项目阅读,把知识用起来。 ## 总结 作为系列终章,本篇实战了 **动态数组**(手写 vector 与扩容)、**链表**(插入/删除与内存管理)、**快速排序与归并排序**(分治思想)、**二分查找**,并用学生成绩管理系统综合运用 STL 算法,最后回顾了 30 篇完整学习路径。从 `Hello World` 到网络编程、从模板元编程到并发线程池,你已经系统掌握了 C++ 的核心知识体系。持续编码、持续学习,C++ 的世界还很大,加油! --- ## C++综合实战:网络编程入门与HTTP客户端 URL: https://wsj-blog.pages.dev/posts/c-zong-he-shi-zhan-wang-luo-bian-cheng-ru-men-yu-http-ke-hu/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 29 篇。上一篇完成了面向对象图书管理系统,本篇实战网络编程:Socket 基础与 TCP 通信、HTTP 协议解析、迷你 HTTP 客户端实现与简易服务器,覆盖 9 个完整示例代码。 ## 一、网络编程基础 ### 1.1 客户端/服务器模型与 Socket 网络通信的核心模型:**服务器**监听端口等待连接,**客户端**主动发起连接。**Socket(套接字)**是操作系统提供的网络编程接口,屏蔽底层协议细节: ``` 客户端 服务器 | | |---- connect() -------->| 监听 listen() | | 接受 accept() |<-- send/recv 数据 ---->| | | |---- close() ---------->| close() ``` TCP 提供**可靠、有序、面向连接**的字节流传输,HTTP、FTP、SSH 等协议都建立在 TCP 之上。 ### 1.2 TCP 客户端完整示例(Windows + Linux 通用思路) ```cpp // tcp_client.cpp —— 简易 TCP 客户端 #include #include using namespace std; // 说明:Windows 用 Winsock,Linux 用 POSIX socket,头文件不同。 // 为便于演示 ,这里给出 Linux/POSIX 版本核心代码。 #include #include #include int main() { int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { cerr << "创建 socket 失败" << endl; return 1; } sockaddr_in server{}; server.sin_family = AF_INET; server.sin_port = htons(8080); inet_pton(AF_INET, "127.0.0.1", &server.sin_addr); if (connect(sock, (sockaddr*)&server, sizeof(server)) < 0) { cerr << "连接失败" << endl; return 1; } cout << "已连接服务器!" << endl; const char* msg = "Hello Server!"; send(sock, msg, strlen(msg), 0); char buffer[1024] = {}; int n = recv(sock, buffer, sizeof(buffer), 0); if (n > 0) cout << "收到回复: " << buffer << endl; close(sock); return 0; } ``` 关键步骤:`socket()` 创建套接字 → `connect()` 连接服务器 → `send()`/`recv()` 收发数据 → `close()` 关闭。`htons` 把端口转为网络字节序。 ## 二、HTTP 协议 ### 2.1 HTTP 请求与响应格式 HTTP 是**请求-响应**模型:客户端发请求,服务器回响应。请求由「请求行 + 请求头 + 空行 + 请求体」组成 : ```http GET /index.html HTTP/1.1 ← 请求行:方法 路径 协议版本 Host: www.example.com ← 请求头(键: 值) User-Agent: MyCppClient/1.0 Accept: */* ← 空行(必须) (请求体,GET 通常为空) ``` 响应由「状态行 + 响应头 + 空行 + 响应体」组成: ```http HTTP/1.1 200 OK ← 状态行:版本 状态码 原因短语 Content-Type: text/html Content-Length: 1256 ... ← 响应体 ``` ### 2.2 常用 HTTP 方法与状态码 | 方法 | 用途 | 状态码 | 含义 | | --- | --- | --- | --- | | GET | 获取资源 | 200 OK | 成功 | | POST | 提交数据 | 201 Created | 已创建 | | PUT | 更新资源 | 301 Moved | 永久重定向 | | DELETE | 删除资源 | 404 Not Found | 资源不存在 | | HEAD | 只取响应头 | 500 Internal Server Error | 服务器错误 | ## 三、迷你 HTTP 客户端 ### 3.1 构造请求并解析响应 写一个简化版 HTTP 客户端:连接服务器、发送 GET 请求、读取并解析响应头与响应体: ```cpp // mini_http_client.cpp —— 迷你 HTTP 客户端(Linux 版核心逻辑) # include #include #include using namespace std; #include #include #include // 解析响应头中的 Content-Length int extractContentLength(const string& header) { size_t pos = header.find("Content-Length:"); if (pos == string::npos) return -1; pos += 15; // 跳过 "Content-Length: " size_t end = header.find("\r\n", pos); return stoi(header.substr(pos, end - pos)); } int main() { int sock = socket(AF_INET, SOCK_STREAM, 0); sockaddr_in server{}; server.sin_family = AF_INET; server.sin_port = htons(80); // HTTP 默认端口 inet_pton(AF_INET, "93.184.216.34", &server.sin_addr); // example.com if (connect(sock, (sockaddr*)&server, sizeof(server)) < 0) { cerr << "连接失败" << endl; return 1; } // 构造 GET 请求 string request = "GET / HTTP/1.1\r\n" "Host: example.com\r\n" "User-Agent: MiniCppClient/1.0\r\n" "Connection: close\r\n" // 响应后关闭连接 "\r\n"; send(sock, request.c_str(), request.size(), 0); // 读取响应 string response; char buffe r[4096]; int n; while ((n = recv(sock, buffer, sizeof(buffer), 0)) > 0) { response.append(buffer, n); } close(sock); // 分离响应头与响应体 size_t headerEnd = response.find("\r\n\r\n"); string header = response.substr(0, headerEnd); string body = response.substr(headerEnd + 4); cout << "===== 状态行 =====" << endl; cout << header.substr(0, header.find("\r\n")) << endl; cout << "===== 响应体前 200 字符 =====" << endl; cout << body.substr(0, 200) << endl; int contentLen = extractContentLength(header); cout << "Content-Length: " << contentLen << "(实际收到 " << body.size() << " 字节)" << endl; return 0; } ``` `Connection: close` 告诉服务器响应后关闭连接,客户端循环 `recv` 直到收到 0 字节即响应结束。 ### 3.2 用系统命令快速验证 ```bash # 命令行验证 HTTP(Linux/macOS) curl -v http://example.com/ curl -I http://example.com/ # 只取响应头 # Windows PowerShell Invoke-WebRequest -Uri http://example.com/ | Select-Object -ExpandProperty Content ``` 用 `curl` 对照自己实现的客户端输出,能直观确认请求格式是否正确。 ## 四、简易 TCP 服务器 ### 4.1 单线程服务器 ```cpp // tcp_server.cpp —— 简易 TCP 服务器(Linux 版) #include #include using namespace std; #include #include #include int main() { int server = socket(AF_INET, SOCK_STREAM, 0); // 允许端口复用(避免 TIME_WAIT 导致 bind 失败) int opt = 1; setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡 addr.sin_port = htons(8080); bind(server, (sockaddr*)&addr, sizeof(addr)); listen(server, 5); // 最大等待队列 5 cout << "服务器监听 8080 端口..." << endl; sockaddr_in client{}; socklen_t len = sizeof(client); int conn = accept(server, (sockaddr*)&client, &len); cout << "客户端已连接!" << endl; char buffer[1024] = {}; int n = recv(conn, buffer, sizeof(buffer), 0); if (n > 0) { cout << "收到: " << buffer << endl; const char* reply = "Hello from server!"; send(conn, reply, st rlen(reply), 0); } close(conn); close(server); return 0; } ``` 服务器四步:`socket()` → `bind()` 绑定端口 → `listen()` 开始监听 → `accept()` 接受连接。之后与客户端收发数据。 ### 4.2 简易 HTTP 服务器 把响应改为 HTTP 格式,就是一个迷你 Web 服务器: ```cpp // mini_http_server.cpp —— 返回固定 HTML 页面的 HTTP 服务器 #include #include using namespace std; #include #include #include int main() { int server = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; addr.sin_port = htons(8080); bind(server, (sockaddr*)&addr, sizeof(addr)); listen(server, 5); cout << "HTTP 服务器: http://127.0.0.1:8080/" << endl; while (true) { // 循环接受多个客户端 sockaddr_in client{}; socklen_t len = sizeof(client); int conn = accept(server, (sockaddr*)&client, &len); char buffer[4096] = {}; recv(c onn, buffer, sizeof(buffer), 0); // 读取请求(此处忽略内容) string body = "

Hello C++!

" "

这是一个 C++ HTTP 服务器

"; string response = "HTTP/1.1 200 OK\r\n" "Content-Type: text/html; charset=utf-8\r\n" "Content-Length: " + to_string(body.size()) + "\r\n" "Connection: close\r\n" "\r\n" + body; send(conn, response.c_str(), response.size(), 0); close(conn); } close(server); return 0; } ``` 浏览器访问 `http://127.0.0.1:8080/` 即可看到返回的页面。`Content-Length` 必须与实际 body 字节数一致,否则浏览器可能显示不完整。 ## 五、实战:带参数的 HTTP 请求 ### 5.1 GET 带查询参数 ```cpp // http_get_with_query.cpp —— 带查询参数的 GET(核心片段) #include #include using namespace std; #include #include #include // URL 编码(简单版):空格转 %20 string urlEncode(const string& s) { string result; for (char c : s) { if (c == ' ') result += "%20"; else result += c; } return result; } int main() { string keyword = "C++ 教程"; string path = "/search?q=" + urlEncode(keyword); string request = "GET " + path + " HTTP/1.1\r\n" "Host: example.com\r\n" "Connection: close\r\n" "\r\n"; cout << "发送请求:" << endl; cout << request << endl; cout << "(完整客户端逻辑同 3.1 节,此处演示请求构造)" << endl; return 0; } ``` 查询参数放在 `?` 之后、`&` 分隔多个键值对;特殊字符(空格、中文)需 URL 编码。 ### 5.2 常见问题排查 | 问题 | 可能原因 | 排查方法 | | --- | --- | --- | | 连接失败 | 端口/地址错误、服务器未启动 | `netstat -an | | 响应为空 | 请求格式错误、未发空行 | 用 curl 对照请求文本 | | 中文乱码 | 编码未指定 | 响应头加 `charset=utf-8` | | Content-Length 不匹配 | body 计算错误 | 用 `body.size()` 而不是手写数字 | | 端口占用 | 前次连接未释放 | `SO_REUSEADDR` 或等 TIME_WAIT 结束 | ## 总结 本篇讲解了 C++ 网络编程入门:**Socket 基础**(客户端/服务器模型与 TCP 通信四步)、**HTTP 协议**(请求/响应格式、方法、状态码)、**迷你 HTTP 客户端**(构造 GET 请求、解析响应头与 Content-Length)、**简易 TCP/HTTP 服务器**(bind/listen/accept 循环)与带查询参数的请求构造及常见问题排查。建议用 `curl` 对照验证,并在本机 8080 端口跑通客户端-服务器闭环。 下一篇将是系列终章 **C++ 综合实战:数据结构与算法实现及系列总结**(手写容器、排序算法与 30 篇回顾),敬请期待! --- ## C++综合实战:面向对象图书管理系统 URL: https://wsj-blog.pages.dev/posts/c-zong-he-shi-zhan-mian-xiang-dui-xiang-tu-shu-guan-li-xi/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 28 篇。上一篇完成了工程化两篇,本篇进入综合实战:用面向对象思想实现一个完整的图书管理系统,综合运用类与封装、继承与多态、STL 容器、文件持久化与菜单交互,覆盖 8 个完整示例代码。 ## 一、系统设计 ### 1.1 需求分析与类设计 图书管理系统需要管理:图书信息(书名/作者/价格/库存)、读者信息(姓名/借书列表)、借还书操作、用面向对象分层设计: | 类 | 职责 | 关键成员 | | --- | --- | --- | | Book | 图书实体 | title, author, price, stock | | Reader | 读者实体 | name, borrowed(借书列表) | | Library | 管理系统 | books(vector), readers(vector) | | Menu | 交互界面 | 显示菜单、接收命令 | ```cpp // book.h —— 图书类 #pragma once #include using namespace std; class Book { private: string title; string author; double price; int stock; // 库存数量 public: Book(string t, string a, double p, int s) : title(t), author(a), price(p), stock(s) {} string getTitle() const { return title; } string getAuthor() const { return a uthor; } double getPrice() const { return price; } int getStock() const { return stock; } bool borrow() { // 借出:库存减一 if (stock <= 0) return false; stock--; return true; } void returnBook() { stock++; } // 归还:库存加一 void show() const { cout << "《" << title << "》 作者:" << author << " 价格:" << price << " 库存:" << stock << endl; } }; ``` ### 1.2 类设计原则 `Book` 籺把数据(成员变量)与行为(借书/还书/展示)封装在一起,外部只能通过公共接口访问,`stock` 的增减逻辑集中在类内部,保证数据一致性。 ## 二、读者类与借还逻辑 ### 2.1 Reader 类 ```cpp // reader.h —— 读者类 #pragma once #include #include #include using namespace std; class Reader { private: string name; vector borrowed; // 借阅的书名列表 public: explicit Reader(string n) : name(n) {} string getName() const { return name; } bool borrowBook(const string& title) { if (borrowed.size() >= 3) { // 每人最多借 3 本 cout < < "借阅数量已达上限(3本)!" << endl; return false; } borrowed.push_back(title); return true; } bool returnBook(const string& title) { for (size_t i = 0; i < borrowed.size(); ++i) { if (borrowed[i] == title) { borrowed.erase(borrowed.begin() + i); return true; } } return false; // 没借过这本书 } void show() const { cout << "读者:" << name << " 已借 "; for (const auto& b : borrowed) cout << b << " "; cout << "(" << borrowed.size() << "本)" << endl; } }; ``` `vector` 存储借阅列表,`erase` 删除指定元素,借阅上限用 `size()` 判断。 ### 2.2 多态:基类与派生类 为了演示多态,把「图书」扩展出「电子书」子类,重写展示方式: ```cpp // ebook.h —— 电子书派生类 #pragma once #include "book.h" class EBook : public Book { private: double fileSizeMB; // 文件大小 public: EBook(string t, string a, double p, int s, double mb) : Book(t, a, p, s), fileSizeMB(mb) {} // 重写展示:多态调用(声明为 virtual) void show() const { cout << "[电子书] "; Book::show(); // 调用基类展示 cout << " 文件大小: " << fileSizeMB << " MB" << endl; } }; ``` 注意:`Book::show()` 需声明为 `virtual`,派生类重写后,通过基类指针/引用调用时会走虚函数表实现多态。 ## 三、Library 管理类 ### 3.1 图书与读者的增删查 ```cpp // library.h —— 管理系统 #pragma once #include #include #include using namespace std; class Library { private: vector books; vector readers; public: void addBook(const Book& b) { books.push_back(b); } void addReader(const Reader& r) { readers.push_back(r); } // 查找图书(返回指针,找不到返回 nullptr) Book* findBook(const string& title) { for (auto& b : books) { if (b.getTitle() == title) return &b; } return nullptr; } Reader* findReader(const string& name) { for (auto& r : readers) { if (r.getName() == name) return &r; } return nullptr; } void showAllBooks() const { cout << "===== 全部图书 =====" << endl; for (const auto& b : books) b.show(); } void showAllReaders() const { cout << "===== 全部读者 =====" << endl; for (const auto& r : readers) r.show(); } }; ``` ### 3.2 借书与还书流程 ```cpp // library_ops.h —— 借还操作 #pragma once #include "library.h" void doBorrow(Library& lib, const string& readerName, const string& bookTitle) { Reader* reader = lib.findReader(readerName); Book* book = lib.findBook(bookTitle); if (!reader) { cout << "读者不存在!" << endl; return; } if (!book) { cout << "图书不存在!" << endl; return; } if (book->borrow()) { // 1. 库存减一 if (reader->borrowBook(bookTitle)) { // 2. 记入借阅列表 cout << readerName << " 成功借出《" << bookTitle << "》" << endl; } else { book->returnBook(); // 3. 借阅超限则回滚库存 } } else { cout << "《" << bookTitle << "》库存不足!" << endl; } } void doReturn(Library& lib, const string& readerName, const string& bookTitle) { Reader* reader = lib.findReader(rea derName); Book* book = lib.findBook(bookTitle); if (!reader || !book) { cout << "读者或图书不存在!" << endl; return; } if (reader->returnBook(bookTitle)) { // 1. 移除借阅记录 book->returnBook(); // 2. 库存加一 cout << readerName << " 成功归还《" << bookTitle << "》" << endl; } else { cout << readerName << " 没有借过这本书!" << endl; } } ``` 借书是「两阶段提交」:先扣库存,成功再记借阅,失败则回滚,保证数据一致性。 ## 四、文件持久化 ### 4.1 保存到文件 用 `fstream` 把图书数据保存为文本文件,下次启动时加载: ```cpp // persistence.h —— 文件持久化 #pragma once #include #include #include "library.h" void saveToFile(const Library& lib, const string& filename) { ofstream fout(filename); if (!fout) { cerr << "无法打开文件!" << endl; return; } // 格式:书名|作者|价格|库存,每行一本书 // (为简化,此处只演示保存,实际可加读者数据) fout << "# 图书数据" << endl; fout.close(); cout << "数据已保存到 " << file name << endl; } bool loadFromFile(Library& lib, const string& filename) { ifstream fin(filename); if (!fin) return false; string line; while (getline(fin, line)) { if (line.empty() || line[0] == '#') continue; // 解析:书名|作者|价格|库存 stringstream ss(line); string title, author; double price; int stock; getline(ss, title, '|'); getline(ss, author, '|'); ss >> price; ss.ignore(); ss >> stock; lib.addBook(Book(title, author, price, stock)); } return true; } ``` ## 五、主程序与菜单 ### 5.1 完整主程序 ```cpp // main.cpp —— 图书管理系统主程序 #include #include #include "library.h" #include "library_ops.h" #include "persistence.h" using namespace std; void showMenu() { cout << "\n===== 图书管理系统 =====" << endl; cout << "1. 添加图书" << endl; cout << "2. 添加读者" << endl; cout << "3. 借书" << endl; cout << "4. 还书" << endl; cout << "5. 显示全部图书" << endl; cout << "6. 显示全部读者" << endl; cout << "7. 保存数据" << endl; cout << " 8. 加载数据" << endl; cout << "0. 退出" << endl; cout << "请选择: "; } int main() { Library lib; // 预置数据 lib.addBook(Book("C++ Primer", "Lippman", 128.0, 5)); lib.addBook(EBook("Effective C++", "Meyers", 68.0, 3, 12.5)); lib.addReader(Reader("张三")); lib.addReader(Reader("李四")); int choice = -1; while (choice != 0) { showMenu(); cin >> choice; string title, name; switch (choice) { case 1: cout << "书名/作者/价格/库存: "; { string a; double p; int s; cin >> title >> a >> p >> s; lib.addBook(Book(title, a, p, s)); } break; case 2: cout << "读者姓名: "; cin >> name; lib.addReader(Reader(name)); break; case 3: cout << "读者姓名 书名: "; cin >> name >> title; doBorrow(lib, name, title); break; case 4: cout << "读者姓名 书名: "; cin >> name >> title; doReturn(lib, name, title); break; case 5: lib.showAllBooks(); break; case 6: lib.showAllReaders(); break; case 7: saveToFile(lib, "library.txt"); break; case 8: if (loadFromFile(lib, "library.txt")) cout << "数据加载成功!" << endl; else cout << "没有可加载的数据文件" << endl; break; case 0: cout << "感谢使用,再见!" << endl; break; default: cout << "无效选项!" << endl; } } return 0; } ``` ### 5.2 运行效果示例 ``` ===== 图书管理系统 ===== 1. 添加图书 ... 请选择: 5 ===== 全部图书 ===== 《C++ Primer》 作者:Lippman 价格:128 库存:5 [电子书] 《Effective C++》 作者:Meyers 价格:68 库存:3 文件大小: 12.5 MB 请选择: 3 读者姓名 书名: 张三 C++ Primer 张三 成功借出《C++ Primer》 请选择: 5 《C++ Primer》 作者:Lippman 价格:128 库存:4 ← 库存减一 ``` ## 六、代码组织与 CMake ### 6.1 项目结构 ``` library/ ├── CMakeLists.txt ├── book.h ├── ebook.h ├── reader.h ├── library.h ├── library_ops.h ├── persistence.h └── main.cpp ``` ```cmake cmake_minimum_required(VERSION 3.16) project(Lib rarySystem LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(library main.cpp) # 头文件都在当前目录,无需额外配置 # 构建: cmake -B build && cmake --build build ``` ### 6.2 扩展建议 | 扩展方向 | 思路 | | --- | --- | | 数据持久化完整版 | 读者数据也存文件,支持恢复借阅关系 | | 图书分类 | 派生 PaperBook/EBook 子类并用多态容器 | | 搜索功能 | 按书名/作者模糊搜索(find_if + lambda) | | 借阅记录 | 记录借阅时间,超期提醒 | | 图形界面 | 改用 Qt/Web 前端替换控制台菜单 | ## 总结 本篇用**图书管理系统**完整实战了 C++ 面向对象核心技能:`Book`/`Reader` 类的**封装**(数据 + 行为)、`EBook` 派生类的**继承与多态**(virtual 重写)、`Library` 管理类的 **STL 容器**(vector + 查找)、借还书的**两阶段提交**保证数据一致、`fstream` **文件持久化**、以及菜单驱动的主程序与 CMake 组织。这套「分层设计 + 单一职责 + 数据一致性」的思想可直接迁移到任何管理类系统开发。 下一篇将讲解 **C++ 综合实战:网络编程入门与 HTTP 客户端**(Socket 编程、HTTP 协议解析与迷你客户端),敬请期待! --- ## C++工程化:编译链接、调试与性能优化 URL: https://wsj-blog.pages.dev/posts/c-gong-cheng-hua-bian-yi-lian-jie-tiao-shi-yu-xing-neng-you/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 27 篇。上一篇讲解了 CMake 构建,本篇深入工程化另一面:预处理/编译/汇编/链接四阶段、GDB 调试、内存检测(ASan/Valgrind)、性能剖析与优化技巧,覆盖 9 个完整示例代码。 ## 一、编译链接的四个阶段 ### 1.1 从源码到可执行文件 C++ 源码变为可执行文件经历四阶段:**预处理**(展开头文件与宏)→ **编译**(生成汇编)→ **汇编**(生成机器码)→ **链接**(合并符号,生成可执行文件): ```bash # 分步执行四阶段 g++ -E main.cpp -o main.i # 1. 预处理:展开 #include/#define g++ -S main.i -o main.s # 2. 编译:生成汇编代码 g++ -c main.s -o main.o # 3. 汇编:生成目标文件(机器码) g++ main.o -o app # 4. 链接:合并符号表生成可执行文件 # 或一步到位 g++ main.cpp -o app ``` ```cpp // main.cpp #include #define SQUARE(x) ((x) * (x)) using namespace std; int main() { cout << SQUARE(5) << endl; // 25,预处理揖宏被加地展开 return 0; } ``` 预处理阶段宏 `SQUARE(5)` 会变成 `((5) * (5))`;编译阶段检查 语法并生成汇编;链接阶段把 `cout` 等标准库符号与代码合并。 ### 1.2 常见编译错误与链接错误 | 错误类型 | 例子 | 原因 | | --- | --- | --- | | 预处理错误 | `No such file or directory` | 头文件路径不对 | | 编译错误 | `expected ';'` | 语法错误 | | 编译警告 | `unused variable` | 未使用变量(可加 -Wall 发现) | | 链接错误 | `undefined reference` | 函数声明了但没实现/没链接对应 .o | | 链接错误 | `multiple definition` | 同名全局变量/函数重复定义 | ```cpp // 链接错误示例:声明了但没实现 int calculate(); // 只有声明 int main() { return calculate(); // 链接时报 undefined reference } ``` ```cpp // 修复:提供实现或链接对应源文件 int calculate() { return 42; } ``` 链接错误通常提示 `undefined reference to 'xxx'`,检查是否忘了 `target_link_libraries` 或漏加 `.cpp` 文件。 ## 二、GDB 调试 ### 2.1 编译带调试信息 用 `-g` 编译保留符号与行号信息,GDB 才能显示源码级调试信息: ```bash g++ -g -O0 main.cpp -o app # -g 调试信息,-O0 禁用优化(避免行号错位) gdb ./app # 启动 GDB ``` ```cpp // bug.cpp —— 待调试程序 #include #include using namespace std; int divide(int a, int b) { return a / b; // b 为 0 时会崩溃 } int main() { vector v = {1, 2, 3}; for (int i = 0; i <= 3; ++i) { cout << v[i] << endl; // i=3 时越界访问 } cout << divide(10, 0) << endl; // 除以零 return 0; } ``` ### 2.2 常用 GDB 命令 ```bash # 常用命令速查 (gdb) break main # 在 main 函数设置断点 (gdb) break bug.cpp:8 # 在文件第 8 行设置断点 (gdb) run # 运行程序 (gdb) next # 单步执行(不进入函数) (gdb) step # 单步执行(进入函数) (gdb) print i # 打印变量 i 的值 (gdb) print v[2] # 打印容器元素 (gdb) backtrace # 查看调用栈 (gdb) continue # 继续运行到下一个断点 (gdb) quit # 退出 ``` 调试流程:`break` 设断点 → `run` 跑到断点 → `print` 检查变量 → `next`/`step` 单步追踪 → 定位 bug 后修复。 ## 三、内存检测 ### 3.1 地址消毒器(AddressSan itizer) ASan 是 GCC/Clang 内置的内存错误检测工具,能发现越界访问、使用已释放内存、内存泄漏等: ```bash # 编译时启用 ASan g++ -g -fsanitize=address -fno-omit-frame-pointer bug.cpp -o app_asan ./app_asan # 运行时检测内存错误并报告 ``` ```cpp // leak.cpp —— 故意泄漏内存 #include using namespace std; void leak() { int* p = new int[100]; // 忘记 delete[],泄漏 400 字节 p[0] = 1; cout << p[0] << endl; } int main() { leak(); return 0; } ``` ASan 运行后会输出类似 `ERROR: AddressSanitizer: heap-use-after-free` 或 `LeakSanitizer: detected memory leaks` 的报告,含精确的文件名与行号。 ### 3.2 Valgrind 内存检查(Linux) Valgrind 是另一款经典工具,不需要重新编译即可检测内存错误与泄漏: ```bash g++ -g leak.cpp -o leak valgrind --leak-check=full ./leak ``` 输出会列出泄漏位置:`definitely lost: 400 bytes in 1 blocks`,配合行号定位到 `new int[100]` 那行。修复方式:`delete[] p;` 或改用 `std::vector`/智能指针。 ## 四、性能剖析与优化 ### 4.1 用 chrono 计时定位瓶颈 ```cpp #include < iostream> #include #include #include using namespace std; int main() { vector data(10000000); for (int i = 0; i < (int)data.size(); ++i) data[i] = rand() % 1000000; auto begin = chrono::high_resolution_clock::now(); sort(data.begin(), data.end()); auto end = chrono::high_resolution_clock::now(); double ms = chrono::duration(end - begin).count(); cout << "排序耗时: " << ms << " ms" << endl; return 0; } ``` ### 4.2 编译器优化级别与常见优化技巧 ```bash # 优化级别对比 g++ -O0 app.cpp -o app # 不优化(调试用) g++ -O2 app.cpp -o app # 常用优化(默认发布级别) g++ -O3 app.cpp -o app # 激进优化 g++ -march=native app.cpp -o app # 针对本机 CPU 指令集优化 ``` ```cpp #include #include using namespace std; // 优化技巧 1:避免不必要的拷贝(用 const& 传参) string toUpper(const string& s) { // const& 不拷贝 string result = s; for (char& c : result) { if (c >= 'a' && c <= 'z') c -= 32; } return result; } // 优化技巧 2:循环外提(避免重复计算) void sumLoop (const int* arr, int n) { long long total = 0; for (int i = 0; i < n; ++i) total += arr[i]; // n 提前缓存 cout << total << endl; } // 优化技巧 3:用 reserve 预留容量,避免多次扩容 void buildVector() { vector v; v.reserve(1000000); // 预留容量,避免 realloc 拷贝 for (int i = 0; i < 1000000; ++i) v.push_back(i); } int main() { string s = "hello world"; cout << toUpper(s) << endl; return 0; } ``` ### 4.3 性能剖析工具(profiler) | 工具 | 平台 | 用途 | | --- | --- | --- | | gprof | Linux | 函数级调用次数与耗时统计 | | perf | Linux | 硬件计数器级性能分析 | | VTune | Intel | 综合性能剖析(函数/线程/缓存) | | Very Sleepy / Visual Studio Profiler | Windows | Windows 下函数级剖析 | ```bash # gprof 使用流程 g++ -pg app.cpp -o app # 编译时加 -pg ./app # 运行生成 gmon.out gprof ./app gmon.out # 生成分析报告 ``` 报告中按「自耗时」排序列出每个函数,优先优化占比最高的热点函数。 ## 五、实战:调试一个真实 Bug 综合运用 GDB 与 ASan 排查「越界 + 泄漏 + 逻辑错 误」三重问题: ```cpp // fixme.cpp #include #include using namespace std; int average(const vector& v) { int sum = 0; for (size_t i = 0; i <= v.size(); ++i) { // Bug1: <= 导致越界读 sum += v[i]; } int* tmp = new int[1]; // Bug2: 泄漏 tmp[0] = sum; int result = tmp[0] / (int)v.size(); // Bug3: 空 vector 除零 delete[] tmp; return result; } int main() { vector data = {10, 20, 30}; cout << "average = " << average(data) << endl; return 0; } ``` 排查流程: 1. `g++ -g -fsanitize=address fixme.cpp -o fixme` 编译并启用 ASan。 2. 运行 `./fixme`,ASan 报告 `heap-buffer-overflow`(Bug1:循环越界,行 `sum += v[i]`)。 3. 修正为 `i < v.size()`。 4. 再次运行,若还有问题用 GDB `break fixme.cpp:8` + `print i` 逐步确认。 5. 全部修复后确认无泄漏、无越界、结果正确(平均值为 20)。 ```cpp // 修复版 int average(const vector& v) { if (v.empty()) return 0; // 防御空容器 int sum = 0; for (size_t i = 0; i < v.size(); ++i) { // 修复:< 而非 <= su m += v[i]; } return sum / (int)v.size(); } ``` ## 总结 本篇讲解了 C++ 工程化的核心工具链:**四阶段编译链接**(预处理/编译/汇编/链接)与常见错误排查、**GDB 调试**(断点/单步/打印变量/调用栈)、**内存检测**(ASan 与 Valgrind 发现越界与泄漏)、**性能优化**(计时定位瓶颈、优化级别、避免拷贝/reserve、剖析工具),最后用一个三合一 Bug 实战串联排查流程。至此工程化两篇(26-27)完成。 下一篇进入 **C++ 综合实战:面向对象图书管理系统**(综合运用类、继承、多态与容器),敬请期待! --- ## C++工程化:CMake构建与多文件项目管理 URL: https://wsj-blog.pages.dev/posts/c-gong-cheng-hua-cmake-gou-jian-yu-duo-wen-jian-xiang-mu/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 26 篇。上一篇完成了并发编程三篇,本篇进入工程化阶段:CMake 构建系统从零到实战、多文件项目组织、可执行文件与静态/动态库构建、跨平台配置与安装部署,覆盖 10 个完整示例代码。 ## 一、为什么需要 CMake ### 1.1 从手动编译到构建系统 小型程序可以用一行 g++ 编译,但真实项目有几十上百个源文件、依赖第三方库、需要跨平台。构建系统自动处理:依赖关系、增量编译、链接、测试与安装: ```bash # 手动编译(文件一多就难以维护) g++ -std=c++17 -O2 main.cpp math.cpp utils.cpp -o app # 多平台差异(Linux 用 -pthread,Windows 用不同标志) g++ -std=c++17 main.cpp -pthread -o app # Linux ``` CMake 用一份 `CMakeLists.txt` 描述项目,自动生成各平台的构建文件(Makefile、Ninja、Visual Studio 工程等),是当前 C++ 项目的事实标准。 ### 1.2 最小 CMake 项目 ```cmake cmake_minimum_required(VERSION 3.16) # 最低版本要求 project(HelloWorld VERSION 1.0.0 LANGUAGES CXX) # 项目名与语言 set(CMAKE_CXX_STANDARD 17) # C++17 标准 set (CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(hello main.cpp) # 生成可执行文件 ``` ```cpp // main.cpp #include using namespace std; int main() { cout << "Hello CMake!" << endl; return 0; } ``` 构建命令:`cmake -B build`(配置,生成 build 目录)、`cmake --build build`(编译)、`./build/hello`(运行)。推荐**外部构建**(build 目录与源码分离),保持源码目录干净。 ## 二、多文件项目组织 ### 2.1 经典目录结构 ``` project/ ├── CMakeLists.txt # 顶层构建文件 ├── include/ # 头文件 │ └── math/ │ └── calc.h ├── src/ # 源文件 │ ├── main.cpp │ └── calc.cpp └── tests/ # 测试 └── test_calc.cpp ``` ```cpp // include/math/calc.h #pragma once namespace math { int add(int a, int b); int multiply(int a, int b); } ``` ```cpp // src/calc.cpp #include "math/calc.h" namespace math { int add(int a, int b) { return a + b; } int multiply(int a, int b) { return a * b; } } ``` ### 2.2 顶层 CMakeLists.txt 组织多目标 ```cmake cmake_min imum_required(VERSION 3.16) project(MathLib VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 头文件目录(供所有目标使用) include_directories(${PROJECT_SOURCE_DIR}/include) # 可执行文件 add_executable(app src/main.cpp src/calc.cpp) # 输出目录放到 build/bin set_target_properties(app PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) ``` ```cpp // src/main.cpp #include #include "math/calc.h" using namespace std; int main() { cout << "add(3,5) = " << math::add(3, 5) << endl; cout << "multiply(3,5) = " << math::multiply(3, 5) << endl; return 0; } ``` `include_directories` 把 `include/` 加入搜索路径,头文件用 `"math/calc.h"` 带子目录引用,避免同名冲突。 ## 三、静态库与动态库 ### 3.1 构建静态库(.a / .lib) 把 `calc.cpp` 编译为静态库,供多个程序复用: ```cmake cmake_minimum_required(VERSION 3.16) project(MathLib VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) include_directories(${PROJECT_SOURCE_DIR}/include) # 静态库:编译时把代码打进可执行文件 add_library(math_static STATIC src/calc.cpp) # 可执行文件链接静态库 add_executable(app src/main.cpp) target_link_libraries(app PRIVATE math_static) ``` 静态库在链接时被整体拷贝进可执行文件,产物独立可分发,但体积大、更新需重编。 ### 3.2 构建动态库(.so / .dll / .dylib) ```cmake cmake_minimum_required(VERSION 3.16) project(MathLib VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) include_directories(${PROJECT_SOURCE_DIR}/include) # 动态库:运行时加载,多个程序共享一份 add_library(math_shared SHARED src/calc.cpp) set_target_properties(math_shared PROPERTIES VERSION ${PROJECT_VERSION} SOVERSION 1) add_executable(app src/main.cpp) target_link_libraries(app PRIVATE math_shared) ``` 动态库在运行时加载,可被多个程序共享、可独立升级;但分发时需连同 `.so`/`.dll` 一起,且存在「依赖地狱」风险。 ### 3.3 库类型对比 | 特性 | 静态库 | 动态库 | | --- | --- | --- | | 链接时机 | 编译期(拷贝进可执行文件) | 运行期(加载) | | 产物体积 | 可执行文件大 | 可执行文件小 + 库文件 | | 更新部署 | 需重新编译可执行文件 | 替换库文件即可 | | | | | | 依赖管理 | 简单,自包含 | 需管理库依赖关系 | ## 四、常用 CMake 语法与配置 ### 4.1 条件编译与编译选项 ```cmake cmake_minimum_required(VERSION 3.16) project(ConfigDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 添加编译选项 add_compile_options(-Wall -Wextra) # Debug/Release 配置 if(CMAKE_BUILD_TYPE STREQUAL "Debug") message(STATUS "构建 Debug 版本(带调试信息)") add_compile_options(-g) else() message(STATUS "构建 Release 版本(优化)") add_compile_options(-O2) endif() # 自定义选项:是否启用测试 option(BUILD_TESTS "构建单元测试" ON) if(BUILD_TESTS) enable_testing() add_executable(test_calc tests/test_calc.cpp src/calc.cpp) add_test(NAME test_calc COMMAND test_calc) endif() add_executable(app src/main.cpp) ``` 配置时指定类型:`cmake -B build -DCMAKE_BUILD_TYPE=Release`。`option` 用于开关功能,配合 `add_test`/`enable_testing` 集成 CTest。 ### 4.2 查找第三方库 `find_package` 查找系统已安装的库,找不到时可配合 `FetchContent` 自动下载: ```cmake cmake_minimum_required(VERSION 3.16) pr oject(UseLib LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) # 方式一:查找系统安装的库 find_package(Threads REQUIRED) find_package(OpenSSL QUIET) # 方式二:自动下载依赖(如 nlohmann/json) include(FetchContent) FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.2/json.tar.xz) FetchContent_MakeAvailable(json) add_executable(app main.cpp) # 链接线程库与 json 头文件库 target_link_libraries(app PRIVATE Threads::Threads nlohmann_json::nlohmann_json) ``` ```cpp // main.cpp #include #include #include using namespace std; using json = nlohmann::json; int main() { thread t([] { cout << "线程运行" << endl; }); t.join(); json data = {{"name", "张三"}, {"age", 25}}; cout << data.dump() << endl; // {"age":25,"name":"张三"} return 0; } ``` `FetchContent` 让「依赖即源码」成为可能:构建时自动拉取并编译依赖,无需手动安装,配合 `find_package` 是现代 CMake 的最佳实践。 ## 五、实战:完整的多目标项目 综合运用可执行文件、静态库、测试与安装规则: ```cmake cmake_minimum_required( VERSION 3.16) project(FullProject VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 源文件自动收集(src 下所有 .cpp) file(GLOB_RECURSE SRC_FILES ${PROJECT_SOURCE_DIR}/src/*.cpp) # 核心库(静态) add_library(core STATIC ${SRC_FILES}) target_include_directories(core PUBLIC ${PROJECT_SOURCE_DIR}/include) # 主程序 add_executable(app src/main.cpp) target_link_libraries(app PRIVATE core) # 测试 enable_testing() add_executable(unit_tests tests/unit_tests.cpp) target_link_libraries(unit_tests PRIVATE core) add_test(NAME unit_tests COMMAND unit_tests) # 安装:make install 时拷贝产物与头文件 install(TARGETS app RUNTIME DESTINATION bin) install(TARGETS core ARCHIVE DESTINATION lib) install(DIRECTORY include/ DESTINATION include) ``` ```cpp // tests/unit_tests.cpp —— 简单断言测试 #include #include "math/calc.h" using namespace std; int main() { int failures = 0; if (math::add(2, 3) != 5) { cout << "FAIL: add" << endl; failures++; } if (math::multiply(2, 3) != 6) { cout << "FAIL: multiply" << endl; failures++; } if (failures == 0) { cout << "全部测试通过!" ld && cmake --build build && ctest` 三连命令。 下一篇将讲解 **C++ 工程化:编译链接、调试与性能优化**(预处理/编译/汇编/链接、GDB/内存检测与性能剖析),敬请期待! << endl; return 0; } return 1; } ``` ```cpp // src/main.cpp #include #include "math/calc.h" using namespace std; int main() { cout << "2+3=" << math::add(2, 3) << " 2*3=" << math::multiply(2, 3) << endl; return 0; } ``` 构建全流程:`cmake -B build && cmake --build build && ctest --test-dir build`(编译 + 运行测试),`cmake --install build --prefix /usr/local`(安装)。 ## 总结 本篇讲解了 C++ 工程化核心的 **CMake 构建系统**:最小项目与构建流程、多文件项目的目录组织与 `include_directories`、**静态库与动态库**的构建与选型(含对比表)、**条件编译/option/查找与自动拉取依赖**(find_package + FetchContent),最后用完整的「库 + 可执行文件 + 测试 + 安装」多目标项目实战串联全部知识。建议实际动手跑一遍 `cmake -B bui --- ## C++并发编程:异步任务与线程池实战 URL: https://wsj-blog.pages.dev/posts/c-bing-fa-bian-cheng-yi-bu-ren-wu-yu-xian-cheng-chi-shi-zhan/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 25 篇。上一篇讲解了条件变量与原子操作,本篇实战并发收尾:std::async 与 future、packaged_task 与 promise、线程池实现与任务调度,覆盖 9 个完整示例代码。 ## 一、异步任务(std::async 与 std::future) ### 1.1 std::async 启动异步任务 `std::async` 在 `` 头文件提供,把「启动线程 + 获取结果」封装成一行。返回 `std::future` 对象,调用 `get()` 阻塞等待结果: ```cpp #include #include #include using namespace std; int computeSum(int n) { int sum = 0; for (int i = 1; i <= n; ++i) sum += i; return sum; } int main() { // 异步启动(默认策略可能开新线程或复用当前线程) future f1 = async(launch::async, computeSum, 10000); future f2 = async(launch::async, computeSum, 5000); // 主线程继续做别的事 cout << "主线程工作中..." << endl; this_thread::sleep_for(chrono::milliseconds(100)); // get() 阻塞直到结果就绪 cout << "sum(1..10000) = " << f1.get() << endl; cout << "sum(1..5000) = " << f2.get() << endl; return 0; } ``` `launch::async` 强制新线程执行;`launch::deferred` 惰性执行(get 时才在调用线程运行);不传则实现自行选择。 ### 1.2 异常传递与 wait_for 超时 `future::get()` 会把异步任务中的异常重新抛出;`wait_for` 可非阻塞查询任务状态: ```cpp #include #include #include #include using namespace std; int riskyTask(int x) { if (x < 0) throw runtime_error("参数不能为负"); this_thread::sleep_for(chrono::milliseconds(300)); return x * 2; } int main() { future f = async(launch::async, riskyTask, -5); // 等待最多 100ms,看是否完成 auto status = f.wait_for(chrono::milliseconds(100)); if (status == future_status::timeout) cout << "任务未完成,继续等待..." << endl; try { int result = f.get(); // 异常在此抛出 cout << "结果: " << result << endl; } catch (const exception& e) { cout << "捕获异常: " << e.what() << endl; } return 0; } ``` `future_status` 有三种:`ready`(已完成)、`timeout`(超时未完成)、`deferred`(惰性未启动)。 ## 二、promise 与 packaged_task ### 2.1 std::promise 手动设置结果 `std::promise` 与 `std::future` 配对:promise 负责「生产结果」,future 负责「消费结果」。适合线程间传递单次计算结果: ```cpp #include #include #include #include using namespace std; void worker(promise p, string name) { this_thread::sleep_for(chrono::milliseconds(200)); p.set_value("你好, " + name + "!"); // 生产结果 } int main() { promise prom; future fut = prom.get_future(); thread t(worker, move(prom), "张三"); cout << "等待结果..." << endl; string result = fut.get(); // 阻塞直到 set_value cout << result << endl; t.join(); return 0; } ``` 注意:`promise` 必须 `move` 进线程(不可拷贝)。`set_value` 只能调用一次,重复调用会抛 `std::future_error`。 ### 2.2 std::packaged_task 包装可调用对象 `packaged_task` 把「可调用对象 + future」打包,适合把任务交给线程池或队列调度: ```cpp #include #include #include using namespace std; int multiply(int a, int b) { return a * b; } int main() { packaged_task task(multiply); future fut = task.get_future(); thread t(move(task), 6, 7); // 在线程中执行任务 cout << "6 * 7 = " << fut.get() << endl; t.join(); return 0; } ``` `packaged_task` 与 `std::function` 用法类似,区别是它自带 future 用于接收任务结果。 ## 三、线程池实现 ### 3.1 为什么要线程池 频繁创建/销毁线程开销大。线程池**预创建固定数量线程**,持续从任务队列取任务执行,大幅降低线程管理开销,也避免线程数无限增长。 ### 3.2 完整线程池实现 综合运用互斥锁、条件变量、函数对象与任务队列: ```cpp #include #include #include #include #include #include #include #include using namespace std; class ThreadPool { vector workers; queue> tasks; mutex mtx; condition_variable cv; bool stop; public: explicit ThreadPool(size_t threads) : stop(false) { for (size_t i = 0; i < threads; ++i) { workers.emplace_back([this] { while (true) { function task; { unique_lock lock(mtx); cv.wait(lock, [this] { return stop || !tasks.empty(); }); if (stop && tasks.empty()) return; task = move(tasks.front()); tasks.pop(); } task(); // 在锁外执行,避免阻塞其他线程取任务 } }); } } // 提交任务,返回 future 获取结果 template auto enqueue(F&& f, Args&&... args) -> future> { using Ret = invoke_result_t; auto task = make_shared>( bind(forward(f), forward(args)...)); future res = task->get_future(); { lock_guard lock(mtx); if (stop) throw runtime_error("线程池已停止"); tasks.emplace([task]() { (*task)(); }); } cv.notify_one(); return res; } ~ThreadPool() { { lock_guard lock(mtx); stop = true; } cv.notify_all(); for (auto& w : workers) w.join(); } }; int square(int x) { return x * x; } int main() { ThreadPool pool(4); vector> results; for (int i = 1; i <= 8; ++i) { results.push_back(pool.enqueue(square, i)); } for (auto& f : results) { cout << f.get() << " "; } cout << endl; return 0; } ``` 核心设计:工作线程循环 `cv.wait` 等待任务;`enqueue` 用 `packaged_task` 包装任务并返回 future;析构时置 `stop` 并 `notify_all`,所有线程优雅退出。锁外执行任务避免串行化。 ## 四、实战:并行归并排序 用线程池加速归并排序,体会「任务拆分 + 并行执行 + 结果合并」的完整异步模式: ```cpp #include #include #include #include #include #include #include #include #include using namespace std; // 简化版线程池(复用 3.2 的思路,此处直接递归拆分线程) mutex outMtx; void merge(vector& arr, int left, int mid, int right) { vector tmp(right - left + 1); int i = left, j = mid + 1, k = 0; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) tmp[k++] = arr[i++]; else tmp[k++] = arr[j++]; } while (i <= mid) tmp[k++] = arr[i++]; while (j <= right) tmp[k++] = arr[j++]; copy(tmp.begin(), tmp.end(), arr.begin() + left); } void parallelMergeSort(vector& arr, int left, int right, int depth = 0) { if (left >= right) return; int mid = (left + right) / 2; if (depth < 3) { // 深度限制,避免创建过多线程 auto f1 = async(launch::async, [&] { parallelMergeSort(arr, left, mid, depth + 1); }); auto f2 = async(launch::async, [&] { parallelMergeSort(arr, mid + 1, right, depth + 1); }); f1.get(); f2.get(); } else { // 深度足够时退化为串行 parallelMergeSort(arr, left, mid, depth + 1); parallelMergeSort(arr, mid + 1, right, depth + 1); } merge(arr, left, mid, right); } int main() { vector arr(100000); for (int i = 0; i < (int)arr.size(); ++i) arr[i] = rand() % 100000; auto begin = chrono::high_resolution_clock::now(); parallelMergeSort(arr, 0, (int)arr.size() - 1); auto end = chrono::high_resolution_clock::now(); double ms = chrono::duration(end - begin).count(); bool sorted = is_sorted(arr.begin(), arr.end()); cout << "排序完成: " << (sorted ? "是" : "否") << ",耗时 " << ms << " ms" << endl; return 0; } ``` 用深度限制控制并行度,避免 `async` 创建成百上千线程导致性能反降。深度 3 意味着最多 8 个并行段,配合归并保证正确合并。 ## 总结 本篇系统讲解了 C++ 异步与线程池:**std::async + future** 一行启动异步任务并获取结果(含异常传递与 wait_for 超时)、**promise/future** 手动生产-消费单次结果、**packaged_task** 把任务与 future 打包、**线程池**的核心实现(条件变量任务队列 + packaged_task 封装 + 优雅关闭)、以及并行归并排序实战。至此 C++ 并发三篇(23-25)全部完成,涵盖锁、原子、条件变量、异步与线程池全链路。 下一篇进入 **C++ 工程化:CMake 构建与多文件项目管理**,敬请期待! --- ## C++并发编程:条件变量与原子操作 URL: https://wsj-blog.pages.dev/posts/c-bing-fa-bian-cheng-tiao-jian-bian-liang-yu-yuan-zi-cao-zuo/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 24 篇。上一篇讲解了线程与互斥锁,本篇深入同步机制进阶:条件变量与生产者消费者模型、原子类型与内存序、无锁编程入门与读写锁,覆盖 9 个完整示例代码。 ## 一、条件变量(std::condition_variable) ### 1.1 为什么需要条件变量 互斥锁只能保证「同时只有一个人进房间」,但无法解决「等待某个条件成立」的问题。条件变量允许线程**阻塞等待**某个条件,直到另一线程**通知**它醒来,避免忙等待浪费 CPU: ```cpp #include #include #include #include using namespace std; mutex mtx; condition_variable cv; bool ready = false; void waiter() { unique_lock lock(mtx); cv.wait(lock, []{ return ready; }); // 阻塞直到 ready == true cout << "等待者被唤醒,开始工作" << endl; } void notifier() { this_thread::sleep_for(chrono::milliseconds(500)); { lock_guard lock(mtx); ready = true; // 修改条件必须在锁内 } cv.notify_one(); // 唤醒一个等待线程 } int main() { thread t1(waiter); thread t2(notifier); t1.join(); t2.join(); return 0; } ``` `wait(lock, predicate)` 的谓词重载等价于 `while (!pred()) wait(lock)`,能自动处理**虚假唤醒(spurious wakeup)**。 ### 1.2 生产消费者模型 条件变量最经典的应用场景。生产者往队列放数据并通知,消费者阻塞等待并从队列取数据: ```cpp #include #include #include #include #include using namespace std; mutex mtx; condition_variable cv; queue tasks; bool done = false; void producer() { for (int i = 1; i <= 5; ++i) { { lock_guard lock(mtx); tasks.push(i); cout << "生产: " << i << endl; } cv.notify_one(); // 通知消费者 this_thread::sleep_for(chrono::milliseconds(100)); } { lock_guard lock(mtx); done = true; } cv.notify_all(); // 唤醒所有消费者(处理结束) } void consumer(int id) { while (true) { unique_lock lock(mtx); cv.wait(lock, []{ return !tasks.empty() || done; }); if (!tasks.empty()) { int task = tasks.front(); tasks.pop(); cout << " 消费者 " << id << " 消费: " << task << endl; } else if (done) { break; // 生产结束且队列为空 } } } int main() { thread p(producer); thread c1(consumer, 1); thread c2(consumer, 2); p.join(); c1.join(); c2.join(); cout << "生产消费完成" << endl; return 0; } ``` **关键点**:`notify_one` 唤醒单个线程,`notify_all` 唤醒全部;等待条件必须用 `while`/谓词重载以防虚假唤醒;`done` 标志防止消费者永久阻塞。 ## 二、原子操作(std::atomic) ### 2.1 原子类型基础 `std::atomic` 提供无锁(或锁内部实现的)原子操作,`fetch_add`、`exchange`、`compare_exchange` 等保证读-改-写完整性,无需互斥锁: ```cpp #include #include #include #include using namespace std; atomic counter{0}; void increment() { for (int i = 0; i < 100000; ++i) counter.fetch_add(1); } int main() { vector threads; for (int i = 0; i < 4; ++i) threads.emplace_back(increment); for (auto& t : threads) t.join(); cout << "counter = " << counter.load() << endl; // 400000,无需加锁 return 0; } ``` `fetch_add` 原子完成「读-加-写」。`load()` 原子读取,`store()` 原子写入。 ### 2.2 compare_exchange 与自旋锁 `compare_exchange_strong` 是 CAS 指令的封装:当前值等于期望值时写入新值,否则更新期望值为实际值。可用它实现自旋锁: ```cpp #include #include #include #include using namespace std; class SpinLock { atomic flag{false}; public: void lock() { // 期望 false,尝试写入 true;失败则自旋重试 while (flag.exchange(true)) { // 空转等待(可加 this_thread::yield() 让出 CPU) } } void unlock() { flag.store(false); } }; SpinLock spin; int counter = 0; void work() { for (int i = 0; i < 50000; ++i) { lock_guard lock(spin); counter++; } } int main() { vector threads; for (int i = 0; i < 4; ++i) threads.emplace_back(work); for (auto& t : threads) t.join(); cout << "counter = " << counter << endl; // 200000 return 0; } ``` 自旋锁适合**临界区极短**的场景;临界区长时应使用会阻塞的 `std::mutex`,避免浪费 CPU。 ## 三、内存序(Memory Order) ### 3.1 为什么要关心内存序 编译器与 CPU 可能重排指令(单线程不可见,多线程可导致意外行为)。内存序控制重排边界: ```cpp #include #include #include using namespace std; atomic ready{false}; int data = 0; void producer() { data = 42; // 写数据 ready.store(tru e, memory_order_release); // 释放语义:之前的写操作全部可见 } void consumer() { while (!ready.load(memory_order_acquire)) { } // 获取语义:确保读到最新 data cout << "data = " << data << endl; // 保证读到 42 } int main() { thread t1(producer); thread t2(consumer); t1.join(); t2.join(); return 0; } ``` `release`(写侧)与 `acquire`(读侧)配对使用,形成**同步关系(happens-before)**,保证生产者写入的数据对消费者可见。 ### 3.2 常见内存序对比 | 内存序 | 语义 | 用途 | | --- | --- | --- | | memory_order_relaxed | 无同步,仅保证原子性 | 计数器、统计量 | | memory_order_acquire | 其后读写不可越过本操作 | 读取标志位 | | memory_order_release | 其前读写不可越过本操作 | 发布数据 | | memory_order_acq_rel | acquire + release | RMW 操作 | | memory_order_seq_cst | 全序一致(默认) | 复杂同步,易推理 | ```cpp #include #include #include using namespace std; atomic hits{0}; void report() { for (int i = 0; i < 1000000; ++i) { hits.fetch_add(1, memory_order_relaxed); // 只需原子性,无需同步 } } int main() { thread t1(report); thread t2(report); t1.join(); t2.join(); cout << "hits = " << hits.load(memory_order_relaxed) << endl; // 2000000 return 0; } ``` **经验**:默讙 seq_cst` 最容易正确,性能足够时优先使用;只有基准测试证明是瓶颈,才降级为 relaxed/acquire/release 并仔细论证正确性。 ## 四、实战:线程安全的任务队列 综合运用互斥锁、条件变量与 RAII 封装一个可直接复用的线程安全队列: ```cpp #include #inc lude #include #include #include #include using namespace std; template class ThreadSafeQueue { mutable mutex mtx; condition_variable cv; queue q; public: void push(T value) { { lock_guard lock(mtx); q.push(move(value)); } cv.notify_one(); } // 阻塞弹出 T pop() { unique_lock lock(mtx); cv.wait(lock, [this]{ return !q.empty(); }); T value = move(q.front()); q.pop(); return value; } // 非阻塞尝试弹出 optional tryPop() { lock_guard lock(mtx); if (q.empty()) return nullopt; T value = move(q.front()); q.pop(); return value; } size_t size() const { lock_guard lock(mtx); return q.size(); } }; int main() { ThreadSafeQueue tq; thread producer([&] { for (int i = 1; i <= 6; ++i) { tq.push(i); this_thread::sleep_for(chrono::milliseconds(50)); } }); thread consumer([&] { for (int i = 0; i < 6; ++i) { int v = tq.pop(); // 阻塞等待 cout << "取出: " << v << "(队列剩余 " << tq.size() << ")" << endl; } }); producer.join(); consumer.join(); cout << "线程安全队列测试完成" << endl; return 0; } ``` 该队列把锁与条件变量的复杂性封装在内部,对外提供 `push`/`pop`/`tryPop`/`size` 四个安全接口,是生产环境常用的基础组件。 ## 总结 本篇讲解了并发同步进阶技术:**condition_variable** 实现阻塞等待与通知(含谓词重载防虚假唤醒)、**生产者消费者模型**的完 整实现、**atomic 原子类型**(fetch_add/CAS 自旋锁)避免数据竞争、**内存序**(relaxed/acquire/release/seq_cst)控制可见性与重排,最后封装了一个线程安全的任务队列组件。建议配合互斥锁按场景选用:临界区短用原子,等待条件用条件变量,常规保护用 mutex。 下一篇将讲解 **C++ 并发编程:异步任务与线程池实战**(std::async、future、packaged_task 与线程池实现),敬请期待! --- ## C++并发编程:线程与互斥锁 URL: https://wsj-blog.pages.dev/posts/c-bing-fa-bian-cheng-xian-cheng-yu-hu-chi-suo/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 23 篇。上一篇讲解了 C++20 新特性,本篇进入并发编程世界:std::thread 线程创建与管理、互斥锁与数据竞争防护、死锁避免与锁的最佳实践,覆盖 9 个完整示例代码。 ## 一、线程基础(std::thread) ### 1.1 创建线程的三种方式 `std::thread` 在 `` 头文件提供。线程对象一旦构造完成立即开始执行,可传入函数指针、函数对象或 Lambda: ```cpp #include #include using namespace std; void worker(int id) { cout << "线程 " << id << " 开始工作" << endl; this_thread::sleep_for(chrono::milliseconds(200)); cout << "线程 " << id << " 工作结束" << endl; } int main() { // 方式一:函数指针 thread t1(worker, 1); // 方式二:函数对象 struct Task { void operator()(int n) const { cout << "函数对象任务 " << n << endl; } }; thread t2(Task{}, 2); // 方式三:Lambda thread t3([](int n) { cout << "Lambda 任务 " << n << endl; }, 3); t1.join(); t2.join(); t3.join(); cout << "主线程结束" << endl; return 0; } ``` `join()` 阻塞主线程直到子线程完成。必须对每个可 join 的线程调用 `join()` 或 `detach()`,否则析构时程序会 `std::terminate`。 ### 1.2 join、detach 与线程参数传递 ```cpp #include #include #include using namespace std; void printMsg(string msg, int times) { for (int i = 0; i < times; ++i) cout << msg << " " << i << endl; } int main() { // 参数按值传递;引用参数需用 std::ref thread t1(printMsg, "hello", 3); int counter = 0; thread t2([&counter] { for (int i = 0; i < 5; ++i) counter++; }); t1.join(); // detach:后台运行,不阻塞主线程 thread t3([]{ cout << "后台线程运行" << endl; }); t3.detach(); t2.join(); cout << "counter = " << counter << endl; return 0; } ``` 传引用给线程函数必须显式用 `std::ref` 包裹,否则会按值拷贝。`detach()` 后线程与主线程分离,访问已销毁的局部变量会引发未定义行为,需谨慎。 ## 二、数据竞争与互斥锁 ### 2.1 数据竞争演示 多个线程同时读写共享变量会产生**数据竞争(data race)**,结果 是未定义的: ```cpp #include #include #include using namespace std; int main() { int counter = 0; vector threads; for (int i = 0; i < 4; ++i) { threads.emplace_back([&counter] { for (int j = 0; j < 100000; ++j) counter++; }); } for (auto& t : threads) t.join(); // 期望 400000,实际可能远小于该值(数据竞争) cout << "counter = " << counter << endl; return 0; } ``` `counter++` 不是原子操作(读-改-写三步),多线程交错执行导致计数丢失。运行多次会得到不同结果。 ### 2.2 用 std::mutex 保护临界区 `std::mutex` 提供互斥访问,`lock()`/`unlock()` 成对使用。更推荐用 RAII 封装的 `std::lock_guard` 或 `std::unique_lock`,异常安全且不会忘记解锁: ```cpp #include #include #include #include using namespace std; mutex mtx; int counter = 0; void safeIncrement() { lock_guard lock(mtx); // 构造时加锁,析构时自动解锁 counter++; } int main() { vector threads; for (int i = 0; i < 4; ++i) { threads.emplace_back([] { for (int j = 0; j < 100000; ++j) safeIncrement(); }); } for (auto& t : threads) t.join(); cout << "counter = " << counter << endl; // 稳定输出 400000 return 0; } ``` ### 2.3 lock_guard 与 unique_lock 对比 | 特性 | lock_guard | unique_lock | | --- | --- | --- | | 加锁方式 | 构造即锁,不可手动解锁 | 可延迟加锁、手动解锁 | | 移动语义 | 不支持 | 支持 | | 配合条件变量 | 不适用(需 unlock) | 适用 | | 开销 | 更小 | 略大(状态标志) | ```cpp #include #include #include using namespace std; mutex mtx; int shared = 0; void doWork(bool critical) { unique_lock lock(mtx, defer_lock); // 延迟匠锁 // ... 偹一些非临界区准备工作 ... lock.lock(); // 需要时前办宜标锁 shared++; lock.unlock(); // 提前解锁 // ... 临界区之外y�: %y��y.#yc��e yllyd�H����B��[�XZ[� Hˆ�XYJ��ܚ��YJNˆ�XY���ܚ��[�JNˆK���[� Nˆ����[� Nˆ��]��\�YH��\�Y[�ˆ�]\��ŸB����9."x� y�f�e y.#�`o�acy ��略 ## 3.1 死锁的产生 两个线程各持有一把锁,又互相等待对方释放,形成循环等待即死锁: ```cpp #include #include #include using namespace std; mutex mtxA, mtxB; void thread1() { lock_guard a(mtxA); this_thread::sleep_for(chrono::milliseconds(10)); lock_guard b(mtxB); // 等待 mtxB(被线程2持有) cout << "线程1完成" << endl; } void thread2() { lock_guard b(mtxB); this_thread::sleep_for(chrono::milliseconds(10)); lock_guard a(mtxA); // 等待 mtxA(被线程1持有)→ 死锁 cout << "线程2完成" << endl; } int main() { thread t1(thread1); thread t2(thread2); t1.join(); t2.join(); return 0; } ``` 程序将永远卡住。**死锁四要素**:互斥、持有并等待、不可剥夺、循环等待,破坏任一即可避免。 ### 3.2 用 std::lock 一次性锁定多把锁 `std::lock` 可原子性地同时锁定多个互斥体,避免死锁;再配合 `std::scoped_lock`(C++17)更简洁: ```cpp #include #include #include using namespace std; mutex mtxA, mtxB; void safeWork(int id) { // scoped_lock 同时锁定两把锁(内部用 std::lock 算法,无死锁) scoped_lock lock(mtxA, mtxB); cout << "线程 " << id << " 同时持有两把锁执行" << endl; this_thread::sleep_for(chrono::milliseconds(20)); } int main() { thread t1(safeWork, 1); thread t2(safeWork, 2); t1.join(); t2.join(); cout << "无死锁,正常结束" << endl; return 0; } ``` 实践原则:**全局统一加锁顺序**(如总是先 A 后 B),或使用 `std::lock`/`scoped_lock` 同时锁定多个锁,避免循环等待。 ## 四、实战:多线程任务累加器 综合运用线程、互斥锁、锁保护与计时的小工具,把一个大任务拆成多个线程并行计算: ```cpp #include #include #include #include #include using namespace std; mutex mtx; long long total = 0; // 每个线程计算一段 [start, end] 的累加 void partialSum(long long start, long long end) { long long local = 0; for (long long i = start; i <= end; ++i) local += i; lock_guard lock(mtx); // 只保护最后合并结果 total += local; } int main() { const long long N = 10000000; // 1 加到 1千万 const int numThreads = 4; vector threads; long long chunk = N / numThreads; auto begin = chrono::high_resolution_clock::now(); for (int t = 0; t < numThreads; ++t) { long long start = t * chunk + 1; long long end = (t == numThreads - 1) ? N : (t + 1) * chunk; threads.emplace_back(partialSum, start, end); } for (auto& th : threads) th.join(); auto end = chrono::high_resolution_clock::now(); double ms = chrono::duration(end - begin).count(); // 理论值 N*(N+1)/2 cout << "结果: " << total << "(期望 50000005000000)" << endl; cout << "耗时: " << ms << " ms,线程数: " << numThreads << endl; return 0; } ``` 关键技巧:每个线程先算**局部结果**,只在合并时动销,把锁竞争降到最低,比每个元素都动销快几个数量级。 ## 总结 本篇讲解了 C++ 并发编程基础:**std::thread** 的创建/join/detach 与参数传递、**数据竞争**的危害、**std::mutex** 与 RAII 封装(lock_guard / unique_lock / scoped_lock)保护共享数据、**死锁**的产生与避免(统一加锁顺序 / std::lock 同时锁定),最后用一个多线程累加器演示「局部计算 + 短临界区合并」的性能最佳实践。 下一篇将讲解 **C++ 并发编程:条件变量与原子操作**(生产消费者模型、condition_variable、atomic 与内存序),敬请期待! --- ## C++20新特性精讲:概念、范围与三路比较 URL: https://wsj-blog.pages.dev/posts/c-20-xin-te-xing-jing-jiang-gai-nian-fan-wei-yu-san-lu-bi/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 22 篇。上一篇讲解了 C++17 新特性,本篇深入 C++20 新特性:概念(concepts)、范围(ranges)、三路比较(spaceship)、协程与模块等,覆盖 8 个完整示例代码。 ## 一、概念(Concepts) ### 1.1 为什么需要概念 C++20 之前,模板参数的约束只能依赖文档和编译期报错。概念(Concept)把「类型必须满足的约束」变成一等公民,让编译器给出清晰错误,也让重载决议更精确: ```cpp #include #include #include using namespace std; // 定义概念:类型必须可相加,且结果可转换为目标类型 template concept Addable = requires(T a, U b) { { a + b } -> convertible_to; }; // 使用概念约束模板参数 template T add(T a, T b) { return a + b; } int main() { cout << add(3, 4) << endl; // 7(int 满足 Addable) cout << add(1.5, 2.5) << endl; // 4(double 满足) // cout << add(string("a"), 1); // 编译错误:string+int 不可相加 return 0; } ``` 概念由 `concept` 儳央字声明,`requires` 表达式描述约束。约束失败时,编译器会指出「不满足哪个概念」而不是给出深奥的模板实例化错误。 ### 1.2 标准概念库 `` 头文件提供大量现成概念:`integral`、`floating_point`、`signed_integral`、`same_as`、`convertible_to`、`derived_from`、`assignable_from` 等: ```cpp #include #include #include using namespace std; // 限定参数必须是整型 template T twice(T x) { return x * 2; } // 多个约束组合(合取) template requires (sizeof(T) >= 4) void printWide(T x) { cout << x << " 至少4字节" << endl; } int main() { cout << twice(21) << endl; // 42(int 是整型) // cout << twice(3.14) << endl; // 错误:double 不是 integral printWide(100); // 满足 sizeof(int)>=4 return 0; } ``` 约束可以写在 `template<...>` 后直接附加(如 `template `),也可以用 `requires` 子句补充更复杂条件。 ## 二、范围(Ranges) ### 2.1 管道运算符初体验 `` 引入惰性求值的**视图(View)**,配合管道运算符 `|` 让数据处理像流水� �一样清晰。视图不拷贝元素,按需计算: ```cpp #include #include #include #include using namespace std; int main() { vector v(10); iota(v.begin(), v.end(), 1); // 1..10 // 取偶数 -> 平方 -> 只取前3个,一气呵成 auto result = v | views::filter([](int n) { return n % 2 == 0; }) // 2 4 6 8 10 | views::transform([](int n) { return n * n; }) // 4 16 36 64 100 | views::take(3); // 4 16 36 for (int x : result) cout << x << " "; // 4 16 36 cout << endl; return 0; } ``` `views::filter` 与 `views::transform` 返回**惰性视图**:只有遍历时才真正计算,且不产生临时容器,性能优于手写中间容器。 ### 2.2 ranges 算法与投影 `std::ranges` 命名空间重新实现了全部 STL 算法,支持直接传容器、支持投影(projection)和哨兵(sentinel): ```cpp #include #include #include #include #include using namespace std; struct Student { string name; int score; }; int main() { vector students = { {"张三", 85}, {"李四", 92}, {"王五", 78}, {"赵六", 95} }; // 直接传容器(旧版要 begin/end),投影到 score 排序 ranges::sort(students, ranges::greater{}, &Student::score); for (auto& s : students) { cout << s.name << ": " << s.score << endl; } // 查找最高分 auto it = ranges::max_element(students, {}, &Student::score); cout << "最高分: " << it->name << " " << it->score << endl; return 0; } ``` 投影 `&Student::score` 让算法在排序/比较时只看该成员,代码更简洁、意图更明确。 ## 三、三路比较(Spaceship) ### 3.1 飞船操作符 <=> 三路比较操作符 `<=>` 一次比较即可得到「小于 / 等于 / 大于」三种结果,返回 `strong_ordering`(强序)或 `partial_ordering`(弱序,允许 NaN 等): ```cpp #include #include using namespace std; int main() { auto r = 3 <=> 5; if (r < 0) cout << "3 < 5" << endl; else if (r == 0) cout << "3 == 5" << endl; else cout << "3 > 5" << endl; // 返回类型 cout << typeid(decltype(3 <=> 5)).name() << endl; // strong_ordering cout << type id(decltype(3.0 <=> 5.0)).name() << endl; // partial_ordering return 0; } ``` ### 3.2 默认三路比较自动生成全套运算符 只要重载 `operator<=>`(并声明为 default),编译器自动生成 `<`、`<=`、`==`、`!=`、`>=`、`>` 全部 6 个运算符,省去大量样板代码: ```cpp #include #include #include #include using namespace std; struct Point { int x, y; // 按 x 再按 y 自动比较 auto operator<=>(const Point&) const = default; }; int main() { vector pts = {{3, 1}, {1, 9}, {2, 5}}; sort(pts.begin(), pts.end()); // 直接可用 < 排序 for (auto& p : pts) cout << "(" << p.x << "," << p.y << ") "; cout << endl; Point a{1, 2}, b{1, 3}; cout << (a < b) << endl; // 1(x 相等,比 y) cout << (a != b) << endl; // 1 return 0; } ``` 成员按声明顺序逐项比较(字典序)。`= default` 的 `<=>` 要求所有成员都支持 `<=>` 或自带比较运算符。 ## 四、协程与模块(C++20 重要特性) ### 4.1 协程:co_await 与生成器 C++20 协程让「可暂停/可恢复」的函数成为语言特性。虽然标准库生成器要等 C++23,但可以借助 `std::generator` 的替代品或自建极简协程。以下用 C++20 编译的最小协程示例(需要协程支持库): ```cpp #include #include using namespace std; // 极简 generator:每次 co_yield 产生一个整数 struct Generator { struct promise_type { int current; Generator get_return_object() { return Generator{this}; } suspend_always initial_suspend() { return {}; } suspend_always final_suspend() noexcept { return {}; } suspend_always yield_value(int v) { current = v; return {}; } void return_void() {} void unhandled_exception() {} }; using handle = coroutine_handle; handle h; explicit Generator(promise_type* p) : h(handle::from_promise(*p)) {} ~Generator() { if (h) h.destroy(); } bool next() { h.resume(); return !h.done(); } int value() { return h.promise().current; } }; Generator counter(int n) { for (int i = 1; i <= n; ++i) co_yield i; // 挂起点 } int main() { auto gen = counter(5); while (gen.next()) cout << gen.value() << " "; // 1 2 3 4 5 cout << end l; return 0; } ``` 协程调用时并不立即执行,首次 `resume()` 才运行到第一个挂起点。`co_yield` 把值交给调用方后挂起,下次 `resume()` 继续。 ### 4.2 模块(Modules)初探 模块取代头文件成为新的代码组织方式,编译更快、隔离更好。模块文件通常以 `.cppm` 结尾: ```cpp // math.cppm —— 模块接口文件 export module math; export int square(int x) { return x * x; } export double pi() { return 3.14159265358979; } // main.cpp —— 使用模块 import ; import math; int main() { std::cout << square(7) << std::endl; // 49 std::cout << pi() << std::endl; // 3.14159 return 0; } ``` 注意:模块编译需要较新的编译器(GCC 11+、Clang 16+、MSVC 2019 16.10+),且部分构建系统(如 CMake 3.28+)才完整支持。 ## 五、其他实用新特性 ### 5.1 std::span:零开销数组视图 `std::span` 是连续内存的「视图」,不拥有数据,可直接当数组使用,避免指针+长度参数: ```cpp #include #include #include using namespace std; void printAll(span s) { for (int x : s) cout << x << " "; cout << endl; } int main() { int arr[] = {1, 2, 3, 4, 5}; vector vec = {10, 20, 30}; printAll(arr); // 数组自动转为 span printAll(vec); // vector 自动转为 span printAll({arr + 1, 3}); // 指定子区间 2 3 4 return 0; } ``` `span` 不拷贝元素、不管理内存,是函数充参的最佳实践之一。 ### 5.2 constexpr 扩展与立即函数 C++20 允许 `constexpr` 函数使用 `try`、`new/delete`、虚函数等,还引入 `consteval`(立即函数,只能在编译期调用): ```cpp #include #include using namespace std; consteval int square(int x) { // 立即函数:只允许编译期求值 return x * x; } constexpr int factorial(int n) { if (n <= 1) return 1; return n * factorial(n - 1); } int main() { constexpr int a = square(9); // 编译期计算 81 constexpr int f = factorial(6); // 720 cout << a << " " << f << endl; return 0; } ``` `consteval` 函数若在运行时上下文调用会直接编译错误,保证所有调用都发生在编译期。 ## 六、实战:C++20 成绩分析器 综合运用概念、范围、三路 比较与 span 的完整案例: ```cpp #include #include #include #include #include #include #include #include using namespace std; // 概念:成绩必须是算术类型 template concept Score = arithmetic; struct Student { string name; Score auto score; // 概念简写:约束成员类型 auto operator<=>(const Student&) const = default; // 自动比较 }; // 用 span 接收成绩数组 template double average(span scores) { double sum = 0; for (auto s : scores) sum += s; return scores.empty() ? 0 : sum / scores.size(); } int main() { vector students = { {"张三", 88}, {"李四", 92.5}, {"王五", 76}, {"赵六", 95} }; // 范围算法 + 投影:按分数降序 ranges::sort(students, ranges::greater{}, &Student::score); cout << "按分数排序:" << endl; for (auto& s : students) cout << s.name << " " << s.score << endl; // 取前两名(视图 + take) auto top2 = students | views::take(2); cout << "前两名: "; for (auto& s : top2) cout << s.name << " "; cout << endl; // 计算平均分(span 传入) vector scores; for (auto& s : students) scores.push_back(s.score); cout << "平均分: " << average(span(scores)) << endl; return 0; } ``` ## 总结 本篇系统讲解了 C++20 核心新特性:**concepts** 让模板约束可读、可复用;**ranges** 以惰性视图与管道风格重构数据流;**三路比较**一次操作自动生成全套比较运算符;协程与模块开启语言新范式;`std::span` 与 `constexpr` 扩展让代码更安全高效。建议在 GCC 11+/Clang 16+/MSVC 2019 16.10+ 上开启 `-std=c++20` 实测。 下一篇将进入 **C++ 并发编程**(线程与互斥锁),敬请期待! --- ## C++17新特性精讲:结构化绑定、if constexpr与实用类型 URL: https://wsj-blog.pages.dev/posts/c-17-xin-te-xing-jing-jiang-jie-gou-hua-bang-ding-if/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 21 篇。上一篇讲解了 C++11/14 新特性,本篇深入 C++17 新特性:if/switch 初始化语句、结构化绑定、if constexpr、std::optional/variant/any、string_view、filesystem 完整实践、[[nodiscard]]。 ### 一、if/switch 初始化语句 #### 1.1 if 初始化语句 C++17 允许在 if/switch 条件中声明变量,作用域限制在语句块内: ```cpp #include #include #include using namespace std; int main() { map scores = {{"张三", 88}, {"李四", 92}}; // C++17:if 初始化语句 if (auto it = scores.find("张三"); it != scores.end()) { cout << "找到: " << it->first << " = " << it->second << endl; } else { cout << "未找到" << endl; } // it 在 if-else 块外不可见 // 对比旧写法(变量泄漏到外层作用域) auto it2 = scores.find("李四"); if (it2 != scores.end()) { cout << "找到: " << it2->second << endl; } return 0; } ``` #### 1.2 switch 初始化语句 ```cpp #include #include using namespace std; enum class Command { Start, Stop, Pause }; Command parseCommand(const string &s) { if (s == "start") return Command::Start; if (s == "stop") return Command::Stop; return Command::Pause; } int main() { // switch 初始化:变量只在 switch 内有效 switch (auto cmd = parseCommand("stop"); cmd) { case Command::Start: cout << "启动" << endl; break; case Command::Stop: cout << "停止" << endl; break; case Command::Pause: cout << "暂停" << endl; break; } // cmd 在 switch 外不可见 return 0; } ``` ### 二、结构化绑定 #### 2.1 基本用法 结构化绑定(Structured Binding)可以一次性解包 pair、tuple、结构体等: ```cpp #include #include #include #include using namespace std; int main() { // 解包 pair pair person{"张三", 25}; auto [name, age] = person; cout << name << " " << age << endl; // 张三 25 // 解包 tuple tuple data(1, "Alice", 88.5); auto [id, n, score] = data; cout << id << " " << n << " " << score << endl; // 解包数组 int arr[3] = {10, 20, 30}; auto [a, b, c] = arr; cout << a << " " << b << " " << c << endl; // 10 20 30 return 0; } ``` #### 2.2 遍历 map 的优雅写法 ```cpp #include #include #include using namespace std; int main() { map wordCount = { {"cpp", 3}, {"python", 2}, {"java", 1} }; // 旧写法:kv.first / kv.second for (const auto &kv : wordCount) { cout << kv.first << ": " << kv.second << endl; } cout << "---" << endl; // C++17 结构化绑定 for (const auto &[word, count] : wordCount) { cout << word << ": " << count << endl; } return 0; } ``` #### 2.3 解包引用(可修改) ```cpp #include #include #include using namespace std; int main() { map data = {{"a", 1}, {"b", 2}}; // 用引用解包,可以修改 for (auto &[key, value] : data) { value *= 10; } for (const auto &[k, v] : data) { cout << k << " = " << v << endl; // a = 10, b = 20 } return 0; } ``` ### 三、if constexpr #### 3.1 编译期条件分支 if constexpr 在**编译期**求值并丢弃不满足的分支: ```cpp #include #include #include using namespace std; template void process(const T &value) { // 编译期分支:只编译满足条件的分支 if constexpr (is_integral_v) { cout << "[整数] " << value << endl; } else if constexpr (is_floating_point_v) { cout << "[浮点] " << value << endl; } else if constexpr (is_same_v) { cout << "[字符串] " << value << endl; } else { cout << "[其他类型]" << endl; } } int main() { process(42); // [整数] 42 process(3.14); // [浮点] 3.14 process(string("hi")); // [字符串] hi process(true); // [整数] 1(bool 是整型) return 0; } ``` #### 3.2 与模板递归结合 ```cpp #include using namespace std; // 变参打印:用 if constexpr 简化递归 template void printOne(const T &value) { cout << value << " "; } template void printAll(First first, Rest... rest) { cout << first << " "; if constexpr (sizeof...(Rest) > 0) { printAll(rest...); // 还有剩余参数才递归 } else { cout << e ndl; // 最后一个参数结束 } } int main() { printAll(1, 2.5, "hello", 'x'); // 1 2.5 hello x printAll(42); // 42 return 0; } ``` ### 四、std::optional #### 4.1 optional 的用途 optional 表示**可能不存在的值**,替代返回 -1 或 nullptr 的惯用法: ```cpp #include #include #include using namespace std; // 查找:可能找不到 optional findValue(const int *arr, int size, int target) { for (int i = 0; i < size; i++) { if (arr[i] == target) return arr[i]; } return nullopt; // 表示不存在 } int main() { int data[] = {3, 1, 4, 1, 5}; auto result = findValue(data, 5, 4); if (result.has_value()) { cout << "找到: " << *result << endl; // 4 } else { cout << "未找到" << endl; } auto missing = findValue(data, 5, 99); // value_or:不存在时给默认值 cout << "结果: " << missing.value_or(-1) << endl; // -1 return 0; } ``` #### 4.2 optional 成员函数 ```cpp #include #include using namespace std; int main() { optional a = 42; optional b; // 空 optional c = nullopt; // 显式空 cout << "a 有值: " << a.has_value() << endl; // 1 cout << "b 有值: " << b.has_value() << endl; // 0 cout << "a 的值: " << a.value() << endl; // 42 cout << "b 默认值: " << b.value_or(100) << endl; // 100 // 解引用 if (a) { cout << "a 存在: " << *a << endl; } // 重置 a.reset(); cout << "重置后: " << a.has_value() << endl; // 0 // 重新赋值 a = 99; cout << "重新赋值: " << *a << endl; // 99 return 0; } ``` ### 五、std::variant #### 5.1 variant 联合类型 variant 是**类型安全**的联合体,可他存储多种类型之一: ```cpp #include #include #include using namespace std; int main() { // 可他存 int、double、string 之一 variant v; v = 42; // 当前是 int cout << "int: " << get(v) << endl; // 42 v = 3.14; // 切换到 double cout << "double: " << get(v) << endl; // 3.14 v = "hello"; // 切换到 string cout << "string: " << get(v) << endl; // hello // 类型安全:访问错误类型会抛异常 try { v = 42; get(v); // 错误!当前是 int } catch (const bad_variant_access &e) { cout << "类型访问错误: " << e.what() << endl; } return 0; } ``` #### 5.2 variant 安全访问 ```cpp #include #include #include using namespace std; // 访问者模式 struct Visitor { void operator()(int value) const { cout << "整数: " << value << endl; } void operator()(double value) const { cout << "浮点: " << value << endl; } void operator()(const string &value) const { cout << "字符串: " << value << endl; } }; int main() { variant v; // 用访问者安全访问 v = 100; visit(Visitor{}, v); // 整数: 100 v = 2.5; visit(Visitor{}, v); // 浮点: 2.5 v = "hi"; visit(Visitor{}, v); // 字符串: hi // 用 lambda 访问 v = 42; visit([](auto &&value) { cout << "值: " << value << endl; }, v); return 0; } ``` ### 六、std::any #### 6.1 any 任意类型 any 可以存储**任意类型**的值(类似 void* 但类型安全): ```cpp #include #include #include using namespace std; int main() { any value; value = 42; // 存 int cout << "int: " << any_cast(value) << endl; // 42 value = string("hello"); // 存 string cout << "string: " << any_cast(value) << endl; // hello value = 3.14; // 存 double cout << "double: " << any_cast(value) << endl; // 3.14 // 检查类型 value = 42; if (value.type() == typeid(int)) { cout << "是 int 类型" << endl; } // 安全转换 auto ptr = any_cast(&value); // 失败返回 nullptr if (ptr) { cout << *ptr << endl; } else { cout << "不是 string 类型" << endl; } return 0; } ``` #### 6.2 三种实用类型对比 | 类型 | 用途 | 适用 | | --- | --- | --- | | optional | 值可能存在或不存在 | 查找、返回值 | | variant | 值是一组类型之一 | 联合数据、状态 | | any | 值可以是任意类型 | 配置、动态数据 | ### 七、string_view #### 7.1 避免不必要的拷贝 string_view 是**只读字符串视图**,� ��拥有内存,避免拷贝: ```cpp #include #include #include using namespace std; // 旧写法:const string& 仍可能触发临时对象 void oldPrint(const string &s) { cout << s << endl; } // 新写法:string_view 零拷贝 void newPrint(string_view sv) { cout << sv << endl; } int main() { string full = "Hello World"; const char *cstr = "C-style"; // string_view 可以直接接收各种来源 newPrint(full); // string newPrint(cstr); // const char* newPrint("literal"); // 字符串字面量 // 子串视图(零拷贝) string_view view(full); string_view sub = view.substr(6, 5); cout << "子串: " << sub << endl; // World // 只读 // sub[0] = 'x'; // 错误!string_view 只读 return 0; } ``` #### 7.2 string_view 注意事项 ```cpp #include #include using namespace std; int main() { // 注意:string_view 不拥有内存! // 返回局部字符串的 view 是悬垂引用 // 正确用法:作为函数参数(只读传递) auto startsWith = [](string_view s, string_view prefix) { return s.substr(0, prefix.size()) == prefix; }; cout << startsWith("HelloWorld", "Hello") << endl; // 1 cout << startsWith("HelloWorld", "World") << endl; // 0 return 0; } ``` ### 八、[[nodiscard]] 与 [[maybe_unused]] #### 8.1 [[nodiscard]] 禁止忽略返回值 ```cpp #include using namespace std; // 警告:调用者必须使用返回值 [[nodiscard]] int computeResult() { return 42; } class Error { public: [[nodiscard]] bool isValid() const { return true; } }; int main() { // computeResult(); // 警告!返回值被忽略 int result = computeResult(); // 正确 cout << "结果: " << result << endl; Error e; // e.isValid(); // 警告!bool 返回值被忽略 if (e.isValid()) { // 正确 cout << "有效" << endl; } return 0; } ``` #### 8.2 [[maybe_unused]] 抑制警告 ```cpp #include using namespace std; void debug([[maybe_unused]] int level) { // level 可能在某些配置下不使用 // [[maybe_unused]] 抑制未使用警告 } int main() { debug(3); return 0; } ``` ### 九、实战:配置文件解析器 综合本篇 C++17 特性,实� ��健壮的配置解析: ```cpp #include #include #include #include #include #include #include #include using namespace std; namespace fs = filesystem; // 配置值:可能是整数、浮点或字符串 using ConfigValue = variant; class ConfigParser { private: map config; optional parseValue(string_view raw) { // 尝试解析为 int try { size_t pos; int i = stoi(string(raw), &pos); if (pos == raw.size()) return ConfigValue(i); } catch (...) {} // 尝试解析为 double try { size_t pos; double d = stod(string(raw), &pos); if (pos == raw.size()) return ConfigValue(d); } catch (...) {} // 默认字符串 return ConfigValue(string(raw)); } public: bool load(string_view filename) { ifstream fin(string(filename)); if (!fin) return false; string line; while (getline(fin, line)) { // 去掉注释和空行 auto comment = line.find('#'); if (comment != string::npos) line = line.substr(0, comment); if (line.empty()) continue; auto eq = line.find('='); if (eq == string::npos) continue; string key = line.substr(0, eq); string value = line.substr(eq + 1); // 去空格 auto trim = [](string s) { while (!s.empty() && isspace(s.back())) s.pop_back(); while (!s.empty() && isspace(s.front())) s.erase(s.begin()); return s; }; config[trim(key)] = *parseValue(trim(value)); } return true; } void showAll() const { for (const auto &[key, value] : config) { cout << kuy << " = "; visit([](const auto &v) { cout << v; }, value); cout << endl; } } template optional get(const string &key) const { auto it = config.find(key); if (it == config.end()) return nullopt; if (auto ptr = get_if(&it->second)) return *ptr; return nullopt; } }; int main() { // 创建配置 ofstream fout("app.conf"); fout << "# 应用配置" << endl; fout << "host = localhost" << endl; fout << "port = 8080" << endl; fout << "timeout = 30.5" << endl; fout << "mode = production" << endl; fout.close(); // 解析配置 ConfigParser parser; if (parser.load("app.conf")) { parser.showAll(); cout << "---" << endl; if (auto port = parser.get("port")) { cout << "端口: " << *port << endl; // 8080 } if (auto host = parser.get("host")) { cout << "主机: " << *host << endl; // localhost } if (auto timeout = parser.get("timeout")) { cout << "超时: " << *timeout << endl; // 30.5 } } fs::remove("app.conf"); return 0; } ``` ### 总结 本篇讲解了 if/switch 初始化语句、结构化绑定、if constexpr 编译期分支、std::optional/variant/any 三种实用类型、string_view 零拷贝视图、[[nodiscard]]/[[maybe_unused]] 属性,并用配置解析器串联实战。重点掌握:结构化绑定遍历 map、if constexpr 与模板的配合、optional 处理可能不存在的值、variant 的类型安全访问、string_view 不拥有内存的特性。 下一篇将讲解 **C++20 新特性精讲**(concepts、ranges、三路比较),敬请期待! --- ## C++11/14新特性精讲:移动语义与智能指针实战 URL: https://wsj-blog.pages.dev/posts/c-11-14-xin-te-xing-jing-jiang-yi-dong-yu-yi-yu-zhi-neng/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 20 篇。模板阶段(16-19 篇)已完成,本篇进入现代 C++ 特性精讲:左值右值分类与移动语义实战、=default/=delete、noexcept、移动与 STL 容器性能、智能指针深化、auto/decltype 回顾。 ### 一、值分类体系 #### 1.1 五类值 C++11 将表达式分为五类值: | 类别 | 说明 | 示例 | | --- | --- | --- | | 左值 lvalue | 有名字,可取地址 | 变量、数组元素 | | 纯右值 prvalue | 临时量,无名字 | 42、a+b、函数返回值 | | 将亡值 xvalue | 即将销毁,可移动 | std::move(x) 的结果 | | 广义左值 glvalue | lvalue + xvalue | | | 右值 rvalue | prvalue + xvalue | | #### 1.2 判断左右值 ```cpp #include #include using namespace std; // 判断表达式的值类别 template void checkValue(T &&value) { if constexpr (is_lvalue_reference_v) { cout << "左值" << endl; } else { cout << "右值" << endl; } } int globalValue = 10; int getRValue() { return 5; } int main() { int x = 42; // x 是左值 int *p = &x; // &x 是右值(地址临时量) cout << "x: "; checkValue(x); // 左值 cout << "42: "; checkValue(42); // 右值 cout << "getRValue(): "; checkValue(getRValue()); // 右值(返回值是临时量) cout << "move(x): "; checkValue(move(x)); // 右值(将亡值) return 0; } ``` #### 1.3 右值引用的生命周期延长 ```cpp #include using namespace std; class Temp { public: Temp() { cout << "构造" << endl; } ~Temp() { cout << "析构" << endl; } void work() const { cout << "工作中" << endl; } }; int main() { // 右值引用延长临时对象生命周期 Temp &&ref = Temp(); ref.work(); // 工作中 // const 左值引用也能延长 const Temp &cref = Temp(); cref.work(); cout << "main 结束" << endl; // 输出顺序:构造 工作中 构造 工作中 main 结束 析构 析构 return 0; } ``` ### 二、移动语义深化 #### 2.1 深拷贝 vs 移动性能对比 ```cpp #include #include #include #include using namespace std; using namespace chrono; int main() { const int N = 100000; // 拷贝版本 vector v1; auto start1 = high_resolution_clock::now(); for (int i = 0; i < N; i++) { string s(100, 'a'); v1.push_back(s); // 拷贝 } auto end1 = high_resolution_clock::now(); auto copyTime = duration_cast(end1 - start1).count(); // 移动版本 vector v2; auto start2 = high_resolution_clock::now(); for (int i = 0; i < N; i++) { string s(100, 'a'); v2.push_back(move(s)); // 移动 } auto end2 = high_resolution_clock::now(); auto moveTime = duration_cast(end2 - start2).count(); cout << "拷贝耗时: " << copyTime << " ms" << endl; cout << "移动耗时: " << moveTime << " ms" << endl; cout << "性能提升: " << (copyTime * 100.0 / max(1, moveTime) - 100) << "%" << endl; return 0; } ``` #### 2.2 移动语义三原则 1. **移动构造/赋值必须 noexcept**(否则 STL 容器扩容时退化为拷贝)。 2. **移动后源对象必须处于有效但未指定状态**(通常置空)。 3. **只对「大对象」有意义**(基本类型移动=拷贝)。 ```cpp #include #include using namespace std; class LargeObject { private: int *data; size_t size; public: LargeObject(size_t n) : data(new int[n]), size(n) {} // 拷贝构造 LargeObject(const LargeObject &other) : data(new int[other.size]), size(other.size) { copy(other.data, other.data + size, data); cout << "深拷贝" << endl; } // 移动构造:必须 noexcept LargeObject(LargeObject &&other) noexcept : data(other.data), size(other.size) { other.data = nullptr; other.size = 0; cout << "移动构造" << endl; } // 移动赋值:必须 noexcept LargeObject &operator=(LargeObject &&other) noexcept { if (this != &other) { delete[] data; data = other.data; size = other.size; other.data = nullptr; other.size = 0; cout << "移动赋值" << endl; } return *this; } ~LargeObject() { delete[] data; } }; int main() { LargeObject a(1000); LargeObject b = move(a); // 移动构造 LargeObject c(500); c = move(b); // 移动赋值 return 0; } ``` ### 三、=default 与 =delete #### 3.1 =default 显式默认 ```cpp #include u sing namespace std; class DefaultDemo { public: // 显式要求编译器生成默认版本 DefaultDemo() = default; ~DefaultDemo() = default; DefaultDemo(const DefaultDemo &) = default; DefaultDemo &operator=(const DefaultDemo &) = default; int value = 0; }; int main() { DefaultDemo d1; DefaultDemo d2 = d1; // 使用默认拷贝 d2.value = 42; cout << "d1.value = " << d1.value << endl; // 0(浅拷贝) cout << "d2.value = " << d2.value << endl; // 42 return 0; } ``` #### 3.2 =delete 禁用函数 ```cpp #include using namespace std; class NoCopy { public: NoCopy() = default; // 禁用拷贝(独占资源类常用) NoCopy(const NoCopy &) = delete; NoCopy &operator=(const NoCopy &) = delete; }; class MathUtils { public: // 禁用整型参数(防止隐式转换歧义) static int square(int x) { return x * x; } static double square(double x) = delete; // 显式禁用 double }; int main() { NoCopy n; // NoCopy n2 = n; // 错误!拷贝已删除 cout << MathUtils::square(5) << endl; // 25 // MathUtils::square(5.0); // 错误!double 版本被删除 return 0; } ``` ### 四、noexcept 异常说明 #### 4.1 noexcept 的作用 ```cpp #include #include using namespace std; // noexcept:承诺不抛异常 void safeFunction() noexcept { cout << "安全函数" << endl; // throw 1; // 如果抛出,会直接 terminate } // 条件 noexcept template void process(T &value) noexcept(noexcept(value.foo())) { value.foo(); } int main() { safeFunction(); cout << "noexcept 检查: " << noexcept(safeFunction()) << endl; // 1 cout << "普通函数: " << noexcept(process) << endl; // 0 return 0; } ``` #### 4.2 为什么移动操作必须 noexcept ```cpp #include #include using namespace std; class MoveNoexcept { public: MoveNoexcept() = default; // noexcept 移动构造 MoveNoexcept(MoveNoexcept &&) noexcept { cout << "noexcept 移动" << endl; } MoveNoexcept(const MoveNoexcept &) { cout << "拷贝" << endl; } }; class MoveThrow { public: MoveThrow() = default; // 可能抛异常的移动构造 MoveThrow(MoveThrow &&) { // 没有 noexcept cout << "非 noexcept 移动" << endl; } MoveThrow(const MoveThrow &) { cout << "拷贝" << endl; } }; int main() { // vector 扩容时: vector v1; for (int i = 0; i < 5; i++) v1.emplace_back(); cout << "---" << endl; // 非 noexcept 移动:扩容时用拷贝(保证强异常安全) vector v2; for (int i = 0; i < 5; i++) v2.emplace_back(); cout << "(观察到拷贝多于移动)" << endl; return 0; } ``` ### 五、智能指针实战深化 #### 5.1 自定义 deleter ```cpp #include #include using namespace std; class File { public: File(const char *name) { cout << "打开文件: " << name << endl; } void read() const { cout << "读取文件内容" << endl; } }; // 自定义删除器 struct FileDeleter { void operator()(File *f) const { cout << "关闭并释放文件" << endl; delete f; } }; int main() { // 智能指针 + 自定义删除器 unique_ptr file(new File("data.txt"), FileDeleter()); file->read(); // shared_ptr 也支持自定义删除器 shared_ptr file2(new File("log.txt"), FileDeleter()); file2->read(); // 离开作用域自动调用删除器 return 0; } ``` #### 5.2 enable_shared_from_this ```cpp #include #include using namespace std; class Node : public enable_shared_from_this { public: shared_ptr getShared() { return shared_from_this(); // 安全地获取自身 shared_ptr } void show() const { cout << "Node 对象" << endl; } }; int main() { shared_ptr n1 = make_shared(); // 错误做法:不能用裸指针创建两个 shared_ptr // Node *raw = n1.get(); // shared_ptr n2(raw); // 双重释放! // 正确做法:shared_from_this shared_ptr n2 = n1->getShared(); cout << "引用计数: " << n1.use_count() << endl; // 2 n2->show(); return 0; } ``` #### 5.3 shared_ptr 循环引用实战 ```cpp #include #include using namespace std; // 循环引用导致内存泄漏的案例 struct BadNode { string name; shared_ptr next; ~BadNode() { cout << name << " 析构" << endl; } }; // 用 weak_ptr 打破循环 struct GoodNode { string name; weak_ptr next; // 弱引用 ~Go odNode() { cout << name << " 析构" << endl; } }; int main() { { cout << "=== 循环引用(泄漏) ===" << endl; auto a = make_shared(); auto b = make_shared(); a->name = "A"; b->name = "B"; a->next = b; b->next = a; // 互相引用 → 都析构不了 // 程序结束:A 和 B 都不析构(泄漏) } { cout << "=== weak_ptr 打破循环 ===" << endl; auto a = make_shared(); auto b = make_shared(); a->name = "A"; b->name = "B"; a->next = b; b->next = a; // weak_ptr 不增加计数 // 程序结束:A 和 B 正常析构 } return 0; } ``` ### 六、auto/decltype 现代用法 #### 6.1 auto 实战场景 ```cpp #include #include #include #include using namespace std; int main() { // 复杂类型简化 map>> complexData; auto it = complexData.begin(); // 迭代器 // 函数指针 auto funcPtr = [](int x) { return x * 2; }; // lambda // std::function 包装 function f1 = [](int x) { return x + 1; }; auto f2 = f1; // 拷贝包装器 // 结构化绑定(C++17)+ auto pair person{"张三", 25}; auto [name, age] = person; // 自动解包 cout << name << " " << age << endl; // 泛型 lambda(C++14) auto genericLambda = [](auto a, auto b) { return a + b; }; cout << genericLambda(1, 2) << endl; // 3 cout << genericLambda(1.5, 2.5) << endl; // 4 return 0; } ``` #### 6.2 decltype(auto) 精确转发 ```cpp #include using namespace std; int globalValue = 100; // 返回引用(decltype(auto) 保留引用) decltype(auto) getGlobal() { return globalValue; // decltype(globalValue) = int& } // auto 会丢失引用(拷贝) auto getGlobalBad() { return globalValue; // auto → int(拷贝) } int main() { decltype(auto) ref = getGlobal(); ref = 999; cout << "globalValue = " << globalValue << endl; // 999 int x = 42; int &xref = x; auto a = xref; // int(拷贝) decltype(auto) b = xref; // int&(引用) a = 100; b = 200; cout << "x = " << x << endl; // 200 return 0; } ``` ### 七、实战:高性能内存池 综合本篇现代特性,实现简单的对象池: ```cpp #include #include #include #include using namespace std; template class ObjectPool { private: vector> objects; public: // 完美转发创建对象 template T *acquire(Args &&... args) { auto obj = make_unique(forward(args)...); T *raw = obj.get(); objects.push_back(move(obj)); return raw; } void releaseAll() { objects.clear(); // 统一释放 cout << "全部释放" << endl; } size_t size() const { return objects.size(); } }; class Task { private: string id; int priority; public: Task(string i, int p) : id(move(i)), priority(p) {} void run() const { cout << "任务 " << id << "(优先级 " << priority << ")执行" << endl; } }; int main() { ObjectPool pool; // 完美转发多参数 Task *t1 = pool.acquire("T1", 3); Task *t2 = pool.acquire("T2", 5); t1->run(); t2->run(); cout << "池中对象数: " << pool.size() << endl; // 2 // 移动语义让 pool 安全析构 pool.releaseAll(); return 0; } ``` ### 总结 本篇讲解了五类值体系与移动语义、深拷贝 vs 移动的性能对比、移动操作 noexcept 的重要性、=default/=delete、智能指针深化(自定义 deleter、shared_from_this、循环引用)、auto/decltype 现代用法,并用对象池串联实战。重点掌握:移动操作必须 noexcept、shared_ptr 循环引用用 weak_ptr 打破、decltype(auto) 保留引用、=delete 禁用不想要的函数。 下一篇将讲解 **C++17 新特性精讲**(结构化绑定、if constexpr、optional/variant/any、filesystem),敬请期待! --- ## C++模板教程:类型推导与完美转发 URL: https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-lei-xing-tui-dao-yu-wan-mei-zhuan-fa/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 19 篇。上一篇讲解了变参模板与 SFINAE,本篇深入现代 C++ 的核心机制:auto 推导规则、decltype/decltype(auto)、左值右值与移动语义、右值引用与引用折叠、std::move、std::forward、完美转发实战。 ### 一、auto 类型推导 #### 1.1 auto 的基本用法 C++11 引入 auto,让编译器自动推导类型: ```cpp #include #include #include using namespace std; int main() { auto i = 42; // int auto d = 3.14; // double auto s = string("hi"); // string auto p = &i; // int* cout << i << " " << d << " " << s << endl; // 简化复杂类型 map> data; // 旧写法:map>::iterator it = data.begin(); auto it = data.begin(); // 自动推导迭代器类型 // 范围 for 中常用 vector v = {1, 2, 3}; for (auto &x : v) x *= 2; // auto& 推导为 int& return 0; } ``` #### 1.2 auto 的推导规则 ```cpp #include using namespace std; int main() { int x = 42; int &ref = x; const int cx = 10; // 推导规则 1:auto 会忽略顶层 const 和引用 auto a1 = x; // int(拷贝) auto a2 = ref; // int(引用被忽略,拷贝) auto a3 = cx; // int(const 被忽略) // 推导规则 2:auto& 保留引用和 const auto &a4 = ref; // int& const auto &a5 = cx; // const int& // 推导规则 3:auto* 保留指针 auto *a6 = &x; // int* // 修改测试 a2 = 100; cout << "x = " << x << endl; // 42(a2 是拷贝,不影响 x) a4 = 200; cout << "x = " << x << endl; // 200(a4 是引用) return 0; } ``` #### 1.3 auto 的使用注意 ```cpp #include #include using namespace std; int main() { // 注意:auto 用于函数返回类型时丢失 const/引用 // 需要时用 auto& 或 decltype(auto) // 初始化列表不能直接推导 // auto list = {1, 2, 3}; // C++17 推导为 initializer_list // 空初始化不允许 // auto e; // 错误!无法推导 // 不要在局部用 auto 声明意图不明的代码 // auto useless = someFunction(); // 可读性差 vector v = {1, 2, 3}; // 只读遍历用 const auto&,避免拷贝 for (const au to &x : v) { // x 是 const int& } return 0; } ``` ### 二、decltype 类型推导 #### 2.1 decltype 的作用 decltype 推导**表达式的精确类型**(保留 const、引用): ```cpp #include using namespace std; int main() { int x = 42; const int cx = 10; int &ref = x; // decltype 保留精确类型 decltype(x) d1; // int decltype(cx) d2 = 10; // const int decltype(ref) d3 = x; // int& // 表达式结果 decltype(x + 1) d4; // int decltype(x * 2.5) d5; // double decltype(cout << x) d6 = cout; // ostream& // 变量 + 括号的区别 decltype(x) d7; // int(变量) decltype((x)) d8 = x; // int&(带括号是左值表达式) return 0; } ``` #### 2.2 auto vs decltype 对比 | 维度 | auto | decltype | | --- | --- | --- | | 方式 | 值推导 | 表达式推导 | | const | 忽略顶层 const | 保留 | | 引用 | 忽略 | 保留 | | 用途 | 简化声明 | 精确类型 | #### 2.3 返回类型推导 ```cpp #include #include using namespace std; // C++11:尾置返回类型 template auto add11(T a, U b) -> decltype(a + b) { return a + b; } // C++14:auto 自动推导 template auto add14(T a, U b) { return a + b; } // C++14:decltype(auto) 保留精确类型 template decltype(auto) identity(T &&value) { return forward(value); // 完美转发 } int main() { cout << add11(3, 4.5) << endl; // 7.5 cout << add14(2.5, 3) << endl; // 5.5 int x = 10; decltype(auto) ref = identity(x); // int&(保留引用) ref = 99; cout << "x = " << x << endl; // 99 return 0; } ``` ### 三、左值与右值 #### 3.1 左值右值概念 - **左值(lvalue)**:有名字、可取地址、可持久存在的表达式。 - **右值(rvalue)**:临时对象、无名字、即将销毁的表达式。 ```cpp #include using namespace std; int main() { int x = 42; // x 是左值 int y = x + 1; // x+1 是右值(临时结果) // &(x + 1); // 错误!不能对右值取地址 // &42; // 错误!字面量是右值 string s1 = "hello"; // s1 是左值 string s2 = s1 + "!"; // s1+"!" 是右值(临时对象) cout << &x << en dl; // 可以对左值取地址 // cout << &(s1 + "!") << endl; // 错误 return 0; } ``` #### 3.2 右值引用 && 右值引用可以**绑定到右值**9.“接章”临时对象的资源: ```cpp #include #include using namespace std; int main() { // 左值引用 & 只绑定左值 int x = 42; int &lref = x; // OK:左值引用绑定左值 // int &lref2 = 42; // 错误:不能绑定右值 // 右值引用 && 只绑定右值 int &&rref = 42; // OK:不能绑定字格的值 // int &&rref2 = x; // 错误:不能监定左值 // 但右值引用可以免合 move 绑定左值 int &&rref3 = move(x); // move 把会家 佬换为右值 string &&sref = string("temp"); // 绑定临时字符串 cout << sref << endl; // temp return 0; } ``` #### 3.3 std::move 的作用 std::move 只是**把左值转换为右值引用**(cast),以便调用移动构造函数: ```cpp #include #include #include using namespace std; class Buffer { private: string data; public: // 拷贝构造(深拷贝) Buffer(const string &s) : data(s)ˆ��]����-'N��]H[�ˆB���9���b�9��:`(;�"9��y�z-a9��;�"B��Y��\���[�� ���H�]J[ݙJ�JHˆ��]����b���]H[�ˆB�N‚�[�XZ[� Hˆ��[���HH�[��ܛ�ˆ�Y��\��J�JN���9���-'y��:`(;�"�H9�+�m�`/;�"B��Y��\���[ݙJ�JJN���9���b�9��:`(;�"[ݙH:/k9c��`/;�"B���[ݙH9d#��H9⭹� y�*�k��.b{�#:`&�n.9..��n���]\��ŸB�����9f��� yo%y�*9��9c������ �H9o%y�*9��9c�:)�9b&B��od��(y�o�c๥l9��9k�9.���'��#9o%y�*9�9o%y�*8�#y����#9�"z)�9b&y��9c�;�&���9��9k�9h�9�#�9��9c�9������KKHKKHKKH�HI� ��I��HI� �I��HI�� ��I���HI�� �I�����)�9b&J���&�c�)�y�"y. 9.*��+�m�`/9o%y�*;�#9�����9l,y�+�m�`/9o%y�*;�&�c깧"y.)9.*�`�y�+�c��`/9o%y�*9�cy�+�c��`/9o%y�*8� ������ ��:/k9c�yo%y�*;�"9.!� �yo%y�*;�"B�����[��YH[���X[O��\�[���[Y\�X�H�‚��� ��9g*9�(y�o�.+y.#y�+�c��`/9o%y�*;�#: #9�+Ⱥ/k9c�yo%y�*��"9.!� �yo%y�*;�"B�[\]H\[�[YH����Y�X�� ���[YJHˆY� \���[YWܙY�\�[��Wݏ�Hˆ��]�9�+�m�`/9o%y�*�[�ˆH[�Hˆ��]�9�+�c��`/9o%y�*�[�ˆB�B��[�XZ[� Hˆ[�H �ˆ�X�� N���9/(9aiym�`/8���H[� �8��� ��9�#�b�9..�[� ���X�� �N���9/(9aiyc��`/8���H[�8��� ��9l,y�+�[� ����]\��ŸB�����9.�8� yk�9/k9c�H����ܝ�\������ K�H9.�9.b9�+�k�9���/k9c�B�����`"y.��.��k��. 9c๥l9g*:+���%�c����9."�� 9n���#9c�y�"y. 9.*�c๥l9�c9�+;�&������[��YH[���X[O���[��YH][]O��\�[���[Y\�X�H�‚���9��y�/�a�y�l;�&�ab9�'�`/9m�`/���Y���\��[� ��[YJHˆ��]���m�`/9i!9�!�WH��[YH[�ŸB����Y���\��[� ���[YJHˆ��]���c��`/9i!9�!�WH��[YH[�ŸB����:/k9c�ya�y�l;�&���9���/�zem�m�`/9c����9c�y/fylg�.��c��`/�[\]H\[�[YH����Y�ܝ�\�\� ���[YJHˆ���ܝ�\��9/�zem�b���"ylg�.��c��`/:`&��B����\���ܝ�\���[YJJNŸB����:e&z+���*9o#��&��$y.�y���/gy/#y�.�`/9`��.��m�`/�[\]H\[�[YH����Y�Y�ܝ�\�\� ���[YJHˆ���\���[YJN����[YH9�+9.���+�m�`/8���9n,��+�m�`/�B��[�XZ[� Hˆ[�H �ˆ�ܝ�\�\� N�����m�`/9i!9�!�WH ��":/k9c�y/�yei9m�`/;�"B��ܝ�\�\�L N�����c��`/9i!9�!�WHL;�":/k9c�y/�zem�c��`/;�"B���Y�ܝ�\�\�L N�����m�`/9i!9�!�WHL;�":e&z+��� y.c�.��c��`/9lg��)��"B��\�]\��ŸB������ K���ܝ�\���[ݙH9k�y����9��9n�����[ݙH����ܝ�\��KKHKKHKKH�9/g9�*9��9�hy.��.��/�9���#9c��/k9�h�c��`/9�"y�hy.��/k9c�{�#9/�y�iyc��`/�9�*9��H[ݙJ H�ܝ�\�� H�:"�y��z`"y��H9���b�:-a9��9k�9/k9c�{�"B�9k���9.$�.&��fy. 9��9�cyb,9.�9.&�`/9/�y�i9c��`/ int x = 42; check(x); // 传入左值 → T = int& → T&& 折叠为 int& check(42); // 传入右值 → T = int → T&& 就是 int&& return 0; } ``` ### 五、完美转发 std::forward #### 5.1 什么是完美转发 完美转发是**保留参数左右值属性**地传递给下一个函数: ```cpp #include #include using namespace std; // 接收函数:区分左值右值 void process(int &value) { cout << "左值处理: " << value << endl; } void process(int &&value) { cout << "右值处理: " << value << endl; } // 转发函数:完美转发保留左右值属性 template void forwarder(T &&value) { // forward 保留原始属性 process(forward(value)); } // 错误示范:直接传递会变成左值 template void badForwarder(T &&value) { process(value); // value 本身是左值 → 总是左值处理 } int main() { int x = 42; forwarder(x); // 左值处理: 42(转发保留左值) forwarder(100); // 右值处理: 100(转发保留右值) badForwarder(100); // 左值处理: 100(错误!丢了右值属性) return 0; } ``` #### 5.2 forward vs move 对比 | 维度 | std::move | std::forward | | --- | --- | --- | | 作用 | 无条件转右值 | 有条件转发 | | 用法 | move(x) | forward(x) | | 场景 | 移动资源 | 完美转发 | | 语义 | 不管原来是什么 | 保留原来属性 | #### 5.3 完美转发实战:make_unique 简化版 ```cpp #include #include using namespace std; template class MyUniquePtr { private: T *ptr; public: // 构造:接收任意参数,完美转发给 T 的构造函数 template explicit MyUniquePtr(Args &&... args) : ptr(new T(forward(args)...)) { cout << "创建对象" << endl; } ~MyUniquePtr() { delete ptr; cout << "销毁对象" << endl; } T *get() const { return ptr; } }; class Person { private: string name; int age; public: Person(string n, int a) : name(move(n)), age(a) {} void show() const { cout << name << " " << age << "岁" << endl; } }; // 模拟 make_unique template MyUniquePtr myMakeUnique(Args &&... args) { return MyUniquePtr(forward(args)...); } int main() { // � �美转发参数到 Person 构造函数 auto p = myMakeUnique("张三", 25); p.get()->show(); // 张三 25岁 return 0; } ``` ### 六、移动语义实战 #### 6.1 移动 vs 拷贝性能对比 ```cpp #include #include #include using namespace std; int main() { // 深拷贝 vs 移动 vector v1; for (int i = 0; i < 1000; i++) { string s(10000, 'x'); // v1.push_back(s); // 拷贝:分配新内存复制 v1.push_back(move(s)); // 移动:接管 s 的内存 } cout << "移动构造完成,v1 大小: " << v1.size() << endl; // 返回值优化 / 移动返回 auto makeVector = []() { vector temp(1000, 7); return temp; // 返回局部对象(移动或 RVO) }; auto v2 = makeVector(); cout << "v2 大小: " << v2.size() << endl; return 0; } ``` #### 6.2 移动构造与移动赋值 ```cpp #include #include using namespace std; class DynamicBuffer { private: char *data; size_t length; public: // 构造 DynamicBuffer(size_t len) : data(new char[len]), length(len) { cout << "构造 " << length << " 字节" << endl; } // 拷贝构造(深拷贝) DynamicBuffer(const DynamicBuffer &other) : data(new char[other.length]), length(other.length) { memcpy(data, other.data, length); cout << "拷贝构造" << endl; } // 移动构造(接管资源) DynamicBuffer(DynamicBuffer &&other) noexcept : data(other.data), length(other.length) { other.data = nullptr; // 置空源对象 other.length = 0; cout << "移动构造" << endl; } // 移动赋值 DynamicBuffer &operator=(DynamicBuffer &&other) noexcept { if (this == &other) return *this; delete[] data; data = other.data; length = other.length; other.data = nullptr; other.length = 0; cout << "移动赋值" << endl; return *this; } ~DynamicBuffer() { delete[] data; } }; DynamicBuffer createBuffer() { return DynamicBuffer(1000); // 临时对象(右值) } int main() { DynamicBuffer b1 = createBuffer(); // 移动构造 DynamicBuffer b2(500); // 普通构造 b2 = createBuffer(); // 移动赋值 return 0; } ``` ### 七、实战:通用容器包装 综合本篇知识,实现支持完美转发的容器: ```cpp #include #include #include using namespace std; // 通用包装器:支持任意类型任意参数构造 template class Wrapper { private: unique_ptr impl; public: // 完美转发构造 template explicit Wrapper(Args &&... args) : impl(make_unique(forward(args)...)) {} // 移动构造 Wrapper(Wrapper &&) = default; Wrapper &operator=(Wrapper &&) = default; // 拷贝删除(独占) Wrapper(const Wrapper &) = delete; Wrapper &operator=(const Wrapper &) = delete; T *operator->() const { return impl.get(); } T &operator*() const { return *impl; } }; class Config { private: string host; int port; vector features; public: Config(string h, int p, initializer_list fs) : host(move(h)), port(p), features(fs) {} void show() const { cout << host << ":" << port << " 功能: "; for (const auto &f : features) cout << f << " "; cout << endl; } }; int main() { // 完美转发多个参数 Wrapper config("localhost", 8080, {"http", "https", "ws"}); config->show(); // localhost:8080 功能: http https ws return 0; } ``` ### 总结 本篇讲解了 auto 推导规则与注意点、decltype/decltype(auto) 的精确类型、左值右值的概念与右值引用、引用折叠规则、std::move 与 std::forward 的区别、完美转发的实现,并用通用容器包装串联实战。重点掌握:auto 忽略 const/引用而 decltype 保留、引用折叠规则、forward 保留左右值属性、移动语义避免深拷贝。 模板阶段(16-19 篇)完成!下一篇进入**现代 C++ 特性精讲:移动语义与智能指针实战**,敬请期待! --- ## C++模板教程:变参模板、折叠表达式与SFINAE URL: https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-bian-can-mu-ban-zhe-die-biao-da-shi-yu/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 18 篇。上一篇讲解了特化与类型萃取,本篇深入模板高级技巧:变参模板(参数包、sizeof…、递归展开)、C++17 折叠表达式、SFINAE 与 enable_if、void_t 技巧、C++20 concepts 预告。 ### 一、变参模板 #### 1.1 什么是变参模板 变参模板(Variadic Templates)允许模板接受**任意数量的参数**,C++11 引入: ```cpp // 参数包:typename... Args template void print(Args... args); ``` - `Args...` 是**类型参数包**。 - `args...` 是**函数参数包**。 - `sizeof...(Args)` 获取参数个数。 #### 1.2 参数包展开与递归 ```cpp #include using namespace std; // 递归终止条件:空参数版本 void printAll() { cout << endl; } // 递归展开:取出第一个参数,剩余继续递归 template void printAll(T first, Rest... rest) { cout << first << " "; printAll(rest...); // 递归调用剩余参数 } int main() { printAll(1, 2.5, "hello", 'a'); // 1 2.5 hello a printAll(10); // 10 return 0; } ``` #### 1.3 sizeof… 获取参数个数 ```cpp #include using namespace std; template void countArgs(Args... args) { cout << "参数个数: " << sizeof...(Args) << endl; cout << "参数个数: " << sizeof...(args) << endl; // 等价 } int main() { countArgs(); // 0 countArgs(1); // 1 countArgs(1, 2.5, "three"); // 3 return 0; } ``` ### 二、折叠表达式(C++17) #### 2.1 折叠表达式语法 折叠表达式对**参数包中的全部元素**应用二元运算符,大大简化变参运算: | 形式 | 含义 | | --- | --- | | `(pack op ...)` | 右折叠:`a op (b op (c op init))` | | `(... op pack)` | 左折叠:`((init op a) op b) op c` | | `(pack op ... op init)` | 带初始值的右折叠 | | `(init op ... op pack)` | 带初始值的左折叠 | #### 2.2 折叠求和 ```cpp #include using namespace std; // C++17 折叠:一行实现任意个数求和 template auto sum(Args... args) { return (args + ... + 0); // 右折叠,初始值 0 } // 左折叠 template auto sumLeft(Args... args) { return (0 + ... + args); } int main() { cout << sum(1, 2, 3, 4, 5) << end l; // 15 cout << sum(1.5, 2.5, 3.0) << endl; // 7 cout << sum() << endl; // 0 cout << sumLeft(1, 2, 3) << endl; // 6 return 0; } ``` #### 2.3 折叠打印(对比递归) ```cpp #include using namespace std; // 用逗号运算符折叠打印 template void printFold(Args... args) { // (cout << ... << args):左折叠 // 逐个输出 ((cout << args << " "), ...); // 逗号折叠 cout << endl; } // 更实用的版本:带分隔符 template void printWithSep(const char *sep, Args... args) { // 第一个直接输出,其余带分隔符 ((cout << args), ...); // 简化版 cout << endl; } int main() { printFold(1, 2.5, "hi", 'x'); // 1 2.5 hi x printWithSep(", ", 1, 2, 3); return 0; } ``` #### 2.4 折叠判断(逻辑运算) ```cpp #include using namespace std; // 全部满足条件(&& 折叠) template bool allPositive(Args... args) { return ((args > 0) && ...); } // 任一满足条件(|| 折叠) template bool anyZero(Args... args) { return ((args == 0) || ...); } int main() { cout << allPositive(1, 2, 3) << endl; // 1 cout << allPositive(1, -2, 3) << endl; // 0 cout << anyZero(1, 2, 0, 4) << endl; // 1 cout << anyZero(1, 2, 3) << endl; // 0 return 0; } ``` ### 三、SFINAE 基础 #### 3.1 什么是 SFINAE SFINAE(Substitution Failure Is Not An Error,替换失败不是错误):模板实例化时,如果某个候选**替换失败**(如类型不支持某操作),编译器**不会报错**,而是继续尝试其他候选。 #### 3.2 利用 SFINAE 做类型判断 ```cpp #include #include using namespace std; // 通用版本:不支持 operator<< 的类型走这里 template void printValue(const T &value, ...) { cout << "不支持输出: (未知类型)" << endl; } // 精确版本:支持 operator<< 的类型走这里 template ())> void printValue(const T &value, int) { cout << "值: " << value << endl; } int main() { printValue(42, 0); // 值: 42 printValue(3.14, 0); // 值: 3.14 printValue("hello", 0); // 值: hello return 0; } ``` #### 3. 3 enable_if 条件启用 ```cpp #include #include using namespace std; // 仅当 T 是整数时启用 template typename enable_if::value>::type process(T value) { cout << "整数处理: " << value << endl; } // 仅当 T 是浮点时启用 template typename enable_if::value>::type process(T value) { cout << "浮点处理: " << value << endl; } int main() { process(42); // 整数处理: 42 process(3.14); // 浮点处理: 3.14 // process("hi"); // 错误!string 不满足任何版本 return 0; } ``` #### 3.4 enable_if 的两种写法 ```cpp #include #include using namespace std; // 写法一:返回类型中启用 template typename enable_if::value, T>::type square(T x) { return x * x; } // 写法二:模板参数中启用(C++11 更通用) template ::value>::type> double squareDouble(T x) { return x * x; } int main() { cout << square(5) << endl; // 25(int 版本) cout << squareDouble(2.5) << endl; // 6.25(double 版本) return 0; } ``` ### 四、void_t 技巧 #### 4.1 void_t 检测特性 ```cpp #include #include using namespace std; // void_t:任何类型都映射为 void(C++17 标准库提供) template using void_t = void; // 检测 T 是否有成员函数 size() template struct HasSize : false_type {}; template struct HasSize().size())>> : true_type {}; // 检测 T 是否有成员 type template struct HasType : false_type {}; template struct HasType> : true_type {}; int main() { cout << "string 有 size(): " << HasSize::value << endl; // 1 cout << "int 有 size(): " << HasSize::value << endl; // 0 struct WithType { using type = int; }; cout << "WithType 有 type: " << HasType::value << endl; // 1 cout << "int 有 type: " << HasType::value << endl; // 0 return 0; } ``` #### 4.2 void_t 检测可调用性 ```cpp #include #include using namespace std; template using void_t = void; // 检测 T 是否支持 operator<< template struct IsPrintable : false_type {}; template struct IsPrintable())>> : true_type {}; struct MyStruct {}; // 不支持输出 int main() { cout << "int 可打印: " << IsPrintable::value << endl; // 1 cout << "string 可打印: " << IsPrintable::value << endl; // 1 cout << "MyStruct 可打印: " << IsPrintable::value << endl; // 0 return 0; } ``` ### 五、变参模板实战 #### 5.1 类型安全的 printf ```cpp #include using namespace std; // 基础版本:无参数 void myPrintf(const char *format) { cout << format << endl; } // 递归展开版本 template void myPrintf(const char *format, T value, Args... args) { while (*format) { if (*format == '%' && *(format + 1) == 'd') { cout << value; // 输出参数 format += 2; myPrintf(format, args...); // 递归剩余 return; } cout << *format++; } } int main() { myPrintf("数字: %d", 42); myPrintf("%d 加 %d 等于 %d", 1, 2, 3); return 0; } ``` #### 5.2 变参构造函数(完美转发预览) ```cpp #include #include using namespace std; // 自定义容器:支持任意参数构造 template class MyContainer { private: vector data; public: // 变参构造函数 template MyContainer(Args... args) { data.reserve(sizeof...(Args)); (data.push_back(args), ...); // C++17 逗号折叠 } void show() const { for (const auto &item : data) cout << item << " "; cout << endl; } size_t size() const { return data.size(); } }; int main() { MyContainer c1(1, 2, 3, 4, 5); cout << "大小: " << c1.size() << endl; // 5 c1.show(); // 1 2 3 4 5 MyContainer c2("C++", "Python"); c2.show(); // C++ Python return 0; } ``` #### 5.3 变参模板应用场景 1. **std::make_shared/make_unique**:参数转发给构造函数。 2. **std::tuple**:存储任意类型任意个数。 3. **printf 风格格式化**。 4. **事件系统**:任意参数的通知。 5. **工厂模式* *:参数转发。 ### 六、C++20 concepts 预告 #### 6.1 concepts 简化约束 C++20 的 concepts 让模板约束更简洁直观: ```cpp #include #include using namespace std; // 定义概念:必须是整数类型 template concept Integral = is_integral_v; // 用概念约束模板参数 template T add(T a, T b) { return a + b; } // 简化写法 template requires Integral T multiply(T a, T b) { return a * b; } int main() { cout << add(3, 4) << endl; // 7 cout << multiply(5, 6) << endl; // 30 // add(3.5, 4.5); // 错误!double 不满足 Integral return 0; } ``` #### 6.2 concepts vs enable_if | 维度 | enable_if | concepts | | --- | --- | --- | | 可读性 | 冗长晦涩 | 直观清晰 | | 报错信息 | 深奥难懂 | 友好明确 | | 语法 | 模板技巧 | 标准语法 | | 标准 | C++11 | C++20 | ### 七、实战:任意类型最大值 综合本篇知识,实现支持任意参数个数的 max: ```cpp #include #include using namespace std; // 两个参数版本(递归终止) template T myMax(T value) { return value; } // 变参版本:比较第一个和剩余的最大值 template auto myMax(T first, Args... rest) { auto restMax = myMax(rest...); // 递归求剩余最大值 return first > restMax ? first : restMax; } // 折叠表达式版本(C++17 更简洁) template auto foldMax(Args... args) { return (args > ...); // 不适用 } // 正确折叠版 template auto foldMax2(First first, Rest... rest) { auto result = first; ((result = (rest > result ? rest : result)), ...); return result; } int main() { cout << myMax(3, 7, 2) << endl; // 7 cout << myMax(1.5, 3.2, 2.8, 4.1) << endl; // 4.1 cout << foldMax2(10, 20, 5, 15) << endl; // 20 cout << foldMax2(3.5, 2.5) << endl; // 3.5 return 0; } ``` ### 总结 本篇讲解了变参模板(参数包、sizeof…、递归展开)、C++17 折叠表达式(求和、打印、逻辑判断)、SFINAE 与 enable_if(条件启用)、void_t 技巧(特性检测)、C++20 concepts 预告,并用任意类型最大值串联实战。重点掌握:参数包递归展开模式、折叠表达式的四种形式、enable_if 的两种写法、void_t 检测原理。 下一篇将讲解**类型推导与完美转发**(auto/decltype/右值引用/完美转发),敬请期待! --- ## C++模板教程:特化、偏特化与类型萃取 URL: https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-te-hua-pian-te-hua-yu-lei-xing-cui-qu/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 17 篇。上一篇讲解了函数模板与类模板入门,本篇深入模板进阶:函数模板全特化、类模板全特化与偏特化、 类型萃取(is_integral/is_pointer/decay 等)、自定义 traits。 ### 一、模板特化概述 #### 1.1 什么是特化 模板是「通用配方」,但某些特殊类型需要**专门的实现**。特化就是为特定类型提供定制版本: ```cpp // 通用模板:处理一般类型 template T myMax(T a, T b) { return a > b ? a : b; } // 全特化:为特定类型定制(如 const char* 比较字符串而非指针) template <> const char *myMax(const char *a, const char *b) { return strcmp(a, b) > 0 ? a : b; } ``` #### 1.2 特化的种类 | 种类 | 说明 | 示例 | | --- | --- | --- | | 函数模板全特化 | 所有参数都确定 | `myMax` | | 类模板全特化 | 所有参数都确定 | `Stack` | | 类模板偏特化 | 部分参数确定 | `Stack` | ### 二、函数模板特化 #### 2.1 函数模板全特化 ```cpp #include #include using namespace std; // 通用模板 template T myMax(T a, T b) { cout << "通用版本" << endl; return a > b ? a : b; } // 全特化:const char* 版本 template <> const char *myMax(const char *a, const char *b) { cout << "特化版本" << endl; return strcmp(a, b) > 0 ? a : b; } int main() { cout << myMax(3, 7) << endl; // 通用版本 → 7 const char *s1 = "apple"; const char *s2 = "banana"; cout << myMax(s1, s2) << endl; // 特化版本 → banana return 0; } ``` #### 2.2 用重载代替函数特化 **建议**:函数模板尽量用**重载**而非特化(更符合直觉、更安全): ```cpp #include #include using namespace std; template T myMax(T a, T b) { return a > b ? a : b; } // 用重载实现 const char* 版本 const char *myMax(const char *a, const char *b) { return strcmp(a, b) > 0 ? a : b; } int main() { cout << myMax(3, 7) << endl; // 7 cout << myMax("apple", "banana") << endl; // banana return 0; } ``` ### 三、类模板全特化 #### 3.1 全特化语法 ```cpp #include using namespace std; // 通用类模板 template class DataBox { private: T value; public: DataBox(T v) : value(v) {} void show() const { cout << "通用: " << value << endl; } T get() const { return value; } }; // 全特化:bool 版本(用位压缩节省内存) template <> class DataBox { private: bool value; public: DataBox(bool v) : value(v) {} void show() const { cout << "bool 特化: " << (value ? "true" : "false") << endl; } bool get() const { return value; } }; int main() { DataBox db1(42); DataBox db2("Hello"); DataBox db3(true); // 使用特化版本 db1.show(); // 通用: 42 db2.show(); // 通用: Hello db3.show(); // bool 特化: true return 0; } ``` #### 3.2 全特化的经典用途:vector 标准库中 `vector` 就是特化的典型例子——用**位压缩**存储,节省 8 倍内存(但代价是返回的是代理对象)。 ### 四、类模板偏特化 #### 4.1 指针偏特化 ```cpp #include using namespace std; // 通用模板 template class Handler { public: static void process(const T &value) { cout << "通用处理: " << value << endl; } }; // 偏特化:指针类型 template class Handler { public: static void process(const T *ptr) { cout << "指针处理: " << *ptr << "(地址 " << ptr << ")" << endl; } }; int main() { Handler h1; h1.process(42); // 通用处理: 42 int value = 99; Handler h2; // 偏特化 h2.process(&value); // 指针处理: 99(地址 0x...) return 0; } ``` #### 4.2 多参数的偏特化 ```cpp #include using namespace std; // 通用:两个类型 template class Printer { public: static void print(const T1 &a, const T2 &b) { cout << "通用: " << a << ", " << b << endl; } }; // 偏特化:两个参数类型相同 template class Printer { public: static void print(const T &a, const T &b) { cout << "同类型特化: " << a << " 与 " << b << endl; } }; // 偏特化:第一个参数是指针 template class Printer { public: static void print(T *ptr, const T2 &b) { cout << "指针特化: " << *ptr << ", " << b << endl; } }; int main() { Printer p1; p1.print(3, 4.5); // 通用: 3, 4.5 Printer p2; p2.print(3, 4); // 同类型特化: 3 与 4 int x = 10; Printer p3; p3.print(&x, 2.5); // 指针特化: 10, 2.5 return 0; } ``` #### 4.3 偏特化的选择规则 1. 全特化优先于偏特化。 2. 偏特化优先于通用模板。 3. 更具体的偏特化优先(如 `T*` 比 `T` 更具体)。 ### 五、类型萃取 type_traits #### 5.1 什么是类型萃取 类型萃取(Type Traits)是**在编译期查询和操作类型属性**的技术。C++11 起标准库提供 ``: ```cpp #include #include using namespace std; int main() { // 类型属性查询(编译期常量) cout << "int 是整数: " << is_integral::value << endl; // 1 cout << "double 是整数: " << is_integral::value << endl; // 0 cout << "int* 是指针: " << is_pointer::value << endl; // 1 cout << "int 是指针: " << is_pointer::value << endl; // 0 // 类型关系 cout << "int == signed int: " << is_same::value << endl; // 1 cout << "in'B��V�6�v�VC�"���5�6�SƖ�B�V�6�v�VC㣧f�VR��V�Fò�������2��rz�X�n�ɥ�bY�{��6�WB��&F�V&�Ri��kZ�x+��"���5�f��F��u����E�c�F�V&�S���V�Fò���&WGW&���Ц��222R�"[��yJ�{;�Y辉>X�`����>X�b�Y �K���zK�K�������������������5���FVw&��C��i��Y ni[NY����B����r�6�"����5�f��F��u����C�C��i��Y nkZ�x+��f��B�F�V&�R����5����FW#�C��i��Y nh�~�(����B��f��B�����5�6�73�C��i��Y n{�{�Y��6�72�7G'V7B����5�6�S�B�S��K�N{�Y�i��Y ny��Y������5�6��7C�C��i��Y b�v��7B����&V��fU�6��7C�C��X뾙�@ const | const int → int | | `remove_reference` | 去除引用 | int& → int | | `decay` | 退化类型 | const int& → int | ### 5.3 decay 与 remove_reference ```cpp #include #include using namespace std; int main() { // remove_reference:去掉引用 using T1 = remove_reference::type; cout << "remove_reference == int: " << is_same::value << endl; // 1 // remove_const:去掉 const using T2 = remove_const::type; cout << "remove_const == int: " << is_same:: value << endl; // 1 // decay:数组退化为指针,const& 退化为值 using T3 = decay::type; cout << "decay == int: " << is_same::value << endl; // 1 using T4 = decay::type; cout << "decay == int*: " << is_same::value << endl; // 1 return 0; } ``` ### 六、自定义类型萃取 #### 6.1 自定义 trait 类 ```cpp #include using namespace std; // 自定义 trait:判断是否为容器类型 template struct IsContainer { static const bool value = false; }; // 特化:数组是容器 template struct IsContainer { static const bool value = true; }; // C++11 风格:使用 integral_constant template struct IsPointer { static const bool value = false; }; template struct IsPointer { static const bool value = true; }; int main() { cout << "int 是容器: " << IsContainer::value << endl; // 0 cout << "int[5] 是容器: " << IsContainer::value << endl; // 1 cout << "int* 是指针: " << IsPointer::value << endl; // 1 cout << "int 是指针: " << IsPointer::value << endl; // 0 return 0; } ``` #### 6.2 基于萃取的类型分派 ```cpp #include #include using namespace std; // 整数版本 void printImpl(int value, true_type) { cout << "整数: " << value << endl; } // 其他类型版本 void printImpl(const auto &value, false_type) { cout << "其他: " << value << endl; } // 用萃取在编译期分派 template void print(const T &value) { // is_integral::type 是 true_type 或 false_type printImpl(value, typename is_integral::type()); } int main() { print(42); // 整数: 42 print(3.14); // 其他: 3.14 print("hello"); // 其他: hello return 0; } ``` #### 6.3 自定义 IsSame trait ```cpp #include using namespace std; // 通用:不相同 template struct IsSame { static const bool value = false; }; // 特化:相同时 template struct IsSame { static const bool value = true; }; // 便捷变量模板(C++17) template const bool IsSame_v = IsSame::value; int main() { cout << IsSame::value << endl; // 1 cout << IsSame::value << endl; // 0 cout << IsSame_v << endl; // 1(C++17) return 0; } ``` ### 七、实战:通用打印工具 综合本篇知识,实现按类型优化的打印: ```cpp #include #include #include using namespace std; // 打印工具类 template class TypePrinter { public: // 编译期判断类型并打印 static void print(const T &value) { printImpl(value, typename is_integral::type(), typename is_floating_point::type()); } private: // 整数 static void printImpl(const T &v, true_type, false_type) { cout << "[整数] " << v << endl; } // 浮点 static void printImpl(const T &v, false_type, true_type) { cout << "[浮点] " << v << endl; } // 其他 static void printImpl(const T &v, false_type, false_type) { cout << "[其他] " << v << endl; } }; // vector 特化打印 template void printVector(const vector &vec) { cout << "[vector<" << typeid(T).name() << "> 大小 " << vec.size() << "] "; for (const auto &item : vec) cout << item << " "; cout << endl; } int main() { TypePrinter::print(42); // [整数] 42 TypePrinter::print(3.14); // [浮点] 3.14 TypePrinter::print("hi"); // [其他] hi vector nums = {1, 2, 3}; printVector(nums); vector words = {"C++", "Java"}; printVector(words); return 0; } ``` ### 总结 本篇讲解了模板特化的概念与种类、函数模板全特化(及用重载替代)、类模板全特化与偏特化的语法和选择规则、type_traits 类型萃取(is_* 系列、remove_* 系列、decay)、自定义 trait 类与类型分派,并用通用打印工具串联实战。重点掌握:全特化 vs 偏特化、_v 后缀简化写法、is_same/decay 的使用、基于萃取的编译期分派。 下一篇将讲解**变参模板、折叠表达式与 SFINAE**,敬请期待! --- ## C++模板教程:函数模板与类模板入门 URL: https://wsj-blog.pages.dev/posts/c-mu-ban-jiao-cheng-han-shu-mu-ban-yu-lei-mu-ban-ru-men/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 16 篇。STL 阶段(11-15 篇)已完成,本篇进入模板与泛型编程:函数模板语法与类型推导、隐式/显式实例化、模板与多文件、类模板成员函数、默认模板参数、非类型模板参数。 ### 一、泛型编程思想 #### 1.1 为什么需要模板 写一个求最大值的函数,若不用模板需要为每种类型写一遍: ```cpp int maxInt(int a, int b) { return a > b ? a : b; } double maxDouble(double a, double b) { return a > b ? a : b; } string maxString(string a, string b) { return a > b ? a : b; } // 每种类型都要写一遍,代码重复 ``` **模板**让「类型」成为参数,一份代码支持所有类型。 #### 1.2 泛型编程 泛型编程(Generic Programming)是**用类型参数编写与类型无关的代码**,STL 就是泛型编程的典范。 ### 二、函数模板 #### 2.1 基本语法 ```cpp #include using namespace std; // 函数模板:template template T myMax(T a, T b) { return a > b ? a : b; } int main() { // 隐式实例化:编译器推导 T cout << myMax(3, 7) << endl; // T = int → 7 cout << myMax(3.5, 2.8) << endl; // T = double → 3.5 cout << myMax('a', 'b') << endl; // T = char → b // 显式指定类型 cout << myMax(3, 2.5) << endl; // 3 string s1 = "apple", s2 = "banana"; cout << myMax(s1, s2) << endl; // banana(字符串按字典序) return 0; } ``` #### 2.2 类型推导规则 ```cpp #include using namespace std; // 返回类型由参数推导 template T square(T x) { return x * x; } // 模板参数之间会统一(同一 T 必须一致) template T add(T a, T b) { return a + b; } int main() { cout << square(5) << endl; // 25 cout << square(2.5) << endl; // 6.25 // 两个参数类型必须一致 // add(3, 3.5); // 错误!int 和 double 冲突 cout << add(3, 3.5) << endl; // 6.5(显式指定) // 自动类型转换在显式指定时生效 cout << add(3, 4) << endl; // 7 return 0; } ``` #### 2.3 多个类型参数 ```cpp #include using namespace std; // 多个类型参数 template T convert(U value) { return static_cast(value); } // 返回类型� � auto + decltype(C++11) template auto addFlexible(T a, U b) -> decltype(a + b) { return a + b; } // C++14 简化:auto 自动推导 template auto multiply(T a, U b) { return a * b; } int main() { cout << convert(3.9) << endl; // 3 cout << addFlexible(3, 4.5) << endl; // 7.5 cout << multiply(2.5, 4) << endl; // 10 return 0; } ``` ### 三、模板与函数重载 #### 3.1 模板与普通函数共存 ```cpp #include using namespace std; // 普通函数(精确匹配优先) int myMax(int a, int b) { cout << "普通函数" << endl; return a > b ? a : b; } // 模板函数 template T myMax(T a, T b) { cout << "模板函数" << endl; return a > b ? a : b; } int main() { cout << myMax(3, 7) << endl; // 普通函数(int 精确匹配) cout << myMax(3.5, 2.8) << endl; // 模板函数(无普通函数匹配) cout << myMax<>(3, 7) << endl; // 模板函数(<> 强制用模板) return 0; } ``` #### 3.2 重载规则总结 1. 普通函数精确匹配优先于模板。 2. 模板可以匹配任何类型3. 用 `<>` 可以强制调用模板版本。 ### 四、模板与多文件 #### 4.1 模板的编译模型 模板是**编译期展开**的,编译器需要看到完整定义才能实例化。因此**模板通常放在头文件中**(与普通函数不同): ```cpp // max_template.h #ifndef MAX_TEMPLATE_H #define MAX_TEMPLATE_H // 模板定义放在头文件(避免链接错误) template T myMax(T a, T b) { return a > b ? a : b; } #endif ``` ```cpp // main.cpp #include #include "max_template.h" using namespace std; int main() { cout << myMax(3, 7) << endl; // 7 cout << myMax(2.5, 1.8) << endl; // 2.5 return 0; } ``` #### 4.2 为什么模板不能只声明在 .cpp 普通函数:声明在 .h,定义在 .cpp,链接器找到定义。 模板:实例化发生在**调用点**,编译器必须看到完整定义,否则报「未定义引用」。 ### 五、类模板 #### 5.1 类模板基础 ```cpp #include using namespace std; // 类模板:存储任意类型 template class Box { private: T value; public: Box(T v) : value(v) {} void set(T v) { value = v; } T get() const { return value; } void show() const { cout << "值: " << value << endl; } }; int main() { Box intBox(42); Box strBox("Hello"); Box dblBox(3.14); intBox.show(); // 值: 42 strBox.show(); // 值: Hello dblBox.show(); // 值: 3.14 intBox.set(100); cout << intBox.get() << endl; // 100 return 0; } ``` #### 5.2 类模板成员函数在类外定义 ```cpp #include using namespace std; template class Calculator { private: T a, b; public: Calculator(T x, T y); T add() const; T subtract() const; }; // 类外定义:需要重复 template 前缀 btract() const; }; // 类外定义:需要重复 template 前缀 template Calculator::Calculator(T x, T y) : a(x), b(y) {} template T Calculator::add() const { return a + b; } template T Calculator::subtract() const { return a - b; } int main() { Calculator ci(10, 3); cout << "int 加法: " << ci.add() << endl; // 13 Calculator cd(5.5, 2.2); cout << "double 减法: " << cd.subtract() << endl; // 3.3 return 0; } ``` #### 5.3 类模板实战:泛型 Stack ```cpp #include using namespace std; template class Stack { private: T data[MAX_SIZE]; int top; public: Stack() : top(-1) {} bool push(const T &value) { if (top >= MAX_SIZE - 1) return false; data[++top] = value; return true; } bool pop(T &value) { if (top < 0) return false; value = data[top--]; return true; } bool isEmpty() const { return top < 0; } int size() const { return top + 1; } }; int main() { // 非类型模板参数:指定大小 Stack stringStack; stringStack.push("C++"); stringStack.push("Python"); stringStack.push("Java"); while (!stringStack.isEmpty()) { string item; stringStack.pop(item); cout << item << " "; } cout << endl; // Java Python C++ return 0; } ``` ### 六、默认模板参数与非类型参数 #### 6.1 默认模板参数 ```cpp #include #include using namespace std; // 默认模板参数:T2 默认 int template class Pair { private: T first; T2 second; public: Pair(T f, T2 s) : first(f), second(s) {} void show() const { cout << first << ", " << second << endl; } }; int main() { Pair p1("张三", 25); // 第二参数默认 int Pair p2("分数", 88.5); // 显式指定 p1.show(); // 张三, 25 p2.show(); // 分数, 88.5 return 0; } ``` #### 6.2 非类型模板参数 ```cpp #include using namespace std; // 非类型模板参数:秬速编译期常量(int、枨举、指针等) template int arraySum(int (&arr)[N]) { int sum = 0; for (int i = 0; i < N; i++) sum += arr[i]; return sum; } template class Rectangle { public: static const int area = W * H; // 编译期计算 }; int main() { int scores[] = {85, 90, 78}; cout << "数组大小: " << arraySum(scores) << endl; // 253(N 自动推导为 3) cout << "矩形面积: " << Rectangle<4, 5>::area << endl; // 20 return 0; } ``` ### 七、模板实战:泛型容器管理 综合本篇知识,实现泛型数据管理器: ```cpp #include #include using namespace std; template class DataManager { private: T *items; int capacity; int count; void resize() { capacity *= 2; T *newItems = new T[capacity]; for (int i = 0; i < count; i++) newItems[i] = items[i]; delete[] items; items = newItems; } public: DataManager(int cap = 4) : capacity(cap), count(0) { items = new T[capacity]; } ~DataManager() { delete[] items; } void add(const T &item) { if (count >= capacity) resize(); items[count++] = item; } T get(int index) const { return items[index]; } int size() const { return count; } T max() const { T result = items[0]; for (int i = 1; i < count; i++) { if (items[i] > result) result = items[i]; } return result; } void showAll() const { for (int i = 0; i < count; i++) { cout << items[i] << " "; } cout << endl; } }; int main() { // 整数管理器 DataManager intManager; intManager.add(85); intManager.add(92); intManager.add(78); intManager.add(95); intManager.add(88); // 触发扩容 cout << "整数列表: "; intManager.showAll(); cout << "最大值: " << intManager.max() << endl; cout << "个数: " << intManager.size() << endl; // 字符串管理器 DataManager strManager; strManager.add("C++"); strManager.add("Python"); strManager.add("Java"); cout << "字符串列表: "; strManager.showAll(); cout << "最大值: " << strManager.max() << endl; return 0; } ``` ### 总结 本篇讲解了泛型编程思想、函数模板的语法与类型推导、模板与函数重载的规则、模板与多文件的编译模型、类模板的成员函数定义、默认模板参数、非类型模板参数,并用泛型数据管理器串联实战。重点掌握:模板必须放在头文件、隐式/显式实例化、类模板类外定义的语法、非类型参数的使用。 下一篇将讲解**特化、偏特化与类型萃取**,敬请期待! --- ## C++STL教程:容器适配器与实用工具 URL: https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-rong-qi-shi-pei-qi-yu-shi-yong-gong-ju/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 15 篇。上一篇讲解了函数对象与 Lambda,本篇讲解容器适配器与实用工具:stack/queue/priority_queue(含自定义比较)、pair/tuple(结构化绑定)、bitset、chrono 时间库、random 随机数库。 ### 一、容器适配器 #### 1.1 什么是容器适配器 容器适配器是**基于其他容器包装出的受限接口**容器,只暴露特定操作: | 适配器 | 底层容器(默认) | 特性 | | --- | --- | --- | | stack | deque | 后进先出 LIFO | | queue | deque | 先进先出 FIFO | | priority_queue | vector | 按优先级出队 | ### 二、stack 栈 #### 2.1 stack 基本操作 ```cpp #include #include using namespace std; int main() { stack s; // 入栈 s.push(10); s.push(20); s.push(30); cout << "栈大小: " << s.size() << endl; // 3 cout << "栈顶: " << s.top() << endl; // 30 // 出栈 s.pop(); cout << "出栈后栈顶: " << s.top() << endl; // 20 // 遍历(弹出方式) while (!s.empty()) { cout << s.top() << " "; s.pop(); } cout << endl; // 20 10 return 0; } ``` #### 2.2 stack 实战:括号匹配 ```cpp #include #include #include using namespace std; bool isBalanced(const string &expr) { stack s; for (char c : expr) { if (c == '(' || c == '[' || c == '{') { s.push(c); } else if (c == ')' || c == ']' || c == '}') { if (s.empty()) return false; char top = s.top(); s.pop(); // 检查是否匹配 if ((c == ')' && top != '(') || (c == ']' && top != '[') || (c == '}' && top != '{')) { return false; } } } return s.empty(); } int main() { cout << "(()): " << isBalanced("(())") << endl; // 1 cout << "([{}]): " << isBalanced("([{}])" << endl; // 1 cout << "(()]: " << isBalanced("(()]") << endl; // 0 cout << "([)]: " << isBalanced("([])") << endl; // 0 cout << "empty: " << isBalanced("") << endl; // 1 return 0; } ``` ### 三、queue 队列 #### 3.1 queue 基本操作 ```cpp #include #include using namespace std; int main() { queue q; // 入队 q.push(10); q.push(20); q.push(30); cout << "队首: " << q.front() << endl; // 10 cout << "队尾: " << q.back() << endl; // 30 // 出队 q.pop(); cout << "出队后队首: " << q.front() << endl; // 20 // 遍历(弹出方式) while (!q.empty()) { cout << q.front() << " "; q.pop(); } cout << endl; // 20 30 return 0; } ``` #### 3.2 queue 实战:任务队列 ```cpp #include #include #include using namespace std; struct Task { string name; int priority; Task(string n, int p) : name(n), priority(p) {} }; int main() { queue tasks; tasks.push(Task("备份数据", 1)); tasks.push(Task("发送邮件", 2)); tasks.push(Task("生成报表", 3)); // 依次处理任务 while (!tasks.empty()) { Task t = tasks.front(); tasks.pop(); cout << "处理: " << t.name << "(优先级 " << t.priority << ")" << endl; } return 0; } ``` ### 四、priority_queue 优先队列 #### 4.1 基本用法 ```cpp #include #include using namespace std; int main() { // 默认:大顶堆(最大元素在队首) priority_queue pq; pq.push(30); pq.push(10); pq.push(50); pq.push(20); cout << "最大值: " << pq.top() << endl; // 50 pq.pop(); cout << "弹出后: " << pq.top() << endl; // 30 // 小顶堆(最小元素在队首) priority_queue, greater> minPQ; minPQ.push(30); minPQ.push(10); minPQ.push(50); cout << "最小值: " << minPQ.top() << endl; // 10 return 0; } ``` #### 4.2 自定义类型的优先队列 ```cpp #include #include #include using namespace std; struct Patient { string name; int severity; // 病情严重程度 Patient(string n, int s) : name(n), severity(s) {} }; // 自定义比较:severity 大的优先 struct ComparePatient { bool operator()(const Patient &a, const Patient &b) const { return a.severity < b.severity; // 与 sort 相反 } }; int main() { priority_queue, ComparePatient> er; er.push(Patient("张三", 3)); er.push(Patient("李四", 5)); er.push(Patient("王五", 2)); er.push(Patient("赵六", 4)); // 按病情紧急程度出队 while (!er.empty()) { Patient p = er.top(); er.pop(); cout << "接诊: " << p.name << "(危重等级 " << p.severity << ")" << endl; } return 0; } ``` ### 五、pair 与 tuple #### 5.1 pair 键值对 ```cpp #include #include #include using namespace std; int main() { // pair 存储两个值 pair p1("张三", 88); pair p2 = {"李四", 92}; auto p3 = make_pair("王五", 76); // 类型推导 cout << p1.first << ": " << p1.second << endl; cout << p2.first << ": " << p2.second << endl; // 修改 p3.second = 80; cout << p3.first << ": " << p3.second << endl; // 比较(先比 first 再比 second) cout << (p1 < p2) << endl; // "张三" < "李四"? 0 // 结构化绑定(C++17) auto [name, score] = p1; cout << "绑定: " << name << " " << score << endl; return 0; } ``` #### 5.2 tuple 多元组 ```cpp #include #include #include using namespace std; int main() { // tuple 可存储任意多个不同类型值 tuple t1(1, "Alice", 88.5); auto t2 = make_tuple(2, "Bob", 95.0); // 访问 cout << get<0>(t1) << " " << get<1>(t1) << " " << get<2>(t1) << endl; // 修改 get<2>(t1) = 90.0; cout << "修改后: " << get<2>(t1) << endl; // 结构化绑定(C++17) auto [id, name, score] = t2; cout << id << " " << name << " " << score << endl; // tie:同时解包 int i; string s; double d; tie(i, s, d) = t1; cout << i << " " << s << " " << d << endl; return 0; } ``` ### 六、bitset 位集 #### 6.1 bitset 基本操作 ```cpp #include #include using namespace std; int main() { // 8 位位集 bitset<8> b1; // 00000000 bitset<8> b2(42); // 00101010(十进制转二进制) bitset<8> b3("10101111"); // 从字符串构造 cout << "b1: " << b1 << endl; cout << "b2: " << b2 << endl; cout << "b3: " << b3 << endl; // 位操作 b3.set(0); // 设置第 0 位 b3.reset(1); // 清除第 1 位 b3.flip(2); // 翻转第 2 位 cout << "操作后: " << c3 << endl; // 查询 cout << "b3 中 1 的个数: " << b3.count() << endl; cout << "b3 大小: " << b3.size() << endl; cout << "b3 是否有 1: " << b3.any() << endl; // 位运算 bitset<8> result = b2 & b3; cout << "b2 & b3: " << result << endl; return 0; } ``` #### 6.2 bitset 实战:素数筛选 ```cpp #include #include using namespace std; // 用 bitset 实现埃拉托斯特尼筛法 void sieve(int n) { bitset<1000> isComposite; for (int i = 2; i * i <= n; i++) { if (!isComposite[i]) { for (int j = i * i; j <= n; j += i) { isComposite[j] = true; } } } cout << "素数: "; for (int i = 2; i <= n; i++) { if (!isComposite[i]) cout << i << " "; } cout << endl; } int main() { sieve(50); return 0; } ``` ### 七、chrono 时间库 #### 7.1 时间点与时长 ```cpp #include #include #include using namespace std; using namespace chrono; int main() { // 当前时间点 auto now = system_clock::now(); cout << "当前时间戳: " << duration_cast(now.time_since_epoch()).count() << " ms" << endl; // 时长类型 seconds s(10); milliseconds ms(1500); minutes m(2); // 时长运算 auto total = s + duration_cast(ms) + m; cout << "总时长: " << total.count() << " 秒" << endl; // 131 // 时长换算 auto ms2 = duration_cast(total); cout << "等于: " << ms2.count() << " 毫秒" << endl; // 131000 return 0; } ``` #### 7.2 程序计时 ```cpp #include #include #include #include using namespace std; using namespace chrono; int main() { vector data(100000); for (int i = 0; i < 100000; i++) data[i] = rand() % 100000; // 开始计时 auto start = high_resolution_clock::now(); sort(data.begin(), data.end()); // 结束计时 auto end = high_resolution_clock::now(); auto duration = duration_cast(end - start); cout << "排序耗时: " << duration.count() << " 毫秒" << endl; return 0; } ``` ### 八、random 随机数库 #### 8.1 现代随机数(优于 rand) ```cpp #include #include using namespace std; int main() { // 随机数引擎 + 种子 random_device rd; // 真随机种子 mt19937 gen(rd()); // 梅森旋转引擎 // 均匀分布 [1, 100] uniform_int_distribution intDist(1, 100); for (int i = 0; i < 5; i++) { cout << intDist(gen) << " "; } cout << endl; // 均匀浮点 [0.0, 1.0) uniform_real_distribution realDist(0.0, 1.0); for (int i = 0; i < 5; i++) { cout << realDist(gen) << " "; } cout << endl; // 正态分布(均值为 0,标准差 1) normal_distribution normalDist(0.0, 1.0); for (int i = 0; i < 5; i++) { cout << normalDist(gen) << " "; } cout << endl; return 0; } ``` #### 8.2 rand vs 现代随机数 | 维度 | rand() | 现代随机数库 | | --- | --- | --- | | 头文件 | cstdlib | random | | 分布 | 只有均匀整数 | 多种分布 | | 质量 | 较差 | 高质量(MT19937) | | 种子 | 需手动 srand | random_device | | 推荐 | 不推荐 | **推荐** | ### 九、实战:任务调度系统 综合本篇知识,实现优先任务调度: ```cpp #include #include #include #include using namespace std; using namespace chrono; struct Job { string id; int priority; // 优先级(越大越优先) double duration; // 预计耗时(秒) Job(string i, int p, double d) : id(i), priority(p), duration(d) {} }; // 大顶堆:优先级高的先执行 struct CompareJob { bool operator()(const Job &a, const Job &b) const { return a.priority < b.priority; } }; int main() { priority_queue, CompareJob> scheduler; scheduler.push(Job("J1", 3, 1.5)); scheduler.push(Job("J2", 5, 0.8)); scheduler.push(Job("J3", 1, 2.0)); scheduler.push(Job("J4", 4, 1.2)); cout << "===== 任务调度(按优先级) =====" << endl; auto start = steady_clock::now(); double totalTime = 0; while (!scheduler.empty()) { Job job = scheduler.top(); scheduler.pop(); cout << "执行 " << job.id << "(优先级 " << job.priority << ",预计 " << job.duration << " 秒)" << endl; totalTime += job.duration; } auto end = steady_clock::now(); auto elapsed = duration_cast(end - start); cout << "总任务数: 4,预计总耗时: " << totalTime << " 秒" << endl; return 0; } ``` ### 总结 本篇讲解了容器适配器(stack 后进先出、queue 先进先出、priority_queue 按优先级)、pair/tuple 多元组与结构化绑定、bitset 位操作与素数筛选、chrono 时间库的计时、random 现代随机数库,并用任务调度系统串联实战。重点掌握:priority_queue 的自定义比较(注意与 sort 相反)、结构化绑定的使用、chrono 计时模式、mt19937 + 分布的搭配。 STL 阶段(11-15 篇)完成!下一篇进入**模板与泛型编程:函数模板与类模板入门**,敬请期待! --- ## C++STL教程:函数对象与Lambda表达式 URL: https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-han-shu-dui-xiang-yu-lambda-biao-da-shi/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 14 篇。上一篇讲解了迭代器与算法库,本篇讲解函数对象与 Lambda 表达式:仿函数、lambda 语法(捕获列表/参数/返回类型)、值捕获与引用捕获、mutable、std::function、std::bind、与算法配合。 ### 一、函数对象(仿函数) #### 1.1 什么是函数对象 函数对象(Functor)是**重载了 `operator()` 的类对象**,可以像函数一样调用。与普通函数相比,它可以**保存状态**。 ```cpp #include using namespace std; // 函数对象:重载 operator() class Adder { private: int base; // 保存状态 public: Adder(int b) : base(b) {} int operator()(int value) const { return value + base; } }; int main() { Adder add5(5); Adder add10(10); cout << add5(3) << endl; // 8(像函数一样调用) cout << add10(3) << endl; // 13 return 0; } ``` #### 1.2 带状态的函数对象 函数对象的优势是**保存状态**,这是普通函数做不到的: ```cpp #include using namespace std; class Counter { private: int count = 0; public: int operator()() { return ++count; // 每次调用状态递增 } }; int main() { Counter counter; cout << counter() << endl; // 1 cout << counter() << endl; // 2 cout << counter() << endl; // 3 return 0; } ``` #### 1.3 函数对象与算法的配合 ```cpp #include #include #include using namespace std; // 阈值过滤器 class GreaterThan { private: int threshold; public: GreaterThan(int t) : threshold(t) {} bool operator()(int value) const { return value > threshold; } }; int main() { vector nums = {3, 8, 1, 6, 9, 2, 7}; // 统计 >5 的元素个数 int count = count_if(nums.begin(), nums.end(), GreaterThan(5)); cout << "大于 5 的个数: " << count << endl; // 4 // 找出第一个 >6 的元素 auto it = find_if(nums.begin(), nums.end(), GreaterThan(6)); if (it != nums.end()) { cout << "第一个 >6: " << *it << endl; // 8 } return 0; } ``` ### 二、Lambda 表达式 #### 2.1 Lambda 基础语法 C++11 引入的 lambda 是**匿名函数对象**,语法简洁: ```cpp [capture](parameters) -> return_type { body }; ``` ```cpp #include using namespace std ; int main() { // 最简单的 lambda:无参数无返回值 auto hello = []() { cout << "Hello Lambda" << endl; }; hello(); // 带参数和返回值 auto add = [](int a, int b) { return a + b; }; cout << add(3, 4) << endl; // 7 // 显式返回类型 auto divide = [](double a, double b) -> double { if (b == 0) return 0; return a / b; }; cout << divide(10, 4) << endl; // 2.5 return 0; } ``` #### 2.2 捕获列表详解 lambda 可以通过捕获列表访问**外部变量**: ```cpp #include using namespace std; int main() { int base = 10; int factor = 2; // 值捕获 [base]:拷贝外部变量 auto addBase = [base](int x) { return x + base; }; cout << addBase(5) << endl; // 15 // 引用捕获 [&factor]:使用引用,可修改外部变量 auto multiply = [&factor](int x) { return x * factor; }; factor = 3; cout << multiply(5) << endl; // 15(factor 已变) // 全部值捕获 [=] auto allByValue = [=](int x) { return x + base + factor; }; // 全部引用捕获 [&] auto allByRef = [&](int x) { return x * factor; }; // 混合捕获 [base, &factor] auto mixed = [base, &factor](int x) { return x + base * factor; }; return 0; } ``` #### 2.3 捕获方式对比 | 捕获方式 | 语法 | 说明 | | --- | --- | --- | | 值捕获 | `[base]` | 拷贝,lambda 内只读 | | 引用捕获 | `[&base]` | 引用,可修改外部 | | 全部值捕获 | `[=]` | 所有用到的按值 | | 全部引用捕获 | `[&]` | 所有用到的按引用 | | 混合 | `[base, &factor]` | 分别指定 | | 全部+例外 | `[=, &factor]` | 除 factor 外按值 | | 全部+例外 | `[&, base]` | 除 base 外按引用 | #### 2.4 mutable:值捕获也可修改 ```cpp #include using namespace std; int main() { int counter = 0; // 默认值捕获在 lambda 内只读 auto increment = [counter]() mutable { counter++; // mutable 允许修改副本 return counter; }; cout << increment() << endl; // 1(副本) cout << increment() << endl; // 2(副本继续) cout << "外部 counter: " << counter << endl; // 0(外部不变) // 引用捕获可以直接修改外部 auto incrementRef = [&counter]() { counter++; return counter; }; cout << increm entRef() << endl; // 1(外部改变) cout << "外部 counter: " << counter << endl; // 1 return 0; } ``` ### 三、Lambda 与算法实战 #### 3.1 排序中的 Lambda ```cpp #include #include #include using namespace std; struct Student { string name; int score; int age; }; int main() { vector students = { {"张三", 88, 20}, {"李四", 92, 19}, {"王五", 88, 22}, {"赵六", 76, 21} }; // 按分数降序 sort(students.begin(), students.end(), [](const Student &a, const Student &b) { return a.score > b.score; }); // 按分数降序,分数相同按年龄升序(多字段排序) sort(students.begin(), students.end(), [](const Student &a, const Student &b) { if (a.score != b.score) return a.score > b.score; return a.age < b.age; }); for (const auto &s : students) { cout << s.name << " " << s.score << "分 " << s.age << "岁" << endl; } return 0; } ``` #### 3.2 Lambda 实现复杂逻辑 ```cpp #include #include #include using namespace std; int main() { vector nums = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; int lower = 3, upper = 8; // 组合条件:筛选 [lower, upper] 范围内的偶数 auto count = count_if(nums.begin(), nums.end(), [lower, upper](int x) { return x >= lower && x <= upper && x % 2 == 0; }); cout << "范围内偶数个数: " << count << endl; // 3(4,6,8) // 变换:范围内的数乘以倍数 int multiplier = 10; vector result; transform(nums.begin(), nums.end(), back_inserter(result), [lower, upper, multiplier](int x) { if (x >= lower && x <= upper) return x * multiplier; return x; }); cout << "变换结果: "; for (int x : result) cout << x << " "; cout << endl; return 0; } ``` ### 四、std::function 与 std::bind #### 4.1 std::function 通用函数包装 `std::function` 可以存储**任何可调用对象**(函数指针、lambda、函数对象): ```cpp #include #include using namespace std; int add(int a, int b) { return a + b; } class Multiplier { public: int factor; Multiplier(int f) : factor(f) {} int operator ()(int x) const { return x * factor; } }; int main() { // std::function 存储不同类型的可调用对象 function op1 = add; // 函数指针 function op2 = [](int a, int b) { // lambda return a - b; }; function op3 = Multiplier(5); // 函数对象 cout << op1(3, 4) << endl; // 7 cout << op2(10, 3) << endl; // 7 cout << op3(6) << endl; // 30 // 可重新赋值 op1 = [](int a, int b) { return a * b; }; cout << op1(3, 4) << endl; // 12 return 0; } ``` #### 4.2 std::function 作为参数 ```cpp #include #include #include using namespace std; // 把"操作"作为参数传入 void processVector(vector &v, const function &operation) { for (auto &x : v) { operation(x); } } int main() { vector nums = {1, 2, 3, 4}; // 传入不同的操作 processVector(nums, [](int &x) { x *= 2; }); cout << "翻倍: "; for (int x : nums) cout << x << " "; cout << endl; // 2 4 6 8 processVector(nums, [](int &x) { x += 1; }); cout << "加一: "; for (int x : nums) cout << x << " "; cout << endl; // 3 5 7 9 return 0; } ``` #### 4.3 std::bind 绑定参数 ```cpp #include #include using namespace std; int power(int base, int exp) { int result = 1; for (int i = 0; i < exp; i++) result *= base; return result; } int main() { using namespace std::placeholders; // 绑定第二个参数为 2(平方函数) auto square = bind(power, _1, 2); cout << "square(5) = " << square(5) << endl; // 25 // 绑定第一个参数为 2(2 的 n 次方) auto powerOf2 = bind(power, 2, _1); cout << "powerOf2(10) = " << powerOf2(10) << endl; // 1024 // 反转参数顺序 auto rev = bind(power, _2, _1); cout << "rev(3, 2) = " << rev(3, 2) << endl; // 8(2^3) return 0; } ``` ### 五、函数对象 vs Lambda 对比 | 维度 | 函数对象 | Lambda | | --- | --- | --- | | 语法 | 定义类,代码长 | 简洁 | | 状态 | 成员变量保存 | 捕获列表 | | 复用 | 可多次实例化 | 每次都是新类型 | | 调试 | 有类型名 | 匿名类型 | | 使用场景 | 需要复用的逻辑 | 一次性的局部逻辑 | ### 六、实战:� ��配置的统计工具 综合本篇知识,用 std::function 实现可配置统计: ```cpp #include #include #include #include using namespace std; class StatsAnalyzer { private: vector data; public: void addData(const vector &values) { data.insert(data.end(), values.begin(), values.end()); } // 通用过滤统计:传入任意判断条件 int countIf(const function &predicate) const { return count_if(data.begin(), data.end(), predicate); } // 通用聚合:传入任意累积规则 int aggregate(int init, const function &accumulator) const { int result = init; for (int x : data) { result = accumulator(result, x); } return result; } // 通用转换:返回新数据 vector transformData(const function &mapper) const { vector result; transform(data.begin(), data.end(), back_inserter(result), mapper); return result; } }; int main() { StatsAnalyzer analyzer; analyzer.addData({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); // 不同条件统计 cout << "偶数: " << analyzer.countIf([](int x) { return x % 2 == 0; }) << endl; cout << "大于 5: " << analyzer.countIf([](int x) { return x > 5; }) << endl; // 不同聚合方式 cout << "求和: " << analyzer.aggregate(0, [](int a, int b) { return a + b; }) << endl; cout << "求积: " << analyzer.aggregate(1, [](int a, int b) { return a * b; }) << endl; // 不同转换 auto squares = analyzer.transformData([](int x) { return x * x; }); cout << "平方: "; for (int x : squares) cout << x << " "; cout << endl; return 0; } ``` ### 总结 本篇讲解了函数对象(带状态、与算法配合)、Lambda 表达式的完整语法(捕获列表、mutable)、值捕获与引用捕获的区别、std::function 通用包装、std::bind 参数绑定,并用可配置统计工具串联实战。重点掌握:捕获列表的七种写法、mutable 的作用、std::function 的灵活用法、多字段排序的 lambda 写法。 下一篇将讲解**容器适配器与实用工具**(stack/queue/priority_queue、pair/tuple、chrono、random),敬请期待! --- ## C++STL教程:迭代器与算法库 URL: https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-die-dai-qi-yu-suan-fa-ku/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 13 篇。上一篇讲解了关联容器与无序容器,本篇讲解迭代器与算法库:迭代器五类与分类、常用算法(sort/find/find_if/count/copy/transform/accumulate/reverse/unique)、删除-擦除惯用法。 ### 一、迭代器基础 #### 1.1 什么是迭代器 迭代器是**统一访问容器元素**的接口,让算法不关心容器类型。可以把迭代器理解为「智能指针」,支持 `*`、`->`、`++`、`--` 等操作。 #### 1.2 迭代器分类 | 类别 | 支持操作 | 可用容器 | | --- | --- | --- | | 输入迭代器 | 只读、++ | istream | | 输出迭代器 | 只写、++ | ostream | | 前向迭代器 | 读写、++ | forward_list | | 双向迭代器 | 读写、++/– | list, set, map | | 随机访问迭代器 | 读写、±/[] | vector, deque, array | #### 1.3 常用迭代器操作 ```cpp #include #include using namespace std; int main() { vector v = {10, 20, 30, 40, 50}; // begin/end cout << "正向遍历: "; for (auto it = v.begin(); it != v.end(); it++) { cout << *it << " "; } cout << endl; // rbegin/rend(反向) cout << "反向遍历: "; for (auto it = v.rbegin(); it != v.rend(); it++) { cout << *it << " "; } cout << endl; // cbegin/cend(const 迭代器) auto cit = v.cbegin(); cout << "首个元素: " << *cit << endl; // *cit = 99; // 错误!const 迭代器不可修改 // 随机访问 auto it = v.begin(); cout << "*(it+2) = " << *(it + 2) << endl; // 30 cout << "it[3] = " << it[3] << endl; // 40 it += 4; cout << "it[0] = " << it[0] << endl; // 50 return 0; } ``` #### 1.4 迭代器失效规则 | 操作 | vector/deque | list/set/map | | --- | --- | --- | | 插入 | 可能全部失效(扩容) | 其他迭代器不受彲响 | | 删除 | 被删元素之后的失效 | 只影响被删元素 | | 注意 | 删除后迭代器悬垂 | 相对安全 | ### 二、查找与统计算法 #### 2.1 find 与 find_if ```cpp #include #include #include using namespace std; int main() { vector v = {5, 2, 8, 1, 9, 3}; // find:查找指定值,返回迭代器 auto it = find(v.begin(), v.end(), 8); if (it != v.end()) { cout << "找到 8,位置: " << (it - v.begin()) << end l; } else { cout << "未找到" << endl; } // find_if:按条件查找 auto it2 = find_if(v.begin(), v.end(), [](int x) { return x > 5; }); // 第一个 >5 的元素 if (it2 != v.end()) { cout << "第一个 >5 的元素: " << *it2 << endl; // 8 } return 0; } ``` #### 2.2 count 与 count_if ```cpp #include #include #include using namespace std; int main() { vector v = {1, 2, 3, 1, 4, 1, 5}; // count:统计指定值出现次数 cout << "1 出现: " << count(v.begin(), v.end(), 1) << " 次" << endl; // 3 // count_if:按条件统计 cout << "偶数个数: " << count_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; }) << endl; // 2 // 判断是否全部满足 bool allPositive = all_of(v.begin(), v.end(), [](int x) { return x > 0; }); cout << "全部为正数: " << allPositive << endl; // 1 // 判断是否存在满足条件 bool anyBig = any_of(v.begin(), v.end(), [](int x) { return x > 4; }); cout << "存在大于 4: " << anyBig << endl; // 1 return 0; } ``` ### 三、排序与排列算法 #### 3.1 sort 与自定义排序 ```cpp #include #include #include using namespace std; struct Student { string name; int score; }; int main() { vector v = {3, 1, 4, 1, 5, 9}; // 默认升序 sort(v.begin(), v.end()); for (int x : v) cout << x << " "; cout << endl; // 1 1 3 4 5 9 // 降序 sort(v.begin(), v.end(), greater()); for (int x : v) cout << x << " "; cout << endl; // 9 5 4 3 1 1 // 结构体自定义排序 vector students = { {"张三", 88}, {"李四", 92}, {"王五", 76} }; // 按分数降序 sort(students.begin(), students.end(), [](const Student &a, const Student &b) { return a.score > b.score; }); for (const auto &s : students) { cout << s.name << " " << s.score << endl; } return 0; } ``` #### 3.2 stable_sort 与 partial_sort ```cpp #include #include #include using namespace std; int main() { // stable_sort:相等元素保持原顺序 vector> v = {{1, 5}, {2, 3}, {1, 7}, {2, 1}}; stable_sort(v.begin(), v.end(), [](const auto &a , const auto &b) { return a.first < b.first; }); // 相同 first 的元素保持相对顺序:(1,5) 在 (1,7) 前 for (const auto &p : v) { cout << "(" << p.first << "," << p.second << ") "; } cout << endl; // partial_sort:只保证前 n 个是最小的 vector nums = {5, 2, 8, 1, 9, 3, 7}; partial_sort(nums.begin(), nums.begin() + 3, nums.end()); cout << "前 3 个最小: "; for (int i = 0; i < 3; i++) cout << nums[i] << " "; cout << endl; // 1 2 3 return 0; } ``` #### 3.3 nth_element 与 reverse ```cpp #include #include #include using namespace std; int main() { // nth_element:第 n 个位置是正确排序值,左侧都小右侧都大 vector v = {7, 2, 9, 1, 5, 3, 8}; nth_element(v.begin(), v.begin() + 3, v.end()); cout << "第 4 小的元素: " << v[3] << endl; // 5 // reverse:反转 vector r = {1, 2, 3, 4, 5}; reverse(r.begin(), r.end()); for (int x : r) cout << x << " "; cout << endl; // 5 4 3 2 1 // rotate:旋转 vector rot = {1, 2, 3, 4, 5}; rotate(rot.begin(), rot.begin() + 2, rot.end()); for (int x : rot) cout << x << " "; cout << endl; // 3 4 5 1 2 return 0; } ``` ### 四、复制与变换算法 #### 4.1 copy 与 copy_if ```cpp #include #include #include using namespace std; int main() { vector src = {1, 2, 3, 4, 5}; // copy:整段复制 vector dest(src.size()); copy(src.begin(), src.end(), dest.begin()); for (int x : dest) cout << x << " "; cout << endl; // 1 2 3 4 5 // copy_if:按条件复制 vector evens; copy_if(src.begin(), src.end(), back_inserter(evens), [](int x) { return x % 2 == 0; }); for (int x : evens) cout << x << " "; cout << endl; // 2 4 // back_inserter:自动扩容追加 vector more; copy(src.begin(), src.end(), back_inserter(more)); cout << "more size = " << more.size() << endl; // 5 return 0; } ``` #### 4.2 transform 变换 ```cpp #include #include #include using namespace std; int main() { vector nums = {1, 2, 3, 4}; // transform:一元变换(每个元素平方) vector squares(nums.size()); transform(nums.begin(), nums.end(), squares.begin(), [](int x) { return x * x; }); for (int x : squares) cout << x << " "; cout << endl; // 1 4 9 16 // transform:二元变换(两序列对应相加) vector a = {1, 2, 3}; vector b = {10, 20, 30}; vector sum(3); transform(a.begin(), a.end(), b.begin(), sum.begin(), [](int x, int y) { return x + y; }); for (int x : sum) cout << x << " "; cout << endl; // 11 22 33 // 原地变换 transform(nums.begin(), nums.end(), nums.begin(), [](int x) { return x * 10; }); for (int x : nums) cout << x << " "; cout << endl; // 10 20 30 40 return 0; } ``` ### 五、数值与去重算法 #### 5.1 accumulate 与 iota ```cpp #include #include #include #include using namespace std; int main() { vector v = {1, 2, 3, 4, 5}; // accumulate:求和(初始值 0) int sum = accumulate(v.begin(), v.end(), 0); cout << "总和: " << sum << endl; // 15 // accumulate:自定义累加(求积) int product = accumulate(v.begin(), v.end(), 1, [](int a, int b) { return a * b; }); cout << "乘积: " << product << endl; // 120 // iota:填充递增序列 vector seq(5); iota(seq.begin(), seq.end(), 10); // 10 11 12 13 14 for (int x : seq) cout << x << " "; cout << endl; // inner_product:内积 vector a = {1, 2, 3}; vector b = {4, 5, 6}; int dot = inner_product(a.begin(), a.end(), b.begin(), 0); cout << "内积: " << dot << endl; // 32 return 0; } ``` #### 5.2 unique 与 erase ```cpp #include #include #include using namespace std; int main() { // unique 只去重相邻重复,通常先排序 vector v = {1, 1, 2, 2, 3, 1, 2}; sort(v.begin(), v.end()); auto last = unique(v.begin(), v.end()); // 此时 v 是 1 2 3 2 3(尾部是"垃圾") // 删除-擦除惯用法:unique 返回新结尾,erase 删除尾部 v.erase(last, v.end()); for (int x : v) cout << x << " "; cout << endl; // 1 2 3 // 删除所有偶数(remove_if + erase 惯用法) vector nums = {1, 2, 3, 4, 5, 6}; nums.erase( remove_if(nums.begin(), nums.end(), [](int x) { return x % 2 == 0; }), nums.end()); for (int x : nums) cout << x << " " ; cout << endl; // 1 3 5 return 0; } ``` #### 5.3 删除-擦除惯用法详解 ```cpp #include #include #include using namespace std; int main() { vector v = {1, 2, 3, 2, 4, 2, 5}; // remove 不真正删除,只是把保留元素移到前面,返回新逻辑结尾 auto newEnd = remove(v.begin(), v.end(), 2); cout << "remove 后 size 未变: " << v.size() << endl; // 7 // erase 真正删除尾部 v.erase(newEnd, v.end()); cout << "erase 后 size: " << v.size() << endl; // 4 for (int x : v) cout << x << " "; cout << endl; // 1 3 4 5 // 一行惯用法 vector w = {1, 2, 3, 4, 5, 6}; w.erase(remove_if(w.begin(), w.end(), [](int x) { return x % 2 == 0; }), w.end()); for (int x : w) cout << x << " "; cout << endl; // 1 3 5 return 0; } ``` ### 六、最小最大值与二分查找 #### 6.1 min/max 系列 ```cpp #include #include #include using namespace std; int main() { vector v = {5, 2, 8, 1, 9}; cout << "max: " << *max_element(v.begin(), v.end()) << endl; // 9 cout << "min: " << *min_element(v.begin(), v.end()) << endl; // 1 // minmax:同时获取 auto result = minmax_element(v.begin(), v.end()); cout << "min=" << *result.first << ", max=" << *result.second << endl; // 两个值比较 cout << "max(3,7) = " << max(3, 7) << endl; // 7 cout << "min(3,7) = " << min(3, 7) << endl; // 3 // clamp:限制范围 cout << "clamp(15, 0, 10) = " << clamp(15, 0, 10) << endl; // 10 cout << "clamp(-5, 0, 10) = " << clamp(-5, 0, 10) << endl; // 0 return 0; } ``` #### 6.2 binary_search 与 lower_bound ```cpp #include #include #include using namespace std; int main() { vector v = {1, 3, 5, 7, 9, 11}; // binary_search:二分查找(要求已排序) cout << "查找 5: " << binary_search(v.begin(), v.end(), 5) << endl; // 1 cout << "查找 6: " << binary_search(v.begin(), v.end(), 6) << endl; // 0 // lower_bound:第一个 >= 值的位置 auto lb = lower_bound(v.begin(), v.end(), 6); cout << "lower_bound(6) = " << *lb << endl; // 7 // upper_bound:第一个 > 值的位置 auto ub = upper_bound(v.begin(), v.end(), 7); cout << "upper_bound(7) = " << *ub << endl; // 9 // equal_range:等于值的一整段 vector dup = {1, 2, 2, 2, 3, 4}; auto range = equal_range(dup.begin(), dup.end(), 2); cout << "2 的出现次数: " << (range.second - range.first) << endl; // 3 return 0; } ``` ### 七、实战:文本分析工具 综合本篇全部算法,实现文本统计工具: ```cpp #include #include #include #include #include #include using namespace std; class TextAnalyzer { private: vector words; public: void addWords(const vector &newWords) { words.insert(words.end(), newWords.begin(), newWords.end()); } size_t totalCount() const { return words.size(); } // 去重后单词种类 size_t uniqueCount() const { vector temp = words; sort(temp.begin(), temp.end()); temp.erase(unique(temp.begin(), temp.end()), temp.end()); return temp.size(); } // 平均单词长度 double averageLength() const { if (words.empty()) return 0; int total = accumulate(words.begin(), words.end(), 0, [](int sum, const string &w) { return sum + w.length(); }); return static_cast(total) / words.size(); } // 最长单词 string longestWord() const { return *max_element(words.begin(), words.end(), [](const string &a, const string &b) { return a.length() < b.length(); }); } // 词频统计(Top N) void showTopFrequent(int n = 3) const { map freq; for (const auto &w : words) freq[w]++; vector> items(freq.begin(), freq.end()); sort(items.begin(), items.end(), [](const auto &a, const auto &b) { return a.second > b.second; }); cout << "===== Top " << n << " 高频词 =====" << endl; for (int i = 0; i < min(n, (int)items.size()); i++) { cout << items[i].first << ": " << items[i].second << " 次" << endl; } } }; int main() { TextAnalyzer analyzer; analyzer.addWords({"cpp", "python", "java", "cpp", "go", "python", "cpp", "rust", "java"}); cout << "总词数: " << analyzer.totalCount() << endl; // 9 cout << "去重词数: " << analyzer.uniqueCount() << endl; // 5 cout << "平均长度: " << analyzer.averageLength() << endl; cout << "最长单词: " << analyzer.longestWord() << endl; analyzer.showTopFrequent(3); return 0; } ``` ### 总结 本篇讲解了迭代器的五类与失效规则、查找统计算法(find/count)、排序排列算法(sort/stable_sort/nth_element)、复制变换算法(copy/transform)、数值去重算法(accumulate/unique)、最小最大值与二分查找,并用文本分析工具串联实战。重点掌握:删除-擦除惯用法、transform 的一元/二元变换、back_inserter、二分查找的前提(有序)。 下一篇将讲解**函数对象与 Lambda 表达式**,敬请期待! --- ## C++STL教程:关联容器与无序容器 URL: https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-guan-lian-rong-qi-yu-wu-xu-rong-qi/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 12 篇。上一篇讲解了序列容器,本篇讲解关联容器与无序容器:set/multiset、map/multimap(红黑树实现)、unordered_set/unordered_map(哈希表实现)、自定义比较器与哈希函数。 ### 一、关联容器概览 #### 1.1 两类关联容器 | 类别 | 实现 | 查找复杂度 | 顺序 | | --- | --- | --- | --- | | 关联容器(set/map) | 红黑树 | O(log n) | 有序 | | 无序容器(unordered_*) | 哈希表 | O(1) 平均 | 无序 | #### 1.2 六种关联容器 | 容器 | 键唯一 | 键值对 | 有序 | | --- | --- | --- | --- | | set | 是 | 否 | 是 | | multiset | 否 | 否 | 是 | | map | 是 | 是 | 是 | | multimap | 否 | 是 | 是 | | unordered_set | 是 | 否 | 否 | | unordered_map | 是 | 是 | 否 | ### 二、set 集合 #### 2.1 set 基本操作 set 存储**唯一且有序**的元素,自动去重: ```cpp #include #include using namespace std; int main() { set s; // 插入(自动排序去重) s.insert(5); s.insert(3); s.insert(8); s.insert(3); // 重复,不插入 s.insert(1); // 遍历(有序) for (int x : s) cout << x << " "; cout << endl; // 1 3 5 8 // 查找 auto it = s.find(5); if (it != s.end()) cout << "找到 5" << endl; // 计数(set 里只能是 0 或 1) cout << "3 的数量: " << s.count(3) << endl; // 1 cout << "9 的数量: " << s.count(9) << endl; // 0 // 删除 s.erase(5); cout << "size = " << s.size() << endl; // 3 // 下界上界 auto lb = s.lower_bound(3); // >= 3 的第一个 auto ub = s.upper_bound(3); // > 3 的第一个 cout << "lower_bound(3) = " << *lb << endl; // 3 cout << "upper_bound(3) = " << *ub << endl; // 8 return 0; } ``` #### 2.2 multiset 允许重复 ```cpp #include #include using namespace std; int main() { multiset ms; ms.insert(3); ms.insert(1); ms.insert(3); // 允许重复 ms.insert(2); ms.insert(3); cout << "3 的数量: " << ms.count(3) << endl; // 3 // 删除所有 3 ms.erase(3); cout << "删除后 size = " << ms.size() << endl; // 2 // 只删除一个 ms.insert(3); ms.insert(3); auto it = ms.find(3); if (it != ms.end()) ms.erase(it); // 只删一个 cout << "3 的数量: " << ms.count(3) << endl; // 1 return 0; } ``` #### 2.3 set 实战:去重排序 ```cpp #include #include using namespace std; int main() { int numbers[] = {5, 2, 8, 2, 9, 1, 5, 8}; // set 自动去重 + 排序 set uniqueSorted(numbers, numbers + 8); cout << "去重排序后: "; for (int x : uniqueSorted) cout << x << " "; cout << endl; // 1 2 5 8 9 // 自定义排序(降序) set> desc(numbers, numbers + 8); cout << "降序: "; for (int x : desc) cout << x << " "; cout << endl; // 9 8 5 2 1 return 0; } ``` ### 三、map 映射 #### 3.1 map 基本操作 map 存储**键值对**,按键自动排序: ```cpp #include #include #include using namespace std; int main() { map scores; // 插入 scores["张三"] = 88; // 方式一:下标 scores["李四"] = 92; scores.insert({"王五", 76}); // 方式二:insert // 访问 cout << "张三: " << scores["张三"] << endl; // 88 cout << "size = " << scores.size() << endl; // 3 // 遍历(按键排序) for (const auto &kv : scores) { cout << kv.first << ": " << kv.second << endl; } // 查找 auto it = scores.find("李四"); if (it != scores.end()) { cout << "找到 " << it->first << " = " << it->second << endl; } // 判断键是否存在(注意下标会创建) if (scores.count("赵六") == 0) { cout << "赵六不存在" << endl; } // scores["赵六"]; // 危险!这会插入一个默认值 // 删除 scores.erase("王五"); return 0; } ``` #### 3.2 map 的 [] 陷阱 ```cpp #include #include #include using namespace std; int main() { map scores; // [] 不存在时自动插入默认值 cout << "赵六: " << scores["赵六"] << endl; // 0(且已插入!) cout << "size = " << scores.size() << endl; // 1 // 正确判断存在性:用 find 或 count map m = {{"a", 1}}; if (m.find("b") == m.end()) { cout << "b 不存在(用 find 判断)" << endl; } return 0; } ``` #### 3.3 multimap 一对多 ```cpp #include #include #include using namespace std; int main() { // 一个键对应多个值 multimap courses; courses.insert({"张三", "C++"}); courses.insert({"张三", "Python"}); courses.insert({"李四", "Java"}); // 统计张三的课程数 cout << "张三的课程数: " << courses.count("张三") << endl; // 2 // 遍历张三的所有课程 auto range = courses.equal_range("张三"); for (auto it = range.first; it != range.second; it++) { cout << it->second << " "; } cout << endl; // C++ Python return 0; } ``` ### 四、unordered_set 与 unordered_map #### 4.1 哈希表特性 unordered 容器用**哈希表**实现,查找 O(1)(平均),但**无序**: ```cpp #include #include #include using namespace std; int main() { unordered_map umap; // 插入 umap["apple"] = 3; umap["banana"] = 5; umap["cherry"] = 2; // 查找 O(1) auto it = umap.find("banana"); if (it != umap.end()) { cout << "banana: " << it->second << endl; // 5 } // 遍历(无序!) for (const auto &kv : umap) { cout << kv.first << ": " << kv.second << endl; } return 0; } ``` #### 4.2 map vs unordered_map | 维度 | map | unordered_map | | --- | --- | --- | | 实现 | 红黑树 | 哈希表 | | 查找 | O(log n) | O(1) 平均 | | 顺序 | 有序 | 无序 | | 内存 | 较小 | 较大 | | 适用 | 需要有序遍历 | 追求查找速度 | #### 4.3 词频统计实战 ```cpp #include #include #include #include using namespace std; int main() { string text = "the quick brown fox jumps over the lazy dog the fox"; unordered_map freq; stringstream ss(text); string word; // 统计每个单词出现次数 while (ss >> word) { freq[word]++; } // 输出词频 for (const auto &kv : freq) { cout << kv.first << ": " << kv.second << endl; } // 查询 cout << "\"the\" 出现次数: " << freq["the"] << endl; // 3 return 0; } ``` ### 五、自定义比较器与哈希 #### 5.1 set/map 自定义排序 ```cpp #include #include #include using namespace std; struct Person { string name; int age; }; // 自定义比较器(按年龄排序) struct CompareByAge { bool operator()(const Person &a, const Person &b) const { return a.age < b.age; } }; int main() { set people; people.insert({"张三", 30}); people.insert({"李四", 25}); people.insert({"王五", 35}); // 按年龄升序遍历 for (const auto &p : people) { cout << p.name << " " << p.age << "岁" << endl; } return 0; } ``` #### 5.2 unordered_map 自定义哈希 ```cpp #include #include #include using namespace std; struct Point { int x, y; // 必须实现 == 运算符 bool operator==(const Point &other) const { return x == other.x && y == other.y; } }; // 自定义哈希函数 struct PointHash { size_t operator()(const Point &p) const { return hash()(p.x) ^ (hash()(p.y) << 1); } }; int main() { unordered_map points; points[{1, 2}] = "A"; points[{3, 4}] = "B"; points[{1, 2}] = "C"; // 覆盖 cout << "({1,2}) = " << points[{1, 2}] << endl; // C cout << "({3,4}) = " << points[{3, 4}] << endl; // B return 0; } ``` ### 六、实战:成绩管理系统 综合本篇知识,实现基于 map 的成绩管理: ```cpp #include #include #include #include using namespace std; class ScoreSystem { private: map scores; // 有序:用于排名展示 unordered_map classes; // 快速查找班级 public: void addStudent(const string &name, const string &cls, int score) { scores[name] = score; classes[name] = cls; } void showAll() const { cout << "===== 成绩表(按姓名排序) =====" << endl; for (const auto &kv : scores) { cout << kv.first << " " << classes.at(kv.first) << "班 " << kv.second << "分" << endl; } } void showTop(int n = 3) const { cout << "===== 前 " << n << " 名 =====" << endl; // 用 multiset 按分数排序 multimap> ranking; for (const auto &kv : scores) { ranking.insert({kv.second, kv.first}); } int count = 0; for (const auto &kv : ranking) { if (count++ >= n) break; cout << kv.second << " " << kv.first << "分" << endl; } } void query(const string &name) const { auto it = scores.find(name); if (it != scores.end()) { cout << name << " " << classes.at(name) << "班 " << it->second << "分" << endl; } else { cout << name << " 不存在" << endl; } } }; int main() { ScoreSystem sys; sys.addStudent("张三", "三", 88); sys.addStudent("李四", "一", 95); sys.addStudent("王五", "二", 76); sys.addStudent("赵六", "三", 91); sys.showAll(); sys.showTop(3); sys.query("李四"); return 0; } ``` ### 总结 本篇讲解了关联容器(set/multiset、map/multimap)与无序容器(unordered_set/unordered_map)的差异、map 的 [] 陷阱、自定义比较器与哈希函数,并用成绩管理系统串联实战。重点掌握:set/map 的有序特性、unordered 容器的 O(1) 查找、count vs find 判断存在性、自定义哈希的写法。 下一篇将讲解**迭代器与算法库**(sort/find/transform/accumulate 等),敬请期待! --- ## C++STL教程:容器总览与序列容器 URL: https://wsj-blog.pages.dev/posts/c-stl-jiao-cheng-rong-qi-zong-lan-yu-xu-lie-rong-qi/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 11 篇。进阶阶段(6-10 篇)已完成,本篇进入 STL 阶段:先总览六大容器家族,再深入序列容器 vector(扩容机制)、deque、list、forward_list、array 的用法与选择。 ### 一、STL 总览 #### 1.1 什么是 STL STL(Standard Template Library,标准模板库)是 C++ 标准库的核心,由**容器、迭代器、算法、函数对象、适配器**五大部分组成。它把「数据存储」与「数据处理」解耦,实现高效复用。 #### 1.2 六大容器家族 | 类别 | 容器 | 特点 | | --- | --- | --- | | 序列容器 | vector, deque, list, forward_list, array | 线性存储,按位置访问 | | 关联容器 | set, multiset, map, multimap | 红黑树,自动排序 | | 无序容器 | unordered_set, unordered_map 等 | 哈希表,O(1) 查找 | | 容器适配器 | stack, queue, priority_queue | 限制接口的包装 | | 字符串 | string | 字符序列 | | 其他 | bitset, valarray | 专用 | #### 1.3 如何选择容器 | 需求 | 推荐容器 | | --- | --- | | 随机访问 + 尾部增删 | vector | | 头尾都频繁增删 | deque | | 任意位置频繁插入删除 | list | | 需要有序且唯一 | set / map | | 需要快速查找 | unordered_map / unordered_set | | 先进后出 | stack | | 先进先出 | queue | | 取最大/最小元素 | priority_queue | ### 二、vector 详解 #### 2.1 vector 基本操作 ```cpp #include #include using namespace std; int main() { // 创建 vector v1; // 空 vector v2(5); // 5 个 0 vector v3(5, 10); // 5 个 10 vector v4 = {1, 2, 3, 4}; // 列表初始化 // 增删 v1.push_back(10); // 尾部添加 v1.push_back(20); v1.push_back(30); v1.pop_back(); // 删除尾部(30) // 访问 cout << "v1[0] = " << v1[0] << endl; // 10 cout << "v1.at(1) = " << v1.at(1) << endl; // 20(带边界检查) cout << "front = " << v1.front() << endl; // 10 cout << "back = " << v1.back() << endl; // 20 // 大小 cout << "size = " << v1.size() << endl; // 2 cout << "empty = " << v1.empty() << endl; // 0 cout << "capacity = " << v1.capacity() << endl; // 2(可能) // 遍历 for (int x : v1) cout << x << " "; cout << endl; return 0; } ``` #### 2.2 vector 扩容机制 vector 容量不足时按**倍增策略**扩容(通常 ×2),元素迁移到新内存: ```cpp #include #include using namespace std; int main() { vector v; int prevCap = 0; for (int i = 0; i < 16; i++) { v.push_back(i); if (v.capacity() != prevCap) { cout << "push_back(" << i << ") 后容量: " << v.capacity() << endl; prevCap = v.capacity(); } } return 0; } ``` 典型输出(容量变化 1→2→4→8→16): ``` push_back(0) 后容量: 1 push_back(1) 后容量: 2 push_back(2) 后容量: 4 push_back(4) 后容量: 8 push_back(8) 后容量: 16 ``` **意义**:倍增策略让 push_back 的均摊复杂度为 O(1),但扩容时旧迭代器全部失效。 #### 2.3 插入与删除 ```cpp #include #include using namespace std; int main() { vector v = {1, 2, 3, 4, 5}; // insert:在指定位置前插入 v.insert(v.begin() + 2, 99); // 1 2 99 3 4 5 v.insert(v.end(), 100); // 尾部插入 = push_back // erase:删除指定位置 v.erase(v.begin()); // 删除第一个 v.erase(v.begin() + 1, v.begin() + 3); // 删除区间 for (int x : v) cout << x << " "; cout << endl; // clear:清空 v.clear(); cout << "clear 后 size = " << v.size() << endl; return 0; } ``` #### 2.4 迭代器失效问题 ```cpp #include #include using namespace std; int main() { vector v = {1, 2, 3, 4, 5}; // 错误示范:erase 后迭代器失效 // for (auto it = v.begin(); it != v.end(); it++) { // if (*it % 2 == 0) v.erase(it); // 危险!it 失效 // } // 正确示范:erase 返回下一个有效迭代器 for (auto it = v.begin(); it != v.end();) { if (*it % 2 == 0) { it = v.erase(it); // 删除并获取下一个 } else { it++; } } for (int x : v) cout << x << " "; cout << endl; // 1 3 5 return 0; } ``` #### 2.5 实用操作 ```cpp #include #include #include using namespace std; int main() { vector v = {3, 1, 4, 1, 5, 9, 2, 6}; // 排序 sort(v.begin(), v.end()); // 1 1 2 3 4 5 6 9 // 反转 reverse(v.begin(), v.end()); // 9 6 5 4 3 2 1 1 // 查找 auto it = find(v.begin(), v.end(), 4); if (it != v.end()) { cout << "找到 4 在位置 " << (it - v.begin()) << endl; } // 统计 cout << "1 出现次数: " << count(v.begin(), v.end(), 1) << endl; // 最大值最小值 cout << "最大值: " << *max_element(v.begin(), v.end()) << endl; cout << "最小值: " << *min_element(v.begin(), v.end()) << endl; // 删除重复(先排序再 unique) sort(v.begin(), v.end()); v.erase(unique(v.begin(), v.end()), v.end()); for (int x : v) cout << x << " "; cout << endl; // 1 2 3 4 5 6 9 return 0; } ``` ### 三、deque 双端队列 #### 3.1 deque 特点 deque(double-ended queue)支持**头尾双端**高效增删,随机访问 O(1): ```cpp #include #include using namespace std; int main() { deque d = {2, 3, 4}; d.push_back(5); // 尾部添加:2 3 4 5 d.push_front(1); // 头部添加:1 2 3 4 5 d.pop_back(); // 删除尾部:1 2 3 4 d.pop_front(); // 删除头部:2 3 4 // 随机访问(O(1)) cout << "d[1] = " << d[1] << endl; // 3 for (int x : d) cout << x << " "; cout << endl; // 2 3 4 return 0; } ``` #### 3.2 vector vs deque 对比 | 维度 | vector | deque | | --- | --- | --- | | 头部插入 | 慢(O(n)) | 快(O(1)) | | 尾部插入 | 快(O(1)均摊) | 快(O(1)) | | 随机访问 | O(1) | O(1) | | 内存 | 连续 | 分段连续 | | 扩容 | 迁移全部元素 | 不彲响已有元素 | | 适用 | 尾部操作为主 | 头尾都要操作 | ### 四、list 双向链表 #### 4.1 list 基本操作 list 是双向链表,**任意位置插入删除 O(1)**,但不支持随机访问: ```cpp #include #include using namespace std; int main() { list lst = {3, 1, 4}; // 头尾插入 lst.push_back(5); lst.push_front(0); // 0 3 1 4 5 // 任意位置插入(O(1)) auto it = lst.begin(); advance(it, 2); // 移到第 3 个元素 lst.insert(it, 99); // 0 3 99 1 4 5 // 删除 lst.remove(99); // 按值删除 // 排序与反转(list 有自己的成员函数) lst.sort(); lst.reverse(); for (int x : lst) cout << x << " "; cout << endl; / / 5 4 3 1 0 return 0; } ``` #### 4.2 list 特有的高效操作 ```cpp #include #include using namespace std; int main() { list l1 = {1, 2, 3, 4, 5}; list l2 = {10, 20, 30}; // splice:把 l2 的元素移到 l1(O(1),不拷贝) l1.splice(l1.end(), l2); // l1: 1 2 3 4 5 10 20 30, l2: 空 // merge:合并两个已排序链表 list a = {1, 3, 5}; list b = {2, 4, 6}; a.merge(b); // a: 1 2 3 4 5 6 // unique:去除相邻重复 list c = {1, 1, 2, 2, 2, 3}; c.unique(); // c: 1 2 3 for (int x : a) cout << x << " "; cout << endl; return 0; } ``` ### 五、forward_list 与 array #### 5.1 forward_list 单向链表 ```cpp #include #include using namespace std; int main() { // 单向链表:只能向前遍历,内存更省 forward_list fl = {1, 2, 3}; fl.push_front(0); // 0 1 2 3(只能头插) fl.pop_front(); // 1 2 3 // 在某个元素后插入(insert_after) auto it = fl.begin(); fl.insert_after(it, 99); // 1 99 2 3 for (int x : fl) cout << x << " "; cout << endl; return 0; } ``` #### 5.2 array 固定数组 ```cpp #include #include using namespace std; int main() { // 固定大小,栈上分配,无动态开销 array arr = {1, 2, 3, 4, 5}; // 与 C 数组兼容 cout << "size = " << arr.size() << endl; // 安全访问 cout << "arr.at(0) = " << arr.at(0) << endl; // 迭代器支持 for (auto it = arr.begin(); it != arr.end(); it++) { cout << *it << " "; } cout << endl; // 传给 C 风格函数 int *cArr = arr.data(); cout << "cArr[2] = " << cArr[2] << endl; // 3 return 0; } ``` ### 六、序列容器对比总结 | 容器 | 随机访问 | 头插入 | 尾插入 | 中间插入 | 内存 | 适用 | | --- | --- | --- | --- | --- | --- | --- | | vector | O(1) | O(n) | O(1)* | O(n) | 连续 | 默认首选 | | deque | O(1) | O(1) | O(1) | O(n) | 分段 | 双端队列 | | list | O(n) | O(1) | O(1) | O(1) | 分散 | 频繁中间操作 | | forward_list | O(n) | O(1) | - | O(1) | 最省 | 极端内存敏感 | | array | O(1) | - | - | - | 栈 | 固定大小 | *均摊复杂度。 ### 七、实战:成绩统计系统 综合本篇知� �,用 vector 实现成绩统计: ```cpp #include #include #include #include using namespace std; class ScoreManager { private: vector scores; public: void add(int score) { scores.push_back(score); } double average() const { if (scores.empty()) return 0; return accumulate(scores.begin(), scores.end(), 0.0) / scores.size(); } int max() const { return *max_element(scores.begin(), scores.end()); } int min() const { return *min_element(scores.begin(), scores.end()); } void sortAndShow() const { vector temp = scores; // 拷贝排序,不改变原数据 sort(temp.begin(), temp.end()); for (int s : temp) cout << s << " "; cout << endl; } // 统计及格人数 int countPass(int threshold = 60) const { return count_if(scores.begin(), scores.end(), [threshold](int s) { return s >= threshold; }); } }; int main() { ScoreManager sm; sm.add(85); sm.add(92); sm.add(58); sm.add(76); sm.add(88); cout << "平均分: " << sm.average() << endl; // 79.8 cout << "最高分: " << sm.max() << endl; // 92 cout << "最低分: " << sm.min() << endl; // 58 cout << "及格人数: " << sm.countPass() << endl; // 4 cout << "排序后: "; sm.sortAndShow(); // 58 76 85 88 92 return 0; } ``` ### 总结 本篇总览了 STL 六大容器家族与选择原则,深入讲解了 vector(扩容机制、迭代器失效)、deque、list、forward_list、array 的特性与适用场景,并用成绩统计系统串联实战。重点掌握:vector 的扩容与迭代器失效、list 的 splice/merge 高效操作、各容器的选择依据。 下一篇将讲解**关联容器与无序容器**(set/map、unordered_*),敬请期待! --- ## C++进阶教程:文件流与字符串流 URL: https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-wen-jian-liu-yu-zi-fu-chuan-liu/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 10 篇。上一篇讲解了智能指针,本篇讲解文件与流操作:fstream 文本与二进制读写、流状态检查、getline、sstream 字符串流、C++17 filesystem 入门。 ### 一、文件流基础 #### 1.1 三种文件流 | 流类型 | 头文件 | 用途 | | --- | --- | --- | | `ifstream` | `` | 读文件(输入流) | | `ofstream` | `` | 写文件(输出流) | | `fstream` | `` | 读写文件 | #### 1.2 写入文件 ```cpp #include #include using namespace std; int main() { // 方式一:先定义再打开 ofstream fout; fout.open("test.txt"); // 默认覆盖写 fout << "Hello, File!" << endl; fout << 42 << " " << 3.14 << endl; fout.close(); // 记得关闭 // 方式二:构造时打开(推荐) ofstream fout2("data.txt", ios::app); // 追加模式 fout2 << "追加一行" << endl; fout2.close(); cout << "文件写入完成" << endl; return 0; } ``` #### 1.3 读取文件 ```cpp #include #include #include using namespace std; int main() { ifstream fin("test.txt"); if (!fin) { // 检查是否成功打开 cout << "文件打开失败!" << endl; return 1; } string line; // 逐行读取 while (getline(fin, line)) { cout << line << endl; } fin.close(); return 0; } ``` #### 1.4 打开模式 | 模式 | 含义 | | — | — | — | | `ios::in` | 读模式(ifstream 默认) | | `ios::out` | 写模式(ofstream 默认,截断) | | `ios::app` | 追加(不截断) | | `ios::ate` | 打开后定位到文件尾 | | `ios::trunc` | 截断文件 | | `ios::binary` | 二进制模式 | ### 二、流状态检查 #### 2.1 流状态标志 ```cpp #include #include using namespace std; int main() { ifstream fin("不存在的文件.txt"); if (fin.fail()) { // 打开失败 cout << "打开失败 (failbit)" << endl; } fin.open("test.txt"); if (fin.good()) { // 状态正常 cout << "文件状态良好" << endl; } // 读取到末尾 string s; while (fin >> s) { } // 读取直到失败 if (fin.eof()) { // 到达文件末尾 cout << "已读到文件� ��尾 (eofbit)" << endl; } fin.close(); return 0; } ``` #### 2.2 打开文件判断的两种写法 ```cpp #include #include using namespace std; int main() { // 写法一:!fin ifstream fin1("a.txt"); if (!fin1) { cout << "方式一检测到打开失败" << endl; } // 写法二:!fin.is_open() ifstream fin2("b.txt"); if (!fin2.is_open()) { cout << "方式二检测到打开失败" << endl; } return 0; } ``` ### 三、文本读写实战 #### 3.1 学生成绩文件读写 ```cpp #include #include #include #include using namespace std; struct Student { string name; int score; }; // 写入学生数据 void saveStudents(const vector &students, const string &filename) { ofstream fout(filename); for (const auto &s : students) { fout << s.name << " " << s.score << endl; } fout.close(); cout << "已保存 " << students.size() << " 条记录" << endl; } // 读取学生数据 vector loadStudents(const string &filename) { vector result; ifstream fin(filename); if (!fin) { cout << "读取失败" << endl; return result; } Student s; while (fin >> s.name >> s.score) { // 格式化读取 result.push_back(s); } fin.close(); return result; } int main() { vector students = { {"张三", 88}, {"李四", 92}, {"王五", 76} }; saveStudents(students, "students.txt"); auto loaded = loadStudents("students.txt"); for (const auto &s : loaded) { cout << s.name << " " << s.score << "分" << endl; } return 0; } ``` #### 3.2 读取整行与单词 ```cpp #include #include nclude #include using namespace std; int main() { ofstream fout("mixed.txt"); fout << "Alice 25 88.5" << endl; fout << "Bob 30 95.0" << endl; fout.close(); ifstream fin("mixed.txt"); string line; while (getline(fin, line)) { // 读整行 stringstream ss(line); // 字符串流解析 string name; int age; double score; ss >> name >> age >> score; // 从行中提取字段 cout << name << " " << age << "岁 " << score << "分" << endl; } return 0; } ``` ### 四、二� �制文件读写 #### 4.1 二进制写入 ```cpp #include #include using namespace std; struct Record { int id; char name[20]; double salary; }; int main() { Record records[] = { {1, "张三", 8000.5}, {2, "李四", 9500.0}, {3, "王五", 7500.75} }; // 二进制写入 ofstream fout("records.dat", ios::binary); fout.write(reinterpret_cast(records), sizeof(records)); fout.close(); cout << "二进制写入完成" << endl; return 0; } ``` #### 4.2 二进制读取 ```cpp #include #include using namespace std; struct Record { int id; char name[20]; double salary; }; int main() { ifstream fin("records.dat", ios::binary); if (!fin) { cout << "打开失败" << endl; return 1; } Record r; while (fin.read(reinterpret_cast(&r), sizeof(Record))) { cout << r.id << " " << r.name << " " << r.salary << endl; } fin.close(); return 0; } ``` #### 4.3 文本 vs 二进制对比 | 维度 | 文本模式 | 二进制模式 | | --- | --- | --- | | 可读性 | 人类可读 | 不可读 | | 大小 | 较大 | 紧凑 | | 精度 | 可能损失(浮点) | 精确保留 | | 跨平台 | 换行符转换 | 按原样 | | 适用 | 配置、日志 | 程序数据、图像 | ### 五、字符串流 sstream #### 5.1 字符串拼接与格式化 ```cpp #include #include #include using namespace std; int main() { // 字符串拼接 stringstream ss; ss << "姓名: " << "张三" << ", 年龄: " << 25 << ", 分数: " << 88.5; string result = ss.str(); // 转为 string cout << result << endl; // 清理复用 ss.str(""); // 清空内容 ss.clear(); // 重置状态 ss << "重新写入"; cout << ss.str() << endl; return 0; } ``` #### 5.2 字符串解析 ```cpp #include #include #include using namespace std; int main() { // 解析 CSV 格式 string csv = "Alice,25,88.5"; stringstream ss(csv); string name; int age; double score; char comma1, comma2; ss >> name >> comma1 >> age >> comma2 >> score; cout << "解析结果: " << name << " " << age << " " << score << endl; // 字符串转数字 string numStr = "3.14159"; double pi; stringstream(numStr) >> pi; cout << "π = " << pi << endl; // 数字转字符串 int value = 42; string valueStr; stringstream().swap(ss); ss << value; valueStr = ss.str(); cout << "字符串: " << valueStr << endl; return 0; } ``` #### 5.3 实用的格式化工具函数 ```cpp #include #include #include using namespace std; // 通用转换函数 template string toString(const T &value) { stringstream ss; ss << value; return ss.str(); } template bool fromString(const string &str, T &value) { stringstream ss(str); return !!(ss >> value); // 转换成功返回 true } int main() { // 格式化数字 stringstream ss; ss << fixed << setprecision(2) << 3.14159; cout << "保留两位: " << ss.str() << endl; // 3.14 // 通用转换 cout << "toString: " << toString(123) << endl; cout << "toString: " << toString(3.14) << endl; double d; if (fromString("99.5", d)) { cout << "fromString: " << d << endl; // 99.5 } return 0; } ``` ### 六、C++17 filesystem 入门 #### 6.1 路径与文件信息 ```cpp #include #include using namespace std; namespace fs = filesystem; // 命名空间别名 int main() { // 当前路径 cout << "当前目录: " << fs::current_path() << endl; // 路径拼接 fs::path p = fs::current_path() / "test.txt"; cout << "完整路径: " << p << endl; cout << "文件名: " << p.filename() << endl; cout << "扩展名: " << p.extension() << endl; cout << "父目录: " << p.parent_path() << endl; return 0; } ``` #### 6.2 目录操作 ```cpp #include #include using namespace std; namespace fs = filesystem; int main() { // 创建目录 fs::create_directory("mydir"); cout << "目录存在: " << fs::exists("mydir") << endl; // 递归创建 fs::create_directories("a/b/c"); cout << "递归目录存在: " << fs::exists("a/b/c") << endl; // 遍历目录 cout << "mydir 内容:" << endl; for (const auto &entry : fs::directory_iterator("mydir")) { cout << " " << entry.path().filename() << endl; } // 删除 fs::remove("mydir"); fs::remove_all("a"); // 递归删除 cout << "清理完成" << endl; return 0; } `` ` ### 6.3 文件大小与状态 ```cpp #include #include #include using namespace std; namespace fs = filesystem; int main() { // 创建一个文件 ofstream fout("temp.bin", ios::binary); fout.write("Hello Filesystem", 16); fout.close(); // 查询文件状态 fs::path p("temp.bin"); if (fs::exists(p)) { cout << "文件存在" << endl; cout << "大小: " << fs::file_size(p) << " 字节" << endl; cout << "是否为文件: " << fs::is_regular_file(p) << endl; cout << "最后修改时间: " << fs::last_write_time(p).time_since_epoch().count() << endl; } fs::remove(p); return 0; } ``` ### 七、实战:配置读写器 综合本篇知识,实现配置文件读写器: ```cpp #include #include #include #include #include using namespace std; class Config { private: map data; public: // 从配置文件加载 bool load(const string &filename) { ifstream fin(filename); if (!fin) return false; string line; while (getline(fin, line)) { if (line.empty() || line[0] == '#') continue; // 跳过空行和注释 size_t pos = line.find('='); if (pos != string::npos) { string key = line.substr(0, pos); string value = line.substr(pos + 1); data[key] = value; } } return true; } // 保存到配置文件 void save(const string &filename) { ofstream fout(filename); for (const auto &kv : data) { fout << kv.first << "=" << kv.second << endl; } cout << "配置已保存" << endl; } string get(const string &key, const string &def = "") const { auto it = data.find(key); return it != data.end() ? it->second : def; } void set(const string &key, const string &value) { data[key] = value; } }; int main() { Config config; // 写入配置 config.set("host", "localhost"); config.set("port", "8080"); config.set("timeout", "30"); config.save("app.conf"); // 重新读取 Config loaded; if (loaded.load("app.conf")) { cout << "host = " << loaded.get("host") << endl; cout << "port = " << loaded.get("port") << endl; cout << "timeout = " << loaded.get("timeout") << endl; cout << "不存在项 = " << loaded.get("nokey", "默认值") << endl; } return 0; } ``` ### 总结 本篇讲解了文件流的三种类型与打开模式、流状态检查、文本与二进制读写、字符串流 sstream 的格式化与解析、C++17 filesystem 的路径目录操作,并用配置读写器串联实战。重点掌握:打开文件的失败检查、getline 与 >> 的区别、文本与二进制的选择、stringstream 的类型转换技巧。 至此 C++ 进阶阶段(6-10 篇)完成!下一篇将进入 **STL 阶段:容器总览与序列容器**,敬请期待! --- ## C++进阶教程:动态内存管理与智能指针 URL: https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-dong-tai-nei-cun-guan-li-yu-zhi-neng/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 9 篇。上一篇讲解了继承与多态,本篇讲解动态内存管理的核心:new/delete 配对、内存泄漏与悬垂指针、RAII 思想、智能指针(unique_ptr/shared_ptr/weak_ptr)、make_unique/make_shared、循环引用。 ### 一、动态内存基础 #### 1.1 new 与 delete 动态内存(堆内存)在**运行时**分配,生命周期由程序员控制: ```cpp #include using namespace std; int main() { // 单个对象 int *p = new int(42); // 分配并初始化 cout << "*p = " << *p << endl; // 42 delete p; // 释放内存(必须!) p = nullptr; // 悬垂指针置空 // 数组 int *arr = new int[5]; for (int i = 0; i < 5; i++) arr[i] = i * 10; cout << "arr[3] = " << arr[3] << endl; // 30 delete[] arr; // 数组用 delete[] // 对象 string *s = new string("Hello"); cout << *s << endl; delete s; return 0; } ``` **配对规则**:`new` 配 `delete`,`new[]` 配 `delete[]`。混用是未定义行为。 #### 1.2 内存泄漏 ```cpp #include using namespace std; void leakExample() { int *p = new int(100); // 没有 delete p!函数结束指针技失,内存无法释放 → 内存泄漏 } int main() { for (int i = 0; i < 1000; i++) { leakExample(); // 每次泄漏 4 字节 } cout << "程序结束(已泄漏内存)" << endl; return 0; } ``` **内存泄漏的危害**:程序长期运行内存持续增长,最终耗尽内存导致崩溃。服务器程序尤其严重。 #### 1.3 悬垂指针与双重释放 ```cpp #include using namespace std; int main() { int *p1 = new int(10); int *p2 = p1; // 两个指针指向同一块内存 delete p1; // 释放 // delete p2; // 错误!双重释放 → 崩溃 // *p2 = 20; // 错误!p2 现在是悬垂指针(指向已释放内存) cout << "避免双重释放和悬垂指针访问" << endl; return 0; } ``` ### 二、RAII 思想 #### 2.1 什么是 RAII **RAII(Resource Acquisition Is Initialization,资源获取即初始化)**:把资源(内存、文件、锁)的获取放在构造函数中,释放放在析构函数中。对象生命周期结束(离开作用域)时,析构函数自动� ��放资源。 ```cpp #include using namespace std; // 手动管理:容易出错 class BadResource { public: int *data; BadResource() : data(new int[100]) {} ~BadResource() { delete[] data; } // 忘记写就泄漏 }; // RAII 思想已体现在之前的 String 类中:构造分配、析构释放 class SmartResource { private: int *data; public: SmartResource() : data(new int[100]) { cout << "获取资源" << endl; } ~SmartResource() { delete[] data; cout << "自动释放资源" << endl; } }; int main() { { S martResource r; // 构造:获取资源 cout << "使用资源..." << endl; } // 析构:自动释放(即使有异常也会释放) cout << "RAII 保证资源安全释放" << endl; return 0; } ``` #### 2.2 RAII 的优势 1. **异常安全**:即使抛出异常,析构函数也会执行。 2. **代码简洁**:无需手动清理。 3. **防止泄漏**:资源一定被释放。 ### 三、智能指针(C++11) #### 3.1 unique_ptr(独占所有权) `unique_ptr` 独占管理指针,**不能拷贝**,只能转移(移动),离开作用 域自动释放: ```cpp #include #include using namespace std; class Resource { public: Resource() { cout << "资源创建" << endl; } ~Resource() { cout << "资源释放" << endl; } void work() { cout << "工作中..." << endl; } }; int main() { // 方式一:直接构造 unique_ptr p1(new Resource()); // 方式二:make_unique(推荐,C++14) auto p2 = make_unique(); // 不能拷贝 // unique_ptr p3 = p1; // 错误! // 转移所有权 unique_ptr p3 = move(p1); // p1 � ��在是空指针 p3->work(); // 离开作用域自动释放 return 0; } ``` **使用场景**:独占资源的所有权,如工厂函数返回值、容器中的对象。 #### 3.2 shared_ptr(共享所有权) `shared_ptr` 使用**引用计数**,多个指针共享同一对象,最后一个释放时销毁对象: ```cpp #include #include using namespace std; class Resource { public: int id; Resource(int i) : id(i) { cout << "创建资源 " << id << endl; } ~Resource() { cout << "释放资源 " << id << endl; } }; int main() { // make_ shared:一次分配对象和计数块(推荐) shared_ptr p1 = make_shared(1); cout << "p1 引用计数: " << p1.use_count() << endl; // 1 { shared_ptr p2 = p1; // 拷贝:计数 +1 shared_ptr p3 = p1; // 拷贝:计数 +1 cout << "计数: " << p1.use_count() << endl; // 3 cout << "p2->id = " << p2->id << endl; // 1 } // p2, p3 销毁,计数 -2 cout << "计数: " << p1.use_count() << endl; // 1 // p1 销毁时计数归零,释放资源 return 0; } ``` #### 3.3 weak_ptr(弱引用,打破循环引用) `weak_ptr` 不增加引用计数,用于**观察** shared_ptr 管理的对象,解决循环引用问题: ```cpp #include #include using namespace std; class B; // 前向声明 class A { public: shared_ptr b_ptr; ~A() { cout << "A 析构" << endl; } }; class B { public: // 若这里用 shared_ptr,则 A 和 B 互相持有 → 循环引用,都不释放 weak_ptr a_ptr; // 用 weak_ptr 打破循环 ~B() { cout << "B 析构" << endl; } }; int main() { shared_ptr a = make_shared() ; shared_ptr b = make_shared(); a->b_ptr = b; b->a_ptr = a; // weak_ptr 不增加计数 cout << "a 计数: " << a.use_count() << endl; // 1(b 持有的是 weak) cout << "b 计数: " << b.use_count() << endl; // 2(a 持有 shared) // 用 weak_ptr 访问需要 lock() if (auto sp = b->a_ptr.lock()) { cout << "通过 weak_ptr 访问 A 成功" << endl; } // 程序结束:a 和 b 都能正常析构 return 0; } ``` #### 3.4 三种智能指针对比 | 维度 | unique_ptr | shared_ptr | weak_ptr | | --- | --- | --- | --- | | 所有� | | | | | �� | 独占 | 共享 | 无(观察) | | 拷贝 | 禁止 | 允许(计数+1) | 允许 | | 移动 | 允许 | 允许 | 允许 | | 引用计数 | 无 | 有 | 有(不增加) | | 开销 | 最小 | 较大 | 较小 | | 适用 | 独占资源 | 多所有者 | 打破循环引用 | ### 四、智能指针实战 #### 4.1 用智能指针重构图形系统 ```cpp #include #include #include using namespace std; class Shape { public: virtual double area() const = 0; virtual void info() const = 0; virtual ~Shape() {} }; class Circle : public Shape { doubl e r; public: Circle(double radius) : r(radius) {} double area() const override { return 3.14159 * r * r; } void info() const override { cout << "圆面积: " << area() << endl; } }; class Rectangle : public Shape { double w, h; public: Rectangle(double a, double b) : w(a), h(b) {} double area() const override { return w * h; } void info() const override { cout << "矩形面积: " << area() << endl; } }; int main() { // 容器存储智能指针:自动管理生命周期,无需手动 delete vector> shapes; shapes.push_back(make_uniqu e(3)); shapes.push_back(make_unique(4, 5)); double total = 0; for (const auto &s : shapes) { s->info(); total += s->area(); } cout << "总面积: " << total << endl; // 离开作用域 vector 析构,unique_ptr 自动释放所有 Shape return 0; } ``` #### 4.2 工厂函数返回智能指针 ```cpp #include #include using namespace std; class Logger { public: virtual void log(const string &msg) const = 0; virtual ~Logger() {} }; class ConsoleLogger : public Logger { public: void log(const string &msg) const override { cout << "[控制台] " << msg << endl; } }; class FileLogger : public Logger { public: void log(const string &msg) const override { cout << "[文件] " << msg << endl; } }; // 工厂函数:返回 unique_ptr,所有权转移给调用者 unique_ptr createLogger(const string &type) { if (type == "console") { return make_unique(); } else { return make_unique(); } } int main() { auto logger = createLogger("console"); logger->log("系统启动"); logger = createLogger( "file"); // 旧对象自动释放 logger->log("切换到文件日志"); return 0; } ``` #### 4.3 正确使用规范 ```cpp #include #include using namespace std; class Widget { public: void show() { cout << "Widget" << endl; } }; void process(shared_ptr p) { cout << "处理中,计数: " << p.use_count() << endl; } int main() { // 规范 1:用 make_shared 而非 new shared_ptr w = make_shared(); // 规范 2:传参用值传递(拷贝)或用 const& process(w); // 规范 3:不要混用裸指针和� �能指针 // Widget *raw = w.get(); // get() 只读不拥有 // 规范 4:不要用裸指针创建多个 shared_ptr // Widget *rw = new Widget(); // shared_ptr s1(rw); // shared_ptr s2(rw); // 错误!双重释放 w->show(); return 0; } ``` ### 五、实战:内存安全的链表 用智能指针实现链表,无需手动管理内存: ```cpp #include #include using namespace std; struct Node { int data; unique_ptr next; // 每个节点独占下一个节点 Node(int v) : data(v) {} }; class Linke dList { private: unique_ptr head; public: // 头插法 void pushFront(int value) { auto newNode = make_unique(value); newNode->next = move(head); // 转移旧头节点 head = move(newNode); } void print() const { Node *cur = head.get(); while (cur) { cout << cur->data << " "; cur = cur->next.get(); } cout << endl; } // 析构:head 释放时自动递归释放整个链表 }; int main() { LinkedList list; list.pushFront(30); list.pushFront(20); list.push Front(10); list.print(); // 10 20 30 // list 析构时整个链表自动释放,无内存泄漏 cout << "链表已自动释放" << endl; return 0; } ``` ### 总结 本篇讲解了 new/delete 配对、内存泄漏与悬垂指针的危害、RAII 思想、三种智能指针(unique_ptr 独占、shared_ptr 共享、weak_ptr 观察)及其选择、循环引用的打破,并用智能指针链表串联实战。重点掌握:RAII 的核心思想、三种智能指针的适用场景、make_unique/make_shared 的使用、循环引用的解决方案。 下一篇将讲解**文件� ��与字符串流**(fstream、sstream、filesystem),敬请期待! --- ## C++进阶教程:继承与多态 URL: https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-ji-cheng-yu-duo-tai/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 8 篇。上一篇讲解了运算符重载,本篇讲解面向对象的核心:继承(三种方式、构造析构顺序、与多态(虚函数、动态绑定、纯虚函数与抽象类、虚析构、override/final、RTTI)。 ### 一、继承基础 #### 1.1 为什么需要继承 继承让新类(**派生类**)复用已有类(**基类**)的成员,并可以扩展新功能,实现**代码复用**和**层次抽象**。例如:猫和狗都是动物,可以继承 Animal 的共性,再各自扩展。 #### 1.2 继承的基本语法 ```cpp #include #include using namespace std; // 基类(父类) class Animal { protected: // 派生类可访问,外部不可访问 string name; public: Animal(string n) : name(n) {} void eat() const { cout << name << " 在吃东西" << endl; } string getName() const { return name; } }; // 派生类(子类):public 继承 class Dog : public Animal { public: Dog(string n) : Animal(n) {} // 调用基类构造 void bark() const { cout << name << " 汪汪叫" << endl; } }; class Cat : public Animal { public: Cat(string n) : Animal(n) {} void meow() const { cout << name << " 喵喵叫" << endl; } }; int main() { Dog dog("旺财"); Cat cat("咪咪"); dog.eat(); // 继承的方法 dog.bark(); // 自己的方法 cat.eat(); cat.meow(); return 0; } ``` #### 1.3 三种继承方式 ```cpp class Base { public: int pub; protected: int prot; private: int priv; }; // public 继承:public→public,protected→protected class PubChild : public Base { }; // protected 继承:public→protected,protected→protected class ProtChild : protected Base { }; // private 继承:public→private,protected→private class PrivChild : private Base { }; ``` | 继��方式 | 基类 public 成员 | 基类 protected 成员 | | --- | --- | --- | | public 继承 | 仍为 public | 仍为 protected | | protected 继承 | 降为 protected | 仍为 protected | | private 继承 | 降为 private | 降为 private | **实际开发中几乎只用 public 继承**,它表达「is-a」关系。 ### 二、派生类的构造与析构 ```cpp #include using namespace std; class Base { private: int value; public: Base(in t v) : value(v) { cout << "Base 构造: " << value << endl; } ~Base() { cout << "Base 析构" << endl; } }; class Derived : public Base { private: int extra; public: // 派生类构造:必须先初始化基类部分 Derived(int v, int e) : Base(v), extra(e) { cout << "Derived 构造: extra=" << extra << endl; } ~Derived() { cout << "Derived 析构" << endl; } }; int main() { cout << "创建派生对象:" << endl; Derived d(10, 20); cout << "销毁派生对象:" << endl; return 0; } ``` 输出顺序: ``` 创建派生对象: Base 构造: 10 Derived 构造: extra=20 销毁派生对象: Derived 析构 Base 析构 ``` **规则**:构造顺序 = 基类 → 成员 → 自身;析构顺序相反。派生类构造必须显式(或隐式)调用基类构造函数。 ### 三、多态与虚函数 #### 3.1 什么是多态 多态(Polymorphism)指**同一个调用在不同对象上表现出不同行为**。通过基类指针/引用调用虚函数,实际执行的是派生类的实现。 **多态三要素**: 1. 基类中定义**虚函数**。 2. 派生类**重写**虚函数。 3. 通过**基类指针/引用**调用。 #### 3.2 虚函数与动态绑定 ```cpp #include using namespace std; class Shape { public: // 虚函数:允许派生类重写,支持多态 virtual double area() const { return 0; } virtual void whoAmI() const { cout << "我是图形" << endl; } }; class Circle : public Shape { private: double radius; public: Circle(double r) : radius(r) {} // 重写虚函数(override 是 C++11 建议标注) double area() const override { return 3.14159 * radius * radius; } void whoAmI() const override { cout << "我是圆" << endl; } }; class Rectangle : public Shape { private: double w, h; public: Rectangle(double a, double b) : w(a), h(b) {} double area() const override { return w * h; } void whoAmI() const override { cout << "我是矩形" << endl; } }; int main() { // 基类指针指向派生类对象 Shape *s1 = new Circle(3); Shape *s2 = new Rectangle(4, 5); // 同一个调用,不同行为(动态绑定) s1->whoAmI(); // 我是圆 s2->whoAmI(); // 我是矩形 cout << "面积: " << s1->area() << endl; // 28.27 cout << "面积: " << s2->area() << endl; // 20 delete s1; delete s2; return 0; } ``` #### 3.3 静态绑定 vs 动态绑定 | 维度 | 静态绑定 | 动态绑定 | | --- | --- | --- | | 时机 | 编译期决定 | 运行期决定 | | 依据 | 指针/引用静态类型 | 对象实际类型 | | 函数 | 非虚函数 | 虚函数 | | 性能 | 更快 | 有虚函数表查找开销 | #### 3.4 虚函数表(vtable) 含有虚函数的类会生成**虚函数表**(vtable),对象头部有**虚指针**(vptr)指向它。调用虚函数时通过 vptr → vtable → 实际函数地址,实现动态绑定。 ``` Shape 对象: [vptr] -> [Shape::area, Shape::whoAmI] Circle 对象: [vptr] -> [Circle::area, Circle::whoAmI] (重写后) ``` ### 四、纯虚函数与抽象类 #### 4.1 抽象类 含**纯虚函数**的类称为抽象类,**不能实例化**,只能作为基类被继承: ```cpp #include using namespace std; // 抽象类:含纯虚函数 class Shape { public: virtual double area() const = 0; // 纯虚函数:= 0 virtual void draw() const = 0; // 抽象类也可以有普通成员函数 void show() const { cout << "面积: " << area() << endl; } // 抽象类需要虚析构(见第五节) virtual ~Shape() {} }; class Circle : public Shape { private: double radius; public: Circle(double r) : radius(r) {} // 必须实现所有纯虚函数才能实例化 double area() const override { return 3.14159 * radius * radius; } void draw() const override { cout << "画一个圆" << endl; } }; int main() { // Shape s; // 错误!抽象类不能实例化 Shape *shape = new Circle(3); shape->draw(); // 画一个圆 shape->show(); // 面积: 28.2743 delete shape; return 0; } ``` #### 4.2 抽象类的用途 - 定义**接口规范**,强制派生类实现。 - 用于设计模式(工厂、策略等)。 - 表达「是一个」的抽象层次。 ### 五、虚析构函数 ```cpp #include using namespace std; class Base { public: // 非虚析构(问题版本) // ~Base() { cout << "Base 析构" << endl; } // 虚析构(正确):通过基类指针删除时正确调用派生类析构 virtual ~Base() { cout << "Base 析构" << endl; } }; cl ass Derived : public Base { private: int *data; public: Derived() : data(new int[100]) { cout << "Derived 构造: 分配内存" << endl; } ~Derived() { delete[] data; // 释放内存 cout << "Derived 析构: 释放内存" << endl; } }; int main() { Base *p = new Derived(); delete p; // 若 Base 析构非虚,这里不会调用 Derived 析构 → 内存泄漏 return 0; } ``` **关键**:凡是可能被继承且通过基类指针删除的类,**析构函数必须声明为 virtual**。 ### 六、override 与 final #### 6.1 override(显式重写) ```cpp class Base { public: virtual void func() {} }; class Derived : public Base { public: // override 标记:编译器检查确实重写了基类虚函数 // 若拼写错误(如 funcc),编译器报错 void func() override {} }; ``` #### 6.2 final(禁止继承/重写) ```cpp #include using namespace std; class Base { public: virtual void show() { cout << "Base" << endl; } }; // final 类:禁止被继承 class FinalClass final : public Base { public: // final 虚函数:禁止子类重写 void show() override final { cout << "FinalClass" << endl; } }; // class Child : public FinalClass {}; // 错误!FinalClass 是 final int main() { FinalClass fc; fc.show(); // FinalClass return 0; } ``` ### 七、RTTI:dynamic_cast 与 typeid #### 7.1 dynamic_cast(安全向下转型) ```cpp #include #include using namespace std; class Animal { public: virtual ~Animal() {} // 必须有虚函数才能 dynamic_cast }; class Dog : public Animal { public: void bark() { cout << "汪汪" << endl; } }; class Cat : public Animal { public: void meow() { cout << "喵喵" << endl; } }; int main() { Animal *a1 = new Dog(); Animal *a2 = new Cat(); // dynamic_cast:安全的向下转型,失败返回 nullptr Dog *d = dynamic_cast(a1); if (d) { d->bark(); // 汪汪 } Dog *d2 = dynamic_cast(a2); // a2 实际是 Cat if (d2) { d2->bark(); // 不会执行 } else { cout << "a2 不是 Dog 类型,转型失败" << endl; } delete a1; delete a2; return 0; } ``` #### 7.2 typeid(获取类型信息) ```cpp #include #include using namespace std; class Animal { public: virtual ~Animal() {} }; class Dog : public Animal {}; class Cat : public Animal {}; int main() { Animal *a1 = new Dog(); Animal *a2 = new Cat(); cout << "a1 实际类型: " << typeid(*a1).name() << endl; // class Dog cout << "a2 实际类型: " << typeid(*a2).name() << endl; // class Cat if (typeid(*a1) == typeid(Dog)) { cout << "a1 是 Dog" << endl; } delete a1; delete a2; return 0; } ``` ### 八、实战:图形系统 综合本篇全部知识,实现完整图形系统: ```cpp #include #include using namespace std; // 抽象基类 class Shape { protected: string name; public: Shape(string n) : name(n) {} virtual double area() const = 0; // 纯虚函数 virtual void info() const = 0; virtual ~Shape() {} // 虚析构 }; class Circle : public Shape { private: double radius; public: Circle(string n, double r) : Shape(n), radius(r) {} double area() const override { return 3.14159 * radius * radius; } void info() const override { cout << name << "(圆,半径 " << radius << ")面积: " << area() << endl; } }; class Rectangle : public Shape { private: double w, h; public: Rectangle(string n, double a, double b) : Shape(n), w(a), h(b) {} double area() const override { return w * h; } void info() const override { cout << name << "(矩形 " << w << "x" << h << ")面积: " << area() << endl; } }; class Triangle : public Shape { private: double base, height; public: Triangle(string n, double b, double hh) : Shape(n), base(b), height(hh) {} double area() const override { return 0.5 * base * height; } void info() const override { cout << name << "(三角形)面积: " << area() << endl; } }; int main() { // 多态容器:基类指针数组管理多种派生对象 vector shapes; shapes.push_back(new Circle("圆形", 3)); shapes.push_back(new Rectangle("矩形", 4, 5)); shapes.push_back(new Triangle("三角形", 6, 4)); double total = 0; for (Shape *s : shapes) { s->info(); total += s->area(); } cout << "总面积: " << total << endl; // 释放内存(虚析构保证正确) for (Shape *s : shapes) { delete s; } return 0; } ``` 运行示例: ``` 圆形(圆,半径 3)面积: 28.2743 矩形(矩形 4x5)面积: 20 三角形(三角形)面积: 12 总面积: 60.2743 ``` ### 总结 本篇讲解了继承的三种方式与构造析构顺序、多态三要素、虚函数与虚函数表、纯虚函数与抽象类、虚析构函数的重要性、override/final、RTTI(dynamic_cast/typeid),并用图形系统串联实战。重点掌握:public 继承的 is-a 语义、多态的实现条件、抽象类不能实例化、基类析构必须 virtual。 下一篇将讲解**动态内存管理与智能指针**(RAII、unique_ptr/shared_ptr/weak_ptr),敬请期待! --- ## C++进阶教程:运算符重载与类型转换 URL: https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-yun-suan-fu-zhong-zai-yu-lei-xing/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 7 篇。上一篇深入了类与对象(深拷贝、static、this、友元),本篇讲解运算符重载与类型转换:可重载运算符清单、常用重载(==、<、<<、[]、()、++、–)、explicit 关键字、类型转换运算符。 ### 一、运算符重载基础 #### 1.1 什么是运算符重载 运算符重载是让 `+`、`==`、`<<` 等运算符可以作用于**自定义类型**,使代码更自然、更直观。例如 `c1 + c2` 让复数相加,`cout << obj` 直接打印对象。 #### 1.2 可重载与不可重载运算符 | 可重载 | 不可重载 | | --- | --- | | `+ - * / %` `== != < >` `<< >>` `[] () ++ --` `= += -=` `&& | | **规则**:不能发明新运算符;至少一个操作数是自定义类型;不能改变优先级与结合性。 #### 1.3 两种形式:成员函数 vs 友元函数 ```cpp #include using namespace std; class Point { private: int x, y; public: Point(int a, int b) : x(a), y(b) {} // 成员函数形式:左操作数是 this,只接受一个参数 Point operator+(const Point &other) const { return Point(x + other.x, y + other.y); } // 友元函数形式:需要两个参数,能处理对称操作 friend bool operator==(const Point &a, const Point &b); friend ostream &operator<<(ostream &os, const Point &p); }; bool operator==(const Point &a, const Point &b) { return a.x == b.x && a.y == b.y; } ostream &operator<<(ostream &os, const Point &p) { os << "(" << p.x << ", " << p.y << ")"; return os; } int main() { Point p1(1, 2), p2(3, 4); Point p3 = p1 + p2; // 成员函数重载 cout << "p3 = " << p3 << endl; // (4, 6) cout << (p1 == Point(1, 2)) << endl; // 1 cout << (p1 == p2) << endl; // 0 return 0; } ``` ### 二、常用运算符重载 #### 2.1 关系运算符 == 和 < ```cpp #include using namespace std; class Student { private: string name; int score; public: Student(string n, int s) : name(n), score(s) {} // == 判断分数相同 bool operator==(const Student &other) const { return score == other.score; } // < 按分数比较(用于排序) bool operator<(const Student &other) const { return score < other.score; } // > 可以用 < 实现 bool operator>(const Student &other) const { return other < *this; } int getScore() const { return score; } }; int main() { Student s1("张三", 88), s2("李四", 88), s3("王五", 92); cout << "s1 == s2: " << (s1 == s2) << endl; // 1 cout << "s1 < s3: " << (s1 < s3) << endl; // 1 cout << "s3 > s1: " << (s3 > s1) << endl; // 1 return 0; } ``` #### 2.2 输出运算符 << ```cpp #include using namespace std; class Complex { private: double real; double imag; public: Complex(double r = 0, double i = 0) : real(r), imag(i) {} // 重载 << 打印复数 friend ostream &operator<<(ostream &os, const Complex &c) { os << c.real; if (c.imag >= 0) os << "+" << c.imag << "i"; else os << c.imag << "i"; return os; } }; int main() { Complex c1(3, 4); Complex c2(2, -5); cout << "c1 = " << c1 << endl; // 3+4i cout << "c2 = " << c2 << endl; // 2-5i return 0; } ``` #### 2.3 下标运算符 [] ```cpp #include #include using namespace std; class IntArray { private: int data[10]; int size; public: IntArray() : size(0) {} void add(int v) { if (size < 10) data[size++] = v; } // 下标运算符:返回引用以便修改 int &operator[](int index) { if (index < 0 || index >= size) { throw out_of_range("下标越界"); } return data[index]; } // const 版本(只读对象可用) const int &operator[](int index) const { if (index < 0 || index >= size) { throw out_of_range("下标越界"); } return data[index]; } int getSize() const { return size; } }; int main() { IntArray arr; arr.add(10); arr.add(20); arr.add(30); cout << arr[0] << " " << arr[1] << " " << arr[2] << endl; // 10 20 30 arr[1] = 99; // 通过引用修改 cout << arr[1] << endl; // 99 return 0; } ``` #### 2.4 自增自减 ++ 和 – ```cpp #include using namespace std; class Counter { private: int value; public: Counter(int v = 0) : value(v) {} // 前置 ++:返回引用(支持链式) Counter &operator++() { ++value; return *this; } // 后置 ++:参数 int 占位区分,返回旧值副� �� Counter operator++(int) { Counter temp = *this; ++value; return temp; } int get() const { return value; } }; int main() { Counter c(5); cout << "c = " << c.get() << endl; // 5 cout << "++c = " << (++c).get() << endl; // 6(前置:先增后取) cout << "c++ = " << (c++).get() << endl; // 6(后置:先取后增) cout << "c = " << c.get() << endl; // 7 return 0; } ``` #### 2.5 函数调用运算符 () ```cpp #include using namespace std; // 仿函数:像函数一样调用对象 class AddBy { private: int base; public: AddBy(int b) : base(b) {} // operator() 让对象可像函数调用 int operator()(int value) const { return value + base; } }; int main() { AddBy add10(10); AddBy add100(100); cout << add10(5) << endl; // 15(对象当函数用) cout << add100(5) << endl; // 105 return 0; } ``` ### 三、赋值运算符与自赋值 #### 3.1 完整的赋值运算符 ```cpp #include #include using namespace std; class Buffer { private: char *data; int length; public: Buffer(const char *s = "") { length = strlen(s); data = new char[length + 1]; strcpy(data, s); } Buffer(const Buffer &other) { length = other.length; data = new char[length + 1]; strcpy(data, other.data); } // 赋值运算符必须处理:自赋值、资源释放、深拷贝 Buffer &operator=(const Buffer &other) { if (this == &other) return *this; // 自赋值检查(关键!) delete[] data; // 释放旧资源 length = other.length; data = new char[length + 1]; strcpy(data, other.data); return *this; // 返回引用支持链式 } ~Buffer() { delete[] data; } const char *get() const { return data; } }; int main() { Buffer b1("Hello"); Buffer b2("World"); b1 = b2; // 赋值 b1 = b1; // 自赋值(安全) cout << b1.get() << endl; // World return 0; } ``` #### 3.2 =default 与 =delete(C++11) ```cpp #include using namespace std; class NoCopy { public: NoCopy() = default; // 使用编译器生成的默认构造 // 禁用拷贝(=delete) NoCopy(const NoC opy &) = delete; NoCopy &operator=(const NoCopy &) = delete; }; class Dummy { public: int value; Dummy() = default; // 显式要求默认构造 Dummy(int v) : value(v) {} }; int main() { NoCopy n1; // NoCopy n2 = n1; // 错误!拷贝已被删除 Dummy d; d.value = 42; cout << d.value << endl; // 42 return 0; } ``` ### 四、类型转换 #### 4.1 explicit 关键字(防止隐式转换) ```cpp #include using namespace std; class Money { private: double amount; public: // 非 explicit:int 可隐式转 Money // Money(double a) : amount(a) {} // explicit:禁止隐式转换,必须显式构造 explicit Money(double a) : amount(a) {} double get() const { return amount; } }; void pay(const Money &m) { cout << "支付 " << m.get() << " 元" << endl; } int main() { Money m1(100.0); // 显式构造 // Money m2 = 200.0; // 错误!explicit 禁止隐式转换 // pay(300.0); // 错误!不能隐式转 Money pay(Money(300.0)); // 显式转换后调用 return 0; } ``` #### 4.2 类型转换运算符 ```cpp #include using namespX�H�‚��\����X�[ۈœ�]�]N��[��[Nˆ[�[�‚�X�X΂���X�[ۊ[��[� H��[J�K[� H�B����9�n�g��/k9�h�/�9���)��&���X�[ۈO��X�B��\�]܈�X�J H�ۜ�ˆ�]\���]X���\��X�O��[JH�[�ˆB������X�[ۈO����;�"9b)9��y�+�d)�..��'�b!��l;�"B�^X�]�\�]܈��� H�ۜ�ˆ�]\���[HOHˆB����Y��� H�ۜ�ˆ��]�[H�Ȉ[��H��]X���\��X�O� �\�H[�ˆB�N‚�[�XZ[� Hˆ��X�[ۈ�� Nˆ�X�HH����:f�9o#�/k�X�B���]��X�H9`/��[������B������� N�����H��B����Y�9�hy.��a�9a`z+����:/k9�h��"^X�]���9g*9�i9g.��k�d"9��{�"B�Y� �Hˆ��]�b!�kd:gg�f툈[�ˆB��]\��ŸB�����9.�8� y��9d"9k��/���&�i#y�l9�n‚���9d"9�+9���aj:`�9��z+��#9k���9k�9�m9�9i#y�l9�n��&������[��YH[���X[O��\�[���[Y\�X�H�‚��\����\^œ�]�]N���X�H�X[ˆ�X�H[XY�‚�X�X΂���\^ �X�H�H�X�HHH H��X[ �K[XY�JH�B����9���+�/�9���"9�$9df9a�y�l;�"B���\^�\�]܊��ۜ���\^ ��H�ۜ�ˆ� turn Complex(real + c.real, imag + c.imag); } Complex operator-(const Complex &c) const { return Complex(real - c.real, imag - c.imag); } Complex operator*(const Complex &c) const { // (a+bi)(c+di) = (ac-bd) + (ad+bc)i return Complex(real * c.real - imag * c.imag, real * c.imag + imag * c.real); } // 比较(友元) friend bool operator==(const Complex &a, const Complex &b) { return a.real == b.real && a.imag == b.imag; } // 输出(友元) friend ostream &operator<<(ostream &os, const Complex &c) { os << c.real; if (c.imag >= 0) os << "+" << c.imag << "i"; else os << c.imag << "i"; return os; } // 输入(友元) friend istream &operator>>(istream &is, Complex &c) { cout << "输入实部: "; is >> c.real; cout << "输入虚部: "; is >> c.imag; return is; } }; int main() { Complex a(3, 4); Complex b(1, -2); cout << "a = " << a << endl; // 3+4i cout << "b = " << b << endl; // 1-2i cout << "a + b = " << (a + b) << endl; // 4+2i cout << "a - b = " << (a - b) << endl; // 2+6i cout << "a * b = " << (a * b) << endl; // 11-2i cout << "a == b: " << (a == b) << endl; // 0 return 0; } ``` ### 总结 本篇讲解了运算符重载的基础(可重载清单、成员/友元两种形式)、常用重载(==、<、<<、W]、()、++/–)、赋值运算符的完整实现(自赋值、深拷贝)、=default/=delete、explicit 关键字和类型转换运算符,并用复数类串联实战。重点掌握:成员 vs 友元的选择、自赋值检查、前置/后置 ++ 的区别、explicit 防止意外转换。 下一篇将讲解**继承与多态**(虚函数、抽象类、RTTI),敬请期待! --- ## C++进阶教程:类与对象深入 URL: https://wsj-blog.pages.dev/posts/c-jin-jie-jiao-cheng-lei-yu-dui-xiang-shen-ru/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 6 篇。上一篇初探了类的基本语法,本篇深入类与对象:构造函数与析构顺序、初始化列表、拷贝构造与拷贝赋值、深拷贝与浅拷贝、static 成员、const 成员函数、this 指针与友元。 ### 一、构造函数深入 #### 1.1 构造函数的种类 ```cpp #include using namespace std; class Person { private: string name; int age; public: // 1. 默认构造(无参) Person() : name("未命名"), age(0) { cout << "默认构造" << endl; } // 2. 带参构造 Person(string n, int a) : name(n), age(a) { cout << "带参构造: " << name << endl; } // 3. 拷贝构造(重要!) Person(const Person &other) : name(other.name), age(other.age) { cout << "拷贝构造: 复制 " << other.name << endl; } void show() const { cout << name << " " << age << "岁" << endl; } }; int main() { Person p1; // 默认构造 Person p2("张三", 25); // 带参构造 Person p3 = p2; // 拷贝构造 Person p4(p2); // 拷贝构造(等价写法) p1.show(); p2.show(); p3.show(); return 0; } ``` #### 1.2 初始化列表(推荐) ```cpp #include using namespace std; class Student { private: const int id; // const 成员必须在初始化列表初始化 string name; public: // 初始化列表语法:在 : 后按声明顺序初始化 Student(int i, string n) : id(i), name(n) { // 函数体(可为空) } void show() const { cout << "学号 " << id << " " << name << endl; } }; int main() { Student s(1001, "李四"); s.show(); // 学号 1001 李四 return 0; } ``` **初始化列表 vs 赋值**:const 成员和引用成员只能在初始化列表初始化;初始化列表更高效(直接构造而非先默认构造再赋值)。 #### 1.3 构造与析枔的顺序 ```cpp #include using namespace std; class A { public: A() { cout << "A 构造" << endl; } ~A() { cout << "A 析构" << endl; } }; class B : public A { // 继承 A(第 8 篇详解) public: B() { cout << "B 构造" << endl; } ~B() { cout << "B 析构" << endl; } }; int main() { cout << "创建 B 对象:" << endl; B b; // ��基类构造,再派生类构造 cout << "销毁 B 对象:" << endl; // 先派生类析构,再基类析构 return 0; } ``` 输出顺序: ``` 创建 B 对象: A 构造 B 构造 销毁 B 对象: B 析构 A 析构 ``` **规则**:构造顺序 = 基类 → 成员 → 自身;析构顺序完全相反。 ### 二、深拷贝与浅拷贝 #### 2.1 浅拷贝的灾难(悬垂指针) ```cpp #include #include using namespace std; class StringBad { private: char *str; // 动态分配的内存 public: StringBad(const char *s) { str = new char[strlen(s) + 1]; strcpy(str, s); } // 默认拷贝构造:逐字节复制(浅拷贝)! // 两个对象共享同一块内存 ~StringBad() { delete[] str; // 两个对象都 delete 同一内存 → 双重释放崩溃 } void show() const { cout << str << endl; } }; int main() { StringBad s1("Hello"); StringBad s2 = s1; // 浅拷贝:s1.str 和 s2.str 指向同一内存 // 程序结束:s2 先析构 delete[],s1 再析构 delete[] 同一地址 → 崩溃! cout << "程序将在此后崩溃(浅拷贝问题)" << endl; return 0; } ``` #### 2.2 深拷贝(正确做法) ```cpp #include #include using namespace std; class String { private: char *str; public: // 构造 String(const char *s = "") { str = new char[strlen(s) + 1]; strcpy(str, s); } // 拷贝构造(深拷贝:分配新内存,复制内容) String(const String &other) { str = new char[strlen(other.str) + 1]; strcpy(str, other.str); cout << "深拷贝构造" << endl; } // 拷贝赋值运算符(深拷贝) String &operator=(const String &other) { if (this == &other) return *this; // 自赋值检查 delete[] str; // 释放旧内存 str = new char[strlen(other.str) + 1]; strcpy(str, other.str); cout << "深拷贝赋值" << endl; return *this; } // 析构 ~String() { delete[] str; } void show() const { cout << str << endl; } }; int main() { String s1("Hello"); String s2 = s1; // 深拷贝构造:各自独立内存 String s3("World"); s2 = s3; // 深拷贝赋值 s1.show(); // Hello s2.show(); // World s3.show(); // World // 各自独立析构,无崩溃 cout << "深拷贝程序正常结束" << endl; return 0; } ``` #### 2.3 三法则(Rule of Three) 如果类需要自定义**析构函数**,则几乎总需要自定义: 1. **拷贝构造函数** 2. **拷贝赋值运算符** 因为默认实现都是浅拷贝,与自定义析构(释放资源)矛盾。 ### 三、static 成员 #### 3.1 静态成员变量 ```cpp #include using namespace std; class Counter { private: static int count; // 静态成员:所有对象共享 public: Counter() { count++; } ~Counter() { count--; } static int getCount() { return count; } // 静态成员函数 }; // 静态成员必须在类外定义(分配存储) int Counter::count = 0; int main() { cout << "初始: " << Counter::getCount() << endl; // 0 Counter c1; Counter c2; { Counter c3; cout << "现在: " << Counter::getCount() << endl; // 3 } // c3 析构 cout << "之后: " << Counter::getCount() << endl; // 2 return 0; } ``` #### 3.2 静态成员函数特性 ```cpp #include using namespace std; class MathUtils { public: // 静态成员函数:不依赖对象,无 this 指针 static int max(int a, int b) { return a > b ? a : b; } static int square(int x) { return x * x; } }; int main() { // 通过类名直接调用 cout << MathUtils::max(3, 7) << endl; // 7 cout << MathUtils::square(5) << endl; // 25 return 0; } ``` **注意**:静态成员函数不能访问非静态成员(没有 this 指针)。 ### 四、const 成员函数 ```cpp #include #include using namespace std; class Account { private: string owner; double balance; public: Account(string n, double b) : owner(n), balance(b) {} // const 成员函数:承诺不修改对象状态 void show() const { cout << owner << " 余额 " << balance << endl; // balance = 0; // 错误!const 成员函数不能修改 } // 非 const 成员函数:可修改 void deposit(double amount) { balance += amount; } // 可以同时有 const 和非 const 版本(重载) void info() const { cout << "[只读] " << owner << endl; } void info() { cout << "[可写] " << owner << endl; } }; void printAccount(const Account &acc) { // 参数是 const 引用,只能调用 const 成员函数 acc.show(); // acc.deposit(100); // 错误! } int main() { Account a("小明", 100); printAccount(a); // const 引用调用 a.deposit(50); // 非 const 对象可调用非 const 方法 a.show(); return 0; } ``` ### 五、this 指针 ```cpp #include #include using namespace std; class Employee { private: string name; public: Employee(string name) { // this 指针:指向当前对象的指针 this->name = name; // 解决参数与成员同名 } // 返回自身引用(链式调用) Employee &setName(string name) { this->name = name; return *this; // 返回解引用的 this } void show() const { cout << this->name << endl; // this-> 可省略 } }; int main() { Employee e("张三"); e.show(); // 链式调用 e.setName("李四").setName("王五"); e.show(); // 王五 return 0; } ``` ### 六、友元 #### 6.1 友元函数 ```cpp #include using namespace std; class Box { private: double width; double height; public: Box(double w, double h) : width(w), height(h) {} // 声明友元函数:可以访问私有成员 friend double getArea(const Box &b); friend Box operator+(const Box &a, const Box &b); }; // 友元函数实现(不是成员函数) double getArea(const Box &b) { return b.width * b.height; // 访问私有成员 } Box operator+(const Box &a, const Box &b) { return Box(a.width + b.width, a.height + b.height); } int main() { Box b1(3, 4), b2(2, 5); cout << "b1 面积: " << getArea(b1) << endl; // 12 Box sum = b1 + b2; cout << "合并面积: " << getArea(sum) << endl; // 45 return 0; } ``` #### 6.2 友元类 ```cpp #include using namespace std; class Secret { private: int code; public: Secret(int c) : code(c) {} friend class Decoder; // Decoder 可以访问 Secret 私有成员 }; class Decoder { public: void reveal(const Secret &s) { cout << "解密密码: " << s.code << endl; // 访问私有 } }; int main() { Secret s(9527); Decoder d; d.reveal(s); // 解密密码: 9527 return 0; } ``` **使用原则**:友元破坏封装,应谨慎 使用(通常只在运算符重载等必要场景)。 ### 七、实战:完整 String 类 综合本篇知识,实现完整的动态字符串类: ```cpp #include #include using namespace std; class MyString { private: char *data; int length; static int count; // 统计创建了多少个 public: // 构造 MyString(const char *s = "") { length = strlen(s); data = new char[length + 1]; strcpy(data, s); count++; cout << "构造: " << data << endl; } // 深拷贝构造 MyString(const MyString &other) { length = other.length; data = new char[length + 1]; strcpy(data, other.data); count++; cout << "拷贝: " << data << endl; } // 深拷贝赋值 MyString &operator=(const MyString &other) { if (this == &other) return *this; delete[] data; length = other.length; data = new char[length + 1]; strcpy(data, other.data); cout << "赋值: " << data << endl; return *this; } // 拼接运算符重载 MyString operator+(const MyString &other) const { char *temp = new char[length + other.length + 1]; strcpy(temp, data); strcat(temp, other.data); MyString result(temp); delete[] temp; return result; } // 比较运算符 bool operator==(const MyString &other) const { return strcmp(data, other.data) == 0; } // 析构 ~MyString() { cout << "析构: " << data << endl; delete[] data; count--; } void show() const { cout << data << endl; } static int getCount() { return count; } }; int MyString::count = 0; int main() { MyString s1("Hello"); MyString s2(" C++"); MyString s3 = s1; // 拷贝构造 MyString s4 = s1 + s2; // 拼接 s3 = s4; // 拷贝赋值 s4.show(); // Hello C++ cout << (s1 == MyString("Hello")) << endl; // 1 cout << "存活对象数: " << MyString::getCount() << endl; return 0; } ``` ### 总结 本篇深入讲解了构造函数的种类与初始化列表、构造析构顺序、深拷贝与浅拷贝的区别及三法则、static 成员、const 成员函数、this 指针和友元,并用完整 String 类串联实战。重点掌握:深拷贝防止双重释放、初始化列表的用法、static 与 const 的语法、this 指针的作用。 下一篇将讲解**运算符重载与类型转换**,敬请期待! --- ## C++入门教程:结构体、枚举与类初探 URL: https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-jie-gou-ti-mei-ju-yu-lei-chu-tan/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 5 篇。上一篇讲解了数组、字符串与指针,本篇正式进入面向对象世界:结构体 struct、枚举 enum/enum class、类 class 的基本语法,理解 public/private 访问控制和构造函数初步。 ### 一、结构体 struct #### 1.1 结构体的定义与使用 结构体是**不同类型数据的集合**,用于描述一个复合对象: ```cpp #include #include using namespace std; // 定义学生结构体 struct Student { string name; int age; double score; }; int main() { // 定义并初始化 Student s1; // 默认构造 s1.name = "张三"; s1.age = 20; s1.score = 88.5; // 列表初始化(C++11) Student s2 = {"李四", 21, 92.0}; // 访问成员 cout << s1.name << " " << s1.age << "岁 " << s1.score << "分" << endl; cout << s2.name << " " << s2.age << "岁 " << s2.score << "分" << endl; return 0; } ``` #### 1.2 结构体数组 ```cpp #include #include using namespace std; struct Student { string name; int age; double score; }; int main() { // 结构体数组 Student students[3] = { {"张三", 20, 88.5}, {"李四", 21, 92.0}, {"王五", 19, 76.5} }; // 遍历并统计平均分 double sum = 0; for (const Student &s : students) { cout << s.name << ": " << s.score << "分" << endl; sum += s.score; } cout << "平均分: " << sum / 3 << endl; return 0; } ``` #### 1.3 结构体指针与传参 ```cpp #include #include using namespace std; struct Student { string name; int age; }; // 用 const 引用避免拷贝 void printStudent(const Student &s) { cout << s.name << " " << s.age << "岁" << endl; } // 用指针修改结构体 void birthday(Student *s) { s->age++; // -> 通过指针访问成员 } int main() { Student stu = {"赵六", 22}; printStudent(stu); // 赵六 22岁 birthday(&stu); // 传地址 printStudent(stu); // 赵六 23岁 // 结构体指针 Student *p = &stu; cout << "(*p).age = " << (*p).age << endl; // 23 cout << "p->age = " << p->age << endl; // 23 return 0; } ``` ### 二、枚举 enum #### 2.1 C 风格枚举 ```cpp #include using namespace std; // 定义枚举类型 enum Color { RED, GREEN, BLUE }; // 默认 0, 1, 2 enum Weekday { MON = 1, TUE, WED }; // 指定起始 1, 2, 3 int main() { Color c = GREEN; cout << "GREEN = " << GREEN << endl; // 1 cout << "BLUE = " << BLUE << endl; // 2 cout << "TUE = " << TUE << endl; // 2 // 枚举参与运算(隐式转 int) int value = c + 1; cout << "value = " << value << endl; // 2 return 0; } ``` #### 2.2 enum class(C++11,推荐) ```cpp #include using namespace std; // 强类型枚举:必须用 类型::值 访问,不能隐式转 int enum class Color { RED, GREEN, BLUE }; enum class Light { RED, YELLOW, GREEN }; int main() { Color c = Color::GREEN; Light l = Light::RED; // 类型安全:不同枚举类型可同名 // int x = c; // 错误!不能隐式转 int int x = static_cast(c); // 显式转换 cout << "GREEN = " << x << endl; // 1 // 可比较(同类型) if (c == Color::GREEN) { cout << "是绿色" << endl; } return 0; } ``` #### 2.3 两种枚举对比 | 维度 | enum | enum class | | --- | --- | --- | | 作用域 | 值注入外层命名空间 | 值限定在类型内 | | 类型安全 | 可隐式转 int | 禁止隐式转换 | | 同名冲突 | 易冲突 | 可同名 | | 推荐程度 | 不推荐 | **推荐** | ### 三、类 class 初探 #### 3.1 类与结构体的关系 ```cpp #include #include using namespace std; // struct 默认成员是 public struct Point { int x; int y; }; // class 默认成员是 private class Circle { public: // 显式声明 public double radius; double area() { // 成员函数 return 3.14159 * radius * radius; } }; int main() { Point p; // struct:直接访问 p.x = 10; Circle c; c.radius = 5; cout << "面积 = " << c.area() << endl; // 78.5398 return 0; } ``` #### 3.2 完整的类定义 ```cpp #include #include using namespace std; class BankAccount { private: // 私有:外部不可直接访问 string owner; double balance; public: // 公有:对外接口 // 构造函数:对象创建时自动调用 BankAccount(string name, double initial) { owne r = name; balance = initial; cout << "账户创建: " << owner << endl; } // 成员函数(方法) void deposit(double amount) { if (amount > 0) { balance += amount; cout << "存入 " << amount << " 元" << endl; } } bool withdraw(double amount) { if (amount > 0 && amount <= balance) { balance -= amount; cout << "取出 " << amount << " 元" << endl; return true; } cout << "余额不足!" << endl; return false; } void showBalance() const { // const 成员函数:不修改对象 cout << owner << " 的余额: " << balance << " 元" << endl; } }; int main() { BankAccount acc("小明", 1000.0); acc.showBalance(); // 小明 的余额: 1000 元 acc.deposit(500); acc.showBalance(); // 余额: 1500 元 acc.withdraw(2000); // 余额不足 acc.withdraw(300); // 取出 300 元 acc.showBalance(); // 余额: 1200 元 // acc.balance = 0; // 错误!private 成员外部不可访问 return 0; } ``` #### 3.3 访问控制总结 | 访问级别 | 类内 | 派生类 | 外部 | | --- | --- | --- | --- | | public | 可 | 可 | 可 | | protected | 可 | 可 | 否 | | private | 可 | 否 | 否 | **封装原则**:数据成员设为 private,通过 public 成员函数访问,保护数据安全。 ### 四、构造函数与析构函数 #### 4.1 构造函数 ```cpp #include using namespace std; class Rectangle { private: double width; double height; public: // 默认构造 Rectangle() { width = 0; height = 0; cout << "默认构造" << endl; } // 带参构造 Rectangle(double w, double h) { width = w; height = h; cout << "带参构造: " << w << "x" << h << endl; } // 初始化列表(推荐,更高效) Rectangle(double side) : width(side), height(side) { cout << "正方形构造" << endl; } double area() { return width * height; } }; int main() { Rectangle r1; // 默认构造 Rectangle r2(3, 4); // 带参构造 Rectangle r3(5); // 正方形构造 cout << "r1 面积: " << r1.area() << endl; // 0 cout << "r2 面积: " << r2.area() << en dl; // 12 cout << "r3 面积: " << r3.area() << endl; // 25 return 0; } ``` #### 4.2 析构函数 ```cpp #include using namespace std; class Resource { public: Resource() { cout << "资源创建" << endl; } // 析构函数:对象销毁时自动调用,用于释放资源 ~Resource() { cout << "资源释放" << endl; } }; int main() { cout << "main 开始" << endl; { Resource res; // 构造 cout << "使用资源..." << endl; } // 离开作用域,析构 cout << "main 结束" << endl; return 0; } ``` 输出顺序: ``` main 开始 资源创建 使用资源... 资源释放 main 结束 ``` ### 五、实战:学生信息登记系统 综合本篇知识,用结构体 + 类实现学生登记: ```cpp #include #include using namespace std; // 结构体:基础数据 struct Student { string name; int age; double score; }; // 类:管理学生集合 class StudentManager { private: Student students[100]; // 最多 100 人 int count; public: StudentManager() : count(0) {} void addStudent(string name, int age, double score) { if (count >= 100) { cout << "人数已满!" << endl; return; } students[count] = {name, age, score}; count++; cout << "已添加: " << name << endl; } void printAll() const { cout << "===== 学生名单 =====" << endl; for (int i = 0; i < count; i++) { cout << i + 1 << ". " << students[i].name << " " << students[i].age << "岁 " << students[i].score << "分" << endl; } } double averageScore() const { if (count == 0) return 0; double sum = 0; for (int i = 0; i < count; i++) { sum += students[i].score; } return sum / count; } }; int main() { StudentManager manager; manager.addStudent("张三", 20, 88.5); manager.addStudent("李四", 21, 92.0); manager.addStudent("王五", 19, 76.5); manager.printAll(); cout << "平均分: " << manager.averageScore() << endl; return 0; } ``` 运行示例: ``` 已添加: 张三 已添加: 李四 已添加: 王五 ===== 学生名单 ===== 1. 张三 20岁 88.5分 2. 李四 21岁 92分 3. 王五 19岁 76.5分 平均分: 85.6667 ``` ### 总结 本篇讲解了结构体 struct(定义/数组/指针传参)、枚举 enum 与 enum class 的区别、类 class 的基本语法(成员/方法/访问控制)、构造函数与析构函数,并用学生信息登记系统串联实战。重点掌握:struct 与 class 的区别、enum class 的类型安全、public/private 封装思想、构造与析构时机。 下一篇将深入讲解**类与对象**(深拷贝/浅拷贝、static 成员、this 指针、友元),敬请期待! --- ## C++入门教程:数组、字符串与指针入门 URL: https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-shu-zu-zi-fu-chuan-yu-zhi-zhen-ru-men/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 4 篇。上一篇讲解了函数与作用域,本篇深入讲解数组、字符串和指针:一维/多维数组、C 风格字符串与 std::string、指针基础、指针与数组的关系、引用与 const 限定。 ### 一、数组 #### 1.1 一维数组 数组是**同类型元素的集合**,内存中连续存储: ```cpp #include using namespace std; int main() { // 声明与初始化 int scores[5]; // 未初始化(含垃圾值) int nums[5] = {1, 2, 3, 4, 5}; // 列表初始化 int partial[5] = {1, 2}; // 其余补 0 int autoSize[] = {10, 20, 30}; // 自动推导大小为 3 // 访问与修改 cout << "nums[0] = " << nums[0] << endl; // 1 nums[2] = 100; // 修改第三个元素 cout << "修改后 nums[2] = " << nums[2] << endl; // 100 // 遍历 for (int i = 0; i < 5; i++) { cout << nums[i] << " "; } cout << endl; return 0; } ``` #### 1.2 数组越界(危险!) ```cpp #include using namespace std; int main() { int arr[3] = {1, 2, 3}; // arr[3] 越界访问:编译不报错,但行为未定义 // 可能读到垃圾值,甚至修改其他内存导致崩溃 cout << "越界访问 arr[3] = " << arr[3] << "(未定义行为!)" << endl; return 0; } ``` **数组下标从 0 开始**,合法范围是 0 ~ 大小-1。C++ 不检查越界,这是很多 bug 的根源。 #### 1.3 多维数组 ```cpp #include using namespace std; int main() { // 3x4 二维数组(3 行 4 列) int matrix[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; // 遍历 for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { cout << matrix[i][j] << "\t"; } cout << endl; } return 0; } ``` #### 1.4 数组与范围 for C++11 起可用范围 for 遍历数组: ```cpp #include using namespace std; int main() { int nums[] = {5, 10, 15, 20}; // 范围 for:只读 for (int n : nums) { cout << n << " "; } cout << endl; // 用引用修改元素 for (int &n : nums) { n *= 2; } for (int n : nums) { cout << n << " "; } cout << endl; // 10 20 30 40 return 0; } ``` ### 二、字符串 #### 2.1 C 风格字符串 ```cpp #include #include using namespace std; int main() { // C 风格字符串:以 '\0' 结尾的字符数组 char str1[] = "Hello"; // 自动加 '\0' char str2[] = {'H', 'i', '\0'}; // 手动加 '\0' char str3[20]; // 常用函数 strcpy(str3, "World"); // 复制 strcat(str3, " C++"); // 拼接 cout << "str3 = " << str3 << endl; // World C++ cout << "strlen(str1) = " << strlen(str1) << endl; // 5(不含'\0') // 比较 if (strcmp(str1, "Hello") == 0) { cout << "相等" << endl; } return 0; } ``` #### 2.2 std::string(推荐) ```cpp #include #include using namespace std; int main() { string s1 = "Hello"; string s2 = " C++"; // 拼接 string s3 = s1 + s2; cout << "s3 = " << s3 << endl; // Hello C++ // 长度与访问 cout << "长度: " << s3.length() << endl; // 9 cout << "首字符: " << s3[0] << endl; // H // 查找 size_t pos = s3.find("C++"); if (pos != string::npos) { cout << "找到 C++ 在位置 " << pos << endl; // 6 } // 截取子串 string sub = s3.substr(6, 3); cout << "子串: " << sub << endl; // C++ // 比较 if (s1 < s2) { cout << "s1 字典序小于 s2" << endl; } return 0; } ``` #### 2.3 string 与 C 字符串互转 ```cpp #include #include using namespace std; int main() { // string -> const char* string s = "Hello"; const char *cstr = s.c_str(); cout << "C 字符串: " << cstr << endl; // const char* -> string const char *p = "World"; string s2 = p; cout << "string: " << s2 << endl; return 0; } ``` ### 三、指针基础 #### 3.1 指针的概念 指针是**存储地址的变量**: ```cpp #include using namespace std; int main() { int num = 42; // 声明指针并取地址 int *ptr = # // & 取地址,* 声明指针 cout << "num 的值: " << num << endl; // 42 cout << "num 的地址: " << &num << endl; // 如 0x61ff08 cout << "ptr 的值: " << ptr << endl; // 同 num 地址 cout << "ptr 解引用: " << *ptr << endl; // 42 // 通过指针修改值 *ptr = 100; cout << "修改后 num = " << num << endl; // 100 return 0; } ``` #### 3.2 空指针与野指针 ```cpp #include using namespace std; int main() { int *p1 = nullptr; // C++11 空指针(推荐) int *p2 = NULL; // C 风格(不推荐) int *p3 = 0; // 等价于空指针 if (p1 == nullptr) { cout << "p1 是空指针" << endl; } // 野指针:未初始化的指针,指向未知地址 int *wild; // 未初始化 // *wild = 10; // 危险!解引用野指针是未定义行为 // 正确做法:使用前必须指向有效内存 int value = 5; int *safe = &value; cout << *safe << endl; // 5 return 0; } ``` #### 3.3 指针的运算 ```cpp #include using namespace std; int main() { int arr[] = {10, 20, 30, 40}; int *p = arr; // 数组名退化为首元素地址 cout << "*p = " << *p << endl; // 10 cout << "*(p+1) = " << *(p + 1) << endl; // 20(指针+1 跳过 1 个 int) cout << "*(p+2) = " << *(p + 2) << endl; // 30 // 指针算术与下标等价 cout << "p[1] = " << p[1] << endl; // 20 cout << "*(arr+1) = " << *(arr + 1) << endl; // 20 // 遍历 for (int *q = arr; q < arr + 4; q++) { cout << *q << " "; } cout << endl; // 10 20 30 40 return 0; } ``` ### 四、指针与数组的关系 #### 4.1 数组名与指针 ```cpp #include using namespace std; int main() { int arr[3] = {1, 2, 3}; // 数组名 arr 可视为指向首元素的常量指针 cout << "arr = " << arr << endl; // 首地址 cout << "&arr[0] = " << &arr[0] << endl; // 同首地址 cout << "a[i] == *(a+i): " << (arr[1] == *(arr + 1)) << endl; // 1(相等) // 数组名不可被赋值(不是真正的变量指针) // arr = &arr[1]; // 错误! // 但可以用指针指向数组并移动 int *p = arr; p++; // 合法,指针可以移动 cout << "*p = " << *p << endl; // 2 return 0; } ``` #### 4.2 指针作为函数参数(传数组) ```cpp #include using namespace std; // 数组参数退化为指针,需要同时传大小 int sumArray(int arr[], int size) { int sum = 0; for (int i = 0; i < size; i++) { sum += arr[i]; } return sum; } // 等价写法:int *arr int sumArrayPtr(int *arr, int size) { int sum = 0; for (int i = 0; i < size; i++) { sum += *(arr + i); } return sum; } int main() { int scores[] = {85, 90, 78, 92}; cout << "总分 = " << sumArray(scores, 4) << endl; // 345 cout << "总分 = " << sumArrayPtr(scores, 4) << endl; // 345 return 0; } ``` ### 五、引用 #### 5.1 引用基础 ```cpp #include using namespace std; int main() { int num = 10; // 引用:变量的别名,必须初始化且不能改绑 int &ref = num; ref = 20; // 通过引用修改原变量 cout << "num = " << num << endl; // 20 cout << "ref = " << ref << endl; // 20 // 引用不是独立变量,修改引用就是修改原变量 int other = 50; ref = other; // 这是把 other 的值赋给 num,不是让 ref 绑定 other cout << "num = " << num << endl; // 50 return 0; } ``` #### 5.2 指针 vs 引用 | 维度 | 指针 | 引用 | | --- | --- | --- | | 定义 | 存储地址的变量 | 变量的别名 | | 初始化 | 可以不初始化(野指针) | 必须初始化 | | 重新绑定 | 可以指向其他对象 | 不可改变 | | 空值 | 可以为 nullptr | 不能为空 | | 语法 | 需要 * 解引用 | 直接使用 | | 适用 | 动态分配、可选对象、数组遍历 | 函数参数、运算符重载 | ### 六、const 限定 #### 6.1 顶层 const 与底层 const ```cpp #include using namespace std; int main() { int num = 10; // 顶层 const:指针本身不可变 int *const pTop = # // pTop 不能指向别处 *pTop = 20; // 但可以修改指向的值 cout << "num = " << num << endl; // 20 // 底层 const:指向的对象不可变 const int *pLow = # // 不能通过 pLow 修改值 // *pLow = 30; // 错误! pLow = nullptr; // 但 pLow 可以指向别处 // 同时 const const int *const pBoth = # // pBoth = nullptr; // 错误! // *pBoth = 30; // 错误! return 0; } ``` #### 6.2 const 指针传参惯例 ```cpp #include using namespace std; // const int* 表示只读访问,不修改数组 void printArray(const int arr[], int size) { for (int i = 0; i < size; i++) { cout << arr[i] << " "; } cout << endl; // arr[0] = 99; // 错误!const 数组不可修改 } int main() { int data[] = {1, 2, 3, 4}; printArray(data, 4); // 1 2 3 4 return 0; } ``` ### 七、实战:字符串反转与回文判断 综合本篇知识,实现字符串处理和回文判断: ```cpp #include #include using namespace std; // 反转字符串(用指针实现) void reverseStr(char *str) { int len = 0; while (str[len] != '\0') len++; // 求长度 char *left = str; char *right = str + len - 1; while (left < right) { char temp = *left; *left = *right; *right = temp; left++; right--; } } // 判断回文(用 std::string) bool isPalindrome(const string &s) { int left = 0, right = s.length() - 1; while (left < right) { if (s[left] != s[right]) return false; left++; right--; } return true; } int main() { // 1. 字符数组反转 char text[] = "HelloWorld"; reverseStr(text); cout << "反转后: " << text << endl; // dlroWolleH // 2. 回文判断 string words[] = {"level", "hello", "上海自来水来自海上"}; for (const string &w : words) { cout << "\"" << w << "\" " << (isPalindrome(w) ? "是回文" : "不是回文") << endl; } return 0; } ``` 运行示例: ``` 反转后: dlroWolleH "level" 是回文 "hello" 不是回文 "上海自来水来自海上" 是回文 ``` ### 总结 本篇讲解了数组(一维/多维/范围 for)、字符串(C 风格与 std::string)、指针基础(地址/解引用/运算/野指针)、指针与数组的关系、引用与指针的区别、const 限定(顶层/底层),并用字符串反转和回文判断串联实战。重点掌握:数组越界的危害、std::string 的常用操作、指针算术、引用与指针的选择。 下一篇将讲解**结构体、枚举与类初探**,正式进入面向对象世界,敬请期待! --- ## C++入门教程:函数与作用域详解 URL: https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-han-shu-yu-zuo-yong-yu-xiang-jie/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 3 篇。上一篇讲解了运算符、表达式与控制流程,本篇深入讲解函数:定义与调用、参数传递方式、函数重载、递归、内联函数,以及变量作用域与生命周期。 ### 一、函数基础 #### 1.1 函数的作用 函数是**组织代码的基本单元**,它把一段完成特定功能衺起来,实现: - **复用**:同一段逻辑只写一次,多处调用。 - **抽象**:调用者只需关心「做什么」,不必关心「怎么做」。 - **维护**:修改只需改一处。 #### 1.2 函数定义与调用 ```cpp #include using namespace std; // 函数定义:返回类型 函数名(参数列表) { 函数体 } int add(int a, int b) { return a + b; } // 无返回值函数 void printLine() { cout << "==========" << endl; } int main() { printLine(); // 调用无参函数 int sum = add(3, 5); // 调用带参函数,接收返回值 cout << "3 + 5 = " << sum << endl; printLine(); return 0; } ``` #### 1.3 函数声明与定义分离 当函数定义在使用之后时,需要先**声明**: ```cpp #include using namespace std; // 函数声明(原型):只写签名,不写函数体 int multiply(int a, int b); int main() { cout << "4 x 7 = " << multiply(4, 7) << endl; return 0; } // 函数定义放在后面 int multiply(int a, int b) { return a * b; } ``` ### 二、参数传递 #### 2.1 值传递(传值) ```cpp #include using namespace std; void changeValue(int x) { x = 100; // 修改的是副本 } int main() { int num = 10; changeValue(num); cout << "num = " << num << endl; // 10,原值不变 return 0; } ``` 值传递时,参数是实参的**拷贝**,函数内修改不影响外部变量。 #### 2.2 引用传递(传引用) ```cpp #include using namespace std; void swap(int &a, int &b) { // 引用参数,直接操作原变量 int temp = a; a = b; b = temp; } int main() { int x = 3, y = 7; swap(x, y); cout << "x = " << x << ", y = " << y << endl; // x = 7, y = 3 return 0; } ``` #### 2.3 const 引用 当函数不需要修改参数但又想避免拷贝开销时,用 `const 引用`: ```cpp #include #include using namespace std; // 传入大对象(如字符串)用 const 引用,避免拷贝且保证不修改 void printMessage(const string &msg) { cout << "消息: " << msg << endl; // msg = "xxx"; // 错误!const 引用不可修改 } int main() { string s = "Hello C++"; printMessage(s); return 0; } ``` #### 2.4 三种传递方式对比 | 方式 | 语法 | 能否修改原值 | 性能 | | --- | --- | --- | --- | | 值传递 | `int x` | 否 | 拷贝开销 | | 引用传递 | `int &x` | 是 | 无拷贝 | | const 引用 | `const int &x` | 否 | 无拷贝 | ### 三、默认参数与函数重载 #### 3.1 默认参数 ```cpp #include using namespace std; // 默认参数:调用时可省略 int power(int base, int exp = 2) { int result = 1; for (int i = 0; i < exp; i++) { result *= base; } return result; } int main() { cout << "3^2 = " << power(3) << endl; // 9,exp 用默认值 2 cout << "3^3 = " << power(3, 3) << endl; // 27 return 0; } ``` **规则**:默认参数必须从右往左连续设置,如 `f(int a, int b = 1, int c = 2)` 合法,`f(int a = 1, int b)` 不合法。 #### 3.2 函数重载 同名函数,参数列表不同(类型、个数、顺序),编译器根据实参选择: ```cpp #include using namespace std; // 三个同名函数,参数不同 int max(int a, int b) { return a > b ? a : b; } double max(double a, double b) { return a > b ? a : b; } int max(int a, int b, int c) { return max(max(a, b), c); } int main() { cout << "max(3, 7) = " << max(3, 7) << endl; // int 版本 cout << "max(3.5, 2.8) = " << max(3.5, 2.8) << endl; // double 版本 cout << "max(1, 5, 3) = " << max(1, 5, 3) << endl; // 三参版本 return 0; } ``` **注意**:重载仅凭返回类型不同无法区分(`int f()` 和 `double f()` 不能共存)。 ### 四、递归 #### 4.1 递归原理 递归是函数**调用自身**的技巧,必须包含: 1. **基线条件**:终止递归的判断。 2. **递归条件**:向基线条件逼近的调用。 ```cpp #include using namespace std; // 阶乘:n! = n * (n-1)! int factorial(int n) { if (n <= 1) return 1; // 基线条件 return n * factorial(n - 1); // 递归调用 } // 斐波那契:f(n) = f(n-1) + f(n-2) int fibonacci(int n) { if (n <= 1) return n; // 基线条件 return fibonacci(n - 1) + fibonacci(n - 2); } int main() { cout << "5! = " << factorial(5) << endl; // 120 cout << "斐波那契第10项 = " << fibonacci(10) << endl; // 55 return 0; } ``` #### 4.2 递归执行过程(以 factorial(4) 为例) ``` factorial(4) = 4 * factorial(3) = 4 * (3 * factorial(2)) = 4 * (3 * (2 * factorial(1))) = 4 * (3 * (2 * 1)) = 24 ``` #### 4.3 递归 vs 迭代 | 维度 | 递归 | 迭代(循环) | | --- | --- | --- | | 代码 | 简洁,符合数学定义 | 略复杂 | | 性能 | 有函数调用开销,深递归可能栈溢出 | 高效 | | 适用 | 树、图、分治等天然递归结构 | 简单重复计算 | ### 五、内联函数 ```cpp #include using namespace std; // inline 建议编译器将函数体展开到调用处(消除调用开销) inline int square(int x) { return x * x; } int main() { // 编译后等价于直接写 x*x cout << "7^2 = " << square(7) << endl; // 49 return 0; } ``` **适用**:函数体很小(几行)、被频繁调用。**注意**:`inline` 只是建议,大型函数编译器会忽略。 ### 六、变量作用域与生命周期 #### 6.1 局部变量与全局变量 ```cpp #include using namespace std; int globalCount = 0; // 全局变量:整个文件可见,生命周期为程序运行期 void increment() { int local = 1; // 局部变量:函数内可见,函数结束即销毁 globalCount += local; } int main() { increment(); increment(); cout << "globalCount = " << globalCount << endl; // 2 // cout << local; // 错误!local 在 main 中不可见 return 0; } ``` #### 6.2 局部变量的遮蔽 ```cpp #include using namespace std; int x = 100; // 全局 x int main() { int x = 5; // 局部 x 遮蔽全局 x cout << "局部 x = " << x << endl; // 5 cout << "全局 x = " << ::x << endl; // 100(用 :: 访问全局) { int x = 50; // 内层块又遮蔽外层局部 cout << "块内 x = " << x << endl; // 50 } cout << "外层局部 x = " << x << endl; // 5 return 0; } ``` #### 6.3 static 局部变量 ```cpp #include using namespace std; void counter() { static int count = 0; // static 局部:只初始化一次,函数结束后保留值 count++; cout << "第 " << count << " 次调用" << endl; } int main() { counter(); // 第 1 次调用 counter(); // 第 2 次调用 counter(); // 第 3 次调用 return 0; } ``` ### 七、函数指针(了解) ```cpp #include using namespace std; int add(int a, int b) { return a + b; } int subtract(int a, int b) { return a - b; } int main() { // 函数指针:指向函数的指针 int (*operation)(int, int); operation = add; cout << "add(10, 3) = " << operation(10, 3) << endl; // 13 operation = subtract; cout << "subtract(10, 3) = " << operation(10, 3) << endl; // 7 return 0; } ``` ### 八、实战:简易计算器函数库 综合本篇知识,编写一个可复用的计算器函数库: ```cpp #include using namespace std; // 函数库:加减乘除 double add(double a, double b) { return a + b; } double subtract(double a, double b) { return a - b; } double multiply(double a, double b) { return a * b; } double divide(double a, double b) { if (b == 0) { cout << "错误:除数不能为 0" << endl; return 0; } return a / b; } // 函数指针数组:把运算函数装进数组,根据索引调用 double (*operations[4])(double, double) = {add, subtract, multiply, divide}; const char *names[4] = {"加法", "减法", "乘法", "除法"}; int main() { double a = 20, b = 4; for (int i = 0; i < 4; i++) { cout << names[i] << ": " << a << " 与 " << b << " 的运算结果 = " << operations[i](a, b) << endl; } return 0; } ``` 运行示例: ``` 加法: 20 与 4 的运算结果 = 24 减法: 20 与 4 的运算结果 = 16 乘法: 20 与 4 的运算结果 = 80 除法: 20 与 4 的运算结果 = 5 ``` ### 总结 本篇讲解了函数的定义与调用、三种参数传递方式(值/引用/const 引用)、默认参数、函数重载、递归、内联函数、变量作用域与生命周期、函数指针,并用计算器函数库串联实战。重点掌握:值传递与引用传递的区别、重载规则、递归的基线条件、static 局部变量。 下一篇将讲解**数组、字符串与指针入门**,敬请期待! --- ## C++入门教程:运算符、表达式与控制流程 URL: https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-yun-suan-fu-biao-da-shi-yu-kong-zhi-liu/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 2 篇。上一篇介绍了 C++ 的环境搭建、第一个程序和基本数据类型,本篇深入讲解运算符、表达式与三种控制流程(顺序、选择、循环),并掌握 break/continue 等跳转语句。 ### 一、运算符详解 #### 1.1 运算符分类总览 | 类别 | 运算符 | 示例 | | --- | --- | --- | | 算术 | `+ - * / %` | `a + b` | | 关系 | `> < >= <= == !=` | `a > b` | | 逻辑 | `&& || !` | `a && b` | | 位运算 | `& | ^ ~ << >>` | `a & b` | | 赋值 | `= += -= *= /= %=` | `a += 3` | | 自增自减 | `++ --` | `a++` | | 条件 | `? :` | `a > b ? a : b` | | 逗号 | `,` | `a, b` | #### 1.2 算术运算符与细节 ```cpp #include using namespace std; int main() { int a = 17, b = 5; cout << "17 / 5 = " << a / b << endl; // 3,整数除法截断 cout << "17 % 5 = " << a % b << endl; // 2,取余 cout << "-17 % 5 = " << (-a) % b << endl; // -2,符号跟随被除数 // 自增自减 int x = 10; cout << "x++ 先取值后自增: " << x++ << endl; // 10 cout << "此时 x = " << x << endl; // 11 cout << "++x 先自增后取值: " << ++x << endl; // 12 return 0; } ``` #### 1.3 关系与逻辑运算符 ```cpp #include using namespace std; int main() { int age = 25; bool hasTicket = true; // 关系运算 cout << "age > 18: " << (age > 18) << endl; // 1 cout << "age == 25: " << (age == 25) << endl; // 1 // 逻辑运算:&&(与)、||(或)、!(非) bool canEnter = (age >= 18) && hasTicket; cout << "可以入场: " << canEnter << endl; // 1 bool student = false; cout << "是学生或已成年: " << (student || age >= 18) << endl; // 1 cout << "不是学生: " << !student << endl; // 1 return 0; } ``` #### 1.4 短路求值 `&&` 和 `||` 具有**短路特性**:左侧结果已能确定最终值时,右侧不再计算。 ```cpp #include using namespace std; int main() { int divisor = 0; // 利用短路防止除零:divisor != 0 为假时,右侧 10/divisor 不会执行 if (divisor != 0 && 10 / divisor > 2) { cout << "条件成立" << endl; } else { cout << "短路保护生效,未发生除零" << endl; } return 0; } ``` #### 1.5 位运算符 ```cpp #include using namespace std; int main() { int a = 5; // 0101 int b = 3; // 0011 cout << "a & b = " << (a & b) << endl; // 1(0001) cout << "a | b = " << (a | b) << endl; // 7(0111) cout << "a ^ b = " << (a ^ b) << endl; // 6(0110) cout << "~a = " << (~a) << endl; // -6 cout << "a << 1 = " << (a << 1) << endl; // 10(左秽一位 = 乘2) cout << "a >> 1 = " << (a >> 1) << endl; // 2(��终位 = 隌) return 0; } ``` ### 二、运算符优先级 #### 2.1 常见优先级速查(从高到低) | 优先级 | 运算符 | | --- | --- | | 1 | `()` `[]` `->` `.` | | 2 | `!` `~` `++` `--` `-`(一元) | | 3 | `* / %` | | 4 | `+ -` | | 5 | `<< >>` | | 6 | `< <= > >=` | | 7 | `== !=` | | 8 | `&` | | 9 | `^` | | 10 | `|` | | 11 | `&&` | | 12 | `||` | | 13 | `?:` | | 14 | `=` `+=` `-=` 等 | #### 2.2 优先级示例 ```cpp #include using namespace std; int main() { int a = 2 + 3 * 4; // 14(* 优先于 +) int b = (2 + 3) * 4; // 20(括号改变优先级) bool c = 3 > 2 && 4 < 5; // true(关系运算优先于逻辑) cout << a << endl; cout << b << endl; cout << c << endl; // 建议:复杂表达式加括号,提高可读性 int score = 85; char grade = score >= 90 ? 'A' : (score >= 80 ? 'B' : 'C'); cout << "等级: " << grade << endl; // B return 0; } ``` ### 三、选择结构 #### 3.1 if / else if / else ```cpp #include using namespace std; int main() { int score; cout << "请输入成绩: "; cin >> score; if (score >= 90) { cout << "优秀" << endl; } else if (score >= 80) { cout << "良好" << endl; } else if (score >= 60) { cout << "及格" << endl; } else { cout << "不及格" << endl; } return 0; } ``` #### 3.2 switch 语句 ```cpp #include using namespace std; int main() { int day; cout << "输入星期几(1-7): "; cin >> day; switch (day) { case 1: cout << "周一" << endl; break; case 2: cout << "周二" << endl; break; case 3: cout << "周三" << endl; break; case 4: cout << "周四" << endl; break; case 5: cout << "周五" << endl; break; case 6: case 7: cout << "周末" << endl; break; // 多个 case 共享 default: cout << "无效输入" << endl; // 缺省分支 } return 0; } ``` #### 3.3 switch 与 if 的选择 | 场景 | 推荐 | | --- | --- | | 判断整数/字符等于某几个值 | switch | | 范围判断、浮点比较、复杂条件 | if/else | ### 四、循环结构 #### 4.1 while 循环 ```cpp #include using namespace std; int main() { // 计算 1 到 100 的和 int sum = 0, i = 1; while (i <= 100) { sum += i; i++; } cout << "1+2+...+100 = " << sum << endl; // 5050 return 0; } ``` #### 4.2 do-while 循环 ```cpp #include using namespace std; int main() { int n; // do-while 至少执行一次,适合「先输入后验证」 do { cout << "请输入一个正数: "; cin >> n; } while (n <= 0); cout << "你输入了: " << n << endl; return 0; } ``` #### 4.3 for 循环 ```cpp #include using namespace std; int main() { // 打印 9x9 乘法表 for (int i = 1; i <= 9; i++) { for (int j = 1; j <= i; j++) { cout << j << "x" << i << "=" << (i * j) << "\t"; } cout << endl; } return 0; } ``` #### 4.4 循环控制:break 与 continue ```cpp #include using namespace std; int main() { // break: 立即终止整个循环 for (int i = 1; i <= 10; i++) { if (i == 5) break; cout << i << " "; } cout << endl; // 输出: 1 2 3 4 // continue: 跳过本次循环剩余语句,进入下一次 for (int i = 1; i <= 10; i++) { if (i % 2 == 0) continue; // 跳过偶数 cout << i << " "; } cout << endl; // 输出: 1 3 5 7 9 return 0; } ``` #### 4.5 嵌套循环与 goto(不推荐) ```cpp #include using namespace std; int main() { // 找出第一个满足 i*j == 24 的组合 for (int i = 1; i <= 10; i++) { for (int j = 1; j <= 10; j++) { if (i * j == 24) { cout << "找到: " << i << " x " << j << " = 24" << endl; goto found; // 跳出多重循环 } } } cout << "未找到" << endl; found: cout << "程序结束" << endl; return 0; } ``` ### 五、实战:猜数字游戏 综合本篇全部知识,实现猜数字游戏: ```cpp #include #include #include using namespace std; int main() { // 用当前时间做随机种子 srand(time(nullptr)); int secret = rand() % 100 + 1; // 1~100 随机数 int guess, attempts = 0; cout << "===== 猜数字游戏 =====" << endl; cout << "我已想好一个 1~100 之间的数字,你来猜!" << endl; while (true) { cout << "请输入你的猜测: "; cin >> guess; attempts++; if (guess > secret) { cout << "太大了!" << endl; } else if (guess < secret) { cout << "太小了!" << endl; } else { cout << "恭喜!你猜对了!" << endl; cout << "数字是 " << secret << ",你共猜了 " << attempts << " 次。" << endl; break; } if (attempts >= 10) { cout << "次数用尽,答案是 " << secret << ",下次加油!" << endl; break; } } return 0; } ``` 运行示例: ``` ===== 猜数字游戏 ===== 我已想好一个 1~100 之间的数字,你来猜! 请输入你的猜测: 50 太大了! 请输入你的猜测: 25 太小了! 请输入你的猜测: 37 恭喜!你猜对了! 数字是 37,你共猜了 3 次。 ``` ### 总结 本篇讲解了运算符的分类与优先级、短路求值、if/switch 选择结构、while/do-while/for 循环和 break/continue/goto 跳转语句,并用猜数字游戏串联实战。重点掌握:优先级与括号、短路求值、三种循环的适用场景、break 与 continue 的区别。 下一篇将讲解**函数与作用域详解**(参数传递、重载、递归),敬请期待! --- ## C语言入门教程:从零开始学C语言编程 URL: https://wsj-blog.pages.dev/posts/c-yu-yan-ru-men-jiao-cheng-cong-ling-kai-shi-xue-c-yu-yan/ License: CC-BY-NC-SA-4.0 #### 目录 - [C语言入门教程:从零开始学C语言编程](#CC_2) - - [一、为什么学C语言](#C_6) - [二、开发环境搭建](#_15) - - [2.1 编译器选择](#21__17) - [2.2 验证安装](#22__23) - [三、第一个C程序](#C_30) - [四、基本语法](#_50) - - [4.1 变量与数据类型](#41__52) - [4.2 格式化输出](#42__72) - [4.3 输入](#43__83) - [五、运算符](#_97) - [六、条件与循环](#_120) - [七、实战:猜数字游戏](#_165) - [总结](#_194) > 本系列是 **C 语言教程系列** 的第 1 篇。C 语言是计算机世界的**基石语言**,操作系统、嵌入式、底层开发都离不开它。本篇从零开始,带你入门。 ### 一、为什么学C语言 C 语言诞生于 1972 年,至今仍是使用最广泛的语言之一: - **底层控制**:可直接操作内存和硬件 - **性能极高**:接近汇编的执行效率 - **承上启下**:理解 C 有助于学习 Python、Java、C++ - **应用广泛**:操作系统(Linux/Windows)、嵌入式、数据库(MySQL) ### 二、开发环境搭建 #### 2.1 编译器选择 - **Windows**:MinGW-w64 或 Visual Studio - **macOS**:Xcode Command Line Tools(自带 gcc) - **Linux**:`sudo apt install gcc` #### 2.2 验证安装 ```bash gcc --version # 输出示例: gcc (MinGW-W64) 13.1.0 ``` ### 三、第一个C程序 ```c #include // 引入标准输入输出库 int main() { // 向屏幕输出文字 printf("Hello, World!\n"); return 0; // 返回0表示程序正常结束 } ``` 编译运行: ```bash gcc hello.c -o hello ./hello # Windows 下是 hello.exe # 输出: Hello, World! ``` ### 四、基本语法 #### 4.1 变量与数据类型 ```c #include int main() { int age = 20; // 整型 float score = 88.5f; // 单精度浮点 double pi = 3.14159; // 双精度浮点 char grade = 'A'; // 单个字符 const int MAX = 100; // 常量 printf("年龄:%d\n", age); printf("成绩:%.1f\n", score); printf("圆周率:%.5f\n", pi); printf("等级:%c\n", grade); return 0; } ``` #### 4.2 格式化输出 ```c printf("%d", 10); // 整数 printf("%f", 3.14); // 浮点 printf("%c", 'A'); // 字符 printf("%s", "hello"); // 字符串 printf("%x", 255); // 十六进制 ff printf("%.2f", 3.14159); // 保留2位小数 3.14 ``` #### 4.3 输入 ```c #include int main() { int num; printf("请输入一个数字:"); scanf("%d", &num); // 注意 & 取地址 printf("你输入的是:%d\n", num); return 0; } ``` ### 五、运算符 ```c // 算术:+ - * / % int a = 10, b = 3; printf("%d %d\n", a / b, a % b); // 3 1(整数除法与取余) // 关系:== != > < >= <= printf("%d\n", a > b); // 1(真) // 逻辑:&& || ! int x = 5; printf("%d\n", x > 0 && x < 10); // 1 // 自增自减 int i = 1; printf("%d %d\n", i++, ++i); // 注意顺序点问题 // 位运算:& | ^ ~ << >> printf("%d\n", 6 & 3); // 2 (110 & 011 = 010) printf("%d\n", 1 << 3); // 8 (左移3位) ``` ### 六、条件与循环 ```c #include int main() { // if / else if / else int score = 85; if (score >= 90) { printf("优秀\n"); } else if (score >= 60) { printf("及格\n"); } else { printf("不及格\n"); } // switch int day = 3; switch (day) { case 1: printf("周一"); break; case 2: printf("周二"); break; default: printf("其他"); } // for for (int i = 1; i <= 5; i++) { printf("%d ", i); // 1 2 3 4 5 } // while int n = 0; while (n < 3) { n++; } // do-while(至少执行一次) int m = 0; do { printf("执行"); } while (m > 0); return 0; } ``` ### 七、实战:猜数字游戏 ```c #include #include #include int main() { srand(time(NULL)); int answer = rand() % 100 + 1; // 1~100 int guess, count = 0; printf("猜数字游戏(1-100)\n"); do { printf("请输入:"); scanf("%d", &guess); count++; if (guess > answer) { printf("太大了!\n"); } else if (guess < answer) { printf("太小了!\n"); } } while (guess != answer); printf("恭喜!猜了%d次就对了\n", count); return 0; } ``` ### 总结 本篇介绍了 C 语言的环境搭建、基础语法、运算符、条件循环,并用猜数字游戏串联实战。下一篇将深入讲解 C 语言的精髓——**指针与内存管理**,敬请期待! --- ## C++入门教程:从零开始学C++编程 URL: https://wsj-blog.pages.dev/posts/c-ru-men-jiao-cheng-cong-ling-kai-shi-xue-c-bian-cheng/ License: CC-BY-NC-SA-4.0 > 本文是 C++ 系列教程的第 1 篇。本系列共 30 篇,从零基础一路讲到现代 C++(C++11/14/17/20)、STL、并发编程与工程实战。本篇目标:认识 C++、搭建开发环境、写出并理解第一个程序,掌握变量与基本数据类型。 ### 一、C++ 是什么 C++ 是一门**编译型、静态类型**的通用编程语言,由 Bjarne Stroustrup 于 1983 年在贝尔实验室基于 C 语言扩展而来。它既保留 C 语言的高性能与底层控制能力,又引入了**面向对象**、**泛型编程**和**现代抽象**机制。 #### 1.1 为什么学 C++ | 优势 | 说明 | | --- | --- | | 性能极高 | 可直接操作内存、无运行时开销的抽象 | | 应用广泛 | 游戏引擎(Unreal)、操作系统、浏览器(Chrome)、数据库、金融高频交易 | | 就业需求大 | 后端、嵌入式、音视频、图形学、自动驾驶等领域核心语言 | | 现代演进 | C++11/14/17/20 大幅改善易用性与安全性 | #### 1.2 C++ 版本演进 - **C++98/03**:经典 C++,面向对象与模板基础。 - **C++11**:里程碑版本,引入 auto、智能指针、lambda、移动语义。 - **C++14**:泛型 lambda、返回类型推导增强。 - **C++17**:结构化绑定、if constexpr、std::optional、filesystem。 - **C++20**:概念(concepts)、范围(ranges)、协程、三路比较。 #### 1.3 编译型语言的工作流程 ``` 源代码(.cpp) --编译器--> 目标代码(.obj) --链接器--> 可执行程序(.exe) --运行--> 输出结果 ``` 与 Python 等解释型语言不同,C++ 代码需要**先编译再运行**,这也带来了更高的执行效率。 ### 二、环境搭建 #### 2.1 Windows 方案 推荐使用 **Visual Studio**(社区版免费)或 **Visual Studio Code + MinGW-w64**: 1. 安装 VS Code,安装 C/C++ 扩展。 2. 安装 MinGW-w64(含 g++ 编译器),并加入 PATH。 3. 命令行验证:`g++ --version`。 #### 2.2 第一个程序的编写与编译 新建 `hello.cpp`: ```cpp #include // 引入输入输出库 using namespace std; // 使用标准命名空间 int main() { cout << "Hello, World!" << endl; // 输出一行文本 return 0; // 返回 0 表示程序正常结束 } ``` 在终端编译并运行: ```bash g++ hello.cpp -o hello.exe ./hello.exe ``` 输出: ``` Hello, World! ``` #### 2.3 逐行拆解第一个程序 | 代码 | 作用 | | --- | --- | | `#include ` | 预处理指令,引入标准输入输出头文件 | | `using namespace std;` | 声明使用 std 命名空间,之后可直接写 cout | | `int main()` | 主函数,程序入口,每个程序必须有且仅有一个 | | `cout << "..."` | 输出流操作符,将内容送到标准输出 | | `endl` | 换行并刷新缓冲区 | | `return 0;` | 返回值给操作系统,0 表示成功 | ### 三、变量与基本数据类型 #### 3.1 基本数据类型 | 类型 | 大小(常见) | 取值范围(典型) | 说明 | | --- | --- | --- | --- | | `bool` | 1 字节 | true / false | 布尔型 | | `char` | 1 字节 | -128 ~ 127 | 字符型 | | `int` | 4 字节 | 约 ±21 亿 | 整型 | | `float` | 4 字节 | 约 7 位有效数字 | 单精度浮点 | | `double` | 8 字节 | 约 15 位有效数字 | 双精度浮点 | #### 3.2 变量定义与初始化 ```cpp #include using namespace std; int main() { // 多种初始化方式 int age = 20; // C 风格初始化 int score(95); // 构造函数风格 int level{3}; // 列表初始化(C++11 推荐,防窄化) double pi = 3.14159; char grade = 'A'; bool isPass = true; cout << "年龄:" << age << endl; cout << "分数:" << score << endl; cout << "等级:" << level << endl; cout << "圆周率:" << pi << endl; cout << "成绩等级:" << grade << endl; cout << "是否通过:" << (isPass ? "是" : "否") << endl; return 0; } ``` **变量命名规则**: - 只能由字母、数字、下划线组成,且不能以数字开头。 - 不能与关键字重名(如 int、if、class)。 - 建议使用有意义的名称,如 `studentCount` 而非 `s`。 #### 3.3 常量 ```cpp #include using namespace std; #define PI 3.14 // 宏常量(预处理阶段替换) int main() { const double E = 2.71828; // const 常量(编译期检查) constexpr int MAX = 100; // 编译期常量(C++11) cout << "PI = " << PI << endl; cout << "E = " << E << endl; cout << "MAX = " << MAX << endl; return 0; } ``` ### 四、输入输出 #### 4.1 基本输入输出 ```cpp #include #include using namespace std; int main() { string name; int age; cout << "请输入你的姓名:"; cin >> name; // 从键盘读取字符串(遇空白停止) cout << "请输入你的年龄:"; cin >> age; cout << "你好," << name << "!你今年 " << age << " 岁。" << endl; return 0; } ``` #### 4.2 读取带空格的行 ```cpp #include #include using namespace std; int main() { string fullName; cout << "请输入全名(可含空格):"; getline(cin, fullName); // 读取一整行,包含空格 cout << "你好," << fullName << "!" << endl; return 0; } ``` ### 五、运算符初步 #### 5.1 算术运算符 ```cpp #include using namespace std; int main() { int a = 10, b = 3; cout << "a + b = " << (a + b) << endl; // 13 cout << "a - b = " << (a - b) << endl; // 7 cout << "a * b = " << (a * b) << endl; // 30 cout << "a / b = " << (a / b) << endl; // 3(整数除法,截断) cout << "a % b = " << (a % b) << endl; // 1(取余) cout << "a / 3.0 = " << (a / 3.0) << endl; // 3.33333(浮点除法) return 0; } ``` #### 5.2 类型转换 ```cpp #include using namespace std; int main() { int total = 7, count = 2; double avg1 = total / count; // 3(整数除法先执行) double avg2 = static_cast(total) / count; // 3.5(显式转换) double avg3 = total / 2.0; // 3.5(隐式提升) cout << avg1 << endl; cout << avg2 << endl; cout << avg3 << endl; return 0; } ``` ### 六、实战:简易加法计算器 综合本篇知识,编写一个支持两个数的加减乘除计算器: ```cpp #include using namespace std; int main() { double num1, num2; char op; cout << "===== 简易计算器 =====" << endl; cout << "请输入第一个数:"; cin >> num1; cout << "请输入运算符(+ - * /):"; cin >> op; cout << "请输入第二个数:"; cin >> num2; double result; bool valid = true; switch (op) { case '+': result = num1 + num2; break; case '-': result = num1 - num2; break; case '*': result = num1 * num2; break; case '/': if (num2 == 0) { cout << "错误:除数不能为 0!" << endl; valid = false; } else { result = num1 / num2; } break; default: cout << "错误:不支持的运算符!" << endl; valid = false; } if (valid) { cout << num1 << " " << op << " " << num2 << " = " << result << endl; } cout << "感谢使用,再见!" << endl; return 0; } ``` 运行示例: ``` ===== 简易计算器 ===== 请输入第一个数:10 请输入运算符(+ - * /):/ 请输入第二个数:4 10 / 4 = 2.5 感谢使用,再见! ``` ### 总结 本篇介绍了 C++ 的历史与版本演进、环境搭建、第一个程序的结构、变量与基本数据类型、常量、输入输出和运算符基础,并用简易计算器串联实战。重点要掌握:`main` 函数结构、变量声明与初始化、`cin`/`cout` 的使用、整数除法与类型转换的坑。 下一篇将深入讲解**运算符、表达式与控制流程**(if/switch/循环),敬请期待! --- ## Python综合实战:从零开发一个命令行工具 URL: https://wsj-blog.pages.dev/posts/python-zong-he-shi-zhan-cong-ling-kai-fa-yi-ge-ming-ling/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python综合实战:从零开发一个命令行工具](#Python_2) - - [一、项目目标](#_6) - [二、项目结构](#_15) - [三、核心逻辑(core.py)](#corepy_31) - [四、命令行入口(cli.py)](#clipy_73) - [五、支持 python -m 运行](#_python_m__123) - [六、打包安装(pyproject.toml)](#pyprojecttoml_133) - [七、编写测试](#_159) - [八、完整使用演示](#_184) - [系列完结](#_207) > 本文是 **Python 入门教程系列** 的收官实战篇(第 20 篇)。前 19 篇学习了语法、库、工程化等知识,本篇把它们串起来,从零开发一个**可发布的命令行工具**。 ### 一、项目目标 开发一个命令行工具 `fileman`,功能: - 统计目录下文件数量与大小 - 按扩展名分类整理文件 - 支持命令行参数 - 可安装到系统全局使用 ### 二、项目结构 ```text fileman/ ├── fileman/ │ ├── __init__.py │ ├── __main__.py # 支持 python -m fileman │ ├── cli.py # 命令行入口 │ └── core.py # 核心逻辑 ├── tests/ │ └── test_core.py ├── pyproject.toml ├── README.md └── .gitignore ``` ### 三、核心逻辑(core.py) ```python # fileman/core.py from pathlib import Path from collections import Counter def scan_directory(path): """扫描目录,返回文件统计信息""" root = Path(path) if not root.exists(): raise FileNotFoundError(f"目录不存在:{path}") files = [f for f in root.rglob("*") if f.is_file()] total_size = sum(f.stat().st_size for f in files) ext_counter = Counter(f.suffix.lower() or "(无扩展名)" for f in files) return { "total_files": len(files), "total_size": total_size, "top_extensions": ext_counter.most_common(5), } def organize_files(source, target): """按扩展名整理文件到子目录""" src = Path(source) dst = Path(target) moved = 0 for file in src.iterdir(): if not file.is_file(): continue ext = file.suffix.lstrip(".").lower() or "other" target_dir = dst / ext target_dir.mkdir(parents=True, exist_ok=True) new_path = target_dir / file.name file.rename(new_path) moved += 1 return moved ``` ### 四、命令行入口(cli.py) ```python # fileman/cli.py import argparse from .core import scan_directory, organize_files def format_size(size): """格式化文件大小""" for unit in ["B", "KB", "MB", "GB"]: if size < 1024: return f"{size:.1f}{unit}" size /= 1024 return f"{size:.1f}TB" def main(): parser = argparse.ArgumentParser( prog="fileman", description="文件管理命令行工具", ) sub = parser.add_subparsers(dest="command") # scan 子命令 p_scan = sub.add_parser("scan", help="扫描目录统计") p_scan.add_argument("path", help="要扫描的目录") # organize 子命令 p_org = sub.add_parser("organize", help="按类型整理文件") p_org.add_argument("source", help="源目录") p_org.add_argument("-o", "--output", default="organized", help="目标目录") args = parser.parse_args() if args.command == "scan": info = scan_directory(args.path) print(f"文件总数:{info["total_files"]}") print(f"总大小:{format_size(info["total_size"])}") print("扩展名统计:") for ext, count in info["top_extensions"]: print(f" {ext}: {count} 个") elif args.command == "organize": moved = organize_files(args.source, args.output) print(f"已整理 {moved} 个文件到 {args.output}/ 目录") else: parser.print_help() if __name__ == "__main__": main() ``` ### 五、支持 python -m 运行 ```python # fileman/__main__.py from .cli import main if __name__ == "__main__": main() ``` ### 六、打包安装(pyproject.toml) ```toml [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] name = "fileman" version = "0.1.0" description = "文件管理命令行工具" requires-python = ">=3.8" [project.scripts] fileman = "fileman.cli:main" ``` ```bash # 安装到系统(全局可用 fileman 命令) pip install -e . # 直接使用 fileman scan . fileman organize ~/Downloads ``` ### 七、编写测试 ```python # tests/test_core.py import pytest from fileman.core import organize_files import tempfile from pathlib import Path def test_organize_files(): with tempfile.TemporaryDirectory() as tmp: src = Path(tmp) / "src" dst = Path(tmp) / "dst" src.mkdir() (src / "a.txt").write_text("hello") (src / "b.jpg").write_bytes(b"123") moved = organize_files(str(src), str(dst)) assert moved == 2 assert (dst / "txt" / "a.txt").exists() assert (dst / "jpg" / "b.jpg").exists() ``` 运行:`pytest tests/ -v` ### 八、完整使用演示 ```bash # 1. 扫描目录 $ fileman scan ~/Downloads 文件总数:125 总大小:2.3GB 扩展名统计: .jpg: 45 个 .pdf: 30 个 .zip: 20 个 .docx: 15 个 (无扩展名): 15 个 # 2. 整理文件 $ fileman organize ~/Downloads -o ~/Organized 已整理 125 个文件到 ~/Organized/ 目录 # 3. 效果 $ ls ~/Organized jpg/ pdf/ zip/ docx/ other/ ``` ### 系列完结 至此,**Python 入门教程系列 20 篇**全部完成! | 阶段 | 篇目 | | --- | --- | | 入门 | 1-4:语法、OOP、文件、标准库 | | 进阶 | 5-10:网络、并发、数据库、数据分析、Web、调试 | | 深入 | 11-14:正则、异步、装饰器、测试 | | 工程 | 15-16:工程化、自动化办公 | | 扩展 | 17-19:机器学习、算法、Git | | 实战 | 20:命令行工具全流程 | 接下来的学习建议: 1. 找真实项目练手,把本系列技术用起来 2. 参与开源项目,阅读优秀代码 3. 持续输出学习笔记,巩固知识 4. 关注新技术方向(AI、大数据、云原生) 感谢你一路读到这里,祝你编程之路越走越远! --- ## Python进阶教程:Git版本控制与团队协作 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-git-ban-ben-kong-zhi-yu-tuan-dui/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:Git版本控制与团队协作](#PythonGit_2) - - [一、Git 是什么](#Git__6) - [二、基本配置与初始化](#_14) - [三、核心操作:add / commit](#add__commit_28) - [四、回退与撤销](#_44) - [五、分支管理](#_60) - [六、远程仓库协作](#_79) - [七、团队协作工作流](#_95) - - [7.1 常用工作流](#71__97) - [7.2 标准流程](#72__107) - [八、解决冲突](#_131) - [九、.gitignore 与规范提交](#gitignore__150) - [十、实战:发布第一个开源项目](#_172) - [总结](#_193) > 本文是 **Python 入门教程系列** 的第 19 篇(扩展篇)。Git 是程序员**必备技能**,本篇讲解 Git 的核心概念与常用操作,以及团队协作工作流。 ### 一、Git 是什么 Git 是一个**分布式版本控制系统**,能记录每次代码修改,支持回溯、分支和多人协作: - **版本回溯**:随时回到任意历史版本 - **分支管理**:并行开发互不影响 - **团队协作**:多人同时开发同一项目 ### 二、基本配置与初始化 ```bash # 全局配置(只需一次) git config --global user.name "你的名字" git config --global user.email "你的邮箱" # 初始化仓库 git init # 查看状态 git status ``` ### 三、核心操作:add / commit ```bash # 将文件加入暂存区 git add main.py git add . # 添加所有文件 # 提交到版本库 git commit -m "添加主程序" # 查看提交历史 git log --oneline # 2f4b148 添加主程序 # c6a96bf 初始化项目 ``` ### 四、回退与撤销 ```bash # 撤销暂存区的修改 git reset HEAD main.py # 丢弃工作区修改(危险!) git checkout -- main.py # 回退到上一个版本 git reset --hard HEAD~1 # 查看所有操作记录(找回误删) git reflog ``` ### 五、分支管理 ```bash # 查看分支 git branch # 创建并切换分支 git checkout -b feature-login # 切换分支 git checkout main # 合并分支(先切回目标分支) git merge feature-login # 删除分支 git branch -d feature-login ``` ### 六、远程仓库协作 ```bash # 关联远程仓库(GitHub / Gitee / GitLab) git remote add origin https://github.com/user/repo.git # 推送代码 git push -u origin main # 拉取代码 git pull origin main # 克隆项目 git clone https://github.com/user/repo.git ``` ### 七、团队协作工作流 #### 7.1 常用工作流 ```text main(稳定版) └── develop(开发版) ├── feature/登录功能 ├── feature/支付功能 └── fix/修复bug-123 ``` #### 7.2 标准流程 ```bash # 1. 拉取最新代码 git pull # 2. 创建功能分支 git checkout -b feature/new-feature # 3. 开发并提交 git add . git commit -m "feat: 新增功能" # 4. 拉取主干最新(避免冲突) git pull origin main # 5. 合并回主干 git checkout main git merge feature/new-feature # 6. 推送 git push origin main ``` ### 八、解决冲突 多人修改同一文件会产生冲突,需手动解决: ```text <<<<<<< HEAD 你写的代码 ======= 别人写的代码 >>>>>>> feature-branch ``` 处理方式:保留需要的部分,删掉 `<<<<<<<`、`=======`、`>>>>>>>` 标记,然后重新提交: ```bash git add 冲突文件.py git commit -m "解决冲突" ``` ### 九、.gitignore 与规范提交 ```text # .gitignore 忽略不该提交的文件 __pycache__/ *.pyc .env venv/ .idea/ .vscode/ ``` ```text # 提交信息规范(Conventional Commits) feat: 新功能 fix: 修复bug docs: 文档更新 refactor: 重构 test: 测试 chore: 杂项 ``` ### 十、实战:发布第一个开源项目 ```bash # 1. 初始化并提交 git init git add . git commit -m "feat: 初始版本" # 2. 在 GitHub 创建空仓库,然后关联 git remote add origin https://github.com/用户名/项目名.git git push -u origin main # 3. 打标签发版本 git tag v1.0.0 git push origin v1.0.0 # 4. 编写 README.md 说明项目 # 项目名 # 简介、安装方法、使用示例、许可证 ``` ### 总结 本篇介绍了 Git 的核心操作(add/commit/分支/合并/远程)、团队协作工作流、冲突解决和提交规范。熟练使用 Git 是专业开发的必备技能,建议在真实项目中多加练习。 --- ## Python进阶教程:算法与数据结构入门 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-suan-fa-yu-shu-ju-jie-gou-ru-men/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:算法与数据结构入门](#Python_2) - - [一、时间复杂度](#_6) - [二、常用数据结构](#_18) - - [2.1 列表与字典](#21__20) - [2.2 栈(Stack)](#22_Stack_34) - [2.3 队列(Queue)](#23_Queue_47) - [三、排序算法](#_59) - - [3.1 冒泡排序(O(n²))](#31_On_61) - [3.2 快速排序(O(n log n))](#32_On_log_n_75) - [四、查找算法](#_90) - [五、递归](#_111) - [六、动态规划入门](#_133) - [七、实战:实现 LRU 缓存](#_LRU__149) - [总结](#_182) > 本文是 **Python 入门教程系列** 的第 18 篇(扩展篇)。算法与数据结构是编程的**内功**,本篇介绍最核心的几种,用 Python 实现。 ### 一、时间复杂度 衡量算法效率用 **大 O 表示法**,描述执行时间随数据规模增长的速度: | 复杂度 | 含义 | 示例 | | --- | --- | --- | | O(1) | 常数时间 | 数组按下标访问 | | O(log n) | 对数时间 | 二分查找 | | O(n) | 线性时间 | 遍历列表 | | O(n log n) | 线性对数 | 快速排序 | | O(n²) | 平方时间 | 冒泡排序 | ### 二、常用数据结构 #### 2.1 列表与字典 ```python # 列表:有序、可重复 fruits = ["苹果", "香蕉", "橙子"] fruits.append("葡萄") print(fruits[0], len(fruits)) # 字典:键值对、查找 O(1) scores = {"张三": 90, "李四": 85} print(scores["张三"]) print(scores.get("王五", "不存在")) ``` #### 2.2 栈(Stack) ```python # 栈:后进先出(LIFO),用列表实现 stack = [] stack.append(1) # 入栈 stack.append(2) stack.append(3) print(stack.pop()) # 3 出栈 print(stack[-1]) # 2 查看栈顶 print(len(stack) == 0) # 判断是否为空 ``` #### 2.3 队列(Queue) ```python from collections import deque # 队列:先进先出(FIFO) queue = deque(["a", "b", "c"]) queue.append("d") # 入队 print(queue.popleft()) # a 出队 print(queue) # deque(["b", "c", "d"]) ``` ### 三、排序算法 #### 3.1 冒泡排序(O(n²)) ```python def bubble_sort(arr): n = len(arr) for i in range(n - 1): for j in range(n - 1 - i): if arr[j] > arr[j + 1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] return arr print(bubble_sort([5, 2, 8, 1, 9])) # [1, 2, 5, 8, 9] ``` #### 3.2 快速排序(O(n log n)) ```python def quick_sort(arr): if len(arr) <= 1: return arr pivot = arr[len(arr) // 2] left = [x for x in arr if x < pivot] mid = [x for x in arr if x == pivot] right = [x for x in arr if x > pivot] return quick_sort(left) + mid + quick_sort(right) print(quick_sort([5, 2, 8, 1, 9])) # [1, 2, 5, 8, 9] ``` ### 四、查找算法 ```python # 二分查找(要求有序,O(log n)) def binary_search(arr, target): left, right = 0, len(arr) - 1 while left <= right: mid = (left + right) // 2 if arr[mid] == target: return mid elif arr[mid] < target: left = mid + 1 else: right = mid - 1 return -1 nums = [1, 3, 5, 7, 9, 11] print(binary_search(nums, 7)) # 3 print(binary_search(nums, 8)) # -1 ``` ### 五、递归 ```python # 递归:函数调用自身 def factorial(n): if n <= 1: return 1 return n * factorial(n - 1) print(factorial(5)) # 120 # 斐波那契(带缓存避免重复计算) from functools import lru_cache @lru_cache(maxsize=None) def fib(n): if n < 2: return n return fib(n - 1) + fib(n - 2) print(fib(50)) # 12586269025 ``` ### 六、动态规划入门 ```python # 经典问题:爬楼梯(每次 1 或 2 阶) def climb_stairs(n): if n <= 2: return n dp = [0] * (n + 1) dp[1], dp[2] = 1, 2 for i in range(3, n + 1): dp[i] = dp[i - 1] + dp[i - 2] return dp[n] print(climb_stairs(10)) # 89 ``` ### 七、实战:实现 LRU 缓存 ```python from collections import OrderedDict class LRUCache: """最近最少使用缓存""" def __init__(self, capacity): self.cache = OrderedDict() self.capacity = capacity def get(self, key): if key not in self.cache: return -1 self.cache.move_to_end(key) # 标记为最近使用 return self.cache[key] def put(self, key, value): if key in self.cache: self.cache.move_to_end(key) self.cache[key] = value if len(self.cache) > self.capacity: self.cache.popitem(last=False) # 淘汰最久未用 cache = LRUCache(2) cache.put(1, "A") cache.put(2, "B") print(cache.get(1)) # A cache.put(3, "C") # 淘汰 key=2 print(cache.get(2)) # -1 print(cache.get(3)) # C ``` ### 总结 本篇介绍了时间复杂度、常用数据结构(栈、队列)、排序与查找算法、递归和动态规划入门,并用 LRU 缓存串联实战。刷题建议:从 LeetCode 简单题开始,每天 1-2 题,坚持就是胜利。 --- ## Python进阶教程:机器学习入门(Scikit-learn) URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-ji-qi-xue-xi-ru-men-scikit-learn/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:机器学习入门(Scikit-learn)](#PythonScikitlearn_2) - - [一、机器学习是什么](#_6) - [二、机器学习基本流程](#_16) - [三、第一个分类模型](#_24) - [四、线性回归](#_54) - [五、模型评估与交叉验证](#_77) - [六、K-Means 聚类](#KMeans__93) - [七、特征工程要点](#_118) - [八、实战:预测鲜花品种](#_139) - [总结](#_166) > 本文是 **Python 入门教程系列** 的第 17 篇(扩展篇)。前面第 8 篇介绍了数据分析,本篇进入**机器学习**入门,使用最流行的 Scikit-learn 库。 ### 一、机器学习是什么 机器学习让计算机**从数据中自动学习规律**并做出预测,主要分三类: - **监督学习**:有标注数据(分类、回归) - **无监督学习**:无标注数据(聚类、降维) - **强化学习**:通过奖励学习策略 安装:`pip install scikit-learn matplotlib` ### 二、机器学习基本流程 1. 准备数据(特征 + 标签) 2. 划分训练集和测试集 3. 训练模型 4. 评估模型 5. 预测新数据 ### 三、第一个分类模型 ```python from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import accuracy_score # 1. 加载鸢尾花数据集 iris = load_iris() X, y = iris.data, iris.target # 2. 划分训练集/测试集 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42 ) # 3. 训练模型 model = DecisionTreeClassifier(max_depth=3) model.fit(X_train, y_train) # 4. 评估 y_pred = model.predict(X_test) print(f"准确率:{accuracy_score(y_test, y_pred):.2f}") # 5. 预测新样本 new_sample = [[5.1, 3.5, 1.4, 0.2]] print(f"预测类别:{iris.target_names[model.predict(new_sample)[0]]}") ``` ### 四、线性回归 ```python from sklearn.linear_model import LinearRegression from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error import numpy as np # 模拟数据:房价与面积 np.random.seed(42) X = np.random.rand(200, 1) * 100 # 面积 0-100 y = 3 * X[:, 0] + 10 + np.random.randn(200) * 5 # 房价 X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2) model = LinearRegression() model.fit(X_train, y_train) print(f"斜率:{model.coef_[0]:.2f},截距:{model.intercept_:.2f}") y_pred = model.predict(X_test) print(f"均方误差:{mean_squared_error(y_test, y_pred):.2f}") ``` ### 五、模型评估与交叉验证 ```python from sklearn.datasets import load_iris from sklearn.model_selection import cross_val_score from sklearn.svm import SVC iris = load_iris() model = SVC(kernel='linear', C=1.0) # 5 折交叉验证 scores = cross_val_score(model, iris.data, iris.target, cv=5) print(f"每折准确率:{scores}") print(f"平均准确率:{scores.mean():.3f}") ``` ### 六、K-Means 聚类 ```python from sklearn.cluster import KMeans from sklearn.datasets import make_blobs import matplotlib.pyplot as plt # 生成聚类数据 X, _ = make_blobs(n_samples=300, centers=4, random_state=42) # K-Means 聚类 kmeans = KMeans(n_clusters=4, random_state=42) kmeans.fit(X) # 查看聚类中心 print(f"聚类中心: {kmeans.cluster_centers_}") print(f"样本所属簇:{kmeans.labels_[:10]}") # 可视化(可选) # plt.scatter(X[:, 0], X[:, 1], c=kmeans.labels_) # plt.scatter(kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1], marker="*", s=200, c="red") # plt.show() ``` ### 七、特征工程要点 好的特征比复杂模型更重要: ```python from sklearn.preprocessing import StandardScaler, OneHotEncoder import numpy as np # 特征缩放(对距离敏感的算法很重要) X = np.array([[1, 1000], [2, 2000], [3, 3000]]) scaler = StandardScaler() X_scaled = scaler.fit_transform(X) print(X_scaled) # 类别特征独热编码 from sklearn.preprocessing import LabelEncoder colors = ["红", "绿", "蓝", "绿", "红"] encoder = LabelEncoder() print(encoder.fit_transform(colors)) # [0 1 2 1 0] ``` ### 八、实战:预测鲜花品种 ```python from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report # 数据准备 iris = load_iris() X_train, X_test, y_train, y_test = train_test_split( iris.data, iris.target, test_size=0.3, random_state=42 ) # 随机森林(比单棵树更稳) model = RandomForestClassifier(n_estimators=100, random_state=42) model.fit(X_train, y_train) # 评估 y_pred = model.predict(X_test) print(classification_report(y_test, y_pred, target_names=iris.target_names)) # 特征重要性 for name, imp in zip(iris.feature_names, model.feature_importances_): print(f"{name}: {imp:.3f}") ``` ### 总结 本篇介绍了机器学习的基本流程、分类/回归/聚类三大经典任务、交叉验证和特征工程,并用鸢尾花数据集跑通了完整实战。机器学习重在实践,建议用 UCI 或 Kaggle 的数据集多做练习。 --- ## Python进阶教程:自动化办公实战 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-zi-dong-hua-ban-gong-shi-zhan/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:自动化办公实战](#Python_2) - - [一、办公自动化常用库](#_6) - [二、Excel 处理(openpyxl)](#Excel_openpyxl_19) - - [2.1 读取 Excel](#21__Excel_21) - [2.2 写入 Excel](#22__Excel_42) - [三、批量处理:合并多个 Excel](#_Excel_72) - [四、Word 文档处理](#Word__96) - [五、PDF 文本提取](#PDF__122) - [六、批量文件处理](#_138) - [七、实战:自动生成成绩单报告](#_156) - [总结](#_188) > 本文是 **Python 入门教程系列** 的第 16 篇(扩展篇)。Python 最接地气的应用场景之一就是**自动化办公**,本篇用实战演示如何用 Python 处理 Excel、Word、PDF 和批量文件。 ### 一、办公自动化常用库 | 库 | 用途 | | --- | --- | | openpyxl | 读写 Excel(.xlsx) | | pandas | 数据分析 + Excel 处理 | | python-docx | 操作 Word 文档 | | PyPDF2 / pdfplumber | 处理 PDF | | Pillow | 图像处理 | | pathlib / shutil | 文件与目录操作 | 安装:`pip install openpyxl python-docx pdfplumber pillow` ### 二、Excel 处理(openpyxl) #### 2.1 读取 Excel ```python import openpyxl # 打开工作簿 wb = openpyxl.load_workbook("sales.xlsx") ws = wb.active # 读取单元格 print(ws["A1"].value) print(ws.cell(row=2, column=1).value) # 遍历所有行 for row in ws.iter_rows(min_row=1, values_only=True): print(row) # 获取行列数 print(ws.max_row, ws.max_column) ``` #### 2.2 写入 Excel ```python import openpyxl wb = openpyxl.Workbook() ws = wb.active ws.title = "成绩表" # 写入表头 headers = ["姓名", "语文", "数学", "英语"] ws.append(headers) # 写入数据 students = [ ("张三", 90, 85, 92), ("李四", 78, 95, 88), ("王五", 85, 90, 86), ] for stu in students: ws.append(stu) from openpyxl.styles import Font ws["A1"].font = Font(bold=True) ws.column_dimensions["A"].width = 12 wb.save("output.xlsx") print("保存成功") ``` ### 三、批量处理:合并多个 Excel ```python import openpyxl import glob def merge_excels(pattern, output): """合并多个 Excel 文件""" wb_out = openpyxl.Workbook() ws_out = wb_out.active for file in glob.glob(pattern): wb = openpyxl.load_workbook(file) ws = wb.active print(f"处理:{file}") for row in ws.iter_rows(values_only=True): ws_out.append(row) wb_out.save(output) print(f"已合并到 {output}") # merge_excels("data/*.xlsx", "merged.xlsx") ``` ### 四、Word 文档处理 ```python from docx import Document # 创建文档 doc = Document() doc.add_heading("工作报告", level=1) doc.add_paragraph("这是第一段内容。") doc.add_paragraph("这是第二段内容。") # 添加表格 table = doc.add_table(rows=3, cols=2) table.cell(0, 0).text = "项目" table.cell(0, 1).text = "进度" table.cell(1, 0).text = "开发" table.cell(1, 1).text = "80%" doc.save("report.docx") # 读取文档 doc2 = Document("report.docx") for para in doc2.paragraphs: print(para.text) ``` ### 五、PDF 文本提取 ```python import pdfplumber with pdfplumber.open("document.pdf") as pdf: print(f"共 {len(pdf.pages)} 页") text = pdf.pages[0].extract_text() print(text[:200]) table = pdf.pages[0].extract_table() if table: for row in table: print(row) ``` ### 六、批量文件处理 ```python from pathlib import Path import shutil src = Path("downloads") dst = Path("organized") for file in src.iterdir(): if file.is_file(): ext = file.suffix.lstrip(".") or "other" target_dir = dst / ext target_dir.mkdir(parents=True, exist_ok=True) shutil.move(str(file), str(target_dir / file.name)) print(f"移动 {file.name} -> {ext}/" ) ``` ### 七、实战:自动生成成绩单报告 ```python import openpyxl from docx import Document def generate_report(excel_file, output_docx): """从 Excel 读取成绩,生成 Word 报告""" wb = openpyxl.load_workbook(excel_file) ws = wb.active doc = Document() doc.add_heading("学生成绩报告", level=1) total = 0 count = 0 for row in ws.iter_rows(min_row=2, values_only=True): name, chinese, math, english = row avg = (chinese + math + english) / 3 total += avg count += 1 doc.add_paragraph(f"{name}:平均分 {avg:.1f}") if count: doc.add_paragraph(f"全班平均分:{total/count:.1f}") doc.save(output_docx) print(f"报告已生成:{output_docx}") # generate_report("scores.xlsx", "report.docx") ``` ### 总结 本篇演示了用 openpyxl 处理 Excel、python-docx 处理 Word、pdfplumber 提取 PDF、以及批量文件整理,并实现了一个 Excel 到 Word 的自动化报告生成器。办公自动化的核心是**用代码替代重复劳动**,建议从自己日常最繁琐的任务开始实践。 --- ## Python进阶教程:项目工程化与虚拟环境 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-xiang-mu-gong-cheng-hua-yu-xu-ni/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:项目工程化与虚拟环境](#Python_2) - - [一、虚拟环境](#_6) - [二、依赖管理](#_24) - - [2.1 requirements.txt](#21_requirementstxt_26) - [2.2 依赖分组(生产/开发)](#22__36) - [三、项目目录结构](#_50) - [四、.gitignore 必备规则](#gitignore__72) - [五、包管理进阶:pip 与 pipenv](#pip__pipenv_102) - [六、使用第三方工具提升效率](#_118) - - [6.1 pyproject.toml 现代配置](#61_pyprojecttoml__120) - [6.2 一键脚本](#62__141) - [七、实战:搭建一个规范的项目](#_154) - - [项目初始化清单](#_179) - [总结](#_189) > 本文是 **Python 入门教程系列** 的第 15 篇(扩展篇)。前面学习了语法和库,本篇介绍如何把代码组织成**规范、可维护、可分享**的工程项目。 ### 一、虚拟环境 不同项目依赖的库版本可能冲突,虚拟环境让每个项目**独立隔离**依赖: ```bash # 创建虚拟环境(Python 3.3+ 内置) python -m venv myenv # 激活(Windows) myenv\Scripts\activate # 激活(macOS / Linux) source myenv/bin/activate # 退出 deactivate ``` ### 二、依赖管理 #### 2.1 requirements.txt ```bash # 导出当前环境依赖 pip freeze > requirements.txt # 在新环境安装 pip install -r requirements.txt ``` #### 2.2 依赖分组(生产/开发) ```text # requirements.txt(生产依赖) flask==3.0.0 requests==2.31.0 # requirements-dev.txt(开发依赖) -r requirements.txt pytest==8.0.0 black==24.1.0 flake8==7.0.0 ``` ### 三、项目目录结构 ```text my_project/ ├── README.md # 项目说明 ├── requirements.txt # 依赖清单 ├── .gitignore # Git 忽略规则 ├── setup.py / pyproject.toml # 打包配置 ├── src/ │ └── my_package/ │ ├── __init__.py │ ├── main.py │ ├── utils.py │ └── models.py ├── tests/ # 测试目录 │ ├── __init__.py │ ├── test_utils.py │ └── test_models.py ├── docs/ # 文档 └── scripts/ # 辅助脚本 ``` ### 四、.gitignore 必备规则 ```text # Python 缓存与构建 __pycache__/ *.py[cod] build/ dist/ *.egg-info/ # 虚拟环境 venv/ myenv/ .venv/ # 测试与覆盖率 .pytest_cache/ .coverage htmlcov/ # IDE 与系统 .vscode/ .idea/ .DS_Store # 敏感信息 .env *.env ``` ### 五、包管理进阶:pip 与 pipenv ```bash # 查看已安装包 pip list # 查看包信息 pip show requests # 更新包 pip install --upgrade requests # 卸载 pip uninstall requests ``` ### 六、使用第三方工具提升效率 #### 6.1 pyproject.toml 现代配置 ```toml [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] name = "my-project" version = "0.1.0" description = "一个示例项目" requires-python = ">=3.9" dependencies = [ "flask>=3.0", "requests>=2.31", ] [project.optional-dependencies] dev = ["pytest", "black", "flake8"] ``` #### 6.2 一键脚本 ```python # scripts/setup.sh / setup.bat # 一键创建虚拟环境并安装依赖 # Windows (setup.bat) python -m venv myenv call myenv\Scripts\activate.bat pip install -r requirements.txt echo 环境配置完成! ``` ### 七、实战:搭建一个规范的项目 ```text # 步骤 1:创建目录结构 project_root/ ├── requirements.txt ├── .gitignore └── src/ └── hello_cli/ ├── __init__.py └── main.py # 步骤 2:编写代码(src/hello_cli/main.py) def main(): print("Hello, Project!") if __name__ == "__main__": main() # 步骤 3:初始化 Git 并提交 git init git add . git commit -m "init project" ``` #### 项目初始化清单 - [x] 创建虚拟环境 - [x] 编写 requirements.txt - [x] 编写 .gitignore - [x] 建立 src / tests / docs 目录 - [x] 初始化 Git 仓库 - [x] 编写 README.md - [x] 首次提交代码 ### 总结 本篇介绍了虚拟环境、依赖管理、项目目录结构、.gitignore 规范等工程化基础。良好的工程习惯能让项目更易维护、协作和部署。下一篇将介绍**自动化办公**,用 Python 解放重复劳动。 --- ## Python进阶教程:单元测试与代码质量 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-dan-yuan-ce-shi-yu-dai-ma-zhi/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:单元测试与代码质量](#Python_2) - - [一、为什么需要测试](#_6) - [二、unittest 基础](#unittest__14) - [三、setUp 与 tearDown](#setUp__tearDown_45) - [四、pytest:更简洁的框架](#pytest_73) - [五、Mock:模拟外部依赖](#Mock_107) - [六、代码质量工具](#_129) - - [6.1 代码风格检查](#61__131) - [6.2 覆盖率统计](#62__147) - [七、实战:为工具类编写测试](#_159) - [总结](#_203) > 本文是 **Python 入门教程系列** 的第 14 篇(扩展篇)。上一篇讲解了装饰器与生成器,本篇介绍如何用**单元测试**保障代码质量。 ### 一、为什么需要测试 随着代码规模增长,手动验证越来越不可靠。自动化测试能: - 快速回归:改动代码后立即发现是否破坏原有功能 - 驱动设计:写测试时倒逼代码结构清晰 - 保障重构:放心地优化代码而不怕改坏 ### 二、unittest 基础 unittest 是 Python 内置测试框架: ```python import unittest def add(a, b): return a + b def divide(a, b): if b == 0: raise ValueError("除数不能为零") return a / b class TestMath(unittest.TestCase): def test_add(self): self.assertEqual(add(1, 2), 3) self.assertEqual(add(-1, 1), 0) def test_divide(self): self.assertEqual(divide(10, 2), 5) with self.assertRaises(ValueError): divide(10, 0) if __name__ == "__main__": unittest.main() ``` 运行:`python -m unittest test_math.py` ### 三、setUp 与 tearDown ```python import unittest class TestDatabase(unittest.TestCase): def setUp(self): """每个测试前执行""" self.data = {"name": "Alice", "age": 20} def tearDown(self): """每个测试后执行""" self.data.clear() def test_data(self): self.assertEqual(self.data["name"], "Alice") @classmethod def setUpClass(cls): """整个测试类执行前运行一次""" print("开始测试") @classmethod def tearDownClass(cls): """整个测试类执行后运行一次""" print("测试结束") ``` ### 四、pytest:更简洁的框架 pytest 语法更简洁,是社区主流(`pip install pytest`): ```python # test_calculator.py import pytest def add(a, b): return a + b def test_add_positive(): assert add(1, 2) == 3 def test_add_negative(): assert add(-1, -2) == -3 # 参数化测试 @pytest.mark.parametrize("a,b,expected", [ (1, 2, 3), (0, 0, 0), (-5, 5, 0), ]) def test_add_param(a, b, expected): assert add(a, b) == expected # 异常断言 def test_add_raises(): with pytest.raises(TypeError): add("1", 2) ``` 运行:`pytest test_calculator.py -v` ### 五、Mock:模拟外部依赖 测试中经常需要模拟网络、数据库等外部依赖: ```python from unittest.mock import Mock, patch def get_user_data(api): """从 API 获取用户数据(依赖外部服务)""" resp = api.get("/users/1") return resp.json()["name"] def test_get_user_data(): # 创建一个 Mock API mock_api = Mock() mock_api.get.return_value.json.return_value = {"name": "Alice"} result = get_user_data(mock_api) assert result == "Alice" mock_api.get.assert_called_with("/users/1") ``` ### 六、代码质量工具 #### 6.1 代码风格检查 ```bash # 安装 pip install flake8 black mypy # 检查代码风格 flake8 my_module.py # 自动格式化 black my_module.py # 类型检查 mypy my_module.py ``` #### 6.2 覆盖率统计 ```bash # 安装 pip install coverage # 统计测试覆盖率 coverage run -m pytest coverage report # 查看报告 coverage html # 生成 HTML 报告 ``` ### 七、实战:为工具类编写测试 ```python # calculator.py class Calculator: def add(self, a, b): return a + b def subtract(self, a, b): return a - b def multiply(self, a, b): return a * b def divide(self, a, b): if b == 0: raise ValueError("不能除以零") return a / b ``` ```python # test_calculator.py import pytest from calculator import Calculator @pytest.fixture def calc(): """测试夹具:为每个测试提供实例""" return Calculator() def test_add(calc): assert calc.add(2, 3) == 5 def test_subtract(calc): assert calc.subtract(10, 4) == 6 def test_divide(calc): assert calc.divide(8, 2) == 4 def test_divide_by_zero(calc): with pytest.raises(ValueError): calc.divide(1, 0) ``` ### 总结 本篇介绍了 unittest 与 pytest 两大测试框架、Mock 模拟外部依赖、以及 flake8/black/mypy/coverage 等代码质量工具,并为计算器类编写了完整测试。养成写测试的习惯,是成为专业开发者的重要一步。 --- ## Python进阶教程:装饰器与生成器深入 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-zhuang-shi-qi-yu-sheng-cheng-qi/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:装饰器与生成器深入](#Python_2) - - [一、函数是一等公民](#_6) - [二、装饰器基础](#_25) - [三、带参数的装饰器](#_51) - [四、常用内置装饰器](#_74) - [五、生成器基础](#_113) - [六、生成器表达式](#_130) - [七、生成器实战:无限序列](#_146) - [八、实战:缓存装饰器](#_162) - [总结](#_191) > 本文是 **Python 入门教程系列** 的第 13 篇(扩展篇)。装饰器和生成器是 Python 中非常优雅的两个特性,本篇深入讲解。 ### 一、函数是一等公民 在 Python 中,函数可以像变量一样传递和返回,这是装饰器的基础: ```python def greet(name): return f"你好,{name}" # 函数可以赋值给变量 say = greet print(say("张三")) # 你好,张三 # 函数可以作为参数传递 def call_twice(func, arg): return func(func(arg)) print(call_twice(greet, "张三")) # 你好,你好,张三 ``` ### 二、装饰器基础 装饰器可以在不修改原函数的情况下**增强函数功能**: ```python import time def timer(func): """统计函数执行时间的装饰器""" def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{func.__name__} 耗时 {elapsed:.4f}s") return result return wrapper @timer def slow_add(a, b): time.sleep(0.1) return a + b print(slow_add(1, 2)) # slow_add 耗时 0.1xxx s 3 ``` ### 三、带参数的装饰器 ```python import functools def repeat(times): """让函数重复执行的装饰器(带参数)""" def decorator(func): @functools.wraps(func) # 保留原函数信息 def wrapper(*args, **kwargs): for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(times=3) def say_hello(): print("Hello!") say_hello() # 打印 3 次 Hello! ``` ### 四、常用内置装饰器 ```python class MyClass: class_var = 0 def __init__(self, value): self._value = value @property def value(self): """只读属性""" return self._value @value.setter def value(self, new_val): """属性赋值检查""" if new_val < 0: raise ValueError("不能为负") self._value = new_val @classmethod def create(cls): """类方法""" return cls(0) @staticmethod def helper(): """静态方法""" return "工具函数" obj = MyClass(10) print(obj.value) # 10 obj.value = 20 # 调用 setter print(obj.value) # 20 print(MyClass.create().value) # 0 print(MyClass.helper()) # 工具函数 ``` ### 五、生成器基础 生成器用 `yield` 逐次产出值,**不一次性占用内存**: ```python def count_up_to(n): i = 1 while i <= n: yield i # 暂停并返回 i += 1 gen = count_up_to(5) print(next(gen)) # 1 print(next(gen)) # 2 print(list(gen)) # [3, 4, 5] 继续消费剩余 ``` ### 六、生成器表达式 ```python # 列表推导式 vs 生成器表达式 squares_list = [x*x for x in range(1000000)] # 立即占用内存 squares_gen = (x*x for x in range(1000000)) # 惰性求值 # 生成器可以传给 sum 等函数 print(sum(x*x for x in range(10))) # 285 # 内存对比 import sys print(sys.getsizeof([x for x in range(100000)])) # 约 800KB print(sys.getsizeof((x for x in range(100000)))) # 约 112B ``` ### 七、生成器实战:无限序列 ```python def fibonacci(): """无限斐波那契序列生成器""" a, b = 0, 1 while True: yield a a, b = b, a + b fib = fibonacci() # 取前 10 个 print([next(fib) for _ in range(10)]) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] ``` ### 八、实战:缓存装饰器 ```python import functools import time def memoize(func): """手写缓存装饰器""" cache = {} @functools.wraps(func) def wrapper(*args): if args not in cache: cache[args] = func(*args) return cache[args] return wrapper @memoize def expensive(n): print(f"计算 {n}...") time.sleep(1) return n * n start = time.perf_counter() print(expensive(5)) # 计算 5... 25 print(expensive(5)) # 直接命中缓存 25(不打印计算) print(f"耗时:{time.perf_counter()-start:.1f}s") # 约 1s ``` ### 总结 本篇深入讲解了装饰器(基础、带参数、内置装饰器)和生成器(基础、表达式、无限序列),并用手写缓存装饰器串联实战。理解这两个特性,能写出更简洁、更 Pythonic 的代码。 --- ## Python进阶教程:异步编程(asyncio) URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-yi-bu-bian-cheng-asyncio/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:异步编程(asyncio)](#Pythonasyncio_2) - - [一、什么是异步编程](#_6) - [二、协程基础](#_16) - [三、并发执行多个任务](#_30) - [四、asyncio.create_task](#asynciocreate_task_57) - [五、异步 HTTP 请求](#_HTTP__78) - [六、asyncio 与同步代码混合](#asyncio__105) - [七、超时与取消](#_126) - [八、实战:异步下载器](#_146) - [总结](#_170) > 本文是 **Python 入门教程系列** 的第 12 篇(扩展篇)。前面第 6 篇介绍了多线程与多进程,本篇讲解**异步编程**(asyncio),它是 I/O 密集任务最高效的并发方案。 ### 一、什么是异步编程 异步编程让程序在等待 I/O 时**不阻塞**,而是继续执行其他任务。与多线程相比: - 单线程内实现并发,**无锁竞争** - 上下文切换开销极小 - 适合大量 I/O 等待场景(网络请求、数据库) Python 3.7+ 使用 `async` / `await` 关键字。 ### 二、协程基础 ```python import asyncio async def hello(): print("开始执行") await asyncio.sleep(1) # 模拟异步等待 print("执行完成") # 运行协程 asyncio.run(hello()) ``` ### 三、并发执行多个任务 ```python import asyncio import time async def fetch(name, delay): print(f"{name} 开始请求...") await asyncio.sleep(delay) print(f"{name} 完成!") return f"{name}的数据" async def main(): start = time.perf_counter() # 并发执行多个协程 results = await asyncio.gather( fetch("请求A", 2), fetch("请求B", 1), fetch("请求C", 3), ) print(results) print(f"总耗时:{time.perf_counter() - start:.1f}s") # 约 3s 而非 6s asyncio.run(main()) ``` ### 四、asyncio.create_task ```python import asyncio async def worker(name, delay): await asyncio.sleep(delay) return f"{name} 完成" async def main(): # 创建任务(不立即 await,让它们并行) task1 = asyncio.create_task(worker("任务1", 2)) task2 = asyncio.create_task(worker("任务2", 1)) # 等待所有任务 results = await asyncio.gather(task1, task2) print(results) asyncio.run(main()) ``` ### 五、异步 HTTP 请求 配合 aiohttp 库实现真正的异步网络请求: ```python # pip install aiohttp import asyncio import aiohttp import time async def fetch_url(session, url): async with session.get(url) as resp: return await resp.text() async def main(): urls = ["https://httpbin.org/get"] * 5 async with aiohttp.ClientSession() as session: start = time.perf_counter() # 并发请求 responses = await asyncio.gather( *(fetch_url(session, url) for url in urls) ) print(f"完成 {len(responses)} 个请求,耗时 {time.perf_counter()-start:.2f}s") asyncio.run(main()) ``` ### 六、asyncio 与同步代码混合 ```python import asyncio import time def sync_blocking(): """模拟阻塞操作(如 CPU 计算、旧库调用)""" time.sleep(2) return "同步结果" async def main(): print("开始") # 用 run_in_executor 把阻塞操作丢到线程池 loop = asyncio.get_running_loop() result = await loop.run_in_executor(None, sync_blocking) print(result) asyncio.run(main()) ``` ### 七、超时与取消 ```python import asyncio async def slow_task(): await asyncio.sleep(10) return "终于完成了" async def main(): try: # 3 秒超时 result = await asyncio.wait_for(slow_task(), timeout=3) print(result) except asyncio.TimeoutError: print("任务超时,已取消") asyncio.run(main()) # 任务超时,已取消 ``` ### 八、实战:异步下载器 ```python import asyncio import aiohttp import time async def download(session, index): """模拟异步下载文件""" await asyncio.sleep(index * 0.3) return f"文件{index} 下载完成" async def main(): async with aiohttp.ClientSession() as session: start = time.perf_counter() tasks = [download(session, i) for i in range(1, 8)] results = await asyncio.gather(*tasks) for r in results: print(r) print(f"总耗时:{time.perf_counter()-start:.2f}s") asyncio.run(main()) ``` ### 总结 本篇介绍了 asyncio 的协程、任务并发、异步 HTTP、超时控制等核心概念,并提供了异步下载器实战。选择并发方案的建议:I/O 密集用 asyncio(最高效)或多线程,CPU 密集用多进程。 --- ## Python进阶教程:正则表达式进阶与文本处理 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-zheng-ze-biao-da-shi-jin-jie-yu/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:正则表达式进阶与文本处理](#Python_2) - - [一、正则基础回顾](#_6) - [二、分组与捕获](#_20) - [三、贪婪与非贪婪](#_42) - [四、常用高级技巧](#_58) - - [4.1 前后查找](#41__60) - [4.2 忽略大小写与多行](#42__76) - [4.3 替换与回调](#43__91) - [五、compile 预编译](#compile__108) - [六、实战:日志解析器](#_123) - [七、文本处理实用工具](#_151) - - [7.1 清理文本](#71__153) - [7.2 提取中文与数字](#72__170) - [总结](#_187) > 本文是 **Python 入门教程系列** 的第 11 篇(扩展篇)。前面第 4 篇介绍了 re 模块的基础用法,本篇深入讲解**正则表达式进阶**技巧与文本处理实战。 ### 一、正则基础回顾 ```python import re # 常用元字符 # . 任意字符 \d 数字 \w 单词字符 \s 空白 # * 零次或多次 + 一次或多次 ? 零次或一次 # {n} 恰好n次 {n,m} n到m次 ^ 开头 $ 结尾 # 基础匹配 print(re.findall(r"\d+", "订单123号,金额456元")) # ["123", "456"] ``` ### 二、分组与捕获 用括号分组,可以提取匹配的子部分: ```python import re text = "张三的电话是138-1234-5678" # 分组捕获 match = re.search(r"(\d{3})-(\d{4})-(\d{4})", text) if match: print(match.group(0)) # 138-1234-5678 整体 print(match.group(1)) # 138 print(match.group(2)) # 1234 print(match.group(3)) # 5678 # 命名分组 match = re.search(r"(?P\d{3})-(?P\d{4})-(?P\d{4})", text) print(match.group("area")) # 138 ``` ### 三、贪婪与非贪婪 ```python import re html = "
内容1
内容2
" # 贪婪模式:尽可能多匹配 greedy = re.findall(r"
.*
", html) print(greedy) # ["
内容1
内容2
"] # 非贪婪模式:加 ? lazy = re.findall(r"
.*?
", html) print(lazy) # ["
内容1
", "
内容2
"] ``` ### 四、常用高级技巧 #### 4.1 前后查找 ```python import re text = "价格:$100,折扣价:$80" # 正向断言 (?=...) 匹配后面跟着的内容 prices = re.findall(r"\d+(?=元)", "苹果5元,香蕉3元") print(prices) # ["5", "3"] # 反向断言 (?<=...) prices2 = re.findall(r"(?<=\$)\d+", text) print(prices2) # ["100", "80"] ``` #### 4.2 忽略大小写与多行 ```python import re # re.IGNORECASE 忽略大小写 print(re.findall(r"python", "Python PYTHON python", re.IGNORECASE)) # 3个 # re.MULTILINE ^ $ 匹配每行 text = "line1 line2 line3" print(re.findall(r"^line", text, re.MULTILINE)) # ["line", "line", "line"] ``` #### 4.3 替换与回调 ```python import re # 简单替换 print(re.sub(r"\d+", "#", "订单123金额456")) # 订单#金额# # 回调函数替换 def hide_phone(match): return match.group(0)[:3] + "****" + match.group(0)[-4:] text = "联系:13812345678 或 13987654321" print(re.sub(r"1\d{10}", hide_phone, text)) # 联系:138****5678 或 139****4321 ``` ### 五、compile 预编译 多次使用同一正则时,先编译成 Pattern 对象可提升性能: ```python import re # 预编译(推荐) phone_pattern = re.compile(r"1[3-9]\d{9}") text = "电话1:13812345678,电话2:15987654321" print(phone_pattern.findall(text)) # 两个号码 print(phone_pattern.search(text).group()) # 第一个号码 ``` ### 六、实战:日志解析器 ```python import re from collections import Counter log_text = """ 2026-08-14 10:00:01 ERROR 数据库连接失败 2026-08-14 10:00:05 INFO 用户登录成功 2026-08-14 10:00:10 ERROR 接口超时 2026-08-14 10:01:02 INFO 用户登出 2026-08-14 10:01:30 WARN 磁盘空间不足 """ def parse_log(text): """解析日志,统计各级别数量""" pattern = re.compile(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (ERROR|INFO|WARN) (.+)") levels = Counter() for line in text.strip().splitlines(): match = pattern.match(line) if match: levels[match.group(1)] += 1 return levels result = parse_log(log_text) print(dict(result)) # {"ERROR": 2, "INFO": 2, "WARN": 1} ``` ### 七、文本处理实用工具 #### 7.1 清理文本 ```python import re def clean_text(text): """清理文本:去HTML标签、多余空白、特殊符号""" text = re.sub(r"<[^>]+>", "", text) # 去HTML标签 text = re.sub(r"\s+", " ", text) # 合并空白 text = re.sub(r"[^\w\u4e00-\u9fa5,。!?、;:""' ]", "", text) # 去特殊符号 return text.strip() raw = "

你好, 世界!

@#$%^&*" print(clean_text(raw)) # 你好, 世界! ``` #### 7.2 提取中文与数字 ```python import re text = "华为Mate60 Pro售价6999元,支持5G网络" # 提取中文 print(re.findall(r"[\u4e00-\u9fa5]+", text)) # ["华为", "售价", "元", "支持", "网络"] # 提取数字 print(re.findall(r"\d+", text)) # ["60", "6999", "5"] # 提取英文单词 print(re.findall(r"[a-zA-Z]+", text)) # ["Mate", "Pro"] ``` ### 总结 本篇深入讲解了正则的分组捕获、贪婪匹配、前后断言、预编译等进阶技巧,并提供了日志解析和文本清理两个实战工具。正则表达式是文本处理的瑞士军刀,值得花时间熟练掌握。 --- ## Python进阶教程:调试技巧与性能优化 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-tiao-shi-ji-qiao-yu-xing-neng-you/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:调试技巧与性能优化](#Python_2) - - [一、print 调试与日志](#print__6) - [二、断言 assert](#_assert_27) - [三、pdb:交互式调试器](#pdb_39) - [四、性能分析](#_59) - - [4.1 时间测量](#41__61) - [4.2 cProfile 性能分析](#42_cProfile__72) - [五、常见性能优化技巧](#_87) - - [5.1 使用内置函数与库](#51__89) - [5.2 列表推导式](#52__109) - [5.3 缓存结果](#53__121) - [5.4 生成器省内存](#54__135) - [六、代码规范](#_146) - - [6.1 PEP 8 要点](#61_PEP_8__148) - [6.2 类型注解](#62__158) - [七、实战:优化一个慢函数](#_168) - [系列总结](#_204) > 本文是 **Python 入门教程系列** 的收官篇(第 10 篇)。前面九篇覆盖了从基础到 Web 开发的完整路径,本篇介绍**调试技巧**与**性能优化**,帮你写出更健壮、更快的代码。 ### 一、print 调试与日志 最朴素的调试方式就是打印,但正式项目应该用 **logging** 模块: ```python import logging # 配置日志 logging.basicConfig( level=logging.DEBUG, format="%(asctime)s %(levelname)s %(message)s", filename="app.log", # 输出到文件(可选) ) logging.debug("调试信息") logging.info("普通信息") logging.warning("警告信息") logging.error("错误信息") logging.critical("严重错误") ``` ### 二、断言 assert 断言用于检查程序状态,条件为假时立即报错: ```python def divide(a, b): assert b != 0, "除数不能为零" return a / b divide(10, 0) # AssertionError: 除数不能为零 ``` ### 三、pdb:交互式调试器 pdb 是 Python 内置调试器,可以在代码中设置断点: ```python import pdb def process(data): result = [] for item in data: pdb.set_trace() # 在此处暂停,进入调试模式 result.append(item * 2) return result # process([1, 2, 3]) # 调试器中常用命令: # n 下一步 | s 进入函数 | c 继续执行 # p 变量名 打印变量 | q 退出 ``` ### 四、性能分析 #### 4.1 时间测量 ```python import time start = time.perf_counter() total = sum(range(10**7)) elapsed = time.perf_counter() - start print(f"耗时:{elapsed:.3f}s") ``` #### 4.2 cProfile 性能分析 ```python import cProfile def slow_function(): total = 0 for i in range(10**6): total += i ** 2 return total # 分析函数耗时 cProfile.run("slow_function()") ``` ### 五、常见性能优化技巧 #### 5.1 使用内置函数与库 ```python import time data = list(range(10**6)) # 慢:Python 循环 start = time.perf_counter() total = 0 for x in data: total += x print(f"循环:{time.perf_counter() - start:.4f}s") # 快:内置 sum start = time.perf_counter() total = sum(data) print(f"内置 sum:{time.perf_counter() - start:.4f}s") ``` #### 5.2 列表推导式 ```python # 慢 squares = [] for i in range(1000): squares.append(i * i) # 快(且更简洁) squares = [i * i for i in range(1000)] ``` #### 5.3 缓存结果 ```python from functools import lru_cache @lru_cache(maxsize=128) def fibonacci(n): if n < 2: return n return fibonacci(n-1) + fibonacci(n-2) print(fibonacci(100)) # 有缓存后瞬间返回 ``` #### 5.4 生成器省内存 ```python # 列表:一次性全部加载到内存 nums_list = [i for i in range(10**7)] # 生成器:按需生成,几乎不占内存 nums_gen = (i for i in range(10**7)) print(sum(nums_gen)) # 仍然可以求和 ``` ### 六、代码规范 #### 6.1 PEP 8 要点 - 缩进用 **4 个空格**,不用 Tab - 变量名用小写 + 下划线(`my_var`) - 常量用大写(`MAX_SIZE`) - 类名用驼峰(`MyClass`) - 函数名用小写 + 下划线(`my_function`) - 每行不超过 79 个字符 - 代码前后留空行 #### 6.2 类型注解 ```python def add(a: int, b: int) -> int: """返回两个整数的和""" return a + b # 类型注解提高可读性,配合 mypy 可静态检查 ``` ### 七、实战:优化一个慢函数 ```python import time from functools import lru_cache # 原始版本(慢) def count_primes_below(n): primes = [] for num in range(2, n): if all(num % p != 0 for p in primes): primes.append(num) return len(primes) # 优化版本(快) @lru_cache(maxsize=None) def optimized_count(n): if n < 2: return 0 sieve = [True] * n sieve[0] = sieve[1] = False for i in range(2, int(n ** 0.5) + 1): if sieve[i]: for j in range(i*i, n, i): sieve[j] = False return sum(sieve) start = time.perf_counter() print(f"慢版本:{count_primes_below(20000)} 个素数") print(f"耗时:{time.perf_counter() - start:.2f}s") start = time.perf_counter() print(f"快版本:{optimized_count(20000)} 个素数") print(f"耗时:{time.perf_counter() - start:.2f}s") ``` ### 系列总结 恭喜你完成 **Python 入门教程系列** 的全部 10 篇文章! | 篇目 | 主题 | | --- | --- | | 1 | Python 入门:基础语法 | | 2 | 面向对象编程(OOP) | | 3 | 文件操作与异常处理 | | 4 | 常用标准库与实用技巧 | | 5 | 网络编程与数据抓取 | | 6 | 多线程与多进程 | | 7 | 数据库操作入门 | | 8 | 数据分析入门(NumPy 与 Pandas) | | 9 | Web 开发入门(Flask) | | 10 | 调试技巧与性能优化 | 学习建议: 1. **多写代码**:每篇的示例都亲手敲一遍 2. **多实践**:把实战项目扩展成自己的作品 3. **善用文档**:遇到问题先查 [Python 官方文档](https://docs.python.org/zh-cn/3/) 4. **坚持输出**:把学到的知识写成博客,教学相长 感谢阅读,祝你编程之路一帆风顺! --- ## Python进阶教程:Web开发入门(Flask) URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-web-kai-fa-ru-men-flask/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:Web开发入门(Flask)](#PythonWebFlask_2) - - [一、Flask 简介](#Flask__6) - [二、第一个 Flask 应用](#_Flask__16) - [三、路由与参数](#_31) - [四、模板渲染](#_57) - [五、处理表单数据](#_91) - [六、JSON API](#JSON_API_108) - [七、实战:待办清单 Web 应用](#_Web__134) - [总结](#_187) > 本文是 **Python 入门教程系列** 的第 9 篇。前面介绍了数据分析,本篇介绍用 **Flask** 框架快速搭建 Web 应用。 ### 一、Flask 简介 Flask 是 Python 最流行的轻量级 Web 框架: - **简单**:一个文件即可启动 Web 服务 - **灵活**:可以自由选择组件 - **生态好**:文档丰富,社区庞大 安装:`pip install flask` ### 二、第一个 Flask 应用 ```python from flask import Flask app = Flask(__name__) @app.route("/") def home(): return "

Hello, Flask!

" if __name__ == "__main__": app.run(debug=True) # 启动,访问 http://127.0.0.1:5000 ``` ### 三、路由与参数 ```python from flask import Flask app = Flask(__name__) # 普通路由 @app.route("/about") def about(): return "关于页面" # 动态参数 @app.route("/user/") def user(name): return f"

欢迎,{name}!

" # 指定请求方法 @app.route("/login", methods=["GET", "POST"]) def login(): return "登录页面" if __name__ == "__main__": app.run(debug=True) ``` ### 四、模板渲染 模板让你在 HTML 中嵌入 Python 变量和逻辑(Jinja2 模板引擎): ```python from flask import Flask, render_template app = Flask(__name__) @app.route("/") def home(): return render_template("index.html", title="首页", items=["Python", "Flask", "Web"]) if __name__ == "__main__": app.run(debug=True) ``` 配套模板文件 `templates/index.html`: ```html {{ title }}

{{ title }}

    {% for item in items %}
  • {{ item }}
  • {% endfor %}
``` ### 五、处理表单数据 ```python from flask import Flask, request app = Flask(__name__) @app.route("/submit", methods=["POST"]) def submit(): name = request.form.get("name") email = request.form.get("email") return f"收到:{name}({email})" if __name__ == "__main__": app.run(debug=True) ``` ### 六、JSON API 现代 Web 应用前后端分离,后端只需返回 JSON: ```python from flask import Flask, jsonify, request app = Flask(__name__) users = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] @app.route("/api/users", methods=["GET"]) def get_users(): return jsonify(users) @app.route("/api/users", methods=["POST"]) def add_user(): data = request.get_json() new_id = max(u["id"] for u in users) + 1 users.append({"id": new_id, "name": data.get("name")}) return jsonify(users[-1]), 201 if __name__ == "__main__": app.run(debug=True) ``` ### 七、实战:待办清单 Web 应用 综合运用所学知识,做一个简单的待办清单应用: ```python from flask import Flask, render_template, request, redirect, url_for app = Flask(__name__) todos = [] # 内存存储,重启会清空 @app.route("/") def index(): return render_template("todos.html", todos=todos) @app.route("/add", methods=["POST"]) def add(): text = request.form.get("text", "").strip() if text: todos.append({"id": len(todos) + 1, "text": text, "done": False}) return redirect(url_for("index")) @app.route("/done/") def done(todo_id): for t in todos: if t["id"] == todo_id: t["done"] = True return redirect(url_for("index")) if __name__ == "__main__": app.run(debug=True) ``` ```html

待办清单

``` ### 总结 本篇介绍了 Flask 的路由、模板、表单处理和 JSON API,并用待办清单应用串联实战。下一篇将介绍**调试与性能优化**,为系列收官,敬请期待! --- ## Python进阶教程:数据分析入门(NumPy与Pandas) URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-shu-ju-fen-xi-ru-men-numpy-yu/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:数据分析入门(NumPy与Pandas)](#PythonNumPyPandas_2) - - [一、数据分析生态](#_6) - [二、NumPy:数值计算](#NumPy_17) - - [2.1 创建数组](#21__19) - [2.2 数组运算](#22__39) - [三、Pandas:数据表处理](#Pandas_56) - - [3.1 Series 与 DataFrame](#31_Series__DataFrame_58) - [3.2 读取与查看数据](#32__79) - [3.3 数据清洗](#33__101) - [3.4 分组聚合](#34__121) - [四、实战:分析销售数据](#_142) - [总结](#_168) > 本文是 **Python 入门教程系列** 的第 8 篇。前面介绍了数据库操作,本篇介绍数据分析的两大核心库:**NumPy** 与 **Pandas**。 ### 一、数据分析生态 Python 数据分析生态: - **NumPy**:科学计算基础库,提供多维数组 - **Pandas**:数据分析利器,提供 DataFrame 数据结构 - **Matplotlib**:数据可视化 - **Scikit-learn**:机器学习 安装:`pip install numpy pandas matplotlib` ### 二、NumPy:数值计算 #### 2.1 创建数组 ```python import numpy as np # 从列表创建 arr1 = np.array([1, 2, 3, 4, 5]) print(arr1, arr1.shape) # [1 2 3 4 5] (5,) # 二维数组 arr2 = np.array([[1, 2], [3, 4]]) print(arr2.shape) # (2, 2) # 常用创建方式 zeros = np.zeros((2, 3)) # 全 0 ones = np.ones((2, 2)) # 全 1 rand = np.random.rand(3) # 随机数 range_arr = np.arange(0, 10, 2) # [0 2 4 6 8] ``` #### 2.2 数组运算 ```python import numpy as np a = np.array([1, 2, 3]) b = np.array([4, 5, 6]) print(a + b) # [5 7 9] 向量化运算,无需循环 print(a * 2) # [2 4 6] print(a ** 2) # [1 4 9] print(a.mean()) # 2.0 均值 print(a.sum()) # 6 求和 print(a.max()) # 3 最大值 print(np.dot(a, b)) # 32 点积 ``` ### 三、Pandas:数据表处理 #### 3.1 Series 与 DataFrame ```python import pandas as pd # Series:一维数据(带索引) s = pd.Series([10, 20, 30], index=["a", "b", "c"]) print(s["b"]) # 20 # DataFrame:二维表格 data = { "姓名": ["张三", "李四", "王五"], "年龄": [20, 22, 21], "成绩": [88.5, 92.0, 95.5], } df = pd.DataFrame(data) print(df) print(df.shape) # (3, 3) print(df.columns) # 列名 ``` #### 3.2 读取与查看数据 ```python import pandas as pd # 读取 CSV 文件 # df = pd.read_csv("data.csv") # 模拟数据 df = pd.DataFrame({ "name": ["Alice", "Bob", "Charlie", "David"], "age": [25, 30, 35, 40], "salary": [50000, 60000, 70000, 80000], }) print(df.head()) # 前 5 行 print(df.info()) # 数据概览 print(df.describe()) # 统计描述 print(df["age"].mean()) # 某列均值 32.5 print(df[df["salary"] > 60000]) # 条件筛选 ``` #### 3.3 数据清洗 ```python import pandas as pd import numpy as np df = pd.DataFrame({ "name": ["Alice", None, "Charlie"], "age": [25, None, 35], "score": [88, 92, None], }) print(df.isnull().sum()) # 查看缺失值 df = df.dropna() # 删除含缺失值的行 df = df.fillna(0) # 或用 0 填充缺失值 # 新增计算列 df["age_plus"] = df["age"] + 1 ``` #### 3.4 分组聚合 ```python import pandas as pd df = pd.DataFrame({ "部门": ["技术", "技术", "市场", "市场", "市场"], "员工": ["A", "B", "C", "D", "E"], "工资": [10000, 12000, 8000, 9000, 9500], }) # 按部门分组求平均工资 grouped = df.groupby("部门")["工资"].mean() print(grouped) # 市场 8833.33 # 技术 11000.00 # 多列聚合 print(df.groupby("部门").agg({"工资": ["mean", "max", "sum"]})) ``` ### 四、实战:分析销售数据 ```python import pandas as pd # 模拟一份销售数据 sales = pd.DataFrame({ "日期": pd.date_range("2026-01-01", periods=30), "商品": [f"商品{i%3+1}" for i in range(30)], "销量": [20 + i % 7 * 5 for i in range(30)], "金额": [100 + i * 3 for i in range(30)], }) print("=== 数据概览 ===") print(sales.describe()) print("=== 各商品总销量 ===") print(sales.groupby("商品")["销量"].sum()) print("=== 销量最高的 3 天 ===") print(sales.nlargest(3, "销量")[["日期", "销量"]]) print("=== 总销售额 ===") print(f"{sales["金额"].sum():.0f} 元") ``` ### 总结 本篇介绍了 NumPy 数组运算和 Pandas 的 Series、DataFrame、数据清洗、分组聚合,并用销售数据分析串联实战。下一篇将介绍**Web 开发入门**(Flask),敬请期待! --- ## Python进阶教程:数据库操作入门 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-shu-ju-ku-cao-zuo-ru-men/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:数据库操作入门](#Python_2) - - [一、什么是数据库](#_6) - [二、SQL 基础](#SQL__16) - [三、SQLite:内置数据库](#SQLite_40) - [四、参数化查询防注入](#_74) - [五、ORM 简介](#ORM__96) - [六、实战:学生成绩管理系统](#_132) - [总结](#_180) > 本文是 **Python 入门教程系列** 的第 7 篇。前面介绍了并发编程,本篇讲解 Python 中**数据库操作**,以 SQLite 和 MySQL 为例。 ### 一、什么是数据库 数据库(Database)是**有组织存储数据的仓库**,支持高效增删改查(CRUD)。常用关系型数据库: - **SQLite**:轻量级,单文件,适合学习和小型应用 - **MySQL / MariaDB**:最流行的开源数据库 - **PostgreSQL**:功能强大的开源数据库 关系型数据库用 **SQL(结构化查询语言)** 操作数据。 ### 二、SQL 基础 ```sql -- 建表 CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER, email TEXT UNIQUE ); -- 插入 INSERT INTO users (name, age, email) VALUES ("Alice", 20, "alice@example.com"); -- 查询 SELECT * FROM users WHERE age > 18; -- 更新 UPDATE users SET age = 21 WHERE name = "Alice"; -- 删除 DELETE FROM users WHERE id = 1; ``` ### 三、SQLite:内置数据库 Python 自带 sqlite3 模块,无需安装任何东西: ```python import sqlite3 # 连接(文件不存在会自动创建) conn = sqlite3.connect("demo.db") cursor = conn.cursor() # 建表 cursor.execute(""" CREATE TABLE IF NOT EXISTS students ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, score REAL ) """) # 插入数据 cursor.execute("INSERT INTO students (name, score) VALUES (?, ?)", ("张三", 88.5)) cursor.execute("INSERT INTO students (name, score) VALUES (?, ?)", ("李四", 92.0)) conn.commit() # 查询 cursor.execute("SELECT * FROM students") for row in cursor.fetchall(): print(row) # 关闭连接 conn.close() ``` ### 四、参数化查询防注入 **永远不要**用字符串拼接 SQL,应使用参数化查询防止 SQL 注入: ```python import sqlite3 conn = sqlite3.connect("demo.db") cursor = conn.cursor() # 错误示范(有注入风险) # name = input("输入姓名:") # cursor.execute(f"SELECT * FROM students WHERE name = '{name}'") # 正确做法:占位符 ? name = "张三" cursor.execute("SELECT * FROM students WHERE name = ?", (name,)) print(cursor.fetchall()) conn.close() ``` ### 五、ORM 简介 直接写 SQL 较繁琐,实际项目常用 **ORM(对象关系映射)**,如 SQLAlchemy: ```python # pip install sqlalchemy from sqlalchemy import create_engine, Column, Integer, String, Float from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() class Student(Base): __tablename__ = "students_orm" id = Column(Integer, primary_key=True) name = Column(String(50), nullable=False) score = Column(Float) # 连接数据库 engine = create_engine("sqlite:///orm_demo.db") Base.metadata.create_all(engine) # 增删改查 Session = sessionmaker(bind=engine) session = Session() student = Student(name="王五", score=95.5) session.add(student) session.commit() result = session.query(Student).filter(Student.score > 90).all() for s in result: print(s.name, s.score) session.close() ``` ### 六、实战:学生成绩管理系统 综合运用所学知识,实现一个简单的学生成绩管理: ```python import sqlite3 class ScoreManager: def __init__(self, db="scores.db"): self.conn = sqlite3.connect(db) self.cursor = self.conn.cursor() self.cursor.execute(""" CREATE TABLE IF NOT EXISTS students ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, score REAL ) """) self.conn.commit() def add(self, name, score): self.cursor.execute("INSERT INTO students (name, score) VALUES (?, ?)", (name, score)) self.conn.commit() print(f"添加成功:{name} {score}分") def list_all(self): self.cursor.execute("SELECT * FROM students") for row in self.cursor.fetchall(): print(row) def top(self, n=3): self.cursor.execute("SELECT * FROM students ORDER BY score DESC LIMIT ?", (n,)) for row in self.cursor.fetchall(): print(f"高分榜:{row}") def close(self): self.conn.close() # 使用示例 mgr = ScoreManager() mgr.add("张三", 88.5) mgr.add("李四", 92.0) mgr.add("王五", 95.5) mgr.list_all() mgr.top(2) mgr.close() ``` ### 总结 本篇介绍了 SQL 基础、SQLite 内置数据库、参数化查询防注入、ORM 概念,并用学生成绩管理系统串联实战。下一篇将介绍**数据分析入门**(NumPy 与 Pandas),敬请期待! --- ## Python进阶教程:多线程与多进程 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-duo-xian-cheng-yu-duo-jin-cheng/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:多线程与多进程](#Python_2) - - [一、为什么需要并发](#_6) - [二、多线程:threading](#threading_10) - - [2.1 创建线程的两种方式](#21__12) - [2.2 多线程加速 I/O 任务](#22__IO__39) - [三、线程安全与锁](#_64) - [四、线程池:ThreadPoolExecutor](#ThreadPoolExecutor_89) - [五、多进程:multiprocessing](#multiprocessing_111) - [六、进程池:ProcessPoolExecutor](#ProcessPoolExecutor_133) - [七、如何选择:线程还是进程?](#_146) - [八、实战:并发下载器](#_154) - [总结](#_180) > 本文是 **Python 入门教程系列** 的第 6 篇。前面介绍了网络编程与数据抓取,本篇讲解 Python 并发编程的核心:**多线程**与**多进程**。 ### 一、为什么需要并发 当程序有大量 I/O 操作(网络请求、文件读写)或计算密集任务时,串行执行会浪费大量等待时间。**并发编程**能让多个任务同时推进,大幅提升效率。 ### 二、多线程:threading #### 2.1 创建线程的两种方式 ```python import threading import time # 方式一:直接传入函数 def worker(name, seconds): time.sleep(seconds) print(f"{name} 完成") t1 = threading.Thread(target=worker, args=("任务1", 1)) t1.start() t1.join() # 等待线程结束 # 方式二:继承 Thread 类 class MyThread(threading.Thread): def run(self): for i in range(3): time.sleep(0.5) print(f"{self.name} 运行中:{i}") t2 = MyThread() t2.start() t2.join() ``` #### 2.2 多线程加速 I/O 任务 ```python import threading import time def download(name, delay): time.sleep(delay) # 模拟网络请求 print(f"{name} 下载完成,耗时{delay}s") tasks = [("文件A", 2), ("文件B", 1), ("文件C", 3)] threads = [] start = time.time() for name, delay in tasks: t = threading.Thread(target=download, args=(name, delay)) t.start() threads.append(t) for t in threads: t.join() print(f"总耗时:{time.time() - start:.1f}s") # 约 3s 而非 6s ``` ### 三、线程安全与锁 多个线程同时修改共享数据可能出错,需要加锁保护: ```python import threading counter = 0 lock = threading.Lock() def increment(n): global counter for _ in range(n): with lock: # 加锁保护临界区 counter += 1 threads = [threading.Thread(target=increment, args=(100000,)) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(counter) # 正确结果 1000000 ``` ### 四、线程池:ThreadPoolExecutor 手动管理线程较繁琐,推荐使用线程池: ```python from concurrent.futures import ThreadPoolExecutor import time def task(i): time.sleep(0.5) return i * i with ThreadPoolExecutor(max_workers=4) as pool: # 提交单个任务 future = pool.submit(task, 5) print(future.result()) # 25 # 批量提交 results = list(pool.map(task, range(6))) print(results) # [0, 1, 4, 9, 16, 25] ``` ### 五、多进程:multiprocessing Python 的 GIL(全局解释器锁)使多线程无法真正并行执行 CPU 密集型任务,此时应使用多进程: ```python from multiprocessing import Process import os def compute(name): print(f"进程 {name},PID={os.getpid()}") total = sum(range(10**7)) print(f"{name} 计算结果:{total}") if __name__ == "__main__": processes = [Process(target=compute, args=(f"P{i}",)) for i in range(4)] for p in processes: p.start() for p in processes: p.join() print("所有进程执行完毕") ``` ### 六、进程池:ProcessPoolExecutor ```python from concurrent.futures import ProcessPoolExecutor def heavy_calc(x): return sum(range(x)) with ProcessPoolExecutor(max_workers=4) as pool: results = list(pool.map(heavy_calc, [10**6] * 4)) print(results) ``` ### 七、如何选择:线程还是进程? | 场景 | 推荐 | 原因 | | --- | --- | --- | | 网络请求、文件读写等 I/O 密集 | 多线程 / 异步 | GIL 影响小,线程开销小 | | CPU 计算密集(如图像处理、算法) | 多进程 | 绕过 GIL,利用多核 CPU | | 任务数很多 | 线程池 / 进程池 | 复用资源,避免频繁创建销毁 | ### 八、实战:并发下载器 综合运用线程池,写一个模拟并发下载器: ```python from concurrent.futures import ThreadPoolExecutor import time import random def download_file(url): """模拟下载文件""" size = random.randint(1, 10) time.sleep(size * 0.1) return f"{url} 下载完成({size}MB)" urls = [f"https://example.com/file{i}.zip" for i in range(10)] start = time.time() with ThreadPoolExecutor(max_workers=5) as pool: results = list(pool.map(download_file, urls)) for r in results: print(r) print(f"全部下载完成,总耗时:{time.time() - start:.1f}s") ``` ### 总结 本篇介绍了多线程(threading、线程池)、多进程(multiprocessing、进程池)、锁机制以及线程/进程的选择策略,并用并发下载器串联实战。下一篇将介绍**数据库操作**,敬请期待! --- ## Python进阶教程:网络编程与数据抓取 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-wang-luo-bian-cheng-yu-shu-ju/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:网络编程与数据抓取](#Python_2) - - [一、HTTP 基础](#HTTP__6) - [二、urllib:标准库 HTTP 客户端](#urllib_HTTP__17) - [三、requests:更优雅的 HTTP 库](#requests_HTTP__37) - [四、HTML 解析:BeautifulSoup](#HTML_BeautifulSoup_64) - [五、实战:抓取网页文章标题](#_97) - [六、爬虫的注意事项](#_128) - [七、进阶:接口调用(API)](#API_149) - [总结](#_171) > 本文是 **Python 入门教程系列** 的第 5 篇。前面四篇介绍了基础语法、OOP、文件操作、常用标准库,本篇介绍**网络编程**与**数据抓取**(爬虫基础)。 ### 一、HTTP 基础 网络编程的核心是 HTTP 协议。HTTP 请求主要由四部分组成: - **方法**:GET(获取)、POST(提交)、PUT、DELETE 等 - **URL**:资源地址 - **请求头**:User-Agent、Cookie、Content-Type 等 - **请求体**:POST 时携带的数据 响应同样包含状态码(200 成功、404 不存在、500 服务器错误)、响应头和响应体。 ### 二、urllib:标准库 HTTP 客户端 ```python import urllib.request import urllib.parse # GET 请求 url = "https://httpbin.org/get" req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=10) as resp: print(resp.status) # 200 print(resp.read().decode("utf-8")[:200]) # POST 请求 data = urllib.parse.urlencode({"name": "Alice", "age": 20}).encode() req = urllib.request.Request("https://httpbin.org/post", data=data) with urllib.request.urlopen(req) as resp: print(resp.read().decode("utf-8")[:200]) ``` ### 三、requests:更优雅的 HTTP 库 requests 是第三方库(`pip install requests`),是实际开发中的首选: ```python import requests # GET 请求 resp = requests.get("https://httpbin.org/get", params={"q": "python"}, timeout=10) print(resp.status_code) # 200 print(resp.json()) # 自动解析 JSON # POST 请求 resp = requests.post("https://httpbin.org/post", json={"name": "Alice"}) print(resp.json()) # 自定义请求头(模拟浏览器) headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"} resp = requests.get("https://httpbin.org/headers", headers=headers) # 下载文件 resp = requests.get("https://httpbin.org/image/png", stream=True) with open("image.png", "wb") as f: for chunk in resp.iter_content(chunk_size=8192): f.write(chunk) ``` ### 四、HTML 解析:BeautifulSoup 抓取网页后需要解析 HTML,BeautifulSoup 是最常用的工具(`pip install beautifulsoup4`): ```python from bs4 import BeautifulSoup import requests html = """

Python 教程

第一篇 第二篇
""" soup = BeautifulSoup(html, "html.parser") # 获取标题 print(soup.h1.text) # Python 教程 # 按 class 查找 div = soup.find("div", class_="article") # 查找所有链接 for a in div.find_all("a"): print(a.text, a["href"]) # 第一篇 /p1 # 第二篇 /p2 ``` ### 五、实战:抓取网页文章标题 综合运用以上知识,写一个抓取网页所有链接和标题的小工具: ```python import requests from bs4 import BeautifulSoup def fetch_links(url): """抓取页面中所有链接及其文本""" try: headers = {"User-Agent": "Mozilla/5.0"} resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() # 非 200 会抛出异常 soup = BeautifulSoup(resp.text, "html.parser") links = [] for a in soup.find_all("a", href=True): text = a.text.strip() or "(无文本)" links.append((text[:30], a["href"])) return links except requests.RequestException as e: print(f"请求失败:{e}") return [] # 使用示例 url = "https://example.com" for text, href in fetch_links(url)[:10]: print(f"{text} -> {href}") ``` ### 六、爬虫的注意事项 合法、规范的爬虫需要注意: 1. **遵守 robots.txt**:访问站点前检查 `https://站点/robots.txt` 了解允许爬取的内容。 2. **控制请求频率**:用 time.sleep 间隔请求,避免给服务器造成压力。 3. **设置合理 UA**:识别为真实浏览器,但不要伪装成他人。 4. **尊重版权**:只抓取允许的数据,注意使用条款。 5. **反爬处理**:遇到验证码、登录墙时不要强行绕过。 ```python import time import requests urls = ["https://httpbin.org/get"] * 5 for url in urls: resp = requests.get(url, timeout=10) print(resp.status_code) time.sleep(2) # 每 2 秒请求一次,礼貌抓取 ``` ### 七、进阶:接口调用(API) 现代开发更多是调用 API 获取 JSON 数据,配合上篇的 json 库非常方便: ```python import requests import json def call_api(url, params=None): resp = requests.get(url, params=params, timeout=10) if resp.status_code == 200: return resp.json() else: print(f"API 返回错误:{resp.status_code}") return None # 调用公开 API 获取天气信息(示例) data = call_api("https://httpbin.org/json") if data: print(json.dumps(data, ensure_ascii=False, indent=2)) ``` ### 总结 本篇介绍了 HTTP 基础、urllib 与 requests 两种 HTTP 客户端、BeautifulSoup 网页解析、以及爬虫的规范与注意事项,并提供了两个实战工具。下一篇将介绍**多线程与多进程**,敬请期待! --- ## Python进阶教程:常用标准库与实用技巧 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-chang-yong-biao-zhun-ku-yu-shi/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:常用标准库与实用技巧](#Python_2) - - [一、标准库概览](#_6) - [二、os 与 sys:系统操作](#os__sys_23) - [三、datetime:日期时间](#datetime_47) - [四、json:数据交换](#json_65) - [五、re:正则表达式](#re_89) - [六、random 与 collections](#random__collections_111) - [七、itertools 与 functools](#itertools__functools_132) - [八、实战:数据统计小工具](#_161) - [总结](#_191) > 本文是 **Python 入门教程系列** 的第 4 篇。前三篇分别介绍了基础语法、面向对象编程、文件操作与异常处理。本篇介绍日常开发中最常用的**标准库**和实用技巧。 ### 一、标准库概览 Python 自带的**标准库**非常丰富,安装 Python 即可使用,无需额外安装。以下是常用标准库速查: | 库 | 用途 | | --- | --- | | os | 操作系统接口(文件、目录、路径) | | sys | 系统相关参数与函数 | | datetime | 日期时间处理 | | json | JSON 数据解析与生成 | | re | 正则表达式 | | random | 随机数 | | collections | 高级容器(Counter、defaultdict) | | itertools | 迭代器工具 | | functools | 函数式编程工具 | | urllib / requests | 网络请求 | ### 二、os 与 sys:系统操作 ```python import os import sys # 当前工作目录 print(os.getcwd()) # 拼接路径(跨平台) path = os.path.join("data", "config.json") print(path) # 遍历目录 for root, dirs, files in os.walk("."): for f in files: print(os.path.join(root, f)) # 系统信息 print(sys.version) # Python 版本 print(sys.platform) # 操作系统 print(sys.argv) # 命令行参数 ``` ### 三、datetime:日期时间 ```python from datetime import datetime, timedelta now = datetime.now() print(now) # 2026-08-14 10:30:00.123456 print(now.strftime("%Y-%m-%d %H:%M:%S")) # 格式化输出 # 日期运算 tomorrow = now + timedelta(days=1) print(tomorrow.strftime("%Y-%m-%d")) # 解析字符串为日期 d = datetime.strptime("2026-08-14", "%Y-%m-%d") print(d.weekday()) # 星期几(0=周一) ``` ### 四、json:数据交换 JSON 是前后端数据交换的标准格式,Python 处理起来非常方便: ```python import json # Python 对象 -> JSON 字符串 data = {"name": "Alice", "age": 20, "hobbies": ["reading", "coding"]} json_str = json.dumps(data, ensure_ascii=False, indent=2) print(json_str) # JSON 字符串 -> Python 对象 parsed = json.loads(json_str) print(parsed["name"]) # Alice # 读写 JSON 文件 with open("data.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) with open("data.json", "r", encoding="utf-8") as f: loaded = json.load(f) ``` ### 五、re:正则表达式 正则表达式用于字符串模式匹配,是文本处理利器: ```python import re text = "我的邮箱是 alice@example.com,备用邮箱 bob@test.org" # 查找所有邮箱 emails = re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", text) print(emails) # ['alice@example.com', 'bob@test.org'] # 匹配并替换 new_text = re.sub(r"\d+", "#", "订单号 12345 金额 99") print(new_text) # 订单号 # 金额 # # 判断是否匹配 if re.match(r"^\d{6}$", "123456"): print("是6位数字") ``` ### 六、random 与 collections ```python import random from collections import Counter, defaultdict # 随机数 print(random.randint(1, 100)) # 1~100 随机整数 print(random.choice(["a", "b", "c"])) # 随机选择 # 统计元素出现次数 words = ["apple", "banana", "apple", "orange", "apple"] counter = Counter(words) print(counter.most_common(2)) # [("apple", 3), ("banana", 1)] # 带默认值的字典 d = defaultdict(list) d["key"].append(1) # 不会报 KeyError print(d) # defaultdict(, {'key': [1]}) ``` ### 七、itertools 与 functools ```python from itertools import chain, product, groupby from functools import lru_cache, partial # 链式拼接 print(list(chain([1, 2], [3, 4]))) # [1, 2, 3, 4] # 笛卡尔积 print(list(product([1, 2], ["a", "b"]))) # [(1,'a'), (1,'b'), (2,'a'), (2,'b')] # 缓存函数结果(性能优化) @lru_cache(maxsize=128) def fib(n): if n < 2: return n return fib(n-1) + fib(n-2) print(fib(50)) # 瞬间返回 12586269025 # 偏函数(固定部分参数) def power(base, exp): return base ** exp square = partial(power, exp=2) print(square(5)) # 25 ``` ### 八、实战:数据统计小工具 综合运用标准库,写一个文本词频统计工具: ```python import re import os from collections import Counter def word_frequency(filepath, top_n=10): """统计文本文件中出现最多的前 N 个单词""" if not os.path.exists(filepath): print(f"文件不存在:{filepath}") return with open(filepath, "r", encoding="utf-8") as f: text = f.read().lower() # 用正则提取英文单词 words = re.findall(r"\b[a-z]+\b", text) counter = Counter(words) print(f"共 {len(words)} 个单词,前 {top_n} 个高频词:") for word, count in counter.most_common(top_n): print(f"{word}: {count} 次") # 使用示例 word_frequency("article.txt", top_n=5) ``` ### 总结 本篇介绍了 os、sys、datetime、json、re、random、collections、itertools、functools 等常用标准库,并通过词频统计工具串联实战。掌握标准库能显著提升开发效率。系列已更新 4 篇:基础语法 → 面向对象 → 文件与异常 → 标准库,后续可根据需要继续扩展(如网络编程、多线程、数据库操作等)。 --- ## Python进阶教程:文件操作与异常处理 URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-wen-jian-cao-zuo-yu-yi-chang-chu/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:文件操作与异常处理](#Python_2) - - [一、文件操作基础](#_6) - - [1.1 打开与关闭文件](#11__8) - [1.2 文件打开模式](#12__23) - [二、读取文件](#_33) - [三、写入文件](#_48) - [四、异常处理](#_64) - - [4.1 try / except](#41_try__except_66) - [4.2 try / except / else / finally](#42_try__except__else__finally_82) - [4.3 主动抛出异常 raise](#43__raise_97) - [五、常用文件操作](#_113) - [六、实战:日志记录器](#_134) - [总结](#_166) > 本文是 **Python 入门教程系列** 的第 3 篇。上一篇讲解了面向对象编程(OOP),本篇介绍开发中高频使用的**文件操作**与**异常处理**。 ### 一、文件操作基础 #### 1.1 打开与关闭文件 用内置函数 open() 打开文件: ```python # 方式一:手动关闭 f = open("data.txt", "r", encoding="utf-8") content = f.read() f.close() # 一定要关闭文件 # 方式二:with 语句(推荐,自动关闭) with open("data.txt", "r", encoding="utf-8") as f: content = f.read() ``` #### 1.2 文件打开模式 | 模式 | 说明 | | --- | --- | | r | 只读(默认) | | w | 写入,会清空原内容 | | a | 追加,在末尾添加 | | r+ | 读写 | | rb / wb | 二进制读写 | ### 二、读取文件 ```python with open("data.txt", "r", encoding="utf-8") as f: # 读取全部内容 content = f.read() # 读取一行 # line = f.readline() # 逐行读取(推荐大文件) # for line in f: # print(line.strip()) ``` ### 三、写入文件 ```python # 覆盖写入 with open("output.txt", "w", encoding="utf-8") as f: f.write("第一行 ") f.write("第二行 ") # 追加写入 with open("output.txt", "a", encoding="utf-8") as f: f.write("追加的内容 ") ``` ### 四、异常处理 #### 4.1 try / except 程序运行时可能出错,用 try/except 捕获异常,避免程序崩溃: ```python try: num = int(input("请输入数字:")) print(10 / num) except ValueError: print("输入的不是数字!") except ZeroDivisionError: print("不能除以零!") except Exception as e: print(f"发生错误:{e}") ``` #### 4.2 try / except / else / finally ```python try: f = open("config.txt", "r", encoding="utf-8") print(f.read()) except FileNotFoundError: print("文件不存在") else: print("读取成功,没有异常") finally: print("无论是否异常都会执行") f.close() ``` #### 4.3 主动抛出异常 raise ```python def check_age(age): if age < 0: raise ValueError("年龄不能为负数") if age < 18: raise Exception("未成年人禁止访问") print("欢迎访问") try: check_age(-5) except ValueError as e: print(e) # 年龄不能为负数 ``` ### 五、常用文件操作 ```python import os # 判断文件是否存在 print(os.path.exists("data.txt")) # True / False # 重命名文件 os.rename("old.txt", "new.txt") # 删除文件 os.remove("temp.txt") # 创建目录 os.makedirs("mydir", exist_ok=True) # 列出目录内容 print(os.listdir(".")) ``` ### 六、实战:日志记录器 综合运用文件操作与异常处理,实现一个简单的日志记录器: ```python import os import time def log(message): """将消息追加写入日志文件""" with open("app.log", "a", encoding="utf-8") as f: timestamp = time.strftime("%Y-%m-%d %H:%M:%S") f.write(f"[{timestamp}] {message} ") def divide(a, b): try: result = a / b log(f"计算 {a}/{b} = {result}") return result except ZeroDivisionError: log("错误:除数为零") print("除数为零,记录到日志") # 测试 divide(10, 2) # 正常记录 divide(10, 0) # 记录错误 print("查看 app.log 内容:") with open("app.log", "r", encoding="utf-8") as f: print(f.read()) ``` ### 总结 本篇讲解了文件读写、异常处理(try/except/else/finally/raise)和常用文件操作,并用日志记录器串联实战。下一篇将介绍**常用标准库与实用技巧**,敬请关注! --- ## Python进阶教程:面向对象编程(OOP) URL: https://wsj-blog.pages.dev/posts/python-jin-jie-jiao-cheng-mian-xiang-dui-xiang-bian-cheng/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python进阶教程:面向对象编程(OOP)](#PythonOOP_2) - - [一、什么是面向对象编程](#_6) - [二、类与对象](#_16) - - [2.1 定义类](#21__18) - [2.2 self 是什么](#22_self__39) - [三、封装](#_43) - [四、继承](#_73) - [五、多态](#_98) - [六、类属性与类方法](#_102) - [七、魔法方法](#_125) - [八、实战:图书管理系统](#_146) - [总结](#_188) > 本文是 **Python 入门教程系列** 的第 2 篇。上一篇介绍了 Python 的基础语法(变量、条件、循环、函数),本篇深入讲解**面向对象编程**。 ### 一、什么是面向对象编程 面向对象编程(Object-Oriented Programming,简称 OOP)是一种编程思想,它把数据和对数据的操作封装成**对象**。与面向过程相比,OOP 更适合大型项目: - **可复用**:类可以多次实例化 - **易维护**:代码结构清晰 - **可扩展**:通过继承轻松扩展功能 OOP 三大特性:**封装、继承、多态**。 ### 二、类与对象 #### 2.1 定义类 用 class 关键字定义类,用类创建对象: ```python class Dog: def __init__(self, name, age): self.name = name self.age = age def bark(self): print(f"{self.name} 汪汪叫!") def info(self): print(f"{self.name}今年{self.age}岁") dog1 = Dog("旺财", 3) dog1.bark() dog1.info() ``` #### 2.2 self 是什么 self 代表**当前实例**,所有实例方法第一个参数必须是 self,用于访问实例属性和其他方法。 ### 三、封装 封装将内部细节隐藏,通过方法对外提供接口。Python 用命名约定实现私有: ```python class BankAccount: def __init__(self, owner, balance=0): self.owner = owner self.__balance = balance def deposit(self, amount): if amount > 0: self.__balance += amount print(f"存入{amount}元,余额{self.__balance}元") def withdraw(self, amount): if 0 < amount <= self.__balance: self.__balance -= amount print(f"取出{amount}元,余额{self.__balance}元") else: print("余额不足!") def get_balance(self): return self.__balance acc = BankAccount("小明", 1000) acc.deposit(500) acc.withdraw(200) ``` ### 四、继承 子类继承父类的属性和方法,并可扩展或重写: ```python class Animal: def __init__(self, name): self.name = name def speak(self): print("动物叫") class Cat(Animal): def speak(self): print(f"{self.name} 喵喵叫!") class Dog(Animal): def speak(self): print(f"{self.name} 汪汪叫!") animals = [Cat("咪咪"), Dog("旺财")] for a in animals: a.speak() ``` ### 五、多态 多态指**同一操作作用于不同对象产生不同结果**。上面的例子中,speak() 对猫和狗有不同的表现,这就是多态。 ### 六、类属性与类方法 ```python class Student: school = "第一中学" def __init__(self, name): self.name = name @classmethod def get_school(cls): return cls.school @staticmethod def hello(): print("这是静态方法") s1 = Student("张三") s2 = Student("李四") print(s1.school, s2.school) print(Student.get_school()) ``` ### 七、魔法方法 Python 中以双下划线开头和结尾的方法是魔法方法,如 **init**、**str**、**len**: ```python class Book: def __init__(self, title, pages): self.title = title self.pages = pages def __str__(self): return f"《{self.title}》共{self.pages}页" def __len__(self): return self.pages b = Book("Python编程", 300) print(b) print(len(b)) ``` ### 八、实战:图书管理系统 综合运用所学知识,实现一个简单的图书管理: ```python class Book: def __init__(self, title, author): self.title = title self.author = author self.borrowed = False def __str__(self): status = "已借出" if self.borrowed else "可借" return f"《{self.title}》- {self.author} [{status}]" class Library: def __init__(self): self.books = [] def add_book(self, book): self.books.append(book) print(f"添加成功:{book}") def borrow(self, title): for book in self.books: if book.title == title and not book.borrowed: book.borrowed = True print(f"借出成功:{book}") return print(f"《{title}》不可借") def show_all(self): for book in self.books: print(book) lib = Library() lib.add_book(Book("Python入门", "张三")) lib.add_book(Book("算法导论", "李四")) lib.borrow("Python入门") lib.show_all() ``` ### 总结 本篇讲解了 OOP 的三大特性(封装、继承、多态)以及类属性、魔法方法等进阶知识,并用图书管理系统串起全部知识点。下一篇将介绍**文件操作与异常处理**,敬请关注! --- ## Python入门教程:从零开始学Python编程 URL: https://wsj-blog.pages.dev/posts/python-ru-men-jiao-cheng-cong-ling-kai-shi-xue-python-bian/ License: CC-BY-NC-SA-4.0 #### 目录 - [Python入门教程:从零开始学Python编程](#PythonPython_2) - - [一、Python是什么?](#Python_4) - [二、环境搭建](#_15) - - [2.1 安装 Python](#21__Python_17) - [2.2 安装 IDE](#22__IDE_26) - [三、第一个程序:Hello World](#Hello_World_30) - [四、基础语法](#_44) - - [4.1 变量与数据类型](#41__46) - [4.2 条件判断](#42__58) - [4.3 循环](#43__70) - [4.4 函数](#44__83) - [五、实战:猜数字小游戏](#_92) - [六、学习资源推荐](#_112) - [总结](#_120) ### 一、Python是什么? Python 是一种**简单易学、功能强大**的解释型高级编程语言,由 Guido van Rossum 于 1991 年发布。它广泛应用于: - Web 开发(Django、Flask) - 数据分析与人工智能(NumPy、Pandas、TensorFlow) - 自动化脚本与爬虫 - 运维与测试 Python 的语法简洁优雅,非常适合作为**第一门编程语言**学习。 ### 二、环境搭建 #### 2.1 安装 Python 访问 [python.org](https://www.python.org) 下载最新版本,安装时务必勾选 **Add Python to PATH**。安装完成后打开终端验证: ```bash python --version # 输出示例: Python 3.12.4 ``` #### 2.2 安装 IDE 推荐使用 **VS Code** 或 **PyCharm**,并安装 Python 扩展以获得代码补全与调试能力。 ### 三、第一个程序:Hello World 用任意文本编辑器创建 hello.py,输入以下代码: ```python print("Hello, World!") ``` 在终端运行: ```bash python hello.py ``` ### 四、基础语法 #### 4.1 变量与数据类型 Python 是动态类型语言,不需要声明变量类型: ```python name = "Alice" # 字符串 age = 20 # 整数 score = 88.5 # 浮点数 is_student = True # 布尔值 print(f"{name}今年{age}岁,成绩{score}分") ``` #### 4.2 条件判断 ```python age = 18 if age >= 18: print("成年了") elif age >= 12: print("青少年") else: print("未成年") ``` #### 4.3 循环 ```python # for 循环 for i in range(5): print(i) # while 循环 count = 0 while count < 3: print(count) count += 1 ``` #### 4.4 函数 ```python def add(a, b): return a + b result = add(3, 5) print(result) # 输出 8 ``` ### 五、实战:猜数字小游戏 综合运用所学知识,编写一个猜数字游戏: ```python import random answer = random.randint(1, 100) guess = None count = 0 print("欢迎来到猜数字游戏!范围 1-100") while guess != answer: guess = int(input("请输入你的猜测:")) count += 1 if guess > answer: print("太大了!") elif guess < answer: print("太小了!") print(f"恭喜你,猜了{count}次就猜对了!") ``` ### 六、学习资源推荐 | 资源 | 说明 | | --- | --- | | [Python 官方文档](https://docs.python.org/zh-cn/3/) | 最权威的语法参考 | | [菜鸟教程](https://www.runoob.com/python3/python3-tutorial.html) | 中文入门教程 | | [LeetCode](https://leetcode.cn) | 算法刷题平台 | ### 总结 本文介绍了 Python 的安装、基础语法和一个小实战项目。学习编程最重要的是**多敲代码**,建议每天坚持练习。后续我会继续更新 Python 进阶教程(面向对象、文件操作、数据处理等),欢迎关注! --- ## Ubuntu IBus 中文输入法终极优化指南:告别切换烦恼,提升输入效率 URL: https://wsj-blog.pages.dev/posts/ubuntu-ibus-zhong-wen-shu-ru-fa-zhong-ji-you-hua-zhi-nan/ License: CC-BY-NC-SA-4.0 ## Ubuntu 20.04/22.04 IBus 中文输入法切换优化指南 > 适用环境:Ubuntu 系统,使用 IBus 输入法框架(系统默认) --- ### 一、确认当前输入法框架 在终端执行以下命令: ```bash ibus version # 查看 IBus 版本 fcitx -v # 查看是否使用 Fcitx(若无输出则未安装) ``` - 若显示 `IBus x.x.x`,则使用 **IBus**(本文档针对此框架) - 若显示 `fcitx version x.x.x`,则使用 **Fcitx**(配置方法不同) --- ### 二、优化方案(按推荐程度排序) #### 方案一:修改切换快捷键(最常用) **操作步骤:** 1. 打开 IBus 设置界面: ```bash ibus-setup ``` 2. 在 **“常规”**(General)标签页中,修改以下选项: | 设置项 | 推荐值 | 说明 | | --- | --- | --- | | 切换引擎 | `Super + Space` | 不与其他软件冲突 | | 切换下一输入法 | `Ctrl + Shift` | 多输入法时使用 | 3. 点击 **“应用”** → **“确定”** 保存。 **立即生效:** ```bash ibus restart ``` --- #### 方案二:设置 Shift 键切换中英文(推荐给打字频繁的用户) 在 IBus 智能拼音中,可单独设置 Shift 键切换中/英状态: 1. 点击桌面右上角输入法图标 → **“首选项”**(Preferences) 2. 进入 **“拼音”** 选项卡 3. 找到 **“中英文切换”** 选项,设置为 **“Shift”** 4. 关闭窗口即可生效 --- #### 方案三:减少输入法数量,加快切换速度 1. 打开 `ibus-setup`,进入 **“输入法”**(Input Method)标签页 2. 只保留: - `中文 - 智能拼音`(Intelligent Pinyin) - `英语 - 美国`(English - US) 3. 删除其他不需要的输入法(如日语、韩语等) **效果:** 切换时只在中/英之间循环,无多余干扰。 --- #### 方案四:使用 Ctrl+Space(经典方案) 如需恢复 Windows 经典习惯: 1. 先解除系统对 `Ctrl+Space` 的占用: ```bash gsettings set org.gnome.desktop.wm.keybindings switch-input-source "[]" gsettings set org.gnome.desktop.wm.keybindings switch-input-source-backward "[]" ``` 2. 打开 `ibus-setup` → **“常规”** → 将切换引擎设为 `Ctrl + Space` 3. 重启 IBus: ```bash ibus restart ``` --- ### 三、常用命令速查 | 目的 | 命令 | | --- | --- | | 打开 IBus 设置 | `ibus-setup` | | 重启 IBus | `ibus restart` | | 重置 IBus 配置 | `ibus reset-config` | | 查看 IBus 版本 | `ibus version` | | 查看系统快捷键(排查冲突) | `gsettings list-recursively | grep -i space` | --- ### 四、常见问题排查 #### Q1:快捷键突然失效或无法切换 **解决:** 重启 IBus ```bash ibus restart ``` #### Q2:某个应用内无法输入中文 **解决:** 右键点击应用窗口标题栏 → **“输入法”** → 选择 **“跟随系统”** 或 **“使用系统默认”** #### Q3:切换顺序混乱 **解决:** `ibus-setup` → **“输入法”** 标签页 → 拖拽列表调整输入法顺序 #### Q4:想改用搜狗输入法(需 Fcitx) **解决:** 需先卸载 IBus 并安装 Fcitx,配置方法不同,需单独处理。 --- ### 五、推荐配置组合 | 使用场景 | 推荐快捷键 | 配置方法 | | --- | --- | --- | | 类似 Windows 习惯 | `Shift` 切换中英文 | 方案二 | | 类似 Mac 习惯 | `Super + Space` | 方案一 | | 多输入法切换 | `Ctrl + Shift` 循环 | 方案一 + 方案三 | | 经典 Ubuntu 习惯 | `Ctrl + Space` | 方案四 | --- ### 六、快速一键配置(Super+Space) 如果您想快速尝试最常用的配置(`Super + Space` 切换),可直接执行: ```bash # 设置 Super+Space 为切换快捷键 gsettings set org.gnome.desktop.wm.keybindings switch-input-source "['space']" gsettings set org.gnome.desktop.wm.keybindings switch-input-source-backward "['space']" # 重启 IBus ibus restart ``` --- **文档版本:** 1.0 **最后更新:** 2026-07-19 **适用系统:** Ubuntu 20.04 / 22.04(GNOME 桌面) --- ## Orange Pi USB Device 模式设置指南 URL: https://wsj-blog.pages.dev/posts/orange-pi-usb-device-mo-shi-she-zhi-zhi-nan/ License: CC-BY-NC-SA-4.0 ### 背景 在 Orange Pi 开发板上使用 Payload-SDK 时,需要将 USB 接口设置为 device 模式。 ### 设置方法 #### 方法1: 使用 set_device.sh 脚本(推荐) ```bash sudo /usr/local/bin/set_device.sh ``` 脚本内容: ```bash #!/bin/bash sudo bash -c "echo device > /sys/kernel/debug/usb/fc000000.usb/mode" sudo systemctl restart usbdevice ``` #### 方法2: 手动设置 ```bash sudo bash -c "echo device > /sys/kernel/debug/usb/fc000000.usb/mode" sudo systemctl restart usbdevice ``` ### 验证 ```bash sudo cat /sys/kernel/debug/usb/fc000000.usb/mode # 输出应为: device ``` ### 注意事项 - `/sys/kernel/debug/usb/` 路径需要使用 `sudo` 才能访问,普通用户无法查看 - USB 控制器地址为 `fc000000.usb` --- ## 数据精度和运算速度的中庸之道 URL: https://wsj-blog.pages.dev/posts/shu-ju-jing-du-he-yun-suan-su-du-de-zhong-yong-zhi-dao/ License: CC-BY-NC-SA-4.0 ## 问题提出 最近在写步进电机的S型加速算法,遇到了这个问题,由于要计算s型曲线,使用exp函数一次全部计算完大概需要1.2s,单片机的实时性得不到保证,每次在pwm中断回调中计算也是耗费算力。而且我对于函数的封装也是封装了很多层,各种数据类型之间相互转换,精度下降很多。 ## 问题解决 #### 计算速度慢的问题 我发现将double换为float,可以使速度提高10倍以上,因此使用expf,cosf函数将会大大节省运算时间。 #### 数据精度差的问题 在运算是,我很多变量是使用的是u32或者int类型,在进行除法时导致了精度的损失,把这些数据都换为float型,精度提高了很多。另外需要注意的是float的数据存放方式和int不同,float的范围也远远大于int,如果出现(float)a==(int)b这种判断语句,请一定要考虑到float的精度损失,在这个时候a很可能不等于b --- ## freertos vTaskDelay卡死 URL: https://wsj-blog.pages.dev/posts/freertos-vtaskdelay-ka-si/ License: CC-BY-NC-SA-4.0 在开始任务中使用动态方法创建任务后,在任务中调用vTaskDelay卡死,然后排查问题,定时器工作正常,如果不使用vTaskDelay也可以一直运行。       解决办法,改成在main方法中创建任务,调用vTaskDelay工作正常,我用的是nuvoton的库,所以可能与sdk移植有关系,反正问题就是这么解决的,希望能帮到同路者。 --- ## stm32高级定时器和基本定时器的踩坑指南 URL: https://wsj-blog.pages.dev/posts/stm32-gao-ji-ding-shi-qi-he-ji-ben-ding-shi-qi-de-cai-keng/ License: CC-BY-NC-SA-4.0 高级定理器tim1和tim8的中断回调函数和通用定时器不相同。 基本定时器中只有计数,没有分频。 --- ## 深度计解算板的报错的问题解决(MS5437转串口模块) URL: https://wsj-blog.pages.dev/posts/shen-du-ji-jie-suan-ban-de-bao-cuo-de-wen-ti-jie-jue-ms5437/ License: CC-BY-NC-SA-4.0 做水下机器人控制中用到了深度解算板,写了相应的驱动程序,也遇到了一些问题,现把一些问题的解决方案放出来,供大家参考。 ![745c8504aee84fd4ba5baeceb71a9535.png](https://i-blog.csdnimg.cn/blog_migrate/d201ba404dd5e59172794b813c851fbe.png) 这个模块本身的协议比较简单,所以模块的驱动也很小巧。 驱动被设计为拥有指令器和接收机两个部分,完全被动方式运行,与具体的平台解耦,只支持串口通讯。 ### 使用中发现主要有两个问题: 1.在水下密闭环境中,总是出现errors。 2电源灯亮度很拉胯,一度让我怀疑接触不良。 ### 解决过程: 通过逆向工程 1.发现电源灯是对稳压芯片的稳压电路,使用了200欧姆的电阻进行限流。本来应该没啥问题的,我怀疑led比较劣质,不能正常使用,于是更换了一个0603封装的,能正常使用。(同时,笔者个人觉得这个灯的限流电阻选择的不太好,影响使用寿命,建议更换1k的电阻) 2.笔者发现图中R8,R7两个电阻是iic通信的上拉电阻,使用10K的电阻进行上拉会严重影响信号质量,笔者更换为4.7K的电阻,问题已经解决。 ### 总结: 笔者分析,是由于水下环境的密闭性,导致一些通信的无线信号影响了iic的通信(不确定) --- ## Espressif-IDE中文汉化教程全网首发 URL: https://wsj-blog.pages.dev/posts/espressif-ide-zhong-wen-han-hua-jiao-cheng-quan-wang-shou-fa/ License: CC-BY-NC-SA-4.0 ### 改为中文后启动失败问题的解决 IDE语言选择Chinese之后,正常情况下,会出现退出后不能够正常运行的情况。如下所示 ![4bb8db96bab74e2d96c2ba6d616ffae6.png](https://i-blog.csdnimg.cn/blog_migrate/a1f0725c77bcec38cc8389d46a568e23.png) 这个则应该是缺少合适的java环境,需要安装有java11或者以上的环境,可以通过以下链接安装java17,[Java Downloads | Oracle![](https://i-blog.csdnimg.cn/blog_migrate/039a84e0f347bb45be0e5dcf71ea800a.png)https://www.oracle.com/java/technologies/downloads/](https://www.oracle.com/java/technologies/downloads/ "Java Downloads | Oracle") 笔者安装的是java8所以不行,但是安装好java11后还是有报错信息。这里可以尝试重新安装 乐鑫IDE(这一步应该是必须的,不清楚的原因是,这些步骤是github的管理员对笔者安装的帮助,笔者是按照这些步骤进行的。) 但是依然会有报错信息,如下所示。 ![44abd86d6a9b4ac8a7707c94534528c5.png](https://i-blog.csdnimg.cn/blog_migrate/36747f38230a6f791f36520f727300ad.png) ![e5529dea71cc42d4aadb2e1d112b7964.png](https://i-blog.csdnimg.cn/blog_migrate/af4a9e6c9926e093257a5d1e6a0327f2.png) 这个时候只需更改ini文件即可。我的路径如下 D:\Espressif\tools\espressif-ide\2.7.0\espressif-ide.ini 采用记事本编辑,更改为以下内容 ```cpp -startup plugins/org.eclipse.equinox.launcher_1.6.400.v20210924-0641.jar --launcher.library plugins/org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.600.v20220720-1916 -data @noDefault -vm C:\Program Files\Java\jdk-11.0.9\bin\server\jvm.dll -vmargs -Xms256m -Xmx2048m -Dosgi.requiredJavaVersion=11 -Dosgi.instance.area.default=@user.home/espressif-workspace ``` 额外注意的是**C:\Program Files\Java\jdk-11.0.9\bin\server\jvm.dll** 是笔者的Java相关路径,你应该改为你自己的。 改完之后你就会发现可以正常打开IDE了,但是汉化不彻底,如下图所示。![54f0fd5036394446ac545d919bd88130.png](https://i-blog.csdnimg.cn/blog_migrate/9012104da3817af042b54408eebf2323.png) ### 上面的是解决启动的bug,下面则是最核心的汉化步骤了。 **操作步骤** **打开IDE 界面 选择菜单 【Help】→【Install new software…】** **添加语言包地址 : https://download.eclipse.org/technology/babel/update-site/latest/** **选择 语言包 中文简体 : 【……in Chinese(Simplified)】**这一步骤要注意,选择语言包的时候最好点开里面取消.rt.rap语言包的选定,不然欢迎界面会报错(这一步则参考了某个网友的方案)。 **一直 【Next】 ,然后 选择全部,最后【Finish】** 最终界面如下图,如果有其他问题可以评论或者通过GitHub账户OPEN-F联系。 ![711339f11cbd42a8adfa6ff4bba5b375.png](https://i-blog.csdnimg.cn/blog_migrate/848dbd42a792ca7488b6e7ebcaf0d92d.png) ![e4c6e050a6194b79881f1c0366906bcc.png](https://i-blog.csdnimg.cn/blog_migrate/250f7e5e37194683c239c16f666cc039.png) ![d5f5a75fcab544e993b31f5510594f42.png](https://i-blog.csdnimg.cn/blog_migrate/63decb6d4b1b0bf5b07daaef7ae4e2e7.png) --- ## Win32DiskImage写入123报错 URL: https://wsj-blog.pages.dev/posts/win32diskimage-xie-ru-123-bao-cuo/ License: CC-BY-NC-SA-4.0 今天需要将SD卡格式化为FAT32,然后向其中写一个img文件。利用读卡器将SD卡连接在主机上,然后使用win32disimager软件向指定盘符写时报错123 **原因** Win32DiskImage不支持中文路径 **解决方法** 将写入的路径改为英文 成功!!!!! ![](https://i-blog.csdnimg.cn/blog_migrate/71a61cb9e73499dbe1b4abc1d33f2537.png) --- ## 这个东西让我的Python代码快了100倍 URL: https://wsj-blog.pages.dev/posts/zhe-ge-dong-xi-rang-wo-de-python-dai-ma-kuai-le-100-bei/ License: CC-BY-NC-SA-4.0 转载于Python数据挖掘。 Python 已经得到了全球程序员的喜爱,连续多期稳坐编程语言排行榜第一把交椅。但是还是遭到一些人的诟病,原因之一就是认为它运行缓慢。 要是有一款能够自动优化我们代码的神器该有多好啊! 于是,大家都在想尽各种办法来提高 Python 代码的运行速度,大多数体现在写代码的习惯优化以及代码优化上。但是平时写代码注意太多这些方面可能会有糟糕的体验,甚至会不利于我们的工作效率。 今天就给大家带来这样的一款神器——taichi,**喜欢记得收藏、点赞。** #### 文章目录 - - [taichi](https://blog.csdn.net/#taichi_8 "taichi") - [安装](https://blog.csdn.net/#_22 "安装") - [一个小例子](https://blog.csdn.net/#_39 "一个小例子") - [总结](https://blog.csdn.net/#_276 "总结") ### taichi Taichi 起步于 MIT 的计算机科学与人工智能实验室(CSAIL),设计初衷是便利计算机图形学研究人员的日常工作,帮助他们快速实现适用于 GPU 的视觉计算和物理模拟算法。 说人话就是 Taichi 是一个基于 Python 的领域特定语言,专为高性能能并行计算设计。 本来是服务于学术界的一款 DSL ,但是我们也可以拿来用在我们这些凡夫俗子的代码中(虽然有点大材小用)! ### 安装 Taichi 是一个 PyPI 包,所以使用 pip 命令即可安装: `pip install taichi` 注意 taichi 安装的先决条件是: - Python: 3.7/3.8/3.9/3.10 (64-bit) - OS: Windows, OS X, and Linux (64-bit) 在使用命令安装的时候,如果遇到错误,可以使用管理员模式命令行进行安装。 ### 一个小例子 我们先来用一个小栗子,感受一下它的鬼斧神工! import time def is_prime(n):   result = True   for k in range(2, int(n**0.5) + 1):       if n % k == 0:           result = False           break   return result def count_primes(n: int) -> int:   count = 0   for k in range(2, n):       if is_prime(k):           count += 1   return count t0 = time.time() print(count_primes(100000)) t1 = time.time() print(t1-t0) 这个是我们以前经常用来做例子的统计质数个数。求100000以内速度比较快,但是到了1000000,运行时间就明显慢了下来,竟然需要3.38秒。 Python 的大型 for 循环或嵌套 for 循环总是导致运行时性能不佳。 我们只需导入 Taichi 或切换到 Taichi 的 GPU 后端,就能看到整体性能的大幅提升: ``` import time import taichi as ti ti.init() @ti.func def is_prime(n): result = True for k in range(2, int(n**0.5) + 1): if n % k == 0: result = False break return result @ti.kernel def count_primes(n: int) -> int: count = 0 for k in range(2, n): if is_prime(k): count += 1 return count t0 = time.time() print(count_primes(1000000)) t1 = time.time() print(t1-t0) ``` 在这里,我们只需要引入 taichi 库,然后加两个注解,速度直接飙到了0.1秒,速度提升了30多倍。如果我们把数字再扩大,速度提升会更明显! 没有使用之前,统计10000000以内质数使用 90 秒,使用之后,并且更改为 GPU 运行,使用 0.1秒。 我们还可以将 Taichi 的后端从 CPU 更改为 GPU 运行: ### 总结 这个库是中国人发明的,它就是毕业于清华大学,后来去麻省理工学院进修的胡渊鸣 --- ## 关于大越创新的板载USB–TTL通信失败问题的修复 URL: https://wsj-blog.pages.dev/posts/guan-yu-da-yue-chuang-xin-de-ban-zai-usb-ttl-tong-xin-shi/ License: CC-BY-NC-SA-4.0 ### 问题描述: 这几天用大越创新stm32f103rct6(升级版)进行传感器读取,发现使用板载串口,电脑识别ch340驱动比较慢,而且发送数据会卡死(如果判断没问题的话是单片机卡了),重新启动串口后,接收一小部分数据后又卡死。 ### 问题解决: 首先考虑到是通信芯片老化,于是进行换新,发现问题依旧,之后查看电路图如下↓ ![](https://i-blog.csdnimg.cn/blog_migrate/53798bf42c16b2bc8a61b5eaaae6b977.jpeg) 可以发现三个问题, #### 1.ch340c的34引脚接了10K的电阻(会使电平处在边沿时间变长)。 #### 2.电路通信方式选择的是一件下载的方式(实际上这种下载方式在stm32系列的单片机并不常用)。 #### 3.供电电源应该用0.1uf的退偶电容,这个板子并联了两个104电容。 下面我们看官方的芯片手册,如下↓ ![](https://i-blog.csdnimg.cn/blog_migrate/ccd2e4d7c9e82a51704d8426bfebfb43.jpeg) ![](https://i-blog.csdnimg.cn/blog_migrate/90f208b3465b5cec66612a9ff68c2998.jpeg) ###  解决方案: 1,取掉Q1和Q2两个三极管。 2,考虑短接R13和R14两个电阻,或者用0欧电阻代替。 3,电源104电容换105。 ### 最终结果: 等待更新。 2022年11月8日更新 拆掉两个三极管后,如下↓ ![](https://i-blog.csdnimg.cn/blog_migrate/cdee9d4d3f672354275840d38ca57f78.jpeg) 通信正常,问题解决。 2022年11月9日更新 检查发现R13和R14两个电阻已经被更换为0欧电阻(应该是店家刊误,或者是故意这样防止盗版吧) ![](https://i-blog.csdnimg.cn/blog_migrate/fe216230ae2c1b21ed9b1f740e881d2e.jpeg) 客服也是相当实在了。 --- ## ROS使用的bug总结 URL: https://wsj-blog.pages.dev/posts/ros-shi-yong-de-bug-zong-jie/ License: CC-BY-NC-SA-4.0 1,[rospack] Error: package 'xxxxxxxxxxx' not found 解决方法,终端输入 ``` source ./devel/setup.bash ``` 之后再 ``` rosrun 功能包 可执行文件 ``` --- ## ubuntu安装时窗口太小,点不到应用 URL: https://wsj-blog.pages.dev/posts/ubuntu-an-zhuang-shi-chuang-kou-tai-xiao-dian-bu-dao-ying/ License: CC-BY-NC-SA-4.0 1,Ctrl+Alt+T打开终端 2,输入命令 ``` xrandr -s 1280x800 ``` 后⾯的1280x800就是分辨率⼤⼩,只要敲击enter,⽴即显⽰效果 要注意的是,两数字间是x字母,⽽不是数字键区的*号。另外,此效果,只能在当前运⾏虚拟机下有效,如果关了虚拟机,之后就没⽤了。所以你必需,⽴刻在system setting⾥点击display,找到分辨率修改,并点击apply进⾏应⽤。 另外,刚按完虚拟机之后,是没有root密码的,你需要设置⼀下 ``` sudo passwd root ``` 输入几次新密码即可。 ---