この記事は ニフティグループ Advent Calendar 2025 および Rust Advent Calendar 2025 シリーズ 1 の 9 日目の記事です。
忙しい方向け
Rust でファミコンの ROM を作ろうとしたら想定外に大変だったけど楽しかった、というお話です。
- mrustc + cc65 という組み合わせで Rust コードをファミコン用にコンパイルしたかった
- mrustc が生成した C コードを cc65 でコンパイルするためにひたすら魔改造
- 本物の libcore も改造しつつコンパイルが通るところまでいって感動
- しかし出来上がったバイナリは 32KB の枠に対して 347KB オーバーという衝撃の結果に…
- リンカ (ld65) を魔改造して不要なシンボルを削ろうとしたが、低レイヤーが分からず撤退
- 最終的には最小限の libcore を実装し、とりあえず動かすことに成功
紆余曲折ありましたが、とても楽しい冒険でした。
まずファミコンってどんなハード?
ファミリーコンピュータ (以下ファミコンと呼びます) は 1983 年に任天堂から発売された家庭用ゲーム機です。海外版の Nintendo Entertainment System という名前から NES とも略されます。今から 40 年以上前のハードウェアですが、スーパーマリオブラザーズやゼルダの伝説など、今なお語り継がれる名作を数多く生み出した伝説的なプラットフォームです。
自分も全く詳しくないのですが、スペックはこんな感じ。
- CPU: 8 bit で MOS 6502 ベースの改造版 (Ricoh 2A03)
- RAM: わずか 2KB
- ROM: カートリッジによるが、今回は 32KB 程度
- 画面解像度: 256×240 ピクセル
現代の感覚からすると信じられないほど小さいスペックですが、この制約の中で当時の開発者たちは驚くべき工夫を凝らしてゲームを作っていたのですね…。
なんでやろうと思ったのか
ある日 YouTube を見ていたら、Rust でファミコンのエミュレータを作る動画に出会いました。
面白そうなので自分もやってみようと思ったのですが、いやちょっと待てよと。
作ってもエミュレータで動かせるソフトを持っていないんですよね。
だったらそれも Rust で作ってしまえばいいのではないでしょうか?
大昔のファミコンで現代の言語である Rust が動いたらうれしいと思いませんか。
私はうれしい気持ちになったのでやってみることにしました。
そもそも Rust でファミコンは可能なの?
アプローチは二通りあると思います。
まずは LLVM ベースのバックエンドを作るなり探すなりして、通常の Rust ツールチェインの上で動かす方法。
もう一つは、何らかの方法で Rust をトランスパイルし、既存の 6502 用のコンパイラでコンパイルする方法です。
まず、既存のツールチェインを探す方法から。通常、新しいプラットフォームを用意するならこちらが正攻法になりそうです。
最初に確認するのは Rust の公式な Platform Support (Tier 1 〜 3) です。
見ます。ファミコンは含まれていません。そりゃそうだ。
しかし、コミュニティによる素晴らしいプロジェクトがいくつか存在するようです。
まず、llvm-mos という LLVM バックエンドがあります。
こちらは 6502 系の CPU をターゲットにしており、ファミコンで動作させた事例も紹介されています。
しかもこのリポジトリ、2 週間前にもコミットがあってちょっとびっくりしました。
Rust は基本的に LLVM をバックエンドにしているので、それさえあれば Rust コードを 6502 系 CPU 向けにコンパイルすることが可能です。
実際、それをベースにした rust-mos というプロジェクトも存在して、Rust ツールチェインの構築までできていそうでした。今回は試していませんが、実用的なレベルの環境を求めるのであればこちらの方が良いような雰囲気があります。
もう一つは、Rust を何らかの言語にトランスパイルしてそこから 6502 用のコンパイラでコンパイルする方法です。
その場合、第一選択肢はコンピュータ界の標準語であるところの C 言語です。
そして、実はこれにちょうど良いプロジェクトが存在していました。
特に cc65 はファミコン用 ROM を作る界隈では有名なようで、日本語での解説やサンプルコード、情報も多数あります。
どちらのアプローチをとるか考えたのですが、後者の方が私にとっては取り組みやすいのではないかと思いました。何かうまく動かないことがあったとしても、C 言語を中間言語にしていれば何が起きているか読めばよいからです。
ということで、今回は mrustc + cc65 の組み合わせで Rust コードをファミコン用にコンパイルすることを目指すことにしました。
Q: でも C 言語経由するのずるくないですか? Rust で書いたって言えるの?
A: ずるいと思いました。でも最終 Rust だけ書いて機械的処理だけで動くって考えたら Rust で書いたって言えると思いませんか? 私はそう言い張ります。
mrustc とは
mrustc は、rustc をブートストラップすることを目標に C++ で書かれた Rust コンパイラです。
若干話が逸れてしまいますが、公式の Rust コンパイラである rustc 自身は Rust で書かれています。そのため Rust をコンパイルするのには Rust コンパイラが必要です!
これは困りました。もしこの世からすべての Rust コンパイラが消滅してしまったら、もう一度 Rust コンパイラを使えるようになるまでには相当な道のりが必要になってしまいます。
ちなみに、その作業をコンパイラのブートストラップと呼ぶらしいです。
そこで mrustc があれば、C++ コンパイラさえあれば Rust コンパイラを再構築できるようになって便利というわけです。
さらに脱線しますが、じゃあ、その gcc やら Linux やらまで一緒に消し飛んだら再構築はできないのでしょうか?
これについては live-bootstrap というプロジェクトなどがあって、手で書いた小さい機械語からアセンブラ、 GCC、coreutils などを経て OS を再構築する手順が丁寧に解説されています。これは異世界転生に備えて覚えておくのも良いかもしれません。きっと現代的なコンピュータ環境を再構築できることでしょう。ソースコードが降ってくれば、ですが…。
話を戻しましょう。
そんな mrustc のツールとしての一番の特徴は、あくまでも valid な Rust コードがコンパイルできることを最重要視している、という点です。逆に invalid な Rust コードがうっかりコンパイルできてしまってもあまり気にしません。例えば…
- ライフタイム検査をしない
- コンパイルエラーは「本来コンパイルが通るはずのコードなので mrustc のバグである」と考え、診断は丁寧ではない
この割り切りは目標がぶれていなくていいなと思いました。そのかわり、コンパイルエラーが発生したときの調査は若干難しいときがありました。
ただ、今回私にとっての何よりの重要ポイントは、mrustc がバイナリではなく C 言語のコードを出力するという点です。
これはおそらく mrustc 自身のコンパイルに C++ を使うので、同じコンパイラで C コードもコンパイルできてブートストラップ上便利という意図なのでしょう。
今回はその特長を生かして高級なトランスパイラとして使わせていただきます。
そして、この C 言語コードを cc65 でコンパイルすることを目指します。
cc65 とは
cc65 は、ファミコンに搭載されている MOS 6502 系 CPU をターゲットとした C コンパイラツールチェインです。
Apple II や Commodore PET、そしてもちろんファミコンなど、6502 系 CPU を搭載したハードウェアで動くソフトウェアの開発に広く使われています。
C99 の一部機能に対応しており、コンパイラ (cl65) 、アセンブラ (ca65) 、リンカ (ld65) に加え、ディスアセンブラ (da65) やオブジェクトダンプ (od65) まで何でも一通りツールがそろっています。
また、ファミコン向けのメモリ領域設定が標準で付属しており、ちょっとした標準ライブラリも備えているので、文字を出力するだけの ROM なら本当にすぐに作れてしまいます。
自力で実装しようと思うと CHR ファイルにフォントのビットマップを用意して PPU レジスタを操作して… と大変らしいですが、cc65 のライブラリを使えばなんと cprintf() 関数一発でリッチに画面に文字を出すことができます。
ファミコン開発においては cc65 は定番ツールの一つとなっているようで、特に日本語の解説記事やサンプルコードも豊富に存在します。今回ファミコン初挑戦なのでこれはありがたいです。
C コンパイラの候補としては先ほどの llvm-mos の成果物である clang だけ使わせていただく手もあると思うのですが、今回はそのお手軽さが決め手となり、cc65 を採用することにしました。
頑張って mrustc が出力した C コードをコンパイルできるようになってもらおうと思います。
レギュレーション
さて、実際に作業を始める前に、どこまでできたら「成功」とするかのレギュレーションを決めておきます。
- Rust だけで書かれたコードに機械的な処理だけを行い、何かしらファミコンエミュレータで動けば OK とします。
- cc65 に入っているライブラリ (例えば
cprintf()) は使って良いことにします。 - 動かしたい部分が動けば OK で、任意の Rust の言語機能が動く必要はありません。
Q: 2 番目と 3 番目、ずるくないですか?
A: いやずるいとは思いました。でも低レイヤー分からなすぎて、PPU の初期化とか割り込みとか、アセンブリレベルで手書きするとすごく変なところで沼りそうな予感がしたんです。技術をつけたらいつか再チャレンジするかもしれません。
とはいえ、最終成果物のために自分で書くコードはすべて Rust で、あとは機械的な変換だけで動かすという方針だけは貫きたいと思います。
cc65 と mrustc の動作確認をする
さて、まずは cc65 と mrustc がそれぞれちゃんと動くことを確認しておかないと始まりません。
cc65 でファミコン用 ROM を作ってみる
cc65 は使うだけならなんと apt でインストールできます。
|
1 |
apt install cc65 |
まずは試しに “Hello, World!” を表示するプログラムを書いてみましょう。
|
1 2 3 4 5 |
#include <conio.h> int main(void) { cprintf("hello, world!"); while (1); // 無限ループで終了しないようにする } |
これを cl65 -t nes hello_world.c でコンパイルすると、あっという間に hello_world という ROM ファイルが生成されます。
もうこれが ROM です。hello_world.nes という名前にしてエミュレータで実行してみると画面に “Hello, World!” と表示されました。あっけないけど、でもすでにちょっとうれしい。

なお今回、スクリーンショットには MERU という Web 上で動くエミュレータを使わせていただいております。なんとこちらのエミュレータも Rust で書かれているそうです。
mrustc をビルドする
続いて mrustc をダウンロードしてビルドしてみます。
|
1 2 3 |
git clone https://github.com/thepowersgang/mrustc cd mrustc make -f minicargo.mk |
これも驚くほどあっさりとビルドが完了しました。
生成されるのは以下のものです。
mrustc: rustc の代替となるコンパイラ本体minicargo: cargo の代替となるビルドツール- 標準ライブラリのオブジェクトファイル
ちなみにこの状態でビルドされたオブジェクトファイルは、ホストマシンの同じ C コンパイラを使って生成されたバイナリです。なのでもうホストマシンで動く Rust プログラムはコンパイルできる状態になっているはずです。試してみましょう。
|
1 2 3 |
fn main() { println!("Hello, world!"); } |
実行します。
|
1 2 |
$ mrustc hello.rs && ./hello Hello, world! |
問題ないですね。
ちなみにこのとき生成された C コードは 324 行ありました。
軽く眺めてみると、ネームマングリングされた大量の関数、名前が消去された変数名、MIR を彷彿とさせる bb1:, bb2: といったラベル、そして大量の goto 文が見られます。
いかにも機械生成されたコードという感じですが、所々に元の型名などをコメントで残してくれているのが良心的で、意外と読みやすそうです。
↓ Rust の main() 関数をコンパイルしたと思われる部分
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// ::"bin#"::main void ZRG1cD3bin4main0g(void) // -> () { tUNIT rv; struct s_ZRG2cE9core0_0_03fmt9Arguments0g var1; // ::"core-0_0_0"::fmt::Arguments<'static,>/*S*/ struct e_ZRG2cE9core0_0_06option6Option1gBsSG4c_A3fmt2rt2v18Argument0g var2; // ::"core-0_0_0"::option::Option<&'static [::"core-0_0_0"::fmt::rt::v1::Argument/*S*/],>/*E*/ SLICE_PTR var3; // &'static [&'static str] SLICE_PTR var4; // &'static [::"core-0_0_0"::fmt::ArgumentV1<'static,>/*S*/] var3 = make_sliceptr(&ZRG2cD3binB_09FRAGMENTS0g.val, 0x1ull); // _3 = MakeDst(&::"bin#"::#0::FRAGMENTS, 0x1 usize) var4 = make_sliceptr(&ZRG1cD3binF6const00g.val, 0x0ull); // _4 = MakeDst(&::"bin#"::const#0, 0x0 usize) memset(&var2, 0, sizeof(struct e_ZRG2cE9core0_0_06option6Option1gBsSG4c_A3fmt2rt2v18Argument0g )); // _2 = Variant(::"core-0_0_0"::option::Option<&'static [::"core-0_0_0"::fmt::rt::v1::Argument/*S*/],> #0, {}) var1._0 = var3; var1._1 = var2; var1._2 = var4; // _1 = Struct(::"core-0_0_0"::fmt::Arguments<'static,>, {_3, _2, _4}) ZRG3cD8std0_0_02io5stdio7__print0g( var1 ); // ^ Call( _0 = ::"std-0_0_0"::io::stdio::_print<'static,>( _1, ), bb1, bb2) /* ZST assign */ return ; // ^ Return bb2: _Unwind_Resume(); // Diverge } |
さて、それぞれのツールが動くことは確認できました。
ここからが本番です。
mrustc の吐き出すコードを cc65 でコンパイルする
いよいよ mrustc が出力した C コードを cl65 でコンパイルしてみます。
cl65 が対応していないコマンドラインオプションをごまかすラッパースクリプトだけ挟み、それ以外は素の状態でまず試してみます。
早速自分で書いたコードをコンパイルしたいところですが、実は何よりも先にコンパイルしなければならないものがあります。core という crate です。
急に出てきてなんだと思われると思いますが、これは Rust の標準ライブラリの一部です。
Rust の標準ライブラリは、実は以下のような複数の crate によって構成されています。
- core: 最小限の言語のコア機能を集めた小さいライブラリ。プリミティブ型のメソッドはもちろん、四則演算 (
Add,Sub,Mul,Div) やIterator,Sizedといったコンパイラが特別扱いするトレイトの実体もここにある。OS やヒープアロケーションに依存しない、純粋な言語機能だけを提供する。 - alloc: メモリアロケーションが必要な機能を提供するライブラリ。
Vec,String,Boxなどがここに含まれる。OS ほどリッチな機能はないがアロケータくらいなら作れる、という環境で便利。 - std: OS 機能を含む、フル機能の標準ライブラリ。ファイル I/O、ネットワーク、スレッドなど、通常のアプリケーション開発で必要な機能がすべて揃っている。
core と alloc の上に作られており、主要な要素を re-export してくれている。そのため、私たちは背後にある core と alloc を意識せず、std::ops::Addやstd::string::Stringとして参照できている。
組み込みプログラミングではリッチな OS サポートがないこともあるので、#![no_std] というフラグをつけて標準ライブラリを切り離すことが多いと聞きます。
しかし core を外すことは通常ありません。core は Rust が正気を保てる最後のラインであり、これを外すと本当に文字通り何もできなくなってしまいます。そのため、今回も cl65 で core をコンパイルできるところをまず目指す必要があります。
さて、core crate を mrustc でコンパイルして C ファイルを作るところまでは、ホストマシン向けの処理と一緒なので特に問題はありません。果たして libcore.rlib.c という単一の C ファイルが得られました。
問題はここから。このファイルを cl65 はコンパイルできるでしょうか。
ドキドキしながら最初のコンパイルを試してみた結果がこちらです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
--- BUILDING core v0.0.0 (0.0% 1r,0w,12b,0c/13t) > /workspace/mrustc-master/bin/mrustc /workspace/mrustc-master/rustc-1.29.0-src/src/libcore/lib.rs -o output/libcore.rlib -C emit-depfile=output/libcore.rlib.d --cfg debug_assertions -O -L output --crate-name core --crate-type rlib --crate-tag 0_0_0 > output/libcore.rlib_dbg.txt (0.0% 1r,0w,12b,0c/13t): core v0.0.0 /workspace/mrustc-master/rustc-1.29.0-src/src/libcore/slice/mod.rs:1947-1948 warn:0:Unexpected attribute rustc_on_unimplemented on impl /workspace/mrustc-master/rustc-1.29.0-src/src/libcore/slice/mod.rs:1960-1961 warn:0:Unexpected attribute rustc_on_unimplemented on impl output/libcore.rlib.c(9): Error: Include file 'stdatomic.h' not found output/libcore.rlib.c(12): Error: Include file 'math.h' not found output/libcore.rlib.c(22): Error: Identifier expected output/libcore.rlib.c(22): Warning: Implicit 'int' is an obsolete feature output/libcore.rlib.c(22): Error: ';' expected output/libcore.rlib.c(26): Warning: Implicit 'int' is an obsolete feature output/libcore.rlib.c(26): Error: ';' expected output/libcore.rlib.c(27): Warning: Implicit 'int' is an obsolete feature output/libcore.rlib.c(27): Error: ';' expected output/libcore.rlib.c(28): Error: Identifier expected output/libcore.rlib.c(28): Warning: Implicit 'int' is an obsolete feature output/libcore.rlib.c(28): Error: ';' expected output/libcore.rlib.c(28): Warning: Implicit 'int' is an obsolete feature output/libcore.rlib.c(28): Error: ';' expected output/libcore.rlib.c(28): Warning: Implicit 'int' is an obsolete feature output/libcore.rlib.c(28): Error: ')' expected output/libcore.rlib.c(28): Warning: Implicit 'int' return type is an obsolete feature output/libcore.rlib.c(28): Error: '{' expected output/libcore.rlib.c(28): Fatal: Too many errors C Compiler failed to execute - error code 256 |
… あの、28 行目で諦められたら困ります。mrustc が吐き出した C コードはまだ 28 万行もあるんですけど…
まあ最初はそんなものでしょう…
cl65 でコンパイルできるようにいじり回す
細かいものは除いて、主な原因は次のような感じです。
- 特定のコンパイラ (gcc または MSVC) に依存するコードが生成されている。
__builtin_*系関数とか。- zero-sized array とか。
- cl65 が対応している C 標準が若干古い。
- C99 のいくらかに対応しているとのことですが、足りないものも多かったです。
- 簡単なところでは for の中での識別子定義
for (int i = 0; ...) - ニッチなのだと designated initializer
struct X x = { .member = value }とか。
- 簡単なところでは for の中での識別子定義
- C99 のいくらかに対応しているとのことですが、足りないものも多かったです。
- プリミティブ型が限定されている。
- cc65 (6502 CPU) には float, double サポートがありません。
- 整数型が (u)int32_t までしかありません。
- Rust でいうと i64, u64, i128, u128 がサポートできません。
- 特に u64 は core::any::TypeId で必要になるので避けられないのですが、避けるしかありません。
- アドレス空間が 16 bit しかない。
- それと関連して、アセンブラ (ca65) が用意するラベル用の領域が合計 16 bit = 32768 個分しか用意されておらず、28 万行というコードベースに耐えられなくてオーバーフローしてしまいました。
- ローカル変数領域のサイズが少ない。
- メモリが小さいのでローカル変数をたくさん配置するのは無理です。
- 調査の段階で core::hash の中で計算用に 1024 バイトの配列をとっているコードを発見し驚くなどしました。現代の PC は大富豪ですね。
ほとんどは mrustc のコード生成の修正です。ただ、ラベル上限などはアセンブラ (ca65) やリンカ (ld65) の修正が必須になりますし、float 等のサポートがない問題については core ライブラリ側の修正が必須でした。
とりあえずまず一旦はコンパイルが通るところを目指し、そのために暴挙という暴挙をいとわず魔改造します。typedef int float; なんて今後書くことはないでしょうね…
試行錯誤の末、なんとか魔改造 cc65 で魔改造 core をコンパイルできるまでには至りました。
ゴールを間近に感じます。
ここからは単純なユーザープログラムをコンパイルできれば良いはずです。最初の C とほぼ同様な、単純なプログラムをコンパイルしてみたところ…
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#![feature(no_core)] #![no_core] #![no_main] extern "C" { #[no_mangle] fn gotoxy(x: u8, y: u8); #[no_mangle] fn cprintf(fmt: *const u8, ...); } #[start] fn start(_argc: isize, _argv: *const *const u8) -> isize { cprintf(b"hello\0" as *const u8); loop {} } |
… なんと、以下のようにリンク時にエラーになってしまいました。
|
1 2 3 4 5 6 7 8 9 |
... (中略) ... output/main.c(252): Warning: '__mrustc_op_and_not32' is defined but never used ld65: Warning: /workspace/cc65/cc65-2.19/cfg/nes.cfg(15): Segment 'CODE' overflows memory area 'ROM0' by 356168 bytes ld65: Error: Cannot generate most of the files due to memory area overflow C Compiler failed to execute - error code 256 Process exited with non-zero exit status 1 FAILING COMMAND: /workspace/mrustc-master/bin/mrustc /workspace/rust-hello-nes/main/src/main.rs -o output/main -C emit-depfile=output/main.d --cfg debug_assertions -O -L output -L output/host --crate-name main --crate-type bin --crate-tag 0_1_0 --target ./target.toml --extern core=output/libcore.rlib Env: OUT_DIR=/workspace/rust-hello-nes/output/build_main-0_1_0 CARGO_MANIFEST_DIR=/workspace/rust-hello-nes/main CARGO_PKG_NAME=main CARGO_PKG_VERSION=0.1.0 CARGO_PKG_VERSION_MAJOR=0 CARGO_PKG_VERSION_MINOR=1 CARGO_PKG_VERSION_PATCH=0 (100.0% 0r,0w,0b,2c/2t): |
曰く、プログラムサイズが ROM 上の領域を 347 KB ほど超過してしまったとのこと。
手元の nes.cfg によれば、0x7FFA = 32762 バイト、つまり約 32 KB しか存在しません。
ゴールが遠ざかっていく雰囲気を感じました。あまりにもあんまりで笑ってしまいました。
ld65 (リンカ) を改造して不要なシンボルを落とせるようにする (失敗)
なぜこんなにサイズが大きいのか、原因は明らかでした。core crate はこの時点で一つのオブジェクトファイルである libcore.rlib.o になっているのですが、ここには core にあるすべての実装が入っているからです。
そして ld65 にはリンク時最適化のようなものがないので、使いもしない関数を含めてすべてバンドルしようとしているようです。そうなると ROM には到底収まらないということですね。
しかし、最適化がないのであれば、最適化を作れば良いのではないでしょうか。
od65 によれば、圧倒的に大きいのは確かに CODE セグメントです。
となれば、スタートアップルーチンから exports の依存関係ツリーを作り、参照されていないものを削除すれば解決できそうな気もします。リンカの魔改造の始まりです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
$ od65 --dump-segments ./output/libcore.rlib.o Segments: Count: 6 Index: 0 Name: "CODE" Flags: 0 Size: 416728 Alignment: 1 Address size: 0x02 (absolute) Fragment count: 280296 Index: 1 Name: "RODATA" Flags: 0 Size: 12346 Alignment: 1 Address size: 0x02 (absolute) Fragment count: 8758 Index: 2 Name: "BSS" Flags: 0 Size: 51 Alignment: 1 Address size: 0x02 (absolute) Fragment count: 51 Index: 3 Name: "DATA" Flags: 0 Size: 3308 Alignment: 1 Address size: 0x02 (absolute) Fragment count: 2104 Index: 4 Name: "ZEROPAGE" Flags: 0 Size: 0 Alignment: 1 Address size: 0x01 (zeropage) Fragment count: 0 Index: 5 Name: "NULL" Flags: 0 Size: 0 Alignment: 1 Address size: 0x02 (absolute) Fragment count: 0 |
今まで特にアセンブリを直接触って何かをしたことはありません。
最初は削除条件を厳しくしすぎてしまい何もしない ROM ができあがってしまいました。
しかし、だからといってもう少し保守的にやると、結局 ROM サイズが 150KB 程度にしか小さくできませんでした。これでは結局オーバーしてしまうことに変わりありません。
デバッグ出力を見る限りまだまだ不要なシンボルを詰め込んでいそうなので、多分最初のスタートアップルーチンとしてマークしている範囲が大きすぎるとか、何か間違ってるんだと思います。
と、いろいろ試行錯誤していたのですが、次第に export 境界の判別という問題がかなり難しいことに気づき始めました。
C や Rust 由来の普通の関数はまだよいのです。
というのも、コンパイラがきちんと .proc – .endproc というもので関数を囲んで生成してくれるからです。そうしておけばアセンブラが .o ファイルにそのまとまりのサイズを一緒に書き込んでくれます。
そのため、特定の関数が別の何を参照しているのかは、そのサイズの範囲内でどこに jmp しているのかを調べればよく、削除を決めたときもどこから何バイト削除すればいいのか比較的容易でした。
一方、直接アセンブリで書かれている関数 (ラベル) たちは自由奔放です。
処理のど真ん中にラベルがついていて外から横入りするようなこともあるみたいですし、極めつけは自己書き換えコードなる存在です。
最初に jmp $0000 という命令を見たときは首をかしげました。しかし、どうも本当に 0 番地に入っているアドレスに飛びたいわけではないらしいのです。その命令に到達する前に、ちょうどそのオペランドにあたるコード領域のメモリを上書きして使うらしいです。
つまり、 jmp 命令に到達する前に store 系の命令をつかい、 $0000 というオペランドそれ自体を書き換えてしまうということですね。すると CPU が jmp 命令に到達したときには、さも最初から jmp $1234 と書かれていたかのように書き換わっているとのことです。なにそれ怖い。
こうなるとどこからどこまでが一つの「関数」なのか判断するには、究極的に実行をシミュレーションする必要がありそうです。分岐などもあるでしょうからとんでもないことになるでしょうし、そもそもどういうステートでその処理に侵入するかによって莫大なパターンがあります。仮にそれを乗り越えてなんとか範囲が分かったとしても、それを適当な位置にちゃんとリロケーションできるのかも怪しくなってきました。
結局、時間切れもあって、このアプローチは断念することになりました。
既存のリンカの最適化はどうなってるんでしょうか。本当にすごいですね。
諦めて必要な部分だけ core を作る
リンク時最適化が無理なのであれば、発想を転換するしかありません。
つまり私が最適化をします。必要な実装だけを含む最小限の libcore を書いてリンクすれば最低限動かすことはできるはずです。
言語機能に必要な marker trait (Sized, Copy たち) などを含めると必要な実装は思ったより多いのですが、例えば四則演算は u8 だけに定義するなどの涙ぐましい努力によって、とにかく最小構成を目指しました。
そして再度コンパイルしてみると…
|
1 2 3 4 |
$ make ... 中略 ... Completed welcome v0.1.0 [bin welcome] (100.0% 0r,0w,0b,3c/3t): |
やっと、コンパイルが通りました。長かったあ…
ファイルが小さくなったので cc65 は無改造でいけるようになるという副次的効果もあります。
遠回りをしましたが、それにしても core crate そのものを書くなんていう経験はなかなかできないので、結構楽しかったです。
Rust 使いなら、プリミティブ型に impl [T] {} とかしてみたいと一度くらいは思ったことがあると思うのですが、それがまさに叶う瞬間でした。
できたもの
最後にもうちょっとライブラリを整理して見た目を整えて、こんな感じのソースコードが実行できるようになりました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
#![feature(no_core)] #![no_core] #![no_main] #[macro_use] extern crate cc65; use cc65::{cprintf, get_screen_size, goto_xy}; fn print_at_center<const N: usize>(line: u8, s: &[u8; N]) { let (width, _) = get_screen_size(); let padding = (width - N as u8) / 2; goto_xy(padding, line); cprintf!(b"%s\0", s); } #[start] fn start(_argc: isize, _argv: *const *const u8) -> isize { print_at_center(5, b"HELLO NES FROM RUST!\0"); print_at_center(15, b"ADVENT CALENDAR 2025\0"); print_at_center(20, b"STATIOLAKE\0"); loop {} } |

これだけ頑張った割に、C を不便にラップしただけで、Rust っぽいことはほとんどできないのは悲しいようにも思えますが、実際は、(自力では) Rust だけを書けばファミコンを動かせるようにできた、という喜びが大きいです。
ちなみに fizzbuzz も動きます。ちょっとは Rust っぽさあるかな…。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#![feature(no_core)] #![no_core] #![no_main] #[macro_use] extern crate cc65; use cc65::{cprintf, get_screen_size, goto_xy}; fn fizz_buzz(n: u8) { let mut i = 1; while i <= n { goto_xy(0, i - 1); match (i % 3, i % 5) { (0, 0) => cprintf!(b"FizzBuzz\0"), (0, _) => cprintf!(b"Fizz\0"), (_, 0) => cprintf!(b"Buzz\0"), _ => cprintf!(b"%d\0", i), } i += 1; } } #[start] fn start(_argc: isize, _argv: *const *const u8) -> isize { let (_, height) = get_screen_size(); fizz_buzz(height); loop {} } |

なんで for i in 1..=n じゃないのか、ですか?core に Iterator を実装しなかったからです…。cprintf! マクロもフォーマット指定が自前なのは片手落ちですが、format_args! をサポートするのはコード長的に相当大変そうだったので見送りました。
とはいえその辺も、頑張れば必要に応じて実装することも可能だと思います。
長かったですが、ここまでなんとかたどり着けました。
お付き合いいただきありがとうございました。
おわりに
相当難しい遊びでしたが、自分が持つ技術セットで考えれば一番楽しいルートを選べたとも思います。最初はずるいな〜と自分でも思っていたのですが、十分に学びのあるルートでした。
目論見通り、C 言語を中間言語として挟んだことで、何が起きているかがわかりやすく、自分であがける範囲が大きかったのも良かったです。
この方針で大きなものを作るのは難しいでしょうが、もうちょっとくらいは作り込めそうではあります。明らかにオーバーヘッドは大きいのでヌルヌル動くボリューミーなゲームは作れないと思いますが、リアルタイム性が低めな、それこそ迷路みたいなものならできても良さそうだなと。いずれ挑戦するかもしれません。
また今回、core crate のサブセットを再実装することで Rust で当たり前にできていることがどのような仕組みで解決されているのかの一端を体感しました。たとえば &str -> *const u8 のようなデリファレンスすらできません。 Unsize とか CoerceUnsize とかいう、普段なら目にもしないようなトレイトが必要なんだそうです。両手両足をもがれたようなプログラミングでしたが、それはそれで新鮮で面白かったです。
40 年以上前のハードウェアと現代の言語を無理やり繋げるという、誰得なチャレンジでしたが、ここまでお読みいただきありがとうございました!
明日の記事は、Rust Advent Calendar はまだ未定のようですが、ニフティグループ Advent Calendar は @su6y さんの「AWS IAM Identity Center経由でKiroを使ってみる」です!お楽しみに。
※「ファミコン」「ファミリーコンピュータ」は任天堂株式会社の登録商標です。
※本記事の内容は個人の技術検証に基づくものであり、任天堂株式会社とは一切関係ありません。


