《深入理解计算机系统》学习三
前言😊
因为学习的进度,先重点学习我想学的,所以这篇随笔记录的是第七章linkers的内容
什么是链接(linking)
- 就是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行
本章学习的目的:
- 理解链接器将帮助你构造大型程序
- 理解链接器将帮助你避免一些危险的编程错误
- 理解链接将帮助你理解你理解语言的作用域规则是如何实现的
- 理解链接将帮助你理解其他重要的系统概念
- 理解链接将使你能够利用共享库
编译器驱动程序😃
0️⃣main.c
int sum(int *a,int n);
int array[2]={1,2};
int main(){
int val=sum(array,2);
return val;
}
1️⃣sum.c
int sum(int *a,int n){
int i,s=0;
for(i=0;i<n;i++){
s+=a[i];
}
return s;
}
🔴 两个源文件main.c和sum.c 经过翻译器编程.o文件(可重定位目标文件),用链接器变成一个prog(完全链接的可执行目标文件)
c预处理器(cpp),c编译器(ccl),汇编器(as)
这玩意就是最简单的静态链接吧,在Linux输入下列命令即可
gcc -Og -o prog main.c sum.c
至于为啥模块化,emmm最近在写webserver,分块和不分块,明显分块看得更舒服,且修改起来也快,一坨的话会改得心态爆炸的
静态链接😃
为了构造可执行文件,链接器必须完成两个主要任务:
- 符号解析 : 目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即C语言中任何以static属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
- 重定位 : 编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
像LinuxLD程序这样静态链接器(static linker)以一组可重定位目标文件和命令行参数作为输入
生成一个完全链接的、可以加载和运行的可执行目标文件作为输出
so 什么是目标文件?=>>>
目标文件😃
目标文件有三种形式:
- 可重定位目标文件(a.o) :包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。
- 可执行目标文件(a.out) :包含二进制代码和数据,其形式可以被直接复制到内存并执行。
- 共享目标文件(.so) :一种特殊类型的可重定位目标文件,可以加在或者运行时被动态地加载进内存并链接。
有三个章节来解释这三个目标文件…
可重定位目标文件😃
下图:一个典型的ELF可重定位目标文件的格式
ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。
ELF header剩下的部分包含帮助链接器语法分析和解释目标文件的信息。
其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
-
.text: 已编译程序的机器代码。
-
.rodata: 只读数据,比如printf语句中的格式串和开关语句的跳转表。
-
.data: 已初始化的库和静态C变量。
局部C变量在运行时被保存在栈中,既不出现在 .data 节中,也不出现在 .bss 节中。
- .bss(Block Storage Start): 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。
在目标文件中这个字节不占据实际的空间,它仅仅是一个占位符。
- . symtab: 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
一些程序员错误的认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在.symtab 中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab 符号表不包含局部变量的条目。
- .re.text: 一个 .text 节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
一般而言,任何调用外部函数引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。
-
.rel.data: 被模块引用或定义的所有全局的重定位信息,一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。
-
.debug: 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表。
-
.line: 原始C源程序中的行号和.text节中机器指令之间的映射。
只有以-g选项调用编译器驱动程序时,才会得到这张表。
- .strtab: 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串的序列。
符号和符号表😃
每个可重定位目标模块都有一个符号表,它包含m定义和引用的符号的信息。在链接器的上下文中,有三种不同的符号:
全局符号(由模块m定义并能被其他模块引用的全局符号),外部符号( 由其它模块定义并被模块m引用的全局符号),局部符号( 只被模块m定义和引用)
这些符号在运行时在栈中被管理,链接器对此类符号不感兴趣
不过定义为带有c static属性的本地过程变量是不在栈中管理的
编译器在.data或.bss中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号
int f()
{
static int x=0;return x;
}
int g()
{
static int x=1;return x;
}
这种相同的静态局部变量x,在编译器向汇编器输出两个不同名字的局部链接器符号,比如可以用x.1表示f函数的定义,用x.2表示g函数的定义
使用static属性在模块内部隐藏变量和函数声明
C源代码文件扮演模块的角色,任何声明带有static属性的全局变量或者函数都是模块私有的
类似地,任何声明为不待static属性的全局变量和函数都是公共的,可以被其他模块访问
尽可能用static属性来保护你的变量和函数是很好地编程习惯
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号
符号解析😃
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来
🔴链接器如何解析多重定义的全局符号
链接器的输入是一组可重定位目标模块
每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是局部的(对其它模块也可见)
在编译时,编译器向汇编器输出每个全局符号,或者是强或者是弱,而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里
函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号
根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:
-
规则1: 不允许有多个同名的强符号。
-
规则2: 如果有一个强符号和多个弱符号同名,那么选择强符号。
-
规则3: 如果有多个弱符号同名,那么从这些弱符号中任意选择一个。
比如:
代码一:
/*a.c*/
int main(){
return 0;
}
/*b.c*/
int main(){
return 0;
}
如果试图把a.c和b.c编译和链接,因为强符号main被多次定义(规则1),会报错
代码二:
/*a.c*/
int x=88;
int main(){
return 0;
}
/*b.c*/
int x=88;
int f(){
return x;
}
如同上面那个多次定于强符号main,这般定义强符号x,报同样的错误
代码三:
/*a.c*/
void f();
int x=88;
int main(){
f();
printf("%d\n",x);
return 0;
}
/*b.c*/
int x;
int f(){
x=8;
}
//输出8
如果模块的x未被初始化,将安静的选择另一个模块的强符号(规则2)
但在运行的时候,函数f将x=88改成了8,这将会对main函数的带来不受欢迎(链接器通常不会表明它检测到多个x的定义)
代码四:
/*a.c*/
void f();
int x;
int main(){
x=888;
f();
printf("%d\n",x);
return 0;
}
/*b.c*/
int x;
int f(){
x=8;
}
//输出8
如果x是两个弱定义,也会发生上面代码三的情况(规则3)
那么如果一个模块的定义是int,而另外一个定义为double呢?
这对于不警觉的程序员来说,规则2和规则3的应用会造成一些不易察觉的运行时错误
编译器按照前面的规则把符号分配为COMMON(未初始化的全局变量)和.bss(未初始化的静态变量,以及初始化为0的全局或静态变量)
实际上,采用这个惯例是由于某些情况中链接器允许多个模块定义同名的全局符号。当编译器在翻译某个模块时,遇到一个弱全局符号,比如说x,它并不知道其他模块是否也定义了x,如果是,它无法预测链接器该使用x的多重定义中的哪一个。所以编译器把x分配成COMMON,把决定权留给链接器。另一方面,如果x初始化为0,那么它是一个强符号(因此根据规则2必须是唯一的),所以编译器可以很自信地将他分配成.bss。类似地,静态符号的构造就必须唯一地,所以编译器可以自信地把它们分配成.data或.bss
🟠与静态库链接
在Unix系统中,静态库以一种称为存档的特殊文件格式存放在磁盘中。
存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。
存档文件名由后缀.a标识
🟡链接器如何使用静态库来解析引用
在符号解析的阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的相同顺序来扫描可重定位目标文件和存档文件。
在这次扫描中,链接器维持一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(即引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已定义的符号集合D。
初始时,E、U和D都是空的。
-
对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件。如果f是一个目标文件,那么链接器吧f添加到E, 修改U和D来反映f中的符号定义和引用,并继续下一个输入文件。
-
如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件都反复进行这个过程,直到U和D都不再发生变化。在此时,任何不包含在E中的目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件。
-
3如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就好输出一个错误并终止。否则,它会合并和重定位E中的目标文件,从而构建输出的可执行文件。
这种算法会导致一些令人困扰的链接时错误,因为命令行上的库和目标文件的顺序非常重要。在命令行中,如果定义一个符号的库出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。关于库的一般准则是将它们放在命令行的结尾。
另一方面,如果库不是相互独立的,那么它们必须排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义实在对s的引用之后的。如果需要满足依赖需求,可以在命令行上重复库。
重定位😃
一旦链接器完成了符号解析这一步,它就是把代码中的每个符号引用和确定的一个符号定义(即它的一个输入目标模块中的一个符号表条目)联系起来。
在此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。
现在就可以开始重定位了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。 重定位有两步组成:
1.重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每个指令和全局变量都有唯一的运行时存储器地址了。
2.重定位节中的符号引用。在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。为了执行这一步,链接器依赖于称为重定位条目的可重定位目标模块中的数据结构。
🔴重定位条目
当汇编器生成一个目标模块时,它并不知道数据和代码最终存放在存储器中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置位置的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。 已初始化的数据的重定位条目放在.rel.data中。
ELF定义了11种不同的重定位类型。我们只关心其中两种最基本的重定位类型:
-
R_386_PC32 重定位一个使用32位PC相对地址的引用。
-
R_386_32 重定位一个使用32位绝对地址的引用。
🟠重定位符号引用
1.重定位pc相对引用
2.重定位绝对引用
可执行目标文件😃
可执行目标文件的格式类似于可重定位目标文件的格式。ELF头部描述文件的总体格式。它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。.text 、.rodata和.data 节和可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时存储器地址以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位了),所以它不再需要.rel节。
ELF可执行文件被设计得很容易加载到存储器,可执行文件的连续的片被映射到连续的存储器段。段头部表描述了这种映射关系。
加载可执行目标文件😃
要运行可执行目标文件p,可以在Unix外壳的命令行中输入它的名字:unix> ./p
我开服务器./myserver
因为p不是一个内置的外壳命令,所以外壳会认为p是一个可执行目标文件,通过调用某个驻留在存储器中的称为加载器(loader)的操作系统代码来运行它。任何Unix程序都可以通过调用execve函数来调用加载器。加载器将可执行目标文件中的代码和数据从磁盘拷贝到存储器中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序拷贝到存储器并运行的过程叫做加载。
每个Unix程序都有一个运行时存储器映像。例如:在32位Linux系统中,代码段总是从地址(0x8048000)处开始。数据段是在接下来的下一个4KB对齐的地址处。运行时堆在读/写段之后接下来的第一个4KB对齐的地址处,并童工调用malloc库往上增长。还有一个段是为共享库保留的。用户栈总是从最大的合法用户地址开始,向下增长的(向低存储器地方向增长)。从栈的上部开始的段是为操作系统驻留存储器的部分(也就是内核)的代码和数据保留的。
在可执行文件中段头部表的指导下,加载器将可执行文件的相关内容拷贝到代码和数据段。接下来,加载器跳转到程序的入口点,也就是符号_start的地址。在_start地址处的启动代码是在目标文件ctrl.o中定义的,对所有的C程序都是一样的。在从.text和.init节中调用了初始化例程后,启动代码调用atexti例程,这个程序附加了一系列在应用程序正常中止时应该调用的程序。exit函数运行atexit注册的函数,然后通过调用_exit将控制返回给操作系统。接着,启动代码调用应用程序的main程序,它会开始执行我们的C代码。在应用程序返回之后,启动代码调用_exit程序,它将控制返回给操作系统。
动态链接共享库😃
前因:
静态库仍然有一些明显的缺点。静态库和所有的软件一样,需要定期维护和更新。而且几乎每个C程序都是用标准I/O函数。
因为在运行时,这些函数的代码会被复制到每个运行进行的本文段中。在一个运行上百个进程的系统上,这就是对内存资源的极大浪费。
后果:
共享库是致力与解决静态库缺陷的一个现代创新产物。
共享库是一个目标模块,在运行时,可以加载到任意的存储器地址,并加一个在存储器中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器的程序来执行的。
共享库也称为共享目标,在Unix系统中通常用.so后缀来表示。(微软的操作系统大量地使用了共享库,它们称为DLL)
🔴私货:function和函数指针区别
函数指针:
int add1(int a1,int b1);
int add2(int a2,int b2);
int main(){
int numa1 = 1, numb1 = 2;
int numa2 = 2, numb2 = 3;
int(*op[2])(int a,int b);
op[0] = add1;
op[1] = add2;
printf("%d%d\n", op[0](numa1, numb1), op[1](numa2, numb2));
}
int add1(int a1,int b1){
return a1 + b1;
}
int add2(int a2,int b2){
return a2 + b2;
}
function
int f(int a,int b){return a+b;}
int main(){
int a=1,b=2;
function <int(int,int)>func=f;
cout<<func(a,b)<<endl;//3
}
是不是很像,但还是有细微区别的:
function
(1) std::function 是 functor ,它可以保存一部分调用所需的额外状态(这种功能有时被称为“闭包 (closure) ”);
(2) std::function 有运行时多态,同样类型的 std::function<Ret(Args)> 对象可以处理不同类型的被调用函数和额外状态。
函数指针
函数指针只能指向同一类型的不同函数(除了 C++17 开始非 noexcept 函数指针可以指向 noexcept 函数),不能保存额外状态。
从应用程序中加载和链接共享库😃
动态链接在现实中的例子:
-
分发软件
-
构建高性能Web服务器(说起来我写的是处理静态资源的服务器,很拉)
位置无关代码😃
共享库的一个主要目的就是允许多个正在运行的进行共享内存中相同的库代码,从而节约内存资源。
为了完美地让多个进程共享共享库中程序的一个副本,现代系统使用一种方法,使得无限多个进程可以共享一个共享模块的代码段的单一副本(每个进程仍然有它自己的读/写数据块)
可以加载而无需重定位的代码称为位置无关代码
- PIC数据引用
想要生成对全局变量PIC引用的编译器利用了这个事实,它在数据段开始的地方创建了一个表,叫做全局偏移量表(Global Offset Table, GOT),在GOT中,每个被这个目标模块引用的全局数据目标都有一个8字节条目。
编译器还为GOT中的每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址
- PIC函数调用
假设程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。
正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候在解析它。
不过,这种方法并不是PIC,因为需要链接器修改调用模块的代码段,GNU编译系统使用了一种很有趣的技术来解决这个问题,称为延迟绑定,将过程的地址的绑定推迟到第一次调用该过程时。
使用延时绑定的动机是对于一个像libc.so这样的共享库输出的成百上千个函数中,一个典型的应用程序只会使用其中很少的一部分。
把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。
第一次调用过程的运行时开销很大,但是其后的每次调用都会只花费一条指令和一个间接的内存引用。
库打桩机制😃
Linux链接器支持一个强大的技术,称为库打桩(library interpositioning),它允许你截获对共享库函数的调用,取而代之执行自己的代码。
使用打桩机制,你可以追踪对某个特殊库函数的调用次数,验证追踪它的输入和输出值,或者甚至把它替换成一个完全不同的实现。
打桩可以发生在三个阶段: