ESP-IDF开发USB HID设备记录(3)——ESP32开发自定义HID设备,通过HID报文通信
冰蓝莓Official
编辑于 2025年07月26日 22:13
收录于文集
共4篇

开发工具:Visual Studio Code+ESP-IDF插件

开发环境:Windows 11 Workstation Pro

目标设备:ESP32-S2FN4R2 Custom board


最近正在开发一些电脑硬件,打算在主机端通过上位软件与下位的自定义USB HID通信,以控制电脑硬件的各种功能。

电脑通过USB HID与下游通信的案例并不少见,比如常见的主板RGB控制器,有部分厂家是使用USB HID通信的,可以通过自定义的软件进行各种操作:

MSI MYSTIC LIGHT RGB控制器设备

AsRock Polychrome RGB控制器设备


首先我们需要了解一下,什么是HID(Human Interface Device):

HID(Human Interface Device,人机接口设备)是USB设备中常用的设备类型,是直接与人交互的USB设备,例如键盘、鼠标与游戏杆等。在USB设备中,HID设备的成本较低。另外,HID设备并不一定要有人机交互功能,只要符合HID类别规范的设备都是HID设备。 Windows操作系统最先支持的HID设备。在windows 98以及后来的版本中内置有 HID设备的驱动程序,应用程序可以直接使用这些驱动程序来与设备通信。 在设计一个USB接口的计算机外部设备时,如果HID类型的设备可以满足需要,可以将其设计为HID类型设备,这样可以省去比较复杂的USB驱动程序的编写,直接利用Windows操作系统对标准的HID类型USB设备的支持。 ——HID 简介 - USB中文网 (https://www.usbzh.com/article/detail-76.html)

HID的典型设备一般包括:

  • 核心人机交互设备:键盘、鼠标、触摸板/屏等;

  • 扩展人机交互设备:游戏手柄、演示器、遥控器、条形码扫描枪等;

  • 非传统人机交互,但是使用HID协议的设备:条码/二维码扫描器环境传感器、工业自动化设备等;


HID协议传输结构:

USB HID协议基于标准的USB结构。

在USB枚举后,主机会识别设备的Interface Class(HID设备类型为0x03),并解析以下信息:

  1. 描述符类型:作用说明;

  2. 设备描述符:声明该设备的USB版本、VID/PID等基本信息;

  3. 配置描述符:定义该设备支持的电源、接口数量等;

  4. 接口描述符:接口描述符中的bInterfaceClass字段值应为0x03来表示HID类;

  5. HID描述符:HID描述符是HID类设备特有的描述符,它包含了Report描述符的长度类型信息,指明了主机接下来需要读取Report描述符的位置和大小。Report描述符本身是一个独立且非常重要的描述符。

  6. 端点描述符:定义中断端点(IN/OUT);

  7. 报告描述符:描述输入输出的报告格式。

HID 协议的数据传输通过“报告(Report)”进行。这些报告包括:

  • 设备→主机(Input Report)

  • 主机←设备(Output Report)

  • 主机设备(Feture Report,用于配置状态)

每一种报告类型的格式由HID Report描述符规定。Report描述符确实是一个字节序列,它由一系列项(Items)组成。这些项遵循一种类似TLV结构(有操作码和可选的数据字节),但严格来说,它是HID规范定义的特定项格式,包含Usage、Collection、Input、Output、Feature、Report ID、Report Size、Report Count、Logical Minimum、Logical Maximum、Physical Minimum、Physical Maximum、Unit、Unit Exponent、Push、Pop等。


数据传输流程:

在一个设备中,如果存在多个不同类型的数据报告(如:一个输入键盘报告、一个输入鼠标报告),可以通过Report ID来区分每种报告。每个Report的第一个字节就是其ID,主机和设备都必须据此正确解析数据结构。


HID通信注意事项:

  1. 报告长度通常是固定的。这是HID协议设计的常见做法,也是实现相对简单的方式。报告描述符定义了每个报告(由Report ID标识)的确切结构和长度。

  2. 在报告描述符中定义的每个报告内的各个字段,其Report Size(位长度)和Report Count(该长度的字段数量)定义了该字段在报告数据包中所占的总位数。所有字段的总位数(加上Report ID本身占用的位数,如果使用)决定了该特定报告的固定字节长度。传输的数据包大小必须严格匹配这个长度。

  3. 中断端点最大包长度有限制。对于Full-Speed USB HID设备(12Mbps),一般情况下为64字节。

  4. Feature Report只能通过控制传输(Control Transfer)进行传输(使用SET_REPORT和GET_REPORT请求)。中断端点不能用于传输Feature Report。

  5. 必须遵循Report描述符一定的格式,否则主机端会忽略数据或识别失败。


ESP32 USB HID设备开发实战:

本次开发的目标测试设备为ESP32-S2 Custom Board,使用GL850G分别引出一个下游口到USB Type-A母座,一个下游口到ESP32-S2,一个下游口到CH340K(用于固件烧录)。

原理图简化后如下:

代码块
Shell
自动换行
复制代码
5V -> SY8089降压3.3V -> ESP32
D+ -> ESP32-S2 GPIO19
D- -> ESP32-S2 GPIO20

为节省布线成本使用了ESP32-S2FN4R2,自带4MB的SPI Flash和2MB的PSRAM,不需要外挂。
天线因为没有需求所以没有拉出。
测试板使用了四种不同的触发电路用于点亮LED,用于验证不同条件下的GPIO-硬件控制电路。
复制成功


代码测试:

以下案例程序均使用64字节的REPORT SIZE。

代码块
clike
自动换行
复制代码
#define REPORT_SIZE 64
复制成功


首先配置HID报告描述符。目标测试使用了64字节的Input/Output通道。

代码块
clike
自动换行
复制代码
const uint8_t hid_report_descriptor[] = {
    TUD_HID_REPORT_DESC_GENERIC_INOUT(REPORT_SIZE)
};
复制成功

注意:TUD_HID_REPORT_DESC_GENERIC_INOUT(REPORT_SIZE)是一个TinyUSB的宏,定义一个无Report ID的64字节通用IN/OUT报告。所以主机端与HID设备通信时,发送的报文应为65字节(第一字节为Report ID,后64字节为实际内容)。


接着设置HID配置描述符:

代码块
clike
自动换行
复制代码
TUD_HID_DESCRIPTOR(0, 0, false, sizeof(hid_report_descriptor), 0x81, REPORT_SIZE, 10),
复制成功

配置描述符案例解析如下:

  • Interface Number = 0

  • String Index = 0

  • bool_protocol = false(这表示这个设备既不是鼠标也不是键盘,而是一个通用HID设备)

  • IN端点地址:0x81(EP1 IN)

  • 报告长度:64

这个配置描述符定义了一个通用HID接口,使用IN端点发送数据回主机,OUT报告通过后面的tud_hid_set_report_cb()接收主机写入时的数据。


当主机写入时ESP32的接收处理:

代码块
clike
自动换行
复制代码
void tud_hid_set_report_cb(uint8_t instance,
	uint8_t report_id,
	hid_report_type_t report_type,
	uint8_t const *buffer,
	uint16_t bufsize) {
  	// 此处为示例代码
	}
复制成功

buffer为不含Report ID的实际数据,bufsize为实际数据长度。可以通过解析buffer进行自定义命令。

最后调用tud_hid_report(0, buffer, bufsize);把收到的数据原样发送回主机(作为 IN 报告)。


最后的总测试代码如下:

代码块
JavaScript
自动换行
复制代码
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "driver/gpio.h"
#include "tinyusb.h"
#include "class/hid/hid_device.h"

static const char *TAG = "Controller";
#define REPORT_SIZE 64
#define LED_GPIO_MASK ((1ULL << GPIO_NUM_1) | (1ULL << GPIO_NUM_2) | (1ULL << GPIO_NUM_3) | (1ULL << GPIO_NUM_4))



// HID 报告描述符
const uint8_t hid_report_descriptor[] = {
    TUD_HID_REPORT_DESC_GENERIC_INOUT(REPORT_SIZE)
};

// 配置描述符
#define TUSB_DESC_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN)
static const uint8_t hid_configuration_descriptor[] = {
    TUD_CONFIG_DESCRIPTOR(1, 1, 0, TUSB_DESC_TOTAL_LEN, 0x00, 100),
    TUD_HID_DESCRIPTOR(0, 0, false, sizeof(hid_report_descriptor), 0x81, REPORT_SIZE, 10),
};

// HID 描述符回调
uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance) {
    return hid_report_descriptor;
}

// 主机读取 HID 报告
uint16_t tud_hid_get_report_cb(uint8_t instance,
                                uint8_t report_id,
                                hid_report_type_t report_type,
                                uint8_t *buffer,

                                uint16_t reqlen) {
    return 0;
}

void tud_hid_set_report_cb(uint8_t instance,
                           uint8_t report_id,
                           hid_report_type_t report_type,
                           uint8_t const *buffer,
                           uint16_t bufsize) {
    ESP_LOGI(TAG, "Received from host: %d bytes", bufsize);
    if (bufsize > 0) {
        switch (buffer[0])
        {
        case 0x01: // LED ON
            gpio_set_level(GPIO_NUM_1, 1);
            save_gpio_state(GPIO_NUM_1, 1);
            ESP_LOGI(TAG, "LED1 ON");
            break;
        case 0x02:
            gpio_set_level(GPIO_NUM_2, 1);
            save_gpio_state(GPIO_NUM_2, 1);
            ESP_LOGI(TAG, "LED2 ON");
            break;
        case 0x03:
            gpio_set_level(GPIO_NUM_3, 1);
            save_gpio_state(GPIO_NUM_3, 1);
            ESP_LOGI(TAG, "LED3 ON");
            break;
        case 0x04:
            gpio_set_level(GPIO_NUM_4, 1);
            save_gpio_state(GPIO_NUM_4, 1);
            ESP_LOGI(TAG, "LED4 ON");
            break;
        default:
            gpio_set_level(GPIO_NUM_1, 0);
            gpio_set_level(GPIO_NUM_2, 0);
            gpio_set_level(GPIO_NUM_3, 0);
            gpio_set_level(GPIO_NUM_4, 0);
            save_gpio_state(GPIO_NUM_1, 0);
            save_gpio_state(GPIO_NUM_2, 0);
            save_gpio_state(GPIO_NUM_3, 0);
            save_gpio_state(GPIO_NUM_4, 0);
            ESP_LOGI(TAG, "LEDs OFF");
            break;
        }
    }

    if (tud_hid_ready()) {
        tud_hid_report(0, buffer, bufsize);
    }

}

void app_main(void) {
    ESP_LOGI(TAG, "Controller Start");

    init_nvs();

    gpio_config_t io_conf = {
        .pin_bit_mask = LED_GPIO_MASK,
        .mode = GPIO_MODE_OUTPUT,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE,
    };
    gpio_config(&io_conf);
    // clear_nvs_all();

    const tinyusb_config_t tusb_cfg = {
        .device_descriptor = NULL,
        .configuration_descriptor = hid_configuration_descriptor,
        .string_descriptor = NULL,
        .external_phy = false,
    };
    ESP_ERROR_CHECK(tinyusb_driver_install(&tusb_cfg));
    ESP_LOGI(TAG, "Controller initialized");

    while (1) {
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}
复制成功

如果需要自定义ESP32的VID/PID可以看这篇教程:ESP-IDF开发USB HID设备记录(1)——自定义VIDPID​


同时在主机端使用Python编写测试程序,通过HID通信点亮电路板上的LED:

代码块
clike
自动换行
复制代码
import hid # 此处使用的是hidapi库,使用pip install hidapi安装

VID = 0x303A
PID = 0x4002
# VID/PID可自行修改

def send_command(cmd_byte, status_byte):
    try:
        dev = hid.device()
        dev.open(VID, PID)
        dev.set_nonblocking(1)
        report = bytes([0x00, cmd_byte, status_byte] + [0x00] * 61)
        dev.write(report)

        resp = dev.read(64, timeout_ms=500)
        if resp:
            print(f"Echo: {resp}")

        dev.close()
    except Exception as e:
        print(f"Error: {e}")

if __name__ == "__main__":
    while True:
        val = input("输入1点亮LED,输入0熄灭LED,输入q退出:").strip()
        # if val == '1':
        #     send_command(0x01)
        # elif val == '0':
        #     send_command(0x00)
        # elif val.lower() == 'q':
        #     break
        # else:
        #     print("无效输入")
        match val:
            case '2':
                send_command(0x02, 0x02)
            case '3':
                send_command(0x03, 0x03)
            case '4':
                send_command(0x04, 0x04)
            case '1':
                send_command(0x01, 0x01)
            case '0':
                send_command(0x00, 0x00)
            case 'q' | 'Q':
                break
            case _:
                print("无效输入")
复制成功

VID/PID可根据实际情况自行修改。

最后的的效果如下:


为什么Python示例代码发送的数据内容为65字节,而ESP32的测试数据为64字节?

Python构造的HID通信数据内容为Report ID+64字节实际内容。

HID 协议定义,每个传输的Report数据必须以 Report ID 开头。

如果你在HID Report Descriptor中定义了Report ID,那么每个Report发送的数据就必须以该 ID 开头。

即使你只定义了一个Report,没有显式加Report ID,主机端依旧要求这个位置必须有一个 Report ID。

而hidapi的write方法会直接把构造的数据字节全部发送到HID端点,所以需要我们手动添加一个Report ID。

而ESP32 TinyUSB实际接收到的是由tud_hid_set_report_cb()处理的内容,根据ESP32的代码,report_id为0x00,buffer是后面的64字节。

所以HID设备的实际“有效数据”其实是后面的64字节数据,第一个字节只是一个Report ID。

根据Device Class Definition for HID 1.11规定,当Report ID不使用时值应为0。

Device Class Definition for HID 1.11在Report Protocol下Report Type章节中关于Report ID的描述:


该文章仅为个人开发ESP32 HID设备的备忘录,如有错误还请海涵(非专业出身)。同时欢迎各位UP对这篇文章进行斧正!