2024年4月16日发(作者:仆翰采)
第1章 U盘的逻辑结构
1.1 U盘的逻辑结构
U盘可以看成是以扇区(1扇区=512Bytes)为单位线性排列的实体,即0号扇区,1
号扇区,2号扇区,……这样按顺序地排列下去。U盘是flash,对flash的操作总是以块为
单位的,因此单片机对U盘的操作是以扇区为单位,整个扇区地读取,或整个扇区地写入。
第2章 USB通信协议
2.1 USB设备开工的机理
USB是即插即用的,涵盖海量存储器(如U盘、移动硬盘)、人机交互设备(如鼠标键
盘游戏杆)、扫描仪、打印机等等各种各样功能的设备,那么USB主机是如何判断目前接入
的设备到底是怎么样的呢?答案是USB描述符,以及USB的枚举。
2.2 USB描述符
这个概念很简单,就是对各种纷繁芜杂的USB外设按功能划分大类(class),大类下又
再细分小类(subclass),每个类别给予一串特定的符号(Descriptor)供主机辨识。
每个USB设备只能有一个DEVICE描述符,它指明了该设备属于哪一大类,是海量存
储器类,还是人机交互设备类,还是打印机或者扫描仪类,等等。
每个DEVICE下可以有1个或多个配置描述符(configuration),以说明该设备含有哪
些功能。如一个USB接口的CDROM可以同时具有读写光盘的功能和播放CD的功能。有
几个功能,就有几个配置描述符。
每种配置对应若干个接口描述符(Interface),以描述该配置使用哪些接口与主机进行通
信。
每个Interface又都是端点(End Point)的集合,端点就是设备与USB主机交换数据的
最原子单位了。每个Interface用到的端点可以是一个或多个。下图摘自USB MASS
STORAGE CBI Transport Specification 第6页,清楚说明各种描述符的组织情况。
2.3 USB设备的枚举过程(开工过程)
有了完善的分类后,USB设备上电即可通过枚举过程告诉USB主机自己的详细信息,
这很类似一个一问一答的过程,如下:
主机(下称H):你是甚么设备?
设备(下称D):我是12 01 0100…… (这就是DEVICE描述符了)
H:你有几种功能?
D:我有 09 02 09 …… (配置描述符)
H:每种功能有几个接口?
D:09 04 00…… (接口描述符)
H:每个接口用到哪些端点?
D:07 05 81 …… (端点描述符)
H:好了,我知道你是谁了,开始传数据吧!
D:OK. READY GO!
具体而言,USB枚举过程有以下步骤:
(1) 集线器检测新设备
主机集线器监视着每个端口的信号电压,当有新设备接入时便可觉察。(集线器端口的两根
信号线的每一根都有15kΩ的下拉电阻,而每一个设备在D+都有一个1.5kΩ的上拉电阻。
当用USB线将PC和设备接通后,设备的上拉电阻使信号线的电位升高,因此被主机集线
器检测到。)
(2) 主机知道了新设备连接后
每个集线器用中断传输来报告在集线器上的事件。当主机知道了这个事件,它给集线器发送
一个Get_Status请求来了解更多的消息。返回的消息告诉主机一个设备是什么时候连接的。
(3) 集线器重新设置这个新设备
当主机知道有一个新的设备时,主机给集线器发送一个Set_Feature请求,请求集线器来重
新设置端口。集线器使得设备的USB数据线处于重启(RESET)状态至少10ms。
(4) 集线器在设备和主机之间建立一个信号通路
主机发送一个Get_Status请求来验证设备是否激起重启状态。返回的数据有一位表示设备仍
然处于重启状态。当集线器释放了重启状态,设备就处于默认状态了,即设备已经准备好通
过Endpoint 0 的默认流程响应控制传输。即设备现在使用默认地址0x0与主机通信。
(5) 集线器检测设备速度
集线器通过测定那根信号线(D+或D-)在空闲时有更高的电压来检测设备是低速设备还是
全速设备。(全速和高速设备D+有上拉电阻,低速设备D-有上拉电阻)。
以下,需要USB的firmware进行干预
(6) 获取最大数据包长度
PC向address 0发送USB协议规定的Get_Device_Descriptor命令,以取得缺省控制管道所
支持的最大数据包长度,并在有限的时间内等待USB设备的响应,该长度包含在设备描述
符的bMaxPacketSize0字段中,其地址偏移量为7,所以这时主机只需读取该描述符的前8
个字节。注意,主机一次只能列举一个USB设备,所以同一时刻只能有一个USB设备使用
缺省地址0。
(7) 主机分配一个新的地址给设备
主机通过发送一个Set_Address请求来分配一个唯一的地址给设备。设备读取这个请求,返
回一个确认,并保存新的地址。从此开始所有通信都使用这个新地址。
(8) 主机向新地址重新发送Get_Device_Descriptor命令,此次读取其设备描述符的全部
字段,以了解该设备的总体信息,如VID,PID。
(9) 主机向设备循环发送Get_Device_Configuration命令,要求USB设备回答,以读取
全部配置信息。
(10) 主机发送Get_Device_String命令,获得字符集描述(unicode),比如产商、产品描
述、型号等等。
(11) 如果主机是PC电脑,此时主机将会弹出窗口,展示发现新设备的信息,产商、产
品描述、型号等。
(12) 根据Device_Descriptor和Device_Configuration应答,PC判断是否能够提供USB
的Driver,一般win2k能提供几大类的设备,如游戏操作杆、存储、打印机、扫描仪等,操
作就在后台运行。
(13) 加载了USB设备驱动以后,主机发送Set_Configuration(x)命令请求为该设备选
择一个合适的配置(x代表非0的配置值)。如果配置成功,USB设备进入“配置”状态,并
可以和客户软件进行数据传输。此时,常规的USB完成了其必须进行的配置和连接工作。
查看注册表,能够发现相应的项目已经添加完毕,至此设备应当可以开始使用。
以上是PC电脑为主机的枚举过程,对于单片机作为主机的情形,过程要简单一些,以
枚举U盘为例:
(1) 芯片SL811监视USB总线电平,当发现有U盘插入后,给单片机一个中断信号。
(2) 单片机给SL811发出端口复位命令,持续100毫秒以上。
(3) 单片机发出Get_Device_descriptor命令,从默认的端口0和地址0发出。该命令先
假设了包传送的大小是64字节,在获得命令返回时修正MaxPacketSize。此步同PC。
(4) 单片机发送Set_Address请求来分配一个唯一的地址给U盘,我们实际应用中固定
分配了地址2。此步同PC。
(5) 单片机向新地址2重新发送Get_Device_Descriptor命令,此次读取U盘设备描述符
的全部字段,以了解该设备的总体信息,如VID,PID。此步同PC。
(6) 单片机发送Get_Configuration_Descriptor命令获取配置描述符。
(7) 根据获取的配置信息,单片机发送SetConfig和SetInterface命令对U盘进行配置。
(8) 对获取的Interface描述符和Endpoint描述符进行分析,判断是否大容量存储设备、
是否支持SCSI命令集、是否BULK_ONLY传输、端口的最大包长等内容。
(9) 发送Get_Max_LUN命令获取U盘的进一步信息(根据协议看此步非必须,有些U
盘此步会返回STALL,即不支持,也没有关系)。但是建议在枚举过程中不省略此步,因为
不同品牌U盘其固件可能不一样,有些固件可能不允许省略此步。
(10) 完成上述步骤后,U盘的枚举过程完成,接着需要发送几条SCSI命令来对U盘进
行初始化,这几条命令依次是Inquiry、ReadFormatCapacity、ReadCapacity。完成后,U盘
已经准备好接收单片机发出的任何读写命令(读写命令也是来自SCSI命令集)。
如果你有兴趣知道USB协议一些更细节的内容,请往下看。否则可以直接跳到第四章
的文件系统部分。
2.4 USB1.1协议
本节内容主要涵盖USB 1.1 Specification的第4、5、8、9章。并且主要描述代码中无法
注释或者在代码中注释会太麻烦的内容。
USB是一种主从的结构。所有传输由Host来发起。当主机发起一次传输时,这次传输
的包(Packets)通常包括三个阶段。主机首先是发送一个Token Packet,内里包含本次传输
的命令类型(type)、方向(direction)、设备的地址(device address)以及端点号(Endpoint)。
紧接着是数据包(data packet),就是包含数据了。最后将由device返回握手信号包(handshake
packet),表示是正确收到了(ACK)还是其他的失败原因。三个包如下图所示。
USB的传输模型:Host和设备的某个端点之间可以看成有一条逻辑管道(pipe)。Pipe
分两种:业务数据流和信令消息。业务流即指纯粹的数据,信令流指控制信息。其实通信协
议很多都如此,分业务流和信令流,例如电信网中的7号信令。
在信令管道中,有一条默认的管道,那就是零地址处的零号端点,这条管道在USB 设
备上电复位或总线复位后就存在了,便于Host统一利用这个地址向USB设备进行配置。显
然对于USB集线器,即使同时插入几个设备,Host也只能一次对一个设备进行配置。USB
设备只有配置(configured)后,才可使用。
USB的传输类型有四种:
控制传输(control transfer),通常只用于在设备复位后Host通过端点0进行配置。
块传输(Bulk Transfer),譬如U盘的大量数据传输即用此方式。
中断传输(Interrupt Transfer),一般用于人机设备如USB鼠标键盘等。
等时传输(Isochronous Transfer),可以进行带宽控制的实时传输形式。
2.4.1 重新认识枚举过程
枚举过程事实上是USB 设备复位后,恢复到0地址0号端点,然后主机通过一系列控
制传输命令对USB设备进行配置,同时也获取一些信息。
使用BUS hound这个工具可以把完整的USB设备枚举过程抓下来。网上很容易找到安
装包。BUS软件的设置如下:可以确保抓下所有的数据包信息。
利用BUS hound的软件抓一下爱国者行业特供型1G的U盘,其在PC下的枚举过程完
全在下图中表现了出来。让我们逐一分析。
由设备16.0抓到的数据包属于USB集线器的行为,在无使用集线器的单片机系统中可
以无视之。设备21.0的含义是:usb设备地址是21,目前管道是跟它的端点0打交道。
数字1处是枚举过程的开始,主机用控制传输发送Get Descriptor获取设备描述符(具
体为何是设备描述符可以对照USB1.1技术规范的第九章来分析左边的那串08 06 00 02……
的二进制数据,下同),这条命令假设了未知设备的端点0的最大包长值64字节,然后在命
令中要求设备返回0x12(十进制18)个字节的device描述符,如图中圈起来的12。值得一
提的是,这条命令无论假设设备的端点0的最大包长(Payload)是8,16,32,64,都是可
以获得想要的数据的(图中的40)。40H指明该设备的端点0的最大包长是64字节,Host
此后的控制传输可以使用64字节的数据包跟设备通信了。64字节数据包的细节后面会介绍。
数字2处表示主机发送Get Descriptor获取配置描述符。但是类似设备描述符的处理方
法,主机也先试探性的获取配置描述符的前9个字节(图中带圈的09),以获悉整个配置描
述符有多长,因为长度信息就位于描述符的第3个字节,如图中带圈的20。根据此20h的
长度信息,数字3处开始正式请求设备完整的配置描述符了,可以在图中看到两个20是对
应的。
整个配置描述符包含32个字节(一般的U盘都是这样)。这32个字节中,包含3部分
内容,包含设备的重要信息。数字5所代表的第一个框表示第一部分:配置描述符。数字6
代表第二部分:接口描述符(Interface)。其中第5字节02表示该设备有2个端点(Bulk_IN
和Bulk_OUT),第6字节08代表这是大容量存储设备(Mass Storage Device)。第7字节06
表示支持SCSI命令,不过我调试过MP3播放器这个地方是05,但也支持SCSI命令。第8
字节50表示数据只支持使用Bulk传输(Bulk Only)(更详细的内容可参考USB 。Mass Storage
Class Bulk-Only Transport Revision 1.0)。
数字7和8代表第三部分:端点描述符。第三字节都是代表端点地址,一般情况是地址
1和地址2。留意图中8框第三字节是82H,这表示该端点地址就是地址2,最高位被置1
以表示这个端点是Bulk_IN端点,所以整个数值变成了82H。但是并非地址2就一定是
Bulk_IN,不同的U盘不一样,所以在程序中要根据描述符的实际值,用变量记录下来的,
后面要用到。继续看8框,第五和第六字节组成一个16位的数值表示该端点的最大包长度
(payload)。对于只支持1.1协议的设备,第六字节是其高8位,都是0,第五字节才是真
正的payload数值,1.1协议规定只能是8,16,32,64之一,由厂家固定。至于我们在上
图看到第六字节是02第五字节是00,组合成200H=512,那是因为U盘控制器认出了这个
Host(PC电脑)支持usb2.0,所以就回应了512,而不是64。Payload值非常重要,后面要
依据此值进行判断和计算。
下面接着的4个get descriptor都是获取设备的string描述符。实际的单片机系统也许不
需要获取这些描述符,而且有些U盘也不支持获取这个描述符(返回STALL)。再往下的就
是set_configuration、set_interface、get_MAX_LUN等。有些U盘,在set_interface处会stall;
有些U盘,如果Host不发送set_interface命令,往后的命令都不响应,所以这个牵涉到兼
容性问题,后面再解释。在上图中可以看到,这个爱国者U盘在遇到get_MAX_LUN时返
回了stall,PC的处理方法是clear feature,然后重试,三次后仍然stall则跳过。这个牵涉到
如何进行差错处理,后面再详细分析。
2.4.2 基于SL811的USB底层传输函数实现要点
前面枚举过程介绍的各种命令,如截图中的数字1处的“80 06 00 01 00 00 12 00”到底
是如何发送出去的,这也许是大家比较感兴趣的问题。
有必要先简单认识一下SL811的功能,虽然这跟USB1.1协议几乎无关。
SL811提供了15个寄存器供使用,实际在进行USB传输时最少只需要用到其中6个,
另外还需要用到SL811内建的240字节RAM作为数据缓冲。
启动SL811发送/接收一次数据(注意不是一帧数据,一帧数据=一个数据包)的步骤
如下:
目标U盘的端点地址和pid → SL811寄存器地址0x03
目标U盘的地址 → 地址0x04
811内部RAM中数据缓冲的地址 → 地址0x01
该次数据的长度(不是该帧数据的长度) → 地址0x02
0xff → 地址0x0d
启动发送的命令字 → 地址0x05
当这一次数据成功了后,如果pid是“发送”,则SL811内部RAM中缓冲处的数据都
被发出去了;如果pid 是“接收”,则SL811内部RAM中缓冲处会填满来自U盘的数据,
长度等于上面黑体字第四行之设定。单片机应该在此时及时把SL811的内建RAM中这些数
据读出来,放进单片机自己开辟的内存区域。
看的出来SL811对USB的物理层已经完全封装了,但是设计者还是需要关心很多细节。
对SL811总线式的读写函数太简单,这里不提了。那是我们这种解决方案下最底层的
函数。关键是usb.c中的usbXfer()和ep0Xfer()这两个函数。
2.4.3 usbXfer()函数
借助分析这个函数的实现可以了解USB的传输模型及差错处理。
分析一下它的输入参数。
int usbXfer (BYTE usbaddr,
// USB设备地址0-127
BYTE endpoint,
// 端点的地址 0-15,对于U盘无非就是0,1,2
BYTE pid,
// 数据包token类型,包括setup,pid_IN,pid_OUT
BYTE iso,
// 是否使用等时传输,对于U盘,该项恒否
WORD wPayload,
//本数据包的最大包长,又称净荷。
WORD wLen,
// 待发送或接收的数据的实际长度
BYTE *buffer
// 待发送的数据的缓冲首址,或者将要接收数据的缓冲首址
)
首先要了解pid。Setup型的pid只出现在控制传输阶段,即usb设备复位配置阶段。Pid_IN
和pid_OUT可能出现在控制传输阶段和此后的Bulk传输阶段。顾名思义,pid_IN表示Host
打算通过这次usbXfer,从U盘读进来wLen长度的数据,放入buffer中。Pid_OUT表示Host
打算向U盘控制器发送wLen长度的数据或者写入U盘wLen长度的数据,这些数据已经在
buffer准备好。
其次是要理清wPayload和wLen的关系。wPayload传递过来的是该端点的最大包长,
在前面枚举中分析那32字节的配置描述符时应该已经记录了下来。在USB1.1的规范里只
能是8,16,32,64这四个值其中之一,实际上我根据SL811的打印只见过8字节和64字
节payload的U盘,而且那些8字节U盘只是端点0是8字节,BULK端点也是64字节的。
应该目前来说多数U盘都是64字节的(1.1的范围内)。
言归正传,由于USB设备的端点有最大包长的限制,SL811启动一次数据传输时必须
保证不超过这一限制,因此,在往SL811的0x02地址发送数据长度前,应作一判断,取
wPayload和wLen之中的较小者。C代码为:
xferLen = (BYTE)((wLen>=wPayload) ? wPayload:wLen);
当wLen < payload的时候,一帧数据只需要SL811启动一次传输就能完成。
当wLen > payload的时候,一帧数据就需要SL811启动多次传输才能完成。
接着让我们根据代码来分析usbXfer()函数的流程。建议对照着附件中usb.c的代码
来看。代码的图在前,分析在后,下同。
简单的函数说明,列出了返回值意义。函数开始的变量定义,有些可以顾名思义,有些
等后面用到了再解释。
红框处需要解释一下。EP0_Buf 值为0x10,它的意义是SL811内建RAM的起始地址。
对于SL811的内建RAM只需要用到其中2×payload个字节,而且是掰开两半来轮换使用。
SL811的应用笔记称之为乒乓缓冲。
举个例子,假设手头遇到一个U盘其端点0是8字节payload的。在枚举U盘时host
请求U盘返回它的32字节配置描述符。此时属于wLen大于payload的情形,需要SL811
启动4次传输才能完全把32字节数据收回来。
首先如上图所示,我们初始化data0指向SL811的RAM起始地址,data1初始化为指向
data0 + payload处,并初始化SL811的缓冲指针寄存器为data0。然后启动SL811发起第一
次传输,让SL811把第一批8字节收到data0处,单片机立即读走这8字节;修改SL811的
缓冲指针寄存器为data1,启动第二次传输,把第二批8字节内容收到data1处,单片机也
立即读走此8个字节。依次轮换,直到4次传输过后,32字节完全收了回来。
平心而论这个作法有点多余,既然是单片机立即读走数据的,一直用data0就可以了。
不过上述做法是Cypress公司提供的例程,很多人都照搬了……大家知道是怎么回事就行了。
这就是前面说的取wLen和Payload中的较小者作为一次传输的长度。等效于前面那行
C代码:xferLen = (BYTE)((wLen>=wPayload) ? wPayload:wLen);
Cmd变量是等会要写入SL811的控制寄存器0x00地址的值。对于Pid_IN的token类型
在这里进行预置。sData0_RD = 00100011B,其含义指:该次数据包的sequence bit 定为
DATA0,产生同步帧SOF,数据方向是IN(即读U盘),使能传输(Enable+ARM)。具体
参考SL811的数据手册。
解释一下其中的Sequence bit,USB1.1协议规定,每个数据包(data packet)都必须包
含一个sequence bit,用于纠错。收发双方的软件也要各自维护自己的sequence bit。Sequence
位要么是DATA0,用0表示,要么是DATA1,用1表示(注意此DATA0与前面的乒乓缓
冲的data0没有半点关系,重名纯属巧合)。USB1.1协议第185页描述了何为一次成功的数
据包收发。
在第i个数据包发送前(左图),TX方的seq bit是DATA0,于是它填充数据包的seq bit
为DATA0。RX方成功收到了这个数据包,于是将自己的seq bit切换到1即DATA1,并返
回一个ACK应答表示成功收到了。当TX方收到ACK后,也切换自己的seq bit到DATA1。
右图的第i+1个数据包就类似了。
1.1协议在第186页描述了一次重发数据包的情形。
同样先看左图,第i个数据包由TX发出,但是由于各种原因RX收到坏(corrupted)
了的包。于是RX方拒绝切换自己的seq bit,并返回NAK给TX方(返回stall或者timeout
等情形类似NAK)。此时TX方由于收到的不是ACK,不能切换seq bit,只能仍然以DATA0
的seq bit组装数据包重发,若如右图RX方接收了这个包了,这才是一次成功的收发,RX
和TX各自的seq bit发生切换。
如下图是一次控制传输涉及到的三个packet,其中中间那个是data packet,红框处就是
其sequence bit。
继续分析代码。
对于PID_OUT,1框处就是将待发送的命令填进SL811内部RAM的data0缓冲处。Cmd
的预置同上面PID_IN,只是方向变成了OUT而已。2框处正是上面提到的切换发送方的Seq
Bit,并反映在cmd中。显然这跟协议规定的只有在收到ACK才允许切换seq bit相悖,但
是Cypress公司的官方例程是这样处理的(又被Cypress耍了一道,尽信code不如无code),
有空我会把这部分代码改过来。
对于Setup Token的处理。
当使用等时传输的时候置cmd的某位。但对于玩U盘,这行其实可以删除。
对于控制传输阶段(endpoint = 0),IN或者OUT的pid都使用DATA1的seq bit。而对
于setup pid,都采用DATA0的seq bit。这是由协议规定的。没什么好说的。
终于可以依次填写SL811的寄存器,启动一次传输。下面开始判断发送是否成功。
开始进入while 循环,循环读入SL811的状态寄存器,判断如果是总线复位或者设备
中途拔出则直接返回-1,以示错误。但如果判断到DONE了,表示传输完成,跳出while,
清中断标志,读入本次传输的结果result,并读入本次传输的剩余字节数remainder。
下面将根据result 的各种不同情况进行处理。开始涉及到USB传输的差错处理。
1.返回ACK
返回ACK是最正常的情形。针对不同的pid,有不同的处理。对于OUT和setup token,
如果返回了ACK,函数可以直接返回了,返回wLen。
对于IN token,稍为复杂。但其实前面已经提到过了,由于wLen>payload,需要继续
启动SL811进行传输,并把数据放于乒乓缓冲中。请看代码。
数字1处,修正剩余的wLen,切换seq bit,dataX加1。dataX的作用就是用来计算接
下来应该使用乒乓缓冲中的data0还是data1。
数字2处,判断之前的传输是否一个字节都没收到(应该不会出现这种情况)。否则认
为之前的传输是成功的,xferlen长度的数据全部收到了SL811的RAM缓冲中,赋值给buflen,
告诉单片机从SL811的缓冲中读取buflen长度的内容。
数字3处,仅当wLen还有剩余,且上次的传输已经全部完成remainder为0的情况下,
再次启动SL811的传输。判断dataX的奇偶性就可以判断应该使用乒乓缓冲的data1还是
data0。
数字1处,单片机及时从SL811的RAM中读取刚收下来的内容到buffer中,并更新
buffer的位置。
数字2处,如果wlen或者remainder等于0,认为本次usbXfer 已经IN 了全部需要的
数据,函数成功返回wLen。假如仍未收完,则会返回到前面的while,等待下一次的DONE
完成,再重复对result的判断,直至函数从这里返回。
2.返回NAK
NAK意味着USB设备暂时无法返回数据给host。
根据USB的协议,返回NAK的可能原因有如下:
A) 设备端处于流量控制状态。目前host的数据发送太频密,为防止设备自己的缓冲溢
出,设备向host发送NAK,意为暂时不要再发数据过来,等设备缓一口气。
B) 设备的端点正在忙(Busy)
C)端点没有数据可传输给host。
D)端点进入了halt状态
对于A和B情形,host应该继续尝试向设备重新发包。C情形一般出现在中断传输里。
D情形,host在重试了足够次数后,应该尝试使用clear feature命令清除端点的状态。
程序段判断对NAK的重发次数是否已到达1000次,否则稍稍延时一下,然后重新发
送。重发超过1000次后,返回-40,表明这是NAK重发失败的返回。我之前调试的时候
NAK只设置为最大重发20次,每次延时5毫秒,结果有些U盘很容易返回NAK错误。现
在看来,NAK的重试次数可以很多,重试间隔可以很短,但太多也不适宜,会让人感觉到
U盘的初始化时间很长。任何时候都不要无限重发,会造成死循环。
3.返回Timeout
设备返回timeout的原因一般是收到无法识别的包(unrecognized)或者坏包(corrupted)。
除了重试外没有什么特别的处理方法。重试的代码类似NAK的。如下图。
4.其他的返回,如stall,overflow,EP0_ERROR
这几种错误如果反馈了回来,不是usbXfer函数能够处理的,唯一能做的就是向上一级
函数返回正确的错误代码,让上级调用函数来处理。
至此,usbXfer函数也结束了。如下图。
2.4.4 ep0Xfer()函数
ep0Xfer 顾名思义是专门跟端点0打交道的函数,负责控制传输即枚举U盘的任务,
get_descriptor、set_address、set_configuration、set_interface、get_MAX_LUN、clear_feature
等命令都是用这个函数组装发送的。
控制传输具有前面提到的USB传输模型中典型的三个阶段:主机发送setup token、主
机发送数据包、主机接收来自设备的握手包又或者称是status stage。
圈1处是把输入结构 setup中的成员作必要的字节交换处理,因为字类型的数据有高位
字节和低位字节的大小端对齐问题(我比较懒,直接弄了个数组来做这个事情,一目了然)。
圈2处通过调用usbXfer 把setup token 发去给设备。注意控制传输一定是发给端点0,
setup token的长度都是8字节,例如前面的get descriptor “80 06 00 01 00 00 12 00”。
接着就是传输数据包的阶段。要判断setup token中的第一个字节bmRequest的最高位,
如果是1,表示该命令要求设备向主机返回数据,例如get descriptor命令,此时传递给usbXfer
的pid必须是pid_IN。反之,就是pid_OUT了。
看得出如果usbXfer返回错误(一般是stall,端点0错误,overflow等),ep0Xfer也不
作处理,直接返回FALSE给上一级。事实上如果在configuration阶段都出现这种错误,说
明硬件上有问题,单片机也作不了什么事情,只能报警。
最后就是接收设备返回来的握手包。三个阶段顺利完成后,ep0Xfer函数成功返回。
2.5 块传输(Bulk)
前面提到USB有4种传输类型,块传输是其中之一,普遍用于U盘这样的大容量存储
设备。usbXfer( ) 函数写好以后,块传输的函数就很容易实现。需要实现的函数有两个:
BulkSend( ) 和 BulkRcv( ) 。
顾名思义这是BULK发送的函数。待发送的数据存放于pBuffer起始的地方,总长度是
buflen。函数主体的工作就是把buflen的内容一个pkglen一个pkglen地发出去。
----------------------------------------------------------------------------------------------------------------
这是BULK 接收函数。实现思路非常类似BulkSend( ),不需多说了。
2.6 SCSI命令
2.6.1 跟U盘初始化有关的SCSI命令
就U盘而言,当枚举过程的最后一条命令Get_Max_Lun执行完,开始进入发送SCSI
命令初始化U盘的阶段。详细的SCSI命令集请参考USB Mass Storage Class UFI Command
Specification和SCSI Block Command -2。使用BUS hound软件能完整的分析这一过程。
继续以我手头的爱国者U盘的bus hound打印来分析。见下图。
从U盘上电枚举过程最后阶段说起,即图中数字1的位置。三次Get_MAX_LUN尝试
都遭遇STALL后,windows决定无视Get_MAX_LUN,开始发送SCSI命令了。第一条就
是Inquiry,如图中红色字。命令是用Bulksend( )发送的,总长31字节,其中红框中的小圆
圈处的12H标志着这串数据正是Inquiry命令。Inquiry命令要求返回36字节的设备信息,
即图中的“aigo Miniking 8.07”那串东东了。这是使用BulkRcv ( )接收的。接着看到数字2
的那个红框,这是由U盘返回的Inquiry命令执行完后的状态字CSW(Command Status
Wrapper),也是使用BulkRcv ( )接收的。顺便说说上面那31字节的命令准确描述应该是CBW
(Command Block Wrapper),CBW和CSW的细节可参看USB Mass Storage Class Bulk-Only
Transport Revision。
数字2下面的那个红框就是一条比较容易出问题的命令:ReadFormatCapacity。小圈中
的23H是该条命令的“身份证”。有些U盘在收到这条命令后返回STALL,可以从上图看
得出,windows执行到这里也收到了STALL,因为它紧接着进行了一次REST,如上图蓝色
直线所示。关于这个REST,我查阅了BUS hound软件的帮助、翻看了手头上所有跟U盘
有关协议文本,勾上了BUS hound的其他捕获选项重试捕获,结论是:这是windows USB
驱动层的一次RESET,似乎在硬件上并无产生什么信号或指令给U盘,也不是Command
Block Reset,更不是USB总线的Port Reset。这个地方我一直希望搞清楚USB总线上发生
了什么事情,但苦于没有USB分析仪,有了解的朋友希望能不吝指教。
我在单片机的程序里对ReadFormatCapacity这里的STALL是这样处理的。先执行一次
Command Block Reset,然后连发两条Clear Feature命令清除端点1和端点2的Halt状态。
具体看usbmsc.c中的代码吧,很简单。
继续分析流程。看下图。
蓝带REST处是接着上一张图最后部分的。REST后,windows获取了
ReadFormatCapacity命令的CSW,注意最后一个字节是01H(用圈圈住的),表示U盘对
ReadFormatCapacity的执行出了问题。按照协议,需要主机发送RequestSense,从图中可见
RequestSense命令的“身份证”是03H….RequestSense收到18字节数据和13字节的CSW,
一切正常。Windows于是重试ReadFormatCapacity。这次在收到20个数据后,估计在获取
CSW的阶段,windows再次被U盘STALL了。此时windows又进行了一次RSET(又到了
我不懂的地方了….)。RSET后,就正常收到CSW了。然后执行的命令应该是ReadCapacity,
其“身份证”是25H。这条命令正常执行完后,主机对U盘的初始化终于终于完成鸟。
2.6.2 Read和Write U盘的命令
读和写U盘只实现了“整个扇区地读”和“整个扇区地写”两个函数。两个函数几乎
一样。以读扇区函数为例进行讲解。读扇区的命令原型也是来自SCSI Primary Command -2
技术规范,乃是其中的READ10命令。
如上图所示,SCSI_Read( )的输入参数之中,lba 是地址,等于将要读取扇区的绝对
扇区号,pBuffer指向读入数据的缓冲区。程序开头先执行另外一条SCSI命令TestUnitReady,
该命令功能如其名,测试U盘是否已经准备好。如果连续执行超过3次都失败,则read函
数失败返回。
这一段是在构造总共31字节的CBW。我比较懒,直接用数据一个字节一个字节地去
填,方便省事,就是浪费了一点点程序ROM。
接下来的就是例行公事,依次发送CBW,接收512字节的扇区内容,接收CSW,然
后返回。
严格来说每次CBW发送后,收到CSW时都需要判断CSW的合法性,以及CSW所
返回的命令执行状态。我的demo程序里都没有这样的处理(除了ReadFormatCapacity和
TestUnit)。有兴趣的朋友很容易自己加上去。
2.7 U盘兼容性问题的探讨
到目前为止,我自己还没有彻底解决兼容性问题(我TCL了…)。不过如果有USB
分析仪,有30个以上不同牌子的U盘给我测试的话,想必会有更多的成果分享给大家。附
件中有我通过BUS hound和串口抓下来的十个不同U盘的打印(为了做这件事情,我把周
围同事的U盘都借光了@@)。
U盘兼容性问题,很大程度上是对协议的理解程度问题,我是这样觉得的。如果时间
充足,最好把以下的协议通读理解:(找不到的话就问computer00要吧hiahia)
USB Mass Storage Class UFI Command Specification Revision 1.0;
USB Mass Storage Class Control/Bulk/Interrupt(CBI) Transport Revision 1.0;
USB Mass Storage Class Bulk-Only Transport Revision 1.0;
USB Specification Revision 1.1;
SCSI Block Commands -2 (SBC-2) Revision 8;
USB Specification Revision 2.0;
以下从U盘插入host开始,按事件发生的时间轴顺序介绍我处理兼容性问题的经验。
BUS hound 软件和串口打印是我唯一的武器。
1)检测到U盘插入后,总线复位时间建议持续400毫秒以上。
我帖一下BUS hound抓的windows对USB总线的复位时间就很清楚的说明问题。
爱国者miniking 1G:
金士顿2G:
读卡器带512Msd卡:
爱国者行业特供型1G:
还有很多个U盘,见我的附件中,基本都需要300毫秒左右。保险点就400ms。
2)严格根据第一条Get Device Discriptor命令的返回获取端点0的payload
有些U盘的端点0是8字节payload的,大多数是64字节。对于8字节payload的U
盘,要严格从第一条Get Device Discriptor命令返回的第八字节获取payload,然后传递给
ep0Xfer,否则尽管枚举过程仍然能pass,但是后面在分析32字节的配置描述符时候会出问
题,没有收到完整的配置描述符是分析不出端点1和端点2的地址以及端点payload的。
3)在枚举阶段获取string 描述符的时候,要判断一下。
有些U盘不支持反馈string 描述符,此时要用if语句判断一下,如果获取不到string
描述符就跳过,不要失败返回。
4)枚举阶段,set configuration后不要省略set Interface,有些U盘的固件如果收不到
set Interface死活不让你过,这个问题不少网友的经验帖中已经提及。
5)对于ReadFormatCapacity和ReadCapacity的处理
帖一下发送SCSI命令初始化U盘的那段代码:
如框中所示,假如ReadFormatCapacity返回失败,不要立即失败返回,应尝试
RequestSense,前面已提到了。至于怎样才算ReadFormatCapacity失败,请看下图:
圈1处是经常发生stall的地方了,如果返回了stall,BulkRcv 的返回就是失败,然后
使用两条ClearFeature清除端点1和端点2的HALT状态,注意Bulk_IN端点地址要加上
0x80。如此操作后,接着去到圈2处,去获取CSW,但通常都会获取到最后一个字节非0
(out[12]不等于0),表示本条命令的执行有问题,这样需要返回FALSE,让上级程序调用
RequestSense。
ReadCapacity的处理类似ReadFormatCapacity。
6)进行读和写U盘之前,适当做一些试探性动作
这在某位网友的帖中也提到了,参考BUS hound的打印,看看windows在读写U盘
前做了什么,我们不妨学着做。
自己抓一次BUS hound就知道了,U盘初始化完成后,即使在空闲时,windows也不
停地对之发送TestUnitReady 命令来检查它的状态。我认为在单片机系统中没必要负担这种
开销,但是在SCSI_Read ( ) 和SCSI_Write ( )的开头我也学习windows加入了TestUnit 命
令。
7) 写U盘的稳定性问题
这个问题的根因应该是不同U盘的速度不一样,flash的搽除是相当耗时间的,而且
有的快有的慢。这个情况下只有增加等待时间和重试命令两种途径比较可行。我选择了增加
等待时间,在SCSI_Write ( )函数中发送完CBW后,插入一个100ms的延时(汗,如果都
这样搞,拷个电影要多长时间…)。我这样做是因为我们的应用中对写U盘的速度要求很低。
网友有些是用重发命令的,应该更合理。
第3章 微软的文件系统
3.1 FAT16文件系统简介
文件系统的作用是对文件在介质上的存储进行管理,并为操作系统提供操作函数。我们
的单片机没有内嵌操作系统,但是为了使我们在U盘上存取的信息在接入PC电脑后能被
windows正常访问,单片机必须满足windows对U盘文件系统的操作规范。
前面我们提到可以把U盘看成是以扇区为单位的逻辑盘(1扇区 = 512字节)。那么
我们看看当一个U盘被格式化成FAT16格式的分区后,它在物理上的全貌到底是怎么样的?
以下就是其结构形式图:
必须说明:大多数情形下的FAT16格式U盘其结构就如上图,但也有例外。如果U盘
在格式化时加上了系统选项,带有boot功能,则在保留区前还将有MBR和若干个隐含扇区。
关于上图的各部分简述:
✓ 引导扇区也叫DBR,它对我们最大的意义是存储了本分区的BPB信息。稍后详解。
✓ FAT1是文件分配表主表(FAT = File Allocation Table),记录数据区中每个簇的使
用情况。其大小根据U盘的实际大小而定。
✓ FAT2是主表的备份。大小与主表一致。
✓ 根文件夹存储文件名目录名等目录信息,固定占据32个扇区大小。
✓ 剩下的数据区就是真正存储数据文件内容的区域
我们在操作一个U盘的文件系统前,必须先获取其文件系统信息,这时必须依赖引导
扇区计算出FAT1、FAT2、根文件夹以及数据区它们的起讫地址。以下逐一介绍这几个区域
以及如何计算它们的起讫地址。
(注:对扇区的访问依照其绝对扇区号寻址,绝对扇区号=逻辑扇区号+隐含扇区数,
逻辑扇区号是指不计算MBR等隐含扇区的号,DBR的逻辑扇区号总是0,即DBR总是逻
辑分区的第一个扇区。)
3.1.1 保留区
首先看看U盘被格式化成boot分区的情况,保留区前含有MBR和若干个隐含扇区。
MBR是主引导记录的缩写(Master Boot Record),是物理上第一个扇区(如果它存在的
话),因而绝对扇区号是0,它独立于任何一个分区(或者称“卷”)。MBR的前446字节是
系统引导程序,接着的64个字节就是大名鼎鼎的分区表DPT(Disk Partition Table)。最后两
字节是扇区有效标志55 AA。一个MBR的例图如下:
DPT以每分区16个字节的大小存放着最多四个主分区的信息。对于U盘来说因为一般
只有一个分区,所以其分区信息就存放在DPT的第一个16字节中。其中我们最关心的是偏
移地址为1C6H的信息,它指示着引导记录DBR相对于MBR的偏移地址。以上图为例,
偏移地址1C6H处的值为0000003F(注意是按双字大小存放的),3FH = 63,即十进制值为
63,表示DBR的绝对扇区号 = MBR的扇区号(0)+63。(意即DBR前面有63个隐含扇区)
鉴于MBR有的情况下存在有的情况下不存在,我们在初始化U盘时可以这样处理:先
尝试读入绝对扇区号0的内容,然后判断偏移地址54、55和82、83处的ASCII值,C语言
代码如下:(假设workbuf 数组是读入的扇区内容)
If ( WorkBuf[54] != 'F' &&
WorkBuf[55] != 'A' &&
WorkBuf[82] != 'F' &&
WorkBuf[83] != 'A' )
{ … } // the current sector is MBR
Else
{ … } // the current sector is DBR
如果上述判断为真,表示当前读入的扇区是MBR,否则是DBR。为什么这样判断呢?
因为如果是DBR,偏移位置54和偏移位置82的内容都会是”FAT” 这个字符串,而MBR
则不可能出现这个字符串。
DBR扇区(DOS Boot Record)位于逻辑分区的第0个扇区(总是逻辑分区的第一个扇
区)。DBR从第一个字节开始就依序存储了重要的分区信息,称为BPB(Bios Prameter Block)。
从以下的DBR数据结构定义可以大致看出FAT16文件系统下BPB存储了哪些内容。
typedef struct { // ofs = offset 偏移
uchar bJmpBoot[3]; //ofs:0.典型的如:0xEB,0x3E,0x90。
char bOEMName[8]; //ofs:3.典型的如:“MSWIN4.1”。
uint BPB_wBytesPerSec; //ofs:11.每扇区字节数。
uchar BPB_bSecPerClus; //ofs:13.每簇扇区数。
uint BPB_wReservedSec; //ofs:14.保留扇区数,从DBR 到FAT 的扇区数。
uchar BPB_bNumFATs; //ofs: 的个数。
uint BPB_wRootEntry; //ofs:17.根目录项数。
uint BPB_wTotalSec; //ofs:19.分区总扇区数(<32M 时用)。
uchar BPB_bMedia; //ofs:21.分区介质标识,优盘一般用0xF8。
uint BPB_wSecPerFAT; //ofs:22.每个FAT 占的扇区数。
uint BPB_wSecPerTrk; //ofs:24.每道扇区数。
uint BPB_wHeads; //ofs:26.磁头数。
ulong BPB_dHiddSec; //ofs:28.隐藏扇区数,从MBR 到DBR 的扇区数。
ulong BPB_dBigTotalSec; //ofs:32.分区总扇区数(>=32M 时用)。
uchar bDrvNum; //ofs:36.软盘使用0x00,硬盘使用0x80。
uchar bReserved1; //ofs:37.保留。
uchar bBootSig; //ofs:38.扩展引导标记:0x29。
uchar bVolID[4]; //ofs:39.盘序列号。
char bVolLab[11]; //ofs:43.“Msdos ”。
char FileSysType[8]; //ofs:54.“FAT16 ”。
uchar ExecutableCode[448]; //ofs:62.引导代码。
uint EndingFlag; //ofs:510.结束标识:0xAA55。
}DBR_tag;
其中红色字部分是计算FAT1起始地址、根目录起始地址、数据区起始地址的重要参数。
计算公式如下:
文件分配表 ≡ 保留扇区数
根目录 ≡ 文件分配表起始号 + FAT 的个数×每个FAT 的扇区数
数据区 ≡ 根目录逻辑扇区号 + 32
根据下图将很容易理解上述公式的含义:
更详尽的BPB介绍可参考《FAT32文件系统规格书》。这是由夏新的工程师khalai翻译的
微软的FAT32白皮书。
3.1.2 FAT区
FAT区包含主表和备份表,两者的内容是完全一致的。单片机若要更新主表必须同时更
新备份表。FAT表的作用是记录数据区中每个簇的使用情况,以及维护簇链的链式关系,是
FAT文件系统的核心。
何为簇?简单的理解就是文件系统固定将若干个连续的扇区定义为一个簇,譬如4个扇
区为一簇;究竟该将多少个扇区定义为一簇,依赖于整个分区的大小,以下为FAT16的原
则:
定义了簇后,再定义:为了存储每个文件,最小分配单位是簇,也就是说即使一个只有
几字节大小的文件,也是分配一个簇的空间来存放。之所以簇为单位而不以扇区为单位进行
磁盘的分配,是因为当分区容量较大时,采用大小为512B的扇区管理会增加fat表的项数,
对大文件存取增加消耗,文件系统效率不高。
有了以上定义后,再来看fat 表就容易理解了。FAT16系统下的FAT表以16bit宽度的
数值记录每个簇的使用情况,即数据区中的每一簇在FAT表中占据2字节(二进制16位)以
存放信息。所以,FAT16最大可以表示的簇号为0xFFFF(十进制的65535),以32K为簇的
大小的话,FAT16可以管理的最大磁盘空间为:32KB×65535=2048MB,这就是为什么FAT16
不支持超过2GB分区的原因。对于目前越来越大的U盘,超过2G是经常的事情,所以如
果做Host,一定要同时支持FAT16和FAT32。
FAT表实际上是一个数据表,以2个字节为单位,我们暂将这个单位称为FAT记录项,
通常情况下其第1、2个记录项(前4个字节)用作介质描述。从第三个记录项开始记录除数
据区文件存储的簇链情况。根据簇的表现情况FAT用相应的取值来描述,见表10
看一幅在winhex所截FAT16的文件分配表,图10:
如图,FAT表以"F8 FF FF FF" 开头,此2字节为介质描述单元,并不参与FAT表簇链
关系。小红字标出的是FAT扇区每2字节对应的簇号。
相对偏移0x4~0x5偏移为第2簇(顺序上第1簇),此处为FF,表示存储在第2簇上的文件
(目录)是个小文件,只占用1个簇便结束了。
第3簇中存放的数据是0x0005,这是一个文件的首簇。其内容为第5簇,就是说接下来
的簇位于第5簇 → FAT表指引我们到达FAT表的第5簇,上面写的数据是"FF FF",意即
此文件已至尾簇。
第4簇中存放的数据是0x0006,这又是一个文件或文件夹的首簇。其内容为第6簇,就
是说接下来的簇位于第6簇 → FAT表指引我们到达FAT表的第6簇,上面写的数据是
0x0007,就是说接下来的簇位于第7簇 → FAT表指引我们到达FAT表的第7簇……直到
根据FAT链读取到扇区相对偏移0x1A~0x1B,也就是第13簇,上面写的数据是0x000E,
也就是指向第14簇 → 14簇的内容为"FF FF",意即此文件已至尾簇。
后面的FAT表数据与上面的道理相同。不再分析。
理解了FAT16的FAT链式存储,FAT32就同理了,区别仅仅是FAT表的簇项记录的宽
度是32bit(占据4字节)。
更详细的介绍簇链可参考文章《4.5万字透视FAT32》。
3.1.3 根文件夹
也称文件目录表FDT(File Directory Table),顾名思义,它主要存放文件和目录的信息,
我们在windows中看到的文件名、创建时间、文件大小等信息即存放于此。
文件目录分两类:根目录和子目录,为描述简单起见这里只讨论根目录和短文件名,子
目录和长文件名可参考微软的FAT白皮书轻松理解。
FAT16的根文件夹固定占有32个扇区的空间,它使用32字节存储每笔记录项,因此
FAT16系统中最多只能处理32×512÷32=512个目录项。
32字节的目录项数据结构定义如下:
typedef struct{
char FileName[8]; //ofs:0.文件名
char ExtName[3]; //ofs:8.扩展名
uchar attribute; //ofs:11.文件属性。典型值:存档(0x20)、卷标(0x08)。
char reserved[10]; //ofs:21.保留
uint time; //ofs:22.时间
uint data; //ofs:24.日期
uint StartClus; //ofs:26.开始簇号
ulong FileLength; //ofs:28.文件长度
}DIR_tag;
3.1.4 数据区
数据文件的内容将不连续地存放于此。
3.2 FAT32文件系统简介
FAT32与FAT16的区别主要有以下几点:
(1) 保留扇区数,FAT16只有引导扇区即DBR,而FAT32除了引导扇区外还有31个保
留扇区(有些U盘是63个)。主要是留给操作系统进行DBR的备份以及存放其他配置。
(2) FAT表项从16bit增加到32bit,即数据区的每簇占据FAT表4个字节。簇链结构同
FAT16一样。
(3) 取消了根文件夹区,把文件目录项信息放入数据区中,等同普通文件一样看待,进
行链式存放。这样就突破了512的限制。
3.3 FAT文件系统的局限性
FAT16和FAT32文件系统虽然现在仍然普遍使用,但是本身有缺憾。最大的缺憾我认
为是对于异常掉电情况很难处理。用过Windows的人都知道如果硬盘上某个分区是FAT16
或FAT32格式的话,假如电脑死机或者异常重启(这种情况实在太普遍了),重启后windows
都要对此分区进行扫描。这正是因为FAT文件系统无法保证异常掉电情况下文件系统的完
好性。设想操作系统刚好为某个文件分配了一个簇,这时候掉电了,那这个簇以后都不能再
用了;设想操作系统刚好写完了一个文件的所有数据簇,正准备更新文件大小的时候掉电了,
那这个文件就相当于残废了……所以微软只好通过重启扫描的办法来勉强维持住FAT文件
系统的完好性。NTFS则没有这个问题,一些嵌入式系统的FS也可以解决这个问题,具体
我没有去研究过。
第4章 编码实例分析
4.1 需求简述
下面让我们以一个简单的程序为例,分析如何编码。程序的功能是:等待U盘插入后,
搜索根目录下名为的文件,打开它,并复制其内容到另一个名为
的新文件中。
4.2 文件结构
4.3 Main.c
具体请参照main.c的代码。此处不贴。
用到两个在别的文件定义的标志位:DevAttached 和 IsFat16。前者可判断是否插入了
U盘,后者判断U盘的格式是FAT16还是 FAT32。
初始化一个文件目录信息数组File_To_Creat[32],待会新建文件就根据这个数组。
InitTimer( ) 、InitComm(38400)、InitSL811 ( ) 初始化硬件。
进入程序主循环,while (!DevAttached) 等待U盘插入;
U盘插入后,IsMassDev() 实行对U盘的枚举过程,若失败,等待U盘拔出后重试;
枚举完成后,InitMassDev() 分析U盘描述符并进行变量初始化,允许失败重试3次,
如果超过3次,同样等待拔出U盘重试;
File_Open("CONFIG TXT") 打开文件 ;
接下来根据U盘的格式是FAT16或是FAT32,新建一个名为的文件,
将 的内容写入。
4.4 USB.c
主要的usbXfer ( )和ep0Xfer ( )都已经在前面解释得很清楚。其他的就是控制传输命
令的构建了,都是通过VendorCmd( )组装。以SetAddress 命令为例进行说明。SetAddress 是
枚举初期USB主机发送给设备的一个请求(Request),该请求是封装在setup包中通过控制
传输从默认地址0发送给USB设备的。Setup包的数据结构如图所示(截自USB1.1协议文
本)。
根据上表,setaddress函数设计如下:
static BOOL SetAddress (BYTE addr)
{
return VendorCmd (0,0,SET_ADDRESS, (WORD)addr, 0, 0, NULL);
}
4.5 timer.c
包括一个全局变量nMsTicks和四个函数: InitTimer、DelayMs、ShortDelay和
Timer_ISR。
nMsTicks 每流逝1毫秒进行加一计数,是系统的时间滴嗒。InitTimer 初始化定时器
的计数变量,DelayMs用于毫秒级的延时,ShortDelay用于NAK或者Timeout的重发之间
的短延时,Timer_ISR是定时器中断处理程序,都比较简单。
4.6 filesys.c
这是对文件系统操作的核心文件。
4.6.1 变量说明
文件系统参数:
xdata BYTE WorkBuf[512]; // 存放ReadSector()所读入的扇区内容
xdata DWORD VolumeStarts; // 卷起始,即DBR的绝对扇区号
xdata DWORD FatStarts; // FAT表起始的扇区号
xdata DWORD RootStarts; // 根目录起始扇区号
DWORD DataStarts; // 数据区起始扇区号
BYTE SectorsPerCluster; // 每簇的扇区数
BOOL IsFat16; // U盘格式标志位,fat16 = 1, fat32 = 0
WORD BPB_FATSz16; // 一个FAT16表的体积,即其占据的扇区数
WORD BPB_FATSz32; //一个FAT32表的体积
处理文件用到的相应变量:
DWORD FileSize; // 记录从根目录获得的文件大小,单位是字节
DWORD FilePtr; // 文件指针,记录当前操作的是文件中的第几字节
DWORD NextSector; // 即将操作当前文件的下一个扇区
DWORD NextCluster; // 即将操作当前文件的下一个簇
DWORD nextcluster_root; // 根目录簇链的下一簇(仅用于FAT32下)
WORD RootEntSector; // 记录某文件目录信息在根目录中保存的扇区位置
WORD RootEntByteOffset; // 某文件目录信息的扇区内偏移
4.6.2 扇区读写函数
ReadSector (DWORD SectorNum) 根据逻辑扇区号读入该扇区内容→WorkBuf[512]。
注意是逻辑扇区号。SCSI_Read ()是读入绝对扇区号的内容。
WriteSector (DWORD SectorNum, BYTE xdata *pBuffer) 把pBuffer 指向的512字节内
容写入扇区号为 SectorNum的扇区。pBuffer都是用WorkBuf[512]数组。
4.6.3 询问下一簇号函数
GetNextCluster_16 (DWORD Cluster) 输入当前簇号Cluster,寻找cluster在FAT16簇
链中的下一簇。
static WORD GetNextCluster_16 (DWORD Cluster)
{
DWORD SectorOffset;
WORD ByteOffset;
SectorOffset = FatStarts + Cluster /256; // ---- (1)
ByteOffset = (Cluster % 256)*2 ; // ---- (2)
if (ReadSector(SectorOffset))
return W_BUF(WorkBuf,ByteOffset);
else
}
return 0xfff7;
(1) 计算cluster簇的记录是位于FAT16表的第几个扇区,因为对于FAT16而言每扇
区可存放 512÷2 = 256笔簇记录,所以只需对cluster除256取整即可。
(2) 再计算cluster簇这笔记录位于SectorOffset中的第几字节,只需对cluster模256,
再由于每簇占用2字节存放,因此再乘以2。
FAT32下的GetNextCluster_32 (DWORD Cluster) 函数就同理了,所不同的是FAT32表
每扇区只能存放128笔簇记录。每笔簇记录占4字节。
4.6.4 FAT初始化函数(BPB信息分析)
无论U盘格式如何,都是先调用FAT_Init()函数。先分析该函数流程:
static BOOL FAT_Init ()
{
// 读入主引导记录MBR并检查合法性
if (!SCSI_Read(0, WorkBuf)) // 能读入MBR吗?
return FALSE; // 失败
if (WorkBuf[510]!= 0x55 || WorkBuf[511] != 0xaa) // MBR扇区有效吗?
return FALSE; // 无效
// 先判断该扇区是否为DBR
If ( WorkBuf[54] != 'F' &&
WorkBuf[55] != 'A' &&
WorkBuf[82] != 'F' &&
WorkBuf[83] != 'A' )
{
// 是MBR,则从偏移454处得到DBR的位置
VolumeStarts = DW_BUF(WorkBuf,454);
}
else
VolumeStarts = 0;
if (!ReadSector(0)) //读入DBR
return FALSE;
if (WorkBuf[510]!= 0x55 || WorkBuf[511] != 0xaa) // DBR扇区有效吗?
return FALSE; // 无效
if (W_BUF(WorkBuf,11) != 512) // 只处理每扇区字节数为512的U盘
return FALSE;
if(W_BUF(WorkBuf,22) == 0x00) // 分区为FAT16格式吗? --- (1)
{
IsFat16 = 0;
return (Init_fat32());
}
else
{
IsFat16 = 1;
return (Init_fat16());
}
}
(1) DBR的偏移22处存放着FAT16表所占的扇区数,而对于FAT32格式U盘的DBR,
该参数为0。因此这里据此判断U盘的格式是FAT16还是FAT32。
可见在FAT_Init()函数的最后,判断了U盘的格式是FAT16还是FAT32,然后各自执行
其初始化函数,以下以Init_fat16() 为例进行分析。
static BOOL Init_fat16 (void)
{
if (!ReadSector(0)) //读入DBR
return FALSE;
if (WorkBuf[510]!= 0x55 || WorkBuf[511] != 0xaa) // DBR扇区有效吗?
return FALSE; // 无效则返回
if (! ( WorkBuf[54]=='F' && // 再次确认为FAT16
WorkBuf[55]=='A' &&
WorkBuf[56]=='T' &&
WorkBuf[57]=='1' &&
WorkBuf[58]=='6'
)
) return FALSE;
// 计算FAT表开始位置
FatStarts = W_BUF(WorkBuf,14); // 加上保留扇区数
// 计算根目录表的开始位置
// RootStarts = FatStarts + FAT表个数 * 每FAT表所占的扇区数
BPB_FATSz16 = W_BUF(WorkBuf,22);
RootStarts = FatStarts + (DWORD)WorkBuf[16] * (DWORD) BPB_FATSz16;
// 计算数据区的开始位置
// 数据区 = 根目录 +32 * 根目录中目录项数 / 每扇区字节数
DataStarts = RootStarts + 32 * (DWORD)(W_BUF(WorkBuf,17) / 512 );
SectorsPerCluster = WorkBuf[13];
return TRUE;
}
对于FAT32,区别仅是一些参数的偏移位置不一样而已。全面的BPB信息分析可参考
《FAT32文件系统规格书》的第二页。
4.6.5 Open Files
类似初始化FAT的函数,打开文件也是从File_Open(BYTE * FileName) 函数入口,再
分FAT16和FAT32处理。
直接看FAT16下的打开文件函数:
static BOOL File_Open_16 (BYTE * FileName)
{
for (s=RootStarts; s { if (!ReadSector(s)) // 逐个扇区读入 return FALSE; for (i=0;i<16;i++) // 每扇区有16个目录项 { if (WorkBuf[32*i+0] == *FileName && // 比较文件名 WorkBuf[32*i+1] == *(FileName+1) && WorkBuf[32*i+2] == *(FileName+2) && WorkBuf[32*i+3] == *(FileName+3) && WorkBuf[32*i+4] == *(FileName+4) && WorkBuf[32*i+5] == *(FileName+5) && WorkBuf[32*i+6] == *(FileName+6) && WorkBuf[32*i+7] == *(FileName+7) && WorkBuf[32*i+8] == *(FileName+8) && WorkBuf[32*i+9] == *(FileName+9) && WorkBuf[32*i+10]== *(FileName+10)) { FileSize = DW_BUF(WorkBuf+32*i,28); // 获取文件大小 if (FileSize ==0) // 文件非空判断,可选 return FALSE; FilePtr = 0; // 复位文件指针 NextSector = DataStarts; // 初始化 NextCluster = W_BUF(WorkBuf+32*i,26); // 指向该文件第一簇 return TRUE; } } } return FALSE; } FAT32的情况下,由于根目录是从数据区的第二簇开始以链式存储,跟普通文件一样, 因此在搜索文件名时跟FAT16不一样,需要从第二簇开始一簇一簇地往下搜。 static BOOL File_Open_32 (BYTE * FileName) { DWORD s; int i,j; } while (nextcluster_root < 0x0fffffff) //根目录簇链尚未到链尾 { s = RootStarts + (nextcluster_root -2)*SectorsPerCluster; for (j=0; j { if (!ReadSector(s)) return FALSE; s++; for (i=0;i<16;i++) // 每扇区有16个目录项 { if (WorkBuf[32*i+0] == *FileName && // 比较文件名 WorkBuf[32*i+1] == *(FileName+1) && WorkBuf[32*i+2] == *(FileName+2) && WorkBuf[32*i+3] == *(FileName+3) && WorkBuf[32*i+4] == *(FileName+4) && WorkBuf[32*i+5] == *(FileName+5) && WorkBuf[32*i+6] == *(FileName+6) && WorkBuf[32*i+7] == *(FileName+7) && WorkBuf[32*i+8] == *(FileName+8) && WorkBuf[32*i+9] == *(FileName+9) && WorkBuf[32*i+10]== *(FileName+10)) { FileSize = DW_BUF(WorkBuf+32*i,28); if (FileSize ==0) return FALSE; FilePtr = 0; NextSector = DataStarts; NextCluster = (DWORD)W_BUF(WorkBuf+32*i,26) + (((DWORD)W_BUF(WorkBuf+32*i,20))<<16 ); return TRUE; } } } //获取下一个根目录簇链 nextcluster_root = GetNextCluster_32 (nextcluster_root); } return FALSE; 4.6.6 创建文件 以FAT16的文件创建程序为例。待创建的文件目录信息从pBuffer带入。 BOOL CreateFile_16 ( DIR_INFO *pBuffer ) { 号 unsigned long s = RootStarts; // s 指向根目录表头 unsigned int i; unsigned char j; pBuffer -> name[0] = 'N'; // 名固定为 ,因需而异 pBuffer -> name[1] = 'E'; pBuffer -> name[2] = 'W'; pBuffer -> name[3] = 'F'; pBuffer -> name[4] = 'I'; pBuffer -> name[5] = 'L'; pBuffer -> name[6] = 'E'; pBuffer -> name[7] = ' '; pBuffer -> extension[0] = 'T'; pBuffer -> extension[1] = 'X'; pBuffer -> extension[2] = 'T'; pBuffer -> startCluster = GetFreeCusterNum_16 (); // 从FAT表查找一个空闲簇的簇 if (pBuffer -> startCluster < 0x2) // 簇号检查,不应该小于2 return FALSE; // 所分配的空闲簇无误后,存入文件目录信息中 pBuffer -> startCluster = SwapINT16(pBuffer -> startCluster); // 在根目录中查找空闲位置创建目录信息 while (s < RootStarts+32) // fat16的根目录通常是含有32个扇区 { if (!ReadSector(s)) // 逐个扇区地读取根目录项信息 return 0x0; for (i=0; i<512; i=i+32) { if (WorkBuf[i] == 0xe5 || WorkBuf[i] == 0x0) // 空闲位置以e5或00打头 { for (j=0; j<32; j++) { WorkBuf[i+j] = *(((BYTE *)pBuffer) + j); // 复制目录信息 } RootEntSector = s ; RootEntByteOffset = i; //记录下该文件的根目录入口以便再次访问 if (!WriteSector( s, WorkBuf)) // 更新根目录信息 return FALSE; return TRUE; } } s++; } } FAT32下的区别仅仅是在于查找根目录空闲位置时,需要从第二簇开始按簇链搜索。详 细可参考代码。 4.6.7 写入文件 看到这里,应该不难理解File_WriteFile_16 和 File_WriteFile_32这两个函数了。代码 中的注释很清楚。 第5章 使用CH375的解决方案 5.1 只作简单介绍 CH375是南京沁恒电子出品的USB总线通用接口芯片,兼容USB2.0。详细内容我不多 说了,我只讲讲使用CH375的感受。 CH375把USB、BULK、SCSI、文件系统的东西都封装了起来,提供一个库文件给用 户调用其中的程序。这样对于项目进度很急的设计师是个好消息,开发速度相当快,一个小 时内就能上手。但是对于学习USB则是不利的,可以说USB的东西你都不用关心了。 U盘兼容性方面,虽然CH375只试过十几个U盘,但还没出现读写不了的。而SL811 的方案,一直在改,一碰到反馈有问题的U盘,又得改,改到有时候都没有办法了…… 读写稳定性方面,CH375的方案只进行过最长6个小时的拷机,即一直的读数据然后 从串口接收,暂时没发现过问题。SL811的方案只要初始化通过的U盘,都还没试过读写 不稳定,最长拷机时间30个小时。 EMC,高温,老化测试方面,CH375的方案没做过。SL811的方案通过测试。
2024年4月16日发(作者:仆翰采)
第1章 U盘的逻辑结构
1.1 U盘的逻辑结构
U盘可以看成是以扇区(1扇区=512Bytes)为单位线性排列的实体,即0号扇区,1
号扇区,2号扇区,……这样按顺序地排列下去。U盘是flash,对flash的操作总是以块为
单位的,因此单片机对U盘的操作是以扇区为单位,整个扇区地读取,或整个扇区地写入。
第2章 USB通信协议
2.1 USB设备开工的机理
USB是即插即用的,涵盖海量存储器(如U盘、移动硬盘)、人机交互设备(如鼠标键
盘游戏杆)、扫描仪、打印机等等各种各样功能的设备,那么USB主机是如何判断目前接入
的设备到底是怎么样的呢?答案是USB描述符,以及USB的枚举。
2.2 USB描述符
这个概念很简单,就是对各种纷繁芜杂的USB外设按功能划分大类(class),大类下又
再细分小类(subclass),每个类别给予一串特定的符号(Descriptor)供主机辨识。
每个USB设备只能有一个DEVICE描述符,它指明了该设备属于哪一大类,是海量存
储器类,还是人机交互设备类,还是打印机或者扫描仪类,等等。
每个DEVICE下可以有1个或多个配置描述符(configuration),以说明该设备含有哪
些功能。如一个USB接口的CDROM可以同时具有读写光盘的功能和播放CD的功能。有
几个功能,就有几个配置描述符。
每种配置对应若干个接口描述符(Interface),以描述该配置使用哪些接口与主机进行通
信。
每个Interface又都是端点(End Point)的集合,端点就是设备与USB主机交换数据的
最原子单位了。每个Interface用到的端点可以是一个或多个。下图摘自USB MASS
STORAGE CBI Transport Specification 第6页,清楚说明各种描述符的组织情况。
2.3 USB设备的枚举过程(开工过程)
有了完善的分类后,USB设备上电即可通过枚举过程告诉USB主机自己的详细信息,
这很类似一个一问一答的过程,如下:
主机(下称H):你是甚么设备?
设备(下称D):我是12 01 0100…… (这就是DEVICE描述符了)
H:你有几种功能?
D:我有 09 02 09 …… (配置描述符)
H:每种功能有几个接口?
D:09 04 00…… (接口描述符)
H:每个接口用到哪些端点?
D:07 05 81 …… (端点描述符)
H:好了,我知道你是谁了,开始传数据吧!
D:OK. READY GO!
具体而言,USB枚举过程有以下步骤:
(1) 集线器检测新设备
主机集线器监视着每个端口的信号电压,当有新设备接入时便可觉察。(集线器端口的两根
信号线的每一根都有15kΩ的下拉电阻,而每一个设备在D+都有一个1.5kΩ的上拉电阻。
当用USB线将PC和设备接通后,设备的上拉电阻使信号线的电位升高,因此被主机集线
器检测到。)
(2) 主机知道了新设备连接后
每个集线器用中断传输来报告在集线器上的事件。当主机知道了这个事件,它给集线器发送
一个Get_Status请求来了解更多的消息。返回的消息告诉主机一个设备是什么时候连接的。
(3) 集线器重新设置这个新设备
当主机知道有一个新的设备时,主机给集线器发送一个Set_Feature请求,请求集线器来重
新设置端口。集线器使得设备的USB数据线处于重启(RESET)状态至少10ms。
(4) 集线器在设备和主机之间建立一个信号通路
主机发送一个Get_Status请求来验证设备是否激起重启状态。返回的数据有一位表示设备仍
然处于重启状态。当集线器释放了重启状态,设备就处于默认状态了,即设备已经准备好通
过Endpoint 0 的默认流程响应控制传输。即设备现在使用默认地址0x0与主机通信。
(5) 集线器检测设备速度
集线器通过测定那根信号线(D+或D-)在空闲时有更高的电压来检测设备是低速设备还是
全速设备。(全速和高速设备D+有上拉电阻,低速设备D-有上拉电阻)。
以下,需要USB的firmware进行干预
(6) 获取最大数据包长度
PC向address 0发送USB协议规定的Get_Device_Descriptor命令,以取得缺省控制管道所
支持的最大数据包长度,并在有限的时间内等待USB设备的响应,该长度包含在设备描述
符的bMaxPacketSize0字段中,其地址偏移量为7,所以这时主机只需读取该描述符的前8
个字节。注意,主机一次只能列举一个USB设备,所以同一时刻只能有一个USB设备使用
缺省地址0。
(7) 主机分配一个新的地址给设备
主机通过发送一个Set_Address请求来分配一个唯一的地址给设备。设备读取这个请求,返
回一个确认,并保存新的地址。从此开始所有通信都使用这个新地址。
(8) 主机向新地址重新发送Get_Device_Descriptor命令,此次读取其设备描述符的全部
字段,以了解该设备的总体信息,如VID,PID。
(9) 主机向设备循环发送Get_Device_Configuration命令,要求USB设备回答,以读取
全部配置信息。
(10) 主机发送Get_Device_String命令,获得字符集描述(unicode),比如产商、产品描
述、型号等等。
(11) 如果主机是PC电脑,此时主机将会弹出窗口,展示发现新设备的信息,产商、产
品描述、型号等。
(12) 根据Device_Descriptor和Device_Configuration应答,PC判断是否能够提供USB
的Driver,一般win2k能提供几大类的设备,如游戏操作杆、存储、打印机、扫描仪等,操
作就在后台运行。
(13) 加载了USB设备驱动以后,主机发送Set_Configuration(x)命令请求为该设备选
择一个合适的配置(x代表非0的配置值)。如果配置成功,USB设备进入“配置”状态,并
可以和客户软件进行数据传输。此时,常规的USB完成了其必须进行的配置和连接工作。
查看注册表,能够发现相应的项目已经添加完毕,至此设备应当可以开始使用。
以上是PC电脑为主机的枚举过程,对于单片机作为主机的情形,过程要简单一些,以
枚举U盘为例:
(1) 芯片SL811监视USB总线电平,当发现有U盘插入后,给单片机一个中断信号。
(2) 单片机给SL811发出端口复位命令,持续100毫秒以上。
(3) 单片机发出Get_Device_descriptor命令,从默认的端口0和地址0发出。该命令先
假设了包传送的大小是64字节,在获得命令返回时修正MaxPacketSize。此步同PC。
(4) 单片机发送Set_Address请求来分配一个唯一的地址给U盘,我们实际应用中固定
分配了地址2。此步同PC。
(5) 单片机向新地址2重新发送Get_Device_Descriptor命令,此次读取U盘设备描述符
的全部字段,以了解该设备的总体信息,如VID,PID。此步同PC。
(6) 单片机发送Get_Configuration_Descriptor命令获取配置描述符。
(7) 根据获取的配置信息,单片机发送SetConfig和SetInterface命令对U盘进行配置。
(8) 对获取的Interface描述符和Endpoint描述符进行分析,判断是否大容量存储设备、
是否支持SCSI命令集、是否BULK_ONLY传输、端口的最大包长等内容。
(9) 发送Get_Max_LUN命令获取U盘的进一步信息(根据协议看此步非必须,有些U
盘此步会返回STALL,即不支持,也没有关系)。但是建议在枚举过程中不省略此步,因为
不同品牌U盘其固件可能不一样,有些固件可能不允许省略此步。
(10) 完成上述步骤后,U盘的枚举过程完成,接着需要发送几条SCSI命令来对U盘进
行初始化,这几条命令依次是Inquiry、ReadFormatCapacity、ReadCapacity。完成后,U盘
已经准备好接收单片机发出的任何读写命令(读写命令也是来自SCSI命令集)。
如果你有兴趣知道USB协议一些更细节的内容,请往下看。否则可以直接跳到第四章
的文件系统部分。
2.4 USB1.1协议
本节内容主要涵盖USB 1.1 Specification的第4、5、8、9章。并且主要描述代码中无法
注释或者在代码中注释会太麻烦的内容。
USB是一种主从的结构。所有传输由Host来发起。当主机发起一次传输时,这次传输
的包(Packets)通常包括三个阶段。主机首先是发送一个Token Packet,内里包含本次传输
的命令类型(type)、方向(direction)、设备的地址(device address)以及端点号(Endpoint)。
紧接着是数据包(data packet),就是包含数据了。最后将由device返回握手信号包(handshake
packet),表示是正确收到了(ACK)还是其他的失败原因。三个包如下图所示。
USB的传输模型:Host和设备的某个端点之间可以看成有一条逻辑管道(pipe)。Pipe
分两种:业务数据流和信令消息。业务流即指纯粹的数据,信令流指控制信息。其实通信协
议很多都如此,分业务流和信令流,例如电信网中的7号信令。
在信令管道中,有一条默认的管道,那就是零地址处的零号端点,这条管道在USB 设
备上电复位或总线复位后就存在了,便于Host统一利用这个地址向USB设备进行配置。显
然对于USB集线器,即使同时插入几个设备,Host也只能一次对一个设备进行配置。USB
设备只有配置(configured)后,才可使用。
USB的传输类型有四种:
控制传输(control transfer),通常只用于在设备复位后Host通过端点0进行配置。
块传输(Bulk Transfer),譬如U盘的大量数据传输即用此方式。
中断传输(Interrupt Transfer),一般用于人机设备如USB鼠标键盘等。
等时传输(Isochronous Transfer),可以进行带宽控制的实时传输形式。
2.4.1 重新认识枚举过程
枚举过程事实上是USB 设备复位后,恢复到0地址0号端点,然后主机通过一系列控
制传输命令对USB设备进行配置,同时也获取一些信息。
使用BUS hound这个工具可以把完整的USB设备枚举过程抓下来。网上很容易找到安
装包。BUS软件的设置如下:可以确保抓下所有的数据包信息。
利用BUS hound的软件抓一下爱国者行业特供型1G的U盘,其在PC下的枚举过程完
全在下图中表现了出来。让我们逐一分析。
由设备16.0抓到的数据包属于USB集线器的行为,在无使用集线器的单片机系统中可
以无视之。设备21.0的含义是:usb设备地址是21,目前管道是跟它的端点0打交道。
数字1处是枚举过程的开始,主机用控制传输发送Get Descriptor获取设备描述符(具
体为何是设备描述符可以对照USB1.1技术规范的第九章来分析左边的那串08 06 00 02……
的二进制数据,下同),这条命令假设了未知设备的端点0的最大包长值64字节,然后在命
令中要求设备返回0x12(十进制18)个字节的device描述符,如图中圈起来的12。值得一
提的是,这条命令无论假设设备的端点0的最大包长(Payload)是8,16,32,64,都是可
以获得想要的数据的(图中的40)。40H指明该设备的端点0的最大包长是64字节,Host
此后的控制传输可以使用64字节的数据包跟设备通信了。64字节数据包的细节后面会介绍。
数字2处表示主机发送Get Descriptor获取配置描述符。但是类似设备描述符的处理方
法,主机也先试探性的获取配置描述符的前9个字节(图中带圈的09),以获悉整个配置描
述符有多长,因为长度信息就位于描述符的第3个字节,如图中带圈的20。根据此20h的
长度信息,数字3处开始正式请求设备完整的配置描述符了,可以在图中看到两个20是对
应的。
整个配置描述符包含32个字节(一般的U盘都是这样)。这32个字节中,包含3部分
内容,包含设备的重要信息。数字5所代表的第一个框表示第一部分:配置描述符。数字6
代表第二部分:接口描述符(Interface)。其中第5字节02表示该设备有2个端点(Bulk_IN
和Bulk_OUT),第6字节08代表这是大容量存储设备(Mass Storage Device)。第7字节06
表示支持SCSI命令,不过我调试过MP3播放器这个地方是05,但也支持SCSI命令。第8
字节50表示数据只支持使用Bulk传输(Bulk Only)(更详细的内容可参考USB 。Mass Storage
Class Bulk-Only Transport Revision 1.0)。
数字7和8代表第三部分:端点描述符。第三字节都是代表端点地址,一般情况是地址
1和地址2。留意图中8框第三字节是82H,这表示该端点地址就是地址2,最高位被置1
以表示这个端点是Bulk_IN端点,所以整个数值变成了82H。但是并非地址2就一定是
Bulk_IN,不同的U盘不一样,所以在程序中要根据描述符的实际值,用变量记录下来的,
后面要用到。继续看8框,第五和第六字节组成一个16位的数值表示该端点的最大包长度
(payload)。对于只支持1.1协议的设备,第六字节是其高8位,都是0,第五字节才是真
正的payload数值,1.1协议规定只能是8,16,32,64之一,由厂家固定。至于我们在上
图看到第六字节是02第五字节是00,组合成200H=512,那是因为U盘控制器认出了这个
Host(PC电脑)支持usb2.0,所以就回应了512,而不是64。Payload值非常重要,后面要
依据此值进行判断和计算。
下面接着的4个get descriptor都是获取设备的string描述符。实际的单片机系统也许不
需要获取这些描述符,而且有些U盘也不支持获取这个描述符(返回STALL)。再往下的就
是set_configuration、set_interface、get_MAX_LUN等。有些U盘,在set_interface处会stall;
有些U盘,如果Host不发送set_interface命令,往后的命令都不响应,所以这个牵涉到兼
容性问题,后面再解释。在上图中可以看到,这个爱国者U盘在遇到get_MAX_LUN时返
回了stall,PC的处理方法是clear feature,然后重试,三次后仍然stall则跳过。这个牵涉到
如何进行差错处理,后面再详细分析。
2.4.2 基于SL811的USB底层传输函数实现要点
前面枚举过程介绍的各种命令,如截图中的数字1处的“80 06 00 01 00 00 12 00”到底
是如何发送出去的,这也许是大家比较感兴趣的问题。
有必要先简单认识一下SL811的功能,虽然这跟USB1.1协议几乎无关。
SL811提供了15个寄存器供使用,实际在进行USB传输时最少只需要用到其中6个,
另外还需要用到SL811内建的240字节RAM作为数据缓冲。
启动SL811发送/接收一次数据(注意不是一帧数据,一帧数据=一个数据包)的步骤
如下:
目标U盘的端点地址和pid → SL811寄存器地址0x03
目标U盘的地址 → 地址0x04
811内部RAM中数据缓冲的地址 → 地址0x01
该次数据的长度(不是该帧数据的长度) → 地址0x02
0xff → 地址0x0d
启动发送的命令字 → 地址0x05
当这一次数据成功了后,如果pid是“发送”,则SL811内部RAM中缓冲处的数据都
被发出去了;如果pid 是“接收”,则SL811内部RAM中缓冲处会填满来自U盘的数据,
长度等于上面黑体字第四行之设定。单片机应该在此时及时把SL811的内建RAM中这些数
据读出来,放进单片机自己开辟的内存区域。
看的出来SL811对USB的物理层已经完全封装了,但是设计者还是需要关心很多细节。
对SL811总线式的读写函数太简单,这里不提了。那是我们这种解决方案下最底层的
函数。关键是usb.c中的usbXfer()和ep0Xfer()这两个函数。
2.4.3 usbXfer()函数
借助分析这个函数的实现可以了解USB的传输模型及差错处理。
分析一下它的输入参数。
int usbXfer (BYTE usbaddr,
// USB设备地址0-127
BYTE endpoint,
// 端点的地址 0-15,对于U盘无非就是0,1,2
BYTE pid,
// 数据包token类型,包括setup,pid_IN,pid_OUT
BYTE iso,
// 是否使用等时传输,对于U盘,该项恒否
WORD wPayload,
//本数据包的最大包长,又称净荷。
WORD wLen,
// 待发送或接收的数据的实际长度
BYTE *buffer
// 待发送的数据的缓冲首址,或者将要接收数据的缓冲首址
)
首先要了解pid。Setup型的pid只出现在控制传输阶段,即usb设备复位配置阶段。Pid_IN
和pid_OUT可能出现在控制传输阶段和此后的Bulk传输阶段。顾名思义,pid_IN表示Host
打算通过这次usbXfer,从U盘读进来wLen长度的数据,放入buffer中。Pid_OUT表示Host
打算向U盘控制器发送wLen长度的数据或者写入U盘wLen长度的数据,这些数据已经在
buffer准备好。
其次是要理清wPayload和wLen的关系。wPayload传递过来的是该端点的最大包长,
在前面枚举中分析那32字节的配置描述符时应该已经记录了下来。在USB1.1的规范里只
能是8,16,32,64这四个值其中之一,实际上我根据SL811的打印只见过8字节和64字
节payload的U盘,而且那些8字节U盘只是端点0是8字节,BULK端点也是64字节的。
应该目前来说多数U盘都是64字节的(1.1的范围内)。
言归正传,由于USB设备的端点有最大包长的限制,SL811启动一次数据传输时必须
保证不超过这一限制,因此,在往SL811的0x02地址发送数据长度前,应作一判断,取
wPayload和wLen之中的较小者。C代码为:
xferLen = (BYTE)((wLen>=wPayload) ? wPayload:wLen);
当wLen < payload的时候,一帧数据只需要SL811启动一次传输就能完成。
当wLen > payload的时候,一帧数据就需要SL811启动多次传输才能完成。
接着让我们根据代码来分析usbXfer()函数的流程。建议对照着附件中usb.c的代码
来看。代码的图在前,分析在后,下同。
简单的函数说明,列出了返回值意义。函数开始的变量定义,有些可以顾名思义,有些
等后面用到了再解释。
红框处需要解释一下。EP0_Buf 值为0x10,它的意义是SL811内建RAM的起始地址。
对于SL811的内建RAM只需要用到其中2×payload个字节,而且是掰开两半来轮换使用。
SL811的应用笔记称之为乒乓缓冲。
举个例子,假设手头遇到一个U盘其端点0是8字节payload的。在枚举U盘时host
请求U盘返回它的32字节配置描述符。此时属于wLen大于payload的情形,需要SL811
启动4次传输才能完全把32字节数据收回来。
首先如上图所示,我们初始化data0指向SL811的RAM起始地址,data1初始化为指向
data0 + payload处,并初始化SL811的缓冲指针寄存器为data0。然后启动SL811发起第一
次传输,让SL811把第一批8字节收到data0处,单片机立即读走这8字节;修改SL811的
缓冲指针寄存器为data1,启动第二次传输,把第二批8字节内容收到data1处,单片机也
立即读走此8个字节。依次轮换,直到4次传输过后,32字节完全收了回来。
平心而论这个作法有点多余,既然是单片机立即读走数据的,一直用data0就可以了。
不过上述做法是Cypress公司提供的例程,很多人都照搬了……大家知道是怎么回事就行了。
这就是前面说的取wLen和Payload中的较小者作为一次传输的长度。等效于前面那行
C代码:xferLen = (BYTE)((wLen>=wPayload) ? wPayload:wLen);
Cmd变量是等会要写入SL811的控制寄存器0x00地址的值。对于Pid_IN的token类型
在这里进行预置。sData0_RD = 00100011B,其含义指:该次数据包的sequence bit 定为
DATA0,产生同步帧SOF,数据方向是IN(即读U盘),使能传输(Enable+ARM)。具体
参考SL811的数据手册。
解释一下其中的Sequence bit,USB1.1协议规定,每个数据包(data packet)都必须包
含一个sequence bit,用于纠错。收发双方的软件也要各自维护自己的sequence bit。Sequence
位要么是DATA0,用0表示,要么是DATA1,用1表示(注意此DATA0与前面的乒乓缓
冲的data0没有半点关系,重名纯属巧合)。USB1.1协议第185页描述了何为一次成功的数
据包收发。
在第i个数据包发送前(左图),TX方的seq bit是DATA0,于是它填充数据包的seq bit
为DATA0。RX方成功收到了这个数据包,于是将自己的seq bit切换到1即DATA1,并返
回一个ACK应答表示成功收到了。当TX方收到ACK后,也切换自己的seq bit到DATA1。
右图的第i+1个数据包就类似了。
1.1协议在第186页描述了一次重发数据包的情形。
同样先看左图,第i个数据包由TX发出,但是由于各种原因RX收到坏(corrupted)
了的包。于是RX方拒绝切换自己的seq bit,并返回NAK给TX方(返回stall或者timeout
等情形类似NAK)。此时TX方由于收到的不是ACK,不能切换seq bit,只能仍然以DATA0
的seq bit组装数据包重发,若如右图RX方接收了这个包了,这才是一次成功的收发,RX
和TX各自的seq bit发生切换。
如下图是一次控制传输涉及到的三个packet,其中中间那个是data packet,红框处就是
其sequence bit。
继续分析代码。
对于PID_OUT,1框处就是将待发送的命令填进SL811内部RAM的data0缓冲处。Cmd
的预置同上面PID_IN,只是方向变成了OUT而已。2框处正是上面提到的切换发送方的Seq
Bit,并反映在cmd中。显然这跟协议规定的只有在收到ACK才允许切换seq bit相悖,但
是Cypress公司的官方例程是这样处理的(又被Cypress耍了一道,尽信code不如无code),
有空我会把这部分代码改过来。
对于Setup Token的处理。
当使用等时传输的时候置cmd的某位。但对于玩U盘,这行其实可以删除。
对于控制传输阶段(endpoint = 0),IN或者OUT的pid都使用DATA1的seq bit。而对
于setup pid,都采用DATA0的seq bit。这是由协议规定的。没什么好说的。
终于可以依次填写SL811的寄存器,启动一次传输。下面开始判断发送是否成功。
开始进入while 循环,循环读入SL811的状态寄存器,判断如果是总线复位或者设备
中途拔出则直接返回-1,以示错误。但如果判断到DONE了,表示传输完成,跳出while,
清中断标志,读入本次传输的结果result,并读入本次传输的剩余字节数remainder。
下面将根据result 的各种不同情况进行处理。开始涉及到USB传输的差错处理。
1.返回ACK
返回ACK是最正常的情形。针对不同的pid,有不同的处理。对于OUT和setup token,
如果返回了ACK,函数可以直接返回了,返回wLen。
对于IN token,稍为复杂。但其实前面已经提到过了,由于wLen>payload,需要继续
启动SL811进行传输,并把数据放于乒乓缓冲中。请看代码。
数字1处,修正剩余的wLen,切换seq bit,dataX加1。dataX的作用就是用来计算接
下来应该使用乒乓缓冲中的data0还是data1。
数字2处,判断之前的传输是否一个字节都没收到(应该不会出现这种情况)。否则认
为之前的传输是成功的,xferlen长度的数据全部收到了SL811的RAM缓冲中,赋值给buflen,
告诉单片机从SL811的缓冲中读取buflen长度的内容。
数字3处,仅当wLen还有剩余,且上次的传输已经全部完成remainder为0的情况下,
再次启动SL811的传输。判断dataX的奇偶性就可以判断应该使用乒乓缓冲的data1还是
data0。
数字1处,单片机及时从SL811的RAM中读取刚收下来的内容到buffer中,并更新
buffer的位置。
数字2处,如果wlen或者remainder等于0,认为本次usbXfer 已经IN 了全部需要的
数据,函数成功返回wLen。假如仍未收完,则会返回到前面的while,等待下一次的DONE
完成,再重复对result的判断,直至函数从这里返回。
2.返回NAK
NAK意味着USB设备暂时无法返回数据给host。
根据USB的协议,返回NAK的可能原因有如下:
A) 设备端处于流量控制状态。目前host的数据发送太频密,为防止设备自己的缓冲溢
出,设备向host发送NAK,意为暂时不要再发数据过来,等设备缓一口气。
B) 设备的端点正在忙(Busy)
C)端点没有数据可传输给host。
D)端点进入了halt状态
对于A和B情形,host应该继续尝试向设备重新发包。C情形一般出现在中断传输里。
D情形,host在重试了足够次数后,应该尝试使用clear feature命令清除端点的状态。
程序段判断对NAK的重发次数是否已到达1000次,否则稍稍延时一下,然后重新发
送。重发超过1000次后,返回-40,表明这是NAK重发失败的返回。我之前调试的时候
NAK只设置为最大重发20次,每次延时5毫秒,结果有些U盘很容易返回NAK错误。现
在看来,NAK的重试次数可以很多,重试间隔可以很短,但太多也不适宜,会让人感觉到
U盘的初始化时间很长。任何时候都不要无限重发,会造成死循环。
3.返回Timeout
设备返回timeout的原因一般是收到无法识别的包(unrecognized)或者坏包(corrupted)。
除了重试外没有什么特别的处理方法。重试的代码类似NAK的。如下图。
4.其他的返回,如stall,overflow,EP0_ERROR
这几种错误如果反馈了回来,不是usbXfer函数能够处理的,唯一能做的就是向上一级
函数返回正确的错误代码,让上级调用函数来处理。
至此,usbXfer函数也结束了。如下图。
2.4.4 ep0Xfer()函数
ep0Xfer 顾名思义是专门跟端点0打交道的函数,负责控制传输即枚举U盘的任务,
get_descriptor、set_address、set_configuration、set_interface、get_MAX_LUN、clear_feature
等命令都是用这个函数组装发送的。
控制传输具有前面提到的USB传输模型中典型的三个阶段:主机发送setup token、主
机发送数据包、主机接收来自设备的握手包又或者称是status stage。
圈1处是把输入结构 setup中的成员作必要的字节交换处理,因为字类型的数据有高位
字节和低位字节的大小端对齐问题(我比较懒,直接弄了个数组来做这个事情,一目了然)。
圈2处通过调用usbXfer 把setup token 发去给设备。注意控制传输一定是发给端点0,
setup token的长度都是8字节,例如前面的get descriptor “80 06 00 01 00 00 12 00”。
接着就是传输数据包的阶段。要判断setup token中的第一个字节bmRequest的最高位,
如果是1,表示该命令要求设备向主机返回数据,例如get descriptor命令,此时传递给usbXfer
的pid必须是pid_IN。反之,就是pid_OUT了。
看得出如果usbXfer返回错误(一般是stall,端点0错误,overflow等),ep0Xfer也不
作处理,直接返回FALSE给上一级。事实上如果在configuration阶段都出现这种错误,说
明硬件上有问题,单片机也作不了什么事情,只能报警。
最后就是接收设备返回来的握手包。三个阶段顺利完成后,ep0Xfer函数成功返回。
2.5 块传输(Bulk)
前面提到USB有4种传输类型,块传输是其中之一,普遍用于U盘这样的大容量存储
设备。usbXfer( ) 函数写好以后,块传输的函数就很容易实现。需要实现的函数有两个:
BulkSend( ) 和 BulkRcv( ) 。
顾名思义这是BULK发送的函数。待发送的数据存放于pBuffer起始的地方,总长度是
buflen。函数主体的工作就是把buflen的内容一个pkglen一个pkglen地发出去。
----------------------------------------------------------------------------------------------------------------
这是BULK 接收函数。实现思路非常类似BulkSend( ),不需多说了。
2.6 SCSI命令
2.6.1 跟U盘初始化有关的SCSI命令
就U盘而言,当枚举过程的最后一条命令Get_Max_Lun执行完,开始进入发送SCSI
命令初始化U盘的阶段。详细的SCSI命令集请参考USB Mass Storage Class UFI Command
Specification和SCSI Block Command -2。使用BUS hound软件能完整的分析这一过程。
继续以我手头的爱国者U盘的bus hound打印来分析。见下图。
从U盘上电枚举过程最后阶段说起,即图中数字1的位置。三次Get_MAX_LUN尝试
都遭遇STALL后,windows决定无视Get_MAX_LUN,开始发送SCSI命令了。第一条就
是Inquiry,如图中红色字。命令是用Bulksend( )发送的,总长31字节,其中红框中的小圆
圈处的12H标志着这串数据正是Inquiry命令。Inquiry命令要求返回36字节的设备信息,
即图中的“aigo Miniking 8.07”那串东东了。这是使用BulkRcv ( )接收的。接着看到数字2
的那个红框,这是由U盘返回的Inquiry命令执行完后的状态字CSW(Command Status
Wrapper),也是使用BulkRcv ( )接收的。顺便说说上面那31字节的命令准确描述应该是CBW
(Command Block Wrapper),CBW和CSW的细节可参看USB Mass Storage Class Bulk-Only
Transport Revision。
数字2下面的那个红框就是一条比较容易出问题的命令:ReadFormatCapacity。小圈中
的23H是该条命令的“身份证”。有些U盘在收到这条命令后返回STALL,可以从上图看
得出,windows执行到这里也收到了STALL,因为它紧接着进行了一次REST,如上图蓝色
直线所示。关于这个REST,我查阅了BUS hound软件的帮助、翻看了手头上所有跟U盘
有关协议文本,勾上了BUS hound的其他捕获选项重试捕获,结论是:这是windows USB
驱动层的一次RESET,似乎在硬件上并无产生什么信号或指令给U盘,也不是Command
Block Reset,更不是USB总线的Port Reset。这个地方我一直希望搞清楚USB总线上发生
了什么事情,但苦于没有USB分析仪,有了解的朋友希望能不吝指教。
我在单片机的程序里对ReadFormatCapacity这里的STALL是这样处理的。先执行一次
Command Block Reset,然后连发两条Clear Feature命令清除端点1和端点2的Halt状态。
具体看usbmsc.c中的代码吧,很简单。
继续分析流程。看下图。
蓝带REST处是接着上一张图最后部分的。REST后,windows获取了
ReadFormatCapacity命令的CSW,注意最后一个字节是01H(用圈圈住的),表示U盘对
ReadFormatCapacity的执行出了问题。按照协议,需要主机发送RequestSense,从图中可见
RequestSense命令的“身份证”是03H….RequestSense收到18字节数据和13字节的CSW,
一切正常。Windows于是重试ReadFormatCapacity。这次在收到20个数据后,估计在获取
CSW的阶段,windows再次被U盘STALL了。此时windows又进行了一次RSET(又到了
我不懂的地方了….)。RSET后,就正常收到CSW了。然后执行的命令应该是ReadCapacity,
其“身份证”是25H。这条命令正常执行完后,主机对U盘的初始化终于终于完成鸟。
2.6.2 Read和Write U盘的命令
读和写U盘只实现了“整个扇区地读”和“整个扇区地写”两个函数。两个函数几乎
一样。以读扇区函数为例进行讲解。读扇区的命令原型也是来自SCSI Primary Command -2
技术规范,乃是其中的READ10命令。
如上图所示,SCSI_Read( )的输入参数之中,lba 是地址,等于将要读取扇区的绝对
扇区号,pBuffer指向读入数据的缓冲区。程序开头先执行另外一条SCSI命令TestUnitReady,
该命令功能如其名,测试U盘是否已经准备好。如果连续执行超过3次都失败,则read函
数失败返回。
这一段是在构造总共31字节的CBW。我比较懒,直接用数据一个字节一个字节地去
填,方便省事,就是浪费了一点点程序ROM。
接下来的就是例行公事,依次发送CBW,接收512字节的扇区内容,接收CSW,然
后返回。
严格来说每次CBW发送后,收到CSW时都需要判断CSW的合法性,以及CSW所
返回的命令执行状态。我的demo程序里都没有这样的处理(除了ReadFormatCapacity和
TestUnit)。有兴趣的朋友很容易自己加上去。
2.7 U盘兼容性问题的探讨
到目前为止,我自己还没有彻底解决兼容性问题(我TCL了…)。不过如果有USB
分析仪,有30个以上不同牌子的U盘给我测试的话,想必会有更多的成果分享给大家。附
件中有我通过BUS hound和串口抓下来的十个不同U盘的打印(为了做这件事情,我把周
围同事的U盘都借光了@@)。
U盘兼容性问题,很大程度上是对协议的理解程度问题,我是这样觉得的。如果时间
充足,最好把以下的协议通读理解:(找不到的话就问computer00要吧hiahia)
USB Mass Storage Class UFI Command Specification Revision 1.0;
USB Mass Storage Class Control/Bulk/Interrupt(CBI) Transport Revision 1.0;
USB Mass Storage Class Bulk-Only Transport Revision 1.0;
USB Specification Revision 1.1;
SCSI Block Commands -2 (SBC-2) Revision 8;
USB Specification Revision 2.0;
以下从U盘插入host开始,按事件发生的时间轴顺序介绍我处理兼容性问题的经验。
BUS hound 软件和串口打印是我唯一的武器。
1)检测到U盘插入后,总线复位时间建议持续400毫秒以上。
我帖一下BUS hound抓的windows对USB总线的复位时间就很清楚的说明问题。
爱国者miniking 1G:
金士顿2G:
读卡器带512Msd卡:
爱国者行业特供型1G:
还有很多个U盘,见我的附件中,基本都需要300毫秒左右。保险点就400ms。
2)严格根据第一条Get Device Discriptor命令的返回获取端点0的payload
有些U盘的端点0是8字节payload的,大多数是64字节。对于8字节payload的U
盘,要严格从第一条Get Device Discriptor命令返回的第八字节获取payload,然后传递给
ep0Xfer,否则尽管枚举过程仍然能pass,但是后面在分析32字节的配置描述符时候会出问
题,没有收到完整的配置描述符是分析不出端点1和端点2的地址以及端点payload的。
3)在枚举阶段获取string 描述符的时候,要判断一下。
有些U盘不支持反馈string 描述符,此时要用if语句判断一下,如果获取不到string
描述符就跳过,不要失败返回。
4)枚举阶段,set configuration后不要省略set Interface,有些U盘的固件如果收不到
set Interface死活不让你过,这个问题不少网友的经验帖中已经提及。
5)对于ReadFormatCapacity和ReadCapacity的处理
帖一下发送SCSI命令初始化U盘的那段代码:
如框中所示,假如ReadFormatCapacity返回失败,不要立即失败返回,应尝试
RequestSense,前面已提到了。至于怎样才算ReadFormatCapacity失败,请看下图:
圈1处是经常发生stall的地方了,如果返回了stall,BulkRcv 的返回就是失败,然后
使用两条ClearFeature清除端点1和端点2的HALT状态,注意Bulk_IN端点地址要加上
0x80。如此操作后,接着去到圈2处,去获取CSW,但通常都会获取到最后一个字节非0
(out[12]不等于0),表示本条命令的执行有问题,这样需要返回FALSE,让上级程序调用
RequestSense。
ReadCapacity的处理类似ReadFormatCapacity。
6)进行读和写U盘之前,适当做一些试探性动作
这在某位网友的帖中也提到了,参考BUS hound的打印,看看windows在读写U盘
前做了什么,我们不妨学着做。
自己抓一次BUS hound就知道了,U盘初始化完成后,即使在空闲时,windows也不
停地对之发送TestUnitReady 命令来检查它的状态。我认为在单片机系统中没必要负担这种
开销,但是在SCSI_Read ( ) 和SCSI_Write ( )的开头我也学习windows加入了TestUnit 命
令。
7) 写U盘的稳定性问题
这个问题的根因应该是不同U盘的速度不一样,flash的搽除是相当耗时间的,而且
有的快有的慢。这个情况下只有增加等待时间和重试命令两种途径比较可行。我选择了增加
等待时间,在SCSI_Write ( )函数中发送完CBW后,插入一个100ms的延时(汗,如果都
这样搞,拷个电影要多长时间…)。我这样做是因为我们的应用中对写U盘的速度要求很低。
网友有些是用重发命令的,应该更合理。
第3章 微软的文件系统
3.1 FAT16文件系统简介
文件系统的作用是对文件在介质上的存储进行管理,并为操作系统提供操作函数。我们
的单片机没有内嵌操作系统,但是为了使我们在U盘上存取的信息在接入PC电脑后能被
windows正常访问,单片机必须满足windows对U盘文件系统的操作规范。
前面我们提到可以把U盘看成是以扇区为单位的逻辑盘(1扇区 = 512字节)。那么
我们看看当一个U盘被格式化成FAT16格式的分区后,它在物理上的全貌到底是怎么样的?
以下就是其结构形式图:
必须说明:大多数情形下的FAT16格式U盘其结构就如上图,但也有例外。如果U盘
在格式化时加上了系统选项,带有boot功能,则在保留区前还将有MBR和若干个隐含扇区。
关于上图的各部分简述:
✓ 引导扇区也叫DBR,它对我们最大的意义是存储了本分区的BPB信息。稍后详解。
✓ FAT1是文件分配表主表(FAT = File Allocation Table),记录数据区中每个簇的使
用情况。其大小根据U盘的实际大小而定。
✓ FAT2是主表的备份。大小与主表一致。
✓ 根文件夹存储文件名目录名等目录信息,固定占据32个扇区大小。
✓ 剩下的数据区就是真正存储数据文件内容的区域
我们在操作一个U盘的文件系统前,必须先获取其文件系统信息,这时必须依赖引导
扇区计算出FAT1、FAT2、根文件夹以及数据区它们的起讫地址。以下逐一介绍这几个区域
以及如何计算它们的起讫地址。
(注:对扇区的访问依照其绝对扇区号寻址,绝对扇区号=逻辑扇区号+隐含扇区数,
逻辑扇区号是指不计算MBR等隐含扇区的号,DBR的逻辑扇区号总是0,即DBR总是逻
辑分区的第一个扇区。)
3.1.1 保留区
首先看看U盘被格式化成boot分区的情况,保留区前含有MBR和若干个隐含扇区。
MBR是主引导记录的缩写(Master Boot Record),是物理上第一个扇区(如果它存在的
话),因而绝对扇区号是0,它独立于任何一个分区(或者称“卷”)。MBR的前446字节是
系统引导程序,接着的64个字节就是大名鼎鼎的分区表DPT(Disk Partition Table)。最后两
字节是扇区有效标志55 AA。一个MBR的例图如下:
DPT以每分区16个字节的大小存放着最多四个主分区的信息。对于U盘来说因为一般
只有一个分区,所以其分区信息就存放在DPT的第一个16字节中。其中我们最关心的是偏
移地址为1C6H的信息,它指示着引导记录DBR相对于MBR的偏移地址。以上图为例,
偏移地址1C6H处的值为0000003F(注意是按双字大小存放的),3FH = 63,即十进制值为
63,表示DBR的绝对扇区号 = MBR的扇区号(0)+63。(意即DBR前面有63个隐含扇区)
鉴于MBR有的情况下存在有的情况下不存在,我们在初始化U盘时可以这样处理:先
尝试读入绝对扇区号0的内容,然后判断偏移地址54、55和82、83处的ASCII值,C语言
代码如下:(假设workbuf 数组是读入的扇区内容)
If ( WorkBuf[54] != 'F' &&
WorkBuf[55] != 'A' &&
WorkBuf[82] != 'F' &&
WorkBuf[83] != 'A' )
{ … } // the current sector is MBR
Else
{ … } // the current sector is DBR
如果上述判断为真,表示当前读入的扇区是MBR,否则是DBR。为什么这样判断呢?
因为如果是DBR,偏移位置54和偏移位置82的内容都会是”FAT” 这个字符串,而MBR
则不可能出现这个字符串。
DBR扇区(DOS Boot Record)位于逻辑分区的第0个扇区(总是逻辑分区的第一个扇
区)。DBR从第一个字节开始就依序存储了重要的分区信息,称为BPB(Bios Prameter Block)。
从以下的DBR数据结构定义可以大致看出FAT16文件系统下BPB存储了哪些内容。
typedef struct { // ofs = offset 偏移
uchar bJmpBoot[3]; //ofs:0.典型的如:0xEB,0x3E,0x90。
char bOEMName[8]; //ofs:3.典型的如:“MSWIN4.1”。
uint BPB_wBytesPerSec; //ofs:11.每扇区字节数。
uchar BPB_bSecPerClus; //ofs:13.每簇扇区数。
uint BPB_wReservedSec; //ofs:14.保留扇区数,从DBR 到FAT 的扇区数。
uchar BPB_bNumFATs; //ofs: 的个数。
uint BPB_wRootEntry; //ofs:17.根目录项数。
uint BPB_wTotalSec; //ofs:19.分区总扇区数(<32M 时用)。
uchar BPB_bMedia; //ofs:21.分区介质标识,优盘一般用0xF8。
uint BPB_wSecPerFAT; //ofs:22.每个FAT 占的扇区数。
uint BPB_wSecPerTrk; //ofs:24.每道扇区数。
uint BPB_wHeads; //ofs:26.磁头数。
ulong BPB_dHiddSec; //ofs:28.隐藏扇区数,从MBR 到DBR 的扇区数。
ulong BPB_dBigTotalSec; //ofs:32.分区总扇区数(>=32M 时用)。
uchar bDrvNum; //ofs:36.软盘使用0x00,硬盘使用0x80。
uchar bReserved1; //ofs:37.保留。
uchar bBootSig; //ofs:38.扩展引导标记:0x29。
uchar bVolID[4]; //ofs:39.盘序列号。
char bVolLab[11]; //ofs:43.“Msdos ”。
char FileSysType[8]; //ofs:54.“FAT16 ”。
uchar ExecutableCode[448]; //ofs:62.引导代码。
uint EndingFlag; //ofs:510.结束标识:0xAA55。
}DBR_tag;
其中红色字部分是计算FAT1起始地址、根目录起始地址、数据区起始地址的重要参数。
计算公式如下:
文件分配表 ≡ 保留扇区数
根目录 ≡ 文件分配表起始号 + FAT 的个数×每个FAT 的扇区数
数据区 ≡ 根目录逻辑扇区号 + 32
根据下图将很容易理解上述公式的含义:
更详尽的BPB介绍可参考《FAT32文件系统规格书》。这是由夏新的工程师khalai翻译的
微软的FAT32白皮书。
3.1.2 FAT区
FAT区包含主表和备份表,两者的内容是完全一致的。单片机若要更新主表必须同时更
新备份表。FAT表的作用是记录数据区中每个簇的使用情况,以及维护簇链的链式关系,是
FAT文件系统的核心。
何为簇?简单的理解就是文件系统固定将若干个连续的扇区定义为一个簇,譬如4个扇
区为一簇;究竟该将多少个扇区定义为一簇,依赖于整个分区的大小,以下为FAT16的原
则:
定义了簇后,再定义:为了存储每个文件,最小分配单位是簇,也就是说即使一个只有
几字节大小的文件,也是分配一个簇的空间来存放。之所以簇为单位而不以扇区为单位进行
磁盘的分配,是因为当分区容量较大时,采用大小为512B的扇区管理会增加fat表的项数,
对大文件存取增加消耗,文件系统效率不高。
有了以上定义后,再来看fat 表就容易理解了。FAT16系统下的FAT表以16bit宽度的
数值记录每个簇的使用情况,即数据区中的每一簇在FAT表中占据2字节(二进制16位)以
存放信息。所以,FAT16最大可以表示的簇号为0xFFFF(十进制的65535),以32K为簇的
大小的话,FAT16可以管理的最大磁盘空间为:32KB×65535=2048MB,这就是为什么FAT16
不支持超过2GB分区的原因。对于目前越来越大的U盘,超过2G是经常的事情,所以如
果做Host,一定要同时支持FAT16和FAT32。
FAT表实际上是一个数据表,以2个字节为单位,我们暂将这个单位称为FAT记录项,
通常情况下其第1、2个记录项(前4个字节)用作介质描述。从第三个记录项开始记录除数
据区文件存储的簇链情况。根据簇的表现情况FAT用相应的取值来描述,见表10
看一幅在winhex所截FAT16的文件分配表,图10:
如图,FAT表以"F8 FF FF FF" 开头,此2字节为介质描述单元,并不参与FAT表簇链
关系。小红字标出的是FAT扇区每2字节对应的簇号。
相对偏移0x4~0x5偏移为第2簇(顺序上第1簇),此处为FF,表示存储在第2簇上的文件
(目录)是个小文件,只占用1个簇便结束了。
第3簇中存放的数据是0x0005,这是一个文件的首簇。其内容为第5簇,就是说接下来
的簇位于第5簇 → FAT表指引我们到达FAT表的第5簇,上面写的数据是"FF FF",意即
此文件已至尾簇。
第4簇中存放的数据是0x0006,这又是一个文件或文件夹的首簇。其内容为第6簇,就
是说接下来的簇位于第6簇 → FAT表指引我们到达FAT表的第6簇,上面写的数据是
0x0007,就是说接下来的簇位于第7簇 → FAT表指引我们到达FAT表的第7簇……直到
根据FAT链读取到扇区相对偏移0x1A~0x1B,也就是第13簇,上面写的数据是0x000E,
也就是指向第14簇 → 14簇的内容为"FF FF",意即此文件已至尾簇。
后面的FAT表数据与上面的道理相同。不再分析。
理解了FAT16的FAT链式存储,FAT32就同理了,区别仅仅是FAT表的簇项记录的宽
度是32bit(占据4字节)。
更详细的介绍簇链可参考文章《4.5万字透视FAT32》。
3.1.3 根文件夹
也称文件目录表FDT(File Directory Table),顾名思义,它主要存放文件和目录的信息,
我们在windows中看到的文件名、创建时间、文件大小等信息即存放于此。
文件目录分两类:根目录和子目录,为描述简单起见这里只讨论根目录和短文件名,子
目录和长文件名可参考微软的FAT白皮书轻松理解。
FAT16的根文件夹固定占有32个扇区的空间,它使用32字节存储每笔记录项,因此
FAT16系统中最多只能处理32×512÷32=512个目录项。
32字节的目录项数据结构定义如下:
typedef struct{
char FileName[8]; //ofs:0.文件名
char ExtName[3]; //ofs:8.扩展名
uchar attribute; //ofs:11.文件属性。典型值:存档(0x20)、卷标(0x08)。
char reserved[10]; //ofs:21.保留
uint time; //ofs:22.时间
uint data; //ofs:24.日期
uint StartClus; //ofs:26.开始簇号
ulong FileLength; //ofs:28.文件长度
}DIR_tag;
3.1.4 数据区
数据文件的内容将不连续地存放于此。
3.2 FAT32文件系统简介
FAT32与FAT16的区别主要有以下几点:
(1) 保留扇区数,FAT16只有引导扇区即DBR,而FAT32除了引导扇区外还有31个保
留扇区(有些U盘是63个)。主要是留给操作系统进行DBR的备份以及存放其他配置。
(2) FAT表项从16bit增加到32bit,即数据区的每簇占据FAT表4个字节。簇链结构同
FAT16一样。
(3) 取消了根文件夹区,把文件目录项信息放入数据区中,等同普通文件一样看待,进
行链式存放。这样就突破了512的限制。
3.3 FAT文件系统的局限性
FAT16和FAT32文件系统虽然现在仍然普遍使用,但是本身有缺憾。最大的缺憾我认
为是对于异常掉电情况很难处理。用过Windows的人都知道如果硬盘上某个分区是FAT16
或FAT32格式的话,假如电脑死机或者异常重启(这种情况实在太普遍了),重启后windows
都要对此分区进行扫描。这正是因为FAT文件系统无法保证异常掉电情况下文件系统的完
好性。设想操作系统刚好为某个文件分配了一个簇,这时候掉电了,那这个簇以后都不能再
用了;设想操作系统刚好写完了一个文件的所有数据簇,正准备更新文件大小的时候掉电了,
那这个文件就相当于残废了……所以微软只好通过重启扫描的办法来勉强维持住FAT文件
系统的完好性。NTFS则没有这个问题,一些嵌入式系统的FS也可以解决这个问题,具体
我没有去研究过。
第4章 编码实例分析
4.1 需求简述
下面让我们以一个简单的程序为例,分析如何编码。程序的功能是:等待U盘插入后,
搜索根目录下名为的文件,打开它,并复制其内容到另一个名为
的新文件中。
4.2 文件结构
4.3 Main.c
具体请参照main.c的代码。此处不贴。
用到两个在别的文件定义的标志位:DevAttached 和 IsFat16。前者可判断是否插入了
U盘,后者判断U盘的格式是FAT16还是 FAT32。
初始化一个文件目录信息数组File_To_Creat[32],待会新建文件就根据这个数组。
InitTimer( ) 、InitComm(38400)、InitSL811 ( ) 初始化硬件。
进入程序主循环,while (!DevAttached) 等待U盘插入;
U盘插入后,IsMassDev() 实行对U盘的枚举过程,若失败,等待U盘拔出后重试;
枚举完成后,InitMassDev() 分析U盘描述符并进行变量初始化,允许失败重试3次,
如果超过3次,同样等待拔出U盘重试;
File_Open("CONFIG TXT") 打开文件 ;
接下来根据U盘的格式是FAT16或是FAT32,新建一个名为的文件,
将 的内容写入。
4.4 USB.c
主要的usbXfer ( )和ep0Xfer ( )都已经在前面解释得很清楚。其他的就是控制传输命
令的构建了,都是通过VendorCmd( )组装。以SetAddress 命令为例进行说明。SetAddress 是
枚举初期USB主机发送给设备的一个请求(Request),该请求是封装在setup包中通过控制
传输从默认地址0发送给USB设备的。Setup包的数据结构如图所示(截自USB1.1协议文
本)。
根据上表,setaddress函数设计如下:
static BOOL SetAddress (BYTE addr)
{
return VendorCmd (0,0,SET_ADDRESS, (WORD)addr, 0, 0, NULL);
}
4.5 timer.c
包括一个全局变量nMsTicks和四个函数: InitTimer、DelayMs、ShortDelay和
Timer_ISR。
nMsTicks 每流逝1毫秒进行加一计数,是系统的时间滴嗒。InitTimer 初始化定时器
的计数变量,DelayMs用于毫秒级的延时,ShortDelay用于NAK或者Timeout的重发之间
的短延时,Timer_ISR是定时器中断处理程序,都比较简单。
4.6 filesys.c
这是对文件系统操作的核心文件。
4.6.1 变量说明
文件系统参数:
xdata BYTE WorkBuf[512]; // 存放ReadSector()所读入的扇区内容
xdata DWORD VolumeStarts; // 卷起始,即DBR的绝对扇区号
xdata DWORD FatStarts; // FAT表起始的扇区号
xdata DWORD RootStarts; // 根目录起始扇区号
DWORD DataStarts; // 数据区起始扇区号
BYTE SectorsPerCluster; // 每簇的扇区数
BOOL IsFat16; // U盘格式标志位,fat16 = 1, fat32 = 0
WORD BPB_FATSz16; // 一个FAT16表的体积,即其占据的扇区数
WORD BPB_FATSz32; //一个FAT32表的体积
处理文件用到的相应变量:
DWORD FileSize; // 记录从根目录获得的文件大小,单位是字节
DWORD FilePtr; // 文件指针,记录当前操作的是文件中的第几字节
DWORD NextSector; // 即将操作当前文件的下一个扇区
DWORD NextCluster; // 即将操作当前文件的下一个簇
DWORD nextcluster_root; // 根目录簇链的下一簇(仅用于FAT32下)
WORD RootEntSector; // 记录某文件目录信息在根目录中保存的扇区位置
WORD RootEntByteOffset; // 某文件目录信息的扇区内偏移
4.6.2 扇区读写函数
ReadSector (DWORD SectorNum) 根据逻辑扇区号读入该扇区内容→WorkBuf[512]。
注意是逻辑扇区号。SCSI_Read ()是读入绝对扇区号的内容。
WriteSector (DWORD SectorNum, BYTE xdata *pBuffer) 把pBuffer 指向的512字节内
容写入扇区号为 SectorNum的扇区。pBuffer都是用WorkBuf[512]数组。
4.6.3 询问下一簇号函数
GetNextCluster_16 (DWORD Cluster) 输入当前簇号Cluster,寻找cluster在FAT16簇
链中的下一簇。
static WORD GetNextCluster_16 (DWORD Cluster)
{
DWORD SectorOffset;
WORD ByteOffset;
SectorOffset = FatStarts + Cluster /256; // ---- (1)
ByteOffset = (Cluster % 256)*2 ; // ---- (2)
if (ReadSector(SectorOffset))
return W_BUF(WorkBuf,ByteOffset);
else
}
return 0xfff7;
(1) 计算cluster簇的记录是位于FAT16表的第几个扇区,因为对于FAT16而言每扇
区可存放 512÷2 = 256笔簇记录,所以只需对cluster除256取整即可。
(2) 再计算cluster簇这笔记录位于SectorOffset中的第几字节,只需对cluster模256,
再由于每簇占用2字节存放,因此再乘以2。
FAT32下的GetNextCluster_32 (DWORD Cluster) 函数就同理了,所不同的是FAT32表
每扇区只能存放128笔簇记录。每笔簇记录占4字节。
4.6.4 FAT初始化函数(BPB信息分析)
无论U盘格式如何,都是先调用FAT_Init()函数。先分析该函数流程:
static BOOL FAT_Init ()
{
// 读入主引导记录MBR并检查合法性
if (!SCSI_Read(0, WorkBuf)) // 能读入MBR吗?
return FALSE; // 失败
if (WorkBuf[510]!= 0x55 || WorkBuf[511] != 0xaa) // MBR扇区有效吗?
return FALSE; // 无效
// 先判断该扇区是否为DBR
If ( WorkBuf[54] != 'F' &&
WorkBuf[55] != 'A' &&
WorkBuf[82] != 'F' &&
WorkBuf[83] != 'A' )
{
// 是MBR,则从偏移454处得到DBR的位置
VolumeStarts = DW_BUF(WorkBuf,454);
}
else
VolumeStarts = 0;
if (!ReadSector(0)) //读入DBR
return FALSE;
if (WorkBuf[510]!= 0x55 || WorkBuf[511] != 0xaa) // DBR扇区有效吗?
return FALSE; // 无效
if (W_BUF(WorkBuf,11) != 512) // 只处理每扇区字节数为512的U盘
return FALSE;
if(W_BUF(WorkBuf,22) == 0x00) // 分区为FAT16格式吗? --- (1)
{
IsFat16 = 0;
return (Init_fat32());
}
else
{
IsFat16 = 1;
return (Init_fat16());
}
}
(1) DBR的偏移22处存放着FAT16表所占的扇区数,而对于FAT32格式U盘的DBR,
该参数为0。因此这里据此判断U盘的格式是FAT16还是FAT32。
可见在FAT_Init()函数的最后,判断了U盘的格式是FAT16还是FAT32,然后各自执行
其初始化函数,以下以Init_fat16() 为例进行分析。
static BOOL Init_fat16 (void)
{
if (!ReadSector(0)) //读入DBR
return FALSE;
if (WorkBuf[510]!= 0x55 || WorkBuf[511] != 0xaa) // DBR扇区有效吗?
return FALSE; // 无效则返回
if (! ( WorkBuf[54]=='F' && // 再次确认为FAT16
WorkBuf[55]=='A' &&
WorkBuf[56]=='T' &&
WorkBuf[57]=='1' &&
WorkBuf[58]=='6'
)
) return FALSE;
// 计算FAT表开始位置
FatStarts = W_BUF(WorkBuf,14); // 加上保留扇区数
// 计算根目录表的开始位置
// RootStarts = FatStarts + FAT表个数 * 每FAT表所占的扇区数
BPB_FATSz16 = W_BUF(WorkBuf,22);
RootStarts = FatStarts + (DWORD)WorkBuf[16] * (DWORD) BPB_FATSz16;
// 计算数据区的开始位置
// 数据区 = 根目录 +32 * 根目录中目录项数 / 每扇区字节数
DataStarts = RootStarts + 32 * (DWORD)(W_BUF(WorkBuf,17) / 512 );
SectorsPerCluster = WorkBuf[13];
return TRUE;
}
对于FAT32,区别仅是一些参数的偏移位置不一样而已。全面的BPB信息分析可参考
《FAT32文件系统规格书》的第二页。
4.6.5 Open Files
类似初始化FAT的函数,打开文件也是从File_Open(BYTE * FileName) 函数入口,再
分FAT16和FAT32处理。
直接看FAT16下的打开文件函数:
static BOOL File_Open_16 (BYTE * FileName)
{
for (s=RootStarts; s { if (!ReadSector(s)) // 逐个扇区读入 return FALSE; for (i=0;i<16;i++) // 每扇区有16个目录项 { if (WorkBuf[32*i+0] == *FileName && // 比较文件名 WorkBuf[32*i+1] == *(FileName+1) && WorkBuf[32*i+2] == *(FileName+2) && WorkBuf[32*i+3] == *(FileName+3) && WorkBuf[32*i+4] == *(FileName+4) && WorkBuf[32*i+5] == *(FileName+5) && WorkBuf[32*i+6] == *(FileName+6) && WorkBuf[32*i+7] == *(FileName+7) && WorkBuf[32*i+8] == *(FileName+8) && WorkBuf[32*i+9] == *(FileName+9) && WorkBuf[32*i+10]== *(FileName+10)) { FileSize = DW_BUF(WorkBuf+32*i,28); // 获取文件大小 if (FileSize ==0) // 文件非空判断,可选 return FALSE; FilePtr = 0; // 复位文件指针 NextSector = DataStarts; // 初始化 NextCluster = W_BUF(WorkBuf+32*i,26); // 指向该文件第一簇 return TRUE; } } } return FALSE; } FAT32的情况下,由于根目录是从数据区的第二簇开始以链式存储,跟普通文件一样, 因此在搜索文件名时跟FAT16不一样,需要从第二簇开始一簇一簇地往下搜。 static BOOL File_Open_32 (BYTE * FileName) { DWORD s; int i,j; } while (nextcluster_root < 0x0fffffff) //根目录簇链尚未到链尾 { s = RootStarts + (nextcluster_root -2)*SectorsPerCluster; for (j=0; j { if (!ReadSector(s)) return FALSE; s++; for (i=0;i<16;i++) // 每扇区有16个目录项 { if (WorkBuf[32*i+0] == *FileName && // 比较文件名 WorkBuf[32*i+1] == *(FileName+1) && WorkBuf[32*i+2] == *(FileName+2) && WorkBuf[32*i+3] == *(FileName+3) && WorkBuf[32*i+4] == *(FileName+4) && WorkBuf[32*i+5] == *(FileName+5) && WorkBuf[32*i+6] == *(FileName+6) && WorkBuf[32*i+7] == *(FileName+7) && WorkBuf[32*i+8] == *(FileName+8) && WorkBuf[32*i+9] == *(FileName+9) && WorkBuf[32*i+10]== *(FileName+10)) { FileSize = DW_BUF(WorkBuf+32*i,28); if (FileSize ==0) return FALSE; FilePtr = 0; NextSector = DataStarts; NextCluster = (DWORD)W_BUF(WorkBuf+32*i,26) + (((DWORD)W_BUF(WorkBuf+32*i,20))<<16 ); return TRUE; } } } //获取下一个根目录簇链 nextcluster_root = GetNextCluster_32 (nextcluster_root); } return FALSE; 4.6.6 创建文件 以FAT16的文件创建程序为例。待创建的文件目录信息从pBuffer带入。 BOOL CreateFile_16 ( DIR_INFO *pBuffer ) { 号 unsigned long s = RootStarts; // s 指向根目录表头 unsigned int i; unsigned char j; pBuffer -> name[0] = 'N'; // 名固定为 ,因需而异 pBuffer -> name[1] = 'E'; pBuffer -> name[2] = 'W'; pBuffer -> name[3] = 'F'; pBuffer -> name[4] = 'I'; pBuffer -> name[5] = 'L'; pBuffer -> name[6] = 'E'; pBuffer -> name[7] = ' '; pBuffer -> extension[0] = 'T'; pBuffer -> extension[1] = 'X'; pBuffer -> extension[2] = 'T'; pBuffer -> startCluster = GetFreeCusterNum_16 (); // 从FAT表查找一个空闲簇的簇 if (pBuffer -> startCluster < 0x2) // 簇号检查,不应该小于2 return FALSE; // 所分配的空闲簇无误后,存入文件目录信息中 pBuffer -> startCluster = SwapINT16(pBuffer -> startCluster); // 在根目录中查找空闲位置创建目录信息 while (s < RootStarts+32) // fat16的根目录通常是含有32个扇区 { if (!ReadSector(s)) // 逐个扇区地读取根目录项信息 return 0x0; for (i=0; i<512; i=i+32) { if (WorkBuf[i] == 0xe5 || WorkBuf[i] == 0x0) // 空闲位置以e5或00打头 { for (j=0; j<32; j++) { WorkBuf[i+j] = *(((BYTE *)pBuffer) + j); // 复制目录信息 } RootEntSector = s ; RootEntByteOffset = i; //记录下该文件的根目录入口以便再次访问 if (!WriteSector( s, WorkBuf)) // 更新根目录信息 return FALSE; return TRUE; } } s++; } } FAT32下的区别仅仅是在于查找根目录空闲位置时,需要从第二簇开始按簇链搜索。详 细可参考代码。 4.6.7 写入文件 看到这里,应该不难理解File_WriteFile_16 和 File_WriteFile_32这两个函数了。代码 中的注释很清楚。 第5章 使用CH375的解决方案 5.1 只作简单介绍 CH375是南京沁恒电子出品的USB总线通用接口芯片,兼容USB2.0。详细内容我不多 说了,我只讲讲使用CH375的感受。 CH375把USB、BULK、SCSI、文件系统的东西都封装了起来,提供一个库文件给用 户调用其中的程序。这样对于项目进度很急的设计师是个好消息,开发速度相当快,一个小 时内就能上手。但是对于学习USB则是不利的,可以说USB的东西你都不用关心了。 U盘兼容性方面,虽然CH375只试过十几个U盘,但还没出现读写不了的。而SL811 的方案,一直在改,一碰到反馈有问题的U盘,又得改,改到有时候都没有办法了…… 读写稳定性方面,CH375的方案只进行过最长6个小时的拷机,即一直的读数据然后 从串口接收,暂时没发现过问题。SL811的方案只要初始化通过的U盘,都还没试过读写 不稳定,最长拷机时间30个小时。 EMC,高温,老化测试方面,CH375的方案没做过。SL811的方案通过测试。