进程(被打开文件)与文件系统的关联
站在操作系统的视角上,可以把物理内存理解成一个巨大的缓冲区。任何的I/O行为都离不开它。而文件的打开势必是要将它的对应属性和数据加载到内存,磁盘和内存物理层面上是要交互的。磁盘的基本单位是块,通常为4KB大小,这里称为页帧。而内存在物理层面上的基本单位是页框,大小也是4KB。将内存和磁盘在物理层面上数据交换的基本单位定义成4KB,是因为内存与磁盘交互的I/O周期较长,一次交互4KB的数据可以减少I/O的次数,出于硬件层面上对效率方面的考量。通常访问一个数据时,根据局部性原理它的相邻数据很有可能再接来下被访问,反正一次加载512B和一次加载4KB的代价是一样的,干脆就加载4KB数据。这也是软件层面上的一种预加载机制,本质上也是出于对系统效率的考量。
操作系统如何对内存做管理呢?当然是用对应结构体描述页(page)对应的属性字段,然后通过特定的算法来对一个个页进行组织。操作系统不仅对物理内存映射出的虚拟内存做管理,还要直接对物理内存做管理。以32位的操作系统为例,当将4GB内存拆分成一个个页看待,就将4GB的空间的管理转化成了对100W个页对象的管理。用一个数组将这些页对象组织起来,对内存的管理就可以转化成对于数组的管理。而数组下标就可以唯一标识一个页,下标也可以称之为页号。如何匹配内存地址对应的页号呢?而通过内存地址(如0x33445566)找对应的页号仅仅需要通过按位与上0xfffff000即可。操作内存本质上就是对操作系统维护的内存page数组进行增删改查。
下面对于page结构体在进行一个更进一步的介绍,以最经典的内核2.6版本的源码为一个样例。
首先这个page结构体的大小是不能太大的。因为4GB内存大概要被一个长度为1048576的数组进行管理,也就意味着这个page每增大一个字节,管理内存的数组的大小就要增大1MB的空间。这也是为什么page机构体内部充斥着许多的union。从另一个角度看,OS仅仅只需要数十MB的空间大小就能管理4GB的内存空间,其实这代价并不是非常高的。page的flags字段用于标识当前页空间是否被使用。申请内存就是将对应flags字段的比特位置成1,这也是最简单最朴素的认知,实际情况远远要更复杂。
电脑开机时,当操作系统加载完毕后,势必会将操作系统所处分区的文件系统块组信息写入内存。如Super Block字段。当然,可能系统上有多个磁盘分区,也可能不同分区有不同的文件系统。但是在内存中,这些Super Block字段会以链表的形式组织起来。 这样文件系统就能够正常的进行运转。前面进程概念文章中提到,进程在内核中以task_struct的结构体来描述,task_struct的内部有一个files_struct字段,files_struct字段内部维护这一个文件描述符表fd_array[]。它里面存放着一个个struct_file。而这个struct_file就是操作系统内核描述文件的结构体。它的内部有一个inode指针,指向内存中已经预加载的inode的地址。当我们写入文件信息的时候,就会将对应文件系统缓存在内存中的inode Table字段的数据初始化struct file中维护的inode指针。struct file中还有一个结构体address_space。address_space的内部有一颗多叉树它用于描述文件的页缓冲区,其叶子结点上连接的就是一个个文件页缓冲区,大小为4KB。数据写入内核时,都会写入到对应的文件页缓冲区中,最终再由内核通过对应的驱动接口写入物理磁盘中。
动静态库
简单制作一份静态库
静态库其实就是多分的源文件进行编译后生成的.o文件的打包而成的一个或多个.a文件。
想让别人用自己写的库无非有两种方式,一种是将.c源文件和.h头文件打包发给他,另一种是将.c文件生成一个库后,将库和头文件一起给他。 下面演示下生成一个库并打包发布的过程。首先生成一个Makefile,然后先用.c文件 使用gcc编译生成.o文件。然后用ar 工具将单个或多个的.o文件生成静态库.a文件。最后写一个简单的发布方法。
静态库的使用
如何使用呢?下面就写一份demo代码进行演示。
用gcc编一下,此时需要使用三个选项 -I 头文件的路径,然后是 -L 库文件的路径, 最后是 -l后面紧跟生成的库文件去掉lib和.a中间的部分。这样编译器才能找到对应的库文件并且连接对应的库。
在此前的学习中,编写的代码通常都是以.h和.c在当前路径的方式自定义库。亦或者直接使用C/C++标准提供的库和Linux下的系统库。所以,采用的是直接gcc 编译连接生成可执行程序。而今天是以第三方库的形式第一次接触到gcc以这种方式进行编译。无论如何连接三方库都必须在gcc 中使用 -l选项 指定对应需要连接的库名称。 这是因为当前系统中只有静态库,所以gcc只能以静态方式连接。
gcc连接库不仅仅有静态库也有动态库。静态链接和动态链接是可以在同一个可执行程序的。并且gcc默认是以动态链接的方式进行链接的
下面,演示一下将库写入到系统默认的路径下的情况。哪怕我们已经将库放到gcc默认链接的路径中,它依旧不认识除了语言标准库和系统默认提供库以外的第三方库。在编译链接时依旧需要指定对应的库名称。
在上面演示中,将对应头文件和库文件拷贝到对应系统路径下的动作就是安装操作了。这两个指令也是Linux环境下安装时,SHELL脚本等必不可少的核心动作。
下面我通过在gcc默认的头文件库文件的搜索路径下分别建立软链接,来演示用软链接解决一下第三方库在编译时搜索的问题。
无论是在gcc编译时通过选项指定对应头文件和库文件的路径,还是通过在系统对应存放库文件和头文件的路径下建立软链接。本质上都是为了让编译器在编译时能够找到对应的头文件声明,编译生成.o文件在链接时能够找到对应的库文件(.a文件),最终形成可执行程序。
动态库的制作
下面,我创建两组.h和.c文件,然后使用gcc将两组.c源文件的内容打包生成一份动态库。首先,分别使用gcc -fPIC -c 文件名编译生成一份同名.o文件。随后,将两份.o文件打包生成.so动态库,使用 gcc -shared -o 动态库名称 被打包的.o文件。这样一个动态库就制作完成了。通过观察可以发现,制作的动态库是具有x权限的。但是无法独立运行,因为它只是程序的一部分,它本身不是程序(没有程序的入口main函数)。
下面将对应的操作写入Makefile中
使用一下动态库
下面再main.c试用一下动态库的方法
但是,./a.out时发现程序报错了。
为什么编译成功了,但是却运行不了呢?因为使用动态库,只告诉编译器你库的位置还不够。当编译器完成对应工作后。可执行程序被生成。运行这个可执行程序时,操作系统的加载器并不知道该去哪里找到对应的动态库,所以报了这个错误。默认的lib64目录下面找不到这个动态库机器相关链接。一般有四种情况,第一种是将对应的路径安装到系统lib64目录下。第二种是在系统的lib64目录下建立软连接。第三种将:LD_LIBRARY_PATH目录中添加动态库的路径。该方法是写入内存级别的系统环境变量,仅在当前的窗口有效。
方法四是进入/etc/ld.so.conf.d,新建一个conf文件,将动态库的路径写入conf文件。输入ldconfig指令更新一下目录就可以让加载器找到对应的动态库路径。
动态库在程序运行时要被加载,而静态库在可执行程序生成的时候就写到可执行程序里了,所以不用在运行时加载。反映的对应的现象就是,当系统提供的libc动态库如果被删除,那么像ls、pwd这类使用libc动态库的程序全部都无法使用,因为像libc这类动态库几乎被所有程序使用,动态库也称为共享库。而静态库只要写进程序内,哪怕静态库被删除,可执行程序依旧可以正常运行。
动态库在系统加载后,可以被操作系统中所有的进程共享。如何做到的呢?每个进程都有对应的进程地址空间,进程地址空间内有堆区、栈区、共享区、正文代码段等。操作系统与运行时,会将对应的动态库加载到内存中。当进程调用printf这种C标准库提供的接口时,进程会将libc动态库在内存的位置通过页表映射的进程的共享区。调用动态库时,会从程序地址空间的正文部分跳转到共享区中printf方法的地址。所以,任何第三方库的方法 在程序内被调用时都是在进程地址空间中执行。
既然动态库被所有链接的进程共享在对应进程地址空间的用户地址空间的堆栈之间(共享区),假如有多个进程链接libc库,当前我的a进程修改了一个动态库的全局变量errno,此时会影响其他进程吗?答案是不会的,因为当某个进程修改动态库某一个字段时,会触发写时拷贝。操作系统会通过写时拷贝为该进程复制数据的私有副本,确保修改不影响其他共享该库的进程。
谈谈地址的理解
在磁盘上的程序有地址吗?
一个编译好的可执行程序内部是有地址的,这个地址是虚拟地址(逻辑地址)。因为,程序在编译后,程序内部的方法调用都会将方法名称给替换成对应程序地址空间的地址。这类地址是平坦的,因为程序的顺序结构决定了每一条指令的逻辑地址是从低往高的。在现代的操作系统学科中,将逻辑地址、虚拟地址以及线性地址统一为一个概念。在进程地址空间这个概念都没出现的时候,程序其实是由逻辑地址加偏移量构成的。程序在磁盘中是存在逻辑地址的概念。
先写一份demo代码,然后用gcc编译链接生成可执行程序后,使用objdump -S a.out生成反汇编。先观察一下汇编代码。
可以通过汇编代码可以看到对应的每一条代码操作都有对应的逻辑地址。根据逻辑地址 + 上对应的指令长度(偏移量)就可以让程序运行起来了。
push、mov、call等指令是CPU指令集的助记符。CPU本质上只认识二进制的指令,在CPU生产中,硬件工程师会写入对应的指令集。程序的运行本质是CPU对于指令的处理,对虚拟地址的操作本质上就是CPU对不同指令的操作。
程序加载后(进程)的地址
程序被加载内存中时,对应的指令和逻辑地址也要被加载到内存中。当进程被调度的时候,CPU内的PC指针指向程序的入口地址(逻辑地址)。此时进程地址空间的页表并没有内容,触发缺页中断,进程地址空间的虚拟地址和物理地址的映射关系以4KB为单位构建。往后,操作对应的逻辑地址,操作系统根据页表就会到物理地址进行对应操作。CPU只需要操作虚拟地址(逻辑地址),便可以直接执行对应的进程的代码和数据。
动态库的地址
首先,确认一个概念,动态库的地址是一个逻辑地址。 当eip指向对应的第一次执行C标准库的库函数printf时,操作系统判断当前页不存在动态库的逻辑地址和物理地址的映射关系、此时发生缺页中断。此时假设这个printf函数的逻辑地址是0x1234。代码走着走着又遇到一个第一次执行的另一个动态库的库函数。此时又发生了缺页中断,意味着共享区的大小变了。如果此时动态库的是以固定地址空间的方式加载,那么往后printf函数将无法正常的进行跳转。由此可以得出动态库不是被加载到固定地址空间位置的。 具体操作系统让函数使用偏移量来进行编制的。共享区内可能会有多个动态库,而每个动态库的起始地址被操作系统以某种数据结构的形式组织起来。所以正文部分代码需要调用库函数时,会采用起始地址 + 库函数在库中的偏移量进行进行跳转。而前面提到的gcc -fPIC中的fPIC选项指的是产生与位置无关码,这个与位置无关码就是用于生成对应的偏移量。这么做可以避免动态库的文本重定位。