在Linux系统中,当我们编译一个简单的C程序时,编译器会生成一个名为ELF(Executable and Linkable Format)的特殊格式文件。这种文件格式是Linux系统中可执行文件、目标文件和共享库的标准格式。理解ELF文件的结构和工作原理,对于深入掌握程序从编译到运行的完整生命周期至关重要。
ELF文件主要分为四种类型,每种类型在程序构建和运行过程中扮演着不同角色:
可重定位文件(Relocatable File):通常以.o为扩展名,包含代码和数据,可以被链接器用来创建可执行文件或共享库。这类文件中的符号引用还未被解析,地址也是相对的。
可执行文件(Executable File):可以直接被操作系统加载执行的文件,所有符号引用已被解析,具有明确的入口点。
共享目标文件(Shared Object File):即动态链接库,以.so为扩展名,包含可在运行时被动态链接的代码和数据。
内核转储文件(Core Dump File):当程序异常终止时,系统会生成这种文件,包含进程终止时的内存状态。
ELF文件由四个主要部分组成,每个部分都有其特定功能:
ELF头(ELF Header):位于文件开头,包含文件的魔数、目标机器类型、版本信息等元数据,以及程序头表和节头表的位置信息。
程序头表(Program Header Table):描述如何将文件映射到进程的虚拟地址空间,每个表项对应一个段(Segment),包含段的类型、偏移量、虚拟地址、物理地址、文件大小、内存大小、标志位和对齐方式等信息。
节头表(Section Header Table):包含文件中所有节的描述信息,每个节头表项描述一个节(Section)的名称、类型、标志、虚拟地址、文件偏移、大小、链接信息、对齐方式等。
节(Sections):ELF文件的实际内容部分,包含代码、数据、符号表、重定位信息等。常见的节包括:
.text:可执行代码.data:已初始化的全局变量和静态变量.bss:未初始化的全局变量和静态变量.rodata:只读数据.symtab:符号表.strtab:字符串表.rel.text:代码重定位信息.rel.data:数据重定位信息注意:节(Section)是链接视图的基本单位,而段(Segment)是执行视图的基本单位。一个段可以包含多个节,链接器主要关注节,而加载器主要关注段。
Linux提供了多个工具来查看和分析ELF文件的结构:
readelf:最全面的ELF文件分析工具,可以查看ELF头、程序头表、节头表等。
bash复制readelf -h <file> # 查看ELF头
readelf -l <file> # 查看程序头表
readelf -S <file> # 查看节头表
objdump:可以反汇编代码、查看节内容等。
bash复制objdump -d <file> # 反汇编代码段
objdump -x <file> # 显示所有头信息
nm:查看符号表。
bash复制nm <file> # 显示符号表
file:快速查看文件类型。
bash复制file <file> # 显示文件类型信息
ELF头是ELF文件的"路线图",它描述了文件的基本属性和组织结构。通过readelf -h命令可以查看ELF头的详细信息:
bash复制$ readelf -h main
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 (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1060
Start of program headers: 64 (bytes into file)
Start of section headers: 13976 (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: 31
Section header string table index: 30
关键字段解析:
0x7f 'E' 'L' 'F',用于识别ELF文件。理解ELF头信息对于分析文件属性、调试程序以及理解程序加载过程都非常重要。在后续章节中,我们将深入探讨ELF文件如何从编译到加载运行的完整过程。
ELF文件提供了两种不同的视角来组织其内容:链接视图和执行视图。这两种视图分别服务于不同的目的,理解它们的区别和联系对于掌握ELF文件的工作机制至关重要。
链接视图是编译器、汇编器和链接器使用的视角,它以节(Section)为基本单位组织文件内容。节头表(Section Header Table)描述了链接视图的结构。
代码相关节:
.text:包含程序的可执行指令.rodata:只读数据,如字符串常量.plt:过程链接表,用于动态链接.init和.fini:程序初始化和终止代码数据相关节:
.data:已初始化的全局变量和静态变量.bss:未初始化的全局变量和静态变量(在文件中不占空间).got:全局偏移表,用于动态链接.dynamic:动态链接信息调试与符号信息节:
.symtab:符号表.strtab:字符串表.debug:调试信息.line:行号信息.comment:编译器版本信息使用readelf -S命令可以查看ELF文件的节头表:
bash复制$ readelf -S main
There are 31 section headers, starting at offset 0x3698:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 0000000000000318 00000318
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.gnu.property NOTE 0000000000000338 00000338
0000000000000030 0000000000000000 A 0 0 8
[ 3] .note.gnu.build-id NOTE 0000000000000368 00000368
0000000000000024 0000000000000000 A 0 0 4
[ 4] .note.ABI-tag NOTE 000000000000038c 0000038c
0000000000000020 0000000000000000 A 0 0 4
[ 5] .gnu.hash GNU_HASH 00000000000003b0 000003b0
0000000000000024 0000000000000000 A 6 0 8
[ 6] .dynsym DYNSYM 00000000000003d8 000003d8
00000000000000a8 0000000000000018 A 7 1 8
[ 7] .dynstr STRTAB 0000000000000480 00000480
000000000000008d 0000000000000000 A 0 0 1
[ 8] .gnu.version VERSYM 000000000000050e 0000050e
000000000000000e 0000000000000002 A 6 0 2
[ 9] .gnu.version_r VERNEED 0000000000000520 00000520
0000000000000030 0000000000000000 A 7 1 8
[10] .rela.dyn RELA 0000000000000550 00000550
00000000000000c0 0000000000000018 A 6 0 8
[11] .rela.plt RELA 0000000000000610 00000610
0000000000000018 0000000000000018 AI 6 24 8
[12] .init PROGBITS 0000000000001000 00001000
000000000000001b 0000000000000000 AX 0 0 4
[13] .plt PROGBITS 0000000000001020 00001020
0000000000000020 0000000000000010 AX 0 0 16
[14] .plt.got PROGBITS 0000000000001040 00001040
0000000000000010 0000000000000010 AX 0 0 16
[15] .plt.sec PROGBITS 0000000000001050 00001050
0000000000000010 0000000000000010 AX 0 0 16
[16] .text PROGBITS 0000000000001060 00001060
0000000000000107 0000000000000000 AX 0 0 16
[17] .fini PROGBITS 0000000000001168 00001168
000000000000000d 0000000000000000 AX 0 0 4
[18] .rodata PROGBITS 0000000000002000 00002000
0000000000000010 0000000000000000 A 0 0 4
[19] .eh_frame_hdr PROGBITS 0000000000002010 00002010
0000000000000034 0000000000000000 A 0 0 4
[20] .eh_frame PROGBITS 0000000000002048 00002048
00000000000000ac 0000000000000000 A 0 0 8
[21] .init_array INIT_ARRAY 0000000000003db8 00002db8
0000000000000008 0000000000000008 WA 0 0 8
[22] .fini_array FINI_ARRAY 0000000000003dc0 00002dc0
0000000000000008 0000000000000008 WA 0 0 8
[23] .dynamic DYNAMIC 0000000000003dc8 00002dc8
00000000000001f0 0000000000000010 WA 7 0 8
[24] .got PROGBITS 0000000000003fb8 00002fb8
0000000000000048 0000000000000008 WA 0 0 8
[25] .data PROGBITS 0000000000004000 00003000
0000000000000010 0000000000000000 WA 0 0 8
[26] .bss NOBITS 0000000000004010 00003010
0000000000000008 0000000000000000 WA 0 0 1
[27] .comment PROGBITS 0000000000000000 00003010
000000000000002b 0000000000000001 MS 0 0 1
[28] .symtab SYMTAB 0000000000000000 00003040
0000000000000360 0000000000000018 29 18 8
[29] .strtab STRTAB 0000000000000000 000033a0
00000000000001da 0000000000000000 0 0 1
[30] .shstrtab STRTAB 0000000000000000 0000357a
000000000000011a 0000000000000000 0 0 1
执行视图是操作系统加载器使用的视角,它以段(Segment)为基本单位组织文件内容。程序头表(Program Header Table)描述了执行视图的结构。
使用readelf -l命令可以查看ELF文件的程序头表:
bash复制$ readelf -l main
Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1060
There are 13 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002d8 0x00000000000002d8 R 0x8
INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000628 0x0000000000000628 R 0x1000
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x0000000000000175 0x0000000000000175 R E 0x1000
LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000
0x00000000000000f4 0x00000000000000f4 R 0x1000
LOAD 0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
0x0000000000000258 0x0000000000000260 RW 0x1000
DYNAMIC 0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc8
0x00000000000001f0 0x00000000000001f0 RW 0x8
NOTE 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000030 0x0000000000000030 R 0x8
NOTE 0x0000000000000368 0x0000000000000368 0x0000000000000368
0x0000000000000044 0x0000000000000044 R 0x4
GNU_PROPERTY 0x0000000000000338 0x0000000000000338 0x0000000000000338
0x0000000000000030 0x0000000000000030 R 0x8
GNU_EH_FRAME 0x0000000000002010 0x0000000000002010 0x0000000000002010
0x0000000000000034 0x0000000000000034 R 0x4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
0x0000000000000248 0x0000000000000248 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 .rela.plt
03 .init .plt .plt.got .plt.sec .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
从程序头表的"Section to Segment mapping"部分可以看到,一个段通常包含多个节。这种合并主要基于节的属性和功能:
.text、.init、.fini、.plt等节,具有读和执行权限。.rodata、.eh_frame等节,具有只读权限。.data、.bss、.got等节,具有读写权限。.dynamic、.dynsym、.dynstr等节,提供动态链接信息。两种视图的存在是为了满足不同阶段的需求:
链接阶段:需要精细地组织各种类型的数据和代码,因此以节为单位更为合适。链接器需要处理符号解析、重定位等复杂操作,节提供了足够的灵活性。
执行阶段:操作系统加载器关注的是如何高效地将文件内容映射到进程地址空间,并设置适当的内存保护属性。以段为单位可以减少内存页的浪费,提高加载效率。
关键点:节是链接器的视角,段是加载器的视角。链接器生成节,加载器使用段。一个段可以包含多个属性相似的节,这种合并减少了内存碎片,提高了加载效率。
理解这两种视图的区别和联系,对于分析ELF文件、调试程序以及理解程序加载过程都非常重要。在下一章中,我们将探讨ELF文件如何从编译生成到最终被加载执行的完整过程。
静态链接是将多个目标文件(.o)和静态库(.a)合并成一个可执行文件的过程。这一过程不仅简单地将各个模块拼接在一起,还涉及到复杂的地址分配、符号解析和重定位操作。理解静态链接的机制对于解决构建过程中的问题至关重要。
静态链接过程可以分为以下几个主要步骤:
当编译器生成目标文件(.o)时,代码和数据被组织在不同的节中。链接器需要将这些节从多个输入文件合并到输出文件中。
一个简单的目标文件通常包含以下节:
.text:程序代码.data:已初始化的全局变量和静态变量.bss:未初始化的全局变量和静态变量(在文件中不占空间).rodata:只读数据.symtab:符号表.rel.text和.rel.data:重定位信息链接器采用"相似节合并"的策略,将来自不同目标文件的相同类型的节合并到输出文件的相应节中。例如:
.text节合并到输出文件的.text节.data节合并到输出文件的.data节这种合并方式使得输出文件的结构清晰,便于加载器处理。
链接器在合并节的同时,需要为每个节和每个符号分配运行时地址。
链接器通常采用两步地址分配策略:
符号解析是链接过程中的关键步骤,其目的是将每个符号引用与一个符号定义关联起来。这个过程包括:
常见问题:如果链接器找不到某个符号的定义,会报告"undefined reference"错误;如果同一个符号被多次定义,可能会报告"multiple definition"错误。
重定位是链接过程中最复杂的部分,它修正代码和数据中对符号的引用,使其指向正确的运行时地址。
目标文件中包含重定位表(如.rel.text和.rel.data),记录了所有需要重定位的位置。每个重定位条目包含:
常见的重定位类型包括:
对于每种重定位类型,链接器使用特定的公式计算修正值。例如:
对于R_X86_64_PC32(相对地址重定位):
code复制修正值 = 符号地址 - 重定位地址
对于R_X86_64_64(绝对地址重定位):
code复制修正值 = 符号地址
让我们通过一个简单的例子来观察静态链接的过程。假设有两个C文件:
c复制/* main.c */
extern void hello();
int main() {
hello();
return 0;
}
c复制/* hello.c */
#include <stdio.h>
void hello() {
printf("Hello, World!\n");
}
bash复制gcc -c main.c -o main.o
gcc -c hello.c -o hello.o
bash复制$ nm main.o
U hello
0000000000000000 T main
$ nm hello.o
0000000000000000 T hello
U puts
可以看到,main.o中hello是未定义的(U),而hello.o中hello是已定义的(T),但puts是未定义的。
bash复制gcc main.o hello.o -o hello
bash复制$ nm hello | grep -E 'main|hello|puts'
0000000000001149 T hello
0000000000004010 B __bss_start
0000000000004010 b completed.0
0000000000001149 t hello
000000000000115d T main
U puts@@GLIBC_2.2.5
现在,main和hello都有了确定的地址,但puts仍然是未定义的,因为它来自动态库。
静态库(.a)实际上是一组目标文件的归档文件。链接器在处理静态库时,只提取那些包含被引用符号的目标文件。
bash复制ar rcs libhello.a hello.o
bash复制gcc main.o -L. -lhello -o hello
优点:
缺点:
静态链接适用于小型工具或需要高度独立性的环境,但在现代操作系统中,动态链接更为常见,因为它能更好地节省磁盘空间和内存,并支持库的独立更新。
在下一章中,我们将探讨更为复杂的动态链接机制,这是现代操作系统和应用程序普遍采用的链接方式。
动态链接是现代操作系统中广泛使用的链接方式,它解决了静态链接在内存使用和库更新方面的问题。动态链接将链接过程推迟到程序运行时,通过共享库机制实现代码的高效复用。
链接时机:
库代码处理:
内存使用:
动态链接的过程涉及多个组件协同工作,主要包括:
/lib64/ld-linux-x86-64.so.2,负责加载和链接共享库.interp节中指定.so文件,包含可共享的代码和数据.interp节指定的动态链接器路径_start)使用ldd命令可以查看程序依赖的动态库:
bash复制$ ldd hello
linux-vdso.so.1 (0x00007ffd45df0000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8c3a200000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8c3a3f0000)
为了实现共享库代码可以被多个进程共享,动态库需要使用地址无关代码(Position Independent Code, PIC)。
使用-fPIC选项生成位置无关代码:
bash复制gcc -fPIC -c hello.c -o hello.o
gcc -shared hello.o -o libhello.so
动态链接通过GOT和PLT实现函数和变量的运行时解析。
GOT(Global Offset Table)是一个指针数组,用于解决数据访问和函数调用的地址问题:
GOT位于数据段,因此每个进程有自己的副本,可以在加载时被修改。
PLT(Procedure Linkage Table)是一小段代码,用于实现延迟绑定(Lazy Binding):
PLT位于代码段,因此可以被多个进程共享。
动态链接器按照以下顺序查找共享库:
LD_LIBRARY_PATH环境变量指定的路径/etc/ld.so.cache中缓存的路径/lib、/usr/lib等几个常用的环境变量:
LD_LIBRARY_PATH:指定额外的库搜索路径LD_PRELOAD:预加载指定的库,可以覆盖函数LD_DEBUG:启用动态链接调试输出例如,查看详细的动态链接过程:
bash复制LD_DEBUG=files ldd hello
bash复制gcc -fPIC -shared -o libhello.so hello.c
bash复制gcc main.o -L. -lhello -o hello
bash复制export LD_LIBRARY_PATH=.
./hello
Linux提供了dlopen系列函数,支持运行时动态加载库:
c复制#include <dlfcn.h>
void* handle = dlopen("libhello.so", RTLD_LAZY);
if (handle) {
void (*hello)() = dlsym(handle, "hello");
if (hello) hello();
dlclose(handle);
}
编译时需要链接-ldl:
bash复制gcc -o