Note: This is part of a series where I follow along with the ROS 2 Humble and Gazebo Fortress tutorials and demos. This is not original content.

Aaaand we’re back! Let’s follow along with this tutorial today, which will teach us how to create a simple ROS publisher/subscriber pair written in C++.

A quick refresher Link to heading

Remember that systems built using ROS consist of a set of nodes (processes) that communicate with each other over the ROS graph using topics.

  • Nodes which send messages to a topic are publishers
  • Nodes which receive the messages from the topic are subscribers

Creating a workspace and a package Link to heading

Let’s create a new workspace, then create a new package within the workspace:

mkdir -p ~/pubsub_ws
mkdir -p ~/pubsub_ws/src
cd ~/pubsub_ws/src
ros2 pkg create --build-type ament_cmake --license Apache-2.0 cpp_pubsub

That should return a message indicating that our package was created successfully:

ros2 pkg create --build-type ament_cmake --license Apache-2.0 cpp_pubsub
bash: cd: pubsub_ws/src: No such file or directory
going to create a new package
package name: cpp_pubsub
destination directory: /home/ubuntu
package format: 3
version: 0.0.0
description: TODO: Package description
maintainer: ['ubuntu <ubuntu@todo.todo>']
licenses: ['Apache-2.0']
build type: ament_cmake
dependencies: []
creating folder ./cpp_pubsub
creating ./cpp_pubsub/package.xml
creating source and include folder
creating folder ./cpp_pubsub/src
creating folder ./cpp_pubsub/include/cpp_pubsub
creating ./cpp_pubsub/CMakeLists.txt

Now, move to the folder which will hold the code for the package and fetch the example code from the ROS docs:

cd ~/pubsub_ws/src/cpp_pubsub/src
wget -O publisher_member_function.cpp https://raw.githubusercontent.com/ros2/examples/humble/rclcpp/topics/minimal_publisher/member_function.cpp

Open up publisher_member_function.cpp and take a look at the top of the file. You’ll see some #include statements there:

#include <chrono>
#include <functional>
#include <memory>
#include <string>

#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"

These allow us to use some of ROS’s built in functonality. In particular, rclcpp.hpp includes some basic ROS functionality, and std_msgs/msg/string.hpp defines a built-in ROS message type.

These #includes represent external code that we will depend on. Remember that we must also update package.xml and CMakeLists.txt to include all of our dependencies. We’ll do that later.

There is one class in our .cpp file:

class MinimalPublisher : public rclcpp::Node

The MinimalPublisher class inherits from one of the built-in classes provided by ROS, rclcpp::Node.

The official docs spend a lot of time going through the body of the class line-by-line. Instead, I reproduce the whole thing here and then attempt to summarize the public and private blocks and their functions. First, here’s the whole thing:

class MinimalPublisher : public rclcpp::Node
{
public:
  MinimalPublisher()
  : Node("minimal_publisher"), count_(0)
  {
    publisher_ = this->create_publisher<std_msgs::msg::String>("topic", 10);
    timer_ = this->create_wall_timer(
      500ms, std::bind(&MinimalPublisher::timer_callback, this));
  }

private:
  void timer_callback()
  {
    auto message = std_msgs::msg::String();
    message.data = "Hello, world! " + std::to_string(count_++);
    RCLCPP_INFO(this->get_logger(), "Publishing: '%s'", message.data.c_str());
    publisher_->publish(message);
  }
  rclcpp::TimerBase::SharedPtr timer_;
  rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
  size_t count_;
};

int main(int argc, char * argv[])
{
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<MinimalPublisher>());
  rclcpp::shutdown();
  return 0;
}

Public block Link to heading

See this block at the top?

public:
  MinimalPublisher()
  : Node("minimal_publisher"), count_(0)
  {
    publisher_ = this->create_publisher<std_msgs::msg::String>("topic", 10);
    timer_ = this->create_wall_timer(
      500ms, std::bind(&MinimalPublisher::timer_callback, this));
  }

What does this code do? In short:

  • Names the node minimal_publisher
  • Sets a message counter to 0
  • Creates a topic that sends String and has a queue depth of 10 messages (if the depth exceeds 10, we start dropping messages)
  • Initializes timer_ so that timer_callback will excute twice a second

Private block Link to heading

The class contains a private function called timer_callback():

private:
  void timer_callback()
  {
    auto message = std_msgs::msg::String();
    message.data = "Hello, world! " + std::to_string(count_++);
    RCLCPP_INFO(this->get_logger(), "Publishing: '%s'", message.data.c_str());
    publisher_->publish(message);
  }
  rclcpp::TimerBase::SharedPtr timer_;
  rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
  size_t count_;
};

What’s going on here?

  • The data our messages will contain is set by the timer_callback() function
  • The macro RCLCPP_INFO makes sure messages get printed to the console
  • Timer, publisher, and counter fields are declared (bottom 3 lines just above };)

main function Link to heading

And of course as with any C or C++ program below all our #imports and definitions, we have main(), the code which actually gets run when our program is invoked:

int main(int argc, char * argv[])
{
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<MinimalPublisher>());
  rclcpp::shutdown();
  return 0;
}

What’s it do?

  • rclcpp::init initializes ROS 2
  • rclcpp::spin starts processing data from the node (this includes our twice-a-second timer callbacks)
  • rclcpp::shutdown() gracefully shuts down our node if ROS 2 asks it to shut down

Adding dependencies Link to heading

We need to add our dependencies into CMakeLists.txt and package.xml to make sure the build process won’t fail.

Updating package.xml Link to heading

Here’s a completed package.xml, note the <depend> lines at the bottom:

<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
  <name>cpp_pubsub</name>
  <version>0.0.0</version>
  <description>TODO: Package description</description>
  <maintainer email="ubuntu@todo.todo">ubuntu</maintainer>
  <license>Apache-2.0</license>

  <buildtool_depend>ament_cmake</buildtool_depend>

  <depend>rclcpp</depend>
  <depend>std_msgs</depend>

  <test_depend>ament_lint_auto</test_depend>
  <test_depend>ament_lint_common</test_depend>

  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

Note: Don’t forget to update your <description>, <maintainer>, and <license>.

Updating CMakeLists.txt Link to heading

Here’s a completed CMakeLists.txt, note the find_package() lines at the bottom:

cmake_minimum_required(VERSION 3.5)
project(cpp_pubsub)

# Default to C++14
if(NOT CMAKE_CXX_STANDARD)
  set(CMAKE_CXX_STANDARD 14)
endif()

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)

add_executable(talker src/publisher_member_function.cpp)
ament_target_dependencies(talker rclcpp std_msgs)

install(TARGETS
  talker
  DESTINATION lib/${PROJECT_NAME})

ament_package()

We have removed some if(BUILD_TESTING) code which is part of default, newly-created CMakeLists.txt files, as we don’t need it here.

Create the subscriber node Link to heading

Now we repeat the process above to creat ea subscriber:

cd ~/pubsub_ws/src/cpp_pubsub/src/
wget -O subscriber_member_function.cpp https://raw.githubusercontent.com/ros2/examples/humble/rclcpp/topics/minimal_subscriber/member_function.cpp

There should now be two .cpp files in src:

publisher_member_function.cpp
subscriber_member_function.cpp

Once again let’s look at the whole code before breaking it into chunks and summarizing what each chunk does:

#include <functional>
#include <memory>

#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"

using std::placeholders::_1;

class MinimalSubscriber : public rclcpp::Node
{
public:
  MinimalSubscriber()
  : Node("minimal_subscriber")
  {
    subscription_ = this->create_subscription<std_msgs::msg::String>(
      "topic", 10, std::bind(&MinimalSubscriber::topic_callback, this, _1));
  }

private:
  void topic_callback(const std_msgs::msg::String & msg) const
  {
    RCLCPP_INFO(this->get_logger(), "I heard: '%s'", msg.data.c_str());
  }
  rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
};

int main(int argc, char * argv[])
{
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<MinimalSubscriber>());
  rclcpp::shutdown();
  return 0;
}

The code is almost exactly the same so we. don’t need to spend as much time dissecting it. Important bits are:

  • topic_callback which receives messages published over the topic
  • RCLCPP_INFO which once again ensures messages are written to the console
  • A single field declaration for a field called subscription which receives String messages

And of cousre there is a main() function, which in this case sets up our MinimalSubscriber node and starts listening for messages.

Note: The dependencies for the subcsriber are the same as those for the publisher, so we don’t need to add new dependencies package.xml or CMakeLists.txt. However we do need to make some small chagnes to CMakeLists.txt, covered in the next section.

Final edits to CMakeLists.txt Link to heading

Under the entries added for the publisher, we add a new install field. Here’s the new complete CmakeLists.txt file:

cmake_minimum_required(VERSION 3.5)
project(cpp_pubsub)

# Default to C++14
if(NOT CMAKE_CXX_STANDARD)
  set(CMAKE_CXX_STANDARD 14)
endif()

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)

add_executable(talker src/publisher_member_function.cpp)
ament_target_dependencies(talker rclcpp std_msgs)

install(TARGETS
  talker
  DESTINATION lib/${PROJECT_NAME})

add_executable(listener src/subscriber_member_function.cpp)
ament_target_dependencies(listener rclcpp std_msgs)

install(TARGETS
  talker
  listener
  DESTINATION lib/${PROJECT_NAME})

ament_package()

Build the package Link to heading

Now we can build and run the code. First, make sure all the required dependencies are installed:

cd ~/pubsub_ws/
rosdep install -i --from-path src --rosdistro humble -y

Then we build with:

colcon build --packages-select cpp_pubsub

Run the code Link to heading

Per usual we need to add our workspace as a overlay with source. A shortcut to do this is:

. install/setup.bash

We can now run the code

ros2 run cpp_pubsub talker

Which will output:

[INFO] [1723714559.668716716] [minimal_publisher]: Publishing: 'Hello, world! 0'
[INFO] [1723714560.168713865] [minimal_publisher]: Publishing: 'Hello, world! 1'
[INFO] [1723714560.668717908] [minimal_publisher]: Publishing: 'Hello, world! 2'
[INFO] [1723714561.168717701] [minimal_publisher]: Publishing: 'Hello, world! 3'
[INFO] [1723714561.668719724] [minimal_publisher]: Publishing: 'Hello, world! 4'
[INFO] [1723714562.168721101] [minimal_publisher]: Publishing: 'Hello, world! 5'
[INFO] [1723714562.668716040] [minimal_publisher]: Publishing: 'Hello, world! 6'
[INFO] [1723714563.168737803] [minimal_publisher]: Publishing: 'Hello, world! 7'
[INFO] [1723714563.668727827] [minimal_publisher]: Publishing: 'Hello, world! 8'
[INFO] [1723714564.168715178] [minimal_publisher]: Publishing: 'Hello, world! 9'
[INFO] [1723714564.668721436] [minimal_publisher]: Publishing: 'Hello, world! 10'
[INFO] [1723714565.168721578] [minimal_publisher]: Publishing: 'Hello, world! 11'
[INFO] [1723714565.668722939] [minimal_publisher]: Publishing: 'Hello, world! 12'
[INFO] [1723714566.168723089] [minimal_publisher]: Publishing: 'Hello, world! 13'
[INFO] [1723714566.668725820] [minimal_publisher]: Publishing: 'Hello, world! 14'
[INFO] [1723714567.168726418] [minimal_publisher]: Publishing: 'Hello, world! 15'
[INFO] [1723714567.668728895] [minimal_publisher]: Publishing: 'Hello, world! 16'
[INFO] [1723714568.168725383] [minimal_publisher]: Publishing: 'Hello, world! 17'
[INFO] [1723714568.668725291] [minimal_publisher]: Publishing: 'Hello, world! 18'
[INFO] [1723714569.168725436] [minimal_publisher]: Publishing: 'Hello, world! 19'

We then do more or less the same thing in a new terminal window, except we run the listener:

. install/setup.bash
ros2 run cpp pubsub listener

Which will output something like this (with the message numbers depending on how much later you started the listener than the talker):

[INFO] [1723714564.168927211] [minimal_subscriber]: I heard: 'Hello, world! 9'
[INFO] [1723714564.668937723] [minimal_subscriber]: I heard: 'Hello, world! 10'
[INFO] [1723714565.168926458] [minimal_subscriber]: I heard: 'Hello, world! 11'
[INFO] [1723714565.668908873] [minimal_subscriber]: I heard: 'Hello, world! 12'
[INFO] [1723714566.168936092] [minimal_subscriber]: I heard: 'Hello, world! 13'
[INFO] [1723714566.668940297] [minimal_subscriber]: I heard: 'Hello, world! 14'
[INFO] [1723714567.168951832] [minimal_subscriber]: I heard: 'Hello, world! 15'
[INFO] [1723714567.668957725] [minimal_subscriber]: I heard: 'Hello, world! 16'
[INFO] [1723714568.168941230] [minimal_subscriber]: I heard: 'Hello, world! 17'
[INFO] [1723714568.668918680] [minimal_subscriber]: I heard: 'Hello, world! 18'
[INFO] [1723714569.168950312] [minimal_subscriber]: I heard: 'Hello, world! 19'

Try stopping the talker (publisher) first and then seeing what happens to the listener. That’s right! It stopped printing messages to the console (because it is no longer receiving any messages from the publisher).

That’s it for this time. Next time, we’ll look at creating a simpe publisher/subscriber using Python.

For more pub/sub examples, look here.