The Problem
You’ve built a robot. You have ROS 2 code for navigation, manipulation, perception. How do you test it safely before deploying to hardware?
Gazebo is the answer. It’s a physics simulator that talks to ROS 2. Your robot code doesn’t know if it’s controlling a simulation or a real robot—it just publishes and subscribes to topics.
But how does this communication actually work?
Architecture Overview
Gazebo and ROS 2 speak different languages. Gazebo uses its own internal API. ROS 2 uses topics/services/actions. The bridge connects them:
Your ROS 2 Node (e.g., nav_controller)
↓
ROS 2 Topic/Service
↓
[gz_ros2_control Bridge] ← The magic
↓
Gazebo Simulator
├─ Physics engine
├─ Sensor simulation
└─ Actor control
↓
Simulated Robot
This is gz_ros2_control. It’s the middleware that translates ROS 2 commands into Gazebo motions.
Part 1: Installation & Setup
Install Dependencies
# Ubuntu 22.04 with ROS 2 Humble
sudo apt install -y \
ros-humble-gazebo-ros \
ros-humble-gazebo-ros2-control \
ros-humble-joint-state-publisher \
ros-humble-robot-state-publisher \
ros-humble-diff-drive-controller \
ros-humble-velocity-controllersBasic World File
Create worlds/empty.sdf:
<?xml version="1.0"?>
<sdf version="1.10">
<world name="empty">
<!-- Physics -->
<physics name="default_physics" type="ode">
<max_step_size>0.001</max_step_size>
<real_time_factor>1.0</real_time_factor>
</physics>
<!-- Ground -->
<model name="ground_plane">
<static>true</static>
<link name="link">
<collision name="collision">
<geometry>
<plane>
<normal>0 0 1</normal>
<size>100 100</size>
</plane>
</geometry>
</collision>
<visual name="visual">
<geometry>
<plane>
<normal>0 0 1</normal>
<size>100 100</size>
</plane>
</geometry>
<material>
<ambient>0.5 0.5 0.5 1</ambient>
<diffuse>0.5 0.5 0.5 1</diffuse>
</material>
</visual>
</link>
</model>
<!-- Lighting -->
<light type="directional" name="sun">
<pose>0 0 10 0 0 0</pose>
<diffuse>1 1 1 1</diffuse>
<specular>0.5 0.5 0.5 1</specular>
<direction>-0.5 0.1 -0.9</direction>
</light>
</world>
</sdf>Launch Gazebo
gazebo worlds/empty.sdfOr headless (for CI/testing):
GZ_SIM_HEADLESS=1 gazebo worlds/empty.sdfPart 2: URDF + ros2_control
Your robot needs two things:
- URDF — Physical description (links, joints, geometry)
- ros2_control config — Control interface (which joints, which controllers)
Simple 2-Wheel Robot URDF
robots/mobile_robot.urdf.xacro:
<?xml version="1.0"?>
<robot name="mobile_robot" xmlns:xacro="http://www.ros.org/wiki/xacro">
<!-- Base link -->
<link name="base_link">
<inertial>
<mass value="1.0"/>
<inertia ixx="0.01" ixy="0" ixz="0" iyy="0.01" iyz="0" izz="0.02"/>
</inertial>
<collision name="collision">
<geometry>
<box size="0.3 0.3 0.1"/>
</geometry>
</collision>
<visual name="visual">
<geometry>
<box size="0.3 0.3 0.1"/>
</geometry>
<material name="grey">
<color rgba="0.5 0.5 0.5 1"/>
</material>
</visual>
</link>
<!-- Left wheel -->
<link name="wheel_left">
<inertial>
<mass value="0.2"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
</inertial>
<collision>
<geometry>
<cylinder radius="0.05" length="0.02"/>
</geometry>
</collision>
<visual>
<geometry>
<cylinder radius="0.05" length="0.02"/>
</geometry>
<material name="black">
<color rgba="0 0 0 1"/>
</material>
</visual>
</link>
<!-- Right wheel (identical to left) -->
<link name="wheel_right">
<inertial>
<mass value="0.2"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
</inertial>
<collision>
<geometry>
<cylinder radius="0.05" length="0.02"/>
</geometry>
</collision>
<visual>
<geometry>
<cylinder radius="0.05" length="0.02"/>
</geometry>
<material name="black">
<color rgba="0 0 0 1"/>
</material>
</visual>
</link>
<!-- Caster wheel -->
<link name="caster">
<inertial>
<mass value="0.1"/>
<inertia ixx="0.0005" ixy="0" ixz="0" iyy="0.0005" iyz="0" izz="0.0005"/>
</inertial>
<collision>
<geometry>
<sphere radius="0.02"/>
</geometry>
</collision>
<visual>
<geometry>
<sphere radius="0.02"/>
</geometry>
</visual>
</link>
<!-- Joints -->
<joint name="wheel_left_joint" type="continuous">
<parent link="base_link"/>
<child link="wheel_left"/>
<origin xyz="0 0.15 0" rpy="-1.57 0 0"/>
<axis xyz="1 0 0"/>
<limit effort="10" velocity="10"/>
<dynamics damping="0.1" friction="0.1"/>
</joint>
<joint name="wheel_right_joint" type="continuous">
<parent link="base_link"/>
<child link="wheel_right"/>
<origin xyz="0 -0.15 0" rpy="-1.57 0 0"/>
<axis xyz="1 0 0"/>
<limit effort="10" velocity="10"/>
<dynamics damping="0.1" friction="0.1"/>
</joint>
<joint name="caster_joint" type="ball">
<parent link="base_link"/>
<child link="caster"/>
<origin xyz="-0.12 0 -0.05"/>
</joint>
</robot>ros2_control Configuration
config/controllers.yaml:
controller_manager:
ros__parameters:
update_rate: 100 # 100 Hz control loop
use_sim_time: true # Critical: use Gazebo's simulated time
joint_state_broadcaster:
type: joint_state_broadcaster/JointStateBroadcaster
diff_drive_controller:
type: diff_drive_controller/DiffDriveController
diff_drive_controller:
ros__parameters:
left_wheel_names: ["wheel_left"]
right_wheel_names: ["wheel_right"]
wheel_separation: 0.3 # Distance between wheels
wheels_per_side: 1
wheel_radius: 0.05
# Limits
linear:
x:
has_velocity_limits: true
max_velocity: 1.0
has_acceleration_limits: true
max_acceleration: 0.5
angular:
z:
has_velocity_limits: true
max_velocity: 1.0
has_acceleration_limits: true
max_acceleration: 0.5
# Commands
cmd_vel_topic: cmd_vel
odom_topic: odom
publish_rate: 50Part 3: Launch File
launch/gazebo.launch.py:
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, DeclareLaunchArgument
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
from launch.substitutions import LaunchConfiguration
import os
def generate_launch_description():
pkg_share = FindPackageShare(package='my_robot').find('my_robot')
# Arguments
use_sim_time = LaunchConfiguration('use_sim_time', default='true')
world_file = LaunchConfiguration('world',
default=os.path.join(pkg_share, 'worlds', 'empty.sdf'))
# Gazebo
gazebo = IncludeLaunchDescription(
os.path.join(FindPackageShare('gazebo_ros').find('gazebo_ros'),
'launch', 'gazebo.launch.py'),
launch_arguments=[('world', world_file)]
)
# Spawn robot
spawn_robot = Node(
package='gazebo_ros',
executable='spawn_entity.py',
arguments=[
'-topic', 'robot_description',
'-entity', 'my_robot',
'-x', '0',
'-y', '0',
'-z', '0.1'
],
output='screen'
)
# Robot state publisher
robot_state_pub = Node(
package='robot_state_publisher',
executable='robot_state_publisher',
parameters=[
{'robot_description':
open(os.path.join(pkg_share, 'urdf', 'robot.urdf')).read()},
{'use_sim_time': use_sim_time}
]
)
# ros2_control
ros2_control = Node(
package='controller_manager',
executable='ros2_control_node',
parameters=[
{'use_sim_time': use_sim_time},
os.path.join(pkg_share, 'config', 'controllers.yaml')
],
output='screen'
)
# Joint state broadcaster
joint_state_broadcaster = Node(
package='controller_manager',
executable='spawner',
arguments=['joint_state_broadcaster', '-c', '/controller_manager'],
output='screen'
)
# Diff drive controller
diff_drive = Node(
package='controller_manager',
executable='spawner',
arguments=['diff_drive_controller', '-c', '/controller_manager'],
output='screen'
)
return LaunchDescription([
DeclareLaunchArgument('use_sim_time', default_value='true'),
DeclareLaunchArgument('world', default_value=world_file),
gazebo,
robot_state_pub,
spawn_robot,
ros2_control,
joint_state_broadcaster,
diff_drive
])Launch
ros2 launch my_robot gazebo.launch.pyPart 4: Communication Patterns
Pattern 1: Command Velocity (Mobile Robot)
Your navigation node publishes velocity commands. The controller consumes them:
# navigation_node.py
import rclpy
from geometry_msgs.msg import Twist
class NavigationNode(rclcpp.Node):
def __init__(self):
super().__init__('navigation_node')
self.pub = self.create_publisher(Twist, 'cmd_vel', 10)
def move_forward(self):
msg = Twist()
msg.linear.x = 0.5 # 0.5 m/s forward
msg.angular.z = 0.0 # No rotation
self.pub.publish(msg)
node = NavigationNode()
node.move_forward()The diff_drive_controller:
- Receives Twist message on
/cmd_vel - Converts linear/angular velocity to wheel velocities
- Sends commands to Gazebo physics engine
- Simulates wheel motion, odometry
Pattern 2: Odometry (Position Feedback)
The controller publishes odometry (estimated pose):
# listener_node.py
import rclpy
from nav_msgs.msg import Odometry
def odom_callback(msg):
x = msg.pose.pose.position.x
y = msg.pose.pose.position.y
theta = msg.pose.pose.orientation.z # Simplified
print(f"Robot at ({x:.2f}, {y:.2f}), heading {theta:.2f}")
node = rclcpp.create_node('odom_listener')
sub = node.create_subscription(Odometry, 'odom', odom_callback, 10)
rclcpp.spin(node)Gazebo simulates wheel encoder feedback → diff_drive_controller computes odometry.
Pattern 3: Joint State (All Joint Angles)
The joint_state_broadcaster publishes all joint positions/velocities:
# Check robot pose in RViz
# ros2 topic echo /joint_states
# Shows: positions=[0.1, 0.15], velocities=[0.5, 0.5]Used by RViz for visualization, URDF tree updates.
Pattern 4: TF Transforms
The controller publishes coordinate frame transformations:
# View TF tree
ros2 run tf2_tools view_frames.py
# Result:
# world → odom → base_footprint → base_link
# → wheel_left
# → wheel_rightCritical for multi-sensor fusion (camera → base_link → wheel).
Part 5: Sensor Simulation
Camera Simulation
Add to URDF:
<link name="camera">
<inertial>
<mass value="0.05"/>
<inertia ixx="0.0001" ixy="0" ixz="0" iyy="0.0001" iyz="0" izz="0.0001"/>
</inertial>
<collision>
<geometry><box size="0.05 0.05 0.05"/></geometry>
</collision>
<visual>
<geometry><box size="0.05 0.05 0.05"/></geometry>
</visual>
</link>
<joint name="camera_joint" type="fixed">
<parent link="base_link"/>
<child link="camera"/>
<origin xyz="0.15 0 0.05" rpy="0 0 0"/>
</joint>In Gazebo world SDF, add camera plugin:
<model name="camera_model">
<link name="camera_link">
<!-- ... URDF collision/visual ... -->
<sensor name="camera" type="camera">
<camera>
<image>
<width>640</width>
<height>480</height>
</image>
<clip>
<near>0.1</near>
<far>100</far>
</clip>
</camera>
<!-- Output to ROS 2 -->
<plugin filename="libgazebo_ros_camera.so" name="camera_plugin">
<ros>
<namespace>robot</namespace>
<remapping>image_raw:=camera/image_raw</remapping>
<remapping>camera_info:=camera/camera_info</remapping>
</ros>
<camera_name>camera</camera_name>
<frame_name>camera</frame_name>
</plugin>
</sensor>
</link>
</model>Subscribe in ROS 2:
from sensor_msgs.msg import Image
import cv2
from cv_bridge import CvBridge
def image_callback(msg):
bridge = CvBridge()
cv_image = bridge.imgmsg_to_cv2(msg, "bgr8")
cv2.imshow('Camera Feed', cv_image)
cv2.waitKey(1)
node = rclcpp.create_node('vision_node')
sub = node.create_subscription(Image, 'robot/camera/image_raw', image_callback, 10)
rclcpp.spin(node)LiDAR Simulation
<sensor name="lidar" type="lidar">
<lidar>
<scan>
<horizontal>
<samples>720</samples>
<resolution>1</resolution>
<min_angle>-3.14159</min_angle>
<max_angle>3.14159</max_angle>
</horizontal>
</scan>
<range>
<min>0.08</min>
<max>10.0</max>
</range>
</lidar>
<plugin filename="libgazebo_ros_lidar_gpu.so" name="lidar_plugin">
<ros>
<remapping>scan:=lidar_scan</remapping>
</ros>
<frame_name>lidar</frame_name>
</plugin>
</sensor>Subscribe:
from sensor_msgs.msg import LaserScan
def lidar_callback(msg):
ranges = msg.ranges
print(f"Min range: {min(ranges):.2f}m")
sub = node.create_subscription(LaserScan, 'lidar_scan', lidar_callback, 10)Part 6: Real-to-Sim Workflow
The Goal
Write code once, run on simulation AND real hardware.
The Key: Topic Names Don’t Change
# Same code for simulation and hardware
class RobotController:
def __init__(self):
self.cmd_pub = self.create_publisher(Twist, 'cmd_vel', 10)
self.odom_sub = self.create_subscription(
Odometry, 'odom', self.odom_callback, 10
)
def move(self, linear_x, angular_z):
msg = Twist()
msg.linear.x = linear_x
msg.angular.z = angular_z
self.cmd_pub.publish(msg)Simulation: Gazebo publishes to /cmd_vel → diff_drive controller → wheels
Hardware: Real motor driver subscribes to /cmd_vel → wheels move
Same code. Different backends.
The catch: Time
Simulation runs at Gazebo’s clock, not wall-clock. Always use use_sim_time:
# Tell ROS 2 to use Gazebo's simulated time
# In launch file:
'use_sim_time': True
# In nodes:
self.get_clock().now() # Returns simulated time, not wall timeWithout this, timing is wrong (e.g., delays publish too fast).
Integration Testing
# test_navigation.py
import pytest
import rclpy
from geometry_msgs.msg import Twist
@pytest.fixture
def node():
rclpy.init()
yield rclpy.create_node('test_node')
rclpy.shutdown()
def test_move_forward(node):
# Publish move command
pub = node.create_publisher(Twist, 'cmd_vel', 10)
msg = Twist()
msg.linear.x = 0.5
pub.publish(msg)
# Wait for odometry update
rclpy.spin_once(node, timeout_sec=0.5)
# Check robot moved
# (requires subscribing to odom in fixture)
assert robot_x > 0.0
# Run with:
# ros2 launch robot gazebo.launch.py
# pytest test_navigation.pyPart 7: Debugging Common Issues
Issue 1: Robot Doesn’t Move
Check:
# Is controller running?
ros2 control list_controllers
# Output:
# joint_state_broadcaster [joint_state_broadcaster/JointStateBroadcaster] active ✓
# diff_drive_controller [diff_drive_controller/DiffDriveController] active ✓
# Is topic being published?
ros2 topic hz cmd_vel
# Should show ~50 Hz
# Check velocity is actually going out
ros2 topic echo cmd_vel
# Should see non-zero linear.x valuesIssue 2: Wrong Odometry
Cause: Wheel radius mismatch between URDF and controller config.
# config/controllers.yaml
wheel_radius: 0.05 # Must match URDF!
# In URDF:
<cylinder radius="0.05" length="0.02"/> # ← Must matchIf they differ, odometry drifts.
Issue 3: Gazebo Crashes on Startup
# Check for URDF errors
check_urdf robot.urdf
# Should say "urdf is valid"
# Check for missing plugins
gazebo --verbose robot.sdf 2>&1 | grep -i "error\|plugin"
# Disable problematic plugins temporarily
<!-- <plugin filename="..." name="..."/> --> <!-- Commented out -->Issue 4: Joint Limits Not Enforced
Make sure <limit> is in URDF:
<joint name="wheel_joint" type="continuous">
<!-- ... -->
<limit effort="10" velocity="10"/> <!-- Critical -->
</joint>Also set in controller config:
linear:
x:
max_velocity: 1.0Real-World Example: Navigation Testing
# Test: Can robot navigate around obstacle?
class NavigationTest:
def __init__(self):
self.node = rclcpp.create_node('nav_test')
self.cmd_pub = self.node.create_publisher(Twist, 'cmd_vel', 10)
self.odom_sub = self.node.create_subscription(
Odometry, 'odom', self.odom_callback, 10
)
self.start_x = None
self.current_x = None
def odom_callback(self, msg):
self.current_x = msg.pose.pose.position.x
if self.start_x is None:
self.start_x = self.current_x
def test_forward_motion(self):
# Command: move forward
for _ in range(50): # 50 steps at 100 Hz = 0.5 seconds
msg = Twist()
msg.linear.x = 0.5 # 0.5 m/s
self.cmd_pub.publish(msg)
rclpy.spin_once(self.node, timeout_sec=0.01)
# Expected: moved ~0.25 meters in 0.5 seconds
distance = self.current_x - self.start_x
assert distance > 0.2, f"Moved only {distance:.2f}m, expected >0.2m"
print(f"✅ Forward motion test passed: {distance:.2f}m")
test = NavigationTest()
test.test_forward_motion()Key Insights
1. Gazebo Simulation Is Physics-Based
Unlike trajectory playback, Gazebo actually simulates forces. If you command too high acceleration, wheels slip.
2. use_sim_time Is Non-Negotiable
Forget it once, your timing is off forever. Always set it in launch files.
3. URDF and Controller Config Must Match
If URDF says wheel radius is 0.05m but config says 0.08m, odometry is garbage.
4. Sensors Require Plugins
Cameras, LiDARs, IMUs don’t appear magically. Each needs a Gazebo plugin + ROS 2 bridge.
5. Real-to-Sim Gaps Still Exist
Friction, air resistance, sensor noise—simulation idealizes. Real robots are messy. Test on hardware.
Resources
- Gazebo Documentation — gazebosim.org
- ROS 2 Gazebo Integration — ros2.org/Documentation
- gz-ros2-control — github.com/gazebosim/gz-ros2-control
- Diff Drive Controller — control.ros.org
Last updated: June 2026 | Tested on Gazebo Ignition Fortress, ROS 2 Humble