编译Go程序为Nintendo Switch原生二进制文件(2022)
摘要
一种通过将系统调用替换为C函数调用来将Go程序编译为Nintendo Switch原生二进制文件的技术,性能优于之前基于WebAssembly的方法。
<p><a href="https://lobste.rs/s/jgzcye/compiling_go_program_into_native_binary">评论</a></p>
查看缓存全文
缓存时间: 2026/07/07 08:14
# 将 Go 程序编译为 Nintendo Switch™ 的原生二进制文件 - Ebitengine
来源:https://ebitengine.org/en/blog/native_compiling_for_nintendo_switch.html
## 将 Go 程序编译为 Nintendo Switch™ 的原生二进制文件
Hajime Hoshi
2022-01-03
本文是 [日文原文](https://zenn.dev/hajimehoshi/articles/72f027db464280) 的英文翻译。
## 摘要
此前,我们通过将 Go 程序编译为 WebAssembly,再转换为 C++ 文件,使其能在 Nintendo Switch 上运行。现在,我成功将 Go 程序编译为 Nintendo Switch 的原生二进制文件,并且让游戏在其中运行。我利用 `-overlay` 选项将系统调用替换为 C 函数调用。此外,我还开发了一个名为 [Hitsumabushi](https://github.com/hajimehoshi/hitsumabushi) 的新包,用于生成相应的 JSON 配置。
## 注意事项
本文及文中涉及的开源项目仅基于公开信息。Hajime 对本文内容负责。请勿就本文内容向任天堂提问。
## 背景
我业余时间一直在开发名为 Ebiten 的 2D 游戏引擎。我成功将其移植到了 Nintendo Switch,并且 [《小熊的餐厅》](https://odencat.com/bearsrestaurant/switch/en.html) 的 Switch 版本已于 2021 年发布。

版权所有 © 2021 Odencat Inc.
此前的方法是将 Go 程序编译为 WebAssembly (Wasm) 二进制文件,再转换为 C++ 文件。详见 [GoConference 2021 Autumn 的演讲幻灯片](https://docs.google.com/presentation/d/e/2PACX-1vTMRSmuWjhpOx3DIgetfi72jcOGvlqPU5z0Nps24YN6dxaBbu4dWm0FXS2f--D4G2b1aAvTmfqNA2IG/pub?start=false&loop=false&delayms=3000)。
这种方法优点是:不确定性低、维护成本低、可移植性高。一旦开发出工具,由于 Wasm 规范稳定,维护成本很小。缺点是性能差、编译时间长。不仅性能不如原生,而且由于单线程,GC 会导致游戏卡顿。
将 Go 程序编译为 Nintendo Switch 的原生二进制文件(不经过 Wasm)是一条充满不确定性的艰难之路。当然,Go 官方并不支持 Nintendo Switch。而且,Nintendo Switch 的源代码和二进制格式并未公开。即使遇到问题,也可能毫无线索可寻。然而,如果我知道自己能成功,性能将比以往更好,编译速度也会像 Go 本身一样快。所以我认为值得一试,并断断续续地进行了一年实验。
## 策略
基本策略是:将运行时和标准库中的系统调用替换为 C 函数调用。系统调用部分依赖操作系统,如果将其替换为可移植的代码,理论上 Go 应该能在任何平台上运行。听起来很简单,对吧?嗯,实际上比想象中困难得多……
下图描述了我需要做的事情。左侧是标准 Go 编译的结构概览。系统调用只在特定系统上工作,当然在 Nintendo Switch 上不行。所以必须像右侧那样将它们替换为标准 C 函数调用。

另外,还需要调整 Go 编译器生成的二进制格式,使其与 Nintendo Switch 兼容。总结一下,行动项如下:
1. 将系统调用替换为标准 C 函数和/或 pthread 函数调用
2. 调整 Go 编译器生成的 ELF 格式
对于替换系统调用,系统调用与 C 函数并非一一对应,而且需要实现的系统调用数量太多。所以我通过逐个查找哪些系统调用在实际 Nintendo Switch 设备上无法工作,逐一替换。Go 编译器只能生成官方支持的格式。例如,当目标为 Linux 时,格式是 ELF。Nintendo Switch 能支持 ELF 吗?长话短说:是的,我能做到。此处不再详述第 2 点 *1。
我需要做的是:使用 `GOOS=linux GOARCH=arm64` 和 `-buildmode=c-archive` 通过 Go 编译器生成 `.a` 文件,然后通过 Nintendo Switch 编译器将其与其他目标文件及库链接。不使用 `-buildmode=default` 的原因是在入口点附近有一些需要处理的事项。我认为,通常来说,依赖平台来处理入口点更具可移植性。
系统调用基本上定义在标准库中,尤其是 `runtime` 和 `syscall` 包。那么,我是如何重写它们的呢?在这个项目中,我采用了 `-overlay` 选项。
## Hitsumabushi —— 使用 `-overlay` 选项重写运行时
`go build` 的 `-overlay` 选项可以覆盖编译时的 Go 文件。我用这个选项覆盖了运行时中的 Go 文件。以下是[官方文档](https://pkg.go.dev/cmd/go) 的解释:
> `-overlay file` 读取一个 JSON 配置文件,为构建操作提供覆盖。该文件是一个 JSON 结构,包含一个名为 'Replace' 的字段,该字段将每个磁盘文件路径(字符串)映射到其备份文件路径,这样构建时将像磁盘文件路径存在且内容为备份文件路径的内容一样,或者如果备份文件路径为空,则像磁盘文件路径不存在一样。`-overlay` 标志的支持有一些限制:重要的是,从包含路径之外包含的 cgo 文件必须与它们所属的 Go 包位于同一目录中,并且通过 `go run` 和 `go test` 运行二进制文件和测试时,覆盖不会出现。
给 `-overlay` 的格式如下:
```json
{
"Replace": {
"/usr/local/go/src/runtime/os_linux.go": "/home/hajimehoshi/my_os_linux.go"
}
}
```
用这个构建 Go 程序时,`runtime` 包中的 `os_linux.go` 内容会被替换为 `my_os_linux.go` 的内容。非常方便吧?
直接管理这个 JSON 文件并不具备可移植性。Go 的安装位置取决于环境,目标文件的位置也会变化。另外,很少需要替换整个文件的内容,大多数情况下只需替换某些函数。因此,每次 Go 版本更新时更新源文件会很麻烦。
所以我为此项目开发了一个新包来生成 JSON。这就是 [Hitsumabushi (ひつまぶし)](https://github.com/hajimehoshi/hitsumabushi) *2。我选择这个名字是想取一个以 'bushi' 结尾的名字,作为 libc(日文发音 りぶしー)的谐音,因为 Hitsumabushi 主要就是处理 libc 相关的东西。另一个候选是 Katsuobushi (かつおぶし) *3,但这里就不展开了……
Hitsumabushi 是一个非常简单的包,定义了如下 API:
```go
// 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
// 使用 os_plan9.go 中的 getRandomData。
//go:nosplit
func getRandomData(r []byte) {
// 灵感来自 wyrand,详见 hash32.go
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`,这不可移植 *4。
这种补丁格式可以节省管理工作量,以便管理需要替换的差异。当然,即使有它,跟上 Go 版本更新的成本也不会降到零,但应该能起到很大帮助。
Hitsumabushi 使用这种格式创建修改后的文件,并将其放入临时目录。然后使用这些文件作为 JSON 的内容(替换源文件名)。注意,Hitsumabushi 重写了标准库和运行时,而不修改 Go 编译器本身。换句话说,使用的是常规的 Go 编译器。
Hitsumabushi 的替换只涉及标准 C 函数调用和 pthread 函数调用。它不处理平台特定的 API *5。因此,理想情况下,**Hitsumabushi 应该能让 Go 程序在任何平台上运行,无论 Go 编译器最初是否支持该平台**。
## 替换
### 从 `runtime` 调用 C 函数
从 `runtime` 调用 C 函数并非易事。在通常的 Go 程序中,可以使用 Cgo 轻松调用 C 函数。但 `runtime` 不能使用 Cgo。使用 Cgo 意味着依赖 `runtime/cgo`,而 `runtime/cgo` 又依赖 `runtime`,会造成循环依赖。
直接说结论:`libcCall` 使得从 `runtime` 调用 C 函数成为可能。一些环境(如 `GOOS=darwin`)已经这样做了。此外,还需要各种[编译器指令](https://pkg.go.dev/cmd/compile#hdr-Compiler_Directives):
- `//go:nosplit`:跳过栈溢出检查。
- `//go:cgo_unsafe_args`:将 Go 参数视为 C 参数。
- `//go:linkname`:将其他包中定义的内容视为当前包中定义的,或者将当前包中定义的内容视为其他包中定义的。忽略符号是否导出。非常有用!
- `//go:cgo_import_static`:静态链接一个 C 函数,并使其符号值在 Go 中可访问。
来看一个实际例子。要从 `runtime` 调用 `write` 系统调用,Go 端定义了一个名为 `write1` 的函数。
```go
// 摘自 Go 1.17.5 的 runtime/stubs2.go
//go:noescape
func write1(fd uintptr, p unsafe.Pointer, n int32) int32
```
```asm
// 摘自 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
```
在 64 位 ARM 上,使用 `SVC` 指令来调用系统调用。现在用 `libcCall` 和编译器指令将其替换为 C 函数调用:
```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
```
```go
// 摘自 Hitsumabushi 替换后的 runtime/os_linux.go
//go:linkname c_write1 c_write1
//go:cgo_import_static c_write1
var c_write1 byte
```
```asm
// 摘自 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
```
```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;
}
```
另外,`libcCall` 在 `GOOS=linux` 上并未定义。我必须适当重写 `runtime/sys_libc.go` 中的 `//go:build`。如果不通过 `libcCall` 而强行用汇编调用 C 函数,C 栈会位于当前 Goroutine 的栈上,可能导致非常奇怪的错误。我不建议在没有 `libcCall` 的情况下调用 C 函数。
### 忽略信号
Hitsumabushi 忽略了所有信号。例如,`runtime` 中的 `sigaltstack` 和 `sigprocmask` 被置为空实现。有一些处理信号的标准 C 函数,但在某些环境中未实现。副作用是,访问空指针会导致 SEGV,并且无法通过 `recover` 恢复。程序甚至会在没有 panic 信息的情况下崩溃。这在一定程度上不方便,但在生产环境中我们需要努力避免这个问题。
### 实现伪文件系统
即使 Go 程序什么都不做,运行时也可能访问文件系统。在 Linux 上,运行时显然会读取以下文件:
- `/proc/self/auxv`(关于页面大小等信息)
- `/sys/kernel/mm/transparent_hugepage/hpage_pmd_size`(大页大小)
我为两者手工构造了内容。例如,将大页大小设为 0 也能工作。实现细节请参见 Hitsumabushi 中的 `c_open`。对于写文件,我只实现了标准输出和标准错误,两者都使用 `fprintf`。没有它们,连 `println` 都无法工作。我决定暂时不实现其他文件的读写。实现细节请参见 Hitsumabushi 中的 `c_write1`。
### 实现伪内存系统
在 Go 的堆内存管理中,`mmap` 系统调用是 Linux 上的底层机制。Go 管理分配在该处的虚拟内存。对于未使用的区域,会调用 `munmap`。堆内存区域有 4 种状态,状态转换如下图所示。

当状态为 "Ready" 时,该区域可用。Go 会指定一个虚拟内存地址,并使用该地址处的已分配内存区域。然而,没有标准 C 函数能够分配特定地址的内存。不幸的是,有些平台无法分配指定地址的内存:Plan 9 和 Wasm。Hitsumabushi 参考了它们,实现了一个“偷工减料”的内存系统。它特别参考了 Wasm 版本,这是最简单的实现。此处不详细介绍,但基本实现如下所示。实际源码请参见 Hitsumabushi 的 `mem_linux.go`。
相似文章
在浏览器代码运行器中添加Go语言
作者详细介绍了在基于浏览器的代码运行器(dailyprog)中添加Go语言所面临的挑战及解决方案,解释了为什么标准的GOOS=js方法无法在V8隔离环境中工作,以及如何通过使用GOOS=wasip1和最小的WASI主机shim来成功实现。
Show HN: Clx – 通过C++20将Lua编译为本地可执行文件
Clx是一个跨平台的提前编译Lua编译器,通过C++20工具链生成独立的本地可执行文件,提供有竞争力的性能、小巧的二进制文件,并支持Lua 5.5。
优化CPU密集型Go热路径的笔记
本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。
Show HN: Tiny – 一种带有内联Go原生函数的动态解释型语言
Tiny是一种用Go编写的新的动态解释型编程语言,具有字节码虚拟机、JIT编译和内联Go原生函数,以实现高性能。
Gobee:用Go编写eBPF程序,通过clang转译
Gobee是一个将Go的子集转译为BPF C的工具,允许开发者用Go而非C编写eBPF程序。它为用户空间生成类型化的Go绑定,并利用clang的后端进行编译。