← Back to Blog
manipulationros2tutorialrobot-arm

ROS 2 A to Z (Part 4): ros2_control and Hardware

Connect ROS 2 to real hardware — write hardware interfaces for motor drivers and read encoders with ros2_control framework.

Nguyen Anh Tuan26 tháng 3, 202610 min read
ROS 2 A to Z (Part 4): ros2_control and Hardware

ros2_control — Bridge Between ROS 2 and Real Hardware

Once you can run a robot in simulation with Nav2, the next question is always: how do I control real motors? This is where ros2_control shines. This framework provides a standardized abstraction layer between controllers (control algorithms) and hardware interfaces (hardware communication), allowing you to decouple control logic from specific drivers.

This article continues the ROS 2 series — if you haven't read them, start with Part 1: Setup and First Node, Part 2: Topics, Services and Actions, and Part 3: Nav2.

Robot arm connected to control board

ros2_control Architecture

ros2_control has 3 main components working together:

┌─────────────────────────────────────────────────┐
│                Controller Manager                │
│  (Load, configure, activate controllers)         │
├──────────────┬──────────────┬───────────────────┤
│ joint_state  │ forward_cmd  │ diff_drive        │
│ _broadcaster │ _controller  │ _controller       │
│              │              │                   │
│ (read state) │ (send command)│ (diff drive)     │
└──────┬───────┴──────┬───────┴──────┬────────────┘
       │              │              │
       │    Command & State interfaces
       │              │              │
┌──────▼──────────────▼──────────────▼────────────┐
│              Hardware Interface                   │
│  (SystemInterface / ActuatorInterface /           │
│   SensorInterface)                               │
├─────────────────────────────────────────────────┤
│  read() → read encoders, IMU                     │
│  write() → send PWM, velocity command            │
└──────────────────┬──────────────────────────────┘
                   │
          ┌────────▼────────┐
          │   Hardware      │
          │  (Motor, Encoder│
          │   IMU, Gripper) │
          └─────────────────┘

Core Concepts:

Concept Role
Controller Manager Central node, manages lifecycle of all controllers
Hardware Interface Plugin communicates directly with hardware via serial/CAN/SPI
State Interface Read state (position, velocity, effort) from encoders/sensors
Command Interface Send commands (position, velocity, effort) to motor drivers
Controller Control algorithm — reads state, calculates, sends command

Install ros2_control

# Install ros2_control packages
sudo apt install -y \
  ros-humble-ros2-control \
  ros-humble-ros2-controllers \
  ros-humble-controller-manager \
  ros-humble-joint-state-broadcaster \
  ros-humble-forward-command-controller \
  ros-humble-diff-drive-controller

# For mock hardware testing
sudo apt install -y ros-humble-ros2-control-test-assets

Step 1: Describe Hardware in URDF

ros2_control uses <ros2_control> tags in URDF to declare hardware interfaces. Here's an example for a 2-wheel differential drive robot with DC motors and encoders:

<!-- ~/ros2_ws/src/my_robot_hardware/urdf/robot.urdf.xacro -->
<?xml version="1.0"?>
<robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="my_diff_robot">

  <!-- Base link -->
  <link name="base_link">
    <visual>
      <geometry><box size="0.3 0.2 0.1"/></geometry>
    </visual>
  </link>

  <!-- Left wheel -->
  <link name="left_wheel">
    <visual>
      <geometry><cylinder radius="0.05" length="0.04"/></geometry>
    </visual>
  </link>
  <joint name="left_wheel_joint" type="continuous">
    <parent link="base_link"/>
    <child link="left_wheel"/>
    <origin xyz="0.0 0.12 0.0" rpy="${-pi/2} 0 0"/>
    <axis xyz="0 0 1"/>
  </joint>

  <!-- Right wheel (similar) -->
  <link name="right_wheel">
    <visual>
      <geometry><cylinder radius="0.05" length="0.04"/></geometry>
    </visual>
  </link>
  <joint name="right_wheel_joint" type="continuous">
    <parent link="base_link"/>
    <child link="right_wheel"/>
    <origin xyz="0.0 -0.12 0.0" rpy="${-pi/2} 0 0"/>
    <axis xyz="0 0 1"/>
  </joint>

  <!-- ros2_control hardware interface -->
  <ros2_control name="DiffDriveSystem" type="system">

    <!-- Plugin: use custom or mock -->
    <hardware>
      <plugin>my_robot_hardware/DiffDriveHardware</plugin>
      <param name="serial_port">/dev/ttyUSB0</param>
      <param name="baud_rate">115200</param>
      <param name="encoder_ticks_per_rev">1440</param>
      <param name="wheel_radius">0.05</param>
    </hardware>

    <!-- Left wheel joint -->
    <joint name="left_wheel_joint">
      <command_interface name="velocity">
        <param name="min">-10.0</param>
        <param name="max">10.0</param>
      </command_interface>
      <state_interface name="position"/>
      <state_interface name="velocity"/>
    </joint>

    <!-- Right wheel joint -->
    <joint name="right_wheel_joint">
      <command_interface name="velocity">
        <param name="min">-10.0</param>
        <param name="max">10.0</param>
      </command_interface>
      <state_interface name="position"/>
      <state_interface name="velocity"/>
    </joint>

  </ros2_control>

</robot>

Explanation:

Step 2: Write Custom Hardware Interface (C++)

This is the most critical part — write a plugin to communicate with real hardware via serial:

// ~/ros2_ws/src/my_robot_hardware/src/diff_drive_hardware.cpp
#include "hardware_interface/system_interface.hpp"
#include "hardware_interface/handle.hpp"
#include "hardware_interface/hardware_info.hpp"
#include "hardware_interface/types/hardware_interface_return_values.hpp"
#include "rclcpp/rclcpp.hpp"
#include <serial/serial.h>  // libserial

namespace my_robot_hardware
{

class DiffDriveHardware : public hardware_interface::SystemInterface
{
public:
  // Initialization — read params from URDF
  hardware_interface::CallbackReturn on_init(
    const hardware_interface::HardwareInfo & info) override
  {
    if (SystemInterface::on_init(info) != CallbackReturn::SUCCESS) {
      return CallbackReturn::ERROR;
    }

    // Read params
    serial_port_ = info_.hardware_parameters["serial_port"];
    baud_rate_ = std::stoi(info_.hardware_parameters["baud_rate"]);
    ticks_per_rev_ = std::stoi(info_.hardware_parameters["encoder_ticks_per_rev"]);
    wheel_radius_ = std::stod(info_.hardware_parameters["wheel_radius"]);

    // Initialize variables
    hw_positions_.resize(2, 0.0);
    hw_velocities_.resize(2, 0.0);
    hw_commands_.resize(2, 0.0);

    return CallbackReturn::SUCCESS;
  }

  // Open serial connection when activated
  hardware_interface::CallbackReturn on_activate(
    const rclcpp_lifecycle::State &) override
  {
    try {
      serial_.setPort(serial_port_);
      serial_.setBaudrate(baud_rate_);
      serial::Timeout timeout = serial::Timeout::simpleTimeout(1000);
      serial_.setTimeout(timeout);
      serial_.open();
      RCLCPP_INFO(rclcpp::get_logger("DiffDriveHardware"),
                  "Connected to %s at %d baud", serial_port_.c_str(), baud_rate_);
    } catch (const serial::IOException & e) {
      RCLCPP_ERROR(rclcpp::get_logger("DiffDriveHardware"),
                   "Cannot open serial port: %s", e.what());
      return CallbackReturn::ERROR;
    }
    return CallbackReturn::SUCCESS;
  }

  // Close serial connection when deactivated
  hardware_interface::CallbackReturn on_deactivate(
    const rclcpp_lifecycle::State &) override
  {
    if (serial_.isOpen()) {
      serial_.close();
    }
    return CallbackReturn::SUCCESS;
  }

  // Export state interfaces for controller to read
  std::vector<hardware_interface::StateInterface> export_state_interfaces() override
  {
    std::vector<hardware_interface::StateInterface> interfaces;
    for (size_t i = 0; i < 2; ++i) {
      interfaces.emplace_back(
        info_.joints[i].name, "position", &hw_positions_[i]);
      interfaces.emplace_back(
        info_.joints[i].name, "velocity", &hw_velocities_[i]);
    }
    return interfaces;
  }

  // Export command interfaces for controller to write
  std::vector<hardware_interface::CommandInterface> export_command_interfaces() override
  {
    std::vector<hardware_interface::CommandInterface> interfaces;
    for (size_t i = 0; i < 2; ++i) {
      interfaces.emplace_back(
        info_.joints[i].name, "velocity", &hw_commands_[i]);
    }
    return interfaces;
  }

  // READ encoder — called each control loop cycle
  hardware_interface::return_type read(
    const rclcpp::Time &, const rclcpp::Duration & period) override
  {
    if (!serial_.isOpen()) return hardware_interface::return_type::ERROR;

    // Send command to read encoder (protocol depends on firmware)
    serial_.write("GET_ENCODERS\n");
    std::string response = serial_.readline();

    // Parse response: "left_ticks right_ticks"
    int left_ticks, right_ticks;
    if (sscanf(response.c_str(), "%d %d", &left_ticks, &right_ticks) == 2) {
      double dt = period.seconds();

      // Calculate position (radians) from encoder ticks
      double left_rad = (2.0 * M_PI * left_ticks) / ticks_per_rev_;
      double right_rad = (2.0 * M_PI * right_ticks) / ticks_per_rev_;

      // Calculate velocity (rad/s)
      hw_velocities_[0] = (left_rad - hw_positions_[0]) / dt;
      hw_velocities_[1] = (right_rad - hw_positions_[1]) / dt;

      hw_positions_[0] = left_rad;
      hw_positions_[1] = right_rad;
    }

    return hardware_interface::return_type::OK;
  }

  // WRITE velocity command to motors — called each control loop cycle
  hardware_interface::return_type write(
    const rclcpp::Time &, const rclcpp::Duration &) override
  {
    if (!serial_.isOpen()) return hardware_interface::return_type::ERROR;

    // Send velocity command via serial (rad/s → RPM or PWM depending on firmware)
    double left_rpm = hw_commands_[0] * 60.0 / (2.0 * M_PI);
    double right_rpm = hw_commands_[1] * 60.0 / (2.0 * M_PI);

    char cmd[64];
    snprintf(cmd, sizeof(cmd), "SET_SPEED %.2f %.2f\n", left_rpm, right_rpm);
    serial_.write(cmd);

    return hardware_interface::return_type::OK;
  }

private:
  serial::Serial serial_;
  std::string serial_port_;
  int baud_rate_;
  int ticks_per_rev_;
  double wheel_radius_;

  std::vector<double> hw_positions_;
  std::vector<double> hw_velocities_;
  std::vector<double> hw_commands_;
};

}  // namespace my_robot_hardware

#include "pluginlib/class_list_macros.hpp"
PLUGINLIB_EXPORT_CLASS(
  my_robot_hardware::DiffDriveHardware,
  hardware_interface::SystemInterface)

Register Plugin

<!-- ~/ros2_ws/src/my_robot_hardware/my_robot_hardware.xml -->
<library path="my_robot_hardware">
  <class name="my_robot_hardware/DiffDriveHardware"
         type="my_robot_hardware::DiffDriveHardware"
         base_class_type="hardware_interface::SystemInterface">
    <description>Hardware interface for differential drive robot via serial</description>
  </class>
</library>

Arduino board connected to motor driver

Step 3: Configure Controllers

YAML file configuring controller_manager and controllers:

# ~/ros2_ws/src/my_robot_hardware/config/controllers.yaml
controller_manager:
  ros__parameters:
    update_rate: 50  # Hz — control loop frequency

    # Declare controllers
    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster

    diff_drive_controller:
      type: diff_drive_controller/DiffDriveController

# Configure JointStateBroadcaster — publish joint states to /joint_states
joint_state_broadcaster:
  ros__parameters:
    joints:
      - left_wheel_joint
      - right_wheel_joint
    interfaces:
      - position
      - velocity

# Configure DiffDriveController — receive /cmd_vel, send velocity to wheels
diff_drive_controller:
  ros__parameters:
    left_wheel_names: ["left_wheel_joint"]
    right_wheel_names: ["right_wheel_joint"]

    # Robot mechanical parameters
    wheel_separation: 0.24     # Distance between wheels (m)
    wheel_radius: 0.05         # Wheel radius (m)

    # Speed limits
    linear.x.max_velocity: 0.5        # m/s
    linear.x.min_velocity: -0.3
    angular.z.max_velocity: 2.0       # rad/s
    angular.z.min_velocity: -2.0

    # Acceleration limits
    linear.x.max_acceleration: 1.0
    angular.z.max_acceleration: 3.0

    # Publish odometry
    publish_rate: 50.0
    odom_frame_id: odom
    base_frame_id: base_footprint
    enable_odom_tf: true

    # Timeout — stop motors if no cmd_vel received
    cmd_vel_timeout: 0.5

Critical Parameters to Measure Accurately:

Step 4: Launch File

# ~/ros2_ws/src/my_robot_hardware/launch/robot_bringup.launch.py
import os
from launch import LaunchDescription
from launch_ros.actions import Node
from launch.actions import RegisterEventHandler, TimerAction
from launch.event_handlers import OnProcessStart
from ament_index_python.packages import get_package_share_directory
import xacro


def generate_launch_description():
    pkg_path = get_package_share_directory('my_robot_hardware')

    # Process URDF from xacro
    xacro_file = os.path.join(pkg_path, 'urdf', 'robot.urdf.xacro')
    robot_description = xacro.process_file(xacro_file).toxml()

    # Robot State Publisher — publish TF from URDF
    robot_state_publisher = Node(
        package='robot_state_publisher',
        executable='robot_state_publisher',
        parameters=[{'robot_description': robot_description}],
    )

    # Controller Manager — load hardware interface + controllers
    controller_manager = Node(
        package='controller_manager',
        executable='ros2_control_node',
        parameters=[
            {'robot_description': robot_description},
            os.path.join(pkg_path, 'config', 'controllers.yaml'),
        ],
        output='screen',
    )

    # Spawn controllers after controller_manager is running
    spawn_jsb = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['joint_state_broadcaster', '--controller-manager', '/controller_manager'],
    )

    spawn_ddc = Node(
        package='controller_manager',
        executable='spawner',
        arguments=['diff_drive_controller', '--controller-manager', '/controller_manager'],
    )

    # Wait for controller_manager then spawn
    delayed_spawners = RegisterEventHandler(
        event_handler=OnProcessStart(
            target_action=controller_manager,
            on_start=[
                TimerAction(
                    period=3.0,
                    actions=[spawn_jsb, spawn_ddc],
                ),
            ],
        )
    )

    return LaunchDescription([
        robot_state_publisher,
        controller_manager,
        delayed_spawners,
    ])

Step 5: Test with Mock Hardware

Before running on real hardware, test with mock. In URDF, replace plugin:

<hardware>
  <!-- Replace custom plugin with mock -->
  <plugin>mock_components/GenericSystem</plugin>
  <param name="mock_sensor_commands">false</param>
  <param name="state_following_offset">0.0</param>
</hardware>
# Launch with mock
ros2 launch my_robot_hardware robot_bringup.launch.py

# Check controllers
ros2 control list_controllers
# joint_state_broadcaster  [active]
# diff_drive_controller    [active]

# Check hardware interfaces
ros2 control list_hardware_interfaces
# command interfaces:
#   left_wheel_joint/velocity [available] [claimed]
#   right_wheel_joint/velocity [available] [claimed]
# state interfaces:
#   left_wheel_joint/position
#   left_wheel_joint/velocity
#   right_wheel_joint/position
#   right_wheel_joint/velocity

# Send cmd_vel test
ros2 topic pub /diff_drive_controller/cmd_vel_unstamped \
  geometry_msgs/msg/Twist \
  "{linear: {x: 0.2}, angular: {z: 0.0}}"

# View joint states
ros2 topic echo /joint_states

Step 6: Run on Real Hardware

When mock test passes, switch to real hardware:

  1. Check serial: ls /dev/ttyUSB* or /dev/ttyACM*
  2. Set permissions: sudo chmod 666 /dev/ttyUSB0 or add user to dialout group
  3. Update URDF plugin back to my_robot_hardware/DiffDriveHardware
  4. Launch and test carefully — start with low velocity
# Set permissions
sudo usermod -a -G dialout $USER
# Logout/login to apply

# Launch
ros2 launch my_robot_hardware robot_bringup.launch.py

# Test with low speed first
ros2 topic pub --once /diff_drive_controller/cmd_vel_unstamped \
  geometry_msgs/msg/Twist "{linear: {x: 0.05}}"

Differential drive robot on test bench

Arduino Firmware Reference

Microcontroller side (Arduino/ESP32) firmware should implement the protocol:

// Arduino firmware (example sketch)
volatile long left_ticks = 0;
volatile long right_ticks = 0;

void setup() {
  Serial.begin(115200);
  // Setup encoder interrupts
  attachInterrupt(digitalPinToInterrupt(2), leftEncoderISR, CHANGE);
  attachInterrupt(digitalPinToInterrupt(3), rightEncoderISR, CHANGE);
  // Setup motor pins
  pinMode(5, OUTPUT); // Left PWM
  pinMode(6, OUTPUT); // Right PWM
}

void loop() {
  if (Serial.available()) {
    String cmd = Serial.readStringUntil('\n');

    if (cmd == "GET_ENCODERS") {
      Serial.print(left_ticks);
      Serial.print(" ");
      Serial.println(right_ticks);
    }
    else if (cmd.startsWith("SET_SPEED")) {
      float left_rpm, right_rpm;
      sscanf(cmd.c_str(), "SET_SPEED %f %f", &left_rpm, &right_rpm);
      setMotorSpeed(left_rpm, right_rpm);
    }
  }
}

Common Troubleshooting

Controller Won't Activate

# Check hardware interface loaded
ros2 control list_hardware_components
# If state = unconfigured → URDF wrong or plugin not found

# Check logs
ros2 topic echo /diagnostics

Encoder Reading Wrong

Odometry Drift

Summary

In this article, you have mastered:

ros2_control is powerful — once you write the hardware interface for your robot, all standard ROS 2 controllers (PID, trajectory, diff drive, Ackermann) work immediately. For more about controlling robots with Python, read Python for Robot Control: From GPIO to ROS 2.


Related Articles

Related Posts

Deep DiveDigital Twins và ROS 2: Simulation trong sản xuất
simulationros2digital-twinPart 6

Digital Twins và ROS 2: Simulation trong sản xuất

Ứng dụng simulation trong công nghiệp — digital twins, ROS 2 + Gazebo/Isaac integration cho nhà máy thông minh.

3/4/202611 min read
TutorialSim-to-Real Pipeline: Từ training đến robot thật
simulationsim2realtutorialPart 5

Sim-to-Real Pipeline: Từ training đến robot thật

End-to-end guide: train policy trong sim, evaluate, domain randomization, deploy lên robot thật và iterate.

2/4/202615 min read
TutorialBắt đầu với MuJoCo: Cài đặt đến mô phỏng robot đầu tiên
simulationmujocotutorialPart 2

Bắt đầu với MuJoCo: Cài đặt đến mô phỏng robot đầu tiên

Hands-on tutorial MuJoCo — cài đặt, tạo MJCF model, simulate robot arm và visualize kết quả với Python.

30/3/202611 min read