👴🏻又开新坑🌶
预处理 -> 编译 -> 汇编 -> 链接
c#include <stdio.h>
#define FORMAT_STRING "%s"
#define MESSAGE "Hello,World!\n"
int main(void)
{
printf(FORMAT_STRING, MESSAGE);
return 0;
}
使GCC在预处理阶段之后停下 需要使用-E
选项 -P
选项忽略调试信息
bashgcc -E -P 1.c
ctypedef long unsigned int size_t;
typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
...
...
int main(void)
{
printf("%s", "Hello,World!\n");
return 0;
}
可以看到gcc会输出预处理之后的源代码 可以发现此时gcc会将我们源代码中的stdio.h
头文件全部包含进源代码中 并且对我们在源码中的define
也会进行处理 直接替换进源码中
编译阶段GCC会将预处理之后的源代码转换成汇编代码 大多数编译器都可以在此阶段选择进行优化
为什么编译器不直接生成机器代码而是先转换一步汇编代码?
从单一语言出发这一步是没有必要的 但是从整体编程语言出发 如果编译器直接生成机器代码 那么每个编译器都需要实现一套机器代码生成器 这样会造成大量的重复工作 但是如果编译器先生成汇编代码 那么每个编译器只需要实现一套汇编代码生成器就可以了
这里可以使用-S
要求GCC在编译阶段后停下
bashgcc -S -masm=intel 1.c
汇编阶段的输入是在编译阶段生成的汇编语言集 输出是一组对象文件
对象文件原则上包含可以由处理器执行的机器指令
可以使用-c
参数让GCC输出对象文件
bashgcc -c 1.c
file
命令显示了改对象文件符合二进制可执行文件的ELF规范 并且是LSB(最低有效位) 即内存排序从小到大生长 并且是relocatable[可重定位的]
这里的可重定位的概念和操作系统中的可重定位一致 即装载程序时不依赖于具体的内存地址 这样做方便对象文件相互独立编译并且可以按照任意顺序将对象文件链接到一起从而形成完整的二进制程序
编译过程的最后阶段 链接会将前面编译出来的所有对象文件链接到一个二进制可执行文件中
链接阶段通过链接器以实现目的 链接器与编译器通常是相互独立工作的[原理之前讲过] 编译器负责执行以上所有步骤 链接器负责执行链接
之前的所有对象文件因为是独立编译的 所以就编译器自身是没有办法确定对象文件在最后执行的时候在整体代码段的具体地址上[无法确定基址] 并且对象文件也会引用其他对象文件中或者库文件中的数据[函数或者变量] 所以对象文件中包含的所有引用都只包含重定位符号
这些重定位符号
决定最终如何解析函数和变量引用
在链接上下文忠依赖于重定位符号的引用称为符号引用
同时因为基址是不确定的 所以即使在对象内部使用绝对地址引用自己的函数或者变量该地址也会被符号化
链接库的执行流程就是获取该程序的所有对象文件 然后解析引用将其合并为一个连贯的可执行文件 架子啊到特定的内存地址 其中在解析库文件的时候会根据库文件的类型决定对其是否完全解析
静态库会完全合并进二进制可执行文件中 允许完全解析所有对静态库的引用
这种方法也就是我们通常说的静态编译
动态库也叫共享库 所有在操作系统上运行的程序共享同一个库文件 对于动态库的引用在最后实际执行加载到内存的时候才会解析对动态库的引用
bashgcc 1.c
可以看到编译出来的文件是ELF规范的64位LSB可执行文件 并且是动态编译的 也就是动态库还没有装入程序
符号其实就是当初编程者自己命名的函数和变量名称 在编译时 编译器会翻衣服好 根据符号跟踪其名称 比如高级函数名称到第一个地址到每个函数大小的映射 链接器会在链接时使用到这些信息 并且符号也可以帮助我们调试
bashreadelf --syms a.out
bashSymbol table '.dynsym' contains 4 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5 (2)
2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2)
3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
Symbol table '.symtab' contains 67 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000400238 0 SECTION LOCAL DEFAULT 1
2: 0000000000400254 0 SECTION LOCAL DEFAULT 2
...
54: 0000000000601028 0 NOTYPE GLOBAL DEFAULT 25 __data_start
55: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
56: 0000000000601030 0 OBJECT GLOBAL HIDDEN 25 __dso_handle
57: 00000000004005c0 4 OBJECT GLOBAL DEFAULT 16 _IO_stdin_used
58: 0000000000400540 101 FUNC GLOBAL DEFAULT 14 __libc_csu_init
59: 0000000000601040 0 NOTYPE GLOBAL DEFAULT 26 _end
60: 0000000000400430 42 FUNC GLOBAL DEFAULT 14 _start
61: 0000000000601038 0 NOTYPE GLOBAL DEFAULT 26 __bss_start
62: 0000000000400526 21 FUNC GLOBAL DEFAULT 14 main
63: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses
64: 0000000000601038 0 OBJECT GLOBAL HIDDEN 25 __TMC_END__
65: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable
66: 00000000004003c8 0 FUNC GLOBAL DEFAULT 11 _init
其中我们可以看到二进制文件加载到内存中指定main
函数的指定加载地址为0x400526
很显然 之前我们编译出来的a.out
是没有经过任何处理的 所以他包含着符号 同时file
的输出也佐证了这一点(no stripped)
可以使用strip
来剥离符号
bashstrip --strip-all a.out
这里仅剩的一些符号都是动态库上的符号了 这些符号是不能被剥除的 毕竟只有等装载的时候才能确定其地址 现在剥除了之后装载的时候就找不到函数了
.rodata段内存储着所有的只读数据 二进制文件中的所有常量都存储在该段中
bashobjdump -sj .rodata 1.o
objdump可以使用-M
来指定汇编风格
bashobjdump -M intel -d 1.o
这里只有一个main
函数的汇编是因为这是源文件中唯一定义的函数 其中奇怪的是我们可以发现在9
的位置这里调用了一个main函数内部的位置
且其参数为0
通过对比源文件很显然此处应该是pust
才对
这里其实是因为我们反汇编的是对象文件 还没有进行链接 对于对象文件中的引用还没有开始解析 对象文件需要链接器对此处的引用来填充正确的值
bashreadelf --relocs 1.o
第一行是告诉链接器解析对字符串的引用 指向.rodata段
第二行是告诉链接器解析对puts的引用
这里有个规律是readelf
指出的偏移值是需要固定的指令的偏移量+1 因为我们通过前面反汇编的结果可以发现 这里我们的操作码都是指定好的 例如mov
和call
真正需要覆盖的只是操作数而已 那么跳过操作码的一个字节就是偏移量+1了
这里其实没什么讲的 跟上述操作一样 只不过可以发现剥离符号之后的二进制程序杂乱许多(废话)
当运行一个二进制文件的时候 操作系统会为该程序创建进程 然后分配资源 其中包括虚拟地址空间
然后操作系统会将解释器映射到虚拟内存中
解释器: 一个用户态程序 其知道如何加载程序并执行必要的重定位
在Linux中其通常为一个名为ld-linux.so的共享库
在Windows中其功能通常为一个叫ntdll.dll的动态库实现
在加载解释器之后内核将控制权交给解释器 随后解释器开始其在用户态的工作
其中在Linux中ELF二进制文件包含一个.interp
的特殊段 该段内存储着解释器的路径
bashreadelf -p .interp a.out
解释器将二进制文件加载进虚拟地址空间 然后解析器会找到二进制程序使用的动态库 并且也将动态库映射到虚拟地址空间 最后在二进制代码中执行所有必要的重定位 填充正确的地址以引用动态库
实际上因为Linux存在延迟绑定机制 也就是通常上会将动态库中对函数的引用的解释过程推迟 直到调用函数的时候才解析
本文作者:Du4t
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!