Linux系统编程基石:静态库·动态库·ELF文件·进程地址空间全景图

前言

在Linux环境下进行C/C++开发时,我们每天都在使用各种库,但你是否真正理解这些库是如何工作的?当程序编译链接通过却运行报错找不到动态库时,你是否感到困惑?本文将带你深入理解动静态库的制作与使用、ELF文件格式、以及程序从编译到运行的全过程。

本文将覆盖以下重点内容:

  • 动静态库的制作与使用
  • 动态库的查找机制与常见问题
  • ELF文件格式深度解析
  • 可执行程序的加载过程
  • 虚拟地址空间与动态库加载原理
  • GOT/PLT机制与动态链接的底层实现

1. 什么是库

1.1 库的本质

重点概念: 库是写好的、现有的、成熟的、可以复用的代码。本质上是一种可执行代码的二进制形式,可以被操作系统载入内存执行。

现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始。库的存在大大提高了软件开发的效率。

1.2 库的分类

库分为两种类型:

类型 Linux后缀 Windows后缀 特点
静态库 .a .lib 编译时链接,代码复制到可执行文件中
动态库 .so .dll 运行时加载,多个程序共享使用
bash 复制代码
# 查看系统中的C标准库(静态库和动态库)
$ 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

# 查看C++标准库
$ 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 -> ...

$ ls /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a
/usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a

1.3 示例:自定义库的头文件

以一个简单的带缓冲区的文件IO库为例:

c 复制代码
// my_stdio.h
#pragma once

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>

#define SIZE 1024

#define FLUSH_NONE 0    // 不刷新
#define FLUSH_LINE 1    // 行刷新
#define FLUSH_FULL 2    // 满刷新

// 自定义文件结构体
typedef struct IO_FILE {
    int flag;           // 刷新方式
    int fileno;         // 文件描述符
    char outbuffer[SIZE]; // 输出缓冲区
    int cap;            // 缓冲区容量
    int size;           // 当前缓冲区大小
} 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);
c 复制代码
// my_string.h
#pragma once

int my_strlen(const char *s);
c 复制代码
// my_string.c
#include "my_string.h"

int my_strlen(const char *s)
{
    const char *end = s;
    while (*end != '\0') end++;
    return end - s;
}

2. 静态库的制作与使用

2.1 静态库的特点

重点概念: 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库,即使删掉库文件,程序照样可以运行。

静态库的优点:

  • 运行时不需要依赖外部库文件
  • 部署简单,一个可执行文件即可运行

静态库的缺点:

  • 生成的可执行文件体积较大
  • 当多个程序使用同一个库时,内存中会有多份拷贝,浪费内存
  • 库更新后需要重新编译链接整个程序

2.2 静态库的制作

易错点: 静态库中不要包含main函数 !静态库本质是对.o目标文件的打包,main函数应由用户程序提供。

静态库的制作主要包含两个步骤:生成目标文件(.o)和使用ar工具打包成.a文件。

2.2.1 生成目标文件
bash 复制代码
# 将源文件编译为目标文件,但不进行链接
$ gcc -c my_stdio.c -o my_stdio.o
$ gcc -c my_string.c -o my_string.o
  • -c 选项:只编译(生成目标文件),不进行链接。
2.2.2 使用ar工具打包
bash 复制代码
# 使用ar命令将多个.o文件打包成静态库
$ ar -rc libmystdio.a my_stdio.o my_string.o

ar命令详解:

  • ar 是GNU归档工具,用于创建、修改和提取静态库。
  • 常用选项:
    • r(replace):将指定的文件插入到归档文件中,如果文件已存在则替换(replace)。
    • c(create):创建归档文件,如果库文件不存在则新建(create)。如果不加car可能会输出警告信息。
    • t(table):列出归档文件中的所有目标文件,通常与v一起使用显示详细信息。
    • v(verbose):显示详细信息,与rct等配合使用。
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
# 输出显示了文件的权限、UID/GID、大小、时间等信息
2.2.3 完整Makefile示例
makefile 复制代码
# Makefile 示例
libmystdio.a: my_stdio.o my_string.o
	@ar -rc $@ $^                           # ar是gnu归档工具,rc表示replace and create
	@echo "build $@ done"

%.o: %.c
	@gcc -c $< -o $@
	@echo "compiling $< to $@ done"

.PHONY: clean
clean:
	@rm -rf *.a *.o
	@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"

2.3 静态库的使用

c 复制代码
// main.c - 测试程序
#include "my_stdio.h"
#include "my_string.h"
#include <stdio.h>

int main()
{
    const char *s = "Hello, Linux Library!";
    printf("%s: length = %d\n", s, my_strlen(s));

    mFILE *fp = mfopen("/tmp/log.txt", "a");
    if (fp == NULL) {
        printf("Failed to open file\n");
        return 1;
    }

    mfwrite(s, my_strlen(s), fp);
    mfwrite("\n", 1, fp);
    mfclose(fp);

    printf("Write success!\n");
    return 0;
}

三种使用场景:

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

# 场景2:头文件和库文件在同一个目录下
$ gcc main.c -L. -lmystdio
# -L: 指定库路径  -l: 指定库名(去除lib前缀和.a后缀)

# 场景3:头文件和库文件有独立路径
$ gcc main.c -I./include -L./lib -lmystdio
# -I: 指定头文件搜索路径

易错点: 库文件名称和链接时的库名规则:去掉前缀lib,去掉后缀.so.a。例如:libc.soclibmystdio.amystdio

编译验证:

bash 复制代码
# 编译后查看可执行文件大小
$ gcc main.c -L. -lmystdio -o test_static
$ ls -lh test_static
-rwxrwxr-x 1 whb whb 17K Oct 29 15:46 test_static

# 删除静态库后程序仍可运行
$ rm libmystdio.a
$ ./test_static
Hello, Linux Library!: length = 20
Write success!

3. 动态库的制作与使用

3.1 动态库的特点

重点概念: 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。

动态库的优点:

  • 可执行文件体积小
  • 多个程序共享同一份动态库,节省内存和磁盘空间
  • 库更新后不需要重新编译程序(接口兼容的情况下)

动态库的缺点:

  • 运行时依赖库文件,部署需要额外考虑库的路径
  • 首次加载时有一定性能开销

3.2 动态库的制作

动态库的制作同样需要先生成目标文件,但编译时必须添加特殊选项,然后链接成共享库。

3.2.1 生成位置无关的目标文件
bash 复制代码
# 使用 -fPIC 选项生成位置无关的目标文件
$ gcc -fPIC -c my_stdio.c -o my_stdio.o
$ gcc -fPIC -c my_string.c -o my_string.o
  • -fPIC(Position Independent Code,位置无关代码):生成可以在内存中任意位置加载而无需重定位的代码。这对于动态库来说是必需的,因为不同的进程可能会将库加载到不同的虚拟地址。PIC通过使用全局偏移表(GOT)和相对寻址等方式实现。
3.2.2 链接为动态库
bash 复制代码
# 使用 -shared 选项创建动态库
$ gcc -shared -o libmystdio.so my_stdio.o my_string.o
  • -shared:指示GCC生成一个共享库(动态库)而不是可执行文件。它会将目标文件组织成适用于动态加载的格式,并添加必要的符号表和重定位信息。

结合使用:

你也可以直接一步到位:

bash 复制代码
$ gcc -fPIC -shared my_stdio.c my_string.c -o libmystdio.so
3.2.3 完整Makefile示例
makefile 复制代码
libmystdio.so: my_stdio.o my_string.o
	gcc -shared -o $@ $^           # -shared: 生成共享库
	@echo "build $@ done"

%.o: %.c
	gcc -fPIC -c $< -o $@           # -fPIC: 生成位置无关代码
	@echo "compiling $< to $@ done"

.PHONY: clean
clean:
	@rm -rf *.so *.o
	@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"

3.3 动态库的使用

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

# 场景2:头文件和库文件在同一个目录下
$ gcc main.c -L. -lmystdio

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

# 查看可执行程序的依赖
$ ldd a.out
        linux-vdso.so.1 => (0x00007fffacbbf000)
        libmystdio.so => not found          # 注意:显示not found!
        libc.so.6 => /lib64/libc.so.6 (0x00007f891735000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f8917905000)

3.4 动态库运行搜索路径问题

关键问题: 编译时通过-L指定了库路径,但运行时还是找不到库!

这是因为-L只告诉链接器 在哪里找库,但程序运行时,动态链接器需要知道库的位置。

解决方案:

方案1:拷贝到系统共享库路径

bash 复制代码
$ sudo cp libmystdio.so /usr/lib/
# 或者
$ sudo cp libmystdio.so /usr/local/lib/

方案2:建立软链接

bash 复制代码
$ sudo ln -s $(pwd)/libmystdio.so /usr/lib/libmystdio.so

方案3:设置环境变量(临时)

bash 复制代码
$ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$(pwd)
$ ./a.out
# 程序正常运行!

方案4:修改系统配置(永久)

bash 复制代码
# 创建配置文件
$ sudo vim /etc/ld.so.conf.d/myconfig.conf
# 添加库路径:/path/to/your/lib
$ sudo ldconfig    # 更新库搜索缓存

方案5:编译时指定运行时路径(推荐)

bash 复制代码
$ gcc main.c -L. -lmystdio -Wl,-rpath='$ORIGIN'
# -Wl,-rpath: 将运行时库搜索路径写入可执行文件
# $ORIGIN: 表示可执行文件所在目录

易错点总结:

  • -L是编译时的库搜索路径,不影响运行时
  • LD_LIBRARY_PATH是运行时的环境变量,仅对当前会话有效
  • -Wl,-rpath将运行路径嵌入可执行文件,是最可靠的部署方式

4. 目标文件与ELF格式

4.1 从编译到链接

bash 复制代码
# 示例源文件
# hello.c
#include <stdio.h>
extern 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 -o hello.o
$ gcc -c code.c -o code.o

$ ls
code.c  code.o  hello.c  hello.o

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

重点概念: 编译的过程就是将程序源代码翻译成CPU能够直接运行的机器代码。.o文件被称作目标文件,是ELF格式的二进制文件。

4.2 ELF文件类型

ELF(Executable and Linkable Format)是Linux下可执行文件的格式,有四种类型:

类型 说明 示例
可重定位文件 包含代码和数据,可与其他目标文件链接 .o文件
可执行文件 可以直接执行的程序 a.out
共享目标文件 动态链接库 .so文件
内核转储 进程执行上下文的转储 core dump文件

4.3 ELF文件结构

重点: 一个ELF文件由四部分组成:

常见Section说明:

节名 说明
.text 代码节,保存机器指令
.data 数据节,已初始化的全局变量和静态变量
.bss 未初始化的全局变量和静态变量(不占磁盘空间)
.rodata 只读数据,如字符串常量
.symtab 符号表,函数名、变量名和代码的对应关系
.got 全局偏移表,用于动态链接
.plt 过程链接表,用于延迟绑定
bash 复制代码
# 查看目标文件的节信息
$ readelf -S hello.o
There are 13 section headers, starting at offset 0x728:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000025  0000000000000000  AX       0     0     1
  [ 2] .data             PROGBITS         0000000000000000  00000065
       0000000000000000  0000000000000000  WA       0     0     1
  [ 3] .bss              NOBITS           0000000000000000  00000065
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .rodata           PROGBITS         0000000000000000  00000065
       000000000000000e  0000000000000000   A       0     0     1
  [ 5] .symtab           SYMTAB           0000000000000000  00000200
       00000000000000f0  0000000000000018           6     8     8
  [ 6] .strtab           STRTAB           0000000000000000  000002f0
       0000000000000039  0000000000000000           0     0     1
  ...
bash 复制代码
# 使用size命令查看目标文件各段大小
$ size hello.o
   text    data     bss     dec     hex filename
    333       0       0     333     14d hello.o

4.4 ELF Header详解

bash 复制代码
$ readelf -h hello.o
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00  ← ELF魔数
  Class:                             ELF64                    ← 64位架构
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)   ← 可重定位文件
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0                      ← 目标文件入口为0
  Start of program headers:          0 (bytes into file)      ← 目标文件无程序头表
  Start of section headers:          728 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         13
  Section header string table index: 12
bash 复制代码
# 查看可执行文件的ELF Header
$ gcc hello.o code.o -o main.exe
$ readelf -h main.exe
ELF Header:
  Type:                              DYN (Shared object file)  ← 可执行文件也是ELF
  Entry point address:               0x1060                    ← 有实际入口地址
  Start of program headers:          64 (bytes into file)      ← 有程序头表
  Number of program headers:         13
  Number of section headers:         31

4.5 为什么要将Section合并为Segment

重要原理: Section合并的主要原因是减少页面碎片,提高内存使用效率

举例说明:

  • 假设操作系统内存页大小为4096字节(4KB,内存管理的基本单位)
  • 如果.text段为4097字节,.init段为512字节
    • 不合并:需要3个页面(.text占2个,.init占1个)
    • 合并后:只需2个页面,节省1个页面

两个视图的对比:

视图类型 对应结构 作用
链接视图 Section Header Table 链接时使用,按功能模块划分
执行视图 Program Header Table 加载时使用,按访问权限合并
bash 复制代码
# 查看执行视图(Program Headers)
$ readelf -l main.exe
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x0000000000000744 0x0000000000000744  R E    200000   ← 只读可执行段
  LOAD           0x0000000000000e10 0x0000000000600e10 0x0000000000600e10
                 0x0000000000000218 0x0000000000000220  RW     200000   ← 可读写段

 Section to Segment mapping:
  Segment Sections...
   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

关键理解:

  • 链接视图(节头表):告诉链接器如何组织各个节
  • 执行视图(程序头表):告诉操作系统如何加载程序到内存
  • 加载时将相同权限的Section合并成Segment(如只读可执行的合并、可读写的合并)

5. 理解链接与加载

5.1 静态链接过程

核心概念: 无论是自己的.o,还是静态库中的.o,本质都是把.o文件进行连接的过程。

bash 复制代码
# 查看目标文件的反汇编
$ objdump -d code.o
code.o:     file format elf64-x86-64

Disassembly of section .text:
0000000000000000 <run>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # b <run+0xb>
   b:   e8 00 00 00 00          callq  10 <run+0x10>         # puts的地址暂为0
  10:   90                      nop
  11:   5d                      pop    %rbp
  12:   c3                      retq
bash 复制代码
$ objdump -d hello.o
hello.o:     file format elf64-x86-64

Disassembly of section .text:
0000000000000000 <main>:
   0:   55                      push   %rbp
   1:   48 89 e5                mov    %rsp,%rbp
   4:   48 8d 3d 00 00 00 00    lea    0x0(%rip),%rdi        # 字符串地址暂为0
   b:   e8 00 00 00 00          callq  10 <main+0x10>       # printf的地址暂为0
  10:   b8 00 00 00 00          mov    $0x0,%eax
  15:   e8 00 00 00 00          callq  1a <main+0x1a>       # run的地址暂为0
  1a:   b8 00 00 00 00          mov    $0x0,%eax
  1f:   5d                      pop    %rbp
  20:   c3                      retq

关键发现: 在目标文件.o中,call指令后面的跳转地址都被设为00 00 00 00

为什么地址都是0?

  • 编译hello.c时,编译器完全不知道printfrun函数在哪里
  • 这些外部符号的地址只能在链接时确定
  • 目标文件中有一个重定位表,记录了需要修正的地址位置

5.2 符号表与重定位

bash 复制代码
# 查看目标文件的符号表
$ readelf -s code.o
Symbol table '.symtab' contains 13 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
    10: 0000000000000000    23 FUNC    GLOBAL DEFAULT    1 run       ← 在本文件定义
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND puts      ← 未定义(UND)!

$ readelf -s hello.o
Symbol table '.symtab' contains 14 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
    10: 0000000000000000    37 FUNC    GLOBAL DEFAULT    1 main      ← 在本文件定义
    11: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_
    12: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND puts      ← 未定义(UND)!
    13: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND run       ← 未定义(UND)!

UND(Undefined):表示该符号在本目标文件中找不到定义,需要链接器在其他目标文件或库中查找。

5.3 链接后的变化

bash 复制代码
# 链接后查看可执行文件
$ gcc hello.o code.o -o main.exe

# 查看符号表 - run函数有了明确的地址
$ readelf -s main.exe | grep run
    52: 0000000000001149    23 FUNC    GLOBAL DEFAULT   16 run
    # 地址: 0x1149, 在第16个section中
    63: 0000000000001160    37 FUNC    GLOBAL DEFAULT   16 main
    # 地址: 0x1160, 在第16个section中
bash 复制代码
# 反汇编可执行文件 - call指令有了正确的地址
$ objdump -d main.exe

0000000000001149 <run>:
    1149:   f3 0f 1e fa             endbr64
    114d:   55                      push   %rbp
    114e:   48 89 e5                mov    %rsp,%rbp
    1151:   48 8d 3d ac 0e 00 00    lea    0xeac(%rip),%rdi        # 2004
    1158:   e8 f3 fe ff ff          callq  1050 <puts@plt>         # 正确地址!
    115d:   90                      nop
    115e:   5d                      pop    %rbp
    115f:   c3                      retq

0000000000001160 <main>:
    1160:   f3 0f 1e fa             endbr64
    1164:   55                      push   %rbp
    1165:   48 89 e5                mov    %rsp,%rbp
    1168:   48 8d 3d a0 0e 00 00    lea    0xea0(%rip),%rdi        # 200f
    116f:   e8 dc fe ff ff          callq  1050 <puts@plt>         # 正确地址!
    1174:   b8 00 00 00 00          mov    $0x0,%eax
    1179:   e8 cb ff ff ff          callq  1149 <run>              # 正确地址!
    117e:   b8 00 00 00 00          mov    $0x0,%eax
    1183:   5d                      pop    %rbp
    1184:   c3                      retq

链接过程总结:

  1. 多个.o的代码段合并到一起,统一编址
  2. 根据重定位表,修正所有未确定的函数地址
  3. 静态库的链接同理,将库中的.o合并到可执行文件

图片2:静态链接过程 -.o文件合并与地址重定位


6. ELF加载与进程地址空间

6.1 虚拟地址的概念

重要概念: 一个ELF程序,在没有被加载到内存的时候,就已经有了地址!这个地址是虚拟地址(逻辑地址)。

当代计算机采用"平坦模式"工作,ELF要求对自己的代码和数据进行统一编址。实际上,虚拟地址 = 起始地址 + 偏移量。对于目标文件,起始地址被认为是0。

bash 复制代码
# objdump反汇编显示的就是虚拟地址
$ objdump -d main.exe
0000000000001149 <run>:    # 0x1149就是run函数的虚拟地址(偏移量)
    1149:   ...
0000000000001160 <main>:   # 0x1160就是main函数的虚拟地址(偏移量)
    1160:   ...

6.2 进程虚拟地址空间结构

当可执行程序被加载到内存时,操作系统会为其创建进程地址空间,由task_struct中的mm_struct管理。mm_struct包含一个指向vm_area_struct链表头的指针mmap,每个vm_area_struct描述一段虚拟内存区域(VMA),记录了起始地址、结束地址、访问权限等。

复制代码
task_struct -> mm_struct -> vm_area_struct链表:
  VMA for 栈区: vm_start ~ vm_end (可读写)
  VMA for 堆区: vm_start ~ vm_end (可读写)
  VMA for 数据段: vm_start ~ vm_end (可读写)
  VMA for 代码段: vm_start ~ vm_end (只读可执行)
  VMA for 共享库区域: vm_start ~ vm_end (只读可执行)
  VMA for BSS段: vm_start ~ vm_end (可读写)

图片3:进程地址空间与vm_area_struct结构

进程虚拟地址空间整体布局:

关键理解:

  • 虚拟地址机制不仅需要OS支持,编译器也需要支持
  • 进程mm_structvm_area_struct在创建时,从ELF各个Segment获取初始化数据
  • 每个Segment有起始地址和长度,用于填充内核结构和页表

6.3 程序入口地址与加载执行

bash 复制代码
$ readelf -h main.exe | grep Entry
  Entry point address:               0x1060        # 程序入口地址!

注意: 程序的入口并不是main函数!在C/C++程序中,入口点是_start,它由C运行时库提供。

下面这张图展示了程序从磁盘到内存执行的核心流程:

图片4:程序加载与入口点执行

程序启动过程:

  1. _start → 初始化堆栈、数据段
  2. _start → 调用动态链接器解析动态库依赖
  3. 动态链接器 → 加载依赖的动态库,完成符号重定位
  4. _start → 调用__libc_start_main
  5. __libc_start_main → 调用main函数
  6. main返回 → __libc_start_main处理返回值 → 调用exit

7. 动态链接与动态库加载

7.1 动态链接概述

为什么需要动态链接? 静态链接会将所有代码合并到可执行文件,导致:

  • 文件体积大
  • 多个程序包含相同代码,浪费磁盘和内存
  • 库更新需要重新编译所有程序

动态链接的优势:

  • 将共享代码提取为独立的动态库
  • 多个程序共享同一份物理内存副本
  • 库更新只需替换.so文件

7.2 进程如何看到动态库

动态库在被使用时,操作系统需要让进程"看到"它。这个过程涉及:找到磁盘上的库文件、加载到内核缓冲区、映射到进程地址空间的共享区、建立页表、获得起始虚拟地址。

图片5:进程通过映射看到动态库

简单来说:

  1. 进程根据库文件路径找到磁盘上的.so文件;
  2. 将文件内容读入内核缓冲区(即物理内存中的文件缓存);
  3. 在进程的虚拟地址空间中分配一块共享区VMA;
  4. 建立页表,将VMA的虚拟地址映射到物理内存中库的数据块;
  5. 于是进程就可以通过虚拟地址访问库中的代码和数据了。

7.3 动态库与PIC(位置无关代码)

核心原理: 动态库为了能在任意内存地址加载,使用-fPIC编译选项,生成位置无关代码

bash 复制代码
# 查看动态库的反汇编
$ objdump -d libmystdio.so

0000000000001129 <mfopen>:
    1129:   endbr64
    112d:   push   %rbp
    112e:   mov    %rsp,%rbp
    # 动态库中的函数使用相对寻址,不依赖绝对地址

7.4 动态库加载流程

7.5 GOT(全局偏移表)与PLT(过程链接表)

核心问题: 代码段(.text)在内存中是只读的,如何修改函数跳转地址?

答案: 使用GOT表!GOT表存储在可读写的数据段(.data区域),动态链接时修改GOT表中的地址即可。

GOT的工作原理:
库映射后的地址计算

当动态库被映射到进程地址空间后,动态链接器会获得该库在进程中的起始虚拟地址(如libc.so映射后起始地址为0x44332211)。库中每个函数的偏移量在编译时就已经确定(例如puts函数在库中的偏移量为0x112233),则puts在进程中的实际虚拟地址为:

起始地址 + 函数偏移量 = 0x44332211 + 0x112233

动态链接器会将计算出的实际地址填入GOT表对应的条目中。这样,当程序调用puts时,就会通过GOT表间接跳转到正确的位置。

图片6:静态库加载与GOT地址填充

PLT(延迟绑定/懒加载):

优化机制: 与其在程序启动时解析所有函数,不如在函数第一次被调用时才解析(延迟绑定)。

第一次调用流程:

图片7:延迟绑定第一次调用

流程描述:

  1. 程序调用func@plt
  2. PLT条目跳转到GOT表对应项
  3. GOT表初始保存的是桩代码(stub)的地址
  4. 桩代码调用动态链接器解析真实函数地址
  5. 动态链接器更新GOT表为该真实地址
  6. 再次跳转并执行真实函数

后续调用流程:

图片8:延迟绑定后续调用

之后程序再次调用func@plt时,GOT表已存储真实地址,直接跳转,避免了重复解析的开销。

bash 复制代码
# 查看PLT和GOT节
$ readelf -S main.exe | grep -E "got|plt"
  [13] .plt              PROGBITS         0000000000001020  00001020
  [14] .plt.got          PROGBITS         0000000000001040  00001040
  [15] .plt.sec          PROGBITS         0000000000001050  00001050
  [24] .got              PROGBITS         0000000000003fb8  00002fb8
  [25] .got.plt          PROGBITS         0000000000003fd0  00002fd0
bash 复制代码
# 查看PLT反汇编
$ objdump -d main.exe

0000000000001050 <puts@plt>:
    1050:   endbr64
    1054:   bnd jmpq *0x2f75(%rip)    # 3fd0 <puts@GLIBC_2.2.5>
    105b:   nopl   0x0(%rax,%rax,1)

总结:

  • PIC(位置无关代码)= 相对编址 + GOT表
  • GOT表使代码段可以共享,每个进程维护自己的GOT表
  • PLT实现延迟绑定,只在首次调用时解析地址

7.6 库间依赖

动态库之间也存在依赖关系!每个动态库都有自己的GOT表,用于解析对其他库函数的引用。

复制代码
可执行程序 ──依赖──→ libmystdio.so ──依赖──→ libc.so
    GOT表              GOT表              GOT表
    ┌────┐            ┌────┐            ┌────┐
    │....│            │....│            │....│
    └────┘            └────┘            └────┘

图片9:库间依赖与内存布局

这也就是为什么动态库也都采用ELF格式 ------ 统一的格式使得库间调用也能通过GOT机制实现地址无关。当liba.so需要调用libb.so中的函数时,liba.so的GOT表会存储libb.so中该函数的实际地址,由动态链接器在加载时填充或首次调用时更新。


8. 总结

8.1 静态链接 vs 动态链接

特性 静态链接 动态链接
链接时机 编译时 运行时
库代码位置 合并到可执行文件 独立文件,运行时加载
可执行文件大小 较大 较小
内存效率 每个程序一份拷贝 多个程序共享一份拷贝
部署复杂度 简单(一个文件) 需管理库路径
更新维护 需重新编译 更换.so即可

8.2 链接过程的三个层次

复制代码
1. 编译时重定位(静态链接)
   → 合并.o文件,修正函数地址
   → 生成独立可执行文件

2. 加载时重定位(动态链接)
   → 加载动态库到内存
   → 建立虚拟地址映射

3. 运行时重定位(延迟绑定PLT)
   → 首次调用时解析函数地址
   → 更新GOT表

8.3 关键命令速查

命令 功能
ar -rc libxxx.a xxx.o 创建静态库
gcc -shared -fPIC -o libxxx.so xxx.c 创建动态库
gcc main.c -L. -lxxx 链接库
gcc main.c -I./include -L./lib -lxxx 指定路径链接
ldd a.out 查看动态库依赖
readelf -h a.out 查看ELF头
readelf -S a.out 查看节头表
readelf -l a.out 查看程序头表
readelf -s a.out 查看符号表
objdump -d a.out 反汇编
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:. 设置库搜索路径

写在最后

理解库的制作与原理,不仅是面试的常见考点,更是日常开发中排查链接错误、设计模块化架构的基础。从静态库到动态库,从编译链接到加载执行,每一个环节都体现了操作系统和编译器设计者的智慧。

希望本文能帮助你在遇到"undefined reference"、"cannot open shared object file"这些问题时,能够快速定位原因并找到解决方案。


相关推荐
用户2367829801681 小时前
从 chmod 755 说起:Unix 文件权限到底是怎么算的?
linux
码云数智-大飞1 小时前
本地部署大模型:隐私安全与多元优势一站式解读
运维·网络·人工智能
Strugglingler2 小时前
【systemctl 学习总结】
linux·systemd·systemctl·journalctl·unit file
Harvy_没救了3 小时前
【网络部署】 Win11 + VMware CentOS8 + Nginx 文件共享服务 Wiki
运维·网络·nginx
春风有信3 小时前
【2026.05.01】Windows10安装Docker Desktop 4.71.0.0步骤及问题解决
运维·docker·容器
嵌入式×边缘AI:打怪升级日志3 小时前
100ASK-T113 Pro 开发板 Bootloader 完全开发指南
linux·ubuntu·bootloader
lzhdim3 小时前
SQL 入门 12:SQL 视图:创建、修改与可更新视图
java·大数据·服务器·数据库·sql
2401_873479404 小时前
断网时如何实时判断IP归属?嵌入本地离线库,保障风控不中断
运维·服务器·网络
守城小轩4 小时前
基于Chrome140的Yahoo自动化(关键词浏览)——需求分析&环境搭建(一)
运维·自动化·chrome devtools·浏览器自动化·指纹浏览器·浏览器开发