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