程序员的自我修养-第三章
.
linux 环境下,可以使用 file 命令查看 elf 文件类型
目标文件
一个简单的目标文件
文件头描述了文件的文件熟悉:是否可执行,目标硬件,段表等。段表描述了文件中各个段在文件中的偏移位置及段的属性等。
.text 保存编译后的机器代码
.data 保存已初始化的全局变量和局部静态变量
.bss 保存未初始化的全局变量和局部静态变量(局部变量会保存到栈中),.bss段只是为未初始化的全局变量和局部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空间。
之后使用如下文件进行分析
使用 objdump 工具可以查看 object 文件的结构。(linux 下还有个工具 readelf 可以解析 elf 文件格式)
1 | objdump -h SimpleSection.o |
与书籍内容类似:
他的 ELF 结构:
代码段
objdump 的 -s 参数可以把所有段的内容以十六进制打印出来
objdump 的 -d 参数可以把所有包含指令的段反汇编
objdump -s -d SimpleSection.o
对比反汇编的开始和结束f3, c3
,与十六进制码是对应的
数据段和只读数据段
.data段保存的是那些已经初始化了的全局静态变量和局部静态变量。前面的SimpleSection.c代码里面一共有两个这样的变量,分别是global_init_varabal 与 static_var 。这两个变量每个4个字节,一共刚好8个字节,所以“.data”这个段的大小为8个字节。
“.rodata”段存放的是只读数据,一般是程序里面的只读变量(如const修饰的变量)和字符串常量。单独设立“.rodata”段有很多好处,不光是在语义上支持了C++的const关键字,而且操作系统在加载的时候可以将“.rodata”段的属性映射成只读,这样对于这个段的任何修改操作都会作为非法操作处理,保证了程序的安全性。另外在某些嵌入式平台下,有些存储区域是采用只读存储器的,如ROM,这样将“.rodata”段放在该存储区域中就可以保证程序访问存储器的正确性。
BSS 段
bss段存放的是未初始化的全局变量和局部静态变量,有些编译器会将全局的未初始化变量存放在目标文件.bss段,有些则不存放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再在.bss段分配空间。
其他段
可以自定义段,但要和系统保留段名的区分开,也可以将一个二进制文件作为目标文件的一个段:
自定义段
ELF 文件结构
文件头
可以使用 readelf
来详细查看 elf 文件
书籍上内容如下
1 | $readelf –h SimpleSection.o |
从上面输出的结果可以看到,ELF的文件头中定义了ELF魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。
ELF文件头结构及相关常数被定义在“/usr/include/elf.h”里,这里以32位版本的文件头结构“ Elf32_Ehdr ”作为例子:
1 | typedef struct { |
可以发现,“Elf32_Ehdr”中的e_ident这个成员对应了readelf输出结果中的“Class”、“Data”、“Version”、“OS/ABI”和“ABI Version”这5个参数。剩下的参数与“Elf32_Ehdr”中的成员都一一对应。
魔数
文件类型
文件类型 e_type 成员表示ELF文件类型,即前面提到过的3种ELF文件类型,每个文件类型对应一个常量。系统通过这个常量来判断ELF的真正文件类型,而不是通过文件的扩展名。
段表
它描述了ELF的各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性。也就是说,ELF文件的段结构就是由段表决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。
“objdump -h”命令只是把ELF文件中关键的段显示了出来,而省略了其他的辅助性的段,我们可以使用readelf工具来查看ELF文件的段.
段表的结构比较简单,它是一个以“ Elf32_Shdr ”结构体为元素的数组。数组元素的个数等于段的个数,每个“ Elf32_Shdr”结构体对应一个段。“ Elf32_Shdr ”又被称为段描述符(SectionDescriptor)。对于SimpleSection.o来说,段表就是有11个元素的数组。ELF段表的这个数组的第一个元素是无效的段描述符,它的类型为“NULL”,除此之外每个段描述符都对应一个段。也就是说SimpleSection.o共有10个有效的段。
Elf32_Shdr 被定义在“/usr/include/elf.h”, 如下
1 | //Elf32_Shdr段描述符结构 |
详细解释如下:
所有段的位置和长度
段的类型
段的名字只是在链接和编译过程中有意义,但它不能真正地表示段的类型。我们也可以将一个数据段命名为“.text”,对于编译器和链接器来说,主要决定段的属性的是段的类型(sh_type )和段的标志位( sh_flags )。
段的标志位
段的标志位( sh_flag ) 段的标志位表示该段在进程虚拟地址空间中的属性,比如是否可写,是否可执行等。相关常量以SHF_开头,
系统保留段的 type 及 flag 属性
段的链接信息( sh_link 、 sh_info )
如果段的类型是与链接相关的(不论是动态链接或静态链接),比如重定位表、符号表等,那么 sh_link 和sh_info 这两个成员所包含的意义如下所示
总结一下 read -S xxx.o显示
重定位表
链接器在处理目标文件时,须要对目标文件中某些部位进行重定位,这些重定位的信息都记录在ELF文件的重定位表里面,对于每个须要重定位的代码段或数据段,都会有一个相应的重定位表。比如SimpleSection.o中的“.rel.text”就是针对“.text”段的重定位表,因为“.text”段中至少有一个绝对地址的引用,那就是对“printf”函数的调用;而“.data”段则没有对绝对地址的引用,它只包含了几个常量,所以SimpleSection.o中没有针对“.data”段的重定位表“.rel.data”。
一个重定位表同时也是ELF的一个段,那么这个段的类型( sh_type )就是“ SHT_REL ”类型的,它的“ sh_link ”表示符号表的下标,它的“ sh_info”表示它作用于哪个段。比如“.rel.text”作用于“.text”段,而“.text”段的下标为“1”,那么“.rel.text”的“ sh_info ”为“1”。
字符串表
一种很常见的做法是把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串
一般字符串表在ELF文件中也以段的形式保存,常见的段名为“.strtab”或“.shstrtab”。这两个字符串表分别为字符串表(String Table)和段表字符串表(Section Header String Table)。顾名思义,字符串表用来保存普通的字符串,比如符号的名字;段表字符串表用来保存段表中用到的字符串,最常见的就是段名( sh_name )
接着我们再回头看这个ELF文件头中的“ e_shstrndx ”的含义,我们在前面提到过,“ e_shstrndx ”是 Elf32_Ehdr 的最后一个成员,它是“Sectionheader string table index”的缩写。我们知道段表字符串表本身也是ELF文件中的一个普通的段,知道它的名字往往叫做“.shstrtab”。那么这个“e_shstrndx ”就表示“.shstrtab”在段表中的下标,即段表字符串表在段表中的下标。前面的SimpleSection.o中,“ e_shstrndx ”的值为8,我们再对照“readelf -S ”的输出结果,可以看到“.shstrtab”这个段刚好位于段表中的下标为8的位置上。由此,我们可以得出结论,只有分析ELF文件头,就可以得到段表和段表字符串表的位置,从而解析整个ELF文件。
链接的接口——符号
在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。
每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里面记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值,叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。除了函数和变量之外,还存在其他几种不常用到的符号。我们将符号表中所有的符号进行分类,它们有可能是下面这些类型中的一种
第一种和第二种是最重要的。
我们可以使用很多工具来查看ELF文件的符号表,比如 readelf、objdump、nm
等
1 | ttj@ttj:~/copy$ nm SimpleSection.o |
ELF 符号表结构
符号表段名一般叫“.symtab”,它是一个Elf32_Sym结构(32位ELF文件)的数组
1 | typedef struct { |
符号类型和绑定信息( st_info ) 该成员低4位表示符号的类型( Symbol Type ),高28位表示符号绑定信息( Symbol Binding )
符号所在段( st_shndx )如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标;但是如果符号不是定义在本目标文件中,或者对于有些特殊符号:
符号值( st_value )每个符号都有一个对应的值,如果这个符号是一个函数或变量的定义,那么符号的值就是这个函数或变量的地址,具体而言:
- 在目标文件中,如果是符号的定义并且该符号不是“COMMON块”类型的,则st_value表示该符号在段中的偏移。即符号所对应的函数或变量位于由st_shndx指定的段,偏移st_value的位置。最常见
- 在目标文件中,如果符号是“COMMON块”类型的,则st_value表示该符号的对齐属性。如
global_uninit_var
- 在可执行文件中,st_value表示符号的虚拟地址。
可以使用 readelf –s SimpleSection.o
来查看符号表,注意 s 是小写,
readelf的输出格式与上面描述的 Elf32_Sym 的各个成员几乎一一对应,第一列Num表示符号表数组的下标,从0开始,共15个符号;第二列Value就是符号值,即 st_value ;第三列Size为符号大小,即 st_size ;第四列和第五列分别为符号类型和绑定信息,即对应 st_info 的低4位和高28位;第六列Vis目前在C/C++语言中未使用,我们可以暂时忽略它;第七列Ndx即 st_shndx ,表示该符号所属的段;当然最后一列也最明显,即符号名称。从上面的输出可以看到,第一个符号,即下标为0的符号,永远是一个未定义的符号。对于另外几个符号解释如下。
func1 和 main 函数都是定义在SimpleSection.c里面的,它们所在的位置都为代码段,所以Ndx为1,即SimpleSection.o里面,.text段的下标为1。这一点可以通过readelf –a或objdump –x得到验证。它们是函数,所以类
型是 STT_FUNC ;它们是全局可见的,所以是 STB_GLOBAL ;Size表示函数指令所占的字节数;Value表示函数相对于代码段起始位置的偏移量。再来看printf这个符号,该符号在SimpleSection.c里面被引用,但是没有被定义。所以它的Ndx是SHN_UNDEF。
global_init_var 是已初始化的全局变量,它被定义在.bss段,即下标为3。global_uninit_var 是未初始化的全局变量,它是一个SHN_COMMON 类型的符号,它本身并没有存在于BSS段;关于未初始化的全局变量具体请参见“COMMON块”。
static_var.1533和static_var2.1534是两个静态变量,它们的绑定属性是STB_LOCAL,即只是编译单元内部可见。至于为什么它们的变量名从“static_var”和“static_var2”变成了现在这两个“static_var.1533”和“static_var2.1534”,我们在下面一节“符号修饰”中将会详细介绍。
对于那些STT_SECTION类型的符号,它们表示下标为Ndx的段的段名。它们的符号名没有显示,其实它们的符号名即它们的段名。比如2号符号的Ndx为1,那么它即表示.text段的段名,该符号的符号名应该就是“.text”。如果我们使用“objdump –t”就可以清楚地看到这些段名符号。
“SimpleSection.c”这个符号表示编译单元的源文件名。
特殊符号
当我们使用ld作为链接器来链接生产可执行文件时,它会为我们定义很多
特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接声明并
且引用它,我们称之为特殊符号,几个很具有代表性的特殊符号如下。
- executable_start ,该符号为程序起始地址,注意,不是入口地址,是程序的最开始的地址。
- etext 或 etext 或 etext ,该符号为代码段结束地址,即代码段最末尾的地址。
- edata 或 edata ,该符号为数据段结束地址,即数据段最末尾的地址。
- _end 或 end ,该符号为程序结束地址
符号修饰与函数签名
为了防止符号名与多种语言库中的符号名冲突,C语言源代码文件中的所有全局的变量和函数经过编译以后,相对应的符号名前加上下划线“_”。而Fortran语言的源代码经过编译以后,所有的符号名前加上“”,后面也加上“_”。
GCC编译器也可以通过参数选项“ -fleading-underscore ”或 “-fno-leading-underscore ”来打开和关闭是否在C语言符号前加上下划线, linux 下 gcc 默认不添加。
c++中的符号修饰
c++包含 类,继承,虚机制,重载,名称空间等诸多特性。符号管理异常复杂。为了支持C++这些复杂的特性,人们发明了符号修饰(Name Decoration)或符号改编(NameMangling)的机制,下面我们来看看C++的符号修饰机制。
编译器在将C++源代码编译成目标文件时,会将函数和变量的名字进行修饰,形成符号名,也就是说,C++的源代码编译后的目标文件中所使用的符号名是相应的函数和变量的修饰后名称。所以对于不同函数签名的函数,即使函数名相同,编译器和链接器都认为它们是不同的函数
不同的编译器厂商的名称修饰方法可能不同,所以不同的编译器对于同一个函数签名可能对应不同的修饰后名称
Visual C++的名称修饰规则并没有对外公开,当然,一般情况下我们也无须了解这套规则,但是有时候可能须要将一个修饰后名字转换成函数签名,比如在链接、调试程序的时候可能会用到。Microsoft提供了一个UnDecorateSymbolName() 的API,可以将修饰后名称转换成函数签名。
由于不同的编译器采用不同的名字修饰方法,必然会导致由不同编译器编译产生的目标文件无法正常相互链接,这是导致不同编译器之间不能互操作的主要原因之一。
extern “C”
C++为了与C兼容,在符号的管理上,C++有一个用来声明或定义一个C的符号的“extern “C””关键字用法:
1 | extern ”C” { |
在linux g++ 下,函数,变量修饰后符号与修饰前相同。
一个问题,一个.h 文件声明了一些 c 语言函数或变量,这个头文件被 c 代码 或者 c++ 代码所引用。如void *memset (void *, int, size_t);
如果不加处理,当我们的C语言程序包含string.h的时候,并且用到了memset这个函数,编译器会将memset符号引用正确处理;但是在C++语言中,编译器会认为这个memset函数是一个C++函数,将memset的符号修饰成 _Z6memsetPvii ,这样链接器就无法与C语言库中的memset符号进行链接。对于 c++ 来说, 需要使用 extern C来声明 memset 函数,但 c 语言又不支持 ertern C,我们又不想声明两套头文件。可以使用 c++ 宏 __cplusplus 来解决。C++编译器会在编译C++的程序时默认定义这个宏,我们可以使用条件宏来判断当前编译单元是不是C++代码。
1 | #ifdef __cplusplus |
弱符号与强符号
强符号:函数和已初始化了的全局变量(都是针对全局变量而言的)
弱符号:未初始化的全局变量,或者加有 __attribute__((weak))
的符号
- 强符号不能被多次定义。但弱符号是可以多次声明的。
- 如果符号在某个文件中是强符号,在其他文件中是弱符号,则选择强符号
- 如果一个符号在所有文件中都是弱符号,则选择其中占用空间最大的。如弱符号 weak 在 A 文件中定义为double,在文件B中定义为 int, 则在链接A,B时,weak 的大小为 double的大小。
如图,右边的a和左边的b为弱符号,不被使用,使用的是强符号的值。
(10条消息) __weak 和 attribute((weak)) 关键字的使用_子曰小玖的博客-CSDN博客
弱引用与强引用
强引用:链接器在链接文件时,如果没有找到符号的定义,会报未定义错误
弱引用:链接器在链接文件时,如果没有找到符号的定义,会默认其值为0,使用 __attribute__((weakref))
来声明一个引用为弱引用,常用方法如下:
不管,其他文件是否定义了 foo 函数,都不会报错,通过判断 foo 是否为零,可以知道其他文件是否定义了 foo 函数。
补充
弱符号与强符号针对定义而言,弱引用和强引用针对引用而言。(个人:未初始化的全局变量,只申明了的函数 是声明,弱符号。初始化了的全局变量,定义了的函数是定义,强符号)
符号:一个文件下未初始化的全局变量为弱符号,加上
__attribute((weak))
修饰的初始化了的全局变量为弱符号(有点怀疑,比如如下使用会报错)。一个文件下只申明未定义的函数为弱符号,定义了的函数加上__attribute((weak))
修饰后变为弱符号。若文件中有其他同名函数时会优先使用后者,否则使用前者。1
2__attribute__((weak)) int global_uninit_var = 4;
int global_uninit_var = 8;引用:引用一般是对其他文件的引用,一般不会初始化了吧,所有只考虑未初始化的情况。默认情况下,文件中的全局变量和函数都可以被其他文件所引用,加上
__attribute((weakref))
后,引用变成弱引用(不加或者加 extern 应该是强引用吧),这样就算其他文件没定义也不会报错,只是一般会将引用赋值为 0 ,使用前判断一下就行了;如果其他文件定义了就使用其他文件的。
使用场景(个人)
- 当我们在写什么底层文件时,可能不知道如何写,或者对于不同的上层,函数不一样时,可以使用
__attribute((weak))
来修饰函数将其变为弱符号,在其内部只写些提示语句,今后写上层文件时,再完成这个函数。 - 当我们调用别人的库时,不确定别人是否一直提供了某一函数,直接引用函数可能报错,如果是只声明函数的话,会被当作弱符号对待,链接时找不到函数定义也会报错。故可以加上
__attribute((weakref))
,若其他文件有该函数时会直接使用,否则其会被赋值为 0。
weakref 的使用有点特殊:新版本的weakref 需要函数别名,且必须是static 修饰
书中源码:
1 | main.c |
改进代码:
1 | main.c |