cxx 크레이트를 사용해 Rust와 C++ 사이의 상호운용 레이어를 생성하는 방법을 소개합니다. opaque 타입과 뉴타입 패턴, Nalgebra/Eigen 간 변환, CMake와 Corrosion 연동, build.rs 구성, 예제 사용법까지 단계별로 다룹니다.
2023년에 저는 반복적이고 오류가 발생하기 쉬운 작업에 코드 생성을 활용하는 데 익숙합니다. 2023년에는 YAML 파일을 입력받아 ROS에서 구성 선언을 위한 C++ 라이브러리를 생성하는, 제가 공저한 코드 생성 도구에 대해 ROSCON에서 발표했습니다.
Cxx는 C++ <-> Rust 상호운용을 위한 가장 잘 알려진 코드 생성 도구입니다. cxx 문서에서:
이 라이브러리는 Rust에서 C++ 코드를, C++에서 Rust 코드를 안전하게 호출할 수 있는 메커니즘을 제공합니다. Rust와 C++가 의미적으로 매우 유사한 공통의 영역을 정하고, 프로그래머가 이 영역 안에서 언어 경계를 효과적으로 표현하도록 이끕니다.
저는 1부를 시작할 때 cxx를 사용하지 않았습니다. 인터페이스에 일급 라이브러리 타입을 원했습니다. 제 API의 함수 매개변수에 그 라이브러리 타입을 유지하는 데는 성공했지만, 그 대가로 많은 보일러플레이트와 unsafe 코드를 작성해야 했습니다. 이 unsafe 코드는 작성하고 테스트하는 데 시간이 많이 들기 때문에 제 애플리케이션에 필요한 최소한만 작성했습니다.

코봇
최소한의 C++ API는 제 C++ 동료들에게 제 라이브러리를 위한 이류 API를 받은 느낌을 주었습니다. 인터페이스에 있는 unsafe 코드와 보일러플레이트의 양을 보고, API를 확장하기 위해 자신들이 더 작성하고 싶지는 않다고도 생각하게 만들었습니다.
다른 사람들과 함께 소프트웨어 시스템을 구축할 때는 기술적 결정의 인간적 비용을 고려해야 합니다. 이 경우, 최소한의 수작업 unsafe 상호운용은 하나의 프로젝트에서는 동작했지만, C++과 Rust 모두에서 일급으로 느껴지는 라이브러리를 만드는 목표에는 미치지 못했습니다.
cxx 크레이트의 기본 아이디어는, 여러분이 Rust와 C++로 작성한 코드를 매크로로 확장해 상호운용 레이어를 만들어 준다는 것입니다. 지원하는 타입의 수는 제한적이며 시간이 지나며 개선될 수 있습니다. 이 제한된 타입 지원은 매개변수에서 일급 라이브러리 타입을 유지하는 과제를 더 어렵게 만듭니다. 여기서는 제가 이 문제를 어떻게 접근했는지 보여 드리겠습니다.
1부에서 Joint 타입을 만들었습니다. cxx의 더 많은 기능을 보여 주기 위해 이를 확장했습니다.
#[derive(Clone, Debug)]
pub struct Joint {
pub name: String,
pub parent_link_to_joint_origin: Isometry3<f64>,
pub parent_link_index: usize,
pub child_link_index: usize,
pub index: usize,
pub dof_index: usize,
}
impl Joint {
pub fn new() -> Self;
pub fn calculate_transform(&self, variables: &[f64]) -> Isometry3<f64>;
}
cxx 상호운용 코드를 보여 드리기 전에, 제가 선택한 몇 가지 세부사항을 먼저 짚고 넘어가야 합니다. 우리가 바인딩하려는 Rust 라이브러리는 순수 Rust입니다. cxx 상호운용 레이어를 위한 별도의 크레이트를 만들겠습니다. 이 선택은 제 cxx 크레이트에서 Rust 크레이트의 타입을 일종의 재내보내기(re-export)해야 함을 의미합니다.
struct Joint(robot_joint::joint::Joint);
#[cxx::bridge(namespace = "robot_joint")]
mod ffi {
extern "Rust" {
type Joint;
fn new_joint() -> Box<Joint>;
fn name(self: &Joint) -> String;
fn parent_link_to_joint_origin(self: &Joint) -> Vec<f64>;
fn parent_link_index(self: &Joint) -> usize;
fn child_link_index(self: &Joint) -> usize;
fn index(self: &Joint) -> usize;
fn dof_index(self: &Joint) -> usize;
fn calculate_transform(self: &Joint, variables: &[f64]) -> Vec<f64>;
fn to_string(self: &Joint) -> String;
}
}
Joint 타입은 순수 Rust 크레이트에서 왔기 때문에 C 메모리 레이아웃을 갖지 않아 타입 자체를 공유할 수 없습니다. 대신 opaque 타입을 만들어야 합니다. Cxx는 다른 크레이트에서 정의된 Rust 타입에 대해 opaque 상호운용을 생성하는 것을 허용하지 않습니다. 제가 찾은 우회 방법은 뉴타입 패턴입니다.
두 번째로 볼 점은 Joint 타입의 각 멤버에 대한 getter를 작성한다는 것입니다. 그 구현은 다음 섹션에 있습니다. 마지막으로, 인터페이스에 Nalgebra 타입이 없다는 것을 볼 수 있습니다. 대신 Nalgebra 타입 대신에 Vec<f64>를 노출했습니다.
fn new_joint() -> Box<Joint> {
Box::new(Joint(robot_joint::joint::Joint::new()))
}
impl Joint {
fn name(&self) -> String {
self.0.name.clone()
}
fn parent_link_to_joint_origin(&self) -> Vec<f64> {
convert::vec_from_isometry3(self.0.parent_link_to_joint_origin)
}
fn parent_link_index(&self) -> usize {
self.0.parent_link_index
}
fn child_link_index(&self) -> usize {
self.0.child_link_index
}
fn index(&self) -> usize {
self.0.index
}
fn dof_index(&self) -> usize {
self.0.dof_index
}
fn calculate_transform(&self, variables: &[f64]) -> Vec<f64> {
convert::vec_from_isometry3(self.0.calculate_transform(variables))
}
}
impl std::fmt::Display for Joint {
fn fmt(&self, f: &mut fmt::Formatter) -> std::fmt::Result {
write!(f, "{:#?}", self.0)
}
}
보일러플레이트가 어마어마하네요. 뉴타입을 통해 내부 멤버에 접근하는 이러한 구현을 피하기 위해 영리한 매크로를 작성하거나 사용할 수도 있을 겁니다.
여기서 Display 구현은 바인딩할 수 있는 to_string 함수를 만들어 줍니다.
눈썰미가 좋은 분들은 아직 구현하지 않은 함수를 제가 여기서 사용하고 있다는 것을 보셨을 겁니다. convert::vec_from_isometry3는 Isometry3<f64>에서 Vec<f64>를 만드는 함수입니다. 다음에서 보시겠습니다.
mod convert {
use nalgebra::{Isometry3, Matrix6xX};
pub fn isometry3_from_slice(data: &[f64]) -> Isometry3<f64> {
nalgebra::try_convert(nalgebra::Matrix4::from_column_slice(data))
.expect("Invalid isometry!")
}
pub fn vec_isometry3_from_vec(data: Vec<f64>) -> Vec<Isometry3<f64>> {
data.chunks(16).map(isometry3_from_slice).collect()
}
pub fn vec_from_isometry3(transform: Isometry3<f64>) -> Vec<f64> {
transform.to_matrix().data.as_slice().to_vec()
}
pub fn vec_from_vec_isometry3(transforms: Vec<Isometry3<f64>>) -> Vec<f64> {
transforms
.into_iter()
.flat_map(|t| t.to_matrix().data.as_slice().to_vec())
.collect::<Vec<f64>>()
}
pub fn vec_from_matrix6x(matrix: Matrix6xX<f64>) -> Vec<f64> {
matrix.data.as_vec().to_owned()
}
}
여기서는 더 큰 라이브러리에서 어떻게 사용하는지 보여 주기 위해 위에서 사용한 것보다 더 많은 예를 보였습니다. 이 접근에서 주목할 점은, 상호운용을 거치면서 이 타입들이 두 번 복사된다는 것입니다. 한 번은 Rust 라이브러리 타입에서 cxx가 지원하는 원시 타입으로의 변환이고, 두 번째는 C++ 쪽에서 원시 타입을 C++ 라이브러리 타입으로 다시 복사하는 것입니다. 저는 이를 벤치마크했는데, 수학 계산에 걸리는 시간과 비교하면 제 애플리케이션에서는 이 비용이 더 의미 있게 느껴질 수 있습니다.
이 상호운용을 동작시키려면, 원시 타입을 C++ 라이브러리 타입으로 변환하는 함수가 필요합니다. 저는 이것들을 CMake 타깃의 일부로 내보낼 생성된 헤더를 포함하는 헤더 파일에 넣었습니다.

ICub
#pragma once
#include <robot_joint/lib.h> // generated by cxx
#include <rust/cxx.h>
#include <Eigen/Geometry>
namespace robot_joint {
constexpr auto kMatrix4dLen = sizeof(Eigen::Matrix4d) / sizeof(double);
template <typename T, typename V>
rust::Slice<T> to_rust_slice(V const& vec) {
return rust::Slice<T>(vec.data(), vec.size());
}
rust::Slice<const double> to_rust_slice(const Eigen::Isometry3d& transform) {
return rust::Slice<const double>(transform.matrix().data(), 16);
}
rust::Vec<double> to_rust_vec(
const std::vector<Eigen::Isometry3d>& transforms) {
rust::Vec<double> vec;
vec.reserve(16 * transforms.size());
for (const auto& t : transforms) {
auto* matrix = t.matrix().data();
for (auto i = 0; i < 16; ++i) {
vec.push_back(matrix[i]);
}
}
return vec;
}
std::vector<Eigen::Isometry3d> to_c_isometry_vector(
rust::Vec<double>&& raw_vec) {
auto const n_transforms = raw_vec.size() / kMatrix4dLen;
std::vector<Eigen::Isometry3d> transforms;
transforms.reserve(n_transforms);
for (size_t i = 0; i < n_transforms; ++i) {
double* ptr = raw_vec.data() + (i * kMatrix4dLen);
Eigen::Isometry3d t;
t.matrix() = Eigen::Map<Eigen::Matrix4d>(ptr);
transforms.push_back(t);
}
return transforms;
}
Eigen::Isometry3d to_c_isometry(rust::Vec<double>&& raw_vec) {
Eigen::Isometry3d transform;
transform.matrix() = Eigen::Map<Eigen::Matrix4d>(raw_vec.data());
return transform;
}
template <typename T>
std::vector<T> to_c_vector(const rust::Vec<T>& rust_vec) {
std::vector<T> cpp_vec;
std::copy(rust_vec.begin(), rust_vec.end(), std::back_inserter(cpp_vec));
return cpp_vec;
}
} // namespace robot_joint
다시 한 번, 여기서는 Joint 타입에 필요한 것보다 더 많은 변환 함수를 포함했습니다.
#include <julien/julien.hpp>
#include <Eigen/Geometry>
#include <iostream>
#include <string>
use robot_joint::to_c_isometry;
use robot_joint::to_rust_slice;
int main() {
auto joint = robot_joint::new_joint();
std::cout << "Joint:\n" << std::string(joint->to_string()) << "\n";
Eigen::VectorXd variables = Eigen::VectorXd::Zero(1);
auto const joint_transform = to_c_isometry(
joint->calculate_transform(to_rust_slice<const double>(variables)));
std::cout << "joint_transform (0):\n" << joint_transform.matrix() << "\n";
}
여기서, 인터페이스에 일급 타입을 사용하지 않았을 때의 비용을 볼 수 있습니다. 이 인터페이스의 사용자는 파라미터를 Rust 타입으로 변환하고 반환 타입을 변환하기 위한 메서드를 사용해야 합니다.
멋진 점 하나는 cxx가 slice 타입을 지원한다는 것입니다. 덕분에 기존 C++ 메모리 위에서 사용할 수 있는 Rust 슬라이스(일종의 C++ 반복자라고 생각하시면 됩니다)를 만들 수 있습니다. 이는 매우 효율적이며 기본 데이터를 복사하지 않습니다.
cmake_minimum_required(VERSION 3.16)
project(robot_joint_cxx_example)
find_package(Eigen3 REQUIRED)
include(FetchContent)
FetchContent_Declare(
robot_joint
SOURCE_DIR
"${CMAKE_CURRENT_LIST_DIR}/../../.."
SOURCE_SUBDIR
"crates/robot_joint-cxx")
FetchContent_MakeAvailable(robot_joint)
add_executable(example example.cpp)
target_link_libraries(example PRIVATE robot_joint::robot_joint)
target_link_libraries(example PUBLIC Eigen3::Eigen)
보시다시피, CMake는 이전과 같은 지점에 도달합니다. 여기의 예제는 FetchContent를 사용해 Rust 상호운용 라이브러리를 의존성으로 지정하고, 생성된 CMake 타깃과 링크할 수 있습니다.
마지막으로, 이를 묶어 주는 CMake를 살펴보겠습니다. 다시 한 번, 훌륭한 CMake 모듈인 Corrosion을 사용해 Rust 라이브러리를 빌드합니다.

복강경 수술 로봇
cmake_minimum_required(VERSION 3.16)
project(robot_joint CXX)
find_package(Eigen3 REQUIRED)
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 robot_joint-cxx)
corrosion_add_cxxbridge(robot_joint CRATE robot_joint-cxx FILES lib.rs)
set_property(TARGET robot_joint PROPERTY CXX_STANDARD 20)
target_link_libraries(robot_joint PUBLIC Eigen3::Eigen)
target_include_directories(
robot_joint PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>)
add_library(robot_joint::robot_joint ALIAS robot_joint)
include(CMakePackageConfigHelpers)
include(GNUInstallDirs)
install(
TARGETS robot_joint robot_joint-cxx
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 robot_joint::
DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME}")
configure_package_config_file(
cmake/robot_jointConfig.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(DIRECTORY include/ DESTINATION include)
왜 cmake/robot_jointConfig.cmake.in의 내용을 포함하지 않았냐고 묻기 전에, 그것은 2부에서 설명한 것과 동일합니다. 필요하다면 거기서 복사해 오세요.
#[allow(unused_must_use)]
fn main() {
cxx_build::bridge("src/lib.rs")
.file("include/robot_joint/robot_joint.hpp")
.std("C++20")
.flag_if_supported("-std=c++20");
println!("cargo:rerun-if-changed=src/lib.rs");
println!("cargo:rerun-if-changed=include/robot_joint/robot_joint.hpp");
}
이 접근의 독특한 점 하나는 build.rs 스크립트를 작성해야 한다는 것입니다. 이것은 종종 코드 생성에 사용되는 cargo 빌드의 탈출구입니다. 이 스크립트는 대부분 cxx_bridge 문서에서 가져왔습니다.
[package]
name = "robot_joint-cxx"
authors.workspace = true
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
crate-type = ["staticlib"]
[dependencies]
robot_joint = { path = "../robot_joint" }
cxx = "1.0"
nalgebra = "0.32.3"
[build-dependencies]
cxx-build = "1.0"
.jpg)
iRobot
이 접근에는 명확한 장점이 있습니다. unsafe 코드가 없습니다. 우리는 그 부분을 로봇에게 맡겼고, 그 대가로 더 풍부한 기능의 C++ API를 구축할 수 있었습니다.