目录
二、目标文件中的段Section和段表Section Header Tables
三、可执行文件和动态库中的段(segment)程序头表(Program Header Table)
四、符号(Symbol)和符号表(Symbol Table)
ELF文件(Executable and Linking Format)是用在Linux系统下的一种目标文件(object file)存储格式。典型的目标文件有如下3类:
- 可重定向文件(relocatable file)可重定向文件里面包含了代码和数据,用于和其他可重定向文件一起链接形成一个可执行文件或者动态库。符号为ET_REL。就是gcc加-c参数而生成的只编译不链接文件。后期还需要ld来完成重定位过程。平时很少直接用到。
- 可执行文件(executable file) 可执行文件里面包含了可以运行的程序代码。符号为ET_EXEC。
- 动态库文件(shared object file) 动态库文件里面也包含了可用于链接的代码和程序。符号为ET_DYN。它用于2个过程,首先链接器把它和其他可重定向文件、动态库一起链接形成一个可执行文件。然后程序运行时,动态链接器负责在需要时动态加载动态库文件。
由汇编程序和链接编辑器生成的目标文件是可以在CPU上执行的二进制表示程序。目标文件统一遵守ELF格式。但是不同的系统架构,ELF里面的格式和数据处理方式会略有不同,但是基本格式如下:
ELF文件格式根据文件类型不同有不同的组成方式。可重定向文件的格式基本由ELF Header、Sections、Section Header Table组成。可执行文件的格式基本由ELF Header、Segments、Program Header Table组成。 其中可重定向文件中的节(Section)和可执行文件中的段(Segment)都是存储了程序的代码部分、数据部分等,区别是可执行目标文件中的某个段就是结合了很多可重定向目标文件中的相关节。如下图所示:
所以平时工作中,很多中国程序员并不会过多区分section和segment,基本都称作段。甚至很多教材中也不做过多区分。本章介绍中也不会过多区分他们
一、ELF文件头
ELF文件头描述了一个目标文件的组织,是对文件基本信息的描述,包括字的大小和字节次序(尾端)、ELF文件头的大小、目标文件类型、机器类型、节头表/段头表的大小和数量、程序入口点等。ELF文件头必须位于文件的最开始。我们可以使用命令“readelf -h 目标文件”来查看一个可执行目标文件的ELF头信息:
$ readelf -h hello
ELF 头:
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: REL (可重定位文件)
Machine: MIPS R3000
Version: 0x1
入口点地址: 0x0
程序头起点: 0 (bytes into file)
Start of section headers: 440 (bytes into file)
标志: 0x80a20007, noreorder, pic, cpic, gs464, mips64r2
本头的大小: 64 (字节)
程序头大小: 0 (字节)
Number of program headers: 0
节头大小: 64 (字节)
节头数量: 13
字符串表索引节头: 10
参数“-h”就是代表head。Magic行的前4个字节“7f 45 4c 46”就标识了这是一个ELF格式的文件。第5个字节“02”代表文件运行在64位体系结构(“01”代表32位)。第6个字节“01”表示小尾端(02代表大尾端字节序列)。第7个字节“01”代表ELF版本。后面的9个字节未定义。”Type:”显示文件是可重定位类型文件,程序头是可选项,这里程序头大小为0字节,表示没有程序头。
二、目标文件中的段Section和段表Section Header Tables
一个可重定向目标文件中(一般以.o为后缀的文件),除了ELF头、程序头和段表之外,所有的其他信息都包含在节(section)中。我们可以使用readelf 带参数“-S”查看段信息(readelf -S 信息要全面于objdump -h):
# readelf -S hello.o
共有 14 个节头,从偏移量 0x1f8 开始:
节头:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
00000000000000a0 0000000000000000 AX 0 0 16
[ 2] .rela.text RELA 0000000000000000 000006b0
0000000000000078 0000000000000018 12 1 8
[ 3] .data PROGBITS 0000000000000000 000000e0
0000000000000000 0000000000000000 WA 0 0 16
[ 4] .bss NOBITS 0000000000000000 000000e0
0000000000000000 0000000000000000 WA 0 0 16
[ 5] .MIPS.options MIPS_OPTIONS 0000000000000000 000000e0
0000000000000028 0000000000000001 Ao 0 0 8
[ 6] .pdr PROGBITS 0000000000000000 00000108
0000000000000040 0000000000000000 0 0 4
[ 7] .rela.pdr RELA 0000000000000000 00000728
0000000000000030 0000000000000018 12 6 8
[ 8] .mdebug.abi64 PROGBITS 0000000000000000 00000148
0000000000000000 0000000000000000 0 0 1
[ 9] .comment PROGBITS 0000000000000000 00000148
000000000000002e 0000000000000001 MS 0 0 1
[10] .gnu.attributes LOOS+ffffff5 0000000000000000 00000176
0000000000000010 0000000000000000 0 0 1
[11] .shstrtab STRTAB 0000000000000000 00000186
0000000000000070 0000000000000000 0 0 1
[12] .symtab SYMTAB 0000000000000000 00000578
0000000000000120 0000000000000018 13 11 8
[13] .strtab STRTAB 0000000000000000 00000698
0000000000000016 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
上面的信息看出:这个段表里面包含了14个段。每个段的段表结构如下:
(1)段名Name
段名是个字符串,位于“.shstrtab”的段中。每个段的功能都不同,比如.text用于存放代码、.data段用于存放数据等,具体如下表所示。
(2)段类型Type
目前有11中类型。程序中段类型以SHT_开头,如SHT_NULL代表无效段、SHT_SYMTAB代表符号表等,但是readelf中省略了SHT_。具体段类型和含义如下表:
段类型 | 含义 |
SHT_NULL | 无效段,忽略 |
SHT_PROGBITS | 程序段。包括代码段、数据段、调试信息等,对应的段名有.text .data .pdr .common等。 |
SHT_SYMTAB | 符号表段。对应的段名是.symtab,里面存放了链接过程需要的所有符号信息。后面有详细介绍。 |
SHT_STRTAB | 字符串表段。包括.strtab和.shstrtab。.strtab用来保存ELF文件中一般的字符串,如变量名、函数名等。.shstrtab用于保存段表中用到的字符串,如段名Name。 |
SHT_RELA | 重定位表段 (with explicit addends)。存放那些代码段和数据段中有绝对地址引用的相关信息,用于链接器的重定位。对应的段名有.rela.text .rela.data等 |
SHT_HASH | 符号表的哈希表 |
SHT_DYNAMIC | 动态链接信息 |
SHT_NOTE | 提示性信息 |
SHT_NOBITS | 表示该段在文件中无内容,比如.bss段 |
SHT_REL | 重定位表段(without explicit addends)。对应的段名有.rel.text .rel.data等 |
SHT_SHLIB | 保留 |
SHT_DNYSYM | 动态链接的符号表 |
(3)段的虚拟地址Address:
段被加载到进程地址空间中的虚拟地址值;上面的信息读取的是可重定向目标文件mips_book.o,在进程中的位置还不确定,所以所有段的虚拟地址都为0。如果我们读取mips_book.o最终的可执行文件,那么段的虚拟地址都将是有效的,对应地址都是最终在内存中的虚拟地址,如下:
[10] .text PROGBITS 00000001200008f0 000008f0
0000000000000270 0000000000000000 AX 0 0 16
这里地址0x1200008f0就是.text代码段最终在内存中的虚拟地址。
(4)段的偏移Offset:
表示该段在ELF文件中的偏移。上面读取hello.o符号表信息中.text段的Offset值为0x40(64字节),而“readelf -h hello.o”显示hello.o文件的头大小为64字节 (如上面 本头的大小: 64 (字节)),说明hello.o的ELF文件中,头信息之后紧接着就是.text段。
(5)段大小Size:
表示该段的大小。上面信息中.text的段大小为0xa0,即代码段占用160个字节。
(6)段Ensize:
...
(7)段的标志位Flag:
表示该段在进程虚拟空间中的属性,比如是否可读、可写、可执行。比如代码段.text的标志位为AX代表alloc+execute,表示该段需要在内存开辟空间并且权限为可执行。如果你的程序里指针错误的往此段写数据,那么你肯定会收到一个没有写权限的段错误。数据段.data的标志位为WA代表write_alloc,表示该段权限为可写段并且需要在内存开辟空间。
(8)段的链接信息Link Info:
如果段的类型与链接相关,比如重定位段、符号表段等,那么。。。
(9)段地址对齐Align:
如果该段有对地址有要求,那么Align就指定了对齐方式。比如.text段的Align值为16就代表该段在内存的必须以16对齐存放,即存放该段的内存起始地址必须可以被16整除。如果Align其值0和1没有意义。
目标文件的段满足如下条件:
- 每个节(section)都对应一个节头。目标文件中可以存在没有节信息的节头。
- 每个节(section)在目标文件中都占用一个连续的空间,这个空间可以是0。
- 一个目标文件中的多个节(section)不能重叠。一个文件中没有一个字节位于多个节中。
ELF中的各个部分是预定义的,并保存程序和控制信息。这些部分由操作系统使用,对于不同的操作系统具有不同的类型和属性。 下表1列举了ELF中定义的段名和功能介绍:
段名 | 功能描述 |
.text | 常称为代码段,用于存放程序的可执行机器指令 |
.data 和.data1 | 数据段,用于存放程序中已经初始化的数据 |
.rodata和 .rodata1 | 只读数据段,用于存放只读数据,如const类型变量和字符串常量 |
.bss | 用于存放未初始化的全局变量和局部静态变量 |
.common | 用于存放编译器的版本信息 |
.hash | 符号哈希表 |
.dynamic | 动态链接信息 |
.strtab | 字符串表,用来存放变量名、函数名等字符串。 |
.symtab | 符号表,用于保存变量、函数等符号值。 |
.shstrtab | 段名表,用于保存段名信息,如“.text” “.data”等 |
.plt和 .got | 动态链接的跳转表和全局入口表 |
.init 和 .fini | 程序初始化和终结代码段 |
还有一些用于调试的段,比如.debug 、.line 和存放编译器信息的段.note
三、可执行文件和动态库中的段(segment)程序头表(Program Header Table)
可执行文件和动态库(.so)文件是存在段(segment)和程序头表的。前面说过segment可以看作是多个.o文件中的相似段(section)的合并,即一个Segment包含一个或多个属性相似的Section。这里的属性相似更多是指权限,比如链接器会把多个.o文件中的都具有可读可执行的.text和.init段都放在最终可执行文件中的一个Segment段内,这样的好处就是可以更多的节省内存空间。这个原因是由于ELF文件被加载时,是以系统的页长度为单位,如果一个Segment的长度小于一个页大小,那么这个Segment也要占据整个页大小。连接器对具有相同权限的段Section合并到一个段Segment后,就可以尽可能的减少内存碎片。
描述Section属性的结构叫段表(Section Header Table),描述Segment结构的叫程序头(Program Header Table),它 指导系统如何把多个segment段加载到内存虚拟空间。我们可以使用readelf -l来查看程序头表。
# readelf -l hello
Elf 文件类型为 EXEC (可执行文件)
入口点 0x1200008f0
共有 10 个程序头,开始于偏移量64
程序头:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000120000040 0x0000000120000040
0x0000000000000230 0x0000000000000230 R E 8
INTERP 0x0000000000000bd0 0x0000000120000bd0 0x0000000120000bd0
0x000000000000000f 0x000000000000000f R 1
[正在请求程序解释器:/lib64/ld.so.1]
LOAD 0x0000000000000000 0x0000000120000000 0x0000000120000000
0x0000000000000c94 0x0000000000000c94 R E 10000
LOAD 0x0000000000000fe8 0x0000000120010fe8 0x0000000120010fe8
0x00000000000000e8 0x00000000000000f8 RW 10000
DYNAMIC 0x0000000000000400 0x0000000120000400 0x0000000120000400
0x00000000000001f0 0x00000000000001f0 RWE 8
NOTE 0x00000000000003d8 0x00000001200003d8 0x00000001200003d8
0x0000000000000024 0x0000000000000024 R 4
NOTE 0x0000000000000c74 0x0000000120000c74 0x0000000120000c74
0x0000000000000020 0x0000000000000020 R 4
GNU_EH_FRAME 0x0000000000000be0 0x0000000120000be0 0x0000000120000be0
0x000000000000001c 0x000000000000001c R 4
GNU_RELRO 0x0000000000000fe8 0x0000000120010fe8 0x0000000120010fe8
0x0000000000000018 0x0000000000000018 R 1
NULL 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 8
Section to Segment mapping:
段节...
00
01 .interp
02 .MIPS.options .note.gnu.build-id .dynamic .hash .dynsym .dynstr .gnu.version .gnu.version_r .init .text .MIPS.stubs .fini .rodata .interp .eh_frame_hdr .eh_frame .note.ABI-tag
03 .init_array .fini_array .jcr .data .rld_map .got .sdata .bss
04 .dynamic
05 .note.gnu.build-id
06 .note.ABI-tag
07 .eh_frame_hdr
08 .init_array .fini_array .jcr
09
从上面可以看出,程序表中包含10个Segment(段节从00-09)。每个Segment包含如下属性:
(1)Segment类型Type
段类型分为LOAD、DYNAMIC、NOTE、NULL等。LOAD类型的Segment是需要被加载到内存的。
(2)Segment偏移Offset
表示此Segment在ELF文件中的偏移。
(3)Segment虚拟地址VirtAddr
表示此Segment在进程内存中的起始地址。
(4)Segment物理地址PhysAddr
?
(5)Segment在文件中的大小FileSiz
此Segment在ELF文件中所占空间的长度。
(6)Segment在内存中的大小MemSiz
此Segment在进程内存中所占的长度。对于代码段,此值和FileSiz相等,但是对于数据段,此值可能大于FileSiz。
(7)Segment权限属性Flags
权限属性包括可读R、可写W和可执行X。
(8)Segment对齐属性Align
只此Segment在内存加载时的对齐方式。其值为2的Align次方。比如上面的Align值为4,那么对齐要求就是16。
从上面也可以看出。所有相同属性的Section被归类到一个Segment中,比如:
03 .init_array .fini_array .jcr .data .rld_map .got .sdata .bss
四、符号(Symbol)和符号表(Symbol Table)
在链接器中,函数和变量统称为符号(Symbol),函数名和变量名称为符号名(Symbol Name)。符合可以分为如下种类:
- 局部符号:类似于函数内部定义的静态局部变量,类似于下面的变量a,b。这类符号只在编译单元内部可见。
int fun(){
static int a,b;
}
- 全局符号:定义在本目标文件的全局符号,可以被其他文件引用。例如下面的global_var 、main:
int global_var;
int main(int arg ,char* arg[]){}
- 外部符号(External Symbol):在本目标文件中引用的全局符号。比如我们经常使用的printf函数,它是定义在模块libc内的符号。
- 段名:由编译器产生的.text、.data等段名符号。
每一个可重定向目标文件中都会有一个名为.symtab的Section用来存放符号表(Symbol Table)。如果是可执行文件,还会有一个段.dynsym存放动态符号表。符号表里面记录了目标文件中所有用到的符号和符合信息。我们可以使用“readelf -s 文件名”来查看一个目标文件中的符号表信息:
从上图对应的C代码如下:
#include <stdio.h>
char* str="HELLO";
int global_var;
int main(){
int a = 0,b = 0;
static int static_a=0;
printf("%s %d \n",str,a+b+static_a);
return 0;
}
从上图可以看出,hello.o文件中共有16个符号,Num从0到15。每个符号都有如下属性:
- 符号名(Name):如上图的最后一列。hello.c、static_a、str、main和printf。没有名字的符号是段,从列Type的SECTION可以看出。
- 符号值(Value):每个符号都有一个对应的值,如果此符号是一个函数、变量,那么符号的值就是函数和变量的地址。上面hello.o是未做重定向的ELF,所以符号值都为0。可执行文件中,符号值可能是符号的虚拟地址、符号所在函数偏移等。
- 符号大小(Size):对于变量,符号大小就是数据类型的大小,比如上面的static_a是int类型,所以size为4。对于函数,符号大小就是该函数中所有指令的字节数,例如main函数中c程序对应的MIIPS指令行数为33行,每条指令占4个字节,所以main符号的的size为136。
- 符号类型和绑定信息(Type & Bind):符号类型(Type)分为如下种类:
NOTYPE: 未知符号类型
OBJECT: 表示该符号是个数据对象,比如变量、数组等。
FUNC: 表示该符号是个函数或其他可执行代码。
SECTION:表示该符号为一个段。
FILE: 该符号表示文件名。
符号绑定信息分为如下种类:
LOCAL: 局部符号。
GLOBAL:全局符号,如上面定义的全局变量global_var、函数main、printf
WEAK: 弱引用符号。在这里没有体现。对于C/C++语言,编译器默认函数和已经初始化的全局变量为强符号。
而未初始化的全局变量和使用__attribute__((weak))定义的变量为弱符号。
- 符号所在段(Ndx):如果符号定义在本目标文件中,那么这个成员表示符号所在的段在段表中的下标。比如静态变量static_a所在的段标为4。Ndx还有如下3中特殊值:
UND:就表示这是个外部符号,不再本目标文件中定义,如printf。
ABS:表示该符号包含了一个绝对值,比如符号hello.c。
COM:表示该符号是个未初始化的全局符号。
五、可执行文件与进程虚拟空间映射关系
系统启动一个进程后,加载ELF文件的方式如下图所示:
从上图可以看出,ELF格式的文件在映射入进程时,ELF头和一个调试信息是不再需要的。只需要加载部分段(Segment)到内存,具体就是加载类型为LOAD的段即可。VMA代表Virtual Memorry Address,即虚拟地址空间。
实际开发中,我们还可以通过查看“/proc/pid/maps”节点来查看一个进程的虚拟空间分布:
其中第一列为VMA的地址范围。第二列为WMA的权限,分为可读r、可写w、可执行x、私有p、可共享s。第三列为WMA对应的Segment在映射文件中的偏移。第四列表示映像文件所在设备的主设备号和次设备号。第五列表示映像文件的节点号。最后一列是映像文件的路径。
上面图中显示此进程共占用15个VMA。前两个VMA(12000000-120004000 、120010000-120014000)映射到ELF文件的两个Segment。程序用到的动态库libc-2.20.so被映射到了接下来的4个VMA。接下来的2个VMA区没有显示映像文件的路径,实际上是堆区(Heap),程序中可以通过类似malloc函数来申请使用。 ld-2.20.so是Linux下的动态链接器,负责libc-2.20.so中和绝对地址相关的代码和数据的动态重定向过程,它占据了2个VMA。后面的2个VMA被vvar和vdso模块占用,这两个模块是内核映射出来的2个区域,用于程序可以绕过系统调用syscall而直接和内核快速通信的一些接口,比如在大部分的体系架构中,gettimeofday都是通过vdso直接实现来提高运行速度。
六、龙芯电脑虚拟地址空间和页大小
在龙芯3A/3B处理器上,进程执行在一个40bit的虚拟地址空间,其地址可以从0到240-1。内存管理硬件(Memory Management Unit 简称MMU)负责把虚拟地址转换成物理地址,向程序隐藏物理地址,从而使得进程可以运行在系统的任何空间。进程可以使用的空间通常按段(Segments)来分割,典型的进程包括至少3个段空间,分别是代码段(也叫text段)、数据段(也叫data段)和栈空间(也叫stack)。有动态链接的进程可以动态创建更多的段空间。
内存是按照页组织的,页是Linux系统下内存管理的最小单元。不同的系统页大小可能不同,这取决于处理器、MMU和系统配置。我们可以通过如下命令查看当前系统的页大小:
$ getconf PAGE_SIZE
16384
上面getconf 命令显示当前系统的页大小是16384Byte ,也就是16K。