📘

CMake + bindgen で Rust と C++ を混在させる

2023/07/08に公開

はじめに

ちょっと復習も兼ねて、 Windows で MediaFoundation を用いたコードを Rust で書こうとして、 Windows API のアクセスに windows-rs を使おうとしていたのですが。

https://github.com/microsoft/windows-rs

どのように書けば期待の動作が得られるのか理解しきれず、無駄に時間が過ぎるので断念しました。

https://twitter.com/TANY_FMPMD/status/1672584063599902720?s=20

とはいっても Rust をメインにしたいのは変わらないので、 Win32 API など unsafe で C++ の方が書きやすいところだけ C++ とすることにして、 CMake + Rust bindgen で Rust と C++ を混在させることにしました。ということで、手順をまとめておきます。

実験プロジェクトはこちら。

https://github.com/aosoft/rust_cmake

cmake-rs

https://github.com/rust-lang/cmake-rs

cmake crate を使用すると cargo のビルドの中に CMake のビルドを混ぜる事ができ、 static library としてビルドすることで Rust 側と最終的に単一バイナリにリンクすることができます。

Cargo.toml
[build-dependencies]

cmake = "0.1.50"

CMake プロジェクトは cargo プロジェクト下ならどこに配置してもよいですが、個人的には Rust 部分と分離したいので cargo プロジェクト直下に "cmake" ディレクトリを作成し、そこを CMake プロジェクトの配置場所としました。

CMakeLists.txt
cmake_minimum_required(VERSION 3.25)
project(cmake)

set(CMAKE_CXX_STANDARD 17)

add_library(cmake library.cpp)

install (TARGETS cmake DESTINATION .)

cmake-rs で取り込む CMakeLists.txt には一点、重要なポイントがあり、必ず install コマンドの定義をする ことです。これをしないとリンカーがリンクできる場所に .lib が配置されません。

build.rs
let dst = cmake::build("cmake");
println!("cargo:rustc-link-search=native={}", dst.display());
println!("cargo:rustc-link-lib=static=cmake");

build.rs に cmake-rs の設定と、 CMake に合わせたリンカーの設定を行います。

windows-msvc toolchain で Debug ビルドをする際の注意事項

(2023/8/27 追記)

Visual C++ の Toolchain で Debug ビルドをする場合、リンク時に次のようなエラーが出る場合があります。

LINK : warning LNK4098: defaultlib 'MSVCRTD' conflicts with use of other libs; use /NODEFAULTLIB:library

これは Rust の Toolchain がコンパイル時にターゲットとする C ランタイムの種類がいわゆる "マルチスレッド DLL (/MD)" 決め打ち指定でランタイムの指定ができない事が根本的な原因となっています。

https://github.com/rust-lang/rust/issues/39016

Debug ビルド時は Debug ランタイムを使用したい (メモリリーク検出などは Debug ランタイムが必要) のですが、現状ではどうしようもありません。

よってビルド時に使用するランタイムを Debug ビルド時でも "/MD" 相当にする必要があります (デバッグ用のシンボルの埋め込みや最適化は使用ランタイムとは関係がないので、コンパイルするコードにブレイクポイントを置く事はできます) 。

この対処は Rust 使用時のみに適用されるようにしたいので、 CMakeLists.txt にオプションを書かず、 build.rs の cmake-rs のパラメーターとして指定します。

build.rs
let dst = Config::new("cmake")
    .define("CMAKE_MSVC_RUNTIME_LIBRARY", "MultiThreadedDLL")
    .build();

Windows 向けの場合は上記のように CMake のランタイム指定を追加します。 Release でも /MD であるため固定指定でよいと思います (プラットフォームの区別も必要ないと思います) 。

Rust bindgen

https://github.com/rust-lang/rust-bindgen

C/C++ のヘッダーファイルから Rust での定義を生成してくれるツールです。bindgen を使用しない場合は手作業で Rust の定義を記述する必要があります。

使い方としては二通りあって、

  • a) build.rs に記述して cargo ビルド時に生成する
  • b) コマンドラインツールで生成する

bindgen は LLVM (clang) のインストールが必須のため、 a) 方式にすると各自の開発環境にも LLVM を入れる必要が出てきて敷居が上がってしまうのが難点です。私はそのため従来は b) 方式で行っていました (WSL 環境で bindgen CLI を使うようにしていた) 。

一方で a) 方式はインストールした LLVM (の環境) に沿ったコードが出力されるため、マルチプラットフォーム対応の場合にそれぞれのプラットフォームにあった bindgen コードが生成されるというメリットがあります。 b) 方式では bindgen CLI 実行環境に適した内容でコードが生成されるため、他プラットフォームに適合したコードが出力されるか (できるか) どうかはわかりません。

build.rs に記述する

https://rust-lang.github.io/rust-bindgen/library-usage.html

まず Cargo.toml に bindgen の crate を追加します。

Cargo.toml
[build-dependencies]

bindgen = "0.65.1"

次に build.rs に bindgen での生成処理を記述します。

build.rs
let bindings = bindgen::Builder::default()
    .header("cmake/library.h")
    .parse_callbacks(Box::new(bindgen::CargoCallbacks))
    .generate()
    .expect("Unable to generate bindings");

let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
    .write_to_file(out_path.join("bindings.rs"))
    .expect("Couldn't write bindings!");

生成コードの出力先はビルドしたものと同じとして一時ファイル扱いにします (Git 等のコミット対象にしない) 。

出力したコードは任意のモジュールの lib.rs に include コードを書いて取り込みます。

https://rust-lang.github.io/rust-bindgen/tutorial-4.html

lib.rs
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(improper_ctypes)]

include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

bindgen で出力したコードは (当然ですが) 元の名前のままで Rust の命名規約に沿っていないため、規約違反を許容する設定をします。

コマンドラインツールで生成する

https://rust-lang.github.io/rust-bindgen/command-line-usage.html

コマンドラインツールで行う場合はまずツールのインストールをします。

% cargo install bindgen-cli

インストールをすると bindgen コマンドが使えるようになるので、コマンドラインから実行します。

% bindgen --output bindgen.rs cmake/library.h

生成された bindgen.rs は使用する Rust プロジェクト下にコピーしてください。

下記は私のプロジェクトで使っている bindgen の実行スクリプトですが

https://github.com/aosoft/unity-native-plugin-rs/blob/master/unity-native-plugin-sys/bindgen.sh

出力結果に対して sed で加工を加えています。ちなみにこれは bindgen の実行を linux x64 環境下で行う事を想定しているため、生成される関数定義の呼び出し規約が extern "C" となっているところを extern "system" に置き換えるためにやっています。

コマンドラインツールで出力した場合はこのように後付けで加工処理がしやすいというメリットもあります (build.rs でできないわけでもないですけど) 。

おわりに

私は Rust / C++ の作業は CLion で行っているのですが、この Rust + CMake のプロジェクトを CLion で開くと CMake 側も CLion で認識され、IDE のコード補間も効いて C++ も Rust もシームレスに作業ができてとても具合がよいです。

やる前は C++ 側のコーディングは CMake プロジェクトのみを別に CLion で開いて作業しようと考えていたのですが、その必要もなさそうです。まあ C++ 側に集中している時に Rust 側のビルドが走るのは時間の無駄なので、そういう場合は CMake 側のみ開くということもあるとは思いますが、選択できるのはよい感じです。

cmake-rs と bindgen との組み合わせてかなり容易に Rust + C++ の混在開発ができ、単一のバイナリにリンクできるのはよい (言語が違う場合は大抵 DLL 分割になる) ので、これからは積極的に使っていこうかなと思います。

Discussion