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