Try   HackMD

測試 Linux 核心的虛擬化環境

virtme 是 Linux 核心開發者利用 QEMU 所建立一個輕量級的 Linux 核心測試環境,和 Linux 核心原始程式碼有很好的整合。

virtme 已不再維護,請改用 virtme-ng

預先安裝套件

以 Ubuntu Linux 22.04 來說,需要安裝以下套件:

$ sudo apt -y -q install       \
  bc                           \
  flex                         \
  bison                        \
  build-essential              \
  expect                       \
  git                          \
  libncurses-dev               \
  libssl-dev                   \
  libelf-dev                   \
  u-boot-tools                 \
  wget                         \
  xz-utils                     \
  qemu-kvm                     \
  iproute2                     \
  python3                      \
  python3-pip

安裝 virtme

virtme 以 Python3 撰寫,可用 pip 安裝,注意 Python 版本需要大於 3.3

$ pip3 install --user \
    git+https://git.kernel.org/pub/scm/utils/kernel/virtme/virtme.git

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 →
$PATH 環境變數需要一併更新:

$ export PATH=$HOME/.local/bin:$PATH

或者列於 $HOME/.bashrc 中。

利用 virtme 建立 Linux 核心測試環境

首先,找一個容納 6 GB 空間的目錄,你需要記住絕對路徑,之後我們還用得到。為了行文的便利,我們用 /home/ubuntu 來稱呼。

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 →
以下命令都該用一般使用者權限來執行,切勿用 root 帳號

執行以下命令來取得 Linux 核心原始程式碼:

$ git clone https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux
$ cd linux
$ git checkout -b linux-6.1.y origin/linux-6.1.y

使用 virtme 選取預設核心組態並編譯:

$ virtme-configkernel --defconfig
$ make ARCH=x86 CROSS_COMPILE=x86_64-linux-gnu- -j$(nproc)

針對 Arm64 處理器架構,改為 make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j$(nproc) 命令

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 →
這邊需要確保 CONFIG_DEBUG_INFO 在組態中有被開啟,以利後續實驗使用。方法是在 $ make 命令前,執行 $ grep CONFIG_DEBUG_INFO .config,預期要看到 CONFIG_DEBUG_INFO=y

在編譯結束後,預期可見以下訊息:

Kernel: arch/x86/boot/bzImage is ready

針對 Arm64 處理器架構,是 arch/arm64/boot/Image

透過以下命令,在 QEMU 虛擬機器中啟動 Linux 核心:

$ virtme-run --kdir . --mods=auto

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 →
看到 virtme-init 開頭的訊息時,請保持耐心等待

上方命令的 --mods=auto 指定自動掛載 Linux 核心模組,若不需要這機制,可改為 $ virtme-run --kimg arch/x86/boot/bzImage

預期可見以下訊息:

virtme-init: console is ttyS0
root@(none):/# 

這時你可在 virtme 輸入一些命令,例如:

# uname -a

為了區隔模擬環境和宿主 (host,即 Ubuntu Linux) 端的終端機操作,我們約定 $ 開頭的命令是執行於宿主端環境,而 # 開頭的命令則執行於 virtme

當你見到 Linux (none) 6.1.21 一類的訊息,就表示成功運作 virtme

倘若要離開測試環境,你可按下 Ctrl-A 放開再按下 X 按鍵。

virtme 的選項

除了用 -kdir 選項指定核心映像檔,尚可指定若干選項,例如:

  • -a : 附加 Linux 核心啟動參數
    • 例如: -a "nokaslr" 抑制 ASLR
  • --disk : 指定磁碟,測試檔案系統或 I/O 操作很好用
    ​​​​$ dd if=/dev/zero of=/tmp/disk.img bs=1M count=1024
    ​​​​$ virtme-run --kimg arch/x86/boot/bzImage --disk "disk1=/tmp/disk.img"
    
    之後在模擬環境中可執行 fdisk -l /dev/sda,隨後亦可掛載檔案系統:
    ​​​​# mkdir -p /tmp/disk
    ​​​​# mkfs.ext4 /dev/sda
    ​​​​# mount /dev/sda /tmp/disk
    
  • --kimg : 指定核心映像檔,例如 -kimg arch/x86/boot/bzImage

載入核心模組

使用 virtmekimg 參數啟動核心映像檔後,若我們想要使用 modprobe 載入與核心一同編譯的核心模組時,會因為與 Ubuntu Linux 共用檔案系統,而無法從預設路徑 /lib/modules/$(uname -r) 中讀取相關設定檔。可以透過以下的方式來進行設定:

我們需要將核心模組安裝到一個暫存的目錄中

$ make modules_install INSTALL_MOD_PATH=/home/ubuntu/test-kmod

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 →
暫存目錄需避免設定於 /tmp 目錄之下,因為宿主的 /tmp 與模擬環境中的 /tmp 無法共通

接著啟動模擬環境,並將前述放置核心模組的目錄掛載到 /lib/modules/

# mount --bind /home/ubuntu/test-kmod/lib/modules /lib/modules

設定完畢後,就能夠使用 modprobe 載入預先編譯好的核心模組。例如載入 TLS 模組

# modprobe tls

並且可以使用 lsmod 列出目前載入的所有核心模組及其相依性。

編譯和測試核心模組

virtme 建立的虛擬環境所用的檔案系統是共用 Ubuntu Linux 的檔案系統 (透過 9P over VirtIO),因此你可以在裡頭編譯核心模組!

還記得之前記住的絕對路徑吧?Linux 核心原始程式碼就在 /home/ubuntu/linux 中,不過在 virtme 創造的虛擬環境中,該路徑是唯讀。為了便利起見,我們就在 /tmp 目錄實驗。你可以研讀 virtme 文件,以得知檔案分享和權限處理的機制。

依據 UNIX 慣例,我們用 Hello World 核心模組來示範:首先在 /tmp 建立實驗用的目錄:

# mkdir -p /tmp/hello

建立檔案 /tmp/hello/hello.c,其內容為:

#include <linux/init.h>
#include <linux/module.h>
MODULE_LICENSE("GPL");

static int hello_init(void)
{
    printk(KERN_ALERT "Hello World! - init\n");
    return 0;
}

static void hello_exit(void)
{
    printk(KERN_ALERT "Hello World! - exit\n");
}

module_init(hello_init);
module_exit(hello_exit);

還要有對應的 /tmp/hello/Makefile,內容如下: (記得要更換 /home/ubuntu 這個路徑)

KDIR=/home/ubuntu/linux

obj-m += hello.o

PWD := $(shell pwd)
# KDIR := $(PWD)/..
# 上面這行會導致錯誤,會使 KDIR 導引到錯誤位置 by hankTaro

all:
    $(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean

注意在 all:$(MAKE) 之間不是空白字元,而是 Tab,同理,clean:$(MAKE) 之間也以 Tab 區隔。

接著在 virtme 編譯核心模組:

# make

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 →
在虛擬環境中,要等待一陣子,請保持耐心。之後應該在宿主端編譯核心及其模組。

預期可見以下輸出:

make[1]: Entering directory '/home/ubuntu/linux'
  CC [M]  /tmp/hello/hello.o
  MODPOST 1 modules
  CC [M]  /tmp/hello/hello.mod.o
  LD [M]  /tmp/hello/hello.ko
make[1]: Leaving directory '/home/ubuntu/linux'

萬事俱備,就來測試:

# insmod hello.ko 

預期可見以下訊息:

[ 2096.143949] hello: loading out-of-tree module taints kernel.
[ 2096.160912] Hello World! - init

移除核心模組也行:

# rmmod hello

透過 virtme 來測試 Linux 核心

我們故意在 Linux 核心原始程式碼做以下更動:

--- a/init/main.c
+++ b/init/main.c
@@ -1372,6 +1372,7 @@ static int __ref kernel_init(void *unused)
 
        rcu_end_inkernel_boot();
 
+       *(char *) NULL = 0;
        if (ramdisk_execute_command) {
                ret = run_init_process(ramdisk_execute_command);
                if (!ret)

透過上述的 $ make ARCH=x86 CROSS_COMPILE=x86_64-linux-gnu- 命令來編譯核心模組,接著用 $ virtme-run --kimg arch/x86/boot/bzImage 啟動 Linux 核心,預期可見到以下錯誤訊息:

[    1.976785] Call Trace:
[    1.977534]  ret_from_fork+0x22/0x40
[    1.977835] Modules linked in:
[    1.978013] CR2: 0000000000000000
[    1.978486] ---[ end trace eeb768630587adc1 ]---
[    1.978687] RIP: 0010:kernel_init+0x3f/0x100
[    1.978838] Code: 00 0f 84 d2 00 00 00 e8 3f 7a 5d ff e8 ba 86 55 ff e8 95 2d 56 ff c7 05 bf 47 a1 00 02 00 00 00 e8 b6 0c 6d ff e8 11 2c 5d ff <c6> 04 25 00 00 00 00 00 48 8b 3d 4a 4b fd 00 48 85 ff 74 21 e8 28
[    1.979560] RSP: 0018:ffffbbf280013f50 EFLAGS: 00000246
[    1.979770] RAX: 0000000000000000 RBX: ffffffff9bb72490 RCX: 0000000000000000
[    1.980045] RDX: 0000000000000005 RSI: ffff9bb447498018 RDI: 000000000002e6b0
[    1.980333] RBP: 0000000000000000 R08: 0000000000000163 R09: 0000000000000049
[    1.980613] R10: fffff56d00147b88 R11: 4b363735203a7972 R12: 0000000000000000
[    1.980902] R13: 0000000000000000 R14: 0000000000000000 R15: 0000000000000000
[    1.981133] FS:  0000000000000000(0000) GS:ffff9bb447a00000(0000) knlGS:0000000000000000
[    1.981433] CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[    1.981644] CR2: 0000000000000000 CR3: 000000000520a000 CR4: 00000000000006f0
[    1.982090] Kernel panic - not syncing: Attempted to kill init! exitcode=0x00000009
[    1.982676] Kernel Offset: 0x1a000000 from 0xffffffff81000000 (relocation range: 0xffffffff80000000-0xffffffffbfffffff)
[    1.983192] ---[ end Kernel panic - not syncing: Attempted to kill init! exitcode=0x00000009 ]---

我們在即將啟動 init 程式之前,故意做非預期的記憶體操作,這時就觸發 Linux 核心註冊的例外處理機制。

上述實驗結束後,請還原程式碼並編譯核心:

$ git reset --hard HEAD
$ make ARCH=x86 CROSS_COMPILE=x86_64-linux-gnu- -j$(nproc)

搭配 crash 進行核心偵錯

許多時候,除了在觸發 kernel panic 時系統會提供對應的 call trace 外,我們也需要交叉比對其他的資訊,諸如 dmesg 或是所有行程,方可定位出問題所在,而 crash 就是一款可用來偵錯的工具。

簡單來說,crash 是一種針對核心偵錯特化的一種 GDB

crash 的維護者,Red Hat 工程師 David Aderson 在〈Whilte Paper: Crash Utility〉提到:

While gdb is an incredibly powerful tool, it is designed to debug user programs, and is not at all "kernel-aware".

安裝 crash

雖然 Ubuntu Linux 提供預先編譯好的 crash 套件,但因為較新的核心 (v5.17 以上) 內部結構已有調整,因此需要 v8.0.1 以上之 crash 才能解析。

我們可以在 crash-utility 下載 8.0.2 版本的 crash 並進行編譯與安裝:

$ wget https://github.com/crash-utility/crash/archive/8.0.2.tar.gz -O crash.tar.gz
$ tar xf crash.tar.gz
$ cd crash-8.0.2
$ make -j$(nproc)
$ sudo make install

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 →
編譯 crash 需要較長的時間,因為要從 GDB 原始程式碼建構,後者又有一系列相依的套件要編譯。

產生 kernel dump

因為 virtme 使用到 QEMU 來建立虛擬環境,因此我們可用 QEMU 的功能選項來產生執行時的 kernel dump。

首先我們需要在啟動虛擬環境的命令中加入 --qemu-opts -qmp tcp:localhost:4444,server,nowait 這樣的參數,啟動 QEMU 的 QEMU Machine Protocol (QMP) 功能,命令如下:

$ virtme-run --kimg arch/x86/boot/bzImage \
             --qemu-opts -qmp tcp:localhost:4444,server,nowait

啟動虛擬環境後,我們使用 Linux Magic System Request 來觸發 kernel panic

# echo c > /proc/sysrq-trigger

預期可以看到系統發生 kernel panic 並印出相關訊息。

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 →
請確保該命令執行在模擬環境之中,若執行在宿主系統上會導致目前使用的系統崩潰

維持虛擬環境繼續執行的情況下,我們回到宿主系統,使用 QMP 來與現行 QEMU 環境通訊,擷取目前虛擬環境的 kernel dump。

依照以下的步驟產生 kernel dump

  1. 使用 telnet 與 QEMU 連線,預期可以看到 QMP 的歡迎訊息
$ telnet localhost 4444
{"QMP": {"version": {"qemu": {"micro": 0, "minor": 6, "major": 1}, "package": ""}, "capabilities": []}}
  1. 輸入以下的命令進入 QMP 的命令模式:
telnet> { "execute": "qmp_capabilities" }
  1. 執行命令將虛擬環境的記憶體內容傾到於指定的檔案中
telnet> { "execute": "dump-guest-memory", "arguments": {"paging": false, "protocol": "file:$out_file" }}

執行 crash 並分析 kernel dump

準備好含有 debug symbol 的 vmlinux 以及 kernel dump,就可以開始使用 crash 偵錯

$ crash /path/to/your/vmlinux /path/to/your/vmcore.img

<...>

      KERNEL: vmlinux                           
    DUMPFILE: vmcore.img
        CPUS: 1
        DATE: Mon Mar 20 22:24:12 CST 2023
      UPTIME: 00:00:32
LOAD AVERAGE: 1.00, 0.27, 0.09
       TASKS: 57
    NODENAME: (none)
     RELEASE: 6.2.7
     VERSION: #2 SMP PREEMPT_DYNAMIC Sat Mar 18 20:58:35 CST 2023
     MACHINE: x86_64  (1991 Mhz)
      MEMORY: 127.5 MB
       PANIC: "Kernel panic - not syncing: sysrq triggered crash"
         PID: 137
     COMMAND: "bash"
        TASK: ffff8fd4c2520000  [THREAD_INFO: ffff8fd4c2520000]
         CPU: 0
       STATE: TASK_RUNNING (PANIC)

crash> 

進入 crash 後,除了會先印出當前使用核心的基本訊息以及 panic 的原因之外,我們也可以透過 shell 風格的命令來印出發生 panic 時 kernel ring buffer 的內容

下面示範印出 ring buffer 最後 30 行的內容:

crash> dmesg | tail -30
[   32.542695] sysrq: Trigger a crash
[   32.542986] Kernel panic - not syncing: sysrq triggered crash
[   32.543488] CPU: 0 PID: 137 Comm: bash Not tainted 6.2.7 #2
[   32.543848] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.15.0-1 04/01/2014
[   32.544343] Call Trace:
[   32.545231]  <TASK>
[   32.545606]  dump_stack_lvl+0x37/0x50
[   32.546117]  panic+0x2fd/0x320
[   32.546275]  ? _printk+0x57/0x80
[   32.546440]  sysrq_handle_crash+0x15/0x20
[   32.546622]  __handle_sysrq+0xa1/0x180
[   32.546794]  write_sysrq_trigger+0x23/0x40
[   32.546969]  proc_reg_write+0x54/0xa0
[   32.547134]  ? preempt_count_add+0x4c/0xa0
[   32.547281]  vfs_write+0xc4/0x3c0
[   32.547407]  ? __do_sys_newfstatat+0x35/0x60
[   32.547532]  ksys_write+0x5e/0xe0
[   32.547664]  do_syscall_64+0x3f/0x90
[   32.547800]  entry_SYSCALL_64_after_hwframe+0x72/0xdc
[   32.548174] RIP: 0033:0x7f4ebe5bba37
[   32.548608] Code: 10 00 f7 d8 64 89 02 48 c7 c0 ff ff ff ff eb b7 0f 1f 00 f3 0f 1e fa 64 8b 04 25 18 00 00 00 85 c0 75 10 b8 01 00 00 00 0f 05 <48> 3d 00 f0 ff ff 77 51 c3 48 83 ec 28 48 89 54 24 18 48 89 74 24
[   32.549317] RSP: 002b:00007ffda4922dd8 EFLAGS: 00000246 ORIG_RAX: 0000000000000001
[   32.549692] RAX: ffffffffffffffda RBX: 0000000000000002 RCX: 00007f4ebe5bba37
[   32.550009] RDX: 0000000000000002 RSI: 000056527820daf0 RDI: 0000000000000001
[   32.550323] RBP: 000056527820daf0 R08: 0000000000000000 R09: 000056527820daf0
[   32.550641] R10: 0000000000000077 R11: 0000000000000246 R12: 0000000000000002
[   32.550929] R13: 00007f4ebe6c1780 R14: 00007f4ebe6bd600 R15: 00007f4ebe6bca00
[   32.551330]  </TASK>
[   32.552166] Kernel Offset: 0x17000000 from 0xffffffff81000000 (relocation range: 0xffffffff80000000-0xffffffffbfffffff)
[   32.552936] ---[ end Kernel panic - not syncing: sysrq triggered crash ]---

從上述的結果,我們可以得知 panic 是被 PID 為 137 的 bash 所觸發

同時我們也可以利用 ps 查看該核心所有行程和狀態

crash> ps
      PID    PPID  CPU       TASK        ST  %MEM      VSZ      RSS  COMM
        0       0   0  ffffffff99a14a00  RU   0.0        0        0  [swapper/0]
        1       0   0  ffff8fd4c1128000  IN   1.7     4492     2228  virtme-init
        2       0   0  ffff8fd4c1128e40  IN   0.0        0        0  [kthreadd]
        3       2   0  ffff8fd4c1129c80  ID   0.0        0        0  [rcu_gp]
        4       2   0  ffff8fd4c112aac0  ID   0.0        0        0  [rcu_par_gp]
        5       2   0  ffff8fd4c112b900  ID   0.0        0        0  [slub_flushwq]
        6       2   0  ffff8fd4c112c740  ID   0.0        0        0  [netns]
        7       2   0  ffff8fd4c112d580  ID   0.0        0        0  [kworker/0:0]
        8       2   0  ffff8fd4c112e3c0  ID   0.0        0        0  [kworker/0:0H]
       <...>
       83      74   0  ffff8fd4c1b6b900  IN   2.6    22988     3464  systemd-udevd
       84      74   0  ffff8fd4c1b6c740  IN   2.6    22988     3388  systemd-udevd
       85      74   0  ffff8fd4c1b6d580  IN   2.5    22988     3308  systemd-udevd
       86      74   0  ffff8fd4c1b6e3c0  IN   2.8    22988     3612  systemd-udevd
>     137       1   0  ffff8fd4c2520000  RU   2.0     4644     2656  bash

於是我們可以使用 bt 調查編號 137 的行程對應的 call stack 及其暫存器狀態

crash> bt 137
PID: 137      TASK: ffff8fd4c2520000  CPU: 0    COMMAND: "bash"
 #0 [ffff9d09c026bd90] panic at ffffffff98084829
 #1 [ffff9d09c026be10] sysrq_handle_crash at ffffffff985efac5
 #2 [ffff9d09c026be18] __handle_sysrq at ffffffff985f00f1
 #3 [ffff9d09c026be48] write_sysrq_trigger at ffffffff985f0703
 #4 [ffff9d09c026be58] proc_reg_write at ffffffff982fd284
 #5 [ffff9d09c026be70] vfs_write at ffffffff98274cf4
 #6 [ffff9d09c026bf00] ksys_write at ffffffff982751ae
 #7 [ffff9d09c026bf38] do_syscall_64 at ffffffff98e2175f
 #8 [ffff9d09c026bf50] entry_SYSCALL_64_after_hwframe at ffffffff990000ae
    RIP: 00007f4ebe5bba37  RSP: 00007ffda4922dd8  RFLAGS: 00000246
    RAX: ffffffffffffffda  RBX: 0000000000000002  RCX: 00007f4ebe5bba37
    RDX: 0000000000000002  RSI: 000056527820daf0  RDI: 0000000000000001
    RBP: 000056527820daf0   R8: 0000000000000000   R9: 000056527820daf0
    R10: 0000000000000077  R11: 0000000000000246  R12: 0000000000000002
    R13: 00007f4ebe6c1780  R14: 00007f4ebe6bd600  R15: 00007f4ebe6bca00
    ORIG_RAX: 0000000000000001  CS: 0033  SS: 002b

如果我們懷疑其中的 sysrq_handle_crash 函式可能是觸發 panic 的元兇,則可以使用類似 GDB 的命令 list 列出該函式對應的原始碼

crash> gdb list sysrq_handle_crash
146     #else
147     #define sysrq_unraw_op (*(const struct sysrq_key_op *)NULL)
148     #endif /* CONFIG_VT */
149     
150     static void sysrq_handle_crash(int key)
151     {
152             /* release the RCU read lock before crashing */
153             rcu_read_unlock();
154     
155             panic("sysrq triggered crash\n");

從上面的結果我們可以看到在第 155 行的程式碼實際呼叫 panic,導致核心進入 panic 的處理機制。

參考資料