[学习/笔记] 程序员的自我修养-第三章
侧边栏壁纸
  • 累计撰写 65 篇文章
  • 累计收到 3 条评论

[学习/笔记] 程序员的自我修养-第三章

x1n
x1n
2021-09-14 / 0 评论 / 16 阅读 / 正在检测是否收录...

三、目标文件

3.1 目标文件的格式

    实际上,目标文件(.obj/.o)和可执行文件的文件组织结构并没有太大差别,可能有些符号或地址还没有调整
    常见的可执行文件格式:Win-PE、ELF,它们都是COFF格式的变种。不太常见的格式在此不做记录
    除可执行文件外,动态链接库和静态链接库也都按照可执行文件格式。静态链接库稍有不同:可以理解为一个有目录索引的库包。
    • Relocatable file : 可以用来链接成可执行文件 .o/.obj/.a/.lib
    • Executable file : 可以直接执行的程序,ELF往往没有扩展名, PE为 .exe
    • Shared Object file : 链接器可以用共享目标文件和其他Relocatable File或者 SO file链接,或者ldd一类的动态链接器将其与可执行文件结合,作为进程的一部分 .so/.dll
    • Core Dump : 核心转储文件

3.2 ELF文件

    目标文件主要包含机器码、数据,此外还包括一些链接信息如符号表、调试信息、字符串等
    信息按不同类型放在不同的节(Section)或者段(Segment)中
    • 机器码一般被放在代码段(Code Section)中,常被记为.text/.code
    • 全局变量、静态变量一般被放在数据段(Data Section)中,而未初始化的全局、静态一般被放在BSS段中,目的是为了节省空间,因为为大量的0分配空间是没有必要的
    • ELF文件头描述了ELF的文件属性,以及一个段表(Section Table),关于文件属性会在后面提到。

把程序的指令和数据分开,有这么几个优点

    • 数据和指令被映射到两个虚拟内存,指令地址可以被设置成只读,防止程序指令被改写
    • 有助于提高程序的局部性,进而提高CPU的缓存命中率,提高程序的运行效率
    • 运行多个某程序时,只需要保存一份指令,只将非只读的数据复制变为进程私有资源,这样可以节省大量的内存

3.3 以 SimpleSection.o 为例挖掘 ELF 内容

    为保护版权,本文并不会将文件内容贴在页面上,如有需求请购买本书。
    利用gcc编译文件:gcc -c SimpleSection.c
    利用objdump查看目标文件结构和内容。:objdum -h SimpleSection.o
    可以发现有:.text .data .bss. rodata .comment .note.GNU-stack段。
    段属性: Size 代表段长,File Offset代表段的偏移,CONTENTS代表该段在文件中存在
4375DA3F-03E8-4F14-B42F-AEB52ED7ADF2.jpeg
    这张图展示了SimpleSection.o的结构,接下来我们逐步分析几个段。
    使用size命令查看ELF的代码段、数据段等的长度size SimpleSection.o

3.3.1 代码段

   一般来讲,代码段可以直接翻译回汇编代码(在没有干扰反编译器的花指令的情况下),.text从第一个十六进制位到最后一个十六进制位,直接翻译即可

3.3.2 数据段与只读数据段

    对于字符串常量,比如printf中的参数"%d\n",这类只读数据往往会存放在rodata中,rodata在语义上支持了C++的const关键字,而且操作系统将rodata映射为只读,这使得程序的安全性得到保障。
    有的时候某些编译器会把字符串常量扔在.data段,根据书上的描述,可以认为某种MSVC编译器会出现这种情况。
    在data段,介绍了一个关于大小端序的问题:对于0x12345678,大端序在储存是是按照0x12放在低地址,在传输时是0x12首先传输,而小端序则正好相反。也就是说,在一般的大端序情况下,0x12345678在内存中的样子是0x12 0x34 0x56 0x78,而LSB是0x78 0x56 0x34 0x12,Intel主机主要采用小端序

3.3.3 BSS段

    前文提到过,全局的未初始化变量和局部静态的未初始化变量往往被存放在bss段,但是在某些编译器的视线中,全局的未初始化变量可能不会存放在bss段,只是预留一个未定义的全局变量,在链接时再为其在bss段分配空间,未来还会深入分析这个问题。

3.3.4 其他段

    ELF文件还会包含一些其他段,我们列举记录一些常用段及其默认功能
    .rodata1 同rodata
    .comment 存放编译器版本信息,如(GCC:(GNU)4.2.0)
    .debug 调试信息
    .dynamic 动态链接信息
    .hash 哈希表
    .line 行号表,指令与源代码的对应
    .note 其他编译器信息,如版本号、公司名
    .strtab String Table 字符串表
    .symtab 符号表
    .shstrtab 段名表
    .plt.got 动态链接的跳转表和入口表
    .init.fini 初始化与终结代码段
    这些段都是用'.'作为前缀,表示系统保留。我们也可以用一些自定义的段。不过不能使用'.'作为前缀。此外,ELF可能存在两个或者两个以上叫.text的段。以及还可能出现一些已经弃用的保留字
    可以通过objcopy将某些文件转换为目标文件的一个段
    可以通过指定attribute使GCC将某些代码放在我们指定的段

3.4 ELF文件描述

    接下来看看ELF文件的结构格式,下图省去了一些繁琐的结构,只留下了一个最基本的结构
9DEE682E-E92B-4129-A4D0-13B2137952C5.jpeg
    ELF目标文件的最前部是文件头(Header),描述了整个文件的属性,如文件版本、机器型号、程序入口地址。后面是各个段,段表(Section Header Table)描述了ELF文件中所包含的段的信息,如段名、偏移、权限以及其他属性。本节主要分析文件头、段表,其他结构将在其他章节展开

3.4.1 文件头

    我们使用 readelf命令查看elf文件。
    ELF文件头定义了ELF魔数、机器字节长度、数据储存方式、版本、运行平台、ABI版本、ELF重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置长度以及段的数量
    ELF文件结构在elf.h中定义了一套变量,以便在ELF相关的结构中使用。如果看过一遍,其实类型名和原始类型的对应是很明显的,这里列举一下便于查阅:

3.4.1.1 ELF变量类型

    • Elf32_Addr -> uint32_t 32位地址
    • Elf32_Half -> uint16_t 32位有符号短整形
    • Elf32_Off -> uint32_t 32位偏移地址
    • Elf32_Sword -> uint32_t 32位有符号整形
    • Elf32_Word -> int32_t 32位无符号整形
    • Elf64_Addr -> uint64_t 64位地址
    • Elf64_Half -> uint16_t 64位有符号短整形
    • Elf64_Off -> uint64_t 64位偏移地址
    • Elf64_Sword -> uint32_t 64位有符号整形
    • Elf64_Word -> int32_t 64位无符号整形
    继续看ELF头,ELF头结构位Elf32_Ehdr,下面是它的定义:

typedef struct {
    unsigned char e_indent[16];
    Elf32_Half e_type;
    Elf32_Half e_machine;
    Elf32_Word e_version;
    Elf32_Addr e_entry;
    Elf32_Off e_phoff;
    Elf32_Off e_shoff;
    Elf32_Word e_flags;
    Elf32_Half e_ehsize;
    Elf32_Half e_phentsize;
    Elf32_Half e_phnum;
    Elf32_Half e_shentsize;
    Elf32_Half e_shnum
    Elf32_Half e_shstrndx;
}

    额,看起来就让人学不下去,但是还是要一个个的了解一下。不知道笔记里面做什么好,就单纯的把书上的表格贴出来吧,这种定义性的东西可能更多还是要背,
39D19E44-8E35-4F12-912A-D6E7981A2FD6.jpeg
    只能记录一下sh代表section headers,ph代表program headers了

3.4.1.2 ELF魔数

    e_ident共16个字节,标识ELF文件属性,前四个字节为 0x7F, 'E', 'L', 'F'。可执行文件的开头几户都是魔数,a.out为0x01 0x07,PE为'M', 'Z'。系统加载可执行文件的第一步就是检查魔数是否正确。
    回到e_ident,第五个字节标识ELF文件类,0x01为32bit,0x02位64bit。第六个字节为字节序,规定文件大端序或小端序。第七个字节为ELF的主版本号,一般是1,因为ELF自1.2之后再也没有更新。后面九个字节往往填充0,某些平台也会利用这九位做其他标识
AAB47C18-5BF1-43FD-8F1E-13169CD76510.jpeg

3.4.1.3 ELF文件类型

    e_type中存放ELF文件类型(.o/NONE/.so),常量标识符以ET_开头

3.4.1.4 机器类型

    ELF设计成跨平台的文件类型,但是这不代表一个ELF可以跨平台使用,e_machine代表了ELF的平台属性,常量标识符以EM_开头,3代表Intel x86

3.4.2 段表

    段表记录了段的结构:名称、长度、偏移、权限等属性。段表的偏移在e_shoff字段。通过readelf命令可以查看段表结构。而objdump只是将重要的段显示出来。
    段表是一个以'Elf32_Shdr'为元素的数组,Also known as ‘Section Descriptor’。第一个元素是无效的描述符,Elf32_Shdr结构如下z。

23C46362-03A2-44E2-ADFC-7FF5202D76FA.jpeg
    所有的这些属性都会在readelf -s中有所体现。我认为这部分结构已经体现的没有特别重要的内容,诸如类型、标志位会在后文记录
    观察程序ELF结构,可以发现Section Table的起始地址为0x0118,这正好是Header中 e_shoff 的值,而Section Table的前驱段 .shstrtab 结束地址为0x117,这应当是因为sh_addralign使得Section Table对对齐到2的指数倍。

3.4.2.1 段的类型

    尽管段名字对编译、链接过程有意义,编译器和链接器读取到的决定性属性是段的类型(sh_type)和段的标志位(sh_flags)。段的相关类型以SHT_做前缀,从0-11共12个常量,无效段,程序、代码、数据段,符号表,字符串表,重定位表,符号哈希表,动态链接信息,无内容,重定位信息,保留以及动态链接符号表等。这种东西我觉得往往是有需要的时候利用好搜索引擎即可。

3.4.2.2 段的标志位

    表示段在进程虚拟地址空间的属性,是一个三位2进制码,类似rwx,不过这个是xaw,a指是否需要分配空间,某些段包含的是指示/控制信息,不需要分配空间,但代码段、数据段等需要分配空间。
    系统保留段的属性一般是预先规定好的,如data段是-aw,text段是xa-,也有一些根据实际情况而不同的flag

3.4.2.3 段的链接信息

    sh_link与sh_info只对链接相关的段有意义,link往往存放下标与系统相关信息之类的。

3.4.3 重定位表

    .rel.text段的type为"SHT_REL",是一个重定位表。.rel.text 就是对.text段的重定位表,因为.text端至少有一个绝对地址的引用:printf的调用,而.data并没有相关引用,所以在这份文件中没有出现.rel.data, 在这里,link提示下标,info代表它作用于哪个段,因为.text的下标为一,所以.rel.text的sh_info是1

3.4.4 字符串表

    段名、变量名等字符串的长度往往不定,难以使用固定结构存放,往往通过放在字符串表中字符串的偏移来引用,ELF引用字符串只需要引用一个下标.strtab/.shstrtab分布代表字符串表和段表字符串表,前者保存普通的字符串,后者保存段表中用到的字符串。
    回头看一下e_shstrndx,它代表了段表字符串表shstrtab在在段表中的下标。这意味着我们获取ELF文件头后,即可获取段表位置和段表字符串表偏移,进而解析整个ELF文件。

3.5 符号

    符号就是链接这个拼图过程中,拼图的凹凸部分。我们将函数和变量称为符号,函数名、变量名称为符号名。目标文件在符号表中记录了这些符号,每个符号有一个符号值,对于变量、函数,这个符号值就是他们的地址。在这一部分,我们主要关注能被其他目标文件引用的全局符号,以及在本目标文件中引用,但是没有在本目标文件中定义的外部符号(External Symbol),比如printf函数
    此外,还有如下三类符号:
    • 段名,往往由编译器产生,值就是段地址
    • 局部符号,只在编译单元内课间。  
    • 行号信息
    readelf、objdump以及nm都可以用来查看ELF的符号表。

3.5.1 ELF符号表结构

    ELF文件中有一个段一般叫".symtab",是一个Elf32_sym数组,其中第一个元素为无效符号,它的定义如下:
3A85E02A-1C13-4F81-835A-1D87D2918E8C.jpeg

3.5.1.1 符号类型和绑定信息

    书上声称st_info的低四位代表符号类型,高28位表示符号绑定信息。但是st_info是一个uchar,只有8位,加之符号绑定信息常量只有0、1、2共三种,我怀疑绑定信息也应当只有四位,本着追根溯源的想法,去找了找相关资料,发现了一个读取符号类型和绑定信息的宏

#define ELF_ST_BIND(info) ((info) >> 4)
#define ELF_ST_TYPE(info) ((info) & 0xF)

    好吧,没有什么有用的信息,但是发现宏值不止书上给的3/4种,还有13-loproc、15-hiproc,进一步读了读感觉这两个没啥用,但是最高为15进一步印证了我的想法。
    前去查询一下ELF文档试试。文档中并没有描述具体是多少位,由此,我认为我的看法应该是对的。根据ELF文档给的宏定义,如果info确实为uint8,那么右移四位后必然只能读取到高四位。如果对此有一些其他想法的师傅,也欢迎指出我的错误。
    言归正传,符号绑定声明了符号对外部是否可见,或是否是弱引用。而符号类型定义了这个符号的类型(废话。。。)数据/代码/段/文件名等

3.5.1.2 符号所在段、符号值

    st_shndx分两种情况,如果符号定义在本目标文件中,它表示符号在段表中的下标,否则会有三种情况:SHN_ABS\SHN_COMMON\SHN_UNDEF,ABS表示符号包含了一个绝对的值,COMMON表示是一个COMMON块的符号、UNDEF代表定义在其他文件中,并且在本文件中引用了。
    st_value在一般情况下,即如果这个符号是一个函数或者变量的定义,那么符号值就是函数/变量的地址,具体有以下几种情况
    • 若某符号不属于COMMON块,且在目标文件中定义,则value代表该符号在段中的偏移
    • 若符号属于COMMON块,value代表其对齐属性
    • 在可执行文件中,value表示符号的虚拟地址,动态链接器将使用这个虚拟地址。

    通过readelf -s,可以分析各个符号在符号表中的状态
    • 对于func1和main,他们在代码段.text中,所以Ndx项为1,Size表示函数指令占的字节数而Value项对代码段起始位置的偏移。
    • printf被引用,但是没有被定义,所以Ndx为SHN_UNDEF
    • global_init_var在bss段,下标为3
    • global_uninit_var未初始化,是一个COMMON类型的符号
    • static_var.1533/1534是两个静态变量,只在编译单元内部可见
    • 对于SECTION类型的符号,表示第Ndx段的段名,比如某符号的Ndx为1,那么他就是.text段的段名,也即应该就是".text"

3.5.2 特殊符号

    ld在链接的时候,会处理许多特殊的符号,可以在未定义的前提下直接声明或使用。它们实际上被定义在ld的链接脚本中。只有在产生可执行文件的时候,这些符号才会存在。下文列举几个特殊符号:
    • __executable_start : 程序起始地址
    • __etext/\_etext/etext : 代码段结束地址
    • \_edata/edata : 数据段结束地址
    • \_end/end : 程序结束地址
    • 这些都是装载时的虚拟地址
    在经过extern引用后,我们可以直接使用这些符号。

3.5.3 符号修饰与函数签名

    在最开始,产生目标文件的符号名与变量、函数名是一致的。但是后期由于汇编库越来越庞大,如果还如此操作,会使得编写的程序与库产生冲突。于是UNIX规定,C源代码经过编译后,相应的符号名前加上下划线"_", 而Fortran在前后分别加上一个"_"。但是这种方式没有从根本上解决问题,当不同模块由不同单位开发时,若命名规范不同,则有可能导致冲突。C++中namespace的出现就是考虑到这个问题,解决多模块的符号冲突。
    但是后来,操作系统和编译器被完全重写过数次,GCC已经去掉了加"_"的方式,但是Windows还在保留这样的传统,gcc可以通过参数选项来选择是否在C语言符号上加下划线。

3.5.3.1 符号修饰

  C++拥有类、继承、虚机制、重载等特性,使得符号管理更复杂,如func(int)/func(double),尽管函数名相同,但是参数列表不同。为了支持C++的特性,采用符号修饰或符号改编机制。
  C++允许同名函数不同参数列表,也就是函数重载,同时允许不同命名空间中有多个同样名字的符号。书上给了一个奇特的实例代码:

int func(int);
float func(float);

class C {
     int func(int);
     class C2 {
         int func(int);
     };
};
namespace N {
     int func(int);
     class C {
         int func(int);
     };
}

    函数签名包含了函数的信息,函数名、参数类型、所在的类、命名空间等信息,函数签名用来识别不同的函数。在编译器、链接器处理符号的时候,使用某种名称修饰的方法,使得函数签名对应一个修饰后名称。编译成目标文件时,将函数、变量的名字进行修饰,也即目标文件中存放的Symbol是修饰后的名称,因为Symbol不同,也就不会产生冲突问题。
GCC下基本修饰方法如下:
  • 所有的符号以"_Z"开头
  • 如果在namespace或者class中,后面跟着N
  • 然后是一系列namespace或class,先是字符串长度,然后是字符串名字
  • 之后以E结尾
  • 之后是参数列表,比如int会用i表示
int C::C2::func(int) -> _ZN1C2C24funcEi
    同样,变量也会被用类似的方法修饰,但是修饰名称中并不包含变量的类型
    同时,名称修饰也需要防止静态变量的明智冲突,比如main和func中都有静态变量foo,GCC会分别修饰成_ZZ4mainE3foo, _ZZ4funcvE3foo.(?)v真的存在么?
    VC++的修饰方法和GCC并不相同,比如
int C::C2::func(int) -> ?func@C2@C@@AAEHH@Z
  • 由?开头
  • 然后是由@结尾的函数名
  • 然后是嵌套上一层的C2,以@结束,直到嵌套结束,再一个@表示名称空间结束。
  • 然后是参数类型与返回值,由@结束
  • 然后由Z结尾
  Windows API中有UnDecorateSymbolName(),可以将修饰后名称转换为函数签名。

  由于不同编译器在实现上,比如名称修饰方法上不同,所以不同编译器的目标文件不能相互链接。

3.5.4 extern "C"

  C++为了兼容C,拥有一个用来声明/定义C符号的 extern "C"关键字。C++编译器会将其打括号内的代码当作C语言代码处理,除了兼容性之外,还有一个很有趣的现象:对于

#include <cstdio>
namespace myname{
     int var = 42;
}
extern "C" double _ZN6myname3varE;
int main() {
     printf("%d\n", _ZN6myname3varE);
}

  输出的结果是42,因为var被修饰后的名称正好是ZN6...

  在C语言中,如果include string.h, 编译器会正确的处理memset,但是在C++中,编译器会将其修饰成_Z6memsetPvii,   导致无法与C库中的memset链接,所以memset必须要通过extern C声明,但是C语言又不支持extern C,所以我们需要使用cplusplus这个宏

#ifdef __cplusplus
extern "C" {
#endif
...
#ifdef __cplusplus
}
#endif

3.5.5 弱符号与强符号

  前文已经多次提到过强弱符号的问题。在这里解释一下。当多个目标文件中有多个同名符号,链接的时候可能会出现符号重复定义的错误。这种符号的定义被称为强符号。编译器认为函数和被初始化的全局变量为强符号,未初始化的为弱符号。同时可以通过GCC的attribute定义强符号为弱符号,符号是针对定义而不是引用。
  • 强符号不允许被多次定义
  • 如果某文件中A为强符号,其他文件中为弱符号,那么选择强符号
  • 如果所有文件中A都为强符号,选择占用空间最大的那个

3.5.5.1 弱引用呢与强引用

  对外部文件引用在链接时,如果没有找到定义,就会报未定义错误,这就是强引用。与之对应的还有弱引用,如果该符号在引用时找到了定义,则链接器引用,否则链接器不宝座,而是一般默认其为0或者某特殊值,便于程序识别,它们主要用于库的链接过程。当调用未被定义的弱引用时,会触发程序错误,因为意图访问0这个地址。

库中的弱符号可以被用户定义的强符号覆盖,即使弱引用的模块被去掉,程序也可以正常链接。

3.6 调试信息

 目标文件里还可以保存调试信息,将一些源代码信息存在目标文件中,ELF文件采用DWARF格式保存调试信息。

0

评论

博主关闭了当前页面的评论