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"这些问题时,能够快速定位原因并找到解决方案。


相关推荐
大树8812 小时前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠12 小时前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质12 小时前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush412 小时前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行52012 小时前
Linux 11 动态监控指令top
linux
小宇宙Zz13 小时前
Maven依赖冲突
java·服务器·maven
Inhand陈工13 小时前
基于台达PLC与映翰通IG502的智慧水产养殖精准投喂与远程运维解决方案
运维·人工智能·物联网·阿里云·信息与通信
酣大智14 小时前
ARP代理--工作原理
运维·网络·arp·arp代理
不会C语言的男孩14 小时前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言
shushangyun_14 小时前
2026年快消品B2B系统推荐:支持终端门店订货、促销政策自动化的工具?
java·运维·网络·数据库·人工智能·spring·自动化