[TOC]
# 簡介
關于虛擬地址和物理地址的映射有很多思路,我們可以假設以程序為單位,把一段與程序運行所需要的同等大小的虛擬空間映射到某段物理空間。
例如程序A需要 10MB 內存,虛擬地址的范圍是從 0X00000000 到 0X00A00000,假設它被映射到一段同等大小的物理內存,地址范圍從 0X00100000 到 0X00B00000,即虛擬空間中的每一個字節對應于物理空間中的每一個字節。
程序運行時,它們的對應關系如下圖所示:

當程序A需要訪問 0X00001000 時,系統會將這個虛擬地址轉換成實際的物理地址 0X00101000,訪問 0X002E0000 時,轉換成 0X003E0000,以此類推。
這種以整個程序為單位的方法很好地解決了不同程序地址不隔離的問題,同時也能夠在程序中使用固定的地址。
**地址隔離**
如上圖所示,程序A和程序B分別被映射到了兩塊不同的物理內存,它們之間沒有任何重疊,如果程序A訪問的虛擬地址超出了 0X00A00000 這個范圍,系統就會判斷這是一個非法的訪問,拒絕這個請求,并將這個錯誤報告給用戶,通常的做法就是強制關閉程序。
**程序可以使用固定的內存地址**
虛擬內存無論被映射到物理內存的哪一個區域,對于程序員來說都是透明的,我們不需要關心物理地址的變化,只需要按照從地址 0X00000000 到 0X00A00000 來編寫程序、放置變量即可,程序不再需要重定位。
**內存使用效率問題**
以程序為單位對虛擬內存進行映射時,如果物理內存不足,被換入換出到磁盤的是整個程序,這樣勢必會導致大量的磁盤讀寫操作,嚴重影響運行速度,所以這種方法還是顯得粗糙,粒度比較大。
# 內存分頁機制
我們知道,當一個程序運行時,在某個時間段內,它只是頻繁地用到了一小部分數據,也就是說,程序的很多數據其實在一個時間段內都不會被用到。
以整個程序為單位進行映射,不僅會將暫時用不到的數據從磁盤中讀取到內存,也會將過多的數據一次性寫入磁盤,這會嚴重降低程序的運行效率。
現代計算機都使用分頁(Paging)的方式對虛擬地址空間和物理地址空間進行分割和映射,以減小換入換出的粒度,提高程序運行效率。
分頁(Paging)的思想是指把地址空間人為地分成大小相等(并且固定)的若干份,這樣的一份稱為一頁,就像一本書由很多頁面組成,每個頁面的大小相等。如此,就能夠以頁為單位對內存進行換入換出:
* 當程序運行時,只需要將必要的數據從磁盤讀取到內存,暫時用不到的數據先留在磁盤中,什么時候用到什么時候讀取。
* 當物理內存不足時,只需要將原來程序的部分數據寫入磁盤,騰出足夠的空間即可,不用把整個程序都寫入磁盤。
**關于頁的大小**
頁的大小是固定的,由硬件決定,或硬件支持多種大小的頁,由操作系統選擇決定頁的大小。比如 Intel Pentium 系列處理器支持 4KB 或 4MB 的頁大小,那么操作系統可以選擇每頁大小為 4KB,也可以選擇每頁大小為 4MB,但是在同一時刻只能選擇一種大小,所以對整個系統來說,也就是固定大小的。
目前幾乎所有PC上的操作系統都是用 4KB 大小的頁。假設我們使用的PC機是32位的,那么虛擬地址空間總共有 4GB,按照 4KB 每頁分的話,總共有 2^32 / 2^12 = 2^20 = 1M = 1048576 個頁;物理內存也是同樣的分法。
**根據頁進行映射**
下面我們通過一個簡單的例子來說明虛擬地址是如何根據頁來映射到物理地址的,請先看下圖:

程序1和程序2的虛擬空間都有8個頁,為了方便說明問題,我們假設每頁大小為 1KB,那么虛擬地址空間就是 8KB。假設計算機有13條地址線,即擁有 2^13 的物理尋址能力,那么理論上物理空間可以多達 8KB。但是出于種種原因,購買內存的資金不夠,只買得起 6KB 的內存,所以物理空間真正有效的只是前 6KB。
當我們把程序的虛擬空間按頁分隔后,把常用的數據和代碼頁加載到內存中,把不常用的暫時留在磁盤中,當需要用到的時候再從磁盤中讀取。上圖中,我們假設有兩個程序 Program 1 和 Program 2,它們的部分虛擬頁面被映射到物理頁面,比如 Program 1 的 VP0、VP1 和 VP7 分別被映射到 PP0、PP2 和 PP3;而有部分卻留在磁盤中,比如 VP2、VP3 分別位于磁盤的 DP0、DP1中;另外還有一些頁面如 VP4、VP5、VP6 可能尚未被用到或者訪問到,它們暫時處于未使用狀態。
> 這里,我們把虛擬空間的頁叫做虛擬頁(VP,Virtual Page),把物理內存中的頁叫做物理頁(PP,Physical Page),把磁盤中的頁叫做磁盤頁(DP,Disk Page)。
圖中的線表示映射關系,可以看到,Program 1 和 Program 2 中的有些虛擬頁被映射到同一個物理頁,這樣可以實現內存共享。
Program 1 的 VP2、VP3 不在內存中,但是當進程需要用到這兩個頁的時候,硬件會捕獲到這個消息,就是所謂的頁錯誤(Page Fault),然后操作系統接管進程,負責將 VP2 和 PV3 從磁盤中讀取出來并且裝入內存,然后將內存中的這兩個頁與 VP2、VP3 之間建立映射關系。
# 實現機制
現代操作系統都使用分頁機制來管理內存,這使得每個程序都擁有自己的地址空間。每當程序使用虛擬地址進行讀寫時,都必須轉換為實際的物理地址,才能真正在內存條上定位數據。如下圖所示:

內存地址的轉換是通過一種叫做頁表(Page Table)的機制來完成的,這是本節要講解的重點,即:
* 頁表是什么?為什么要采用頁表機制,而不采用其他機制?
* 虛擬地址如何通過頁表轉換為物理地址?
## 直接使用數組轉換
最容易想到的映射方案是使用數組:每個數組元素保存一個物理地址,而把虛擬地址作為數組下標,這樣就能夠很容易地完成映射,并且效率不低。如下圖所示

但是這樣的數組有 2^32 個元素,每個元素大小為4個字節,總共占用16GB的內存,顯現是不現實的!
## 使用一級頁表
既然內存是分頁的,只要我們能夠定位到數據所在的頁,以及它在頁內的偏移(也就是距離頁開頭的字節數),就能夠轉換為物理地址。例如,一個 int 類型的值保存在第 12 頁,頁內偏移為 240,那么對應的物理地址就是 2^12 \* 12 + 240 =?49392。
> 2^12 為一個頁的大小,也就是4K。
虛擬地址空間大小為 4GB,總共包含 2^32 / 2^12 = 2^20 = 1K \* 1K ?= 1M =?1048576 個頁面,我們可以定義一個這樣的數組:它包含 2^20 = 1M 個元素,每個元素的值為頁面編號(也就是位于第幾個頁面),長度為4字節,整個數組共占用4MB的內存空間。這樣的數組就稱為頁表(Page Table),它記錄了地址空間中所有頁的編號。
虛擬地址長度為32位,我們不妨進行一下切割,將高20位作為頁表數組的下標,低12位作為頁內偏移。如下圖所示:

為什么要這樣切割呢?因為頁表數組共有 2^20 = 1M 個元素,使用虛擬地址的高20位作為下標,正好能夠訪問數組中的所有元素;并且,一個頁面的大小為 2^12 = 4KB,使用虛擬地址的低12位恰好能夠表示所有偏移。
注意,表示頁面編號只需要 20 位,而頁表數組的每個元素的長度卻為 4 字節,即 32 位,多出 32 - 20 = 12 位。這 12 位也有很大的用處,可以用來表示當前頁的相關屬性,例如是否有讀寫權限、是否已經分配物理內存、是否被換出到硬盤等。
例如一個虛擬地址 0XA010BA01,它的高20位是 0XA010B,所以需要訪問頁表數組的第?0XA010B 個元素,才能找到數據所在的物理頁面。假設頁表數組第?0XA010B 個元素的值為 0X0F70AAA0,它的高20位為 0X0F70A,那么就可以確定數據位于第 0X0F70A 個物理頁面。再來看虛擬地址,它的低12位是 0XA01,所以頁內偏移也是 0XA01。有了頁面索引和頁內偏移,就可以算出物理地址了。經過計算,最終的物理地址為 0X0F70A \* 2^12 + 0XA01 = 0X0F70A000 + 0XA01 = 0X0F70AA01。
這種思路所形成的映射關系如下圖所示:

可以發現,有的頁被映射到物理內存,有的被映射到硬盤,不同的映射方式可以由頁表數組元素的低12位來控制。
使用這種方案,不管程序占用多大的內存,都要為頁表數組分配4M的內存空間(頁表數組也必須放在物理內存中),因為虛擬地址空間中的高1G或2G是被系統占用的,必須保證較大的數組下標有效。
現在硬件很便宜了,內存容量大了,很多電腦都配備4G或8G的內存,頁表數組占用4M內存或許不覺得多,但在32位系統剛剛發布的時候,內存還是很緊缺的資源,很多電腦才配備100M甚至幾十兆的內存,4M內存就顯得有點大了,所以還得對上面的方案進行改進,壓縮頁表數組所占用的內存。
## 使用兩級頁表
上面的頁表共有 2^20 = 2^10 \* 2^10 個元素,為了壓縮頁表的存儲空間,可以將上面的頁表分拆成 2^10 = 1K = 1024 個小的頁表,這樣每個頁表只包含 2^10 = 1K = 1024 個元素,占用 2^10 \* 4 = 4KB 的內存,也即一個頁面的大小。這 1024 個小的頁表,可以存儲在不同的物理頁,它們之間可以是不連續的。
那么問題來了,既然這些小的頁表分散存儲,位于不同的物理頁,該如何定位它們呢?也就是如何記錄它們的編號(也即在物理內存中位于第幾個頁面)。
1024 個頁表有 1024 個索引,所以不能用一個指針指向它們,必須將這些索引再保存到一個額外的數組中。這個額外的數組有1024個元素,每個元素記錄一個頁表所在物理頁的編號,長度為4個字節,總共占用4KB的內存。我們將這個額外的數組稱為頁目錄(Page Directory),因為它的每一個元素對應一個頁表。
如此,只要使用一個指針來記住頁目錄的地址即可,等到進行地址轉換時,可以根據這個指針找到頁目錄,再根據頁目錄找到頁表,最后找到物理地址,前后共經過3次間接轉換。
那么,如何根據虛擬地址找到頁目錄和頁表中相應的元素呢?我們不妨將虛擬地址分割為三分部,高10位作為頁目錄中元素的下標,中間10位作為頁表中元素的下標,最后12位作為頁內偏移,如下圖所示:

前面我們說過,知道了物理頁的索引和頁內偏移就可以轉換為物理地址了,在這種方案中,頁內偏移可以從虛擬地址的低12位得到,但是物理頁索引卻保存在 1024 個分散的小頁表中,所以就必須先根據頁目錄找到對應的頁表,再根據頁表找到物理頁索引。
例如一個虛擬地址 0011000101 ?1010001100 ?111100001010,它的高10位為?0011000101,對應頁目錄中的第?0011000101 個元素,假設該元素的高20位為 0XF012A,也即對應的頁表在物理內存中的編號為?0XF012A,這樣就找到了頁表。虛擬地址中間10位為?1010001100,它對應頁表中的第?1010001100 個元素,假設該元素的高20位為 0X00D20,也即物理頁的索引為0X00D20。通過計算,最終的物理地址為?0X00D20?\* 2^12 +?111100001010?= 0X00D20F0A。
這種思路所形成的映射關系如下圖所示:

> 圖中的點狀虛線說明了最終的映射關系。圖中沒有考慮映射到硬盤的情況。
采用這樣的兩級頁表的一個明顯優點是,如果程序占用的內存較少,分散的小頁表的個數就會遠遠少于1024個,只會占用很少的一部分存儲空間(遠遠小于4M)。
在極少數的情況下,程序占用的內存非常大,布滿了4G的虛擬地址空間,這樣小頁表的數量可能接近甚至等于1024,再加上頁目錄占用的存儲空間,總共是 4MB+4KB,比上面使用一級頁表的方案僅僅多出4KB的內存。這是可以容忍的,因為很少出現如此極端的情況。
也就是說,使用兩級頁表后,頁表占用的內存空間不固定,它和程序本身占用的內存空間成正比,從整體上來看,會比使用一級頁表占用的內存少得多。
## 使用多級頁表
對于64位環境,虛擬地址空間達到 256TB,使用二級頁表占用的存儲空間依然不小,所以會更加細化,從而使用三級頁表甚至多級頁表,這樣就會有多個頁目錄,虛擬地址也會被分割成多個部分,思路和上面是一樣的
# MMU
在CPU內部,有一個部件叫做MMU(Memory Management Unit,內存管理單元),由它來負責將虛擬地址映射為物理地址,如下圖所示:

在頁映射模式下,CPU 發出的是虛擬地址,也就是我們在程序中看到的地址,這個地址會先交給 MMU,經過 MMU 轉換以后才能變成了物理地址。
即便是這樣,MMU也要訪問好幾次內存,性能依然堪憂,所以在MMU內部又增加了一個緩存,專門用來存儲頁目錄和頁表。MMU內部的緩存有限,當頁表過大時,也只能將部分常用頁表加載到緩存,但這已經足夠了,因為經過算法的巧妙設計,可以將緩存的命中率提高到 90%,剩下的10%的情況無法命中,再去物理內存中加載頁表。
有了硬件的直接支持,使用虛擬地址和使用物理地址相比,損失的性能已經很小,在可接受的范圍內。
MMU 只是通過頁表來完成虛擬地址到物理地址的映射,但不會構建頁表,構建頁表是操作系統的任務。在程序加載到內存以及程序運行過程中,操作系統會不斷更新程序對應的頁表,并將頁目錄的物理地址保存到 CR3 寄存器。MMU 向緩存中加載頁表時,會根據 CR3 寄存器找到頁目錄,再找到頁表,最終通過軟件和硬件的結合來完成內存映射。
> CR3 是CPU內部的一個寄存器,專門用來保存頁目錄的物理地址。
每個程序在運行時都有自己的一套頁表,切換程序時,只要改變 CR3 寄存器的值就能夠切換到對應的頁表
- c語言
- 基礎知識
- 變量和常量
- 宏定義和預處理
- 隨機數
- register變量
- errno全局變量
- 靜態變量
- 類型
- 數組
- 類型轉換
- vs中c4996錯誤
- 數據類型和長度
- 二進制數,八進制數和十六進制數
- 位域
- typedef定義類型
- 函數和編譯
- 函數調用慣例
- 函數進棧和出棧
- 函數
- 編譯
- sizeof
- main函數接收參數
- 宏函數
- 目標文件和可執行文件有什么
- 強符號和弱符號
- 什么是鏈接
- 符號
- 強引用和弱引用
- 字符串處理函數
- sscanf
- 查找子字符串
- 字符串指針
- qt
- MFC
- 指針
- 簡介
- 指針詳解
- 案例
- 指針數組
- 偏移量
- 間接賦值
- 易錯點
- 二級指針
- 結構體指針
- 字節對齊
- 函數指針
- 指針例子
- main接收用戶輸入
- 內存布局
- 內存分區
- 空間開辟和釋放
- 堆空間操作字符串
- 內存處理函數
- 內存分頁
- 內存模型
- 棧
- 棧溢出攻擊
- 內存泄露
- 大小端存儲法
- 寄存器
- 結構體
- 共用體
- 枚舉
- 文件操作
- 文件到底是什么
- 文件打開和關閉
- 文件的順序讀寫
- 文件的隨機讀寫
- 文件復制
- FILE和緩沖區
- 文件大小
- 插入,刪除,更改文件內容
- typeid
- 內部鏈接和外部鏈接
- 動態庫
- 調試器
- 調試的概念
- vs調試
- 多文件編程
- extern關鍵字
- 頭文件規范
- 標準庫以及標準頭文件
- 頭文件只包含一次
- static
- 多線程
- 簡介
- 創建線程threads.h
- 創建線程pthread
- gdb
- 簡介
- mac使用gdb
- setjump和longjump
- 零拷貝
- gc
- 調試器原理
- c++
- c++簡介
- c++對c的擴展
- ::作用域運算符
- 名字控制
- cpp對c的增強
- const
- 變量定義數組
- 盡量以const替換#define
- 引用
- 內聯函數
- 函數默認參數
- 函數占位參數
- 函數重載
- extern "C"
- 類和對象
- 類封裝
- 構造和析構
- 深淺拷貝
- explicit關鍵字
- 動態對象創建
- 靜態成員
- 對象模型
- this
- 友元
- 單例
- 繼承
- 多態
- 運算符重載
- 賦值重載
- 指針運算符(*,->)重載
- 前置和后置++
- 左移<<運算符重載
- 函數調用符重載
- 總結
- bool重載
- 模板
- 簡介
- 普通函數和模板函數調用
- 模板的局限性
- 類模板
- 復數的模板類
- 類模板作為參數
- 類模板繼承
- 類模板類內和類外實現
- 類模板和友元函數
- 類模板實現數組
- 類型轉換
- 異常
- 異常基本語法
- 異常的接口聲明
- 異常的棧解旋
- 異常的多態
- 標準異常庫
- 自定義異常
- io
- 流的概念和類庫結構
- 標準io流
- 標準輸入流
- 標準輸出流
- 文件讀寫
- STL
- 簡介
- string容器
- vector容器
- deque容器
- stack容器
- queue容器
- list容器
- set/multiset容器
- map/multimap容器
- pair對組
- 深淺拷貝問題
- 使用時機
- 常用算法
- 函數對象
- 謂詞
- 內建函數對象
- 函數對象適配器
- 空間適配器
- 常用遍歷算法
- 查找算法
- 排序算法
- 拷貝和替換算法
- 算術生成算法
- 集合算法
- gcc
- GDB
- makefile
- visualstudio
- VisualAssistX
- 各種插件
- utf8編碼
- 制作安裝項目
- 編譯模式
- 內存對齊
- 快捷鍵
- 自動補全
- 查看c++類內存布局
- FFmpeg
- ffmpeg架構
- 命令的基本格式
- 分解與復用
- 處理原始數據
- 錄屏和音
- 濾鏡
- 水印
- 音視頻的拼接與裁剪
- 視頻圖片轉換
- 直播
- ffplay
- 常見問題
- 多媒體文件處理
- ffmpeg代碼結構
- 日志系統
- 處理流數據
- linux
- 系統調用
- 常用IO函數
- 文件操作函數
- 文件描述符復制
- 目錄相關操作
- 時間相關函數
- 進程
- valgrind
- 進程通信
- 信號
- 信號產生函數
- 信號集
- 信號捕捉
- SIGCHLD信號
- 不可重入函數和可重入函數
- 進程組
- 會話
- 守護進程
- 線程
- 線程屬性
- 互斥鎖
- 讀寫鎖
- 條件變量
- 信號量
- 網絡
- 分層模型
- 協議格式
- TCP協議
- socket
- socket概念
- 網絡字節序
- ip地址轉換函數
- sockaddr數據結構
- 網絡套接字函數
- socket模型創建流程圖
- socket函數
- bind函數
- listen函數
- accept函數
- connect函數
- C/S模型-TCP
- 出錯處理封裝函數
- 多進程并發服務器
- 多線程并發服務器
- 多路I/O復用服務器
- select
- poll
- epoll
- epoll事件
- epoll例子
- epoll反應堆思想
- udp
- socket IPC(本地套接字domain)
- 其他常用函數
- libevent
- libevent簡介