Linux操作系统分析 | 深入理解系统调用

实验要求

1、找一个系统调用,系统调用号为学号最后2位相同的系统调用

2、通过汇编指令触发该系统调用

3、通过gdb跟踪该系统调用的内核处理过程

4、重点阅读分析系统调用入口的保存现场、恢复现场和系统调用返回,以及重点关注系统调用过程中内核堆栈状态的变化

 


 

 

 

实验环境及配置

 

VMware® Workstation 15 Pro

 

Ubuntu 16.04.3 LTS

64位操作系统

 

 

一、基本理论

1、Linux 的系统调用

Linux操作系统分析 | 深入理解系统调用

当用户态进程调用一个系统调用时,CPU切换到内核态并开始执行 system_call (entry_INT80_32 或 entry_SYSCALL_64)  汇编代码,其中根据系统调用号调用对应的内核处理函数。

具体来说,进入内核后,开始执行对应的中断服务程序 entry_INT80_32 或者 entry_SYSCALL_64。

2、触发系统调用的方法

(1)使用C库函数触发系统调用

以time系统调用为例:

Linux操作系统分析 | 深入理解系统调用

(2)使用 int &0x80 或者 syscall 汇编代码触发系统调用

以time系统调用为例。

32位系统:

Linux操作系统分析 | 深入理解系统调用

 

64位系统:

Linux操作系统分析 | 深入理解系统调用

 

二、通过汇编指令触发一个系统调用

1、选择一个系统调用

(1)步骤:

 Linux源代码中的 syscall_32.tbl 和 syscall_64.tbl 分别定义了 32位x86 和 64位x86-64的系统调用内核处理函数。

由于我的 Linux 系统是64位的,所以进入Linux源代码中:

~/arch/x86/entry/syscalls/syscall_64.tbl

可以查看系统调用表,如下图所示:

Linux操作系统分析 | 深入理解系统调用

我的学号最后两位为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. 结果如下:

执行代码前:

Linux操作系统分析 | 深入理解系统调用

 

可以看出文件夹中目前只有 server.c 和 client.c。

执行代码后:

Linux操作系统分析 | 深入理解系统调用

Linux操作系统分析 | 深入理解系统调用

发现文件夹中已经生成了我们想要的可执行文件 server 和 client。

 

(4)执行可执行文件

a. 启动 server,表明服务器端启动

代码如下:

sudo  ./server

服务器端启动,结果如下:

Linux操作系统分析 | 深入理解系统调用

可以看到输出 “listen is being called”,表明我们想要调用的系统函数 listen() 已经被成功触发,即系统调用成功。

此时服务器端就等待客户端与其建立链接并通信。

b. 再启动一个终端充当客户端,在该终端中启动 client,表明客户端启动

代码如下:

sudo ./client

客户端启动,结果如下:

Linux操作系统分析 | 深入理解系统调用

 

可以看到客户端的终端输出 ”Recive from server:hello!0",表明客户端与服务器端已成功建立连接,并且客户端收到了服务器端发回的信息。

c. 此时,服务器端的信息为:

Linux操作系统分析 | 深入理解系统调用

服务器端继续 listen 来自客户端的信息。

如果我们再在另外一个终端内使用 sudo ./client 启动一个客户端,服务器端也会有相应启动成功的信息生成:

Linux操作系统分析 | 深入理解系统调用

 

三、通过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

结果如下:

Linux操作系统分析 | 深入理解系统调用

说明 init 脚本被执行。

 

2、跟踪调试 Linux 内核

(1)根据第二部分的内容编写利用汇编指令触发系统调用的代码

在 rootfs/home 目录下分别创建两个名为 server.c 和 client.c 的文件,并存入第二部分相应的代码。

Linux操作系统分析 | 深入理解系统调用

(2)使用 gcc 编译成可执行文件 server 和 client

 

gcc -o server server.c -static
gcc -o client client.c -static

 

Linux操作系统分析 | 深入理解系统调用  

 

(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,可以看到虚拟机一启动就暂停了,终端停留在下面的界面:

Linux操作系统分析 | 深入理解系统调用  

 

参数:-nographic -append "console=ttyS0" 

启动时不会弹出 qemu 虚拟机窗口,可以在纯命令行下启动虚拟机。

【可以通过 killall qemu-system-x86_64 命令强制关闭虚拟机】

b. 在打开一个终端窗口,进入 linux-5.4.34 目录下,加载内核镜像:

 

gdb vmlinux

 

Linux操作系统分析 | 深入理解系统调用  

 

 

c. 连接 gdb server,即在 gdb 中运行下方代码:

 

(gdb) target remote:1234

 

Linux操作系统分析 | 深入理解系统调用  

 

d. 给文章中使用的系统调用设置断点

方法:

(gdb) b 系统调用函数名

上文可知,我选择的系统调用函数为 listen(),具体信息如下:

Linux操作系统分析 | 深入理解系统调用代码如下:

(gdb) b __x64_sys_listen

Linux操作系统分析 | 深入理解系统调用

e. 输入 (gdb) c 指令继续运行程序

Linux操作系统分析 | 深入理解系统调用

此时,第一个打开的终端的内容为:

Linux操作系统分析 | 深入理解系统调用

f. 运行编译好的可执行代码 server,使用 gdb 进行单步调试

在第一个终端中输入如下代码:

/home # ls
/home # ./server

Linux操作系统分析 | 深入理解系统调用

此时第二个终端内容为:

Linux操作系统分析 | 深入理解系统调用

在第二个终端中输入:

 

(gdb) n

 

结果为: 

Linux操作系统分析 | 深入理解系统调用 

报错:

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 文件内容:

 Linux操作系统分析 | 深入理解系统调用

 

将如上图所以的两行原有代码注释掉,然后添加如下的代码:
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

第二个终端内容为:

Linux操作系统分析 | 深入理解系统调用

可以看出断点位置。

b. 查看堆栈信息

在第二个终端中输入命令:

 

(gdb) bt

 

查看当前堆栈信息,如下所示:

Linux操作系统分析 | 深入理解系统调用

 

c. 单步调试

在第二个终端输入如下命令,进行单步调试:

 

(gdb) n

 

结果如下:

Linux操作系统分析 | 深入理解系统调用

 

 

Linux操作系统分析 | 深入理解系统调用

 

 

Linux操作系统分析 | 深入理解系统调用

 

四、分析总结

1、使用 (gdb) bt 查看当前堆栈情况

Linux操作系统分析 | 深入理解系统调用

根据结果显示,函数调用可以分为4层:

顶层: __x64_sys_listen       作用:开放给用户态使用的系统调用函数接口

第二层:do_syscall_64       作用:获取系统调用号,从而调用系统函数

第三层:entry_syscall_64   作用:保存现场工作,调用第二层的 do_syscall_64

第四层:操作系统

 

2、根据单步调试结果从顶层往下依次查看

(1)断点定位

Linux操作系统分析 | 深入理解系统调用

 

 

 断点定位为:

/home/linux/linux-5.4.34/net/socket.c 1688

执行以下代码,前往相应位置查看:

cd linux/linux-5.4.34/net
cat -n socket.c

结果为:

Linux操作系统分析 | 深入理解系统调用

 

 

 进入  __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 函数

Linux操作系统分析 | 深入理解系统调用

 

 

 该函数定位在:

/home/linux/linux-5.4.34/arch/x86/entry/common.c 的第300行

Linux操作系统分析 | 深入理解系统调用

 

 

 (3)执行 entry_SYSCALL_64 函数

Linux操作系统分析 | 深入理解系统调用

 

 

 该函数定位在:

/home/linux/linux-5.4.34/arch/x86/entry/entry_64.S 的第184行

Linux操作系统分析 | 深入理解系统调用

 

3、系统调用总结

(1)用户态的程序代码 server.c 中的内嵌汇编指令 syscall 触发系统调用

Linux操作系统分析 | 深入理解系统调用

 

 

 (2)通过 MSR 寄存器找到函数入口

中断函数入口为:

/home/linux/linux/-5.4.34/arch/entry/entry_64.S 第145行 ENTRY(entry_SYSCALL_64) 函数,这个函数为 x86_64 系统进行系统调用的通用入口。

ENTRY函数如下:

Linux操作系统分析 | 深入理解系统调用

 

 

 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函数

 

Linux操作系统分析 | 深入理解系统调用

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

Linux操作系统分析 | 深入理解系统调用

上一篇:MAC本安装vmware开机黑屏


下一篇:Linux 7.6查看开机自启