mobile robots from scratch, part 10: ROS 2, how the pieces talk


Part 7 described a robot’s software as a crowd of small programs, and part 9 wrote one of them. ROS 2 (the Robot Operating System) is how almost every research and many commercial robots connect those programs. Despite the name, it isn’t an operating system: it’s a library for passing messages, a set of tools, and thousands of packages that already do most of what this series describes.

This part is about the few ideas in it that change how a robot behaves.

How the pieces talk, step by step

Each program is a node, and nodes talk by publishing messages on named topics. The lidar driver publishes on /scan; anything that wants scans subscribes to /scan. Neither side knows the other: the driver doesn’t care whether one node listens or five, or none.

Each topic has a message type, fixed in advance: /scan carries sensor_msgs/LaserScan, /cmd_vel carries a geometry_msgs/Twist (forward speed and turn rate, part 3’s \(v\) and \(\omega\)). That contract is what lets you swap one lidar’s driver for another’s without touching anything else.

Two other ways to talk exist for jobs that aren’t a stream: a service is a request with one reply (“save the map”); an action is a long job with progress and the option to cancel (“drive to the kitchen”).

Underneath, ROS 2 sends messages with DDS (or Zenoh), and each topic chooses its quality of service (QoS). The first choice is what to do when a message is lost, which happens all the time over Wi-Fi.

Best effort sends each message once, ten scans a second. Here, with 10% lost, 8 of 100 scans never arrive, and the rest take about 5 ms. Reliable resends until each message gets through: none are lost, but the delay is now 6.5 ms on average and 45 ms at worst, and a resent message holds up the ones behind it. At 30% loss, best effort loses 33 scans, and reliable’s worst delay reaches 105 ms.

For sensor data, a lost scan doesn’t matter: the next one comes in 100 ms, and it’s newer. So drivers publish sensor data best effort. Maps, commands and anything sent once go reliable.

The two must match. A subscriber that asks for reliable gets nothing from a best-effort publisher, with only a warning. This is the most common reason a topic “has no data”.

The second choice is the history depth: how many messages to keep waiting for a subscriber that’s busy. It’s part 7’s queue again.

A node takes 40 ms on each of 30 frames a second. With a depth of 10, a common default in examples, it falls behind until the queue is full, and then every result is about 360 ms old: the robot sees the room as it was a third of a second ago. With a depth of 1, it skips frames, uses 249 of 300, and each result is about 57 ms old.

Slide the depth: every extra message kept adds about a frame’s worth of delay, 33 ms. For data where only the newest matters, keep 1.

Part 1’s frames are kept by tf2: each node publishes the transforms it knows, and any node can ask for any transform between two frames. Two of them matter most.

The odometry (the EKF of part 12) publishes odom → base_link: the robot’s pose by its wheels and IMU. It’s smooth and never jumps, but it drifts: here, part 4’s faulty wheels, 27.3 cm off after a lap (orange). The localiser (parts 13 and 14) doesn’t fix that pose. It publishes map → odom: where the whole odometry frame sits in the map. The two together put the robot where it really is (blue). Scroll to drive the lap.

Controllers use the odom frame, which is smooth; planners use the map frame, which is right. The odom frame starts where the robot started. After the lap, the localiser has moved it 27.3 cm and turned it 8.0°: exactly the wheels’ error, undone in one transform, while odom → base_link stayed smooth.

Every sensor message carries a stamp: the time it was measured. That matters because tf2 keeps every transform for the last 10 seconds, and can give the robot’s pose at any moment in between.

To place a scan in the room, a node must ask for the transform at the scan’s stamp, not the newest one. Here the robot drives at 0.4 m/s and turns at 1 rad/s. Look the pose up 50 ms late and the points land 16.8 cm off on average, 33.4 cm at worst; even 10 ms late puts them 3.4 cm off. It’s part 9’s skew, made by software.

If the transform for the stamp hasn’t arrived yet, the right answer is to wait for it a little (tf2’s MessageFilter does this), not to use the old one.

A node, in code

A complete node in Python that subscribes to scans, with sensor QoS, and publishes a speed command that slows the robot down as the nearest obstacle in front gets closer:

import math

import rclpy
from geometry_msgs.msg import Twist
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import LaserScan


class Cautious(Node):
    def __init__(self):
        super().__init__("cautious")
        self.pub = self.create_publisher(Twist, "/cmd_vel", 10)
        # best effort, depth 5: matches what lidar drivers publish
        self.create_subscription(LaserScan, "/scan", self.on_scan, qos_profile_sensor_data)

    def on_scan(self, scan):
        ahead = [
            r
            for i, r in enumerate(scan.ranges)
            if abs(scan.angle_min + i * scan.angle_increment) < math.radians(20)
            and scan.range_min < r < scan.range_max
        ]
        nearest = min(ahead, default=scan.range_max)
        cmd = Twist()
        cmd.linear.x = max(0.0, min(0.4, 0.5 * (nearest - 0.3)))  # stop 30 cm away
        self.pub.publish(cmd)


rclpy.init()
rclpy.spin(Cautious())

Run it with ros2 run, or start many nodes at once with a launch file. Then the command-line tools show what’s happening: ros2 topic list, ros2 topic hz /scan (is it really 10 Hz?), ros2 topic info -v /scan (whose QoS doesn’t match?), and ros2 run tf2_tools view_frames (is the tf tree connected?). RViz draws scans, maps, paths and frames in 3-D, and ros2 bag record saves topics to replay later, so you can test a change on yesterday’s data.

Distributions

ROS 2 has a new release every May, named in alphabetical order; those in even years are supported for five years. As of October 2026:

release out supported until Ubuntu
Humble Hawksbill May 2022 May 2027 22.04
Jazzy Jalisco May 2024 May 2029 24.04
Kilted Kaiju May 2025 November 2026 24.04
Lyrical Luth May 2026 May 2031 26.04

For a new robot, use the newest long-term release your computer’s Ubuntu supports: Lyrical on Ubuntu 26.04, or Jazzy on 24.04, which is what many boards’ images still ship.

What’s still missing

  • The depth camera, the other sensor most robots have: part 11.
  • The nodes that publish odom → base_link and map → odom. Fusing the wheels with an IMU is part 12; localising on a map, part 13.

Going deeper

DDS, Zenoh and discovery

ROS 2 doesn’t send messages itself: it hands them to a middleware through a layer called the RMW. The default is eProsima’s Fast DDS; Cyclone DDS and, since Kilted, Zenoh are also fully supported. DDS nodes find each other by discovery, sending multicast messages on the local network. That’s why two robots on one network see each other’s topics (set a different ROS_DOMAIN_ID for each), and why discovery can struggle on Wi-Fi networks that block or drop multicast. Zenoh instead uses a router that nodes connect to, which tends to be easier over Wi-Fi and the internet.

micro-ROS on the ESP32

The ESP32 of part 6 can be a ROS node too, with micro-ROS: a small version of the ROS 2 client library that runs on FreeRTOS. It talks over serial or Wi-Fi to a micro-ROS agent on the main computer, which relays its messages into the ROS graph. Then /cmd_vel arrives on the microcontroller as a normal subscription, and wheel ticks leave as a normal topic. Keep the messages small: part 6 showed a full Odometry message is too big for a serial link at 50 Hz.

Executors and callbacks

A node’s callbacks run when spin calls them, from an executor. The default runs one callback at a time, so a slow scan callback delays a timer in the same node. Give slow work its own node or callback group, with a multi-threaded executor, so the control loop’s timer isn’t kept waiting.

Further reading




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • mobile robots from scratch, part 9: lidar, processing a scan
  • mobile robots from scratch, part 8: lidar, how it measures
  • mobile robots from scratch, part 7: the onboard computer, Raspberry Pi and Jetson
  • mobile robots from scratch, part 6: the microcontroller, ESP32
  • mobile robots from scratch, part 5: motors and PID control