当前位置: 首页 > news >正文

Linux:库的原理

库的原理

我们之前学过,库是由多个.o文件链接而成的。这些.o文件、以及我们常见的动静态库、可执行文件,都属于同一种文件格式 ——ELF(可执行与链接格式)

一、ELF 文件的基本组成

一个 ELF 格式的文件,主要包含以下几个部分:

  • ELF Header(ELF 头):记录文件的整体信息(比如是否是可执行文件、适配的系统架构等)。
  • Program Header Table(程序头表):描述文件加载到内存后的 “段” 信息。
  • Section(数据节):存放实际内容的片段(比如代码、数据等)。
  • Section Header Table(节头表):描述所有数据节的详细信息(比如位置、大小、属性等)。

二、常见的 Section(数据节)

我们可以通过size命令查看 ELF 文件中各段的大小,比如:

会显示类似 “text(代码段)、data(数据段)、bss(未初始化数据段)” 的大小。这三个是最核心的 Section:

1. .text(代码段)

存放程序的机器指令(代码),比如函数实现、逻辑运算等。这部分内容在程序运行时通常是只读的(防止意外修改)。

2. .data(数据段)

存放已初始化的全局变量和静态变量。比如代码中写int a = 10;,这个a就会存在这里 —— 因为它有初始值 “10”。

3. .bss(未初始化数据段)

存放未初始化的全局变量和静态变量。比如定义int b; int c;(没有赋值),系统会把它们暂时 “打包” 成一个整体(比如int[50]),统一初始化为 0 存放在这里。等程序加载到内存后,再 “拆包” 成独立的变量。

简单说:.data 是 “带初始值的行李”,.bss 是 “空箱子”(用的时候再装东西),两者都属于数据,只是存储方式不同。

三、ELF 文件的 “组装” 过程

当多个.o文件链接成可执行文件时,系统会把属性相同的 Section 合并:

  • 所有.o文件的.text合并成一个大的.text(代码集中存放);
  • 所有.o文件的.data合并成一个大的.data(已初始化数据集中存放);
  • 同理,.bss也会合并。
  • 这样做的目的是减少冗余,节省空间

四、Section Header Table 与 Program Header Table

观察 ELF 文件有两个重要视角:

1. 编译链接视角:Section Header Table

它就像 “零件清单”,详细记录每个 Section 的位置、大小、类型等。比如用readelf -S 文件名命令,可以看到.text在文件中的偏移量、大小等信息 —— 这是编译器和链接器关注的内

2. 执行视角:Program Header Table

当文件加载到内存中运行时,系统会根据 Program Header Table 把合并后的 Section 再 “打包” 成更大的Segment(段)。比如把.text和只读数据合并成一个 “代码段”,把.data.bss合并成一个 “数据段”。

这样做是为了简化内存管理—— 系统只需按 Segment 分配权限(比如代码段只读可执行,数据段可读可写)。

ELF Header:

用于记录该ELF文件的总体信息

Magic(魔术):在OS执行ELF文件时,首先要确认你是ELF文件格式才能执行,而magic就是用于表示该文件的格式。

五、.o文件链接的细节:

如上图,有一个hello.c与run.c文件,接下来将它们编译成.o文件后进行反汇编查看它们的汇编代码。

观察上图可以看到,当hello.s还没有被链接时候,在函数里面call的地址都是0地址,只有在链接后call的地址才会转成真正的地址。

从上图可以看到,当我们链接形成main.exe文件后,使用readelf -s  查看main.exe文件的数据节,可以看到 main函数与run函数都放在第13个数据节里。再使用 readelf -S 查看节表头,可以看到第13个数据节正是.text数据节里。

对main.exe 进行反汇编可以看到,在main函数call了400540地址,而这个地址正是run函数的地址。

而在之前,main函数里call的地址都还是0地址,现在已经变成正确的地址了。所以我们的.o文件又称之为可重定向文件

、地址的那些事儿:逻辑地址、虚拟地址、物理地址

我们在 ELF 文件中看到的 “地址”,其实有不同的含义:

逻辑地址:

其实从之前的图就能发现,我们的main.exe明明还没有加载到内存里,却已经有了地址。那这个地址是什么地址呢?先说结论,main.exe还没加载到内存时的地址是逻辑地址

为什么要有逻辑地址?

如上图,我们每一个segment的都有属于自己的基地址。而段内地址如同上图 a=10与fun函数都是段内基地址加上它们自身的偏移量。

如果我们将每一个segment的基地址都设置成0,那么所有的函数以及变量的编址都是从0开始加上偏移量。

所以一个程序中所有的函数与变量统一从0地址开始向下进行编址。

如上图main.exe文件的起始地址为4003e0开始一路往下进行线性编址,虽然不是从0地址开始,因为0地址还要被其他方法占用。

那么这种线性编址的地址我们就称之为逻辑地址,而这种编址模式称之为平坦模式编址。

虚拟地址:

程序加载到内存后,CPU 实际使用的地址是虚拟地址。它和逻辑地址本质上是一回事,只是换了个场景(运行时)

物理地址:

内存中真正的硬件地址(比如内存芯片上的存储单元位置)。虚拟地址会通过系统的 “页表” 映射到物理地址,这样程序不用关心实际存在内存的哪个角落,由系统统一管理。

所以ELF文件从磁盘加载进入内存时,将ELF文件里的每一个segment加载进内存,而ELF又已经经过绝对编址,所以每一个segment上面都拥有虚拟地址,所以用每一个segment的起始与结束的地址来初始化,PCB里mm_struct中每一个vm_area_struct里的start与end,然后构建页表映射关系,然后把ELF Header里的 Entry_point_address的地址喂给cpu,cpu就能通过通过MMU进行自动查找虚拟地址与物理地址的映射位置,就能运行代码转起来。

、程序是怎么 “启动” 的?

这就不得不回到我们之前说的ELF Header

当我们使用 readelf -h main.exe  查看ELF表头时候,会发现一个名为

Entry point address的地址,而这个地址正是记录程序入口的起始地址,所以当该文件运行时候,OS就会查找该文件的ELF表头,将里面记录的程序入口地址交给CPU,CPU就开始运行该可执行文件。

所以当我们修改一个文件时完整步骤应该为:

通过open打开文件,OS对当前文件的路径与文件名进行拼接。通过该文件的绝对地址去内存中目录缓冲dentry树里查找是否对应的文件名,如果没有就去磁盘上查找。当找到与之对应的文件名时,就拿到文件名对应该文件的inode信息,通过文件的inode信息将文件数据从磁盘加载到内存。

加载到内存后,PCB就会初始化它的mm_Struct,构建页表中物理地址与虚拟地址的映射关系。

接着通过mm_struct去查找你所修改的数据在文件的哪个区域,找到mm
_struct中对应的vm_area_struct,通过vm_area_struct中的file指针找到该文件对应的dentry树,通过dentry拿到该文件inode,最后通过inode找到对应的data_block进行修改。

八、动态库与可执行文件的关联:

这里先不谈动态库的加载,我们先谈谈动态库与可执行文件如何关联。

我们的可执行文件如果是动态链接库时,那么在文件中有一块区域是共享区,共享区里存放着与库函数相关的映射地址。

库函数想要被调用时同样需要加载到内存,而库加载到内存时也会拥有物理地址,那么同样的对库文件建立物理地址与虚拟地址的映射关系后,当程序调用到库文件时,就通过文件的共享区找到库的虚拟地址,再通过页表映射找到库的物理地址,再把参数通过cpu传入到库中与之相对的函数里进行函数调用,当调用完后再把参数放入cpu中进行返回。


而我们也会有多个程序调用同一种库的情况

所以当多个程序调用同一个库时,虽然它们的虚拟地址不一样,但库在内存中只有一份,多个PCB就可以通过不同的虚拟地址(它们独有的)通过页表映射找到同一个物理地址后进行调用。

所以Linux下很多库都是动态库调用,基本没有静态库。这么做也是为了节省内存空间。

九、动态库的加载与链接:

在程序运行前可以使用ldd命令查看当前文件所依赖的是哪个库。

但我们之前只提到libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3077e98000)这一段代码,这段代码是查看当前我们绑定的是什么库。而没有说过它的下一段代码 /lib64/ld-linux-x86-64.so.2 (0x00007f3078099000)

这段代码指的是动态链接器(加载器)

动态连接器:

动态链接器是一个至关重要的系统组件,在程序运行时发挥着关键作用:  

加载共享库:它会查找并加载程序所依赖的全部共享库(像 libc.so.6 这类)。

符号解析:负责将程序中的符号引用(例如函数调用)与实际的共享库函数进行关联。

初始化运行环境:在程序的入口点(main() 函数)被执行之前,完成一系列初始化操作。

其实我们动态库的加载是被推迟到程序加载过程中的,当运行程序时,并不会直接运行main函数,而运行的是_start函数

在程序的开始函数(_start)会调用动态加载器(ld-linux.so),动态加载器就会根据当前代码中需要调用到哪些库,然后帮我在系统里,把需要调用的库加载到内存里。当程序启动时,动态连接器会解析程序中的动态依赖库,并加载这些库到内存中。

十、动态库如何载入内存:

动态库本质也是.o文件,所以和普通ELF文件一样都是通过路径查询,找到库文件对应名字,找到与之映射的inode,找到inode后就能从磁盘中找到动态库数据,载入内存,构建页表映射关系。

假设我们调用的puts函数的偏移量地址为0x112233,在还没链接的时,call的地址是libc.so@+偏移量。但为什么当形成可执行文件时,该地址就被修改成一个具体地址了呢?再说了我们的代码是被放在文件的.text区域,而我们知道.text区域是不可以被修改的,为什么就修改了呢?

十、GOT全局偏移量表:

动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数 的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。

因为.data区域是可以修改的。因此我们就可以在GOT表里进行KV映射,我们将需要调用的库函数名称放入GOT表里,在程序加载时GOT中就存入函数名对应的虚拟地址

所以当我们调用库函数时,在call时候根本添加的根本不是函数的虚拟地址,而添加的是got表里与之对应的地址+偏移量。

总结

ELF 文件是程序在 Linux 中的 “标准包装格式”,无论是.o文件、库还是可执行文件,都遵循这个格式。理解它的组成(Section、Program Header 等)和地址映射(逻辑地址、虚拟地址),能帮我们搞懂程序从编译到运行的全过程。而动态库的共享机制和 GOT 表,则是实现高效复用的关键 —— 让程序更轻便,也更节省资源。

---------本篇文章就到这里,感谢各位观看

http://www.dtcms.com/a/272611.html

相关文章:

  • swift开发,关于应用、页面、视图的生命周期
  • [C++ STL] list类的刨析及简易实现
  • 亚马逊首个“海折节”,缘何加码进口电商?
  • java多线程环境下资源隔离机制ThreadLocal详解
  • C#内插字符串:从语法糖到深度优化
  • 学习笔记(32):matplotlib绘制简单图表-数据分布图
  • 入门级别的Transformer模型介绍
  • Rust中Option和Result详解
  • 微调性能赶不上提示工程怎么办?Can Gradient Descent Simulate Prompting?——论文阅读笔记
  • Apache Shiro 框架详解
  • 【三维生成】FlashDreamer:基于扩散模型的单目图像到3D场景
  • Express 入门指南(超详细教程)
  • 机器学习之逻辑回归和k-means算法(六)
  • 32多串300A保护板测试仪:新能源电池安全的核心守护者
  • 生成式人工智能实战 | 自注意力生成对抗网络(Self-Attention Generative Adversarial Network, SAGAN)
  • 深入理解fork():系统调用创建进程的原理与实践
  • 项目部署:nginx的安装和配置
  • 利用Pandas进行条件替换与向前填充
  • Linux中的命令连接符
  • Layui —— select
  • 图解Java数据容器(三):Queue
  • CAS登录工作流程简述
  • 【前端】【Echarts】ECharts 词云图(WordCloud)教学详解
  • Prompt提示词的主要类型和核心原则
  • 在vscode中和obsidian中使用Mermaid
  • Spring AI Alibaba(2)——通过Graph实现工作流
  • Flutter 与 Android 的互通几种方式
  • Linux 中 sed 命令
  • RedisJSON 路径语法深度解析与实战
  • Spring Boot + Javacv-platform:解锁音视频处理的多元场景