虽然游戏操纵杆已经能够正常工作,但是非常好学的你肯定很想知道:为什么开发板上的按键可以控制PC鼠标的移动方向呢?为什么在PC任意一个USB接口依次插入的鼠标、键盘或其他USB设备都能够被正确识别呢?这就是 总线枚举 (Bus Enumeration)的功劳。
什么是枚举呢?以公司招聘面试为例,面试官通常会根据面试者的简历提出一些问题,然后针对回复提出另一个有一定关联的问题,依次循环,继而达到全面考查面试者业务水平的目的,经过综合评估才能判断是否符合公司的录用标准。当面试者通过面试顺利入职公司后,相关部门通常会在管理系统中增加一条代表“面试者就职于该公司”的记录,同时分配一个工号。例如,“1”号通常就是意气风发的老板,“7259”号就是籍籍无名的你。
整个面试与入职 过程就是一次完整的枚举过程。在面试的场景中,你就是USB设备,公司就是USB主机,而面试官则负责建立一套“是否符合公司录用标准”的判断流程。所以简单地说,枚举就是“识别”或“鉴别”的同义词,而员工的工号就相当于主机给设备分配的地址(一个USB主机最多可以扩展127个设备,而每个设备都会有一个唯一的地址),如图10.1所示。
图10.1 面试与枚举
同样的道理,当USB设备连接到PC(主机)时,PC也需要询问一些关于它的信息,以确定到底是个什么东西。PC当然是不能说话的,它只会发送一些命令, USB设备必须对这些命令进行响应 ,否则枚举就会失败(问话你一声不吭,面试失败)。当然, USB设备必须进行正确响应 ,“乱弹琴”也会导致枚举失败(回答问题牛头不对马嘴,面试失败)。当然, 即便枚举成功了,也不一定代表USB设备肯定能够正常工作 (有些人面试时能说会道,但真正做起事来却完全不行,失败中的失败),就像每个人(USB设备)进出城门需要特定令牌一样,令牌本身代表着一定的特权,拿着它就能够自由进出城门(枚举成功了),但是令牌本身可能是抢来或骗来的,这个人本身并不具备享有该特权的合法资格,也就无法真正实施与该特权相当的事情(USB设备不能正常工作),只不过用来欺骗守城门人(枚举流程)而已。
概括来讲,如果我们要让自己的USB设备成功与主机传输数据,关键的两个步骤是必须进行的。其一, 让USB设备遵循USB规范正确回应主机的命令,以成功完成枚举过程 。其二, 在枚举成功后,将数据按正确的格式进行传输 。
有些人可能会想:怎么还要响应命令呢?粗看了一下貌似很复杂,玩不了,回家洗洗睡了!这里必须得打击你一下,USB底层的实现确实有点复杂,因为USB 的简易性 是以 协议的复杂性 为代价的。但幸运的是,通常厂商都会将底层的核心实现打包成了固件库,我们只要修改 应用方面 的一些数据即可。也就是说,如果只是使用USB传输数据,你没机会(也不需要)去修改底层那些复杂的源代码。
那到底需要修改什么地方才能成功完成总线枚举呢?其实道理跟工程师做项目一样!例如,当我们使用单片机编程来控制新的元器件时,首先需要了解元器件的基本原理,包括硬件电路的连接要求、通信时序、寄存器定义等。厂家为了方便用户使用,通常都会准备好相应的数据手册,它包含了用户应用该元器件的所有信息。
同样的道理,如果将你当成一台PC,当USB鼠标与某个USB接口相连时,你又是怎么知道它是鼠标,而不是键盘或其他设备呢?很明显,你(PC)也需要数据手册之类能够描述插入设备所有信息的媒介,对不对?USB规范中定义的描述符(Descriptor)就是这个目的。
描述符本身就是一些常量数据。例如,现在要定义一个员工的描述符,它应该包含姓名、性别、年龄、工号等信息,我们可以称这些信息为字段(Field),相应可供使用的结构体类似如清单10.1所示。
清单10.1 员工描述符结构体
为了方便数据的传输,我们也可以统一使用无符号8位整型(uint8_t)数组来描述员工信息,只要约定好字符串与字段之间的对应关系即可,类似如清单10.2所示。
清单10.2 员工描述符数组
数组name中保存了具体姓名对应的英文字符串,为了能够表达世界上所有语言的字符,我们采取了目前通用的Unicode,它采用双字节16位进行编号,可最多编码65536个字符。英文字母的ASCII补0(一个字节)就是相应的Unicode(低位在前)。例如,字符“A”的ASCII为0x41,则相应的Unicode编码为0x0041。而在数组longhu中,“姓名”字段则只保存name数组中对应的索引即可。我们再约定“0”代表女性,“1”代表男性,所以“性别”字段对应的数值就是1。年龄为35自然不用多说。需要特别注意的是,我们再次约定使用两个字节的BCD码来表示工号,并且低2位在前面,高2位在后面,所以相应的工号为“7259”。我们也可以进一步增加更多信息,这样数组longhu就可以完整地表达员工的信息(描述符)了。
USB规范定义的描述符也是类似的,常用的标准描述符就包括设备描述符(Device Descriptor)、配置描述符(Configuration Descriptor)、接口描述符(Interface Descriptor)、端点描述符(Endpoint Descriptor)、字符串描述符(String Descriptor),它们定义的字段比之前的员工描述符更复杂一些,但基本的原理仍然相同。也就是说,我们总是会使用类似 常量整数数组 的方式保存关于USB设备的一些信息,主机会通过特定的命令来查询,USB设备只需要在必要的时候响应主机的命令即可。当然,我们没有必要对所有细节都亲力亲为,厂商已经将底层细节实现打包成了固件库,甚至发布好了经过测试的例程, 我们只需要找到描述符定义所在的源文件,再根据USB规范修改一些常量数据即可 。如果这些常量数据修改正确,厂商提供的固件库会自动根据主机发送的命令提交描述符信息,这样主机就能够正确识别你的USB设备,也就完成了整个总线枚举过程。
与使用单片机控制元器件一样,我们还需要清楚元器件所在的状态。例如,不能在元器件处于复位期间发送命令,也不能在休眠状态下发送它无法响应的命令。在枚举与正常工作过程中,USB设备也会处于不同的状态,主机也必须清楚设备当前所处的状态。USB规范定义了6种USB设备的状态,如表10.1所示(符号“—”表示不关心)。
表10.1 USB设备的状态
USB设备可以与主机连接(Attached)或断开(Detached), 连接状态 指的是物理层面的状态,此时还没有直接与USB接口的信号线连通,而 供电状态 (Powered)则可以理解为仅电源连接(信号线未连接)。我们前面已经提过,为了实现USB设备热插拔, USB物理接口中的电源线比数据线要长一些 ,所以才会存在 供电状态 。当然,你也可以认为 供电状态 是USB设备的数据线已经与主机连接,但是还没有对总线有任何响应之前的状态。
默认状态 (Default)是USB设备的数据线与主机连接后,主机对设备进行复位后的状态。所有USB设备在供电与复位以后都使用默认地址(也就是0),此时设备处于 可编址 状态(Addressable),图10.2展示了未连接、连接、供电、默认4个状态(仅用于状态理解示意)。
图10.2 设备状态
设备处于 可编址状态 ( 默认状态 )后,USB主机给设备分配一个唯一的地址,设备进入了 编址状态 (Address),而在USB设备正常工作前还必须被正确配置,这样成功配置后的USB设备才会最终处于 已配置状态 (Configured)。 挂起状态 (Suspended)主要是为节省电源,所有设备在一段特定时间(典型值为3ms)内没有总线活动时会自动进入该状态,无论设备是否已经分配地址或者完成配置,此时USB设备保持本身的内部状态(包括地址及配置)。另外,如果USB设备所连接的集线器端口失效(例如,使用命令禁用某个端口),USB设备也会进入挂起状态,这在USB规范中属于选择性挂起(Selective Suspend)。相反,当总线有活动时,设备会被唤醒(Wakeup)而退出 挂起状态 ,也称从 挂起状态 恢复(Resume)。
USB规范给出了设备状态的转换关系,如图10.3所示,填充圆圈中的状态对主机是可见的。
图10.3 设备状态的转换关系
对于我们设计的基于STM32单片机的USB设备,所有状态被定义在usb_pwr.h头文件中,如清单10.3所示。其中,DEVICE_STATE枚举类型中定义了6种状态,当USB设备未与主机相连时,默认处于未连接状态(UNCONNECTED)。只有当USB设备处于 已配置状态 (CONFIGURED)时,USB设备才是可用的,所以在清单9.1中,每次读取按键状态前必须先判断设备的状态(bDeviceState)是否处于 已配置状态 ,因为在USB设备尚未完成配置前,即使有按键按下的情况发生,也无法正常给主机发送数据。另外,需要特别注意的是,DEVICE_STATE枚举类型中定义的ATTACHED对应表10.1中的默认状态,后续在阅读代码时就会知道。
清单10.3 usb_pwr.h头文件
在对USB设备的状态进行初步了解之后,我们来看看详细的USB总线枚举的全部过程,如图10.4所示。
图10.4 USB总线枚举的全部过程
主机端的USB集线器监视着每个下行端口的信号线电压,当USB设备连接主机时会被检测到,此时USB设备处于供电状态,然后主机会对USB设备进行复位操作,并通过发送命令来验证是否完成设备复位(成功复位后的USB设备默认地址为0,即处于 默认状态 )。如果设备支持全速模式,则其在复位期间还会检测设备是否支持 高速 模式(涉及比较复杂的协议,现在知道有这回事就可以了,后续还会详细讨论)。
紧接着,主机第一次发送获取 设备描述符 命令, 设备描述符 提供关于USB设备的多种信息(共18个字节,后述),但第一次只会读取该描述符的前8个字节,用来获得设备端点0支持的最大数据包字节长度(第8个字节包括该信息)。
到目前为止,主机通过默认的地址0与设备进行通信(主机一次只能枚举一个USB设备,所以同一时刻只有一个USB设备使用默认地址),主机此时发送命令给为设备分配唯一的(非0)地址。设备收到该命令后保存分配的新地址,并且返回确认信息,此后主机与设备之间的通信都将使用新地址,USB设备也就进入了 编址态 。
主机随后再次(给已分配新地址的设备)发出获取 设备描述符 的命令获取完整的设备信息(18个字节)。由于USB设备还定义了一个或多个 配置描述符 (包括所属的 接口 、 端点及其他描述符 ),主机紧接着再次或多次发出获取 配置描述符 命令,如果 设备描述符 中指定了描述厂商、产品和设备序列号等信息的 字符串描述符 索引(相当于清单10.2中的longhu数组中的“姓名”字段值),则主机同样会再次发出获取 字符串描述符 命令。
主机已经从所获取的多个描述符中充分知悉了关于USB设备的所有信息,然后开始为设备选择并加载合适的驱动程序。如果一切顺利,设备驱动程序就会接管原属于主机的控制权,并发送命令为设备选择合适的配置(进入 已配置状态 ),USB枚举过程至止结束,设备可以正常使用了。
如果觉得USB总线枚举的过程有点复杂,那可以这么说:以上所述还只是简化描述。实际枚举过程还涉及底层握手信号的状态细节,现阶段的你只需要知道总枚举的总体流程就行了,后续我们还会深入探讨,现在亟须解决的问题是: 配置、接口、端点到底是什么呢?它们对应的描述符又有什么关系呢? 请参见后文。