🎅

Nintendo Switch™ ネイティブバイナリへの Go コンパイルを成功させた話

17 min read

本記事は「Go Advent Calender」25 日目の投稿です。 Happy Holidays!

https://qiita.com/advent-calendar/2021/go

EDIT (2022-01-03): There is an English version of this article.

tl;dr

いままでは Go プログラムを Nintendo Switch 上で動かすために WebAssembly に一度変換し、それを C++ に変換してコンパイルするということを行ってきました。今回、 Go の Nintendo Switch 向けネイティブコンパイルに成功し、実際に手元でゲームを動かすことができました。手法として、システムコール呼び出しを C の関数呼び出しに置き換えるように -overlay オプションを指定してビルドしました。また、 -overlay オプションに指定する JSON を生成するパッケージ Hitsumabushi を開発しました。

注意

本記事および関連するオープンソースプロジェクトは、全て公開情報のみに基づいています。文責はすべて筆者である星一にあります。本記事の内容について、任天堂株式会社に問い合わせないでください。

背景

自分は Ebiten という Go の 2D ゲームエンジンを趣味で開発しています。今年 Nintendo Switch 向けポート開発に成功し、実際に「くまのレストラン」の Nintendo Switch 版がリリースされました。

https://ebiten.org

https://store-jp.nintendo.com/list/software/70010000041018.html

ここでの手法は「一旦 WebAssembly (Wasm) にコンパイルし、それを C++ に変換する」というものでした。詳しい説明は GoConference 2021 Autumn での発表を参照してください。 Wasm を経由する方法は、回りくどい方法ではありますが、利点が大きいため採用しました。利点として、不確実性が低く、メンテナンスコストが低く、ポータビリティが高いという点が挙げられます。実際一度作れば Wasm の仕様が安定している以上メンテナンスコストは安く済みます。一方欠点として、パフォーマンスはあまり良くなく、コンパイル時間が長いという点が挙げられます。ただでさえパフォーマンスがネイティブに比べると悪いのにシングルスレッドになってしまうため、 GC の発生によって止まってしまうという問題もありました。

Wasm を経由せずに Go を Nintendo Switch 向けネイティブバイナリにコンパイルするのは、不確実性が高く、茨の道です。 Go は当然公式では Nintendo Switch には対応しておらず、また Nintendo Switch のソースコードやバイナリフォーマットは当然非公開です。なにかしら問題が発生したとしても、何も原因がわからないということも最悪あり得ます。しかし実際に成功するならば、パフォーマンスは最も高く、コンパイル速度も Go のコンパイル速度そのままで爆速になることが期待できました。よって挑戦する価値はあると考え、様々な試行錯誤を一年前から断続的に行っていました。

戦略

戦略としては基本的に、ランタイムや標準ライブラリのシステムコール呼び出しを C 関数呼び出しに置き換えるだけです。システムコール部分が OS 依存になっている部分であり、ここをポータブルなものに置き換えれば、理屈上はどこでも動くわけです。こう書くととても簡単に見えますね。実際にはかなり大変だったんですが。

やることを図にすると次図のようになります。左が Go コンパイラで普通にコンパイルした場合の構造です。システムコールは特定のシステム上でしか動かず、当然 Nintendo Switch では動きません。これを右のように、標準ライブラリなどの C 関数呼び出しに置換してあげればよいわけです。

システムコールを C 関数に置き換える
システムコールを C 関数に置き換える

さらに Go コンパイラが吐くバイナリ形式を Nintendo Switch に合わせるという作業もあります。やることをまとめると次のようになります。

  1. システムコールを置き換えて標準 C 関数や pthread の関数呼び出しにする
  2. Go コンパイラが吐く ELF バイナリを何とかする

システムコール置き換えについては、当然標準 C 関数などと一対一対応するわけではありません。また全部実装してはきりがありません。 Nintendo Switch 実機で動かないものを発見次第 1 つずつ潰していくという方法を取りました。

Go コンパイラが吐くバイナリ形式は当然公式にサポートしているものだけです。例えば Linux をターゲットにすると ELF になります。 ELF は Nintendo Switch のコンパイラが取り扱えるのでしょうか? 結論を言うと、なんとかなりました。この 2. についての詳細は省きます[1]

GOOS=linux GOARCH=arm64 および -buildmode=c-archive を指定して Go コンパイラで .a ファイルを作ります。それをうまいこと Ninitendo Switch のコンパイラで他のオブジェクトファイルやライブラリとリンクすれば出来上がりです。 -buildmode=default ではないのは、 エントリーポイント周りについて Nintendo Switch 側で色々やらなきゃいけないからです。 Nintendo Switch に限らず、エントリーポイントについてはそのプラットフォームに任せる形にしたほうがポータビリティが高いであろう、という判断もあります。

システムコールは基本的に標準ライブラリ、特に runtimesyscall パッケージに定義されています。ではこの内容をどうやって書き換えたのでしょうか。本プロジェクトでは、 -overlay オプションというのを使いました。

Hitsumabushi ― -overlay オプションを使ったランタイムの書き換え

go build-overlay はコンパイル対象となる Go ファイルを書き換えることができるオプションです。基本的にはこれを使ってランタイムの Go を書き換えます。公式ドキュメントによる説明は以下のとおりです。

-overlay file
	read a JSON config file that provides an overlay for build operations.
	The file is a JSON struct with a single field, named 'Replace', that
	maps each disk file path (a string) to its backing file path, so that
	a build will run as if the disk file path exists with the contents
	given by the backing file paths, or as if the disk file path does not
	exist if its backing file path is empty. Support for the -overlay flag
	has some limitations: importantly, cgo files included from outside the
	include path must be in the same directory as the Go package they are
	included from, and overlays will not appear when binaries and tests are
	run through go run and go test respectively.

-overlay に与える JSON は次のようなフォーマットです。

{
  "Replace": {
    "/usr/local/go/src/runtime/os_linux.go": "/home/hajimehoshi/my_os_linux.go"
  }
}

これを与えてビルドすると、 runtimeos_linux.go の内容がまるまる my_os_linux.go に置き換わります。とても便利ですね。

この JSON をいちいち管理するのはポータブルではありません。 Go のインストール場所は環境によってまちまちなので、置き換えたいファイルの場所が環境によって変わってしまいます。また置き換えたい内容も、ファイル単位でまるごと置き換えることは稀で、実際には一部の関数を置き換えたい場合がほとんどです。そのため置き換える内容のファイルを Go のバージョンアップに合わせて追従するのが面倒です。

そこで、今回のプロジェクトのために、 -overlay に与える JSON を自動生成する Package を作りました。 Hitsumabushi (ひつまぶし) です。名前は、 libc を取り扱うので、「ぶし」で終わるのが良かったからです。ちなみに名前の他の候補として「鰹節」もありました。まあそれはどうでもいいですね。

Hitsumabushi は次のような API を持つ非常に単純なパッケージです。

// GenOverlayJSON は指定されたオプションを元に、 -overlay に与えるための JSON の内容を
// 返す。または、エラーが起きた場合はエラーを返す。
//
// オプションとして、コマンド引数の指定、 CPU コア数などの指定がある。
func GenOverlayJSON(options ...Option) ([]byte, error)

Hitsumabushi の実装

Hitsumabushi のために、次のような簡易パッチフォーマットを作りました。

//--from
func getRandomData(r []byte) {
	if startupRandomData != nil {
		n := copy(r, startupRandomData)
		extendRandom(r, n)
		return
	}
	fd := open(&urandom_dev[0], 0 /* O_RDONLY */, 0)
	n := read(fd, unsafe.Pointer(&r[0]), int32(len(r)))
	closefd(fd)
	extendRandom(r, int(n))
}
//--to
// Use getRandomData in os_plan9.go.

//go:nosplit
func getRandomData(r []byte) {
	// inspired by wyrand see hash32.go for detail
	t := nanotime()
	v := getg().m.procid ^ uint64(t)

	for len(r) > 0 {
		v ^= 0xa0761d6478bd642f
		v *= 0xe7037ed1a0b428db
		size := 8
		if len(r) < 8 {
			size = len(r)
		}
		for i := 0; i < size; i++ {
			r[i] = byte(v >> (8 * i))
		}
		r = r[size:]
		v = v>>32 | v<<32
	}
}

//--from 以後と //--to 以後がそれぞれ置換前と置換後を表します。わざわざ独自の簡易パッチフォーマットを作ったのは、普通のパッチフォーマットは人間が手で編集することを前提としていないので取り扱いづらいためです。上の例でいうと、 Linux の getRandomData 実装を Plan 9 のものに置き換えています。 Linux の getRandomData/dev/urandom を利用しますが、当然これはポータブルには使えないからです[2]。このパッチ形式で、置換したい必要最小限の部分だけ管理すればよくなります。もちろん Go のバージョンアップ追従の手間がゼロになったわけではないのですが、かなり楽になるはずです。

Hitsumabushi は、この形式のパッチを使って元のファイルの一部を置き換えたものを一時ディレクトリに配置します。この一時ディレクトリのファイルを JSON の内容 (置き換え内容ファイルのファイル名) として利用します。

ちなみに今回は標準ライブラリやランタイムを書き換えたのであって、 Go コンパイラそのものは書き換え対象ではありません。つまり普通の Go コンパイラをそのまま使っています。

Hitsumabushi が置き換える内容は、標準 C 関数呼び出しや pthread 関数の呼び出しなどの標準的なライブラリのみです。プラットフォーム固有の API は一切取り扱いません[3]。そのため理想的には、本来 Go が対応していないようなあらゆる環境で Go プログラムを動かすことが、Hitsumabushi を利用するとできるようになるはずです

書き換え内容

runtime からの C 関数呼び出し

runtime から C 関数を呼ぶのは一筋縄ではいきません。 Go プログラムは普通 Cgo を使うと C 関数を簡単に呼べるのですが、 runtime からは Cgo は使えません。 Cgo を使うということは runtime/cgo に依存するということであり、 runtime/cgoruntime に依存するので、循環参照になってしまうからです。

結論を言うと、 libcCall という関数を使えば runtime から C 関数を呼ぶことが可能です。実際 GOOS=darwin などの一部の環境ではそのようにして C 関数を呼んでいます。

他に、様々なコンパイラディレクティブを使います。

  • //go:nosplit: スタックのオーバーフローチェックを行わない。
  • //go:cgo_unsafe_args: Go の引数を C の引数として取り扱う。
  • //go:linkname: 他のパッケージで定義されたものをあたかも自分のパッケージ内で定義されたかのように扱う。または逆に、自分のパッケージ内で定義したものをあたかも他のパッケージで定義されたかのように扱う。 export などもガン無視できるので便利。
  • //go:cgo_import_static: C 関数を静的リンクし、そのシンボルの値を Go で取り扱えるようにする。

具体例を見てみます。 runtime から write システムコールを呼ぶために、 Go 側で write1 という関数が定義されていました。

stubs2.go
// Go 1.17.5 における runtime/stubs2.go から抜粋

//go:noescape
func write1(fd uintptr, p unsafe.Pointer, n int32) int32
sys_linux_arm64.s
// Go 1.17.5 における runtime/sys_linux_arm64.s から抜粋

TEXT runtime·write1(SB),NOSPLIT|NOFRAME,$0-28
	MOVD	fd+0(FP), R0
	MOVD	p+8(FP), R1
	MOVW	n+16(FP), R2
	MOVD	$SYS_write, R8
	SVC
	MOVW	R0, ret+24(FP)
	RET

64bit ARM の場合は SVC を使ってシステムコール呼び出しをしていることがわかります。

これを、 libcCall やコンパイラディレクティブを使って、 C 関数呼び出しに置き換えると、次のようになります。

stubs2.go
// Hitsumabushi による置換後の runtime/stubs2.go から抜粋

//go:nosplit
//go:cgo_unsafe_args
func write1(fd uintptr, p unsafe.Pointer, n int32) int32 {
	return libcCall(unsafe.Pointer(abi.FuncPCABI0(write1_trampoline)), unsafe.Pointer(&fd))
}
func write1_trampoline(fd uintptr, p unsafe.Pointer, n int32) int32
os_linux.go
// Hitsumabushi による置換後の runtime/os_linux.go から抜粋

//go:linkname c_write1 c_write1
//go:cgo_import_static c_write1
var c_write1 byte
sys_linux_arm64.s
// Hitsumabushi による置換後の runtime/sys_linux_arm64.s から抜粋

TEXT runtime·write1_trampoline(SB),NOSPLIT,$0-28
	MOVD	8(R0), R1	// p
	MOVW	16(R0), R2	// n
	MOVD	0(R0), R0	// fd
	BL	c_write1(SB)
	RET
gcc_linux_arm64.c
// Hitsumabushi による置換後の runtime/cgo/gcc_linux_arm64.c から抜粋

int32_t c_write1(uintptr_t fd, void *p, int32_t n) {
  static pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
  int32_t ret = 0;
  pthread_mutex_lock(&m);
  switch (fd) {
  case 1:
    ret = fwrite(p, 1, n, stdout);
    fflush(stdout);
    break;
  case 2:
    ret = fwrite(p, 1, n, stderr);
    fflush(stderr);
    break;
  default:
    fprintf(stderr, "syscall write(%lu, %p, %d) is not implemented\n", fd, p, n);
    break;
  }
  pthread_mutex_unlock(&m);
  return ret;
}

ちなみに、 libcCallGOOS=linux では未定義になってしまうので、適当に runtime/sys_libc.go//go:build を書き換えて定義してやるようにします。

なお、 libcCall を経由しないでアセンブラを使って C 関数を無理やり呼ぼうとすると、現在の Goroutine のスタックの上にそのまま C のスタックが乗っかる形になります。そのため、摩訶不思議なバグが生まれることがあります。 libcCall を経由せずに C 関数を呼ぶのはやめたほうが良いでしょう。

シグナルの無視

シグナル周りは一切無視します。たとえば runtimesigaltstacksigprocmask は空実装になっています。シグナルを取り扱う C 標準関数はあることはあるのですが、環境によっては実装されていません。

副作用として、 nil ポインタへのアクセスがそのまま SEGV となり、 recover が不可能になってしまいました。 panic のメッセージも出ずに即死します。若干不便ですが、本番環境でそういうエラーが起きないように頑張るしかないでしょう。

疑似ファイルシステムの実装

Go プログラムが何もしなくとも、ランタイムからファイルにアクセスすることがあります。 Linux においては次のファイルがランタイムから自動的に読まれるようです。

  • /proc/self/auxv (ページサイズなどの情報)
  • /sys/kernel/mm/transparent_hugepage/hpage_pmd_size (Huge Page Size)

いずれも適当に内容を拵えて、固定値を返すようにしました。たとえば Huge Page Size については 0 を返しても動くのでそうしています。実装については Hitsumabushi の c_open を参照してください。

ファイル書き込みについては、標準出力と標準エラー出力にのみ対応しました。それぞれ fprintf してあげるだけです。ちなみにこれを実装しないと println すら動きません。それ以外のファイル読み書きは一旦対応しないことにしました。実装については Hitsumabushi の c_write1 を参照してください。

疑似メモリシステムの実装

Go のヒープメモリは、 Linux においては mmap システムコール呼び出しが最下層です。そこで確保された仮想メモリ上でやりくりしています。不要な領域は munmap を呼びます。

ヒープメモリ領域の状態は 4 種類あり、次の図のように状態が遷移します。実際に使用可能なメモリになるのは Ready 状態の時です。

メモリの状態遷移図
メモリの状態遷移図

Go は、仮想メモリ上のアドレスを指定して、そこで確保されたメモリ領域を基本的に用います。しかしながら、特定のアドレスを指定してメモリを確保する方法は標準 C 関数にはありません。困りました。

Go がサポートしている環境で、指定したアドレスの仮想メモリを確保することが不可能な環境が実はあります。 Plan 9 と Wasm がそうです。 Hitsumabushi では、それらの実装を参考にした「手抜き」実装を行いました。最も単純な実装である Wasm 版を基本的に参考にしました。詳細は省きますが、以下のとおりです。実際のソースはHitsumabushi の mem_linux.goを参照してください。

  • sysAlloc: sysReservesysMap を呼ぶ。
  • sysMap: ヒープメモリ合計値記録を増やす。
  • sysFree: ヒープメモリ合計値記録を減らす。
  • sysReserve: calloc を呼ぶ。
  • それ以外の関数は何もしない。

こう見て分かる通り、 calloc はありますが free はありません。 calloc で確保された領域の一部分だけを free するようなことはできないからです。というわけで仮想メモリ使用量は単調増加するということになります。もともと Ebiten を Nintendo Switch で動かす方法が Wasm 経由で C++ に変換していましたが、そこでもメモリ使用量は単調増加していました[4]。状況が悪化したわけではないので、一旦これで良しとしました。将来的にはなんとかしたいですが…。

疑似 futex の実装

futex はスレッドを眠らせたり起こしたりする実装の最下層部分です。当然標準 C 関数や pthread の関数から直接呼ぶことはできません。よって、 futex の挙動を模倣するようなコードをなんとかして pthread で書く必要があります。本来は pthread 自身が futex を使って定義されるはずなので、それの逆のことをしなければなりません。

Go が使う futex には 2 種類の使い方があります。

  • futexsleep(uint32 *addr, uint32 val): addr の値が val のときにスリープする。
  • futexwake(uint32 *addr): addr によってスリープしているスレッドを起こす。

Hitsumabushi では、次のような簡易的な実装を行いました。実際のソースはHitsumabushi の pseudo_futexを参照してください。

// 擬似コード
pseudo_futex(void* uaddr, int32_t val) {
  static pthread_cond_t cond; // 条件変数
  
  switch (mode) {
  case sleep:
    if (*uaddr == val) {
      cond_wait(&cond); // スリープする
    }
    break;
  case wake:
    cond_broadcast(&cond); // 条件変数 cond で寝ているスレッドをすべて起こす
    break;
  }
}

wake のときに、必要なスレッドだけを起こすのではなく、すべてのスレッドを起こしています。必要なスレッドのみを起こそうとすると、そのために条件変数を個別に管理する必要が生じ、とても面倒だからです。起きる必要がないのに起きることを Spurious wakeup といいます。これは Go のソースコードにも明記されているとおり、問題のない挙動です。ただし動作効率は落ちるかもしれません。

CPU コア数の調整

CPU コア数は sched_getaffinity システムコールの結果で決まります。これに対応する標準 C 関数は存在しないため、 Hitsumabushi の GenOverlayJSON のオプションとしてコア数を指定するようにし、それに合わせて疑似 sched_getaffinity の結果を変えるようにしました。具体的なソースは Hitsumabushi の c_sched_getaffinity を参照してください。

環境によっては、 CPU コア数を 2 以上に指定すると、なぜかフリーズしてしまうという問題がありました。これはスレッドが使用するコアの指定が、デフォルトだと 1 コアしかない場合があるためです。そのため、 pthread_setaffinity_np を明示的に呼ぶ必要があります。 Hitsumabushi では、 pthread_create 直後に pthread_setaffinity_np を呼ぶように改造しました。 具体的なソースは Hitsumabushi の overlay.go を参照してください。なおこの解決法を発見するのにかなり苦労しました。直ってよかったですね。

エントリーポイント

Hitsumabushi は -buildmode=c-archive とともに使用することを想定しています。これは C のライブラリとなり、 Go の関数は main ですら呼ばれません。 Go のmain を呼びたい場合は、 C 関数を定義し、その中で main を明示的に呼んでやります。 main 関数を呼ぶという通常ではありえないコードですが、 c-archive においては実用性のあるコードだと思われます。

main.go
package main

import "C"

//export GoMain
func GoMain() {
	main()
}
main.c
// C のエントリーポイントで Go のエントリーポイントを呼ぶ。
int main() {
  GoMain();
  return 0;
}

結果

  • ゲーム、具体的には「いのべーしょん」、が Nintendo Switch 実機で動きました。コントローラやタッチ入力、及び音楽も完璧に動きます。いのべーしょんは Ebiten の機能をそれなりに一通り使っているので、他の Ebiten のゲームもほぼ間違いなく動くでしょう。
  • コンパイルがめちゃくちゃ早くなりました。いままで C++ フルビルドに 5 分から 10 分は要していたのですが、およそ 10 秒以内にビルドが終わります。これはすごい。
  • GC による停止はなくなったように見えます。
  • Go の新バージョンがリリースされるたびに Hitsumabushi を更新する手間が生じるようになりました。まあしょうがないですね。過去の経験から察するに、ファイルの内容はそんなに大きく変動しないと期待したいです。

所見

本題とあんまり関係ないですが、 Go のランタイム実装はモダン OS 周りの知見の蓄積になっており、大変勉強になります。コンピュータサイエンスのかなりの部分が、このランタイム実装から学べるのではないかと思われます。目的なく読むのはとてもしんどいので、何しかしらの改造目的を持って読んでみると良いのではないでしょうか。

本プロジェクトがほぼ成功したことにより、 Go Conference で発表した手法は過去のものになりつつあります。少しさみしいですが仕方がないですね。

今後の予定

Nintendo Switch 向けゲームをちゃんとリリースできる段階まで持っていきます。最初に述べたとおり、このプロジェクトは不確実性が高いプロジェクトです。実際にゲームをリリースするまでどんな問題が発生するかわからず、油断なりません。最悪また go2cpp を使えばリリースは続行できるという安心感はあるものの、せっかくここまで来たんだからちゃんと Hitsumabushi でゲームをリリースして実績を積みたいですね。

謝辞

PySpa コミュニティの皆様には技術的側面でお世話になりました。また、 Ebiten を Nintendo Switch で実際に使ってくださっている Odencat 株式会社の Daigo 氏にもお世話になりました。この場を借りて感謝申し上げます。

それでは良いお年を。

脚注
  1. 大人の事情です。 ↩︎

  2. /dev/urandom ファイルを偽造するという方法も取れましたが、今回はやりませんでした。プラットフォーム固有の API を使う他にいい方法がなかったためです。 ↩︎

  3. ポータビリティのためというのが主な理由ですが、プラットフォーム固有の API を使ってしまうとオープンソースにできなくなる、というシビアな事情もあります。 ↩︎

  4. 正確に言うと、 2G ほどのメモリを最初に確保して、それをそのまま使っていました。 ↩︎

この記事に贈られたバッジ

Discussion

ログインするとコメントできます