[关闭]
@zhengyuhong 2015-04-13T10:22:13.000000Z 字数 5747 阅读 1389

程序员的自我修养-链接、装载与库

读书笔记


1、温故而知新

1.1、从Hello World说起

  1. #include <stdio.h>
  2. int main(char* argv[], int argc){
  3. printf("Hello World\n");
  4. return 0;
  5. }

2、编译与链接

  C语言经典Hello World

  1. #include <stdio.h>
  2. int main(char* argv[], int argc){
  3. printf("Hello World\n");
  4. return 0;
  5. }

  在Linux下,当我们使用GCC来编译Hello World程序时,只须使用最简单的命令(假设源代码文件名为hello.c)

  1. gcc hello.c
  2. ./a.out

  事实上,上述过程可以分解为4个步骤,分别是预处理、汇编、链接

2.1.1、预处理

  首先是源代码文件hello.c和相关头文件stdio.h被预编译器与预编译成为一个.i文件。第一步与编译的过程相当如下命令(-E表示只进行预编译)

  1. gcc -E hello.c -o hello.i

  预编译过程主要处理源代码中以“#”开始的预编译指令,譬如“#include”、“#define”等,主要处理规则如下:

2.1.2、编译

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

gcc -S hello.i -o hello.s

2.1.3、汇编

  汇编器是将汇编代码转变为机器码,每一个汇编语句几乎都对应一条指令。所以汇编器的过程相对于编译器来说比较简单,它没有复杂的语法,也没有语义,也不需要指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了。
as hello.s -o hello.o或者

as -c heloo.s -o hello.o
  通过我们在生成目标文件*.o时是直接使用如下命令,涵盖了预编译、编译、汇编直接生成目标文件
gcc -c hello.c -o hello.o

2.1.4、链接

  链接通常是一个让人费解的过程,为什么汇编器不直接输出可执行文件而是输出一个目标文件呢?暂且不表,有了其他预备知识再讲述。

2.2、编译器做了什么

  从最直观的角度来讲,编译器就是将高级语言翻译成机器语言的一个工具。
  编译过程一般可以分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。

2.4、模块拼装--静态链接

  程序设计的模块化是人们一直追求的目标,因为当一个系统十分复杂的时候,我们不得不将复杂的系统逐步分割成小的系统以达到各个突破的目的。一个复杂的软件也如此,人们把每个源代码模块独立编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接(Linking)。链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确衔接。链接器所要做的工作其实就是把一些指令对其他符号地址的引用加以协整。链接过程主要包括了地址空间分配符号决议重定位
  符号决议有时候也被叫做符号绑定(Symbol Binding)、名称绑定(Name Binding)、名称决议(Name Resolution),甚至还有叫做地址绑定(Address Binding)。大体意思都是一样,但从细节角度区分,它们之间还是存在一定区别。比如“决议”更倾向静态链接,而“绑定”更倾向于动态链接,即它们so使用的范围不一样。
  最基本的静态链接过程如下:每一个模块的源代码经过编译器编译成目标文件,目标文件和库一起链接形成最终可执行文件。
  

3、目标文件有什么

3.1、目标文件格式

  目标文件就是源代码编译后但未进行链接的那些中间文件,它跟可执行文件的内容与结构很相似,所以一般跟可执行文件格式一起采用一种格式存储。从广义上看,目标文件与可执行文件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行文件看成一种类型的文件。不光目标文件按照可执行文件格式存储,动态链接库(DLL,Dynamic Linking Library)、静态链接库(Static Linking Library)文件都按照可执行文件格式存储。静态链接库稍有一些不同,它把恒丰多目标文件捆绑在一个文件,然后加上一些索引,可以简单理解它为一个包含很多目标文件的文件包。
  程序源代码编译后的机器指令经常被放在代码段里;全局变量和局部静态变量经常放在数据段。

  1. int global_init_var = 84; //data section(数据段)
  2. int global_uninit_var; //bss section(数据段)
  3. void foo(int x){ //text section(代码段)
  4. printf("%d\n",x);
  5. }
  6. int main(char* argc[],int argc){//text section
  7. static int static_var_a = 85;//data section
  8. static int static_var_b;//bss section
  9. int a = 1;//data section
  10. int b;//bss section
  11. foo(static_var_a);//text section
  12. return 0;
  13. }

  一般C语言编译后执行语句都编译成既机器代码,保存在text段中,已初始化的全局变量和局部静态变量都保存在data段中,而未初始化的全局变量与局部静态变量一般放在一个叫bss段中。未初始化的全局变量和局部静态变量默认值都为0,本来它们也可以被放在data段的,但是因为它们都是0,所以为它们在data段分配空间并且存放数据0是没有必要。程序运行的时候它们的确需要占内存,并且可执行文件必须记录所以未初始化的全局变量和局部静态变量的大小综合,记为bss段。所以bss段知识为未初始化的全局变量和局部静态变量预留位置而已(用于申请内存),它并没有内容,所以在文件中也不占空间,仅仅一个符号表示。
  代码段与数据段分开存放的原因是:
  
- 数据可读可写,程序只能读不能写,防止程序的指令被有意或者无意修改。
- 对于CPU来说,有指令缓存、数据缓存,分开提高命中率
- 多进程共享数据

3.5、链接的接口--符号

  链接过程的本质就是要把多个不同的目标文件之间相互“粘”在一起,为了让不同目标文件能够相互粘合,目标文件之间必须有固定的规则,就像积木模块必须有凹凸部分才能拼合。在链接中,目标文件之间相互拼合实际是目标文件之间对地址的引用,即对函数和变量的地址的引用。比如目标文件B用到目标文件A的函数“foo”,那么我们就称目标文件A定义(define)了函数“foo”,称目标文件B引用(reference)了目标文件A中的函数“foo”。这两个概念适用于变量、函数。每个函数或者变量都有自己独特的名字,才能避免链接过程中不同变量和函数之间的混淆。在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。每一个目标文件都一个对应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每一个定义的符号有一个对应的值,对于变量和函数来说,这个值就是它们的地址。

3.5.3、符号修饰语函数签名

  在以前符号表(Symbol Table)中符号名与相应变量的名字是一样的,比如有函数int foo(int x);,foo在符号表中的符号名也是foo,为了防止符号名冲突,UNIX下的C语言就规定,C语言源代码文件中的所有全局变量和函数经常编译后,相对应的符号名前加上"_",而Fortan语言的源代码编译后,符号的符号名前后加上"_"。比如一个C函数foo在符号表就变成了"_foo",而Fortan语言的就是"_foo_"。这种方法减少了多种语言目标文件之间的符号冲突,但是还没有从根本上解决符号冲突问题。比如同一种语言编写的目标文件还可能会产生符号冲突。当程序很大时,不同模块由多个部门(个人)开发,它们之间的命名规则如果不严格,就会产生冲突。于是像后来新生的C++开始考虑这个问题,增加了命名空间的方法来解决多模块的符号冲突问题。
  从所周知,强大而又复杂的C++拥有类、继承、虚机制、重载、命名空间等特性,它们使得符号管理更加复杂。最简单的例子就是有相同名字的函数

  1. int foo(int x);
  2. int foo(double x);

  为了支持C++这些复杂特性,人们发明了符号修饰(Name decoration)或符号改编(Name Mangling)的机制。比如:

  1. int func(int);
  2. float func(float);
  3. class C{
  4. int func(int):
  5. class C2 {
  6. int func(int);
  7. }
  8. namespace N{
  9. int func(int);
  10. class C{
  11. int func(int);
  12. }
  13. }

  这段代码中有6个同名函数func,只不过它们的返回类型和参数及所在命名空间不同。我们引入一个函数签名(Function Signature),函数签名囊括了函数的所有信息,包括函数名、参数类型、所在的类、命名空间以及其他信息。这样子对于不同函数产生的函数签名就不一样了,能够识别不同函数。上面6个函数签名在GCC编译器下,相对应的修改后的名称如下:

函数签名 符号名
int func(int) _Z4funci
float func(float) _Z4funcf
int C::func(int) _ZN1C4funcEi
int C::InnerC::func(int) _ZN1C2C23funcEi
int N::func(int) _ZN1N4funcEi
int N::C::func(int) _ZN1N1C4funcEi

4、静态链接

4.1.1、空间与地址分配

  对于链接器来说,整个链接过程中。它就是将几个输入目标文件加工后合并成一个输出文件,连接器如何将它们的各个段合并到输出文件?
  一个最简单的方法就是将输入的目标文件按照次序叠加。但是这样坐有一个问题,在有很多输入目标文件情况下,输出文件将会有很多零散的段。
  一个更实际的方法是将相同性质的段合并到一起,比如讲text section合并到输出文件的text section,data section合并到输出文件的data section,bss section合并到输出文件的bss section。

7、动态链接

7.1、为什么要动态链接

  静态链接使得不同程序开发者和部门能够相对独立开发和测试自己的程序模块,从某种意义上来讲大大促进程序开发的效率。但是慢慢地发现,静态链接可能会导致内存、硬盘、模块更新等困难。
  如模块分工时都引用了静态库lib.a,但最终链接组合时可执行程序中含有多份lib.a,浪费内存、磁盘空间。
  当lib.a更新,所有引用了lib.a的目标文件、可执行文件都需要重新链接。
  要解决空间浪费个更新问题最简单的方法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态地链接起来。简单来讲,就是不对那些组成程序的目标文件进行链接,等到程序运行时才进行链接。把链接的过程推迟到运行时再进行。

7.3.1、固定装载地址的困扰

  共享对象(lib.so)在被装载时,如何确定它在进程虚拟地址空间中的位置?
  回顾第2章提到的,程序模块的指令和数据中可能会包含一些绝对地址的引用,我们在链接产生输出文件的时候,就假设模块被装载的目标地址。很明显动态链接情况下,这不可行。当多个程序引用多个共享对象(多对多),这样子管理共享对象的地址就是无比繁琐的。
  为了解决这个模块装载地址固定的问题,我们设想是否可以让共享对象在任意地址加载?这个问题另一种表述方法就是:共享对象在编译时不假设自己在进程中虚拟地址中的位置。基本思路是让链接器去管理动态库,如果当前动态库没有加载到内存,就加载,然后返回加载地址,倘若已经加载,直接返回地址给进程。

10、内存

10.2.1

什么是栈
  栈(stack)是计算机最要重要的一个概念,没有栈就没有函数、局部变量。在计算机系统中,栈是一个具有FIFO的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
  栈在运行中具有举足轻重的地位。栈保存了一个函数调用所需要的维护信息,这常常被称为栈帧(Stack Frame)或者活动记录(Active Record)。栈帧一般包括如下几个方面内容。
- 函数的返回地址和参数
- 临时变量
- 保存的上下文:包括函数调用前后保持不变的寄存器。

10.3.1、堆

  相对于栈而言,堆这篇内容面临一个稍微复杂的行为模式:在任意时刻,程序可能发出请求,要么申请一段内存,要么释放一段已申请过的内存。
  光有栈对于面向过程的程序设计还远远不够,因为栈熵的数据在函数返回时就会被释放掉,所以无法将数据传递到函数外部。而全局变量没有办法动态地产生。只能在编译的时候定义,在这种情况,堆是唯一的选择。
  堆是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分。在这片空间里,程序可以请求一块连续内存,并自由地使用,这块内存在程序主动放弃之前都会一直保持有效。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注