What Problem ros2_control Actually Solves

Before ros2_control existed as a standard, every ROS robot driver reinvented the same wheel: reading joint encoders, running a control loop, handling trajectory interpolation, and exposing that to the rest of the software stack — all custom, all slightly incompatible with the next robot's driver. ros2_control's core idea is separation of concerns: hardware-specific code (talking to your actual motors) is isolated into a small, swappable component, while control logic (trajectory following, PID loops, gravity compensation) lives in reusable, hardware-agnostic controllers that work identically whether you're driving a simulated arm, a Dynamixel-based DIY arm, or an EtherCAT industrial servo system.

Editorial flowchart of ros2_control: user node, controller manager, hardware interface and robot or simulator.
Original editorial illustration for this article. Conceptual illustration prepared for this article.

This matters practically: once you've written a correct hardware interface for your robot, you get access to the entire ecosystem of existing controllers (joint trajectory controller, forward position controller, admittance controller) and tools (MoveIt 2, rqt_joint_trajectory_controller, ros2_control's built-in diagnostics) without writing any of that logic yourself.

The Three Layers of the Architecture

  1. Hardware Interface — the plugin you write that talks to real (or simulated) actuators and sensors. It exposes standardized state interfaces (position, velocity, effort feedback) and accepts standardized command interfaces (position, velocity, or effort setpoints).
  2. Controller Manager — a ROS 2 node that loads your hardware interface, loads one or more controllers, and runs the update loop at a configured frequency, routing data between them.
  3. Controllers — hardware-agnostic algorithms (like joint_trajectory_controller) that consume state interfaces and produce command interfaces to drive the robot toward a goal.

Step 1 — Declaring Hardware in URDF

ros2_control configuration starts in your robot's URDF/xacro description, inside a <ros2_control> tag that declares each joint's available interfaces:

XML — robot_description.urdf.xacro (excerpt)
<ros2_control name="SixDofArmSystem" type="system">
  <hardware>
    <plugin>my_robot_hardware/SixDofArmHardwareInterface</plugin>
    <param name="serial_port">/dev/ttyUSB0</param>
    <param name="baud_rate">1000000</param>
  </hardware>

  <joint name="joint_1">
    <command_interface name="position"/>
    <state_interface name="position"/>
    <state_interface name="velocity"/>
  </joint>
  <joint name="joint_2">
    <command_interface name="position"/>
    <state_interface name="position"/>
    <state_interface name="velocity"/>
  </joint>
  <!-- Repeat for joint_3 through joint_6 -->
</ros2_control>

The plugin tag references the C++ class you'll write in the next step — this is how the controller manager knows which hardware interface implementation to load for this robot.

Step 2 — Writing the Hardware Interface Plugin

The hardware interface is a C++ class inheriting from hardware_interface::SystemInterface. It implements four key lifecycle methods: reading current joint states, and writing commanded joint values. Here's a simplified but functionally accurate skeleton:

C++ — six_dof_arm_hardware_interface.hpp
#include "hardware_interface/system_interface.hpp"

class SixDofArmHardwareInterface : public hardware_interface::SystemInterface {
public:
  hardware_interface::CallbackReturn on_init(
      const hardware_interface::HardwareInfo & info) override {
    // Parse URDF params (serial_port, baud_rate) and allocate joint buffers
    joint_positions_.resize(info.joints.size(), 0.0);
    joint_commands_.resize(info.joints.size(), 0.0);
    return hardware_interface::CallbackReturn::SUCCESS;
  }

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

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

  hardware_interface::return_type read(const rclcpp::Time &, const rclcpp::Duration &) override {
    // Poll real hardware (serial bus, EtherCAT, etc.) and update joint_positions_
    // e.g. readServoPositions(joint_positions_);
    return hardware_interface::return_type::OK;
  }

  hardware_interface::return_type write(const rclcpp::Time &, const rclcpp::Duration &) override {
    // Send joint_commands_ to real hardware
    // e.g. writeServoTargets(joint_commands_);
    return hardware_interface::return_type::OK;
  }

private:
  std::vector<double> joint_positions_;
  std::vector<double> joint_commands_;
};

The read() and write() methods are called at the controller manager's update rate — this is where you'd insert the actual serial protocol code (like the bus servo packet structure covered in our servo motor guide) or an EtherCAT process data exchange. Everything above this layer never needs to know which one you're using.

The plugin must be exported and registered. A working hardware interface also requires a PLUGINLIB_EXPORT_CLASS macro call and a plugin XML description file so pluginlib can dynamically load your class by the name referenced in the URDF. Omitting this is one of the most common first-time build errors — the controller manager fails at runtime with a "plugin not found" error rather than a compile error.

Step 3 — Configuring Controllers via YAML

With the hardware interface in place, you configure which controllers to run in a YAML file loaded by the controller manager. A typical 6-DOF arm setup uses a joint_trajectory_controller for coordinated motion and a joint_state_broadcaster to publish current state:

YAML — controllers.yaml
controller_manager:
  ros__parameters:
    update_rate: 500  # Hz

    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster

    arm_trajectory_controller:
      type: joint_trajectory_controller/JointTrajectoryController

arm_trajectory_controller:
  ros__parameters:
    joints:
      - joint_1
      - joint_2
      - joint_3
      - joint_4
      - joint_5
      - joint_6
    command_interfaces:
      - position
    state_interfaces:
      - position
      - velocity
    constraints:
      goal_time: 0.5
      stopped_velocity_tolerance: 0.02

The update_rate here determines how frequently the controller manager calls your hardware interface's read()/write() methods — 500Hz is a common starting point for servo-based arms; industrial EtherCAT systems often run at 1kHz or higher. Setting this higher than your actual hardware can physically respond to just wastes CPU cycles without improving control quality.

Step 4 — Launching and Verifying

A launch file starts the controller manager with your URDF and controller YAML, then spawns the controllers:

Python — robot_control.launch.py (excerpt)
Node(
    package="controller_manager",
    executable="ros2_control_node",
    parameters=[robot_description, controller_config_path],
    output="screen",
),
Node(
    package="controller_manager",
    executable="spawner",
    arguments=["joint_state_broadcaster"],
),
Node(
    package="controller_manager",
    executable="spawner",
    arguments=["arm_trajectory_controller"],
),

Once running, you can inspect and interact with the system directly from the command line — genuinely useful for debugging before writing any client code:

Shell — verification commands
# List all loaded controllers and their state
ros2 control list_controllers

# List available hardware interfaces
ros2 control list_hardware_interfaces

# Echo current joint states being published
ros2 topic echo /joint_states

Connecting to MoveIt 2

ros2_control handles execution — actually moving the joints along a trajectory. It doesn't do motion planning. That's MoveIt 2's job: given a target pose, it plans a collision-free joint trajectory and sends it as a FollowJointTrajectory action goal, which your joint_trajectory_controller receives and executes. The two systems are configured to work together through MoveIt's moveit_controllers.yaml, which maps MoveIt's planning groups to the specific ros2_control controller that should execute their trajectories:

YAML — moveit_controllers.yaml (excerpt)
moveit_simple_controller_manager:
  controller_names:
    - arm_trajectory_controller

  arm_trajectory_controller:
    action_ns: follow_joint_trajectory
    type: FollowJointTrajectory
    joints:
      - joint_1
      - joint_2
      - joint_3
      - joint_4
      - joint_5
      - joint_6

This is the seam where inverse kinematics (covered in our IK solver article) and trajectory execution meet: MoveIt handles the planning and IK internally, and hands off only the resulting joint trajectory to ros2_control for execution — you generally don't call your own IK code directly when using this stack.

Real-Time Considerations

For simulation or slow-moving educational arms, a standard Linux kernel works fine. For anything driving physical actuators at meaningful speed, scheduling jitter matters: if the OS occasionally delays your control loop by a few milliseconds, a fast-moving joint can overshoot or stutter. Production deployments typically run on a PREEMPT_RT patched kernel, which guarantees more consistent timing for the process running the controller manager. This is a deployment/OS-level concern, not something configured within ros2_control itself, but it directly affects whether your configured update_rate is actually achievable in practice.

Common Integration Mistakes

"The hardware interface is the only part of this stack that needs to know anything about your specific robot. Every mistake in that boundary shows up as a mystery somewhere else in the pipeline."
— Robotics Engineering, Motion Control Editorial Notes

Open-Source References Worth Studying

📦
ros2_control — the core framework repository, including the demo hardware interfaces referenced throughout this article. github.com/ros-controls/ros2_control
📦
ros2_controllers — the standard controller implementations (joint trajectory controller, forward command controllers, etc.) usable with any compliant hardware interface. github.com/ros-controls/ros2_controllers
📦
ros2_control_demos — official example packages with complete, working hardware interface implementations for reference. github.com/ros-controls/ros2_control_demos
📦
MoveIt 2 — motion planning framework that integrates with ros2_control via FollowJointTrajectory actions. moveit.ros.org

Sources and Further Reading

Code in this article is a simplified, illustrative implementation of the ros2_control hardware interface pattern. Production deployments should follow the complete API contract documented in the official ros2_control reference, including proper lifecycle state transitions (configure, activate, deactivate) omitted here for clarity.

Written by the Robotics Engineering Editorial Team
Technical content focused on 6-DOF robot arm design, motion control, and ROS 2 software architecture. Code examples are simplified for clarity — refer to the official ros2_control documentation for the complete hardware interface lifecycle before production deployment.

What is ros2_control and how is it different from writing your own driver?

ros2_control is a standardized framework that separates hardware communication from control algorithms. Instead of writing a custom PID loop and trajectory follower for every robot, you write a small hardware interface plugin and reuse standard, tested controllers that work identically across different robots.

What's the difference between a hardware interface and a controller in ros2_control?

The hardware interface is the layer that talks to your actual motors and encoders, specific to your robot. The controller is hardware-agnostic logic that runs on top of it, consuming standardized state data and producing standardized commands regardless of the physical hardware underneath.

Do I need real-time Linux (PREEMPT_RT) to use ros2_control?

Not for development or simulation, but for control loops running at high update rates on real hardware, a standard kernel's scheduling jitter can cause instability. PREEMPT_RT is recommended for production deployments controlling physical actuators directly.

Can ros2_control talk directly to an EtherCAT servo drive?

Yes, but not natively out of the box — you write a hardware interface plugin that internally uses an EtherCAT master library (such as SOEM or IgH EtherCAT Master) and exposes the resulting joint data through the standard ros2_control interface.

Related Reading

Next: the 6-DOF Robot Arm Master Guide

Architecture, programming, calibration and application fundamentals in one place.

Read Full Guide →