USB设备开发学习(一)
最近有个相关需求,所以准备开始研究一下,怎么开发一个USB设备。本篇内容为我个人的一个学习总结,都是一个非常基础的内容。
如何上手
首先Google搜了一下有没有相关学习资料,搜到一本书——《圈圈教你玩USB》,搞到书了以后发现,需要买硬件设备,不过我认为这属于后期的内容了。
前期还是考虑拿我手头上有的设备来进行学习研究,第一个考虑的是手机,但是研究了一下,发现安卓对这块的支持不是很好,再加上安卓又包装了一层,这会让我不能很好的理解USB底层的细节。
因为我手头上正好有树莓派4b,所以又考虑使用树莓派4b来进行USB设备开发,进过搜索发现有一个项目能作为我的着手点:https://github.com/mtlynch/key-mime-pi
随后通过该项目来开始进行USB设备开发的学习研究。
工具
经过一番研究,最后还是选择Windows作为USB主机,因为Windows上的USB工具可视化做的更好。
USB Tree View
第一个工具是USB Tree View,能很好的展示主机上USB设备树的主从情况,USB描述信息等数据。
可以从上面的USB设备树中看出,一台主板最底层的是主机控制器直连CPU,然后上层是USB根集线器,从中文名看不出来是啥,如果翻译成英文其实就是HUB,再类比一下就是USB扩展坞。比如在第一个主机控制器上,有一个通用USB集线器,其实就是我台式机上机箱的USB口,在机箱上有个USB HUB的芯片连接到主板的USB3.0接口。
并且点击已经连接上的设备,还可以查看该USB设备更详细的信息:
具体的信息代表什么意思,之后再说,使用这个工具主要是能很方便的查看USB设备的详细信息。不过也有BUG,比如下图,改工具无法解析HID信息:
Wireshark
wireshark装上USBPcap,能够抓取主机控制器上的USB流量,比如我电脑有三个主机控制器,所以就显示有三个USBPcap:
如果不知道自己插的USB口属于哪个主机控制器,就可以使用USBTree View查看。不过由于Wireshark是抓取主机控制器上的流量,所以当我要研究单个USB设备的时候,需要通过表达式把该主机控制器上的其他设备给过滤掉。
Bus Hound
这个设备可以抓取指定USB设备的流量,不过缺点就是要花钱,要不然抓包size和数量都有限制。暂时没找到该软件的破解版,而且暂时也没有必须要使用这个软件的原因。下面是该软件的界面:
使用树莓派4b作为PC的USB键盘
接下来就是通过key-mime-pi
项目来学习USB设备的开发,简单看了下该项目,发现使用树莓派4b作为一个USB设备非常容易。
- 需要开启dwc2驱动:在树莓派的config.txt中添加
dtoverlay=dwc2
,设备启动后,确认一下:
1 | $ lsmod|grep dwc2 |
- 运行下面这个脚本:
1 | !/usr/bin/env bash |
经过研究发现利用的是USB gadget驱动,USB的gadget驱动可以通过在/sys/kernel/config/usb_gadget/
目录下创建目录/删除目录来控制USB设备的绑定与解绑。
后面有需要可以去看看这部分的代码,位于Linux内核的:linux/drivers/usb/dwc2
,linux/drivers/usb/gadget
通过流量在学习理解USB协议
首先讲讲一个前置的知识点:USB一定会分为主机和设备的,并且通信永远都是由主机先发起的。
在我的环境下,首先打开Wireshark,开始捕获USBPcap1的流量,然后把树莓派的type-c口(电源供电口)连接到主机的USB口上,这个时候主机并不会识别树莓派为USB设备,仅供电,所以这个时候树莓派开始启动。
然后开始把wireshark能抓到的流量地址都给过滤掉,这些都不属于树莓派的USB流量。
然后在树莓派上以root权限运行上面的脚本,然后再查看wireshark抓到的流量:
使用USBPcap抓到的usb流量地址一般为:x.y.z,x为USB总线(Bus)号,一般为主机控制器的编号,我抓到的USBPcap1上的流量,x的值都为1。y为设备号,指向该Bus上的某个设备,但是有个问题USBPcap
和USBTree View
对于设备编号的方法不一样,USBPcap
是按照顺序来的,USBTree View
一开始已经对所有USB接口编号号了,不管你插没插上USB设备。
不过按我理解,USBPcap
可能更符合底层实际情况,因为它不是自己编的号,而是解析了抓到的流量。
z的值表示的是端点号(Endpoint),我觉得有点像一个程序的文件描述符(fd),USB主机和设备间就是通过端点号来进行通信的,当USB设备还未在主机上注册时,默认使用0
端点号来进行通信(有点像我还没获取到ip的时候,DHCP请求包是从广播地址发出去的?)。
设备描述符
设备描述符的结构体定义为于:linux/include/uapi/linux/usb/ch9.h
,结构体如下:
1 | /* USB_DT_DEVICE: Device descriptor */ |
USB协议有好几个结构体,结构体每个字段的含义可以参考:https://zhuanlan.zhihu.com/p/558716468
然后查看USBPcap上的结果:
再对比一下USBTree View
上的详情:
然后能发现,控制这个描述符的代码是下面这几行:
1 | echo 0x1d6b > idVendor # Linux Foundation |
主要就是定义了设备的协议(USB2.0),然后是供应商和产品的ID,bNumConfigurations
配置描述符数量,剩下的都是一些标识信息。
设备协议应该影响到后续的协议结构体,还有传输的最高速度。供应商信息大部分情况下就是作为标识信息作用的,但是我后面看到有些驱动可能会识别指定的供应商和产品ID。目前只是想模拟一下鼠标/键盘这种东西,这些ID是可以随便改的,大不了就是电脑显示是未知设备,但是不影响使用。
然后就是定义了配置描述符的数量,虽然主机将会向设备请求指定数量的配置描述符。
配置描述符
设备描述符的结构体定义为于:linux/include/uapi/linux/usb/ch9.h
,结构体如下:
1 | struct usb_config_descriptor { |
然后查看USBPcap上的结果:
一开始都是请求固定长度为9的配置描述符。
然后设备描述符的wTotalLength
字段会告诉主机,配置描述符的实际长度,接着主机就会读取完整的配置描述符:
从USBPcap的流量中可以发现,在配置描述符的响应包里,除了配置描述符的信息,还包含了接口描述符,端点描述符,应该注册的是一个USB HID设备,所以还包含着HID描述符。
在这里面一个是可以控制电源的相关数据,设备是可以自供电还是需要USB设备供电,设备是否可以通过USB远程唤醒,然后就是设备能接受的最高电流。(不确定现在的主板能控制电流控制的这么精细?不过也只是最大电流,也许吧?)
然后主要就是获取设备的接口描述符的数量,去请求获取设备的接口描述符,还有一个是bConfigurationValue
,该值用在Set Configuration Request
里,具体的作用还不知晓。(告诉设备主机接受并注册了该配置?)
控制该部分的代码如下:
1 | CONFIG_INDEX=1 |
除了电源属性的配置,好像也没有其他需要我们修改控制的字段了。(电源属性一般也不需要改。)
接口描述符
接口描述符的结构体定义为于:linux/include/uapi/linux/usb/ch9.h
,结构体如下:
1 | /* USB_DT_INTERFACE: Interface descriptor */ |
然后查看USBPcap上的结果:
再查看USBTree View
上的信息:
在接口描述符中,主要定义的是设备的类型和端点的数量,在USB协议中,端点的通信是单向的(更向Linux的文件描述符了),所以在这里定义了两个端点描述符,一个表示的是输入,一个表示的是输出。
另一个就是标识接口的类型,在这里标识的是一个HID设备,所以主机会再去读取HID描述符。并且定义了接口协议为键盘,这样就会让键盘的HID驱动来处理该USB的通信。
接口类型可以看Linux的代码中定义的有以下几类(同样是在ch9.h中定义的):
1 | /* |
Linux内置的HID驱动协议,我看源码里好像只定义了鼠标和键盘,其他设备要额外下载驱动?待后续研究。
端点描述符
端点描述符的结构体定义为于:linux/include/uapi/linux/usb/ch9.h
,结构体如下:
1 | /* USB_DT_ENDPOINT: Endpoint descriptor */ |
查看USBPcap
信息:
主要是定义了端口号,和该端口的方向,还有使用该端口进行通信的方式(中断传输),通信数据包的最大尺寸(8)字节。
我查看了下,Linux Gadget目录下的文件,好像没有控制端点信息的文件:
1 | $ tree |
就一个no_out_endpoint
文件,如果置为1,端点描述符可能就只有一个了。另外猜测report_length文件应该是控制数据包的最大长度。
字符串描述符
还有一个字符串描述符,也是位于ch9.h
:
1 | struct usb_string_descriptor { |
主要作用还是标识信息,在wireshark我们没抓到字符串描述符,我认为应该是默认情况下,驱动不会请求字符串描述符,应为没啥用,这些信息是给人看的,机器不需要。只要当我们打开设备管理器(或者USBTree View
),要查看这个设备的信息的时候,才会获取该描述符。
可以在USBTree View
中查看:
比如在接口描述符中,iInterface
字段的值就是字符串描述符的偏移。
HID报告描述符
当设备通过接口描述符得知该设备是HID设备时,将会再获取HID报告描述符:
HID报告描述符在代码中的位置:
1 | Write the report descriptor |
该值的含义在注视里写了,从https://www.kernel.org/doc/html/latest/usb/gadget_hid.html网站获取的:
所以在这里我们还得在学学,理解一下HID的报告描述符,完整的文档可以参考:https://usb.org/sites/default/files/hut1_4.pdf,官方文档,优点是齐全,缺点是太多了,对于我们新手来说,不太适合,我认为比较适合上手了以后作为参考手册。
解析HID配置描述符
首先,是作为新手的我来参试解析一下HID的配置描述符。
我们先看看key-mime-pi
项目的通信代码,树莓派告诉主机按了什么键:
1 | def send(hid_path, control_keys, hid_keycode): |
需要发送两个8字节的buf,这个可以理解,因为在端点描述符里限制了最大的包大小为8字节。第二个buf的8字节全都置为0,第一个buf的第一个字节为控制字符,经过研究设置了8个控制字符:
1 |
GUI
应该就是windows的win键,mac的command。
第一个buf的第二个字节不设置,默认为0,第三字节到第8字节,有6个字节,为输入的按键。
大致了解了一下,我们是如何输入的,然后再来看看HID的报告描述符:
1 | static struct hidg_func_descriptor my_hid_data = { |
首先是USAGE_PAGE
和USAGE
,这两个字段可以看参考文档,都是目前我们只能选取定义好的那些应用。暂时还不知道是否影响驱动的识别。
主要看集合部分的内容,集合以COLLECTION
开始END_COLLECTION
结束。
第一部分:
1 | 0x05, 0x07, /* USAGE_PAGE (Keyboard) */ |
按我的理解,这部分的含义如下:
指定键盘功能,最小值为Keyboard LeftControl
(0xe0),最大值为Keyboard Right GUI
(0xe7)。逻辑最小值为0,最大值为1,1为按下,0为没有按下。一个按键占1bit,有8个按键,一共占1字节。比如:0b00000001就表示LeftControl
键被按下了。从这看,我们可以一次性把8个控制键都按下。
第二部分:
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x08, /* REPORT_SIZE (8) */
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
有8个1bit的值,总共1字节,并且是常量(Cnst全程是constant),上下文没看到有设置值,所以认为是默认值0。(暂时也不确定设置为其他值有没有影响。)
第三部分:
0x95, 0x05, /* REPORT_COUNT (5) */
0x75, 0x01, /* REPORT_SIZE (1) */
0x05, 0x08, /* USAGE_PAGE (LEDs) */
0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */
0x29, 0x05, /* USAGE_MAXIMUM (Kana) */
0x91, 0x02, /* OUTPUT (Data,Var,Abs) */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x03, /* REPORT_SIZE (3) */
0x91, 0x03, /* OUTPUT (Cnst,Var,Abs) */
定义了一个LED功能,总共占1字节,高3bit为常量0,低5bit估计表示的是小键盘灯,大小写灯这些键盘上的灯,并且是由主机发送给设备的,主机可以主动控制键盘灯的亮灭。
第四部分:
1 | 0x95, 0x06, /* REPORT_COUNT (6) */ |
定义了键盘功能,按键的值从0-0x65,一共有102个键,逻辑值也是从0-0x65,一个按键占1字节,有6个按键,总共占6字节。
到这里键盘的HID报告描述符的定义就分析完了,我们发现该定义的含义和我们的输入能吻合。
发送的buf第一字节就是表示8个控制按键,第二字节固定为0,后面6个字节为输入按键。
(到这我就在想,键盘只能同时按6个键?不过在一个流量包里的6个字节仍然有顺序,又想想好像无所谓,好像并不影响同时按几个按键,毕竟有顺序嘛。只影响速度?)
剩下还有两个个问题:第一,我的键盘是104键的键盘,为啥有102+8=110个值?经过研究发现:
1 | #define KEY_NONE 0x00 // No key pressed |
这么算一下,刚好104键的键盘。
第二个问题:为啥还要发送一个全为0的数据包,经过研究我认为是在系统的内存上,映射了8字节的内存给键盘,系统一直重复获取键盘这8字节的内容,键盘只是控制这8字节的内存数据的值。所以当我们输入值的时候,修改了内存之后,还需要把内存值修改回0(表示松开按键),要不然系统下一次扫描该内存段,会认为我们还在按压该按键。
简单点说,就是第一次发送8字节的buf,表示告诉系统,我们按下了哪些按键,第二次发送8字节的buf,表示我们松开了按键。
下一步研究方向
接下来就考虑以下几个方向的研究:
- 微调HID报告描述符,看看对实际使用有什么影响。
- 通过修改接口描述符字段和HID报告描述符字段,来模拟一个鼠标。
- 研究一下手柄,讲道理手柄也是使用HID协议,但是Linux的代码里没看到相关定义。
- 研究非HID协议,比如U盘,网卡,打印机这些。
- 研究驱动系统细节,在Linux内核的drivers目录下,可以搜索
module_usb_driver
字符串,这是一个宏定义函数,usb主机端的驱动都是通过该函数注册到内核当中的。
USB设备开发学习(一)