8-bit 计算机搭建笔记(二):点亮第一盏流水灯

8-bit 计算机搭建笔记(二):点亮第一盏流水灯

从第一次上电、排查电路问题,到用总线监视器监测,最终用汇编程序点亮第一条流水灯。 Light up the first flowing lamp from first power on, troubleshooting circuit, bus monitoring, to programming in assembly.

前言

在 8-bit 计算机搭建笔记(一):从零开始构建 中,已经完成了 PCB 的焊接工作。接下来便是上电测试了。此前在原理图和 PCB 设计阶段进行的各种理论分析,也将在这一刻接受真正的检验。对于第一次独立电路设计,只要元件没有着火爆炸基本成功了一半。

在正式通电之前,还是需要对电路进行一次基本检查。首先检查电源和 GND 之间是否存在明显短路,然后确认各个芯片的安装方向、型号以及插座位置是否正确,同时检查 PCB 上有没有明显的焊接问题。对于 CPU、RAM、ROM 等主要芯片,还需要再次确认它们的电源引脚和总线连接没有接错。

可以先使用万用表对电源与 GND 之间进行测量。此时电路板应当处于断电状态,万用表可以拨到电阻档,将黑表笔接 GND,红表笔接 5V 电源。正常情况下,两者之间不应该表现为近似 0 Ω 的低阻状态。如果测得的阻值非常低,或者随着测量时间推移仍然稳定在很低的数值,就需要进一步检查电源部分是否存在焊接短路、芯片安装错误等问题。

检查完成之后,接入电源进行第一次测试。实际结果比预期要顺利得多:电源电压正常,没有冒烟,电容也没有爆炸。不过很快就发现了一个小问题:RESET 和 NMI 信号的有效电平与最初设计时的预期相反。结果就是,如果松开 RESET 按键,CPU 无法正常保持运行,只有一直按住 RESET 按键,系统才能维持在预期的工作状态。

排查之后发现,最初按钮采用的是上拉电阻的设计,按下按键时产生低电平,松开按键后则恢复为高电平。为了进行按键消抖,这个信号随后经过了施密特触发反相器,因此输出的电平极性也随之发生了反转。原本的设计只进行了这一次反相,而按照 CPU 对 RESET 和 NMI 信号有效电平的要求,这里实际上还需要再经过一次反相,或者将按键电路改成下拉结构。

由于当时 PCB 已经制作完成并且焊接完毕,因此没有再新打样电路板,仅修改了原理图。好在这个问题只影响复位和 NMI 的正常使用,并没有影响 CPU、RAM、ROM 以及其他总线逻辑的工作。只是得一直按住 RESET 按键,才能让系统进入正常的运行状态。

这个问题也算是第一次实际调试中比较直观的一次教训:原理图上的逻辑关系即使看起来没有问题,也需要结合器件实际的有效电平以及整个信号链路进行检查。

开发环境与工具链

在开始之前先简单提一下开发工具链的配置。本项目采用 vasm 作为汇编器,并通过 VS Code 的任务系统执行生成任务。vasm 是一款支持多种处理器架构的汇编器,其中的 vasm6502_oldstyle 用于汇编 6502 系列处理器的代码。本项目使用的 CPU 是 W65C02S,因此需要启用 CMOS 6502 指令集支持,确保汇编器能够识别相应的扩展指令。

为了避免每次编译都手动输入命令,可以在 VS Code 的 .vscode/tasks.json 中配置一个生成任务。以下是本项目使用的配置:

JSON
{
    "label": "Build 6502 ROM (vasm)",
    "type": "shell",
    "command": "vasm6502_oldstyle",
    "args": [
        "-Fbin",
        "-dotdir",
        "-c02",
        "-o",
        "${workspaceFolder}/build/${fileBasenameNoExtension}.bin",
        "${file}"
    ],
    "group": {
        "kind": "build",
        "isDefault": true
    },
    "problemMatcher": {
        "owner": "6502",
        "fileLocation": ["autoDetect", "${workspaceFolder}"],
        "pattern": {
            "regexp": "^(fatal error|error|warning)\\s+(\\d+)\\s+in\\s+line\\s+(\\d+)\\s+of\\s+\"(.*)\":\\s*(.*)$",
            "severity": 1,
            "code": 2,
            "line": 3,
            "file": 4,
            "message": 5
        }
    },
    "detail": "用 vasm 把当前 .s 文件汇编成 build/<name>.bin"
}

这里有几个值得说明的参数。

  • command 指定要执行的汇编器命令。需要提前将 vasm 所在目录加入 Windows 的 PATH 环境变量,或者在这里填写可执行文件的完整路径。
  • -Fbin 指定输出格式为原始二进制文件。生成的 .bin 文件不包含额外的文件头,适合用于后续写入 ROM。
  • -dotdir 允许使用以点号开头的伪指令,例如程序中的 .org 和 .word。
  • -c02 启用 65C02 指令集支持,以匹配本项目使用的 W65C02S。
  • -o 指定输出文件路径。这里将编译结果放在工作区的 build 目录中,并使用当前源文件的名称作为输出文件名。
  • ${file} 表示当前在 VS Code 中打开的文件。这样,同一个任务就可以用于编译不同的汇编源文件。

配置中的 group 将该任务设为默认生成任务,因此在 VS Code 中执行「运行生成任务」时,可以直接选择它,也可以使用默认生成任务的快捷键 Ctrl+Shift+B 启动编译。

problemMatcher 则用于识别 vasm 输出的错误信息,并尝试将错误位置关联到源代码。编译失败时,可以直接从 VS Code 的终端输出中检查问题;如果错误格式与正则表达式不匹配,问题面板可能无法正确定位错误,但不影响汇编器本身的编译过程。

需要注意的是,build 目录必须预先存在,因为这份任务配置并不会自动创建输出目录。另外,-Fbin 只决定文件格式,并不会自动将二进制文件填充到完整的 32 KB ROM 容量。实际生成的文件大小取决于汇编器输出内容及源代码的布局,因此在写入 ROM 之前,还需要确认生成文件的大小、地址布局和中断向量是否符合电路设计的要求。

Arduino 总线监视器

当初在设计 PCB 的时候,为了方便调试,特意设计了直接连接到地址和数据总线,以及一些控制信号如 RWB, PHI2, SYNC 等上的排母。程序通过时钟信号的上升沿触发 Arduino 的中断函数,并打印此时的地址、数据以及读写情况。

这套程序的基本思路参考了 Ben Eater 的实现,根据实际需求稍作了一些修改。内容如下:

C
#include "w65C02s_lookup.h"

#define CLK 2   // Clock signal
#define RW 3    // Read/Write signal
#define SYNC 4  // Synchronize signal

const uint8_t DATA[] = { 22, 23, 24, 25, 26, 27, 28, 29 }; // Data pins
const uint8_t ADDR[] = {
        // Address pins
        30, 31, 32, 33, 34, 35, 36, 37,
        38, 39, 40, 41, 42, 43, 44, 45
};

void setup() {
        for (int i = 0; i < 16; i++) pinMode(ADDR[i], INPUT); // Initialize address pins
        for (int i = 0; i < 8; i++) pinMode(DATA[i], INPUT);  // Initialize data pins

        pinMode(CLK, INPUT);
        pinMode(RW, INPUT);
        pinMode(SYNC, INPUT);

        // Interrupt while clock pin is on the rising edge
        // While the interrupt is triggered, call the ISR (Interrupt Service Routine)
        attachInterrupt(digitalPinToInterrupt(CLK), OnClock, RISING);

        Serial.begin(57600);
}

// An ISR function, call while interrupt is triggered
// Each clock cycle run once
void OnClock() {
        char output[35];

        // Print address pins' output
        uint16_t addr = 0;

        for (int i = 0; i < 16; i++) {
                int bit = digitalRead(ADDR[i]) ? 1 : 0;
                Serial.print(bit, DEC);

                addr |= ((uint16_t)bit << i);
        }

        Serial.print("    ");

        // Print data pins' output
        uint8_t data = 0;

        for (int i = 0; i < 8; i++) {
                int bit = digitalRead(DATA[i]) ? 1 : 0;
                Serial.print(bit, DEC);

                data |= ((uint8_t)bit << i);
        }

        Serial.print("    ");

        bool isRead = digitalRead(RW);
        bool isSync = digitalRead(SYNC);

        if (isSync) {
                char mnemonic[6];
                getMnemonic(data, mnemonic);
                sprintf(output, "%04x  %c  %02x  %s", addr, isRead ? 'R' : 'W', data, mnemonic);
        } else {
                sprintf(output, "%04x  %c  %02x", addr, isRead ? 'R' : 'W', data);
        }

        Serial.println(output);
}

W65C02S 的 SYNC 输出用于表示当前总线周期正在进行指令读取。当 CPU 从存储器中读取下一条指令时,SYNC 会变为高电平。因此,监视器可以利用这个信号判断当前数据总线上的内容是否是 CPU 正在读取的操作码。这样就可以避免单纯根据地址或者读写方向来猜测数据的含义。

确定当前数据是操作码之后,下一步就是把机器码转换成人类更容易阅读的助记符。这里采用了一个非常简单的查表方式,将 W65C02S 支持的操作码预先存放在 w65c02s_lookup.h 中:

C
#ifndef W65C02S_LOOKUP
#define W65C02S_LOOKUP

#include <avr/pgmspace.h>

const char LOOKUP[256][6] PROGMEM = {
	"BRK",  "ORA",  "???",  "???",  "TSB", ...
};

inline void getMnemonic(uint8_t opcode, char* buffer) {
    strcpy_P(buffer, LOOKUP[opcode]);
}

#endif

LOOKUP 数组使用操作码本身作为索引。例如,如果数据总线上的值为某个有效操作码,那么程序直接使用这个数值作为数组下标,就可以得到对应的助记符。对于尚未定义或者不支持的操作码,则使用 "???" 作为占位符。

由于 Arduino 的 SRAM 资源有限,这里还使用了 AVR 的 PROGMEM 将查找表放入程序存储器,而不是占用运行时 SRAM。getMnemonic() 再通过 strcpy_P() 将对应的字符串复制到临时缓冲区中。

不过,这种通过 Arduino 监视总线的方式与专业逻辑分析仪还是有明显区别。Arduino 更适合进行低速、简单的状态观察,而不是精确测量高速总线的完整时序。尤其是在 CPU 运行速度提高之后,Arduino 本身的 GPIO 读取速度、程序执行时间以及串口输出速度都可能成为限制。

为了方便调试,这次测试没有直接使用原本由晶振提供的 2 MHz 时钟,而是暂时换成了一个由 555 定时器构成的外部低速时钟。这个时钟利用了无稳态和单稳态,既可以通过按键手动发送单个脉冲,也可以通过电位器连续调整输出频率,从而可以根据实际测试需要降低 CPU 的运行速度,甚至逐个时钟周期观察总线上的变化。关于该计时器的详细介绍会在以后的文章中进行。

观察 CPU 的第一次运行

监视程序准备好之后,下一步就是实际观察 CPU 的运行情况。第一次测试使用了一个非常简单的 ROM 镜像:整块 EEPROM 全部填充为 NOP 指令。

NOP 的机器码是 $EA。它不会修改寄存器或内存,也不会产生额外的 I/O 操作,只会让 CPU 完成当前指令之后继续执行下一条指令。如果 CPU、ROM、地址总线和时钟都能够正常工作,那么在完成复位之后,就应该能够在总线上持续看到 $EA,同时地址不断向后递增。

首先,通过如下程序生成全是 $EA 的 32 KB 二进制文件:

Python
# Data to be written into the ROM
rom = bytearray([0xea] * 32768)

# Create a binary file
with open("rom.bin", "wb") as out_file:
    out_file.write(rom);

通过 Hexdump 检查确保里面全是 $EA。接下来再通过编程器将其烧录进 AT28C256.

烧录完成后,把 EEPROM 装回电路板,接上外部时钟,启动总线监视器,然后按下复位。监视器输出结果如下。前两列是地址与数据的二进制形式;后四列依次是十六进制地址、读写状态、数据,以及取指周期才会出现的助记符。重点关注后四列。可以看到,一开始监视器读取到了一些奇奇怪怪的数据;不过很快就能看到 NOP 有规律地出现。

监视器的输出结果。在经历一系列步骤以后,总线上开始有规律地出现 NOP。
监视器的输出结果。在经历一系列步骤以后,总线上开始有规律地出现 NOP。

这意味着至少可以确认几件事情:CPU 能够正常读取 ROM;同时地址总线也能够按照预期不断变化。不过,要理解这组输出究竟意味着什么,还有最开始「奇奇怪怪」的数据到底是什么意思,还需要先看看 W65C02S 是如何完成复位的。

W65C02S 的复位流程

对于 6502 系列 CPU 来说,复位并不是简单地把程序计数器设置成某个固定值,然后立即开始执行程序。CPU 需要完成一系列内部操作,才能开始执行程序。

在内部实现上,6502 架构的复位过程与中断处理具有高度相似的结构,可以看作一种特殊的中断序列。CPU 会按照固定的内部时序逐步完成复位操作。不过,与普通中断不同,复位过程中不会将当前处理器状态保存到堆栈,而是限制为只读操作,从而避免在系统尚未完成初始化时对内存造成写入。

根据手册,W65C02S 的复位需要 7 个时钟周期。可以大致分成三个部分:前面的内部初始化过程、中间三个对栈区域的访问,以及最后两个读取复位向量的周期:

  • 第 1 和第 2 周期:在标准中断时,CPU 利用这些周期读取当前操作码和状态寄存器,准备将它们备份;而复位时则会丢弃此前卡住的任何指令,并清空内部流水线。此时不关心地址总线上的内容。
  • 第 3,4,5 周期:在标准中断时,CPU 会利用这三个周期将程序计数器高字节、程序计数器低字节和处理器状态寄存器(P)写入堆栈内存空间;而复位时 CPU 仍会将地址总线驱动到堆栈指针区域,并依次执行这三个周期。然而,内部硬件会强制将 RWB 拉高(只读)。
  • 第 6,7 周期:获取复位向量的低字节和高字节。

W65C02S 规定复位向量位于地址 $FFFC–$FFFD。这两个地址位于 ROM 区域,因此 CPU 可以直接通过正常的内存读取周期取得其中保存的地址。复位向量储存的是一个 16 位的地址,其中 $FFFC 保存低字节,$FFFD 保存高字节。假设 ROM 中的内容为:

AutoIt
$FFFC = $00
$FFFD = $C0

那么 CPU 读取这两个字节之后,就可以得到:

TOML, also INI
PC = $C000

之后 CPU 就从 $C000 开始读取并执行程序。

这也是前面设计内存映射时,必须把 $FFFC–$FFFD 放在 ROM 区域的原因。CPU 并不知道我们的 ROM 是什么,也不知道程序应该放在哪里;它只是按照 6502 架构规定,在复位过程中从固定的地址取得 Reset Vector。如果这两个地址不能正常返回数据,那么 CPU 就无法得到正确的程序入口地址。

在这次测试中,由于 ROM 里面填满了 $EA,故 CPU 在读取复位向量的时候自然会读取到 $EAEA,因此程序会跳到该地址开始执行。

流水灯的实现

确认 CPU 能够完成复位,并且可以从 ROM 中连续执行 NOP 之后,下一步就可以开始运行一个真正具有输出效果的程序了。在前面的地址空间设计中,$8000–$BFFF 被分配给了 I/O。因此,控制 LED 点亮的方式即往该地址区间内写入数值。

程序的初始化

程序开头的两行并不是 CPU 指令,而是汇编器使用的符号定义和伪指令。

65C02 Assembly
LED_PORT = $8000
	.org $C000

LED_PORT = $8000 为地址 $8000 定义了一个名称。根据前面的硬件设计,LED 通过地址译码映射到了 I/O 空间中的这个地址,因此后续程序只需要使用 LED_PORT,就可以表示对 LED 输出端口的访问。

.org $C000 则告诉汇编器,从 $C000 开始安排接下来的程序代码。它本身不会生成一条让 CPU 执行的指令,也不会在运行时修改程序计数器。它影响的是汇编阶段生成的机器码在存储器地址空间中的布局。

65C02 Assembly
RESET:
	SEI					; Disable interrupts
	CLD					; Clear decimal mode
	LDX #$FF			; Initialize stack pointer
	TXS					; Transfer the X register to the Stack pointer register

	LDA #%00000001		; Initialize flow lamp style

程序中像 RESET, LOOP, DELAY 等的称作标签(Label),用来标记代码中的某个位置。它本身不占用存储空间,也不会生成机器指令。汇编时会被替换成对应的地址。

复位向量只回答了「从哪里开始执行」,并没有回答「以什么状态开始执行」。CPU 从 $FFFC/$FFFD 取得 $C000 之后,程序计数器有了确定的去处,但寄存器 A, X, Y 和堆栈指针 SP 的内容都没有被规定。因此 RESET 标签下的几条指令并不完成某个具体的输入输出功能,而是在为后续的程序执行建立必要的初始状态。

第一条 SEI 是让中断保持关闭。SEI 将 I 标志置位,使 CPU 屏蔽普通的 IRQ 请求。这里的目的不是让 CPU 永远不能处理中断,而是在程序刚开始运行时,先明确普通中断的处理状态。

当前的最小系统还没有复杂的中断驱动逻辑,因此暂时禁止 IRQ,可以避免后续扩展中断功能时出现尚未准备好的中断处理流程。需要注意,SEI 不会屏蔽 NMI。W65C02S 的 NMI 属于不可屏蔽中断,即使 I 标志已经置位,NMI 仍然可以触发。

第二条 CLD 是清除十进制模式标志 D。要理解这条指令的作用,需要先了解 BCD 编码,以及 6502 系列处理器为什么会提供十进制运算模式。

BCD 是 Binary-Coded Decimal(二进制编码的十进制数)的缩写。在计算机刚刚诞生的年代,它是为了克服十进制与二进制相互转换的棘手难题才发明的,因为转换的运算需要非常复杂且占用大量空间的电路。

BCD 码并不是把十进制数直接转换成普通的二进制数,而是将十进制数的每个数位(0~9)单独用一个 4 位的二进制数来表示。最常见的形式是 8421 码。一个字节的高、低四位各保存一个十进制数字,因此每个半字节可以表示 0 到 9。对照表如下:

十进制 BCD 码 十进制 BCD 码
0 0000 5 0101
1 0001 6 0110
2 0010 7 0111
3 0011 8 1000
4 0100 9 1001

因为 4 位二进制数可以表示 24=162^4 = 16 种状态(从 0000 到 1111),而十进制数字只有 0 到 9 十个数字,所以 BCD 码只取了前 10 种状态(0000 到 1001)来分别对应十进制的 0 到 9,剩下的 6 种状态(1010 到 1111)在标准的 BCD 码中是无效的。

例如,十进制数 18 的普通二进制表示为 0001 0010,而它的 BCD 表示则是将 1 和 8 分别转成它们的 BCD 码再组合起来:

  • 1 → 0001,
  • 8 → 1000,

所以 18 的 BCD 码就是 0001 1000.

6502 的 D 标志会改变 ADC(ADd with Carry)和 SBC(SuBtract with Carry)的含义。当 D = 1 时,加减运算按 BCD 编码逐位进行,而不是普通的二进制运算。假设要计算十进制的 18+25=4318+25=43:

在 BCD 码中:

  • 18 表示为 0001 1000;
  • 25 表示为 0010 0101;
  • 纯二进制加法 0001 1000 + 0010 0101 = 0011 1101(即十进制的 61,这显然不对)。

而在十进制模式下,以加法为例,CPU 检测到低四位大于 9,或产生进位时,会加上 6 修正结果,高四位同理。而在减法发生借位的情况下则是减 6。

回到当前的流水灯程序,它并不需要进行 BCD 运算。程序后续会使用 ASL 实现流动的效果。这属于位操作,与十进制调整无关。因此,初始化时执行 CLD,可以明确保证后续算术运算采用普通二进制模式。

接下来,程序开始处理堆栈。我们不妨先理解一下 65C02 中堆栈是如何运作的,会更容易理解这两条指令的作用。

对于 65C02 来说,函数或程序之间的嵌套、调用等,本质上是一次有去有回的跳转,CPU 既要跳到入口,又要在结束时能够正确返回,因此需要使用堆栈保存返回地址。当前流水灯程序不仅会调用延迟子程序,还会在延迟过程中保存累加器以及其他寄存器的内容。因此,堆栈必须有一个明确的初始位置,才能保证这些数据被保存到正确的内存区域。

6502 系列处理器的堆栈固定在地址 $0100–$01FF,共有 256 个字节。堆栈指针寄存器 SP 是一个 8 位寄存器,用来指示堆栈当前的位置。由于堆栈所在的页面是固定的,实际访问地址可以由固定的高字节 $01 与 SP 组合而成。

堆栈最重要的特点是后进先出(Last In, First Out, LIFO)。理解这一规则,可以把函数或子程序之间的嵌套调用想象成一段逐层向下延伸的楼梯。整个过程就像沿着楼梯逐层深入,再严格按照相反的顺序原路返回。CPU 需要记住每次调用结束后应该从哪里继续执行。每次新的子程序调用都会把新的返回地址压入堆栈,因此,最近一次调用对应的返回地址总是最先被取出。这正是 LIFO 规则在程序控制流程中的体现。

堆栈从高地址向低地址增长。初始化时,程序会将堆栈指针设为 $FF,对应栈顶。每次执行压栈操作时,CPU 先将数据写入当前堆栈指针所指向的地址,然后将 SP 减一;每次执行出栈操作时,则先将 SP 加一,再从新指向的地址读取数据。因此,随着数据不断压入堆栈,SP 的数值会逐渐减小;取出数据时,SP 则会逐渐增大。

回到初始化代码,LDX #$FF 将立即数 $FF 装入 X,随后执行 TXS,将 X 的值复制到堆栈指针 SP。之所以要绕一次 X,是因为 6502 没有把立即数直接装入 SP 的指令,只能通过 X 读写。堆栈指针就被初始化为 $FF,为后续的子程序调用和数据保存做好准备。

最后一条 LDA #%00000001 与前面三条性质不同。它没有改变 CPU 的工作方式,而是为主循环准备第一帧画面,即最低位的那颗 LED。把它放在初始化段,只是为了让进入 LOOP 时 A 中已经有一个可用的初值。

到这里,初始化阶段就完成了。程序已经明确了 LED 输出端口的地址,设置了 CPU 的基本状态,初始化了堆栈指针,并将第一个 LED 模式放入累加器 A。

程序的主循环

这一段可以概括为:把 A 中保存的 8 位图案送到 LED,等待一小段时间,再将图案左移一位,如此往复。

65C02 Assembly
LOOP:
	STA LED_PORT		; Writes current image into LED_PORT
	JSR DELAY			; Call DELAY subroutine
	
	ASL A				; Arithmetic shift one bit left
	BNE LOOP			; If A != 0 after shift, continue looping
	
	LDA #%00000001		; If A == 0, reset to $01
	JMP LOOP

LOOP 是主循环的标签,后续程序可以通过跳转指令返回这里继续执行。首先,STA LED_PORT 将累加器 A 的内容写入地址 $8000。由于这个地址连接了 LED 控制电路,写入的数据会决定哪些 LED 点亮。

写入完成后,JSR DELAY 将执行权交给延时子程序,LED 的状态在这段时间内保持不变,直到延迟子程序执行完毕并通过 RTS 返回主循环。这样做是为了避免 LED 状态变化过快,使人眼能够观察到逐颗移动的效果。该部分的具体实现稍后再介绍。

ASL A 将 A 的 8 位整体左移一位。以 %00000001 为起点,寄存器内的值依次变为 %00000010, %00000100,直到 %10000000。此时再次左移,最高位的 1 就会被移出寄存器,结果变为 %00000000。移位同时影响 C(进位标志,Carry Flag)与 Z(零标志, Zero Flag)。被挤出的最高位进入 C,移位结果是否为零反映在 Z 上。结果为零时,Z 置一;否则 Z 清零。

紧接着的 BNE LOOP 会检查零标志。BNE 是结果不为零时跳转(Branch if Not Equal),当 Z 为 0 时,程序跳回 LOOP,继续显示下一种 LED 状态;当 Z 为 1 时,程序不会跳转,而是继续执行下一条指令。

这时,LDA #%00000001 将累加器 A 重新设置为 %00000001,为下一轮流水灯循环做好准备。随后,JMP LOOP 无条件跳回主循环开头,重新写入 LED 端口并开始下一轮延迟和移位操作。

判断「是否移完」既可以看 Z,也可以看 C(例如改用 BCC(Branch on Carry Clear,C 清零时跳转)),两种写法是等价的。ROL(ROtate Left,循环左移)也可以实现类似的效果,不过由于它也会将最高位移入 C 中,因此出现短暂的全部熄灭,需要配合额外的检测逻辑,同时应用 CLC(CLear Carry flag)在初始化时和重装之后清零 C 以保证正确的显示效果。

程序的延迟

如果每次写完 LED 都立即移位,那么在 2 MHz 下,一次循环只消耗几微秒,八个 LED 的变化会快到肉眼无法分辨。因此需要在每次写入之后插入一段等待,让每一帧停留到人眼可以看清的时间。

W65C02S 没有可用的定时器,所以实现等待的方法是重复执行一组指令,利用指令执行所消耗的时钟周期来等待:

65C02 Assembly
DELAY:
	; Push into the stack
	; Note: The original 6502 cannot push X and Y directly, so
	; their values are transferred through A for portability.
	PHA					; Push accumulator on stack
	TXA					; Transfer the X register to the Accumulator
	PHA
	TYA
	PHA

	LDX #$FF			; Outer loop counter
DELAY_OUTER:
	LDY #$00			; Inner loop counter, #00 will become #FF when decrement by 1
DELAY_INNER:
	DEY					; Decrement Y by one
	BNE DELAY_INNER		; If Y != 0, jump to DELAY_INNER until Y == 0
	DEX					; Decrement X by one
	BNE DELAY_OUTER		; If X != 0, jump to DELAY_OUTER until X == 0

	; Pop out from the stack
	PLA					; Pull Accumulator from stack
	TAY
	PLA
	TAX
	PLA

	RTS					; Return from subroutine

首先看开头的几条指令。这几步操作就是将 A, X, Y 中的值压入栈,这样就可以腾出这几个寄存器来读写新的值。6502 中 X, Y 无法直接压栈,因此只能先把值传递给 A,再将 A 压栈。而在 65C02 中新增了 PHX, PHY 指令,因此针对这款 CPU 压栈可以简化成:

65C02 Assembly
	; Push into the stack
	PHA
	PHX
	PHY

之所以需要这样做,是因为 DELAY 接下来会使用 X, Y 构成嵌套循环。如果不先保存它们,子程序执行结束后,主程序中的寄存器值就可能被覆盖。

DELAY_INNER 是内层循环,以 Y 为计数器。DEY 将 Y 的值减 1。Y 是一个 8 位寄存器,因此从 $00 减 1 后会回绕到 $FF,即十进制的 255。此时结果不为零,BNE 就会跳回 DELAY_INNER,继续执行 DEY,直到减到 $00,此时程序离开内层循环。

外层循环 DELAY_OUTER 的原理也与之类似。X 初值为 $FF,直到减为 $00,结束延时。接下来再从堆栈中恢复数据。请注意,出栈顺序应与入栈的完全相反,这正是前文中提到的 LIFO 规则。65C02 也新增了出栈的指令 PLX 和 PLY,因此可以简化成:

65C02 Assembly
	; Pop out from the stack
	PLA
	PLX
	PLY

最后,RTS 从堆栈中取回 JSR DELAY 保存的返回地址,并让程序继续执行主循环中调用延迟子程序之后的指令,也就是 ASL A。

程序的中断向量表

程序的最后几行代码与流水灯的具体逻辑无关,而是用于设置处理器的中断向量表:

65C02 Assembly
; Interrupt vector table
	.org $FFFA
	.word RESET			; $FFFA-$FFFB: NMI vector
	.word RESET			; $FFFC-$FFFD: RESET vector
	.word RESET			; $FFFE-$FFFF: IRQ/BRK vector

.org $FFFA 是汇编器伪指令,用于将接下来生成的内容放到地址 $FFFA 开始的位置。

.word RESET 则用于在当前位置写入标签 RESET 所代表的 16 位地址。由于前面已经定义了 RESET 标签,汇编器会将它解析为实际的程序入口地址。此前已经介绍过复位向量,而 NMI 和 IRQ/BRK 也与之类似,只是存放的地址不同。

在当前程序中,这样安排意味着 NMI、RESET 和 IRQ/BRK 三种向量都指向 RESET 标签。对于本例的简单流水灯程序,这种设置可以让 CPU 在复位后进入初始化流程,也能让这些向量指向一个已知入口。不过,这并不意味着三种事件的处理方式完全相同:如果程序真正使用中断,就通常需要为 NMI 和 IRQ/BRK 编写专门的中断处理程序,并正确处理相关的中断状态。

至此,流水灯程序的四个部分——初始化、主循环、延时和中断向量表都已说明。把它烧录进 ROM 之后,就可以观察实际的运行效果了。

实际运行效果

实际运行效果。

监视器的输出。为了方便看清楚,使用了外部低速时钟信号。

总结

通过这次流水灯实验,我们完成了从硬件电路到软件控制的初步验证。程序能够将数据写入映射到 $8000 的 LED 控制端口,通过移位操作依次改变输出状态,并利用循环和延迟子程序控制 LED 的变化节奏。

虽然程序本身并不复杂,但它涉及了 6502 汇编编程中的多个基础概念:寄存器初始化、条件分支、子程序调用与返回、堆栈的使用,以及复位和中断向量的设置。这些内容不仅是编写流水灯程序所必需的,也是后续开发更复杂软件的基础。

至此,这台基于 W65C02S 的计算机已经能够运行一个完整的汇编程序。接下来会继续完善硬件和软件环境,逐步加入更多外设与功能,让它从一个能够执行简单指令的实验平台,发展成一台具备实际交互能力的 8 位计算机。