🎮

スーパーファミコンで mruby/c を動かす

2024/01/19に公開

はじめに

mruby/c という軽量な Ruby 処理系をスーパーファミコンに移植し、標準出力に文字列を出力する簡単な Ruby コードをエミュレータ上で動かしたので、その移植作業を記事にまとめました。
この記事で動かしたコードは次のリポジトリにあります。
https://github.com/gedorinku/snes-ruby/tree/wdc-tools

この移植作業を行うという発想は、2年前の RubyKaigi 2022 で行われた mruby/c を Mega Drive 上で動かすという Yuji Yokoo さんによる発表(https://rubykaigi.org/2022/presentations/yujiyokoo.html)がベースとなっています。
元々スーパーファミコン上で動くコードを趣味で書いたことはあったのですが、最近スーパーファミコンで使われている 65C816 という CPU 向けの(まともに動く) C コンパイラが存在することを知ったので、今回移植作業に取り組んでみました。

C コンパイラ/リンカ

スーパーファミコンは 65C816 という CPU を搭載しており、65C816 向け C コンパイラ/リンカはいくつか存在します。

  • WDCTools
    • 65C816 を開発した The Western Design Center, Inc. が配布しているツール群に 65C816 向け C コンパイラの WDC816CC とリンカの WDCLN が含まれています
    • ツールに関しては Windows 向けのバイナリしか配布されてません
  • PVSnesLib
    • コンパイラ、リンカなどのツールやスーパーファミコンの I/O をラップしたライブラリが含まれています
    • コンパイラは Tiny C Compiler を fork して 65C816 に対応させたものです

この記事では WDCTools を使います。
PVSnesLib はとても便利そうですが、mruby/c のコンパイルを試したところ 32bit ポインターの演算について正しいネイティブコードが出力されないなど複数のバグを踏んでしまったため、PVSnesLib を使うのは諦めました。

また、WDCTools を使ってスーパーファミコン用の ROM をビルドするためのテンプレートを公開している ARM9/snes-c-template という GitHub リポジトリが存在します。今回の移植作業では crt0(main 関数の前に実行されるスタートアップルーチン) などはこれをベースにしつつ、mruby/c が動くようにカスタムしていきます。

コンパイル

まずは正しく mruby/c のランタイムをコンパイルするために行った作業をまとめます。

24bit アドレスを使う

65C816 のアドレス空間は 24bit ありますが、WDC816CC に渡すオプションによっては C のポインターなどが far キーワードを使って指定しない限り 16bit になり、far キーワードを適切に使っていないコードは動かなくなります。
WDC816CC に -ML オプションを渡すと、ポインターや関数呼び出しで常に 24bit アドレスが使われるようになります(実際にはポインターのサイズは 32bit になります)。

C89 への変換

WDC816CC は C99 より新しい C の機能を実装していないため、そのままでは mruby/c のランタイムをコンパイルできません。

今回は c99-to-c89 というツールによる自動変換と手動での変換を組み合わせてコンパイルできる状態にしました。
特に複合リテラルや匿名共用体は WDC816CC が対応してないかつ c99-to-c89 による自動変換ができなかったので手動でコードを修正しています。

スケジューラーの削除

複数プログラムを同時実行するためのスケジューラーの実装が mruby/c の src/rrt0.c にありますが、今回使うことがないかつ後述の実装が必要な Hardware Abstraction Layer が増えてしまうのでコードごと削除します。

Hardware Abstraction Layer

mruby/c をスーパーファミコンに移植する場合、ハードウェアを隠蔽する Hardware Abstraction Layer(HAL) を実装する必要があります。
とはいえ実装する必要があるのは hal_write という関数のみです。これはファイルディスクリプタを指定してバイト列を書き込む関数で、Ruby の puts などを呼んだときに使われるようです。return 0 する実装だけでも動きますが、今回は以下のように固定のメモリ領域に書き込むようにしてエミュレータ上のでデバッグなどに使えるようにしました。

#include <string.h>
#include "hal.h"

#define HAL_BUF_SIZE (1024)
static char hal_write_buf[HAL_BUF_SIZE];
static int hal_write_buf_pos = 0;

int hal_write(int fd, const void *buf, int nbytes) {
  int l = 0;
  while (l < nbytes) {
    int size = HAL_BUF_SIZE - hal_write_buf_pos;
    if (nbytes - l < size) {
      size = nbytes - l;
    }

    memcpy(hal_write_buf + hal_write_buf_pos, (const char *)buf + l, size);

    l += size;
    hal_write_buf_pos += size;

    if (HAL_BUF_SIZE <= hal_write_buf_pos) {
      hal_write_buf_pos = 0;
    }
  }

  return 0;
}

また、mruby/c 自体の HAL ではないのですが、以下の関数の実装が WDCTools の標準 C ライブラリに含まれておらずリンク時にエラーとなります。今のところ使う予定はないので適当に空の実装を追加しておきました。write くらいは hal_write と同じ実装をしても良いかもしれません。

#include <stddef.h>

int unlink(const char * path) {}
int close(int f) {}
int isatty(int fd) {}
size_t write(int fs, void * buf, size_t n) {}
long lseek(int f, long o, int p) {}

ヒープ領域の設定

WDCTools 組み込みの malloc 関数などのために、スーパーファミコンの 24bit のアドレス空間のうちどこをヒープ領域とするか設定します。
次のように heap_start heap_end という名前でグローバル変数を定義してそれぞれ始点と終点のアドレスを入れておくと組み込みの malloc 関数などから参照されるようになります。

void * heap_start = (void*)0x7f2000;
void * heap_end = (void*)0x800000;

なお次の節で詳しく書きますが、24bit アドレスの好きな領域がヒープ領域として使えるわけではありません。

WDC816CC のバグの回避

WDC816CC は次のコードに対して誤ったネイティブコードを出力します。

https://github.com/mrubyc/mrubyc/blob/18e8f9df8d15eb0f817e8ee6c9c57f84d3657cc6/src/load.c#L347

mrbc_irep_pool_ptr は mruby/c の
https://github.com/mrubyc/mrubyc/blob/18e8f9df8d15eb0f817e8ee6c9c57f84d3657cc6/src/vm.h#L81-L82
で定義されているマクロで、上記のコードでこのマクロを展開すると

const uint8_t *p = vm->cur_irep->pool + mrbc_irep_tbl_pools(vm->cur_irep)[(n)];

となります。これをコンパイルすると + 演算の結果を変数 p に代入する部分は次のようになります。<L194+p_4 はスタック上に存在する変数 p のアドレスを表しています。

adc	[<R0],Y
sta	<L194+p_4
lda	#$0
sta	<L194+p_4+2

65C816 のアキュムレータのサイズは(最大) 16bit でポインター型のサイズは 32bit です。2回に分けて演算結果をメモリに書き込んでいますが、上位 16bit が常に 0 になってしまいます。
詳しい再現条件まで調査できてませんが、次のように vm->cur_irep->pool を一旦ローカル変数に入れると正しくコンパイルできるようになります。

const uint8_t *pool = vm->cur_irep->pool;
const uint8_t *p = pool + mrbc_irep_tbl_pools(vm->cur_irep)[(n)];

リンク

ここまでにコンパイルしたコードをリンクしてスーパーファミコンの ROM として使えるようにするには、どのアドレスをどのように使うか正しく設定する必要があります。この各領域のことを Section は呼ばれています。
スーパーファミコンのアドレス空間は 24bit あり、RAM だけでなく ROM や画面出力を行う PPU(Picture Processing Unit) との I/O などもこのアドレス空間に割り当てられています。
アドレス空間の割り当ては次の図通りとなっています。横軸がアドレスの上位 8bit(バンクと呼びます)、縦軸が残りの 16bit です。


https://forums.nesdev.org/viewtopic.php?p=235113#p235113

WDCTools で定義されている Section は次の通りです。

  • CODE: 実行可能なプログラムが入る領域
  • KDATA: 定数が入る領域
  • DATA: 初期値を持つ変数が入る領域。C のグローバル変数などが当てはまる。
  • UDATA: 初期値を持たない変数が入る領域。C のグローバル変数などが当てはまる。

なお Section の設定を行う際には特に次の2点に注意する必要があります。

  • リセット時やその他割り込み時に呼ばれるコードは 0x00 バンクに配置しなければならない
  • DATA と UDATA は全て同一バンクに配置しなければならない
    • WDCTools はこれを前提としたネイティブコードを出力します。なお C のポインターを使ったは常に 24bit アドレスでデータを参照するようにすることができるので、ヒープ領域にはこの制約がありません。

今回はリンカに次のようなオプションを渡して Section を指定しました。

-Zcode=8000 -C8000 -K8000 -D7e2000,0000 -U7e8000,0000

-K8000 はそのまま KDATA Section が 0x8000 から始まることを意味します。-D7e2000,0000 は DATA Section が ROM 上の 0x0000 から配置され、ロード時に 0x7e2000 から配置されることを意味しています。-U7e8000,0000 も同じように UDATA Section について設定しています。
-Zcode=8000 -C8000 は CODE Section に関する設定ですが、CODE Section は一つのバンクに収まらなかったため少し特殊な設定になっています。0x8000 から CODE Section が配置されますが、バンク内に収まらなかった場合は次のバンクの -Zcode= で指定したアドレス(この指定だと 0x018000)に配置されます。0x00 バンクから 0x3f バンクの [0x0000, 0x8000) の範囲は RAM やその他 I/O がマッピングされているため、これらの領域を避けて CODE Section を配置する必要があります。

Ruby のコードを動かす

ここまででコンパイルとリンクが動くようになったので、いよいよ Ruby のコードを動かします。
今回は次の Hello world を出力するコードを動かします。なお画面出力に関する実装をまだ行っていないので、Hardware Abstraction Layer で追加した固定のメモリ領域に Hello world が書き込まれていることをエミュレータで確認します。

puts 'Hello world'

まず次のようにして mruby のバイトコードへ変換を行います。

mrbc -s --remove-lv -Bmrbbuf -o src/mrb_bytecode.c src/main.rb

するとバイトコードが入った配列を定義する C ファイルが出力されます。なお WDCTools に付属する stdint.h では uint8_t が宣言されてないため、ファイルの先頭で typedef unsigned char uint8_t などとしておく必要があります。

#include <stdint.h>
static
const uint8_t mrbbuf[] = {
0x52,0x49,0x54,0x45,0x30,0x33,0x30,0x30,0x00,0x00,0x00,0x5c,0x4d,0x41,0x54,0x5a,
0x30,0x30,0x30,0x30,0x49,0x52,0x45,0x50,0x00,0x00,0x00,0x40,0x30,0x33,0x30,0x30,
0x00,0x00,0x00,0x34,0x00,0x01,0x00,0x04,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x0a,
0x51,0x02,0x00,0x2d,0x01,0x00,0x01,0x38,0x01,0x69,0x00,0x01,0x00,0x00,0x0b,0x48,
0x65,0x6c,0x6c,0x6f,0x20,0x77,0x6f,0x72,0x6c,0x64,0x00,0x00,0x01,0x00,0x04,0x70,
0x75,0x74,0x73,0x00,0x45,0x4e,0x44,0x00,0x00,0x00,0x00,0x08,
};

このコードを include して、次のように mrbbuf 配列を mruby/c に渡すとこのバイトコードが実行されます。

#include "mrubyc.h"

#include "mrb_bytecode.c"

int run() {
  mrbc_init_global();
  mrbc_init_class();

  mrbc_vm *vm = mrbc_vm_open(NULL);
  if (vm == NULL) {
    return -1;
  }

  if (mrbc_load_mrb(vm, mrbbuf) != 0) {
    return -1;
  }

  mrbc_vm_begin(vm);
  int ret = mrbc_vm_run(vm);
  mrbc_vm_end(vm);
  mrbc_vm_close(vm);

  return ret;
}

static int ret = 0;

int main(void) {
  ret = run();

  while (1) {}

  return 0;
}

このコードをコンパイルしてエミュレータで動かし、メモリビューアーで Hardware Abstraction Layer で追加した hal_write_buf 変数の領域を確認すると、Hello world が出力されていることが分かります。
エミュレータには https://github.com/SourMesen/Mesen2 を使っています。

おわりに

mruby/c をスーパーファミコンに移植し、標準出力として固定のメモリ領域に文字列を出力する単純な Ruby コードを実行してエミュレータ上で動作を確認しました。
これだけだとスーパーファミコンらしいことが何もできていないため、今後は PPU などの I/O を行う Ruby の C 拡張を実装していきたいと思います。

参考

https://www.westerndesigncenter.com/wdc/documentation/816cc.pdf
https://www.westerndesigncenter.com/wdc/documentation/Assembler_Linker.pdf
https://github.com/mrubyc/mrubyc
https://github.com/yujiyokoo/mega-present
https://github.com/ARM9/snes-c-template
https://qiita.com/HirohitoHigashi/items/049cfe71a582de4fb33d

Discussion