Linux修炼:库制作与原理(一)

Hello大家好!很高兴我们又见面啦!给生活添点passion,开始今天的编程之路!

我的博客: <但凡.

我的专栏: 《编程之路》《数据结构与算法之美》《C++修炼之路》《Linux修炼:终端之内 洞悉真理》《Git 完全手册:从入门到团队协作实战》

感谢你打开这篇博客!希望这篇博客能为你带来帮助,也欢迎一起交流探讨,共同成长。

目录

1、什么是库

2、静态库

创建库的头文件(mylibrary.h)

实现库的源文件(mylibrary.cpp)

使用库的主程序(main.cpp)

编译和链接

3、动态库

4、ELF文件

4.1、认识ELF格式

[ELF头部(ELF Header)](#ELF头部(ELF Header))

[程序头表(Program Header Table)](#程序头表(Program Header Table))

[节头表(Section Header Table)](#节头表(Section Header Table))

节(Sections)

4.2、谈链接过程

静态链接的过程

关键特点


1、什么是库

库就是已经写好的可以复用的代码。库是一种可执行代码的二进制形式,可以被操作系统载入到内存执行。库有两种:

静态库 .a[Linux]、.lib[windows]

动态库 .so[Linux]、.dll[windows]

2、静态库

程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。

我们首先自己写一个库来理解一下什么是库:

我们准备三个文件,一个是创建库的头文件,一个是实现库的源文件,还有一个是使用库的主程序。

创建库的头文件(mylibrary.h)

cpp 复制代码
#ifndef MYLIBRARY_H
#define MYLIBRARY_H

namespace MyLibrary {
    class Calculator {
    public:
        static int add(int a, int b);
        static int subtract(int a, int b);
    };

    void greet(const char* name);
}

#endif // MYLIBRARY_H

实现库的源文件(mylibrary.cpp)

cpp 复制代码
#include "mylibrary.h"
#include <iostream>

namespace MyLibrary {
    int Calculator::add(int a, int b) {
        return a + b;
    }

    int Calculator::subtract(int a, int b) {
        return a - b;
    }

    void greet(const char* name) {
        std::cout << "Hello, " << name << "!" << std::endl;
    }
}

使用库的主程序(main.cpp)

cpp 复制代码
#include "mylibrary.h"
#include <iostream>

int main() {
    // 使用库中的类
    std::cout << "5 + 3 = " << MyLibrary::Calculator::add(5, 3) << std::endl;
    std::cout << "5 - 3 = " << MyLibrary::Calculator::subtract(5, 3) << std::endl;

    // 使用库中的函数
    MyLibrary::greet("World");

    return 0;
}

编译和链接

要将上述代码编译为库并链接到主程序,可以按照以下步骤操作:

  1. 编译库文件为对象文件(注意是把库的实现文件编译成.o)

    bash 复制代码
    g++ -c mylibrary.cpp -o mylibrary.o
  2. 将对象文件打包为静态库

    bash 复制代码
    ar rcs libmylibrary.a mylibrary.o
  3. **其中,ar是创建,修改,提取,静态库文件的命令,rcs是三个命令选项,其中r是替换或添加文件到归档中,c是静默模式,不显示警告信息,s是创建或更新归档索引。**我们要把目标文件mylibrary.o打包成静态库libmylibrary.a。库必须以lib开头。

  4. 编译主程序并链接库:

    bash 复制代码
    g++ main.cpp -L. -lmylibrary -o myprogram
  5. -L.是指定链接器搜索库文件的路径是当前目录。链接器会在当前目录寻找libmylibrary。-lmylibrary为我们要链接的库名。-l自动补全前缀lib和后缀。-o myprogram指生成的可执行文件名为myprogram

  6. 运行程序:

    bash 复制代码
    ./myprogram

库其实就是把程序员写好的源代码,编译成.o,然后打了个包。

此时如果我们使用ldd命令去查看myprogram的依赖库信息,会发现查不到。这是因为静态库会把自己的代码合并到目标文件,即一旦形成可执行程序,就不再依赖静态库了。

一般官方的库都被装在了系统的默认路径下,而gcc/g++编译程序时,一般会在默认路径下查找。所以,假设我们的可执行程序中只包含了stdio.h这个头文件,那么我们不需要-L来指示库的位置,因为C标准库就在系统的默认路径中。所谓的库的安装,主要就是把库文件拷贝到系统的默认路径下。

又因为gcc是专门编译C语言的,所以他默认就要认识libc。因此在编译的时候,我们不需要带着-lc。第三方的库都需要带-l来表示库名称。

假设我们现在想把自己写的静态库中的头文件在include的时用<>包含起来,我们需要怎么做呢?

**第一种方法就是在执行命令时,加上-I.,表示在当前路径搜索头文件。**第二种方式就是把我们的头文件也拷贝到系统的指定目录下。我们把libmylibrary移动到默认路径下,再把头文件拷贝到系统的指定目录之后,我们就可以通过执行以下命令成功编译程序:

g++ main.cpp -lmylibrary -o myprogram

库=头文件+库文件。库的使用需要搜索找到头文件(-I),库路径(-L),库是谁(-l)。库如果不想过多使用上面的选项,就需要把库安装到指定了系统特定路径。一般情况下,头文件拷贝到/user/include,而库文件拷贝到/lib64。

现在我们正儿八经的用make构建制作静态库的流程:

bash 复制代码
libmyc.a:mymath.o mystdio.o
    ar -rc $@ $^
%.o:%.c
    gcc -c $<

.PHONY:clean
clean:
    rm -f *.a *.o
 
.PHONY:output
output:
    mkdir mylibc
    mkdir -p mylibc/include
    mkdir -p mylibc/lib
    cp *.h mylibc/include
    cp *.a mylibc/lib
    tar czf mylibc.tgz mylibc     

执行make,会自动生成静态库的压缩包。

3、动态库

如果想创建动态库(共享库),可以使用以下命令:

  1. 编译为动态库:

    bash 复制代码
    g++ -shared -fPIC mylibrary.cpp -o libmylibrary.so
  2. 其中,-shared 指示编译器生成动态链接库(共享库),而不是可执行文件。-fPIC指生成位置无关代码,这时动态链接库必需的,确保代码可以在内存中任意位置加载。动态库的名称一般以.so为后缀。

  3. 链接动态库:

    bash 复制代码
    g++ main.cpp -L. -lmylibrary -o myprogram
  4. 运行前需要设置库路径:

    bash 复制代码
    export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
    ./myprogram

现在我们自动化构建动态库:

bash 复制代码
libmyc.so:mymath.o mystdio.o
    gcc -o $@ $^ -shared 
%.o:%.c
    gcc -fPIC -c $<

.PHONY:clean
clean:
    rm -rf *.so *.o mylibc *.tgz

.PHONY:output
output:
    mkdir mylibc
    mkdir -p mylibc/include
    mkdir -p mylibc/lib
    cp *.h mylibc/include
    cp *.so mylibc/lib                                                                                                                                                                          
    tar czf mylibc.tgz mylibc

使用libmyc.so时,我们需要指示库的路径,头文件的路径,以及库的名字:

bash 复制代码
gcc test.c -I mylibc/include. -L mylibc/lib/ -lmyc

在我们配置编译器环境时,比如vs,它会自动给我们下载一堆东西,这堆东西中就有各种我们需要用的的库和头文件。

接着我们运行程序,发现会报错,这是因为我们的动态链接要求在运行时也要能找到动态库。 我们时刻需要访问动态库。而之前在编译时,我们只是告诉了编译器动态库在哪里,但是系统(中的加载器)并不知道动态库在哪。

**我们可以把动态库拷贝到lib64(即系统默认路径)中,这样系统和编译器就都能找到了。当然我们还有别的方法,我们还可以在libb64目录下,以软连接的方式进行关联。软连接的名字应当与库名称一样。**其实一般情况下,我们所有的库都是放在lib64中的。

除了这两种方式以外,还有一种方式就是通过环境变量。我们可以配置指定的环境变量,让系统找到指定的动态库。即上面第4条介绍的那种方式。这种方式是临时方式。

除此之外,我们还可以将动态库查找路径全局有效,更改系统配置文件。在这里就不演示了,因为我们常用的是第一二种方法。

**动态库和静态库同时存在的时候,gcc/g++默认优先使用动态库。默认进行动态链接。**一个可执行程序,可能会依赖多个库。如果我们只提供静态库,即使我们是动态链接,gcc也没有办法,只能对只提供静态库的库进行静态链接。

我们在编译时可以使用--static选项,要求必须采用静态链接。这种情况下,静态库必须存在。

**大部分系统只安装了C/C++的动态库,没有安装静态库。**我们可以通过以下指定手动安装静态库:

bash 复制代码
#C语言
sudo apt/yum install glibc-static
#C++
sudo apt/yum install libstdc++-static

4、ELF文件

4.1、认识ELF格式

在过去的一段时间里,我们接触过几种特殊的文件,包括可重定位文件(.o为后后缀),可执行文件,共享目标文件(.so为后缀),内核转储。以上这些文件都是ELF文件。

以人类的眼光看,我们直接打开ELF文件,里面的内容都是乱码。其实这些乱码也是有固定的存储规则的。

ELF文件由以下部分组成:

ELF头部(ELF Header)

描述文件的基本属性,如文件类型(可执行、目标文件等)、目标架构(如x86、ARM)和程序入口地址。

程序头表(Program Header Table)

仅存在于可执行文件和共享库中,定义如何将文件加载到内存。每个条目描述一个段(如代码段、数据段)的内存布局。

节头表(Section Header Table)

包含文件中所有节(section)的描述信息,如代码节(.text)、数据节(.data)和符号表(.symtab)。目标文件通常依赖此表进行链接。

节(Sections)

存储实际内容,例如:

  • .text:可执行代码。
  • .data:已初始化的全局变量。
  • .bss:未初始化的全局变量(不占文件空间)。
  • .rodata:只读数据(如字符串常量)。

我们可以通过readelf命令来获取ELF文件的各种信息,比如我们可以查看ELF Header中的信息:

**ELF(Executable and Linkable Format)文件的开头是一个固定大小的头部结构(ELF Header),它描述了整个文件的基本属性和布局。**以下是 ELF Header 中存储的关键信息:

  1. Magic Number

    • 前 4 字节为 0x7F'E''L''F',用于标识文件是否为 ELF 格式。
    • 后续字节包含 ELF 文件的类别(32/64 位)、字节序(大端/小端)和版本信息。
  2. 文件类型

    • 2 字节字段,标识文件类型,如:
      • ET_REL(可重定位文件,如 .o 文件)
      • ET_EXEC(可执行文件)
      • ET_DYN(共享库,如 .so 文件)
  3. 机器架构

    • 2 字节字段,指定目标机器的 CPU 架构,例如:
      • EM_X86_64(x86-64)
      • EM_ARM(ARM)
      • EM_RISCV(RISC-V)
  4. ELF 版本

    • 4 字节字段,通常为 EV_CURRENT(当前版本)。
  5. 程序入口地址

    • 8 字节字段(64 位)或 4 字节字段(32 位),指定可执行文件的入口点(即 _start 函数的地址)。
  6. Program Header 和 Section Header 的位置

    • e_phoff:Program Header 表在文件中的偏移量。
    • e_shoff:Section Header 表在文件中的偏移量。
  7. 标志与对齐信息

    • 处理器特定的标志(如 ABI 版本)。
    • Program Header 和 Section Header 的条目大小及数量。

我们可以通过指令来读一下节头表中的内容:

其中包含了各个节的名称,比如text(文本),rodate(已初始化全局数据区)等等。Section Header会记录一共有多少个节,并且记录每个节的大小、偏移量和权限等等。

节的大小不一定是4KB,但操作系统每次读取数据都是以4KB为单位的。 而且,不同的节也可能会有相同的属性。所以,我们每次IO的时候,都需要将多个属性相同的section合并,对齐4KB(对齐4KB的倍数)。合并之后的数据节叫数据段(segment)。没错,我们之前学过的什么BSS段,数据段,代码段,都是从ELF文件中加载进来的。 ELF加载到内存的时候,会被OS自动合并成为多个segment,加载到内存中。而程序头表(Program Header Table)就是合并成为segment的方法表。也就是说,合并的方式在ELF文件被创建的时候就已经规定好了,就像所有命运馈赠的礼物早就被暗中标好了价格。

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

我们可以通过指令查看程序头表(Program Header Table)中的内容:

合并之后就变成了八个数据段,分别对应编号01到08,最后合并成的数据段的个数是不固定的,不同文件可能不一样。

一个ELF文件要有两种视角,一种视角是编译器视角(section),另一种是系统视角(segment)。

4.2、谈链接过程

链接其实就是把多个ELF文件中对应的属性相同的section合并到一起:

当然实际上的链接要复杂的多。

静态链接的过程

符号解析

链接器读取所有输入的目标文件,检查每个目标文件中的符号定义与引用。确保每个符号有且仅有一个定义,解决所有未解析的符号引用。

地址与空间分配

为输出文件中的代码段、数据段等分配运行时内存地址。合并所有目标文件的同类段(如.text.data),并确定每个段在最终可执行文件中的大小和位置。

重定位

修改目标文件中的符号引用地址,使其指向正确的运行时地址。根据段合并后的新基址,调整代码和数据中的相对地址或绝对地址引用。

生成可执行文件

将重定位后的代码、数据及必要的头部信息(如ELF头、段表)写入输出文件,形成可直接加载运行的静态可执行文件。

关键特点

  • 所有外部符号在链接时已解析并绑定。
  • 最终文件独立运行,无需运行时加载动态库。
  • 文件体积较大,因库代码被完整复制到可执行文件中。

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

**一个ELF可执行程序,在没有加载到内存的时候,就已经有地址了!Linux系统编译形成可执行程序的时候,需要对代码和数据进行编址。**当代CPU和计算机和操作系统,在对ELF编址的时候采用的做法都是平坦模式进行的,我们仍可以看作是段起始地址+偏移量的方式,只不过段起始地址是0。

**这种从全零到全F的线性编址得到的地址起始就是我们之前讲的虚拟地址。在磁盘中的可执行文件,它的地址是以逻辑地址的方式表示的,而在内存中,它是以虚拟地址的方式表示的。**尽管在磁盘中是逻辑地址,在内存中是虚拟地址,但是他们都是从全0到全F的。

**当我们把可执行程序加载到内存中时,我们的程序内部就建立起了物理地址到虚拟地址的映射关系。这个映射关系被记录在页表之中。cpu可以根据存储在EIP中的程序地址入口(即ELF Header中的entry point address)去访问物理地址。需要注意的是,这个程序地址入口存储的地址是程序在内存中的虚拟地址。**当cpu拿到虚拟地址之后,在根据页表得到物理地址再进行访问。当CPU需要将虚拟地址转换为物理地址时,会从CR3寄存器获取页表基址开始查找。

**CR3 寄存器为 MMU 提供了页表的起始地址。**在 x86 分页机制中,虚拟地址到物理地址的转换需要多级页表(如页目录和页表)。CR3 存储的是页目录的物理地址,MMU 使用 CR3 的值作为起点,逐级查找页表项,最终完成地址转换。转换完成后,cpu和内存又通过系统总线连接起来,进行数据的传输。

好了,今天的内容就分享到这,我们下期再见!

相关推荐
Maple_land5 小时前
编译器的“隐形约定”与本地变量:解锁Linux变量体系的关键密码
linux·运维·服务器·c++·centos
深思慎考6 小时前
微服务即时通讯系统(服务端)——Speech 语音模块开发(2)
linux·c++·微服务·云原生·架构·语音识别·聊天室项目
小蜜蜂爱编程6 小时前
Ubuntu无法开机Failed to activate swap /swapfile
linux·运维·ubuntu
阿巴~阿巴~6 小时前
CPU 指令集、权限与用户态内核态机制
linux·运维·服务器·指令集·权限·用户态内核态
小涵6 小时前
企业SRE/DevOps向的精通Linux课程培训课程
linux·运维·devops·1024程序员节
航Hang*7 小时前
第1章:初识Linux系统——第8节:查看/修改权限控制和ACL
linux·运维·服务器·笔记·操作系统
一个处女座的程序猿O(∩_∩)O7 小时前
深入浅出 SSE:实现服务器向客户端的单向实时通信
运维·服务器
捷智算云服务7 小时前
DGX A100服务器常见故障解析与维修攻略
运维·服务器
安当加密7 小时前
基于TDE透明加密实现异地服务器间文件自动加密传输的实践与思考
运维·服务器