用Arduino Uno模拟6502系统内存与总线,运行WozMon监控程序

发布时间:2026/8/19 4:38:00
用Arduino Uno模拟6502系统内存与总线,运行WozMon监控程序 1. 项目缘起为什么用Arduino Uno给6502“搭桥”如果你玩过复古计算机或者对微处理器历史感兴趣那么“6502”这个名字一定如雷贯耳。这颗诞生于1975年的8位CPU是苹果II、Commodore 64、任天堂红白机NES等一系列经典设备的“心脏”。而WozMon则是苹果联合创始人史蒂夫·沃兹尼亚克为苹果I设计的一个极简监控程序它允许用户通过一个串行终端与计算机进行最底层的交互——查看内存、修改内存、运行程序是那个时代硬件调试和编程的“瑞士军刀”。现在手头可能有一片65C02CMOS工艺的6502更省电、指令更丰富想让它跑起来重温那段“从零构建”的乐趣。但问题来了经典的6502系统需要ROM存放WozMon、RAM存放临时数据、时钟电路、复位电路还有至关重要的串行接口RS232来连接终端。对于只是想快速验证CPU、学习其工作原理的爱好者来说搭建一整套外围电路既繁琐又容易出错。这时Arduino Uno的优势就凸显出来了。它本质上是一个集成了MCUATmega328P、晶振、复位电路、USB转串口芯片的完整开发板。我们完全可以将Arduino Uno“降级”使用让它扮演6502系统的“外设管家”角色用它的GPIO模拟ROM和RAM用它的硬件串口通过USB转串口提供RS232终端连接。这个项目的核心就是用一块常见的Arduino Uno开发板为一片独立的65C02 CPU构建一个最小化的运行环境并成功引导WozMon让你能通过电脑上的串口终端软件如PuTTY、Tera Term与这台“自制”的6502计算机对话。这不仅仅是简单的连线更是一次对计算机启动流程Boot Process和总线通信Bus Communication的深度实践。你会清晰地看到CPU如何从特定地址对于WozMon是0xFF00取第一条指令如何通过地址总线、数据总线和控制总线与“内存”此处由Arduino模拟交互。整个过程Arduino Uno就像一座精心设计的桥梁连接了经典的65C02硬件与现代便捷的PC调试环境。2. 系统架构解析Arduino Uno如何扮演“全能外设”要实现这个项目首先得理解一个典型6502最小系统需要哪些部件以及Arduino Uno如何一一对应地实现它们。下图清晰地展示了整个系统的硬件互联与数据流逻辑flowchart TD subgraph A [现代PC调试端] direction LR A1[串口终端软件br如PuTTY] -- USB虚拟串口 -- A2[Arduino UnobrUSB转串口芯片] end subgraph B [Arduino Uno 核心模拟层] direction TB B1[ATmega328P MCU] -- B2[“模拟ROM/RAMbr存储WozMon镜像与数据”] B1 -- “硬件串口 (UART)” -- A2 end subgraph C [经典6502 CPU系统] C1[65C02 CPU] -- “16位地址总线br8位数据总线br控制信号R/W, PHI2” -- C2[“Arduino GPIObr模拟总线接口”] end A2 -- “TX/RX信号” -- B1 C2 -- “总线读写” -- B2 B -- “5V电源与地线” -- C style A fill:#e1f5fe style B fill:#f3e5f5 style C fill:#fff3e0从上图可以看出整个系统分为三个逻辑层次。我们的核心工作就是利用Arduino Uno中间层来桥接现代PC和经典65C02 CPU。2.1 核心需求与Arduino的对应关系一个能让6502运行WozMon的最小系统需要以下核心部件ROM只读存储器存放WozMon的二进制代码。WozMon非常精简只有256字节固化在0xFF00到0xFFFF的高地址区域。RAM随机存取存储器供WozMon使用的栈空间和临时变量区。通常需要几KB地址映射在低地址区如0x0000开始。串行接口UART/RS232用于与终端通信。需要电平转换如MAX232芯片将TTL电平转换为RS232电平。时钟源为6502提供稳定的时钟信号PHI2典型频率在1-2MHz。复位电路提供上电复位和手动复位信号。Arduino Uno如何满足这些需求ROM与RAM由ATmega328P内部的Flash和SRAM模拟。我们将WozMon的二进制代码作为一个常量数组const byte rom[] {...}存储在Flash中模拟ROM。同时在SRAM中开辟一个数组byte ram[8192]来模拟RAM。Arduino通过其GPIO口监听6502的地址总线当CPU访问特定地址范围时Arduino就通过数据总线返回读或接收写相应的数据。串行接口Arduino Uno自带硬件UART并且板载的ATmega16U2芯片负责USB转串口。我们直接使用Serial对象Arduino负责将来自6502的数据通过USB发送到PC终端并将PC终端输入的数据传递给6502。这里完全规避了传统的RS232电平转换电路因为USB本身就是现代标准。时钟源由外部有源晶振或时钟模块提供直接连接到6502的PHI2引脚。Arduino不负责产生时钟这是为了确保时钟信号的纯净和稳定。时钟模块是独立于Arduino的。复位电路一个简单的RC电路10k电阻上拉10uF电容下拉即可实现上电复位。也可以将复位按钮一端接地另一端接6502的RESET引脚。Arduino可以监控这个引脚状态但通常不参与控制。2.2 总线接口的引脚分配策略这是硬件连接的核心。6502有40个引脚我们需要连接其中最关键的三组总线地址总线A0-A1516根线决定CPU要访问哪个内存位置。我们需要用Arduino的16个GPIO来读取这些引脚的状态。数据总线D0-D78根线双向传输。CPU读数据时外部设备Arduino将数据放到这8根线上CPU写数据时CPU将数据放到这8根线上外部设备读取。我们需要8个GPIO并且必须配置为输入/输出方向可动态切换。控制总线最关键的两根R/WRead/Write高电平表示CPU读低电平表示CPU写。Arduino需要监听此引脚来决定数据流方向。PHI2时钟6502的工作时钟。Arduino需要在PHI2的上升沿或高电平期间稳定地采样地址线和R/W线并在恰当的时间放置或读取数据。注意PHI2由外部时钟模块驱动Arduino只是“监听”它。一个典型的引脚分配方案基于Arduino Uno的GPIO数量限制可能需要一些优化地址总线 A0-A7连接到 Arduino 数字引脚 2-9PORTD。地址总线 A8-A15连接到 Arduino 数字引脚 10-17其中17是A3实际是模拟引脚A3但用作数字IO。更常见的做法是使用两片74HC595或74LS164这样的移位寄存器来锁存高8位地址以节省Arduino的IO口。因为Arduino Uno只有14个数字IO6个模拟IO可作数字用全部分配给地址线后就没有多余的给数据线了。数据总线 D0-D7连接到 Arduino 的某一组8位端口如模拟引脚A0-A7它们可以作为一个8位端口PORTK来快速读写。必须配置为输入上拉模式在需要输出时才切换为输出模式。控制线 R/W连接到一个中断引脚如数字引脚2或3以便快速响应。控制线 PHI2连接到一个外部中断引脚如数字引脚2或3在时钟边沿触发中断进行总线操作。注意直接使用20根线连接会耗尽Arduino Uno的IO并导致程序效率低下。因此在实际高手项目中普遍采用“地址线锁存”方案。即只使用8根线AD0-AD7同时传输低8位地址和数据在PHI2的特定阶段用一个ALE地址锁存使能信号将地址锁存到外部锁存器如74HC573中。但经典6502没有ALE引脚。因此更实用的方法是使用两片74HC595串联通过3根线数据、时钟、锁存串行输出16位地址极大地节省了Arduino的IO资源。数据总线则独占8个IO。这是性能与资源占用的一个关键权衡点。3. 固件设计核心Arduino如何模拟内存与处理总线周期硬件连接是骨架Arduino的固件Firmware才是灵魂。它的核心任务是在每个6502时钟周期内快速完成“监听地址 - 判断读写 - 存取数据”这一套操作。由于6502在1MHz下时钟周期为1微秒留给Arduino的反应时间非常短因此代码必须极度优化。3.1 内存映射与模拟实现首先我们需要在Arduino的存储空间中定义出模拟的ROM和RAM区域。// WozMon 二进制代码通常从 0xFF00 开始 const uint8_t WOZMON_ROM[256] PROGMEM { 0xA2, 0x00, 0xBD, 0x00, 0xFF, 0x20, 0xEF, 0xFF, // 示例数据实际需填充完整WozMon镜像 // ... 其余248个字节 }; // 模拟 8KB RAM地址映射到 0x0000 - 0x1FFF uint8_t emulated_ram[8192];接下来最关键的是processBusCycle()函数它会在PHI2时钟的上升沿通过外部中断触发被调用。volatile uint16_t current_address 0; volatile uint8_t current_data 0; volatile bool is_write_cycle false; // 假设我们通过74HC595读取了地址存储到current_address // 假设我们通过某个端口读取了R/W线状态存储到is_write_cycle (true写 false读) void onPhi2Rising() { // 中断服务程序 // 1. 快速读取地址总线通过移位寄存器或直接IO current_address readAddressBus(); // 2. 快速读取R/W线状态 is_write_cycle (read_RW_pin() LOW); // 注意电平逻辑 // 3. 处理数据总线 if (is_write_cycle) { // CPU写周期CPU将数据放到数据总线上我们需要读取它 current_data readDataBus(); writeMemory(current_address, current_data); // 写入模拟内存 } else { // CPU读周期我们需要将数据放到数据总线上供CPU读取 current_data readMemory(current_address); setDataBusAsOutput(); writeDataBus(current_data); // 注意需要在PHI2下降沿前将数据总线切换回高阻态防止总线冲突 } } uint8_t readMemory(uint16_t addr) { if (addr 0xFF00 addr 0xFFFF) { // 访问ROM区域 return pgm_read_byte((WOZMON_ROM[addr - 0xFF00])); } else if (addr 0x2000) { // 访问RAM区域 return emulated_ram[addr]; } else { // 未映射区域返回固定值如0xEA即NOP指令 return 0xEA; } } void writeMemory(uint16_t addr, uint8_t data) { if (addr 0x2000) { // 只允许写入RAM区域 emulated_ram[addr] data; } // ROM区域忽略写操作 }3.2 串行I/O的模拟与集成WozMon的输入输出依赖于两个特定的内存映射地址Memory-Mapped I/O。在苹果I的设计中通常是输出ECHO向0xF001地址写入一个字符该字符就会从串口发送出去。输入KBD从0xF000地址读取会返回从串口接收到的字符。我们需要在readMemory和writeMemory函数中加入对这些特殊地址的处理。uint8_t readMemory(uint16_t addr) { if (addr 0xF000) { // 键盘输入寄存器 if (Serial.available() 0) { return Serial.read(); // 返回从PC终端接收到的字符 } else { return 0x00; // 无输入时返回0 } } else if (addr 0xFF00) { // ... ROM读取 } else if (addr 0x2000) { // ... RAM读取 } return 0xEA; } void writeMemory(uint16_t addr, uint8_t data) { if (addr 0xF001) { // 输出寄存器 Serial.write(data); // 将字符发送到PC终端 } else if (addr 0x2000) { // ... RAM写入 } // 忽略其他地址的写操作 }这样当WozMon运行到需要输出一个字符的指令时例如将字符A存入0xF001地址实际上会触发Arduino的writeMemory函数进而通过Serial.write(A)将字符发送到串口最终显示在你的终端软件上。反之当你在终端键入字符WozMon从0xF000地址读取时Arduino的readMemory函数会从串口缓冲区取出字符返回给CPU。4. 实战步骤从零搭建与调试理论清晰后我们进入动手环节。以下是详细的步骤、物料清单和调试方法。4.1 物料清单与硬件连接必需物料65C02 CPU一片注意是CMOS的65C02而非NMOS的6502功耗和稳定性更好。Arduino Uno R3一块。1MHz 有源晶振模块为65C02提供时钟。比无源晶振加电容的方案更稳定。74HC595 移位寄存器2片用于锁存16位地址节省Arduino IO。面包板、杜邦线若干。10kΩ电阻、10μF电解电容、轻触开关用于构建复位电路。USB数据线为Arduino供电并连接PC。连接示意图关键部分65C02 Pin - 连接到 PHI2 (37) - 1MHz有源晶振输出 RESET (40) - RC复位电路 (10k上拉到Vcc, 10uF下拉到GND按钮并联电容) Vcc (8) - 5V (来自Arduino 5V引脚) GND (1,21) - GND (来自Arduino GND引脚) 数据总线 D0-D7 (26,27,28,29,30,31,32,33) - Arduino 模拟口 A0-A7 (作为8位数据端口) 控制总线 R/W (34) - Arduino 数字引脚 D2 (配置为输入并启用中断) 地址总线 A0-A7 (9,10,11,12,13,14,15,16) - 第一片74HC595的并行输出Q0-Q7 地址总线 A8-A15 (17,18,19,20,22,23,24,25) - 第二片74HC595的并行输出Q0-Q7 74HC595 (两片级联) DS (14) - Arduino D11 (数据) SH_CP (11) - Arduino D12 (时钟) ST_CP (12) - Arduino D13 (锁存)4.2 Arduino固件编写与烧录固件代码除了包含上述内存模拟和中断服务程序外还需要完整的初始化设置。#include SPI.h // 使用SPI硬件加速与74HC595的通信 // 引脚定义 #define PIN_RW 2 #define PIN_PHI2 3 // 外部中断1 #define PIN_LATCH 13 // 74HC595 锁存 volatile uint16_t latched_address 0; volatile bool bus_cycle_complete false; void setup() { // 初始化串口波特率与WozMon匹配通常是9600或115200 Serial.begin(115200); // 配置数据总线端口A0-A7为输入上拉初始状态为高阻 DDRK 0x00; // 端口KA0-A7全部输入 PORTK 0xFF; // 启用上拉电阻 // 配置控制引脚 pinMode(PIN_RW, INPUT); pinMode(PIN_LATCH, OUTPUT); // 配置SPI用于驱动74HC595高速输出地址 SPI.begin(); SPI.setClockDivider(SPI_CLOCK_DIV2); // 高速模式 // 配置PHI2引脚为中断上升沿触发 attachInterrupt(digitalPinToInterrupt(PIN_PHI2), onPhi2Rising, RISING); // 初始化复位拉低RESET线一段时间 pinMode(4, OUTPUT); // 假设用D4控制复位 digitalWrite(4, LOW); delay(100); digitalWrite(4, HIGH); Serial.println(6502 System Emulator Ready.); } void loop() { // 主循环不处理关键总线事务它们都在中断中完成 // 这里可以处理一些非实时任务如更新状态LED delay(100); } // 中断服务程序 (ISR) - 必须极其高效 void onPhi2Rising() { // 1. 锁存当前地址总线上的值通过74HC595 digitalWrite(PIN_LATCH, HIGH); // 通过SPI读取两字节地址假设已提前在PHI2低电平时将地址加载到595 // 这里是一个简化实际需要根据硬件连接调整 uint16_t addr SPI.transfer(0) 8; addr | SPI.transfer(0); digitalWrite(PIN_LATCH, LOW); latched_address addr; // 2. 判断读写 bool isWrite (digitalRead(PIN_RW) LOW); // 3. 处理数据 if (isWrite) { // 写周期先切换数据端口为输入再读取 DDRK 0x00; uint8_t data PINK; // 读取整个端口K writeMemory(latched_address, data); } else { // 读周期先获取数据再切换端口为输出并写入 uint8_t data readMemory(latched_address); DDRK 0xFF; // 切换为输出 PORTK data; // 输出到数据总线 } // 注意在PHI2下降沿后需要将数据总线恢复高阻态。这可以在PHI2下降沿中断中做或由硬件电路如总线收发器自动处理。 // 为简化可以在ISR末尾或下一个周期开始时将DDRK设为输入。 }关键技巧在onPhi2Rising中断服务程序ISR中绝对不能使用任何耗时操作如Serial.print()、delay()。这些函数会禁用中断导致丢失后续的时钟周期系统必然崩溃。所有调试信息应在主循环中通过设置标志位来有条件地打印。4.3 上电、连接与首次对话硬件检查连接完成后务必用万用表检查所有Vcc和GND连接确保无短路。特别注意65C02的引脚不要接反。烧录固件将上述代码需补充完整的WozMon ROM数组编译并上传到Arduino Uno。连接终端打开串口终端软件如PuTTY、CoolTerm选择Arduino对应的串口如COM3或/dev/ttyUSB0设置波特率为115200与代码中Serial.begin(115200)一致数据位8停止位1无校验。上电先给Arduino上电通过USB再接通6502的5V电源如果独立供电。观察终端窗口。期待的画面如果一切正常复位后6502会从0xFFFC和0xFFFD读取复位向量Reset Vector这两个地址在ROM中应被正确设置为0x00和0xFF这样CPU就会跳转到0xFF00开始执行WozMon。你将在终端看到WozMon的提示符通常是一个星号*或者一个简单的提示符。5. 深度排错当终端一片寂静时怎么办成功并非一蹴而就终端没有反应是最常见的情况。不要慌张按照以下系统性步骤排查。5.1 排查第一步电源、时钟与复位这是基础中的基础90%的问题出在这里。电压用万用表测量65C02的Vcc第8脚和Vss第1、21脚之间电压确保是稳定的4.75V-5.25V。电压不足会导致CPU无法启动。时钟用示波器或逻辑分析仪探头测量PHI2引脚第37脚。你应该看到一个稳定的1MHz方波。如果没有波形检查有源晶振模块是否已供电输出是否连接正确。时钟是CPU的心脏没有心跳一切免谈。复位65C02的RESET引脚第40脚是低电平复位。上电后应用万用表测量应为高电平约5V。按下复位按钮时应观察到短暂的低电平脉冲。确保你的RC复位电路时间常数足够通常10kΩ和10μF可以提供约100ms的低电平时间。5.2 排查第二步总线活动与Arduino响应如果电源、时钟、复位都正常CPU应该开始取指执行了。此时需要检查总线是否有活动。工具逻辑分析仪是最佳选择。将探头连接到地址总线的低几位如A0-A3和数据总线D0-D7。观察现象复位释放后你应该看到地址总线上出现有规律的变化CPU在顺序读取指令数据总线上也应出现相应的数据。如果地址总线毫无变化说明CPU根本没有运行回头检查时钟和复位。如果地址总线在变化但数据总线全是0xFF或0x00说明CPU没有读到有效的指令码问题出在内存模拟Arduino端。Arduino端诊断在Arduino代码中可以添加一个非侵入式的诊断功能。例如在readMemory函数中如果访问的地址是复位向量0xFFFC/0xFFFD则点亮一个LED或通过串口发送一个调试字符注意此串口调试输出必须与模拟的WozMon串口使用不同的硬件串口或SoftSerial否则会干扰通信。这能验证Arduino是否收到了CPU的读请求。5.3 排查第三步软件逻辑与WozMon镜像如果总线有活动但终端仍无输出问题可能更深入。WozMon镜像你使用的WozMon二进制文件是否正确且完整确保它确实是针对从0xFF00地址开始的版本。一个错误的镜像会导致CPU执行乱码很快跑飞。串口映射地址确认你的代码中为WozMon模拟的输入输出地址0xF000, 0xF001与WozMon代码中使用的地址完全一致。不同版本的WozMon可能使用不同的地址。中断冲突确保PHI2的中断服务程序尽可能短。如果ISR执行时间超过一个时钟周期1微秒就会丢失下一个总线周期导致系统不稳定。使用volatile关键字声明在ISR和主循环间共享的变量。波特率匹配终端软件的波特率必须与Arduino代码中Serial.begin()的波特率以及WozMon代码中初始化的波特率三者一致。经典的WozMon通常使用9600波特但修改版可能用115200。5.4 一个高级调试技巧单步跟踪对于极其棘手的问题可以实现一个“单步模式”。修改代码让onPhi2RisingISR只执行一次后就自动禁用自身中断。在终端发送一个字符如‘s’来重新启用一次中断。这样每发送一次‘s’CPU就执行一个时钟周期。你可以在Arduino的主循环中打印出每个周期后的地址、数据、R/W状态。这能让你像调试器一样一步一步地观察CPU的执行流精确定位是在读取哪个地址时出了问题或者是在执行哪条指令后跑飞。这个过程非常耗时但它是理解6502和你的硬件/软件交互的最深刻方式。当你看到第一个提示符*出现在终端时那种成就感是无与伦比的。这不仅仅是一个项目成功更是与计算机历史的一次直接对话。