ros2_control Hardware Interface: A 2026 Tutorial

ros2_control Hardware Interface: A 2026 Tutorial

ros2_control Hardware Interface: A 2026 Tutorial

Most ROS 2 robots do not fail their first bring-up in the planner. They fail in the sub-millisecond gap between “the controller computed a command” and “the motor actually received it.” That gap is exactly where a ros2_control hardware interface lives. Get it wrong and you see jitter, silently dropped commands, or an arm that lurches because someone called new or took a contended mutex inside the real-time loop.

The API has also moved under people’s feet. Between Jazzy and the current Rolling branch, on_init changed signature, interface export became framework-managed, asynchronous components became first-class, and the supported distribution list now includes Lyrical Luth. Most tutorials still show the 2022 shape of the code.

This guide is written for engineers who already run ROS 2 and now need to put their own hardware under ros2_control. You will leave with a mental model of the controller manager and resource manager, a complete C++ SystemInterface plugin with its pluginlib export, the URDF block and controller YAML that wire it in, and a real-time checklist you can audit your own driver against.

What this covers: the architecture and data flow, the component lifecycle, a full plugin and its build files, URDF and YAML configuration, launch and testing with mock components, async and multi-rate components, chained controllers, real-time hardening, and the failure modes that cost the most bring-up days.

Context and Background

Before ros2_control, every ROS robot driver reinvented the same plumbing: a node that subscribed to a command topic, talked to a motor bus, and published a joint state message. The result was fragmentation. A MoveIt trajectory written against one arm would not run on another because the command topic, the state message, and the timing model all differed. ROS 1’s ros_control solved part of that, and ros2_control is its redesigned successor with lifecycle management, a plugin-based hardware layer, and tighter real-time discipline.

The central idea is to standardize the boundary between control algorithms and hardware, not the hardware itself. The framework is documented at control.ros.org and splits the world in two. On one side are controllers: reusable algorithms such as joint_trajectory_controller, forward_command_controller or diff_drive_controller that read state and compute commands. On the other side are hardware components: thin drivers you write that expose state interfaces (what the robot reports) and command interfaces (what the robot accepts). The controller_manager owns a periodic loop and a ResourceManager that decides who may write to which joint.

This is why the design scales. The same joint_trajectory_controller binary drives a simulated arm, a CAN-bus arm, and an EtherCAT arm, because only the hardware component underneath differs. If you are still on Humble, our ROS 2 Jazzy Jalisco migration guide from Humble covers the breaking changes that touch hardware interfaces, and the Jazzy to Kilted migration guide continues the story.

Which distribution should you target in 2026?

Three release lines matter in October 2026. Jazzy Jalisco (the 2024 LTS) ships ros2_control in the 4.x series; the Jazzy branch of the repository carries hardware_interface version 4.49.0 at the time of writing. Kilted Kaiju (the 2025 non-LTS release) is on the 5.x series, with 5.19.0 on its branch. Lyrical Luth was released on 22 May 2026 as an LTS with support announced through May 2031, and Ubuntu 26.04 is its Tier 1 platform per the Open Robotics announcement.

The practical consequence: write against the on_init(const HardwareComponentInterfaceParams &) overload and the framework-managed interface export. Both exist in the Jazzy and Kilted branches, and the older on_init(const HardwareInfo &) overload is marked deprecated there. Code written this way compiles across all three lines with at most small adjustments, and our Kilted to Lyrical migration guide lists distribution-level changes. One caveat: the demo repository’s master branch now requires controller_manager 5.0.0 or newer in its CMake, so copy demo code from the branch matching your distro, not from master.

The ros2_control Reference Architecture

A ros2_control hardware interface is a C++ class, loaded at runtime through pluginlib, that translates between the framework’s standardized read and write contract and your device’s real protocol. The controller manager calls read() to refresh state interfaces, runs the active controllers’ update(), then calls write() to push command interfaces to the device, once per cycle.

ros2_control hardware interface architecture showing controller manager, resource manager and hardware components

Figure 1: ros2_control architecture. Controllers never touch hardware directly; they claim command interfaces and read state interfaces owned by the resource manager.

Figure 1 shows the pieces. The URDF carries <ros2_control> tags that the controller manager parses into HardwareInfo structures. The resource manager loads one plugin per tag, asks each to initialize, and keeps a registry of every state and command interface they export. Controllers are loaded separately, usually by spawner nodes, and claim the interfaces they need. A claim is exclusive for command interfaces: two controllers cannot both write joint1/position. State interfaces can be read by anyone.

The three component types

The framework defines three base classes, chosen by what the hardware is. ActuatorInterface models a single actuator, one joint with its own driver. SensorInterface exports state only; its command export is overridden to return an empty set. SystemInterface models a multi-joint device behind one communication channel, such as an industrial arm, a CAN-connected drive group, or a mobile base with wheel motors and an IMU.

Pick SystemInterface when one physical bus transaction serves several joints, because it lets you read all encoders in one frame and write all setpoints in another. Pick ActuatorInterface only when each motor is genuinely independent, as with separate serial smart servos. A system component can also expose <gpio> and <sensor> entries, so a gripper with a vacuum valve and a force sensor does not need three plugins.

Command and state interfaces

An interface is a named, typed value: joint1/position, joint1/velocity, joint1/effort, tcp_fts_sensor/fx. The standard names live in hardware_interface/types/hardware_interface_type_values.hpp as constants such as HW_IF_POSITION, HW_IF_VELOCITY, HW_IF_ACCELERATION and HW_IF_EFFORT. You are not limited to these: GPIOs and custom interfaces use arbitrary names and, since recent releases, an optional data_type such as bool or uint16 on the interface tag.

Three design rules follow. First, state and command are separate memory: a controller commands a position but reads back the measured position, and the difference is your tracking error. Second, interfaces are declared in the URDF, not in code, so the same plugin serves different robots by configuration. Third, the framework now generates the interface objects from the URDF automatically, and your component reads and writes them through get_state, set_state, get_command and set_command by name.

Lifecycle: why your constructor does almost nothing

Every hardware component is a lifecycle object. After the plugin is constructed with its no-argument constructor, the framework calls on_init. The header documentation in system_interface.hpp defines the states precisely:

  • Unconfigured (after on_init and on_cleanup): initialized, but communication is not started and no interfaces are available.
  • Inactive (after on_configure and on_deactivate): communication is open and configured. State can be read; command interfaces are not available.
  • Active (after on_activate): power stages are enabled and the device can move, for example brakes are released. Command interfaces are available.
  • Finalized (after on_shutdown): ready for destruction, memory cleaned up.

ros2_control hardware component lifecycle states from unconfigured through inactive to active

Figure 2: The hardware component lifecycle. Open the bus in on_configure, energize the drives only in on_activate.

The mapping to real hardware is the whole point. Open the CAN socket or serial port in on_configure, so that state starts flowing and operators see encoder values before anything can move. Enable motor drivers, release brakes and sync the command to the measured state in on_activate. Do the reverse in on_deactivate and on_cleanup. Callbacks return SUCCESS, FAILURE (the call failed but may be retried) or ERROR (a critical error handled by on_error).

A subtle point from the header comments: the source contains a todo noting that, in a future version, the Inactive state should allow non-movement commands while still blocking position, velocity, acceleration and effort interfaces. Today, treat Inactive as read-only.

Why the split into read and write matters

The two-phase cycle exists because control theory wants a consistent snapshot. All state interfaces are refreshed first, then controllers compute from that snapshot, then all commands go out together. If your driver mixes the two phases (for example writing a command from inside read() because a reply arrived), you break the timing assumptions of every controller above it. Keep read() as “make state interfaces reflect the device” and write() as “make the device reflect command interfaces,” with nothing clever in between.

Walk-through: Writing a Complete SystemInterface Plugin

We will build a plugin for a fictional two-joint drive pair, acme_drive, that speaks a fixed-size binary protocol over a non-blocking transport. The transport is deliberately abstract: swap it for SocketCAN, a UDP socket, an EtherCAT master cyclic exchange, or a vendor SDK. What matters is the structure around it, and every structural choice below is made so that read() and write() are safe to run inside a real-time thread. The code is illustrative and was not compiled against a hardware target for this article; the API calls themselves were checked against the ros2_control sources for the Jazzy, Kilted and master branches.

Package layout

acme_drive_hardware/
  CMakeLists.txt
  package.xml
  acme_drive_hardware.xml          # pluginlib description
  include/acme_drive_hardware/acme_drive_system.hpp
  src/acme_drive_system.cpp
  config/acme_controllers.yaml
  urdf/acme_arm.ros2_control.xacro
  launch/acme_arm.launch.py

The header

The class derives from hardware_interface::SystemInterface, overrides the lifecycle callbacks it needs, and holds only preallocated state. Note what is absent: no std::mutex member, no std::vector that grows, no std::string built per cycle.

#pragma once
#include <array>
#include <atomic>
#include <memory>
#include <string>
#include <vector>

#include "hardware_interface/system_interface.hpp"
#include "hardware_interface/types/hardware_interface_return_values.hpp"
#include "rclcpp/macros.hpp"
#include "rclcpp_lifecycle/state.hpp"

namespace acme_drive_hardware
{
// Minimal transport contract: all calls must be non-blocking and allocation free.
struct DriveFrame { double pos_rad; double vel_rad_s; double eff_nm; uint16_t status; };

class DriveBus
{
public:
  virtual ~DriveBus() = default;
  virtual bool open(const std::string & device, int baud) = 0;      // may block, non-RT
  virtual void close() = 0;
  virtual bool poll(size_t axis, DriveFrame & out) = 0;             // non-blocking
  virtual bool send_velocity(size_t axis, double vel_rad_s) = 0;    // non-blocking
  virtual bool enable(size_t axis, bool on) = 0;                    // non-RT
};

class AcmeDriveSystem : public hardware_interface::SystemInterface
{
public:
  RCLCPP_SHARED_PTR_DEFINITIONS(AcmeDriveSystem)

  hardware_interface::CallbackReturn on_init(
    const hardware_interface::HardwareComponentInterfaceParams & params) override;
  hardware_interface::CallbackReturn on_configure(
    const rclcpp_lifecycle::State & previous_state) override;
  hardware_interface::CallbackReturn on_activate(
    const rclcpp_lifecycle::State & previous_state) override;
  hardware_interface::CallbackReturn on_deactivate(
    const rclcpp_lifecycle::State & previous_state) override;
  hardware_interface::CallbackReturn on_cleanup(
    const rclcpp_lifecycle::State & previous_state) override;

  hardware_interface::return_type read(
    const rclcpp::Time & time, const rclcpp::Duration & period) override;
  hardware_interface::return_type write(
    const rclcpp::Time & time, const rclcpp::Duration & period) override;

private:
  std::unique_ptr<DriveBus> bus_;
  std::string device_;
  int baud_{1000000};
  std::vector<std::string> joint_names_;   // sized once in on_init
  std::vector<uint16_t> status_;           // sized once in on_init
  uint32_t consecutive_misses_{0};
  uint32_t max_misses_{3};
};
}  // namespace acme_drive_hardware

The implementation, part one: parameters and validation

on_init is your only chance to reject a bad URDF before anything runs, so be strict. Call the base implementation first; it stores info_ (the parsed HardwareInfo). Then read hardware parameters from info_.hardware_parameters, a string map, and validate every joint’s declared interfaces. The official RRBot demo does exactly this, failing with CallbackReturn::ERROR and a fatal log if a joint has the wrong number or type of interfaces.

#include "acme_drive_hardware/acme_drive_system.hpp"
#include "hardware_interface/types/hardware_interface_type_values.hpp"
#include "pluginlib/class_list_macros.hpp"

namespace acme_drive_hardware
{
using hardware_interface::CallbackReturn;
using hardware_interface::return_type;

CallbackReturn AcmeDriveSystem::on_init(
  const hardware_interface::HardwareComponentInterfaceParams & params)
{
  if (hardware_interface::SystemInterface::on_init(params) != CallbackReturn::SUCCESS)
  {
    return CallbackReturn::ERROR;
  }

  auto & hw = info_.hardware_parameters;
  if (hw.count("device") == 0)
  {
    RCLCPP_FATAL(get_logger(), "Hardware parameter 'device' is required");
    return CallbackReturn::ERROR;
  }
  device_ = hw.at("device");
  if (hw.count("baud")) { baud_ = std::stoi(hw.at("baud")); }
  if (hw.count("max_missed_cycles")) { max_misses_ = std::stoul(hw.at("max_missed_cycles")); }

  for (const auto & joint : info_.joints)
  {
    // Command: velocity only. State: position, velocity, effort.
    if (joint.command_interfaces.size() != 1 ||
        joint.command_interfaces[0].name != hardware_interface::HW_IF_VELOCITY)
    {
      RCLCPP_FATAL(get_logger(), "Joint '%s' must have exactly one velocity command interface",
                   joint.name.c_str());
      return CallbackReturn::ERROR;
    }
    if (joint.state_interfaces.size() != 3)
    {
      RCLCPP_FATAL(get_logger(), "Joint '%s' needs position, velocity and effort states",
                   joint.name.c_str());
      return CallbackReturn::ERROR;
    }
    joint_names_.push_back(joint.name);
  }
  status_.assign(joint_names_.size(), 0);
  return CallbackReturn::SUCCESS;
}

Notice that nothing here exports interfaces. In current releases the framework creates the StateInterface and CommandInterface objects from the URDF and stores them in maps such as joint_state_interfaces_ and joint_command_interfaces_, keyed by the full interface name like joint1/velocity. You only override the export_unlisted_*_interface_descriptions() methods when you need interfaces that are not in the URDF at all, and if you override the lower-level on_export_* methods you take on the burden of storing the returned shared pointers yourself.

The implementation, part two: lifecycle and the real-time pair

The lifecycle callbacks are where blocking work belongs, because they run outside the update loop. Open the bus in on_configure, zero the interface values the way the official demo does (“reset values always when configuring hardware”), and enable drives in on_activate after synchronizing commands to the measured state. That last step prevents the classic start-up lurch: if the first command a controller sees is zero while the arm sits at 1.2 rad, a position controller will slam the joint.

CallbackReturn AcmeDriveSystem::on_configure(const rclcpp_lifecycle::State &)
{
  if (!bus_) { /* construct your concrete transport here, e.g. a SocketCAN bus */ }
  if (!bus_ || !bus_->open(device_, baud_))
  {
    RCLCPP_ERROR(get_logger(), "Cannot open %s", device_.c_str());
    return CallbackReturn::FAILURE;   // retryable, stays Unconfigured
  }
  for (const auto & [name, descr] : joint_state_interfaces_) { set_state(name, 0.0); }
  for (const auto & [name, descr] : joint_command_interfaces_) { set_command(name, 0.0); }
  consecutive_misses_ = 0;
  return CallbackReturn::SUCCESS;
}

CallbackReturn AcmeDriveSystem::on_activate(const rclcpp_lifecycle::State &)
{
  // Read a fresh frame for every axis so commands start from reality.
  for (size_t i = 0; i < joint_names_.size(); ++i)
  {
    DriveFrame f{};
    if (!bus_->poll(i, f)) { return CallbackReturn::FAILURE; }
    set_state(joint_names_[i] + "/position", f.pos_rad);
    set_state(joint_names_[i] + "/velocity", f.vel_rad_s);
    set_state(joint_names_[i] + "/effort", f.eff_nm);
    set_command(joint_names_[i] + "/velocity", 0.0);
    if (!bus_->enable(i, true)) { return CallbackReturn::ERROR; }
  }
  return CallbackReturn::SUCCESS;
}

CallbackReturn AcmeDriveSystem::on_deactivate(const rclcpp_lifecycle::State &)
{
  for (size_t i = 0; i < joint_names_.size(); ++i) { bus_->send_velocity(i, 0.0); bus_->enable(i, false); }
  return CallbackReturn::SUCCESS;
}

CallbackReturn AcmeDriveSystem::on_cleanup(const rclcpp_lifecycle::State &)
{
  if (bus_) { bus_->close(); }
  return CallbackReturn::SUCCESS;
}

The next two methods run in the controller manager’s real-time thread, and the rules change. No allocation, no logging at a rate that could block, no locks shared with non-real-time threads, no syscalls with unbounded latency. In the example, string concatenation in on_activate is fine because activation is not in the loop; in read() we would precompute the names. A cleaner approach is to cache the interface keys in on_configure and index into them by joint.

return_type AcmeDriveSystem::read(const rclcpp::Time &, const rclcpp::Duration &)
{
  for (size_t i = 0; i < joint_names_.size(); ++i)
  {
    DriveFrame f{};
    if (!bus_->poll(i, f))
    {
      if (++consecutive_misses_ > max_misses_) { return return_type::ERROR; }
      continue;                       // keep last good state this cycle
    }
    consecutive_misses_ = 0;
    status_[i] = f.status;
    set_state(joint_names_[i] + "/position", f.pos_rad);
    set_state(joint_names_[i] + "/velocity", f.vel_rad_s);
    set_state(joint_names_[i] + "/effort", f.eff_nm);
  }
  return return_type::OK;
}

return_type AcmeDriveSystem::write(const rclcpp::Time &, const rclcpp::Duration &)
{
  for (size_t i = 0; i < joint_names_.size(); ++i)
  {
    if (status_[i] & 0x8000) { return return_type::DEACTIVATE; }   // drive fault bit
    const double cmd = get_command(joint_names_[i] + "/velocity");
    if (!bus_->send_velocity(i, cmd)) { return return_type::ERROR; }
  }
  return return_type::OK;
}
}  // namespace acme_drive_hardware

PLUGINLIB_EXPORT_CLASS(acme_drive_hardware::AcmeDriveSystem, hardware_interface::SystemInterface)

Keep the PLUGINLIB_EXPORT_CLASS line after the namespace closes, as the official guide specifies. The string-keyed lookups in the sketch are convenient but each call builds a temporary string; a production driver should hold the joint_state_interfaces_ handles or prebuilt key vectors, a point we return to in the real-time section.

What the return values actually do

The return values matter more than most tutorials admit. In the master branch of resource_manager.cpp, a read() returning anything other than OK (including DEACTIVATE, which is logged as “treated the same as ERROR” in that cycle) calls the component’s error handling and removes all of its interfaces from the available list. A write() returning ERROR does the same, while write() returning DEACTIVATE moves the component to the Inactive state. The controller manager then deactivates controllers that depended on those interfaces. That makes the fault-bit check above meaningful: a drive fault becomes an orderly stop instead of a command stream into a faulted amplifier. I read this from the source; confirm the behavior on your distro’s branch before you depend on it for safety, and never treat ros2_control’s error path as a substitute for a hardware safety circuit.

Build files and the plugin description

The plugin XML tells pluginlib the class name, its C++ type, and its base class. The name you give here is the string the URDF <plugin> tag must match.

<library path="acme_drive_hardware">
  <class name="acme_drive_hardware/AcmeDriveSystem"
         type="acme_drive_hardware::AcmeDriveSystem"
         base_class_type="hardware_interface::SystemInterface">
    <description>Two-axis velocity drive pair behind one bus.</description>
  </class>
</library>
cmake_minimum_required(VERSION 3.16)
project(acme_drive_hardware LANGUAGES CXX)

find_package(ament_cmake REQUIRED)
foreach(dep IN ITEMS hardware_interface pluginlib rclcpp rclcpp_lifecycle)
  find_package(${dep} REQUIRED)
endforeach()

add_library(acme_drive_hardware SHARED src/acme_drive_system.cpp)
target_include_directories(acme_drive_hardware PUBLIC
  $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:include/acme_drive_hardware>)
target_link_libraries(acme_drive_hardware PUBLIC
  hardware_interface::hardware_interface pluginlib::pluginlib
  rclcpp::rclcpp rclcpp_lifecycle::rclcpp_lifecycle)

pluginlib_export_plugin_description_file(hardware_interface acme_drive_hardware.xml)

install(DIRECTORY include/ DESTINATION include/acme_drive_hardware)
install(DIRECTORY urdf config launch DESTINATION share/${PROJECT_NAME})
install(TARGETS acme_drive_hardware EXPORT export_acme_drive_hardware
  ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin)
ament_export_targets(export_acme_drive_hardware HAS_LIBRARY_TARGET)
ament_export_dependencies(hardware_interface pluginlib rclcpp rclcpp_lifecycle)
ament_package()

The two lines that people forget are pluginlib_export_plugin_description_file(hardware_interface <file>.xml), which registers the plugin with the hardware_interface base package, and the matching <depend> entries in package.xml. If the controller manager prints “class not found” at startup, the cause is almost always one of those two, or a mismatch between the XML class name and the URDF plugin string.

Configuration, Launch and Testing Without Hardware

The C++ plugin is only half of the system. The other half is declarative: a URDF block that names the plugin and its interfaces, and a controller YAML that tells the controller manager how fast to run and which controllers to load. The robot description reaches the controller manager as the robot_description parameter, which is why URDF quality matters; see our comparison of URDF, SDF, MJCF and OpenUSD for how descriptions travel into simulation and digital twins.

The URDF ros2_control tag

Each <ros2_control> tag declares one hardware component. The type attribute (system, actuator or sensor) must match the base class of the plugin. Inside <hardware> you name the plugin and pass free-form parameters, which arrive in info_.hardware_parameters as strings. Each <joint> lists its command and state interfaces. A min and max parameter on a command interface lets the framework and some controllers enforce limits.

<?xml version="1.0"?>
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="acme_arm">
  <xacro:macro name="acme_ros2_control" params="use_mock:=false device:=/dev/ttyUSB0">
    <ros2_control name="AcmeDrivePair" type="system" rw_rate="500">
      <hardware>
        <xacro:if value="${use_mock}">
          <plugin>mock_components/GenericSystem</plugin>
          <param name="calculate_dynamics">true</param>
        </xacro:if>
        <xacro:unless value="${use_mock}">
          <plugin>acme_drive_hardware/AcmeDriveSystem</plugin>
          <param name="device">${device}</param>
          <param name="baud">1000000</param>
          <param name="max_missed_cycles">3</param>
        </xacro:unless>
      </hardware>
      <joint name="joint1">
        <command_interface name="velocity">
          <param name="min">-3.0</param>
          <param name="max">3.0</param>
        </command_interface>
        <state_interface name="position"/>
        <state_interface name="velocity"/>
        <state_interface name="effort"/>
      </joint>
      <joint name="joint2">
        <command_interface name="velocity">
          <param name="min">-3.0</param>
          <param name="max">3.0</param>
        </command_interface>
        <state_interface name="position"/>
        <state_interface name="velocity"/>
        <state_interface name="effort"/>
      </joint>
    </ros2_control>
  </xacro:macro>
</robot>

The use_mock switch is the single most valuable pattern in this article. It lets the same URDF run your real plugin on the robot and mock_components/GenericSystem on a laptop or in CI, with identical controller configuration. One caution: with calculate_dynamics enabled, the mock integrates velocity commands to produce position, so a velocity-only command interface behaves like a plausible plant. Without it the mock simply mirrors commands to states.

The optional rw_rate attribute (500 in the example) sets the component’s own read/write frequency in Hz. Per the “Different update rates” documentation, if it is omitted the default of 0 means the component runs at the controller manager’s rate, and if it exceeds the controller manager rate the component runs at the controller manager’s rate instead. Use it to run a slow bus alongside a fast one inside one control loop.

Controller YAML and the update rate

The controller manager’s own parameters sit at the top of the YAML, followed by one block per controller. The update_rate is the loop frequency for the controller manager itself, and each controller’s type names its plugin.

controller_manager:
  ros__parameters:
    update_rate: 1000        # Hz, control loop of the controller manager

    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster

    velocity_controller:
      type: velocity_controllers/JointGroupVelocityController

velocity_controller:
  ros__parameters:
    joints:
      - joint1
      - joint2

The official RRBot demo configuration follows the same shape: a joint_state_broadcaster entry and a forward_command_controller/ForwardCommandController with a joints list and an interface_name. Controller names under controller_manager must match the top-level parameter blocks, a frequent source of “parameters not found” errors. Two-joint velocity commands through a forward controller are the simplest possible test, and they exercise the whole chain.

Launch

The launch file starts ros2_control_node with the URDF and YAML, a robot_state_publisher, and spawner nodes that load and activate controllers. The spawner is a CLI utility in controller_manager; it blocks until the controller manager is up, then loads, configures and activates the named controller.

from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import Command, FindExecutable, LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.parameter_descriptions import ParameterValue
from launch_ros.substitutions import FindPackageShare


def generate_launch_description():
    use_mock = LaunchConfiguration("use_mock")
    pkg = FindPackageShare("acme_drive_hardware")
    robot_description = ParameterValue(
        Command([FindExecutable(name="xacro"), " ",
                 PathJoinSubstitution([pkg, "urdf", "acme_arm.urdf.xacro"]),
                 " use_mock:=", use_mock]), value_type=str)
    controllers = PathJoinSubstitution([pkg, "config", "acme_controllers.yaml"])

    return LaunchDescription([
        DeclareLaunchArgument("use_mock", default_value="true"),
        Node(package="robot_state_publisher", executable="robot_state_publisher",
             parameters=[{"robot_description": robot_description}]),
        Node(package="controller_manager", executable="ros2_control_node",
             parameters=[{"robot_description": robot_description}, controllers],
             output="screen"),
        Node(package="controller_manager", executable="spawner",
             arguments=["joint_state_broadcaster"]),
        Node(package="controller_manager", executable="spawner",
             arguments=["velocity_controller"]),
    ])

On the first run use use_mock:=true. Confirm that ros2 control list_hardware_interfaces shows your command interfaces as [available] [unclaimed] and that after the spawner runs the velocity interfaces show as claimed. Then publish a command with ros2 topic pub /velocity_controller/commands std_msgs/msg/Float64MultiArray "{data: [0.5, 0.0]}" and watch /joint_states. Only after the mock path passes should you flip to the real plugin.

Per-cycle flow

Figure 3 shows what happens in each period once everything is active.

ros2_control read update write cycle sequence between controller manager, hardware component and controllers

Figure 3: One control cycle. read refreshes state interfaces, active controllers compute commands, write sends them to the device.

The controller manager runs the resource manager’s read on all components, calls update() on active controllers in dependency order, and then calls write. In the master source, read visits actuators, then sensors, then systems, and write visits actuators and then systems. The consequence for multi-component robots is that you cannot rely on one component’s state being older or newer than another’s by more than the cycle ordering.

Testing a hardware component

Test at four levels, cheapest first. Unit tests link your plugin and call on_init, read and write with a fake DriveBus; the official guide uses ament_cmake_gmock for this. Integration tests start ros2_control_node with the mock path and assert that controllers activate. Loopback tests point the real plugin at a simulated device, a virtual CAN interface or a socket pair. Last, bring up the physical robot with limited torque and an operator at the emergency stop.

A useful trick is a fake DriveBus that injects faults: drop every thousandth frame, return a stuck status bit, or add jitter. If your max_missed_cycles logic and the DEACTIVATE path never run in CI, they will run for the first time in front of a customer.

Deeper Analysis: Async Components, Multi-Rate Hardware and Chained Controllers

The synchronous model has one weakness: read() and write() run inside the controller manager’s loop, so a slow device stretches every cycle. Three framework features exist to relieve that pressure, and choosing between them is a core design decision.

Option 1: keep it synchronous and make it fast

If your bus transaction takes a few hundred microseconds at worst, stay synchronous. It is the simplest model, it has the best determinism, and the timing of state and command is exact. A 1 kHz loop gives a one-millisecond budget for all components plus all controllers, so budget explicitly. As an illustrative example, a 250 microsecond read, 150 microseconds of controller compute and a 250 microsecond write leaves roughly 350 microseconds of headroom for jitter, which is thin on a non-real-time kernel.

Option 2: lower rate per component with rw_rate

When one device simply does not need the full loop rate, use rw_rate on its <ros2_control> tag. The controller manager still runs at its update_rate; the component’s read and write are executed on the cycles that best match the requested period. The source shows the logic: it compares how close the actual period would be to the target if it runs now versus if it skips this cycle, and executes only if running now is closer. A 250 Hz force-torque sensor beside a 500 Hz arm inside a 1 kHz manager is the documented use case.

Option 3: asynchronous components

When the device I/O can block for a meaningful fraction of the cycle, such as a vendor SDK with a blocking call or a wireless link, mark the component asynchronous. In the current parser the is_async attribute on the <ros2_control> tag enables it, and an <async> child can set thread priority, scheduling policy, CPU affinity cores and whether deadline-miss warnings print. The default thread priority in the header is 50, and the default scheduling policy string is synchronized. Internally the base class exposes trigger_read and trigger_write, which run your read and write either synchronously or on a dedicated worker thread through realtime_tools::AsyncFunctionHandler, and it returns a cycle status instead of a bare result.

Async buys isolation, not magic. Because the worker thread runs on its own schedule, the state the controllers see may be one or more cycles old, and the command written is whatever was latched when the worker last woke. That is acceptable for a gripper and wrong for a high-bandwidth torque loop. Treat async as a way to protect the main loop from a slow component, and then measure the staleness it introduces. The asynchronous-execution documentation page could not be retrieved while preparing this article, so verify the exact URDF syntax for your distribution at control.ros.org before copying the attribute names.

Decision flow for choosing synchronous, rw_rate or asynchronous ros2_control hardware components

Figure 4: Choosing an execution model. Start synchronous, add rw_rate for slow devices, use async only when I/O can block.

Figure 4 puts the decision in order. The key question is not “how fast is my device?” but “what is the worst-case duration of one I/O call, and can I bound it?” If you cannot bound it, it does not belong in the main thread no matter how fast it is on average.

Decision matrix

Situation Execution model Why Main risk
EtherCAT or CAN, bounded cyclic exchange under 200 us Synchronous Exact timing, simplest code Loop overrun if bus stalls
Sensor at a lower rate than arm rw_rate Same thread, fewer calls Rate quantization to loop ticks
Vendor SDK with blocking calls Async Isolates blocking State staleness, extra thread
Wireless or Ethernet gripper Async plus watchdog Tolerates variable latency Stale commands during link loss
Simulation or CI mock_components No hardware needed Ideal plant hides real faults

Chained controllers and cascade control

Real robots rarely use one controller. A mobile base might run a diff_drive_controller whose output feeds per-wheel velocity PID controllers. ros2_control supports this through chainable controllers: a downstream controller exports reference interfaces that an upstream controller claims as if they were command interfaces. The framework then orders update() calls so the upstream computes first, and treats the chain as a unit for activation and failure.

The wiring is mostly configuration. The upstream controller lists the downstream controller’s reference interfaces in its interface parameters, and you spawn the downstream controller first, in a chained-mode-capable state, then the upstream one. In the source, the controller manager’s documentation recommends including chainable controllers in the fallback list, and the release notes for the master branch state that the whole chain is now deactivated if any controller in it fails during update. That is the correct behavior for safety, because a downstream PID that stops updating under a live upstream is a hazard. It also means a single misbehaving controller can take down a chain, so keep chains short.

The hardware interface author rarely writes chainable controllers, but the knowledge shapes driver design. If your drive can do its own velocity loop internally, expose a velocity command interface and let ros2_control chain position control on top of it. If your drive only accepts torque, expose effort and let a ros2_control PID close the velocity loop in the update phase. Moving a loop across the boundary changes the rate and the latency of that loop, so decide deliberately which side owns each.

Hardware groups and status publishing

Recent releases add two lesser-known capabilities. A <group> tag lets several components be treated as a unit for error handling, so that a failure in one marks the group. And a component may override init_hardware_status_message (non-real-time, called once) and update_hardware_status_message (real-time safe) so the framework publishes a control_msgs::msg::HardwareStatus message, enabled with the status_publish_rate parameter inside <hardware>; the docs say a value of 0.0 disables it. Use this to surface drive temperatures and fault words without opening a second ROS node inside your plugin.

Real-Time Hardening: Where Determinism Is Won or Lost

Controllers and hardware components live on the controller manager’s real-time thread. The official determinism documentation says the main thread attempts to configure SCHED_FIFO with a priority of 50 regardless of kernel, that users by default lack permission to set such priorities, and that the fix is a realtime group with rtprio 99 and unlimited memlock in /etc/security/limits.conf. Without that, you silently run as an ordinary thread and every later optimization is moot.

The kernel layer

A stock desktop kernel can run SCHED_FIFO threads, but it can still be delayed by long non-preemptible kernel sections. The documentation points to real-time kernel options such as Real-time Ubuntu and, on Raspberry Pi, the linux-raspi-realtime package, and notes that although a real-time kernel gives the best results, the framework attempts real-time scheduling regardless. In my experience on x86 gateways, PREEMPT_RT plus isolated cores and a pinned controller manager is the difference between occasional millisecond spikes and tails under about a hundred microseconds; treat that as an expectation to measure on your own board with cyclictest, not a guarantee.

The code layer: rules for read() and write()

Inside the loop, follow rules derived from first principles about what can block or allocate. Each has a concrete failure mode.

No heap allocation. malloc takes a lock inside the allocator and may call into the kernel for pages. Preallocate everything in on_init or on_configure: vectors sized once, fixed arrays for frames, interface keys cached as members. The std::string concatenation in our sketch’s read() allocates when the result exceeds the small-string buffer, and should be replaced with cached keys.

No contended mutexes. A mutex shared with a non-real-time thread causes priority inversion: the real-time thread waits for a low-priority thread that a medium-priority thread has preempted. If your driver needs a receive thread, hand data across with a lock-free single-producer single-consumer ring buffer or a realtime_tools::RealtimeBuffer, the pattern the ros2_control ecosystem itself uses.

No unbounded syscalls or logging. RCLCPP_INFO in read() formats strings and writes to a sink. The official demo throttles its logs (RCLCPP_INFO_THROTTLE) but it is a demo, and the comments mark that code as not for production. In production, count events in an atomic and report them from a non-real-time context, or use the status-message mechanism above.

No lifecycle queries. The controller manager documentation explicitly warns that get_lifecycle_state() is not real-time safe and should not be used in the loop of controllers or hardware components.

Bound every I/O call. Use non-blocking sockets or poll with a zero or tiny timeout, and decide what a missed frame means. Our example tolerates a few consecutive misses by holding the last state, then returns ERROR. The right threshold depends on how stale a state your controllers can tolerate, which is a control-bandwidth question, not a software one.

Memory and containers

memlock unlimited exists because page faults in the loop are latency bombs. Make sure the process locks its memory (the documented memlock limit is what permits it) and pre-touch buffers during on_configure. For Docker, the documentation notes you must pass capability options allowing the container to set thread priority and lock memory, and the Docker engine’s real-time scheduler settings apply on top. A container does not give you a real-time kernel; it only avoids blocking the capabilities.

Measure, do not guess

The resource manager collects execution time and periodicity statistics per component, visible in the code as read_statistics and write_statistics. Use them, plus ros2 topic hz on /joint_states and external measurement with a logic analyzer on a GPIO toggled at the end of write(). If the periodicity standard deviation is large, suspect the kernel and the scheduling policy first, then your driver. Our guide on Zephyr, FreeRTOS and NuttX for industrial RTOS use covers the microcontroller side of the same loop, and EtherCAT versus PROFINET versus OPC UA FX discusses the fieldbus choice that sets your jitter floor.

Trade-offs, Gotchas, and What Goes Wrong

Most bring-up pain falls into a few recurring patterns. Knowing them in advance saves days.

The plugin loads but nothing moves. Check ros2 control list_hardware_components. If the component is Unconfigured, on_configure failed, usually because a port is busy or a permission is missing. If it is Inactive, nobody activated it; check the controller manager logs for why activation did not happen. The interface list shows what is available and claimed.

Interface name mismatches. The URDF says velocity and the controller asks for velocities. The error mentions an interface that “does not exist” and the fix is in YAML or URDF, not code. Standard constants exist to avoid typos in C++; there is no such help in YAML.

Start-up lurch. Covered earlier: always set commands from measured state during on_activate. For velocity interfaces, start at zero; for position interfaces, copy the state.

Hidden dynamic allocation. std::function, std::string, std::vector::push_back and exceptions all allocate. A static analysis pass or running under LD_PRELOAD malloc hooks in a test (real-time tooling exists for this) finds them in CI.

Treating mocks as truth. mock_components mirrors commands to states ideally (optionally integrating with calculate_dynamics). It validates plumbing, not tuning. Gains that look perfect on the mock will oscillate on a real drive with backlash and filtering.

Stale-state bugs with async or rw_rate. If a controller computes with a state that is several cycles old, its effective phase margin drops. Record the age of state as an interface and alarm on it.

Conflating ros2_control safety with functional safety. The error path stops controllers and withdraws interfaces; it does not provide safe torque off, certified stop categories or watchdog supervision. Use hardware safety (safety relays, drive STO) for anything that can hurt a person, and treat ros2_control as the performance layer above it.

Version drift. Demo code from master may require controller_manager 5.0.0 or newer, while Jazzy runs the 4.x series. Pin the branch of every dependency, and run CI against each distribution you claim to support.

The honest limit of the framework is that it assumes a periodic, cyclic model. Event-driven devices, such as those that only publish when something changes, need an adapter: a receive thread that latches the latest value into a real-time buffer which read() copies. That adapter is where most subtle bugs hide, so test it with fault injection.

Practical Recommendations

Start by modeling your hardware honestly. Use one SystemInterface per physical bus, expose only the interfaces you really implement, and keep all vendor-specific detail behind a small transport class you can fake in tests. Write the mock-first URDF switch on day one, since it lets controller and MoveIt work proceed while the hardware is still being wired. Target the on_init overload that takes HardwareComponentInterfaceParams, lean on framework-managed interface export, and avoid the deprecated entry points that the Jazzy and Kilted headers already flag.

Treat real-time as a requirement with a budget, not an aspiration: set up the realtime group, choose a kernel deliberately, pin the process, and measure with the framework’s own statistics before and after each change. For robots with more than one bus, use rw_rate for slower devices and reserve async for components with genuinely unbounded I/O. For a broader robotics stack, pair this with our guides on behavior trees for task planning and MoveIt 2 versus Tesseract.

Pre-commissioning checklist:

  • [ ] on_init validates every joint interface and required parameter, and returns ERROR loudly.
  • [ ] Bus open in on_configure, drives enabled only in on_activate, reverse order on the way down.
  • [ ] Commands initialized from measured state at activation.
  • [ ] read() and write() free of allocation, shared mutexes, blocking calls and logging.
  • [ ] Fault handling returns DEACTIVATE or ERROR deliberately and has a fault-injection test.
  • [ ] Mock path and real path share one URDF and one controller YAML.
  • [ ] realtime group, rtprio and memlock limits configured; kernel choice documented.
  • [ ] Cycle statistics and cyclictest results recorded as a baseline.
  • [ ] Hardware safety circuit independent of ros2_control.
  • [ ] CI builds against every distribution you support (Jazzy, Kilted, Lyrical).

Frequently Asked Questions

What is a ros2_control hardware interface?

It is a C++ plugin class derived from SystemInterface, ActuatorInterface or SensorInterface that adapts a physical device to the ros2_control contract. It exports state interfaces for measured values and command interfaces for setpoints, implements lifecycle callbacks for configure and activate, and implements read() and write() that the controller manager calls every cycle. Controllers stay hardware-agnostic because they only see interfaces.

When should I use SystemInterface instead of ActuatorInterface?

Use SystemInterface when several joints share one communication channel or one coordinated transaction, which is the case for most arms, humanoids and mobile bases. Use ActuatorInterface for an independent single actuator with its own driver, and SensorInterface for read-only devices. A system component can also export GPIO and sensor entries, so you rarely need many plugins for one robot.

How do I test a hardware interface without a robot?

Switch the URDF plugin to mock_components/GenericSystem, which mirrors commands to states and can integrate velocity into position with calculate_dynamics. That tests controllers, launch files and MoveIt integration. Then unit-test your real plugin against a fake transport with gmock and inject faults, and finally use loopback devices such as virtual CAN before powering the physical robot.

Do I need PREEMPT_RT to use ros2_control?

No. The controller manager attempts SCHED_FIFO scheduling at priority 50 on any kernel, provided your user may set real-time priorities and lock memory. A real-time kernel such as PREEMPT_RT improves worst-case latency and is recommended by the documentation for the best results. Whether you need it depends on loop rate and tolerance; measure jitter with cyclictest and the framework’s statistics before deciding.

What changed in the API between Jazzy, Kilted and Lyrical?

On the Jazzy and Kilted branches, the on_init(const HardwareComponentInterfaceParams &) overload is the preferred one and the HardwareInfo overload is deprecated, and the framework creates interfaces from the URDF. Kilted adds the hardware status message hooks and an async_params structure. Lyrical, released 22 May 2026, builds on this; check the release notes of its ros2_control version, which I did not verify.

Why does my component disappear after a read error?

When read() returns anything other than OK, the resource manager calls the component’s error handling and removes its interfaces from the available list, so dependent controllers are deactivated. This is by design. Fix the cause, then reconfigure and reactivate the component, or add retry logic that tolerates a small number of missed frames before returning ERROR.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *