1. 项目概述:在FPGA上复活经典8位计算机
如果你和我一样,对上世纪七八十年代那些经典的8位计算机架构——比如Zilog Z80和Intel 8051——抱有浓厚的兴趣,同时又对现代FPGA技术着迷,那么这个项目绝对会让你兴奋。它不是一个简单的仿真器,而是用一块实实在在的Intel MAX 10 FPGA开发板,通过硬件描述语言(HDL)重新“铸造”出这两颗传奇CPU的软核,并围绕它们构建出完整的、可以运行真实汇编和BASIC程序的单板计算机(SBC)。这就像用今天的先进制造工艺,去复刻一台老式收音机的核心电路,不仅能听,还能亲手触摸和修改每一个“零件”。
这个项目的核心价值在于“贯通”。它不仅仅是为了怀旧,更是为了学习。通过从零开始(或者说,从已有的成熟软核开始)在FPGA内部搭建一个包含CPU、内存、总线和外设的完整系统,你能深刻理解计算机体系结构中最本质的“冯·诺依曼”或“哈佛”架构是如何在硅片上实现的。对于FPGA初学者,这是从点亮LED到构建复杂数字系统的绝佳跳板;对于嵌入式开发者,这是透视单片机内部运作机制的X光机;对于复古计算爱好者,这是一台可以无限魔改的“时光机”。
我选择Intel MAX 10 FPGA作为平台,原因很实际:它成本亲民,开发工具链(Quartus Prime)成熟,片上资源(逻辑单元、存储块)足够容纳这两个8位CPU及其基本外设,而且引脚丰富,便于扩展。下面,我们就来深入拆解这个项目的设计思路、实现细节,并分享我在复现过程中踩过的坑和总结的经验。
2. 核心架构设计与软核选型考量
2.1 为何是Z80和8051?
在开始动手之前,首先要问:为什么选择Z80和8051?市面上开源的CPU软核很多,从简单的8位到复杂的RISC-V。选择这两款,首先是出于其历史地位和教育意义。Z80是早期个人电脑(如ZX Spectrum, CP/M系统)的心脏,其指令集清晰,寻址方式丰富,是理解CISC(复杂指令集)架构的活教材。8051则是嵌入式领域的“常青树”,其哈佛架构(程序与数据存储器分开)、特殊的SFR(特殊功能寄存器)和位寻址空间,是单片机教学的基石。
其次,生态成熟。经过几十年的发展,围绕这两款处理器的开源软核实现(如T80 for Z80, 8051兼容核)已经非常稳定和优化,有大量的文档、示例程序和社区支持。这能让我们把精力集中在系统集成和应用上,而不是从头调试一个CPU核。
2.2 系统整体框图与模块划分
无论是Z80 SBC还是8051 SBC,其核心架构思想是一致的:以CPU软核为中心,通过总线(或直接连接)与各个外设模块通信。在FPGA内部,我们可以用硬件逻辑精确地定义这些模块和它们之间的时序。
Z80 SBC 典型架构:
- CPU核心:采用开源的T80软核。它完整实现了Z80指令集,包括未公开的指令,并且是同步设计,易于在FPGA中集成。
- 地址解码器:这是系统的“交通警察”。Z80发出的16位地址线,需要通过这个模块解码,决定是访问ROM、RAM还是某个外设(如UART、视频控制器)。例如,可以规划地址空间:0x0000-0x1FFF为8KB ROM,0x8000-0x8FFF为4KB RAM,0xF0-0xF3为UART数据/状态端口。
- 存储器:
- ROM:用于存放监控程序(Monitor)或BASIC解释器。在FPGA中,通常用片上存储器(M9K)初始化成ROM,或者从外部Flash加载。
- RAM:作为系统的工作内存,存放用户程序和变量。同样使用FPGA的M9K块实现。
- 外设控制器:
- UART:实现串口通信,用于与PC终端交互。需要实现波特率发生器、发送/接收移位寄存器。这是调试和交互的生命线。
- 视频输出:根据开发板资源,可以是简单的VGA文本模式控制器。它从显存(RAM中划出的一块区域)读取字符,转换成VGA时序信号输出。
- 键盘接口:连接PS/2键盘,将扫描码转换为ASCII码供CPU读取。
- 时钟与复位:为整个系统提供稳定的时钟和可靠的复位信号。MAX10板载有晶振,可以直接使用或通过内部PLL倍频/分频。
8051 SBC 架构特点:8051是哈佛架构,其程序存储器(ROM)和数据存储器(RAM)在物理上是分开的,地址空间独立。因此,其架构略有不同:
- CPU核心:选用一个兼容Intel 8051指令集的开源软核。需要注意其是否是“12T”或“1T”核心(即每条指令需要的时钟周期数),这直接影响性能。
- 程序存储器(CODE):存放BASIC-52解释器或用户程序。通常映射到独立的ROM空间。
- 内部数据存储器(IDATA):128字节的片上RAM,用于存放工作寄存器、堆栈和用户变量。
- 外部数据存储器(XDATA):项目中使用的是16KB或32KB的外部RAM,通过并行或SPI接口扩展,用于存放更大的用户程序和数据。
- 外设:8051核通常已集成UART、定时器/计数器等基本外设。我们需要做的是将核的I/O端口(P0, P1等)映射到FPGA的物理引脚,连接LED、按键等。对于I2C等高级功能,需要额外实现IP核并挂接到总线上。
注意:在FPGA中实现这些模块,本质上是用Verilog或VHDL描述它们的数字电路行为。例如,一个简单的地址解码器可能就是一个大的
case语句,根据输入地址产生不同的片选信号。理解每个模块的时序要求(如Z80的M1周期、读写时序,8051的ALE信号)是正确连接它们的关键。
3. 硬件平台搭建与工程配置详解
3.1 Intel MAX 10 FPGA开发板选型与资源评估
我使用的是一块MAX 10 10M08SCM153C8G型号的开发板。我们简单算一下账,看资源是否够用:
- 逻辑单元(LEs):约8000个。一个优化后的Z80 T80核大约消耗1200-1500个LEs,一个8051核大约消耗1000-2000个LEs(取决于实现)。地址解码、UART、简单VGA控制器等外设模块,每个可能在几百到一千LEs。所以,同时实现两套系统资源紧张,但分别实现绰绰有余。
- 嵌入式存储器(M9K):这是实现RAM和ROM的关键。每块M9K是9Kbit(实际8Kb数据+1Kb校验)。8KB ROM需要8块M9K(8 * 8Kb = 64Kb = 8KB),4KB RAM需要4块M9K。MAX 10 10M08型号通常有30多块M9K,完全满足需求。
- 锁相环(PLL):用于从板载12MHz晶振生成系统所需的各种时钟频率(如50MHz for CPU, 25MHz for VGA)。
- 用户I/O:充足,用于连接串口、VGA、PS/2、LED和按键。
实操心得:在选择具体型号时,一定要预留至少20%-30%的逻辑和存储资源余量,以备调试和未来功能扩展(比如增加声音芯片SD卡接口)。资源利用率超过80%后,布局布线的难度和时序收敛的风险会显著增加。
3.2 Quartus Prime工程创建与关键设置
- 新建工程:打开Quartus Prime(我用的18.1标准版,与项目原作者一致,避免兼容性问题),指定工程目录、顶层实体名(如
z80_sbc_top)。 - 添加设计文件:将下载的源码中所有
.v或.vhd文件添加到工程。注意文件之间的编译顺序,确保底层模块先于顶层模块被分析。 - 器件选择:在
Device设置中,精确选择你的FPGA型号(如10M08SCM153C8G)。这一步错了,后续的引脚分配和编程都会失败。 - 配置未使用引脚:在
Device -> Device and Pin Options -> Unused Pins中,将未使用的引脚设置为As input tri-stated。这是一个非常重要的安全设置,可以防止悬空引脚产生振荡电流,导致芯片发热或不稳定。 - 编译设置:在
Analysis & Synthesis Settings中,可以适当提高优化级别(如Balanced或Performance),但初期调试建议用Balanced以缩短编译时间。
3.3 引脚分配与物理连接
这是将FPGA内部逻辑与外部世界连接起来的一步。需要仔细阅读开发板原理图。
- 时钟输入:找到板载晶振连接的FPGA引脚(如
PIN_E1),在Quartus的Pin Planner中,将该引脚分配给顶层模块的clk_12m输入信号,并指定其I/O Standard为3.3-V LVTTL。 - 复位按键:分配一个按键引脚给
reset_n信号,注意是低电平有效还是高电平有效,并在代码中做相应的去抖动处理。 - UART:找到板载USB转串口芯片(如CH340)连接的FPGA引脚,将
txd和rxd信号分配过去。 - VGA:根据VGA接口的R、G、B(各可能需要2-4位)、HSYNC、VSYNC信号,分配到对应的FPGA引脚。
- LED/按键:分配剩余的I/O。
踩坑记录:我曾因为将VGA的HSYNC信号错误地分配到了一个被银行(Bank)电压配置为2.5V的引脚上,而VGA接口需要3.3V电平,导致显示器无法识别信号。务必在Pin Planner中检查每个引脚的
I/O Bank和推荐的I/O Standard,确保电平匹配。
4. Z80单板计算机的深入实现与调试
4.1 T80软核集成与内存映射配置
项目使用了成熟的T80软核。将其集成到顶层模块中,主要工作是正确连接其总线接口:
t80s cpu_core ( .RESET_n (~reset), // 复位,低有效 .CLK (clk_sys), // 系统时钟,如50MHz .WAIT_n (1‘b1), // 等待请求,这里直接拉高表示不等待 .INT_n (1’b1), // 中断,暂时不用 .NMI_n (1‘b1), // 非屏蔽中断,暂时不用 .BUSRQ_n (1’b1), // 总线请求,暂时不用 .M1_n (m1_n), // 机器周期1,可用于外设识别 .MREQ_n (mreq_n), // 存储器请求 .IORQ_n (iorq_n), // I/O请求 .RD_n (rd_n), // 读信号 .WR_n (wr_n), // 写信号 .RFSH_n (rfsh_n), // 刷新信号,DRAM相关,此处可忽略 .HALT_n (halt_n), // 暂停状态输出 .BUSAK_n (busak_n), // 总线响应输出 .A (addr_bus), // 16位地址总线 .DI (data_in), // 8位数据输入 .DO (data_out) // 8位数据输出 );内存映射是设计的核心。我们需要编写一个地址解码模块,根据addr_bus,mreq_n,iorq_n等信号,产生各个存储器和外设的片选信号。
always @(*) begin rom_cs_n = 1‘b1; ram_cs_n = 1’b1; uart_cs_n = 1‘b1; // ... 其他片选默认无效 if (!mreq_n) begin // 存储器访问 if (addr_bus >= 16‘h0000 && addr_bus <= 16’h1FFF) // 8KB ROM rom_cs_n = 1‘b0; else if (addr_bus >= 16’h8000 && addr_bus <= 16‘h8FFF) // 4KB RAM ram_cs_n = 1’b0; end if (!iorq_n) begin // I/O访问 if (addr_bus[7:0] == 8‘hF0) // UART数据端口 uart_cs_n = 1’b0; // ... 其他I/O端口 end end数据总线仲裁:CPU、ROM、RAM、UART的数据输出线需要连接到一起(data_in),但同一时刻只能有一个设备驱动总线。需要通过三态门或选择器控制:
assign data_in = (!rom_cs_n && !rd_n) ? rom_data_out : (!ram_cs_n && !rd_n) ? ram_data_out : (!uart_cs_n && !rd_n) ? uart_data_out : 8‘hFF; // 默认上拉值4.2 串口(UART)通信模块的实现与测试
一个最简单的UART发送器可以用一个移位寄存器实现。接收器稍复杂,需要过采样来寻找起始位中点。
- 波特率生成:系统时钟50MHz,要产生9600bps的波特率时钟。分频系数 = 50,000,000 / 9600 ≈ 5208。我们需要一个计数器,计数到5208/2时产生一个采样脉冲。
- 发送过程:当CPU向UART数据端口写入时,启动发送状态机。依次送出起始位(0)、8位数据位(LSB first)、停止位(1)。每个位占用一个波特率时钟周期。
- 接收过程:检测到
rxd线从高变低(起始位),启动接收状态机。在每位的中点采样数据位,存入移位寄存器,直到收齐停止位,将数据放入接收缓冲区,并置位状态寄存器的“数据就绪”位。
调试技巧:初期可以不用连接PC串口,而是写一个“回环测试”程序。让CPU通过UART发送一个固定字符(如‘A’),同时将UART的发送线txd直接连接到接收线rxd。在代码中,CPU发送后延迟一段时间再去读取UART数据,看是否收到相同的‘A’。这是验证UART底层逻辑是否正常的最快方法。
4.3 监控程序(Monitor)与BASIC解释器加载
一个“裸”的CPU上电后,PC指针指向0x0000,它需要执行指令。因此,ROM的0x0000地址必须存放有效的机器码。通常这里存放一个简单的监控程序(Monitor),它初始化堆栈,设置串口,然后等待用户输入命令。更复杂的情况是直接存放一个BASIC解释器,如Microsoft BASIC 4.7b。
如何将程序放入ROM?在Quartus中,我们可以创建一个ROM的IP核(使用M9K),并指定一个.hex或.mif文件作为其初始化内容。这个文件就是我们的监控程序或BASIC解释器的二进制机器码。如何得到这个文件?
- 找到Z80的监控程序或BASIC的汇编源码(项目开源库中通常提供)。
- 使用交叉汇编工具(如
zasm或sjasmplus)在PC上将其汇编成二进制文件(.bin)。 - 使用工具(如
bin2hex)将.bin转换成Quartus可识别的.hex格式。 - 在Quartus中为ROM IP核指定这个
.hex文件路径。
实操心得:确保汇编时指定的起始地址(ORG指令)与你在FPGA中为ROM分配的地址空间完全一致。例如,ROM物理地址从0x0000开始,那么汇编源码的第一条指令就必须ORG 0000H。
4.4 运行与交互:从汇编到BASIC
当一切就绪,编译生成.sof文件并下载到FPGA后:
- 打开PC上的串口终端软件(如Putty、Tera Term),设置正确的COM口、波特率(9600, 8N1)。
- 给FPGA开发板上电或按下复位键。
- 终端上应该会打印出监控程序或BASIC的提示符(如“>”或“OK”)。
- 此时,你可以输入汇编指令进行机器码级的调试,或者直接输入BASIC程序,就像操作一台真正的80年代电脑一样。
项目提供的BASIC计数程序(OUT 145, 255-I)就是一个很好的测试。它通过I/O端口控制外部LED(如果连接了的话),你可以看到LED的亮灭规律变化。更复杂的ASCII Art程序,则考验了系统的浮点运算(BASIC解释器软件实现)和输出性能。
5. 8051单板计算机的实现差异与要点
5.1 哈佛架构与存储器接口设计
8051软核的实现与Z80有显著不同。最大的区别在于其哈佛架构:程序存储器(ROM)和数据存储器(RAM)有独立的地址空间和控制信号。
- 程序存储器接口:8051核通过
psen_n(程序存储使能)信号来读取指令。我们需要设计一个ROM控制器,当psen_n有效且地址在范围内时,将对应地址的指令数据放到data_bus上。 - 数据存储器接口:分为内部RAM(128字节,直接集成在核内)和外部RAM(通过
rd_n,wr_n,ale等信号访问)。对于外部RAM,我们需要实现一个类似于Z80系统中的总线仲裁和读写控制器。
关键点:很多开源8051软核为了简化,会将外部RAM也映射到与程序存储器相同的物理RAM块,但使用不同的控制信号区分。在FPGA中,这意味着一块M9K存储器,需要支持两套独立的访问接口(一套给psen_n,一套给rd_n/wr_n),这可以通过双端口RAM IP核来实现。
5.2 BASIC-52解释器的集成与时钟配置
项目提供了两个预配置的镜像,核心区别在于集成的BASIC-52解释器版本和时钟频率。
- 8kB ROM + 32kB RAM @ 50MHz:使用较基础的BASIC-52 1.1版本,不支持I2C。时钟通过MAX10内部PLL从12MHz倍频到50MHz,性能极高。
- 16kB ROM + 16kB RAM @ 11.0592MHz:使用功能更全的BASIC-52 1.31版本,支持I2C扩展。时钟频率设为11.0592MHz,这是一个非常经典的频率,因为它可以精确地分频出标准的串口波特率(如9600, 115200),保证串口通信无误差。
如何选择?如果你需要连接I2C设备(如EEPROM、传感器),必须选择11.0592MHz的版本。如果追求极致的BASIC程序运行速度(如计算密集型图形),50MHz版本优势明显。需要注意的是,BASIC-52中的XTAL变量必须设置为实际的系统频率(单位Hz),其内部延时循环和串口波特率计算依赖于此值。
5.3 I/O端口映射与外围设备驱动
8051的P0、P1、P2、P3端口在软核中通常是作为一组输出寄存器存在。我们需要在顶层模块中,将这些寄存器输出到FPGA的物理引脚。
// 假设8051核输出一个8位寄存器 port1_out always @(posedge clk) begin if (reset) begin led_reg <= 8‘hFF; // LED低电平点亮,初始全灭 end else begin led_reg <= ~port1_out; // 取反后驱动LED end end assign led_pins = led_reg; // 连接到板载LED项目中的LED流水灯程序,就是通过BASIC语言向PORT1寄存器写入不同的值来实现的。PORT1 = 0FFH.XOR.LED这行代码,利用了8051 BASIC的位操作功能,实现了LED的走马灯效果。
6. 项目调试、问题排查与性能优化
6.1 常见问题与诊断流程
在实现这类项目时,问题可能出在任何环节。以下是一个系统性的排查思路:
系统完全无反应(串口无输出):
- 检查电源和时钟:用示波器测量FPGA的供电电压和时钟输入引脚,确认是否有稳定的12MHz时钟信号。
- 检查复位电路:确保复位信号在上电后有一个从低到高的稳定跳变。可以在代码中让一个测试LED随复位信号闪烁,进行肉眼判断。
- 检查编译报告:查看Quartus的“Compilation Report”,确认没有严重的时序违规(Timing Violation),特别是建立时间(Setup Time)和保持时间(Hold Time)。
- 使用SignalTap II逻辑分析仪:这是FPGA调试的利器。在代码中嵌入SignalTap,抓取CPU的地址总线、数据总线、控制总线(如
mreq_n,rd_n)以及关键模块的使能信号。看看上电后PC指针是否从0x0000开始递增,是否发出了第一个ROM读请求。
串口有乱码或数据错误:
- 确认波特率:检查终端软件、UART模块的波特率设置是否完全一致。计算分频系数时是否考虑了系统时钟误差。
- 检查时序:用SignalTap抓取UART的
txd信号,测量位宽。在50MHz系统时钟下,9600bps的位宽应该是50,000,000 / 9600 ≈ 5208个时钟周期。如果偏差较大,说明波特率发生器设计有误。 - 电平转换:确认FPGA引脚的电平标准(3.3V LVTTL)与USB转串口芯片的电平是否匹配。有些老式串口是RS-232电平(±12V),需要电平转换芯片。
BASIC程序运行异常或死机:
- 内存映射错误:这是最常见的原因。CPU试图访问一个没有设备响应的地址,总线会浮空,读回的数据不确定,导致程序跑飞。用SignalTap确认CPU的访问地址是否都在你定义的ROM、RAM和外设地址范围内。
- 堆栈溢出:Z80和8051的堆栈都是向低地址生长的。如果初始化时堆栈指针(SP)设置得太靠近RAM底部,或者程序递归/中断调用太深,就会破坏RAM中的程序或数据。确保SP初始化在RAM区域的较高地址(如0x8FFF for Z80)。
- 中断冲突:如果项目启用了中断,但中断服务程序(ISR)没有正确编写或中断向量表地址错误,也会导致不可预测的行为。初期建议禁用所有中断。
6.2 性能优化与扩展思路
当基本系统运行稳定后,可以考虑优化和扩展:
提升CPU性能:
- 时钟频率:在满足时序约束的前提下,尝试通过PLL提高系统时钟频率。MAX 10在50-100MHz范围内通常能稳定运行。
- 使用缓存:为ROM或RAM添加一个简单的高速缓存(Cache),可以显著减少CPU等待时间,尤其对于循环密集的BASIC程序效果明显。
扩展外设:
- PS/2键盘:实现一个PS/2解码模块,将扫描码转换为ASCII码,并通过中断或查询方式告知CPU,实现真正的键盘输入。
- VGA图形模式:在文本模式基础上,实现一个位图模式的帧缓冲区,让BASIC能够绘制像素图形。
- SD卡存储:通过SPI接口连接SD卡,实现文件系统,让BASIC程序可以保存和加载。
- 声音输出:增加一个简单的PWM音频模块,让计算机能发出蜂鸣声甚至播放简单的音乐。
软件生态丰富:
- 除了BASIC,可以尝试移植更复杂的系统,比如为Z80移植一个精简版的CP/M操作系统,或者为8051移植一个微型的RTOS(如FreeRTOS)。这将把项目从单板计算机推向更接近实际应用的操作系统层面。
这个项目的魅力在于,它既是一个对计算机历史的致敬,也是一个绝佳的现代数字系统设计实践平台。从一颗软核CPU开始,到最终构建出一个能与人交互的完整系统,每一步都充满了挑战和乐趣。当你第一次在终端上看到自己用BASIC写的程序控制着LED闪烁,或者在VGA显示器上显示出字符时,那种成就感是无可比拟的。它打通了软件与硬件之间的那层抽象,让你真正理解了从按键到屏幕显示,电流与代码是如何协同工作的。