从这一讲开始,后面将进行到实践环节,这一讲主要是演示如何模拟一个标准的 USB 键盘。
只是在查找资料的朋友建议直接去“第五章 知识总结与梳理”回顾一下本节相关的全部基础知识。
一、标准USB键盘传输分析
1.1知识点回顾
通过USB1.1分析仪软件,观察电脑和标准键盘的传输过程。 回顾USB1.1协议分析仪,如下:
本次使用的是上图中后面的界面,USB1.1。
1.2测试环境
测试软件:USB Monitor(在沁恒微电子官网直接下载)
测试硬件:USB1.1分析仪、电脑、Dell 键盘
1.3后续动作
在连接分析仪之后,就按下深绿色的开始捕捉按键,再连接键盘后自动开始捕捉。
1.3.1第一次获取设备描述符
(1)第 1 行已经标明令牌是RESET,当主机识别到设备之后,就会发出RESET信号,这其实是一个总线状态,不算是包,D+、D-都是低电平,一般这个时间要大于10ms,具体时间要看主机本身安装相应设备驱动,以及接不同的设备该时间有差异。
(2)第 2 行标明的是EOP,等效为 Soft ,但是 Soft 是一个包,这 EOP 可以作为一个包结尾,并不是一个完整的令牌包。
之前在全速和高速通讯中支持 soft 包的 ,而低速是不支持的,低速使用 EOP 代替。从此处出现 EOP 可以判断出当前所连接的键盘是一个低速传输的键盘。
在全速传输中, soft 是间隔一帧是 1ms ,EOP 既然替代它 ,那么 EOP 到下一个 EOP 间隔也是 1ms。
第二行中出现的EOP(34),其中括号中个数,表示这里出现了 34 个 EOP,与最后一列的 34ms 相符合。
(3)第 3.0 行出现的set up 包,黄色的 set up 包是控制传输起始的令牌包,发出了主机的控制请求,作用是获取设备的设备描述符。
set up 包中间的数据一定是固定的 8 个字节,这 8 个字节代表着主机各种各样的命令,就看其中的填充的数据内容。
(4)第 7.1 行和 9.1 行,是设备上传的 18 个字节,是完整的设备描述符。
后面是状态阶段,结束。
(5)第 13 行是主机发出的总线复位。
1.3.2配置地址和第二次获取设备描述符
(1)第 15.0 行是在中线复位之后,主机再次发送了一次控制传输,这次是为设备分配一个地址,地址是 0B ,也就是说地址是 11。
在这个控制传输结束以后,设备获取到 0B 这个地址后会更新设备内部自己的地址。所以在上图中的第 3 列,在本行以前都是使用默认地址 00 进行通讯,在分配完地址以后都是以 0B 这个地址进行通讯。直到设备再次得到 RESET 复位信号后,它才会取消这个地址更换为原来的默认地址 00。
(老师还说了一句我不懂的话:)并且这些都是控制传输,所以它的端点一定是 0 号端点,所以上图中的第 4 列端点一直都是 0 。
(2)第 18 行,出现了 EOP(10),说明中间大概空闲了 10 ms。
(3)第 19 行,在空闲 10 ms 后,主机再次发出控制请求,(可以看描述的那一列)还是获取设备的设备描述符。
注意第一次请求设备描述符的数据内容是:(8 byte)80 06 00 01 00 00 40 00
主机发出的最后四位为:40 00,是向设备要 64 个字节的设备描述符,但实际上真正的设备描述符只有 18 个字节,所以设备只会上传 18 个字节,正如设备回复 7.1~12.1 共 18 个字节。
即主机请求的数据字节数大于设备数据的实际长度,设备会按照实际长度上传。
注意第二次请求设备描述符的数据内容是:(8 byte)80 06 00 01 00 00 12 00
主机发出的最后四位为: 12 00 ,代表主机就请求了 18 个字节的设备描述符,设备在23.1~28.1自然是上传 18 个字节的设备描述符。
(上面的第3、4位是 06,代表是获取描述符,第7、8是 01 代表的是获取设备描述符,拓展:02 代表是配置描述符。)
1.3.3获取配置描述符
(1)第 30.1 行:是主机向设备索要配置描述符,因为主机不知道设备描述符的实际长度所以此处要了一个比较大的 FF (255)个字节长度。
(2)上图的后面部分都是设备反馈的配置描述符集合,细节部分后面再讲解,这里统计一下他的长度为 34 个字节,小于 FF 字节数。
1.3.4获取语言字符串描述符
(1)第 44.1 行代表获取的是一个语言字符串。其中第 3 个字节 00 代表是的语言,第4个字节 03 代表的是字符串。
(2)第46.1 行:设备反馈解释出来表示它后面描述的语言是英文。各个国家有各个国家的原码,主要取决于后面 0904 的这个代码。
1.3.5获取产品字符串描述符
(1)第 49.1 行第 4 个字节 03 代表索要的仍然是字符串,第 3 个字节是 02 ,代表这里可以索要的字符串有 3 个,分别是厂商字符串、产品字符串、产品序列号字符串。
至于这里的 02 代表的具体是上面 3 个中的那一个取决于之前回复的设备描述符如 9.1 行的最后一个字节,回复的是 02 ,代表这里主机索要的事一个产品字符串描述符。
(2)第 51.1~66.1 行是设备回复的产品字符串描述符
1.3.6第三次获取设备描述符
这里主机第 3 次的索要设备的设备描述符。
设备是可以反复获取的设备的描述符的,取决于主机中哪一些驱动层去获取。
但是对于嵌入式平台或者裸机,可能分层不会像这个一样分的这么细,一次获取到相关描述符后后面就可以直接使用。为了节省代码空间和时间,往往也都只要一遍,不会重复去要。
1.3.7第二、三次获取配置描述符
上图是主机第 2 次索要配置描述符,但是最后 4 位是 09 00,获取的是 9 个字节,所以后面反馈给主机的是 9 个字节的。
上图是主机第 3 次索要 0X 22 个字节(第 84.1 行的倒数第二个字节为 16 进制的 22,代表十进制的 34)的配置描述符,后面是设备反馈完整的 34 个字节的配置描述符。
看到这里你肯定会好奇:主机怎么刚刚好要 34 位配置描述符呢?
因为在上面要 9 个字节的配置描述符时,设备反馈的第一个八字节中的第 3 个字节为 22 ,这个参数表明总长度,所以下一次控制传输中就能准确要求设备返回 0x22 个字节给主机。
1.3.8设置配置
在倒数第二列中有英文描述为“Set_Config:01”,表示设置配置,相当于启用了键盘的这个配置。
在前几江中说过设备的结构,设备下面的结构为配置,设备可以有多个配置,并且这些配置一般是互斥关系。当主机要使用哪个配置的时候,必须要先启动它,一旦设备配置被启用,表示这个功能可以使用了。
上面的1.3.1~1.3.7的控制传输发出的命令都是标准的 USB 请求命令,是所有的 USB 设备都必须支持的。(特点是主机发出的请求命令:第一个字节里面的位6,位5都是 0 <老师这里说的不是很清楚,没有字幕,我不确定是否正确,听着像位6 和位 5 ,但是老师的鼠标却放在第一个字节的第二位上>)
1.3.9类请求
位 6 和位 5 如果是1,表示是一个类请求<我不知道具体哪个是位 6 和位 5 ,但是老师的鼠标放在102.1行的 “21” 的 “1” 上>
(1)第102.1行:位 6 、位 5 代表是这个是一个类请求。类请求就要看设备属于什么类,像目前操作的键盘属于 HID 类,就必须支持 HID 类的类请求;若是存储类设备就要支持存储类的类请求;若是音频、视频类就要支持它们隶属于自己的那个传输类的类请求。
这个请求是一个 Set_Idle 的命令,后续会讲解这个类请求对键盘的作用。
1.3.10获取报表描述符
第105.1行的第二个字节是 06 ,表示该命令仍然属于获取描述符。第三个字节是 22,表示获取特有的报表描述符。
(1)第105.1行:表示获取报表描述符,该描述符是属于该键盘特有的描述符。获取到的报表描述符比较长,主要是告知主机这个键盘的键码,包括上传的数据键值的长度等。
1.3.11主机“点灯”
第132.1行:前 2 个字节是 2109,表示它是一个 set report。相当于初始化一下键盘上的指示灯,标准键盘上一般会有 3 个指示灯位置(大小写、数字键、滚动锁定键),这条命令是主机下传的(上面也有是 “out” 的),主机将数据传下来为了更新一下当前键盘的灯的状态。
从这里开始,往下的后面就不一样了,具体的哪里不一样,后面会详细说明。
1.3.12计算机扫描键盘----无按下操作
注意看在上面端点那一列(第4列),在之前的绝大部分都是 00,从这里开始端点这一列全部变成01:表明以上对键盘的控制传输的识别过程已经结束了,这里的对地址 0B、端点 01 去发的 in 包都是去获取键盘的键值数据。但是目前没有按下任何一个键,所以相当于是没有获取到键盘状态的改变,上面 in 都没有 in 到任何数据,键盘回复的也都是 NAK。
同时也可以发现一些规律:
主机对 01 端点发出一个 in 包 → 键盘回复 NAK → EOP(8);如此循环。
可以看出:每间隔 8ms 主机会发出一次 in 包来获取数据,相当于每间隔 8ms 主机扫描键盘转态的改变,目前没有按下所以一直的 NAK 。
1.3.13按下某个按键(不释放→释放)
在展示之前可以先将占页面篇幅很大的 NAK 和 EOP 过滤掉,不再显示在页面上,但是后台仍然在扫描。
1、一直按着空格键不松手
2、松手抬起
空格按下,IN 到的 DATA0 数据:(8 byte)00 00 2C 00 00 00 00 00
空格释放,IN 到的 DATA1 数据:(8 byte)00 00 00 00 00 00 00 00
说明:
(1)当按下空格键不释放,就只有一包 DATA0 数据;当松手抬起,才会有第二包 DATA1 数据。在按下没有释放的这一段长时间里并没有数据再上传上去。
(2) 按下时,主机 in 到的数据0 ,并不是说中间 data 0,1翻转刚好到释放时 in 到了数据1 ,而是中间没有有效数据,只有遇到有效数据的时候它才会翻转。
之前上传了 data0 ,成功传走了,所以下一次才会变成 data1,这一次 data1 成功传走,下一次就会变成 data0,中间的 NAK 是不进行翻转的。
同样主机也会进行识别,如果这次上传的是 data0 ,下次还上传 data0 ,主机会降 data0 这一包数据丢弃,这可以作为 USB 传输的一个校验,来确定中间没有丢包,或者包的翻转是对的。
空格的按下和抬起,在动作改变(没按下→按下),上传;再从动作改变(按下→抬起),上传。中间如果一直按下,是没有任何状态改变去传输的。
1.3.14按下某个带点灯功能的按键(Cap键)
下图展示的是:按下大写锁定按键CAPS LOCK 后抬起
1、按下
(1)第 1.0 行:主机向键盘要数据 in。
(2)第 1.1 行:主机索要到了 DATA0 数据。
(3)第 2.0 行:这是中间插入的控制传输,这个控制传输实际进行的就是点灯(在上面的枚举过程中,就有 21 09 00 这一段,但是当时下面 01,因为是将键盘上的 block 点亮了),现在下面变成 03 (第 3.1 行、第 4.1 行),是他对应的点了两位,现在键盘上对应的 lock 和 cap 两个都点亮了。<其实没有特别清楚的弄懂,但是明白大致意思>
2、抬起
(1)第 6.1 行:电脑重新发一遍,两遍一摸一样,不会改变任何现象。
(2)第10.1行:是按键释放以后,被电脑 in 到的释放动作。
3、按下 CAPS LOCK 释放,就像平时按下按键一样
注意这里买一个坑:按下按键主机获取到(00 00 39 00 00 00 00 00),释放按键主机获取到(00 00 00 00 00 00 00 00),这里的 “ 39” 后面会讲解一张对应的表格。
1.3.15多个按键同时按下
1、单个按键的按下与松开
2、同时按下A和S,并同时松开
3、同时按下A、S、D和松开
先讲结论,具体的原因在后续章节中叙说。
(1)该键盘的报表描述符上传的其实是一个:告知主机的一个 8 字节的报表,也就是键码。
第一个字节 00 :代表的是功能键,对应的是左右shift 、左右ctrl 、左右alt 、左右window;
第二个字节 00:是保留字节,一直维持 00;
后面的六个字节:全部都是键盘的键码数据,也就是说常规的键盘和电脑一般可以同时 6 个键同时组合。但是实际情况是,电脑都是以矩阵交叉的方式去扫,所有有一些不支持组合或者说扫描起来可能有一些问题。
1.4捕包结果
1.5 标准命令请求
1.5.1知识点回忆
穿插回忆一下:
回忆一下控制传输的建立阶段固定的 8 个字节请求命令的结构:
第一个字段:请求类型。
第 7 位:决定了后面数据阶段是由主机到设备还是由设备到主机,像获取描述符都是设备到主机,所以第 7 位一般是 1 ;当主机为设备配置地址等,都是主机到设备,一般是 0。
第6 、5 位:决定了请求类型是标准请求(Standard)、类请求(Class)、产商自定义请求(Vendor)。在刚才的分析软件中出现了标准请求和类请求。
第4、0位:决定了该请求命令是针对设备的哪一层,是设备、接口还是端点这一层等。
第二个字段:请求命令。
在刚刚的演示汇总看到最多的就是 06 ,就是获取描述符;在设置地址的时候是 05。对应的表如上图所示。
第三个字段:参数。
是配合第二个字段去应用的,需要根据具体的请求分析。
第四个字段:一般表示当前设备的接口号或端点号。
在上面的演示中使用的是一个单一的键盘,所以这个单元一般写的都是 0 。
第五个字段:代表长度。
这个代表是主机请求的最大字段,并不是设备实际的最大字段,设备实际可以回的数据小于这个长度,就按照主机请求的回;如果大于它,就取其中的回。
1.5.2标准命令请求
以下是上面使用到的标准命令请求总结:
1、80 06 00 01 00 00 12 00 主机获取设备描述符
(1)80:
①8:主机向设备请求数据,中间的数据阶段一定是 in;
②0:表示是向设备的设备这一层请求的。
(2)06:表示获取的是描述符。
(3)00 01:代表附带这个参数,获取的是设备描述符,而不是其他描述符。
(下面的同样这个位置的00 02,00 03分别代表是配置描述符和字符串描述符;前面的00换成01,02,03就分别代表厂商描述符、产品描述符和产品序列号描述符)
(4)00 00:表示这里没有接口和端点的概念,因为这里面向的是设备层
(5)12 00:这个表示请求数据的长度,这里是小端在前,其实表示的是:0x0012,请求18个字节。
2、00 09 01 00 00 00 00 00 设置设备配置
(1)00 09:固定的
(2)01:01的配置值到底是多少取决于之前要的描述符中配置值,常规的一般是 01,一定要是非零数,如果是 00 的话相当于这个配置并没有启动.
1.5.3类请求
注意这里的:
第一个字节的第2位都是1 ,上面的都是0 ,0 表示操作都是在设备层,1 表示操作都是对接口这一层;
第一个字节的第1位是 8 表示主机获取,是 2 表示主机设置。
(1)21 0A 00 00 00 00 00 00 :SET_IDLE
其中第四个 00 是一个通常都会写成 00 的参数,表达的意思是如果键盘有任何键值或键状态的该键(比如说单个按键变成多个按键、键由按下变成抬起或者由抬起变成按下)才上传数据,没有键转态的改变就像上述抓包一样,主机 in 然后键盘回复 nek 就可以了。
这样的优点是可以节约带宽,如果这里不是 00 而是定时时间去上传当前键盘的数据,因为上传数据都会占用一定带宽,所以这样比较消耗带宽,另外主机还需要自己去判断键盘状态的改变,和上一次获取的按键值进行比较,然后才能进行下一步动作。
(2)81 06 00 22 00 00 81 00:获取报表描述符
主要获取了键盘的这些键位上传的数据含义、表达的长度以及作用
(3)SET_REPORT
在上面的应用中,可以将SET_REPORT简单的理解成点灯。
但是在不同的情况下也有其他的含义,遇到了额外在讲,在键盘上可以单独的理解成点灯。
1.6 HID 类常用文档
- Device Class Definition for Human Interface Devices(HID)
标准的HID文档讲解了一些 HID 描述符的规范进而一些举例说明 - Hid Usage Tables
可以认为是一个查表的文档,关于键码的位置、对应的值以及平时做相关的 HID 手柄、遥感之类的报表定义都在文档中查询,比较实用。
二、软件实现
2.1软件搭载平台

(1)CH549:51单片机,属于 8 位增强型 USB 系列;
(2)ESD保护器件:由于 USB 是接口是金属,并且经常插拔,故必须有防静电;
(3)保险丝与电阻:都为了防止电流过大;
(4)一般代码下载可以通过 USB 口或者串口下载;
(5)按键:可以相当于进行 USB 下载的 boot 引脚。
(6)其他说明:
2.2软件设计思路
在数据传输阶段是主机周期性的发 IN 包获取当前的键值,一般主机会在枚举里面发SET_IDLE来告知键盘有状态改变的时候需要进行上传。
所以在中断传输中没有改变时设置回应的是NAK 包;当有数据变化的时候,要上传对应的键值。
当上传的键码是带点灯功能的,一旦上传后主机识别后会发出点灯命令,此时又变成了控制传输,控制键盘上的指示灯是亮还是灭。
2.3软件框架
当收到主机“设置设备配置”说明主机已经“认识”到键盘,开始键盘的功能,并且将标志 Cfgflag 置一;Set_Idle 就是匹配当前是否进行间隔型上传键值;Set_Report (设置报表)根据主机设置报表下传的数据来点亮/熄灭键盘上的指示灯。
三、软件代码说明
3.1方案说明
主要演示的功能有:
- 电脑识别到模拟的USB键盘
- 上传固定的按键码
- 上传点灯按键
- 上传组合按键值
使用的是 Keil 工程,工程结构如下:
其中,KeyBoard 是主结构;
DEBUG 是 CH549 单片机厂家提供的调试驱动,其中的代码主要有配置时钟(CfgFsys)、延时函数(mDelayuS)、串口(CH549UART0Alter )、打印(mInitSTDIO)、复位(CH549SoftReset)等一些比较辅助的功能。
在课程中老师是直接展示、讲解代码,由于不是带着一步一步的敲代码,我下面的笔记可能会有点“跳跃”。
3.2 KeyBoard.C 包含的头文件
(1)CH549.H:单片机自带的一个头文件,里面定义了一些外设寄存器的地址以及访问的一些系统变量;
(2)DEBUG.H :对应 DEBUG.C ,里面定义了一些帮用户写好的调试函数。
(3)KeyCodeTable.H :(是老师额外加的),里面将键码数据进行罗列,在调用的时候直接调用对应的名称即可。
3.3 KeyBoard.C中的设备描述符
#include ".\Public\CH549.H"
#include ".\Public\DEBUG.H"
#include".\KeyCodeTable.H"
#ifdef Fullspeed
#define THIS_ENDPO_SIZE 64
#else
#define THIS_ENDPO_SIZE 8 //低速USB,中断传输、控制传输最大包长度为8
#endif
#define EP1_IN_SIZE 8 //键盘端点数据包大小
#pragma NOAREGS
/*设备描述符*/
UINT8C DevDesc[]={
0x12, //bLength::描述符总长度(18字节)
0x01, //bDescriptorType:设备描述符类型(0x01)
0x10,0x01, //bcdUSB:USB版本1.10(低位在前)
0x00, //bDeviceClass:设备类(0表示每个接口指定)
0x00, //bDeviceSubClass:子类(无)
0x00, //bDeviceProtocol:协议(无)
THIS_ENDPO_SIZE, //bMaxPacketSize0:端点0最大包大小(取决于上面定义)
0x86,0x1a, //idVendor:厂商ID
0xe1,0xe6, //idProduct:产品ID
0x00,0x01, //bcdDevice:设备版本号(1.00)
0x01, //iManufacturer:字符串描述符索引(厂商)
0x02, //iProduct:字符串描述符索引(产品)
0x00, //iSerialNumber:序列号(0表示无)
0x01 //bNumConfigurations:该设备支持的配置数量(1个)
}
(1)5~13行
判断是否使用Full-speed USB(64字节),否则默认使用低速(8字节)。
#define EP1_IN_SIZE:定义端点1(IN方向)数据包大小为8字节,用于上传键盘数据。
可以得出这里模拟的是一个低速键盘。
(2)第 16 行:UINT8C DevDesc[]
主机会向设备要设备描述符,所以像设备描述符、配置描述符都是固化好再设备里面,所以一般定义的时候可以把它定义成一个 code。跳转之后的页面如下:
这样的话,它在代码存储的时候会放在 code 区,可以节约我们的 RAM 资源。不需要的话,放哪里的都可以。
3.4 KeyBoard.C中的配置描述符
/*配置描述符*/
UINT8C CfgDesc[]=
{
/*配置描述符 */
0x09, //bLength:配置描述符的长度(9 字节)
0x02, //bDescriptorType:配置描述符类型(0x02)
0x22,0x00, //wTotalLength:该配置中所有描述符总长度(34 字节 = 0x0022)
0x01, //bNumInterfaces:该配置支持的接口数量(1 个接口)
0x01, //bConfigurationValue:该配置的编号,主机用于 SetConfiguration 设置该值
0x00, //iConfiguration:字符串描述符索引(0 表示无字符串描述符)
0xA0, //bmAttributes:
// Bit 7 固定为1
// Bit 6 = 1:自供电
// Bit 5 = 0:不支持远程唤醒
// => 0xA0 表示自供电设备
0x32, //MaxPower:设备最大电流(0x32 * 2mA = 100mA)
/*接口描述符,键盘功能 */
0x09, //bLength:接口描述符长度(9 字节)
0x04, //bDescriptorType:接口描述符类型(0x04)
0x00, //bInterfaceNumber:接口编号(0,第一个接口)
0x00, //bAlternateSetting:备用接口设置(0,无备用设置)
0x01, //bNumEndpoints:该接口使用的端点数(1 个端点)
0x03, //bInterfaceClass:接口类(0x03 = HID,人机接口设备类)
0x01, //bInterfaceSubClass表示设备属于 HID Boot Interface 子类,这样在没
有专用驱动的系统(如 BIOS、U盘启动器)中也可以识别为标准键盘。这里
是主机,表示支持BIOS启动。
0x01, //bInterfaceProtocol:01代表使用的是 HID 键盘协议,02代表的是鼠标
0x00, //iInterface:表示该接口没有额外的字符串描述符,比如接口名称。
/*HID类描述符 */
0x09, //bLength:HID 类描述符长度(9 字节)
0x21, //bDescriptorType HID描述符
0x10,0x01, //bcdHID:HID 规范版本 1.10(低位在前)
0x00, //bCountryCode:国家代码(0 = 无特定国家)
0x01, //bNumDescriptors支持的附属描述符数目(0x22 = 报告描述符)
0x22, //bDescriptorType类别描述符类型
0x3f,0x00, //wDescriptorLength:报告描述符长度(0x003F = 63 字节)
/*端点描述符 */
0x07, //bLength:端点描述符长度(7 字节)
0x05, //bDescriptorType:端点描述符类型(0x05)
0x81, //bEndpointAddress:端点地址(0x81 表示端点1,IN方向)
0x03, //bmAttributes:传输类型(0x03 = 中断传输)
EP1_IN_SIZE, 0x00, //wMaxPacketSize:最大包大小(EP1_IN_SIZE 一般为 8)
0x0a, //bInterval:主机轮询端点的时间间隔(单位:毫秒,这里是 10ms)
}
(1)
老师的第一句:0x22, //bDescriptorType类别描述符类型
老师的第二句并翻到前面对应的PPT:对应主机跟键盘获取的报表描述符:81 06 00 22 00 00 81 00 中的22
解释:
①背景知识
HID(Human Interface Device)是一种USB设备类型,比如鼠标、键盘、手柄。HID设备不是直接发送数据,而是通过叫“报表(Report)”的结构和主机交互。
这些“报表”的格式,是通过一个叫 HID 报表描述符(HID Report Descriptor) 的结构告诉主机
②第一句解释
这是 HID 描述符中的一项,它的意思是:“接下来我要告诉你我支持的附属描述符类型了,类型是 0x22
”。
而 0x22
正好是:“HID 报表描述符”(HID Report Descriptor)的类型代码
也就是说,这里告诉主机:“我的 HID 类设备附带了一个 HID 报表描述符”。
③81 06 00 22 00 00 81 00
这 8 个字节拆解如下:
字节位置 | 含义 | 值 |
0 | bmRequestType:设备->主机 | 0x81 |
1 | bRequest:GET_DESCRIPTOR 请求 | 0x06 |
2,3 | wValue = 0x2200:请求类型 0x22(报表描述符),索引 0x00 | 0x00 0x22 |
4,5 | wIndex:接口编号,一般为 0 | 0x00 0x00 |
6,7 | wLength:期望读取的数据长度,比如 129 字节 | 0x81 0x00 |
④总体解释
老师的意思是:
设备在 HID 描述符中写了 0x22
(表示我有 HID 报表描述符),
主机看到之后,就会通过 USB 控制传输,发出一个 GET_DESCRIPTOR
请求,
请求类型编号是 0x22
,来问设备:“你那份 HID 报表描述符(Report Descriptor)给我看一下!”
(对于会的人来说,应该很简单,但是我不会,所以我多写一小点)
(2) EP1_IN_SIZE, 0x00, //wMaxPacketSize:一个端点可以上传的最大包大小(EP1_IN_SIZE 这里为 8 ,由上面的宏定义定义完成的)
这里需要区分一下概念:端点大小和报表长度,这两个概念并不是互等
①端点是USB真实存在的物理结构,实际可以传输包。
像下面每一个包可以传输数据的长度一定是小于等于端点定义的最大描述符大小的。
但是报表长度是一种从软件的概念,主机去解析有效数据内容的长度,比如说键盘告知电脑“我的报表是 16 个字节,但是端点描述符是 8 个字节”
那么在上图中看到的传输还是 8 个字节一传,8 个字节一传,但是电脑一定要等到两包数据,满 16 个字节才会去解析(两个 8 字节 ),才能表达一次完整有效的数据
3.5 KeyBoard.C中的字符串描述符
/*字符串描述符*/
UINT8C MyLangDescr[]={0x04,0x03,0x09,0x04}; //语言描述符
UINT8C MyManuInfo[]={0x0E,0x03,'w',0,'c',0,'h',0,'c',0,'n',0}; //厂家信息
UINT8C MyManuInfo[]={0x0C,0x03,'C',0,'H',0,'5',0,'4',0,'9',0}; //产品信息
没有定义产品序列号,因为这里是对应设备描述符中倒数4、3、2行的厂家描述符索引号、产品描述符索引号和序列号描述符。
其中产品信息序列号写的是 “0x00”,主机会认为是没有产品序列号的,是不会请求列要的该序列号描述符。
如果希望主机一个描述符都不要“要”,可以将倒数4、3、2行全部写成 0x00 。
3.6 KeyBoard.C中的键盘功能报表描述符
UINT8C KeyRepDesc[] =
{
0x05, 0x01, // Usage Page (Generic Desktop Controls):通用桌面设备(如键盘、鼠标)
0x09, 0x06, // Usage (Keyboard):使用项为键盘
0xA1, 0x01, // Collection (Application):开始一个应用集合(键盘的整个描述开始)
0x05, 0x07, // Usage Page (Key Codes):键盘按键页
0x19, 0xE0, // Usage Minimum (Left Control):控制键的最小编号(从E0开始)
0x29, 0xE7, // Usage Maximum (Right GUI):控制键的最大编号(到E7)
0x15, 0x00, // Logical Minimum (0):逻辑值最小为0
0x25, 0x01, // Logical Maximum (1):逻辑值最大为1
0x75, 0x01, // Report Size (1):每个按键状态用1位表示
0x95, 0x08, // Report Count (8):有8个控制键(如Ctrl、Alt、Shift)
0x81, 0x02, // Input (Data, Variable, Absolute):输入端,表示这8个位是控制键的状态
0x95, 0x01, // Report Count (1):分配1个字节
0x75, 0x08, // Report Size (8):大小8位
0x81, 0x01, // Input (Constant):常量数据,保留字节(padding)
0x95, 0x03, // Report Count (3):接下来为LED灯(如大写锁定、数字锁定等)分配3位
0x75, 0x01, // Report Size (1):每个LED灯状态用1位表示
0x05, 0x08, // Usage Page (LEDs):LED使用页
0x19, 0x01, // Usage Minimum (Num Lock):LED最小为Num Lock
0x29, 0x03, // Usage Maximum (Scroll Lock):LED最大为Scroll Lock
0x91, 0x02, // Output (Data, Variable, Absolute):输出端,表示LED状态的控制
0x95, 0x05, // Report Count (5):还剩5个位没用
0x75, 0x01, // Report Size (1):每个位1位
0x91, 0x01, // Output (Constant):这5个位作为保留字节(padding)
0x95, 0x06, // Report Count (6):普通按键区同时最多按6个键
0x75, 0x08, // Report Size (8):每个按键用一个字节
0x15, 0x00, // Logical Minimum (0):按键值最小为0
0x25, 0x65, // Logical Maximum (101):按键值最大为101(即键值范围0~0x65)
0x05, 0x07, // Usage Page (Key Codes):键值页
0x19, 0x00, // Usage Minimum (0):键值0起
0x29, 0x65, // Usage Maximum (101):键值最大为0x65
0x81, 0x00, // Input (Data, Array):输入端,键值列表(最大支持6键同时按下)
0xC0 // End Collection:集合结束
};
上面涉及到的知识点比较多,放在后面做详细的讲解。
上述代码流程总结:
- 主机枚举时读取设备的 HID 报表描述符;
- 通过这个结构,主机知道:
“原来你会发8个修饰键 + 保留 + 最多6个普通键”; - 当你上传按键信息时,只需按照这个结构发数据包,主机就能正确理解你发了什么键。
3.7 KeyBoard.C中的定义两个缓冲区及以下一些代码
UINT8X EpoBuffer[THIS_ENDPO_SIZE+2] _at_ 0x0000;
// 端点0的 OUT & IN 缓冲区,起始地址必须是偶数(对齐要求),分配大小为端点0的数据大小 + 2 个额外字节。
UINT8X EplBuffer[EP1_IN_SIZE+2] _at_ THIS_ENDPO_SIZE+2;
// 端点1的 IN 缓冲区(发送数据到主机),也必须对齐到偶地址,地址紧接在 EpoBuffer 之后。
#define UsbSetupBuf ((PUSB_SETUP_REQ)EpoBuffer)
// 将端点0缓冲区强制转换为 USB 请求结构体指针(方便处理 SETUP 请求包)
UINT8 SetupRegType;
// USB SETUP 请求的类型字段(bmRequestType)
UINT8 SetupReq;
// USB SETUP 请求的 Request 字段(bRequest)
UINT16 SetupLen;
// USB SETUP 请求的数据长度(wLength)
UINT8 UsbDevConig = 0;
// USB 设备当前配置值(由主机设置)
UINT8 UsbDevAddr = 0;
// USB 设备地址(由主机通过 SET_ADDRESS 请求设置)
PUINT8 pDescr;
// 指向当前 USB 描述符的数据指针(用于返回配置/接口/设备/字符串描述符)
UINT8 HIDKey[EP1_IN_SIZE];
// 定义一个 HID 键盘报告数据缓存,长度等于端点1的 IN 缓冲大小
//UINT8 EndplBusy = 0;
// (可选)端点1忙标志位,注释掉了,若启用用于防止重复发送数据
UINT8 WakeUpEnFlag = 0;
// USB远程唤醒功能使能标志(Remote Wakeup Enable Flag)
3.8 KeyBoard.C中初始化函数(重要)
void USBDeviceInit( void )
{
IE_USB = O;
/*端点收发缓冲区配置,端点0 +端点1发送 */
UEPO_DMA = EOBuffer; //端点0数据传输地址
UEPO_CTRL=UEP_R_RES_ACK | UEP_T_RES_NAK; //OUT事务返回ACK,IN事务返回NAK
UEP1_DMA = EplBuffer; //端点1数据传输地址
UEP4_1MOD=bUEP1_TX_EN; //端点1 发送使能 单64字节缓冲区
UEP1_CTRL = bUEP_AUTO_TOG | UEP_T_RES_NAK; //端点1自动翻转同步标志位,IN事务返回NAK
UEP1_T_LEN = O; //预使用发送长度一定要清空
/*控制寄存器配置 */
USB_DEV_AD = Ox00;
USB_CTRL=bUC_DEV_PU_EN | bUC_INT_BUSY | bUC DMA EN; //启动USB设备及DMA,在中断期间中断标志未清除前自动返回NAK
UDEV_CTRL = bUD_PD_DIS; //禁止DP/DM下拉电阻
USB_INT_FG = OxFF; //清中断标志
USB_INT_EN=bUIE_SUSPEND | bUIE_TRANSFER | bUIE_BUS_RST;
#ifndef Fullspeed
UDEV_CTRL | = bUD_LOW_SPEED; //选择低速1.5M模式
USB_CTRL |= bUC_LOW_SPEED;
#else
UDEV_CTRL &=~bUD_LOW_SPEED; //选择全速12M模式,默认方式
USB_CTRL &=~bUC_LOW_SPEED;
#endif
UDEV CTRL |= bUD_PORT_EN; //允许USB端口
IE USB = 1; //打开USB中断
}
(1)第 10 行:AUTO_TOG 自动翻转
设备上传数据的时候可以看到 DATA0 和 DATA1 翻转 HID ,平时控制器一般可能会将这些权限都给用户,让用户自己去填是 DATA0 还是 DATA1。
549 的功能设置了该 AUTO 会根据你上次传的 DATA0 / DATA1,下次就自己自动翻转。当发生错误或者需要强制自己变换的话,可以切换成手动自己去改发送的数据是 DATA0 还是 DATA1。
(2)第 15 行:bUC_INT_BUSY
当键盘接受到主机发出的控制传输, set up 包的时候,这个时候键盘可能需要去解析这条命令是做什么用的,并且根据这个去装载数据。
这个时候在单片机平台下载都是会有消耗时间的,但是 USB 通讯是要有固定的时间保障的。故BUSY 信号显得非常重要,当键盘处于 BUSY 阶段的时候(比如处于处理主机的请求包的过程中)它会自动回复 NAK 信号,告诉主机“我暂时没有数据或者忙”,而不会因为键盘的忙碌暂停传输。
(3)第 19~24 行:设备速度
控制器决定当前配的是一个低速设备还是全速设备,会自动在 USB 的 UD+ 和 UD- 引脚上内部有自动的上拉电阻,不需要额外去添加外面的电阻,所以 USB 外围的电路非常的简单。
3.9 KeyBoard.C中中断函数(重要)
void DeviceInterrupt( void )interrupt INT_NO_USB using 1 //USB中断服务程序,使用寄存器组1
{
UINT16 len =0;
if( UIF_TRANSFER ) //USB传输完成标志
{
else if(UIF_BUS_RST) //设备模式USB总线复位中断
{
printf("raset\n");
UEPO_CTRL = UEP_R_RES_ACK | UEP_T_RES_NAK;
UEP1_CTRL = bUEP_AUTO_TOG | UEP_T_RES_NAK;
USB_INT_FG = OxFF;
}
else if(UIF_SUSPEND) //USB总线挂起/唤醒完成
{
UIF SUSPEND=O;
if( USB_MIS_ST & bUMS_SUSPEND ) //总线挂起
{
printf("suspend\n”);
/*可以设置设备进入低功耗状态 */
}
else //总线唤醒
{ }
}
else{ //意外的中断,不可能发生的情况
USB_INT_FG = 0xFF;
printf("UnkownInt\n");
}
}
(1)中断
键盘设备中的传输过程,尤其是控制传输全部是靠中断完成的。549 单片机一共开启了 3 个中断,在 3.8 节的代码中的第 18 行:
USB_INT_EN=bUIE_SUSPEND | bUIE_TRANSFER | bUIE_BUS_RST;
bUIE_SUSPEND:挂起中断;
bUIE_TRANSFER:传输中断;
bUIE_BUS_RST:复位中断;
以上三个中断比较重要,其中
①挂起中断
告知主机当前是否还想继续进行操作,如果处于挂起状态代表主机不操作或者主机睡眠,这个时候若没有相应的 USB 传输或者其他的工作,可以将自己进入一个低功耗的状态。
当主机想操作键盘的时候,会撤销挂起状态,进行唤醒。
也就是“挂起”其实表示的两个意思:从正常工作到→挂起;从挂起→唤醒。
②传输中断
USB 传输都是以事务为单位的,所以传输中断对应的是一个事务的完成,完成之后上报的一个“通知”。比如说:主机下发一个 SET UP 令牌包下来进行一个 SET UP 事务,当键盘收到事务并应答 ACK 以后,它就会报出一个传输完成。
当主机跟你要一个数据,你上传数据结束之后,收到主机的应答 ACK ,也是一个传输完成。
所以传输完成( 中断)是后面的实战中经常用到的。
③总线复位
总线复位相当于主机告知设备可以把相应的 USB 状态都回归为初始状态,包括地址清零,PID 、回归 DATA0 等等。
3.10 用中断方式相应 USB 的传输
前面开了 3 种中断 ,所以相应的:一旦单片机发现了 USB 中断就无非是下面几种。
void DeviceInterrupt(void) interrupt INT_NO_USB using 1
// USB中断服务程序(Interrupt Service Routine),使用寄存器组1
{
UINT16 len = 0;
// 定义一个16位的变量,用于临时存储传输的数据长度(后续可用于 EP 传输)
if (UIF_TRNSFER)
// 判断是否是“USB传输完成中断”,此处应加处理逻辑(缺少了大括号中的处理内容)
{
switch(USB_INT_ST & (MASK_UIS_TOKENN | MASK_UIS_ENDP))
//下面的每一个案例代码非常长,我在下面的文字中说一下思路
}
else if (UIF_BUS_RST)
// 判断是否是“USB总线复位中断” —— 设备连接或重连时会触发
{
printf("reset\n");
USB_DEV_AD = 0x00;
//复位将设备地址设为 0
// 设置端点0控制器:接收应答ACK,发送为NAK(暂时不应答)
UEPO_CTRL = UEP_R_RES_ACK | UEP_T_RES_NAK;
// 设置端点1控制器:自动切换数据同步标志,发送为NAK
UEP1_CTRL = bUEP_AUTO_TOG | UEP_T_RES_NAK;
// 清除所有USB中断标志位
USB_INT_FG = 0xFF;
}
else if (UIF_SUSPEND)
// 判断是否是“USB挂起或唤醒中断”
{
UIF_SUSPEND = 0; // 手动清除中断标志位
if (USB_MIS_ST & bUMS_SUSPEND)
// 判断是否为“挂起状态”
//通过状态寄存器判断当前是正常→挂起还是挂起→唤醒
{
printf("suspend\n");
// 可以在这里加入代码:进入低功耗状态
// 例如:关闭某些模块、降低系统时钟频率等
}
else
{
// 如果不是挂起状态(即为唤醒状态),也可以处理恢复
//可以在这里补自己相应要做的工作
}
}
else
{
// 处理未知类型的中断
USB_INT_FG = 0xFF; // 清除所有中断标志
printf("UnknowInt\n"); // 打印提示信息
}
}
这个中断服务函数主要处理以下三类 USB 事件:
-
UIF_TRNSFER:USB数据传输完成;
-
UIF_BUS_RST:USB总线复位,初始化端点设置;
-
UIF_SUSPEND:主机发出挂起(Suspend)或唤醒信号,可用于低功耗控制。
其中USB数据传输完成只写了一句( 第 10 行):switch(USB_INT_ST & (MASK_UIS_TOKENN | MASK_UIS_ENDP))。传输完成代表着:EST UP 包接收完成,OUT 包接受完成或者 IN 包上传数据完成。在传输完成时,用 switch 来判断当前中断状态是具体是由什么引发的(就是上面的说的是EST UP 包、OUT 包还是 IN 包)。具体如下:
判断是端点1的 IN 还是端点 0 的 SET UP;还是端点 0 的 IN 以及 OUT。
因为一共就两个端点 1 和 0 ,1 端点只定义上传端点,那么只会有上传端点 1 IN。
端点一是进行数据按键传输的端点,端点 0 的SET UP 、0 、1 都是跟控制传输相关的。
SET UP的流程:
- 先读取SET UP 接受的数据长度,判断这个长度,是不是一个 8 个字节的控制传输包(一个结构体)。
- 保留主机请求总长度,主机请求类型和主机请求码,也就是下图,保存后后续使用。
- 判断当前的请求类型可以区分当前是不是类请求或者标准请求,若是标准请求需要进行获取描述符,需要对应描述符填充之前讲的参数,并赋给一个指针,最后传出去。
- 老师,说上面没有详细学习的必要,厂家会提供比较详细、稳定的代码,这里就不讲了。
3.11主函数
void main()
{
UINT8 i, scankey[2]; // i 用于记录上一次按键状态;scankey[2] 用于读取两次按键状态实现去抖
UINT8 key; // 当前按键值
CfgFsys(); // 配置系统时钟
mDelaymS(20); // 延时20毫秒,确保系统稳定
mInitSTDIO(); // 初始化串口,用于 printf 打印调试
printf("USB Keyboard Test...\n"); // 打印提示信息
USBDeviceinit(); // 初始化USB设备
EA = 1; // 允许全局中断
memset(HIDKey, 0, sizeof(HIDKey)); // 清空键值缓存(HID上报使用)
// 配置P1.0为上拉输入,用于检测按键
P1_DIR_PU |= (1 << 0); // 设置P1.0为输入方向,并启用上拉(P1方向寄存器)
P1_MOD_PU |= (1 << 0); // 设置P1.0的输入模式为上拉模式(P1模式寄存器)
key = 0; // 当前按键初始为0(无按键)
i = 0; // 上一次记录的按键值初始化为0
while (1)
{
/* 执行按键扫描:读取两次P1.0状态,避免抖动 */
scankey[0] = P1_0; // 第一次读取按键值
mDelaymS(10); // 延时10ms
scankey[1] = P1_0; // 第二次读取按键值
// 如果两次读取值相等,说明按键稳定
if (scankey[0] == scankey[1])
{
if (!scankey[0]) // 如果P1.0为低电平,表示按键按下
key = HID_KEYBOARD_A; // 赋值为'A'键对应的HID键码(用户需定义宏)
else
key = 0; // 否则表示没有按键
}
/* 若检测到按键状态发生变化,上传新的按键值 */
if (i != key)
{
if (key != 0)
KeyBoardUPKeyValue(key); // 上报按键值到主机
else
; // 可选:释放按键或清空状态
i = key; // 更新上一次按键状态
}
}
}
(1)第 3~7 行:主函数进行时钟配置
(2)第 9~10 行:是一个调试口,连接一个串口到电脑,可以看到他的输出执行状态
(3)第 12 行:设备初始化;
第 13 行:打开了一个计算机中断
在初始化里面已经打开了 USB 中断,在 EA 里面再打开系统总中断,这样就可以进行 USB 传输了。
(4)第 18、19 行:配置了一个 IO 口 P10 作为外部的按键,模拟某一个按键状态
(5)按照之前的主机架构,进行扫描
扫描按键的动作具体过程如下:因为外面只有一个案件所以只要检测这一个按键的变化,是连续扫描两次,一次扫描之后延迟 10ms 后再读一次,相当于消抖。
当当前的状态有效(按下/抬起),这个时候根据当前按下的状态读到 IO 口是低电平,认为是按下了数字 A 键,否则是抬起。
其中涉及到了 i 表示上一次按键的记录值,K表示当前按键值(初始为0,无按键)。
如果状态改变,并判断不是一个非 0 键之后,上传当前的键码,否则上传的释放键全 0 。
最后将本次的值赋给 i ,作为一个新的记录,方便下一次对比。
(6)在(5)中“上传当前键码”的函数名为:KeyBoardUPKeyValus,跳转到这个函数:
UINT8 KeyBoardUPKeyValue(UINT8 key) // 上传单个按键值到主机(按下动作)
{
/* 判断键码是否超出HID规范范围(排除非法键值) */
if (key > HID_KEYBOARD_RIGHT_GUI)
return ERR_KEY_CODE; // 返回错误:键码超范围
/* 排除无效编码区间(功能键与普通键中间存在空档) */
else if ((key > HID_KEYBPAD_APP) && (key < HID_KEYBOARD_LEFT_CTRL))
return ERR_KEY_CODE; // 返回错误:键码处于空缺区域
/* 若是普通按键(A~Z、1~9等),赋值给HID报告的第3字节 */
if (key < HID_KEYBPAD_APP)
HIDKey[2] = key;
/* 若是功能键(Ctrl、Shift等),设置HID报告第1字节的相应bit位 */
if ((key >= HID_KEYBOARD_LEFT_CTRL) && (key <= HID_KEYBOARD_RIGHT_GUI))
HIDKey[0] = (1 << (key - HID_KEYBOARD_LEFT_CTRL)); // 功能键使用bit位标识
/* 等待端点空闲(主机已取走上次数据),才能发送本次键值 */
while ((UEP1_CTRL & MASK_UEP_T_RES) == UEP_T_RES_ACK); // 等待端点状态不是ACK
/* 通过USB端点1向主机上传键值报告 */
EnplIntIn(HIDKey, sizeof(HIDKey));
return 0; // 返回成功
}
在看上代码之前,需要先去看一下键值:
其中 8 个字节的数据,只有第二个字节是一个保留键,第一个字节是功能键,第三个字节代表的是剩下其他的键。
所以上面函数的功能是:将单个按键(按下动作)通过USB HID协议发送给主机。它会判断按键是否合法,并将按键值填入 HID 报告结构中,通过 USB 端点1 传输给主机。
它主要完成了三个事情:
-
验证按键是否合法(防止传入错误键值)。
-
将合法的键值写入 HID 报告数组
HIDKey[]
:-
普通键写入第3个字节(
HIDKey[2]
)。 -
功能键(如Ctrl)通过 bit 位写入第1个字节(
HIDKey[0]
)。
-
-
等待端点空闲后通过
EnplIntIn()
函数上传该报告到主机。
在上面代码的倒数第二行有 EnplIntIn(HIDKey, sizeof(HIDKey)),在跳转到 EnplIntIn 函数如下:
void EnplIntIn(UINT8 *buf, UINT8 len) // 将数据从缓冲区发送到USB端点1(IN传输)
{
memcpy(EplBuffer, buf, len); // 将要发送的数据从 buf 拷贝到端点1的发送缓冲区 EplBuffer
UEP1_T_LEN = len; // 设置端点1发送的数据长度
UEPI_CTRL = UEPI_CTRL & ~MASK_UEP_T_RES | UEP_T_RES_ACK;
// 清除端点传输响应位(先将 T_RES 位清零),然后设置为 ACK
// 表示端点准备好发送数据,主机会收到一个 ACK 信号,表示有数据可取
}
该函数的作用是:通过 USB 端点1(IN端点)发送数据到主机。
它完成了如下步骤:
-
把要发送的数据复制到端点缓冲区;
-
设置数据长度;
-
修改端点控制寄存器,让 USB 主机知道设备准备好了数据(设置为 ACK),主机将从中读取这段数据。
说人话就是:设置当前要填充上传 8 个字节,就要填充对应的长度,以及要把它对应上传数据,上传数据时的状态还需要改成 ACK 。要不然默认的情况下它都是 NCK 情况(因为没有键的时候,默认值是 NCK )
回到主函数中,一旦传输完成之后会发成中断,告诉你这个传输完成,默认应答 NCK ,一旦清除中断以后,它救护自动以 NCK 应答主机的请求。
四、效果演示
由于看不到老师手上拿的板子已经上面的指示灯,所以以串口打印的方式演示它的运行过程。
软件使用的工具是:WCHISPTool 工具
4.1写在前面
主机上电的时候给键盘发出了一次 reset ,发出之后进入挂起。
先获取了设备描述符再发一次 reset 进行地址配置。
其次 “SET CONFIG” 就是在软件框架中的 “ 检测到主机开启自身配置,设置标志Cfgflag=1”,在串口助手上做了一个打印。
最后一行在老师修改代码之后变成“Light off Cap Lock LED!”(翻译为:关闭大写锁定指示灯!),再按一下大写锁定键之后变成“Light off Cap Lock LED!”
4.2 按下a与长按a
按下不松手时,它的状态只有从“没按”到“按下”有一个状态,所以它传完之后因为一直没有上传释放按键,计算机会记住当前的状态,然后认为没有释放。
4.3上传组合按键
shift+A:就是大写A,现在用这个做示例。重新写上传组合按键的函数如下:
UINT8 KeyBoardUPKeyValue_COP(UINT8 key) // 上传组合按键(比如 Shift + A)
{
/* 判断键码是否超出 HID 编码范围 */
if (key > HID_KEYBOARD_RIGHT_GUI)
return ERR_KEY_CODE;
else if ((key > HID_KEYBPAD_APP) && (key < HID_KEYBOARD_LEFT_CTRL))
return ERR_KEY_CODE;
/* 设置普通键(非功能键)的值 */
if (key < HID_KEYBPAD_APP)
HIDKey[2] = key; // 将普通按键码放入键值数组的第三字节
/* 设置组合功能键(如 Shift、Ctrl 等) */
if ((key >= HID_KEYBOARD_LEFT_CTRL) && (key <= HID_KEYBOARD_RIGHT_GUI))
HIDKey[0] = (1 << (HID_KEYBOARD_LEFT_SHIFT - HID_KEYBOARD_LEFT_CTRL));
// 设置功能键掩码,比如左Shift 是第1位,就设置为 00000010
/* 等待前一次传输完成 */
while ((UEP1_CTRL & MASK_UEP_T_RES) == UEP_T_RES_ACK);
/* 执行USB传输,将 HID 报告发送到主机 */
EnplIntIn(HIDKey, sizeof(HIDKey));
return 0; // 返回0表示成功
}
总结函数作用:
这个函数实现了向主机发送一个组合键(比如 Shift+A)。
- 如果是普通键(如 A),则写入 HID 报告第 3 字节;
- 如果是功能键(如 Shift),则设置 HID 报告第 1 字节的某一位;
- 最后通过 USB 端点1 发送这个 HID 报告,让主机知道用户按下了组合键。
下图是输入的“A”的示例:
大写的“A”与小写的“a”传输的键值唯一的区别就是在八个字节的第一个字节里面加了一个 shift 功能码,将相应位置一。
五、知识总结与梳理
上述的演示的键盘是进行低速通讯的:
普通的键盘只涉及到中断传输和控制传输,中断传输来上传端点,控制传输用来进行最开始的枚举、点灯的部分。
其中一个很特别的信号是“EOP”,作为一个时间长度单位。
下面是一个事务的构成,它是 USB 传输最基本的单位,上述的软件都是基于这个事务的结构相应中断去完成的。
5.1 SETUP事务
5.1.1什么是“以 SETUP 为令牌起始的事务”?
在 USB 协议中,控制传输是一种用于配置和管理设备的特殊传输方式,它的开始就是通过一个 SETUP 令牌(Token) 来发起的。
▶️ 控制传输专用于:
-
设备初始化
-
获取设备描述符(如设备名称、VID/PID 等)
-
设置设备地址
-
请求 HID 报表
-
Class-specific requests 等
其中请求 HID 报表:
在 USB HID(Human Interface Device)设备中,比如鼠标、键盘、游戏手柄,它们和主机(比如电脑)之间要交换数据。这些数据就被组织成 “报表”(Report)。
-
举个例子:
-
鼠标移动了、点击了,这个动作会变成一个 HID 报表(比如:X 方向移动 +10,左键按下)。
-
这个报表是一个固定格式的结构,里面包含设备的状态信息。
-
-
HID 报表分成几种类型:
-
Input Report:设备 → 主机(比如:鼠标移动数据)
-
Output Report:主机 → 设备(比如:LED 状态)
-
Feature Report:设备和主机之间用于配置的一些特殊数据(不频繁使用)
-
Class-specific requests(类特定请求):
USB 规范里有一些通用的请求(比如 Get Descriptor),但 HID 类设备也定义了一些只适用于 HID 设备的请求,这些就叫 类特定请求。
-
常见的类特定请求包括:
-
GET_REPORT
:主机向设备请求一个 HID 报表。 -
SET_REPORT
:主机向设备发送一个 HID 报表。 -
GET_IDLE
/SET_IDLE
:设置设备多久没动作就上报一次数据。 -
GET_PROTOCOL
/SET_PROTOCOL
:设置设备是走 Boot 协议还是 Report 协议。
-
这些请求都是通过 控制传输(Control Transfer) 来完成的,特别是在设备刚接入时,用来做初始化和配置。
总结一下:
名词 | 是什么 | 用来干啥 | 怎么传 |
---|---|---|---|
HID 报表(Report) | 固定格式的数据结构 | 传输鼠标/键盘的动作等 | 通常是中断传输(数据传输阶段) |
类特定请求(Class-specific Requests) | USB 协议里 HID 设备专属的控制命令 | 用来请求或设置报表、协议等 | 控制传输(Setup 阶段) |
所以:
“以 SETUP 为令牌起始的事务”,就是指 主机对设备发送一个控制请求,由一个 SETUP
token 开始,紧接着携带了具体请求内容的数据包(DATA0),最后设备确认收到了这个请求(ACK)。
5.1.2 SETUP + DATA0 + ACK 的结构详解
这是一组完整的 USB 控制传输的第 1 个阶段:SETUP阶段。
控制传输有三个阶段:
-
SETUP(设置)
-
DATA(可选,看请求类型)
-
STATUS(状态确认)
(1)SETUP Token
-
由主机发出
-
含义:我要向你发送一个控制命令(设置请求)
-
内容:包含目标端点地址、方向(IN/OUT)、类型(标准/类/厂商)、接收对象(设备/接口/端点/其他)
-
仅仅是命令头,不带数据
(2)DATA0 数据包
-
由主机发送
-
数据段,总是 DATA0
-
携带真正的请求内容,长度固定为 8 字节
-
内容是一个结构体,称为
Setup Packet
,格式如下:
Byte | 含义 |
---|---|
0 | bmRequestType (请求类型) |
1 | bRequest (请求编号) |
2-3 | wValue (请求值) |
4-5 | wIndex (索引或接口号) |
6-7 | wLength (后续数据长度) |
举个例子:
bmRequestType = 0x80 // 主机从设备读取,标准请求,目标是设备
bRequest = 0x06 // GET_DESCRIPTOR
wValue = 0x0100 // 类型=设备描述符 (0x01), index=0
wIndex = 0x00
wLength = 0x12 // 想要读取12字节
(3)ACK 应答包
-
由设备返回
-
设备返回 ACK,表示我已经收到了你的 Setup 请求和数据。
(4)总结
步骤 | 主机发送 | 设备响应 | 描述 |
---|---|---|---|
1 | SETUP token | — | 表示主机发起控制传输请求 |
2 | DATA0 数据包 | — | 主机发送 8 字节的请求内容结构体 |
3 | — | ACK | 设备确认收到这个 Setup 请求 |
SET UP:set up 一定是 data 0,中间一定是为 ACK ,不管 set up 是否支持或者接收这条命令,设备都必须将其接受下来,除非设备本身的 USB 控制器出了问题,否则的话很少不回复 ACK 。
5.2 IN事务
5.2.1什么是“以 IN 为令牌起始的事务”?
在 USB 通信中,一切通信都由主机(Host)发起。
而 “令牌(Token)” 是 USB 协议的基本帧结构之一,用来标识这一次数据传输的方向和目的端点。
当主机想要从设备中读取数据时,它就会先发一个:
🔸 IN Token —— 表示“我要从你(设备)这边读数据”。
所以,“以 IN 为令牌起始的事务” 就是指主机想读数据、并以 IN Token 开始的一个完整的 USB 通信事务。这个事务最终由设备来决定是返回数据,还是返回状态。
5.2.2 IN + DATAx + ACK
-
含义:主机发起读取请求,设备成功响应并发送了数据,主机收到后确认。
-
流程详解:
-
主机发
IN
token(我要读数据) -
设备返回
DATA0
或DATA1
(有效数据包) -
主机返回
ACK
(确认收到)
-
-
说明:这是一次完整、成功的数据读取过程。
-
用途:主机成功从设备中读出数据(比如读 HID 键盘的按键信息)。
5.2.3 IN + NAK
-
含义:主机发起读取请求,但设备暂时没有数据可以提供,拒绝发送。
-
流程详解:
-
主机发
IN
token -
设备回复
NAK
(暂时没准备好)
-
-
说明:这不是错误,只表示当前时刻没数据。
-
用途:比如鼠标没动,设备就会回复
NAK
表示没有新数据。
5.2.4 IN + STALL
-
含义:设备报告“永久性错误”或“当前端点已停止服务”。
-
流程详解:
-
主机发
IN
token -
设备回复
STALL
-
-
说明:设备当前不支持此请求或端点已停用,主机需要复位才能继续通信。
-
用途:例如某些不合法的请求,或者固件认为某个条件不满足时,会返回
STALL
。
5.2.5 IN
-
含义:主机发了 IN token,但设备没有任何响应。
-
可能原因:
-
设备未连接或掉线
-
设备 USB 控制器挂掉
-
总线异常
-
-
说明:通信失败,主机会重试几次,如果还没回应就会判定设备异常。
5.2.6 总结
结构 | 含义 | 是否成功读取数据 | 典型场景 |
---|---|---|---|
IN + DATAx + ACK | 成功读取数据 | ✅ 是 | 键盘、鼠标有输入时 |
IN + NAK | 暂时没数据,可稍后再试 | ❌ 否 | 鼠标没动、键盘没按时 |
IN + STALL | 端点停用或非法请求 | ❌ 否 | 非法命令或端点被禁用 |
IN | 没有响应,异常情况 | ❌ 否 | 设备未接、挂死或通信异常 |
5.3OUT事务
5.3.1 什么是“以 OUT 为令牌起始的事务”?
在 USB 通信中,主机是始终主动发起通信的一方。
而 OUT token 表示的是:“主机要向设备发送数据”。
所以,
“以 OUT 为令牌起始的事务” 就是指主机打算把某些数据 写给设备(OUT方向),并通过
OUT token
来开启这个过程。
它一般出现在:
-
控制传输的 DATA OUT 阶段
-
批量传输 OUT(如 USB 串口写数据)
-
中断传输 OUT(如写 HID OUT 报文)
-
等等。
5.3.2常见的 OUT 事务结构
结构 | 含义 |
---|---|
OUT + DATAx + ACK | 主机发数据 → 设备成功接收 → 回应 ACK |
OUT + DATAx + NAK | 主机发数据 → 设备当前忙、缓存满 → 回应 NAK(请稍后再试) |
OUT + DATAx + STALL | 主机发数据 → 设备不支持该请求或异常 → 回应 STALL |
OUT + DATAx | 主机发数据 → 设备无回应(如接收器关闭或错误,少见) |
5.3.3具体说明每一个部分
✅OUT(Token 包)
-
由主机发送
-
内容:指定目标设备、目标端点号、数据方向是 OUT
-
意思是:我要往你设备的某个端点写入数据
✅ DATAx(数据包)
-
由主机发送
-
x 是 0 或 1,表示数据包序号(Data0/Data1)——用于防止丢包或重复包
-
内容:实际要写入给设备的数据(比如一个 HID 控制命令、一个串口数据)
✅ ACK(确认应答)
-
由设备返回
-
设备表示:“我收到数据了,没问题!”
✅ NAK(暂时拒收)
-
由设备返回
-
意思是:“我现在还没准备好接收数据,你等等”
-
主机会在后面再次尝试发送
✅ STALL(错误拒收)
-
由设备返回
-
设备遇到错误或不支持当前命令,比如写了非法端点或不允许的操作
-
主机会停止该事务,等待进一步恢复
✅ OUT + DATAx(无回应)
-
极少见,有时表示设备异常、挂掉或信号丢失
-
在调试中可能会碰到(特别是 USB 固件写错了)
5.3.4示例
比如你通过主机 USB 向某个 USB HID 设备发送 8 字节的数据,主机会按以下流程操作:
1)OUT token → 设备端点1
2)DATA1 包 → 例如发送 [01 02 03 04 05 06 07 08]
3)ACK → 设备端点1 返回,表示接收成功
若设备忙:
1)OUT token
2)DATAx 包
3)NAK → 设备说我忙,主机下次再试
5.3.5总结
“以 OUT 为令牌起始的事务” 就是主机要写数据到设备的行为,
它的流程是 OUT token → 数据包 DATAx → 设备响应(ACK / NAK / STALL)
,
用于写入、设置、控制等所有“发送到设备”的操作场景。
5.4中断传输示意图
5.5中断传输总结
5.6HID类请求
键盘主要用到了上面的倒数第 3 和倒数第 2 条。
本专栏说明:本人是USB的初学者,该专栏是我CSDN上第一个学习USB技术的专栏,笔记会比较口语,不是很精炼,后续有点基础后,争取“字字珠玑”。