Linux:理解库制作与原理

1.什么是库

库是写好的现有的,成熟的,可以复⽤的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个⼈的代码都从零开始,因此库的存在意义⾮同寻常。
本质上来说库是⼀种可执⾏代码的⼆进制形式,可以被操作系统载⼊内存执⾏。库有两种:

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

2.静态库

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

2.1静态库⽣成

bash 复制代码
// Makefile
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

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

bash 复制代码
$ ar -tv libmystdio.a
rw-rw-r-- 1000/1000 2848 Oct 29 14:35 2024 my_stdio.o
rw-rw-r-- 1000/1000 1272 Oct 29 14:35 2024 my_string.o

2.2静态库使⽤

cpp 复制代码
// 任意⽬录下,新建
// main.c,引⼊库头⽂件
#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. - lmystdio
// 场景3:头⽂件和库⽂件有⾃⼰的独⽴路径
$ gcc main.c - I头⽂件路径 - L库⽂件路径 - lmystdio

-I: 指定头⽂件搜索路径
-L: 指定库路径
-l: 指定库名
库⽂件名称和引⼊库的名称:去掉前缀 lib ,去掉后缀 .so , .a ,如: libc.so -> c

3.动态库

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

3.1动态库⽣成

bash 复制代码
// Makefile
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

shared: 表⽰⽣成共享库格式
fPIC:产⽣位置⽆关码(position independent code)
库名规则:libxxx.so

3.2动态库使⽤

bash 复制代码
// 场景1:头⽂件和库⽂件安装到系统路径下
$ gcc main.c -lmystdio
// 场景2:头⽂件和库⽂件和我们⾃⼰的源⽂件在同⼀个路径下
$ gcc main.c -L. -lmystdio // 从左到右搜索-L指定的⽬录
// 场景3:头⽂件和库⽂件有⾃⼰的独⽴路径
$ gcc main.c -I头⽂件路径 -L库⽂件路径 -lmystdio

$ 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.3库运⾏搜索路径

3.3.1问题

bash 复制代码
$ 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)

3.3.2解决⽅案

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

4.⽬标⽂件

接下来我们深⼊探讨⼀下编译和链接的整个过程,来更好的理解动静态库的使⽤原理。
先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成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 ,是对⼆进制代码的⼀种封装。

bash 复制代码
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

5.ELF⽂件

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

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

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

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

最常见的节:

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

6.ELF从形成到加载轮廓

6.1 ELF形成可执⾏

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

6.2 2 ELF可执⾏⽂件加载

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

# 查看section合并的segment
$ readelf -l a.out

为什么要将section合并成为segment

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

对于 程序头表 和 节头表 ⼜有什么⽤呢,其实 ELF ⽂件提供 2 个不同的视图/视⻆来让我们理解这
两个部分:

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


从 链接视图 来看:

  • 命令 readelf -S hello.o 可以帮助查看ELF⽂件的 节头表。

  • .text节 :是保存了程序代码指令的代码节。

  • .data节 :保存了初始化的全局变量和局部静态变量等数据。

  • .rodata节 :保存了只读的数据,如⼀⾏C语⾔代码中的字符串。由于.rodata节是只读的,所以只能存在于⼀个可执⾏⽂件的只读段中。因此,只能是在text段(不是data段)中找到.rodata节。

  • .BSS节 :为未初始化的全局变量和局部静态变量预留位置

  • .symtab节 : Symbol Table 符号表,就是源码⾥⾯那些函数名、变量名和代码的对应关系。

  • .got.plt节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供了对导⼊的共享库函数的访问⼊⼝,由动态链接器在运⾏时进⾏修改。

  • 使⽤ readelf 命令查看 .so ⽂件可以看到该节。

从 执⾏视图 来看:

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

我们可以在 ELF头 中找到⽂件的基本信息,以及可以看到ELF头是如何定位程序头表和节头表的。

bash 复制代码
// 查看可执⾏程序
$ gcc* .o
$ readelf - h a.out
ELF Header :
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class : ELF64
Data : 2's complement, little endian
Version : 1 (current)
OS / ABI : UNIX - System V
ABI Version : 0
Type : DYN(Shared object file)
Machine : Advanced Micro Devices X86 - 64
Version : 0x1
Entry point address : 0x1060
Start of program headers : 64 (bytes into file)
Start of section headers : 14768 (bytes into file)
Flags : 0x0
Size of this header : 64 (bytes)
Size of program headers : 56 (bytes)
Number of program headers : 13
Size of section headers : 64 (bytes)
Number of section headers : 31
Section header string table index : 30

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

7.理解连接与加载

7.1静态链接

⽆论是⾃⼰的.o, 还是静态库中的.o,本质都是把.o⽂件进⾏连接的过程

所以:研究静态链接,本质就是研究.o是如何链接的

bash 复制代码
$ ll
-rw-rw-r-- 1 whb whb 62 Oct 31 15:36 code.c
-rw-rw-r-- 1 whb whb 103 Oct 31 15:36 hello.c
whb@bite:~/test/test/test$ gcc -c *.c
whb@bite:~/test/test/test$ gcc *.o -o main.exe
$ ll
-rw-rw-r-- 1 whb whb 62 Oct 31 15:36 code.c
-rw-rw-r-- 1 whb whb 1672 Oct 31 15:46 code.o
-rw-rw-r-- 1 whb whb 103 Oct 31 15:36 hello.c
-rw-rw-r-- 1 whb whb 1744 Oct 31 15:46 hello.o
-rwxrwxr-x 1 whb whb 16752 Oct 31 15:46 main.exe*

查看编译后的.o⽬标⽂件

bash 复制代码
$ objdump -d code.o
code.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <run>:
0: f3 0f 1e fa           endbr64
4: 55 push               %rbp
5: 48 89 e5              mov %rsp,%rbp
8: 48 8d 3d 00 00 00 00  lea 0x0(%rip),%rdi # f<run+0xf>
f: e8 00 00 00 00        callq 14 <run+0x14>
14: 90                   nop
15: 5d                   pop %rbp
16: c3                   retq
$ objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: f3 0f 1e fa           endbr64
4: 55                    push %rbp
5: 48 89 e5              mov %rsp,%rbp
8: 48 8d 3d 00 00 00 00  lea 0x0(%rip),%rdi # f<main+0xf>
f: e8 00 00 00 00        callq 14 <main+0x14>
14: b8 00 00 00 00       mov $0x0,%eax
19: e8 00 00 00 00       callq 1e <main+0x1e>
1e: b8 00 00 00 00       mov $0x0,%eax
23: 5d                   pop %rbp
24: c3                   retq

objdump -d 命令:将代码段(.text)进⾏反汇编查看
hello.o 中的 main 函数不认识 printf 和 run 函数

cpp 复制代码
$ cat hello.c
#include<stdio.h>
void run();
int main()
{
    printf("hello world!\n");
    run();
    return 0;
}

code.o 不认识 printf 函数

cpp 复制代码
$ cat code.c
#include<stdio.h>
void run()
{
    printf("running...\n");
}

我们可以看到这⾥的call指令,它们分别对应之前调⽤的printf和run函数,但是你会发现他们的跳转地址都被设成了0。那这是为什么呢?
其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的,⽐如他们
位于内存的哪个区块,代码⻓什么样都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为0。
这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表⾥记录的地址将其修正。
静态链接就是把库中的.o进⾏合并,所以链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴的可执⾏⽂件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这其实就是静态链接的过程。

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

7.2ELF加载与进程地址空间

7.2.1 虚拟地址/逻辑地址

  1. ⼀个ELF程序,在没有被加载到内存的时候,有没有地址呢?
    2.进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?
    ⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机⼯作的时候,都采⽤"平坦
    模式"进⾏⼯作。所以也要求ELF对⾃⼰的代码和数据进⾏统⼀编址,下⾯是 objdump -S 反汇编
    之后的代码


最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量), 但是我们
认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执
⾏程序进⾏统⼀编址了.
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?从ELF各个
segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start, end]
等范围数据,另外在⽤详细地址,填充⻚表.
所以:虚拟地址机制,不光光OS要⽀持,编译器也要⽀持.

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

ELF 在被编译好之后,会把⾃⼰未来程序的⼊⼝地址记录在ELF header的Entry字段中:

bash 复制代码
$ gcc *.o
$ readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class:                      ELF64
Data:                       2's complement, little endian
Version:                    1 (current)
OS/ABI:                     UNIX - System V
ABI Version:                0
Type:                       DYN (Shared object file)
Machine:                    Advanced Micro Devices X86-64
Version:                    0x1
Entry point address:        0x1060
Start of program headers:   64 (bytes into file)
Start of section headers:   14768 (bytes into file)
Flags:                      0x0
Size of this header:        64 (bytes)
Size of program headers:    56 (bytes)
Number of program headers:  13
Size of section headers:    64 (bytes)
Number of section headers:  31
Section header string table index: 30

7.3动态链接与动态库加载

7.3.1进程如何看到动态库

7.3.2进程间如何共享库的

7.3.3动态链接

7.3.3.1概要

动态链接其实远⽐静态链接要常⽤得多。⽐如我们查看下 hello 这个可执⾏程序依赖的动态库,会发现它就⽤到了⼀个c动态链接库:

bash 复制代码
$ ldd hello
linux-vdso.so.1 => (0x00007fffeb1ab000)
libc.so.6 => /lib64/libc.so.6 (0x00007ff776af5000)
/lib64/ld-linux-x86-64.so.2 (0x00007ff776ec3000)
# ldd命令⽤于打印程序或者库⽂件所依赖的共享库列表。

这⾥的 libc.so是C语⾔的运⾏时库,⾥⾯提供了常⽤的标准输⼊输出⽂件字符串处理等等这些功能。
那为什么编译器默认不使⽤静态链接呢?静态链接会将编译产⽣的所有⽬标⽂件,连同⽤到的各种
库,合并形成⼀个独⽴的可执⾏⽂件,它不需要额外的依赖就可以运⾏。照理来说应该更加⽅便才对是吧?
静态链接最⼤的问题在于⽣成的⽂件体积⼤,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费⼤量的硬盘空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独⽴的动态链接库,等到程序运⾏的时候再将它们加载到内存,这样不但可以节省空间,因为同⼀个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享。

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

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

  • 设置堆栈:为程序创建⼀个初始的堆栈环境。
  • 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
  • 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调⽤和变量访问能够正确地映射到动态库中的实际地址。

动态链接器:
动态链接器(如ld-linux.so)负责在程序运⾏时加载动态库。
当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
环境变量和配置⽂件:
Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置⽂件(如/etc/ld.so.conf及其⼦配置
⽂件)来指定动态库的搜索路径。
这些路径会被动态链接器在加载动态库时搜索。
缓存⽂件:
为了提⾼动态库的加载效率,Linux系统会维护⼀个名为/etc/ld.so.cache的缓存⽂件。
该⽂件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会⾸先
搜索这个缓存⽂件。

  • 调⽤ __libc_start_main :⼀旦动态链接完成, _start 函数会调⽤__libc_start_main (这是glibc提供的⼀个函数)。 __libc_start_main 函数负责执⾏⼀些额外的初始化⼯作,⽐如设置信号处理函数、初始化线程库(如果使⽤了线程)等。
  • 调⽤ main 函数:最后, __libc_start_main 函数会调⽤程序的 main 函数,此时程序的执⾏控制权才正式交给⽤⼾编写的代码。
  • 处理 main 函数的返回值:当 main 函数返回时, __libc_start_main 会负责处理这个返回 值,并最终调⽤ _exit 函数来终⽌程序
7.3.3.2动态库中的相对地址

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

7.3.3.3我们的程序,怎么和库具体映射起来的

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

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

7.3.3.4我们的程序,怎么进⾏库函数调⽤

库已经被我们映射到了当前进程的地址空间中
库的虚拟起始地址我们也已经知道了
库中每⼀个⽅法的偏移量地址我们也知道
所有:访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅法
⽽且:整个调⽤过程,是从代码区跳转到共享区,调⽤完毕在返回到代码区,整个过程完全在进程地址空间中进⾏的.

7.3.3.5全局偏移量表GOT(global offset table)

也就是说,我们的程序运⾏之前,先把所有库加载并映射,所有库的起始虚拟地址都应该
提前知道
然后对我们加载到内存中的程序的库函数调⽤进⾏地址修改,在内存中⼆次完成地址设置
(这个叫做加载地址重定位)
等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?
所以:动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。

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

bash 复制代码
$ readelf - S a.out
...
[24].got PROGBITS 0000000000003fb8 00002fb8
0000000000000048 0000000000000008 WA 0 0 8
...
$ readelf - l a.out # .got在加载的时候,会和.data合并成为⼀个segment,然后加载在⼀起

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

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

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


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

相关推荐
落笔画忧愁e几秒前
腾讯云轻量服务器 + OpenClaw 部署全攻略:从购买到飞书接入
服务器·飞书·腾讯云
第七序章1 分钟前
【Linux学习笔记】初识Linux —— 理解gcc编译器
linux·运维·服务器·开发语言·人工智能·笔记·学习
AI科技星4 分钟前
从ZUFT光速螺旋运动求导推出自然常数e
服务器·人工智能·线性代数·算法·矩阵
迎仔8 分钟前
A-总览:GPU驱动运维系列总览
linux·运维
tiantangzhixia11 分钟前
Master PDF Linux 平台的 5.9.35 版本安装与自用
linux·pdf·master pdf
choke23315 分钟前
软件测试任务测试
服务器·数据库·sqlserver
yyy的学习记录17 分钟前
Ubuntu下urdf模型转换成proto模型
linux·运维·ubuntu
礼拜天没时间.22 分钟前
自定义镜像制作——从Dockerfile到镜像
linux·docker·容器·centos·bash
xixingzhe222 分钟前
ubuntu安装gitlab
linux·ubuntu·gitlab
强风79430 分钟前
Linux-传输层协议TCP
linux·网络·tcp/ip