《程序员的自我修养》笔记2——静态链接

一、前言

在前面的文章我们大致说明了 目标文件 中的内容并解释其用处和含义。本文将延续上一章内容,讲解 目标文件 中的内容如何用于 静态链接 及 简单说明静态链接 的过程。

二、静态链接

2.1 静态链接步骤

静态链接 一般分 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反汇编

可以从上图看出,此时的 a.o 并不知道变量 shared 的地址,所以使用 0 来代替,而函数 swap 也同理。

ab反汇编

从上图可以知道,此时 可执行文件ab 已经获取到了 变量shared函数swap 的地址并已经修改了对应的指令。

一般在 ARM架构 下,编译器把指令的地址部分暂时用 0地址 代替,把地址计算工作留给了链接器。链接器在完成地址和空间分配后就已经确定过来所有符号的地址,此时根据符号的地址读每个需要重定位的指令进行 地址修正

2.3 符号解析与重定位

各个 符号在段内的 相对位置(偏移)固定 的。通过确定段的 起始地址,再为段内的每个 符号加上各自的偏移 即可得到各个符号的 虚拟地址

2.3.1 重定位表

ELF文件 中的 重定位表 存放着许多重定位信息,它在 ELF文件 中往往是一个或者多个段。对于每个需要被重定位的ELF段都有一个对应的重定位表,而重定位表本身就是一个段,所以重定位表也叫重定位段。关于前面重定位段的命名上一篇文章已经讲过,一般是对应段的名字加 .rel

查看 目标文件a.o 的重定位表:

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`

其常见原因如下:

  • 链接时少了
  • 输入目标文件 路径不正确
  • 符号的声明与定义 不一致

重定位过程 中,每个 重定位入口 都是对一个符号的引用,如果链接器需要对某个 符号引用 进行 重定位 时,需要先确定该符号的 目标地址。此时 链接器 会查找 由所有目标文件符号表组成的全局符号表,找到对应的符号后进行 重定位

总结如下:

  1. 先查找 重定位表
  2. 再找 全局符号表
  3. 两者符号一致则进行 重定位

2.3.3 COMMON块

由于 弱符号机制 允许同一个符号定义在多个文件中。如果一个 弱符号 定义在多个目标文件中,且 类型 不同。变量类型 对于 链接器 来说是透明的,链接器并不知道类型是否一致。

对于多种不同类型的符号,主要有以下 3 中情况:

  1. 两个或两个以上强符号类型不一致,情况链接器会报 多重定义错误
  2. 有一个强符号,其他都是弱符号,其类型不一致
  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_varNdx字段 的值为 COM,这就说明它的类型为 SHN_COMMON

按照 COMMON类型 的链接原则,最终输出的文件中,COMMON块类型 的符号以输入文件中最大的为主,比如本文件 global_uninit_var8 字节,而另一个 global_uninit_var4 字节。则输出的大小为 8 字节。

在编译目标文件时,如果 编译单元 包含了 弱符号,此时该 弱符号 所占大小是未知的。在其他编译单元中也有可能存在更大的相同 弱符号。所以编译器无法为 弱符号bss段 中分配目标,毕竟所占大小未知。只有在链接时才能确定 弱符号 大小,所以链接后的弱符号在 bss段,链接前在 COMMOM块 中。

可以使用 -fno-common 关闭 COMMON块机制 ,或者使用 attribute(nocomon) 来声明一个变量不使用 COMMON块机制。那么该符号将成为一个 强符号

2.4 链接过程及其控制方法

2.4.1 链接步骤

链接过程 可以分为以下几步:

  1. 调用 ccl程序 进行汇编工作,即将 源代码文件(.c文件) 翻译为 汇编语言(.S文件),再翻译为 目标文件(.o文件)
  2. 调用 collect2 执行 链接工作collect2ld链接器 的包装。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种规则
  ...
}
  1. 指定 输入文件输入段,即 文件filename 中的 段section1段section2 等作为 输出段sections* 的 输入段
  2. 指定 输入文件所有段,即 文件filename 中的 所有段 都作为 输出段sections输入段
  3. 指定 输入段,即 所有文件section段 都作为 输出段sections输入段
  4. /DISCARD/ 是一种特殊的 输出段名,如果将其作为 输出段的段名,则该段的 所有输入段 将被 丢弃,不输出到文件中。

注意:在第 3 种规则中,* 号 代表 通配符。所以第 3 种规则可以推广为 正则表达式

三、附录

  • 《ELF for the Arm Architecture》

你可能感兴趣的:(《程序员的自我修养》笔记2——静态链接)