深入了解linux系统—— 库的链接和加载

article/2025/8/25 2:46:14

一、目标文件

我们知道源文件经过编译链接形成可执行程序,在Windows下这两个步骤被IDEA封装的很完美,我们使用起来也非常方便;

Linux中,我们可以通过gcc编译器来完成编译链接这一系列操作。

而源文件经过编译形成.o文件,而库文件是由.o文件形成的;那.o文件是什么呢?

.o文件被称为目标文件,也被称为可重定位目标文件;

目标文件是一个二进制文件,其格式是ELF

二、ELF文件

而我们还知道,我们源文件经过编译形成.o文件,然后再经过链接(将所有的.o文件合并,链接库文件)才能形成可执行文件。

那在链接的过程中做了什么呢?我们的.o文件为什么能够和库文件进行链接呢?

目标文件的文件格式是ELF、库文件的文件格式也是ELF、可执行文件的文件格式也是ELF

在这里插入图片描述

可以看到.o目标文件、共享目标文件、可执行文件的文件格式都是ELF

可重定位文件:.o文件

可执行文件:可执行程序

共享目标文件:.so库文件

内核转储:存放当前进程的执行上下文,用于dump信号触发。

ELF文件组成

.o、库文件.so(静态库.a.o的归档文件)以及可执行文件都是ELF格式文件,那ELF文件都包含什么呢?

一般ELF文件都包含以下几部分:

  1. ELF头(ELF Header
  2. 程序头表(program Header Table
  3. 节(Section)
  4. 节头表(Section Header Table

在这里插入图片描述

那这些部分都包含哪些内容呢?

Linux系统中,我们可以通过指令readelf来查看ELF格式文件的这几个部分的内容。

节(Section

节是ELF文件中的基本组成单位,包含了特定类型的数据;ELF文件的各种信息和数据都存储在不同的节中。

例如.text.data.bss等。

这里简单了解一下.bss:我们知道未初始化的全局变量它默认就是0;为什么呢?

初始化的全局变量,我们就要记录下来类型和数值;这些存储在.data中;那未初始化的全局变量它需要存储数值吗?

很显然不需要,这些数值都是0,所以未初始化的全局变量就存储在.bss段,只存储类型/个数,这样在程序加载到内存时,再对.bss段进行展开并初始化成0

节头表(Section Header Table

ELF文件中存在非常多的节,那如何区分这些节呢?

在节头表Section Header Table中,就记录了每一个节的描述信息。(比如NameTypeAddress

我们可以使用readelf -S来查看一个ELF文件的节头表:

在这里插入图片描述

这里内容比较多,只截取了一部分;

在这里面,我们可以看到存在.text代码、.data数据、rodata只读数据、.bss等。

程序头表Program Header Table

在程序头表中,记录了所有有效段和它们的属性。

在表中记录着每一段的开始位置和位移offset、长度;以及这些段如何去合并

我们可以使用readelf -l查看ELF文件的程序头表

在这里插入图片描述

ELF Header

ELF Header中,记录了文件的主要特性,程序的入口地址、节的个数,大小等等。

readelf -h可以查看一个ELF文件的ELF Header

在这里插入图片描述

可执行程序的形成

了解了ELF文件的组成,感觉还是云里雾里的;(这里了解ELF文件中有哪几个部分组成,每一个部分大概内容即可)

.o文件,库文件.so以及可执行文件都是ELF文件,那我们的可执行程序(文件)如何形成的呢?

这个就比较简单了,因为我们的.o文件的格式都是ELF,所以我们所有的.o文件形成可执行时,只需要将所有相同的数据节进行合并形成一个大的数据节;也就是形成一个大的ELF格式的文件。

在这里插入图片描述

这里简单了解一下,在后续链接和加载内容中详细说明。

ELF可执行文件的加载

我们知道一个ELF文件中存在非常多的Section,在加载到内存时,也会进行Section的合并,形成segment

合并规则:相同属性,可读/可写/可执行等等。

这样不同的Section在加载到内存时,就会合并成segment

而合并方式在形成ELF时就已经确定了,在ELF的程序头表Paogram Header Table中我们可以查看。

在这里插入图片描述

可以看到我们的.text代码段和.rodata只读数据段是被合并到一个segment的;

.got.data.bss段这些可读可写的数据是合并到一个segment的。

看到这里,可以会感到疑问,为什么要将这些数据节合并成segment呢?

答案就是提高空间利用率,在内存中空间也是以4KB为基本单位的,那也就是说就算我们只需要1KB,在申请内存空间时也是申请4KB的空间。

  • 而我们ELF文件中存在那么多节,如果我们不进行合并就发发现在内存中有非常多的块4KB空间中都存在浪费的空间,而合并节形成segment就是为了减小页面碎片,提供内存使用率。
  • 还用一点就是:将相同权限的节进行合并,这样具有相同属性的节就会形成一个大的segment;这样就可以更好的进行内存管理和权限访问控制。

三、链接和加载

静态链接

首先我们要知道,静态链接本质上就是将我们所有的.o文件和静态库文件进行合并,形成可执行;(静态库就是.o文件的归档,所以:静态链接本质上就和将所有的.o链接起来)

.o文件是如何链接的呢?

现在有存在code.cfun.c两份源文件,简单代码:

//code.c
#include <stdio.h>
void fun();
int main()
{fun();printf("code: \n");return 0;
}
//fun.c
#include <stdio.h>
void fun()
{printf("fun: \n");
}

我们知道编译形成的.o以及最终形成的可执行程序都是二进制文件,我们可以使用objdump对这些二进制文件进行反汇编。

objdump -d对代码部分进行反汇编。
在这里插入图片描述

我们可以发现,在code.cfun.c各自经过编译后形成的.o文件,经过反汇编,我们发现,在code.s中调用fun函数时,cal调用的函数地址是0,调用printf函数的地址也是0;在fun.scall调用printf的地址也是0

我们再来对可执行程序进行返汇编查看一下:

在这里插入图片描述

可以看到,在可执行程序反汇编形成的文件中,callq调用函数时 就有了函数的地址。

所以,在编译code.c文件时,编译器完全不知道fun函数和printf函数的存在,不知道这两个函数的地址;编译器就只好将这两个函数的地址设置成0。(所以,当我们调用一个没有实现的函数时,是不会编译报错的;在链接时,对照符号表发现不存在的符号才会报错

而在链接时,这些0地址才会被修正;为了让链接器能够更好的进行地址的修正,还存在一个符号表。

readelf -s可以查看ELF文件的符号表

在这里插入图片描述

在链接时,对照符号表,根据表里记录的地址来修正函数的地址。

在这里插入图片描述

这里,前面的数字表示fun函数被合并到了哪一个segment

所以,链接的本质上就是编译之后的所有目标文件连同用到的一些静态库运行时库组合,形成一个独立的可执行文件。

当所有模块组合在一起之后,链接器就会根据我们的.o文件或者静态库中的重定位表找到那些被重定位的函数,从而修改它们的地址。

我们链接的过程中就会涉及到对目标文件.o的地址修正(地址重定位);所以.o目标文件也被称为可重定位目标文件。

加载ELF和进程地址空间

这里有一个问题:进程地址空间mm_structvm_area_struct在进程刚创建时,初始化数据从哪里来?

我们对.o文件、可执行文件进行反汇编可以发现,一个ELF文件,在还没有被加载到内存时,在其内部就存在地址;

在这里插入图片描述

上图中最左侧就是ELF文件中的地址;严格意义上将,这种地址应该叫做逻辑地址:起始位置+偏移量。

而当我们把起始位置当做0,此时就成了虚拟地址;也就是说,在我们的程序还没加载到内存时,就已经把可执行程序进行统一编址了。

所以,我们进程在创建时,虚拟地址空间mm_structvm_arae_sruct的初始化数据从哪里来?就显而易见了;

从ELF中的segment中来,而每一个segment都有自己的起始位置和长度,就用来初始化内核数据结构vm_area_struct[start , end]等数据,以及初始化页表。

所以虚拟地址,不仅操作系统要支持,而编译器也要支持。(因为程序在还没有加载到内存时,就已经进行统一编址了

进程地址空间

所以,在程序运行时,该可执行程序要加载到内存;而进程的进程地址空间mm_struct中的虚拟地址从可执行程序中来,而可执行程序的代码和数据要加载到内存;操作系统就要为这些代码和数据开辟空间,然后填充页表。

这样进程才能被CPU调度,那CPU是如何知道进程的起始地址呢?

还记得在ELF格式的ELF Header中,存在一个Entry point address入口地址,所以在CPU调度进程时,CPU中的EIP寄存器,就会记录下一条要运行指令的地址;而在CPU在还存在CR3寄存器,它指向当前进程的页表。

所以在进程被调度时,就会把Entry point address入口地址拷贝到CPU中的EIP寄存器中,然后再修改CPU中其他信息(如CR3等);这样CPU就知道进程的起始地址;而且还知道当前进程的页表,根据进程地址空间中的虚拟地址,查找页表就可以找到当前进程代码和数据的物理地址。

在这里插入图片描述

动态库

动态链接,简单来说就是将可执行程序和库产生关联,然后在程序运行时再加载动态库;

这也是我们在动态链接我们自己的库,生成可执行,在运行时还需要让系统找到我们的库的原因。

程序在运行时,才会加载动态库,那进程如何看到我们的动态库呢?

这个问题还是比较简单的,我们程序在运行时加载到内存中;

在我们的可执行程序中,知道依赖哪些库,我们就要加载这些库;而要加载这些库就要先找到这些库。

所以我们的进程就可以根据依赖哪些库,库的路径找到这些库,然后将这些库加载到内存中;此外也要将库映射到进程的地址空间中。

了解了一个进程如何找到我们的动态库;我们还知道动态库也被称为共享目标文件,也就是说我们的库可以被多个进程共享的。

所以在我们进程去找自己依赖的库时,如果当前库已经被加载到内存了,当前进程就可以根据库文件的struct file找到库文件的struct path,再根据path找到struct dentry然后就可以找到库文件的inode这样就可以找到库文件。

这样讲库映射到进程的地址空间中,这样多个进程就共享同一个库了;而在内存中库文件就只存在一份

在这里插入图片描述

动态链接

我们知道静态链接就是将静态库合并到我们的可执行程序中,这样静态链接形成的可执行不依赖库,就可以执行;按理来说应该比的链接更加方便。

但是,当我们静态库文件特别大,我们如果使用静态链接,这样形成的可执行都包含一份静态库代码;而当程序运行起来时,在内存中就势必会存在多份源文件代码。

再看动态链接,只是将可执行文件和动态库文件产生关联,在程序运行时才进行链接,可执行文件中不存在库代码;而且在内存中,多个进程可以共享一个动态库,在内存中也不会出现多份库代码。

那动态链接如何实现的呢?

总的来讲,动态链接实际上只是将链接的工作延迟到运行时;也就是在程序运行时才会加载动态库。

可执行程序被编译器处理过

C/C++程序开始执行时,它并不是直接执行main函数;

实际是程序的入口不是main,而是_start

在这里插入图片描述

可以看到可执行程序的入口地址并不是main、而是_startLinux下);_start函数是C运行时库(glibc)或者链接器(id)提供的特殊函数。

_start函数它做了什么呢?

  1. 设置堆栈:为程序创建堆栈环境。
  2. 初始化数据段:将程序的数据段(全局变量/静态变量)从初始化数据段拷贝到相应的内存位置,清零未初始化的数据段。
  3. 动态链接:(关键)_start函数会调用动态链接器的代码来解析和加载程序所以来的动态库;动态链接器会处理所有符号解析和重定位,确保程序中的函数调用和变量访问能正确映射到动态库中的实际位置。
  4. 调用_libc_start_main:链接完成之后,_start函数就会调用libc_start_main函数(glibc提供的);lib_start_main函数负责执行额外的初始化工作(例如设置信号处理函数,初始化线程库等);
  5. 调用main函数:lib_start_main调用程序的main函数,此时程序的执行才到了用户编写的代码;
  6. 处理main返回值:main返回时,lib_start_main就会处理这个返回值,然后调用_exit终止程序。

动态链接器:负责在程序运行时加载动态库

在这里插入图片描述

可以看到这里程序都依赖ld-linux-x86-64.so.2这一个库。也就是动态链接器库

  • 在程序启动时,动态链接器就会解析程序中的动态库依赖,并将这些库加载到内存中;
  • Linux系统通过环境变量(LD_LIBRARY_PATH)/配置文件(etc/ld.so.conf)来指定动态库的搜索路径;这些路径会被动态链接器在加载动态库时进行搜索。

缓存文件:

  • 为了提高动态库的加载效率,Linux系统会维护一个/etc/ld.so.cache的缓冲文件;
  • 该文件保存了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会优先搜索这个缓冲文件。

库函数调用

说了这么多,那动态库该如何链接和加载的呢?我们的程序又是如何调用库函数呢?

我们知道,库要被映射到当前进程的地址空间中,所以我们进程是知道库在自己进程地址空间的起始位置的;

那库.so也是ELF文件,那就也存在ELF Header,那库的虚拟起始地址我们也可以知道。

最后库中每一个方法的偏移量地址,我们也知道(ELF符号表中)

那我们就清楚了,库映射到进程地址空间后,我们只需要知道库的起始虚拟地址和要调用方法的偏移量就可以找到库中的方法。

那也就是说,我们在动态链接的过程中,我们只需要记录下来调用库函数依赖的库名称,和该库函数中动态库中的偏移量地址即可。

在这里插入图片描述

全局偏移量表GOT

  • 在动态链接的过程中,在程序中就下来了库函数所依赖的库名称,以及该函数在动态库中的偏移量地址。
  • 这样在程序运行时,要进行动态库的加载(如果库在内存中已经存在,就直接映射),这样在程序的地址空间中就存在了动态库的映射;那也就知道了库的起始虚拟地址;
  • 这样,我们再对加载到内存中的程序的库函数调用处,修改动态库的地址;在内存中完成二次地址重定位。

但是,我们知道代码区是只读的;那如何修改呢?代码区是不能修改的。

这里的动态链接的做法就是:在.data区中专门预留一片区域来专门存放函数的跳转地址。

这里也被叫做全局偏移量表GOT,在该表中存储着该模块运行要调用的全局变量/函数的地址。

在这里插入图片描述

所以,在动态链接的时候,在可执行程序中就存在了全局偏移量表got;在动态链接时在表中就存放了调用库函数的库名称和函数的 偏移量地址。

这样执行加载时,库加载映射到进程的进程地址空间中,然后修改got表中的库的虚拟地址即可。

在这里插入图片描述

因为代码段是只读的,不能修改的,所以有了GOT表;代码可以被多个进程共享,但是在不同的地址空间中库的绝对地址、相对位置都不同;所以每一个进程的每一个动态库都有自己的GOT表。

  • 在一个.so动态库中,GOT表和.text的相对位置都是固定的,就可以使用CPU的相对寻址来查找GOT表;
  • 在调用库函数时,就会先查GOT表,根据表中的地址进行跳转,跳转到要调用函数的位置。(这里表中的地址在库加载就会被修改成真实的地址)。

这种方式实现动态链接被称为 地址无关码

简单来说就是:我们动态库不需要做任何修改,被加载到任意内存地址处都能正常运行,且能够被所有进程共享。

这也就是在制作动态库,生成.o文件要带-fPIC选项的原因。

什么是plc

在这里插入图片描述

我们通过查看汇编代码可以发现,在进行库函数调用时,存在一个plc<puts@plc>)。

这里plc指的是什么呢?

plc简单来说就是延迟绑定

我们知道动态链接在程序加载时需要进程大量函数地址的重定位(修改大量的函数地址),显然是非常耗费时间的;并且有很多函数我们并没有调用。

为了进一步降低消耗,操作系统就会做优化:延迟绑定也就是plc

GOT表中跳转地址默认会指向一段辅助代码,这段代码也被称为桩代码/stup

在第一次进行函数调用时,/stup代码就负责去查询函数真正的跳转地址,并且更新GOT表。

这样当我们再次调用函数时,就会直接跳转到动态库中函数真正的地址。

最后在这里补充一点:库函数也会调用其他库函数,也就是库之间也存在依赖。

动态文件中也存在GOT表,因为动态库.so文件也是ELF格式。

到这里本篇文件内容就大致结束了

简单总结:

  • ELF文件ELF HeaderProgram Header TableSection Header TableSection
  • 静态链接:将所有目标文件和静态库文件进行合并,进程地址重定位。
  • 进程地址空间:在ELF格式文件中存在逻辑地址(起始位置+偏移量),起始位置为0,偏移量地址也就是虚拟地址;在进程创建时进程地址空间mm_structvm_area_struct中初始化数据就来源于可执行文件中的地址。
  • 动态链接:链接时在可执行文件中记录库函数所依赖的库和偏移量地址(GOT表),在加载时根据动态库在进程地址空间的映射位置进行地址重定位;这样无论库加载到内存的什么地方,都要映射到进程地址空间中,这样在执行函数时通过查找GOT表的方式进行调用。
  • PLC延迟绑定:在第一次进行调用函数时,GOT表中指向辅助代码/stup,去查找函数真正的跳转地址,并更新GOT表;再次调用函数时,就直接跳转到函数的真正地址。

http://www.hkcw.cn/article/vdVFdHwWxc.shtml

相关文章

Cesium 8 ,在 Cesium 上实现雷达动画和车辆动画效果,并控制显示和隐藏

目录 ✨前言 一、功能背景 1.1 核心功能概览 1.2 技术栈与工具 二、车辆动画 2.1 模型坐标 2.2 组合渲染 2.3 显隐状态 2.4 模型文件 三、雷达动画 3.1 创建元素 3.2 动画解析 3.3 坐标联动 3.4 交互事件 四、完整代码 4.1 属性参数 4.2 逻辑代码 加载车辆动画…

ElasticSearch简介及常用操作指南

一. ElasticSearch简介 ElasticSearch 是一个基于 Lucene 构建的开源、分布式、RESTful 风格的搜索和分析引擎。 1. 核心功能 强大的搜索能力 它能够提供全文检索功能。例如&#xff0c;在海量的文档数据中&#xff0c;可以快速准确地查找到包含特定关键词的文档。这在处理诸如…

Transformer《Attention is all you need》

发布时间&#xff1a;2017/06/12 发布单位&#xff1a;Google、多伦多⼤学 简单摘要&#xff1a;直译为“变换器”&#xff0c;⼀种采⽤⾃注意⼒机制的深度学习模型&#xff0c;按照输⼊数据各部分重要 性不同⽽分配不同权重。⼴泛⽤于NLP和CV领域。 阅读重点&#xff1a;s…

html+css+js趣味小游戏~HexGL赛车竞速(附源码)

下面是一个简单的记忆卡片配对游戏的完整代码&#xff0c;使用HTML、CSS和JavaScript实现&#xff1a; html <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><meta name"viewport" content"wid…

VL 中间语言核心技术架构:构建全链路开发生态

一、VL 中间语言核心架构&#xff1a;全链路开发的三大关键层级 在企业级应用开发面临效率与技术深度双重挑战的背景下&#xff0c;iVX 自主研发的 VL&#xff08;Visual Language&#xff09;中间语言体系&#xff0c;通过 "可视化建模 - 语义编译 - 多端适配" 三大…

GPU 图形计算综述 (二):固定管线

在计算机图形学中&#xff0c;图形管线&#xff08;Graphics Pipeline&#xff09;是指通过一系列软硬件算法&#xff0c;将三维空间中的物体表征&#xff0c;转换为二维空间的物体表征的过程。一般通过3D网格&#xff08;Mesh&#xff09;等图元&#xff08;Primitive&#xf…

Manus数据手套:赋能人形机器人遥操作与AI数据训练的创新力量

人形机器人技术与AI技术正在进入蓬勃发展的黄金时代。特斯拉高调发布其即将推向市场的人形机器人Optimus&#xff0c;引发全球瞩目&#xff1b;与此同时&#xff0c;国内人形机器人产业也如雨后春笋般迅速崛起&#xff0c;展现出强劲的发展势头。在这一技术浪潮中&#xff0c;M…

C# 控制台程序实现定时自动退出

一、基础实现方式&#xff1a;同步阻塞等待 通过Thread.Sleep暂停主线程&#xff0c;适合简单场景&#xff08;需阻塞当前线程&#xff09;。 static void Main(string[] args) { Console.WriteLine("程序启动&#xff0c;5秒后自动退出..."); Thread.Slee…

【笔记】suna部署之获取 Firecrawl API key

#工作记录 Firecrawl 一、前期准备 在进行 Suna 部署时&#xff0c;获取 Firecrawl API key 是其中一个关键步骤。Firecrawl 是一款功能强大的工具&#xff0c;在 Suna 项目中可发挥重要作用&#xff0c;比如助力数据获取等相关任务。 二、获取步骤 &#xff08;一&#xff…

花哨桌面 V 3.0.0 (火影忍者版)

废话不多说,直接上链接,源码在之前版本的帖子里,本次主要修改了部分元素. 功能也不描述了哦 效果图

西门子PLC结构化编程_优化后的调节阀标准块

文章目录 前言一、功能概述二、程序编写1.新建数据类型“5_RegvalveType”2.新建FB块“6_Regvalve”3.SCL和LAD混合编程 总结 前言 在之前的文章中&#xff0c;分享过一个基于SCL语言实现的调节阀控制块西门子PLC常用底层逻辑块分享_调节阀&#xff0c;在实际应用过程中&#…

react-color-palette源码解析

项目中用到了react-color-palette组件&#xff0c;以前对第三方组件都是不求甚解&#xff0c;这次想了解一下其实现细节。 简介 react-color-palette 是一个用于创建颜色调色板的 React 组件。它提供了一个简单易用的接口&#xff0c;让开发者可以轻松地创建和管理颜色调色板。…

(一)视觉——工业相机(以海康威视为例)

一、工业相机介绍 工业相机是机器视觉系统中的一个关键组件&#xff0c;其最本质的功能就是将光信号转变成有序的电信号。选择合适的相机也是机器视觉系统设计中的重要环节&#xff0c;相机的选择不仅直接决定所采集到的图像分辨率、图像质量等&#xff0c;同时也与整个系统的运…

PnP(Perspective-n-Point)算法 | 用于求解已知n个3D点及其对应2D投影点的相机位姿

什么是PnP算法&#xff1f; PnP 全称是 Perspective-n-Point&#xff0c;中文叫“n点透视问题”。它的目标是&#xff1a; 已知一些空间中已知3D点的位置&#xff08;世界坐标&#xff09;和它们对应的2D图像像素坐标&#xff0c;求解摄像机的姿态&#xff08;位置和平移&…

C++核心编程_4.5 运算符重载_4.5.1 加号运算符重载

#include <iostream> #include <string> using namespace std;/* ### 4.5 运算符重载 运算符重载概念&#xff1a;对已有的运算符重新进行定义&#xff0c;赋予其另一种功能&#xff0c;以适应不同的数据类型 *//* 4.5.1 加号运算符重载 作用&#xff1a;实现两…

文本预处理

文本预处理 1 词向量表示 1.1 word2vec之skipgram方式&#xff1a; 定义&#xff1a;给你一段文本&#xff0c;选定特定的窗口长度&#xff0c;然后利用中间词来预测上下文 实现过程&#xff1a;1、选定一个窗口长度&#xff1a;3、5、7等&#xff1b;2、指定词向量的维度&a…

C++中单例模式详解

在C中&#xff0c;单例模式 (Singleton Pattern) 确保一个类只有一个实例&#xff0c;并提供一个全局访问点来获取这个实例。这在需要一个全局对象来协调整个系统行为的场景中非常有用。 为什么要有单例模式&#xff1f; 在许多项目中&#xff0c;某些类从逻辑上讲只需要一个实…

什么是单片机?

众所周知&#xff0c;人类行为受大脑调控&#xff0c;正如视觉、听觉、味觉、嗅觉、触觉及运动功能等感官与肢体活动均受其指挥&#xff1b;换言之&#xff0c;大脑作为人体的中枢神经系统&#xff0c;负责管理所有可控制的生理功能。 在电子设备领域&#xff0c;单片机…

DMBOK对比知识点整理(4)

1.常见数据质量维度 常见数据质量维度(DMBOK-P353)质量维度

Web攻防-SQL注入增删改查盲注延时布尔报错有无回显错误处理

知识点&#xff1a; 1、Web攻防-SQL注入-操作方法&增删改查 2、Web攻防-SQL注入-布尔&延时&报错&盲注 案例说明&#xff1a; 在应用中&#xff0c;存在增删改查数据的操作&#xff0c;其中SQL语句结构不一导致注入语句也要针对应用达到兼容执行&#xff0c;另…