自学内容网 自学内容网

【Linux】动静态库


了解动静态库,理解程序是如何运行的。

什么是库

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

本质上来说库是⼀种可执行代码的⼆进制形式,可以被操作系统载入内存执行。库有两种:静态库和动态库,在不同系统中,其后缀有所区别。

  • Linux中,.a后缀为静态库,.so后缀为动态库
  • Windows中,.lib后缀为静态库,.dll后缀为动态库

而库的命名也是有规范的:
无论动静态库,都必须以lib开头,紧接着是库名,最后为区别类型的后缀,如C++动态库:libstdc++.so.6stdc++为去掉前后缀的库名。

库的基本原理

在编写C/C++代码时,第一件事就是将头文件包含进来#include...,常见的头文件有stdio.h stdlib.h都知道头文件包含了函数的声明,有了这份声明,就可以调用对应函数,如printf,我们并没有实现printf这个函数;但实际上这个printf的实现已经在库中了,所以可以直接调用。正是由于库中包含了大量函数的实现,所以可以通过包含对应头文件来使用对应方法。

一堆源文件和头文件最终变成一个可执行程序需要经历以下四个步骤:

  1. 预处理: 完成头文件展开、去注释、宏替换、条件编译等,最终形成xxx.i文件。
  2. 编译: 完成词法分析、语法分析、语义分析、符号汇总等,检查无误后将代码翻译成汇编指令,最终形成xxx.s文件。
  3. 汇编: 将汇编指令转换成二进制指令,最终形成xxx.o文件。
  4. 链接: 将生成的各个xxx.o文件进行链接,最终形成可执行程序。

自己所写的源文件需要经过以上步骤才能变为可执行程序。其中的链接就是指将我们源文件中的所调用的库函数链接进来。所以,库中包含的是各个函数实现的.o文件

动静态库介绍

动静态库是软件开发中常用的两种库类型,它们各自具有独特的特点和用途。以下是对动静态库的详细介绍:

静态库(Static Library)

定义:
静态库是在程序编译时,将库中的代码直接复制到最终的可执行文件中的库类型。

特点:

  1. 编译时链接:静态库在编译时期就被链接到应用程序中,生成的可执行文件包含了所有必要的库代码。
  2. 独立性:由于静态库中的代码被复制到最终的可执行文件中,因此程序运行时不再依赖于静态库文件。
  3. 内存占用:如果多个程序都使用了相同的静态库,每个程序都会有一份库代码的副本,这可能导致内存占用较大。
  4. 更新与部署:当静态库更新时,所有使用它的程序都需要重新编译。

优点:

  • 无需考虑库的版本兼容性问题,因为库代码已经嵌入到程序中
  • 程序运行时不依赖于外部库文件,提高了程序的独立性。

缺点:

  • 增加了最终可执行文件的大小。
  • 当库更新时,需要重新编译所有使用它的程序。

查看静态库

在Ubuntu中,C/C++的静态库一般在以下路径中:可使用ls指令查看,

  • C++:ls -l /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a
  • C:ls -l /lib/x86_64-linux-gnu/libc.a

静态库

动态库(Dynamic Library)

定义:
动态库是在程序运行时被加载到内存中的库类型,多个程序可以共享同一个动态库文件。

特点:

  1. 运行时链接动态库在程序运行时才被加载到内存中,因此程序在编译时不需要包含库代码。(留意一下这个特性)
  2. 共享性:多个程序可以共享同一个动态库文件,这可以节省内存和磁盘空间。
  3. 更新与部署:当动态库更新时,只需要替换库文件,而无需重新编译使用它的程序(除非库的接口发生变化)。

优点:

  • 节省了内存和磁盘空间,因为多个程序可以共享同一个动态库。
  • 当库更新时,只需替换库文件,无需重新编译所有使用它的程序。
  • 提高了代码的复用性和模块化程度。

缺点:

  • 程序运行时需要加载动态库,这可能会增加程序的启动时间。
  • 如果动态库文件丢失或损坏,程序将无法正常运行
  • 存在版本兼容性问题,需要确保程序使用的动态库版本与库文件相匹配。

查看动态库

在Ubuntu中,C/C++的动态库一般在以下路径中:可使用ls指令查看:

  • C++:ls -l /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so
  • C:ls -l /lib/x86_64-linux-gnu/libc-2.31.so

动态库

总结
动静态库各有优缺点,选择使用哪种库类型取决于具体的应用场景和需求。静态库适用于需要独立性、对性能要求较高且不需要频繁更新的场景;而动态库则适用于需要节省内存和磁盘空间、需要频繁更新库且多个程序共享同一个库的场景。

最大的不同就是两个库的加载时间不同,静态库是在编译时就链接,直接将库中对应代码复制到最终的可执行文件中,所以该执行程序体积比较大,但是独立性强。动态库则是在运行时链接,不需要将代码复制到可执行程序,但是十分依赖动态库,动态库一旦出问题,该可执行程序也无法继续执行。
动静态链接

  • gcc/g++默认为动态链接,如要使用静态链接,带上-static选项

静态库的制作及使用

为了方便演示,将之前的C语言文件流的模拟实现拿过来制作一个静态库:mystdio.hmystdio.c,再加个模拟实现的strlen:mystring.hmystring.c

静态库制作

制作

制作一个静态库有两步:

  1. 将所要实现的函数预处理编译汇编成.o文件
  2. .o文件打包形成成静态库如:libxxx.a

1:让所有源文件生成对应的目标文件

gcc -c mystring.c mystdio.c
  • 使用gcc -c将源文件形成目标文件.o

.o文件

2:使用ar命令将所有目标文件打包为静态库

使用ar -rc libmystr.a mystdio.o mystring.o形成静态库

  • argnu归档⼯具, 选项rc 表示 (replace and create)常用于将目标文件打包为静态库

.a

此外,我们可以用ar命令的-t选项和-v选项查看静态库当中的文件

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

查看静态库

至此一个简易的静态库就完成了。(该库属于第三方库)

使用

使用以下代码进行演示

#include"mystdio.h"
//通过自己实现的MY_fopen向log.txt中写内容
// int main()
// {
//     MY_FILE*pf=MY_fopen("log.txt","w");
//     if(!pf)
//     {
//         perror("MY_fopen failed\n");
//         return -1;
//     }

//     int cnt=5;
//     //const char*str="hello C\n";
//     const char* str="abcdefghijklnmopqrstuvwxwz";//26
    
//     // while(cnt--)
//     // {
//     //     MY_fwrite(str,strlen(str),1,pf);
//     // }

//     MY_fwrite(str,strlen(str),1,pf);
//     MY_fclose(pf);

//     return 0;
// }

//使用MY_fopen读取文件中的内容,并且使用MY_strlen计算读取内容的长度。
int main()
{
    MY_FILE*pf=MY_fopen("log.txt","r");
    if(!pf)
    {
        perror("MY_fopen failed\n");
        return -1;
    }
    char buffer[64];
    size_t sz=MY_fread(buffer,sizeof(buffer),1,pf);
    //printf("%s",buffer);
    size_t x=MY_strlen(buffer);
    printf("str length:%d\n",x);

    MY_fclose(pf);

    return 0;
}

如果此时直接进行gcc编译,会发现编译报错,理由是未定义该函数,也就是说找不到该函数的声明;
使用

这是因为编译器不认识第三方库(需要提供第三方库的路径及库名),编译器只能认识标准库;如Linux操作系统自带的库,包括内核库、标准C库(如glibc)、数学库等。如果你要是用第三方库,就必须告诉编译器。

根据第三方库是否在系统路径中,可以分为两种使用方式:

第三方库不在系统路径中

使用gcc编译时,需要使用以下选项:

  • -L: 指定库路径
  • -I: 指定头文件搜索路径
  • -l: 指定库名:注意去掉前后缀

开始前先观察一下此时的文件结构,注意头文件include,标准库lib的位置

目录
源文件test.c位于Lesson5中,头文件include,标准库lib分别位于Lesson5/mystdlib中。

对于不在系统路径的第三方库,使用gcc -o test test.c -Iinclude -Llib -lmystr
三方库

第三方库在系统路径中

首先将对应的头文件,静态库添加到系统路径中。
将头文件加入头文件系统目录/usr/includesudo cp include/*.h /usr/include/
将第三方库加入库系统目录/libsudo cp lib/*.a /lib

此时仍需要告诉编译器所使用的库叫什么名字,否则也找不到。

静态库

动态库的制作和使用

动态库的使用和静态库的使用一致,但是对于动态库的制作有所不同

制作

制作一个动态库有两步:

  1. 将所要实现的函数预处理编译汇编成.o文件,但是此时需要带上fPIC:产生位置无关码(position independent code)
  2. .o文件打包形成成动态库如:libxxx.so

让所有源文件生成对应的目标文件

使用:gcc -c mystring.c mystdio.c -fPIC生成动态库所需的.o目标文件

  • -fPIC:这个选项告诉编译器生成可以在内存中的任何位置运行的代码。这通常用于创建共享库(动态链接库),因为共享库在加载到内存时,其加载地址可能不是固定的。生成与位置无关的代码可以确保库中的代码和数据无论被加载到内存的哪个位置都能正确运行

使用gcc -shared生成动态库

使用: gcc -o libmystr.so mystring.o mystdio.o -shared生成对应动态库

  • 后面libmystr.so移到了lib目录下

动态库打包

使用

第三方动态库编译器也是不认识的。也需要带上ILl选项:

  • -I: 指定头文件搜索路径
  • -L: 指定库路径
  • -l: 指定库名:注意去掉前后缀

位于系统目录中

将对应头文件,动态库加入系统目录,与静态库方法一致

$ sudo cp include/*.h /usr/include/
$ sudo cp lib/*.so /lib

使用gcc -l编译,即便加入系统路径,但是还是需要使用-l指明是哪一个库
动态库

不在系统路径中

此时再使用gcc -o test test.c -Iinclude -Llib -lmystr,发现编译通过了,但是在运行时却出错了。报错提示为无法找到该目录;使用ldd查看可执行程序的链接信息发现动态库没有找到

gcc
这是因为你在编译时的确告诉了gcc所要使用动态库的位置;所以编译通过了;但是在运行时,程序变为进程,操作系统只会在系统路径中找对应的库,此时我们所写的库并不在系统路径中,所以OS就找不到具体的库,也就显示找不到了 这也是动态库的第一个特性导致的。

对此,我们提供两种解决方案:

一:将动态库加入系统目录中

加入系统目录
此时,OS就能找到对应的动态库了
ldd

二:环境变量 LD_LIBRARY_PATH

添加环境变量LD_LIBRARY_PATH(默认没有这个环境变量)并将第三方动态库路径添加至此环境变量中;

但是在命令行中export的环境变量在下一次登录时就失效了。所以需要把环境变量LD_LIBRARY_PATH加入到配置文件中,这样就能在每一次登录后添加到环境变量中。

如果你希望永久为所有用户设置环境变量,可以编辑系统配置文件,如 /etc/environment 或 /etc/profile。这些更改会影响所有用户的会话。

这里采用使用vim修改配置文件/etc/environment为例:sudo vim /etc/environment
修改配置

  • 必须是完整路径,从根目录/开始。
  • 注意:在修改环境变量时,需要注意是否保留以前的配置。
  • 保存并退出编辑器后,这些更改将在下次系统启动或用户登录时生效。

此后,OS就能找到对应的动态库了。
ldd

动态库链接

根据动静态库的特点,我们已经知道静态库链接会直接把对应函数的.o目标文件直接拷贝到源文件中,所以静态链接的可执行程序大小会比动态链接的可执行程序大
动静态链接
那么动态链接是如何将.o目标文件和程序目标文件连接起来的呢?

回顾动态库的定义:

动态库是在程序运行时被加载到内存中的库类型,多个程序可以共享同一个动态库文件

加载到内存中,多个程序,同一个动态库,这些关键字眼不难发现,动态库的的加载与链接和进程地址空间是有关的。

实际中当程序开始运行时,操作系统会为其创建一个进程,并初始化进程地址空间,当程序执行到需要调用动态库中的函数或数据时,操作系统会识别出这一需求,操作系统根据动态库的路径,找到动态库文件,并将其加载到内存中(共享区)。加载过程中,动态库会被映射到进程地址空间的共享区中,以便多个进程共享

动态链接

而动态库会被加载到共享区,由此一来,当cpu执行可执行程序时,在正文段开始执行代码(指令),当需要进行动态链接时,就会跳转至共享区,再通过页表和MMU进行虚拟地址到真实物理地址的转化找到对应内容,之后再放回正文段继续执行剩余代码(指令);这样一来,程序执行的任何代码都是在自己的进程地址空间中执行。

动态库的地址

在编译动态库的.o目标文件时,使用了-fPIC——产生位置无关码(position independent code)选项;这是为什么呢?

动态库的特点之一为:运行时链接;动态库在程序运行时才被加载到内存中,因此程序在编译时不需要包含库代码。既然如此动态库应该加载多少到内存中呢?(OS运行时可不止一个进程在跑)而且你也没办法知道你到底需要多少个动态库中的方法,所以动态库是没有办法采用绝对地址的方式来加载动态库的。

所以动态库加载到内存中采用的是起始位置加偏移量的方式;这样一来,只需要知道动态库的起始位置,对应方法就可以通过起始地址+偏移量的方式,加载到内存中的任意位置了。所以在编译.o文件时,使用-fPIC告诉编译器这是一个动态库的方法,采用起始位置+偏移量的方式。

进程地址空间

在之前的博客中就对进程地址空间进行过简略的介绍——进程地址空间,现在再对一些细节进行推敲。如:mm_struct的虚拟地址是什么时候初始化的?CPU是如何执行可执行程序的?

没有加载前程序的地址

编译好一个可执行程序后,它静静的躺在磁盘中;当程序还没有被加载到内存中时,它本身并不具有物理内存地址。然而,程序在编译后,其内部确实包含了一些地址相关的信息,但这些地址是逻辑地址或虚拟地址(平坦模式编址下,可以认为是一致的),具体来说,当程序被编译后,编译器会为程序中的变量、函数等元素分配逻辑地址。这些逻辑地址是在编译时确定的,用于在程序内部进行引用和跳转。

使用objdump -S可以查看可执行程序的反汇编;平坦模式下,所有的段都被视为位于同一个连续的内存空间中,不再有明显的段边界。这简化了内存管理,因为不再需要处理复杂的段间保护和段间跳转。最左侧的就是逻辑地址,右侧的为指令的机器码。

所以,当程序被编译好之后,就已经有了地址的概念了。

反汇编

ELF格式

一个程序到底是如何跑起来的呢?它是怎么被cpu执行呢?这就需要简单了解一下ELF格式了

一个可执行程序是按照ELF格式编译的:(浅浅了解一下即可)

ELF格式是一种强大且灵活的文件格式,在Unix和类Unix系统中得到广泛应用。它为软件开发和系统编程提供了一种统一的文件格式,使得不同工具和平台之间能够更加容易地交互和协作。

⼀个ELF⽂件由以下四部分组成:

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

其中最常见的节:

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

ELF

ELF形成可执行

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

ELF文案
注意:

  • 实际合并是在链接时进行的,但是并不是这么简单的合并,也会涉及对库合并,此处不做过多追究。

其中,ELF的表头记录了相关信息,包括程序的入口地址,而在task_struct有指向这个入口地址的指针,这样一来CPU就能通过该入口地址执行该程序了。

使用readelf -h 可查看对应可执行程序的ELF格式的ELF头信息,其中会发现一个入口地址;

ELF表头

紧接着使用objdump -S 查看反汇编,发现确实是一个入口地址(不是main函数,main函数也是被调用的)继续往下看就能找到main函数了
反汇编

最终调用main函数

main函数

加载后程序的地址

通过上面的介绍就知道了,对于ELF格式的可执行程序,CPU能够通过task_struct中指向的ELF头中的入口地址信息加载该程序。

现在的问题就是mm_struct的虚拟地址是在什么时候初始化的?及,CPU读取的是什么地址?所以宏观的看待一下CPU是如何加载,执行程序的。

对于一个可执行程序而言,编译好之后就有对应的地址了(逻辑地址),当运行一个程序时,CPU会通过读取task_struct指向的程序入口地址,从而执行已经编译好的CPU能执行的指令(代码编译后会被转化为指令集,由CPU执行),当执行相关指令时,将读取的指令的地址通过页表+MMU(内存管理单元)到内存中查找对应内容,如果该内容还没被加载到内存中,就会引发缺页中断,将对应数据加载到内存中,并将CPU读取的地址与内存地址建立映射,填充页表,至此建立好虚拟地址与真实物理内存地址的映射关系。

程序加载

所以:CPU读取的是可执行程序在编译好时就已经形成的逻辑地址,也就是虚拟地址;当CPU顺序执行对应指令时,如果发生缺页中断就会将虚拟地址与物理地址通过页表建立好映射关系,所以mm_struct的虚拟地址就是在这个时候由内核完成填充的。


原文地址:https://blog.csdn.net/Mesar33/article/details/143904129

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!