Linux 当中的 内存管理模块
不管是操作系统对于进程之间的管理,还是 对于文件的访问和修改等等的操作,都是要把数据加载到内存当中的,所以,所有的工作都离不开 内存管理模块。
内存的本质其实是对数据的一种临时存储,可以把 内存看做是一个 加载 外存 和 cpu / cpu 寄存器之间的一个 数据的缓冲区。
当我们要从外设当中,比如是 磁盘当中访问的修改数据的话,需要把数据加载到内存当中。当这部分加载到内存当中的数据,不在被使用之时,在根据需要,把内存当中的这部分数据的地址空间的访问权限放开,使得这部分数据可以被其他数据所覆盖,也就相当于是把这部分数据删除掉了。
或者是,这份数据是需要保存的,那么就把数据 交换到磁盘当中的 对应位置。
而操作系统 为了方便管理 内存空间,又把空间划分长一个一个的小区间,这个区间大小 可能是 4kb, 2kb ,8kb 都有可能,我们把这个在内存当中划分出的一个一个的区间 称之为 --- 页框。对于一个页框的大小,主要看 操作操作系统的管理。
而在 磁盘当中,假设我们现在要把一个可执行程序加载到内存当中,其实这个可执行程序 在磁盘当中也是像 内存当中一样的,是把这个可执行文件 分为了一个一个的小区间,大小和 内存当中的页框基本上是一样的。我们把 在磁盘当中,把一个要加载到内存当中的 文件划分出的 一个一个的小区间 称之为 -- 页帧。
当 系统想要把 磁盘当中的数据 加载到内存当中之时,不是一股脑的 直接按照 磁盘当中文件顺序加载,而是从这个文件当中 顺序加载一个 页帧,加载到 内存当中对应 页框当中。
如下所示:
当然,肯定是 4kb 的内容访问效率 比 1 kb 的 内容访问效率 高的,因为 对于机械硬盘来说,要想访问数据,其实就是需要其中的磁头定位到 某一个扇区当中,如果是一次访问4 kb 的话,就不用每一次都去访问 1kb 一次次的去定位磁头位置了,我也 机械硬盘访问需要一次次的 旋转 盘 来是的 磁头能够定位到 对应 盘面上的 扇区位置。
但是如果 页帧的大小 太大了,那么 就会造成对于 大小比较小的文件访问 多余不需要的内容,比如 一次访问 10kb,但是现在需要访问文件实际上只有 1kb ,但是操作系统是一次按照 10 kb 大小去访问的,那么 在内存当中就是按照 10kb 来存储的,所以此时在内存当中就有 9kb 的内容被浪费了。
对于 4kb,如果现在像访问的文件当中只有 100 个字节呢?所以,操作系统就觉得,反正后序浪费的也不多,直接把 4kb 的数据直接加载到内存当中。
因为 ,在计算机硬件存储当中有一个 特性 -- 局部性原理。在磁盘当中存储的数据,旁边的数据也大概率是 相关数据,所以直接把 适应大小 的附近的 数据在第一次加载当中也加载进来。这种基于 局部性原理,每一次加载(在适应程度上)超过当前 要访问数据范围的方式,称之为 --- 预加载。
所以为什么是 4 kb呢?这其实是 在经过大量的数据实验过程当中得出的一种效率最高的一种存储机制。
而在内存当中分出的一个一个的页,其实要被操作系统所管理,实际上就是一个先描述在组织的过程。
为了管理一个一个页框,可以定义一个结构体,在这个结构体当中存储了 页框各个属性,比如上述所说的 内存当中的分出的一个一个的页框 的 结构体,这样的话,按照这个数据的下标来划分出 一个一个的页框的 编号。
至此,操作系统通过 这个存储 每一个 页框的 结构体的 数组,就可以知道 内存当中每一个 页框的 当前处于什么状态。
比如:某一个页框其中的空间是否已经被使用了,通过数组下标(相当于是页号),就可以找到某一个页框。
这个过程其实 就是一个 先描述,在组织的过程。
如上图所示,一个 page 当中就存储了 一个页框当中所有属性,而在 mem_array[] 这个数组当中,就存储了 内存当中所有的 页款结构体,以此来更好的关系这些个 页框。
所以,如果我们得到了一个 内存的物理地址,通过下述类似的方式可以计算出一个 对应的 页号:
0x11223344 & 0xFFFF F000
我们要访问一个 内存空间,只需要先找到这个空间对应的 page 结构体对象,然后就可以通过这个结构体对象在系统当中找到对应的物理页框。
操作系统所有在访问内存的操作,实际上就是在访问 这个 存储 page 对象的数组。
如下所示,就是 page 这个结构体 源代码:
这个page 结构体对象的空间不能大,因为 存储 page 的mem_array [] 这个数组的大小其实是由 一个一个的 page 结构体对象来 决定的。
而,这个数组要想被 操作系统做 访问,那么就必须在内存上存储,才能被访问,所以这个数组是存储在内存当中的;
所以,如果这个数组占用的空间已经占用了一大半的 内存资源,那么其他进程该如何使用 内存呢?如果运行呢?是不是体验就不好了。
在计算机开机之时,不仅仅会把 对应的 操作系统 或者是 其他进程 运行起来。操作系统这个软件的 程序是加载到内存当中的,所以,要想运行 操作系统等等的程序,都必须把这个 可执行文件 加载到内存当中来运行。
所以,在运行操作系统之前,其实还有一个步骤 -- 加载文件系统。
因为 ,只有加载了文件系统才能 知道 操作系统这个软件存储在 磁盘上的什么位置。
所以,预先加载的其实是 像 Super Block ,GDT , Block Bitmap , Inode Bitmap ,inode table 这些文件系统当中用于管理各个文件分区,分组的 属性。
这些 管理文件的属性其实就是在开始之时,预先加载到 内存当中。
文件页缓冲区(内核缓冲区)
在strutc file 文件对象,这个内核级的结构体当中吗,是会存储少量的 文件属性的,但是,主要的属性还是存储在 inode 当中。在 struct file 当中会存储 inode 结构体的指针,指向当前这个文件的 inode 结果体对象。
也就是说,在 struct file 这个结构体当中其实是存储了 inode 结构体对象的 指针,在 struct file 当中可以找到 这个文件的对应的 inode 结构体对象,也就可以访问到这个文件的全部属性。
所以,其实关于Linux 内核当中访问文件的过程大概是下图所呈现的方式来访问的;
在上述用户级缓冲区当中想刷新数据之时,其实就是通过 pcb 对象,然后一层一层找到 对应文件的 struct file 文件对象,在文件当中当中就有 *inode 保存文件属性的 inode 结构体对象的指针;
和 struct address_space 这个结构体当中有一个多叉树的结构,其中的叶子结点就存储了 一个一个的 struct page 对象,所以,这个多叉树的叶子结点可以有很多个,因为 页框 有很多个,对应 页框的 struct page 对象就有很多个。
像上述这种一层一层套用到 对应存储 page 结构体 的数据结构当中的操作,就是我们曾经提到过的 用户缓冲区 刷新数据到 系统级缓冲区的操作(内核缓冲区)。
其实想上述这种 radix_tree_root 叫做 基数树/基树。本质上就是一种 字典树。
它的基本结构其实是这样的:
struct node
{
int level;
void* slot[26];
}
大概就是上述的这种结构。
在最后的叶子结点是这样存储的。每个叶子结点存储的是 struct page 对象:
所以,在文件当中按照 4kb 大小分割出的 一个一个的 数据块,也是有自己的编号的,更具这个编号,就可以从 上述的多叉树结构当中计算出 这个 数据块 对应的 page 编号,从而写入数据到 对应位置的 数据块当中。
像上述就已经从 用户级缓冲区当中把数据 刷新到 内存当中的 内核缓冲区当中了。
接下来是把 数据从 内核缓冲区当中把数据 写到文件当中这个操作。
当然,把数据写到文件当中的操作,进程自己本身是不关心的。接下来,什么时候刷新数据到文件当中,一次刷新多少,等等这些问题就不由 进程来关心了,都交给 操作系统当中的内存管理模块来做了。
所以,在上述 把数据 写到 内存缓冲区当中,但是还未写到 文件当中,这个过程当中,如果我们电脑死机了,关机了,或者说是电脑断电 了,那么 内存当中的存储是电信号的方式来存储的,一旦断电,那么其中存储的 数据就会丢失。
而操作系统也是调用对应的 驱动系统来实现的,像这里就是要调用 IO子系统 来实现。
一个文件可能会有多个 IO 请求,因为可能不是一个用户在访问这个文件,同时对于一个用户而言,用户所启动的进程有很多个,这些个进程都有可能同时对一个文件发送 IO 请求。
所以,操作系统要把这些 IO 管理起来。
所以,如果是C/C++ 当中有数据要写入文件的话,首先要从需要拷贝的数据 ,先拷贝到 用户级缓冲区当中(也就是拷贝到 C/C++ 帮我们创建的 语言级别的缓冲区当中);
然后,在从 用户级缓冲区当中拷贝数据到 文件缓冲区(也就是上述的 内核级缓冲区)当中;
最后再从 文件缓冲区当中 拷贝数据到 文件当中。
这就是我们上述所说的 内存管理模块 在用户使用 C/C++ 代码 往文件当中写入数据,这个数据是如何往 文件当中写入数据的。
你可能会质疑 一个文件有一个缓冲区,那么这个缓冲区是不是会很大呢?
其实这个缓冲区不用很大,在 struct page 当中有一个 lru ,所有的 文件page 都会用 这个 lru 去链接,在 page 当中也会有 一个 flag 表示当前这个文件是否需要 缓冲区去定期刷新。或者说是 flag 表示当前这个数据页 是否是 “脏” 的,所谓 “ 脏” 的,就是被修改过的数据,如果是 “脏” 的,说明就要把 文件页当中的数据 刷新到 文件当中。
文件的读取并不是把 文件当中的全部都进行读取,而是 按需读取 ,所以,文件的缓冲区大小也是跟着 每一次 访问/修改 文件多少数据来进行更新的。
还是一样跟着 读文件时,使用的指针的方式来对文件当中的数据进行访问,那么在文件内容当中,历史上读过的数据,我们可能就不会在访问了,操作系统对于文件的访问
如果要新增一个 文件缓冲区,其实是在 基数树当中给这个 数据结构有更多的配置(让这个基数树存储更多的空间)。
动静态库
关于 动态链接 和 静态链接,在下述博客当中已经说明了,如有疑问,请参考下述博客:
Linux - 配置系统白名单 - gcc/g++_linux 白名单设置-CSDN博客
如果我们想把我们自己实现的 源代码文件,和这个源代码文件所对应的头文件当中的一些声明,直接给别人使用的话,我们大可以直接把源代码文件和 头文件直接打包发给别人,别人如果想使用这个库,那么只需要把这个库,跟 他自己的 源代码程序 一起编译就可以了。
但是这样做不仅麻烦,而且还会暴露我们自己的写的库的源代码。
如果我们不想暴露源代码,有什么方式可以实现呢?
答案就是给他一个库,在这个库当中就可有 对应方式的实现,他就可以自己去调用这些方法。
把自己实现的 源代码 想办法打包成一个库,这样他就不能直接访问到 源代码了;此时我们只需要把 库 + .h 这两个直接发给他,他就可以直接链接库,然后通过引头文件,表示他想要调用什么 源代码程序当中的内容。
所以,头文件是必须要有的,因为头文件是区分 库当中 不同源程序的 说明书,头文件说明了 什么程序当中是实现了什么功能的。
任何库,其中的头文件必须要公开。
简单静态库的模拟实现
我们知道,如果我们直接把我们自己写的 程序,发给 别人来使用的话,别人要想使用我们所编写的 源程序,就要把我们实现源程序和 别人自己的 源程序做链接,也就是一起编译。
其实本质上就是把 两边的源程序文件(比如是 xxx.c),编译为 (xxx.o)文件,然后把这些文件做 链接,形成可执行文件。
所以,既然都要 把 ".o" 文件进行 链接的话,其实,作为编写库的人,只需要把 我们想要给其他二年使用的 源程序文件,预先编译为 ".o" 文件,然后,使用者只需要 把 我们预先编译好的 ".o" 文件,和 他自己写的源程序文件 生成的 ".o" 文件做链接即可。
但是,我们给别人使用的 预先编译的 ".o" 文件可能有很多个,所以,我们把所有的 ".o" 文件都打个包,命名为类似 libxxx.a 这样的文件。
这样的操作,所形成的 ".a" 文件,就是我们所说的 静态库。在静态库当中,其实就是我们拿 各个源程序文件生成的 ".o" 文件的一个集合。
所以,使用者只需要 拿上述的 libxxx.a 静态库 和 自己写的 源程序文件 编译生成的 ".o" 文件进行链接,就可以达到我们 静态链接 的效果。
所以,我们可以自己实现,自己打包出我们自己实现的 静态库:
# 下述是当前目录下有的文件
[root@localhost linux]# ls
add.c add.h main.c sub.c sub.h
# 把上述的两个 源程序文件,编译形成 .o 文件
[root@localhost linux]# gcc -c add.c -o add.o
[root@localhost linux]# gcc -c sub.c -o sub.o
# 生成静态库
[root@localhost linux]# ar -rc libmymath.a add.o sub.o
# ar是gnu归档工具,rc表示(replace and create)
# 简单来说,就是把 add.o 和 sub.o 两个文件合并,打包成 libmymath.a 文件
查看静态库中的目录列表
[root@localhost linux]# ar -tv libmymath.a
rw-r--r-- 0/0 1240 Sep 15 16:53 2017 add.o
rw-r--r-- 0/0 1240 Sep 15 16:53 2017 sub.o
在 makefile 当中就可以这样写:
lib=libmymath.a
$(lib):mymath.o
ar -rc $@ $^
mymath.o:mymath.c
gcc -c $^
.PHONY:clean
clean:
rm -rf *.o *.a lib
# 上述只生成了 一个 mymath.o 文件,所以打包出的 静态库当中也只有一个 mymath.o 文件
mymath.c 和 mymath.h:
// mymath.c
#include "mymath.h"
int myerrno = 0;
int add(int x, int y)
{
return x + y;
}
int sub(int x, int y)
{
return x - y;
}
int mul(int x, int y)
{
return x * y;
}
int div(int x, int y)
{
if(y == 0){
myerrno = 1;
return -1;
}
return x / y;
}
// mymath.h
#pragma once
#include <stdio.h>
extern int myerrno;
int add(int x, int y);
int sub(int x, int y);
int mul(int x, int y);
int div(int x, int y);
那么在上述,创建库之后,如何把库给发布出来供人使用呢?
我们同样可以使用 makefile 来模拟:
lib=libmymath.a
$(lib):mymath.o
ar -rc $@ $^
mymath.o:mymath.c
gcc -c $^
.PHONY:clean
clean:
rm -rf *.o *.a lib
# 上述是对 静态库的生产
# 下述是对 静态库的发布
# 其实就是 把上述生成的 .h 和 .a 文件
# 创建一个 lib 文件夹,把这些文件存入到对应文件夹当中
.PHONY:output
output:
mkdir -p lib/include
mkdir -p lib/mymathlib
cp *.h lib/include
cp *.a lib/mymathlib
如下就是上述 makefile 的生成结果:
这样的话,如果有人需要用到我们实现的 静态库,只需要把上述的 "lib" 文件夹,打包发给 他即可。
当用户把上述的 "lib" 文件夹打包下载下来之后,就可以下述一样引用头文件,使用其中的方法了:
如上所示,我们直接把lib 文件夹 移动到 text 目录当中,模拟用户已经把 lib 库下载下来了,然后就可以使用这个库了:
#include "lib/include/mymath.h"
int main()
{
printf("1+1=%d\n", add(1,1));
int ret = div(10, 0); // 因为C语言从右向左进行形参实例化的,如果不写这一行
// 那么下述 myerrno 实例化是0
// 所以要先计算div 的值
printf("10/0=%d, errno=%d\n", ret, myerrno);
return 0;
}
如上,就可以包含头文件,使用其中声明的方法。
但是,上述我们使用了相对路径的方式来找到这个 头文件,但是我们在包含头文件之时,是直接使用 #include"xxx/h" 这样的方式来 引入头文件的。
上述的 #include"xxx/h" 这样的方式 其实是在 系统默认的 路径下 , 或者实在当前目录下进行寻找:
像上述目录下都是我们可以直接使用的 头文件。不需要带上路径。
我们可以使用 gcc 命令当中的 "-I" (注意是大写)选项,在后面指定寻找路径,意思就是,如果在 系统默认的 路径下 , 或者实在当前目录下进行寻找 都找不到,那么,就在上述指定路径当中进行寻找。
但是,虽然上述找到了头文件,但是现在 链接报错,找不到 add() 这个函数:
之所以说是 链接报错,不是编译报错,是因为,我们直接进行编译,是能够编译出来的 ".o" 文件的:
所以,报 了 链接式报错,说明是找不到 add()这个函数的实现。因为 add()函数的实现在 lib 库当中的 ".o" 文件当中。找不到 这个 lib 静态库。
所以,还是上述头文件的一样,他是默认在系统默认的库路径当中去找库文件了,但是发现找不到。
所以,gcc 还有一个 "-L" (大写)选项,指定去 在默认路径 和 当前目录当中都找不到的话,就去指定路径当中去寻找。
带上 "-L" 选项,发现还是报和上述一样的链接错误。
因为上述只是告诉了 库 在那个路径当中,但是没有告诉编译器 要链接 哪一个库。就算只有一个库,编译器也是不认识的,因为我们没有指明。因为在这个目录下,有可能不仅仅只有一个库,可能有很多个库。
上述使用 "-i" 选项找头文件的操作之所以可行,是因为在 源代码当中,已经 #include 引用的头文件名,所以编译器才知道要引用哪一个头文件。
但是库文件不一样,我们在代码当中并没有声明。
所以,现在还需要使用 gcc 命令当中的 "-i" (小写),表示当前要链接哪一个库:
但是发现还是报错。
这里需要注意的是:-i 后跟的 库文件名 去掉前缀(上述当中的lib) 和 后缀(上述的 .a )之后的名字,才是这个库的名字。
在上述库当中的 myerrno 这个全局变量所实现的效果就类似于 在C 库当中的 errno 类似的效果。
因为库,一旦被 其他源程序所引用的话,在库当中的全局变量就可以被 这个源程序所 “继承”,也就可以被这个源程序所使用和访问。
其实这也就是 C 当中的 errno 报错的实现。
我们在使用 malloc ,realloc , fopen 这些函数的时候,是有可能会调用失败的,比如:内存空间满了,不能再 动态的开辟空间了;或者是 传入 fopen 函数的文件名 或者是 路径是错误的,找不到这个文件等等 有很多的错误信息。
而对于C语言的使用者来说,最想知道的不是是否报错,而是是什么原因报错。
所以,在C库 当中就定义了 一个 全局变量 errno ,来帮助我们查看出错码。
查看某一个源程序链接方式
我们可以使用 ldd 可执行文件名 的方式来查看 某一个可执行文件的 链接关系。
如上述所示:a.out 文件就是上述 的 main.c 源程序文件所生成的 可执行文件,有上述可以看到 ,链接的库的后缀是 .so。说明,上述的 a.out 这个可执行文件的 链接方式是动态链接的。
因为 gcc 命令默认链接的库就是 动态链接 的方式来链接库的。
但是在上述链接结果当中,我们并没有发现我们自己实现的 静态库的链接。
这是因为,我们因为我们自己实现的库是静态库,gcc 虽然默认是按照 动态链接的方式来进行链接的,但是如果我们想链接的库是静态库的话,那么 gcc 只能按照静态链接的方式来进行链接。
同样的,我们在编写源程序的时候,我们可能不会只使用一个库,那么在 gcc 当中可以一路考 "-l" 选项来一路 链接下去。
如果我们想用第三方库,也就是不是官方所写的库,是别人或者是 我们自己实现的库的话,需要再gcc 命令当中使用 "-l" ,"-i" , "-L" 选项来分别链接 库 , 库所在地址 , 头文件。
可能你并不会使用 "-i" , "-L" 这两个选项来链接 库所在地址 , 头文件,但是 "-l" 链接 对应的库是必须的。
库的安装
上述的方式还是太麻烦了,使用个 gcc 命令还要带上一堆的选项,所以,可以考虑直接把 上述的lib 文件,直接拷贝到 系统默认的 路径下。
这个其实就是库的安装。
除了上述的方式,还可以使用软链接的方式来实现。
我们可以在 上述 C默认找库的路径当中,建立一个软链接,这个链接就链接我们上述在 其他目录当中 保存的 lib 库的路径位置。
像上述就是在 /usr/include/ 这个C默认路径的当中创建一个 myinc 软链接
在 这个软链接当中链接 /home/.../lib/include 这个路径,在 include 当中就是库的头文件存储位置。
如果是按照上述的方式,也就是软链接文件 链接的事 库当中头文件存储位置的话,在源程序当中要想直接引用这个头文件的话,如下所示:
上述就是 使用 myinc 来找到软链接,找到软链接的话,就找到了 lib/include 这个目录,在这个目录当中找到 mymath.h 这个头文件。
同样的,我们还要建立一个 库文件 的软链接文件。
在上述动态链接过后,就可以不用像之前一样,在使用 gcc/g++ 编译过程当中,还需要使用 一些选项来 链接头文件或者是 库。
在之后的使用方式就是和 我们使用 C库当中的官方库一样方便了,直接引用头文件就可以了。
但是上述 使用 软链接 的方式 可能会造成污染原本库当中的内容的 不好结果。而且,在别人实现的库当中,可能有很多的链接关系,如果我们直接自己进行软链接安装,不仅我们自己安装麻烦,而且在使用库当中可能会出现一些问题。
一般在安装第三方库的过程当中,最多的使用方式就是开头所说的 直接拷贝到系统的默认路径当中。而这些一般是安装程序会自动帮我们做的事情。