什么是ELF ELF是一种文件格式,这种文件格式的目标是打通我们的程序和操作系统之间的最后一公里。

我们可以用很多编程语言编写程序。

这些程序最终都是要操作系统帮我们加载和执行。操作系统不可能对每种编程语言制定一个加载和执行的程序。所以,操作系统就约定一个文件格式,甭管你是用什么编程语言写的程序,最后,都得给我按照文件格式生成文件,否则,我就不加载,这个文件格式就是ELF。

有了ELF后,随着实际情况的发展,ELF格式的文件,除了是作为操作系统加载执行的可执行文件,又承担了两项新的职责,就是目标文件和共享库文件,这两种文件也遵循ELF格式,只不过不能直接加载执行,他们的出现,主要是为了模块化程序,同时方便程序员和操作系统的。

ELF示例

假设我们有如下的C程序

// simple_section.c
int printf( const char* format, ...);

int global_init_var = 84;
int global_uninit_var;

void func1( int i)
{
    printf("%d\n", i);
}

int main(void)
{
    static int static_var = 85;
    static int static_var2;
    int a = 1;
    int b;

    func1(static_var + static_var2 + a + b);

    return a;
}

使用gcc -c进行编译后,就生成了一个目标文件。 gcc -c simple_section.c 执行上面的命令后,会在本级目录下看到一个simple_section.o文件,这就是遵循ELF格式的目标文件。

我们知道一个程序中的内容无非就是两大块:指令和数据。

为了便于管理,ELF文件是由文件头和段组成,所谓段,最常用的就是代码段和数据段,可以看出来,明显对应到程序中的指令和数据。

除了这两个段,还有其他的一些段,但都是辅助代码段和数据段的,所以,抓住了代码段和数据段,就抓住了ELF文件的关键。

我们可以通过 readelf -S simple_section.o命令,查看ELF文件中的段信息。

里面的text和data段就是代码段和数据段。上图中其他内容可暂时略过。

段表

为了管理组织好这些段,在ELF文件中专门有个段,叫section table,这个段中的内容就是该ELF中所有的段的基本信息。每个段的基本信息就是一个c语言中的结构,包含了该段在文件中的位置、段的名称、读写权限等。section table就是这种结构的一个数组。

上面的图其实就是section table。段表相当于整个ELF文件的结构地图,找到了section table,就可以掌握ELF文件的宏观结构。

段表是ELF文件中除了文件头之外最重要的结构,编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的。

那么问题来了,操作系统如何知道段表在ELF文件中的位置呢? 答案是,在ELF文件头中,指明了段表在ELF文件中的位置。

我们可以通过readelf -h simple_section.o命令查看ELF文件头,如下所示:

红框中标明的,就是section table在ELF文件中的位置。

通过文件头中的信息,找到section table在文件中的位置,通过section table,我们就可以知道整个ELF文件中所有的其他段的基本信息。

段的具体结构

上篇文章中,我们知道段表中存储着每个段的基本信息,这些基本信息用一个叫段描述符的结构来组织。 想要查看段描述符的结构,可以在 /usr/include/elf.h中搜Elf32_Shdr,如下所示:

typedef struct
{
  Elf32_Word    sh_name;  /* Section name (string tbl index) */
  Elf32_Word    sh_type; /* Section type */
  Elf32_Word    sh_flags;/* Section flags */
  Elf32_Addr    sh_addr; /* Section virtual addr at execution */
  Elf32_Off     sh_offset; /* Section file offset */
  Elf32_Word    sh_size; /* Section size in bytes */
  Elf32_Word    sh_link; /* Link to another section */
  Elf32_Word    sh_info; /* Additional section information */
  Elf32_Word    sh_addralign;/* Section alignment */
  Elf32_Word    sh_entsize;  /* Entry size if section holds table */
} Elf32_Shdr;

前缀sh表示的是section header。

  • sh_offset 表示该段在文件中的偏移量。相当于该段在文件中的起始地址。 注意: 这里的偏移单位是字节。

  • sh_size 表示该段的长度。有了sh_offset和sh_size,我们就可以从文件中完整地取出该段的内容。

  • sh_addr 表示段虚拟地址,这是指当进程需要将该段加载到进程地址空间时,请加载到这个地址。

其余的字段,有的看名字就知道含义,不知道的,暂时不用管。

符号段

示例代码里面的变量名和函数名都是一种符号。符号的本质就是标记一段代码或者数据。

func1和main这两个函数名,标记分别标记了一段指令。我们知道,所有的指令都在.text这个段中。

现在,让我们查看一下这个段的内容。看看func1和main代表的指令在不在里面。我们使用objdump -s -d simple_section.o命令,-d选项会把包含指令的段进行反汇编。

图片中是对.text段进行反汇编的结果。我们看到红框框标识了函数func1和main的起始位置,这里的起始位置指的是它们在段.text中的偏移字节量。

问题来了,.text段中就是一条接一条的指令,非常的纯粹,那么,func1和main1的起始位置信息是从哪里获得的呢?

答案是有一个专门的段,叫符号段.symtab,这个段记录了func1和main这种符号信息。

现在让我们看看符号段的内容。可以使用命令readelf -s simple_section.o,如下图所示:

我们来看看如何通过这个.symtab定位func1和main函数。

主要是用到Ndx和value两个字段。

首先看func1的Ndx值是1,表示的是func1这个符号在段表中第1个段,即.text段。

value是0000000000000000,表示的是func1符号在.text段中的偏移字节量。

TYPE的值FUNC表示func1符号是一个函数。

同理,可以找到main函数的具体位置信息。

Note: 此时我们说的位置,都是指在目标文件中的偏移字节量,是针对目标文件的内容而言的,并不是虚拟内存地址,要注意区分。

字符串段

在上面的符号段中,我们看到了函数和变量名称的字符串,看起来,这些名称字符串就存在符号段中。但是实际上并不是,而是有一个专门的字符串段.strtab来统一保存这些字符串,符号段中的显示的字符串是从字符串段中取出来的。

如下就是字符串段:

我们来尝试从这个段中找出func1和main。上图中的红框显示,字符串段在目标文件000002a8处,那我们就到这里看看,使用hexdump -c -s 0x000002a8 simple_section.o命令,如下图所示:

另外,.shstrtab段也是字符串段,只是它存的是段名的字符串,比如.text,.data这样的字符串。感兴趣的小伙伴可以自己探索一下。

为啥要专门用一个字符串段来存储字符串呢?这是因为,符号段中,记录每个符号信息使用了固定字节数的结构,没法表示长度不同的字符串名称,索性,将所有的字符串放到一个段中,这样,符号段中,符号名处只需要记录该符号名在字符串段中的偏移值即可。

https://zhuanlan.zhihu.com/p/313161665