《程序员的自我修养》笔记2——静态链接
一、前言
在前面的文章我们大致说明了 目标文件 中的内容并解释其用处和含义。本文将延续上一章内容,讲解 目标文件 中的内容如何用于 静态链接 及 简单说明静态链接 的过程。
二、静态链接
2.1 静态链接步骤
静态链接 一般分 2 步来完成:
-
空间和地址分配:链接器 扫描所有 输入的目标文件,获取它们 各个段 的 长度 、 属性和 位置,并且将 输入目标文件 中的 符号表 中所有的 符号定义 和 符号引用 收集起来将建立一个 全局符号表,再将各个段进行 合并。最后计算 输出文件中 各个段 合并后的 长度 和 位置,分配虚拟地址并建立映射。
-
符号解析与重定位:读取 输入目标文件 中所有段的 数据、重定位信息,并且进行 符号解析 与 重定位、调整代码的地址 等。
可执行文件中的 代码段 和 数据段 都是从 输入目标文件 中 合并 而来。链接器 将 输入目标文件 的 相似段 合并起来。比如将所有 输入目标文件的.txt段 合并到 输出文件 的 .txt段等。
一般来说,链接器需要分配 2 种 地址空间,如下:
- 存储空间:即 可执行文件内部的存储空间,可以理解为存储在 硬盘 上的空间。对于实际存在的段,比如 .txt段 和 .data段,那么它们在文件内部是 占有硬盘存储空间 的。而 .bss段 由于都为 0(即不需要存储空间),所以 .bss段 在文件内部 不占据存储空间。
- 虚拟地址空间:即装载在 内存 中的 虚拟地址空间。链接器 需要为每个段 分配虚拟地址,才能让各个段在 内存中得以存在和运行。在内存中 .bss段 和 .txt段 实际存在,所以它们都 拥有虚拟地址空间。
在链接时,连机器为各个段分配各自的 虚拟地址。虚拟地址的选择设计到 操作系统 的 进程虚拟地址空间的分配规则。一般在 Linux下,ELF可执行文件 的 默认地址 从 0x08048000 开始分配。
2.2 空间和地址分配
/* 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;
}
对上面的程序按顺序使用:
arm-linux-gnueabihf-gcc -c a.c b.c(编译)
arm-linux-gnueabihf-ld a.o b.o -e main -o ab(链接)
arm-linux-gnueabihf-objdump -d a.o(查看a.o(目标文件)反汇编)
arm-linux-gnueabihf-objdump -d ab(查看ab(可执行文件)反汇编)
结果如下:
可以从上图看出,此时的 a.o 并不知道变量 shared 的地址,所以使用 0 来代替,而函数 swap 也同理。 ab反汇编
从上图可以知道,此时 可执行文件ab 已经获取到了 变量shared 和 函数swap 的地址并已经修改了对应的指令。
一般在 ARM架构 下,编译器把指令的地址部分暂时用 0地址 代替,把地址计算工作留给了链接器。链接器在完成地址和空间分配后就已经确定过来所有符号的地址,此时根据符号的地址读每个需要重定位的指令进行 地址修正
2.3 符号解析与重定位
各个 符号在段内的 相对位置(偏移) 是 固定 的。通过确定段的 起始地址,再为段内的每个 符号加上各自的偏移 即可得到各个符号的 虚拟地址。
2.3.1 重定位表
ELF文件 中的 重定位表 存放着许多重定位信息,它在 ELF文件 中往往是一个或者多个段。对于每个需要被重定位的ELF段都有一个对应的重定位表,而重定位表本身就是一个段,所以重定位表也叫重定位段。关于前面重定位段的命名上一篇文章已经讲过,一般是对应段的名字加 .rel。
查看 目标文件a.o 的重定位表:
其中每个需要 被重定位的地址 都称为 重定位入口
-
重定位段:图中的 RELOCATION RECORDS FOR [.text] 表示该重定位表所在的段,即该表为 .txt 的重定位表。
-
偏移offset:表示该入口在 被重定位段 中的位置。比如在重定位表中,swap 的偏移为 16,该函数在 a.o 中的所在段的偏移也是 16
a.o反汇编
重定位表 的代码表现为 Elf32_Rel 结构体数组,每个数组元素对应一个 重定位入口。
typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index */
} Elf32_Rel;
-
r_offset:重定位入口的偏移。
- 可重定位文件:重定位入口的起始地址相对于 段起始地址的偏移
- 可执行文件/共享对象文件:重定位入口的起始虚拟地址。
-
r_info:重定位的类型和符号
- 类型:该成员的 低8位 表示重定位入口的 类型,每种CPU架构都有自己的重定位入口类型
- 符号:该成员的 高24位 表示重定位入口的符号在符号表中的下标
2.3.2 符号解析
在编程时,我们常见的 编译问题 之一就是 链接时符号未定义,其输出如下:
undefined reference to `xxx`
其常见原因如下:
- 链接时少了 库
- 输入目标文件 路径不正确
- 符号的声明与定义 不一致
在 重定位过程 中,每个 重定位入口 都是对一个符号的引用,如果链接器需要对某个 符号引用 进行 重定位 时,需要先确定该符号的 目标地址。此时 链接器 会查找 由所有目标文件符号表组成的全局符号表,找到对应的符号后进行 重定位。
总结如下:
- 先查找 重定位表
- 再找 全局符号表
- 两者符号一致则进行 重定位
2.3.3 COMMON块
由于 弱符号机制 允许同一个符号定义在多个文件中。如果一个 弱符号 定义在多个目标文件中,且 类型 不同。变量类型 对于 链接器 来说是透明的,链接器并不知道类型是否一致。
对于多种不同类型的符号,主要有以下 3 中情况:
- 两个或两个以上强符号类型不一致,情况链接器会报 多重定义错误
- 有一个强符号,其他都是弱符号,其类型不一致
- 两个或两个以上弱符号类型不一致
链接器需要处理 情况2 和 情况3,此时链接器会采取 类似COMMON块(Common block) 的机制
编译器将为初始化的全局变量定义作为弱符号处理,我们可以看一下代码
extern int shared;
int global_uninit_var;
int main()
{
int a = 100;
swap(&a, &shared);
}
使用以下命令查看:
arm-linux-gnueabihf-gcc -c a.c
arm-linux-gnueabihf-readelf -s a.o
结果如下图所示:
common
可以看到 变量global_uninit_var 的 Ndx字段 的值为 COM,这就说明它的类型为 SHN_COMMON。
按照 COMMON类型 的链接原则,最终输出的文件中,COMMON块类型 的符号以输入文件中最大的为主,比如本文件 global_uninit_var 为 8 字节,而另一个 global_uninit_var 为 4 字节。则输出的大小为 8 字节。
在编译目标文件时,如果 编译单元 包含了 弱符号,此时该 弱符号 所占大小是未知的。在其他编译单元中也有可能存在更大的相同 弱符号。所以编译器无法为 弱符号 在 bss段 中分配目标,毕竟所占大小未知。只有在链接时才能确定 弱符号 大小,所以链接后的弱符号在 bss段,链接前在 COMMOM块 中。
可以使用 -fno-common 关闭 COMMON块机制 ,或者使用 attribute(nocomon) 来声明一个变量不使用 COMMON块机制。那么该符号将成为一个 强符号。
2.4 链接过程及其控制方法
2.4.1 链接步骤
链接过程 可以分为以下几步:
- 调用 ccl程序 进行汇编工作,即将 源代码文件(.c文件) 翻译为 汇编语言(.S文件),再翻译为 目标文件(.o文件)
- 调用 collect2 执行 链接工作。collect2 是 ld链接器 的包装。collcet2 调用 ld链接器 来完成对 目标文件 的 链接 并对 链接结果 进行处理,主要是 收集所有与程序初始化相关的信息 并且 构造初始化结构。
有时需要对链接过程进行一些控制,一般有以下几种方式控制链接过程:
- 在使用 链接器 时加入参数
- 将 链接指令 存在放在 目标文件 中,一般比较少用
- 使用 链接器脚本(lds文件)
2.4.2 链接器脚本
链接器脚本 的输入文件中的段称为 输入段,而输出文件中的段称为 输出段。链接器脚本 可以控制 输入段 如何变成 输出段,比如合并输入段,指定 输出段 的 名字、 属性 及 装载地址 等。
ld链接器 在产生 可执行文件 时会产生 段名字符表串、符号表 和 字符串表。
链接器脚本 语句分 2 种:
- 命令语句
- 赋值语句
在语法上需要注意几点:
- 语句之间使用分号 ; 来作为分隔符
- 表达式和运算符 类似 C语言,支持 +、-、/、+=等运算操作符,也支持 | 、>> 等位操作符
- 注释:使用 /* */ 作为 注释。
- 字符引用:类似于编程语言的 关键字。链接器脚本 对某些符号需要使用某种转义符来表示。脚本中使用到的 文件名、格式名 或 段名 等含有 ;号 或者 其他分隔符 的,需要使用 双引号 将其全称引用起来。如果名字中包含 引号 则无法处理。
赋值语句 比较简单,这里需要注意 .号 在脚本中表示为 当前虚拟地址。如果出现类似 .=0xXXXXXXX 等语句,则表明 当前虚拟地址 为 0xXXXXXXXX。在该语句后面的 段 将会被分配到该 虚拟地址。
命令语句常用如下:
命令语句 | 说明 |
---|---|
ENTRY(symbol) | 指定 符号symbol 的值为 入口地址 |
STARTUP(filename) | 将 文件filename 作为链接过程的 第一个输入文件 |
SEARCH_DIR(path) | 将 路径path 加入到 库查找目录,与 -Lpath 的作用一致 |
INPUT(file, file, ...) | 将指定文件作为链接过程的输入文件 |
INCLUDE filename | 将指定文件包含进脚本 |
PROVIDE(symbol) | 在链接脚本中定义某个符号 |
SECTIONS{} | 表示输入输出的规则 |
-
入口地址 即进程执行的 第一条指令 在进程空间中的 地址。它被指定在 Elf32_Ehdr(ELF文件头) 中的 e_entry 成员。链接器 有多种方法可以设置 进程入口,按 优先级(高到低) 排序如下:
- ld命令 使用 -e 选项
- 链接器脚本的 ENTRY命令
- 如果定义了 _start符号,使用 _start符号值
- 如果存在 .text段,使用 .text段 的第一个字节地址
- 使用 0
- 使用 PROVIDE 定义的符号可以在程序中被引用,一些特殊符号就是由系统默认的链接脚本通过 PROVIDE 定义在脚本中
SECTIONS 语句是 最重要、最复杂的,其基本格式如下:
SECTIONS
{
...
secname : {contents}
...
}
- secname:表示 输出段 的段名,后面必须有一个 空格符。
- contents:描述了一套 规则 和 条件,它表示 符合条件 的 输入段 将合并到这个 输出段 中。其语法具体如下:
SECTIONS
{
...
secname : {filename(sections)}
...
}
filename(sections):filename 表示 文件名 ,sections 表示 输入段名。
通过下面的例子来说明规则:
SECTIONS
{
...
sections : {filename(section1, section2, ...)} //第1种规则
sections : {filename} //第2种规则
sections : {*(section)} //第3种规则
/DISCARD/ : {filename(section1, section2, ...)} //第4种规则
...
}
- 指定 输入文件 的 输入段,即 文件filename 中的 段section1 和 段section2 等作为 输出段sections* 的 输入段。
- 指定 输入文件 的 所有段,即 文件filename 中的 所有段 都作为 输出段sections 的 输入段
- 指定 输入段,即 所有文件 的 section段 都作为 输出段sections 的 输入段。
- /DISCARD/ 是一种特殊的 输出段名,如果将其作为 输出段的段名,则该段的 所有输入段 将被 丢弃,不输出到文件中。
注意:在第 3 种规则中,* 号 代表 通配符。所以第 3 种规则可以推广为 正则表达式。
三、附录
- 《ELF for the Arm Architecture》