NOTEThis is a follow-on to Rust/C++ Interop where we built a bridge between Rust and C++.
As a bridge builder and Rust radical living on a C++ team, CMake is the next layer of this onion you need to peel back. For this blog post, I’ll presume these things:
- You have written some Rust code and done the work to create a C++ interface for it.
- You have coworkers who use CMake to build their C++ projects.
- You would like to write tests in C++ and compile them with sanitizers to identify bugs in your interop layer.
Unlike in Rust, C++ has many different build systems. I work at PickNik Robotics, and we use CMake to build our C++ projects, so I’ll cover that here.
NOTEIf you are copy-pasting examples. I’ve used the standin
<lib-name>
for the name of the library you are building an interface to.
At the end of this, you should have a C++ library that CMake users can depend on using FetchContent
:
include(FetchContent)
FetchContent_Declare(
<lib-name>
GIT_REPOSITORY https://github.com/org/<lib-name>
GIT_TAG main
SOURCE_SUBDIR "crates/<lib-name>-cpp")
FetchContent_MakeAvailable(<lib-name>)
target_link_libraries(mylib PRIVATE <lib-name>::<lib-name>)
§ Project Layout
I use a cargo workspace for Rust projects with interop to other languages. These are the folders and files in the structure:
├── Cargo.toml
├── README.md
└── crates
├── <lib-name>
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
└── <lib-name>-cpp
├── Cargo.toml
├── CMakeLists.txt
├── cmake
│ └── <lib-name>Config.cmake.in
├── include
│ └── <lib-name>.hpp
├── src
│ ├── lib.cpp
│ └── lib.rs
└── tests
├── CMakeLists.txt
└── tests.cpp
§
Cargo.toml
[workspace]
resolver = "2"
members = ["crates/<lib-name>", "crates/<lib-name>-cpp"]
[workspace.package]
description = "What is your project about?"
authors = ["Name <email@example.com>"]
version = "0.1.0"
edition = "2021"
license = "BSD-3-Clause"
readme = "README.md"
keywords = [""]
categories = [""]
repository = "https://github.com/org/project/"
Here is my root Cargo.toml
. When I do a cargo build, I want to build both my safe and FFI Rust libraries.
§
crates/<lib-name>-cpp/Cargo.toml
[package]
name = "<lib-name>-cpp"
authors.workspace = true
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "<lib-name>cpp"
crate-type = ["staticlib"]
[dependencies]
<lib-name> = { path = "../<lib-name>" }
Here, we tell Cargo how to build the Rust FFI library and use the relative path syntax for how it depends on the pure Rust library. Under the lib section, you should note that we name the library with the cpp suffix, which is essential to separate it from the pure Rust library for Cargo. We drop the dash used in the package name because Cargo does not allow a dash in library names. Finally, we build this as a static lib because we will statically link this into the C++ interop library. This FFI library is a detail we expect users to only depend on or link with. This library should not be published to crates.io.
§
crates/<lib-name>-cpp/CMakeLists.txt
This is the entry point for building your C++ interop layer and the most complex part of this project. We’ll take it in sections.
§ Project and Dependencies
cmake_minimum_required(VERSION 3.16)
project(<lib-name> VERSION 0.1.0)
find_package(Eigen3 REQUIRED)
First, we need to tell CMake the minimum version we depend on. This is a trade-off between choosing an old enough version all your users will have on their systems and a new enough version with all the features you want to use. For my project, 3.16 is the sweet spot because it is the oldest version users will likely have packaged with their Linux install.
Next comes the project
command. This can tell CMake many things about your project. I’ve chosen to only include the version. Here are the CMake docs on the project.
After that, you use find_package
to list the C++ dependencies you need to link into your C++ library. In my case, I’m using Eigen3
.
§ Corrosion - Build the Rust
include(FetchContent)
FetchContent_Declare(
Corrosion
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
GIT_TAG v0.4)
FetchContent_MakeAvailable(Corrosion)
corrosion_import_crate(MANIFEST_PATH Cargo.toml CRATES <lib-name>-cpp)
Corrosion is a CMake module that knows how to build Rust projects. Here, we use FetchContent
to retrieve it over the Internet. corrosion_import_crate
tells CMake how to convert your Rust library into a CMake target. Later, when we make a CMake target that depends on this new target, it will set up the dependency relationship, so building the CMake target will first build the Rust library.
Lastly, an important detail is that I told it to only create a CMake target for the FFI crate. I did this because I want to use the same library name for my CMake target and project as I did for my pure Rust library. If you don’t specify this option, Corrosion will create CMake targets for every library building the Cargo.toml
builds.
§ CMake library
add_library(<lib-name> STATIC src/lib.cpp)
target_include_directories(
<lib-name> PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>)
target_link_libraries(<lib-name> PUBLIC Eigen3::Eigen)
target_link_libraries(<lib-name> PRIVATE <lib-name>cpp)
set_property(TARGET <lib-name> PROPERTY CXX_STANDARD 20)
set_property(TARGET <lib-name> PROPERTY POSITION_INDEPENDENT_CODE ON)
Here, we tell CMake how to build our C++ library, where to find the header files, to link it with Eigen3::Eigen, and our rust library <lib-name>cpp
.
I’m using C++20, so I set that here.
To make links with the Rust static library work, I enable POSITION_INDEPENDENT_CODE
for this project.
§ Install
include(CMakePackageConfigHelpers)
include(GNUInstallDirs)
install(
TARGETS <lib-name> <lib-name>cpp
EXPORT ${PROJECT_NAME}Targets
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
install(
EXPORT ${PROJECT_NAME}Targets
NAMESPACE <lib-name>::
DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME}")
configure_package_config_file(
cmake/<lib-name>Config.cmake.in
"${PROJECT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME}")
install(FILES "${PROJECT_BINARY_DIR}/${PROJECT_NAME}Config.cmake"
DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME}")
install(FILES include/<lib-name>.hpp DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
Next comes the most magical part of this whole thing. If I’m being honest, I copy-paste this from project to project without understanding it. This is needed so the CMake target we are creating is consumable by other CMake projects. To use this, copy-paste it and replace all the occurrences of <lib-name>
with your project’s name. You’ll also need the file crates/<lib-name>-cpp/cmake/<lib-name>Config.cmake.in
shown below.
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
find_dependency(Eigen3)
include("${CMAKE_CURRENT_LIST_DIR}/@PROJECT_NAME@Targets.cmake")
One important point to make here is that you’ll need to add a find_dependency
for every C++ dependency on which you previously did a find_package
.
§ Testing
if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME)
include(CTest)
if(BUILD_TESTING)
add_subdirectory(tests)
endif()
endif()
We only want to build tests when building this as the root CMake project and not when this CMake project is built through FetchContent
in another project. This is one way to make your project user-friendly by giving the fastest possible build times depending on your library.
§
crates/<lib-name>-cpp/tests/CMakeLists.txt
include(FetchContent)
FetchContent_Declare(
Catch2
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
GIT_TAG v3.5.2)
FetchContent_MakeAvailable(Catch2)
include(Catch)
add_executable(tests tests.cpp)
target_link_libraries(tests PRIVATE Catch2::Catch2WithMain
<lib-name>::<lib-name>)
catch_discover_tests(tests)
Catch2 is a beautiful, modern C++ testing framework. The docs explain how to write tests using it. The killer feature of having tests of your C++ interop library is that we can now build with linters and test for mistakes in our unsafe code.
§ static_assert the copy/move behavior
Another helpful suggestion from Kris van Rens was to use static_assert
to statically assert the special function behavior of our Joint class.
The purpose of these tests is to guarantee that the behavior of this class continues to be what we expect.
If a change is introduced to the class later that causes it to no longer have the same public interface, these static_asserts would no longer compile.
Here is an excerpt from our crates/<lib-name>-cpp/tests/tests.cpp
file.
#include <type_traits>
#include "robot_joint.hpp"
using namespace robot_joint;
// Joint should be a move-only resource handle (tests will fail at build time).
static_assert(std::is_nothrow_destructible_v<Joint>);
static_assert(std::is_nothrow_default_constructible_v<Joint>);
static_assert(!std::is_copy_constructible_v<Joint>);
static_assert(!std::is_copy_assignable_v<Joint>);
static_assert(std::is_nothrow_move_constructible_v<Joint>);
static_assert(std::is_nothrow_move_assignable_v<Joint>);
§ Building and Testing
After all that boilerplate, we can now build our project using CMake.
cmake -B build -S crates/<lib-name>-cpp -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ctest --test-dir build --output-on-failure
To build and test with sanitizers, add options like these to the first command:
-DCMAKE_CXX_FLAGS="-fsanitize=undefined"
-DCMAKE_CXX_FLAGS="-fsanitize=address"
§ GitHub Actions CI
To make this all build in CI here is my .github/workflows/ci.yaml
file you can copy into your project.
name: CI
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
cpp:
name: Cpp
runs-on: ubuntu-latest
strategy:
matrix:
cxx-flags:
- "-fsanitize=undefined"
- "-fsanitize=address"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: sudo apt install libeigen3-dev
- name: Configure, Build, and Test Project
uses: threeal/cmake-action@v1.3.0
with:
source-dir: crates/<lib-name>-cpp
generator: Ninja
cxx-flags: ${{ matrix.cxx-flags }}
run-build: true
run-test: true
§ Conclusion
Drop this in your README.md and your coworkers who use CMake should find your library easy to use.
include(FetchContent)
FetchContent_Declare(
<lib-name>
GIT_REPOSITORY https://github.com/org/<lib-name>
GIT_TAG main
SOURCE_SUBDIR "crates/<lib-name>-cpp")
FetchContent_MakeAvailable(<lib-name>)
target_link_libraries(mylib PRIVATE <lib-name>::<lib-name>)
Next: C++ Interop Part 3 - Cxx