1. 项目概述与核心思路
几年前,我在整理旧物时翻出了一台老旧的Gameboy,那种按下电源键后屏幕亮起、响起经典开机音效的瞬间,一下子就把我拉回了童年。但遗憾的是,它的屏幕已经老化,电池也早已报废。修复它成本不低,一个念头突然冒出来:为什么不自己动手,用更现代的微控制器和元件,复刻一个属于我自己的、可以运行经典游戏的掌机呢?这个想法最终催生了这个基于ATtiny85的复古掌上游戏机项目。
这个项目的核心目标,是打造一个极致简约、完全由自己掌控的便携式游戏设备。它不像树莓派Pico或者ESP32那样功能强大,但正是ATtiny85这种仅有8个引脚、资源极其有限的8位微控制器,带来了独特的挑战和乐趣。你需要像一位老派的程序员,在仅有8KB的Flash和512字节的RAM里“螺蛳壳里做道场”,精心优化每一行代码,高效利用每一个IO口。最终,你将收获一个可以流畅运行《太空侵略者》(Space Invaders)和《青蛙过河》(Frogger)等像素风游戏的完整设备,其核心部件仅包括一片ATtiny85、一块0.96英寸的OLED屏幕、三个按钮、一个蜂鸣器以及一枚3V的纽扣电池。
整个制作过程,你会亲历嵌入式开发的全流程:从在Arduino IDE中为ATtiny85搭建开发环境,到使用另一块Arduino板作为编程器为其烧录引导程序和游戏代码;从在Wokwi这样的在线模拟器上验证电路逻辑,到在真实的万用板上焊接每一个元件;最后,将这些分散的模块整合成一个可以握在手中的完整设备。这不仅仅是一个焊接和编程的练习,更是一次对系统设计、电源管理和代码优化的深度实践。无论你是想重温经典游戏的乐趣,还是希望深入理解微控制器如何与显示、输入、音频设备交互,这个项目都将提供一条清晰、可实现的路径。
2. 核心元件选型与原理剖析
为什么是ATtiny85?在开始动手之前,理解每个元件的角色和选型理由至关重要。这能帮助你在后续遇到问题时,知道从哪里着手排查,甚至在资源耗尽时找到替代方案。
2.1 大脑:ATtiny85微控制器
ATtiny85是Atmel(现属Microchip)AVR家族中的一位“小个子”成员。它只有8个引脚,其中5个可以作为通用输入/输出(GPIO)。对于这个项目,它几乎是量身定做的选择。
- 资源与分配:其8KB的Flash存储器足以容纳我们优化后的游戏代码和字库;512字节的SRAM是运行时变量(如游戏角色位置、子弹数组、分数)的生存空间,需要精打细算;而5个GPIO的分配则是项目成功的关键:
- PB3 (MOSI) 和 PB4 (SCK):这两个引脚在烧录程序时用于与编程器通信。在运行时,我们通过软件模拟I2C协议,将它们重新定义为SDA和SCL,用来驱动OLED屏幕。这是AVR单片机的一个灵活特性,即引脚功能复用。
- PB0 和 PB2:作为游戏的两个动作按钮输入(开火和移动)。我们将其配置为带上拉电阻的输入模式,并启用引脚变化中断(PCINT),以实现即时、低延迟的按键响应,这对于游戏体验至关重要。
- PB1:作为PWM输出,驱动蜂鸣器发出游戏音效。通过快速切换高低电平产生不同频率的方波,模拟“哔哔”声。
- PB5 (RESET):通常作为复位引脚。在我们的设计中,它也被连接了一个按钮,用于游戏复位。这里需要注意,如果将其用作普通IO,需要先通过编程熔丝位(Fuse Bits)禁用复位功能,但本项目保留了其复位功能,并通过模拟读取(ADC)或外部上拉电阻结合代码逻辑来实现复位检测,这是一种更稳妥的做法。
注意:ATtiny85的工作电压范围是1.8V-5.5V。我们使用3V纽扣电池供电,这在其工作范围内,且能显著降低功耗,延长续航。但需注意,在3V电压下,其运行速度(CPU频率)如果仍设置为8MHz,可能会因电压不足而不稳定。因此,在软件中,我们通常将系统时钟设置为内部1MHz或使用8MHz并配合更宽松的电源设计,这是初学者容易忽略的细节。
2.2 眼睛:SSD1306驱动的OLED屏幕
我们选择0.96英寸、128x64分辨率的I2C接口OLED屏幕,而非更大的LCD或SPI接口的OLED,原因如下:
- 接口节省:I2C协议仅需两根线(SDA, SCL),完美匹配ATtiny85所剩无几的IO口。SPI接口通常需要3-4根线,在这里显得奢侈。
- 自发光与对比度:OLED每个像素独立发光,显示黑色时像素点不工作,因此可以实现极高的对比度和纯黑的背景,非常适合显示复古游戏的像素画面,视觉效果远超需要背光的LCD。
- 功耗极低:显示静态画面时,OLED功耗远低于LCD背光。在我们的游戏中,大部分背景是黑色的,这进一步节省了电量。
- 驱动库简化:有成熟的、针对AVR单片机优化过的
Tiny4kOLED或SSD1306Ascii等极简驱动库,它们比通用的Adafruit_SSD1306库体积小得多,更适合ATtiny85有限的存储空间。
2.3 交互与反馈:按钮与蜂鸣器
- 按钮:选用最常用的6x6mm贴片微动按钮或直插式轻触开关。电路设计上采用“上拉电阻”模式。即按钮一端接地(GND),另一端接单片机IO口,同时该IO口通过一个10kΩ电阻连接到VCC(正极)。当按钮未按下时,IO口被电阻“拉”至高电平;按下时,IO口直接与GND相连,变为低电平。单片机通过检测这个“高到低”的跳变来识别按键动作。我们代码中使用的
digitalRead()函数就是读取这个电平状态。 - 蜂鸣器:选择无源蜂鸣器。它与有源蜂鸣器的区别在于,有源蜂鸣器内部有振荡电路,给电就响,但只能发一种声音;无源蜂鸣器内部没有振荡源,需要外部输入不同频率的方波信号才能发声,因此可以演奏简单的旋律。我们利用ATtiny85的PB1引脚输出PWM波来驱动它,通过改变频率来模拟游戏中的射击音效、爆炸声等。
2.4 能源:3V纽扣电池与电源管理
CR2032纽扣电池标称电压3V,容量约200mAh。ATtiny85在1MHz、3V电压下运行,工作电流可能低于1mA。OLED屏幕的功耗是变量,全亮时可能达到10-20mA。因此,理论续航时间可能在几小时到十几小时不等。为了最大化续航,我们在软件中做了关键优化:
- 睡眠模式:当游戏处于非活动状态(如暂停、游戏结束等待复位)时,代码调用
system_sleep()函数,将ATtiny85置入SLEEP_MODE_PWR_DOWN模式。在此模式下,CPU和几乎所有外围时钟都停止,电流消耗可降至1微安以下,电池几乎不再放电。 - 中断唤醒:睡眠模式不是“关机”,它可以通过外部中断唤醒。我们将两个游戏按钮(PB0, PB2)配置为引脚变化中断(PCINT)。当玩家按下任何一个按钮时,就会产生一个中断,立即唤醒单片机,恢复正常游戏流程。这种“随按随用”的设计是便携设备长续航的秘诀。
3. 开发环境搭建与ATtiny85编程实战
ATtiny85本身无法通过USB直接与电脑通信,所以我们需要一个“翻译官”——另一块更常见的Arduino开发板(如Arduino Uno)作为编程器(ISP)。这个过程也称为“烧录引导程序(Bootloader)”,但更准确地说,我们是在配置单片机的熔丝位并直接上传程序。
3.1 配置Arduino IDE支持ATtiny85
首先,确保你电脑上安装的是较新版本的Arduino IDE(1.8.x或2.0+)。
添加开发板支持网址:
- 打开Arduino IDE,点击
文件->首选项。 - 在“附加开发板管理器网址”一栏中,填入以下网址(如果已有其他网址,用逗号分隔):
https://raw.githubusercontent.com/damellis/attiny/ide-1.6.x-boards-manager/package_damellis_attiny_index.json - 点击“好”保存。
- 打开Arduino IDE,点击
安装ATtiny核心:
- 点击
工具->开发板->开发板管理器...。 - 在弹出的窗口中,搜索“attiny”。
- 你应该会找到由David A. Mellis发布的“attiny”包。点击并选择安装最新版本。
- 点击
3.2 将Arduino Uno设置为ISP编程器
现在,拿出你的Arduino Uno和USB数据线。
- 上传ArduinoISP示例程序:
- 用USB线将Uno连接到电脑。
- 在Arduino IDE中,选择开发板为
Arduino Uno,并选择正确的端口。 - 点击
文件->示例->11. ArduinoISP->ArduinoISP。 - 将此程序编译并上传到你的Arduino Uno上。现在,这块Uno就变成了一个专业的AVR芯片编程器。
3.3 硬件连接:搭建编程电路
这是关键且容易出错的一步。你需要用杜邦线将“编程器”(Arduino Uno)与“目标板”(ATtiny85)连接起来。建议先在面包板上搭建这个电路。
| Arduino Uno 引脚 | 连接到 ATtiny85 引脚 | 功能说明 |
|---|---|---|
| 5V | Pin 8 (VCC) | 提供5V工作电源。注意:后续我们游戏机用3V电池,但烧录时通常用5V更稳定。 |
| GND | Pin 4 (GND) | 共地,建立参考零电位。 |
| Pin 10 (RESET) | Pin 1 (RESET) | 编程器通过此线控制目标芯片复位,进入编程模式。 |
| Pin 11 (MOSI) | Pin 5 (PB0) | 主设备输出,从设备输入。编程器发送数据给ATtiny85。 |
| Pin 12 (MISO) | Pin 6 (PB1) | 主设备输入,从设备输出。ATtiny85返回数据给编程器。 |
| Pin 13 (SCK) | Pin 7 (PB2) | 串行时钟线,提供通信时序。 |
连接示意图(面包板视图):
Arduino Uno -> 面包板 -> ATtiny85 5V ------> 正极总线 ------> Pin 8 (VCC) GND ------> 负极总线 ------> Pin 4 (GND) D10 ------> 跳线 ----------> Pin 1 (RESET) D11 ------> 跳线 ----------> Pin 5 (PB0/MOSI) D12 ------> 跳线 ----------> Pin 6 (PB1/MISO) D13 ------> 跳线 ----------> Pin 7 (PB2/SCK)重要提示:在
VCC和GND之间,靠近ATtiny85的位置,务必连接一个0.1uF(104)的陶瓷电容,用于电源去耦,滤除高频噪声,防止编程过程中电压波动导致芯片复位或数据错误。这是很多教程会省略但极其重要的步骤。
3.4 烧录引导程序与设置熔丝位
硬件连接无误后,回到Arduino IDE软件端进行配置。
选择目标板和编程器:
工具->开发板->ATtiny25/45/85。工具->处理器->ATtiny85。工具->时钟->内部 8 MHz。这里注意:我们选择8MHz是为了让芯片以标称速度运行。如果你计划最终使用3V供电,为了稳定性,可以考虑选择内部 1 MHz,但游戏帧率会受影响。一个折中方案是仍烧录8MHz,但通过代码在初始化时降低系统时钟分频,这在后续优化中会提到。工具->编程器->Arduino as ISP。
烧录引导程序:
- 点击
工具->烧录引导程序。 - IDE会通过Uno向ATtiny85写入一组关键的配置信息(熔丝位),将其时钟源设置为内部8MHz RC振荡器,并设置相应的启动延时等。此时,Uno板上的LED会快速闪烁,表示正在通信。烧录成功会在底部状态栏显示“Done burning bootloader”。
- 点击
熔丝位解读:这个操作本质上设置了芯片的“硬件配置”。例如,将CKDIV8熔丝位编程(值为0),意味着上电后系统时钟不进行8分频,直接使用8MHz。如果此熔丝位未编程(值为1),则芯片默认以1MHz启动。理解这一点对调试时钟相关的问题很有帮助。
3.5 上传第一个测试程序
现在,你的ATtiny85已经准备好接收程序了。我们上传一个最简单的“Blink”程序来测试。
- 点击
文件->示例->01.Basics->Blink。 - 需要修改代码,因为ATtiny85的引脚编号与Arduino不同。我们将控制板载LED(通常连接在PB1,即Arduino引脚1)闪烁。
// ATtiny85 Blink Test // LED is connected to PB1 (Arduino Pin 1) void setup() { pinMode(1, OUTPUT); // 设置PB1为输出 } void loop() { digitalWrite(1, HIGH); // 点亮LED delay(1000); // 等待1秒 digitalWrite(1, LOW); // 熄灭LED delay(1000); // 等待1秒 } - 确保开发板、处理器、编程器设置正确。
- 点击“上传”按钮(向右的箭头)。IDE会编译代码,然后通过Uno编程器将其写入ATtiny85的Flash存储器。
- 如果一切顺利,你将看到编译和上传成功的提示。此时,可以将一个LED和220Ω电阻串联后接到ATtiny85的PB1和GND之间,应该能看到LED以1秒间隔闪烁。
实操心得:第一次烧录失败很常见。请按以下顺序排查:1) 检查所有6根连接线是否牢固,特别是VCC和GND;2) 确认ATtiny85芯片方向正确(有半圆缺口或圆点标记的一端对应引脚1);3) 检查是否已安装0.1uF去耦电容;4) 在Arduino IDE中,尝试点击
工具->编程器->Arduino as ISP,然后选择工具->使用编程器上传。这有时比直接点击上传按钮更可靠。5) 如果使用面包板,检查面包板内部连接是否老化、接触不良。
4. 游戏代码解析与核心功能实现
成功点亮LED后,我们就可以挑战核心部分了:让ATtiny85运行游戏。我们将以《太空侵略者》为例,拆解其代码结构。代码通常分为几个核心模块:显示驱动、输入处理、游戏逻辑、声音生成和电源管理。
4.1 显示驱动:在OLED上绘制像素世界
由于资源限制,我们不能使用庞大的图形库。通常我们会使用一个高度精简的、针对SSD1306 OLED的驱动,或者甚至直接通过软件I2C发送最原始的绘图命令。
核心概念:帧缓冲(Framebuffer)与页面(Page)SSD1306 OLED控制器将128x64的屏幕在垂直方向分为8个“页”(Page),每页高8行像素。数据以字节形式发送,每个字节控制一列中8个垂直像素(一个页内的8行)的亮灭(1亮0灭)。我们需要在内存中建立一个代表整个屏幕状态的“帧缓冲”数组。对于128x64的屏幕,如果使用整个缓冲,需要128 * (64/8) = 1024字节,这超过了ATtiny85的RAM总量。因此,必须采用动态绘制或分块缓冲的策略。
常见优化策略:
- 双缓冲(Double Buffering):在RAM中开辟两个缓冲区,一个用于绘制下一帧,一个用于显示当前帧。这在ATtiny85上不现实,因为RAM太小。
- 直接绘制(Direct Drawing):不建立完整缓冲,每次画面更新时,只计算变化的部分(如移动的飞船、子弹),然后向OLED发送局部更新命令。这是最节省RAM的方法。
- 精简缓冲(Tiny Buffer):只缓冲一行或一小块区域的数据。例如,在《太空侵略者》中,外星人矩阵、玩家飞船、子弹、堡垒的位置是已知的。我们可以只存储这些对象的坐标和状态,在
loop()函数中,根据这些坐标实时计算出需要点亮哪些像素,然后直接发送绘图命令到OLED。
代码片段示例(简化绘图函数):
// 假设使用了一个极简的OLED库,提供了setPixel函数 void drawPlayer(int x, int y) { // 在坐标(x, y)处绘制一个3x5像素的飞船 oled.setPixel(x, y); oled.setPixel(x+1, y-1); oled.setPixel(x+1, y); oled.setPixel(x+1, y+1); oled.setPixel(x+2, y); // ... 更多像素点 // 注意:需要处理边界,防止绘制到屏幕外 } void drawInvader(int x, int y, int frame) { // 根据帧数(frame)绘制外星人的两种动画状态 uint8_t sprite[8]; // 定义一个字节数组存储一行精灵数据 if(frame % 2 == 0) { // 精灵数据A: 例如 0b00011000, 0b00111100... memcpy_P(sprite, invader_sprite_A, 8); // 从程序存储器(Flash)复制数据 } else { // 精灵数据B memcpy_P(sprite, invader_sprite_B, 8); } // 将sprite数据绘制到屏幕的(x, y)位置 for(int i=0; i<8; i++) { oled.drawBitmap(x, y+i, sprite[i], 8, 1); // 假设drawBitmap函数绘制一行 } }这里的关键是使用PROGMEM(程序存储器)来存储精灵(Sprite)数据,因为Flash空间相对充裕。使用memcpy_P函数从Flash中读取数据到RAM进行处理。
4.2 输入处理:中断与防抖
游戏需要实时响应按钮操作。我们为“移动”和“开火”按钮使用引脚变化中断(PCINT),为“复位”按钮使用模拟读取或外部上拉。
中断服务程序(ISR):
volatile bool buttonFirePressed = false; volatile bool buttonMovePressed = false; void setup() { // 配置PB0和PB2为输入,启用内部上拉电阻 pinMode(0, INPUT_PULLUP); // PB2, 对应Arduino Pin 0? 注意映射! pinMode(2, INPUT_PULLUP); // PB0, 对应Arduino Pin 2 // 启用引脚变化中断 PCMSK |= (1 << PCINT0) | (1 << PCINT2); // 使能PB0和PB2的引脚变化中断 GIMSK |= (1 << PCIE); // 使能引脚变化中断总开关 sei(); // 开启全局中断 } // 引脚变化中断服务程序 ISR(PCINT0_vect) { // 这是一个非常简化的处理,实际需要防抖 if(digitalRead(2) == LOW) { // 检查PB0 buttonFirePressed = true; } if(digitalRead(0) == LOW) { // 检查PB2 buttonMovePressed = true; } }重要:按键消抖(Debouncing)机械按钮在按下和释放的瞬间,触点会产生物理抖动,导致电平在几毫秒内快速变化,单片机可能误判为多次按下。在中断服务程序(ISR)中直接处理状态是不稳妥的,因为ISR应该尽可能短。更好的做法是:在ISR中只设置一个“按键事件发生”的标志位,然后在主循环
loop()中,通过延时或状态机来进行消抖和确认。// 在主循环中处理消抖 void loop() { if(buttonFirePressed) { // 中断标志被置位 delay(50); // 等待一段时间(如50ms)跳过抖动期 if(digitalRead(2) == LOW) { // 再次确认按键仍被按下 // 执行真正的开火逻辑 playerFire(); } buttonFirePressed = false; // 清除标志位 } // ... 其他游戏逻辑 }
4.3 游戏逻辑与状态机
游戏的核心是一个大状态机(State Machine)。它定义了游戏可能处于的各种状态(如开始画面、进行中、暂停、游戏结束),以及状态之间转换的条件。
简化游戏主循环:
enum GameState { MENU, PLAYING, GAME_OVER, PAUSED }; GameState currentState = MENU; void loop() { switch(currentState) { case MENU: drawMenu(); if(buttonFirePressed) { // 按开火键开始游戏 initGame(); // 初始化游戏变量(玩家位置、敌人位置、分数等) currentState = PLAYING; } break; case PLAYING: processInput(); // 处理移动和开火 updateGame(); // 更新所有对象位置(玩家、敌人、子弹),检查碰撞 drawGame(); // 将更新后的状态绘制到屏幕 generateSound();// 根据事件产生声音 if(isPlayerDead()) { currentState = GAME_OVER; } // 进入睡眠的检查可以放在这里,例如一段时间无操作后 break; case GAME_OVER: drawGameOverScreen(); if(buttonResetPressed) { // 按复位键回到菜单 currentState = MENU; } break; case PAUSED: // ... 暂停逻辑 break; } // 每次循环末尾,检查是否进入睡眠 checkAndSleep(); }updateGame()函数是游戏的大脑,它按照固定的时间步长(用millis()函数实现非阻塞延时)移动外星人矩阵、更新子弹位置、进行碰撞检测(判断子弹是否击中敌人或玩家,敌人是否碰到底部等),并更新分数。
4.4 声音生成:用PWM模拟音效
ATtiny85的硬件PWM可以输出频率可调的方波。我们通过tone()函数或直接操作定时器寄存器来产生特定频率的声音。
void beep(int frequency, int duration) { tone(1, frequency); // 在PB1(Arduino Pin 1)上产生指定频率的PWM delay(duration); noTone(1); // 停止发声 } // 在游戏逻辑中调用 void playerFire() { // ... 发射子弹逻辑 beep(800, 50); // 发射时发出一个短促的“哔”声 } void invaderKilled() { // ... 敌人被消灭逻辑 beep(1200, 100); // 消灭敌人时音调更高 }由于PWM驱动能力有限,直接连接蜂鸣器声音可能很小。通常会在PB1和蜂鸣器之间加一个NPN三极管(如2N3904)进行放大,并在基极串联一个1kΩ电阻到PB1。
4.5 电源管理:睡眠模式实现
这是实现长续航的关键。我们使用<avr/sleep.h>库。
#include <avr/sleep.h> #include <avr/power.h> #include <avr/wdt.h> // 看门狗定时器,可用于唤醒 void system_sleep() { // 关闭未使用的外设以省电 power_adc_disable(); power_timer0_disable(); // 注意:禁用timer0会影响delay()和millis() power_timer1_disable(); // 设置睡眠模式为 POWER_DOWN,最省电 set_sleep_mode(SLEEP_MODE_PWR_DOWN); sleep_enable(); // 确保在睡眠前完成所有IO操作 sleep_cpu(); // 进入睡眠 // 程序在此暂停,直到被中断唤醒... sleep_disable(); // 唤醒后继续执行 // 重新启用必要的外设 power_all_enable(); } void checkAndSleep() { static unsigned long lastActivityTime = millis(); const unsigned long sleepTimeout = 30000; // 30秒无操作进入睡眠 if((millis() - lastActivityTime) > sleepTimeout) { // 进入睡眠前,可以关闭OLED显示以进一步省电 oled.ssd1306_command(SSD1306_DISPLAYOFF); system_sleep(); // 被中断唤醒后 oled.ssd1306_command(SSD1306_DISPLAYON); lastActivityTime = millis(); // 重置活动计时器 } // 每次有按钮按下时,更新lastActivityTime if(buttonPressed) { lastActivityTime = millis(); } }注意:进入深度睡眠(PWR_DOWN)后,只有外部中断、看门狗中断等少数事件能唤醒芯片。我们配置的按钮中断(PCINT)可以唤醒它。但要注意,唤醒后程序会从
sleep_cpu()之后继续执行,所有变量状态保持不变。确保在睡眠前保存必要的游戏状态(如果需要的话),并处理好外设(如OLED)的重新初始化。
5. 电路焊接与实体机制作
当代码在模拟器或面包板上运行稳定后,就可以着手制作最终的实体设备了。这一步将从“原型”走向“产品”。
5.1 从面包板到万用板(洞洞板)
- 规划布局:在纸上或脑海中先规划好所有元件在万用板上的位置。核心原则是:ATtiny85居中,OLED屏幕在上方,三个按钮在下方或侧面,蜂鸣器和电池座放在背面或边缘。尽量使连线最短、最直,避免交叉。
- 焊接核心芯片:首先焊接IC座(推荐使用8脚DIP座),而不是直接焊接ATtiny85。这样方便日后更换或调试芯片。注意缺口方向。
- 焊接电源相关:焊接3V电池座。从电池正极(+)引出一根线作为电路的
VCC总线,负极(-)作为GND总线。在ATtiny85的VCC(Pin 8)和GND(Pin 4)附近,分别焊接一个0.1uF的陶瓷电容到地,进行电源去耦。这是保证系统稳定工作的基石。 - 焊接OLED屏幕:OLED屏幕通常有4个引脚(VCC, GND, SCL, SDA)。使用排针或直接焊接导线。根据代码定义,将SCL连接到ATtiny85的PB4,SDA连接到PB3。VCC和GND分别连接到电源总线。
- 焊接按钮:三个按钮的一端全部连接到
GND总线。另一端分别连接到:- 开火按钮 ->PB0
- 移动按钮 ->PB2
- 复位按钮 ->RESET (PB5)
- 特别注意复位按钮:需要在RESET引脚和VCC之间焊接一个10kΩ的上拉电阻。这样,当按钮未按下时,RESET引脚被拉高;按下时,引脚接地,触发芯片复位。这是标准的复位电路。
- 焊接蜂鸣器:无源蜂鸣器有正负极之分,通常长脚为正。正极通过一个100Ω的限流电阻连接到PB1,负极接
GND。如果需要更大音量,可以按前面所述,在PB1和蜂鸣器正极之间加入一个NPN三极管放大电路。 - 检查与飞线:使用万用表的通断档,仔细检查每一条连接是否正确、是否短路(特别是VCC和GND之间)。对于无法通过板子背面铜箔走通的连接,使用细导线(飞线)在元件面进行连接,并确保焊接牢固,飞线整齐不杂乱。
5.2 外壳设计与装配
一个舒适的外壳能极大提升使用体验。你可以使用3D打印、激光切割亚克力,甚至手工改造一个旧的塑料盒。
- 人机工程学:按钮位置要便于拇指操作。屏幕视角要正对眼睛。整体大小和厚度要适合手握。
- 开孔精度:使用卡尺精确测量屏幕和按钮的位置,在外壳上开孔。可以先开小一点,再用锉刀慢慢修整到合适大小。
- 固定方式:使用螺丝、卡扣或胶水固定内部电路板。确保元件,特别是OLED屏幕,被牢固固定,不会因晃动而松动。
- 电池仓:设计易于更换电池的仓门。考虑使用带开关的电池座,或者通过软件长按某种组合键关机(进入深度睡眠)。
5.3 最终测试与调试
组装完成后,不要急于合上外壳,先进行通电测试。
- 上电测试:装入电池,观察是否有异常发热、冒烟(立即断电!)。正常情况下,电流应非常小。
- 功能测试:
- 按下复位按钮,观察游戏是否正常启动,显示开始画面。
- 测试两个游戏按钮,角色能否移动和开火。
- 测试声音,蜂鸣器是否按游戏事件发出声音。
- 测试睡眠功能,静置一段时间后,屏幕是否熄灭,按下按钮是否能立即唤醒。
- 功耗测量:使用万用表的电流档,串联在电池和电路之间,分别测量游戏运行时的电流和睡眠时的电流。睡眠电流应低于10微安(μA)为佳。如果睡眠电流过大,检查是否有LED漏电、上拉电阻值过小(导致电流过大)等问题。
- 压力测试:连续玩10-15分钟,观察是否有死机、画面错乱、响应变慢等情况。这可能是电源不稳(电池电量不足或去耦电容没焊好)或软件逻辑缺陷导致的。
6. 常见问题排查与进阶优化
即使按照指南操作,你也可能会遇到一些问题。这里列出一些常见故障及其解决方法。
6.1 编程与烧录问题
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| “avrdude: stk500_getsync() attempt X of 10: not in sync” | 1. 硬件连接错误或松动。 2. 目标芯片(ATtiny85)供电不足或电压不对。 3. Arduino作为ISP的程序未正确上传。 4. 端口选择错误。 | 1.逐根检查6根编程线,确保VCC和GND连接正确且牢固。 2. 测量ATtiny85的VCC和GND之间电压,应为5V左右。确保已焊接0.1uF去耦电容。 3. 重新在Uno上上传 ArduinoISP示例程序。4. 在IDE中确认选择了正确的COM端口。 |
| 上传成功但程序不运行 | 1. 熔丝位设置错误,特别是时钟源。 2. 复位引脚被意外拉低。 3. 电源问题。 | 1. 确认烧录引导程序时选择的时钟是“内部 8 MHz”。如果使用3V电池,尝试选择“内部 1 MHz”重新烧录。 2. 检查复位按钮是否卡住,或复位引脚的上拉电阻是否虚焊。 3. 断开编程器,仅用电池供电测试。 |
| 程序运行不稳定,随机复位 | 1. 电源噪声大。 2. 代码中存在数组越界、堆栈溢出等错误。 3. 看门狗定时器未正确处理。 | 1.务必在ATtiny85的VCC和GND引脚附近焊接0.1uF电容。如果使用电机等感性负载,需额外加更大容量的电解电容(如10uF)。 2. 检查代码中数组访问的边界。ATtiny85 RAM极小,避免使用大型局部变量。 3. 如果启用了看门狗(WDT),确保在超时前定期喂狗( wdt_reset())。 |
6.2 显示与输入问题
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| OLED屏幕不亮或白屏 | 1. I2C地址不对。 2. 初始化序列错误或时序问题。 3. 电源或连接问题。 | 1. 常见的SSD1306地址是0x3C或0x3D,检查代码中begin()函数的地址参数。2. 确保使用的OLED驱动库兼容ATtiny85和3.3V/5V逻辑电平。有些库初始化时需要延时。 3. 用万用表测量OLED的VCC是否有3V电压,SCL/SDA线是否连通。 |
| 画面闪烁或有残影 | 1. 刷新率过高,I2C通信跟不上。 2. 电源带载能力不足。 | 1. 降低游戏帧率,或在两次完整刷新之间增加微小延时。 2. 电池电量可能不足,换新电池试试。在电源总线并联一个47uF的电解电容稳压。 |
| 按钮无反应或连发 | 1. 上拉电阻未启用或虚焊。 2. 按键消抖代码有问题。 3. 中断配置冲突。 | 1. 代码中设置pinMode(pin, INPUT_PULLUP),或硬件上焊接10kΩ上拉电阻到VCC。2. 确保消抖延时足够(通常10-50ms),且在主循环中处理,不在ISR中延时。 3. 检查是否多个中断源共用一个中断向量,处理不当导致丢失中断。 |
6.3 进阶优化技巧
当基本功能实现后,你可以尝试以下优化,让你的游戏机更完善:
- 增加更多游戏:Flash空间还有剩余吗?可以尝试移植更简单的游戏,如《Pong》(乒乓球)、《Snake》(贪吃蛇)。需要为每个游戏设计独立的代码文件和资源,并通过菜单选择。
- 实现游戏存档:ATtiny85内部有EEPROM(通常512字节)。可以利用它来保存最高分记录。使用
EEPROM.write()和EEPROM.read()函数,注意EEPROM有写入寿命限制(约10万次),不要在每个游戏循环中都写。 - 优化功耗到极致:
- 在睡眠前,将未使用的IO口设置为输出低电平或输入带上拉,避免浮空输入消耗电流。
- 如果游戏逻辑允许,可以动态调整系统时钟。在菜单界面或简单场景使用1MHz,在需要快速响应的游戏场景切换到8MHz。
- 使用
power_all_disable()关闭所有外设模块(ADC, Timer, USI等),在需要时再power_all_enable()。
- 改进音效:目前是单音调蜂鸣。可以尝试用两个不同频率的PWM快速切换来模拟更复杂的音效,或者使用一个简单的R-2R电阻网络配合多个IO口来产生粗糙的“数字音频”。
- 制作PCB:如果你希望作品更精致、可靠,可以使用KiCad或EasyEDA等免费工具,根据万用板上的成功电路,设计一块专属的PCB。打样成本现在已经很低,这能让你的游戏机真正拥有“产品级”的完成度。
这个项目从一片比指甲盖还小的芯片开始,最终变成一个可以握在手中、运行着童年记忆里游戏的设备,整个过程充满了挑战和成就感。它不仅仅是一个玩具,更是一个深入学习嵌入式系统底层原理的绝佳载体。当你按下自己焊接的按钮,屏幕上的像素飞船射出的子弹击中外星人,并发出一声清脆的蜂鸣时,那种由自己亲手创造交互的快乐,是任何现成商品都无法替代的。希望这份详细的指南能帮你绕过我当年踩过的那些坑,顺利点亮属于你自己的那一片像素星空。如果在制作过程中遇到任何代码或硬件上的“怪事”,不妨回到最基本的电源、时钟和信号连接这三点来排查,大多数问题都藏在这些基础之中。