软硬链接
介绍
软链接
通过下图可以看出软链接和原始文件是两个独立的文件,因为软链接有着自己的inode编号:
具有独立的 inode ,也有独立的数据块,它的数据块里面保存的是指向的文件的路径,公用 inode
硬链接
通过下图可以看出硬链接和原始文件是同一个文件,因为二者的inode
编号是相同的,并且创建完硬链接后改变了原始文件的引用计数:
观察 inode 编号可以发现,软硬链接的区别:是否具有独立的Inode。
软链接具有独立的Inode:可以被当作独立的文件看待。
如果想删除一个软链接或者硬链接,可以使用删除命令rm
,也可以使用unlink
命令,例如删除上面的硬链接:
软硬链接的使用场景
如果对一个文件既创建了软链接,也创建了一个硬链接,那么删除原文件时,软链接将失效,但是硬链接不会:
此时再访问软链接指向的文件中的内容就会无效,但不会影响硬链接
因为对于存在硬链接的文件来说,删除原文件就是减少其引用计数,只要引用计数不为0,那么该文件就不会被认为失效,而创建硬链接会增加原文件的引用计数,所以此时删除原文件就只是让原文件的引用计数从2变为1,从而保证文件还在硬盘上存在
从上面删除文件的例子可以看出,使用硬链接可以做到对原文件的备份
我们新建一个目录,引用计数是2,新建一个普通文件,引用计数为1
empty目录里面有两个隐藏文件,其中一个是 . 表示当前路径,文件名不同,inode相同
在empty里面再新建一个目录dir,引用计数变为3,新建目录dir里面有隐藏文件 .. 表示上级路径
和empty的inode相同,相当于硬链接
linux不允许对目录新建硬链接(会形成环状路径)
下面看一下软链接的应用场景:
软链接就像windows下的快捷方式,路径直接跳转
当前目录下有一个test.cc文件,g++编译形成可执行程序,直接运行可以看到运行结果
但是上面的运行需要带./
限定才能正常运行a.out
文件,在linux命令行与环境变量提到之所以需要./
作为限定是因为Linux默认查找可执行文件的路径是/bin
路径下,而当前a.out
文件并不在该目录。当时解决这个问题的办法就是将a.out
移动到/bin
路径下
实际上,
/bin
也是一个软链接,该链接指向的原文件是/usr/bin
在软链接部分,就可以通过为a.out
创建软链接,再将软链接移动到/bin
路径下即可执行a.out
文件:此时的软链接指向的原文件需要使用绝对路径
这时候直接写a.out不用带./就可以运行test.txt程序了
通过上面的例子可以看出,软链接的作用主要是相当于一个快捷方式 ,软链接里面保存的是与文件所处路径的映射关系
动静态库
创建静态库与使用
我们自己封装了两个简单的库,stdio,string库,为了将我们的库给别人使用,并且不暴露源码,我们可以将这两个库制作成静态库
制作静态库的两种方式
方式一:将静态库放到/usr/lib64
目录下,将头文件放到/usr/include
目录下 (安装到系统里)
制作步骤如下:
1.将需要打包为静态库的.c文件编译生成.o文件
2.使用ar -rc lib库名.a 指定的.o文件生成静态库,注意静态库的名称一定要以lib开头,后缀为.a
3.将头文件使用cp命令拷贝到/usr/include目录下,使用cp命令将静态库拷贝到/usr/lib64目录下。
这一步仅仅只是完成静态库的安装,如果直接编译要生成可执行程序的文件会出现链接报错
4.编译要生成可执行程序的文件时使用gcc 文件名 -l+库名称(去掉lib和.a)
方法二: 通过编译器选项指定静态库路径,并且使用当前目录下的头文件
默认情况下,gcc不会在当前目录查找需要的静态库,也不会在指定目录中自动查找需要的静态库,所以需要指定静态库路径和静态库名称
当前目录下存在静态库、头文件和用于生成可执行程序的源文件:
使用下面的指令指定静态库所在位置:
gcc 文件名 -L路径 -l静态库(去掉lib和.a)
方式3:通过编译器选项指定头文件和静态库文件的位置
使用Makefile
自动化生成静态库和.o
文件
使用下面的指令指定头文件所在路径和静态库所在路径
gcc 文件名 -I.h所在位置 -L静态库路径 -l静态库名称(去掉lib和.a)
运行结果如下:
创建动态库与使用
创建动态库的命令不再是ar
而是直接使用gcc
,但是在生成动态库前必须保证.o
文件具有绝对地址,即对.o
文件的生成方式需要改变:使用-fPIC
选项生成带有与位置无关码的.o
文件,即:
运行结果如下
动态库前两种创建方式与静态库一样
第三种创建方式与静态库不同
gcc编译动态库时,并没有把代码加载到程序里,静态库加载到程序里面了,直接运行即可。
动态库没有加载到程序里,运行时找不到 libmystdio ,系统找动态库默认从lib64找
如何给系统指定路径,查找自己的动态库:
1.拷贝到系统默认路径下(与静态库使用第一种方法相同)
2.在系统路径,建立软链接
3.linux系统中,OS查找动态库,环境变量,LD_LIBRARY_PATH
4. ldconfig⽅案:配置/ etc/ld.so.conf.d/ ,ldconfig更新 (系统级别)
动静态库同时使用的细节
1.同时存在动静态库时,gcc/g++默认使用动态库
如果想使用静态库,编译时应该带上 -static
2.如果强制静态链接,必须提供对应的静态库
3.如果只提供静态库,但是连接方式是动态链接的,gcc/g++只能针对.a局部性采用静态链接
动态库的加载
先探讨⼀下编译和链接的整个过程,来更好的理解动静态库的使用原理
ELF的形成与加载
编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。在编译之后会⽣成两个扩展名为 .o 的文件,它们被称作目标文件
目标文件是⼀个⼆进制的文件,文件的格式是 ELF ,是对⼆进制代码的⼀种封装
ELF文件
以下四种都是ELF文件:
1.可重定位文件(xxx.o文件) 2.可执行程序 3.共享目标文件(.so文件) 4.内核转储(core dumps)
ELF文件由以下四部分组成:
ELF头(ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位文件的其他部分。
• 程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表⾥
记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,需要段表的描述信息,才能把他们每个段分割开。
• 节头表(Section header table) :包含对节(sections)的描述。
• 节(Section ):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等
最常见的节:
代码节(.text) : 用于保存机器指令,是程序的主要执行部分
数据节(.data) :保存已初始化的全局变量和局部静态变量
链接就是将一个一个的相同属性的section合并
对任何一个文件,文件的内容就是一个巨大的“一维数组”,标识文件任何一个区域,用偏移量+大小的方式
动态库的加载
使用动态库的可执行程序在调用动态库中的方法时需要知道动态库的地址,动态库还未加载到内存之前,先使用一些内容进行占位,等到执行到指定的动态库代码再加载动态库,此时就形成了动态库的虚拟地址和物理地址映射关系,根据这个虚拟地址替换掉进程中调用动态库代码的占位内容即可。这个过程也被称为地址重定位
看似上面的思路好像没问题,实际上,虚拟地址空间的代码区是不可写的,也就是说,如果进程的代码加载到虚拟地址空间就不无法再更改其中的内容,那么此时又是如何做到使用动态库加载到内存之后的虚拟地址替换进程调用动态库代码的位置的内容
其实,进程调用动态库代码的位置的内容并不是直接写动态库的地址,而是写入一个GOT表的地址,这个表中存储的就是指定动态库和对应的虚拟地址的映射关系,进程在调用动态库代码的位置此时只需要写上调用的是GOT表中的哪一个动态库的下标即可,剩下的就交给GOT表来进行,即当动态库加载到内存后,虚拟地址填充到GOT表指定动态库对应下标即可。这也就是所谓的「生成与位置无关码」
所以,一个动态库之所以可以只加载一次而可以被任何进程所调用,本质就是因为这个GOT表,只需要知道这个GOT表的地址和对应库的下标,即可调用对应动态库中的内容
重谈地址空间--可执行程序,加载问题
可执行程序是有地址的
CPU要执行进程中的代码,就需要知道对应代码的地址,所以在磁盘的可执行程序中,尽管其未加载到内存,但是在编译链接时就已经形成了地址,使用下面的指令对main
程序进行反汇编可以看到每一个步骤对应的虚拟地址
注意,不是物理地址,因为此时可执行程序还没有被加载到内存,只有被加载到内存后,才有物理地址。程序在加载到内存之前只有虚拟地址或逻辑地址,只有在加载到物理内存后才会被分配对应的物理地址。
objdump -S指令显示目标文件的详细信息
平坦模式:逻辑地址=起始地址+偏移量
平坦模式(Flat Mode)是指在计算机系统中的一种内存管理模式,其中整个地址空间被看作是单一的、连续的线性空间。在这种模式下,所有代码和数据都位于一个大的、平坦的地址范围内,没有分段或分区的概念。这种模式简化了编程模型,使得编译器和程序员不需要处理复杂的段选择符或偏移量计算
ELF在没有加载到内存的时候就已经按照[000...000,FFF...FFF](虚拟地址)进行编址了
结论:编译器编译,就已经形成虚拟地址了
当可执行程序加载到内存之后,其ELF中的LOAD部分的内容就会分别被加载到指定的区域,例如.text的内容被加载到代码区、.data的内容被加载到数据区等,这个过程就完成了虚拟地址空间的初始化
但是只有初始化还不够,为了保证物理地址和可执行程序的虚拟地址可以匹配,此时就需要页表进行对应的映射
上面整个过程完成,一个可执行程序就从硬盘加载到内存,变为了一个可以被CPU调度的进程
接着,CPU要执行这个进程,PC寄存器就需要找到第一条指令的地址(即找到入口地址),这个地址在ELF头中可以看到Entry point address字段:
但是这个地址依旧是虚拟地址,所以依旧需要使用页表进行映射,对应着的就是反汇编代码中的<_start>地址(此处<_start>相当于关于C语言函数栈帧:main函数被其他函数调用的__tmainCRTStartup())
所以,不论是进程还是CPU的PC寄存器,二者访问到的都是虚拟地址,但是这个虚拟地址要和物理地址在页表中建立映射关系。同时CPU内部还有一个寄存器,称为CR3寄存器,其中存储的就是页表本身的物理地址,这个寄存器是操作系统本身使用的。有了CR3寄存器后,就需要一个硬件配合其完成查表的工作,这个硬件称为MMU,也是在CPU内部。还有一个寄存器EIP,将pc指针中的虚拟地址,通过MMU查表转化为物理地址
虚拟空间是操作系统,CPU,编译器协作下的产物,通过上面的过程,再次思考为什么需要有虚拟地址和虚拟地址空间:编译器在编译代码时不再需要考虑物理内存,完成操作系统和编译器进行解耦合。
理解虚拟地址空间的区域划分
前面提到,虚拟地址空间初始化时会由ELF文件中的内容对指定区域进行初始化,但是并没有看到ELF文件中存在对栈、堆和共享区进行初始化的部分,这些部分如何进行的初始化就是下面需要讨论的问题
实际上,虚拟地址空间中还存在一个结构,称为vm_area_struct,即虚拟区域结构,其对应的部分源码如下:
struct vm_area_struct {struct mm_struct * vm_mm; /* The address space we belong to. */unsigned long vm_start; /* Our start address within vm_mm. */unsigned long vm_end; /* The first byte after our end addresswithin vm_mm. *//* linked list of VM areas per task, sorted by address */struct vm_area_struct *vm_next;
};
真正的栈、堆和共享区都是vm_area_struct结构对象,有着自己的vm_start和vm_end用于标记区域的开始和结束,每一个vm_area_struct结构对象通过链表进行链接。所以,CPU在访问栈、堆和共享区时实际上访问的也是对应的vm_area_struct对象的虚拟地址,在页表中也存在着这些虚拟地址和物理地址的映射
有了上面这种思想,当一个可执行程序有很多内容时,操作系统可以考虑先加载一部分的Section形成vm_area_struct对象,再根据需要加载后面的Section,这也就实现了Section的懒加载
所以,如果有多个动态库需要加载,本质上就是创建一个vm_area_struct结构对象链接到指定的区域