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

Linux 基础IO(下)

目录

前言

深入动静态库

1.概念

2.动静态库和动静态链接

3.ELF文件

a.ELF形成可执行文件

b.ELF可执行文件的加载

4.理解链接与加载

a.静态链接

b.ELF文件的加载

c.动态库是如何和我们的可执行程序关联

d.动态库是如何加载的

结尾


前言

  本篇是我们linux基础IO的最后一篇,也是最难理解的一篇,不过相信大家如果能坚持看完,对文件系统的理解一定是会很深的,加油!!

深入动静态库

1.概念

什么是库:

库是写好的现有的,成熟的,可以复⽤的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个⼈的代码都从零开始,因此库的存在意义⾮同寻常。

本质上来说库是⼀种可执⾏代码的⼆进制形式,可以被操作系统载⼊内存执⾏。库有两种:

• 静态库 .a[Linux]、.lib[windows]

• 动态库 .so[Linux]、.dll[windows]

  • 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库

  • 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。

如果我们不想给对方我们的源代码,直接给他提供我们的.o可重定位目标二进制文件,让对方用自己的代码进行链接(gcc *.o)就行,除此之外还需要.h文件,未来我们可以给对方提供.o(方法的实现)、.h(都有什么方法),但是这样当.o文件大多,就很麻烦,所以我们就尝试着将所有的“.o文件”打一个包(.h文件打不了包,因为.h文件要给使用者阅读看方法是怎么调用的),给对方提供一个库文件即可 —— 也就是产生库的原因,多个.o ——> 库 ——>采用的打包工具和方式的不同又产生了动静态库

所以头文件的本质:对源文件的方法说明文档

所以库的本质:就是源文件对应的.o文件的集合

静态库的本质:就是.o打了个包,linux提供的一个工具就是把所有的.o打包生成一个文件

(具体则是下面的内容)

2.动静态库和动静态链接

动静态库中不需要main函数

生成静态库:

ar -rc libmain.a *.o(这样就可以直接取到所有.o文件然后进行归档打包了)

一般静态库的命名规范:前缀lib,后缀.a

-rc:表示如果在libmain.a中已存在相同.o,直接替换,不存在,直接创建

.a静态库本质是一种归档文件,不需要使用者解包,而用gcc/g++直接进行连接即可

image-20250513112730792

交付库 ——>库文件.a / .so + 匹配的头文件都需要交付

Makefile在发布库之前的前置工作是需要把.c文件形成.o,再所有的.o文件形成静态库

image-20250520220512979

发布库的makefile

image-20250513133847151

image-20250513133900856

然后再将这个目录打包压缩(tar czf mylib.tgz mylib)好,然后把这个软件包放入yum资源当中,别人就可以使用yum下载,或者放到网站当中,然后别人下载解压好,要安装的本质就是把对应的可执行程序拷贝到指定的系统能找到的目录下

如果要链接第三方的库,必须指明库名称;所以在编译时得在后面加 -I(大i)头文件所在路径,以及-L 库文件所在路径还要指明这个库的名称:-l(小L)库名称(要去掉lib和.后面的动静态库的标识);但是我们之前写的c、c++编译时却不需要这些,是因为gcc、g++默认就能对应上c和c++的库

image-20250513173015439

带不带空格都行

image-20250513173249170

image-20250513173338539

形成一个可执行程序,可能不仅仅依赖一个库!!

gcc默认是动态链接的(建议行为),对于特定指定具体的一个库,究竟是动,还是静,取决于你提供的是动态库还是静态库,如果动静态库都有,那么取决于gcc,只要有一个动态库,那么就是动态链接,如果非要静态链接,必须要在最后带-static(一旦带了-static,就必须存在对应的静态库)

以上的操作都是建立在我们的gcc/g++默认就找不到我们的头文件和库的情况下的

如果不想像上面这么麻烦,可以直接把我们的库安装到我们系统头文件路径下

image-20250513185445239

但即便是安装好了,在编译时也是需要把我们的库名称写上去

image-20250513212039572

动态库:

生成.o文件:gcc/g++ -fPIC -c *.c

生成动态库不需要ra来打包直接gcc/g++后加shared即可:gcc/gc++ -shared -o libmyc.so *.o

image-20250520214750253

这种相对位置就是位置无关

image-20250514110335663

image-20250521092932809

生成动态库之后也链接好了,但是我们运行这个程序却会出现:

image-20250521093139168

所以我们生成动态库然后链接之后是没有办法立即运行的

image-20250521094049888

[^]  我们的可执行程序是依赖libmyc.so这个动态库的,但是现在它这里是没有找到的 

开始运行的时候,我们的可执行程序所依赖的动态库是需要被操作系统知道的,系统 != gcc

image-20250513221533556

静态库就不会有这个运行时找不到依赖库的问题,原因:静态库在链接时是直接把库的实现拷贝到可执行程序中,一旦形成可执行程序,可执行程序不再依赖静态库

要如何才能运行:

  1. 我们已经知道主要的原因就是系统找不到这个动态库,而系统主要是在我们的系统目录下或者用户安装库的路径下去找,所以方法1就是把我们自己的动态库拷贝到操作系统内部

  2. image-20250521095346648

  1. 我们有一个环境变量:LD_LIBRARY_PATH,操作系统运行程序,也会在该环境变量下查找动态库,这个环境变量经常都是空的,我们可以把我们的库路径导入到这个环境变量中,进而操作系统就可以找到这个库了

    image-20250521101212697

当然这种方法我们把操作系统一关,它这个环境变量就没了,是临时的

image-20250513214711272

image-20250513214729717

  1. 系统当中会存在/etc/ld.so.conf.d/这样一个路径,在这个路径中都会包含要求系统提前准备好要查找/确认的动态库的路径;所以我们可以在这个路径下新建一个文件,文件内容就是我们动态库所在的路径,这样系统也可以查找到

    image-20250521103057727

然后把我们动态库的路径写入到这个路径下的我们创建的.conf文件中就好

image-20250521103415821

最后我们重新加载一下配置(sudo ldconfig),把这个配置文件在系统中重新生成一下,操作系统就也可以找到我们的动态库了

第三个方法每次关闭重启之后还会有效(永久有效)

  1. 可以在当前路径对这个动态库建立软链接

image-20250513215959859

  1. 也可以在系统路径下建立软链接

image-20250513220209083

image-20250513220420808

对我们的以上内容做一个总结

结论1:我们要使用运行用动态库链接的可执行程序有以下几种方法

  1. 拷贝至系统

  2. 建立软链接

  3. 更改LD_LBRARY环境变量

  4. 在/etc/ld.so.conf.d/路径下新增内容为我们动态库路径的.conf文件,

结论2:在linux下,默认安装的大部分库都是优先安装动态库

结论3:库 :应用程序 = 1 :n

结论4:我们用的vs不仅仅可以形成可执行程序,也能形成动静态库(感兴趣具体可以问问ai)

其他的第三方库

image-20250521115656603

3.ELF文件

上面的动静态库、可执行程序和.o文件都是二进制的,这些二进制文件都有自己固定的格式,这种格式叫做ELF,他们都是以这种格式放入到二进制文件中的

image-20250521155609019

image-20250521155326748

我们首先了解一下ELF文件的构成:

image-20250521161533548

$ size main text data bss dec hex filename 2092 612 4 2708 a94 main

image-20250521161658625

a.ELF形成可执行文件

所以从ELF角度来看通过.o文件加动静态库形成可执行程序实际是这样的:

  1. 将多分c/c++源代码,翻译成为目标 .o 文件

  2. 将多份 .o 文件section进行合并

本质上就是进行二进制文件的合并

image-20250521162516529

[^]  以上只是一个形象图,实际当中合并是在链接时进⾏的,但是并不是这么简单的合并,也会涉及对库合并,此处不做过多追究 

b.ELF可执行文件的加载

image-20250521165751943

我们之前学习到的虚拟地址空间中各区的划分和编译器、二进制可执行程序ELF格式有关,加载到操作系统之前,操作系统要读取可执行程序ELF格式,然后用ELF格式中的相关字段初始化地址空间的结构体变量,进而形成地址空间

查看一个可执行程序的section headers可用readelf -S 可执行程序(比如readelf -S a.out)

屏幕截图 2025-05-22 210407

抬头分别是:名字 类型 起始地址 偏移量

我们目前只需要知道section headers是一个数组,其中0和30位置是每一个数据节的开始和结束

通过起始地址加偏移量就能把每一个节划分出来

查看一个可执行程序的数据节可以用:size 可执行程序

屏幕截图 2025-05-22 204230

其中的bss段是用来存储未初始化的全局变量的,等加载到内存时,才会开辟对应的类型值的空间,所以bss可以根据字母缩写记忆成更好的节省我们形成的可执行程序所占据的磁盘空间,因为有很多的全局变量如果没有初始化,不需要把它的初始值记录下来

查看一个可执行程序合并之后的segment(程序头)可用:readelf -l 可执行程序

image-20250522210710276

原本的30个数据节合并成了9个数据节

image-20250522210918536

在链接的时候把合并的规则放入Program Header Table中,在这个程序要加载,操作系统就会读取Program Header Table,根据这个Program Header Table所描述的找到要加载的在文件当中的位置,加载指定大小,根据大小把它的若干个数据节依次连续加载到空间里,这样就完成了加载的过程

image-20250522211734487

目前我们只需要知道,之前在文件系统部分,我们知道了在磁盘是被我们分区分组划分成以4kb为单位的,那么同样的,在内存中也是一样被分成以4kb为单位的大小,无论是我们之前的写时拷贝开的空间还是以new开辟空间,操作系统都是统一以4kb为单位来给我们分配空间的;都是和上面section合并成segment一样是通过用统一的4kb大小来提供空间,进而减少页面碎片化,统一且提高内存使用效率的做法

我们的可执行程序也是文件——ELF文件

image-20250522213333698

所以这些数据段整体都是按4kb进行保存的

那么节头表(Section header table)又有什么用呢?

当我们在进行链接时,我们需要把所有的.o文件各自的.test和.data还有各种数据节合并一下,合并之后文件整体变大,每一个节点的起始和结束地址都不一样了,所以需要更新Section header table,此时,它表示的是可执行程序是如何形成的,同时在连接时也会把Program header table形成

image-20250522215051139

节头表也告诉操作系统哪些模块可以被加载进内存,以及加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执⾏的也会让操作系统知道

查看一个可执行程序的ELF Header可以用:readelf -h 可执行程序

image-20250523131437560

Magic可以让系统判断这个文件是不是ELF格式的文件

4.理解链接与加载

理解链接的本质就是在深入理解.o文件

a.静态链接

查看编译后的.o目标文件可用:objdump -d(一部分反汇编代码) .o文件

image-20250523141306162

image-20250523141348059

所以.o文件在链接时它的地址会被重新修改(链接过程中会涉及到对.o外部符号进行地址的重定位),因此叫做可重定向目标文件,

b.ELF文件的加载

image-20250523154654064

所以进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?从ELF中各个segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start, end]等范围数据,另外在⽤详细地址,填充⻚表;

所以:虚拟地址机制,不光光操作系统要支持,编译器也要支持;多个section进行合并之后就是在对可执行程序进行统一编址,有了统一的地址之后,用真实地址填充在.o文件中call后的空地址,就产生了可执行程序内部互相之间的调用关系

用图梳理上述内容:ELF程序是如何转换成为进程的

可执行程序加载进内存时,系统创建tast_struct进程pcb,还要创建对应的mm_struct(管理进程虚拟地址空间的数据结构)结构体

image-20250523215558638

entry point address(入口地址)就是虚拟地址,在磁盘上也叫逻辑地址

image-20250523185849067

再放大到mm_struct的构建:

image-20250523184302703

动静态库都适用的ELF程序是如何加载到内存中的图

image-20250523185111630

c.动态库是如何和我们的可执行程序关联

静态库一般不考虑它的加载过程,不存在加载的问题,因为静态库和.o文件链接形成一个可执行程序了,静态库就是以ELF为载体加载的,所以只要把ELF文件的加载过程弄清楚,就不需要考虑其他的了

image-20250514105313091

库函数的调用分两步

  1. 被进程看到:动态库映射到进程的地址空间(映射到对应的共享区)

  2. 被进程调用:在进程的地址空间中进行跳转(从代码区跳转到共享区去访问库)

    image-20250523203047907

d.动态库是如何加载的

先给一个结论:动态链接实际上将链接的整个过程推迟到了程序加载的时候

1.要弄清动态库的加载,首先我们的知道:其实我们的可执行程序被编译器动了手脚

image-20250523210515734

动态链接器:

  • 动态链接器(如 ld - linux.so)负责在程序运行时加载动态库。

  • 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。

环境变量和配置文件:

  • Linux 系统通过环境变量(如 LD_LIBRARY_PATH)和配置文件(如 /etc/ld.so.conf 及其子配置文件)来指定动态库的搜索路径。

  • 这些路径会被动态链接器在加载动态库时搜索。

缓存文件:

  • 为了提高动态库的加载效率,Linux 系统会维护一个名为 /etc/ld.so.cache 的缓存文件。

  • 该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。

image-20250523212007895

image-20250523212153707

上述过程描述了C/C++程序在main函数之前执⾏的⼀系列操作,但这些操作对于⼤多数程序员来说是透明的。程序员通常只需要关注main函数中的代码,⽽不需要关⼼底层的初始化过程。然⽽,了解这些底层细节有助于更好地理解程序的执⾏流程和调试问题

2.动态库也是ELF文件,我们也理解成为:统一编址,采用绝对编址的方案进行编址的(平坦模式):起始地址(0) + 偏移量

image-20250523213221685

注意:

• 动态库也是⼀个⽂件,要访问也是要被先加载,要加载也是要被打开的

• 让我们的进程找到动态库的本质:也是⽂件操作,不过我们访问库函数,是通过虚拟地址进⾏跳转访问的,所以需要把动态库映射到进程的地址空间中

image-20250523220155586

动态库加载和调用的过程

就是在可执行程序中找到要用到相关动态库,确定它的起始偏移地址,然后加载到内存中,内存通过页表映射到PCB指向的进程的虚拟地址处的共享区,此时进程就确定了这个库的起始地址,而我们进程在运行时代码段的该动态库的函数方法要使用时,这些函数方法内部会包含它在库中的偏移量,通过起始地址加上偏移量进而成功加载调用

• 库已经被我们映射到了当前进程的地址空间中

• 库的虚拟起始地址我们也已经知道了

• 库中每⼀个⽅法的偏移量地址我们也知道

• 所有:访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅法

• ⽽且:整个调⽤过程,是从代码区跳转到共享区,调⽤完毕在返回到代码区,整个过程全 在进程地址空间中进⾏的

调用

image-20250523222047275

• 也就是说,我们的程序运⾏之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道

• 然后对我们加载到内存中的程序的库函数调⽤进⾏地址修改,在内存中⼆次完成地址设置(这个叫做加载地址重定位)

但是但是!!!,我们发现上图中的操作居然对代码区进行了修改,我们要知道,代码区是不可以被修改的(在进程中是只读的),所以我们不能通过上图的方式来取得库方法地址进行调用,而是沿用起始地址+偏移量的思想,动态链接采⽤的做法是在 .data(可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址,(因为.data区域是可读写的,所以可以⽀持动态进⾏修改)

image-20250524092523010

加载时地址重定位

[^]  在编译的时候只初始化偏移量,在加载库链接成功之后,动态修改该data数据区所对应的库的虚拟地址,从而可以完成从可执行程序到库的跳转,整个过程代码区不受影响 

  1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,库代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表。

  2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表。

  3. 在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址。

  4. 这种⽅式实现的动态链接就被叫做PIC地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。

上面我们了解了可执行程序调用依赖库,那库之间也有相同的依赖关系吗?

答:有的,那么如何做到库和库之间互相调⽤也是与地址⽆关的呢,其实库中也有.GOT,和可执⾏⼀样!这也就是为什么⼤家为什么都是ELF的格式

image-20250524094640318

但是这样也就意味着无论是可执行程序调库,还是库之间调用依赖都需要让动态链接在程序加载的时候需要对⼤量函数进⾏重定位,这⼀步显然是⾮常耗时的。为了进⼀步降低开销,我们的操作系统还做了⼀些其他的优化,⽐如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序⼀开始就对所有函数进⾏重定位,不如将这个过程推迟到函数第⼀次被调⽤的时候,因为绝⼤多数动态库中的函数可能在程序运⾏期间⼀次都不会被使⽤到。

思路是:GOT中的跳转地址默认会指向⼀段辅助代码,它也被叫做桩代码/stup。在我们第⼀次调⽤函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调⽤函数的时候,就会直接跳转到动态库中真正的函数实现

image-20250524095827254

image-20250524095857008

[^]  解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程 

总⽽⾔之,动态链接实际上将链接的整个过程,⽐如符号查询、地址的重定位从编译时推迟到了程序的运⾏时,它虽然牺牲了⼀定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利⽤磁盘空间和内存资源,以极⼤⽅便了代码的更新和维护,更关键的是,它实现了⼆进制级别的代码复⽤

结尾

  大家看完下来是不是正如我所说的那样,对于文件系统有了比较深层次的理解呢,感觉比较乱和不理解也没关系,因为这部分内容本来就比较杂且具有深度,下来可以再反复消化一下

http://www.xdnf.cn/news/938431.html

相关文章:

  • Linux 内核内存管理子系统全面解析与体系构建
  • 基于cornerstone3D的dicom影像浏览器 第三十章 心胸比例测量工具CTRTool
  • 深入浅出WebGL:在浏览器中解锁3D世界的魔法钥匙
  • 隐函数 因变量确定标准
  • 《从零掌握MIPI CSI-2: 协议精解与FPGA摄像头开发实战》-- CSI-2 协议详细解析 (三)数据格式
  • (LeetCode 动态规划(基础版))96. 不同的二叉搜索树 (递推 || 递归)
  • 自定义连接线程池
  • 【Erdas实验教程】016:遥感图像空间增强(卷积增强)
  • 01.SQL语言概述
  • 华为OD机考- 简单的自动曝光/平均像素
  • (每日一道算法题)验证二叉搜索树
  • 随机算法一文深度全解
  • Dify 工作流全解:模块组成、设计思路与DSL实战指南
  • 【ROS2】核心概念8——参数设置(Parameters)
  • 商家平台AI智能搜索工程实践|RAG|向量检索增强
  • AT_abc409_e [ABC409E] Pair Annihilation
  • 三级流水线是什么?
  • OpenJudge | 大整数乘法
  • 5.子网划分及分片相关计算
  • python中使用LibreHardwareMonitorLib.dll获取电脑硬件信息~~【不用同步打开exe文件】
  • Docker知识五:服务编排(Docker Compose概念)
  • [M132][Part_1] chromium codelab
  • JDK 17 新特性
  • three.js 零基础到入门
  • GeoBoundaries下载行政区划边界数据(提供中国资源shapefile)
  • 重复文件管理 一键清理重复 图片 文档 免费 超轻量无广告
  • 机器学习 [白板推导](四)[降维]
  • SpringBoot自定义EndPoint实现线程池动态管理
  • 6月8日day48打卡
  • 动态工作流:目标结构来自外部数据集