可执行文件的生成与加载执行
第一节 可执行文件生成概述
知识点 1 预处理、编译和汇编
一个大的程序往往会分成多个源程序文件来编写,因而需要对各个不同源程序文件分别进行编译和汇编, 以生成 多个不同的目标代码文件。为了生成一个可执行文件,需要将所有关联的目标代码文件,包括用到的标准库函数目标 文件,按照某种形式组合在一起,形成一个具有统一地址空间的可被加载到存储器直接执行的程序。
这种将一个程序的所有关联模块对应的目标代码文件结合在一起, 以形成一个可执行文件的过程称为链接, 由专 门的链接程序(Linker ,也称为链接器)来实现。链接生成的可执行文件可以被加载并在计算机中执行,计算机能自动 逐条取出程序中的指令并执行。
链接概念早在高级编程语言出现前就已存在。在汇编语言代码中,可以用一个标号表示某个跳转目标指令的地址 (即给定了一个标号的定义),而在另一条跳转指令中引用该标号;也可以用一个标号表示某个操作数的地址,而在 某条使用该操作数的指令中引用该标号。
因而,在对汇编语言源程序进行汇编的过程中,需要对每个标号的引用,找到该标号对应的定义,建立每个标号 的引用和其定义之间的关联关系,从而在引用标号的指令中正确地填入对应的地址码字段, 以保证能访问到所引用的 符号定义处的信息。
在高级编程语言出现之后,程序功能越来越复杂,程序规模越来越大,需要多人开发不同的程序模块。
在每个程序模块中,包含一些变量和子程序(函数)的定义。这些被定义的变量和子程序的起始地址就是符号定 义,子程序(函数或过程)的调用或者在表达式中使用变量进行计算就是符号引用。
某一个模块中定义的符号可以被另一个模块引用,因而最终必须通过链接将程序包含的所有模块合并起来,合并 时须在符号引用处填入定义处的地址。
在第 1 章和第 3 章中都提到过,将高级语言源程序转换为可执行文件通常分为预处理、编译、汇编和链接 4 步。 前三步用来对每个模块(即源程序文件)生成可重定位目标文件(Relocatable Object File) 。GCC 生成的可重定位目标 文件后缀为.o ,VS 输出的可重定位目标文件扩展名为.obj 。最后一步为链接,用来将若干可重定位目标文件(可能包括 若干标准库函数目标模块)组合起来,生成一个可执行目标文件(Executable Object File)。
本书将可重定位目标文件和可执行目标文件分别简称为可重定位文件和可执行文件。
下面以GCC 处理 C 语言程序为例说明处理过程。可以通过-v 选项查看 GCC 每一步的处理结果。如果想得到每个处理过程的结果,则可以分别使用-E 、-S 和-c 选项来进行预处理、编译和汇编,对应的处理工具分别为 cpp 、ccl 和 as; 处理后得到文件的文件名后缀分别是.i 、.s 和.o。
1.预处理
预处理是从源程序变成可执行文件的第一步,C 预处理程序为 cpp(即 CPreprocessor),主要用于 C 语言编译器对 各种预处理命令进行处理,包括对头文件的包含、宏定义的扩展、条件编译的选择等。
例如,对#include 指示的处理结果,就是将相应.h 文件的内容插入源程序文件中。
GCC 中的预处理命令是“gcc -E ”或“cpp ”,
例如,可用命令“gcc -E main.c -O main.i ”或“cpp main.c-o main.i ”将 main.c 转换为预处理后的文件 main.i。
2.编译
C 编译器在进行具体的程序翻译前,会先对源程序进行词法分析、语法分析和语义分析,然后根据分析的结果进行 代码优化和存储分配,最终把 C 语言源程序翻译成汇编语言程序。编译器通常采用对源程序进行多次扫描的方式进行 处理,每次扫描集中完成一项或几项任务,也可以将一项任务分散到几次扫描去完成。
例如,可以按照以下四趟扫描进行处理:
第一趟扫描进行词法分析;
第二趟扫描进行语法分析;
第三趟扫描进行代码优化和存储分配;
第四趟扫描生成代码。
GCC 可以直接产生机器语言代码,也可以先产生汇编语言代码,然后再通过汇编程序将汇编语言代码转换为机器 语言代码。
GCC 中的编译命令是“gcc -S ”或“ccl ”,
例如,可使用命令“gcc-S main.i-o main.s ”或“ccl main.i-o main.s ”对 main.i 进行编译并生成汇编代码文件 main.s, 也可以使用命令“gcc-S main.c-omain.s ”或“gcc -S main.c ”直接对 main.c 预处理并编译生成汇编代码文件 main.s。
3.汇编
汇编的功能是将编译生成的汇编语言代码转换为机器语言代码。因为通常最终的可执行文件由多个不同模块对应 的机器语言目标代码组合而成,所以,在生成单个模块的机器语言目标代码时,不可能确定每条指令或每个数据最终
的地址,即单个模块的机器语言目标代码需要重新定位,因此,通常把汇编生成的机器语言目标文件称为可重定位目
标文件。
GCC 中的汇编命令是“gcc-c ”或“as ”命令。
例如,可用命令“gcc -c main.s-0main.o ”或“as main.s-o main.o ”对汇编语言代码文件 main.s 进行汇编,以生成可 重定位文件 main.o 。也可以使用命令“gcc -c main.c -o main.o ”或“gcc -c main.c ”直接对 main.c 进行预处理并编译生成 可重定位文件 main.o。
知识点 2 程序的链接过程
链接的功能是将所有关联的可重定位目标文件组合起来,以生成一个可执行目标文件。 例如,图所示的两个模块 main.c 和 test.c,
假定通过预处理、编译和汇编,分别生成了可重定位目标文件 main.o 和test.o,则可以用命令“gcc -o test main.o test.o ” 或“ld -o test main.o test.o ”来生成可执行文件 test。
这里,ld 是静态链接器命令。也可以用一个命令“gcc-o test main.c test.c ”来实现对源程序文件 main.c 和 test.c 的 预处理、编译和汇编,并将两个可重定位文件 main.o 和 test.o 进行链接,最终生成可执行文件文件 test。
命令“gcc-o test main.c test.c ”的处理过程如图所示。

可重定位文件和可执行文件都是机器语言目标文件,所不同的是前者是单个模块生成的,而后者是多个模块组合 而成的。
因而,对于前者,代码总是从 0 开始,而对于后者,代码在 ABI 规范规定的虚拟地址空间中产生。
例如,通过“objdump -d test.o ”命令显示的可重定位文件 test.o 的结果如下。

通过“objdump -d test ”命令显示的可执行文件 test 的结果如下。

上述给出的通过 objdump 命令输出的结果包括指令的地址、机器代码和反汇编出来的汇编代码。
可以看出,在可重定位文件 test.o 中 add 函数的起始地址为 0 ;而在可执行文件 test 中 add 函数的起始地址为 0x80483d4。
实际上,可重定位文件和可执行文件都不是可以直接显示的文本文件,而是不可显示的二进制文件,它们都按照 一定的格式以二进制字节序列构成一种目标文件,其中包含二进制代码区、只读数据区、初始化数据区和未初始化数 据区等,每个信息区称为一个节(Sec-tion),如代码节(.text)、只读数据节(.rodata)、已初始化全局数据节(.data) 和未初始化全局数据节( .bss)等。
链接器在将多个可重定位文件组合成一个可执行文件时,主要完成符号解析和重定位两个任务。
1.符号解析
符号解析的目的是将每个符号的引用与一个确定的符号定义建立关联。符号包括全局静态变量名和函数名,而非 静态局部变量名则不是符号。
例如,对于图 4.1 所示的两个源程序文件 main.c 和 test.c,在 main.c 中定义了符号 main,并引用了符号 add;在 test.c 中则定义了符号 add,而入口参数 i、j 和非静态局部变量 x 都不是符号。链接时需要将 main.o 中引用的符号 add 和 test.o 中定义的符号 add 建立关联。
再例如,全局变量声明“int *xp=&x; ”中,通过引用符号 x 来对符号 xp 进行了定义。编译器将所有符号存放在可 重定位文件的符号表(Symbol Table)中。
2.重定位
可重定位文件中的代码区和数据区都是从地址 0 开始的。链接器需要将不同模块中相同的节合并起来生成一个新 的单独的节,并将合并后的代码区和数据区按照 ABI 规范确定的虚拟地址空间划分(也称存储器映像)来重新确定位 置。
例如,对于 IA-32+Linux 系统存储器映像,其只读代码段总是从地址 0x8048000 开始,而可读可写数据段总是在只 读代码段后面的第一个 4KB 对齐的地址处开始。因而链接器需要重新确定每条指令和每个数据的地址,并且在指令中 需要明确给定所引用符号的地址,这种重新确定代码和数据的地址并更新指令中被引用符号地址的操作称为重定位 (Relocation)。
使用链接的第一个好处就是“模块化 ”,它能使一个程序被划分成多个模块, 由不同的程序员进行编写,并且可 以构建公共的函数库(如数学函数库、标准 I/0 函数库等) 以提供给不同的程序进行重用。
采用链接的第二个好处是“效率高 ”。每个模块可以分开编译,在程序修改时只须重新编译那些修改过的源程序文件,然后再重新链接,从时间上来说,能够提高程序开发的效率; 同时,因为源程序文件中无须包含共享库的所有 代码,只要直接调用即可,而且在可执行文件运行时的内存中,也只需要包含所调用函数的代码而不需要包含整个共 享库,因而链接也有效地提高了空间利用率。
第二节 目标文件格式
知识点 1 ELF 目标文件格式
目标代码(Object Code)指编译器或汇编器处理源代码后所生成的机器语言目标代码。 目标文件(Object File)指 存放目标代码的文件。
目标文件中包含可直接被 CPU 执行的机器代码以及代码在运行时使用的数据,还有其他的如重定位信息和调试信 息等,不过。 目标文件中唯一与运行时相关的要素是机器代码及其使用的数据,例如,用于嵌入式系统的目标文件可 能仅仅含有机器代码及所用数据。
最初。不同的计算机都拥有自身独特的目标文件格式,但随着 UNIX 和其他可移植操作系统的问世,人们定义了一 些标准目标文件格式,并在不同的系统上使用它们。最简单的是 DOS 的 COM 文件格式,它仅由代码和数据组成。而且 始终被加载到某个固定位置。
其他的目标文件格式(如 COFF 和 ELF)都比较复杂。由一组严格定义的数据结构序列组成,这些复杂格式的规范 说明书一般会有许多页。
System V UNIX 的早期版本使用的是通用 目标文件格式(Common Object FileFormat ,COFF) 。Windows 使用的是 COFF 的一个变种,称为可移植可执行格式(Portable Executable ,PE)。
现代 UNIX 操作系统,如 Linux 、BSD Unix 等,主要使用可执行可链接格式(Executable and Linkable Format ,ELF)。 本章采用 ELF 标准二进制文件格式进行说明。
ELF 目标文件既可用于程序的链接,也可用于程序的执行。图说明了 ELF 目标文件格式的基本框架。

右图是链接视图,主要由不同的节组成。节是 ELF 文件中具有相同特征的最小可处理信息单位,不同的节描述了目标文件中不同类型的信息及其特征,
例如,代码节(.text)、只读数据节(.rodata)、已初始化的全局数据节(.data)、未初始化的全局数据节(,bss) 等。
右图是链接视图,主要由不同的节组成。节是 ELF 文件中具有相同特征的最小可处理信息单位,不同的节描述了 目标文件中不同类型的信息及其特征,
例如,代码节(.text)、只读数据节(.rodata)、已初始化的全局数据节(.data)、未初始化的全局数据节(,bss) 等。图 4.3b 是执行视图,主要由不同的段(Segment)组成,描述了目标文件中的节如何映射到存储空间的段中,可 以将多个节合并后映射到同一个段,例如。可以合并节,data 和节.bss 的内容,并映射到一个可读可写的数据段中。

右图是执行视图,主要由不同的段(Segment)组成,描述了目标文件中的节如何映射到存储空间的段中,可以将 多个节合并后映射到同一个段,
例如。可以合并节,data 和节.bss 的内容,并映射到一个可读可写的数据段中。

前面提到通过预处理、编译和汇编三个步骤后,可生成可重定位目标文件。
多个关联的可重定位目标文件经过链接后生成可执行目标文件。
这两类目标文件对应的 ELF 视图不同;显然,可重定位目标文件对应链接视图,而可执行目标文件对应执行视图。
ELF 文件中的节头表包含其中各节的说明信息,每个节在该表中都有一个与之对应的项,每一项都指定了节名和节 大小之类的信息。用于链接的目标文件必须具有节头表,例如,可重定位文件就一定要有节头表。程序头表用来指示 系统如何创建进程的存储器映像,用于创建进程存储映像的可执行文件和共享库文件必须具有程序头表,而可重定位 目标文件无需程序头表。
知识点 2 可重定位目标文件格式
可重定位文件主要包含代码部分和数据部分,它可以与其他可重定位文件链接,从而创建可执行文件或共享库文 件。如图 所示,ELF 可重定位文件由 ELF 头、节头表以及各个不同的节组成。

1.ELF 头
ELF 头位于目标文件的起始位置,包含文件结构说明信息。ELF 头的数据结构分 32 位系统对应结构和 64 位系统对 应结构。以下是 32 位系统对应的数据结构,共占 52B。

64 位系统对应的数据结构为 Elf64_Ehdr,占 64B,其中描述的成员与 Elf32_Ehdr 类似。文件开头几个字节称为魔数, 通常用来确定文件的类型或格式。在加载或读取文件时,可用魔数确认文件类型是否正确。在 32 位 ELF 头的数据结构中,
Ø 字段 e_ident 是一个长度为 16 的字节序列,其中,最开始的4B 为魔数,用来标识是否为 ELF 文件,第一字节为 0x7F ,后面三字节分别为“E ”“L ”“F ”。再后面的 12B 中,主要包含一些标识信息,如标识是 32 位还是 64 位格式、 标识数据按小端还是大端方式存放、标识 ELF 头的版本号等;
Ø e_type 用于说明目标文件的类型是可重定位文件、可执行文件、共享库文件,还是其他类型文件;
Ø e_machine 用于指定机器结构类型,如 IA-32 、SPARC V9 、AMD64 等;
Ø e_version 用于标识目标文件版本;
Ø e_entry 用于指定程序的起始虚拟地址(入口点),如果文件没有关联的入口点,则为零,对于可重定位文件此 字段为 0;
Ø e_ehsize 用于说明 ELF 头的大小(以字节为单位);
Ø e_shoff 指出节头表在文件中的偏移量(以字节为单位);
Ø e_shentsize 表示节头表中一个表项的大小(以字节为单位),所有的表项大小相同
Ø e_shnum 表示节头表中的项数,e_shentsize 和 e_shnum 共同确定节头表大小(以字节为单位)。
仅 ELF 头在文件中具有固定位置,即总是在最开始的位置,其他部分的位置由 ELF 头和节头表指出,不需要具有固 定的顺序。
可以使用 readelf -h 命令对 ELF 头进行解析。
例如,以下是通过“readelf -h main.o ”对某 main.o 文件进行解析的结果。

从上述解析结果可以看出,该 main.o 文件中,ELF 头长度(e_ehsize)为 52B,因为是可重定位文件,所以字段 e_entry (Entry point address)为 0 ,无程序头表(Size of progra mheaders=0)。
节头表离文件起始处的偏移( e_shoff)为 516B ,每个表项大小( e_shentsize) 占 40B ,表项数( e_shnum)为 15 个。
字符串表( .strtab 节)在节头表中的索引(e_shstrndx)为 12。
2.节
节是 ELF 文件中的主体信息,包含了链接过程所用的目标代码信息,包括指令、数据符号表和重定位信息等。一 个典型的 ELF 可重定位目标文件中包含下面几个节。
.text: 目标代码部分。
.rodata:只读数据,如 printf 语句中的格式串、开关语句(如 switch-case)的跳转表等。
data:已初始化且初值不为 0 的全局变量和静态变量。
.bss:所有未初始化或初始化为 0 的全局变量和静态变量。因为未初始化变量没有具体的值,所以无须在目标文件 中分配用于保存值的空间,即它在目标文件中不占据实际的盘空间,仅是一个占位符。运行时在存储器中再为这些变 量分配空间,并设定初始值为 0 。 目标文件中区分初始化和未初始化变量是为了提高盘空间利用率。
对于 auto 型局部变量,它们在运行时被分配在栈中,因此既不出现在.data 节,也不出现在,bss 节。
.symtab:符号表(Symbol Table)。在程序中被定义的函数名和全局静态变量名都属于符号,与这些符号相关的信 息被保存在符号表中。每个可重定位目标文件都有一个 symtab 节。
.rel.text:.text 节相关的可重定位信息。当链接器将某个目标文件和其他目标文件组合时,.text 节中的代码被合并 后,一些指令中引用的操作数地址信息或跳转目标指令位置信息等都可能要被修改,因此需要说明指令如何进行重定 位。
.rel.data:.data 节相关的可重定位信息。当链接器将某个目标文件和其他目标文件组合时,.data 节被合并后,一 些全局变量的地址可能被修改,因此需要说明数据如何进行重定位。
.debug:调试用符号表,有些表项对定义的局部变量和类型定义进行说明,有些表项对定义和引用的全局静态变量 进行说明。只有使用带-g 选项的 gcc 命令才会得到这张表。
.line:C 源程序中的行号和.text 节中机器指令之间的映射。只有使用带-g 选项的 gcc 命令才会得到这张表。
.strtab:字符串表,包括.symtab 节和.debug 节中的符号以及节头表中的节名。字符串表就是以 null 结尾的字符串 序列。
3.节头表
节头表由若干个表项组成,每个表项描述相应节的节名、在文件中的偏移、大小、访问属性、对齐方式等, 目标 文件中的每个节都有一个表项与之对应。除 ELF 头之外,节头表是 ELF 可重定位目标文件中最重要的一部分内容。
以下是 32 位系统对应的数据结构,节头表中每个表项占 40B。

64 位系统对应的数据结构为 Elf64_Shdr , 占 64B ,其中描述的成员与 Elf32_Shdr 类似。可以使用 readelf -S 命令对 某个可重定位目标文件的节头表进行解析。
例如, 以下是通过“readelf -S test.o ”对某 test.o 文件进行解析的结果。根据每个节在文件中的偏移地址和长度, 可以画出可重定位目标文件 test.o 的结构

知识点 3 可执行目标文件格式
链接器将相互关联的可重定位目标文件中相同的代码数据节(如,text 节、,rodata 节、.data 节和,bss 节)合并, 以形成可执行目标文件中对应的节。因为相同的代码数据节合并后。在可执行目标文件中各条指令之间、各个数据之 间的相对位置就可以确定。因而所定义的函数(过程)和变量的起始位置就可以确定,也即每个符号的定义(即符号
所在的首地址)即可确定,从而在符号的引用处可以根据确定的符号定义进行重定位。
ELF 可执行目标文件由 ELF 头、程序头表、节头表以及各个不同的节组成,如图。
可执行文件格式与可重定位文件格式类似,例如,这两种格式中,ELF 头的数据结构一样,,text 节、,rodata 节 和,data 节中除了有些重定位地址不同以外,大部分都相同。

与 ELF 可重定位文件格式相比,ELF 可执行文件的不同点主要有以下几个方面。
1)ELF 头中字段 e_entry 给出程序执行入口地址,可重定位文件中此字段为 0。
2)通常会有.init 节和.fini 节,其中,init 节定义一个 init 函数,用于可执行文件开始执行时的初始化工作,当程序 开始运行时,系统会在进程进入主函数 main 之前,先执行这个节中的指令代码。,fini 节中包含进程终止时要执行的 指令代码,当程序退出时,系统会执行这个节中的指令代码。
3)少了.rel ,text 和.rel ,data 等重定位信息节。因为可执行文件中的指令和数据已被重定位,故可去掉用于重定 位的节。
4)多了一个程序头表,也称段头表(Segment Header Table),它是一个结构数组。可执行文件中所有代码位置连 续,所有只读数据位置连续,所有可读可写数据位置连续。如图所示,因而在可执行文件中,ELF 头、程序头表、.init 节、.fini 节、.text 节和,rodata 节合起来可构成一个只读代码段;.data 节和.bss 节合起来可构成一个可读/写数据段 (Read/WriteData Segment)。显然,在可执行文件启动运行时,这两个段必须分配存储空间并装入内存,因而称为可 装入段。

为了在可执行文件执行时能够在内存中访问到代码和数据。必须将可执行文件中这些连续的具有相同访问属性的 代码和数据段映射到存储空间中。
程序头表就用于描述这种映射关系,一个表项对应一个连续的存储段或特殊节。程序头表的表项大小和表项数分 别由 ELF 头中 e_phentsize 和 e_phnum 字段指定。
32 位系统的程序头表中每个表项具有以下数据结构:

64 位系统对应的数据结构为 Elf64_Phdr,其中描述的成员与 Elf32_Phdr 类似,出于对齐考虑,Elf64_Phdr 将 p_flags 移到了 p_offset 之前。
P_type 描述存储段的类型或特殊节的类型。例如,
是否为可装入段(PT_LOAD),
是否是特殊的动态节(PT_DYNAMIC),
是否是特殊的解释程序节(PT_INTERP)。
p_offset 指出本段的首字节在文件中的偏移地址。
p_vaddr 指出本段首字节的虚拟地址。
p_paddr 指出本段首字节的物理地址,因为物理地址由操作系统根据情况动态确定,因而该信息通常是无效的。 p_filesz 指出本段在文件中所占的字节数,可以为 0。
p_memsz 指出本段在存储器中所占字节数,也可以为 0。
p_flags 指出存取权限。
p_align 指出对齐方式,用一个模数表示,为 2 的正整数幂,通常模数与页面大小相关,若页面大小为 4KB ,则模 数为 212。
图给出了使用“readelf -l main ”命令显示的可执行文件 main 的程序头表中的部分。
图给出的程序头表中有 8 个表项,其中有两个是可装入段(Type=LOAD)对应的表项信息。

第一个可装入段对应可执行目标文件中第 0x00000~0x004d3 字节的内容(包括 ELF 头、程序头表以及.init 、.text 和.rodata 节等),被映射到虚拟地址 0x8048000 开始的长度为 0x004d4 字节的区域,按 0x1000=212=4KB 对齐,具有只 读/执行权限(Flg=RE),它是一个只读代码段。

第二个可装入段对应可执行目标文件中第 0x000f0c 开始的长度为 0x00108 字节的内容(即.data 节),被映射到虚拟地址 0x8049f0c 开始的长度为 0x00110 字节的存储区域,在 0x00110=272 字节的存储区中,前 0x00108=264B 用.data节的内容来初始化,而后面的 272-264=8B 对应.bss 节,被初始化为 0 ,该段按 0x1000=4KB 对齐,具有可读可写权限 (Flg=RW),因此,它是一个可读/写数据段。

从这个例子可以看出。.data 节在可执行目标文件中占用了相应的盘空间,在存储器中也需要给它分配相同大小的 空间;而.bss 节在文件中不占用盘空间。但在存储器中需要给它分配相应大小的空间。
知识点 4 可执行文件的存储器映像
对于特定系统。可执行文件与虚拟地址空间之间的存储器映像(Memory Mappíng) 由 ABI 规范定义。
例如,对于 IA-32+linux 系统,i386 System V ABI 规范规定,只读代码段总是映射到虚拟地址为 0x8048000 开始的一 段区域;
可读/写数据段映射到只读代码段后面按 4KB 对齐的高地址上。其中,bss 节所在存储区在运行时被初始化为 0。运 行时堆(Run-Time Heap)则在可读/写数据段后面 4KB 对齐的高地址处。通过调用 malloc()库函数动态向高地址分配 空间。而运行时用户栈(UserStack)则从用户空间的最大地址往低地址方向增长。
堆区和栈区中间有一块空间保留给共享库目标代码。用户栈区以上的高地址区是操作系统内核的虚拟存储区。对 于图 4.7 所示的可执行文件 main ,对应的存储器映像如图所示。
图中左边为可执行文件 main 中的存储信息,右边为虚拟地址空间中的存储信息。

可以看出,可执行文件最开始长度为 0x004d4 的可装入段映射到虚拟地址 0x8048000 开始的只读代码段:可执行 文件中从 0x00f0c 到 0x01013 之间为,data 节和.bss 节(实际上都是,data 节信息,而,bss 节不占盘空间)。

映射到虚拟地址 0x8049000 开始的可读/写数据段,其中,data 节从 0x8049f0c 开始,共占 0x00108=264 字节,随
后的 8B 空间分配给,bss 节中定义的变量,初值为 0。

第三节 符号解析与重定位
知识点 1 符号和符号表
目标文件中有一个符号表。表中包含了在程序模块中定义的所有符号的相关信息。对于某个 C 程序模块 m 来说。 包含在符号表中的符号有以下三种不同类型。
1)在 m 中定义并被其他模块引用的全局符号(Global Symbol)。这类符号包括非静态的函数名和全局变量名。
2) 由其他模块定义并被 m 引用的全局符号,称为 m 的外部符号(External Symbol),包括在 m 中引用的在其他 模块定义的外部函数名和外部变量名。
3)在 m 中定义并在 m 中引用的本地符号(Local Symbol) 。这类符号包括带 static 属性的函数名和全局变量名。 这类在模块内部定义的带 static 属性的本地变量不在栈中管理,而是分配在静态数据区。即编译器为它们在节.data 或.bss 中分配空间。如果在 m 内有两个函数使用了同名 static 本地变量,则需要为这两个变量都分配空间,并作为两个不同 的符号记录到符号表中。
注意,上述三类符号不包括分配在栈中的非静态局部变量(auto 变量),链接器不需要这类变量的信息,因而它 们不包含在由节.symtab 定义的符号表中。
例如,对于图给出的两个源程序文件 main.c 和 swap.c 来说,在 main.c 中的全局符号有 buf 和 main ,外部符号有 swap;在 swap.c 中的全局符号有 bufp0 、bufp1 和 swap ,外部符号有 buf 。swap.c 中的 temp 是局部变量,是在运行时 动态分配的,因此,它不是符号,不会被记录在符号表中。

ELF 文件中包含的符号表中每个表项具有以下数据结构。

64 位系统对应的数据结构为 Elf64_Sym ,其中成员的描述与 Elf32_Sym 类似。
字段 st_name 给出符号在字符串表中的索引(字节偏移量) ,指向在字符串表( .strtab 节) 中的一个以 null 结尾 的字符串,即符号。
st_value 给出符号的值,在可重定位文件中是指符号所在位置相对于所在节起始位置的字节偏移量。例如,图 4, 9 中 main ,c 的符号 4 在,data 节中,其偏移量为 0 。在可执行目标文件和共享目标文件中,st_alue 则是符号所在的虚 拟地址。
st_size 给出符号所表示对象的字节个数。若符号是函数名,则是指函数所占字节个数;若符号是变量名,则是指 变量所占字节个数。如果符号表示的内容改有大小或大小未知,则值为 0。
字段 st_info 指出符号的类型和绑定属性,从以下定义的宏可以看出,符号类型占低 4 位,符号绑定属性占高 4 位。

号类型可以是未指定(NOTYPE)、变量(OBJECT)、函数(FUNC)、节(SECNON)等。
当类型为“节 ”时,其表项主要用于重定位。绑定属性可以是本地(I0CAL)。
全局(GLOBAL)、弱(WEAK)等。其中,本地符号指在包含其定义的目标文件的外部是不可见的。 名称相同的 本地符号可存在于多个文件中而不会相互干扰。
全局符号对于合并的所有目标文件都可见。
弱符号是通过 CCC 扩展的属性指示符 attribute((weak) )指定的符号,它与全局符号一样,对于所有被合并目 标文件都可见。
字段 st_other 指出符号的可见性。通常在可重定位文件中指定可见性,它定义了当符号成为可执行文件或共享目 标库的一部分后访问该符号的方式。
字段 st_shndx 指出符号所在节在节头表中的索引,有些符号属于三种特殊伪节(PseudoSection)之一,伪节在节 头表中没有相应的表项,无法表示其索引值,因而用以下特殊的索引值表示:
ABS 表示该符号不会被重定位:
UNDEF 表示未定义符号。即在本模块引用而在其他模块定义的外部符号;
COMMON 表示未被分配位置的未初始化的变量,称为 COMMON 符号,对应 st_value 字段给出其对齐要求,st_size 字段给出其最小长度。
可通过 GNU READELF 工具显示符号表。例如,对于图 4 ,9 中 main ,c 和 swap ,c ,可便用命令“readelf -smain, o ”查看 main ,o 中的符号表,其最后三个表项显示结果如图
可看出,main 模块的三个全局符号中,buf 是变量(Type=OBJECT),位于节头表中第三个表项(Ndx=3)对应的, data 节中偏移量为 0(Value=0)处, 占 8B(Size=8);
main 是函数(Type=FUNC),位于节头表中第一个表项对应的,text 节中偏移量为 0 处, 占 17B;
swap 是未指定(NOTYPE)且无定义(UND)的符号,说明 swap 是在 main 中被引用的由外部模块定义的符号。

swap.o 符号表中最后 4 个表项结果如图.可看出,swap 模块的 4 个符号都是全局符号, bufp0 位于节头表中第三个表项对应的.data 节中偏移量为 0 处, 占 4B;
buf 是未指定的且无定义的全局符号,说明 buf 是在 swap 中被引用的由外部模块定义的符号;

swap 是函数,位于节头表中第一个表项对应的.text 节中偏移量为 0 处, 占 39B;
bufp1 是未分配位置且未初始化(Ndx=COM) 的全局变量,按 4B 边界对齐,至少占 4B 。注意,swap 模块中的变 量 temp 是自动变量,因而不在符号表中说明。

知识点 2 符号解析和静态链接
符号解析的目的是将每个模块中引用的符号与某个目标模块中的定义符号建立关联。每个定义符号在代码段或数 据段中都被分配了存储空间,因此,将引用符号与对应的定义符号建立关联后,就可以在重定位时将引用符号的地址 重定位为相关联的定义符号的地址。
对于在同一个模块中定义且引用的本地符号的符号解析比较容易,因为编译器会检查每个模块中的本地符号是否 具有唯一的定义,所以只要找到第一个本地定义符号与之关联即可。本地符号在可重定位文件的符号表中特指绑定属 性为 LOCAL 的符号,包括所有在.text 节中定义的带 static 属性的函数, 以及在.data 节和.bss 节中定义的所有被初始化 或未被初始化的带 static 属性的静态变量。
对于跨模块的全局符号,因为在多个模块中可能会出现对同名全局符号进行多重定义,所以链接器需要确认以哪 个定义为准来进行符号解析。
1.全局符号的解析规则
编译器在对源程序编译时,会把每个全局符号的定义输出到汇编代码文件中,汇编器通过对汇编代码文件的处理, 在可重定位文件的符号表中记录全局符号的特性, 以供链接时全局符号的符号解析所用。
一个全局符号可能是函数,或者是.data 节中具有特定初始值的全局变量(如图 4.1 中 main.c 的全局变量 a),或 者是.bss 节中被初始化为 0 的全局变量(如图 4.1 中 main.c 的全局变量 b),或者是说明为 COMMON 伪节的未初始化 全局变量(即 COMMON 符号),还可能是绑定属性为 WEAK 的弱符号。
为便于说明全局符号的多重定义问题,本书将前三类全局符号(即函数、.data 节和.bss 节中的全局变量)统称为 强符号。
●规则 3 :若同时出现 COMMON 符号定义和弱符号定义,则按 COMMON 符号定义为准。
●规则 4:若一个 COMMON 符号出现多次定义。则以其中占空间最大的一个为准。因为符号表中仅记录 COMMON 符号的最小长度,而不会记录变量的类型,因此在链接器确定多重 COMMON 符号的唯一定义时,以最小长度中的最大 值为准进行符号解析.能够保证满足所有同名 COMMON 符号的空间要求。
●规则 5 :若使用编译选项-fno-common ,则不考虑 COMMON 符号,相当于将 COMMON 符号作为强符号处理。
例如,对于图所示例子,x 在两个模块中都被定义为强符号,y 在 main 模块定义为 COMMON 符号,而在 p1 模块 是定义在.bss 节的强符号,因此,链接器会由于 x 的两次强符号定义而输出一条出错信息

考察图所示例子中的符号 y 和符号 z 的情况。符号 y 在 main.c 中是强符号,在 p1.c 中是 COMMON 符号,根据规 则 2 可知,链接器将 main.o 符号表中的符号 y 作为其唯一定义符号,而在 p1 模块中的 y 作为引用符号,其地址等于 main 模块中定义符号 y 的地址,也即这两个 y 是同一个变量。在 main 函数调用 p1 函数后,y 的值从初始化的 100 被 修改为 200 ,因而,在 main 函数中用 printf 打印出来后 y 的值为 200 ,而不是 100。

符号 z 在 main 和 p1 模块中都没有初始化,在两个模块中都是 COMMON 符号,根据规则4 可知,链接器将其中占 空间较大的符号作为唯一定义符号,因此,链接器将 main 模块中定义符号 z 作为唯一定义符号,而在 p1 模块中的 z 作为引用符号,符号 z 的地址为 main 模块中定义的地址。在 main 函数调用 p1 函数后,z 的值从 1000 被修改为 2000, 因而,在 main 函数中用 printf 打印出来后 z 的值为 2000 ,而不是 1000。

上述例子说明,如果在两个不同模块定义相同变量名,那么很可能会发生程序员意想不到的结果。特别当两个重 复定义的变量具有不同类型时,更容易出现难以理解的结果。
例如,对于图所示的例子,全局变量 d 在 main 模块中为 int 型强符号,在 p1 中是 double 型 COMMON 符号。根据 规则 2 可知,链接器将 main.o 符号表中的符号 d 作为其唯一定义符号,其地址和所占字节数等于 main 模块中定义符 号 d 的地址和字节数,因此长度为 4B ,而不是 double 型变量的 8B。

由于 p1.c 中的 d 为引用,因而其地址与 main 中变量 d 的地址相同,在 main 函数调用 p1 函数后,地址&d 中存放 的是 double 型浮点数 1.0 对应的低 32 位机器数 00000000H,地址&x 中存放的是 double 型浮点数 1.0 对应的高 32 位机 器数 3FF00000H(对应真值为 1072693248),如图所示。因而,在 main() 函数中用 printf 打印出来后 d 的值为 0 ,x 的值是 1072693248。
可见 x 的值被 pl.c 中的变量 d 给冲掉了。这里,double 型浮点数 1.0 的机器数为 3FF0000000000000H ,以小端方式 存放。

上述由于多重定义变量引起的值的改变往往是在没有任何警告的情况下发生的,而且通常是在程序执行了一段时 间后才表现出来,并且远离错误发生源,甚至错误发生源在另一个模块。对于由成千上万个模块组成的大型程序的开 发,这种问题将更加麻烦,如果变量定义不规范,那将很难避免这类错误的发生。最好使用相应的选项命令-fno-common, 告诉链接器在遇到多重定义的全局符号时,触发一个错误,或者使用-Werror 选项命令,将所有警告变为错误。
解决上述问题的办法是,尽量避免使用全局变量,一定需要用的话,可以定义为 static 属性的静态变量。此外,尽 量要给全局变量赋初值使其变成强符号,而外部全局变量则尽量使用 extern 。对于程序员来说最好能了解链接器是如 何工作的,并养成良好的编程习惯。
2.与静态库的链接
编译系统通常会提供一种将多个目标模块打包成一个单独的库文件的机制,这个库文件就是静态库(Static Library)。 在构建可执行文件时只需指定静态库文件名,链接器会自动到库中寻找那些在应用程序中用到的目标模块,并且只把 用到的模块从库中提取出来,和应用程序模块进行链接。
在类 UNIX 系统中,静态库文件采用一种称为存档档案(Archive)的特殊文件格式,使用.a 后缀。例如,标准 C 函 数库文件名为 libc.a,其中包含一组广泛使用的标准 I/O 函数、字符串处理函数和整数处理函数,如 atoi 、printf 、scanf、 strcpy 等,libc.a 是默认的用于静态链接的库文件,无须在链接命令中显式指出。还有其他的函数库,例如浮点数运算 函数库,文件名为 libm.a ,其中包含 sin 、cos 和 sqrt 函数等。
用户可以自定义一个静态库文件。以下通过一个简单例子来说明如何生成自己的静态库文件。假定有两个源文件 myproc 1.c 和 myproc2.c ,如图所示。

可以使用AR 工具生成静态库,在此之前需要用“gcc-c ”命令将静态库中包含的目标模块先生成可重定位目标文件。 以下两个命令可以生成静态库文件 mylib ,a ,其中包含两个目标模块 myproc1.o 和 myproc2.o。
Ø gcc -c myprocl.c
Ø gcc -c myproc2.c
Ø ar res mylib.a myproc1.o myproc2.o
假定有一个 main.c 程序,其中调用了静态库 mylib.a 中的函数 myfunc1。
void myfunc1(viod);
int main ()
{
myfunc1 () ;
return 0;
}
为了生成可执行文件 myproc,可以先将 main.c 编译并汇编为可重定位目标文件 main.o,然后再将 main.o 和 mylib.a 以及标准 C 函数库 libc.a 进行链接。
以下两条命令可以完成上述功能。
$ gcc -c main.c
$ gcc -static -o myproc main.o ./mylib.a
命令中使用-static 选项指示链接器生成一个完全链接的可执行文件。即生成的可执行文件应能直接加载到存储器执 行,而不需要在加载或运行时再动态链接其他目标模块。
图给出了可重定位目标文件与静态库进行静态链接生成完全链接的可执行目标文件的过程。链接器进行符号解析 时会根据命令中指定的输入文件顺序进行处理。

对于命令“gcc -static-o myprocmain.o./mylib.a ”,链接器首先处理输入文件 main.o.确定其引用了符号 myfunc1 ,然 后按顺序处理 mylib.a 文件,在其中的 myproc1.o 模块中找到符号 myfunc1 的定义,从而建立符号 myfunc1 的引用和定 义之间的关联。

在对 myproc1.o 模块处理时,又发现了 myfunc1 的定义需要引用符号 printf ,因此,链接器又进一步处理默认的 C 标准库文件 libc.a ,在其中的 printf.o 模块中找到 printf 的定义,从而又可以建立符号 printf 的引用和

知识点 3 重定位过程
重定位的目的是在符号解析的基础上将所有关联的目标模块合并,并确定每个定义符号在 ABI 规范规定的虚拟地
址空间中的地址,在定义符号的引用处重定位引用的地址。
例如。对于图 4.16 中的例子,因为编译 main.c 时,编译器还不知道函数 myproc1 的地址,所以编译器只是将一个 “临时地址 ”放到可重定位文件 main.o 的 call 指令中,在链接阶段,这个“临时地址 ”将被修改为正确的引用地址, 这个过程叫重定位。
具体来说,重定位有以下两方面工作。

(1)节和定义符号的重定位
链接器将相互关联的所有可重定位文件中相同类型的节合并,生成一个同一类型的新节。并根据合并后的新节在 虚拟地址空间中的起始位置以及新节中定义的每个符号的位置,确定每个符号的存储地址。例如,将所有模块中的.data 节合并后作为可执行文件中的.data 节,并重新确定其中每个定义符号在虚拟地址空间中的位置。
(2)引用处符号的重定位
链接器对合并后的新代码节( .text)和新数据节( .data)中所有符号引用处进行重定位,使其指向对应的定义符 号的起始位置。为了实现这一步工作,链接器需要知道可重定位目标文件中存在哪些需要重定位的符号引用、所引用 的是哪个定义符号等,这些称为重定位信息,放在重定位节(.rel.text 和.rel.data)的重定位表项中。重定位过程中,根 据重定位节,rel.text 和.rel.data 中的重定位表项,分别对新的.text 节和.data 节中的符号引用进行重定位处理。
例如,图 4.9 所示两个程序模块中,main.o 的.rel.text 节中有一个重定位表项:
r_offset=0x7,r_sym=10,r_type=R_386_PC32,该表项说明,需要在其.text 节中偏移量为 0x7 的地方按照 R_386_PC32 方式进行重定位,所引用的符号为 main.o 的符号表中第 10 个表项代表的符号,根据下图可知,该符号为 swap。

同时,swap.o 的.rel.data 节中有一个表 r_offset=0x0 ,r_sym=9 ,r_type=R_386_32 ,该表项说明,需要在其.data 节 中偏移量为 0 的地方按 R_386_32 方式进行重定位,所引用的符号为 swap.o 的符号表中第 9 个表项代表的符号,根据 图可知,该符号为 buf

1.R_386_PC32 方式的重定位
对于图 4.9 所示例子,模块 main.o 的.text 节中主要是 main() 函数的机器代码,其中有一处需要重定位,就是与 main.c 中第 7 行 swap()函数对应的调用指令中的目标地址图给出了 main.o 中.text 节和.rel.text 节的内容通过 OBJDUMP 工具反汇编出来的结果。

从图可以看出,符号 main 的定义从.text 节中偏移量为 0 处开始,共占 18(0x12)字节;.rel.text 节中有一个重定 位表项:r_offset=0x7 ,r_sym=10 ,r_type=R_386_PC32 ,被 OBJDUMP 工具以“7 :R_386_PC32 swap ”的可重定位信息 显示在需重定位的 call 指令的下一行(第 7 行),

说明需重定位的是离.text 节起始处偏移量为 0x7 的地方,采用 PC 相对地址方式(R_386_PC32),重定位后指向符 号 swap 的定义处(swap 过程首地址)。也就是说,上述 text 节中第 6 行 call 指令的最后 4 字节(feffffff)需要重定位, 使得 call 指令的目标跳转地址为 swap 过程的首地址。

假定链接后在可执行文件中main 过程的机器代码从虚拟地址空间中的 0x8048380 开始,紧跟在main 后面的是 swap 过程的机器代码,因为 0x8048380+0x12=0x8048392,而 swap 过程首地址应按 4 字节对齐,因此 swap 过程将从 0x8048394 开始。
IA-32 中跳转目标地址(即有效地址)计算公式为跳转目标地址=PC+偏移地址。这里 PC 是 call 指令的下一条指令
的地址,偏移地址则是 call 指令的最后 4 字节,即重定位值,因此重定位值=跳转目标地址-PC。这里的跳转目标地址为
swap 过 程 首 地 址 0x8048394 , 而 PC 内 容 为 0x8048380+0x7+4=0x804838b , 故 重 定 位 值 为 0x8048394-0x804838b=0x00000009 。 因为 IA-32 中偏移地址按小端方式排列 ,所以 main 过程中 call 指令的代码为 “e809000000 ”
根据图 4.17 中 call 指令的机器代码“e8 fc ffffff ”可知,需重定位的 4 字节初值(init)为 0xfffffc ,即-4 。汇编器用
-4 作为偏移量,其原因是,在 call 指令的跳转目标地址计算中所用的 PC 指向的是 call 指令的下一条指令,此处相对于 需重定位的位置偏移为 4 个字节。
从上面分析过程可以看出,PC 相对地址方式下的重定位值计算公式如下:
重定位值=ADDR(r_sym)-((ADDR( .text)+r_offset)-init)
其中 ADDR(r_sym)表示符号 r_sym 的首地址,ADDR( .text)表示.text 节的起始地址,它加上偏移量 r_offset 后 得到需重定位处的地址,再减初值 init(相当于加 4)后,便得到 PC 值。ADDR(r_sym)减 PC 值就是重定位值。
例如,在上述例子中,
ADDR(swap)=0x8048394 ,ADDR( .text)=0x8048380 ,r_offset=0x7 ,init=-4。
2.R_386_32 方式的重定位
对于图 4.9 所示例子。因为 main.c 中只有一个已初始化的全局定义符号 buf ,并且 buf 的定义没有引用其他符号, 因此 main.o 中的.data 节对应的重定位节.rel.data 中没有任何重定位表项。main.o 中的.data 节和.rel.data 节的内容通过 OBJDUMP 工具反汇

对于图 4.9 所示例子中的 swap.c ,其中第 3 行有一个对全局变量 bufp0 赋初值的语句,bufpO 被初始化为外部数组 变量 buf 的首地址。因而,在 swap.o 的.data 节中有相应的对 bufp0 的定义,在.rel.data 节中有对应的重定位表项。右 图给出了 swap.o 中.data 节和.rel.data 节的内容通过 OBJDUMP 工具反汇编出来的结果。

从右图中可以看出, 目标模块 swap 中全局符号 bufp0 的定义在.data 节中偏移量 0 处,占 4 个字节,初始值(init) 为 0x0(00000000)。对应重定位节.rel.data 中有一个重定位表项:
r_offset=0x0,r_sym=9,r_type=R_386_32,OBJDUMP 工具解释后显示为“0;R386_32 buf ”。重定位类型是 R_386_32, 即绝对地址方式,因而重定位值应是初始值加所引用符号地址。假定所引用符号 buf 的地址为 ADDR(buf)=0x8049620, 则在可执行目标文件中重定位后的 bufp0 的内容变为 0x8049620 ,即“20960408 ”。

可执行目标文件中的.data 节是将 main.o 中的.data 节和 swap.o 中的.data 节合并后生成的,经过重定位后得到合并 后可执行文件中的.data 节的内容,如图

从图可以看出,链接器进行重定位后,确定了可执行文件中.data 节在虚拟存储空间中的首地址为 0x8049620 ,该 地址处定义了从 main.o 中合并过来的 buf 符号,也即 buf 数组的第一个元素的地址为 0x8049620 ,buf 数组有两个 int 型元素,因而占用 8B 空间。从地址 Ox8049620+8=0x8049628 开始,定义的是从 swap.o 的.data 节合并过来的符号 bufp0, 其内容为 buf 的首地址 0x8049620。

下图给出了图 4.9 所示例子中两个可重定位模块 main.o 和 swap.o 合并成可执行文件的过程。从图中可以看出,在 可执行目标文件的,text 节和,data 节中还分别包含了系统代码和系统数据。

知识点 4 动态链接
4.2 节介绍了可重定位和可执行两种目标文件。还有一类目标文件是共享目标文件(SharedObject File),也称为共 享库文件。它是一种特殊的可重定位目标文件,其中记录了相应的代码、数据、重定位和符号表信息,能在可执行文 件装入或运行时被动态地装入内存并自动被链接,这个过程称为动态链接(Dynamic Link)。
由一个称为动态链接器(DynamicLinker)的程序来完成。
Ø 类 UNIX 系统中共享库文件扩展名为.so,
Ø Windows 系统中为动态链接库(Dynamic Link Libraries ,DLLs),文件扩展名为.dll 。对于 4 ,3 ,2 节介绍的静态 链接方式,因为库函数代码被合并在可执行文件中,因而会造成盘空间和主存空间的浪费。
例如,静态库 libc.a 中的 printf 模块会在静态链接时合并到每个引用 printf 的可执行文件中,其中的 printf 代码会 各自占用不同的盘空间。通常硬盘上存放有数千个可执行文件,因而静态链接方式会造成盘空间的极大浪费;在引用 printf 的应用程序同时在系统中运行时,这些程序中的 printf 代码也会占用内存空间,对于并发运行几十个进程的系统 来说,会造成极大的主存资源浪费。
此外,静态链接方式下,程序员还需要定期维护和更新静态库,关注它是否有新版本出现,在出现新版本时需要 重新对程序进行链接操作, 以将静态库中最新的目标代码合并到可执行文件中。因此,静态链接方式更新困难、使用 不便。
针对上述静态链接方式下的这些缺点,提出了一种共享库的动态链接方式。共享库以动态链接的方式被正在加载 或执行中的多个应用程序共享,因而,共享库的动态链接有两个方面的特点:一是“共享性 ”;二是“动态性 ”。
“共享性 ”是指共享库中的代码段在内存只有一个副本,当应用程序在其代码中需要引用共享库中的符号时,在 引用处通过某种方式确定指向共享库中对应定义符号的地址即可。
例如,对于动态共享库 libc ,so 中的 printf 模块,内存中只有一个 printf 副本,所有应用程序都可以通过动态链接 printf 模块来使用它。因为内存中只有一个副本,硬盘中也只有共享库中一份代码,因此能节省主存资源和硬盘空间。
“动态性 ”是指共享库只在使用它的程序被加载或执行时才加载到内存,因而在共享库重新后并不需要重新对程 序进行链接,每次加载或执行程序时所链接的共享库总是最新的。可以利用共享库的这个特性来实现软件分发或生成 动态 Web 网页等。动态链接有两种方式,一种是在程序加载过程中加载和链接共享库,另一种是在程序执行过程中加 载并链接共享库。
第四节 可执行文件的加载
知识点 1 程序和进程的概念
启动一个可执行目标文件执行时,首先会通过某种方式调出常驻内存的一个称为加载器(Loader) 的操作系统程 序来进行处理。例如,UNIX/ Linux 系统中程序的加载执行通过调用 execve 系统调用函数,在当前进程的上下文中启动 加载器进行。
任何一个高级语言源程序被编译、汇编、链接转换为可执行文件后,就可以被计算机直接执行。对计算机来说, 程序(Program)就是代码和数据的集合,程序的代码是一个机器指令序列,因而程序是一种静态的概念,它可以作为 文件存放在硬盘中。进程(Process)可以看成是程序的一次运行过程,因此进程是一个具有一定独立功能的程序关于 某个数据集合的一次运行活动,因而进程具有动态的含义。计算机处理的所有任务实际上是由进程完成的。
每个应用程序在系统中运行时均有属于它自己的存储空间,用来存储它自己的程序代码和数据,包括只读区(代 码和只读数据)、可读/写数据区(初始化数据和未初始化数据)、动态的堆区和栈区等。
进程是操作系统对处理器中程序运行过程的一种抽象。进程有自己的生命周期,它由于任务的启动而创建,随着 任务的完成(或终止)而消亡,它所占用的资源也随着进程的终止而释放。
一个可执行文件可以被多次加载执行,也就是说,一个程序可能对应多个不同的进程。
例如,在 Windows 系统中用 word 程序编辑一个文档时,相应的进程就是 winword.exe ,如果多次启动同一个 word 程序,就得到多个 winword.exe 进程。对于现代多任务操作系统,通常一段时间内会有多个不同的进程在系统中运行, 这些进程轮流使用处理器并共享同一个主存。程序员在开发应用程序时,并不用考虑如何和其他程序一起共享处理器 和存储器资源,而只要考虑自己的程序代码和所用数据如何组织在一个独立的虚拟存储空间中。
也就是说,程序员可以把一台计算机的所有资源看成由自己的程序所独占。可以认为自己的程序是在处理器上执 行的和在存储空间中存放的唯一的用户程序。显然,这是一种“错觉 ”。这种“错觉 ”带来了极大的好处,它简化了 编程、编译、链接和加载等整个过程。
知识点 2 进程的虚拟地址空间
在 4.2.3 节和 4.2.4 节中提到,可执行文件中的程序头表描述了其中的只读代码段和可读/写数据段与虚拟地址空间 之间的映射关系。当可执行文件被启动加载成为进程后,可执行文件中的只读代码段和可读/写数据段等信息变成进程 中的存储区域,操作系统把进程中所有存储区域信息记录在进程描述符中。
例如,Linux 内核为每个进程维护了一个数据类型为 task_struct 结构的进程描述符,taskstruct 中记录了进程的描述 信息,如进程的 PID 、指向用户栈的指针、可执行目标文件名等
下图给出了 Linux 系统中进程的虚拟地址空间中区域的描述,进程虚拟地址空间中的只读代码区对应可执行文件中 的只读代码段,进程虚拟地址空间中的可读/写数据区对应可执行文件中的可读/写数据段。

task_struct 结构中有个指针 mm 指向一个 mm_struct 结构。mm_struct 描述了对应进程虚拟存储空间的当前状态, 其中,有一个字段是 pgd ,它指向对应进程的第一级页表的首地址。mm_struct 中还有一个字段 mmap ,它指向一个由 vm_area_struct 结构构成的链表表头。
每个 vm_area_struct 结构描述了对应进程虚拟地址空间中的一个区域,可执行文件被启动加载时,操作系统通过 读取可执行文件中的程序头表,来生成 vm_area_struct 结构内容。
vm_area_struct 中部分字段的含义如下。
Ø vm_start:指向区域的开始处。
Ø vm_end:指向区域的结束处。
Ø vm_prot:描述区域包含的所有页面的访问权限。
Ø vm_flags:描述区域包含页面是否与其他进程共享等。
Ø vm_next:指向链表下一个 vm_area_struct。
知识点 3 execve 函数和 main 函数
execve 函数的功能是在当前进程的上下文中加载并运行一个新程序。execve 函数的用法如下。
int execve(char *filename , char *argv[] ,*envp[]);
该函数用来加载并运行可执行目标文件 filename ,可带参数列表 ;argv 和环境变量列表 envp。
若出现错误,如找不到指定的文件 filename ,则返回-1 并将控制权返回给调用程序;若函数功能执行成功,则不 返回。而是跳转到可执行文件 ELF 头中由字段 e_entry 定义的入口点(即符号_start 处)执行。符号_start 在启动例程 模块 crtl.o 中定义,每个 C 语言程序的_start 定义都一样。
符号_start 处定义的启动代码主要是一系列过程调用。
例如,可以先依次调用_libc_init_first 和_init 两个初始化过程:随后通过调用 atexit 过程对程序正常结束时需要调 用的函数进行登记注册,这些函数被称为终止处理函数,将由 exit 函数自动调用执行;然后,再调用可执行目标文件 中的主函数 main;最后调用_exit 过程,以结束进程的执行。返回到操作系统内核。
因此.启动代码中的过程调用顺序可以是_libc_init_first →_init →atexit → main(其中可能会调用 exit 函数)→_exit。 通常,主函数 main 的原型有如下两种形式:
int main(int argc , char * *argv ,char **envp);
int main(int argc ,char *argv[] ,char *envp[]);
其中,参数列表 argv 可用一个以 null 结尾的指针数组表示。每个数组元素都指向一个用字符串表示的参数。
通常,argv[0]指向可执行目标文件名,argv[1]是命令(以可执行文件名作为命令名)第一个参数的指针,argv[2] 是命令第二个参数的指针, 以此类推。参数个数由 argc 指定。
参数列表结构如图所示。图中显示了命令行“ld-o test main.o test.o ”对应的参数列表结构。

环境变量列表 envp 的结构与参数列表结构类似。也用一个以 null 结尾的指针数组表示,每个数组元素都指向一 个用字符串表示的环境变量串。其中每个字符串都是一个形加“NAME=VALUE ”的名—值对。
当 IA-32+Linux 系统开始执行 main 函数时。在虚拟地址空间的用户栈中具有如图所示的组织结构。

如图,用户栈的栈底是一系列环境变量串,然后是命令行参数串,每个串以 null 结尾,连续存放在栈中,串 i 在栈 中的位置由相应的 envp[i]和 argv[i]中的指针指示。在命令行参数串后面是指针数组 envp 的数组元素,全局变量 environ 指向这些指针中的第一个指针 envp[0] 。然后是指针数组 argv 的数组元素。在栈的顶部是 main() 函数的三个参数; envp 、argv 和 argc 。在这三个参数所在单元的后面将生成 main() 函数的栈帧。

知识点 4 fork 函数和程序的启动加载
在父进程中可通过 fork 函数创建一个子进程,fork 函数的原型如下:
pid_t fork(void);
在 Linux 系统中,返回值类型 pid_t 在头文件 sys/types.h 中定义为 int 型,fork 函数原型在头文件 unistd.h 中定义。 通常用一个唯一的正整数标识一个进程,称为进程 ID 、简写为 PID 。这里的返回值实际上就是一个 PID。
通过 fork 函数新创建的子进程和父进程几乎一样,通过复制父进程的相关数据结构,使得子进程具有与父进程完 全相同但独立的虚拟地址空间,也即只读代码段、可读/写数据段、堆、用户栈、共享库区域都完全相同。此外,子进 程还继承了父进程的打开文件描述符表,也即子进程可以读/写父进程中打开的任何文件。新创建的子进程和父进程之 间最大的差别是它们的 PID 不同。
以下说明通过 shell 命令行输入可执行文件名 a.out 进行程序加载的过程,大致如下
1)shell 命令行解释器输出一个命令行提示符(如:unix>),并开始接受用户输入的命今行。
2)当用户在命令行提示符后输入命令行“./a ,out[enter] ”后,shell 命令行程序开始对命令行进行解析,获得各 个命令行参数并构造传递给函数 execve 的参数列表 argv 和参数个数 argc。
3)调用 fork 函数,创建一个子进程。
4)以第 2)步命令行解析得到的参数个数 argc 、参数列表 argv 以及全局变量 environ 作为参数,调用函数 execve, 从而实现在当前进程(用fork 新创建的子进程)的上下文中加载并运行 a.out 程序。在函数 execve 中,通过启动加载 器执行加载任务并启动程序运行。
这里的“加载 ”实际上并没有将 a.out 文件中的代码和数据(除 ELF 头、程序头表等信息)从硬盘读入主存。而是 根据可执行文件中的程序头表等。对当前进程描述符中的一些数据结构进行初始化,也即生成上述 task_struct 结构中 vm_area_struct 等信息。
当加载器执行完加载任务后。便将 PC 设定指向程序入口点(即符号_start 处),从而开始转到 a.out 程序执行。从 此。a.out 程序开始在新进程的上下文中运行。在运行过程中,一旦 CPU 检测到所访问的指令或数据不在主存(即缺页), 则调用操作系统内核中的缺页处理程序执行。在处理过程中才将代码或数据真正从 a.out 文件装入主存。
第五节 程序的执行和中央处理器
知识点 1 程序及指令的执行过程
可执行文件被启动加载后,CPU 就会按照可执行文件只读代码段中指令给定的顺序执行。从前面介绍的有关机器 级代码的表示和生成可以看出。指令按顺序存放在存储空间的连续单元中。正常情况下。指令按其存放顺序执行。遇 到跳转、过程调用或按条件分支执行时.CPU 则会根据相应的跳转类指令(包括无条件跳转指令、条件跳转指令、调用 指令和返回指令等)来改变程序执行流程。
CPU 取出并执行一条指令的时间称为指令周期。不同指令所要完成的功能不同,因而不同指令所用的指令周期可 能不同。例如,对于 4.3.1 节图 4.9 中的例子,其链接生成的可执行目标文件的.text 节中的 main() 函数包含的指令序 列如下。

可以看出,指令按顺序存放在地址 0x08048380 开始的存储空间中,每条指令的长度不同,如 push 、leave 和 ret 指令各占 1B ,第 3 行的 mov 指令占 2B ,第 4 行 and 指令占 3B ,第 5 行和第 6 行指令各占 5B。

每条指令对应的 0/ 1 序列含义不同,如
Ø “push %ebp ”指令为 55H=01010101 B ,其中高 5 位 01010 为 push 指令操作码,后三位 101 为 EBP 的编号 Ø “leave ”指令为 C9H=11001001B ,没有显式操作数。8 位都是指令操作码。

指令执行的顺序如下:第 2~5 行指令按顺序执行,第 5 行指令执行后跳转到 swap 过程执行,执行完 swap 过程后 回到第 6 行指令执行,然后顺序执行到第 8 行指令。执行完第 8 行指令后,再转到另一处开始执行。

CPU 为了能完成指令序列的执行。必须解决以下一系列问题:
如何判定每条指令有多长?
如何判定指令操作类型、寄存器编号、立即数等?
如何区分第 3 行和第 6 行的两条 mov 指令有何不同
如何确定操作数是在寄存器中还是在存储器中?
一条指令执行结束后如何正确地读取到下一条指令?
CPU 执行一条指令的大致过程如所示,分成取指令、指令译码、计算源操作数地址并取操作数、执行数据操作、 计算目的操作数地址并存结果、计算下条指令地址这几个步骤。

1)取指令。马上将要执行的指令的地址总是在程序计数器(PC) 中,因此,取指令的操作就是从 PC 所指出的存 储单元中取出指令送到指令寄存器(IR)。
例如,对于上述 main 函数的执行,刚开始时,PC(即 IA-32 中的 EIP)中存放的是首地址 0x08048380 ,因此,CPU 根据 PC 的值取到一串 0/ 1 序列送 IR ,可以每次总是取最长指令字节数,假定最长指令占 4B ,即 IR 为 32 位,此时,从 0x08048380 开始取 4 个字节到 IR 中,也即,将 55H 、E5H 和 83H 送到 IR 中。
2)对 IR 中的指令操作码进行译码。不同指令其功能不同,即指令涉及的操作过程不同,因而需要不同的操作控 制信号。
例如,上述第 6 行指令“mov $0x0,%eax ”要求将立即数 0x0 送寄存器 EAX 中;而上述第 3 行指令“mov %esp,%ebp ” 则要求从寄存器 ESP 中取数,然后送寄存器 EBP 中。
因而,CPU 应该根据不同的指令操作码译出不同的控制信号。
例如,对取到 IR 中的 5589 E583H 进行译码时,可根据对最高 5 位(01010)的译码结果得到 push 指令的控制信号。
3)源操作数地址计算并取操作数。根据寻址方式确定源操作数地址计算方式。若是存储器数据,则需要一次或多 次访存,
例如,当指令为间接寻址或两个操作数都在存储器的双目运算时,就需要多次访存;若是寄存器数据,则直接从 寄存器取数后,转到下一步进行数据操作。
4)执行数据操作。在 ALU 或加法器等运算部件中对取出的操作数进行运算。
5) 目的操作数地址计算并存结果。根据寻址方式确定目的操作数地址计算方式,若是存储器数据,则需要一次或 多次访存(间接寻址时);若是寄存器数据,则在进行数据操作时直接将结果存到寄存器。
如果是串操作或向量运算指令,则可能会并行执行或循环执行第 3)~5)步多次。
6)指令地址计算并将其送 PC 。顺序执行时,下条指令地址的计算比较简单,只要将 PC 加上当前指令长度即可,
例如,当对 IR 中的 5589E583H 进行操作码译码时,得知是 push 指令,指令长度为 1B ,因此,指令译码生成的控 制信号会控制使 PC 加 1(即 0x08048380+1) ,得到即将执行的下条指令的地址为 0x08048381 。如果译码结果是跳转 类指令时,则需要根据条件标志、操作码和寻址方式等确定下条指令地址。
对于上述过程的第 1)步和第 2)步,所有指令的操作都一样;而对于第 3)~5)步,不同指令的操作可能不同, 它们完全由第 2)步译码得到的控制信号控制。也即指令的功能由第 2)步译码得到的控制信号决定。对于第 6)步, 若是定长指令字,处理器会在第 1)步取指令的同时计算出下条指令地址并送 PC ,然后根据指令译码结果和条件标志 决定是否在第 6)步修改 PC 的值,因此,在顺序执行时,实际上是在取指令时计算下条指令地址,第 6)步什么也不 做。
根据对上述指令执行过程的分析可知,每条指令的功能总是通过对以下 4 种基本操作进行组合来实现的,也即, 每条指令的执行可以分解成若干个以下基本操作。1)读取某存储单元内容(可能是指令或操作数或操作数地址),并 将其装入某个寄存器 2)把某个寄存器中的数据存储到给定的存储单元中。
3)把一个数据从某个寄存器传送到另一个寄存器或者 ALU 的输入端。
4)在 ALU 中进行某种算术运算或逻辑运算,并将结果传送到某个寄存器。
知识点 2 CPU 的基本功能和组成
CPU 的基本职能是周而复始地执行指令,4.5.1 节介绍的机器指令执行过程中的全部操作都是由 CPU 中的控制器控 制执行的。
随着超大规模集成电路技术的发展,更多的功能逻辑被集成到 CPU 芯片中,包括 cache 、MMU 、浮点运算逻辑、 异常和中断处理逻辑等,因而 CPU 的内部组成越来越复杂,甚至可以在一个 CPU 芯片中集成多个处理器核。
但是,不管 CPU 多复杂,它最基本的部件是数据通路(Datapath)和控制部件(Control Unit) 。控制部件根据每 条指令功能的不同生成对数据通路的控制信号,并正确控制指令的执行流程。
为了在教学上遵循由易到难的原则,我们首先从 CPU 最基本的组成开始了解。CPU 的基本功能决定了 CPU 的基本 组成,图所示是 CPU 的基本组成原理图。

图中的地址线、数据线和控制线并不属于 CPU ,构成系统总线的这三组线主要用来使 CPU 与 CPU 外部的部件(如 主存储器)交换信息,交换的信息包括地址、数据和控制信号三类,分别通过地址线、数据线和控制线进行传送,这 里的数据信息包含指令,即数据和指令都看成是数据信息,因为对总线和存储器来说,指令和数据在形式上没有区别, 而且数据和指令的访存过程也完全一样。

除了地址和数据(包括指令) 以外的所有信息都属于控制信息。地址线是单向的, 由 CPU 送出地址,用于指定需 要访问的指令或数据信息所在的存储单元地址。
图所示的 CPU 中只包括最基本的执行部件,如 ALU 、通用寄存器和状态寄存器等,其余都是控制逻辑或与其密切 相关的逻辑,主要包括以下几个部分。

1)程序计数器(PC)。PC 又称指令计数器或指令指针寄存器(IP),用来存放即将执行指令的地址。顺序执行时, PC+“1 ”形成下一条指令地址(这里的“1 ”是指一条指令的字节数);需要改变程序执行顺序时,CPU 根据跳转类指 令提供的信息生成跳转目标指令的地址,并将其作为下一条指令地址送 PC。
2)指令寄存器(IR)。IR 用以存放现行指令。上文提到,每条指令总是先从存储器取出后才能在 CPU 中执行,指 令取出后存放在指令寄存器中, 以便送指令译码器进行译码。
3)指令译码器(ID)。ID 对 IR 中的操作码部分进行译码,产生的译码信号提供给操作控制信号形成部件,以产生 控制信号。
4)启停控制逻辑。脉冲源产生一定频率的脉冲信号作为 CPU 的时钟信号。启停控制逻辑在需要时能保证可靠地开 放或封锁时钟信号,实现对机器的启动与停机。
5)时序信号产生部件。该部件以时钟信号为基础,产生不同指令对应的时序信号,以实现机器指令执行过程的时 序控制。
6)操作控制信号形成部件。该部件综合时序信号、指令译码信号和执行部件反馈的条件标志(如 CF 、SF 、ZF 和 OF)等,形成不同指令操作所需要的控制信号。
7)总线控制逻辑。实现对总线传输的控制,包括对数据和地址信息的缓冲与控制。CPU 对于存储器的访问通过总 线进行,CPU 将存储访问命令(即读/写控制信号)送到控制线,将要访问的存储单元地址送到地址线,并通过数据线 取指令或者与存储器交换数据信息。
8)中断机构。实现对异常情况和外部中断请求的处理。
知识点 3 打断程序正常执行的事件
从开机后 CPU 被加电开始,到断电为止,CPU 自始至终就一直重复做一件事情:读出 PC 所指存储单元的指令并执 行它。每条指令的执行都会改变 PC 的内容,因而 CPU 能够不断地执行新的指令。
正常情况下,CPU 按部就班地按照程序规定的顺序一条指令接着一条指令执行,或者按顺序执行,或者跳转到跳 转类指令设定的跳转目标指令处执行,这两种情况都属于正常执行顺序。
当然,程序并不总是能按正常顺序执行。有时 CPU 会遇到一些特殊情况而无法继续执行当前程序。例如, 以下事 件可能会打断程序正常执行。
l 对指令操作码进行译码时,发现是不存在的“非法操作码 ”,因此,CPU 不知道如何实现当前指令而无法继续执 行。
l 在访问指令或数据时,发现页故障,如段错误(Segmentation Fault)、缺页(PageFault)等,因此,CPU 没有访 问到正确的指令或数据而无法继续执行当前指令。
l 在 ALU 中运算的结果发生溢出,或者整数除法指令的除数为 0 等,因此,CPU 发现运算结果不正确而无法继续执 行程序。
l 在程序执行过程中,CPU 接收到外部发送来的中断请求信号。
CPU 除了能够正常地不断执行指令以外,还必须具有程序正常执行被打断时的处理机制,这种机制称为异常控制, 也称为中断机制,CPU 中相应的异常和中断处理逻辑称为中断机构.。
计算机中很多事件的发生都会中断当前程序的正常执行,使 CPU 转到操作系统中预先设定的与所发生事件相关的 处理程序执行,有些事件处理完后可回到被中断的程序继续执行,此时相当于执行了一次过程调用,有些事件处理完 后则不能回到原被中断的程序继续执行。
所有这些打断程序正常执行的事件被分成两大类: 内部异常和外部中断。
(1) 内部异常
内部异常(Exception)是指由 CPU 在执行某条指令时引起的与该指令相关的意外事件
如除数为 0 、结果溢出、断点、单步跟踪、寻址错、访问超时、非法操作码、栈溢出、缺页、地址越界(段错误) 等。
(2)外部中断
程序执行过程中,若 CPU 外部发生了采样计时时间到、网络数据包到达、用户按下<Ctrl+C>等外部事件,要求 CPU 中止当前程序的执行,则会向 CPU 发中断请求信号,要求 CPU 对这些情况进行处理。通常,每条指令执行完后,CPU 都会主动去查询有没有中断请求,有的话,则将下一条指令地址作为断点保存,然后转到用来处理相应中断事件的中 断服务程序去执行,结束后回到断点继续执行。这类事件与执行的指令无关,由 CPU 外部的/O 子系统发出,所以,称 为/O 中断或外部中断(Interrupt),需要通过外部中断请求线向 CPU 发请求信号。
知识点 4 异常和中断的响应过程
每种指令集架构都会各自定义它所处理的异常和中断类型。而且对于异常和中断的处理方式也有所不同。不过其 基本原理相同。
在 CPU 执行指令过程中。如果发生了内部异常事件或外部中断请求。则 CPU 必须进行相应处理。CPU 从检测到异 常或中断事件。到调出相应的异常/中断处理程序准备执行。其过程称为异常和中断的响应。CPU 对异常和中断的响应 过程可分为三个步骤:
l 保护断点和程序状态、
l 关中断、
l 识别异常和中断事件并转相应处理程序。
1.保护断点和程序状态
为了 CPU 在异常和中断处理后能正确返回原被中断的程序继续执行。在异常/中断响应时 CPU 必须能正确保存回到 被中断程序执行的返回地址(即断点),可以将断点保存在栈中或特定寄存器中。不同异常事件对应的断点不同。如 页故障的断点是发生页故障的指令的地址。对于中断,因为 CPU 总是在每条指令执行结束时查询中断请求,因此所有 中断的断点都是中断响应时的 PC 值。
异常/中断处理后可能要回到原被中断的程序继续执行,因此必须保存并恢复被中断时原程序的状态(如产生的各 种标志信息、允许中断标志等)。每个正在运行程序的状态信息称为程序状态字(Program Status Word ,PSW),通常 存放在程序状态字寄存器(PSWR)中。如在 IA-32 中程序状态字寄存器就是标志寄存器 EFLAGS 。与断点一样,PSW 也 要被保存到栈或特定寄存器中,在异常/中断返回时,将保存的 PSW 恢复到 PSWR 中。
2.关中断
如果中断处理程序在保存原被打断程序现场的过程中又发生了新的中断,那么,就会因为要处理新的中断,而破 坏原被打断程序的现场以及已保存的断点和程序状态等,因此,需要有一种机制来禁止在处理中断时再响应新的中断。 通常通过设置中断使能位来实现。
Ø 当中断使能位被置 1 ,则为开中断,表示允许响应中断;
Ø 若中断使能位被清 0 ,则为关中断,表示不允许响应中断。
例如,IA-32 中的中断使能位就是 EFLAG 寄存器中的中断标志位 IF。
为了避免已保存断点和程序状态等被破坏,通常在异常和中断响应过程中由 CPU 将中断使能位清 0 。以进行关中 断操作。
除了在异常和中断响应阶段由 CPU 对中断使能位清 0 以关中断外,也可以在异常/中断处理程序中,执行相应指令 设置或清除中断使能位。
在 IA-32/x86-64 架构中,可通过执行指令 sti 或 cli ,将标志寄存器 EFLAGS 中的 IF 位置 1 或清 0 , 以使 CPU 处在开 中断或关中断状态。
3.识别异常和中断事件并转相应的处理程序
在调出异常/中断处理程序之前。必须知道发生了什么异常或哪个 I/0 设备发出了中断请求。一般来说。 内部异常 事件和外部中断源的识别方式不同,大多数处理器会将两者分开来处理。
内部异常事件的识别很简单。CPU 在执行指令时把检测到的事件对应的异常类型号或标识异常类型的信息记录到 特定的内部寄存器中即可。外部中断源的识别比较复杂。通常是由中断控制器根据 I/O 设备的中断请求和中断屏蔽情 况,结合中断响应优先级来识别当前请求的中断类型号,并通过数据总线将中断类型号送到 CPU 。有关中断响应处理 的详细内容参见 6.4.5 节。
异常和中断源的识别可以采用软件识别或硬件识别两种方式。
l 软件识别通常是在 CPU 中设置一个原因寄存器,该寄存器中有一些标识异常原因或中断类型的标志信息。操作 系统使用一个统一的异常/中断查询程序,该程序按一定的优先级顺序查询原因寄存器。如 MIPS 架构就采用软件识别
方式,有一个 cause 寄存器,位于 0x80000180 处有专门的异常/中断查询程序,它通过查询 cause 寄存器来跳转到操作 系统内核中具体的处理程序去执行。
l 硬件识别称为向量中断方式。这种方式下,通常将不同异常/中断处理程序的首地址称为中断向量,所有中断向 量存放在一个表中,称为中断向量表。每种异常和中断类型都被设定一个中断类型号,中断向量存放的位置与对应的 中断类型号相关,例如,类型 0 对应的中断向量存放在第 0 表项,类型 1 对应的中断向量存放在第
1 表项, … , 以此类推,因而可以根据中断类型号快速跳转到对应的异常/中断处理程序去执行。
知识点 5 指令流水线的基本概念
机器指令的执行是在 CPU 中完成的,通常将指令执行过程中数据所经过的路径,包括路径上的部件称为数据通路。 如图 4.24 中的 ALU 、通用寄存器、状态寄存器等都是指令执行过程中数据流经的部件,都属于数据通路的一部分。
整数运算数据通路的宽度就是指其中通用寄存器和 ALU 的位数,它们总是一致的,因此机器字长就等于整数运算 数据通路的宽度。
通常把数据通路中专门进行数据运算的部件称为执行元件(Execution Unit)或功能元件(Function Unit),数据通 路由控制元件(也称控制器)进行控制。
CPU 中最基本的电路主要有数据通路和控制器两部分组成。
自从 1946 年冯 ·诺依曼及同事在普林斯顿高级研究院开始设计存储程序计算机(被称为 IAS 计算机,它是后来通 用计算机的原型) 以来,从 IAS 计算机中 CPU 内部的分散连接结构,到基于单总线、双总线或三总线的总线式 CPU 结 构,再到基于简单流水线和超标量/动态调度的流水线 CPU 结构、多核 CPU 结构等,CPU 结构发生了较大变化。
在 CPU 设计中最关键的思路之一是让指令在 CPU 中按流水线方式执行。
以下以 1.1.3 节中给出的 8 位模型机为例,对流水线 CPU 的基本工作原理和流水线 CPU 结构进行简要介绍。 由图 1.2 可知,该模型机中每条指令的长度都是 8 位,即下一条指令地址等于 PC+1 ,指令中操作码 op 固定在指令码的高 4 位,寄存器编号 rt 和 rs 各占 2 位,主存地址 addr 占 4 位,它们都固定在指令码的低 4 位。
该模型机支持的基本指令包括运算类指令、寄存器传送指令、装入和存储类指令。
例如,
“add rO ,r1 ”的功能为 R[r0] ←R[r0]+R[r1],
指令“mov r1 ,r0 ”的功能为 R[r1] ←R[r0],
装入指令“loadr0 ,6# ”的功能为 R[r0] ←M[6],
存储指令“store 8# ,r0 ”的功能为 M[8] ←R[r0]。
所有这些指令的处理过程可以归纳为以下 4 个阶段。
1)取指令并 PC 加 1(IF):根据 PC 的值从存储器取出指令,并 PC ←PC+1。
2)译码并读寄存器(ID):对指令操作码进行译码并生成控制信号,同时读取寄存器 rs 和 rt 的内容。
3)运算或读存储器(EX):在 ALU 中对寄存器操作数进行运算,或者根据 addr 读存储器
4)结果写回(WB):将结果写入目的寄存器 rt ,或写人主存单元 addr 中。

显然,对于 add 这种运算类指令和取数指令 load ,其处理过程将包含上述 4 个阶段,而对于寄存器传送指令 mov 和存数指令 store ,其处理过程仅包含 IF 、ID 和 WB 三个阶段。
如果将各阶段看成相应的流水段,则指令的执行过程就构成了一条指令流水线。为了规整指令流水线。指令的流 水段个数通常取最复杂指令所需的阶段数,其他指令通过加入“空 ”段向最复杂指令靠齐。
进入流水线的指令流,由于后一条指令的第 i 步与前一条指令的第 i+1 步同时进行,从而使一串指令的总处理时间 大为缩短。如图所示,在理想状态下,完成 4 条指令的执行只用了 7 个时钟周期,若采用非流水线方式的串行执行处 理,则最多需要 16 个时钟周期。

从图可看出,理想情况下,每个时钟都有一条指令进入流水线;从第 5 时钟周期开始,每个时钟周期都有一条指 令完成。 由此可以看出,理想情况下,每条指令执行的时钟周期数(即 CPI)都为 1。
下面用一个简单的例子对指令串行执行方式和流水线方式进行比较。

假设上述 8 位模型机指令执行时主要操作所用时间分别如下:
指令译码—60ps;
存储器读或存储器写—200ps;
PC 加 1—40ps;
寄存器读或寄存器写—50ps;
ALU—100ps,
串行执行方式下
mov 指令执行时间约为 200ps+60ps+50ps=310ps,
add 指令执行时间约为 200ps+60ps+100ps+50ps=410ps,
load 指令执行时间约为 200ps+60ps+200ps+50ps=510 ps,
store 指令执行时间约为 200ps+60ps+200ps=460ps。
在指令的流水线执行方式下,流水线 CPU 设计的原则是:
指令流水段个数以最复杂指令所用的阶段数为准;
流水段执行时间以最复杂操作所用时间为准。
对于上述例子,最复杂指令为 load 指令,最复杂操作为存储器读/写,因而指令流水段个数为 4 ,每个流水段长度 为 200ps。
在流水线 CPU 中,每条指令的执行时间为4×200ps=800ps ,反而比串行执行时所有指令的执行时间都长,因此流 水线方式并不能缩短一条指令的执行时间。
但是,对于整个程序来说,流水线方式可以大大增加指令执行的吞吐率。
若流水段数为 M ,每个流水段的执行时间为 T ,则理想情况下,N 条指令的执行总时间为(M-1+N) ×T。
例如,对于上述模型机对应的4 段流水线,假定某程序有 N 条指令,在不考虑任何其他额外开销和冲突的情况下, 流水线处理器所用时间为(3+N) ×200ps 。当 N 很大时,流水线比串行执行方式要快得多。