2024年1月5日发(作者:)

第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的方案通过测试。