简介:本资源面向嵌入式开发学习者与STM32工程师,提供一套基于STM32F429的SD卡FatFS文件系统与以太网TCP服务器完整工程,解决网络数据接收后本地存储为.csv文件的实际需求。压缩包共1482个文件,约59.43MB,以629个c源文件、382个h头文件为核心,辅以144个d依赖文件、144个o目标文件、142个crf交叉引用文件及lib、a等库文件,并包含uvprojx、uvoptx工程配置与hex烧录文件,工程结构完整可直接编译。内容覆盖FatFS移植与参数配置、SDIO/SPI底层驱动、f_open与f_write生成CSV格式记录,以及lwIP协议栈下TCP服务器监听、数据接收与写入流程,涉及MAC地址配置、连接建立与中断处理等关键环节。已有8150人学习下载,适合希望打通存储与网络通信链路的开发者参考复用。
1. 从一张“读不出数据”的SD卡说起:STM32挂载FatFs并生成.csv的完整路径
实验室里常有这种场景:一块STM32板子跑了一周的数据采集,拔下SD卡插到电脑上,文件夹是空的,或者.csv文件只有表头没有内容。排查半天发现不是卡坏了,而是文件系统根本没挂上,或者f_write之后没做f_sync,数据全卡在缓存里。这类问题在嵌入式存储里非常典型,也是很多做数据记录设备的工程师绕不开的一道坎。
这份资源要解决的就是这件事:在STM32上通过SDIO或SPI接口驱动SD卡,移植FatFs文件系统,把采集到的数据按行写入.csv文件,最终生成一个能被Excel直接打开、带列分隔的表格文件。它适合做工业数据记录、传感器日志、设备黑匣子这类需要离线存储的场景,也适合正在学FatFs移植、想搞懂底层读写链路的人。整套流程不依赖操作系统,裸机就能跑,资源占用可控,是嵌入式文件存储里最稳的一条路。
2. FatFs移植与SD卡底层驱动:从diskio到f_mount的完整链路
2.1 为什么选FatFs而不是自己写文件系统
在STM32上做SD卡存储,绕不开文件系统这一层。裸写扇区当然可以,但你要自己管理FAT表、目录项、簇链,一旦数据量上来,维护成本极高。FatFs的好处是它把这一层全封装了,对外只暴露f_open、f_write、f_close这几个接口,你只需要实现底层disk_read、disk_write、disk_initialize和disk_ioctl四个函数,剩下的交给它。
选FatFs还有几个现实理由。第一,它是纯C写的,跟STM32的HAL库、标准库都能配合,不依赖操作系统,裸机跑没问题。第二,它支持FAT12/16/32和exFAT,市面上常见的SDHC卡(32GB以下)默认就是FAT32,插上就能用。第三,它的配置集中在ffconf.h里,裁剪方便,比如你不需要长文件名,把_USE_LFN关掉能省不少Flash。第四,生成.csv本质上就是写文本文件,FatFs的f_printf或者f_write配合sprintf就能搞定,不需要额外库。
常见做法是:SD卡走SDIO 4位模式,DMA搬运,diskio层直接调HAL_SD_ReadBlocks和HAL_SD_WriteBlocks。如果板子没有SDIO,走SPI也行,速度慢一些,但代码更简单,适合小数据量记录。
2.2 底层diskio.c的四个函数怎么写
FatFs移植的核心就是diskio.c。这个文件里你要把FatFs的抽象接口映射到具体的SD卡驱动上。下面是我一般会用的写法,基于SDIO+DMA的方式。
/* diskio.c - FatFs底层接口映射 */ #include "ff.h" #include "diskio.h" #include "sdio_driver.h" /* 自己封装的SD卡驱动 */ /* 初始化SD卡,返回0表示成功 */ DSTATUS disk_initialize(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; if (SD_Init() != SD_OK) { return STA_NOINIT; /* 卡没插好或初始化失败 */ } return 0; } /* 读扇区:sector是扇区号,count是扇区数,buff是目标缓冲区 */ DRESULT disk_read(BYTE pdrv, BYTE *buff, DWORD sector, UINT count) { if (pdrv != 0) return RES_PARERR; if (SD_ReadBlocks(buff, sector, count) != SD_OK) { return RES_ERROR; } return RES_OK; } /* 写扇区:注意SD卡写操作必须以扇区为单位,不能跨扇区写 */ DRESULT disk_write(BYTE pdrv, const BYTE *buff, DWORD sector, UINT count) { if (pdrv != 0) return RES_PARERR; if (SD_WriteBlocks((BYTE *)buff, sector, count) != SD_OK) { return RES_ERROR; } return RES_OK; } /* 控制命令:获取扇区数量、扇区大小等 */ DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { DRESULT res = RES_ERROR; if (pdrv != 0) return RES_PARERR; switch (cmd) { case CTRL_SYNC: res = RES_OK; /* 确保写入完成,SDIO模式下一般不用额外操作 */ break; case GET_SECTOR_COUNT: *(DWORD *)buff = SD_GetSectorCount(); res = RES_OK; break; case GET_SECTOR_SIZE: *(WORD *)buff = 512; res = RES_OK; break; case GET_BLOCK_SIZE: *(DWORD *)buff = 1; /* 擦除块大小,SD卡填1即可 */ res = RES_OK; break; default: res = RES_PARERR; break; } return res; } /* 获取卡状态,简单返回0表示就绪 */ DSTATUS disk_status(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; return 0; }这段代码的逻辑很直接:disk_initialize负责把卡初始化到可读写状态,disk_read和disk_write是实际的数据搬运,disk_ioctl提供FatFs需要的元信息。参数上要注意几个点。pdrv是物理驱动号,单卡场景固定填0。sector是逻辑扇区号,从0开始,FatFs会自己算偏移。count是连续扇区数,SDIO的HAL_SD_ReadBlocks支持多块读,但要注意DMA缓冲区地址必须4字节对齐,否则会进HardFault。
还有一个容易翻车的地方:SD_ReadBlocks和SD_WriteBlocks的返回值判断。很多SD卡驱动在初始化时返回SD_OK,但实际读写时因为卡的质量问题会返回SD_ERROR,这时候disk_read要如实返回RES_ERROR,让FatFs知道这次操作失败了,否则FatFs会以为写成功,后面文件系统就乱了。
2.3 f_mount挂载与格式化判断
底层通了之后,上层就是f_mount。这一步是把FatFs的工作区和SD卡的物理驱动关联起来。
/* main.c - 挂载文件系统 */ #include "ff.h" FATFS fs; /* 文件系统对象 */ FIL fil; /* 文件对象 */ FRESULT fres; void FatFs_Init(void) { /* 挂载驱动器0,第二个参数1表示立即挂载 */ fres = f_mount(&fs, "0:", 1); if (fres != FR_OK) { /* 挂载失败,可能是卡没格式化 */ printf("f_mount failed: %d\r\n", fres); /* 尝试格式化,注意:格式化会清空卡上所有数据 */ fres = f_mkfs("0:", FM_FAT32, 0, NULL, 0); if (fres == FR_OK) { f_mount(&fs, "0:", 1); /* 格式化后重新挂载 */ } } }f_mount的第二个参数是逻辑驱动器号,格式是“0:”或“0:/”,不能写成“0”。第三个参数是立即挂载标志,填1表示现在就执行挂载,填0表示延迟到第一次访问时挂载。我一般填1,这样能尽早发现卡的问题。
如果f_mount返回FR_NOT_READY,说明底层disk_initialize没成功,要回去查SD卡初始化流程。如果返回FR_NO_FILESYSTEM,说明卡上没有FatFs能识别的文件系统,这时候要么用电脑格式化成FAT32,要么调f_mkfs在板子上格式化。f_mkfs的参数里FM_FAT32指定格式类型,第三个参数是簇大小,填0表示自动选择,最后一个参数是工作缓冲区,填NULL表示用FatFs内部的。
注意:f_mkfs会清空卡上所有数据,量产设备里不要随便调,最好在出厂前用电脑格式化好。
3. 生成.csv文件:f_open、f_write与f_sync的时序细节
3.1 创建文件与写入表头
.csv文件本质是纯文本,逗号分隔列,换行分隔行。生成它的第一步是用f_open创建文件,模式选FA_CREATE_ALWAYS | FA_WRITE,这样每次上电都会新建一个文件,避免追加到旧数据里。
/* 创建csv文件并写入表头 */ FRESULT CSV_Create(const char *filename) { UINT bw; /* 实际写入的字节数 */ const char *header = "timestamp,temperature,humidity,voltage\r\n"; /* 创建文件,如果已存在则覆盖 */ FRESULT res = f_open(&fil, filename, FA_CREATE_ALWAYS | FA_WRITE); if (res != FR_OK) { return res; } /* 写入表头 */ res = f_write(&fil, header, strlen(header), &bw); if (res != FR_OK || bw != strlen(header)) { f_close(&fil); return FR_DISK_ERR; } /* 立即同步到卡,确保表头落盘 */ f_sync(&fil); return FR_OK; }这里有几个细节值得说。f_open的第三个参数是模式组合,FA_CREATE_ALWAYS表示如果文件存在就截断重写,FA_WRITE表示可写。如果你希望追加数据,用FA_OPEN_APPEND,但.csv场景下我一般用覆盖模式,因为每次采集是一个独立的数据集。
f_write的第四个参数是输出参数,返回实际写入的字节数。一定要判断bw是否等于你期望写入的长度,如果不等,说明卡写满了或者驱动有问题。f_sync的作用是把FatFs缓存里的数据强制刷到SD卡上,不调它的话,数据可能还在内存里,拔卡就丢。表头写完后调一次f_sync,是为了确保文件结构已经建立,后面追加数据时不会因为文件头没落盘而出错。
3.2 按行写入数据与格式化技巧
数据写入的核心是把传感器读数拼成一行CSV格式的字符串,然后调f_write。这里用sprintf或者snprintf来拼字符串,注意缓冲区要留够。
/* 写入一行数据 */ FRESULT CSV_WriteRow(float temp, float humi, float volt) { char line[64]; UINT bw; int len; /* 拼成CSV格式:时间戳,温度,湿度,电压 */ len = snprintf(line, sizeof(line), "%lu,%.2f,%.2f,%.2f\r\n", HAL_GetTick(), temp, humi, volt); if (len <= 0 || len >= sizeof(line)) { return FR_INT_ERR; /* 格式化失败或缓冲区不够 */ } FRESULT res = f_write(&fil, line, len, &bw); if (res != FR_OK || bw != len) { return FR_DISK_ERR; } /* 每写一行同步一次,防止掉电丢数据 */ f_sync(&fil); return FR_OK; }snprintf比sprintf安全,它能限制写入长度,避免缓冲区溢出。格式串里%.2f表示保留两位小数,%lu对应HAL_GetTick()返回的uint32_t。行尾用\r\n而不是\n,是因为Windows的Excel对\r\n兼容更好,纯\n在某些版本里会显示成一行。
f_sync的调用频率是个权衡。每写一行就sync一次,数据最安全,但SD卡写入次数有限,频繁sync会加速卡老化。我一般会根据数据速率来定:如果每秒写一行,就每行sync;如果每秒写几十行,就攒10行sync一次。下面这个写法是攒够一定行数再sync。
/* 带缓冲的行写入,减少sync次数 */ #define SYNC_INTERVAL 10 static uint8_t sync_cnt = 0; FRESULT CSV_WriteRowBuffered(float temp, float humi, float volt) { char line[64]; UINT bw; int len = snprintf(line, sizeof(line), "%lu,%.2f,%.2f,%.2f\r\n", HAL_GetTick(), temp, humi, volt); if (len <= 0) return FR_INT_ERR; FRESULT res = f_write(&fil, line, len, &bw); if (res != FR_OK || bw != len) return FR_DISK_ERR; if (++sync_cnt >= SYNC_INTERVAL) { f_sync(&fil); sync_cnt = 0; } return FR_OK; }SYNC_INTERVAL这个宏可以根据实际场景调。数据重要就调小,卡寿命优先就调大。但要注意,如果系统随时可能断电,sync间隔内的数据是有丢失风险的,这个取舍要提前想清楚。
3.3 关闭文件与异常处理
文件写完后必须f_close,它会刷新缓存并更新目录项。不调f_close直接拔卡,文件很可能不完整。
/* 关闭文件,完成写入 */ void CSV_Close(void) { f_sync(&fil); /* 最后再同步一次 */ f_close(&fil); /* 关闭文件,更新目录项 */ }f_close内部会调f_sync,所以理论上只调f_close也行。但我习惯先显式sync再close,这样如果sync出错还能捕获到。异常处理上,每个FatFs函数的返回值都要判断,FR_OK之外的值都要当作错误处理。常见的错误码有FR_DISK_ERR(底层读写失败)、FR_NO_FILE(文件不存在)、FR_DENIED(权限不够)、FR_NOT_ENOUGH_CORE(内存不够)。调试时可以把返回值打印出来,对照ff.h里的定义排查。
4. 避坑与排查:SD卡挂载失败、写入丢数据和.csv乱码的五个血泪经验
4.1 f_mount返回FR_NOT_READY,卡明明插着
现象:板子上电后f_mount一直返回FR_NOT_READY,但卡插得好好的,电脑上也能读。
原因:disk_initialize里SD_Init失败,常见的是SDIO时钟配置不对,或者卡的上电时序没满足。SD卡初始化需要至少74个时钟周期,很多驱动忘了这一步。
解决:检查SD_Init里有没有先发74个CLK,再发CMD0。另外SDIO的时钟分频要设对,初始化阶段用低速(比如400kHz),初始化完成后再切到高速。如果用的是SPI模式,检查CS片选有没有拉低,SPI速率是不是超过了卡的支持范围。
4.2 文件创建成功但数据是空的
现象:f_open返回FR_OK,f_write也返回FR_OK,但拔卡插电脑上文件是0字节。
原因:数据还在FatFs的缓存里,没有调f_sync或f_close。FatFs为了减少写次数,会把数据攒在缓冲区,只有缓冲区满了或者显式sync才真正写到卡上。
解决:每次写完关键数据后调f_sync,或者至少在f_close之前调一次。如果是掉电场景,sync频率要更高。另外检查f_write的bw参数,如果bw是0,说明底层disk_write失败了,要回去查SD_WriteBlocks的返回值。
4.3 .csv文件用Excel打开全是乱码
现象:文件在记事本里看正常,用Excel打开中文列名变成乱码。
原因:Excel默认用系统编码打开.csv,如果文件里没有BOM头,中文会按ANSI解析,导致乱码。另外行尾如果是纯\n,某些Excel版本会把所有行挤在一起。
解决:在文件开头写入UTF-8 BOM(0xEF 0xBB 0xBF),Excel就能正确识别UTF-8。行尾统一用\r\n。如果列名是英文,这个问题不明显,但养成加BOM的习惯没坏处。
/* 写入UTF-8 BOM,解决Excel中文乱码 */ const uint8_t bom[] = {0xEF, 0xBB, 0xBF}; UINT bw; f_write(&fil, bom, 3, &bw);4.4 写一段时间后f_write返回FR_DISK_ERR
现象:刚开始写正常,跑了几分钟或几小时后f_write开始报错。
原因:SD卡写满,或者卡的质量问题导致某些扇区写入失败。另外如果DMA缓冲区地址没对齐,也会间歇性出错。
解决:先检查卡剩余空间,f_getfree能查。如果是卡满了,要么换大卡,要么做文件滚动(写满一个文件后新建下一个)。如果是随机性错误,检查DMA缓冲区是不是4字节对齐,用__attribute__((aligned(4)))修饰。还可以在disk_write里加重试机制,失败后重试2到3次。
4.5 拔卡后文件系统损坏,电脑提示需要格式化
现象:运行中直接拔卡,再插电脑上提示“需要格式化才能使用”。
原因:FatFs的目录项和FAT表还在缓存里没落盘,拔卡导致文件系统元数据不一致。
解决:运行中不要拔卡。如果必须热插拔,在拔卡前调f_mount(NULL, "0:", 0)卸载文件系统,它会强制sync所有缓存。另外可以在硬件上加一个卡检测引脚,检测到卡拔出时先卸载再提示用户。
5. 进阶技巧:用f_printf简化写入与.csv文件完整性校验
5.1 开启f_printf减少sprintf依赖
FatFs提供了一个f_printf函数,用法跟标准printf一样,直接往文件里写格式化字符串。开启它需要在ffconf.h里把_USE_STRFUNC设为1或2。这样上面的CSV_WriteRow可以简化成:
/* 用f_printf直接写格式化数据 */ FRESULT CSV_WriteRowEx(float temp, float humi, float volt) { int ret = f_printf(&fil, "%lu,%.2f,%.2f,%.2f\r\n", HAL_GetTick(), temp, humi, volt); if (ret < 0) { return FR_DISK_ERR; /* f_printf返回负值表示失败 */ } f_sync(&fil); return FR_OK; }f_printf的好处是不用自己开缓冲区,FatFs内部处理了。但要注意_USE_STRFUNC设为2时会支持浮点格式化,设为1时浮点可能不支持。如果发现%.2f输出成空白,把_USE_STRFUNC改成2。
5.2 写入后校验文件完整性
数据记录设备最怕的是文件写完了但内容不对。我一般会在关闭文件后重新打开读一遍,校验行数和表头。
/* 校验csv文件行数 */ FRESULT CSV_Verify(const char *filename, uint32_t expect_rows) { FIL f; FRESULT res = f_open(&f, filename, FA_READ); if (res != FR_OK) return res; uint32_t lines = 0; char buf[128]; while (f_gets(buf, sizeof(buf), &f)) { lines++; /* 每读一行计数 */ } f_close(&f); /* 表头占一行,所以总行数应该是expect_rows + 1 */ if (lines != expect_rows + 1) { return FR_INT_ERR; } return FR_OK; }f_gets按行读取,遇到\r\n或\n就返回。这个校验函数在每次采集结束后调一次,能发现大部分写入异常。如果行数不对,说明中间有丢数据,可以结合f_sync的频率来排查。
5.3 文件滚动与容量管理
长时间记录场景下,单个.csv文件会越来越大,最终撑满SD卡。我一般会做文件滚动:每个文件记录固定行数(比如10000行),写满后自动新建下一个文件,文件名带序号。
/* 文件滚动:写满MAX_ROWS行后切换新文件 */ #define MAX_ROWS 10000 static uint32_t row_cnt = 0; static uint16_t file_idx = 0; FRESULT CSV_RollingWrite(float temp, float humi, float volt) { if (row_cnt == 0) { char fname[32]; snprintf(fname, sizeof(fname), "0:/log_%04d.csv", file_idx++); FRESULT res = f_open(&fil, fname, FA_CREATE_ALWAYS | FA_WRITE); if (res != FR_OK) return res; const char *header = "timestamp,temperature,humidity,voltage\r\n"; UINT bw; f_write(&fil, header, strlen(header), &bw); } FRESULT res = CSV_WriteRowBuffered(temp, humi, volt); if (res != FR_OK) return res; if (++row_cnt >= MAX_ROWS) { f_close(&fil); row_cnt = 0; /* 触发下一次新建文件 */ } return FR_OK; }这个逻辑里,row_cnt为0时新建文件并写表头,每写一行递增,到MAX_ROWS就关闭文件并重置计数。file_idx用4位数字格式化,生成log_0000.csv、log_0001.csv这样的文件名,方便排序和查找。MAX_ROWS可以根据卡容量和单行长度估算,比如单行40字节,10000行约400KB,一张8GB卡能存两万个文件,足够跑很久。
从那以后我每次做SD卡记录,都会在正式跑数据前先用CSV_Verify校验一遍小批量数据,确认行数和内容都对得上,再放开跑长时间采集。这个习惯帮我省过好几次返工。希望帮到你。
本文还有配套的精品资源,点击获取