USB的HID类设备开发 (STM32)(以F4为例)
参考链接:
HID 简介 – USB中文网
HID报表描述符(目前最全的解析,也是USB最复杂的描述符)_hid描述符-CSDN博客
一、HID设备简介
HID(Human Interface Device,人机接口设备)是USB设备中常用的设备类型,是直接与人交互的USB设备,例如键盘、鼠标与游戏杆等。在USB设备中,HID设备的成本较低。另外,HID设备并不一定要有人机交互功能,只要符合HID类别规范的设备都是HID设备。
二、独有特性
1.HID描述符
HID描述符 – USB中文网
2.HID报表描述符
HID 报告及报告描述简介 – USB中文网
3.通过HID报表描述符实现复合设备
HID复合设备(键盘、鼠标)的实现 – USB中文网
三、HID鼠标例程(以F4为例)
我们以STM32中的USB设备库的HID例程为例,生成一个有鼠标功能的工程
1.开启USB OTG FS外设
2.将USB的设备库中间件设置为HID设备
3.处理时钟问题
4.查看现象
编译下载后将STM32的USB连接到电脑上之后,正常情况下就可以通过UsbTreeView看到对应端口上挂载了相应的设备,如下图所示,也能够看到设备的描述符。
通过使用Bus Hound也可以看到USB设备加载的过程
5.使用工程
编译下载后将STM32的USB连接到电脑上就可以发现鼠标一直在持续向右移动
通过使用Bus Hound也可以看到USB设备确实将数组内的数据上传到电脑上了
那么为什么这样发送就是鼠标右移呢?
6.报表描述符解析
我们都知道通信协议的目的就是希望通信的双方通过共有的“密码本”来对数据进行编码发送和接收解码。从而让数据有了实际的意义,而报告描述符在HID设备中就扮演着这种角色。
在usbd_hid.c文件中的HID_MOUSE_ReportDesc数组中我们可以看到当前STM32的HID报表描述符
6.1例程中的报表描述符
0x05, 0x01, /* Usage Page (Generic Desktop Ctrls) 通用桌面设备 */
0x09, 0x02, /* Usage (Mouse) 设备类型鼠标 */
0xA1, 0x01, /* Collection (Application) 集合开始 */
0x09, 0x01, /* Usage (Pointer) */
0xA1, 0x00, /* Collection (Physical) */
0x05, 0x09, /* Usage Page (Button) 设置鼠标的按键 */
0x19, 0x01, /* Usage Minimum (0x01) 键值范围设置 */
0x29, 0x03, /* Usage Maximum (0x03) 分别对应鼠标的左右中键 */
0x15, 0x00, /* Logical Minimum (0) 按键逻辑值设置 */
0x25, 0x01, /* Logical Maximum (1) */
0x95, 0x03, /* Report Count (3) 占用三个位置 */
0x75, 0x01, /* Report Size (1) 每个位置长度为1位 */
0x81, 0x02, /* Input (Data,Var,Abs) 以上数据是输入的变量 绝对数据*/
0x95, 0x01, /* Report Count (1) 占用一个位置 */
0x75, 0x05, /* Report Size (5) 每个位置长度为5位 */
0x81, 0x01, /* Input (Const,Array,Abs) 以上数据是不变的常量,用于填充当前字节 */
0x05, 0x01, /* Usage Page (Generic Desktop Ctrls) */
0x09, 0x30, /* Usage (X) X轴,对应屏幕的横坐标 */
0x09, 0x31, /* Usage (Y) Y轴,对应屏幕的纵坐标 */
0x09, 0x38, /* Usage (Wheel) 滚轮,对应鼠标的滚轮 */
0x15, 0x81, /* Logical Minimum (-127) 逻辑值设置 */
0x25, 0x7F, /* Logical Maximum (127) */
0x75, 0x08, /* Report Size (8) 每个位置长度为8位 */
0x95, 0x03, /* Report Count (3) 占用三个位置 */
0x81, 0x06, /* Input (Data,Var,Rel) 以上数据是输入的变量 相对数据 */
0xC0, /* End Collection */
0x09, 0x3C, /* Usage (Motion Wakeup) */
0x05, 0xFF, /* Usage Page (Reserved 0xFF) */
0x09, 0x01, /* Usage (0x01) */
0x15, 0x00, /* Logical Minimum (0) */
0x25, 0x01, /* Logical Maximum (1) */
0x75, 0x01, /* Report Size (1) */
0x95, 0x02, /* Report Count (2) */
0xB1, 0x22, /* Feature (Data,Var,Abs,NoWrp) */
0x75, 0x06, /* Report Size (6) */
0x95, 0x01, /* Report Count (1) */
0xB1, 0x01, /* Feature (Const,Array,Abs,NoWrp) */
0xC0 /* End Collection */
所以根据以上报告描述符可以知道这四个字节的意义如下
鼠标发送给PC的数据每次4个字节
BYTE1 BYTE2 BYTE3 BYTE4
定义分别是:
BYTE1 –
|–bit2: 1表示中键按下
|–bit1: 1表示右键按下
|–bit0: 1表示左键按下
BYTE2 – X坐标变化量,负数表示向左移,正数表示右移。范围:-127 ~ 127
BYTE3 – Y坐标变化量,负数表示向下移,正数表示上移。范围:-127 ~ 127
BYTE4 – 滚轮变化。范围:-127 ~ 127
BYTE1高5位是可以不用关注的,一般这5bit 在HID描述符中都是作为填充位使用,置0即可。
所以之前的那种现象自然也就变得非常明白了。
7.报告描述符编写举例
那么我们怎么去修改HID的报告描述符来满足我们自己的要求呢?
可以参考以下内容。
参考链接:HID报告描述符教程 手把手教你编写HID报告描述符 – USB中文网
工具:HID 描述符工具 |USB-IF 接口
HID报表描述符规范:HID 用法表 1.5 |USB-IF 接口
例如我们想实现一个鼠标+键盘+音量控制的HID设备为了实现这样的复合设备即多个功能于一体的设备,其实有两种方法,一种是通过定义不同的接口代表不同的功能,从而实现复合,第二种是HID设备独有的,就是通过在报表描述符中编写多种报表描述符从而实现复合,比如:鼠标+键盘。那么怎么区分发送的数据对应的哪种设备呢?其实就是通过报告ID进行区分的。
那么我们的报表描述符可以如下所示
7.1复合设备的报表描述符
/* USER CODE BEGIN 0 */
//76byte
0x05, 0x01, /* Usage Page (Generic Desktop Ctrls) */
0x09, 0x02, /* Usage (Mouse) */
0xA1, 0x01, /* Collection (Application) */
0x85, 0x01, // REPORT_ID (1)//报告ID
0x09, 0x01, /* Usage (Pointer) */
0xA1, 0x00, /* Collection (Physical) */
0x05, 0x09, /* Usage Page (Button) */
0x19, 0x01, /* Usage Minimum (0x01) */
0x29, 0x03, /* Usage Maximum (0x03) */
0x15, 0x00, /* Logical Minimum (0) */
0x25, 0x01, /* Logical Maximum (1) */
0x95, 0x03, /* Report Count (3) */
0x75, 0x01, /* Report Size (1) */
0x81, 0x02, /* Input (Data,Var,Abs) */
0x95, 0x01, /* Report Count (1) */
0x75, 0x05, /* Report Size (5) */
0x81, 0x01, /* Input (Const,Array,Abs) */
0x05, 0x01, /* Usage Page (Generic Desktop Ctrls) */
0x09, 0x30, /* Usage (X) */
0x09, 0x31, /* Usage (Y) */
0x09, 0x38, /* Usage (Wheel) */
0x15, 0x81, /* Logical Minimum (-127) */
0x25, 0x7F, /* Logical Maximum (127) */
0x75, 0x08, /* Report Size (8) */
0x95, 0x03, /* Report Count (3) */
0x81, 0x06, /* Input (Data,Var,Rel) */
0xC0, /* End Collection */
0x09, 0x3C, /* Usage (Motion Wakeup) */
0x05, 0xFF, /* Usage Page (Reserved 0xFF) */
0x09, 0x01, /* Usage (0x01) */
0x15, 0x00, /* Logical Minimum (0) */
0x25, 0x01, /* Logical Maximum (1) */
0x75, 0x01, /* Report Size (1) */
0x95, 0x02, /* Report Count (2) */
0xB1, 0x22, /* Feature (Data,Var,Abs,NoWrp) */
0x75, 0x06, /* Report Size (6) */
0x95, 0x01, /* Report Count (1) */
0xB1, 0x01, /* Feature (Const,Array,Abs,NoWrp) */
0xC0, /* End Collection */
0x05, 0x01, // USAGE_PAGE (Generic Desktop)
0x09, 0x06, // USAGE (Keyboard)
0xa1, 0x01, // COLLECTION (Application)
0x85, 0x02, // REPORT_ID (2)//报告ID
0x05, 0x07, // USAGE_PAGE (Keyboard)
0x19, 0xe0, // USAGE_MINIMUM (Keyboard LeftControl)
0x29, 0xe7, // USAGE_MAXIMUM (Keyboard Right GUI)
0x15, 0x00, // LOGICAL_MINIMUM (0)
0x25, 0x01, // LOGICAL_MAXIMUM (1)
0x75, 0x01, // REPORT_SIZE (1)
0x95, 0x08, // REPORT_COUNT (8)
0x81, 0x02, // INPUT (Data,Var,Abs)
0x95, 0x01, // REPORT_COUNT (1)
0x75, 0x08, // REPORT_SIZE (8)
0x81, 0x03, // INPUT (Cnst,Var,Abs)
0x95, 0x06, // REPORT_COUNT (6)
0x75, 0x08, // REPORT_SIZE (8)
0x15, 0x00, // LOGICAL_MINIMUM (0)
0x25, 0x65, // LOGICAL_MAXIMUM (101)
0x05, 0x07, // USAGE_PAGE (Keyboard)
0x19, 0x00, // USAGE_MINIMUM (Reserved (no event indicated))
0x29, 0x65, // USAGE_MAXIMUM (Keyboard Application)
0x81, 0x00, // INPUT (Data,Ary,Abs)
0xc0, // END_COLLECTION
/*
当前实现的是音量加和音量减的按键
长按会导致重复触发
*/
0x05, 0x0c, // USAGE_PAGE (Consumer Devices)
0x09, 0x01, // USAGE (Consumer Control)
0xa1, 0x01, // COLLECTION (Application)
0x85, 0x03, // REPORT_ID (3)//报告ID
0x15, 0x00, // LOGICAL_MINIMUM (0)
0x25, 0x01, // LOGICAL_MAXIMUM (1)
0x09, 0xe9, // USAGE (Volume Up)
0x09, 0xea, // USAGE (Volume Down)
0x75, 0x01, // REPORT_SIZE (1)
0x95, 0x02, // REPORT_COUNT (2)
0x81, 0x06, // INPUT (Data,Var,Rel)
0x75, 0x01, // REPORT_SIZE (1)
0x95, 0x06, // REPORT_COUNT (6)
0x81, 0x01, // INPUT (Cnst,Ary,Abs)
/* USER CODE END 0 */
0xC0 /* END_COLLECTION */
从中可以看出相比上一节提到的例程中的报表描述符,本节的描述符中每个功能的报表描述符中均有一个新的元素即报表ID,这个就是通过HID报表实现复合设备的方法,对比多个接口的复合设备肯定就简单很多了。
那么程序中这个报表ID应该怎么用呢?其实就是把每次发送的报表的首字节换成报表ID然后后面再跟着正常的报表数据即可
例如鼠标的数据
7.1中我们应该发送 00 0a 00 00来让鼠标右移10像素
那么8.1中应该发送 01 00 0a 00 00 来让鼠标右移10像素,相比7.1中的报表增加了报表开头的报表ID。(报表ID要从1开始)
7.2测试
编写以下代码进行测试(仅核心函数)
uint8_t Mouse_buffer[]={1,0,0,0,0};
uint8_t Keyboard_buffer[]={2,0,0,0,0,0,0,0,0};
uint8_t Volume_buffer[]={3,0};
while (1)
{
HAL_Delay(100);
Keyboard_buffer[1]|=(0x01<<3);
while(USBD_HID_SendReport(&hUsbDeviceFS,Keyboard_buffer,sizeof(Keyboard_buffer)/sizeof(Keyboard_buffer[0]))!=USBD_OK);
HAL_Delay(100);
Keyboard_buffer[1]=0;
USBD_HID_SendReport(&hUsbDeviceFS,Keyboard_buffer,sizeof(Keyboard_buffer)/sizeof(Keyboard_buffer[0]));
HAL_Delay(1000);
Keyboard_buffer[1]|=(0x01<<3);
USBD_HID_SendReport(&hUsbDeviceFS,Keyboard_buffer,sizeof(Keyboard_buffer)/sizeof(Keyboard_buffer[0]));
HAL_Delay(100);
Keyboard_buffer[1]=0;
USBD_HID_SendReport(&hUsbDeviceFS,Keyboard_buffer,sizeof(Keyboard_buffer)/sizeof(Keyboard_buffer[0]));
HAL_Delay(100);
Volume_buffer[1]|=(0x01<<0);
USBD_HID_SendReport(&hUsbDeviceFS,Volume_buffer,sizeof(Volume_buffer)/sizeof(Volume_buffer[0]));
HAL_Delay(100);
Volume_buffer[1]&=~(0x01<<0);
USBD_HID_SendReport(&hUsbDeviceFS,Volume_buffer,sizeof(Volume_buffer)/sizeof(Volume_buffer[0]));
HAL_Delay(1000);
Volume_buffer[1]|=(0x01<<1);
USBD_HID_SendReport(&hUsbDeviceFS,Volume_buffer,sizeof(Volume_buffer)/sizeof(Volume_buffer[0]));
HAL_Delay(100);
Volume_buffer[1]&=~(0x01<<1);
USBD_HID_SendReport(&hUsbDeviceFS,Volume_buffer,sizeof(Volume_buffer)/sizeof(Volume_buffer[0]));
HAL_Delay(1000);
for(uint8_t i=0;i<4;i++)
{
Mouse_buffer[2]=10;
USBD_HID_SendReport(&hUsbDeviceFS,Mouse_buffer,sizeof(Mouse_buffer)/sizeof(Mouse_buffer[0]));
HAL_Delay(100);
}
}
测试时会发现会依次执行以下动作:
打开开始菜单-》关闭开始菜单-》音量增加-》音量减小-》鼠标指针右移
CSDN_正常情况的HID
在工具中也能看到电脑接受到的报告与我们发送的一致先发送4次键盘报告然后再发送音量调节报告
7.3问题描述与溯源
可能会有小伙伴在此处的代码中有些疑问,比如为什么第一次发送报表要用死循环等待?
我们先来看一下如果不用死循环等待电脑接收到的数据是什么样的
我们会发现第一次发送数据时原本的4次监盘报告,在实际中主机只接收到两次,原因是什么呢?
其实是因为发送报告的函数并非百分百发送成功
这个是函数原型
uint8_t USBD_HID_SendReport(USBD_HandleTypeDef *pdev, uint8_t *report, uint16_t len)
{
USBD_HID_HandleTypeDef *hhid = (USBD_HID_HandleTypeDef *)pdev->pClassDataCmsit[pdev->classId];
if (hhid == NULL)
{
return (uint8_t)USBD_FAIL;
}
if (pdev->dev_state == USBD_STATE_CONFIGURED)
{
if (hhid->state == USBD_HID_IDLE)
{
hhid->state = USBD_HID_BUSY;
(void)USBD_LL_Transmit(pdev, HIDInEpAdd, report, len);
}
}
return (uint8_t)USBD_OK;
}
如果hhid设备指针为空就会返回USBD_FAIL。此处要说明的是hhid设备指针是通过后期赋值获取到的并非原本就有,所以程序此处会有判空操作。hhid设备指针是在调用USBD_HID_Init函数期间通过设置局部静态变量而获取到的。
hhid = (USBD_HID_HandleTypeDef *)USBD_malloc(sizeof(USBD_HID_HandleTypeDef));
USBD_malloc函数其实是下面的函数
原理就是申请静态的局部变量之后,静态的局部变量并不会被释放内存,所以其实是允许程序携带这个局部变量的指针赋值给hhid用于后续使用,(后续通过这个指针操作的其实就是这个局部变量的内存,这种用法个人觉得其实和设置一个全局变量作用几乎一模一样)反例就是如果不是静态修饰的局部变量的指针是不能离开函数的,因为离开函数后就意味着变量的空间会被释放。这个指针就没有意义了,俗称野指针。
所以上面的例子中丢失报告数据的例子其实大概率就是因为STM32模拟的HID设备并未枚举完成,还没有调用USBD_HID_Init函数从而得到数据指针,所以前两次发送报告时因为设备指针为空所以并未发送成功,如果程序中不加死循环进行返回值检测,自然就不会检测到这个情况,但是这个情况只存在于枚举完成之前,枚举完成后(即设备指针成功获取)再次发送数据就不会因为设备数据指针为空而导致发送失败的情况了。
在看这段代码中我们也可以发现即使备指针成功获取也可能会发生返回USBD_OK但是数据却没有真的发送出去。原因就是代码中去检查了当前HID设备的状态,当HID设备的状态为空闲时,可以向对应的缓冲区写入数据,从而等到主机读取时,USB外设将数据从缓冲区发送出去。
这个状态是怎么设置的呢?其实是检测到数据IN(设备发送数据给主机)阶段就会将状态置为空闲,从而在调用USBD_HID_SendReport函数的时候能够真正的将数据发送出去。
所以这个数据的上传逻辑如下图所示(假设已经枚举完成,不考虑设备指针为空的问题)
8.注意事项
HID_FS_BINTERVAL这个宏定义设置的轮询间隔,范围是1-255ms,单位为1ms,
HID_EPIN_SIZE这个宏定义表示上传数据的最大长度,单位为字节。修改过报表描述符之后注意要把这里也同时修改例如原来的例程中设置为4即可,但是在本文中的复合设备中则至少为9,即1个报告ID字节+8个报告数据字节。
作者:可乐苏打水