ROS2 Workspace Structure - Uvic-Robotics-Club/uvic-rover-2027 GitHub Wiki
This workspace is the root directory that contains the entire codebase. All environment configuration, firmware, and ROS2 packages live here. ROS2 follows a strict structure that must be respected for the build system to work correctly.
rover_ws/ # Workspace root
├── src/ # All ROS2 packages live here
│ ├── drive_package/
│ ├── arm_package/
│ ├── comms_package/
│ ├── perception_package/
│ ├── odometry_package/
│ └── rover_interfaces/ # Shared custom messages, services, actions
├── build/ # Generated by colcon — do not touch
├── install/ # Generated by colcon — do not touch
├── log/ # Build and runtime logs — do not touch
├── docker/ # Docker environment configuration
├── .gitignore
└── README.md
The build/, install/, and log/ directories are generated automatically when you build the workspace. You never edit these manually and they are gitignored.
A package is the basic unit of organization in ROS2. Each subsystem is its own package. Packages can also exist purely to share things across other packages. An example is the rover_interfaces package which holds all custom message, service, and action definitions so every other package can use these WITHOUT duplicating code.
There are two types of packages:
- ament_cmake: C++ packages. Uses
CMakeLists.txtas the build file. - ament_python: Python packages. Uses
setup.pyas the build file.
A package can also be a mix of both but this adds complexity so only do this where necessary.
To create a new package, run one of these from inside src/:
# C++ package
ros2 pkg create --build-type ament_cmake <package_name>
# Python package
ros2 pkg create --build-type ament_python <package_name>This scaffolds the required files and folders automatically. Do not create packages manually.
C++ Package
drive_package/
├── include/
│ └── drive_package/
│ └── drive_node.hpp # Header files — declare classes and functions
├── src/
│ └── drive_node.cpp # Implementation — the actual node code
├── launch/
│ └── drive_launch.py
├── config/
│ └── params.yaml # Parameter files
├── test/
│ └── test_drive_node.cpp # GTest files
├── CMakeLists.txt # Build configuration
└── package.xml # Package metadata and dependencies
Python Package
perception_package/
├── perception_package/
│ ├── __init__.py
│ └── camera_node.py # Node code lives here
├── launch/
│ └── perception_launch.py
├── config/
│ └── params.yaml
├── test/
│ └── test_camera_node.py
├── setup.py # Build configuration
├── setup.cfg
└── package.xml # Package metadata and dependencies
Interfaces Package
rover_interfaces/
├── action/
│ └── MoveArm.action
├── msg/
│ └── RoverStatus.msg
├── srv/
│ └── EnableArm.srv
├── CMakeLists.txt
└── package.xml
Custom messages, services, and actions always live here. This keeps them accessible to every package without creating circular dependencies.
This is the package manifest. It tells ROS2 what the package is and what it depends on. Every dependency your package uses must be declared here. If it isn't, the build system won't know to fetch it and your package will fail to build on a fresh install.
<?xml version="1.0"?>
<package format="3">
<name>drive_package</name>
<version>0.0.1</version>
<description>Drive subsystem for the rover</description>
<maintainer email="[email protected]">Your Name</maintainer>
<license>MIT</license>
<!-- Build tool — always required for ament_cmake packages -->
<buildtool_depend>ament_cmake</buildtool_depend>
<!-- ROS2 core dependency -->
<depend>rclcpp</depend>
<!-- Message type dependencies -->
<depend>std_msgs</depend>
<depend>geometry_msgs</depend>
<!-- Dependency on our shared interfaces package -->
<depend>rover_interfaces</depend>
<!-- Test dependencies — only needed for building tests -->
<test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>A common mistake is adding a dependency in code but forgetting to declare it here. The package may build locally if the dependency happens to be installed but will fail on another machine.
This is the build configuration file. It tells the build system how to compile the package, what to link against, and what to install. This is the most complex file in the package and the one most likely to cause confusing build errors if something is missing.
cmake_minimum_required(VERSION 3.8)
project(drive_package)
# Find ROS2 core and any packages declared in package.xml
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)
find_package(rover_interfaces REQUIRED)
# Add the executable — maps the node name to its source file
add_executable(drive_node src/drive_node.cpp)
# Tell the compiler where to find header files for this target
target_include_directories(drive_node PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
# Link the node against its dependencies
ament_target_dependencies(drive_node
rclcpp
std_msgs
rover_interfaces
)
# Install the compiled executable so ros2 run can find it
install(TARGETS drive_node
DESTINATION lib/${PROJECT_NAME}
)
# Install launch files so ros2 launch can find them
install(DIRECTORY launch/
DESTINATION share/${PROJECT_NAME}/launch
)
# Install config files so they are accessible at runtime
install(DIRECTORY config/
DESTINATION share/${PROJECT_NAME}/config
)
ament_package()
The most common mistakes here are:
- Adding a new source file but forgetting to add it to
add_executable() - Adding a new dependency but forgetting to add it to both
find_packageandament_target_dependencies - Forgetting to install a directory (launch, config) and then wondering why
ros2 launchcan't find the file
The Python equivalent of CMakeLists.txt. Simpler but still requires attention when adding new nodes or data files.
from setuptools import setup
import os
from glob import glob
package_name = 'perception_package'
setup(
name=package_name,
version='0.0.1',
packages=[package_name],
data_files=[
# Required — registers the package with ROS2
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
# Install launch files
(os.path.join('share', package_name, 'launch'), glob('launch/*_launch.py')),
# Install config files
(os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
],
install_requires=['setuptools'],
zip_safe=True,
entry_points={
# This is how ROS2 knows about your nodes
# Format: 'node_name = package_name.module_name:main_function'
'console_scripts': [
'camera_node = perception_package.camera_node:main',
],
},
)Every new Python node must be registered under console_scripts, without this, ros2 run cannot find it.
All build commands are run from the workspace root, never from inside a package.
# Build all packages
colcon build
# Build a specific package only — faster during development
colcon build --packages-select drive_package
# Build a package and everything that depends on it
colcon build --packages-up-to drive_package
# Source the workspace after building — required to use the built packages
source install/setup.bashsource install/setup.bash is required to run in every new terminal to successfully build and utilize ROS2. However, sourcing is handled automatically by the Docker setup, so this should never need to run manually.
colcon resolves build order automatically based on package.xml dependencies. If package A depends on package B, colcon builds B first. This means rover_interfaces always builds before any subsystem package since every package depends on it.
If you see a build error saying a package cannot be found, check:
- Is it declared in
package.xml? - Is it declared in
CMakeLists.txtunderfind_package? - Had the dependency package itself been built successfully?