1. 拆解之前:SD卡不是"一张闪存",而是一台微型计算机
设计数据记录板时,我把SD卡当成一个大号U盘用,以为只要能跑通文件读写就够了。结果一个"格式化后依然写保护"的诡异现象,让我不得不把整条链路从头刨了一遍。最后发现问题的根源,既不是卡坏了,也不是代码错了,而是我完全没理解SD卡的命令协议在初始化阶段的信号时序。这篇文章的初衷,就是把我这次系统性的刨析过程记录下来:SD卡的内部结构原理、卡协议的命令与响应、硬件电路设计中容易忽略的细节,以及MicroPython驱动从零实现到文件系统挂载的完整闭环。适合正在做数据记录、音频存储、嵌入式Linux启动卡这类项目的朋友,也适合想真正搞懂"卡"而不只是"会调库"的电子爱好者。
在大多数人眼里,SD卡就是"一块能存数据的NAND闪存加一个接口"。但实际上,SD卡内部至少有一颗32位MCU、一块SRAM、以及完整的FTL固件。它是一台非常完整的微型计算机。你发一个读命令给它,它会自己完成坏块检查、ECC校验、地址映射、数据搬运,然后把结果通过数据线返回。这意味着,SD卡对外暴露的接口是"经过包装的命令/响应协议",而不是原始闪存总线。理解这一点,是后面所有排错的基础。
1.1 从一次"格式化后仍然写保护"的故障说起
那次故障的具体表现是:板子上的SD卡能初始化、能挂载FAT32、能写文件,但运行约一个小时后,新写入的文件内容全是0xFF。更诡异的是,把卡插到电脑读卡器上,Windows提示需要格式化;格式化之后重新插回板子,依然写不进去。
我最初怀疑卡座虚焊,补焊后无效;怀疑SD卡坏了,换一张新卡故障复现;怀疑是电路供电不足,用示波器抓VDD,纹波正常。直到我在调试器里单步执行初始化代码,发现程序在ACMD41的循环里多等了整整3秒才收到0x00响应——也就是说,卡内部一直在做"上电初始化",而我过早地开始发读写命令。后续的写失败,其实是卡在某种未完全就绪状态下,数据命令被内部静默丢弃的结果。
这个案例给我的教训是:SD卡的一切问题,都要从"协议状态机"的角度去排查,而不是先怀疑硬件。卡内部有一个有限状态机,从空闲态、就绪态、识别态、传输态到数据态,每一步都依赖正确的命令序列和数据响应。任何一步时序不满足,它都可能停在某个中间态,表现成"能用但不完全能用"。所以这篇文章我不打算只讲怎么接线、怎么调库,而是把每一层状态转换的逻辑讲透,这样就算换平台、换语言,你也能自己排查。
1.2 全文的技术地图:从引脚到文件的五层链路
从宏观上看,一次"往SD卡写一个文件"的操作,其实贯穿了五层:物理层(引脚与电气特性)、链路层(命令帧/响应帧/数据token)、驱动层(初始化和扇区读写)、卷层(FAT文件系统)、应用层(open/write/close)。这五层每一层都有独立的状态和故障模式,排查时必须分层隔离。
这篇文章我会按这个顺序走:先拆开SD卡看内部结构,明白它为什么需要FTL和ECC;然后逐字节拆解卡协议的命令帧、响应帧和初始化时序;接着讲硬件电路设计里供电、上拉、ESD、走线的取舍;再手写一个MicroPython驱动,把协议变成代码;最后挂载FAT32文件系统,并分析常见故障。每一层我都会给出实测数据或踩坑记录,而不是只贴规范。
2. 内部解剖:NAND阵列、FTL与卡上控制器
SD卡的内部结构,其实比很多人想象得复杂得多。一张标准的SD卡内部有这几大块:NAND闪存阵列、主控芯片(包含CPU核心、ROM、RAM)、以及电源管理/电平转换电路。主控芯片通过内部总线直接管理NAND闪存,而用户能接触到的只有那几个金手指引脚。换句话说,SD卡本身就是一个完整的"闪存盘"设备,主控芯片就是这台微型计算机的CPU。
2.1 存储介质:为什么是NAND而不是NOR
SD卡用的存储介质是NAND Flash,不是NOR Flash。NAND的特点是写入前必须先擦除,而且按块(Block)擦除,按页(Page)写入。一个Block通常是64页到256页,一页通常是2KB到16KB,具体看闪存制程和型号。这种结构决定了它的成本低、容量大,但不适合随机字节写入——你没法像改内存那样改一个字节,只能整页读出来、改掉、再整页写回去。
NAND还有一个更头疼的毛病:颗粒本身会坏。出厂时就有坏块,使用过程中坏块还会不断增加。这在消费级产品里完全不可接受,所以SD卡内部必须有一个"翻译层"来隐藏这些缺陷。这个翻译层就是FTL(Flash Translation Layer)。用户读写的逻辑地址,会被FTL映射到物理地址,写坏了的物理块会被替换到备用块,整个过程对外透明。所以你看到的SD卡"逻辑扇区",和闪存颗粒里的"物理页"根本不是一回事。
2.2 FTL与控制器:容量虚标和寿命的秘密
FTL在主控固件里承担三件事:逻辑到物理的映射、磨损均衡、坏块管理。逻辑到物理映射通常以页或块为单位建立一个映射表,维护在RAM里,定期写回NAND的映射区。磨损均衡则是为了让所有块被擦写的次数尽量平均,避免某些块先被写坏。坏块管理则在发现读写错误时把坏块标记出来,把数据搬到备用块。
理解了FTL,你就能看懂两个现象。第一,SD卡标称容量和实际可用容量的差。比如一张64GB的卡,实际用户可见的容量往往只有61GB左右,多出来的空间是给FTL做映射表、坏块替换和磨损均衡用的。第二,为什么SD卡的"写放大"问题主要发生在小文件随机写入场景——因为FTL为了更新一个逻辑页,经常要把整个物理块先拷贝出来再整体写回。这些原理听起来和驱动开发无关,但当你遇到"卡用久了写速度明显下降""新卡和旧卡行为不一致"这类问题时,就能明白是FTL在背后起作用。
2.3 引脚定义与SD/SPI双模式
SD卡对外有9个引脚,但支持两种完全不同的接口模式:SD总线模式和SPI模式。SD总线模式使用CLK、CMD和DAT0-DAT3四条数据线,最高支持4-bit并行传输,性能更好;SPI模式则复用这些引脚为SCLK、DI、DO、CS,只走串行协议。几乎所有MCU平台上的SD卡驱动,起步阶段都是用SPI模式,因为大多数MCU的SDIO外设比SPI更复杂、更容易踩坑。
引脚对应关系如下:
| SD卡引脚 | SD总线模式 | SPI模式 |
|---|---|---|
| 1 | CD/DAT3(数据线3/卡检测) | CS(片选) |
| 2 | CMD(命令线) | DI(主机->卡数据) |
| 3 | VSS1(地) | VSS1(地) |
| 4 | VDD(供电) | VDD(供电) |
| 5 | CLK(时钟) | SCLK(时钟) |
| 6 | VSS2(地) | VSS2(地) |
| 7 | DAT0(数据线0) | DO(卡->主机数据) |
| 8 | DAT1(数据线1) | RSV(保留) |
| 9 | DAT2(数据线2) | RSV(保留) |
这里有个细节值得注意:SD卡的上电检测是靠DAT3(即SPI模式的CS引脚)上的上拉电阻实现的。卡插入卡座时,DAT3会被卡内部的电阻拉低或拉高(取决于卡座设计),主机可以据此判断卡是否插入。很多自制的卡座电路忽略了DAT3的接法,导致卡永远检测不到或初始化失败。
3. 卡协议逐层拆解:命令帧、响应与初始化时序
SD卡协议的精髓,在于它是一个"命令-响应"模型。主机发送一个48位的命令帧,卡在规定的时钟周期内返回一个或多个响应帧,然后主机根据响应决定下一步动作。这条命令链路的所有规则都写在SD卡规范里,但规范文件几百页,大部分人没有耐心啃。我这里把最核心的内容提取出来,按"帧格式-命令-响应-初始化时序-数据传输"的顺序讲。
3.1 48位命令帧:起始位、索引、参数与CRC的排布
主机发送的每一条命令都是48位,在SPI模式下是6个字节。这48位的布局是这样的:第1位是起始位,固定为0;第2位是传输方向位,固定为1,表示这是主机到卡的方向;接下来6位是命令索引(CMD0到CMD64);再接下来32位是命令参数,具体含义由每条命令定义;然后是7位CRC校验;最后1位是结束位,固定为1。
从字节角度看,这6个字节就是:
- Byte0 = 0x40 | 命令索引
- Byte1-Byte4 = 参数(大端序,高位在前)
- Byte5 = CRC7 + 结束位(结束位是bit0,所以通常CRC7左移1位后或上0x01)
这里最容易出问题的是CRC。在SPI模式下,卡进入SPI模式之前(也就是CMD0还没成功之前),命令帧的CRC必须正确计算;进入SPI模式之后,部分卡会忽略CRC校验,但严谨的驱动还是会算出正确的CRC再发送,以兼容所有厂商。很多人写驱动时偷懒,初始化阶段也随便填CRC,结果卡一直返回0x01(空闲态),怎么查都查不出来。
3.2 常见命令与响应格式:R1、R2、R3、R7
SD卡规范里定义了多种响应格式,SPI模式下最常见的是R1、R2、R3和R7。驱动代码里最常打交道的是R1响应。
R1响应是1个字节,最高位(bit7)固定为0,表示命令已被卡正确接收。剩余7位各自表示一种卡状态,置1代表对应状态有效。最常检查的位是bit0,它表示卡处于空闲态;初始化完成后这个位应该是0。所以很多驱动判断"卡是否忙/是否完成初始化",就是看R1的低7位是不是0。
R2响应在SPI模式下用于读取CID寄存器和CSD寄存器。它的结构是1字节R1加16字节寄存器数据,总共17个字节。R3用于读取OCR寄存器,结构是1字节R1后跟4字节OCR内容。R7用于CMD8(接口状态检查),结构是1字节R1后跟4字节参数回显。之所以特别提CMD8,是因为它是区分SD卡版本和电压支持范围的关键命令。
我建议你写一个工具函数,把所有可能的响应打印出来,对照规范排查。比如:
def cmd(self, index, arg=0, crc=0): # 发送命令帧 frame = bytearray([0x40 | index, (arg >> 24) & 0xFF, (arg >> 16) & 0xFF, (arg >> 8) & 0xFF, arg & 0xFF, (crc << 1) | 1]) self.cs.value(0) self.spi.write(frame) # 等待响应:读字节直到最高位为0 for _ in range(10): b = self.spi.read(1)[0] if not (b & 0x80): return b return -1 # 超时这个函数的逻辑是所有SD卡驱动的基础:发命令帧,等R1响应,超时则返回错误。
3.3 初始化时序:从CMD0到CMD7,每一步在干什么
SD卡的初始化是一个严格有序的过程,顺序不能乱。完整流程是:上电 -> 发送至少74个时钟 -> CMD0 -> CMD8 -> ACMD41(循环等待就绪)-> CMD2 -> CMD3 -> CMD7。SPI模式下,CMD2/CMD3主要用于读取CID和获取RCA,之后通过CMD7选中卡,但实际上很多SPI驱动在ACMD41就绪后直接跳到读取CSD,不再执行CMD2/CMD3,因为SPI模式下片选本身就选定了唯一设备。
我把关键步骤的作用列一下:
- 74个时钟:卡上电后需要一段时间稳定内部电路,同时在CS为高时收到至少74个SCLK时钟,卡才能准备好接受第一条命令。
- CMD0(参数0,CRC 0x95):复位卡,让它进入空闲态。这是唯一一个在SPI模式下必须附带正确CRC的命令,因为此时卡可能还在SD模式监听。
- CMD8(参数0x1AA,CRC 0x87):检查卡是否支持2.7V-3.6V工作电压。如果卡支持,会回显0x1AA;不支持则返回非法命令错误。
- CMD55 + ACMD41(参数0x40000000):CMD55本身不做实际操作,它的作用是告诉卡"下一条命令是应用特定命令ACMD"。ACMD41携带HCS位(bit30),如果置1,说明主机支持高容量卡;卡就绪后会返回R1的bit0清0。
- CMD58:读取OCR寄存器,检查CCS位,判断卡是SDSC还是SDHC/SDXC。这在决定地址模式时至关重要。
- CMD9:读取CSD寄存器,解析出卡容量、块大小等信息。
- CMD17/CMD24:真正读取/写入扇区。
初始化代码的骨架长这样:
def init_card(self): # 1. 时钟预热 self.cs.value(1) dummy = bytearray(10) self.spi.write(b'\xff' * 10) # 80个时钟 # 2. 进入SPI模式 self.cs.value(0) r = self.cmd(0, 0, 0x95) # CMD0 if r != 0x01: raise OSError("CMD0 failed: %d" % r) # 3. 检查工作电压 r = self.cmd(8, 0x1AA, 0x87) # CMD8 if r == 0x01: # 支持,继续读取4字节回显 resp = self.spi.read(4) # 4. 等待ACMD41就绪 while True: self.cmd(55, 0) r = self.cmd(41, 0x40000000) if r == 0: break time.sleep_ms(10) # 5. 读取OCR判断卡类型 r = self.cmd(58, 0) ocr = self.spi.read(4) self.is_hc = True if (ocr[0] & 0x40) else False # bit30为1表示SDHC这段代码里最容易踩的坑,是ACMD41的循环里漏掉了CMD55前缀。ACMD是"应用特定命令",必须先发CMD55再发CMD41,否则卡会把CMD41当作无效命令忽略掉。我见过好几个项目都是卡在这一步,初始化永远超时。
3.4 数据读写的完整交互:命令、数据token与忙检测
初始化完成后,真正的读写是另一个状态机。以读单块为例,主机发送CMD17(参数是块地址),卡如果能处理,会先返回R1响应(bit0清0),然后发送一个数据起始token(0xFE),紧接着发送512字节数据和2字节CRC。主机必须等token出现才能开始接收数据,否则会收到一堆无用的0xFF。
写单块的过程稍微复杂一点。主机发送CMD24后,卡返回R1响应,然后主机要发送一个数据起始token(0xFE)、512字节数据和2字节CRC。卡收到后会给一个数据响应字节:0x05表示接受,0x0B表示CRC错误,0x0D表示写错误。接着卡会拉低数据线表示"忙",主机要一直发送时钟直到卡释放数据线(读到0xFF)。
这里有个非常重要的细节:在SPI模式下,卡没有独立的"忙"引脚,忙状态是通过数据线DO持续输出低电平来表示的。所以驱动里要有等待不忙的函数:
def wait_not_busy(self, timeout_ms=500): deadline = time.ticks_ms() + timeout_ms while time.ticks_ms() < deadline: if self.spi.read(1)[0] == 0xFF: return True time.sleep_ms(1) raise OSError("Card busy timeout")如果你在写数据后没有等不忙就直接发下一条命令,卡会静默丢弃命令,导致"写进去了但数据不对""写了一半文件系统损坏"这类问题。
3.5 SD模式与SPI模式的取舍:什么时候用哪种
说完了SPI模式,再说说SD模式和SPI模式的选择问题。很多文章只讲SPI模式,导致新手以为SD卡只有SPI一种接口。实际上SD模式下可以通过4条数据线并行传输,同样的时钟频率下带宽是SPI模式的4倍。
日常开发中我的选择标准很简单:如果MCU有SDIO外设,且官方例程靠谱,优先用SD模式,因为速度快;如果没有SDIO外设,或者调试期想快速验证硬件,用SPI模式就好。SPI模式的最大缺点不仅仅是带宽低,还在于它只支持单卡、命令响应延迟更大,以及某些卡的SPI模式实现有bug。但SPI模式的优势也很明显:几乎所有MCU都有SPI,时序可以手工控制,排查问题更直观。我自己做数据记录板时为了稳定,长期跑的就是SPI@20MHz,实测读速度约2MB/s,写约1.5MB/s,对大多数日志类应用完全够用。
4. 硬件电路设计:供电、上拉、ESD与布线
SD卡的硬件电路乍一看很简单,不就一个卡座加几根线吗?但正是这些"简单"的细节,导致我格式化后写保护那次故障花了整整两天。下面按照电源、信号、保护、布线四个维度,说说我实测过的设计经验。
4.1 电源设计:3.3V纹波与去耦电容
SD卡的工作电压范围是2.7V到3.6V,推荐用3.3V供电。但"3.3V"不是随便一个LDO输出就行。卡的峰值电流在写入时可以达到100mA到200mA,某些大容量卡在突发写的时候能接近300mA。如果供电网络内阻偏大,写入瞬间VDD会被拉低到2.7V以下,卡会执行欠压保护,表现就是"读到一半写失败"。
我的做法是:VDD对VSS加总共不小于10uF的去耦电容,其中至少一颗是低ESR的陶瓷电容(0402或0603封装,0.1uF),紧贴卡座的VDD引脚放置;再并联一颗10uF的钽电容或X5R陶瓷电容。LDO的输入侧也要有10uF级别的储能电容。如果用DC-DC给SD卡供电,要注意纹波峰值不要超过50mV,特别是开关频率落在音频范围的DC-DC,实际纹波可能在100mV以上,这时候需要加大输出电容或者改用电感更大的DC-DC拓扑。
4.2 信号处理:上拉电阻与串联阻尼电阻怎么选
在SD总线模式下,CMD、DAT0-DAT3四条线都需要上拉电阻,阻值10kΩ到100kΩ都可以,我习惯用10kΩ。SD卡规范里,主机侧可以内置上拉,但自制板卡为了稳妥,我会在外围加4颗10kΩ排阻。特别注意DAT3这个引脚,它兼任卡检测功能,卡插入后卡座会通过内部机械结构把它和某个引脚短接或断开,所以DAT3的上拉是必须的,否则无法检测插卡。
在SPI模式下,CS引脚也建议加上拉,保证MCU复位期间CS不被误触发。CLK线建议串联一颗22Ω到33Ω的电阻,放在主机输出端,作用是抑制过冲和振铃。信号线如果走线超过3厘米,串联电阻几乎是必需品。我实测过一条15厘米长的杜邦线连接SPI,在20MHz下波形已经有明显振铃,降到4MHz才稳定。所以高速SPI时钟和高阻抗杜邦线是天然敌人。
4.3 热插拔与ESD防护:卡座选型与电路保护
SD卡最容易被忽视的威胁就是静电。人手拿卡插入卡座,摩擦起电可能瞬间产生几千伏的静电脉冲,直接从金属引脚打进主控IO,把MCU烧了或者把卡内部控制器打挂。我推荐在卡座引脚附近加专门的ESD保护器件,比如TVS二极管阵列,选型时注意结电容要小于5pF,否则高速信号会被衰减。
卡座选型上,优先选带卡检测(CD)和写保护(WP)引脚的类型。CD引脚通常是一个机械开关,卡插入后电平跳变,可以接到MCU的中断引脚,实现"插入即唤醒"。WP引脚则连到卡侧面的写保护滑块,很多卡实际不实现这个开关,但卡座上一般会留,接法要参考具体卡座的规格书,不能想当然。
4.4 一张直接可参考的原理图与PCB笔记
一个标准的SPI模式SD卡电路,包含这几部分:3.3V电源、10uF+0.1uF去耦电容、SPI四根信号线(SCLK/MOSI/MISO/CS)、CS上拉10k、CLK串联22Ω、ESD保护器件。如果用的是MCU板载SD卡载板,还要注意承载电流的CMOS输出能力,普通MCU GPIO推挽输出驱动几mA没问题,但别指望它直接给卡供电,供电一定要走电源网络。
PCB上,信号线尽量等长、不走直角、避免跨越大面积地平面开槽。SCLK是所有信号里速率最高的,它和其他信号线之间最好用地线隔开。如果卡座是抽屉式,注意卡座底下不要走重要的高速线,避免插拔时机械应力反复挤压。差分对在这个场景不存在,只需要关心单端信号的参考地完整。
5. 从零写一个MicroPython驱动:SPI模式实战
理论讲完,现在进入实践。我会用MicroPython在ESP32上从头写一个最精简的SD卡驱动,并在关键位置解释为什么这么写。最终这个驱动能实现底层的扇区读写,并可以被MicroPython的VFS层挂载成文件系统。
5.1 为什么自己写驱动而不是直接调现成库
先说一个很多人会问的问题:MicroPython官方仓库的drivers/sdcard.py已经提供了一个可用的SD卡驱动,为什么还要自己写?我的理由是:官方驱动封装度太高,把命令帧、响应、状态机都藏在了类内部,出了问题你只能干瞪眼。自己写一遍,你才能真正理解SD卡协议的状态流转,调起错来才有方向。而且官方驱动有一些可选配置(比如是否启用CRC校验、是否开启4-bit模式),不读源码你根本不知道这些开关在哪。
更重要的一点,MicroPython的版本碎片化严重,不同平台对SPI、Pin的实现细节不一致。官方sdcard.py在ESP32上正常,换到RP2040上可能因SPI初始化参数差异出问题。自己维护一份驱动,反而能在换平台时快速定位问题。
5.2 初始化流程的代码化:74个时钟与ACMD41的坑
先看初始化部分。我写了SDCard类,构造函数接收SPI对象和CS引脚对象。初始化第一步是预热时钟。不能少,必须在CS为高的情况下发至少74个时钟。ESP32的SPI写入0xFF就是输出时钟,因为SPI协议在无数据时总线为高电平。
from machine import Pin, SPI import time class SDCard: def __init__(self, spi, cs): self.spi = spi self.cs = cs self.cs.init(Pin.OUT, value=1) self.is_hc = False self.sectors = 0 self._init_card() def _wait_ready(self, timeout_ms=500): deadline = time.ticks_ms() + timeout_ms while time.ticks_ms() < deadline: if self.spi.read(1)[0] == 0xFF: return True return False def cmd(self, index, arg=0, crc=0x00): self.cs.value(0) buf = bytearray(6) buf[0] = 0x40 | index buf[1] = (arg >> 24) & 0xFF buf[2] = (arg >> 16) & 0xFF buf[3] = (arg >> 8) & 0xFF buf[4] = arg & 0xFF buf[5] = (crc << 1) | 1 self.spi.write(buf) for _ in range(8): r = self.spi.read(1)[0] if not (r & 0x80): self.cs.value(1) return r self.cs.value(1) return -1注意cmd函数里的cs拉低和拉高时机。发送命令期间CS必须保持为低,等待响应期间也保持为低,直到拿到响应后才拉高。如果你在等待响应过程中提前拉高CS,卡会把后面的数据当作下一个命令的开头,命令序列就会错乱。
接着是初始化函数:
def _init_card(self): # 预热:发送至少74个时钟 self.cs.value(1) self.spi.write(b'\xff' * 10) # 80个时钟 # CMD0: 进入SPI模式 self.cs.value(0) r = self.cmd(0, 0, 0x95) if r != 0x01: raise OSError("CMD0 failed") # CMD8: 电压检查 r = self.cmd(8, 0x1AA, 0x87) if r == 0x01: # 读4字节回显 resp = self.spi.read(4) elif r == -1: raise OSError("CMD8 timeout") # 如果返回其他值,说明卡不支持CMD8,可能是老卡 # ACMD41循环 while True: r = self.cmd(55, 0) if r != 0x01: pass r = self.cmd(41, 0x40000000) if r == 0: break time.sleep_ms(10) # CMD58: 读取OCR,判断SDHC/SDSC r = self.cmd(58, 0) if r == 0: ocr = self.spi.read(4) if ocr[0] & 0x40: self.is_hc = True # CMD9: 读取CSD,计算容量 r = self.cmd(9, 0) if r == 0: csd = self.spi.read(16) c_size = ((csd[7] & 0x3F) << 16) | (csd[8] << 8) | csd[9] self.sectors = (c_size + 1) * 1024这段代码里ACMD41的HCS参数是0x40000000,置位了bit30。如果你用的是老卡(SDSC),HCS位会被忽略,卡以字节寻址模式工作,地址参数就是字节地址。如果是SDHC/SDXC,地址参数就是块号。这个差异在后续的读写函数里必须体现,否则会出现"地址错乱,读到的数据完全不对"的现象。
5.3 扇区读写:CMD17/CMD24与数据token
初始化完成,核心的扇区读写函数就可以写了。读单块:
def read_block(self, block_num, buf, offset=0): if not self.is_hc: block_num *= 512 r = self.cmd(17, block_num) if r != 0: raise OSError("CMD17 failed") # 等待数据起始token 0xFE for _ in range(512): if self.spi.read(1)[0] == 0xFE: break else: raise OSError("Data token not found") self.spi.readinto(buf, offset + 512) # 读512字节到buf self.spi.read(2) # 丢弃CRC写单块:
def write_block(self, block_num, buf): if not self.is_hc: block_num *= 512 r = self.cmd(24, block_num) if r != 0: raise OSError("CMD24 failed") # 发送数据token self.spi.write(b'\xfe') self.spi.write(buf) self.spi.write(b'\x00\x00') # 伪CRC # 读取数据响应 resp = self.spi.read(1)[0] if (resp & 0x0F) != 0x05: raise OSError("Write rejected: 0x%02X" % resp) # 等待不忙 self._wait_ready()这段代码里读写都用了"先判R1响应再等token或数据响应"的顺序。很多人初写时容易漏掉等待token的循环,直接开始读数据,导致读到的是数据和token混在一起。写函数里,发送伪CRC是可以接受的,因为进入SPI模式后大部分卡不检查数据CRC;但如果你在初始化时启用了CRC校验(通过CMD59),这里就必须计算真实CRC。
5.4 对接VFS接口:readblocks/writeblocks/count
SD卡驱动写完后,要能塞进MicroPython的VFS层,还需要实现几个特定名字的方法:readblocks、writeblocks、count、sync。count返回设备总扇区数,readblocks接收块号和缓冲区,writeblocks接收块号和缓冲区。sync用来把缓存刷到设备。
def readblocks(self, block_num, buf): self.read_block(block_num, buf) def writeblocks(self, block_num, buf): self.write_block(block_num, buf) def count(self): return self.sectors def sync(self): # SD卡在SPI模式下没有内部写缓存,sync可以留空 pass这里有一个MicroPython的隐藏约定:块设备回调函数的签名不是固定的,有些平台会传入offset参数,有些不会。为了兼容,readblocks里要容忍可选参数。我的做法是定义成readblocks(self, block_num, buf, offset=0),这样既满足VFS调用,又让我自己的代码能灵活读取buf的任意偏移。
6. 挂载文件系统:FAT32、VFS与os.mount
驱动能读扇区后,文件系统就是最后一步了。MicroPython的VFS层支持FatFS(FAT12/16/32),只需要把块设备对象交给os.mount,之后就可以用open/write/read来操作文件。但要想在出问题时知道怎么排查,还是得懂一点FAT的内部结构。
6.1 FAT32内部结构:MBR、DBR、FAT表与目录项
一块SD卡如果格式化成FAT32,它的数据布局从前往后是这样的:第0扇区是MBR(主引导记录),里面有一部分是分区表,分区表里记录了第一个分区的起始LBA地址。分区自己的第一个扇区是DBR(DOS引导记录),里面是BPB参数,记录了每扇区字节数、每簇扇区数、FAT表个数、根目录簇号等。然后是一张或多张FAT表,接着是根目录区,最后是数据区。
FAT表本质上是一个数组,数组下标是簇号,数组元素的值指向下一个簇号,文件占用的簇链就靠这个数组串起来。目录项则是一个32字节的结构,记录文件名、扩展名、属性、起始簇号、文件大小。对驱动开发来说,你不需要自己去解析这些结构,因为MicroPython的VfsFat已经帮你做完了;但当你遇到"文件系统损坏"时,知道是FAT表还是目录项出问题,能帮你更快决定是先用工具修复还是直接格式化。
6.2 MicroPython的VFS机制与挂载过程
MicroPython的VFS机制很简单:任何实现了readblocks/writeblocks/count/sync的对象,都可以被os.VfsFat识别为块设备,然后挂载到某个路径。这是"面向协议编程"的典型例子,接口正确就能工作,不关心底层是SD卡、eMMC还是SPI NOR Flash。
挂载的代码量非常少:
import os from machine import Pin, SPI from sdcard import SDCard # 初始化SPI,时钟先走慢速 spi = SPI(1, baudrate=400000, polarity=0, phase=0, bits=8, firstbit=SPI.MSB) cs = Pin(5, Pin.OUT) sd = SDCard(spi, cs) vfs = os.VfsFat(sd) os.mount(vfs, '/sd') # 现在可以正常操作文件了 with open('/sd/test.txt', 'w') as f: f.write('hello sd card\n') print(os.listdir('/sd'))这里有个细节:初始化SPI时,baudrate先设低一点(400kHz),因为卡在刚上电时对时钟速率有严格限制。初始化完成后,再把baudrate提上去,比如写到20MHz,这样读写速度才上得来。很多人在初始化阶段就跑满速,卡直接不响应。
提到挂载路径,还有一点值得说明:同一块卡可以连到不同的主机,但卡上的FAT表不会因为主机不同而自动重新格式化。所以你在一台电脑上格式化成exFAT,MicroPython的VfsFat是不认识的。MicroPython只支持FAT12/16/32,跨平台使用前先确认格式,否则会报"filesystem not mounted"之类的问题。
6.3 挂载后的测试与性能评估
挂载成功后,先别急着写业务代码,我建议先跑一段简单的吞吐测试,确认实际读写速度。一个快速测试方法:连续写多个扇区,统计耗时。
import time buf = bytearray(4096) # 读测试:读128个扇区(64KB) start = time.ticks_ms() for i in range(128): sd.readblocks(i, buf) print("read 64KB: %d ms" % time.ticks_diff(time.ticks_ms(), start)) # 写测试 buf = bytearray(b'\x5a' * 4096) start = time.ticks_ms() for i in range(128): sd.writeblocks(i, buf) print("write 64KB: %d ms" % time.ticks_diff(time.ticks_ms(), start))我实测的结果是:ESP32上SPI时钟20MHz,读64KB约31ms(约2.1MB/s),写64KB约41ms(约1.6MB/s)。如果换成4-bit SDIO,读写速度能到10MB/s以上。如果你的数据量不大,SPI模式完全够用;如果需要高吞吐,就该考虑SDIO了。
这个测试还能暴露一个常见问题:如果你的写速度特别慢(比如写着写着卡住),多半是_FAT层的写分配策略和物理写放大造成的,不一定是驱动的问题。你可以用os.statvfs查看文件系统状态,确认剩余空间是否充裕。文件系统接近写满时,FAT碎片增多,写速度会明显下降。
6.4 文件系统常见故障排查
文件系统挂载失败,是最常被报告的问题,但多数原因其实非常简单。我按频率排一下:
- 卡不是FAT格式:用读卡器在电脑上格式化为FAT32(小于32GB的卡)或FAT16(小于2GB的卡)。不要选exFAT或NTFS。
- 扇区大小不匹配:VfsFat默认要求512字节扇区,如果你的驱动返回的块大小不是512,挂载会失败。
- 卡有坏块或文件系统损坏:用电脑上的chkdsk或第三方工具修复一下。
- 初始化时SPI频率过高:卡没完成初始化,后续读取自然失败。把baudrate降到400kHz试一次。
- 供电不稳导致写失败:抓VDD波形,确认写入瞬间电压不掉压。
还有一类隐蔽故障是"插卡后能挂载,但每次开机第一次访问文件就报错"。这个大概率是上电时序问题。SD卡和MCU之间如果存在"MCU先上电、卡后上电"的情况,初始化时序会乱。解决方法是把卡的VDD和MCU的VDD拉在同一组电源域,或者在代码里加一个上电延时,等卡稳定后再初始化。
7. 实验心得与扩展方向:踩坑记录和后续可做的事
这套驱动我在两个项目里反复用过,一次是数据记录板,一次是音频回放设备。数据记录板掉过一次文件系统,原因是断电前没有sync,FAT表没来得及更新。后来我在掉电检测的中断里强制sync,问题再没出现过。音频设备那边踩的坑则是CLK线串联电阻没加,导致高频下波形振铃,读出来的音频文件有爆音。硬件上的小细节,最后都会在数据上以某种方式显现出来。
如果你的项目需要更高性能,下一步值得尝试的方向有三个:一是切换SDIO 4-bit模式,把备用引脚全部用起来,这时候要特别注意DAT1-DAT3的初始化时序,SD模式必须走完整的CMD2/CMD3/CMD7流程;二是用DMA配合传输,把CPU从搬运数据中解放出来;三是做掉电保护,检测到电压跌落时立刻停写并把FAT表落盘,这一步对任何存储类设备都是加分项。
还有一点心得想分享:调试SD卡时,不要一次性写完整驱动再上板。先用一个最简单的脚本,只做初始化、读CSD打印容量,确认物理层通了再继续。物理层不通,后面所有调试都是浪费。反过来,如果物理层通了但文件系统挂不上,优先怀疑卡的格式和分区表,而不是驱动代码。按照这个顺序排查,绝大多数问题都能在两小时内定位。