深入理解系统调用

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

写一篇博客记录实验过程并总结分析系统调用的工作机制。

 

构建实验环境:

0.本次实验所需环境当前用户的家目录下,分为3个文件夹:linux-5.4.34内核文件夹,busybox文件夹和rootfs文件夹。

1.安装开发工具

cd ~
sudo apt install build-essential
sudo apt install qemu # install QEMU
sudo apt install libncurses5-dev bison flex libssl-dev libelf-dev

2.下载内核源代码

下载地址为https://mirrors.edge.kernel.org/pub/linux/kernel/v5.x/linux-5.4.34.tar.xz,可以用迅雷在本机下载,然后用VMware Tools拖动进虚拟机中,这样可以节省下载时间;也可以用axol在虚拟机中直接下载。下载好压缩包后解压到家目录。下载耗时约10分钟。

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.配置内核选项

# ~/linux-5.4.34
make menuconfig

结果如下图:

按照下述方式配置内核:

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)

本机CPU为i5-9300H,分配虚拟机4核心,4G虚拟机内存,编译用时约15分钟。

5.制作根文件系统

电脑加电启动⾸先由bootloader加载内核,内核紧接着需要挂载内存根⽂件系统,其中包含必要的设备驱动和⼯具,bootloader加载根⽂件系统到内存中,内核会将其挂载到根⽬录/下,然后运⾏根⽂件系统中init脚本执⾏⼀些启动任务,最后才挂载真正的磁盘根⽂件系统。
我们这⾥为了简化实验环境,仅制作内存根⽂件系统。这⾥借助BusyBox 构建极简内存根⽂件系统,提供基本的⽤户态可执⾏程序。
⾸先从官网https://www.busybox.net下载 busybox源代码解压,解压完成后,跟内核⼀样先配置编译,并安装。
注意:官网最新版busybox与最新版gcc编译器不兼容,在这种情况下,不要去官网下载busybox,
去 https://github.com/mirror/busybox 下载。
(If the browser cann't open GitHub,maybe you should install some kind of accelerator.)

执行如下指令:

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

配置成功后就可以编译busybox。

make -j$(nproc) && make install

用时约2分钟。

接下来回到用户家目录,执行如下指令:

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 MengningOS!" 
echo "--------------------" 
cd home 
/bin/sh

效果图如下:

 

创建并保存init文件后,接下来在该文件夹下开启一终端,输入如下命令:

chmod +x init

这是为了给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-system-x86_64 -kernel linux-5.4.34/arch/x86/boot/bzImage-initrd rootfs.cpio.gz这一步就是开启QEMU虚拟机,运行linux内核代码的命令!
效果如下图:
 

 至此,实验环境搭建完成。

 

跟踪调试Linux内核的基本⽅法:
下⾯具体看看如何使⽤gdb跟踪调试Linux内核。使⽤gdb跟踪调试内核,加两个参数,⼀个是-s,在TCP 1234端⼝上创建了⼀个gdb-server。可以另外打开⼀个窗⼝,⽤gdb把带有符号表的内核镜像vmlinux加载进来,然后连接gdb server,设置断点跟踪内核。若不想使⽤1234端⼝,可以使⽤-gdb tcp:xxxx来替代-s选项),另⼀个是-S代表启动时暂停虚拟机,等待 gdb 执⾏ continue指令(可以简写为c)。
 
本实验中启动虚拟机的方法:
qemu-system-x86_64 -kernel linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s
# 纯命令行下启动虚拟机
qemu-system-x86_64 -kernel linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s -nographic -append "console=ttyS0"
⽤以上命令先启动,然后可以看到虚拟机⼀启动就暂停了。加-nographic -append "console=ttyS0"参数启动不会弹出QEMU虚拟机窗⼝,可以在纯命令⾏下启动虚拟机,此时可以通过“killall qemu-systemx86_64”命令强⾏关闭虚拟机。
 
此时切不可关闭QEMU及终端。再打开另一个终端(建议在linux5.4.34目录下打开),输入如下命令:
gdb
file ./vmlinux
target remote:1234
# break 可以简写为b;c是continue的简写
break start_kernel
c
list

效果如下:

 

可以看到,start_kernel系统调用位于内核下init/main.c第577行。

在gdb检测到断点后,可以通过lisi命令查看该断点的源码片段:

(gdb)list

效果如下:

 

现在我们要做的是,将47号系统调用的执行过程用汇编代码的形式体现出来,并且用gdb检测到它。

47号系统调用是__x64_sys_recvmsg,可以在内核源码目录linux-5.4.34/arch/x86/entry/syscalls/syscall_64.tbl中看到:

 

 

该系统调用是socket套接字体系下的系统调用,负责接收信息,可以通过man命令查询其功能:

 

值得注意的是,该系统调用的源码位于./net/socket.c中,里面设计到函数重载,重名函数用于处理不同情况下的消息接收,这一点需要注意区分,因为它们的参数列表各不相同。

在rootfs/home下编写源代码文件exp_syscall.c:

int main(int argc, char** argv){
    asm volatile(
    "movq $1,%rcx\n\t"
    "movq $1,%rdx\n\t"
    "movq $1,%rsi\n\t"
    "movq $1,%rdi\n\t"
    "movl $0x2f,%eax\n\t"
    "syscall\n\t"
    );
    
    return 0;
}

因为本实验仅观察中断和系统调用的执行过程,不需要函数的执行结果,所以系统调用的参数可以给符合类型要求的任意值。

写好后,将其编译:

# ~/rootfs/home
gcc exp_syscall.c -o exp_syscall.o -static
./exp_syscall.o

编译后,重新制作根文件系统,并重启QEMU:

find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz
cd ..
qemu-system-x86_64 -kernel linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s

重新打开一个终端,并运行以下命令:

~ ~/linux-5.4.34
gdb
(gdb)file ./vmlinux
(gdb)target remote:1234
(gdb)b  __x64_sys_recvmsg
(gdb)c

输入c,以继续运行。结果可以看到和start_kernel相同的结果。

 

系统调用的流程分析:

syscall指令触发系统调用,通过MSR寄存器找到了中断函数入口,其中使用了swapgs这一方法来快照式的保存现场,加快了系统调用,随后对一些相关寄存器进行压栈操作。

 

实验总结:

中断的存在意义在于什么?为什么要有系统调用?要想理解中断和系统调用就必须从计算机技术发展的角度来理解。

1.从计算机CPU与I/O设备的交互方式谈起

计算机CPU与I/O设备的交互方式有最早的程序查询(也叫轮询)方式,发展到后来的程序中断方式,DMA方式等。简单来说,最早的程序查询方式的机制是,CPU若想和I/O设备交互,首先向I/O设备发出命令,查询并读取设备的状态,如果此时设备可用,则设备开始进行准备工作;CPU每隔一段时间便向设备发出命令,以查询并读取设备的当前状态;当设备准备好后,开始进行数据的传输,在传输过程中CPU同样要每隔一段时间就查询设备发送数据的情况,以防止存储I/O交互数据的寄存器(也叫数据端口)溢出导致传输失败。程序查询方式最明显的特点在于:I/O设备无任何自主性,I/O设备的状态转换和数据传输的全过程均由CPU全程干预,CPU必须抽出大量的时间用于定期轮询I/O设备的情况,大大降低了CPU的运算效率。

 

上图是程序查询方式和程序中断方式的执行示意图。

为什么要让CPU和I/O设备的交互如此频繁呢?换句话说,为什么要让I/O设备毫无任何自主性呢?进一步讲,如果让I/O设备有着初步的自主性,允许I/O设备在准备好以及数据传输完毕后主动通知CPU,从而打断了CPU的执行,令CPU转而服务I该/O设备,这样做就可以大大提高CPU的执行效率,这就是中断方式。在著名计算机入门教程《穿越计算机的迷雾》中,作者是这样讲述中断机制的:“中断的意思是在做一件事情的时候临时打个岔,中途去做另外一件事情,然后再回来。这好比拍一下中央处理器的肩膀,告诉它这里有一件事情需要它过来帮个小忙。在有些计算机原理书上,他们把中断看成你在吃饭,突然电话铃响了,于是你放下碗筷去接电话,然后再坐下来接着吃。”

中断机制的执行具体过程如下:

①关中断,目的是防止其他中断源前来破坏现场;

②保存断点,这是为了保证中断服务程序执行完毕后能正确返回原处;

③引出中断服务程序,将其于内存中的地址送入CPU的程序计数器PC,这本质上就是一个CPU指令系统的特殊寻址过程;寻址中断服务程序的入口内存地址有两种策略:硬件向量法(硬件产生中断向量,中断向量由中断号决定,中断号的概念在下一段有具体解释)和软件查询法(利用软件编程的方式事先规定好);

④保存现场状态;

⑤开中断,这是为了响应更高级的中断请求,实现中断嵌套;

⑥执行中断服务程序;

⑦关中断,这是为了保证恢复现场时不被外界打扰;

⑧恢复原来的现场和屏蔽字;

⑨开中断,中断返回。(中断服务程序的最后一条指令)

 其中,①~③由硬件自动完成,该过程也被抽象描述为“中断隐指令”(这只是一个抽象过程,不是真正的指令);其余步骤由中断服务程序完成。

如上所述,中断机制有两个好处:第一个好处,也是最明显的好处——通过赋予I/O设备一定的独立性从而增大CPU执行效率。中断机制的第二个好处是,不同的外设有不同的中断信号,因此它们都被CPU分配了各自不同的中断号,这就意味着计算机内存里可以防止多个不同的程序,而不是像以前那样每次只能有一个,这也意味着中断的种类可以有多种多样,中断机制不仅可以用在CPU与I/O设备交互上,还可用于软件应用程序与操作系统的交互上——系统调用。

 

2.从中断到系统调用

如上所述,中断分为繁多的类型,因此中断也有不同的分类方法。

最常用的分类方法是“外中断”和“内中断”。该方法可以涵盖所有的中断。

外中断(Interruption,有时直接被称为“中断”)指来自CPU和内存以外的部件引起的中断,如上文所述的I/O设备中断,如用户在键盘上输入命令等。外中断有时直接被称为“中断”。

内中断,又叫“异常”(Exception,这个概念在高级语言编程中经常被提到),则指在CPU和内存内部产生的中断,最简单的例子,如“拔电源”,系统突然断电,CPU确实失去了电能因此无法工作,这是典型的内中断。此外,如地址非法,除数为0,算数操作溢出,内存页面失效,用户程序执行了特权指令等均为内中断。显然,系统调用属于内中断。

此外,还有“硬件中断”和“软件中断”的分类。硬件中断是指外部硬件产生的中断,这显然属于外中断;软件中断指的是,通过编程实现的,通过某条指令产生的中断,显然系统调用属于软件中断,软件中断又属于内中断。

 

3.从程序接口到系统调用

操作系统为用户和应用程序均提供了对计算机硬件系统的接口。前者为命令接口,后者为程序接口,命令接口,如SHELL,脚本等。程序接口,由一组系统调用命令(也叫广义指令)组成,用户通过在程序中使用这些系统调用命令来请求操作系统为其提供服务。用户在程序中可以直接使用这组系统调用命令向系统提出各种服务要求。如当前流行的图形用户界面GUI,其本质就是利用系统调用。

 

4.系统调用

系统调用,就是用户在程序中调用操作系统所提供的一些子功能,系统调用可以被看做特殊的公共子程序。系统中的各种共享资源都由操作系统统一掌管,因此在用户程序中,凡是与资源有关的操作,如存储分配,进行I/O传输,及管理文件等,都必须通过系统调用方式向操作系统提出夫区请求,并由操作系统代为完成。同城,一个操作系统提供的系统调用命令有数百条。这些系统调用按功能大致可分为如下几类:

设备管理——完成设备的请求与释放,以及设备启动禁用等功能;如多个进程同时争夺一个声卡;

文件管理——完成文件的创建,读写等;如下载器在下载之前需要用户设定文件存储地址;

进程管理——完成对进程的创建,撤销,阻塞与唤醒等;

进程通信——完成进程之间的消息传递或信号传递等功能;

内存管理——完成内存的分配,回收以及获取作业占用内存区大小和初始地址等;

显然,系统调用运行在系统的核心态,通过系统调用的方式来使用系统功能,可以摆正系统的稳定性和安全性,防止用户随意更改或访问系统的数据或命令。系统调用命令是由操作系统提供的一个或多个子程序模块实现的。系统调用的运行机制为:用户通过操作系统运行上层程序,如系统提供的命令解释程序或用户自编程序,而上层程序的运行依赖于操作系统的底层管理程序提供服务支持,当需要管理程序服务时,系统则通过硬件中断机制进入和心态,运行管理程序;也可能是程序运行出现异常情况,被动地需要管理程序的服务,这时就通过异常处理来进入核心态。当管理程序运行结束时,用户程序需要继续运行,则通过相应的保存的程序现场退出中断处理程序或异常处理程序,返回断点处继续执行。

操作系统从用户态转向核心态的情况有:系统调用——用户程序要求操作系统的服务,发生一次中断,用户程序中产生一个错误状态,用户程序企图执行一条特权指令等。如果程序的运行由用户态转向核心态,会用到访管指令,这是一条在用户态使用的,因此不是特权指令。

 

 上图是系统调用的执行过程。

 

 上图显示了操作系统用户态和内核态之间的关系。系统调用是二者间重要的桥梁。

 

参考资料:

* 《庖丁解牛Linux内核分析》 孟宁老师著
* 《计算机组成原理》(第二版) 唐朔飞老师著
* 《王道2019计算机组成原理考研复习指导》

 

 

posted @ 2020-05-27 17:05  Noble~小仙女(何昳遥)  阅读(366)  评论(0编辑  收藏  举报