深入理解操作系统(18)第七章:链接(2)符号解析+重定位+可执行目标文件(强弱符合/多个同名全局变量规则/静态库背景/libc.a和printf.o/链接器解析符号/重定位步骤,表目,类型/段头表)
1. 符号解析
1.1 符号解析定义
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的
一个确定的符号定义联系起来。
简单说:符号解析的目的是将每个符号引用和一个符号定义联系起来。
1.1.1 本地符号解析:
对那些和引用定义在相同模块中的本地符号的引用。符号解析是非常简单明了的。
编译器只允许每个模块中的每个本地符号只有一个定义。
编译器还确保静态本地变量,它们也会有本地链接器符号,拥有惟一的名字。
1.1.2 全局符号解析:
不过,对全局符号的引用解析就棘手得多。
当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时。它会假设该符号是在其他某个模块中定义的,生成个链接器符号表表目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用的符号,它就输出一条错误信息并终止。
1.2 链接器如何解析多重定义的全局符号
1.2.1 强符号和弱符合
在编译时,编译器输出每个全局符号给汇编器,或者是强(strong),或者是弱(weak),而汇编器把这个信息隐含地编码在可重定位目标文件的符号表里。
函数和己初始化的全局变量是强符号,
未初始化的全局变量是弱符号。
1.2.2 同名全局变量的三个规则!!!(重要)
根据强弱符号的定义,Unix链接器使用下面的规则来处理多处定义的符号:
规则1:不允许有多个强符号
就是不允许定义并初始化多个同名的全局变量。
如果有则报重复定义。
规则2:如果有一个强符号和一个或多个弱符号,那么选择强符号。
可以定义一个并初始化和多个未初始化的同名全局变量
规则3:果有多个弱符号,那么从这些弱符号中任意选择一个。
这就是common块
可以定义多个未初始化的同名全局变量
1.2.3 同名全局变量的三个规则例子说明
例子1:违反规则1 :不允许有多个强符号
图1
例子2:规则2 :如果有一个强符号和多个弱符号,那么选择强符号。
图2
例子3:规则3 :果有多个弱符号,那么从这些弱符号中任意选择一个。
图3
总结:
不要定义同名全局变量!!!
不要定义同名全局变量!!!
不要定义同名全局变量!!!
1.3 与静态库连接
1.3.1 静态库
迄今为止,我们都是假设链接器读一组可重定位目标文件,并把它们链接起来,成为一个输出的可执行文件。
1. 所有的编译系统都提供一种机制,将所有相关的目标模块打包为一个单独的文件,
称为静态库(stauclibrary)
3. 当链接器构造一个输出的可执行文件时,它只拷贝静态库里被应用程序引用的目标模块。
这个重要,如hello.c 的目标文件a.out中只引用了libc.a静态库中printf.o 等相关的目标文件,而没有其他。
优点:减少最终可执行文件的大小
1.3.2 静态库背景
为什么系统要支持库的概念呢?
以ANSI C为例,它定义了一组广泛的标准I/0、字符串操作和整数算术函数,例如atoi、printf、scanf和random它们在libca库中,对每个c程序来说都是可用的。
ANSIC还在libm.a库中定义了一组广泛的浮点算术函数,例如sin、cos等。
1.3.2.1 如果没有静态库:处理方法一
让我们来看看如果不使用静态库,编译器开发人员会使用什么方法来向用户提供这些函数。
一种方法是:
让编译器辨认出对标准函数的调用,并直接生成相应的代码。
例如,Pascal语言只提供了一小部分标准函数,采用的就是这种方法,但是这种方法对C而言是不合适的,因为c标准定义了大量的标准函数。这种方法将给编译器增加显著的复杂性,而且每次添加、删除或修改一个标准函数时,就需要一个新的编译器版本。然而,对于应用程序员而言,这种方法会是非常方便的,因为标准函数总是可用的。
1.3.2.1 如果没有静态库:处理方法二
另一种方法是
将所有的标准C库都放在一个单独的可重定位目标模块中一比如说libc.o中
例如,应用程序员可以把这个模块链接到他们的可执行文件中。
gcc -o result main.c /usr/lib/libc.o
方法二,libc.o的优缺点:
优点:这种方法的优点是它将编译器的实现与标准函数的实现分离开来,并且仍然对程序员保持适度的便利。
缺点:1. 很大的缺点是系统中每个可执行文件现在都包含着一份标准函数集合的完全拷贝,这对磁盘空间是很大的浪费。
(在一个典型的系统上,libc.a约是8MB,而libm.a大约是1MB,太浪费空间了)
缺点:2. 更糟的是,每个正在运行的程序都将它自己的这些函数的拷贝放在存储器中,这又是极度浪费存储器的。
缺点:3. 另一个大的缺点是,对任何标准库的任何改变,都要求库的开发人员重新编译整个源文件,
这是一个非常耗时的操作,使得标准函库的开发和维护变得很复杂。
1.3.3 静态库出现
1.初期:
我们通过为每个标准函数创建一个分离的可重定位文件,把它们存放在一个为大家所知的目录中来解决其中的一些问题。
然而,这种方法要求应用程序员显式地链接合适的目标模块到它们的可执行文件中,这是一个容易出错而且耗时的过程:
gcc hello.c /usr/lib/printf.o /usr/lib/scanf.o ……
新手鬼知道用到哪些目标文件呢…………大型程序老司机也未必知道全部……
2.静态库概念被提出来
静态库概念被提出来,以解决这些不同方法的缺点。
1. 相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。
2. 然后,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。
比如,使用标准c库和数学库中函数的程序可以用如下的命令行来编译和链接:
gcc hello.c /usr/lib/libm.a /usr/lib/12bc.a
(实际上,编译器默认添加了这些库文件)
优点:
1. 在链接时,链接器将只拷贝被程序引用的目标模块,这就减少了可执行文件在磁盘和存储器中的大小。
2. 另一方面,应用程序员只需要包含较少的库文件的名字
(实际上,c编译器驱动程序总是传送libc.a给链接器,所以前面提到的对libc.a的引用是不必要的)。
1.3.4 存档文件
在Unix系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。
存档文件是一组连接起来的可重定位目标文件的集合,有一个头部描述每个成员目标文件的大小和位置。
1.3.5 例子:Linux下创建与使用静态库 libmyhello.a
代码:hello.h、hello.c和main.c
程序1: hello.h
#ifndef HELLO_H
#define HELLO_H
void hello(const char *name);
#endif //HELLO_H
程序2: hello.c
#include <stdio.h>
void hello(const char *name)
{
printf("Hello %s!\n", name);
}
程序3: main.c
#include "hello.h"
int main()
{
hello("everyone");
return 0;
}
1.将hello.c编译成.o文件
无论静态库,还是动态库,都是由.o文件创建的。
因此,我们必须将源程序hello.c通过gcc先编译成.o文件。
# gcc -c hello.c //汇编处理,生成目标文件
2.由.o文件创建静态库;
静态库文件名的命名规范是以lib为前缀,紧接着跟静态库名,扩展名为.a
我们将创建的静态库名为myhello,则静态库文件名就是libmyhello.a
# ar -cr libmyhello.a hello.o
3. 在程序中使用静态库;
需要在使用到这些公用函数的源程序中包含这些公用函数的原型声明
然后在用gcc命令生成目标文件时指明静态库名,gcc将会从静态库中将公用函数连接到目标文件中
注意,gcc会在静态库名前加上前缀lib,然后追
加扩展名.a得到的静态库文件名来查找静态库文件
先生成main.o:gcc -c main.c
再生成可执行文件:
gcc -o hello main.o libmyhello.a
4. 或者不用编译main
gcc -o hello main.c -L. -lmyhello //这里-L后面有个点.
[root@hani test]# gcc main.c -L. -lmyhello -o hello
[root@hani test]# ll
total 48
-rwxr-xr-x 1 root root 8424 Mar 31 08:07 hello
5. 参数说明
-L选项告诉编译器去哪里找需要的库文件
-L.表示在当前目录找。 //这里-L后面有个点.
-lmyhello 告诉编译器要链 接 libmyhello 库
参考:静态链接库和动态链接库
https://blog.csdn.net/lqy971966/article/details/105207532
1.3.6 查看静态库 libc.a
ar -t libc.a //查看静态库 libc.a 包含哪些文件
root@ubuntu-admin-a1:/usr/lib# ar -t /usr/lib/x86_64-linux-gnu/libc.a
init-first.o
libc-start.o
sysdep.o
version.o
check_fds.o
libc-tls.o
elf-init.o
……
1.3.7 查找 printf 在 libc.a 库的哪个目标文件
objdump -t libc.a grep -w printf //查找 printf 在 libc.a 库的哪个目标文件
libc.a 库里面包含了 1400多个目标文件。
root@ubuntu-admin-a1:/home/4Chapter# objdump -t /usr/lib/x86_64-linux-gnu/libc.a | grep -w printf
……
printf.o: file format elf64-x86-64
0000000000000000 g F .text 000000000000009e printf
……
root@ubuntu-admin-a1:/home/4Chapter#
其实,printf.o 也依赖其他的目标文件,stdout 和 vfprintf
linux 系统库很复杂,多层级调用。
1.3.8 为什么一个目标文件只包含一个函数?
- 减少空间浪费
详细解释
由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,
那些没有被利用到的目标文件/函数就不要链接到最终的输出文件中。
1.4 链接器如何使用静态库来解析引用
虽然静态库是很有用而且重要的工具,但是它们同时也是程序员迷惑的源头,因为Unix链接器使用它们解析外部引用的方式是令人困惑的。
1.4.1 链接器解析符号的处理步骤:
1. 在符号解析的阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的
相同顺序来扫描可重定位目标文件和存档文件。
(驱动程序自动将命令行中所有的.c文件翻译为.o文件。)
2. 在这次扫描中,链接器维持一个可重定位目标文件的集合E
这个集合中的文件会被合并起来形成可执行文件
3. 和一个未解析的符号(也就是,引用了但是尚未定义的符号〕集合U
4. 以及一个在前面输入文件中己定义的符号集合D
5. 初始地,E、U和D都是空的。
6. 对于命令行上的每个输入文件f,链接器会判断f是一个目标文件还是一个存档文件(carchive)。
7. 如果f是一个目标文件,那么链接器把f添加到E,
修改U和D来反映f中的符号定义和引用,并继续下一个输入文件。
8. 如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号,
如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,
并且链接器修改U和D来反映m中的符号定义和引用。
对存档文件中所有的成员目标文件都反复进行这个过程,直到U和D都不再发生变化。
9. 在此时,任何不包含在E中的成员目标文件都被丢弃,而链接器将继续到下一个输入文件。
10. 结果:
如果当链接器完成对命令行上输入文件的扫描后,
U是非空的,那么链接器就会输出一个错误并终止。
否则,它会合并和重定位E中的目标文件,从而构建输出的可执行文件。
1.4.2 缺点:
不幸的是,这种算法会导致一些令人扰的链接时错误,
因为命令行上的库和目标文件的顺序非常重要。
如果在命令行中,定义一个符号的厍出现在引用这个符号的目标文件之前,那么引用就不能被解析,链接会失败。
1.4.3 库的放置/引用顺序
关于库的一般准则是将它们放在命令行的结尾。
1.如果各个库的成员是相互独立的,也就是说没有成员引用另一个成员定义的符号,那么这些库就可以以任何顺序放置在命令行的结尾处。
2.另一方面,如果厍不是相互独立的,那么它们必须排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个的定义是在对s的引用之后的。
例子1:
比如,假设foo.c调用libx.a和libz.a中的函数,而这两个库又调用liby.a中的函数,那么,在命令行中libx.a和libz.a必须处在liby.a之前:
那么, gcc foo.c libx.a libz.a liby.a
例子2:
如果需要满足依赖需求,可以在命令行上重复库。
比如,假设foo.c调用中的函数,该库又调用liby.a中的函数,而liby.a又调用libx.a中的函数。
那么libx.a必须在命令行上重复出现:
gcc foo.c libx.a liby.a libx.a
作为另一种方法,我们可以将libx.a和liby.a合并成一个单独的存档文件,
2. 重定位
2.1 回顾符号解析和重定位:
1. 符号解析: 符号解析的目的是将每个符号引用和一个符号定义联系起来。
2. 重定位: 链接器通过把每个符号定义与一个存储器位置联系起来,
然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。
一旦链接器完了符号解析这一步,它就把代码中的每个符号引用和确定的一个符号定义(也就是,它的一个输入目标模块中的一个符号表表目)联系起来。在此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。
2.2 重定位的两个步骤
重定位由两步组成
步骤一:重定位节和符号定义。
在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。
例如,来自输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时存储器地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每个指令和全局变量都有惟一的运行时存储器地址了。
步骤二:重定位节中的符号引用。
在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
为了执行这一步,链接器依赖于称为重定位表目(relocationentry)的可重定位目标模块中的数据结构,我们接下来将会描述这种数据结构。
2.3 重定位表目
2.3.1 背景:
当汇编器生成一个目标模块时,它并不知道数据和代码最终将存放在存储器中的什么位置。
它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。
2.3.2 重定位表目的出现:
所以无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位表目ionentry)
告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。
代码的重定位表目放在.relo.text中,
己初始化数据的重定位表目放在.relo.data中。
2.3.3 ELF重定位表目的格式
typedef struct{
int offset; //offset是需要被修改的引用的节偏移
int symbol:24, //symbol标识被修改引用应该指向的符号
type:8; //type告知知链接器如何修改新的引用
}Elf32_rel
2.3.4 两种最基本的重定位类型
ELF定义了11种不同的重定位类型,有些相当隐秘。我们只关心其中两种最基本的重定位类型
R_386_PC32:
重定位一个使用32位相关的地址引用,回想一下3.6.3节,一个PC相关地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行使用相关寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址,PC值通常是存储器中下一条指令的地址。
R_386_32:
重定位一个使用32位绝对地址的引用。
通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
2.4 重定位符号引用
2.4.1 重定位算法
/* 两个foreach 在每个节s以及与每个节相关联的重定位表目上迭代执行 */
foreach section s{
foreach relocation entry r {
refptr = s + r.offset; // ptr to referencere to be located
/* relocate a PC-relative reference 如果这个引用使用的是相关寻址 */
if(r.type == R_386_PC32) {
refaddr = ADDR(s) + r.refset; /* ref run-timea ddress */
*refptr = (unsigned)(ADDR{r.symbol)+ *refptr - refaddr):
}
/* relocate absolute reference 如果该引用使用的是绝对寻址 */
if(r.type == R_386_32) {
*refptr = (unsigned)(ADDR{r.symbol)+ *refptr):
}
}
}
2.4.2 重定位PC相关引用
略
2.4.3 重定位绝对引用
略
3. 可执行目标文件
我们已经看到链接器是如何将多个目标模块合并成一个可执行目标文件的。我们的C程序,开始时是一组ASCII文本文件,已经被转化为一个二进制文件,且这个二进制文件包含加载程序到存储器并运行它所需的所有信息。
3.1 典型的ELF可执行文件结构
图7.11概括了一个典型的ELF可执行文件中的各类信息。
图7.11
3.2 可执行目标文件和可重定位目标文件比较
图7.11-1
比较;
1. 可执行目标文件的格式类似于可重定位目标文件的格式,ELF头部描述文件的总体格式
2. 它还包括程序的入口(entrypoint),也就是当程序运行时要执行的第一条指令的地址。
3. text、和、data节和可重定位目标文件中的节是相似的,除了这些节己经被重定位到它们最终的运行时存储器地址以外。
4. .init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。
5. 因为可执行文件是完全链接的(己被重定位了),所以它不再需要.relo节。
3.3 段头表
ELF可执行文件被设计为很容易加载到存储器,连续的可执行文件的组块(chunks)被映射到连续的存储器段。
段头表(segment header)描述了这种映射关系。
图7.12展示了我们的示例可执行文件的段头表,是由OBJDUMP显示的。
图7.12
从段头表中,我们看到会根据可执行目标文件的内容初始化两个存储器段。第1行和第2行告诉我们第一个段(代码段)对齐到一个4KB的边界,有读/执行许可。开始于存储器地址0x08048000处。总共的存储器大小是0x448字节,并且被初始化为可执行目标文件的头0x448个字节,其中包括ELF头部、段头表以及.init、.text和.rodata节。
第3行和第4行告诉我们第二个段(数据段)被对齐到一个4KB的边界,有读/写许可,开始于存儲器地址0x08049448处,总的存储器大小为0x104字节,并用从文件偏移0x448处开始的0xe8跹8个字节初始化,在此例中,偏移0x448处正是.data节的开始。该段中剩下的字节对应于运行时将被初始化为零的bss数据。