本项目基于ESP32-S2,实现了一个USB键盘与鼠标的复合设备。利用IO扩展板上的双轴电位计摇杆控制鼠标移动,旋转编码器按键模拟鼠标左键点击,独立按键用于触发键盘输入一串预置字符“eetree.cn”。
硬件介绍
1. ESP32-S2-MINI-1模块
该开发板集成ESP32-S2 Wi-Fi SoC、USB Type-C接口、用户按键及LED指示灯,并通过排针引出关键IO。它支持USB供电或3.3V排针供电,是一款功能丰富且主打安全的单核Wi-Fi芯片。
2. 输入、输出扩展板
本项目主要使用了扩展板上的两处外设:
- 旋转编码器及按键 - 以模拟信号方式输入
- 双轴电位计摇杆 - 以数字信号(PWM)方式输入
功能分析与设计思路
外设电路分析
1. 旋转编码器与按键电路
这部分外设连接至一个电阻网络,将旋转编码器产生的PWM相位信号和按键开关状态转换为不同的模拟电压值,供ESP32-S2的ADC采样。

其工作原理如下:
- 旋转编码器转动时,A、B脚产生的PWM波在电阻网络分压下,使输出端
A_Out 产生具有特定台阶的锯齿状电平,通过状态机可判断转动方向。
- 按下旋转编码器或独立按键(K1)时,会分别将
A_Out 电压拉低至不同的稳定值,从而区分不同按键动作。
2. 摇杆电路
摇杆(FJ08K)连接到一个由四运放LMV324构成的电路,将X、Y轴的两组分压信号转化为一个PWM信号输出。

电路简析:
- U2D为后续电路提供电压基准。
- U2A与外围RC构成振荡电路,产生震荡信号。
- U2B作为比较器,将摇杆的分压信号与震荡信号比较,最终输出PWM信号(
PWM_OUT)。
- 总结:该电路将摇杆的模拟位置信息转换为脉宽可变的数字PWM信号。
软件设计思路
1. 总体思路
- 开发环境选择:选用Arduino IDE而非ESP-IDF。主要因为Arduino对USB HID的支持更友好,且便于通过串口(USB-CDC)打印调试信息,避免了ESP-IDF中USB-CDC与HID可能存在的堆栈冲突问题。

- 定时器轮询:为了避免在
loop主循环中使用延时导致阻塞,采用ESP32-S2的硬件定时器来定期执行模拟量采样和PWM捕获任务,保证系统响应及时。
hw_timer_t* timer_analog = NULL;
hw_timer_t* timer_joystick = NULL;
hw_timer_t* timer_led_fade = NULL;
- USB HID库:直接使用Arduino-ESP32核心库提供的USB HID功能,简化开发。
#include "USB.h"
#include "USBHIDKeyboard.h"
#include "USBHIDMouse.h"
- 状态机防抖:针对模拟电压读取可能存在的噪声,为按键设计了状态机,确保只有稳定且确认的按压动作才会被识别,提高了可靠性。
typedef enum key_status {
IDLE,
PrePressed,
OnPressed,
Pressed,
AfterPressed,
Released
}key_status;
- 主循环驱动:为避免在中断服务程序(ISR)中直接调用复杂外设可能引发的问题,定时器中断仅负责更新状态和数值,所有HID设备(鼠标、键盘)的最终操作都在
loop()主循环中执行。
⚠️ 重要提示:所有模拟量和PWM的阈值都是在特定硬件环境下测试得出的。如果迁移代码,请务必根据实际硬件重新校准!
核心功能实现详解
1. 旋转编码器与按键处理
这部分通过ADC采样模拟电压来识别动作。
关键实现:
- ADC采样:使用
analogReadResolution(12)设置12位分辨率,并通过akeys.analogValue = analogRead(1);读取电压值(0-4095)。
- 状态判断:在定时器中断中,将采样到的电压值与预设阈值进行比较,并通过状态机
ModifyKey()函数更新按键状态。只有状态确认为Pressed时,主程序才会执行相应操作。
- 阈值与冗余设计:为每个按键动作定义了中心电压值,并设置一个较大的容差
ACCURACY。状态机中的冗余状态(如PrePressed, OnPressed, AfterPressed)确保了按键动作被准确、唯一地识别。
#define CLOCKWISE_V 3400
#define ANTICLOCKWISE_V 3300
#define ENCODER_PRESSED_V 2800
#define KEY_V 1000
#define ACCURACY 1000
- 功能映射:由于旋转编码器顺时针与逆时针的电压阈值非常接近,难以稳定区分,最终项目简化了设计:
- 旋转编码器按下:映射为鼠标左键单击。
- 独立按键(K1)按下:触发键盘打印字符串
eetree.cn。
2. 摇杆处理
通过捕获PWM信号的高电平脉宽来判断摇杆方向。
关键实现:
- PWM捕获:使用Arduino的
pulseIn(JOY_STICK_PIN, HIGH)函数读取指定引脚上高电平脉冲的持续时间(微秒)。
- 方向打表:通过实测,为八个方向及中心点定义了对应的脉宽值。
typedef enum joyStickDest {
Up = 1178,
Up_Right = 1111,
Right = 1384,
Down_Right = 1815,
Down = 2850,
Down_Left = 3000,
Left = 2640,
Up_Left = 1650,
CENTER = 2000
} joyStickDest;
- 容差处理:考虑到很难精确达到理论值,为每个方向添加了容差范围
ACCURACY。对于更难触达的斜方向(如Up_Left),则使用更大的容差(ACCURACY * OBLIQUE)。
#define RANGE 1 //鼠标单次移动像素
#define ACCURACY 40
#define OBLIQUE 4
- 鼠标移动:根据识别到的方向,通过
MoveMouse()函数计算x_dis和y_dis位移量,最终在主循环中调用Mouse.move(x_dis, y_dis, 0)实现鼠标移动。
3. 其他功能
- 显示方案:最初计划使用TFT屏幕显示状态,但因软件SPI刷新慢而放弃。改为用板载LED指示灯提示操作状态:按键按下时闪烁,摇杆移动时常亮。
- 调试宏:为方便调试,在各个模块中定义了
DEBUG宏(如DEBUG_JOYSTICK),可以灵活控制串口信息的输出,避免大量打印影响性能。
系统架构与流程
1. 系统框图
展示了硬件模块与软件功能之间的信号流关系。

2. 软件流程图
概括了程序从初始化到主循环处理的基本流程。

实现效果
- 鼠标控制:摇杆可实现八个方向的鼠标移动(正方向稳定,斜方向因容差设置可能需轻微调整)。
- 左键点击:按下旋转编码器,触发一次鼠标左键点击。
- 键盘输入:按下独立按键K1,在当前光标位置输入字符串“eetree.cn”。
功能展示图


核心代码摘要
1. 主程序框架 (main.cpp)
#include "AnalogKeys.h"
#include "JoyStick.h"
#include "USB.h"
#include "USBHIDKeyboard.h"
#include "USBHIDMouse.h"
#define LED1_PIN 40
#define LED2_PIN 40
USBHIDMouse Mouse;
USBHIDKeyboard Keyboard;
hw_timer_t* timer_analog = NULL;
hw_timer_t* timer_joystick = NULL;
AnalogReadKeys akeys;
int x_dis, y_dis;
void setup(){
analogReadResolution(12);
Mouse.begin();
Keyboard.begin();
USB.begin();
pinMode(JOY_STICK_PIN, INPUT);
pinMode(LED1_PIN,OUTPUT);
pinMode(LED2_PIN,OUTPUT);
// 初始化并启动定时器
timer_analog = timerBegin(0, 80, true);
timerAttachInterrupt(timer_analog, &analog_key_read, true);
timerAlarmWrite(timer_analog, 4000, true);
timerAlarmEnable(timer_analog);
timer_joystick = timerBegin(1, 80, true);
timerAttachInterrupt(timer_joystick, &joystick_read, true);
timerAlarmWrite(timer_joystick, 7000, true);
timerAlarmEnable(timer_joystick);
}
void loop(){
// 处理按键打印字符串
if (akeys.key == KEY && akeys.status == Pressed) {
digitalWrite(LED1_PIN,HIGH);
Keyboard.print("eetree.cn");
}
// 处理编码器按下作为鼠标左键
if (akeys.key == EncoderPressed && akeys.status == Pressed) {
if (!Mouse.isPressed(MOUSE_LEFT)) {
Mouse.press(MOUSE_LEFT);
digitalWrite(LED1_PIN,HIGH);
}
} else {
Mouse.release(MOUSE_LEFT);
}
// 处理摇杆移动鼠标
if (x_dis != 0 || y_dis != 0) {
digitalWrite(LED2_PIN,HIGH);
Mouse.move(x_dis, y_dis, 0);
}
}
// 定时器中断服务程序
void IRAM_ATTR analog_key_read(){
akeys.analogValue = analogRead(1);
ScanKeys(&akeys);
}
void IRAM_ATTR joystick_read(){
joyStickDest joystickdst = scanJoyStick();
MoveMouse(joystickdst, &x_dis, &y_dis);
}
2. 按键状态机模块 (AnalogKeys.h 节选)
typedef enum key_status { IDLE, PrePressed, OnPressed, Pressed, AfterPressed, Released } key_status;
typedef struct AnalogReadKeys {
int analogValue;
keys key;
key_status status;
}AnalogReadKeys;
key_status ModifyKey(key_status status_old){
switch (status_old) {
case PrePressed: return OnPressed; break;
case OnPressed: return Pressed; break;
case Pressed: return AfterPressed; break;
case AfterPressed: return AfterPressed; break;
case Released: return Released; break;
default: return IDLE; break;
}
}
void ScanKeys(AnalogReadKeys* akey){
// 根据模拟电压值判断按键,并更新状态
if (akey->analogValue > CLOCKWISE_V && akey->analogValue < CLOCKWISE_V + ACCURACY) {
if (akey->key == ClockWise)
akey->status = ModifyKey(akey->status);
else {
akey->key = ClockWise;
akey->status = PrePressed;
}
}
// ... 其他按键判断逻辑(ANTICLOCKWISE_V, ENCODER_PRESSED_V, KEY_V)
}
遇到的问题与解决方案
-
阈值不准:模拟电压和PWM脉宽的实测值与理论偏差大。
-
开发环境冲突:在ESP-IDF中难以同时使用USB-CDC(串口调试)和USB-HID功能。
- 解决:切换到对USB HID支持更完善的Arduino IDE进行开发,这涉及到开发工具和调试方法的选择,你可以在 云栈社区 的基础技术板块找到更多关于环境配置和调试技巧的讨论。
-
调试信息混杂:多文件编程时,串口输出难以溯源。
- 解决:为每个模块定义独立的
DEBUG宏,分段启用调试输出。
-
按键误触发:模拟电压波动导致一次按压被多次识别。
- 解决:引入多状态的状态机,在定时器中多次采样确认后才判定为有效按压。
总结与展望
本项目成功利用ESP32-S2的USB和ADC、PWM捕获功能,实现了一个功能完整的HID复合设备。整个过程加深了对状态机编程、定时器使用以及USB HID协议的理解。
当然,目前的实现仍有改进空间:
- 摇杆控制:目前依赖经验“打表”,未来可通过分析电路原理,建立PWM脉宽与摇杆坐标的数学模型,实现更平滑、线性的鼠标控制。
- 编码器功能:当前放弃了旋转方向识别。未来可通过改进算法,区分正转与反转,并将其映射为鼠标滚轮或其他键盘功能。
- 开发框架:最终希望能在ESP-IDF环境下实现,并解决USB复合设备驱动的更深层次问题。
这只是一个起点,通过对硬件更深入的理解和软件算法的优化,还能挖掘出该硬件平台的更多潜力。如果你对嵌入式开发和USB协议栈感兴趣,欢迎在技术社区进行更深入的交流与探讨。