库的原理和制作 动态库如何和可执行程序相关联,为什么程序入口点不是main函数,GOT表,PIC地址无关代码(2)
摘要: 本文探讨ELF程序的加载机制与进程地址空间管理。ELF文件在磁盘上已通过虚拟地址统一编址,程序入口地址记录在ELF header的Entry字段。进程创建时,操作系统初始化PCB和页表,建立虚拟地址到文件的映射。CPU通过虚拟地址访问指令,触发缺页异常后加载物理内存并更新页表。动态库通过共享区映射实现多进程共享,节省内存和磁盘空间,其只读代码段可被映射到同一物理页。 关键词: ELF加载、
🎬 胖咕噜的稞达鸭:个人主页
《Linux系统学习》
《算法日记》


问题二:ELF程序是如何 加载到内存的,(找到它,路径+文件名),ELF程序是如何转化为进程的(逻辑地址,物理地址,虚拟地址),虚拟地址空间?
一个可执行程序,如果没有加载到内存中,该可执行程序,有没有地址?
YOU!有的!
当代计算机工作的时候都采用==“平坦模式”==进行工作,所以也要求ELF对自己的代码和数据进行统一编址。这个统一编址就是ELF虚拟地址。
严格意义上叫做逻辑地址(起始地址+偏移量)但是我们认为起始地址就是0,其实虚拟地址在我们程序还没有加载到内存的时候就已经把可执行程序进行统一编址了。
对可执行程序,完成在磁盘上的编址,所有的可执行程序,就是一个seg,所有的seg所有函数,变量编址起始偏移量都从0开始。
虚拟地址空间:不仅仅是进程看代内存的方式,磁盘上的可执行程序,代码和数据编址其实就是虚拟地址的统一编址。操作系统支持,编译器也要支持。
cpu怎么知道,你的可执行程序的起始地址是什么?也就是说CPU怎么知道从哪里 开始执行的呢
进入到CPU中的地址全部都是虚拟地址!
ELF在编译好之后,就会把自己的未来程序的入口地址记录在ELF header的Entry字段中,
CPU执行流程
步骤1:操作系统告诉CPU
// 进程创建时,操作系统设置:
PCB.entry_point = 0x1060; // 程序入口地址
CPU.PC = 0x1060; // 设置程序计数器
步骤2:CPU获取指令
CPU: "我要执行0x1060处的指令"
MMU: "0x1060是虚拟地址,我来查页表..."
MMU: "对应物理地址是0x12345678"
CPU: "好,我从0x12345678取指令执行"
完整阐述一下这个过程:
- 当用户请求执行程序,内核会创建
PCB(进程控制块),此时创建进程的内存描述符(mm_struct),初始化虚拟地址空间(建立页表,此时还没有分配物理内存) ELF文件加载,读取ELF header,获取入口点虚拟地址(Entry),解析program Header,了解文件中的偏移大小,内存中的虚拟地址范围以及访问权限,随后建立内存映射,只建立了虚拟到文件的映射关系。CPU开始执行参与,CPU到入口点拿取到ELF header的Entry虚拟地址,由MMU地址触发缺页查找页表,随后页表被填充,有物理地址和虚拟地址。通过虚拟地址找到物理地址。
┌─────────┐ 创建 ┌─────────┐ 解析 ┌─────────┐
│ 用户请求 │ ───────▶ │ PCB │ ───────▶ │ ELF文件 │
│ │ │ │ │ │
└─────────┘ └─────────┘ └─────────┘
│ │
▼ ▼
┌─────────┐ 建立VMA ┌─────────┐
│ mm_struct│ ◀────────── │ 段信息 │
│ │ │ │
└─────────┘ └─────────┘
│
▼ 创建空页表
┌─────────┐
│ 页表 │ ←─ 初始全是"不存在"
│ (空框架) │
└─────────┘
│
调度执行 ▼ 开始执行时触发
┌─────────────────────┤ ┌─────────────┐
│ ▼ │ 缺页异常处理 │
┌─────────┐ ┌─────────┐ ┌────┴───────────┐
│ 调度器 │ ────▶ │ CPU执行 │ ─────▶│ 1.分配物理页 │
│ │ │ Entry │ │ 2.从文件读内容 │
└─────────┘ └─────────┘ │ 3.更新页表 │
└────┬───────────┘
│
▼
┌─────────┐
│ 继续执行 │
│ │
└─────────┘
问题三:动态库是如何和我们的可执行程序相关联的?


逻辑简述:
进程加载到内存中创建PCBtask_struct进程控制块,创建进程的内存描述符(mm_struct)通过虚拟地址映射到进程的代码区,数据区,共享区,通过虚拟地址看到共享库,通过进程A的代码和数据进入到物理内存,磁盘上的xx.so库(磁盘上的共享库文件)加载到物理内存中,读取到xx.so共享库的物理副本(可以被多个进程共享)。最后两部分通过页表建立联系。通过地址空间(进程关联)
进程创建PCB->内存描述符(内有共享区,代码区数据区,互通)->页表(虚拟地址,物理地址)
磁盘上的xxx.so库(库加载)->物理内存(存储A,B进程的代码和数据,xxx.so)->页表(物理地址,虚拟地址)
进程加载时创建
task_struct和mm_struct,建立虚拟地址空间(包含私有代码区、数据区和共享库映射区)。当进程访问代码或共享库时,通过缺页异常将磁盘内容加载到物理内存:进程私有部分加载到私有物理页,共享库加载到共享物理页。页表建立虚拟地址到物理地址的映射,其中共享库的只读代码段可被多个进程映射到同一物理页,实现内存共享。
进程A启动 → task_struct → mm_struct → 虚拟地址空间
↓
访问代码 → 缺页 → 加载可执行文件代码到私有物理页
↓
调用库函数 → 缺页 → 加载xx.so到共享物理页(如首次)
↓
进程B启动 → 调用同一库函数 → 映射到同一共享物理页
↓
页表:虚拟地址A → 私有物理页(进程A代码)
虚拟地址B → 共享物理页(xx.so代码)
共享的好处:节省内存空间;节省磁盘空间。
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 进程A启动 │ │ 进程B启动 │ │ 磁盘 │
│ │ │ │ │ libc.so │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
▼ ▼ │
┌─────────────┐ ┌─────────────┐ │
│ 第一次使用 │ │ 第一次使用 │ │
│ libc函数 │ │ libc函数 │ │
└──────┬──────┘ └──────┬──────┘ │
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ 物理内存(只有一份) │
│ libc.so的代码段 │
└─────────────────────────────────────────────────────┘
▲ ▲
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ 进程A的页表 │ │ 进程B的页表 │
│ 映射到同一 │ │ 映射到同一 │
│ 物理页 │ │ 物理页 │
└─────────────┘ └─────────────┘
库函数调用:
4. 被进程看到:动态库映射到进程的地址空间;
5. 被进程调用:在进程的地址空间中进行跳转
问题四:动态库是如何加载的?
动态链接实际上将链接的整个过程推迟到了程序加载的时候。怎么说:操作系统将程序的代码和数据连同用到的一系列动态库先加载到内存,每个动态库的加载地址都是固定不变的。操作系统会为他们统一分配内存。动态库一旦被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中跳转地址了。
为什么程序的入口点不是main()函数?
程序内核运行时,加载ELF文件,建立进程地址空间,设置入口点为-start,(ELF Header中的e_entry指向的是_start);
_ start函数会进行一系列的初始化操作:
- 设置堆栈;
- 初始化数据段(全局变量和静态变量从初始化数据段复制到相应的内存位上)
- 动态链接:_ start函数调用动态链接器代码来解析和加载程序所依赖的动态库,确保虚拟地址映射到物理地址)。动态链接器加载共享库到虚拟地址空间,解析符号引用,建立快速跳转机制(
PLT/GOT),并通过页表实现按需加载到物理内存。
所以我们可以得知:当在main()函数在函数内部开始调用的时候,堆栈已经建立好了,全局变量,动态库都已经准备就绪。
main不是第一个C函数:__libc_start_main才是第一个真正的C函数
- 调用
__libc_start_main(动态链接完成之后)执行其他的一些初始化工作; - 最后
__libc_start_main调用main函数,此时程序的控制权才交给用户编写的代码。 - 处理main函数的返回值,由
__libc_start_main返回返回值,最后调用exit函数来终止程序。
让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址跳转访问的,所以需要把动态库映射到进程的地址空间中。

我们的程序怎么进行库函数调用?
我们已经知道了库的映射虚拟地址,库的虚拟地址我们都知道了,库中每一个方法的偏移量我们也知道,所有:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法。然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位)。
动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运行模块要引用的一个全局变量或函数的地址。
因为.data区域是可读写的,所以可以支持动态进行修改。
由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。

**动态链接库不是程序编译时就直接“塞”进可执行文件的,而是运行时由操作系统负责加载到内存,并通过地址映射让程序能调用到库函数。**整个过程涉及进程地址空间、内核文件系统数据结构、磁盘IO、页表映射、符号解析与重定位等一系列技术和概念。
在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表。
在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
这种方式实现的动态链接就被叫做PIC 地址无关代码。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
总而言之,动态链接实际上将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时,它虽然牺牲了⼀定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利用磁盘空间和内存资源,以极大方便了代码的更新和维护,更关键的是,它实现了二进制级别的代码复用。
总结:
ELF在磁盘上已有虚拟地址:编译时已统一编址,所有段从0开始偏移
CPU从哪里开始执行:ELF header中的Entry字段记录程序入口虚拟地址
操作系统创建进程时设置CPU的PC寄存器为该地址CPU通过MMU将虚拟地址转换为物理地址执行
完整加载过程:
用户执行请求 → 内核创建PCB和mm_struct → 解析ELF文件建立内存映射
→ 设置入口地址 → CPU执行触发缺页 → 按需加载物理页 → 更新页表
共享机制:多个进程共享同一份库代码的物理内存
内存布局:
- 进程私有:代码区、数据区
- 进程共享:库代码段(只读)
- 映射方式:通过页表将库映射到进程的共享区
- 好处:节省内存和磁盘空间,方便更新维护
为什么入口不是main:_start先初始化环境(堆栈、全局变量、动态链接)
动态链接时机:推迟到运行时,按需加载
函数调用机制:
库起始地址 + 函数偏移量
GOT表(全局偏移表):
位于.data段,可读写
存储函数实际地址
每个进程有独立的GOT表
PIC(地址无关代码):
使用相对寻址找到GOT表
库可加载到任意地址
编译时加-fPIC参数
核心思想总结
按需加载:程序运行时才真正加载所需代码到内存
内存共享:库代码只读部分多进程共享,节省资源
间接寻址:通过GOT表间接调用函数,实现灵活加载
延迟绑定:第一次调用函数时才解析地址,加快启动
一句话:动态链接通过运行时加载、内存共享和间接跳转机制,实现了高效的内存使用和灵活的代码更新。
更多推荐



所有评论(0)