ぶていのログでぶログ

思い出したが吉日

Rustでmrubyの拡張を書く

reddish-shellでRustからmrubyを呼び出すことができたので、逆もできないかと思って試してみたらすんなりできたのでメモしておく。

前提: rust-mruby crateを使う

mrubyのAPIをRustから呼び出すためには、mruby.hをRustで定義し直す必要がある。 拙作のrust-mrubyではbindgenにより生成された定義ファイルを同梱しているので、これを使うことで手間が省ける。 mrubyのビルドマクロ(MRB_XXX)が私の環境でのデフォルトを想定していたり、いくつかのマクロ関数などがbindgenで生成されていなかったりといま時点では完璧ではないが、ちょっとずつ増やす予定…。

完成品

サンプルとして以下にリポジトリを用意したのでこれをgit clone && rake すると動くはず。 一応、手元の環境であるUbuntu 21.04 + rustc 1.56.0では動くことを確認している。

github.com

ビルドして実行すると以下のようなメッセージが表示される。

❯ ./mruby/bin/mruby-rust-extension-sample
hello world from Rust!!

Rustで書いたmrbgemはmrbgems/rust-extension配下にある。

解説

難しいことをしているわけではないのでmruby-rust-extension-sampleのコードを見てもらえれば大体わかるとおもうが、ここからは解説を書いておく。

ディレクトリ構成

mrgemのディレクトリ構成は、特殊なものではなくcargo init --libして出力された結果にmrbgem.rakeを追加したものになる。 今回は以下のような構成で作成した。

rust-extension
├── Cargo.lock
├── Cargo.toml
├── mrbgem.rake
└── src
   └── lib.rs

libとしてcrateを定義していてもRustでのみしか使えずCからは使えない。 そのため、Cから扱えるように以下の定義をCargo.tomlに追加する必要がある。

[lib]
crate-type = ["staticlib"]

lib.rs

mrbgemでは mrb_XXX_gem_initmrb_XXX_gem_final という2つの関数を定義する必要がある。 そのため、lib.rsでは以下のような定義を行う。

use rust_mruby::api::*;

#[no_mangle]
pub extern "C" fn mrb_rust_extension_gem_init(mrb: *mut mrb_state)

#[no_mangle]
pub extern "C" fn mrb_rust_extension_gem_final(mrb: *mut mrb_state)

mrb_stateは、 rust_mruby::api::mrb_state として定義されている。

クラス・クラスメソッドを追加する

gem_initが定義できてもほとんど何もできないので、クラスやメソッドを追加する必要がある。 クラスの追加はmrb_define_class、クラスメソッドの追加はmrb_define_class_methodを使う。 RustではFFIの仕組みが充実しているので、これらの関数を呼び出すのは難しくはない。 rust-exampleでは、Rustクラスを定義してクラスメソッドとしてfuncを追加している。

#[no_mangle]
pub extern "C" fn mrb_rust_extension_gem_init(mrb: *mut mrb_state) {
    let mrb = unsafe { NonNull::new(mrb).unwrap().as_mut() };   // mrb_state.object_classにアクセスするためにraw pointerから変換している

    let class_name = CString::new("Rust").unwrap();
    let class = unsafe { mrb_define_class(mrb, class_name.as_ptr(), mrb.object_class) };

    let func_name = CString::new("func").unwrap();
    unsafe {
        mrb_define_class_method(
            mrb,
            class,
            func_name.as_ptr(),
            Some(rust_func),
            MRB_ARGS_NONE(),
        )
    }
}

Rust#funcの実体として定義しているrust_funcは以下のように定義している。

#[no_mangle]
extern "C" fn rust_func(_mrb: *mut mrb_state, _self: mrb_value) -> mrb_value {
    println!("hello world from Rust!!");
    mrb_nil_value()
}

mrbgem.rakeでcargo buildを追加する

mrbgem.rakeに定義を追加して、cargo buildを実行する。 targetディレクトリをmrubyが用意するbuildディレクトリに設定している。 これはやる必要はないが、mrbgemのディレクトリを汚さないために分離するようにしている。

MRuby::Gem::Specification.new('rust-extension') do |spec|
  spec.license = 'MIT'
  spec.author  = 'buty4649'
  spec.summary = 'Rust in mruby'

  cargo_dir = File.dirname(__FILE__)
  cargo_target_dir = File.join(build_dir, "target")

  FileUtils.mkdir_p(build_dir)
  Dir.chdir(cargo_dir) do
    `cargo build --release --target-dir "#{cargo_target_dir}"`
    fail "cargo build failed" unless $?.success?
  end

  library_name = "lib#{funcname}.a"
  library_file = File.join(cargo_target_dir, "release", library_name)
  spec.objs << library_file
  spec.linker.flags_before_libraries << library_file
end

spec.objsに追加すると、mrubyのビルド時にGENERATED_mrb_XXX_gem_init/finalというラッパー関数が定義され、その関数からmrb_XXX_gem_init/finalを呼び出されるようになる。 しかし、そのときに生成されるobjectファイルでは、mrb_XXX_gem_init/finalの解決ができないため、spec.linker.flags_before_librariesにも追加する必要がある。 こちらはlibmruby.aを作成するときのリンカに追加されるので、前述のmrb_XXX_gem_init/finalが解決できない問題を回避できる。 もう少しうまいやり方があるかもしれないが、調べた限りだとわからなかった…。

mrubyから利用する

ここまで作成すれば、利用元のmrbgem.rakeにdependencyに追加すればビルドできるはず。

spec = MRuby::Gem::Specification.new('mruby-rust-extension-sample') do |spec|
-- snip --
  spec.add_dependency 'rust-extension', :path => '../mrbgems/rust-extension'
-- snip --

Rust#funcの呼び出し部分

def __main__(argv)
  if argv[1] == "version"
    puts "v#{MrubyRustExtensionSample::VERSION}"
  else
    Rust.func  👈 ここ
  end
end

おわりに

mrubyからRustのコードを呼び出すことができた。 しかし、今のrust-mrubyだとCの関数を直接呼び出しているのであまりRustを使っているメリットを感じないので微妙…。 一応ラッパーを書いてはいるが、まだ中途半端なのでもう少し作り込むが必要がありそう。