tags:
- Notes
进程的一生——从出生到死亡 (Abandoned)
存储程序的工作方式是现代计算机运行的根基之一,它由冯诺依曼在1946年提出。它的核心思想是将用于解决问题的程序和数据一起存储在计算机的存储器中。计算机通过读取和执行这些存储的指令自动地完成各种任务,在执行过程中不需要认为的干预。
Any problem in computer science can be solved by another layer of indirection.
当今的计算机是层层抽象的产物,我们已经不需要直接和逻辑进行交互,而将这些繁琐的程序交给操作系统这个”管家“(管理软硬件资源)。有了一层层的抽象,我们现在只需要把高级语言源程序交给操作系统,而不需要明白管家之后要做什么。
我们有了底层的门电路后能够将电路实现进行封装,创造出自己的存储元件、计算器、控制器等这些功能部件。之后,我们组合这些元件可以组成自己简易的计算机了,但是指令太乱、没有章法,咋办?
人们提出了一个个的指令集体系结构来应对指令混乱的问题,规约底层硬件的实现,为上层提供操作计算机的接口。最开始我们用开关、灯泡来作为计算机的输入和输出。后来,我们将这些工作交给操作系统代为我们完成,我们只用输入指令。这也是汇编诞生的节点,而直到这里都是机器级的代码,也就是对于不同的ISA,你需要使用不同的汇编。
再到后来,具有跨平台特性(portable)的高级语言诞生了。汇编指令和机器码是一一对应的,我们可以通过汇编器来直接转换,我们在一定程度上可以说汇编就是机器语言。而高级对于机器来说就相当于外国语,因为高级语言是人类读写的,是人类与机器交流的接口。通过编译器,我们就可以将高级语言翻译为机器能够理解的语言。所有相比于汇编器,编译器可就复杂多了。
从高级语言的诞生开始,你就可以用高级语言提供的抽象来操作计算机底层硬件为你做各种各样的操作。各种应用层出不穷,为人们的生活带来了极大的便利。
程序自动执行而不需要人为干预听起来可能有些不知所以然。到了后面,我们会慢慢发现,这些其实都是堆和栈的功劳。在进程的虚拟内存中,只有堆和栈是会一直变化的,栈中的创建栈帧、销毁栈帧更是时刻不停的发生。
栈是一种数据结构,遵循着先进后出(Last In First Out, LIFO) 的原则进行工作。在程序运行的过程中,我们往往需要一些函数帮助我们实现想要的功能,而栈的应用就是保存在程序执行过程中所需要保存的返回地址和局部变量,还负责保存和恢复现场信息。
如果你学过系统调用或中断,你一定对“栈”不陌生。栈非常重要,它保存的数据是程序近期会用到的数据。在硬件中断或软件中断发生时,操作系统为了响应这些中断,会将程序运行的现场信息保存到内核栈。等操作系统应付完差事,读取栈中数据并恢复现场状态。
如果执行用户程序呢?在x86-64架构下,当我们进入一段用户程序的函数中时,我们会:
call func
push ebp
mov ebp, esp
push sub_args
add
or sub
and so on函数执行完毕要返回,这时我们会:
mov esp, ebp
pop ebp
ret
其中,EBP和ESP分别是帧指针寄存器和栈指针寄存器,分别用来指 向当前栈帧的底部和顶部。其实也并不复杂,就是调用函数的栈底就是被调用函数的栈顶。我们后面会用具体的例子说明。
我们可能会知道,堆是容纳、存放动态内存分配的内存块。尽管使用上理解起来很简单,但是堆的实现比栈来得复杂。堆内存的分配有很多种实现方式,如bitmap,linked list等。在这个文档中,你只需要知道,堆是用于动态内存分配,程序在运行时可以请求和释放内存块。
x86汇编有三部分构成:1) 伪指令,2) 指令,3) 标号 所构成。
指令中需要给出的信息有:
入口参数的位置:从左到右的顺序入栈,即最右边的参数先入栈。
不同于传统32位机器将所有要传递的参数压到栈中保存的方式,在x86-64架构下的机器会先使用寄存器传递参数,通过通用寄存器传送参数,很多过程不用访问栈,缩短了代码的执行时间。在x86-64架构下的机器中,最多可有6个整型或指针型参数通过寄存器传递。当超过6个入口参数时,后面的通过栈来传递。
寄存器传递:在x86-64架构下,前六个整数参数依次存放在寄存器 %rdi、%rsi、%rdx、%rcx、%r8 和 %r9 中。如果有浮点参数,它们会依次存放在 %xmm0 到 %xmm7 中。
栈传递:如果参数数量超过了寄存器的数量限制,多余的参数会依次压入栈中,从右到左的顺序。在栈中传递的参数若是基本类型,则都被分配8个字节。
假设有一个函数 func,它有八个整型参数和两个浮点型参数:
void func(int a, int b, int c, int d, int e, int f, int g, int h, float i, float j);
在调用 func 时,前六个参数 a 到 f 会存放在寄存器中,而参数 g 和 h 会被压入栈中:
整型数:
a -> %rdi
b -> %rsi
c -> %rdx
d -> %rcx
e -> %r8
f -> %r9
g -> 栈
h -> 栈
浮点数:
i -> %xmm0
j -> %xmm1
...
klmnop -> %xmm2-%xmm7
q -> 栈
在上面,我们知道当我们直接使用寄存器来传递参数后,能够免去一系列的微指令的开销。当我们每次调用过程中都只使用寄存器传递参数的话,我们就能保证系统的性能处在最佳状态。为了优化为了减少参数传递的开销,可以考虑以下优化方法:
从我们编写程序,到一个真正可以在机器上运行的二进制可执行目标程序ELF之间,我们需要执行许多步骤。C语言源程序的预处理、编译到汇编最后链接之后我们才能获得二进制的可执行文件。下面,我们会一步一步的说明每个阶段的作用是什么。
当你创建了一个 xxxx.c
的文件,一颗ELF的种子开始在此处生根。我们前面说过,高级语言源程序是给人类读的,机器没办法直接执行高级语言源程序。可能我们没有办法直接理解这句话,我们下面用最简单的 hello.c
源程序来说明一下。
#include <stdio.h>
int main(){
printf("hello, world\n"); // This prints "hello, world\n"
}
上面的代码在计算机看来就是一连串的ASCII字符,转换成ASCII就是:
# i n c l u d e <sp> < s t d i o . h > \n \n i n t m a i n ( ) \n { \n <sp> <sp> <sp> <sp> p r i n t f ( " h e l l o , w o r l d \ n " ) ; \n }
而计算机实际上只能存储0和1所组成的二进制数,将这些字符转换成对应的十进制数数字就是:
35 105 110 99 108 117 100 101 32 60 115 116 100 105 111 46 104 62 10 10 105 110 116 32 109 97 105 110 40 41 10 123 10 32 32 32 32 112 114 105 110 116 102 40 34 104 101 108 108 111 44 32 119 111 114 108 100 92 110 34 41 59 10 125
在得到源程序后,我们可以用下面的命令对源程序进行预处理:
gcc -E hello.c -o hello.i
cpp hello.c > hello.i
预处理完毕后,我们就得到了 xxxx.i
的预处理后文本文件。在Linux中,文件的后缀并不重要,但我们这样规定,使得我们能够清楚 xxxx.i
是一个预处理文件。那么预处理阶段做了什么呢?
预处理文件处理源文件中以 '#' 开头的语句。如:
#define
并展开其所定义的宏#if
、#ifdef
、#endif
等#include
处,可以递归方式进行处理(复制粘贴)#pragma
编译指令(编译用)完成这六步的源文件处理后,我们就得到了预处理文件,虽然预处理文件仍然可读,但是不包含任何头文件信息和宏定义。这时的种子褪去外壳。
编译非常重要,因为这是从 human readable 到 machine readable 的阶段。编译过程将预处理文件进行词法分析、语法分析、语义分析和优化后生成汇编代码文件。《编译原理》就是专门讨论编译阶段而诞生的学科。我们称进行编译处理的程序为编译器(Compiler) 。
我们用如下的命令可将程序编译为可读的汇编代码文件。虽然机器无法理解这些代码,但是汇编代码和二进制机器语言代码一一对应。
gcc -S hello.i -o hello.s
gcc -S hello.c -o hello.s
/user/lib/gcc/xxxx-linux-gnu/4.1/cc1 hello.c
其中,cc1 是 GCC 的内部编译器,它负责将预处理后的 C 语言文件转换为汇编代码。直接调用cc1可以跳过 GCC 的其他阶段,直接进行编译。gcc命令实际上是具体程序(如ccp、cc1、as等)的包装命令, 用户通过gcc命令来使用具体的预处理程序ccp、编译程序cc1和 汇编程序as等。
编译阶段的实现非常复杂,我们不介绍。
编译阶段完成后,生成ELF可执行文件的程序就依然走完了大半。在汇编阶段,汇编程序(汇编器)会将编译阶段所产生的汇编代码文件转换成机器指令序列。我们提到过汇编指令和机器指令是一一对应的,都属于机器级代码,只不过前者是后者的符号标识而已。我们可以用如下的指令汇编得到可重定位目标文件。
gcc –c hello.s –o hello.o
gcc –c hello.c –o hello.o
as hello.s -o hello.o
汇编结果是一个可重定位目标文件(如,hello.o),其中包含着的是人不可读的二进制代码,必须用相应的工具软件来查看其内容。
预处理、编译和汇编三个阶段针对一个模块(一个.c文件)进行处理,得到对应的一个可重定位目标文件(一个.o文件)。而链接过程是将多个可重定位目标文件合并生成一个可执行文件。我们可以用下面的shell命令来生成一个可执行文件:
gcc –static –o myproc main.o test.o
ld –static –o myproc main.o test.o # 需要C静态标准库
其中,–static 表示静态链接,如果不指定-o选项,则可执行文件名 为“a.out”。
早期程序员是在纸带上编写程序的,当程序员修改了某行指令(增加/删除指令)时,纸带很可能作废。这是因为有些代码是位置相关的(如 jmp
,即跳转指令),当增加一行代码,jmp
后面跟的绝对地址就需要跟着改变。
后来汇编语言出现了,汇编语言用符号表示跳转的位置,不需要修改 jmp
指令的跳转目标了。如:
0: 0101 0110 add B
1: 0010 0101 jmp L0
...
5: 0110 0111 L0: sub C
这样,我们开始编写的汇编指令都是位置无关的。随着程序越来越复杂,一个程序可能由多个子模块组成。子程序(函数)的起始地址和变量的起始地址我们也用符号来定义,调用子程序或对变量的使用就是顾好的引用(reference)。之后这些符号通过汇编、链接后才会确定符号的地址(符号解析和重定位)。
我们编写的C语言源程序通过预处理、编译、汇编之后终于得到一个二进制的目标文件了。之后我们还需要链接之后才能成为可执行目标文件。那为什么要链接,链接给我们带来什么好处了?简单来说,链接容许我们程序的模块化,当你要使用某个功能时,只需要加载对应功能的模块就好了。这种模块化加载还带来了另一个好处,即效率高,debug程序和更新程序也只需要修改某一个模块就好了。开发工作由此也可以并发进行,效率更高。
程序经过编译汇编之后能够生成下面的三类目标文件。
即未链接之前的目标文件,每个.o文件都由对应的.c文件生成。每个.o文件代码和数据的地址都从0开始。虽然这时的目标文件代码是机器能够识别的机器代码,但由于没有经过符号的解析和重定位,这种目标文件仍然是不可执行的。可重定位目标文件包含代码、数据和重定位的信息。
静态链接库文件可以又若干的可重定位目标文件构成。
要得到可执行目标文件,我们需要将可重定位目标文件和其他可重定位文件合并为可执行文件。
可执行目标文件包含的代码和数据可以被直接复制到内存中执行。这时的代码和数据的地址不再是从0开始,经过重定位之后,代码和数据的地址为虚拟地址空间中的地址。
**链接的本质就是合并多个可重定位目标文件的代码节、数据节等成代码段、数据段。**
但是在合并这些可重定位目标文件的代码节、数据节等节之前,我们需要对这些符号(即全局变量和函数名) 进行解析并重定位这些符号。(局部变量不放入符号表)
共享库文件是特殊的可重定位目标文件,能够在程序装入内存或运行时自动地被装入内存并自动被链接。在Windows中称为动态链接库文件(Dynamic Link Libraries, DLL)。
目标代码(Object Code):编译器和汇编器处理源代码后所生成的机器语言目标代码。
目标文件(Object File):指包含目标代码的文件,最早的目标文件是自由格式,非标准的。标准的几种目标文件格式:
可执行可链接目标文件有两种视图,我们说的三类目标文件都是是ELF。其中 .o 后缀的文件和 .so 后缀的文件其实并不是可执行文件,而是可重定位目标文件,这时*.o 文件并不具有可执行属性,是ELF的链接视图。当这些链接视图的文件链接后就会生成可执行目标文件(默认 a.out ),这时得到的目标文件具有可执行的属性,是ELF的执行视图。
我们现在可以将链接的概念拓展一下:链接的本质就是将多个可重定位目标文件的代码节、数据节等合并成可执行目标文件中的代码段和数据段。
本节课我们依旧使用下面的程序做例子:
#include <stdio.h>
int main(){
printf("hello, world\n");
}
开始之前,我们先用命令将C语言程序生成可重定位目标文件和可执行目标文件:
gcc -c hello.c -o hello.o
gcc hello.c -o hello
需要用到的命令:
# 显示 ELF 文件的所有信息
readelf -a objfile
# 显示 ELF 文件头信息
readelf -h objfile
# 显示 ELF 文件的节头信息
readelf -S objfile
# 显示 ELF 文件的符号表信息
readelf -s objfile
# 显示 ELF 文件的程序头信息
readelf -l objfile
# 反汇编 ELF 文件中的代码段
objdump -d objfile
# 以十六进制和 ASCII 格式显示文件内容
hexdump -C objfile
# 查看目标文件中的符号表信息
nm
可重定位目标文件由ELF头、程序头表(可选)、节(Sections)、和节头表组成。节(section)是ELF文件中具有相同特征的最小可处理单位,链接时就是对相同的属性的节进行组合成段(segments)。
ELF头标识了文件的架构类型、数据的编码方式、文件类型、目标机器架构、入口点地址、节头表的位置和header的大小等信息。而节头表标识了每个节的属性信息,这两个部分是可重定位目标文件最重要的部分,搞清楚这两个部分,你就明白了ELF的链接视图是怎么样的。
ELF头位于ELF文件最开始的地方(偏移为0),包含了文件结构的说明信息。这些信息存放在一个数据结构中。
#include <stdint.h>
#define EI_NIDENT 16
typedef struct {
unsigned char e_ident[EI_NIDENT]; // 魔数和其他标识信息,16字节
uint16_t e_type; // 文件类型,2字节
uint16_t e_machine; // 目标机器架构(如 x86-64),2字节
uint32_t e_version; // ELF 版本号,4字节
uint64_t e_entry; // 程序入口地址(可执行文件使用),8字节
uint64_t e_phoff; // 程序头表在文件中的偏移量,8字节
uint64_t e_shoff; // 节头表在文件中的偏移量,8字节
uint32_t e_flags; // 特定于处理器的标志,4字节
uint16_t e_ehsize; // ELF 头部的大小,2字节
uint16_t e_phentsize; // 程序头表中每个条目的大小,2字节
uint16_t e_phnum; // 程序头表中的条目数,2字节
uint16_t e_shentsize; // 节头表中每个条目的大小,2字节
uint16_t e_shnum; // 节头表中的条目数,2字节
uint16_t e_shstrndx; // 节头字符串表的索引,2字节
} Elf64_Ehdr; // ELF头部总共64字节
ELF64头信息在机器中的编码是01序列,我们可以通过 readelf 这种工具软件来查看ELF中包含的信息。这里我们需要读取文件的头信息,我们用 readelf -h hello.o
来获取ELF的头包含什么信息。
du@DVM:~/Desktop$ readelf -h hello.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x0
Start of program headers: 0 (bytes into file)
Start of section headers: 600 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 0 (bytes)
Number of program headers: 0
Size of section headers: 64 (bytes)
Number of section headers: 14
Section header string table index: 13
从中,我们能够读取到很多信息,如果你好奇查看了 hello 可执行文件的ELF头,你就会发现有几项不一样。比如文件的类型、入口地址、程序头和节信息变了。其中入口地址是我们不得不关注的,hello.o 是可重定位的目标文件,给出的是ELF的链接视图,所以装入的入口地址是0x0。
我们再来读一下其他信息,在ELF头中,我们可以读到另一个重要表项——节头表的信息。我们在下面的注释中给出。
# 节头表开始的位置,偏移量为600字节(0x258)
Start of section headers: 600 (bytes into file)
# 节头表由多个节头的信息组成,这里节头表由14个大小为64个字节的节头构成。一共896Bytes
Size of section headers: 64 (bytes)
Number of section headers: 14
# 字符串表在节头表中的位置 .strtab
Section header string table index: 13
思考一下,为什么需要额外地指出 .strtab 在节头表中的位置?
我们前面在ELF头中其实都看到 hello.o 中有多少个节头了。这些节头给出每个节的相关信息,如节的名称、节的起始地址、节的偏移等等。每个节承担不同的功能,我们很快就能根据这些信息从文件的二进值信息这找到我们写进去的数据了(Hacker 101)。
在 ELF 文件中,节头表(Section Header Table)包含了每个节的信息。节头的结构体定义如下:
typedef struct {
uint32_t sh_name; // 节名称的索引
uint32_t sh_type; // 节的类型
uint64_t sh_flags; // 节的标志(在虚拟空间中的访问属性)
uint64_t sh_addr; // 节的虚拟内存地址(链接视图无意义)
uint64_t sh_offset; // 节在文件中的偏移
uint64_t sh_size; // 节的大小
uint32_t sh_link; // 节的链接信息
uint32_t sh_info; // 链接信息
uint64_t sh_addralign; // 对齐要求信息
uint64_t sh_entsize; // 节中条目的大小
} Elf64_Shdr;
节头的结构体中的数据都是01序列,不方便读懂,我们用 readelf -S
命令来获取节头表的信息:
du@DVM:~/Desktop$ readelf -S hello.o
There are 14 section headers, starting at offset 0x258:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
000000000000001e 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000198
0000000000000030 0000000000000018 I 11 1 8
[ 3] .data PROGBITS 0000000000000000 0000005e
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 0000005e
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .rodata PROGBITS 0000000000000000 0000005e
000000000000000d 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 0000006b
000000000000002c 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 00000097
0000000000000000 0000000000000000 0 0 1
[ 8] .note.gnu.pr[...] NOTE 0000000000000000 00000098
0000000000000020 0000000000000000 A 0 0 8
[ 9] .eh_frame PROGBITS 0000000000000000 000000b8
0000000000000038 0000000000000000 A 0 0 8
[10] .rela.eh_frame RELA 0000000000000000 000001c8
0000000000000018 0000000000000018 I 11 9 8
[11] .symtab SYMTAB 0000000000000000 000000f0
0000000000000090 0000000000000018 12 4 8
[12] .strtab STRTAB 0000000000000000 00000180
0000000000000013 0000000000000000 0 0 1
[13] .shstrtab STRTAB 0000000000000000 000001e0
0000000000000074 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), l (large), p (processor specific)
属性信息的含义如下:
.text
、.data
等。Link
字段一起使用。现在,我们就能准确地从中知道每个节相对 0 的确切位置。但仍然迷惑的是为何所有节的 address 字段都为 0000?这是因为当前 hello.o 并没有链接重定位生成可执行目标文件,所以对应的每个节的起始地址都为0(因为这时的节地址是毫无意义的)。
我们的例子中并没有使用任何变量,所以在 .data 节和 .bss 节中什么都没有,size 字段为0。而我们的 hello world
字符串属于 .rodata 节中的内容(只读数据、printf格式串、switch跳转表),一共13个字节(13 = 0xd),所以 .rodata 的 size 字段为 0xd。我们之后会在 hello.o 文件中查看我们 .rodata 中的内容。
如果你定义了初始化的全局变量和未初始化的全局变量,你就会发现 .data 节中是有数据的,而 .bss 节中无论你定义了多少未初始化的全局变量和局部静态变量都不会存放任何数据即 size 字段永远是0。这是因为 .data 节中存放具体的初始值,需要占磁盘空间。而 .bss 节用专门的节头表来说明应该为 .bss 节预留多大的空间,所以只要说明 .bss 中的每个变量将来在执行时占用几个字节即可,因此,.bss 节实际上不占用磁盘空间,提高了磁盘空间利用率。(C语言默认未初始化的全局和局部静态变量的值为0)
通过节头表中的信息和ELF头的信息,我们就能绘制出 hello.o 文件结构:
+-------------------------+-------------------------+ 0x000
| ELF Header | 64 bytes (0x40) |
+-------------------------+-------------------------+ 0x040
| .text | 30 bytes (0x1e) |
+-------------------------+-------------------------+ 0x05e
| .data | 0 bytes |
+-------------------------+-------------------------+ 0x05e
| .bss | 0 bytes |
+-------------------------+-------------------------+ 0x05e
| .rodata | 13 bytes (0x0d) |
+-------------------------+-------------------------+ 0x06b
| .comment | 44 bytes (0x2c) |
+-------------------------+-------------------------+ 0x097
| .note.GNU-stack | 0 bytes |
+-------------------------+-------------------------+ 0x097
+-------------------------+-------------------------+ 0x098(0x97对齐)
| .note.gnu.property | 32 bytes (0x20) |
+-------------------------+-------------------------+ 0x0b8
| .eh_frame | 56 bytes (0x38) |
+-------------------------+-------------------------+ 0x0f0
| .symtab | 144 bytes (0x90) |
+-------------------------+-------------------------+ 0x180
| .strtab | 19 bytes (0x13) |
+-------------------------+-------------------------+ 0x193
+-------------------------+-------------------------+ 0x198(0x193对齐)
| .rela.text | 48 bytes (0x30) |
+-------------------------+-------------------------+ 0x1c8
| .rela.eh_frame | 24 bytes (0x18) |
+-------------------------+-------------------------+ 0x1e0
| .shstrtab | 116 bytes (0x74) |
+-------------------------+-------------------------+ 0x254(596 Bytes)
+-------------------------+-------------------------+ 0x258(600 Bytes)
| Section Headers | 896 bytes (14 * 64) |
+-------------------------+-------------------------+ 0x5d8(600+896 Bytes)
通过结构信息,我们可以很清楚地看到文件从哪开始,从哪里结束。我们用 hexdump -C
以16进制和ASCII格式查看 hello.o 文件。我们看到,程序如我们预想的一样从 0x5d8 结束。查看 .rodata 节的位置,我们也如预料地看到了 hello,world.
这样12个字符。至此,关于 hello.o 的解读圆满结束!
du@DVM:~/Desktop$ hexdump -C hello.o
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
...
00000050 89 c7 e8 00 00 00 00 b8 00 00 00 00 5d c3 68 65 |............].he|
00000060 6c 6c 6f 2c 20 77 6f 72 6c 64 00 00 47 43 43 3a |llo, world..GCC:|
...
000005d0 00 00 00 00 00 00 00 00 |........|
000005d8
switch
jump table可执行文件中包括代码、数据(已初始化的 .data 和未初始化的 .bss)。与可重定位的目标文件不同,我们定义的所有变量和函数在可执行目标文件中都明确了其地址(虚拟地址空间),符号的引用处也已被重定位,指向所引用的定义符号。
为了执行的方便,链接时还会将具有相同访问属性的节进行合并成段(如 .text 节和 .rodata 节合并为 .rodata 段,.data 和 .bss 节合并为 .data 段)。这些段的信息被存放在程序头表/段头表中。
在Linux中的可执行目标文件没有文件扩展名(默认为a.out),在Windows中,可执行文件的扩展名为 .exe。
我们先用命令 readelf -h 查看ELF头,看看与可重定位目标文件有什么不同。首先,最大的不同就是程序的入口地址不再是0了,还多了程序头表/段头表(Segment header table) 和 .init 节。然后我们发现,在链接(重定位)过后,我们少了带重定位信息的节(如.rela.text、.rela.data等)。
du@DVM:~/Desktop$ readelf -h hello
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1060
Start of program headers: 64 (bytes into file)
Start of section headers: 13976 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 31
Section header string table index: 30
我们简单介绍 .init 节和段头表的作用。.init 节用于定义 _init函数,这个函数用于可执行目标文件开始时的初始化工作。和节头表一样,段头表也是一个结构体数组,段头表用于描述这些段的各种属性信息。从上面的ELF头信息中我们可以读出,程序有13个段头,每个段头有56个字节。
程序头表描述了可执行文件中的段的属性信息和虚拟空间中的存储段和节之间的映射关系(段由哪些节构成)。在我的系统上,每个段头有52字节,这52字节就说明虚拟地址空间中一个连续的段或特殊节的描述信息。
typedef struct {
uint32_t p_type; // 段的类型
uint32_t p_flags; // 段的权限标志
uint64_t p_offset; // 段在文件中的偏移量
uint64_t p_vaddr; // 段在内存中的虚拟地址
uint64_t p_paddr; // 段在内存中的物理地址
uint64_t p_filesz; // 段在文件中的大小
uint64_t p_memsz; // 段在内存中的大小
uint64_t p_align; // 段在内存中的对齐要求
} Elf64_Phdr;
同样,我们用 readelf -l 读取段头表的信息:
du@DVM:~/Desktop$ readelf -l hello
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1060
There are 13 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002d8 0x00000000000002d8 R 0x8
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000628 0x0000000000000628 R 0x1000
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x0000000000000175 0x0000000000000175 R E 0x1000
LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000
0x00000000000000f4 0x00000000000000f4 R 0x1000
LOAD 0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
0x0000000000000258 0x0000000000000260 RW 0x1000
DYNAMIC 0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc8
0x00000000000001f0 0x00000000000001f0 RW 0x8
NOTE 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000030 0x0000000000000030 R 0x8
NOTE 0x0000000000000368 0x0000000000000368 0x0000000000000368
0x0000000000000044 0x0000000000000044 R 0x4
GNU_PROPERTY 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000030 0x0000000000000030 R 0x8
GNU_EH_FRAME 0x0000000000002014 0x0000000000002014 0x0000000000002014
0x0000000000000034 0x0000000000000034 R 0x4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
0x0000000000000248 0x0000000000000248 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .plt.got .plt.sec .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .data .bss
06 .dynamic
07 .note.gnu.property
08 .note.gnu.build-id .note.ABI-tag
09 .note.gnu.property
10 .eh_frame_hdr
11
12 .init_array .fini_array .dynamic .got
段头的的属性信息含义入下:
LOAD
表示可加载段/可装入段,DYNAMIC
表示动态链接信息段。R
表示可读,W
表示可写,E
表示可执行。通过Section to Segment mapping中的信息,我们能够知道各个段和其所包含的节之间的映射关系,哪个段由哪些节组成。并且通过段的类型能够知道哪些段是需要载入内存,与存储器进行映像的。通过这些段头的信息,和在上节课的操作一样,我们可以通过这些地址信息找到我们只读字符串的位置。
00002000 01 00 02 00 68 65 6c 6c 6f 2c 20 77 6f 72 6c 64 |....hello, world|
00002010 00 00 00 00 01 1b 03 3b 30 00 00 00 05 00 00 00 |.......;0.......|
在 ELF 文件中,只有 LOAD
类型的段会被装入内存。这些段包含了程序的代码和数据,加载器会根据这些段的信息将它们映射到进程的地址空间中。其他类型的段,如 PHDR
、INTERP
、DYNAMIC
、NOTE
等,虽然在 ELF 文件中有定义,但不会被直接装入内存,而是用于辅助加载和运行程序。
链接的操作实际上是合并多个可重定位目标文件,要合并这些可重定位目标文件,我们就需要先对这些符号进行符号解析。这是因为不同的可重定位目标文件中可能有其他文件中的符号引用。我们确定这些符号的引用关系的这一过程就是符号解析(将每个符号的引用都与一个确定的符号定义建立关联)。
符号解析后,我们就能够合并相关的.o文件了。具体一点的过程是:先确定每个符号的地址,然后在引用处填入符号的地址。
在上面的例子中,P0.o中引用了符号P1(使用外部定义的函数),在P1.o中引用了P0.o中的符号A和B(外部数据)。而在编译阶段,符号的位置是未知的,因为它们可能在不同的目标文件中。链接器将可重定位目标文件中属性相同的节合并成段。链接器解析这些符号,确定它们的最终地址。在符号引用处将引用替换为解析后的地址,从而完成对符号的访问。
每个可重定位目标文件都有一个符号表,包含着在文件中定义和引用的符号。有三种连接器符号:
模块内部定义的全局符号(global symbols):
由模块m定义并能被其他模块引用的符号。例如,非static的函数和非static的全局变量。
如,my_func.c 中的全局变量名buf
。
外部定义的全局符号(external symbols):
由其他模块定义并被模块引用的全局符号。如在main.c
中的函数名swap
和add
。
本模块的局部符号(local symbols):
仅由模块m定义和引用的本地符号。在模块定义的带static的函数和全局变量。如,my_var.c中的static变量local
。
需要注意的是链接的局部符号不是指程序中的局部变量(分配在栈中的临时性变量)。对于C语言中的局部变量,链接器并不关心。
在符号解析的时候,当本模块内引用的是本地的局部符号,我们只要与本模块内唯一的定义符号关联即可。而全局符号的解析涉及多个模块(内部定义的、外部定义的),所以较复杂。
在程序中,我们需要先定义一个符号。之后对该符号的操作其实都是对符号的引用,例如我们定义了一个函数(符号的定义),之后使用函数的操作叫函数的引用(符号的引用)。变量也有相似的操作。例如:
void test(){} // 定义符号 test
void test(); // 定义符号(弱符号)
test(); // 引用符号 test
int x; // 全局变量x,定义符号x
int *xp = &x; // 全局变量xp,定义符号xp,引用符号x
说明类型的符号都是一种定义,而其他的属于引用。局部变量会在栈中分配空间,不会被过程外的文件中引用,所以不属于符号,不会出现在符号表中。
我们举个例子:
我们将变量都放在 my_var.c 中(3个符号,没有引用)
int buf[3] = {1, 2, 0}; // 全局符号的定义
int var2 = 15; // 全局符号的定义
static var1 = 5; // 局部符号(static)的定义
然后我们有 my_func.c(3个符号,7个 .rela.text 的引用(buf),2个 .rela.eh_frame 的引用)
extern int buf[]; // 外部符号的定义
void swap(){ // 全局符号的定义
int temp;
temp = buf[0]; // buf引用
buf[0] = buf[1]; // 2*buf的引用
buf[1] = temp; // 1*buf的引用
}
void add(){ // 全局符号的定义
buf[2] = buf[0] + buf[1]; // 2*buf的引用
}
之后所有的操作在 main.c 中完成(6个符号)
int buf[]; // 外部符号(弱符号)的定义
void add(); // 外部符号(弱符号)的定义
void swap(); // 外部符号(弱符号)的定义
void local(){}// 全局符号的定义
int main(){ // 全局符号的定义
swap(); // 全局符号的引用
add(); // 全局符号的引用
local(); // 全局符号的引用
return 0;
}
编译器会将符号的定义存放到一个符号表(symbol table) 即我们之前看到的 .symtab 中。符号表是一个结构体数组,每个表项都包含着符号名、长度和位置等信息。在64位系统中,符号表的每一项有24字节大小。(32位系统位16字节)
typedef struct {
Elf64_Word st_name; // 符号名(.strtab节中的偏移量) 4B
unsigned char st_info; // 符号类型(低四位)和绑定(高四位) 1B
unsigned char st_other; // 符号可见性,通常为0 1B
Elf64_Half st_shndx; // 符号对应目标所在的节,或其他情况 2B
Elf64_Addr st_value; // 在对应节中的偏移量,或虚拟地址 8B
Elf64_Xword st_size; // 符号对应的字节数(函数大小或变量长度) 8B
} Elf64_Sym;
绑定属性(Bind):局部符号(0)、全局符号(1)、弱符号(2)
符号类型(Type):未指定类型(0)、数据(1)、函数或可执行代码(2)、节(3)、文件名(4)。其他情况下,ABS表示不该被重定位、UND表示未定义、COM表示未初始化数据(.bss),此时,value表示对齐要求,size给出最小大小。(x64 下未初始化数据一般用 .bss 节号指代未初始化符号的类型)
当我们查看符号表时,会发现我们的文件中额外多了两个符号。第一个符号(Num: 0)通常是占位符,之前我们查看节头表信息时也会发现第一个节头也是占位符。第二个符号(Num: 1)通常是源文件的名称。往后的符号表信息往往就是我们所定义或引用的符号了。
符号的重定位信息会被编译器放到重定位节中。当符号解析完成且没有错误时,链接器会进行符号的重定位,即将符号的引用与一个确定的符号定义相关联。这一过程涉及符号表和重定位节之间的关联。在重定位阶段,多个代码节和数据节会合并成单独的代码段和数据段。链接器会计算每个定义的符号在虚拟地址空间中的绝对地址,并将引用处的地址修改为重定位后的地址信息。
从符号对应的字节数我们能够看到,我们其实就是用符号来指代一段存储空间。当符号为变量名时,变量名指的就是其所占的静态数据区,当符号是函数名时,函数么指的其实就是代码所在的区域大小。
全局符号的强弱特性:
符号解析规则:
gcc -c main.c -o main.o -fno-common #禁止弱符号
用上面的命令链接时,会告诉链接器在遇到多个弱定义的全局符号时输出一条警告信息。
static变量是弱符号且只能在当前文件中使用(限制符号的可见性),所以即使在不同文件中定义有相同变量名的static变量或函数也不会发生冲突。
多重定义全局变量会造成一些意想不到的错误,编译系统也可能不会发出警告。程序运行过程中可能就会发生意料之外的错误。随着软件不断做大,错误的根源也会变得愈发难找。
我们下面看一下这个例子。在编译的时候,模块各自进行编译,在main.c
中的变量d
是int
类型的变量,运算时的命令是整型运算命令。而p1.c
中的变量d
虽然是未初始化的全局变量(弱符号)。但是编译的时候并不知晓这个符号是否外部定义了。所以执行d = 1.0;
的时候会将d
作为浮点类型变量进行运算。
链接的时候强弱符号的定义非常清晰,并不会发生冲突。
# include <stdio.h>
int d = 100;
int x = 200;
d += 10;
void p1(void);
int main(){
p1();
printf("d = %d, x = %d\n", d, x);
return 0;
}
main.c
double d;
void p1(){
/*
d = 1.0;会被汇编成浮点运算指令
FLD1
FSTPI &d
*/
d = 1.0;
}
p1.c
由于浮点数是8Bytes,int型变量只有4Bytes。当在main()
函数中对p1()
进行调用之后,写回结果时就会出现意想不到的结果。我们得到的变量d
和变量x
都不是我们想要的结果。
要避免多重定义全局符号的问题。
尽量避免使用全局变量
一定需要用的话,请按照以下规则使用:
我们前面链接不同文件生成可执行文件时,使用的链接命令就是把多个可重定位目标文件静态链接成一个可执行目标文件的过程。在这个过程中,静态链接的对象是多个可重定位目标文件和标准静态库。静态库文件就是 .a
文件,包含有多个 .o
模块。
为了增加代码的可重用,我们可以将一些函数进行分类并将分类好的函数存放在多个源文件中,编译后将这些 .o
可重定位目标文件进行归档。这样生成的文件就是静态库文件,也叫做归档文件(archive file)。常见的库函数模块有 libc.a
(C标准库) 和 libm.a
(C数学库)。
当我们自定义静态库时,我们应当注意避免下面的两种极端:
在构建可执行文件时,只需指定库文件名,链接器会自动到库中寻找那些应用程序所用到的目标模块,并且只把用到的模块从库中拷贝出来。在GCC命令行中无需明显指定 libc.a
,涉及到文件路径的查找问题。
我们可以用归档器 ar
将 .o
目标文件模块进行归档为 .a
文件。归档器允许程序进行增量更新,如果我们重新编写了某一源程序或者想为静态库增加新的功能,我们只需要编译那一个文件并将其归档即可。
常用的标准库有:
libc.a
:C标准库,包含I/O、存储分配、信号处理、字符串处理、时间和日期、随机数生成、定点整数算术运算实现等的目标文件。约有8MB。libm.a
:C数学库,浮点数算术运算(如sin, cos, tan, log, exp, sqrt, …),约1MB。我们新创建两个模块(module1.c 和 module2.c),将这两个文件编译后归档成我们自己的静态库。
// module1.c
#include <stdio.h>
void print_hello(){
printf("hello\n");
}
// module2.c
#include <stdio.h>
void print_world(){
printf("world\n");
}
编译后我们用归档程序将这两个模块( .o )文件归档成一个归档文件( .a )文件。
ar rcs mylib.a module1.o module2.o
我们可以用下面的命令来查看存档文件中有哪些目标文件:
# 查看xxxx.a中目标文件成员表,-t 表示table listing
ar t xxxx.a | sort
用我们创建的静态库,与一个main.o链接,最后会发生什么?
int main(){
print_hello();
return 0;
}
函数之间的调用关系很清楚,main()
调用print_hello()
,然后print_hello()
调用C库中的printf()
函数。如果链接后查看可执行文件的符号表,你会发现虽然我们链接的是整个 mylib.a ,但是符号表中确没有print_world
。也就是说 module2.o 并没有走到“节的合并”这一步。这里面发生了什么就是我们符号解析要关注的。
在符号解析开始之前,我们先给出三个符号解析需要用到的集合:
当执行下面的命令时,链接器会先对所要链接的模块进行符号解析。从上面的字符说明不难明白,符号解析完成后U应当为空,否则就会链接失败。
$ gcc –o proc main.o mylib.a
具体一点的符号解析过程如下:
main
和未定义的引用符号 print_hello
。随后将 main
加入D中、将 print_hello
加入U中并将main.o加入E中。print_hello
的定义在 module.o 中,随后将符号 print_hello
从U转移到D中并将 module1.o 加入到E中。之后 print_hello
引入了一个未定义的符号 printf
到U里面。printf
的符号定义,最后会从 libc.a 中一个一个查找 printf
的符号定义,过程和上面的相同。(这里我并没有使用静态标准库)由于符号解析是顺序进行的,这就为我们带来了一个问题,那就是链接时的顺序问题。如果我们把静态库文件放在前头会怎么样?
du@DVM:~/Desktop$ gcc -o proc mylib.a main.o
结果出错了,链接器告诉我们在main.o中引用的符号 print_hello
没有找到定义。还记得我们的函数调用顺序么?是这样的:main -> print_hello -> printf
。由于 main
是程序的入口,其他文件在先于 main
链接时由于集合U为空,所以扫描这些文件无一会被加入集合E里。等到扫描main.o时就会出现找不到定义的问题。(因为后面没有能扫描的模块了)
/usr/bin/ld: main.o: in function `main':
main.c:(.text+0xe): undefined reference to `print_hello'
collect2: error: ld returned 1 exit status
除了使用正确的顺序外,我们可以用下面亡羊补牢的命令简单的解决这种问题:
du@DVM:~/Desktop$ gcc -o proc mylib.a main.o mylib.a
事实上,如果两个模块互相引用对方模块中定义的符号,我们就可以用上面这种形式解决符号的互相调用问题。假设调用关系如下:func.o → libx.a 和 liby.a 中的函数 libx.a → liby.a 同时 liby.a → libx.a 则以下命令行可行:
gcc -static –o myfunc func.o libx.a liby.a libx.a
链接由下面的四部分构成:符号解析、同节合并、确定地址 和 修改引用。我们上面已经做完了第一步,也就是符号解析过程,后面三步就属于重定位所关系的。
符号解析完成之后,我们得到了E集合和D集合。之后在重定位的时候我们就会将E集合中所有的.o文件进行同节合并。然后再确定D集合中所有符号的虚拟空间地址,最后将合并的文件中所有引用处的符号替换为符号定义处的地址就完成了重定位。
同节合并看起来很简单,合并之后确定符号定义的地址也不复杂,现在我们可能仍然困惑于引用处是如何替换地址的。在学习节头表时,我们看到重定位信息会被放到 .rela 的重定位节中(x64)。
当汇编器进行汇编时,它一旦遇到符号的引用,就会初始化引用地址并生成一个重定位条目用于链接时的重定位。在IA32中,将对数据引用的重定位条目在 .rel_data 节中,对指令的重定位条目在 .rel_text 节中。
IA-32中有两种最基本的重定位类型:
在x64中重定位类型被分为:
ELF中重定位条目的格式如下:
typedef struct{
int offset;
int symbol:24, type:8;
} ELF32_Rel;
我们用readelf
读重定位条目信息:
readelf -r xxxx.o
这里我们先编写两个模块的程序,最后链接时将两个模块链接为一个可执行文件,这里我们对符号解析的过程不再赘述,我们两个单独的模块如下:
// module1.c
int i = 50; // Symbol 'i' is defined in the module
static s_i = 100; // Local defined symbol
extern int j; // Symbol 'j' and 'k' is defined outside of the module
extern double k;
void test(){
i = j + k; // In case global variables won't be optimized.
}
int main(){
externFunc(); // ref to an extern function
test(); // ref to an internal defined function
return 0;
}
// module2.c
int j = 100;
double k = 300;
externFunc(){
j = 4;
k = 5;
}
编译汇编为可重定位目标文件后,我们先读取符号表:
du@DVM:~/Desktop$ readelf -s module1.o
Symbol table '.symtab' contains 10 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS module1.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 s_i
4: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 i
5: 0000000000000000 47 FUNC GLOBAL DEFAULT 1 test
6: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND j
7: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND k
8: 000000000000002f 35 FUNC GLOBAL DEFAULT 1 main
9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND externFunc
du@DVM:~/Desktop$ readelf -s module2.o
Symbol table '.symtab' contains 7 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS module2.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1 .text
3: 0000000000000000 0 SECTION LOCAL DEFAULT 5 .rodata
4: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 j
5: 0000000000000008 8 OBJECT GLOBAL DEFAULT 3 k
6: 0000000000000000 37 FUNC GLOBAL DEFAULT 1 externFunc
读重定位条目。我们先看module1.o的重定位条目信息:
du@DVM:~/Desktop$ readelf -r module1.o
Relocation section '.rela.text' at offset 0x240 contains 5 entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000000a 000500000002 R_X86_64_PC32 0000000000000000 j - 4
00000000001a 000600000002 R_X86_64_PC32 0000000000000000 k - 4
000000000028 000300000002 R_X86_64_PC32 0000000000000000 i - 4
00000000003d 000800000004 R_X86_64_PLT32 0000000000000000 externFunc - 4
000000000047 000400000004 R_X86_64_PLT32 0000000000000000 test - 4
Relocation section '.rela.eh_frame' at offset 0x2b8 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0
000000000040 000200000002 R_X86_64_PC32 0000000000000000 .text + 2f
下面是module2.o的重定位条目信息:
du@DVM:~/Desktop$ readelf -r module2.o
Relocation section '.rela.text' at offset 0x1d0 contains 3 entries:
Offset Info Type Sym. Value Sym. Name + Addend
00000000000a 000400000002 R_X86_64_PC32 0000000000000000 j - 8
000000000016 000300000002 R_X86_64_PC32 0000000000000000 .rodata - 4
00000000001e 000500000002 R_X86_64_PC32 0000000000000008 k - 4
Relocation section '.rela.eh_frame' at offset 0x218 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0
反汇编后的代码如下:
du@DVM:~/Desktop$ objdump -d module1.o
module1.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <test>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # e <test+0xe>
e: 66 0f ef c9 pxor %xmm1,%xmm1
12: f2 0f 2a c8 cvtsi2sd %eax,%xmm1
16: f2 0f 10 05 00 00 00 movsd 0x0(%rip),%xmm0 # 1e <test+0x1e>
1d: 00
1e: f2 0f 58 c1 addsd %xmm1,%xmm0
22: f2 0f 2c c0 cvttsd2si %xmm0,%eax
26: 89 05 00 00 00 00 mov %eax,0x0(%rip) # 2c <test+0x2c>
2c: 90 nop
2d: 5d pop %rbp
2e: c3 ret
000000000000002f <main>:
2f: f3 0f 1e fa endbr64
33: 55 push %rbp
34: 48 89 e5 mov %rsp,%rbp
37: b8 00 00 00 00 mov $0x0,%eax
3c: e8 00 00 00 00 call 41 <main+0x12>
41: b8 00 00 00 00 mov $0x0,%eax
46: e8 00 00 00 00 call 4b <main+0x1c>
4b: b8 00 00 00 00 mov $0x0,%eax
50: 5d pop %rbp
51: c3 ret
du@DVM:~/Desktop$ objdump -d module2.o
module2.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <externFunc>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: c7 05 00 00 00 00 04 movl $0x4,0x0(%rip) # 12 <externFunc+0x12>
f: 00 00 00
12: f2 0f 10 05 00 00 00 movsd 0x0(%rip),%xmm0 # 1a <externFunc+0x1a>
19: 00
1a: f2 0f 11 05 00 00 00 movsd %xmm0,0x0(%rip) # 22 <externFunc+0x22>
21: 00
22: 90 nop
23: 5d pop %rbp
24: c3 ret
在同节合并后,所有的数据节、代码节都会合并成数据段、代码段。我们在.rela.eh_frame
中看到很多如下的重定位项,其实当同节合并后这些不同的 .text
节的虚存地址也就能够得到确认了。这时只要将节合并后的 .text
节首地址同后面的偏移量相加即可。
Relocation section '.rela.eh_frame' at offset 0x2b8 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0
000000000040 000200000002 R_X86_64_PC32 0000000000000000 .text + 2f
一般PC相对地址的重定位多用于函数符号的重定位上。因为函数都是作为代码段映射到虚拟内存上的。如果引用符号在本模块里定义了,我们就只需要像上面那样计算出偏移量即可知道函数符号的定义在哪里了。
同样,在所有的同节合并后,当我们定义和引用数据符号时,由于它们不在同一个段内,我们往往直接将数据的绝对地址填入数据符号的引用处。在符号引用时,能够直接在数据段读取数据。这时,相对地址的方式就显得繁琐。
在不考虑内核区内存布局的情况下,一般而言用户区的进程内存布局从低地址到高地址分别是代码段、数据段、堆和栈。它们具体的作用如下: