为什么我在2024年用Zig编写了一个Game Boy Advance游戏

Lobsters Hottest 新闻

摘要

一位开发者解释了为什么他们选择Zig编程语言来创建Game Boy Advance游戏,强调了Zig的交叉编译能力及其对嵌入式编程的适用性。

<p><a href="https://lobste.rs/s/9a3nvt/why_i_wrote_game_boy_advance_game_zig_2024">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/12 22:58

# Jonot 的博客 来源:https://jonot.me/posts/zig-gba/ Game Boy Advance 是一台有趣的主机。它拥有现代风格的 CPU(32 位 ARM,大量寄存器),但却沿用了 80 年代 NES 那种老旧的基于瓦片(tile)的渲染方式。别误会,作为任天堂最后一批基于瓦片的系统之一,他们把这种设计理念发挥到了极致,加入了仿射变换、透明度和精灵特效等花哨功能。但归根结底,它占据了一个非常独特的技术空间: 在这个象限里,另一个主要成员是 Nintendo DS。虽然我对 DS 有着美好的回忆,但我认为双屏会让编程变得更困难。所以,基于这些考虑,我决定为 Game Boy Advance 开发一款游戏: 我在那些关于数字方块以及行列组合规则的游戏(如 Picross、Picross 3D、扫雷)上花费了无数时间,因此 2048 是一个不错的项目选择。你可以在此处的模拟器(https://github.com/jonot-cyber/2048-zig/releases/tag/1.0)中尝试这个游戏。 但对我来说,这个项目最有趣的部分是所使用的编程语言。我最初在 Game Boy Advance 上的尝试性项目是用 C++ 写的,但我的第一个完整游戏却是用 Zig 写的。这有点奇怪。首先,Zig 是一门非常小众的语言,仍然处于测试阶段,而且它在 Game Boy Advance 于日本发售 15 年后才诞生。但尽管它并不算流行,我认为 Zig 非常适合这个项目,而这正是这篇博客要讨论的内容。 这篇博文并非关于 Zig 在通用领域的优越性,而是主要关注该语言中那些使其适合此类嵌入式编程的特性。网上有很多其他人谈论为何你应该使用 Zig,我会把那些内容留给他们。 五年前我开始使用 Linux,主要原因是当时我搞不定如何让 Python 在 Windows 上运行,结果发现 Linux 自带 Python。从那以后,我一直在与打包、版本、依赖以及其他所有让程序在电脑上运行不起来的难题作斗争。 正因为如此,大约一年前当我决定为 Nintendo DS 制作一个简单的 3D 场景时,我立刻感到不安。为这些“复古”任天堂主机开发游戏最流行的方式是通过 devKitPro(https://devkitpro.org/),它打包了适用于这些主机的 GCC 工具链、库和开发工具。如何获取这些包?你需要下载 ArchLinux 的包管理器,将其配置指向他们的仓库,然后按照那种方式安装。 即使在当时,这也让我很烦恼。一般来说,我使用的不同包管理器越多,我就越不开心。我最终确实安装了它,但我尝试将一些链接文件复制到我的仓库中,这样我就可以直接使用标准的 arm gcc 工具链。 当我开始我的 GBA 演示项目时,这种感受一直伴随着我。我再次从复制 devKitPro 的链接脚本开始,并拼凑了一套奇怪的 Meson(https://mesonbuild.com/)设置,以便让所有东西与 clang 一起工作。这简直一团糟,但确实能运行。好的一面是,由于我对工具链有了更好的理解,我成功让 newlib 工作起来,从而可以使用 C 和 C++ 标准库。我制作了一些演示来理解精灵的工作原理,但最终我转向了: ### Zig 我记得第一次听说 Zig 是 Andrew Kelly 的这篇博客文章(https://andrewkelley.me/post/zig-cc-powerful-drop-in-replacement-gcc-clang.html),讲的是使用 Zig 进行交叉编译。你不需要通过奇怪的包管理器(在我看来 rustup 也算一个)来设置这些工具链。只需设置一个目标,一切就能工作。 这还只是 Zig 让构建过程变得更好的一个方面。Zig 构建是通过运行 `build.zig` 文件中的 `build` 函数来完成的。以下是该游戏构建过程的概览: - 使用链接脚本正确布局文件,编译一个 ELF 二进制文件,并包含一个用汇编写的启动文件 - 使用 objcopy 从 ELF 结构中提取所有数据(现在这基本上就是一个 GBA ROM 了) - 使用像 gbafix 这样的工具添加正确的头部信息(在真实硬件上需要,但在模拟器上不需要) 因为 Zig 包含了 objcopy,我可以直接在构建文件中完成所有这些步骤。这使得过程更简洁,不易出错。 构建系统让代码生成也变得更简单。我使用代码生成将 png 图像转换成 GBA 能理解的像素数据。Zig 的构建系统允许我编译生成代码的程序、生成代码,然后在一个函数内将它们链接在一起。你在这个文件中指定依赖关系,因此构建过程可以在你崭新电脑的所有核心上并行进行。 Zig 让交叉编译和工具链管理——这一直是我生活中的噩梦——变成了我不必担心的事情。 ## 压缩结构体(Packed Structs) 我把它放在第二位,因为尽管这个特性在 Zig 文档中并没有被深入讨论,但它却是 Game Boy Advance 编程中最好的特性之一。 如果你不知道的话,像 Game Boy Advance 这样的主机并没有完整的操作系统,也没有高级的 API 调用来实现图形。你需要通过 *寄存器* 来访问视频、控制、音频以及其他硬件控制。这些寄存器是内存中的某些区域,通常是 16 位宽,并且将多个字段打包在一起。这些寄存器位于固定的内存地址,你可以读写它们。以 BGxCNT 为例。有 4 个变体,x 的数字不同,它们控制着主机 4 个背景层的显示方式。以下是内存布局,感谢 gbatek(https://problemkaputt.de/gbatek.htm#gbalcdvideocontroller): ``` 位 说明 0-1 BG 优先级 (0-3, 0=最高) 2-3 字符基块 (0-3, 以 16 KB 为单位) (=BG 瓦片数据) 4-5 未使用 (必须为零) (除 NDS 模式外: 字符基的高位) 6 马赛克 (0=禁用, 1=启用) 7 颜色/调色板 (0=16/16, 1=256/1) 8-12 屏幕基块 (0-31, 以 2 KB 为单位) (=BG 地图数据) 13 BG0/BG1: 未使用 (除 NDS 模式外: BG0/BG1 的外部调色板槽) 13 BG2/BG3: 显示区域溢出 (0=透明, 1=环绕) 14-15 屏幕尺寸 (0-3) ``` 假设你想为背景 0 开启马赛克效果。在 C 语言中你会怎么做?假设你已经在某个 `#define` 中定义了内存地址,你可以这样做: ```c *REG_BG0CNT |= BG_MOSAIC; ``` 其中 `BG_MOSAIC` 是一个等于 `0x0040` 的常量。这可行,但体验并不好。在这个简单例子中还好,但在更复杂的场景(比如设置多个布尔值之外的东西)下,可读性就会变差。在 C++ 或 Rust 中,你可能会看到使用构建者(builder)模式。这样做显而易见发生了什么,而且由于零成本抽象,你几乎不会损失什么。但 Zig 呢? Zig 有一个非常有趣的特性:你可以拥有不是 2 的幂次方的整数类型。你可以使用 `u8` 表示无符号 8 位整数,但没人会阻止你创建一个 `u7`。这个特性与 *压缩结构体* 配合工作。通常,结构体中的字段会按 2 的幂次方对齐以加快访问速度,但压缩结构体允许你选择将所有字段尽可能紧密地挤在一起。这让我们可以创建像这样的结构体: ```zig const BldCnt = packed struct { bg_priority: u2 = 0, character_base: u2 = 0, _unused: u2 = 0, mosaic: bool = false, // ... }; ``` 然后我们可以像这样使用它: ```zig REG_BG0CNT.mosaic = true; ``` 这好太多了!也许你从未遇到过这个问题,但如果你使用像 Game Boy Advance 这样的硬件,你会经常处理这种压缩的内存布局。仅凭这一点,对我来说就是最大的卖点。 ## 编译时(Comptime) 当我开始构建我的游戏时,我对 ROM 文件大小不太满意。我决定压缩所有精灵,以减小 ROM 体积。幸运的是,Game Boy Advance 在其 BIOS 中包含了解压缩功能,所以我只需要实现压缩,并调用内置函数。 但等等:如果我在运行时压缩,实际上什么也没做成。我需要的是在编译时压缩。那么我是不是又要用代码生成?不。 Zig 最强大的通用特性之一就是能够以几乎与运行时完全相同的方式在编译时运行代码。这仍然是他们网站上列出的首要特性之一。我编写了一个实现游程编码(RLE)的函数,调用它,并将结果存储在一个全局常量中。Zig 处理了大部分工作,使得压缩数据变得非常容易。 ## 标准库 我喜欢 Zig 的另一个方面是它的标准库。当然,你*可以*使用 newlib 来获得 C/C++ 标准库,但我总感觉在这种硬件上我只能使用库的一个较小子集。而 Zig 则灵活得多。 该标准库的核心是围绕一种支持泛型的语言构建的,并且大多数库函数都设计为支持泛型。Zig 利用这些能力让你比其他语言对标准库的工作方式拥有更多的控制权。 例如,Zig 标准库中所有分配内存的函数都有一个用于内存分配器的参数。这意味着你可以提供自己的结构体,包含自定义的 allocate、reallocate 和 free 函数,以最适合你的方式处理内存。如果你需要快速进行大量分配,可以使用区域分配器(arena allocator)。如果你想检查未被释放的内存,可以使用通用分配器。如果你只想用 `malloc(1)`,使用 `std.heap.c_allocator`。你也可以在内存中某处有一个字节数组,并设置一个固定缓冲区分配器(fixed-buffer allocator)从中分配。在这个项目中我实际上并不需要使用任何分配器,但如果需要,最后那种方式会很适用。 像这样的特性让我感觉可以自由地使用整个标准库,只要我认为它最适合我的问题。在编写了一个没有 libc 的内核(https://github.com/jonot-cyber/Todd)之后,这算是一种不错的疗愈。 附注:游戏中的分数显示只是使用带有自定义 `io.Writer` 的 `print` 实现的。我本可以用更高效的方式,但目前这样工作得很好,而且比在 C 语言中实现要好。Zig 的特性使其更适合在编译时排除未使用的代码。因此,处理浮点数的相对庞大的代码不会包含在最终二进制文件中,因为语言可以判断它没有被使用。 ## 问题 不过,Zig 并非完美的语言。和我之前的许多赞美一样,我在这里提出的问题也非常特定于 Game Boy Advance,并不具有普遍性。 ### 内联汇编 Zig 支持内联汇编,而且相当不错。但不幸的是,它只支持一个输出。在 GBA 上,有些 BIOS 函数你可能想用内联汇编来调用,它们会在不同寄存器中输出多个值,这对 Zig 来说是个问题。这个问题目前正在解决中(https://github.com/ziglang/zig/issues/215),未来可能会改进。 ### Thumb 代码 / ARM 代码 Game Boy Advance 的 CPU 有两种操作模式。ARM 模式更高级,有更多的指令和寄存器可用,但指令占用空间更大。Thumb 指令则更小巧。在 Game Boy Advance 上,由于内存的工作方式,几乎总是使用 Thumb 更好。 然而,有些操作不能仅用 Thumb 完成。如果你想使用硬件中断,处理中断的函数必须使用 ARM 模式。这通常不是问题,因为 ARM 和 Thumb 函数可以互相调用而不出问题。 在 C 和 C++ 中,你可以像这样指定一个函数是 ARM 还是 Thumb: ```c __attribute__((target("arm"))) void interrupt_handler() { // ... } ``` 据我所知,在 Zig 中你不能这样做。这使得使用该语言变得不那么方便,因为你需要在单独的编译单元中指定 ARM 函数,然后将它们链接在一起(我实际上没有测试过这是否可行,因为我的游戏没有使用中断)。 ### 奇怪的内存 你是否曾经花了几天时间调试一个 Bug,最后发现它其实是个非常愚蠢的问题?不相关的问题:你知道在 Game Boy Advance 上不能以 8 位为单位写入视频内存吗?如果你这样做了,它仍然能工作,但所有图形都会错乱,而且除非你阅读了文档中特定的一段,否则很难找出原因。嘿,你知道当你编译优化大小的二进制时,内存复制会以 8 位为单位进行,但*如果你使用调试模式,就不会这样吗?* 不管怎样。在我弄清楚这个问题之后,我仍然在确保精灵和调色板被正确复制方面遇到了问题。有时候,编译器会自作聪明,认出我写的某个函数只是复制内存。它会好心地用 `memcpy` 替换整个函数体以节省空间。 事实证明,Game Boy Advance 有不少“奇怪的内存”,你必须用奇怪的方式绕过它们。我知道这可能有点异想天开,但如果有一种方法可以指定某些地址范围内的内存如何被访问,那就太好了。 也许这说明我只需要自己写一门编程语言。最坏的情况是,至少下次我又有借口将近一年不更新这个网站了。

相似文章

Zig 示例教程

Hacker News Top

通过带注释的示例,对 Zig 编程语言进行实践性介绍,涵盖从基础到高级的主题。灵感来源于 Go by Example。

用 Zig 写一个 C 编译器

Hacker News Top

一位开发者记录了用 Zig 语言、按照 Nora Sandler 的教程系列构建名为 paella 的 C 编译器的全过程。

在iPhone上编程GBA游戏

Hacker News Top

一位作者记录了如何完全在iPhone上编程Game Boy Advance游戏,使用了iSH、Textastic、Delta和gba bootstrap等工具,最终制作了一款名为TO THE TOWER的短小游戏。

2026 年的 Zig 与 Rust

Lobsters Hottest

本文在 2026 年的背景下对比了 Zig 和 Rust,认为编程代理通过自动化生成 Rust 代码,削弱了 Zig 在人机交互体验上的优势。

Zig 构建速度正在提升

Mitchell Hashimoto

Zig 0.15 相比 0.14 在编译时性能有显著提升,构建脚本编译时间从约 7 秒降至约 1.7 秒,完整构建时间从 41 秒降至 32 秒,且仍使用 LLVM。本文重点介绍了自托管后端和增量编译方面的进展。