16.1 程序的构建

通常的开发环境都是流行的集成开发环境(IDE),一般都将编译和链接的过程一步完成,通常将这种 编译链接 合并到一起的过程称为 构建(Build)。即使使用命令行来编译一个源代码文件,简单的一句 gcc hello.c 命令就包含了非常复杂的过程。

构建程序的过程可以大致分解为预处理、编译、汇编和链接。但本节将其之前和之后的相关工作也加入进来,力图全面地讨论一个程序构建的全过程。在此要特别感谢阮一峰老师的文章以及《程序员的自我修养》这本书

image-center

image-center

源代码也称源程序,是指一系列人类可读的计算机语言指令。C 语言的源代码文件后缀为 .c

16.1.1 配置

Configure

编译器在开始工作之前,需要知道当前的系统环境,比如标准库在哪里、软件的安装位置在哪里、需要安装哪些组件等等。这是因为不同计算机的系统环境不一样,通过指定编译参数,编译器就可以灵活适应环境,编译出各种环境都能运行的机器码。这个确定编译参数的步骤,就叫做 “配置”(configure)。

这些配置信息保存在一个配置文件之中,约定俗成是一个叫做 configure 的脚本文件。通常它是由 autoconf 工具生成的。编译器通过运行这个脚本,获知编译参数。

configure 脚本已经尽量考虑到不同系统的差异,并且对各种编译参数给出了默认值。如果用户的系统环境比较特别,或者有一些特定的需求,就需要手动向 configure 脚本提供编译参数。

$ ./configure --prefix=/www --with-mysql

上面代码是 php 源码的一种编译配置,用户指定安装后的文件保存在 www 目录,并且编译时加入 mysql 模块的支持。

16.1.2 确定标准库和头文件的位置

源码肯定会用到标准库函数(standard library)和头文件(header)。它们可以存放在系统的任意目录中,编译器实际上没办法自动检测它们的位置,只有通过配置文件才能知道。

编译的第二步,就是从配置文件中了解标准库和头文件的位置。一般来说,配置文件会给出一个清单,列出几个具体的目录。等到编译时,编译器就按顺序到这几个目录中,寻找目标。

16.1.3 确定信赖关系

对于大型项目来说,源码文件之间往往存在依赖关系,编译器需要确定编译的先后顺序。假定 A 文件依赖于 B 文件,编译器应该保证做到下面两点。

  • 只有在 B 文件编译完成后,才开始编译 A 文件。
  • 当 B 文件发生变化时,A 文件会被重新编译。

编译顺序保存在一个叫做 makefile 的文件中,里面列出哪个文件先编译,哪个文件后编译。而 makefile 文件由 configure 脚本运行生成,这就是为什么编译时 configure 必须首先运行的原因。

在确定依赖关系的同时,编译器也确定了,编译时会用到哪些头文件。

16.1.4 头文件的预编译

PreCompilation

不同的源码文件,可能引用同一个头文件(比如 stdio.h)。编译的时候,头文件也必须一起编译。为了节省时间,编译器会在编译源码之前,先编译头文件。这保证了头文件只需编译一次,不必每次用到的时候,都重新编译了。

不过,并不是头文件的所有内容,都会被预编译。用来声明宏的 #define 命令,就不会被预编译。

16.1.5 预处理

PreProcessing

C 预处理器不是编译器的组成部分,但是它是编译过程中一个单独的步骤。C 预处理器只不过是一个 文本替换工具 而已,它们会指示编译器在实际编译之前完成所需的预处理。C 预处理器(C Preprocessor)简写为 CPP。它用于在编译器处理程序之前 预扫描源代码,完成头文件的包含, 宏扩展, 条件编译, 行控制(line control)等操作。

C 源代码文件扩展名为 .c,预处理之后的扩展名为 .i

经过预处理后的 .i 文件不包含任何宏定义,因为所有的宏已经被 展开,并且包含的文件也已经被 插入.i 文件中。所以当我们无法判断宏定义是否正确或头文件包含是否正确时,可以查看预处理后的文件来确定问题。

编译阶段

C 语言标准规定,编译共分 8 个阶段,预处理是指前 4 个编译阶段(phases of translation)。

  • 三字符组与双字符组的替换
  • 行拼接(Line splicing): 把物理源码行(Physical source line)中的换行符转义字符处理为普通的换行符,从而把源程序处理为逻辑行的顺序集合。
  • 单词化(Tokenization): 把处理结果变成 token 和空格,把注释替换为空格。
  • 宏扩展与预处理指令(directive)处理.

处理头文件

include 包含文件,即头文件,扩展名为 .h

处理 #include 预编译指令,将被包含的文件 插入 到该预编译指令的位置。注意,这个过程是 递归 进行的,也就是说被包含的文件可能还包含其他文件。

处理条件预编译指令

处理所有条件预编译指令,如 #if#ifdef#elif#else#endif

处理宏定义与扩展

将所有的 #define 删除,并且展开所有的宏定义。

特殊宏与指令

__FILE____LINE__, 扩展为当前文件与行号。以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。

Token 字符串化

# 运算符把随后的 token(记号) 转化为 C 语言的字符串。

Token 连接

## 运算符连接两个 token 为一个 token.

## 运算符左侧或右侧如果是另一个宏名,这个宏名将不会被宏展开,而是按照字面值被当作一个 token。因此,如果需要 ## 运算符左右的宏名做宏展开,需要使用两层宏的嵌套使用,其中外层的宏展开时也一并把 ## 运算符左右的宏名做宏展开。

用户定义的编译错误与警告

编译器相关的预处理特性

#pragma 指令提供了编译器特定的预处理功能。

16.1.6 编译

Compilation

预处理的输出通常是轻微扩展过的 C,包含对于标准库所有相关部分的声明,下一步的构建流程是编译和汇编,它们把预处理的输出转换成目标文件。

预处理之后,编译器就开始生成机器码。有两种基本的方法把 C 转成机器码,一种是直接进行转换,另一种是先转换为汇编码(介于 C 与机器码之间,易于阅读),再转换为机器码。

编译过程就是把预处理完的文件进行一系列 “词法分析、语法分析、语义分析及优化” 后生产相应的 汇编代码文件,这个过程往往是我们所说的整个程序构建的核心部分,也是最复杂的部分之一。

基础概念

编译器

编译器(compiler),是一种计算机程序,可以把(某种编程语言写成的)源代码转换成另一种编程语言。

常用的编译器为 GCC,即 GNU Compiler Collection。

原始语言与目标语言

对于编译器来说,原始的编程语言称原始语言,转换后的语言称目标语言。

对于程序员来说,原始语言往往更便于编写、阅读、维护,是高级计算机语言。而目标语言往往是计算机可以解读、运行的低级语言。

源代码一般为高阶语言,如 Pascal、C、C++、C# 、Java 等,而目标语言则是汇编语言或目标机器的目标代码,有时也称作机器代码(Machine code)。

编译器的目的在于把源代码程序翻译为目标语言程序。

编译的基本过程

编译在把源语言编译为目标语言的过程中,通常包括以下几个步骤:

  • 扫描
  • 语法分析;
  • 语义分析;
  • 源代码优化
  • 中间代码生成和优化;
  • 目标代码生成和优化;

image-center

现在版本的 GCC 把预处理和编译两个步骤 合并 成一个步骤,使用一个叫做 cc1 的程序来完成这两个步骤。这个程序位于 /usr/lib/gcc/i486-linux-gnu/4.1/,也可以直接调用 cc1 来完成它:

$ /usr/lib/gcc/i486-linux-gnu/4.1/cc1 hello.c
 main
Execution times (seconds)
 preprocessing  :0.01(100%)usr  0.01(33%)sys  0.00( 0%)wall 77 kB( 8%)ggc
 lexical analysis :0.00( 0%)usr 0.00( 0%)sys  0.02(50%)wall 0 kB(0%)ggc
 parser         :0.00( 0%)usr 0.00( 0%)sys  0.01(25%)wall 125 kB(13%)ggc
 expand         :0.00( 0%)usr 0.01(33%)sys  0.00( 0%)wall 6 kB(1%)ggc
 TOTAL            :0.01         0.03        0.04          982 kB

或者使用 gcc:

$ gcc –S hello.c –o hello.s

以上两种方法都可以得到汇编输出文件 hello.s。对于 C 语言的代码来说,这个预编译和编译的程序是 cc1

所以实际上 gcc 这个命令只是这些后台程序的包装,它会根据不同的参数要求去调用预编译编译程序 cc1、汇编器 as、链接器 ld

汇编

对于某些编译器来说,还存在一个中间步骤,会先把源码转为汇编码(assembly),然后再把汇编码转为机器码。

汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。

所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了,“汇编” 这个名字也来源于此。

上面的汇编过程我们可以调用汇编器 as 来完成:

$as hello.s –o hello.o

或者使用 gcc 命令从 C 源代码文件开始,经过预编译、编译和汇编直接输出目标文件(Object File):

$gcc –c hello.c –o hello.o

目标文件

Object file

程序的源代码经过 编译后生成的文件,称为目标文件,其实如果叫 中间目标文件 会更容易理解其作用。

目标文件从结构上讲,它是已经编译后的 可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整。其实它本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构上稍有不同。

目标文件的格式

当今可执行文件格式主要是 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 格式等。

目标文件一般有以下几类:

文件类型 英文名 说明 实例
可重定位文件 Relocatable File 包含 代码和数据,可以被链接成可执行文件或共享目标文件,静态链接库 属于这一类 Linux 的 .o
可执行文件 Executable File 包含了 可以直接执行的程序,一般 没有扩展名 /bin/bash
共享目标文件 Shared Object File 包含 代码和数据,可以跟其他可重定位文件和共享目标文件链接产生新的目标文件,也可以跟可执行文件结合作为进程映像的一部分来运行 .so 文件
核心转储文件 Core Dump File 当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件 core dump

可以用 file 命令来查看相应的类型:

$ file foobar.o
foobar.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
#                        ^^^^^^^^^^^
$ file /bin/bash
/bin/bash: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), for GNU/Linux 2.6.8, dynamically linked (uses shared libs), stripped
#                         ^^^^^^^^^^
$ file /lib/ld-​2.​6.​1.​so
/lib/libc-2.​6.​1.​so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), for GNU/Linux 2.6.8, stripped
#                                  ^^^^^^^^^^^^^
可执行文件格式的演变

目标文件与可执行文件格式跟操作系统和编译器密切相关,所以不同的系统平台下会有不同的格式,但这些格式又大同小异,目标文件格式与可执行文件格式的历史几乎是操作系统的发展史。

COFF 是由 Unix System V Release 3 首先提出并且使用的格式规范,后来微软公司基于 COFF 格式,制定了 PE 格式标准,并将其用于当时的 Windows NT 系统。System V Release 4 在 COFF 的基础上引入了 ELF 格式,目前流行的 Linux 系统也以 ELF 作为基本可执行文件格式。这也就是目前 PE 和 ELF 如此相似的主要原因,它们都源于同一种可执行文件格式 COFF。

Unix 最早的可执行文件格式为 a.​out 格式,它的设计非常简单,以至于后来共享库这个概念出现的时候,a.​out 格式就变得捉襟见肘了。于是人们设计了 COFF 格式来解决这些问题,这个设计非常通用,以至于 COFF 的继承者到目前还在被广泛地使用。

COFF 的主要贡献是在目标文件里面引入了 “” 的机制,不同的目标文件可以拥有不同数量及不同类型的 “段”。另外,它还定义了调试数据格式。

目标文件结构

目标文件中的内容除了编译后的机器指令代码、数据以外,还包括链接时所须要的符号表、调试信息、字符串等。一般目标文件将这些信息按不同的属性,以 “节”(Section)的形式存储,有时候也叫 “段”(Segment),在一般情况下,它们都表示一个一定长度的区域,基本上不加以区别,唯一的区别是在 ELF 的链接视图和加载视图的时候,默认情况下统一将它们称为 “段”。

目标文件由许多段组成,其中主要的段包括:

  • 代码段.code.text,保存编译后得到的 机器指令
  • 数据段.data,保存 已初始化的全局静态变量和局部静态变量
  • 只读数据段.rodata,保存 只读变量和字符串常量,有些编译器会把字符串常量放到 .data 段。
  • BSS 段.bss,保存 未初始化的全局变量和局部静态变量

未初始化的全局变量和局部静态变量默认值都为 0,本来它们也可以被放在 .data 段的,但是因为它们都是 0,所以为它们在 .data 段分配空间并且存放数据 0 是没有必要的。程序运行的时候它们的确是要占内存空间的,并且可执行文件必须记录所有未初始化的全局变量和局部静态变量的大小总和,记为 .bss 段。所以 .bss 段只是为未初始化的全局变量和局部静态变量 预留位置 而已,它并没有内容,所以它在文件中也 不占据空间

目标文件的结构

从图中可以看到,ELF 文件的开头是一个 “文件头”,它描述了整个文件的文件属性,包括文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行文件)、目标硬件、目标操作系统等信息,文件头还包括一个段表(Section Table),段表其实是一个描述文件中各个段的数组。段表描述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息。文件头后面就是各个段的内容,比如代码段保存的就是程序的指令,数据段保存的就是程序的静态变量等。

总体来说,程序源代码被编译以后主要分成两种段:程序指令程序数据。代码段属于程序指令,而数据段和 .bss 段属于程序数据。

16.1.7 链接

Linking

目标文件还不能运行,必须进一步转成可执行文件。

编译器的下一步工作,就是把外部函数的代码(通常是后缀名为 .lib.a 的文件),添加到可执行文件中。

make 命令的作用,就是从头文件预编译开始,一直到做完这一步。

基础概念

链接器

链接器是一个独立程序,用于将一个或多个库或目标文件链接到一起,生成可执行程序。

gcc 中的链接器为 ld

image-center

链接器不仅可以把源代码编译成的目标文件链接成为可执行文件,还可以用于生成库文件。

重定位

Relocation

重定位是指给部分程序源代码 分配加载地址,但该部分代码在整体源代码中的位置不是固定的。这个工作通常由链接器来完成。它会在文件或运行库中搜索特定的符号或运行库的名称,用真实可用的内存地址来替换。只有经过该过程,程序才能正常运行。

重定位通常由链接器在链接时完成,但也可以由重定位加载器在加载时完成,或者由运行时程序在运行时完成。

符号

Symbol

在重定位时,汇编语言使用特定的字符串来标记位置,以便于定位。该字符串称为符号。

汇编语言使用接近人类的各种符号和标记来帮助记忆,比如指令采用两个或三个字母的缩写。

符号这个概念随着汇编语言的普及迅速被使用,它用来表示一个 地址,这个地址可能是一段 子程序(后来发展成函数)的起始地址,也可以是一个 变量 的起始地址。

汇编器在每次汇编程序的时候,会重新计算符号的地址,然后把所有引用到该符号的指令修正到这个正确的地址。

符号相当于链接中的粘合剂,整个链接过程正是基于符号才能够正确完成。链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。

模块

随着软件的规模日渐庞大,程序的代码量快速膨胀,于是人们开始将代码 按照功能或性质划分,分别形成不同的功能模块,不同的模块之间按照 层次结构 或其他结构来组织。C语言中,最小的单位是变量和函数,若干个变量和函数组成一个模块,存放在一个 .c 的源代码文件里,然后这些源代码文件按照目录结构来组织。

现代的大型软件往往拥有成千上万个模块,它们相互依赖又相对独立。这种按照层次化及模块化存储和组织源代码有很多好处,比如代码更容易阅读、理解、重用,每个模块可以单独开发、编译、测试,改变部分代码不需要编译整个程序等。

在一个程序被分割成多个模块以后,就要实现 模块之间的通信

最常见的属于静态语言的 C/C++ 模块之间通信有两种方式,一种是模块间的 函数调用,另外一种是模块间的 变量访问。函数调用须知道目标函数的地址,变量访问也须知道目标变量的地址,所以这两种方式都可以归结为一种方式,那就是 模块间符号的引用

链接

程序设计的模块化使得复杂的软件其源代码模块能够独立地编译,然后按照需要将它们 “组装” 起来,这个组装模块的过程就是链接(Linking)。链接的主要内容就是把各个模块之间 相互引用的部分 都处理好,使得各个模块之间能够正确地衔接。从原理上来讲,就是把一些指令对其他符号地址的引用加以修正。

链接过程主要包括了地址和空间分配(Address and Storage Allocation)、符号解析(Symbol Resolution)和重定位(Relocation)等这些步骤。

符号解析

Symbol Resolution,也叫 Symbol Binding,Name Binding,Name Resolution,Address Binding,Instruction Binding。变态!

符号解析就是将目标文件中的 符号引用 与其它目标文件中的 符号定义 绑定 起来

最常见的简单解析方式需要将一个目标文件中的符号引用与另一个目标文件中的符号定义绑定起来。此种绑定可用于两个可重定位目标文件之间,也可用于一个可重定位目标文件与在共享目标文件依赖项中找到的第一个定义之间。复杂解析方式通常用于两个或多个可重定位目标文件之间。

库(library)是用于开发软件的 子程序集合。库和可执行文件的区别是,库不是独立程序,他们是向其他程序提供服务的代码。

链接库 是指把一个或多个库包括到程序中,有两种链接形式:静态链接动态链接,相应的,前者链接的库叫做静态链接库,后者叫动态链接库。

Linux 内核提供很多内核相关的函数库与外部参数,这些核心功能在设计硬件驱动程序时相当有用,多位于 /usr/include/usr/lib/usr/lib64 里面。

静态链接

静态链接是由链接器在链接时 将库的内容加入到可执行程序中 的做法。对于链接器来说,静态链接的过程,就是将几个输入文件加工后 合并 成一个输出文件。输入为目标文件、库文件,输出为可执行文件。

静态链接的最大缺点是生成的 可执行文件体积过大,需要更多的系统资源,在载入内存时也会消耗更多的时间。

链接基本流程

静态链接的主要工作包括空间和地址分配、符号决议、重定位。链接完毕后,数据访问、指令跳转的目标虚拟地址就都确定了。

链接器首先扫描输入的目标文件,计算输出文件各段大小,合并同类段,分配虚拟地址。再根据重定位表(记录哪些指令需要调整、如何调整)和符号表(记录所有的符号及地址),确定编译时无法确定的符号地址,修改机器指令,这一步可以概括为 “把什么修改为什么”。如果最后仍有符号无法确定地址,就报告 “undefined reference” 错误。

合并文件

几个目标文件进行链接时,每个目标文件都有其自身的代码段、数据段等,链接器需要将它们各个段的合并到输出文件中,具体有两种合并方法:

  • 按序叠加:将输入的目标文件按照次序叠加起来。
  • 相似段合并:将相同性质的段合并到一起,比如将所有输入文件的 .text 合并到输出文件的 .text 段,接着是 .data 段、.bss 段等。

第一种方法会产生很多零散的段,而且每个段有一定的地址和空间对齐要求,会造成内存空间大量的内部碎片。所以现在的链接器空间分配基本采用 第二种方法,而且一般采用一种称为 两部链接 的方法:

  1. 空间与地址分配。扫描所有输入的目标文件,获得他们各个段的长度、属性和位置,收集它们符号表中所有的符号定义和符号引用,统一放到一个全局符号表中。此时,链接器可以获得所有输入目标文件的段长度,将他们合并,计算出输出文件中各个段合并后的长度与位置并建立映射关系。
  2. 符号解析与重定位。使用上面收集到的信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。

经过第一步后,输入文件中的各个段在链接后的虚拟地址已经确定了,链接器开始计算各个符号的虚拟地址。各个符号在段内的相对地址是固定的,链接器只需要给他们加上一个偏移量,调整到正确的虚拟地址即可。

重定位

ELF 中每个需要重定位的段都有一个对应的重定位表,也称为重定位段。重定位表中每个需要重定位的地方叫一个重定位入口,包含:

  • 重定位入口的偏移:对于可重定位文件来说,偏移指该重定位入口所要修正的位置的第一个字节相对于该段的起始偏移。
  • 重定位入口的类型和符号:低8位表示重定位入口的类型,高24位表示重定位入口的符号在符号表的下标。

不同的处理器指令对于地址的格式和方式都不一样,对于每一个重定位入口,根据其重定位类型使用对应的指令修正方式修改其指令地址,完成重定位过程。

动态链接

静态链接的问题

  • 同时运行多个静态链接的进程时,如果其中包含多个相同的模块,会级大地浪费内存空间。
  • 一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户

为解决以上问题,可以把程序的模块相互分割开,开成独立的文件,等到程序要运行时才进行链接。

动态链接是指在可执行文件加载或运行时,由操作系统的加载程序动态加载库

现实中,大部分软件采用动态连接,共享库文件。这种动态共享的库文件,Linux 平台扩展名为 .so,Windows 平台是 .dll,Mac 平台是 .dylib

动态链接原理

使用了动态链接之后,当我们运行一个程序时,系统会首先加载该程序依赖的其他的目标文件,如果其他目标文件还有依赖,系统会按照同样方法将它们 全部加载到内存

当所需要的所有目标文件加载完毕之后,如果依赖关系满足,系统开始进行链接操作,包括符号解析及地址重定位等。

完成之后,系统把控制权交回给原程序,程序开始运行。

此时如果运行第二个程序,它依赖于一个已经加载过的目标文件,则系统不需要重新加载目标文件,而只要将它们连接起来即可。

对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。但是对于动态链接来说,除了可执行文件本身,还有它所依赖的共享目标文件(库),此时,它们都是被操作系统用同样的方法映射进进程的虚拟地址空间,只是它们占用的虚拟地址和长度不同。另外,动态链接器也和普通共享对象一样被映射到进程的地址空间。

系统开始运行程序之前,会把控制权交给动态链接器,由它完成所有的动态链接工作,然后再把控制权交回给程序,程序就开始执行。

动态链接优点

模块化的好处不仅仅是节省内存,它还可以 减少物理页面的换入换出,也可以 增加 CPU 缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上。

动态链接方案也可以使程序的升级变得更加容易,当我们要升级程序库或程序共享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接一遍。当程序下一次运行的时候,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。

程序可扩展性和兼容性

动态链接还有一个特点就是程序在运行时可以动态地选择加载各种程序模块,这个优点就是后来被人们用来制作程序的插件。

加载时重定位

动态链接的共享对象在被加载时,其在进程虚拟地址空间的位置是不确定的,为了使共享对象能够在任意地址加载,可以参考静态链接时的重定位(Link Time Relocation)思想,在链接时对所有的绝对地址的引用不做重定位,把这一步推迟到加载时再完成。一旦模块加载完毕,其地址就确定了,即目标地址确定,系统就对程序中所有的绝对地址引用进行重定位。这种加载时重定位(Load Time Relocation)又称为基址重置(Rebasing)。

但是动态链接模块被加载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于加载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位之后对于每个进程来讲是不同的。当然,动态链接库中的可修改的数据部分对于不同的进程来说有多个副本,所以它们可以采用加载时重定位的方法来解决。

16.1.8 安装

Installation

安装就是指把生成的可执行文件与相关数据文件复制到安装目录。

经过链接,链接器在内存中生成了可执行文件。下一步,必须将可执行文件保存到用户事先指定的安装目录。

此时可执行文件与目标文件、源文件都放在一起,非常没有条理。这种情况,很难把程序传递给别人。当然,在编译时可以把生成的可执行文件保存到指定的路径。但是这两种情况都需要复制一些文件,再重新组织一下文件,程序才能正常使用。

提升权限

要说最简单的安装,可以把源文件、目标文件、可执行文件一股脑统统复制到安装路径,在概念上没有什么难点。但往往需要单独的一步:提升权限。因为安装路径往往是系统目录,普通用户是无权写入的,如 /usr/bin

细节操作

在此步骤中,构建系统还有一些细微的细节。例如:

  • 在把程序安装到目标路径之前,构建系统需要首先 创建目录
  • 安装程序时往往需要 “轻量化” 可执行文件:放弃符号表、调试信息等,程序运行时不需要它们
  • 调试符号 单独 分割 也来,保存为一个单独的文件,通常保存在网络上,需要调试信息时再下载

设定文件的权限

为要安装的文件设定权限,很容易弄错,尤其是需要与默认权限不同的时候。在 Linux 中,需要设定精细的权限来避免危及系统安全。

16.1.9 链接到操作系统

单纯的一个可执行文件文件没什么用,它只有一个文件名而已。此时需要的是一种元数据:比文件名更长的描述、图标、版本号诸如此类的东西。操作系统系统需要把程序的所有这些元数据都登记下来。在 Linux 中,这些信息通常保存在 /usr/share/applications 目录中的 .desktop 文件中。在 windows 中还要在开始菜单中创建快捷方式。

这些操作就是 “链接到操作系统”。make install 所做的就是 “安装” 和 “链接到操作系统” 这两步操作。

16.1.10 生成安装包

可执行文件只有做成可以分发的安装包,才便于程序的传播和使用。

所以,编译器还必须有生成安装包的功能。通常是将可执行文件(连带相关的数据文件),以某种目录结构,保存成压缩文件包,交给用户。

16.2 自动化构建工具 make

以上所讨论的是程序编译的理论步骤,但实际使用中不可能每一步都需要人为地干预,更不可能每一步都用 gcc 命令去人工运行,往往会使用更有效率的自动化工具。

make 是一个用于程序构建的 自动化 工具,它会从源代码自动地构建可执行文件和库。它需要借助 makefile 来设定构建过程要遵守的 规则。它可用于任何涉及把命令变成目标文件的场景。

gcc 虽然可以编译、链接单个的文件,但它不知道如何把 多个 源文件组合成一个可执行文件,通常需要至少两个 gcc(编译和链接)调用来创建最简单的程序。

makefile 中的规则详细描述了如何从源代码一步一步地变成目标程序。make 会解析 makefile,确定哪些文件需要编译,然后调用 gcc 进行对应的操作。make 非常适用于 大型 的开发项目,成百上千的源文件,可以持续追踪编译选项、包含路径等,还可以追踪源文件与目标文件的信赖关系,根据规则 只编译最近发生变化的部分

make 会去读取 makefile 中的设置,创建目标文件,链接函数库进行编译。类似于批处理的效果。如果修改了某一个文件之后,重新使用 make 来编译,make 只会更新修改过的文件。

make 使用 目标 做为 参数 来构建程序。如果不加参数,它会构建在 makefile 中第一个出现的目标文件,通常是一个名为 all 的伪目标文件。

但如果修改文件后,其 mtime 未发生变化,此时 make 就检测不到。在还原一个旧版本的源文件时很有可能会发生这样的情况,或者源文件在 NFS 上,其时钟或时区没有与运行 make 的主机同步,也会发生。面对这种情况,必须强制进行一次彻底的重新构建。

相反地,如果文件的时间为未来时间,会造成不必要的重新构建。

make 主要用于自动判断一个大组程序中哪一块需要重新编译,仅针对这一小部分执行命令来编译。只要编译器可以用命令来执行的,都可以用该命令。而且 make 不限于程序代码,可以用它来描述任何任务,只要是需要自动判断部分文件更新的情况就可以。

以 Tarball 方式发布的软件,通常使用 make 命令进行编译。

16.2.1 makefile

makefile 是用于自动编译和链接的,可以看成是一个 配置文件,专门给 make 命令使用。

一个工程有很多文件组成,每一个文件的改变都会导致工程的重新链接,但不是所有的文件都需要重新编译,makefile 中记录着文件的信息,在 make 时会决定在链接的时候需要重新编译哪些文件。

makefile 的宗旨就是:让编译器知道要编译一个文件需要依赖其他的哪些文件。当那些依赖文件有了改变,编译器会自动的发现最终的生成文件已经过时,而重新编译相应的模块。

make 必须配合 makefile 一起使用,它会在当前目录搜索 makefile,然后根据文件的设定运行特定目标。

makefile 中,可以调用 gcc 等工具,把源代码转换成可执行文件。

规则

程序构建的规则都写在 makefile 中。每条规则的形式如下:

<target> : <prerequisites>
[tab]  <commands>

target 目标,必需,不可省略

prerequisites 前置条件,可选

第二行必须由 tab 开头,后面跟着 命令,可选。

前置条件和命令至少要有一个。

每条规则是为了明确:构建目标的 前置条件 是什么,如何构建

目标

一个目标(target)就构成一条规则。

  • 目标通常是 文件名,指明 make 命令所要构建的对象,可以是 一个 文件名,也可以是 多个 文件名,之间用 空格 分隔。
  • 目标还可以是某个 操作 的名字,这称为 “伪目标”(phony target)。
clean:
  rm *.o

上面代码的目标是 clean,它不是文件名,而是一个操作的名字,属于 “伪目标”,作用是删除对象文件。

以目标为参数运行 make

$ make clean

但是,如果当前目录中,正好有一个文件叫做 clean,那么这个命令不会执行。因为 make 发现 clean 文件已经存在,会认为没有必要重新构建了,就不会执行指定的 rm 命令。

为了避免这种情况,可以使用内置目标 .PHONY 来显示声明 clean 是 “伪目标”,写法如下:

.PHONY: clean
clean:
    rm *.o temp

声明 clean 是 “伪目标” 之后,make 就不会去检查是否存在一个叫做 clean 的文件,而是每次运行都执行对应的命令。像 .PHONY 这样的 内置目标名 还有不少,可以查看手册

如果 make 命令运行时没有指定目标,默认会执行 makefile 文件的第一个目标。

$ make

上面代码执行 makefile 文件的第一个目标。

前置条件

前置条件通常是 一组文件名,之间用 空格 分隔。它指定了 “目标” 是否重新构建的 判断标准:只要有一个前置文件不存在,或前置文件的 mtime 比目标的新,目标就需要重新构建。

result.txt: source.txt
    cp source.txt result.txt

上面代码中,构建 result.txt 的前置条件是 source.txt 。如果当前目录中,source.txt 已经存在,那么 make result.txt 可以正常运行,否则必须再写一条规则,来生成 source.txt 。将以下代码加在上面代码之后:

source.txt:
    echo "this is the source" > source.txt

上面代码中,source.txt 后面 没有前置条件,就意味着它 跟其他文件都无关,只要这个文件还不存在,每次调用 make source.txt,它都会生成。

$ make result.txt
$ make result.txt

上面命令连续执行两次 make result.txt。第一次执行会先新建 source.txt,然后再新建 result.txt。第二次执行,Make 发现 source.txt 没有变动,就不会执行任何操作,result.txt 也不会重新生成。

如果需要生成多个文件,往往采用下面的写法。

source: file1 file2 file3
file1:
    echo "this is the file1" > file1
file2:
    echo "this is the file2" > file2
file3:
    echo "this is the file3" > file3

上面代码中,source 是一个伪目标,只有三个前置文件,没有任何对应的命令。

$ make source

执行 make source 命令后,就会一次性生成 file1,file2,file3 三个文件。这比下面的写法要方便很多。

命令

命令(commands)表示如何更新目标文件,由一行或多行的 Shell 命令组成。它是构建目标的具体指令,它的运行结果通常就是生成目标文件。

每行命令之前 必须 有一个 tab 键。如果想用其他键,可以用内置变量 .RECIPEPREFIX 声明。

.RECIPEPREFIX = >
all:
> echo Hello, world

上面代码用 .RECIPEPREFIX 指定,大于号(>)替代 tab 键。所以,每一行命令的起首变成了大于号,而不是 tab 键。

需要注意的是,每行命令在一个 单独的 shell 中执行,属于不同的进程。这些 Shell 之间 没有继承关系

var-lost:
    export foo=bar
    echo "foo=[$$foo]"

上面代码执行后 make var-lost,取不到 foo 的值。

一个解决办法是将两行命令写在一行,中间用 分号 分隔:

var-kept:
    export foo=bar; echo "foo=[$$foo]"

另一个解决办法是在换行符前加反斜杠 转义

var-kept:
    export foo=bar; \
    echo "foo=[$$foo]"

最后一个方法是使用内置目标 .ONESHELL:

.ONESHELL:
var-kept:
    export foo=bar;
    echo "foo=[$$foo]"

makefile 文件的语法

注释

# 在 makefile 中表示注释,用法与 bash 中的注释一样。

回显

echoing

正常情况下,make 会回显每条命令及注释,然后再执行,如果在命令前加上 @,就可以关闭该条命令的回显。

test:
	@# comment
	@echo TODO

由于在构建过程中,需要了解当前在执行哪条命令,所以通常只会关闭注释和 echo 的回显。

通配符

通配符用来指定一组符合条件的文件名。makefile 的通配符与 Bash 一致,主要有 *[...]

clean:
        rm -f *.o
模式匹配

make 命令允许对文件名,进行类似正则运算的匹配,使用匹配符 %,可以将大量同类型的文件,只用一条规则就完成构建。

比如,假定当前目录下有 f1.cf2.c 两个源码文件,需要将它们编译为对应的对象文件。

%.o: %.c

等同于:

f1.o: f1.c
f2.o: f2.c
变量和赋值符

makefile 允许使用等号 = 为变量赋值。

txt = Hello World
test:
    @echo $(txt)

上面代码中,变量 txt 等于 Hello World。调用时,变量需要放在 $( ) 之中。

调用 Shell 变量,需要在美元符号前,再加一个美元符号,这是因为 make 命令会对美元符号转义。

test:
    @echo $$HOME

有时,变量的值可以是另一个变量。

v1 = $(v2)

上面代码中,变量 v1 的值是另一个变量 v2。这时会产生一个问题,v1 的值到底在定义时扩展(静态扩展),还是在运行时扩展(动态扩展)?如果 v2 的值是动态的,这两种扩展方式的结果可能会差异很大。

为了解决类似问题,Makefile一共提供了四个 赋值运算符

=

VARIABLE = value

执行时扩展,允许递归扩展。

:=

VARIABLE := value

定义时扩展

?=

VARIABLE ?= value

只有在该变量 为空时才赋值

+=

VARIABLE += value

将值 追加 到变量的尾端。

内建变量

Implicit Variables

make 命令提供一系列内建变量,比如,$(CC) 指向当前使用的编译器,$(MAKE) 指向当前使用的 make 工具。这主要是为了跨平台的兼容性。

output:
    $(CC) -o output input.c
自动变量

Automatic Variables

make 命令还提供一些自动变量,它们的值与当前规则有关。主要有以下几个。

$@

$@ 指代 当前目标

a.txt b.txt:
    touch $@

等同于:

a.txt:
    touch a.txt
b.txt:
    touch b.txt
$<

$< 指代 第一个前置条件。比如,规则为 t: p1 p2,那么 $< 就指代 p1。

a.txt: b.txt c.txt
    cp $< $@

等同于:

a.txt: b.txt c.txt
    cp b.txt a.txt
$?

$? 指代 比目标更新的所有前置条件,之间以空格分隔。比如,规则为 t: p1 p2,其中 p2 的时间戳比 t 新,$? 就指代 p2

$^

$^ 指代 所有前置条件,之间以空格分隔。比如,规则为 t: p1 p2,那么 $^ 就指代 p1 p2

$*

$* 指代匹配符 % 匹配的部分, 比如 % 匹配 f1.txt 中的 f1$* 就表示 f1

$(@D)$(@F)

$(@D)$(@F) 分别指向 当前目标 $@目录名文件名。比如,$@src/input.c,那么 $(@D) 的值为 src$(@F) 的值为 input.c

$(<D)$(<F)

$(<D)$(<F) 分别指向 第一个前置条件 $<目录名文件名

判断和循环

makefile 使用 Bash 语法完成判断和循环。

ifeq ($(CC),gcc)
  libs=$(libs_for_gcc)
else
  libs=$(normal_libs)
endif

上面代码判断当前编译器是否 gcc ,然后指定不同的库文件。

LIST = one two three
all:
    for i in $(LIST); do \
        echo $$i; \
    done

运行结果为:

one
two
three
函数

makefile 还可以使用函数,格式如下。

$(function arguments)${function arguments}

makefile 提供了许多内置函数,可供调用。下面是几个常用的内置函数。

shell 函数

shell 函数用来 执行 shell 命令

srcfiles := $(shell echo src/{00..99}.txt)
wildcard 函数

wildcard 函数用来 替换 Bash 的通配符

srcfiles := $(wildcard src/*.txt)
subst 函数

subst 函数用来 文本替换,格式如下:

$(subst from,to,text)

下面的例子将字符串 feet on the street 替换成 fEEt on the strEEt

$(subst ee,EE,feet on the street)

下面是一个稍微复杂的例子:

comma:= ,
empty:=
# space 变量用两个空变量作为标识符,用空格分隔
space:= $(empty) $(empty)
foo:= a b c
bar:= $(subst $(space),$(comma),$(foo))
# bar is now `a,b,c'.
patsubst 函数

patsubst 函数用于 模式匹配的替换,格式如下:

$(patsubst pattern,replacement,text)

下面的例子将文件名 x.c.c bar.c,替换成 x.c.o bar.o

$(patsubst %.c,%.o,x.c.c bar.c)
替换后缀名

替换后缀名函数的写法是:

变量名 + 冒号 + 后缀名替换规则

它实际上是 patsubst 函数的一种简写形式。

min: $(OUTPUT:.js=.min.js)

上面代码的意思是,将变量 OUTPUT 中的后缀名 .js 全部替换成 .min.js

makefile 范例

执行多个目标
.PHONY: cleanall cleanobj cleandiff

cleanall : cleanobj cleandiff
        rm program

cleanobj :
        rm *.o

cleandiff :
        rm *.diff
编译 C 语言项目
edit : main.o kbd.o command.o display.o
    gcc -o edit main.o kbd.o command.o display.o

main.o : main.c defs.h
    gcc -c main.c
kbd.o : kbd.c defs.h command.h
    gcc -c kbd.c
command.o : command.c defs.h command.h
    gcc -c command.c
display.o : display.c defs.h
    gcc -c display.c

clean :
     rm edit main.o kbd.o command.o display.o

.PHONY: edit clean

另一个:

TARGET = prog
LIBS = -lm
CC = gcc
CFLAGS = -g -Wall

.PHONY: default all clean

default: $(TARGET)
all: default

OBJECTS = $(patsubst %.c, %.o, $(wildcard *.c))
HEADERS = $(wildcard *.h)

%.o: %.c $(HEADERS)
    $(CC) $(CFLAGS) -c $< -o $@

.PRECIOUS: $(TARGET) $(OBJECTS)

$(TARGET): $(OBJECTS)
    $(CC) $(OBJECTS) -Wall $(LIBS) -o $@

clean:
    -rm -f *.o
    -rm -f $(TARGET)

注:最后两行的 -rm 中的减号 - 表示如果该命令返回错误,不要停止,继续运行下一条命令。如果不加该修饰符,make 遇到错误会中止运行。

16.2.2 configure

上面提到的 makefile 这么有用,每次都要手动编写吗?不用。

configure 是指一个 可执行脚本,它是专门为了帮助程序开发而设计的,在编译之前,它会检查编译环境,判断哪些库是可以用的。作为惯例,所有的这种脚本都命名为 configure,通常是为 Bourne Shell 编写的,基本上可以在任何 shell 中执行。

configure 用于 生成 makefile,以便使用 make 来实现自动化构建程序。

configure 脚本有成千上万条代码,也无需手动编写,它是由名为 autotools 的一系列程序产生的,其中包括 autoconfautomake 及其它程序,它们为软件的维护带来很大的便利,最终的使用用户基本上看不到这些工具,但这些工具却最终帮助他们在不同发行版上可以顺利地安装软件。

configure 脚本会 检查 当前系统,使用它收集到的信息把 Makefile.in 模板 文件 转换 成一个 makefile

configure 所检查的内容:

  • 编译器
  • 函数库或其他信赖
  • 操作系统,Linux 内核版本
  • 内核的表头定义文件(header include)

16.2.3 使用 make

只有简单的软件才会使用一个比较通用的、简单的 makefile,更加复杂的安装需要根据库、头文件及其它资源的位置来裁剪 makefile

make 如何解析参数

make shit

make 首先会在 makefile 中查找名为 shit目标 及其规则,如果没找到,它会在其内置规则中查找,其中有一条内置规则会告诉 make,如果在当前目录发现了名为 shit扩展名为 .o 的文件,即 shit.o,可以运行 ld 等链接器,用其构建可执行文件。

如果找到了 shit.o,make 还要确认它是否是最新的,即其 mtime 是否比源文件 shit.c 新。

如果 make 也发现了源文件 shit.c,它会检查其时间戳,以确保 shit.o 比较新一些。如果 shit.o 不存在,或比 shit.c 要旧,make 会使用另一个内置的规则,使用 gcc 等工具把 shit.c 编译成一个新的 shit.o

于是 make 运行的结果,就是保证构建可执行文件,并是最新版的。

make 常用参数

调用 make 时如果不使用任何参数,通常会执行 makefile 中第一个目标。

make 还可以完成许多其他任务:

make install 把文件安装到正确的路径,此时 installmakefile 中的一个目标。

make clean 删除无用的目标文件,同样 clean 也是一个目标

make -n 预览构建过程,会把所有将要运行的命令全部打印出来

16.3 Tarball 程序的构建与安装流程

以上所有文字都是为了帮助理解程序构建的过程,但实际工作中空间要如何一步步地操作呢?

许多软件包是以源代码压缩包的形式传递的,同一个软件包在生成程序以后,可以运行于多种不同的主机上,节省了作者的精力,不用花时间去对不同的系统做不同的版本。只不过这个任务落在了你我的身上。

16.3.1 Tarball

Tar 是压缩打包工具,可以将多个文件合并为一个文件,打包后的后缀为 .tar。已压缩的 tar 文件也叫 “Tarball”。大部分开源软件的源代码采用 tarball 的形式发布。被压缩的 tar 文件则追加压缩文件的扩展名,如经过 gzip 压缩后的 tar 文件,扩展名为 “.tar.gz”,其它的如 .tar.bz2 .tar.xz

开源软件的 Tarball 中通常有:

  • 源代码文件
  • 检测程序文件(configure 脚本)
  • 简介与安装说明(INSTALL 或README)

以 Tarball 方式发布的软件,通常使用 make 命令进行编译。

16.3.2 经典的安装三步曲

  • ./configure
  • make
  • make install

多数人习惯性地用了多年,也不明白这三步曲的意义所在,现在我们都懂了:

首先,如果 Tarball 中提供了帮助文档 README 或 INSTALL,可以先阅读一下。

./configure 是为了生成 makefile 文件。

如果帮助文档中有说明,此时可能需要运行一下 make clean 来删除现有的目标文件,以便下一步生成新的目标文件。

make 不带参数地执行 make,默认会执行 makefile 中的第一个目标,通常会进行一系列的编译、链接的操作,生成可执行文件。

make install install 为 makefile 中的一个目标。编译生成的可执行文件和相关配置文件与源代码在同一目录,install 目标的命令会把这些文件安装(移动)到正确的目录。

16.3.3 安装路径

发行版安装的软件大多在 /usr/ 目录中,用户自行安装的软件建议位于 /usr/local/ 中。

以软件名 ABC 为例:

  • 总目录:/usr/local/ABC/
  • 源代码:/usr/local/ABC/src/
  • 配置文件:/usr/local/ABC/etc/
  • 可执行文件:/usr/local/ABC/bin/
  • 函数库:/usr/local/ABC/lib/
  • 说明文档:/usr/local/ABC/man/

默认情况下,man 程序会检查 /usr/local/ 中的说明文档。

需要把 /usr/local/ABC/man 加入 man page 的查找路径中。/etc/man_db.conf 中约 40~50 行处,插入:

MANPATH_MAP /usr/local/software/bin /usr/local/software/man

16.2.4 更新源代码

很多软件开发商在更新了源代码之后,会发布更新文件。

patch -pN < patch_file