
Firmware RP2040 que conecta un microdrive SDIO Toshiba MK4001MTD de 0.85" como un dispositivo de almacenamiento masivo USB, implementando la pila completa del protocolo SDIO-ATA desde cero con lecturas/escrituras aceleradas por PIO y recuperación de sectores defectuosos.
Firmware RP2040 Pico que puentea un microdrive SDIO Toshiba MK4001MTD de 0.85" como dispositivo de almacenamiento masivo USB.

El MK4001MTD es un microdrive de 4 GB originalmente usado en el Nokia N91 y otros dispositivos, como reproductores MP3 o unidades USB, cuando el almacenamiento flash aún era bastante caro.
Quizás hayas visto presentaciones que afirman que esta unidad usa el protocolo MMC, pero eso es incorrecto. He estado investigando esto por un tiempo: intenté construir un lector de tarjetas MMCplus de 8 bits y probé diferentes lectores SD/MMC sin éxito. Como último recurso, compré un Nokia N91 para capturar trazas lógicas y confirmar qué protocolo usa realmente.
Aquí la foto cuando intentaba usarlo con mi placa lectora 8bit-MMCPlus, y resulta que no es MMC :(

Así que terminé comprando el N91 para recolectar trazas:

A diferencia de los microdrives ATA/CF estándar, utiliza una interfaz SDIO con comandos ATA tunelizados a través de CMD52/CMD53. Ningún controlador existente soporta este protocolo, por lo que este firmware implementa la pila completa desde cero.
Esto me sorprendió, porque existe un estándar SDIO-to-ATA llamado CE-ATA. Pero si observas la línea de tiempo de lanzamiento, CE-ATA llegó después que esta unidad. Como resultado, esta unidad depende completamente de comandos SDIO, y CE-ATA no está disponible. CE-ATA tiene dos nuevos comandos CMD60/CMD61 y utiliza CMD12/39, pero se puede ver en las trazas que no usa ninguno de ellos.
El segundo punto de hardware es que otra desinformación flotando —afirmando que es una tarjeta MMCPlus de 8 bits— no solo es falsa, sino que el pinout tampoco sigue el estándar MMC. Puedes encontrar el manual de servicio del Nokia N91 con algo de documentación sobre el pinout: aunque la numeración de pines sigue el estándar MMCPlus, la asignación de pines no. Este es un detalle importante si lo estás cableando tú mismo: usa el mismo conector MMC, pero la asignación de pines es diferente, más en la sección de Hardware.
Finalmente, ten en cuenta que esto es co-desarrollado con Claude/OpenClaw. Yo recolecté las trazas lógicas manualmente y configuré una estación de prueba de bucle cerrado para que OpenClaw iterara en el desarrollo —analizando las trazas e implementando funcionalidades. La documentación será principalmente escrita por Claude; también agregaré mis notas inline. También he leído y verificado la documentación yo mismo, y debería ser confiable y fácil de seguir.
Para obtener información sobre el análisis de la traza del N91, está en /docs/N91_TRACE_ANALYSIS.md; también he puesto allí el manual de servicio del N91 junto con las trazas lógicas sin procesar.
Ver más en la publicación del blog aquí: https://www.willwhang.dev/Reading-MK4001MTD/
Verlo en acción aquí: https://youtu.be/GC4xil3_Bbc
Almacenamiento masivo USB completamente funcional con lecturas/escrituras aceleradas por PIO y gestión de energía en reposo.
USB Host ←→ USB MSC (TinyUSB) ←→ Capa ATA ←→ Capa SDIO (PIO) ←→ MK4001MTD
El firmware tiene cuatro capas:
USB MSC (msc_device.c) — TinyUSB Mass Storage Class. Traduce SCSI READ(10)/WRITE(10) a operaciones de sectores ATA. Buffer EP de 32 KB, agrupando hasta 64 sectores por transferencia USB. La E/S de la unidad se superpone con USB en ambas direcciones, como un puente ATA-USB real con un disco de caché: un pre-buscador de lectura secuencial obtiene el siguiente bloque mientras el anterior se transmite al host, y las escrituras se ponen en cola y se vacían mientras USB recibe la siguiente pieza. El dispositivo anuncia su caché de escritura (Página de modo Caching, WCE=1 — los hosts reportan "Write cache: enabled" y emiten SYNCHRONIZE CACHE en fsync/desmontaje/suspensión, que el firmware respeta). Un vaciado en segundo plano fallido se refleja como MEDIUM ERROR en la próxima WRITE o SYNCHRONIZE CACHE; las escrituras a sectores malos conocidos siguen una ruta síncrona estricta.
ATA-over-SDIO (ata_sdio.c) — Implementa comandos ATA (IDENTIFY, READ SECTORS, WRITE SECTORS) escribiendo en registros ATA mapeados en el espacio de direcciones de la función SDIO 1 a través de CMD52, y transfiriendo datos de sectores a través de CMD53. Lógica de reintento de 3 niveles a nivel de CMD, datos y ATA.
PIO SDIO (sdio_pio.c, sdio.pio) — SDIO acelerado por hardware usando el periférico PIO del RP2040 (bus de 4 bits a 10 MHz, 4 ciclos PIO por bit con sincronizadores de entrada omitidos). Tres programas PIO comparten una sola máquina de estados mediante intercambio dinámico de programas:
() — Inicialización de GPIO y control de energía del HDD. Toda la comunicación SDIO usa PIO.
Notas humanas: Curiosamente, Claude fue muy reacio a implementar SDIO en PIO, y se desperdiciaron muchos ciclos de desarrollo yendo y viniendo entre PIO y bit-banging.
El MK4001MTD se presenta como una tarjeta SDIO con una función de E/S. La inicialización estándar de tarjetas SDIO (CMD5/CMD3/CMD7) configura el bus, luego los registros ATA se acceden mediante comandos SDIO:
Acceso a registros (CMD52): Cada registro ATA está mapeado a una dirección de función 1:
Transferencia de datos (CMD53): Los datos de sectores se transfieren emitiendo CMD53 en modo bloque apuntando al registro DATA (dirección 0x00). Para lecturas de múltiples sectores, un solo CMD53 con block_count=N transfiere N × 512 bytes en una sola transacción SDIO multi-bloque.
Señalización de interrupción: La unidad señala la preparación del sector activando una interrupción SDIO (bit INT_PENDING 1 en el registro CCCR 0x05). La lectura del registro ATA STATUS borra la interrupción.
Para una lectura de 16 sectores:
1. Escribir registros ATA mediante PIO CMD52:
SECCOUNT=16, LBA_LO/MID/HI, DEV/HEAD=0xE0, CMD=0x20
2. Sondear STATUS mediante CMD52 hasta que DRQ (bit 3) esté activo
3. Cambiar PIO al programa de lectura DAT
4. Enviar CMD53: block_mode=1, fn=1, addr=0x0000, block_count=16
5. Lectura DAT PIO: para cada uno de los 16 bloques:
a. Esperar bit de inicio (todas las líneas DAT en bajo)
b. DMA de 1024 nibbles (512 bytes) desde FIFO RX de PIO al búfer
c. Esperar a que SM termine de enviar los nibbles de CRC+fin (sondear PC de SM)
d. Reempaquetar nibbles → bytes en el lugar
6. Cambiar PIO de nuevo al programa CMD
Para una escritura de 16 sectores:
1. Escribir registros ATA mediante PIO CMD52:
SECCOUNT=16, LBA, DEV/HEAD=0xE0, CMD=0x30
2. Sondear STATUS mediante CMD52 hasta que DRQ (bit 3) esté activo
(STATUS 0xD8 = BSY+DRQ tratado como DRQ listo, según traza N91)
3. Cambiar PIO al programa de escritura DAT
4. Enviar CMD53: block_mode=1, fn=1, addr=0x0000, block_count=16
5. Escritura DAT PIO: para cada uno de los 16 bloques:
a. Precalcular CRC16-CCITT por línea DAT (4 CRC independientes)
b. Construir flujo de nibbles: inicio(0x0) + datos(1024 nibbles) + CRC(16) + fin(0xF)
c. DMA del flujo de nibbles al FIFO TX de PIO
d. PIO saca todos los nibbles, luego:
- Cambia DAT a entrada
- Genera 16 ciclos para el estado CRC desde la tarjeta
- Sonda DAT0 hasta que la tarjeta libera el estado ocupado
- Dispara IRQ 0 para señalar finalización del bloque
6. Cambiar PIO de nuevo al programa CMD
El PIO del RP2040 tiene 32 ranuras de instrucción por bloque. Nuestros tres programas suman 55 instrucciones, por lo que no pueden coexistir. En su lugar, se usa una sola SM0 en PIO0, y los programas se intercambian escribiendo directamente en la memoria de instrucciones del 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];
}
Esto evita el asignador pio_add_program/pio_remove_program del SDK. El intercambio de programas toma ~1 µs. Cada intercambio va seguido de una reinicialización específica del programa que establece los mapeos de pines, la dirección de desplazamiento y el divisor de reloj.
El análisis de las trazas lógicas del Nokia N91 revela una gestión de energía agresiva:
El firmware replica este comportamiento con un tiempo de espera de inactividad configurable:
#define IDLE_STANDBY_MS 5000 // en main.c
Dos rutas desencadenan el corte de energía del HDD:
Ambas rutas envían ATA STANDBY IMMEDIATE (0xE0) para vaciar la caché de escritura y estacionar las cabezas, luego cortan la energía mediante GP9.
Secuencia de activación (desencadenada por la primera READ/WRITE después del corte):
Cuando una transferencia multi-sector se encuentra con un sector defectuoso:
STATUS/ERROR en lugar de colapsar la falla en un DRQ timeout genéricoMEDIUM ERROR (lectura: 03/11/00, escritura: 03/0C/00)El punto 6 no es académico: esta unidad tenía un sector ilegible de larga data en LBA 1952 (READ: ST=0x51 ERR ERR=0x40 UNC). Una vez que el puente permitió que una escritura realmente llegara, la unidad reescribió el sector y se ha leído limpio desde entonces:
[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)La versión del SDK está fijada: si PICO_SDK_PATH está definido (variable de entorno o de CMake), se usa y su versión se verifica contra la fijación — una discrepancia falla la configuración con instrucciones (anular con -DMK4001_ALLOW_SDK_MISMATCH=ON). Sin PICO_SDK_PATH, la versión fijada del SDK se obtiene de GitHub automáticamente durante la configuración, por lo que un simple git clone && cmake && make es totalmente reproducible.
El firmware necesita un controlador de clase MSC de TinyUSB parcheado (datos de sentido de aplicación preservados en errores de lectura/escritura + una página de modo Caching con WCE=1). Ese archivo está incluido en este repositorio en lib/tinyusb_patched/msc_device.c — la compilación lo compila automáticamente en lugar de la copia del SDK, por lo que nunca se necesita cirugía del SDK. El diff contra el TinyUSB oficial (0.18.0, tal como se incluye con pico-sdk 2.2.0) está en lib/tinyusb_patched/; la fijación del SDK existe precisamente porque este archivo incluido debe seguir el TinyUSB del SDK.
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"
Nota: GP0 y GP1 están muertos en esta unidad Pico específica. Todas las asignaciones de pines SDIO están desplazadas +2.
Notas humanas: Claude se equivocó aquí porque no se dio cuenta de que GP0 y GP1 estaban siendo usados para la terminal UART en su configuración de compilación. Siguió olvidándolo, hasta el punto de que simplemente moví los GPIOs SDIO fuera de esa UART.
HDD_PWR no es necesario. No tienes que reiniciar la unidad para usarla; es más una conveniencia de desarrollo para reiniciar el HDD cuando muchas cosas están hardcodeadas. Dicho esto, si quieres ahorrar energía, puedes usar esa señal, pero puede manejar un reinicio en caliente sin problemas.
Verás mensajes de depuración a través de UART. No van a través de USB-CDC porque fue más fácil para Claude configurar un enlace de registro UART a USB separado que no se desconecte ni se vuelva inestable durante el desarrollo temprano.
El registro UART también informa la temperatura de la unidad cada 30 segundos mientras la unidad está activa ([TEMP] drive temperature: 29 C). El sensor se descubrió mediante ingeniería inversa del comando de proveedor Toshiba 0xC2 — el N91 lo lee al inicio de cada sesión de la unidad para hacer cumplir sus límites de temperatura de funcionamiento del HDD. Detalles en docs/N91_TRACE_ANALYSIS.md §4.
Aquí hay un ejemplo del registro:
========================================
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
Finalmente, aquí está el cableado a la unidad real.
Aquí hay un recorte del esquema del N91; también puedes mapear el número de pin.

Nota al margen: esta es una unidad de 3V, pero creo que 3.3V está bien, principalmente es para ahorrar trabajo de cambio de nivel.
¡El HW diseñado específicamente para esta unidad está en /hardware!

# Verificar que el dispositivo apareció
lsblk -dno NAME,MODEL | grep MK4001
# Prueba del sistema de archivos — montar, copiar archivos, verificar
sudo mount /dev/sdX1 /mnt/mk4001
cp /tmp/testfile /mnt/mk4001/
sync
md5sum /tmp/testfile /mnt/mk4001/testfile # deberían coincidir
sudo umount /mnt/mk4001
# Benchmarks de velocidad (dispositivo sin procesar, NO montar primero — dañará el sistema de archivos)
# Usar un offset seguro más allá del sistema de archivos o una unidad sin particionar
sudo dd if=/dev/sdX of=/dev/null bs=64k count=128 iflag=direct # lectura
sudo dd if=/dev/zero of=/dev/sdX bs=64k count=64 oflag=direct seek=1024 # escritura (offset más allá del FS)
Notas humanas aquí, dato curioso: Cuando comenzó a hacer pruebas de velocidad, realmente hizo dd directamente a la unidad y dañó los sistemas de archivos... Afortunadamente, eso no importa mucho durante los desarrollos, pero ten siempre en cuenta cuando manejes tu configuración de OpenClaw.
No me importa.
| Métrica | Valor |
|---|
| Velocidad de lectura | ~985 kB/s (limitado por USB full-speed) |
| Velocidad de escritura | ~920 kB/s (limitado por USB full-speed, caché de escritura anunciada) |
| Velocidad bruta lado SDIO | ~2.35 MB/s lectura / ~2.15 MB/s escritura (limitado por la unidad) |
| Capacidad | 3.75 GB (7,862,400 sectores) |
| Sistema de archivos | FAT32 verificado (mount/unmount/fsck limpio) |
| Integridad de datos | Lectura+reescritura verificada; CRC16 por bloque en las 4 líneas DAT |
| Standby en reposo | 5 s inactivo o suspensión USB → STANDBY IMMEDIATE + corte de energía |
sdio_hw.c| Dirección | Registro | Uso |
|---|
| 0x00 | DATA | Objetivo CMD53 para datos de sector |
| 0x01 | ERR/FEAT | Error (lectura) / Feature (escritura) |
| 0x02 | SECCOUNT | Conteo de sectores |
| 0x03 | LBA_LO | LBA bits 0-7 |
| 0x04 | LBA_MID | LBA bits 8-15 |
| 0x05 | LBA_HI | LBA bits 16-23 |
| 0x06 | DEV/HEAD | Dispositivo/Cabeza + LBA bits 24-27 |
| 0x07 | CMD/STATUS | Comando (escritura) / Estado (lectura) |
| GPIO del Pico | Función | Notas |
|---|
| GP2 | SDIO_CLK | Salida de reloj del host |
| GP3 | SDIO_CMD | Línea de comando bidireccional |
| GP4 | SDIO_DAT0 | Bit de datos 0 |
| GP5 | SDIO_DAT1 | Bit de datos 1 |
| GP6 | SDIO_DAT2 | Bit de datos 2 |
| GP7 | SDIO_DAT3 | Bit de datos 3 |
| GP9 | HDD_EN | Habilitación de energía de la unidad (HIGH=encendido) |
| GP12 | UART TX | Salida de depuración @ 115200 |
| GP13 | UART RX | Entrada de depuración |
| GP16 | LED: Energía HDD | Activo bajo |
| GP17 | LED: HDD Saludable | Activo bajo |
| GP18 | LED: Lectura | Activo bajo |
| GP19 | LED: Escritura | Activo bajo |
| Archivo | Líneas | Propósito |
|---|
main.c | 210 | Inicialización, standby inactivo, suspensión/reanudación USB |
msc_device.c | 400 | Callbacks USB MSC, activación por corte de energía, caché de sectores defectuosos |
ata_sdio.c | 390 | Comandos ATA, recuperación de errores, diagnósticos de proveedor |
sdio_pio.c | 635 | PIO SDIO: CMD52, CMD53 lectura/escritura, intercambio de programas, CRC16 |
sdio_hw.c | 45 | Inicialización de pines + control de energía del HDD |
sdio.pio | 200 | Ensamblador PIO + ayudantes de inicialización del SDK C |
led.h | 37 | Ayudantes de LED (GP16–GP19, activo bajo) |
usb_descriptors.c | 77 | Descriptores de dispositivo/configuración/cadena USB |
tusb_config.h | 20 | Configuración de TinyUSB (MSC, búfer EP de 32KB) |
| Versión | Lectura | Escritura | Cambio Clave |
|---|
| v0.1–v0.3 | 105 kB/s | 93 kB/s | SDIO bit-bang, CRC16, lógica de reintentos |
| v0.5 | 374 kB/s | — | PIO de una sola SM, intercambio directo de memoria de instrucciones |
| v0.6 | 583 kB/s | 93 kB/s | Lecturas CMD53 multi-bloque, corrección de drenaje de reloj CRC |
| v0.8 | 588 kB/s | 274 kB/s | Escrituras PIO, corrección de vaciado OSR |
| v0.9 | 475 kB/s | 371 kB/s | Fragmentos de 64 sectores, verificación de lectura CRC16 |
| v0.10 | 453 kB/s | 329 kB/s | Reasignación de LED, pin HDD EN, UART en GP12/GP13 |
| v0.11 | ~450 kB/s | ~340 kB/s | Corte de energía HDD, activación PIO, sentido de sector defectuoso, suspensión USB |
| v0.12 | ~985 kB/s | ~920 kB/s | Superposición unidad/USB (pre-búsqueda de lectura + caché de escritura anunciada con escritura diferida), bloques PIO en pipeline, DMA bswap, bucles PIO de 4 ciclos, semántica de sector defectuoso estilo SBC (reparación por escritura), controlador TinyUSB MSC incluido |