利用 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 |
Build Linux Kernel
首先需要安裝以下套件:
sudo apt update && sudo apt upgrade |
新增一個專案資料夾並進入該資料夾。
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 |
使用 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 |
這邊需要確保 CONFIG_DEBUG_INFO
以及 CONFIG_GDB_SCRIPTS
在組態中有被開啟,以利後續實驗使用。方法是在 $ make
命令前,執行:
grep CONFIG_DEBUG_INFO .config |
預期要看到:
CONFIG_DEBUG_INFO=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 .. |
截至目前為止,專案資料夾結構應如下:
linux-kernel |
移動至 busybox-1.36.1
資料夾內並執行 make menuconfig
。
cd busybox-1.36.1 |
選擇 Settings ---> Build static binary
並執行。
make install |
接著要製作 mount 至 kernel 的資料夾。
cd _install |
寫入開機之後需要的腳本,首先利用 vim 建立 rcS 檔案。
vim ./etc/init.d/rcS |
將以下腳本寫入至 rcS 並儲存 (# 為註解符號,並非執行於 QEMU 內):
!/bin/sh |
並非所有的檔案都需要 mount ,可視實際需求而定
設定 rcS 腳本的權限並且建立 rootfs 的 image。
chmod +x etc/init.d/rcS |
測試 kernel 是否能順利運作,首先移動至 linux-6.6 資料夾並啟動 QEMU 。
cd ../../linux-6.6 |
當看到以下畫面,代表順利進入 QEMU 執行環境,可以輸入 ls
指令做確認:
若要離開測試環境,可以按下 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 |
參數 -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 會將程式暫停在進入 start_kerne()
(Line 871) 的位置,接下來可以透過輸入 n (next) 來進行單步執行。
(gdb) n |
在輸入 n 之後可以發現 GDB 將程式暫停在第 875 行的 set_task_stack_end_magic()
,若是想要進入該函式可以輸入 s (step) ,並且游標會移動至第 1096 行,也就是進入 set_task_stack_end_magic()
內。
(gdb) s |
當我們希望程式繼續執行至下一個中斷點時的時候可以輸入 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 。
接下來可以透過 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 開始一層層呼叫,直到此函數 |