虚拟内存分页机制的地址映射
概述
在之前的文章虛擬內存對分頁機制做了簡單的介紹. 還有一個疑問, 那就是如何將虛存中的邏輯地址映射為物理地址呢? 今天就來簡單分析一下.
對于一個分頁的地址來說, 一般包含兩個元素:
- 頁號: 第幾頁
- 偏移量: 當前頁的第幾個字節
以下以 addr_virtual(p, o)表示一個邏輯地址, 以addr_real(p, o)表示一個物理地址(物理地址也是分頁的).
頁表
第一步先想一下, 如果要根據一個邏輯地址找到對應的物理地址, 那么這個對應關系必然是存放在某個地方的, 因為映射是沒有規律的嘛. 應該使用什么數據結構來存儲呢?
因為在分頁中, 頁是一個最小單位, 故我們只需要頁號的映射關系即可, 邏輯地址和物理地址的頁大小相同, 偏移量也是完全一樣的.
根據 key 尋找 value, 這不就是一個map嘛. 再一看這個map的key, 頁號都是數字, 而且是順序連續的. 這不就是個數組嘛. 漂亮, 就是一個數組.
也就是說, 這個頁表是一個以邏輯頁號為索引, 物理頁號為值的一維數組. 那么這么一個地址轉換流程大致如下:
頁表中的元素并不是僅僅存儲物理頁號, 還存儲了一些額外的標志位, 用來標識當前頁的屬性, 簡單舉幾個例子:
- 存在位: 當前也是否加載到內存中了. 若沒有加載需要操作系統進行加載
- 修改位: 當前頁在內存中是否被修改過. 若修改過, 則回收物理內存時需要將其寫會硬盤
- 內核權限: 當前頁是否需要內核模式才能訪問
- 是否可讀位
- 是否可寫位
- 是否可執行
- 等等
因為每個進程都擁有獨自的虛擬內存, 故每個進程都需要自己的頁表.
為了提高運行效率, 這個翻譯過程是通過硬件完成的, 既CPU中的內存管理單元mmu來完成的.
是不是看著還挺簡單的? 好, 介紹完畢, 文章到此結束.
問題與解決方案
哈哈, 開個玩笑. 哪有這么容易就結束了. 現在簡單分析一下這個簡單模型存在的問題. 根據算法的經驗, 大部分算法實現, 要么時間復雜度太高, 要么空間復雜度太高.
時間問題
試想一下訪問一個內存的步驟:
查找頁表的操作也是一次內存訪問. 也就是說, CPU每訪問一次內存就需要一次額外的內存訪問. 執行時間直接翻倍.
解決方案
解決的方法就是我們現在已經用爛了的: 緩存. 內存到 CPU之間已經有了L1 L2緩存, 在mmu中還存在著一個頁表的緩存TLB. 每次地址翻譯的步驟如下(忽略缺頁的情況):
TLB存在的前提, 是程序的訪問具有局部性. 終于, 又是程序的局部性救了我們.
空間問題
我們簡單計算一下要存放這個頁表需要多少空間.
在32位CPU 中, 可訪問的邏輯地址空間為4G. 假設頁大小為: 4kb, 那么總頁數為:
4G / 4kb = (2^32) / (2^12) = 2^20 = 1mb
再假設, 頁表的每個元素需要4個字節, 那么需要的總空間為: 4mb. 每個進程僅僅是存儲頁表就需要4mb. 而且這還是32位, 如果是64位呢? 可以計算下看看, 結果很夸張.
解決方案
借鑒一下內存分頁的思路, 我們將內存分為 n 個頁, 就可以按需加載了. 同樣, 也可以將一個大的頁表分為n個小的頁表, 就可以進行部分加載了, 既多級頁表
以最簡單的二級頁表進行說明, 其虛擬內存劃分大致如下:
頁表的結構大致如下:
注意, 此時邏輯地址中頁號內容存儲了兩個內容:
為什么說多級頁表解決了空間的問題呢? 再次根據程序的局部性原理, 一級頁表中的大部分對應的值為空, 既大部分二級頁表并沒有加載到內存中.
此時再算一下, 還是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緩存了所有頁的映射, 此時兩種方式是沒有差別的. 但是:
口說無憑
當然, 口說無憑, 為了對上面頁的切換機制有個直觀的感受, 我們通過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
好, 萬事具備, 來看看結果:
方式一
方式二
可以看到, 方式一想比方式二要好很多.
故, 對于性能要求很高的程序, 當你沒有優化方向了, 局部性可能會幫到你.
原文鏈接: https://hujingnb.com/archives/698
創作挑戰賽新人創作獎勵來咯,堅持創作打卡瓜分現金大獎總結
以上是生活随笔為你收集整理的虚拟内存分页机制的地址映射的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 代码整洁之道小结
- 下一篇: HTML字体小于12谷歌不兼容,Chro