链接,装载与库

Any problem in computer science can be solved by another layer of indirection.
“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”

人们总是希望计算机越来越快,这是毫无疑问的。在过去的50年里,CPU的频率从几十KHz到现在的4GHz,整整提高了数十万倍,基本上每18个月频率就会翻倍。但是自2004年以来,这种规律似乎已经失效,CPU的频率自从那时开始再也没有发生质的提高。原因是人们在制造CPU的工艺方面已经达到了物理极限,除非CPU制造工艺有本质的突破,否则CPU的频率将会一直被目前4GHz的“天花板”所限制。
于是处理器的厂商开始考虑将多个处理器“合并在一起打包出售”,这些“被打包”的处理器之间共享比较昂贵的缓存部件,只保留多个核心,并且以一个处理器的外包装进行出售,售价比单核心的处理器只贵了一点,这就是多核处理器(Multi-core Processor)的基本想法。

操作系统做什么

操作系统作为硬件层的上层,它是对硬件的管理和抽象。对于操作系统上面的运行库和应用程序来说,它们希望看到的是一个统一的硬件访问模式。作为应用程序的开发者,我们不希望在开发应用程序的时候直接读写硬件端口、处理硬件中断等这些繁琐的事情。由于硬件之间千差万别,它们的操作方式和访问方式都有区别。比如我们希望在显示器上画一条直线,对于程序员来说,最好的方式是不管计算机使用什么显卡、什么显示器,多少大小多少分辨率,我们都只要调用一个统一的LineTo()函数,具体的实现方式由操作系统来完成。

操作系统的一个功能是提供抽象的接口,另外一个主要功能是管理硬件资源。

硬件是接口的定义者,硬件的接口定义决定了操作系统内核,具体来讲就是驱动程序如何操作硬件,如何与硬件进行通信。这种接口往往被叫做硬件规格(Hardware Specification),硬件的生产厂商负责提供硬件规格,操作系统和驱动程序的开发者通过阅读硬件规格文档所规定的各种硬件编程接口标准来编写操作系统和驱动程序。

多任务(Multi-tasking)系统。操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程(Process)的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会得到CPU,但是,如果运行时间超出了一定的时间,操作系统会暂停该进程,将CPU资源分配给其他等待运行的进程。这种CPU的分配方式即所谓的抢占式(Preemptive),操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程。如果操作系统分配给每个进程的时间都很短,即CPU在多个进程间快速地切换,从而造成了很多进程都在同时运行的假象。

文件系统保存了这些文件的存储结构,负责维护这些数据结构并且保证磁盘中的扇区能够有效地组织和利用。那么当我们在Linux操作系统中,要读取这个文件的前4 096个字节时,我们会使用一个read的系统调用来实现。文件系统收到read请求之后,判断出文件的前4 096个字节位于磁盘的1000号逻辑扇区到1007号逻辑扇区。然后文件系统就向硬盘驱动发出一个读取逻辑扇区为1000号开始的8个扇区的请求,磁盘驱动程序收到这个请求以后就向硬盘发出硬件命令。

现代的硬盘普遍使用一种叫做LBA(Logical Block Address)的方式,即整个硬盘中所有的扇区从0开始编号,一直到最后一个扇区,这个扇区编号叫做逻辑扇区号。逻辑扇区号抛弃了所有复杂的磁道、盘面之类的概念。当我们给出一个逻辑的扇区号时,硬盘的电子设备会将其转换成实际的盘面、磁道等这些位置。

内存不够怎么办

我们可以用的一个办法是将其他程序的数据暂时写到磁盘里面,等到需要用到的时候再读回来。

我们把程序给出的地址看作是一种虚拟地址(Virtual Address),然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。这样,只要我们能够妥善地控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程序所能够访问的物理内存区域跟另外一个程序相互不重叠,以达到地址空间隔离的效果。

在页映射模式下,CPU发出的是Virtual Address,即我们的程序看到的是虚拟地址。经过MMU转换以后就变成了Physical Address。一般MMU都集成在CPU内部了,不会以独立的部件存在。
进程虚拟空间、物理空间和磁盘之间的页映射关系

虚拟地址到物理地址的转换

线程

当线程数量小于等于处理器数量时(并且操作系统支持多处理器),线程的并发是真正的并发,不同的线程运行在不同的处理器上,彼此之间互不相干。但对于线程数量大于处理器数量的情况,线程的并发会受到一些阻碍,因为此时至少有一个处理器会运行多个线程。
在单处理器对应多线程的情况下,并发是一种模拟出来的状态。操作系统会让这些多线程程序轮流执行,每次仅执行一小段时间(通常是几十到几百毫秒),这样每个线程就“看起来”在同时执行。这样的一个不断在处理器上切换不同的线程的行为称之为线程调度(Thread Schedule)。在线程调度中,线程通常拥有至少三种状态,分别是:

  • 运行(Running):此时线程正在执行。
  • 就绪(Ready):此时线程可以立刻运行,但CPU已经被占用。
  • 等待(Waiting):此时线程正在等待某一事件(通常是I/O或同步)发生,无法执行。
    处于运行中线程拥有一段可以执行的时间,这段时间称为时间片(Time Slice),当时间片用尽的时候,该进程将进入就绪状态。如果在时间片用尽之前进程就开始等待某事件,那么它将进入等待状态。每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪状态。

线程调度自多任务操作系统问世以来就不断地被提出不同的方案和算法。现在主流的调度方式尽管各不相同,但都带有优先级调度(Priority Schedule)轮转法(Round Robin)的痕迹。所谓轮转法,即是之前提到的让各个线程轮流执行一小段时间的方法。这决定了线程之间交错执行的特点。而优先级调度则决定了线程按照什么顺序轮流执行。在具有优先级调度的系统中,线程都拥有各自的线程优先级(Thread Priority)。具有高优先级的线程会更早地执行,而低优先级的线程常常要等待到系统中已经没有高优先级的可执行的线程存在时才能够执行。

我们一般把频繁等待的线程称之为IO密集型线程(IO Bound Thread),而把很少等待的线程称为CPU密集型线程(CPU Bound Thread)。IO密集型线程总是比CPU密集型线程容易得到优先级的提升。在优先级调度下,存在一种饿死(Starvation)的现象,一个线程被饿死,是说它的优先级较低,在它执行之前,总是有较高优先级的线程试图执行,因此这个低优先级线程始终无法执行。当一个CPU密集型的线程获得较高的优先级时,许多低优先级的进程就很可能饿死。而一个高优先级的IO密集型线程由于大部分时间都处于等待状态,因此相对不容易造成其他线程饿死。为了避免饿死现象,调度系统常常会逐步提升那些等待了过长时间的得不到执行的线程的优先级。在这样的手段下,一个线程只要等待足够长的时间,其优先级一定会提高到足够让它执行的程度。

让我们总结一下,在优先级调度的环境下,线程的优先级改变一般有三种方式。
用户指定优先级。
根据进入等待状态的频繁程度提升或降低优先级。
长时间得不到执行而被提升优先级。
可抢占线程和不可抢占线程
我们之前讨论的线程调度有一个特点,那就是线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做抢占(Preemption),即之后执行的别的线程抢占了当前线程。在早期的一些系统(例如Windows 3.1)里,线程是不可抢占的。线程必须手动发出一个放弃执行的命令,才能让其他的线程得到执行。在这样的调度模型下,线程必须主动进入就绪状态,而不是靠时间片用尽来被强制进入。如果线程始终拒绝进入就绪状态,并且也不进行任何的等待操作,那么其他的线程将永远无法执行。

Linux对多线程的支持颇为贫乏,事实上,在Linux内核中并不存在真正意义上的线程概念。Linux将所有的执行实体(无论是线程还是进程)都称为任务(Task),每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。不过,Linux下不同的任务之间可以选择共享内存空间,因而在实际意义上,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就成了这个进程里的线程。在Linux下,用以下方法可以创建一个新的任务,如表1-2所示。

系统调用 作用
fork 复制当前进程
exec 使用新的可执行映像覆盖当前可执行映像
clone 创建子进程并从指定位置开始执行

我们把单指令的操作称为原子的(Atomic),因为无论如何,单条指令的执行是不会被打断的。

为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们需要将各个线程对同一个数据的访问同步(Synchronization)。所谓同步,既是指在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。如此,对数据的访问被原子化了。

同步的最常见方法是使用锁(Lock)。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图获取(Acquire)锁,并在访问结束之后释放(Release)锁。在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用。

二元信号量(Binary Semaphore)是最简单的一种锁,它只有两种状态:占用与非占用。它适合只能被唯一一个线程独占访问的资源。当二元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二元信号量的线程将会等待,直到该锁被释放。

对于允许多个线程并发访问的资源,多元信号量简称信号量(Semaphore),它是一个很好的选择。一个初始值为N的信号量允许N个线程并发访问。线程访问资源的时候首先获取信号量,进行如下操作:

  • 将信号量的值减1。
  • 如果信号量的值小于0,则进入等待状态,否则继续执行。

访问完资源之后,线程释放信号量,进行如下操作:

  • 将信号量的值加1。
  • 如果信号量的值小于1,唤醒一个等待中的线程。

互斥量(Mutex)和二元信号量很类似,资源仅同时允许一个线程访问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁,其他线程越俎代庖去释放互斥量是无效的。

临界区(Critical Section)是比互斥量更加严格的同步手段。在术语中,把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界区。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取该锁是合法的。然而,临界区的作用范围仅限于本进程,其他的进程无法获取该锁。除此之外,临界区具有和互斥量相同的性质。

读写锁(Read-Write Lock)致力于一种更加特定的场合的同步。对于一段数据,多个线程同时读取总是没有问题的,但假设操作都不是原子型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。如果我们使用上述信号量、互斥量或临界区中的任何一种来进行同步,尽管可以保证程序正确,但对于读取频繁,而仅仅偶尔写入的情况,会显得非常低效。读写锁可以避免这个问题。对于同一个锁,读写锁有两种获取方式,共享的(Shared)或独占的(Exclusive)。当锁处于自由的状态时,试图以任何一种方式获取锁都能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共享的方式获取锁仍然会成功,此时这个锁分配给了多个线程。然而,如果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它将必须等待锁被所有的线程释放。相应地,处于独占状态的锁将阻止任何其他线程获取该锁,不论它们试图以哪种方式获取。

条件变量(Condition Variable)作为一种同步手段,作用类似于一个栅栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。也就是说,使用条件变量可以让许多线程一起等待某个事件的发生,当事件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。

线程的并发执行是由多处理器或操作系统调度来实现的。但实际情况要更为复杂一些:大多数操作系统,包括Windows和Linux,都在内核里提供线程的支持,内核线程(注:这里的内核线程和Linux内核里的kernel_thread并不是一回事)和我们之前讨论的一样,由多处理器或调度来实现并发。然而用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程。用户态线程并不一定在操作系统内核里对应同等数量的内核线程,例如某些轻量级的线程库,对用户来说如果有三个线程在同时执行,对内核来说很可能只有一个线程。本节我们将详细介绍用户态多线程库的实现方式。
1. 一对一模型
对于直接支持线程的系统,一对一模型始终是最为简单的模型。对一对一模型来说,一个用户使用的线程就唯一对应一个内核使用的线程(但反过来不一定,一个内核里的线程在用户态不一定有对应的线程存在),这样用户线程就具有了和内核线程一致的优点,线程之间的并发是真正的并发,一个线程因为某原因阻塞时,其他线程执行不会受到影响。此外,一对一模型也可以让多线程程序在多处理器的系统上有更好的表现。
一般直接使用API或系统调用创建的线程均为一对一的线程。例如在Linux里使用clone(带有CLONE_VM参数)产生的线程就是一个一对一线程,因为此时在内核有一个唯一的线程与之对应。
一对一线程缺点有两个:

  • 由于许多操作系统限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制。
  • 许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降。
    2. 多对一模型
    多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行,因此相对于一对一模型,多对一模型的线程切换要快速许多。多对一模型一大问题是,如果其中一个用户线程阻塞,那么所有的线程都将无法执行,因为此时内核里的线程也随之阻塞了。另外,在多处理器系统上,处理器的增多对多对一模型的线程性能也不会有明显的帮助。但同时,多对一模型得到的好处是高效的上下文切换和几乎无限制的线程数量。
    3. 多对多模型
    多对多模型结合了多对一模型和一对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上,在多对多模型中,一个用户线程阻塞并不会使得所有的用户线程阻塞,因为此时还有别的线程可以被调度来执行。另外,多对多模型对用户线程的数量也没什么限制,在多处理器系统上,多对多模型的线程也能得到一定的性能提升,不过提升的幅度不如一对一模型高。

编译和链接

通常将这种编译和链接合并到一起的过程称为构建(Build)。即使使用命令行来编译一个源代码文件,简单的一句“gcc hello.c”命令就包含了非常复杂的过程。
GCC编译过程分解

预编译

首先是源代码文件hello.c和相关的头文件,如stdio.h等被预编译器cpp预编译成一个.i文件。对于C++程序来说,它的源代码文件的扩展名可能是.cpp或.cxx,头文件的扩展名可能是.hpp,而预编译后的文件扩展名是.ii。预编译过程主要处理那些源代码文件中的以“#”开始的预编译指令。比如“#include”、“#define”等。
经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问题。

编译

编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。
现在版本的GCC把预编译和编译两个步骤合并成一个步骤,使用一个叫做cc1的程序来完成这两个步骤。这个程序位于“/usr/lib/gcc/i486-linux-gnu/4.1/”
可以得到汇编输出文件hello.s。

汇编

汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来源于此。

链接

链接通常是一个让人比较费解的过程,为什么汇编器不直接输出可执行文件而是输出一个目标文件呢?链接过程到底包含了什么内容?为什么要链接?
编译过程一般可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。

词法分析
首先源代码程序被输入到扫描器(Scanner),扫描器的任务很简单,它只是简单地进行词法分析,运用一种类似于有限状态机(Finite State Machine)的算法可以很轻松地将源代码的字符序列分割成一系列的记号(Token)

语法分析
接下来语法分析器(Grammar Parser)将对由扫描器产生的记号进行语法分析,从而产生语法树(Syntax Tree)。整个分析过程采用了上下文无关语法(Context-free Grammar)的分析手段。
简单地讲,由语法分析器生成的语法树就是以表达式(Expression)为节点的树。我们知道,C语言的一个语句是一个表达式,而复杂的语句是很多表达式的组合。上面例子中的语句就是一个由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组成的复杂语句。
对于不同的编程语言,编译器的开发者只须改变语法规则,而无须为每个编译器编写一个语法分析器,所以它又被称为“编译器编译器(Compiler Compiler)”。

语义分析
接下来进行的是语义分析,由语义分析器(Semantic Analyzer)来完成。
编译器所能分析的语义是静态语义(Static Semantic),所谓静态语义是指在编译期可以确定的语义,与之对应的动态语义(Dynamic Semantic)就是只有在运行期才能确定的语义。
比如当一个浮点型的表达式赋值给一个整型的表达式时,其中隐含了一个浮点型到整型转换的过程,语义分析过程中需要完成这个步骤。比如将一个浮点型赋值给一个指针的时候,语义分析程序会发现这个类型不匹配,编译器将会报错。动态语义一般指在运行期出现的语义相关的问题,比如将0作为除数是一个运行期语义错误。

中间语言生成
现代的编译器有着很多层次的优化,往往在源代码级别会有一个优化过程。我们这里所描述的源码级优化器(Source Code Optimizer)在不同编译器中可能会有不同的定义或有一些其他的差异。源代码级优化器会在源代码级别进行优化,在上例中,细心的读者可能已经发现,(2 + 6)这个表达式可以被优化掉,因为它的值在编译期就可以被确定。
我们看到(2 + 6)这个表达式被优化成8。其实直接在语法树上作优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码(Intermediate Code),它是语法树的顺序表示,其实它已经非常接近目标代码了。但是它一般跟目标机器和运行时环境是无关的,比如它不包含数据的尺寸、变量地址和寄存器的名字等。中间代码有很多种类型,在不同的编译器中有着不同的形式,比较常见的有:三地址码(Three-address Code)P-代码(P-Code)
中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个前端和针对不同机器平台的数个后端。

目标代码生成与优化
源代码级优化器产生中间代码标志着下面的过程都属于编辑器后端。编译器后端主要包括代码生成器(Code Generator)目标代码优化器(Target Code Optimizer)。
让我们先来看看代码生成器。代码生成器将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等。

链接器
模块之间如何组合的问题可以归结为模块之间如何通信的问题,最常见的属于静态语言的C/C++模块之间通信有两种方式,一种是模块间的函数调用,另外一种是模块间的变量访问。函数访问须知道目标函数的地址,变量访问也须知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是模块间符号的引用。模块间依靠符号来通信类似于拼图版,定义符号的模块多出一块区域,引用该符号的模块刚好少了那一块区域,两者一拼接刚好完美组合(见图2-7)。这个模块的拼接过程就是本书的一个主题:链接(Linking)。
模块间拼合

程序设计的模块化是人们一直在追求的目标,因为当一个系统十分复杂的时候,我们不得不将一个复杂的系统逐步分割成小的系统以达到各个突破的目的。一个复杂的软件也如此,人们把每个源代码模块独立地编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接(Linking)。链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。

最基本的静态链接过程如图2-8所示。每个模块的源代码文件(如.c)文件经过编译器编译成目标文件(Object File,一般扩展名为.o或.obj),目标文件和库(Library)一起链接最基本的静态链接过程如图2-8所示。每个模块的源代码文件(如.c)文件经过编译器编译成目标文件(Object File,一般扩展名为.o或.obj),目标文件和库(Library)一起链接。
链接过程

目标文件

目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整。其实它本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构上稍有不同。
现在PC平台流行的可执行文件格式(Executable)主要是Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkable Format),它们都是COFF(Common file format)格式的变种。目标文件就是源代码编译后但未进行链接的那些中间文件(Windows的.obj和Linux下的.o),它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储。从广义上看,目标文件与可执行文件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行文件看成是一种类型的文件,在Windows下,我们可以统称它们为PE-COFF文件格式。在Linux下,我们可以将它们统称为ELF文件。其他不太常见的可执行文件格式还有Intel/Microsoft的OMF(Object Module Format)、Unix a.out格式和MS-DOS .COM格式等。

Unix最早的可执行文件格式为a.out格式,它的设计非常地简单,以至于后来共享库这个概念出现的时候,a.out格式就变得捉襟见肘了。于是人们设计了COFF格式来解决这些问题,这个设计非常通用,以至于COFF的继承者到目前还在被广泛地使用。
COFF是由Unix System V Release 3首先提出并且使用的格式规范,后来微软公司基于COFF格式,制定了PE格式标准,并将其用于当时的Windows NT系统。System V Release 4在COFF的基础上引入了ELF格式,目前流行的Linux系统也以ELF作为基本可执行文件格式。这也就是为什么目前PE和ELF如此相似的主要原因,因为它们都是源于同一种可执行文件格式COFF。

.bss段存放的是未初始化的全局变量和局部静态变量。

函数签名(Function Signature)名包含了一个函数的信息,包括函数名、它的参数类型、它所在的类和名称空间及其他信息。函数签名用于识别不同的函数,就像签名用于识别不同的人一样,函数的名字只是函数签名的一部分。

extern “C”
C++为了与C兼容,在符号的管理上,C++有一个用来声明或定义一个C的符号的“extern “C””关键字用法:

extern ”C” {
    int func(int);
    int var;
}

C++编译器会将在extern “C” 的大括号内部的代码当作C语言代码处理。
所以很明显,上面的代码中,C++的名称修饰机制将不会起作用。它声明了一个C的函数func,定义了一个整形全局变量var。
从上文我们得知,在Visual C++平台下会将C语言的符号进行修饰,所以上述代码中的func和var的修饰后符号分别是_func和_var;但是在Linux版本的GCC编译器下却没有这种修饰,extern “C”里面的符号都为修饰后符号,即前面不用加下划线。
如果单独声明某个函数或变量为C语言的符号,那么也可以使用如下格式:

extern "C" int func(int);
extern "C" int var;

很多时候我们会碰到有些头文件声明了一些C语言的函数和全局变量,但是这个头文件可能会被C语言代码或C++代码包含。比如很常见的,我们的C语言库函数中的string.h中声明了memset这个函数,它的原型如下:

void *memset (void *, int, size_t);

如果不加任何处理,当我们的C语言程序包含string.h的时候,并且用到了memset这个函数,编译器会将memset符号引用正确处理;但是在C++语言中,编译器会认为这个memset函数是一个C++函数,将memset的符号修饰成_Z6memsetPvii,这样链接器就无法与C语言库中的memset符号进行链接。所以对于C++来说,必须使用extern “C”来声明memset这个函数。但是C语言又不支持extern “C”语法,如果为了兼容C语言和C++语言定义两套头文件,未免过于麻烦。幸好我们有一种很好的方法可以解决上述问题,就是使用C++的宏“__cplusplus”,C++编译器会在编译C++的程序时默认定义这个宏,我们可以使用条件宏来判断当前编译单元是不是C++代码。具体代码如下:

#ifdef __cplusplus
extern "C" {
#endif
 
void *memset (void *, int, size_t);

#ifdef __cplusplus
}
#endif

如果当前编译单元是C++代码,那么memset会在extern “C”里面被声明;如果是C代码,就直接声明。上面这段代码中的技巧几乎在所有的系统头文件里面都被用到。

目标文件里面还有可能保存的是调试信息。值得一提的是,调试信息在目标文件和可执行文件中占用很大的空间,往往比程序的代码和数据本身大好几倍,所以当我们开发完程序并要将它发布的时候,须要把这些对于用户没有用的调试信息去掉,以节省大量的空间。

ELF文件使用段文件存储方式:

  • .text肯定保存的是程序的指令,它是只读的。
  • .rodata保存的是字符串“Hello World!\n”,它也是只读的。
  • .data保存的是str全局变量,看上去它是可读写的,但我们并没有在程序中改写该变量,所以实际上它也是只读的。
  • .comment保存的是编译器和系统版本信息,这些信息也是只读的。由于.comment里面保存的数据并不关键,对于程序的运行没有作用,所以可以将其丢弃。

静态链接

对于链接器来说,整个链接过程中,它就是将几个输入目标文件加工后合并成一个输出文件。那么在这个例子里,我们的输入就是目标文件“a.o”和“b.o”,输出就是可执行文件“ab”。

两步链接(Two-pass Linking):
第一步 空间与地址分配 扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。
第二步 符号解析与重定位 使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。事实上第二步是链接过程的核心,特别是重定位过程。

在我们通常的观念里,之所以要链接是因为我们目标文件中用到的符号被定义在其他目标文件,所以要将它们链接起来。比如我们直接使用ld来链接“a.o”,而不将“b.o”作为输入。链接器就会发现shared和swap两个符号没有被定义,没有办法完成链接工作。这也是我们平时在编写程序的时候最常碰到的问题之一,就是链接时符号未定义。导致这个问题的原因很多,最常见的一般都是链接时缺少了某个库,或者输入目标文件路径不正确或符号的声明与定义不一样。所以从普通程序员的角度看,符号的解析占据了链接过程的主要内容。

我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)
API往往是指源代码级别的接口,比如我们可以说POSIX是一个API标准、Windows所规定的应用程序接口是一个API;而ABI是指二进制层面的接口,ABI的兼容程度比API要更为严格,比如我们可以说C++的对象内存分布(Object Memory Layout)是C++ ABI的一部分。

C++一直为人诟病的一大原因是它的二进制兼容性不好,或者说比起C语言来更为不易。不仅不同的编译器编译的二进制代码之间无法相互兼容,有时候连同一个编译器的不同版本之间兼容性也不好。比如我有一个库A是公司Company A用Compiler A编译的,我有另外一个库B是公司Company B用Compiler B编译的,当我想写一个C++程序来同时使用库A和B将会很是棘手。有人说,那么我每次只要用同一个编译器编译所有的源代码就能解决问题了。不错,对于小型项目来说这个方法的确可行,但是考虑到一些大型的项目,以上的方法实际上并不可行。

很多时候,库厂商往往不希望库用户看到库的源代码,所以一般是以二进制的方式提供给用户。这样,当用户的编译器型号与版本与编译库所用的编译器型号和版本不同时,就可能产生不兼容。如果让库的厂商提供所有的编译器型号和版本编译出来的库给用户,这基本上不现实,特别是厂商对库已经停止了维护后,使用这样陈年老“库”实在是一件令人头痛的事。以上的情况对于系统中已经存在的静态库或动态库须要被多个应用程序使用的情况也几乎相同,或者一个程序由多个公司或多个部门一起开发,也有类似的问题。
所以人们一直期待着能有统一的C++二进制兼容标准(C++ ABI),诸多的团体和社区都在致力于C++ ABI标准的统一。但是目前情况还是不容乐观,基本形成以微软的VISUAL C++和GNU阵营的GCC(采用Intel Itanium C++ ABI标准)为首的两大派系,各持己见互不兼容。早先时候,*NIX系统下的ABI也十分混乱,这个情况一直延续到LSB(Linux Standard Base)和Intel的Itanium C++ ABI标准出来后才有所改善,但并未彻底解决ABI的问题,由于现实的因素,这个问题还会长期地存在。

在一般的情况下,一种语言的开发环境往往会附带有语言库(Language Library)。这些库就是对操作系统的API的包装,比如我们经典的C语言版“Hello World”程序,它使用C语言标准库的“printf”函数来输出一个字符串,“printf”函数对字符串进行一些必要的处理以后,最后会调用操作系统提供的API。各个操作系统下,往终端输出字符串的API都不一样,在Linux下,它是一个“write”的系统调用,而在Windows下它是“WriteConsole”系统API。

其实一个静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。比如我们在Linux中最常用的C语言静态库libc位于/usr/lib/libc.a,它属于glibc项目的一部分;像Windows这样的平台上,最常使用的C语言库是由集成开发环境所附带的运行库,这些库一般由编译器厂商提供,比如Visual C++附带了多个版本的C/C++运行库。

我们知道在一个C语言的运行库中,包含了很多跟系统功能相关的代码,比如输入输出、文件操作、时间日期、内存管理等。glibc本身是用C语言开发的,它由成百上千个C语言源代码文件组成,也就是说,编译完成以后有相同数量的目标文件,比如输入输出有printf.o,scanf.o;文件操作有fread.o,fwrite.o;时间日期有date.o,time.o;内存管理有malloc.o等。把这些零散的目标文件直接提供给库的使用者,很大程度上会造成文件传输、管理和组织方面的不便,于是通常人们使用“ar”压缩程序将这些目标文件压缩到一起,并且对其进行编号和索引,以便于查找和检索,就形成了libc.a这个静态库文件。
我们也可以使用“ar”工具来查看这个文件包含了1 400个目标文件。

Q:为什么静态运行库里面一个目标文件只包含一个函数?比如libc.a里面printf.o只有printf()函数、strlen.o只有strlen()函数,为什么要这样组织?

A:我们知道,链接器在链接静态库的时候是以目标文件为单位的。比如我们引用了静态库中的printf()函数,那么链接器就会把库中包含printf()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中。由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那些没有被用到的目标文件(函数)就不要链接到最终的输出文件中。

BFD库:
由于现代的硬件和软件平台种类非常繁多,它们之间千差万别,比如,硬件中CPU有8位的、16位的,一直到64位的;字节序有大端的也有小端的;有些有MMU有些没有;有些对访问内存地址对齐有着特殊要求,比如MIPS,而有些则没有,比如x86。软件平台有些支持动态链接,而有些不支持;有些支持调试,有些又不支持。这些五花八门的软硬件平台基础导致了每个平台都有它独特的目标文件格式,即使同一个格式比如ELF在不同的软硬件平台都有着不同的变种。种种差异导致编译器和链接器很难处理不同平台之间的目标文件,特别是对于像GCC和binutils这种跨平台的工具来说,最好有一种统一的接口来处理这些不同格式之间的差异。
BFD库(Binary File Descriptor library)就是这样的一个GNU项目,它的目标就是希望通过一种统一的接口来处理不同的目标文件格式。BFD这个项目本身是binutils项目的一个子项目。BFD把目标文件抽象成一个统一的模型,比如在这个抽象的目标文件模型中,最开始有一个描述整个目标文件总体信息的“文件头”,就跟我们实际的ELF文件一样,文件头后面是一系列的段,每个段都有名字、属性和段的内容,同时还抽象了符号表、重定位表、字符串表等类似的概念,使得BFD库的程序只要通过操作这个抽象的目标文件模型就可以实现操作所有BFD支持的目标文件格式。
现在GCC(更具体地讲是GNU 汇编器GAS, GNU Assembler)、链接器ld、调试器GDB及binutils的其他工具都通过BFD库来处理目标文件,而不是直接操作目标文件。这样做最大的好处是将编译器和链接器本身同具体的目标文件格式隔离开来,一旦我们须要支持一种新的目标文件格式,只须要在BFD库里面添加一种格式就可以了,而不须要修改编译器和链接器。到目前为止,BFD库支持大约25种处理器平台,将近50种目标文件格式。
当我们安装了BFD开发库以后(在我的ubuntu下,包含BFD开发库的软件包的名字叫binutils-dev),我们就可以在程序中使用它。比如下面这段程序可以输出该BFD库所支持的所有的目标文件格式:

/* target.c */
#include <stdio.h>
#include "bfd.h"

int main()
{
    const char** t = bfd_target_list();
    while(*t) {
        printf("%s\n", *t);
        t++;
    }
} 

编译运行:

gcc -o target target.c –lbfd
./target
elf32-i386
a.out-i386-linux
efi-app-ia32
elf32-little
elf32-big
elf64-x86-64
efi-app-x86_64
elf64-little
elf64-big
srec
symbolsrec
tekhex
binary
ihex
trad-core

Windows PE/COFF

在32位Windows平台下,微软引入了一种叫PE(Protable Executable)的可执行格式。作为Win32平台的标准可执行文件格式,PE有着跟ELF一样良好的平台扩展性和灵活性。PE文件格式事实上与ELF同根同源,它们都是由COFF(Common Object File Format)格式发展而来的,更加具体地讲是来源于当时著名的DEC(Digital Equipment Corporation)的VAX/VMS上的COFF文件格式。因为当微软开始开发Windows NT的时候,最初的成员都是来自于DEC公司的VAX/VMS小组,所以他们很自然就将原来系统上熟悉的工具和文件格式都搬了过来,并且在此基础上做重新设计和改动。
微软将它的可执行文件格式命名为“Portable Executable”,从字面意义上讲是希望这个可执行文件格式能够在不同版本的Windows平台上使用,并且可以支持各种CPU。比如从Windows NT、Windows 95到Windows XP及Windows Vista,还有Windows CE都是使用PE可执行文件格式。不过可惜的是Windows的PC版只支持x86的CPU,所以我们几乎只要关注PE在x86上的各种性质就行了。
上面在讲到PE文件格式的时候,只是说Windows平台下的可执行文件采用该格式。事实上,在Windows平台,VISUAL C++编译器产生的目标文件仍然使用COFF格式。由于PE是COFF的一种扩展,所以它们的结构在很大程度上相同,甚至跟ELF文件的基本结构也相同,都是基于段的结构。所以我们下面在讨论Windows平台上的文件结构时,目标文件默认为COFF格式,而可执行文件为PE格式。但很多时候我们可以将它们统称为PE/COFF文件,当然我们在下文中也会对比PE与COFF在结构方面的区别之处。
随着64位Windows的发布,微软对64位Windows平台上的PE文件结构稍微做了一些修改,这个新的文件格式叫做PE32+。新的PE32+并没有添加任何结构,最大的变化就是把那些原来32位的字段变成了64位,比如文件头中与地址相关的字段。绝大部分情况下,PE32+与PE的格式一致,我们可以将它看作是一般的PE文件。
与ELF文件相同,PE/COFF格式也是采用了那种基于段的格式。一个段可以包含代码、数据或其他信息,在PE/COFF文件中,至少包含一个代码段,这个代码段的名字往往叫做“.code”,数据段叫做“.data”。不同的编译器产生的目标文件的段名不同,VISUAL C++使用“.code”和“.data”,而Borland的编译器使用“CODE”,“DATA”。也就是说跟ELF一样,段名只有提示性作用,并没有实际意义。当然,如果使用链接脚本来控制链接,段名可能会起到一定的作用。
跟ELF一样,PE中也允许程序员将变量或函数放到自定义的段。在GCC中我们使用“attribute((section(“name”)))”扩展属性,在VISUAL C++中可以使用“#pragma”编译器指示。比如下面这个语句:

#pragma data_seg("FOO")
int global = 1;
#pragma data_seg(".data")

就表示把所有全局变量“global”放到“FOO”段里面去,然后再使用“#pragram”将这个编译器指示换回来,恢复到“.data”,否则,任何全局变量和静态变量都会被放到“FOO”段。

可执行文件的装载与进程

程序和进程有什么区别

程序(或者狭义上讲可执行文件)是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件;进程则是一个动态的概念,它是程序运行时的一个过程,很多时候把动态库叫做运行时(Runtime)也有一定的含义。有人做过一个很有意思的比喻,说把程序和进程的概念跟做菜相比较的话,那么程序就是菜谱,计算机的CPU就是人,相关的厨具则是计算机的其他硬件,整个炒菜的过程就是一个进程。计算机按照程序的指示把输入数据加工成输出数据,就好像菜谱指导着人把原料做成美味可口的菜肴。从这个比喻中我们还可以扩大到更大范围,比如一个程序能在两个CPU上执行等。
我们知道每个程序被运行起来以后,它将拥有自己独立的虚拟地址空间(Virtual Address Space),这个虚拟地址空间的大小由计算机的硬件平台决定,具体地说是由CPU的位数决定的。硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小,比如32位的硬件平台决定了虚拟地址空间的地址为 0 到 2^32-1,即0x00000000~0xFFFFFFFF,也就是我们常说的4 GB虚拟空间大小;而64位的硬件平台具有64位寻址能力,它的虚拟地址空间达到了264字节,即0x0000000000000000~0xFFFFFFFFFFFFFFFF,总共17 179 869 184 GB,这个寻址能力从现在来看,几乎是无限的,但是历史总是会嘲弄人,或许有一天我们会觉得64位的地址空间很小,就像我们现在觉得32位地址不够用一样。当人们第一次推出32位处理器的时候,很多人都在疑惑4 GB这么大的地址空间有什么用。
其实从程序的角度看,我们可以通过判断C语言程序中的指针所占的空间来计算虚拟地址空间的大小。一般来说,C语言指针大小的位数与虚拟空间的位数相同,如32位平台下的指针为32位,即4字节;64位平台下的指针为64位,即8字节。当然有些特殊情况下,这种规则不成立,比如早期的MSC的C语言分长指针、短指针和近指针,这是为了适应当时畸形处理器而设立的,现在基本可以不予考虑。
那么32位平台下的4 GB虚拟空间,我们的程序是否可以任意使用呢?很遗憾,不行。因为程序在运行的时候处于操作系统的监管下,操作系统为了达到监控程序运行等一系列目的,进程的虚拟空间都在操作系统的掌握之中。进程只能使用那些操作系统分配给进程的地址,如果访问未经允许的空间,那么操作系统就会捕获到这些访问,将进程的这种访问当作非法操作,强制结束进程。我们经常在Windows下碰到令人讨厌的“进程因非法操作需要关闭”或Linux下的“Segmentation fault”很多时候是因为进程访问了未经允许的地址。
那么到底这4 GB的进程虚拟地址空间是怎样的分配状态呢?首先以Linux操作系统作为例子,默认情况下,Linux操作系统将进程的虚拟地址空间做了如图6-1所示的分配。
Linux进程虚拟空间分布
整个4 GB被划分成两部分,其中操作系统本身用去了一部分:从地址0xC00000000到0xFFFFFFFF,共1 GB。剩下的从0x00000000地址开始到0xBFFFFFFF共3 GB的空间都是留给进程使用的。那么从原则上讲,我们的进程最多可以使用3 GB的虚拟空间,也就是说整个进程在执行的时候,所有的代码、数据包括通过C语言malloc()等方法申请的虚拟空间之和不可以超过3 GB。在现代的程序中,3 GB的虚拟空间有时候是不够用的,比如一些大型的数据库系统、数值计算、图形图像处理、虚拟现实、游戏等程序需要占用的内存空间较大,这使得32位硬件平台的虚拟地址空间显得捉襟见肘。当然一本万利的方法就是使用64位处理器,把虚拟地址空间扩展到17 179 869 184 GB。当然不是人人都能顺利地更换64位处理器,更何况有很多现有的程序只能运行在32位处理器下。

对于Windows操作系统来说,它的进程虚拟地址空间划分是操作系统占用2 GB,那么进程只剩下2 GB空间。2 GB空间对一些程序来说太小了,所以Windows有个启动参数可以将操作系统占用的虚拟地址空间减少到1 GB,即跟Linux分布一样。方法如下:修改Windows系统盘根目录下的Boot.ini,加上“/3G”参数。

[boot loader]
timeout=30
default=multi(0)disk(0)rdisk(0)partition(1)\WINDOWS
[operating systems]
multi(0)disk(0)rdisk(0)partition(1)\WINDOWS="Microsoft Windows XP Professional" /3G /fastdetect /NoExecute=OptIn
PAE

32位的CPU下,程序使用的空间能不能超过4 GB呢?这个问题其实应该从两个角度来看,首先,问题里面的“空间”如果是指虚拟地址空间,那么答案是“否”。因为32位的CPU只能使用32位的指针,它最大的寻址范围是0 到4 GB;如果问题里面的“空间”指计算机的内存空间,那么答案为“是”。Intel自从1995年的Pentium Pro CPU开始采用了36位的物理地址,也就是可以访问高达64 GB的物理内存。
从硬件层面上来讲,原先的32位地址线只能访问最多4 GB的物理内存。但是自从扩展至36位地址线之后,Intel修改了页映射的方式,使得新的映射方式可以访问到更多的物理内存。Intel 把这个地址扩展方式叫做PAE(Physical Address Extension)
当然扩展的物理地址空间,对于普通应用程序来说正常情况下感觉不到它的存在,因为这主要是操作系统的事,在应用程序里,只有32位的虚拟地址空间。那么应用程序该如何使用这些大于常规的内存空间呢?一个很常见的方法就是操作系统提供一个窗口映射的方法,把这些额外的内存映射到进程地址空间中来。应用程序可以根据需要来选择申请和映射,比如一个应用程序中0x10000000~0x20000000这一段256 MB的虚拟地址空间用来做窗口,程序可以从高于4 GB的物理空间中申请多个大小为256 MB的物理空间,编号成A、B、C等,然后根据需要将这个窗口映射到不同的物理空间块,用到A时将0x10000000~0x20000000映射到A,用到B、C时再映射过去,如此重复操作即可。在Windows下,这种访问内存的操作方式叫做AWE(Address Windowing Extensions);而像Linux等UNIX类操作系统则采用mmap()系统调用来实现。
当然这只是一种补救32位地址空间不够大时的非常规手段,真正的解决方法还是应该使用64位的处理器和操作系统。这不仅使人想起了DOS时代16位地址不够用时,也采用了类似的16位CPU字长,20位地址线长度,系统有着640 KB、1 MB等诸多访问限制。由于很多应用程序须访问超过1 MB的内存,所以当时也有很多类似PAE和AWE的方法,比如当时很著名的XMS(eXtended Memory Specification)
Windows下的PAE和AWE可以使用与/3G相似的启动选项/PAE和/AWE打开。

装载的方式

程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的办法就是将程序运行所需要的指令和数据全都装入内存中,这样程序就可以顺利运行,这就是最简单的静态装入的办法。但是很多情况下程序所需要的内存数量大于物理内存的数量,当内存的数量不够时,根本的解决办法就是添加内存。相对于磁盘来说,内存是昂贵且稀有的,这种情况自计算机磁盘诞生以来一直如此。所以人们想尽各种办法,希望能够在不添加内存的情况下让更多的程序运行起来,尽可能有效地利用内存。后来研究发现,程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。
覆盖装入(Overlay)页映射(Paging)是两种很典型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。
按照2009年2月的数据,以一个普通的希捷7200RPM的桌面PC硬盘为例,它拥有8 MB缓存,500 GB的容量,价格是459元。按照每GB的价格来算,DDR2 667内存每GB约150元,而硬盘每GB的价格不到1元,价格大约是内存的1/200。

1.首先是创建虚拟地址空间。回忆第1章的页映射机制,我们知道一个虚拟空间由一组页映射函数将虚拟空间的各个页映射至相应的物理空间,那么创建一个虚拟空间实际上并不是创建空间而是创建映射函数所需要的相应的数据结构,在i386 的Linux下,创建虚拟地址空间实际上只是分配一个页目录(Page Directory)就可以了,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。
2.读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。上面那一步的页映射关系函数是虚拟空间到物理内存的映射关系,这一步所做的是虚拟空间与可执行文件的映射关系。我们知道,当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该“缺页”从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。但是很明显的一点是,当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这就是虚拟空间与可执行文件之间的映射关系。从某种角度来看,这一步是整个装载过程中最重要的一步,也是传统意义上“装载”的过程。
由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件(Image)
让我们考虑最简单的情况,假设我们的ELF可执行文件只有一个代码段“.text“,它的虚拟地址为0x08048000,它在文件中的大小为0x000e1,对齐为0x1000。由于虚拟存储的页映射都是以页为单位的,在32位的Intel IA32下一般为4 096字节,所以32位ELF的对齐粒度为0x1000。由于该.text段大小不到一个页,考虑到对齐该段占用一个段。所以一旦该可执行文件被装载,可执行文件与执行该可执行文件进程的虚拟空间的映射关系如图所示。
可执行文件与进程虚拟空间
很明显,这种映射关系只是保存在操作系统内部的一个数据结构。Linux中将进程虚拟空间中的一个段叫做虚拟内存区域(VMA, Virtual Memory Area);在Windows中将这个叫做虚拟段(Virtual Section),其实它们都是同一个概念。比如上例中,操作系统创建进程后,会在进程相应的数据结构中设置有一个.text 段的VMA:它在虚拟空间中的地址为0x08048000~0x08049000,它对应ELF文件中偏移为0的.text,它的属性为只读(一般代码段都是只读的),还有一些其他的属性。
3.将CPU指令寄存器设置成可执行文件入口,启动运行。第三步其实也是最简单的一步,操作系统通过设置CPU的指令寄存器将控制权转交给进程,由此进程开始执行。这一步看似简单,实际上在操作系统层面上比较复杂,它涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换。不过从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。还记得ELF文件头中保存有入口地址吗?没错,就是这个地址。

页错误
上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入到内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚存之间的映射关系而已。假设在上面的例子中,程序的入口地址为0x08048000,即刚好是.text段的起始地址。当CPU开始打算执行这个地址的指令时,发现页面0x08048000~0x08049000是个空页面,于是它就认为这是一个页错误(Page Fault)。CPU将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。这时候我们前面提到的装载过程的第二步建立的数据结构起到了很关键的作用,操作系统将查询这个数据结构,然后找到空页面所在的VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还回给进程,进程从刚才页错误的位置重新开始执行。
随着进程的执行,页错误也会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求,如图6-6所示。当然有可能进程所需要的内存会超过可用的内存数量,特别是在有多个进程同时执行的时候,这时候操作系统就需要精心组织和分配物理内存,甚至有时候应将分配给进程的物理内存暂时收回等,这就涉及了操作系统的虚拟存储管理。
页错误
操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以分为如下几种VMA区域:

  • 代码VMA,权限只读、可执行;有映像文件。
  • 数据VMA,权限可读写、可执行;有映像文件。
  • 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。
  • 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展。

当我们在讨论进程虚拟空间的“Segment”的时候,基本上就是指上面的几种VMA。

现在再让我们来看一个常见进程的虚拟空间是怎么样的,如图所示。
ELF与Linux进程虚拟空间映射关系
细心的读者可能已经发现,我们在Linux的“/proc”目录里面看到的VMA2的结束地址跟原先预测的不一样,按照计算应该是0x080bc000,但实际上显示出来的是0x080bb000。这是怎么回事呢?这是因为Linux在装载ELF文件时实现了一种“Hack”的做法,因为Linux的进程虚拟空间管理的VMA的概念并非与“Segment”完全对应,Linux规定一个VMA可以映射到某个文件的一个区域,或者是没有映射到任何文件;而我们这里的第二个“Segment”要求是,前面部分映射到文件中,而后面一部分不映射到任何文件,直接为0,也就是说前面的从“.tdata”段到“.data”段部分要建立从虚拟空间到文件的映射,而“.bss”和“__libcfreeres_ptrs”部分不要映射到文件。这样这两个概念就不完全相同了,所以Linux实际上采用了一种取巧的办法,它在映射完第二个“Segment”之后,把最后一个页面的剩余部分清0,然后调用内核中的do_brk(),把“.bss”和“__libcfreeres_ptrs”的剩余部分放到堆段中。不过这种具体实现问题中的细节不是很关键,有兴趣的读者可以阅读位于Linux内核源代码“fs/Binfmt_elf.c”中的“load_elf_interp()”和“elf_map()”两个函数。

堆的最大申请数量
Linux下虚拟地址空间分给进程本身的是3GB(Windows默认是2GB),那么程序真正可以用到的有多少呢?在我的Linux机器上,运行上面这个程序的结果大概是2.9 GB左右的空间;在Windows下运行这个程序的结果大概是1.5 GB。那么malloc的最大申请数量会受到哪些因素的影响呢?实际上,具体的数值会受到操作系统版本、程序本身大小、用到的动态/共享库数量、大小、程序栈数量、大小等,甚至有可能每次运行的结果都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术(主要是出于安全考虑,防止程序受恶意攻击),使得进程的堆空间变小。

段地址对齐
可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位。对于Intel 80x86系列处理器来说,默认的页大小为4 096字节,也就是说,我们要映射将一段物理内存和进程虚拟地址空间之间建立映射关系,这段内存空间的长度必须是4 096的整数倍,并且这段空间在物理内存和进程虚拟地址空间中的起始地址必须是4 096的整数倍。由于有着长度和起始地址的限制,对于可执行文件来说,它应该尽量地优化自己的空间和地址的安排,以节省空间。

Linux内核装载ELF过程简介
当我们在Linux系统的bash下输入一个命令执行某个ELF程序时,Linux系统是怎样装载这个ELF文件并且执行它的呢?
首先在用户层面,bash进程会调用fork()系统调用创建一个新的进程,然后新的进程调用execve()系统调用执行指定的ELF文件,原先的bash进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。
在进入execve()系统调用之后,Linux内核就开始进行真正的装载工作。在内核中,execve()系统调用相应的入口是sys_execve(),它被定义在arch\i386\kernel\Process.c。sys_execve()进行一些参数的检查复制之后,调用do_execve()。do_execve()会首先查找被执行的文件,如果找到文件,则读取文件的前128个字节。为什么要这么做呢?因为我们知道,Linux支持的可执行文件不止ELF一种,还有a.outJava程序和以“#!”开始的脚本程序。Linux还可以支持更多的可执行文件格式,如果某一天Linux须支持Windows PE的可执行文件格式,那么我们可以编写一个支持PE装载的内核模块来实现Linux对PE文件的支持。这里do_execve()读取文件的前128个字节的目的是判断文件的格式,每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,常常被称做魔数(Magic Number),通过对魔数的判断可以确定文件的格式和类型。比如ELF的可执行文件格式的头4个字节为0x7F、’e’、’l’、’f’;而Java的可执行文件格式的头4个字节为’c’、’a’、’f’、’e’;如果被执行的是Shell脚本或perl、python等这种解释型语言的脚本,那么它的第一行往往是“#!/bin/sh”或“#!/usr/bin/perl”或“#!/usr/bin/python”,这时候前两个字节’#’和’!’就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序的路径。
当do_execve()读取了这128个字节的文件头部之后,然后调用search_binary_handle()去搜索和匹配合适的可执行文件装载处理过程。Linux中所有被支持的可执行文件格式都有相应的装载处理过程,search_binary_handle()会通过判断文件头部的魔数确定文件的格式,并且调用相应的装载处理过程。比如ELF可执行文件的装载处理过程叫做load_elf_binary();a.out可执行文件的装载处理过程叫做load_aout_binary();而装载可执行脚本程序的处理过程叫做load_script()。这里我们只关心ELF可执行文件的装载,load_elf_binary()被定义在fs/Binfmt_elf.c,这个函数的代码比较长,它的主要步骤是:
(1)检查ELF可执行文件格式的有效性,比如魔数、程序头表中段(Segment)的数量。
(2)寻找动态链接的“.interp”段,设置动态链接器路径(与动态链接有关,具体请参考第9章)。
(3)根据ELF可执行文件的程序头表的描述,对ELF文件进行映射,比如代码、数据、只读数据。
(4)初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址(参照动态链接)。
(5)将系统调用的返回地址修改成ELF可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的ELF可执行文件,这个程序入口就是ELF文件的文件头中e_entry所指的地址;对于动态链接的ELF可执行文件,程序入口点是动态链接器。

当load_elf_binary()执行完毕,返回至do_execve()再返回至sys_execve()时,上面的第5步中已经把系统调用的返回地址改成了被装载的ELF程序的入口地址了。所以当sys_execve()系统调用从内核态返回到用户态时,EIP寄存器直接跳转到了ELF程序的入口地址,于是新的程序开始执行,ELF可执行文件装载完成。

Windows PE的装载

在讨论结构的具体装载过程之前,我们要先引入一个PE里面很常见的术语叫做RVA(Relative Virtual Address),它表示一个相对虚拟地址。这个术语看起来比较晦涩难懂,其实它的概念很简单,就是相当于文件中的偏移量的东西。它是相对于PE文件的装载基地址的一个偏移地址。比如,一个PE文件被装载到虚拟地址(VA)0x00400000,那么一个RVA为0x1000的地址就是0x00401000。每个PE文件在装载时都会有一个装载目标地址(Target Address),这个地址就是所谓的基地址(Base Address)。由于PE文件被设计成可以装载到任何地址,所以这个基地址并不是固定的,每次装载时都可能会变化。如果PE文件中的地址都使用绝对地址,它们都要随着基地址的变化而变化。但是,如果使用RVA这样一种基于基地址的相对地址,那么无论基地址怎么变化,PE文件中的各个RVA都保持一致。
装载一个PE可执行文件并且装载它,是个比ELF文件相对简单的过程:
先读取文件的第一个页,在这个页中,包含了DOS头、PE文件头和段表。
检查进程地址空间中,目标地址是否可用,如果不可用,则另外选一个装载地址。这个问题对于可执行文件来说基本不存在,因为它往往是进程第一个装入的模块,所以目标地址不太可能被占用。主要是针对DLL文件的装载而言的,我们在后面的“Rebasing”这一节还会具体介绍这个问题。
使用段表中提供的信息,将PE文件中所有的段一一映射到地址空间中相应的位置。
如果装载地址不是目标地址,则进行Rebasing。
装载所有PE文件所需要的DLL文件。
对PE文件中的所有导入符号进行解析。
根据PE头中指定的参数,建立初始化栈和堆。
建立主线程并且启动进程。

动态链接

为什么要动态链接?

静态链接使得不同的程序开发者和部门能够相对独立地开发和测试自己的程序模块,从某种意义上来讲大大促进了程序开发的效率,原先限制程序的规模也随之扩大。但是慢慢地静态链接的诸多缺点也逐步暴露出来,比如浪费内存和磁盘空间、模块更新困难等问题,使得人们不得不寻找一种更好的方式来组织程序的模块。

内存和磁盘空间
静态链接这种方法的确很简单,原理上很容易理解,实践上很难实现,在操作系统和硬件不发达的早期,绝大部分系统采用这种方案。随着计算机软件的发展,这种方法的缺点很快就暴露出来了,那就是静态连接的方式对于计算机内存和磁盘的空间浪费非常严重。特别是多进程操作系统情况下,静态链接极大地浪费了内存空间,想象一下每个程序内部除了都保留着printf()函数、scanf()函数、strlen()等这样的公用库函数,还有数量相当可观的其他库函数及它们所需要的辅助数据结构。在现在的Linux系统中,一个普通程序会使用到的C语言静态库至少在1 MB以上,那么,如果我们的机器中运行着100个这样的程序,就要浪费近100 MB的内存;如果磁盘中有2 000个这样的程序,就要浪费近2 GB的磁盘空间,很多Linux的机器中,/usr/bin下就有数千个可执行文件。
比如图7-1所示的Program1和Program2分别包含Program1.o和Program2.o两个模块,
静态链接时文件在内存中的副本
并且它们还共用Lib.o这两模块。在静态连接的情况下,因为Program1和Program2都用到了Lib.o这个模块,所以它们同时在链接输出的可执行文件Program1和Program2有两个副本。当我们同时运行Program1和Program2时,Lib.o在磁盘中和内存中都有两份副本。当系统中存在大量的类似于Lib.o的被多个程序共享的目标文件时,其中很大一部分空间就被浪费了。在静态链接中,C语言静态库是很典型的浪费空间的例子,还有其他数以千计的库如果都需要静态链接,那么空间浪费无法想象。

程序开发和发布
空间浪费是静态链接的一个问题,另一个问题是静态链接对程序的更新、部署和发布也会带来很多麻烦。比如程序Program1所使用的Lib.o是由一个第三方厂商提供的,当该厂商更新了Lib.o的时候(比如修正了lib.o里面包含的一个Bug),那么Program1的厂商就需要拿到最新版的Lib.o,然后将其与Program1.o链接后,将新的Program1整个发布给用户。这样做的缺点很明显,即一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。比如一个程序有20个模块,每个模块1 MB,那么每次更新任何一个模块,用户就得重新获取这个20 MB的程序。如果程序都使用静态链接,那么通过网络来更新程序将会非常不便,因为一旦程序任何位置的一个小改动,都会导致整个程序重新下载。

动态链接
要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态地链接在一起。简单地讲,就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking)的基本思想。
还是以Program1和Program2为例,假设我们保留Program1.o、Program2.o和Lib.o三个目标文件。当我们要运行Program1这个程序时,系统首先加载Program1.o,当系统发现Program1.o中用到了Lib.o,即Program1.o依赖于Lib.o,那么系统接着加载Lib.o,如果Program1.o或Lib.o还依赖于其他目标文件,系统会按照这种方法将它们全部加载至内存。所有需要的目标文件加载完毕之后,如果依赖关系满足,即所有依赖的目标文件都存在于磁盘,系统开始进行链接工作。这个链接工作的原理与静态链接非常相似,包括符号解析、地址重定位等,我们在前面已经很详细地介绍过了。完成这些步骤之后,系统开始把控制权交给Program1.o的程序入口处,程序开始运行。这时如果我们需要运行Program2,那么系统只需要加载Program2.o,而不需要重新加载Lib.o,因为内存中已经存在了一份Lib.o的副本(见图7-2),系统要做的只是将Program2.o和Lib.o链接起来。
很明显,上面的这种做法解决了共享的目标文件多个副本浪费磁盘和内存空间的问题,可以看到,磁盘和内存中只存在一份Lib.o,而不是两份。另外在内存中共享一个目标文件.
动态链接时文件在内存中的副本
模块的好处不仅仅是节省内存,它还可以减少物理页面的换入换出,也可以增加CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上。
上面的动态链接方案也可以使程序的升级变得更加容易,当我们要升级程序库或程序共享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接一遍。当程序下一次运行的时候,新版本的目标文件会被自动装载到内存并且链接起来,程序就完成了升级的目标。
当一个程序产品的规模很大的时候,往往会分割成多个子系统及多个模块,每个模块都由独立的小组开发,甚至会使用不同的编程语言。动态链接的方式使得开发过程中各个模块更加独立,耦合度更小,便于不同的开发者和开发组织之间独立进行开发和测试。

程序可扩展性和兼容性
动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点就是后来被人们用来制作程序的插件(Plug-in)
比如某个公司开发完成了某个产品,它按照一定的规则制定好程序的接口,其他公司或开发者可以按照这种接口来编写符合要求的动态链接文件。该产品程序可以动态地载入各种由第三方开发的模块,在程序运行时动态地链接,实现程序功能的扩展。
动态链接还可以加强程序的兼容性。一个程序在不同的平台运行时可以动态地链接到由操作系统提供的动态链接库,这些动态链接库相当于在程序和操作系统之间增加了一个中间层,从而消除了程序对不同平台之间依赖的差异性。比如操作系统A和操作系统B对于printf()的实现机制不同,如果我们的程序是静态链接的,那么程序需要分别链接成能够在A运行和在B运行的两个版本并且分开发布;但是如果是动态链接,只要操作系统A和操作系统B都能提供一个动态链接库包含printf(),并且这个printf()使用相同的接口,那么程序只需要有一个版本,就可以在两个操作系统上运行,动态地选择相应的printf()的实现版本。当然这只是理论上的可能性,实际上还存在不少问题,我们会在后面继续探讨关于动态链接模块之间兼容性的问题。
从上面的描述来看,动态链接是不是一种“万能膏药”,包治百病呢?很遗憾,动态链接也有诸多的问题及令人烦恼和费解的地方。很常见的一个问题是,当程序所依赖的某个模块更新后,由于新的模块与旧的模块之间接口不兼容,导致了原有的程序无法运行。这个问题在早期的Windows版本中尤为严重,因为它们缺少一种有效的共享库版本管理机制,使得用户经常出现新程序安装完之后,其他某个程序无法正常工作的现象,这个问题也经常被称为“DLL Hell”。

动态链接的基本实现
动态链接涉及运行时的链接及多个文件的装载,必需要有操作系统的支持,因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下也会有一些微妙的变化。目前主流的操作系统几乎都支持动态链接这种方式,在Linux系统中,ELF动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),简称共享对象,它们一般都是以“.so”为扩展名的一些文件;而在Windows系统中,动态链接文件被称为动态链接库(Dynamical Linking Library),它们通常就是我们平时很常见的以“.dll”为扩展名的文件。
从本质上讲,普通可执行程序和动态链接库中都包含指令和数据,这一点没有区别。在使用动态链接库的情况下,程序本身被分为了程序主要模块(Program1)动态链接库(Lib.so),但实际上它们都可以看作是整个程序的一个模块,所以当我们提到程序模块时可以指程序主模块也可以指动态链接库。
在Linux中,常用的C语言库的运行库glibc,它的动态链接形式的版本保存在“/lib”目录下,文件名叫做“libc.so”。整个系统只保留一份C语言库的动态链接文件“libc.so”,而所有的C语言编写的、动态链接的程序都可以在运行时使用它。当程序被装载的时候,系统的动态链接器会将程序所需要的所有动态链接库(最基本的就是libc.so)装载到进程的地址空间,并且将程序中所有未决议的符号绑定到相应的动态链接库中,并进行重定位工作。
程序与libc.so之间真正的链接工作是由动态链接器完成的,而不是由我们前面看到过的静态链接器ld完成的。也就是说,动态链接是把链接这个过程从本来的程序装载前被推迟到了装载的时候。可能有人会问,这样的做法的确很灵活,但是程序每次被装载时都要进行重新进行链接,是不是很慢?的确,动态链接会导致程序在性能的一些损失,但是对动态链接的链接过程可以进行优化,比如我们后面要介绍的延迟绑定(Lazy Binding)等方法,可以使得动态链接的性能损失尽可能地减小。据估算,动态链接与静态链接相比,性能损失大约在5%以下。当然经过实践的证明,这点性能损失用来换取程序在空间上的节省和程序构建和升级时的灵活性,是相当值得的。

简单的动态链接例子

Windows平台下的PE动态链接机制与Linux下的ELF动态链接稍有不同,ELF比PE从结构上来看更加简单,我们先以ELF作为例子来描述动态链接的过程,接着我们将会单独描述Windows平台下PE动态链接机制的差异。
首先通过一个简单的例子来大致地感受一下动态链接,我们还是以图7-2中的Program1和Program2来做演示。我们分别需要如下几个源文件:“Program1.c”、“Program2.c”、“Lib.c”和“Lib.h”。

/* Program1.c */
#include "Lib.h"

int main()
{   
    foobar(1);
    return 0;
}

/* Program2.c */
#include "Lib.h"
int main()
{   
  foobar(2);
    return 0;
}

/* Lib.c */
#include <stdio.h>

void foobar(int i) 
{
    printf("Printing from Lib.so %d\n", i);
}

/* Lib.h */
#ifndef LIB_H
#define LIB_H

void foobar(int i);

#endif

程序很简单,两个程序的主要模块Program1.c和Program2.c分别调用了Lib.c里面的foobar()函数,传进去一个数字,foobar()函数的作用就是打印这个数字。然后我们使用GCC将Lib.c编译成一个共享对象文件:

gcc -fPIC -shared -o Lib.so Lib.c

上面GCC命令中的参数“-shared”表示产生共享对象,“-fPIC”我们稍后还会详细解释,这里暂且略过。
这时候我们得到了一个Lib.so文件,这就是包含了Lib.c的foobar()函数的共享对象文件。然后我们分别编译链接Program1.c和Program2.c:

gcc -o Program1 Program1.c ./Lib.so
gcc -o Program2 Program2.c ./Lib.so

这样我们得到了两个程序Program1和Program2,这两个程序都使用了Lib.so里面的foobar()函数。从Program1的角度看,整个编译和链接过程如图7-3所示。
动态链接过程
Lib.c被编译成Lib.so共享对象文件,Program1.c被编译成Program1.o之后,链接成为可执行程序Program1。图7-3中有一个步骤与静态链接不一样,那就是Program1.o被连接成可执行文件的这一步。在静态链接中,这一步链接过程会把Program1.o和Lib.o链接到一起,并且产生输出可执行文件Program1。但是在这里,Lib.o没有被链接进来,链接的输入目标文件只有Program1.o(当然还有C语言运行库,我们这里暂时忽略)。但是从前面的命令行中我们看到,Lib.so也参与了链接过程。这是怎么回事呢?

关于模块(Module)
在静态链接时,整个程序最终只有一个可执行文件,它是一个不可以分割的整体;但是在动态链接下,一个程序被分成了若干个文件,有程序的主要部分,即可执行文件(Program1)程序所依赖的共享对象(Lib.so),很多时候我们也把这些部分称为模块,即动态链接下的可执行文件和共享对象都可以看作是程序的一个模块。
让我们再回到动态链接的机制上来,当程序模块Program1.c被编译成为Program1.o时,编译器还不不知道foobar()函数的地址,这个内容我们已在静态链接中解释过了。当链接器将Program1.o链接成可执行文件时,这时候链接器必须确定Program1.o中所引用的foobar()函数的性质。如果foobar()是一个定义与其他静态目标模块中的函数,那么链接器将会按照静态链接的规则,将Program1.o中的foobar地址引用重定位;如果foobar()是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。
那么这里就有个问题,链接器如何知道foobar的引用是一个静态符号还是一个动态符号?这实际上就是我们要用到Lib.so的原因。Lib.so中保存了完整的符号信息(因为运行时进行动态链接还须使用符号信息),把Lib.so也作为链接的输入文件之一,链接器在解析符号时就可以知道:foobar是一个定义在Lib.so的动态符号。这样链接器就可以对foobar的引用做特殊的处理,使它成为一个对动态符号的引用。

地址无关代码
为了解决这个模块装载地址固定的问题,我们设想是否可以让共享对象在任意地址加载?这个问题另一种表述方法就是:共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。与此不同的是,可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的地址,比如Linux下一般都是0x08040000,Windows下一般都是0x0040000。
为了能够使共享对象在任意地址装载,我们首先能想到的方法就是静态链接中的重定位。这个想法的基本思路就是,在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。一旦模块装载地址确定,即目标地址确定,那么系统就对程序中所有的绝对地址引用进行重定位。假设函数foobar相对于代码段的起始地址是0x100,当模块被装载到0x10000000时,我们假设代码段位于模块的最开始,即代码段的装载地址也是0x10000000,那么我们就可以确定foobar的地址为0x10000100。这时候,系统遍历模块中的重定位表,把所有对foobar的地址引用都重定位至0x10000100。
事实上,类似的方法在很早以前就存在。早在没有虚拟存储概念的情况下,程序是直接被装载进物理内存的。当同时有多个程序运行的时候,操作系统根据当时内存空闲情况,动态分配一块大小合适的物理内存给程序,所以程序被装载的地址是不确定的。系统在装载程序的时候需要对程序的指令和数据中对绝对地址的引用进行重定位。但这种重定位比前面提到过的静态链接中的重定位要简单得多,因为整个程序是按照一个整体被加载的,程序中指令和数据的相对位置是不会改变的。比如一个程序在编译时假设被装载的目标地址为0x1000,但是在装载时操作系统发现0x1000这个地址已经被别的程序使用了,从0x4000开始有一块足够大的空间可以容纳该程序,那么该程序就可以被装载至0x4000,程序指令或数据中的所有绝对引用只要都加上0x3000的偏移量就可以了。
我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置(Rebasing)
Linux和GCC支持这种装载时重定位的方法,我们前面在产生共享对象时,使用了两个GCC参数“-shared”和“-fPIC”,如果只使用“-shared”,那么输出的共享对象就是使用装载时重定位的方法。
那么什么是“-fPIC”呢?使用这个参数会有什么效果呢?
其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC, Position-independent Code)的技术。
要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其他模块的全局变量的地址是跟模块装载地址有关的。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。
-fpic和-fPIC
使用GCC产生地址无关代码很简单,我们只需要使用“-fPIC”参数即可。实际上GCC还提供了另外一个类似的参数叫做“-fpic”,即“PIC”3个字母小写,这两个参数从功能上来讲完全一样,都是指示GCC产生地址无关代码。唯一的区别是,“-fPIC”产生的代码要大,而“-fpic”产生的代码相对较小,而且较快。那么我们为什么不使用“-fpic”而要使用“-fPIC”呢?原因是,由于地址无关代码都是跟硬件平台相关的,不同的平台有着不同的实现,“-fpic”在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而“-fPIC”则没有这样的限制。所以为了方便起见,绝大部分情况下我们都使用“-fPIC”参数来产生地址无关代码。

延迟绑定实现(PLT)
动态链接的确有很多优势,比静态链接要灵活得多,但它是以牺牲一部分性能为代价的。据统计ELF程序在静态链接下要比动态库稍微快点,大约为1%~5%,当然这取决于程序本身的特性及运行环境等。我们知道动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的GOT定位,然后间接寻址;对于模块间的调用也要先定位GOT,然后再进行间接跳转,如此一来,程序的运行速度必定会减慢。另外一个减慢运行速度的原因是动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都要进行一次链接工作,正如我们上面提到的,动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作,这些工作势必减慢程序的启动速度。
在动态链接下,程序模块之间包含了大量的函数引用(全局变量往往比较少,因为大量的全局变量会导致模块之间耦合度变大),所以在程序开始执行前,动态链接会耗费不少时间用于解决模块之间的函数引用的符号查找以及重定位,这也是我们上面提到的减慢动态链接性能的第二个原因。不过可以想象,在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。所以ELF采用了一种叫做延迟绑定(Lazy Binding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。所以程序开始执行时,模块间的函数调用都没有进行绑定,而是需要用到时才由动态链接器来负责绑定。这样的做法可以大大加快程序的启动速度,特别有利于一些有大量函数引用和大量模块的程序。
ELF使用PLT(Procedure Linkage Table)的方法来实现。

动态链接相关结构
动态链接情况下,可执行文件的装载与静态链接情况基本一样。首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的“Program Header”中读取每个“Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置,这些步骤跟前面的静态链接情况下的装载基本无异。在静态链接情况下,操作系统接着就可以把控制权转交给可执行文件的入口地址,然后程序开始执行,一切看起来非常直观。
但是在动态链接情况下,操作系统还不能在装载完可执行文件之后就把控制权交给可执行文件,因为我们知道可执行文件依赖于很多共享对象。这时候,可执行文件里对于很多外部符号的引用还处于无效地址的状态,即还没有跟相应的共享对象中的实际位置链接起来。所以在映射完可执行文件之后,操作系统会先启动一个动态链接器(Dynamic Linker)
在Linux下,动态链接器ld.so实际上是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后,就将控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
那么系统中哪个才是动态链接器呢,它的位置由谁决定?是不是所有的NIX系统的动态链接器都位于/lib/ld.so呢?实际上,动态链接器的位置既不是由系统配置指定,也不是由环境参数决定,而是由ELF可执行文件决定。在动态链接的ELF可执行文件中,有一个专门的段叫做“.interp”段(“interp”是“interpreter”(解释器)的缩写)。
“.interp”的内容很简单,里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径,在Linux下,可执行文件所需要的动态链接器的路径几乎都是“/lib/ld-linux.so.2”,其他的
nix操作系统可能会有不同的路径,我们在后面还会再介绍到各种环境下的动态链接器的路径。在Linux的系统中,/lib/ld-linux.so.2通常是一个软链接,比如在我的机器上,它指向/lib/ld-2.6.1.so,这个才是真正的动态链接器。在Linux中,操作系统在对可执行文件的进行加载的时候,它会去寻找装载该可执行文件所需要相应的动态链接器,即“.interp”段指定的路径的共享对象。

动态链接的步骤和实现
动态链接的步骤基本上分为3步:先是启动动态链接器本身,然后装载所有需要的共享对象,最后是重定位和初始化。

动态链接器自举
我们知道动态链接器本身也是一个共享对象,但是事实上它有一些特殊性。对于普通共享对象文件来说,它的重定位工作由动态链接器来完成;它也可以依赖于其他共享对象,其中的被依赖的共享对象由动态链接器负责链接和装载。可是对于动态链接器本身来说,它的重定位工作由谁来完成?它是否可以依赖于其他的共享对象?
这是一个“鸡生蛋,蛋生鸡”的问题,为了解决这种无休止的循环,动态链接器这个“鸡”必须有些特殊性。首先是,动态链接器本身不可以依赖于其他任何共享对象;其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。对于第一个条件我们可以人为地控制,在编写动态链接器时保证不使用任何系统库、运行库;对于第二个条件,动态链接器必须在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时又不能用到全局和静态变量。这种具有一定限制条件的启动代码往往被称为自举(Bootstrap)
动态链接器入口地址即是自举代码的入口,当操作系统将进程控制权交给动态链接器时,动态链接器的自举代码即开始执行。自举代码首先会找到它自己的GOT。而GOT的第一个入口保存的即是“.dynamic”段的偏移地址,由此找到了动态连接器本身的“.dynamic”段。通过“.dynamic”中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始,动态链接器代码中才可以开始使用自己的全局变量和静态变量。

装载共享对象
完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)。然后链接器开始寻找可执行文件所依赖的共享对象,我们前面提到过“.dynamic”段中,有一种类型的入口是DT_NEEDED,它所指出的是该可执行文件(或共享对象)所依赖的共享对象。由此,链接器可以列出可执行文件所需要的所有共享对象,并将这些共享对象的名字放入到一个装载集合中。然后链接器开始从集合里取一个所需要的共享对象的名字,找到相应的文件后打开该文件,读取相应的ELF文件头和“.dynamic”段,然后将它相应的代码段和数据段映射到进程空间中。如果这个ELF共享对象还依赖于其他共享对象,那么将所依赖的共享对象的名字放到装载集合中。如此循环直到所有依赖的共享对象都被装载进来为止,当然链接器可以有不同的装载顺序,如果我们把依赖关系看作一个图的话,那么这个装载过程就是一个图的遍历过程,链接器可能会使用深度优先或者广度优先或者其他的顺序来遍历整个图,这取决于链接器,比较常见的算法一般都是广度优先的。

Linux共享库的组织

其实从文件结构上来讲,共享库和共享对象没什么区别,Linux下的共享库就是普通的ELF共享对象。由于共享对象可以被各个程序之间共享,所以它也就成为了库的很好的存在形式,很多库的开发者都以共享对象的形式让程序来使用,久而久之,共享对象和共享库这两个概念已经很模糊了,所以广义上我们可以将它们看作是同一个概念。

共享库版本命名
既然共享库存在这样那样的兼容性问题,那么保持共享库在系统中的兼容性,保证依赖于它们的应用程序能够正常运行是必须要解决的问题。有几种办法可用于解决共享库的兼容性问题,有效办法之一就是使用共享库版本的方法。Linux有一套规则来命名系统中的每一个共享库,它规定共享库的文件名规则必须如下:
libname.so.x.y.z
最前面使用前缀“lib”、中间是库的名字和后缀“.so”,最后面跟着的是三个数字组成的版本号。“x”表示主版本号(Major Version Number),“y”表示次版本号(Minor Version Number),“z”表示发布版本号(Release Version Number)。三个版本号的含义不一样。
主版本号表示库的重大升级,不同主版本号的库之间是不兼容的,依赖于旧的主版本号的程序需要改动相应的部分,并且重新编译,才可以在新版的共享库中运行;或者,系统必须保留旧版的共享库,使得那些依赖于旧版共享库的程序能够正常运行。
次版本号表示库的增量升级,即增加一些新的接口符号,且保持原来的符号不变。在主版本号相同的情况下,高的次版本号的库向后兼容低的次版本号的库。一个依赖于旧的次版本号共享库的程序,可以在新的次版本号共享库中运行,因为新版中保留了原来所有的接口,并且不改变它们的定义和含义。比如系统中有个共享库为libfoo.so.1.2.x,后来在升级过程中添加了一个函数,版本号变成了1.3.x。因为1.2.x的所有接口都被保留到1.3.x中了,所以那些依赖于1.1.x或1.2.x的程序都可以在1.3.x中正常运行。
发布版本号表示库的一些错误的修正、性能的改进等,并不添加任何新的接口,也不对接口进行更改。相同主版本号、次版本号的共享库,不同的发布版本号之间完全兼容,依赖于某个发布版本号的程序可以在任何一个其他发布版本号中正常运行,而无须做任何修改。
当然现在Linux中也存在不少不遵守上述规定的“顽固分子”,比如最基本的C语言库Glibc就不使用这种规则,它的基本C语言库使用libc-x.y.z.so这种命名方式。Glibc有许多组件,C语言库只是其中一个,动态链接器也是Glibc的一部分,它使用ld-x.y.z.so这样的命名方式,还有Glibc的其他部分,比如数学库libm、运行时装载库libdl等。

SO-NAME
因为我们知道不同主版本号之间的共享库是完全不兼容的,所以程序中保存一个诸如libfoo.so.2的记录,以防止动态链接器在运行时意外地将程序与libfoo.so.1或libfoo.so.3链接到一起。通过这个可以发现,如果在系统中运行旧的应用程序,就需要在系统中保留旧应用程序所需要的旧的主版本号的共享库。

SO-NAME

对于新的系统来说,包括Solaris和Linux,普遍采用一种叫做SO-NAME的命名机制来记录共享库的依赖关系。每个共享库都有一个对应的“SO-NAME”,这个SO-NAME即共享库的文件名去掉次版本号和发布版本号,保留主版本号。比如一个共享库叫做libfoo.so.2.6.1,那么它的SO-NAME即libfoo.so.2.
由于历史原因,动态链接器和C语言库的共享对象文件名规则不按Linux标准的共享库命名方法,但是C语言的SO-NAME还是按照正常的规则:Glibc的C语言库libc-2.6.1.so,它的SO-NAME是libc.so.6;为了“彰显”动态连接器的与众不同,它的SO-NAME命名也不按照普通的规则,比如动态链接器的文件名是ld-2.6.1.so,它的SO-NAME是ld-linux.so。
那么以“SO-NAME”为名字建立软链接有什么用处呢?实际上这个软链接会指向目录中主版本号相同、次版本号和发布版本号最新的共享库。也就是说,比如目录中有两个共享库版本分别为:/lib/libfoo.so.2.6.1和/lib/libfoo.2.5.3,那么软链接/lib/libfoo.so.2会指向/lib/libfoo.so.2.6.1。这样保证了所有的以SO-NAME为名的软链接都指向系统中最新版的共享库。

符号版本
在一些早期的系统中,应用程序在被构建时,静态链接器会把程序所依赖的所有共享库的名字、主版本号和次版本号都记录到最终的应用程序二进制输出文件中。在运行时,由于动态链接器知道应用程序所依赖的共享库的确切版本号,所以兼容性问题比较容易处理。比如在SunOS 4.x中,动态链接器会根据程序的共享库依赖列表中的记录,在系统中查找相同共享库名和主版本号的共享库;如果某个共享库在系统中存在相同主版本号不同次版本号的多个副本,那么动态链接器会使用那个最高次版本号的副本。
动态链接器在查找共享库过程中,如果找到的共享库的次版本号高于或等于依赖列表中的版本,那么链接器就默认共享库满足要求,因为更高次版本号的共享库肯定包含所有需要的符号;如果找到的共享库次版本号低于所需要的版本,SunOS 4.x系统的策略是向用户发出一个警告信息,表示系统中仅有低次版本号的共享库,但运行程序还是继续运行。程序很有可能能够正常运行,比如该程序只用了低次版本号中的接口,而没有用到高次版本号中新添加的那些接口。当然,程序如果用到了高次版本号中新添加的接口而目前系统中的低次版本号的共享库中不存在,那么就会发生重定位错误。有些采取更加保守策略的系统中,对于这种系统中没有足够高的次版本号满足依赖关系的情况,程序将会被禁止运行,以防止出现意外情况。
这两种策略或可能导致程序运行错误(第一种只通过警告的策略),或者会阻止那些实际上能够运行的程序(第二种保守策略)。实际上很多应用程序在高次版本的系统中都有构建,但实际上它只用到了低次版本的那部分接口,在采取第二种策略的系统中,如果系统中只有低次版本号的共享库,那么这些程序就不能运行。我们可以把这个问题叫做次版本号交会问题(Minor-revision Rendezvous Problem)
次版本号交会问题并没有因为SO-NAME而解决
动态链接器在进行动态链接时,只进行主版本号的判断,即只判断SO-NAME,如果某个被依赖的共享库SO-NAME与系统中存在的实际共享库SO-NAME一致,那么系统就认为接口兼容,而不再进行兼容性检查。这样就会出现一个问题,当某个程序依赖于较高的次版本号的共享库,而运行于较低次版本号的共享库系统时,就可能产生缺少某些符号的错误。因为次版本号只保证向后兼容,并不保证向前兼容,新版的次版本号的共享库可能添加了一些旧版没有的符号。
正常情况下,为了表示某个共享库中增加了一些接口,我们就把这个共享库的次版本号升高(表示里面添加了一些东西)。但是我们需要一种更为巧妙的方法,来解决次版本号交会问题。Linux下的Glibc从版本2.1之后开始支持一种叫做基于符合的版本机制(Symbol Versioning)的方案。这个方案的基本思路是让每个导出和导入的符号都有一个相关联的版本号,它的实际做法类似于名称修饰的方法。与以往简单地将某个共享库的版本号重新命名不同(比如将libfoo.so.1.2升级到libfoo.so.1.3),当我们将libfoo.so.1.2升级至1.3时,仍然保持libfoo.so.1这个SO-NAME,但是给在1.3这个新版中添加的那些全局符号打上一个标记,比如“VERS_1.3”。那么,如果一个共享库每一次次版本号升级,我们都能给那些在新的次版本号中添加的全局符号打上相应的标记,就可以清楚地看到共享库中的每个符号都拥有相应的标签,比如“VERS_1.1”、“VERS_1.2”、“VERS_1.3”、“VERS_1.4”。
这个基于符号版本的方案最早是Sun在1995年的Solaris 2.5中实现的,在这个新的机制中,Solaris的ld链接器为共享库新增了版本机制(Versioning)范围机制(Scoping)
版本机制的想法很简单,也就是定义一些符号的集合,这些集合本身都有名字,比如叫“VERS_1.1”、“VERS_1.2”等,每个集合都包含一些指定的符号,除了可以拥有符号以外,一个集合还可以包含另外一个集合,比如“VERS_1.2”可以包含集合“VERS_1.1”。就概念而言与其说是“包含”,不如说是“继承”,比如“VERS_1.2”的符号集合包含(继承)了所有“VERS_1.1”的符号,并且包含所有“VERS_1.2”的符号。
那么,这些集合的定义及它们包含哪些符号是怎样指定的呢?在Solaris中,程序员可以在链接共享库时编写一种叫做符号版本脚本的文件,在这个文件中指定这些符号与集合之间及集合与集合之间的继承依赖关系。链接器在链接时根据符号版本脚本中指定的关系来产生共享库,并且设置符号的集合与它们之间的关系。
举个简单的例子,假设有个名为libstack.so.1的共享库编写的符号版本脚本文件如下:

SUNW_1.1 { 
    global: 
    pop; 
    push; 
} 
  
SUNWprivate { 
    global: 
    __pop; 
    __push; 
    local: 
    *; 
} 

在这个脚本文件中,我们可以看到它定义了两个符号集合,分别为“SUNW_1.1”和“SUNWprivate”(在Solaris系统中,符号的集合名通常由“SUNW”开头)。第一个包含了两个全局符号pop和push;在第二个集合中,包含了两个全局符号“__pop”和“__push”。第二个集合中最后的“local: *;”表示:除了上述被标识为全局的“pop”、“push”、“__pop”和“__push”这4个符号以外,共享库中其他的本来是全局的符号都将成为共享库局部符号,也就是说链接器会把原先是全局的符号全部变成局部的,这样一来,共享库外部的应用程序或其他的共享库将无法访问这些符号。这种方式可以用于保护那些共享库内部的公用实用函数,但是共享库的作者又不希望共享库的使用者能够有意或无意地访问这些函数。这种方法又被称为范围机制(Scoping),它实际上是对C语言没有很好的符号可见范围的控制机制的一种补充,或者说是一种补救性质的措施。
假设现在这个共享库升级了,在原有的基础上添加了一个全局函数“swap”,那么新的符号版本脚本文件可以在原有的基础上添加如下内容:

SUNW_1.2 { 
    global: 
    swap; 
} SUNW_1.1; 

上面的脚本就表示了一个典型的向上兼容的接口:1.2版的共享库增加了一个swap接口,并且它继承了1.1的所有接口。那么我们可以按照这种方式,共享库中的版本序号SUNW_1.1、SUNW_1.2、SUNW_1.3……分别表示每次共享库添加接口以后的更新,它们依次向后继承,向后兼容。这里值得一提的是,跟在“SUNW_”前缀后面的版本号由主版本号与一个次版本号构成,这里的主版本号对应于共享库实际的SO-NAME中的主版本号。
当共享库的符号都有了版本集合之后,一个最明显的效果就是,当我们在构建(编译和链接)应用程序的时候,链接器可以在程序的最终输出文件中记录下它所用到的版本符号集合。值得注意的是,程序里面记录的不是构建时共享库中版本最新的符号集合,而是程序所依赖的集合中版本号最小的那个(或者那些)。比如,一个共享库libfoo.so.1中有6个符号版本,从SUNW_1.1到SUNW_1.6,某个应用程序app_foo在编译时,系统中的libfoo.so.1的符号版本为SUNW_1.6,但实际上app_foo只用到了最高到SUNW_1.3集合的符号,那么应用程序实际上依赖于SUNW_1.3,而不是SUNW_1.6。链接器会计算出app_foo所用到的最高版本的符号,然后把SUNW_1.3记录到app_foo的可执行文件内。
在程序运行时,动态链接器会通过程序内记录的它所依赖的所有共享库的符号集合版本信息,然后判定当前系统共享库中的符号集合版本是否满足这些被依赖的符号集合。通过这样的机制,就可以保证那些在高次版本共享库的系统中编译的程序在低次版本共享库中运行。如果该低次版本的共享库满足符号集合的要求,比如app_foo在libfoo.so.1次版本号大于等于3的系统中运行,就没有任何问题;如果低次版本共享库不满足要求,如app_foo在libfoo.so.1次版本号小于3的系统中运行,动态链接器就会意识到当前系统的共享库次版本号不满足要求,从而阻止程序运行,以防止造成进一步的损失。
这种符号版本的方法是对SO-NAME机制保证共享库主版本号一致的一种非常好的补充。

Linux系统中符号版本机制实践
在Linux下,当我们使用ld链接一个共享库时,可以使用“--version-script”参数;如果使用GCC,则可以使用“-Xlinker”参数加“--version-script”,相当于把“--version-script”传递给ld链接器。如编译源代码为“lib.c”,符号版本脚本文件为“lib.ver”:

gcc -shared -fPIC lib.c -Xlinker --version-script lib.ver -o lib.so

假设lib.c里面定义了一个foo的函数,而main.c调用了这个函数,如我们使用下面的符号版本脚本编译一个lib.so:

VERS_1.2 {
    global:
        foo;
    local:
        *;
};

那么很明显,这个版本的lib.so里面foo的符号版本是VERS_1.2。然后将main.c编译并且链接到当前版本的lib.so:

gcc main.c ./lib.so –o main

于是main程序里面所引用的foo也是VERS_1.2的。如果把这个main程序拿到一台只包含低于VERS_1.2的foo的lib.so系统中运行,那么动态链接器就会报运行错误并且退出程序,防止了符号版本不符所造成额外的损失:

./main
./main: ./lib.so: version `VERS_1.2' not found (required by ./main)

共享库系统路径
目前大多数包括Linux在内的开源操作系统都遵守一个叫做FHS(File Hierarchy Standard)的标准,这个标准规定了一个系统中的系统文件应该如何存放,包括各个目录的结构、组织和作用,这有利于促进各个开源操作系统之间的兼容性。共享库作为系统中重要的文件,它们的存放方式也被FHS列入了规定范围。FHS规定,一个系统中主要有两个存放共享库的位置,它们分别如下:
/lib,这个位置主要存放系统最关键和基础的共享库,比如动态链接器、C语言运行库、数学库等,这些库主要是那些/bin和/sbin下的程序所需要用到的库,还有系统启动时需要的库。
/usr/lib,这个目录下主要保存的是一些非系统运行时所需要的关键性的共享库,主要是一些开发时用到的共享库,这些共享库一般不会被用户的程序或shell脚本直接用到。这个目录下面还包含了开发时可能会用到的静态库、目标文件等。
/usr/local/lib,这个目录用来放置一些跟操作系统本身并不十分相关的库,主要是一些第三方的应用程序的库。比如我们在系统中安装了python语言的解释器,那么与它相关的共享库可能会被放到/usr/local/lib/python,而它的可执行文件可能被放到/usr/local/bin下。GNU的标准推荐第三方的程序应该默认将库安装到/usr/local/lib下。
所以总体来看,/lib和/usr/lib是一些很常用的、成熟的,一般是系统本身所需要的库;而/usr/local/lib是非系统所需的第三方程序的共享库。

共享库查找过程
在开源系统中,包括所有的Linux系统在内的很多都是基于Glibc的。我们知道在这些系统里面,动态链接的ELF可执行文件在启动时同时会启动动态链接器。在Linux系统中,动态链接器是/lib/ld-linux.so.X(X是版本号),程序所依赖的共享对象全部由动态链接器负责装载和初始化。我们知道任何一个动态链接的模块所依赖的模块路径保存在“.dynamic”段里面,由DT_NEED类型的项表示。动态链接器对于模块的查找有一定的规则:如果DT_NEED里面保存的是绝对路径,那么动态链接器就按照这个路径去查找;如果DT_NEED里面保存的是相对路径,那么动态链接器会在/lib、/usr/lib和由/etc/ld.so.conf配置文件指定的目录中查找共享库。为了程序的可移植性和兼容性,共享库的路径往往是相对的。
ld.so.conf是一个文本配置文件,它可能包含其他的配置文件,这些配置文件中存放着目录信息。在我的机器中,由ld.so.conf指定的目录是:

/usr/local/lib
/lib/i486-linux-gnu
/usr/lib/i486-linux-gnu

如果动态链接器在每次查找共享库时都去遍历这些目录,那将会非常耗费时间。所以Linux系统中都有一个叫做ldconfig的程序,这个程序的作用是为共享库目录下的各个共享库创建、删除或更新相应的SO-NAME(即相应的符号链接),这样每个共享库的SO-NAME就能够指向正确的共享库文件;并且这个程序还会将这些SO-NAME收集起来,集中存放到/etc/ld.so.cache文件里面,并建立一个SO-NAME的缓存。当动态链接器要查找共享库时,它可以直接从/etc/ld.so.cache里面查找。而/etc/ld.so.cache的结构是经过特殊设计的,非常适合查找,所以这个设计大大加快了共享库的查找过程。
如果动态链接器在/etc/ld.so.cache里面没有找到所需要的共享库,那么它还会遍历/lib和/usr/lib这两个目录,如果还是没找到,就宣告失败。
所以理论上讲,如果我们在系统指定的共享库目录下添加、删除或更新任何一个共享库,或者我们更改了/etc/ld.so.conf的配置,都应该运行ldconfig这个程序,以便调整SO-NAME和/etc/ld.so.cache。很多软件包的安装程序在往系统里面安装共享库以后都会调用ldconfig。
不同的系统中,上面的各个文件的名字或路径可能有所不同,比如FreeBSD的SO-NAME缓存文件是/var/run/ld-elf.so.hints,我们可以通过查看ldconfig的man手册来得知这些信息。

环境变量
Linux系统提供了很多方法来改变动态链接器装载共享库路径的方法,通过使用这些方法,我们可以满足一些特殊的需求,比如共享库的调试和测试、应用程序级别的虚拟等。改变共享库查找路径最简单的方法是使用LD_LIBRARY_PATH环境变量,这个方法可以临时改变某个应用程序的共享库查找路径,而不会影响系统中的其他程序。
在Linux系统中,LD_LIBRARY_PATH是一个由若干个路径组成的环境变量,每个路径之间由冒号隔开。默认情况下,LD_LIBRARY_PATH为空。如果我们为某个进程设置了LD_LIBRARY_PATH,那么进程在启动时,动态链接器在查找共享库时,会首先查找由LD_LIBRARY_PATH指定的目录。这个环境变量可以很方便地让我们测试新的共享库或使用非标准的共享库。比如我们希望使用修改过的libc.so.6,可以将这个新版的libc放到我们的目录/home/user中,然后指定LD_LIBRARY_PATH:

LD_LIBRARY_PATH=/home/user /bin/ls

Linux中还有一种方法可以实现与LD_LIBRARY_PATH类似的功能,那就是直接运行动态链接器来启动程序,比如:

/lib/ld-linux.so.2 –library-path /home/user /bin/ls

就可以达到跟前面一样的效果。有了LD_LIBRARY_PATH之后,再来总结动态链接器查找共享库的顺序。动态链接器会按照下列顺序依次装载或查找共享对象(目标文件):

  • 由环境变量LD_LIBRARY_PATH指定的路径。
  • 由路径缓存文件/etc/ld.so.cache指定的路径。
  • 默认共享库目录,先/usr/lib,然后/lib。

共享库的创建
创建共享库非常简单,我们在前面已经演示了如何创建一个“.so”共享对象。创建共享库的过程跟创建一般的共享对象的过程基本一致,最关键的是使用GCC的两个参数,即“-shared”和“-fPIC”。“-shared”表示输出结果是共享库类型的;“-fPIC”表示使用地址无关代码(Position Independent Code)技术来生产输出文件。另外还有一个参数是“-Wl”参数,这个参数可以将指定的参数传递给链接器,比如当我们使用“-Wl、-soname、my_soname”时,GCC会将“-soname my_soname”传递给链接器,用来指定输出共享库的SO-NAME。所以我们可以使用如下命令行来生成一个共享库:

gcc –shared –Wl,-soname,my_soname –o library_name source_files library_files

如果我们不使用-soname来指定共享库的SO-NAME,那么该共享库默认就没有SO-NAME,即使用ldconfig更新SO-NAME的软链接时,对该共享库也没有效果。
比如我们有libfoo1.c和libfoo2.c两个源代码文件,希望产生一个libfoo.so.1.0.0的共享库,这个共享库依赖于libbar1.so和 libbar2.so这两个共享库,我们可以使用如下命令行:

gcc –shared -fPIC –Wl,-soname,libfoo.so.1 –o libfoo.so.1.0.0 \
libfoo1.c libfoo2.c \
-lbar1 -lbar2

当然我们也可以把编译和链接的步骤分开,分多步进行:

gcc –c –g –Wall –o libfoo1.o libfoo1.c
gcc –c –g –Wall –o libfoo2.o libfoo2.c
ld –shared –soname libfoo.so.1 –o libfoo.so.1.0.0 \
libfoo1.o libfoo2.o –lbar1 –lbar2

几个值得注意的事项:
不要把输出共享库中的符号和调试信息去掉,也不要使用GCC的“-fomit-frame-pointer”选项,这样做虽然不会导致共享库停止运行,但是会影响调试共享库,给后面的工作带来很多麻烦。

共享库的安装
创建共享库以后我们须将它安装在系统中,以便于各种程序都可以共享它。最简单的办法就是将共享库复制到某个标准的共享库目录,如/lib、/usr/lib等,然后运行ldconfig即可。
不过上述方法往往需要系统的root权限,如果没有,则无法往/lib、/usr/lib等目录添加文件,也无法运行ldconfig程序。

共享库构造和析构函数
很多时候你希望共享库在被装载时能够进行一些初始化工作,比如打开文件、网络连接等,使得共享库里面的函数接口能够正常工作。GCC提供了一种共享库的构造函数,只要在函数声明时加上“attribute((constructor))”的属性,即指定该函数为共享库构造函数,拥有这种属性的函数会在共享库加载时被执行,即在程序的main函数之前执行。如果我们使用dlopen()打开共享库,共享库构造函数会在dlopen()返回之前被执行。
与共享库构造函数相对应的是析构函数,我们可以使用在函数声明时加上“attribute((destructor))”的属性,这种函数会在main()函数执行完毕之后执行(或者是程序调用exit()时执行)。如果共享库是运行时加载的,那么我们使用dlclose()来卸载共享库时,析构函数将会在dlclose()返回之前执行。声明构造和析构函数的格式如下:

void __attribute__((constructor)) init_function(void);
void __attribute__((destructor))  fini_function (void);

当然,这种__attribute__的语法是GCC对C和C++语言的扩展,在其他编译器上这种语法并不通用。

Windows下的动态链接

DLL 简介
DLL即动态链接库(Dynamic-Link Library)的缩写,它相当于Linux下的共享对象。Window系统中大量采用了这种DLL机制,甚至包括Windows的内核的结构都很大程度依赖于DLL机制。Windows下的DLL文件和EXE文件实际上是一个概念,它们都是有PE格式的二进制文件,稍微有些不同的是PE文件头部中有个符号位表示该文件是EXE或是DLL,而DLL文件的扩展名不一定是.dll,也有可能是别的比如.ocx(OCX控件)或是.CPL(控制面板程序)。
DLL的设计目的与共享对象有些出入,DLL更加强调模块化,即微软希望通过DLL机制加强软件的模块化设计,使得各种模块之间能够松散地组合、重用和升级。所以我们在Windows平台上看到大量的大型软件都通过升级DLL的形式进行自我完善,微软经常将这些升级补丁积累到一定程度以后形成一个软件更新包(Service Packs)。比如我们常见的微软Office系列、Visual Studio系列、Internet Explorer甚至Windows本身也通过这种方式升级。
另外,我们知道ELF的动态链接可以实现运行时加载,使得各种功能模块能以插件的形式存在。在Windows下,也有类似ELF的运行时加载,这种技术在Windows下被应用得更加广泛,比如著名的ActiveX技术就是基于这种运行时加载机制实现的。

进程地址空间和内存管理
在早期版本的Windows中(比如Windows 1.x、2.x、3.x),也就是16-bit的Windows系统中,所有的应用程序都共享一个地址空间,即进程不拥有自己独立的地址空间(或者在那个时候,这些程序的运行方式还不能被称作为进程)。如果某个DLL被加载到这个地址空间中,那么所有的程序都可以共享这个DLL并且随意访问。该DLL中的数据也是共享的,所以程序以此实现进程间通信。但是由于这种没有任何限制的访问权限,各个程序之间随意的访问很容易导致DLL中数据被损坏。
后来的Windows改进了这个设计,也就是所谓的32位版本的Windows开始支持进程拥有独立的地址空间,一个DLL在不同的进程中拥有不同的私有数据副本,就像我们前面提到过的ELF共享对象一样。在ELF中,由于代码段是地址无关的,所以它可以实现多个进程之间共享一份代码,但是DLL的代码却并不是地址无关的,所以它只是在某些情况下可以被多个进程间共享。我们将在后面详细探讨DLL代码段的地址相关问题。

基地址和RVA
PE里面有两个很常用的概念就是基地址(Base Address)相对地址(RVA,Relative Virtual Address)。当一个PE文件被装载时,其进程地址空间中的起始地址就是基地址。对于任何一个PE文件来说,它都有一个优先装载的基地址,这个值就是PE文件头中的Image Base。
对于一个可执行EXE文件来说,Image Base一般值是0x400000,对于DLL文件来说,这个值一般是0x10000000。Windows在装载DLL时,会先尝试把它装载到由Image Base指定的虚拟地址;若该地址区域已被其他模块占用,那PE装载器会选用其他空闲地址。而相对地址就是一个地址相对于基地址的偏移,比如一个PE文件被装载到0x10000000,即基地址为0x10000000,那么RVA为0x1000的地址为0x10001000。

程序的内存布局

现代的应用程序都运行在一个内存空间里,在32位的系统里,这个内存空间拥有4GB(2的32次方)的寻址能力。相对于16位时代i386的段地址加段内偏移的寻址模式,如今的应用程序可以直接使用32位的地址进行寻址,这被称为平坦(flat)的内存模型。在平坦的内存模型中,整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意内存位置。例如:

int *p = (int*)0x12345678;
++*p;

这段代码展示了如何直接读写指定地址的内存数据。不过,尽管当今的内存空间号称是平坦的,但实际上内存仍然在不同的地址区间上有着不同的地位,例如,大多数操作系统都会将4GB的内存空间中的一部分挪给内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间。Windows在默认情况下会将高地址的2GB空间分配给内核(也可配置为1GB),而Linux默认情况下将高地址的1GB空间分配给内核,这些在前文中都已经介绍过了。
用户使用的剩下2GB或3GB的内存空间称为用户空间。在用户空间里,也有许多地址区间有特殊的地位,一般来讲,应用程序使用的内存空间里有如下“默认”的区域。
栈:栈用于维护函数调用的上下文,离开了栈函数调用就没法实现。栈通常在用户空间的最高地址处分配,通常有数兆字节的大小。
堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。堆会在10.3节详细介绍。堆通常存在于栈的下方(低地址方向),在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多,可以有几十至数百兆字节的容量。
可执行文件映像:这里存储着可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取或映射到这里。在此不再详细说明。
保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称,例如,大多数操作系统里,极小的地址通常都是不允许访问的,如NULL。通常C语言将无效指针赋值为0也是出于这个考虑,因为0地址上正常情况下不可能有有效的可访问数据。
下图是Linux下一个进程里典型的内存布局。
Linux进程地址空间布局
有一个没有介绍的区域:“动态链接库映射区”,这个区域用于映射装载的动态链接库。在Linux下,如果可执行文件依赖其他共享库,那么系统就会为它在从0x40000000开始的地址分配相应的空间,并将共享库载入到该空间。
图中的箭头标明了几个大小可变的区的尺寸增长方向,在这里可以清晰地看出栈向低地址增长,堆向高地址增长。当栈或堆现有的大小不够用时,它将按照图中的增长方向扩大自身的尺寸,直到预留的空间被用完为止。
Q&A
Q:我写的程序常常出现“段错误(segment fault)”或者“非法操作,该内存地址不能read/write”的错误信息,这是怎么回事?
A:这是典型的非法指针解引用造成的错误。当指针指向一个不允许读或写的内存地址,而程序却试图利用指针来读或写该地址的时候,就会出现这个错误。在Linux或Windows的内存布局中,有些地址是始终不能读写的,例如0地址。还有些地址是一开始不允许读写,应用程序必须事先请求获取这些地址的读写权,或者某些地址一开始并没有映射到实际的物理内存,应用程序必须事先请求将这些地址映射到实际的物理地址(commit),之后才能够自由地读写这片内存。当一个指针指向这些区域的时候,对它指向的内存进行读写就会引发错误。造成这样的最普遍原因有两种:

  1. 程序员将指针初始化为NULL,之后却没有给它一个合理的值就开始使用指针。
  2. 程序员没有初始化栈上的指针,指针的值一般会是随机数,之后就直接开始使用指针。
    因此,如果你的程序出现了这样的错误,请着重检查指针的使用情况。

栈与调用惯例

什么是栈?
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今能够看见的所有的计算机语言。在解释为什么栈会如此重要之前,让我们来先了解一下传统的栈的定义:
在经典的计算机科学中,栈被定义为一个特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO),多多少少像叠成一叠的书:先叠上去的书在最下面,因此要最后才能取出。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使栈减小。
在经典的操作系统里,栈总是向下增长的。在i386下,栈顶由称为esp的寄存器进行定位。压栈的操作使栈顶的地址减小,弹出的操作使栈顶地址增大。
程序栈实例
这里栈底的地址是0xbfffffff,而esp寄存器标明了栈顶,地址为0xbffffff4。在栈上压入数据会导致esp减小,弹出数据使得esp增大。相反,直接减小esp的值也等效于在栈上开辟空间,直接增大esp的值等效于在栈上回收空间。
栈在程序运行中具有举足轻重的地位。最重要的,栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧(Stack Frame)活动记录(Activate Record)。堆栈帧一般包括如下几方面内容:
函数的返回地址和参数。
临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
保存的上下文:包括在函数调用前后需要保持不变的寄存器。
在i386中,一个函数的活动记录用ebp和esp这两个寄存器划定范围。esp寄存器始终指向栈的顶部,同时也就指向了当前函数的活动记录的顶部。而相对的,ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又被称为帧指针(Frame Pointer)。一个很常见的活动记录示例如图10-4所示。
活动记录
在参数之后的数据(包括参数)即是当前函数的活动记录,ebp固定在图中所示的位置,不随这个函数的执行而变化,相反地,esp始终指向栈顶,因此随着函数的执行,esp会不断变化。固定不变的ebp可以用来定位函数活动记录中的各个数据。在ebp之前首先是这个函数的返回地址,它的地址是ebp-4,再往前是压入栈中的参数,它们的地址分别是ebp-8、ebp-12等,视参数数量和大小而定。ebp所直接指向的数据是调用该函数前ebp的值,这样在函数返回的时候,ebp可以通过读取这个值恢复到调用前的值。之所以函数的活动记录会形成这样的结构,是因为函数调用本身是如此书写的:一个i386下的函数总是这样调用的:
把所有或一部分参数压入栈中,如果有其他参数没有入栈,那么使用某些特定的寄存器传递。
把当前指令的下一条指令的地址压入栈中。
跳转到函数体执行。
其中第2步和第3步由指令call一起执行。跳转到函数体之后即开始执行函数,而i386函数体的“标准”开头是这样的(但也可以不一样):
push ebp:把ebp压入栈中(称为old ebp)。

堆与内存管理

相对于栈而言,堆这片内存面临一个稍微复杂的行为模式:在任意时刻,程序可能发出请求,要么申请一段内存,要么释放一段已申请过的内存,而且申请的大小从几个字节到数GB都是有可能的,我们不能假设程序会一次申请多少堆空间,因此,堆的管理显得较为复杂。

什么是堆
光有栈对于面向过程的程序设计还远远不够,因为栈上的数据在函数返回的时候就会被释放掉,所以无法将数据传递至函数外部。而全局变量没有办法动态地产生,只能在编译的时候定义,有很多情况下缺乏表现力。在这种情况下,堆(Heap)是唯一的选择。
堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分。在这片空间里,程序可以请求一块连续内存,并自由地使用,这块内存在程序主动放弃之前都会一直保持有效。下面是一个申请堆空间最简单的例子。

int main()
{
    char * p = (char*)malloc(1000);
    /* use p as an array of size 1000*/
    free(p);
}

在第3行用malloc申请了1000个字节的空间之后,程序可以自由地使用这1000个字节,直到程序用free函数释放它。
那么malloc到底是怎么实现的呢?有一种做法是,把进程的内存管理交给操作系统内核去做,既然内核管理着进程的地址空间,那么如果它提供一个系统调用,可以让程序使用这个系统调用申请内存,不就可以了吗?当然这是一种理论上可行的做法,但实际上这样做的性能比较差,因为每次程序申请或者释放堆空间都需要进行系统调用。我们知道系统调用的性能开销是很大的,当程序对堆的操作比较频繁时,这样做的结果是会严重影响程序的性能的。比较好的做法就是程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,而具体来讲,管理着堆空间分配的往往是程序的运行库。
运行库相当于是向操作系统“批发”了一块较大的堆空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。当然运行库在向程序零售堆空间时,必须管理它批发来的堆空间,不能把同一块地址出售两次,导致地址的冲突。于是运行库需要一个算法来管理堆空间,这个算法就是堆的分配算法。

Linux进程堆管理
从本章的第一节可知,进程的地址空间中,除了可执行文件、共享库和栈之外,剩余的未分配的空间都可以被用来作为堆空间。Linux下的进程堆管理稍微有些复杂,因为它提供了两种堆空间分配的方式,即两个系统调用:一个是brk()系统调用,另外一个是mmap()。brk()的C语言形式声明如下:

int brk(void* end_data_segment)

brk()的作用实际上就是设置进程数据段的结束地址,即它可以扩大或者缩小数据段(Linux下数据段和BSS合并在一起统称数据段)。如果我们将数据段的结束地址向高地址移动,那么扩大的那部分空间就可以被我们使用,把这块空间拿来作为堆空间是最常见的做法之一(我们还将在第12章详细介绍brk的实现)。Glibc中还有一个函数叫sbrk,它的功能与brk类似,只不过参数和返回值略有不同。sbrk以一个增量(Increment)作为参数,即需要增加(负数为减少)的空间大小,返回值是增加(或减少)后数据段结束地址,这个函数实际上是对brk系统调用的包装,它是通过brk()实现的。
mmap()的作用和Windows系统下的VirtualAlloc很相似,它的作用就是向操作系统申请一段虚拟地址空间,当然这块虚拟地址空间可以映射到某个文件(这也是这个系统调用的最初的作用),当它不将地址空间映射到某个文件时,我们又称这块空间为匿名(Anonymous)空间,匿名空间就可以拿来作为堆空间。它的声明如下:

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

mmap的前两个参数分别用于指定需要申请的空间的起始地址和长度,如果起始地址设置为0,那么Linux系统会自动挑选合适的起始地址。prot/flags这两个参数用于设置申请的空间的权限(可读、可写、可执行)以及映射类型(文件映射、匿名空间等),最后两个参数是用于文件映射时指定文件描述符和文件偏移的,我们在这里并不关心它们。

glibc的malloc函数是这样处理用户的空间请求的:对于小于128KB的请求来说,它会在现有的堆空间里面,按照堆分配算法为它分配一块空间并返回;对于大于128KB的请求来说,它会使用mmap()函数为它分配一块匿名空间,然后在这个匿名空间中为用户分配空间。当然我们直接使用mmap也可以轻而易举地实现malloc函数:

void *malloc(size_t nbytes)
{
    void* ret = mmap(0, nbytes, PROT_READ | PROT_WRITE,
        MAP_PRIVATE | MAP_ANONYMOUS, 0, 0);
    if (ret == MAP_FAILED)
        return 0;
    return ret;
}

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

运行库

入口函数
操作系统装载程序之后,首先运行的代码并不是main的第一行,而是某些别的代码,这些代码负责准备好main函数执行所需要的环境,并且负责调用main函数,这时候你才可以在main函数里放心大胆地写各种代码:申请内存、使用系统调用、触发异常、访问I/O。在main返回之后,它会记录main函数的返回值,调用atexit注册的函数,然后结束进程。
运行这些代码的函数称为入口函数或入口点(Entry Point),视平台的不同而有不同的名字。程序的入口点实际上是一个程序的初始化和结束部分,它往往是运行库的一部分。一个典型的程序运行步骤大致如下:

  • 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。
  • 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等。
  • 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。
  • main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。

C语言运行库
任何一个C程序,它的背后都有一套庞大的代码来进行支撑,以使得该程序能够正常运行。这套代码至少包括入口函数,及其所依赖的函数所构成的函数集合。当然,它还理应包括各种标准库函数的实现。
这样的一个代码集合称之为运行时库(Runtime Library)。而C语言的运行库,即被称为C运行库(CRT)
一个C语言运行库大致包含了如下功能:

  • 启动与退出:包括入口函数及入口函数所依赖的其他函数等。
  • 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现。
  • I/O:I/O功能的封装和实现。
  • 堆:堆的封装和实现。
  • 语言实现:语言中一些特殊功能的实现。
  • 调试:实现调试功能的代码。

在这些运行库的组成成分中,C语言标准库占据了主要地位并且大有来头。C语言标准库是C语言标准化的基础函数库,我们平时使用的printf、exit等都是标准库中的一部分。标准库定义了C语言中普遍存在的函数集合,我们可以放心地使用标准库中规定的函数而不用担心在将代码移植到别的平台时对应的平台上不提供这个函数。

系统调用介绍

什么是系统调用?
在现代的操作系统里,程序运行的时候,本身是没有权利访问多少系统资源的。由于系统有限的资源有可能被多个不同的应用程序同时访问,因此,如果不加以保护,那么各个应用程序难免产生冲突。所以现代操作系统都将可能产生冲突的系统资源给保护起来,阻止应用程序直接访问。这些系统资源包括文件、网络、IO、各种设备等。举个例子,无论在Windows下还是Linux下,程序员都没有机会擅自去访问硬盘的某扇区上面的数据,而必须通过文件系统;也不能擅自修改任意文件,所有的这些操作都必须经由操作系统所规定的方式来进行,比如我们使用fopen去打开一个没有权限的文件就会发生失败。
此外,有一些行为,应用程序不借助操作系统是无法办到或不能有效地办到的。例如,如果我们要让程序等待一段时间,不借助操作系统的唯一办法就是使用这样的代码:

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

这样实现等待的确可以勉强达到目的,但是在等待的时候会白白地消耗CPU时间,造成系统资源的浪费,最大的问题是,它将随着计算机性能的变化而耗费不同的时间,比如在100MHz的CPU中,这段代码需要耗费1秒,而在1000MHz的CPU中,可能只需要0.1秒,因此用这段代码来实现定时并不是好办法。使用操作系统提供的定时器将会更加方便并且有效,因为在任何硬件上,代码执行的效果是一样的。
用现代的机器玩某些古老DOS游戏的时候是否会觉得游戏进行得太快?
可见,没有操作系统的帮助,应用程序的执行可谓寸步难行。为了让应用程序有能力访问系统资源,也为了让程序借助操作系统做一些必须由操作系统支持的行为,每个操作系统都会提供一套接口,以供应用程序使用。这些接口往往通过中断来实现,比如Linux使用0x80号中断作为系统调用的入口,Windows采用0x2E号中断作为系统调用入口。
系统调用涵盖的功能很广,有程序运行所必需的支持,例如创建/退出进程和线程、进程内存管理,也有对系统资源的访问,例如文件、网络、进程间通信、硬件设备的访问,也可能有对图形界面的操作支持,例如Windows下的GUI机制。
系统调用既然作为一个接口,而且是非常重要的接口,它的定义将十分重要。因为所有的应用程序都依赖于系统调用,那么,首先系统调用必须有明确的定义,即每个调用的含义、参数、行为都需要有严格而清晰的定义,这样应用程序(运行库)才可以正确地使用它;其次它必须保持稳定和向后兼容,如果某次系统更新导致系统调用接口发生改变,新的系统调用接口与之前版本完全不同,这是无法想象的,因为所有之前能正常运行的程序都将无法使用。所以操作系统的系统调用往往从一开始定义后就基本不做改变,而仅仅是增加新的调用接口,以保持向后兼容。
不过对于Windows来讲,系统调用实际上不是它与应用程序的最终接口,而是API,所以上面这段对系统调用的描述同样适用于Windows API,我们也暂时可以把API与系统调用等同起来。事实上Windows系统从Windows 1.0以来到最新的Windows Vista,这数十年间API的数量从最初1.0时的450个增加到了现在的数千个,但是很少对已有的API进行改变。因为API一旦改变,很多应用程序将无法正常运行。

系统调用原理
现代的CPU常常可以在多种截然不同的特权级别下执行指令,在现代操作系统中,通常也据此有两种特权级别,分别为用户模式(User Mode)内核模式(Kernel Mode),也被称为用户态和内核态。由于有多种特权模式的存在,操作系统就可以让不同的代码运行在不同的模式上,以限制它们的权力,提高稳定性和安全性。普通应用程序运行在用户态的模式下,诸多操作将受到限制,这些操作包括访问硬件设备、开关中断、改变特权模式等。
一般来说,运行在高特权级的代码将自己降至低特权级是允许的,但反过来低特权级的代码将自己提升至高特权级则不是轻易就能进行的,否则特权级的作用就有名无实了。在将低特权级的环境转为高特权级时,须要使用一种较为受控和安全的形式,以防止低特权模式的代码破坏高特权模式代码的执行。
系统调用是运行在内核态的,而应用程序基本都是运行在用户态的。用户态的程序如何运行内核态的代码呢?操作系统一般是通过中断(Interrupt)来从用户态切换到内核态。什么是中断呢?中断是一个硬件或软件发出的请求,要求CPU暂停当前的工作转手去处理更加重要的事情。举一个例子,当你在编辑文本文件的时候,键盘上的键不断地被按下,CPU如何获知这一点的呢?一种方法称为轮询(Poll),即CPU每隔一小段时间(几十到几百毫秒)去询问键盘是否有键被按下,但除非用户是疯狂打字员,否则大部分的轮询行为得到的都是“没有键被按下”的回应,这样操作就被浪费掉了。另外一种方法是CPU不去理睬键盘,而当键盘上有键被按下时,键盘上的芯片发送一个信号给CPU,CPU接收到信号之后就知道键盘被按下了,然后再去询问键盘被按下的键是哪一个。 这样的信号就是一种中断。
中断一般具有两个属性,一个称为中断号(从0开始),一个称为中断处理程序(Interrupt Service Routine, ISR)。不同的中断具有不同的中断号,而同时一个中断处理程序一一对应一个中断号。在内核中,有一个数组称为中断向量表(Interrupt Vector Table),这个数组的第n项包含了指向第n号中断的中断处理程序的指针。当中断到来时,CPU会暂停当前执行的代码,根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它。中断处理程序执行完成之后,CPU会继续执行之前的代码。

笔记

程序的运行流程:
源码;
编译:编译器将源码编译为目标文件 .o中间目标文件, .o文件是elf格式,分为文件头,代码段,数据段等其他段信息,此时.o文件中的符号的地址是相对位置;
链接:链接器将.o文件经过链接,重定位符号的地址,整合成可执行文件,此文件依然是elf格式,包括代码段数据段等,与.o的区别是地址是重定位后的地址;
装载:操作系统将elf文件再度整合,整合标准是段的读写属性。然后操作系统将程序从硬盘加载到虚拟内存,当cpu需要某段程序时由操作系统也错误处理分配物理内存并加载程序;

静态链接:程序运行前就加载好程序段,
动态链接:程序运行时需要某个模块再加载并链接这个模块,性能牺牲大概为5%左右。

参考书目:《程序员的自我修养-链接、装载与库》

posted @ 2020-05-12 06:57  多弗朗强哥  阅读(237)  评论(0编辑  收藏  举报