简介:面向STM32F429开发者的嵌入式工程资源包,实现了基于FatFS的SD卡文件系统,可将采集数据写成CSV文件,同时集成以太网驱动与TCP服务器,用于接收网络数据并存储。其适用场景包括数据采集、工业监控、物联网网关等需要本地落盘与网络交互的场景,适合有一定STM32基础、想快速上手文件系统和网络协议栈的工程师或学生。压缩包共1482个文件,约59.43MB,其中C语言源码与头文件占比最高,含629个c文件和382个h文件,便于阅读和修改;同时包含Keil工程配置、链接脚本、编译生成的hex和axf文件,可直接烧录验证。已有8121人学习下载。资源中FatFS已完成SDIO/SPI驱动适配,提供f_open、f_write等API调用示例;CSV生成部分详细展示了逗号分隔与换行记录写法;TCP服务器基于lwIP实现,涵盖连接建立、数据接收、缓冲区处理和文件写入流程。通过分析其工程结构和中断设计,可快速搭建“网络数据→SD卡CSV日志”的完整链路,也可迁移到其他STM32系列。 STM32的SD卡存储需求,很多项目最终都会落到“数据记录”这个场景上。前期我用串口把传感器数据发到上位机,几百个点还好,等到连续采集几分钟、几千条数据的时候,串口工具卡死、数据错位、还得手动复制粘贴到Excel里清洗,整个人都不好了。后来把STM32、SD卡、FATFS这套组合跑通,直接把数据写成CSV文件,插电脑上就能用Excel或者Python处理,效率完全不是一个量级。这篇东西就是记录整个落地过程,包括FATFS怎么移植、CSV怎么写、踩过哪些坑,给准备做数据记录仪或者小型采集器的朋友一份可以直接参考的实操笔记。
1. 为什么用SD卡加FATFS存CSV,而不是串口直接传
1.1 从“能存”到“会存”,关键在文件系统
SD卡本身只是存储介质,往里面写数据可以分两个层次。
裸写扇区,也就是自己计算柱面、磁道、扇区地址,直接把数据怼到固定位置。这种方案能做,但是每次想看看数据,得自己写一个上位机,按照你定义的格式去解析原始二进制或者特定协议数据。麻烦的地方在于:换一台电脑、换一个读卡器,解析工具还得跟着走;数据量大了以后,你很难判断哪个扇区写满了、哪个扇区数据是新的。我见过有人这么做,维护成本非常高。
另一种就是挂FATFS文件系统。FATFS是一个开源的FAT/exFAT文件系统模块,专门为嵌入式小资源环境设计的,占用的RAM和ROM都很少。它的核心价值在于:让SD卡变成“U盘”,插到电脑上直接被操作系统识别,里面的文件直接双击打开。用户拿到你的记录仪,不需要任何专用软件就能看数据,光这一点就值得引入文件系统。
FATFS做的事,本质上就是把你在PC上熟悉的文件操作,抽象成f_open、f_write、f_read、f_close这套API。底层怎么擦写扇区、怎么维护文件分配表,全部帮你封装好了。文件系统这一层不只是方便,它还对数据做了结构化管理。文件末尾、剩余空间、目录项,都有统一规范,不容易出现“写到某个地方覆盖了旧数据”这种低级问题。
1.2 CSV文件是这个场景里的最优解
CSV的全称是Comma-Separated Values,逗号分隔值。它本质上是纯文本,每一行是一条记录,每个字段用逗号隔开。CSV最舒服的地方在于它没有复杂的二进制格式,任何文本编辑器都能打开,Excel直接双击就能识别,Python的pandas.read_csv()一行代码就能读取分析。
对于单片机来说,CSV的生成成本极低。STM32只需要用f_printf往文件里格式化输出字符串,然后加\r\n换行,这就完事了。相比输出二进制数据,CSV多占一点存储空间,但换来的是跨平台可读性和调试便利性。在一个采集系统里,调试成本往往比存储成本更值钱。
我用FATFS生成CSV后,实测一个包含时间戳、温度、湿度三个字段的数据文件,每秒采一条,跑24小时,大概是7到8MB。一张8GB的Class10 TF卡,按这个写入频率可以连续记录接近一年。所以对于绝大多数嵌入式数据记录场景,FATFS加CSV的组合无论从开发效率还是用户使用体验上,都是性价比最高的方案。
2. 硬件准备与FATFS源码获取,这两步决定后面顺不顺
2.1 SD卡接口选SPI还是SDIO
STM32操作SD卡,物理层接口通常有SPI和SDIO两种。SPI模式兼容性最好,几乎所有的SD卡和MicroSD卡都支持SPI协议,而且STM32的SPI外设是标配,引脚分配灵活,代码也简单顺手。缺点是传输速度上限不如SDIO,大概是2到10Mbps的水平,对于每秒几十个字节到几百个字节的传感器数据记录,完全够用。如果只是数据记录,我建议直接上SPI,不要犹豫。
SDIO模式支持4位并行传输,速度可以跑到几十Mbps,适合音频、视频、高速数据采集这类大吞吐量场景。代价是引脚占用多,SDIO的时序要求更严格,走线要留意信号完整性,初始化流程比SPI复杂。还有一个实际痛点:某些低端SD卡在SDIO模式下的兼容性反而不如SPI稳定,容易在初始化阶段卡住。千万别只看峰值速度就选SDIO,吞吐量需求上不去,SPI就是最稳的选择。
FatFs本身跟硬件接口是解耦的,它在底层抽象了disk_initialize、disk_read、disk_write、disk_status这几个函数,文件系统逻辑完全不关心你的硬件是SPI还是SDIO。所以哪怕你后面换SDIO,FatFs代码主体不用动,只需要改底层接口实现。
2.2 电源、上拉电阻、电平匹配
SD卡对供电比较敏感,MicroSD卡正常工作电压是3.3V,瞬间电流可以达到50到100mA甚至更高,尤其写入扇区的时候。如果用STM32开发板上的3.3V稳压器,要留意这个稳压器的输出能力。有些小板子的AMS1117-3.3输出电流上限是1A,理论上够,但实际接上电机、OLED、无线模块之后,3.3V可能被拉低,SD卡就表现为初始化失败或者读写中途返回错误。
SPI模式下,SD卡的CS、SCK、MOSI、MISO这几根信号线,建议都接10kΩ左右的上拉电阻到3.3V。SD卡协议要求这些引脚在空闲状态是高电平,上拉电阻能提升抗干扰能力。我自己第一次做的时候没接上拉,板子在实验室环境跑得好好的,拿到现场一上电就概率性初始化失败,后来加上拉电阻,问题消失。如果用的是现成开发板,一般已经处理好了,自制板子务必注意。
电平匹配上,STM32的IO基本都是3.3V逻辑,直接跟SD卡连接没问题。如果用的是5V单片机的板子,需要做电平转换。SD卡的信号引脚不推荐直接接5V,长时间超过额定电压容易损坏卡。
2.3 FatFs源码获取与版本选择
去FatFs官网下载源码包就行,当前主流的版本是R0.14系列、R0.15系列。源码包里核心文件就几个:ff.c(文件系统实现)、ff.h(头文件)、ffconf.h(配置头文件)、diskio.c(底层磁盘IO接口)、diskio.h。另外还有integer.h这类辅助头文件。
下载的时候建议直接拿完整源码包,不要用别人工程里拷贝出来的精简版。精简版经常被改过宏定义,出了问题你很难判断是官方逻辑还是被改动导致的。用官方原包,自己在ffconf.h里改配置,出问题也好查。官方源码包附带doc目录,里面每个函数的说明非常详细,比网上很多二手资料的描述准确得多,建议常翻。
3. FatFs配置里必须改的几个宏,背后是成本和兼容性的权衡
3.1 核心配置开关说明
ffconf.h是FatFs的配置文件,里面全是宏定义,初学者容易忽略,但决定文件系统能不能正常工作、功能是否符合需求的恰恰是这些宏。我把关键几个列出我实际用的配置:
| 宏定义 | 我用的值 | 理由 |
|---|---|---|
FF_USE_STRFUNC | 2 | 启用f_printf等字符串格式化函数,这是在CSV里写格式化文本的核心功能 |
FF_FS_EXFAT | 1 | 开启exFAT支持,新买的大容量SD卡出厂可能是exFAT格式,不开启会挂载失败 |
FF_USE_LFN | 2 | 启用长文件名支持,0表示只用短文件名,实际用可能遇到“8.3格式不够用” |
FF_USE_MKFS | 1 | 允许在设备上直接格式化SD卡,避免第一次使用还要专门拿电脑格式化 |
FF_PRINT_LLI | 1 | 让f_printf支持%lld和%llu,打印64位整数,时间戳和计数器用得上 |
FF_USE_STRFUNC这个宏是重点。它默认是0,也就是f_printf和f_puts这些函数根本不会被编译进去。CSV文件写入字符串格式化完全依赖它,设置为1时支持f_printf和f_puts,设置为2时额外支持f_gets和f_putc。我直接设2,功能全开。
FF_USE_LFN配置为2时,FatFs会把长文件名的缓冲区放在堆上动态分配,这比静态分配省RAM但是要求你的编译器提供了malloc和free。如果工程没开堆,就用FF_USE_LFN=1,把缓冲区做成静态数组。还有一种做法是配置FF_VOLUMES和FF_LFN_BUF,适当调节缓冲区大小。文件名我建议尽量用英文和数字,避免中文编码带来的乱码麻烦。
3.2 工作区内存和栈空间要注意
FatFs在挂载文件系统时需要一个FATFS对象,结构体不小,包含了文件分配表缓存等信息。如果用exFAT加长文件名,FATFS结构体体积会明显增大,有些配置下可能到几百字节甚至更多。加上FIL文件对象、DIR目录对象,你自己算一下RAM,STM32F103C8T6只有20KB RAM,全部塞进去够用,但留给业务逻辑的内存就紧张了。如果做稍微复杂一点的采集系统,建议上STM32F103RCT6或者F407系列,RAM容量会更宽裕。
另外,FatFs内部操作会用到栈,尤其是f_mount做挂载解析时,需要的临时空间比较大。STM32工程里默认栈大小有1KB到2KB,有时不够。我在移植过程中遇到过栈溢出导致的硬件错误,后来把启动文件里的Stack_Size从0x400加到0x1000,问题解决。具体来说,0x400是1KB,0x1000是4KB,这个配置改一下不亏。
4. 写CSV的完整代码实现:初始化、文件名生成、格式化落卡
4.1 挂载与初始化,注意“首次使用要先格式化”
下面这段是初始化和写文件的完整核心代码,基于标准库或者HAL库都可以,FATFS部分的操作是平台无关的。
#include "ff.h" FATFS g_fs; // 文件系统对象 FIL g_file; // 文件对象 UINT g_br; // 实际写入字节数 void SD_Storage_Init(void) { FRESULT res; // 挂载SD卡文件系统,第二个参数是逻辑驱动器号,空字符串表示默认驱动器 res = f_mount(&g_fs, "", 1); if (res == FR_NO_FILESYSTEM) { // 没有文件系统,需要格式化 // 先卸载,再执行格式化,最后重新挂载 f_mount(NULL, "", 0); BYTE work[FF_MAX_SS * 2]; res = f_mkfs("", FM_FAT32, 0, work, sizeof(work)); if (res != FR_OK) { // 格式化失败,打印错误码后停止 return; } f_mount(&g_fs, "", 1); } else if (res != FR_OK) { // 挂载失败,检查硬件和SPI/SDIO初始化 return; } }这段代码的意图很明确:f_mount把文件系统对象跟物理驱动器绑定,返回值FR_NO_FILESYSTEM表示SD卡里没有合法的文件系统。这时候就需要调用f_mkfs对SD卡做格式化。f_mkfs需要的work缓冲区至少要FF_MAX_SS * 2字节,FF_MAX_SS默认是512,所以这里用了BYTE work[1024],也可以直接声明大一点。
注意一个细节:f_mount成功挂载不等于SD卡一定能读写,它只是把文件系统元数据加载到内存里。如果f_mount返回FR_NOT_READY,基本可以确定问题出在底层硬件:供电、SPI初始化、卡片没插好。返回FR_INVALID_DRIVE则说明逻辑驱动器号没对上,需要检查FF_VOLUMES配置和f_mount的第一个参数。
4.2 生成带时间戳的文件名
CSV文件如果永远叫data.csv,第二次运行就会覆盖掉第一次的数据。比较好用的方案是用时间戳或者日期来命名文件。前提是你的系统里有RTC,并且正确设置时间。
char filename[32]; RTC_DateTypeDef sdate; RTC_TimeTypeDef stime; HAL_RTC_GetDate(&hrtc, &sdate, RTC_FORMAT_BIN); HAL_RTC_GetTime(&hrtc, &stime, RTC_FORMAT_BIN); snprintf(filename, sizeof(filename), "0:/%04d%02d%02d_%02d%02d%02d.csv", sdate.Year + 2000, sdate.Month, sdate.Date, stime.Hours, stime.Minutes, stime.Seconds);这样生成的文件名例如0:/20250614_153012.csv,按时间排序也方便。文件名的前缀我建议加一个逻辑驱动器号0:,这是FatFs里的逻辑卷编号,如果你只挂载了一张SD卡,只有0:,不写也没有关系,但写上是好习惯,避免后续扩展多个存储设备时混乱。
文件名一定要控制在合理长度内,虽然开了FF_USE_LFN支持长文件名,但FAT文件系统单个文件名最长255字节,加上路径别超。还要避免使用/\:*?"<>|这些非法字符。
4.3 写入传感器数据,核心是格式化字符串
文件打开后,写入CSV文件的核心就变成了一行一行地格式化字符串。CSV文件每一行对应一条记录,行内字段用逗号分隔。
void Write_CSV(TempHumidity_t *data, const char *filename) { FRESULT res; char line_buf[128]; // FA_OPEN_ALWAYS:文件存在则打开,不存在则创建 // FA_WRITE:以写模式打开 res = f_open(&g_file, filename, FA_OPEN_ALWAYS | FA_WRITE); if (res != FR_OK) { return; } // 把文件指针移动到文件末尾,这样不会覆盖之前的数据 f_lseek(&g_file, f_size(&g_file)); snprintf(line_buf, sizeof(line_buf), "%lu,%d.%02d,%d.%02d\r\n", (unsigned long)data->timestamp, >if (write_count % 20 == 0) { f_sync(&g_file); }f_sync这个函数就是强制把所有缓存信息写回SD卡,包括文件分配表。它比f_close轻量,不需要重新打开文件,所以适合周期性调用。代价是写入次数变多,卡寿命会有所消耗,但对于普通TF卡,这点损耗完全在可接受范围内。
我遇到的实际案例是:程序跑了20分钟,中途拔卡,插电脑上看CSV只有前2分钟的数据。后来在任务循环里每50条记录调一次f_sync,拔卡测试,最多丢最后几条,基本可以接受。
5.3 坑三:CSV里出现乱码或者中文文件名变成“____”
FATFS默认的代码页是FF_CODE_PAGE,如果配置为936(GBK),那么写入的中文字符串会被当作GBK编码存进去。问题是CSV文件里的“编码元数据”不明确,Excel在不同语言环境下打开时,可能用系统默认代码页解析,导致乱码。
处理办法有两个方向。一是CSV内容统一用ASCII字符集,不要写字面中文字符,表头用英文,比如timestamp,temperature,humidity,数据都是数字,完全避开编码问题。二是如果业务必须写入中文,建议文件表头用UTF-8的BOM开头,即先写入0xEF 0xBB 0xBF三个字节,这样现代版本Excel能自动识别UTF-8,但老版本Excel依然可能乱码。
文件名方面,FatFs支持长文件名和Unicode,但配置起来比较繁琐,需要在ffconf.h里选择正确的FF_CODE_PAGE并处理UTF-8与Unicode双向转换。我图省事,文件名全部用英文加日期数字,再也没有乱码问题。
5.4 坑四:写入过程中返回FR_DISK_ERR怎么办
FR_DISK_ERR是底层磁盘IO错误,意思是disk_write或disk_read返回了失败,而文件系统无法自己恢复。常见原因有三个。
- SD卡供电不稳。看是否在写入瞬间电压被拉低,用示波器量一下3.3V波形,如果掉到3.0V以下,考虑加一个100μF或者更大容量的电容在卡座电源附近。
- SPI速率太快。有些卡在1MHz能读写,上了18MHz就罢工。排查时先用低速模式,比如HAL库的
SPI_BAUDRATEPRESCALER_256,确认硬件没问题再把速率逐步提高。 - 卡本身虚焊或者损坏。换一张卡测试,如果换卡后正常,基本就是卡或卡座的问题。
5.5 坑五:SPI模式初始化卡的兼容性问题
SPI模式初始化SD卡有一个特殊流程:上电后至少给74个时钟周期,让卡完成内部初始化。很多移植代码写得太随意,上电后马上发CMD0,卡片还没缓过劲来,命令发出去石沉大海,然后返回初始化失败。
在做底层disk_initialize时,可以在发送CMD0之前加一段延时和时钟脉冲:
for (uint16_t i = 0; i < 10; i++) { SD_SPI_CS_HIGH(); for (int j = 0; j < 0x10; j++) { SD_SPI_ReadWriteByte(0xFF); } } HAL_Delay(10);这段操作本质上是让SCK先输出一些空时钟,帮助SD卡完成启动序列,然后再开始CMD0握手。如果你用的是CubeMX自动生成的BSP驱动,一般已经处理过,但如果你从某些开源工程里粘贴的驱动,没有这个预热过程,就要小心了。
6. 进阶优化:批量写入、多文件管理和给数据加表头
6.1 批量写入提高吞吐量
如果采集频率高,比如每秒采样100次,每次写一行字符串,频繁调用f_write会产生很多小写入操作。每次f_write都要检查文件系统状态、更新文件指针,效率并不高。更好的做法是在SRAM里开一个环形缓冲区,攒够一定字节数再一次性写入SD卡。
uint8_t sdcard_buffer[1024]; uint16_t buffer_len = 0; void Append_To_Buffer(const char *data) { uint16_t len = strlen(data); if (buffer_len + len < sizeof(sdcard_buffer)) { memcpy(&sdcard_buffer[buffer_len], data, len); buffer_len += len; } if (buffer_len >= sizeof(sdcard_buffer) - 64) { Flush_Buffer_To_SD(); } } void Flush_Buffer_To_SD(void) { if (buffer_len > 0) { f_write(&g_file, sdcard_buffer, buffer_len, &g_br); buffer_len = 0; } }这个思路是把“采集”和“存储”解耦,避免底层存储阻塞采集逻辑。需要注意的是,缓冲区的刷新时机要在掉电保护前尽量主动,比如主循环里每隔一段时间就检查一次,一旦数据达到阈值就刷新,别攒到快溢出才动。如果系统跑的是RTOS,也可以把存储放到单独的任务,用消息队列把数据传给存储任务。
6.2 数据量大的时候如何切分文件
一个CSV文件写到几百MB以后,在Windows上打开会明显变慢,Excel直接卡死,而且单个文件过大一旦损坏,损失太大。更好的做法是按时间或者按容量自动切换新文件。
代码里维护一个当前文件写入字节数的计数器,当达到设定上限,比如10MB,就把当前文件关闭,生成一个新的时间戳文件名继续写。这样既能控制单文件大小,也方便用户按时间段查看数据。
还需要注意,如果采集任务持续运行,打开的文件一直不关闭,会占用一个FIL对象,而FatFs里每个文件对象都需要占用独立内存。如果同时只写一个文件,一个FIL就够了,但是如果说你要做“边写数据边读配置”的功能,就要留意FF_FS_LOCK配置项,开启文件锁功能才能支持多文件同时打开,否则只能关掉一个再打开另一个。
6.3 数据表头与CSV兼容性
CSV文件的表头不是必需的,但没有表头会让数据列含义不明确。建议在新建文件时写入一次表头:
void Create_CSV_With_Header(const char *filename) { f_open(&g_file, filename, FA_CREATE_NEW | FA_WRITE); f_printf(&g_file, "timestamp,temperature,humidity\r\n"); f_close(&g_file); }FA_CREATE_NEW指定只有文件不存在时才创建,如果文件已存在会返回FR_EXIST。这个标志在生成新文件、避免覆盖时特别好用,比FA_OPEN_ALWAYS更安全。相比之下,FA_OPEN_ALWAYS虽然简洁,但在有些逻辑路径下可能会打开一个旧的、数据格式不对的文件,继续往里追加,结果生成一个表头和数据穿插的畸形CSV。
如果数据字段里有逗号,比如描述信息“Hello, world”,CSV文件会解析错位。解决办法是用双引号把包含逗号的字段包起来,比如"Hello, world"。需要注意的是,字段内部如果自身包含双引号,需要用两个双引号转义,"He said""hi"""这样。不过对于纯传感器数据,字段都是数字和单位,基本不会遇到。
6.4 FatFs的disk_status函数别忽略
disk_status是FatFs在每次读写前检查硬件状态的一个接口,默认实现可能只是返回RES_OK。如果你在写数据过程中希望检测到SD卡被拔掉,disk_status里最好加入对卡检测引脚的判断。很多SD卡座自带一个CD(Card Detect)引脚,插卡时接地,拔卡时悬空,用这个引脚就可以在FatFs读写前快速判断卡是否在位。
如果不做这个检测,卡在写入过程中被拔出,FatFs可能返回FR_DISK_ERR或者直接卡死在底层SPI读写里。小组里做数据记录仪时,我们还在SD卡槽旁边加了一个LED指示状态,读写时闪烁,写满时常亮,用户反馈体验很好。
7. 最终落地时的几点体会
这套SD卡加FATFS加CSV的方案,我前前后后做了三轮迭代,从最初的裸写扇区,到后来FATFS加手动拼接CSV,再到现在的格式化添加表头加定时同步加自动切分文件。最大的一点体会是:不要把文件系统想象得很神秘,它就是一个帮你管理数据的库,你能用它把复杂的事情拆成简单API调用,但前提是理解它底层的缓存机制和配置项的取舍。
另一个经验是,SD卡初始化失败的问题,不要拿着代码反复看,先拿逻辑分析仪去看SPI波形是否正常,看CMD0之后有没有回应。很多时候排查半天,最后发现是卡座虚焊,或者SPI引脚被复用成了其他外设。写代码前把硬件通路先验证明白,能省大量时间。
做存储类项目,提前多花一点时间设计好文件格式和分片策略,后面数据分析会非常省心。CSV文件简单到了极点,但正是这种简单,让它在嵌入式系统里优势尽显,没有解析负担,不用维护二进制协议,上位机拿来就能用。如果你刚开始做数据记录,建议直接跳过裸扇区方案,从FATFS开始,这会是你最省力的一条路。
本文还有配套的精品资源,点击获取