# Linux 核心專題: 系統虛擬機器開發和改進
> 執行人: yanjiew1
> [專題解說錄影](https://youtu.be/S0NuNnUM47c)
:::success
:question: 提問清單
* 是否能整理出移植到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](https://github.com/sysprog21/kvm-host) 則是建構在 KVM 基礎上的虛擬機器實作,可載入 Linux 核心和相關應用程式。
目前 [kvm-host](https://github.com/sysprog21/kvm-host) 僅支援 x86(-64) 架構,本任務嘗試進行初步的 ARMv8-A 架構的移植。
相關資訊:
* [KVM](https://hackmd.io/@RinHizakura/SJpFJ0mfF)
* [2022 年開發紀錄](https://hackmd.io/@ray90514/HyefaHiwc)
* [KVM-host 筆記](https://github.com/magnate3/linux-riscv-dev/blob/main/exercises2/kvm/kvm.md)
* [Gunyah Hypervisor from QUIC](https://www.phoronix.com/news/Qualcomm-Gunyah-Linux-v12)
* [Cloud Hypervisor](https://github.com/cloud-hypervisor/cloud-hypervisor): 支援 Arm64
* [kvmtool](https://github.com/kvmtool/kvmtool)
熱身準備:
* [kvmrun](https://github.com/loicpoulain/kvmrun): 以 KVM 為基礎的精簡 VMM,支援 Aarch64 guest
* [kvm-unit-tests](https://gitlab.com/kvm-unit-tests/kvm-unit-tests): 支援 Aarch64
## TODO: 改進 UART 裝置的封裝和實作
> [Issue #10](https://github.com/sysprog21/kvm-host/issues/10)
> Pull request [#14](https://github.com/sysprog21/kvm-host/pull/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 。
```c
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` 結構:
```c
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_io` 或 `vm_handle_mmio` ,會呼叫 bus 系統中的 `bus_handle_io()` 讓 bus 系統去處理 I/O 。
`bus_handle_io()` 會走訪 linked list ,找到合適的 `struct dev` 後,去呼叫 callback 函式。
```c
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()` 程式碼:
```c
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.c` 和 `bus.h` 。 (commit [64d9f0e](https://github.com/yanjiew1/kvm-host/commit/64d9f0e98e8b81c8284a76440f4c898be634be9c))。
接下來修改 serial 裝置,透過在 `struct serial` 內嵌 `struct dev` 結構,並於 `serial_init` 函式中初始化 `struct dev` 及向 I/O Bus 註冊 I/O ports 。 (commit [d63b17b](https://github.com/yanjiew1/kvm-host/commit/d63b17b21073a447adb433b008f3265c08fb6338)) 。
在 `struct serial` 內嵌 `struct dev` 結構:
```diff
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` ,成為
```c
int serial_init(serial_dev_t *s, struct bus *bus);
```
並在 `serial_init()` 最後 `return 0;` 前加入下面二行程式:
```c
dev_init(&s->dev, COM1_PORT_BASE, COM1_PORT_SIZE, s, serial_handle_io);
bus_register_dev(bus, &s->dev);
```
修改 `vm.c` 中 `vm_init()` 函式,讓它在裡面呼叫 `serial_init()` 時傳入 I/O Bus (`&v->io_bus`) ,並且在 `bus_init` 初始化 I/O Bus 和 MMIO bus 後才呼叫 `serial_init()` 初始化 serial device。
```diff
@@ -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` 如下:
```c
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_out` 或 `serial_in` 函式。
最後把舊的 `serial_handle` 函式移除,也把 `vm_handle_io` 中針對 serial 裝置特例的部份移除:
```diff
@@ -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:
- [PR #17 Separate x86-64 specific code out of generic implementation](https://github.com/sysprog21/kvm-host/pull/17)
- [PR #19 Make IRQ number of serial device changable](https://github.com/sysprog21/kvm-host/pull/19)
- [PR #20 Make throw_err support printf-style formatting](https://github.com/sysprog21/kvm-host/pull/20)
- [PR #21 Add PCI MMIO device](https://github.com/sysprog21/kvm-host/pull/21)
- [PR #23 Change type of guest param of vm_guest_to_host()](https://github.com/sysprog21/kvm-host/pull/23)
- [PR #24 Import part of libfdt library](https://github.com/sysprog21/kvm-host/pull/24)
- [PR #26 Add initial support for arm64](https://github.com/sysprog21/kvm-host/pull/26)
- [PR #27 Add arm64 support for "make check"](https://github.com/sysprog21/kvm-host/pull/27)
TODO List:
- [x] 把 x86_64 專用程式碼分離出來
- [x] Arm64 VM 基本實作
- [x] 定訂記憶體和 MMIO 對應
- [x] 實作 Arm64 Linux Boot Protocol
- [x] 載入 Linux 核心和 Initramfs
- [x] 設定 vCPU 暫存器
- [x] VM 初始化
- [x] 中斷控制器
- [ ] GICv2
- [x] GICv3
- [x] vCPU 初始化
- [x] 實作 Device Tree
- [x] 移植 serial 裝置並確認 Linux 可啟動至 initramfs 並輸出開機資訊
- [x] 加入 PCI 介面並確認相關週邊 (virtio-blk) 可運作
- [ ] 改善 console ,增加類似 QEMU 按 Ctrl-A X 才終止 VM 的機制,並把 Ctrl-C 傳給 Guest
- [ ] 實作 virtio-console
- [ ] 實作 virtio-net
### kvm-host 的 VM 建立及開啟流程
為了方便閱讀後面的共筆,先簡介 kvm-host 的架構。
#### `vm_t` 資料結構
所有 VM 的狀態都放在這裡面,如下:
```c
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 會指向下面的結構:
```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;
```
#### `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, ®ion)`: 用 `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_bus` 和 `mmio_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](https://github.com/sysprog21/kvm-host/pull/17)
因為 kvm-host 原本只支援 x86-64 ISA,故只適用於 x86-64 的程式碼跟其他部份程式碼混雜在其中。其中 `vm.c` 中的程式,大多數都只適用於 x86-64 。
分離的方法是把 x86-64 ISA 相關的程式,搬到 `src/arch/x86` 目錄下。此目錄下有二個檔案,分別是 [`desc.h`](https://github.com/sysprog21/kvm-host/blob/master/src/arch/x86/desc.h) 和 [`vm.c`](https://github.com/sysprog21/kvm-host/blob/master/src/arch/x86/vm.c) 。 `desc.h` 包含各個 ISA 可以共用程式邏輯,但有一些常數定義不一樣,例如 RAM 起始位址。而 `vm.c` 就包含了 ISA 特有的程式邏輯。
目前,`desc.h` 定義了 RAM 起始位址、中斷編號,及 kernel cmdline 。例如:在 x86-64 的 `src/arch/x86/desc.h` 如下:
```c
#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`](https://github.com/sysprog21/kvm-host/blob/master/src/arch/arm64/vm-arch.h)
跟 x86 不同,在 arm64 中,可以自己自訂每一個裝置 MMIO 位址還有可用記憶體的位址。之後再透過 device tree 告訴 kernel 是如何配置的。
其中有參考 [kvmtool 的配置方式](https://github.com/kvmtool/kvmtool/blob/master/arm/include/arm-common/kvm-arch.h),但不是完全一致
以下是 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 定義為
```c
#define RAM_BASE 0
```
而在 arm64 定義為
```c
#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` 加入了一個私有的資料結構:
```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.h` 中 `vm_t` 加入 `priv` 指標,用來指向不同 ISA 實作的私有資料結構:
```diff
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;
```
### 建立與初始化中斷控制器
> 參考資料:
> - [GICv3 and GICv4 Software Overview](https://developer.arm.com/documentation/dai0492/latest/)
> - [Linux KVM API](https://www.kernel.org/doc/Documentation/virtual/kvm/api.txt)
> - [Linux 核心中針對 vGICv3 的說明](https://www.kernel.org/doc/Documentation/virtual/kvm/devices/arm-vgic-v3.txt)
> - [kvmtool 相關程式](https://github.com/kvmtool/kvmtool/blob/master/arm/gic.c)
實作在 [`src/arch/arm64/vm.c`](https://github.com/sysprog21/kvm-host/blob/master/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](https://en.wikichip.org/wiki/ampere_computing/emag/8180) 主機僅能支援建立虛擬 GICv3 的中斷控制器,故下面以 GICv3 來說明。
GICv3 中斷控制器架構:
![](https://hackmd.io/_uploads/SJHJc1Xuh.png)
圖取自 [GICv3 and GICv4 Software Overview](https://developer.arm.com/documentation/dai0492/latest/)
在 GICv3 中, CPU Interface 是以系統暫存器的方式存取,使用 `msr` 和 `mrs` 指令來讀寫暫存器。而 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` 結構。如下:
```c
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` ,故流程如下:
```c
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` 結構,內容如下:
```c
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`](https://github.com/sysprog21/kvm-host/blob/master/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 得到。故完整程式碼如下:
```c
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 ` 定義如下:
```c
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`](https://github.com/sysprog21/kvm-host/blob/master/src/arch/arm64/vm.c) 中)]
```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`](https://github.com/sysprog21/kvm-host/blob/master/src/arch/arm64/desc.h) 中:
```c
#define SERIAL_IRQ 0
#define VIRTIO_BLK_IRQ 1
```
### Platform 裝置
這是 arm64 的 `vm_init_platform_device()` 的程式碼:
```c
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 定義。
```c
typedef struct {
....
/* This device is a bridge between mmio_bus and io_bus*/
struct dev iodev;
} vm_arch_priv_t;
```
其 handler :
```c
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()` 中的相關程式:
```c
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](https://github.com/sysprog21/kvm-host/pull/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 程式碼如下:
```c=
/* 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 的程式:
```c
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](https://docs.kernel.org/arm64/booting.html)
> 實作在 [`src/arch/arm64/vm.c`](https://github.com/sysprog21/kvm-host/blob/master/src/arch/arm64/vm.c) 中的 `vm_arch_load_image` 和 `vm_arch_load_initrd` 函式。
根據 [Linux 核心文件](https://docs.kernel.org/arm64/booting.html)提供的資料,一個 arm64 的 Linux kernel image , header 的內容如下:
```c
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_image` 和 `vm_arch_load_initrd` 二個函式,包含載入 Linux 核心和 initramfs 的程式。
`vm_arch_load_image` 會檢查核心的 header 。檢查完成後,把核心載入預先定義好的位址( `ARM_KERNEL_BASE` ),再加上 `text_offset` 。
```c
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`):
```c
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](https://github.com/devicetree-org/devicetree-specification/releases/download/v0.3/devicetree-specification-v0.3.pdf)
> [kvmtool 的實作](https://github.com/kvmtool/kvmtool/blob/master/arm/fdt.c)
啟動核心前需要把 device tree 所在的實體記憶體位址透過 `x0` 暫存器傳給核心。
[kvmtool](https://github.com/kvmtool/kvmtool) 透過 libfdt 來產生 device tree ,因此我們也可以採用同樣的作法。
libfdt 是包含在 dtc 套件內的函式庫,此為其 Git Repository 如下
- <https://git.kernel.org/pub/scm/utils/dtc/dtc.git>
用 git 把它 clone 下來後,在裡面下 `make libfdt` 命令即可編譯,在 `libfdt` 目錄內就有我們要的函式庫 `libfdt.a`
實作 device treE 過程,有參考 kvmtool 的實作。這是 [kvmtool 的 DTB Dump](https://gist.github.com/yanjiew1/53be2d03430d187e61fff48eca3b6591) 。
Device Tree 主要會定義下列:
- Machine Type
- CPU
- Memory
- Initramfs Address
- Bootargs
- Interrupt Controller
- 16550 UART Addresses
- PCI Addresses
:::info
研究 `libfdt` API 時,發現裡面的註解有打錯字。送了 [Pull Request](https://github.com/dgibson/dtc/pull/104) 。被接受後的 [Commit](https://github.com/dgibson/dtc/commit/ccf1f62d59adc933fb348b866f351824cdd00c73)
:::
使用 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](https://github.com/sysprog21/kvm-host/pull/24)
並在 Makefile 加入 `libfdt` 的路徑和 CFLAGS
#### 實作 device tree 產生程式
參考了下面的文件
- [Device Tree Specification](https://github.com/devicetree-org/devicetree-specification/releases/download/v0.3/devicetree-specification-v0.3.pdf)
- [kvmtool 的實作](https://github.com/kvmtool/kvmtool/blob/master/arm/fdt.c)
- [PCI Bus Binding to: IEEE Std 1275-1994](https://www.devicetree.org/open-firmware/bindings/pci/pci2_1.pdf)
- [eLinux device tree usage](https://elinux.org/Device_Tree_Usage)
- [GICv3 device tree binding](https://www.kernel.org/doc/Documentation/devicetree/bindings/interrupt-controller/arm%2Cgic-v3.txt)
- [arm Arch timer device tree binding](https://www.kernel.org/doc/Documentation/devicetree/bindings/arm/arch_timer.txt)
在 [`src/arch/arm64/vm.c`](https://github.com/sysprog21/kvm-host/blob/master/src/arch/arm64/vm.c) 中,新增了 `generate_fdt()` ,它會被 `vm_late_init()` 呼叫,會在特定的 Guest 位址中產生 Linux 可以使用的 device tree 。
為了方便錯誤處理,故寫了個巨集:
```c
/* 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()` 的部份程式,因篇幅綠故,沒有完整列出:
```c
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` 的部份。我花了很多時間實驗和看下述文件,才實作出來的。
- [Device Tree Specification](https://github.com/devicetree-org/devicetree-specification/releases/download/v0.3/devicetree-specification-v0.3.pdf)
- [kvmtool 的實作](https://github.com/kvmtool/kvmtool/blob/master/arm/fdt.c)
- [PCI Bus Binding to: IEEE Std 1275-1994](https://www.devicetree.org/open-firmware/bindings/pci/pci2_1.pdf)
- [eLinux device tree usage](https://elinux.org/Device_Tree_Usage)
### 設定 vCPU 暫存器初始值
> 參考資料: [Booting arm64 Linux](https://docs.kernel.org/arm64/booting.html)
> [kvmtool arm/aarch64/kvm-cpu.c](https://github.com/kvmtool/kvmtool/blob/master/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 的起始位址。
```c
/* 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, ®) < 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, ®) < 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, ®) < 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](https://github.com/sysprog21/kvm-host/pull/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`](https://github.com/sysprog21/kvm-host/blob/master/mk/external.mk) 。故做了下面的修改:
```diff
# 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`](https://github.com/sysprog21/kvm-host/blob/master/configs/linux-arm64.config) 。而原本 x86-64 的設定檔重新命名為 [`linux-x86.config`](https://github.com/sysprog21/kvm-host/blob/master/configs/linux-x86.config) 。
做了上述修改後,只要執行
```shell
$ make check
```
即可在 arm64 測試 kvm-host 功能。
### 啟動 arm64 Linux
以上是主要移植到 arm64 的過程。完整的程式碼[在 GitHub](https://github.com/sysprog21/kvm-host)上。
可以用 `git` 把程式碼及 dtc submodule 下載下來,
```shell
$ git clone https://github.com/sysprog21/kvm-host.git
$ cd kvm-host
$ git submodule update --init
```
再用 `make` 編譯。
```shell
$ make
```
若要快速進行測試,可以用 `make check` 。過程中會要輸入 sudo 密碼。
```shell
$ make check
```
之後準備好 arm64 的核心和 initramfs。(initramfs 可用 buildroot 製作),假設這二個檔案分別為 `Image` 和 `rootfs.cpio` 。輸入下面命令可以開啟虛擬機器執行核心 `Image` 及使用 `rootfs.cpio` initramfs
```shell
$ ./build/kvm-host -k Image -i rootfs.cpio
```
## TODO: 改進 UART 實作
> [GitHub Issue #15](https://github.com/sysprog21/kvm-host/issues/15)
> [Pull Requeest #22](https://github.com/sysprog21/kvm-host/pull/22)
> Wikibooks [8250 UART 參考資料](https://en.wikibooks.org/wiki/Serial_Programming/8250_UART_Programming)
### 現有問題
目前 16550A 裝置模擬存在二個問題,分別是 signal 風暴和與 `vm_run()` 直接呼叫 serial 的 `serial_console()` API 。
kvm-host 中的 serial 裝置會建立一個執行緒,此執行緒工作很簡單,用 `poll` 系統呼叫 stdin 是否可讀取,若可讀取則發送 SIGUSR1 至主執行緒(KVM_RUN 執行的執行緒)。
```c
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()` 程式碼的其中一片段:
```c
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` 的結構:
```diff
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;
};
```
`lock` 和 `cond` 是改進後版本加上去的。
在 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 執行程式變為:
```c
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 :
```c
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](https://github.com/sysprog21/kvm-host/pull/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 核心載入流程,及系統程式設計這方面更有經驗。