迈克尔·马丁软件实验的综合网站
菜单包含以下内容:
- 主页
- 平台指南
- 项目
- 技术方法
ZX Spectrum 系统之旅:文本模式
既然我们已经了解了 Spectrum 的 BASIC 对硬件的控制方式,现在要深入研究机器语言,看看它能带来什么。这一领域内容丰富,预计会分多篇文章介绍。
与其他平台相比,这更具挑战性,因为 Sinclair 不太关注提供一致且结构化的固件接口。Commodore 的 8 位计算机从最初的 PET 到 C128,在其“KERNAL”ROM 末尾都有向后兼容的跳转表,很多机器语言代码在进行文件处理和磁盘访问等操作时运行方式相同。MSX 是制造商遵循的行业标准,靠近中断向量处有定义明确的跳转表。IBM PC 的 BIOS 也有类似作用,但没那么主动。
而 Spectrum 只有少数定义好的入口点,开发者常直接调用系统 ROM 内部深层代码来实现目标。Timex Sinclair 2068 的 ROM 不兼容,几乎没有 Spectrum 软件能在它上面运行,这似乎是其在美国市场失败的主要原因。不过,MSX、Commodore 16 和 Coleco Adam 在美国市场也惨败,它们是 TS2068 的直接竞争对手。鉴于 128 型在 2068 型失败后推出,且 128 型谨慎保持其 ROM 相关部分与 48K 版本兼容,推测 Sinclair 从中吸取了教训。
本系列文章以 48K Spectrum 为目标平台,也会确保介绍的技术方法适用于 16K 和 128K 机型。Spectrum 有大量克隆机和后续机型,质量不一,若遇到相关有趣内容会顺带提及,但属于旁支内容。
首次探索将研究如何实现像上周展示的纯 BASIC 游戏《Manic Mechanic》那样的显示效果:
第一篇文章先了解基本的系统组织,包括机器语言程序在系统中的存在、加载和运行方式,以及它们与 BASIC 和 BIOS 的共存方式。接着深入研究系统中基于文本的显示功能,包括《Manic Mechanic》依赖的“用户自定义图形”功能。最后探讨键盘和游戏手柄输入,发现这与在 BASIC 中操作相似。
基本原则:运行代码
刚开始探索 ZX Spectrum 时,惊喜地发现 BASIC 能与机器代码轻松共存。许多标准 BASIC 命令有额外模式,适用于纯机器代码程序和 BASIC/ML 混合程序。
- 磁带上的文件会标识自己是 BASIC 代码、BASIC 数据还是二进制数据。BASIC 的`SAVE`和`LOAD`命令有扩展选项,可指定加载内容,类似其他 BASIC 中的`LOAD`和`BLOAD`命令。从磁带上加载机器代码,需在文件名后加关键字`CODE`。
- BASIC 可通过向`CLEAR`命令传递参数限制其内存使用。将 BASIC 的内存上限降低到机器代码加载点以下,可避免内存冲突。
- BASIC 使用`USR`函数调用机器代码子程序。该函数以子程序地址为参数,子程序执行完任务后用`RET`指令返回 BASIC。`BC`寄存器对的值会作为 16 位无符号整数返回给 BASIC 作为函数值。无有意义返回值的子程序通常会将其传递给随机数生成器处理,Sinclair BASIC 中的`RANDOMIZE USR`与 C64 BASIC 中的`SYS`作用大致相同。
除保存二进制数据范围,`SAVE`函数还可保存 BASIC 程序,使其在`LOAD`后立即开始运行,且允许从任意行号开始执行。2017 年为 Spectrum 程序创建了更复杂的 BASIC 存根并沿用至今,总体策略如下:
- 保存 BASIC 程序,使其从第 30 行而非第 10 行开始执行。
- 第 30 行代码用`CLEAR`命令限制 BASIC 内存,然后将磁带上的下一个文件(实际程序)作为二进制文件加载。加载完成后,跳转到程序顶部。
- 第 10 行和第 20 行用`RANDOMIZE USR`调用加载的机器代码,然后跳过加载器逻辑,使程序正常退出。
最终结果是,程序运行时执行其机器代码部分,加载时执行所有必要的准备工作。将其转化为可直接使用的打包脚本超出本次探索范围,仅提供 关于它的原始文章链接 和 脚本本身的副本链接(若需要)。
机器代码视角下的 Spectrum
Z80 有 64KB 的地址空间,48K Spectrum 会全部使用。
- `$0000`-`$3FFF`是系统 ROM。
- `$4000`-`$5AFF`是图形 RAM,用于存储位图数据和颜色表。
- 其他部分是系统使用的 RAM。
16K 和 128K Spectrum 基本类似,不过 16K 的 RAM 到`$7FFF`结束,128K 的 ROM 和 RAM 都采用了存储体切换技术。
编写汇编语言代码时,不能使用系统 ROM 拥有的功能,包括与中断、影子寄存器和`IY`寄存器相关的内容。
`IY`寄存器较特殊,虽不能修改,但可读取(值始终是`$5C3A`),还可用它进行索引。它保存着 Sinclair 所称的“系统变量”的基地址,这些变量是关于当前系统状态的数据集合,常需读取和写入。这些变量的位置在不兼容的变体之间比 ROM 更一致,做第一个项目时大量依赖了这一点。
实际上,ROM 对此不严格,常直接通过地址引用系统变量,也会根据需要使用`IY`进行索引来访问。索引操作成本高但更灵活,在明确知道基地址又有索引寄存器的情况下,会在使用绝对地址和索引访问之间切换。编写代码时会让所有系统变量访问都通过`IY`进行,但通常并非如此(自己在 《Spectrum 平台指南》 中也没这样做)。
输出文本
在 BASIC 探索中可知,Spectrum 将屏幕分为 22 行的上窗口用于主显示,2 行的下窗口用于输入和状态消息。这并非 BASIC 特性,在机器代码层面也存在。要将文本输出到上窗口,需配置系统变量,告诉其输出例程将文本输出到那里,可通过向`IY+2`处的`TVFLAG`写入 0 实现。完成后,可调用地址为`$10`的 ROM 例程来打印`A`中的字符。Spectrum 使用 ASCII 编码,普通文本很熟悉。
这使“Hello World”程序变得简单:
org $7000
ld (iy+2),0 ; 写入上屏幕
ld hl,msg
lp: ld a,(hl)
inc hl
or a
ret z
rst $10
jr lp
msg: db "Hello, world!",13,0
通常将`$7000`作为起始地址,因为它深入到 RAM 中,能给 BASIC 留出足够空间,也为较小项目留出空间,使其能在 16K 机器上运行。使用类似`sjasm hello.asm hello.bin`的命令来汇编这个程序,并用`spectralink.py hello.bin 7000`进行打包,然后将`hello.bin.tzx`加载到 FUSE 中……成功了!
定义和使用图形字符
使用 BASIC 时,有两种图形字符:内置的半图形字符和一套可重新编程的字符,BASIC 称之为“用户自定义图形”。可通过切换到“图形输入模式”,按 1 - 8 键输入半图形字符(必要时使用反显模式以获得全部 16 种可能的组合),按 A - U 键输入用户自定义字符。
在这方面,机器语言比 BASIC 稍容易。“图形模式”是输入系统的特性,摆脱直接输入所需字符的限制后,可直接将扩展字符传递给地址为`RST $10`的字符打印例程。
使用预定义的半图形字符有好处:反显问题是因键盘按键不足采取的不太方便的解决办法。实际上,在`$80`-`$8F`范围内,可立即使用全部 16 种可能的半图形字符。它们排列合理,第 1、2、4 和 8 位分别对应字符的右上、左上、右下和左下象限。
用户自定义图形类似,它们占据`$90`-`$A4`范围,从图形 A 到图形 U(128K 机型少两个字符,只提供到`$A2`)。字符集其余部分直到`$FF`是 BASIC 关键字的缩写形式(这就是 128K 机型少几个位置的原因,`SPECTRUM`和`PLAY`命令就出现在那里)。
用户自定义图形的确切位置因系统而异,但在`IY+65`处有一个名为`UDG`的 16 位指针指向它们。这正是 BASIC 函数`USR "A"`返回的值,由于 BASIC 本身将其视为一个可`POKE`的缓冲区,在机器代码中也可这样做。下面是一个简单的加载例程,假设在`gfx`处有`num_chars`个字符的数据:
ld hl,gfx
ld e,(iy+65)
ld d,(iy+66)
ld bc,num_chars*8
ldir
这些在 BASIC 中也适用,可使用`CHR$`打印出所有图形字符,而无需切换到图形或反显模式。
让文本更美观
上周研究了七个命令,可单独使用,也可融入`PRINT`和`DRAW`命令中,以控制位置、颜色和闪烁文本等行为。字符打印例程会自动处理大部分功能,但协议与 BASIC 略有不同。
基本思路是,通过打印特殊控制代码来重现对文本属性的内联更改。字符代码`$10`到`$17`复制了上周看到的文本控制命令,依次代表:`INK`、`PAPER`、`FLASH`、`BRIGHT`、`INVERSE`、`OVER`、`AT`和`TAB`(上周没讨论`TAB`,因为它是标准 BASIC 命令,并非 Spectrum 特有,但实际也是这样实现的)。除`AT`需要两个字节作为参数外,其他命令都只需一个字节作为参数。这些命令的工作方式与 BASIC 中完全相同,包括`PAPER`和`INK`中颜色 8 和 9 的特殊透明/高对比度模式,以及`AT`参数按行、列顺序排列,左上角为 (0,0)。
与 BASIC 不同的是,这些命令在`PRINT`语句结束后不会重置。即使打印换行符(在这个系统中是单纯的回车符`$0D`)也不会恢复初始状态。系统对“永久”和“临时”字符属性有区分,但这与 ROM 的内部机制紧密相关。要充分利用这些特性,需深入研究 Sinclair 最初未期望关注的内容。
更深入地与系统 ROM 协作
系统变量中有六个字节与文本显示交互:
- `IY+85` 处的 `ATTR-T`:“临时”文本属性。使用`RST $10`指令打印字符时,该变量中的字节会被复制到颜色内存中的相应单元格,以设置其颜色、亮度和闪烁效果。
- `IY+86` 处的 `MASK-T`:“临时”属性掩码。实际上,`ATTR-T`中的字节并非完全复制。该字节中设置的任何位在复制过程中都不会改变。这就是`PAPER 8`和`INK 8`的工作原理,它们屏蔽了该变量的相关部分,打印例程会保留颜色单元格的这些部分不变。
- `IY+83` 和 `IY+84` 处的 `ATTR-P` 和 `MASK-P`:“永久”文本属性和掩码。在 BASIC 中使用`INK`和`PAPER`等命令作为独立命令时,这些值会更新,而`PRINT`语句首先会将它们复制到临时位置。这就是临时颜色更改具有临时性的原因。清屏指令也会参考`ATTR-P`来决定将屏幕清除为什么颜色。
- `IY+87` 处的 `P-FLAGS`:非颜色文本控制。`OVER`和`INVERSE`修饰符不属于文本属性,特殊的`INK 9`和`PAPER 9`模式所需的逻辑也未直接包含在`ATTR`或`MASK`中。这些值作为单个位存储在这里,临时值存储在偶数位,永久值存储在奇数位。这种交错排列允许系统 ROM 通过一些紧凑的位操作将永久颜色复制到临时颜色。
- `IY+14` 处的 `BORDERCR`:边框颜色。写入该值实际上并不会设置边框颜色,边框颜色是通过向 I/O 端口`$FE`的低三位写入值来控制的,但系统 ROM 在重置边框本身时会参考它,更重要的是,在设置下输入窗口的颜色属性时也会参考它。该字节中的值存储在第 3 - 5 位;在存储之前,需要将颜色值乘以 8。
同样,这些都不是任何正式 BIOS 接口的一部分,使用系统 ROM 功能在某种程度上假定手头有一份带注释的 ROM 反汇编文件。幸运的是,有 skoolkid 网站上的超链接版本,能有个大致了解。
通过字符打印代码访问的内容永远不会触及永久数据。不过,已发布的反汇编文件中提到,在`$0D4D`处有一个内部 ROM 例程`TEMPS`,它会将所有永久值复制到临时位置,实现属性重置。该函数无需参数,但会破坏`A`和`HL`寄存器,不过可在需要时随时调用。
永久属性主要由清屏函数`CLS`使用,这是一个位于`$0D6B`的 ROM 例程。这些属性用于填充上窗口的属性单元格,下窗口的值来自`BORDERCR`以及一些基本复制`INK 9`功能的逻辑。它还会将临时颜色重置为永久颜色。令人恼火的是,尽管在这里检查了`BORDERCR`,但它实际上并不会设置边框。更令人恼火的是,它会将输出位置重置到下窗口。
可通过自己编写一个辅助函数来解决这些问题。这个`clrto`函数会将`A`中的属性字节作为参数,将整个屏幕清除为该颜色,包括上窗口、下窗口和边框。它还会清除可能开启的任何非颜色模式,并在结束时将输出恢复到上窗口。可使用`TVFLAG`来实现,但既然直接调用系统 ROM 例程,不妨“正确”地操作,实际打开上窗口的输出通道。位于`$1601`的`CHAN-OPEN`例程用于选择要发送字符的输出设备,设备 2 就是上窗口。完整的`clrto`函数如下:
clrto: ld (iy+83),a ; ATTR-P
and $38
ld (iy+14),a ; BORDERCR
rrca
rrca
rrca
out ($fe),a
xor a
ld (iy+84),a ; MASK-P
ld (iy+87),a ; P-FLAGS
call $0d6b ; CLS
ld a,2
jp $1601 ; CHAN-OPEN
Spectrum 的文本系统还有个小问题。若打印的文本过多,导致屏幕需要滚动,默认情况下它不会向下滚动。相反,它会暂停输出,等待用户确认是否真的希望屏幕滚动,或者是否希望结束程序。`IY+82`处的系统变量`SCR-CT`用于衡量在再次提示之前要打印多少行;若希望屏幕连续滚动,可定期将其重置为一个较大的正数。
读取按键和进行 I/O 操作
BASIC 提供了两种读取输入的选项:`INKEY$`函数用于检查是否有按键等待处理,以及使用`IN`函数直接与键盘和游戏手柄交互。在机器代码层面也有对应的操作。
每一帧都会触发一个中断,由固件进行拦截。其中断处理程序会更新一个计时器(`FRAMES`,位于`IY+64`处的一个 3 字节值),并扫描键盘端口。如果检测到按键按下,会将其解码为一个字符,将该字符放入`IY - 50`处的`LAST-K`中,然后设置`IY+1`处`FLAGS`的第 5 位。以下是一个简单的例程,用于等待按键按下,然后将其值返回给`A`:
getkey: halt
bit 5,(iy+1)
jr z,getkey
ld a,(iy-50)
res 5,(iy+1)
ret
在循环中使用`HALT`是因为只有中断处理程序会实际更新这些标志,当可以简单地等待下一次扫描时,就无需对这些标志进行自旋锁操作。
直接输入更简单,因为作为汇编语言程序员,可使用`IN`和`OUT`直接访问 I/O 总线。不过,有个小问题:与 BASIC 不同,指令集表明 I/O 地址只有 8 位,而不是 16 位。这实际上是 Zilog 的一个设计疏忽,但却成为了 Sinclair 依赖的一个特性:指令`IN A,(C)`和`OUT (C),A`会将整个`BC`寄存器放在地址总线上,而不仅仅是`C`。要检查 A 键是否被按下,可这样写:
ld bc,$fdfe ; BASIC 中的 IN 65022
in a,(c)
rra
jr nc,pressedA
Kempston 游戏手柄只使用 8 位端口地址,所以相关代码更简单。以下是一个完整的读取例程,将 x 轴偏移量返回给`L`,y 轴偏移量返回给`H`,开火按钮状态返回给`C`:
rdjoy: ld hl,0
ld c,h
in a,($1f)
rra
jr nc,1F
inc l
1 rra
jr nc,1F
dec l
1 rra
jr nc,1F
inc h
rra
jr nc,1F
dec h
1 rra
ret nc
inc c
ret
关于哪些端口对应哪些按键,以及各种游戏手柄布局的完整文档,可参考 Martin Korth 的 ZX Docs,其中有很多不错的表格。游戏手柄操作与 BASIC 基本相同,所以上周提到的各种注意事项仍然适用,但影响会稍小一些。模拟器在模拟 SHIFT 键时仍然有些不一致,但不再需要担心在同时移动和射击时意外触发`L BREAK INTO PROGRAM`错误。
整合所有内容
为总结学到的内容,将它们整合到一个程序中,实现与上次 BASIC 程序相同的横幅显示效果:
主程序基本上只是依次调用实现的各个功能,除了直接的键盘或游戏手柄读取:
org $7000
ld a,$20 ; 清除为绿色背景黑色文字
call clrto
ld hl,gfx ; 将我们的 4 个自定义字符加载到 $90 - $93
ld e,(iy+65) ; UDG
ld d,(iy+66)
ld bc,32
ldir
ld hl,msg ; 打印显示内容
call print
call getkey ; 等待按键按下
ld a,$38 ; 清除回白色背景黑色文字
jp clrto ; 完成后退出
`clrto`和`getkey`函数与上面列出的完全相同。不过,`print`函数需要与“Hello World”程序中的稍有不同。在之前的程序中,使用 0 字节作为字符串结束符,现在这样做不太合适,因为在显示中会在不同位置将`PAPER`和`BRIGHT`都设置为 0,这需要通过实际打印`CHR$ 0`字符来实现。解决方案是将隐式比较`A`是否为 0 的`OR A`指令替换为显式比较其是否为 255 的`CP $FF`函数。这是一个更安全的哨兵代码,它对应于 BASIC 关键字`COPY`,而实际上永远不会打印这个关键字。
print: ld a,(hl)
inc hl
cp $ff
ret z
rst $10
jr print
然后是数据本身:要打印的消息和雨伞的图形定义。消息数据大量使用了上面提到的文本控制代码,因此基本上难以阅读。图形数据与原始 BASIC 中的`DATA`语句完全相同,只是这次以十六进制形式呈现,就像 TI BASIC 中那样:
msg: db $10,4,$11,0,$16,10,2,$8b," ",$87
db $13,1,$10,1,$16,11,2," ",$90,$91,$10,7," BUMBERSHOOT SOFTWARE "
db $10,1,$16,12,2," ",$92,$10,6,$13,0,$93,$13,1,$10,7,"Showing off the Spectrum "
db $13,0,$10,4,$16,13,2,$8e," ",$8d,$ff
gfx: db $03,$0f,$1f,$3f,$7f,$7f,$ff,$ff
db $c0,$f0,$f8,$f8,$f0,$e0,$c0,$80
db $ff,$fe,$7c,$78,$30,$00,$00,$00
db $80,$c0,$60,$30,$18,$08,$38,$00
事实证明,这个程序比最初的 BASIC 程序小得多,即使考虑到 BASIC 存根和额外的磁带头,这个程序的大小也不到原来的一半。
本尼会笑到最后吗?
今天涵盖的内容足以想象一个纯机器代码版本的《Manic Mechanic》类型的游戏,上周将其作为示例。现在已经了解了如何重现主游戏显示,以及如何处理输入和动画。不过,仍然缺少声音功能,而且还无法实现标题屏幕:
像`DRAW`命令生成的线条图形在机器语言中实现起来比较麻烦,但还有一些 BASIC 根本无法比拟的技巧。请继续关注。
文章发布于 2026 年 5 月 30 日 [12:30 pm],作者是 迈克尔·马丁,分类为 复古编程、Sinclair。
文章导航:
<- 给 ZX Spectrum 一个公平的机会 插曲:Spectrum 与 C64 BASIC 对决 ->