十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SD卡协议深度拆解:从内部结构到MicroPython驱动实现

SD卡协议深度拆解:从内部结构到MicroPython驱动实现 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而不是NORSD卡用的存储介质是NAND Flash不是NOR Flash。NAND的特点是写入前必须先擦除而且按块Block擦除按页Page写入。一个Block通常是64页到256页一页通常是2KB到16KB具体看闪存制程和型号。这种结构决定了它的成本低、容量大但不适合随机字节写入——你没法像改内存那样改一个字节只能整页读出来、改掉、再整页写回去。NAND还有一个更头疼的毛病颗粒本身会坏。出厂时就有坏块使用过程中坏块还会不断增加。这在消费级产品里完全不可接受所以SD卡内部必须有一个翻译层来隐藏这些缺陷。这个翻译层就是FTLFlash 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模式1CD/DAT3数据线3/卡检测CS片选2CMD命令线DI主机-卡数据3VSS1地VSS1地4VDD供电VDD供电5CLK时钟SCLK时钟6VSS2地VSS2地7DAT0数据线0DO卡-主机数据8DAT1数据线1RSV保留9DAT2数据线2RSV保留这里有个细节值得注意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、R7SD卡规范里定义了多种响应格式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, arg0, crc0): # 发送命令帧 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参数0CRC 0x95复位卡让它进入空闲态。这是唯一一个在SPI模式下必须附带正确CRC的命令因为此时卡可能还在SD模式监听。CMD8参数0x1AACRC 0x87检查卡是否支持2.7V-3.6V工作电压。如果卡支持会回显0x1AA不支持则返回非法命令错误。CMD55 ACMD41参数0x40000000CMD55本身不做实际操作它的作用是告诉卡下一条命令是应用特定命令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然后发送一个数据起始token0xFE紧接着发送512字节数据和2字节CRC。主机必须等token出现才能开始接收数据否则会收到一堆无用的0xFF。写单块的过程稍微复杂一点。主机发送CMD24后卡返回R1响应然后主机要发送一个数据起始token0xFE、512字节数据和2字节CRC。卡收到后会给一个数据响应字节0x05表示接受0x0B表示CRC错误0x0D表示写错误。接着卡会拉低数据线表示忙主机要一直发送时钟直到卡释放数据线读到0xFF。这里有个非常重要的细节在SPI模式下卡没有独立的忙引脚忙状态是通过数据线DO持续输出低电平来表示的。所以驱动里要有等待不忙的函数def wait_not_busy(self, timeout_ms500): 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时序可以手工控制排查问题更直观。我自己做数据记录板时为了稳定长期跑的就是SPI20MHz实测读速度约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电源、10uF0.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, value1) self.is_hc False self.sectors 0 self._init_card() def _wait_ready(self, timeout_ms500): 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, arg0, crc0x00): 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。如果你用的是老卡SDSCHCS位会被忽略卡以字节寻址模式工作地址参数就是字节地址。如果是SDHC/SDXC地址参数就是块号。这个差异在后续的读写函数里必须体现否则会出现地址错乱读到的数据完全不对的现象。5.3 扇区读写CMD17/CMD24与数据token初始化完成核心的扇区读写函数就可以写了。读单块def read_block(self, block_num, buf, offset0): 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/countSD卡驱动写完后要能塞进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, offset0)这样既满足VFS调用又让我自己的代码能灵活读取buf的任意偏移。6. 挂载文件系统FAT32、VFS与os.mount驱动能读扇区后文件系统就是最后一步了。MicroPython的VFS层支持FatFSFAT12/16/32只需要把块设备对象交给os.mount之后就可以用open/write/read来操作文件。但要想在出问题时知道怎么排查还是得懂一点FAT的内部结构。6.1 FAT32内部结构MBR、DBR、FAT表与目录项一块SD卡如果格式化成FAT32它的数据布局从前往后是这样的第0扇区是MBR主引导记录里面有一部分是分区表分区表里记录了第一个分区的起始LBA地址。分区自己的第一个扇区是DBRDOS引导记录里面是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, baudrate400000, polarity0, phase0, bits8, firstbitSPI.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表不会因为主机不同而自动重新格式化。所以你在一台电脑上格式化成exFATMicroPython的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. 实验心得与扩展方向踩坑记录和后续可做的事这套驱动我在两个项目里反复用过一次是数据记录板一次是音频回放设备。数据记录板掉过一次文件系统原因是断电前没有syncFAT表没来得及更新。后来我在掉电检测的中断里强制sync问题再没出现过。音频设备那边踩的坑则是CLK线串联电阻没加导致高频下波形振铃读出来的音频文件有爆音。硬件上的小细节最后都会在数据上以某种方式显现出来。如果你的项目需要更高性能下一步值得尝试的方向有三个一是切换SDIO 4-bit模式把备用引脚全部用起来这时候要特别注意DAT1-DAT3的初始化时序SD模式必须走完整的CMD2/CMD3/CMD7流程二是用DMA配合传输把CPU从搬运数据中解放出来三是做掉电保护检测到电压跌落时立刻停写并把FAT表落盘这一步对任何存储类设备都是加分项。还有一点心得想分享调试SD卡时不要一次性写完整驱动再上板。先用一个最简单的脚本只做初始化、读CSD打印容量确认物理层通了再继续。物理层不通后面所有调试都是浪费。反过来如果物理层通了但文件系统挂不上优先怀疑卡的格式和分区表而不是驱动代码。按照这个顺序排查绝大多数问题都能在两小时内定位。
返回列表