【Linux:文件】库的制作与原理进阶

🎬 个人主页艾莉丝努力练剑
专栏传送门 :《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录
Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享

⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平


🎬 艾莉丝的简介:


文章目录

  • 前言
  • [1 ~> 什么是库](#1 ~> 什么是库)
  • [2 ~> 静态库](#2 ~> 静态库)
    • [2.1 静态库生成](#2.1 静态库生成)
    • [2.2 静态库使用](#2.2 静态库使用)
  • [3 ~> 动态库](#3 ~> 动态库)
    • [3.1 动态库生成](#3.1 动态库生成)
    • [3.2 动态库使用](#3.2 动态库使用)
    • [3.3 动态库运行搜索路径](#3.3 动态库运行搜索路径)
  • [4 ~> 使用外部库](#4 ~> 使用外部库)
    • [4.1 安装ncurses库](#4.1 安装ncurses库)
    • [4.2 ncurses示例:进度条程序](#4.2 ncurses示例:进度条程序)
  • [5 ~> 目标文件](#5 ~> 目标文件)
  • [6 ~> ELF文件](#6 ~> ELF文件)
    • [6.1 ELF文件组成](#6.1 ELF文件组成)
    • [6.2 常见节介绍](#6.2 常见节介绍)
  • [7 ~> ELF从形成到加载轮廓](#7 ~> ELF从形成到加载轮廓)
    • [7.1 ELF形成可执行文件](#7.1 ELF形成可执行文件)
    • [7.2 ELF可执行文件加载](#7.2 ELF可执行文件加载)
  • [8 ~> 理解连接与加载](#8 ~> 理解连接与加载)
    • [8.1 静态链接](#8.1 静态链接)
    • [8.2 ELF加载与进程地址空间](#8.2 ELF加载与进程地址空间)
      • [8.2.1 虚拟地址](#8.2.1 虚拟地址)
      • [8.2.2 进程地址空间初始化](#8.2.2 进程地址空间初始化)
    • [8.3 动态链接与动态库加载](#8.3 动态链接与动态库加载)
      • [8.3.1 程序执行流程](#8.3.1 程序执行流程)
      • [8.3.2 位置无关码(PIC)与全局偏移表(GOT)](#8.3.2 位置无关码(PIC)与全局偏移表(GOT))
      • [8.3.3 过程链接表(PLT)与延迟绑定](#8.3.3 过程链接表(PLT)与延迟绑定)
  • [9 ~> 总结](#9 ~> 总结)
  • 结尾


前言

一、整体知识思维导图

bash 复制代码
Linux库制作与原理
├─ 1. 什么是库
│  ├─ 库的本质:可执行二进制代码,可被OS载入内存执行
│  ├─ 库的分类
│  │  ├─ 静态库:Linux(.a)、Windows(.lib)
│  │  └─ 动态库:Linux(.so)、Windows(.dll)
│  └─ 系统库示例:libc.so、libstdc++.so及其静态版本
├─ 2. 静态库
│  ├─ 静态库原理:编译链接时将库代码直接嵌入可执行文件
│  ├─ 静态库生成
│  │  ├─ 编译源文件为.o目标文件
│  │  └─ ar工具归档:ar -rc libxxx.a xxx.o yyy.o
│  └─ 静态库使用
│     ├─ 编译参数:-I(头文件路径)、-L(库路径)、-l(库名)
│     └─ 三种使用场景:系统路径、同目录、独立路径
├─ 3. 动态库
│  ├─ 动态库原理:运行时才链接库代码,多进程共享
│  ├─ 动态库生成
│  │  ├─ 编译参数:-fPIC(位置无关码)
│  │  └─ 链接参数:-shared(生成共享库格式)
│  ├─ 动态库使用:编译参数同静态库
│  └─ 动态库运行搜索路径
│     ├─ 问题:编译成功但运行时找不到库
│     └─ 四种解决方案
│        ├─ 拷贝.so到系统共享库路径
│        ├─ 建立系统路径软连接
│        ├─ 设置环境变量LD_LIBRARY_PATH
│        └─ 配置/etc/ld.so.conf.d/ + ldconfig更新
├─ 4. 使用外部库
│  └─ ncurses图形库示例
│     ├─ 安装方法:CentOS(yum)、Ubuntu(apt)
│     └─ 进度条示例代码
├─ 5. 目标文件
│  ├─ 编译与链接流程:源文件→.o目标文件→链接库→可执行文件
│  └─ 目标文件本质:ELF格式的可重定位文件
├─ 6. ELF文件
│  ├─ ELF文件的四种类型
│  │  ├─ 可重定位文件(.o)
│  │  ├─ 可执行文件
│  │  ├─ 共享目标文件(.so)
│  │  └─ 内核转储文件(core dumps)
│  ├─ ELF文件组成
│  │  ├─ ELF头:描述文件基本特性,定位其他部分
│  │  ├─ 程序头表:描述段(segments)信息,用于加载
│  │  ├─ 节头表:描述节(sections)信息,用于链接
│  │  └─ 节:存储代码、数据等具体内容
│  └─ 常见节介绍
│     ├─ .text:代码节,存储机器指令
│     ├─ .data:数据节,存储已初始化全局/静态变量
│     ├─ .bss:未初始化全局/静态变量预留位置
│     ├─ .rodata:只读数据节
│     ├─ .symtab:符号表
│     └─ .got/.plt:全局偏移表/过程链接表
├─ 7. ELF从形成到加载轮廓
│  ├─ ELF形成可执行文件
│  │  └─ 多个.o文件的节合并:.text合并、.data合并等
│  └─ ELF可执行文件加载
│     ├─ 节合并为段(segment):相同属性的节合并
│     ├─ 合并目的:减少页面碎片,提高内存效率
│     └─ ELF的两个视图
│        ├─ 链接视图:对应节头表,链接时使用
│        └─ 执行视图:对应程序头表,加载时使用
└─ 8. 理解连接与加载
   ├─ 静态链接
   │  ├─ 本质:多个.o文件(包括库中的.o)的合并与地址重定位
   │  ├─ 过程:符号解析→重定位表修正函数地址
   │  └─ 特点:可执行文件独立,体积大,无运行时依赖
   ├─ ELF加载与进程地址空间
   │  ├─ 虚拟地址:程序编译时已完成统一编址
   │  └─ 进程地址空间初始化:从ELF的段信息获取
   └─ 动态链接与动态库加载
      ├─ 动态链接本质:链接过程推迟到程序运行时
      ├─ 程序执行流程:_start→动态链接器→__libc_start_main→main
      ├─ 位置无关码(PIC):相对编址+GOT表
      ├─ 全局偏移表(GOT):存储函数/变量的实际地址,可读写
      ├─ 过程链接表(PLT):延迟绑定,第一次调用时解析地址
      └─ 动态库共享原理:物理内存一份,多个进程虚拟地址映射

二、导入语

在软件开发中,我们几乎不会从零开始编写所有代码,而是大量复用已经写好的、经过验证的成熟代码,这些代码的集合就是"库"。比如我们每天都在使用的printfscanf函数,就来自C标准库libc.so;C++中的std::coutstd::string则来自C++标准库libstdc++.so

库的出现极大地提高了软件开发效率,降低了代码冗余。但很多开发者只知道如何调用库函数,却不了解库的制作原理、链接过程以及程序运行时库是如何被加载到内存中的。这些底层知识不仅能帮助我们解决编译链接时遇到的各种"undefined reference"、"cannot open shared object file"等错误,还能让我们更深入地理解操作系统的内存管理和程序执行机制。

本文将从库的基本概念入手,详细讲解Linux系统下静态库和动态库的制作与使用方法,深入剖析ELF可执行文件的格式结构,最后完整梳理程序从编译链接到加载运行的整个过程,揭开库背后的神秘面纱。


1 ~> 什么是库

库是写好的、成熟的、可以复用的代码集合。本质上来说,库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。

库主要分为两种类型,在不同操作系统下有不同的后缀名:

  • 静态库 :Linux系统下后缀为.a,Windows系统下后缀为.lib
  • 动态库 :Linux系统下后缀为.so(shared object),Windows系统下后缀为.dll(dynamic link library)

在Linux系统中,系统自带的C和C++标准库同时提供了动态和静态版本:

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

# Ubuntu系统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 -> ../../../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

为了后续讲解库的制作,我们先准备两个简单的自定义模块:一个模拟C标准库的stdio功能,一个实现简单的字符串长度函数。

my_stdio.h头文件

c 复制代码
#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;
    // TODO
};

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实现文件

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头文件

c 复制代码
#pragma once

int my_strlen(const char *s);

my_string.c实现文件

c 复制代码
#include "my_string.h"
int my_strlen(const char *s)
{
    const char *end = s;
    while(*end != '\0')end++;
    return end - s;
}

2 ~> 静态库

静态库(.a)的工作原理是:程序在编译链接的时候把库的代码完整地链接到可执行文件中。程序运行的时候将不再需要静态库,因为所有用到的库代码已经被嵌入到可执行文件内部了。

一个可执行程序可能同时用到静态库和动态库,GCC编译器默认优先使用动态链接,只有在找不到对应动态库的时候才会采用同名的静态库。我们也可以使用-static选项强制编译器使用静态链接。

2.1 静态库生成

静态库的生成分为两个步骤:

  1. 将所有源文件编译成目标文件(.o)
  2. 使用ar归档工具将多个目标文件打包成静态库文件

我们可以编写一个Makefile来自动化这个过程:

bash 复制代码
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
  • t:列出静态库中的文件
  • v:verbose,显示详细信息

2.2 静态库使用

使用静态库时,我们需要在编译可执行文件时告诉编译器三个信息:头文件的搜索路径、库文件的搜索路径以及要链接的库名。

首先编写一个测试程序main.c:

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:头文件和库文件已安装到系统路径下 如果我们将头文件拷贝到/usr/include目录,库文件拷贝到/usr/lib/lib等系统默认搜索路径,那么编译时只需要指定库名即可:

bash 复制代码
$ gcc main.c -lmystdio

场景2:头文件和库文件与源文件在同一个目录下 此时需要用-L.指定库文件在当前目录:

bash 复制代码
$ gcc main.c -L. -lmystdio

场景3:头文件和库文件有自己的独立路径 这是最通用的场景,需要同时指定头文件路径(-I)和库文件路径(-L):

bash 复制代码
$ gcc main.c -I./stdc/include -L./stdc/lib -lmystdio

编译参数说明:

  • -I:指定头文件的搜索路径
  • -L:指定库文件的搜索路径
  • -l:指定要链接的库名。注意库名的规则是:去掉前缀lib,去掉后缀.so.a。例如libmystdio.a的库名是mystdiolibc.so的库名是c

静态库的一个重要特点是:当可执行文件生成后,即使删除静态库,程序仍然可以正常运行,因为所有用到的库代码已经被嵌入到可执行文件中了。


3 ~> 动态库

动态库(.so)的工作原理与静态库完全不同:程序在运行的时候才去链接动态库的代码,多个程序可以共享使用同一份库的代码。

一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)

动态库的优势非常明显:

  • 可执行文件体积更小,节省磁盘空间
  • 多个进程可以共享内存中的同一份动态库代码,节省内存
  • 库的更新和维护更方便,只需要替换.so文件,不需要重新编译所有使用该库的程序

3.1 动态库生成

动态库的生成也分为两个步骤,但编译和链接的参数与静态库不同:

  1. 将所有源文件编译成目标文件(.o),必须加上-fPIC参数生成位置无关码
  2. 使用-shared参数将多个目标文件链接成动态库文件

对应的Makefile如下:

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

参数说明:

  • -fPIC:产生位置无关码(Position Independent Code),这是动态库必须的参数,使得动态库可以被加载到任意内存地址
  • -shared:表示生成共享库格式

3.2 动态库使用

动态库的编译参数与静态库完全相同,同样使用-I-L-l参数:

bash 复制代码
# 场景1:系统路径
$ gcc main.c -lmystdio

# 场景2:同目录
$ gcc main.c -L. -lmystdio

# 场景3:独立路径
$ gcc main.c -I./stdc/include -L./stdc/lib -lmystdio

我们可以使用ldd命令查看可执行文件或动态库依赖的其他动态库:

bash 复制代码
$ ldd libmystdio.so 
linux-vdso.so.1 => (0x00007fffacbbf000)
libc.so.6 => /lib64/libc.so.6 (0x00007f8917335000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8917905000)

3.3 动态库运行搜索路径

动态库的使用有一个常见的"陷阱":编译成功了,但运行时却提示找不到库文件。例如:

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

ldd命令查看可执行文件的依赖,可以看到libmystdio.so显示为not found

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)

这是因为编译器在编译时知道库的位置,但操作系统在运行程序时并不知道去哪里找这个动态库。有四种解决方案:

  1. 拷贝.so文件到系统共享库路径下 系统默认的共享库路径包括/usr/lib/usr/local/lib/lib64等:
bash 复制代码
$ sudo cp libmystdio.so /usr/lib
  1. 向系统共享库路径下建立同名软连接 这种方式不需要拷贝文件,只需要建立一个软链接:
bash 复制代码
$ sudo ln -s /path/to/your/libmystdio.so /usr/lib/libmystdio.so
  1. 更改环境变量LD_LIBRARY_PATH 这是临时解决方案,只对当前终端会话有效:
bash 复制代码
$ export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
$ ./a.out  # 现在可以正常运行了
  1. ldconfig方案 这是永久解决方案,需要修改系统配置:
    1. /etc/ld.so.conf.d/目录下创建一个配置文件,例如bit.conf
    2. 在文件中写入动态库所在的绝对路径
    3. 执行ldconfig命令更新系统的动态库缓存
bash 复制代码
$ sudo echo "/root/tools/linux" > /etc/ld.so.conf.d/bit.conf
$ sudo ldconfig

4 ~> 使用外部库

系统中其实有很多现成的库可以使用,它们通常由一组互相关联的用来完成某项常见工作的函数构成。这里我们以ncurses图形库为例,演示如何使用外部库。

ncurses是一个用于在终端上创建图形界面的库,很多经典的终端程序如vim、top、htop都是基于ncurses开发的。

4.1 安装ncurses库

bash 复制代码
# CentOS系统
$ sudo yum install -y ncurses-devel

# Ubuntu系统
$ sudo apt install -y libncurses-dev

4.2 ncurses示例:进度条程序

下面是一个使用ncurses库实现的彩色进度条程序:

c 复制代码
#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 -o progress
$ ./progress

5 ~> 目标文件

编译和链接是生成可执行程序的两个核心步骤。在Windows下,IDE已经将这两个步骤完美封装,我们一般都是一键构建。但一旦遇到链接相关的错误,很多人就束手无策了。深入理解编译和链接的过程,能帮助我们更好地理解动静态库的使用原理。

编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。例如,我们有两个源文件:

c 复制代码
// 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命令分别编译这两个源文件:

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

编译之后会生成两个扩展名为.o的文件,它们被称作目标文件。目标文件的一个重要优势是:如果我们只修改了一个源文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程。

目标文件是一个二进制文件,它的格式是ELF(Executable and Linkable Format) ,是对二进制代码的一种封装。我们可以使用file命令来验证这一点:

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

6 ~> ELF文件

要理解编译链接的细节,我们不得不了解ELF文件格式。实际上,以下四种文件都是ELF文件:

  1. 可重定位文件(Relocatable File) :即.o目标文件,包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据
  2. 可执行文件(Executable File):即我们可以直接运行的程序
  3. 共享目标文件(Shared Object File) :即.so动态库文件
  4. 内核转储文件(core dumps):存放当前进程的执行上下文,用于程序崩溃时的调试

6.1 ELF文件组成

一个ELF文件由以下四部分组成:

  1. ELF头(ELF header):描述文件的主要特性,位于文件的开始位置,它的主要目的是定位文件的其他部分
  2. 程序头表(Program header table):列举了所有有效的段(segments)和它们的属性,告诉操作系统如何加载文件到内存
  3. 节头表(Section header table):包含对节(sections)的描述,用于链接过程
  4. 节(Section):ELF文件中的基本组成单位,包含了特定类型的数据,如代码、数据、符号表等

6.2 常见节介绍

ELF文件的各种信息和数据都存储在不同的节中,最常见的节有:

  • .text节:代码节,用于保存机器指令,是程序的主要执行部分,具有可读和可执行权限
  • .data节:数据节,保存已初始化的全局变量和局部静态变量,具有可读和可写权限
  • .bss节:为未初始化的全局变量和局部静态变量预留位置,在程序加载时会被清零
  • .rodata节:只读数据节,保存字符串常量等只读数据
  • .symtab节:符号表,存储源码中函数名、变量名和代码地址的对应关系
  • .got节和.plt节:全局偏移表和过程链接表,用于动态链接时的函数地址解析

我们可以使用size命令查看一个可执行文件的主要节的大小:

bash 复制代码
$ size code
   text    data     bss     dec     hex filename
   3312     636       4    3952     f70 code

7 ~> ELF从形成到加载轮廓

7.1 ELF形成可执行文件

生成可执行文件的过程,本质上是将多个目标文件(.o)和库文件进行链接的过程。链接器会将多个目标文件中相同类型的节进行合并:

  • 所有目标文件的.text节合并成一个大的.text
  • 所有目标文件的.data节合并成一个大的.data
  • 其他节也按照同样的规则进行合并

合并完成后,链接器会进行符号解析和地址重定位,修正所有未定义的函数和变量的地址,最终生成一个完整的可执行ELF文件。

7.2 ELF可执行文件加载

当我们运行一个可执行文件时,操作系统会将其加载到内存中。在加载过程中,操作系统并不是将每个节单独加载,而是将具有相同属性的节合并成一个段(segment) 进行加载。

合并的原则是:将具有相同访问权限(可读、可写、可执行)的节合并到同一个段中。例如:

  • .text.rodata等只读节会合并成一个只读段
  • .data.bss.got等可写节会合并成一个可读写段

这样做的主要目的是:

  1. 减少页面碎片,提高内存使用效率 :如果不进行合并,假设页面大小为4096字节,.text部分为4097字节,.init部分为512字节,那么它们将占用3个页面;而合并后,它们只需2个页面
  2. 实现访问权限控制:操作系统可以为不同的段设置不同的访问权限,例如代码段设置为只读和可执行,数据段设置为可读和可写,防止程序被恶意修改

ELF文件提供了两个不同的视图来理解其结构:

  • 链接视图(Linking view):对应节头表,文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图
  • 执行视图(Execution view):对应程序头表,告诉操作系统如何加载可执行文件,完成进程内存的初始化

我们可以使用readelf命令来查看ELF文件的节头表和程序头表:

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

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

8 ~> 理解连接与加载

8.1 静态链接

无论是我们自己编写的.o文件,还是静态库中的.o文件,静态链接的本质都是把这些.o文件进行合并和地址重定位的过程。

我们可以通过反汇编来观察静态链接的过程。首先查看编译后的目标文件:

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

可以看到,main函数中调用printfrun函数的callq指令后面的地址都是00 00 00 00。这是因为在编译hello.c的时候,编译器完全不知道printfrun函数的存在,不知道它们位于内存的哪个位置,因此只能将这两个函数的跳转地址暂时设为0。

这些地址会在链接的时候 被修正。为了让链接器能够正确定位到这些需要被修正的地址,在目标文件中还存在一个重定位表,这张表记录了所有需要被重定位的地址。

我们可以通过查看符号表来验证这一点:

bash 复制代码
$ readelf -s hello.o 

Symbol table '.symtab' contains 14 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS hello.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 
     3: 0000000000000000     0 SECTION LOCAL  DEFAULT    3 
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    4 
     5: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 
     6: 0000000000000000     0 SECTION LOCAL  DEFAULT    7 
     7: 0000000000000000     0 SECTION LOCAL  DEFAULT    8 
     8: 0000000000000000     0 SECTION LOCAL  DEFAULT    9 
     9: 0000000000000000     0 SECTION LOCAL  DEFAULT    6 
    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
    13: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND run

可以看到,puts(printf的底层实现)和run符号的Ndx字段都是UND,表示"undefined",即这两个符号在本目标文件中未定义。

当我们将hello.ocode.o链接成可执行文件后,再查看反汇编结果:

bash 复制代码
$ gcc hello.o code.o -o main.exe
$ objdump -d main.exe 

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 <_IO_stdin_used+0xf>
    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   

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 <_IO_stdin_used+0x4>
    1158:        e8 f3 fe ff ff               callq  1050 <puts@plt>
    115d:        90                           nop
    115e:        5d                           pop    %rbp
    115f:        c3                           retq   

可以看到,原来的callq 0已经被修正为具体的函数地址:

  • 调用run函数的指令变成了callq 1149 <run>1149就是run函数在可执行文件中的地址
  • 调用puts函数的指令变成了callq 1050 <puts@plt>,这是动态链接的入口,我们后面会详细讲解

静态链接的整个过程可以总结为:

  1. 符号解析:找到所有未定义符号的定义位置
  2. 节合并:将多个目标文件的相同节合并
  3. 地址重定位:根据重定位表修正所有未定义的函数和变量的地址

8.2 ELF加载与进程地址空间

8.2.1 虚拟地址

一个常见的疑问是:一个ELF程序在没有被加载到内存的时候,有没有地址?

答案是:。当代计算机工作的时候,都采用"平坦模式"进行工作,所以也要求ELF对自己的代码和数据进行统一编址。我们在反汇编结果中看到的最左侧的数字,就是ELF的虚拟地址。

也就是说,虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统一编址了。这不仅需要操作系统支持虚拟地址机制,编译器也需要支持。

8.2.2 进程地址空间初始化

当我们运行一个程序时,操作系统会为其创建一个新的进程。进程的地址空间由内核中的mm_structvm_area_struct数据结构来描述。

这些数据结构的初始化数据来自哪里呢?答案是:来自ELF文件的程序头表 。每个段(segment)有自己的起始地址和长度,操作系统根据这些信息来初始化内核结构中的[start, end]等范围数据,然后填充页表,建立虚拟地址到物理地址的映射。

8.3 动态链接与动态库加载

动态链接实际上远比静态链接要常用得多。我们可以用ldd命令查看任意一个可执行程序依赖的动态库:

bash 复制代码
$ ldd /usr/bin/ls
linux-vdso.so.1 (0x00007fffdd85f000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f42c025a000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f42c0068000)
libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f42bffd7000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f42bffd1000)
/lib64/ld-linux-x86-64.so.2 (0x00007f42c02b6000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f42bffae000)

静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,不同的软件可能都包含了相同的功能和代码,这会浪费大量的硬盘和内存空间。动态链接的出现就是为了解决这个问题。

8.3.1 程序执行流程

在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到main函数。实际上,程序的入口点是_start,这是一个由C运行时库(通常是glibc)或链接器提供的特殊函数。

_start函数会执行一系列初始化操作:

  1. 设置堆栈:为程序创建一个初始的堆栈环境
  2. 初始化数据段:将程序的数据段从初始化数据段复制到相应的内存位置,并清零未初始化的数据段
  3. 动态链接:调用动态链接器的代码来解析和加载程序所依赖的动态库,处理所有的符号解析和重定位
  4. 调用__libc_start_main:执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库等
  5. 调用main函数:将程序的执行控制权正式交给用户编写的代码
  6. 处理main函数的返回值:当main函数返回时,__libc_start_main会负责处理这个返回值,并最终调用_exit函数来终止程序

8.3.2 位置无关码(PIC)与全局偏移表(GOT)

动态库需要能够被加载到任意进程的任意内存位置,并且多个进程可以共享同一份动态库代码。为了实现这一点,动态库必须使用位置无关码(Position Independent Code, PIC)

位置无关码的核心思想是:代码中不使用绝对地址,所有的地址引用都通过相对偏移来计算。但是,当我们需要引用全局变量或者调用其他动态库中的函数时,仅仅使用相对偏移是不够的,因为这些变量和函数的地址在程序加载时才能确定。

为了解决这个问题,动态链接采用了全局偏移表(Global Offset Table, GOT) 的机制。GOT是一个位于数据段(.data)中的表,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。

因为.data区域是可读写的,所以可以在程序加载时动态修改GOT表中的内容。当动态库被加载到内存后,动态链接器会解析所有未定义的符号,并将它们的实际地址填入GOT表中。

这样,代码段中的所有对全局变量和函数的引用,都通过GOT表来间接进行。由于代码段只包含对GOT表的相对偏移引用,不需要被修改,因此可以被多个进程共享。

8.3.3 过程链接表(PLT)与延迟绑定

由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,操作系统还做了一个优化:延迟绑定(Lazy Binding),也叫过程链接表(Procedure Linkage Table, PLT)。

延迟绑定的思想是:与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候。因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。

PLT的工作原理是:

  1. GOT中的跳转地址默认会指向一段辅助代码,也被叫做桩代码(stub)
  2. 当我们第一次调用某个函数时,会执行这段桩代码,它会负责查询真正函数的跳转地址,并且去更新GOT表
  3. 当我们再次调用这个函数的时候,就会直接跳转到动态库中真正的函数实现

9 ~> 总结

本文全面讲解了Linux系统下库的制作与原理,从库的基本概念入手,深入剖析了静态库和动态库的制作、使用以及底层原理,详细介绍了ELF可执行文件的格式结构,最后完整梳理了程序从编译链接到加载运行的整个过程。

核心知识点总结

1、库的分类与本质

  1. 库是可复用的二进制代码集合,分为静态库(.a/.lib)和动态库(.so/.dll)
  2. 静态库在编译时嵌入可执行文件,动态库在运行时加载

2、静态库制作与使用

  1. 生成:gcc -c编译源文件→ar -rc归档成静态库
  2. 使用:编译时指定-I(头文件路径)、-L(库路径)、-l(库名)
  3. 特点:可执行文件独立,体积大,无运行时依赖

3、动态库制作与使用

  1. 生成:gcc -fPIC -c编译源文件→gcc -shared链接成动态库
  2. 使用:编译参数同静态库,运行时需要配置库搜索路径
  3. 运行时搜索路径解决方案:拷贝到系统路径、建立软链接、设置LD_LIBRARY_PATH、配置ld.so.conf.d
  4. 特点:可执行文件体积小,多进程共享,更新方便

4、ELF文件格式

  1. 四种类型:可重定位文件(.o)、可执行文件、共享目标文件(.so)、内核转储文件
  2. 组成:ELF头、程序头表、节头表、节
  3. 两个视图:链接视图(节头表)、执行视图(程序头表)
  4. 常见节:.text(代码)、.data(已初始化数据)、.bss(未初始化数据)、.symtab(符号表)、.got/.plt(动态链接)

5、静态链接原理

  1. 本质:多个.o文件的节合并与地址重定位
  2. 过程:符号解析→重定位表修正函数地址
  3. 特点:编译时完成所有链接工作

6、动态链接原理

  1. 本质:链接过程推迟到程序运行时
  2. 位置无关码(PIC):相对编址+GOT表,实现代码共享
  3. 全局偏移表(GOT):存储函数/变量的实际地址,可读写
  4. 过程链接表(PLT):延迟绑定,第一次调用时解析地址
  5. 共享原理:物理内存一份,多个进程虚拟地址映射

理解这些底层知识,不仅能帮助我们解决日常开发中遇到的编译链接错误,还能让我们更深入地理解操作系统的工作原理,写出更高效、更健壮的代码。


结尾

uu们,本文的内容到这里就全部结束了,艾莉丝在这里再次感谢您的阅读!

结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 艾莉丝努力练剑 C/C++ & Linux 底层探索者 | 一个正在努力练剑的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日练剑达成!" "技术之路难免有困惑,但同行的人会让前进更有方向。" |

往期回顾

【Linux:文件】库的制作与原理:动静态库

🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡 ૮₍ ˶ ˊ ᴥ ˋ˶₎ა

相关推荐
北京软秦科技有限公司10 小时前
档案复核联动文档核验,IACheck AI报告审核让资料管理体系真正闭环
人工智能
洛阳泰山10 小时前
MaxKB4j 近三月开发进展速览:从 RAG 引擎到全能 AI 工作流平台
人工智能·后端
RuiZN10 小时前
UE5 蓝图 FPS 02 Event Beginplay
c++·ue5
欧米欧10 小时前
C++进阶之AVL树
java·服务器·c++
小陶来咯10 小时前
agent × 豆包:端到端语音实时交互
网络·ai·机器人·bug·交互
战族狼魂10 小时前
Claude 大模型在真实业务场景中的落地应用指南
人工智能·chatgpt·大模型
学困昇10 小时前
Linux 信号机制详解:从 Ctrl+C 到 SIGCHLD,一文理解进程信号
linux·c语言·开发语言·人工智能·面试
Strugglingler10 小时前
Linux Device Drivers-第八章 内存分配
linux·kernel·读书笔记·内存分配
圣殿骑士-Khtangc10 小时前
构建AI Agent系统的可观测性:从“盲目信任“到“可视化治理“
人工智能