ESP32-S3 实战教程:做一个 USB HID 宏键盘
这次让 ESP32-S3 不再只是“插在电脑旁边的开发板”,而是伪装成一个真正的 USB 键盘。按一下物理按键,可以发送 Ctrl+C、打开终端,甚至输入一段固定文本。它很适合做代码片段键盘、直播快捷键、机房运维按钮,也能顺便理解 USB 设备枚举、HID 报告、按键去抖和宏执行队列。

一、为什么必须用 ESP32-S2 或 ESP32-S3
普通 ESP32 只有 USB 转串口芯片,并不具备可以直接连接电脑的原生 USB OTG 外设。ESP32-S2、ESP32-S3 才能通过 TinyUSB 作为键盘、鼠标、MIDI、CDC 或复合设备。开发板上如果有两个 USB 接口,通常一个接 USB-Serial-JTAG,另一个才是 USB OTG;插错口时,代码没有问题,电脑也不会把它识别成键盘。
USB HID 的妙处是电脑不需要安装驱动。设备枚举成功后,操作系统会把它当作普通键盘,浏览器、编辑器和终端都能接收按键。也正因为它拥有真实键盘的权限,宏内容必须由自己控制,不能把未经确认的网络数据直接转换成按键发送。
二、硬件准备和按键设计
建议使用 ESP32-S3-DevKitC,一组 3 到 6 个独立按键,按键一端接 GPIO,另一端接 GND,软件启用内部上拉。想做得更像产品,可以加一块 OLED 显示当前宏名称、一个旋钮切换宏组,再加一颗状态灯提示 USB 是否已连接。

按键逻辑不要写成“检测到低电平就马上发送”。机械按键在按下和松开的一瞬间会抖动十几毫秒,如果直接读取,电脑可能收到一串重复字符。工程上通常在状态变化后等待 15 到 30 ms,再确认一次;长按和连发则由状态机处理,避免把 delay() 塞到主循环里。
三、Arduino Core 中的 HID 依赖
Arduino-ESP32 的 USB 功能只对具备原生 USB 外设的芯片开放。不同核心版本对 HID 类头文件的命名和示例略有差异,安装 ESP32 板卡包后,应从“示例 -> USB”里打开对应 HID 示例,再在此基础上增加业务逻辑。常见的类包括 USBHIDKeyboard、USBHIDMouse 等。
下面给出一个表达核心逻辑的示例,头文件名称以当前板卡包的示例为准:
#include <Arduino.h>
#include "USB.h"
#include "USBHIDKeyboard.h"
USBHIDKeyboard Keyboard;
const int KEY_PIN = 4;
bool lastPressed = false;
uint32_t changedAt = 0;
void sendMacro() {
Keyboard.press(KEY_LEFT_CTRL);
Keyboard.press('L');
Keyboard.releaseAll();
delay(30);
Keyboard.print("https://www.cnblogs.com/");
Keyboard.write(KEY_RETURN);
}
void setup() {
pinMode(KEY_PIN, INPUT_PULLUP);
Keyboard.begin();
USB.begin();
}
void loop() {
bool pressed = digitalRead(KEY_PIN) == LOW;
if (pressed != lastPressed) {
changedAt = millis();
lastPressed = pressed;
}
if (pressed && millis() - changedAt > 25) {
sendMacro();
while (digitalRead(KEY_PIN) == LOW) delay(5);
}
}
示例中的 while 只是为了让入门代码直观,正式版本应改为非阻塞状态机,否则多个按键同时按下时,一个宏会把其他按键“堵”在门外。宏动作也建议放进队列,一次只执行一个,给每个动作加最小间隔。
四、HID 报告背后的事情
电脑收到的不是“发送字符串”这句话,而是一个固定格式的 HID 报告:修饰键位、普通键位数组以及释放状态。按下 Ctrl+L 的最小序列通常是“发送 Ctrl+L 报告,再发送全释放报告”。如果只按不释放,系统会认为 Ctrl 键一直卡住,接下来按任何字母都可能变成快捷键。
因此,宏执行器要把动作拆成明确步骤:按下修饰键、发送字符、释放全部按键、等待窗口切换、再发送下一段文本。涉及桌面应用时,给窗口切换留出 100 到 300 ms 的等待,比盲目提高发送速度更可靠。电脑不是流水线传送带,它需要一点时间把窗口从“还没打开”变成“现在可以接收输入”。
五、把宏放进 Preferences
为了不用每次改宏都重新烧录,可以把宏名称和动作保存到 NVS。简单项目可以预置几个宏编号,网页或串口只修改文本;复杂项目则保存一个小型 JSON,但要限制总长度,并且不要在每次按键时写闪存。
#include <Preferences.h>
Preferences prefs;
String loadMacro(uint8_t index) {
char key[8];
snprintf(key, sizeof(key), "m%u", index);
if (!prefs.begin("macros", true)) return "";
String value = prefs.getString(key, "");
prefs.end();
return value;
}
bool saveMacro(uint8_t index, const String& value) {
char key[8];
snprintf(key, sizeof(key), "m%u", index);
if (!prefs.begin("macros", false)) return false;
bool ok = prefs.putString(key, value) > 0;
prefs.end();
return ok;
}
保存接口必须限制字符集和长度,避免把网页中的换行、控制字符直接变成键盘动作。对“删除文件”“执行命令”这种危险宏,最好不要支持远程编辑,或者要求设备上的物理确认键。
六、USB 和串口调试的冲突
ESP32-S3 的 USB-Serial-JTAG、USB OTG 和开发板菜单选项之间有关系。启用 USB OTG TinyUSB 时,可能不能同时把同一个 USB 口当作启动日志串口;如果突然看不到 Serial,先检查 Tools 中的 USB Mode 和 USB CDC On Boot,而不是立刻认为程序死机。
调试阶段可以让宏键盘通过 USB 工作、日志通过 UART0 输出;或者做一个复合设备,同时提供 CDC 和 HID。复合设备的好处是电脑只插一根线,坏处是描述符和调试配置更复杂。先把单一 HID 跑通,再加第二个接口,排错会清爽很多。
七、常见问题
电脑完全没有识别
确认芯片是 S2/S3,确认插的是 USB OTG 口,确认 Tools 里的 USB Mode 不是纯串口。换一根支持数据的线,有些“只能充电”的线会让所有软件排查变成徒劳。
键盘能识别但按键重复
检查去抖状态机,确认一次按下只入队一次;发送完动作要有释放报告;不要在 loop() 每次循环都调用 Keyboard.print()。
中文文本输入乱码
HID 键盘发送的是键位,不是 Unicode 字符。中文输入需要依赖操作系统输入法,或改成发送剪贴板粘贴流程;不要把 UTF-8 字节逐个当成键盘扫描码。
八、把它做成真正好用的工具
可以增加宏组切换、OLED 菜单、旋钮、LED 状态和安全锁。设备上电后默认不发送任何按键,USB 枚举完成后亮绿灯,执行宏时亮蓝灯,宏队列异常时亮红灯。所有宏都应该有最大执行时长和最大字符数,防止某次配置错误让设备像失控的打字机一样不停输出。
这个项目最有价值的地方,是把“一个按钮触发一串动作”做成了完整系统:输入有去抖,动作有队列,输出有释放,配置有持久化,USB 有兼容性边界。玩通之后,还可以继续做鼠标控制器、游戏脚踏板、直播切换台或无障碍输入设备。
如果要把宏键盘交给别人使用,还应考虑可解释性:每个按键旁边标清功能,OLED 上显示当前宏名称,执行前用 LED 闪烁提示。宏里不要默认打开终端后执行未知命令,也不要把管理员密码预置在固件中。一个看似方便的快捷键,实际上拥有和用户键盘一样的操作权限,设计时应该把它当成输入设备和安全边界的一部分。
测试时可以准备一张按键矩阵表,逐项记录短按、长按、连按、拔插 USB、电脑锁屏和电脑休眠后的行为。特别要测试按键卡住和 USB 线突然拔掉两种情况:前者不应持续发送,后者重新插入后不应重复执行上一次未完成的宏。
参考资料
打赏
如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏支持:王庆岩
浙公网安备 33010602011771号