【立创开发板】GameStation-YunQy:基于梁山派打造NES掌机的硬件设计与模拟器移植实战
基于梁山派打造NES掌机:硬件设计与模拟器移植实战
最近有不少朋友问我,能不能用国产的GD32单片机做个好玩的东西?正好,立创EDA的梁山派开发板(GD32F470)性能强劲,价格也合适,我就用它从零开始打造了一台能玩经典红白机(NES)游戏的掌上游戏机。整个过程涉及硬件选型、电路设计、外设驱动,以及最核心的NES模拟器移植,算是一个比较综合的嵌入式实战项目。
今天,我就把这个项目的完整过程分享出来,手把手带你走一遍。无论你是想学习如何将多个外设集成到一个系统中,还是对在单片机上跑游戏模拟器感到好奇,这篇文章都能给你清晰的指引。咱们先从硬件设计开始。
1. 硬件系统设计与选型
一台掌机,核心是“玩”和“看”。玩,需要输入设备(摇杆/按键)和反馈(震动);看,需要一块清晰的屏幕。此外,为了保存游戏进度,还需要掉电不丢失的存储。下面我们就围绕这几个核心需求,来搭建硬件系统。
1.1 核心控制单元:梁山派开发板
整个项目的“大脑”是立创EDA的梁山派开发板,它主控芯片是兆易创新的GD32F470。这颗芯片基于ARM Cortex-M4内核,主频高达240MHz,内置了硬件浮点单元(FPU),处理NES模拟器的运算绰绰有余。它丰富的GPIO、SPI、I2C、ADC、PWM等外设,正好能满足我们所有外设的驱动需求。
简单来说,选它就是因为性能足够强,接口足够多,社区资料也丰富,能让我们把精力集中在应用开发上,而不是折腾底层硬件。
1.2 视觉核心:1.69寸 SPI IPS显示屏
掌机的眼睛就是这块屏幕。我选择了一块1.69英寸的IPS圆角屏,分辨率是240x280。IPS屏的优势是可视角度大、色彩鲜艳,显示游戏画面效果很好。
提示:对于嵌入式设备,SPI接口的屏幕是首选。因为它需要的控制IO少(通常只需要4-6根线:SCK, MOSI, DC, CS, RST,可能还有背光控制),通信协议简单,对MCU的负担也小。虽然刷新率比不上并口屏,但对于NES游戏(帧率通常60Hz)来说,硬件SPI驱动完全够用。
屏幕的驱动我移植了中景园电子的代码,并使用了GD32F470的硬件SPI控制器进行通信,这样可以最大化数据传输效率,减少CPU占用,把宝贵的算力留给游戏模拟器。
1.3 操控核心:ADC摇杆与按键
操控方面,我使用了一个小巧的滑动摇杆。这种摇杆内部本质上是两个电位器,分别对应X轴和Y轴。
它的工作原理很简单:MCU通过ADC(模数转换器)通道读取摇杆输出的电压值。当摇杆在中心位置时,输出电压是一个中间值(比如1.65V,对应ADC数值约2048,假设是12位ADC)。向前、后、左、右推动时,电压会相应变化,MCU通过判断ADC数值的范围,就能知道当前的操控方向。
除了摇杆,你还需要设计几个实体按键(比如A、B、START、SELECT),这些直接用GPIO输入模式读取即可。
1.4 沉浸感增强:PWM震动电机
为了在游戏碰撞、爆炸等场景下提供触觉反馈,我加入了一个3610贴片震动马达。它的工作电流不大,约85mA,所以可以通过一个简单的三极管开关电路来驱动。
控制震动有两种方式:
- GPIO高低电平控制:直接给高电平震动,低电平停止。这种方式最简单,但只能“震”或“不震”,无法调节强度。
- PWM控制:通过PWM(脉冲宽度调制)信号来控制三极管的导通程度,从而控制电机的平均电压,实现震动强弱的无极调节。显然,PWM方式体验更好,我们的GD32F470有丰富的定时器资源来产生PWM,所以采用这种方式。
1.5 记忆单元:I2C EEPROM
虽然很多MCU内部都有Flash可以模拟EEPROM存储,但使用独立的外部EEPROM芯片更可靠,擦写次数近乎无限,而且不占用主控内部空间。
我这里选用了一颗非常常见的AT24C02芯片,容量2Kbit(256字节)。通过I2C总线与MCU连接,只需要两根线(SCL和SDA)。对于保存几KB的游戏存档、高分记录来说,完全足够了。电路设计上,因为只使用一片EEPROM,所以把芯片的地址选择引脚(A0, A1, A2)全部接地,将其I2C设备地址设置为0xA0(写)/0xA1(读)。
2. 外设驱动与集成
硬件搭好了,接下来就是让MCU认识并控制它们。我们需要为每个外设编写或移植驱动程序。
2.1 屏幕驱动(硬件SPI)
首先确保在GD32的标准库或HAL库中,初始化好SPI外设和对应的GPIO(推挽输出模式,高速)。屏幕驱动函数的核心是写命令和写数据。
// 示例:通过硬件SPI发送一个字节(命令或数据) void LCD_WR_Byte(uint8_t data, uint8_t cmd) { // 1. 设置DC引脚电平:高为数据,低为命令 if(cmd) { gpio_bit_set(LCD_DC_PORT, LCD_DC_PIN); } else { gpio_bit_reset(LCD_DC_PORT, LCD_DC_PIN); } // 2. 拉低片选CS gpio_bit_reset(LCD_CS_PORT, LCD_CS_PIN); // 3. 通过SPI发送数据 while (RESET == spi_i2s_flag_get(SPI0, SPI_FLAG_TBE)); // 等待发送缓冲区空 spi_i2s_data_transmit(SPI0, data); while (RESET == spi_i2s_flag_get(SPI0, SPI_FLAG_RBNE)); // 等待接收完成(可读) spi_i2s_data_receive(SPI0); // 读取一下以清除标志 // 4. 拉高片选CS gpio_bit_set(LCD_CS_PORT, LCD_CS_PIN); }初始化时,需要按照屏幕数据手册的时序,依次发送一系列初始化命令。之后,就可以实现画点函数,再基于画点函数实现画线、填充、显示图片和字符等高级功能。
2.2 摇杆数据读取(ADC)
摇杆需要两个ADC通道。配置ADC为规则组扫描模式,开启连续转换和DMA,这样MCU可以自动、不间断地读取摇杆电压值并存入内存,我们只需读取内存中的数值即可。
// 假设使用ADC0,通道0和1对应摇杆X,Y uint16_t adc_value[2]; // 用于DMA传输的数组 void ADC_Config(void) { // ... 初始化ADC和DMA的代码 // 配置规则组:通道0,通道1 // 使能扫描模式、连续转换、DMA请求 } // 在主循环中,直接读取数组即可得到当前ADC值 uint16_t joy_x = adc_value[0]; uint16_t joy_y = adc_value[1]; // 将ADC值转换为方向状态 enum Dir get_joystick_dir(void) { if(joy_x < 1000) return LEFT; else if(joy_x > 3000) return RIGHT; else if(joy_y < 1000) return UP; else if(joy_y > 3000) return DOWN; else return CENTER; }注意:ADC数值的阈值(如上面的1000和3000)需要根据你实际电路的供电电压和摇杆特性进行校准。可以先打印出中心位置和四个极限位置的ADC值,再确定合适的阈值范围。
2.3 震动电机控制(PWM)
使用一个通用定时器(如TIMER3)的PWM输出功能,控制连接到电机驱动三极管基极的GPIO。
void Motor_PWM_Init(uint16_t arr, uint16_t psc) { // 1. 初始化GPIO(复用推挽输出) // 2. 初始化定时器,设置自动重装载值arr和预分频psc,决定PWM频率 // 频率 = 主频 / ( (arr+1)*(psc+1) ),对于电机,几十到几百Hz即可。 // 3. 配置PWM模式(如PWM模式1) // 4. 设置通道输出比较寄存器的初始值(即占空比),并使能通道输出 // 5. 使能定时器 } // 控制震动强度:strength范围0-100(百分比) void set_motor_vibrate(uint8_t strength) { if(strength > 100) strength = 100; // 计算比较寄存器的值:占空比 = pulse / (arr+1) uint16_t pulse = (g_timer_period * strength) / 100; timer_channel_output_pulse_value_config(TIMER3, TIMER_CH_0, pulse); }调用set_motor_vibrate(0)停止震动,调用set_motor_vibrate(70)以70%的强度震动。
2.4 EEPROM读写(I2C)
AT24C02的驱动就是标准的I2C读写。注意I2C通信的时序和应答。
// 向指定地址写入一个字节 void EEPROM_WriteByte(uint8_t addr, uint8_t data) { I2C_Start(); I2C_SendByte(0xA0); // 设备地址+写命令 I2C_WaitAck(); I2C_SendByte(addr); // 内存地址 I2C_WaitAck(); I2C_SendByte(data); // 要写入的数据 I2C_WaitAck(); I2C_Stop(); delay_ms(5); // 必须延时,等待芯片内部写周期完成 } // 从指定地址读取一个字节 uint8_t EEPROM_ReadByte(uint8_t addr) { uint8_t data; I2C_Start(); I2C_SendByte(0xA0); // 设备地址+写命令(发送地址阶段) I2C_WaitAck(); I2C_SendByte(addr); // 内存地址 I2C_WaitAck(); I2C_Start(); // 发送重复起始条件 I2C_SendByte(0xA1); // 设备地址+读命令 I2C_WaitAck(); data = I2C_ReadByte(); I2C_NAck(); // 发送非应答 I2C_Stop(); return data; }3. NES模拟器移植详解
这是整个项目的软件核心。NES(任天堂红白机)的CPU是6502,我们要在ARM Cortex-M4上模拟它的运行。
3.1 模拟器源码选择与准备
网上开源的单片机端NES模拟器有好几个版本。经过对比,我选择了正点原子优化过的版本。这个版本最初源于ye781205的开源项目,正点原子团队对其进行了优化,代码结构更清晰,在STM32上运行稳定,这为我们移植到GD32打下了很好的基础。
你需要获取到这份源码,核心文件通常包括:
nes.c/nes.h:模拟器主循环、调度核心。cpu.c/cpu.h:6502 CPU指令模拟。ppu.c/ppu.h:图像处理单元模拟,负责生成画面。apu.c/apu.h:音频处理单元模拟(本项目未使用音频,可先忽略)。mapper.c/mapper.h:卡带映射器处理,不同的游戏卡带需要不同的映射方式。input.c/input.h:输入控制接口。
3.2 移植关键步骤
移植的本质是让模拟器代码适应我们的硬件平台,主要修改点集中在硬件抽象层。
1. 修改数据类型和编译器相关定义:确保源码中的标准类型定义(如uint8_t、int16_t)与你的编译环境(如ARM GCC)一致。通常包含<stdint.h>即可。
2. 替换图形输出接口:这是最重要的部分。原模拟器的ppu.c中,最终会生成一个代表一帧画面的像素缓冲区(通常是一个uint8或uint16的数组)。我们需要修改显示部分,将这个缓冲区的内容画到我们的LCD上。
// 在模拟器的主循环或渲染函数中,找到画面更新的地方 extern uint16_t nes_framebuffer[240][256]; // 假设模拟器生成的是240x256的16位色缓冲区 void NES_VideoUpdate(void) { // 我们的屏幕是240x280,NES原生分辨率是256x240。 // 可能需要缩放或居中显示。这里简单演示逐点绘制(效率较低,实际可用DMA加速) for(int y=0; y<240; y++) { for(int x=0; x<256; x++) { // 1. 将nes_framebuffer[y][x]的颜色值转换为LCD支持的RGB565格式(如果需要) uint16_t color = convert_color(nes_framebuffer[y][x]); // 2. 调用你的LCD画点函数,可能需要计算偏移量使其居中 LCD_DrawPoint(x_offset + x, y_offset + y, color); } } // 或者更高效的方式:将整个framebuffer通过SPI DMA发送到屏幕 }实际项目中,为了达到流畅的帧率,绝不能使用双重for循环画点。应该将帧缓冲区转换为屏幕驱动能直接接收的数据格式,然后通过SPI的DMA功能一次性发送到屏幕。这是优化性能的关键。
3. 重定义输入读取接口:修改input.c中的按键读取函数,将其映射到我们实际的ADC摇杆和GPIO按键。
uint8_t NES_GetPadState(int pad_num) { uint8_t state = 0; // 读取物理按键和摇杆方向,映射到NES的8个按键:A, B, SELECT, START, UP, DOWN, LEFT, RIGHT if(KEY_A_Pressed()) state |= 0x01; // A if(KEY_B_Pressed()) state |= 0x02; // B if(KEY_SELECT_Pressed()) state |= 0x04; if(KEY_START_Pressed()) state |= 0x08; if(joy_dir == UP) state |= 0x10; if(joy_dir == DOWN) state |= 0x20; if(joy_dir == LEFT) state |= 0x40; if(joy_dir == RIGHT) state |= 0x80; return state; }4. 提供系统时钟和延时:模拟器主循环可能需要delay_ms或获取系统tick的函数,用GD32的SysTick定时器实现即可。
5. 文件系统与游戏ROM读取:你需要一种方式将.nes游戏文件存储到MCU的外部Flash或SD卡中,并实现一个简单的文件读取函数,让模拟器可以读取到ROM数据。对于简单的项目,可以直接将游戏ROM转换成C语言数组,编译进代码里。
3.3 主程序逻辑整合
最后,将各个模块整合到main.c中。
int main(void) { // 1. 系统时钟、中断初始化 System_Init(); // 2. 外设初始化 LCD_Init(); ADC_Init(); Motor_PWM_Init(); I2C_Init(); // 3. 初始化NES模拟器 NES_Init(); // 4. 加载游戏ROM(从数组或存储设备) NES_LoadROM((uint8_t*)super_mario_rom); // 5. 主循环 while(1) { // 运行一帧NES模拟 NES_Frame(); // 处理震动反馈(例如,根据游戏事件设置震动) if(game_hit_event) { set_motor_vibrate(80); delay_ms(100); set_motor_vibrate(0); } // 其他后台任务... } }4. 调试心得与常见问题
1. 屏幕刷新太慢,游戏卡顿:这是最常见的问题。务必使用硬件SPI+DMA的方式来刷新屏幕,避免CPU被大量占用在IO操作上。同时,检查模拟器源码中是否有等待垂直同步的延时,可以适当优化或调整。
2. 摇杆反应不灵敏或方向错乱:首先用调试器或串口打印出ADC的原始数值,观察中心值和边界值是否合理。根据打印结果调整代码中的方向判断阈值。确保ADC的参考电压稳定。
3. 模拟器运行异常,游戏花屏或崩溃:
- 首先确保游戏ROM文件是完整且正确的。
- 检查内存是否足够。NES模拟器需要几十KB的RAM作为帧缓冲和工作内存,确保你的MCU有足够资源,并正确分配堆栈大小。
- 逐步调试,看是在CPU模拟、PPU渲染还是Mapper处理环节出错。可以从非常简单的测试ROM(如
nestest.nes)开始。
4. 震动电机不工作:
- 检查三极管驱动电路是否正确,电机两端是否有电压。
- 用示波器或万用表测量控制引脚,看PWM信号是否正常输出。
- 注意电机的启动电流可能较大,确保电源能提供足够的电流。
这个项目做下来,最深的体会就是嵌入式开发是一个系统工程,硬件、驱动、应用逻辑环环相扣。从点亮第一颗像素,到摇杆控制马里奥跳跃,再到最终完整地运行一个游戏,每一步的调试成功都带来巨大的成就感。希望这份详细的实战记录,能帮你少走些弯路,顺利打造出属于自己的那台复古掌机。
