2025. 3. 15. 18:50ㆍCS/OS

디스크 I/O | OS in 1,000 Lines
operating-system-in-1000-lines.vercel.app
2022-05-12 리눅스_디바이스_드라이버_1
Device란? 네트워크 어댑터, LCD 디스플레이, 오디오, 터미널, 키보드, 하드디스크, 플로피디스크, 프린터 등과 같은 주변 장치를 뜻함. 디바이스의 구동을 위해서 디바이스 드라이버가 필요함. Devic
ramen4598.tistory.com
가상 디스크 장치인 virtio-blk를 위한 디바이스 드라이버를 구현한다.
1. Virtio
Virtio는 Virtual I/O의 약자로 가상 장치(virtio devices)를 위한 디바이스 인터페이스 표준이다.
디바이스 드라이버가 가상 장치를 제어하는 데 사용하는 API로, 네트워크, 디스크, 그래픽스와 같은 다양한 I/O 작업을 가상 환경에서 처리할 수 있게 해 준다.

virtio-blk의 'blk'는 'block'의 약자로, 블록 디바이스(block device)를 의미한다.
- block drivers : 버퍼를 사용하는 블록단위의 데이터를 다루는 디바이스 드라이버.
- char drivers : 버퍼가 사용 없이 데이터를 다루는 디바이스 드라이버.
- network (IF) drivers : network의 물리계층과 frame 단위의 데이터 송수신.
버퍼 캐시를 사용하여 데이터를 블록 단위로 모아서 읽고 쓰기 때문에 char device와 비교해 file system이 관여하는 바가 더 크다.
char devices는 키보드나 직렬 통신, 마우스, 모니터 등등과 연결되어 있다.
이들은 버퍼를 거치지 않고 읽고 쓰기 때문에 block devices에 비해서 즉각적인 입출력에 유리하다.
virtio-blk은 실제 하드웨어에서는 존재하지 않지만, 실제 디바이스와 동일한 인터페이스를 사용한다.
가. virtqueue
Virtio 장치는 virtqueue라는 드라이버와 장치가 공유하는 큐가 존재한다.
드라이버와 장치 간의 효율적인 데이터 전송을 위해 필요하다.
큐 구조는 비동기적인 I/O 처리와 여러 요청의 동시 처리를 가능하게 한다.
더불어 드라이버와 장치 간 메모리를 효율적으로 공유하여 불필요한 데이터 복사를 최소화한다.

이름 | 내용 |
Descriptor Area | 요청의 주소와 크기를 기록한 디스크립터 테이블 |
Available Ring | 장치에 처리할 요청들을 등록함 |
Used Ring | 장치가 처리한 요청들을 기록함 |
virtqueue는 zero-copy I/O를 구현한 메커니즘이다.
- descripter는 실제 데이터의 메모리 주소를 가리키는 포인터만 저장.
- Available Ring과 Used Ring은 descripter의 인덱스만 주고받음.
- 실제 데이터는 한 번도 복사되지 않고 원래 위치에서 직접 처리됨.
이러한 방식으로 메모리 복사 오버헤드를 최소화하고 I/O 성능을 향상할 수 있음.
💡 디스크립터를 체인으로 구성하는 이유?
- 메모리 단편화 해결: 하나의 큰 연속된 메모리 블록 대신 여러 개의 작은 메모리 블록을 사용
- 유연한 I/O 작업: 읽기와 쓰기 작업을 하나의 요청에서 혼합하여 사용 가능
- 효율적인 메모리 관리: 각 디스크립터가 다른 메모리 영역을 가리킬 수 있어 메모리를 효율적으로 사용
예를 들어, 디스크에서 데이터를 읽을 때:
- 첫 번째 디스크립터: 읽기 명령과 파라미터를 포함
- 두 번째 디스크립터: 실제 데이터가 저장될 버퍼를 가리킴
- 세 번째 디스크립터: 상태 정보를 저장할 공간을 지정
이렇게 여러 디스크립터를 체인으로 연결하여 하나의 완성된 I/O 요청을 구성합니다.
2. virtio 장치 활성화
$ echo "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In ut magna consequat, cursus velit aliquam, scelerisque odio. Ut lorem eros, feugiat quis bibendum vitae, malesuada ac orci. Praesent eget quam non nunc fringilla cursus imperdiet non tellus. Aenean dictum lobortis turpis, non interdum leo rhoncus sed. Cras in tellus auctor, faucibus tortor ut, maximus metus. Praesent placerat ut magna non tristique. Pellentesque at nunc quis dui tempor vulputate. Vestibulum vitae massa orci. Mauris et tellus quis risus sagittis placerat. Integer lorem leo, feugiat sed molestie non, viverra a tellus." > lorem.txt
아무 텍스트로 채워진 lorem.txt
파일을 준비한다.
- run.sh
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-d unimp,guest_errors,int,cpu_reset -D qemu.log \
-drive id=drive0,file=lorem.txt,format=raw,if=none \ # new
-device virtio-blk-device,drive=drive0,bus=virtio-mmio-bus.0 \ # new
-kernel kernel.elf
옵션 | 설명 |
-drive id=drive0 | 드라이브에 고유 식별자drive0를 할당 |
file=lorem.txt | 미리 생성한 lorem.txt 파일을 디스크로 사용한다. ← 파일을 디스크로 사용하는 신기한 방식 |
format=raw | 디스크 이미지 형식을 raw로 지정 |
if=none | 기본 if(interface)를 사용하지 않고, 대신 device 옵션을 통해 명시적으로 지정한다 |
-device virtio-blk-device | virtio 블록 디바이스를 생성 |
drive=drive0 | 앞서 정의한 drive0 식별자를 가진 드라이브를 사용하도록 지정 |
bus=virtio-mmio-bus.0 | 가상 디바이스를 MMIO 방식으로 연결하기 위한 버스 지정자. MMIO를 통해 입출력 장치가 메모리 주소 공간의 일부로 매핑되어 CPU가 일반 메모리처럼 접근할 수 있다 |
💡 MMIO (Memory-Mapped I/O)란?
입출력 장치를 메모리 주소 공간의 일부로 매핑하여 접근하는 방식입니다.
- 주요 특징
- 일반 메모리 접근 명령어로 I/O 장치와 통신 가능
- 별도의 I/O 명령어가 필요하지 않음
- MMU를 통한 접근 제어 가능
- 동작 방식
- 특정 메모리 주소 범위가 I/O 장치에 할당됨
- 해당 주소 읽기/쓰기 시 실제로는 I/O 장치와 통신
- CPU는 일반 메모리처럼 접근하여 I/O 수행
- 예시
- 메모리 주소 0x1000_0000 ~ 0x1000_FFFF가 디스플레이 컨트롤러에 매핑되어 있다면, 이 주소에 대한 메모리 접근은 실제로 디스플레이 컨트롤러의 레지스터를 제어하게 됩니다.
3. C 매크로 및 구조체 정의
구현은 매크로 및 구조체 정의 → MMIO 영역 매핑 → Virtio 디바이스 초기화 → Virtqueue 초기화 → I/O 요청 보내기 순서로 진행된다.

Virtqueues and virtio ring: How the data travels
This post continues where the "Virtio devices and drivers overview" leaves off. After we have explained the scenario in the previous post, we are reaching the main point: how does the data travel from the virtio-device to the driver and back?
www.redhat.com
가. 매크로 정의
- kernel.h
#define SECTOR_SIZE 512
#define VIRTQ_ENTRY_NUM 16
#define VIRTIO_DEVICE_BLK 2
#define VIRTIO_BLK_PADDR 0x10001000
#define VIRTIO_REG_MAGIC 0x00
#define VIRTIO_REG_VERSION 0x04
#define VIRTIO_REG_DEVICE_ID 0x08
#define VIRTIO_REG_QUEUE_SEL 0x30
#define VIRTIO_REG_QUEUE_NUM_MAX 0x34
#define VIRTIO_REG_QUEUE_NUM 0x38
#define VIRTIO_REG_QUEUE_ALIGN 0x3c
#define VIRTIO_REG_QUEUE_PFN 0x40
#define VIRTIO_REG_QUEUE_READY 0x44
#define VIRTIO_REG_QUEUE_NOTIFY 0x50
#define VIRTIO_REG_DEVICE_STATUS 0x70
#define VIRTIO_REG_DEVICE_CONFIG 0x100
#define VIRTIO_STATUS_ACK 1
#define VIRTIO_STATUS_DRIVER 2
#define VIRTIO_STATUS_DRIVER_OK 4
#define VIRTIO_STATUS_FEAT_OK 8
#define VIRTQ_DESC_F_NEXT 1
#define VIRTQ_DESC_F_WRITE 2
#define VIRTQ_AVAIL_F_NO_INTERRUPT 1
#define VIRTIO_BLK_T_IN 0
#define VIRTIO_BLK_T_OUT 1
매크로 | 값 | 설명 |
SECTOR_SIZE | 512 | 디스크 섹터의 크기 (바이트) |
VIRTQ_ENTRY_NUM | 16 | Virtqueue 엔트리의 개수(큐의 크기) |
VIRTIO_DEVICE_BLK | 2 | 블록 디바이스의 ID |
VIRTIO_BLK_PADDR | 0x10001000 | Virtio 블록 디바이스의 물리 주소 |
VIRTIO_REG_MAGIC | 0x00 | 매직 넘버 레지스터 오프셋 |
VIRTIO_REG_VERSION | 0x04 | 버전 레지스터 오프셋 |
VIRTIO_REG_DEVICE_ID | 0x08 | 디바이스 ID 레지스터 오프셋 |
VIRTIO_REG_QUEUE_SEL | 0x30 | 큐 선택 레지스터 오프셋 |
VIRTIO_REG_QUEUE_NUM_MAX | 0x34 | 최대 큐 크기 레지스터 오프셋 |
VIRTIO_REG_QUEUE_NUM | 0x38 | 현재 큐 크기 레지스터 오프셋 |
VIRTIO_REG_QUEUE_ALIGN | 0x3c | 큐 정렬 레지스터 오프셋 |
VIRTIO_REG_QUEUE_PFN | 0x40 | 큐 물리 페이지 번호 레지스터 오프셋 |
VIRTIO_REG_QUEUE_READY | 0x44 | 큐 준비 상태 레지스터 오프셋 |
VIRTIO_REG_QUEUE_NOTIFY | 0x50 | 큐 알림 레지스터 오프셋 |
VIRTIO_REG_DEVICE_STATUS | 0x70 | 디바이스 상태 레지스터 오프셋 |
VIRTIO_REG_DEVICE_CONFIG | 0x100 | 디바이스 설정 레지스터 오프셋 |
VIRTIO_STATUS_ACK | 1 | 디바이스 인식 상태 비트 |
VIRTIO_STATUS_DRIVER | 2 | 드라이버 준비 상태 비트 |
VIRTIO_STATUS_DRIVER_OK | 4 | 드라이버 초기화 완료 상태 비트 |
VIRTIO_STATUS_FEAT_OK | 8 | 기능 협상 완료 상태 비트 |
VIRTQ_DESC_F_NEXT | 1 | 다음 디스크립터 존재 플래그 |
VIRTQ_DESC_F_WRITE | 2 | 디바이스의 쓰기 작업 플래그 |
VIRTQ_AVAIL_F_NO_INTERRUPT | 1 | 인터럽트 비활성화 플래그 |
VIRTIO_BLK_T_IN | 0 | 읽기 작업 타입 |
VIRTIO_BLK_T_OUT | 1 | 쓰기 작업 타입 |
이러한 매크로와 레지스터 오프셋은 Virtio 표준 스펙에서 정의된다.
나. 구조체 정의
- kernel.h
// Virtqueue Descriptor area entry - 디스크립터 영역의 각 엔트리를 정의
struct virtq_desc {
uint64_t addr; // 버퍼의 물리 메모리 주소
uint32_t len; // 버퍼의 길이(바이트)
uint16_t flags; // 디스크립터 플래그 (NEXT, WRITE 등)
uint16_t next; // 체인에서 다음 디스크립터의 인덱스
} __attribute__((packed));
// Virtqueue Available Ring - 드라이버가 장치에게 사용 가능한 버퍼를 알리는 링
struct virtq_avail {
uint16_t flags; // 인터럽트 제어 플래그
uint16_t index; // 다음에 사용할 ring[] 배열의 인덱스
uint16_t ring[VIRTQ_ENTRY_NUM]; // 사용 가능한 디스크립터의 인덱스 배열
} __attribute__((packed));
// Virtqueue Used Ring entry - 장치가 처리 완료한 버퍼 정보
struct virtq_used_elem {
uint32_t id; // 처리 완료된 디스크립터 체인의 첫 번째 디스크립터 인덱스
uint32_t len; // 처리된 바이트 수
} __attribute__((packed));
// Virtqueue Used Ring - 장치가 드라이버에게 처리 완료를 알리는 링
struct virtq_used {
uint16_t flags; // 인터럽트 제어 플래그
uint16_t index; // 다음에 사용할 ring[] 배열의 인덱스
struct virtq_used_elem ring[VIRTQ_ENTRY_NUM]; // 처리 완료된 버퍼 정보 배열
} __attribute__((packed));
// Virtqueue - 전체 가상 큐 구조체
struct virtio_virtq {
struct virtq_desc descs[VIRTQ_ENTRY_NUM]; // 디스크립터 영역
struct virtq_avail avail; // Available Ring
struct virtq_used used __attribute__((aligned(PAGE_SIZE))); // Used Ring (페이지 정렬)
int queue_index; // 큐 인덱스
volatile uint16_t *used_index; // 장치가 실시간으로 업데이트하는 Used Ring의 index 필드에 대한 포인터
uint16_t last_used_index; // 드라이버가 마지막으로 확인한 마지막으로 처리된 Used Ring 인덱스
} __attribute__((packed));
// Virtio-blk request - 블록 장치 요청 구조체
struct virtio_blk_req {
uint32_t type; // 요청 타입 (읽기/쓰기)
uint32_t reserved; // 예약됨 (사용되지 않음)
uint64_t sector; // 접근할 디스크 섹터 번호
uint8_t data[512]; // 데이터 버퍼 (섹터 크기)
uint8_t status; // 요청 처리 결과 (0: 성공, 다른 값: 실패)
} __attribute__((packed));
__attribute__((packed))
: 컴파일러가 구조체 멤버 사이에 패딩을 추가하지 않고 꽉 채워서 배치. 패딩이 추가되면 드라이버와 장치가 서로 다른 값을 보게 될 수 있음.
다. 유틸리티 함수 정의
- kernel.c
uint32_t virtio_reg_read32(unsigned offset) {
return *((volatile uint32_t *) (VIRTIO_BLK_PADDR + offset));
}
uint64_t virtio_reg_read64(unsigned offset) {
return *((volatile uint64_t *) (VIRTIO_BLK_PADDR + offset));
}
void virtio_reg_write32(unsigned offset, uint32_t value) {
*((volatile uint32_t *) (VIRTIO_BLK_PADDR + offset)) = value;
}
void virtio_reg_fetch_and_or32(unsigned offset, uint32_t value) {
virtio_reg_write32(offset, virtio_reg_read32(offset) | value);
}
virtio_reg_read32(offset)
: 32비트 값을 읽기.virtio_reg_read64(offset)
: 64비트 값을 읽기.virtio_reg_write32(offset, value)
: 32비트 쓰기.virtio_reg_fetch_and_or32(offset, value)
: 값을 읽어서 주어진 value와 OR 연산을 한 후 다시 쓰기.
💡 WARNING
MMIO 레지스터에 접근할 때는 일반 메모리 접근과 다릅니다. volatile 키워드를 사용하여 컴파일러가 읽기/쓰기 작업을 최적화하지 않도록 해야 합니다. MMIO에서는 메모리 접근이 부수 효과(예: 장치에 명령 전송)를 일으킬 수 있습니다.
4. MMIO 영역 매핑
- kernel.c
struct process *create_process(const void *image, size_t image_size) {
/* omitted */
for (paddr_t paddr = (paddr_t) __kernel_base;
paddr < (paddr_t) __free_ram_end; paddr += PAGE_SIZE)
map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X);
// 추가
map_page(page_table, VIRTIO_BLK_PADDR, VIRTIO_BLK_PADDR, PAGE_R | PAGE_W);
커널에서 MMIO 레지스터에 접근할 수 있도록 virtio-blk
의 MMIO 영역을 페이지 테이블에 매핑한다.
앞서 VIRTIO_BLK_PADDR
는 0x10001000
라고 정의했다.
💡 모든 드라이버가 MMIO 방식으로 동작하는가?
모든 드라이버가 MMIO(Memory-Mapped I/O) 방식으로 동작하진 않는다. 드라이버와 하드웨어가 통신하는 방식은 크게 두 가지가 있다:
- MMIO (Memory-Mapped I/O): 하드웨어 레지스터가 메모리 주소 공간에 매핑되어 있어, 일반 메모리 접근 명령어로 하드웨어를 제어하는 방식
- PMIO (Port-Mapped I/O): 별도의 I/O 주소 공간을 사용하며, 특별한 I/O 명령어(예: x86의 in/out 명령어)를 통해 하드웨어와 통신하는 방식
많은 x86 시스템의 레거시 장치들은 PMIO를 사용하고, 현대적인 장치들은 주로 MMIO를 선호하는 경향이 있다. 어떤 방식을 사용할지는 하드웨어 설계에 따라 결정된다.
💡 현대 시스템에서는 MMIO가 더 일반적으로 사용되는 이유는?
- 성능과 단순성: MMIO는 일반 메모리 접근 명령어를 사용하므로 별도의 I/O 명령어가 필요 없어 더 효율적.
- 메모리 관리: 현대 CPU의 메모리 관리 기능(캐싱, 가상 메모리 등)을 그대로 활용할 수 있음.
- 64비트 지원: PMIO는 일반적으로 16비트 포트 주소만 지원하는 반면, MMIO는 64비트 주소 공간을 활용할 수 있습니다.
다만 레거시 하드웨어 지원이나 특수한 요구사항이 있는 경우 PMIO를 사용하는 드라이버도 여전히 존재합니다.
5. Virtio 디바이스 초기화
Virtio devices and drivers overview: Who is who
This three-part series will take you through the main virtio data plane layouts: the split virtqueue and the packed virtqueue. This is the basis for the communication between hosts and virtual environments like guests or containers
www.redhat.com
가. 초기화 순서
- 초기화 순서
: 디바이스를 초기 상태로 되돌립니다. - 디바이스 인식
: 운영체제가 디바이스를 인식했다는 것을 알리기 위해 ACKNOWLEDGE 비트를 설정합니다. - 드라이버 설정
: 운영체제가 이 디바이스를 다룰 수 있다는 것을 알리기 위해 DRIVER 비트를 설정합니다. - 기능 확인 및 선택
: 디바이스가 할 수 있는 기능들을 확인하고, 운영체제가 사용할 기능들을 선택합니다. 이때는 디바이스의 설정을 확인만 할 수 있고 바꿀 수는 없습니다. - 기능 확정
: 선택한 기능들을 확정하기 위해 FEATURES_OK 비트를 설정합니다. - 기능 검증
: FEATURES_OK 비트가 제대로 켜져 있는지 다시 확인합니다. 만약 꺼져있다면, 디바이스가 필요한 기능을 지원하지 않는다는 뜻이므로 사용할 수 없습니다. - 기본 설정
: 디바이스 사용을 위한 기본 설정을 합니다. 다음 작업들을 수행합니다:- virtqueue 찾기
- 기본적인 연결 설정하기
- 디바이스의 설정을 확인하고 필요하면 수정하기
- virtqueue 초기 설정하기
- 상태 확정
: 마지막으로 DRIVER_OK 비트를 설정하여 디바이스가 사용 가능한 상태임을 알립니다.
나. 구현
- kernel.c
struct virtio_virtq *blk_request_vq;
struct virtio_blk_req *blk_req;
paddr_t blk_req_paddr;
unsigned blk_capacity;
void virtio_blk_init(void) {
if (virtio_reg_read32(VIRTIO_REG_MAGIC) != 0x74726976)
PANIC("virtio: invalid magic value");
if (virtio_reg_read32(VIRTIO_REG_VERSION) != 1)
PANIC("virtio: invalid version");
if (virtio_reg_read32(VIRTIO_REG_DEVICE_ID) != VIRTIO_DEVICE_BLK)
PANIC("virtio: invalid device id");
// 1. 장치를 리셋합니다.
virtio_reg_write32(VIRTIO_REG_DEVICE_STATUS, 0);
// 2. ACKNOWLEDGE 상태 비트를 설정합니다: 게스트 OS가 장치를 인식했음을 알림.
virtio_reg_fetch_and_or32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_ACK);
// 3. DRIVER 상태 비트를 설정합니다.
virtio_reg_fetch_and_or32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_DRIVER);
// 5. FEATURES_OK 상태 비트를 설정합니다.
virtio_reg_fetch_and_or32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_FEAT_OK);
// 7. 장치별 설정 수행 (예, virtqueue 검색)
blk_request_vq = virtq_init(0);
// 8. DRIVER_OK 상태 비트를 설정합니다.
virtio_reg_write32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_DRIVER_OK);
// 디스크 용량을 가져옵니다.
blk_capacity = virtio_reg_read64(VIRTIO_REG_DEVICE_CONFIG + 0) * SECTOR_SIZE;
printf("virtio-blk: capacity is %d bytes\n", blk_capacity);
// 장치에 요청(request)을 저장할 영역을 할당합니다.
blk_req_paddr = alloc_pages(align_up(sizeof(*blk_req), PAGE_SIZE) / PAGE_SIZE);
blk_req = (struct virtio_blk_req *) blk_req_paddr;
}
일단 디바이스의 매직 값, 버전, 디바이스 ID를 확인하여 올바른 virtio-blk 장치인지 검증한다.
장치를 리셋하고 상태 비트들(ACKNOWLEDGE, DRIVER, FEATURES_OK, DRIVER_OK)을 순차적으로 설정하여 초기화를 진행한다.
virtqueue를 초기화한다. (아래에서 구현함)
// 장치에 요청(request)을 저장할 영역을 할당합니다.
blk_req_paddr = alloc_pages(align_up(sizeof(*blk_req), PAGE_SIZE) / PAGE_SIZE);
blk_req = (struct virtio_blk_req *) blk_req_paddr;
Descriptor는 I/O 요청의 메모리 위치를 가리키는 포인터다.
실제 요청 데이터는 별도의 메모리 공간에 저장된다.
// Virtio-blk request - 블록 장치 요청 구조체
struct virtio_blk_req {
uint32_t type; // 요청 타입 (읽기/쓰기)
uint32_t reserved; // 예약됨 (사용되지 않음)
uint64_t sector; // 접근할 디스크 섹터 번호
uint8_t data[512]; // 데이터 버퍼 (섹터 크기)
uint8_t status; // 요청 처리 결과 (0: 성공, 다른 값: 실패)
} __attribute__((packed));
매크로로 정의한 blk_req
는 실제 디스크 I/O 요청 데이터를 저장하는 구조체다.
Descriptor는 이 blk_req
구조체의 주소를 저장하며, 장치에게 이 주소를 전달하여 I/O 요청을 처리하도록 합니다.
- kernel.c
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
WRITE_CSR(stvec, (uint32_t) kernel_entry);
virtio_blk_init(); // 추가
커널 시작 시 Virtio 장치 초기화.
6. Virtqueue 초기화
Virtqueues and virtio ring: How the data travels
This post continues where the "Virtio devices and drivers overview" leaves off. After we have explained the scenario in the previous post, we are reaching the main point: how does the data travel from the virtio-device to the driver and back?
www.redhat.com
가. 초기화 순서
Virtqueue 역시 초기화한다.
- 큐 선택
: QueueSel 레지스터에 큐의 인덱스를 기록합니다. 첫 번째 큐는 0번입니다. - 사용 여부 확인
: QueuePFN 레지스터를 읽어서 0이 반환되는지 확인합니다. 0이 반환되면 해당 큐가 사용 중이지 않다는 의미입니다. - 최대 크기 확인
: QueueNumMax 레지스터에서 큐가 지원하는 최대 엔트리 수를 읽습니다. 0이 반환되면 해당 큐를 사용할 수 없습니다. - 메모리 할당
: 큐를 위한 연속된 메모리 공간을 할당하고 0으로 초기화합니다. Used Ring은 페이지 크기에 맞춰 정렬되며, 큐의 크기는 QueueNumMax 이하여야 합니다. - 크기 설정
: QueueNum 레지스터에 실제 사용할 큐의 크기를 기록하여 장치에 알립니다. - 정렬 설정
: QueueAlign 레지스터에 Used Ring의 정렬 단위(바이트)를 기록합니다. - 물리 주소 등록
: 할당된 큐의 첫 페이지의 물리 주소를 QueuePFN 레지스터에 기록합니다.
나. 구현
- kernel.c
struct virtio_virtq *virtq_init(unsigned index) {
// virtqueue를 위한 메모리 영역을 할당합니다.
paddr_t virtq_paddr = alloc_pages(align_up(sizeof(struct virtio_virtq), PAGE_SIZE) / PAGE_SIZE);
struct virtio_virtq *vq = (struct virtio_virtq *) virtq_paddr;
vq->queue_index = index;
vq->used_index = (volatile uint16_t *) &vq->used.index;
// 1. QueueSel 레지스터에 인덱스를 기록하여 큐 선택.
virtio_reg_write32(VIRTIO_REG_QUEUE_SEL, index);
// 5. QueueNum 레지스터에 큐의 크기를 기록하여 장치에 알림.
virtio_reg_write32(VIRTIO_REG_QUEUE_NUM, VIRTQ_ENTRY_NUM);
// 6. QueueAlign 레지스터에 정렬값(바이트 단위)을 기록.
virtio_reg_write32(VIRTIO_REG_QUEUE_ALIGN, 0);
// 7. 할당한 큐 메모리의 첫 페이지의 물리적 번호를 QueuePFN 레지스터에 기록.
virtio_reg_write32(VIRTIO_REG_QUEUE_PFN, virtq_paddr);
return vq;
}
이 함수는 virtqueue를 위한 메모리 영역을 할당하고, 그 물리적 주소를 장치에 알려줍니다.
장치는 이 메모리 영역을 사용하여 요청을 읽거나 씁니다.
VIRTIO_BLK_PADDR
(0x10001000
) + offset
(VIRTIO_REG_QUEUE_???
)에 정보를 기록한다.
💡virtio_reg_write32(VIRTIO_REG_QUEUE_SEL, index)는 뭐 하는 거지?
처리에 앞서 사용할 virtqueue를 선택한다. 교안에서는 단일 queue만 사용하므로 index는 항상 0이지만, 경우에 따라서 다중 queue를 사용할 수 있다. 작업 특성에 따라 queue를 분리하여 성능 최적화 가능하다.
예: 읽기 전용(index 0), 쓰기 전용(index 1), 우선순위 큐(index 2)
7. I/O 요청 보내기
지금까지 virtio-blk를 초기화했다.
이제 I/O 요청을 보내보자.
“디스크에 I/O 요청을 보낸다”는 "virtqueue에 처리 요청을 추가하는 것"과 같다.
가. 시퀀스 다이어그램

실제 운영체제에서는 여러 디스크 요청을 동시에 처리해야 하므로, 사용 가능한 디스크립터들을 추적하고 관리하는 더 복잡한 시스템이 필요하다.
나. virtq_kick
- kernel.c
// desc_index는 새로운 요청의 디스크립터 체인의 헤드 디스크립터 인덱스입니다.
// 장치에 새로운 요청이 있음을 알립니다.
void virtq_kick(struct virtio_virtq *vq, int desc_index) {
vq->avail.ring[vq->avail.index % VIRTQ_ENTRY_NUM] = desc_index;
vq->avail.index++;
__sync_synchronize();
virtio_reg_write32(VIRTIO_REG_QUEUE_NOTIFY, vq->queue_index);
vq->last_used_index++;
}
__sync_synchronize();
: 메모리 배리어(barrier) 명령어
:이 명령어 이전의 모든 메모리 작업이 이후의 메모리 작업보다 먼저 완료되도록 보장한다.
: Available Ring의 업데이트가 장치에게 알림을 보내기 전에 완료되도록 함.virtio_reg_write32(VIRTIO_REG_QUEUE_NOTIFY, vq->queue_index);
: 장치에게 해당 큐에 처리할 새로운 요청이 있다는 것을 알림.vq->last_used_index++;
: 드라이버에선 요청을 처리함.
드라이버가 먼저 last_used_index
를 증가시키고, 그다음에 장치가 요청을 처리한 후 used_index
를 증가시킨다.
다. virtq_is_busy
- kernel.c
// 장치가 요청을 처리 중인지 확인합니다.
bool virtq_is_busy(struct virtio_virtq *vq) {
return vq->last_used_index != *vq->used_index;
}
volatile uint16_t *used_index;
: 장치가 실시간으로 업데이트하는 Used Ring의 index 필드에 대한 포인터uint16_t last_used_index;
: 드라이버가 마지막으로 확인한 마지막으로 처리된 Used Ring 인덱스return vq->last_used_index != *vq->used_index;
: 드라이버의last_used_index
와 장치의used_index
를 비교하여 장치가 요청을 처리 중인지 확인하는 코드.
라. read_write_disk
- 접근하려는 섹터가 디스크 용량을 초과하지 않는지 확인
- virtio-blk 장치에 보낼
virtio_blk_req
을 구성한다. - 디스크립터 체인 구성. 헤더, 데이터, 상태를 위한 3개의 디스크립터 설정
virtq_kick()
으로 장치에 새 요청을 알리고 처리 완료까지 대기- 처리 결과를 확인한다.
- 읽기 작업인 경우 데이터를 버퍼에 복사
- kernel.c
// virtio-blk 장치로부터 읽기/쓰기를 수행합니다.
void read_write_disk(void *buf, unsigned sector, int is_write) {
// sector가 (전체 용량/섹터 크기)보다 크다면 유효하지 않은 접근
if (sector >= blk_capacity / SECTOR_SIZE) {
printf("virtio: tried to read/write sector=%d, but capacity is %d\n",
sector, blk_capacity / SECTOR_SIZE);
return;
}
// virtio-blk 사양에 따라 요청을 구성합니다.
blk_req->sector = sector;
blk_req->type = is_write ? VIRTIO_BLK_T_OUT : VIRTIO_BLK_T_IN;
if (is_write)
memcpy(blk_req->data, buf, SECTOR_SIZE);
// virtqueue 디스크립터를 구성합니다 (3개의 디스크립터 사용).
struct virtio_virtq *vq = blk_request_vq;
vq->descs[0].addr = blk_req_paddr;
vq->descs[0].len = sizeof(uint32_t) * 2 + sizeof(uint64_t);
vq->descs[0].flags = VIRTQ_DESC_F_NEXT;
vq->descs[0].next = 1;
vq->descs[1].addr = blk_req_paddr + offsetof(struct virtio_blk_req, data);
vq->descs[1].len = SECTOR_SIZE;
vq->descs[1].flags = VIRTQ_DESC_F_NEXT | (is_write ? 0 : VIRTQ_DESC_F_WRITE);
vq->descs[1].next = 2;
vq->descs[2].addr = blk_req_paddr + offsetof(struct virtio_blk_req, status);
vq->descs[2].len = sizeof(uint8_t);
vq->descs[2].flags = VIRTQ_DESC_F_WRITE;
// 장치에 새로운 요청이 있음을 알림.
virtq_kick(vq, 0);
// 장치가 요청 처리를 마칠 때까지 대기(바쁜 대기; busy-wait).
while (virtq_is_busy(vq))
;
// virtio-blk: 0이 아닌 값이 반환되면 에러입니다.
if (blk_req->status != 0) {
printf("virtio: warn: failed to read/write sector=%d status=%d\n",
sector, blk_req->status);
return;
}
// 읽기 작업의 경우, 데이터를 버퍼에 복사합니다.
if (!is_write)
memcpy(buf, blk_req->data, SECTOR_SIZE);
}
unsigned sector
: sector는 접근할 디스크 섹터 번호.blk_req
: 앞서 선언한virtio_blk_req
타입의 전역 변수임.
설명 1
if (is_write) memcpy(blk_req->data, buf, SECTOR_SIZE);
쓰기 작업일 경우, 입력받은 buf
의 데이터를 virtio_blk_req
구조체의 data
필드로 복사.
디바이스와 데이터를 주고받는 중간 버퍼 역할.
설명 2
vq->descs[0].len = sizeof(uint32_t) * 2 + sizeof(uint64_t);
virtio_blk_req
구조체의 첫 번째 부분(헤더)의 크기를 계산.
헤더는 다음과 같은 필드들로 구성된다.
타입 | 필드 | 크기 |
uint32_t | type | 4바이트 |
uint32_t | reserved | 4바이트 |
uint64_t | sector | 8바이트 |
따라서 sizeof(uint32_t) * 2 + sizeof(uint64_t)
즉, 16바이트는 헤더의 크기다.
설명 3
vq->descs[1].addr = blk_req_paddr + offsetof(struct virtio_blk_req, data);
vq->descs[2].addr = blk_req_paddr + offsetof(struct virtio_blk_req, status);
offsetof(type, member)
는 구조체 내에서 특정 멤버가 시작되는 위치(바이트 단위)를 반환.
“[1000줄 OS 구현하기] C 표준 라이브러리”에서 구현했다.
data
, status
는 virtio_blk_req
구조체의 member 이름임.
설명 4
vq->descs[0].flags = VIRTQ_DESC_F_NEXT;
vq->descs[1].flags = VIRTQ_DESC_F_NEXT | (is_write ? 0 : VIRTQ_DESC_F_WRITE);
vq->descs[2].flags = VIRTQ_DESC_F_WRITE;
VIRTQ_DESC_F_WRITE
는 MMIO 영역에 디바이스가 쓰기 가능하다는 것을 나타내는 플래그다.
플래그 | 의미 | 작업 |
VIRTQ_DESC_F_WRITE | 디바이스 메모리 쓰기 | OS의 읽기 작업 아님 (is_write=false) |
0 | 디바이스 메모리 읽기 | OS의 쓰기 작업임 (is_write=true) |

여기선 헤더, 데이터, 상태 3개의 디스크립터로 구성된 체인을 사용했다.
struct virtio_blk_req {
// 첫 번째 디스크립
uint32_t type;
uint32_t reserved;
uint64_t sector;
// 두 번째 디스크립터
uint8_t data[512];
// 세 번째 디스크립터
uint8_t status;
} __attribute__((packed));
Descriptor | VIRTQ_DESC_F_NEXT | VIRTQ_DESC_F_WRITE |
header | O (다음 체인 존재함) | X (장치는 읽기만 가능) |
data | O (다음 체인 존재함) | O or X (읽기 작업 시 장치가 쓸 수 있음) |
status | X (마지막 체인) | O (장치가 처리 결과를 기록) |
8. 직접 실행해 보기
마지막으로, 디스크 I/O를 시험해본다.
- kernel.c
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
WRITE_CSR(stvec, (uint32_t) kernel_entry);
virtio_blk_init();
char buf[SECTOR_SIZE]; // 추가
read_write_disk(buf, 0, false /* read from the disk */); // 추가
printf("first sector: %s\n", buf); // 추가
strcpy(buf, "hello from kernel!!!\n"); // 추가
read_write_disk(buf, 0, true /* write to the disk */); // 추가
$ ./run.sh
virtio-blk: capacity is 1024 bytes
first sector: Lorem ipsum dolor sit amet, consectetur adipiscing elit ...
여기서 디스크 이미지로 lorem.txt
파일을 지정했기 때문에, 원본 내용이 그대로 출력된다.
$ head lorem.txt
hello from kernel!!!
amet, consectetur adipiscing elit ...
실행 후 lorem.txt
를 열어보면 첫 번째 섹터는 "hello from kernel!!!" 문자열로 덮어써진다.
💡TIP
보시다시피, 디바이스 드라이버는 OS와 장치 사이의 "접착제(Glue)" 역할을 합니다. 드라이버는 장치에게 직접 하드웨어를 제어하도록 하지 않고, 장치의 내부 소프트웨어(예: 펌웨어)와 통신하며 나머지 무거운 작업(예: 디스크 읽기/쓰기 헤드 이동 등)을 장치에 맡깁니다.
'CS > OS' 카테고리의 다른 글
[1000줄 OS 구현하기] 파일 시스템 (0) | 2025.03.17 |
---|---|
[1000줄 OS 구현하기] 시스템 콜 (0) | 2025.03.11 |
[1000줄 OS 구현하기] 유저 모드 (0) | 2025.03.11 |
[1000줄 OS 구현하기] 애플리케이션 (0) | 2025.03.09 |
[1000줄 OS 구현하기] 페이지 테이블 (0) | 2025.03.07 |