深入理解系统调用

实验要求:

  • 找一个系统调用,系统调用号为学号最后2位相同的系统调用
  • 通过汇编指令触发该系统调用
  • 通过gdb跟踪该系统调用的内核处理过程
  • 重点阅读分析系统调用入口的保存现场、恢复现场和系统调用返回,以及重点关注系统调用过程中内核堆栈状态的变化

实验流程:

一.编译配置安装Linux内核

这一部分,上次实验已经介绍过了,老师上课的课件上给出的内容也比较详细,这里就不再累赘

 

二.制作根⽂件系统

为了简化实验流程,这⾥借助 BusyBox 构建极简内存根⽂件系统,提供基本的⽤户态可执⾏程序。(注:实验过程中发现BusyBox最后编译时,链接总是无法成功,后来将虚拟机的乌班图换成18.04,问题就解决了,猜测可能是版本问题,一开始使用的是20.04版本的系统)

⾸先从https://www.busybox.net下载 busybox源代码解压,解压完成 后,跟内核⼀样先配置编译,并安装。

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
make menuconfig 

记得要编译成静态链接,不⽤动态链接库。所以需要再设置选项中勾选静态链接。不然后续调试因为用动态链接库,需要拷贝大量相关文件,才能使系统顺利运行。

• Settings  ---> •    [*] Build static binary (no shared libs) 

 

 然后编译安装,默认会安装到源码⽬录下的 _install ⽬录中。 

 make -j$(nproc) && make install

 

然后制作内存根⽂件系统镜像,⼤致过程如下: 

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/

 

• 准备init脚本⽂件放在根⽂件系统跟⽬录下(rootfs/init),添加如下内容到init⽂件。

#!/bin/sh 
mount -t proc none /proc 
mount -t sysfs none /sys 
echo "Wellcome TestOS!" 
echo "--------------------" 
cd home 
 /bin/sh 

 

给init脚本添加可执⾏权限 

chmod +x init

打包成内存根⽂件系统镜像

find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../ rootfs.cpio.gz  

测试挂载根⽂件系统,看内核启动完成后是否执⾏init脚本 

qemu-system-x86_64 -kernel linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz

查看试验效果:

 

 

 再QEMU虚拟机上看到init进程成功启动,打印出相关文字。

 

三.分析系统调用过程,并利用gdb进行调试和验证

 

系统调用的大致流程:

当⽤户态进程调⽤⼀个系统调⽤时,CPU切换到内核态并开始执⾏ system_call(entry_INT80_32或entry_SYSCALL_64)汇编代码,其 中根据系统调⽤号调⽤对应的内核处理函数。具体来说,在Linux中通 过执⾏int $0x80或syscall指令来触发系统调⽤的执⾏,其中这条int $0x80汇编指令是产⽣中断向量为128的编程异常(trap)。另外Intel 处理器中还引⼊了sysenter指令(快速系统调⽤)。在这里只关注int指令和syscall指令触发 的系统调⽤,进⼊内核后,开始执⾏对应的中断服务程序 entry_INT80_32或entry_SYSCALL_64。

因为笔者安装的Linux系统是64位的,为了简化实验流程,这里重点分析64系统下系统调用的流程。

按照学号尾数获取相应系统调用,进行实例分析:

我的学号尾数是58,查看系统调用表可得:

 

 系统调用号58对应的系统调用为vfork,下面简单的介绍一下vfork

vfork() 函数和 fork() 函数一样都是在已有的进程中创建一个新的进程,但它们创建的子进程是有区别的。

1)fork(): 父子进程的执行次序不确定。

   vfork():保证子进程先运行,在它调用 exec(进程替换) 或 exit(退出进程)之后父进程才可能被调度运行。

2)fork(): 子进程拷贝父进程的地址空间,子进程是父进程的一个复制品。

   vfork():子进程共享父进程的地址空间(准确来说,在调用 exec(进程替换) 或 exit(退出进程) 之前与父进程数据是共享的)

这里主要的目的是分析系统调用的过程,不用过多分析该系统调用的功能。

下面编写了一个包含上述系统调用的c文件,vfork.c,代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
 
int main(int argc, char *argv[])
{
    pid_t pid;
    
    pid = vfork();    // 创建进程
    if(pid < 0){ // 出错
        perror("vfork");
    }
    
    if(0 == pid){ // 子进程
        sleep(3); // 延时 3 秒
        printf("i am son\n");
        
        _exit(0); // 退出子进程,必须
    }else if(pid > 0){ // 父进程
        
        printf("i am father\n");
    }
    
    return 0;
}

gcc -o vfork vfork.c -static 

将其编译生成可执行文件,执行效果如下:

 

 然后反汇编可执行文件,查看汇编代码:

objdump -S vfork > vfork64.S 

 

 在main函数中,调用了相关函数__libc_vfork

跳转到相应位置,查看该函数的具体实现:

 

可以清楚的看到,这和上文分析的情况相同,该系统调用先将系统调用号0x3a(58)传入%eax寄存器,然后调用syscall,启动系统调用。

查看系统表可得,vfork的内核处理函数为 __x64_sys_vfork

下面进行gdb调试,验证分析过程的正确性:

在gdb调试之前把刚刚生成的可执行文件复制到rootfs/home,以便开启虚拟机后可运行相关程序,启动系统调用。

复制完成后,因为原先的文件系统已改变,需要再次将文件系统打包封装成镜像:

find . -print0|cpio --null -ov --format=newc|gzip -9>../rootfs.cpio.gz

之后可进行gdb调试:

 

gdb调试的流程如下:

使⽤gdb跟踪调试内 核,加两个参数,⼀个是-s,在TCP 1234端⼝上创建了⼀个gdbserver。可以另外打开⼀个窗⼝,⽤gdb把带有符号表的内核镜像 vmlinux加载进来,然后连接gdb server,设置断点跟踪内核。若不想 使⽤1234端⼝,可以使⽤-gdb tcp:xxxx来替代-s选项),另⼀个是-S 代表启动时暂停虚拟机,等待 gdb 执⾏ continue指令(可以简写为 c)。

1.qemu-system-x86_64 -kernel linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s 

启动虚拟机

2.再打开⼀个窗⼝,启动gdb,把内核符号表加载进来,建⽴连接:

gdb vmlinux 

(gdb) target remote:1234

3.打上断点

(gdb)b  __x64_sys_vfork

 

 成功打上断点

4.使用相关命令进行调试,查看调试结果:

 

可得58号系统调用确实如上文所分析那样,调用了内核处理函数 __x64_sys_vfork。

四. 阅读分析系统调用入口的保存现场、恢复现场和系统调用返回

系统调⽤实质上是⼀种特殊的中断,int $0x80指令触发系统调⽤会在内核堆栈上保存⼀些寄存器的值,会保存系统调⽤发⽣时当前执⾏程序的栈顶地址(SS:ESP)、当时的状态字(EFlags)、当时 的 CS:EIP的值。同时会将当前进程内核堆栈的栈顶地址、内核的状态字等放⼊ CPU 对应的寄存 器,并且 CS:EIP 寄存器的值会指向中断处理程序的⼊⼝,对于系统调⽤来讲是指向系统调⽤处理 的⼊⼝。

更⼀般地来看,中断发⽣时CPU第⼀时间就是保存当前CPU执⾏的关键上下⽂(栈顶指针寄存器、 标志寄存器、指令指针寄存器等),然后保存现场就是把其他寄存器的值也保存起来,当中断处理 程序结束时恢复现场并中断返回,也就是负责把中断时保存的“现场”恢复到当前的 CPU ⾥⾯。

最后的 iret 与中断信号(包括 int 指令)发⽣时的 CPU 做的动作正好相反,之前是保存,这⾥就是 恢复。 

 

 

 x86 的系统调⽤实现经历了 int $0x80/iret 到 sysenter/sysexit 再到 syscall/sysret 的演变。

• int $0x80/iret 中断⽅式,32位x86

传统系统调⽤(int $0x80) 通过中断/异常实现,在执⾏ int 指令时,发⽣ trap。硬件找到在中断描述符表中的表项,在⾃动切换到内核栈 (tss.ss0 : tss.esp0) 后根据中断描述符的 segment selector 在 GDT / LDT 中找到对应的段描述符,从段描述符拿到段的基 址,加载到 cs ,将 offset 加载到 eip。后硬件将 ss / sp / eflags / cs / ip / error code 依次压到内核栈。返回时,iret 将先前压 栈的 ss / sp / eflags / cs / ip 弹出,恢复⽤户态调⽤时的寄存器上下⽂。

• sysenter/sysexit 快速系统调⽤,仅Intel CPU⽀持

32位x86 为了加速系统调⽤通过引⼊新的 MSR 来存放内核态的代码和栈的段号和偏移量,从⽽实现快速跳转:

• syscall/sysret 快速系统调⽤

x86-64 会⾃动将 rip 保存到 rcx ,然后将entry_SYSCALL_64加载到 rip

• 保存现场和恢复现场 

swapgs

x86-64引⼊了swapgs指令,类似快照的⽅式将保存现场和恢复现场时的CPU寄存器也通过CPU内部的存储器快速保存和恢复,近⼀步加快了系统调⽤

 

现在我们来厘清一下思路:

1.系统调用本质上通过中断机制实现。

2.系统调用会涉及到现场保护和恢复现场,显然这些都会在系统调用的入口出现,我们应当跟踪这一部分的代码,观察其工作过程。

3.32位和64位的系统调用在工作机制上有些区别,32位系统调用是中断的一种,64位的syscall为了加速系统调用引入了新的寄存器MSR。

 

代码分析:

传统系统调用的方式,现场保护和恢复现场的机制已在上文讲得很清楚了,因为本次实验我使用的是64位的系统,所以不展示相关代码了。重点分析syscall系统调用入口的现场保护,恢复现场,和它的工作机制。

如上文所述entry_SYSCALL_64是系统调用的入口点,它完成了保存现场,调用对应的内核处理函数、恢复现场、系统调用返回等工作。下面给出enrty_SYSCALL_64这段代码:

 1 ENTRY(entry_SYSCALL_64)
 2     UNWIND_HINT_EMPTY
 3     /*
 4      * Interrupts are off on entry.
 5      * We do not frame this tiny irq-off block with TRACE_IRQS_OFF/ON,
 6      * it is too small to ever cause noticeable irq latency.
 7      */
 8 
 9     swapgs
10     /* tss.sp2 is scratch space. */
11     movq    %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
12     SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
13     movq    PER_CPU_VAR(cpu_current_top_of_stack), %rsp
14 
15     /* Construct struct pt_regs on stack */
16     pushq    $__USER_DS                /* pt_regs->ss */
17     pushq    PER_CPU_VAR(cpu_tss_rw + TSS_sp2)    /* pt_regs->sp */
18     pushq    %r11                    /* pt_regs->flags */
19     pushq    $__USER_CS                /* pt_regs->cs */
20     pushq    %rcx                    /* pt_regs->ip */

很清晰的可以看到,该入口程序先是调用了swapgs,然后再堆栈上构建了一个pt_regs结构体用来保存和恢复现场。该结构体的内容如下图:

 

 然后cpu将进入内核态,根据系统调用传入的参数即寄存器%eax的值,调用相应的内核处理函数,内核处理函数运行结束后,需要进行现场恢复,这一部分的代码出现在结尾部分:

 1     /*
 2      * We are on the trampoline stack.  All regs except RDI are live.
 3      * We can do future final exit work right here.
 4      */
 5     STACKLEAK_ERASE_NOCLOBBER
 6 
 7     SWITCH_TO_USER_CR3_STACK scratch_reg=%rdi
 8 
 9     popq    %rdi
10     popq    %rsp
11     USERGS_SYSRET64
12 END(entry_SYSCALL_64)

首先进行了两个popq指令,将%rdi和%rsp出栈。然后又调用了一个宏指令

USERGS_SYSRET64
查询它的具体实现可得:

 

 这个指令包含了两个命令,一个是swapgs指令将入口处保护起来的寄存器恢复,一个是系统调用返回指令。

至此可得,实际代码的情况与理论分析一致。

 


 

posted @ 2020-05-27 17:30  hambug  阅读(437)  评论(0)    收藏  举报