欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 教育 > 幼教 > Linux程序诞生记:ELF的链接、加载与执行三部曲

Linux程序诞生记:ELF的链接、加载与执行三部曲

2025/9/23 5:24:02 来源:https://blog.csdn.net/2301_80758704/article/details/146965843  浏览:    关键词:Linux程序诞生记:ELF的链接、加载与执行三部曲

1. ELF文件

要理解编译链链接的细节,我们不得不了解⼀下ELF⽂件。其实有以下四种⽂件其实都是ELF⽂件:
  • 可重定位目标文件(Relocatable File):即 xxx.o ⽂件。包含适合于与其他⽬标⽂件链接来创建可执⾏⽂件或者共享⽬标⽂件的代码和数据。
  • 共享目标文件Shared Object File) :即 xxx.so⽂件。
  • 可执行文件Executable File) :即可执⾏程序。
  • 内核转储(core dumps) ,存放当前进程的执⾏上下⽂,⽤于dump信号触发。

 ⼀个ELF⽂件由以下四部分组成:

  • ELF Header(ELF头):描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂件的其他部分。
  • Program Header Table(程序头表):列举了所有有效的段(segments)和他们的属性。表⾥记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,需要段表的描述信息,才能把他们每个段分割开。
  • Section(节):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。
  • Section Header Table(节头表):包含对节(sections)的描述

最常见的节:

  • 代码节(.text):⽤于保存机器指令,是程序的主要执⾏部分。
  • 数据节(.data):保存已初始化的全局变量和局部静态变量。

2. ELF从形成到加载轮廓

2.1. ELF形成可执行

Step1:讲多份C/C++源代码编译形成.o文件。

Step2:将多份.o文件链接形成可执行程序的.o文件。

(Segment的形成实在可执行文件加载到内存时形成的)

2.2. ELF可执行文件的加载

  • ⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment
  • 合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间
  • 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
  • 很显然,这个合并⼯作也已经在形成ELF的时候,合并⽅式已经确定了,具体合并原则被记录在了ELF的程序头表(Program header table)

 查看可执行程序的section:

查看可执行程序合并的segment:readelf -l test后分为两个部分

第一部分:(每个segment的相关信息)

第二部分:(这个segment是由那几个section合并而来的)

为什么要将section合并成为segment?

  •  因为内存和磁盘的最小I/O单元是4kb,而一个section的大小在通常情况下不足四字节,如果按照一个section一个section那么会产生大量的页面碎片。假设⻚⾯⼤⼩为4096字节(内存块基本⼤⼩,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占⽤3个⻚⾯,⽽合并后,它们只需2个⻚⾯。这样子减少了页面碎片,提高了内存的使用效率。
  • 此外,我们将属性相同的section进行合并形成一个大块的segment优化了内存管理和权限控制访问。
对于 程序头表节头表 ⼜有什么⽤呢,其实 ELF ⽂件提供 2 个不同的视图/视⻆来让我们理解这
两个部分:
  • 链接视图 (Linking view) - 对应节头表 Section header table(程序未加载到内存时)
  1. ⽂件结构的粒度更细,将⽂件按功能模块的差异进⾏划分,静态链接分析的时候⼀般关注的
    是链接视图,能够理解 ELF ⽂件中包含的各个部分的信息。
  2. 为了空间布局上的效率,将来在链接⽬标⽂件时,链接器会把很多节(section)合并,规整
    成可执⾏的段(segment)、可读写的段、只读段等。合并了后,空间利⽤率就⾼了,否
    则,很⼩的很⼩的⼀段,未来物理内存⻚浪费太⼤(物理内存⻚分配⼀般都是整数倍⼀块给
    你,⽐如4k),所以,链接器趁着链接就把⼩块们都合并了。
  • 执⾏视图 (execution view) - 对应程序头表 Program header table(程序已经加载到了内存)

    告诉操作系统,如何加载可执⾏⽂件,完成进程内存的初始化。⼀个可执⾏程序的格式中, ⼀定有 program header table

     从 链接视图 来看:

    输入readelf -S xxx命令之后,截取部分,下面介绍几个section的含义

    • .text 节 :是保存了程序代码指令的代码节。
    • .data 节 :保存了初始化的全局变量和局部静态变量等数据。
    • .rodata 节 :保存了只读的数据,如⼀⾏C语⾔代码中的字符串。由于.rodata节是只读的,所以只能存在于⼀个可执⾏⽂件的只读段中。因此,只能是在text段(不是data段)中找到.rodata节。
    • .BSS 节 :为未初始化的全局变量和局部静态变量预留位置
    • .symtab 节 : Symbol Table 符号表,就是源码⾥⾯那些函数名、变量名和代码的对应关系。
    • .got.plt 节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供了对导⼊的共享库函数的访问⼊⼝,由动态链接器在运⾏时进⾏修改。

     从 执⾏视图 来看:

    • 告诉操作系统哪些模块可以被加载进内存。
    • 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执⾏的。
    我们可以在 ELF 头 中找到⽂件的基本信息,以及可以看到ELF头是如何定位程序头表和节头表的。例 如我们查看下hello.o这个可重定位目标文件的主要信息:
    // 查看⽬标⽂件
    $ readelf -h hello.oELF Header:
    Magic:     7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 #用来判断一个文件是否为ELF文件Class:     ELF64 # ⽂件类型Data:     2's complement, little endian # 指定的编码⽅式
    Version:     1 (current)
    OS/ABI:     UNIX - System V
    ABI Version:     0
    Type:     REL (Relocatable file) # 指出ELF⽂件的类型
    Machine:     Advanced Micro Devices X86-64 # 该程序需要的体系结构
    Version: 0x1
    Entry point address: 0x0 # 系统第⼀个传输控制的虚拟地址,在那启动进程。假如⽂件没有如何关联的⼊⼝            点,该成员就保持为0。Start of program headers:     0 (bytes into file)
    Start of section headers:     728 (bytes into file)Flags:     0x0Size of this header:     64 (bytes) # 保存着ELF头⼤⼩(以字节计数)
    Size of program headers:     0 (bytes) # 保存着在⽂件的程序头表(program header table)中⼀个⼊⼝的⼤⼩
    Number of program headers:     0 # 保存着在程序头表中⼊⼝的个数。因此,e_phentsize和e_phnum的乘机就是表的⼤⼩(以字节计数).假如没有程序头表,变量为0。Size of section headers:     64 (bytes) # 保存着section头的⼤⼩(以字节计数)。⼀个section头是在section头表的⼀个⼊⼝Number of section headers:     13 # 保存着在section header table中的⼊⼝数⽬。因此,e_shentsize和e_shnum的乘积就是section头表的⼤⼩(以字节计数)。
    假如⽂件没有section头表,值为0。Section header string table index: 12 # 保存着跟section名字字符表相关⼊⼝的section头表(section header table)索引。

    3.理解连接与加载

    3.1. 静态链接

    • ⽆论是⾃⼰的.o, 还是静态库中的.o,本质都是把.o⽂件进⾏连接的过程。
    • 所以:研究静态链接,本质就是研究.o是如何链接的。
    $ ll
    -rw-rw-r-- 1 wzh wzh 62 Oct 31 15:36 code.c
    -rw-rw-r-- 1 wzh wzh 103 Oct 31 15:36 hello.c
    wzh:~/test/test/test$ gcc -c *.c
    wzh:~/test/test/test$ gcc *.o -o main.exe
    $ ll
    -rw-rw-r-- 1 wzh wzh 62 Oct 31 15:36 code.c
    -rw-rw-r-- 1 wzh wzh 1672 Oct 31 15:46 code.o
    -rw-rw-r-- 1 wzh wzh 103 Oct 31 15:36 hello.c
    -rw-rw-r-- 1 wzh wzh 1744 Oct 31 15:46 hello.o
    -rwxrwxr-x 1 wzh wzh 16752 Oct 31 15:46 main.exe*

     查看编译后的.o⽬标⽂件

    $ objdump -d code.ocode.o: file format elf64-x86-64Disassembly of section .text:0000000000000000 <run>:
    0: f3 0f 1e fa                  endbr64
    4: 55                           push   %rbp
    5: 48 89 e5                     mov %rsp,%rbp
    8: 48 8d 3d 00 00 00 00         lea 0x0(%rip),%rdi # f
    <run+0xf>
    f: e8 00 00 00 00               callq 14 <run+0x14>
    14: 90                          nop
    15: 5d                          pop %rbp
    16: c3                          retq$ objdump -d hello.ohello.o: file format elf64-x86-64Disassembly of section .text:0000000000000000 <main>:
    0: f3 0f 1e fa             endbr64
    4: 55                      push %rbp
    5: 48 89 e5                mov %rsp,%rbp
    8: 48 8d 3d 00 00 00 00    lea 0x0(%rip),%rdi # f
    <main+0xf>
    f: e8 00 00 00 00          callq 14 <main+0x14>
    14: b8 00 00 00 00         mov $0x0,%eax
    19: e8 00 00 00 00         callq 1e <main+0x1e>
    1e: b8 00 00 00 00         mov $0x0,%eax
    23: 5d                     pop %rbp
    24: c3                     retq
    4: 55                      push %rbp
    5: 48 89 e5                mov %rsp,%rbp
    8: 48 8d 3d 00 00 00 00    lea 0x0(%rip),%rdi # f
    <main+0xf>
    f: e8 00 00 00 00          callq 14 <main+0x14>
    14: b8 00 00 00 00         mov $0x0,%eax
    19: e8 00 00 00 00         callq 1e <main+0x1e>
    1e: b8 00 00 00 00         mov $0x0,%eax
    23: 5d                     pop %rbp
    24: c3                     retq
    我们可以看到这⾥的call指令,它们分别对应之前调⽤的printf和run函数,但是你会发现他们的跳转地址都被设成了0。 其实就是在编译 hello.c 的时候,编译器是完全不知道 printf run 函数的存在的,⽐如他们 位于内存的哪个区块,代码⻓什么样都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为0。 这个地址会在哪链接的时候被修正!为了让链接器将来在链接时能够正确定位到这些被修正 的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表⾥记录的 地址将其修正。
    所以链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴的可执⾏⽂件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这其实就是静态链接的过程。

    3.2. ELF加载与进程地址空间

    3.2.1. 虚拟地址/逻辑地址

    ⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机⼯作的时候,都采⽤"平坦模式"进⾏⼯作。所以也要求ELF对⾃⼰的代码和数据进⾏统⼀编址,下⾯是 objdump -S 反汇编之后的代码。
    最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量), 但是我们认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执⾏程序进⾏统⼀编址了。
    进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?从ELF各个 segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start, end] 等范围数据,另外在⽤详细地址,填充⻚表。

    3.2.2. 进程虚拟地址空间

    ELF 在被编译好之后,会把⾃⼰未来程序的⼊⼝地址记录在ELF header的Entry字段中:

    3.3. 动态链接与动态库加载

    3.3.1. 进程如何看到动态库

     3.3.2 进程间如何共享动态库

    3.3.3 动态链接

    3.3.3.1. 概要
    动态链接其实远⽐静态链接要常⽤得多。⽐如我们查看下 hello 这个可执⾏程序依赖的动态库,会发现它就⽤到了⼀个c动态链接库:
    $ ldd hellolinux-vdso.so.1 => (0x00007fffeb1ab000)libc.so.6 => /lib64/libc.so.6 (0x00007ff776af5000)/lib64/ld-linux-x86-64.so.2 (0x00007ff776ec3000)# ldd命令⽤于打印程序或者库⽂件所依赖的共享库列表。
    这⾥的 libc.so是C语⾔的运⾏时库,⾥⾯提供了常⽤的标准输⼊输出⽂件字符串处理等等这些功能。
    那为什么编译器默认不使⽤静态链接呢?静态链接会将编译产⽣的所有⽬标⽂件,连同⽤到的各种库,合并形成⼀个独⽴的可执⾏⽂件,它不需要额外的依赖就可以运⾏。照理来说应该更加⽅便才对是吧?静态链接最⼤的问题在于⽣成的⽂件体积⼤,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费⼤量的硬盘空间。

    这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独⽴的动态链接库,等到程序运⾏的时候再将它们加载到内存,这样不但可以节省空间,因为同⼀个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享。

    动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使⽤情况为它们动态分配⼀段内存。
    当动态库被加载到内存以后,⼀旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
     3.3.3.2. 我们的可执⾏程序被编译器动了⼿脚
    $ ldd /usr/bin/lslinux-vdso.so.1 (0x00007fffdd85f000)libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
    (0x00007f42c025a000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f42c0068000)libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0
    (0x00007f42bffd7000)libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f42bffd1000)/lib64/ld-linux-x86-64.so.2 (0x00007f42c02b6000)libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0
    (0x00007f42bffae000)
    $ ldd main.exelinux-vdso.so.1 (0x00007fff231d6000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f197ec3b000)/lib64/ld-linux-x86-64.so.2 (0x00007f197ee3e000)

     在C/C++程序中,当程序开始执⾏时,它⾸先并不会直接跳转到 main 函数。实际上,程序的⼊⼝点是 _start ,这是⼀个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数。

    _start 函数中,会执⾏⼀系列初始化操作,这些操作包括:
    • 设置堆栈:为程序创建⼀个初始的堆栈环境。
    • 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调⽤和变量访问能够正确地映射到动态库中的实际地址。
    • 调⽤ __libc_start_main :⼀旦动态链接完成, _start 函数会调⽤
      __libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执⾏
      ⼀些额外的初始化⼯作,⽐如设置信号处理函数、初始化线程库(如果使⽤了线程)等。
    • 调⽤ main 函数:最后, __libc_start_main 函数会调⽤程序的 main 函数,此时程序的执⾏控制权才正式交给⽤⼾编写的代码。
    • 处理 main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回值,并最终调⽤ _exit 函数来终⽌程序。

    版权声明:

    本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

    我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

    热搜词