这篇仍然是深入理解计算机系统的学习笔记,结合自己理解体会所写。程序是运行在内存中的,而程序的运行时候不是直接操作物理内存的,为什么不直接操作物理内存那,有几个原因:首先容易想到的是,计算机中运行的程序不可能只是一个,如果大家都直接操作物理内存,有安全问题,即 A 程序有可能会破坏 B 程序的内容;其次,如果直接操作物理内存,那么每个程序使用的空间都不是一样的,这也带来了不方便。所以前辈们给出的方案是采用虚拟内存的方式,虚拟内存是物理内存和磁盘的抽象,提供一个统一的虚拟内存,每个程序的虚拟范围是一致的,简化了编程操作;通过一个叫 MMU(存储器管理单元)的芯片将虚拟地址转成物理地址;还有一点,虚拟内存将内存和磁盘结合一起看待,将不常用的数据存放在磁盘上,需要用的时候,才从磁盘调到内存中,从而提升内存的利用率。
一 虚拟内存空间划分
1.1 基本概述
虚拟内存有多大那,在 32 位系统上,虚拟内存可以访问的存储空间为:232;64 位系统中虚拟存储地址空间范围不是 264,而是一般为:248,以为现在还用不了这么大的空间,可以通过命令:
[root@izbp14xswj2tx6qgnz9dllz ~]# cat /proc/cpuinfo
......
address sizes : 46 bits physical, 48 bits virtual
power management:
结果中:
address sizes : 46 bits physical, 48 bits virtual
表示物理地址为:46 位,虚拟地址为 48 位。
这么大的空间是如何划分的那:
每个进程启动之后,都会有自己的页表(存储的是虚拟地址和物理地址的映射),结合 MMU 完成虚拟地址的转换。好处除了上面所说的外,还可以简化共享,比如我们多个 C 程序在运行的时候都需要 libc 库,如果每个程序都加载一份的话,势必会造成资源的浪费,我们可以在内存中只保留一份数据。每个应用程序关于库的虚拟地址,映射到共享存储,简化了数据的共享。
虚拟内存采用页为单位进行存储管理的,典型的页面大小为 4KB 或 1MB,linux 下可以通过 getconf 命令查看。页面大小顺便说一句,有些特殊的场景喜欢设置超级大页,比如在 DPDK 这种高性能网络处理库中,常设置大页为 1GB,目的是为了减少页表的条目,可以让页表完全保存的高速缓存中,提升内存分配效率。
[root@ ~]# getconf PAGE_SIZE
4096
1.2 虚拟空间的磁盘部分
我们知道,虚拟空间是内存和磁盘的抽象,如果内存空间不够的时候,如果我们再运行程序,势必需要将内存的保存数据换出内存,保存到磁盘中,将需要加载到内存中的数据从磁盘再加载到内存中,这就是常说的换入换出。
磁盘上保存内存换出的空间一般叫 swap 空间,在 linux 下,通过 free 命令查看 swap 空间大小,下面机器是
[root@localhost ~]# cat /proc/swaps
Filename Type Size Used Priority
/dev/dm-1 partition 2097148 0 -1
[root@localhost ~]# free -h
total used free shared buff/cache available
Mem: 2.8G 155M 2.4G 9.8M 234M 2.4G
Swap: 2.0G 0B 2.0G
[root@localhost ~]# swapon -s
文件名 类型 大小 已用 权限
/dev/dm-1 partition 2097148 0 -1
这里面想说的是,一些运行大数据软件的主机上,很多人倾向于将 swap 空间直接关掉,因为 linux 下,有时候内存还是够的情况下,仍然使用 swap 空间,造成了 java 一些程序在 gc 的时候会超时。当时是不能直接关闭的,因为关闭的话,如果系统的内存不够的话,会把内存占用大的进程 kill 掉,所以我们只是将 swap 空间的使用倾向更小,而不是完全关闭。
echo "vm.swappiness=1" >> /etc/sysctl.conf
sysctl -p
sysctl -a|grep swappiness
注意:swap 空间大小限制当前运行进程可以分配的虚拟存储页面的总数。
二 虚拟存储器工作原理
虚拟地址到物理地址的翻译,其实就是一个从虚拟地址空间向物理内存空的映射,通过 MMU 结合页表来实现地址的翻译,页表的地址又在哪里了,cpu 中有个专门保存页表的寄存器:PTBR 指向当前页表的基地址。
每个虚拟地址有两部分组成:虚拟页号(VPN)+ 虚拟页偏移量(VPO),如上图,当 cpu 生成一个虚拟地址的时候,将地址传递给 MMU 开始翻译,MMU 利用虚拟地址的 VPN 来选择 PTE(页表项),如果位置有效,则将页表项中的物理页号 PPN 取出来,结合虚拟地址中的 VPO 生成物理地址。
2.1 缺页
处理步骤如下:
处理器生成一个虚拟地址,将它传递给 MMU。
MMU 将虚拟地址的 VPN 取出来,结合 CPU 的寄存器保存的页表地址去取此虚拟地址对应的 PTE
从高速缓存或内存中获取 PTE,检查 PTE 的有效位是否为 0,即此虚拟地址是否已经对应真正的物理内存地址。
有效位为 0,发生缺页异常,调用缺页异常处理程序,将磁盘上对应地址的新页调入到内存中,如果内存满了,则需要进行内存页面的换出,换出页面,用新加载的页面替换。
其实我们注意到,虚拟内存的翻译过程还是挺麻烦的,需要先取出 PTE,再通过物理地址真正去取数据,当时如果页表不在高速缓存中,或者此物理地址页面不在高速缓存中,需要从内存再加载下,先将数据加载到高速缓存中。
2.2 TLB
为了加快地址翻译的速度,在 MMU 中包含一个关于 PTE 的小缓存,称为 TLB,每一行都保存一个 PTE,MMU 在翻译地址的时候,先到 TLB 中查找,如果命中,直接取回 PTE,如果不命中,则需要从高速缓存 L1 或内存中取到 PTE,替换 TLB 中数据,再进入下一步的翻译:这和前面说的内存大页结合起来,大的页面,页表的条目数必然小,可能 TLB 就可以完全存放下,这样就可以加快地址翻译的速度了。
2.3 多级页表
现实永远比理论要复杂,我们前面分析的是单页表,即通过一个页表完成地址翻译,当时我们知道每个进程都有一个地址范围很大的地址空间,比如在 32 位系统中位 4G,而我们实际上是使用不了这么多的空间的,如果用页表来存储所有的页表项的话,按照每个页表项 4B 来计算,每个页面 4KB 来计算,一个进程将需要 4MB 的页表占用,这是极大的浪费,因为很多虚拟地址空间是程序没用的,为解决这个问题引入了多级页表:
一个一级页表对应了 1024 个二级页表,而每个二级 PTE 对应 4KB 的页面,所以一个一级页表对应的空间为 4MB,1024 个一级页表就满足了 4GB 的总空间。一级页表项的数据不是都有值,如果程序没使用这个范围内的空间则对应的一级页表的 PTE 为空,如果有这个范围内地址一级页表保存的是指向二级页表的基地址。
2.4 奔腾处理器系列地址翻译
说明:
cpu 产生虚拟地址
虚拟地址送 MMU 进行地址翻译,MMU 先查看 TLB 是否命中,命中直接得到物理地址
不命中,则需要从内存或高速缓存中查到 PTE,通过四级页表得到物理地址
物理地址得到后查询 L1 缓存,看是否命中,如果不命中到其他缓存和内存中查询加载。
三 虚拟存储区域
在虚拟内存划分图中,程序将整个虚拟内存空间分为不同的段,text,data,bss 都是不同的段,每个虚拟页面都要在特定的段中,不存在段中的虚拟页面或地址是无法使用的,我们在程序中通过 fork 命令创建进程的时候,会在内核中创建各种数据结构,比如 pid,为了给子进程创建新的虚拟存储,需要创建 task_struct 数据结构和复制原理的页表,task_struct 数据结构图如下:
重点看两个:pgd 指向页面目录表的基地址;mmap 保存的是区域结构,我理解就是段结构。保存着区域的开始地址,结束地址,权限以及是否共享等。
3.1 MMAP
MMAP 是一个函数,通过这个函数可以创建一个新的虚拟存储器区域,可以映射一个普通的文件到一个虚拟存储器区域,也可以映射一个匿名文件到虚拟内存区域;如果是后者,匿名文件由内核来创建,包含内容全部是 0,比如用在.bss 段初始化等。
说明:start:从地址 start 开始处创建,通常为 NULL;length:连续对象的大小;port:访问权限(PROT_EXEC\PROT_READ\PROT_WRITE\PROT_NONE); flags:被映射对象的位(MAP_ANOE\MAP_PRIVATE\MAP_SHARED); fd: 指定的磁盘文件;offset:距离磁盘文件偏移的位置处开始;返回值:调用成功,返回新区域的地址。
也许我们更关心这个函数什么优势那,这个函数在映射之后,并没有实际将文件读入到内存,而是只是建立了虚拟内存段和文件的映射,真正操作的时候,会像前面介绍的一样,发生缺页异常,会真正将文件内容从磁盘加载到物理内存中。
这样读文件有什么好处那,可以减少一次内存拷贝,提升读文件的性能:一般情况下读文件:磁盘 -> 内核缓存 -> 用户内存;用 mmap 将 fd 进行映射后:磁盘 -> 用户内存 可以看到只有一次拷贝。读取和修改文件内容:
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
int main(int argc, char *argv[])
{
int fd=open("test.file",O_RDWR,0644);
struct stat statbuf;
char* start;
char buf[2]={ 0 };
int ret=0;
fstat(fd,&statbuf);
start=(char*)mmap(NULL,statbuf.st_size,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
do{
*buf=start[ret++];
printf("%c", *buf);
}while (ret<statbuf.st_size);
getchar();
memset(start,0x0,statbuf.st_size);
sprintf(start,"%s","test");
munmap(start,statbuf.st_size);
close(fd);
return 0;
}
通过以下命令可以查看虚拟内存的映射情况:
[root@localhost ~]# cat /proc/5123/smaps |grep "test.file" -A15
7fe7b8f22000-7fe7b8f23000 -w-s 00000000 fd:00 3185332 /home/miaohq/tests/test.file
Size: 4 kB
Rss: 0 kB
Pss: 0 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 0 kB
Referenced: 0 kB
Anonymous: 0 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Locked: 0 kB
VmFlags: wr sh mr mw me ms sd
mmap 还可以实现不同不同进程间的内存共享,从而实现通信;还可以实现用户空间和内核空间的高效交互,不过我没用过无法提供什么例子。
3.2 Malloc
相对于 mmap 这种低级函数,我们在 c 中编程更多用的是 malloc 进行内存分配,其实 malloc 看做是 mmap 和 sbrk 的封装:
#include <unistd.h>
void * sbrk(int incr);
看上面虚拟内存结构图中有 brk 指针,这个函数通过将内核的 brk 指针的增加来扩充或收缩堆。如果成功返回 brk 的旧值,如果失败返回-1。
一般来说,对于小于 128k 的内存,malloc 采用在现有的堆空间中搜索,按照堆分配算法分配并返回,对于大于 128k 的内存,一般采用 mmap 分配一块匿名空间。可以通过如下方式实现 malloc,不过现实中这样分配的都是以内存页为单位的即一般为 4KB 为单位做内存分配的,会造成很多浪费。
void * malloc(size_t bytes)
{
void * ret = mmap(0,bytes,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,0,0);
if (ret == MAP_FAILED) {
return 0;
}
return ret;
}