程序的链接¶
前言¶
程序的转化处理过程¶
- 预处理
- 命令
gcc -E hello.c -o hello.i
- 作用:
- 删除并展开define定义的宏
- 处理条件预编译指令
#if``#ifdef
等 - 删除注释
- 插入头文件到include
- 添加行号和文件名标识,便于产生调试用的信息
- 保留所有#pragma编译指令
- 得到不包含宏定义的可读文本文件
- 编译
- 命令:
gcc -S hello.i -o hello.s
- 作用:
- 对预处理文件进行词法分析、语法分析、语义分析并优化,生成汇编代码文件
- 得到可读的汇编代码文本文件(汇编语言源程序)
- 汇编
- 命令:
gcc -c hello.s -o hello.o
- 将汇编语言源程序转化为机器指令程序
- 得到的是可重定向目标文件,其中包含不可读的二进制代码
- 链接:
- 命令:
gcc -static(静态链接) -o myproc(目标文件名称,默认为a.out) main.o test.o(需要连接在一起的文件)
- 预处、理编、译汇编都是针对一个模块(.c文件)
- 链接是将多个可重定向目标文件合并以生成可执行目标文件
链接器的由来¶
- 直接用行号标识跳转的目标地址太原始,插入一条代码后需要对整个程序进行修改(机器语言)
- 用符号表示跳转位置和变量位置:(汇编语言)
- 用助记符表示操作码
- 用符号表示位置
- 用助记符表示寄存器
- 不能很好的适应高级编程语言中多模块开发的需求
- 高级语言:(链接)
- 子程序(函数)起始地址和变量起始地址是符号定义;
- 调用子程序(函数或过程)和使用变量即是符号的引用;
- 一个模块定义的符号可以被另一个模块引用;
- 最终须链接(即合并),合并时须在符号引用处填入定义处的地址
- 链接的优点;
- 模块化
- 一个程序可以分成很多源程序文件
- 可构建公共函数库,如数学库,标准I/O库等(多人开发,代码复用)
- 效率高
- 时间上,可分开编译(只需编译被修改的源程序的文件,然后重新链接)
- 空间上,无需包含共享库所有代码(不需要包含源码可以直接调用共享库函数,在可执行文件以及运行的的内存中只需包含所调用函数的代码,而不需要包含整个共享库)
链接概述¶
- 代码和代码合并,数据和数据合并
- 链接时不知道程序将会被放入到内存什么位置,因此合并到虚拟地址空间中
目标文件格式¶
三类目标文件¶
- 可重定位目标文件 (.o)
- 其代码和数据可和其他可重定位文件合并为可执行文件
- 每个.o 文件由对应的.c文件生成
- 每个.o文件代码和数据地址都从0开始
- 可执行目标文件 (默认为a.out)(exe)
- 包含的代码和数据可以被直接复制到内存并被执行
- 代码和数据地址为虚拟地址空间中的地址
- *共享的目标文件 (.so)(dll)
- 特殊的可重定位目标文件,能在装入或运行时被装入到内 存并自动被链接,称为共享库文件
目标文件格式¶
- 目标代码:指编译器和汇编器处理源代码后所生成的机器语言目标代码
- 目标文件:指包含目标代码的文件
- 分类:
- DOS操作系统(最简单) :COM格式,文件中仅包含代码和数据, 且被加载到固定位置
- System V UNIX早期版本:COFF格式,文件中不仅包含代码和数据 ,还包含重定位信息、调试信息、符号表等其他信息,由一组严格定 义的数据结构序列组成
- Windows: PE格式(COFF的变种),称为可移植可执行( Portable Executable,简称PE)
- Linux等类UNIX:ELF格式(COFF的变种),称为可执行可链接( Executable and Linkable Format,简称ELF)
ELF¶
链接视图(可重定位文件)¶
- 特点:
- 可被链接(合并)生成可执行文件或共享目标文件
- 静态链接库文件由若干个可重定位目标文件组成
- 包含代码、数据(已初始化.data和未初始化.bss)
- 包含重定位信息(指出哪些符号引用处需要重定位)
- 节是ELF 文件中的主体信息,包含了链接过程所用的目标代码信息,包括指令、数据、符号表和重定位信息等。
-
- 魔数:用来确定文件类型格式
- 只有这四个节需要被装载到存储空间
-
- .symtab符号表:程序中定义的函数名和全局静态变量名都属于符号
- elf头
- ELF头位于ELF文件开始,包含文件结构说明信息。分32位系统对应结构 和64位系统对应结构(32位版本、64位版本)
- 52字节
- e_type:文件类型(可重定向文件、可执行...)
- e_machine:机器结构类型(IA32、AMD64)
- e_version:目标文件版本
- e_entry:起始虚拟地址,如果没有关联的入口则为0(对于重定向文件都是0)
- e_ehsize:elf头的大小
- e_shoff:节头表在文件中的偏移量
- e_shentsize 表示节头表中一个表项的大小
- e_shnum 表示节头表中的项数
- 读取可重定位目标文件的ELF头
readelf -h main.o
- 节头表占40*15字节
- 节头表(section header)
- 除ELF头之外,节头表是ELF可重定位目标文件中最重要的部分内容
- 描述每个节的节名、在文件中的偏移、大小、访问属性、对齐方式等
- 40字节/表项
- 对每一个节都有这些信息(40字节)
- 可以得到每一个节的各种信息,完整复原出来
- 可重定位文件中,所有节的虚拟地址都是0
- 查看信息
readelf -S test.o
- 将bss和data分开的好处:
- data中存放具体的初始值,需要占磁盘空间
- bss无需放初始值,只要说明未来执行时每个变量占据多少空间(在未来执行时预留相应的空间),实际上bss不占用磁盘空间,提高磁盘利用率
执行视图(可执行文件)¶
- 特点:
- 包含代码、数据(已初始化.data和未初始化.bss),链接器将相互关联的可重定位目标文件中相同的代码和数据节
- 定义的所有变量和函数已有虚拟地址
- 符号引用处已被重定位,以指向所引用的定义符号
-
可被CPU直接执行,指令地址和指令给出的操作数地址都是虚拟地址
-
.text 节、.rodata 节和. data 节中除了有些重定位地址不同
-
elf头
readelf -h main
-
- 程序头表从52字节开始,占8*32字节
-
程序头表:
- ELF 头、程序头表、.init 节、.fini 节、.text 节和.rodata节合起来可构成一个只读代码段;.data 节和.bss 节合起来可构成一个可读写数据段。
- 在可执行文件启动运行时,这两个段必须装入内存且需要为之分配存储空间
- 为了在可执行文件执行时能够在内存中访问到代码和数据,必须将可执行文件中这些连续的、具有相同访问属性的代码和数据段映射到存储空间(通常是虚拟地址空间)中。程序头表就用千描述这种映射关系,一个表项对应一个连续的存储段或特殊节。
- 将程序中的节搬到内存中的段中
- 用来说明段信息
- p_type 描述存储段的类型或特殊节的类型。例如,是否为可装入段(LOAD) ,是否是特殊的动态节(Pf_DYNAMIC) ,是否是特殊的解释程序节(_INTERP)
- p_offset 指出本段的首字节在文件中的偏移地址。
- p_vaddr 指出本段首字节的虚拟地址。
- p_paddr 指出本段首字节的物理地址,因为物理地址由操作系统根据情况动态确定,所以该信息通常是无效的。
- p_filesz指出本段在文件中所占的字节数,可以为0 。
- p_memsz 指出本段在存储器中所占字节数,也可以为0 。p_flags 指出存取权限。(对于.bss与memsz与filesz有区别,memsz>filesz)
- p_align 指出对齐方式,用一个模数表示,为2 的匡整数幕,通常模数与页面大小相关,若页面大小为4KB, 则模数为212
可执行文件的存储器映像¶
- 对千lA-32 + Linux 系统, i386 System V ABI 规范规定:
- 只读代码段总是映射到从虚拟地址为0x8048000 开始的一段区域;
- 可读写数据段映射到只读代码段后面按4KB 对齐的高地址上,其中.bss 节所在存储区在运行时被初始化为0 。
- 运行时堆则在可读写数据段后面4 KB 对齐的高地址处,通过调用malloc 库函数动态向高地址分配空间
- 而运行时用户栈(run-time user slack) 则是从用户空间的最大地址往低地址方向增长。
- 堆区和栈区中间有一块空间保留给共享库目标代码,栈区以上的高地址区是操作系统内核的虚拟存储区。
- 当启动一个可执行目标文件执行时,首先会通过某种方式调出常驻内存的一个称为加载器的操作系统程序来进行处理。例如,任何UNlX 程序的加载执行都是通过调用execve系统调用函数来启动加载器进行的。加载器根据可执行目标文件中的程序头表信息,将可执行目标文件中相关节的内容与虚拟地址空间中的只读代码段和可读写数据段通过页表建立映射,然后启动可执行目标文件中的第一条指令执行。
- 特定的系统平台中的每个可执行目标文件都采用统一的存储器映像,映射到一个统一的虚拟地址空间,使得链接器在重定位时可以按照一个统一的虚拟存储空间来确定每个符号的地址,而不用关心其数据和代码将来存放在主存或磁盘的何处。因此,引入统一的虚拟地址空间简化了链接器的设计和实现。
- 加载时,只读代码段和可读写数据段对应的页表项都被初始化为“未缓存页" (即有效位为0) ,并指向磁盘中可执行目标文件中适当的地方。因此,程序加载过程中, 实际上并没有真正从磁盘上加载代码和数据到主存,而是仅仅创建了只读代码段和可读写数据段对应的页表项。只有在执行代码过程中发生了"缺页“异常时,才会真正从磁盘加载代码和数据到主存。
- 4kb对应16进制为1000,故读写数据取起点为0x8049000
程序链接的过程¶
- 不包括局部变量,局部变量不出现在符号表中
符号解析¶
- 定义声明函数、变量就是定义符号;调用函数、使用变量就是引用符号
- 编译器会将定义的符号放入符号表(一个结构数组在.symtab中)
- 每个表项包含符号名、长度和位置等信息
- 编译器会将符号的引用放在重定位节中(ret.text,rel.data)
- 连接器把符号的引用与符号的定义进行关联
- 每个定义符号在代码段或数据段中都被分 配了存储空间,将引用符号与定义符号建 立关联后,就可在重定位时将引用符号的 地址重定位为相关联的定义符号的地址。
- 每个可重定位目标模块m都有一个符号表,它包含了在m中定义的符号。
符号分类¶
- Global symbols(模块内部定义的全局符号)
- 由模块m定义并能被其他模块引用的符号。例如,非static 函数和非 static的全局变量(指不带static的全局变量)
- 如,main.c 中的全局变量名buf
- External symbols(外部定义的全局符号)
- 由其他模块定义并被模块m引用(声明、使用)的全局符号(定义和使用跨模块)
- 如,main.c 中的函数名swap
- Local symbols(本模块的局部符号)(不是局部变量,是文件作用域变量)
- 仅由模块m定义和引用的本地符号。例如,在模块m中定义的带static 的函数和全局变量(这里的static用于限制文件作用域)
- 不一定是全局变量,函数中的static修饰的静态变量也是!
-
如,swap.c 中的static变量名bufp1
符号表¶
- .symtab记录符号表信息(对应的名称(字符串)保存在strtab中),是一个结构数组,每个表项为16B:
- 符号的定义:指被分配了存储空间。为函数名时,指代码所在区;为变量名时,指所占的静态数据区。 所有定义符号的值就是其目标所在的首地址
符号解析¶
全局符号的强/弱¶
- 函数名和已初始化的全局变量名是强符号
- 未初始化的全局变名、函数声明是弱符号
多重定义¶
- 强符号不能多次定义,强符号只能被定义一次,否则链接错误
- 若一个符号被定义为一次强符号和多次弱符号,则按强定义为准,对弱符号的引用被解析为其强定义符号
- 若有多个弱符号定义,则任选其中一个
- 使用命令 gcc –fno-common链接时,会告诉链接器在 遇到多个弱定义的全局符号时输出一条警告信息。
- 由于程序是分开编译的,因此虽然在执行时p1中的d引用的是main中的,但是编译时会认为是p1中的double类型的变量,因此在赋值时也会按照double的规则和长度进行处理,从而导致错误
- tip:注意小端写入顺序!
- 多重定义的问题
- 使用全局变量可能会导致各种意料之外的错误(尤其是发生了重定义的情况)
- 多重定义全局变量会造成一些意想不到的错误,而且是默默发生 的,编译系统不会警告,并会在程序执行很久后才能表现出来, 且远离错误引发处。特别是在一个具有几百个模块的大型软件中, 这类错误很难修正。
- 要尽量避免使用全局变量
- 一定需要用的话,就按以下规则使用
- 尽量使用本地变量(static)
- 全局变量要赋初值
- 外部全局变量要使用extern
静态共享库¶
- 模块的划分:
- 静态库概念
- 将所有相关的目标模块(.o)打包为一个单独的库文件 (.a),称为静态库文件 ,也称存档文件
- 增强了链接器功能,使其能通过查找一个或多个库文件 中的符号来解析符号
- 在构建可执行文件时只需指定库文件名,链接器会自动 到库中寻找那些应用程序用到的目标模块,并且只把用到的模块从库中拷贝出来
- 创建
- 自定义:
- 常用静态库
解析过程¶
- 如果最后U仍然不为空,则说明发生了错误
链接顺序¶
- 特点:
- 按照命令行给出的顺序扫描.o 和.a 文件
- 扫描期间将当前未解析的引用记录到一个列表U中
- 每遇到一个新的.o 或 .a 中的模块,都试图用其来解析U中的符号
- 如果扫描到最后,U中还有未被解析的符号,则发生错误
- 关键
- 能否正确解析与命令行给出的顺序有关
- 好的做法:将静态库放在命令行的最后
- 同一个库可以重复出现
重定位¶
- 将多个代码段(E)与数据段分别合并为一个单独的代码段和数据段
- 计算每个定义的符号在虚拟空间中绝对地址
- 将可执行文件中符号引用处的临时地址修改为重定向后的地址信息
- 目标文件中哪些引用符号需要重定位、所引用的是哪个定义符号等,这些称为重定位信息,放在重定位节(.rel.text 和.rel.data) 中。
步骤¶
- 合并相同的节
- 将集合E的所有目标模块中相同的节合并成新节
- 例如,所有.text节合并作为可执行文件中的.text节
- 对定义符号进行重定位
- 确定新节中所有定义符号在虚拟地址空间中的地址
- 例如,为函数确定首地址,进而确定每条指令的地址,为变量确定首地址
- 完成这一步后,每条指令和每个全局变量都可确定地址
- 对引用符号进行重定位
- 修改.text节和.data节中对每个符号的引用(地址)
- 需要用到在.rel_data和.rel_text节中保存的重定位信息
基本概念¶
- 目的:将符号(临时地址)替换为具体的地址(相对/绝对)
重定位表¶
- 重定位表存储在.rel节之中,不同的节(如data\text)具有各自的.rel节
```c 重定位节 '.rel.text' at offset 0x380 contains 2 entries: 偏移量 信息 类型 符号值 符号名称 00000007 00000301 R_386_32 00000000 .data 00000010 00000e02 R_386_PC32 00000000 puts
重定位节 '.rel.data' at offset 0x390 contains 2 entries:
偏移量 信息 类型 符号值 符号名称
00000098 00000601 R_386_32 00000000 .rodata
0000018c 00000d01 R_386_32 00000000 do_phase
重定位节 '.rel.eh_frame' at offset 0x3a0 contains 1 entry:
偏移量 信息 类型 符号值 符号名称
00000020 00000202 R_386_PC32 00000000 .text
```
- 偏移量指的就是需要重定位的元素在相应的节中的位置(距离起点的偏移量),由此可以将一个无意义的地址转化为函数调用等信息
c
00000000 <do_phase>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 ec 08 sub $0x8,%esp
6: b8 9a 00 00 00 mov $0x9a,%eax
b: 83 ec 0c sub $0xc,%esp
e: 50 push %eax
f: e8 fc ff ff ff call 10 <do_phase+0x10>
14: 83 c4 10 add $0x10,%esp
17: 90 nop
18: c9 leave
19: c3 ret
- 结合重定位表可以发现call 10
指向的是puts函数;0x9a
是指的.data中偏移量为0x9a的变量
重定位过程¶
- 例:
相对地址重定位方式¶
- .text空间占用就是指令长度(数16进制位数,18字节)
- 其中ADDR(r_sym) 表示符号r_sym 在运行时的存储地址。ADDR(.text) 表示节.text 在运行时的起始地址,它加上偏移盘r_offset 后得到需重定位处的地址,再减初始值init (相当于加4),便得到PC 值。ADDR(r_sym) 减PC 值就是重定位值。例如,在上述例子中, ADDR
(swap) =0x8048394, ADDR(.text) =0x8048380, r_offset=0x7, init= -4 。
- 转移目的地址=pc(下条指令的起始地点)+ 偏移量
- 即 偏移量=转移目的地址-pc
- main开始地址为8048380,执行完call后的地址应该为804838b(数出来,别忘了eip还要加上指令的长度,因此数到下一条指令的起始地址),而swap的地址为8048394,再做差就可以得到偏移量
绝对地址重定位方式¶
- 绝对寻址中初始值为偏移量,映射后的绝对地址要在加上这个偏移量
swap重定位(续)¶
可执行文件的加载¶
动态链接¶
共享库¶
- 静态库的缺点:
- 库函数(如printf)被包含在每个运行进程的代码段中,对于并发 运行上百个进程的系统,造成极大的主存资源浪费
- 库函数(如printf)被合并在可执行目标中,磁盘上存放着数千个 可执行文件,造成磁盘空间的极大浪费
- 程序员需关注是否有函数库的新版本出现,并须定期下载、重新编 译和链接,更新困难、使用不便
- 共享库:
- 是一个目标文件,包含有代码和数据
- 从程序中分离出来,磁盘和内存中都只有一个备份
- 可以动态地在装入时或运行时被加载并链接
- Window称其为动态链接库(Dynamic Link Libraries,.dll文件)
- Linux称其为动态共享对象( Dynamic Shared Objects, .so文件)
- 链接方式:
- 在第一次加载并运行时进行
- Linux通常由动态链接器(ld-linux.so)自动处理
- 标准C库 (libc.so) 通常按这种方式动态被链接
- 在已经开始运行后进行
- 在Linux中,通过调用 dlopen()等接口来实现
- 优点:
- 在内存中只有一个备份,被所有进程共享(调用),节省内存空间
- 一个共享库目标文件被所有程序共享链接,节省磁盘空间
- 共享库升级时,被自动加载到内存和程序动态链接,使用方便
- 共享库可分模块、独立、用不同编程语言进行开发,效率高
- 第三方开发的共享库可作为程序插件,使程序功能易于扩展
自定义共享/动态链接过程¶
-
库
- 首先,通过dlopen函数加载和链接共享库,含义是启动动态链接器来加载并链接当前目录中的共享库文件mylib.so,这里dJopen函数的第二个 参数为RTLD_LAZY,用来指示链接器对共享库中外部符号的引用不在加载时进行重定位,而 是延迟到第一次函数调用时进行重定位,称为延迟绑定(lazybinding)技术。若dlopen函数出 错,则返回值为NULL;否则返回指向共享库文件句柄的指针。
- 在lJopen函数正常返回的情况下,通过dJsym函数获取共享库中所需函数。含义是指示动态链接器返回指定共享库mylib.so中指定符号myfuncl 的地址。若指定共享库中不存在指定的符号,则返回NULL。dlsym函数的第一个参数是指定共 享库的文件句柄,第二个参数用来标识指定符号的字符串,通常是后面将要使用的函数的函 数名。
- 在dlsym函数正常返回的情况下,就可以使用共享库中的函数,函数对应代码的首地址由dJsym函数返回。
- 在使用完程序所需的所有共享库内函数或变扯后,使用dlclose函数卸载这个共享库。若卸载成功,返回为0,否则为-l。
位置无关代码PIC¶
- GCC选项-fPIC指示生成PIC代码
-
要实现动态链接, 必须生成PIC代码
-
共享库代码是一种PIC
- 共享库代码的位置可以是不确定的
- 即使共享库代码的长度发生变化,也不影响调用它的程序
- 引入PIC的目的
- 链接器无需修改代码即可将共享库加载到任意地址运行
引用情况¶
- 模块内部函数调用或跳转
- 模块内部数据引用
- get_pc是为了借助call拿到返回地址(下一条指令的起始地址)
- 这是因为x86中没有可以直接获得eip值的指令
- 模块外数据的引用
- 同样利用call拿到地址,偏移从got拿到目标地址
- 模块间调用、跳转
- 方法一:在加载时进行重定位
方法二:延迟绑定¶
- 在第一次函数调用时执行重定位
- 在连接时完成对GOT表的初始化太慢(并且不是所有的函数都会被使用)
- GOT是.data节的一部分,而PLT是.text节的一部分
- 过程:
- 执行call指令后跳转到804834c(plt[1])
- 跳转到8049590(got[3])第一次到达got[3]时,got中并没有目标函数的地址
- 回到8048352(plt[1]),将ext的id(0)压栈,跳转804833c(plt[0])
- 将地址8049588压栈(got[1]),跳转到804958c(got[2])指出的动态链接器的延时绑定代码并执行
- 绑定器会根据栈中的ext的id以及got[1]中的信息对ext进行重定位,将重定位后的结果填入got[3]
- 延迟绑定技术的开销主要在第一次过程调用,需要额外执行多条指令,而以后每次都只是多执行一条指令,这对千同一个外部过程被多次调用的情况非常有益。
- 第一次调用
- 之后调用