Rosetta 2を参考にバイナリトランスレータを作成したので、学んだことをブログにまとめる。趣味の文書のため網羅性・正確性は保証しないが、アーキテクチャの雰囲気は伝わるはず

はじめに

Rosetta 2 (Wikipedia) は、macOS上で動作する互換レイヤのソフトウェアである(Wikipedia Rosetta (software) (English))。 Apple Silicon搭載Macであれば、x86_64(Intel Mac)向け実行ファイルをそのまま動かせる仕組みである 技術的にはバイナリトランスレーションとAOT(Ahead of Time)をベースとしている。 実行時の動的変換、あるいはAOTによる事前変換により、異なるCPUアーキテクチャのプログラムを短い起動・実行時間で動かす工夫がされている。

Rosetta 2について調べ、macOS向けに実装したのがこちら。 https://github.com/suma/rustmute

モチベーション

Wikipediaやニュースによると、将来Rosetta 2はmacOSから削除されるらしい。 また、バイナリトランスレーションは漢のロマン、ということで実際にClaude Codeで実際に作ってどんなものか体験してみたい。

趣旨は次の点になる

  • macOS上で実際に動くバイナリトランスレーションの実装を作る(手でコードを書かず、コーディングエージェントClaude Codeの使用可)
  • x86_64向けのコードのCLIである程度は動くようにする
  • 作ること、理解することが目的なので、各種属性(製品としてのクオリティ、速さ、安全性など)は重視しない。が、最後に気を付ける点をあげても面白いかもしれない
  • 本ブログ執筆にあたって、LLM及び生成物(markdown, source code)は利用してもよい

このブログエントリで言及しないこと、書かないこと:

  • Claude CodeやLLMのプロンプト、ハウツー
  • バイナリトランスレータの作り方、実装の仕方など(だけど仕組みの解説はする!)
  • ソースコードのスニペットなどは貼り付けない

このブログエントリはLLMの生成物が一部入るかもだが、基本的には人間によって執筆されている。

設計の開始

Claude Codeで愚直にアーキテクチャを聞いたところ、Rosetta 2 クローンの設計はすぐ出てきた。 書籍『System Design Interview』にありそうな内容で、実装するだけなら丁度良い難易度である。

全体

x86-64バイナリとそれが利用するdylib(shared object) を含め、命令をすべてユーザー空間で実行する。 要するに、Apple Silicon以外のx86_64命令で書かれた実行ファイルを翻訳して動かすソフトウェアである。

実行モード

  • JIT (実行時あるいは動的に命令を翻訳する = 逆アセンブルして解釈されて実行される)
  • AOT(事前あるいは実行前に命令を逆アセンブル・変換しておき、実行時は翻訳済みコードを実行する)

時間の都合により、今回はJITのみ実装した。

設計

  • ローダー / プロセスの起動
  • デコーダ + IR リフタ (Lifter)
  • バックエンド(コード生成)
  • ランタイム
  • システム互換層

以下Claude Code生成によるコンポーネントの構成を示す。

プロジェクトの構成

好みで実装言語にRustを選択。 プログラム名は Rustmute (Rust + transmute)とした。

crate 構成(これもClaude Codeによる生成)

  • rustmute-core 共通型、GuestCpu 状態、ゲストメモリ抽象、エラー型
  • rustmute-loader Mach-O 解析、dyld エミュレーション(依存解決・再配置・束縛)
  • rustmute-ir Rustmute IR 定義、IR ビルダ、最適化パス
  • rustmute-decoder iced-x86 によるデコード、x86 → IR リフタ
  • rustmute-arm64 ARM64 エンコーダ(dynasm ベース)、共通ロワリング
  • rustmute-jit JIT エンジン、ブロックキャッシュ、ディスパッチャ
  • rustmute-aot AOT コンパイラ、翻訳済み Mach-O/オブジェクト出力(未実装)
  • rustmute-runtime syscall/Mach trap 変換、シグナル、TLS、guest↔host ブリッジ
  • rustmute-cli Rustmute run foo, Rustmute compile foo -o foo.arm64

各コンポーネントの依存

逆アセンブラ、アセンブラのcrateを依存に追加し利用している。

  • rustmute-decoder
    • iced-x86 … iced-x86 is a blazing fast and correct x86/x64 disassembler, assembler and instruction decoder written in Rust
  • rustmute-arm64
    • dynasm … A plugin for assembling code at runtime. Combined with the runtime crate dynasmrt it can be used to write JIT compilers easily.
    • dynasmrt … A simple runtime for assembling code at runtime. Combined with the plugin crate dynasm it can be used to write JIT compilers easily.

実装

Claude Codeの生成物ではある。 今回作成のバイナリトランスレータRustmuteがどんな機能を持っているか記す。

Loader

実行ファイル及び動的ライブラリであるMach-Oやdylibの読み込みを行う。 ファイル内のセクション、CPUの命令、文字列やテーブルなどの情報を読み込む。

既存crateを使うか、自作(Claude Code任せ)か迷うところで判断があったが、自動的に自作となった。 自作の利点としてMach-Oの読み込みの際の自由度があがったと認識している。

覚えておくと良いキーワードとして、VA(Virtual Address)といったものがある(WindowsだとPEフォーマットでRelative Virtual Addressが出たかも)。 ファイル上のオフセットを、ロード後の仮想アドレスに変換する必要がある。

セキュリティに気を使うならば、これらのファイルヘッダーの数値(サイズ、カウント)の値が過剰なまでに巨大になっていないかチェックしたり、数値の演算でオーバーフローになってないかチェックしてもよさそうである。

Loader その2 (dyld)

macOSのdyld (動的リンカー) を使うことも設計上の選択であった。

  • x86_64用にdyldを実装する
  • dyldの代わりに専用のランタイムで処理する(Rosetta 2)

参考: Appleシリコン搭載MacのRosetta 2 - Apple サポート (日本)

それとは別で、libcなどの関数をホストのRustmute側でラップして呼び出す方式(shim方式)もあったが、今回はRosetta型のsyscall trapの方式を採用することにした。 速度を求めるなら当然libc内部をエミュレートしない方がもちろん速いのだが、今回はそこそこエミュレーションさせたいので私の好みでこうなった。

macOSの仕組みな話(確信があまりない)。

  • dyld … dyld (Dynamic Linker, 動的リンカー) は動的ライブラリをロードする。コード署名などのチェックといった役割も担う。
  • dyld_shared_cache … Appleの主要フレームワークを事前リンクしたキャッシュ。起動の高速化、メモリ節約、シンボル解決の高速化が利点。 ここでmacOSのシステムの解説をしているということはつまり、バイナリトランスレータからx86_64の起動時にmacOSにおけるモジュールロードあたりでOSの知識が必要になってくるわけである。

キーワード: dylib, mach-o, virtual address, dyld, dyld_shared_cache

Decoder その1

命令のデコード(逆アセンブル)をし、それを後続に渡す。 昔は練習で逆アセンブラを作りかけたが、x86の逆アセンブラは命令が複雑で学習目的に向かないと思う。 当初から逆アセンブラは iced-x86 crate を考えていたが、自動的にこちらが採用となった。

x86(x64も)は可変長の命令セットのCPUアーキテクチャである。 つまり、1バイトで表現する命令もあれば、2バイト・3バイト…で表現できる命令もあるわけだ。 これは表現したい命令列を短いバイト数で表現できることもあるが、CPUのハードウェアを作るときも、バイナリトランスレータで利用する逆アセンブラを実装するとき命令が固定長のものを作るよりも難しくなる。

さいわいにも、Rustには iced-x86が存在したので助かったのだが、正確性だけでなく実行速度も速いとは恐れ入る。

Decoder その2(Lifter)

後述するIRという構造(表現)へ、抽象度を上げるのでLift(LiftしているコンポーネントをLifter)と言う。 IRになった段階で、後続(AOT/JIT)のために一部の最適化も実行できるようになる。

この段階で、実行できる最適化で代表的なものは以下のものがある。

  • 遅延フラグ除去
  • レジスタの不要なコード除去(DCE, Dead code elimination)
  • 定数畳み込み

ランタイム・JITエンジン及びsyscall

実質ここがプログラムの起点(main)であり、図のように実行ファイルを処理するentrypointとなるプログラムである。

syscallの実装は、Rustで書かれたlibcを呼び出したりしている。

Guest ↔ Host ブリッジ

syscall / Mach trap を境界とし、

  • ユーザー空間のx86コード(メインバイナリ+ dylib)は全てARM64に変換、翻訳して実行
  • カーネルへの問い合わせが必要な瞬間(syscall、Mach trap)だけホストネイティブ(rustmute-runtime)に渡される
  1. syscallとMach trapは別々のディスパッチに振り分けられる
  2. rustmute-runtimeの命令実行ループ(ランループ)は、syscall/Mach trapに遭遇すると制御を取り戻し、ホスト側ハンドラを実行後、ゲストを再開する。
  3. メモリモデル… x86_64とホストの仮想アドレスは1:1マッピングされる。
  4. ゲストはx86_64、ホストはARM64なのでゲスト向けに嘘の情報を返すこともある。

(ここはLLMのコピペ&Claudeで図を作成):

計算ロジックは翻訳済みARM64コードとして完結させたまま、「OS・カーネルとやり取りする瞬間」だけホスト機能を呼び出し、その結果をx86的なレイアウト・値に変換してゲストに返す、というのがブリッジの基本設計です。

例外/シグナル/フォールト

基本

  • ゲストコードが例外(未対応命令、不正アクセス等)に遭遇すると、ランループに制御が戻る。

シグナル

  • ゲストでの sigaction で登録されたハンドラをホスト側で保持される。
  • 同期と非同期シグナルの区別。
  • ランループでホストに制御が戻り、ホスト側のハンドラを実行している。

ページフォールト(ここはLLMのコピペ):

  ARM64 生成コード(メモリヘルパ)
    null/OOB アクセス検出
    → PENDING_MEM_FAULT に詰めて即 return(panicしない)
          ↓
  JIT run() ループ(ブロック実行直後、毎回チェック)
    take_pending_mem_fault() で検出
    → get_sig_disp(11) でハンドラ有無を確認(runtime のグローバル表)
          ↓
    ハンドラあり: deliver_signal(11, rip)
      → ゲストスタックに siginfo_t/ucontext_t 構築
      → SIG_RETURN_ADDR を仕込んでハンドラへジャンプ(SyscallResult::Redirect)
      → ハンドラ ret 時に run() が sig_stack から元の文脈を復元
    ハンドラなし: exit(139)  ← 実機の SIGSEGV デフォルト動作を模倣

ポイントは、実際の ARM64 MMU フォールト(W^X やマップ外アクセス)ではなく、ソフトウェアでの範囲チェックを使って意図的にSIGSEGV相当の状況を作り出している点です。これにより Rust 側で panic/unwind することなく、x86ゲストにとって自然な「SIGSEGVが配送される」という観測結果を再現しています。

Mach trap (Machのシステムコール)

macOSはBSD系のUnix syscallだけでなく、 Mach(XNU カーネル)由来のMach trapというシステムコールを持っている。 それをrustmute-runtimeではsyscallと並べてMach trapを処理するコードを書いている。

メモリオーダリング (TSO)

時間が足らずTSO(Total Store Ordering)の実装は非対応。

  • x86_64 … TSO(Total Store Ordering) を採用
  • ARM64 … 弱いメモリ順序

バイナリトランスレーションで、x86_64命令をARM64命令に1対1で変換しているだけだと、ロック処理、並行処理に依存したコードで異なる挙動を示す可能性がある。 そこで、Apple SiliconではTSOにメモリモデルを切り替える命令があるらしい。

参考:

IR の設計

IR(Intermediate Representation 中間表現)について説明する。 IRの意義は、バイナリトランスレーションのような異なる表現間(今回はCPUアーキテクチャ。プログラミング言語でもいい)において、共通の意味表現を提供し、今回は命令の実行やコード生成、そして最適化を実現することにある。

例えば、「x86_64 → IR → JIT(ARM64)」「x86_64 → IR → AOT(実行ファイル)」のように、共通のx86_64から異なるバックエンド(JIT, AOT)を生成するための独自表現として今回のIRは利用される。

今回のIRコンポーネントは表現の提供以外に、以下の特徴を持っている。

  1. フラグの遅延評価 … 後述する「デッドフラグ除去」が可能になる。
  2. x86の加減算のように桁上がり下がりのキャリーを保持した細かい表現を、IRで実現している。
  3. JIT/AOT 共有 … 一度フロントエンド(decoder)を書けば、JIT/AOT両方で利用できる。

最適化のパターン

バイナリトランスレーションやJITにおける最適化はいくつかある。 初期実装と、後から実装に加えた物それぞれ示す。

  • 初期からあるもの
    • 命令キャッシュ … 逆アセンブルしてARM64コードに変換済みの命令列をキャッシュする。 HashMapで1探索で完了する。アドレス単位で探索。
    • レジスタ読み書きの関数呼び出しを当初していたが、相当する命令に置き換えたことによる高速化。
  • 後から追加のもの
    • デッドフラグ除去 … フラグレジスタへ出力する命令でも、後続でフラグが入力されていない・使われていないなら計算は無駄となる。これをIRレベルで除去する。
    • レジスタのdead code 除去(dead code elimination) … 短時間で終わるプログラムでは最適化が逆に時間を食っているかも。
  • 今回、実装を見送ったもの
    • ブロックチェイニング … 分岐先へ直接ジャンプしてディスパッチ往復を省くもの。自己書き換えコード(SMC)への対応にも追われそうで、修正規模の割に(今回の実験的な実装での)メリットがなく未実装。

今回実行したファイルは最適化ビルドされたものであるため、バイナリトランスレーション後に最適化が効く余地は狭いのでは、という話もある。

実装・デバッグのアプローチ

主にClaude Codeでのやり方を書くが、人間がやってもおよそ同じやり方をとると思う。

  1. ローダーやランタイムをギリギリ動かす最低限繋げる/主要な命令(加減算や条件分岐など)も実装する
  2. 代表的なプログラム(CLIで、echoなど)を動かす
  3. 未実装のx86_64の命令あるいは実行結果やエラーや正常終了するまで動かす
  4. 結果(出力、エラー)が出たらデバッグ(命令を実装したり失敗箇所を修正する)/成功したら2で別のプログラムを実行する

要は、バグが出なくなるまで色んな実行バイナリを食わせてぐるぐるデバッグと実装を回す方法である。 人間がバイナリトランスレータなりエミュレータなりを実装する時もこんなアプローチだと想像できるが、これは結構Claude Codeと相性が良かった(トークンを消費するが)。

動作させた実行ファイル

完全動作かは別として、起動・実行を確認した代表例。

  • tar, gzip, bzip2, zip, sw_vers, seq, ls, cp, file, sort, curl, textutil

開発中は実装不足だったが、最終的にはおおよそ動作するようになった。

ベンチマーク

md5コマンドの実行時間を紹介する。

$ time ./target/release/rustmute run-with-dyld /sbin/md5 /sbin/md5
[dyld-boot] main binary: /sbin/md5  entry=0x100000680  is_lc_main=true
[dyld-boot] dyld: /usr/lib/dyld  entry=0x4e50
[dyld-boot] KernelArgs.mainExecutable=0x100000000
[dyld-boot] shared cache mapped at 0x7ff800000000
[dyld-boot] starting JIT at dyld entry 0x4e50, RSP=0x7fffffdff320
MD5 (/sbin/md5) = 2e26db32e9d772b7c4551c11977e444e

real    0m0.704s
user    0m0.315s
sys     0m0.387s
$ time /sbin/md5 /sbin/md5
MD5 (/sbin/md5) = 2e26db32e9d772b7c4551c11977e444e

real    0m0.006s
user    0m0.002s
sys     0m0.004s

md5コマンドのファイルは133KBなのでmd5を計算するにしては負荷が小さいく、RustmuteのJITのオーバーヘッドが大きいため、md5計算自体の差は見えない。

ubuntu-24.04.3-desktop-amd64.iso (5.9G)を対象にするとmd5コマンドで12.560s,、rustmuteで306.697s 純粋にCPU負荷のワークロードではまだまだ遅い。

isoイメージは大きすぎたので適当なepub(300MB近い)を使って、実行時間も計測した。

rustmute timings [run-with-dyld]:
  load        :      0.1 ms
  stack-setup :      0.0 ms
  cache-map   :    165.1 ms
  lift        :      7.3 ms   (13699 calls)
  codegen     :    121.0 ms   (13699 calls)
  execute     :  13242.5 ms   (57331408 calls)
  syscall     :    134.7 ms   (73929 calls)
  ───────────────────────────────────
  measured    :  13670.8 ms
  total wall  :  16309.3 ms
  block cache:
    hot hit    :   57317709  (99.98%)
    slow hit   :          0  ( 0.00%)
    cold miss  :      13699  ( 0.02%)
    smc        :          0  ( 0.00%)
    ─ total    :   57331408
  dispatch exits:
    jump       :   57257479  (99.87%)  ← chainable
    syscall    :      73929  ( 0.13%)
    ret        :          0  ( 0.00%)
    trap       :          0  ( 0.00%)
    ─ total    :   57331408  (== execute ✓ )
  chaining estimate:
    loop overhead  :   46.0 ns/iter
    potential save : 2635.2 ms  (= jump × overhead)

改善

  • Rustの std::time::Instant をコンポーネントの各フェーズに仕込み、実行時間を見えるようにした。
  • 変換した命令のブロックキャッシュにhit/missカウンタを追加した。
  • ブロックチェイニングの最適化が有効化どうかを出力させた。

今回は結果がすぐ欲しくて雑にカウンタを追加しちゃった(Claudeの言いなり)。

実行結果は想像通り、md5アルゴリズムの実行であるから変換した命令(execute)の実行時間と実行回数が明らかに大きい。 命令の変換自体はオーバーヘッドとしては小さいことがわかる。 こういう結果を見ながら最適化をしてみたいものである。

参考 - Hot hit, Slow hit, Cold miss

まとめ

バイナリトランスレータの設計、実装についてまとめた。 実装の詳細は多く省いたが、内部構造の理解の助けになれば幸いである。

書いてない技術トピック

Claude Code(コーディングエージェント)の力を借りて実装したわけだが、乗り越える必要のあった技術課題、コンピュータアーキテクチャの専門分野がいくつもあったはずである。 書き残しておきたいトピックはここで取り上げる。

  • 仮想メモリの実現方法
  • プロファイル
    • ベンチマークのところで少し取り上げたが、Rustmuteの各機能ごとの実行時間の測定。これによりオーバーヘッドの切り分けも可能になる。
    • キャッシュの性能、ヒット率、ミス数の計測。次に実行する最適化の有効性を確認することもできる。

将来の展望

試しに作ったバイナリトランスレータであるが、意外とCLIなどはサクサク動いたのであった。 色んな制約から今すぐは実装しないが、作ると面白そうなものを次にあげる。

  • アンチウイルスのサンドボックス
  • フロントエンドでのARM64命令の対応
  • 開発ツール(Valgrindもどき、プロファイラ、仮想マシン技術を応用して何か)

バイナリトランスレーションがなければ、ほぼx86_64向けエミュレータ兼macOS ローダー及びランタイムである。

コーディングエージェントでソフトウェアが簡単に作れる時代ではあるが、設計とアーキテクチャ学習を繰り返し、人の手でアイディアやPoCの実現を加速させたい。

ウェブ上の資料

まずは基本的(公式あるいはWikipedia)の資料に頼るつもりで、他の資料に頼らず作成、執筆した。 参考にはなるはず。

キーワード

  • shim 方式 … ゲストがライブラリやシステムコールを呼ぶ際に、ホストの薄いラッパーを呼ぶ方式のことをこう言うとか(ウェブで資料が出てこない)
  • SMC(Self-Modifying Code): 自己書き換えコードのこと。実行時にプログラムがCPUの命令を生成したり、命令にパッチをあてたりすることがある。例えばパフォーマンス目的で、今回のようにJITコンパイラなどを作る場合。また、アンチデバッグ技術として実行ファイルの解析をしづらくさせるために自己書き換えを行うこともある。
  • Lift, Lifter: 上位へ持ち上げるという意味で、機械語→ IR などが例となるらしい。逆方向がLower。
  • Lower: Lift, Lifterとは逆に IR → 機械語というようにより低レイヤのこと。ロワリングは低レイヤへ変換すること。

おまけ - コード行数

create内のコード行数の一覧
$ wc -l `find crates/ -name "*.rs"`
     107 crates/rustmute-jit/tests/dynlink.rs
     119 crates/rustmute-jit/tests/threads.rs
    2622 crates/rustmute-jit/src/lib.rs
     255 crates/rustmute-jit/src/futex.rs
     159 crates/rustmute-ir/src/types.rs
     261 crates/rustmute-ir/src/flags.rs
      25 crates/rustmute-ir/src/lib.rs
     107 crates/rustmute-ir/src/block.rs
    1148 crates/rustmute-ir/src/instr.rs
    1083 crates/rustmute-interp/tests/interp_basic.rs
     292 crates/rustmute-interp/src/flags.rs
      10 crates/rustmute-interp/src/lib.rs
     869 crates/rustmute-interp/src/interp.rs
     173 crates/rustmute-arm64/src/flag_dce.rs
     575 crates/rustmute-arm64/src/regalloc.rs
      79 crates/rustmute-arm64/src/lib.rs
     886 crates/rustmute-arm64/src/helpers.rs
     125 crates/rustmute-arm64/src/abi.rs
    1857 crates/rustmute-arm64/src/lower.rs
      21 crates/rustmute-aot/src/lib.rs
     223 crates/rustmute-runtime/tests/execution.rs
     233 crates/rustmute-runtime/src/native.rs
    2912 crates/rustmute-runtime/src/lib.rs
     444 crates/rustmute-runtime/src/shims.rs
    1054 crates/rustmute-decoder/tests/lift.rs
    3208 crates/rustmute-decoder/src/lifter.rs
      10 crates/rustmute-decoder/src/lib.rs
     140 crates/rustmute-decoder/src/regmap.rs
     339 crates/rustmute-cli/src/main.rs
     168 crates/rustmute-cache/tests/parse_x86_cache.rs
      22 crates/rustmute-cache/examples/symbolize.rs
      27 crates/rustmute-cache/src/error.rs
     128 crates/rustmute-cache/src/lib.rs
     128 crates/rustmute-cache/src/header.rs
     197 crates/rustmute-cache/src/exports.rs
      95 crates/rustmute-cache/src/image.rs
      87 crates/rustmute-cache/src/mapping.rs
     557 crates/rustmute-core/src/cpu.rs
      46 crates/rustmute-core/src/error.rs
      13 crates/rustmute-core/src/lib.rs
     482 crates/rustmute-core/src/memory.rs
     199 crates/rustmute-loader/tests/load_macho.rs
      24 crates/rustmute-loader/examples/disas.rs
      15 crates/rustmute-loader/examples/readstr.rs
     235 crates/rustmute-loader/src/cache.rs
      20 crates/rustmute-loader/src/lib.rs
     338 crates/rustmute-loader/src/macho.rs
     326 crates/rustmute-loader/src/dyld.rs
     123 crates/rustmute-loader/src/fat.rs
     240 crates/rustmute-loader/src/stack.rs
     295 crates/rustmute-loader/src/loader.rs
     124 crates/rustmute-loader/src/shims.rs
   23225 total