Linux作業系統分析 | 深入理解系統呼叫

琉婭璃發表於2020-05-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 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
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 https://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 檔案內容

由 http://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 

進行現場的恢復。

至此,整個系統呼叫完成。

 

參考文章:

https://blog.csdn.net/u013920085/article/details/20574249

https://blog.csdn.net/yangbodong22011/article/details/60399728

https://blog.csdn.net/barry283049/article/details/42970739

相關文章