开发工具: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),并解析以下信息:
描述符类型:作用说明;
设备描述符:声明该设备的USB版本、VID/PID等基本信息;
配置描述符:定义该设备支持的电源、接口数量等;
接口描述符:接口描述符中的bInterfaceClass字段值应为0x03来表示HID类;
HID描述符:HID描述符是HID类设备特有的描述符,它包含了Report描述符的长度和类型信息,指明了主机接下来需要读取Report描述符的位置和大小。Report描述符本身是一个独立且非常重要的描述符。
端点描述符:定义中断端点(IN/OUT);
报告描述符:描述输入输出的报告格式。
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通信注意事项:
报告长度通常是固定的。这是HID协议设计的常见做法,也是实现相对简单的方式。报告描述符定义了每个报告(由Report ID标识)的确切结构和长度。
在报告描述符中定义的每个报告内的各个字段,其Report Size(位长度)和Report Count(该长度的字段数量)定义了该字段在报告数据包中所占的总位数。所有字段的总位数(加上Report ID本身占用的位数,如果使用)决定了该特定报告的固定字节长度。传输的数据包大小必须严格匹配这个长度。
中断端点最大包长度有限制。对于Full-Speed USB HID设备(12Mbps),一般情况下为64字节。
Feature Report只能通过控制传输(Control Transfer)进行传输(使用SET_REPORT和GET_REPORT请求)。中断端点不能用于传输Feature Report。
必须遵循Report描述符一定的格式,否则主机端会忽略数据或识别失败。
ESP32 USB HID设备开发实战:
本次开发的目标测试设备为ESP32-S2 Custom Board,使用GL850G分别引出一个下游口到USB Type-A母座,一个下游口到ESP32-S2,一个下游口到CH340K(用于固件烧录)。

原理图简化后如下:
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。
#define REPORT_SIZE 64
首先配置HID报告描述符。目标测试使用了64字节的Input/Output通道。
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配置描述符:
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的接收处理:
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 报告)。
最后的总测试代码如下:
#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:
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对这篇文章进行斧正!