ELF格式与程序运行的内存模型

最近在复习一些操作系统相关的知识,而操作系统说白了就是为程序运行提供的环境的,和硬件直接接触的软件.现代操作系统要为程序提供一种虚拟环境,包括但不限于一个独立的地址空间,一个独立的虚拟CPU以及其他一些东西(如系统调用等).今天正好借这个机会来梳理一下程序的运行环境(主要是内存模型).

下面的所有例子都已C语言为例,但是请注意,这只是一种较为场景的实现,而不是C语言标准的一部分

程序运行的内存模型

静态内存和动态内存

我们先从最简单的C语言程序开始,这个程序甚至没有main函数:

1
2
3
4
void foo(){
int a = 1;
int b = a;
}

这里我们仅从内存的角度来考虑问题(即不考虑编译器的寄存器优化的影响),这段代码在内存中的运行时可能是下面这样的:

1
2
3
4
5
┌──────────┐
a (4bytes)│
├──────────┤
│b (4bytes)│
└──────────┘

然后我们再稍微来点更复杂的例子:

1
2
3
4
5
6
7
8
9
10
strcut A{
int x;
char c;
}
void f(){
struct A x[3];
x[0].x = 12;
x[1].c = 'c'
struct A* p = x + 1;
}

它在内存中的分布可能是这样的:

1
2
3
4
5
6
7
8
9
┌─────────────┐
│x[0] (8bytes)│
├─────────────┤
│x[1] (8bytes)│
├─────────────┤
│x[2] (8bytes)│
├─────────────┤
p (8bytes)│
└─────────────┘

不难发现一点,在上述的示例代码中,程序占用的内存空间是编译期可以算出来的,如第一个例子ab变量就占据了固定的8bytes,而第一个例子一个数组+3个指针一共占据了24bytes.到此为止,我们不难想到,为了节省运行时的开销,如果我们让编译器在编译的时候就计算出来程序的内存布局(也就是怎么排布,占多少个字节).

但遗憾的是,不是所有场景下我们都编译期间知道内存大小,再来看个例子:

1
2
3
4
5
void foo(){
int x;
scanf("%d",&x);
int *y = malloc(x * sizeof(int));
}

如果继续使用我们上面的模型,那么它们在内存中的分布可能是这样的(让我们暂时忽略函数调用):

1
2
3
4
5
6
7
8
9
10
11
┌──────────────────────────┐
│x (4bytes) │
├──────────────────────────┤
│y[0] (4bytes) │
├──────────────────────────┤
│y[1] (4bytes) │
├──────────────────────────┤
│... (4 * (x-3) bytes)│
├──────────────────────────┤
│y[x-1] (4bytes) │
└──────────────────────────┘

上面这个例子就要占用4 + x * 4字节的空间,而且这个空间无法在编译期计算得出,因为x只有等到用户输入才知道,因此也就不能使用上述的编译期间计算的模型了.为了解决这个问题,现在普遍采用的是静态+动态两种方法同时使用的内存分配方案:

  1. 对于程序执行流中编译期能计算出来的大小,我们就直接在编译期分配好(也就是告诉操作系统需要多少内存),暂时称这算为静态内存区域
  2. 对于只有运行时才能算出来的大小,就使用一个额外的动态内存区域在运行时分配内存,而静态内存区域只需要存储指向这段内存的一个指针即可(请注意指针是能编译期算出来的)

上面的说法有点抽象,还是以上面的代码举个例子,在使用静态+动态内存分配后,我们就可以使用如下的内存模型:

1
2
3
4
5
6
7
8
9
10
                    		┌──────────────────────────┐
┌─────────────────┐ │y[0] (4bytes) │
│x (4bytes)│ ├──────────────────────────┤
├─────────────────┤ │y[1] (4bytes) │
│y* (8bytes)│────────>├──────────────────────────┤
└─────────────────┘ │... (4 * (x-3) bytes)│
静态内存区域 ├──────────────────────────┤
│y[x-1] (4bytes) │
└──────────────────────────┘
动态内存区域

接下来来介绍所谓的静态内存区域动态内存区域到底是怎样的.

静态内存区域与栈

上述说的静态内存区域其实就是程序的运行栈至于为什么要叫栈,这就是程序的执行特征来决定的了:程序在运行的时候有大量的函数调用行为,而栈是用来描述这一行为最合适的数据结构:

下面有一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
void a(){
int x = 0;
b();
}
void b (){
int y = 0;
c();
}
void c(){
int z = 0;
}

这里我们运行a(),当函数调用链到达d的时候,栈空间的排布是如下这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌────────────┐<- stack top
│context of a│
├────────────┤
│x (4 bytes)│
├────────────┤
│context of b│
├────────────┤
│y (4bytes) │
├────────────┤
│context of c│
├────────────┤
│u (4bytes) │
│ │<- stack pointer

每当有一个函数被调用的时候,编译器就将该函数的调用上下文(包括传入的参数,返回值,栈指针等信息)入栈(往下移动栈指针),然后开始分配该函数所需要的经验静态内存空间;每当一个函数返回的时候, 编译器就将当前栈分配的静态内存空间和调用上下文等信息回收(往上移动栈指针即可),这样就形成了完整的函数但用过程. 这里将函数的调用上下文和其分配的静态内存空间称之为栈帧,上述过程也可以简化成这样:

  1. 函数调用就是往栈中推入栈帧
  2. 函数的返回就是往栈中回收栈帧

至于调用上下文,它会存储部分调用函数传递给当前函数的参数(还有部分参数直接写在寄存器中,不经过栈内存,具体看相关ABI),当前的栈指针,预分配的返回值等信息.

动态内存区域与堆

动态内存区域就是由操作系统管理的一块空闲的内存空间,当一个用户(进程)需要的时候,可以通过特定的系统调用(如经C语言封装的malloc)来分配一块指定大小的内存,并返回指向该内存的指针作为句柄.这段内存被申请后,就无法被其他的用户使用了,直到该用户手动告诉操作系统这段内存自己不需要了(表现在C语言内就是free函数).我们管这段内存叫做并不是说它用了堆数据结构,而是这就是一个约定俗成的叫法,没啥理由..

如何实现一个堆内存分配器也是需要研究的地方,由于不是本篇的主题,这里就不细讲了.

说到这里你也就明白了如下的问题:

  1. 为什么不能在函数中返回静态数组,而需要动态内存分配

    1
    2
    3
    4
    int * get_array(){
    int array[3];
    return array;
    }

    这是因为get_array返回后array所在的栈帧就被回收了,而如果你使用动态内存分配,实际的数组其实在动态内存区域中,不会被栈帧的回收所影响.

  2. 为什么C++不支持VLA(变成数组,C语言支持),所谓的VLA就是栈内数组的长度只有在运行时才能确定,如:

    1
    2
    3
    4
    5
    int val_test(){
    int a;
    scanf("%d",&a);
    int arr[a];
    }

    很明显,这违背了我们讲的上述内存分配模型:运行时才能确定的内存大小应该在分配在堆中,而不能在栈中.

其他的内存模型

静态数据区域

你可能会注意到,并不是所有的变量都是声明在函数内的,如果直接将这样的变量分配到栈中,那么该分到栈的哪个地方呢,答案是哪都不去,编译器会这种类型的数据专门开几个分区如

  1. bss用来存储未初始化的全局变量
  2. datarodata分别用存储可读写以及只读的已初始化的全局变量

字符串字面量

如果你是C语言新手,看到这段代码你可能会迷惑:为什下面这算代码中的str在函数执行完成后不会析构,这是因为在C语言中字符串字面量的内容(在本例中代指Hello world这几个字符 )自身不会存在栈中,而是可能放在.rodata中(具体在哪视编译器而定)。只有指向它的占8bytes的指针str会被放在栈中,因此这段代码是能够正常运行的。

1
2
3
4
const char* get_str(){
const char* str = "Hello world"
return str;
}

完整的内存模型

说了这么多后,我们可以介绍一个完整的程序运行的内存模型了,无论如何请注意:该模型并不是C语言标准的一部分,而是一种较为常见的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌────────────────────┐
│stack frame main
├────────────────────┤
│stack frame 1
├────────────────────┤
│stack frame 2
├────────────────────┤
│... │
├────────────────────┤
│ │
├────────────────────┤
heap(dynamic memory)│
├────────────────────┤
.bss
├────────────────────┤
.data
├────────────────────┤
.rodata
├────────────────────┤
.text
└────────────────────┘

这之中只有.text没有提过,其实.text就是存储编译器指令代码的地方,程序运行就是程序计数器PC在这部分内存中跑来跑去。

ELF的文件结构

说完了可执行文件的内存结构,接下来介绍以下编译后的程序和数据在磁盘中的结构。在Unix(或类)Unix操作系统中,可执行文件(.out或者无后缀),静态(.a)或者动态库(.so)文件都具有相同的文件格式:ELF,全称为Executable and Linkable Format.ELF文件基本上由三个header和一堆section构成。三个header分别为file-headersection-headers以及program-headers.

  1. 和一般的二进制文件一样,File-header就是文件的头部元数据,里面标识了文件最基本的一些元数据,使用readelf -Wh a.out可以读出一个elf文件的file header:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    ELF Header:
    Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
    Class: ELF64
    Data: 2's complement, little endian
    Version: 1 (current)
    OS/ABI: UNIX - System V
    ABI Version: 0
    Type: DYN (Shared object file)
    Machine: Advanced Micro Devices X86-64
    Version: 0x1
    Entry point address: 0x1050
    Start of program headers: 64 (bytes into file)
    Start of section headers: 12600 (bytes into file)
    Flags: 0x0
    Size of this header: 64 (bytes)
    Size of program headers: 56 (bytes)
    Number of program headers: 13
    Size of section headers: 64 (bytes)
    Number of section headers: 27
    Section header string table index: 26

    可以看到,头部包含了魔数,版本号,ABI,机器和指令集等信息。

  2. Section-Header,这部分比较好理解,经过编译(可能还有链接)后程序由多个section组成,比如.text,.rodata等,注意这些section和第一节提到的内存布局有一定区别,当然也有相当大的联系.而Section-Header则定义了这些section的级别信息,如果位置,大小,权限等等。使用readelf -WS a.out可以读出a.out的Section-Header等信息,因此我们可以说Section-Header定义了section在磁盘中的格式

    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
    33
    34
    35
    36
    There are 27 section headers, starting at offset 0x3138:

    Section Headers:
    [Nr] Name Type Address Off Size ES Flg Lk Inf Al
    [ 0] NULL 0000000000000000 000000 000000 00 0 0 0
    [ 1] .interp PROGBITS 0000000000000318 000318 00001c 00 A 0 0 1
    [ 2] .note.gnu.property NOTE 0000000000000338 000338 000020 00 A 0 0 8
    [ 3] .note.gnu.build-id NOTE 0000000000000358 000358 000024 00 A 0 0 4
    [ 4] .note.ABI-tag NOTE 000000000000037c 00037c 000020 00 A 0 0 4
    [ 5] .gnu.hash GNU_HASH 00000000000003a0 0003a0 000024 00 A 6 0 8
    [ 6] .dynsym DYNSYM 00000000000003c8 0003c8 000090 18 A 7 1 8
    [ 7] .dynstr STRTAB 0000000000000458 000458 00007d 00 A 0 0 1
    [ 8] .gnu.version VERSYM 00000000000004d6 0004d6 00000c 02 A 6 0 2
    [ 9] .gnu.version_r VERNEED 00000000000004e8 0004e8 000020 00 A 7 1 8
    [10] .rela.dyn RELA 0000000000000508 000508 0000c0 18 A 6 0 8
    [11] .init PROGBITS 0000000000001000 001000 00001b 00 AX 0 0 4
    [12] .plt PROGBITS 0000000000001020 001020 000010 10 AX 0 0 16
    [13] .plt.got PROGBITS 0000000000001030 001030 000010 10 AX 0 0 16
    [14] .text PROGBITS 0000000000001040 001040 000175 00 AX 0 0 16
    [15] .fini PROGBITS 00000000000011b8 0011b8 00000d 00 AX 0 0 4
    [16] .rodata PROGBITS 0000000000002000 002000 000004 04 AM 0 0 4
    [17] .eh_frame_hdr PROGBITS 0000000000002004 002004 00003c 00 A 0 0 4
    [18] .eh_frame PROGBITS 0000000000002040 002040 0000e8 00 A 0 0 8
    [19] .init_array INIT_ARRAY 0000000000003df0 002df0 000008 08 WA 0 0 8
    [20] .fini_array FINI_ARRAY 0000000000003df8 002df8 000008 08 WA 0 0 8
    [21] .dynamic DYNAMIC 0000000000003e00 002e00 0001c0 10 WA 7 0 8
    [22] .got PROGBITS 0000000000003fc0 002fc0 000040 08 WA 0 0 8
    [23] .data PROGBITS 0000000000004000 003000 000010 00 WA 0 0 8
    [24] .bss NOBITS 0000000000004010 003010 000008 00 WA 0 0 1
    [25] .comment PROGBITS 0000000000000000 003010 00002b 01 MS 0 0 1
    [26] .shstrtab STRTAB 0000000000000000 00303b 0000fc 00 0 0 1
    Key to Flags:
    W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
    L (link order), O (extra OS processing required), G (group), T (TLS),
    C (compressed), x (unknown), o (OS specific), E (exclude),
    l (large), p (processor specific)

    注意这里有相当多的section

  3. Program Header,这部分是由编译期和链接器共同生成的,它定义了一个可执行文件被加载到内存后的布局,也就是说第一节中的内存布局就是由这个Header定义的,使用readelf -Wl a.out可以读出a.out的内存布局信息:

    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
    Program Headers:
    Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
    PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8
    INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1
    [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
    LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0005c8 0x0005c8 R 0x1000
    LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x0001c5 0x0001c5 R E 0x1000
    LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x000128 0x000128 R 0x1000
    LOAD 0x002df0 0x0000000000003df0 0x0000000000003df0 0x000220 0x000228 RW 0x1000
    DYNAMIC 0x002e00 0x0000000000003e00 0x0000000000003e00 0x0001c0 0x0001c0 RW 0x8
    NOTE 0x000338 0x0000000000000338 0x0000000000000338 0x000020 0x000020 R 0x8
    NOTE 0x000358 0x0000000000000358 0x0000000000000358 0x000044 0x000044 R 0x4
    GNU_PROPERTY 0x000338 0x0000000000000338 0x0000000000000338 0x000020 0x000020 R 0x8
    GNU_EH_FRAME 0x002004 0x0000000000002004 0x0000000000002004 0x00003c 0x00003c R 0x4
    GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
    GNU_RELRO 0x002df0 0x0000000000003df0 0x0000000000003df0 0x000210 0x000210 R 0x1

    Section to Segment mapping:
    Segment Sections...
    00
    01 .interp
    02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn
    03 .init .plt .plt.got .text .fini
    04 .rodata .eh_frame_hdr .eh_frame
    05 .init_array .fini_array .dynamic .got .data .bss
    06 .dynamic
    07 .note.gnu.property
    08 .note.gnu.build-id .note.ABI-tag
    09 .note.gnu.property
    10 .eh_frame_hdr
    11
    12 .init_array .fini_array .dynamic .got

    该命令的输出有两个部分,第一部分是一个表格,定义了内存中的每个段,下面简单介绍下这个表是什么意思:

    1. Offset: 该段在文件内的偏移量
    2. VirtAddr: 该段被加载到进程地址空间后应当位于的虚拟地址
    3. PhysAddr: 如果目标硬件没有虚拟内存机制而使用的物理内存,那么该字段就定义了一个段被加载到内存中的物理地址

    第二步是是段Segmentsection的对应关系,我们以04段为例,这个段包含了.rodata .eh_frame_hdr .eh_frame这三个secion

在三个headers后就是每个section的具体内容了,其内部的内容有数据,代码以及一些调试信息等等,具体细节我也不够清晰,因此也不分析了。

简单总结下:

  1. elf文件内部有多个section和两个主要的header
  2. section-header记录了每个section的基本信息以及其在文件内的位置和大小
  3. program-header则记录了每个segment和section的对应关系,以及每个segment在内存中排布规则