linux可执行文件结构及链接过程分析

前言

  简单总结一下linux可执行文件的结构,以及有关链接的几个比较重要的概念,如符号表,重定位,静态链接,动态链接等。本文例子及部分描述摘自《深入理解计算机系统》及MIT教学操作系统xv6(Unix第6版用ANSI C重写)的源码。

elf格式

  gcc是linux环境里的c语言编译器,它编译得到的可执行文件都是elf文件格式,这些文件包括我们平常使用的cat,ls,mkdir,touch等等命令,还包括我们每天都在使用的shell程序。

从源文件到可执行文件

  在linux系统编译简单的c程序:

1
gcc -Og -o prog main.c sum.c

  该命令执行后,会编译main.c和sum.c两个源文件,生成名为prog的可执行文件。这个过程可分为4个步骤:

  1. 预处理阶段,把#include语句以及一些宏插入程序文本中,得到main.i和sum.i文件。
  2. 编译阶段,将文本文件main.i和sum.i编译成文本文件main.s和sum.c的汇编语言程序。
  3. 汇编阶段,将main.s和sum.s翻译成机器语言的二进制指令,并打包成一种叫做可重定位目标程序的格式,并将结果保存在main.o和sum.o两个文件中。这种文件格式就比较接近elf格式了。
  4. 链接阶段,合并main.o和sum.o,得到可执行目标文件,就是elf格式文件。

链接到底做了什么

  先看一个简单的例子。

  main.c的源代码:

1
2
3
4
5
6
7
8
9
int sum(int *a, int n);
int array[2] = {1, 2};
int main()
{
int val = sum(array, 2);
return val;
}

  sum.c的源代码:

1
2
3
4
5
6
7
8
9
10
int sum(int *a, int n)
{
int i, s = 0;
for(i = 0; i < n; i++){
s += a[i]
}
return s;
}

  在汇编阶段后,可重定位目标程序main.o和sum.o得到的全是汇编的二进制指令,这些指令有很重要的一部分都涉及对内存的引用,这些内存引用就是我们在程序中定义的全局变量和函数,例如sum, array。汇编阶段会给这些全局变量和函数分配一个假的内存地址。

  由于main.c和sum.c的源代码是单独编译的,main函数知道array这个符号是真实存在的,因为它的引用和定义都在一个文件内,但它不知道sum函数的定义,它就无法给它指定地址。所以真正内存地址的指定是在链接阶段后完成的。链接完成后,所有全局变量和函数的内存地址都确定,那么在运行可执行文件的时候,操作系统就知道把这些数据和代码加载到内存的哪个位置,每一个CPU指令就知道应该把数据从哪个内存地址转移到哪个另外的内存地址,或者从哪个内存位置把数据加载到寄存器中。链接主要就是与这些符号打交道,它要把每一个源代码文件的符号的定义和引用都关联起来,让每一次符号引用(即内存引用)都能定位到正确的内存定义上(符号真正的内存地址)。

  因此,链接主要的任务有两个,符号解析与重定位。符号解析需要借助一个叫做符号表的数据结构,它把源代码中涉及到的所有符号都单独提出来,以便在链接时候帮助定位每一个符号定义和引用,把每一次引用都与一次定义关联起来。这个符号表在每个可重定位目标程序中都存在,依上例,main.o和sum.o都有一个这样的结构。重定位就是把所有可重定位目标程序的数据和代码都合到一个文件里,给它们指定符号定义的真正的内存地址,并修改每一次符号引用的地址。

elf文件结构

  由于链接过程涉及的符号解析和重定位都与elf文件的某个结构相关,因此先介绍一下elf文件结构。

reconstruct-elf

  只介绍几个比较重要的部分。

  1. .text已编译的机器代码。
  2. .data已初始化的全局和静态变量。
  3. .bss未初始化的全局和静态变量,不真正分配空间,仅仅作为占位符。
  4. .symtab符号表,程序定义和引用的全局变量、静态变量及函数信息。
  5. .rel.text,.text节的可重定位信息。
  6. .rel.data,.data节的可重定位信息。
  7. .strtab, 符号表中出现的符号的名字,符号表会有很多指针指向这个部分的某个偏移。这个部分就是以null结尾的字符串序列。

符号表

symbol

symbol-description

重定位

  链接的主要目的是要让多个源代码文件编译生成的多个二进制机器码文件能够合并起来,给代码和数据指定真实的内存地址。符号解析是为重定位服务的。重定位分为两步:

  1. 把.o文件(可重定位目标文件)合并成一个文件,相同类型的节合到一起,例如.text都合到一起,.data也合到一起,各自成为可执行文件的一个大的.text和.data节。把这些节的每一条代码和数据都指定运行时的内存地址。因此,符号表中的每一个符号都有了正确的运行时内存地址。
  2. 重定位符号引用。修改.text和.data中的对每个符号的引用,使其指向正确的运行时地址。

.rel.text和.rel.data

rel

  type主要有两种类型。

  1. R_X86_64_PC32。32位PC相对地址引用
  2. R_X86_64_32。32位绝对地址引用

重定位符号引用

readdr

  引用《深入理解计算机系统》的例子,说明一下相对地址引用的重定位。

rel-main

rel-sum

  因为在执行call指令时,PC指向的实际是call指令的下一条指令,所以计算PC相对地址需要有一个-4的padding。

静态链接

标准库函数如何链接?

  我们平常用到的标准库函数,例如printf, scanf, atoi等等,对每个c程序都是可用的,我们只需要include相应的头文件,例如#include <stdlib.h>这样,就可以使用。但我们在编译时候并没有显式的指定相应的源代码文件,那么这些代码是怎么跟我们自己写的代码链接到一起的呢?

  在说gcc的做法之前,我们可以先思考一下,如果是自己实现编译器,会怎么处理这个问题。

  1. 把所有的库函数硬编码到编译器代码里。但是由于标准库函数非常多,显然会令gcc的实现非常复杂,而且每当库函数有更新,都要更新编译器的版本。
  2. 将所有标准c函数都编译到一个可重定位目标文件(.o)文件里,然后每次链接的时候指定一下gcc main.c /usr/lib/libc.o。但是,可能我们的代码只用到一个printf函数,但我们就需要把所有库函数的代码和数据段都合并到最终的可执行文件中,白白浪费磁盘空间。
  3. 将每一个标准库函数单独编译成一个.o文件,在需要的时候手动链接。例如gcc main.c /usr/lib/prinf.o /usr/lib/scanf.o,这样编译过程就弄得非常复杂,对于大项目,我们可能要手动链接成百上千个.o文件。

linux的做法

  静态库以归档文件的特殊格式存放在磁盘上,由.a后缀标识。将标准库函数拆分成很多的目标模块,将不同的模块一起打包成一个.a文件。在链接的时候,判断我们的程序用到哪个模块,就把哪个模块的数据段和代码段和我们的程序一起链接成可执行文件。标准库归档文件libc.a会自动作为参数传给编译器,无需显示指定。

静态库链接的符号解析过程

gcc foo.c libx.a liby.a libz.a

  1. 整个链接过程,编译器会维护两个数据集合。一个代表所有已定义的符号集合D,一个代表未解析的符号结合U。
  2. 一个一个分析输入参数,如果是可重定位目标文件,将所有已定义的符号都加入到D集合中,把所有该文件引用了,但没有找到定义的符号都加入到集合U中。
  3. 如果输入参数是归档文件,那么就遍历该归档文件的所有模块,看哪些模块定义了当前集合U中的符号,找到的话把这个模块加入链接,并去掉U中的相应符号。同时把模块里的所有符号按照一样的规则加入到集合D和集合U中。

  从链接的过程可以看到,参数的顺序非常重要。如果foo.c引用了libz.a的某个模块的符号,而该模块的某个符号又引用了libx.a的一个符号,就会链接失败。一般各个库都是相对独立的,我们只要保证把自己写的源文件放到最前面就好了。

动态链接

  现代的标准库函数都是动态链接的,即在编译时不进行链接,而在运行可执行文件时候才进行链接。

静态链接有什么问题?

  1. 几乎所有程序都需要printf这样的库函数,每个可执行文件都包含该模块的代码段和数据段,浪费磁盘空间。
  2. linux采用虚拟内存管理内存分配,每个进程的内存空间是独立的,运行时所有程序都要把这些库函数代码段和数据段加载到自己的内存里,浪费内存。

如何实现动态链接

  1. 共享库是.so文件,不会和我们自己的代码一起合并成可执行文件,不占磁盘空间。
  2. 运行时一个共享库的代码段和数据段在物理内存中只有一份,但映射到多个虚拟内存片段上,供不同程序使用。其中代码段是只读的,整个操作系统绝对只有一份。但数据段有可能被修改,在修改的时候则会复制一个副本,每个进程有自己的一个内存副本。
  3. 共享库的代码段和数据段加载到任意的内存段中,位置不固定。
  4. 加载完成后,进行符号重定位。

  回想一下之前说过的重定位过程,需要修改所有符号引用的地址。由于动态链接在运行时才确定共享库代码段和数据段的内存地址,所以在运行时才能进行重定位。

  1. 运行时修改代码,想想就觉得不优雅。
  2. Linux不允许在运行时修改代码段。

  由此,要完成动态链接,还需要引入了最后一个重要的概念,位置无关代码,即在加载时无需重定位的代码。

位置无关代码(Position-Independent Code, PIC)

  所有的技巧都来自于一个标准:无论是共享库还是目标模块,代码段和数据段的距离都是恒定的。由于数据段是可以在运行时修改的,所以可以把对代码段的修改转化为对数据段的修改。

  在数据段前面加入一个数据结构,全局偏移量表(GOT)。每一个被该模块引用的符号,都在GOT里有一个8字节条目,并为每个条目生成一个重定位条目。

pic

  这样,在重定位时只需要重定位GOT相应条目的内容即可,不需要修改代码段。

加载可执行文件

可执行文件磁盘布局

disk-structure

可执行文件内存布局

mem-structure

加载时需要做什么?

  把相应的文件区域映射到内存区域,要知道从哪个文件偏移的多少字节数据是哪个程序段的,需要加载到哪个内存地址上。这就需要借助ELF头部和段头部表里。

xv6加载内核程序到内存

  内核代码也是一个elf格式的文件存放在磁盘上。以这一段xv6教学操作系统加载内核的小函数为例,分析可执行文件的加载过程。当然,这个过程是几十年前的技术了,现代

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void
bootmain(void)
{
struct elfhdr *elf;
struct proghdr *ph, *eph;
void (*entry)(void);
uchar* pa;
elf = (struct elfhdr*)0x10000; // scratch space
// Read 1st page off disk
readseg((uchar*)elf, 4096, 0);
// Is this an ELF executable?
if(elf->magic != ELF_MAGIC)
return; // let bootasm.S handle error
// Load each program segment (ignores ph flags).
ph = (struct proghdr*)((uchar*)elf + elf->phoff);
eph = ph + elf->phnum;
for(; ph < eph; ph++){
pa = (uchar*)ph->paddr;
readseg(pa, ph->filesz, ph->off);
if(ph->memsz > ph->filesz)
stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);
}
// Call the entry point from the ELF header.
// Does not return!
entry = (void(*)(void))(elf->entry);
entry();
}
  1. elfhdr结构体就是ELF头部的格式。首先解析elf头部。
  2. 首先检查magic字段,判断该文件是否是elf格式文件。
  3. 然后取出elf->phoff,找出程序头部表在文件中的位置;取出elf->phnum,找出程序头部表的数目,一般代码段是一个程序段,数据段也是一个段,phnum有两个。proghdr结构体就是程序头部表的格式。
  4. 逐个程序段解析proghdr,ph->paddr代表程序段的要加载到的内存地址,ph->filesz代表在文件中的大小,ph->memsz代表加载到内存后的大小。调用readseg函数读磁盘内容到相应内存位置,memsz有可能比filesz大,因为.bss和.data一起作为一个程序段加载到内存,最后的部分可能要补0预先占位。
  5. elf->entry是入口函数的地址,这个函数最终调用main函数。