《程序员的自我修养》第4章---静态链接

x33g5p2x  于2021-11-14 转载在 其他  
字(5.4k)|赞(0)|评价(0)|浏览(244)

第4章 静态链接

4.1 空间和地址分配:

a.c :

extern int shared;

int main() 
{
	int a = 100;
	swap(&a, &shared);
}

b.c :

int shared = 1;
void swap(int* a, int* b)
{
    *a ^= *b ^= *a ^= *b;
}

对于链接器来说,整个链接过程,它的工作就是将几个输入的目标文件加工、合并成一个输出的可执行文件。

例如将输入的 a.o 和 b.o 文件合并成可执行文件 ab。

链接器的合并方式:

4.1.1 按序叠加:

pass
浪费空间。

4.1.2 相似段合并:

将所有输入文件的 .text 合并到输出文件的 .text段,.data段合并到 .data段,以此类推,等等。

“链接器为目标文件分配地址和空间” 这句话中的 “地址和空间” 其实有两个含义:

  1. 在输出的 可执行文件 中的空间;(链接器通过 .o文件生成可执行文件,所以可执行文件有多大、文件内容都是由链接器决定的,所以可执行文件的各个段的空间由链接器负责生成)
  2. 在装载后的 虚拟地址 中的空间;(程序在被操作系统加载后开始运行,此时为进程,操作系统为其分配虚拟地址空间,进程的虚拟地址空间中包含 .text, .data 等各个段的空间,这个空间大小也是由链接器进行赋值的)

当我们谈到空间分配时,只关注虚拟地址空间的分配。

“相似段合并”的空间分配方法采用 “两步链接”(Two-pass Linking)的方法:

  1. 第一步: 空间与地址分配;(搜集所有输入文件.o 的符号信息、段表长度,将其统一放到一个全局符号表中,给每个符号分配虚拟地址,通过每个符号的偏移量计算各个符号的虚拟地址)
  2. 第二步: 符号解析与重定位。(根据第一步中搜集到的信息进行 符号解析、重定位、调整代码中的地址等)

在Linux下,ELF可执行文件默认从地址 0x08048000 开始分配(32位操作系统)。

4.2 符号解析与重定位:

4.2.2 重定位表:

链接器如何知道哪些指令需要被调整?
借助于 ELF文件中的 “重定位表”(Relocation Table)。

重定位表在ELF文件中一般是一个或多个段,例如,如果 .data段中有需要被重定位的符号,那么ELF文件中就会有一个相对应的 .rel.text段,用于保存 .data段中需要被重定位的地方。

使用 objdump -r 命令可以查看目标文件中的所有重定位入口:

objdump -r, --reloc  		Display the relocation entries in the file
	//查看目标文件中的所有重定位入口

例如,查看 a.o 目标文件中的重定位入口信息:

[linux] objdump -r a.o

a.o:     文件格式 elf64-x86-64

RELOCATION RECORDS FOR [.text]:
OFFSET           TYPE              VALUE
0000000000000014 R_X86_64_32       shared
0000000000000021 R_X86_64_PC32     swap-0x0000000000000004

RELOCATION RECORDS FOR [.eh_frame]:
OFFSET           TYPE              VALUE
0000000000000020 R_X86_64_PC32     .text

查看 b.o 目标文件中的重定位入口信息:

[linux] objdump -r b.o

b.o:     文件格式 elf64-x86-64

RELOCATION RECORDS FOR [.eh_frame]:
OFFSET           TYPE              VALUE
0000000000000020 R_X86_64_PC32     .text

可以看到 a.o 中有两个重定位入口,重定位入口 偏移(Offset) 表示该入口在要被重定位的段中的位置。

4.4 C++相关问题:

C++的一些语言特性使之必须由编译器和链接器共同支持才能完成,最主要的有两个方面:

  1. 一个是C++的重复代码消除;
  2. 还有一个是 全局构造与析构。

4.4.1 重复代码消除:

C++编译器会产生重复代码的场景: 模板、外部内联函数、虚函数表, 这些都有可能在不同的编译单元里产生相同的代码。

模板在本质上来讲很像宏(宏也是符号的替代),

例如一个模板 template class A {}; 在头文件 header.h 中,
在 a.c 中实例化 A a, 在 b.c 中实例化为 A b,这会导致在 a.o 与 b.o 的目标文件中存在重复的代码,
“当模板在一个编译单元里被实例化时,它并不知道自己是否在别的单元也被实例化了。所以当一个模板在多个编译单元同时实例化成相同的类型的时候,必然会生成重复的代码。”
如果不管这些,直接将重复的代码都保留下来,会造成下面几个问题:

  1. 空间浪费;(假设几百个编译单元同时实例化了许多个模板)
  2. 地址较易出错;
  3. 指令运行效率较低;

一种解决模板产生重复代码的方法是:

编译器 ----> 遇到template类模板或函数模板时,将每个模板的实例代码都单独存放在一个段里(目标文件中),在目标文件中以相同的规则取名 ----> 链接器 ----> 在最终的链接阶段,将同类型的模板实例的段进行合并,然后生成可执行文件。

例如有一个模板函数 add(), 某个编译单元.c文件中以 int 和float 类型实例化了该模板函数,那么该编译单元的目标文件中就包含了两个该模板实例的段,假设名为:
.temp.add , .temp.add
这样,当别编译单元也以 int 或 float 类型实例化 该模板函数后,也会生成相同的名字,
这样链接器在最终链接的时候就可以区分这些相同的模板实例段,然后将它们合并入最后的代码段。

这种做法目前被主流编译器所采用,包括 GCC 和 Visual C++。

对于 外部内联函数 和 虚函数表,消除重复代码的方法也与 模板 的做法类似:

例如对于一个有虚函数的类,有一个与之对应的虚函数表,编译器会在用到该类的多个编译单元生成虚函数表,造成代码重复,默认构造函数、默认拷贝构造函数、赋值构造运算符等也有类似的问题,解决方法与类模板一样: 编译器在 编译阶段 在目标文件.o中为虚函数表生成单独的段,链接器在 链接阶段 合并不同目标文件中的同名的段,最终生成可执行文件。

特殊情况是:
不同的编译单元使用不同的编译器,然后将生成的多个目标文件.o 使用链接器进行合并,
此时不同的编译单元中的段的名字相同,但内容由于编译器版本或者编译选项的不同,导致同一个函数编译出来的实际代码有所不同,此时:
链接器会做出一个选择,随机选取一个版本进行链接,并抛出一个警告消息。

函数链接级别:

Visual C++编译器提供一个 编译选项 叫 “函数级别链接”,针对程序和库较大时,成千上百个函数或变量,有些函数或变量可能并没有被用到,此时就没必要将其链接进来,此编译选项允许当链接器用到某个函数时,再将其合并到输出文件中,以达到节省空间的目的。缺点是会减慢编译和链接过程。

4.4.2 全局构造与析构:

一个C/C++程序 是从main开始执行,随着main函数的结束而结束。
在main函数开始调用之前,操作系统需要先初始化进程执行环境,包括:
堆内存分配初始化、线程子系统等。 C++全局对象的构造函数也在这一时期被执行。

C全局对象 的构造函数在 main 之前被执行,C全局对象 的析构函数在 main之后被执行。

Linux系统下一般程序的入口是 _start 函数,这个函数是Linux系统库(Glibc)的一部分。

4.4.3 C++ 与 ABI:

ABI = Application Binary Interface,与API(Application Programming Interface)类似,只是在不通层面的接口。

如果要让不同的编译器产生的目标文件能够兼容链接,要考虑它们的ABI是否兼容。

4.5 静态库链接:

程序之所以有用,因为它会有输入输出,这些输入输出的对象可以是数据,可以是人,也可以是另外一个程序,还可以是另一台计算机,一个没有输入输出的程序没有任何意义。

但是一个程序如何做到输入输出呢?
最简单的办法是使用操作系统提供的API(应用程序编程接口,Application Programming Interface)。

程序如何调用操作系统提供的API呢?
一般情况下,一种语言的开发环境虎附带有 “语言库”(Language Library),这些库就是对操作系统API的包装。
例如经典的C语言的“hello world”程序,它使用 C语言标准库 的 printf函数来输出一个字符串,在Linux下,它是对 write 系统调用的封装,在Windows下,它是对 WriteConsole 系统API的封装。

如何组织C运行库:
在一个C运行库中,包含了很多跟系统功能相关的代码,例如输入输出、文件操作、时间日期、内存管理等等,glibc中由成百上千的C语言源程序文件组成,也就是编译后会产生相同数量的目标文件。
如果把这些零散的目标文件直接提供给库的使用者,则会造成传输、管理的不便,因此,通常使用 ar 工具将这些目标进行压缩打包,生成 libc.a 静态库文件。

可以使用ar工具查看静态库文件中包含哪些目标文件:
(libc.a中共包含了大概 1400个目标文件)

ar -t libc.a

...

其实一个静态库可以简单看成 一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。

ar 压缩程序 ----> 多个 .o 文件 ----> 合成 .a 库文件

一个链接过程,就是不断的寻找程序中的符号所在的目标文件,然后将其加入进来,如果靠人工这将是一个很复杂的过程。

例如:
hello.c ----> printf.o ----> stdout.o, vprintf.o ----> …
(hello.c中包含printf()函数,在printf.o目标文件中,printf.o中又有stdout和vprintf两个符号,以此类推,找出所有的依赖的符号所在的目标文件。。)

“幸好ld链接器会处理这一切繁琐的事务,自动寻找所有需要的符号及它们所在的目标文件,将这些目标文件从 “libc.a”中 “解压”出来,最终将它们链接在一起称为一个可执行文件。”

然而仅仅是将.a库中的所有依赖目标文件找到并链接仍是不够的,后面再继续介绍。

collect2:

collect2 可以看作是ld链接器的一个包装,它会调用ld链接器来完成对目标文件的链接,然后再对链接结果进行一些处理,主要是收集所有与程序初始化相关的信息并且构造初始化的结构。

Q&A:

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

9. C/C++ 运行库、静态库、动态库 的区别与联系:

https://blog.csdn.net/ithzhang/article/details/20160009

9.1 从C和C++运行库说起:

为了提高C语言的开发效率,C标准定义了一系列常用的函数,称为C库函数。
C标准仅仅定义了函数原型,并没有提供实现。因此这个任务留给了各个支持C语言标准的编译器。

每个编译器通常实现了标准C的超集,称为 “C运行时库”(C Run Time Library),简称 CRT。

对于VC++编译器来说,它提供的CRT库支持C标准定义的标准C函数,同是也有一些专门针对Windows系统特别设计的函数(对于Linux系统、GCC编译器也是一样的道理)。

可以简单理解:
C标准库 + 编译器自定义的针对具体操作提供的专门函数 = C运行时库(CRT)

与C语言类似,C也定义了自己的标准,同时提供相关支持库,我们将它称为C运行时库或C++标准库。

由于C对C的兼容性,C标准库包括了C标准库,除此之外还包括 IO流标准模板库STL

9.2 VC在何处实现C和C运行库:

VC完美支持C和C标准,即按照C和C++的标准中定义的函数原型实现了上述运行时库。

为了方便有不同需求的用户的使用,VC++分别实现了 动态链接库DLL版本 和 静态库LIB版本。
同时为了支持程序调试且不影响程序的性能,又分别提供了对应的调试版本。

对于C运行时库 CRT、VC6.0、VC2005、VC2008、VC2010等,均提供了 DLL版本 和 LIB版本(供用户根据实际需要选择DLL方式还是LIB方式的库)。

9.3 动态版(DLL)和静态版(LIB)C和C++运行库的优缺点:

动态链接库: DLL(Dynamic Linking Library),Windows下的 .dll 和 Linux下的 .so 文件;
静态链接库: Static Linking Library, Windows下的 .lib 和 Linux下的 .a 文件。

因为静态版必须把C和C++运行库复制到目标程序(.o)中,所以产生的可执行文件会比较大。

使用DLL版的C和C++运行库,程序砸运行时动态的加载对应的DLL,程序体积变小,但一个很大的问题就是一旦找不到对应的DLL,程序将无法运行。

静态库的链接方法:

gcc main.c -static -o main -L. -lstatic

动态库的链接方法:

gcc main.c -o main -L. -lshared

相关文章