
简介这是一套基于STM32F103的SD卡驱动与FATFS文件系统移植工程合集适合想掌握SPI模拟时序、文件系统集成和LCD图像显示的嵌入式开发者。压缩包内包含3个递进工程01实现SPI模拟时序下的SD卡扇区读写帮助理解底层存储访问02完成FATFS移植及目录文件基础测试演示文件系统集成03实现截屏保存BMP图片与BMP解码显示串联LCD驱动、图像编码和显示输出。资源共425个文件以.c源文件和.h头文件为主体附带Keil工程配置、编译中间文件及hex/axf固件输出整体约12.99MB便于直接打开对照与二次移植。已有617人学习下载从底层驱动到上层应用学习路径清晰可作为入门实践和项目开发参考。1. 为什么STM32F103上SD卡驱动要选SPI模拟时序很多人第一次在STM32F103最小系统板上跑SD卡第一反应是上SDIO接口。结果发现SDIO占用的引脚和复用配置比想象中复杂寄存器一旦设错调试起来一整天都找不到原因。这个工程反过来走了一条更稳的路用普通GPIO软件模拟SPI时序去驱动SD卡。它的优势不是速度快而是逻辑透明、引脚任意、容易移植。工程里包含三个递进项目第一个只做扇区读写第二个在此基础上移植FATFS文件系统第三个用FATFS把屏幕内容保存为BMP图片再把BMP解码回显到LCD。对于只需要记录日志、导出传感器数据或做简易图像存储的开发者这套方案在F103上足够可靠甚至可以直接改到GD32、CH32等国产M0内核芯片上。后文的代码全部基于标准库V3.5但改造HAL库也不难。2. SD卡SPI模式底层驱动命令序列与扇区读写2.1 软件模拟SPI的关键字节交换软件模拟SPI的本质是控制CLK引脚产生时钟边沿同时按位收发数据。SD卡在SPI模式下要求CLK空闲为低电平第一个边沿是上升沿这就是SPI模式0CPOL0CPHA0。所以一个字节交换函数可以写成这样// 软件SPI字节交换MSB先行模式0 uint8_t SD_SPI_ExchangeByte(uint8_t data) { for (uint8_t i 0; i 8; i) { // 先放数据到MOSI if (data 0x80) { SD_MOSI_HIGH; } else { SD_MOSI_LOW; } data 1; // 产生一个时钟周期 SD_SCK_LOW; // 插入微秒级延时保证信号稳定 delay_us(2); SD_SCK_HIGH; // 上升沿后读取MISO if (SD_MISO_READ) { data | 0x01; } } return data; }这段代码的逻辑很直观每个循环先安排MOSI电平再拉低SCK随后拉高SCK产生上升沿此时采样MISO。为什么要插入2微秒延时因为普通GPIO翻转速度够快但SD卡内部电路需要一定建立时间尤其在3.3V电压下驱动线较长时。延时大小要根据实际接线调整如果使用杜邦线连接SD卡模块建议加2到5微秒如果是PCB直连可以去掉延时把SPI时钟压到几百纳秒级别。实际调用时空闲状态下SCK始终为低CS拉低后开始传输传输完成后CS拉高。注意在SD卡初始化之前主机必须先发送至少74个时钟周期的脉冲并且期间CS为高让卡片先完成上电序列。2.2 初始化流程CMD0、CMD8、ACMD41的坑SD卡从SD模式切换到SPI模式是有严格顺序的。第一步是发送CMD0让卡进入SPI模式。CMD0的CRC字段固定为0x95SPI模式下卡会忽略CRC检查但为了兼容某些老卡还是要正确发送。核心初始化代码如下uint8_t SD_InitSequence(void) { uint8_t retry; // 先拉高CS发送至少74个时钟 SD_CS_HIGH; for (uint8_t i 0; i 10; i) { SD_SPI_ExchangeByte(0xFF); // 每字节8个时钟共80个时钟 } // 拉低CS准备发送命令 SD_CS_LOW; // CMD0参数0CRC0x95 retry SD_SendCmd(0, 0, 0x95); if (retry ! 0x01) { // 必须响应该字节R1的bit0为1表示进入idle SD_CS_HIGH; return 1; } // CMD8参数0x1AACRC0x87用于检测SD V2.0 retry SD_SendCmd(8, 0x1AA, 0x87); if (retry 0x01) { // 如果卡响应0x01说明是SD2.0以上版本 // 还应该读取随后的4字节操作电压信息和echo back for (uint8_t i 0; i 4; i) { SD_SPI_ExchangeByte(0xFF); } } // 循环发送ACMD41使卡退出idle状态 // 注意ACMD41必须由CMD55引导 for (retry 0; retry 100; retry) { // 先发送CMD55告知下一命令是应用特定命令 SD_SendCmd(55, 0, 0x01); // 再发送CMD41参数0x40000000表示HCS1卡可能进入SDHC模式 if (SD_SendCmd(41, 0x40000000, 0x01) 0x00) { break; } delay_ms(10); } SD_CS_HIGH; return (retry 100) ? 2 : 0; }这里有一个容易被忽视的细节CMD8的返回值和CMD0不同。CMD0成功后卡响应的R10x01表示进入idle状态CMD8若成功R1同样是0x01后面还会跟4字节数据其中低字节应该是0xAA表示回波。如果卡是SD V1.0或者MMCCMD8会返回0x05非法命令。工程里没有用力检查这4字节的内容实际开发中建议读取并校验避免后面ACMD41一直卡死。ACMD41的HCS位bit30很关键。如果置1兼容SDHC卡置0则强制卡工作在标准容量SDSC模式此时扇区地址可以按字节寻址扇区号左移9位。这个工程为了简化地址换算没有做SDHC的块寻址适配所以这里就算返回0x00成功卡也很有可能被初始化成SDSC模式。不过大多数普通TF卡在SPI模式下都能接受这种处理只有极大容量的卡比如64GB以上可能会出现兼容问题。2.3 扇区读写CMD17与CMD24的数据流读单扇区命令是CMD17参数是字节地址。如果使用扇区号需要左移9位得到字节地址。SD卡返回0x00表示命令接受然后会发送一个数据起始令牌0xFE后面跟着512字节数据和2字节CRC。读单扇区的实现如下uint8_t SD_ReadSector(uint32_t sector, uint8_t *buffer) { uint8_t retry; SD_CS_LOW; // 发送CMD17参数为扇区号左移9位 retry SD_SendCmd(17, sector 9, 0x01); if (retry ! 0x00) { SD_CS_HIGH; return 1; } // 等待数据令牌 0xFE for (uint16_t wait 0; wait 10000; wait) { retry SD_SPI_ExchangeByte(0xFF); if (retry 0xFE) break; } if (retry ! 0xFE) { SD_CS_HIGH; return 2; } // 读取512字节 for (uint16_t i 0; i 512; i) { buffer[i] SD_SPI_ExchangeByte(0xFF); } // 跳过2字节CRC SD_SPI_ExchangeByte(0xFF); SD_SPI_ExchangeByte(0xFF); SD_CS_HIGH; return 0; }写扇区使用CMD24数据块前需要发送0xFE作为起始令牌然后写512字节数据再写2字节CRCSPI模式下可写入0xFF。卡收到数据后会进入忙状态需要持续读取MISO直到它变为高电平才能结束本次写操作。实际代码中要特别注意如果卡的质量不好忙等待时间可能长达几百毫秒建议加超时退出避免程序卡死。多块读写CMD18/CMD25虽然能提高效率但这个工程没有体现。原因是FATFS底层读写一般以扇区为单位单块已经能满足大多数场景。如果你需要批量记录数据可以考虑在多块读写基础上做环形缓冲但复杂度会直线上升。3. FATFS文件系统移植diskio接口与目录文件操作3.1 FATFS版本和工程文件组织FATFS是一个开源的嵌入式FAT文件系统模块源码由ff.c、ff.h、ffconf.h、diskio.c、diskio.h组成。移植时主要修改diskio.c和ffconf.h。这个工程使用的FATFS版本较老但接口和最新版R0.15基本兼容。注意新版FATFS将DWORD扇区类型换成了LBA_t如果你的编译器是Keil MDK5.3以上建议直接使用新版FATFS避免出现类型重定义问题。ffconf.h里比较关键的两个配置项_USE_MKFS用于支持格式化函数_USE_STRFUNC用于支持f_printf。这个工程只做目录文件基本测试所以没有打开这些宏。如果你希望用f_mkfs格式化SD卡一定要把_USE_MKFS设为1并且在disk_ioctl中实现GET_SECTOR_COUNT和GET_SECTOR_SIZE。3.2 实现disk_initialize、disk_read、disk_ioctldiskio.c是FATFS与硬件之间的桥梁。它定义了四个接口函数disk_status、disk_initialize、disk_read、disk_write以及disk_ioctl和get_fattime。其中最核心的是读取和写入扇区实现如下DSTATUS disk_initialize(BYTE pdrv) { // 忽略pdrv因为这里只有一个物理盘 if (SD_InitSequence() 0) return 0; return STA_NOINIT; } DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { for (UINT i 0; i count; i) { if (SD_ReadSector(sector i, buff i * 512) ! 0) { return RES_ERROR; } } return RES_OK; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { for (UINT i 0; i count; i) { if (SD_WriteSector(sector i, buff i * 512) ! 0) { return RES_ERROR; } } return RES_OK; } DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { switch (cmd) { case GET_SECTOR_COUNT: { *(LBA_t *)buff SD_GetSectorCount(); return RES_OK; } case GET_SECTOR_SIZE: *(WORD *)buff 512; return RES_OK; case GET_BLOCK_SIZE: *(DWORD *)buff 1; return RES_OK; case CTRL_SYNC: SD_WaitBusy(); return RES_OK; default: return RES_PARERR; } }代码中SD_GetSectorCount需要读取CSD寄存器的内容本工程无论卡实际容量多大都返回一个固定值比如2048个扇区对应1MB空间。这种做法在测试时没问题但如果你要保存长日志就会写满后报错。正确做法是解析CSD寄存器我在第5章给出可用的计算方法。disk_read的count参数在FATFS中可能大于1所以这里用循环逐个扇区读取。如果每次都重新拉低CS效率会低一些。更好的做法是让SD卡进入多块读模式但这里以简单优先。注意buff地址可能是非4字节对齐的所以不能直接在DMA模式下使用。3.3 挂载目录并扫描文件移植完成后就可以在main函数里调用FATFS的API。最常见的操作是挂载文件系统然后扫描根目录打印出所有文件名和大小FATFS fs; FILINFO fno; DIR dir; // 挂载逻辑驱动号0:第二个参数1表示立即挂载 f_mount(fs, 0:, 1); memset(fno, 0, sizeof(fno)); if (f_opendir(dir, /) FR_OK) { while (1) { FRESULT res f_readdir(dir, fno); if (res ! FR_OK || fno.fname[0] 0) break; // 读到末尾 if (fno.fattrib AM_DIR) { printf([DIR] %s\r\n, fno.fname); } else { printf([FILE] %s, %ld bytes\r\n, fno.fname, fno.fsize); } } f_closedir(dir); }注意f_readdir每次返回一个目录项当fno.fname[0]为0时表示目录结束。这里必须用memset清空fno否则某些旧版本FATFS可能会残留上次的字符串。另外printf是重定向到串口的如果你没做fputc重定向建议改用一段简单的字符串发送函数或者直接在调试器里看变量。挂载失败最常见的原因有两个一是底层disk_read返回错误这通常是SD初始化时序不对或者扇区地址计算错误二是SD卡根本没有FAT文件系统需要先用PC格式化或者用f_mkfs格式化。如果出现FR_NO_FILESYSTEM别急着改代码先用读卡器在电脑上格式化成FAT32格式再插回STM32测试。4. BMP图片保存与解码显示FATFS实战应用4.1 把LCD屏幕内容保存为24位BMPBMP文件由文件头、信息头和像素数据三部分构成。24位真彩色BMP每个像素占3字节分别对应蓝、绿、红。文件头共54字节前14字节为文件信息包括文件大小、数据偏移后40字节为图像信息宽度、高度、位深等。保存屏幕内容时需要先构造这两个结构体typedef struct { uint16_t bfType; // 固定为0x4D42即BM uint32_t bfSize; // 文件总大小 uint32_t bfReserved; // 保留填0 uint32_t bfOffBits; // 像素数据偏移54 } BMP_FileHeader; typedef struct { uint32_t biSize; // 结构体大小40 int32_t biWidth; // 图像宽度像素 int32_t biHeight; // 图像高度像素 uint16_t biPlanes; // 固定为1 uint16_t biBitCount; // 位深24 uint32_t biCompression; // 0表示不压缩 uint32_t biSizeImage; // 像素数据大小 int32_t biXPelsPerMeter; // 分辨率通常填0 int32_t biYPelsPerMeter; uint32_t biClrUsed; // 0 uint32_t biClrImportant; // 0 } BMP_InfoHeader;写入文件时先写文件头和信息头然后从LCD显存里读取像素数据。这里的关键坑是BMP的像素行序是从下往上存储的也就是说图像的左下角是文件的第一行数据。如果你的屏幕坐标原点在左上角那么写文件时必须从屏幕的最后一行开始逐行向上倒序写入。伪代码逻辑// 打开文件写入54字节头部 f_open(fp, shot.bmp, FA_CREATE_ALWAYS | FA_WRITE); // BMP头部填充bfSize 54 width * height * 3biSizeImage width * height * 3 f_write(fp, bmpFH, sizeof(bmpFH), bw); f_write(fp, bmpIH, sizeof(bmpIH), bw); // 像素数据从最后一行开始 for (int32_t y height - 1; y 0; y--) { for (int32_t x 0; x width; x) { // 读取屏幕点(x,y)的RGB注意顺序为B,G,R uint8_t bgr[3]; LCD_ReadPixel(x, y, bgr[2], bgr[1], bgr[0]); // 注意赋值方向 f_write(fp, bgr, 3, bw); } } f_close(fp);实际执行时由于逐点调用LCD_ReadPixel效率很低建议先按行读出一整行的RGB数据到RAM再一次性写入文件。STM32F103的SRAM通常是20KB可以容纳一行的像素数据比如320像素宽的屏幕每行约960字节完全放得下。4.2 读取BMP文件并解码显示BMP解码是截图保存的逆过程。读取时先解析文件头确认文件类型和宽度高度然后跳转到位图数据偏移逐行读取像素并绘制到LCD。代码骨架如下FIL fp; UINT br; uint8_t lineBuffer[320 * 3 4]; // 存放一行BGR数据 f_open(fp, image.bmp, FA_READ); // 读取文件头 f_read(fp, bmpFH, sizeof(bmpFH), br); f_read(fp, bmpIH, sizeof(bmpIH), br); // 检查bmpFH.bfType为0x4D42bmh.biBitCount为24 f_lseek(fp, bmpFH.bfOffBits); // 跳转到位图数据 int32_t width bmpIH.biWidth; int32_t height bmpIH.biHeight; int32_t rowSize (width * 3 3) ~3; // BMP每行按4字节对齐 for (int32_t y height - 1; y 0; y--) { f_read(fp, lineBuffer, rowSize, br); // 把lineBuffer里的BGR数据转换成RGB后绘制到LCD第y行 LCD_DrawRow(y, width, lineBuffer); } f_close(fp);这里很多人会混淆biHeight的正负含义。BMP规范中biHeight为正表示图像从下往上存储如果为负则表示从上往下存储。大多数Windows生成的BMP是正的所以解码循环里y从height - 1减到0正好对应屏幕从上往下显示。如果你的图像倒过来了检查一下是不是这里写反了。解码时不要用f_read一次读取整个图像因为FATFS底层是扇区读写每次读取到的数据可能跨越多个扇区但只要调用f_read就能自动处理。关键是rowSize必须按4字节对齐计算否则数据错位后整个图像会出现彩色条纹。5. 进阶从CSD寄存器读取卡容量与SPI时序排错5.1 用CMD9解析物理扇区数第3章的disk_ioctl提到GET_SECTOR_COUNT如果写死返回值文件系统实际使用空间就会受限。正确做法是读取CSD寄存器。CSD有1.0和2.0两种版本这里给一个兼容公式。发送CMD9后SD卡会返回一个0xFE令牌然后回传16字节CSD数据。解析两个关键字段C_SIZE、C_SIZE_MULT、READ_BL_LEN。对于CSD 1.0标准容量卡容量计算如下uint8_t csd[16]; // 发送CMD9读取CSD SD_CS_LOW; SD_SendCmd(9, 0, 0x01); while (SD_SPI_ExchangeByte(0xFF) ! 0xFE); // 等待数据令牌 for (uint8_t i 0; i 16; i) { csd[i] SD_SPI_ExchangeByte(0xFF); } SD_CS_HIGH; uint32_t read_bl_len csd[5] 0x0F; uint32_t c_size ((csd[6] 0x03) 10) | (csd[7] 2) | ((csd[8] 0xC0) 6); uint32_t c_size_mult ((csd[9] 0x03) 1) | ((csd[10] 0x80) 7); uint32_t block_len 1u read_bl_len; // 典型512对应9 uint32_t block_count (c_size 1) (c_size_mult 2); uint32_t total_bytes block_count * block_len;得到的total_bytes就是卡的总字节数扇区数等于total_bytes / 512。注意这个公式只适用于CSD 1.0。如果卡容量大于2GBCSD就是2.0上面字段位置全变了。对于大于2GB的卡建议在ACMD41时清掉HCS位强制卡用SCSI模式报告容量这样CSD仍然是1.0格式。不过有些大卡在SPI模式下并不遵守这个约定所以如果你的卡初始化后读出的容量总是异常小直接换一个小容量SD卡测试。5.2 用逻辑分析仪定位初始化失败SD卡初始化失败最典型的症状是SD_InitSequence返回非0或者FATFS挂载返回FR_DISK_ERR。排查时不要只盯程序。拉出CS、CLK、MOSI、MISO四个信号到逻辑分析仪触发条件设为CS下降沿然后观察MISO上是否有正确的R1响应。常见异常情况有三种第一种是MISO始终为高说明SD卡没有响应任何命令。先检查CS是否在发送第二个字节时被拉低有时初始化的80个时钟期间CS为高命令发送时CS变低但如果CS初始化不够及时卡还没被选中命令就丢了。第二种是CMD0响应不是0x01而是0x05这通常是卡没有进入SPI模式可检查前面是否发送满了74个时钟。第三种是ACMD41一直返回0x01卡始终不进入就绪状态问题一般出在CMD55和CMD41之间的间隔超过规范要求的毫秒级或者供电电压不足。另外SD卡模块上通常有电平转换电路如果你的模块是3.3V供电而STM32F103的GPIO也是3.3V不需要额外转换。但如果你用5V给模块供电MISO引脚就可能抬高到5V这会反向灌入F103的PA6引脚长时间运行会损坏芯片。务必用万用表确认模块供电电压或者改用3.3V稳压输出。这个硬件坑导致的问题往往比代码错误更隐蔽因为卡偶尔能初始化成功但频繁读写后就会丢数据。最后再说一个实用技巧在SPI模式下所有命令和响应都通过SD_SPI_ExchangeByte传输你可以在该函数入口加一个调试计数器每传输一个字节计数器加1。初始化失败后把这个计数器的值打印出来结合逻辑分析仪的波形能快速判断命令是否完整发送。不要小看这种笨办法它比在代码里到处加断点更直观尤其在排查与时间相关的并发问题时。本文还有配套的精品资源点击获取