CMake에서 Rust와 C++을 연동하는 인터롭 레이어를 구성하는 방법을 설명합니다. Corrosion로 Rust 크레이트를 CMake 타깃으로 가져오고, FetchContent로 의존 가능하도록 패키징/설치 설정을 구성하며, Catch2로 테스트하고 sanitizer 빌드 및 GitHub Actions CI까지 설정합니다.
이는 우리가 Rust와 C++ 사이의 브리지를 구축했던 글 Rust/C++ Interop의 후속 글입니다.
C++ 팀에서 활동하는 브리지 빌더이자 Rust 전도사라면, 다음으로 벗겨야 할 양파의 겹은 CMake입니다. 이 글에서는 다음을 전제로 합니다:
Rust와 달리 C++에는 빌드 시스템이 여러 가지입니다. 저는 PickNik Robotics에서 일하며 C++ 프로젝트를 CMake로 빌드하므로, 여기서는 CMake를 다루겠습니다.
예시를 복붙하는 경우를 대비해, 인터페이스를 제공할 라이브러리 이름 자리에 <lib-name>라는 플레이스홀더를 사용했습니다.
이 글을 마치면, CMake 사용자가 FetchContent로 의존할 수 있는 C++ 라이브러리를 갖게 될 것입니다:
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>)
다른 언어와의 인터롭이 있는 Rust 프로젝트에는 cargo workspace를 사용합니다. 구조는 다음과 같습니다:
├── 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/"
루트 Cargo.toml입니다. cargo build를 실행하면, 안전한 Rust 라이브러리와 FFI Rust 라이브러리를 모두 빌드하고 싶습니다.
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>" }
여기서는 Rust FFI 라이브러리를 어떻게 빌드할지 Cargo에 알려주고, 순수 Rust 라이브러리에 대한 의존성을 상대 경로로 지정합니다. lib 섹션에서 라이브러리 이름에 cpp 접미사를 붙인 점에 주목하세요. 이는 Cargo에서 이 라이브러리를 순수 Rust 라이브러리와 구분하기 위해 중요합니다. 또한 패키지 이름에 사용한 대시는 라이브러리 이름에는 사용할 수 없으므로 제거했습니다. 마지막으로, 이 라이브러리는 C++ 인터롭 라이브러리에 정적으로 링크할 것이므로 정적 라이브러리(staticlib)로 빌드합니다. 이 FFI 라이브러리는 구현 세부사항이며 최종 사용자가 직접 의존하거나 링크하지 않기를 기대합니다. 이 라이브러리는 crates.io에 발행하지 않는 것이 좋습니다.
crates/<lib-name>-cpp/CMakeLists.txt이 파일은 C++ 인터롭 레이어를 빌드하는 진입점이며, 프로젝트에서 가장 복잡한 부분입니다. 섹션별로 살펴보겠습니다.
cmake_minimum_required(VERSION 3.16)
project(<lib-name> VERSION 0.1.0)
find_package(Eigen3 REQUIRED)
먼저 CMake가 필요한 최소 버전을 알려야 합니다. 이는 모든 사용자가 시스템에 가지고 있을 만한 충분히 오래된 버전과, 사용하고 싶은 기능이 있는 충분히 새로운 버전 사이의 균형점입니다. 제 프로젝트에서는 3.16이 리눅스 배포판에 기본으로 들어 있을 가능성이 크면서도 필요한 기능을 제공하는 적절한 버전이었습니다.
다음은 project 명령입니다. 이 명령은 프로젝트에 대한 여러 정보를 CMake에 전달할 수 있습니다. 여기서는 버전만 포함했습니다. project에 대한 CMake 문서는 참고 자료에 있습니다.
그다음으로 C++ 라이브러리에 링크할 C++ 의존성을 find_package로 나열합니다. 제 경우 Eigen3를 사용합니다.
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은 Rust 프로젝트를 빌드하는 방법을 아는 CMake 모듈입니다. 여기서는 FetchContent로 인터넷에서 가져옵니다. corrosion_import_crate는 Rust 라이브러리를 CMake 타깃으로 변환하는 방법을 CMake에 알려줍니다. 이후에 이 새 타깃에 의존하는 CMake 타깃을 만들면, CMake는 의존 관계를 설정하여 CMake 타깃을 빌드하기 전에 Rust 라이브러리를 먼저 빌드합니다. 마지막으로 중요한 점은, FFI 크레이트에 대해서만 CMake 타깃을 만들도록 지정했다는 것입니다. 순수 Rust 라이브러리와 같은 이름을 CMake 프로젝트와 타깃에도 사용하고 싶기 때문입니다. 이 옵션을 지정하지 않으면, Corrosion은 해당 Cargo.toml이 빌드하는 모든 라이브러리에 대해 CMake 타깃을 생성합니다.
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)
여기서는 C++ 라이브러리를 어떻게 빌드할지, 헤더 파일의 위치, Eigen3::Eigen 및 Rust 라이브러리 <lib-name>cpp와의 링크 방법을 CMake에 알려줍니다. 저는 C++20을 사용하므로 여기서 설정합니다. Rust 정적 라이브러리와의 링크가 잘 동작하도록 이 타깃에 POSITION_INDEPENDENT_CODE를 활성화합니다.
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})
다음은 전체 과정에서 가장 마법 같은 부분입니다. 솔직히 말해 저도 완벽히 이해하지 못한 채로 프로젝트마다 복붙합니다. 우리가 만드는 CMake 타깃을 다른 CMake 프로젝트가 소비할 수 있도록 하려면 이 설정이 필요합니다. 사용하려면 이 블록을 복사해 <lib-name>을 여러분 프로젝트 이름으로 모두 바꾸세요. 또한 아래의 crates/<lib-name>-cpp/cmake/<lib-name>Config.cmake.in 파일도 필요합니다.
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
find_dependency(Eigen3)
include("${CMAKE_CURRENT_LIST_DIR}/@PROJECT_NAME@Targets.cmake")
여기서 중요한 점 한 가지는, 이전에 find_package를 수행했던 모든 C++ 의존성마다 find_dependency를 추가해야 한다는 것입니다.
if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME)
include(CTest)
if(BUILD_TESTING)
add_subdirectory(tests)
endif()
endif()
이 CMake 프로젝트가 다른 프로젝트에서 FetchContent로 포함되어 빌드될 때가 아니라, 루트 CMake 프로젝트로 빌드될 때만 테스트를 빌드하고 싶습니다. 이는 라이브러리에 따라 가능한 한 빠른 빌드 시간을 제공해 사용자 친화적으로 만드는 한 방법입니다.
crates/<lib-name>-cpp/tests/CMakeLists.txtinclude(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는 아름답고 현대적인 C++ 테스트 프레임워크입니다. 테스트 작성 방법은 문서를 참고하세요. C++ 인터롭 라이브러리에 대한 테스트를 갖추면, 이제 린터와 sanitizer를 켜서 unsafe 코드의 실수를 잡아낼 수 있다는 점이 결정적인 장점입니다.
Kris van Rens가 준 또 하나의 유용한 제안은 Joint 클래스의 특수 함수 동작을 static_assert로 정적으로 보장하는 것이었습니다. 이 테스트의 목적은 이 클래스의 동작이 우리가 기대한 대로 계속 유지되도록 보장하는 것입니다. 나중에 클래스가 변경되어 공개 인터페이스가 더 이상 동일하지 않게 되면, 이러한 static_assert는 컴파일되지 않을 것입니다. 아래는 crates/<lib-name>-cpp/tests/tests.cpp에서 발췌한 코드입니다.
#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>);
보일러플레이트를 모두 갖춘 후에는 이제 CMake로 프로젝트를 빌드할 수 있습니다.
cmake -B build -S crates/<lib-name>-cpp -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ctest --test-dir build --output-on-failure
sanitizer로 빌드하고 테스트하려면 첫 번째 명령에 다음과 같은 옵션을 추가하세요:
CI에서 이 모든 것을 빌드하려면, 다음의 .github/workflows/ci.yaml 파일을 프로젝트에 추가하면 됩니다.
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
다음을 README.md에 추가하면, CMake를 사용하는 동료들이 여러분의 라이브러리를 쉽게 사용할 수 있을 것입니다.
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>)
다음: C++ Interop Part 3 - Cxx