7.4. rclcpp, the C++ client library#

In Section 7.2 you worked through the basics of the Python ROS client library, rclpy. In this section we will mirror the steps of that section, but now using the C++ ROS client library, rclcpp.

Note

In this section you will build simple ROS nodes in C++ using standard C++ build tools such as cmake and make, without creating a ROS package.

In Section 8.4 you will learn how to setup a C++ ROS package and use colcon to build your ROS projects.

7.4.1. Publishing Topics in C++#

Now we will write the same talker and listener pair as we did in Python, but this time using C++.

Here is a complete C++ program that publishes String messages:

#include <chrono>
#include <iostream>
#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>

int main(int argc, char *argv[])
{
    rclcpp::init(argc, argv);

    auto node = rclcpp::Node::make_shared("my_publisher");
    auto pub = node->create_publisher<std_msgs::msg::String>("chatter", 10);

    // Use lambda function to create callback using the node and publisher we created
    auto cb_timer = [node, pub]() -> void
    {
        // Get time reported by node
        auto now = node->get_clock()->now();
        double now_in_seconds = now.seconds();

        // Set the std_msgs/msg/String message
        auto message = std_msgs::msg::String();
        message.data = "Hello World, the time is: " + std::to_string(now_in_seconds);

        // Publish the message
        std::cout << "Publishing: '" << message.data << "'" << std::endl;
        pub->publish(message);
    };

    auto timer = rclcpp::create_timer(
        node, node->get_clock(), std::chrono::milliseconds(500), cb_timer);

    rclcpp::spin(node);
    rclcpp::shutdown();

    return 0;
}

Copy the C++ code into a file called talker.cpp. We will discuss this source code in more detail in a moment in Section 7.4.3.

Of course, as with all C++ programs, we need to compile this program before we can run it. Managing the compilation commands for C++ is cumbersome, so we use CMake to help us.

Here is a complete CMake build file that allows us to build the talker node. Save this in a file called CMakeLists.txt (the default name for CMake build files):

cmake_minimum_required(VERSION 3.5)
project(talker_listener)

find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)

add_executable(talker talker.cpp)
target_include_directories(talker PRIVATE ${rclcpp_INCLUDE_DIRS} ${std_msgs_INCLUDE_DIRS})
target_link_libraries(talker ${rclcpp_LIBRARIES} ${std_msgs_LIBRARIES})

Note

Let’s briefly analyse this CMakeLists.txt file:

  • The first two lines indicate that these instructions are intended for at least CMake version 3.5, and that this build file will define a new build project called talker_listener.

  • The next two lines with find_package(..) tell CMake this project will require additional configuration options to support using rclcpp and std_msgs, which will provide the C++ ROS client API and ROS std_msgs interface specifications in C++. These CMake packages will set additional variables, such as rclcpp_INCLUDE_DIRS and std_msgs_INCLUDE_DIRS that define additional C++ directories containing the header files that should be found by C++’s #include <...> statements. The variables rclcpp_LIBRARIES and std_msgs_LIBRARIES will specify which libraries should be linked to use these packages.

  • The next line tells CMake it should build an executable using a single source file: talker.cpp. The general syntax is add_executable(<executable_name> <cpp_file1> <cpp_file2> ...). So, the line here defines an executable talker based on a single source file talker.cpp.

  • The final two lines tell CMake that the talker executable will require the additional include directories and libraries provided by rclcpp and std_msgs.

Ok, let’s try to build the project now. Let’s make a build/ directory from where to execute the build process:

$ mkdir build
$ cd build

Warning

Building executables with C++ will generate a lot of intermediate files and directories. Keeping such intermediate files around is useful, because then we can skip some processing steps if we later need to rebuild our C++ project. However, it is not so nice to generate all these temporary files in the same directory as our source code, because it makes it harder for us to locate our source files, we might inadvertently add such derived files to our Git repository, or we might accidentally delete our source files when we try to cleanup the derived files.

Therefore, before we attempt to build our executable, it is good practice to create a separate directory from where we execute the build steps, such that all temporary files are stored there. A common name for such a directory is build/.

From within this (still) empty build directory, we can now invoke cmake and point it to the directory where the CMakeLists.txt file is located. Then cmake will generate the necessary setup files inside our current working directory. Since that working directory is build/ and the CMakeLists.txt is “one directory up”, we point cmake to our working directory’s parent directory using ..:

$ cmake ..
$ ls
ament_cmake_core/  ament_cmake_package_templates/  ament_cmake_uninstall_target/  CMakeCache.txt  CMakeFiles/ 
cmake_install.cmake  CTestConfiguration.ini  CTestCustom.cmake  CTestTestfile.cmake  Makefile

As we can see cmake generated a lot of additional configuration and setup files for us. Also note that there is now a file called Makefile. This Makefile contains the actual compiler and linker commands to build our program.

We can now build the program using the make program which will look for Makefile. From within the build/ directory containing the Makefile, simply run:

$ make

The make program will now work through the building recipe in the Makefile and generate the executable. If you make changes to the source files, you would just need to rerun this make command to rebuild the executable. The cmake instruction would only need to be rerun if you make changes to original the CMakeLists.txt file, for instance if you add additional executables to build or add new C++ source files to be used in an executable.

After make is finished, you should end up with a compiled executable called talker in the build/ directory. Still from the build/ directory, run the talker:

$ ./talker

You should see:

Publishing: 'Hello World, the time is: 1728380337.162038'
Publishing: 'Hello World, the time is: 1728380337.661520'
Publishing: 'Hello World, the time is: 1728380338.161462'

Keep the talker running and another shell try ros2 topic to listen in:

$ ros2 topic echo chatter

You should see (numbers will vary depending on timing between the two commands):

data: 'Hello World, the time is: 1728380545.713562'
---
data: 'Hello World, the time is: 1728380546.213435'
---
data: 'Hello World, the time is: 1728380546.713456'

Note

A benefit of using a separate build/ directory from our source code is that if we somehow mess up the temporary files, we can simply remove this one directory and make a clean new build. First, you would delete the build directory (rm -r build/), and then repeat the standard building steps:

mkdir build
cd build
cmake ..
make

7.4.2. Subscribing to Topics in C++#

Next, we can also write our own listener to use in place of ros2 topic. Here is a complete C++ code for a listener ROS node which subscribes to string messages and prints them to console:

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

void cb(const std_msgs::msg::String::SharedPtr msg)
{
  std::cout << "I heard: " << msg->data << std::endl;
}

int main(int argc, char * argv[])
{
  rclcpp::init(argc, argv);

  auto node = rclcpp::Node::make_shared("my_subscriber");
  auto sub = node->create_subscription<std_msgs::msg::String>("chatter", 10, cb);

  rclcpp::spin(node);
  return 0;
}

If you are still in the build/ subdirectory, go back to the parent directory (cd ..) where your other source file is located and copy the code block above into a new file called listener.cpp.

We now have to update our project configuration to build a second executable from this single C++ source file. To do this add some corresponding cmake instructions to the bottom of our CMakeLists.txt file from earlier:

add_executable(listener listener.cpp)
target_include_directories(listener PRIVATE ${rclcpp_INCLUDE_DIRS} ${std_msgs_INCLUDE_DIRS})
target_link_libraries(listener ${rclcpp_LIBRARIES} ${std_msgs_LIBRARIES})

Note

The CMakeLists.txt should now have 2 lines with add_executable(...), namely one to define the talker executable, and one to define the listener executable.

Re-enter the build/ directory, and configure and build your C++ project again:

$ cd build/
$ cmake ..
$ make

Now you should have also have a listener executable. With your talker still running in one shell, start up your listener in another shell:

$ ./listener

You should see (again, numbers will vary depending on timing):

I heard: "Hello World, the time is: 1728380337.661520"
I heard: "Hello World, the time is: 1728380338.161462"
I heard: "Hello World, the time is: 1728380338.661460"

7.4.3. Digging into the C++ Code#

Now that we know these programs work, we can dig into their code. Both programs start with the same preamble:

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

We always need to include the rclcpp client library, which gives us much of what we need to write ROS applications in C++. But we also need to specifically import the ROS message type(s) that we will use. In this case we are using the simple std_msgs/String message, which contains a single field called data, of type string. If we wanted to use the sensor_msgs/Image message, which represents camera images, then we would #include "sensor_msgs/msg/image.hpp".

After the imports, both programs perform common initialization:

rclcpp::init(argc, argv);
auto node = rclcpp::Node::make_shared("my_node_name");

We initialize the rclcpp library and then call into it to create a Node object via a shared pointer, giving the node its name in the contructor. Subsequently we will operate on that Node object.

In the talker, we use the Node object to create a shared pointer to a Publisher object:

auto pub = node->create_publisher<std_msgs::msg::String>("chatter", 10);

We declare via template the type of data we will publish (std_msgs/String), the name of the topic on which we will publish (chatter), and the maximum number of outbound messages to locally queue up (10). That last argument comes into play when we are publishing faster than subscribers are consuming the data.

The equivalent step in the listener is to create a shared pointer to a Subscription object:

auto sub = node->create_subscription<std_msgs::msg::String>("chatter", 10, cb);

The type (String) and topic name (chatter) arguments have the same meaning as for the create_publisher() call, and the numerical argument (10) is setting an analogous maximum queue size for inbound messages. The key difference is the cb argument, which refers to this callback function that we also defined in the listener:

void cb(const std_msgs::msg::String::SharedPtr msg)
{
  std::cout << "I heard: " << msg->data << std::endl;
}

That function will be called whenever the listener receives a message, and a shared pointer to the received message will be passed in as an argument. In this case we simply print the content to console.

Note

You can see that the type of the msg argument is std_msgs::msg::String::SharedPtr. This type is just a shorthand for std::shared_ptr<std_msgs::msg::String>, the shared pointer type for ROS’s String message format. Many ROS classes provide such shorthands for their shared pointers, for instance:

  • rclcpp::Node::SharedPtr is a shorthand for std::shared_ptr<rclcpp::Node>

  • rclcpp::Publisher::SharedPtr is a shorthand for std::shared_ptr<rclcpp::Publisher>

  • rclcpp::Subscriber::SharedPtr is a shorthand for std::shared_ptr<rclcpp::Subscriber>

  • etc.

With the callback defined and the Subscription created, the rest of the listener is one line:

rclcpp::spin(node);

This call hands control over to rclcpp to wait for new messages to arrive (and more generally for events to occur) and invoke our callback.

Back in the talker, we create a callback function for a timer to call. In this example, we used a C++11 lambda function to define the callback. The callback will need to access node to get the node’s current timestamp, and it will need to access the publisher pub to publish the message. Therefore, we let the lambda function capture the node and pub shared pointers. The callback function itself takes no arguments and should return void, as this is the function signature rclcpp::create_timer expects for the callback.

// Use lambda function to create callback using the node and publisher we created
auto cb_timer = [node, pub]() -> void { 
    // Get time reported by node
    auto now = node->get_clock()->now();
    double now_in_seconds = now.seconds();

    // Set the std_msgs/msg/String message
    auto message = std_msgs::msg::String();
    message.data = "Hello World, the time is: " + std::to_string(now_in_seconds);

    // Publish the message
    std::cout << "Publishing: \"" << message.data << "\"" << std::endl;
    pub->publish(message);
};

Once the callback is defined, we can setup the timer for our node, where the delay between subsequent calls can be defined using objects from std::chrono that represent time durations:

auto timer = rclcpp::create_timer(node, node->get_clock(), std::chrono::milliseconds(500), cb_timer);

Finally, the main loop for our talker can also be executed:

rclcpp::spin(node);

In these steps we create a message object then on each iteration of the loop we update the message content and publish it, sleeping briefly between iterations.

7.4.4. Summary#

That completes the (very) brief introduction to the C++ ROS client library. You have implemented two ROS nodes and configured the CMakeLists.txt to build the executables. We only covered the basics of using topics, but again not services, actions, parameters, or the many other facets of ROS.

In a later section, you will write C++ nodes as part of a ROS 2 package.