實驗要求:
- 找一個系統調用,系統調用號為學號最后2位相同的系統調用
- 通過匯編指令觸發該系統調用
- 通過gdb跟蹤該系統調用的內核處理過程
- 重點閱讀分析系統調用入口的:保存現場、恢復現場和系統調用返回,以及重點關注系統調用過程中內核堆棧狀態的變化
一、系統調用相關知識
系統調用(system call)利用陷阱(trap),是異常(Exception)的一種,從用戶態進⼊內核態。
系統調用具有以下功能和特性:
把用戶從底層的硬件編程中解放出來。操作系統為我們管理硬件,⽤戶態進程不用直接與硬件設備打交道。
極⼤地提高系統的安全性。如果用戶態進程直接與硬件設備打交道,會產⽣安全隱患,可能引起系統崩潰。
使用戶程序具有可移植性。用戶程序與具體的硬件已經解耦合並用接⼝(api)代替了,不會有緊密的關系,便於在不同系統間移植。
二、環境准備
1. 安裝開發工具:
sudo apt install build-essential sudo apt install qemu
sudo apt install libncurses5-dev bison flex libssl-dev libelf-dev sudo apt install axel
2. 下載內核源碼:
axel -n 20 https://mirrors.edge.kernel.org/pub/linux/kernel/v5.x/linux-5.4.34.tar.xz
xz -d linux-5.4.34.tar.xz tar -xvf linux-5.4.34.tar
3. 編譯menuOS調試工具
cd linux-5.4.34
make defconfig #默認配置基於'x86_64_defconfig'
make menuconfig
4. 配置內核選項
#打開debug相關選項
Kernel hacking ---> Compile-time checks and compiler options ---> [*] Compile the kernel with debug info [*] Provide GDB scripts for kernel debugging [*] Kernel debugging
#關閉KASLR,否則會導致打斷點失敗
Processor type and features ----> [] Randomize the address of the kernel image (KASLR)
5. 編譯內核
make -j$(nproc) #編譯內核,需要幾分鍾的時間
#測試一下,不能正常加載運行
qemu-system-x86_64 -kernel arch/x86/boot/bzImage
6. 制作根文件系統
電腦加電啟動⾸先由bootloader加載內核,內核緊接着需要掛載內存根⽂件系統,其中包含必要的設備驅動和⼯具,bootloader加載根⽂件系統到內存中,內核會將其掛載到根⽬錄/下,然后運⾏根⽂件系統中init腳本執⾏⼀些啟動任務,最后才掛載真正的磁盤根⽂件系統。
我們這⾥為了簡化實驗環境,僅制作內存根⽂件系統。這⾥借助BusyBox 構建極簡內存根⽂件系統,提供基本的⽤戶態可執⾏程序
下載 busybox源代碼解壓:
axel -n 20 https://busybox.net/downloads/busybox-1.31.1.tar.bz2
tar -jxvf busybox-1.31.1.tar.bz2 cd busybox-1.31.1
配置編譯 並安裝:
make menuconfig /* 記得要編譯成靜態鏈接,不用動態鏈接庫。 Settings ---> [*] Build static binary (no shared libs) 然后編譯安裝,默認會安裝到源碼目錄下的 _install 目錄中。 */
make -j$(nproc) && make install
7. 制作內存根文件系統鏡像
mkdir rootfs cd rootfs
cp ../busybox-1.31.1/_install/* ./ -rf
mkdir dev proc sys home sudo cp -a /dev/{null,console,tty,tty1,tty2,tty3,tty4} dev/
8. init腳本
准備init腳本文件放在根文件系統目錄下(rootfs/init),添加如下內容到init文件:
#!/bin/sh mount -t proc none /proc
mount -t sysfs none /sys echo "Welcome to qingyang-OS!"
echo "--------------------" cd home /bin/sh
給init腳本添加可執行權限:
chmod +x init
打包成內存根文件系統鏡像:
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz
鏡像文件在上一級目錄:
測試掛載根文件系統,看內核啟動完成后是否執行init腳本:
cd ../ #一定要返回到上一級,因為rootfs.cpio.gz在上一級 qemu-system-x86_64 -kernel ~/linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz
QEMU界面如下,第一步系統配置完成:
三、通過匯編指令觸發該系統調用
1. 首先查看系統調用表,我的學號末尾兩位為01
cat ~/linux-5.4.34/arch/x86/entry/syscalls/syscall_64.tbl
如上圖,01是寫調用: 系統調用 write
,函數入口為 __x64_sys_write
。
2. 自己寫一個簡單C語言程序Write.c,通過這個程序觸發系統調用write:
#include <unistd.h> #include <stdio.h> #include <fcntl.h> #include <stdlib.h> #include <string.h>
int main(void) { char buffer[50] = "hello==>qingyang2199\n"; //buffer里面寫上String類型的內容
int count; int fd = open ("abc.txt",O_RDWR); if (fd == -1) { fprintf(stderr,"can't open file:[%s]\n","abc.txt"); //打不開文件
exit(EXIT_FAILURE); } count = write(fd,buffer,strlen(buffer)); //在這里【write函數】將buffer里的內容,寫入文件abc.txt
if (count == -1) { fprintf(stderr,"write error\n"); //寫的時候出錯
exit(EXIT_FAILURE); } exit(EXIT_SUCCESS); }
gcc編譯(這里采用靜態編譯)后運行,輸出結果:
gcc -o Write Write.c -static
生成可執行文件后,還需要一個abc.txt:
然后執行可執行文件Write:
可見,write的作用是將buffer里的內容寫入文件。
3.編寫匯編程序Write-asm.c,觸發write系統調用:
寫Write-asm.c之前,還需要從反匯編Write來獲取一些信息:
objdump -S Write >Write.S #反匯編
從Write.S匯編代碼中得知,入口地址0x1:
Write.c里面的write函數的那一行:
count = write(fd,buffer,strlen(buffer)); //在這里【write函數】將buffer里的內容,寫入文件abc.txt
編寫匯編程序Write-asm.c,只要把上面的Write.c里面的write函數的那一行,改寫成匯編代碼就可以了:
#include <unistd.h> #include <stdio.h> #include <fcntl.h> #include <stdlib.h> #include <string.h>
int main(void) { char buffer[50] = "hello==>qingyang2199\n"; //buffer里面寫上String類型的內容
int count; int fd = open ("abc.txt",O_RDWR); if (fd == -1) { fprintf(stderr,"can't open file:[%s]\n","abc.txt"); //打不開文件
exit(EXIT_FAILURE); } //count = write(fd,buffer,strlen(buffer)); //這行被下面的asm替換
//------------------asm匯編代碼-------------------// asm volatile( "movq %3, %%rdx\n\t" // 參數3
"movq %2, %%rsi\n\t" // 參數2
"movq %1, %%rdi\n\t" // 參數1
"movl $0x1,%%eax\n\t" // 傳遞系統調用號(入口地址0x1,從Write.S中得知,如下圖:)
"syscall\n\t" // 系統調用
"movq %%rax,%0\n\t" // 結果存到%0 就是count中
:"=m"(count) //輸出到count
:"a"(fd),"b"(buffer),"c"(strlen(buffer)) //對應輸入的三個參數
); //------------------asm匯編代碼-------------------//
if (count == -1) { fprintf(stderr,"write error\n"); //寫的時候出錯
exit(EXIT_FAILURE); } exit(EXIT_SUCCESS); }
編譯新寫的匯編程序:
gcc -o Write-asm Write-asm.c -static
然后運行匯編程序:
./Write-asm
write匯編有問題,感謝熱心同學指導,我替換為了writev,用來實現write相同的功能:
write.c:
#include <stdio.h> #include <sys/uio.h>
/* struct iovec { void *iov_base; //指向一個char數組 size_t iov_len; //大小 }; */
int main(int argc,char *argv[]) { struct iovec vec[2]; char buf[]="qingyang2199"; int str_len; vec[0].iov_base=buf; vec[0].iov_len=8; // 1 標准輸出 // vec 緩沖區 // 1 緩沖區長度
str_len=writev(1,vec,1); //調用writev()函數
puts(""); printf("Write bytes: %d \n",str_len); return 0; }
從write.S匯編代碼中得知,入口地址0x14:
write-asm.c:
#include <stdio.h> #include <sys/uio.h>
/* struct iovec { void *iov_base; //指向一個char數組 size_t iov_len; //大小 }; */
int main(int argc,char *argv[]) { struct iovec vec[2]; char buf[]="qingyang2199"; int str_len; vec[0].iov_base=buf; vec[0].iov_len=8; // 1 標准輸出 // vec 緩沖區 // 1 緩沖區長度 //str_len=writev(1,vec,1); //調用writev()函數
asm volatile( "movq $0x1, %%rdx\n\t" // 參數3
"movq %1, %%rsi\n\t" // 參數2
"movq $0x1, %%rdi\n\t" // 參數1
"movl $0x14,%%eax\n\t" // 傳遞系統調用號
"syscall\n\t" // 系統調用
"movq %%rax,%0\n\t" // 結果存到%0 就是str_len中
:"=m"(str_len) // 輸出
:"g"(vec) // 輸入
); puts(""); printf("Write bytes: %d \n",str_len); return 0; }
運行一下匯編程序:
./write
四、通過gdb跟蹤該系統調用的內核處理過程
gdb調試基礎知識:
- r : run 運行程序
- q : quit
- b : break 設置斷點
- c : continue
- l : list 顯示多行源代碼
- step 執行下一條語句(若是函數調用,則進入)
- next 執行下一條語句(不進入函數調用)
- print 打印內部變量值
1.重新制作根文件系統:
把編譯好的 write-asm文件放在rootfs/syscall目錄下:
重新生成根文件系統(rootfs目錄下):
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz
2. 純命令行下啟動虛擬機:
使用gdb跟蹤調試內核,加兩個參數,一個是-s,在TCP 1234端口上創建了一個gdbserver。可以另外打開一個窗口,用gdb把帶有符號表的內核鏡像vmlinux加載進來,然后連接gdb server,設置斷點跟蹤內核。若不想使用1234端口,可以使用-gdb tcp:xxxx來替代-s選項),另一個是-S代表啟動時暫停虛擬機,等待 gdb 執行 continue指令(可以簡寫為c):
qemu-system-x86_64 -kernel ~/linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s -nographic -append "console=ttyS0"
然后發現這個窗口暫停等待(作為gdbserver,端口號TCP1234):
3. 另外打開一個窗口,用gdb把帶有符號表的內核鏡像vmlinux加載進來:
然后連接gdb server:
4. 設置斷點跟蹤內核:
在虛擬機中執行 write-asm
,會卡住:
在gdb界面查看斷點分析:
5. gdb界面bt查看堆棧:
查看此時堆棧情況,有4層:
- 第一層/ 頂層
__x64_sys_writev
系統調用函數 - 第二層
do_syscall_64
獲取系統調用號, 前往系統調用函數 - 第三層
entry_syscall_64
中斷入口,做保存線程工作,調用do_syscall_64
- 第四層 內部不可見
6. 繼續深入查看系統調用:
斷點定位為到 /home/qyf001/linux-5.4.34/fs/read_write.c
1128行:
writev()函數內調用 do_writev():
進入do_writev函數查看:
可知,這里是完成程序內容的地方,前期的保存現場工作已經完成。
執行完這個函數,發現回到了函數堆棧上一層的do_sys_call_64
中 ,接下來要執行的 syscall_return_slowpath
函數要為恢復現場做准備。
繼續執行(next),回到了函數堆棧的上一層,entry_SYSCALL_64:
接下來執行的是用於恢復現場的匯編指令:
最后伴隨着兩個pop
指令,恢復了rdi
和rsp
寄存器。系統調用完成:
實驗操作部分到此結束,下面是工作機制的理論分析
五、分析系統調用的工作機制
writev函數從用戶空間到內核空間的過程:
- 第一層/ 頂層 __x64_sys_writev 系統調用函數
- 第二層 do_syscall_64 獲取系統調用號, 前往系統調用函數
- 第三層 entry_syscall_64 中斷入口,做保存線程工作,調用 do_syscall_64
- 第四層 內部不可見
系統調用全部步驟:
(1)匯編指令 syscall 觸發系統調用,通過MSR寄存器找到了中斷函數入口,此時,代碼執行到/home/qyf001/linux-5.4.34/arch/x86/entry/entry_64.S 目錄下的ENTRY(entry_SYSCALL_64)入口,然后開始通過swapgs 和壓棧動作保存現場。
(2)然后跳轉到了/linux-5.4.34/arch/x86/entry/common.c 目錄下的 do_syscall_64 函數,在ax寄存器中獲取到系統調用號,然后去執行系統調用內容:
(3)然后程序跳轉到/linux-5.4.34/fs/read_write.c 下的do_writev 函數,開始執行:
(4)函數執行完后回到步驟(3)中的 syscall_return_slowpath(regs); 准備進行恢復現場:
(5)接着程序再次回到arch/x86/entry/entry_64.S,執行恢復現場,最后兩句完成了堆棧的切換。
過程分步驟截圖:
(1)匯編指令 syscall 觸發系統調用,通過MSR寄存器找到了中斷函數入口,此時,代碼執行到/home/qyf001/linux-5.4.34/arch/x86/entry/entry_64.S 目錄下的ENTRY(entry_SYSCALL_64)入口,然后開始通過swapgs 和壓棧動作保存現場:
(2)然后跳轉到了/linux-5.4.34/arch/x86/entry/common.c 目錄下的 do_syscall_64 函數,在ax寄存器中獲取到系統調用號,然后去執行系統調用內容:
(3)然后程序跳轉到/linux-5.4.34/fs/read_write.c 下的do_writev 函數,開始執行功能函數【這是本次系統調用最深的一層】:
(4)函數執行完后回到步驟(2)中的 syscall_return_slowpath(regs); 准備進行恢復現場:
(5)接着程序再次回到arch/x86/entry/entry_64.S,執行恢復現場,最后兩句完成了堆棧的切換。
附:相關知識-學習筆記
匯編指令學習:
x86架構
- Intel:Windows派系 -> vc編譯器
- AT&T:Linux/iOS派系 -> gcc編譯器
寄存器(16位):
- ax bx cx dx 通用數據
- sp 堆棧指針 bp 基址指針
- ip 指令指針(下一條)
- cs ds ss es 段 si di 變址 flag 標志
16位:- - push %ax
32位:l e pushl %eax
64位:q r pushq %rax
8086常用指令(16位為例):
mov ax,1122H //將1122H存入寄存器ax
jmp ax //如果ax是1000H,那么IP將被改為1000H
add ax,1111H //將寄存器ax中的值加上1111H再賦值給ax //sub類似
ret //棧頂值出棧,給IP
lea dx,1111H //把偏移地址存到dx
cmp 比較
inc 加一 dec減一
mul 無符號乘法 div 無符號除法
shl shr 邏輯左移/右移
call 過程調用 ret 過程返回
proc 定義過程 endp過程結束
segment 定義段 ends段結束
end程序結束
大小端:
- 大端模式(Big Endian):數據的低字節保存在內存的高地址。
- 小端模式(Little Endian):數據的低字節保存在內存的低地址。(從右到左保存)(8086、X86是小端)
gcc-gdb使用方法學習:
源文件123.c編譯:gcc 123.c -o 123 得到123可執行文件
然后 gdb 123 進行調試:b/c/s/...
gdb調試基礎知識:
- r : run 運行程序
- b : break 設置斷點
- c : continue
- bt : 查看堆棧狀況
- n : next 執行下一條語句(不進入函數調用)
- s : step 執行下一條語句(若是函數調用,則進入)
- q : quit 結束調試
- l : list 顯示多行源代碼
- print 打印內部變量值