리눅스 시스템에서 read() 또는 write()를 호출했을 때, 이 요청은 어떻게 실제 eMMC 디바이스까지 도달할까?
이번 글에서는 사용자의 File I/O 요청이 커널 eMMC 디바이스로 연결되는 전체 흐름을 커널 내부 경로를 따라 단계별로 정리한다.
[User App]
↓ read()/write() 시스템 콜
[VFS] (file_operations)
↓
[파일시스템] (ext4/f2fs) → 페이지 캐시 or Direct I/O
↓
submit_bio() → submit_bio_noacct() → blk_mq_submit_bio()
↓
blk-mq (I/O 스케줄러 적용)
↓
mmc_mq_queue_rq() → mmc_blk_mq_issue_rq()
↓
mmc_start_request() (제출은 여기서 끝, 완료는 인터럽트/워크큐로 비동기 처리)
↓
sdhci_request() → sdhci_send_command() (DMA/PIO)
↓
eMMC 디바이스 (CMD18/25)
User 영역 → 시스템 콜
- read(), write(), open() 등 시스템 콜 호출
- syscall 진입 후
sys_read(),sys_write()→vfs_read(),vfs_write()실행
ssize_t sys_read(unsigned int fd, char __user *buf, size_t count);
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos);
실제 심볼 이름은 아키텍처와 커널 버전에 따라 __x64_sys_read처럼 syscall wrapper 접두사가 붙을 수 있다. sys_read는 SYSCALL_DEFINE3 매크로가 만들어내는 논리적인 이름으로 이해하면 된다.
VFS (Virtual File System)
- 파일 시스템 추상화 계층
struct file_operations→read_iter,write_iter에 따라 실제 파일 시스템 핸들러 호출
const struct file_operations ext4_file_operations = {
.read_iter = ext4_file_read_iter,
.write_iter = ext4_file_write_iter,
};
파일 시스템 (ext4, f2fs 등)
- inode → logical block 번호 계산
- read/write 시 페이지 캐시를 통해 처리하거나,
O_DIRECT설정 시 캐시 우회
ext4_file_read_iter() → generic_file_read_iter()
# write_iter는 디스크에 바로 쓰지 않는다 — 페이지를 dirty로 마킹하고 리턴한다
f2fs_file_write_iter() → generic_perform_write() (페이지 캐시에 기록, dirty 마킹)
# 실제 디스크 쓰기는 이후 writeback 시점에 별도로 호출된다 (->writepages 콜백)
f2fs_write_data_pages() → f2fs_write_cache_pages() → __f2fs_write_data_pages()
f2fs_write_data_pages()는 write_iter가 직접 호출하는 함수가 아니라, dirty 페이지가 쌓인 뒤 writeback이 트리거될 때(주기적 flush, sync(), 메모리 압박 등) 커널이 address_space_operations.writepages 콜백으로 부르는 별도 경로다. 아래 “페이지 캐시 / Direct I/O” 절의 흐름과 이어서 보면 된다.
페이지 캐시 / Direct I/O
페이지 캐시 사용 시
readpage(),writepage()를 통해 I/O 요청 전후에 캐싱 수행- dirty 페이지는 이후
writeback시점에 디바이스로 flush
Direct I/O (O_DIRECT)
- 페이지 캐시를 사용하지 않고 직접 BIO 생성 → submit_bio() 호출
Block I/O 계층 (BIO → Request)
submit_bio()를 통해 block I/O 요청 생성- BIO는 하나 이상의 page를 포함할 수 있음
submit_bio()
→ submit_bio_noacct()
→ __submit_bio()
→ blk_mq_submit_bio()
- 블록 계층은 I/O scheduler(mq-deadline, bfq, none 등) 적용 후 드라이버로 요청 전달
블로그나 오래된 커널 문서에서 blk_mq_make_request(), blk_mq_sched_insert_request() 같은 이름을 자주 볼 수 있는데, 이는 blk-mq 초기 버전의 이름이다. 현재는 blk_mq_submit_bio() 하나로 정리되어 있으니, 커널 버전에 따라 이름이 다를 수 있다는 점을 감안해야 한다.
MMC 블록 계층 (eMMC 처리)
- blk-mq를 통해
mmc_mq_queue_rq()로 요청 전달됨 (blk_mq_ops.queue_rq로 등록된 콜백)
mmc_mq_queue_rq()
→ mmc_blk_mq_issue_rq()
→ mmc_blk_mq_issue_rw_rq()
→ mmc_start_request() // 여기서 리턴, 블로킹 대기하지 않는다
- MMC Core → Host Controller로 명령 전달
- 내부적으로 CMD18 (multi-block read), CMD25 (multi-block write) 명령 사용
여기서 중요한 점은 완료 처리가 동기 대기가 아니라는 것이다. 과거 단일 큐(legacy) MMC 드라이버는 mmc_wait_for_req()로 요청이 끝날 때까지 블로킹했지만, blk-mq로 전환된 현재의 기본 경로는 mmc_start_request()가 요청을 하드웨어에 제출한 뒤 곧바로 리턴한다. 실제 완료는 하드웨어 인터럽트가 걸리면 mmc_blk_mq_req_done() 콜백이 실행되고, 이어서 워크큐(mmc_blk_mq_complete_work())를 거쳐 mmc_blk_mq_complete_rq()가 처리한다. 제출과 완료가 분리된 이 구조가 blk-mq 전환의 핵심이고, 덕분에 하나의 요청이 끝나길 기다리지 않고 다음 요청을 큐에 계속 밀어 넣을 수 있다.
Host Controller (e.g., sdhci)
sdhci_request()→sdhci_send_command_retry()→sdhci_send_command()→ eMMC PHY 레벨 전송- DMA or PIO 방식으로 전송
- 완료 시 interrupt 발생 → 위에서 설명한
mmc_blk_mq_req_done()콜백으로 이어짐
eMMC 디바이스
- NAND 기반의 내부 컨트롤러 보유
- FTL, wear leveling, garbage collection 등 내장 처리
- 리눅스 커널은 블록 단위로만 접근
🔍 디버깅/성능 분석 툴
| 목적 | 툴 / 명령어 |
|---|---|
| 블록 I/O 추적 | blktrace, btrace, blkparse |
| 페이지 캐시 상태 | /proc/meminfo, vmstat, perf record -e vmscan:* |
| BIO 경로 분석 | ftrace, trace-cmd, bpftrace |
| mmc 요청 디버깅 | dmesg, cat /sys/kernel/debug/mmc* |
| DMA 문제 분석 | dma_map_sg, dma_sync_single 로그 삽입 |
| I/O 스케줄러 확인/변경 | cat /sys/block/mmcblk0/queue/scheduler, echo none > /sys/block/mmcblk0/queue/scheduler |
📎 전체 흐름 정리 (제출 + 완료 경로)
User App
│
├─▶ read()/write()
│
├─▶ VFS (file_operations)
│
├─▶ FS (ext4/f2fs)
│ ├─▶ page cache (dirty 마킹, writeback 때 flush)
│ └─▶ direct I/O (즉시 BIO 생성)
│
├─▶ submit_bio() → submit_bio_noacct() → blk_mq_submit_bio()
│
├─▶ blk-mq (I/O 스케줄러) → mmc_mq_queue_rq()
│
├─▶ mmc_blk_mq_issue_rq() → mmc_start_request() (제출, non-blocking)
│
├─▶ sdhci_request() → sdhci_send_command() (DMA/PIO)
│
├─▶ eMMC 처리 (CMD18/25)
│
└─▶ (완료) 인터럽트 → mmc_blk_mq_req_done() → 워크큐 → mmc_blk_mq_complete_rq()
🧠 마무리
eMMC 기반 시스템에서는 고성능 NVMe처럼 복잡한 계층은 없지만, BIO → blk-mq → mmc → host driver → eMMC 까지의 흐름을 정확히 이해하는 것이 매우 중요하다.
특히 page cache의 개입 여부, BIO 구조 생성 위치, 그리고 mmc 요청이 제출과 완료로 분리되어 비동기로 처리된다는 점은 성능 최적화나 디버깅에서 핵심이다. 커널 버전에 따라 함수 이름이 바뀌는 경우가 많으므로(특히 blk-mq 계층), 실제 디버깅 시에는 자신이 쓰는 커널 소스에서 blk_mq_ops와 mmc_host_ops 구조체를 직접 확인하는 습관을 들이는 편이 안전하다.