从一个迟到的字符说起

向显示端口写入一个 A,它什么时候出现在屏幕上?

这个问题足以把一台 Apple I 模拟器从“能运行程序”带向更深的地方。CPU 完成写指令时,终端可能还没有接收字符;终端接收以后,视频电路又需要把它送进行缓冲、字模和像素移位寄存器。屏幕上一个字母的出现,横跨了几种彼此相关、却不能合并的时间。

Hesper 的 Apple I 实现值得细读,正因为它认真处理了这段距离。它把 CPU、总线、PIA、循环显示存储器与数字视频接在一起,让字符经过这些环节抵达屏幕。沿着这条路径读代码,也就能看见一台早期个人计算机如何用有限的器件安排计算与显示。

本文基于 Hesper 提交 6e0c06c,重点阅读 crates/apple1,并核对相关测试与 Wasm 接口。前一篇关于 6502 核心的文章讨论处理器内部;这里把视线移向主板,看看 CPU 之外的器件如何决定程序的行为。

主板有自己的时间

Hesper 的 CPU 核心通过 Bus 访问外部世界,不拥有 Apple I 的内存和设备。机器层负责把它接到具体总线上,也负责决定什么时候允许它向前走。

因此,Apple1::tick() 的单位是一拍主晶振。板级时序从 14,318,180 Hz 出发,经 14 分频得到约 1.023 MHz 的字符时钟,再派生水平扫描、存储时钟与 CPU 相位。每条水平扫描线有 65 个字符时钟,其中 4 个用于刷新,抑制 CPU 的 Φ2。

这一点在原始操作手册中写得很直接:每 65 个时钟中的 4 个用于刷新,刷新期间抑制 Φ2;同一页另行介绍 RDY 的单步与慢速 ROM 用途。1976 年操作手册,Section III / REFRESH 与 DMA

两者的区别会落到具体的总线行为上。用 RDY 模拟等待,可能仍然产生停滞期间的读取;刷新门控下则没有那次 CPU 总线访问,PIA 也看不到对应的 E 使能沿。与此同时,视频计数器与单稳态脉冲仍然运行。倘若把整台机器一起暂停,显示端的应答就会被错误地拖慢。

这使 Tick 中的 cpu: Option<Cycle> 有了清楚的含义:这一拍主板时间里,可能根本没有完成 CPU 总线周期。None 保存了一个硬件事实。机器调度实现

可以用一个独立的小程序复算时序。水平计数从 95 到 159;十进制低位到 9,且高位计数器对应位为 1 时,选中刷新槽:

python

该推导示例已在本站 Python 运行器执行,结果为刷新槽 [34, 44, 54, 64]、每行 910 拍主时钟、61 次 CPU 访问,有效 CPU 频率约 959,790.09 Hz,正常帧率约 60.05444 Hz。它复算的是模型参数,并未执行模拟器本身。

这里特意说“正常帧”:后面会看到,写入控制能够改变垂直扫描过程。把帧长硬编码为一个永远不变的常数,会抹去这种反馈。板级时序实现

地址是接线的结果

CPU 看见的是地址,设备看见的是片选信号。理解两者之间的差别,才能理解 Apple I 的内存映射。

Hesper 默认安装两组独立的 4 KiB RAM,分别位于 0x0000–0x0FFF0xE000–0xEFFF;后者可以装入 BASIC。256 字节的 Woz Monitor ROM 位于 0xFF00–0xFFFF。可选扩展在 0x1000–0x1FFF 增加 4 KiB RAM。这里实现的是一套明确的配置,没有模拟任意跳线方案或扩展卡电路。

PIA 通常以 0xD010–0xD013 四个地址介绍,但代码中的选择条件更有意思:

(addr & 0xF010) == 0xD010

被选中以后,再用 addr & 3 选择寄存器。未参与译码的地址位可以变化,所以 0xD012 与 BASIC 使用的 0xD0F2 会抵达同一个 Port B 寄存器。

镜像地址于是成为接线的自然结果。更关键的是,它们共享读取副作用:从别名读取数据寄存器,也必须清除同一组标志、报告同一次读取事件。仅仅让两个地址返回相同的字节,还不足以实现这种关系。总线与别名测试

同一个端口地址还可能指向数据方向寄存器或外设数据寄存器,取决于控制寄存器的 bit 2。于是“读了某个地址”并不能单独说明发生了什么;必须把片选、内部寄存器选择和当时的设备状态一起考虑。

这也是总线抽象需要容纳副作用的原因。在内存里,读取通常只是观察;在设备上,读取可能就是一次确认。

一个字符的握手

Apple I 的输出路径通过 PIA 把 CPU 和终端连接起来。Hesper 的实现保留了请求、等待和确认之间的阶段。

CPU 写 Port B 输出寄存器后,PIA 先记录一个待触发选通。到了下一次真实的 E 上升沿,CB2 才拉低;接线将它转换为终端的 DA 请求。字符数据线仍然保持实时连接,终端会在实际接收时采样。

flowchart TD
    A["CPU 写 Port B 输出寄存器"] --> B["下一次 E 沿触发 CB2"]
    B --> C["DA 请求到达终端"]
    C --> D["等待循环存储器读头抵达光标槽"]
    D --> E["采样当前数据线,接收字符"]
    E --> F["RDA 触发 B3,脉冲送至 CB1"]
    F --> G["配置的有效边沿释放 CB2"]
    G --> H["PB7 反映 DA 状态,软件继续运行"]
    E --> I["数据进入显示存储与视频流水线"]

B3 的标称 3.5 微秒脉冲被向上量化为 51 拍主时钟。这个倒计时依附于主板时间,刷新期间照常推进。其数值是数字模型对标称脉宽的表达,没有包含真实 RC 元件的容差。PIA 握手实现

源码测试抓住了一个很好的反例:请求时数据线为 A,接收前变成 B,最终存入的必须是 B。如果在 CPU 写端口的一刻就把字符复制进显示队列,这个例子会暴露错误。数据在何时被锁存,是设备契约的一部分。

输入侧同样区分“有键可读”与“键已读走”。键盘通过 Port A 数据线和 CA1 选通呈现字符;只有 CPU 真正读取外设数据寄存器,才产生消费事件。读取 DDR 或控制寄存器不会吃掉按键,重复读取尚未更新的数据线也不会顺手吞掉下一个键。

Hesper 为宿主输入提供 FIFO,方便终端粘贴和浏览器输入。这是现代宿主的便利设施;其对接 PIA 的一侧仍然遵循数据与选通协议。把 FIFO 明确放在这条边界上,也避免把它误写成原始键盘的硬件能力。键盘实现

屏幕是一条不断转动的环

40×24 的字符网格很容易让人联想到二维数组。Hesper 的 Display 确实提供 screen(),但驱动终端行为的状态位于它背后:1024 个槽位组成的循环存储器、六条字符数据轨道,以及循环光标的位置。

其中 960 个槽位对应可见字符,剩下 64 个在正常帧的消隐阶段被擦除。存储器只在 MEMΦ 到来时前进;它有自己的物理读头,不会因为宿主希望刷新画面就立即改变位置。

当一个输出请求到来,终端等待读头与光标相遇。如果刚刚错过,就需要等下一轮。常见的慢速字符输出由这种相遇关系产生,代码无需另设一个“打印速度”计时器。

回车也有实际的过程:它获得写入口,逐槽擦除余下字符,直到行末条件成立。因而,回车处理中按 RESET、按 CLEAR SCREEN,或继续推进板时钟,都有可以分别检验的行为。

最精巧的地方是滚屏。显示写控制反馈给垂直计数器;特定条件下,计数器重新扫描底部的一条扫描线,而循环存储器继续前进。测试中的一次 0xBF 重载把正常的 262 行扩展成 263 行,让存储环多走 40 个槽位。新的屏幕起点随之出现。显示存储器实现

代码用一个简短的关系重新投影文本窗口:

origin = (head + 1024 - raster_slot) % 1024

head 表示循环存储器的位置,raster_slot 表示扫描所解释的位置。垂直重载改变后者,存储环继续移动;二者的相位差决定可见窗口从哪里开始。

屏幕上的“向上滚动”,在这里可以追溯为两个位置之间的变化。这个实现的深度就在于,它保留了产生滚屏的机制,因此同一套状态也能解释回车、消隐擦除和下一字符的落点。

看见字符,还不等于生成了视频

文本投影非常适合 TUI:宿主读取 screen()cursor(),就能画出可用的终端。但模拟数字视频还需要继续向下走。

Hesper 的视频链从原始数据轨道取值,经过 2519 的 40 级行缓冲、2513 字模,再由 74166 串行移出像素。tick().video 每拍返回亮度与同步信号。视频路径不读取宿主 ASCII 网格;宿主文本只是另一个观察面。

这个分离可以用一个不太直觉的现象验证:某些输入在宿主字符表示上不同,但经过硬件存储的位选择后相同,数字视频就应该相同。hardware_aliases_have_identical_video_despite_different_host_text 专门检查了这件事。数字视频集成测试

另一个关键是沿上的取样顺序。同一个点时钟沿上,像素移位寄存器先读取行缓冲的旧输出,行缓冲随后采样新输入。若把顺序写反,就相当于让新数据穿过原本存在的一级状态,提前出现在视频里。

因此,第一行在垂直计数 0–7 期间装入,8–15 期间重放;字模的第 0 行为空白,第一条可见字形扫描线位于 9。最后一行仍会在 192–199 输出,不能直接拿存储控制使用的 VBL 标志把这段像素抹掉。存储消隐、视频流水延迟、垂直同步各自有含义,即使它们都与画面的上下边界有关。视频时序说明

光标也进入同一条数据路径。模型通过字符地址位的门控,在空白光标位置产生闪烁的 @ 字形;TUI 绘制的下划线光标则属于宿主界面。这种区分让两类使用者都能得到合适的接口,又不需要让界面风格参与硬件计算。

字模采用有明确授权的 P-Lab 2513 替换字模。项目记录了文件哈希、转换方式与许可证,也明确保留了一个边界:尚未完成所有 64 个字形与目标原板掩膜的逐点独立认证。可追溯的字模身份,比笼统声称“原版字体”更有用。

RESET 的边界画在哪里

理解机器状态,有时从复位最容易入手:哪些东西会消失,哪些仍然留下?

Hesper 的板级 RESET 同时驱动 CPU 与 PIA。PIA 寄存器被清零,CPU 进入物理 RESET 流程;RAM、显示内容与视频时序保留。宿主队列里尚未读走的键也保留,并重新安排选通,避免复位导致丢键或重放已读按键。

CLEAR SCREEN 则直接作用于显示与视频状态,清空内容、归位光标,不运行 CPU 周期。当前实现把它视为一次原子操作,没有模拟真实按钮持续按下时的全部脉冲行为。

机器重新构造又是第三种动作:它创建新的状态,并由宿主重新装载固件与程序。把这三者分开,终端上的“复位”“清屏”“重新启动”才有准确的含义。RESET 与机器状态测试

这种边界也延伸到执行接口。宿主可以把同一段主时钟预算拆成不同大小的批次,只要在同一主时钟位置注入相同事件,机器结果就应一致。项目为普通运行以及包含输入、RESET 的时间线分别设置了批次无关性测试。

于是,终端事件循环和浏览器调度可以选择自己的工作批量。它们负责什么时候给模拟器计算时间,机器负责这段时间里发生什么。Wasm 绑定复用同一台机器,并为单次 runTicks 设置预算上限;它没有另外维护一套浏览器版硬件逻辑。Wasm 接口

精确性需要说到具体处

读完这些代码,很容易把“周期精确”扩写为对整台机器的总体评价。更有解释力的做法,是指出哪些行为已有模型与证据,哪些仍是工程约定。

层面当前实现与边界
总线与 PIA实现地址镜像、寄存器选择、读副作用和输出握手;代码类型名为 Pia6821,原板标注 MC6820,不能据此混同两者全部电气特性
板级时序用主时钟推进分频、刷新门控与垂直重载;未模拟 DRAM 单元的真实电荷衰减
显示与视频循环存储、行缓冲、像素移位和同步具有明确先后顺序;未求解模拟视频电路的传播、串扰与负载
未映射总线确定性返回最后一次总线驱动值,方便复现;真实浮空读值没有这个保证
输入与清屏FIFO、确定性初态和原子清屏属于宿主或模型约定,不能当作原板上电及按钮行为的认证

例如 nominal_millivolts() 给出同步 0 mV、黑 300 mV、白 1000 mV,便于宿主消费数字采样。它没有求解输出晶体管、电位器和负载,因此这些数值不应被写成原板实测电压。

项目的硬件证据矩阵把原始资料、原图推导、实现关系与模拟约定分别记录下来。这使后续改进有了具体目标:可以加强某一条信号路径或补足某项独立验证,而不必围绕一个含糊的“还原度百分比”争论。

为核对本文,2026 年 9 月 16 日在上述提交执行了:

cargo test --locked -p hesper-apple1
cargo test --locked -p hesper-apple1 --release

两种构建各通过 109 项测试:64 项单元测试、25 项机器集成测试、11 项时序测试、5 项视频测试,以及 4 项真实 Woz Monitor 联调测试。后者覆盖启动提示符、查看内存、写入内存和执行程序。

这些结果证明本文所依赖的本地回归通过;它们不等于本次重新完成了全工作区验证、CPU 全外部语料验证、远端 CI 或原板电气认证。尤其值得看的,是那些让错误模型露出区别的测试:刷新期间终端仍能接收字符、接收前改变数据线、旧行缓冲输出不能被新值穿透、复位不能重放已经读走的键。

留住一台机器的因果关系

Hesper 的 Apple I 实现有一种克制:CPU 不认识终端,终端不需要知道用户使用 TUI 还是浏览器,数字视频也不借用宿主已经排好的字符画面。各层通过总线访问、时钟沿和少量明确的信号相接。

这些接口之所以成立,是因为它们把“谁拥有状态、谁推进时间、谁可以确认一次事件”交代清楚了。CPU 写了一个字符,终端仍有权等待;CPU 暂停访问,视频仍会前进;按下 RESET,屏幕仍然记得此前发生过什么。

再回到最初那个迟到的 A。从程序的写指令到屏幕的第一个亮点,中间留下的每一道边沿、每一次等待,都让实现能够回答一个更具体的问题:此刻,为什么会发生这件事。