主講人: jserv
User-Mode Linux (以下簡稱 UML
) 顧名思義是將 Linux 核心移植到 user-space,如此一來,就可將這個修改的核心當作一般的 Linux process 來執行,技術分類來說屬於 para-virtualization,在 Kernel-based Virtual Machine (KVM) 問世前 (Intel/AMD 硬體虛擬化加速擴展普及之前,見 x86 virtualization),UML 是唯一 Linux 核心內建的虛擬化機制。UML 有什麼好處呢?至少有以下應用:
kernel unit-testing framework(KUnit)也採用 UML:
KUnit addresses the problem of being able to run tests without needing a virtual machine or actual hardware with User Mode Linux. User Mode Linux is a Linux architecture, like ARM or x86; however, unlike other architectures it compiles to a standalone program that can be run like any other program directly inside of a host operating system; to be clear, it does not require any virtualization support; it is just a regular program.
透過 UML,我們可以建構出一個極佳的測試環境。Android 5.0 以後,使用 UML 來測試核心和網路連線:
rootfs/net_test.sh
及 run_net_test.sh
UML 所使用的檔案系統對宿主 Linux 來說也不過只是單純的檔案,一切都好比置身於保護的 sandbox (原文的意思就是「貓沙盒」,給調皮的貓咪一個自得其樂卻不傷害家具的器具,引申為受限的封閉測試機制),經由適當配置,我們大可放心對虛擬機器作任何更動,而不必擔憂損害到真實的硬體與系統。
相當重要的觀念是:UML 本身就是全功能的核心,具備專屬的虛擬環境,對硬體的支援僅仰賴於宿主 Linux 系統,
UML = Linux-Kernel running as regular Linux user-process without root privileges
pcap - not much use for actual network connectivity, but great for monitoring traffic on the host
CONFIG_UML_TIME_TRAVEL_SUPPORT
- ptrace 系統呼叫用以實做 gdb 一類可斷點 (breakpoint) 的追蹤除錯,或作系統呼叫的追蹤分析
- ptrace 允許一個 parent process 去監控另一個 process 的執行,並得以檢驗 / 更改執行時期的系統 image (映射於虛擬記憶體) 和暫存器
- 使用情境可透過 fork 系統呼叫去建立 child process (搭配 exec 系統呼叫) 或者直接追蹤某個已執行的 process
UML 預計會重用 LKL (Linux Kernel Library) 的成果。
延伸閱讀: LKL: 重用 Linux 核心的成果
編譯 User-Mode Linux 所仰賴的套件,原則上如同編譯 Linux 核心,以 Ubuntu Linux 來說,需要事先安裝以下套件:
$ sudo apt install build-essential libncurses-dev flex bison
$ sudo apt install xz-utils wget ca-certificates bc
取得 Linux 核心原始程式碼,以 v5.12.0 為例:
$ wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.12.tar.xz
$ tar xvf linux-5.12.tar.xz
切換到 Linux 核心原始程式碼,欣賞史上最偉大的開放原始碼專案的面貌:
$ cd linux-5.12
若沒有特別說明,本文在開發端 (即編輯修改原始程式碼、編譯和準備相關工具等等動作) 的工作目錄皆位於上方解壓縮後的核心原始程式碼目錄。
為避免目錄切換導致的錯誤,可用環境變數保存: (此處 WS
指 "workspace",命名沒有特別意思)
export WS=`pwd`
設定核心組態,特別注意 ARCH=um
就是指定 UML:
$ make mrproper
$ make defconfig ARCH=um SUBARCH=x86_64
$ make linux ARCH=um SUBARCH=x86_64 -j `nproc`
如果編譯順利的話,預期會得到名為 linux
的執行檔:
$ file linux
參考輸出:
linux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 3.2.0
為了說明 UML 真的就是一般的執行檔,我們也能這樣做:
$ ./linux --help
然後就會看到輸出中有 --showconfig
, iomem
, mem
, debug
等等字樣。
光有 Linux 核心是不夠的,我們還要準備 root file system (簡稱 rootfs
),本文採用 Alpine Linux,後者支援 x86, x86-64, ARMhf, AArch64 等硬體架構。
建立檔案系統時,可能會遇到權限問題,但我們又不想貿然用 sudo
來執行命令 (避免不小心毀壞開發環境),這時可安裝 fakeroot 套件:
$ sudo apt install fakeroot
以下從 Alpine Linux 的套件管理系統 APK (不要跟 Ubuntu/Debian 的 apt 搞混) 建立 rootfs:
$ export REPO=http://dl-cdn.alpinelinux.org/alpine/v3.13/main
$ mkdir -p rootfs
$ curl $REPO/x86_64/APKINDEX.tar.gz | tar -xz -C /tmp/
$ export APK_TOOL=`grep -A1 apk-tools-static /tmp/APKINDEX | cut -c3- | xargs printf "%s-%s.apk"`
$ curl $REPO/x86_64/$APK_TOOL | fakeroot tar -xz -C rootfs
$ fakeroot rootfs/sbin/apk.static \
--repository $REPO --update-cache \
--allow-untrusted \
--root $PWD/rootfs --initdb add alpine-base
$ echo $REPO > rootfs/etc/apk/repositories
$ echo "LABEL=ALPINE_ROOT / auto defaults 1 1" >> rootfs/etc/fstab
Operation not permitted
script exited with error 127
可先略過。
接著我們就能準備啟動 UML,為方便之後測試,我們先建立以下檔案,名為 UML.sh
:
#!/bin/sh
./linux umid=uml0 \
root=/dev/root rootfstype=hostfs hostfs=./rootfs \
rw mem=64M init=/bin/sh quiet
注意,當指定 rootfs 型態為 hostfs 時,可搭配以下兩種參數: (擇一或組合):
hostfs=相對路徑
rootflags=絕對路徑
這裡採用 hostfs=
來指定相對路徑,即稍早建立的 rootfs
目錄。
隨即啟動 UML:
$ chmod +x UML.sh
$ ./UML.sh
命令一執行,就會看到類似以下的輸出:
Failed to initialize ubd device 0 :Couldn't determine size of device's file
/bin/sh: can't access tty; job control turned off
/ #
別懷疑,這表示 UML 和第一個使用者層級的程式 (即 /bin/sh
) 已啟動,很快!
可在 UML.sh
裡頭 ./linux
後方加上 ubd0=/dev/null
來抑制 Failed to initialize ubd device 0 :Couldn't determine size of device's file
這個錯誤訊息。
為了區隔命令不是在開發主機 (也稱為 host) 而是在 UML 的環境 (也稱為 guest) 中執行,我們用 UML #
的表示法來標註在 UML 環境中執行的命令。
稍早準備的檔案系統,已有 busybox,不過相關的 symbolic link 還未設定,我們需要執行以下: (只要做一次)
UML # /bin/busybox --install
由於我們沒有特別去撰寫 init scripts,像是 procfs 沒預先掛載,需要手動執行以下命令:
UML # mount -t proc none /proc
之後你可在 UML 執行 ps
和 uname -a
一類的命令。
還記得編譯 UML 時,我們在 Linux 核心程式碼指定 ARCH=um
,這對於 UML 環境的影響是什麼呢?執行下列命令:
UML # cat /proc/cpuinfo
預期會得到類似的輸出:
processor : 0
vendor_id : User Mode Linux
model name : UML
mode : skas
host : Linux node1 4.15.0-72-generic #81-Ubuntu SMP Tue Nov 26 12:20:02 UTC 2019 x86_64
bogomips : 7722.59
在 mode
那行可見是 skas
,可對照 UML 的文件 skas mode,後者是 "Separate Kernel Address Space" 的縮寫。
在命令提示執行 exit
命令,就會讓 UML 停止運作,因為已經沒有使用者層級的程式:
UML # exit
你會看到類似的輸出:
Kernel panic - not syncing: Attempted to kill init! exitcode=0x00000000
...
(core dumped) ./linux
如果發現開發環境的終端機游標消失,可執行 reset
(不要擔心,這是重置終端機,不是整台電腦) 命令。
若你嫌每次都要額外執行 $ reset
,也可將下方命令加在 UML.sh
的最後一行:
stty sane ; echo
這樣下次啟動 UML 並離開後,終端機就會自行復原。
以下命令針對開發環境:
$ sudo ip tuntap add tap0 mode tap
$ sudo ip link set tap0 up
$ sudo ip address add 192.168.100.100/24 dev tap0
修改之前的 UML 啟動參數,在 umid=uml0
後面增加 hostname=uml1 eth0=tuntap,tap0
(記得前後有空白)
重新啟動 UML 後,執行以下命令:
UML # ip link set eth0 up
UML # ip address add 192.168.100.101/24 dev eth0
UML # ping -c 3 192.168.100.100
預期可見以下輸出:
PING 192.168.100.100 (192.168.100.100): 56 data bytes
64 bytes from 192.168.100.100: seq=0 ttl=64 time=0.343 ms
64 bytes from 192.168.100.100: seq=1 ttl=64 time=0.235 ms
64 bytes from 192.168.100.100: seq=2 ttl=64 time=0.281 ms
--- 192.168.100.100 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2054ms
rtt min/avg/max/mdev = 0.050/0.057/0.065/0.006 ms
tini 工具可避免 UML 一類的 guest 環境造成 zombie processes。取得並在 rootfs 做好準備:
$ wget -O rootfs/sbin/tini https://github.com/krallin/tini/releases/download/v0.19.0/tini-static
$ chmod +x rootfs/sbin/tini
建立 rootfs/init.sh
檔案,其內容如下:
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sys /sys
exec /sbin/tini /bin/sh +m
記得要變更檔案權限,使其可執行:
$ chmod +x rootfs/init.sh
在 rootfs/init.sh
中,我們在 /bin/sh
後方加上 +m
參數,目的是抑制 /bin/sh: can't access tty; job control turned off
這項錯誤訊息。
之前我們準備了 UML.sh
檔案來啟動 UML,因應上述這個 init script,修改 UML.sh
來指定 init
:
#!/bin/sh
./linux umid=uml0 \
root=/dev/root rootfstype=hostfs hostfs=./rootfs \
rw mem=64M init=/init.sh quiet
stty sane ; echo
接著重新執行 UML.sh
即可在開機過程自動掛載 /proc
和 /sys
。
我們可變更 UML 環境中命令提示訊息,使得裡頭包含 UML
字樣,例如:
UML # export PS1='UML:\w\ $ '
也可追加終端機色彩,使得 UML
命令提示更顯目:
UML # export PS1='\[\033[01;32mUML:\w\033[00m \$ '
這樣命令提示訊息就變成綠色。更多色彩的組合,可參見:
可在 host 端修改 rootfs/init.sh
,將上述 export PS1
加在 exec /sbin/tini /bin/sh
的前一行,這樣下次啟動 UML 就會生效。
編譯核心模組
$ make ARCH=um SUBARCH=x86_64 modules
預期將看到若干個以 .ko
結尾的檔案。隨後我們將安裝這些核心模組到 rootfs 所在的目錄,注意最終目錄名稱應為
/lib/modules/`uname -r`
這裡先用固定名稱,稍後切換到 UML 環境時再更名:
$ make modules_install MODLIB=`pwd`/rootfs/lib/modules/VER ARCH=um
As suggested by
make help
Other generic targets: all - Build all targets marked with [*] * vmlinux - Build the bare kernel * modules - Build all modules modules_install - Install all modules to INSTALL_MOD_PATH (default: /) ...
Nickchen Nick
Thu, Jun 16, 2022 10:32 AM
啟動 UML 並在環境中執行以下命令:
UML # cd /lib/modules
UML # mv VER `uname -r`
UML # depmod -ae `uname -r`
測試:
UML # modprobe isofs
UML # lsmod
預期輸出:
Module Size Used by Tainted: G
isofs 25330 0
確認核心模組的功能正確運作後,接著我們可撰寫自己的核心模組。在 Linux 核心程式碼最上層建立一個名為 tests
的目錄:
$ mkdir -p tests
建立檔案 tests/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);
還要有對應的 Makefile
,內容如下:
obj-m += hello.o
PWD := $(shell pwd)
KDIR := $(PWD)/..
default:
$(MAKE) -C $(KDIR) M=$(PWD) modules ARCH=um
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean ARCH=um
注意在 default:
和 $(MAKE)
之間不是空白字元,而是 Tab,同理,clean:
和 $(MAKE)
之間也以 Tab 區隔。
編譯上述核心模組並複製到 rootfs 中:
$ make -C tests
$ cp tests/hello.ko rootfs/
再次啟動 UML,掛載 hello.ko
:
UML # insmod hello.ko
預期輸出為:
Hello World! - init
隨後卸載核心模組:
UML # rmmod hello
剛才的核心模組太單純,我們再挑戰稍微複雜的試驗。以下的核心模組嘗試將 Linux 內部的 jiffies
和 HZ
透過 sysfs 揭露給使用者層級。
先準備開發用目錄:
$ mkdir -p tests/ticks
建立 tests/ticks/ticks.c
檔案,內容如下:
#include <linux/module.h>
#include <linux/init.h>
#include <linux/sysfs.h>
#include <linux/kobject.h>
#include <linux/kernel.h>
static ssize_t jiffies_show(struct kobject *kobj, struct kobj_attribute *attr,
char *buf) {
return sprintf(buf, "%lu", jiffies);
}
static ssize_t hz_show(struct kobject *kobj, struct kobj_attribute *attr,
char *buf) {
return sprintf(buf, "%u", HZ);
}
static struct kobj_attribute jiffies_attr =
__ATTR(jiffies, 0444, jiffies_show, NULL);
static struct kobj_attribute hz_attr = __ATTR(hz, 0444, hz_show, NULL);
static struct attribute *ticks_attrs[] = { &jiffies_attr.attr, &hz_attr.attr,
NULL };
static struct attribute_group ticks_grp = { .attrs = ticks_attrs };
static struct kobject *ticks;
static int __init ticks_init(void) {
int retval;
ticks = kobject_create_and_add("ticks", NULL);
if (!ticks)
return -EEXIST;
retval = sysfs_create_group(ticks, &ticks_grp);
if (retval)
kobject_put(ticks);
return retval;
}
module_init(ticks_init);
static void __exit ticks_exit(void) {
sysfs_remove_group(ticks, &ticks_grp);
kobject_put(ticks);
}
module_exit(ticks_exit);
MODULE_LICENSE("GPL");
也準備對應的 tests/ticks/Makefile
檔案,內容如下:
obj-m := ticks.o
KDIR= ../../
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules ARCH=um
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean ARCH=um
$(MAKE) -C
之前的字元不是空白,而是 tab
編譯核心模組:
$ pushd tests/ticks
$ make
$ popd
$ cp tests/ticks/ticks.ko rootfs/
pushd
和 popd
是 GNU Bash 提供的內建命令,可參見 Directory Stack Builtins,允許快速在目錄間切換,若你不使用 GNU Bash,該命令可能會無效,請自行切換目錄並記得回到 $WS
指向的目錄,即 Linux 核心原始程式碼所在目錄。
進入 UML 環境,並進行測試:
UML # insmod /ticks.ko
UML # cat /sys/ticks/jiffies
UML # cat /sys/ticks/hz
應可看到各自的數值,其中 HZ
設定為 100
。
Linux 核心提供 GDB script,讓分析和除錯更加便利,不過 GDB script 用 Python 撰寫,於是我們首先檢查 gdb 是否開啟 Python 的整合:
$ gdb -q -ex "python print(1+1)" -ex "quit"
若看到輸出 2
也就是 Python 執行 1 + 1
的結果,那表示 GDB 內建 Python 模組。
建構 GDB script:
$ echo "CONFIG_GDB_SCRIPTS=y" > .config-fragment
$ ARCH=um scripts/kconfig/merge_config.sh .config .config-fragment
$ make ARCH=um scripts_gdb
注意上方的 ARCH=um
不能省略,否則會使核心組態錯亂。
用下行命令來啟動 GDB,指定載入 Linux 核心提供的 GDB script:
$ gdb -ex "add-auto-load-safe-path scripts/gdb/vmlinux-gdb.py" \
-ex "file vmlinux" \
-ex "lx-version" -q
預期可見到:
Reading symbols from vmlinux...done.
Linux version 5.12.0 (jserv@node1) (gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0, GNU ld (GNU Binutils for Ubuntu) 2.34) #1 Tue Apr 26 05:41:22 CST 2021
如果沒有見到 Linux version 5.12.0
這個訊息,就意味著 Linux 核心提供的 GDB script 沒有正確產生。GDB script 沒有正確載入的錯誤訊息如下:
Undefined command: "lx-version". Try "help".
以下用 (gdb)
表示在 GDB 命令提示列裡頭輸入的命令
此時在 (gdb)
命令提示符號旁輸入 lx
但不要按下 Enter/Return 按鍵,而是按下 Tab 按鍵,就會發現 GDB 自動補完由 GDB script 提供的自行定義命令,像是 lx-dmesg
, lx-symbols
, lx-cpus
等等。詳細描述可透過執行以下命令取得:
(gdb) apropos lx
為了讓後續的操作更便利,我們準備名為 gdbinit
的檔案,內容如下:
python gdb.COMPLETE_EXPRESSION = gdb.COMPLETE_SYMBOL
add-auto-load-safe-path scripts/gdb/vmlinux-gdb.py
file vmlinux
lx-version
set args umid=uml0 root=/dev/root rootfstype=hostfs rootflags=FULLPATH/rootfs rw mem=64M init=/init.sh quiet
handle SIGSEGV nostop noprint
handle SIGUSR1 nopass stop print
再執行:
$ sed -i 's|FULLPATH|'"$PWD"'|' gdbinit
用意是將 FULLPATH
換為目前目錄的完整路徑。
為何要設定 handle 呢?
SIGSEGV
作為內部通訊,而 GDB 會自動將此 signal 轉包給除錯器本身;SIGUSR1
是我們要求停止 UML 執行所用的 signal;接著可執行以下命令:
$ gdb -q -x gdbinit
預期會看到 UML 的核心版本號碼和 (gdb)
命令提示。此刻,我們終於可在 GDB 內啟動 UML:
(gdb) run
這時可見 UML 執行到 UML:/ #
命令提示訊息。不過很快就發現,就算按下 Ctrl-C 組合按鍵,我們仍身處 UML,那該如何喚醒 GDB 呢?再開啟另一個終端機視窗,然後執行:
$ pkill -SIGUSR1 -o vmlinux
再切換回原本執行 GDB 的終端機視窗,可見到以下訊息:
Thread 1 "vmlinux" received signal SIGUSR1, User defined signal 1.
我們即可在 GDB 執行命令,例如:
(gdb) lx-mounts
(gdb) lx-cmdline
(gdb) lx-ps
(gdb) lx-dmesg
(gdb) lx-lsmod
(gdb) print $lx_module("hello")
(gdb) print $lx_module("hello")
應在 UML # insmod /hello.ko
之後執行。
注意到最後一個命令的 $
符號,這是 GDB script 所定義的函式。我們可執行以下 GDB 命令:
(gdb) print $lx_task_by_pid(1).comm
(gdb) print $lx_task_by_pid(1).cred
上方函式的參數 1
就表示 PID (Process ID) = 1 即 init
程序。我們繼續做實驗:
(gdb) lx-ps
預期得到以下輸出:
0x603f8aa0 <init_task> 0 swapper
0x63828040 1 tini
0x63832080 2 kthreadd
0x638380c0 3 kworker/0:0
0x6383a100 4 kworker/0:0H
0x63846140 5 kworker/u2:0
0x6384c180 6 mm_percpu_wq
0x6384e1c0 7 ksoftirqd/0
0x6385a200 8 kdevtmpfs
0x6386a240 9 netns
0x63888280 10 oom_reaper
0x638902c0 11 writeback
0x63892300 12 kworker/u2:1
0x6389c340 15 kblockd
0x638a2380 16 blkcg_punt_bio
0x638fc3c0 17 kworker/0:1
0x63916400 18 kswapd0
0x639c0480 21 sh
第一個欄位是地址,第二個欄位是 PID,不難發現 init 程序的地址是 0x63828040
,於是我們可揭開 init 的內部資訊: (很長的列表,按下 Enter 繼續瀏覽)
(gdb) print *(struct task_struct *)0x63828040
也可檢驗 Linux 核心內部來得知:
(gdb) p $container_of(init_task.tasks.next, "struct task_struct", "tasks")
預期輸出就是 0x63828040
。別忘了,Linux 核心用 linked list 來表示程序資訊,來做實驗:
(gdb) lx-list-check init_task.tasks
預期輸出:
Starting with: {next = 0x63828230, prev = 0x639c0670}
list is consistent: 18 node(s)
感受到 UML 搭配 GDB 的威力了吧!
延伸閱讀: Debugging kernel and modules via gdb
當然也能在 GDB 設定中斷點,比方說在掛載核心模組的實作程式碼:
(gdb) break do_init_module
參考輸出:
Breakpoint 1 at 0x600865ce: file kernel/module.c, line 3519.
注意到這個中斷點編號為 1
,如果每次有核心模組掛載時,就會觸發中斷並且停留在命令提示列,說來有點惱人,於是我們可善用 GDB 裡頭的 command
。先執行以下命令: (1
是數字一)
(gdb) command 1
然後會見到這樣的訊息:
Type commands for breakpoint(s) 1, one per line.
End with a line saying just "end".
>
在提示符號 >
後面,就是我們自訂的命令動作,繼續輸入以下:
py if str(gdb.parse_and_eval("mod->name")).find("hello") != 1: gdb.execute("continue", False, False)
end
在 end
輸入後,我們又見到熟悉的 (gdb)
命令提示列。
輸入 (gdb) continue
繼續執行,並參照上述掛載 hello.ko
:
UML # insmod /hello.ko
這時就會觸發中斷點,參考輸出如下:
Thread 1 "vmlinux" hit Breakpoint 1, do_init_module (mod=0x6481d140) at kernel/module.c:3519
3519 {
用 (gdb) list
觀察,預期會得到類似下方輸出:
3514 *
3515 * Keep it uninlined to provide a reliable breakpoint target, e.g. for the gdb
3516 * helper command 'lx-symbols'.
3517 */
3518 static noinline int do_init_module(struct module *mod)
3519 {
3520 int ret = 0;
3521 struct mod_initfree *freeinit;
3522
3523 freeinit = kmalloc(sizeof(*freeinit), GFP_KERNEL)
TODO: