ROS2_control 这套框架,我最早是在做一套六轴协作臂的力控项目时被迫啃下来的。当时的需求很直接:把原来基于 ROS1 的关节轨迹控制器迁到 ROS2,同时要能接自定义的力传感器反馈,还要在仿真里先跑通再上真机。翻了一圈官方文档和示例仓库,发现能跑起来的 demo 不少,但真正讲清楚"控制器怎么被加载""硬件接口怎么和真实驱动对接""自定义插件到底该继承哪个类"的资料少得可怜。这篇就把我这段时间踩过的坑、理清的调用链路、以及几个能直接抄的插件模板整理出来,给同样在啃 ROS2_control 的朋友省点时间。
需要先说明的是,ROS2_control 本身还在快速迭代,不同发行版(Humble、Iron、Jazzy)之间 API 有差异,我下面主要以 Humble 为基准,遇到差异会单独标注。另外这篇不追求大而全,重点放在控制器的加载机制、硬件接口抽象、自定义插件编写、以及 gz_ros2_control 仿真联调这几块,因为这几块是实际项目里绕不开、又最容易卡住的地方。
1. 先把 ROS2_control 的调用链路捋直
很多人一上来就去看ros2_control的源码,结果被controller_manager、resource_manager、hardware_interface这几个包绕晕。我的建议是先别急着看代码,先把"一条控制指令从发出到作用到电机上"这条链路在脑子里跑一遍,后面看代码就是填空。
1.1 从 controller_manager 到 hardware_interface 的数据流
整条链路的核心角色其实就四个:controller_manager、controller、resource_manager、hardware_interface。它们的关系可以这样理解:
controller_manager是总调度,负责按配置加载控制器、管理控制器的生命周期(configure/activate/deactivate)、以及以固定周期触发update()。controller是具体算法,比如关节轨迹控制器、差分驱动控制器,它每个周期算出目标位置/速度/力矩,写进 command interface。resource_manager是资源管家,它持有所有硬件组件(也就是 hardware interface 的实例),负责在控制器和硬件之间做接口的读写仲裁。hardware_interface是对真实硬件(或仿真)的抽象,它暴露 state interface(读)和 command interface(写),并负责read()和write()的实际执行。
一个控制周期里发生的事情大致是:controller_manager的update()被定时器触发 → 调用resource_manager->read()把所有硬件的状态读进来 → 调用每个激活 controller 的update(),controller 从 state interface 读数据、算完写进 command interface → 调用resource_manager->write()把 command interface 的值下发到硬件。这个顺序非常关键,read 在前、write 在后,中间夹着 controller 的计算,理解这一点后面调时序问题会轻松很多。
1.2 为什么 command_interface 和 state_interface 要分开
刚接触的时候我有个疑问:为什么不能一个接口既能读又能写?非要拆成 state 和 command 两套。后来做力控才明白,这个拆分是为了解耦控制算法和硬件实现。
state interface 代表"硬件当前是什么状态",command interface 代表"我希望硬件变成什么状态"。控制器只关心这两组抽象接口的名字和数据类型,完全不关心底层是 CAN 总线、EtherCAT 还是仿真。硬件插件则只负责把物理量映射到这些接口上。这样一来,同一个关节轨迹控制器,既能在仿真里跑,也能在真机上跑,切换的只是底层 hardware 插件,控制器代码一行不用改。
这个设计还有个隐含好处:接口的读写是有所有权和仲裁的。多个控制器可能同时想写同一个 command interface,resource_manager会通过 claim 机制保证同一时刻只有一个控制器能写某个接口,避免冲突。这个机制在配置多控制器时特别重要,后面会细说。
1.3 一个最小系统的配置文件长什么样
光说概念太虚,直接看一个能跑的最小配置。假设我们有一个两关节的手臂,用位置控制,配置文件通常分三块:URDF 里的<ros2_control>标签、controller 的 yaml 配置、以及 launch 文件。
URDF 里的硬件描述大概是这样:
<ros2_control name="ArmSystem" type="system"> <hardware> <plugin>my_robot_hardware/MyArmHardware</plugin> <param name="can_interface">can0</param> </hardware> <joint name="joint1"> <command_interface name="position"/> <state_interface name="position"/> <state_interface name="velocity"/> </joint> <joint name="joint2"> <command_interface name="position"/> <state_interface name="position"/> <state_interface name="velocity"/> </joint> </ros2_control>controller 的 yaml 配置:
controller_manager: ros__parameters: update_rate: 100 # Hz joint_state_broadcaster: type: joint_state_broadcaster/JointStateBroadcaster arm_position_controller: type: position_controllers/JointGroupPositionController arm_position_controller: ros__parameters: joints: - joint1 - joint2 interface_name: position这里有个新手常踩的坑:update_rate设成 100Hz,意味着controller_manager每 10ms 触发一次 update。但如果你底层硬件的read()是阻塞式的(比如等 CAN 回包),实际周期会被拉长,控制器算出来的轨迹就会抖。我一般会把硬件通信做成非阻塞 + 缓存,read()只读缓存,真正的通信放在独立线程里。
2. 自定义硬件插件:从继承哪个类开始
官方示例里给的硬件插件往往是最简单的,真接上自己的设备就会发现不够用。这一节讲清楚硬件插件的类层次,以及不同场景该继承哪个基类。
2.1 System、Sensor、Actuator 三种硬件类型的取舍
hardware_interface提供了三个基类:SystemInterface、SensorInterface、ActuatorInterface。名字很直白,但实际选型时有讲究:
| 基类 | 适用场景 | 典型接口 |
|---|---|---|
| SystemInterface | 一个硬件单元同时有多个关节的读写 | 机械臂、移动底盘 |
| ActuatorInterface | 单个执行器,只有写没有读或读很简单 | 单个电机、夹爪 |
| SensorInterface | 纯传感器,只读不写 | IMU、力传感器、编码器 |
我一开始图省事,所有东西都用SystemInterface,结果接一个纯力传感器时发现它强制要求实现write(),而传感器根本没有可写的东西,只能写个空函数,很别扭。后来改成SensorInterface就清爽了。选型原则很简单:有 command interface 就用 System 或 Actuator,纯 state 就用 Sensor。
还有一个容易忽略的点:SystemInterface的read()和write()是分开的,但很多总线(比如某些 CAN 协议)读写是耦合的,一次通信既发指令又收状态。这种情况下可以在read()里做完整通信,把收到的状态存下来,write()里只更新待发送的指令缓存,下一个周期的read()再真正发出去。这样会引入一个周期的延迟,但对大多数控制场景可以接受。
2.2 on_init、on_configure、on_activate 的职责边界
硬件插件的生命周期回调有好几个,职责划分不清楚的话很容易把初始化逻辑放错地方。我整理了一下实际项目里的分工:
on_init():解析 URDF 传进来的参数,做接口的声明(info_.joints的填充),这时候还不能碰真实硬件,因为硬件可能还没上电。on_configure():做硬件连接,比如打开 CAN 口、建立 socket、加载参数文件。这一步失败要返回 ERROR,controller_manager 会知道配置失败。on_activate():真正开始通信前的最后准备,比如使能电机、清空缓存。这一步之后read()/write()就会被周期调用了。on_deactivate():停止通信,但保持连接,比如让电机进入待机而不是断电。on_cleanup():释放资源,关闭连接。
我踩过的一个坑:在on_init()里就去打开 CAN 口,结果仿真环境下根本没有 CAN 设备,直接初始化失败,连仿真都跑不起来。正确做法是把设备相关的操作放到on_configure(),并且根据参数判断是仿真还是真机,仿真时走另一套逻辑。
2.3 一个可复用的硬件插件骨架
下面这个骨架是我从几个项目里提炼出来的,去掉了业务逻辑,保留了结构,可以直接拿去改:
#include "hardware_interface/system_interface.hpp" #include "hardware_interface/types/hardware_interface_return_values.hpp" #include "rclcpp/rclcpp.hpp" namespace my_robot_hardware { class MyArmHardware : public hardware_interface::SystemInterface { public: hardware_interface::CallbackReturn on_init( const hardware_interface::HardwareInfo & info) override { if (hardware_interface::SystemInterface::on_init(info) != hardware_interface::CallbackReturn::SUCCESS) { return hardware_interface::CallbackReturn::ERROR; } // 根据关节数量初始化缓存 const size_t n = info_.joints.size(); hw_positions_.resize(n, 0.0); hw_velocities_.resize(n, 0.0); hw_commands_.resize(n, 0.0); // 读取 URDF 里的自定义参数 can_interface_ = info_.hardware_parameters["can_interface"]; return hardware_interface::CallbackReturn::SUCCESS; } hardware_interface::CallbackReturn on_configure( const rclcpp_lifecycle::State &) override { // 这里做真实硬件连接,仿真时跳过 if (!connectToHardware()) { return hardware_interface::CallbackReturn::ERROR; } return hardware_interface::CallbackReturn::SUCCESS; } std::vector<hardware_interface::StateInterface> export_state_interfaces() override { std::vector<hardware_interface::StateInterface> interfaces; for (size_t i = 0; i < info_.joints.size(); ++i) { interfaces.emplace_back( info_.joints[i].name, hardware_interface::HW_IF_POSITION, &hw_positions_[i]); interfaces.emplace_back( info_.joints[i].name, hardware_interface::HW_IF_VELOCITY, &hw_velocities_[i]); } return interfaces; } std::vector<hardware_interface::CommandInterface> export_command_interfaces() override { std::vector<hardware_interface::CommandInterface> interfaces; for (size_t i = 0; i < info_.joints.size(); ++i) { interfaces.emplace_back( info_.joints[i].name, hardware_interface::HW_IF_POSITION, &hw_commands_[i]); } return interfaces; } hardware_interface::return_type read( const rclcpp::Time &, const rclcpp::Duration &) override { // 从硬件读状态,写入 hw_positions_ / hw_velocities_ return hardware_interface::return_type::OK; } hardware_interface::return_type write( const rclcpp::Time &, const rclcpp::Duration &) override { // 把 hw_commands_ 下发到硬件 return hardware_interface::return_type::OK; } private: std::vector<double> hw_positions_; std::vector<double> hw_velocities_; std::vector<double> hw_commands_; std::string can_interface_; }; } // namespace my_robot_hardware #include "pluginlib/class_list_macros.hpp" PLUGINLIB_EXPORT_CLASS(my_robot_hardware::MyArmHardware, hardware_interface::SystemInterface)这个骨架里最关键的是export_state_interfaces()和export_command_interfaces(),它们把成员变量的地址暴露给resource_manager,之后控制器读写接口实际上就是读写这些成员变量。所以这些成员变量的生命周期必须覆盖整个硬件插件的生命周期,千万别用局部变量或者会被重新分配内存的容器。
3. 自定义控制器插件:别急着写算法
控制器插件这块,很多人一上来就想写复杂的控制算法,结果卡在插件注册和接口声明上。我的经验是先把一个"什么都不做但能加载"的控制器跑通,再往里填算法。
3.1 controller_interface 的继承与 update 实现
控制器基类是controller_interface::ControllerInterface,核心要实现的就三个:command_interface_configuration()、state_interface_configuration()、update()。
command_interface_configuration()告诉resource_manager这个控制器要写哪些接口,返回一个InterfaceConfiguration。这里有个细节:配置里可以指定接口名,也可以用通配符。我一般明确列出关节名,避免误 claim 到别的控制器的接口。
update()是每个周期被调用的核心,签名是update(const rclcpp::Time & time, const rclcpp::Duration & period)。注意period是实际周期,不一定等于配置的update_rate的倒数,因为调度会有抖动。做积分类算法时一定要用这个实际period,不要用固定值,否则累积误差会很明显。
3.2 接口 claim 冲突:多控制器共存的真实案例
我遇到过一个很典型的问题:同时加载了joint_state_broadcaster和一个自定义的位置控制器,结果启动时报接口冲突。原因是joint_state_broadcaster会 claim 所有关节的 state interface,而我的控制器也 claim 了同样的 state interface。
这里要区分清楚:state interface 是可以被多个控制器同时读的,command interface 才是独占的。但joint_state_broadcaster默认配置会尝试 claim 所有 state,如果和你的控制器配置重叠,需要检查是不是配置写重了。实际上 state interface 的共享是允许的,报冲突往往是 command interface 的问题。
真正会冲突的是 command interface。比如你同时加载了两个都想写joint1/position的控制器,第二个激活时就会失败。解决办法有两种:一是用controller_manager的switch_controller服务做互斥切换,二是把两个控制器的控制目标合并到一个控制器里。我一般用第一种,因为切换逻辑清晰,也方便做状态机。
3.3 从零写一个正弦轨迹控制器
为了把上面这些串起来,写一个最简单的正弦轨迹控制器,让关节按正弦规律运动。这个控制器虽然简单,但包含了控制器插件的所有必要元素:
#include "controller_interface/controller_interface.hpp" #include "rclcpp_lifecycle/state.hpp" #include <cmath> namespace my_controllers { class SineController : public controller_interface::ControllerInterface { public: controller_interface::InterfaceConfiguration command_interface_configuration() const override { controller_interface::InterfaceConfiguration config; config.type = controller_interface::interface_configuration_type::INDIVIDUAL; for (const auto & joint : joint_names_) { config.names.push_back(joint + "/position"); } return config; } controller_interface::InterfaceConfiguration state_interface_configuration() const override { return {controller_interface::interface_configuration_type::NONE, {}}; } controller_interface::return_type update( const rclcpp::Time & time, const rclcpp::Duration & period) override { elapsed_ += period.seconds(); for (size_t i = 0; i < joint_names_.size(); ++i) { double value = amplitude_ * std::sin(2.0 * M_PI * frequency_ * elapsed_ + phase_[i]); command_interfaces_[i].set_value(value); } return controller_interface::return_type::OK; } // on_init / on_configure / on_activate 省略,主要做参数读取和接口绑定 private: std::vector<std::string> joint_names_; std::vector<double> phase_; double amplitude_ = 0.5; double frequency_ = 0.2; double elapsed_ = 0.0; }; } // namespace my_controllers #include "pluginlib/class_list_macros.hpp" PLUGINLIB_EXPORT_CLASS(my_controllers::SineController, controller_interface::ControllerInterface)写完插件后,别忘了在包的pluginlib导出文件里注册,否则controller_manager找不到。这个导出文件通常放在包的根目录,名字随意,但要在package.xml里用<export>标签引用。
4. gz_ros2_control 仿真联调:先仿真再上真机
真机调试成本高,一不小心就撞机。我的习惯是先在 Gazebo 里把控制器逻辑跑通,再切到真机。gz_ros2_control就是干这个的,它把 Gazebo 的物理引擎包装成一套 hardware interface,让控制器以为自己在跟真硬件说话。
4.1 仿真硬件插件和真实插件的差异点
gz_ros2_control提供的GazeboSimSystem本质上也是一个SystemInterface实现,只不过它的read()是从 Gazebo 的仿真世界里取关节状态,write()是把指令塞回 Gazebo 的关节控制器。对上层控制器来说,这两者没有区别。
但有几个差异点要注意:
- 仿真里没有通信延迟,真机上的延迟、丢包、抖动在仿真里都体现不出来。所以仿真跑通不代表真机没问题,反过来真机的问题往往在仿真里复现不了。
- 仿真的关节限位和动力学参数如果和真机不一致,控制器参数需要重新调。我一般会把真机的 URDF 参数尽量准确地填进仿真模型。
- 仿真里的传感器噪声默认是没有的,做滤波类算法时要手动加噪声,否则滤波器参数在真机上会完全不对。
4.2 URDF 里 gz_ros2_control 标签的正确写法
在 URDF 里用gz_ros2_control和用真实硬件插件的写法几乎一样,只是 plugin 名字不同:
<ros2_control name="GazeboSystem" type="system"> <hardware> <plugin>gz_ros2_control/GazeboSimSystem</plugin> </hardware> <joint name="joint1"> <command_interface name="position"> <param name="min">-3.14</param> <param name="max">3.14</param> </command_interface> <state_interface name="position"/> <state_interface name="velocity"/> </joint> </ros2_control>这里command_interface里的min/max参数在仿真里会被 Gazebo 用来做限位,真机上则要看你的硬件插件是否解析这些参数。我建议真机插件也解析这两个参数,在write()里做软限位保护,多一层保险。
4.3 仿真里控制器不动的排查顺序
仿真里控制器加载成功但关节不动,这是最常见的问题。我总结了一个排查顺序,基本能覆盖 90% 的情况:
- 确认 controller 是否 active:
ros2 control list_controllers,看状态是不是active。如果是inactive,说明没激活,检查 launch 里有没有调用switch_controller。 - 确认 command interface 有没有被写:在
update()里打日志,看set_value有没有被调用,值是不是非零。 - 确认 Gazebo 那边有没有收到指令:
gz topic -l看有没有关节指令话题,或者直接在 Gazebo GUI 里看关节有没有力矩。 - 确认 URDF 里的关节名和控制器配置一致:这个坑我踩过不止一次,URDF 里叫
joint_1,控制器配置里写joint1,加载不报错但就是不动。 - 确认
update_rate和 Gazebo 的物理步长匹配:如果update_rate远高于物理步长,指令会被覆盖,看起来就像没动。
提示:排查时把
controller_manager的日志级别调到 debug,能看到接口 claim 和 controller 切换的详细过程,比盲猜快得多。
5. 那些文档里不会写的实操经验
前面讲的都是框架层面的东西,这一节分享几个只有真正上手才会遇到的问题,以及我的处理方式。
5.1 实时性:别在 update 里做动态内存分配
update()是在实时线程里跑的,任何可能阻塞或分配内存的操作都会导致周期抖动。我见过有人在update()里push_back一个 vector,结果控制周期从 1ms 抖到 5ms。正确做法是所有容器在on_configure()里就 resize 好,update()里只做读写和计算。
同理,update()里不要打日志(尤其是RCLCPP_INFO),不要做文件 IO,不要调用可能阻塞的服务。需要调试信息的话,用无锁队列把数据传出来,在非实时线程里打印。
5.2 参数热更新的边界
ROS2 的参数系统支持运行时更新,但controller_manager对参数热更新的支持是有限的。update_rate这种参数改了之后需要重新配置控制器才生效,不是改了就立刻变。我一般把这类参数在 launch 时就固定好,运行时不改,避免出现"改了没生效"的困惑。
硬件插件里的参数(比如 PID 增益)如果要做热更新,需要在插件里自己实现参数回调,controller_manager不会自动帮你转发。这块我一般用 ROS2 的 parameter callback 在硬件插件内部处理。
5.3 从仿真切真机时最容易翻车的三个点
最后说三个我实际切换时翻过的车:
- 单位不一致:仿真里角度用弧度,真机驱动器可能用度或者编码器计数。这个必须在硬件插件的
read()/write()里统一转换,别指望控制器帮你转。 - 零点不一致:仿真模型的零位和真机的机械零位往往对不上,上电后要先做回零,把编码器值映射到 URDF 定义的零位。
- 方向不一致:某个关节在仿真里正转,真机上可能是反转,取决于电机安装方向。这个在硬件插件里用符号系数处理,别去改 URDF,否则仿真和真机又不一致了。
这三个点看起来简单,但每一个都能让你调半天。我的建议是切换前先写一个简单的"点动"测试,每个关节单独小幅运动,确认方向、单位、零点都对,再跑完整轨迹。
ROS2_control 这套东西上手曲线确实陡,但一旦把调用链路和插件机制理清楚,后面加新硬件、加新控制器就是套模板的事。我现在的习惯是每接一个新设备,先写一个最小硬件插件,只做 read/write 打通,确认能读到状态、能下发指令,再往上叠控制器逻辑。这样出问题时排查范围小,不至于一锅粥。