简介:这是一份基于Keil开发环境的51单片机读写SD卡与FAT32文件系统完整源码工程,面向单片机应用开发、电子竞赛及嵌入式初学者,帮助解决在8051平台上挂载与读写FAT16/FAT32文件系统的实际需求。工程参考振南电子znFAT范例,代码结构清晰,打开即可编译,经测试可通过;运行时通过串口输出SD卡的文件系统信息与目录内容,便于读者对照打印结果理解FAT表、目录项、扇区读写等底层机制。压缩包共42个文件,以C源文件和H头文件为源码核心,另有ASM汇编、OBJ目标、LST列表、HEX固件以及工程配置、运行效果截图等辅助文件,整体约175KB,类型覆盖从源码到中间产物再到烧录文件的完整链路。目前已有1770人学习使用,对于希望快速上手51读写SD卡或参考成熟文件系统移植方案的开发者,是一份可直接编译、对照学习的高性价比基础资源。
1. 51单片机读写SD卡上的FAT32:硬骨头但值得啃
51单片机读写SD卡上的FAT32,这组合听起来像拿8位老古董去伺候32位文件系统,但它偏偏是低成本数据采集、环境监测、设备日志这一档产品里最常出现的存储方案。做这件事的人需求通常很具体:设备断电不能丢数据,采集的数据能插上电脑直接读,最好还能从电脑拷贝配置文件给设备。标题里的单片机读写FAT32、51读写SD卡、51读写文件系统源码,拆开就是三层活:SPI驱动SD卡、解析FAT32结构、在51极其有限的RAM和ROM里把前两件事撑起来。这篇文章按这三层依次讲,新手能照命令和代码跑通,熟手可以直接跳到参数说明和坑位部分对照。
2. FAT32对51意味着什么:选型理由与架构边界
2.1 裸写物理扇区的三个代价
第一版方案直接对SD卡物理扇区读写,这是最快的跑通方式,但交到使用者手里全是问题。首先是数据没法在电脑上直接看,必须用专门读扇区的工具,客户没这个工具也不会用。其次是定位困难,没有索引表,要知道哪段数据对应哪个扇区,就得自己记扇区号,时间一长或者拷过几次卡,记录对不上的情况非常普遍。第三是覆盖管理缺失,日志类应用写满后要回头覆盖老区域,没有FAT表的分配信息,很容易反复擦写同一批物理块,加速卡老化。FAT32这套结构被操作系统原生识别,格式化、文件读写、空间分配都是现成的,对低频写入的日志应用来说,索引开销完全可以接受。
我接过一个某公司的高温记录仪需求,第一批样机按物理扇区读写,在逻辑分析仪上验证一切正常,到了客户手里导数据需要专员拿十六进制工具找扇区,用了一周就被退回来,第二版才补上FAT32。从那以后我接存储类需求先问一句:数据要不要让非程序员直接读?答案是肯定的,就老老实实上文件系统。
| 对比项 | 裸写物理扇区 | FAT32文件系统 |
|---|---|---|
| 电脑可读性 | 完全不可读,需专用工具 | 插读卡器直接可读 |
| 数据定位 | 手工记录扇区号 | 目录项+簇链自动定位 |
| 覆盖管理 | 无,容易重复擦写同一区域 | FAT表按簇分配,均衡覆盖 |
| 代码量 | SPI层约1K ROM | 裁剪后约6~12K ROM |
| RAM开销 | 512字节扇区缓冲 | 扇区缓冲+目录项+FAT缓冲,约需1KB以上 |
从ROM和RAM开销能看出,FAT32不是没有代价,但对于“能插电脑直接读”这个需求,它是唯一不需要写上位机软件的选择。
2.2 SPI模式下的容量、速度与资源预算
SD卡支持SD和SPI两种总线模式,51全部走SPI。SPI模式下只有一根数据线收、一根数据线发,没有命令队列和宽总线,协议极其简单,代价是速度。初始化阶段SPI时钟必须压到400kHz以下,这是SD卡规范里明确写的,很多卡在更高频率下初始化不稳定。工作阶段即使允许提高时钟,51主频一般12MHz到24MHz,硬件SPI分频后能跑到1~2MHz算不错,纯软件模拟再打五折。实测读一个512字节扇区几十毫秒,写扇区要等卡内部编程,通常100到200毫秒。这个速度框定了适用范围:每分钟一条采样记录、每天一个配置文件、定期追加的小批量日志,这些没问题;视频、图片、大批量音频这类连续大文件,不适合用51做。
资源方面最现实的瓶颈是RAM。FAT32解析一个目录项要32字节,一个FAT表扇区512字节,加上数据扇区缓冲512字节,总量已经超过常见51芯片内置256字节RAM。我一般看到两种做法:一种是选带1KB以上内部xdata的51内核1T芯片,价格贵不了几毛钱,缓冲全部放内部,代码最简单;另一种是外扩SRAM或串行RAM,适合老款芯片。ROM方面,SPI驱动加FAT32只读解析精简后约6KB,如果要完整写文件,会到10到12KB,选4K ROM的芯片根本塞不下,至少8K起步。
关于文件系统选型,FAT16在128MB以内小卡上效率不错,但如今小容量卡不好买,Windows 10/11格式化也默认不提供FAT16选项,FAT32兼容性最好,直接定它就行。51处理FAT32的难点不在协议规范本身,而在32位簇号和FAT表项的数学运算,这部分第4章专门讲。1T 51内核芯片的型号很多,不必拘泥于某一家,选的时候先确认xdata容量和硬件SPI外设,有两样能省很多事。
3. SD卡底层读写:手写SPI协议,从初始化到单扇区读写
3.1 SD卡上电时序:从CMD0到ACMD41的关键命令序列
SD卡进入SPI模式的流程有固定顺序。卡上电后先处于SD模式,主机需要发送至少74个时钟周期让卡内部电源稳定,然后拉低片选,发送CMD0。CMD0是进入SPI模式的总开关,卡在收到CMD0后才会切到SPI,返回R1响应0x01。CMD0必须带正确CRC字节0x95,这是SD规范里少数在SPI模式下仍然强制校验CRC的命令。很多人省掉这一个字节,结果卡永远不回包,我统计过一个批次的卡,初始化卡死有九成是这里。
CMD0之后发CMD8,参数固定0x000001AA,CRC字节0x87。支持SD2.0协议的卡会返回R7响应,不支持的老卡返回R1的0x01,这一条只是探测电压范围,不影响后续流程。接着循环发送CMD55加ACMD41,ACMD41参数的第30位置1,表示请求高容量SDHC卡,循环直到返回0x00表示就绪。最后发CMD58读OCR,从响应里检查第30位CCS,CCS为1是SDHC或SDXC,块地址就是扇区号;CCS为0是SDSC,块号必须换算成字节地址。这个分支决定后面所有读写命令的参数格式,容易埋雷。
sbit SD_CS = P1^4; unsigned char SD_Init(void) { unsigned char i, res; SD_CS = 1; for (i = 0; i < 10; i++) { SD_SPI_Write(0xFF); /* 上电至少74个时钟,多写几十个更稳 */ } SD_CS = 0; /* 片选拉低才允许进入SPI模式 */ res = SD_Cmd(0, 0x00000000, 0x95); if (res != 0x01) return 1; /* 期望R1=0x01,已进入SPI模式 */ res = SD_Cmd(8, 0x000001AA, 0x87); if (res == 0x01) { /* V2以下老卡不认CMD8,返回0x01;V2以上卡返回R7,不用管 */ } do { res = SD_Cmd(55, 0x00000000, 0xFF); /* 先发CMD55,预告下一条是应用特定命令 */ res = SD_Cmd(41, 0x40000000, 0xFF); /* HCS=1请求SDHC */ } while (res != 0x00); /* 0x00表示ACMD41被接受,卡初始化完成 */ res = SD_Cmd(58, 0x00000000, 0xFF); /* 读OCR,确认CCS位 */ /* R3响应共4字节,需要连续读4次,bit30=1为SDHC */ res = SD_Cmd(16, 0x00000200, 0xFF); /* 设置块长512字节 */ return 0; }SD_Cmd是自制的发命令函数,负责发送6字节命令帧并读回R1响应。ACMD41循环里的CMD55每次都必须重新发送,有的初学者把CMD55放在循环外面,结果ACMD41永远不被接受。CMD58返回的R3有4个字节,如果只读第一字节,CCS位判定就是错的。CMD16在SDHC卡上可以省略,因为SDHC块长固定512,但保留它能让SDSC卡也正常工作,代价只是多一次命令交互。
初始化阶段的SPI时钟建议用最大分频,让SCLK在400kHz以下。有些代码上来就用高频初始化,低速老卡直接不响应。还有一个细节:发送CMD0之前,MOSI要保持高电平,如果MOSI拉低过,卡可能把总线状态误判成数据。
3.2 单扇区读写:CMD17和CMD24的C代码与响应检查
初始化通过后基础操作就两个:CMD17读一个扇区,CMD24写一个扇区。读扇区时,主机发完CMD17,先等R1响应为0x00,然后继续读数据线,直到看到0xFE数据起始令牌,后面紧跟512字节数据和2字节CRC。CRC在SPI模式下可以不校验,但必须把那两个字节读掉并丢弃,否则下一次命令的响应会被旧数据污染。
unsigned char SD_Read_Sector(unsigned long addr, unsigned char *buf) { unsigned char res; unsigned int i; SD_CS = 0; res = SD_Cmd(17, addr << 9, 0xFF); /* addr<<9: 扇区号转字节地址 */ if (res != 0x00) { SD_CS = 1; return 1; } i = 0; while (1) { res = SD_SPI_Write(0xFF); /* 读数据必须持续发时钟 */ if (res == 0xFE) break; /* 数据起始令牌 */ if (++i > 20000) { SD_CS = 1; return 2; } /* 超时约200ms */ } for (i = 0; i < 512; i++) { buf[i] = SD_SPI_Write(0xFF); /* 每个字节都由主机时钟驱动 */ } SD_SPI_Write(0xFF); /* 丢弃CRC低字节 */ SD_SPI_Write(0xFF); /* 丢弃CRC高字节 */ SD_CS = 1; SD_SPI_Write(0xFF); /* 释放总线 */ return 0; }这里addr左移9位是把扇区号乘以512,等价于字节地址。如果初始化判定为SDHC,直接用扇区号即可,不需要移位;SDSC才需要乘512。为了统一接口,我一般在调用层约定:传入的永远是物理扇区号,函数内根据CCS标志决定是否左移。混用最常见的现象是读小容量卡前半段正常,后半段数据乱跳,这是地址空间错位。
写扇区用CMD24,流程比读多一步忙等待。卡在写完数据后会把数据线拉低表示忙,主机必须持续发0xFF直到读到0xFF才表示写入完成。写命令是否成功不能只看CMD24的R1,很多第一次做的人在这里误判,把R1为0当成写成功,实际上真正决定成败的是忙周期后的返回值。
unsigned char SD_Write_Sector(unsigned long addr, unsigned char *buf) { unsigned char res; unsigned int i; SD_CS = 0; res = SD_Cmd(24, addr << 9, 0xFF); if (res != 0x00) { SD_CS = 1; return 1; } SD_SPI_Write(0xFE); /* 写起始令牌 */ for (i = 0; i < 512; i++) { SD_SPI_Write(buf[i]); } SD_SPI_Write(0xFF); /* 伪CRC */ SD_SPI_Write(0xFF); i = 0; while (SD_SPI_Write(0xFF) != 0xFF) { /* 忙时数据线被拉低,读到0xFF代表写完 */ if (++i > 30000) { SD_CS = 1; return 2; } } SD_CS = 1; SD_SPI_Write(0xFF); return 0; }写忙的等待时间因卡而异,一般几十到几百毫秒,循环上限要放宽到3万次以上。有人把while条件写成 != 0x00,意思是一读到0xFF就退出,也能工作,但语义容易误解,我建议明确写成读到0xFF才退出。写完片选拉高后,再补一个0xFF释放总线,这是为了让卡在CS拉高前完成最后的信号释放,玄学成分有一点,长期用下来能减少偶发下一条命令错位。
调试顺序建议:SD_Init返回0后,先读扇区0看内容,正常卡的第一扇区是MBR或引导扇区,十六进制串口打印出来应该有“EB 58 90”或者分区表的结尾标志0x55AA。如果这一步能过,物理层就是通的,再往FAT32走。很多人跳过这一步直接解析文件系统,出了问题分不清是卡没通还是协议错了,排查起来非常痛苦。
4. FAT32结构拆解:从BPB解析到文件定位
4.1 BPB参数:扇区大小、FAT起始与根目录簇号
FAT32引导扇区也叫VBR,从第0扇区开始。关键参数分布在固定偏移位置,协议是小端存储,低字节在前。51的C编译器没有原生32位变量对齐保证,直接定义结构体读出来,字节序经常是反的,我全部按偏移量手动拼装。需要关心的参数:0x0B到0x0C是每扇区字节数,固定512;0x0D是每簇扇区数,常见值1、8、16、32、64;0x0E到0x0F是保留扇区数,通常为32;0x10是FAT表个数,通常为2;0x24到0x27是每个FAT表占多少扇区;0x2C到0x2F是根目录起始簇号,FAT32下一般是2,这个值不能写死,从卡上读最稳妥。
unsigned char g_BPB[512]; unsigned int g_BytePerSec; unsigned char g_SecPerClus; unsigned int g_ResvSecCnt; unsigned char g_NumFATs; unsigned long g_FATSz; unsigned long g_RootClus; unsigned char FAT32_Load_Param(void) { if (SD_Read_Sector(0, g_BPB) != 0) return 1; if (g_BPB[0x0B] != 0x00 || g_BPB[0x0C] != 0x02) return 2; /* 校验512字节扇区 */ g_BytePerSec = g_BPB[0x0B] | ((unsigned int)g_BPB[0x0C] << 8); g_SecPerClus = g_BPB[0x0D]; g_ResvSecCnt = g_BPB[0x0E] | ((unsigned int)g_BPB[0x0F] << 8); g_NumFATs = g_BPB[0x10]; g_FATSz = (unsigned long)g_BPB[0x24] | ((unsigned long)g_BPB[0x25] << 8) | ((unsigned long)g_BPB[0x26] << 16) | ((unsigned long)g_BPB[0x27] << 24); g_RootClus = (unsigned long)g_BPB[0x2C] | ((unsigned long)g_BPB[0x2D] << 8) | ((unsigned long)g_BPB[0x2E] << 16) | ((unsigned long)g_BPB[0x2F] << 24); return 0; }这段代码把BPB参数存成全局变量,后续所有函数都直接引用。扇区大小校验写在最前面,如果卡上是个FAT16卷或者其他格式,从这里就会暴露。拼装32位值时,强制转换每个字节到unsigned long再移位,避免中间量被16位截断。这些全局变量建议显式声明到xdata区域,51默认data区只有几十字节,放不下这些结构。
有了这些参数,可以算出两个最基础的值。FAT起始扇区等于保留扇区数,也就是g_ResvSecCnt。数据区起始扇区等于FAT起始扇区加上FAT表个数乘以每FAT扇区数,公式:DataStart = g_ResvSecCnt + g_NumFATs * g_FATSz。这两个值后面所有簇号换算都要用。
4.2 遍历根目录项:8.3文件名与首簇号
FAT32的根目录和普通目录一样,在数据区里通过簇链组织,不是固定在某个扇区。根目录起始扇区换算公式是:DataStart + (g_RootClus - 2) * g_SecPerClus。这里必须减2,因为FAT32的簇号从2开始,2号簇对应数据区第一扇区。公式里减去的2是FAT32的标准行为,很多实现把这一步漏掉,结果读到的全是数据区前部空间,完全对不上目录内容。
目录项每32字节一条,关键字段在固定位置。0x00到0x07是文件名的前8字节,不足部分用0x20空格补齐;0x08到0x0A是扩展名3字节;0x0B是属性字节;0x14到0x15存首簇号高16位;0x1A到0x1B存首簇号低16位;0x1C到0x1F存文件大小。属性字节0x08是卷标,0x0F是长文件名项,0x10是目录。51上处理长文件名成本太高,直接跳过0x0F项只匹配8.3短名。
| 目录项字段 | 偏移 | 长度 |
|---|---|---|
| 文件名 | 0x00 | 8字节 |
| 扩展名 | 0x08 | 3字节 |
| 属性 | 0x0B | 1字节 |
| 首簇高16位 | 0x14 | 2字节 |
| 首簇低16位 | 0x1A | 2字节 |
| 文件大小 | 0x1C | 4字节 |
遍历根目录的代码:
unsigned char FAT32_Find_File(unsigned char *file_name, unsigned long *file_size, unsigned long *file_cluster) { unsigned long sector = FAT32_Cluster_To_Sector(g_RootClus); unsigned char dir_buf[512]; unsigned int i; while (1) { if (SD_Read_Sector(sector, dir_buf) != 0) return 1; for (i = 0; i < 16; i++) { /* 512 / 32 = 每扇区16项 */ unsigned char *ent = dir_buf + i * 32; if (ent[0] == 0x00) return 2; /* 0x00表示目录结束 */ if (ent[0] == 0xE5) continue; /* 0xE5表示空目录项 */ if ((ent[11] & 0x0F) != 0x00) continue; /* 跳过卷标、长文件名、目录 */ if (FAT32_Name_Match(ent, file_name)) { *file_size = (unsigned long)ent[28] | ((unsigned long)ent[29] << 8) | ((unsigned long)ent[30] << 16) | ((unsigned long)ent[31] << 24); *file_cluster = ((unsigned long)ent[20] << 16) | ((unsigned long)ent[21] << 24) | ent[26] | ((unsigned long)ent[27] << 8); return 0; } } sector = FAT32_Next_Cluster(sector, 0); if (sector == 0xFFFFFFFF) break; /* 簇链结束 */ } return 3; }首簇号的拼装顺序特别容易出错,高16位在偏移20和21,低16位在偏移26和27。有人顺手按连续偏移拼,得到的是一个错误大簇号,读出来的数据完全不是文件开头。文件大小同理,四字节逐位拼,不能用联合体直接赋值。FAT32_Name_Match把用户输入的“ABC TXT”十一字节和目录项前11字节做比较,需要注意目录项内文件名大写、小写字母存在时会被格式化为大写,字母大小写敏感比较可能匹配不上。
4.3 FAT表追簇链:顺序读文件时的高效路径
拿到文件首簇后,读数据前先把簇号换算成扇区:DataStart + (cluster - 2) * g_SecPerClus。一个簇可能包含多个连续扇区,读取时优先把整个簇读完,再查FAT表找下一簇,比每512字节查一次FAT快很多。FAT表项在FAT32里4字节一条,第N个簇的表项偏移等于N乘以4,对应的扇区是FAT起始扇区加上偏移除以512,表内偏移是偏移对512取余。查表时h国经典坑是cluster乘以4在51的unsigned int下溢出,cluster超过16383就出问题,必须把cluster转成unsigned long再乘。
unsigned long FAT32_Read_File(unsigned long start_cluster, unsigned long size, unsigned char *buf, unsigned long buf_len) { unsigned long cluster = start_cluster; unsigned long pos = 0; unsigned char sector_buf[512]; unsigned int clus_bytes = (unsigned int)g_SecPerClus * 512; unsigned int sec_offset = 0; while (pos < size && pos < buf_len) { unsigned long sector = FAT32_Cluster_To_Sector(cluster); unsigned int to_read = (size - pos) < clus_bytes ? (unsigned int)(size - pos) : clus_bytes; while (to_read > 0) { unsigned int n = (to_read > 512) ? 512 : to_read; if (SD_Read_Sector(sector + sec_offset, sector_buf) != 0) return pos; memcpy(buf + pos, sector_buf, n); pos += n; to_read -= n; sec_offset++; } sec_offset = 0; cluster = FAT32_Next_Cluster(cluster); if (cluster >= 0x0FFFFFF8) break; /* 簇链结束 */ } return pos; }这里每读完一个簇才查一次FAT表,簇内多个连续扇区通过内层循环读。如果文件有碎片,每到一个新簇就要多读一个FAT扇区,碎片多了读速度会明显下降,这是FAT32本身的特性。memcpy的buf参数指向外部RAM缓冲区,调用前要确认缓冲区够大,否则溢出会覆盖其他全局变量,导致各种诡异问题。
FAT32_Next_Cluster的实现有两个边界情况必须处理:0x0FFFFFF7是坏簇标记,遇到它说明数据区有坏块,继续追会死循环;0x0FFFFFF8及以上的值都是正常簇链结束。上层拿到这两个值之一就该停止读文件。我一般统一返回0xFFFFFFFF表示异常,调用方判断一次就够。这两个函数是所有FAT32功能的基石,建议单独放一个模块文件,改的时候只动这一处。
5. 51做FAT32最常见的翻车:现象、原因与排查
5.1 初始化死循环:卡在等待CMD0的0x01回包
现象是SD_Init里执行到SD_Cmd(0)就不往下走,调试发现返回值一直不是0x01,或者卡死在ACMD41的do-while循环里。
排查下来最常见的原因有三个。第一,CMD0之前的主机时钟不足,卡还在内部上电状态,CMD0被忽略。第二,SPI时钟频率太高,低速老卡跟不上,规范要求初始化在400kHz以下,有些代码图省事直接全速初始化。第三,片选时序不对,主机先拉低CS再发时钟,卡在上电瞬间看到CS有效但总线没有稳定时钟,状态机直接乱了。
解决方法是初始化函数开头强制拉高CS,发12个0xFF凑够96个时钟;初始化阶段SPI分频调到最大;CMD0发完多读4个字节,R1不一定在第一个字节出现,有的卡延迟几个时钟才回。换一张不同品牌的老卡对比测试也有助于定位,新卡在SPI模式下的兼容性有时候反而不如老卡。如果有逻辑分析仪,抓CS、CLK、MOSI、MISO四根线,CMD0应为6字节帧,之后MISO上出现0x01,没有就按上面的顺序逐条查。
5.2 读到文件内容全0xFF:读数据时没有持续发时钟
现象是初始化正常、FAT32参数解析正常、文件名匹配也成功,但读出来的缓冲区全是0xFF,偶尔前面几十字节正确后面全错。
原因几乎都在SPI读函数的实现上。SPI是全双工协议,主机不发时钟,从机就不会输出有效数据。如果读函数直接返回硬件寄存器值而没有发起写操作,数据线上根本没有时钟,卡不会吐数据。我在一个仓促上线的采集器项目里见过这种实现,初始化时读命令响应恰好能返回,因为命令本身就是主机写出去的,但后续读数据时函数只读不写,全部返回0xFF,程序卡在等0xFE的死循环里。
解决方法是把读和写统一封装成一个函数,发送0xFF同时接收数据,每读一个字节都必须产生至少8个时钟。等待数据起始令牌0xFE时,令牌前的0xFF是正常填充,令牌如果出现0x00,说明SD卡传输层错误,此时应退出并返回错误码,不能一直死等。给循环加超时,超时后片选拉高复位SD状态机,再查FAT32的调用逻辑。
5.3 文件大小对不上:16位溢出与32位乘除法
现象是小文件读写正常,文件超过2MB或者簇号上千后,读出来的内容比实际多几个扇区,或者文件大小显示成几KB,明显缺了一大截。
原因是51的unsigned int是16位,最大值65535,而FAT32的各种计算公式动辄超出这个范围。两个典型位置:一是簇号乘以每簇扇区数,cluster乘g_SecPerClus,cluster超过32767除以每簇扇区数的值就溢出;二是FAT表项寻址时cluster乘4,cluster超过16383就直接截断。溢出后算出来的扇区号是错的,读到的自然是错误数据。
解决方式是所有涉及簇号的数学运算都显式转换成unsigned long再计算。有性能顾虑的话,可以判断cluster小于0x4000走16位快速路径,否则走32位路径,省掉一部分库函数调用时间。但实际瓶颈是SD卡读取的毫秒级延时,不是这几条算术指令,我一般直接全用32位,代码更整洁。另外不要用浮点参与任何簇号计算,51没有硬件浮点,一个浮点乘法能吃掉上百个指令周期,完全没必要。
5.4 写完数据电脑不认:扇区回写不完整与FAT表未同步
现象是写操作函数返回0,卡插到电脑上看不到文件,或者文件大小为0,更隐蔽的是老文件出现内容损坏。
原因大概率是只更新了数据扇区,没有同步更新FAT表和目录项。FAT32写一个完整文件至少要涉及三处修改:数据扇区、FAT表项、目录项。FAT表不更新,目录项指向的簇链就是旧的,电脑读出来的还是老内容或空白。还有一个常见误操作:更新FAT表时只把修改的几个字节写进去,没有先读回整个512字节扇区,结果同一扇区里其他文件的FAT表项全部被清掉,表现为其他文件一起损坏。
解决方法是所有写操作都遵循读改写模式:先把目标扇区读到缓冲区,修改需要的字节,再把整个512字节回写。FAT表扇区包含多个文件的信息,尤其必须整扇区读改写。写目录项时,文件名、首簇号、文件大小、时间戳必须一起更新,缺一不可。数据扇区也建议读改写,哪怕只是改一个扇区里的部分内容。掉电场景下,写FAT表前把旧值备份到另外一个缓冲区,写完FAT1再写FAT2,写完后读回验证,能减少掉电损坏历史文件的概率。我在模拟项目X上遇到过一次掉电后目录项还在、数据全丢的情况,复盘就是FAT2没有同步,从那以后写FAT表必更新两份。
6. 进阶:只读方案优化与验证,让51的FAT32走得更稳
6.1 同簇连续扇区合并读:省一半FAT查询
51读文件最耗时的动作其实是FAT表查询,多读一个扇区就多几十毫秒。FAT32格式化的卡,文件在刚写入时通常是连续的,物理上占用的扇区一个挨着一个。如果在读完首簇后,先检查FAT表项是不是连续递增,就能把整个文件当成一个大块连续读,中间不需要反复查FAT表。判断逻辑很简单:从首簇开始,逐项读FAT表,每一项都等于前一项加1,走到文件结束就算连续。连续读的缓冲区可以开大一点,一次读多个扇区,速度提升非常明显。这个优化对配置导入、固件升级包这类一次性读取的场景特别合适,对随机读意义不大,因为随机读本来就要频繁查FAT表。做这个优化时,大缓冲区要放到xdata外部RAM,51内部RAM放不下几个扇区。
6.2 低成本验证方法:读卡器回读比对与掉电测试
51上没有文件系统调试器,最可靠的验证工具就是读卡器。我的调试流程是固定的:电脑上格式化一张小容量TF卡,格式化成FAT32,分配单元大小保持默认不乱改;拷入一个已知大小的TEST.TXT文件;插到板子上,用单片机读出文件内容,通过串口打印和电脑里的原始文件逐字节比对。这一步通过说明读链路通。写方向反过来,让单片机新建一个文件,写入固定内容,正常返回后把卡插回读卡器,用十六进制工具打开检查文件大小和内容,再做改名、删除、追加的回归测试。
掉电测试一定不能省。在写文件代码里加一个外部按键模拟突然断电,分别在数据扇区写入时断电、FAT表回写时断电、目录项更新后断电,三种时刻各断电50次,插卡检查历史文件有没有损坏。FAT32本身没有日志,掉电必然丢最后一次写的部分数据,但好的实现能保证不把之前的老文件搞坏。如果老文件出现损坏,基本可以断定是FAT表扇区被部分覆盖了,按第5章读改写的方式修正。
有一个习惯我坚持了很久:涉及FAT表修改的操作,第一次写入先更新FAT2,再更新FAT1,保证两份表至少有一份完整。某次现场导出日志,卡里存了三个月的记录,过半数文件打不开,复盘发现就是掉电时FAT1写了一半,FAT2还是旧状态,两边都对不上,从那以后这个习惯救了好几个项目。FAT32的源码模块最好也按SPI驱动、FAT32解析、应用接口分成三个文件,调试哪一个就单独替换哪一个,不要揉在一起。希望帮到你。
本文还有配套的精品资源,点击获取