本記事は「Go Advent Calender」25 日目の投稿です。 Happy Holidays!
https://qiita.com/advent-calendar/2021/go
EDIT (2022-01-03): There isan English version of this article.
いままでは Go プログラムを Nintendo Switch 上で動かすために WebAssembly に一度変換し、それを C++ に変換してコンパイルするということを行ってきました。今回、 Go の Nintendo Switch 向けネイティブコンパイルに成功し、実際に手元でゲームを動かすことができました。手法として、システムコール呼び出しを C の関数呼び出しに置き換えるように-overlay オプションを指定してビルドしました。また、-overlay オプションに指定する JSON を生成するパッケージHitsumabushi を開発しました。
本記事および関連するオープンソースプロジェクトは、全て公開情報のみに基づいています。文責はすべて筆者である星一にあります。本記事の内容について、任天堂株式会社に問い合わせないでください。
自分はEbiten という Go の 2D ゲームエンジンを趣味で開発しています。今年 Nintendo Switch 向けポート開発に成功し、実際に「くまのレストラン」の Nintendo Switch 版がリリースされました。
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 関数に置き換える
さらに Go コンパイラが吐くバイナリ形式を Nintendo Switch に合わせるという作業もあります。やることをまとめると次のようになります。
システムコール置き換えについては、当然標準 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 に限らず、エントリーポイントについてはそのプラットフォームに任せる形にしたほうがポータビリティが高いであろう、という判断もあります。
システムコールは基本的に標準ライブラリ、特にruntime やsyscall パッケージに定義されています。ではこの内容をどうやって書き換えたのでしょうか。本プロジェクトでは、-overlay オプションというのを使いました。
-overlay オプションを使ったランタイムの書き換えgo build の-overlay はコンパイル対象となる Go ファイルを書き換えることができるオプションです。基本的にはこれを使ってランタイムの Go を書き換えます。公式ドキュメントによる説明は以下のとおりです。
-overlay fileread a JSON config file that provides an overlay for build operations.The file is a JSON struct with a single field, named 'Replace', thatmaps each disk file path (a string) to its backing file path, so thata build will run as if the disk file path exists with the contentsgiven by the backing file paths, or as if the disk file path does notexist if its backing file path is empty. Support for the -overlay flaghas some limitations: importantly, cgo files included from outside theinclude path must be in the same directory as the Go package they areincluded from, and overlays will not appear when binaries and tests arerun through go run and go test respectively.-overlay に与える JSON は次のようなフォーマットです。
{"Replace":{"/usr/local/go/src/runtime/os_linux.go":"/home/hajimehoshi/my_os_linux.go"}}これを与えてビルドすると、runtime のos_linux.go の内容がまるまるmy_os_linux.go に置き換わります。とても便利ですね。
この JSON をいちいち管理するのはポータブルではありません。 Go のインストール場所は環境によってまちまちなので、置き換えたいファイルの場所が環境によって変わってしまいます。また置き換えたい内容も、ファイル単位でまるごと置き換えることは稀で、実際には一部の関数を置き換えたい場合がほとんどです。そのため置き換える内容のファイルを Go のバージョンアップに合わせて追従するのが面倒です。
そこで、今回のプロジェクトのために、-overlay に与える JSON を自動生成する Package を作りました。Hitsumabushi (ひつまぶし) です。名前は、 libc を取り扱うので、「ぶし」で終わるのが良かったからです。ちなみに名前の他の候補として「鰹節」もありました。まあそれはどうでもいいですね。
Hitsumabushi は次のような API を持つ非常に単純なパッケージです。
// GenOverlayJSON は指定されたオプションを元に、 -overlay に与えるための JSON の内容を// 返す。または、エラーが起きた場合はエラーを返す。//// オプションとして、コマンド引数の指定、 CPU コア数などの指定がある。funcGenOverlayJSON(options...Option)([]byte,error)Hitsumabushi のために、次のような簡易パッチフォーマットを作りました。
//--fromfuncgetRandomData(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:nosplitfuncgetRandomData(r[]byte){// inspired by wyrand see hash32.go for detailt:=nanotime()v:=getg().m.procid^uint64(t)forlen(r)>0{v^=0xa0761d6478bd642fv*=0xe7037ed1a0b428dbsize:=8iflen(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/cgo はruntime に依存するので、循環参照になってしまうからです。
結論を言うと、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 という関数が定義されていました。
// Go 1.17.5 における runtime/stubs2.go から抜粋//go:noescapefuncwrite1(fduintptr, p unsafe.Pointer, nint32)int32// Go 1.17.5 における runtime/sys_linux_arm64.s から抜粋TEXT runtime·write1(SB),NOSPLIT|NOFRAME,$0-28MOVDfd+0(FP), R0MOVDp+8(FP), R1MOVWn+16(FP), R2MOVD$SYS_write, R8SVCMOVWR0, ret+24(FP)RET64bit ARM の場合はSVC を使ってシステムコール呼び出しをしていることがわかります。
これを、libcCall やコンパイラディレクティブを使って、 C 関数呼び出しに置き換えると、次のようになります。
// Hitsumabushi による置換後の runtime/stubs2.go から抜粋//go:nosplit//go:cgo_unsafe_argsfuncwrite1(fduintptr, p unsafe.Pointer, nint32)int32{returnlibcCall(unsafe.Pointer(abi.FuncPCABI0(write1_trampoline)), unsafe.Pointer(&fd))}funcwrite1_trampoline(fduintptr, p unsafe.Pointer, nint32)int32// Hitsumabushi による置換後の runtime/os_linux.go から抜粋//go:linkname c_write1 c_write1//go:cgo_import_static c_write1var c_write1byte// Hitsumabushi による置換後の runtime/sys_linux_arm64.s から抜粋TEXT runtime·write1_trampoline(SB),NOSPLIT,$0-28MOVD8(R0), R1// pMOVW16(R0), R2// nMOVD0(R0), R0// fdBLc_write1(SB)RET// Hitsumabushi による置換後の runtime/cgo/gcc_linux_arm64.c から抜粋int32_tc_write1(uintptr_t fd,void*p,int32_t n){staticpthread_mutex_t m= PTHREAD_MUTEX_INITIALIZER;int32_t ret=0;pthread_mutex_lock(&m);switch(fd){case1: ret=fwrite(p,1, n,stdout);fflush(stdout);break;case2: 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;}ちなみに、libcCall はGOOS=linux では未定義になってしまうので、適当にruntime/sys_libc.go の//go:build を書き換えて定義してやるようにします。
なお、libcCall を経由しないでアセンブラを使って C 関数を無理やり呼ぼうとすると、現在の Goroutine のスタックの上にそのまま C のスタックが乗っかる形になります。そのため、摩訶不思議なバグが生まれることがあります。libcCall を経由せずに C 関数を呼ぶのはやめたほうが良いでしょう。
シグナル周りは一切無視します。たとえばruntime のsigaltstack やsigprocmask は空実装になっています。シグナルを取り扱う 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:sysReserve とsysMap を呼ぶ。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){staticpthread_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 コア数は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 においては実用性のあるコードだと思われます。
package mainimport"C"//export GoMainfuncGoMain(){main()}// C のエントリーポイントで Go のエントリーポイントを呼ぶ。intmain(){GoMain();return0;}本題とあんまり関係ないですが、 Go のランタイム実装はモダン OS 周りの知見の蓄積になっており、大変勉強になります。コンピュータサイエンスのかなりの部分が、このランタイム実装から学べるのではないかと思われます。目的なく読むのはとてもしんどいので、何しかしらの改造目的を持って読んでみると良いのではないでしょうか。
本プロジェクトがほぼ成功したことにより、 Go Conference で発表した手法は過去のものになりつつあります。少しさみしいですが仕方がないですね。
Nintendo Switch 向けゲームをちゃんとリリースできる段階まで持っていきます。最初に述べたとおり、このプロジェクトは不確実性が高いプロジェクトです。実際にゲームをリリースするまでどんな問題が発生するかわからず、油断なりません。最悪またgo2cpp を使えばリリースは続行できるという安心感はあるものの、せっかくここまで来たんだからちゃんと Hitsumabushi でゲームをリリースして実績を積みたいですね。
PySpa コミュニティの皆様には技術的側面でお世話になりました。また、 Ebiten を Nintendo Switch で実際に使ってくださっているOdencat 株式会社の Daigo 氏にもお世話になりました。この場を借りて感謝申し上げます。
それでは良いお年を。
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。
