Skip to content

磁碟輸入/輸出(Disk I/O)

在本章中,我們將實作一個虛擬磁碟裝置的驅動程式:virtio-blk。儘管 virtio-blk 並不存在於實體硬體中,但它使用的介面與真實的磁碟裝置幾乎完全相同。

Virtio

Virtio 是一種用於虛擬裝置(virtio devices)的裝置介面標準。換句話說,它是驅動程式用來控制裝置的一種 API 標準。就像你使用 HTTP 來存取網頁伺服器一樣,你可以使用 Virtio 來存取 virtio 裝置。Virtio 廣泛應用於虛擬化環境中,例如 QEMU 和 Firecracker。

NOTE

最新的 Virtio 規格定義了兩種介面:Legacy 和 Modern。在這個實作中,我們使用 Legacy 介面,因為它稍微簡單一些,且與 Modern 版本差異不大。

請參考舊版 PDF,或在最新的 HTML 版本中搜尋以 Legacy Interface: 開頭的章節。

Virtqueue

Virtio 裝置中有一種稱為 virtqueue 的結構,顧名思義,它是一個由驅動程式與裝置共享的佇列。簡單來說,一個 virtqueue 包含以下三個區域:

名稱撰寫者內容具體內容
Descriptor Table驅動程式一個描述項(descriptor)表格:儲存請求的位址與大小記憶體位址、長度、下一個描述項的索引
Available Ring驅動程式通知裝置有哪些請求可以開始處理descriptor 鏈的起始索引(head index)
Used Ring裝置裝置已經處理完成的請求descriptor 鏈的起始索引(head index)

virtqueue diagram

每個請求(例如寫入磁碟)由多個描述項(descriptors)組成,這稱為描述項鏈(descriptor chain)。透過使用多個描述項,你可以指定分散的記憶體區塊(即 Scatter-Gather IO),或是設定不同的描述項屬性(例如是否允許裝置寫入)。

例如在寫入磁碟時,virtqueue 的使用流程如下:

  1. 驅動程式在 Descriptor Table 中撰寫讀寫請求。
  2. 驅動程式將該 descriptor chain 的起始索引加入 Available Ring。
  3. 驅動程式通知裝置有新的請求。
  4. 裝置從 Available Ring 中讀取請求並處理。
  5. 裝置將已處理的 descriptor 索引寫入 Used Ring,並通知驅動程式完成。

詳細資訊可參考 virtio 規格文件。在本章的實作中,我們會專注於一個名為 virtio-blk 的裝置。

啟用 virtio 裝置

在撰寫裝置驅動程式之前,讓我們先準備一個測試用的檔案。請建立一個名為 lorem.txt 的檔案,並填入像下面這樣的一些隨機文字內容:

$ 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

接著,將 virtio-blk 裝置掛載到 QEMU 上:

run.sh
bash
$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

新加入的 QEMU 參數說明如下:

  • -drive id=drive0:定義一個名為 drive0 的磁碟,並使用 lorem.txt 作為磁碟映像。磁碟格式為 raw,也就是直接將檔案內容視為磁碟資料。
  • -device virtio-blk-device:加入一個 virtio-blk 裝置,並使用 drive0 作為磁碟來源。bus=virtio-mmio-bus.0 表示將該裝置掛載到 virtio 的記憶體對應匯流排(MMIO)。

定義 C 巨集與結構

首先,讓我們在 kernel.h 中加入一些與 virtio 相關的定義:

kernel.h
c
#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_PAGE_SIZE     0x28
#define VIRTIO_REG_QUEUE_SEL     0x30
#define VIRTIO_REG_QUEUE_NUM_MAX 0x34
#define VIRTIO_REG_QUEUE_NUM     0x38
#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 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

// Virtqueue Descriptor Table entry.
struct virtq_desc {
    uint64_t addr;
    uint32_t len;
    uint16_t flags;
    uint16_t next;
} __attribute__((packed));

// Virtqueue Available Ring.
struct virtq_avail {
    uint16_t flags;
    uint16_t index;
    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;
    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;
    struct virtq_used used __attribute__((aligned(PAGE_SIZE)));
    int queue_index;
    volatile uint16_t *used_index;
    uint16_t last_used_index;
} __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;
} __attribute__((packed));

NOTE

__attribute__((packed)) 是一種編譯器擴充語法,用來告訴編譯器「不要在結構成員之間加入填充位元(padding)」。否則,編譯器可能會為了對齊效能,在成員之間自動插入隱藏的填充位元,導致驅動程式與裝置看到的資料格式不一致,進而發生錯誤。

接下來,在 kernel.c 中加入存取 MMIO(記憶體對映 I/O)暫存器的輔助函式:

kernel.c
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);
}

WARNING

存取 MMIO(Memory-Mapped I/O)暫存器與存取一般記憶體不同。你應該使用 volatile 關鍵字,以防止編譯器將讀寫操作優化掉。在 MMIO 中,對記憶體的存取可能會觸發副作用(例如:向裝置發送指令)。

映射 MMIO 區域

首先,要將 virtio-blk 的 MMIO 區域映射到頁表中,以便核心可以存取這些 MMIO 暫存器。這個步驟非常簡單:

kernel.c
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); // new

Virtio 裝置初始化

初始化流程在規格中描述如下:

  1. Reset the device. This is not required on initial start up.
  2. The ACKNOWLEDGE status bit is set: we have noticed the device.
  3. The DRIVER status bit is set: we know how to drive the device.
  4. Device-specific setup, including reading the Device Feature Bits, discovery of virtqueues for the device, optional MSI-X setup, and reading and possibly writing the virtio configuration space.
  5. The subset of Device Feature Bits understood by the driver is written to the device.
  6. The DRIVER_OK status bit is set.

Virtio 0.9.5 Specification (PDF)

你可能會被這些冗長的步驟搞得眼花撩亂,但別擔心,一個最簡單版本的實作其實非常簡單:

kernel.c
c
struct virtio_virtq *blk_request_vq;
struct virtio_blk_req *blk_req;
paddr_t blk_req_paddr;
uint64_t 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 狀態位元:已發現裝置
    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);
    // 設定頁面大小:使用 4KB 頁面。這用於 PFN(頁框編號)的計算
    virtio_reg_write32(VIRTIO_REG_PAGE_SIZE, PAGE_SIZE);
    // 初始化磁碟讀寫請求用的佇列
    blk_request_vq = virtq_init(0);
    // 6. 設定 DRIVER_OK 狀態位元:現在可以使用裝置了
    virtio_reg_write32(VIRTIO_REG_DEVICE_STATUS, VIRTIO_STATUS_DRIVER_OK);

    // Get the disk capacity.
    blk_capacity = virtio_reg_read64(VIRTIO_REG_DEVICE_CONFIG + 0) * SECTOR_SIZE;
    printf("virtio-blk: capacity is %d bytes\n", (int)blk_capacity);

    // Allocate a region to store requests to the device.
    blk_req_paddr = alloc_pages(align_up(sizeof(*blk_req), PAGE_SIZE) / PAGE_SIZE);
    blk_req = (struct virtio_blk_req *) blk_req_paddr;
}
kernel.c
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(); // new

這是裝置驅動程式的典型初始化模式。重設裝置、設定參數,然後啟用裝置。作為作業系統,我們不需要關心裝置內部發生了什麼。只需像上面那樣執行一些記憶體讀寫操作即可。

Virtqueue 初始化

Virtqueue 應按以下方式初始化:

  1. Write the virtqueue index (first queue is 0) to the Queue Select field.
  2. Read the virtqueue size from the Queue Size field, which is always a power of 2. This controls how big the virtqueue is (see below). If this field is 0, the virtqueue does not exist.
  3. Allocate and zero virtqueue in contiguous physical memory, on a 4096 byte alignment. Write the physical address, divided by 4096 to the Queue Address field.

Virtio 0.9.5 Specification (PDF)

以下是一個簡單的實作:

kernel.c
c
struct virtio_virtq *virtq_init(unsigned index) {
    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;
    // 選擇佇列:寫入 virtqueue 索引(第一個佇列為 0)
    virtio_reg_write32(VIRTIO_REG_QUEUE_SEL, index);
    // 指定佇列大小:寫入要使用的描述項數量
    virtio_reg_write32(VIRTIO_REG_QUEUE_NUM, VIRTQ_ENTRY_NUM);
    // 寫入佇列的頁框編號(不是實體位址!)
    virtio_reg_write32(VIRTIO_REG_QUEUE_PFN, virtq_paddr / PAGE_SIZE);
    return vq;
}

這個函式會為 virtqueue 分配一段記憶體區域,並將其頁框編號(不是實體位址!)告訴裝置。裝置將使用這段記憶體來讀寫請求資料。

TIP

驅動程式在初始化流程中所做的事,通常包括:檢查裝置能力與功能、分配作業系統資源(如記憶體區段)、以及設定參數。這過程是不是很像網路協定中的握手(handshake)機制呢?

傳送 I/O 請求

現在我們已經初始化好一個 virtio-blk 裝置了。接下來,我們要送出一筆 I/O 請求給磁碟。對磁碟的 I/O 請求,是透過「將處理請求加入 virtqueue」的方式來實作的,步驟如下:

kernel.c
c
// Notifies the device that there is a new request. `desc_index` is the index
// of the head descriptor of the new request.
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++;
}

// Returns whether there are requests being processed by the device.
bool virtq_is_busy(struct virtio_virtq *vq) {
    return vq->last_used_index != *vq->used_index;
}

// Reads/writes from/to virtio-blk device.
void read_write_disk(void *buf, unsigned sector, int is_write) {
    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;
    }

    // Construct the request according to the virtio-blk specification.
    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);

    // Construct the virtqueue descriptors (using 3 descriptors).
    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;

    // Notify the device that there is a new request.
    virtq_kick(vq, 0);

    // Wait until the device finishes processing.
    while (virtq_is_busy(vq))
        ;

    // virtio-blk: If a non-zero value is returned, it's an error.
    if (blk_req->status != 0) {
        printf("virtio: warn: failed to read/write sector=%d status=%d\n",
               sector, blk_req->status);
        return;
    }

    // For read operations, copy the data into the buffer.
    if (!is_write)
        memcpy(buf, blk_req->data, SECTOR_SIZE);
}

傳送一筆請求的步驟如下:

  1. blk_req 中建立一筆請求。指定你要存取的磁區號(sector number)以及讀取或寫入的類型。
  2. 建立一組描述元鏈(descriptor chain),指向 blk_req 中的每個區域(見後方描述)。
  3. 將描述元鏈(descriptor chain)中第一個描述元的索引值加入 Available Ring 中。
  4. 通知裝置:有一筆新的待處理請求。
  5. 等待裝置處理完成(這個過程稱為 busy-waitingpolling)。
  6. 檢查裝置的回應結果。

在這裡,我們建立了一組由三個描述元組成的描述元鏈。我們需要三個描述元,因為每個描述元具有不同的屬性(flags),如下所示:

c
struct virtio_blk_req {
    // First descriptor: read-only from the device
    uint32_t type;
    uint32_t reserved;
    uint64_t sector;

    // Second descriptor: writable by the device if it's a read operation (VIRTQ_DESC_F_WRITE)
    uint8_t data[512];

    // Third descriptor: writable by the device (VIRTQ_DESC_F_WRITE)
    uint8_t status;
} __attribute__((packed));

因為我們每次都會忙等(busy-wait)直到裝置處理完成,所以可以簡單地每次都使用環形緩衝區(ring)中的「前」三個描述元(descriptor)。然而,在實務中,若要同時處理多筆請求,就需要追蹤哪些描述元是「可用的」與「已使用的」。

實際試用看看

最後,我們來試試看磁碟 I/O。請將以下程式碼加入 kernel.c

kernel.c
c
    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 */);

由於我們指定 lorem.txt 作為(raw)磁碟映像檔,其內容應該會被原封不動地顯示出來:

$ ./run.sh

virtio-blk: capacity is 1024 bytes
first sector: Lorem ipsum dolor sit amet, consectetur adipiscing elit ...

接著,我們將第一個區段(sector)覆寫為字串 "hello from kernel!!!"。

$ head lorem.txt
hello from kernel!!!
amet, consectetur adipiscing elit ...

恭喜你!你已成功實作一個磁碟 I/O 驅動程式!

TIP

正如你可能已經注意到的,裝置驅動程式其實只是作業系統與硬體之間的「膠水」。驅動程式本身不直接控制硬體;它們是透過與硬體上運行的其他軟體(例如:韌體)來進行溝通的。真正負責「出力工作」的是裝置與它們內部的軟體,而不是作業系統的驅動程式。例如像移動磁碟的讀寫磁頭這類工作。