程序的一生

一、 程序的诞生

1. 概览

程序,是我们天天接触的东西。而且在很大意义上,我们是它们的缔造者,不过,由于被现代化社会劳动的特性所左右,在创造过程中我们大量地使用了各种工具,甚至使得我们对于自己的作品有些什么特质都没有能够充分了解,这不能不说是一件遗憾的事情。

下面是一个 Symbian 程序从源代码以及相关的资源或者数据,生成最终的可执行程序的过程:

这张图有点老,aif 现在已经过时,不过整个过程还基本保持着。

对于 Windows 类型的程序来说,较大的区别在于资源的处理。 Windows 平台允许把资源和可执行代码以及数据等组合(也是链接)在同一个文件中,而没有强制分离(当然也支持分离),而且资源的 ID 完全由用户控制,不存在像 Symbian 那样硬性的必须由资源编译程序( rcomp.exe )生成。

2. 一些细节

2.1. 代码依赖(库)

事实上,以上的图表在 link 步骤处的信息有所省略。link 所需要的输入,除去由源代码直接生成的 .obj 文件之外,还需要有所依赖的 .lib,只不过这些 lib 通常/大多数都不是你亲手写就的。

lib 分为两种,一种是代码/实现本身就在 lib 当中的,库中的代码/实现会被复制到最终的可执行体中,当可执行运行时,将不再有其他相关依赖,这种库被称之为静态链接库,有时简称为静态库,即 static linked library,简称为 static library 或者 static lib。

另外一种 lib,本身体内不包含所需功能的代码/实现,仅包含有一些描述,例如真正的实现是在哪一个文件中(通常是一个动态链接库),函数名字与序号的对应关系,等等。这种库链接之后,最终的可执行体在运行时必须依赖其他的函数实现所在的库文件,否则系统加载就会失败。这种库通常称之为导入库,即 import library,或者 import lib。事实上,可以把这种 lib 看作 static lib 的一个特例。

相对于 static linked library 的概念的,是 dynamic linked library,缩写为 DLL,即我们日常所称呼的动态链接库,或者动态库。这类文件的扩展名在 Windows 类操作系统上通常为 DLL,有一些有专用用途的可能会变,比如 OCX 等,在 Unix 类平台上的扩展名则通常叫做 SO 或者 DSO(含义为 shared object/dynamic shared object)。

动态库有两种使用方式,一种是通过与动态库对应导入库,在编译时就建立依赖关系,另一种是通过操作系统的动态库加载 API,例如 Windows 下的 LoadLibrary,Unix 下的 dlopen,以及 Symbian 平台下的 RLibrary 类。

由于动态链接库的出现,使得原本简单的事情出现了一些复杂化的地方。对于静态链接库中的函数来讲,在链接阶段调用者已经可以准确地知道其函数地址(至少是相对偏移),这样就可以直接生成最终代码;但是如果被调用的函数位于动态库中的话,由于动态库是在运行期才加载的,根本无法在执行体生成期中就知道目标函数的准确地址。这也是“动态”二字的中心思想所在。

导致这些问题的本质原因有若干。首先是,动态链接库在执行期加载到进程中的基地址可能会变化,从而无法得到准确地址或者相对偏移,其次是,由于不可预料的其他因素(例如动态链接库的版本变化),目标函数在动态链接库自身中的位置也不能保证是固定的,更何况,有的函数可能会不存在(例如,执行时遇到了一个老版本的动态链接库,或者要调用到的函数在更新的版本中被移除掉了)。

为了解决上面的问题,操作系统的可执行体中通常都设立了两个表格。其中的一个表格,帮助最终执行体表达出它自己在运行时需要依赖哪些别的动态库(即使执行体本身就是一个动态库,它自己也很可能会依赖其他动态库); 另一个表格则对外说明最终可执行体自己包含了些什么可让其他执行体引用的东西,在什么位置。这两个表格,前一个通常被称作导入表(import table),后一个通常被称作导出表(export table)。

2.2. 数据安排

任何一个程序,都必然会有数据打交道,其中一些数据的值在编译时已经确定,另外一些则可能是运行时才能确定。我粗略地将数据分为了几个类别,编译器和链接器在工作的时候,通常也会按照类别把星罗棋布于程序代码中的数据按类别汇总到一起,然后置入可执行体中。之所以要这样做,是因为操作系统(或者中央处理器)往往会有底层建筑可以保障各种数据的特性要求(例如不许篡改),有的则可以节省内存的开销。

通常链接器把每一类都放到可执行文件的一个节(Section,也有的翻译为段或者块)中。节,是大多数现代操作系统所采用的可执行文件结构的基本单元。

2.2.1. 全局常量

有一部分数据,是在写程序的时候就已经指定了其确切的值的,而且在整个程序的运行期间都不允许对它的值进行修改。这类数据主要为使用 const 修饰的量(其实无所谓是全局的还是局部的),此类数据一般会被汇总到一个单独的节中。这样做的目的有两个,一是对于那些支持页保护的体系上,可以为这些数据所加载到的页增加只读属性,这样就降低了数据被无意或者恶意篡改的风险,一旦有这样的企图,则可能引发严重异常,程序不再继续执行;二是,如果此类数据所在的可执行体被操作系统加载了多次(例如,一个程序的多份实例,或者一个动态库被多个程序同时加载),此类数据仅需要在一份物理内存页上加载,而不必分配多份,节省了内存使用。

Symbian 上之所以长期不允许全局可写数据存在于 DLL 中,就是因为可写全局数据势必是在每个进程中都需要有单独的一份内存占用,而 Symbian 的设计人员认为在 DLL 中全局的可写量数量很少,通常也就几十到几百字节,而解释是操作系统的内存分配最小单元(通常为 4K 的页)也比这个数量大很多,一个进程浪费 3K ,那么这个 DLL 被加载的次数越多,浪费也就越严重,这对于本来资源就紧张的手机系统是不可接受的。

然而事实证明,这种设计思路是狭隘的,高版本的 Symbian 系统已经去除了这一限制就是证明。这一系统的设计缺陷严重地影响了 Symbian 开发人员的工作习惯,也降低了工组效率,对于现有的很多成熟的开源代码也不能很好地支持。最糟糕的是,许多竞争系统在配置相近的硬件平台上没有此类限制也运行得不错,使得这一设计更是弊端凸显。

最后说一下这个节的大小。大小原则上为所有数据的大小之和,当然,最终会根据可执行体的链接设置中的节对齐属性取整。

最后再顺便说一句,相同用途的节,在同一个可执行文件的映像中,可能会存在多个。

2.2.2. 全局已初始化变量

另外的一部分数据,尽管在写程序的时候已经指定了值,但却可能在运行的时候会被更改,这部分数据也会被汇总到单独的一个节中。其大小,原则上等同于上文对全局常量的描述,只不过在运行期加载之后,所在页不能被增加只读的属性。

2.2.3. 全局未初始化变量

还有一部分全局数据,我们在一开始并不知道/并不在意其初始值。同样,这部分数据也会被单独置入一个数据节中,为了节省可执行体的占用空间,此部分数据通常仅在文件中记录一个大小,而不像前面的两种数据那样会占用实际的文件字节。

3. 定局

如果一切顺利(源代码能够正常编译,而且链接时该找的都能找到而又没什么冲突),最终的可执行体就会理所当然地出现在眼前。

无论如何,历尽诸多坎坷崎岖,总算诞生了。

二、程序的活动

程序诞生之后,执行就注定是它的天职了。它总是安安静静地躺在那里,等待别人对它发号司令,这个别人,有时候是最终用户,有时候是别的程序,有时候甚至是它自己。

1. 程序和其他概念的关系

程序、可执行文件/可执行体、映像、模块、进程、任务。

程序(Program)是个静态的概念,体现到表现方式上,那就通常是一个文件。由于程序是可以执行的,所以其载体也被称之为可执行文件(Executable File),或者叫可执行体(Executive)。在现在的操作系统上,可执行文件通常既可以是一个可以直接运行的主程序文件,也可以是一个库文件,但大多数情况下指代前者。

因为可执行文件被加载之后,事实上存在着与进程在内存中的呈现的一种对应关系,因此有时也把可执行文件称为映像(Image)。被加载到内存中的可执行体,无论是主程序还是库,通称之为模块(Module)。

主程序被加载到内存开始执行后,即是产生了一个进程(Process)。除非程序的编制者有意为之,或者系统本身有某种制约,一般一个程序都可以创建多分进程实例。如果拿 C++ 里的属于打个比方的话,程序就是 class,进程就是 object。前者是某种静态描述,而后者可以被看做是有机的活体。

任务(Task)是个逻辑概念,在不同的系统上含义不同。在绝大多数时候,一个进程就是一个任务,不过在 Symbian 上,一个 App 有时也被称作一个任务,并不管它在 EKA1 架构下其实只是一个线程而在 EKA2 架构下成为了一个进程的区别。

2. 程序的加载

程序最开始从存储介质上加载,直到系统把执行的控制权交给它,这段时间我们可以称之为初始化阶段。在这段时间里,程序本身就像一个被催眠了的僵尸,要无条件听从大法师的摆布和安排,这个大法师,就是通常隐身在幕后、为绝大多数人所忽略的 —— 加载器。

系统内存在不止一个加载器,这是迫不得已的事情。要知道整个系统总是从一条指令开始启动的,要经历一个从简单到复杂的环境变迁。在开始的简陋时期,加载器必然只完成一些特定的或者特殊的工作,而不是一个全能的形象。直到整个系统初始化完毕,全能的加载器才会有足够的舞台来施展。而我们上文所提到的,正是这个全能加载器,在 Symbian 系统中,这个家伙和文件服务器厮混在一起。

加载器的主要工作在于把程序从外存上加载到内存中。触发加载器开始工作的,通常是已经运行起来的程序对系统相关 API 的调用,例如,创建进程(Windows 系统上的 CreateProcess 函数族, Symbian 上的 RProcess 类的对应功能),或者动态加载 DLL(Windows 系统上的 LoadLibrary 函数族, Symbian 上的 RLibrary 类的对应功能)。对于加载器来说,创建进程时的工作显然要比加载一个 DLL 要来的复杂许多。

3. 创建进程

抛开各个平台的差异不说,创建进程起码要有以下工作:

1 、检查目标映像是否存在,是否是合法的可执行文件
2 、分配虚拟地址空间
3 、分配足够的物理空间,将必须的内容从映像中读入内存
4 、创建必要的内核对象(进程对象和线程对象)
5 、处理依赖(可能是递归的)

以上的步骤在各系统平台上的执行顺序可能不一致,而且由于内存模型的不同,有可能会有步骤的合并或者更细化的分离。

在第 5 步中,针对于所依赖的每个 DLL,又分别会重复第 1 和第 3 以及第 5 步,而且在某些平台上,会在适当的线程上下文内调用 DLL 的入口点函数(如 Windows 上的 DllMain 函数)。第 1 步很简单,再次不必赘述。第 3 步和第 5 步,对于我们认知程序的内幕则相当重要。

4. 单个模块的处理

在第 3 步中,加载器的主要工作是打开目标映像文件,逐节将之加载到内存中去。根据各个节的链接时指定/自动生成的属性,做相应的处理。到目前为止,我们至少已经知道了几类节,除了它们之外,代码节也是我们耳熟能详的。

4.1. 节的处理

对于代码节,加载器首先需要按照节的大小在内存中分配空间(分配的单位通常是页),然后把映像中的代码复制到内存中。根据代码本身所具有的特征,一般会把这些内存页面的属性置为“可执行、只读”。

对于全局常量所在的节,分配并初始化之后的页面属性通常被置为“只读”。

对于全局已初始化变量所在的节,分配并初始化之后的页面属性通常被置为“读写”。

以上两个节的“初始化”操作,无非也就是把相应内容从文件中读取到内存中。略有不同的是全局未初始化变量的节的处理,内存分配是必不可少的,初始化则变为了简单而又粗暴的内存清零操作(大家都很熟悉的 ZeroMemory 或者 Mem::FillZ 操作)。注意,这一点,正是 C/C++ 语言中“所有未指定初值的全局变量均初始化为零”的保证。

另外还有一些和数据有关的节,例如共享节或者资源节,这些节和可执行文件所运行的平台息息相关,暂时不在此叙述。

接下来要说到的是导出表和导入表的处理。这两个表的处理是有关联的。

我们知道,导出表中存放了本可执行体对外开放的函数(其实也有可能是变量)的名字,以及函数在执行体中的入口偏移,以及其他一些对我们目前的讨论关联不大的数据。当加载一个映像时,此映像在本进程中的起始地址就固定了下来,根据此起始地址,再加上导出表中的偏移信息,则其他的模块对某一函数的引用信息就已经完整了。

在继续之前,此处插一段别的内容。在某些系统(如 Windows)上,一个可执行体在链接时是可以强行指定将来被加载到内存中的起始地址的,如果在加载的时候该地址已经被占用(例如在它之前被加载的其他可执行映像),则加载会失败。如果是一个动态库,而且又是被动态加载的,则仅仅是本模块加载失败而已,如果此库是由于静态依赖而在进程启动初期被加载的,则进程的创建工作也会随之失败。动态库的静态依赖和动态依赖,后文会有描述。

接前文。当依赖的映像全部加载完毕后,加载器就会对本模块中的导入表做填充动作了。逐个遍历导入表中的导入函数,根据所依赖模块的起始地址以及其导出表中的偏移信息,把正确的目标函数地址填入。

导入表和导出表的处理事宜完成之后,它们所占据的内存页属性则与全局常量几无不同。事实上,很多时候链接器会把导入表和导出表与全局常量放到同一个节中。(如是,很显然,全局常量节的只读属性需要是在此填充操作之后才能设置的)。

另外还有一件事情,就是重定位。当模块被加载到了一个并非等同于模块本身所指定的首地址时,函数调用代码中的目标地址就相应地发生了变化,因此加载器还需要根据重定位信息对其进行修正。(很显然,代码节的只读属性需要是在此修正操作之后才能设置的)。

5. 动态加载

前面有所提及的还有,动态库有一种动态加载模式。动态加载一个 DLL,其初衷往往是为了能够实现代码的跨系统版本兼容,而在各自版本的系统上,又能最大限度地利用最新最好的功能。由于是动态的依赖,因而此依赖关系就不会体现在导入表中,也因此,调用到得位于被依赖的模块中的函数就不会由系统自动定位并取其地址,而只能由实现者自行去解决(Windows 平台下的函数是 GetProcAddress,Symbian 下的是 RLibrary::Lookup() 方法,Unix 类系统下则是 dlsym 函数)。

动态依赖的好处是,除非执行流程已经流转到了必要的分支,否则不会加载某些依赖库,可以在一定程度上降低内存消耗,而且,如果所依赖到的函数如果不存在,很可能会由程序编制者选择一个更优雅的容错机制而不是导致程序无法运行,这就使得软件的适应性得到了提高。当然也有缺点,代码编写更为繁琐,往往需要自己定义函数指针的原型,自己去处理函数不存在的情况等等。由于这些个原因,如果存在引用了目标依赖模块中相当多数量的函数的情况,则通常不适于使用动态加载的方式。在 Symbian 平台上,系统 API 的提供大量使用了类的形式,导致函数之间的关联性有了增强,获取单个函数地址即可进行有效使用的可能性被削弱,在一定程度上遏制了动态加载方式的应用。至于被动态起来的模块的静态依赖的处理,则与进程创建时的处理类似。

6. 内存消耗

从上文对可执行文件的加载过程的叙述即可以看出,对于整个系统来说,加载一个模块大概会有以下的内存开销。

代码节和只读的数据节,在理想状态下,仅当此映像在第一次加载时需要分配内存,此后再有其他的加载请求的话,如果是本进程的只需增加引用计数即可,如果是其他进程的则调整虚拟内存的映射关系即可。糟糕的情况发生在,如果在其他进程中加载,其起始地址不能与之前进程中已经加载起来的实例保持一致的话,由于涉及到代码重定位的工作,则不得不重新申请内存。

以上说的是本质上具有只读特性的节。如果不是只读的,必然需要在各个进程中都有独立的副本,占用另外的内存。

除了这些加载映像所必需的内存消耗之外,在进程真正要开始运行之前,还有一些额外的内存需要准备出来。一个是进程的默认堆,一个是主线程所要使用的栈。这两种不同的内存,其区别主要是针对在进程中的用途而言的,对于加载器则没有任何不同,都需要向操作系统申请。在可执行文件中,一般会有指示本程序要求的堆和栈的大小的域,加载器会优先使用这些指定的值去分配,如果没有指定,则加载器将使用相应的默认值。

所指定的进程堆的大小,仅在进程创建时使用一次,而指定的线程的栈的大小则可能会被用到多次。主线程是由加载器创建出来的,我们没有办法动态指定其所使用的栈的大小,因此它必然使用可执行文件中的指定值或者操作系统的默认值。对于新创建的线程,创建代码通常都会指定栈的大小(作为线程创建函数的一个参数传入),如果创建时没有指定,则将采用与主线程相同的值。在现代操作系统中,线程的栈是各自私有的,这也解释了为什么多个线程同时调用相同的函数时,局部变量的值不会互相影响(而全局变量需要做额外的互斥处理)。

还有一点可以说一下。通常进程的默认堆(之所以称作默认堆是因为堆也可以使用代码另外创建)可以被进程中的所有线程访问。在多线程的进程中,如果各个线程均有较为频繁的堆操作(分配或者释放),则势必带来线程间同步引入的时间上的开销,降低执行效率。在这种情况下,建议线程在执行之初就创建属于自己的私有堆,可以提升一定的运行时速度。不过在实践中发现, Symbian 系统上的线程创建时也会创建私有的堆,其验证方法为此线程中分配的内存无法在另外的线程中释放。在 Windows 平台下,当一个模块被加载时也会为此模块生成私有的堆。

7. 和编程语言特性的关联

(TODO: 全局对象的构造和析构等)

三、 程序的消亡

(TODO: 退出、卸载等)

四、 相关资料和推荐书目

PE 可执行文件格式/ELF 可执行文件格式/E32 可执行文件格式
《深入解析 Windows 操作系统》
《 Symbian OS Internals – Real-time Kernel Programming 》
《编程卓越之道》,第一卷,第二卷

五、 后记

本文已经写就经年,文中标记 TODO 处 原计划另行补齐,后来发现有名为《程序员的自我修养——链接、加载与库》的新书上市,其中的阐述比本文更加详尽,遂决定不再更新。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注