Rust FFIを活用してGBAのエミュレータの太陽センサーの値を制御する拡張機能を実装した

やったこと

GBAエミュレータ(visualboyadvance-m)上の太陽センサー*1の値をエミュレータ内threadに立てたHTTPサーバによって操作できるようにした。

github.com

今回はRust FFIを利用してできるだけ元のコードを汚さずに、かつHTTPサーバの処理等はRustの世界で閉じたものにする、という方向で実装していた。

ちなみにFFIを触っているが自分はRustはまあともかくC/C++は多少読めるけどちゃんと書くのは無理、ぐらいのC/C++の習熟度です。

Rust FFIを実装する上で必要な作業

正直、割と曖昧なまま触っている部分も多いが概ね以下の作業が必要。Ubuntu(apt)のmxe repoが古く、C++17サポートが不完全であるためWindows target向けのクロスコンパイルが壊れているので、それらをなんとかする作業も必要だったが、本筋ではないので割愛*2

  • C/C++
    • Rust側で触る想定のリソース(変数・関数)をRust側から見つけられるようにexposeする
    • Rustからexposeされている関数等のリソースをextern宣言し、呼び出したい箇所で使用するように実装する。
  • Rust 側
    • C/C++側のリソースの宣言
    • C/C++側で使ってもらう関数の実装、expose
    • staticlibとしてビルドする
  • ビルドツールたち
    • CMakeLists.txt を頑張って編集して、C/C++側のオブジェクトファイルとRust側のstaticlibをリンクする

C/C++側: Rust側で触る想定のリソース(変数・関数)をRust側から見つけられるようにexposeする

今回、Rust側では以下のリソースを触る想定となっている。

  • 太陽センサーの値の取得する関数
  • 太陽センサーの値を更新する関数

そのため、以下のように extern "C" を利用してCのABIで*3 関数をexposeしてあげる必要がある

extern "C" uint8_t systemGetSensorDarkness()
{
    return sensorDarkness;
}
// (snip)

extern "C" int level = 0;

extern "C" void systemUpdateSolarSensor()
{
    uint8_t sun = 0x0; //sun = 0xE8 - 0xE8 (case 0 and default)
    // このlevelはRust側にexposeして触りたい
    // int level = sunBars / 10;

systemUpdateSolarSensor 関数のローカル変数を extern してexposeしているのは、 systemUpdateSolarSensor 関数のシグネチャを変更する場合他の関数の呼び出し方を変更する必要があり、シグネチャを変更せずに済むようにRust側で level を編集してから呼び出すといった形で実装することにしたため。

その後、今回 extern 宣言に変更したリソースを使っている他の箇所で同様に extern "C" する形に変更していく必要がある。(staticextern ではメモリの確保のされ方(主にmangling周り)が変わるので別のアドレスが確保される可能性がありそうなので)

余談だが、最初は sensorDarkness という systemUpdateSolarSensor で更新している値を直接変更していたが、なぜか元の値に戻ってしまうのでこういった形で触るようにした。たぶん触ろうとしている関数が適切じゃないんだと思う。

C/C++側: Rustからexposeされている関数等のリソースをextern宣言し、呼び出したい箇所で使用するように実装する。

Rust側でexposeする関数は void start_server(void) なので以下のように宣言しておく。

// src/wx/wxvbam.cpp
extern "C" {
    void start_server(void);
}

そしてたぶん一回だけ呼ばれるであろう関数で呼び出すようにしておく。このあたりは適当なので本当はちゃんと考えたほうがいい。*4

// src/wx/wxvbam.cpp
bool wxvbamApp::OnInit() {
    start_server();

Rust側: C/C++側のリソースの宣言

Rustonomiconとか参考にしましょう。

doc.rust-lang.org

今回の場合は以下みたいな感じ、特に説明することはないですが、リンク時に undefined reference とか言われる場合は extern 宣言をミスってるとかそういうことだと思います。C++ ABIで単に extern するとおそらく普通にmanglingされるのでRust側から見つけられないです(たぶん)。

リンク時にミスってたらひたすらオブジェクトファイルをnm とかしてシンボル一覧と睨めっこしましょう。俺はした。

extern "C" {
    static mut level: i8;
    fn systemUpdateSolarSensor();
    fn systemGetSensorDarkness() -> u8;
}

Rust側: C/C++側で使ってもらう関数の実装、expose

単純に extern "C" で関数を宣言しましょう。 #[no_mangle] をつけてこちらもmanglingされないように気をつけて。

#[no_mangle]
extern "C" fn start_server() {

また、今回サーバ実装部分をaxumで実装しているので、axumのサーバ実行時にFutureをExecutorで実行してあげる必要があります。(単に実行するだけではFuture内の処理は実行されない)(と思う)

    std::thread::Builder::new()
        .name("solar_server".into())
        .spawn(|| {
            let rt = tokio::runtime::Runtime::new().unwrap();
            rt.block_on(async {
                axum::Server::bind(&"0.0.0.0:8000".parse().unwrap())
                    .serve(app.into_make_service())
                    .await
                    .unwrap();
            });
        });

単に tokio::runtime::Runtime::block_on を実行するとそこでthreadがブロックされるので、適当に std::thread::Builder::spawn してあげましょう。これで別Threadでサーバが動いてくれるはず。

CMakeLists.txt を頑張って編集して、C/C++側のオブジェクトファイルとRust側のstaticlibをリンクする

ここが一番分かっていないけどなんとかしました。適当に以下でいけたぜ、という程適当にはやっていないがあんま詳しくないぜ。

# src/wx/CmakeList.txt
target_link_libraries(visualboyadvance-m ${CMAKE_SOURCE_DIR}/solar-server/target/x86_64-pc-windows-gnu/release/libsolar_server.a userenv ntdll bcrypt)

やっていることとしては wx 内にある CMakeLists.txt で その中のプロジェクト(?)である visualboyadvance-m のオブジェクト達を今回作ったRustのstaticlibとリンクしてあげるという作業です。

userenv ntdll bcrypt あたりはRustのstaticlibが解決できなかったシンボルを解決するためにリンクしています。 GetUserProfileW みたいなWindows APIっぽい関数が見つけられていなかったので、そのあたりで必要そうなライブラリを探してきてリンクしたという形になります。

ビルド

export PATH="${PATH}:/PATH/TO/MXE/usr/bin"
mkdir build && cd build
/PATH/TO/MXE/usr/bin/x86_64-w64-mingw32.static-cmake .. -DCMAKE_RELEASE_TYPE=Release -G Ninja
ninja

やるだけ、リンクに失敗したらシンボルを見てコードを修正していくしかないので頑張りました。

*1:ボクらの太陽シリーズのカートリッジについているもの、内部的にはGPIOを触っているっぽい。コロコロカービィとかの傾きセンサーも似たような実装になっているらしい。なんでカートリッジにGPIOとセンサーが生えてるんだよ

*2:mxeのソースから必要なツールチェインの最新版をビルドして利用すればいいです。あるいはCIを参考にWindowsで開発環境を整えてください。

*3:C++のmanglingを回避して

*4:まあOnInitだし一回だけだろ…2回起動してもRust側でサーバがbindできなくてThreadが落ちるだけだし…