Linux操作系统分析 | 深入理解系统调用
实验要求
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