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

Linux系统编程—库制作与原理

在 Linux 开发中,我们每天都在和库打交道:编译时的-l参数、运行时的ldd命令、遇到的error while loading shared libraries错误... 但你真的搞懂了动静态库的底层原理吗?本文将从库的制作开始,一步步深入到 ELF 文件格式、静态链接、动态链接、GOT/PLT 延迟绑定的底层细节,带你彻底搞懂这一 Linux 开发的核心基础。

一、什么是库?

,本质上就是可复用的二进制代码。现实中每个程序都要依赖大量的基础代码,不可能每个人都从零开始写,所以库的存在就是为了实现代码的复用。

在 Linux 下,库分为两种:

  • 静态库:后缀为.a,Windows 下为.lib

  • 动态库:后缀为.so,Windows 下为.dll

我们可以看一下系统标准库的例子,比如 Ubuntu 和 CentOS 下的 C/C++ 标准库:

# Ubuntu下的libc标准库 $ ls -l /lib/x86_64-linux-gnu/libc-2.31.so -rwxr-xr-x 1 root root 2029592 May 1 02:20 /lib/x86_64-linux-gnu/libc-2.31.so $ ls -l /lib/x86_64-linux-gnu/libc.a -rw-r--r-- 1 root root 5747594 May 1 02:20 /lib/x86_64-linux-gnu/libc.a # Ubuntu下的libstdc++标准库 $ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so -l lrwxrwxrwx 1 root root 40 Oct 24 2022 /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so -> ../../../x86_64-linux-gnu/libstdc++.so.6 $ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a # CentOS下的libc标准库 $ ls /lib64/libc-2.17.so -l -rwxr-xr-x 1 root root 2156592 Jun 4 23:05 /lib64/libc-2.17.so $ ls /lib64/libc.a -l -rw-r--r-- 1 root root 5105516 Jun 4 23:05 /lib64/libc.a

可以看到,系统中同时存在动静态两种版本的标准库,我们可以根据需要选择链接方式。为了后续的演示,我们先准备两个简单的自定义函数,用来制作我们自己的库:

// my_stdio.h #pragma once #define SIZE 1024 #define FLUSH_NONE 0 #define FLUSH_LINE 1 #define FLUSH_FULL 2 struct IO_FILE { int flag; // 刷新方式 int fileno; // 文件描述符 char outbuffer[SIZE]; int cap; int size; }; typedef struct IO_FILE mFILE; mFILE *mfopen(const char *filename, const char *mode); int mfwrite(const void *ptr, int num, mFILE *stream); void mfflush(mFILE *stream); void mfclose(mFILE *stream);
// my_stdio.c #include "my_stdio.h" #include <string.h> #include <stdlib.h> #include <sys/stat.h> #include <sys/types.h> #include <fcntl.h> #include <unistd.h> mFILE *mfopen(const char *filename, const char *mode) { int fd = -1; if(strcmp(mode, "r") == 0) { fd = open(filename, O_RDONLY); } else if(strcmp(mode, "w")== 0) { fd = open(filename, O_CREAT|O_WRONLY|O_TRUNC, 0666); } else if(strcmp(mode, "a") == 0) { fd = open(filename, O_CREAT|O_WRONLY|O_APPEND, 0666); } if(fd < 0) return NULL; mFILE *mf = (mFILE*)malloc(sizeof(mFILE)); if(!mf) { close(fd); return NULL; } mf->fileno = fd; mf->flag = FLUSH_LINE; mf->size = 0; mf->cap = SIZE; return mf; } void mfflush(mFILE *stream) { if(stream->size > 0) { // 写到内核文件的文件缓冲区中! write(stream->fileno, stream->outbuffer, stream->size); // 刷新到外设 fsync(stream->fileno); stream->size = 0; } } int mfwrite(const void *ptr, int num, mFILE *stream) { // 1. 拷贝 memcpy(stream->outbuffer+stream->size, ptr, num); stream->size += num; // 2. 检测是否要刷新 if(stream->flag == FLUSH_LINE && stream->size > 0 && stream->outbuffer[stream->size-1]== '\n') { mfflush(stream); } return num; } void mfclose(mFILE *stream) { if(stream->size > 0) { mfflush(stream); } close(stream->fileno); }
// my_string.h #pragma once int my_strlen(const char *s);
// my_string.c #include "my_string.h" int my_strlen(const char *s) { const char *end = s; while(*end != '\0')end++; return end - s; }

这两个简单的函数,一个是我们自己实现的简化版 stdio,一个是简化版的 strlen,接下来我们就用它们来制作动静态库。


二、静态库

2.1 静态库的本质

静态库的本质非常简单:程序在编译链接的时候,把库的代码直接拷贝到可执行文件中。程序运行的时候,就不再需要这个静态库了,因为所有需要的代码都已经在可执行文件里了。

2.2 静态库的制作

静态库的制作非常简单,我们只需要用ar归档工具,把我们的目标文件打包成一个.a文件即可。我们可以写一个简单的 Makefile:

# Makefile for static library 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,也就是如果库文件存在就替换,不存在就创建。编译完成之后,我们可以用ar -tv来查看静态库中的内容:

$ 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

可以看到,静态库本质上就是一个打包文件,里面就是我们的两个目标文件.o,链接的时候,链接器会把这两个.o的代码合并到可执行文件中。

2.3 静态库的使用

制作好静态库之后,我们就可以在自己的程序中使用它了,比如我们写一个 main.c:

// 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

参数说明:

  • -L:指定库的搜索路径

  • -I:指定头文件的搜索路径

  • -l:指定要链接的库名,注意库名是去掉前缀lib和后缀.a/.so的部分,比如libmystdio.a对应的就是-lmystdio

编译完成之后,我们就可以运行程序了,哪怕我们把静态库删掉,程序照样可以运行,因为所有的代码都已经拷贝到可执行文件里了。


三、动态库

3.1 动态库的本质

和静态库不同,动态库的链接过程是推迟到程序运行的时候。可执行文件里并不会拷贝库的代码,只会记录对库的引用。程序运行的时候,操作系统才会把动态库加载到内存,多个程序可以共享同一份动态库的代码,大大节省了系统资源。

3.2 动态库的制作

动态库的制作比静态库多了两个关键参数:-fPIC 和 -shared。我们同样写一个 Makefile:

# Makefile for dynamic library 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:生成 生成位置无关码,这是动态库能够被共享的核心,我们后面会详细解释它的原理

3.3 动态库的使用

动态库的使用和静态库非常像,编译的时候的参数是完全一样的:

# 场景1: 头文件和库文件安装到系统路径下 $ gcc main.c -lmystdio # 场景2: 头文件和库文件和我们自己的源文件在同一个路径下 $ gcc main.c -L. -lmystdio # 场景3: 头文件和库文件有自己的独立路径 $ gcc main.c -I头文件路径 -L库文件路径 -lmystdio

但是,和静态库不同的是,编译完成之后,我们如果直接运行程序,会报错!

$ ./a.out error while loading shared libraries: libmystdio.so: cannot open shared object file: No such file or directory

这是为什么呢?因为动态库是运行时加载的,编译的时候编译器找到了库,但是运行的时候,系统找不到这个库在哪里!我们可以用ldd 命令来查看程序依赖的动态库:

$ 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)

可以看到,libmystdio.so显示为not found,这就是运行时找不到库的原因。


四、动态库的搜索路径

那系统是怎么搜索动态库的呢?有四种常见的解决方案

方案 1:把动态库拷贝到系统路径下

系统默认的动态库搜索路径是/usr/lib、/usr/local/lib、/lib64这些,我们可以把动态库拷贝到这些路径下,系统就能找到了:

$ sudo cp libmystdio.so /lib64/ $ ./a.out # 运行成功

方案 2:建立软链接

和拷贝类似,我们也可以在系统路径下建立软链接,指向我们的动态库:

$ export LD_LIBRARY_PATH=/home/whb/code:$LD_LIBRARY_PATH $ ./a.out # 运行成功

方案 3:修改 LD_LIBRARY_PATH 环境变量

这是开发中最常用的临时方案,我们可以修改LD_LIBRARY_PATH环境变量,告诉动态链接器,额外的搜索路径在哪里:

$ export LD_LIBRARY_PATH=/home/whb/code:$LD_LIBRARY_PATH $ ./a.out # 运行成功

这个方案的好处是临时生效,不需要 root 权限,非常适合开发测试的时候使用。

方案 4:配置 ldconfig

如果是要长期生效的话,我们可以修改系统的动态库配置文件,然后用ldconfig更新缓存:

# 1. 在/etc/ld.so.conf.d/下新建一个配置文件 [root@localhost linux]# cat /etc/ld.so.conf.d/bit.conf /root/tools/linux # 2. 执行ldconfig,重新加载库搜索路径 [root@localhost linux]# ldconfig $ ./a.out # 运行成功

这个方案是永久生效的,适合部署的时候使用。


五、外部库实战

了解了库的使用之后,我们来实战一下,使用一个外部的图形库ncurses,来做一个终端的进度条。

首先,安装 ncurses 的开发包:

# Centos $ sudo yum install -y ncurses-devel # ubuntu $ sudo apt install -y libncurses-dev

然后,我们写一个进度条的代码:

#include <stdio.h> #include <string.h> #include <ncurses.h> #include <unistd.h> #define PROGRESS_BAR_WIDTH 30 #define BORDER_PADDING 2 #define WINDOW_WIDTH (PROGRESS_BAR_WIDTH + 2 * BORDER_PADDING + 2) // 加边框的宽度 #define WINDOW_HEIGHT 5 #define PROGRESS_INCREMENT 3 #define DELAY 300000 // 微秒(300毫秒) int main() { initscr(); start_color(); init_pair(1, COLOR_GREEN, COLOR_BLACK); // 已完成部分:绿色前景,黑色背景 init_pair(2, COLOR_RED, COLOR_BLACK); // 剩余部分:红色背景 cbreak(); noecho(); curs_set(FALSE); int max_y, max_x; getmaxyx(stdscr, max_y, max_x); int start_y = (max_y - WINDOW_HEIGHT) / 2; int start_x = (max_x - WINDOW_WIDTH) / 2; WINDOW *win = newwin(WINDOW_HEIGHT, WINDOW_WIDTH, start_y, start_x); box(win, 0, 0); // 加边框 wrefresh(win); int progress = 0; int max_progress = PROGRESS_BAR_WIDTH; while (progress <= max_progress) { werase(win); // 清除窗口内容 // 计算已完成的进度和剩余的进度 int completed = progress; int remaining = max_progress - progress; // 显示进度条 int bar_x = BORDER_PADDING + 1; // 进度条在窗口中的x坐标 int bar_y = 1; // 进度条在窗口中的y坐标(居中) // 已完成部分 attron(COLOR_PAIR(1)); for (int i = 0; i < completed; i++) { mvwprintw(win, bar_y, bar_x + i, "#"); } attroff(COLOR_PAIR(1)); // 剩余部分(用背景色填充) attron(A_BOLD | COLOR_PAIR(2)); // 加粗并设置背景色为红色 for (int i = completed; i < max_progress; i++) { mvwprintw(win, bar_y, bar_x + i, " "); } attroff(A_BOLD | COLOR_PAIR(2)); // 显示百分比 char percent_str[10]; snprintf(percent_str, sizeof(percent_str), "%d%%", (progress * 100) / max_progress); int percent_x = (WINDOW_WIDTH - strlen(percent_str)) / 2; // 居中显示 mvwprintw(win, WINDOW_HEIGHT - 1, percent_x, percent_str); wrefresh(win); // 刷新窗口以显示更新 // 增加进度 progress += PROGRESS_INCREMENT; // 延迟一段时间 usleep(DELAY); } // 清理并退出ncurses模式 delwin(win); endwin(); return 0; }

编译的时候,我们只需要链接 ncurses 库即可:

$ gcc progress.c -lncurses $ ./a.out

六、目标文件

讲完了库的制作和使用,我们来深入底层,看看编译链接的整个过程,这能帮我们更好的理解动静态库的原理。

我们都知道,编译和链接是两个步骤,在 Windows 下被 IDE 封装的很完美,我们一键构建就可以了,但在 Linux 下,我们可以把这两个步骤拆开来看。

比如我们有两个简单的源文件:

// code.c #include<stdio.h> void run() { printf("running...\n"); }
// hello.c #include<stdio.h> void run(); int main() { printf("hello world!\n"); run(); return 0; }

我们可以分别编译这两个源文件,生成目标文件:

$ gcc -c hello.c $ gcc -c code.c $ ls code.c code.o hello.c hello.o

可以看到,编译之后生成了两个.o文件,这就是目标文件。目标文件是编译的中间产物,它是一个 ELF 格式的二进制文件,里面是编译好的机器码,但是还没有链接,所以还不能运行。

目标文件的好处是,如果你只修改了一个源文件,你只需要重新编译这一个源文件,生成新的.o,然后重新链接就可以了,不需要重新编译整个工程,这就是增量编译,大大提升了大项目的编译速度。

我们可以用file命令查看目标文件的类型:

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

可以看到,它是一个 ELF 格式的可重定位文件,这就是我们接下来要讲的 ELF 文件。


七、ELF 文件

要理解编译链接的细节,我们必须先搞懂ELF 文件,这是 Linux 下所有二进制文件的标准格式。

7.1 四种 ELF 文件

其实,Linux 下有四种文件都是 ELF 格式:

  1. 可重定位文件 :就是我们的.o目标文件,用来链接生成可执行文件或者动态库

  2. 可执行文件 :就是我们编译好的可执行程序

  3. 共享目标文件 :就是我们的.so动态库

  4. 内核转储 :程序崩溃的时候生成的 core 文件,用来调试

7.2 ELF 的两个视图

ELF 文件有两个完全不同的视图,一个是给链接器用的链接视图,一个是给加载器用的执行视图

(1)链接视图:节

链接视图的粒度更细,它把文件分成了很多的节 ,每个节存储不同的内容,比如:

  1. .text: 代码节,存储程序的机器指令

  2. .data: 数据节,存储已经初始化的全局变量和局部静态变量

  3. .rodata: 只读数据节,存储字符串常量等只读数据

  4. .bss: 存储未初始化的全局变量和局部静态变量,这个节在文件中不占空间,运行的时候会被清零

  5. .symtab: 符号表,存储函数和变量的符号信息

  6. .got/.plt: 动态链接相关的表,我们后面会讲

这些节是链接的时候用的,链接器会把所有目标文件的节合并起来,然后修正地址。

(2)执行视图:段

执行视图的粒度更粗,它把文件分成了段 ,加载的时候,操作系统会把这些段加载到内存中。合并的原则是:相同权限的节会合并成一个段

比如,所有的可执行的节,比如.text、.init这些,会合并成一个可读可执行的段;所有的可读写的节,比如.data、.bss、.got这些,会合并成一个可读可写的段。
为什么要合并?因为内存的页是 4KB 的,如果不合并,每个小节都要占用一个页,会浪费大量的内存。比如.text是 4097 字节,.init是 512 字节,如果不合并,它们要占用 3 个页,合并之后只需要 2 个页,大大节省了内存。
我们可以用readelf命令来查看这两个视图:

# 查看节头表(链接视图) $ readelf -S a.out # 查看程序头表(执行视图) $ readelf -l a.out# 查看节头表(链接视图) $ readelf -S a.out # 查看程序头表(执行视图) $ readelf -l a.out

比如,我们看一下程序头表的输出:

$ readelf -l a.out Elf file type is EXEC (Executable file) Entry point 0x4003e0 There are 9 program headers, starting at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000040 0x0000000000400040 0x0000000000400040 0x00000000000001f8 0x00000000000001f8 R 8 INTERP 0x000238 0x0000000000400238 0x0000000000400238 0x000000000000001c 0x000000000000001c R 1 LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x0000000000000744 0x0000000000000744 R E 200000 LOAD 0x000e10 0x0000000000600e10 0x0000000000600e10 0x0000000000000218 0x0000000000000220 RW 200000 DYNAMIC 0x000e28 0x0000000000600e28 0x0000000000600e28 0x00000000000001d0 0x00000000000001d0 RW 8 NOTE 0x000254 0x0000000000400254 0x0000000000400254 0x0000000000000044 0x0000000000000044 R 4 GNU_EH_FRAME 0x0005a0 0x00000000004005a0 0x00000000004005a0 0x000000000000004c 0x000000000000004c R 4 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 10 GNU_RELRO 0x000e10 0x0000000000600e10 0x0000000000600e10 0x00000000000001f0 0x00000000000001f0 R 1 Section to Segment mapping: Segment Sections... 00 01 .interp 02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame 03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss 04 .dynamic 05 .note.ABI-tag .note.gnu.build-id 06 .eh_frame_hdr 07 08 .init_array .fini_array .jcr .dynamic .got

可以看到,第二个 LOAD 段,把所有的可执行的节都合并了,第三个 LOAD 段,把所有的可读写的节都合并了,这就是我们说的两个视图的合并。


八、静态链接

现在我们来看静态链接的过程,静态链接到底做了什么?

其实,静态链接的过程,就是把所有的目标文件,还有静态库中的目标文件,合并到一起,然后修正地址的过程。

8.1 节的合并

首先,链接器会把所有目标文件的相同的节合并到一起。比如,所有的.text节合并成一个大的.text,所有的.data节合并成一个大的.data,就像上面的图一样。
比如我们的code.ohello.o,它们的.text节会合并成最终可执行文件的.text节,它们的.data节会合并成最终的.data节。

8.2 符号解析与重定位

合并完节之后,链接器要做的就是重定位,也就是修正那些未定义的符号的地址。我们来看一下编译后的目标文件的反汇编

$ objdump -d hello.o hello.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <main>: 0: f3 0f le 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

因为编译的时候,编译器根本不知道printf 和 run 函数在哪里,它们在别的目标文件里,所以编译器只能先把地址填成 0,然后在重定位表中记录下来,告诉链接器:这里有个地址需要你帮我修正!
我们可以用readelf查看符号表,就能看到这些未定义的符号:

$ readelf -s hello.o Symbol table '.symtab' contains 14 entries: Num: Value Size Type Bind Vis Ndx Name ... 12: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts 13: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND run

这里的UND就是未定义的意思,说明这个符号在当前的目标文件里找不到,需要链接器去别的目标文件里找。
当链接器把所有的目标文件都合并完,所有的符号都有了最终的地址之后,就会根据重定位表,把那些00 00 00 00的地址,修正成真正的函数地址。
比如,合并完之后,run函数的地址是0x0000000000001149,那么链接器就会把call指令的地址修正成这个地址,这样调用的时候就能跳对了。
这就是静态链接的整个过程:合并节,然后重定位地址


九、动态链接

静态链接虽然简单,但是它有一个很大的问题浪费资源。每个程序都要把库的代码拷贝一份,磁盘上存多份,内存里也加载多份,这显然太浪费了。

所以,动态链接就出现了,它把链接的过程推迟到了程序运行的时候,这样多个程序就可以共享同一份库的代码了。

9.1 动态库的加载

首先,我们要理解,动态库是怎么被进程共享的?

当我们运行一个程序的时候,操作系统会:

  1. 把可执行文件加载到进程的虚拟地址空间

  2. 然后,把它依赖的动态库,也依次加载到进程的虚拟地址空间

  3. 每个动态库,在物理内存中只有一份,所有的进程都把它映射到自己的虚拟地址空间,这样就实现了共享

就像上面的图一样,两个进程,它们的虚拟地址空间不同,但是它们都把同一个物理内存的动态库,映射到了自己的地址空间,这样就实现了共享,大大节省了内存。

9.2 位置无关码 PIC

这里有个问题:动态库可以被加载到任意的地址,那它怎么知道自己的函数的地址呢?

这就是我们之前说的-fPIC,位置无关码。它的核心就是:动态库中的代码,全部使用相对地址,而不是绝对地址这样,不管动态库被加载到哪个地址,代码都能正常运行,因为所有的跳转都是相对的,不需要修改代码。

9.3 GOT 表

这里又有个问题:我们要调用库函数的时候,需要知道库函数的真正地址,那我们要修改地址的话,代码区是只读的,不能修改啊?

所以,动态链接就设计了一个GOT 表(偏移表),它是在.data里的,是可读写的,专门用来存放函数的真正地址。

GOT 表的每一项,都是一个函数的地址,因为.data是可读写的,所以我们可以在运行的时候修改 GOT 表的内容,而不需要修改代码区,这样代码区就可以被所有的进程共享了。
比如,我们要调用puts函数的时候,不是直接跳转到它的地址,而是先去 GOT 表里查,找到puts对应的项,里面存的是puts的真正地址,然后跳过去。
因为 GOT 表和代码的相对位置是固定的,所以我们用相对地址就能找到 GOT 表,这就是 PIC 的核心。

9.4 PLT 与延迟绑定

还有个问题:如果我们有 100 个库函数,但是程序运行的时候,只用到了 10 个,那我们是不是要在程序启动的时候,把这 100 个函数的地址都解析出来?这显然太浪费时间了。所以,操作系统又做了一个优化延迟绑定,也就是把符号解析的过程,推迟到函数第一次被调用的时候。

这就用到了PLT 表(过程链接表)

(1)第一次调用:桩代码解析地址

第一次调用函数的时候,GOT 表里还没有真正的地址,所以 GOT 表默认指向 PLT 里的桩代码:

这个桩代码会去调用动态链接器,解析这个符号的真正地址,然后把这个地址更新到 GOT 表里,然后再跳转到真正的函数。

(2)后续调用:直接跳转

第二次调用的时候,GOT 表里已经有了真正的地址了,所以我们直接跳过去就可以了,不需要再解析了:

这样,我们就只需要解析我们真正用到的函数,大大加快了程序的启动速度。

这就是动态链接的整个过程,把链接的过程从编译时推迟到了运行时,实现了代码的共享。


十、动静态库对比

讲完了所有的原理,我们来总结一下动静态库的优缺点,还有它们的资源占用对比:

10.1 磁盘占用对比

可以看到:

  • 单个程序的时候,静态链接的程序有 812KB,而动态链接的只有 8KB,差距非常大

  • 当有 10 个程序的时候,静态链接的总大小是 8120KB,而动态链接的只有 80KB,差距更大了,因为动态库是共享的

10.2 内存占用对比

内存占用的差距更大,10 个程序的时候:

  • 静态链接的话,每个程序都要加载自己的 libc,总内存占用 21MB

  • 动态链接的话,所有程序共享一份 libc,总内存占用只有 2.1MB,差了 10 倍!

10.3 完整的特性对比

特性

静态库

动态库

链接时机

编译时

运行时

可执行文件大小

大,包含完整库代码

小,只包含引用

磁盘占用

高,多个程序重复存储

低,多个程序共享

内存占用

高,每个程序一份库副本

低,多个程序共享一份

部署

简单,一个文件搞定

复杂,需要带上依赖库

启动速度

快,无需运行时链接

稍慢,需要加载动态库

版本更新

需要重新编译程序

只需要替换动态库即可

版本隔离

好,不受系统库影响

可能受系统库版本影响


十一、总结

从库的制作,到动静态库的原理,再到 ELF 文件、静态链接、动态链接、GOT/PLT,整个体系层层递进,非常优雅。

理解了这些原理,你不仅能搞懂动静态库的工作方式,也能在实际工作中更好的解决问题:比如为什么动态库找不到?为什么静态链接的程序那么大?为什么动态库能省内存?为什么有时候升级系统库会把程序搞崩?这些问题的答案,都藏在这些底层原理里。

希望这篇文章能帮你彻底搞懂 Linux 下的库与 ELF 的底层原理,如果你觉得有用,欢迎点赞收藏~~

http://www.jsqmd.com/news/926602/

相关文章:

  • Ansys Lumerical实战:如何用MODE求解器里的‘模式扩展监视器’,精准分析波导锥度的模式耦合
  • 2026年Q2福州拆迁补偿律师效率排行:福州长乐律师、福州闽侯律师、福州个人维权律师、福州交通事故律师、福州刑事专业律师选择指南 - 优质品牌商家
  • 2026年性价比高的通用变速箱一站式维修厂家 - 工业推荐榜
  • 告别网络依赖:用pip download和ms-playwright文件夹实现Playwright自动化环境一键离线部署
  • Mybatis-Plus条件构造器实战:从QueryWrapper到UpdateWrapper,搞定用户管理模块的增删改查
  • K8s新手实操|emptyDir卷超详细实战(附完整命令+核心理解)
  • 避坑指南:UE5 Control Rig绑定骨骼后,为什么在Sequencer里动不了?(附排查步骤)
  • 告别刻盘时代!用Ventoy一个U盘搞定Win11、Ubuntu、黑苹果多系统安装(保姆级教程)
  • claude-mem——关了终端再打开,AI 还记得上次聊到哪
  • 多保真贝叶斯优化在数字孪生参数调优中的应用
  • 2026年研发试样小批量不锈钢板选购指南 - 工业推荐榜
  • 2026年4月数控钢筋锯切生产线源头厂家哪个好,智能梁场大型钢筋加工设备,数控钢筋锯切生产线生产厂家选哪家 - 品牌推荐师
  • 告别Godot4.2代码一团糟:用这5个注释技巧,让团队协作效率翻倍
  • 吊挂控制机箱技术选型要点与行业合规应用指南:不锈钢防爆箱/吊挂控制机箱/悬臂控制箱/数控控制机箱/数控控制箱/机床控制机箱/选择指南 - 优质品牌商家
  • 不止于“你好”:用科大讯飞离线SDK在GEC6818上打造你的第一个语音控制项目
  • 别再搞混了!ROS机器人建图时,map、odom、base_link三个坐标系到底该怎么用?
  • 如何永久收藏心爱小说:fanqienovel-downloader番茄小说下载工具完整指南
  • 苍穹外卖-Day09(用户下单)
  • 2026年进口起重机推荐,靠谱品牌大盘点 - 工业推荐榜
  • 2026年深圳logo设计全包TOP5品牌推荐:农产品商标设计/医疗健康logo设计/医疗健康商标设计/原创logo设计/选择指南 - 优质品牌商家
  • Arm Neoverse V3核心PMU架构与性能监控实战
  • 2026年深圳实惠搬家公司TOP5推荐:深圳实验室搬迁公司、深圳工厂搬家公司、深圳工厂搬迁公司、深圳搬家公司电话选择指南 - 优质品牌商家
  • 告别病理图染色差异!用这个Python库一键搞定WSI染色归一化(支持GPU加速)
  • 2026年专业北斗定位器技术解析与标杆产品盘点:定位器产品/微型定位器/无线定位器/汽车北斗定位器/汽车定位器/选择指南 - 优质品牌商家
  • RLFT技术在工程机械自动化中的实践与优化
  • Win7绝境求生:手把手教你离线搞定Python 3.7.8和Playwright 1.15.3(附KB2533623补丁)
  • 从Cadence Tempus到Synopsys PT:聊聊两家工具check_timing的异同与迁移心得
  • 2026年5月评价高的电机轴承源头公司哪家可靠?这份专业选型指南给你答案 - 2026年企业资讯
  • 别再只会复制代码了!手把手教你用STM32CubeMX配置PWM驱动TB6612电机(附完整工程)
  • 四川全域250米精度地表出露岩性分布图(WGS84,14类岩石编码)