ros2_control: the standard hardware interface
Every Nav2 stack and MoveIt arm in ROS 2 sits on top of ros2_control. It's the layer between your high-level controllers and the actual motors. Here's what each piece does — and why getting this right unlocks the rest of the ecosystem.
When you wire a real motor to a ROS 2 robot, ros2_control is the layer that makes it work without re-inventing the controller wheel for every project. Nav2 expects it. MoveIt expects it. Every wheeled robot driver in 2026 expects it. Skipping it means writing the same plumbing five times across five projects — and getting it wrong four of them.
The picture
The three layers, in plain English
1. Hardware interface
The lowest layer. You write a small C++ class that says: "here's how to read the current state of one or more joints (positions, velocities, efforts), and here's how to write a command to them." It talks to your specific hardware — over USB, CAN, EtherCAT, or a custom protocol.
This is the only piece that's hardware-specific. Everything above is reusable.
2. controller_manager
A long-running ROS 2 node. It loads your hardware interface and a set of controllers, then runs a fixed-rate loop:
- call
read()on the hardware interface (get current state) - call
update()on each active controller (compute commands) - call
write()on the hardware interface (send commands)
Typical rate: 100–1000 Hz. The controller_manager is the heartbeat of the robot.
3. Controllers
Plug-and-play modules from ros2_controllers (the standard library) or your own. Each consumes a stream of high-level commands (e.g., geometry_msgs/Twist for diff_drive) and produces the per-joint commands the controller_manager will write.
The catalog you'll meet most:
- diff_drive_controller — converts
cmd_velto per-wheel velocity commands. - joint_trajectory_controller — what MoveIt drives. Tracks a planned trajectory through time.
- position_controllers / velocity_controllers / effort_controllers — single-joint pass-through controllers, useful for testing.
- imu_sensor_broadcaster, joint_state_broadcaster — read-only, publish state to topics.
The minimum config
You need three things wired up:
(a) URDF tells controller_manager what hardware exists
<ros2_control name="my_robot_hw" type="system">
<hardware>
<plugin>my_robot_hw/MyRobotHardware</plugin>
<param name="serial_port">/dev/robot_arm</param>
</hardware>
<joint name="left_wheel_joint">
<command_interface name="velocity"/>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<joint name="right_wheel_joint">
<command_interface name="velocity"/>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
</ros2_control>
Embed this inside your URDF (or Xacro). It tells the framework which joints exist, which interfaces they expose, and which plugin to load.
(b) Controller config YAML
controller_manager:
ros__parameters:
update_rate: 100 # Hz
diff_drive_controller:
type: diff_drive_controller/DiffDriveController
joint_state_broadcaster:
type: joint_state_broadcaster/JointStateBroadcaster
diff_drive_controller:
ros__parameters:
left_wheel_names: ["left_wheel_joint"]
right_wheel_names: ["right_wheel_joint"]
wheel_separation: 0.32
wheel_radius: 0.06
cmd_vel_timeout: 0.5
(c) Launch file wires them together
Node(package="controller_manager", executable="ros2_control_node",
parameters=[robot_description, controller_config_path]),
Node(package="controller_manager", executable="spawner",
arguments=["diff_drive_controller", "joint_state_broadcaster"])
Writing the hardware interface (the part that's actually new)
The C++ skeleton:
class MyRobotHardware : public hardware_interface::SystemInterface {
public:
CallbackReturn on_init(const hardware_interface::HardwareInfo &info) override {
// parse joint list, open the serial port, etc.
return CallbackReturn::SUCCESS;
}
std::vector<hardware_interface::StateInterface> export_state_interfaces() override {
// expose position + velocity for each joint
}
std::vector<hardware_interface::CommandInterface> export_command_interfaces() override {
// expose the command channels
}
return_type read(const rclcpp::Time &, const rclcpp::Duration &) override {
// read encoder positions over serial; populate state vectors
return return_type::OK;
}
return_type write(const rclcpp::Time &, const rclcpp::Duration &) override {
// send velocity commands over serial
return return_type::OK;
}
};
That's the entire contract. Most existing diff-drive robots have this in 200–400 lines of C++. Plug-load via pluginlib, expose to controller_manager via the URDF block above.
Why this layered design wins
- Same controller, different robot. diff_drive_controller works on any robot whose hardware interface exposes two velocity-commanded joints. Swap the underlying motors and the controller doesn't notice.
- Same robot, different controllers. A robot exposing position interfaces can run trajectory controllers (for arms) or admittance controllers (for compliant tasks) without changing the hardware code.
- MoveIt and Nav2 plug straight in. They don't talk to your hardware — they talk to the standard joint_trajectory_controller / diff_drive_controller. Your hardware interface is invisible to them.
Common ros2_control gotchas
- "Cannot find a controller for joint …" — your URDF lists a joint, but no controller claims it. Either spawn a controller that handles it, or remove from URDF.
- Hardware interface returns ERROR — your read/write threw an exception or the device disappeared. controller_manager will keep trying; check logs.
- Update rate mismatch — your controllers expect 100 Hz but the controller_manager runs at 50. Set
update_rateconsistently in YAML and don't override it from launch. - State interfaces missing — Nav2 wants position+velocity on every joint to compute odometry. If you only expose position, things "work" but odometry is wonky. Expose both.
Where this fits in the bigger picture
If you're building a real robot, the build order is usually:
- Write the URDF (covered in URDF & Xacro).
- Write the hardware interface (this lesson).
- Spawn standard controllers from the catalog.
- Drive Nav2 / MoveIt on top — they expect the catalog's controllers, not your hardware.
Skipping ros2_control and trying to talk to MoveIt / Nav2 directly is the most common reason teams spend a month on integration that should take a weekend. Embrace the layers.
Next
Nav2 — the mobile navigation stack that runs on top of this layer cake.
Comments
Sign in to post a comment.