虚拟内存分页机制的地址映射

概述

在之前的文章虚拟内存对分页机制做了简单的介绍. 还有一个疑问, 那就是如何将虚存中的逻辑地址映射为物理地址呢? 今天就来简单分析一下.

对于一个分页的地址来说, 一般包含两个元素:

  • 页号: 第几页
  • 偏移量: 当前页的第几个字节

以下以 addr_virtual(p, o)表示一个逻辑地址, 以addr_real(p, o)表示一个物理地址(物理地址也是分页的).

页表

第一步先想一下, 如果要根据一个逻辑地址找到对应的物理地址, 那么这个对应关系必然是存放在某个地方的, 因为映射是没有规律的嘛. 应该使用什么数据结构来存储呢?

因为在分页中, 是一个最小单位, 故我们只需要页号的映射关系即可, 逻辑地址和物理地址的页大小相同, 偏移量也是完全一样的.

根据 key 寻找 value, 这不就是一个map嘛. 再一看这个mapkey, 页号都是数字, 而且是顺序连续的. 这不就是个数组嘛. 漂亮, 就是一个数组.

也就是说, 这个页表是一个以逻辑页号为索引, 物理页号为值的一维数组. 那么这么一个地址转换流程大致如下:

image-20211121164930820

页表中的元素并不是仅仅存储物理页号, 还存储了一些额外的标志位, 用来标识当前页的属性, 简单举几个例子:

  • 存在位: 当前也是否加载到内存中了. 若没有加载需要操作系统进行加载
  • 修改位: 当前页在内存中是否被修改过. 若修改过, 则回收物理内存时需要将其写会硬盘
  • 内核权限: 当前页是否需要内核模式才能访问
  • 是否可读位
  • 是否可写位
  • 是否可执行
  • 等等

因为每个进程都拥有独自的虚拟内存, 故每个进程都需要自己的页表.

为了提高运行效率, 这个翻译过程是通过硬件完成的, 既CPU中的内存管理单元mmu来完成的.

是不是看着还挺简单的? 好, 介绍完毕, 文章到此结束.

问题与解决方案

哈哈, 开个玩笑. 哪有这么容易就结束了. 现在简单分析一下这个简单模型存在的问题. 根据算法的经验, 大部分算法实现, 要么时间复杂度太高, 要么空间复杂度太高.

时间问题

试想一下访问一个内存的步骤:

  1. 查找页表找到对应的物理地址
  2. 访问物理地址

查找页表的操作也是一次内存访问. 也就是说, CPU每访问一次内存就需要一次额外的内存访问. 执行时间直接翻倍.

解决方案

解决的方法就是我们现在已经用烂了的: 缓存. 内存到 CPU之间已经有了L1 L2缓存, 在mmu中还存在着一个页表的缓存TLB. 每次地址翻译的步骤如下(忽略缺页的情况):

  1. 查看TLB中是否存在缓存, 若存在直接获取
  2. TLB中不存在, 从内存页表中获取并放入TLB

TLB存在的前提, 是程序的访问具有局部性. 终于, 又是程序的局部性救了我们.

空间问题

我们简单计算一下要存放这个页表需要多少空间.

在32位CPU 中, 可访问的逻辑地址空间为4G. 假设页大小为: 4kb, 那么总页数为:

4G / 4kb = (2^32) / (2^12) = 2^20 = 1mb

再假设, 页表的每个元素需要4个字节, 那么需要的总空间为: 4mb. 每个进程仅仅是存储页表就需要4mb. 而且这还是32位, 如果是64位呢? 可以计算下看看, 结果很夸张.

解决方案

借鉴一下内存分页的思路, 我们将内存分为 n 个页, 就可以按需加载了. 同样, 也可以将一个大的页表分为n个小的页表, 就可以进行部分加载了, 既多级页表

以最简单的二级页表进行说明, 其虚拟内存划分大致如下:

image-20211121175855428

页表的结构大致如下:

image-20211121174340432

注意, 此时逻辑地址中页号内容存储了两个内容:

  1. 一级页表的索引
  2. 二级页表的索引

为什么说多级页表解决了空间的问题呢? 再次根据程序的局部性原理, 一级页表中的大部分对应的值为空, 既大部分二级页表并没有加载到内存中.

此时再算一下, 还是32位CPU, 页的大小还是4kb, 页中元素大小还是4字节. 此时假设一级页表每个元素负责4mb的空间. 那么一级页表占用的总页数为: 4G / 4mb = (2^32) / (2^22) = 2^10. 一级页表占用空间为: (2^10) * (2^2)=4kb

每个二级页表的总页数为: 4mb / 4kb = (2^22) / 2(12) = 2^10 = 1024, 占用空间: (2^10) * (2^2) = 4kb

其中只有一级页表是常驻内存的, 二级页表只需要加载其中一部分. 空间直接降下来了.

但是, 又带来一个新的问题, 现在获取一个物理地址, 需要访问两次内存, 这不是比原来还要慢么? 别忘了刚刚的TLB, 有了这一层缓存, 大部分访问都在mmu内部进行了. 又又又一次, 程序的局部性原理救了我们.

多级页表 , 将二级页表进一步扩展, 就可以得到多级页表了, 不再赘述.

程序的局部性

知道了地址是如何映射的, 对我们平常写程序有什么帮助呢?

页的转换是根据程序的局部性, 所以我们在写代码的时候, 要尽量保证写出来的是具有局部性的, 举个例子:

int main() {int i, j;int arr[1024][1024];// 第一种方式for(i = 0; i < 1024; i++){for(j = 0; j < 1024; j++){global_arr[i][j] = 0;}}// 第二种方式for(j = 0; j < 1024; j++){for(i = 0; i < 1024; i++){global_arr[i][j] = 0;}}
}

上面这段代码目的很简单, 给一个1024*1024的二维数组进行初始化. 你能看出这两种方式有什么不同么?

遍历方式不同, 方式一是一行一行的遍历, 方式二则是一列一列的遍历.

我们知道, 二维数组在内存中是顺序存储的. 也就是说, 一个二维数组: [[1, 2, 3], [4, 5, 6]], 在内存中的存储顺序是: 1, 2, 3, 4, 5, 6.

而我们这个数组, 每行1024个int元素, 正好是4kb 一页的大小.

因此, 方式一访问页的顺序是: page1, page1 ... page1024, page1024, 每页访问1024次后,切换到下一页, 共发生 1024 次页的切换

而, 方式二访问页的顺序是: page1, page2...page1024 ... page1, page2...page1024, 依次访问每一页, 每页访问1024次, 共发生 1024*1024次页的切换

性能高下立判, 方式一更加符合局部性原理, 方式二的访问太跳跃了.

当然, 现在内存很大的时候, 所有内容都加载到了内存中, 同时TLB缓存了所有页的映射, 此时两种方式是没有差别的. 但是:

  1. TLB容量不足, 新的缓存会淘汰旧的缓存, 频繁访问不同的页会造成更多的缓存失效
  2. 若内存容量不足, 写入新的页会淘汰旧的页, 频繁访问不同的页会导致更多内存的换入换出.

口说无凭

当然, 口说无凭, 为了对上面页的切换机制有个直观的感受, 我们通过getrusage函数来获取程序运行的页切换信息. 代码如下:

#include <stdio.h>
#include <sys/resource.h>const int M = 1024;
// 增加列的大小, 以使得效果明显. 10mb
const int N = 1024*10;
// 因为限制了栈的大小, 故将变量提升为全局, 放到堆中
int global_arr[1024][1024*10];int main() {int i, j;// 第一种方式for(i = 0; i < M; i++){for(j = 0; j < N; j++){global_arr[i][j] = 0;}}// 第二种方式
//    for(j = 0; j < N; j++){
//        for(i = 0; i < M; i++){
//            global_arr[i][j] = 0;
//        }
//    }struct rusage usage;getrusage(RUSAGE_SELF, &usage);printf("页回收次数: %ld\n", usage.ru_minflt);printf("缺页中断的次数: %ld\n", usage.ru_majflt);
}

现在电脑跑这么个小程序还是比较简单的, 不会有什么区别, 因此还要对进程的内存进行限制. 我是通过限制docker可用内存来实现的:

docker run -it -m 6m --memory-swap -1 debian bash

好, 万事具备, 来看看结果:

方式一

image-20211121193445493

方式二

image-20211121193542561

可以看到, 方式一想比方式二要好很多.

故, 对于性能要求很高的程序, 当你没有优化方向了, 局部性可能会帮到你.

原文链接: https://hujingnb.com/archives/698

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/508363.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

虚拟内存分页机制的页面置换

前言 之前简单介绍过虚拟内存是如何与物理内存进行地址映射的: 虚拟内存分页机制的地址映射, 但是仅仅地址映射是不够的, 在地址映射说过会有缺页的情况, 此时就需要操作操作系统将缺少的页加载到内存中. 但是, 如果内存满了怎么办呢? 毕竟虚拟内存一般都要大于物理内存的, 不…

Kubernetes各个组件的概念

前言 Kubernetes中的概念太多了, 什么Pod Service Deployment 等等等等, 给刚接触的我都整蒙了. 通过几天观察下来, 说一下我对各个组件的理解. 此文章仅仅对这些概念做一个简单的介绍, 不至于后面看其他文章的时候一头雾水. Node Node很好理解. 就是服务实际运行的实例, 可…

Kubernetes中Pod生命周期

在 Kubernetes中Pod是容器管理的最小单位, 有着各种各样的Pod管理器. 那么一个Pod从启动到释放, 在这期间经历了哪些过程呢? Pod自开始创建, 到正常运行, 再到释放, 其时间跨度及经历的阶段大致如下: 说一下各个阶段的作用以及是为了解决什么问题. 容器调度和下载镜像的过程就…

Kubernetes存储卷的使用

在Kubernetes中, 有这不同方式的内容挂载, 简单记录一下他们的配置方式. ConfigMap 配置内容 内容配置 apiVersion: v1 kind: ConfigMap metadata:name: test-config data: # 添加配置的 key-value 内容test-key: test-value引入 apiVersion: v1 kind: Pod spec: containe…

响应HTTP服务的shell脚本

以下内容均为第一版, 实际使用请查看最新信息, 转至: https://hujingnb.com/archives/729 前言 兄弟萌, 我实现了一个实用的小工具, 特来分享. 事情刚开始是这样的, 我需要一个脚本来实现代码仓库web hook的任务, 首先想到的是直接调用php, 但是php-fpm是以www-data用户运行…

wait函数的作用

前言 在编写C程序的时候, 通过fork函数来创建新的进程, wait函数来等待子进程结束. 那么就有一个问题了, 什么情况下父进程需要等待子进程结束后继续执行呢? 如果需要等待子进程结束, 那直接将操作放到父进程执行不就醒了么? 反正等着也是等着. 当然, 还有有一种情况, 任务…

OAuth1.0介绍

背景 为什么需要OAuth授权呢? 最典型的应用场景就是第三方登录了, 我们开发了一个网站希望用户可以QQ登录, 但是怎么能拿到用户的 QQ 信息呢? 用户将 账号密码告诉我们当然可以, 但是这样有如下隐患: 我们拿到了用户的密码, 这样很不安全. 而且任意一个应用被黑, 所有相关…

PHP 数组的内部实现

前言 这几天在翻github的时候, 碰巧看到了php的源码, 就 down 下来随便翻了翻. 地址: https://github.com/php/php-src 那么PHP中什么玩意最引人注目嘞? 一定是数组了, PHP中的数组太强大了, 于是就想着不如进去看看数组的实现部分. 这篇文章打算全程针对代码进行解读了. 以…

go1.18新特性

前言 最近突然发现golang更新版本1.18了, 于是迫不及待的来看看这个版本加了些什么新特性. 没准就有之前困扰很久的问题, 在新版本被官方解决了呢. 先简单概述一下都有些什么变化, 后面再细说: 增加泛型的支持系统库方法增加修复 bug 另外, 像"系统内核更新"这种…

base64编码原理

引出 众所周知, ASICC编码共127个, 使用了7个bit进行编码. 而文件在存储的时候是以 字节为单位, 也就是8bit. 这就难免导致有一部分编码是没有定义在ASICC编码中的. 而在网络中传输二进制数据的时候(字符串本质上也是二进制数据嘛), 如果直接传输比特流, 倒也不是不可以, 只是…

页面加载速度-合并资源文件

前言 一直觉得自己的博客站点页面加载很慢, 就想着去优化一下. 呐, 下图是一次文章页面的加载, 需要2.5s. 其中 js 文件就有18个. 众所周知, 浏览器对资源文件的并行下载数量是有限制的(不同浏览器限制不同). 也就是说, 这18个 js 文件是无法同时下载的, 再说了, 页面中还有其…

hbase/thrift/go连接失败

问题 在通过Go连接hbase的过程中, 发现 get操作可以查到数据, 但是scanner命令访问数据失败, 也没有报错, 就是单纯的查不到数据. 而且Python PHP都一切正常. 这里简单复述一下我出现问题的情况, 安装过程和网上大部分内容一致, 这里简单列一下, 只是为了查询问题时参考安装过…

常用搜索引擎及语法

在平常需要进行搜索的时候是不是只知道Google Baidu ?? 他们其实是全文搜索引擎, 还有一些特定领域的搜索引擎. 而且, 搜索时可以添加特定语法, 让你的搜索事半功倍. 本文整理各种场景下使用的搜索引擎, 以及各个搜索引擎支持的语法, 不定期进行更新. 如果你知道其他搜索引…

自旋锁与互斥锁

前言 在编程中经常需要使用到互斥. 互斥就是, 这个事情只能有一个人干, 我正在做着的时候, 别人要想做这件事就得等我做完了. 互斥的实现是通过锁的机制, 也就是我把这块锁上了, 别人就进不来了, 等我做完再把锁释放掉. 但是, 前辈们已经证明了, 要想单纯的在软件层面上实现…

printf缓冲区踩坑

问题 碰到了这样一段代码(经过简化的): #include "stdio.h" #include "unistd.h" #include "sys/wait.h"int main(){fork();printf("1\n");fork();printf("1\n");wait(NULL);return 0; }这里我们简单算一下, 结果会打印几…

进程切换时是如何保存上下文的

前言 当前操作系统大部分采用分时的进程调度, 既每个进程运行一小段时间, 然后切换到下一个进程运行, 依次往复. 当进程运行的时候是独占CPU的, 此时操作系统是无法强行介入的, 为了将执行权让出来, 就需要硬件的配合了. 硬件每个一个时钟周期(比如10ms), 就会产生一个时钟中…

GO/testing包

前言 之前在写GO单元测试的时候, 使用了这个结构testing.T. 进来无事翻了翻, 发现testing包中还有一些其他的结构体, 想来是不同用处. 没想到GO的testing包竟然默默做了这么多支持, 之前竟然不知道. 在testing包中包含一下结构体: testing.T: 这就是我们平常使用的单元测试t…

CPU的分支预测

前言 最近在进行性能调优的时候, 碰到了这样的一段代码(为了展示问题而简化的代码): <?php // 第一次运行 $start microtime(true); for ($i 0; $i < 100; $i) {for ($j 0; $j <1000; $j) {for ($k 0;$k < 10000; $k) {}} } $end microtime(true); echo fi…

Golang Context 简介

前言 在写Golang程序调用各种第三方库的时候, 经常会传一个叫做Context的参数. 之前基本上见到接Context, 根本不管是干什么用的, 直接无脑context.Background(). 但是, 传着传着就不免发生一些小疑问, 这个参数到底是干什么用的呢? 这么多库都在使用, 至少说明其是Golang中…

PHP获取Opcode及C源码

是什么 在开始之前, 必须要先介绍一下Opcode是什么. 众所周知, Java在执行的时候, 会将.java后缀的文件预先编译为.class字节码文件, JVM加载字节码文件进行解释执行. 而字节码文件存在的意义, 就是为了加速执行. 那么PHP的Opcode与之类似, 也是从.php文件到执行的过程中, 所…