我来,我提示,我离开 第二部分:一个月内从零构建 GPU 驱动
上一篇博客文章:https://codyho.dev/blog/hypervisor-macbook-neo/
我们做了什么
简而言之: 我和 Niklas 在大约一个月内为 M4 Mac Mini 和 MacBook Neo 构建了一个完全符合 OpenGL ES 3.0 标准的 GPU 驱动,这个过程通常需要数年。以下是 Chrome 和 Firefox 在 M4 Mac Mini 上运行 WebGL 并具有正常合成效果:
最重要的是,该驱动速度足够快,可以以 200fps 运行 Minecraft:
构建这个驱动涉及逆向工程 AGX(苹果对 GPU 的称呼)极其复杂的固件 ABI 和用户空间组件。这一切都是以透明、可验证的洁净室方式使用成熟技术完成的。代码尚未准备好供最终用户使用,但我们希望尽快将其提供给最终用户。
我们如何做到的
之前,我构建了一个 hypervisor 来逆向工程 macOS。现在目标变成了用它做一些有用的事情,还有什么比编写 GPU 驱动更好的目标呢。GPU 实际上是任何现代系统的必需品,否则一切都必须由 CPU 渲染,速度慢几个数量级且能效更低。我们的目标是为 M4 Mac Mini 和 MacBook Neo 实现符合标准的 OpenGL(以及即将推出的 Vulkan)驱动。
通常,构建 GPU 驱动需要数年时间;我们的目标是在几天内完成。事实证明,几天过于乐观,但几周仍然是巨大的改进。在这几周里,我们:
- 仅使用实时探测逆向工程了 M4、A18 Pro 和(大部分)M5 用户空间,发现了苹果驱动未发出的硬件支持特性和指令
- 构建了一个完全可用的用户空间驱动,包括新的自定义 IR/着色器编译器、命令流构建器以及更多组件
- 使用我之前构建的 hypervisor 的跟踪,从零开始逆向工程了完整的 AGX 固件 ABI
- 为上述固件 ABI 实现了完整的 Linux 内核驱动
在整个过程中,我们没有查看任何苹果二进制文件,只查看了硬件跟踪(来自我们的 hypervisor)和我们自己构建的着色器。对于用户空间图形逆向工程,我们小心地将任何必需的苹果二进制大对象视为不透明对象。我们请一位朋友编写了关于这些二进制大对象的文档 1,以便我们自己编写洁净室实现(主要是通过盲目尝试直到成功)。我们已经发布了所有实验,以便任何人都可以验证我们工作的来源(参见 Deliverables 下的 twin agx-re 仓库)。
这篇博客文章分为两部分:用户空间和内核空间。这反映了所有现代 GPU 驱动的划分:内核负责与固件接口、分配缓冲区和管理调度,而缓冲区的实际内容以及正在调度的内容是不透明的。用户空间负责实际理解 GPU 如何工作并用内容填充这些缓冲区。
内核空间
在 Apple Silicon 上,内核驱动不直接与硬件接口。相反,它与运行名为 RTKit 的自定义 RTOS 的 GPU 固件通信。这意味着内核驱动的第一步不是与硬件通信,而是弄清楚固件 ABI。
固件 ABI 是这个项目中最令人头疼的部分,因为 Apple 并没有采取合理的做法——设计一个具有良好接口的合理 ABI——而是基本上拿了一个常规的内核驱动,把它切成两半,然后将其中一半放进 AGX 并称之为固件,另一半内核驱动则通过内存中的共享结构体进行通信。这些结构体中有许多包含固件拥有的字段(我们绝不能修改这些字段,必须通过逆向工程来了解它们),并与主机控制的字段交错在一起。为了了解这个 ABI 有多复杂,以下是 M1/M2 上共享内存树的样子:
Asahi Lina 在艰苦的 12 小时工作日中弄清了这一切,构建了
M1/M2 内核驱动,这是一项惊人的技术成就。不幸的是,A18 Pro 固件 ABI(我在 MacBook Neo 上开始了我的逆向工程工作,后来
转向了 M4 Mac Mini)比已经非常复杂的 M1 固件 ABI 显著 更加复杂:
Apple,搞什么鬼。 注意 A18 有:
- 1.5 倍的结构体数量
- 两倍的指针数量
- 提交工作的过程显著更复杂
还有许多其他问题给逆向工程过程增加了阻力 2。我
确实有一些关于固件 ABI 的文档,但它非常不完整,老实说并不是很有用
。
3我的方法很简单,基于成功逆向工程 M1/M2 机器所使用的方法:观察 macOS 做了什么,重放它,然后尝试自己去做,这得益于虚拟机监控程序而成为可能。
当我向 LLM 描述这种方法时,它极其字面地理解了“重放”:
它做的第一件事是等待第一个固件可见事件(这些被称为“kicks”),然后保存整个 GPU 内存状态的副本。在
重启后,它将保存的内存状态直接复制回主机内存,执行 kick,并看到输出页面发生了变化。然后它会尝试
在代码中重建这些对象,跟随所有指针并理解
内容。在连续的实验中,Codex 会减少它复制的
页面数量,直到没有更多重放状态,一切都从源代码构建。4 令人惊讶的是,我注意到 Codex 在何时应该更多地戳硬件、何时应该只运行虚拟机监控程序并自行捕获状态方面有很好的品味。
有三个主要问题,都是由于我们无法获得主机工作的干净捕获所导致的:
第一个问题是 GPU 固件启动之后提交的渲染工作。我们
可以在固件启动前预置工作,启动 GPU,这些工作
会按预期完成,但一旦固件启动,任何提交的工作
都只会被 ACK 并退役,而实际上什么也没做。一旦
固件启动,捕获状态就困难得多,因为一切都
变得动态,固件变成了一个有状态的对象,其状态你无法
轻易重放。
此时我不得不介入并检查 Codex 的过程。事实证明它试图在 AGX 生命周期的很晚阶段重放一个捕获,那时已经有许多先前的事件。当我告诉它在 AGX 生命周期的更早阶段选择一个捕获,即固件启动后的第一个捕获时,Codex 几乎立即发现了问题(它缺少一个单字节描述符)。这花了几天时间。
第二个,也是唯一一个主要的阻塞性问题,是计算。AGX 大致支持两种工作:计算和渲染。在常规的 GUI 路径中,计算工作只有在大量渲染工作已经执行之后才会被调度。因此,要获得一个干净的计算工作负载捕获需要很长时间,当 Codex 最终完成时,它的大小是 336 MB,并且无法重放(它尝试了很长时间)。它还尝试通过查看捕获来自己构建对象,并花了超过一周的时间做这件事,但最终没有成功。有太多无意义的内容需要筛选。我这边的问题加剧了这种情况——在让渲染工作之后,我期望提交计算工作会更简单(计算的固件 ABI 确实更简单,所以我在这一点上是正确的),但我失去了谦逊,认为这将是小菜一碟,只需要几个小时。因此,我没有为 LLM 正确地搭建任务框架。
修复方法实际上是在另一个 Codex 会话中给我的。本质上:
- 通过启动到单用户模式来禁用 GUI;这意味着不会进行渲染工作。
- 安装一个 LaunchDaemon,在尽可能早的时刻运行,即 Metal(苹果的专有图形框架)可用时。
- 运行我们提供的一个小型 Metal 程序
- 捕获并重放这个微小的、纯计算的跟踪。
跟踪被成功捕获。几个小时内,Codex 就解构了它,几天内,Codex 就让计算工作了。至于为什么原来的计算代码库不工作…… Codex 也不知道。工作的和坏掉的看起来非常相似。
事后看来,这应该从一开始就是策略——尽可能小的捕获,在单用户模式下运行以避免干扰结果。我从这里的错误中吸取了教训,用于最后一个问题:
部分渲染最终成为最难弄清楚的事情之一。当 Tiled Vertex Buffer (TVB) 不够大,无法存储当前几何体时(即,要绘制的三角形太多),就会发生这种情况。在这些情况下,有两种选择,驱动程序需要同时支持两者:要么增加 TVB 的大小,要么执行部分渲染,即渲染部分几何体,然后用剩余的三角形重新加载缓冲区,并完成部分渲染。这些部分渲染结果非常非常棘手,甚至比其他工作更棘手,因为它们本质上意味着向 GPU 驱动程序添加保存和恢复功能。
我之前发现的工作流程在这里非常方便。Codex 能够重放一个部分渲染事务,然后修改我们的 Metal 着色器以执行多个部分渲染(这很容易,只需用数千个三角形猛击一个瓦片,直到触发部分渲染),然后学会了如何重放这些。一旦 Codex 成功重放,学会自己构建只是时间问题。
构建内核驱动程序
从 Python 原型驱动程序过渡到功能齐全的 Linux 驱动程序花了三天时间,其中一天几乎完全浪费了,因为 Codex,出于我仍然不明白的原因,选择先处理部分渲染(迄今为止最难的任务),而不是先做计算(最简单的任务)。一旦我告诉它先做计算,一切就顺利了。
在高层次上,整个过程基本上是:
- 重写现有的
drm-shim
在 Rust 中遵循完全相同的模式;这给了我们一个同步的 Rust 驱动。
- 将前端重写为异步;实际的 GPU 提交保持同步。
- 将 GPU 提交重构为异步,并且不是轮询,而是监听固件事件并将工作与 fence 关联起来。
- 实现一些低垂的优化,例如批量工作提交。
这些都是相当常规的工程工作,LLM 肯定有能力完成。
我发现的唯一值得注意的事情是,Codex 积极使用 hypervisor 来调试其代码为何不工作,包括捕获完整地址空间并将其与已知良好的样本进行比较。这种系统性的调试正是 Codex 成为我最喜欢的编码代理的原因。
用户空间
A18 Pro 的用户空间与 M1/M2 非常不同;它有新的描述符格式、新的 ISA 以及一堆其他新东西。除了是一个为运行 Metal 而设计的基于瓦片的延迟渲染器之外,它只是一个不同的 GPU。
好消息是用户空间 RE 有一个非常明确的流程。只需编写一个小型 Metal 程序,编译它,运行它,看看有什么变化,然后拆解它并开始摆弄这些位,直到我们理解它们的作用。如果你认为这听起来像是 LLM 非常擅长的那种无聊、重复、死记硬背的工作,那你就对了。
RE 工作分两个阶段进行。在第一阶段,我让 Claude 查看它能找到的每一个可能的 Metal 程序,并尝试构建反汇编器、汇编器,并理解 GPU 驱动所需的所有其他描述符/命令流等的格式。我让 Claude 枚举所有内容,包括 Linux 无法使用的东西(如曲面细分)以确保完整性。这是成功的,但仅仅因为 Claude 可以反汇编然后重新汇编程序,并不意味着它知道如何自己构建一个。当试图弥合这一差距,即理解每条指令足以真正能够编译我们自己的任意程序时,Claude 做得很糟糕,基本上零进展。
此时,Niklas 完成了他的 drm-shim
用于 M4 Mac Mini 并加入了我
进行用户空间 RE 的第二阶段。我们有两种不同的方法
来实际完成用户空间驱动:
我的方法是优先考虑硬件 RE,并专注于弄清楚硬件和所有指令如何工作。然后我会写一个规范,让 LLM 实现它,希望最终实现完整的 OpenGL 和 Vulkan 合规性。这意味着我的 LLM 大部分时间都花在编写硬件实验上,而不是实际实现 Mesa 代码。这个想法是,一旦我理解了硬件,其他一切都会随之而来。
Niklas 采取了不同的方法,我将其描述为“Mesa 优先”。本质上,他试图先构建 Mesa,并且只为了构建某些功能而进行 RE。他的时间分配在构建和测试 Mesa 以及执行 RE 之间。
事实证明,Niklas 的进展比我快得多,因为我的代理会以完整性的名义在次要的、无关紧要的任务上花费大量时间。相比之下,他的代理受到实际构建 Mesa 的需求的约束,因此它更有效地利用了时间和资源。他最终比我快得多,以至于我最终只是试图通过调查他尚未理解的任何行为来支持他的工作。
这是我注意到的 Codex 的一个重大局限。我能想到的最贴切的词是“迂腐”——它总是极其彻底,这在某些场景下可能是很大的优势,但其他时候它会陷入无关紧要的细节中,损害整体目标。
在我们的逆向工程过程中,我们发现了一些硬件支持但 不 被 Metal 支持的行为;这是通过直接操作不同指令的比特位,并根据已知存在的内容推断 可能 存在的内容而发现的,就像 Alyssa Rosenzweig 在逆向 M1/M2 时所做的那样。这包括:
- 原生单指令 64 位加法
- 各向异性到 128x(Metal 上限为 16x)
- 矩阵单元的新模式
uniform_mov的 7 位立即数支持
Mesa 开发
在构建用户空间图形时,有几件事对我们极为有利。最值得注意的是,Khronos 兼容性测试套件(CTS)已经是一个详尽的测试语料库,我们的驱动必须通过。换句话说,使用 LLM 中最困难、最敏感的部分——给它们好的测试来奠定基础——已经为我们完成了。
此外,Mesa 已经有很好的抽象,让我们的生活轻松得多。这就是 Linux 上现代 OpenGL 栈的样子:

我们所要做的只是在 Gallium(Mesa 的内部 API)和 AGX 硬件语义之间进行转换。这个过程的最大部分之一是将 NIR(Mesa 的内部 IR,与 LLVM IR 非常相似)转换为 AGX 的专有 ISA。额外的好处是,这个编译器可以复用于未来的 Vulkan 驱动。
Niklas 能够逐步迭代 OpenGL 功能,同时逆向用户空间,直到他最终实现了完整的 OpenGL ES 3.0 合规性(不支持的测试是可选的扩展):
在整个过程中,我们受益于现有的 M1/M2 工作:虽然确切的硬件语义不同,但整体形态相似,因此许多正确的权衡/决策已经为我们做出。在我构建这个驱动的整个过程中,越来越清楚的是,Alyssa Rosenzweig 和其他构建 M1/M2 驱动的人简直是巫师——向他们致敬!
交付成果
Mesa: https://github.com/niklassheth/mesa
Linux 内核驱动: https://github.com/GravityLinux/linux/gravity-m4
用户空间逆向工程文档(可怕的 LLM 垃圾堆,但功能可用):Cody Niklas
剩余工作
Vulkan 1.4、OpenGL 4.6、OpenGL ES 3.2、OpenCL 3.1、Direct3D 12(通过 Proton)和光线追踪都在范围内。我们希望我们的驱动能像世界上最好的图形驱动一样好。
此外,Niklas 和我想将所有这些上游化,但存在一些重大障碍。我们使用了未修改的 Asahi UAPI,因此 Mesa 上游化没有政策问题,但它需要更多的测试、人工审查,并重构为可审查的 PR。我们还预计会遇到重大怀疑,因为这可能是史上第一个完全由 LLM 编写的 GPU 驱动,而且我们的代码将被要求达到比人类编写的代码 更高 的标准。我们已准备好迎接这些挑战,但它们主要是
人工且非技术性的工作,LLM 帮不上忙。
Linux 内核驱动会是更大的问题,因为 M1/M2 驱动尚未进入上游,而实际上我们无力改变这一点。我们认为这里最好的做法就是等待该驱动进入上游,然后在 M1/M2 进入上游之后(在完成所有必要的重构 + 审查 + 分解等等之后),再把我们的驱动推向上游。不幸的是,这可能要等上一阵子。
我什么时候能用上它?
耐心点,年轻的蚱蜢,我们期待很快就能把这份代码交到你手中,等待的时间可能远比你预期的要短。毕竟,苹果掉下来不会离树太远。
加入我们
如果你有兴趣参与其中,我们邀请你加入我们的
Discord 服务器。欢迎随时过来
讨论想法、聊天,或者只是闲逛!
附录:真的吗,Sam?
我在内核逆向工程任务中使用了 Codex 搭配 GPT-5.6 Sol(后来 GPT-6 Astra 发布后换成了它)。我遇到的最大问题之一是过于激进的网络安全限制(我没有加入受信任访问)。
百分之九十九的情况下,一个简单的 /goal resume
或“继续”
就足以让 LLM 继续下去(这也说明这些限制过于
激进),但它们仍然破坏了我的无人值守工作流。我编写了最快、最笨的解决方案:一个守护进程,每分钟截一次屏,将其与上一次截图做差异比较,如果完全相同(因为 Codex 停止了
推进),就输入 /goal resume
。我不小心把它留在了某些群聊里:
话虽如此,GPT-6 Astra 和 GPT-5.6 Sol 绝对疯狂,是固件 ABI 逆向工程中迄今为止表现最好的,所以这个 hack 完全值得。
附录:M4 vs A18 Pro vs M5
据我所知,M4 和 A18 Pro 的用户空间实现
实际上完全相同,我能找到的唯一区别是 M4 上有一个值
略大一些,这与它拥有更多核心一致。两者的固件
ABI 差异显著:A18 Pro 上的第二个 RTKit 协处理器
让一切变得更加复杂。这样一来,A18 Pro 在固件方面更接近
M5,而不是 M4。M5 的用户空间与 M4 有一些
相似之处,ISA 大部分是超集,但有些部分
完全不同,比如它的纹理描述符。M5 的用户空间
已被部分逆向工程;其固件 ABI 已完全逆向
工程;一个原型 drm-shim
已构建并经过充分测试;而且我
认为将原型提升为完整的 Rust 驱动不会花太长时间。
我的主要目标仍然是 M4 Mac Mini 和 MacBook Neo。
脚注
我不确定 Claude 关于这不是洁净室的标题是怎么回事,我认为它把
agx-re
中“不要看 Apple 二进制文件”的指示搞混了
。文档本身显然不包含任何受污染的信息。↩︎简要列表:
- 如果我们曾经让固件崩溃,唯一的恢复方式就是完全重启
- 如果工作握手有任何问题,不会报告错误,工作会被 ACK 然后永远不会完成。
- 有些问题会导致输出按预期变化,但也会在本不应被触碰的页面上造成任意损坏。
↩︎如果我们发现某个页面包含着色器,我们就会丢弃该页面,并替换为我们自己的着色器,作为额外的版权保护措施。
