基于ESP32-C3的USB HID转蓝牙适配器:实现有线键鼠蓝牙化与宏功能(附指纹解锁方案)

最近在整理桌面,看着一堆缠在一起的有线鼠标键盘,还有那个专门用来打游戏的带宏功能的有线鼠标,总觉得不够清爽。有没有办法让这些老设备也“无线化”,还能保留甚至增强它们的自定义功能呢?于是,我动手做了一个小玩意儿——一个基于ESP32-C3的USB HID转蓝牙适配器。

这个小板子能干什么呢?简单说,它能把你的有线鼠标、键盘、甚至游戏手柄,通过蓝牙连接到电脑、平板或手机上,瞬间变成蓝牙设备。更棒的是,它还支持通过网页配置鼠标键盘宏,并且未来计划加入指纹解锁电脑的功能。听起来是不是很酷?今天,我就带你从零开始,深入理解这个项目的原理,并手把手教你如何复现它。

1. 项目概览:它到底能做什么?

在开始研究代码和电路之前,咱们先搞清楚这个适配器的核心功能,这样你才知道它是否适合你的需求。

核心功能清单:

  • 有线转无线:通过两个USB-A母口,接入传统的有线鼠标、键盘等USB HID设备,让它们通过蓝牙与主机(电脑、手机等)连接。
  • 双工作模式
    • 翻译模式 (TRANSLATE):对于常见的鼠标和键盘,适配器会智能解析其USB报告描述符,并将其数据“翻译”成标准的蓝牙HID报告格式。这是最理想、即插即用的模式。
    • 直通模式 (PASSTHROUGH):对于游戏手柄等不常见或解析失败的设备,适配器会将原始的报告描述符和数据原封不动地转发给蓝牙主机。兼容性更强,但设置稍显复杂。
  • 网页配置宏:你可以通过浏览器访问一个管理网页,为工作在翻译模式下的鼠标或键盘定义复杂的宏命令(比如一键连招、组合键等)。
  • 电池管理:板子支持电池供电,也可以用USB口供电,或者通过USB口给电池充电,非常灵活。
  • 指纹解锁(开发中):计划集成指纹模块,通过模拟键盘输入密码来实现Windows系统的指纹解锁。
  • 无线管理:所有状态查看和宏配置,都通过蓝牙连接的管理网页完成,无需额外数据线。

硬件核心:ESP32-C3 整个项目的“大脑”是一颗乐鑫的ESP32-C3芯片。这是一款集成Wi-Fi和蓝牙5.0的RISC-V架构微控制器,性能足够,功耗也低,非常适合这种需要无线连接和复杂协议处理的应用。

2. 核心原理剖析:如何用GPIO“模拟”出USB主机?

这是本项目最硬核也最有趣的部分。ESP32-C3本身并没有USB主机(USB Host)控制器,那它怎么读取USB设备的数据呢?答案是:用GPIO引脚,通过软件模拟出一个低速USB主机。

2.1 软件模拟USB总线

这个功能基于开源项目 esp32_usb_soft_host。简单理解,就是通过程序精确控制两个GPIO引脚(一个模拟D+数据线,一个模拟D-数据线)的电平变化时序,来模拟USB低速模式(1.5 Mbps)的通信。

注意:这个软实现仅支持低速USB HID设备。如何识别呢?给设备上电后,如果D-线被拉高,就是低速设备;如果D+线被拉高,就是全速或高速设备。大部分鼠标和键盘都是低速设备,所以够用。

为了让这个软USB主机稳定工作,需要对ESP-IDF(乐鑫的开发框架)进行两个关键配置:

  1. 将编译优化等级设置为 O2
  2. 关闭内存保护功能Component config -> ESP System Setting -> Memory protection)。这是因为模拟代码需要动态向可执行内存段写入指令。

配置好后,USB主机的核心逻辑在一个 1ms的定时器中断 中运行。我们来看看这个中断服务程序(ISR)的简化代码:

void IRAM_ATTR usb_process()
{
    // 启用周期计数器(用于精确计时)
#if CONFIG_IDF_TARGET_ESP32C3
    cpu_ll_enable_cycle_count();
#endif	
    // 遍历两个USB端口
    for(int k=0; k<NUM_USB; k++)
    {
        current = &current_usb[k];
        if(current->isValid) // 如果该端口有设备
        {
            setPins(current->DP, current->DM); // 切换到对应GPIO
            timerCallBack();  // 处理NRZI编码/解码
            fsm_Mashine();    // 更新USB状态机
        }
    }
}

每个1ms,程序会检查每个USB端口,并执行两步操作:timerCallBack 根据上一周期的状态进行实际的数据位读写,fsm_Mashine 则更新整个USB枚举和数据传输的状态机。

2.2 精准的微秒级延时

USB低速模式的位周期大约是0.667微秒。在中断里,我们没法用 usleep 这样的函数。那怎么实现如此精确的延时呢?作者用了一个非常巧妙的方法:动态生成并执行NOP(空操作)指令

NOP 指令在RISC-V(ESP32-C3的架构)中就是 addi x0, x0, 0(向零寄存器加0,结果丢弃)。执行一条NOP需要固定的CPU周期。通过向内存中写入特定数量的NOP指令,然后跳转过去执行,就能实现精确的指令周期级延时。

下面这段 setDelay 函数就是干这个的,它会根据需要的延时周期(ticks),在内存中“编织”出一段由NOP指令组成的函数。

void setDelay(uint8_t ticks)
{
    // ... (内存分配等准备工作)
    uint8_t* pnt = (uint8_t*)pntS;
    // 写入ticks条NOP指令的机器码
    for(int k=0; k<ticks; k++)
    {
        *pnt++ = 0x01; // NOP指令的部分机器码
        *pnt++ = 0x00; // NOP指令的部分机器码
    }
    // 写入函数返回的指令尾
    *pnt++ = 0x82;
    *pnt++ = 0x80;
    // 将这块内存区域重新分配为可执行属性
    delay_pntA = heap_caps_realloc(pntS, MAX_DELAY_CODE_SIZE, MALLOC_CAP_32BIT | MALLOC_CAP_EXEC);
    // 之后调用 (*delay_pntA)(); 就会执行刚刚写入的NOP指令流,实现延时
}

这就是为什么必须关闭内存保护——因为程序需要动态修改可执行代码段的内容。初始的ticks值(比如110)会在启动时通过测量NOP+GPIO操作的实际时间来校准,以保证延时精度。

3. 从USB到蓝牙:HID报告的获取与转发

成功模拟USB主机并与设备通信后,下一步就是获取设备的“身份证”和“语言”——即HID报告描述符(Report Descriptor),然后决定如何转发数据。

3.1 获取HID报告描述符

HID报告描述符是一种非常紧凑且灵活的数据结构,它定义了设备发送的数据报告(Report)的格式和含义。比如,一个鼠标的报告里,哪几个比特代表左键,哪几个字节代表X轴移动量,都是描述符定义的。

在USB枚举过程中,主机需要向设备请求这个描述符。在状态机(fsm_Mashine)中,对应着发送特定请求的步骤:

// 状态机中的某个状态(例如状态660)
else if(current->fsm_state == 660){
    // 发送标准请求:获取报告描述符
    Request(T_SETUP, ASSIGNED_USB_ADDRESS, 0b0000, T_DATA0, 0x81, 0x6, 0x2200, 0x0000, DEF_BUFF_SIZE, current->hid[current->hid_report_desc_count].wDescriptorLength);
    current->fsm_state = 661; 
    return;
} else if(current->fsm_state == 661){
    // 接收并存储描述符数据
    int len = current->hid[current->hid_report_desc_count].wDescriptorLength;
    if(current->acc_decoded_resp_counter == len) // 确认接收完整
    {
        memcpy(&current->hid_report_desc_buffer[current->hid_report_desc_count], current->acc_decoded_resp, len);
        current->hid_report_desc_count++;
        // ... 处理下一个接口的描述符或进入下一步
    }
}

拿到描述符后,程序会解析其开头的 Usage PageUsage 字段,来判断插入的是鼠标、键盘还是其他设备。

3.2 翻译模式:让设备说“蓝牙语”

对于识别为鼠标或键盘的设备,适配器会尝试解析其报告描述符,并建立一个“翻译规则”。

1. 解析与映射 解析的目的是找出原始报告中每个功能(如X坐标、左键)对应的数据在报告字节中的位置(偏移量、比特位)、长度(占多少比特)以及数值范围(逻辑最小值/最大值)。

2. 建立线性变换 然后,程序会计算一个线性变换公式,将原始设备的数据映射到一个预定义的标准报告模型上。这个模型是蓝牙HID设备通用的格式。

例如,标准鼠标报告模型定义如下:

#pragma pack(1)
typedef struct {
    uint8_t report_id;  // 报告ID
    uint8_t buttons;    // 8个按钮,每位代表一个按键
    int16_t x;          // X轴位移,范围-32767到32767
    int16_t y;          // Y轴位移
    int16_t wheel;      // 滚轮位移
} standard_mouse_report_t;
#pragma pack()

对应的报告描述符定义了8个按钮位和3个16位有符号位移量。

翻译规则结构体 translate_item_t 就保存了每个数据项的映射关系:

typedef struct {
    unsigned defined: 1;        // 该项是否有效
    unsigned data_signed: 1;    // 数据是否有符号
    uint8_t byte_offset;        // 在原始报告中的字节偏移
    uint8_t bit_offset;         // 在字节中的比特偏移
    uint8_t bit_count;          // 占用的比特数

    // 线性变换参数:输出 = (输入 + pre_scale_bias) * scale_factor + post_scale_bias
    int32_t pre_scale_bias;
    int32_t post_scale_bias;
    double scale_factor;
} translate_item_t;

这样,无论你的鼠标报告里X坐标是8位还是12位,范围是0-255还是-127-127,都能通过这个公式转换成标准的-32767到32767的范围。

3.3 直通模式:当个“传声筒”

对于游戏手柄等不常见设备,或者解析失败的鼠标键盘,适配器会启用直通模式。这个模式简单粗暴:把从USB设备获取到的原始报告描述符和数据,原封不动地打包成蓝牙HID报告发送出去。

注意:直通模式的使用有“坑” 由于蓝牙HID连接的特性,直通模式用起来比翻译模式麻烦一点:

  1. 启动顺序:需要先成功连接一次蓝牙,USB总线才会开启。否则可能崩溃。
  2. 描述符缓存:蓝牙连接前需要确定报告描述符。因此,新插入的直通设备,其描述符会被保存到Flash中,然后设备自动重启。之后每次连接都使用这个缓存的描述符。
  3. 系统缓存:Windows等系统会缓存已配对设备的描述符。所以,换了新的直通设备后,需要在电脑上删除旧设备并重新配对

正确使用步骤:连接蓝牙 -> 插入新设备 -> (设备自动重启) -> 在电脑上删除旧蓝牙设备并重新搜索配对 -> 连接使用。

4. 特色功能:网页配置与鼠标宏

让设备无线化只是基础,可编程的宏功能才是提升效率的利器。

4.1 宏的定义与触发

宏,本质上是一套“如果…就…”的规则。在这个项目中,宏以翻译模式设备的标准报告作为输入,当输入条件满足时,就输出另一个标准报告

一个宏定义主要包含三部分:

  • 触发条件:比如,鼠标的某个按键(或组合键)被按下。
  • 动作内容:当触发后,要执行什么操作。比如,输出一连串的键盘按键序列(模拟快捷键操作)。
  • 动作参数:比如,动作开始前的延迟(action_delay),以及输出报告持续的时长(report_duration)。

宏的数据结构如下(以鼠标宏为例):

typedef struct{
    saved_list_head_t head;     // 链表节点
    uint8_t version;            // 版本

    // 触发条件部分
    bool cancel_input_report;   // 触发后是否阻止原输入报告继续上报
    macro_model_t input_model;  // 输入设备类型(如鼠标)
    union{
        struct{ // 鼠标触发条件
            uint8_t trigger_buttons_mask; // 触发按钮的掩码,比如0x01代表左键
        };
    };

    // 动作部分
    macro_model_t output_model;          // 输出设备类型(如键盘)
    macro_action_type_t action_type;     // 动作类型(单次、连续等)
    uint16_t action_delay;               // 动作延迟(毫秒)
    uint16_t report_duration;            // 报告持续时间(毫秒)
    union{
        standard_mouse_report_t mouse_output_report; // 输出的鼠标报告
        // 未来会有 keyboard_output_report 等
    };
} macro_t;

例如,你可以定义一个宏:当按下鼠标侧键(映射为buttons的某一位)时,触发并输出一个 Ctrl+C 的键盘报告,从而实现一键复制。

4.2 网页管理界面

所有宏的配置、设备状态的查看,都是通过一个Web页面完成的。这个页面是一个纯前端的Vue3应用,开源在另一个仓库。

它的工作原理很巧妙:利用浏览器最新的WebHID API。当你的适配器通过蓝牙连接到电脑后,这个网页可以通过WebHID API直接与适配器进行HID协议通信,读取状态、下发配置。

提示:WebHID目前仍是实验性功能,请使用PC版Chrome 89+、Edge 89+或Opera 75+浏览器访问。

通信协议 为了在网页和适配器之间通信,它们定义了一套基于HID报告的私有协议。协议采用了类似HTTP的请求-响应模型,并且为了兼顾短消息的效率和长消息的传输,定义了两个不同大小的报告通道(Report ID 3和4)。

短消息结构如下:

// 请求
typedef struct{
    uint16_t opcode;    // 操作码,类似HTTP Method
    uint16_t session;   // 会话ID,用于匹配请求和响应
    uint16_t length;    // 数据长度
    uint8_t  data[];    // 数据(可能是JSON或普通文本)
} request_t;

// 响应
typedef struct{
    uint16_t session;   // 对应请求的会话ID
    uint16_t length;    // 数据长度
    uint8_t  success;   // 成功标志(0失败,1成功)
    uint8_t  data[];    // 响应数据
} response_t;

对于更长的数据(比如传输复杂的配置),则采用分片传输机制,每个分片512字节,包含了分片序号等信息,在接收端重新组装。

5. 动手实践:编译与烧录指南

如果你对这个项目感兴趣,想自己动手做一块或者修改代码,可以参考以下步骤。

5.1 准备开发环境

  1. 获取源码:克隆主仓库和相关的子模块。
    git clone --recursive https://github.com/dnstzzx/usb-hid-bler.git
    
  2. 设置ESP-IDF:本项目锁定了特定的ESP-IDF版本(97fb98a91b308d4f4db54d6dd1644117607e9692),以确保API兼容性。你需要安装并切换到该版本。
  3. 应用补丁:由于蓝牙HID任务栈大小问题,需要替换一个官方文件。将项目 .github/ 目录下的 ble_hidd.c.replace 文件,复制并覆盖到你的ESP-IDF目录中的 components/esp_hid/src/ble_hidd.c
  4. 关键配置:在 idf.py menuconfig 中,务必完成以下两项配置:
    • Compiler options -> Optimization Level 设置为 -O2
    • Component config -> ESP System Setting -> Memory protection 关闭。

5.2 编译与烧录

配置完成后,进入项目目录,使用标准的IDF命令进行编译和烧录即可。

idf.py set-target esp32c3
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor # 请将 /dev/ttyUSB0 替换为你的实际串口

注意:每次执行 idf.py set-target 后,优化等级可能会被重置,需要重新在menuconfig中检查并设置为 -O2

如果你在本地环境编译遇到困难,也可以直接Fork项目到你的GitHub,利用仓库中已经配置好的 GitHub Actions 工作流进行在线编译,它会自动生成固件文件供你下载。

5.3 硬件组装参考

原作者提供了PCB设计,你可以在立创EDA等平台查看。核心部件包括:

  • ESP32-C3模块(建议选择带外部天线接口的版本,信号更稳)
  • 两个USB-A母座
  • 锂电池充电管理芯片(如TP4056)
  • 电平转换电路(确保USB信号电平匹配)
  • 必要的电容、电阻和指示灯

组装时,注意USB数据线(D+, D-)要连接到ESP32-C3指定的GPIO上,并在代码中正确配置引脚号。电源部分要确保能稳定提供5V和3.3V。

6. 进阶与展望:指纹解锁与更多可能

目前,指纹解锁功能还在开发中。其思路是集成一个指纹识别模块(如FPMxx系列),当指纹验证通过后,适配器模拟键盘输入预设的Windows登录密码,从而实现“指纹解锁”。这相当于一个物理层面的密码管理器,安全性取决于硬件本体的保管。

这个项目展示了ESP32-C3 GPIO模拟能力的强大,以及HID协议的灵活性。你可以基于此进行很多扩展:

  • 支持更多设备:在翻译器中加入对游戏手柄、绘图板等设备的解析规则。
  • 宏功能增强:实现更复杂的逻辑判断、循环、随机延迟等,让宏更像编程。
  • 多设备切换:让一个适配器记忆多套键鼠配置,通过按键切换连接不同的主机。
  • 低功耗优化:当没有检测到USB设备插入时,让ESP32-C3进入深度睡眠,仅由蓝牙广播唤醒,极大延长电池续航。

希望这篇详细的解析能帮你彻底理解这个有趣的DIY项目。从软件模拟USB到协议翻译,再到无线配置,每一个环节都充满了嵌入式开发的乐趣和挑战。如果你在复现过程中遇到了问题,不妨去原项目的GitHub仓库看看Issue,或者自己动手调试一番,这本身就是最好的学习过程。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐