把一张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模式 |
|---|---|---|
| 1 | DAT3 / CS | CS |
| 2 | CMD | MOSI |
| 3 | VSS1 | GND |
| 4 | VDD | 3.3V |
| 5 | CLK | SCLK |
| 6 | VSS2 | GND |
| 7 | DAT0 | MISO |
| 8 | DAT1 | NC |
| 9 | DAT2 | NC |
在SPI模式下,DAT1和DAT2引脚直接悬空即可,但DAT3必须作为片选信号使用,这点容易踩坑。
2.2 48位命令字与典型命令解析
SD卡协议的命令是48位数据包,结构是:1位起始位(0),1位传输方向(主机到卡为1),6位命令索引,32位命令参数,7位CRC,1位停止位(1)。比如CMD0就是发送字节0x40,参数为0x00000000,CRC为0x95。大部分命令在SPI模式下可以不发送有效CRC,但CMD8和CMD58这类要求CRC,所以驱动里最好统一带上CRC,省得排查问题时犯糊涂。
下面是几个初始化时绕不开的命令:
| 命令 | 名称 | 参数含义 | 作用 |
|---|---|---|---|
| CMD0 | GO_IDLE_STATE | 无 | 复位卡,进入空闲态 |
| CMD8 | SEND_IF_COND | 供电电压+校验模式 | 区分SD卡版本 |
| ACMD41 | SD_SEND_OP_COND | HCS位+供电窗口 | 启动初始化,获取卡状态 |
| CMD58 | READ_OCR | 无 | 读取OCR寄存器 |
| CMD17 | READ_SINGLE_BLOCK | 块地址 | 读取单个512字节块 |
| CMD24 | WRITE_BLOCK | 块地址 | 写入单个512字节块 |
| CMD55 | APP_CMD | 无 | 告诉卡下一条是应用命令 |
| CMD12 | STOP_TRANSMISSION | 无 | 停止多块读/写 |
2.3 初始化时序:从CMD0到ACMD41
SPI模式初始化有一套固定的"拜码头"流程,顺序错了卡就是不理你。我踩过最狠的坑是把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老卡,后面初始化要按老流程走。
- 循环发送CMD55+ACMD41。CMD55是0x77,参数0;ACMD41是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_SIZE(12位)、C_SIZE_MULT(3位)、READ_BL_LEN(4位)。
公式是:总块数 = (C_SIZE + 1) * 2^(C_SIZE_MULT + 2),块大小 = 2^(READ_BL_LEN),容量 = 总块数 * 块大小。我实际拿一张16GB SDHC卡验证过,算出来的结果是15931539456字节,约14.8GiB,和电脑上看到的完全一致。
3. 电路设计里的隐形坑:供电、上拉、写保护与PCB
3.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, baudrate=400000, polarity=0, phase=0, bits=8, firstbit=machine.SPI.MSB) def spi_transfer(data): return spi.write_readinto(data, data) # 部分固件返回发送结果 def send_cmd(cmd, arg, crc=0x00): 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_ms=500): 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永远返回0xFF | CS引脚没有拉低,或者SPI极性相位不对 | 确认CS时序,polarity=0,phase=0 |
| CMD8返回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, baudrate=20000000, polarity=0, phase=0) 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高电平上有明显的毛刺坠入低电平,大概率是电源去耦不足或信号线上串入了噪声。定期跑一跑全卡写读校验,比任何单一测试都更能暴露接触不良和虚焊问题。