Linux内核之进程和系统调用
什么是系统调用
在Linux的世界里,我们经常会遇到系统调用这一术语,所谓系统调用,就是内核提供的、功能十分强大的一系列的函数。这些系统调用是在内核中实现的,再通过一定的方式把系统调用给用户,一般都通过门(gate)陷入(trap)实现。系统调用是用户程序和内核交互的接口。
为什么要有系统调用
系统调用在linux系统中发挥着巨大的作用,如果没有系统调用,那么应用程序就失去了内核的支持。
我们在编程时用的很多函数,如fork、open等这些函数最终都是在系统调用里面实现的。系统调用是用户接口在内核中的实现,如果没有系统调用,用户就不能利用内核。
Linux系统在CPU的保护模式下提供了四个特权级别,目前内核都只用到其中的两个特权级别,分别是“特权级0”和“特权级3”,即我们通常所讲的内核模式与用户模式。划分这两个级别主要是对系统提供保护。内核模式可以执行一些特权指令和进入用户模式,用户模式则不能。
特别指出:内核模式与用户模式分别使用自己的堆栈,当发生模式切换的时候,同时要进行堆栈的切换。
每个进程都有自己的地址空间(也称为进程空间),进程地址空间也分为两部分:用户控件和系统空间。在用户模式下,只能访问进程的用户空间;在内核模式下则可以访问进程的全部地址空间,这个地址空间的地址是一个逻辑地址,通过系统段叶式的管理机制,访问实际内存要做二级地址转换,即:逻辑地址?线性地址?物理地址。
系统调用对于内核来说相当于函数,我们的关键问题是从用户模式切换到内核模式、堆栈的切换,以及参数的传递。
系统调用过程
1、 执行用户程序(如:fork)
2、 根据glibc中的函数实现,取得系统调用号并执行int $0x80产生中断。
3、 进行地址空间的转换和堆栈的切换,执行SAVE_ALL。(进入内核模式)
4、 执行中断处理,根据系统调用表调用内核函数。
5、 执行内核函数。
6、 执行RESTORE_ALL并返回用户模式。
维基百科描述:
- 应用程序调用库函数(API);
- API将系统调用号存入EAX,然后通过中断调用使系统进入内核态;
- 内核中的中断处理函数根据系统调用号,调用对应的内核函数(系统调用);
- 系统调用完成相应功能,将返回值存入EAX,返回到中断处理函数;
- 中断处理函数返回到API中;
- API将EAX返回给应用程序。
应用程序调用系统调用的过程是:
- 把系统调用的编号存入EAX
- 把函数参数存入其它通用寄存器
- 触发0x80号中断(int 0x80)
系统调用的那些事儿
1.系统调用
我们知道,Linux将整个虚拟地址空间划分为两部分:用户空间和内核空间。并且规定,用户空间不能直接访问内核空间,而内核空间则可以访问用户空间。通过这样的级别划分,可以使得内核空间更加的稳定和安全。但是,当用户进程必须访问内核或使用某个内核函数时,就得使用系统调用(System Call)。在Linux中,系统调用是用户空间访问内核空间的唯一途径。
系统调用是内核提供的一组函数接口,它使得用户空间上运行的进程可以和内核之间进行交互。比如,用户进程通过系统调用访问硬件设备或操作系统的某些资源等。系统调用如同内核空间和用户空间的一个传话者。内核如同一个高高在上的帝王,而用户空间的进程则属于级别很小的官员。由于用户进程资质太浅,当它需要得到内核的支持时,它并没有权利直接上报内核,而只能通过系统调用这个传话人来得到内核的支持。
具体的,用户程序通过应用编程接口来使用系统调用,而系统调用则是在内核中通过内核函数来实现的。
2.应用编程接口
应用编程接口(Application Programming Interface,API)其实就是程序员在用户空间下可以直接使用的函数接口。每个API会对应一定的功能。比如strlen(),它所实现的功能就是求所传递字符串的长度。
有时候,某些API所提供的功能会涉及到与内核空间进行交互。那么,这类API内部会封装系统调用。而不涉及与内核进行交互的API则不会封装系统调用。也就是说,API和系统调用并没有严格对应关系,一个API可能恰好只对应一个系统调用,比如read()API和read()系统调用;一个API也可能由多个系统调用实现;有时候,一个API的功能可能并不需要内核提供的服务,那么此时这个API也就不需要任何的系统调用,比如abs()。另外,一个系统调用可能还被多个不同的API内部调用。
对于编程者来说,系统调用和API都是一组函数,并无什么两样;但是事实上,系统调用的实现是在内核完成的,API则是在函数库中实现的。
API是用户程序直接可以使用的函数接口,但如果每个操作系统都拥有只属于自己的API,那么应用程序的移植性将会很差。基于POSIX(Portable Operating System Interface)标准的API拥有很好的可移植性,它定义了一套POSIX兼容标准,这使得按这个标准实现的API可以在各种版本的UNIX中使用。现如今,它也可以在除UNIX之外的操作系统中使用,比如Linux,Windows NT等。
3.函数库
一个.c文件会经过预处理、编译、汇编、链接四个步骤。在汇编阶段,输出的是.o文件,即我们常说的目标文件。目标文件并不能直接执行,它需要链接器的再一次加工。链接器将所有的目标文件集合在一起,加上库文件,最后才能得到可执行文件。
函数库完成了各种API函数的定义,只不过函数库是二进制的形式,我们不能直接去查看这些API函数如何实现。这些API函数的声明则散步在不同的头文件中,比如我们常用(也许你并未感知我们频繁的使用这个函数库)的标准函数库libc.so,在其中包含多个我们常用的函数定义,但是这些函数的声明却分布在stdio.h和string.h等头文件中。
我们每次在链接程序时,都必须告诉链接器需要链接到那个库中。只不过通常默认的链接让我们忽视了这一点。
比如,一个简单的helloworld程序中,仅使用了stdio.h头文件。我们当然可以这样轻松的编译:gcc helloworld.c -o helloworld。之所以可以毫无顾忌是因为stdio.h中所声明的函数都定义在libc.so中,而对于这个函数库,连接器是默然链接的。
如果我们编译如下程序:
#include < stdio.h > #include < math.h > int main() { double i; scanf("%lf",&i); printf("%lf",sqrt(i)); return 0; }
按照我们以往的编译方法显然是不行的:
edsionte@edsionte-desktop:~$ gcc test.o -o test test.o: In function `main': test.c:(.text+0x39): undefined reference to `sqrt' collect2: ld returned 1 exit status
因为在这个程序中使用了math.h头文件,而这个头文件中声明的函数sqrt()被定义在libm.so函数库中。那么,这个时候应该这样编译:gcc test.c -o test -lm。最后的-lm选项即告诉链接器需要加入libm.so函数库。
上述一步到位的编译方法似乎又无形中掩盖了函数库的加入时间。如果我们按照编译程序的四个步骤依次处理相应文件时,就可以发现只有到了最后的链接过程中才会出现上述错误信息。也就是说,函数库的加入是在链接部分。
从上述内容中,我们知道应用程序直接使用的并不是系统调用(不过可以通过_syscallN的方法直接使用系统调用)而是API。内核中提供的每个系统调用都通过libc库封装成相应的API。如果一个API函数中包含系统调用,那么它通常在libc库中会对应一个封装例程(wrapper routine)。封装例程可能正好对应一个与API同名的系统调用,有时为了实现更加复杂的功能会封装多个系统调用。
4.系统命令
每一个系统命令其实就是一个可执行的程序,这些可执行程序的实现调用了某些系统调用。并且,这些可执行程序又分为普通用户可使用的命令和管理员可使用的命令。根据上述分类,普通用户可用的命令和管理可用的命令分别被存放于/bin和/sbin目录下。
5.系统调用的服务例程
系统调用的实现是在内核中完成的,它通过封装对应的内核函数(通常是以sys_开头,再加上相应的系统调用名)来实现其代表的功能。内核函数和用户空间中函数并无两样,只不过内核函数是在内核中实现。也就是说,用户程序通过某个系统调用进入内核后,会接着去执行这个系统调用对应的内核函数。这个内核函数也称为系统调用的服务例程。
由于内核函数是在内核中实现的,因此它必须符合内核编程的规则,比如函数名以sys_开始,函数定义时候需加asmlinkage标识符等。
一.fork和execl
我们先尝试编写创建两个程序,来理解Linux创建新进程的过程:
1 #include <sys/systypes.h> 2 #include <unistd.h> 3 4 int main() 5 { 6 if (fork() == 0) { 7 print("Child process! "); 8 } else { 9 print("Parent process! "); 10 } 11 return 0; 12 }
运行结果为:
Child process!
Parent process!
代码二
1 #include <sys/types.h> 2 #include <unistd.h> 3 #include <stdio.h> 4 5 int main() 6 { 7 execl("./hello_world", NULL, NULL); 8 printf("execl failed!"); 9 return 0; 10 }
运行结果
Hello World!
通过上面的对比,可以看错:fork实际上是复制了一个“自己”,在fork之后有两个自己在运行。子进程和父进程是依靠fork的返回值是否为0来决定执行流。返回值为0则是子进程。返回值不是0则是父进程。
execl则是将执行的内容完全替换掉。在execl之后,执行的完全是一个新的进程。就进程不复存在。
可以将fork看作是复制,而execl则是替换。
linux就是这样创建进程的:fork一个进程,在用execl加载新的执行程序,替换新建的进程的执行内容。比如ls:bash通过fork一个新的bash,再execl("ls")来创建一个ls进程。
二.系统调用的过程
想要详细定位fork和execl在内核中的实现需要了解linux系统调用的处理流程。我们以fork为例子。我们可以通过下面的代码调用fork:
#include <memory.h> #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <unistd.h> int main() { pid_t pid; // pid = fork(); asm volatile( "mov $0x2, %%eax " // 将fork的系统调用号2存到eax寄存器 "int $0x80 " // 产生int 0x80中断 "mov %%eax,%0 " // 将结果存入pid中 : "=m" (pid) ); if (pid == 0) { printf("Child process "); } else if (pid > 0) { printf("Parent process "); } return 0; }
这样就能通过内核调用fork了。可以看到我们是通过int 0x80这条指令实现的。实际上,这是一个中断。我们通过128号中断调用内核的中断服务例程,调用fork的。
在系统调用的过程中,我们需要记住两张表。
一、中断向量表:在内核启动时初始化,然后当有中断发生时,内核就会根据中断向量号(如:0x80),查找对应的中断处理服务程序。
二、系统调用表:在编译内核时就已经卸载内核中,系统调用处理程序会根据系统调用号处理对应的系统调用程序。
例如,上图的getpid函数,然后进入getuid系统调用。getuid进入system_call系统中断服务例程,system_call再去调用sys_getuid16运行服务例程。
三. 查找对应的内核服务例程
通过上面的理解,我们就可以很快的使用source insight来查找fork和execl对应的内核服务例程了。
fork对应的是:kernel/fork.c中的sys_fork函数
execl对应的是:fs/exec.c中的sys_execl函数。
这些函数太过复杂,需要非常好的基础才能看懂。我目前基础不够,只能结合《深入理解linux内核》中的进程和文件系统两章做一点简单的理解。这里就无法再做分析了。只对理解的部分做出一点总结。