linux系统调用

深入理解linux系统调用

Posted by HHP on April 20, 2019

前言

系统调用一词,懂点操作系统的人肯定都听过。而搞操作系统开发的人,对这个词更不陌生,因为在开发中我们总会直接或者间接的使用到系统调用。但是,如果有人问,到底什么是系统调用?它的机制又是怎样的?我相信,不是每一个操作系统开发工程师都能很好的解释清楚。我之前对这个问题也是完全模糊的,但最近对这个问题很感兴趣,于是自己研究了一番。下面我一步步和大家去探讨一下这个的问题,进行理论分析和实践验证,最后和大家一块去实现一个系统调用。Let‘s go~

什么是系统调用

系统调用是应用程序与操作系统内核之间的接口,他决定了应用程序是如何与内核打交道的。就是说,只要你在操作系统上写的代码,如果使用到了系统的资源,像网络、文件、io等,都需要系统调用去作为桥梁帮你去完成。常见的系统调用函数有(以Linux为例子)open、fork、epoll等等。看了一下源码,linux的系统调用已经有300多个了(不同体系结构的数量不一样)。

为什么要有系统调用

大家都知道,现在的操作系统功能都十分的强大,而系统有限的资源很有可能是被多个不同的进程同时访问的。所以,如果对这些资源不加以保护,很容易就会造成各个程序之间的冲突,崩溃等问题。因此,现代操作系统都把这些有可能造成冲突的资源给保护起来,来阻止进程直接访问。这些系统资源就包括网络、文件、io、设备等。就像我们写代码,是不可能擅自去操作某块硬盘上的数据的,一定要通过系统调用,操作系统帮我们完成。

此外,有一些行为,比如说定时功能,如果我们不借助操作系统,是很难去实现的。有人会说用for循环可以实现延时,但这样做一定时不精确,二浪费CPU资源,是极其低效的操作,我猜现实中应该没多少人会这样去做吧。

基于以上这两点,已经充分说明了使用系统调用的必要性:)

系统调用的机制是怎样的

好了,终于来到了这个话题的重点了。刚才已经说了,目前linux的系统调用已经有300多个了(当然,系统调用的数量都是比较稳定的,因为真正设计一个可以使用的系统调用不是一件简单的事,要经得起实践和时间的考验,而目前linux里的系统调用都经受住了时间的考验)。我们也没必要一个个去解释,因为背后的机制其实都是大同小异的。

先来说说用户态内核态是什么。现代的CPU可以在不同的特权级下执行我们的指令。从操作系统的角度来说,有两种特权级,分别是用户模式(对应用户态)和内核模式(对应内核态)。系统调用是运行在内核态的,而其他代码是运行在用户态的。一般情况下,操作系统是通过中断来从用户态切换到内核态的。

linux传统的系统调用就是基于软件中断(在x86中是int 0x80指令,在ARM中是SWI 0x80指令)实现的。整个系统调用的过程如下图所示:

上图步骤为:

  • 触发中断int 0x80

  • 堆栈切换:

    在真正执行中断向量表中的0x80号元素之前,CPU要先进行堆栈切换,将当前栈由用户栈切换到内核栈。

  • 响应中断处理程序,查找系统调用表,找到系统调用号对应的系统调用函数执行

好了,理论讲的差不多,接下来要实践验证一下到底是不是这样。下面以探讨fork系统调用为例,其他的探索流程也是大同小异,照葫芦画瓢:)

  • 首先,写个最简单的程序

    //test.c
    #include <unistd.h>
    #include <stdio.h>
    int main(){
      fork();
    }
    
  • 然后编译一下

    [email protected]:~$gcc test.c -static
    

    编译这里最好用静态链接,直接一个可执行文件就能分析,否则用动态链接,还要去找相应的动态链接库,很麻烦(亲测)。

  • 接着反汇编一下,把汇编代码重定向到一个文件code中

    [email protected]:~$ objdump -d -M intel a.out > code
    

    这里加-M intel意思是以intel格式输出汇编指令

  • 打开code文件,开始分析~

    直接找到main函数,可以看到,就几个指令。前两行指令push rbpmov rbp , rsp 是设置堆栈帧的,每个函数调用时都要用到这两条指令,这里不用管。接下来就是call 4b0fb0 <__libc_fork> ,这个就是fork函数调用了,函数地址在0x4b0fb0。其实之前我是以为系统调用不关glibc事的,以为对应的函数是直接对应内核的系统调用号的,今天看到这个指令才发现原来系统调用是经过glibc的包装的,实际上用系统调用还是要用到glibc(当然系统调用确实可以绕过glibc,但我们一般都使用glibc封装后的函数)。

  • 既然函数地址是0x4b0fb0,那我们就去找这个地址的函数呗:

    咦?怎么__libc_fork函数里面不见int 0x80指令的?难道是我的理解有问题?别慌,可以看到有个syscall指令。为什么是syscall呢?原来由于基于int的系统调用在新的处理器上性能不佳,已经成为性能瓶颈,这种方式已经过时了,所以linux从2.5开始已经开始支持一种新型的系统调用方式,而放弃了int方式,而新的指令就是sysenter/sysexit(X86_32)和syscall/sysexit(X86_64)。ok,既然syscall是系统调用指令,那前一条指令mov eax, 0x38根据理论应该就是将系统调用号0x38赋值给eax寄存器了。

    到底是不是呢?好,继续验证分析。0x38的十进制表示就是56,那去看看系统调用号56对应的是不是fork就好啦~

  • 打开unistd.h头文件,如图

    因为我的平台是64位,所以点进去asm/unistd_64.h里面找系统调用表:

    好,找到56对应的系统调用号,居然是clone…而不是fork,fork在57啊…为啥?我决定去看看glibc的源码。

    看了一下符号表,发现fork用的是2.2.5版本的glibc的符号:

  • 于是乎download了2.2.5版本(2.2.5版本以上的都可以)的glibc下来,搜索__libc_fork,定位到fork.S这个汇编文件:

    看了一下注释和汇编,原来在glibc中,对fork的系统调用确实是实际上底层是调用了clone这个系统调用,因为将clone函数的参数flags仅仅设为SIGCHLD实际上实现的效果和fork()是一样的,这段汇编就是用户态的边缘了,执行了这段代码,之后就是执行syscall指令,然后进入内核态了。最后我们来看一下内核究竟是怎么实现的吧~

  • download源码下来,打开,找到fork的实现代码,代码如下:

    #ifdef __ARCH_WANT_SYS_FORK
    SYSCALL_DEFINE0(fork)
    {
    #ifdef CONFIG_MMU
    	return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);
    #else
    	/* can not support in nommu mode */
    	return -EINVAL;
    #endif
    }
    #endif
      
    #ifdef __ARCH_WANT_SYS_VFORK
    SYSCALL_DEFINE0(vfork)
    {
    	return _do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0,
    			0, NULL, NULL, 0);
    }
    #endif
      
    #ifdef __ARCH_WANT_SYS_CLONE
    #ifdef CONFIG_CLONE_BACKWARDS
    SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
    		 int __user *, parent_tidptr,
    		 unsigned long, tls,
    		 int __user *, child_tidptr)
    #elif defined(CONFIG_CLONE_BACKWARDS2)
    SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
    		 int __user *, parent_tidptr,
    		 int __user *, child_tidptr,
    		 unsigned long, tls)
    #elif defined(CONFIG_CLONE_BACKWARDS3)
    SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
    		int, stack_size,
    		int __user *, parent_tidptr,
    		int __user *, child_tidptr,
    		unsigned long, tls)
    #else
    SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
    		 int __user *, parent_tidptr,
    		 int __user *, child_tidptr,
    		 unsigned long, tls)
    #endif
    {
    	return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
    }
    #endif
    

    这里真相大白了,实际上无论我们是调用fork、vfork还是clone系统调用,内核的实现都是通过_do_fork这个函数实现的。这里_do_fork的具体实现就不展开说了,有兴趣的朋友可以进一步往下探究。

    那么,既然glibc对fork实质上是调用了clone这个系统调用,那fork这个系统调用是不是不会用到了呢?确实平时我们如果直接链接C运行库的时候是不会用到的,但我们也可以在应用层直接写汇编陷入或者使用glibc提供的syscall函数通过系统调用号来真正调用到fork这个系统调用~~

    到这里就验证得差不多了~下面我们就来一起实现一个系统调用吧!是不是很激动人心~~

实战:添加一个系统调用

实现一个系统调用并不是一件复杂得事情。相反,十分的简单。由于现在我们大多发行版使用的内核版本都是linux4.0+了,所以下面就以linux4.18.8为例实现一个系统调用吧!(其他的版本其实也是大同小异)

比如说我想实现一个名字叫foo的系统调用,从用户层的一块内存copy到另外一块内存:

1、首先我们从官网download一份linux4.18.8的源码下来,解压,打开源码。

2、打开系统调用表的生成文件,路径为linux-4.18.8/arch/x86/entry/syscalls/syscall_64.tbl(针对自己的体系结构进入相应的目录,我是x86_64),makefile就是根据这个文件来生成对应的asm/syscalls_64.h文件。打开后如下图,这里我添加了335号系统调用foo:

#
# 64-bit system call numbers and entry vectors
#
# The format is:
# <number> <abi> <name> <entry point>
#
# The __x64_sys_*() stubs are created on-the-fly for sys_*() system calls
#
# The abi is "common", "64" or "x32" for this file.
#
0	common	read			__x64_sys_read
1	common	write			__x64_sys_write
2	common	open			__x64_sys_open
3	common	close			__x64_sys_close
4	common	stat			__x64_sys_newstat
5	common	fstat			__x64_sys_newfstat
6	common	lstat			__x64_sys_newlstat
7	common	poll			__x64_sys_poll
8	common	lseek			__x64_sys_lseek
9	common	mmap			__x64_sys_mmap
10	common	mprotect		__x64_sys_mprotect
11	common	munmap			__x64_sys_munmap
12	common	brk			__x64_sys_brk
13	64	rt_sigaction		__x64_sys_rt_sigaction
14	common	rt_sigprocmask		__x64_sys_rt_sigprocmask
15	64	rt_sigreturn		__x64_sys_rt_sigreturn/ptregs
16	64	ioctl			__x64_sys_ioctl
17	common	pread64			__x64_sys_pread64
18	common	pwrite64		__x64_sys_pwrite64
19	64	readv			__x64_sys_readv
20	64	writev			__x64_sys_writev
21	common	access			__x64_sys_access
... ...
329	common	pkey_mprotect		__x64_sys_pkey_mprotect
330	common	pkey_alloc		__x64_sys_pkey_alloc
331	common	pkey_free		__x64_sys_pkey_free
332	common	statx			__x64_sys_statx
333	common	io_pgetevents		__x64_sys_io_pgetevents
334	common	rseq			__x64_sys_rseq


335 common  foo             __x64_sys_foo         

3、接下来我们打开linux-4.18.8/include/linux/syscalls.h这个头文件,如下图所示,添加一个声明asmlinkage long sys_foo(const char* __user src,const char* __user dst,unsigned long len);.这里asmlinkage是一个宏,告诉编译器传参要从栈中传递而不是从寄存器传入。返回类型一定要是long。

4、之后打开linux-4.18.8/kernel/sys.c (这里图方便就放在sys.c里面,也可以自己创建专门的.c文件)定义我们的foo函数。这里使用了SYSCALL_DEFINE3宏,展开后其实就是和我们声明的函数名一样。

5、好了,这样就修改好了内核代码了,接下来就可以编译了,编译步骤可参考我之前写的linux内核入门,编译时 间会比较长,大家可以先去喝杯咖啡再回来~

6、编译安装好内核之后,就重启电脑,加载新的内核。

7、至于怎么验证我们的系统调用有没有实现成功呢?我们可以在用户层写一个简单的程序验证一波,这里想实现的就是通过我们的foo系统调用将src数组的字符串hello world复制到dst数组里。这里调用我们foo的方法就是使用了glibc的syscall函数。代码如下图:

8、编译好执行一下,发现终端打印出了hello world,证明我们成功了!!

9、觉得还是不放心,那就执行dmesg看一下内核输出,发现确实打印除了成功copy的输出,如下图所示。至此,我们已经成功添加了我们的系统调用了,是不是有点小激动~

—END—