爲 Nintendo Switch™ 編譯 Go 程序

之前,我們將 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] :

讓我們看一個實際示例。要從 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 上,運行時似乎從以下文件讀取:

我爲這兩個文件手工製作了一些內容。例如,我使用 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] 。

如你所見,有一個calloc調用但沒有free調用。不可能釋放由calloc分配的區域的一部分。這意味着內存使用是單調增加的。最初,使 Ebiten 應用程序在 Nintendo Switch 上工作的方法是通過 Wasm 將 Go 轉換爲 C++,內存使用也是單調增加的 *。至少沒有讓事情變得更糟,所以到目前爲止我已經妥協了這個解決方案,但我希望將來能夠修復這個問題……

實現僞futex

futex[14] 是處理線程睡眠和喚醒的底層部分。當然,標準 C 函數和 pthread 函數不能直接調用futex。因此,我不得不用 pthread 模仿futex的行爲。原本 pthread 本身是用futex實現的,所以我不得不做相反的事情。

通過 Go 有 兩種方式 [15] 使用futex

在 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;
}

結果

備註

作爲旁註,Go 運行時的實現對現代操作系統有豐富的知識積累,非常有見地。我認爲它可以教給你很多計算機科學知識。話雖如此,如果沒有明確目的,閱讀起來可能會很嚇人,所以我建議帶着某種修改項目的想法來閱讀。

由於這個項目近乎成功,我在 Go Conference 上提出的方法現在已經過時。這是不可避免的,但看到辛勤工作變得過時還是讓我有點難過。

未來工作

我將繼續完善這個項目,以便爲 Nintendo Switch 發佈一款正式遊戲。正如我最初描述的,這個項目存在很高的不確定性。在遊戲發佈之前,我無法預料會發生什麼樣的問題,我必須始終保持高度警惕。然而,即使在最壞的情況下,我知道我們可以在  go2cpp[25]  的幫助下繼續發佈遊戲,這令人寬慰。儘管如此,考慮到我已經付出的所有努力,我真的希望能用 Hitsumabushi 發佈一款遊戲並取得實際成果。

致謝

感謝 PySpa 社區的朋友們提供的所有技術建議。我還要感謝 Daigo, Odencat Inc. 的總裁 [26] ,他友好地在 Nintendo Switch 上使用 Ebiten。非常感謝。

新年快樂!

參考鏈接

  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
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/NUC5eMUUjS-0er0fAwZAiA