Linux操作系統分析 | 深入理解系統調用
- 2020 年 5 月 27 日
- 筆記
實驗要求
1、找一個系統調用,系統調用號為學號最後2位相同的系統調用
2、通過彙編指令觸發該系統調用
3、通過gdb跟蹤該系統調用的內核處理過程
4、重點閱讀分析系統調用入口的保存現場、恢復現場和系統調用返回,以及重點關注系統調用過程中內核堆棧狀態的變化
實驗環境及配置
VMware® Workstation 15 Pro
Ubuntu 16.04.3 LTS
64位操作系統
一、基本理論
1、Linux 的系統調用
當用戶態進程調用一個系統調用時,CPU切換到內核態並開始執行 system_call (entry_INT80_32 或 entry_SYSCALL_64) 彙編代碼,其中根據系統調用號調用對應的內核處理函數。
具體來說,進入內核後,開始執行對應的中斷服務程序 entry_INT80_32 或者 entry_SYSCALL_64。
2、觸發系統調用的方法
(1)使用C庫函數觸發系統調用
以time系統調用為例:
(2)使用 int &0x80 或者 syscall 彙編代碼觸發系統調用
以time系統調用為例。
32位系統:
64位系統:
二、通過彙編指令觸發一個系統調用
1、選擇一個系統調用
(1)步驟:
Linux源代碼中的 syscall_32.tbl 和 syscall_64.tbl 分別定義了 32位x86 和 64位x86-64的系統調用內核處理函數。
由於我的 Linux 系統是64位的,所以進入Linux源代碼中:
~/arch/x86/entry/syscalls/syscall_64.tbl
可以查看系統調用表,如下圖所示:
我的學號最後兩位為50,所以選擇 50號 系統調用。
(2)listen 函數
a. 作用
listen 函數用於監聽來自客戶端的 tcp socket 的連接請求,一般在調用 bind 函數之後、調用 accept 函數之前調用 listen 函數。
b. 函數原型
#include <sys/socket.h> int listen(int sockfd, int backlog)
參數 sockfd:被 listen 函數作用的套接字
參數 backlog:偵聽隊列的長度
返回值:
成功 | 失敗 | 錯誤信息 |
0 | -1 |
EADDRINUSE:另一個socket 也在監聽同一個端口 EBADF:參數sockfd為非法的文件描述符。 ENOTSOCK:參數sockfd不是文件描述符。 EOPNOTSUPP:套接字類型不支持listen操作 |
2、通過彙編指令觸發系統調用
(1)新建服務器端程序:server.c
#include <stdio.h> #include <stdlib.h> #include <errno.h> #include <string.h> #include <sys/types.h> #include <netinet/in.h> #include <sys/socket.h> #include <sys/wait.h> int main() { int sockfd,new_fd,listen_result; struct sockaddr_in my_addr; struct sockaddr_in their_addr; int sin_size; //建立TCP套接口 if((sockfd = socket(AF_INET,SOCK_STREAM,0))==-1) { printf("create socket error"); perror("socket"); exit(1); } //初始化結構體,並綁定2323端口 my_addr.sin_family = AF_INET; my_addr.sin_port = htons(2328); my_addr.sin_addr.s_addr = INADDR_ANY; bzero(&(my_addr.sin_zero),8); //綁定套接口 if(bind(sockfd,(struct sockaddr *)&my_addr,sizeof(struct sockaddr))==-1) { perror("bind socket error"); exit(1); } //創建監聽套接口, 監聽隊列長度為10 //listen_result = listen(sockfd,10); asm volatile( "movl $0xa,%%edi\n\t" //listen函數的第二個參數 "movl %1,%%edi\n\t" //listen函數的第一個參數 "movl $0x32,%%eax\n\t" //將系統調用號50存入eax寄存器 "syscall\n\t" "movq %%rax,%0\n\t" :"=m"(listen_result) :"g"(sockfd) ); if(listen_result == 0) { printf("listen is being called\n"); } if(listen_result ==-1) { perror("listen"); exit(1); } //等待連接 while(1) { sin_size = sizeof(struct sockaddr_in); printf("server is run.\n"); //如果建立連接,將產生一個全新的套接字 if((new_fd = accept(sockfd,(struct sockaddr *)&their_addr,&sin_size))==-1) { perror("accept"); exit(1); } printf("accept success.\n"); //生成一個子進程來完成和客戶端的會話,父進程繼續監聽 if(!fork()) { printf("create new thred success.\n"); //讀取客戶端發來的信息 int numbytes; char buff[256]; memset(buff,0,256); if((numbytes = recv(new_fd,buff,sizeof(buff),0))==-1) { perror("recv"); exit(1); } printf("%s",buff); //將從客戶端接收到的信息再發回客戶端 if(send(new_fd,buff,strlen(buff),0)==-1) perror("send"); close(new_fd); exit(0); } close(new_fd); } close(sockfd); }
其中對 listen() 函數的調用採用了內嵌彙編指令的形式,即:
asm volatile( "movl $0xa,%%edi\n\t" //listen函數的第二個參數 "movl %1,%%edi\n\t" //listen函數的第一個參數 "movl $0x32,%%eax\n\t" //將系統調用號50存入eax寄存器 "syscall\n\t" "movq %%rax,%0\n\t" :"=m"(listen_result) :"g"(sockfd) );
asm volatile 內聯彙編格式
asm volatile(
“Instruction List”
: Output
: Input
: Clobber/Modify
);
a. asm 用來聲明一個內聯彙編表達式,任何內聯彙編表達式都是以它開頭,必不可少。
b. volatile 是可選的,如果選用,則向GCC聲明不對該內聯彙編進行優化。
c. Instruction List 是彙編指令序列,如果有多條指令時:
可以將多條指令放在一隊引號中,用 ; 或者 \n 將它們分開;
也可以一條指令放在一對引號中,每條指令一行。
d. Output 用來指定內聯彙編語句的輸出,相當於系統函數的返回值,格式為:
“=a”(initval)
e. Input 用來指定當前內聯彙編語句的輸入,相當於系統函數的參數(當該參數為使用C語言的變量的值時,採用這種方法),格式為:
“constraint(variable)”
可以看到,如果使用庫函數觸發函數調用的話,應該是被注釋掉的語句:
listen_result = listen(sockfd,10);
該函數有兩個參數,分別是變量 sockfd 和 常量10,返回值為 listen_result,按照上述規定完成彙編指令觸發系統調用。
(2)新建客戶端程序:client.c
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <netdb.h> #include <sys/types.h> #include <sys/socket.h> int main(int argc,char *argv[]) { int sockfd,numbytes; char buf[100]; struct sockaddr_in their_addr;
//建立一個TCP套接口 if((sockfd = socket(AF_INET,SOCK_STREAM,0))==-1) { perror("socket"); printf("create socket error.建立一個TCP套接口失敗"); exit(1); } //初始化結構體,連接到服務器的2323端口 their_addr.sin_family = AF_INET; their_addr.sin_port = htons(2328); // their_addr.sin_addr = *((struct in_addr *)he->h_addr); inet_aton( "127.0.0.1", &their_addr.sin_addr ); bzero(&(their_addr.sin_zero),8); //和服務器建立連接 if(connect(sockfd,(struct sockaddr *)&their_addr,sizeof(struct sockaddr))==-1) { perror("connect"); exit(1); } //向服務器發送數據 if(send(sockfd,"hello!socket.",6,0)==-1) { perror("send"); exit(1); } //接受從服務器返回的信息 if((numbytes = recv(sockfd,buf,100,0))==-1) { perror("recv"); exit(1); } buf[numbytes] = '/0'; printf("Recive from server:%s",buf); //關閉socket close(sockfd); return 0; }
(3)對兩個程序分別編譯、鏈接
a. 代碼如下:
gcc -o server server.c -static gcc -o client client.c -static
格式:gcc -o file file.c
將文件 file.c 編譯成可執行文件 file
參數 -static:強制使用靜態庫鏈接
參數 -m32:在64位機器上輸出32位代碼時,需要加上 -32
b. 結果如下:
執行代碼前:
可以看出文件夾中目前只有 server.c 和 client.c。
執行代碼後:
發現文件夾中已經生成了我們想要的可執行文件 server 和 client。
(4)執行可執行文件
a. 啟動 server,表明服務器端啟動
代碼如下:
sudo ./server
服務器端啟動,結果如下:
可以看到輸出 「listen is being called」,表明我們想要調用的系統函數 listen() 已經被成功觸發,即系統調用成功。
此時服務器端就等待客戶端與其建立鏈接並通信。
b. 再啟動一個終端充當客戶端,在該終端中啟動 client,表明客戶端啟動
代碼如下:
sudo ./client
客戶端啟動,結果如下:
可以看到客戶端的終端輸出 」Recive from server:hello!0″,表明客戶端與服務器端已成功建立連接,並且客戶端收到了服務器端發回的信息。
c. 此時,服務器端的信息為:
服務器端繼續 listen 來自客戶端的信息。
如果我們再在另外一個終端內使用 sudo ./client 啟動一個客戶端,服務器端也會有相應啟動成功的信息生成:
三、通過gdb跟蹤該系統調用的內核處理過程
1、環境配置
(1)安裝開發工具
sudo apt install build-essential sudo apt install qemu # install QEMU sudo apt install libncurses5-dev bison flex libssl-dev libelf-dev sudo apt install axel
以上工具在第一次實驗時已經進行了安裝。
(2)下載內核源代碼
axel -n 20 //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 cd linux-5.4.34
(3)配置內核選項
make defconfig # Default configuration is based on 'x86_64_defconfig' make menuconfig # 打開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)
(4)編譯內核
make -j$(nproc) # nproc gives the number of CPU cores/threads available
(5)啟動qemu
#測試⼀下內核能不能正常加載運⾏,因為沒有⽂件系統最終會kernel panic
qemu-system-x86_64 -kernel arch/x86/boot/bzImage
(6)製作內存根文件系統
a. 下載解壓:
axel -n 20 //busybox.net/downloads/busybox-1.31.1.tar.bz2 tar -jxvf busybox-1.31.1.tar.bz2 cd busybox-1.31.1
b. 配置編譯、安裝:
make menuconfig #記得要編譯成靜態鏈接,不⽤動態鏈接庫。 Settings ---> [*] Build static binary (no shared libs) #然後編譯安裝,默認會安裝到源碼⽬錄下的 _install ⽬錄中。 make -j$(nproc) && make install
c. 製作內存根文件系統鏡像:
在 linux-5.4.34 目錄下創建 rootfs 文件夾
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/
d. 準備 init 腳本文件放在根文件系統根目錄下(rootfs/init):
新建名為 init 的文檔文件,添加如下內容到init文件
#!/bin/sh mount -t proc none /proc mount -t sysfs none /sys echo "Wellcome Liu JianingOS!" echo "--------------------" cd home /bin/sh
給init腳本添加可執行權限
chmod +x init
e. 打包成內存根文件系統鏡像
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz
f. 測試掛在根文件系統,看內核啟動完成後是否執行 init 腳本
返回到 linux-5.4.34目錄下,啟動qemu
qemu-system-x86_64 -kernel arch/x86/boot/bzImage -initrd rootfs.cpio.gz
結果如下:
說明 init 腳本被執行。
2、跟蹤調試 Linux 內核
(1)根據第二部分的內容編寫利用彙編指令觸發系統調用的代碼
在 rootfs/home 目錄下分別創建兩個名為 server.c 和 client.c 的文件,並存入第二部分相應的代碼。
(2)使用 gcc 編譯成可執行文件 server 和 client
gcc -o server server.c -static gcc -o client client.c -static
(3)重新打包內存根文件系統鏡像
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz
(4)使用 gdb 跟蹤調試
方法:
使用 gdb 跟蹤調試內核時,在啟動 qemu 命令上添加兩個參數:
a. -s
作用:
- 在TCP 1234 端口上創建了一個 gdb-server(如果不想使用1234端口,可以用 -gdb tcp:xxxx 來替代 -s 選項)
- 打開另外一個窗口,用 gdb 把帶符號表的內核鏡像 vmlinux 加載進來
- 然後連接 gdb server,設置斷點跟蹤內核
b. -S
作用:
- 表示啟動時暫停虛擬機,等待 gdb 執行 continue 指令(可以簡寫為c)。
步驟:
a. 使用純命令行啟動 qemu
qemu-system-x86_64 -kernel arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s -nographic -append "console=ttyS0"
用該命令啟動qemu,可以看到虛擬機一啟動就暫停了,終端停留在下面的界面:
參數:-nographic -append “console=ttyS0”
啟動時不會彈出 qemu 虛擬機窗口,可以在純命令行下啟動虛擬機。
【可以通過 killall qemu-system-x86_64 命令強制關閉虛擬機】
b. 在打開一個終端窗口,進入 linux-5.4.34 目錄下,加載內核鏡像:
gdb vmlinux
c. 連接 gdb server,即在 gdb 中運行下方代碼:
(gdb) target remote:1234
d. 給文章中使用的系統調用設置斷點
方法:
(gdb) b 系統調用函數名
上文可知,我選擇的系統調用函數為 listen(),具體信息如下:
代碼如下:
(gdb) b __x64_sys_listen
e. 輸入 (gdb) c 指令繼續運行程序
此時,第一個打開的終端的內容為:
f. 運行編譯好的可執行代碼 server,使用 gdb 進行單步調試
在第一個終端中輸入如下代碼:
/home # ls
/home # ./server
此時第二個終端內容為:
在第二個終端中輸入:
(gdb) n
結果為:
報錯:
GDB 遠程調試錯誤:Remote ‘g’ packet reply is too long
解決方法:
重新下載 gdb,並修改其中 remote.c 文件內容
由 //ftp.gnu.org/gnu/gdb/ 下載 gdb的較新版本,此處我下載的是 gdb-7.8.tar.gz,並將其放在了 /home/linux 目錄下
進入 /home/linux 目錄下,對該文件進行解壓縮
tar zxvf gdb-7.8.tar.gz
修改 gdb-7.8/gdb 目錄下的 remote.c 文件內容:
if (buf_len > 2 * rsa->sizeof_g_packet) { rsa->sizeof_g_packet = buf_len ; for (i = 0; i < gdbarch_num_regs (gdbarch); i++) { if (rsa->regs->pnum == -1) continue; if (rsa->regs->offset >= rsa->sizeof_g_packet) rsa->regs->in_g_packet = 0; else rsa->regs->in_g_packet = 1; } }
在 gdb-7.8 目錄下執行以下命令安裝 gdb:
./configure
make
make install
至此,我們再重複上述步驟就可以使用 gdb 對程序設置斷點,並且進行單步調試。
(5)使用 gdb 對程序進行單步調試
gdb操作指令:
(gdb) l 查看代碼情況
(gdb) n 單步執行
(gdb) step 進入函數內部
(gdb) bt 查看堆棧
重新安裝並調整 gdb 之後,按照步驟(4)中的 a – f 依次執行。
a. 當第一個終端運行可執行文件server之後,即:
/home # ./server
第二個終端內容為:
可以看出斷點位置。
b. 查看堆棧信息
在第二個終端中輸入命令:
(gdb) bt
查看當前堆棧信息,如下所示:
c. 單步調試
在第二個終端輸入如下命令,進行單步調試:
(gdb) n
結果如下:
四、分析總結
1、使用 (gdb) bt 查看當前堆棧情況
根據結果顯示,函數調用可以分為4層:
頂層: __x64_sys_listen 作用:開放給用戶態使用的系統調用函數接口
第二層:do_syscall_64 作用:獲取系統調用號,從而調用系統函數
第三層:entry_syscall_64 作用:保存現場工作,調用第二層的 do_syscall_64
第四層:操作系統
2、根據單步調試結果從頂層往下依次查看
(1)斷點定位
斷點定位為:
/home/linux/linux-5.4.34/net/socket.c 的1688行
執行以下代碼,前往相應位置查看:
cd linux/linux-5.4.34/net cat -n socket.c
結果為:
進入 __sys_listen(fd, backlog) 函數查看:
int __sys_listen(int fd, int backlog) { struct socket *sock; int err, fput_needed; int somaxconn; sock = sockfd_lookup_light(fd, &err, &fput_needed); if (sock) { somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn; if ((unsigned int)backlog > somaxconn) backlog = somaxconn; err = security_socket_listen(sock, backlog); if (!err) err = sock->ops->listen(sock, backlog); fput_light(sock->file, fput_needed); } return err; }
(2)執行 do_syscall_64 函數
該函數定位在:
/home/linux/linux-5.4.34/arch/x86/entry/common.c 的第300行
(3)執行 entry_SYSCALL_64 函數
該函數定位在:
/home/linux/linux-5.4.34/arch/x86/entry/entry_64.S 的第184行
3、系統調用總結
(1)用戶態的程序代碼 server.c 中的內嵌彙編指令 syscall 觸發系統調用
(2)通過 MSR 寄存器找到函數入口
中斷函數入口為:
/home/linux/linux/-5.4.34/arch/entry/entry_64.S 第145行 ENTRY(entry_SYSCALL_64) 函數,這個函數為 x86_64 系統進行系統調用的通用入口。
ENTRY函數如下:
a. swapgs
使用 swapgs 指令和 下面一系列的壓棧動作來保存現場。
b. call do_syscall_64
調用 do_syscall_64 查找系統調用表,獲得所要使用的系統調用號。
(3)跳轉執行 do_syscall_64
跳轉到
/home/linux/linux-5.4.34/x86/entry/common.c 下的 do_system_64函數
a. regs->ax = sys_call_table[nr](regs)
從系統調用表中獲得系統調用號,並將其存在到 ax 寄存器中,然後去執行系統調用函數。
b. syscall_return_slowpath(regs)
用於系統調用函數執行結束後,恢復現場
(4)跳轉執行系統系統函數 listen
跳轉到 /home/linux/linux-5.4.34/net/socket.c 函數,開始執行函數;
(5)恢復現場
函數執行完成後,需要進行現場恢復,因此再次回到:
/home/linux/linux/-5.4.34/arch/x86/entry/entry_64.S
進行現場的恢復。
至此,整個系統調用完成。
參考文章:
//blog.csdn.net/u013920085/article/details/20574249
//blog.csdn.net/yangbodong22011/article/details/60399728
//blog.csdn.net/barry283049/article/details/42970739