
目录
["节"与"段"的区别:链接 VS 执行](#“节”与“段”的区别:链接 VS 执行)
1).从shell到execve.从shell到execve)
[execve( )系统调用的作用:进程程序替换](#execve( )系统调用的作用:进程程序替换)
2).程序头表解析与内存映射建立.程序头表解析与内存映射建立)
3).task_struct、mm_struct、vm_area_struct的关系.task_struct、mm_struct、vm_area_struct的关系)
前言
当我们在命令行上输入好想要运行的指令,并按下Enter键后,一个崭新新的进程运行起来了,尽管它的生命周期可能比我们眨一次眼的时间还短,但你知道它是如何从一个"静止"的ELF程序到"活动"的进程吗?一条简单的命令背后,操作系统完成了怎样复杂的工作?
本文将完整揭示从静态ELF文件到运行中进程的完整转换过程。
------文中展示图片皆来自网络
一、基础概念铺垫
"可执行文件的由来","链接细节过程",想要理解它们,我们就不得不了解ELF文件。
常见的ELF文件包括:可重定位文件(.o文件)、可执行文件、共享目标文件(.so动态库文件)与内存转储文件。
1.ELF文件格式介绍
ELF文件组成
ELF文件由如下四部分组成:

其中数量最多的节(Section) :它是ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中。
如代码节(.text)存储了cpu可执行代机器指令;
数据节(.data)存储了已初始化的全局变量和静态数据等。
我们可以用size指令查看ELF文件中代码节和数据节的分布情况:

**ELF头(ELF header):**位于文件的开始位置,它的主要目的是告诉操作系统整个ELF文件的构成部分。
程序头表(Program header table):列举了所有有效的段(segments)和他们的属性。程序头表里记着每个段的开始的位置和位移(offset)、⻓度等信息,因为这些段数据都是紧密的放在二进制文件中,所以需要段表的描述信息,才能把他们每个段分割开。
**节头表(Sectionheadertable):**包含对节(sections)的描述。
而其中程序表头与节头表尤为重要,在我们讨论了"节"与"段"的区别后再详细介绍。
"节"与"段"的区别:链接 VS 执行
ELF文件中的节(section)和段(segment):
节(section) :是ELF文件中的最小组织单元,由链接器使用。例如,.text(代码)、.data(已初始化数据)、.bss(未初始化数据)等。每个节都有特定的用途。
段(segment) :是程序执行视图的一部分,由加载器使用 。**一个段通常由多个节组成,**例如,一个可加载的代码段可能包含.text节和只读数据节。段描述了如何将程序映射到虚拟内存中。
节的分类:
.text:存放可执行代码
.data:存放已初始化的全局变量和静态变量
.bss:存放未初始化的全局变量和静态变量(在文件中不占空间,但节头表会记录其大小)
.rodata:存放只读数据
.symtab:符号表
.strtab:字符串表
.rel.*:重定位表
**节的合并原则:相同属性的节会合并成同一个段中。**比如:可读,可写,可执行,需要加载时申请空间等。
(这就是为什么C/C++程序中,字符串常量存放在只读数据区的原因,读者可在下方段图中查看.text 与 .rodata,可以发现它们就在一个段中)
我们说过可执行文件也是ELF类型的,所以这个合并工作在形成ELF可执行文件的时候,就已经确定了,具体合并细节记录在了ELF的程序头表(Program header table)中,之后操作系统会据此加载数据到进程地址空间。
节与段的关系:多对一。

我们可以用readelf -S xxx 指令观察程序的节:

我们可以用objdump -h xxx 指令观察程序的段:

为什么要将section合并成为segment?
①减少内存碎片,提高内存使用效率。Linux中不管是磁盘还是内存,都是以4KB内存块大小为单位进行传输和管理的,如果不合并,那么一些小节可能只有几百Byte,以4KB的内存块来装载,太浪费内存空间了;
**②优化内存管理与权限访问。**将具有相同属性的section合并成一个大的segment,比如只读的节合并成一个段,只写的节合并成一个段。
程序表头与节头表的作用:两种视角看待ELF文件
前面说到,程序表头与节头表具有重要作用:
节头表的作用:
①描述目标文件的内部结构:
记录每个节(section)的名称、类型、大小、位置包含.text、.data、.bss、.rodata等所有节的详细信息。
②指导静态链接过程: 读取节头表; 合并相同类型的节; 所有.text合并,所有.data合并等 解析符号引用; 进行重定位;
③**提供调试和符号信息:**包含.symtab(符号表)、.strtab(字符串表)、.debug*(调试信息)
④重定位信息。
程序头表的作用:
①告诉操作系统哪些模块可以被加载进内存:描述哪些部分需要加载到内存;指定加载地址、权限、对齐方式;
②定义内存段:代码段和数据段。
大白话就是:一个在链接时作用,一个在运行加载时作用。
2.进程地址空间核心概念
三种地址间的辨析:逻辑地址、虚拟地址、物理地址
什么是物理地址?
物理地址内存芯片(RAM)上实实在在的硬件地址。每一个字节的内存都有一个唯一的物理地址。CPU的内存管理单元最终就是通过这个地址总线来读写数据的。
什么是逻辑地址?
逻辑地址 也称为相对地址 或偏移地址 。它是程序(比如一个编译好的C程序)内部使用的地址,比如指针变量中存放的地址,它总是相对于某个段的基地址而言的。
什么是虚拟地址?
虚拟地址是逻辑地址经过分段单元转换后 得到的地址。在Linux中,我们通常说进程运行在"虚拟地址空间"里,这个空间的地址就是虚拟地址。虚拟地址 = 段基地址 + 段内偏移量。段基地址又是什么呢? 在现代操作系统中(包括Linux),通常使用平坦内存模型(flat memory model),而在平坦内存模型中,段基地址被设置为0,所以现代操作系统的虚拟地址 = 段内偏移量。什么又是段内偏移量呢?
想象一下进程的地址空间是一个巨大的、从0开始编号的线性数组,当可执行程序的代码和数据调入这个进程地址空间后,各个段在这个线性空间中都有自己的数组下表,而段内存储的代码或者数据,相较于段头又有一段偏移,这个偏移就是段内偏移量。
综上,虚拟地址 = 每段被分配的虚拟地址 + 每个代码/数据在段中的偏移量。
例如,假设代码段的起始虚拟地址是0x08048000,.text节在代码段内的偏移为0,那么第一条指令的虚拟地址就是0x08048000。如果.data节被链接到虚拟地址0x0804a000,那么一个位于.data节开头后的0x100字节处的全局变量的虚拟地址就是0x0804a100。
值得一提的是:在Linux/x86下,逻辑地址中的"段内偏移"部分,就直接被当作虚拟地址来使用了 。这就是为什么很多现代教材对这两个词不再严格区分。所以我们可以粗略地认为:逻辑地址 ≈ 虚拟地址。
逻辑地址------>虚拟地址的转化
上面在介绍上面是虚拟地址时,已经变相介绍过了,这里就不再赘述直接得出结论:在当代Linux中,逻辑地址 ≈ 虚拟地址。
虚拟地址------>物理地址的转化
每个进程都有自己的 页表,这是由操作系统内核维护的一种数据结构。它定义了虚拟地址到物理内存的映射。当进程执行一条指令,想要读取虚拟地址处的数据时,CPU会把虚拟地址交给操作系统。操作系统会查阅当前进程的页表,找到这个虚拟地址对应的物理地址。再将物理地址交给CPU用这个物理地址去访问物理内存芯片。
为什么进程需要虚拟进程地址空间?
虚拟地址空间的存在,让每个进程都可以在一个从0开始的、独立的、连续的巨大内存里编址(32位是4GB,64位是天文数字),完全不用管实际的混乱的、碎片化的物理内存。这提供了 进程隔离 (进程无法直接访问其他进程的内存)、简化编程 和 实现共享库 等巨大好处。
二、理解链接与加载
1.从.o到可执行文件:静态链接的过程
无论是我们自己写的.o文件我,还是静态库中的.o,本质都是把.o文件进行连接形成可执行程序的过程。
上面我们已经了解到.o文件实质上是ELF类型文件,我们说.o文件之间的链接是链接的什么?
当多个.o文件链接时,链接器会将相同类型的节合并到一个段中。例如,所有输入文件的.text节会被合并到可执行文件的一个.text段中。同样,.data节会被合并到.data段中。

出此之外,链接器还干了一件事-地址修正:将原.o文件里,彼此间只有声明,没有真实调用地址的函数调用,填充上合并成一个可执行文件后相关函数在文件中的地址;或者为只有变量声明,没有定义的变量填充上地址。
所以链接其实就是将编译之后的所有.o目标文件,与用到的一些静态库运行时库组合(如果有的话),拼装成一个独立的可执行文件。当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。
2.程序加载流程详解
1).从shell到execve
shell中会执行类似下述代码:
cpp
// Shell进程执行
pid = fork(); // 创建子进程
if (pid == 0) {
// 在子进程中
execve("/bin/ls", argv, envp); // 加载新程序
}
Shell会调用 fork() 创建一个新的子进程,然后,通常是子进程调用 execve() 系统调用,execve() 会找到磁盘上的那个可执行文件,并识别出它是ELF格式。之后操作系统会调用加载器解析ELF文件头 ,以弄清楚这个程序有哪些部分(比如代码段 .text, 已初始化数据段 .data 等),应该被映射到内存的什么位置。
execve( )系统调用的作用:进程程序替换
execve() 是exec*系列库函数的底层实现,它的作用是将用新的程序替换当前进程的代码、数据、堆栈等。
进程程替换只替换内存中的代码和数据,并修改mm_struct中各个代码段、数据段等区域的开始与结束,并重新修改页表的虚拟地址与物理地址间的映射关系,而与task_struct等其他内核数据结构无关 。所以**进程替换并没有创建新进程,**而是在原进程基础上的替换操作。
2).程序头表解析与内存映射建立
加载器在加载的时候是不是根据查询程序头表将可执行文件中的数据映射到,平常说的进程地址空间栈、堆、BSS段、数据段、代码段里呢?
代码段(text段) 和数据段(data段) :**这些是直接由ELF文件中的可加载段(类型为PT_LOAD的段)映射而来的。**通常有两个PT_LOAD段:一个用于代码段(权限为读和执行,通常包含.text等节),另一个用于数据段(权限为读和写,通常包含.data、.bss等节)。
BSS段 :**它是数据段的一部分,在ELF文件中,BSS节通常被包含在第二个PT_LOAD段(数据段)中。**在程序头表中,会指定这个段在内存中的大小大于在文件中的大小,多出来的部分就是BSS段,加载器会将这些区域初始化为0。
注意,.bss节在ELF文件中不占用文件空间,但在程序头表中会指定在内存中分配的大小。因为bss中记录的是那些全局未初始化变量或者局部静态未初始化变量,如果在ELF文件中存储,由于没有初始化值默认全部是系统默认值而没有存储价值,不如等到加载在内存中准备执行时再为其开辟空间,将其初始化为0。
栈(stack) :**栈并不是由ELF文件映射而来的,而是由操作系统在创建进程时自动分配的。**通常,栈位于用户虚拟地址空间的最高地址,并向下增长。其大小可以由系统限制或用户指定。
堆(heap) :**堆也不是直接由ELF文件映射,而是由程序运行时通过brk或mmap系统调用动态扩展的。**通常,堆紧挨着数据段(BSS段之后)并向上增长。
所以,总结一下:加载器根据程序头表将PT_LOAD段映射到内存,形成代码段和数据段(包括BSS)。栈和堆则是由操作系统和运行时动态管理的内存区域。
3).task_struct、mm_struct、vm_area_struct的关系
task_struct:这是进程描述符,每个进程都有一个task_struct结构,它包含了进程的所有信息,比如状态、调度信息、文件描述符等。
mm_struct:这是内存描述符,每个进程都有一个mm_struct(内核线程可能没有,它们使用前一个进程的地址空间)。它代表了进程的整个地址空间,包含了很多内存管理的信息,比如页表、进程的代码段、数据段、堆、栈等区域的信息。
vm_area_struct:这是虚拟内存区域描述符。一个进程的地址空间通常被划分为多个区域,每个区域由vm_area_struct表示。例如,代码段、数据段、每个内存映射文件、共享内存段等都是一个vm_area_struct。这些区域按照地址顺序组织成链表或红黑树,以便快速查找。
关系总结:
每个进程(task_struct)都有一个指向mm_struct的指针(mm字段);而在mm_struct中,有一个指向vm_area_struct链表的指针(mmap字段)。它们属于层层递进的包含关系。
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?
就是从ELF可调用文件的各个segment段来的,每个段有自己的起始地址和自己的区域⻓度,用来初始化内核结构中的[start,end]等范围数据。
三、库的作用:动态链接解析
1.偏爱使用动态库的原因
为什么当代编译器都钟爱使用动态库而非静态库呢?先看看动态链接是如何形成可执行文件的:静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。似乎看起来很好,具有独立性?
代编译器都钟爱使用动态库而非静态库原因在于:静态链接生成的文件体积大,耗费内存资源。并且不**同的可执行程序有可能都包含了相同的功能和代码,如果都使用静态链接,显然会浪费大量的磁盘空间,并且调用时也会浪费珍贵的内存空间。**比如C语言中的printf函数,如果每一个使用它的可执行文件中都包含一个printf的函数实现,那物理内存中肯定充斥大量重复的代码,十分浪费。
而此时动态链接的优点就体现出来:我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。
2.编译器添加的_start函数的作用
那么动态链接机制是怎么实现的呢?我们知道动态库.so文件本质上也是ELF类型得文件,那它是什么时候被调入内存,又是什么时候将函数地址填充到调用它得进程得地址空间中去的呢?
首先操作系统其实很"懒",动态链接实际上将链接的整个过程推迟到了程序加载的时候,也就是说真的要用到动态库里面的函数实现时,操作系统才会把动态库调入内存,并填充好调用关系。
其次调用动态链接的过程其实是由链接器提供的_start函数调用的。是的,链接器在链接我们的程序时,不仅合并了节等数据,还为我们的添加了一个_start函数!
这个_start函数会为我们的程序执行一些初始化操作,包括:
①设置堆栈:为程序创建一个初始的堆栈环境。
②初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段;
③调用动态链接器加载程序所依赖的动态库;
④调用__libc_start_main:一旦动态链接完成,_start函数会调用__libc_start_main(这是glibc提供的一个函数)。__libc_start_main函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等;
⑤调用main函数:此时,__libc_start_main函数才会调用程序的main函数,此时程序的执行控制权才正式交给用戶编写的代码;
⑥处理main函数的返回值 :当main函数返回时,__libc_start_main会负责处理这个返回值,并最终调用_exit函数来终止程序。
**调用动态链接器,加载程序所依赖的动态库是本文的介绍重点,**上述其他几点不做过多讨论。
首先什么是动态链接器?当代Linux操作系统中的动态链接器一般是/lib64/ld-linux-x86-64.so.2文件,我们可以通过 ldd xxx 指令查看:

那么动态链接器会怎么做呢?
①在_start函数中调用动态链接器后,动态链接器会根据program Head表知晓程序需要的库文件,然后通过环境变量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf及其子配置文件)来指定动态库的搜索路径。(这也是用第三方库,GCC/G++ 编译链接时为什么需要通过 -L 和-l告诉第三方库文件路径的原因)
②将找到的库文件加载物理内存中,然后映射进进程的虚拟地址空间中,由于库文件也是ELF文件,所以也可以认为它的虚拟地址= 被映射的虚拟地址空间 + 偏移量,将库的虚拟地址范围填入mm_struct中的vm_area_struct的strat和end中,再将实际需要调用的库函数地址填入GOT表中即可。
注意:库的加载地址通常不是固定的,而是使用地址无关代码(PIC)技术,使得库可以在任何地址加载。这也是用gcc打包生成动态库文件时,需要添加-fPIC的原因。
全局偏移量表GOT
为什么把需要使用的函数地址填入GOT表中,不直接填入进程地址空间中代码区呢?不要忘了代码区在进程地址空间中是只读的,这也是GOT表出现的原因之一。
动态链接采用的做法是在.data数据段(可执行程序或者库自己)中专⻔预留一片区域用来存放函数的跳转地址,这片区域就被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。


如上图所示,观察可执行程序的段表,可以发现got与data是在同一个段中的。
四、总结一个程序被加载全流程
①查找可执行文件路径: 当我们在shell中输入一个程序时,shell首先检查该程序是否是内置指令,如果不是才通过PATH环境变量来找到可执行文件的路径,或者根据执行程序时给出的执行路径(如果是相对路径还要访问shell自己的cwd);
**②进程创建与程序替换:**复制父进程task_struct 、mm_struct等内核数据结构,fork( )执行完后创建出一个子进程,等待execve( )程序替换;
**③文件查找与验证:**通过路径解析找到该可执行程序在dentry树中的相应节点,然后再从dentry中找到struct inode,通过inode找到该文件在磁盘中的元数据和data数据块,检查该文件权限是否可执行,并读取文件开头验证魔数(验证是不是ELF文件);
**④ELF解析与内存映射建立:**通过加载器依据文件的Program Header Table解析文件,遍历程序头表中的每个段,并每个LOAD段建立内存映射,同时创建对应的vm_area_struct插入mm_struct管理;
⑤构建新地址空间: 创建新的mm_struct**,** 照程序头表将代码段、BSS段和数据段的虚拟地址填入新建的mm_struct中的vm_area_struct的start和end中,完成进程地址空间的建立,最后释放旧的进程(Shell子进程)的mm_struct和所有VMA;
**⑥动态链接处理:**加载动态库,将库的虚拟地址范围填入mm_struct中的vm_area_struct的strat和end中,动态连接器填充GOT表;
⑦程序开始执行:开始调用_start函数执行程序。
本文总结
本文详细解析了ELF文件到运行进程的完整转换过程。
首先介绍了ELF文件结构,包括节(section)与段(segment)的区别及其在链接和执行时的作用。然后阐述了进程地址空间概念,包括逻辑地址、虚拟地址和物理地址的转换关系。
文章重点剖析了程序加载流程:从shell调用fork()创建子进程,到execve()系统调用执行程序替换,再到加载器根据程序头表建立内存映射,初始化task_struct、mm_struct等内核数据结构。最后讲解了动态链接机制,包括_start函数的作用、动态链接器的工作流程以及GOT表的用途。整个过程展现了操作系统如何将静态ELF文件转换为活动进程的复杂工作。
笔者水平有限,这是目前笔者对Linux中程序加载的理解,若有错误或不足的地方,万望读者指出,共同进步~
整理不易,读完点赞,手留余香~