基于STM32的自制游戏手柄:摇杆ADC采样、按键扫描与HID输出实战

发布时间:2026/10/15 20:06:41
基于STM32的自制游戏手柄:摇杆ADC采样、按键扫描与HID输出实战 简介这是一份基于STM32微控制器实现的游戏手柄设计资料包面向嵌入式学习者、电子爱好者及游戏外设开发人员解决方向键和8个功能按键的识别、GPIO扫描、中断处理及USB HID通信等完整链路问题。压缩包含354个文件11.8MB除工程源码外还包含STM32标准外设库头文件、启动与链接脚本、编译链接生成文件、Hex固件和Keil工程配置等可直接查看源码和烧录验证。资料采用VET6增强版固件方案读者可从中理解矩阵键盘/独立按键接入方式、按键中断实时响应机制以及如何通过USB模拟游戏控制器并被操作系统识别。目前已有2400人学习下载适合想从零搭建或二次开发STM32游戏手柄的中级开发者参考。1. 为什么用STM32做游戏手柄方向输入与8个按键的可行解如果你手头有一台复古游戏机模拟器或者想给某个机器人遥控器加个手柄又或者纯粹想做一个能自定义按键的输入设备用STM32搭建一个带方向控制、8个独立按键的手柄是一个很成熟的方案。方向输入通常来自摇杆或者十字键8个按键则对应动作键、功能键关键点在于怎么把模拟信号和数字信号干净地读进来再变成电脑或主机能识别的数据。我做过类似需求用一块十几元的开发板加一个摇杆、八个微动开关就能在两小时内跑起来。这条路适合懂一点C语言、会看数据手册的入门者也适合想把手柄做得更顺手的熟手——本章先把整体思路摆出来后面逐步拆解电路、代码和调试方法。2. 方向识别与按键输入摇杆ADC和矩阵扫描的选型逻辑2.1 方向输入选摇杆还是十字键ADC采样与GPIO读法的取舍方向输入首先要想清楚用摇杆还是十字键。十字键本质上是四个微动开关接法简单逻辑也简单但手感偏硬不容易做细微方向控制。摇杆则是一个带有两个电位器的模块X轴和Y轴分别输出模拟电压通过ADC读取数值来感知位置。大多数自制手柄我会推荐摇杆原因有三一是摇杆模块便宜且容易买到二是ADC分辨率足够细腻三是游戏里需要模拟量输入比如油门、视角转向时也能胜任。如果你的需求是纯复古格斗游戏十字键可能更贴合习惯。但标题里“方向”如果指的是摇杆那就要考虑ADC的引脚分配。STM32的ADC通道不是所有引脚都能用具体见对应型号的数据手册。以常见的STM32F103C8T6为例PA0到PA7对应ADC通道0到7接摇杆的X和Y就可以用PA0、PA1。这里有个容易犯的错摇杆模块的电源引脚要接到3.3V而不是5V否则读数会超出ADC参考电压范围。摇杆模块还有两个按键引脚通常接在十字键附近按下时输出低电平。如果你的8个按键里包含这些要注意区分。我一般会把摇杆的按键单独接不和其他按键混在一起这样扫描逻辑更清晰。2.2 8个按键怎么接独立IO与矩阵扫描的引脚代价8个按键接法有两种独立GPIO和矩阵扫描。独立接法简单粗暴每个按键一端接GND另一端接一个IO引脚IO内部上拉按下时读到低电平。这种接法需要8个IO对于STM32来说是足够的但要注意如果用了摇杆占掉几个IO还要留出串口或USB引脚总数就要提前算好。我常用的小开发板有20多个IO完全够用。矩阵扫描适合IO不够的情况。把按键排成4x2或者3x3一行接输出一列接输入通过扫描行列电平来判断哪个键按下。但矩阵扫描有个缺点需要不断轮流拉低行线CPU占用稍微高一点而且如果按键接触不好容易产生鬼键。对于8个按键我建议用独立GPIO毕竟引脚够用代码也直白。只有按键超过12个才考虑矩阵。实际接线时还要考虑按键的机械结构。常用的是轻触开关两个引脚对角短接另外两个引脚是内部连通的。如果你用面包板很容易插错方向导致按键一直处于按下状态。我的习惯是先用万用表确认哪两个引脚是常开的再画接线图。这一步能省下后面不少排查时间。3. 在STM32上把方向与按键变成数据最小可跑通的代码骨架3.1 摇杆方向读取ADC采样、中值校准与死区处理摇杆输出的模拟电压在静止时大约是VCC/2也就是1.65V左右3.3V供电时。往一个方向推到底电压会接近0或3.3V。ADC读到的原始值范围取决于分辨率和参考电压。如果配置为12位分辨率3.3V参考读数范围是0~4095中心值约2048。但实际中心值会因元件误差偏移比如我遇到过中心值跑到2055甚至2100的。所以代码上要做两件事中值校准和死区处理。中值校准在上电时完成读取几十次摇杆静止值取平均作为X和Y的中心点。死区处理则是在判断方向时如果当前值与中心点的差值小于某个阈值比如50就认为摇杆在中心不输出方向。否则根据差值的正负和大小映射到方向或速度。下面是一段最小可跑的ADC读取代码假设用STM32的HAL库#include stm32f1xx_hal.h ADC_HandleTypeDef hadc1; uint16_t adc_x_raw, adc_y_raw; int16_t x_center, y_center; // 初始化ADC1通道0和通道1分别接摇杆X和Y void ADC_Init(void) { __HAL_RCC_ADC1_CLK_ENABLE(); hadc1.Instance ADC1; hadc1.Init.ScanConvMode ENABLE; // 扫描模式连续转换两个通道 hadc1.Init.ContinuousConvMode ENABLE; // 连续转换 hadc1.Init.NbrOfConversion 2; // 转换2个通道 hadc1.Init.Resolution ADC_RESOLUTION_12B; // 12位分辨率 HAL_ADC_Init(hadc1); ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel ADC_CHANNEL_0; // PA0 - X轴 sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_55CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); sConfig.Channel ADC_CHANNEL_1; // PA1 - Y轴 sConfig.Rank 2; HAL_ADC_ConfigChannel(hadc1, sConfig); } // 读取摇杆返回校准后的值 void Read_Joystick(int16_t *x, int16_t *y) { HAL_ADC_Start(hadc1); // 等待两个通道转换完成 HAL_ADC_PollForConversion(hadc1, 10); adc_x_raw HAL_ADC_GetValue(hadc1); HAL_ADC_PollForConversion(hadc1, 10); adc_y_raw HAL_ADC_GetValue(hadc1); HAL_ADC_Stop(hadc1); *x (int16_t)adc_x_raw - x_center; // 相对中心点的偏移 *y (int16_t)adc_y_raw - y_center; }这段代码里HAL_ADC_Start开始一轮转换PollForConversion等待指定通道的数据就绪。HAL_ADC_GetValue取出当前通道的原始值。注意扫描模式下每调用一次PollForConversion会依次转换下一个通道所以连续调用两次就能拿到X和Y。x_center和y_center是上电时校准得到的具体校准函数放在主循环前执行一次。死区处理通常在调用方实现比如#define DEAD_ZONE 50 int8_t MapDirection(int16_t offset) { if (offset DEAD_ZONE) return 1; // 正向 if (offset -DEAD_ZONE) return -1; // 负向 return 0; // 中心 }如果你希望输出的是角度或百分比可以进一步把offset映射到-100~100。但很多场景下只需要方向用正负判断就够了。3.2 按键扫描与去抖从读到稳定电平的三行状态机按键的机械抖动是必须处理的问题。按下和释放的瞬间电平会跳动几毫秒到几十毫秒。如果直接用HAL_GPIO_ReadPin去读一次按下可能被识别成多次触发。常见去抖方法有延时消抖、计数消抖、状态机消抖。我推荐定时器中断里做状态机不阻塞主循环。下面是用定时器周期1ms扫描一次的实现思路假设按键接PA2~PA9全部内部上拉按下为低电平。#define KEY_NUM 8 uint8_t key_state[KEY_NUM] {0}; // 稳定后的状态1表示按下 uint8_t key_his[KEY_NUM] {0}; // 上一次扫描的原始电平 void Scan_Key_1ms(void) { GPIO_TypeDef* ports[KEY_NUM] {GPIOA, GPIOA, GPIOA, GPIOA, GPIOA, GPIOA, GPIOA, GPIOA}; uint16_t pins[KEY_NUM] {GPIO_PIN_2, GPIO_PIN_3, GPIO_PIN_4, GPIO_PIN_5, GPIO_PIN_6, GPIO_PIN_7, GPIO_PIN_8, GPIO_PIN_9}; uint8_t raw; uint8_t i; for (i 0; i KEY_NUM; i) { raw (HAL_GPIO_ReadPin(ports[i], pins[i]) GPIO_PIN_RESET) ? 1 : 0; if (raw key_his[i]) { key_state[i] raw; // 连续两次相同认为稳定 } key_his[i] raw; } }这个逻辑很简单每次扫描把当前读到的电平存到key_his如果和上一次一致就更新到key_state。这样一来只有电平稳定至少两次扫描2ms才会改变状态抖动期间电平反复变化key_state不会跟着抖。如果你觉得2ms不够可以在key_his基础上再加一个计数器连续N次一致才更新但2ms对微动开关来说通常够用。按键的触发事件不是在key_state改变时立刻上报而是要看变化沿。比如按下事件是key_state[i]从0变为1。主循环里可以这样检测if (key_state[0] 1) { // 按键A按下执行对应动作 }但如果你需要“按下瞬间触发一次”而不是“按住一直触发”就要记录上一次的key_state做边沿检测。这个细节在后面讲组合键时还会用到。3.3 数据打包输出串口调试与HID手柄的两种出口手柄做出来是要给外部设备用的输出方式有两种常见选择串口和USB HID。串口适合调试和连接单片机比如给你的机器人主控发方向键值USB HID则能直接让电脑识别成游戏手柄不用装驱动。先看串口输出。定义一个字节的按键位图把8个按键映射到bit0~bit7方向用两个有符号字节表示X和Y偏移这样一帧数据只需要4字节。比如typedef struct { int8_t x; // -100~100 int8_t y; // -100~100 uint8_t keys; // bit0~bit7对应8个按键 } Gamepad_Report; void Send_Report_UART(Gamepad_Report *rpt) { uint8_t buf[4]; buf[0] (uint8_t)rpt-x; buf[1] (uint8_t)rpt-y; buf[2] rpt-keys; buf[3] 0xAA; // 帧尾 HAL_UART_Transmit(huart1, buf, 4, 10); }接收端按帧解析0xAA作为结束标记。如果你觉得太简单可以加个帧头0x55。这个格式方便调试也能在PC上用串口助手直接看数据。USB HID则要复杂一些。STM32F103没有原生USB-PHY需要用外部晶振和USB电路或者选带USB的型号比如STM32F042但原理一致。HID方案的核心是端点描述符和报告描述符报告描述符里要把游戏手柄的用法页、用途定义清楚比如X轴、Y轴、按键。我提供一个极简报告描述符的示例适用于STM32 USB库static uint8_t ReportDesc[] { 0x05, 0x01, // USAGE_PAGE (Generic Desktop) 0x09, 0x05, // USAGE (Game Pad) 0xA1, 0x01, // COLLECTION (Application) 0x09, 0x01, // USAGE (Pointer) 0xA1, 0x00, // COLLECTION (Physical) 0x09, 0x30, // USAGE (X) 0x09, 0x31, // USAGE (Y) 0x15, 0x00, // LOGICAL_MINIMUM (0) 0x26, 0xFF, 0x00, // LOGICAL_MAXIMUM (255) 0x75, 0x08, // REPORT_SIZE (8) 0x95, 0x02, // REPORT_COUNT (2) 0x81, 0x02, // INPUT (Data, Var, Abs) 0xC0, // END_COLLECTION 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) 0x95, 0x08, // REPORT_COUNT (8) 0x81, 0x02, // INPUT (Data, Var, Abs) 0x75, 0x01, // REPORT_SIZE (1) 0x95, 0x08, // REPORT_COUNT (8) 0x81, 0x01, // INPUT (Const) 填充到2字节边界 0xC0 // END_COLLECTION };这个描述符把X轴和Y轴定义为8位有符号0~255中心值128按键定义8个bit。发送报告时HID报告长度是4字节[x, y, buttons, 0]。注意HID报告里字母顺序要和描述符一致否则电脑端读取会错位。我在这里翻过一次车把按键放到了第0字节结果驱动识别成鼠标滚轮。4. 手柄实做中的5个典型踩坑从方向漂移到按键粘连4.1 摇杆不回中导致方向漂移现象手柄静止时方向信号不是中心值甚至持续输出某个方向。原因摇杆电位器磨损或弹簧老化导致机械中心偏移或者上电校准点取到了当前偏移位置。解决不要只讲上电校准一次最好在代码里做一个“长按按键方向归零”的手动校准功能。另外检查摇杆模块供电电压3.3V供电时中心值读数应在2048附近如果偏差超过300可能是模块质量问题换一个摇杆再试。4.2 按键按下/释放电平反了现象按键没按的时候代码判断为按下按下后反而松开。原因按键接法反了或者上拉/下拉配置错误。解决先查引脚配置。我习惯在初始化里开内部上拉按键一端接GND这时复位电平为高按下为低。如果用了外部上拉电阻要确认电阻没接错。另外用万用表量一下按键两个引脚之间的电压常态下应为高电平按下时拉低。4.3 矩阵扫描鬼键现象使用矩阵扫描时按下某个键却同时触发了相邻键。原因行列二极管未加按键按下时电流通过相邻路径回流产生串扰。解决要么给每个按键串联二极管要么换回独立GPIO接法。对于8个按键我用独立GPIO就没再遇到鬼键。如果你坚持矩阵至少保证扫描时序里行线先输出低电平列线读之前留一小段延时让电平稳定。4.4 供电不足导致ADC读数跳动现象摇杆推动时ADC读数大幅跳动尤其是多个按键同时按下时。原因开发板通过USB供电电流余量不够按键和摇杆同时动作导致电压跌落。解决用独立5V供电给开发板摇杆模块的VCC不要从板子上的3.3V引脚取电而是从ADC参考电压脚单独引线并加一个100nF去耦电容。如果用的是电池供电先测一下电池空载和负载时的电压差。4.5 忘记上拉/下拉电阻导致悬浮电平现象按键没有按下读到的电平随机变化时高时低。原因GPIO没配置成上拉输入外部也没有电阻引脚悬空。解决把按键接的引脚都配置为内部上拉代码里用GPIO_PULLUP。如果用了外部下拉按键接VCC按下为高则配置为下拉输入。这一条最基础但也是最常见的翻车点尤其新手会误以为内部上拉在按键接地时不需要。5. 让手柄更好用的三个进阶技巧5.1 摇杆自动校准上电时采样中心值上一章提到校准这里展开讲。上电后延时500ms等摇杆稳定然后连续读50次X和Y取平均作为中心值。注意采样时要求用户不要碰摇杆否则中心点会偏但正式产品不可能要求用户每次上电都不碰。更好的做法是保存上次校准值到Flash上电先用保存值只有在用户按下特定组合键时才重新校准。代码逻辑很简单void Calibrate_Joystick(void) { int32_t sum_x 0, sum_y 0; uint8_t i; for (i 0; i 50; i) { Read_Joystick(x_center, y_center); // 直接读原始值 sum_x x_center; sum_y y_center; HAL_Delay(2); } x_center (int16_t)(sum_x / 50); y_center (int16_t)(sum_y / 50); }然后把x_center写Flash下次上电读取。这样即使摇杆零位漂移也能保证初始状态手柄不误动。5.2 组合键与连发用定时器扫描替代主循环延时手柄上有8个按键总是有组合键需求比如“AB”同时按下触发特殊动作。用主循环轮询时如果每读到一次键就处理组合键很容易因为时间差错过。我通常的做法是在定时器中断里维护一个10ms的按键状态快照主循环每10ms对这个快照做边沿检测和组合判断。这样A和B无论哪个先按下只要在10ms内有重叠就算组合成功。连发功能也一样给某个按键一个计数器每N个周期模拟一次按下而不是靠延时阻塞。核心逻辑是uint8_t key_snapshot[KEY_NUM]; uint8_t key_event[KEY_NUM]; // 0无, 1按下, 2释放 void Build_Key_Event(void) { static uint8_t prev[KEY_NUM]; uint8_t i; for (i 0; i KEY_NUM; i) { key_event[i] 0; if (key_snapshot[i] !prev[i]) key_event[i] 1; // 按下 if (!key_snapshot[i] prev[i]) key_event[i] 2; // 释放 prev[i] key_snapshot[i]; } }然后在主循环里读key_event针对按下事件做组合判断。这样就不会因为某个键的长时间按下而阻塞其他键的检测。5.3 通过HID描述符让电脑识别为游戏手柄如果你选择了USB HID方案描述符是核心。我给的示例里没有配置输入模式导致Windows可能识别为“HID兼容设备”而不是“游戏控制器”。你需要在描述符里把物理用途设置成Game Pad并且确保报告长度和描述符一致。另外STM32的USB库需要配置端点通常用HID数据端点0x81传输方式中断最大包长64。发送报告的频率不要太高125Hz足够也就是每8ms发送一次。如果发送间隔小于端点间隔描述符设定值USB会丢弃数据。我习惯于在定时器里每10ms发送一次完整报告端点间隔设置为10ms这样不会出现发送失败。USB HID踩坑最多的是报告描述符里逻辑最小值/最大值与发送数据不匹配。比如你定义X轴为0~255但发送的int8_t是有符号的中心128没问题但推满一侧是0另一侧是255电脑端读到的数值就是0~255。如果不想做有符号转换就把发送报告改为uint8_t并且把中心值128映射好。或者把描述符的逻辑最小值改为-127最大值127发送有符号数这样更直观。6. 验证方向与按键一个自制的测试小工具手柄做完最怕的是“主控说正常但游戏里方向错乱”。我建议做一个串口测试工具不用上位机软件直接在终端里看数据。写一个Python脚本读取串口每秒打印一帧方向值、按键位图并画一个简单的方向指示。这样能快速验证ADC方向是否正确。import serial import time ser serial.Serial(COM3, 115200, timeout0.1) direction {0: 中心, 1: 上, -1: 下, 2: 左, -2: 右} while True: data ser.read(4) if len(data) 4 and data[3] 0xAA: x data[0] if data[0] 128 else data[0] - 256 y data[1] if data[1] 128 else data[1] - 256 keys data[2] # 判断方向 dx x // 64 dy y // 64 if abs(dx) 1 and abs(dy) 1: d 0 elif dy 0: d 1 elif dy 0: d -1 # 按键位图转为可读列表 key_names [fK{i} for i in range(8)] pressed [key_names[i] for i in range(8) if (keys i) 1] print(fX{x:4d} Y{y:4d} 方向{direction.get(d, ?)} 按键{pressed}) else: time.sleep(0.01)这个脚本默认用串口输出格式X偏移、Y偏移、按键位图、帧尾。X和Y是有符号字节0xAA作为结束标记。如果发现方向和物理推杆不一致比如向上推时Y是负数就把代码里的Y取反或者把摇杆模块旋转90度安装。我做这个项目时就遇到过X轴和Y轴接反了的情况因为摇杆模块引脚顺序和图纸不一致用这个工具一推就暴露出来了比盲改代码快得多。后续你还可以把手柄接到电脑的“游戏控制器”面板Windows自带的属性窗口能显示摇杆轴和按键测试但那个只能验证USB HID。如果你用串口方案这个Python工具就是你的“后悔药”改完代码跑一遍就知道有没有翻车。我自己的习惯是先做串口输出验证逻辑再切USB HID这样即使HID有问题也能确定是USB部分还是手柄逻辑部分。希望这个思路能帮到你少走一点我当年踩过的坑。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询