1. 温故而知新
运行库使用操作系统提供的系统调用接口,系统调用接口在实现中往往以软件中断的方式提供。比如Linux使用0x80号中断作为系统调用接口,Windows使用0x2E号中断作为系统调用接口。
虚拟内存的实现依靠硬件的支持,采用MMU(Memory Management Unit)进行页映射。程序的虚拟内存空间分页加载到物理内存中,页错误(Page Fault)时由操作系统接管,并将页加载进物理内存。CPU发出的指令为虚拟地址,经MMU转换后变成物理地址。
一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。
- 线程私有数据:1. 局部变量;2. 函数的参数;3. 线程本地存储
- 线程之间共享的数据:1. 全局变量;2. 堆上的数据;3. 函数里的静态变量;4. 程序代码,任何线程都有权利读取并执行当前进程可执行的任何代码;5. 打开的文件,A线程打开的文件可由B线程读写
把单指令的操作称为原子的(Atomic),因为无论如何,单指令的执行是不会被打断的。
同步的最常见方式是使用锁。二元信号量(Binary Semaphore)是最简单的一种锁,它只有两种状态:占用与非占用。它适合只能被唯一线程独占访问的资源。当二元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放。互斥锁(Mutex)和二元信号量类似,资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统里可以被任意线程获取和释放,也就是说,同一个信号量可以被一个线程获取之后由另一个线程释放。而互斥锁要求哪个线程获取了互斥锁,哪个线程就要负责释放这个锁。
读写锁有两种获取方式,共享的(shared)和独占的(exclusive)。当锁处于自由状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共享方式获取锁仍然会成功,此时锁分配给了多个线程。然后,如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它将必须等待锁被所有的线程释放。
条件变量(Condition Variable)作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒。也就是说,使用条件变量可以让许多线程一起等待某个事件的发生,所有线程可以一起恢复执行。
一个函数要成为可重入的,必须具有如下几个特点:
- 不使用任何静态变量或全局的非const变量
- 不返回任何静态变量或全局的非const变量的指针
- 仅依赖于调用方提供的参数
- 不依赖任何单个资源的锁
- 不调用任何不可重入的函数
可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下安全使用。
volatile关键字可以阻止编译器进行过度优化
- 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回
- 阻止编译器调整操作volatile变量的指令顺序
2. 编译和链接
上述过程分解为四个步骤,1. 预处理;2. 编译;3. 汇编;4. 链接。
预编译
第一步预编译的过程相当于如下命令
预编译过程主要处理那些源代码文件中的以#
开头的预编译指令。比如#include
、#define
等,主要处理规则如下:
- 将所有的
#define
删除,并且展开所有的宏定义 - 处理所有条件预编译指令,
#if
、#ifdef
、#elif
、#else
、#endif
等 - 处理
#include
预编译指令,将被包含的文件插入到预编译指令的位置。 - 删除注释
- 添加行号和文件名标识,以便于编译时产生调试用的行号信息以及用于编译时产生编译错误或警告时能显示行号。
- 保留所有
#pragma
编译器指令
编译
编译过程相当于如下命令
编译过程进行词法分析、语法分析、语义分析以及优化后产生汇编代码文件。直观来说,编译器将高级语言翻译成低级语言。
词法分析产生Token,将Token分成关键字、标识符、字面量和特殊符号。语法分析器对Token进行语法分析,产生语法树。语义分析处理声明和类型的转换,比如将一个浮点型的表达式赋值给一个整形表达式,语义分析就需要完成隐式转化的步骤。经过语义分析后,整个语法树的表达式都被标识了类型,或者是在语法树的某些节点前添加了隐式转换的节点。
编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。编译器后端主要包括代码生成器和目标代码优化器,代码生成器将中间代码转换成目标机器代码,目标代码优化器对目标代码进行优化,比如寻找合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等。
汇编
汇编器将汇编代码转变成机器可以执行的指令。
上述两种方式都可以执行汇编过程。
链接
在C语言中,最小的单位是变量和函数,若干变量和函数组成一个模块,存放在一个.c
的源代码文件中。
静态语言的C/C++模块之间通信有两种方式,1. 模块之间函数的调用;2. 模块之间的变量访问。函数访问需知道目标函数的地址,变量访问也需要知道目标变量的地址,所以这两种方式都可以归结为一种方式,就是模块间符号的引用。
链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。
链接过程主要包括地址和空间分配、符号决议和重定位等。
3. 目标文件里有什么
目标文件是源代码编译汇编后但未进行链接的那些中间文件,在Windows下的.obj
和Linux下的.o
文件。
可执行文件格式,包括Windows下的PE(Portable Executable)以及Linux下的ELF(Executable Linkable Format)。不止可执行文件(Windows下的.exe
和Linux下的ELF可执行文件)是可执行文件格式,动态链接库(Windows下的.dll
和Linux下的.so
)以及静态链接库(Windows下的.lib
和Linxu下的.a
)文件都按照可执行文件格式存储。
ELF
目标文件中包含编译后的机器指令代码、数据、符号表、调试信息等,在目标文件中以段的形式存储。
机器指令被放在代码段里,常见的名字有.code
或者.text
,全局变量和局部静态变量数据放在数据段里,已初始化的全局变量和局部静态变量都保存在.data
段,未初始化的全局变量和局部静态变量一般放在.bss
段里。
ELF文件的开头是一个文件头,描述了整个文件的属性,包括文件是否可执行、是静态链接还是动态链接及入口地址、目标硬件等信息。文件头还包括一个段表,描述文件中各个段的偏移位置以及段的属性等。
利用objdump
提取目标文件中的信息,
s
表示将所有段内容以十六进制的方式打印出来,d
参数可以将所有包含指令的段进行反编译。
gcc提供一个扩展机制,使得程序员可以指定变量所处的段:
ELF文件的段结构由段表决定,编译器、链接器和装载器依靠段表来定位和访问各个段的属性。段表是一个以Elf32_Shdr
结构体为元素的数组,数组元素的个数等于段的个数。
链接的接口:符号
在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。每一个目标文件都会有一个相应的符号表,这个表里记录了目标文件中所用到的所有符号。每个定义的符号有一个相对应的值,叫做符号值,对于变量和函数来说,符号值就是它们的地址。
符号可以分为:
- 定义在目标文件中的全局符号,可以被其他文件引用;
- 在本目标文件中引用的全局符号,没有定义在本文件中,被称为外部符号;
- 局部符号,只在编译单元内可见;
符号表是ELF文件中的一个段,段名一般叫做.symtab
。符号表是一个Elf32_Sym
结构体的数组,每一个Elf32_Sym
对应一个符号。
符号修饰(Name Decoration)和符号改编(Name Mangling)机制是为了保证相同名称的变量编译后的符号不同,以避免冲突。不同的编译器采用不同的符号修饰方式,导致由不同编译器编译产生的目标文件无法正常相互链接。
4. 静态链接
对于链接器来说,整个链接过程中,就是将几个输入目标文件加工后合并成一个输出文件。现在的链接器一般都将目标文件的各个段合并起来放到最终的可执行文件中。
两步链接(Two-pass Linking):1. 空间与地址的分配:扫描所有输入目标文件,获取各个段的长度、属性和位置,并将所有的符号定义和符号引用统一放到一个全局符号表。在这一步中,链接器能够获得所有输入目标文件的段长度,并将它们合并,计算出输出文件中各个段合并后的位置和长度,建立映射关系;2. 符号解析和重定位,调整代码中的地址。第二步是链接过程的核心,特别是重定位过程。
在链接之后,可执行文件中使用的地址已经是程序在进程中的虚拟地址。
符号解析和重定位
链接器在完成地址和空间分配之后就可以确定所有符号的虚拟地址了,然后链接器就可以根据符号的地址对每个需要重定位的指令进行地址修正。
对于可重定位的ELF文件来说,必须包含有重定位表。每一个要被重定位的地方叫一个重定位入口,在重定位表中记录了重定位入口的偏移。链接时根据其他目标文件中的信息修改重定位入口处的符号地址。
重定位的过程伴随着符号的解析。重定位过程中,每个重定位的入口都是对一个符号的引用,那么当链接器需要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。
Common块
现在的编译器和链接器都支持COMMON块的机制,这种机制最早来源于Fortran,早期的Fortran没有动态分配空间的机制,程序员必须事先声明它所需要的临时使用空间的大小,Fortran把这种空间叫Common块,当不同的目标文件需要的Common块空间大小不一致时,以最大的那块为准。
弱符号机制允许同一个符号的定义存在于多个文件中。当多个以上弱符号类型不一致,且没有同名强符号时,按照所占空间最大的那个弱符号分配空间。
未初始化的全局变量是典型的弱符号。
GCC的-fno-common
允许把所有未初始化的全局变量不以Common块的形式处理,或者使用__attribute__
扩展:
int global __attribute__((nocommon));
C++相关问题
重复代码消除
模版、外部内联函数、虚函数表都有可能在不同的编译单元里生成相同的代码。
有效的做法是,将每一个模版的实例代码都单独放在一个段里,每个段里只包含一个模版实例,同一个模版实例的段名保持一致,链接器在看到重复模版实例的段名时,只保存其中一个。虚函数表和外部内联函数都可以这样处理。
可能有问题的地方是,同样名称的模版实例可能所包含的内容不同,例如编译不同的编译单元时采用了不同的优化等级。
函数级别链接
一个目标文件中可能包含很多函数和变量,链接一个目标文件,可能将其中无用的部分也链接进来。避免这种无用代码引入,可以使用函数级别链接,将每个函数保存在单独的段中,只引用必须要用的段,抛弃没有用的函数。
代价是编译链接过程减慢,段增加使得重定位变得复杂。
在gcc中可以使用-ffunction-sections
和-fdata-sections
开启函数级别链接。
全局构造和析构
在main
函数调用之前,为了程序能够顺利执行,首先需要初始化进程执行环境。C++的全局对象构造函数在main
之前执行,C++全局对象的析构函数在main
之后执行。
链接过程控制
用链接控制脚本控制链接过程。以ld
链接器为例,如果没有指定链接控制脚本,则会使用默认链接脚本。
ld
链接器的链接脚本继承于AT&T链接器命令语言的语法。
5. 可执行文件的装载与进程
每个程序被运行起来之后,它将拥有独立的虚拟地址空间。虚拟地址空间的大小由计算机的硬件平台决定,比如32位的硬件平台的虚拟地址空间为4G。从程序的角度,可以通过C语言程序的指针所占的空间来计算虚拟地址空间的大小。
程序装载时,首先将程序中的代码和数据以页为单位划分为若干页,在程序运行时,操作系统发现某个页不在内存中,就会将对应页加载到内存中。
创建一个进程,最开始只有三件事情需要做:
- 创建一个独立的虚拟空间;
- 读取可执行文件头,并建立虚拟空间与可执行文件的映射关系;
- 将CPU的指令寄存器设置为可执行文件的入口地址,启动执行。
在做完了这些准备工作之后,程序开始执行,由于可执行文件没有装载到内存,所以会报页错误,然后控制权交还给操作系统,由操作系统完成物理内存的分配。
进程虚拟空间地址:操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以分为如下几种VMA区域:
- 代码VMA,权限可读、可执行;有映像文件。
- 数据VMA,权限可读写、可执行;有映像文件。
- 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。
- 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展。
进程初始化时,会将环境变量以及进程运行的参数放到栈VMA的位置,包括参数的个数以及各个参数。进程启动之后,会将栈中的初始化信息传递给main
函数,也就是我们熟知的argc
和argv
这两个参数。
Linux内核装载ELF简介
在Linux系统的bash
下输入一个命令执行某个ELF可执行文件时,首先在用户层面,bash
进程会调用fork
系统调用创建一个新的进程,然后新的进程调用execve
系统调用执行指定的ELF文件。
execve
系统调用被定义在unistd.h
中,它的原型如下:
它的三个参数分别是被执行的程序文件名、执行参数和环境变量。
在进行execve
系统调用之后,Linux内核就开始进行真正的装载工作。
首先会读取文件的前128个字节,判断文件的类型,如ELF文件,Java文件,或者bash脚本文件等。然后做如下操作:
- 检查ELF可执行文件格式的有效性;
- 寻找动态链接的
.interp
段,设置动态链接器路径; - 根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据;
- 初始化ELF进程环境;
- 将系统调用的返回地址修改成ELF可执行文件的入口地址。
当系统调用返回时,由于返回地址已经被改成了ELF程序的入口地址,所以返回到用户态时,EIP寄存器直接跳转到ELF程序的入口地址,新的程序开始运行,ELF可执行文件装载完成。
6. 动态链接
把链接这个过程推迟到了运行时进行,这就是动态链接的基本思想。
动态链接可以使得多个进程依赖同一个加载到内存中的动态库,大大降低了内存的占用。
动态链接的基本实现
动态链接是把程序按照模块拆分成各个相对独立的部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块都链接成一个单独的可执行文件。
在Linux下,ELF动态链接文件被称为动态共享对象(Dynamic Shared Object),简称共享对象,它们一般都是以.so
为扩展名的一些文件;而在Windows系统中,动态链接文件被称为动态链接库(Dynamic Linking Library),它们一般都是以.ddl
为扩展名的文件。
当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。
链接器处理目标文件时,会确定函数的性质,如果是该函数定义于静态目标模块中,则链接器会对函数进行地址重定位;如果该函数定义在动态库中,链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。
动态链接程序运行时地址空间分布
对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。对于动态链接来说,除了可执行文件本身之外,还有它所依赖的共享目标文件。
共享对象的最终装载地址在编译时是不确定的。在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟空间地址给相应的共享对象。
动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的。如果在装载时对动态库进行重定位,那么对于每个进程,都要保存一份重定位信息的副本。
为了使指令部分可以在多个进程之间共享,所以把指令中需要重定位的部分分离出来,放到数据部分,这样指令部分就可以保持不变,数据部分可以在每个进程中拥有一个副本,这种方案目前被称为地址无关码。
动态链接比静态链接要灵活得多,但是以牺牲一部分性能为代价。动态链接比静态链接慢的主要原因是动态链接对于全局数据和静态数据的访问都要进行复杂的GOT定位,然后间接寻址。另一个运行速度慢的原因是,程序开始执行时,动态链接器在运行时需要完成一次链接工作。
加载引用了动态库中符号的可执行文件时,操作系统会先将可执行文件加载进内存,然后将动态链接器加载进内存,之后将返回地址设置为动态链接器的入口地址。动态链接器接管程序之后,会将可执行文件所需的动态库加载进内存,然后再将程序的控制权交还给可执行文件。
在ELF文件中,.interp
段记录了动态链接器的路径,例如在Linux下,.interp
段的内容是/lib/ld-linux.so.2
。
动态链接的步骤
动态链接基本分为三步,1. 启动动态链接器本身;2. 装载所有需要的共享对象;3. 重定位和初始化。
动态链接器本身也是一个共享对象。但是动态链接器本身不依赖其他任何共享对象,其次动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。
完成动态链接器的初始化之后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表中,称为全局符号表。然后链接器开始寻找可执行文件依赖的共享对象。由于加载的共享对象还可能依赖别的共享对象,所以整个加载过程可以看作是一个图的遍历。当一个新的共享变量被装载进来之后,它的符号表会被合并到全局符号表中。
全局符号介入:当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
当上述步骤完成后,动态链接器开始遍历可执行文件和共享对象的重定位表,并进行地址重定位。
Linux动态链接的实现
在Linux下,操作系统通过 execve()
系统调用将可执行文件加载到进程的地址空间中。对于不需要动态连接的程序,加载完可执行文件之后,内核将控制权限转让给可执行文件的入口函数。对于需要进行动态链接的程序,会先调用动态链接器处理动态库的加载和符号的重定位等工作。Linux的ELF动态链接器是glibc的一部分,它的源码位于glibc的源代码的elf
目录下。
显式运行时链接
打开动态库dlopen
,查找符号dlsym
,错误处理dlerror
,关闭动态度dlclose
。
7. 内存
程序的内存布局
现代的应用程序都运行在一个内存空间里,在32位的系统里,这个内存空间拥有4GB的寻址能力。在平坦的内存模型中,整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意内存位置。
不过,尽管当今的内存空间号称是平坦的,但实际上内存仍然在不同的地址区间有着不同的地位,例如,大多数操作系统都会将4GB的内存空间中的一部分挪给内核用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间。
剩下的内存空间称为用户空间。一般来讲,应用程序使用的内存空间有如下默认的区域:
- 栈:栈用于维护函数调用的上下文。
- 堆:堆用来容纳应用程序动态分配的内存区域。
- 可执行文件映像:储存可执行文件在内存里的映像。
- 保留区:对内存中受到保护而禁止访问的内存区域的总称。
- 动态链接库映射区:用于映射装载的动态链接库。
在Linux下,如果可执行文件依赖其他共享库,那么系统就会为它在从0x40000000开始的地址分配相应的空间,并将共享库载入到该空间。
栈和调用惯例
栈保存了一个函数调用所需要的维护信息,这常常被称为栈帧(Stack Frame),栈帧一般包括如下几方面内容:
- 函数的返回地址和参数。
- 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器。
Where your statics go depends on whether they are zero-initialized. zero-initialized static data goes in .BSS (Block Started by Symbol), non-zero-initialized data goes in .DATA.
一个函数总是这样调用的:
- 把所有参数或一部分参数压入栈中,如果有一部分参数没有入栈,那么使用某些特定寄存器传递。
- 把当前指令的下一条指令压入栈中。
- 跳转到函数体执行。
对于一个被声明为static的函数,且没有函数指针指向这个函数,调用的方式可能不是这样。因为编译器可以随意更改这个函数的任意方面,包括进入和推出指令序列。
在C++中返回一个对象或者一个大的栈变量时,会进行两次拷贝,对于对象来说还需要进行一次临时对象的析构。返回值优化(RVO)可以将某些场合下对象拷贝的次数减一。
堆和内存管理
只有栈对于面向过程的程序设计还远远不够,因为栈上的数据在函数返回时就会被释放掉,所以无法将数据传递至函数外部。而全局变量没有办法动态地产生,只能在编译的时候定义。堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分。
Linux下提供两种堆空间分配的方式,即两个系统调用,一个是brk()
系统调用,一个是mmap()
系统调用。
brk()
的作用实际上就是设置进程数据段的结束地址,在Linux下,.bss
段和.data
段合并在一起统称数据段。如果我们将数据段的结束地址向高地址移动,那么扩大的空间就可以被我们使用,把这块空间拿来做为堆空间是最常见的做法之一。
mmap()
的作用是向操作系统申请一段虚拟地址空间,当然这块虚拟地址空间可以映射到某个文件,当它不将地址空间映射到某个文件时,我们又称这块空间为匿名空间,匿名空间就可以拿来作为堆空间。它的声明如下:
mmap
的前两个参数分别用于指定需要申请的空间的起始地址和长度,如果起始地址设为0,那么系统会挑选合适的起始地址。prot
和flag
两个参数用于设置申请的空间的权限(可读、可写、可执行)以及映射类型(文件映射、匿名空间等)。
由于mmap函数是向系统虚拟空间申请内存,其申请的空间的起始地址和大小都必须是系统页的大小的整数倍,对于字节数很小的请求如果也使用mmap的话,无疑是会浪费大量空间的。
8. 运行库
入口函数和程序初始化
操作系统装载程序之后,首先运行的代码并不是main
的第一行,而是某些别的代码,这些代码负责准备好main
函数执行所需要的环境,并负责调用main
函数。
运行这些代码的函数称为入口函数,视平台的不同而有不同的名字。程序的入口点实际上是一个程序的初始化和结束部分 ,它往往是运行库的一部分。一个典型的程序运行步骤大致如下:
- 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。
- 入口函数对运行库和程序运行环境进行初始化,包括堆、IO、线程、全局变量构造等等。
- 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。
- main函数执行完毕之后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭IO等,然后进行操作系统调用结束进程。
运行库和IO
在Linux中,值为0、1、2的File Descriptor分别代表标准输入、标准输出和标准错误输出。在程序中打开文件得到的fd从3开始增长。fd具体是什么呢?在内核中,每个进程都有一个私有的“打开文件表”,这个表是一个指针数组,每一个元素都指向一个内核的打开文件对象。而fd就是这个表的下标。当用户打开一个文件时,内核会在内部生成一个打开文件的对象,并在这个表里找到一个空项,让这一项指向生成的打开文件对象,并返回这一项的下标作为fd。
IO初始化函数需要在用户空间中建立stdin
、stdout
、stderr
及其对应的FILE
结构,使得程序进入main
之后可以直接使用printf
、scanf
等函数。
C/C++运行库
一个C语言运行库大致包含了如下功能:
- 启动与退出:包括入口函数以及入口函数所依赖的其他函数等。
- 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现,
printf
、exit
等。 - IO:IO功能封装和实现。
- 堆:堆的封装和实现。
- 语言实现:语言中一些特殊功能的实现。
- 调试:实现调试功能的代码。
C++全局构造和析构
对于每个编译单元,GCC编译器会遍历其中所有的全局对象,生成一个特殊的函数,这个特殊的函数作用是对本编译单元里的所有全局对象进行初始化。一旦一个目标文件里有这样的函数,编译器会在这个编译单元产生的目标文件的.ctors
段里放置一个指针,指向这个函数。
当编译器为每一个编译单元生成一份特殊函数之后,链接器在连接这些目标文件时,会将同名的段合并在一起,这样每个目标文件的.ctors
段将会被合并成为一个.ctors
段,其中的内容是各个目标文件的.ctors
段的内容拼接而成。由于每个目标文件的.ctors
段都只储存了一个指针,因此拼接起来的.ctors
段就成为了一个函数指针数组,每个元素都指向了一个目标文件的全局构造函数。在入口函数中,这个函数指针数组会被依次调用,完成全局对象的构造。
在生成的全局构造函数中,会进行全局对象的初始化,同时注册一个全局对象的析构函数。保证在main
函数退出之后,调用了构造函数的对象会被析构。
9. 系统调用与API
系统调用是应用程序与操作系统内核之间的接口,它决定了应用程序是如何与内核打交道的。
为了让应用程序有能力访问系统资源,也为了程序借助操作系统做一些必须由操作系统支持的行为,每个操作系统都会提供一套接口,以供应用程序使用。这些接口往往通过中断来实现,比如Linux使用0x80号中断作为系统调用的接口,Windows使用0x2E号中断作为操作系统调用入口。
Linux系统调用
在x86下,系统调用由0x80号中断完成,各个通用寄存器用于传递参数,EAX
寄存器用于表示系统调用的接口号,例如EAX=1
表示退出进程;EAX=2
表示创建进程;EAX=3
表示读取文件或IO;EAX=4
表示写文件或IO等,每个系统调用都对应于内核源代码中的一个函数,它们都以sys_
开头,比如exit
调用对应内核中的sys_exit
函数,当系统调用返回时,EAX又作为调用结果的返回值。
系统调用原理
系统调用是运行在内核态的,而应用程序基本都是运行在用户态的。操作系统一般通过中断来从用户态切换到内核态。
中断一般具有两个属性,一个称为中断号,一个称为中断处理程序。在内核中,有一个数组称为中断向量表,这个数组的第n项包含了指向第n号中断的中断处理程序的指针。当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU会继续执行之前的代码。
在Linux下采用INT 0x80
来触发所有的系统调用。
在触发中断后,系统会切换到内核态,然后CPU会找到中断向量表中的第0x80号元素。在实际执行中断处理程序之前,CPU首先还要进行栈的切换。在Linux中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。但是在应用程序调用0x80号中断时,程序的执行流程从用户态切换到内核态,这时程序的当前栈必须也相应地从用户栈切换到内核栈。从中断处理函数返回时,程序的当前栈还要从内核栈切换回用户栈。进入到内核栈之后,就可以开始执行中断处理函数了。