RP2040 Pico 固件,将东芝 MK4001MTD 0.85英寸 SDIO 微型硬盘桥接为 USB 大容量存储设备。

MK4001MTD 是一款 4 GB 微型硬盘,最初用于诺基亚 N91 音乐手机和一些其他设备,如 MP3 播放器或 U 盘,当时闪存仍然相当昂贵。
你可能见过一些介绍声称这款硬盘使用 MMC 协议,但实际上这是不正确的。我对此进行了一段时间的研究:我尝试构建一个 8 位 MMCplus 读卡器,并测试了不同的 SD/MMC 读卡器,但没有成功。最后,我买了一部诺基亚 N91 来捕获逻辑信号,并确认它实际使用的协议。
这是我尝试将其与我的 8 位 MMCPlus 读卡器板一起使用时的照片,结果发现它不是 MMC :(

所以我最终购买了 N91 来收集信号:

与标准的 ATA/CF 微型硬盘不同,它使用 SDIO 接口,通过 CMD52/CMD53 隧道传输 ATA 命令。没有现有的驱动程序支持此协议,因此此固件从头实现了完整的协议栈。
这让我感到惊讶,因为有一个叫 CE-ATA 的 SDIO 转 ATA 标准。但如果你仔细查看发布时间线,CE-ATA 出现在这款硬盘之后。因此,这款硬盘完全依赖 SDIO 命令,而 CE-ATA 不可用。CE-ATA 有两条新命令 CMD60/CMD61,并使用 CMD12/39,但从追踪信号可以看出,它并没有使用这些命令。
第二个硬件要点是,另一个流传的错误信息——声称它是 8 位 MMCPlus 卡——不仅不真实,而且引脚排列也不符合 MMC 标准。你可以在诺基亚 N91 维修手册中找到一些关于引脚排列的文档:虽然引脚编号遵循 MMCPlus 标准,但引脚映射不遵循。如果你自己接线,这是一个重要的细节:它使用相同的 MMC 连接器,但引脚映射不同,更多信息在硬件部分。
最后,请注意这是与 Claude/OpenClaw 共同开发的。我手动收集了逻辑信号,并为 OpenClaw 搭建了一个闭环测试站,以便迭代开发——分析信号并实现功能。文档大部分由 Claude 编写;我还会在文档中添加我的注释。我自己也阅读并仔细检查了文档,它应该是可靠且易于理解的。
关于 N91 信号分析的详细信息,请参阅 /docs/N91_TRACE_ANALYSIS.md,我还将 N91 维修手册和原始逻辑信号放在那里。
更多内容请参见博客文章:https://www.willwhang.dev/Reading-MK4001MTD/
实际运行视频:https://youtu.be/GC4xil3_Bbc
功能完整的 USB 大容量存储,具有 PIO 加速读写和空闲电源管理。
| 指标 | 数值 |
|---|---|
| 读取速度 | ~985 kB/s(受 USB 全速限制) |
| 写入速度 | ~920 kB/s(受 USB 全速限制,支持写入缓存) |
| 原始SDIO侧速度 | ~2.35 MB/s 读取 / ~2.15 MB/s 写入(受硬盘限制) |
| 容量 | 3.75 GB(7,862,400 扇区) |
| 文件系统 | FAT32 已验证(挂载/卸载/fsck 干净) |
| 数据完整性 | 写入+回读已验证;所有 4 条 DAT 线路上均有每块 CRC16 |
| 空闲待机 | 空闲 5 秒或 USB 挂起 → STANDBY IMMEDIATE + 电源门控 |
USB 主机 ←→ USB MSC (TinyUSB) ←→ ATA 层 ←→ SDIO 层 (PIO) ←→ MK4001MTD
固件包含四层:
USB MSC (msc_device.c) — TinyUSB 大容量存储类。将 SCSI READ(10)/WRITE(10) 转换为 ATA 扇区操作。32 KB EP 缓冲区,每 USB 传输可批处理多达 64 个扇区。硬盘 I/O 与 USB 双向重叠,类似于具有缓存磁盘的真实 ATA-USB 桥接器:顺序读取预取在将前一块流式传输到主机的同时获取下一块,写入在 USB 接收下一块的同时进行暂存和刷新。设备通告其写入缓存(缓存模式页面,WCE=1 — 主机报告“写入缓存:已启用”并在 fsync/卸载/挂起时发出 SYNCHRONIZE CACHE,固件会执行此命令)。后台刷新失败会在下一次 WRITE 或 SYNCHRONIZE CACHE 时显示为 MEDIUM ERROR;对已知坏扇区的写入采用严格的同步路径。
ATA-over-SDIO (ata_sdio.c) — 通过将 ATA 寄存器写入映射到 SDIO 功能 1 地址空间的 CMD52 来实现 ATA 命令(IDENTIFY、READ SECTORS、WRITE SECTORS),并通过 CMD53 传输扇区数据。在 CMD、数据和 ATA 级别采用 3 层重试逻辑。
PIO SDIO (sdio_pio.c, sdio.pio) — 使用 RP2040 的 PIO 外设(4 位总线,10 MHz,每个位 4 个 PIO 周期,绕过输入同步器)实现硬件加速的 SDIO。三个 PIO 程序通过动态程序交换共享一个状态机:
引脚/电源 (sdio_hw.c) — GPIO 初始化和硬盘电源控制。所有 SDIO 通信均使用 PIO。
人工备注:有趣的是,Claude 非常不情愿在 PIO 中实现 SDIO,许多开发周期都在 PIO 和位碰撞之间来回浪费。
MK4001MTD 将自身呈现为带有一个 I/O 功能的 SDIO 卡。标准 SDIO 卡初始化(CMD5/CMD3/CMD7)设置总线,然后通过 SDIO 命令访问 ATA 寄存器:
寄存器访问 (CMD52): 每个 ATA 寄存器映射到一个功能 1 地址:
| 地址 | 寄存器 | 用途 |
|---|---|---|
| 0x00 | DATA | CMD53 扇区数据目标地址 |
| 0x01 | ERR/FEAT | 错误(读取)/ 特性(写入) |
| 0x02 | SECCOUNT | 扇区计数 |
| 0x03 | LBA_LO | LBA 位 0-7 |
| 0x04 | LBA_MID | LBA 位 8-15 |
| 0x05 | LBA_HI | LBA 位 16-23 |
| 0x06 | DEV/HEAD | 设备/磁头 + LBA 位 24-27 |
| 0x07 | CMD/STATUS | 命令(写入)/ 状态(读取) |
数据传输 (CMD53): 扇区数据通过以块模式发出针对 DATA 寄存器(地址 0x00)的 CMD53 传输。对于多扇区读取,单个 block_count=N 的 CMD53 在一个 SDIO 多块事务中传输 N × 512 字节。
中断信号: 硬盘通过置位 SDIO 中断(CCCR 寄存器 0x05 中的 INT_PENDING 位 1)来指示扇区准备就绪。读取 ATA STATUS 寄存器会清除中断。
对于 16 扇区读取:
1. 通过 PIO CMD52 写入 ATA 寄存器:
SECCOUNT=16, LBA_LO/MID/HI, DEV/HEAD=0xE0, CMD=0x20
2. 通过 CMD52 轮询 STATUS 直到 DRQ(位 3)置位
3. 交换 PIO 到 DAT 读取程序
4. 发送 CMD53:block_mode=1, fn=1, addr=0x0000, block_count=16
5. PIO DAT 读取:对于 16 个块中的每一个:
a. 等待起始位(所有 DAT 线为低)
b. DMA 从 PIO RX FIFO 读取 1024 个半字节(512 字节)到缓冲区
c. 等待 SM 完成时钟 CRC+结束半字节(轮询 SM PC)
d. 就地重新打包半字节 → 字节
6. 交换 PIO 回到 CMD 程序
对于 16 扇区写入:
1. 通过 PIO CMD52 写入 ATA 寄存器:
SECCOUNT=16, LBA, DEV/HEAD=0xE0, CMD=0x30
2. 通过 CMD52 轮询 STATUS 直到 DRQ(位 3)置位
(STATUS 0xD8 = BSY+DRQ 视为 DRQ 就绪,根据 N91 信号)
3. 交换 PIO 到 DAT 写入程序
4. 发送 CMD53:block_mode=1, fn=1, addr=0x0000, block_count=16
5. PIO DAT 写入:对于 16 个块中的每一个:
a. 预计算每条 DAT 线的 CRC16-CCITT(4 个独立 CRC)
b. 构建半字节流:start(0x0) + data(1024 半字节) + CRC(16) + end(0xF)
c. DMA 半字节流到 PIO TX FIFO
d. PIO 时钟输出所有半字节,然后:
- 将 DAT 切换为输入
- 时钟 16 个周期以从卡接收 CRC 状态
- 轮询 DAT0 直到卡释放忙状态
- 触发 IRQ 0 以指示块完成
6. 交换 PIO 回到 CMD 程序
RP2040 PIO 每个块有 32 个指令槽。我们的三个程序总共需要 55 条指令,因此它们无法共存。相反,使用 PIO0 上的单个 SM0,通过直接写入 PIO 指令内存来交换程序:
static void load_program_raw(const pio_program_t *program) {
for (uint i = 0; i < program->length; i++)
pio->instr_mem[FIXED_OFFSET + i] = program->instructions[i];
}
这绕过了 SDK 的 pio_add_program/pio_remove_program 分配器。程序交换大约需要 1 µs。每次交换后都需要执行特定于程序的重新初始化,设置引脚映射、移位方向和时钟分频器。
对诺基亚 N91 逻辑信号的分析显示了激进的电源管理:
固件使用可配置的空闲超时复制了此行为:
#define IDLE_STANDBY_MS 5000 // 在 main.c 中
两种路径触发硬盘电源门控:
两种路径都发送 ATA STANDBY IMMEDIATE (0xE0) 以刷新写入缓存并停放磁头,然后通过 GP9 切断电源。
唤醒序列(由首次 READ/WRITE 触发):
当多扇区传输遇到坏扇区时:
STATUS/ERROR 位,而不是将失败归结为通用的 DRQ timeoutMEDIUM ERROR(读取:03/11/00,写入:03/0C/00)第 6 点并非理论:该驱动器在 LBA 1952 处存在一个长期不可读的扇区(READ: ST=0x51 ERR ERR=0x40 UNC)。一旦桥接器允许实际写入到达该扇区,驱动器就会重写该扇区,并且此后读取一直正常:
[ATA] FAST-RD: ST=0x51 ERR ERR=0x40 UNC LBA=1952
[MSC] BAD SECTOR read LBA=1952
[MSC] Bad sector LBA=1952 repaired by write
arm-none-eabi-gcc)SDK 版本是锁定的:如果设置了 PICO_SDK_PATH(环境变量或 CMake 变量),则使用该路径,并检查其版本是否与锁定的版本匹配——如果不匹配,配置失败并显示说明(可通过 -DMK4001_ALLOW_SDK_MISMATCH=ON 覆盖)。如果完全没有 PICO_SDK_PATH,则会在配置时自动从 GitHub 获取锁定的 SDK 版本,因此简单的 git clone && cmake && make 是完全可重现的。
该固件需要一个修补过的 TinyUSB MSC 类驱动程序(在读取/写入错误时保留应用程序感知数据 + 一个带有 WCE=1 的缓存模式页面)。该文件已在此仓库中供应商化,位于 lib/tinyusb_patched/msc_device.c — 构建会自动编译该文件而不是 SDK 中的副本,因此永远不需要修改 SDK。与上游 TinyUSB(0.18.0,随 pico-sdk 2.2.0 捆绑)的差异位于 lib/tinyusb_patched/;SDK 锁定正是因为这个供应商化文件必须与 SDK 的 TinyUSB 版本保持一致。
cd /home/pi/mk4001_bridge/build
cmake ..
make -j4
sudo openocd -f interface/cmsis-dap.cfg -f target/rp2040.cfg \
-c "adapter speed 1000" -c "init" -c "reset halt" -c "sleep 200" \
-c "program /home/pi/mk4001_bridge/build/mk4001_bridge.elf verify" \
-c "reset run" -c "exit"