# 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, &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_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, &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](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 核心載入流程,及系統程式設計這方面更有經驗。