——是时候整理一下一团乱麻的存储介质了<1>
经常从事嵌入式开发的同学们应当会频繁触碰到一个痛点:数据持久化(掉电存储),最常见的应用场景就是配置信息或者控制器参数的保存和读取——依赖于便写固件时一次性的将PID参数写死而造成的后续调参体验真的太糟糕了。我们首先需要分析FATFS底层的逻辑组织形式!
1. 从文件系统这件事开始说起
学过操作系统(Operating System)这门课的同学们都知道,在一个操作系统中文件系统是至关重要的,并且基于文件系统的管理我们可以实现很多炫酷的效果。这里就不得不说Linux系统的设计理念——一切都是文件(包括硬件,你甚至可以在/dev目录找到你的CPU)确实令当时作为初学者的我耳目一新。
当我们在谈论计算机存储和数据管理的时候,有这样一种管理数据的方法,被我们称之为文件系统,就像是数据库一样,一个最基本的文件系统必须实现增删查改四大功能。说到底文件系统实际上是一种中间件(Middleware):我们使用某种介质存储数据,直接操作这种介质的部分,例如读取寄存器的引脚电平的高低,就被称之为驱动(Driver),很显然这部分是直接面向物理层的。但是作为日常被硬件工程师鄙视的”柔弱而多事“的软件工程师,他们其实更依赖于逻辑接口。
我们举一个很简单的例子:我们现在通过SPI接口去操纵一块Flash,这个Flash芯片分为很多页(Page)和扇区(Sector),我们假设一个Page是4KB也就是4096Byte。当软件工程师想要写入一个6300Byte的文件时,通常来说他的思维过程很简单:判断有无6300Byte及以上的剩余空间,fopen打开文件,fprint写入文件,最后fclose关闭文件。但是对于面向底层的硬件工程师或者说嵌入式开发人员来说,事情远远没有这么简单,他们显然会考虑一些令人心肌梗死的问题:
- 判断有无剩余空间——你怎么知道哪些空间是占据的哪些空间不是?0xFF是Flash的默认取值不假,但是有没有可能0xFF本身也是用户存储的数据呢?
- 判断有无剩余空间——假设我现在已经确认了有剩余空间,只不过一段空间长度3072Byte,而另一段长度为6144Byte,理论上是放得下的,但是我怎么”整理“出空间来呢?
- 还有很多很多:文件名如何存储?复杂的目录嵌套关系如何记录?……
所以文件系统的本质是处理硬件驱动暴露出来的接口,抽象后形成新的,便于理解和使用的逻辑接口暴露给应用层开发人员进行使用。上层应用、文件系统、底层驱动、物理电路的关系大概是这个样子的:
通过上图我们可以了解到,文件系统的本质就是中间件(Middleware)或者说抽象层(Abstract Layer),那么我们在嵌入式系统中实现文件系统的主要手段就是进行逻辑接口到驱动接口的映射。文件系统中有很多基本概念,这些概念组成了基本的逻辑结构供用户使用。常用的基本概念如下所示:
- 文件和目录:文件系统中最终存储的数据我们称之为文件(File),而逻辑上存放文件的容器我们称之为目录(Directory),当然容器与容器之间是可以形成嵌套关系的。
- 层次与路径:很显然无论一个容器内部存放的是数据还是其他的容器,这种存放位置的传递关系一定呈现出树状结构。有同学可能会想到Windows系统中的快捷方式,Linux系统中的链接,MacOS中的替身,但是需要指出的是,这些都是一种“指针类型的数据”而非破坏了树状结构本身。在这棵树的结点继承关系中,文件一定是树的终端结点,而从根节点寻路到文件所处的终端结点所途径的层次关系就称之为路径(Path)。
- 属性和元数据:作为文件本身可以分为两部分:真正的数据部分和用于辅助文件管理的描述部分。后者一般被称为属性(Attributes)或者元数据(MetaData),例如文件名称,文件大小,创建修改时间,访问权限等等。
- 操作与分配:文件系统必须提供基础的文件操作管理,也就是增删查改四大功能。同时,文件系统需要向下对接底层驱动,故而必须决定在物理介质上如何分配空间的问题。
2. FATFS的组织形式
所谓FATFS就是:File Allocation Table File-System既文件分配表格式下的文件系统。当我们讨论文件系统的时候,我们讨论的是一种在物理存储介质和逻辑存储空间之间的映射关系,首先我们需要明确这两侧的单位划分:
- 物理介质:任何物理存储介质,无论是机械磁盘、FLASH闪存还是固态硬盘,其介质都被划分为若干个最小存储单元,这些单元被称为扇区(Sector)
- 逻辑空间:在逻辑上,一片可以存放数据的存储空间也被划分为若干个最小单元,这些单元称之为簇(Cluster)
- 对应关系:通常来说,一个Sector只会有一个对应的Cluster,不会出现一个物理扇区对应到多个逻辑簇的情况。但是为了提高读写性能等原因,一个Cluster可以由多个物理上相连(相邻)的Sector构成。
FATFS通常有四个核心部件:引导扇区(Boot Sector),文件分配表(File Allocation Table),根目录(Root Directory)[在FAT32中没有]和数据区(Data Aera)构成,完整包含所有的核心区域的介质称之为一个存储卷(Volume)。卷内的区域映射关系如下所示:
2.1 引导扇区Boot Sector
引导扇区(Boot Sector):这个部分是文件系统的起始部分,也就是BPB(BIOS Paramter Block),这个扇区同城被称为VBR(Volume Boot Record)或者PBR(Private Boot Record),储存在介质上的首个Sector。引导扇区将会占用512字节大小的空间,无论是FAT12、FAT16还是FAT32。引导扇区的内容可能包括以下几类:
- 引导程序代码:例如启动介质内操作系统的指令或者如果介质单纯存储静态数据,可能这里出现的是NOP
- OEM名称:用于标志创建此文件系统的操作系统或者开发者的标志信息
- 扇区大小:标定了每个Sector占用多少个Byte
- 簇大小:标定了每个Cluster占用了多少个Sector
- 保留扇区数目:标定了除Boot Sector和存储数据用的Data Aera之外有多少个Sector用于存放FAT和根目录
- FAT数量:文件系统中FAT表的数目
- 媒体描述符:用于描述存储介质的物理类型
- FAT开始位置:通常来说只记录FAT1也就是主表的开始位置,其他的备份表可以通过读取主表并且解算获得
- 其他保留字段
在Boot Sector的前36个Byte,也就是0~35部分是FAT12/16/32共同执行的标准,而从第36字节开始FAT32就和FAT12/16的数据组织方式变得有所不同起来,如下展示的是相关表格。
Offset [0~35] Boot Sector表格
字段名称 | 偏移地址 | 字段长度 | 字段描述信息 |
BS_JmpBoot | 0 | 3 | 引导程序的跳转信息,可以分为长跳转和段跳转,用于x86指令集。短跳转为0xEB 地址Byte 0x90(NOP);长跳转为0xE9 地址Byte1 地址Byte2 |
BS_OEMName | 3 | 8 | 表示创建Volume的OS名称,常见为”MSWIN 4.1″有时候也会使用”MSDOS 5.0″,虽然一些FAT驱动会引用这个名称但微软并不特别关注这个关键字,这个字段可以写成其他的8 Byte信息。 |
BPB_BytsPerSec | 11 | 2 | 表示扇区大小,也就是Sector包含的Byte数目,有效值可以是512、1024、2048、4096这几种。微软支持以上所有大小,但是特定的FAT驱动程序可能默认Sector为512然后并不关心这个字段。所以采用512可以获得最大的兼容性,但永远应该保证它和物理介质Sector一致。 |
BPB_SecPerClus | 13 | 1 | 表示簇大小,也就是Cluster包含Sector的数目。这个数目必须是2的整数幂例如1,2,4……128。但是需要注意的是,应当谨慎地保证每个Cluster的大小不超过32KB,虽然Windows支持超过32KB大小的Cluster,但是一些老旧的驱动或者MS-DOS并不支持这种做法 |
BPB_RsvdSecCnt | 14 | 2 | 用于保留区的Sector的数量。这个数目必不为0,显然BootSector本身就是保留区的一部分,保留区是不能够存储数据的。对于FAT12/16来说这个字段的典型值为1,但是对FAT32来说就是32了 |
BPB_NumFATs | 16 | 1 | 文件分配表(FAT)的数量,典型值为2,不建议更改。FAT1为主表,FAT2为备份表,提供了数据冗余性,保证了安全。有些FAT驱动程序会忽略这个字段直接认为FAT有2个,所以不建议更改。 |
BPB_RootEntCnt | 17 | 2 | 代表着32Byte大小的根目录节点的数量,这个值应当保证对齐到偶数扇区,也就是BPB_RootCnt*32=(2k)*BPB_BytsPerSec。 FAT16建议设置为512,FAT32必须设置为0 |
BPB_TotSec16 | 19 | 2 | 标定了本Volume所有区域占用的总的Sector数量,注意到本字段大小只有16bit,所以一旦Sector数目大于0x10000也就是64K时这个字段将设置为0,并且将真实值存储在BPB_TotSec32中,FAT32的这个字段必须设置为0。 |
BPB_Media | 21 | 1 | 0xF0代表不分区的可移动磁盘,0xF8是不可移动磁盘的标准值。除此之外0xF9~0xFF都是有效值。注意相同的值必须放入FAT[0]的低8bit所在字节;这个字段仅用于MS-DOS1.0,现在已经废弃。 |
BPB_FATSz16 | 22 | 2 | FAT表格占据的Sector上的数量,这个设置仅对FAT12/16生效。对于FAT32来说这个字段必须设置为0,然后使用BPB_FATSz32计算大小。最终FAT大小=BPB_FATSz16/32 * BPB_NumFATs个Sector |
BPB_SecPerTrk | 24 | 2 | 每个磁道的扇区数。这个字段只与具有特定几何结构的介质有关,仅用于IBM PC的磁盘BIOS。在现代磁盘上上可以认为废弃 |
BPB_NumHeads | 26 | 2 | 磁头数目。同上个条目 |
BPB_HiddSec | 28 | 4 | FAT存储卷前端隐藏掉的物理Sector数目,怎样设置取决于适用平台,如果卷存储位置从物理介质的头部开始,设置为0即可。 |
BPB_TotSec32 | 32 | 4 | 参考BPB_TotSec16字段 |
Offset [36~512] Boot Sector FAT12/16表格
字段名称 | 偏移地址 | 字段长度 | 字段描述信息 |
BS_DrvNum | 36 | 1 | 本字段表示卷的驱动代号,0x00表示可移动磁盘,0x80表示固定磁盘,事实上具体怎么设置怎么使用依赖于操作系统本身。 |
BS_Reserved | 37 | 1 | 保留字,设置为0 |
BS_BootSig | 38 | 1 | 扩展引导签名(0x29),代表接下来的三个字段存在 |
BS_VolID | 39 | 4 | 存储卷ID,经常用于标定可移动存储设备的唯一身份,这个字段通常是FAT存储卷被创建出来的时候根据时间戳统一生成的 |
BS_VolLab | 43 | 11 | 11Byte的存储卷标签信息,本字段应该和Root Directory的记录相同。事实上现代Windows系统并不进行这个同步动作,只有老MS-DOS才这样做。默认值为”NO NAME” |
BS_filSysType | 54 | 8 | 设置为字符串”FAT12″、“FAT16″或者”FAT” |
BS_BootCode | 62 | 448 | 引导程序,如果没有引导程序,设置为全0 |
BS_BootSign | 510 | 2 | 0xAA55 Boot Sector签名 |
512 | 如果Sector的大小大于512,剩余空间应当设置为全0 |
Offset [36~512] Boot Sector FAT32表格
字段名称 | 偏移地址 | 字段长度 | 字段描述信息 |
BPB_FATSz32 | 36 | 4 | 参照第一个表格中BPB_FATSz16字段的说明。 |
BPB_ExtFlags | 40 | 2 | 标志哪些FAT是激活的,高8bit是保留字段,设置为0。如果bit7为0代表每个FAT都是活动的,而且被镜像备份了;如果bit7为1代表只有bit[3:0]指代的FAT为活动的,假设[3:0]=0001就代表FAT2是活动的,FAT1是未被使用的。bit[6:4]一定为0,保留。 |
BPB_FSVer | 42 | 2 | FAT32版本号。高八位是主要的版本号,低八位是小版本号,例如Ver3.5就会是[0x03 0x05],但是FAT32版本其实已经不会更新了。 |
BPB_RootClus | 44 | 4 | 根目录存放点的第一个Cluster的编号,典型值是2,但也可是其他值 |
BPB_FSInfo | 48 | 2 | 存放文件系统信息结构体的Sector的编号,典型值是1(因为Sector 0存放的是Boot Sector的信息) |
BPB_BkBootSec | 50 | 2 | 存放Boot Sector备份信息的Sector编号,通常来说是6,不推荐使用其他的值更改这个选项。 |
BPB_Reserved | 52 | 12 | 保留,设置为0 |
BS_DrvNum | 64 | 1 | 同FAT12/16表 |
BS_Reserved | 65 | 1 | 同FAT12/16表 |
BS_BootSig | 66 | 1 | 同FAT12/16表 |
BS_VolID | 67 | 4 | 同FAT12/16表 |
BS_VolLab | 71 | 11 | 同FAT12/16表 |
BS_FilSysType | 82 | 8 | 应当设置为”FAT32″ |
BS_BootCode32 | 90 | 420 | 同FAT12/16表BS_BootCode字段 |
BS_BootSign | 510 | 2 | 同FAT12/16表 |
512 | 同FAT12/16表 |
在以上表格中,显然我们会注意到Boot Sector的510Byte是签名字节,有些说法说检测Boot Sector的倒数第几个字节即可,这种说法是部分错误的。我们需要考虑一种状况就是Sector本身大于512字节,例如1024B,那么这个时候签名仍然出现在510字节,并且为0xAA55。所以检测/写入510和511字节是更加稳妥,兼容性最大的做法。依据BPB的信息,我们可以做出许多简单的计算:
FATFS Sector偏移计算代码
//文件分配表FAT起始Sector:
FatStartSector = BPB_ResvSecCnt;
//文件分配表FAT占据Sector数目,BPB_FATSz对应16和32两种选项:
FatSectors = BPB_FATSz * BPB_NumFATs;
//根目录开始点:
RootDirStartSector = FatStartSector + FatSectors;
//根目录大小,这里的运算-1是为了兼容FAT12/16/32三种系统:
RootDirSectors = ( 32 * BPB_RootEntCnt + BPB_BytsPerSec -1 ) / BPB_BytsPerSec;
//数据区开始点:
DataStartSector = RootDirStartSector + RootDirSectors;
//数据区大小:
DataSectors = BPB_TotSec - DataStartSector;
以上计算基于整个Volume从存储介质的Sector[0]开始存储,如果存储介质本身就是分区的,那么以上所有计算应该加上一个偏移量。
2.2 判断FAT文件系统的变种类型
标准的FAT文件系统可以分为FAT12,FAT16和FAT32三种,关于他们的判别方式出现了非常多的混淆和乱七八糟的界定方法,然而官方认为:FAT变种只能由Volume中所包含的Cluster数量界定。这个数量计算采取以下方法:
- ClusterCount = DataSectors / BPB_SecPerClus
- 以上运算中如果产生了余数,直接舍弃
- 结果在 0~4085 范围内的FAT界定为FAT12文件系统
- 结果在 4086~65525 范围内的FAT界定为FAT16文件系统
- 结果在 65526 以上的FAT界定为FAT32文件系统
FAT的最大Cluster数目并没有明确的定义,通过观察可以看到实际上限可能在268435445也就是2^28-11这个数值。注意,关于界定方法官方是确认的,但是关于界定的明确界限他是不确定的,非常诡异,官方推荐在创建FATFS的时候避免接近这些边界数值,至少离上面提到的边界16个远。有趣的是考虑Cluster字节数在512B到32768B之间的情况,各个文件系统变种的容量也可以计算出来:
- FAT12 0~128MB
- FAT16 2MB~2GB
- FAT32 32MB~2TB
2.3 FAT表基础形式与Cluster关系
FAT,File Allocation Table,也就是文件分配表,记录了文件如何存储。FAT的本质是一个整型数组,对于三种不同的FAT变种,每个条目(Entry)的大小不同,FAT12的Entry大小12bit,FAT16的大小为16bit,FAT32的大小为32bit(只有低28bit是有效的),条目排列如下所示:
显然对于FAT16和FAT32的内容读写是很简单的,因为这两种FAT变种的条目长度都是Byte的整数倍,可以直接使用uint16_t和uint32_t去进行读写,但是FAT12的条目长度为1.5Byte需要一些处理。对于条目N,也就是FAT[N]的读取Sector和在Sector内的偏移可以进行如下计算:
FAT条目定位计算代码
//FAT12的FAT[N]对应Sector编号和Sector内的Byte编号
FATSecNum[N] = BPB_ResvdSecCnt + ((N + (N/2)) / BPB_BytsPerSec );
FATEntOff[N] = (N + (N/2)) % BPB_BytsPerSec;
//FAT16的FAT[N]对应Sector编号和Sector内的Byte编号
FATSecNum[N] = BPB_ResvdSecCnt + ( N * 2 / BPB_BytsPerSec );
FATEntOff[N] = ( N * 2 ) % BPB_BytsPerSec;
//FAT32的FAT[N]对应Sector编号和Sector内的Byte编号
FATSecNum[N] = BPB_ResvdSecCnt + ( N * 4 / BPB_BytsPerSec );
FATEntOff[N] = ( N * 4 ) % BPB_BytsPerSec;
对于三种不同的系统FAT[N]的读写也略有不同,首先对于FAT12,由于我们不能够用既定的int类型去读取,我们需要计算额外的bits;其次对于FAT32,由于只有低28bit是有效的,我们需要加入额外的掩码运算,计算方法如下所示:
FAT条目读写代码
//@param tmp 临时变量
//@param SecBuf 用于读取整个Sector的缓冲区数组
//@param EntryVal 用于接收或者写入FAT[N]的内容
//@func ReadSector(buf,pos) 用于读取pos位置的Sector并且存储到buf中
//@func WriteSector(buf,pos) 用于将buf中的内容写入到pos位置的Sector中
//FAT12的读FAT[N]程序
ReadSector(SecBuf, FATSecNum[N]);
if(N&1){ //如果是奇数项
//奇数项对应的字节低4bit是上一项,所以将高4bit下移,将下一个字节上移
EntryVal = (SecBuf[FATEntOff[N]] >> 4) | ((uint16_t)SecBuf[FATEntOff[N] + 1] << 4);
}else{ //如果是偶数项
//偶数项对应的下一个字节的低4bit是FAT[N]的高4bit
EntryVal = SecBuf[FATEntOff[N] | ((uint16_t)(SecBuf[FATEntOff[N] + 1] & 0x0F) << 8);
}
//FAT12的写FAT[N]程序
ReadSector(SecBuf, FATSecNum[N]);
if(N&1){ //如果是奇数项
//截取低4bit写入到对应字节的高4bit,高8bit写入到下一个字节
SecBuf[FATEntOff[N]] = (SecBuf[FATEntOff[N]] & 0x0F) | (EntryVal << 4);
SecBuf[FATEntOff[N] + 1] = EntryVal >> 4;
}else{ //如果是偶数项
//截取低8bit写入到对应字节,高4bit写入到下一字节低4bit
SecBuf[FATEntOff[N]] = EntryVal;
SecBuf[FATEntOff[N] + 1] = (SecBuf[FATEntOff[N] + 1] & 0xF0) | ((EntryVal >> 8) & 0x0F);
}
WriteSector(SecBuff, FATSecNum[N]);
//以上的FAT12程序在碰到Sector的最后一个字节时可能会出现数组越界的情况,需要注意
//FAT16的读FAT[N]程序
ReadSector(SecBuf, FATSecNum[N]);
EntryVal = *(uint16_t*)(Sec + FATEntOff[N]);
//FAT16的写FAT[N]程序
ReadSector(SecBuf, FATSecNum[N]);
*(uint16_t*)(SecBuf + FATEntOff[N]) = EntryVal;
WriteSector(SecBuf, FATSecNum[N]);
//FAT32的读FAT[N]程序
ReadSector(SecBuf, FATSecNum[N]);
EntryVal = *(uint32_t*)(Sec + FATEntOff[N]) * 0x0FFFFFFF;
//FAT32的写FAT[N]程序
ReadSector(SecBuf, FATSecNum[N]);
tmp = *(uint32_t*)(SecBuf + FATEntOff[N]);
tmp = (tmp & 0xF000000) | (EntryVal & 0x0FFFFFFF);
*(uint32_t*)(SecBuf + FATEntOff[N]) = tmp;
WriteSector(SecBuf, FATSecNum[N]);
假设一个文件(File)能够在一个Cluster之内进行存储,这很好。但是我们经常遇到的情况是一个文件跨越多个Cluster,这时候我们说文件是被一个簇链(Cluster Chain)存储的,而这个链接信息就定义在FAT之中。需要说明的是,目录和文件都在FAT中进行定义,目录事实上是一种特殊的文件,具有一个“容器”的属性。通常来说FAT中的每个Entry都和每个Cluster相关联,但是前两个FAT Entry也就是FAT[0]和FAT[1]通常是保留不用的。所以与Data Aera的第一个Cluster相关联的是FAT[2]。
2.4 文件保存Cluster与FAT记录的关系
FATFS中的每一个文件(File)都需要挂在到一个目录(Directory)之下并且被该目录管理,也就是一个长度为32Byte的目录条目。目录条目(Directory Entry)中主要记载了:文件名、文件大小、时间戳以及文件数据存储使用的第一个Cluster的编号。按照这个Cluster编号开始,顺着我们前面提到的Cluster Chain读取,我们就可以便利整个文件占用的所有Cluster。我们考虑一种极其特殊的情况就是这个文件目前的大小为0,只有文件名,时间戳等信息但是没有实质性内容:那么Cluster编号就被设置为0。(这也是为什么FAT从FAT[2]开始生效的原因之一),因此我们可以得出Cluster编号的计算方法:
Cluster编号计算方法
//N个Cluster编号从2开始,到N+1结束,对应N+2个FAT条目
//第N个Cluster开始的Sector坐标就是:
FirstSectorOfCluster = DataStartSector + (N - 2) * BPB_SecPerClus
//这里的坐标N显然是从2开始有效的
如果文件的大小不到Cluster,无论是几个Sector加上零散的Byte还是根本就不到一个Sector的大小,整个Cluster都会被占用。这就是为什么我们有时候新建一个txt文件时它占用0字节,但是当我们写入了哪怕一个字符,它的空间占用都会达到4KB——对应的FATFS的Cluster大小为4096Byte!所以存储大量小文件的时候会造成可观的空间浪费,而基础的FATFS对这部分是没有优化的。
假设我们现在正在读取一个文件,正在读取的Cluster编号为N,当我们完成读取,我们需要知道文件的下一个Cluster编号为多少我们才能够继续读取,这个编号或者说指针就是FAT[N]。有的同学可能已经联想到数据结构中经典的链表结构,这就是我们为什么将这种存储结构称之为Cluster Chain,并且显然这个链表是单向不循环链表。我们使用一个特殊的值EOC(End of Cluster)去标记整个链表的结束,也就是文件的结束,该值永远不会和任何有效的Cluster编号匹配,EOC如下所示:
- FAT12的EOC:0xFF8~0xFFF(后者为典型值)
- FAT16的EOC:0xFFF8~0xFFFF(后者为典型值)
- FAT32的EOC:0x0FFFFFF8~0x0FFFFFFF(后者为典型值)
除了以上EOC特殊值之外,我们还需要考虑到一片存储介质内部很可能产生零星的坏点,例如硬盘的坏道,我们不可能因为少量的坏点就放弃整个盘片,但是放任坏点的存在又会产生数据丢失。所以FATFS专门设定了一个特殊值标记坏簇(Bad Cluster),这个值就是EOC最小值-1:
- FAT12的Bad Cluster:0xFF7
- FAT16的Bad Cluster:0xFFF7
- FAT32的Bad Cluster:0x0FFFFFF7
这里我们回顾一下FATFS中文件系统所拥有的最大的簇数,那些看似奇怪的数字有了很好的解释,例如0xFF7=4087,那么最大的有效编号就是4086,由于编号从2开始,所以FAT12的最大簇数就是4085。基于同样的原因,FAT32的最大簇数是268435445,但是通常很多操作系统都限制FAT的最大簇数和FAT表本身的最大容量,所以并不能够达到256M*32KB=8TB的容量。
FAT[2]开始的初始值应当设置为0,代表着本Cluster并未被占用,也就是可以写入新的数据,所以在进行格式化时候应当注意设置为全0。这里还有一个有趣的区别:FAT12/16是通过遍历整个FAT表并且计算0的数量来计算介质的空余容量的;但是FAT32的FAT可能很大,以至于遍历FAT可能造成极大的读写性能下降,故而FAT32使用FSInfo来存储空余容量信息。最后谈一下前两个FAT的取值问题:
FAT[0]与FAT[1]取值表
系统 | FAT[0] | FAT[1] |
FAT12 | 0xF?? | 0xFFF |
FAT16 | 0xFF?? | 0xFFFF |
FAT32 | 0x0FFFFF?? | 0x0FFFFFF |
表格中的??其实就是该系统记录的媒体类型BPB_Media(见Boot Sector部分的表格),但是事实上这个FAT Entry根本没有任何作用。FAT[1]中的某些bit记录了“使用过程中的错误记录”:
FATFS错误标志位表
系统 | 脏卷(Dirty Volume)错误 | 硬件(Hard Error)错误 |
FAT12 | NaN | NaN |
FAT16 | bit[15] | bit[14] |
FAT32 | bit[31] | bit[30] |
- 脏卷错误:在启动时清除标志位,当正常关闭Volume时置位,因此可以检测出不正常的关闭Volume例如在读写过程中断开连接,一定程度上指示了逻辑错误发生的可能性
- 硬件错误:在不可恢复的读写错误发生时清除标志位,代表着需要坏道检测
这些标志位的存在可能在介质接入支持此功能的系统时的boot阶段就自动启动磁盘检查工具或者向操作者提示需要进行磁盘检测。当然很多操作系统事实上利用了Boot Sector中其他的标志位或者保留字段标记这些错误信息。FAT本身也有可能和文件一样,没有占满整个Sector,尚未使用的部分应当直接设置为0并且不再使用。以下是FAT标记的总结表格:
FAT标记总结表
FAT12 | FAT16 | FAT32 | FAT记录含义 |
0x000 | 0x0000 | 0x00000000 | 空闲 |
0x001 | 0x0001 | 0x00000001 | 保留 |
0x002~0xFF6 | 0x0002~0xFFF6 | 0x00000002~0x0FFFFFF6 | 使用中(后续) |
0xFF7 | 0xFFF7 | 0x0FFFFFF7 | 坏道 |
0xFF8~0xFFF | 0xFFF8~0xFFFF | 0x0FFFFFF8~0x0FFFFFFF | 使用中(结束) |
2.5 系统信息FSInfo与Boot备份[仅FAT32]
FAT区域的大小在FAT12系统上可以达到6KB,在FAT16系统上也不过128KB,对于无嵌套循环遍历如此大小的区域来说所花费的时间并不会对他们的读写性能产生很大的影响(毕竟小容量),但是建立额外的记录区域会进一步压缩他们的容量。然而对于FAT32系统,FAT区可能达到MB的数量级,并且如果不加限制的话这个大小最大可以扩张到256MB左右,这时候就需要FSInfo区域了。这个信息存储区域大小为512Byte并且必定占据一个Sector,具体占用了哪个Sector由Boot中的BPB_FSInfo字段指定。如下是这个Sector之内具体的数据分配情况:
FSInfo字段表
字段名称 | 字段偏移 | 字段大小 | 字段描述信息 |
FSI_LeadSig | 0 | 4 | 定值0x41615252,这个字段是引导性质的签名,用于验证本Sector确实是一个FSInfo扇区。 |
FSI_Reserved1 | 4 | 480 | 保留区域,格式化时应当始终设施为全0 |
FSI_StrucSig | 484 | 4 | 0x61417272,这个字段是另一个签名,也用于标志使用的字段的位置,更加局部化一些 |
FSI_Freee_Count | 488 | 4 | 这个字段标记着最后一次统计的卷内可用的Cluster的数量,如果设定值为0xFFFFFFFF表示可用数据是未知的,这个字段不一定是完全准确的,所以FAT的驱动程序必须要验证数据的有效性。 |
FSI_Nxt_Free | 492 | 4 | 这个字段给FAT驱动做出提示性信息,代表着驱动应该从所标记的Cluster开始寻找空闲Cluster。这个值一般被FAT驱动设定为最后一个使用的Cluster编号。同样0xFFFFFFFF代表数据未知。 |
FSI_Reserved2 | 496 | 12 | 保留区域,格式化时应当始终设施为全0 |
FS_TrailSig | 508 | 4 | 定值0xAA5500,这个字段是引导性质的签名,用于验证本Sector确实是一个FSInfo扇区。 |
512 | 如果Sector大小大于512Byte,此后的数据应当设置为0 |
FAT32的另一个特有功能是备份Boot Sector。由于FAT32存储的数据可能极多,因此如果唯一的Boot Sector发生坏道或者错误都且等情况,数据丢失的成本极大。因此FAT32会选定一个Sector作为Boot区域的备份,这个区域的编号通过Boot Sector中的BPB_BkBookSec指定,官方强烈建议将此字段设置为6。这样做的原因是一个逻辑上的考虑:假设Boot Sector已经损坏,那么驱动就无法读取该字段并且直接定位到对应的Sector——这时候只能从头开始验证有哪些Sector是带有Boot签名的!所以不如约定俗成使用6号Sector进行备份。以及FAT32的Boot扇区大小其实是3个512Byte——第一个是Boot Sector,第二个是FSInfo,第三个是保留扇区——这样就保证了这三个扇区都会被备份并且他们的510Byte都是0xAA55的Boot标志签名。
2.6 FAT标记的目录
当前讨论的目录(Directory)只包括FATFS中一项最基本的功能:短文件名(SFN, Short FileName)存储,前文提到目录就是一种带有“容器”特性的特殊文件。这种文件叫做目录条目(Directory Entry),包含有逻辑上处于容器内部的所有文件(当然这里说的文件也可以是另一个目录)的元信息(Meta Data),每个条目包含有32Byte的信息,指向其内部的文件(目录数据最大可以到2MB,也就是65536个条目)。
所有的目录中有一个最特殊的叫做根目录(Root Directory)处于整个Volume存储层级结构树的根节点,在FAT12/16的Volume中,根目录并不是作为文件存储的,而是作为一个和FAT,Boot Sector等区域同级别的”超级区域“存储起来的,根目录的数量在Boot Sector中的BPB_RootEntCnt字段指定;而在FAT32的Volume中就没有这种区别,根目录也是存储在Data Aera的。但是要注意,当FAT32 Volume的整个卷是空的时候,根目录存放的Cluster处于BPB_RootClus字段标定的Cluster中。
在一般的计算机系统中(windows用户可能对此感受不深刻)目录中都包含有两个特殊的子节点,我们称之为点条目(“.” “..”)代表着本目录自身和父节点(也就是上一层目录),根目录并不包含有这两个子节点;但是根目录可以包含有一个特殊结点:卷标(Volume Label),就是一个带有ATTR_VOLUEM_ID的特殊条目。目录条目(Directory Entry)结构如下表所示,其中DIR_Attr字段将给出详表:
目录条目字段表
字段名称 | 字段偏移 | 字段大小 | 字段描述信息 |
DIR_Name | 0 | 11 | 指向实体的短文件名(SFN) |
DIR_Attr | 11 | 1 | 文件权限与信息,高2bit必须为0,用作保留字段,其他的bit由以下选项组合而成: 0x01-只读文件 0x02-隐藏文件 0x04-系统文件 0x08-卷标 0x10-目录 0x20-归档文件 0x0F-长文件名条目(LFN Entry) |
DIR_NTRes | 12 | 1 | 标记文件名的额外可选标志位 0x08-文件名字母全小写 0x10-文件扩展名字母全小写 |
DIR_CrtTimeTenth | 13 | 1 | 可选字段,亚秒信息,DIR_CrtTime的分辨率只有2秒,此字段可以设置为0~199,单位是10ms,如果不支持这个功能直接设置为0,后续不做修改 |
DIR_CrtTime | 14 | 2 | 可选字段,文件创建时间,如果不支持这个功能直接设置为0,后续不做修改 |
DIR_CrtDate | 16 | 2 | 可选字段,文件创建日期,如果不支持这个功能直接设置为0,后续不做修改 |
DIR_LstAccDate | 18 | 2 | 可选字段,文件最后的访问日期,如果不支持这个功能直接设置为0,后续不做修改 |
DIR_FstClusHI | 20 | 2 | 代表了文件内容的第一个Cluster编号的高2Byte,对于FAT12/16来说没有意义,直接设置为0 |
DIR_WrtTime | 22 | 2 | 当文件关闭时记录的最后一次的修改时间 |
DIR_WrtDate | 24 | 2 | 当文件关闭时记录的最后一次的修改日期 |
DIR_FstClusLO | 26 | 2 | 代表了文件内容的第一个Cluster编号的低2Byte,当文件大小为0时,这个值必须设置为0;但是如果文件本身是个目录,那么这个值必须设定为目录的Entry而不能是0 |
DIR_FileSize | 28 | 4 | 代表了文件占用的Byte的数目,如果文件本身是个目录,这个值一定要设置为0 |
文件属性标志位表
标志位 | 标志位具体含义 |
ATTR_READ_ONLY | 代表着一个只读文件,任何对于文件内容的更改或者文件删除动作都禁止 |
ATTR_HIDDEN | 任何普通模式下的目录子节点列表都不包含这个结点[依赖于操作系统] |
ATTR_SYSTEM | 代表着这个文件是系统运行所需文件而不是普通文件[依赖于操作系统] |
ATTR_ARCHIVE | 这个属性实质上用于备份动作。当FAT驱动程序对一个文件做出新建、修改或者重命名的动作时,这个标志位被设置。当驱动程序完成上述动作并且确保了信息没有丢失后这个表示被清除 |
ATTR_VOLUME_ID | 带有此标志位的条目为卷标Entry,必须挂载在根目录下并且只能存在一个,这个Entry没有自己的Cluster Chain;有些系统可能会将这个标志位和ARCHIVE混淆使用,但是这并无实际意义 |
ATTR_DIRECTORY | 代表了这个Entry指向的Cluster Chain是一个容器,代表着一个目录 |
ATTR_LONG_NAME | 这是一个复合标签,表现为0x0F,代表这个Entry是一个长文件名 |
需要指出的是DIR_Name字段的第一个字节也就是DIR_Name[0]是一个特殊字节,用于指示目录条目的状态。如果为0xE5代表这个目录条目未使用并且可以重新分配;如果为0x00代表着不仅这个目录条目未使用,并且之后的所有目录条目都未使用。其他情况下都代表着这个Entry正在使用中——还有一个极其特殊的情况就是文件名的第一个字符正好是0xE5(非ASCII字符,但有可能是Unicode或者在区位码表中),这时候这个字节设置为0x05作为替代。短文件名默认的划分是8bit给文件名本身,3bit给文件的扩展名而隐去文件名中的点。
2.7 目录操作
在FATFS的目录操作中主要包含创造文件、创造子文件夹、删除文件、删除子文件夹和更改卷标等主要内容,查找和改动文件以及重命名文件夹都是极其简单的操作,或者说以上主要操作的弱化版本。
为了创建文件,FAT驱动将在目录中找到一个开头为0xE5的空闲条目来装填文件的元数据。假设找不到空闲条目,那么本目录所指向的Cluster Chain中最后一个Cluster对应的FAT信息将不再是EOC,转而扩展一个新的Cluster出来。但是需要注意目录的大小不能够超过2MB,也就是65536个条目。新的Directory Entry中如果带有ATTR_ARCHIVE也就是归档文件的标签,那么其指向的Cluster和文件大小都将被设置为0。当写入文件的任何数据导致其大小不等于0时会创建一个新的Cluster Chain。
而创建子目录就要略有不同,首先必须要求ATTR_DIRECTORY也就是目录字段被选中。其次其文件大小必须设置为0,并且一定要新建一个Cluster Chain并且将其中的全部Entry设置为空也就是开头字节设置为0xE5。完成此动作之后,将DIR[0]也就是新Cluster的0号条目设置为”.”并且指向自身;将DIR[1]也就是新Cluster的1号条目设置为”..”并且指向父节点,需要注意的是假设父目录是根节点,那么其指向信息Cluster字段将设置为0。
删除文件则是一个特别简单的操作,如果被删除的文件具有Cluster Chain,那么将其对应的FAT信息更改为空闲。此后将其条目首字节写入为0xE5即可。这里就可以看出FATFS是一个中间件,他只要保证逻辑接口的正常即可,而不必真的进行Cluster Chain的数据擦除工作。这里有一点需要特殊注意:不要只擦除Cluster Chain的首节点,那样将致使其后挂载的全部结点变成“死节点”或者“野指针”,除格式化外无法再次将其标定为可用。
删除目录和删除文件是类似的,只不过要注意将目录内所有的子节点全部删除后再删除目录的Entry,如果目录本身带有子目录,那么这个动作需要递归执行。
最后一个操作就是为FAT Volume标定卷标(Volume Label),本质就是新建一个条目并且将其卷标属性也就是ATTR_VOLUME_ID标定为选中。卷标没有自己的Cluster Chain,也不具有任何指向性,它应当挂载在根目录中。
2.8 文件名与命名空间
前文提到Directory Entry的字段DIR_NAME是一个长度为11Byte的字符串类型数据,它被FAT系统驱动分为两部分,符合8+3的形式,也就是8Byte长的文件名和3Byte长的扩展名,文件名中的”.”是被默认隐藏的。如果既定的文件名没有完全填充这些部分,那么剩下的空余Byte将被填充为0x20,文件名使用的字形码表(Code Page)取决于运行驱动本身的操作系统。下面是一些文件名被FAT驱动处理后的格式以及这种处理的说明:
SFN文件名处理范例表
文件名 | 记录值 | FAT驱动处理说明 |
FILENAME.TXT | FILENAMETXT | “.”在FAT的SFN存储中是默认缺省的 |
DOG.JPG | DOG JPG | DOG不能占满8bit,所以低5bits设置为0x20 |
file.txt | FILE TXT | 不仅使用0x20填充,并且整体需要设置为大写 |
蜃気楼.JPG | ・気楼 JPG | “蜃”是一个双字节字符,第一个字节是0xE5,为了标定本Entry已经被使用,所以第一个字节设置为0x05,转换为”・” |
MAKEFILE | MAKEFILE | 不包含扩展名,扩展名的3bits被填充为0x20 |
.cnf | 非法!SFN不承认没有主体文件名的文件 | |
new file.txt | 非法!SFN不承认包含空格文件名的文件 | |
longext.jpeg | 非法!SFN不承认扩展名大于3bit的文件 | |
abcdefgh.txt | 非法!SFN不承认文件名大于8bit的文件 | |
make.bak.txt | 非法!SFN不承认有多个扩展名的文件 |
以上要求表明了SFN文件名对于8.3格式拥有极其严格的格式要求,除了以上的要求之外,能够合法的出现在SFN中的字符包括以下几种:
- 阿拉伯数字 0~9
- 大写英文字符 A~Z (小写的英文字符会被强制转换)
- 特殊字符包括:! # $ % & ‘ ( ) – @ ^ ` ~ _ { } 一共16种
这里需要对ASCII字符集之中的特殊字符做出考虑,不同于常见的小写英文字母会替换成大写字母,由于ASCII码表只针对0x00到0x7F做出了定义,故而0x80到0xFF是极其特殊的字符,他们通常由两个字节组成双字节字符,并且对应到区位码表。对这些字符的替换规则有很多变种,但是需要注意的是使用扩展字符集可能会造成非常严重的兼容性问题,尤其是在不同的系统和不同的运行环境下。在讨论兼容性之前我们还需要考虑长文件名(LFN, Long File Name)的问题。
事实上LFN这项特性实在最后几次更新中才被加入到FAT文件系统标准之中的,作为“补丁”这种特性一定要向下兼容老旧版本的FAT驱动程序,否则可能造成更大的兼容性和版本差异问题。为了保证向下兼容性质,以下三条准则是一定要满足的:
- LFN相关信息对低版本的FAT驱动需要绝对不可见,例如老旧的MS-DOS系统从逻辑上来说应当完全读取不到这些信息,故而就不会产生错误处理程序。
- 记录LFN的相关信息需要在物理介质上接近Directory Entry以保证FATFS的读写性能
- 如果磁盘工具在FAT Volume上找到了LFN信息,需要保证系统的健全性,也就是这些信息在逻辑上来讲在任何情况下不可能被”正常“的进行”不合逻辑“的处理。
在这里FATFS的设计者做了一个天才型的设计,他们将LFN设置为一个Directory Entry类型的数据,并且通过一个特殊的属性ATTR_LONG_NAME和掩码ATTR_LONG_NAME_MASK进行标记:
LFN标记掩码定义与判定代码
#define ATTR_LONG_NAME (ATTR_READ_ONLY|ATTR_HIDDEN|ATTR_SYSTEM|ATTR_VOLUME_ID)
//ATTR_LONG_NAME = 0x0F
#define ATTR_LONG_NAME_MASK (ATTR_READ_ONLY|ATTR_HIDDEN|ATTR_SYSTEM|ATTR_VOLUME_ID|ATTR_DIRECTORY|ATTR_ARCHIVE)
//ATTR_LONG_NAME_MASK = 0x3F
bool isLFN(uint8_t DIR_Attr){
if(DIR_Attr==ATTR_LONG_NAME||DIR_Attr==ATTR_LONG_NAME_MASK) return true;
else return false;
}
如上方的代码所说,假设Entry之中的DIR_Attr字段和以上定义的两个标记之中的任何一个匹配,就说明这个Entry是一个LFN类型的条目。这个ENtry也是32Byte,它的参数如下所示:
LFN字段定义表
字段名称 | 字段偏移 | 字段大小 | 字段说明信息 |
LDIR_Ord | 0 | 1 | 长文件名LFN是多段Entry拼合的Entry Chain,这个链条的最大长度可以是20,这个字段就代表了目前的Entry在整个LFN中是第几个。假设这个Entry是最后一个,那么它的序列号会带有一个特殊标志位,我们称之为LAST_LONG_ENTRY=0x40 |
LDIR_Name1 | 1 | 10 | 本段Entry记录部分的前10个byte,对应5个双字节字符 |
LDIR_Attr | 11 | 1 | 应当符合ATTR_LONG_NAME或者ATTR_LONG_NAME_MASK |
LDIR_Type | 12 | 1 | 一定要设置为0 |
LDIR_Chksum | 13 | 1 | 存储校验和,用于检测数据的完整性和正确性 |
LDIR_Name2 | 14 | 12 | 本段Entry记录部分的中间12个byte,对应双字节字符6-11 |
LDIR_FstClusLO | 26 | 2 | 一定要设置为0 |
LDIR_Name3 | 28 | 4 | 本段Entry记录部分的最后4个byte,对应双字节字符12-13 |
LFN Entry永远需要和SFN Entry相关联,独立存在的LFN Entry是无效的,可以被视作垃圾信息,这主要是为了向下兼容性。如果SFN和LFN同时存在,那么LFN是默认使用的文件名,SFN只是一个备用的文件名称;虽然不承认LFN特性的驱动系统不会读取LFN,但是仍然能够通过SFN访问文件内容。以下使用官方的例子展示文件名”MultiMediaCard System Summary.docx”如何存储在FAT文件系统中,关于标志位一列,0x0F或者0x80的意思是强制这些bits是1,其他位置自由:
LFN存储实例表
Entry位置 | 首字节内容 | 文件名区域内容 | 标志位 | 逻辑内容 |
DIR[N-3] | 0x40|0x03 | ary.docx | 0x0F | LFN第三部分 字符26_38 |
DIR[N-2] | 0x02 | d System Summ | 0x0F | LFN第二部分 字符13~25 |
DIR[N-1] | 0x01 | MultiMediaCar | 0x0F | LFN第一部分 字符0~12 |
DIR[N] | ’M’ | MULTIM~1PDF | 0x80 | 与LFN关联的SFN Entry |
以上举出的例子中LFN的长度超过了13Byte也就是一个LFN所能够容纳的极限,那么这个时候LFN就是由一系列的Entry组成的了。LFN的字符编码采用Unicode也就是UTF-16,但是适用于SFN的编码格式是ANSI/OEM(当然还是更多的取决于系统)。如果最后一部分的长度没有满足13个字符,那么此后的空域部分由一个开头的(U+0000)和填充的(U+FFFF)组成。考虑到读写性能,SFN和对应的LFN Entry必须以降序的方式在存储介质上严谨的相邻。此外,还应保证SFN和LFN之间的关联是通过LFN条目内部存储的校验和(Checksum)定位到相应SFN的,校验算法如下:
SFN-LFN校验和计算代码
/**
* @brief 本函数用于计算SFN的校验和
* @param sfn是一个目录条目类型(DIR)的指针
* @retval 函数返回校验和
*/
uint8_t calc_checksum(const DIR* sfn){
uint8_t sum,i;
for(i=sum=0;i<11;i++)
//先将sum循环右移1bit后叠加对应的SFN文件名字符
sum = (sum >> 1) + (sum << 7) + sfn->DIR_NAME[i];
return sum;
}
当LFN的校验和与SFN的校验和不一致时,我们通常认为这个LFN Entry以及与其相关的Entry Chain是损坏的,FATFS对于损坏的LFN条目并没有GC(Garbage Collection)的机制,所以这种垃圾条目只能通过磁盘工具删除,而在正常的使用流程中就会降低磁盘的可用空间。现在我们可以对FATFS中文件名存储的机制也就是命名空间(Namespace)做出结论:
- SFN也就是8.3格式的文件名,允许使用之前提到的字符表,但是会进行大写转换,同样一些系统承认ASCII码表中的扩展字符(0x80~0xFF)
- LFN的最大长度可以达到255字符,基本上符合现代计算机系统对于文件名命名规则的“宽容”标准并且不进行大写转换。
- 由于SFN(OEM)和LFN(Unicode)使用不同的码表(Code Page),所以字符需要通用性码表之间的转换,但是当OEM的Code Page是双字节码表(DBCS)时,转换表需要数百KB的空间,这对于RAM很小的嵌入式系统来说非常困难。
明确了命名空间的问题之后,扑面而来的就是文件名匹配(Name Matching)的问题,也就是查找文件动作中常用的判别标准。每个文件名在当前目录中必须是唯一的,无论存储形式是SFN还是LFN。虽然LFN可以包含小写字母,但是和SFN一样需要在不分大小写的状况下进行匹配——在目录中搜索文件时不区分大小写。当LFN和SFN同时存在时,优先使用LFN而非SFN进行比较和输出。
在支持LFN的FATFS中还有一个小问题,就是当我们给系统输入一个LFN,这时候它的SFN该如何确定?虽然从规定上来说SFN可以是任意的,但是从逻辑上除非SFN与另一个SFN冲突,否则SFN应当与LFN统一起来,或者说SFN应当遵循一个规则以LFN为标准生成。生成规则如下并给出一些例子:
- SFN总体格式为 body [+numeric-tail] [+extension]
- 无论是否是扩展字符,将小写字符转换为大写字符
- 如果LFN是以”.”开头,那么删除这个点并且写入“损失标志”
- 如果存在多个”.”,那么删除除了最后一个点并且写入“损失标志”
- 如果存在非法的SFN字符,使用”_”替代这些字符并且写入”损失标志“
- 如果输入LFN为Unicode并且部分字符无法转换为OEM,那么使用”_”替代并写入“损失标志”
- 如果主文件名超过8bit,扩展名超过3bit那么截断并且写入”损失标志”
- 损失标志为“~N”,N最长可以是6bit,从1开始计算,不能导致文件SFN重叠
- 如果LFN和SFN完全相同,不生成LFN
LFN对应SFN转换范例表
LFN | SFN(8.3) | SFN[Final] | 转换规则 |
File.txt | FILE.TXT | “FILE TXT” | 大写 |
foo.tar.gz | FOOTAR~1.GZ | “FOOTAR~1GZ “ | 大写,去点 |
.conf | CONF~1 | “CONF~1 “ | 大写,去点 |
a+b=c | A_B_C~1 | “A_B_C~1 “ | 大写,非法字符 |
Asakura Otome.jpeg | ASAKUR~1.JPE | “ASAKUR~1JPE” | 大写,去空格,截断 |
Asakura Yume.jpeg | ASAKUR~2.JPE | “ASAKUR~2JPE” | 大写,去空格,截断,损失不重叠 |
2.9 FATFS时间戳、兼容性问题和硬件分区
在之前的分析中,我们不难发现,在文件的Directory Entry中文件的创建时间日期、修改时间日期还有可能的最后读取日期都会用到记录当前时间的功能,事实上FATFS的官方介绍中就含有相关示意图,指示在嵌入式系统中移植FATFS可能需要在底层提供RTC(Real-Time Clock)支持:
- 日期(Date)信息:Bit[15:9]为年信息[0~127]映射为[1980~2107]
- 日期(Date)信息:Bit[8:5]为月份信息[0~11]映射为[Jun~Dec],12~15无意义
- 日期(Date)信息:Bit[4:0]为日文i信息[0~30]映射为[1~31],31无意义
- 时间(Time)信息:Bit[15:11]为小时信息[0~23]映射为[0~23],24~31无意义
- 时间(Time)信息:Bit[10:5]为分钟信息[0~59]映射为[0~59],60~63无意义
- 时间(Time)信息:Bit[4:0]为2秒钟信息[0~29]映射为[0~58,2分辨率],30~31无意义
我们讨论的兼容性问题主要包括:不支持LFN的系统如何驱动FATFS、不同的OEM字符码表符合配置以及在MacOSX上可能出现的问题。首先讨论不支持LFN的系统,由于LFN设计具有向下兼容性,所以单纯使用SFN的系统能够以SFN中的文件名读取FAT Volume中的文件,但是对于已经存在LFN的文件进行SFN的重命名会导致LFN条目损坏并且不可回收——起码文件内容数据本身不会变,而且在21世纪20年代的今天,不支持LFN的系统也比较少见。
关于OEM字符码表,出问题的主要是DBCS。第一个就是之前提到的DBCS和Unicode之间的转换可能引起巨大的RAM消耗,这一点在嵌入式系统上较为致命。第二点就是大多数FAT驱动使用0x5C作为目录分隔符,如果文件名中包含这些字符可能引起IO错误[此处对于一些SBCS的大小写转换也可能引起这种问题],所以在不同的Code Page系统之间交换文件的时候,理论上不应该使用任何扩展字符作为文件的名称使用。补充:LFN在这部分中没有问题,问题出在SFN。
最后是苹果公司的Mac OS X系统,它支持在文件末尾出现”.”或者空格,FATFS是有和MacOS挂载和读写的兼容性的,这个时候需要注意,上述提出的文件名是绝对非法的,无论是LFN还是SFN。解决方法是将末尾的非法字符进行替换,空格替换为U+F028,点替换为U+F029处理。
FATFS系统分析的最后一部分讨论一下在嵌入式系统中非常不常用的物理设备硬件分区的问题。首先说本文讨论的事实上是FAT Volume,当我们处于Volume这个层级的时候已经和分区(Partition)不相关了,比起文件系统,这部分内容归类到磁盘驱动中更加合理一些,但是在开发过程中也可能用到,所以进行简单的探讨。磁盘的分区方式主要分为两种:
- MBR(Master Boot Record,主引导记录)格式:也被称为FDISK,通常用于硬盘和存储卡。这种分区格式的本质就是将物理驱动器划分为多个逻辑块地址(LBA, Logical Block Address),MBR本身作为LBA 0出现,而后LBA x就可以看作一个独立的驱动器。
- SFD(Super floppy Disk, 超级软盘):这种分区格式为非分区磁盘格式,不存在任何磁盘分区,FAT直接从LBA 0开始写,通常用于软盘光盘,一般来说不使用。
接下来简单介绍以下MBR的分区表和分区条目,MBR的Boot Sector长度也是固定的512Byte,主要就是记录一共有几个分区,分区入口在什么地方,以及一段引导程序(主要是用于启动操作系统)。MBR设备最多可以被分为四个分区,MBR分区表和Partition详细内容表如下:
MBR分区字段表
字段名称 | 字段偏移 | 字段大小 | 字段说明信息 |
MBR_BootCode | 0 | 446 | 主要存储用于引导操作系统的代码,用于启动操作系统或者系统指定的其他用途,如果没有的话,直接全部填充为0 |
MBR_Partition1 | 446 | 16 | 分区表1 |
MBR_Partition1 | 462 | 16 | 分区表2 |
MBR_Partition1 | 478 | 16 | 分区表3 |
MBR_Partition1 | 494 | 16 | 分区表4 |
MBR_Sig | 510 | 2 | 老朋友0xAA55签名 |
MBR分区Partition字段详解表
字段名称 | 字段偏移 | 字段大小 | 字段说明信息 |
PT_BootID | 0 | 1 | 引导指示信息,如果是可引导内容就是0x00,反之则是0x80。可引导内容代表着可以从此分区启动操作系统 |
PT_StartHd | 1 | 1 | CHS格式下(0-254)的分区起始Sector编号,CHS主要是在传统的拥有磁头,柱面,盘片等结构的机械磁盘中使用。 |
PT_StartCySc | 2 | 2 | CHS格式下的分区起始点柱面号(bit[9:0] 0~1023)和在该柱面内部的Sector序号(bit[15:10] 1~63) |
PT_System | 4 | 1 | 分区系统类型: 0x00-未知 0x01-FAT12 [CHS或者LBA,小于65536 Sectors] 0x04-FAT16 [CHS或者LBA,小于65536 Sectors] 0x05-扩展分区 [CHS或者LBA] 0x06-FAT12/16 [CHS或者LBA,大于等于65536 Sectors] 0x07-HPFS/NTFS/exFAT [CHS或者LBA] 0x0B-FAT32 [CHS或者LBA] 0x0C-FAT32 [LBA] 0x0E-FAT12/16 [LBA] 0x0F-扩展分区 [LBA] |
PT_EndHd | 1 | 1 | CHS格式下(0-254)的分区结束Sector编号 |
PT_EndCySc | 2 | 2 | CHS格式下的分区起结束柱面号(bit[9:0] 0~1023)和在该柱面内部的Sector序号(bit[15:10] 1~63) |
PT_LbaOfs | 8 | 4 | LBA类型分区的起始Sector |
PT_LbaSize | 12 | 4 | LBA类型分区的Sector数量 |
2.10 FATFS逻辑结构总结
3. 关于exFAT文件系统
随着现代计算机系统的发展,传统的FATFS事实上遇到了性能瓶颈——因此exFAT事实上是作为FATFS标准的补充和扩展开发出来的,相较于传统的FAT系统exFAT Volume具有以下特点:
- 支持超过4GB的单个大文件的存储[可以看作无上限]
- 支持超过2TB的单个Volume的系统[可以看作无上限]
- 使用分配位图(Allocation Bitmap)提升了簇分配(Cluster Allocation)的效率
- 扩展了Cluster的大小到32MB以增强数据传输性能
- 支持使用UTC格式的时间戳(UTC Timestamp)
- 支持安全的与其他文件系统的交互
相比于传统的FATFS的Volume结构,exFAT也将介质空间划分为玩个大部分,当然也有一定的特性更改:
- Boot Area:引导区,包括传统的Boot Sector和新的Extended BS以及OEM参数区。相比于传统的Backup备份区,还加入和校验和Checksum
- FAT:文件分配表,几乎和传统FATFS相同
- Data Aera:文件数据,几乎和传统FATFS相同
3.1 exFAT下的Boot Sector
相比于传统的FATFS在Boot Sector的数据组织方面还略有模糊,exFAT明确的将Boot Aera划分为两组,最少占据24个Sector。其中前12个Sector是主要的Boot Aera,后12个Sector是用于备份前段的数据的。其中除了传统的Boot Sector之外,还有8个扩展Sector,一个OEM参数区,一个校验和区域和一个保留区域,相关字段组织表格如下所示:
exFAT Boot Sector字段表
字段名称 | 字段偏移 | 字段大小 | 字段说明信息 |
JumpBoot | 0 | 3 | x86指令集下的跳转代码,必须是0xEB-0x76-0x90 |
FileSystemName | 3 | 8 | 必须设定为”EXFAT “ |
MustBeZero | 11 | 53 | 此段必须设置为0,因为要避免和传统FATFS系统的BPB重合导致驱动代码误读文件系统类型和信息 |
ParitionOffset | 64 | 8 | 此数据代表了exFAT Volume距离物理存储介质开始的Sector数目,如果设置为0,那么毫无意义并且应该忽略 |
VolumeLength | 72 | 8 | 说明了exFAT Volume占据的Sector数目。与传统的FATFS不同,这个数值必须和卷容器(例如分区表中登记的大小)相同,这个大小计算后必须大于等于1MB |
FatOffset | 80 | 4 | FAT表区域距离卷首偏移的Sector数量 |
FatLength | 88 | 4 | FAT表的长度,以Sector为单位 |
ClusterHeapOffset | 92 | 4 | 卷中含有的Cluster的数目,最大为0xFFFFFFF5 |
FirstClustOfRootDir | 96 | 4 | 根目录存储数据的第一个Cluster的编号 |
VolumeSerialNumber | 100 | 4 | 卷序列号,通过时间日期在格式化时生成 |
FileSystemRev | 104 | 2 | 文件系统版本号,主版本号占据高字节,低版本号占据低字节,例如2.11一般是0x020B。常见版本号为1.0 |
VolumeFlags | 106 | 2 | 这个字段存放了一些关于exFAT Volume的状态标志位 bit[0] ActiveFat,0既使用FAT1和Bitmap1,1代表使用其他 bit[1] VolumeDirty,文件系统挂载时设置标志位,文件系统安全推出时清除标志位;标定文件系统是否有损坏 bit[2] MediaFailure,当文件系统出现致命性的读写错误时设置标志位,磁盘完全检查恢复后清除标志位 bit[15:3] 保留,设置为0 |
BytsPerSecShift | 108 | 1 | 这个字段是每个Sector占据字节数量的2对数,有效值为9-12对应了512-4096,应当和物理介质保持相同 |
SectsPerClustShit | 109 | 1 | 这个字段是每个Cluster占据Sector数量的2对数,有效值为0-25,对应了1到2^25(32M) |
NumberOfFats | 110 | 1 | FAT和Bitmap的数量。1代表了只有一组分配表和位图;2代表了两组分配表和位图。对于可移动介质应当使用1而2则是为了支持更高级的TexFAT Volume |
DriveSelect | 111 | 1 | BIOS调用的驱动器号,一般来说是0x80。这个字段用于引导程序,事实上这个字段取决于操作系统本身 |
PercentInUse | 112 | 1 | 代表了当前存储空间的使用百分比,0xFF代表系统不可用 |
Reserved | 113 | 7 | 保留字段,全部设置为0 |
BootCode | 120 | 390 | 引导程序,如果没有需要,应当全部设置为0 |
BootSignature | 510 | 2 | 签名,老朋友0xAA55 |
512 | 如果Sector大于512,后面的部分也被Boot Sector占用 |
exFAT Extended Boot Sector字段表
字段名称 | 字段偏移 | 字段大小 | 字段说明信息 |
ExtendedBootCode | 0 | BytsPerSec-4 | 存放扩展引导区域之中的扩展引导代码,如果不用这些功能,应当设置为0 |
ExtendedBootSignature | BytsPerSec-4 | 4 | 签名,老朋友0xAA550000 |
Sector[1:8]一共八个Sector作为扩展引导区域,Sector[9]存储OEM参数信息,Sector[10]作为保留区块使用,这两个区块的数据事实上是依赖于操作系统和驱动程序填充的,如果不使用这两个区块那么需要全部设置为0。Sector[11]使用依据Sector[0:10]中的数据生成的32/16bit校验和(Checksum)填充,其中的两个字段:VolumeFlags和PercentInUse字段不参与校验和运算,校验算法如下:
exFAT Boot区域校验和算法
//该算法仅仅是作为逻辑性说明,其中不参与校验运算的数据的剔除工作并未展示
/**
* @brief checksum函数用于计算校验和
* @param p 代表待校验区域的指针
* @param n 代表待校验区域大小
* @retval 返回相应校验值
*/
//32-bit
uint32_t checksum(const void* p, uint32_t n){
uint32_t sum = 0;
const uint8_t *dp = (const uint8_t*)p;
do{
sum = ((sum & 1) ? 0x80000000 : 0) + (sum >> 1) + *dp++;
}while(--n);
return sum;
}
//16-bit
uint16_t checksum(const void* p, uint16_t n){
uin16_t sum = 0;
const uint8_t *dp = (const uint8_t*)p;
do{
sum = ((sum & 1) ? 0x8000 : 0) + (sum >> 1) + *dp++;
while(--n);
return sum;
}
3.2 FAT与分配位图(Allocation Bitmap)
exFAT系统中FAT Entry的大小与FAT32相同,都是32bit小端存储。但是需要注意的是exFAT系统与FAT32不同之处在于,FAT32只是用前28bit,而exFAT使用全部32bit,故而标定对应的Cluster是否在使用中需要额外的数据表:分配位图(Allocation Bitmap)。Allocation Bitmap中每一个bit代表着对应的Cluster是否处于占用模式,例如Cluster2就对应到第一个Byte的bit[0]——0代表Cluster空闲,1代表Cluster被文件占用。下表展示了FAT和Allocation Bitmap和Cluster状态之间的关系,在特殊情况下即使Cluster可能正在使用中,FAT Entry的记录值也不一定有意义,这部分关系到exFAT的目录操作:
exFAT FAT表标志解释
Bitmap | FAT | Cluster使用状态 |
0 | 没有意义 | 空闲 |
1 | 0或者1 | FAT信息处于保留段,一般不出现 |
1 | 2~ClustersCount+1 | 正在使用并且有Cluster Chain后续节点 |
1 | ClustersCount+2~0xFFFFFFF6 | FAT信息处于保留段,一般不出现 |
1 | 0xFFFFFFF7 | 坏道 |
1 | 0xFFFFFFF7~0xFFFFFFFE | 一般来说是保留段,但是也用来表示坏道 |
1 | 0xFFFFFFFF | 正在使用,且处于Cluster Chain的尾结点 |
Allocation Bitmap并不记录在FAT区域中,分配位图事实上记录在数据区域中。Bitmap的分配信息(主要是该Bitmap对应的起始簇编号和簇的数量)作为一个Directory Entry信息存储在根目录之中;每一个Cluster都在Bitmap中占用一个1bit,如果不足一个Byte,那么剩余部分占据空间但是不使用。
3.3 exFAT中的目录条目
所有的Directory都挂载在根目录Root Directory之中——存储在数据区域中的某个Cluster Chain之中并且定义一个Cluster编号存储于前文提到的Boot Sector中的FisrtClustOfRootDir字段之中。exFAT的目录条目长度也是32B,但是其实际Cluster Chain中存储的内容可以达到256MB也就是8M个Entry——而传统的FATFS只能容纳2MB长度的目录Cluster Chain。exFAT中的目录条目有若干种变种,每个变种的字段表都各不相同,只有首个字节的EntryType字段相同,相关表格如下所示:
exFAT 目录条目EntryType标志位分配
字段分配 | 字段标志位具体含义 |
InUse(bit[7]) | 本bit用于标定本Entry是否正在使用,0表示空闲,1表示占用 |
TypeCategory(bit[6]) | 本bit用于分类,0表示主条目,1表示挂载在主条目上的辅助条目 |
TypeImportance(bit[5]) | 本bit用于标定重要性,0表示重要条目,1表示起始条目 |
TypeCode(bit[4:0]) | 类型代码 |
exFAT 目录条目代码表
条目类型代码 | 条目类型描述信息 |
0x81 | 分配位图Allocation Bitmap |
0x82 | Up-case table大写字母转换表 |
0x83 | 卷标条目(Volume Label) |
0x85 | 文件或者目录(包括文件参数和时间戳) |
0xC0 | 扩展数据流(Stream Extention),记录文件的分配存储信息 |
0xC1 | 文件名 |
0xC2 | Windows CE 访问控制列表 |
0xA0 | 卷GUID |
0xA1 | TexFAT Padding 附加空间 |
0xA2 | Windows CE 访问控制表格 |
上述条目类型中的后四类对于通用性的exFAT系统和驱动程序实际上是可以使用的,但是通常属于扩展类功能,并且在exFAT 1.00标准中未定义,故而使用主体功能是可以不做关心。不同于传统FATFS中标定条目空闲只要设置首字节为0xE5即可,在exFAT中必须将首字节的bit[0] InUse设置为0;如果整个字节EntryType是0x00,那么就代表后续所有的Entry都是空闲而且都是EntryType=0x00。以下是各种主要类型的目录条目字段分配表:
exFAT [Allocation Bitmap]分配位图-条目字段表
字段名称 | 字段偏移 | 字段大小 | 字段功能说明 |
EntryType | 0 | 1 | 0x81 标定为Allocation Bitmap类型 |
BitmapFlags | 1 | 1 | bit[0]:0代表一号bitmap,1代表二号bitmap bit[7:1]保留使用,设置为0 |
Reserved | 2 | 18 | 保留使用,设置为0 |
FirstCluster | 20 | 4 | Allocation Bitmap具体存储数据的第一个Cluster编号 |
DataLength | 24 | 8 | Allocation Bitmap的长度,单位为byte |
exFAT [Up-case Table]大写字符表-条目字段表
字段名称 | 字段偏移 | 字段大小 | 字段功能说明 |
EntryType | 0 | 1 | 0x82 标定为Up-case Table类型 |
Reserved1 | 1 | 3 | 保留使用,设置为0 |
TableChecksum | 4 | 4 | 大写字母表的 32bit 校验和 |
Reserved2 | 20 | 4 | 保留使用,设置为0 |
FirstCluster | 20 | 4 | Up-case Table具体存储数据的第一个Cluster编号 |
DataLength | 24 | 8 | Up-case Table的长度,单位为byte |
exFAT [Volume Label]卷标-条目字段表
字段名称 | 字段偏移 | 字段大小 | 字段功能说明 |
EntryType | 0 | 1 | 0x83 标定为Volume Label类型 |
CharacterCount | 1 | 1 | 卷标长度,单位为双字节,取值范围为0~11 如果大小为0,证明未设定卷标 |
Volume | 2 | 22 | 卷标字符串为UTF-16LE编码。与FAT文件系统不同的是,大小写信息得以保留,并且尾随的空格作为标签的一部分是有效的 |
Reserved | 24 | 8 | 保留使用,设置为0 |
以上三个条目可以独立存在,exFAT除了独立存在的条目之外还包含有一个叫做条目集(Entry Set)的特性,由一系列连续的目录条目组成,用于记录文件或者子目录的元数据。条目集需要含有1个文件或者目录条目(File/Directory Entry, 0x85),1个数据流扩展条目(Stream Extension,0xC0)和1~17个不等的文件名条目(FileName, 0xC1)组成,故而整个条目集的长度为3~19个不等。以下表格将对三类不同的目录条目的具体字段内容做出说明:
exFAT [File and Directory]文件目录-条目字段表
字段名称 | 字段偏移 | 字段大小 | 字段功能说明 |
EntryType | 0 | 1 | 0x85 标定为File/Directory类型 |
SecondaryCount | 1 | 1 | 设定为后续Entry Set中剩余的Entry节点数目,故而整体的Entry Set大小就是SecondaryCount+1 |
SetCheckSum | 2 | 2 | 整个Entry Set(不包括本字段)的16bit校验和 |
FileAttribute | 4 | 2 | 文件属性,和FATFS一样,是各种标志位的组合 0x0001-只读 0x0002-隐藏 0x0004-系统 0x0008-保留 0x0010-目录 0x0020-存档(Archive) bit[15:6]保留设0 |
Reserved1 | 6 | 2 | 保留使用,设置为0 |
CreateTimestamp | 8 | 4 | 文件创建时的时间戳,高16bit是本地日期,低16bit是本地时间,整个数据分配方式和FATFS相同 |
LastModifiedTimestamp | 12 | 4 | 文件最后更改的时间戳,格式同上 |
LastAccessedTimestamp | 16 | 4 | 文件最后的访问时间戳,格式同上 |
Create10msIncrement | 20 | 1 | CreateTimestamp的亚秒统计,单位10ms,范围0-199 |
LastModified10msIncrement | 21 | 1 | LastModifiedTimestamp的亚秒统计,格式同上 |
CreateTZOffset | 22 | 1 | 创建时间戳所使用的时区代码,偏移单位是15分钟 该参数与0x80进行OR位运算 例如GMT+9就是9[小时数]*4[每小时刻数]|0x80=0xA4 当时区选项不用时,设置为0x00 |
LastModifiedTZOffset | 23 | 1 | LastModifiedTimestamp的时区偏置,格式同上 |
LastAccessedTZOffset | 24 | 1 | LastAccessedTimestamp的时区偏置,格式同上 |
Reserved | 25 | 7 | 保留使用,设置为0 |
exFAT [Stream Extension]数据流扩展-条目字段表
字段名称 | 字段偏移 | 字段大小 | 字段功能说明 |
EntryType | 0 | 1 | 0xC0 标定为Stream Extension类型 |
GenertalSecondaryFlags | 1 | 1 | 本字段为标志位组合而成,代表了文件分配的状态 bit[0]<AllocationPossible>:0代表了目前Cluster头节点和文件长度未定义,不可分配;1代表了Cluster头节点和文件长度已定义,可以分配。 bit[1]<NoFatChain>:0代表FAT中记录的Cluster Chain有效;1代表Cluster Chain连续但是在FAT中没有记录 bit[15:2]:保留使用,设置为0 当AllocationPossible==0(少见),本目录条目中三个其他的相关参数:FirstCluster,DataLength,ValidDataLength都无效。 当Cluster Chain连续分配,NotFatChain设置为1并且相关信息不必记录到FAT表当中 |
Reserved1 | 2 | 1 | 保留使用,设置为0 |
NameLength | 3 | 1 | UTF-16编码下文件名的长度,取值范围为0~255 |
NameHash | 4 | 2 | 文件名转换为大写字符后的16bit校验和,这样在按照文件名匹配查找文件时,校验和不匹配可以直接跳过,提高性能 |
Reserved2 | 4 | 2 | 保留使用,设置为0 |
ValidDataLength | 8 | 8 | 以字节为单位的文件有效数据长度,也是实际就写入数据的长度。有效值为0~DataLength。超过此范围的任何数据的结果是完全随机的,对于这种读取,应当认为时0x00。这个字段可以辅助f_allocate函数高效执行。 如果文件本身是个目录,这个值一定要等于DataLength |
Reserved3 | 16 | 4 | 保留使用,设置为0 |
FirstCluster | 20 | 4 | 指向文件存储的Cluster Chain的第一个Cluster编号 如果文件大小为0,那么这个值也应当设置为0 |
DataLength | 24 | 8 | 文件大小,如果是子目录,这个值一定是Cluster大小的整数倍 |
exFAT [File Name]文件名-条目字段表
字段名称 | 字段偏移 | 字段大小 | 字段功能说明 |
EntryType | 0 | 1 | 0xC1 标定为File Name类型 |
GeneralSecondaryFlags | 1 | 1 | 必须设置为0 |
Filename | 2 | 30 | 以UTF-16LE编码的文件名字符串,如果文件名少于15个双字节字符,剩下的部分应当填充为0 注意:exFAT不支持传统FATFS Volume的SFN短文件名 |
3.4 exFAT中的大写字符转换表
在exFAT文件系统中,并不支持SFN格式的文件名,整体按照LFN进行处理,在记录的时候保留大小写信息,并且在文件名匹配时不区分大小写。不同于FATFS的大写转换使用系统的字符表,exFAT文件系统自带大写字符表(Up-case Table)能够提高兼容性。大写字符表必定支持所有的ASCII字符,目前这个表格是Windows在格式化exFAT时自动装填的,将U+0000~U+FFFF压缩后记录到表中。以下提取压缩表并且创建大小写转换关系的代码:
exFAT 大写字符表映射算法
/**
* @brief 该函数用于建立大小写转换表
* @param dst 是一个16bit类型数组,输出U+0000~U+FFFF的转换表
* @param src 压缩过的待转换的字符表
* @param n_src 压缩表的大小
void load_upcase(uint16_t dst[], uint16_t src[], uint16_t n_src){
uint16_t c, si, di;
//将转换表预先按照默认值填充
c = 0;
do dst[c] = c;
while(++c);
si = di = 0;
do{
c = src[si++]; //读取一个大写字符
if(c == 0xFFFF && si < n_src)
di += src[si++]; //当读取到U+FFFF字符,跳过
else
dst[di++] = c; //存储大写字符
}while(si < n_src);
}
3.5 exFAT操作过程中的其他不同
- exFAT在分区时几乎与FAT相同,但是MBR格式不支持大于2TB的设备,所以如果exFAT Volume的存储容量大于2TB,那么需要使用GPT分区格式[不推荐SFD分区格式]
- exFAT在创建文件时找到一个空闲的Entry并且创建Entry Set,并且标记为Archive属性,文件长度设置为0,AllocationPossible=1,NoFatChain=0。初次向文件中写入信息分配新的Cluster时将这个编号设置为FirstCluster并且令NoFatChain=1;只要Cluster Chain连续,就不记录到FAT;反之则将NoFatChain设置为0并且写入所有信息到FAT。
- exFAT在创建目录时与FATFS有一个区别:不在目录中写入点目录”.”和”..”,后使用逻辑代码处理
- exFAT在删除文件时相比FATFS需要清除Entry中的InUse bit;并且如果该文件的Cluster Chain在FAT中有记录,清除FAT的方法也有不同,只需要将Allocation Bitmap中的bit标定为空闲即可,不必真的更改FAT中的Entry。
- exFAT在删除目录时和FATFS的区别同上,也需要注意递归动作,否则造成Cluster泄露。