库的制作与原理

发布于:2025-07-19 ⋅ 阅读:(18) ⋅ 点赞:(0)

目录

什么是库?

静态库

静态库的制作

动态库

动态库的制作

动静态库制作总结

ELF文件

ELF从形成到加载轮廓

ELF文件形成可执行

ELF可执⾏⽂件加载

理解加载

静态链接

总结

动态链接


什么是库?

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

1.

本质上来说库是⼀种可执⾏代码的⼆进制形式(本质就是二进制文件),可以被操作系统载⼊内存执⾏。

2.

通俗的来讲,库其实就是一个个.o文件的集合(多个.o文件打包)(是多个源文件汇编而来),.o文件里面大多都是函数的实现和各种变量,使用时,库并不需要解压,它是归档文件,直接使用gcc/g++链接即可使用,而.h文件其实就是一个说明文档(各种声明函数和变量),可以一般给使用者直接阅读。

3.

既然库是.o文件的集合,那么库中肯定无main函数,所以当你不想让某个库别人使用,就可以在库中加一个main函数,这样,使用者链接的时候,就会因为出现多个main函数而报错。

4.

库有两种:

  • 静态库 :.a[Linux] 、 .lib[windows]
  • 动态库 .so[Linux] 、 .dll[windows]

在linux下,库一般存放在/lib64下。

静态库

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

⼀个可执⾏程序可能⽤到许多的库,这些库运⾏有的是静态库,有的是动态库,⽽我们的编译默 认为动态链接库,只有在该库下找不到动态.so的时候才会采⽤同名静态库。我们也可以使⽤gcc 的-static 强转设置链接静态库。

静态库的制作

制作静态库语法:

ar -rc 静态库名字 .o文件

ar 是 gnu 归档⼯具, rc 表⽰ (replace and create)

注意:命名库名称需要以lib开头,.a/.so结尾,而真正的库名字其实就是中间的(去掉前缀和后缀)。

我们现在制作一个库,并且将这个库打包,交给他人使用:

makefile代码:

make一下:

打包一下:

我们现在交给xxx用户使用:

交给别人使用,只需要将我们的lib文件解压即可使用,不需要将库解压。

我们将main.c编译汇编成main.o文件:

表示找不到test.h头文件,因为我们直接这样写,OS默认到系统路径下和当前路径下找头文件,没找到就报错。

所以两种解决办法:

第一种:将test.h头文件导入系统头文件默认路径下。

系统头文件默认路径下是:/usr/include

第二种办法:

利用指令指定路径寻找头文件。

执行程序:

我们可以直接指令指定库搜索路径来生成可执行程序,如果不指定库搜索路径,OS就会默认到 /lib64 路径下搜索!

-L: 指定库路径    -l: 指定库名

我们当然也可以将库放到系统默认路径(/lib64)下,这样我们就不用指定路径了,但仍需要指定库名!

总结:

当然我们不需一定要经过.o才能编成可执行程序,我们这里只是帮助理解,我们可以直接从.c编译成可执行程序。

同时,我们可以看一下,main这个可执行程序,比较大,有8592字节,这就是静态链接的特点,可执行程序较大,在链接过程中,会将库中全部代码拷贝到可执行文件中!

而且,已经形成了可执行程序,我们将库删掉,依然可以运行:

动态库

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

  • ⼀个与动态库链接的可执⾏⽂件仅仅包含它⽤到的函数⼊⼝地址的⼀个表,⽽不是外部函数所在⽬ 标⽂件的整个机器码。
  • 在可执⾏⽂件开始运⾏以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中, 这个过程称为动态链接(dynamiclinking)
  • 动态库可以在多个程序间共享,所以动态链接使得可执⾏⽂件更⼩,节省了磁盘空间。操作系统采 ⽤虚拟内存机制允许物理内存中的⼀份动态库被要⽤到该库的所有进程共⽤,节省了内存和磁盘空 间。

动态库的制作

动态库制作语法:

gcc -fPIC -c 源文件  //生成对应的.o文件
gcc -shared -o 动态库名字 .o文件  //制作动态库

当然,也可以这样:

gcc -shared -fPIC -o 动态库名 源文件 

当然也可以使用g++来制作自己的库,像gcc一样。

注意:命名规则和静态库一样,去掉前缀和后缀才是真正的名字。

shared:表⽰⽣成共享库格式    fPIC:产⽣位置⽆关码(positionindependentcode)

和静态库不同的是动态库可以直接使用gcc制作,而静态库制作需要借助ar。

制作:

打包:

交给xxx用户使用:

解包:

直接生成可执行程序:

直接指定头文件路径,库路径,库名

但是运行时出错:

查看可执行程序依赖的库:

ldd  可执行程序

为什么会出现运行时可执行程序找不到库的问题?

1.

虽然在编译时给了-L参数指定了动态库路径,但是这和运行时库搜索路径是俩码事,-L只在编译链接时生效,它告诉链接器在哪里寻找动态库,以便正确生成可执行文件,但不会影响程序运行时的库搜索路径。也就是说只告诉了gcc,并没有告诉系统,gcc!=系统,运行时,OS需要动态加载库。

2.

运行时,依赖动态库的查找规则:

程序运行时,动态链接器(ld.so)会从以下位置查找 .so 文件(按优先级顺序):

  • LD_LIBRARY_PATH 环境变量指定的路径

  • /etc/ld.so.cache(系统缓存的库路径,由 ldconfig 管理)

  • 默认系统路径(如 /usr/lib/usr/local/lib

  • 不会自动包含编译时 -L 指定的路径!

为什么静态库不会出现这种问题?

1.

当静态链接时,编译器会从静态库(.a)中提取需要的代码直接合并到最终的可执行文件中,生成的可执行文件是一个独立的二进制文件,不再依赖外部.a文件。

2.

而动态链接时,代码不会嵌入到可执行文件中,而是在程序运行时动态加载,仅记录动态库的符号引用信息(如函数名),并检查库是否存在(通过 -L 和 -l),但不会将代码复制到可执行文件中。

解决办法有四种:

1.

将动态库拷贝到系统路径:

2.

在系统默认路径下建立软连接:

3.

配置LD_LIBRARY_PATH环境变量:

运行时,OS也会在这个环境变量找库。

4.

配置/ etc/ld.so.conf.d/:

注意:如果把这个动态库删掉,再去运行这个可执行程序,就会因为动态库缺失而报错。

动静态库制作总结

1.

gcc/g++默认使用动态库,如果非要静态链接就只能加上 -static,一旦-static就必须存在对应的静态库,如果只存在静态库,就只能静态链接了。

2.

在linux下,默认优先安装的都是动态库。

3.

往往都是多个程序使用一个库,库:程序=1:n

4.

在windows下,有时候我们会出现 xxx.dll缺失问题,也就是动态库缺失,可能是因为杀毒软件把这个xxx.dll文件当作危险文件给删除了。

5.

vs 2022不仅可以形成可执行程序,也可以形成动静态库。

ELF文件

有以下四种文件都是ELF文件:

  1. 可重定位⽂件( Relocatable File ) :即xxx.o⽂件。包含适合于与其他⽬标⽂件链接来创 建可执⾏⽂件或者共享⽬标⽂件的代码和数据。
  2. 可 执⾏⽂件( Executable File ) :即可执⾏程序。
  3. 共 享⽬标⽂件( Shared Object File ) :即xxx.so⽂件。
  4. 内 核转储 (core dumps) ,存放当前进程的执⾏上下⽂,⽤于dump信号触发。

⼀个ELF⽂件由以下四部分组成:

  1. ELF 头 (ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂件的其他部分。
  2. 程序头表 (Program header table) :列举了所有有效的段(segments)和他们的属性。表⾥ 记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中, 需要段表的描述信息,才能把他们每个段分割开。
  3. 节 头表 (Section header table) :包含对节(sections)的描述。
  4. 节( Section ):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和 数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。

最常⻅的节:

  • 代码节(.text):⽤于保存机器指令,是程序的主要执⾏部分。
  • 数据节(.data):保存已初始化的全局变量和局部静态变量。

ELF从形成到加载轮廓

ELF文件形成可执行

  • step-1:将多份 C/C++ 源代码,翻译成为⽬标 .o 文件
  • step-2:将多份 .o ⽂件section进⾏合并

注意:实际合并是在链接时进⾏的,但是并不是这么简单的合并,也会涉及对库合并。

ELF可执⾏⽂件加载

  • ⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment
  • 合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等
  • 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
  • 很显然,这个合并⼯作也已经在形成ELF的时候,合并⽅式已经确定了,具体合并原则被记录在了 ELF的 程序头表 (Program header table) 中

为什么要将section合并成为segment?

  • Section合并的主要原因是为了减少⻚⾯碎⽚,提⾼内存使⽤效率。如果不进⾏合并, 假设⻚⾯⼤⼩为4096字节(内存块基本⼤⼩,加载,管理的基本单位),如果.text部分 为4097字节,.init部分为512字节,那么它们将占⽤3个⻚⾯,⽽合并后,它们只需2个 ⻚⾯。
  • 此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个⼤的 segment,这样就可以实现不同的访问权限,从⽽优化内存管理和权限访问控制。

对于程序头表和节头表又有什么用?其实ELF文件提供两个不同的视图来让我们理解这两个部分:

链接视图 (Linking view)-对应节头表 Section header table

  • ⽂件结构的粒度更细,将⽂件按功能模块的差异进⾏划分,静态链接分析的时候⼀般关注的 是链接视图,能够理解ELF⽂件中包含的各个部分的信息。
  • 为了空间布局上的效率,将来在链接⽬标⽂件时,链接器会把很多节(section)合并,规整 成可执⾏的段(segment)、可读写的段、只读段等。合并了后,空间利⽤率就⾼了,否 则,很⼩的很⼩的⼀段,未来物理内存⻚浪费太⼤(物理内存⻚分配⼀般都是整数倍⼀块给 你,⽐如4k),所以,链接器趁着链接就把⼩块们都合并了。

执 ⾏视图 (execution view)-对应程序头表– Program header table

  • 告诉操作系统,如何加载可执⾏⽂件,完成进程内存的初始化。⼀个可执⾏程序的格式中, ⼀定有 program header table
  • 说⽩了就是:⼀个在链接时作⽤,⼀个在运⾏加载时作⽤。

从链接视图来看:

  • 命令 readelf -S hello.o 可以帮助查看ELF⽂件的节头表。
  • .t ext 节 :是保存了程序代码指令的代码节。
  • .data 节 :保存了初始化的全局变量和局部静态变量等数据。
  • .rodata 节 :保存了只读的数据,如⼀⾏C语⾔代码中的字符串。由于.rodata节是只读的,所 以只能存在于⼀个可执⾏⽂件的只读段中。因此,只能是在text段(不是data段)中找到.rodata 节。
  • .BSS 节:为未初始化的全局变量和局部静态变量预留位置
  • .s ymtab 节 :SymbolTable符号表,就是源码⾥⾯那些函数名、变量名和代码的对应关系。
  • . got.plt 节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供 了对导⼊的共享库函数的访问⼊⼝,由动态链接器在运⾏时进⾏修改。

从执⾏视图来看:

  • 告诉操作系统哪些模块可以被加载进内存。
  • 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执⾏的。

一个可执行程序加载到内存中,该可执行程序有没有地址?

有地址,而且还是虚拟地址,也就是说可执行程序在磁盘的时候就已经有地址了,地址是用起始地址+偏移量来确定的,而不管哪个可执行文件,它的起始地址都是从0开始编址的,也就是说所有变量或者函数,在这个时候的地址就是它们的偏移量。

理解加载

静态链接

1.

我们知道单个源文件可以独自编译汇编,最后链接形成可执行文件,那它们独自编译汇编的时候,调用函数,是根本不知道是否有这个函数的,只知道某处有函数调用,直到链接的时候才会报出没有这个函数的错误!

我们反汇编看一下,发现不管是正确的函数还是会报错的函数,它们的地址都被编址成了0

2.

所以,在编译汇编的时候,编译器是完全不知道调用函数的存在,比如它们位于内存的哪个区块,代码⻓什么样都是不知道的,因此,编辑器只能将这些函数的跳转地址先暂时设为0。

这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正 的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表⾥记录的 地址将其修正。

3.

链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴ 的可执⾏⽂件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我 们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这 其实就是静态链接的过程。

4.

⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机⼯作的时候,都采⽤"平坦 模式"进⾏⼯作。所以也要求ELF对⾃⼰的代码和数据进⾏统⼀编址,下⾯是 objdump -S 反汇编 之后的代码:

最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量),但是我们 认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执⾏程序进⾏统⼀编址了.

5.

进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?

从ELF各个 segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start,end] 等范围数据,另外在⽤详细地址,填充⻚表,所以:虚拟地址机制,不光光OS要⽀持,编译器也要⽀持.

6.

ELF编译好后,会将自己未来程序的入口地址记录在ELF header的Entry字段中:

总结

1.

可执行程序会在磁盘中统一编址,在这时候就有虚拟地址了,其实就是偏移量,然后我们运行这个可执行程序,OS会进行路径解析(文件系统下找到该可执行文件),OS将可执行文件加载到物理内存中,那么这就是进程了,在加载到物理内存的这个过程中,会开辟空间,这个时候所有函数,变量都有它们真实的物理地址,而我们又知道各个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构(mm_struct、vm_area_struc)中的[start,end] 等范围数据,所以就有了进程虚拟地址空间各个区域的起始地址和长度,也有了所有函数和变量的偏移量,于是函数和变量的确切虚拟地址就有了,而现在又有了真实的物理地址,所以填充页表。

2.

当cpu跑起来,进去cpu的都是虚拟地址,然后cpu查询页表访问各个函数和变量!至此用户几乎看到的都是虚拟地址,而看不到真实的物理地址。

动态链接

和静态链接不同的是,动态链接需要不仅要将可执行程序加载到内存,也需要将库也加载到内存。

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

⾸先要交代⼀个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏ ⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动 态库的加载地址都是不固定的,操作系统会根据当前地址空间的使⽤情况为它们动态分配⼀段内存。 当动态库被加载到内存以后,⼀旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转 地址了。

在C/C++程序中,当程序开始执⾏时,它⾸先并不会直接跳转到 是 main 函数。实际上,程序的⼊⼝点 _start ,这是⼀个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数.

在 _start 函数中,会执⾏⼀系列初始化操作,这些操作包括:

  1. 设置堆栈:为程序创建⼀个初始的堆栈环境。
  2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位 置,并清零未初始化的数据段。
  3. 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的 动态库(sharedlibraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调 ⽤和变量访问能够正确地映射到动态库中的实际地址.
  4. 调⽤ __libc_start_main :⼀旦动态链接完成, _start 函数会调⽤ __libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执⾏ ⼀些额外的初始化⼯作,⽐如设置信号处理函数、初始化线程库(如果使⽤了线程)等。
  5. 调⽤ main 函数:最后, __libc_start_main 函数会调⽤程序的 ⾏控制权才正式交给⽤⼾编写的代码。 
  6. 处理 main 函数的返回值:当main函数返回时,_ _libc_start_main 会负责处理这个返回值,并最终调用_exit函数来终止程序。

1.

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

2.

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

3.

问题:修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?

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

因为.data区域是可读写的,所以可以⽀持动态进⾏修改

  1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不 同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的 每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表。
  2. 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找 到GOT表。
  3. 在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会 被修改为真正的地址。
  4. 这种⽅式实现的动态链接就被叫做 PIC 地址⽆关代码。换句话说,我们的动态库不需要做任何修 改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给 编译器指定-fPIC参数的原因, PIC=相对编址+GOT。

静态链接和动态链接修正模块间函数的跳转地址区别:

  • 我们知道静态链接会将编译产⽣的所有⽬标⽂件,和⽤到的各种库合并成⼀个独⽴的可执⾏⽂件, 其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
  • ⽽动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系 统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址 都是不固定的,但是⽆论加载到什么地⽅,都要映射到进程对应的地址空间,然后通过.GOT⽅式进 ⾏调⽤(运⾏重定位,也叫做动态地址重定位)。

好了,我们下期见!!!


网站公告

今日签到

点亮在社区的每一天
去签到