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 fail their first hardware bring-up not in the planner but in the 1-millisecond gap between “the controller computed a command” and “the motor actually moved.” That gap is exactly where a ros2_control hardware interface lives, and getting it wrong produces jitter, dropped commands, or a robot that lurches because someone called malloc inside the real-time loop. With ROS 2 Jazzy and Kilted now the supported LTS-class releases in 2026, the hardware-interface API has stabilized enough to teach it precisely. By the end of this tutorial you will have written a compilable SystemInterface component, exported it as a pluginlib plugin, described it in URDF, and spawned a real controller against it.

What this covers: the ros2_control architecture, a minimal C++ SystemInterface, the plugin export and description XML, the <ros2_control> URDF block, a controllers.yaml, the read/update/write loop, and the real-time discipline that keeps it deterministic.

Context and Background

Before ros2_control, every robot driver reinvented the same plumbing: a node that subscribed to commands, talked to a motor bus, and published joint states. The result was a fragmentation problem — a MoveIt trajectory written for one arm would not run on another because the command topic, the state message, and the timing model all differed. ros2_control fixes this by standardizing the boundary between controllers and hardware, not the hardware itself.

The framework, documented at control.ros.org, splits the world into two halves. On one side are controllers — reusable algorithms like joint_trajectory_controller or forward_command_controller that read state and compute commands. On the other side are hardware components — thin drivers that you write, which expose state interfaces (what the robot reports) and command interfaces (what the robot accepts). Between them sits the controller_manager, which owns a real-time update loop and a Resource Manager that arbitrates who is allowed to write to which joint.

This separation is why ros2_control scales. The same joint_trajectory_controller binary drives a simulated arm, a CAN-bus arm, and an EtherCAT arm, because each only differs in the hardware component you plug underneath. If you are coming from earlier distributions, the API shifted meaningfully between Humble and Jazzy — see our ROS 2 Jazzy Jalisco migration guide from Humble for the breaking changes that affect hardware interfaces specifically. The payoff is that your driver code becomes the only hardware-specific thing in the stack.

The ros2_control Reference Architecture

A ros2_control hardware interface is a C++ class, loaded as a plugin at runtime, that translates between the framework’s standardized read/write contract and your physical bus. The controller_manager calls your component’s read() to pull sensor state and write() to push commands, on a fixed period, inside one real-time thread. Everything else — controllers, the Resource Manager, lifecycle transitions — exists to make those two calls safe and deterministic.

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

Figure 1: The ros2_control architecture. Controllers claim command and state interfaces through the Resource Manager, which routes them to your hardware component.

The diagram shows the full ownership chain. The URDF declares which joints and interfaces exist. The Resource Manager parses that, loads your hardware plugin, and calls export_state_interfaces() and export_command_interfaces() to learn what handles your component publishes. Controllers then claim those handles by name — and the Resource Manager refuses to let two controllers claim the same command interface, which is what prevents two algorithms from fighting over one motor.

This claiming model is the conceptual heart of ros2_control, and it is worth dwelling on. A state interface can be read by any number of controllers simultaneously — a broadcaster, a trajectory controller, and a safety monitor can all watch joint1/position at once. A command interface, by contrast, is exclusive: only one controller may hold the write handle for joint1/velocity at a time. That asymmetry is enforced in software, so a misconfiguration surfaces as a clean “interface already claimed” error at activation rather than as two controllers silently overwriting each other mid-motion. When you later switch controllers at runtime — say from a trajectory controller to a teleop velocity controller — the Resource Manager performs an atomic handoff of the command interface, and your hardware never sees a gap.

Three component types, one base each

ros2_control gives you three abstract bases, and choosing correctly avoids a lot of boilerplate:

  • SystemInterface — a complete robot with multiple joints that both reports state and accepts commands. A 6-DOF arm or a mobile base is a System. This is the most common choice and the one we build below.
  • ActuatorInterface — a single read/write actuator. Useful when you compose a robot from independent motor modules, each a separate plugin.
  • SensorInterface — read-only. A force/torque sensor or IMU exposes state interfaces but no command interfaces.

Interfaces are typed, named handles

A state interface is identified by <joint>/<interface_type>, for example joint1/position. The standard interface types are position, velocity, effort, and acceleration, but the names are just strings — you can export joint1/temperature if your firmware reports it. A command interface is the same idea in the write direction: joint1/velocity means “I accept a velocity setpoint for joint1.” A controller declares which interface types it needs; the match happens by string. This stringly-typed naming is powerful and is also the single most common source of silent bugs, which we return to in the gotchas section.

The lifecycle wraps the loop

Hardware components are managed lifecycle nodes. They move through states — UNCONFIGURED, INACTIVE, ACTIVE — via callbacks you implement: on_init, on_configure, on_activate, on_deactivate. The rule that matters: read() and write() only run when the component is ACTIVE. You open your bus connection in on_configure, enable torque in on_activate, and keep the per-cycle callbacks free of any setup work. Allocating, connecting, or locking belongs in the lifecycle callbacks, never in read() or write().

Walk-through: Writing a Minimal SystemInterface

Here is a minimal but correct SystemInterface for a two-joint robot exposing position state and velocity command interfaces. The code targets the Jazzy/Kilted hardware_interface API, where callbacks return hardware_interface::CallbackReturn and the per-cycle calls return hardware_interface::return_type.

Sequence diagram of the ros2_control read update write real-time control loop

Figure 2: The control loop. Each period the controller_manager calls read, then every controller’s update, then write — all in one thread.

The sequence above is the contract your read() and write() must honor. They receive the current time and the elapsed period, and they must return quickly and deterministically. The header:

// rrbot_system.hpp
#pragma once
#include <string>
#include <vector>
#include "hardware_interface/system_interface.hpp"
#include "rclcpp_lifecycle/state.hpp"

namespace my_robot
{
class RRBotSystem : public hardware_interface::SystemInterface
{
public:
  hardware_interface::CallbackReturn on_init(
    const hardware_interface::HardwareInfo & info) 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;

  std::vector<hardware_interface::StateInterface> export_state_interfaces() override;
  std::vector<hardware_interface::CommandInterface> export_command_interfaces() 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::vector<double> hw_positions_;
  std::vector<double> hw_velocity_commands_;
};
}  // namespace my_robot

The implementation. Note that every vector is sized once, in on_init, so the per-cycle calls never allocate:

// rrbot_system.cpp
#include "rrbot_system.hpp"
#include "hardware_interface/types/hardware_interface_type_values.hpp"
#include "pluginlib/class_list_macros.hpp"

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

CallbackReturn RRBotSystem::on_init(const hardware_interface::HardwareInfo & info)
{
  if (hardware_interface::SystemInterface::on_init(info) != CallbackReturn::SUCCESS)
    return CallbackReturn::ERROR;

  // Size buffers once. info_.joints is populated from the URDF.
  hw_positions_.assign(info_.joints.size(), 0.0);
  hw_velocity_commands_.assign(info_.joints.size(), 0.0);

  // Validate the URDF contract: each joint must expose exactly the
  // interfaces this driver supports, or fail loudly at startup.
  for (const auto & joint : info_.joints)
  {
    if (joint.command_interfaces.size() != 1 ||
        joint.command_interfaces[0].name != hardware_interface::HW_IF_VELOCITY)
      return CallbackReturn::ERROR;
    if (joint.state_interfaces.size() != 1 ||
        joint.state_interfaces[0].name != hardware_interface::HW_IF_POSITION)
      return CallbackReturn::ERROR;
  }
  return CallbackReturn::SUCCESS;
}

CallbackReturn RRBotSystem::on_configure(const rclcpp_lifecycle::State &)
{
  // Open the bus, handshake with firmware. Allowed to block here.
  std::fill(hw_positions_.begin(), hw_positions_.end(), 0.0);
  std::fill(hw_velocity_commands_.begin(), hw_velocity_commands_.end(), 0.0);
  return CallbackReturn::SUCCESS;
}

CallbackReturn RRBotSystem::on_activate(const rclcpp_lifecycle::State &)
{
  // Enable torque / start the motors. Seed commands to a safe value.
  std::fill(hw_velocity_commands_.begin(), hw_velocity_commands_.end(), 0.0);
  return CallbackReturn::SUCCESS;
}

CallbackReturn RRBotSystem::on_deactivate(const rclcpp_lifecycle::State &)
{
  // Disable torque. Bus stays open until on_cleanup.
  return CallbackReturn::SUCCESS;
}

std::vector<hardware_interface::StateInterface> RRBotSystem::export_state_interfaces()
{
  std::vector<hardware_interface::StateInterface> state_interfaces;
  for (size_t i = 0; i < info_.joints.size(); ++i)
    state_interfaces.emplace_back(
      info_.joints[i].name, hardware_interface::HW_IF_POSITION, &hw_positions_[i]);
  return state_interfaces;
}

std::vector<hardware_interface::CommandInterface> RRBotSystem::export_command_interfaces()
{
  std::vector<hardware_interface::CommandInterface> command_interfaces;
  for (size_t i = 0; i < info_.joints.size(); ++i)
    command_interfaces.emplace_back(
      info_.joints[i].name, hardware_interface::HW_IF_VELOCITY, &hw_velocity_commands_[i]);
  return command_interfaces;
}

return_type RRBotSystem::read(const rclcpp::Time &, const rclcpp::Duration & period)
{
  // RT-safe: only copy from a pre-filled shared buffer here.
  // Demo integrator: position += velocity * dt.
  const double dt = period.seconds();
  for (size_t i = 0; i < hw_positions_.size(); ++i)
    hw_positions_[i] += hw_velocity_commands_[i] * dt;
  return return_type::OK;
}

return_type RRBotSystem::write(const rclcpp::Time &, const rclcpp::Duration &)
{
  // RT-safe: push hw_velocity_commands_ to the shared buffer the bus reads.
  return return_type::OK;
}
}  // namespace my_robot

PLUGINLIB_EXPORT_CLASS(my_robot::RRBotSystem, hardware_interface::SystemInterface)

The PLUGINLIB_EXPORT_CLASS macro registers the class so the Resource Manager can load it by name. That name comes from a plugin description XML you ship and reference in CMakeLists.txt via pluginlib_export_plugin_description_file(hardware_interface my_robot.xml):

<!-- my_robot.xml -->
<library path="my_robot_hardware">
  <class name="my_robot/RRBotSystem"
         type="my_robot::RRBotSystem"
         base_class_type="hardware_interface::SystemInterface">
    <description>A two-joint demo SystemInterface for ros2_control.</description>
  </class>
</library>

Describing the hardware in URDF

The Resource Manager learns about your component from the <ros2_control> tag, usually expanded from a xacro macro. The <plugin> string must match the name in the plugin XML exactly:

<ros2_control name="RRBotSystem" type="system">
  <hardware>
    <plugin>my_robot/RRBotSystem</plugin>
  </hardware>
  <joint name="joint1">
    <command_interface name="velocity"/>
    <state_interface name="position"/>
  </joint>
  <joint name="joint2">
    <command_interface name="velocity"/>
    <state_interface name="position"/>
  </joint>
</ros2_control>

Building the package

The component is a shared library that pluginlib loads at runtime, so the build must export both the library and the plugin description. The relevant CMakeLists.txt fragment:

add_library(my_robot_hardware SHARED src/rrbot_system.cpp)
target_include_directories(my_robot_hardware PUBLIC include)
ament_target_dependencies(my_robot_hardware
  hardware_interface pluginlib rclcpp rclcpp_lifecycle)

# Make the plugin discoverable by the Resource Manager.
pluginlib_export_plugin_description_file(hardware_interface my_robot.xml)

install(TARGETS my_robot_hardware DESTINATION lib)
install(DIRECTORY include/ DESTINATION include)
ament_export_libraries(my_robot_hardware)

The package.xml needs hardware_interface, pluginlib, and rclcpp_lifecycle as dependencies. After colcon build, confirm the plugin is visible to ros2_control with ros2 control list_hardware_components once the manager is running — if your class name is missing there, the description file did not get installed and the URDF <plugin> lookup will fail before any interface is exported.

Configuring and spawning controllers

Controllers are configured in a controllers.yaml loaded by the controller_manager. This sets the update rate and declares a forward_command_controller writing velocity, plus the always-present joint_state_broadcaster:

controller_manager:
  ros__parameters:
    update_rate: 500  # Hz - the read/update/write loop frequency
    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster
    velocity_controller:
      type: forward_command_controller/ForwardCommandController

velocity_controller:
  ros__parameters:
    joints:
      - joint1
      - joint2
    interface_name: velocity

Launch the stack with ros2_control_node (or a controller_manager in your launch file), then spawn the controllers and inspect them with the CLI:

# Spawn controllers (the spawner activates them via the manager)
ros2 run controller_manager spawner joint_state_broadcaster
ros2 run controller_manager spawner velocity_controller

# Inspect the running system
ros2 control list_hardware_interfaces
ros2 control list_controllers

# Send a velocity command
ros2 topic pub /velocity_controller/commands std_msgs/msg/Float64MultiArray \
  "{data: [0.5, -0.3]}"

Swap forward_command_controller for joint_trajectory_controller when you need smooth, time-parameterized motion from MoveIt or Nav2. For visualizing the resulting joint state and command streams live, a tool like Rerun pairs well — see our Rerun.io robotics telemetry visualization tutorial.

Trade-offs, Gotchas, and What Goes Wrong

The lifecycle and the loop are unforgiving, and a few mistakes recur on nearly every first bring-up.

ros2_control hardware component lifecycle states and the real-time update loop

Figure 3: The lifecycle. read and write only execute in the ACTIVE state; all setup belongs in on_configure and on_activate.

Interface name mismatches fail silently-ish. If your URDF declares state_interface name="position" but your controller asks for velocity state, the controller will simply refuse to activate, often with a terse “interface not available” log. The strings must agree across the URDF, the export functions, and the controller config. Standardize on the HW_IF_* constants in code so a typo becomes a compile error rather than a runtime mystery.

Allocations and locks in read()/write() destroy determinism. Anything that can block — malloc, std::mutex, logging to disk, a synchronous socket call — can stall the entire control thread past its deadline. A 500 Hz loop has 2 ms per cycle; one page fault blows that budget. Pre-size every buffer in on_init, and move all bus I/O to a separate non-RT thread that exchanges data through a lock-free buffer, as Figure 4 shows.

Lifecycle ordering bites people. Returning ERROR from on_init because the URDF lacks an expected interface is correct and good — it fails fast. But opening a serial port in on_init instead of on_configure means the port opens even when the component is only being introspected, and may never get closed. Match each side effect to the right callback.

update_rate is not free. Setting 1000 Hz on a Linux kernel without PREEMPT_RT and SCHED_FIFO will produce missed deadlines under load. Measure your actual jitter before trusting the number.

Real-Time Discipline

The reason read() and write() must be deterministic is that they run inside the controller_manager’s update thread, which you should pin to a real-time scheduling policy. On a PREEMPT_RT kernel you run the process under SCHED_FIFO so it preempts ordinary tasks. Crucially, the RT thread must never touch the bus directly — hardware I/O is non-deterministic — so it exchanges data with a non-RT I/O thread through a lock-free structure.

Real-time control thread exchanging data with a non-real-time IO thread through a lock-free buffer

Figure 4: Real-time separation. The SCHED_FIFO loop only loads and stores into a lock-free buffer; a separate thread does the blocking bus I/O.

In practice that means an atomic double-buffer or a single-producer single-consumer ring between read()/write() and your EtherCAT or CAN driver. The RT side does a wait-free load or store; the I/O side does the slow work. A common pattern is two pre-allocated state structs and an std::atomic<int> index: the I/O thread fills the inactive struct from the bus, then flips the index with a single atomic store; read() does one atomic load and copies the now-current struct. No struct is ever resized, no mutex is taken, and the worst case the RT thread sees is one slightly stale cycle of data rather than a blocked thread. For EtherCAT specifically, the bus cycle and the control cycle are often locked together so the freshest frame is always available at read().

This is also why MoveIt and motion-planning stacks layer cleanly on top — they run in their own non-RT nodes and only ever touch the standardized command interface. If you are weighing planners that sit above this loop, our comparison of MoveIt 2 vs Tesseract for robot motion planning covers the trade-offs.

Practical Recommendations

Start in simulation with a fake or mock component before touching real motors, so you can validate naming and controller activation without risking hardware. Get a forward_command_controller moving a joint first; only then graduate to joint_trajectory_controller. Treat ros2 control list_hardware_interfaces and list_controllers as your primary debugging tools — most failures show up there as an unclaimed or unavailable interface. Keep all I/O off the RT thread from day one; retrofitting lock-free comms later is painful.

Checklist for a clean hardware interface:

  • [ ] Pick the right base: SystemInterface, ActuatorInterface, or SensorInterface.
  • [ ] Size all buffers in on_init; validate the URDF interface contract there.
  • [ ] Open the bus in on_configure, enable torque in on_activate.
  • [ ] Use HW_IF_* constants so interface names are compile-checked.
  • [ ] Keep read() and write() allocation-free and lock-free.
  • [ ] Match <plugin> string in URDF to the plugin description XML name.
  • [ ] Verify with ros2 control list_hardware_interfaces before spawning controllers.
  • [ ] Run under PREEMPT_RT + SCHED_FIFO for any rate above a few hundred Hz.

Frequently Asked Questions

What is the difference between SystemInterface and ActuatorInterface?

SystemInterface models a complete multi-joint robot that both reports state and accepts commands through one component — a 6-DOF arm or a mobile base. ActuatorInterface models a single read/write actuator, useful when you build a robot from independent motor modules each loaded as its own plugin. Use a System when one driver owns the whole bus; use Actuators when motors are physically and logically independent units you compose.

Why must read() and write() avoid allocations and locks?

Because they run inside the controller_manager’s real-time update thread on a fixed deadline. A 500 Hz loop has 2 ms per cycle. Any malloc, mutex, or blocking call can stall past that deadline and cause missed control cycles, which manifests as jitter or unstable motion. Pre-allocate everything in on_init and push all bus I/O to a separate non-RT thread via a lock-free buffer.

How do I spawn a controller in ros2_control?

Use the spawner: ros2 run controller_manager spawner <controller_name>. It loads, configures, and activates the controller through the running controller_manager using your controllers.yaml. The joint_state_broadcaster is almost always spawned first so other nodes see joint state. Verify the result with ros2 control list_controllers, which shows each c

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 *