深入理解系统调用
实验要求:
- 找一个系统调用,系统调用号为学号最后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指令将入口处保护起来的寄存器恢复,一个是系统调用返回指令。
至此可得,实际代码的情况与理论分析一致。
浙公网安备 33010602011771号