ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从结构到驱动:SD卡协议、电路设计与MicroPython实现全解析

从结构到驱动:SD卡协议、电路设计与MicroPython实现全解析 把一张SD卡插进卡座读卡器死活不认弹出请插入磁盘换一张旧卡却一切正常。这个现象让我决定把SD卡从结构原理、卡协议、电路到MicroPython驱动彻底吃透。这篇文章就是那次折腾的完整记录适合FPGA玩家、单片机开发者和MicroPython爱好者。我会从SD卡外壳内的真实结构讲起接着拆解卡协议和初始化时序然后说说电路设计里那些容易翻车的细节再手写一个可用的MicroPython SPI驱动最后把文件系统挂载起来让数据真正变成文件。1. 拆开SD卡的马甲内部结构到底有多复杂1.1 从外壳到主控你拿到的不只是一片NAND很多人第一反应是SD卡等于NAND Flash加上几个引脚其实差的远了。拆开一张普通SD卡的外壳里面基本是一小块PCB板上至少有三样东西主控芯片、NAND Flash颗粒、以及石英晶振或RC振荡器。金属外壳和引脚只是接口真正干活的是主控。主控芯片通常是一个微控制器内置ROM固件它负责两件事一是把主机发出的SD协议命令翻译成NAND Flash的读写时序二是承担坏块管理、错误校验和磨损均衡。换句话说卡出厂时主控里已经烧录了一套翻译官程序你在单片机上发的CMD17、CMD24这类命令到NAND层会变成一套完全不同的地址和页读写指令。我见过有人想绕过主控直接读NAND颗粒比如热搜词里就有FPGA读取SD卡的老问题。这么说吧你从拆机市场上买到的NAND颗粒内部坏块分布是完全随机的而且每个颗粒的页大小、块大小、冗余区布局都可能不同。没有主控的ECC和坏块表你读回来的数据很可能全是花屏。所以别跟主控较劲老老实实走SD卡协议才是最省事的路。1.2 主控芯片的角色协议栈加管家主控承担的坏块管理和U盘里的主控逻辑类似。NAND Flash的寿命依赖于可编程擦除次数频繁写同一块很快会报废。主控内部维护一张逻辑地址到物理地址的映射表M你写逻辑块地址LBA 100它可能映射到物理块5000并且定期搬移冷热数据把擦除次数抹平。这套机制对上层透明你看到的始终是连续的扇区。这也解释了一个常见现象为什么同一批卡有的写入速度稳定有的前面快后面慢。除了闪存等级差异主控的垃圾回收策略和缓存策略占很大关系。你在调试SD卡驱动时如果发现某次写入特别慢先别怀疑代码很可能是主控正在做后台整理。主控还负责电压转换。卡内部电压通常是1.8V或2.8V而外部接口在3.3V模式下工作。卡通过内部LDO把供电电压降下来这也是为什么SD卡对电源纹波比较敏感。1.3 NAND Flash与逻辑块地址LBA主机视角里SD卡是一串连续的512字节扇区这个空间被称为逻辑块地址空间。文件系统只需要按LBA顺序读写完全不需要关心NAND物理页和块的关系。SD卡在出厂时主控已经把所有块的原始信息和映射关系建立好并且预留了一部分备份块用于替换坏块所以标称容量和实际可用容量经常有偏差。高容量SDXC卡默认扇区大小也可以是4096字节但大多数卡仍然通过仿真提供512字节扇区以保证与老设备兼容。写驱动时我建议固定按512字节扇区操作除非你明确知道卡支持4Kn模式。这样既能兼容老卡也方便和FAT文件系统对接。关键点一句话SD卡是NAND Flash 专用主控的复合设备你操作的是主控提供的逻辑接口不是裸闪存。2. 卡协议不是纸面规范命令、响应和初始化流程2.1 三种工作模式怎么选SD卡支持三种接口模式分别是SD总线模式默认、SPI模式和UHS模式。SD总线模式下数据线可以配置为1位或4位并行理论速率高得多但需要控制严格的时钟和数据同步。SPI模式下卡被当成一个SPI从设备最高速度受限于SPI时钟但接线简单任意带SPI外设的单片机都能驱动。选模式时要想清楚场景。如果你做的是高速读写设备比如FPGA直接采集视频写入SD卡那必须用SD总线4位模式甚至UHS-I否则带宽不够。如果是做日志记录、配置存储这种非实时应用SPI模式完全够用还省引脚。热词里提到支持USB Host的MicroPython固件这种固件通常会用SDMMC外设走SD总线模式速度更快但移植复杂度也高。引脚对应关系值得记一下下面是SD卡座默认的引脚定义引脚SD/USD总线模式SPI模式1DAT3 / CSCS2CMDMOSI3VSS1GND4VDD3.3V5CLKSCLK6VSS2GND7DAT0MISO8DAT1NC9DAT2NC在SPI模式下DAT1和DAT2引脚直接悬空即可但DAT3必须作为片选信号使用这点容易踩坑。2.2 48位命令字与典型命令解析SD卡协议的命令是48位数据包结构是1位起始位01位传输方向主机到卡为16位命令索引32位命令参数7位CRC1位停止位1。比如CMD0就是发送字节0x40参数为0x00000000CRC为0x95。大部分命令在SPI模式下可以不发送有效CRC但CMD8和CMD58这类要求CRC所以驱动里最好统一带上CRC省得排查问题时犯糊涂。下面是几个初始化时绕不开的命令命令名称参数含义作用CMD0GO_IDLE_STATE无复位卡进入空闲态CMD8SEND_IF_COND供电电压校验模式区分SD卡版本ACMD41SD_SEND_OP_CONDHCS位供电窗口启动初始化获取卡状态CMD58READ_OCR无读取OCR寄存器CMD17READ_SINGLE_BLOCK块地址读取单个512字节块CMD24WRITE_BLOCK块地址写入单个512字节块CMD55APP_CMD无告诉卡下一条是应用命令CMD12STOP_TRANSMISSION无停止多块读/写2.3 初始化时序从CMD0到ACMD41SPI模式初始化有一套固定的拜码头流程顺序错了卡就是不理你。我踩过最狠的坑是把ACMD41误发成了CMD41结果卡一直返回0x00就绪信号查了一下午才发现CMD55前缀没发。正确流程是给SPI时钟引脚一个至少74个时钟周期的低电平脉冲这是SD卡协议的上电预热要求让卡内部电压稳定同时让卡从任何状态回到初始状态。拉低CS发送CMD0(0x40, 0x00000000)等待响应0x01。注意在SPI模式下CMD0的响应是R1类型只有低7位有效0x01表示进入空闲状态。发送CMD8(0x48, 0x000001AA)参数0x1AA表示请求2.7V到3.6V供电校验字节0xAA。如果卡响应0x01且后跟0x1AA说明是SD Spec 2.0的卡可以支持高容量。如果CMD8非法说明是1.x老卡后面初始化要按老流程走。循环发送CMD55ACMD41。CMD55是0x77参数0ACMD41是0x69参数0x40000000。0x40000000表示HCS位告诉卡我想支持高容量SDHC/SDXC卡。每发送一次读响应如果不是0x00就延时再试直到卡返回0x00表示初始化完成。发送CMD58(0x7A, 0)读取OCR寄存器判断卡是否支持高容量模式。OCR的第30位如果为1表示SDHC/SDXC卡。发送CMD16(0x50, 0x00000200)设置块长度为512字节。虽然很多卡默认就是512字节但显式设置一次更稳妥避免某些卡默认块长是1024或2048。初始化完成后SPI时钟频率可以从400kHz提高到20MHz或更高。注意在时钟升高后建议重新发送CMD16确认块长度有些卡在速度切换后会假装失忆。2.4 用CSD算容量的实战方法CSD寄存器里存着卡的容量、最大传输速度、读块长度等信息。发送CMD9(0x49, 参数为0)可以读取CSD的16字节数据。CSD格式比较复杂但计算容量只需关注两个字段C_SIZE12位、C_SIZE_MULT3位、READ_BL_LEN4位。公式是总块数 (C_SIZE 1) * 2^(C_SIZE_MULT 2)块大小 2^(READ_BL_LEN)容量 总块数 * 块大小。我实际拿一张16GB SDHC卡验证过算出来的结果是15931539456字节约14.8GiB和电脑上看到的完全一致。3. 电路设计里的隐形坑供电、上拉、写保护与PCB3.1 供电设计LDO还是DC-DC很多开发板直接把SD卡电源接到3.3V LDO输出系统空闲时没什么问题但一旦开始连续写入卡瞬间电流可能冲到100mA到200mA如果LDO的压差大、输入电压波动输出就会跌落导致卡识别失败或者写数据错误。我的建议是给SD卡单独一个3.3V电源网络并且靠近卡座放置去耦电容至少一个4.7uF陶瓷电容外加一个0.1uF高频电容。如果系统主供电是电池或DC-DC输出SD卡电源最好再经过一级LDO把纹波压到50mV以内。热词里反复出现buck电路、LDO电路这里补充一句不要在SD卡电源上直接并联一个buck电感旁边的敏感信号线开关噪声很容易串进CLK和DATA线造成偶发读写错误。如果必须用DC-DC给SD卡供电那么请选择低纹波模式输出电容加大到22uF并确保开关频率不在SD卡时钟的谐波频点上。否则高速模式下的误码率会非常感人。3.2 信号完整性上拉电阻、电平转换与走线SD卡的数据线、命令线内部有弱上拉但驱动能力不足主机侧通常需要外接上拉电阻。SPI模式下CS、MOSI、SCLK这三个信号由主机推挽驱动可以不用上拉但MISO是卡推挽输出也不需要。真正的麻烦是SD总线模式CMD、DAT0-DAT3都有开漏特性必须接上拉电阻典型值10kΩ到47kΩ。电平转换是另一个高频翻车点。如果你的MCU是5V供电SD卡却是3.3V逻辑直接用5V推一个3.3V的卡大概率能跑但长期使用会损坏卡。正确做法是加电平转换芯片或者用NMOS双向电平转换电路。我个人偏爱用TXS0108E或74LVC4245这类专用芯片原理简单调试省心。PCB走线要注意:CLK线尽量远离电源电感和MOS管开关节点在卡座附近、信号线上加10pF到22pF的对地电容可以滤掉一些高频毛刺但别加太大否则上升沿变缓高速模式直接失败。3.3 卡座与写保护没锁却提示写保护的真相SD卡没锁但是写保护是个经典问题热词里有我自己也遇到过。排查顺序建议是先看卡座上的写保护检测开关是否弹片接触不良尤其是那种有机械滑块的卡座滑块没到位或者弹片氧化会让主机误判卡被锁。再看主控软件部分读卡器驱动会在卡插入时读取写保护引脚状态如果引脚悬空默认可能判为写保护。用万用表量卡座写保护脚通常插卡前是高电平插卡后被卡体边缘的塑料挡块压低如果插卡后电平没有变化就是卡座机械结构问题。解决办法是拔插几次或者往弹片里喷一点触点清洁剂严重的话换卡座。插卡检测脚CD/DAT3也需要防抖处理。机械卡座在插拔瞬间会产生多次抖动单片机上如果直接作为中断触发轻则误判几次重则导致文件系统挂载失败。软件上做一个20ms到50ms的消抖延时硬件上可以在检测脚到地加一个0.1uF电容都比裸奔稳定。4. MicroPython驱动实现把协议翻译成代码4.1 为什么选择SPI模式在MicroPython环境下驱动SD卡最常见的方案是SPI模式因为大部分开发板没有现成的SDMMC外设驱动固件里往往直接暴露了machine.SPI。SPI模式虽然带宽低一些但代码简单逻辑分析仪也能直接抓到波形方便调试。如果你用支持SDMMC外设的板子比如某些ESP32或STM32F4固件可以考虑SDMMC模式MicroPython标准库并没有直接提供需要自己封装寄存器或者使用社区维护的sdcard驱动。我建议先跑通SPI验证卡和电路没问题再追求速度。4.2 驱动代码的骨架下面是我在ESP32上调试通过的SPI模式MicroPython驱动骨架只包含最核心的初始化和单块读写逻辑。你也可以把它封装成类挂在os挂载接口下。import machine import time # 初始化引脚和SPI时钟先降到400kHz cs machine.Pin(5, machine.Pin.OUT) cs.value(1) spi machine.SPI(1, baudrate400000, polarity0, phase0, bits8, firstbitmachine.SPI.MSB) def spi_transfer(data): return spi.write_readinto(data, data) # 部分固件返回发送结果 def send_cmd(cmd, arg, crc0x00): cs.value(0) time.sleep_us(10) buf bytearray(6) buf[0] 0x40 | (cmd 0x3f) buf[1] (arg 24) 0xFF buf[2] (arg 16) 0xFF buf[3] (arg 8) 0xFF buf[4] arg 0xFF buf[5] (crc 1) | 1 resp 0xFF for b in buf: spi.write(bytes([b])) # 读取响应最多等8个字节 for _ in range(8): resp spi.read(1)[0] if not (resp 0x80): break cs.value(1) time.sleep_us(10) return resp def wait_ready(timeout_ms500): start time.ticks_ms() while time.ticks_diff(time.ticks_ms(), start) timeout_ms: r spi.read(1)[0] if r 0xFF: return True return False def sd_init(): # 上电前先给至少74个时钟周期让卡进入SPI模式 cs.value(1) for _ in range(10): spi.write(bytes([0xFF])) # 进入空闲态 r send_cmd(0, 0x00000000, 0x95) if r ! 0x01: raise RuntimeError(CMD0 failed: 0x%02X % r) # 检查SD Spec版本 r send_cmd(8, 0x000001AA, 0x87) if r 0x01: # 读取4字节响应 resp spi.read(4) if resp[2] 0xAA: print(SD v2 detected) else: print(SD v1 or MMC, trying legacy init) # 循环ACMD41 for _ in range(100): send_cmd(55, 0) r send_cmd(41, 0x40000000) if r 0x00: break time.sleep_ms(10) else: raise RuntimeError(ACMD41 timeout) # 读取OCR r send_cmd(58, 0) ocr spi.read(4) print(OCR:, ocr[0], ocr[1], ocr[2], ocr[3]) # 设置块长度512 send_cmd(16, 512) return True这段代码的核心思路是每个命令先拉低CS发送6字节命令包然后读取卡返回的R1响应。R1响应最高位为0表示有效响应如果一直读到0xFF说明命令没被卡认收要从时序上排查。4.3 调通驱动的关键细节和失败复盘我调这个驱动的过程中遇到过几个非常典型的失败现象列出来供你对照现象可能原因解决办法CMD0永远返回0xFFCS引脚没有拉低或者SPI极性相位不对确认CS时序polarity0phase0CMD8返回0x00而不是0x01卡不支持SPI模式或供电电压不正常换卡测试检查VDD电压和去耦ACMD41循环超时没发CMD55前缀或者卡启动慢每次ACMD41前必须发CMD55重试间隔加长读块时一直收到0xFE前同步字节地址错误或者上电识别状态没完成确认块地址是扇区号不是字节地址高SPI时钟时写数据偶发错电源纹波大或走线过长降低SPI频率加强去耦缩短走线还有一个细节SPI模式下所有命令和响应都依赖字节读取很多新手忽略了一个哑时钟问题卡在发送R1响应后还没有就绪时会连续输出0x00或0xFF主机必须持续发送SCLK时钟才能驱动数据线。所以在每次命令后至少再读8个字节把卡内部状态机的时钟需求满足掉。当SPI初始化通过后读单块CMD17非常简单发送CMD17参数是LBA块号比如想读第100块参数就是100。然后持续读字节直到读到0xFE后面就是512字节数据加两个字节CRC。写单块CMD24则要先发CMD24然后发一个字节0xFE作为起始字节再发512字节数据加两个字节CRC最后读卡返回的数据响应字节低5位中bit0表示接受。5. 文件系统挂载让数据从字节变成文件5.1 FAT文件系统与SD卡分区SD卡出厂时一般预装了一个FAT分区大多数情况下是FAT32。FAT文件系统的核心价值在于兼容性Windows、Linux、相机、手机都能识别。热词里提到fatfs sd卡FatFs这个开源库在嵌入式世界非常流行但它需要你自己提供读/写扇区的底层接口正好可以把我上一节的SD卡驱动接进去。不过在MicroPython里不需要自己移植FatFs固件自带VFS层你只需要提供块设备接口再用os.mount挂载。块设备接口需要实现以下方法readblocks、writeblocks、ioctl。readblocks接收块号和数据缓冲区把SD卡的512字节块读出来填充缓冲区writeblocks类似ioctl处理块大小、设备容量等查询。5.2 MicroPython挂载代码与常见操作假设你已经把SD卡驱动封装为sdcard模块并且SDCard类实现了上述块设备接口挂载就非常简单import machine import os from sdcard import SDCard spi machine.SPI(1, baudrate20000000, polarity0, phase0) cs machine.Pin(5, machine.Pin.OUT) sd SDCard(spi, cs) os.mount(sd, /sd) print(os.listdir(/sd))挂载后就能用标准文件操作读写文件with open(/sd/hello.txt, w) as f: f.write(hello from SD card\n) with open(/sd/hello.txt, r) as f: print(f.read())需要注意MicroPython的VFS FAT实现默认不支持大容量exFAT分区。如果你拿了一张64GB以上、已经被格式化为exFAT的卡直接挂载大概率报错。热词里怎么更改sd卡的格式指的就是这种情况建议用SD卡协会的官方格式化工具重新格式化为FAT32或者使用支持exFAT的固件版本。FAT32理论上能支持到2TB只是Windows的格式化工具对大容量只给exFAT选项这时可以用专门的SD Card Formatter强制FAT32。5.3 格式化、掉电保护和整卡镜像格式化SD卡看起来简单但工具选择很容易踩坑。Windows自带格式化工具某些时候会把FAT32的簇大小弄得很奇怪导致Linux设备识别不了。我习惯用SD Card Formatter它支持分区大小对齐、全覆盖擦除和FAT32快速格式化兼容性最稳。掉电保护是文件系统挂载中最容易忽视的问题。写FAT文件时文件系统会同时修改目录项、文件分配表和文件数据三个区域如果写入过程中突然掉电轻则文件系统损坏重则目录结构错乱。我的思路是重要数据先写到临时文件写完同步flush后再改名覆盖目标文件这样每次写入只有一个菜单位置需要更新掉电最多丢临时文件原有文件还能保住。MicroPython里文件对象有flush()方法挂载卸载时也要调os.sync()或先os.umount再断电。热词里还有64g SD卡系统镜像img文件下载嵌入式场景里经常需要把整个SD卡做成镜像备份。在Linux下直接用dd命令读整个/dev/sdX在Windows下可以用Win32DiskImager备份之后可以完全还原包括分区表、FAT表、文件数据。这个操作不依赖文件系统状态哪怕SD卡文件系统已经损坏只要底层块还能读就能救回大部分数据。写在最后的经验和建议这套从内部结构到文件系统挂载的跑通流程我前前后后折腾了三个晚上。最开始的误区是把SD卡当裸Flash对待总想直接跨过协议层去读NAND后来发现主控才是卡的核心协议才是正确的沟通语言。如果你也在做类似项目我的建议是先在逻辑分析仪上确认初始化命令都得到了预期响应再上文件系统先用小容量卡调试别拿珍贵的1TB卡做实验写驱动时把SPI时钟降到400kHz跑通全流程再逐步提高速度每提高一档就做一遍长时间连续读写压力测试。最后分享一个小技巧怀疑电路问题时用示波器同时抓CLK和MISO波形看卡返回的数据位是否干净。如果MISO高电平上有明显的毛刺坠入低电平大概率是电源去耦不足或信号线上串入了噪声。定期跑一跑全卡写读校验比任何单一测试都更能暴露接触不良和虚焊问题。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进