


大家好,欢迎回来! 今天,我要分享一个既有趣又小巧的项目——口袋版超级任天堂手柄 (Pocket SNES)!这是一个完全从零开始打造的经典超级任天堂手柄迷你版,使用了定制 PCB 板和3D 打印外壳。
这个项目经历了不少改进。最初我做了一个超大号版本,核心是 XIAO SAMD21 微控制器,装在一个特大的外壳里。那时我不得不把外壳分成三部分打印,再组装成一个巨大的手柄单元。对于第二个版本,我的目标是在保留所有功能的前提下,把它缩小到比手掌还小的尺寸。
为了实现这个小巧版本,我打算使用 RP2040-Tiny 迷你开发板,它是树莓派 PICO 的贴片模块版本。我把这块 RP2040 Tiny 模块安装在一个手柄形状的 PCB 板上:正面装有小型贴片微动按钮,背面则固定着 RP2040 主控制器。为了提升握持手感,我在 PCB 板底部加装了一个外壳,使其握持起来更舒适。
别看它体积小,口袋版 SNES 手柄表现相当出色。我们首先用它玩了《Broforce》,需要把手柄按键映射到游戏角色的动作上。简单来说,这个手柄可以用于任何游戏,我们只需要在游戏里设置好按键映射就行。
接着,我们又在 MAC Pi 游戏机上测试了这个手柄。我在 PPSSPP 模拟器上玩了几款游戏,包括《Moto GP 3》和《生死格斗天堂》。同样地,我们也在 PPSSPP 模拟器的设置里映射了手柄按键。
总的来说,这个迷你版的 SNES 手柄的功能和标准尺寸的 SNES 手柄(拥有 12 个按键)是一样的。这个项目最棒的一点是,它只需要把一块 PICO 和 12 个按钮连接起来,简单几步就能完成。
在这篇教程里,我会详细介绍打造这个小巧又简洁的游戏手柄的全过程。让我们开始吧!
所需材料
本项目用到的材料如下:
- 定制 PCB 板(由 PCBWAY 提供服务)
- RP2040 Tiny 开发板(带 USB 适配板)
- 贴片微动按钮
- 3D 打印外壳
- 插件式直角微动开关
- M2 螺丝
第一步:设计



我们从创建项目的模型开始,这一步挑战很大。
在开发这个迷你版本之前,我们做过一个超大号版本(XL)。XL 版本空间充裕,元器件可自由布局,基本不受限制。
但在迷你版本中,最初想把所有元件都放进一个外壳的想法行不通。于是我设计了一块可双面安装元件的 PCB 板,并在底部加装了一个框架结构。这个框架既保护了 USB 适配板,也大大改善了握持手感。
- 贴片按钮安装在 PCB 板的顶面。
- 直角微动开关和 RP2040 Tiny 开发板安装在 PCB 板的底面。
- USB 适配板安装在框架背面,用集成在框架上的螺丝柱固定。
模型定稿后:
- 我们将模型导出为网格文件,使用透明 PLA 材料进行 3D 打印(这样板载的 RGB LED 灯光就能透出来)。
- 同时导出了 PCB 板的 DWG 文件,用于后续的 PCB 设计。
第二步:RP2040-Tiny 微型开发板



我们使用的是基于树莓派 RP2040 核心的 RP2040-Tiny 迷你开发板。它附带一个独立的编程适配板,将 Type-C USB 接口与主电路分开,有效减小了整体厚度和尺寸,方便用户集成到自己的项目中。
技术规格方面: 它本质上就是一个带有 RP2040 微控制器芯片的 Pico,但做了一些改进:
- 板载 8PIN FPC 软排线接口,用于连接 Type-C 适配板。
- 增加了半孔焊盘 (Castellated holes/pads),可以直接焊接到载板上。
- 板上还添加了一颗 WS2812B 2020 封装的 LED 灯(连接到 GPIO16)。
总体而言,核心参数与 Pico 相同:
- RP2040 双核 Arm Cortex M0+ 处理器
- 高达 133 MHz 的灵活时钟频率
- 264KB SRAM
- 2MB 板载闪存
关于这块开发板的详细信息,可以访问其官方 Wiki 页面。
这块板子我是从 PCBWAY 的礼品商店 (Gift Shop) 购买的。这是他们的在线商城,提供正品价格的电子模块、开发板和传感器,也可以通过 PCBWAY 的积分系统“豆子 (beans)”兑换。
第三步:PCB 设计



如前所述,游戏手柄的 PCB 设计其实非常简单:本质上就是将 RP2040 Tiny 开发板连接到 12 个贴片按钮。每个按钮都连接到一个 RP2040 的 GPIO 引脚上。所有贴片按钮的引脚都连接到地线 (GND)。当一个按钮被按下时,与之相连的 GPIO 引脚就接地了,我们的微控制器就能检测到这个状态变化。
绘制好原理图后,我们导出网络表,并使用 3D 模型中的 DWG 文件来定义 PCB 的形状。所有贴片按钮(包括两个扳机按键)都严格按 3D 模型的位置摆放。PCB 板上还开了四个安装孔,用于将框架固定到 PCB 板上。
RP2040 开发板安装在 PCB 板的底面。
第四步:PCBWAY 服务


PCB 设计完成后,我们选择了白色阻焊层 + 黑色丝印的工艺,并在 PCBWAY 的报价页面上传了 Gerber 文件。
一周内就收到了 PCB 板,质量非常棒! 我们在 PCB 的丝印层添加了一些设计元素来提升项目的美观度。PCBWAY 完美地实现了这些定制细节,充分展现了他们卓越的 PCB 制造能力。
在过去的十年里,PCBWay 凭借提供出色的 PCB 制造和组装服务而闻名,成为全球无数工程师和设计师信赖的合作伙伴。
如果你需要高性价比的优质 PCB 服务,可以去看看 PCBWAY。
第五步:PCB 组装 - 贴片按钮安装




- 点焊锡膏: 我们用点胶针头,将普通的 Sn/PB 63/37 焊锡膏逐个点在 PCB 顶面每个按钮焊盘上。
- 放置按钮: 用防静电镊子拾取并安装所有贴片微动按钮。
- 回流焊接: 把电路板放到我们的 Miniware 回流焊加热台上加热。加热台从底部加热 PCB,直到焊锡膏熔化,这样所有元件就一次性焊接到位了。
第六步:PCB 组装 - RP2040 Tiny 安装



RP2040 Tiny 的安装过程和前面的回流焊工艺略有不同。因为 PCB 顶面已有贴片元件,无法再用加热台从底部加热来焊接背面的元件。
- 使用电烙铁: 我们在这里使用电烙铁。首先,在烙铁头上蘸点助焊剂,然后在 RP2040 Tiny 焊盘中的一个焊点上熔化一点焊锡。
- 定位焊接: 用镊子夹起 RP2040 Tiny 模块,精确放到它的位置上。接着用电烙铁加热刚刚上锡的那个焊点。熔化的焊锡会把模块初步固定住。
- 焊接对侧焊点: 再焊接模块对角的焊点,确保模块完全固定。最后,用电烙铁把所有剩下的焊点都焊好。
第七步:PCB 组装 - 插件开关安装



- 安装开关: 将插件式 (THT) 开关从电路板的底面插入。
- 焊接引脚: 在电路板的顶面用电烙铁焊接开关的引脚。
- 剪掉多余引脚: 最后用剪线钳剪掉顶面露出的插件开关引脚。
第八步:连接适配板与控制器



PCB 板组装完成后,开始组装连接 USB 适配板和主控板。
- 连接排线: 我们使用随附的柔性排线 (FPC)。先将排线连接到 Type-C 适配板上。
- 连接主控板: 接着将排线的另一端连接到 RP2040 Tiny 板上的接口。排线的连接方向很重要:蓝色面(通常标识为第1脚)应朝上,并且要确保排线完全插入接口内。
- 整理排线: 原配的排线有点长,我们把它弯曲折叠一下,使其更紧凑。
第九步:框架组装



- 穿入适配板: 先将 USB 适配板穿过框架部件。
- 对齐固定: 将 PCB 板的安装孔与框架上的安装孔对齐。
- 固定框架: 用四颗 M2 螺丝将框架部件紧固到 PCB 板上。
- 固定适配板: 将适配板放到框架背面预留的螺丝柱上,再用两颗 M2 螺丝将其固定。
口袋版 SNES 手柄的组装就完成了!接下来我们看看这个项目的代码。
第十步:代码
本项目使用的代码非常简单:
#include "Adafruit_TinyUSB.h" // 包含USB通信库
#include <Adafruit_NeoPixel.h> // 包含NeoPixel LED控制库
// === 设置 WS2812 LED ===
#define LED_PIN 16 // LED连接在GPIO16
#define NUM_LEDS 1 // 只有1个LED
Adafruit_NeoPixel pixel(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800); // 创建NeoPixel对象
// === 设置游戏手柄 ===
#define NUM_BUTTONS 12 // 总共12个按钮
const uint8_t buttonPins[NUM_BUTTONS] = { // 按钮连接的GPIO引脚号
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11
};
// 定义USB HID报告描述符 (12个按钮的游戏手柄)
uint8_t const hid_report_descriptor[] = {
0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x15, 0x00,
0x25, 0x01, 0x35, 0x00, 0x45, 0x01, 0x75, 0x01,
0x95, 0x0C, 0x05, 0x09, 0x19, 0x01, 0x29, 0x0C,
0x81, 0x02, 0x75, 0x01, 0x95, 0x04, 0x81, 0x03,
0xC0
};
Adafruit_USBD_HID usb_hid; // 创建USB HID对象
uint8_t report[2] = {0}; // 存储按钮状态报告的数组 (2字节 = 16位)
void setup() {
// 1. 配置按钮引脚为上拉输入模式
for (int i = 0; i < NUM_BUTTONS; i++) {
pinMode(buttonPins[i], INPUT_PULLUP);
}
// 2. 初始化NeoPixel LED
pixel.begin(); // 启动LED
pixel.setBrightness(50); // 设置亮度为50 (范围0-255)
pixel.show(); // 初始状态关闭LED
// 3. 配置USB HID (游戏手柄)
usb_hid.setReportDescriptor(hid_report_descriptor, sizeof(hid_report_descriptor)); // 设置报告描述符
usb_hid.setPollInterval(2); // 设置轮询间隔(毫秒)
usb_hid.begin(); // 启动USB HID
// 4. 等待USB连接成功
while (!USBDevice.mounted()) delay(10);
}
void loop() {
// 1. 限制执行频率:每10毫秒运行一次
static uint32_t last = 0; // 记录上次运行时间
if (millis() - last < 10) return; // 如果不到10ms就跳过
last = millis(); // 更新上次运行时间
// 2. 清除按钮状态报告
report[0] = 0; // 清零报告字节0
report[1] = 0; // 清零报告字节1
bool anyPressed = false; // 记录是否有按钮被按下
// 3. 读取所有按钮状态
for (int i = 0; i < NUM_BUTTONS; i++) {
if (digitalRead(buttonPins[i]) == LOW) { // 如果按钮按下(引脚变低电平)
// 计算该按钮在报告中的位置:i/8确定字节索引, i%8确定位索引
report[i / 8] |= (1 << (i % 8)); // 将该按钮对应的位置1
anyPressed = true; // 标记有按钮被按下
}
}
// 4. 更新LED状态:如果有按钮按下则亮紫色,否则关闭
if (anyPressed) {
pixel.setPixelColor(0, pixel.Color(255, 0, 255)); // 设置LED为紫色 (R=255, G=0, B=255)
} else {
pixel.setPixelColor(0, 0); // 关闭LED (颜色值为0)
}
pixel.show(); // 应用LED颜色更改
// 5. 通过USB HID发送按钮状态报告给电脑
usb_hid.sendReport(0, report, sizeof(report));
}
代码解释:
这段代码的核心是使用 Adafruit TinyUSB 库将 RP2040 配置成一个 USB HID 游戏手柄。同时,我们还加入了控制 WS2812B LED 的代码,在按键按下时提供视觉反馈。
#include "Adafruit_TinyUSB.h"
#include <Adafruit_NeoPixel.h>
- 开头引入必需的库:
Adafruit_TinyUSB
负责 USB 通信,Adafruit_NeoPixel
负责控制 WS2812B LED。
#define LED_PIN 16
#define NUM_LEDS 1
Adafruit_NeoPixel pixel(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800);
- 定义连接到 GPIO16 的单个 WS2812 LED。
- 创建一个 Adafruit NeoPixel 对象
pixel
来管理这个 LED。
#define NUM_BUTTONS 12
const uint8_t buttonPins[NUM_BUTTONS] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
- 定义 12 个按钮,它们分别连接到 GPIO 引脚 0 到 11。这些引脚用于读取玩家的按键输入。
uint8_t const hid_report_descriptor[] = { ... };
- 定义 USB HID 报告描述符,描述这是一个 12 按钮的游戏手柄。这告诉电脑如何理解从手柄发过来的数据,即哪些按钮被按下了。
Adafruit_USBD_HID usb_hid;
uint8_t report[2] = {0};
usb_hid
:初始化 USB HID 对象进行通信。
report[2]
:一个数组(2字节=16位),用于存储当前被按下的按钮状态。
void setup() {
// 1. 配置按钮引脚 (上拉输入模式)
for (int i = 0; i < NUM_BUTTONS; i++) {
pinMode(buttonPins[i], INPUT_PULLUP); // 设置为上拉输入:默认高电平(松开),按下时变低电平
}
// 2. 初始化NeoPixel LED
pixel.begin(); // 启动LED
pixel.setBrightness(50); // 设置亮度(0-255)
pixel.show(); // 初始关闭LED
// 3. 配置USB HID (游戏手柄)
usb_hid.setReportDescriptor(hid_report_descriptor, sizeof(hid_report_descriptor)); // 设置报告格式
usb_hid.setPollInterval(2); // 设置电脑查询手柄状态的频率(毫秒)
usb_hid.begin(); // 启动USB HID功能
// 4. 等待USB连接成功
while (!USBDevice.mounted()) delay(10); // 循环等待直到USB被电脑识别
}
- 1. 初始化按钮: 每个按钮引脚配置为上拉输入 (INPUT_PULLUP)。这意味着按钮松开时,引脚读到的是高电平 (HIGH);按钮按下时,引脚通过内部电阻被拉到低电平 (LOW)。
- 2. 初始化 LED: 启动 WS2812B LED,设置初始亮度为 50(范围 0-255),并确保 LED 初始状态是关闭的。
- 3. 配置 USB HID: 设置之前定义的报告描述符(告诉电脑这是一个游戏手柄),设置轮询间隔(电脑询问手柄状态的频率),然后启动 USB HID 功能。
- 4. 等待 USB 连接: 循环等待,直到电脑成功识别并挂载了这个 USB HID 设备,确保通讯正常。
void loop() {
// 1. 限制执行频率:每10毫秒运行一次主循环
static uint32_t last = 0; // 存储上次运行loop的时间点
if (millis() - last < 10) return; // 如果距离上次运行还不到10ms,直接返回(不执行后面代码)
last = millis(); // 更新上次运行时间为当前时间
// 2. 清除按钮状态报告
report[0] = 0; // 清零报告字节0
report[1] = 0; // 清零报告字节1 (共16位,对应12个按钮)
bool anyPressed = false; // 标志位,记录本轮是否有按钮被按下
// 3. 读取所有按钮状态
for (int i = 0; i < NUM_BUTTONS; i++) {
if (digitalRead(buttonPins[i]) == LOW) { // 如果检测到引脚是低电平(按钮按下)
// 计算该按钮在report数组中的位置:
// i / 8 => 确定该按钮状态存储在report[0]还是report[1] (0-7在report[0], 8-11在report[1])
// i % 8 => 确定在该字节中的第几位(0-7)
report[i / 8] |= (1 << (i % 8)); // 将该按钮对应的比特位置1 (|= 按位或操作, << 左移操作)
anyPressed = true; // 设置标志位为true,表示有按钮按下
}
}
// 4. 根据按键状态更新LED
if (anyPressed) {
pixel.setPixelColor(0, pixel.Color(255, 0, 255)); // 如果有按钮按下,设置LED为紫色 (R=255, G=0, B=255)
} else {
pixel.setPixelColor(0, 0); // 如果没有按钮按下,关闭LED (颜色值0)
}
pixel.show(); // 将颜色设置应用到LED上
// 5. 发送按钮状态报告给电脑
usb_hid.sendReport(0, report, sizeof(report)); // 发送报告ID 0,内容为report数组,长度为2字节
}
- 1. 限制执行频率: 使用
millis()
计时确保 loop()
函数的核心逻辑大约每 10 毫秒运行一次,避免不必要的频繁更新占用资源。
- 2. 清除报告: 每次循环开始时,将存储按钮状态的
report
数组清零(两个字节都设为 0),并重置 anyPressed
标志为 false
。
- 3. 读取按钮状态: 遍历所有 12 个按钮:
- 检查对应 GPIO 引脚是否为
LOW
(低电平,表示按钮被按下)。
- 如果按下,计算该按钮在
report
数组中的位置(哪个字节的哪一位),并将该位置设为 1
。
- 同时将
anyPressed
标志设为 true
。
- 4. 更新 LED 反馈: 检查
anyPressed
标志:
- 如果有按钮被按下,将 WS2812B LED 设置为紫色 (R=255, G=0, B=255)。
- 如果没有按钮被按下,则关闭 LED (
0
)。
- 调用
pixel.show()
将颜色更改实际应用到 LED。
- 5. 发送报告: 调用
usb_hid.sendReport()
函数将包含当前按钮状态的 report
数组(2 字节)发送给连接的电脑。电脑根据之前定义的报告描述符将其识别为游戏手柄输入。
第十一步:成果展示



这就是我们这个简单小巧项目的最终成果:口袋版 SNES 游戏手柄!这是一款极致便携的游戏手柄,专为玩怀旧复古游戏设计,布局类似经典的 SNES 手柄。它也能用来玩一些不需要左右摇杆的现代游戏。
这款设备的尺寸仅为 74.5mm x 33.8mm x 11.5mm,堪称最小的 DIY 游戏手柄之一。
测试环节:
- Broforce (Windows): 我们首先在 Windows 上玩《Broforce》。这是一款充满动作元素的横版“跑轰”游戏,包含战役、奖励关卡、可解锁角色以及激烈的敌人战斗。Broforce 的成员是一群受到老牌电影和电视剧角色启发的超硬汉动作英雄,他们的任务是打击恐怖分子并营救被囚禁的兄弟。每个角色都有独特的技能、武器和特殊攻击,适应各种战斗场景。这款游戏非常精彩又好玩,尤其适合多人联机。我们在游戏设置里映射好手柄按键,就能用它操控角色了。
- MAC Pi (树莓派/PPSSPP 模拟器): 我们把这个手柄用在我们之前的项目 MAC Pi 游戏机上。通过 Pi 应用安装了 PPSSPP 模拟器,然后玩了几款 PSP 游戏,包括《Dead or Alive Paradise》和《Moto GP 3》。同样地,我们需要先在模拟器设置里映射好手柄按键。两款游戏运行都很流畅,我们的迷你手柄完全能胜任!
手感反馈: 那些小型贴片按钮按起来手感不算特别软,但整体功能表现良好。
第十二步:未来展望

在下一个版本中,我特别想做的一项改进是:用树莓派 PICO W 或 PICO 2W 替换掉 RP2040 Tiny。因为它们集成了 WiFi/蓝牙功能,这样就完全不需要 USB 线了。这将使设备实现真正的无线便携。
目前,这个项目已经圆满完成了。所有必要的细节,包括 PCB 文件、代码以及其他相关信息,都已整理好并随项目提供。
感谢您耐心把教程完整读完!
祝大家制作愉快![i][i][i][i]