库制作与原理

一、什么是库

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

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

  1. 静态库 .a[Linux]、.lib[windows]
  2. 动态库 .so[Linux]、.dll[windows]

上图是C语言的静态库,因为OS不自带C静态库,需要执行sudo apt install libc6-static下载命令,再执行查找命令ls -l /usr/lib/x86_64-linux-gnu/libc.a

二、静态库

  • 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库
  • ⼀个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,而我们的编译默认为动态链接库,只有在该库下找不到动态.so的时候才会采用同名静态库;我们也可以使用gcc的-static强转设置链接静态库

(1) 静态库的生成

cpp 复制代码
libmystdio.a:my_stdio.o my_string.o
    @ar -rc $@ $^
    @echo "build $^ to $@ ... done"
%.o:%.c
    @gcc -c $<
    @echo "compling $< to $@ ... done"
.PHONY:clean
clean:
    @rm -rf *.a *.o stdc*
    @echo "clean ... done"
.PHONY:output
output:
    @mkdir -p stdc/include
    @mkdir -p stdc/lib
    @cp -f *.h stdc/include
    @cp -f *.a stdc/lib
    @tar -czf stdc.tgz stdc
    @echo "output stdc ... done"
  • ar 是 gnu 归档工具, rc 表示(replace and create) (归档就是把处理完的文件、资料、记录整理好,统一保存起来,方便以后查阅,不再日常使用 )
  • t:列出静态库中的文件
  • v:verbose详细信息

(2) 静态库的使用

cpp 复制代码
#include "my_stdio.h"
#include "my_string.h"
#include <stdio.h>

int main()
{
    const char *s = "abcdefg";
    printf("%s: %d\n", s, my_strlen(s));
    mFILE *fp = mfopen("./log.txt", "a");
    if(fp == NULL) return 1;
    mfwrite(s, my_strlen(s), fp);
    mfwrite(s, my_strlen(s), fp);
    mfwrite(s, my_strlen(s), fp);
    mfclose(fp);
    return 0;
}

// 场景1:头⽂件和库⽂件安装到系统路径下 
$ gcc main.c -lmystdio

// 场景2:头⽂件和库⽂件和我们⾃⼰的源⽂件在同⼀个路径下 
$ gcc main.c -L. -lmymath

// 场景3:头⽂件和库⽂件有⾃⼰的独⽴路径 
$ gcc main.c -I头⽂件路径 -L库⽂件路径 -lmymath
    • L :指定库路径
  1. -Ⅰ:指定头文件搜索路径
    • l :指定库名
  2. 测试目标文件生成后,静态库删掉,程序照样可以运行
  3. 库文件名称和引入库的名称:去掉前缀lib,去掉后缀.so,.a,如:lib.so ---> c

三、动态库

  • 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码
  • ⼀个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的⼀个表,而不是外部函数所在目标文件的整个机器码
  • 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中, 这个过程称为动态链接(dynamic linking)
  • 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间;操作系统采用虚拟内存机制允许物理内存中的⼀份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间

(1) 动态库的生成

cpp 复制代码
libmystdio.so:my_stdio.o my_string.o 
    gcc -o $@ $^ -shared 

%.o:%.c
    gcc -fPIC -c $< 
 
.PHONY:clean 
clean:
    @rm -rf *.so *.o stdc* 
    @echo "clean ... done" 
 
.PHONY:output 
output: 
    @mkdir -p stdc/include 
    @mkdir -p stdc/lib 
    @cp -f *.h stdc/include 
    @cp -f *.so stdc/lib 
    @tar -czf stdc.tgz stdc 
    @echo "output stdc ... done"
  • shared:表示生成共享库格式
  • fPIC:产生位置无关码
  • 库名规则:libxxx.so

动态库没办法使用归档工具

(2) 动态库的使用

cpp 复制代码
// 场景1:头⽂件和库⽂件安装到系统路径下 
$ gcc main.c -lmystdio

// 场景2:头⽂件和库⽂件和我们⾃⼰的源⽂件在同⼀个路径下 
$ gcc main.c -L. -lmymath // 从左到右搜索-L指定的⽬录 

// 场景3:头⽂件和库⽂件有⾃⼰的独⽴路径
$ gcc main.c -I头⽂件路径 -L库⽂件路径 -lmymath
$ ldd libmystdio.so // 查看库或者可执⾏程序的依赖 
  linux-vdso.so.1 => (0x00007fffacbbf000)
  libc.so.6 => /lib64/libc.so.6 (0x00007f8917335000)
  /lib64/ld-linux-x86-64.so.2 (0x00007f8917905000)

// 以场景2为例 
$ ll
total 24
-rwxrwxr-x 1 whb whb 8592 Oct 29 14:50 libmystdio.so
-rw-rw-r-- 1 whb whb 359 Oct 19 16:07 main.c
-rw-rw-r-- 1 whb whb 447 Oct 29 14:50 my_stdio.h
-rw-rw-r-- 1 whb whb 447 Oct 29 14:50 my_string.h
$ gcc main.c -L. -lmystdio
$ ll
total 36
-rwxrwxr-x 1 whb whb 8600 Oct 29 14:51 a.out
-rwxrwxr-x 1 whb whb 8592 Oct 29 14:50 libmystdio.so
-rw-rw-r-- 1 whb whb 359 Oct 19 16:07 main.c
-rw-rw-r-- 1 whb whb 447 Oct 29 14:50 my_stdio.h
-rw-rw-r-- 1 whb whb 447 Oct 29 14:50 my_string.h
[whb@bite-alicloud other]$ ./a.out

(3) 库运行搜索路径

Ⅰ:问题--->Not Found

ldd:是 Linux 下用于查看程序/共享库依赖库的工具,图中内容是正常的系统依赖输出

cpp 复制代码
$ ldd a.out 
  linux-vdso.so.1 => (0x00007fff4d396000)
  libmystdio.so => not found
  libc.so.6 => /lib64/libc.so.6 (0x00007fa2aef30000)
  /lib64/ld-linux-x86-64.so.2 (0x00007fa2af2fe000)

Ⅱ:解决方案

  1. 拷贝.so文件到系统共享库路径下,⼀般指 /usr/lib、/usr/local/lib、/lib64或者开篇指明的库路径等
  2. 向系统共享库路径下建立同名软连接
  3. 更改环境变量:LD_LIBRARY_PATH
  4. ldconfig方案:配置/ etc/ld.so.conf.d/ ,ldconfig更新

四、目标文件

编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们⼀般都是⼀键构建非常方便, 但⼀旦遇到错误的时候呢,尤其是链接相关的错误,很多人就束手无策了;在Linux下,我们之前也学习过如何通过gcc编译器来完成这⼀系列操作

  • 接下来我们深入探讨⼀下编译和链接的整个过程,来更好的理解动静态库的使用原理
  • 先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码
  • 比如:在⼀个源文件 hello.c 里便简单输出"hello world!",并且调用⼀个run函数,而这个函数被定义在另⼀个原文件 code.c 中;这里我们就可以调用 gcc -c 来分别编译这两个原文件
cpp 复制代码
// hello.c
#include<stdio.h>
void run();
int main() 
{
 printf("hello world!\n");
 run();
 return 0;
}

// code.c
#include<stdio.h>
void run() 
{
 printf("running...\n");
}

// 编译两个源⽂件 
$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c code.o hello.c hello.o

可以看到,在编译之后会生成两个扩展名为 .o 的文件,它们被称作目标文件;要注意的是如果我们修改了⼀个原文件,那么只需要单独编译它这⼀个,而不需要浪费时间重新编译整个工程;目标文件是⼀个二进制的文件,文件的格式是ELF,是对⼆进制代码的⼀种封装

## file命令用于辨识文件类型

五、ELF文件

要理解编译链链接的细节,我们不得不了解⼀下ELF文件;其实有以下四种文件其实都是ELF文件:

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

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

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

最常见的节:

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

注:size 目标文件 --- 可以查看节的信息

六、ELF从形成到加载轮廓

(1) ELF形成可执行

  • step-1:将多份C/C++源代码,翻译成为目标.o文件+动静态库(ELF)
  • step-2:将多分.o文件section进行合并

**注意:**实际合并是在链接时进行的,但是并不是这么简单的合并,也会涉及对库合并,此处不做过多追究

(2) 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文件提供2个不同的视图/视角来让我们理解这两个部分:

  • 链接视图(Linking view) -对应节头表 Section header table
  1. 文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候⼀般关注的是链接视图,能够理解ELF文件中包含的各个部分的信息
  2. 为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等;合并了后,空间利用率就高了,否则,很小小的很小的⼀段,未来物理内存页浪费太大(物理内存页分配⼀般都是整数倍⼀块给你,比如4k),所以,链接器趁着链接就把小块们都合并了
  • 执行视图(execution view) -对应程序头表 Program header table
  1. 告诉操作系统,如何加载可执行文件,完成进程内存的初始化;⼀个可执行程序的格式中, ⼀定有 program header table
  • 说白了就是:⼀个在链接时作用,⼀个在运行加载时作用

从链接视图来看(节头表内容):

  • 命令 readelf -S hello.o 可以帮助查看ELF文件的节头表
  • .text节 :是保存了程序代码指令的代码节
  • .data节 :保存了初始化的全局变量和局部静态变量等数据
  • .rodata(read only)节 :保存了只读的数据,如一行C语言代码中的字符串;由于.rodata节是只读的,所 以只能存在于⼀个可执行文件的只读段中;因此,只能是在text段(不是data段)中找到.rodata 节
  • .BSS节 :为未初始化的全局变量和局部静态变量预留位置
  • .symtab节 : Symbol Table 符号表,就是源码里面那些函数名、变量名和代码的对应关系
  • .got.plt节 (全局偏移表-过程链接表):.got节保存了全局偏移表;.got节和.plt节⼀起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改;对于GOT的理解,我们后面会说(使用 readelf 命令查看.so文件可以看到该节)

从执行视图来看(程序头表内容):

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

我们可以在 ELF头中找到文件的基本信息,以及可以看到ELF头是如何定位程序头表和节头表的;例如我们查看下hello.o这个可重定位文件的主要信息:

  • 查看目标文件(readelf -h *.o)
  • 查看可执行程序**(readelf -h *.exe)**

对于 ELF HEADER 这部分来说,我们只用知道其作用即可,它的主要目的是定位文件的其他部分

七、理解链接与加载

(1) 静态链接

  • 无论是自己的.o,还是静态库中的.o,本质都是把.o文件进行链接的过程
  • 所以:研究静态链接,本质就是研究.o是如何链接的

查看编译后的.o目标文件

  • objdump -d 命令:将代码段(.text)进行反汇编查看

结论:静态链接就是把库中的.o进行合并,和上述过程⼀样所以链接其实就是将编译之后的所有目标⽂件连同用到的⼀些静态库运行时库组合,拼装成⼀个独立的可执行文件;其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的 .o 文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址;这其实就是静态链接的过程

所以,链接过程中会涉及到对.o中外部符号进行地址重定位

(2) ELF加载与进程地址空间

Ⅰ. 虚拟地址 / 逻辑地址

问题:

  • 一个ELF程序,在没有被加载到内存的时候,也没有地址呢?
  • 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?

答案:

  • ⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机工作的时候,都采用"平坦模式"进行工作;所以也要求ELF对自己的代码和数据进行统⼀编址,下面是 objdump -S 反汇编之后的代码

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

  • 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的[start,end]等范围数据,另外在用详细地址,填充页表.

所以:虚拟地址机制,不光光OS要支持,编译器也要支持

Ⅱ. 重新理解进程虚拟地址空间

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

⼀张图说清楚,需要当场画

素材1

素材2

(3) 动态链接与动态库加载

Ⅰ. 进程如何看到动态库

Ⅱ . 进程间如何共享库的

Ⅲ. 动态链接

  • 概要

动态链接其实远比静态链接要常用得多;比如我们查看下 hello 这个可执行程序依赖的动态库,会发 现它就用到了⼀个C动态链接库:

1、这里的libc.so是C语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等等这些功能

2、那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成⼀个独立的可执行文件,它不需要额外的依赖就可以运行;照理来说应该更加方便才对是吧?

答:静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源;随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间

3、这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同⼀个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享

4、动态链接到底是如何工作的??

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

  • 我们的可执行程序被编译器动了手脚

在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数;实际上,程序的入口点是 _start ,这是⼀个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数;在 _start 函数中,会执行⼀系列初始化操作,这些操作包括:

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

动态链接器:

  1. 动态链接器(如ld-linux.so)负责在程序运行时加载动态库
  2. 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中

环境变量和配置文件:

  1. Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf及其子配置文件)来指定动态库的搜索路径
  2. 这些路径会被动态链接器在加载动态库时搜索

缓存文件:

  1. 为了提高动态库的加载效率,Linux系统会维护⼀个名为/etc/ld.so.cache的缓存文件
  2. 该⽂件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件

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

  • 动态库中的相对地址

动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统⼀编址, 采用相对编址的方案进行编制的(其实可执行程序也⼀样,都要遵守平坦模式,只不过exe是直接加载的)

cpp 复制代码
# ubuntu下查看任意⼀个库的反汇编 
objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less

# Cetnos下查看任意⼀个库的反汇编 
$ objdump -S /lib64/libc-2.17.so | less
  • 我们的程序,怎么和库具体映射起来的

📍注意:

  • 动态库也是⼀个文件,要访问也是要被先加载,要加载也是要被打开的
  • 让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中

⼀张图解释清楚

  • 我们的程序,怎么进行库函数调用

📍注意:

  1. 库已经被我们映射到了当前进程的地址空间中
  2. 库的虚拟起始地址我们也已经知道了
  3. 库中每⼀个方法的偏移量地址我们也知道
  4. 所有:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法
  5. 而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的.
  • 全局偏移量表GOT(global offset table)

📍注意:

  • 也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道
  • 然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中⼆次完成地址设置 (这个叫做加载地址重定位)
  • 等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?

所以:动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运行模块要引用的⼀个全局变量或函数的地址

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

📍备注:PLT是什么

答:与 Linux 动态链接密切相关,是 ELF 可执行文件中用于解析外部函数调用的间接跳转表,配合GOT(全局偏移表)实现动态库函数的延迟绑定,是你日常编程中可能接触到的底层机制

  • 库间依赖(简单说明即可)

注意:

  • 不仅仅有可执行程序调用库
  • 库也会调用其他库!!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢??
  • 库中也有.GOT,和可执行⼀样!这也就是为什么⼤家为什么都是ELF的格式!

由于GOT表中的映射地址会在运行时去修改,我们可以通过gdb调试去观察GOT表的地址变化;在这里我们只用知道原理即可,有兴趣的同学可以参考:使用gdb调试GOT

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

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

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

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

  • 总结
  1. 静态链接的出现,提高了程序的模块化水平;对于⼀个大的项目,不同的人可以独立地测试和开发自己的模块;通过静态链接,生成最终的可执行文件
  2. 我们知道静态链接会将编译产生的所有目标⽂件,和用到的各种库合并成⼀个独立的可执⾏文件, 其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)
  3. 而动态链接实际上将链接的整个过程推迟到了程序加载的时候;比如我们去运行⼀个程序,操作系统会首先将程序的数据代码连同它用到的⼀系列动态库先加载到内存,其中每个动态库的加载地址 都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用(运行重定位,也叫做动态地址重定位)

补充:

  • 若使用静态链接,需加上 -static 选项
  • 库的本质是 .o 文件的集合
  • 把源文件编译成 .o 和其他文件无关;只有在链接时多个 .o 才会关联
  • -l:告诉库的名称,gcc专门编译C语言,本身就认识C库
  • -L:是告诉库的路径在哪
  • 问:给别人提供库,我们应该提供什么?
  1. .a与.h文件(库的使用规则)
  2. .a文件是通过编译生成目标文件 .o 和打包(使用ar工具)目标文件生成的
  • 不提供动态库的原因 (程序 != 进程)
  1. 兼容性隐患:动态库和运行环境强绑定,版本不匹配会报错
  2. 部署成本高:使用动态库需要下载库文件,配置环境变量,指定库搜索路径
  3. 版本管控难:若双方未同步版本,可能导致接口不一致,致使程序崩溃
  • 问:别人如何使用我们的库?

📍-I(i)选项:指定头文件的搜索目录

  1. 安装道系统路径下,就是拷贝(软链接)到系统路径下
  2. 使用 -L、-l、-I(i)选项,可以随意链接任意库
  • 同时存在两种库,默认使用动态库
  1. -static 选项:使用静态库(如若使用 -static ,前提是必须要有静态库)
  2. 误区:不一定要同时拥有静态库和动态库,单独存在即可链接(除了强制要求下)
  3. 若只提供静态库,即使默认动态链接,也只能是走静态链接
  • ELF格式(Linux特有)
  1. .o文件可以形成 .so 或者可执行文件本质上都是ELF文件格式
  • 特点与优势
  1. 多类型文件统一格式:适配 .o、.so、可执行文件等,简化编译器与链接器的处理逻辑,无需为不同文件独立解析规则
  2. 灵活的分段与分节机制:文件内部分为段与节;既能满足内存的高效映射,又能支持符号表与重定位表等链接需求
  3. 支持动态链接与共享库:内置动态链接所需结构,能高效实现运行时库加载与符号解析,是动态库 .so 实现的基础
  4. 平台扩展性强:通过程序头表和节头表的灵活设计,可适配32位 / 64位架构、不同指令集,且支持调试信息嵌入,方便gdp等工具调试
  5. 内存映射高效:运行时操作系统可直接将ELF文件的段映射到进程地址空间,无需额外的文件加载拷贝,提升程序启动速度( 不经过内核缓冲区→用户态的拷贝,数据直接在磁盘和进程地址空间之间映射**)**
  • ELF Header中有一个成员:Entry point address ---> 入口地址(程序)(CPU第一条执行的指令)

📍ELF中是有一个一个的节

  1. 节是ELF文件在编译、链接阶段的最小组织单位,用来存放不同的代码和数据,不直接对应内存加载
  2. 特点:粒度细,分类明确,是链接过程中符号解析、重定位的核心依据

📍ELF中是没有一个一个段的

  1. 段是ELF文件在运行阶段的组织单位,由一个或多个属性相同的节组成,直接对应进程地址空间的内存区域
  2. 编译时操作,运行时加载(可执行文件存在磁盘中)
  • 细节
  1. 创建一个进程,选创建内核进程相关的数据结构,再加载ELF格式的二进制文件
  2. 程序还没有加载到内存,就有自己的虚拟地址(链接时)
  3. 逻辑地址 <------------> 虚拟地址、逻辑地址(反汇编) = 段地址+偏移量(编译时生成、不面向物理内存)(负责映射到内存空间)
  4. 平坦模式对我们的可执行文件中每一行代码都进行编址,原则从0号开始,到32位的1结束
  • 静态链接
  1. CPU有自己的指令集
  2. 本质:是把库中相关代码,拷贝到了你的程序中(无加载过程,在编译期已完成)
  3. 拷贝:把代码和数据拷贝到磁盘中
  4. 加载:把代码和数据弄到了内存当中
  • 动态链接
  1. 📍动态库中是要被映射到进程的共享区 ---> 动态库也叫共享区

  2. 库函数的调用,也就是在进程的虚拟地址空间中进行函数跳转

  3. 共享库:多个进程需要用到的资源,只要在内存中形成一份(通过先描述再组织让OS把库管理起来)

  4. 动态库中不包含main函数

  5. 动态库链接实际上将链接的整个过程推迟到了程序加载的时候

  6. 知道虚拟地址+函数偏移量可以通过页表映射到对应的物理地址

  • GOT --- PLT延迟绑定机制(数据段操作)
  1. 在动态链接中,不会直接修改代码段中call指令后的地址,而是通过GOT --- PLT延迟绑定机制间接完成地址跳转的动态修正,核心的是修改数据段GOT表条目而非代码段指令
  2. call后面的偏移地址是固定的,全程不会修改
  3. 动态链接器将printf的真实虚拟地址写入got条目,完成数据段的地址修正
  • PLT:需要时再链接 / 加载相关库

实际内存地址 = 相对偏移地址(0x112233)+虚拟地址(0x44332211)

  • 加载器

(1) 概念

是软件系统中负责将目标资源从存储位置加载到运行环境 / 内存中,并完成初始化、解析、转换等预处理的核心组件

(2) 作用

  1. 解决寻址、读取、解析 / 转化 / 预处理的问题
  2. 屏蔽底层细节,向上层提供统一的资源访问接口
  3. 支持资源的按需加载,预处理,提升系统性能或兼容性

(3) 应用

  1. 模块化 / 可拔插:支持拓展
  2. 缓存机制:避免重复加载
  3. 按需加载:仅在需要时加载,降低内存占用
  4. 错误处理:处理资源不存在、格式错误、权限不足等异常
  5. 性能优化:批量加载,并行加载、预加载等提升效率
相关推荐
PyHaVolask2 小时前
Linux实用工具与技巧
linux·运维·chrome
不才小强2 小时前
Linux开发环境搭建指南
linux·运维·服务器
2501_920627612 小时前
Flutter 框架跨平台鸿蒙开发 - 数据库学习助手
数据库·学习·flutter·华为·harmonyos
海参崴-2 小时前
三足鼎立:Linux、苹果macOS与微软Windows的前世今生及核心差异
linux·microsoft·macos
RisunJan2 小时前
Linux命令-mysqlshow(显示MySQL中数据库相关信息)
linux·数据库·mysql
小江的记录本2 小时前
【Docker】 Docker 全平台部署(Linux / Windows / MacOS)与 前后端分离项目 容器化方案
java·linux·windows·http·macos·docker·容器
真心喜欢你吖2 小时前
CentOS 安装部署OpenClaw实战教程(SELinux+防火墙配置)
linux·运维·centos·大模型·智能体·openclaw·小龙虾
ShineWinsu2 小时前
对于Linux:进程地址空间(虚拟地址空间)的解析
linux·服务器·面试·笔试·内存·进程·虚拟空间
wuhui21002 小时前
Kali Linux 输入法问题排查与解决记录
linux·运维·服务器