1. 程式人生 > 其它 >作業系統虛擬記憶體發展史

作業系統虛擬記憶體發展史

目錄

混沌歲月

開天闢地之初,早期的記憶體並沒有什麼複雜的抽象,實體記憶體簡單粗暴。

想要寫什麼?給,實體地址給你,隨便搞。這樣的作業系統並沒有擔負起它該有的責任,反而更像一個函式庫,給了你一些系統呼叫之類的函式,開發人員很自由。

過了一段時間,多程式時代來臨。怎麼讓這些程式有條不紊地執行,成為了一個必須考慮的問題。例如是給一個程式所有地址空間還是一部分,地址空間如何分配,如果有程式不小心或惡意訪問、修改其他程式該怎麼辦?作業系統,起來幹活了!

CPU利用了時分共享來應對多程式時代,每個程式跑一段時間,自動讓出CPU,或者時鐘中斷之後CPU切換到作業系統,作業系統來呼叫程式,應用一系列策略來決定下一個時間片誰使用CPU。

問題到了記憶體這裡,記憶體又將採用何種策略應對新的時代?

左右橫跳

一種方法是輪到哪個程序就把全部的記憶體都給它,結束之後將所有記憶體,暫存器全部儲存到硬碟,然後把記憶體給下一個程式使用,問題在於,磁碟很慢,以前的機械硬碟更慢。

記憶體和機械硬碟差了五個數量級,在記憶體和磁碟之間橫跳太慢了。

各自為王

與上一種方法作對比,很明顯,也可以一個程序只佔用部分記憶體,每個程序可以在各自記憶體區間裡進行活動。

目前,我們的問題就到了程式這裡了,不是佔用整個記憶體了,而是由作業系統給你分配記憶體區間。那寫程式地址怎麼辦?如上圖的程序C,如果C妄圖攻打程序B的地址區間怎麼辦?CPU提供了兩個暫存器來解決,基址暫存器和界限暫存器。基址暫存器負責提供程序從哪開始,界限暫存器負責監督程序超界了沒有,超界了由CPU進行對應的處理,一般是直接掛掉它。上圖中,程式只需要當做自己的64KB地址是從0開始的就好,地址轉換由硬體進行。

上面的硬體地址轉換看起來不錯,可是仍舊有個問題:64KB對於有的程序太大了怎麼辦?豈不是造成了記憶體浪費,記憶體可是十分昂貴的,經不起浪費啊!

64KB對於現在的程序來說太小了,那是因為現在記憶體相對之前便宜了,我們都被慣壞了,一張圖片都可以幾兆。鼎鼎大名的《超級馬里奧》才40KB。

程序及其地址空間如下:

堆和棧之間可能存在大量的間隙沒有用到,但是在實體記憶體裡卻佔用了位置,這種浪費稱為記憶體碎片。所以我們需要更復雜的機制,來更好地利用記憶體。

拆分王權

典型地址空間有三個邏輯不同的段:程式碼,堆,棧。既然堆、棧之間的未分配區域造成了記憶體碎片,那就更細化一點,不是將程序地址整個對映到實體地址,而是將不同的段分別對映到實體地址,當然,MMU(memory management unit)也就需要不止一對基址和界限暫存器。

那麼,硬體地址轉換如何知道引用了哪個段呢?可以在虛擬地址裡面加兩個標誌位來表明;也可以通過地址產生方式來確定段,如是指令獲取,或者棧指標,或者其他(堆)。

一個分配的例子如下:

這裡面,棧的硬體地址轉換有所區別,因為它是反向增長。

為了節省記憶體(任何時候都是有必要的,記憶體在增加,但是應用的記憶體佔用也在增加),出現了程式碼共享,這也就需要硬體提供保護位,來標識程式是否能都讀寫或者執行,同時不破壞隔離的思想。

作業系統在任務切換時需要負責儲存各個暫存器的內容,新的地址空間被建立時,作業系統需要在實體記憶體中為它的段找到空間。碎片問題依然存在,這裡稱為外部碎片。

如果要分配一個20KB的段,左圖明明有空間,卻無法分配。

一種方法是:CPU負責整理,將他們的資料複製到連續記憶體區域,改變段暫存器值,如右圖。但是這個操作成本很高。

更簡單的方法就是通過空閒列表管理演算法實現,嘗試保留更大的記憶體塊。具體的演算法很多,但都無法完全消除外部碎片。

管理的煩惱

就像去餐館吃飯,每個四人桌都坐了一個人,來了四個人一起吃飯,一看空位幾十個,但是就是沒有空閒的四人桌,啪的一下心情就不愉悅了。

所以這一節的主題就是如何管理空閒空間。

棧是自動管理的,壓進彈出不需要我們操心,我們要關心的是堆。在堆上管理空閒空間的資料結構就是上文提到的空閒列表。

會堆空間的申請釋放都會體現在空閒列表上。當上圖used釋放時,列表會表現為三段空閒的區域,這個時候很自然的一個策略是合併相鄰空閒塊。記住,我們的總體目標是要有儘可能大的連續空閒區域。

分配策略

記憶體的分配釋放是任意的,記憶體可能會被搞得稀碎,理想的分配程式應該保證快速和碎片最小化。一個記憶體分配請求來了,該如何分配記憶體?

  • 最優分配:遍歷整個空閒連結串列,找到符合要求的地址區間最小的。避免了空間浪費,但是效能差。
  • 首次匹配:遍歷,找到了符合條件的地址區間就結束。空閒列表開頭會被分裂成很多小塊。
  • 下次匹配:比首次匹配多維護一個指標,指向上一次查詢結束的位置,就是為了避免首次匹配空閒列表開頭頻繁分割的問題。

一些有趣的方式

  • 分離空閒列表

    對於應用程式頻繁申請的一種或幾種大小的記憶體空間,用一個獨立的列表來管理,其他的給通用記憶體分配程式。

  • 夥伴系統

    這個是為了合併空閒記憶體更加簡單,思路就是將空閒空間一分為二,直到找到小的不能再小的地址區間,返回給使用者。合併的時候只要檢視相鄰區間,就可以直到是否可以合併。

沒有規矩,不成方圓

分段將空間切成不同大小的分片之後,空間會碎片化,再想合回來就難了。一種對應的解決方法是將空間分割成固定長度的分片,稱為分頁

作業系統為每個程序儲存一個數據結構,來記錄地址空間虛擬頁在實體記憶體的位置,稱為頁表。主要用來進行地址轉換。

上圖的例子中,地址空間為64位元組,所以虛擬地址需要6位。頁大小為16位元組,所以頁面號要兩位,偏移量4位。

進行地址轉換的時候就根據頁面號和偏移量查詢頁表項,找到期望的物理幀號(PFN)。

如x86頁表項如下:

20位的物理幀號,4KB的頁面,正好32位。頁表項後面幾位則是標誌位,如讀寫為R/W,訪問位A等。

頁表放在哪裡?當然是放在記憶體裡,那樣每一個記憶體引用都要執行一個額外的記憶體引用來從頁表獲取地址轉換。時間翻倍!而且頁表記憶體佔用很大,算一筆賬,以上面的x86為例,一個頁表項32位,4個位元組。一個頁幀4KB,那樣就有2的20次方個頁幀,一個頁幀對應一個頁表項,也就是2的24次方位元組,就是4MB。系統後臺可能有上百個程序,光是頁表就佔了400MB以上,這河狸嗎?

問題擺在這,後面討論如何解決。

撥雲見日

使用分頁作為核心機制來實現虛擬記憶體,效能開銷較大,那如何加速地址轉換呢?

軟體上想不通的,就要由硬體來提供幫助,而且很多時候硬體的一些略微的改變,會帶來巨大的效能提升。這裡就是要增加地址轉換旁路緩衝儲存器(translation-lookaside buffer, TLB),或者稱為地址轉換快取。還記的上面的訪問速度圖嗎,cache超快的。每次記憶體訪問,先看一下TLB,如果有就很快完成轉換,不再訪問頁表,沒有就需要去查頁表了。redis和傳統資料庫的組合不就是很像這個做法嗎?

初學程式設計的時候,老師告訴你,記憶體最好是連續訪問,例如二維陣列,你應該a[0][0],a[0][1],a[0][2]這樣遍歷,而不是a[0][0],a[1][0],a[2][0]這樣去遍歷,現在你知道原因了嗎?就是和地址轉換快取的命中率有關。

典型的TLB有32項、64項或者128項。硬體會並行查詢期望的轉換對映。

同樣,頁表,TLB都只是對一個程序有效,上下文切換時會進行切換。一種方法是簡單清空TLB,如有效位置0。如果作業系統頻繁切換程序,這種每次程序執行都會觸發TLB未命中。

或者增加硬體支援,增加程序識別符號。這樣就不用了清零了。那新快取來了,替換哪一個呢?

一種常見的策略是替換最少使用的項(LRU),另一種典型策略就是隨機替換,來防止極端情況。

速度問題有了解決辦法,那空間問題呢?4MB的問題還是存在啊!

之前我們預設說的都是線性頁表,一種方法是採用更大的頁。很明顯,頁更大了,頁表裡面的項就更少了,記憶體佔用也就少了。但是頁太大會造成內部碎片,空間浪費。

古語有言:取其精華去其糟粕。結合分頁和分段機制也是一種方法。這種方法就是拋去了堆和棧之間的空閒區域,不再對映實體記憶體,也沒有頁表,來節省空間。

資料結構的強大之處來了。線性列表解決不了,換個結構試試,出現了多級頁表,類似樹的結構。

多級頁表增加了頁目錄的概念,用來標記一個頁是否是無效頁,無效就沒有頁表項。成本就是TLB未命中時需要從記憶體載入兩次,一次頁目錄,一次PTE。

頁目錄還是太大怎麼辦?再加一層頁目錄。。。

更極端一點,有反向頁表。他就是一個頁表記錄所有程序的地址對映,無效的不記錄,頁表項裡標識哪個程序或哪些程序在用這個地址對映。線性查詢自然不現實,這種做法的查詢應該是建立散列表來加速查詢。

之前說過一句:頁表放在哪?當然是記憶體。這句話也不絕對,對於大部分記憶體都不絕對,記憶體不夠用時,作業系統會將一部分不常用的記憶體放入磁碟,稱為交換空間。

塵埃落地

至此,虛擬記憶體的大致脈絡已經清晰,它是軟硬體,資料結構,演算法的結合,是空間和時間的權衡。

本文同步釋出於orzlinux.cn