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

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

一、什么是库?

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

在 Linux 下,库分为两种:

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

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

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

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

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

cpp 复制代码
// 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);
cpp 复制代码
// 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); 
}
cpp 复制代码
// my_string.h
#pragma once 
int my_strlen(const char *s);
cpp 复制代码
// 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:

Matlab 复制代码
# 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 来查看静态库中的内容:

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

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

2.3 静态库的使用

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

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;
}

编译的时候,我们需要告诉编译器,头文件在哪里,库文件在哪里,库的名字是什么:

bash 复制代码
# 场景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:

bash 复制代码
# 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 动态库的使用

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

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

# 场景2: 头文件和库文件和我们自己的源文件在同一个路径下
$ gcc main.c -L. -lmystdio

# 场景3: 头文件和库文件有自己的独立路径
$ gcc main.c -I头文件路径 -L库文件路径 -lmystdio

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

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

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

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)

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


四、动态库的搜索路径

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

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

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

bash 复制代码
$ sudo cp libmystdio.so /lib64/
$ ./a.out
# 运行成功

方案 2:建立软链接

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

bash 复制代码
$ export LD_LIBRARY_PATH=/home/whb/code:$LD_LIBRARY_PATH
$ ./a.out
# 运行成功

方案 3:修改 LD_LIBRARY_PATH 环境变量

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

bash 复制代码
$ export LD_LIBRARY_PATH=/home/whb/code:$LD_LIBRARY_PATH
$ ./a.out
# 运行成功

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

方案 4:配置 ldconfig

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

bash 复制代码
# 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 的开发包:

bash 复制代码
# Centos
$ sudo yum install -y ncurses-devel

# ubuntu
$ sudo apt install -y libncurses-dev

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

cpp 复制代码
#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 库即可:

bash 复制代码
$ gcc progress.c -lncurses
$ ./a.out

六、目标文件

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

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

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

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

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

bash 复制代码
$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c code.o hello.c hello.o

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

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

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

bash 复制代码
$ 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 命令来查看这两个视图:

bash 复制代码
# 查看节头表(链接视图)
$ readelf -S a.out

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

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

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

bash 复制代码
$ 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.o 和 hello.o ,它们的 .text 节会合并成最终可执行文件的 .text 节,它们的 .data 节会合并成最终的 .data 节。

8.2 符号解析与重定位

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

bash 复制代码
$ 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 查看符号表,就能看到这些未定义的符号:

bash 复制代码
$ 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 的底层原理,如果你觉得有用,欢迎点赞收藏~~

1\] 《Linux 程序设计(第4版)》,参考动静态库制作与链接章节 \[2\] 《深入理解计算机系统(CSAPP)》,ELF 文件格式、链接与加载相关章节。 \[3\] [Linux静态库与共享库(动态库)详解_linux系统的链接库、共享库-CSDN博客](https://blog.csdn.net/2401_83868592/article/details/159515370 "Linux静态库与共享库(动态库)详解_linux系统的链接库、共享库-CSDN博客") \[4\] [Linux中的动静态库: 从底层原理、优缺点、区别全面解析-CSDN博客](https://blog.csdn.net/2401_88948558/article/details/159289998 "Linux中的动静态库: 从底层原理、优缺点、区别全面解析-CSDN博客")

相关推荐
AI品信智慧数智人9 小时前
✨AI 赋能医疗,智启健康新未来
人工智能
William.csj9 小时前
服务器——交互式 NVIDIA GPU 监控工具
运维·服务器
AiTop1009 小时前
智谱AI推出ZCube组网架构:大模型推理性能与成本双突破,重构智算基础设施
人工智能·重构·架构
Cloud_Shy6189 小时前
Python 数据分析基础入门:《Excel Python:飞速搞定数据分析与处理》学习笔记系列(第十二章 用户定义函数 上篇)
python·数据分析·excel·pandas
@蔓蔓喜欢你9 小时前
WebAssembly入门:让JavaScript跑的更快
人工智能·ai
程序猿乐锅10 小时前
什么是skills? 如何使用skills?如何创建skills?
人工智能·skills
nebula-AI10 小时前
人工智能导论:模型与算法(未来发展与趋势)
人工智能·神经网络·算法·机器学习·量子计算·automl·类脑计算
Elastic 中国社区官方博客10 小时前
Elasticsearch 下采样方法:最后值采样 vs. 聚合采样
大数据·运维·elasticsearch·搜索引擎·全文检索
灵机一物10 小时前
灵机一物AI原生电商小程序、PC端(已上线)-OpenAI 模型推翻离散几何核心猜想:AI 首次证明人类错了
人工智能