为 Nintendo Switch™ 编译 Go 程序

文摘   2024-11-21 08:46   上海  

tl;dr

之前,我们将Go程序编译为WebAssembly,然后转换为C++文件以在Nintendo Switch上运行。现在,我已成功将Go程序编译为Nintendo Switch的原生二进制文件,并在那里运行游戏。我使用-overlay选项用C函数调用替换系统调用。此外,我开发了一个新的包 Hitsumabushi[2] 来生成此内容的JSON。

注意

本文及文中的开源项目仅基于公开可用的信息。Hajime对本文内容负责。请勿向任天堂询问本文。

背景

我一直在业余时间开发一个名为Ebiten的2D游戏引擎。我已成功将其移植到Nintendo Switch,并且 Nintendo Switch版本的"熊的餐厅"[3] 已于2021年发布。

版权所有 2021 Odencat Inc.

之前的方法是将Go程序编译为WebAssembly(Wasm)二进制文件,然后转换为C++文件。请参见 2021年秋季Go大会演示幻灯片[4] 了解更多详情。优点是不确定性低、维护成本低且可移植性高。一旦开发了工具,由于Wasm规范稳定,其维护成本相当小。另一方面,缺点是性能差且编译时间长。不仅性能不如原生,而且GC还会因单线程而暂停游戏。

不使用Wasm将Go程序编译为Nintendo Switch的原生二进制文件是相当不确定且充满挑战的。当然,Go官方并不支持Nintendo Switch。而且,Nintendo Switch的源代码和二进制格式并不开放。即使遇到问题,也可能找不到任何线索来帮助解决。然而,如果我知道我将会成功,性能将比以前更好,编译速度将与Go一样快。所以我认为值得一试,并断断续续地进行了一年的实验。

策略

基本策略是在运行时和标准库中用C函数调用替换系统调用。系统调用部分是特定于操作系统的,如果我用可移植的东西替换它,Go理论上应该可以在任何地方工作。听起来似乎很简单,对吧?嗯,实际上比我预期的困难得多...

下面的图形描述了我必须做的事情。左侧是标准Go编译的结构概述。系统调用在特定系统上工作,当然,这在Nintendo Switch上不起作用。所以我必须用标准C函数调用(如右侧)替换它们。

用C函数调用替换系统调用

还有另一个需要调整的项目,即调整Go编译器生成的二进制格式以适应Nintendo Switch。总之,行动项目如下:

  1. 用标准C函数和/或pthread函数调用替换系统调用
  2. 调整Go编译器生成的ELF格式

对于替换系统调用,系统调用当然不能一一对应C函数。而且,要实现的系统调用太多了。所以,我通过找出在实际Nintendo Switch设备上无法工作的系统调用,逐一替换它们。

Go编译器只能生成Go编译器官方支持的格式。例如,当目标是Linux时,格式是ELF。Nintendo Switch能支持ELF吗?长话短说,是的,我设法做到了。关于第2点的细节我不在此描述*。

我必须做的是使用 GOOS=linux GOARCH=arm64-buildmode=c-archive 通过 Go 编译器创建一个 .a 文件,然后通过 Nintendo Switch 编译器将其与其他目标文件和库链接。我不使用 -buildmode=default 的原因是我必须在入口点周围做一些事情。在我看来,通常依赖平台的入口点更具可移植性。

系统调用基本上在标准库中定义,特别是 runtimesyscall 包。那么我是如何重写它们的呢?在这个项目中,我采用了 -overlay 选项。

Hitsumabushi - 使用 -overlay 选项重写运行时

go build-overlay 是一个覆盖要编译的 Go 文件的选项。我用这个选项覆盖了运行时中的 Go 文件。这是 官方文档的解释[5]

-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 的格式:

{
  "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 (ひつまぶし)[1] *。我采用这个名字是因为我想要一个以 'bushi' 结尾的名字,作为对 libc(日语发音为 ree-boo-shee (りぶしー))的一种戏仿,因为这是 Hitsumabushi 处理的主要事情之一。我还考虑过另一个候选名称 Katsuobushi(かつおぶし)*,但我不会详细说明...

Hitsumabushi 是一个非常简单的包,定义了这样的 API:

// GenOverlayJSON generates JSON content that can be passed
// to -overlay based on the given options, or returns an error
// when an error occurs.
//
// There are some options like specifying command arguments
// and specifying the number of 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,这是不可移植的*。这种补丁格式节省了管理我想替换的差异的一些工作。当然,即使使用这种方式,跟上 Go 版本更新的成本也不会变为零,但它应该会有很大帮助。

Hitsumabushi 使用这种格式创建修改后的文件,并将它们放在一个临时目录中。它使用这些文件作为 JSON 的内容(替换源文件名)。

请注意,Hitsumabushi 重写标准库和运行时,Go 编译器不是要重写的目标。换句话说,使用常规的 Go 编译器。

Hitsumabushi 的替换仅限于标准 C 函数调用和 pthread 函数调用。它从不处理平台特定的 API*。所以,理想情况下,Hitsumabushi 应该使 Go 程序能够在任何平台上运行,无论 Go 编译器是否原本支持它

替换

runtime 调用 C 函数

runtime 调用 C 函数并不是一件容易的事。在通常的 Go 程序中,你可以使用 Cgo 轻松地调用 C 函数。然而,runtime 不能使用 Cgo。使用 Cgo 意味着依赖 runtime/cgo,而 runtime/cgo 依赖于 runtime,所以这将是一个循环依赖。

直接说明,libcCall 使得可以从 runtime 调用 C 函数。一些环境如 GOOS=darwin 已经这样做了。

此外,需要 各种编译器指令[6]

  • //go:nosplit:跳过栈溢出。
  • //go:cgo_unsafe_args:将 Go 参数视为 C 参数。
  • //go:linkname:将另一个包中定义的内容视为在本包中定义,或将本包中定义的内容视为在另一个包中定义。它忽略符号是否导出。非常有用!
  • //go:cgo_import_static:静态链接 C 函数,并使其可以在 Go 中使用符号值。

让我们看一个实际示例。要从 runtime 调用 write 系统调用,在 Go 端定义了一个名为 write1 的函数。

// An excerpt from runtime/stubs2.go in Go 1.17.5

//go:noescape
func write1(fd uintptr, p unsafe.Pointer, n int32) int32

// An excerpt from runtime/sys_linux_arm64.s in Go 1.17.5

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 和编译器指令替换它。

// An excerpt from runtime/stubs2.go after Hitsumabushi's replacement

//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

// An excerpt from runtime/os_linux.go after Hitsumabushi's replacement

//go:linkname c_write1 c_write1
//go:cgo_import_static c_write1
var c_write1 byte

// An excerpt from runtime/sys_linux_arm64.s after Hitsumabushi's replacement

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

// An excerpt from runtime/cgo/gcc_linux_arm64.c after Hitsumabushi's replacement

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 函数,C 栈将位于当前 Goroutine 的栈上。然后,你可能会遇到非常神秘的错误。我不建议在没有 libcCall 的情况下调用 C 函数。

忽略信号

Hitsumabushi 忽略所有信号。例如, runtime[7] 。有一些处理信号的标准 C 函数,但在某些环境中未实现。

作为副作用,访问空指针导致 SEGV,并且无法 recover。程序甚至死亡且没有 panic 消息。这在某种程度上很不方便,但我们必须努力避免在生产环境中出现此问题。

实现伪文件系统

即使 Go 程序什么都不做,运行时也可能访问文件系统。在 Linux 上,运行时似乎从以下文件读取:

  • /proc/self/auxv(关于例如页面大小的信息)
  • /sys/kernel/mm/transparent_hugepage/hpage_pmd_size(巨大页面大小)

我为这两个文件手工制作了一些内容。例如,我使用 0 作为巨大页面大小,因为它可以工作。有关实现,请参见  Hitsumabushi 的[8]

对于写入文件,我只实现了标准输出和标准错误。两者都使用 fprintf。没有它们,甚至 println 都无法工作。我决定暂时不实现读取和写入其他文件。有关实现,请参见  Hitsumabushi 的[9]

实现伪内存系统

在 Go 的堆内存管理中, mmap[10]  系统调用是 Linux 上的底层调用。Go 管理在那里分配的虚拟内存。对于未使用的区域,调用 munmap

堆内存区域有 4 种状态[11] ,这些状态按照下面的图表转换。当状态为"就绪"时,该区域可用。

Go 内存的状态转换图

Go在虚拟内存中指定一个地址,并使用具有该地址的已分配内存区域。然而,没有标准的C函数可以分配具有特定地址的内存。这很不幸。

在某些平台上,分配具有特定地址的内存是不可能的:Plan 9和Wasm。Hitsumabushi提到了它们并实现了一个"曲线救国"的内存系统。它特别参考了 Wasm版本[12] ,这是最简单的实现。我不会在这里描述细节,但基本实现如下列表所示。对于实际源代码,请参见 Hitsumabushi的[13]

  • sysAlloc:调用sysReservesysMap
  • sysMap:增加堆内存的总大小记录。
  • sysFree:减少堆内存的总大小记录。
  • sysReserve:调用calloc
  • 其他函数不执行任何操作。

如你所见,有一个calloc调用但没有free调用。不可能释放由calloc分配的区域的一部分。这意味着内存使用是单调增加的。最初,使Ebiten应用程序在Nintendo Switch上工作的方法是通过Wasm将Go转换为C++,内存使用也是单调增加的*。至少没有让事情变得更糟,所以到目前为止我已经妥协了这个解决方案,但我希望将来能够修复这个问题……

实现伪futex

futex[14] 是处理线程睡眠和唤醒的底层部分。当然,标准C函数和pthread函数不能直接调用futex。因此,我不得不用pthread模仿futex的行为。原本pthread本身是用futex实现的,所以我不得不做相反的事情。

通过Go有 两种方式[15] 使用futex

  • futexsleep(uint32 *addr, uint32 val):当addrval时使线程睡眠。
  • futexwake(uint32 *addr):唤醒使用addr睡眠的线程。

在Hitsumabushi中,我添加了这样一个简单的实现。对于实际源代码,请参见 Hitsumabushi的[16]

// A pseudo code
pseudo_futex(void* uaddr, int32_t val) {
  static pthread_cond_t cond; // A condition variable

  switch (mode) {
  case sleep:
    if (*uaddr == val) {
      cond_wait(&cond); // Sleep
    }
    break;
  case wake:
    cond_broadcast(&cond); // Wake up all the threads sleeping with cond.
    break;
  }
}

当调用wake时,它不仅会唤醒必要的线程,还会唤醒所有线程。如果要仅唤醒必要的线程,你需要为每个uaddr管理多个条件变量,这将很麻烦。这种不必要的唤醒称为 虚假唤醒[17] 。 Go源代码中明确预期了这一点[18] ,所以这不是问题。但性能可能会降低。

调整CPU核心数

CPU核心数由 sched_getaffinity[19] 系统调用的结果决定。没有对应的标准C函数,所以我给Hitsumabushi一个选项,可以在GenOverlayJSON中指定核心数。对于实际源代码,请参见 Hitsumabushi的[20]

在某些环境中,指定2个或更多CPU核心时应用程序会冻结。这是因为默认情况下线程只能使用一个核心。因此,我不得不显式调用  pthread_setaffinity_np[21] 。在Hitsumabushi中,我添加了一个在  pthread_create[22]  之后立即调用 pthread_setaffinity_np 的黑客技巧。实际源代码请参见  Hitsumabushi的[23] 。顺便说一句,找到这个解决方案相当困难。我无法告诉你我最终解决这个难题时有多么高兴。

入口点

假定Hitsumabushi与 -buildmode=c-archive 一起使用。生成的文件是一个C库,甚至 main 都不会被调用。如果要调用 main,必须定义一个C函数并在其中显式调用 main。通常显式调用 main 没有意义,但我认为对于 c-archive 来说是实用的。

package main

import "C"

//export GoMain
func GoMain() {
    main()
}

// Call the entry point in Go in the entry point in C.
int main() {
  GoMain();
  return 0;
}

结果

  • 我成功在实际的Nintendo Switch设备上运行了一个名为" Innovation 2007[24] "的游戏。控制器支持、触摸输入和音频都完美工作。Innovation 2007使用了Ebiten的大部分功能,所以我相信其他游戏也可以同样工作。
  • 编译速度变得更快。在这个解决方案之前,完整构建一个C++项目需要5到10分钟,现在只需不到10秒。这太棒了!
  • GC暂停似乎已经消失。
  • 现在我必须在每次Go发布新版本时更新。对我来说这是可以接受的妥协。根据我过去的实验,我不希望会有任何重大变化。

备注

作为旁注,Go运行时的实现对现代操作系统有丰富的知识积累,非常有见地。我认为它可以教给你很多计算机科学知识。话虽如此,如果没有明确目的,阅读起来可能会很吓人,所以我建议带着某种修改项目的想法来阅读。

由于这个项目近乎成功,我在Go Conference上提出的方法现在已经过时。这是不可避免的,但看到辛勤工作变得过时还是让我有点难过。

未来工作

我将继续完善这个项目,以便为Nintendo Switch发布一款正式游戏。正如我最初描述的,这个项目存在很高的不确定性。在游戏发布之前,我无法预料会发生什么样的问题,我必须始终保持高度警惕。然而,即使在最坏的情况下,我知道我们可以在  go2cpp[25]  的帮助下继续发布游戏,这令人宽慰。尽管如此,考虑到我已经付出的所有努力,我真的希望能用Hitsumabushi发布一款游戏并取得实际成果。

致谢

感谢PySpa社区的朋友们提供的所有技术建议。我还要感谢Daigo, Odencat Inc.的总裁[26] ,他友好地在Nintendo Switch上使用Ebiten。非常感谢。

新年快乐!

  • *1 由于复杂的商业原因。
  • *2 Hitsumabushi是 日本食物[27]
  • *3 鲣鱼节是 另一种日本食物[28]
  • *4 还有另一种解决方案,制作一个伪 /dev/urandom 文件,但我没有采用。除了使用平台特定API外,没有其他好办法。
  • *5 主要原因是可移植性,但还有另一个令人信服的原因:如果使用平台特定API,我将无法使其开源。
  • *6 确切地说,首先分配了大约2G内存,并在没有额外分配的情况下使用。

参考链接

  1. 我的日语文章: https://zenn.dev/hajimehoshi/articles/72f027db464280
  2. Hitsumabushi: https://github.com/hajimehoshi/hitsumabushi
  3. Nintendo Switch版本的"熊的餐厅": https://odencat.com/bearsrestaurant/switch/en.html
  4. 2021年秋季Go大会演示幻灯片: https://docs.google.com/presentation/d/e/2PACX-1vTMRSmuWjhpOx3DIgetfi72jcOGvlqPU5z0Nps24YN6dxaBbu4dWm0FXS2f--D4G2b1aAvTmfqNA2IG/pub?start=false&loop=false&delayms=3000
  5. 官方文档的解释: https://pkg.go.dev/cmd/go
  6. 各种编译器指令: https://pkg.go.dev/cmd/compile#hdr-Compiler_Directives
  7. runtime: https://github.com/hajimehoshi/hitsumabushi/blob/033f91b0b848e44349a91ccd28d6436bc22d0c44/1.17/runtime/os_linux.go.patch#L165-L180
  8. Hitsumabushi 的: https://github.com/hajimehoshi/hitsumabushi/blob/033f91b0b848e44349a91ccd28d6436bc22d0c44/1.17/runtime/cgo/gcc_linux_arm64.c.patch#L437-L454
  9. Hitsumabushi 的: https://github.com/hajimehoshi/hitsumabushi/blob/033f91b0b848e44349a91ccd28d6436bc22d0c44/1.17/runtime/cgo/gcc_linux_arm64.c.patch#L480-L499
  10. mmap: https://man7.org/linux/man-pages/man2/mmap.2.html
  11. 4 种状态: https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/runtime/malloc.go;l=349-360
  12. Wasm版本: https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/runtime/mem_js.go
  13. Hitsumabushi的: https://github.com/hajimehoshi/hitsumabushi/blob/033f91b0b848e44349a91ccd28d6436bc22d0c44/1.17/runtime/mem_linux.go
  14. futex: https://man7.org/linux/man-pages/man2/futex.2.html
  15. 两种方式: https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/runtime/os_linux.go;l=17-24
  16. Hitsumabushi的: https://github.com/hajimehoshi/hitsumabushi/blob/033f91b0b848e44349a91ccd28d6436bc22d0c44/1.17/runtime/cgo/gcc_linux_arm64.c.patch#L321-L385
  17. 虚假唤醒: https://en.wikipedia.org/wiki/Spurious_wakeup
  18. Go源代码中明确预期了这一点: https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/runtime/os_linux.go;l=41-42
  19. sched_getaffinity: https://man7.org/linux/man-pages/man2/sched_setaffinity.2.html
  20. Hitsumabushi的: https://github.com/hajimehoshi/hitsumabushi/blob/033f91b0b848e44349a91ccd28d6436bc22d0c44/overlay.go#L177-L208
  21. pthread_setaffinity_np: https://man7.org/linux/man-pages/man3/pthread_setaffinity_np.3.html
  22. pthread_create: https://man7.org/linux/man-pages/man3/pthread_create.3.html
  23. Hitsumabushi的: https://github.com/hajimehoshi/hitsumabushi/blob/033f91b0b848e44349a91ccd28d6436bc22d0c44/overlay.go#L217-L247
  24. Innovation 2007: https://github.com/hajimehoshi/go-inovation
  25. go2cpp: https://github.com/hajimehoshi/go2cpp
  26. Odencat Inc.的总裁: https://odencat.com/
  27. 日本食物: https://en.wikipedia.org/wiki/Unadon#Variations
  28. 另一种日本食物: https://en.wikipedia.org/wiki/Katsuobushi

幻想发生器
图解技术本质
 最新文章