文章目录
- 详解`ELF`文件-> `main.o`
- 前十六个字节的含义
- 推测`elf`的大小
- 查看节头部表
- 推断每个`section`在`elf`中的具体位置
- 查看`.text`的内容
- 查看`.data`的内容
- 关于`.bss`
- 查看`.rodata`的内容
- 关于其他的节表示的信息
- 详解符号表
- 符号
- 编译器如何解析多重定义的全局符号
- 静态库与静态链接
- 构造和使用一个静态库
- 静态库的解析过程
- 重定位
- 重定位节(`section`)和重定位符号定义
- 重定位节中的符号引用
- 可执行目标文件
- 加载可执行目标文件
- 加载运行过程细节
- 共享库
- 构造一个共享库
- 使用共享库
- 解释共享库工作原理
详解ELF
文件-> main.o
-
elf
文件可以分成三部分ELF
头——ELF header
- 不同的
section
——Sections
- 以及描述这些
section
信息的表——Section header table
假设有如下main.c
文件
#include<stdio.h>
int count = 10;
int value;
void func(int sum)
{
printf("sum is:%d\n", sum);
}
int main()
{
static int a = 1;
static int b = 0;
int x = 1;
func(a + b + x);
return 0;
}
-
ELF头
-
gcc -c
编译后产生main.o
文件然后使用readelf -h main.o
查看ELF
头
-
前十六个字节的含义
-
7f 45 4c 46
前四个字节被称为ELF
文件的魔数- 分别与
ascll
码中的DEL
控制符、字符E
、字符L
、字符F
对应 - 魔数就是用来确认文件类型的
- 分别与
-
02 01 01
-
最后
9
个字节ELF
的标准中没有定义,用0
填充
推测elf
的大小
-
本头的大小可以推出
sections
的起始地址,这里就是0x40
-
推测整个
elf
文件的大小, 利用本头的大小和下图的三个信息-
start of section headers:
可以确定节头部表的起始位置 -
节头大小和节头数量可以确定节头表的大小
-
查看节头部表
readelf -S main.o
:查看节头部表的信息
推断每个section
在elf
中的具体位置
-
整个
elf
一共包含12
个section
-
offset
代表每个section
的起始位置 -
size
每个section
的大小-
例如
.text
这一section
他的offset
是0x40
,大小为0x54
,由于elf
头的大小为0x40
,所以.text
是紧跟在elf
的后面的
-
查看.text
的内容
已知.text
是存放已编译程序的机器代码
objdump -s -d main.o
-s
:查看机器指令-d
:查看汇编代码
- 前两个字节是指令地址
- 后面的指令是机器指令
- 每个指令对应的汇编代码可以在
-d
中查看
查看.data
的内容
已知.data
是存放已初始化的全局和静态C
变量,而初始化为0的全局和静态变量被存在.bss
中
objdump -s -d main.o
- 由于
.text
的起始位置是0x40
,大小为0x56
,故可以推断出下一个section
的起始位置是0x94
,查表可知.data
的offset
是0x94
,刚好对应的上
- 小端存储
- 初始化为
0
的b
不会出现在此处
关于.bss
已知其是存放未初始化的静态C
变量(未初始化的全局变量放在COMMON
中)和初始化为0的全局和静态变量
-
观察节头部表可以发现,
.bss
和.rodata
的起始位置一样但大小不一样- 这是因为
bss
不占据实际的空间,他就是一个占位符,程序区分已初始化和未初始化的变量是为了节省空间,当程序运行是,会在内存中分配这些变量,并把初始值设为0
- 这是因为
查看.rodata
的内容
ro
就是read only
,代表只读,此区域就是存放只读数据的,例如printf
语句中的格式串,或者是switch
的跳转表
0x73
对应115(d)
对应s的ascll
码
关于其他的节表示的信息
详解符号表
typedef struct{
int name; // 符号的名称 通过在字符串表中的字节偏移中得到
char type:4, // 该符号的类别 函数还是变量
binding:4; // 全局还是本地
char reserved; // 没有被用到
short section; // 符号所属目标文件的节
long value; // 符号的地址相对于所属节起始位置的偏移
long size; // 符号的大小
} Elf64_Symbol;
- 符号是链接的粘合剂,整个链接过程是基于符号才能正确完成
readelf -s main.o
Ndx
对应section
,代表符号所属目标文件的节Ndx
的取值还有三个,他们分别是ABS、UNDEF、COMMON
,他们被统称为伪节,他们在节头部表中没有条目。只有可重定位目标文件才有这些伪节,可执行目标文件中没有ABS
:不该被重定位的符号UNDEF
:未定义的符号,但是在其他文件有定义COMMON
:还未被分配位置的未初始化的数据目标,也就是未初始化的全局变量会存放于此
Value
代表符号的地址相对于所属节起始位置的偏移- 例如
func
符号,属于.text
节,value
为0x00
,大小为36
。
- 例如
符号
-
全局符号
-
外部符号
- 本文件引用外部文件的变量
-
局部符号
static
是局部符号
编译器如何解析多重定义的全局符号
强弱符号的概念
- 函数和已初始化的全局变量是强符号
- 未初始化的全局变量是弱符号
处理多重定义的符号名
- 规则一:不允许有多个同名的强符号
- 规则二:如果有一个强符号与多个若符号同名,那么选择强符号
- 规则三:如果有多个弱符号同名,那么从这些弱符号中任意选择一个
对于这样一种情况:如果重复定义的符号是不同类型时,往往会破坏其他符号的内存
// foo1.cpp
#include<stdio.h>
void f();
int x = 15212;
int y = 15213;
int main()
{
f();
printf("x = 0x%x y = 0x%x \n", x, y);
return 0;
}
// foo2.cpp
double x;
void f(){
x = -0.0;
}
- 在
foo1
中x
和y
的地址是连续的,被定义被int
,占4
个字节,但是在bar
中,x
是double
类型,占8
个字节,在bar
中对x
赋值会影响y
的值
静态库与静态链接
静态库:可以将多个相关的目标模块打包成一个单独的文件,称为静态库
-
通过静态库,相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件
-
静态库可以用作链接器的输入。链接器在构造可执行文件时,从静态库中复制被应用程序引用的目标模块,其他未用到的模块则不会复制
-
静态库的实例:
-
// main2.c #include <stdio.h> #include "vector.h" int x[2] = {1, 2}; int y[2] = {3, 4}; int z[2]; int main() { addvec(x, y, z, 2); printf("z = [%d %d]\n", z[0], z[1]); return 0; } // vector.h 其中的函数定义都在打包后的静态库中 void addvec(int *x, int *y, int *z, int n); void multvec(int *x, int *y, int *z, int n); int getcount();
构造和使用一个静态库
-
> gcc -c addvec.c multvec.c # 将想要打包的函数定义变成可重定位目标文件 > ar rcs libvector.a addvec.o mutvec.o # 打包成静态库 > gcc -c main2.c > gcc -static -o prog main2.o ./libvector.a # 与静态库链接(使用静态库)
-
总结:
- 静态库就是各种可重定位文件的集合
- 静态链接链接一个静态库的时候会按需链接
静态库的解析过程
-
当在命令行输入以下命令来让程序使用静态库时
gcc -static -o prog2c main2.o ./libvector.a
链接器从左到右按照他们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件,这会导致一些问题,最后来说。
-
在链接器的扫描过程中,链接器维护三个集合
- 可重定位目标文件的集合
E
(这个集合中的文件会被合并起来形成可执行文件) - 未解析的符号集合
U
(就是引用了但是没有定义,链接器会把他当做在其他文件定义) - 在前面的输入文件以及定义的符号集合
D
- 可重定位目标文件的集合
-
模拟解析过程
-
第一个扫描的文件是
main2.o
,观察源程序可知,main.o
会被放进集合E
中,U
中会增加main2.o
中调用的addvec
和printf
,D
中会增加已定义的函数main
和变量x、y、z
-
第二个扫描的文件是
libvector.a
,根据其后缀,可知其为一个静态库,此时链接器就会尝试在U
集合中寻找是否有与静态库同名的变量或函数,由于U
中有addvec
,故匹配,删除U
集合中的addvec
,将addvec.o
放入E
集合中,然后将addvec.o
中定义的符号放进符号D
中 -
libvector.a
中包含的所有目标文件要执行上述操作 -
任何不包含在集合
E
中的成员目标文件都被简单的丢弃
-
-
未定义的原因
- 如果在最后一个目标文件读取完成之后,
U
集合不为空,则会产生未定义的情况。
- 如果在最后一个目标文件读取完成之后,
-
该算法的缺陷
- 由于链接器是按照顺序从前往后的,故如果此指令
gcc -static -o prog2c main2.o ./libvector.a
中main
与静态库调换顺序,当扫描静态库时U
集合并没有元素,故main扫描后U
中符号无法消除就会产生未定义的情况
- 由于链接器是按照顺序从前往后的,故如果此指令
-
互相依赖的命令
-
当
foo.c
调用libx.a
,libx.a
调用liby.a
,然后liby.a
又调用libx.a
:gcc -static foo.c libx.a liby.a libx.a
-
重定位
重定位节(section
)和重定位符号定义
- 对于
main.o
和sum.o
,他们相同类型的section
会被合并为一个新的section
- 观察
main.o
和sum.o
的符号表,他们的.text section
都是从0开始的 - 在
64
位linux
系统中,ELF
可执行文件默认从地址0x400000
处开始分配,人们
重定位节中的符号引用
-
本步骤是确定那些调用外部函数的目的地址(就是指令编码后面的的字节,汇编器把他们都填充成0,由链接器来赋值)
-
链接器要依赖可重定位条目的数据结构来决定目的地址的值
-
当编译器遇到最终位置不确定的符号引用时,他就产生一个重定位条目
typedef struct{ long offset; //需要被修改的引用的节偏移(即该符号引用距离所在节的初始位置的偏移)。 long type:32, //重定位类型,不同的重定位类型会用不同的方式来修改引用 symbol:32; //指向的符号,比如sum long addend; //一个有符号常数,一些类型的重定位要使用它对被修改引用的值做偏移调整 pc相对寻址中默认是-4 绝对寻址默认是 0 }
关于
type
字段,只学习两种即可,-
R_X86_64_PC32
:PC
相对地址 -
R_X86_64_32
:绝对地址
-
-
给出重定位之后的汇编代码,解释
当执行到
call
指令时,PC
为0x4004e3
,目标地址就是PC
地址加0x00000005
,这个5
就是链接器通过重定位条目所计算出来的如何计算的?
// 对于这样两个函数 // main.c int sum(int *a, int n); int array[2] = {1, 2}; int main(){ int val = sum(array, 2); return val; } // sum.c int sum(int * a, int n){ int i, s = 0; for(i = 0; i < n; i++){ s += a[i]; } return s; } // 已经确定重定位后的.text节和sum函数的绝对地址分别为 0x4004d0 和 0x4004e8 ` // 重定位之前的汇编代码(在合成之前main.o或者sum.o的.text起始地址都是0) 000000000000<main>: 0: 48 83 ec 08 sub $0x8, %rsp 4: be 02 00 00 00 mov $0x2, %esi 9: bf 00 00 00 00 moV $0×0, %edi e: e8 00 00 00 00 callq 13 <main+13> 13: 48 83 c4 08 add $0x8, %rsp 17: c3 retq // call后的目标位置值为f,表示与所在节的初始位置的偏移值为f,所以计算 目标位置的值为 sum - addend - (main + offset) 0x4004e8 - 4 - (0x4004d0 + f) = 0x5 // 所以 e: e8 00 00 00 00 callq 13 <main+13> // 变为 e: e8 05 00 00 00 callq 4004e8 <sum>
可执行目标文件
可执行目标文件是一个二进制文件
-
可执行目标文件的格式与可重定位目标文件的格式类似
ELF
头部描述文件的总体格式,还包括程序的入口点也就是当程序运行时要执行的第一条地址.init
节定义了一个小函数,叫做_init
,程序的初始化代码会调用它
加载可执行目标文件
-
任何
Linux
程序都可以通过调用execve
函数来调用加载器。加载器将可执行文件的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口来运行该程序。这个将程序复制到内存并运行的过程叫做加载 -
每个
Linux
程序都有一个运行时内存映像,内存映像如图所示- 在
Linux x86-64
系统中,代码段总是从地址0x400000
处开始后面是数据段, - 运行时堆在数据段之后,通过调用
malloc
库往上增长 - 堆后的区域是为共享模块保留的
- 用户栈总是从最大的合法用户地址(248 - 1)开始,向小内存地址增长。
- 内核就是操作系统驻留在内存的部分
- 注意:
- 为了简洁,将代码段与数据段挨在了一起,事实上,
.data
段是有对齐要求的。所以代码段和数据段之间是有间隙的。 - 同时,在分配栈、共享库和堆段运行时地址时,链接器还会使用地址空间布局随机化。但是他们的相对位置不会变
- 为了简洁,将代码段与数据段挨在了一起,事实上,
- 在
加载运行过程细节
-
当加载器运行时,在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段。
-
然后,加载器跳转到程序的入口点(
_start
函数的地址),这个函数是在系统目标文件ctrl.o
中定义的 -
start函数调用系统启动函数
_ _libc_start_main
,该函数定义在libc.so
中。 -
上一函数初始化执行环境,调用用户层的main函数,再由
_ _libc_start_main
处理main
函数的返回值,并且它在需要的时候返回给内核
共享库
是一种特殊的可重定位目标~文件
构造一个共享库
> gcc -shared -fpic -o libvector.so addvec.c mulvec.c
-fpic
:告诉编译器生成与位置无关的代码
使用共享库
> gcc -o prog main.c ./libvector.so
解释共享库工作原理
- 当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。
- 没有任何
libvector.so
的代码和数据节真的被复制到可执行文件prog
中,-> 链接器复制了一些重定位和符号表信息,他们使得运行时可以解析对libvector.so
中代码和数据的引用 - 当可执行程序
prog
被加载运行时,加载器会发现prog
中存在一个名为.interp
的section
,这个section
包含了动态链接器的路径名,这个动态链接器本身也是一个共享目标文件 - 接下来,加载器会将这个动态链接器加载到内存中运行,然后由动态链接器执行重定位代码和数据的工作
- 重定位
libc.so
的文本和数据到某个内存段 - 重定位
libvecor.so
的文本和数据到另一个内存段 - 重定位
prog
中所有由libc.so
和libvector.so
定义的符号的引用
- 重定位
- 之后共享库的位置就固定了,并且在程序执行的过程中都不会改变
文章参考与<零声教育>的C/C++linux服务期高级架构系统教程学习