简介本资源面向嵌入式开发学习者与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卡驱动上。下面是我一般会用的写法基于SDIODMA的方式。/* 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_OKf_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 BOM0xEF 0xBB 0xBFExcel就能正确识别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校验一遍小批量数据确认行数和内容都对得上再放开跑长时间采集。这个习惯帮我省过好几次返工。希望帮到你。本文还有配套的精品资源点击获取