简介:这是一份基于STM32微控制器的游戏手柄设计工程资源,面向嵌入式学习者和电子设计爱好者,适合用来掌握GPIO按键扫描、中断响应与USB HID设备实现等技能。资源包共354个文件,压缩后约11.8MB,以C源码、H头文件、TXT说明、工程配置(uvproj)以及编译生成的hex/axf等文件为主,结构覆盖代码、文档与构建产物,可直接打开工程查看运行逻辑。设计中包含方向键与8个独立按键的输入处理,并实现USB与电脑通信,使手柄可被系统识别为游戏控制器。通过阅读源码可学习STM32CubeMX初始化流程、按键消抖与扫描方法、USB描述符定制等实用内容,对于理解STM32嵌入式开发与USB协议具有较好参考价值。目前已有2400人学习下载,适合作为课程设计或入门进阶的参考资料。
1. STM32游戏手柄:方向加8个按键,先把“能不能用”说透
想做个STM32游戏手柄,方向加8个按键,插电脑上就能被识别成手柄,这是很多从开发板起步的人都会有的念头。一开始我也以为要画一块像样的PCB,实际上只要一块带USB的最小系统板,加一个双轴摇杆和8个微动开关,就能让系统把它当标准HID设备用,连驱动都不用装。这个工程做起来不难,但坑不少,从USB枚举失败到摇杆飘,我全踩过一遍。这篇按我的搭建顺序写:硬件怎么接、按键消抖和ADC怎么调、HID上报怎么写,最后是几条最麻烦的排查记录。新手可以顺着走,已经会点外设的可以直接看参数和坑在哪。
2. 硬件连接与引脚分配:摇杆、8个按键和USB怎么接不翻车
手柄的电路其实很朴素,不需要运放、电平转换,甚至不需要外部晶振校准,难的是引脚分配和几个不起眼的硬件细节。这部分要是接错,后面写再多代码都是白费,所以先把电路上的事情理顺。
2.1 主控选择与器件清单
主控我选的是STM32F103C8T6,64KB Flash、20KB RAM,内置USB全速从机外设,做HID手柄绰绰有余。F103系列在市面上最常见,板子也便宜,坏了换一块不心疼。更高端的F407性能强,但USB外设配置反而更复杂,对纯手柄这种小流量应用完全是浪费。
配套器件也很常规,列一下我实际用到的:
| 器件 | 规格/数量 | 作用 |
|---|---|---|
| STM32最小系统板 | F103C8T6 一块 | 主控,提供USB、ADC、GPIO |
| 双轴摇杆模块 | 内置两个10k电位器 | 提供方向X/Y模拟量 |
| 微动按键 | 6x6或12x12,8个 | 8个独立按键输入 |
| 电阻 | 10k插脚电阻 8颗 | 按键外部上拉,防干扰 |
| 电阻 | 1.5k贴片 1颗 | USB D+上拉,枚举必需 |
| 电容 | 100nF 若干、10uF 1颗 | 电源去耦,稳定ADC参考 |
这里有个容易忽略的点:摇杆模块上自带的电位器是模拟器件,供电必须接干净的3.3V,如果直接从某个外设的引脚取电,摇杆一动电压就抖,ADC读出来的数值会跟着跳。
2.2 引脚分配与接线逻辑
我按习惯把数字和模拟分开管,避免数字信号干扰ADC采样。按键全放在GPIOB的低8位,摇杆放在ADC1的通道0和通道1,USB则用PA11和PA12这对固定引脚。完整分配如下:
| 信号 | 引脚 | 接法说明 |
|---|---|---|
| 摇杆X轴 | PA0 | 摇杆模块的VRx输出,接ADC1_IN0 |
| 摇杆Y轴 | PA1 | 摇杆模块的VRy输出,接ADC1_IN1 |
| 按键1~8 | PB0~PB7 | 每个按键一端接对应引脚,另一端接GND |
| USB D- | PA11 | 直连USB座的D- |
| USB D+ | PA12 | 接USB座D+,同时经1.5k电阻上拉到3.3V |
| 摇杆供电 | 3.3V / GND | 模块VCC接3.3V,GND接GND |
按键为什么这样做:PB0~PB7配置成上拉输入,默认读到高电平,按下后引脚被拉低。这样省掉8个外部下拉电阻,而且按键松开和按下的电平区分清楚,软件消抖也好做。摇杆X/Y输出是0~3.3V的模拟电压,对应ADC的0~4095。
2.3 USB D+上拉电阻与供电隔离
这部分是整块板最容易翻车的地方,单独拿出来说。全速USB设备靠D+线上的1.5k上拉电阻告诉主机“我是全速设备”,没有这颗电阻,主机根本不知道有东西插进来。很多最小系统板会在板上预留这个电阻的位置,但有些简洁版没有。我拿到板子第一件事就是查原理图,确认D+上拉有没有接好。
供电上也要留个心眼。USB枚举瞬间电流大,摇杆和按键虽然功耗低,但如果它们和USB上拉共用同一个压降较大的稳压器,摇杆快速拨动时电流波动会把D+上拉电压拉低,导致设备刚枚举成功就掉线。我习惯在3.3V电源处并一颗100nF和一颗10uF,并按“摇杆模块的地尽量靠近主控地”的原则走线。如果不做板子,用杜邦线搭,就把地线并成星形,不要串成一串。
提示:USB D+在空闲时电压应该在3V左右,低于2.8V基本可以断定上拉电阻没接好或者焊虚了。
3. 按键与摇杆驱动:消抖、ADC滤波和方向量化
电路接好,接下来就是让单片机“读得懂”这些信号。按键要消抖,摇杆要滤波,最后还得把ADC值变成游戏能识别的方向和按键状态。这里的每一步都会直接影响手感,值得慢慢调。
3.1 按键初始化与消抖
我把PB0~PB7配置成上拉输入,按下为0,读取后按位取反,用bit0~bit7代表8个按键,1表示按下。
#define KEY_GPIO_PORT GPIOB #define KEY_GPIO_PINS (GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | \ GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7) /* 按键引脚全部初始化为上拉输入 */ static void key_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin = KEY_GPIO_PINS; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(KEY_GPIO_PORT, &gpio); }GPIO速度在这里设成低速就够了,按键信号频率很低,高速反而容易引入振铃。内部上拉一般够用,但如果用的杜邦线比较长,环境干扰明显,我建议再外接10k上拉到3.3V,这样松开时的电平稳一点。
消抖逻辑我用的是“边沿触发+持续确认”:
static uint8_t keys_prev = 0xFF; static uint32_t key_tick[8]; /* 返回消抖后的按键状态,bit0~bit7对应8个按键 */ uint8_t read_keys_with_debounce(void) { uint8_t raw = 0; uint8_t now = 0; uint32_t t = HAL_GetTick(); raw = (uint8_t)(~HAL_GPIO_ReadPort(KEY_GPIO_PORT) & 0xFF); for (int i = 0; i < 8; i++) { if ((raw & (1 << i)) && !(keys_prev & (1 << i))) { key_tick[i] = t; /* 第一次检测到按下,记时间 */ } if ((raw & (1 << i)) && (t - key_tick[i] >= 20)) { now |= (1 << i); /* 持续按下20ms才认 */ } } keys_prev = raw; /* 保存原始电平用于边沿判断 */ return now; }20ms消抖时长是我试过比较稳的值,太短会放过机械抖动,太长打游戏时连按会被吞。逻辑说明:keys_prev记录上一次的原始电平,用于判断“刚按下”这一瞬间;HAL_GetTick()是系统自带的时间基准,不阻塞主循环。按键按下时第一个if会被触发,但now要等20ms之后才置位,这样抖动期间的多个毛刺不会导致重复触发。读按键放在主循环里轮询就行,别在外部中断里做延时消抖,中断里延时会把USB协议栈饿死。
3.2 摇杆双轴ADC与均值滤波
摇杆输出的是模拟量,用ADC1的通道0和通道1同时采样X和Y。我开了扫描模式加DMA,让ADC持续把数据搬到内存,软件只负责取最近几个点做均值。
#define ADC_BUF_SIZE 8 static uint16_t adc_buf[ADC_BUF_SIZE]; /* 初始化ADC1双通道,DMA模式 */ static void adc_init(void) { __HAL_RCC_ADC1_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); ADC_ChannelConfTypeDef adc_ch = {0}; hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; hadc1.Init.ContinuousConvMode = ENABLE; hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; hadc1.Init.NbrOfConversion = 2; HAL_ADC_Init(&hadc1); adc_ch.Channel = ADC_CHANNEL_0; /* PA0 -> X轴 */ adc_ch.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &adc_ch); adc_ch.Channel = ADC_CHANNEL_1; /* PA1 -> Y轴 */ HAL_ADC_ConfigChannel(&hadc1, &adc_ch); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, ADC_BUF_SIZE); }关键参数有两个。采样时间我选了239.5周期,让内部采样电容充分充电,通道阻抗高时数值更稳。DMA传输长度设8,意味着每轮采4组X/Y数据,配合软件均值滤波。
/* 取DMA缓冲区中最新4组采样值求平均,传入通道偏移0或1 */ int32_t filter_adc_channel(int channel_offset) { int32_t sum = 0; int count = ADC_BUF_SIZE / 2; /* X和Y交替存放,各占4个 */ for (int i = 0; i < count; i++) { sum += adc_buf[i * 2 + channel_offset]; } return sum / count; }均值滤波不是万能的。DMA缓冲区里X和Y交替排列,所以偏移0取X,偏移1取Y。这里只做最简单的算术平均,已经能滤掉多数电源高频噪声。如果摇杆模块本身电位器磨损,均值也无济于事,那就是后面要讲的校准问题。
3.3 方向量化与死区设置
游戏手柄方向轴不会直接暴露ADC原始值,而是转换成带符号的8位数值。12位ADC的0对应-127,4095对应+127,2048附近是回中位。实际摇杆回中时,ADC值经常落在2010到2080之间,如果不设死区,游戏里角色会自己缓慢走动。
/* 把12位ADC值映射成-127~127,并带死区 */ int8_t axis_to_signed(int32_t adc_val, int32_t dead_zone) { int32_t offset = adc_val - 2048; if (offset > -dead_zone && offset < dead_zone) { return 0; /* 死区内,输出0 */ } if (offset < -2048) { offset = -2048; /* 防止溢出 */ } return (int8_t)(offset * 127 / 2048); }死区大小我放在了dead_zone参数里,测试时可以从20往上调。死区太小,摇杆只要有一点点偏差就输出方向;死区太大,微调时没反应。我在实际手柄上取的值是20,对应ADC原始值大约偏移20,体感比较合适。注意最后映射时先除以2048再乘127,避免整数溢出,offset范围是-2048到2048,乘127后用int32_t撑得住。
4. USB HID实现:让STM32被系统认成标准游戏手柄
按键和方向数据读出来之后,剩下的问题是怎么让电脑把这些字节当成手柄输入。这叫USB HID协议,本质是向主机描述“我是什么设备、我的数据长什么样”。STM32F103内置USB全速外设,枚举过程由硬件和库共同完成,我们主要操心报告描述符和上报函数。
4.1 HID报告描述符:2轴和8按键的编码方式
报告描述符决定主机如何解析我们上报的数据。我要做成标准游戏手柄:两个8位带符号轴,8个按钮各占1个bit,总报告长度3字节。描述符数组如下:
/* 2轴、8按键手柄报告描述符 */ static const uint8_t hid_report_desc[] = { 0x05, 0x01, /* Usage Page (Generic Desktop) */ 0x09, 0x05, /* Usage (Game Pad) */ 0xA1, 0x01, /* Collection (Application) */ /* 两个方向轴 */ 0x09, 0x30, /* Usage (X) */ 0x09, 0x31, /* Usage (Y) */ 0x15, 0x81, /* Logical Minimum (-127) */ 0x25, 0x7F, /* Logical Maximum (127) */ 0x75, 0x08, /* Report Size (8 bits) */ 0x95, 0x02, /* Report Count (2) */ 0x81, 0x02, /* Input (Data,Var,Abs) */ /* 8个按键,每个占1bit */ 0x05, 0x09, /* Usage Page (Button) */ 0x19, 0x01, /* Usage Minimum (Button 1) */ 0x29, 0x08, /* Usage Maximum (Button 8) */ 0x15, 0x00, /* Logical Minimum (0) */ 0x25, 0x01, /* Logical Maximum (1) */ 0x75, 0x01, /* Report Size (1 bit) */ 0x95, 0x08, /* Report Count (8) */ 0x81, 0x02, /* Input (Data,Var,Abs) */ 0xC0 /* End Collection */ };这个描述符里最容易错的是按键的Usage Minimum和Usage Maximum。按键序号必须从1开始连续编号,主机才会把这些bit映射成8个不同的按钮;如果写错成0到7,部分系统会直接不识别或有缺失。另外我特意没加Report ID,这样上报数据只有3字节,省掉一个字节的头。在PC上验证时,设备会被识别成一个带方向轴和8个按钮的游戏手柄。
4.2 设备初始化和HID上报函数
USB初始化由芯片厂商的官方代码生成工具自动完成,我们需要指定端点描述符和报告描述符。F103作为全速设备,HID中断端点用1ms间隔,报告长度设置为3字节。设备句柄在生成工程里一般是hUsbDeviceFS,如果你的工程变量名不同,替换成自己的即可。
上报部分的核心是三个字节:
/* 每1ms由定时器回调或主循环调用一次 */ void hid_send_report(void) { int8_t axis_x = axis_to_signed(filter_adc_channel(0), 20); int8_t axis_y = axis_to_signed(filter_adc_channel(1), 20); uint8_t keys = read_keys_with_debounce(); uint8_t report[3]; report[0] = (uint8_t)axis_x; /* X轴,-127~127,补码表示 */ report[1] = (uint8_t)axis_y; /* Y轴 */ report[2] = keys; /* bit0~bit7对应按键1~8 */ USBD_HID_SendReport(&hUsbDeviceFS, report, 3); }USBD_HID_SendReport是协议栈提供的发送接口,参数分别为设备句柄、数据指针和长度。发送频率不用真的卡死1ms,USB中断端点本身有最大速率限制,主循环跑几毫秒一次也不会明显影响手感。但要注意:发送频率太低,按键延迟会超过几十毫秒,玩动作游戏能感觉到不跟手;太高则浪费CPU,因为主机侧HID轮询默认也就1ms一次。我一般把上报放在主循环里,配合一个5ms的软件定时器。
4.3 主机侧验证:从设备管理器到游戏控制器
代码烧进去之后不要急着进游戏,先在系统面板里确认枚举结果。插上USB,打开设备管理器,应该看到“HID-compliant game controller”之类的条目。然后打开系统的游戏控制器设置,里面会显示这个手柄的轴和按钮,拨动摇杆时十字光标会动,按按键时对应的方框会亮。
如果设备管理器有设备但游戏控制器里打不开属性,多半是报告描述符和实际发送字节对不上,回去检查report[3]的长度和描述符里的Report Count。如果能在控制器面板里看到灵敏度,说明USB枚举和上报链路都是通的,剩下就是手感调优。
提示:验证时先拨动摇杆,再按按键,最后同时按多个键。很多问题在单独按正常、组合按不正常时才暴露。
5. 避坑与排查:USB不识别、方向漂移和按键粘连
这个工程我前后调了两天,遇到的大多数问题都不是代码逻辑,而是硬件细节和描述符配置。按现象分类记下来,下次再做USB手柄类项目能少走弯路。
5.1 枚举失败:设备管理器一片空白
现象:USB插上后,系统完全没反应,设备管理器也不出现未知设备。
原因:排查第一步就是D+上拉电阻。F103的全速USB外设要求D+经1.5k电阻上拉至3.3V,我用的核心板默认没接这个电阻,D+一直处于低电平,主机自然不会认为有全速设备插入。
解决:在PA12和3.3V之间焊一颗1.5k电阻。焊好后用万用表量D+对地电压,空闲状态应该在3V左右。我后来每次焊接USB设备,都会先量这个电压再插电脑,已经被这个习惯救过好几次。
5.2 插上后反复断开重连
现象:设备能被识别,但过一两秒断开,然后又识别,循环往复,系统提示音一直响。
原因:电源跌落。摇杆快速拨动和按键同时按下时,板上3.3V电压瞬间被拉低,USB D+上拉电压跟着掉,主机就认为设备断开了。后来发现核心板上的稳压器输出能力有限,而摇杆模块和USB共用这一路。
解决:在电源入口并联100nF和10uF电容,并把USB D+上拉的供电单独接到3.3V的主滤波电容附近。如果用外部供电,优先用独立的3.3V稳压器给USB部分供电,模拟部分再从同一路滤波后引出。
5.3 摇杆回中时方向漂移
现象:摇杆松手后,游戏控制器里十字光标不在正中心,有时还会自己往一个方向缓慢移动。
原因:机械电位器回中精度和ADC参考电压浮动共同造成的。2048是理想中值,实际摇杆回中ADC值可能在2010到2080之间波动,加上3.3V供电纹波,数值就不稳定。
解决:软件校准。上电后先让用户保持摇杆回中5秒,把这5秒的ADC均值记录为中值,替代写死在代码里的2048。死区再配合校准值,能解决绝大多数漂移。如果校准后还是飘,就要检查摇杆模块电源纹波。
5.4 按键粘连和误触发
现象:明明只按一次,游戏里却像连按了两三次;或者按相邻按键时两个一起触发。
原因:机械按键抖动没滤干净。我最初消抖只做了2ms,快速操作时抖动毛刺被当成多次按下。此外,如果按键引脚用内部上拉,长杜邦线带来的干扰也可能让引脚电平跳变。
解决:把消抖时间从2ms提到20ms,并将按键状态改成“边沿+持续确认”逻辑。按键线超过10cm时,外接10k上拉并用万用表观察波形;条件允许的话,按键和主控之间的线束用双绞线或排线,减少相邻线间的串扰。
5.5 方向轴反了或按键顺序错乱
现象:拨动摇杆向右,光标向上;按物理第1个键,游戏却识别成第3个键。
原因:X轴Y轴接反了,或者ADC通道和代码里的映射不一致。按键乱序则多半是描述符里Usage Minimum写错,或者PCB走线时按键顺序和主控引脚顺序对不上。
解决:先分清是硬件问题还是软件问题。硬件上用万用表量摇杆输出电压,左右拨动时PA0电压变化,上下拨动时PA1变化,哪一路不对就交换接线。按键顺序则在代码中扫一遍,逐个按下并在串口打印bit位,建立实际物理键和bit位的映射表,然后改按键扫描顺序或报告字节里的位定义。
6. 进阶调试技巧:串口日志与组合键怎么加
手柄做到这一步,“能识别”只是及格线,离“能玩”还有一段距离。我给自己的习惯是:USB上报之前,先把原始数据通过串口打出来,确认主板读到的就是理想数据,再去看主机端表现,这样能快速锁定问题在采集链路的哪一段。
串口调试先重定向fputc到USART1,然后在主循环里周期打印:
printf("x=%d y=%d keys=0x%02X\r\n", axis_to_signed(filter_adc_channel(0), 20), axis_to_signed(filter_adc_channel(1), 20), read_keys_with_debounce());打印频率别太高,100ms一条就够。拨动摇杆时观察串口数值是否平滑变化,逐颗按键按下观察keys的bit位是否正确。如果串口数据正常但游戏控制器里表现不对,问题就在USB描述符或上报逻辑;如果串口数据本身就是乱跳,那就要回到硬件和滤波部分排查。
组合键是提高手柄实用性最直接的手法。比如按住键1和键8同时按下时切换方向轴灵敏度,或者触发一个特殊功能:
uint8_t keys = read_keys_with_debounce(); /* 键1和键8同时按下,切换灵敏度档位 */ if ((keys & 0x81) == 0x81) { if (!combine_triggered) { toggle_sensitivity(); combine_triggered = 1; } } else { combine_triggered = 0; }这里注意组合键只能触发一次,所以要加combine_triggered标志位,否则主循环每跑一遍都会切换一次,按钮按着不动也会不断翻转。这个“边沿触发”的思想和按键消抖是一脉相承的。
从那以后,我每次做USB手柄类工程,都强制自己先接串口打原始数据,再碰USB协议栈,省掉了大量对黑匣子的猜测。希望帮到你。
本文还有配套的精品资源,点击获取