ELF格式与程序运行的内存模型
最近在复习一些操作系统相关的知识,而操作系统说白了就是为程序运行提供的环境的,和硬件直接接触的软件.现代操作系统要为程序提供一种虚拟环境,包括但不限于一个独立的地址空间,一个独立的虚拟CPU以及其他一些东西(如系统调用等).今天正好借这个机会来梳理一下程序的运行环境(主要是内存模型).
下面的所有例子都已C语言为例,但是请注意,这只是一种较为场景的实现,而不是C语言标准的一部分
程序运行的内存模型
静态内存和动态内存
我们先从最简单的C语言程序开始,这个程序甚至没有main函数:
1 |
|
这里我们仅从内存的角度来考虑问题(即不考虑编译器的寄存器优化的影响),这段代码在内存中的运行时可能是下面这样的:
1 |
|
然后我们再稍微来点更复杂的例子:
1 |
|
它在内存中的分布可能是这样的:
1 |
|
不难发现一点,在上述的示例代码中,程序占用的内存空间是编译期可以算出来的,如第一个例子a
和b
变量就占据了固定的8bytes,而第一个例子一个数组+3个指针一共占据了24bytes.到此为止,我们不难想到,为了节省运行时的开销,如果我们让编译器在编译的时候就计算出来程序的内存布局(也就是怎么排布,占多少个字节).
但遗憾的是,不是所有场景下我们都编译期间知道内存大小,再来看个例子:
1 |
|
如果继续使用我们上面的模型,那么它们在内存中的分布可能是这样的(让我们暂时忽略函数调用):
1 |
|
上面这个例子就要占用4 + x * 4
字节的空间,而且这个空间无法在编译期计算得出,因为x
只有等到用户输入才知道,因此也就不能使用上述的编译期间计算的模型了.为了解决这个问题,现在普遍采用的是静态+动态两种方法同时使用的内存分配方案:
- 对于程序执行流中编译期能计算出来的大小,我们就直接在编译期分配好(也就是告诉操作系统需要多少内存),暂时称这算为静态内存区域
- 对于只有运行时才能算出来的大小,就使用一个额外的动态内存区域在运行时分配内存,而静态内存区域只需要存储指向这段内存的一个指针即可(请注意指针是能编译期算出来的)
上面的说法有点抽象,还是以上面的代码举个例子,在使用静态+动态内存分配后,我们就可以使用如下的内存模型:
1 |
|
接下来来介绍所谓的静态内存区域和动态内存区域到底是怎样的.
静态内存区域与栈
上述说的静态内存区域其实就是程序的运行栈至于为什么要叫栈,这就是程序的执行特征来决定的了:程序在运行的时候有大量的函数调用行为,而栈是用来描述这一行为最合适的数据结构:
下面有一个简单的例子:
1 |
|
这里我们运行a()
,当函数调用链到达d的时候,栈空间的排布是如下这样的:
1 |
|
每当有一个函数被调用的时候,编译器就将该函数的调用上下文(包括传入的参数,返回值,栈指针等信息)入栈(往下移动栈指针),然后开始分配该函数所需要的经验静态内存空间;每当一个函数返回的时候, 编译器就将当前栈分配的静态内存空间和调用上下文等信息回收(往上移动栈指针即可),这样就形成了完整的函数但用过程. 这里将函数的调用上下文和其分配的静态内存空间称之为栈帧,上述过程也可以简化成这样:
- 函数调用就是往栈中推入栈帧
- 函数的返回就是往栈中回收栈帧
至于调用上下文,它会存储部分调用函数传递给当前函数的参数(还有部分参数直接写在寄存器中,不经过栈内存,具体看相关ABI),当前的栈指针,预分配的返回值等信息.
动态内存区域与堆
动态内存区域就是由操作系统管理的一块空闲的内存空间,当一个用户(进程)需要的时候,可以通过特定的系统调用(如经C语言封装的malloc
)来分配一块指定大小的内存,并返回指向该内存的指针作为句柄.这段内存被申请后,就无法被其他的用户使用了,直到该用户手动告诉操作系统这段内存自己不需要了(表现在C语言内就是free
函数).我们管这段内存叫做堆并不是说它用了堆数据结构,而是这就是一个约定俗成的叫法,没啥理由..
如何实现一个堆内存分配器也是需要研究的地方,由于不是本篇的主题,这里就不细讲了.
说到这里你也就明白了如下的问题:
为什么不能在函数中返回静态数组,而需要动态内存分配
1
2
3
4int * get_array(){
int array[3];
return array;
}这是因为
get_array
返回后array
所在的栈帧就被回收了,而如果你使用动态内存分配,实际的数组其实在动态内存区域中,不会被栈帧的回收所影响.为什么C++不支持VLA(变成数组,C语言支持),所谓的
VLA
就是栈内数组的长度只有在运行时才能确定,如:1
2
3
4
5int val_test(){
int a;
scanf("%d",&a);
int arr[a];
}很明显,这违背了我们讲的上述内存分配模型:运行时才能确定的内存大小应该在分配在堆中,而不能在栈中.
其他的内存模型
静态数据区域
你可能会注意到,并不是所有的变量都是声明在函数内的,如果直接将这样的变量分配到栈中,那么该分到栈的哪个地方呢,答案是哪都不去,编译器会这种类型的数据专门开几个分区如
bss
用来存储未初始化的全局变量data
和rodata
分别用存储可读写以及只读的已初始化的全局变量
字符串字面量
如果你是C语言新手,看到这段代码你可能会迷惑:为什下面这算代码中的str
在函数执行完成后不会析构,这是因为在C语言中字符串字面量的内容(在本例中代指Hello world
这几个字符 )自身不会存在栈中,而是可能放在.rodata
中(具体在哪视编译器而定)。只有指向它的占8bytes的指针str
会被放在栈中,因此这段代码是能够正常运行的。
1 |
|
完整的内存模型
说了这么多后,我们可以介绍一个完整的程序运行的内存模型了,无论如何请注意:该模型并不是C语言标准的一部分,而是一种较为常见的实现
1 |
|
这之中只有.text
没有提过,其实.text
就是存储编译器指令代码的地方,程序运行就是程序计数器PC
在这部分内存中跑来跑去。
ELF的文件结构
说完了可执行文件的内存结构,接下来介绍以下编译后的程序和数据在磁盘中的结构。在Unix(或类)Unix操作系统中,可执行文件(.out
或者无后缀),静态(.a
)或者动态库(.so
)文件都具有相同的文件格式:ELF,全称为Executable and Linkable Format
.ELF文件基本上由三个header和一堆section
构成。三个header分别为file-header
,section-headers
以及program-headers
.
和一般的二进制文件一样,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
20ELF 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,机器和指令集等信息。
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
36There 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
。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
32Program 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该命令的输出有两个部分,第一部分是一个表格,定义了内存中的每个段,下面简单介绍下这个表是什么意思:
- Offset: 该段在文件内的偏移量
- VirtAddr: 该段被加载到进程地址空间后应当位于的虚拟地址
- PhysAddr: 如果目标硬件没有虚拟内存机制而使用的物理内存,那么该字段就定义了一个段被加载到内存中的物理地址
第二步是是段
Segment
和section
的对应关系,我们以04
段为例,这个段包含了.rodata .eh_frame_hdr .eh_frame
这三个secion
在三个headers后就是每个section的具体内容了,其内部的内容有数据,代码以及一些调试信息等等,具体细节我也不够清晰,因此也不分析了。
简单总结下:
elf
文件内部有多个section和两个主要的header- section-header记录了每个section的基本信息以及其在文件内的位置和大小
- program-header则记录了每个segment和section的对应关系,以及每个segment在内存中排布规则