Linux 文件 IO 管理(第四讲:软硬链接和动静态库)
软硬链接
操作与现象
仔细观察,现在的 link
目录下只有以下两个文件 :
接下来进行 软硬链接 :
软链接
执行以下 命令 :
ln -s file_target1.txt file_soft.link
运行 ll -i
命令,如下图 结果 :
硬链接
执行以下 命令 :
ln file_target2.txt file_hard.link
运行 ll -i
命令,如下图 结果 :
解释
注意观察上述 软硬链接 现象的结果:
-
在 软链接 后,第一列的
inode number
都是不一样的;但在 硬链接 后,相关联的两个文件inode number
似乎都一样,所以:- 软链接是一个独立的文件,因为
file_target1.txt
和file_soft.link
具有独立的inode number
,所以 软链接 的 文件类型 是l
(表示link
) - 硬链接不是一个独立的文件,因为
file_target2.txt
和file_hard.link
的inode number
都是2491846
,所以file_hard.link
并 不是一个独立的文件,而是使用目标文件的inode
- 软链接是一个独立的文件,因为
-
仔细观察第三列的数字,居然有变成
2
的文件- 其实这一列数字被叫做 硬链接数
软链接
软链接的内容 :目标文件所对应的路径字符串 ;未来我们要找到一个文件可以通过 软链接 的方式,也就是说,如果现在我们往文件 file_target1.txt
里写数据,也是可以通过 file_soft.link
查看的:
[exercise@localhost link]$ echo "Hello soft link" > file_target1.txt
[exercise@localhost link]$ cat file_soft.link
Hello soft link
[exercise@localhost link]$
所以 软链接 特别像 Windows 系统下的快捷方式 ;而 软链接 的删除是不会影响目标文件的,所以多少人在刚接触电脑时卸载软件是直接将快捷方式拖进回收站的呢 ^ ^
软链接 依附于目标文件,是一种依附关系,但它自己和目标文件是两个不同的文件;如果现在删除文件 file_target1.txt
,虽然文件 file_soft.link
还是存在的,只是此时的 软链接 没有了依附对象,根本没用:
所以你现在可以直接为系统里的某个可执行程序创建 软链接(快捷方式),运行起来嘎嘎简单(运行 软链接 的格式还是一样的,路径必须要带上)
硬链接
上面说 硬链接 不是一个独立的文件,那 硬链接 是什么?
某些操作上和 软链接 一样,往 file_target2.txt
写数据后,查看 file_hard.link
也可以看到一模一样的数据,这也可以理解,毕竟人家的 inode number
和目标文件是一样的
可是某些又不一样,比如我现在直接把文件 file_target2.txt
删除,会发现文件 file_hard.link
好好的,啥事也没有,你查看它和之前的结果是一样的
所以 硬链接操作 的现象图片里,被称作 硬链接数 的数字 本质上是个引用计数
啊那就明白了呀!那 硬链接 的本质就是:一个文件名和 inode
编号的映射关系 ;而 建立硬链接 ,就是 在指定目录下添加一个新的文件名和 inode number
的映射关系
所以 硬链接 根本就没创建新文件啊,只是将已存在的文件,再次取个别名;而这时候定然存在 引用计数 来表示有几个 文件名 正在和 同一个 inode number
进行映射,显然此 引用计数 就是 硬链接数 啊!
那么上面的现象也就好解释咯
作用
那 硬链接 有什么用呢?
先看下面的场景:
如果新创建一个 普通文件,那它的 硬链接数 一定是 1
,毋庸置疑;那如果是 目录文件 呢?硬链接数 就是 2
,为什么?
因为任何目录下都存在 .
和 ..
目录,新创建的 dir
也不例外,而此时的 dir
目录里就有 .
目录,和 dir
映射同一个 inode number
,所以硬链接数就是 2
运行如下指令查看 .
目录和 dir
目录的 inode number
是否一样:
ll dir -ai
所以啊,第一个作用 :构建 Linux 的路径结构,让我们可以使用 .
和 ..
来进行路径定位
这里 硬链接 是系统为目录建立的,但我们用户是无法使用 ln
命令手动为目录创建 硬链接
其实倒也可以理解,是为了避免形成 路径环绕 ,万一在哪一级子目录下 硬链接根目录 /
,那使用 tree
命令一定会造成死循环,也就是 路径环绕
当然啦,第二个作用就是 文件备份 咯
动静态库
初识
我们有没有用过库呢?
只要你写代码,就肯定用过的:用 C 语言 就离不开 C 语言的标准库;用 C++ 就离不开 C++ 的标准库
C 语言 strerror
,strstr
,strcpy
,memset
都用过吧?
C++ list
,vector
,stack
,queue
,map
,set
也都用过吧?
其实这些都是库里面人家提供好的函数和对象方法
那么问题来了:我们自己写一串代码都需要头文件声明,源文件实现,万一代码里含有 strcpy
函数,将来要想编译成功,就 必须得有这个函数的具体实现,可是我们用库的时候没怎么关心过怎么实现啊,从来没见过
其实就是在库里,不然为什么叫做 库函数 呢?就比如下面这一串代码:
#include <stdio.h>
#include <string.h>
int main()
{
char buffer[1024];
strcpy(buffer, "Hello C!");
printf("%s\n", buffer);
return 0;
}
使用了两个库函数 printf
和 strcpy
,这两个 库函数 都是 要有具体实现 才能跑起来,可是自己的代码并没有实现,但依然可以跑起来是因为 gcc
编译器默认帮我们链接了对应的库
什么库呢?我们可以使用 ldd
命令,作用 就是 帮我们找出可执行程序所依赖的库
编译上面的源代码变成 a.out
可执行程序,运行命令 ldd a.out
:
观察上图,第一行和第三行是系统自带的库,先不管,着重观察第二行的库:
libc.so.6 => /lib64/libc.so.6 (0x00007f8c25082000)
这就是传说中的 C 标准库 ,后面也标识了相关路径,咱们查看一下:
ll /lib64/libc.so.6
呀,看出来了,这是个 软链接 啊!链接的是文件 libc-2.17.so
,而此文件就是 C 语言的标准库的真身,来看一看是何方大能:
ll /lib64/libc-2.17.so
那么现在说白了,要想运行程序,必须要有此 标准库 和你自己的 可执行文件
而在之前的章节里,我们已经解释过 动静态库 的相关概念;
在 Windows 里,动态库的后缀是 .dll
,而静态的为 .lib
;在 Linux 里,动态库的后缀是 .so
,而静态的为 .a
;
而在编译的时候加上 -static
选项就是向编译器指明,这次编译后所 链接 的库均为 静态库;但如果不加就会优先选择 动态库,可是如果有需要 链接第三方静态库 时,不带上 -static
选项编译器也可以执行 链接
静态库
怎么做库(开发角度)
C 语言 项目里如果你有很多 源文件,那么经过编译器 预处理,编译,汇编 之后,会将项目里的众多 .c
源文件 变成 .o
目标文件(可重定位目标文件) ,那么再将相关 目标文件 进行 链接 变成可执行文件就可以执行了
为什么要说这个呢?其实 .h
头文件 就是一个使用手册,提供函数的声明,也告诉了用户怎么用;而 .o 文件提供实现方法
实际上只需要补上一个 含有 main
函数的 源文件 去调用 头文件 提供的方法,然后和 .o
进行 链接,就能形成 可执行程序
可是一个大型项目里包含的源文件何止十几个,上百个,成为 .o
目标文件之后,很容易遗漏,非常麻烦,怎么办?一个很通用的方式就是打包起来,但实际上还是需要解包然后进行链接,有没有可以直接使用这些 .o
目标文件的方法?有, ar
命令( 建立静态库的命令 ):
ar -rc libmyc.a *.o
那么 libmyc.a
此时就是一个小型的 静态库,它涵盖了所有必要的 .o
文件,那么所谓的 库文件,本质:就是将所有必要的 .o
文件打包
库本身就是提供方法的源头,是让后人站在你的肩膀上继续开发,为的就是 提高开发效率
理解了上面的内容之后,其实上面的建库工作并不专业,我们需要将头文件归类,库文件归类:
比如现在我们创建出一个 mylib
的目录,里面包含 include
和 lib
目录,而在 include
目录下包含该库所有的头文件,lib
目录下包含所有的 .a
静态库,如下图所示:
怎么用库(使用角度)
很显然啊,你现在已经手握 静态库 libmyc.a
,但这时候你要用依然是要把 静态库 直接链接 在 gcc/g++
的选项里,只是怎么样才能正常使用呢?有两种方法
安装
你需要把 include
目录下的所有头文件放在系统的头文件目录下,把 .a
静态库文件 放在系统的库文件目录下,此时还不够,因为 gcc/g++
是默认认识 C/C++
的库,而 libmyc.a
本质上是第三方库, gcc/g++
根本就不认识,所以编译时需要带上选项 -l
,表示 指定第三方库名称
但 gcc/g++
又认为,库文件的文件名是有格式的,对于 libmyc.a
来说,lib
是前缀,.a
是后缀,所以真正的名字就是 myc
,所以此时若是在 main.c
里调用此库里的相关函数,进行 编译链接 就是如下命令:
gcc main.c -lmyc
当前目录直接使用
就拿上面的 mylib
来说,直接在当前目录下进行使用;若是直接 gcc main.c
,会导致 gcc
根本找不到 对应头文件,所以你需要 -I
选项来 指明用户自定义头文件的路径:
gcc main.c -I ./mylib/include
可是这样还不够,因为找到了 头文件,找不到 库文件 呐,导致的 链接报错
那就还需要 -L
选项来 指明必要的自定义库文件路径 :
gcc main.c -I ./mylib/include -L ./mylib/lib/
可是依然不够,因为你并没有告诉编译器你要链接此路径下的哪个库文件啊,所以像安装那样来表明要链接的库名,也就是 -lmyc
选项,所以 最终编译命令 为:
gcc main.c -I ./mylib/include -L ./mylib/lib/ -lmyc
动态库
动态库是我们开发当中用的种类最多的,最频繁的,主要是因为 静态库 形成的可执行程序体量太大了,不便于下载,所以学习 动态库 非常有必要
怎么做库(开发角度)
和 静态库 一样,我们仍然需要将 .c
源文件 经过 预处理、编译、汇编 之后成为 .o
目标文件 ,但咱们却要带上一个选项 -fPIC
,这个选项的含义我们后面再解释,先看命令:
gcc -fPIC -c mystdio.c
当然啦,和 静态库 一样,我们依然需要将 .o
目标文件 打包形成 动态库,而如此常用的 动态库 于 gcc
而言也是至关重要的,所以 gcc
也能做形成 动态库 的工作 ,但这毕竟要区别于形成 可执行程序 的工作,所以要带上选项 -shared
表明要形成 动态库,如下:
gcc *.o -o libmyc.so -shared
如果再将此库拷贝进 mylib/lib/
目录下,那现在 动静态库 就都有了
怎么用库(使用角度)
显然啊,也是可以分为 安装使用 和 在当前目录下使用 ,但在讲解 静态库 的时候就发现,安装也无非是为了让 gcc
精准找到对应的 第三方头文件路径 和 第三方库路径 ,因为安装之后也只是把 相应头文件和库 放在了系统和 gcc
默认的路径下罢了,所以就不做讲解了;
而 在当前目录下使用 的情况下,也可以在 gcc
后面 带上选项来指明 相应头文件和库路径 ,那么和 静态库 的使用是一样的:
gcc main.c -I mylib/include/ -L mylib/lib -lmyc
那这命令和 静态库 那个命令不是一样的吗?
是啊,就是一样的!在 只有静态库 的情况下,虽然没有 -static
选项, gcc
也只能链接 静态库 啊,不然程序跑不起来啊!而现在路径下已经有了名为 myc
的 动态库,那肯定就 优先链接动态库 咯(没有 -static
选项)
好了现在可以编译成功了,也可以看到可执行文件 a.out
,但如果你运行,肯定会报错,咱们不妨来看看 ldd a.out
命令结果:
怎么回事,为啥没找到这个动态库?不是已经编译成功了吗,甚至都已经链接到了,为啥没找到?不妨再来看看运行什么结果:
[exercise@localhost lib]$ ./a.out
./a.out: error while loading shared libraries: libmyc.so: cannot open shared object file: No such file or directory
[exercise@localhost lib]$
好像还是没找到库的意思,为什么呢?
你是把库的所有信息告诉了 编译器 gcc/g++
, 但你没有告诉 OS;为什么要告诉 OS 啊?你告诉了编译器 gcc
之后,编译器也确实帮你形成了可执行程序啊,它的工作已经完成了,当你 ./a.out
运行的时候和 gcc
一点关系都没有的,那和谁有关系啊?想运行变成进程就和 OS 离不开
因为这就是 动态库 的工作机制,在程序开始运行的时候,要找到动态库加载并运行,程序都要运行了,谁去找?当然是 OS;那为啥 静态库 没这么多问题,因为 静态库 的工作机制就是 在编译期间,已经将库中的代码拷贝到我们的可执行程序内部了,所以对于 静态库 而言,程序后续加载就和库没有关系了
所以你还要告诉 OS 动态库在哪,怎么告诉它?
- 动态库 也是有自己的系统默认路径的,就在
/lib64
目录下,如果把 第三方动态库 直接放在系统的目录下,OS 就会自动在该路径下寻找,当然可以实现,但是太简单粗暴,不太好 - 当然还是要从系统默认路径下手,只是不像上一个方法那么直接,咱可以在
/lib64
创建 软链接 ,让这个软连接可以找到此库 - 利用 环境变量 :
LD_LIBRARY_PATH
就是 加载库路径 ,可以使用echo $LD_LIBRARY_PATH
命令查看
那我们就着重讲解第三个方法,使用 echo $LD_LIBRARY_PATH
命令后可查看系统 动态库 的默认路径
现在将你的 动态库路径 加进去即可:
LD_LIBRARY_PATH=$LD_LIBRARY_PATH:你的动态库所在路径
那么现在 就能跑起来了,运行程序也能找到库的位置咯(ldd a.out
):
但请记住,这样的修改依然是内存级的,也就是只在当前 bash 有效,如果关闭重新启动一个终端就会失效,要想长久的可以认识这个库的位置,得修改配置文件咯(在用户的家目录下有配置文件 .bashrc
),当然这是 用户级别 的配置文件,还可以修改 系统级别 的配置文件(在 /etc/ld.so.conf.d
路径下新建配置文件,以 .conf
后缀结尾,里面直接添加动态库路径即可,保存退出并运行命令 ldconfig
即可生效)
动态库的加载
动态库整体使用轮廓
首先一个 可执行程序 a.out
本身是一个文件,没有被打开运行就是在磁盘上存放;当然,动态库 文件也一样
可是在 a.out
程序里要用到 动态库 的函数,比如 C 标准库;那么想要运行此程序就不能只加载 a.out
进内存对吧?库也得加载进来,可是动态库那么多,OS 怎么知道程序需要什么动态库呢?所以可执行文件和库文件之间一定有某种微妙的关联,细节我们后面说;
可是问题来了,a.out
程序运行起来变成进程之后,代码部分自然是放在 地址空间的代码区,可是库里的代码放在哪里?其实是 地址空间里堆栈之间的共享区,我们后面再详说;而 动态库 也是 共享库 ,不止你一个程序要用,也不只你一个用户要用,内存里只要加载一份动态库文件即可 啊
显然现在可执行文件和库文件的实体代码被加载到了物理内存,同时通过页表,完成物理内存和虚拟内存之间的映射关系,此时进程在 代码区 要是遇到使用库函数的语句,直接跳转至 地址空间里堆栈之间的共享区 继续运行即可
理解程序加载
为了便于理解,先写一段简单的代码:
#include <stdio.h>
int Sum(int num)
{
int total = num;
while (num--)
total += num;
return total;
}
int main()
{
int n = 100;
int ret = Sum(n);
printf("result : %d\n", ret);
return 0;
}
这段代码再简单不过了,编译成为可执行程序之后,运行也没有任何问题,就是 5050 ,但需要进行反汇编来查看这段代码的详细地址情况,下面的命令可以将反汇编的结果重定向到文件 code.s
中,再顺便用 vim
打开:
objdump -S code.exe > code.s
仔细观察上图看结论
我们的可执行程序在编译成功后在没有被加载运行的状态下,二进制代码里是有 地址 的,如上图所示:文件里面已经包含了大量的地址;而我们的代码经过编码之后,所有的函数都是被经过编址的,里面的每一条指令都是有自己的地址的,也是从上往下依次进行编址;于是在二进制文件里,该代码就已经被分好了代码区,数据区等等
也正是因为有了地址,我们直接看源代码就可以在大脑中模拟出 CPU 运行程序的状态;而我们在代码里定义出来的许多变量函数等等,在变成可执行文件之后是没有变量名的说法的,所有的变量名会全部被替换成为地址,所以对于计算机底层而言,无非就是疯狂的寻址,去 callq
调用,去 jmp
跳转,最后对相应地址的空间进行操作
我们说可执行程序编译之后,会变成很多行汇编语句或机器代码,但不管怎么样,上图中也看到了,每条汇编语句都会有自己的地址,问题又来了,程序还没加载呢,你这地址是怎么回事啊?什么地址呀?咱在磁盘上还没被打开,和内存还没打交道呢,那这又是如何编址的呢?
上图可以看到,不论是在 Sum
函数还是 main
函数里面,语句都是接着函数名继续编址的,也就是说这是一种绝对的编址,从 0000...0000
到 FFFF....FFFF
进行 绝对编址,线性增长,这种做法被叫做 平坦模式 ,在物理内存地址里敢直接这么编吗?不敢的!!!所以 这地址就是地址空间里的虚拟地址(目前还不太准确,严格意义上说是逻辑地址,只是在平坦模式里是等价的) 啊;而实际上也有数据区代码区之类的,在区内从零编址,这就是 偏移量
一个可执行程序除了有自己的源代码,还要有管理信息;在 Linux 中形成的可执行程序一般都是 ELF 格式 ,说白了就是代码被编译好之后有自己的固定格式,这个 ELF 格式 会有自己对应的头部,里面包含很多可执行程序的属性,很显然这就是 管理信息;在加载可执行程序数据之前, Linux 会先加载一个 加载器,实际上就是加载一个 动态库(/lib64/ld-linux-x86-64.so.2 (0x00007f33cc3f0000)
)到内存里,执行这个库里的方法,把我们的可执行程序拷贝到内存里,这个 加载器 会解释该头部的 管理信息,扫描代码找到 main
函数的入口,并根据头部信息加载程序的各个区域,如代码区,数据区等等
那么 ELF 和 加载器 一起就可以 进行动态计算,完成各个区域的起始和结束地址的划分和获取 main 函数的入口地址 ,把数据送给 OS 为创建对应的进程做准备
但是 进程 = 内核数据结构 + 代码和数据,而创建一个进程一定是先创建其内核数据结构,再加载代码和数据;所以会先创建 PCB ,地址空间 mm_struct
,页表等等内容,而 mm_struct
是个结构体对象,里面是含有成员变量的,里面的初始值是哪来的呢?其实根据上面的推断,可执行程序本身 就有 各个区域的起始和结束地址 ,这是头部信息和加载器的工作呀!!!
所以 虚拟地址空间 这个玩意,不是 OS 独有的,而是 OS ,编译器,加载器都是要支持的设计标准
那么 PCB
和 地址空间 初始值都搞定了,还剩下 页表 咯,而 页表 并不着急进行映射,因为可执行程序还没被加载进来,怎么映射?好,那加载可执行程序简单啊,加载器 直接完成嘛,那么加载完成之后,每条语句都有自己的物理内存地址,因为代码也是数据嘛,很好理解,那么当前可执行程序里每一条语句的状态,都是有俩地址,一个是地址空间里的虚拟地址,一个是物理内存的实际地址,那么现在才有资格来初始化 页表 咯,进行 范围型映射 ,至此进程创建彻底完成,CPU 根据 PC 指针寄存器 开始执行机器指令,而第一条 main
函数入口地址从哪来?OS 早就通过通过 加载器 获取了嘛,也说明 CPU 里执行的地址也是 虚拟地址 , 都是要经过页表映射 的!!!
理解动态库加载
咱是不是讲岔了?咱不是要讲 动态库的加载 吗?没错,要想理解 动态库加载,理解 可执行程序的加载 是 基石
理解了 可执行程序的加载 过程后,那么现在假如现有一可执行程序 a.out
,里面使用了动态库 libmyc.so
的函数,现在都在磁盘上还未被打开运行
而动态库是怎么形成的大家还记得吗,依然是通过 gcc
的选项 -shared
完成的对吧?那这个选项的对象是谁呢?是 .o
的目标文件,而 gcc
在生成这些目标文件的时候也做了手脚,加了选项 -fPIC
,这个选项的作用是:产生位置无关码 ,这是什么意思呢?
其实你也可以对 动态库 进行 反汇编观察,里面的每一条语句也会有地址,但这里的地址却比较小,其实是 相对地址,讲白了,每条语句前的地址就是相对库起始地址的偏移量,未来想要找到库里的每一条语句,只需要用:库的起始地址 + 对应语句的偏移量 既可寻到,而在可执行程序的代码里,对该函数的调用也是使用的此地址
而接下来 可执行程序就要加载进内存 了,怎么加载上面也已解释清楚,那么 PCB、地址空间和页表 均已初始化完毕,CPU 开始执行 a.out
程序,当 CPU 按行执行代码执行到需要调用 动态库 libmyc.so
里的函数时(假如 OS 发现库还没有被加载),页表映射就会失败,发生 缺页中断,那么 OS 就会把 动态库 libmyc.so
加载进物理内存
只要把库加载进物理内存,库的每一行汇编指令的地址和库的起始地址就都有了,既然进程要用库,就要把库的位置映射到该进程的地址空间里,毕竟进程不能直接使用物理内存的地址,那么 OS 就会在当前要使用该库进程的堆栈区之间申请一段虚拟地址空间范围(位于共享区范围内),申请多大空间取决于库的大小,那么这片虚拟地址的起始地址也就有了,接下来就可以将申请来的地址和实际库的物理内存地址进行映射,构建映射关系之后,该进程的共享区内就有了库的虚拟地址
那么 CPU 就可以继续执行下去了,调用库里的函数时,由于是偏移量,会和该库所在地址空间里的起始地址相加得到库函数真正的虚拟地址,再经过页表映射,找到实际物理内存地址
所以啊,经过上面的解释,由于库里使用的 偏移量 概念,被 OS 映射到地址空间的什么位置根本不用在意,不重要,因为 偏移量 是不变的,所以现在可以彻底理解选项 -fPIC
:产生位置无关码
最后一个问题
库有没有被加载, OS 怎么知道,如果所有的进程都要使用库,难道都要重复加载吗?不可能这么浪费内存空间的,所以 OS 肯定知道库有没有被加载,怎么知道?
首先问一个问题,系统使用的动态库多不多?多,肯定多对吧?那么内存里就可能同时存在非常多不同的库,因为程序要与运行!那么用了要加载,不用就释放,有这么随意吗?OS 是不是要做管理啊?很显然,先描述,再组织 嘛
我们自己不用管动态库,甚至是直接用动态库,都不用手动打开或是关闭,而 库也是文件 啊!所以 OS 注定是要管理的,所以也一定会有可以完美描述库文件的结构体,里面包含很多库属性信息,例如:有多少进程在映射?物理地址是什么?然后再用 链表 的形式组织起来
最后怎么知道库有没有被加载?遍历 链表
而把库放在地址空间里的什么位置是 加载器 帮忙维护的,暂时不用理解咯