绪论
大家好,欢迎来到【程序员的自我修养】专栏。正如其专栏名,本专栏主要分享学习《程序员的自我修养——链接、装载与库》的知识点以及结合自己的工作经验以及思考。编译原理相关知识本身就比较有难度,我会尽自己最大的努力,争取深入浅出。若你希望与一群志同道合的朋友一起学习,也希望加入到我们的学习群中。文末有加入方式。
简介
从上文【程序员的自我修养02】初识ELF文件格式-CSDN博客中,我们已经初步了解ELF文件布局,其中应该有文件头、代码段、数据段、.bss段、段表等。本文我们从段表开始,进一步了解ELF文件的布局。请耐心看完,我也会分享一些不常用,但是很有趣的小技巧。
.section tables
我们可通过readelf -S example.o命令查看段表信息。如下:
yihua@ubuntu:~/test/example$ readelf -S example.o
There are 13 section headers, starting at offset 0x450:
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 0000000000000059 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000340 0000000000000078 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 0000009c 0000000000000008 0000000000000000 WA 0 0 4
[ 4] .bss NOBITS 0000000000000000 000000a4 0000000000000004 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 000000a4 0000000000000004 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 000000a8 000000000000002a 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000d2 0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 000000d8 0000000000000058 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 000003b8 0000000000000030 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 00000130 0000000000000198 0000000000000018 11 11 8
[11] .strtab STRTAB 0000000000000000 000002c8 0000000000000075 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 000003e8 0000000000000061 0000000000000000 0 0 1
由上分析:
由第一句可知,本段表共有13个段,并在文件中offset 0x450 处开始,这与上一章中文件头内容是匹配上的。(0x450是1104的的16进制)。
我们先看一下表的列项:
- Name :表示段名称。可知ELF文件中不仅仅包含.text、.data、.bss,还有其它内容。
- Type : 表示段的类型。因为链接器并不是通过段名称确定段类型的。比如.text段就一定是代码段吗?常见的段类型有:
PROGBITS:程序段。代码段和数据段都是这种类型。
SYMTAB:符号表内容。也就是我们nm命令查看的内容。
STRTAB:表示该段的内容为字符串表。
RELA:重定位表。该段包含了重定位信息。在下个章节静态链接阶段我们会再讨论。
HASH:符号的哈希表。
DYNAMIC:动态链接信息。
NOBITS:表示该段没有内容。比如.bss段。如上所示,.bss和.rodata段的偏移是一样的。
REL:该段包含重定位信息。在下个章节静态链接阶段我们会再讨论。
DNYSYM:动态链接的符号表。
- Address: 段虚拟地址。即程序加载时,该段要加载到虚拟内存的地址。这是一个很关键的知识点:代码的虚拟地址在编译阶段就已经确定了,并不是运行时由系统决定的。gdb调试时,定位动态库的调试信息,就是依赖于该点。
但是为什么上述为NULL呢?
因为example.o是可重定位文件,虚拟机地址还未确认,只有可执行文件各端的虚拟地址是确认的,如下:
yihua@ubuntu:~/test/example$ readelf -S example
There are 29 section headers, starting at offset 0x19f8:
Section Headers:
[Nr] Name Type Address Offset Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000000238 00000238 000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.ABI-tag NOTE 0000000000000254 00000254 0000000000000020 0000000000000000 A 0 0 4
[ 3] .note.gnu.build-i NOTE 0000000000000274 00000274 0000000000000024 0000000000000000 A 0 0 4
[ 4] .gnu.hash GNU_HASH 0000000000000298 00000298 000000000000001c 0000000000000000 A 5 0 8
[ 5] .dynsym DYNSYM 00000000000002b8 000002b8 00000000000000a8 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 0000000000000360 00000360 0000000000000084 0000000000000000 A 0 0 1
[ 7] .gnu.version VERSYM 00000000000003e4 000003e4 000000000000000e 0000000000000002 A 5 0 2
[ 8] .gnu.version_r VERNEED 00000000000003f8 000003f8 0000000000000020 0000000000000000 A 6 1 8
- Offset: 该段位于文件中的偏移。注:.bss 段的该参数就没有意义,因为它并不存在于文件中。
- Size:该段内容长度。
- EntSize: 项的长度。有些段包含了一些大小固定的项,比如符号表,它包含的每个符号所占的大小都是一样的。对于这种段,EntSize 表示每个项的大小。如果为0,则表示该段不包含固定大小的项。
- Flags:段的标志位。该标志位决定该段在进程虚拟地址的属性。我们一般只要关注:
【A】表示该段在进程空间中必须分配空间。比如代码段,数据段,.bss段一定会有该标识。但是符号表,调试信息则不需要。
【W】表示该段在进程空间中可写。比如.bss 和数据段。而代码段是不可写的。
【X】表示该段在进程空间中可被执行。一般指代码段。
- Link 和 Info:表示段的链接信息。
- Align:表示该段对地址对其的要求。
通过对段表的了解,我们现在对example.o的内容布局更详细了,如下:
其中空白内容,则是因为各段有字节对齐要求,进行了对齐。而其它部分的内容暂不关注,一般就是调试信息等。
综上所述,我们基本已经了解了ELF文件格式的布局,接下来,我们尝试深入了解各个段的内容。
.text
我们可以通过objdump -s -d example.o查看目标文件的代码段。其中-s表示将所有段的内容以十六进制输出,-d表示将所有包含指令的段反汇编。输出如下:
yihua@ubuntu:~/test/example$ objdump -s -d example.o |
如上所示:
绿色字体:用16进制打印了.text、.data、.rodata、.comment、.eh_frame段的信息,其长度与段表中显示的长度是一一对应的。
黄色字体:则是代码段经过反汇编的内容。
正常情况下,我们一般是不需要关注代码的汇编层面的。但是当遇到一些棘手问题时,特别是踩内存导致得到异常情况,我们就有可能需要分析程序的汇编语句了。我曾经遇到的一个案例。大致问题原因如下:
客户的提供的第三方代码出现了踩内存问题,修改了上层栈内容。导致调用客户接口之后,在回到我的接口后,执行打印语句,类似:printf("hello world\n");就会crash。这个问题困扰了客户,和同事很长时间。
我的排查思路,大致如下:
- 通过gdb 查看crash 堆栈信息,发现入参hello world是一个非法地址。
- 通过反汇编,查看相应代码段的入参保存在哪个寄存器,以及这个寄存器由哪里赋值而来。
- 最终再结合gdb watch指令,一点点逆向排查,确定了是客户提供的sdk 操作了 其它函数栈里面的内容(越界)。
注:"有时候"我们通过虚拟机里面的objdump 指令,并不能反汇编目标平台的程序。那是因为代码段是二进制代码,不同平台是不一样的。因而非x86平台的程序,虚拟机是无法解析的。需要使用交叉编译工具链中对应的调试工具。
.data & .rodata
我们知道.data 段保存的是那些已经初始化了的全局变量和局部静态变量。.rodata 段保存的是只读数据,一般是程序里面的只读变量(const 修饰的变量)和字符串常量。但是真是如此吗?我们不妨进一步验证以下。
我们由段表可知:.data段的起始地址是0x9c,且长度 8字节; .rodata 段的起始地址0xa4,且长度是4。我们尝试用二进制文本编辑器查看example.o文件。如下:
结合上篇文章中描述的该可重定位文件是小端字节序。因此这两个值就分别对应0x54和0x55。转为十进制,就正好对应int global_init_var = 84;和static int static_var = 85;。是不是很惊喜~~
.rodata段的内容如下:
可知.rodata起始保存的就是我们printf接口中的字符串常量"%d\n",字符串默认结尾有\0。
tips:
我既然我们已经知道数据在example.o的具体位置,如果我们通过二进制编辑器修改这个值,是否能达到修改最终的输出结果呢?于是尝试如下:
最终结果如我们所想,的确可以通过该方式简单修改程序的逻辑。但是在实际工作场景中,如何应用起来,我还不清楚。暂且当作一个小技巧吧。
.bss
.bss段用于存放为未初始化的全局变量和局部静态变量。如演示代码所示,int global_unint_var;和static int static_var2;两变量应该保存在该段中。根据段表信息
Section Headers:
[Nr] Name Type Address Offset Size EntSize Flags Link Info Align
[ 4] .bss NOBITS 0000000000000000 000000a4 0000000000000004 0000000000000000 WA 0 0 4
[ 5] .rodata PROGBITS 0000000000000000 000000a4 0000000000000004 0000000000000000 A 0 0 1
我有两个疑问:
一、 为什么global_unint_var 和 static_var2两个变量应该占用8个字节,但是.bss 段的Size 只有4Byte?
二、为什么.bss 段和 .rodata段的起始地址都一样?也就是.bss 为什么不存在于文件?
第一个问题,先不做回答,后面.symtab 小节再讨论。对于问题二,我的理解如下:
我们知道,未初始化的全局变量和局部静态变量的值默认初始化为0,那么即没有必要在ELF文件中体现。只需要告诉操作系统,bss 段占用4字节,当程序加载时,自然会在虚拟空间中分配对应内存给bss。这样就达到节省磁盘的目的。(注:没有节省内存),那么这4字节表示哪个变量呢?请继续往下看。
.rel.text
.rel.text 段的类型是REL,也就是说它是一个重定位表。
我们知道链接器在处理目标文件时,需要对目标文件中某些部位进行重定位,即代码段和数据段中哪些绝对地址的引用需要修改。.ral.text 则是对.text段的重定位表;.rel.data 则是对.data段的重定位表。
我们可以通过readelf -r example.o查看文件的重定位表,内容如下:
yihua@ubuntu:~/test/example$ readelf -r example.o |
关于如何理解这些信息,我们再后续静态链接章节描述。大家可以先知道有这个东西,留个印象。
.symtab
我们知道不同文件间对函数、变量之间的引用,其实就是对符号的引用。链接的过程,其实质就是通过符号将不同的目标文件相互“粘”到一起。因此符号对于一个目标文件就显得是什么重要。
我们可以通过readelf -s example.o查看文件的符号表。如下:
yihua@ubuntu:~/test/example$ readelf -s example.o |
- Value:符号相对应的值。这个值的含义与符号相关,不同的符号表示的含义不同。规则如下:
- 在目标文件中,如果是符号的定义,并且不是“COM”类型,表示该符号在段中的偏移。比如static_var2.1802符号,则在.bss段的起始地址。同时也回到了上面提到的问题:.bss段保存的是哪一个变量。
- 在目标文件中,如果符号是“COM”类型,则表示对其的类型。比如:global_unint_var符号,要求4字节对其。
- 在可执行文件中,则表示进程空间的虚拟地址。
- Size:符号大小。
- Type:符号类型。【NOTYPE】表示该符号是未知的类型、【OBJECT】表示该符号是个数据类型、【SECTION】表示该符号是一个段、【FUNC】表示该符号是函数、【FILE】表示该符号是文件名。
- Bind:符号绑定信息。【LOCAL】局部符号,对于目标文件外不可见。【GLOBAL】全局符号,对外部可见。【WEAK】弱引用。可参考C语言中弱符号与弱引用的实际应用_c接口声明位弱符号_谢艺华的博客-CSDN博客。可通过该标识,判断客户提供的SDK,是否对外提供了对应接口。
- Ndx:符号所在的段。如果符号在本目标文件中,那么数字则表示所在的段处于段表中的下标。结合readelf -S 查看。比如3 static_var.1801 则表示static_var.1801符号处于.data段。其也有特殊值:
- ABS:表示该符号包含了一个绝对值。比如文件名的符号就属于该类型。
- COM:一般情况下,为初始化的全局变量就是这个类型。比如:global_unint_var。
- UND:未定义的符号。表示该符号在本目标文件被引用到,但是定义在其它目标文件中。
对于符号我们经常遇到的问题就是链接阶段符号未定义错误。undefined reference to `***。一般去找对应的源码件,查看是否编译或链接库即可。若即编译也链接了,大概率就是符号修饰问题了,可参考该文章深思:C与C++相互调用问题-CSDN博客
其它
还有其它的段比如:dynamic、.symtab、.plt、.got、.init、.fini。我们在本文就不再详细介绍了,在后续的章节再逐一介绍。还请朋友们关注,不迷路哦~~~。
总结
本文在上一篇的基础上,进一步了解了ELF的文件格式,了解其文件布局。
之后再介绍常见的段,代码段,数据段,bss段,符号表等内容,以及工作中可以借鉴的地方。也初步了解到了重定位表的存在及含义,为后面的静态链接做好了铺垫。
有任何相关问题欢迎留言讨论,我会尽快回复。
若您正遇到相关问题,苦于没有一群志同道合的朋友交流,探讨。也欢迎加入我们的讨论组群。