博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
第四章 库与运行库
阅读量:5795 次
发布时间:2019-06-18

本文共 13156 字,大约阅读时间需要 43 分钟。

1 内存

1.1 程序的内存布局

现代的应用程序都运行在一个内存空间里,在32位的系统里,这个内存空间拥有4GB的寻址能力。在Linux下默认将高地址的1GB空间分配给内核,低地址的3GB默认是用户空间。在用户空间里,又有不同的区域:

1)栈:栈用于维护函数调用的上下文,离开了栈函数调用就没有办法实现。栈通常在用户空间的最高地址处分配,通常由数兆字节的大小。

2)堆:堆是来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。堆通常存在于栈的下方。堆一般比较大,可以有几十至数百兆字节的容量。

3)可执行文件映像:这里存储着可执行文件在内存里的映像(包括数据段和代码段)。由转载器在装载时将可执行文件的内存读取或映射到这里。

4)保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称。

下图是Linux下一个进程典型的内存布局

 

其中,有个“动态链接库映射区”,这个区域用于映射装载的动态链接库。在Linux下,如果可执行文件依赖其他共享库,那么系统会为它从0x40000000开始的地址分配相应的空间,并将共享库载入到该空间。

1.2 栈与调用惯例

1.2.1 什么是栈

栈是写现代操作系统最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量。

栈在程序运行中具有举足轻重的地位。最重要的,栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧或活动记录。堆栈帧一般包括如下几方面内容:

1)函数的返回地址和参数

2)临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量

3)保存的上下文:包括在函数调用前后需要保存不变的寄存器

在一个函数的活动记录用ebp和esp这两个寄存器划定范围。esp寄存器始终指向栈的顶部,同时也就指向了当前函数的活动记录的顶部。而相对的,ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又被称为帧指针。一个很常见的活动记录实例如下:

 

在参数之后的数据(包括参数)即是当前函数的活动记录,ebp固定在图中所示的位置,不随这个函数的执行而变化,相反地,esp始终指向栈顶,因此随着函数的执行,esp会不断变化。固定不变的ebp可以用来定位函数活动记录中的各个数据。在ebp之前首先是这个函数的返回地址,它的地址是ebp-4,再往前是压入栈中的参数,它们的地址分别是ebp-8、ebp-12等。ebp所指向的数据是调用该函数前ebp的值,这样在函数返回的时候,ebp可以通过读取这个值恢复到调用前的值。一个i386下的函数总是这样调用的:

1)把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递。

2)把当前指令的下一条指令的地址压入栈中

3)跳转函数体执行

其中第二步和第三步由call指令一起执行。跳转到函数体之后即开始执行函数

4)push ebp;  把ebp压入栈中(称为old ebp)

5)mov ebp,esp; ebp=esp(这时ebp指向栈顶,而此时栈顶就是old ebp)

6)【可选】sub esp,XXX :在栈上分配XXX字节的临时空间

7)【可选】push XXX : 如有必要,保存名XXX的寄存器(可重复多个)

把ebp压入栈中,是为了在函数返回的时候便于恢复以前的ebp值。而之所以可能要保存一些寄存器,在于编译器可能要去某些寄存器在调用前后保存不变,那么函数就可以在调用开始时将这些寄存器的值压入栈中,在结束后再取出。函数返回时:

1)【可选】pop XXX: 如有必要,恢复保存过的寄存器(可重复多个)

2)mov esp,ebp: 恢复esp同时回收局部变量空间

3)pop ebp: 从栈中恢复保存的ebp的值

4)ret:从栈中取得返回地址,并跳转到该位置

例如:

int foo(){    return 123;}

反汇编的结果:

在第五步,函数将0x7B(即123)赋值给eax,作为返回值传出。在函数返回之后,调用方可以通过读取eax寄存器来获取返回值。接下来的几步是函数的资源清理阶段,从栈中恢复保存的寄存器、ebp等。最后使用ret指令从函数返回。

它们的基本形式:

 

其中x为栈上开辟出来的临时空间的字节数,reg1...regn分别代表需要保存的n个寄存器。

1.2.2 调用惯例

一般,函数的调用方和被调用方对函数如何调用有着统一的理解。如果不这样的话,函数将无法正确运行。例如:

int foo(int n,float m){    int a=0,b=0;    ...}

如果函数的调用方在传递参数时先压入参数n,再压入参数m,而foo函数却认为其调用的方式应该是压入参数m,后压入参数n,那么不难想象foo内部的m和n的值将会被交换。

因此,毫无疑问函数的调用方和被调用方对于函数如何调用必须有一个明确的约定,只有双方都遵守通用的约定,函数才能被正确地调用,这样的约定就称为调用惯例。一个调用惯例一般会规定如下几个方面的内容:

1)函数参数的传递顺序和方式

函数参数的传递有很多种方式,最常见的一种是通过栈传递。函数的调用方将参数压入栈中,函数自己再从栈中将参数取出。对于有多个参数的函数,调用惯例要规定函数调用方将参数压栈的顺序。

2)栈的维护方式

在函数将参数压栈之后,函数体会被调用,此时需要将被压入栈中的参数全部弹出,以使得函数调用前后保持一致。这个弹出的工作可以由函数的调用方完成,也可以由函数本身完成。

3)名字修饰的策略

为了连接的时候对调用惯例进行区分,调用管理要对函数本身的名字进行修饰。不同的调用管理有不同的名字修饰策略。

事实上,在C语言里,存在着多个调用惯例,而默认的调用惯例是cdecl。任何一个没有显示指定调用惯例的函数都是cdecl惯例。对于函数foo的声明,它的完整形式:

int _cdecl foo(int n,float m);

cdecl这个调用惯例是C语言默认的调用惯例,如图所示:

按照cdecl的参数传递方式,具体的堆栈操作如下:

1)将m压入栈

2)将n压入栈

3)调用_foo,此步骤有分两步:

a 将返回地址压入栈

b 跳转到_foo执行

当前函数返回之后:sp=sp+8(参数出栈,由于不需要得到出栈的数据,所以直接调整栈顶位置就可以了)。因此进入foo函数之后,栈上大致如图所示:

 

然后在foo里面要保存一系列的寄存器,包括函数调用方的ebp寄存器,以及要为a和b两个局部变量分配空间。最终栈如图所示:

 

对于不同的编译器,由于分配局部变量和保存寄存器的策略不同,这个结果可以不同。以上布局中,如果想访问变量n,实际的地址是使用ebp+8.当foo返回时,程序首先会使用pop恢复保存在栈里的寄存器,然后从栈里取得返回地址,返回到调用方。调用方再通知esp将堆栈恢复。因此有如下代码:

 其中虚线指向该指令执行后的栈状态,实线表示程序的跳转状况。同样,对于多级调用,如果我们有如下代码:

void  f(int y){    printf("y=%d",y);}int main(){    int x=1;    f(x);    return 0;}

箭头代表指向关系,而待下划线的代码代表当前执行的函数。除了cdecl调用惯例外,还存在很多别的调用惯例,如图所示:

1.2.3函数返回值传递

除了参数的传递之外,函数与调用方的交互还有一个就是返回值。通过反汇编可以看出,发现eax是传递返回值的通道。函数将返回值存储在eax中,返回后函数的调用方再读取eax。

1.3 堆与内存管理

相对于栈而言,堆这片内存面临一个稍微复杂的行为模式:在任意时刻,程序可能发出请求,要么申请一段内存,要么释放一段已申请过的内存,而且申请的大小从几个字节到数GB都是可能的。

1.3.1 什么是堆

光有栈对于面向过程的程序设计还远远不够,因为栈上的数据在函数返回的时候就会被释放掉,所以无法将数据传递至函数外部。而全局变量没有办法动态地产生,只能在编译的时候定义,有很多情况下缺乏表现力。在这种情况下,堆是唯一的选择。

堆是一个块巨大的内存空间,常常占据整个虚拟空间的绝大部分。在这片空间里,程序可以请求一块连续内存,并自由使用,这块内存在程序主动放弃之前都会一直保持有效。

例如:

int main(){    char *p=(char*)malloc(100);    free(p);}

那么malloc到底是怎么实现的呢?有一种做法是,把进程的内存管理交给操作系统内核去做,既然内核管理着进程的地址空间,那么如果它提供了一个系统调用,那么让程序使用这个系统调用申请内存,不就可以了吗?当然这是一种理论上的做法,但实际上这样不可行,如果每次程序的申请和释放都要进程系统调用,那么性能的开销很大。比较好的做法是程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,而具体来讲,管理着堆空间分配的往往是程序的运行库。

运行库相当于向操作系统“批发”了一块较大的堆空间,然后“零售”给程序用。当全部“售完”(由malloc分配)或程序由大量的内存需求时,再根据实际需求向操作系统“进货”(由brk和mmap分配)。当然运行库在向程序零售堆空间时,必须管理它批发来的堆空间,不能把同一块地址出售两次,导致地址的冲突。于是运行库需要一个算法来管理堆空间,这个算法就是堆的分配算法。不过在了解具体的分配算法之前,我们来看看运行库是怎么向操作系统批发内存的

malloc分配的时候,首先在堆中查找是否有空闲块用来分配,如果有空闲块,则直接分配;如果没有,当要分配的内存小于128KB时,malloc函数中调用brk申请虚拟内存用来进行分配,否则,调用mmap向操作系统批发虚拟内存,然后分配给用户

可以看看:

 

1.3.2 Linux进程堆管理

Linux下的进程堆管理有两种分配方式,即两个系统调用:一个是brk()系统调用,另外一个是mmap()。(这两个函数用于向操作系统批发内存作为堆空间)

int brk(void *end_data_segment);

brk()的作用实际上就是设置进程数据段的结束地址,即它可以扩大或者缩小数据段。

mmap()的作用就是向操作系统申请一段虚拟地址空间,当然这块虚拟地址空间可以映射到某个文件,当它不将地址空间映射到某个文件时,我们又称这块空间为匿名空间,匿名空间就可以拿来作为堆空间。它的声明如下:

void *mmap(void *start,size_t length,int prot,int flags,int fd, off_t offset);

mmap的前两个参数分配用于指定需要申请的空间的起始地址和长度,如果起始地址设置为0,那么Linux系统会自动挑选合适的起始地址。

Q&A

Q:可以重复释放两次堆里的同一片内存吗?

A:不能。几乎所有的堆实现里,都会在重复释放同一片堆里的内存时产生错误。

Q:调用malloc会不会最后调用到系统调用或者API?

A:这个取决于当前进程向操作系统批发的那些空间还够不够用,如果够用了,那么它可以直接在仓库里取出来卖给用户;如果不够用了,它就只能通过系统调用或者API向操作系统再进一批货了。

Q:malloc申请的内存,进程结束以后还会不会存在?

A:不会存在。因为当进程结束后,所有与进程相关的资源,包括进程的地址空间、内存空间、打开的文件、网络链接等都被操作系统关闭或者收回,所以无论malloc申请了多少内存,进程结束以后都不存在了。

Q:malloc申请的空间是不是连续的?

A:在分析这个问题之前,我们首先要分清楚“空间”这个词所指的意思。如果“空间”是指虚拟空间的话,那么答案是连续的,即每一次malloc分配后返回的空间都可以看做是一块连续的地址;如果空间是指“物理空间”的话,则答案是不一定连续,因为一块连续的虚拟地址空间有可能是若干个不连续的物理页拼接凑成的。

1.3.3 堆分配算法

前面已经介绍了堆在进程中的地址空间是如何分布的,对于程序来说,堆空间只是程序向操作系统申请划分出来的一大块地址空间(通过brk和mmap申请)。而程序在通过malloc在堆空间申请内存时的大小却是不一定的,从数个字节到数个GB都是有可能的。于是我们必须将堆空间管理起来,将它分块地按照用户要求出售给最终的程序,并且还可以按照一定的方式收回内存。其实这个问题可以归结为:如何管理一大块连续的内存空间,能够按照要求分配、释放其中的空间,这就是堆分配的算法。

1)空闲链表

空闲链表的方法实际上是把堆上各个空闲的块按照链表的方式链接起来,当用户调用malloc请求一块空间时,可以遍历整个列表,直到找到合适的小的块并且将它拆分;当用户释放空间时将它合并到空闲链表中。

首先需要一个数据结构来登记堆空间里所有的空闲空间,这样才能知道程序请求空间的时候分配给它哪一块内存。

空闲链表就是在堆里的每一个空闲空间的开头(或结尾)有一个头(header),头结构里记录了上一个(prev)和下一个(next)空闲块的地址,也就是说,所有的空闲块形成了一个链表。如果所示:

 

在这样的结构如何分配空间呢?

首先在空闲链表里查找足够容纳请求大小的一个空闲块,然后将这个块分为两部分,一部分为程序请求的空间,另一部分为剩下来的空闲空间,然后原来空闲空间的结构更新为新的剩下的空闲块,如果剩下的空间为0,则直接将这个结构从链表中删除。下图演示了用户请求一块和空闲块2恰好相等的内存空间后堆的状态。

 

这样的空闲链表实现尽管简单,但在释放空间的时候,给定一个已分配块的指针,堆无法确定这个块的大小。一个简单的解决方法是当用户请求k个字节空间的时候,我们实际分配k+4个字节,这4个字节用于存储该分配的大小,即k+4.这样释放该内存的时候只要看看这4个字节的值,就能知道该内存块的大小,然后将其插入到空闲链表里就可以了。

当然这仅仅是最简单的一种分配策略,这样的思路存在很多问题。例如,一旦链表被破坏,或者记录长度的那4字节被破坏,整个堆就无法正常工作,而这些数据恰恰很容易被越界读写接触到。

2 位图

针对空闲链表的弊端,另一种分配方式显得更为稳健。这种方式称为位图,其核心思想是将整个堆划分为大量的块,每个块的大小相同。当用户请求内存的时候,总是分配整数个块的空间给用户,第一个块我们称为已分配区域的头(Head),其余的称为已分配区域的主体(Body)。而我们可以使用一个整数数组来记录块的使用情况,由于每个块只有头/主体/空闲三种状态,因此仅仅需要两位即可表示一个块,因此称为位图。

例如:

 

这样的实现有几个优点:

1)速度快:由于整个堆的空闲信息存储在一个数组内,因此访问该数组时cache容易命中。

2)稳定性好:为了避免用户越界读写破坏数据,我们只需简单地备份一个位图即可。而且即使部分数据被破坏,也不会导致整个堆无法工作。

3)块不需要额外信息,易于管理。

当然缺点也是显而易见的:

1)分配内存的时候容易产生碎片

2)如果堆很大,或者设定的块很小(可以减少内部碎片),那么位图将会很大,可能失去cache命中率高的优势,而且也会浪费一定的空间。

2 运行库

2.1 入口函数和程序初始化

2.1.1 程序从main开始吗

程序从main开始。但是事情的真相真是如此吗?

例如:

#include
#include
int a=3;int main(int argc,char* argv[]){ int *p=(int*) malloc(sizeof(int)); scanf("%d",p); printf("%d",a+b); free(p);}

从代码中我们可以看到,在程序刚刚执行到main的时候,全局变量的初始化过程已经完成了,main函数的两个参数也被正确传了进来。此外在你不知道的时候,堆和栈的初始化悄悄完成了,一些系统I/O也被初始化了,因此可以放心的使用printf和malloc。

例如,在C++里,main之前能够执行的代码会更多。

 

#include
using namespace std;string v;double foo(){ return 1.0;}doble g=foo();int main(){}

 

在这里,对象v的构造函数,以及用于初始化全局变量g的函数foo都会在main之前调用。

例如:

atexti也是一个特殊的函数。atexit接受一个函数指针作为参数,并保证在程序正常退出(指从main里返回或调用exit函数)时,这个函数指针指向的函数会被调用。例如:

 

void foo(void){    printf("bye!\n";}int main(){    atexit(&foo);    printf("endof main\n");}

 

用atexit函数注册的函数的调用时机是在main结束之后,因此这段代码的输出时:

endof main

byte!

所有这些例子都证明:操作系统装载程序之后,首先运行的代码不是main的第一行,而是某些别的代码,这些代码负责准备好main函数执行所需要的环境,并且负责调用main函数,这时候你才可以在main函数里大胆地写各种代码:申请内存、使用系统调用、触发异常、访问I/O。在main返回之后,它会记录main函数的返回值,调用atexit注册的函数,然后结束进程。

运行这些代码的函数称为入口函数入口点。一个典型的程序运行步骤如下:

1)操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数

2)入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等等。

3)入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分

4)main函数完成以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、对摧毁、关闭I/O等,然后进行系统调用结束进程。

2.1.2 入口函数如何实现

glibc的启动过程在不同的情况下差别很大,比如静态glibc和动态glibc的区别,glibc用于可执行文件好用于共享文件的差别。这里只取最简单的静态glibc用于可执行文件的例子。

glibc的程序入口为_start(这个入口是由ld链接器默认的链接脚本所指定的,我们也可以通过相关参数设定自己的入口)。_start由汇编器实现,并且和平台相关。

 

可以看到_start函数最终调用了名为_lib_start_main的函数。其实在调用_start前,装载器会把用户的参数和环境变量压入栈中,按照其压栈的方法,实际上栈顶的元素是argc,而接下来其下就是argv和环境变量的数组。

 

综合以上分析,我们可以把_start改写成一段更具有可读性的伪代码:

 

其中argv除了指向参数表外,还隐含接着环境变量表。这个环境变量表要在_libc_start_main里从argv内提取出来。

实际执行代码的函数是_libc_start_main

这是_libc_start_main的函数头部,可见和_start函数里的调用一致,一共有7个参数,其中main由第一个参数传入,紧接着是argc和argv。除了main的函数指针之外,外部还要传入3个函数指针,分别是:

1)init:main调用前的初始化工作。

2)fini:main结束后的收尾工作

3)rtld_fini:和动态加载有关的收尾工作,rtld是runtime loader的缩写。

最后的stack_end标明了栈底的地址,即最高的栈地址。

在main函数结束之后调用的,在_libc_start_main的末尾,关键的两行代码:

 

在最后,mian函数终于被调用,并退出。然后我们看看exit函数:

 

在其中的_exit_funcs是存储由_cxa_atexit和atexit注册的函数的链表,而这里的这个while环则遍历该链表并逐个调用这些注册的函数。在_exit调用后,进程就会直接结束。程序正常结束有两种情况,一种是main函数的正常返回,一种是程序中用exit退出。在_libc_start_main里面我们看到,即使main返回了,exit还是会被调用,exit是进程正常退出的必经之路。因此把调用atexit注册的函数的任务交给exit来完成万无一失。

2.2 C/C++运行库

2.2.1 C语言运行库

任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使得程序能够正常运行。这套代码至少包括入口函数,及其所依赖的函数所构成的函数集合。当然,它还理应包括各种标准库函数的实现。

这样的一个代码集合称之为运行时库。而C语言的运行时库,即被称为C运行库(CRT)。

一个C语言运行库大概包含了如下功能:

1)启动与退出:包括入口函数及入口函数所依赖的其他函数等。

2)标准函数:由C语言标志规定的C语言标准库所拥有的函数实现

3)I/O:I/O功能的封装和实现

4)堆:堆的封装和实现

5)语言实现

6)调试:实现调试功能的代码

2.3 运行库与多线程

2.3.1 CRT的多线程困扰

线程的访问能力非常自由,它可以访问进程内存里的所有数据,甚至包括其他线程的堆栈(如果它知道其他线程的堆栈地址),但实际运用中线程也拥有自己的私有存储空间,包括:

1)栈

2)线程局部存储(TLS)。线程局部存储是某些操作系统单独提供的私有空间,但通常只具有很有限的尺寸。

3)寄存器(包括PC寄存器)

由于当初设计CRT的时候没有考虑多线程的环境,因此C/C++运行库在多线程方面吃了不少苦。例如:

1)errno:在C标准库里,大多数错误代码是在函数返回之前赋值在名为errno的全局变量里的。多线程并发的时候,有可能A线程的errno的值在获取之前就被B线程给覆盖掉,从而获得错误的出错信息。

2)strtok()等函数都会使用函数内部的局部静态变量来存储字符串的位置,不同的线程调用这个函数将会把它内部的局部静态变量弄混乱。

3)malloc/new与free/delete:堆分配/释放函数或关键字在不加锁的情况下是线程不安全的。

4)异常处理:在早期的C++运行库里,不同的线程抛出的异常会彼此冲突,从而造成信息丢失的情况

5)printf/fprintf及其他I/O函数:流输出函数同样是线程不安全的,因为它们共享了同一个控制台或文件输出。不同的输出并发时,信息会混杂在一起。

6)其他线程不安全函数:包括与信号相关的函数。

在不进行线程安全保护的情况下,自然的具有线程安全的函数有:

1)字符处理,包括isdigit、topper等

2)字符串处理函数,包括strlen、strcmp等

3)数学函数,包括sin、pow等

4)字符串转换函数,包括atoi、atof等

5)获取环境变量,包括getenv等

6)变长数组辅助函数

7)非局部跳转函数,包括setjmp和longjmp

2.4 C++全局构造和析构

在C++中,入口函数还需要在main函数的前后完成全局变量的构造和析构。

2.4.1 glibc全局构造和析构

在glibc的启动文件中存在“.init”和”.finit“段,这两个段中的代码会调用_init()和_finit(),这两个函数先于/后于main函数执行。

为了了解全局对象的构造细节,对程序的启动过程进行更深一步的研究是必须的。在_start传递进来的init函数指针究竟指向什么?通过对地址的跟踪,init实际指向了_libc_csu_init函数。例如:

 

这段代码调用了_init()函数。

3 系统调用与API

沿着程序与操作系统交互的轨迹,我们从程序如何链接、如何使用运行库的实现机制,层层挖掘和剖析,现在已经到了用户层与内核层面的界限了,也就是常说的系统调用。系统调用时应用程序与操作系统内核之间的接口,它决定了应用程序是如何与内核打交道的。无论程序是直接进行系统调用,还是通过运行库,最终还是会到达系统调用这个层面上。

3.1 系统调用介绍

3.1.1 什么是系统调用

在现代操作系统里,程序运行的时候,本身是没有权利访问多少系统资源的。由于系统有限的资源有可能被多个不同的应用程序同时访问,因此,如果不加以保护,那么各个应用程序难免产生冲突。所以现代操作系统都将可能产生冲突的系统资源保护起来,阻止应用程序直接访问。这些系统资源包括文件、网络、IO、各种设备等。

此外,还有一些行为,应用程序不借助操作系统是无法办到的。例如,让程序等待一段时间,

int i;

for(i=0;i<1000000;++i)

这样实现等待,消耗了CPU资源,所以并不是好办法。

可见,没有操作系统的帮助,应用程序的执行可谓寸步难行。为了让应用程序有能力访问系统资源,也为了让程序借助操作系统做一些必须由操作系统支持的行为,每个操作系统都会提供一套接口,以供应用程序使用。这些接口往往通过中断来实现,比如Linux使用0x80号中断作为系统调用的入口。

系统调用作为一个接口,而且是非常重要的接口,它的定义将十分重要。因为所有的应用程序都依赖于系统调用,那么,首先系统调用必须有明确的定义,即每个调用的含义、参数、行为都需要有严格而清晰的定义,这样应用程序(运行库)才可以正确地使用它;其次它必须保持稳定和向后兼容。

3.1.2 Linux系统调用

在x86下,系统调用由0x80中断完成,各个通用寄存器用于传递参数,EAX寄存器用于表示系统调用的接口号,比如EAX=1表示退出进程(exit);EAX=2表示创建进程(fork);EAX=3表示读取文件或IO(read);EAX=4表示写文件或IO(write)等,每个系统调用都对应于内核源代码中的一个函数,它们都以sys_开头的,不然exit调用对应于sys_exit函数。当系统调用返回时,EAX又作为系统调用的返回值。

下图是部分系统调用:

 

3.1.3 系统调用的弊端

系统调用完成了应用程序和内核交流的工作,因此理论上只需要系统调用就可以完成一些程序,但是:

事实上,包括Linux,大部分操作系统的系统调用都有两个特点:

1)使用不便。操作系统提供的系统调用接口往往过于原始,程序员需要了解很多与操作系统相关的细节。如果没有很好的包装,使用起来很不方便。

2)各个操作系统之间系统调用不兼容。例如Windows系统和Linux系统之间的系统调用基本上就完全不同。

”解决问题可以通过增加层来实现“,于是库挺身而出,它作为系统调用和程序之间的一个抽象层可以保持着这样的特点:

1)使用简便。因为库本来就是语言级别的,它一般都设计的相对比较友好。

2)形式统一。运行库有它的标准,叫做标准库。

例如,在C语言中用fread来读取文件,在Windows下这个函数调用ReadFile,在Linux,调用read系统调用。但是都可以使用C运行库的fread来读取文件。

运行库将不同的操作系统的系统调用包装为统一固定的接口,使得同样的代码,在不同的操作系统下都可以直接编译,并产生一致的效果。

3.2 系统调用的原理

3.2.1 特权级和中断

现代的CPU常常可以在多种截然不同的特权级别下执行指令,分别为用户模式和内核模式,也被称为用户态和内核态。由于有多种特权模式的存在,操作系统就可以让不同的代码运行在不同的模式上,以限制它们的权力,提高稳定性和安全性。

系统调用运行在内核态,而应用程序基本都是运行在用户态的。用户态的程序如何运行内核态的代码?操作系统一般是通过中断来从用户态切换到内核态。什么是中断呢?中断是一个硬件或软件发出的请求,要求CPU暂停当前的工作转去处理更加重要的事情。

中断一般具有两个属性,一个称为中断号(从0开始),一个称为中断处理程序。不同的中的具有不同的中断号,而同时一个中断处理程序一一对应一个中断号。在内核中,有一个数组称为中断向量表,这个数组的第n项包含了指向第n号中断的中断处理程序的指针。当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成后,CPU会继续执行之前的代码。

通常意义上,有两种类型的中断,一种是硬中断,另一种是软中断,软中断一般是一条指令带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断处理程序。在i386下,int  0x80这条指令会调用低0x80号中断的处理程序。

由于中断号是很有限的,操作系统不舍得用一个中断号来对应一个系统调用,而更倾向于用一个或少数几个中断号来对应所有的系统调用。例如,在Linux则使用int 0x80来触发所有的系统调用。对于同一中断号,操作系统如何知道是哪一个系统调用要被调用呢?和中断一样,系统调用都有一个系统调用号,这个系统调用号通常就是系统调用在系统调用表中的位置,例如Linux里fork的系统调用号是2.这个系统调用号在执行int指令前会被放置在某个固定的寄存器里,对应的中断代码会取得这个系统调用号,并且调用正确的函数。以Linux的int 0x80为例,系统调用号是由eax来传入的。用户将系统调用号放入eax中,然后使用int 0x80调用中断,中断服务程序就可以从eax里取得系统调用号,进而调用相应的函数。

3.2.2 基于int的Linux的经典系统调用实现

图以fork为例的Linux系统调用的执行过程

1)触发中断

例如:

int main(){    fork();}

fork函数是对系统调用fork的封装,fork的汇编代码:

 

当用户调用某个系统调用的时候,实际上是执行了以上一段汇编代码。CPU执行到int $0x80时,会保存现场以便恢复,接着会将特权状态切换到内核态。然后CPU便会查找中断向量表中的0x80号元素。

2)切换堆栈

在实际执行中断向量表中的第0x80号元素所对应的函数之前,CPU首先还要进行堆栈的切换。在Linux中,内核态和用户态使用的是不同的堆栈,两者各自负责各自的函数调用,互不干扰。但在应用程序调用0x80号中断时,程序的执行流程从用户态切换到内核态,这时程序的当前栈必须也相应地从用户栈切换到内核栈。从中断处理程序返回后,程序的当前栈从内核栈切换会用户栈。

所谓”当前栈“,指的是ESP的值所指的栈空间。如果ESP的值位于用户栈的范围内,那么程序的当前栈就是用户栈,反之亦然。此外,存储器SS的值还应指向当前栈所在的页。过程如下:

1)保存当前的ESP、SS的值

2)将ESP、SS的值设置为内核栈的相应值

反过来,内核栈切换到用户栈:

1)恢复原来的ESP、SS的值

2)用户态的ESP和SS的值是保存在内核栈上的,内核态的ESP和SS的值不需要保存。

当0x80中断发生的时候,CPU除了切入内核态之外,还会自动完成下列几件事:

1)找到当前进程的内核栈(每一个进程都有自己的内核栈)

2)在内核栈中依次压入用户态的寄存器SS、ESP、EFLAGS、CS、EIP

而当内核从系统调用返回时,需要调用iret指令来回到用户态,iret指令则会从内核栈里弹出寄存器SS、ESP、EFLAGS、CS、EIP的值,使得栈恢复到用户态的状态。这个过程如下:

3)中断处理程序

在中断向量表中找到int 0x80的中断处理程序。Linux的中的处理程序例程:

 

通过中断号0x80,最后调用函数system_call。通过eax中存放的系统调用号,查找系统调用表,然后调用相应的系统调用函数,即,sys_fork

Q&A

Q:内核里以sys开头的系统调用函数是如何从用户那里获得参数的?

A:我们知道用户系统调用时,根据系统调用参数数量的不同,依次将参数放入EBX、ECX、EDX、ESI、EDI和EBP这6个寄存器中传递。

你可能感兴趣的文章
线程sleep,join,yield的区别
查看>>
如何理解EM算法
查看>>
nginx 域名跳转一例~~~(rewrite、proxy)
查看>>
我的友情链接
查看>>
linux用户家目录无损迁移到独立硬盘
查看>>
文件查找
查看>>
shell编程前言(一)
查看>>
5、centos7.*配置yum的EPEL源及其它源
查看>>
JSON前后台简单操作
查看>>
shell中一些常见的文件操作符
查看>>
第一次作业
查看>>
ssh连接提示问题
查看>>
CentOS 7 装vim遇到的问题和解决方法
查看>>
JavaScript基础教程1-20160612
查看>>
django项目配置
查看>>
使用第三方类、库需要注意的正则类RegexKitLite的使用
查看>>
iOS \U7ea2 乱码 转换
查看>>
FCN图像分割
查看>>
ios xmpp demo
查看>>
设计模式之-工厂模式、构造函数模式
查看>>