CSAPP第七章-链接

前言

这章有点细,得慢慢读,有的地方名词稍微有点不同,很容易看错。不得不说CSAPP是本好书,如果要想更加深入,还是去读原书吧,然后做些实验。

从代码到可执行文件(gcc为例,gcc main.c -o test)

  1. 预处理器(cpp)
    将源程序翻译成一个ASCII码的中间文件 main.i
  2. C编译器(cll)
    将main.i翻译成一个ASCII汇编语言文件 main.s
  3. 汇编器(as)
    将main.s翻译成一个可重定位目标文件,main.o
  4. 链接器(ld)
    将多个.o文件以及一些必要的系统目标文件组合起来,创建一个可执行目标文件

链接必须完成两个主要任务:

  1. 符号解析
    目标文件定义和引用符号。符号解析的目的是将每个符号引用刚好和一个符号定义联系起来。(这里的符号包括变量名和函数名)
  2. 重定位
    链接器通过把每个符号定义和一个存储器位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储器位置,从而重定位这些节。(联想程序实际执行时是通过移动PC来执行存储器中某一地址的指令。“节”是可重定位目标文件的构造协议而言)

目标文件(Executable and Linkable Format, ELF)

三种ELF文件形式:
  1. 可重定位目标文件
    包含二进制代码和数据,其可以在编译时与其他可重定位目标文件合并起来,组成一个可执行二进制文件。
  2. 可执行目标文件
    包含二进制代码和数据,可以被直接拷贝到存储器中执行。
  3. 共享目标文件
    特殊类型的可重定位目标文件,可以在加载或运行时被动态地加载到存储器,并链接。

可重定位目标文件

文件结构

下图是一个典型的ELF文件格式。
这里写图片描述
1. ELF头,以一个16字节的序列开始,描述生成该文件的系统的字的大小和字节顺序。剩下字节包括帮助链接器语法分析和解释目标文件的信息,包括ELF头的大小、目标文件的类型(可重定位、可执行、共享的)、机器类型(如IA32)、节头部表的文件偏移,以及节头部表(见图最后)中的条目大小和数量。夹在ELF和节头部表中间的都是节。
2. .text,已编译的机器代码。
3. .rodata,read only data,只读数据,比如printf语句中的格式串和switch的跳转表。
4. .data,已初始化的全局C变量。局部变量在运行时保存在栈中,既不在.data,也不在.bss中。
5. .bss,未初始化的全局C变量。这个节不占据实际的磁盘空间。区分初始化和未初始化是为了空间效率。(意思是,.data磁盘实际保存的只有初始化的全局变量)
6. .symtab,符号表,程序中定义和引用的函数和全局变量的信息。每个ELF文件都有。
7. .rel.text,当链接噐把这个目标文件和其他文件结合时,.text节中的许多位置都需要修改。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面调用本地函数的指令则不需要修改。然而可执行目标文件中并不需要重定位信息,除非用户指定。
8. .rel.data,被模块引用或定义的任何全局变量的重定位信息。一般而言,任何已被初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址(指针?),都需要被修改。
9. .debug,调试符号表。条目是程序中定义的局部变量和类型定义,定义和引用的全局变量,原始的C源文件。-g选项编译才会得到这张表,gdb调试?
10. .line,原始C源程序行号和.text节机器指令之间的映射关系。要求-g编译。
11. .strtab,一个字符串表,每个字符串以null结尾。包括.symtab和.debug中的符号表,节头部中的节名字。

符号和符号表

每个可重定位目标模块m都有一个符号表(.symtab)

  1. 由m定义并能被其他模块引用的全局符号。对应:非静态的C函数以及非静态的C全局变量。
  2. 只被m定义和引用的本地符号。对应:带static的C函数和static全局变量。以及static局部变量。
  3. 由其他模块定义,并被m引用的全局符号,称为外部符号。对应:定义在其他模块中的C函数和变量。

PS:在函数内部定义的static变量,不在栈中管理。而是在.data和.bss中为每个定义分配空间,并且在.symtab中创建一个名字唯一的本地符号。

符号解析

符号解析是链接的两个主要任务之一,方法是将每个引用和一个确切的定义联系起来。那么如果多个目标文件同时定义了相同的符号怎么办哩?
1. 强、弱符号
强符号:函数和已初始化的全局变量。
弱符号:未初始化的全局变量。
2. Unix链接器使用如下规则来处理多重定义的符号:

  1. 不允许有多个强符号
  2. 一个强符号,多个弱符号,选强符号
  3. 只有多个弱符号,随便选一个(卧槽,这种不确定性看起来就好坑啊)

举个例子说明链接器对规则2和规则3相关的错误

/* main.c */
#include <stdio.h>
void func();
int x = 1;
int y = 2;
int main()
{
    func();
    printf("x = %x, y = %x\n", x, y);
}
/* func.c */
double x;
void func()
{
    x = -0.0;
}
// output: x = 0, y = 8000 0000

这种情况,在生成可重定位的模块时文件时,都不会有错误。在链接的时候这也不会报错。double是8个字节(32w位上也是8字节,只有整数有区别),而int是4个字节,因此在func()中对x的赋值会覆盖x,y的位置,因此y的内存表示就变为了00 00 00 80,又由于机器是小端的,所以y=8000 0000.
当然也是有解决办法的,编译时通过使用以下参数,在遇到多重定义的全局符号时,会输出警告信息。

gcc -fno-common main.c func.c -o t

静态库

静态库概念

在Unix系统中,静态库以存档(archive,*.a)的特殊文件格式存放在硬盘上。

gcc -c func1.c func2
ar rcs libfunc.a func1.o func2.o

此外,链接的时候,拷贝到最终可执行文件的基本单位还是模块。比如只用到了func1.o,就不会同时打包libfunc.a里面的func2.o

静态库链接过程
  1. 链接器按照命令行输入顺序,从左到右扫描可重定位目标文件和存档文件(静态库)。
  2. 在此次扫描中,链接器维持一个可重定位目标文件的集合E(这个集合的文件会被合并起来形成可执行文件),一个未解析符号集合U(引用了但是尚未定义),以及一个已定义符号集合D(前面输入文件已定义)。刚开始E、U、D都是空的。

    1. 对于命令行的每个输入文件f,先判断f是目标文件还是存档文件。目标文件直接把f加入E,然后根据f的内容修改U,D集合。
    2. 若f是存档文件,尝试匹配U中未解析的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加入E中,并且根据m的内容来修改U,D集合。对存档文件的所有成员反复进行这个过程,直到U,D不再发生变化。然后继续处理下一个文件。
    3. 如果链接器完成了对所有输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。若U为空,则表明各个符号解析成功,它会合并和重定位E中的目标文件,输出可执行目标文件。
  3. 所以,一般将库放在命令行的结尾。若是有特殊的需求,比如循环引用,也可以在命令行上重复导入某个库。(出现这种情况,更好的办法应该是,这两个相互依赖的模块放在同一个.a存档文件中)。

重定位

重定位是链接的第二个任务,将符号定义与一个特定的存储地址联系起来。

重定位由两步组成
  1. 重定位节和符号定义
    这一步链接器将所有相同类型的节合并为同一类型的新的聚合节。例如来自输入模块的.data节全部被合并成一个节,这个节成为输出的可执行目标文件中的.data节。这一步完成时,程序中的每一个指令和全局变量都有唯一的运行时存储器地址了。(指令在.txt,全局变量在.data,当所有节的大小都确定后,相对地址也就确定了。这么理解?)
  2. 重定位节中的符号引用
    链接器修改代码节和数据节中对每个符号的引用,使它们指向正确的运行时地址。这一步依赖于代码和数据的重定位条目。.rel.text和.rel.data节。前者存放代码的重定位条目,后者存放已初始化数据的重定位条目。
重定位PC相对引用、重定位绝对引用

这两种绝对和相对都是相对来说的。即使是绝对引用,也是相对于链接第一步合并后的文件来说的。

可执行目标文件

下图是一个典型的ELF可执行目标文件结构
ELF可执行目标文件
对比一下实际运行时的存储器映像
这里写图片描述

可执行目标文件

ELF头部描述文件的总体格式。它还包括程序的入口点,也就是程序运行时第一条执行的指令地址。.text、.rodata、.data节和之前的可重定位目标文件中的对应节类似,只是它们已经被重定位到最终的运行时存储器地址。.init节定义了一个小函数_init,程序的初始化代码会调用它。由于可执行文件时完全链接(重定位过),因此不再需要.rel.text和.rel.data节。

加载可执行目标文件
  1. Unix的shell通过调用execve函数来调用加载器(loader),加载器将可执行文件的代码和数据(精确,不包括其他段)从硬盘拷贝到存储器中,然后跳转到其第一条指令开始运行。
  2. Linux将这个运行时存储器映像组织成若干段的集合,它主要有两部分:进程虚拟存储器、内核虚拟存储器。进程虚拟存储器有我们熟悉的代码段、数据段、运行时堆、共享库段、用户栈。内核虚拟存储器包括内核中的代码和数据结构、与进程相关的数据结构。以32位系统的可执行文件的运行时存储器映像来说:

    1. 代码段总是从地址0x08048000处开始,它保存编译程序的机器代码
      • data段在接下来的一个4KB对齐的地址处,保存已初始化的全局C变量和静态变量
      • bss段记录的是未初始化的全局C变量,事实上它并不占据目标文件的任何空间,只是一个占位符
      • 运行时堆在接下来的第一个4KB对齐的地址处,通过调用malloc库向上增长,用于程序的动态内存管理
      • 共享库段,用于加载共享库、映射共享内存和文件I/O,使用mmap和unmap函数申请和释放新的内存区
      • 用户栈占据进程地址空间的最高部分,并向下增长,用于存放调用过程中的局部变量、函数返回地址、参数
      • 内核代码和数据、物理存储器,它们均被映射到所有进程共享的物理页面,这就为内核提供一个便利的方法来访问内存中任何特定的位置。对于每个进程来说他们均是一样的
      • 最顶层的内核地址空间包括了与进程有关的数据结构,如页表、内核在进程的上下文结构task_struct和mm结构,内核栈
  3. 存储器映像创建好后,加载器跳转到程序的入口点,也就是符号_start的地址。_start在目标文件ctrl.o中定义。然后执行所有C/C++都需要的startup/exit流程:
    (call __libc_init_first => _init => atexit => main => _exit)。
    C程序启动例程的伪代码

动态链接共享库

  1. 即使使用静态库,加载时也会出现重复加载到存储器的浪费情况,比如C标准库。
  2. 共享库(shared library)也称共享目标(shared object),Unix中,通常用.so后缀表示。(静态库是.a)。Windows中用.dll文件表示(dynamic linking library)。
  3. 给定的文件系统中,对于一个库只能有一个.so文件;其次,在存储器中,一个共享库的.text节的一个副本可以被不同的运行进程共享。
  4. 链接时,不会有任何.so的代码和数据节被拷贝到可执行目标文件中,只拷贝了一些重定位和符号表信息,以便于运行时可以解析对.so中的代码和符号引用。
  5. 对共享库的引用有两种,一是在程序执行之前,被加载时,动态加载器加载和链接共享库;二是,在运行时通过动态加载器加载、链接特定的库,而无需再编译时链接那些库到应用中(有点像Java的动态加载Class.forName(…))。
  6. 运行时加载的相关API:
#include <dlfcn.h>

/* 打开共享库,成功返回指向句柄的指针,失败返回null */
void *dlopen(const char *filename, int flag);

/* 解析符号,输入是共享库句柄指针和符号名,成功返回符号地址,失败返回null */
void *dlsym(void *handle, char *symbol);

/* 卸载共享库,如果此时没有其他共享库正在使用这个共享库的话 */
int dlclose(void *handle)

/* 返回上面三个api发生的错误,需要手动调用,没有错误返回null */
const char *dlerror(void)
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值