
RP2040 펌웨어로, 도시바 MK4001MTD 0.85인치 SDIO 마이크로드라이브를 USB 대용량 저장 장치로 브리징하며, PIO 가속 읽기/쓰기 및 불량 섹터 복구 기능을 갖춘 전체 SDIO-ATA 프로토콜 스택을 처음부터 구현합니다.
Toshiba MK4001MTD 0.85인치 SDIO 마이크로드라이브를 USB 대용량 저장 장치로 연결하는 RP2040 Pico 펌웨어.

MK4001MTD는 4GB 마이크로드라이브로, 원래 Nokia N91 뮤직폰 및 일부 MP3 플레이어나 USB 드라이브 같은 기기에 사용되었으며, 당시 플래시 저장 장치는 꽤 비쌌습니다.
이 드라이브가 MMC 프로토콜을 사용한다는 소개를 본 적이 있을 수 있지만, 이는 사실 잘못된 정보입니다. 저는 이 문제를 한동안 조사해 왔습니다. 8비트 MMCplus 카드 리더기를 만들어 보기도 하고 다양한 SD/MMC 리더기를 테스트해 보았지만 성공하지 못했습니다. 최후의 수단으로 Nokia N91을 구입하여 로직 트레이스를 캡처하고 실제로 어떤 프로토콜을 사용하는지 확인했습니다.
아래는 제 8비트 MMCPlus 리더 보드와 함께 사용하려고 시도했을 때의 사진입니다. 결과적으로 MMC가 아니었습니다 :(

그래서 결국 N91을 구입하여 트레이스를 수집했습니다:

표준 ATA/CF 마이크로드라이브와 달리, SDIO 인터페이스를 사용하며 ATA 명령이 CMD52/CMD53을 통해 터널링됩니다. 이 프로토콜을 지원하는 기존 드라이버가 없기 때문에, 이 펌웨어는 처음부터 전체 스택을 구현합니다.
이것은 저에게 놀라운 점이었습니다. SDIO-to-ATA 표준인 CE-ATA가 존재하기 때문입니다. 하지만 출시 타임라인을 자세히 살펴보면, CE-ATA는 이 드라이브보다 나중에 나왔습니다. 결과적으로 이 드라이브는 전적으로 SDIO 명령에 의존하며 CE-ATA는 사용할 수 없습니다. CE-ATA는 두 개의 새로운 명령 CMD60/CMD61과 CMD12/39를 사용하지만, 트레이스에서 볼 수 있듯이 이 드라이브는 이러한 명령을 전혀 사용하지 않습니다.
두 번째 하드웨어 관련 사항은, 돌아다니는 또 다른 잘못된 정보—즉, 이 드라이브가 8비트 MMCPlus 카드라는 주장—이 사실이 아닐 뿐만 아니라 핀 배치도 MMC 표준을 따르지 않는다는 점입니다. Nokia 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
PIO 가속 읽기/쓰기 및 아이들 전원 관리 기능을 갖춘 완전한 기능의 USB 대용량 저장 장치.
USB 호스트 ←→ USB MSC (TinyUSB) ←→ ATA 레이어 ←→ SDIO 레이어 (PIO) ←→ MK4001MTD
펌웨어는 네 개의 레이어로 구성됩니다:
USB MSC (msc_device.c) — TinyUSB Mass Storage Class. SCSI READ(10)/WRITE(10)를 ATA 섹터 연산으로 변환합니다. 32KB 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 명령(IDENTIFY, READ SECTORS, WRITE SECTORS)을 구현하며, ATA 레지스터를 CMD52를 통해 SDIO 함수 1 주소 공간에 매핑하고 CMD53을 통해 섹터 데이터를 전송합니다. CMD, 데이터, ATA 레벨에서 3단계 재시도 로직.
PIO SDIO (sdio_pio.c, sdio.pio) — RP2040의 PIO 주변 장치를 사용한 하드웨어 가속 SDIO (4비트 버스, 10MHz, 입력 동기화기 우회로 비트당 4 PIO 사이클). 동적 프로그램 스와핑을 통해 단일 상태 머신을 공유하는 세 가지 PIO 프로그램:
핀/전원 (sdio_hw.c) — GPIO 초기화 및 HDD 전원 제어. 모든 SDIO 통신은 PIO를 사용합니다.
인간 노트: 흥미롭게도 Claude는 PIO에서 SDIO를 구현하는 것을 매우 꺼려했고, PIO와 비트뱅잉 사이를 왔다 갔다 하면서 많은 개발 주기가 낭비되었습니다.
MK4001MTD는 하나의 I/O 함수를 가진 SDIO 카드로 자신을 식별합니다. 표준 SDIO 카드 초기화(CMD5/CMD3/CMD7)가 버스를 설정한 후, ATA 레지스터는 SDIO 명령을 통해 접근됩니다:
레지스터 접근 (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. PIO RX FIFO에서 버퍼로 1024 니블(512바이트) DMA
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는 N91 트레이스에 따라 DRQ-준비로 처리)
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. 니블 스트림 구축: 시작(0x0) + 데이터(1024 니블) + CRC(16) + 종료(0xF)
c. 니블 스트림을 PIO TX FIFO로 DMA
d. PIO가 모든 니블을 클록 아웃한 후:
- DAT를 입력으로 전환
- 카드의 CRC 상태를 위해 16사이클 클록
- 카드가 busy를 해제할 때까지 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가 소요됩니다. 각 교체 후에는 핀 매핑, 시프트 방향 및 클록 분배기를 설정하는 프로그램별 재초기화가 이어집니다.
Nokia N91 로직 트레이스 분석 결과, 공격적인 전원 관리가 드러났습니다:
펌웨어는 구성 가능한 아이들 타임아웃으로 이 동작을 복제합니다:
#define IDLE_STANDBY_MS 5000 // main.c
두 가지 경로가 HDD 전원 게이팅을 트리거합니다:
두 경로 모두 ATA STANDBY IMMEDIATE (0xE0)를 전송하여 쓰기 캐시를 플러시하고 헤드를 주차한 다음 GP9를 통해 전원을 차단합니다.
깨우기 시퀀스 (게이트 후 첫 READ/WRITE에 의해 트리거):
다중 섹터 전송이 불량 섹터에 도달하면:
DRQ 시간 초과로 실패를 축소하지 않고 최종 STATUS/ERROR 비트를 캡처할 수 있을 만큼 충분히 오래 대기MEDIUM 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가 전혀 없으면 고정된 SDK 릴리스가 구성 시 자동으로 GitHub에서 가져와지므로, 단순히 git clone && cmake && make만으로 완전히 재현 가능합니다.
펌웨어는 패치된 TinyUSB MSC 클래스 드라이버가 필요합니다(읽기/쓰기 오류 시 앱 센스 데이터 보존 + WCE=1인 캐싱 모드 페이지). 해당 파일은 이 저장소에 벤더링되어 lib/tinyusb_patched/msc_device.c에 있습니다. 빌드는 SDK 사본 대신 이 파일을 자동으로 컴파일하므로 SDK 수정이 전혀 필요 없습니다. 업스트림 TinyUSB(pico-sdk 2.2.0에 번들된 0.18.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은 필수가 아닙니다. 드라이브를 사용하기 위해 전원을 껐다 켤 필요는 없으며, 많은 부분이 하드코딩되어 있을 때 HDD를 리셋하는 개발 편의 기능에 가깝습니다. 즉, 전원 절약이 필요하다면 해당 신호를 사용할 수 있지만, 웜 리셋은 문제없이 처리할 수 있습니다.
UART를 통해 디버그 메시지를 볼 수 있습니다. USB-CDC를 통해 나오지 않는 이유는 Claude가 초기 개발 중에 연결이 끊기거나 불안정해지지 않는 별도의 UART-to-USB 로깅 링크를 설정하는 것이 더 쉬웠기 때문입니다.
UART 로그는 드라이브가 활성 상태인 동안 30초마다 드라이브 온도도 보고합니다([TEMP] drive temperature: 29 C). 센서는 Toshiba 벤더 명령 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도 괜찮다고 생각합니다. 주로 레벨 시프팅 작업을 줄이기 위한 것입니다.
이 드라이브 전용으로 설계된 HW는 /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 full-speed 제한) |
| 쓰기 속도 | ~920 kB/s (USB full-speed 제한, 광고된 쓰기 캐시) |
| 순수 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 | 드라이브 전원 활성화 (HIGH=켜짐) |
| GP12 | UART TX | 디버그 출력 @ 115200 |
| GP13 | UART RX | 디버그 입력 |
| GP16 | LED: HDD 전원 | 액티브 로우 |
| GP17 | LED: HDD 정상 | 액티브 로우 |
| 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 | 핀 초기화 + HDD 전원 제어 |
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 드라이버 |