基于Intel MAX 10 FPGA的Z80与8051双核SoC设计与实现
1. 项目概述当经典CPU遇上现代FPGA最近在整理工作室的旧物翻出来几块尘封已久的Z80和8051开发板看着上面密密麻麻的飞线和74系列逻辑芯片一个念头突然冒了出来能不能用一块更现代的芯片把这些经典架构“复活”并且做得更紧凑、更灵活于是我把目光投向了手边闲置的Intel MAX 10 FPGA开发板。这个项目的核心就是在一片MAX 10 FPGA芯片内部用硬件描述语言“铸造”出Z80和8051这两颗历史上举足轻重的CPU核心并围绕它们构建一个完整的单板计算机系统包括内存、外设和基本的I/O功能。这不仅仅是怀旧更是一次深入理解计算机体系结构从硬件到软件全栈的绝佳实践。对于嵌入式开发者、计算机体系结构爱好者或是想从软件跨入硬件世界的朋友来说跟着走一遍这个过程你会对“计算机如何工作”有焕然一新的认识。2. 核心设计思路与方案选型2.1 为什么选择Intel MAX 10 FPGA在开始动手之前选型是第一步。我手头有几种常见的FPGA比如Artix-7、Cyclone IV最终锁定MAX 10主要基于几个非常实际的考量。首先成本与易用性的平衡。MAX 10系列定位是低成本、高集成度的FPGA它内部集成了配置闪存、模拟数字转换器ADC甚至双电源支持这意味着我只需要一块核心板无需额外复杂的配置电路和电源管理芯片就能快速搭建系统原型极大降低了硬件设计的复杂度和物料成本。其次逻辑资源与项目需求匹配。实现一个8位的CPU内核例如一个精简的8051大约需要1000-2000个LE逻辑单元而一个完整的Z80核心则需要更多一些大约在3000-5000个LE左右。我使用的10M08型号拥有约8000个LE这为同时实现两个CPU核心、总线仲裁、内存控制器以及UART、GPIO等外设留下了充足的空间甚至还有余量做一些性能优化。如果选用Artix-7资源固然绰绰有余但杀鸡用牛刀成本和功耗都上去了。最后开发工具链的亲和力。Intel Quartus Prime Lite版对MAX 10提供免费支持其集成的设计工具、仿真器和编程器已经足够成熟和稳定。对于这种规模的项目一个稳定、易上手的工具链能节省大量在环境配置和调试上的时间。综合来看MAX 10在资源、成本、功耗和开发便利性上达到了一个完美的平衡点是这类中等复杂度数字系统原型的理想载体。2.2 “软核CPU”与“单板计算机”架构解析这个项目的本质是在FPGA内部通过数字逻辑电路实现CPU的功能这就是所谓的“软核CPU”Soft-Core CPU。与市面上直接购买ARM或RISC-V硬核IP不同软核给了我们从门电路级别开始构建和观察CPU的上帝视角。我选择了Z80和8051是因为它们指令集相对简单、文档丰富是学习CPU设计的经典模型。整个单板计算机的架构设计可以类比为一个微型的“主板”。FPGA内部需要构建以下几个关键部分CPU核心Z80 Core和8051 Core这是系统的“大脑”。总线系统包括地址总线、数据总线和控制总线。由于有两个CPU我需要设计一个简单的总线仲裁器来决定在某一时刻哪个CPU可以访问共享资源如公共内存或外设防止冲突。存储器系统利用FPGA内部的嵌入式内存块M9K来构建RAM和ROM。ROM用于存放监控程序或简单的操作系统内核比如CP/M for Z80, 或一个简单的任务调度器RAM作为程序运行时的临时空间。外设控制器这是让计算机“有用”的关键。我计划实现最基础的几个UART串口用于与PC通信这是调试和程序加载的生命线。GPIO通用输入输出连接LED、按键实现最直观的交互。定时器/计数器为系统提供时基用于任务调度或延时。简单的VGA/字符显示器控制器如果资源允许用于显示输出让系统更完整。所有这些模块通过内部总线互联并由一个顶层的“系统集成”模块进行例化和连接。FPGA外部的物理引脚则根据MAX 10开发板的布局映射到具体的功能上如连接USB转串口芯片的RX/TX引脚或连接LED阵列的IO引脚。3. 核心模块实现与细节剖析3.1 Z80软核的设计与实现要点Z80是一款经典的8位CPU拥有丰富的指令集和寄存器组。在FPGA中实现它本质上是用Verilog或VHDL语言描述其内部数据通路和控制逻辑。我参考了公开的T80核心并进行了裁剪和优化以适应MAX 10的资源。核心数据通路这是CPU的“躯干”。你需要精确实现其寄存器文件主寄存器组、备用寄存器组、专用寄存器如PC、SP等、算术逻辑单元ALU、内部数据总线。Z80的ALU支持加、减、逻辑与或非、移位等多种操作在硬件描述时可以用一个多路选择器case语句根据操作码选择运算功能。控制单元与指令译码这是CPU的“神经中枢”也是最复杂的部分。Z80的指令周期由多个T状态时钟周期组成如取指、译码、执行、存储器读写等。你需要设计一个状态机Finite State Machine, FSM来精确模拟这些时序。例如一条“LD A, (HL)”指令将HL寄存器指向的内存内容加载到A寄存器其状态机可能经历S0: 输出PC地址到地址总线发出读信号S1: 从数据总线读取操作码PC1S2: 译码发现是寄存器间接寻址S3: 输出HL地址到地址总线发出读信号S4: 从数据总线读取数据存入A寄存器。注意在实现状态机时强烈建议使用“三段式”FSM写法次态逻辑、状态寄存器、输出逻辑分离这样代码清晰且利于综合工具优化避免产生毛刺或锁存器。中断与总线请求处理Z80有可屏蔽中断INT和非屏蔽中断NMI以及总线请求BUSRQ和响应BUSAK。在单板计算机系统中中断通常由定时器或UART接收完成触发。你需要实现中断向量表IVT的机制并在CPU状态机中插入中断响应周期。总线请求则用于DMA直接存储器访问在这个项目中如果未来要添加高速数据搬运外设比如音频芯片就需要实现它初期可以简化。实测心得仿真Simulation是软核调试的生命线。我几乎花了与实际编写代码同等的时间在ModelSim或Quartus自带的仿真器上。为Z80核心编写一个完整的测试平台Testbench用汇编语言写一小段程序比如计算斐波那契数列将其机器码预先加载到仿真ROM中然后一步步跟踪每个时钟周期下寄存器、总线、状态机的变化这是定位问题最有效的方法。光靠上板看LED闪烁来调试效率极低。3.2 8051软核的设计考量与差异8051是另一款极其流行的8位微控制器其FPGA实现与Z80有显著不同主要体现在其“微控制器”的特性上。哈佛架构与存储器空间8051采用程序存储器ROM和数据存储器RAM分开的哈佛架构且有独特的特殊功能寄存器SFR空间。在FPGA中我们需要构建独立的ROM和RAM块并通过不同的地址选通信号进行访问。SFR如P0、P1、TCON、SCON等需要映射到特定的数据存储器地址范围内80H-FFH并用独立的逻辑来实现对其的读写操作。位寻址能力这是8051的一大特色。部分数据RAM区20H-2FH和许多SFR支持位操作。在硬件实现上这意味着你需要为这些区域提供“位寻址”的读写端口。一种常见的做法是在正常的字节读写数据通路旁为这些区域增加一套位地址到字节地址位偏移的转换逻辑并生成对应的位读写使能信号。定时器/计数器与串口的集成与Z80需要外接独立芯片不同8051的核心外设如两个定时器/计数器、一个全双工串口通常作为软核的一部分一同实现。这意味着你的8051模块不仅要包含CPU核心还要集成这些外设的控制逻辑。例如串口UART的发送和接收移位寄存器、波特率发生器通常由定时器1溢出驱动都需要用硬件逻辑描述。资源优化技巧8051的指令集比Z80简单但它的硬件结构有其复杂性。为了节省MAX 10的LE资源可以对一些不常用的指令或功能进行裁剪。例如如果项目不需要省电可以简化掉掉电模式Power-down和空闲模式Idle的逻辑。对于乘法除法指令MUL/DIV如果应用场景不涉及复杂运算可以考虑用软件子程序替代从而节省大量用于实现乘法器/除法器的逻辑资源。3.3 总线仲裁与共享外设设计当两个CPU核心存在于同一片FPGA中它们如何和谐地共享内存、串口等资源这就需要总线仲裁器。我设计了一个基于固定优先级Priority-based的简单仲裁器。赋予Z80更高的优先级因为在我的设想中Z80可能运行一个更复杂的监控程序而8051负责处理实时性要求高的外设控制如精确 PWM 生成。仲裁器内部有一个状态机其工作流程如下请求阶段Z80和8051在需要访问共享总线时分别发出bus_req_z80和bus_req_51信号。仲裁阶段仲裁器根据优先级假设Z80优先级高进行裁决。如果Z80请求有效则立即授予Z80总线使用权bus_grant_z801并通知8051等待bus_grant_510。只有当Z80释放总线bus_req_z800且8051正在请求时总线才会授予8051。授权与隔离获得授权的CPU其地址、数据输出使能信号被打开连接到共享总线上。同时另一个CPU的这些输出必须被置为高阻态Tri-state从而实现电气隔离。共享设备如公共RAM的片选和读写信号则由当前获得授权的CPU控制。共享外设的地址映射这是软件编程的基础。我需要为两个CPU统一规划一个地址空间。例如0x0000 - 0x7FFFZ80私有RAM/ROM。0x8000 - 0xFFFF共享资源区。0x8000 - 0x8FFF共享双端口RAM实际用FPGA内存块模拟需设计仲裁逻辑防止同时写冲突。0x9000共享UART数据寄存器只读为接收只写为发送。0x9001共享UART状态/控制寄存器。0xA0008051私有外设映射起始在8051看来这些地址可能对应其SFR或XRAM空间需要一层地址转换桥接。这样Z80可以通过向0x9000写数据来发送串口信息8051也可以通过访问它自己的“特殊地址”经过桥接转换到0x9000来接收实现了进程间通信IPC的雏形。4. 系统集成、调试与上板实操4.1 顶层模块集成与引脚分配当所有子模块Z80核心、8051核心、仲裁器、内存控制器、UART、GPIO等都经过仿真验证后下一步就是在Quartus中创建顶层模块Top-level Entity将它们像搭积木一样连接起来。module z80_8051_soc ( input wire clk_50m, // 50MHz主时钟 input wire rst_n, // 低电平复位 // UART接口 output wire uart_txd, input wire uart_rxd, // GPIO接口 - 8个LED 4个按键 output wire [7:0] led, input wire [3:0] key ); // 内部信号声明 wire clk_cpu; // 分频后的CPU时钟如3.125MHz wire [15:0] z80_addr, cpu51_addr; wire [7:0] z80_data_out, cpu51_data_out, sys_data_in; // ... 其他大量内部连线 // 时钟分频模块实例化 clock_divider clk_gen (.clk_in(clk_50m), .clk_out(clk_cpu)); // Z80核心实例化 z80_core u_z80 ( .clk(clk_cpu), .reset(~rst_n), .addr(z80_addr), .data_out(z80_data_out), .data_in(sys_data_in), // ... 其他控制信号连接到仲裁器 ); // 8051核心实例化 cpu51_core u_51 ( .clk(clk_cpu), .rst(~rst_n), .addr(cpu51_addr), .data_out(cpu51_data_out), .data_in(sys_data_in), // ... 其他信号 ); // 总线仲裁器实例化 bus_arbiter u_arbiter ( .clk(clk_cpu), .req_a(z80_bus_req), .req_b(cpu51_bus_req), .grant_a(z80_bus_grant), .grant_b(cpu51_bus_grant) ); // 共享RAM控制器实例化例化一个M9K RAM块 shared_ram_ctrl u_ram ( .address(selected_addr[12:0]), // 来自仲裁后的地址 .clock(clk_cpu), .data(selected_data_out), .wren(selected_write_en ram_cs), .q(sys_data_to_cpu) ); // UART控制器实例化 uart_controller u_uart ( .clk(clk_50m), // UART通常需要更高频率的时钟进行波特率生成 .rst(~rst_n), .addr(selected_addr[0]), .wr_en(selected_write_en uart_cs), .rd_en(selected_read_en uart_cs), .data_in(selected_data_out), .data_out(uart_data_out), .txd(uart_txd), .rxd(uart_rxd) ); // 地址译码与数据总线多路选择逻辑 // ... 根据仲裁结果和地址高位产生各设备的片选信号ram_cs, uart_cs... // ... 根据当前总线拥有者选择将哪个CPU的数据输出连接到共享总线上 // ... 将来自共享设备RAM, UART的数据路由回当前总线拥有者 // GPIO控制逻辑 assign led gpio_out_reg; // 将寄存器输出连接到LED always (posedge clk_cpu) begin if (gpio_write_en) gpio_out_reg selected_data_out; if (gpio_read_en) gpio_in_data {4‘b0, key}; // 按键值读入 end endmodule引脚分配Pin Assignment这是连接FPGA逻辑与物理世界的关键一步。在Quartus的Pin Planner工具中需要根据MAX 10开发板的原理图将顶层模块的输入输出信号分配到具体的物理引脚上。例如clk_50m- 连接到开发板的50MHz晶振引脚如PIN_M2。rst_n- 连接到开发板的一个按键如PIN_A7配置为上拉输入按下为低电平。uart_txd/rxd- 连接到板载USB转串口芯片的对应引脚如PIN_B12, PIN_A12。led[7:0]- 连接到8个用户LED的引脚。key[3:0]- 连接到4个用户按键的引脚。时序约束Timing Constraints为了让工具优化布局布线满足时序要求必须创建.sdc文件。最基本的约束是创建时钟create_clock -name {clk_50m} -period 20.000 [get_ports {clk_50m}] create_generated_clock -name {clk_cpu} -source [get_ports {clk_50m}] -divide_by 16 [get_pins {clk_gen|clk_out}]这告诉时序分析工具主时钟是50MHz周期20ns生成的CPU时钟是其16分频。工具会确保所有寄存器到寄存器的路径在这个时钟频率下都能稳定工作。4.2 软件开发与交叉编译环境搭建硬件就绪后需要为这两个CPU编写软件。这离不开交叉编译工具链。对于Z80我选择了开源的z88dk套件。它包含了C编译器、汇编器、链接器和丰富的库函数。在Linux或WSL环境下安装后即可使用。编写一个简单的“Hello World”串口输出程序#include stdio.h void main() { // 假设UART数据寄存器地址为0x9000状态寄存器为0x9001 #define UART_DATA (*(volatile unsigned char*)0x9000) #define UART_STATUS (*(volatile unsigned char*)0x9001) char *str Hello from Z80!\n; while(*str) { while((UART_STATUS 0x02) 0); // 等待发送缓冲区空 UART_DATA *str; } while(1); }使用zcc编译并生成Intel HEX格式的机器码zcc z80 -clibsdcc_iy -startup1 -o hello.ihx hello.c。这个.ihx文件需要转换成二进制objcopy或直接由Quartus的In-System Memory Content Editor工具写入到FPGA的ROM内存块中。对于8051同样使用开源的sdccSmall Device C Compiler。它支持8051架构。编写一个让LED闪烁的程序#include 8051.h // 包含SFR定义 void delay_ms(unsigned int ms) { unsigned int i, j; for(i0; ims; i) for(j0; j120; j); } void main() { while(1) { P1 0x00; // 假设LED连接在P1口低电平点亮 delay_ms(500); P1 0xFF; delay_ms(500); } }编译命令sdcc --model-small hello.c会生成一系列文件其中.ihx是最终的可执行文件格式同样需要转换并加载到8051的程序ROM中。程序加载方法对于FPGA原型系统最方便的程序加载方式有两种编译时固化将编译好的机器码在Quartus工程中作为.mifMemory Initialization File或.hex文件直接初始化到ROM内存块的初始内容中。这样每次FPGA配置后程序就已经在里面了。适合固化不变的监控程序。运行时加载通过串口利用一个预先固化在ROM中的小型引导加载程序Bootloader接收PC端发送的新的程序二进制流并写入到RAM或Flash如果外挂了的指定位置然后跳转执行。这种方式便于调试和更新软件。4.3 系统级调试与问题排查实录将综合、布局布线后的.sof文件下载到MAX 10开发板后真正的挑战才开始。以下是我在调试过程中遇到的一些典型问题及解决方法问题一系统完全无反应LED不亮串口无输出。排查思路这是最令人头疼的情况。遵循从外到内、从电源到时序的原则。硬件检查确认开发板供电正常下载线连接可靠.sof文件下载成功。复位信号这是最常见的坑。用示波器或逻辑分析仪抓取rst_n引脚波形确保上电后有一个稳定的低脉冲然后保持高电平。我的代码中是低电平复位如果按键电路是上拉按下为低要确保默认状态未按下为高。我曾因为复位信号接反常低导致CPU一直处于复位状态。时钟信号测量clk_cpu信号是否存在频率是否正确。如果时钟分频模块有误CPU可能得不到时钟。可以在分频器输出后加一个reg用always (posedge clk_50m)将其反相输出到一个测试引脚用示波器看是否有方波。仿真回归如果硬件基本信号正常立刻回到仿真。在Testbench中模拟上电复位和时钟运行足够长的仿真时间查看顶层关键信号如CPU的地址总线、读写信号是否有活动。如果仿真正常但板子不行极有可能是时序约束或引脚分配问题。问题二串口输出乱码或只能输出第一个字符。排查思路这通常是波特率不匹配或UART控制器逻辑有误。波特率校准UART的波特率由输入时钟分频产生。计算公式为分频系数 系统时钟频率 / (目标波特率 * 16)。例如50MHz时钟要得到115200波特率分频系数 50,000,000 / (115200 * 16) ≈ 27.13。取整27或27.1如果支持小数分频都会导致误差。误差过大会导致采样点偏移产生乱码。我通常先用一个更精确的时钟如50MHz/434115207波特率误差很小或者使用MAX 10内部PLL生成一个更接近目标频率的时钟。UART状态机确保发送状态机在“等待发送完成”的状态停留足够的时间一个完整的位周期。常见的错误是状态切换太快导致数据位还没送完就开始了下一帧。在发送移位寄存器移出每一位后必须等待一个“波特率时钟”周期。软件等待循环检查Z80或8051的发送程序是否在写入数据寄存器后正确地轮询状态寄存器的“发送缓冲区空”标志位。如果没等空就写下一个字节会导致数据覆盖。问题三双核系统中一个CPU工作正常另一个完全不工作或行为异常。排查思路问题很可能出在总线仲裁或资源共享上。仲裁逻辑死锁检查仲裁器的状态机是否存在两个CPU同时请求且优先级处理不当导致都无法获得授权的情况。在仿真中可以编写一个激励让两个CPU几乎同时发起总线请求观察仲裁器的grant信号。地址/数据总线冲突当CPU未获得总线授权时其地址和数据输出必须为高阻态‘Z‘。在Verilog中需要用三态缓冲器assign bus grant ? cpu_data : 8‘bZZZZ_ZZZZ;来实现。如果处理不当两个CPU的输出级连在一起会产生总线竞争导致信号电平不确定。共享RAM访问冲突虽然通过仲裁避免了同时访问但如果两个CPU访问共享RAM的时序非常接近而RAM本身没有同步或双端口机制仍可能出错。对于单端口RAM模拟共享必须在仲裁器授权切换时插入至少一个时钟周期的“空闲周期”让前一个访问完全结束再允许下一个访问开始。更好的办法是使用FPGA内置的双端口RAMTrue Dual-Port RAM块两个CPU可以真正同时访问不同地址。问题四程序运行一段时间后跑飞或死机。排查思路这通常是时序违例Timing Violation或亚稳态Metastability引起的。时序报告分析编译完成后一定要仔细查看Quartus的时序分析报告TimeQuest Timing Analyzer。关注“最差建立时间余量Worst-case Setup Slack”和“最差保持时间余量Hold Slack”。如果余量为负说明存在时序违例寄存器采样可能出错。解决方法包括降低CPU时钟频率、优化关键路径逻辑如插入流水线寄存器、加强时序约束。跨时钟域处理如果系统中存在多个不同频率的时钟如50MHz系统时钟和3.125MHz CPU时钟以及可能由外部按键产生的异步信号必须在跨时钟域的信号路径上使用同步器两级或三级D触发器串联。例如按键信号进入CPU时钟域前必须经过同步器处理否则极易引发亚稳态导致系统状态机进入非法状态。看门狗定时器作为一种容错设计可以在系统中加入一个简单的看门狗定时器。如果主程序正常运行它会定期“喂狗”清零定时器。一旦程序跑飞无法按时喂狗看门狗超时就会产生系统复位让程序从头开始。这在调试初期非常有用。5. 项目总结与扩展思考经过数周的设计、编码、仿真和调试当Z80的监控程序通过串口打印出欢迎信息同时8051控制的LED开始有节奏地闪烁时那种成就感是无可比拟的。这个项目远不止是让两个老古董CPU“复活”它是一次完整的片上系统SoC开发实践。我个人最深的体会是仿真与调试的时间占比远超编码。在硬件描述语言的世界里思维必须是并发的、时序精确的。一个看似微小的逻辑错误比如状态机状态编码重叠或者组合逻辑产生的毛刺在仿真中可能被忽略但上板后就会导致难以定位的随机故障。养成严谨的代码风格如always块内用非阻塞赋值描述时序逻辑用阻塞赋值描述组合逻辑以及建立完善的仿真测试平台是提高效率、减少挫折的关键。这个项目还有巨大的扩展空间。例如可以为Z80移植一个精简版的CP/M操作系统让它能够从SD卡通过SPI接口加载和运行更大的程序。可以为8051添加更多的外设驱动如I2C温湿度传感器、SPI OLED屏幕让它成为一个真正的智能外设协处理器。甚至可以利用MAX 10内部剩余的LE资源实现一个简单的VGA字符发生器让这台“计算机”拥有自己的显示器输出。从更宏观的视角看基于FPGA的软核CPU设计是理解现代复杂SoC如手机处理器、路由器芯片的绝佳起点。你今天在MAX 10上手动连接的总线、仲裁器、内存控制器正是那些商用IP核如AMBA AHB/APB总线所抽象和优化的对象。通过这个亲手搭建的过程你获得的对计算机底层运行机制的理解是阅读任何教科书都无法替代的。