本次实验,我们将通过追踪linux中的socket库函数的调用直至内核函数的过程,来对用户态到内核态的过程加以分析,本次实验使用Ubuntu18.0.4,Linux 5.0.1内核以及64位的MenuOS
一、系统调用过程综述
1.什么是系统调用
系统调用是操作系统为用户提供的一系列API。系统调用将用户的请求发给内核,内核执行完以后,将结果返回给用户。这实际上是一个用户态->内核态->用户态的过程。也就是说系统调用可以完成用户态和内核态的切换,那么什么是用户态和内核态的切换呢,为什么要进行用户态和内核态的切换,怎么进行用户态和内核态的切换。我们在研究系统调用之前先来搞清楚这些问题。
2.用户态和内核态的切换
在讨论这个问题之前,我们先理解用户态和内核态的区别。
2.1 用户态和内核态的概念区别
(1)例子
void testfork(){ if(fork()==0) printf("create new process success!/n"); printf("testfork ok/n"); }
这段代码实现的功能实际很简单,实际上就是生成了一个新的进程,静态的来看,就是判断fork()的返回值是否为0,然后根据条件打印一些语句,但这好像跟用户态和内核态没什么关系。
此时我们还可以从动态的角度来看这段代码,即它被转换成CPU执行的指令后加载执行的过程,这时这段程序就是一个动态执行的指令序列。而究竟加载了哪些代码,如何加载就是和操作系统密切相关了。
(2)特权级
我们知道fork实际上是以系统调用的方式完成相应功能的,在Linux中具体的工作是由sys_fork实现。实际上对于任何操作系统来说,创建一个新的进程都是属于核心功能。因为创建进程并不像我们表面上理解的类似与新建一个文件并给他取个名字那样简单,它要做很多底层细致的工作,需要消耗系统的物理资源,比如分配物理内存,从父进程拷贝相关信息,拷贝设置页目录页表等等,这些操作显然不能随便哪个程序都可以做,因为有些操作如果不当的话会带来毁灭性的后果,于是就自然引出特权级别的概念,显然,最关键性的权力必须由高特权级的程序来执行,这样才可以做到集中管理,减少有限资源的访问和使用冲突。
特权级的实现需要硬件上的支持,Intel x86架构的CPU中一共有0~3四个特权级,0级最高,3级最低,硬件上在执行每条指令时都会对指令所具有的特权级做相应的检查。操作系统的作用就是好好利用这些机制,对Linux来说,只使用了0级特权级和3级特权级。也就是说在Linux系统中,一条工作在0级特权级的指令具有了CPU能提供的最高权力,而一条工作在3级特权级的指令具有CPU提供的最低或者说最基本权力。
(3)用户态和内核态
用Linux来举例,现在我们就可以很容易理解了,当程序运行在3级特权级上时,我们就可以称之为用户态,因为此时程序只拥有最低特权级,无法进行一些特权级高的操作,当程序运行在0级特权级上时,我们就可以称之为内核态,因为此时程序拥有操作系统可以提供的最高特权级。
结合刚刚的例子来说testfork函数显然是运行在用户态的普通程序,他无法调用sys_fork去创建一个新的进程,因为它的特权级不够,所以这时只能通过调用fork,让fork去触发执行sys_fork,从而进入内核态。到这里就是用户态到内核态的切换了,现在我们应该了解用户态和内核态的区别以及什么是用户态到内核态的切换了
2.2 为何要进行内核态到用户态的切换
这个问题跟刚刚介绍的特权级有关,操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的资源,而且如果不对这些操作加以区分,很可能造成资源访问的冲突,特权级机制导致了有些操作可以在用户态执行,有些操作只能在内核态执行,当一个运行在用户态的程序想要执行更高级别的操作时,那就必须陷入内核态了。
2.3 如何进行用户态和内核态的切换
用户态到内核态的切换主要有三种方式
(1)异常
当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
(2)外围设备的中断
当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如从键盘敲入一个字符。
(3)系统调用
系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。
从触发方式上看,用户态和内核态的切换可以认为存在以上3种不同的方式,但是实际都相当于执行了一个中断响应的过程,因为系统调用实际上也是一种中断,我们可以称为软中断,在Linux系统中,可以查阅中断表得知,用于系统调用的中断向量是0x80,通过指令int& 0x80产生中断。
3.系统调用的过程
现在我们了解到如果应用程序想要主动从用户态陷入内核态,需要借助系统调用,我们通过结合socketapi的系统调用来具体分析。
下面是socketapi系统调用的过程图,可以看到系统调用是用户态和内核态之间的桥梁
当我们在用户态调用socketapi时,当运行到int &0x80中断指令时,会跳转到entry_INT80_32,这是Linux系统调用的入口,
而在64位系统中对应的则是entry_SYSCALL_compat,它是一段汇编代码,entry_SYSCALL_compat通过系统调用号来查询
对应的内核处理函数并跳转到相应的内核处理函数执行,执行完毕后再按顺序逐步返回到用户态。
接下来,我们在gdb中调试测试以下,先介绍一些常用的gdb命令
(gdb)help:查看命令帮助,具体命令查询在gdb中输入help + 命令,简写h (gdb)run:重新开始运行文件(run-text:加载文本文件,run-bin:加载二进制文件),简写r (gdb)start:单步执行,运行程序,停在第一执行语句 (gdb)list:查看原代码(list-n,从第n行开始查看代码。list+ 函数名:查看具体函数),简写l (gdb)set:设置变量的值 (gdb)next:单步调试(逐过程,函数直接执行),简写n (gdb)step:单步调试(逐语句:跳入自定义函数内部执行),简写s (gdb)backtrace:查看函数的调用的栈帧和层级关系,简写bt (gdb)frame:切换函数的栈帧,简写f (gdb)info:查看函数内部局部变量的数值,简写i (gdb)finish:结束当前函数,返回到函数调用点 (gdb)continue:继续运行,简写c (gdb)print:打印值及地址,简写p (gdb)quit:退出gdb,简写q (gdb)break+num:在第num行设置断点,简写b (gdb)info breakpoints:查看当前设置的所有断点 (gdb)delete breakpoints num:删除第num个断点,简写d (gdb)display:追踪查看具体变量值 (gdb)undisplay:取消追踪观察变量 (gdb)watch:被设置观察点的变量发生修改时,打印显示 (gdb)i watch:显示观察点 (gdb)enable breakpoints:启用断点 (gdb)disable breakpoints:禁用断点 (gdb)x:查看内存x/20xw 显示20个单元,16进制,4字节每单元 (gdb)run argv[1] argv[2]:调试时命令行传参 (gdb)set follow-fork-mode child#Makefile项目管理:选择跟踪父子进程(fork()) core文件:先用$ ulimit -c 1024 开启core,当程序出错会自动生成core文件。调试时 gdb a.out core
在测试之前,我们需要在用户态下查看是否像我们所说涉及到int 0x80指令的使用。我们在用户态下运行menu目录下的
init可执行文件。这里使用objdump命令来反汇编它
在vim中打开test.txt文件
我们可以看到,涉及系统调用确实会使用int &0x80指令。
二、结合socketAPI分析系统调用
所有的socket系统调用的总入口是sys_socketcall(),在include/linux/Syscalls.h中定义
其中参数call是标识接口编号,args是接口参数指针,接口编号的定义在 include/uapi/linux/net.h中定义
我们可以看到一共有20个接口,接口编号对应的参数个数在net/socket.c文件中的nargs数组中定义
在net/compat.c中第836行有一个函数COMPAT_SYSCALL_DEFINE2(socketcall, int, call, u32 __user *, args)便是socket调用的入口。
我们开始在gdb中开始测试
cd ~/LinuxKernel/menu make rootfs #重新打开一个终端 gdb file ~/LinuxKernel/linux-5.0.1/vmlinux target remote:1234 #设置断点对__ia32_compat_sys_socketcall进行跟踪 b __ia32_compat_sys_socketcall
然后我们一直按c运行直到MenuOS加载完成,到这一步,系统捕获到了3次__ia32_compat_sys_socketcall,分别如下
其中第二次捕捉到__ia32_compat_sys_socketcall,是以太网卡的启动。然后出现以下画面,gdb调试的终端持续continue。
这也就也就意味着我们要在MenuOS中输入命令,这里我们使用replyhi和hello来测试。在此之前我们来看一看COMPAT_SYSCALL_DEFINE2的源码。由于源码太长我这里只贴了一部分。
COMPAT_SYSCALL_DEFINE2(socketcall, int, call, u32 __user *, args) { ..... .....
#通过switch,进入参数对应的分支,这里实际上一共有20个case分支,也对应上面include/uapi/linux/net.h中对应的20个接口 switch (call) { case SYS_SOCKET: ret = __sys_socket(a0, a1, a[2]); break; case SYS_BIND: ret = __sys_bind(a0, compat_ptr(a1), a[2]); break; case SYS_CONNECT: ret = __sys_connect(a0, compat_ptr(a1), a[2]); break; case SYS_LISTEN: ret = __sys_listen(a0, a1); break; case SYS_ACCEPT: ret = __sys_accept4(a0, compat_ptr(a1), compat_ptr(a[2]), 0); break; ..... ..... case SYS_SEND: ret = __sys_sendto(a0, compat_ptr(a1), a[2], a[3], NULL, 0); break; case SYS_SENDTO: ret = __sys_sendto(a0, compat_ptr(a1), a[2], a[3], compat_ptr(a[4]), a[5]); break; case SYS_RECV: ret = __compat_sys_recvfrom(a0, compat_ptr(a1), a[2], a[3], NULL, NULL); break; ..... ..... } return ret; }
可以看到常用的__sys_socket,__sys_bind等函数,接下来,我们要在MenuOS中输入命令replyhi来继续追踪,由于我们本次
实验是结合replyhi和hello来分析的,所以这里先简单看一下它们的源码,在LinuxKernel/menu中找到test.c文件,先来看看主函数,
可以发现replyhi和hello命令需要分别调用 StartReplyhi 和 Hello函数。
int main() { BringUpNetInterface(); PrintMenuOS(); SetPrompt("MenuOS>>"); MenuConfig("version","MenuOS V1.0(Based on Linux 3.18.6)",NULL); MenuConfig("quit","Quit from MenuOS",Quit); MenuConfig("replyhi", "Reply hi TCP Service", StartReplyhi); MenuConfig("hello", "Hello TCP Client", Hello); ExecuteMenu(); }
我们先来看 StartReplyhi
int StartReplyhi(int argc, char *argv[]) { int pid; /* fork another process */ pid = fork(); if (pid < 0) { /* error occurred */ fprintf(stderr, "Fork Failed!"); exit(-1); } else if (pid == 0) { /* child process */ Replyhi(); printf("Reply hi TCP Service Started! "); } else { /* parent process */ printf("Please input hello... "); } }
然后StartReplyhi又调用了Replyhi
int Replyhi()
{ char szBuf[MAX_BUF_LEN] = "