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 usingrclcpp
andstd_msgs
, which will provide the C++ ROS client API and ROSstd_msgs
interface specifications in C++. These CMake packages will set additional variables, such asrclcpp_INCLUDE_DIRS
andstd_msgs_INCLUDE_DIRS
that define additional C++ directories containing the header files that should be found by C++’s#include <...>
statements. The variablesrclcpp_LIBRARIES
andstd_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 isadd_executable(<executable_name> <cpp_file1> <cpp_file2> ...)
. So, the line here defines an executabletalker
based on a single source filetalker.cpp
.The final two lines tell CMake that the
talker
executable will require the additional include directories and libraries provided byrclcpp
andstd_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 forstd::shared_ptr<rclcpp::Node>
rclcpp::Publisher::SharedPtr
is a shorthand forstd::shared_ptr<rclcpp::Publisher>
rclcpp::Subscriber::SharedPtr
is a shorthand forstd::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.