利用 QEMU 與 GDB 追蹤 Linux 核心

利用 QEMU 與 GDB 追蹤 Linux 核心

GDB 全名為 GNU Debugger ,是 GNU 軟體系統中的標準除錯器,在許多類 Unix 的作業系統中都能夠使用,而現有的 GDB 所能支援除錯的程式語言有 C 、 C++ 、 Pascal 以及 FORTRAN。本篇文章主要是介紹如何編譯 Linux 核心並且在 QEMU 中運作,同時透過 GDB 追蹤 Linux 核心。

在本篇筆記的命令列範例中,若前綴為 $ 者,表示其執行在 host 端;前綴為 # 者,表示其須執行在 guest 端 (QEMU 內);前綴為 (gdb) 者,表示其執行在 GDB 模式。

測試環境如下:

OS: Ubuntu 22.04
ARCH: X86_64
Linux Kernel Source Version: 6.6

Build Linux Kernel

首先需要安裝以下套件:

$ sudo apt update && sudo apt upgrade
$ 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

新增一個專案資料夾並進入該資料夾。

$ mkdir -p linux-kernel && cd linux-kernel

下載 kernel 的 source code 並且 build 起來,可以使用 wget 下載核心壓縮檔或者使用 git clone 取得原始碼,這裡使用 wget 作為示範。

$ wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.tar.xz
$ tar -xvf linux-6.6.tar.xz
$ cd linux-6.6
$ make allnoconfig

使用 git clone:

$ git clone --depth=5 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

接著設定 config。

$ make menuconfig

將下列所有選項都勾起來:

Linux/x86 6.6.0 Kernel Configuration

├─ General Setup
│ └─ Initial RAM filesystem and RAM disk (initramfs/initrd) support -> Enable
├─ 64-bit kernel -> Enable
├─ Process type and features
│ └─ Linux guest support -> Enable
│ └─ Support for running PVH guests -> Enable
├─ Enable loadable module support -> Enable
├─ Executable file formats -> Enable all
├─ Device Drivers
│ └─ Character devices
│ └─ Serial drivers
│ ├─ 8250/16550 and compatible serial support -> Enable
│ └─ Console on 8250/16550 and compatible serial port -> Enable
└─ Kernel hacking
├─ Kernel debugging -> Enable
└─ Compile-time checks and compiler options
├─ Debug information
│ └─ Generate DWARF Version 4 debuginfo -> Enable
└─ Provide GDB scripts for kernel debugging -> Enable

這邊需要確保 CONFIG_DEBUG_INFO 以及 CONFIG_GDB_SCRIPTS 在組態中有被開啟,以利後續實驗使用。方法是在 $ make 命令前,執行:

$ grep CONFIG_DEBUG_INFO .config
$ grep CONFIG_GDB_SCRIPTS .config

預期要看到:

CONFIG_DEBUG_INFO=y
CONFIG_GDB_SCRIPTS=y

編譯 kernel,參數 $(nproc) 代表系統最大核心數量。

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

針對 Arm64 處理器架構,將指令更改為:

$ make ARCH=arm64 CORSS_COMPILE=aarch64-linux-gun- -j$(nproc)

編譯結束後,預期會見到以下訊息:

Kernel: arch/x86/boot/bzImage is ready

在編譯完成後,額外執行以下命令:

$ make ARCH=x86 scripts_gdb

我們首先嘗試使用以下指令啟動核心。

$ qemu-system-x86_64 -kernel arch/x86/boot/bzImage -nographic -append "console=ttyS0"

基本的意思就是啟動一台 x86_64 架構處理器的 VM ,以 bzImage 映像檔作為核心 ,關閉圖形並將文字輸出到 ttyS0 裝置上。

沒有特別的狀況的話, 核心會開始啟動,直到處理器使用率被跑滿,畫面會停在這一句:

[    1.882581] ---[ end Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0) ]---

表示 kernel 找不到可以掛載的 rootfs (root filesystem) ,沒辦法啟動應該在裡面的 init process。這時候我們可以按下 Ctrl-A 放開再按下 X 按鍵來離開 QEMU 環境。

Build Root FS

回到專案資料夾 linux-kernel 內下載 busybox 並編譯。

$ cd ..
$ wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2
$ tar -xf busybox-1.36.1.tar.bz2

截至目前為止,專案資料夾結構應如下:

linux-kernel
├─ busybox-1.36.1
└─ linux-6.6

移動至 busybox-1.36.1 資料夾內並執行 make menuconfig

$ cd busybox-1.36.1
$ make menuconfig

選擇 Settings ---> Build static binary 並執行。

$ make install

接著要製作 mount 至 kernel 的資料夾。

$ cd _install
$ mkdir -p lib lib64 proc sys etc etc/init.d

寫入開機之後需要的腳本,首先利用 vim 建立 rcS 檔案。

$ vim ./etc/init.d/rcS

將以下腳本寫入至 rcS 並儲存 (# 為註解符號,並非執行於 QEMU 內):

#!/bin/sh
# Mount the /proc and /sys filesystems
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
mount -t debugfs none /sys/kernel/debug
# Populate /dev
/sbin/mdev -s

並非所有的檔案都需要 mount ,可視實際需求而定

設定 rcS 腳本的權限並且建立 rootfs 的 image。

$ chmod +x etc/init.d/rcS
$ find . | cpio -o --format=newc | gzip > ../../linux-6.6/rootfs.img.gz

測試 kernel 是否能順利運作,首先移動至 linux-6.6 資料夾並啟動 QEMU 。

$ cd ../../linux-6.6
$ qemu-system-x86_64 -kernel vmlinux -nographic -initrd rootfs.img.gz -append "root=/dev/ram rdinit=/sbin/init console=ttyS0"

當看到以下畫面,代表順利進入 QEMU 執行環境,可以輸入 ls 指令做確認:
image

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

啟動 GDB

linux kernel 有提供一些 linux debug 用的 GDB 指令,可以修改 GDB 的設定檔讓 GDB 啟動時自動載入加入這些指令的 script

$ echo "add-auto-load-safe-path `pwd`/scripts/gdb/vmlinux-gdb.py" >> ~/.gdbinit

輸入以下指令以再次啟動 Linux 核心。

$ qemu-system-x86_64 -kernel vmlinux -nographic -initrd rootfs.img.gz -append "root=/dev/ram rdinit=/sbin/init console=ttyS0" -S -s

-S 參數是讓 QEMU 將 VM 啟動時就將 VM 停住等待 GDB 的指令, -s 參數則是讓 QEMU 會監聽 port:1234 的連線。nokaslr 的核心參數是停用隨機分配 kernel 運作位址的功能。

接著開啟新的終端機移動到 linux/linux-6.6 並且啟動 GDB 。

$ cd linux/linux-6.6
$ gdb vmlinux -tui

參數 -tui 將以 TUI (Text User Interface) 模式啟動 GDB ,這個功能能夠在 GDB 命令列頂部顯示一個包含原始程式碼的視窗。

進入到 GDB 模式後我們首先需要設定監聽的埠號

(gdb) target remote:1234

設定中斷點

接下來可以設定中斷點來觀察核心的運作情況,可以利用 GDB 將中斷點設定在以下函式,並查看其作用。

不同的 Linux 核心版本所提供函式可能不同,以本文中所使用的 x86 平台為例,下列的函式在核心版本 5.0 仍存在,但到了 6.0 之後則有些被移除。可透過 Elixir Bootlin 比較不同核心版本之間的差異

函式 說明
start_kernel 大概可以視為 Linux 的 main function
syscall_init 設定 system call 的進入點
set_intr_gate 設定中斷向量表
entry_SYSCALL_64 System call 的進入點, rax 暫存器內放的是系統呼叫的編號
apic_timer_interrupt 時間中斷的進入點
interrupt_entry 負責將所有暫存器 push 到堆疊,在中斷完成之後可以繼續原先的工作
do_IRQ Linux 處理中斷的地方,中斷編號放在 vector = ~regs->orig_ax;

首先我們將中斷點設定在 start_kernel ,並且輸入 c (continue) 讓程式繼續執行至設定的中斷點。

(gdb) b start_kernel
(gdb) c

image

可以發現 GDB 會將程式暫停在進入 start_kerne() (Line 871) 的位置,接下來可以透過輸入 n (next) 來進行單步執行。

(gdb) n

image

在輸入 n 之後可以發現 GDB 將程式暫停在第 875 行的 set_task_stack_end_magic() ,若是想要進入該函式可以輸入 s (step) ,並且游標會移動至第 1096 行,也就是進入 set_task_stack_end_magic() 內。

(gdb) s

image

當我們希望程式繼續執行至下一個中斷點時的時候可以輸入 c ,由於我們沒有設定其他的中斷點,因此可以發現在 QEMU 的環境內 Linux 核心會直接完成載入。

觀察 Linux 核心中斷機制

以 Intel 為例,在 Intel® 64 and IA-32 Architectures Software Developer’s Manual Vol 3A 的 Table 6-1 中提供了異常和中斷向量表 (Exceptions and Interrupts Table ,簡稱 IDT) ,並且列出了 21 個 traps 。

image

接下來可以透過 GDB 觀察以下函式,來了解 Linux 核心的中斷機制。

函式名稱 目的
trap_init() start_kernel() (相當於 Linux 的 main) 呼叫,初始化 Intel 處理器的 traps
init_IRQ() start_kernel() (相當於 Linux 的 main) 呼叫,設定外部中斷的中斷向量表
native_init_IRQ init_IRQ 呼叫此函數「真正」去設定中斷向量表
serial_link_irq_chain UART 驅動程式向 Linux 核心 註冊當 serial port 裝置發生中斷時,應該呼叫哪個函數,此函數屬於驅動程式的一部分
common_interrupt 所有的中斷服務函式都會「跳到」這段組合語言,它的主要功能是將所有的暫存器除存下來,然後呼叫 do_IRQ ,從 do_IRQ 開始就是 C 語言
serial8250_interrupt 如果這個裝置會發出中斷,那麼這樣的函數就是開發驅動程式的人必須撰寫的「其中一部分」。此部份稱之為 top halve ,由 common_ingterrupt->do_IRQ 開始一層層呼叫,直到此函數

參考資料