리눅스 커널의 이해
12

ch14. block device driver

linuxfilesystemkernel
BLOCK DEVICE DRIVER · CH.14
LECTURE NOTE — LINUX KERNEL, CHAPTER 14

블록 디바이스 드라이버,
느린 디스크를 빠른 시스템처럼

CPU는 나노초 단위로 동작하지만 디스크는 물리적 구동 탓에 밀리초 단위로 움직입니다. 커널은 이 거대한 속도 차이를 극복하기 위해 I/O 요청을 모으고, 정렬하고, 병합하여 가장 최적화된 시점에 드라이버로 전달합니다. 이 강의 노트에서는 단순한 read() 호출이 실제 디스크 제어 명령으로 변환되기까지의 전 과정을 상세히 살펴봅니다.

1
VFS 파일 추상화, 캐시 확인
2
Mapping Layer 파일 블록 → 디스크 논리 블록 변환
3
Generic Block Layer bio 생성, 공통 요청 추상화
4
I/O Scheduler 요청 정렬 · 병합 · plugging
5
Block Device Driver 하드웨어 컨트롤러 제어, DMA, 인터럽트
Figure 14-1 — 블록 I/O 하위 시스템 계층 구조
01

큰 그림: 블록 디바이스 드라이버는 "느린 디스크를 빠른 시스템처럼 보이게 하는 기술"WHY BLOCK I/O IS COMPLEX

CPU는 명령어를 나노초 단위로 처리합니다. 반면, 하드디스크는 데이터를 읽기 위해 물리적으로 헤드를 움직이고 원하는 위치를 찾아가야 하므로 밀리초 단위의 시간이 소모됩니다. 이 장의 핵심은 바로 '디스크가 매우 느리다'는 근본적인 한계에서 출발합니다.

CPU 및 버스의 속도와 디스크 하드웨어의 접근 시간 사이에는 엄청난 간극이 존재합니다. 디스크는 한 번 정확한 위치를 찾으면 빠르게 데이터를 전송할 수 있지만, 탐색 자체에 드는 비용이 막대하므로 이를 보완하기 위한 커널의 I/O 구조 역시 정교하고 복잡해질 수밖에 없습니다. 따라서 커널은 발생한 요청을 즉시 처리하지 않고 잠시 큐(Queue)에 모아둔 뒤, 순서를 재배치하고 병합하여 드라이버에 넘겨줍니다.

CPU와 디스크의 속도 차이는 마치 책상 위 메모지를 집어 드는 일과, 도서관 지하 서고까지 직접 내려가 책을 찾아오는 일의 차이와 같습니다. 커널의 블록 I/O 하위 시스템은 이 엄청난 간극을 최소화하기 위해 마련된 고도로 정교한 완충 장치입니다.

이번 학습을 통해 다음 세 가지 핵심 질문에 답할 수 있어야 합니다. 첫째, 사용자의 파일 읽기 요청이 어떻게 디스크의 특정 섹터 요청으로 변환되는가? 둘째, 커널은 왜 요청을 즉시 처리하지 않고 큐에 쌓아두는가? 셋째, 블록 디바이스 드라이버는 정확히 어느 시점에 물리적 하드웨어에 접근하는가?

이 장의 목적은 단순히 "개별 디스크 드라이버 코드를 짜는 법"을 배우는 것이 아닙니다. 리눅스 커널이 드라이버 구현을 쉽게 하도록 제공하는 bio, request, request_queue, gendisk, block_device와 같은 공통 자료구조들이 어떻게 유기적으로 맞물려 돌아가는지 그 생태계를 이해하는 것이 더 중요합니다.

핵심 O/X 퀴즈

1. 블록 디바이스 처리 과정의 가장 큰 과제는 CPU와 디스크 접근 시간 사이의 엄청난 속도 차이를 극복하는 것이다.
O이 속도 차이가 블록 I/O 하위 시스템을 복잡하게 설계해야만 했던 근본적인 이유입니다.
2. 사용자 프로그램에서 read()를 호출하면 그 즉시 디스크 컨트롤러로 명령이 전달된다.
XVFS, 페이지 캐시, 매핑 계층, Generic Block Layer, 그리고 I/O Scheduler 등 여러 계층을 거치며 최적화됩니다.
3. 이 장은 특정 디스크의 물리적 동작 원리보다, 리눅스 블록 I/O 시스템을 구성하는 공통 커널 구조체들의 연결성을 이해하는 데 중점을 둔다.
Obio, request, gendisk와 같은 핵심 자료구조 간의 상호작용이 학습의 중심입니다.
02

read() 요청의 여행: VFS에서 디스크 컨트롤러까지Figure 14-1 WALKTHROUGH

어떤 프로세스가 파일 내부의 데이터를 가져오기 위해 read() 시스템 콜을 호출했다고 가정해 봅시다. 마치 우리가 온라인으로 물건을 주문할 때 단순히 '결제' 버튼만 누르면 되는 것 같지만, 이면에서는 재고 파악, 창고 분류, 배송 경로 최적화, 기사 배정 등 복잡한 물류 시스템이 가동되는 것과 동일한 이치입니다.

1
VFS (Virtual File System). 다양한 파일시스템을 동일한 인터페이스로 다루게 해주는 추상화 계층입니다. 사용자가 전달한 파일 디스크립터와 오프셋을 분석해 "이 파일의 어느 부분을 읽고자 하는지"를 파악합니다.
2
디스크 캐시 확인. 최근에 읽거나 쓴 데이터라면 굳이 디스크까지 가지 않고 RAM 내부의 페이지 캐시에 이미 존재할 확률이 높습니다. 이 경우 캐시에서 바로 데이터를 반환하고 끝냅니다.
3
매핑 계층 (Mapping Layer). 파일 내 논리적 블록 번호를 실제 디스크(또는 파티션)의 물리적 논리 블록 번호로 변환합니다. 파일 데이터는 디스크상에 흩어져 저장될 수 있으므로 정확한 위치 매핑이 필수적입니다.
4
Generic Block Layer. 매핑된 위치 정보를 토대로 "어느 섹터부터 얼만큼 읽어라"라는 표준화된 I/O 요청 객체인 bio 구조체를 생성합니다.
5
I/O Scheduler. 생성된 요청들을 무작정 보내지 않고 효율적인 순서로 정렬하거나 병합합니다. 물리적 디스크 헤드의 이동 경로를 최소화하는 방향으로 최적화합니다.
6
Block Device Driver. 최종적으로 스케줄링된 요청을 받아 하드웨어 컨트롤러에 명령을 전송하고, DMA를 세팅하며 완료 인터럽트를 처리합니다.

핵심 O/X 퀴즈

1. VFS는 리눅스가 여러 종류의 파일시스템을 단일한 인터페이스로 취급할 수 있도록 도와주는 계층이다.
OVFS 덕분에 사용자 애플리케이션은 대상이 ext4인지 FAT3인지 알 필요 없이 I/O 작업을 수행할 수 있습니다.
2. 디스크 캐시에 이미 데이터가 존재하더라도 데이터의 무결성을 위해 무조건 실제 디스크를 한 번은 읽어야 한다.
X캐시 히트(Cache Hit)가 발생하면 디스크 접근을 생략하고 메모리에서 즉시 데이터를 반환합니다.
3. 매핑 계층의 주된 역할은 파일 수준의 블록 번호를 실제 장치 수준의 논리 블록 번호로 변환하는 것이다.
O이 변환 과정을 통해 커널은 파일이 디스크의 어느 물리적 위치에 매핑되어 있는지 알아냅니다.
03

섹터, 블록, 페이지, 세그먼트: 같은 데이터를 바라보는 네 개의 눈Figure 14-2

섹터, 블록, 페이지, 세그먼트 — 이들은 모두 커널이 다루는 '데이터의 조각'을 뜻하지만, 처리하는 주체에 따라 기준이 다릅니다. 이 용어들의 미묘한 차이를 명확하게 정립해 두어야 이후 I/O 흐름을 이해하기 수월해집니다.

네 가지 단위의 명확한 정의
섹터 (sector)
하드웨어 컨트롤러가 데이터를 전송하는 가장 기본적인 단위입니다. 리눅스 커널 내부에서는 관례적으로 이 크기를 512바이트로 고정하여 사용합니다. 실제 하드웨어가 4KB 섹터를 사용하더라도, 하위 드라이버 단에서 적절히 변환하여 처리합니다.
블록 (block)
VFS 및 파일시스템이 데이터를 관리하고 취급하는 논리적 단위입니다. 크기는 반드시 2의 거듭제곱이어야 하며, 시스템 페이지 프레임 크기를 초과할 수 없고 섹터 크기의 정수배여야 합니다. (예: 512, 1024, 2048, 4096 바이트)
페이지 (page)
메모리 관리 하위 시스템과 디스크 캐시가 동작하는 단위입니다. 예를 들어 4KB 페이지 하나에는 1KB 크기의 블록 버퍼 네 개가 꽉 차게 들어갈 수 있습니다.
세그먼트 (segment)
블록 디바이스 드라이버가 DMA 전송을 수행할 때 바라보는 메모리 조각입니다. 보통 하나의 메모리 페이지 전체이거나 그 일부이며, 디스크 상에서 물리적으로 인접한 섹터들의 데이터를 담게 됩니다.

하나의 동일한 아파트 단지를 두고 행정기관은 '동' 단위로 파악하고, 택배 회사는 '배송 권역'으로 관리하며, 거주자는 개별 '호수'로 인식하는 것과 같습니다. 하드웨어는 섹터, 파일시스템은 블록, 캐시는 페이지, 드라이버는 세그먼트 단위로 대상을 바라보며, Generic Block Layer는 이 각기 다른 시선들을 하나로 이어주는 번역기 역할을 합니다.

Figure 14-2를 상기해 보면, 동일한 RAM 영역일지라도 계층에 따라 어떻게 해석되는지 잘 알 수 있습니다. 4KB 페이지 하나가 파일시스템 관점에서는 1KB 블록 버퍼 네 개로 나뉘어 보이지만, 그중 뒤쪽 3KB 영역이 단일 세그먼트로 묶여 컨트롤러 관점에서는 512바이트 크기의 섹터 여섯 개로 다뤄지게 됩니다.

핵심 O/X 퀴즈

1. 리눅스의 블록 I/O 하위 시스템에서 섹터의 크기는 관례적으로 512바이트 단위로 고정하여 표기한다.
O실제 물리 하드웨어가 더 큰 단위의 섹터를 쓰더라도 커널 내부 인터페이스에서는 512바이트 기준으로 소통하며 드라이버가 이를 중재합니다.
2. 하나의 하드디스크 내에 존재하는 모든 파티션은 예외 없이 동일한 파일시스템 블록 크기를 가져야만 한다.
X파티션마다 서로 다른 파일시스템으로 포맷될 수 있으므로, 각 파티션은 독립적인 블록 크기를 가질 수 있습니다.
3. Generic Block Layer는 섹터, 블록, 페이지, 세그먼트라는 서로 다른 관점의 단위들을 매끄럽게 연결해주는 중추적인 역할을 수행한다.
O각 계층이 사용하는 이질적인 단위들을 상호 변환하고 이어주는 핵심 접착제입니다.
04

DMA와 세그먼트: 드라이버는 블록을 모른다, 전송할 메모리 조각을 본다SCATTER-GATHER DMA

대부분의 디스크 I/O 작업은 '디스크상의 인접한 섹터 영역'과 '메모리(RAM)상의 특정 영역' 간의 데이터 교환으로 이루어집니다. 이때 CPU가 일일이 바이트 단위로 데이터를 옮긴다면 막대한 시스템 오버헤드가 발생하므로, 컨트롤러가 메모리에 직접 접근하여 전송을 대신 처리하는 DMA (Direct Memory Access) 기술이 필수적으로 쓰입니다.

DMA 방식 비교
단순 DMA
메모리상에서도 물리적으로 완전히 연속된 공간에 대해서만 전송이 가능한 구형 방식입니다. 구식 컨트롤러 환경에서 주로 발견됩니다.
Scatter-gather DMA
디스크의 연속된 섹터에서 데이터를 읽어오되, 이를 메모리의 여러 흩어진(비연속적인) 영역에 나누어 담거나 반대로 모아 쓸 수 있는 최신 기법입니다. 드라이버는 컨트롤러에 시작 섹터와 총 섹터 수, 그리고 데이터를 담을 메모리 주소 및 길이의 '목록'을 한 번에 전달합니다.

블록 디바이스 드라이버 입장에서는 파일시스템의 블록 크기나 블록 버퍼의 존재를 굳이 알 필요가 없습니다. 상위 계층에서는 페이지 하나를 잘게 쪼개어 여러 블록 버퍼로 관리하더라도, 가장 아래쪽의 드라이버는 단순히 "이 메모리 세그먼트들을 하드웨어로 밀어 넣으면 된다"는 사실에만 집중합니다. 논리적 파일시스템의 세계와 물리적 하드웨어 전송의 세계가 이 지점에서 완벽하게 분리됩니다.

여기서 짚고 넘어가야 할 또 다른 중요 개념이 세그먼트 병합(Segment Merging)입니다. 만약 RAM의 특정 페이지 프레임들이 물리적으로 연속되어 있고, 대응하는 디스크의 대상 영역도 인접해 있다면, Generic Block Layer는 잘게 나뉜 세그먼트들을 하나의 더 큰 물리적 세그먼트(physical segment)로 통합해 전송 효율을 극대화합니다.

핵심 O/X 퀴즈

1. DMA는 디스크 컨트롤러가 귀중한 CPU 자원을 소모하지 않고 메모리에 직접 접근해 데이터를 복사하는 효율적인 메커니즘이다.
O이를 통해 대용량 I/O 중에도 CPU는 다른 작업을 계속 수행할 수 있습니다.
2. Scatter-gather DMA 방식은 메모리상에 흩어져 있는 여러 비연속적인 영역들을 단 한 번의 컨트롤러 전송 작업으로 묶어서 처리할 수 있게 해준다.
O메모리 영역들의 리스트(주소와 길이의 쌍)를 작성해 컨트롤러에 넘겨주어 효율을 극대화합니다.
3. 블록 디바이스 드라이버가 I/O 요청을 올바르게 수행하려면 해당 파티션의 파일시스템 블록 크기를 반드시 사전에 숙지하고 있어야 한다.
X드라이버는 메모리 조각인 '세그먼트' 단위로만 데이터를 이해할 뿐, 상위 계층의 '블록' 개념에는 관여하지 않습니다.
05

Generic Block Layer와 bio: 블록 I/O 요청의 표준 포장지THE bio STRUCTURE

커널 내부에서 수많은 I/O 요청이 이리저리 이동할 때 단순히 구두로 "이거 읽어줘"라고 지시할 수는 없습니다. 택배 화물에 도착지 주소와 내용물 정보를 명시한 표준 송장이 부착되듯이, 블록 I/O 요청 역시 처리 규격에 맞춘 단일화된 포장지가 필요합니다. 그 핵심 구조체가 바로 bio입니다.

bio 구조체의 주요 필드
bi_sector
접근하고자 하는 디스크 상의 첫 시작 섹터 번호.
bi_bdev
요청의 대상이 되는 논리적 블록 디바이스 객체를 가리키는 포인터.
bi_rw
데이터의 전송 방향을 나타내는 플래그 (READ 또는 WRITE).
bi_io_vec
개별 세그먼트 정보를 담은 bio_vec 배열을 가리키는 포인터.
bi_vcnt
배열에 포함된 bio_vec 세그먼트의 총 개수.
bi_idx
요청 처리 중 아직 전송되지 않은 첫 번째 세그먼트의 인덱스를 지속적으로 추적.
bi_end_io
모든 전송 작업이 성공 혹은 실패로 완전히 끝났을 때 커널이 호출해 줄 콜백 함수.
bio_vec 구조체 — 단일 세그먼트의 명세서
bv_page
해당 세그먼트의 실제 데이터가 존재하는 물리적 메모리 페이지 프레임 포인터.
bv_len
이 세그먼트가 차지하는 데이터 길이 (바이트 단위).
bv_offset
페이지의 시작점에서 이 세그먼트가 위치한 실제 오프셋.

bio가 개별적인 I/O 요구 사항을 담은 원재료라면, 이를 하나로 묶어 다루기 쉽게 만든 배송 패키지가 request입니다. bio는 성능을 위해 주로 슬랩 할당자(slab allocator)를 통해 매우 빠르게 생성(bio_alloc())되며, 극단적인 메모리 부족 상황에서도 시스템이 멈추지 않게끔 작고 독립적인 메모리 풀을 예비로 유지합니다. 참조 카운트가 0이 되면 bio_put()을 통해 안전하게 해제됩니다.

중요한 점은 하나의 bio 요청이 반드시 한 번의 하드웨어 전송으로 곧장 완료되지는 않는다는 것입니다. 전송이 쪼개어 일어날 경우 bi_idx 필드가 다음 전송 타깃을 가리키며 갱신되고, 드라이버는 bio_for_each_segment 같은 매크로를 이용해 남은 세그먼트 조각들을 하나씩 성실하게 처리합니다.

핵심 O/X 퀴즈

1. bio 구조체는 타겟 디스크의 섹터 위치 정보와 메모리에 매핑될 세그먼트 정보를 단일 구조 안에 모두 포함한다.
O디스크 위치(bi_sector)와 메모리 조각 리스트(bi_io_vec)가 함께 결합되어 전달됩니다.
2. 단일 세그먼트를 설명하는 bio_vec 구조체는 메모리 페이지 포인터, 길이, 그리고 해당 페이지 내의 오프셋 정보를 담고 있다.
O이 세 가지 정보(bv_page, bv_len, bv_offset)만 있으면 메모리상 정확한 영역을 특정할 수 있습니다.
3. 한 번 생성된 bio 구조체 내부의 값들은 I/O 작업이 완전히 종료될 때까지 어떠한 수정도 가해지지 않는 불변의 상태를 유지한다.
X작업이 쪼개어 처리될 수 있으므로, 커널은 진행 상황을 추적하기 위해 bi_idx 등의 필드를 계속해서 업데이트합니다.
06

gendisk와 파티션: 커널이 물리적 디스크를 인지하고 관리하는 방식DISK DESCRIPTOR

단순한 bio 구조체 하나만으로는 온전한 블록 I/O 처리가 불가능합니다. 시스템에는 여러 디스크가 존재하므로 "이 요청이 정확히 어느 장치로 가야 하는가?", "이 디스크의 주(major) 번호는 무엇인가?", "요청을 쌓아둘 전용 큐는 어디에 있는가?" 같은 전반적인 메타데이터가 필요합니다. 디스크 전체의 거시적 정보를 대변하는 객체가 바로 gendisk입니다.

gendisk 핵심 구성 요소
major
이 장치를 대표하는 드라이버의 주(major) 번호.
first_minor / minors
이 디스크가 사용할 수 있는 부(minor) 번호의 범위.
disk_name
시스템 장치 노드에서 흔히 보이는 관습적인 문자열 이름 (예: "sda", "hda").
capacity
디스크의 총 용량을 512바이트 섹터 단위 개수로 표현한 값.
queue
해당 디스크를 위해 대기 중인 요청들이 쌓일 I/O 큐(request_queue) 포인터.
fops
블록 장치에 특화된 연산 메서드(open, release, ioctl 등)가 정의된 테이블 포인터.
part
디스크 내부의 개별 파티션 정보(hd_struct)를 가리키는 배열.

비유하자면 gendisk는 하드디스크라는 기기 전체를 나타내는 '신분증'이고, hd_struct는 그 기기 안을 쪼개놓은 파티션별 '주민등록표'와 같습니다. 만약 gendisk 객체에 GENHD_FL_UP 플래그가 세워져 있다면 커널에 의해 정상적으로 활성화되었음을 뜻하고, GENHD_FL_REMOVABLE이 켜져 있다면 언제든 탈착이 가능한 USB나 CD-ROM 같은 장치임을 나타냅니다.

하나의 통짜 하드디스크는 보통 여러 논리 파티션으로 분할되어 사용됩니다. 통상 디스크 전체와 각 파티션은 같은 major 번호를 공유하되 minor 번호를 연속적으로 할당받아 구분됩니다. hd_struct 구조체 안에는 파티션이 시작되는 절대 섹터 번호(start_sect)와 총 크기(nr_sects), 그리고 통계 수치 등이 포함됩니다.

새로운 물리적 장치가 연결되어 커널이 이를 감지하면, 드라이버는 alloc_disk() 함수를 호출해 gendisk 공간을 확보하고 파티션 배열도 초기화합니다. 모든 세팅이 끝나면 마지막으로 add_disk()를 호출함으로써 비로소 이 장치가 시스템 블록 I/O 생태계에 정식 등록됩니다.

핵심 O/X 퀴즈

1. gendisk 구조체는 실제 존재하는 물리적 디스크 장치뿐 아니라 디스크처럼 에뮬레이션되는 논리적 장치 영역을 표현하는 데에도 사용된다.
O램디스크(RAM Disk)나 가상 블록 디바이스 등 커널이 블록 장치로 취급하는 모든 객체가 gendisk로 포장됩니다.
2. 디스크의 파티션 세부 정보는 전송을 위한 메모리 조각 리스트인 bio_vec 배열 내에 보관된다.
X파티션 정보는 전용 디스크립터인 hd_struct 배열에 기록되며, gendisk의 part 필드가 이 배열을 관리합니다.
3. hd_struct 구조체는 개별 파티션의 시작 물리 섹터 번호와 포함된 섹터의 개수 등 위치 정보를 보관한다.
O커널이 요청받은 파티션 기준의 오프셋을 디스크 전체의 절대 오프셋으로 변환할 때 이 start_sect 정보가 핵심적으로 쓰입니다.
07

요청 제출: bio_alloc()에서 generic_make_request()까지REQUEST SUBMISSION PATH

매핑 계층이 "필요한 파일 데이터가 디스크 물리 공간 어디에 존재하는지"를 정확히 계산해 냈다면, 커널은 본격적으로 Generic Block Layer에 해당 작업을 발주해야 합니다. 이 발주 프로세스를 관장하는 중추적인 진입 함수가 바로 generic_make_request()입니다.

1
bio 생성. bio_alloc()을 호출해 빈 구조체를 확보한 뒤 요청 명세를 작성합니다. 시작 섹터, 타깃 디바이스, 데이터 세그먼트 배열, 방향 플래그(READ/WRITE), 콜백 함수 등을 꼼꼼히 채워 넣습니다.
2
섹터 범위 유효성 검사. generic_make_request()는 넘어온 bio의 요청 범위가 대상 장치의 물리적 끝을 넘어서지 않는지 검증합니다. 한계를 벗어났다면 요청을 즉각 거부하고 에러 상태로 콜백을 발생시킵니다.
3
대상 큐(Queue) 확보. bio에 기록된 타깃 장치를 추적해 연결된 gendisk 객체를 찾고, 그에 종속된 request_queue 포인터를 획득합니다.
4
파티션 기준 오프셋 보정. 요청이 특정 파티션 기준으로 작성되었다면 blk_partition_remap()을 거쳐 전체 물리 디스크 관점의 절대 섹터 번호로 환산합니다. 이후의 하위 계층들은 파티션의 존재를 완전히 잊고 통짜 디스크로만 취급합니다.
5
요청 적재 (make_request_fn). 변환된 요청을 실제 큐에 삽입하는 함수(주로 __make_request())로 넘깁니다. 이 시점부터 I/O 스케줄러의 복잡한 정렬 및 병합 마법이 시작됩니다.

핵심 O/X 퀴즈

1. generic_make_request()는 생성된 I/O 요청을 Generic Block Layer 하부로 본격적으로 밀어 넣는 공식적인 진입점이다.
O기본적인 범위 안전성 검사와 디바이스 매핑을 한 뒤 스케줄러 영역으로 요청을 이관합니다.
2. 파티션을 대상으로 발생한 요청은 하드웨어 컨트롤러에 도달할 때까지 파티션 기준의 로컬 오프셋 번호를 끝까지 유지한다.
X하부 I/O 계층과 디스크 컨트롤러는 파티션을 이해하지 못하므로, 사전에 전체 디스크 기준의 절대 섹터 번호로 맵핑(remap)이 선행되어야 합니다.
3. bio 구조체 내부의 bi_end_io 포인터는 해당 I/O 작업의 성공이나 실패가 확정되었을 때 커널이 사후 처리를 위해 호출해 줄 콜백 함수를 가리킨다.
O이 콜백 덕분에 요청을 발생시킨 프로세스는 작업 완료 통보를 비동기적으로 받을 수 있습니다.
08

I/O Scheduler의 존재 이유: 기다림의 미학, 미루는 것이 오히려 더 빠르다WHY SCHEDULING MATTERS

커널은 기껏 전달받은 디스크 I/O 요청들을 즉시 하드웨어 컨트롤러로 쏘아 보내지 않는 경우가 허다합니다. 이는 시스템이 처리할 능력이 없어서 밀리는 것이 아니라, 오히려 전체적인 처리량을 극대화하기 위해 다분히 의도적으로 요청들을 대기시키는 것입니다.

자기 디스크(HDD)의 특성상 목표한 섹터로 물리적 헤드를 이동시키는 작업(Seek)은 어마어마한 시간이 소모됩니다. 무작위 순서로 A, Z, B, Y 구역의 요청이 차례로 들어올 때 이를 곧이곧대로 처리하면 헤드가 디스크 전체를 정신없이 왕복해야 합니다. 그러나 잠시 큐에 담아두고 기다리면 커널이 이를 A, B, Y, Z와 같이 물리적으로 가까운 동선 순으로 매끄럽게 재배열할 수 있습니다.

I/O 스케줄러가 '엘리베이터(Elevator)'라는 별칭으로 불리는 이유도 이와 같습니다. 건물 엘리베이터가 먼저 버튼을 누른 층 순서대로 움직이지 않고, 현재 이동하는 방향의 경로상에 있는 층들을 묶어서 한 번에 처리하는 효율성을 본뜬 것입니다.

또한 스케줄러는 단순히 순서만 바꾸는 데 그치지 않고, 새로 유입된 요청의 대상 영역이 기존 대기 중인 요청 영역과 연속되어 있다면 두 요청을 아예 커다란 하나의 덩어리로 합쳐(Merge) 버립니다. 이렇게 하면 추가적인 탐색(Seek) 비용 없이 한 번의 큰 전송만으로 여러 개의 요청을 동시에 소화해낼 수 있습니다. 실무 I/O 패턴은 순차적 접근이 주를 이루기 때문에 이 병합 전략은 극적인 성능 향상을 가져옵니다.

결국 I/O Scheduler는 물리적인 디스크 헤드의 불필요한 움직임을 극한으로 줄이기 위해 요청을 분류, 정렬, 병합하는 커널 내의 교통 관제탑입니다. 블록 I/O는 이 스케줄러를 거치며 비동기적으로 제어되고, 물리 디스크마다 고유의 독립된 request queue와 스케줄러 인스턴스를 유지합니다.

핵심 O/X 퀴즈

1. I/O 스케줄러의 궁극적인 설계 목표는 무작위로 쏟아지는 요청들을 물리적 위치 순으로 정렬하고 병합하여 디스크의 값비싼 탐색(Seek) 비용을 최소화하는 것이다.
O헤드 이동을 줄이는 것이 고전적인 블록 I/O 최적화의 알파이자 오메가입니다.
2. 사용자의 응답성을 최상으로 끌어올리기 위해서는 디스크 요청이 발생하는 즉시 일말의 지연도 없이 하드웨어에 전달하는 설계가 가장 이상적이다.
X단일 요청의 응답성은 조금 떨어지더라도, 약간 지연시키며 다른 요청과 병합하는 편이 전체 시스템 처리량(Throughput) 면에서 압도적으로 유리합니다.
3. 블록 디바이스 드라이버는 스케줄링된 요청 전송을 하드웨어에 지시한 뒤 결과를 끝까지 기다리지 않고 반환하며, 실제 완료 처리는 인터럽트에 의해 비동기적으로 이루어진다.
ODMA 전송을 하드웨어에 위임한 CPU는 곧바로 다른 작업을 처리하러 떠납니다.
09

request_queue와 request: 스케줄러가 조율하는 물리적 대기열 구조QUEUE STRUCTURES

I/O 스케줄러가 실제로 어떤 그릇을 가지고 작업을 조율하는지 살펴보겠습니다. 핵심적인 커널 구조체는 두 개입니다. 요청들이 담기는 거대한 통인 request_queue, 그리고 그 큐 안에서 개별 작업 단위를 형성하는 request 디스크립터입니다.

request_queue 구조체의 핵심 멤버들
elevator
이 특정 큐에 바인딩되어 현재 동작 중인 I/O 스케줄러 알고리즘 객체 포인터.
request_fn
스케줄링이 완료된 요청들을 최종적으로 드라이버로 내려보낼 때 호출되는 전략 루틴(Strategy routine) 진입점.
make_request_fn
새롭게 생성된 요청을 이 큐로 처음 밀어 넣고자 할 때 호출되는 함수.
back/front_merge_fn
새로 들어온 bio 객체가 이미 큐에서 대기 중인 request의 앞부분이나 뒷부분과 연속되어 하나로 병합될 수 있는지 판별하는 로직.
unplug_fn
잠시 멈춰둔(Plugging) 큐의 문을 다시 열어 드라이버 전송을 본격적으로 재개시킬 때 호출되는 함수.
max_sectors 등
하드웨어 컨트롤러가 한 번에 감당할 수 있는 최대 요청 크기나 DMA 세그먼트 개수 등의 물리적 제약 정보.
request 구조체 — 여러 bio들의 집합체
bio / biotail
이 request 묶음에 병합되어 있는 첫 번째 bio와 마지막 bio를 가리키는 연결 리스트 포인터.
flags (REQ_RW)
현재 묶음이 디스크 읽기인지 쓰기인지 등 I/O의 성격과 방향성을 나타내는 플래그.
sector
현재 이 request 덩어리 안에서 다음 차례로 전송되어야 할 타깃 섹터 번호.
nr_sectors
이 request 내에 아직 전송이 완료되지 않고 남아있는 잔여 섹터의 총합.
queuelist
이 request 객체 자체를 앞뒤 다른 request들과 이어주는 스케줄러 큐 내부용 이중 연결 리스트 노드.

단일 bio를 배송할 개별 택배 상자라고 비유한다면, request는 도착지 동선이 같은 상자들을 한데 모아 실은 대형 화물 트럭입니다. request_queue는 트럭들이 질서정연하게 출발을 기다리는 터미널에 해당하며, I/O 스케줄러는 어느 트럭에 어떤 상자를 추가로 싣고 어떤 트럭을 가장 먼저 출발시킬지 결정하는 노련한 배차 소장입니다.

핵심 O/X 퀴즈

1. 단일 request 구조체 안에는 연속적인 위치를 가리키는 여러 개의 bio 구조체들이 연결 리스트 형태로 묶여 포함될 수 있다.
O이것이 바로 I/O 병합(Merging)의 본질입니다.
2. request_queue 구조체 내부의 request_fn 포인터는 일반적으로 드라이버 개발자가 직접 작성한 디바이스 전략 루틴(Strategy routine)을 가리킨다.
O장치 초기화 시점에 커널 API를 통해 이 포인터가 드라이버의 전송 시작 함수로 세팅됩니다.
3. request 내부의 REQ_RW 플래그는 해당 요청이 어느 파티션에서 유래했는지를 구분 짓기 위해 존재하는 태그이다.
X파티션 정보가 아니라 데이터 전송의 방향, 즉 이 묶음이 일괄 Read인지 일괄 Write인지를 명시하는 플래그입니다.
10

큐 혼잡, plugging, unplugging: 성급하게 보내지도, 너무 오래 막지도 않는 절묘한 줄다리기CONGESTION & TIMING

요청 큐는 요술 주머니가 아닙니다. 물리 메모리 기반의 유한한 공간이므로 무한정 I/O 요청을 수용할 수는 없습니다. 시스템에 과부하가 걸려 디스크 I/O 작업이 폭주하면, 새로운 요청을 큐에 삽입하려는 상위 프로세스들은 빈 슬롯을 얻지 못한 채 블록(Block) 상태에 빠지게 됩니다. 커널은 기본적으로 하나의 큐에 대해 Read와 Write 각각 최대 128개까지의 대기열(pending request)을 허용하며, 이 임계치를 초과하면 해당 큐는 포화 상태(full)로 간주되어 더 이상 요청을 받지 않습니다.

Plugging / Unplugging 메커니즘의 정체
plug (마개 막기)
큐에 처리할 요청이 들어와도 하단 드라이버가 이를 즉시 가져가지 못하도록 임시로 문을 걸어 잠급니다. 이는 후속 요청들이 충분히 들어와 유의미한 규모의 '병합' 덩어리가 형성되길 기다리는 전략적 지연입니다. 커널은 QUEUE_FLAG_PLUGGED 상태로 전환하고 짧은 타이머를 시작시킵니다.
unplug (마개 뽑기)
큐에 충분한 양(기본 4개)의 요청이 쌓였거나, 타이머가 만료(약 3ms)되었거나, 당장 완료를 기다리는 동기식(Sync) 요청이 도착했을 때 문을 다시 엽니다. 이 순간 kblockd 워크큐 스레드나 관련 로직이 개입해 드라이버의 전략 루틴을 호출하여 억눌려있던 I/O 전송을 폭발적으로 시작합니다.

너무 성급하게 요청 하나마다 컨트롤러를 깨우면 병합의 마법을 부릴 기회를 허무하게 날리게 됩니다. 반대로 병합만을 위해 마냥 큐를 닫고 기다리면 전체적인 시스템 지연 시간(Latency)이 심각하게 늘어납니다. Plugging과 Unplugging은 커널이 이 두 모순된 목표 사이에서 최적의 타이밍을 찾아내는 정교한 밸런싱 기법입니다.

또한 큐가 가득 차기 직전까지 가면 커널은 큐가 혼잡(congested) 상태에 진입했다고 판단하여 상위 계층의 페이지 캐시나 VFS 단에서 새로운 I/O 요청의 생성 자체를 조절하도록 간접적인 브레이크 신호를 보냅니다. 통상적으로 대기열이 113개를 넘어서면 혼잡 상태로 표기하고, 다시 111개 이하로 안정화되면 해제 신호를 발생시킵니다.

핵심 O/X 퀴즈

1. Plugging 상태로 전환된 큐는 처리할 요청을 보유하고 있더라도 병합 효율을 위해 드라이버 하단으로 요청을 넘기지 않고 대기하는 상태를 뜻한다.
O잠시 마개를 덮어두어 추가 요청들이 모일 시간을 벌어주는 최적화 기법입니다.
2. Unplugging이라는 용어는 에러가 발생한 I/O 큐 자체를 커널의 메모리 영역에서 영구적으로 폐기하고 삭제한다는 의미이다.
X삭제가 아니라, 막아두었던 마개를 뽑아 드디어 드라이버가 물리적 전송을 시작하도록 활성화(Activate)시킨다는 긍정적인 의미입니다.
3. 큐 혼잡(Congestion) 신호는 VFS나 캐시 관리자와 같은 상위 시스템이 I/O 발생 속도를 능동적으로 조절하게 만드는 피드백 장치로 기능한다.
O이를 통해 시스템은 과부하로 인해 I/O 서브시스템이 완전히 마비되는 현상을 사전에 방지합니다.
11

네 가지 I/O Scheduler: 각기 다른 철학을 지닌 엘리베이터들ELEVATOR ALGORITHMS

리눅스 2.6 기반 커널 환경에서는 워크로드 특성에 맞춰 선택할 수 있는 네 가지 종류의 스케줄러(Elevator)를 제공합니다. '엘리베이터'라는 명칭은 디스크 헤드가 마치 건물 승강기처럼 안쪽 트랙과 바깥쪽 트랙을 유연하게 오가며 대기열을 처리하는 모습에서 유래했습니다. 알고리즘마다 공정성을 중시할지, 대기 지연을 중시할지 그 철학이 확연히 다릅니다.

NOOP 극단적인 단순함

복잡한 정렬 알고리즘 없이 새로 들어온 요청을 대기열의 끝이나 앞단에 단순 병합 및 삽입만 합니다. 스마트한 하드웨어 RAID 컨트롤러나 자체적인 최적화 로직을 갖춘 고성능 SSD 환경에서 오히려 뛰어난 퍼포먼스를 발휘합니다.

CFQ Complete Fairness Queueing — 절대적 공정성

프로세스마다 별도의 큐(총 64개)를 배정하고, Round-robin 방식으로 골고루 기회를 분배하여 특정 무거운 I/O 프로세스가 전체 디스크 대역폭을 독점하는 사태를 원천 차단합니다. 데스크톱 환경의 기본값으로 널리 쓰입니다.

DEADLINE 기아 상태(Starvation) 철저 방지

섹터 위치 기반의 정렬 큐 2개와 타이머 기반의 마감 시한(Deadline) 큐 2개를 병렬로 운영합니다. 특히 읽기(Read) 요청에 500ms의 짧은 시한을 주어(쓰기는 5초), 결과를 애타게 기다리는 프로세스들이 무한정 멈춰있는 현상을 우선적으로 구제합니다.

ANTICIPATORY Deadline의 진화 — 미래를 예측하는 찰나의 기다림

특정 프로세스의 읽기 요청을 막 끝냈을 때 즉시 다음 위치로 떠나지 않습니다. 통계적으로 해당 프로세스가 아주 가까운 인접 섹터에 연속적인 후속 요청을 던질 확률이 높으므로, 디스크 헤드를 멈추고 약 7ms 정도 허공에서 '예측 대기'를 수행하여 막대한 탐색 비용을 극적으로 아낍니다.

결국 스케줄러는 단순히 짐을 쌓아두는 창고 관리자가 아니라, 디스크 물리 구동 한계와 프로세스들의 응답성 요구 사이에서 아슬아슬한 타협점을 찾아내는 정책 관리자입니다. 플래시 메모리 시대가 도래하며 Noop이나 Deadline 계열의 비중이 커졌지만, 여전히 이들이 제안한 정렬과 병합의 사상은 I/O 하위 시스템의 근간을 이룹니다.

핵심 O/X 퀴즈

1. CFQ 스케줄러의 핵심 철학은 어떤 단일 프로세스도 디스크 전체 대역폭을 독점하지 못하도록 공정하게 I/O 기회를 배분하는 것이다.
O다수의 내부 큐를 운용하며 Round-robin으로 균형감 있게 자원을 배분합니다.
2. Deadline 스케줄러는 성능을 다소 양보하더라도 특정 I/O 요청이 영원히 처리되지 않고 방치되는 Starvation 현상을 막기 위해 고안되었다.
O특히 동기적 성향이 강한 읽기 작업에 훨씬 엄격한 마감 시간(500ms)을 부여해 응답성을 보장합니다.
3. Anticipatory 알고리즘은 단 1밀리초의 지연도 허용하지 않는 엄격함으로, 현재 요청이 끝나자마자 어떠한 멈춤도 없이 즉각 다음 큐를 꺼내 처리한다.
X오히려 이름(Anticipatory) 그대로 다음 인접 요청이 들어올 것을 예측해 의도적으로 7ms가량 작업을 멈추고 기다리는 독특한 지연 전략을 사용합니다.
12

__make_request()와 병합의 기술: 묶을 것인가, 새로 만들 것인가MERGE LOGIC

generic_make_request()가 기본적인 문지기 역할을 끝내면, 실제 큐 삽입의 뼈대 로직인 __make_request()가 바통을 넘겨받습니다. 여기서 맞닥뜨리는 핵심 과제는 단순명료합니다. "방금 도착한 이 bio를 새로운 request 트럭에 실을 것인가? 아니면 이미 대기 중인 트럭의 화물칸에 이어서 밀어 넣을 것인가?"

1
바운스 버퍼(Bounce buffer) 처리. 타깃 메모리 영역을 물리 하드웨어가 직접 주소 지정하지 못할 경우, 접근 가능한 영역에 임시 버퍼 공간을 마련하는 작업을 선행합니다.
2
비어있는 큐의 사전 플러깅(Plugging). 만약 대기열이 완전히 비어있다면, 드라이버가 곧장 낚아채는 것을 막기 위해 일단 큐 마개를 닫습니다. 방금 도착한 요청 혼자 외롭게 떠나지 않고 뒤따라올 요청들과 병합될 기회를 열어두는 것입니다.
3
병합 가능성 심사. 스케줄러의 elv_merge() 알고리즘을 호출해 판정을 받습니다. 결과는 세 가지로 나뉩니다. 기존 요청 덩어리의 뒤에 붙이기(BACK_MERGE), 앞에 붙이기(FRONT_MERGE), 또는 병합 불가 판정(NO_MERGE).
4
퍼즐 맞추기(연쇄 병합 시도). 새로운 bio를 기존 request의 한쪽 끝에 성공적으로 붙였다면, 그로 인해 길어진 화물 덩어리가 또 다른 request 덩어리와 맞닿아 아예 통째로 합쳐질 가능성이 생기므로 추가적인 연쇄 병합 심사를 이어갑니다.
5
최종 독립 request 할당. 어떻게든 묶을 구석이 없다면 그제서야 어쩔 수 없이 새 request 구조체를 할당받고, bio를 단독 적재하여 큐에 진입시킵니다. 만약 즉각적인 동기적 처리가 필요한 작업(BIO_RW_SYNC)이라면 큐의 마개를 바로 뽑아버리기도 합니다.

연쇄 병합 로직은 마치 이 빠진 테트리스를 맞추는 것과 같습니다. 비어있던 단 하나의 블록(bio)을 끼워 넣는 순간, 왼쪽 조각 모음과 오른쪽 조각 모음이 완전히 맞물리며 단숨에 거대한 하나의 라인(단일 request)으로 합쳐지는 쾌감이 블록 I/O 시스템 내부에서 끊임없이 벌어지고 있습니다.

한 가지 특이한 케이스로, BIO_RW_AHEAD 플래그가 찍힌 미래 예측용 '미리 읽기(Read-ahead)' 요청의 경우 시스템 메모리가 넉넉하지 않은 상황이라면 커널은 미련 없이 이를 즉시 포기(Drop)하고 에러 콜백을 반환합니다. 어차피 당장 급한 데이터가 아닌 일종의 보험성 요청이므로 굳이 자원을 무리하게 소모할 필요가 없기 때문입니다.

핵심 O/X 퀴즈

1. __make_request() 내부 흐름의 가장 중요한 분기점은 새로 유입된 bio를 기존 대기열의 요청 덩어리들과 병합할 수 있는지 판별하는 과정이다.
Oelv_merge()를 통해 가능하면 기존 트럭에 화물을 끼워 넣는 전략을 최우선으로 구사합니다.
2. ELEVATOR_BACK_MERGE 판정이 내려지면 이는 새 bio를 기존 대기 요청 덩어리의 가장 첫머리(Front) 부분에 밀어 넣어야 함을 의미한다.
XBACK_MERGE는 기존 화물의 뒷부분(꼬리)에 이어 붙이는 것이고, 앞머리에 붙이는 동작은 FRONT_MERGE라고 명명되어 있습니다.
3. 처리 결과 반환을 기다려야 하는 동기식 요청(BIO_RW_SYNC)이 대기열에 진입하면, 커널은 성능 저하를 감수하고서라도 지연 없이 큐 마개를 뽑아(Unplug) 드라이버를 즉시 구동시키려 시도한다.
O동기식 I/O는 프로세스 자체를 블록 상태로 만들기에 응답성 확보가 지연 최적화보다 우선순위가 높습니다.
13

Bounce buffer: 구형 하드웨어를 위한 커널의 헌신적인 임시 우회로MEMORY CONSTRAINTS

이상적인 세계라면 하드웨어 컨트롤러가 시스템에 장착된 모든 물리 RAM 공간을 마음껏 들여다보고 데이터를 쏴줄 수 있어야 합니다. 하지만 현실에서는 버스 대역 제한(예: 구형 ISA 버스의 24비트 16MB 한계) 탓에 접근 범위가 지극히 제한된 레거시 하드웨어들이 존재합니다. 커널은 이들을 포기하지 않고 우회로인 바운스 버퍼(Bounce buffer)를 구성해 줍니다.

1
제약 조건 감지. blk_queue_bounce() 함수가 타깃 큐의 주소 제한 임계값을 검사하여, 원본 bio가 요구하는 메모리 영역이 하드웨어의 도달 불가능 구역(High memory 등)에 속해있는지 판단합니다.
2
바운스 전용 bio 복제. 제약에 걸린다면, 하드웨어가 닿을 수 있는 안전한 메모리 구역(ZONE_NORMAL이나 ZONE_DMA)에 새로운 페이지 공간을 임시로 임대합니다. 그리고 원본을 복제한 새로운 바운스 bio를 만들어 이 임시 페이지를 목적지로 가리키게 합니다.
3
쓰기(Write)의 중계. 디스크에 데이터를 기록하려는 상황이라면, 커널이 먼저 원본 High memory 영역의 데이터를 이 임대된 안전 구역(바운스 버퍼)으로 열심히 복사해 줍니다. 그런 뒤 하드웨어 컨트롤러에게 이 안전 구역을 대상으로 DMA를 지시합니다.
4
읽기(Read)의 사후 전달. 하드웨어는 당연히 지정받은 안전 구역(바운스 버퍼)에 읽어온 데이터를 붓고 작업을 마칩니다. 그 직후 바운스 전용 콜백 루틴이 작동하여, 임시 버퍼에 담긴 따끈따끈한 데이터를 비로소 프로세스가 원래 요구했던 High memory 원본 위치로 안전하게 끌어올려 복사해 준 뒤 임대 구역을 파기합니다.

건물 경비실 택배 보관소와 똑같습니다. "보안 규칙상 택배기사(구형 하드웨어)님은 건물 10층(High memory)까지 직접 배달하실 수 없습니다. 1층 경비실 보관소(Bounce buffer)에 짐을 두고 가시면, 나중에 관리인(커널)이 수레를 끌고 10층 원주인에게 가져다드리겠습니다." 과정의 낭비가 심하지만 호환성 유지를 위해 감수해야만 하는 필연적 우회로입니다.

복제 과정을 거치며 새롭게 탄생한 바운스 bio는 자신이 진짜 원본이 아님을 잊지 않기 위해 BIO_BOUNCED 플래그를 이마에 붙이고, 사후 뒤처리를 위해 bi_private 필드 주머니 속에 몰래 원본 bio의 포인터 주소를 간직한 채 큐를 향해 흘러갑니다.

핵심 O/X 퀴즈

1. 바운스 버퍼(Bounce buffer) 기법은 하드웨어 컨트롤러가 시스템의 특정 물리 메모리 주소 대역에 DMA 접근 권한이나 능력이 없을 때 발동되는 호환성 유지 수단이다.
O한계가 명확한 하드웨어도 현대적인 대용량 메모리 시스템 환경에서 문제없이 동작하도록 돕는 중계 장치입니다.
2. 디스크에 데이터를 기록(Write)하는 흐름에서 바운스 버퍼가 개입하면, 커널은 원본 버퍼의 데이터를 컨트롤러가 접근 가능한 안전한 임시 버퍼 공간으로 일일이 복사하는 추가적인 수고를 감내해야 한다.
O이러한 메모리 카피 오버헤드 때문에 바운스 버퍼 메커니즘은 필연적인 성능 페널티를 동반하게 됩니다.
3. 바운스 처리를 위해 새로 파생된 임시 bio는 원래의 원본 bio와 연결고리가 완전히 단절되어 독립적으로만 소모되고 사라진다.
X작업이 끝난 뒤 원본 버퍼로 데이터를 덮어쓰거나 최종 완료 보고를 수행하기 위해 bi_private 필드에 원본 주소를 단단히 묶어둡니다.
14

block_device와 bdev 파일시스템: 논리적 껍데기가 통일성을 유지하는 비결Figure 14-3

앞서 배운 gendisk가 '이런 디스크가 시스템에 장착되어 있다'를 선언하는 물리적 신분증이라면, block_device는 '커널 내 어떤 구성요소가 지금 이 블록 장치 영역을 활발하게 다루고 있다'를 추적하기 위한 동적인 논리 상태 객체에 가깝습니다. 이는 통짜 디스크 전체일 수도 있고, 특정 조각난 파티션일 수도 있습니다.

block_device 상태 객체의 요주의 필드들
bd_dev
이 장치를 유일하게 식별해 내는 주/부(major/minor) 번호의 조합.
bd_inode
커널 내부에 숨겨진 특수 파일시스템인 bdev 파일시스템의 inode 객체와 연결된 포인터.
bd_openers
현재 시스템 전체에서 이 장치 공간이 몇 번이나 개방(Open)되어 사용 중인지를 집계하는 참조 카운터.
bd_contains
이 객체가 파티션이라면 부모가 되는 '전체 디스크'의 block_device 주소를 담고, 원래부터 전체 디스크 객체라면 자기 자신의 주소를 품어 정체성을 확립합니다.
bd_part
파티션의 경우 hd_struct 구조체를 연결해 주며, 통짜 디스크라면 NULL로 비워둡니다.
bd_holder
이 장치를 독점적이거나 특별한 권한으로 점유하고 있는 주체(예: 마운트 로직을 수행한 특정 파일시스템)의 서명을 기록합니다.

왜 굳이 bdev라는 숨겨진 가상 파일시스템이 필요할까요? 관리자의 실수나 의도에 따라 똑같은 하드디스크 장치를 가리키는 디바이스 파일(예: /dev/sda, /tmp/my_disk_link)이 여러 경로와 이름으로 수십 개씩 존재할 수 있기 때문입니다. 경로가 다르더라도 major/minor 번호가 동일하다면, bdget() 함수가 bdev 파일시스템의 장부를 뒤져서 시스템 전역에서 오직 단 하나만 존재하는 고유한 block_device 객체를 찾아냅니다. 덕분에 커널은 파편화된 장치 파일 이름들에 현혹되지 않고 일관된 상태 관리를 이뤄냅니다.

장치를 배타적으로 획득하고자 할 때는 bd_claim()을 호출하여 bd_holder 자리에 이름을 남기고, 볼 일이 끝나면 bd_release()로 소유권을 반납합니다. 계층 구조를 도식화한 Figure 14-3을 되짚어보면, 전체 디스크용 block_device, 개별 파티션용 block_device, 물리적 gendisk, hd_struct 배열들, 그리고 이를 떠받치는 단일 request_queue가 어떻게 하나의 거대한 그물망으로 엮여 있는지 확인할 수 있습니다.

핵심 O/X 퀴즈

1. 특정 파티션을 관할하는 block_device 객체의 bd_contains 필드는 자신의 모체가 되는 전체 통짜 디스크의 block_device 객체 주소를 가리키고 있다.
O이 부모-자식 연결 고리를 통해 파티션을 열 때 전체 디스크 단위의 관리 작업도 병행될 수 있습니다.
2. bd_holder 필드는 실제 전기적 신호를 주고받으며 디스크 헤드를 움직이는 최하단 디바이스 드라이버 로직 자체를 의미한다.
Xbd_holder는 이 블록 장치 위에서 살림을 차리고 공간을 독점 소유한 논리적 주체, 즉 '마운트된 파일시스템'이나 특수 커널 컴포넌트를 지칭합니다.
3. bdev 가상 파일시스템이 도입된 주된 목적은, 아무리 다양한 경로와 이름의 장치 파일들이 산재해 있어도 동일한 물리적 기기(major/minor 기준)라면 오직 단 하나의 통합된 block_device 디스크립터로 매핑해주기 위함이다.
O동일 장치에 대한 커널 내부 상태의 일관성과 무결성을 사수하는 가장 확실한 방어벽입니다.
15

드라이버 등록과 초기화 과정: 코드가 커널의 공식 기관으로 인정받기까지DRIVER INITIALIZATION

단순한 C 언어 코드가 어엿한 커널의 일원인 '블록 디바이스 드라이버'로 거듭나는 일련의 의식(Initialization) 과정을 따라가 보겠습니다. 이해를 돕기 위해 foo라는 가상의 신규 드라이버를 모델로 삼았습니다. 중요한 것은 각각의 커널 객체들이 어떤 논리적 순서를 밟으며 생성되고 서로 결합되는지 파악하는 것입니다.

1
내부 디스크립터 장만. 드라이버 고유의 상태를 관리할 foo_dev_t 구조체 메모리를 할당받고, 제어할 I/O 포트 영역, 사용될 IRQ 번호, 동기화를 위한 락(spinlock), gendisk 연결 포인터 등을 빈틈없이 세팅합니다.
2
가문 등록 (register_blkdev). register_blkdev(FOO_MAJOR, "foo")를 호출하여 시스템에 주(major) 번호를 선점하고 드라이버의 간판(이름)을 건넸습니다. 이제 /proc/devices 통계표에 새로운 항목이 위풍당당하게 나타납니다.
3
디스크 신분증 발급 (alloc_disk). alloc_disk(16)을 통해 최대 15개(자신 포함 16개)의 파티션을 거느릴 수 있는 gendisk 객체를 커널로부터 받아옵니다. major, minor, 용량(capacity), 장치 이름 등의 빈칸을 꼼꼼히 작성해 넣습니다.
4
행동 강령(fops) 서약. 외부에서 이 장치를 열고, 닫고, 특수 명령(ioctl)을 내릴 때마다 호출될 드라이버 고유의 함수 포인터들(메서드 테이블)을 gendiskfops 필드에 명확히 걸어둡니다.
5
전용 I/O 큐 창설. blk_init_queue()를 호출해 이 드라이버만을 위한 전속 요청 큐를 개설하고, 배차를 통제할 핵심 전략 루틴(foo_strategy)을 진입점(request_fn)으로 단단히 결속시킵니다. 아울러 최대 섹터 수 등 하드웨어 스펙 한계치도 함께 커널에 통보합니다.
6
인터럽트 핫라인 개통. request_irq()를 호출하여, 하드웨어가 작업 완료를 보고할 때 번개처럼 튀어 나갈 인터럽트 핸들러 함수를 공식 지정합니다.
7
최종 승인 및 데뷔 (add_disk). 모든 준비가 완벽히 끝났음을 add_disk(foo.gd) 선언으로 커널에 알립니다. 비로소 GENHD_FL_UP 깃발이 꽂히고 시스템 전역에 이 디스크의 존재가 방송되며, /sys/block/foo 경로에도 번듯한 객체가 노출되어 실질적인 I/O 요청을 수신하기 시작합니다.

핵심 O/X 퀴즈

1. alloc_disk() 함수는 물리 디스크 전체를 대변하는 gendisk 구조체를 커널 메모리에 할당해 주며, 인자로 넘긴 숫자를 바탕으로 파티션 배열 크기까지 함께 확보한다.
O예를 들어 16을 넘기면 전체 디스크 1개와 파티션 15개를 지원할 수 있는 넉넉한 공간이 마련됩니다.
2. blk_init_queue()를 호출하면 빈 큐가 생성됨과 동시에, 해당 큐에 요청이 차올랐을 때 이를 끄집어내어 하드웨어로 보낼 드라이버의 핵심 전략 루틴(Strategy routine)이 request_fn으로 확정 바인딩된다.
O추가로 스케줄러 계층과의 연결 파이프라인도 이 시점에 기초 세팅됩니다.
3. 코드 상위에서 register_blkdev()와 alloc_disk() 호출만 정상적으로 끝나면, add_disk()를 생략해도 디스크는 커널에서 완벽히 활성화되어 상위 프로세스들의 읽기/쓰기 요청을 즉각 수용한다.
X앞의 과정들은 내부적인 틀을 짜는 준비 단계일 뿐, 실제 커널 I/O 서브시스템에 장치를 공식 노출하고 요청 접수를 개시하는 절대적 스위치는 add_disk() 호출입니다.
16

Strategy routine: 막후에서 하드웨어로 진격 신호를 쏘아 올리는 순간HARDWARE TRANSFER

Strategy routine (전략 루틴)은 겹겹이 쌓인 소프트웨어 계층의 가장 깊숙한 끝자락에서 대기열의 요청 덩어리(Request)를 뽑아들어 물리적 하드웨어 컨트롤러에 직접 전기적 신호를 세팅하는 핵심 함수입니다. 우리의 가상 드라이버 예시에서는 foo_strategy()가 이 막중한 역할을 맡고 있습니다.

1
가장 최적화된 요청 픽업. elv_next_request(q)를 호출해 I/O 스케줄러가 심혈을 기울여 정렬해 놓은 가장 첫 번째 request 트럭을 배차받습니다. 만약 큐가 완전히 비어있다면 조용히 루틴을 종료합니다.
2
정상 요청 규격 검증. blk_fs_request(req)로 이 요청이 일반적인 파일시스템 레벨의 데이터 읽기/쓰기가 맞는지 확인합니다. 저수준의 컨트롤러 진단 패킷이나 특수 명령이라면 우회 경로로 빠집니다.
3
스마트 하드웨어를 위한 DMA 맵핑. 컨트롤러가 강력한 Scatter-gather DMA를 지원한다면, blk_rq_map_sg()를 통해 이 거대한 요청 덩어리에 포함된 파편화된 메모리 주소 리스트(Scatter-list)를 일거에 구성하여 컨트롤러 레지스터에 한 방에 쏟아 넣습니다.
4
구형 하드웨어를 위한 수제 맵핑. Scatter-gather 능력이 없다면 어쩔 수 없이 rq_for_each_biobio_for_each_segment 이중 매크로 지옥을 순회하며, 세그먼트 조각 하나하나를 kmap_atomic()으로 매핑하고 개별적인 DMA 전송 명령을 수동으로 반복 타달해야 합니다.
5
뒤돌아보지 않는 즉시 반환. DMA 엔진에 "출발!" 명령을 내렸다면 미련 없이 루틴을 빠져나옵니다. 하드웨어가 묵묵히 짐을 옮기는 아득한 밀리초의 시간 동안, 비싼 CPU는 그 자리에서 하염없이 기다리며 낭비되지 않습니다.

전략 루틴 코딩의 철칙은 "명령만 내리고 칼같이 돌아온다"입니다. 여기서 결과를 보겠다며 루틴 안에서 Sleep 하거나 블로킹되면 시스템 I/O 전체가 숨통이 막혀버리게 되며, 여러 요청을 병렬로 동시 소화할 수 있는 강력한 최신 컨트롤러의 진가도 전부 무용지물이 됩니다. 데이터 이동의 고단한 과정은 하드웨어 엔진에 전적으로 위임하고, 완료의 순간은 훗날 인터럽트가 날카롭게 알려줄 것입니다.

핵심 O/X 퀴즈

1. 드라이버의 전략 루틴(Strategy routine)은 스케줄러 큐의 가장 앞에서 대기 중인 요청을 가져와 실제 물리적 하드웨어의 전송을 촉발시키는 방아쇠 역할을 담당한다.
O소프트웨어의 논리적 요청이 물리적 하드웨어의 동작으로 치환되는 최전선입니다.
2. 가장 성능이 우수하고 효율적으로 설계된 블록 디바이스 드라이버일수록, 전송 명령을 내린 직후 디스크가 작업을 끝낼 때까지 전략 루틴 안에서 묵묵히 동기적으로 대기(Wait)하는 끈기를 보여준다.
X최악의 설계입니다. 전략 루틴은 전송만 트리거한 채 즉각적으로 반환(Return)하여 CPU 점유를 풀어주어야 하며, 완료 처리는 철저히 비동기 인터럽트 핸들러에 맡겨야 합니다.
3. 컨트롤러가 하드웨어 레벨의 Scatter-gather DMA를 지원한다면, 드라이버는 여러 메모리 세그먼트 조각 정보들을 한데 엮은 리스트 구조를 만들어 단 한 번의 명령으로 우아하게 전송을 지시할 수 있다.
O커널의 blk_rq_map_sg() 함수가 이 복잡한 리스트 생성 작업을 아주 깔끔하게 대행해 줍니다.
17

Interrupt handler: 영수증 처리와 새로운 시작, 세밀한 진척도 추적의 기술COMPLETION PROCESSING

하드웨어 컨트롤러가 고된 DMA 전송 노동을 성공적으로(혹은 실패로) 마치면 마침내 CPU에 전기적 펄스, 즉 인터럽트(Interrupt) 신호를 날립니다. 부름을 받고 튀어나온 드라이버의 인터럽트 핸들러가 해야 할 미션은 명확합니다. "방금 끝난 하드웨어 전송으로 인해, 애당초 거대하게 뭉쳐있던 request 덩어리 중 정확히 '어느 지점'까지 작업이 클리어되었는가?"를 커널 장부에 세밀히 기록하고 다음 타자를 준비시키는 일입니다.

1
진척도 보고 (end_that_request_first). 방금 처리된 요청의 포인터, 전송 성공 여부, 그리고 하드웨어가 실질적으로 소화해 낸 정확한 섹터 개수를 인자로 넘깁니다. 커널 내부는 이를 바탕으로 덩어리 내부의 bio와 세그먼트 체인을 훑어가며 남은 바이트 수(bi_idx 등)를 지워나갑니다. 만약 특정 bio 하나가 온전히 다 처리되었다면 기쁜 마음으로 bio_endio() 콜백을 날려 상위 프로세스의 블록 상태를 해제시켜 줍니다.
2
잔여물 확인. 위 함수의 반환값을 면밀히 살핍니다. 아직 값이 1이라면 덩어리가 워낙 커서 이번 인터럽트 한 번으로 다 소화되지 못하고 남은 부스러기가 존재한다는 뜻이므로, 전략 루틴을 재차 호출해 끈질기게 후속 전송을 이어붙입니다. 만약 0이 떨어졌다면 이 request 덩어리는 완벽히 처리 종료된 것입니다.
3
대기열 명부에서 삭제. 완전히 클리어된 요청은 blkdev_dequeue_request()의 손길을 거쳐 스케줄러 큐의 이중 연결 리스트에서 영원히 퇴출당합니다.
4
마무리 청소 (end_that_request_last). 디스크 사용량 통계를 최신으로 갱신하고, 혹여나 아직 깨어나지 못한 큐 주변의 대기 프로세스들을 깨워주며, 수명을 다한 request 빈 껍데기 디스크립터를 시스템 메모리로 반환합니다.
5
쉼 없는 굴레. 큐에 아직 다음 트럭들이 출발을 기다리고 있다면, 다시금 rq→request_fn(rq)(전략 루틴)의 시동을 걸어 끊임없는 데이터의 흐름을 이어 나갑니다.

전략 루틴(Strategy routine)이 거대한 화물차를 목적지로 출발시키는 배차 관리자라면, 인터럽트 핸들러(Interrupt handler)는 트럭이 기지로 복귀했을 때 길고 긴 배송 송장 목록표를 일일이 대조하며 빨간 줄을 긋는 꼼꼼한 검수자입니다. 어떤 상자는 배송 완료 처리되어 전산망에 등록되고(bio_endio), 누락되거나 다 싣지 못한 화물이 발견되면 같은 차를 그 자리에서 다시 출동시키며, 리스트가 완벽히 비워지면 비로소 다음 배송을 위한 트럭을 새롭게 호출합니다.

이 일련의 과정에서 가장 돋보이는 커널의 우아함은, 거대한 request 덩어리가 하나의 I/O 작업으로 뭉뚱그려 처리되는 것이 아니라 하부에 속한 bio와 개별 세그먼트 단위로 매우 촘촘하게 진척도가 쪼개어 관리된다는 점입니다. 어마어마한 양의 단일 요청이 컨트롤러의 한계 탓에 여러 번의 자잘한 DMA 작업으로 파편화되어 쪼개져도, 리눅스 커널은 단 한 바이트의 미아도 허용하지 않고 오차 없이 정확하게 남은 위치를 추적해 냅니다.

핵심 O/X 퀴즈

1. 드라이버의 인터럽트 핸들러는 컨트롤러로부터 DMA 완료 통보를 받는 즉시, 방금 끝난 전송량을 토대로 현재 요청 덩어리 내에 아직 처리해야 할 데이터가 얼마나 남았는지 잔여 범위를 정확하게 갱신하는 책무를 지닌다.
O커널 헬퍼 함수인 end_that_request_first()가 내부 필드(bi_idx 등)를 정밀하게 조율하여 이 복잡한 계산을 대행합니다.
2. end_that_request_first() 함수는 하나의 거대한 request 내에서 특정 bio 객체의 처리가 완전히 끝났음을 감지하면 지체 없이 bio_endio() 콜백을 유발해 상위 시스템에 성공을 타전한다.
Orequest 전체가 끝날 때까지 기다리지 않고, 포함된 개별 bio가 완성될 때마다 신속하게 상위 계층에 알림을 주어 시스템 전반의 응답성을 끌어올립니다.
3. 처리율을 높이기 위해 I/O 요청이 완벽히 종료된 이후에도 해당 request 디스크립터 자체는 다음 재사용을 위해 현재의 스케줄러 큐 내부 구조에 영구 보존되어야만 한다.
X모든 작업이 종결된 디스크립터는 end_that_request_last() 과정에서 큐의 연결 리스트로부터 완전히 적출되어 분리되며, 사용되었던 메모리 공간은 커널 풀로 말끔히 회수됩니다.
18

블록 디바이스 파일 열기: 단순한 파일 오픈을 넘어선 커널 생태계의 융합blkdev_open()

리눅스 환경에서 블록 디바이스를 지칭하는 특수 파일(예: /dev/sda1)이 열리는(Open) 상황은 크게 세 가지입니다. 관리자가 특정 파일시스템을 마운트(Mount)하려 할 때, 가상 메모리 확보를 위해 스왑(Swap) 파티션을 활성화시킬 때, 또는 dd나 디스크 유틸리티 같은 사용자 프로세스가 저수준 제어를 위해 명시적으로 open() 시스템 콜을 던졌을 때입니다. 이 순간 VFS 계층의 dentry_open()은 일반 파일과는 달리 객체의 연산 테이블(f_op)을 블록 장치 전용인 def_blk_fops로 탈바꿈시켜 특수한 처리 경로를 개통합니다.

1
일관된 디스크립터 확보 (bd_acquire). 가장 먼저 대상의 block_device 주소를 찾아 나섭니다. 이미 VFS inode의 i_bdev 필드에 값이 세팅되어 있다면 운 좋게 재활용하지만, 초기 상태라면 bdget()을 기동시켜 디바이스의 major/minor 번호를 들고 은밀한 bdev 파일시스템을 수색하여 유일무이한 객체를 찾아내거나 신규 발급받습니다.
2
물리적 신분증 대조 (get_gendisk). 찾아낸 논리적 블록 디바이스가 실제로 매핑되는 gendisk 객체를 탐색해 연결합니다. 대상이 파티션이라면 이 과정에서 몇 번째 조각에 해당하는지 인덱스 값도 함께 확보됩니다.
3
최초 오픈의 특권 (전체 디스크 초기화). 만약 이 장치가 시스템 부팅 후 생전 처음 열리는 것이라면, 내부 포인터(bd_disk)를 연결하고 드라이버가 등록해 둔 본연의 오픈 메서드(disk→fops→open)를 직접 호출해 줍니다. 부가적으로 매체 탈착이 가능한 CD-ROM 류의 장치라면 파티션 테이블을 처음부터 다시 스캔(rescan_partitions)하는 기민함도 보여줍니다.
4
파티션 종속성 체인 구축. 사용자가 전체 통짜 디스크가 아닌 그 안의 특정 파티션을 열었다 하더라도, 커널은 배후에서 조용히 부모 격인 통짜 디스크의 block_device 객체까지 덩달아 찾아내고 오픈 절차를 밟습니다. 그런 뒤 자식 객체의 bd_contains 포인터가 통짜 디스크 객체를 가리키도록 연결망을 짜고, hd_struct 구조체도 정확히 결속시킵니다.
5
독점권 행사를 위한 서명 (O_EXCL 검증). 파일시스템 마운트 작업 등에서 누구의 간섭도 허용치 않겠다는 배타적 권한(O_EXCL)을 요구하며 열었다면, bd_claim()을 호출하여 bd_holder 명부에 자신의 이름을 당당히 새겨 넣습니다. 만약 한발 앞서 누군가 점유하고 있다면 단호하게 -EBUSY 에러를 뱉어내고 쫓아냅니다.

블록 디바이스 드라이버는 절대 우주에 홀로 떠 있는 작은 외딴섬이 아닙니다. 상공의 VFS 레이어, 데이터의 온기가 머무는 페이지 캐시 공간, 주소를 번역하는 매핑 계층, 표준 포장지 bio를 찍어내는 Generic Block Layer, 엘리베이터의 지휘자인 I/O Scheduler, 전송의 일꾼 DMA 모듈, 타이밍의 마술사 Interrupt handler, 그리고 신분증 위조를 막는 bdev 파일시스템까지 — 이 모든 거대하고 치밀한 커널 파이프라인 톱니바퀴들이 오차 없이 맞물려 돌아간 끝에 비로소 호출되는 최후의 실행자에 불과합니다. 이 웅장한 연결의 큰 그림을 머릿속에 간직한다면, 코드 속 흩뿌려진 bio, request, gendisk, block_device 같은 차가운 이름들이 파이프라인의 생동감 넘치는 주인공들로 재탄생해 보일 것입니다.

핵심 O/X 퀴즈

1. VFS 계층에서 블록 장치 특수 파일에 대한 open() 시스템 콜이 감지되면, 커널은 일반 텍스트 파일과는 전혀 다른 경로를 걷게 하기 위해 파일 연산 테이블을 def_blk_fops 구조체로 교체 적용한다.
O이 교체 작업(dentry_open 단계) 덕분에 후속되는 read, write 연산이 전부 블록 디바이스 특화 로직으로 라우팅됩니다.
2. bd_acquire() 함수의 주요 임무 중 하나는 현재 다루고자 하는 장치의 inode 상태를 살피고, 필요하다면 bdget()을 거쳐 숨겨진 bdev 파일시스템에서 유일한 block_device 객체를 찾아내거나 새로 발급받는 일이다.
O이 과정을 거치며 장치 파일의 경로명이 제아무리 다르더라도 일관된 논리 객체로 수렴하게 됩니다.
3. 사용자가 단지 파티션 장치 하나만을 열고자 할 때, 커널은 효율성을 위해 그 파티션을 품고 있는 모체 통짜 하드디스크의 block_device 객체 정보 따위는 일절 검색하지 않고 무시한다.
X파티션은 결국 물리 디스크에 종속된 일부분이므로, 커널은 언제나 배후에서 통짜 디스크의 block_device 객체까지 함께 획득하고 참조 카운터를 올린 뒤 bd_contains 필드로 이 둘을 단단히 결속시킵니다.

블록 디바이스 드라이버는 독고다이가 아닙니다. VFS · 페이지 캐시 · 매핑 계층 · Generic Block Layer · I/O 스케줄러 · DMA 엔진 · 인터럽트 핸들러 · bdev 가상 파일시스템이 하나로 집결하는 리눅스 커널의 가장 치열하고 역동적인 교차로입니다.

강의노트 — Linux Kernel, Chapter 14 "Block Device Drivers" 기반 재구성