ROS Project Templating
ROS projects can quickly become large and over complicated. Packages and nodes add up, and maintaining Makefiles
along with project organisation can become a major challenge. To make matters worse, the ROS maintainers/creators have no recommended organisational tree or structure of any kind.
This article suggests one possible way to organise a ROS project.
Packages and Nodes
Packages and Nodes are the primary organisational method provided by the ROS maintainers. To better understand the relationship between packages and nodes, we can refer to the ROS documentation: “A package is the main unit for organising software in ROS. A node is an executable file with a package.” Following this relationship, we can construct a sample organisational structure.
1
2
3
MyPackage
├── MyNode1
└── MyNode2
Naive Approach
From the ROS documentation, each package should have a single CMakeLists.txt
file. Given the use of a single build file, one could construct a naive package structure like follows:
1
2
3
4
5
6
7
8
9
MyPackage
├── include
│ ├── my_node1.hpp
│ └── my_node2.hpp
├── src
│ ├── my_node1.cpp
│ └── my_node2.cpp
├── package.xml
└── CMakeLists.txt
However, if the above structure is used, code from both nodes will be deeply entangled. This can quickly lead to numerous issues including packages having large, convoluted build files, difficult to diagnose errors, and decreased portability.
Another possible approach would be to limit each package to contain one node. However, this approach provides little benefit over the previous approach, requiring an abundance of packages and removing organisational structure from the project.
A Better Approach
Recapping the previous section, the problems with the naive approach surround the nodes being deeply entangled. Fortunately, the smart people who developed CMake have found a way to simplify large build files. Therefore, the project organisation can be adjusted to as follows:
1
2
3
4
5
6
7
8
9
10
11
MyPackage
├── MyNode1
│ ├── include
│ ├── src
│ └── build.cmake
├── MyNode2
│ ├── include
│ ├── src
│ └── build.cmake
├── package.xml
└── CMakeLists.txt
Configuring CMakeLists.txt
As seen in the above “better” approach, multiple CMake files are used to complete the project compilation. Each node receives its own build.cmake
file, specifying how the node should be built. Each package maintains a single CMakeLists.txt
file, upholding the requirements of ROS and Colcon. The following subsection details the construction of these build files.
build.cmake
Each node maintains its own build.cmake
file that specifies how the node should be built. The following is an example of how this file should be created.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# CMake Build File for "Node Name"
# Jacob Chisholm (https://Jchisholm204.github.io)
# version 0.1
# 2025-03-23
# Modify this to align with the "Node Name"
set(NODE node_name)
# Ensure the required packages are avaliable
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(sensor_msgs REQUIRED)
find_package(cv_bridge REQUIRED)
find_package(OpenCV 4 REQUIRED)
# Gather all sources for this project
file(GLOB_RECURSE PROJECT_SOURCES FOLLOW_SYMLINKS
${CMAKE_CURRENT_SOURCE_DIR}/${NODE}/src/*.cpp
)
# Setup Library Dependencies
set(${NODE}_DEPS rclcpp std_msgs sensor_msgs cv_bridge OpenCV)
# Add to include directories
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/${NODE}/include)
# Add this node as an executable
add_executable(${NODE}
${PROJECT_SOURCES}
)
target_include_directories(${NODE} PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/${NODE}/include>
$<INSTALL_INTERFACE:include>
)
# Library dependencies
ament_target_dependencies(${NODE} ${${NODE}_DEPS})
# Install header files
install(
DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/${NODE}/include/
DESTINATION include/${PROJECT_NAME}
)
# Install library files
install(
TARGETS ${NODE}
EXPORT export_${NODE}
DESTINATION lib/${PROJECT_NAME}
)
# Exports
ament_export_include_directories(${CMAKE_CURRENT_SOURCE_DIR}/${NODE}/include)
ament_export_targets(export_${NODE} HAS_LIBRARY_TARGET)
ament_export_dependencies(${${NODE}_DEPS})
If the above file is reused, only a select few of the parameters need to be changed.
The first and most important parameter to modify is the NODE
parameter in the first line. This parameter sets the node name, and is used for gathering dependencies, building, and installing the node. NOTE: The node name MUST match the name of the folder in which the node is contained.
The second parameter that must be changed is the NODE_DEPS
parameter. This parameter must be set to contain a list of all required dependencies. Without setting this parameter properly, the node will fail to build.
Finally, the find_package
lines must be changed to correspond with the NODE_DEPS
parameter.
CMakeLists.txt
In providing each node its own build.cmake
file, the package CMakeLists.txt
becomes vastly reduced. An example of how the CMakeLists.txt
file should be written is shown below:
1
2
3
4
5
6
7
8
9
10
11
cmake_minimum_required(VERSION 3.8)
project(package_name)
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic)
endif()
include(my_node1/build.cmake)
include(my_node2/build.cmake)
ament_package()
Package Contents
Now that we have a good way of organising nodes within packages, we need to determine how to group packages and write the package.xml
file responsible for generating the build order.
Package Grouping
With this method, a general way to group packages is by their requirements. For example, most computer vision nodes require the same dependencies. Therefore, they can be grouped into the same package. Another possible example of nodes with similar algorithms would be path planning and navigation nodes.
Another possible method of grouping is via physical subsystem. For example, the low level control node and inverse kinematics (IK) node for an arm could also be grouped together.
package.xml
The following is an example of the package.xml
file for a package. Each package must have exactly one package.xml
file used for generating a build order between packages. The following is an example package.xml
file. The only requirement of this file is that it must contain references for ALL of the dependencies for ALL of the nodes within the package.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?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>vision</name>
<version>0.0.0</version>
<description>TODO: Package description</description>
<maintainer email="jacobchisholm1010@gmail.com">jacob</maintainer>
<license>TODO: License declaration</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>rclcpp</depend>
<depend>sensor_msgs</depend>
<depend>opencv4</depend>
<depend>cv_bridge</depend>
<depend>message_filters</depend>
<depend>geometry_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>
Build System
There are two key elements to the ROS build system. These elements are Colcon and the package.xml
files used to control build order. For more information on the package.xml
files, see the previous section. In short, if the package.xml
files are incorrectly set up, the project may experience random failures during the initial build process, or fail to build entirely. Colcon is the build system use by ROS and is akin to CMake.
Given the complexity and intricacy of the ROS build system, it is often best to interact with it through build scripts and Makefiles. The following section details how to implement these intermediary build scripts.
Shell Script
ROS nodes are often run across various machines networked together. Therefore, some packages/nodes may require dependencies only present on some systems. This functionality could be implemented directly through a Makefile, but is often implemented easier through a bash or zsh script. The following is a sample ZSH script that can be used for this purpose.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#!/bin/zsh
# This Script runs using ZSH!
PKGS_COMMON=("vision" "joystick_controller" "driver")
PKGS_JETSON=()
PKGS_RASBPI=("pix_driver")
HOSTNAME_JETSON="jetson"
HOSTNAME_RASBPI="team2pi"
HOSTNAME=$(hostname)
build() {
if [[ $# -eq 0 ]]; then
echo "Build must be called with a list of packages"
return 1
fi
if [[ "$HOSTNAME" == "$HOSTNAME_RASBPI" ]]; then
export CC=clang-15
export CXX=clang++-15
else
export CC=clang
export CXX=clang++
fi
# Build the packages
colcon build --packages-select "$@" --cmake-args -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_BUILD_TYPE=Debug
# Source Install
if [[ -f "./install/local_setup.zsh" ]]; then
source ./install/local_setup.zsh
fi
# Symlink the compile commands
if [[ -f "./build/compile_commands.json" ]]; then
ln -sf ./build/compile_commands.json ./
fi
}
if [[ "$HOSTNAME" == "$HOSTNAME_JETSON" ]]; then
echo "Building ROS Packages for Jetson"
build "${PKGS_COMMON[@]}" "${PKGS_JETSON[@]}"
elif [[ "$HOSTNAME" == "$HOSTNAME_RASBPI" ]]; then
echo "Building ROS Packages for Raspberry Pi"
build "${PKGS_COMMON[@]}" "${PKGS_RASBPI[@]}"
else
echo "Building ROS Workspace Common Packages"
build "${PKGS_COMMON[@]}"
fi
In the above script, the first few lines detail what packages are specific to certain devices. The PKGS_COMMON
variable contains the list of packages that can be built on any system. Nearing the bottom of the script, the build function is called with a list of packages depending on the hostname of the system.
Makefile
One could invoke the above build script directly from the command line. However, having a Makefile is generally useful. Below is a sample Makefile for this purpose:
clean:
rm -r ./build ./install ./log
rm ./compile_commands.json
build:
./build.sh
Additional Notes
Clang Formatting
The following is a sample formatting file that could be used for clang formatting:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
BasedOnStyle: LLVM
IndentWidth: 4
TabWidth: 4
UseTab: Never
# Keep braces consistent and readable
BreakBeforeBraces: Attach
AllowShortBlocksOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
AllowShortFunctionsOnASingleLine: InlineOnly
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
# Control line length and wrapping
ColumnLimit: 80
PenaltyBreakBeforeFirstCallParameter: 50
PenaltyBreakString: 1000
PenaltyReturnTypeOnItsOwnLine: 200
# Pointer & reference alignment
PointerAlignment: Left
ReferenceAlignment: Left
# Namespace formatting
NamespaceIndentation: None
CompactNamespaces: false
# Include ordering
IncludeBlocks: Regroup
SortIncludes: true
# Other readability tweaks
SpaceAfterCStyleCast: true
SpaceBeforeParens: ControlStatements
SpacesInParentheses: false
SpacesInAngles: false
Neovim Compatibility
To ensure Neovim compatibility, first ensure the project is compiled using Clangd. To further ensure compatibility the build/compile-commands.json
file can be symlinked into the base directory of each package.