Featured illustration by Josh Ellingson CC BY-NC 2.0
![Cover of Make Volume 95. Headline is "Super [Tiny] Computers". A Raspberry Pi 500+ with RGB lights and an Arduino Q board are on the cover.](https://i0.wp.com/makezine.com/wp-content/uploads/2025/10/M95_Cover_promo.jpeg?resize=150%2C213&ssl=1)
You’ve been hired to work on a large humanoid robot along with a dozen other engineers. Your job is to develop the arms and hands: moving them to specific locations, picking up objects, shaking human hands, and so on. You do not have direct sensor input, as that’s another engineer’s job. You also need to work with the locomotion team, as the arms need to move to help balance the robot. Everyone meets one day to figure out how all this will work together.
In the conference room, on a giant whiteboard, you map out all the components and how they’ll interact. You need the cameras and motion sensors in the head to send raw data to the main processing core, which computes motions for the arms and legs. But those positions also affect the stability of the robot, so positional data needs to be sent back to the processing code.
How will you accomplish all this communication? Will you need to develop a unique scheme for each component?
The Robot Operating System
Up until the late 2000s, roboticists struggled with these exact concepts and would often re-create messaging schemes from scratch for every new robot. “Reinventing the wheel” of messaging and underlying frameworks ended up consuming more time than actually building the robot!
In 2006, two Ph.D. students at Stanford’s Salisbury Robotics Lab, Eric Berger and Keenan Wyrobek, set out to solve this problem. They created the Robot Operating System (ROS) to standardize communication among various robot components. Scott Hassan, founder of the Willow Garage incubator, took notice and invited Berger and Wyrobek to continue their work in Willow Garage’s program.
Over the next 3 years, the team built the PR2 robot, a successor to the PR1 begun at Stanford, and fleshed out ROS to act as the underlying software framework for the PR2.

Photo by Timothy Vollmer CC BY 2.0
ROS is an open-source robotics middleware framework and collection of libraries. It is not a true “operating system” like Windows, macOS, or Linux, as it cannot control hardware directly and does not have a kernel for handling processes and memory allocation. Rather, it is built on top of an operating system (usually Linux), handles multiprocessing communication, and offers a collection of computational libraries, such as the Transform Library 2 (TF2) for handling coordinate frame transformations.
Because ROS requires a full operating system and assumes you’re working in a multiprocessing environment, it is not well suited for simple, single-purpose robotics, like basic vacuum robots or maze solvers. Rather, scalability is the deciding factor: when you have multiple, complex components that need to operate together, ROS can save you hundreds of hours of work and frustration.
While ROS is used across academia for research, it also has been adopted by industry for real, commercial robots. Examples include some of the Amazon warehouse robots, Avidbots commercial-grade cleaners, and Omron’s TM manipulator arms.
If you’re looking to build a large, complex helper bot for household chores, improve your robotic programming skills for a job, or simply see what the hype is about, we’ll walk you through installing ROS and creating simple communication examples using topics and services.
Install ROS Docker Image
The first iteration of ROS had some technical limitations in the underlying messaging layers, so the team created ROS 2, which began life in 2014. ROS 1 reached end-of-life status on May 31, 2025, which means it will no longer receive updates or support. ROS 2 has fully replaced it.
About once a year the ROS team releases a new distribution, which is a versioned set of ROS packages, much like Linux distributions. Each release is given a whimsical, alliterative name featuring a turtle and progressing through the alphabet. The latest release, Kilted Kaiju, came out in May 2025, but we will stick to Jazzy Jalisco, which has long-term support until 2029.
Each ROS distribution is pinned to a very particular version of an operating system to ensure that all of its underlying libraries work properly. Jazzy Jalisco’s officially supported operating systems are Ubuntu 24.04 and Windows 10 (with Visual Studio 2019).
For a real robot that communicates with motors and sensors, you likely want Ubuntu installed on a small laptop or single-board computer (e.g. Raspberry Pi). For this tutorial, I will demonstrate a few ROS principles using a premade Docker image. This image pins various package versions and works across all the major operating systems (macOS, Windows, and most Linux distributions).

If you do not have it already installed on your host computer, head to docker.com, download Docker Desktop, and run the installer. Accept all the defaults.
Next, get the ROS Docker image and example repository. Navigate to the GitHub repository, click Code, and click Download ZIP. Unzip the archive somewhere on your computer.
Open a command line terminal (e.g. zsh, bash, PowerShell), navigate to the project directory, and build the image:
cd introduction-to-ros/
docker build -t env-ros2 .
Wait while the Docker image builds. It is rather large, as it contains a full instance of Ubuntu 24.04 with a graphical interface.

Once that finishes, run the image with one of the following commands, depending on your operating system.
For macOS or Linux:
docker run --rm -it -e PUID=$(id -u) -e PGID=$(id -g) -p 22002:22 -p 3000:3000 -v “${PWD}/workspace:/config/workspace” env-ros2
For Windows (PowerShell):
docker run --rm -it -e PUID=$(wsl id -u) -e PGID=$(wsl id -g) -p 22002:22 -p 3000:3000 -v “${PWD}\workspace:/config/workspace” env-ros2
If everything works, you should see the Xvnc KasmVNC welcome message. You can ignore the keysym and mieq warnings as well as the xkbcomp error message.

Open a browser on your host computer and navigate to https://localhost:3000. You should be presented with a full Ubuntu desktop.

Topics: Publish and Subscribe
In ROS 2, applications are divided up into a series of nodes, which are independent processes that handle specific tasks, such as reading sensor data, processing algorithms, or driving motors. Each node runs separately in its own runtime environment and can communicate with other nodes using a few basic techniques.
The first communication method is the topic, which relies on a publish/subscribe messaging model. A publisher can send data to a named topic, and the underlying ROS system will handle delivering that message to any node subscribed to that topic.

Nodes in ROS are independent runtime processes, which essentially means they’re separate programs that can be written in one of several supported programming languages. Out of the box, ROS 2 supports Python and C++, but you can write Nodes in other community-supported languages like Ada, C, Java, .NET (e.g. C#), Node.js (JavaScript), Rust, and Flutter (Dart). The beauty of ROS is that nodes written in one language can communicate with nodes written in other languages!
In general, you’ll find C++ used for low-level drivers and processes that require fast execution. Python nodes, on the other hand, offer faster development time with some runtime overhead, which makes them great for prototyping and working with complex vision processing (e.g. OpenCV) and machine learning frameworks (e.g. PyTorch, TensorFlow).
ROS relies heavily on object-oriented programming principles. Individual nodes are written as subclasses of the Node class, inheriting properties from it as defined by ROS. Publishers and subscribers are object instances within these nodes. As a result, your custom node can have any number of publishers and subscribers.
In our example, we will create a simple subscriber in a node that listens on the my_topic topic channel and prints to the console whatever it hears over that channel. We will then create a simple publisher in another node that transmits “Hello world” followed by a count value to that topic twice per second.
Create a ROS Package
Double-click the VS Code ROS2 icon on the left of the desktop. This opens an instance of VS Code in the Docker container preconfigured with various extensions, and it automatically enables the ROS 2 development environment.
Click View → Terminal to open a terminal pane at the bottom of VS Code.
Navigate into the src/ directory in the workspace/ folder. Note that we mounted the workspace/ directory from the host computer’s copy of the introduction-to-ros repository. That gives you access to all the code from the repository, and any changes you make to files in the workspace/ directory will be saved on your host computer. If you change anything in the container outside of that directory, it will be lost, as the container is completely deleted when you exit!
In ROS 2, a workspace is a directory where you store and build ROS packages. The workspace/ folder is considered a ROS 2 workspace. A package is a fundamental unit of code organization in ROS 2. You will write one or more nodes (parts of your robot application) in a package in the src/ directory in the workspace. When you build your nodes (in a package), any required libraries, artifacts, and executables end up in the install/ directory in the workspace. ROS 2 uses the build/ and log/ directories to put intermediate artifacts and log files, respectively.
From the workspace/ directory, navigate into the src/ folder and create a package. Note that the Docker image mounts the workspace/ directory (on your host computer) to /config/workspace/ in the container.
cd /config/workspace/src
ros2 pkg create --build-type ament_python my_first_pkg
This will create a directory named my_first_pkg/ in workspace/src/. You might need to click the Refresh Explorer button at the top of VS Code to see the folder show up. The my_first_pkg/ folder will contain a basic template for creating nodes. Note that we used ament_python as the build type, which tells ROS 2 that we intend to use Python to create nodes in this package.

We will write our source code in my_first_pkg/my_first_pkg/. The other files in my_first_pkg/ help ROS understand how to build and install our package.
The workspace contains a number of other example packages from the GitHub repository, such as my_bringup and my_cpp_pkg. You are welcome to explore those to see how to implement other nodes and features in ROS.
Create Publisher and Subscriber Nodes
Create a new file named my_publisher.py in my_first_pkg/my_first_pkg/ and open it in VS Code:
code my_first_pkg/my_first_pkg/my_publisher.py
Open a web browser and navigate to bit.ly/41TlYuj to get the publisher Python code.
Copy the code from that GitHub page into your my_publisher.py document. Feel free to read through the comments.
Notice that we are creating a subclass named MinimalPublisher from the ROS-provided Node class, which gives us access to all the data and methods in Node. Inside our MinimalPublisher, we create a new publisher object with self.create_publisher(), which is a method in the Node class.

We also create a new timer object and set it to call the _timer_callback() method every 0.5 seconds. In that method, we construct a “Hello world: ” string followed by a counter number that we increase each time _timer_callback() executes.
In our main() entrypoint, we initialize rclpy, which is the ROS Client Library (RCL) for Python. This gives us access to the Node class and other ROS functionality in our code. We create an instance of our MinimalPublisher() class and then tell it to spin(), which just lets our node run endlessly.
If our program crashes or we manually exit (e.g. with Ctrl+C), we perform some cleanup to ensure that our node is gracefully removed and rclpy is shut down. The last two lines are common Python practice: if the current file is run as the main application, Python assigns the String ‘__main__’ to the internal variable __name__. If this is the case, then we tell Python to run our main() function.
Save your code. Create a new file named my_subscriber.py in my_first_pkg/my_first_pkg/ and open it in VS Code:
code my_first_pkg/my_first_pkg/my_subscriber.py
Open a web browser and navigate to bit.ly/3JqL0ut to get the subscriber Python code.
Copy the code from that GitHub page into your my_subscriber.py document.
Similar to our publisher, we create a subclass from Node named MinimalSubscriber. In that node, we instantiate a subscription object with a callback. The callback is the method _listener_callback() that gets called whenever a message appears on the topic we subscribe to (‘my_topic’). The message is passed into the method as the msg parameter. In our callback, we simply print that message to the screen.

As with our previous program’s main(), we initialize rclpy, instantiate our node subclass, and let it run. We catch any crashes or exit conditions and gracefully shut everything down.
Don’t forget to save your work!
Build the Package
Before we can build our package, we need to tell ROS about our nodes and list any dependencies. Open my_first_pkg/package.xml, which was created when we generated the package template. This file is the package manifest that lists any metadata and dependencies for the ROS package. You can give your package a unique name and keep track of the version here. The only line we need to add tells ROS that we are using the rclpy package as a dependency in our package. Just after the <license> line, add the following:
<license>TODO: License declaration</license>
<depend>rclpy</depend>
<test_depend>ament_copyright</test_depend>
We add this line as our package requires rclpy to build and run, as noted by the import rclpy line in our publisher and subscriber code. While you can import any Python packages or libraries you might have installed on your system in your node source code, you usually want to list imported ROS packages in package.xml. This helps the ROS build systems and runtime know what ROS packages to use with your package. Save this file.
Next, we need to tell the ROS build system which source files it should build and install. Open my_first_pkg/setup.py, which was also created on package template generation. The entry_points parameter lists all of the possible entry points for the executables in the package. It allows us to list functions in our code that act as entry points for new applications or processes.
Add the following to the console_scripts key:
entry_points={
‘console_scripts’: [
“my_publisher = my_first_pkg.my_publisher:main”,
“my_subscriber = my_first_pkg.my_subscriber:main”,
],
},
Save this file. We’re finally ready to build! In the terminal, navigate back to the workspace directory and use the colcon build command to build just our package, which we select with the --packages-select parameter. Colcon is the build tool for ROS 2 and helps to manage the workspace.
cd /config/workspace/
colcon build --packages-select my_first_pkg
Your package should build without any errors.

Run Your Publisher and Subscriber
Now it’s time to test your code. Click on the Applications menu in the top-left of the container window, and click Terminal Emulator. Repeat this two more times to get three terminal windows. You are welcome to use the desktop icons in the top-right of the container window to work on a clean desktop.

We need two of the terminal windows to run our publisher and subscriber as two separate processes. We’ll use the third window to examine a graph of how our nodes are communicating.
In the first terminal, source the new package environment and run the node. The Docker image is configured to initialize the global ROS environment, so ROS commands and built-in packages work in these terminal windows. However, ROS does not know about our new package yet, so we need to tell it where to find that package. So, every time you open a new terminal window, you need to source the environment for the workspace you wish to use, which includes all the packages built in that workspace. We do this by running an automatically generated bash script in our workspace.
From there, run our custom publisher node using the ros2 run command:
cd /config/workspace/
source install/setup.bash
ros2 run my_first_pkg my_publisher
This should start running the publisher in the first terminal.
In the second terminal, source the workspace setup.bash script again and run the subscriber:
cd /config/workspace/
source install/setup.bash
ros2 run my_first_pkg my_subscriber
You should see the “Hello world: ” string followed by a counter appear in both terminals. The publisher prints its message to the console for debugging, and the subscriber prints out any messages it receives on the my_topic topic.

Finally, in the third terminal, run the RQt graph application, which gives you a visualization of how your two nodes are communicating:
rqt_graph
RQt is a graphical interface that helps users visualize and debug their ROS 2 applications. The RQt graph shows nodes and topics, which can be extremely helpful as you start running dozens or hundreds of nodes in your robot application. RQt contains many other useful tools, but we’ll stick to the RQt graph for now. Note that you might need to press the Refresh button in the top-left of the window to get the nodes to appear.

Press Ctrl+C in each of the terminals or close them all to stop the nodes.
Clients and Servers, Requests and Responses
The publish/subscribe model works well when you need to regularly transmit data out on a particular subject, such as sensor readings, but it doesn’t work well when you need one node to make a request to another node. For this, ROS has another method for handling messaging between nodes: services.
A service follows a client/server model where one node acts as a server, waiting to receive incoming requests. Another node acts as a client and sends a request to that particular server and waits for a response. This pattern works well in instances where you want to set a parameter, trigger an action, or receive a one-time update from another node. For example, your computation node might request that the motion node moves the robot forward by some amount.

Create Subscriber and Client Nodes
In my_first_pkg/my_first_pkg/, create a new file, my_server.py:
cd /config/workspace/src/
code my_first_pkg/my_first_pkg/my_server.py
Copy the code found at bit.ly/4fSWUcT and paste it into that file.
Here, we create another subclass node named MinimalServer. In that node, we instantiate a service named add_ints, which turns the node into a server. We must specify an interface for the service so that we know what kind of data it contains. In this case, we specify AddTwoInts as the interface, which was imported at the top of the file in the from example_interfaces.srv... line. ROS 2 contains a number of example interfaces, but you can also define your own. Finally, we attach the callback method _server_callback() to the service.
Whenever a request comes in for that named service, _server_callback() is called. The request is stored in the req parameter. This service works only with the AddTwoInts interface, so we know that the request will have two fields: a and b, each containing an integer. We add the two integers together, store the sum in the sum field of the response, print a message to the console for debugging, and then return the response object. The ROS 2 framework will handle the underlying details of delivering the response message back to the client. Save your file.
Now let’s create our client. In my_first_pkg/my_first_pkg/, create my_client.py:
code my_first_pkg/my_first_pkg/my_client.py
Copy the code found at bit.ly/47bipmK and paste it into that file.
In our client node, we create a client object and give it the interface, AddTwoInts, and the name of the service, ‘add_ints’. We also create a timer object, much like we did for the publisher node. In the callback method for the timer, we fill out the request, which adheres to the AddTwoInts interface. We then send the request message to the server and assign the result to a future. We repeat this process every 2 seconds.
A future is an object that acts as a placeholder for a result that will be available later. We add a callback to that future, which gets executed when this node receives the response from the server. At that point, the future is complete, and we can access the value within. The response callback simply prints the resulting sum to the console. Don’t forget to save your work!
As we did with the publisher and subscriber examples, we need to tell the ROS 2 build system about our new nodes. Add the following to my_first_pkg/setup.py:
entry_points={
‘console_scripts’: [
“my_publisher = my_first_pkg.my_publisher:main”,
“my_subscriber = my_first_pkg.my_subscriber:main”,
“my_client = my_first_pkg.my_client:main”,
“my_server = my_first_pkg.my_server:main”,
],
},
Rebuild your package:
cd /config/workspace/
colcon build --packages-select my_first_pkg
The package should build without any errors.

Run Your Server and Client
As you did with the publisher and subscriber demo, open three terminal windows. In the first window, source your workspace environment and run the server node:
cd /config/workspace/
source install/setup.bash
ros2 run my_first_pkg my_server
In the second window, source the workspace environment again and run the client node:
cd /config/workspace/
source install/setup.bash
ros2 run my_first_pkg my_client
You should see the server terminal receive the request from the client containing two random integers for a and b, each between 0 and 10. The server sends the response back to the client, which prints the sum to the terminal.

You are welcome to run rqt_graph in the third terminal, but you will only see the nodes — no service interface or lines connecting them as you saw with topics.

Mighty Middleware
It might seem odd that we spent all this time just getting some pieces of software to talk to each other, but these concepts form the basis for ROS. Remember, ROS is a middleware messaging layer at its core, not a collection of robot drivers or sensor libraries. ROS solves an important challenge by helping developers scale software projects in large, complex robotics. It contains far too much overhead to be useful for smaller robotics projects.
That being said, I hope this brief introduction satisfied your curiosity about ROS. If you’d like to learn more, check out the examples and getting started video series. Beyond the messaging system we just looked at, ROS ships with a number of libraries, like TF2, diagnostic tools, and visualizers to help you build, test, and deploy your robot software. Topics and services are just the beginning; ROS scales up to handle extremely complex designs and is currently used in many commercial robots found around the world!
This article appeared in Make:Volume 95.
ADVERTISEMENT


