Linux---动静态库的制作与使用

硬链接

是什么?

对于同一个inode来说,表明有多少个文件名指向inode,也就是说inode和文件名是1对多的关系,在当前目录下,文件名具备唯一性,一个文件一个inode,所以硬链接和目标文件本质是一个文件,就是别名。ln指令可以创建硬链接,后者链接前者,log_hard(硬链接)就是log.txt的别名,建立硬链接的本质就是在当前目录下新建一个新的字符串(文件名)与目标文件inode的映射关系。看硬链接数有几个就是去看同一个inode对应了多少个文件(数量)。

为什么要有硬链接(硬链接的意义的什么)?

硬链接本质就是备份,你删除文件的时候先删除的是文件名与inode的映射关系。

.和..的本质?

本质就是硬链接。接下来做一组实验。

终极问题,我知道了一个目录的硬链接数,请问该目录里有多少个目录?答案就是硬链接数字减2就是该目录里的目录数,为什么要减2呢?减去的就是该目录自己跟该目录里的.。

注意:目录是不能创建硬链接的,因为创建了硬链接在find指令查找的时候是会发生环形路径问题的,会造成死循环,假设我现在的路径是/home/xxc/code/test,然后我在test目录里创建了code的硬链接叫code_hard,code_hard和code的inode一样,查找到code_hard的时候不就相当于又回到了code目录里了嘛,就形成死循环了啊。.和..是系统里给目录建立的硬链接,必须要这么干,是有对应场景的,它不会形成环形路径,.和..是两个特殊的文件名,会做特殊处理,find查找的时候遇到.和..会忽略这两个目录。

软链接

是什么?

ln -s指令可以创建软连接,由于它有独立的inode,所以是一个独立的文件,文件=内容+属性,它的内容就是指向的目标文件的路径(路径也是字符串,也是数据),具体暂时没法看,先知道一下。

为什么要有软链接?

软链接可以保存目标文件的路径,当我们写项目的时候,一个可执行文件的路径可能很长,不想去找到它的目录再去执行它,想直接就在我当前目录里执行,这时候就可以创建软链接。你给它链接什么路径,它里边就是什么路径,执行run的时候就直接按照run里的内容去找test.exe,对于下文案例,run里存的就是./a/b/c/d/e/f/text.exe,由此也可以看出run其实就相当于windows里的快捷方式。

应用场景

我要把某个软件或者某个库安装到系统中,拿软件举例子,比如说你自己写的可执行程序,不想带路径就执行,也不想将它拷贝到/usr/bin目录下(本质意思就是环境变量里),那就可以直接到/usr/bin里创建一个软链接,此时最好是创建绝对路径的软链接,防止系统找不到。补充:指令/usr/bin/myexe的意思就是在/usr/bin目录下创建一个叫做myexe的软链接,执行的时候就直接可以myexe,不用带路径,因为相当于此时myexe就在环境变量里了,可以直接被找到了。

库的认识

回顾C/C++标准库

我们写C/C++的时候需要包含头文件,使用里边的库函数,库函数来自于库,ldd指令可以查看指定可执行程序依赖了哪些库。

使用gcc/g++编译的时候默认使用动态链接,如果必须使用静态链接就要用到选项-static。默认可能在你的系统里没有安装C标准库,先安装一下就可以了。

最佳实践:我们在编译链接的时候一般是先将*.c编译成*.o,然后再将*.o,头文件,标准库一起链接成可执行程序的。你把你的源文件编译成.o的时候跟其他文件是没有关系的,编译的过程只会去检查当前文件是否有语法错误,只有当链接的时候,多个.o之间才会发生关联。

所谓的库,其实就是.o文件的集合,.o文件以动态或者静态方式打包之后的一个集合就是库。为什么这么说呢?mine文件夹里有mystdio.h,mystdio.c(这两个文件是我之前博客里写过的封装IO函数),当然还能有更多,然后other文件夹想直接用我的mystdio.c,我不想把我自己函数的实现以及各种细节告诉别人,因此我会先将mystdio.c编译成mystdio.o文件,将这个文件以及mystdio.h里的各种声明告诉other就可以了,至于底层怎么实现的我不告诉它。other拿到了我的.o文件以及.h文件之后就可以使用它们了,other自己写一个main函数,里边加上mystdio.h这个头文件就可以使用里边的各种函数了,最后other自己写的main.c和我的mystdio.c,mystdio.h一起编译形成一个可执行程序。上述故事里的mystdio.o其实就是库,只不过真实情况是库里不会只有一个.o文件。

自己制作库

静态库

上文其实也说的很清楚了,所谓的库就是.o文件的集合,我现在手头有几千个.o文件想给你用,总不能一个个cp过来吧,因此在我这里可以先将它们打包成一个库。

指令:ar -rc libXXX.a *.o -rc的意思就是如果libXXX.a存在且里有同名的.o,*.o将覆盖掉它们,原有的不同名文件将保留,如果库本身不存在就新建一个名叫libXXX.a的静态库。

还是依赖上文的场景,mine文件夹目前情况,我将所有.o文件打包成了一个库,然后将这个库拷贝到other里边,当然mystdio.h也需要,里边是声明啊。在other里边写一个main.c,编译链接一下看看效果。

解释一下gcc -o myexec main.o -lmystdio -L./,-l是告诉编译器gcc我要链接的是哪个库,库名字告诉它,为什么我们用gcc链接C标准库的时候不用带上-l选项,因为gcc就是编译C语言的,它默认就认识C标准库,-L是告诉编译器库在哪里,路径告诉它,对于C标准库来说gcc是默认去/lib64里去找的。库和头文件的关系就是库是具体的实现,头文件是声明。

综上:我给别人提供一个库的时候提供的是库文件和头文件(你库的使用手册)。

别人怎么使用我的库?

1.将.o文件打包成.a库文件,然后将头文件和库文件一起放到一个文件夹里,这就是一个我们自己制作的库了。(mystdio)

2.现在你就可以将这个库给other了,other自己写一个main.c并将其编译成main.o,但是在链接的时候直接gcc链接是会报错的,原因就是编译器找不到头文件找不到库。

法一:将库和头文件拷贝到系统路径下,也称为安装库到系统中

为什么还要告诉库名字是因为lib64目录里的库很多,gcc只会默认认识C标准库,其他库你要告诉它名字,下边的main.o是可执行文件了,名字起的有点歧义。

法二:建立软链接(本质跟法一是一样的)

不想拷贝库到lib64里,那就创建软链接,但是别忘了要将头文件拷贝到/usr/include里。这里再次补充,如果使用了软链接的方式,名字起成了mystdio这种就会很麻烦,所以名字要起成libXXX.a这种形式,最后-l后边跟上XXX就行了,库的标准前后缀gcc会自动帮我们补上。gcc默认只认识C标准库,其他库都要指定名字。

法三:随意链接任意库

注:编译器默认只会去当前路径和系统默认路径里去找头文件

动态库

**制作:**动态库从制作上就和静态库不一样了,这里用makefile演示。gcc链接*.o(所有.o文件)的时候要带上选项-shared,此时就不再链接成可执行程序,而是形成一个动态库,另外,.c编译成.o文件需要带上-fPIC选项,具体什么意思下文会说。细节:我们制作静态库的时候有单独的指令ar,制作动态库的时候为什么没有呢?动态库是使用库的常见场景,因此编译器默认就能形成动态库,静态库属于附加功能,就单独用一个ar命令。

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

.PHONY:clean
clean:
	@rm -rf *.o *.so

.PHONY:output
output:
	@mkdir -p mystdio
	@mkdir -p mystdio/include
	@mkdir -p mystdio/lib
	@cp mystdio.h mystdio/include
	@cp libmystdio.so mystdio/lib

**编译链接.c:**跟静态库一样,用gcc编译链接的时候有3种方式来让编译器找到头文件和库,这里我们就直接用指令来编译链接它们了。

**库加载时候的查找问题:**上述问题的原因就是库信息告诉的是编译器gcc,所以它成功编译链接形成了myexe可执行程序,可是你运行myexe的时候加载器找不到库,说白了就是OS找不到。为什么使用静态库的时候没这个问题?静态库中的方法拷贝到了我的程序内部,程序运行的时候就不需要库了。动态库需要运行时搜索,之前写工具gcc那篇博客里写过关于它们的感性认识,可以去看看,那怎么解决这个问题呢?(注意:以下说的查找问题都是指的程序运行的时候动态库的查找问题,不是编译的时候,静态库由于是直接将方法拷贝到我程序里了,所以运行的时候就不用查找了)

方案一:将库和头文件拷贝到系统默认路径里/使用软链接干同样的事情(由于两种方式的唯一区别是一个是真拷贝过去了,一个是拷贝了路径过去,因此就演示一个)

方案二:更改环境变量LD_LIBRARY_PATH,添加路径的时候不用加上库名字,因为myexe知道自己依赖哪个库, 你gcc编译的时候就已经告诉它了,但是运行的时候它找不到。这种方式只能暂时有效,因为系统重新登录之后就又没了,如果想永久有效就必须将这个路径添加到bash的配置文件里。

方案三:(全局有效的推荐做法),配置/etc/ld.so.conf.d/目录,目录里全是配置文件,名字随便起,配置的内容即为库的路径,同样只要路径不用写库名字,配置好了用指令ldconfig就可以让你的配置文件在OS级别全局生效。

问题一

gcc编译的时候,同时存在动静态两种库(里边的.o文件完全相同,只是库有两种不同的版本),默认使用哪个库?

默认使用动态库,使用-static选项可以强制使用静态库,如果没有静态库就会报错,有静态库就会用静态库链接。没有用-static,但是当前只有静态库,没有动态库,那就还是静态链接。综上:没有-static,有动态用动态,没有动态只能用静态,有-static,强制用静态,没有就报错。

问题二

我们使用的IO库函数的返回值FILE结构体是谁申请的?是库函数自己申请的(之前的博客里写过模拟实现),也就是说是库自己申请的,不要觉得库有多高大上,就是.o文件的集合,.o文件是什么?不就是.c编译之后来的吗,.c文件就是普通的C语言文件啊,没什么稀奇的,所以本质上还是在用C语言在帮你申请FILE,只不过帮你把函数实现的.c文件编译变成.o之后全部打包变成了库。

所以说库可以帮助用户申请空间或者对象,申请的动作对应的源代码实现在库里边,真正申请的人还是用户自己。

问题三

刚才上边的所有演示都是自己写的库,如何真正使用别人的库?库有很多的应用场景,这里我们拿ncurses库举个例子,这个库是一个图形库。

1.安装:

2.简单让大模型生成一段运行后生成一个心形图案的代码:(1,2不贴图了,直接问大模型)

3.编译链接:

4.让编译器找到这个库,链接成可执行程序

ELF文件

对于我们最为熟知的可执行程序来说,它是一个二进制文件,这个文件里不是随意放在一起的二进制,它有自己的格式和结构,这个格式结构就叫做ELF格式,不止是可执行程序,可重定位目标文件(.o文件)(这里说的重定位跟之前的什么重定向不是一个东西,就单纯一个名字),动静态库都是ELF格式的,ELF全程英文的含义叫可执行与可链接格式。ELF格式是Linux底下特有的。

单个ELF文件的格式我们先不去谈,先从宏观上,我们知道一个可执行程序是由.o文件+动静态库链接而成的,.o文件是ELF格式的,动静态库也是ELF格式的,为什么就可以链接成一个文件了呢?而且库自己本身就是许多.o文件的集合,库又是怎么形成的呢?.o和.so或者.a文件都是ELF格式的,相同格式,里边的各个部分相同区域就可以合并。

格式细节

一个ELF结构里包含了4部分内容,第一部分为ELF Header,描述的是整个ELF文件,像之前博客里说到的存一个组的管理信息的GDT,第二部分为program header table,暂时先不说,第三部分整体叫为(Sections),由一个个节组成,里边存的东西就可以理解为是将文件的代码和数据分类存进了一个个节里,就比如代码,全局数据,常量数据......第四部分叫节头表,它是对所有节的描述。

查看header:readelf -h 文件名

ELF本质就是一个文件(文件内容),只不过它把文件的内容划分成了好多部分,你就可以将文件在逻辑结构上想象成一个数组,这个数组被划分成了好多个部分。

查看Section header table:readelf -S 文件名

还有很多节,就不一一介绍了.....

查看program header table:readelf -l 文件名

program是由段组成的,ELF里没有段信息,就是ELF里不存段(segment),段跟虚拟地址空间有很大的关系。那之前Sections里的节跟段有什么区别呢?节是存在ELF里的,不同的节大小不一,权限可以是相同的,ELF是文件,它在磁盘里是存在一个个4KB里的,读取文件的时候,将文件从磁盘加载到内存是以4KB为单位导入到内存里的,那我每一个节的大小不一样,不管大的小的都在磁盘上以一个个4KB块去存啊,有的节的大小可能连4KB都没有,内存空间又很金贵,不可能将所有保存节的4KB块全部(存满的没存满的)都加载到内存里,因此多个权限相同的节在加载的时候由加载器帮我们进行节合并,合并之后可能原来在磁盘上要用3个4kb去存的(但里边实际的内容都远远小于4kb)变成了1个4kb,多个节合并之后就变成了一个段。

上图里的mapping就是一个映射表,里边早就给你安排好了,哪些节合并到哪些段里都给你安排好了。所以我们加载运行可执行程序的时候优先去看的是pragram里边的段信息,从而知道我要加载哪些东西到内存里,然后再去header里去Section table里去找具体哪些节在ELF里的哪里,最后就是合并节变成段,加载到内存里去运行。

**查看节:**没有专门的指令,但是可以将ELF转反汇编(将ELF里Sections的内容转反汇编),也就是每一个节转反汇编,从而可以看到每一个节。objdump -S 文件名,具体就不贴图了,为了查看方便可以重定向到比如说test.s文件里。

可执行程序加载问题

1.创建一个进程,先创建内核进程相关的数据结构还是先加载ELF格式的二进制文件?

答:先创建数据结构,再加载。之前的博客里提到过了,真实数据从磁盘加载到内存可以懒加载,要用的时候再加载,但进程创建必须要有对应的PCB。

2.程序没有被加载到内存之前,程序自己有没有地址?什么地址?

答:程序是有地址的,每一行代码"都有地址",编译器在编译的时候会进入平坦模式,对我们的可执行程序中的"每一行代码"都进行编址,原则上从0号地址开始,对于32位系统来说编址范围就是从32全0到32全1,每一行代码的地址就叫虚拟地址,也叫逻辑地址(0+偏移量的模式去编址的,具体不要深究),一般对于进程的虚拟地址空间那个地址才叫虚拟地址,对于编译器编译的地址叫逻辑地址,都一样,名字变变而已。所以由此我们就知道了其实虚拟地址空间是一种技术,是一个标准,OS遵守,编译器也遵守,正是因为它们有同一套标准,所以笼统理解就是可以互相吻合无缝衔接。

有了上边两个问题之后,那mm_struct(虚拟地址空间)创建出来之后怎么初始化呢?

上文也说过了,编译之后每一行代码都有虚拟地址,程序运行加载到内存之后还会有物理地址,程序里没加载的部分就没有物理地址,只有虚拟地址,也就是说程序加载之后,物理地址和虚拟地址在内存里都有了,直接填充页表。

至此,mm_struct和页表里的映射关系都初始化好了。都准备好了,CPU就要开始执行了呀,从哪里开始呢?ELF里的hearer里有entry point address,这个就是程序的入口地址呀,之前博客也说过,CPU是靠PC指针(EIP)来知道程序的起始地址的,并且之后执行到哪一行代码都是靠PC指针来知道的,entry point address说白了就是初始化PC指针的。

动起来:在main函数里也可以调用其他函数,怎么调用的呢?首先肯定是知道函数的虚拟地址的,你用汇编语言的方式查看代码节(上文说过)你会看到call 地址,这个地址就是虚拟地址,拿到函数的地址之后首先肯定是交给CPU里的PC指针(进到CPU里的地址全是虚拟地址),在CPU里还有两个寄存器叫MMU和CR3,EIP会将虚拟地址交给MMU,MMU会去查虚拟地址对应的物理地址,CR3会指向当前进程的页表,所以MMU会通过CR3从而得到物理地址,通过函数的物理地址找到它在内存里的位置从而调用到它。虚拟地址转物理地址的过程都由硬件(CPU)内部自动完成。

总结:虚拟地址空间不仅仅要让OS参与,编译器也要参与这个过程(本质是ELF参与)。