【Linux系统篇】:从创建到链接--动静态库的工作原理与差异

✨感谢您阅读本篇文章,文章内容是个人学习笔记的整理,如果哪里有误的话还请您指正噢✨
✨ 个人主页:余辉zmh–CSDN博客
✨ 文章所属专栏:Linux篇–CSDN博客

在这里插入图片描述

一.静态库和动态库

首先需要明白什么是库(Libraries)链接(Linking)

  • 是一组预编译好的函数,类或资源的集合,供程序调用。(注意是预编译好的,所以一定是.o文件的集合;除了提供库文件,还要提供头文件.h,二者共同实现代码的接口与实现分离,两者的关系可以用”说明书“和”工具箱"来类比)。通过提供库文件,可以避免重复造轮子,提升代码复用性。其中库又分为静态库动态库
  • 链接是将程序代码与库文件合并成最终可执行文件的过程。其中链接又分为静态链接和动态链接,分别对应静态库和动态库的链接方式。

1.静态库与静态链接

核心概念

  • 概念:一组预编译的目标文件(.0文件)的集合叫做静态库,链接时被完整复制到最终的可执行文件中,这种链接方式就做静态链接

  • 特点

    • 可执行文件独立运行,无需依赖外部库文件。
    • 可执行文件体积变大(包含所有依赖的库代码)。
    • 更新静态库时,需要重新编译整个可执行程序。
  • 文件格式:Linux下为libXXX.a格式的文件,其中前缀为lib,后缀为.a

设计简单的静态库

创建一个静态数学库libmymath.a,由源代码mymath.c形成mymath.o文件存放到静态库中;再提供一份头文件mymath.h。最后将头文件和库文件分别放到不同的路径中。

在这里插入图片描述

  • 步骤1:编写源代码和和头文件

    mymath.h头文件:

    #pragma once 
    
    #include<stdio.h>
    
    //声明一个全局变量
    extern int myerron;
    
    int add(int x, int y);
    int sub(int x, int y);
    int mul(int x, int y);
    int div(int x, int y);
    
    

    mymath.c库文件源代码:

    #include "mymath.h"
    
    //定义一个全局变量
    int myerron=0;
    
    int add(int x,int y){
          
          
        return x+y;
    }
    int sub(int x,int y){
          
          
        return x-y;
    }
    int mul(int x,int y){
          
          
        return x*y;
    }
    int div(int x,int y){
          
          
        if(y==0){
          
          
            myerron=1;
            return -1;
        }
        return x/y;
    }
    
    

    C语言会为库文件提供一个全局变量errno

  • 步骤2:编译形成目标文件.o

    gcc -c mymath.c
    
  • 步骤3:将.o文件打包成静态库

    ar -rc libmymath.a mymath.o
    
  • 步骤4:头文件和库文件存放到不同的路径下

    mymath.c头文件放到/lib/include路径下:

    mkdir -p lib/include
    cp *.h lib/include
    

    libmymath.a库文件放到/lib/mymathlib路径下:

    mkdir -p lib/mymathlib
    cp *.a lib/mymathlib
    

使用静态库

编写一个主程序main.c,调用静态库中的函数:

#include "mymath.h"

int main(){
    
    
    printf("1+1=%d\n",add(1,1));
    return 0;
}
  • 步骤5:编译并链接静态库

    使用gcc编译主程序并链接静态库:

    gcc main.c -I lib/include -L lib/mymathlib -lmymath
    

    选项

    • -I:指定头文件的搜索路径为lib/include
    • -L:指定库文件的搜索路径为lib/mymathlib
    • -l:链接名为libmymath.a的静态库(选项后紧跟库名,注意要省略前缀lib和后缀.a
  • 步骤6:生成可执行程序a.out,运行

    在这里插入图片描述

为什么这里编译链接时gcc需要带这三个选项

这是因为我们自己写的库文件属于第三方库,前面的步骤将头文件和库文件分别存放到不同的路径下,就是模拟系统中的存放形式,系统的环境变量中包含系统库文件的路径信息,所以使用系统库时不需要指明路径信息;而自己的头文件和库文件的路径信息并不存在系统的环境变量中,所以系统并不能自动找到对应的所在位置,如果不指明具体的头文件和库文件搜索路径,就会链接失败。

在这里插入图片描述

而至于为什么头文件不需要指明具体的哪一个,库文件却需要指明?

这是因为在主程序的源代码中就已经包含了使用的头文件具体是哪一个,所以不需要再指明;而库文件却没有,所以需要使用-l选项指明具体的库文件名。

除了上面的带上选项指明路径信息解决外,还可以将第三方的头文件和库文件拷贝到系统路径下

两种方式:

  • 直接拷贝

    #头文件拷贝到系统头文件/usr/include下
    sudo cp lib/include/mymath.h /usr/include
    #库文件拷贝到系统库文件/lib64下
    sudo cp lib/mymathlib/libmymath.a /lib64
    
    #拷贝到系统路径后依然要指明具体的库文件名
    gcc main.c -lmymath
    ./a.out
    
    
    #从系统路径中删除
    sudo rm /usr/include/mymath.h
    sudo rm /lib64/libmymath.a
    

    在这里插入图片描述

    平常我们所说的安装软件本质其实就是将路径信息拷贝到系统的默认路径下。

  • 建立软链接

    #头文件建立软链接
    sudo ln -s /home/zmh01/linux/file/library/test/lib/include /usr/include/myinc
    #库文件建立软链接
    sudo ln -s /home/zmh01/linux/file/library/test/lib/mymathlib/libmymath.a /lib64/libmymath.a
    
    
    #主程序源代码中的头文件修改成“myinc/mymath.h"
    #include "myinc/mymath.h"
    
    
    gcc main.c -lmymath
    ./a.out
    
    #从系统路径中删除软链接
    sudo unlink /usr/include/myinc
    sudo unlink /lib64/libmymath.a
    

    在这里插入图片描述

    在这里插入图片描述

2.动态库与动态链接

核心概念

  • 概念:包含已编译的代码,程序在运行时动态加载,这种链接方式就是动态链接

  • 特点

    • 可执行文件体积小(仅记录对动态库的引用)。
    • 多个程序可共享同一份动态库,节省内存。
    • 更新动态库无需重新编译主程序。
  • 文件格式

    Linux下为libXXX.so格式的文件,其中前缀为lib,后缀为.so

设计简单的动态库

创建一个动态库libmymethod.so,由源代码mylog.c和myprintf.c形成mymlog.o和myprintf.o两个.o文件存放到动态态库中;再提供两个头文件mylog.h和myprintf.h。最后将头文件和库文件分别放到不同的路径中。

在这里插入图片描述

  • 步骤1:编写源代码和和头文件

    两个头文件:

    //mylog.h:
    #pragma once
    
    #include<stdio.h>
    
    void Log(const char *);
    
    
    //myprintf.h:
    #pragma once
    
    #include<stdio.h>
    
    void Print();
    

    两个源代码:

    //mylog.c:
    #include "mylog.h"
    
    void Log(const char*info){
          
          
        printf("Waring: %s\n", info);
    }
    
    
    
    //myprintf.c:
    #include"myprintf.h"
    
    void Print(){
          
          
        printf("hello new world\n");
        printf("hello new world\n");
    }
    
  • 步骤2:编译形成的目标文件.o

    mylog.o:mylog.c
    	gcc -fPIC -c mylog.c
    myprintf.o:myprintf.c
    	gcc -fPIC -c myprintf.c
    

    选项-fPIC表示位置无关码,后面会讲。

  • 步骤3:将.o文件打包成动态库

    gcc -shared -o libmymethod.so mylog.o myprintf.o
    

    选项-shared表示生成共享库格式

  • 步骤4:头文件和库文件存放到不同的路径下

    头文件存放到mylib/include路径下:

    mkdir -p mylib/include
    cp *.h mylib/include
    

    库文件存放到mylib/lib路径下:

    mkdir -p mylib/lib
    cp *.so mylib/lib
    

使用动态库

编写一个主程序main.c,调用静态库中的函数:

#include "mylog.h"
#include "myprintf.h"

int main()
{
    
    
    Print();
    Log("hello log function");
    return 0;
}

  • 步骤5:编译并链接动态库

    使用gcc编译主程序并链接动态库:

    gcc main.c -I mylib/incllude -L mylib/lib -lmymethod
    

    选项使用方式和静态库一样,需要指明具体的头文件和库文件路径信息,以及指明具体的库文件是哪一个。

  • 步骤6:设置动态库加载路径并运行

    编译链接动态库形成可执行文件后,如果直接运行可执行文件,就会报错:

    在这里插入图片描述

    使用ldd a.out指令查看该文件的链接信息,就会发现链接我们自己的库文件显示的是not found

    但在使用gcc编译的时候不是已经指明路径信息了吗?为什么运行时还是找到不到?

    这是因为,动态库和静态库的链接方式不同:

    静态链接是编译时直接将库文件中的实现方法直接复制到可执行文件中,所以运行时无需再关心路径信息。

    动态链接则是在运行时加载库文件的实现方法,通过加载器来实现,使用gcc编译时指明路径信息只是告诉了编译器需要去哪里进行链接,而运行时是还需要告诉加载器需要去哪里进行加载,也是需要指明路径信息。

    至于使用系统库文件不需要指明,还是因为系统会默认到系统路径中查找;而使用我们自己的库则需要给加载器指明路径信息。

    运行时设置动态库加载路径的方法

    1.拷贝到系统默认的库文件路径

    #拷贝
    sudo cp mylib/lib/libmymethod.so /lib64
    
    #删除
    sudo rm /lib64/libmymethod.so
    

    在这里插入图片描述

    2.在系统默认的库文件路径下建立软链接

    #建立软链接
    sudo ln -s ...(绝对路径)/mylib/lib/libmymethod.so /lib64/libmymethod.so
    
    #删除软链接
    sudo unlink 
    

    在这里插入图片描述

    3.将自己的库所在路径添加到系统的环境变量中

    环境变量LD_LIBRARY_PATH:专门用来给用户提供搜索用户自定义的库路径(和静态库没有关系,因为不需要加载)

    export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:...(绝对路径)/mylib/lib
    #注意只需要指明路径即可,具体的库名称系统已经获取了
    

    在这里插入图片描述

    但是环境变量每次启动都会重置,所以这种方法相当于一次性的,每次启动都要手动设置,如果嫌麻烦,可以在启动的脚本中(~/.bash_profile)添加上面的修改语句,这样每次启动时就会自动执行。

    4.添加自定义路径到/etc/ld.so.conf

    #在超级用户下进入/etc/ld.so.conf目录
    cd /etc/ld.so.conf
    
    #先创建一普通文件
    touch libmymethod.conf
    
    #在该文件中写入库文件路径信息
    vim libmymethod.conf
    #写入
    
    #重置所有配置文件
    ldconfig
    

    在这里插入图片描述

    实际情况下我们很少用到自己写的第三方库,大多都是是用别人的成熟的第三方库,所以一般都是采用第一种方式,直接安装。

3.总结

结合前面的动静态库制作和使用,可以大概清楚动静态库的区别,动态库在进程运行的时候,是要被加载的(静态库不需要,因为静态库是直接复制到可执行程序中),动态库在系统中加载后,可以被其他的可执行程序使用,使动态库被所有进程共享,因此动态库又叫做共享库

除此之外,在链接同一名称的动态库和静态库时,默认情况下,链接器会优先选择动态库,如果需要强制链接静态库,需显示指定。

如果使用-stati选项,就会强制所有库使用静态版本。

二.动态库的加载原理

1.程序没有加载前的地址

在程序编译好后,还没有被加载到内存时,程序内部是有地址的;而这个地址就是逻辑地址(本质上就是虚拟地址,不是物理地址)。

编译好后程序会被划分为不同的段,每个段存储特定类型的数据或指令;每个段中的数据或指令都有一个固定的地址(相对于起始地址0开始的偏移量)。而不同段的划分编译器也需要考虑操作系统,方便后续的管理和优化程序的加载。此时划分依据就是根据进程的虚拟地址空间进行划分,所以对应的地址就是虚拟地址(现在这里还不叫做虚拟地址,而是逻辑地址)。

常见的段包括:

  • 代码段(.text:存储机器指令(即程序的可执行代码)。
  • 数据段(.data:存储已初始化的全局变量和静态变量。
  • BSS 段(.bss:存储未初始化的全局变量和静态变量。
  • 只读数据段(.rodata:存储常量数据(如字符串常量、const 变量)。
  • 堆(Heap):用于动态内存分配(如 mallocnew)。
  • 栈(Stack):用于函数调用时的局部变量和返回地址。

在编译好后,程序还没有被加载到内存时,此时的目标文件包含了各个段的内容及其逻辑地址

示例

假设有一个简单的C程序:

int global_var = 42;         // 已初始化的全局变量
int uninitialized_var;       // 未初始化的全局变量

void func() {
    
    
    printf("Hello, World!\n");
}

int main() {
    
    
    func();
    return 0;
}

编译后,各段的地址可能如下:

  • 代码段(.text):
    • func 的地址:0x0
    • main 的地址:0x10(假设 func 占用 16 字节)
  • 数据段(.data):
    • global_var 的地址:0x3
  • BSS 段(.bss):
    • uninitialized_var 的地址:0x4(假设 global_var 占用 4 字节)

2.程序加载后的地址

  • 虚拟地址的映射

    程序加载到内存之前,操作系统要先为进程分配独立的PCB,虚拟地址空间和页表

    操作系统为进程分配独立的虚拟地址空间,此时程序内部的各段映射到预设的虚拟地址中(此时逻辑地址才叫做虚拟地址)。此时仅建立虚拟地址到磁盘文件的映射(通过页表标记为“未加载”)。

    当建立内核数据结构并且映射虚拟地址后,就要由CPU开始执行程序,如何执行代码段的第一条指令?

    可执行程序在编译形成时,就已经在可执行程序的头部写好了一个入口地址entry,这个入口地址也是虚拟地址,而不是物理地址;根据这个入口地址就可以在虚拟地址空间中找到代码段的第一条指令开始执行。

    根据虚拟地址,页表,物理地址三者之间的转换,由虚拟地址找到物理地址,但此时页表标记的是“未加载”,就会立刻触发缺页中断,系统将可执行程序从磁盘加载到物理内存中,然后在页表中填充虚拟地址到物理地址的映射,继续执行当前指令。

  • 加载到物理内存中

    当程序加载到内存后,肯定要占用实际的物理内存,就天然的具备了物理地址;所以各段内容除了含有自己内部的逻辑地址外,在物理内存中还有自己的物理地址。

    就好比学生在校园中,每个人都有自己的学号,而在教室中,每个人还都有一个属于自己的座位,第几排第几个;当一个学生进入教室后坐在自己的座位上,除了有学号还要有自己的座位编号,将程序比作学生,逻辑地址比作学号,物理地址比作座位编号就能更好地理解。

    可以理解为,根据物理地址找到可执行程序的内容(指令或数据)后,因为内容内部还有自己的虚拟地址,所以还可以获取到该内容的虚拟地址。

    当可执行程序加载到内存后,CPU就可以继续执行,根据页表找到对应的物理地址,获取内容。

    注意加载到物理内存可能并不是一次性全部加载,可能会是按需加载,当读取到某条指令时,物理内存中并没有加载,同样也是触发缺页中断,先加载到物理内存后再继续执行

  • 虚拟地址到物理地址之间的循环转换

    假设现在CPU执行到代码段的第三条指令,根据虚拟地址0x3(举例说明,并不是实际的虚拟地址格式)从页表中获取到对应的物理地址0x77(举例说明,并不是实际的物理地址格式),再根据物理地址从物理内存中读取到对应的指令,此时CPU读取到的指令,内部可能有数据,也可能有地址(比如当前第三条指令是调用某个函数,这个地址就是该函数的地址);如果是地址,这个地址也是虚拟地址

    根据从物理内存中读取到的地址,就可以从虚拟地址空间中获取到要调用的函数的虚拟地址,然后从页表中获取对应的物理地址(同样的,如果还没有加载,就先触发缺页中断进行加载,然后再获取物理地址),根据物理地址再从物理内存中找到要调用的函数,整个过程就形成了一个虚拟地址空间–页表–物理内存–虚拟地址空间的循环转换。

    这时候再来理解编译后可执行程序内部形成的地址,就可以明白为什么是虚拟地址,当可执行程序加载到物理内存后,两者一结合,就可以实现虚拟和物理两者之间的循环转换;这也是编译器和操作系统协同工作的最重要表现之一。

3.动态库地址

什么是绝对地址和相对地址

**绝对地址是内存中的固定位置(比如0x40000),编译时已经确定,**不依赖运行时的基地址(通常是起始地址0)。

相对地址是相对于某个基地址的偏移量(比如当前基地址是0x90000,偏移量是0x1000,相对地址就是0x90000+0x1000),实际的地址在运行时通过基地址+偏移量计算。

对于可执行程序的主程序来说,因为主程序只有一份,通常是加载到固定的地址空间,编译时采用绝对地址编址;而对于动态库来说,通常不能加载到固定的地址空间。

主程序使用的动态库,是加载到地址空间的共享区,而共享区非常大,并且主程序可能会调用多个动态库,若动态库编译时使用绝对地址,不同库可能要求加载到同一内存区域,就会导致冲突,使其中某个库加载失败。

因此为了解决这一问题,动态库通常采用位置无关码(PIC)来解决。

PIC的核心思想

  • 代码和数据的地址在编译时以相对偏移的形式存在。
  • 运行时通过动态基地址+相对偏移量计算出实际内存地址。

在前面设计自己的动态库时,使用gcc编译源代码形成.o文件时,选项-fPIC就是要求在编译时采用相对偏移的形式进行编址。

一个主程序可能会使用多个动态库,采用PIC,每个动态库就可以在共享区随意存放,而系统需要对多个动态库进行管理,记录每个动态库的基地址

当CPU在代码段读取到某条指令需要调用某个动态库中的函数时,根据这个函数的相对偏移量+动态库的基地址就可以找到对应动态库中的函数,然后再从页表中查找对应的物理地址,如果此时这个动态库还没有被加载到内存中,就会先触发缺页中断加载要使用的动态库,加载后再继续执行。

动态库采用相对地址PIC,除了可以解决加载冲突问题,还可以实现多个进程共享动态库。

补充内容

  • 传统主程序使用绝对地址(如旧版的Linux程序)
  • 现代主程序使用相对地址(与动态库相同)
  • 动态库则必须使用相对地址(PIC),否则无法实现动态库共享以及解决加载冲突问题