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 加速读写和空闲电源管理。
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 地址:
数据传输 (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"
注意: 该特定 Pico 单元上的 GP0 和 GP1 已损坏。所有 SDIO 引脚分配均偏移了 +2。
人工备注:Claude 在这里出错了,因为它没有意识到 GP0 和 GP1 在其构建配置中用于 UART 终端。它反复忘记这一点,以至于我干脆将 SDIO GPIO 移出了那个 UART。
HDD_PWR 并非必需。你不必通过掉电重启来使用硬盘;它更像是开发便利功能,用于在大量内容硬编码时重置硬盘。也就是说,如果你想要省电,可以使用那个信号,但硬盘在热复位下也可以正常工作。
你将通过 UART 看到调试消息。它们不通过 USB-CDC,因为对于 Claude 来说,设置一个独立的 UART 转 USB 日志链路更容易,该链路在早期开发期间不会断开或变得不稳定。
UART 日志还每 30 秒报告一次驱动器温度(当驱动器活动时):[TEMP] drive temperature: 29 C。该传感器是通过逆向工程东芝供应商命令 0xC2 发现的——N91 在每个驱动器会话开始时读取它,以强制执行其 HDD 工作温度限制。详细信息请参见 docs/N91_TRACE_ANALYSIS.md 第 4 节。
以下是日志示例:
========================================
MK4001MTD USB Bridge v0.11
SDIO-ATA → USB Mass Storage (PIO)
========================================
[MAIN] Pre-delay 5000ms...
[PIO] Init OK: clkdiv=3.12 (~10.0 MHz), CMD@0
[MAIN] Power cycling HDD...
[SDIO] HDD power OFF
[SDIO] HDD power ON
[MAIN] SDIO init (PIO)...
[SDIO] CMD5 ready (OCR=0x901F8000)
[SDIO] RCA=0x0001
[SDIO] fn1 ready (attempt 0)
[MAIN] ATA IDENTIFY...
[ATA] IDENTIFY complete
Model: [TOSHIBA MK4001MTD]
Serial: [ 763B004HA]
Firmware: [VH173A]
Sectors: 7862400 (3839 MB)
SMART: not supported (supported=0, enabled=0)
IDENTIFY: W0=0040 W47=0000 W49=0000 W59=0000
ATA W80=0000 Cmd W82=0000 W83=0000 W84=0000
En W85=0000 W86=0000 W87=0000 W89=0008 W128=0001
[DIAG] === Drive Diagnostics ===
[DIAG] Standard SMART: not supported (IDENTIFY W82 bit0 = 0)
[DIAG] Toshiba vendor CMD 0xC2:
FEAT=0x01 unknown_01 → SC=00 LBA=02/00/00 ST=50
FEAT=0x02 unknown_02 â SC=00 LBA=02/00/00 ST=50
FEAT=0x03 unknown_03 → SC=00 LBA=02/00/00 ST=50
FEAT=0x04 unknown_04 → SC=00 LBA=02/00/00 ST=50
FEAT=0x10 diag_10 (LBA_LO varies) → SC=00 LBA=00/00/00 ST=50
FEAT=0x11 diag_11 → SC=00 LBA=00/00/00 ST=50
FEAT=0x12 diag_12 (LBA_LO varies) → SC=00 LBA=01/00/00 ST=50
FEAT=0x20 query_20 (N91: SC=0xFF always) → SC=FE LBA=00/FF/00 ST=50
FEAT=0x21 query_21 (N91: SC varies per boot) → SC=1B LBA=00/FF/00 ST=50
[MAIN] MBR: valid 0x55AA
[MAIN] Warming up...
[MAIN] PIO OK, STATUS=0x50
[MAIN] Drive: 7862400 sectors (3839 MB)
[MAIN] Ready.
[PWR] Idle 5000ms → STANDBY + power gate
[PWR] STANDBY IMMEDIATE → power gate
[SDIO] HDD power OFF
最后,以下是到实际硬盘的接线。
这是从 N91 原理图中截取的部分,你也可以映射引脚编号。

附注:这是一个 3V 驱动器,但我认为 3.3V 也可以,主要是为了省去电平转换的工作。
专门为此驱动器设计的硬件位于 /hardware!

# 检查设备是否出现
lsblk -dno NAME,MODEL | grep MK4001
# 文件系统测试 — 挂载、复制文件、验证
sudo mount /dev/sdX1 /mnt/mk4001
cp /tmp/testfile /mnt/mk4001/
sync
md5sum /tmp/testfile /mnt/mk4001/testfile # 应一致
sudo umount /mnt/mk4001
# 速度基准测试(原始设备,请勿先挂载 — 会损坏文件系统)
# 使用文件系统之后的安全偏移或未分区的驱动器
sudo dd if=/dev/sdX of=/dev/null bs=64k count=128 iflag=direct # 读取
sudo dd if=/dev/zero of=/dev/sdX bs=64k count=64 oflag=direct seek=1024 # 写入(偏移过 FS)
人工备注,有趣的事实:当它第一次开始速度测试时,它实际上直接 dd 到驱动器并损坏了文件系统..... 谢天谢地,在开发过程中这并不太重要,但请始终记住当你操作 OpenClaw 的配置时要小心。
我不在意。
| 指标 | 数值 |
|---|
| 读取速度 | ~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 + 电源门控 |
| 地址 | 寄存器 | 用途 |
|---|
| 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 | 命令(写入)/ 状态(读取) |
| Pico GPIO | 功能 | 备注 |
|---|
| GP2 | SDIO_CLK | 主机时钟输出 |
| GP3 | SDIO_CMD | 双向命令线 |
| GP4 | SDIO_DAT0 | 数据位 0 |
| GP5 | SDIO_DAT1 | 数据位 1 |
| GP6 | SDIO_DAT2 | 数据位 2 |
| GP7 | SDIO_DAT3 | 数据位 3 |
| GP9 | HDD_EN | 驱动器电源使能(高电平=开) |
| GP12 | UART TX | 调试输出 @ 115200 |
| GP13 | UART RX | 调试输入 |
| GP16 | LED:硬盘电源 | 低电平有效 |
| GP17 | LED:硬盘正常 | 低电平有效 |
| GP18 | LED:读取 | 低电平有效 |
| GP19 | LED:写入 | 低电平有效 |
| 文件 | 行数 | 用途 |
|---|
main.c | 210 | 初始化、空闲待机、USB 挂起/恢复 |
msc_device.c | 400 | USB MSC 回调、电源门控唤醒、坏扇区缓存 |
ata_sdio.c | 390 | ATA 命令、错误恢复、供应商诊断 |
sdio_pio.c | 635 | PIO SDIO:CMD52、CMD53 读/写、程序交换、CRC16 |
sdio_hw.c | 45 | 引脚初始化 + 硬盘电源控制 |
sdio.pio | 200 | PIO 汇编 + C SDK 初始化助手 |
led.h | 37 | LED 助手(GP16–GP19,低电平有效) |
usb_descriptors.c | 77 | USB 设备/配置/字符串描述符 |
tusb_config.h | 20 | TinyUSB 配置(MSC,32KB EP 缓冲区) |
| 版本 | 读取 | 写入 | 关键变化 |
|---|
| v0.1–v0.3 | 105 kB/s | 93 kB/s | 位碰撞 SDIO、CRC16、重试逻辑 |
| v0.5 | 374 kB/s | — | 单 SM PIO、直接指令内存交换 |
| v0.6 | 583 kB/s | 93 kB/s | 多块 CMD53 读取、CRC 时钟耗尽修复 |
| v0.8 | 588 kB/s | 274 kB/s | PIO 写入、OSR 刷新修复 |
| v0.9 | 475 kB/s | 371 kB/s | 64 扇区块、CRC16 读取验证 |
| v0.10 | 453 kB/s | 329 kB/s | LED 重新映射、HDD EN 引脚、GP12/GP13 上的 UART |
| v0.11 | ~450 kB/s | ~340 kB/s | HDD 电源门控、PIO 唤醒、坏扇区感知、USB 挂起 |
| v0.12 | ~985 kB/s | ~920 kB/s | 驱动器/USB 重叠(读取预取 + 带写后缓存的通告写入缓存)、流水线 PIO 块、bswap DMA、4 周期 PIO 循环、SBC 风格坏扇区语义(写修复)、供应商化 TinyUSB MSC 驱动程序 |