用 Claude Code 以 Clean Room 方式实现 Z80 / ZX Spectrum 模拟器
原文由 Salvatore Sanfilippo 于 发布,订阅该博客
Anthropic 最近发布了一篇博客,介绍了一项实验:在“Clean Room”环境下,让最新版的 Opus 4.6 用 Rust 编写一个 C 编译器。
这次实验的方法论让我对其想要论证的观点产生了怀疑。为什么不给智能体提供 ISA 文档?为什么要用 Rust?编写 C 编译器本质上就是一项巨大的图操作任务:这类程序用 Rust 写反而更难。而且,在 Clean Room 实验中,智能体本应可以获取所有关于优化编译器领域那些已经成熟的计算机科学进展的信息:有大量论文完全可以被提炼成若干个 Markdown 文件。比如 SSA、寄存器分配、指令选择与调度。这些东西本该*先*作为前置工作去研究,即便如此,实现本身依然算是“Clean Room”。
不允许智能体联网,也不允许它访问任何其他编译器的源代码,这无疑是正确的决定。较难理解的是几乎零引导的原则,不过如果实验目的是为了展示完全自主地编写一个大型项目,这么做倒也自洽。然而我们都知道,实际使用编码智能体时大多数时候并非如此。大量使用过编码智能体的人都很清楚,即便完全不去碰代码,只是时不时给几句点拨,最终结果的质量也会截然不同。
Z80 实验
我觉得也该自己来做一次类似的实验,最多花上一两个小时,而且要符合我的 Claude Code Max 套餐额度:我决定在一种我认为更符合“Clean Room”含义的条件下,编写一个 Z80 模拟器,进而再实现一个 ZX Spectrum 模拟器(甚至还有一个 CP/M 模拟器,后文会提到)。成果在这里可以看到:https://github.com/antirez/ZOT。
我采用的流程
我先写了一个 Markdown 文件,用来说明我想做什么。只是用英语写的高层次想法,概括了要实现的 Z80 模拟器的范围。比如我提到:模拟器应该以整条指令为单位执行,而不是以单个时钟周期为步进,因为这个模拟器必须能在 RP2350 这类资源受限的硬件上运行。模拟器需要准确追踪已消耗的时钟周期(我还特别说明,之后可以利用这一特性来实现 ZX Spectrum 中内存访问时与 ULA 的冲突),提供内存访问回调,并且要模拟 Z80 所有已知的官方和非官方指令。
对于作为后续步骤的 Spectrum 实现,我在 Markdown 文件中提供了更多的信息,比如我想要的 RGB 缓冲区渲染方式,以及为什么它必须是可选的——以便嵌入式设备可以在向 ST77xx 显示屏(或类似设备)传输数据的同时直接渲染扫描线;还有如何通过与 I/O 端口交互来设置 EAR 位,以非常逼真的方式模拟磁带加载,以及我对这个模拟器的其他诸多期望。
这个文件中还包含了智能体需要遵守的规则,例如:
- 禁止访问互联网,但可以使用我放在 ./z80-specs 目录下的规约和测试向量文件。
- 代码要简洁清晰,切勿过度复杂化。
- 每取得一项实质性进展,都要提交到 Git 仓库。
- 提交前必须测试,确保产出的代码质量高且能够正常工作。
- 随着功能增加,要编写详尽的测试套件,每次重大改动后都必须重新执行测试。
- 代码需要有非常充分的注释:要用即使不熟悉 Z80 或 Spectrum 内部细节的人也能看懂的方式来解释。
- 过程中不要停下来等待用户提示,用户已离开键盘。
- 在该文件末尾创建一个进行中日志,记录已完成的内容和待完成的内容,并持续更新。
- 每次上下文压缩后,都要重新阅读该文件。
接着,我启动了一个 Claude Code 会话,让它从网上搜集所有关于 Z80 的有用文档(之后对 Spectrum 也做了同样的事),并只把有用的事实性信息提取到 Markdown 文件中。我还提供了针对 Z80 的最全面的测试向量的二进制文件、ZX Spectrum 的 ROM,以及其他几个可用于检验模拟器是否正确执行代码的二进制文件。所有这些信息收集完成后(它们都包含在仓库中,你可以自行查看生成了哪些内容),我彻底清除了这个 Claude Code 会话,以确保在搜索过程中看到的任何源代码都不会造成污染。
我开启了一个新会话,让它查看规约 Markdown 文件和所有可用文档,然后开始实现 Z80 模拟器。规则是:无论出于何种原因都不得联网(我在智能体编写代码时全程监督,以确保这一点得到遵守),也不得在磁盘上搜索类似的源代码,因为这是一次“Clean Room”实现。
在实现 Z80 时,我完全没有进行引导。而在实现 Spectrum 时,为了实现 TAP 加载功能,我进行了大量的引导。关于我给智能体的反馈,后文会详述。
作为最后一步,我把仓库复制到了 /tmp,彻底删除了其中的 “.git” 仓库文件,然后启动了一个全新的 Claude Code(以及 Codex)会话,并声称该实现很可能是剽窃或过度借鉴了他人的作品。任务是将其与所有主流的 Z80 实现进行对比,看是否存在抄袭的证据。两个智能体(Codex 和 Claude Code)在经过大量检索后,都未能发现任何版权问题的证据。仅有的相似之处在于那些已被广泛采用的模拟模式,以及 Z80 特有的、无法以其他方式实现的部分,整个实现与其他实现相比在很大程度上是截然不同的。
结果
Claude Code 总共工作了 20 到 30 分钟,就用 1200 行可读性很强、注释充分的 C 代码(算上注释和空行共 1800 行)产出了一个能够通过 ZEXDOC 和 ZEXALL 测试的 Z80 模拟器。整个实现过程中我一次也没有对智能体进行提示,它完全是独立完成的。它从未访问过互联网,实现过程是持续测试的过程:与实现了 ZEXDOC 和 ZEXALL 的 CP/M 二进制文件交互,仅编写了在屏幕上输出结果所需的 CP/M 系统调用。它还多次使用了已有的 Spectrum ROM 和其他二进制文件,或是从零开始创建二进制文件来验证模拟器是否正常工作。简而言之,实现过程与人类程序员的做法非常相似,而不是一次性从权重中“解压”出一个完整实现。相反,不同类别的指令是逐步增量实现的,期间出现的 Bug 则通过集成测试、调试、内存转储、printf 打印等方式逐一修复。
下一步:ZX Spectrum
我又重复了一遍这个流程。我非常细致地指示文档搜集会话去网上查找我想要的细节,尤其是 ULA 与 RAM 访问的交互、键盘映射、I/O 端口、磁带的工作原理及其使用的 PWM 编码方式,以及它们在 TAP 或 TZX 文件中的编码方式。
如前所述,这次的设计说明非常详尽,因为我希望这个模拟器专门为嵌入式系统设计,所以只做 48K 模拟、可选的帧缓冲渲染、占用极少的额外内存(不为 ULA/Z80 访问冲突使用大的查找表)、ROM 不复制到 RAM 中以避免额外占用 16K 内存,而只是在初始化时引用(这样在可执行文件中只有一份拷贝),等等。
智能体成功创建了一份关于 ZX Spectrum 内部机制的非常详细的文档。我提供了几个游戏的 .z80 镜像,以便它能在真实软件环境中测试模拟器。同样,我清除了会话并重新开始。智能体开始工作,10 分钟后就完成了,整个过程让我着迷,相信你也很熟悉:你会看到智能体运用多种不同的技能在工作。它在所有与编程相关的领域都是专家,因此在实现模拟器的同时,它能立刻编写出详尽的插桩代码,一步步“观察”Z80 在做什么,以及这些操作如何改变 Spectrum 的模拟状态。在这方面,我认为自动编程已经是超人级别的——不是说它现在能写出人类写不出的代码,而是指它能同时运用多种编程语言、系统编程技术、DSP 知识、操作系统技巧、数学以及达成目标所需的一切手段,并以最直接的方式得出结果。
完成后,我让它编写一个基于 SDL 的简单集成示例。模拟器立刻就能毫无问题地运行 Jetpac 游戏,声音正常,而且即便在我那台速度很慢的 Dell Linux 机器上,CPU 占用也非常低(包括 SDL 渲染在内,仅占单核的 8%)。
在基础功能跑通后,我想让它直接加载 TAP 文件,以模拟磁带加载。这是智能体第一次遗漏了一些东西,具体是关于 Spectrum 加载例程所期望的时序,而这正是 LLM 开始表现得不那么高效的领域:它们无法轻易地运行 SDL 模拟器并观察随着数据接收边框颜色的变化等情况。我让 Claude Code 做了一次重构,使 zx_tick() 可以被直接调用,不再是 zx_frame() 的一部分,并让 zx_frame() 变成一个简单的包装函数。这样一来,将 EAR 与预期状态同步就简单多了,无需回调或它之前实现的那些错误的抽象。做出这一改动后,几分钟内模拟器就能毫无问题地通过模拟磁带来加载 TAP 文件了。
现在的工作方式如下:
do {
zx_set_ear(zx, tzx_update(&tape, zx->cpu.clocks));
} while (!zx_tick(zx, 0));之后我又继续提示 Claude Code,让按键绑定变得更实用,并做了一些其他调整。
CP/M
有一件事让我觉得特别有意思,那就是 LLM 能够检查用于 Z80 的 ZEXALL / ZEXCOM 测试的 COM 文件,轻松识别出其中使用的 CP/M 系统调用(总共三个),并为扩展的 Z80 测试(通过 make fulltest 执行)实现它们。既然如此,何不直接实现一个完整的 CP/M 环境呢?又是同样的流程,几分钟内就得到了同样不错的结果。这一次我与它互动得稍多一些,涉及 VT100 / ADM3 终端转义序列的转换,一开始还报告了 WordStar 中无法正常工作的地方,几分钟后我测试的所有功能就都基本正常了(不过仍有一些需要修复的地方,比如模拟 2Mhz 时钟,目前它是以全速运行,导致 CP/M 游戏无法正常游玩)。
我们能从中学到什么?
最显而易见的教训是:一定要为你的智能体提供设计提示和关于其要做之事的详尽文档。这类文档完全可以由智能体自己来获取。同时,还要确保智能体拥有一份说明如何执行编码任务的规则 Markdown 文件,以及一份记录其工作进展的日志,并时常更新和重读。
但我相信,这些技巧对于近几个月深入使用过自动编程的人来说都已经非常清楚了。以“人类会需要什么”的角度去思考往往是最好的策略,再加上一些针对 LLM 的特殊考量,比如上下文压缩后的遗忘问题、持续验证其是否走在正确轨道上的能力等等。
回到 Anthropic 那次编译器的尝试:智能体失败的其中一个环节,恰恰是与预训练集中内容记忆这一想法关联最紧密的部分:汇编器。有了详尽的文档,我实在想不出 Claude Code(更不用说在我看来处理复杂任务时能力更强的 GPT5.3-codex)有什么理由会无法做出一个能用的汇编器,因为这本来就是一个相当机械化的过程。在我看来,这与“LLM 记住了整个训练集并将其解压输出”的观点是相矛盾的。LLM 确实能记住某些过度出现的文档和代码,而且如果加以提示,它们也能逐字提取出这些代码片段,但在常规运行中,它们既没有保存训练集中见过的所有内容的完整副本,也不会自发地输出已见代码的复制品。我们大多数时候要求 LLM 完成的工作,需要它们整合所掌握的不同知识,而结果通常是运用了已知技术和模式、但属于全新代码的产物,并不构成对既有代码的复制。
同样值得注意的是,相比本文所述的 Clean Room 规则,人类往往遵循的是一个不那么严谨的流程:人类常常会下载与自己想要实现的目标相关的不同实现的代码,仔细阅读,然后尽量避免逐字复制,但又常常会从中获得强烈的启发。我认为这个过程完全可以接受,但重要的是要认清人类编写代码时的现实情况。毕竟,信息技术之所以能如此快速地发展,也正是得益于这种大规模的交叉借鉴效应。
基于上述所有原因,当我使用自动编程来实现代码时,我毫不介意以 MIT 许可发布它,就像我对这个 Z80 项目所做的那样。反过来,这个代码库也将成为下一代 LLM(包括开源权重模型)训练时的优质输入。
后续步骤
要让我的实验更具说服力,应该尝试在不向智能体提供任何文档的情况下实现 Z80 和 ZX Spectrum 模拟器,然后对比实现结果。我还没抽出时间来做这件事,但这可能会很有启发性。
随机一篇博客
评论
登录后参与讨论