Simulating Robot Manipulators with ros2_control in Gazebo: A Practical Integration Guide
Wiring ros2_control into Gazebo lets you command simulated manipulators with standard ROS 2 controllers. This post walks through the plugin architecture, a minimal two-link arm example, version compatibility, and the three most common misconfiguration traps.
04 Jan 2026, 21:18 UTC

The Integration Gap Between Simulation and Control
You've built a URDF for your 6-DOF arm, launched it in Gazebo, and now you need to close the loop: send trajectory commands from ROS 2 and see the simulated joints move under physics. The naive approach—writing a custom Gazebo plugin that subscribes to /joint_trajectory and applies forces—works for a weekend prototype but becomes a maintenance burden when you swap controllers, add friction compensation, or migrate to a different physics engine. The ros2_control framework solves this by standardizing the controller interface, but wiring it into Gazebo still trips up teams on version matching, SDF tags, and controller spawning order.
How the gazebo_ros2_control Plugin Bridges the Gap
The gazebo_ros2_control plugin loads a controller_manager node inside the Gazebo process. That manager reads ros2_control tags from your robot description, instantiates the requested controllers (e.g., joint_state_controller, effort_controllers/JointTrajectoryController), and exposes their command and state interfaces to the Gazebo physics engine. When a controller writes an effort command, the plugin forwards it to the corresponding joint's SetForce() call; when the physics step completes, joint positions and velocities flow back into the controller's state interfaces. This happens at the simulation rate, so the real-time factor stays near 1.0 for typical manipulators using ODE or Bullet.
Supported combinations as of late 2024:
- Gazebo Classic 11 + ROS 2 Humble (binary packages via
ros-humble-gazebo-ros2-control) - Ignition Gazebo Fortress/Garden + ROS 2 Humble/Iron (package
ros--gz-ros2-control) - Gazebo Harmonic + ROS 2 Jazzy (aligned releases)
Mixing Gazebo Classic with ROS 2 Foxy or earlier usually requires building the plugin from source because the gazebo_ros2_control CMake targets depend on rclcpp APIs that landed in Galactic.
Worked Example: Two-Link Planar Arm
Below is a minimal URDF snippet that declares the ros2_control interfaces for a two-revolute-joint arm. Save it as two_link.urdf.
<robot name="two_link" xmlns:xacro="http://www.ros.org/wiki/xacro">
<link name="base_link"/>
<joint name="joint1" type="revolute">
<parent link="base_link"/>
<child link="link1"/>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit effort="10" velocity="2.0" lower="-3.14" upper="3.14"/>
</joint>
<link name="link1">
<inertial>
<mass value="1.0"/>
<inertia ixx="0.01" ixy="0" ixz="0" iyy="0.01" iyz="0" izz="0.01"/>
</inertial>
</link>
<joint name="joint2" type="revolute">
<parent link="link1"/>
<child link="link2"/>
<origin xyz="0 0 0.5" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit effort="10" velocity="2.0" lower="-3.14" upper="3.14"/>
</joint>
<link name="link2">
<inertial>
<mass value="0.5"/>
<inertia ixx="0.005" ixy="0" ixz="0" iyy="0.005" iyz="0" izz="0.005"/>
</inertial>
</link>
<!-- ros2_control interfaces -->
<ros2_control name="GazeboSystem" type="system">
<hardware>
<plugin>gazebo_ros2_control/GazeboSystem</plugin>
</hardware>
<joint name="joint1">
<command_interface name="effort"/>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<joint name="joint2">
<command_interface name="effort"/>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
</ros2_control>
</robot>
Create a controller YAML (controllers.yaml) that asks for a joint state broadcaster and an effort-based trajectory controller:
controller_manager:
ros__parameters:
update_rate: 100
joint_state_broadcaster:
type: joint_state_broadcaster/JointStateBroadcaster
arm_controller:
type: effort_controllers/JointTrajectoryController
joints:
- joint1
- joint2
command_interfaces:
- effort
state_interfaces:
- position
- velocity
allow_partial_joints_goal: true
Launch Gazebo with the model and the plugin. On a terminal sourced with ROS 2 Humble and Gazebo Classic 11:
# Terminal 1: start Gazebo with the robot
gazebo --verbose -s libgazebo_ros2_control.so two_link.urdf
# Terminal 2: spawn controllers
ros2 run controller_manager spawner joint_state_broadcaster arm_controller \
--controller-manager /controller_manager
# Terminal 3: verify joint states
ros2 topic echo /joint_states
# Terminal 4: send a trajectory goal
ros2 action send_goal /arm_controller/follow_joint_trajectory \
control_msgs/action/FollowJointTrajectory \
"{trajectory: {joint_names: ['joint1', 'joint2'], points: [{positions: [1.0, -0.5], time_from_start: {sec: 2}}]}}"
Expected checks:
- Gazebo console prints
[gazebo_ros2_control] Loaded GazeboSystem pluginwith noERRORlines. /joint_statespublishessensor_msgs/msg/JointStateat ~100 Hz with non-zero position/velocity fields.- The arm moves in the Gazebo viewport; the action client reports
SUCCEEDED.
Where the Integration Still Bites
Three failure modes appear repeatedly:
- Missing or mismatched transmission tags. Older tutorials add
<transmission>blocks forgazebo_ros_control(ROS 1). The ROS 2 plugin ignores them; only theros2_controlXML block matters. Leaving stale transmissions in the URDF does no harm but clutters the file. - Joint limit inconsistency. If the URDF
<limit effort="…">is lower than the controller's commanded effort, the physics engine clamps the torque and the trajectory falls behind. The controller then reportsPATH_TOLERANCE_VIOLATED. Align the URDF effort limit with the controller'seffort_limitparameter (or omit the parameter to use the URDF value). - Controller manager namespace collisions. Spawning multiple robots in one Gazebo instance requires distinct
controller_managernamespaces (e.g.,/robot1/controller_manager,/robot2/controller_manager). The plugin respects therobotNamespaceSDF tag; set it in the<plugin>block inside the SDF wrapper or via thegazebo_ros2_controllaunch argument.
Quick Verification Checklist Before You Commit
- Run
ros2 control list_hardware_interfacesagainst the running controller manager; you should seejoint1andjoint2witheffortcommand andposition/velocitystate interfaces claimed. - Check
ros2 param get /controller_manager arm_controller.effort_limitmatches the URDF limit. - Plot
/joint_statesposition vs. the commanded trajectory in rqt_plot or Foxglove; the curves should overlay within a few degrees for a well-tuned PID (the default gains are conservative).
Closing: Treat the Plugin as a Contract, Not Magic
The gazebo_ros2_control plugin gives you a supported, versioned path from URDF to physics-backed joint control without custom C++. The contract is simple: declare your interfaces in the ros2_control block, match Gazebo and ROS 2 distributions, and spawn controllers after the plugin loads. When the arm doesn't move, the Gazebo console and controller_manager logs tell you exactly which interface is missing or which version mismatch broke the link. Fix the declaration, relaunch, and you're back to testing control logic instead of debugging plugin glue.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.