1. 项目概述:为什么要在Pico上折腾SPI SD卡?
如果你手头有一块Raspberry Pi Pico,并且觉得它那2MB的板载Flash有点捉襟见肘,那你肯定想过外接存储。U盘?太笨重。EEPROM?容量太小。这时候,一张小小的SD卡就成了最优雅的解决方案。而连接Pico和SD卡最直接、最经典的方式,就是SPI协议。
这个项目,就是教你如何用Pico的SPI接口,驱动一张SD卡,把它变成一个稳定可靠的“外置硬盘”。听起来简单,不就是接几根线、写几行代码吗?但实际做下来,从硬件连接到协议理解,再到软件调试,每一步都有不少门道。我折腾过好几次,从最早的SDSC卡到现在的SDHC/SDXC卡,从简单的文件读写到追求速度的DMA传输,踩过的坑一个接一个。今天,我就把这些经验系统地梳理出来,目标不只是让你“点亮”SD卡,而是让你彻底理解SPI模式下SD卡的“脾气”,写出稳定、高效的驱动代码,无论是用于数据采集、日志存储,还是充当一个小型文件服务器,都能得心应手。
2. 核心硬件设计与连接要点
硬件连接是第一步,也是决定后续调试难易的关键。Pico的SPI接口很灵活,但连接SD卡时,有几个细节必须注意。
2.1 SPI接口选择与引脚定义
Pico有两个硬件SPI接口:spi0和spi1。理论上任选一个即可。我通常优先使用spi0,因为它的默认引脚(GPIO16/GPIO19)在Pico板子边缘,接线方便。SD卡在SPI模式下需要4根信号线:
- SCK (Serial Clock): 时钟线,由主设备(Pico)产生。
- MOSI (Master Out Slave In): 主设备输出,从设备输入。Pico通过这根线向SD卡发送命令和数据。
- MISO (Master In Slave Out): 主设备输入,从设备输出。SD卡通过这根线向Pico返回响应和数据。
- CS (Chip Select): 片选线,低电平有效。用于选中当前要通信的SD卡。
此外,还需要连接电源(3.3V)和地(GND)。特别注意:Pico的逻辑电平是3.3V,SD卡在SPI模式下也使用3.3V电平,两者可以直接连接,无需电平转换。这是Pico的一个巨大优势。
一个典型的连接方案如下(以使用spi0默认引脚为例):
| Pico GPIO | 引脚名称 | SD卡 SPI 引脚 | 功能 |
|---|---|---|---|
| GPIO16 | SPI0 RX | DO (Data Out) | MISO,数据从卡到Pico |
| GPIO17 | SPI0 CSn | CS (Chip Select) | 片选,可任选一个GPIO |
| GPIO18 | SPI0 SCK | SCLK (Clock) | 时钟 |
| GPIO19 | SPI0 TX | DI (Data In) | MOSI,数据从Pico到卡 |
| 3V3(OUT) | 3.3V Power | VDD | 电源(3.3V) |
| GND | Ground | VSS | 地 |
注意:SD卡座的CD(卡检测)和WP(写保护)引脚不是SPI协议必需的,可以不接。如果你想实现物理卡检测或写保护功能,可以将它们连接到Pico的其它GPIO上,并通过上拉电阻处理。
2.2 硬件电路设计的三个关键细节
接线看似简单,但为了稳定,有三个地方必须处理好:
- 上拉电阻:SD卡的CMD、DAT0-DAT3(在SPI模式下,我们主要用DAT1作为MISO)内部通常有弱上拉,但在长导线或高噪声环境下,在Pico端为MISO和MOSI线添加一个4.7kΩ到10kΩ的外部上拉电阻到3.3V,可以显著增强信号稳定性,尤其在初始化阶段。
- 电源去耦:一定要在SD卡座的VCC和GND引脚之间,靠近卡座的位置,并联一个100nF的陶瓷电容和一个10μF的钽电容或电解电容。这是消除电源噪声、防止读写过程中电压跌落导致操作失败的关键。很多“玄学”般的读写错误,根源都在这里。
- 电平匹配再确认:虽然都是3.3V,但务必确保你的SD卡模块(如果使用模块)是3.3V逻辑的。有些模块为了“兼容”5V系统,会做电平转换或使用5V耐受的卡座,但其逻辑电平输出可能仍是3.3V。最保险的方法是直接用万用表测量模块输出引脚的电平。
3. SPI协议与SD卡命令层深度解析
硬件通了,接下来是软件的灵魂——协议。SPI本身很简单,但SD卡在SPI模式下的命令-响应机制需要仔细理解。
3.1 SPI模式与配置
SD卡SPI模式使用模式0(CPOL=0, CPHA=0)或模式3(CPOL=1, CPHA=1)。绝大多数SD卡两种模式都支持,但为了最大兼容性,我们通常使用模式0。在初始化阶段,时钟频率不能太高,一般建议在100kHz到400kHz之间。初始化成功后,再切换到更高的频率(如Pico可以轻松跑到几十MHz)。
在Pico的SDK中,配置SPI时需要注意数据位顺序。SD卡协议规定数据字节高位(MSB)先发送。这是默认设置,但务必在代码中确认。
3.2 SD卡命令格式与初始化序列
这是驱动的核心。所有与SD卡的交互都通过发送命令(Command)来完成。一个命令帧由6个字节组成:
- 字节1:
0 1 x x x x x x。最高位始终为0,第二位始终为1,后6位是命令号(如CMD0是0x40,CMD8是0x48)。 - 字节2-5:命令参数(32位)。例如,CMD8的参数中包含了电压检查信息。
- 字节6:7位CRC校验 + 1位停止位(始终为
1)。但在SPI模式下,初始化阶段CRC可以禁用(CMD0的CRC是0x95是硬编码的,CMD8需要有效CRC),之后发送CMD59可以关闭CRC检查,以简化操作。
初始化流程是一套固定的“舞蹈”,顺序错了或者响应不对,卡就不会就绪。基本流程如下:
- 上电后延时:发送至少74个时钟脉冲(不拉低CS),让卡完成内部上电复位。简单做法是先拉高CS,然后以低速时钟连续发送至少10个字节的
0xFF。 - 发送CMD0(GO_IDLE_STATE):拉低CS,发送CMD0(
0x40 0x00 0x00 0x00 0x00 0x95),使卡进入SPI模式。期望返回的响应字节R1为0x01(空闲状态)。 - 发送CMD8(SEND_IF_COND):这是一个电压检查命令,用于验证卡是否支持2.7-3.6V电压。参数通常为
0x000001AA,CRC为0x87(计算得出)。如果卡返回0x01后面跟着4个字节的回应(其中包含我们发送的0xAA),说明是SDC V2.0或更新的卡。 - 发送ACMD41(SD_SEND_OP_COND):这是一个应用特定命令。先发送CMD55(
0x77)告诉卡下一个是应用命令,然后再发送ACMD41(0x69)。参数中的0x40000000位(HCS位)用于询问卡是否支持高容量(SDHC/SDXC)。需要循环发送这个命令序列,直到返回的R1响应为0x00(就绪),而不是0x01(忙)。这个过程可能持续数百毫秒。 - 发送CMD58(READ_OCR):读取操作条件寄存器,可以确认卡电压范围和CCS(卡容量状态)位。CCS位为1表示是SDHC/SDXC(块寻址),为0表示是SDSC(字节寻址)。这一点至关重要,决定了后续读写操作的寻址方式。
- 发送CMD59(CRC_ON_OFF):参数为
0x00以关闭CRC检查,简化后续通信。 - 设置块长度:对于SDSC卡,可能需要发送CMD16(
0x50)设置块长度为512字节。SDHC/SDXC卡的块长度固定为512字节,无需此步骤。 - 提高SPI时钟频率:初始化完成后,将SPI时钟频率提高到目标值,例如8MHz、16MHz甚至更高,以提升读写速度。
实操心得:初始化流程中,每次发送命令后,都要等待并读取响应。SD卡会在MISO线上输出一个响应字节(
0xFF表示忙,具体响应值因命令而异)。在读取数据阶段,卡会先发送一个数据起始令牌(0xFE),然后才是512字节的数据块和2字节的CRC。我们的驱动代码必须严格遵循这个时序去解析。
4. 软件驱动实现与FatFS文件系统集成
理解了协议,我们就可以用代码来实现它。我将以Raspberry Pi Pico的C/C++ SDK为例,分步解析。
4.1 底层SPI读写函数封装
首先,我们需要封装最底层的字节读写函数。Pico SDK提供了硬件SPI的API,但为了更好的控制和超时处理,我习惯自己封装一层。
#include "hardware/spi.h" #include "hardware/gpio.h" #define SD_SPI_PORT spi0 #define SD_PIN_MISO 16 #define SD_PIN_CS 17 #define SD_PIN_SCK 18 #define SD_PIN_MOSI 19 // 初始化SPI硬件 void sd_spi_init() { spi_init(SD_SPI_PORT, 100 * 1000); // 初始化低速,100kHz gpio_set_function(SD_PIN_MISO, GPIO_FUNC_SPI); gpio_set_function(SD_PIN_SCK, GPIO_FUNC_SPI); gpio_set_function(SD_PIN_MOSI, GPIO_FUNC_SPI); gpio_init(SD_PIN_CS); gpio_set_dir(SD_PIN_CS, GPIO_OUT); gpio_put(SD_PIN_CS, 1); // CS高电平,不选中 } // 交换一个字节(同时完成发送和接收) static uint8_t sd_spi_transfer(uint8_t data) { uint8_t rx_data; spi_write_read_blocking(SD_SPI_PORT, &data, &rx_data, 1); return rx_data; } // 发送命令(6字节)并返回R1响应 uint8_t sd_send_cmd(uint8_t cmd, uint32_t arg) { uint8_t r1; uint8_t crc = 0xFF; // 默认CRC,对于CMD0和CMD8需要特殊处理 // 构造命令帧 uint8_t frame[6]; frame[0] = cmd | 0x40; // 起始位+命令号 frame[1] = (arg >> 24) & 0xFF; frame[2] = (arg >> 16) & 0xFF; frame[3] = (arg >> 8) & 0xFF; frame[4] = arg & 0xFF; // 处理特殊命令的CRC if (cmd == 0) crc = 0x95; // CMD0 else if (cmd == 8) crc = 0x87; // CMD8 frame[5] = crc; // 发送命令前先发送8个时钟脉冲,并拉低CS sd_spi_transfer(0xFF); gpio_put(SD_PIN_CS, 0); for (int i = 0; i < 6; i++) { sd_spi_transfer(frame[i]); } // 等待响应(最多重试N次) int retries = 10; while ((r1 = sd_spi_transfer(0xFF)) == 0xFF) { if (--retries == 0) { r1 = 0xFF; // 超时 break; } } // 注意:这里不立即拉高CS,因为有些命令(如CMD17)后面要紧跟数据读写 // 对于纯命令,调用者需要在获取响应后拉高CS return r1; }4.2 数据块读写函数实现
读写数据块是文件操作的基础。读一个块的典型流程是:发送CMD17(0x51)带块地址参数,等待数据起始令牌0xFE,读取512字节数据,读取2字节CRC,最后拉高CS。
// 读取一个512字节的数据块 bool sd_read_block(uint32_t block_addr, uint8_t *buffer) { uint8_t r1 = sd_send_cmd(17, block_addr); // CMD17: READ_SINGLE_BLOCK if (r1 != 0x00) { gpio_put(SD_PIN_CS, 1); // 出错,拉高CS sd_spi_transfer(0xFF); // 额外时钟脉冲 return false; } // 等待数据起始令牌 int timeout = 100000; // 超时计数 while (sd_spi_transfer(0xFF) != 0xFE) { if (--timeout == 0) { gpio_put(SD_PIN_CS, 1); sd_spi_transfer(0xFF); return false; } } // 读取512字节数据 for (int i = 0; i < 512; i++) { buffer[i] = sd_spi_transfer(0xFF); } // 跳过2字节CRC sd_spi_transfer(0xFF); sd_spi_transfer(0xFF); gpio_put(SD_PIN_CS, 1); sd_spi_transfer(0xFF); // 额外时钟脉冲 return true; }写块(CMD24)流程类似,但需要先发送起始令牌0xFE,然后发送512字节数据,再发送2字节伪CRC(通常0xFF, 0xFF),最后等待卡返回一个数据响应令牌,并等待卡完成编程(busy位为0)。
4.3 集成FatFS文件系统
直接操作扇区太原始,我们需要一个文件系统。FatFS是一个轻量级、通用的FAT文件系统模块,完美嵌入单片机项目。在Pico上集成FatFS,需要为其提供底层磁盘I/O接口,即实现disk_read、disk_write、disk_ioctl等函数。
- 获取FatFS源码:从elm-chan.org获取最新版FatFS(例如R0.15)。
- 实现磁盘IO层:在项目中创建一个文件(如
sd_diskio.c),实现以下函数:disk_initialize: 调用我们编写的SD卡初始化函数。disk_status: 返回磁盘状态。disk_read: 调用我们的sd_read_block函数。disk_write: 调用我们的sd_write_block函数。disk_ioctl: 处理控制命令,如获取扇区数量(GET_SECTOR_COUNT)、获取扇区大小(GET_SECTOR_SIZE)等。这些信息可以从SD卡的CSD(卡特定数据)寄存器中解析获得,或在初始化时通过CMD9(SEND_CSD)命令读取。
- 在应用层使用:包含
ff.h和diskio.h,然后就可以像在PC上一样使用f_open,f_read,f_write,f_close等标准C文件操作函数了。
注意事项:FatFS本身不支持长文件名(LFN)和中文,需要额外启用相关模块并提供编码转换函数。对于大多数嵌入式日志存储应用,短文件名(8.3格式)已经足够。此外,首次使用SD卡前,需要在PC上或用Pico代码将其格式化为FAT32(对于容量大于2GB的卡)或FAT16。
5. 性能优化与高级话题
基础功能实现后,我们自然会追求更快、更稳、更省资源。
5.1 使用DMA提升读写吞吐量
Pico的SPI支持DMA(直接存储器访问),可以解放CPU,在传输数据的同时让CPU去处理其他任务,并且能实现更高的连续传输速度。优化读卡流程的核心思路是:使用DMA将SPI RX FIFO中的数据直接搬运到内存缓冲区。
关键步骤包括:
- 配置SPI的DMA控制寄存器,启用RX DMA请求。
- 设置DMA通道,将源地址设为SPI接收FIFO的只读寄存器,目标地址设为你的数据缓冲区。
- 在发送读命令并检测到数据起始令牌
0xFE后,启动DMA传输512字节。 - 等待DMA传输完成中断或轮询完成标志。
这样,512字节的数据传输几乎不占用CPU时间。实测下来,在Pico上使用DMA可以将SPI时钟推到更高(如30MHz以上),连续读取速度可以超过2MB/s,相比纯CPU轮询方式有显著提升。
5.2 多块读写与擦除预优化
对于需要连续写入大量数据的场景(如录制音频),单块写入(CMD24)效率低。SD卡支持多块写入(CMD25)。
- 发送CMD25并指定起始地址。
- 对于每个要写的块,先发送起始令牌
0xFC(多块写入令牌),然后是512字节数据和2字节CRC。 - 发送停止传输令牌
0xFD来终止多块写入序列。 多块写入允许卡内部进行更有效的数据管理和缓存,通常比多次单块写入更快。更重要的是,在写入大量数据前,如果知道后续要写入的扇区范围,可以提前发送CMD32(ERASE_WR_BLK_START)和CMD33(ERASE_WR_BLK_END)来标记擦除范围,最后发送CMD38(ERASE)进行擦除。先擦除再写入,比直接覆盖写入要快得多,因为闪存必须先擦除(置1)才能写入(置0)。
5.3 电源管理与错误处理
嵌入式设备常需考虑功耗。SD卡在空闲时可以通过发送CMD0使其进入空闲状态,或发送CMD15(GO_INACTIVE_STATE)使其进入更深的休眠。重新唤醒时需要重新初始化。复杂的错误处理是工业级应用的关键。驱动中应检查每个命令的响应:
R1响应:最高位为1表示错误。常见错误有:地址超限、写保护、擦除序列错误、CRC错误等。- 数据响应令牌:写操作后,卡会返回一个数据响应令牌,其中包含写入是否被接受的信息。
- 编程超时:写或擦除操作后,需要持续读取MISO线直到它变高(忙状态结束)。必须设置一个合理的超时时间(如500ms),防止卡死锁。
一个健壮的驱动应该在初始化失败、读写失败时进行多次重试,并记录错误类型,而不是简单地返回失败。
6. 实战调试与常见问题排查
理论再完美,也要实战检验。下面是我在调试过程中遇到的一些典型问题及解决方法。
6.1 初始化失败问题排查表
| 现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 发送CMD0后无响应(始终为0xFF) | 1. 硬件连接错误(MISO/MOSI接反) 2. CS引脚控制逻辑反了 3. 电源问题(电压不足、电流不够) 4. 时钟频率初始过高 | 1. 用逻辑分析仪或示波器检查SCK、MOSI、CS波形,确认有数据发出。 2. 确认CS引脚初始化后为高电平,发送命令前拉低,结束后拉高。 3. 测量SD卡VCC引脚电压,确保在3.3V左右。尝试单独给SD卡模块供电。 4. 将初始SPI时钟降至50kHz甚至更低再试。 |
| CMD0返回0x01后,CMD8无响应或返回非预期值 | 1. CMD8的CRC错误 2. 卡不支持CMD8(可能是V1.x的SD卡或MMC卡) | 1. 确认CMD8的CRC字节计算正确(应为0x87)。 2. 尝试跳过CMD8,直接进入ACMD41循环。对于老卡,ACMD41的参数中不要设置HCS位(即参数为0)。 |
| ACMD41循环一直返回0x01(忙) | 1. 供电不足 2. 卡损坏或不兼容 3. 发送ACMD41前未正确发送CMD55 | 1. 检查电源,确保上电后有多余的74个时钟脉冲等待时间。 2. 换一张卡试试。 3.确保每次发送ACMD41前,都先发送CMD55。CMD55的响应也需检查,应为0x01(空闲)或0x00(就绪)。 |
| 初始化成功但读CID/CSD失败 | 1. 初始化后SPI模式或时钟频率设置不当 2. 命令CRC问题未关闭 | 1. 确认初始化完成后已将SPI切换到正确的模式(0)和较高频率。 2. 确认发送了CMD59关闭CRC检查。 |
6.2 读写操作中的“玄学”问题
问题:偶尔能读写,但不稳定,数据错误。
- 排查:这几乎是硬件问题的典型表现。重点检查:
- 电源去耦电容:是否紧靠SD卡座?容值是否足够(建议100nF陶瓷电容并联10μF电解电容)?
- 导线长度与质量:杜邦线过长或接触不良会引入噪声。尽量使用短而粗的导线,或直接使用焊接连接。
- 上拉电阻:在SCK、MOSI、MISO线上添加4.7kΩ上拉电阻到3.3V。
- 共地:确保Pico和SD卡模块的GND是连接在一起的。
- 排查:这几乎是硬件问题的典型表现。重点检查:
问题:写文件后,拔插卡在电脑上显示文件损坏或需要格式化。
- 排查:
- 未正确关闭文件/卸载卷:在
f_close()和f_mount(NULL, ...)(卸载)之前,数据可能还在缓存中,没有真正写入卡。确保所有文件操作后都正确关闭和卸载。 - 写操作未等待完成:
f_write是异步的?对于FatFS,需要检查f_sync或确保写操作后文件被关闭,以将缓存数据刷入磁盘。 - 文件系统结构损坏:频繁断电可能导致FAT表或目录项损坏。考虑启用FatFS的
_FS_READONLY或_FS_MINIMIZE模式减少写操作,或实现掉电保护机制。
- 未正确关闭文件/卸载卷:在
- 排查:
问题:高速读写时(如使用DMA),数据错乱。
- 排查:
- SPI时钟相位/极性:高速下必须严格匹配。用逻辑分析仪捕获波形,确认数据在正确的时钟边沿被采样。
- DMA缓冲区对齐:Pico的DMA对数据对齐有要求。确保你的读写缓冲区地址是4字节对齐的(例如使用
aligned属性声明数组)。 - CPU缓存:如果使用了Pico的缓存(在更高性能模式下),需要确保DMA操作的内存区域是非缓存的,或者在进行DMA操作前手动清理缓存。
- 排查:
调试时,一个逻辑分析仪(哪怕是便宜的USB逻辑分析仪配合Sigrok/PulseView软件)是 invaluable 的。它能让你直观地看到SPI总线上的每一个命令、响应和数据字节,比任何打印调试都管用。
7. 项目扩展与应用场景
一个稳定的SPI SD卡接口,能为Pico项目打开一扇新的大门。
- 数据记录器:结合传感器(温湿度、加速度计、GPS),将采集的数据以CSV或二进制格式定时写入SD卡,形成独立的数据黑匣子。通过优化写入策略(如缓冲、批量写入),可以极大延长Flash寿命并保证数据完整性。
- 音频播放器/录音机:存储WAV或MP3文件。通过高速读取和DMA传输至I2S DAC,实现音频流播放。反过来,也可以将I2S ADC采集的数据流写入SD卡。
- 配置存储与固件更新:将设备配置参数(如Wi-Fi密码、校准系数)存储在SD卡的配置文件中。更高级的,可以实现通过SD卡进行固件升级(Bootloader从SD卡读取新固件并写入Flash)。
- 简易网络文件共享:如果Pico连接了以太网或Wi-Fi模块(如Pico W),可以运行一个轻量级的FTP或HTTP服务器,允许网络上的其他设备访问SD卡中的文件。
- 图像存储与显示:从摄像头模块(如OV7670)读取图像数据,以BMP或JPEG格式保存到SD卡。或者,从SD卡读取图片文件,发送到SPI TFT屏幕进行显示。
在我做的一个环境监测项目中,Pico每10分钟读取一次传感器数据,先缓存在内部RAM中,每攒够1小时的数据(6条记录),再一次性写入SD卡的一个日志文件。这样将频繁的小写操作合并为一次性的大写操作,显著减少了SD卡的磨损和功耗。同时,在每次上电时,程序会检查SD卡根目录下是否存在一个特殊的“配置.txt”文件,如果存在,就读取其中的网络配置和报警阈值,实现了无代码更新的设备配置。
最后,关于SD卡的选择,我的经验是,对于嵌入式应用,不要追求极致的UHS速度等级。那些为高速连拍设计的卡,往往更耗电,初始化时序也可能更挑剔。一张普通的、品牌可靠的Class10或U1规格的SDHC卡,通常具有最好的兼容性和稳定性。