【Linux系统编程】库制作与原理

【Linux系统编程】库制作与原理

  • [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 库运行搜索路径)
      • [3.3.1 问题](#3.3.1 问题)
      • [3.3.2 解决方案](#3.3.2 解决方案)
  • [4. 使用外部库](#4. 使用外部库)
  • [5. 目标文件](#5. 目标文件)
  • [6. ELF文件](#6. ELF文件)
  • [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 进程间如何共享库](#8.3.2 进程间如何共享库)
      • [8.3.3 动态链接](#8.3.3 动态链接)
      • [8.3.4 总结](#8.3.4 总结)

1. 什么是库

库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。

本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:

  • 静态库:.a[Linux].lib[Windows]
  • 动态库:.so[Linux].dll[Windows]
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

//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/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 动静态库
// C
$ 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

// C++
$ ls /lib64/libstdc++.so.6 -l
lrwxrwxrwx 1 root root 19 Sep 18 20:59 /lib64/libstdc++.so.6 ->libstdc++.so.6.0.19
$ ls /usr/lib/gcc/x86_64-redhat-linux/4.8.2/libstdc++.a -l
-rw-r--r-- 1 root root 2932366 Sep 30 2020 /usr/lib/gcc/x86_64-redhat-linux/4.8.2/libstdc++.a

2. 静态库

  • 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。
  • 一个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,而我们的编译默认为动态链接库,只有在该库下找不到动态.so的时候才会采用同名静态库。我们也可以使用gcc的-static强转设置链接静态库。

2.1 静态库生成

准备工作

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

#include <stdio.h>

#define SIZE 1024

#define NON_BUFFER  1 //   1
#define LINE_BUFFER 2 //  10
#define FULL_BUFFER 4 // 100

#define MODE 0666

typedef struct _myFILE
{
    int fd;
    int flags;                                                                                                                                                                    
    int flush_mode;
    char outbuffer[SIZE];
    int pos;
    int cap;
}myFILE;

myFILE* myfopen(const char* pathname, const char* mode); // r, w, a, r+, w+, a+
int myfputs(const char* str, myFILE* fp);
void myfflush(myFILE* fp);
void myfclose(myFILE* fp);

// mystdio.c
#include "mystdio.h"                                                                                                                                                              
#include <string.h>    
#include <sys/types.h>    
#include <sys/stat.h>    
#include <fcntl.h>    
#include <stdlib.h>    
#include <unistd.h>    
    
#define TRY_FLUSH  1    
#define MUST_FLUSH 2    
    
myFILE* myfopen(const char* pathname, const char* mode) // r, w, a, r+, w+, a+    
{    
    int fd = -1;    
    int flags = 0;    
    if(strcmp(mode, "r") == 0)    
    {    
        flags = O_RDONLY;    
        fd = open(pathname, flags);    
    }    
    else if(strcmp(mode, "w") == 0)    
    {    
        flags = O_WRONLY | O_CREAT | O_TRUNC;    
        fd = open(pathname, flags, MODE);    
    }    
    else if(strcmp(mode, "a") == 0)    
    {    
        flags = O_WRONLY | O_CREAT | O_APPEND;    
        fd = open(pathname, flags, MODE);    
    }    
    else    
    {}    
    if(fd < 0)    
        return NULL;    
    myFILE* fp = (myFILE*)malloc(sizeof(myFILE));    
    if(fp == NULL)    
        return NULL;    
    
    fp->fd = fd;    
    fp->flags = flags;    
    fp->flush_mode = LINE_BUFFER;    
    fp->cap = SIZE;    
    fp->pos = 0;

    return fp;
}

void myfflush(myFILE* fp)                                                                                                                                                         
{
    // 写到内核中
    write(fp->fd, fp->outbuffer, fp->pos);
    fp->pos = 0; // 清空缓冲区
}

static void myfflushcode(myFILE* fp, int flag)
{
    if(fp->pos == 0)
        return;
    if(flag == TRY_FLUSH)
    {
        if(fp->flush_mode & LINE_BUFFER)
        {
            // "abcd\n"
            if(fp->outbuffer[fp->pos - 1] == '\n')
            {
                myfflush(fp);
            }   
        }
        else if(fp->flush_mode & FULL_BUFFER)
        {
            //if(fp->pos == fp->cap)
        }
        else if(fp->flush_mode & NON_BUFFER)
        {
            //write()
        }
    }
    else if(flag == MUST_FLUSH)
    {
        myfflush(fp);
    }
}

int myfputs(const char* str, myFILE* fp)
{
    if(strlen(str) == 0)
        return 0;                                                                                                                                                                 
    // step 1:向文件流里面写,本质是:文件缓冲区 -> 拷贝    
    memcpy(fp->outbuffer + fp->pos, str, strlen(str));
    fp->pos += strlen(str);

    // step 2:如果条件允许,可以自己刷新
    myfflushcode(fp, TRY_FLUSH); 
    return strlen(str);
}

void myfclose(myFILE* fp)
{
    // 1. 强制刷新到内核
    myfflushcode(fp, MUST_FLUSH);

    // 1.2 强制刷新到磁盘
    fsync(fp->fd); // 不是必须的

    // 2. 关闭文件
    close(fp->fd);

    // 3. free
    free(fp);
}

// mystring.h
#pragma once                                                                                                                                                                      
    
void mystrlen();    

// mystring.c
#include "mystring.h"                                                                                                                                                             
#include <stdio.h>    
    
void mystrlen()    
{    
    printf("我自己的mystrlen库函数\n");    
}    

// Makefile
libmystdio.a: mystdio.o mystring.o                                                                                                                                                
    ar -rc $@ $^    
%.o:%.c    
    gcc -c $<    
    
.PHONY:clean    
clean:    
    rm -rf  *.o *.a mylib    
    
.PHONY:output    
output:    
    mkdir -p mylib    
    mkdir -p mylib/include    
    mkdir -p mylib/lib    
    cp *.h mylib/include    
    cp *.a mylib/lib    

argnu归档工具,rc表示(replace and create)

  • t:列出静态库中的文件
  • v(verbose):详细信息

2.2 静态库使用

c 复制代码
// main.c
#include "mystdio.h"    
#include "mystring.h"    
#include <unistd.h>    
    
int main()    
{    
    myFILE* fp = myfopen("log.txt", "w");    
    if(fp == NULL)    
    {    
        printf("myfopen failed\n");    
        return 1;    
    }    
    
    printf("write fp success\n");                                                                                                                                                 
                                                                            
    mystrlen();                                                             
                                                                            
    return 0;                                                               
} 

// 场景1:头文件和库文件安装到系统路径下
$ gcc main.c -l mystdio

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

// 场景3:头文件和库文件有自己的独立路径
$ gcc main.c -I 头文件路径 -L 库文件路径 -l mystdio                                                                          
  • -L:指定库路径
  • -I:指定头文件搜索路径
  • -l:指定库名
  • 测试目标文件生成后,静态库删掉,程序照样可以运行
  • 库文件名称和引入库名称 :去掉前缀lib,去掉后缀.so.a,如:libc.so -> c

3. 动态库

  • 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
  • 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。
  • 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)
  • 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。

3.1 动态库生成

c 复制代码
// Makefile
libmystdio.so: mystdio.o mystring.o                                                                                                                                               
    gcc -shared -o $@ $^    
%.o:%.c    
    gcc -fPIC -c $<    
    
.PHONY:clean    
clean:    
    rm -rf  *.o *.so mylib    
    
.PHONY:output    
output:    
    mkdir -p mylib    
    mkdir -p mylib/include    
    mkdir -p mylib/lib    
    cp *.h mylib/include    
    cp *.so mylib/lib    
  • shared:表示生成共享库格式
  • fPIC:产生位置无关码(position independent code)
  • 库名规则:libxxx.so

3.2 动态库使用

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

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

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

$ ldd libmystdio.so // 查看库或者可执行程序的依赖
	linux-vdso.so.1 =>  (0x00007ffe5b5df000)
	libc.so.6 => /lib64/libc.so.6 (0x00007fe995b8f000)
	/lib64/ld-linux-x86-64.so.2 (0x00007fe996160000)

// 以场景2为例
$ ll
total 28
-rwxrwxr-x 1 lsb lsb 12800 Dec  1 18:20 libmystdio.so
-rw-rw-r-- 1 lsb lsb   273 Dec  1 18:20 main.c
-rw-rw-r-- 1 lsb lsb   470 Dec  1 18:20 mystdio.h
-rw-rw-r-- 1 lsb lsb    31 Dec  1 18:20 mystring.h
$ gcc main.c -L . -l mystdio
$ ll
total 40
-rwxrwxr-x 1 lsb lsb  8440 Dec  1 18:23 a.out
-rwxrwxr-x 1 lsb lsb 12800 Dec  1 18:20 libmystdio.so
-rw-rw-r-- 1 lsb lsb   273 Dec  1 18:20 main.c
-rw-rw-r-- 1 lsb lsb   470 Dec  1 18:20 mystdio.h
-rw-rw-r-- 1 lsb lsb    31 Dec  1 18:20 mystring.h
$ ./a.out 
write fp success
我自己的mystrlen库函数

3.3 库运行搜索路径

3.3.1 问题

头文件和库文件有自己的独立路径

bash 复制代码
[lsb@hcss-ecs-c88a test]$ ll
total 8
-rw-rw-r-- 1 lsb lsb  273 Dec  1 18:20 main.c
drwxrwxr-x 4 lsb lsb 4096 Dec  1 18:31 mylib
[lsb@hcss-ecs-c88a test]$ tree
.
├── main.c
└── mylib
    ├── include
    │   ├── mystdio.h
    │   └── mystring.h
    └── lib
        └── libmystdio.so

3 directories, 4 files
[lsb@hcss-ecs-c88a test]$ gcc main.c -I mylib/include/ -L mylib/lib/ -l mystdio
[lsb@hcss-ecs-c88a test]$ ll
total 20
-rwxrwxr-x 1 lsb lsb 8440 Dec  1 18:34 a.out
-rw-rw-r-- 1 lsb lsb  273 Dec  1 18:20 main.c
drwxrwxr-x 4 lsb lsb 4096 Dec  1 18:31 mylib
[lsb@hcss-ecs-c88a test]$ ./a.out 
./a.out: error while loading shared libraries: libmystdio.so: cannot open shared object file: No such file or directory
[lsb@hcss-ecs-c88a test]$ ldd a.out 
	linux-vdso.so.1 =>  (0x00007ffd959d3000)
	libmystdio.so => not found
	libc.so.6 => /lib64/libc.so.6 (0x00007f0596ed7000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f05972a5000)

./a.out不能运行,并且libmystdio.so => not found,找不到mystdio

因为OS不知道mystdio库在哪里,我们用gcc时,gcc知道库在哪里,但是OS是不知道的。

3.3.2 解决方案

  • 拷贝.so文件到系统共享库路径下,一般指/usr/lib、/usr/local/lib、/lib64
  • 向系统库路径下建立同名软链接
  • 更改环境变量:LD_LIBRARY_PATH
  • ldconfig方案:配置/etc/id.so.conf.d/,ldconfig更新

4. 使用外部库

我们现在没接触过太多的库,唯一接触过的就是C、C++标准库,这里我们可以推荐一个好玩的图形库:ncurses

bash 复制代码
// 安装
// Ubuntu
$ sudo apt install -y libncurses-dev

// Centos
$ sudo yum install -y ncurses-devel

系统中其实有很多库,它们通常由一组互相关联的用来完成某项常见工作的函数构成。比如我们让DeepSeek用ncurses库绘制一个心形图案。

5. 目标文件

编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们一般都是一键构建非常方便,但一旦遇到错误的时候呢?尤其是链接相关的错误,很多人就束手无策了。在Linux下,我们之前也学习过如何通过gcc编译器来完成这一系列操作。

接下来我们深入探讨一下编译和链接的整个过程,来更好的理解动静态库的使用原理。

先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。

比如:在一个源文件main.c里简单输出"hello world!",并且调用一个run函数,而这个函数被定义在另一个源文件run.c中。这里我们就可以用gcc -c来分别编译这两个源文件。

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

可以看到,在编译之后会生成两个扩展名为.o的文件,它们被称作目标文件 。要注意的是如果我们修改了一个源文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程。目标文件是一个二进制的文件,文件格式是ELF,是对二进制代码的一种封装。

6. ELF文件

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

  • 可重定位文件(Relocatable File):即xxx.o 文件。包含适合于其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据
  • 可执行文件(Executable File):即可执行程序。
  • 共享目标文件(Shared Object File):即 xxx.so 文件。
  • 内核转储(core dumps):存放当前进程的执行上下文,用于dump信号触发。

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

  • ELF头(ELF header):描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。
  • 程序头表(Program header table):列举了所有有效的段(segments)和它们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中,需要段表的描述信息,才能把他们每个段分割开。
  • 节头表(Section header table):包含对节(sections)的描述。
  • 节(Section):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。

最常见的节:

  • 代码节(.text):⽤于保存机器指令,是程序的主要执⾏部分。
  • 数据节(.data):保存已初始化的全局变量和局部静态变量。

7. ELF从形成到加载轮廓

7.1 ELF形成可执行

  • step-1:将多份C/C++源代码,翻译称为目标.o文件
  • step-2:将多份.o文件section进行合并

注意:
实际合并是在链接时进行的,但是并不时这么简单的合并,也会涉及对库合并,此处不做过多了解

7.2 ELF可执行文件加载

  • ⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment
  • 合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等
  • 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
  • 很显然,这个合并⼯作已经在形成ELF的时候确定了,具体合并原则被记录在了ELF的程序头表(Program header table)
bash 复制代码
# 查看可执行程序的section
[lsb@hcss-ecs-c88a hello]$ readelf -S a.out

# 查看section合并的segment
[lsb@hcss-ecs-c88a hello]$ readelf -l a.out

为什么要将section合并成为segment?

  • Section合并的主要原因是为了减少页面碎片,提⾼内存使⽤效率。如果不进⾏合并,假设⻚⾯⼤⼩为4096字节(内存块基本⼤⼩,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占⽤3个⻚⾯,⽽合并后,它们只需2个⻚⾯。
  • 此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个⼤的segment,这样就可以实现不同的访问权限,从⽽优化内存管理和权限访问控制。

对于程序头表节头表 又有什么用呢?其实ELF文件提供了2个不同的视图/视角来让我们理解这两个部分:

  • 链接视图(Linking view) - 对应节头表Section header table

    ⽂件结构的粒度更细,将⽂件按功能模块的差异进⾏划分,静态链接分析的时候⼀般关注的是链接视图,能够理解ELF⽂件中包含的各个部分的信息。

    为了空间布局上的效率,将来在链接⽬标⽂件时,链接器会把很多节(section)合并,规整成可执⾏的段(segment)、可读写的段、只读段等。合并了后,空间利⽤率就⾼了,否则,很⼩很⼩的⼀段,未来物理内存⻚浪费太⼤(物理内存⻚分配⼀般都是整数倍⼀块给

    你,⽐如4k),所以,链接器趁着链接就把⼩块们都合并了。

  • 执行视图(execution view) - 对应程序头表Program header table

    告诉操作系统,如何加载可执⾏⽂件,完成进程内存的初始化。⼀个可执⾏程序的格式中,⼀定有program header table

  • 说白了就是:一个在链接时作用,一个在运行加载时作用。

链接视图来看:

  • 命令readel -S main.o可以帮助查看ELF文件的节头表。
  • .text节:是保存了程序代码指令的代码节。
  • .data节:保存了初始化的全局变量和局部静态变量等数据。
  • .rodata节:保存了只读的数据,如一行C语⾔代码中的字符串。由于.rodata节是只读的,所以只能存在于⼀个可执⾏⽂件的只读段中。因此,只能是在text段(不是data段)中找到.rodata节。
  • .BSS节:为未初始化的全局变量和局部静态变量预留位置
  • .symtab节:Symbol Table符号表,就是源码⾥⾯那些函数名、变量名和代码的对应关系。
  • .got、.plt节(全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供了对导⼊的共享库函数的访问⼊⼝,由动态链接器在运⾏时进⾏修改。对于GOT的理解,我们后⾯会说。

执行视图来看:

  • 告诉操作系统哪些模块可以被加载进内存。
  • 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执⾏的。

8. 理解链接与加载

8.1 静态链接

  • ⽆论是⾃⼰的.o,还是静态库中的.o,本质都是把.o⽂件进⾏链接的过程
  • 所以:研究静态链接,本质就是研究.o是如何链接的

查看编译后的.o目标文件

  • objdump -d命令:将代码段(.text)进行反汇编查看
  • main.o中的main函数不认识printfrun函数
  • run.o中的run函数不认识printf函数

我们可以看到这⾥的call指令,它们分别对应之前调⽤的printf和run函数,但是你会发现他们的跳转地址都被设成了0。那这是为什么呢?

其实就是在编译main.o的时候,编译器是完全不知道printfrun函数的存在的,⽐如他们位于内存的哪个区块,代码⻓什么样都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为0。

这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表⾥记录的地址将其修正。

printf涉及到动态库,这里暂不说明。

整个过程

静态链接就是把库中的.o进⾏合并,和上述过程⼀样

所以链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴的可执⾏⽂件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这其实就是静态链接的过程。

所以,链接过程中会涉及到对.o中外部符号进行地址重定位。

8.2 ELF加载与进程地址空间

8.2.1 虚拟地址/逻辑地址

问题:

  • 一个ELF程序,在没有被加载到内存的时候,有没有地址呢?
  • 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?

答案:

  • ⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机⼯作的时候,都采⽤"平坦模式"进⾏⼯作。所以也要求ELF对⾃⼰的代码和数据进⾏统⼀编址,下⾯是objdump -S 反汇编之后的代码

最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址 + 偏移量),但是我们认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执⾏程序进⾏统⼀编址了。

  • 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?从ELF各个segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start,end]等范围数据,另外再⽤详细地址,填充⻚表

所以:虚拟地址机制,不光光OS要⽀持,编译器也要⽀持

8.2.2 重新理解进程虚拟地址空间

ELF在被编译好之后,会把⾃⼰未来程序的⼊⼝地址记录在ELF header的Entry字段中:

8.3 动态链接与动态库加载

8.3.1 进程如可看到动态库

8.3.2 进程间如何共享库

8.3.3 动态链接

概要

动态链接其实远⽐静态链接要常⽤得多。⽐如我们查看下main.exe这个可执⾏程序依赖的动态库,会发现它就⽤到了⼀个c动态链接库:

ldd命令用于打印程序或者库文件所依赖的共享库列表。

这⾥的libc.so是C语⾔的运⾏时库,⾥⾯提供了常⽤的标准输⼊输出⽂件字符串处理等等这些功能。

那为什么编译器默认不使⽤静态链接呢?静态链接会将编译产⽣的所有⽬标⽂件,连同⽤到的各种库,合并形成⼀个独⽴的可执⾏⽂件,它不需要额外的依赖就可以运⾏。照理来说应该更加⽅便才对吧?

静态链接最⼤的问题在于⽣成的⽂件体积⼤,并且相当耗费内存资源 。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费⼤量的硬盘

空间。

这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独⽴的动态链接库,等到程序运⾏的时候再将它们加载到内存,这样就可以大大节省空间,因为同⼀个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享。

动态链接到底是如何⼯作的??

⾸先要交代⼀个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使⽤情况为它们动态分配⼀段内存。

当动态库被加载到内存以后,⼀旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。

我们的可执行程序被编译器动了手脚

在C/C++程序中,当程序开始执⾏时,它⾸先并不会直接跳转到main函数。实际上,程序的⼊⼝点是_start,这是⼀个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数。在_start函数中,会执⾏⼀系列初始化操作,这些操作包括:

  1. 设置堆栈:为程序创建⼀个初始的堆栈环境。
  2. 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
  3. 动态链接:这是关键的⼀步,_start函数会调⽤动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调⽤和变量访问能够正确地映射到动态库中的实际地址。

动态链接器:

  • 动态链接器(如ld-linux.so)负责在程序运⾏时加载动态库。
  • 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。

环境变量和配置文件:

  • Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置⽂件(如/etc/ld.so.conf及其⼦配置⽂件)来指定动态库的搜索路径。
  • 这些路径会被动态链接器在加载动态库时搜索。

缓存文件:

  • 为了提⾼动态库的加载效率,Linux系统会维护⼀个名为 /etc/ld.so.cache 的缓存⽂件。
  • 该⽂件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会⾸先搜索这个缓存⽂件。
  1. 调用__libc_start_main:⼀旦动态链接完成,_start函数会调⽤__libc_start_main(这是glibc提供的⼀个函数)。__libc_start_main函数负责执⾏⼀些额外的初始化⼯作,⽐如设置信号处理函数、初始化线程库(如果使⽤了线程)等。
  2. 调用main函数:最后__libc_start_main函数会调⽤程序的main函数,此时程序的执⾏控制权才正式交给用户编写的代码。
  3. 处理main函数的返回值:当main函数返回时,__libc_start_main会负责处理这个返回值,并最终调⽤_exit函数来终⽌程序。

上述过程描述了C/C++程序在main 函数之前执⾏的⼀系列操作,但这些操作对于⼤多数程序员来说是透明的。程序员通常只需要关注main 函数中的代码,⽽不需要关⼼底层的初始化过程。然⽽,了

解这些底层细节有助于更好地理解程序的执⾏流程和调试问题。

动态库中的相对地址

动态库为了随时进⾏加载,为了⽀持并映射到任意进程的任意位置,对动态库中的⽅法,统⼀编址,采⽤相对编址的⽅案进⾏编址的(其实可执⾏程序也⼀样,都要遵守平坦模式,只不过exe是直接加载的)。

bash 复制代码
# ubuntu下查看任意⼀个库的反汇编
$ objdump -S /lib/x86_64-linux-gnu/libc-2.31.so | less
# Cetnos 下查看任意⼀个库的反汇编
$ objdump -S /lib64/libc-2.17.so | less

我们的程序,怎么和库具体映射起来的

注意:动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的。让我们的进程找到动态库的本质:也是⽂件操作,不过我们访问库函数,是通过虚拟地址进⾏跳转访问的,所以需要把动态库映射到进程的地址空间中

我们的程序,怎么进行库函数调用

注意:
库已经被我们映射到了当前进程的地址空间中。
库的虚拟起始地址我们也已经知道了。
库中每⼀个⽅法的偏移量地址我们也知道。
所以:访问库中任意⽅法,只需要知道库的起始虚拟地址 + ⽅法偏移量 即可定位库中的⽅法。
⽽且整个调⽤过程,是从代码区跳转到共享区,调⽤完毕在返回到代码区,整个过程完全在进程地址空间中进⾏的。

全局偏移量表GOT(global offset table)

注意:
也就是说,我们的程序运⾏之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道,然后对我们加载到内存中的程序的库函数调⽤进⾏地址修改,在内存中⼆次完成地址设置(这个叫做加载地址重定位),等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?

所以:动态链接采⽤的做法是在.data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移量表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。

因为.data区域是可读写的,所以可以⽀持动态进⾏修改。

  1. 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表
  2. 在单个.so下,由于GOT表与.text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表。
  3. 在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址。
  4. 这种⽅式实现的动态链接就被叫做PIC地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC = 相对编址 + GOT

库间依赖

不仅仅可执⾏程序会调⽤库,库也会调⽤其他库!!库之间是有依赖的,如何做到库和库之间互相调⽤也是与地址⽆关的呢??库中也有.GOT,和可执⾏⼀样!这也就是为什么⼤家都是ELF的格式!

由于GOT表中的映射地址会在运⾏时去修改,我们可以通过gdb调试去观察GOT表的地址变化。在这⾥我们只⽤知道原理即可。

由于动态链接在程序加载的时候需要对⼤量函数进⾏重定位,这⼀步显然是⾮常耗时的。为了进⼀步降低开销,我们的操作系统还做了⼀些其他的优化,⽐如延迟绑定,或者也叫PLT(过程链接表(Procedure Linkage Table))。与其在程序⼀开始就对所有函数进⾏重定位,不如将这个过程推迟到函数第⼀次被调⽤的时候,因为绝⼤多数动态库中的函数可能在程序运⾏期间⼀次都不会被使⽤到。

思路是:GOT中的跳转地址默认会指向⼀段辅助代码,它也被叫做桩代码/stup。在我们第⼀次调⽤函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调⽤函数的时候,就会直接跳转到动态库中真正的函数实现。

总⽽⾔之,动态链接实际上将链接的整个过程,⽐如符号查询、地址的重定位从编译时推迟到了程序的运⾏时,它虽然牺牲了⼀定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利⽤磁盘空间和内存资源,极⼤⽅便了代码的更新和维护,更关键的是,它实现了⼆进制级别的代码复⽤。

解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程

8.3.4 总结

  • 静态链接的出现,提⾼了程序的模块化⽔平。对于⼀个⼤的项⽬,不同的⼈可以独⽴地测试和开发⾃⼰的模块。通过静态链接,⽣成最终的可执⾏⽂件。
  • 我们知道静态链接会将编译产⽣的所有⽬标⽂件和⽤到的各种库合并成⼀个独⽴的可执⾏⽂件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
  • ⽽动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是⽆论加载到什么地⽅,都要映射到进程对应的地址空间,然后通过.GOT⽅式进⾏调⽤(运⾏重定位,也叫做动态地址重定位)。
相关推荐
茶杯67531 分钟前
“舒欣双免“方案助力MSI-H/dMMR结肠癌治疗新突破
java·服务器·前端
我真会写代码31 分钟前
从入门到精通:Java Socket 网络编程实战(含线程池优化)
java·linux·服务器·socket·tcp/ip协议
刘某的Cloud35 分钟前
全局禁用ipv6
linux·运维·网络·系统·ipv6
乾元37 分钟前
多厂商配置对齐器:AI 如何在 Cisco / Huawei / Juniper 间做语义映射
运维·开发语言·网络·人工智能·网络协议·华为·智能路由器
熊文豪37 分钟前
使用Python快速开发一个MCP服务器
服务器·开发语言·python·mcp
Jtti38 分钟前
高防ip和普通ip两者有何区别?
运维·网络协议·tcp/ip
herinspace38 分钟前
管家婆软件中如何运用商品副单位
运维·服务器·数据库·windows·电脑
程序辕日记39 分钟前
Linux环境docker离线安装教程
linux·docker
Channing Lewis41 分钟前
zoho crm中如何记录下已删除的子表recordid
运维·服务器·oracle