Try   HackMD

Linux 核心專題: 系統虛擬機器開發和改進

執行人: yanjiew1
專題解說錄影

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →
提問清單

  • 是否能整理出移植到arm64上需要哪幾個步驟??例如cpu/memory/interrupt/io/dtb需要先做哪個?

    • 我嘗試實作的步驟大致是
      1. 把跟 x86-64 專屬的程式分離
      2. Memory
      3. CPU
      4. Interrrupt
      5. I/O Bus 映射到 MMIO 前 64K
      6. 核心與 Initramfs 載入
      7. DTB 初步實作
      8. CPU Register 初始化
      9. 測試是否能啟動至 Initramfs
      10. PCI
      11. 在 DTB 內加入 PCI 節點
      12. 測試 virtio-blk 是否正常運作
    • 我一開始的目標是先能啟動至 Initramfs 並能用鍵盤互動。接下來再移植 PCI 並確認 virtio-blk 的運作。且實際上,我覺得沒有一定要哪個先實作,例如:我可以先做 CPU 初始化,再去做 Memory。因為都是要實作到一定的程度時, Guest OS 才能跑起來,才能看到 Guest OS 輸出的螢幕上的訊息。
  • 移植過程如何debug?

    • 我覺得 Debug 的確是最難的。我遇到的狀況分成三類:
      • KVM ioctl 呼叫時回傳錯誤訊息: 遇到此情況時,我會先用 errno 的內容去查 Linux 核心程式碼內的文件,看看此錯誤產生的原因。有時文件沒寫,就會直接去看 Linux 核心程式碼,看看此 errno 是怎麼產生的。知道原因後,再去修正錯誤。過程中,也會去對照 kvmtool 專案中,對 KVM ioctl 操作的流程,看看是不是順序錯,或參數傳錯。
      • 所有的 KVM ioctl 都執行成功,但執行 KVM_RUN ioctl 後, Guest 沒輸出訊息、沒反應: 這是最難 Debug 的,因為 gdb 不會告訴你現在 Guest 跑到哪。後來我是仔細檢查 Device Tree 、 CPU 暫存器初始化的程式,發現有寫錯才找到 Bug 的。
      • serial device 問題 (第三個 TODO):一開始我觀察到的現象只有輸入無反應。我用 GDB debug 但看不出所以然(也有可能我沒有很認真的用 GDB) 。我發現降低 signal 產生的頻率(例如:加入 sleep()),問題就不會產生,且我試的 x86 機器都正常,只有老師的 arm64 機器會有問題。我閱讀了關於 read 系統呼叫的文件後,發現 read 若遇到 signal 會被中斷,且若還沒讀到任何東西,會回傳 EINTR 。故我一開始就是直接猜測在 stdin 可讀時,大量的 signal 導致主執行緒沒辦法有進展,就往這方面去解決。但實際上是 read 系統呼叫被 signal 中斷,或是每次 signal handler return 時還來不及執行下一個指令就又被 signal 中斷,我就沒有去研究是哪個原因,因為後來改用別的方式處理 stdin 就解決問題了。
  • 如何驗證這個guest OS是正確的?

    • 在 Guest OS 啟動後,若可以看到核心啟動,且啟動至 initramfs 內的 busybox shell ,我就已經初步確認它啟動成功了。至於是不是功能完全正常,這需要更深入的測試,可能需要專門測試程式或測試用的核心模組跑在 Guest OS 環境下,我沒有去做這方面的測試。 而 virtio-blk 的測試,是嘗試在 Guest OS 去掛載和使用它,並看看能不能用,但也沒有去做很深入功能測試。
    • 這說明 kvm-host 目前還算是實驗性的專案,還不適合用在 production 環境。

任務簡述

KVM 可將 Linux 核心轉為 type-2 hypervisor,結合硬體的虛擬化支援,使得 host machine 上可以執行多個獨立的虛擬環境,稱為 guest 或者 virtual machine。由於 KVM 直接提供 CPU 和記憶體的虛擬化,guest os 的 CPU 指令不需要額外經過軟體的 decode,而是直接交給硬體進行處理,因此有效提高運行速度。而結合軟體 (例如 KVM 搭配 QEMU) 模擬 CPU 和記憶體以外的裝置後,guest OS 便可以被完整地支援在 host OS 上載入並執行。kvm-host 則是建構在 KVM 基礎上的虛擬機器實作,可載入 Linux 核心和相關應用程式。

目前 kvm-host 僅支援 x86(-64) 架構,本任務嘗試進行初步的 ARMv8-A 架構的移植。

相關資訊:

熱身準備:

TODO: 改進 UART 裝置的封裝和實作

Issue #10
Pull request #14

Bus

在程式碼 pci.c 中, PCI 實作內,有針對 Port I/O 和 MMIO 設計一個 bus 實作,每一個 bus 以 struct bus 為代表,可以透過 bus 實作去為裝置 struct dev 註冊 MMIO 或 Port I/O 的位址。當 Guest 去存取這段 MMIO 和 Port I/O 時, bus 相關實作就會根據存取的位址找到註冊的裝置,並呼叫裝置的 handler 。

struct bus {
    uint64_t dev_num;
    struct dev *head;
};

使用 dev_init() 可以初始化 struct dev 裝置並定義其 MMIO 或 Pprt I/O 位址的範圍、 owner 指標(會在呼叫 I/O 處理程式時傳入)、處理 I/O 的 callback 函式。即初始化 struct dev 結構:

struct dev {
    uint64_t base;
    uint64_t len;
    void *owner;
    dev_io_fn do_io;
    struct dev *next;
};

dev_init() 初始化裝置後,再使用 bus_register_dev() 把此裝置註冊到對應的 bus 上,bus_register_dev() 會使用 linked list 方式把 struct dev 串起來,並且接在 struct bus 上。

一旦註冊後,只要 Guest 進行 MMIO 或 Port I/O 時,會觸發 VM exit ,在 vm.c 內的 vm_handle_iovm_handle_mmio ,會呼叫 bus 系統中的 bus_handle_io() 讓 bus 系統去處理 I/O 。

bus_handle_io() 會走訪 linked list ,找到合適的 struct dev 後,去呼叫 callback 函式。

void bus_handle_io(struct bus *bus,
                   void *data,
                   uint8_t is_write,
                   uint64_t addr,
                   uint8_t size)
{
    struct dev *dev = bus_find_dev(bus, addr);

    if (dev && addr + size - 1 <= dev->base + dev->len - 1) {
        dev->do_io(dev->owner, data, is_write, addr - dev->base, size);
    }
}

struct bus 不一定只能用在 Port I/O 或 MMIO Bus ,像是 PCI Configuration Space 也是使用 struct bus 來管理各 PCI 裝置的 Configuration Space 操作。

移除特例並重新封裝 serial device

看一下未改進前的 vm_handle_io() 程式碼:

void vm_handle_io(vm_t *v, struct kvm_run *run)
{
    uint64_t addr = run->io.port;
    void *data = (void *) run + run->io.data_offset;
    bool is_write = run->io.direction == KVM_EXIT_IO_OUT;

    if (run->io.port >= COM1_PORT_BASE && run->io.port < COM1_PORT_END) {
        serial_handle(&v->serial, run);
    } else {
        for (int i = 0; i < run->io.count; i++) {
            bus_handle_io(&v->io_bus, data, is_write, addr, run->io.size);
            addr += run->io.size;
        }
    }
}

會發現此段程式會先去檢查 I/O 號碼是否在 serial 裝置的範圍,如果是就呼叫 serial 裝置的 I/O 處理函式 serial_handle(),其他狀況才呼叫 bus_handle_io() 這就形成特例。目標是移除特例,完全使用 bus_handle_io 來處理 Port I/O 的操作。

原本的 bus 系統相關的資料結構和函式都跟 PCI 系統放一起,但由於 bus 系統不一定只能給 PCI 裝置使用,故我把 bus 系統的介面和實作從 PCI 系統分離出來,獨立出二個檔案: bus.cbus.h 。 (commit 64d9f0e)。

接下來修改 serial 裝置,透過在 struct serial 內嵌 struct dev 結構,並於 serial_init 函式中初始化 struct dev 及向 I/O Bus 註冊 I/O ports 。 (commit d63b17b) 。

struct serial 內嵌 struct dev 結構:

 struct serial_dev {
     void *priv;
     pthread_t main_tid, worker_tid;
     int infd; /* file descriptor for serial input */
+    struct dev dev;
 };

向 I/O Bus 註冊 sttruct dev 的部份實作在 serial_init() 函式。因為註冊 I/O ports 需要取得 I/O bus struct bus 的指標。故修改此函式宣告,加入 struct bus *bus ,成為

int serial_init(serial_dev_t *s, struct bus *bus);

並在 serial_init() 最後 return 0; 前加入下面二行程式:

dev_init(&s->dev, COM1_PORT_BASE, COM1_PORT_SIZE, s, serial_handle_io);
bus_register_dev(bus, &s->dev);

修改 vm.cvm_init() 函式,讓它在裡面呼叫 serial_init() 時傳入 I/O Bus (&v->io_bus) ,並且在 bus_init 初始化 I/O Bus 和 MMIO bus 後才呼叫 serial_init() 初始化 serial device。

@@ -115,11 +115,11 @@ int vm_init(vm_t *v)
 
     vm_init_regs(v);
     vm_init_cpu_id(v);
-    if (serial_init(&v->serial))
-        return throw_err("Failed to init UART device");
     bus_init(&v->io_bus);
     bus_init(&v->mmio_bus);
     pci_init(&v->pci, &v->io_bus);
+    if (serial_init(&v->serial, &v->io_bus))
+        return throw_err("Failed to init UART device");
     virtio_blk_init(&v->virtio_blk_dev);
     return 0;
 }

dev_io_fn 為一個用 typedef 定義的一個函式型別,用於 struct dev 的 callback 函式。

接下來宣告一個符合 dev_io_fn 介面的函式 serial_handle_io 如下:

static void serial_handle_io(void *owner,
                             void *data,
                             uint8_t is_write,
                             uint64_t offset,
                             uint8_t size)
{
    serial_dev_t *s = (serial_dev_t *) owner;
    void (*serial_op)(serial_dev_t *, uint16_t, void *) =
        is_write ? serial_out : serial_in;

    serial_op(s, offset, data);
}

這是參考原本的 serial_handle 撰寫。先判斷是讀或寫,選擇呼叫 serial_outserial_in 函式。

最後把舊的 serial_handle 函式移除,也把 vm_handle_io 中針對 serial 裝置特例的部份移除:

@@ -223,13 +223,9 @@ void vm_handle_io(vm_t *v, struct kvm_run *run)
     void *data = (void *) run + run->io.data_offset;
     bool is_write = run->io.direction == KVM_EXIT_IO_OUT;
 
-    if (run->io.port >= COM1_PORT_BASE && run->io.port < COM1_PORT_END) {
-        serial_handle(&v->serial, run);
-    } else {
-        for (int i = 0; i < run->io.count; i++) {
-            bus_handle_io(&v->io_bus, data, is_write, addr, run->io.size);
-            addr += run->io.size;
-        }
+    for (int i = 0; i < run->io.count; i++) {
+        bus_handle_io(&v->io_bus, data, is_write, addr, run->io.size);
+        addr += run->io.size;
     }
 }

TODO: 移植到 ARMv8-A 處理器架構

所有相關 Pull requests:

TODO List:

  • 把 x86_64 專用程式碼分離出來
  • Arm64 VM 基本實作
    • 定訂記憶體和 MMIO 對應
    • 實作 Arm64 Linux Boot Protocol
      • 載入 Linux 核心和 Initramfs
      • 設定 vCPU 暫存器
    • VM 初始化
      • 中斷控制器
        • GICv2
        • GICv3
      • vCPU 初始化
    • 實作 Device Tree
    • 移植 serial 裝置並確認 Linux 可啟動至 initramfs 並輸出開機資訊
    • 加入 PCI 介面並確認相關週邊 (virtio-blk) 可運作
  • 改善 console ,增加類似 QEMU 按 Ctrl-A X 才終止 VM 的機制,並把 Ctrl-C 傳給 Guest
  • 實作 virtio-console
  • 實作 virtio-net

kvm-host 的 VM 建立及開啟流程

為了方便閱讀後面的共筆,先簡介 kvm-host 的架構。

vm_t 資料結構

所有 VM 的狀態都放在這裡面,如下:

typedef struct {
    int kvm_fd, vm_fd, vcpu_fd;
    void *mem;
    serial_dev_t serial;
    struct bus mmio_bus;
    struct bus io_bus;
    struct pci pci;
    struct diskimg diskimg;
    struct virtio_blk_dev virtio_blk_dev;
    void *priv;
} vm_t;

其中 priv 在 arm64 會指向下面的結構:

typedef struct {
    uint64_t entry;
    size_t initrdsz;
    int gic_fd;

    /* This device is a bridge between mmio_bus and io_bus*/
    struct dev iodev;
} vm_arch_priv_t;

main.c 中啟動 VM 流程

main.c 中包含 main 函式,即為程式的進入點。它會依序呼叫下面幾個函式把虛擬機器建立起來。

  • vm_init(&vm);: 初始化 VM
  • vm_load_image(&vm, kernel_file);: 載入 Linux 核心
  • vm_load_initrd(&vm, initrd_file);: 載入 initramfs
  • vm_load_diskimg(&vm, diskimg_file);: 指定 disk image 並初始化 virtio-blk
  • vm_late_init(&vm); (直接在 ISA 專屬程式部份實作): 執行 VM 前最後的初始化
  • vm_run(&vm);: 開始執行 VM
  • vm_exit(&vm);: VM 結束、資源釋放

vm.c 中的各別函式說明

vm_init() 內會依序呼叫下列函式:

  • open("/dev/kvm", O_RDWR): 開啟 KVM 裝置。 KVM 是以裝置檔案及 fd 的方式與 User space 互動。並且都是用 ioctl 系統呼叫,傳入 KVM 相關的 fd 來操作 KVM。
  • ioctl(v->kvm_fd, KVM_CREATE_VM, 0): 使用 KVM_CREATE_VM ioctl 來建立新的 VM 。此時 Linux 核心內的 KVM 模組會建立 VM ,並回傳此 VM 的 fd 。
  • vm_arch_init(v): 呼叫各 ISA 專屬的 VM 初始化程式碼。
  • mmap 系統呼叫配置 Guest 記憶體空間
  • ioctl(v->vm_fd, KVM_SET_USER_MEMORY_REGION, &region): 用 KVM_SET_USER_MEMORY_REGION ioctl ,可以把 User space 的記憶體空間分配給 VM 。這裡就是把剛才 mmap 拿到的空間給 VM 使用。
  • ioctl(v->vm_fd, KVM_CREATE_VCPU, 0) : 使用 KVM_CREATE_VCPU ioctl 來建立 vCPU 。
  • vm_arch_cpu_init(v) : 呼叫 ISA 自己專屬的 vCPU 初始化程式。
  • bus_init(&v->io_bus)bus_init(&v->mmio_bus) : 初始化 vm_t 中的 io_busmmio_bus
  • vm_arch_init_platform_device(v) :Platform device 指的是 PCI Bus 或 serial device 。各個 ISA 有不同的初始化方式,透過此函式來進行。

vm_late_init():不同 ISA 有自己獨立的實作。 x86-64 不做任何事,而在 arm64 會建立 device tree 和設定 CPU register 。

把 x86-64 特有程式碼分離

Pull Request #17

因為 kvm-host 原本只支援 x86-64 ISA,故只適用於 x86-64 的程式碼跟其他部份程式碼混雜在其中。其中 vm.c 中的程式,大多數都只適用於 x86-64 。

分離的方法是把 x86-64 ISA 相關的程式,搬到 src/arch/x86 目錄下。此目錄下有二個檔案,分別是 desc.hvm.cdesc.h 包含各個 ISA 可以共用程式邏輯,但有一些常數定義不一樣,例如 RAM 起始位址。而 vm.c 就包含了 ISA 特有的程式邏輯。

目前,desc.h 定義了 RAM 起始位址、中斷編號,及 kernel cmdline 。例如:在 x86-64 的 src/arch/x86/desc.h 如下:

#pragma once

#define RAM_BASE 0
#define SERIAL_IRQ 4
#define VIRTIO_BLK_IRQ 15
#define KERNEL_OPTS "console=ttyS0 pci=conf1"

Makefile 中,加入 ARCH ?= $(shell uname -m) 來取得 CPU ISA。並加入下方針對 x86-64 加入 x86-64 特有的 OBJS 檔案及標頭檔路徑:

ifeq ($(ARCH), x86_64)
	CFLAGS += -I$(PWD)/src/arch/x86
	CFLAGS += -include src/arch/x86/desc.h
	OBJS += arch/x86/vm.o
endif

Guest VM 中 MMIO 和記憶體配置

記憶體配置位址定義在此 src/arch/arm64/vm-arch.h

跟 x86 不同,在 arm64 中,可以自己自訂每一個裝置 MMIO 位址還有可用記憶體的位址。之後再透過 device tree 告訴 kernel 是如何配置的。

其中有參考 kvmtool 的配置方式,但不是完全一致

以下是 kvm-host arm64 移植的配置:

  • 0 - 64K I/O port
  • 1M - 16M GIC
  • 1GB - 2GB PCI MMIO
  • 2GB - DRAM

跟 x86-64 不一樣的是, DRAM 的起始位址,在 x86-64 為 0 ,但在 arm64 內為 2GB 。故在各個架構的 desc.h 檔裡,定義對應該架構的 RAM 起始位址的巨集 RAM_BASE

在 x86-64 定義為

#define RAM_BASE 0

而在 arm64 定義為

#define RAM_BASE (1UL << 31)

arm64 專屬資料結構建立

因為 arm64 port 需要額外的地方記錄:

  1. initramfs 大小
  2. GIC deivce fd
  3. kernel entry point
  4. 放置 Port I/O bus 和 MMIO bus 的 bridge

故在 src/arch/arm64/vm.c 加入了一個私有的資料結構:

typedef struct {
    uint64_t entry;
    size_t initrdsz;
    int gic_fd;

    /* This device is a bridge between mmio_bus and io_bus*/
    struct dev iodev;
} vm_arch_priv_t;

static vm_arch_priv_t vm_arch_priv;

並且在 src/vm.hvm_t 加入 priv 指標,用來指向不同 ISA 實作的私有資料結構:

 typedef struct {
     int kvm_fd, vm_fd, vcpu_fd;
     void *mem;
     serial_dev_t serial;
     struct bus mmio_bus;
     struct bus io_bus;
     struct pci pci;
     struct diskimg diskimg;
     struct virtio_blk_dev virtio_blk_dev;
+    void *priv;
 } vm_t;

建立與初始化中斷控制器

參考資料:

實作在 src/arch/arm64/vm.c 中的 create_irqchip()finalize_irqchip() 二個函式中。create_irqchip() 會被 vm_arch_init() 函式呼叫;而 finalize_irqchip() 會被 vm_init_platform_device() 呼叫。

KVM 提供虛擬化中斷控制器,支援 GICv2 和 GICv3。取決於 Host 硬體的支援,在 Host 為 GICv3 的硬體上,如果 GICv3 的硬體不支援 GICv2 模擬的功能,那麼只能建立虛擬 GICv3 的中斷控制器。而如果 Host 的中斷控制器為 GICv2 ,那麼只能建立 GICv2 的硬體。

透過 KVM 提供的虛擬化中斷控制器,我們可以不必在 User space 自行實作中斷控制器的模擬,可以直接使用 Linux 核心提供的實作。

eMAG 8180 主機僅能支援建立虛擬 GICv3 的中斷控制器,故下面以 GICv3 來說明。

GICv3 中斷控制器架構:


圖取自 GICv3 and GICv4 Software Overview

在 GICv3 中, CPU Interface 是以系統暫存器的方式存取,使用 msrmrs 指令來讀寫暫存器。而 Redistributer 為每個 CPU Core 各一個, Distributer 則是整個系統共用一個。 Redistributer 和 Distributer 都是用 MMIO 來存取。

使用 KVM_CREATE_VM ioctl 建立 VM 後,就可以用 KVM_CREATE_DEVICE VM ioctl 來建立中斷控制器。

KVM_CREATE_DEVICE ioctl 要傳入 struct kvm_create_device 結構。如下:

struct kvm_create_device gic_device = {
    .type = KVM_DEV_TYPE_ARM_VGIC_V3,
};

ioctl(v->vm_fd, KVM_CREATE_DEVICE, &gic_device);

裡面的 .type 屬性為 KVM_DEV_TYPE_ARM_VGIC_V3 ,代表我們要建立一個 GICv3 的虛擬中斷控制器。

ioctl(v->vm_fd, KVM_CREATE_DEVICE, &gic_device) 來建立 GICv3 的中斷控制器。建立後,在 struct kvm_create_device 內的 fd 屬性可取得此 GICv3 中斷控制器的 File Descriptor ,並把它存在 arm64 的私有資料結構 vm_arch_priv_t 中。

接下來要設定 Redistributer 和 Distributer 的 MMIO 位址。使用 KVM_SET_DEVICE_ATTR ioctl 來設定,其中 File Descriptor 是剛才取得的 GICv3 的 fd 。此 ioctl 傳入的參數為 struct kvm_device_attr ,故流程如下:

uint64_t dist_addr = ARM_GIC_DIST_BASE;
uint64_t redist_addr = ARM_GIC_REDIST_BASE;

struct kvm_device_attr dist_attr = {
    .group	= KVM_DEV_ARM_VGIC_GRP_ADDR,
    .attr	= KVM_VGIC_V3_ADDR_TYPE_DIST,
    .addr	= (uint64_t) &dist_addr,
};

struct kvm_device_attr redist_attr = {
    .group	= KVM_DEV_ARM_VGIC_GRP_ADDR,
    .attr	= KVM_VGIC_V3_ADDR_TYPE_REDIST,
    .addr	= (uint64_t) &redist_addr,
};

ioctl(gic_fd, KVM_SET_DEVICE_ATTR, &redist_attr);
ioctl(gic_fd, KVM_SET_DEVICE_ATTR, &dist_attr);

因為 .addr 要放一個指向 uint64_t 的指標,故我們先宣告區域變數,再透過 & 取得此區域變數的指標。

最後再透過 ioctl(gic_fd, KVM_SET_DEVICE_ATTR, &redist_attr)ioctl(gic_fd, KVM_SET_DEVICE_ATTR, &dist_attr) 來設定 GICv3 的 MMIO 位址。

GICv3 建立完後,再建立所有要使用的 vCPU 後,還需要進行初始化,才能讓 VM 順利執行。需對剛才建立的 GICv3 的 fd 執行 KVM_SET_DEVICE_ATTR ioctl ,也是傳入 struct kvm_device_attr 結構,內容如下:

struct kvm_device_attr vgic_init_attr = {
    .group = KVM_DEV_ARM_VGIC_GRP_CTRL,
    .attr = KVM_DEV_ARM_VGIC_CTRL_INIT,
};

ioctl(gic_fd, KVM_SET_DEVICE_ATTR, &vgic_init_attr);

呼叫 ioctl(gic_fd, KVM_SET_DEVICE_ATTR, &vgic_init_attr) 即可初始化 GICv3 。一旦初始化 GICv3 後,就不能再建立新的 vCPU 了。

這一節所提到的建立 GICv3 實作在 src/arch/arm64/vm.c 檔案中的 create_irqchip() 函式中,被 vm_arch_init() 呼叫;而初始化 GICv3 則是在同樣檔案的 finalize_irqchip() 函式中,被 vm_arch_init_platform_device() 呼叫。

初始化 vCPU

vm_init() ,會透過 ioctl(v->vm_fd, KVM_CREATE_VCPU, 0) 建立 vCPU ,得到 vcpu_fd,之後就會呼叫 vm_arch_cpu_init() 來初始化 vCPU 。

在 arm64 中,可以對 vcpu_fd 呼叫 KVM_ARM_VCPU_INIT ioctl 初始化 vCPU ,其中需傳入指向 struct kvm_vcpu_init 結構的指標作為參數。而 struct kvm_vcpu_init 可直接透過在 vm_fd 上呼叫 KVM_ARM_PREFERRED_TARGET ioctl 得到。故完整程式碼如下:

int vm_arch_cpu_init(vm_t *v)
{
    struct kvm_vcpu_init vcpu_init;
    if (ioctl(v->vm_fd, KVM_ARM_PREFERRED_TARGET, &vcpu_init) < 0)
        return throw_err("Failed to find perferred CPU type\n");

    if (ioctl(v->vcpu_fd, KVM_ARM_VCPU_INIT, &vcpu_init))
        return throw_err("Failed to initialize vCPU\n");

    return 0;
}

中斷處理

在 arm64 架構中, IRQ 號碼分配如下:

  • 0 - 15: SGI (Software generated interrupt)
  • 16 - 31: PPI (Private peripheral interrupt)
  • 32 - 1019: SPI (Shared peripheral interrupt)

週邊裝置的中斷通常會是 SPI 中斷。

在 KVM 中,也有另外一組中斷編號為 GSI (Global System Interrupt) ,在 arm64 , GSI + 32 就會是 arm64 的中斷編號了。

在 arm64 向 Guest 發送中斷比較特別,雖然也是對 vm_fd 呼叫 KVM_IRQ_LINE ioctl ,會傳入指向 struct kvm_irq_level 的指標。

以下是取自 Linux 核心的說明文件

struct kvm_irq_level 定義如下:

struct kvm_irq_level {
	union {
		__u32 irq;     /* GSI */
		__s32 status;  /* not used for KVM_IRQ_LEVEL */
	};
	__u32 level;           /* 0 or 1 */
};

而結構中的 irq 就是用來設定要發的中斷編號。但在 arm64 下, irq 不是設為 GSI (x86-64 下為 GSI),而是設為下面的格式:

  bits:  | 31 ... 24 | 23  ... 16 | 15    ...    0 |
  field: | irq_type  | vcpu_index |     irq_id     |

The irq_type field has the following values:
- irq_type[0]: out-of-kernel GIC: irq_id 0 is IRQ, irq_id 1 is FIQ
- irq_type[1]: in-kernel GIC: SPI, irq_id between 32 and 1019 (incl.)
               (the vcpu_index field is ignored)
- irq_type[2]: in-kernel GIC: PPI, irq_id between 16 and 31 (incl.)

(The irq_id field thus corresponds nicely to the IRQ ID in the ARM GIC specs)

因為我們要發送的是 SPI ,故要把 irq_type 設為 1 ,然後 irq_id 設為 GSI + 32 。

跟據上面的說明,我們可以寫出 arm64 專屬的發送中斷函式:(位於 src/arch/arm64/vm.c 中)]

int vm_irq_line(vm_t *v, int irq, int level)
{
    struct kvm_irq_level irq_level = {
        .level = level,
    };

    irq_level.irq = (KVM_ARM_IRQ_TYPE_SPI << KVM_ARM_IRQ_TYPE_SHIFT) |
                    ((irq + ARM_GIC_SPI_BASE) & KVM_ARM_IRQ_NUM_MASK);

    if (ioctl(v->vm_fd, KVM_IRQ_LINE, &irq_level) < 0)
        return throw_err("Failed to set the status of an IRQ line, %llx\n",
                         irq_level.irq);

    return 0;
}

另外我們也為 serial device 和 virtio-blk 指定 GSI 中斷編號,定義在 desc.h 中:

#define SERIAL_IRQ 0
#define VIRTIO_BLK_IRQ 1

Platform 裝置

這是 arm64 的 vm_init_platform_device() 的程式碼:

int vm_arch_init_platform_device(vm_t *v)
{
    vm_arch_priv_t *priv = (vm_arch_priv_t *) v->priv;

    /* Initial system bus */
    bus_init(&v->io_bus);
    bus_init(&v->mmio_bus);
    dev_init(&priv->iodev, ARM_IOPORT_BASE, ARM_IOPORT_SIZE, v, pio_handler);
    bus_register_dev(&v->mmio_bus, &priv->iodev);

    /* Initialize PCI bus */
    pci_init(&v->pci);
    v->pci.pci_mmio_dev.base = ARM_PCI_CFG_BASE;
    bus_register_dev(&v->mmio_bus, &v->pci.pci_mmio_dev);

    /* Initialize serial device */
    if (serial_init(&v->serial, &v->io_bus))
        return throw_err("Failed to init UART device");

    if (finalize_irqchip(v) < 0)
        return -1;

    return 0;
}

裡面包含了 Bus 初始化、 serial device 初始化、 PCI Bus 初始化,遇有對 GICv3 的初始化。關於 Bus 和 PCI Bus 初始化相關的說明請見後面二節。

IO Bus

在 x86-64 中,有 Port I/O 指令,而 Port I/O 指令會呼叫 kvm-host 的 I/O Bus handler 處理,但在 arm64 中,所有的 I/O 操作都是 MMIO 。為了相容原本 serial device 的程式碼,故在 arm64 的實作中,定義了一個 bridge device ,用來把對前 64K 的 MMIO 操作導向 I/O bus 。

vm_arch_priv_t 內,用來做 mmio_bus 和 io_bus bridge deivce 定義。

typedef struct {
    ....

    /* This device is a bridge between mmio_bus and io_bus*/
    struct dev iodev;
} vm_arch_priv_t;

其 handler :

static void pio_handler(void *owner,
                        void *data,
                        uint8_t is_write,
                        uint64_t offset,
                        uint8_t size)
{
    vm_t *v = (vm_t *) owner;
    bus_handle_io(&v->io_bus, data, is_write, offset, size);
}

vm_arch_init_platform_device() 中的相關程式:

dev_init(&priv->iodev, ARM_IOPORT_BASE, ARM_IOPORT_SIZE, v, pio_handler);
bus_register_dev(&v->mmio_bus, &priv->iodev);

故 serial device 就不用特別對 I/O Port 的部份進行修改。

PCI Bus

Pull Request #21

目前的 virtio-blk 裝置是放在 PCI 上,故需把 PCI 支援移植到 arm64 平台。

PCI 有三個 address space :

  • Configuration Space
  • MMIO Space
  • IO Space

其中,Configuration Space 在 x86 上是透過 Port I/O 存取。故要再修改 PCI 實作讓它支援用 MMIO 存取才能在 arm64 上使用。另外 I/O Space 部份,因為 virtio-blk 不使用,故就先不實作。

原本 pci_init() 函式會把 PCI bus 存取 PCI Configuration Space 的 struct dev 註冊到 vm_t 內的 io_bus ,但是在 arm64 因為是用 MMIO 存取 Configuration Space ,故這樣的作法不適用。所以把 struct dev 註冊的程式從 pci_init() 拿掉,改在 vm_arch_platform_device() 進行。

此外,也在 struct pci 內加入 pci_mmio_dev 型態為 struct dev ,它的 callback 函式會被初始化為 pci_mmio_io()pci_mmio_io() 就是針對 PCI Configuration Space 的 MMIO 處理函式。把 struct pci 內的 pci_mmio_dev 註冊到 vm_t 內的 mmio_bus ,就能使用 MMIO 存取 PCI Configuration Space 。

在 arm64 中的 vm_arch_platform_device() 註冊 PCI bus 程式碼如下:

/* Initialize PCI bus */ pci_init(&v->pci); v->pci.pci_mmio_dev.base = ARM_PCI_CFG_BASE; bus_register_dev(&v->mmio_bus, &v->pci.pci_mmio_dev);

其中第 3 行是指定 MMIO 的位址,第 4 行就是把 pci_mmio_dev 註冊到 vm_t 上的 mmio_bus

接下來看一下 pci_mmio_io 用來處理 PCI Configuration Space MMIO Handler 的程式:

static void pci_mmio_io(void *owner,
                        void *data,
                        uint8_t is_write,
                        uint64_t offset,
                        uint8_t size)
{
    struct pci *pci = (struct pci *) owner;
    bus_handle_io(&pci->pci_bus, data, is_write, offset, size);
}

會直接把 MMIO 對 Configuration Space 的讀寫轉發到 struct pci 內的 pci_bus 中。

PCI device 在使用 line based interrupt 時,通常會是 level-triggered 。但是因為原本的 virtio-blk 實作,使用 irqfd 來送中斷,會是 edge-triggered 故會無法正常運作。解決方式是,在 device tree 中,描述此中斷為 edge-triggered 即可。

載入 Linux 核心和 Initrd 檔案

參考資料: Booting arm64 Linux
實作在 src/arch/arm64/vm.c 中的 vm_arch_load_imagevm_arch_load_initrd 函式。

根據 Linux 核心文件提供的資料,一個 arm64 的 Linux kernel image , header 的內容如下:

typedef struct {
    uint32_t code0;       /* Executable code */
    uint32_t code1;       /* Executable code */
    uint64_t text_offset; /* Image load offset, little endian */
    uint64_t image_size;  /* Effective Image size, little endian */
    uint64_t flags;       /* kernel flags, little endian */
    uint64_t res2;        /* reserved */
    uint64_t res3;        /* reserved */
    uint64_t res4;        /* reserved */
    uint32_t magic;       /* Magic number, little endian, "ARM\x64" */
    uint32_t res5;        /* reserved (used for PE COFF offset) */
} arm64_kernel_header_t;

在文件中提到

The Image must be placed text_offset bytes from a 2MB aligned base address anywhere in usable system RAM and called there.

故要選定一個 2MB 對齊的記憶體位址,然後再加上 header 中 text_offset 的值,這個位址就是核心 Image 放置的位置。而 image_size 是從核心 Image 放置的起始位置算起,要留給核心的可用記憶體空間。

載入核心後,要啟動核心執行第一個指令位址是 Image 載入的位址,即 code0 的位址,故也把此位址記錄下來,放到 vm_arch_priv_t 內的 entry 成員變數中。

除了載入核心外,還要載入 initramfs 作為開機時的第一個檔案系統。文件中提到:

If an initrd/initramfs is passed to the kernel at boot, it must reside entirely within a 1 GB aligned physical memory window of up to 32 GB in size that fully covers the kernel Image as well.

代表 initramfs 的位置跟 kernel 必須要在同一個 1GB aligned 下的 32 GB 的 window 內,但它沒有提到 initramfs 要不要對齊。

以下是我的實作:

src/arch/arm64/vm.c 中的 vm_arch_load_imagevm_arch_load_initrd 二個函式,包含載入 Linux 核心和 initramfs 的程式。

vm_arch_load_image 會檢查核心的 header 。檢查完成後,把核心載入預先定義好的位址( ARM_KERNEL_BASE ),再加上 text_offset

int vm_arch_load_image(vm_t *v, void *data, size_t datasz)
{
    vm_arch_priv_t *priv = (vm_arch_priv_t *) v->priv;

    arm64_kernel_header_t *header = data;
    if (header->magic != 0x644d5241U)
        return throw_err("Invalid kernel image\n");

    uint64_t offset;
    if (header->image_size == 0)
        offset = 0x80000;
    else
        offset = header->text_offset;

    if (offset + datasz >= ARM_KERNEL_SIZE ||
        offset + header->image_size >= ARM_KERNEL_SIZE) {
        return throw_err("Image size too large\n");
    }

    void *dest = vm_guest_to_host(v, ARM_KERNEL_BASE + offset);
    memmove(dest, data, datasz);
    priv->entry = ARM_KERNEL_BASE + offset;
    return 0;
}

而 initramfs 也是直接載入到固定位址(ARM_INITRD_BASE):

int vm_arch_load_initrd(vm_t *v, void *data, size_t datasz)
{
    vm_arch_priv_t *priv = (vm_arch_priv_t *) v->priv;
    void *dest = vm_guest_to_host(v, ARM_INITRD_BASE);
    memmove(dest, data, datasz);
    priv->initrdsz = datasz;
    return 0;
}

Device Tree 實作

Device Tree Specification
kvmtool 的實作

啟動核心前需要把 device tree 所在的實體記憶體位址透過 x0 暫存器傳給核心。

kvmtool 透過 libfdt 來產生 device tree ,因此我們也可以採用同樣的作法。

libfdt 是包含在 dtc 套件內的函式庫,此為其 Git Repository 如下

用 git 把它 clone 下來後,在裡面下 make libfdt 命令即可編譯,在 libfdt 目錄內就有我們要的函式庫 libfdt.a

實作 device treE 過程,有參考 kvmtool 的實作。這是 kvmtool 的 DTB Dump

Device Tree 主要會定義下列:

  • Machine Type
  • CPU
  • Memory
  • Initramfs Address
  • Bootargs
  • Interrupt Controller
  • 16550 UART Addresses
  • PCI Addresses

研究 libfdt API 時,發現裡面的註解有打錯字。送了 Pull Request 。被接受後的 Commit

使用 libfdt 來產生 device tree 流程:

  1. 利用 fdt_create() 函式指定欲放置 device tree 的緩衝區及其空間,即新建空的 device tree。
  2. 使用 fdt_begin_node() 來新增節點。一開始需要新增根節點,故需先呼叫 fdt_begin_node(fdt, "")
  3. 節點中,可以用 fdt_property(), fdt_property_cell(), fdt_property_u64() 等來加入屬性。其中若使用 fdt_property(),libfdt 會原封不動的把內容放置進入,但 device tree 內的數值採用 big-endian 表示,故 fdt_property() 要搭配 cpu_to_fdt32()cpu_to_fdt64() 來轉換 endianness 。
  4. 節點的屬性都新增完後,用 fdt_end_node() 來關閉節點。
  5. 最後用 fdt_finish() ,來完成 device tree 。 fdt_finish() 呼叫完成後,只要上述 fdt_begin_node()fdt_end_node() 都有正確配對,緩衝區的內容即為合法的 device tree 了。

引入 libfdt 至 kvm-host 中

因為不是每台電腦都有裝 libfdt 套件,故透過 git submodule 的方式,把 dtc 放入 kvm-host 中。

Pull Request #24

並在 Makefile 加入 libfdt 的路徑和 CFLAGS

實作 device tree 產生程式

參考了下面的文件

src/arch/arm64/vm.c 中,新增了 generate_fdt() ,它會被 vm_late_init() 呼叫,會在特定的 Guest 位址中產生 Linux 可以使用的 device tree 。

為了方便錯誤處理,故寫了個巨集:

/* Helper macro to simplify error handling */
#define __FDT(action, ...)                                                   \
    do {                                                                     \
        int __ret = fdt_##action(fdt, ##__VA_ARGS__);                        \
        if (__ret >= 0)                                                      \
            break;                                                           \
        return throw_err("Failed to create device tree:\n %s\n %s\n",        \
                         "fdt_" #action "(fdt" __VA_OPT__(", ") #__VA_ARGS__ \
                         ")",                                                \
                         fdt_strerror(__ret));                               \
    } while (0)

__FDT, 內第一個參數傳入要做的動作,第二個以後的參數則是傳入要傳給 fdt_... 函式的參數。它會轉換成 fdt_....(fdt, ...) 函式呼叫,並進行錯誤處理。

這是 generate_fdt() 的部份程式,因篇幅綠故,沒有完整列出:

static int generate_fdt(vm_t *v)
{
    vm_arch_priv_t *priv = (vm_arch_priv_t *) v->priv;
    void *fdt = vm_guest_to_host(v, ARM_FDT_BASE);

    /* Create an empty FDT */
    __FDT(create, FDT_MAX_SIZE);
    __FDT(finish_reservemap);

    /* Create / node with its header */
    __FDT(begin_node, "");
    __FDT(property_cell, "#address-cells", 0x2);
    __FDT(property_cell, "#size-cells", 0x2);
    __FDT(property_cell, "interrupt-parent", FDT_PHANDLE_GIC);
    __FDT(property_string, "compatible", "linux,dummy-virt");

    /* Create /chosen node */
    __FDT(begin_node, "chosen");
    __FDT(property_string, "bootargs", KERNEL_OPTS);
    __FDT(property_string, "stdout-path", "/uart");
    if (priv->initrdsz > 0) {
        __FDT(property_u64, "linux,initrd-start", ARM_INITRD_BASE);
        __FDT(property_u64, "linux,initrd-end",
              ARM_INITRD_BASE + priv->initrdsz);
    }
    __FDT(end_node); /* End of /chosen node */

    /* Create /memory node */
    __FDT(begin_node, "memory");
    __FDT(property_string, "device_type", "memory");
    uint64_t mem_reg[2] = {cpu_to_fdt64(RAM_BASE), cpu_to_fdt64(RAM_SIZE)};
    __FDT(property, "reg", mem_reg, sizeof(mem_reg));
    __FDT(end_node); /* End of /memory node */

    /* Create /cpus node */
    __FDT(begin_node, "cpus");
    /* /cpus node headers */
    __FDT(property_cell, "#address-cells", 0x1);
    __FDT(property_cell, "#size-cells", 0x0);
    /* The only one CPU */
    __FDT(begin_node, "cpu"); /* Create /cpus/cpu subnode */
    uint64_t mpidr;
    if (get_mpidr(v, &mpidr) < 0)
        return -1;
    __FDT(property_cell, "reg", mpidr);
    __FDT(property_string, "device_type", "cpu");
    __FDT(property_string, "compatible", "arm,arm-v8");
    __FDT(end_node); /* End of /cpus/cpu */
    __FDT(end_node); /* End of /cpu */

    /* Create /timer node
     * Use the example from
     * https://www.kernel.org/doc/Documentation/devicetree/bindings/arm/arch_timer.txt
     */
    __FDT(begin_node, "timer");
    __FDT(property_string, "compatible", "arm,armv8-timer");
    uint32_t timer_irq[] = {
        cpu_to_fdt32(1), cpu_to_fdt32(13), cpu_to_fdt32(0xf08),
        cpu_to_fdt32(1), cpu_to_fdt32(14), cpu_to_fdt32(0xf08),
        cpu_to_fdt32(1), cpu_to_fdt32(11), cpu_to_fdt32(0xf08),
        cpu_to_fdt32(1), cpu_to_fdt32(10), cpu_to_fdt32(0xf08)};
    __FDT(property, "interrupts", &timer_irq, sizeof(timer_irq));
    __FDT(property, "always-on", NULL, 0);
    __FDT(end_node); /* End of /timer node */

    /* Create /intr node: The interrupt controller */
    __FDT(begin_node, "intr");
    uint64_t gic_reg[] = {
        cpu_to_fdt64(ARM_GIC_DIST_BASE), cpu_to_fdt64(ARM_GIC_DIST_SIZE),
        cpu_to_fdt64(ARM_GIC_REDIST_BASE), cpu_to_fdt64(ARM_GIC_REDIST_SIZE)};
    __FDT(property_string, "compatible", "arm,gic-v3");
    __FDT(property_cell, "#interrupt-cells", 3);
    __FDT(property, "interrupt-controller", NULL, 0);
    __FDT(property, "reg", &gic_reg, sizeof(gic_reg));
    __FDT(property_cell, "phandle", FDT_PHANDLE_GIC);
    __FDT(end_node);

    /* /uart node: serial device */
    /* The node name of the serial device is different from kvmtool. */
    __FDT(begin_node, "uart");
    __FDT(property_string, "compatible", "ns16550a");
    __FDT(property_cell, "clock-frequency", 1843200);
    uint64_t serial_reg[] = {cpu_to_fdt64(ARM_IOPORT_BASE + COM1_PORT_BASE),
                             cpu_to_fdt64(COM1_PORT_SIZE)};
    __FDT(property, "reg", &serial_reg, sizeof(serial_reg));
    uint32_t serial_irq[] = {cpu_to_fdt32(ARM_FDT_IRQ_TYPE_SPI),
                             cpu_to_fdt32(SERIAL_IRQ),
                             cpu_to_fdt32(ARM_FDT_IRQ_LEVEL_TRIGGER)};
    __FDT(property, "interrupts", &serial_irq, sizeof(serial_irq));
    __FDT(end_node);

    /* /pci node */
    __FDT(begin_node, "pci");
    __FDT(property_string, "device_type", "pci");
    __FDT(property_cell, "#address-cells", 3);
    __FDT(property_cell, "#size-cells", 2);
    __FDT(property_cell, "#interrupt-cells", 1);
    __FDT(property_string, "compatible", "pci-host-cam-generic");
    __FDT(property, "dma-coherent", NULL, 0);
    uint32_t pci_bus_range[] = {cpu_to_fdt32(0), cpu_to_fdt32(0)};
    __FDT(property, "bus-range", &pci_bus_range, sizeof(pci_bus_range));
    /* reg should contains the address of configuration space */
    uint64_t pci_reg[] = {cpu_to_fdt64(ARM_PCI_CFG_BASE),
                          cpu_to_fdt64(ARM_PCI_CFG_SIZE)};
    __FDT(property, "reg", &pci_reg, sizeof(pci_reg));
    /* ranges contains the mapping of the MMIO and IO space.
     * We only map the MMIO space here.
     */
    struct {
        uint32_t pci_hi;
        uint64_t pci_addr;
        uint64_t cpu_addr;
        uint64_t size;
    } __attribute__((packed)) pci_ranges[] = {
        {cpu_to_fdt32(FDT_PCI_MMIO_SPACE), cpu_to_fdt64(ARM_PCI_MMIO_BASE),
         cpu_to_fdt64(ARM_PCI_MMIO_BASE), cpu_to_fdt64(ARM_PCI_MMIO_SIZE)},
    };
    __FDT(property, "ranges", &pci_ranges, sizeof(pci_ranges));
    /* interrupt-map contains the interrupt mapping between the PCI device and
     * the IRQ number of interrupt controller.
     * virtio-blk is the only PCI device.
     */
    struct virtio_blk_dev *virtio_blk = &v->virtio_blk_dev;
    struct pci_dev *virtio_blk_pci = (struct pci_dev *) virtio_blk;
    struct {
        uint32_t pci_hi;
        uint64_t pci_addr;
        uint32_t pci_irq;
        uint32_t intc;
        uint32_t gic_type;
        uint32_t gic_irqn;
        uint32_t gic_irq_type;
    } __attribute__((packed)) pci_irq_map[] = {{
        cpu_to_fdt32(virtio_blk_pci->config_dev.base & ~(1UL << 31)),
        0,
        cpu_to_fdt32(1),
        cpu_to_fdt32(FDT_PHANDLE_GIC),
        cpu_to_fdt32(ARM_FDT_IRQ_TYPE_SPI),
        cpu_to_fdt32(VIRTIO_BLK_IRQ),
        cpu_to_fdt32(ARM_FDT_IRQ_EDGE_TRIGGER),
    }};
    __FDT(property, "interrupt-map", &pci_irq_map, sizeof(pci_irq_map));
    __FDT(end_node); /* End of /pci node */

    /* Finalize the device tree */
    __FDT(end_node); /* End the root node */
    __FDT(finish);

    /* Now, we have a valid device tree stored at ARM_FDT_BASE */
    return 0;
}

這裡面最複雜的就是 PCI Bus ,尤其是 interrupt-map 的部份。我花了很多時間實驗和看下述文件,才實作出來的。

設定 vCPU 暫存器初始值

參考資料: Booting arm64 Linux
kvmtool arm/aarch64/kvm-cpu.c

設定 vCPU 暫存器的程式在 src/arch/arm64/vm.c 中的 init_reg(),由 vm_late_init() 呼叫。

init_reg() 內的程式,會把 x0 設定為 device tree 的位址, x1 ~ x3 設為 0 。把 Program Counter 設成 kernel 的起始位址。

/* Initialize the vCPU registers according to Linux arm64 boot protocol
 * Reference: https://www.kernel.org/doc/Documentation/arm64/booting.txt
 */
static int init_reg(vm_t *v)
{
    vm_arch_priv_t *priv = (vm_arch_priv_t *) v->priv;
    struct kvm_one_reg reg;
    uint64_t data;

    reg.addr = (uint64_t) &data;
#define __REG(r)                                                  \
    (KVM_REG_ARM_CORE_REG(r) | KVM_REG_ARM_CORE | KVM_REG_ARM64 | \
     KVM_REG_SIZE_U64)

    /* Clear x1 ~ x3 */
    for (int i = 0; i < 3; i++) {
        data = 0;
        reg.id = __REG(regs.regs[i]);
        if (ioctl(v->vcpu_fd, KVM_SET_ONE_REG, &reg) < 0)
            return throw_err("Failed to set x%d\n", i);
    }

    /* Set x0 to the address of the device tree */
    data = ARM_FDT_BASE;
    reg.id = __REG(regs.regs[0]);
    if (ioctl(v->vcpu_fd, KVM_SET_ONE_REG, &reg) < 0)
        return throw_err("Failed to set x0\n");

    /* Set program counter to the begining of kernel image */
    data = priv->entry;
    reg.id = __REG(regs.pc);
    if (ioctl(v->vcpu_fd, KVM_SET_ONE_REG, &reg) < 0)
        return throw_err("Failed to set program counter\n");

#undef __REG
    return 0;
}

裡面定義了 __REG 巨集,它可以用來產生 KVM_SET_ONE_REG ioctl 中,傳入的 struct kvm_one_reg 中,裡面的暫存器 ID 。我也是花時間看 kvmtool 範例和 Linux 核心程式碼才得知用法寫出來的。

make check 移植

make check 移植 PR #27

在 kvm-host 中有一個快速可以測試 kvm-host 功能的 Makefile target check 。只要打 make check 就能夠測試 kvm-host 的功能。

它會下載、編譯 Linux 核心,下載、編譯 busybox ,並用 busybox 建構出簡單的 initramfs ,及產生一個 disk image 來測試功能。

其中「編譯 Linux 核心」步驟,需要特別為 arm64 移植。因為在 arm64 使用的設定檔不一樣,且編譯出來的核心檔案名稱為 Image ,跟 x86-64 上的 bzImage 不同。

編譯 Linux 核心和指定 Linux 核心檔名的 Makefile 檔案在 mk/external.mk 。故做了下面的修改:

 # Build Linux kernel image
-LINUX_IMG = $(OUT)/bzImage
+ifeq ($(ARCH), x86_64)
+LINUX_IMG_NAME = bzImage
+ARCH = x86
+else ifeq ($(ARCH), aarch64)
+LINUX_IMG_NAME = Image
+ARCH = arm64
+else
+    $(error Unsupported architecture)
+endif
+LINUX_IMG := $(addprefix $(OUT)/,$(LINUX_IMG_NAME))
+
 $(LINUX_IMG): $(LINUX_SRC)
 	$(VECHO) "Configuring Linux kernel... "
-	$(Q)cp -f ${CONF}/linux.config $</.config
-	$(Q)(cd $< ; $(MAKE) ARCH=x86 olddefconfig $(REDIR)) && $(call notice, [OK])
+	$(Q)cp -f ${CONF}/linux-$(ARCH).config $</.config
+	$(Q)(cd $< ; $(MAKE) ARCH=$(ARCH) olddefconfig $(REDIR)) && $(call notice, [OK])
 	$(VECHO) "Building Linux kernel image... "
-	$(Q)(cd $< ; $(MAKE) ARCH=x86 bzImage $(PARALLEL) $(REDIR))
-	$(Q)(cd $< ; cp -f arch/x86/boot/bzImage $(TOP)/$(OUT)) && $(call notice, [OK])
+	$(Q)(cd $< ; $(MAKE) ARCH=$(ARCH) $(LINUX_IMG_NAME) $(PARALLEL) $(REDIR))
+	$(Q)(cd $< ; cp -f arch/$(ARCH)/boot/$(LINUX_IMG_NAME) $(TOP)/$(OUT)) && $(call notice, [OK])

主要是把增加判斷是 x86-64 還是 arm64 ,指定對應的核心檔名和編譯核心用的設定檔。

此外,我也預先製作了 arm64 版本的核心設定檔,放在 configs/linux-arm64.config 。而原本 x86-64 的設定檔重新命名為 linux-x86.config

做了上述修改後,只要執行

$ make check

即可在 arm64 測試 kvm-host 功能。

啟動 arm64 Linux

以上是主要移植到 arm64 的過程。完整的程式碼在 GitHub上。

可以用 git 把程式碼及 dtc submodule 下載下來,

$ git clone https://github.com/sysprog21/kvm-host.git
$ cd kvm-host
$ git submodule update --init

再用 make 編譯。

$ make

若要快速進行測試,可以用 make check 。過程中會要輸入 sudo 密碼。

$ make check

之後準備好 arm64 的核心和 initramfs。(initramfs 可用 buildroot 製作),假設這二個檔案分別為 Imagerootfs.cpio 。輸入下面命令可以開啟虛擬機器執行核心 Image 及使用 rootfs.cpio initramfs

$ ./build/kvm-host -k Image -i rootfs.cpio

TODO: 改進 UART 實作

GitHub Issue #15
Pull Requeest #22
Wikibooks 8250 UART 參考資料

現有問題

目前 16550A 裝置模擬存在二個問題,分別是 signal 風暴和與 vm_run() 直接呼叫 serial 的 serial_console() API 。

kvm-host 中的 serial 裝置會建立一個執行緒,此執行緒工作很簡單,用 poll 系統呼叫 stdin 是否可讀取,若可讀取則發送 SIGUSR1 至主執行緒(KVM_RUN 執行的執行緒)。

static void *serial_thread(serial_dev_t *s)
{
    while (!__atomic_load_n(&thread_stop, __ATOMIC_RELAXED)) {
        if (serial_readable(s, -1))
            pthread_kill((pthread_t) s->main_tid, SIGUSR1);
    }

    return NULL;
}

其中 serial_readable() 裡面就是使用 poll 來檢查 stdin 是否可讀取。因為 poll 是 level trigger ,故當 stdin 可讀時, poll 會馬上 return ,導致在使用者有跟 Console 互動時,到主執行緒用 read 系統呼叫讀取 stdin 前, SIGUSR1 會一直不斷地被觸發。這導致 signal handler 不斷被執行,導致主執行緒執行速度變慢或沒有進展外,且 read 系統呼叫可能也馬上被中斷,導致 read 無法讀取資料,回傳 EINTR 。

另外看看 vm_run() 程式碼的其中一片段:

int vm_run(vm_t *v)
{

    ...
        case KVM_EXIT_INTR:
            serial_console(&v->serial);
            break;
    ...
}

判斷 vmexit 為 KVM_EXIT_INTR 時直接呼叫 serial_console() 把 stdin 資料讀進 FIFO Receive Ring Buffer (rx_buf),此處把 vm_run 和 serial 高度耦合,不是好的 coding style 。

解決方法

為了解決 serial device 問題,修改了原有 serial device 的架構。

先看一下 struct serial_dev_priv 的結構:

 struct serial_dev_priv {
     uint8_t dll;
     uint8_t dlm;
     uint8_t iir;
     uint8_t ier;
     uint8_t fcr;
     uint8_t lcr;
     uint8_t mcr;
     uint8_t lsr;
     uint8_t msr;
     uint8_t scr;

     struct fifo rx_buf;
+    pthread_mutex_t lock;
+    pthread_cond_t cond;
 };

lockcond 是改進後版本加上去的。

在 serial device 的 worker thread 內,當 stdin 有資料時(也就是 poll 會 return 時),直接在 worker thread 讀取 stdin ,並把讀到的資料放到 serial device 中接收資料的 Ring buffer rx_buf 中。之後修改 serial device 的暫存器,並適時向 VM 發送 Interrupt 。

但因為會有二個執行緒同時存取 serial device 的暫存器,故使用一個 Mutex lock 來確保共享資源的正確性。

另外有一種情況是 rx_buf 滿了,但 stdin 仍有資料可以讀,此時 poll 會馬上 return 。在此情況下, worker thread 會在 condition variable 上等候 rx_buf 有空間放資料。目前的程式是:當 rx_buf 有一半空間時,就會去 signal condition varriable ,會讓卡在 condition variable 的 worker thread 可以繼續執行。

另外因為讀取 stdin 的工作放到 worker thread ,在 vm_run() 就不必再呼叫 serial_console() 把 stdin 資料讀進 rx_buf

修改後的 worker thread 執行程式變為:

static void *serial_thread(serial_dev_t *s)
{
    struct serial_dev_priv *priv = (struct serial_dev_priv *) s->priv;
    while (!__atomic_load_n(&thread_stop, __ATOMIC_RELAXED)) {
        if (!serial_readable(s, -1))
            continue;
        pthread_mutex_lock(&priv->lock);
        if (fifo_is_full(&priv->rx_buf)) {
            /* stdin is readable, but the rx_buf is full.
             * Wait for notification.
             */
            pthread_cond_wait(&priv->cond, &priv->lock);
        }
        serial_console(s);
        pthread_mutex_unlock(&priv->lock);
    }

    return NULL;
}

而在 Main thead ,當 Guest 要從 serial device 讀資料時,則要判斷 rx_buf 的狀態,再決定是否要 signal condition variable :

pthread_mutex_lock(&priv->lock);
if (fifo_get(&priv->rx_buf, value))
    IO_WRITE8(data, value);

if (fifo_is_empty(&priv->rx_buf)) {
    priv->lsr &= ~UART_LSR_DR;
    serial_update_irq(s);
}
/* The worker thread waits on the condition variable when rx_buf is
 * full and stdin is still readable. Notify the worker thread when
 * the capacity of the buffer drops to its half size, so the worker
 * thread can read up to half of the buffer size before it is
 * blocked again.
 */
if (fifo_capacity(&priv->rx_buf) == FIFO_LEN / 2)
    pthread_cond_signal(&priv->cond);
pthread_mutex_unlock(&priv->lock);

目前在 main thread 中,盡可能讓持有 mutex lock 的範圍縮小,所有有些對暫存器的存取也改用 atomic 操作來完成。

完整修改請看 Pull Requeest #22

心得

把 kvm-host 移植到 arm64 是件很不容易的事,因為 KVM 的文件不完整。移植過程中,除了對作業系統的運作要了解外,還需要了解 像是 PCI 、 GIC 等架構和運作原理。

不過還好有 kvmtool 這個專案,它是一個精簡的利用 KVM hypervisor 的實作。 VM 啟動的流程、 KVM ioctl 的呼叫、device tree 實作,都有參考 kvmtool ,當自己不知道要怎麼做,或遇到瓶頸時,就先看看 kvmtool 怎麼做。但即使參考 kvmtool ,總不能只是把它呼叫 KVM API 流程、產生 Device Tree 作法抄下來,還是需要理解其中的原理,這時還是得搭配其他參考文件,例如: Device Tree Specification、 PCI 說明資料、Linux 核心中的原始碼、說明文件等。

成功把 kvm-host 移植到 arm64 讓我也覺得很有成就感。雖然它只是一個教學用途上的實作,不太會在生產的環境上用,但透過動手作,讓我在計算機結構、Linux 核心載入流程,及系統程式設計這方面更有經驗。