<code>主存(RAM)</code> 是一件非常重要的資源,必須要小心對待記憶體。雖然目前大多數記憶體的增長速度要比 IBM 7094 要快的多,但是,程式大小的增長要比記憶體的增長還快很多。正如帕金森定律說的那樣:<code>不管存儲器有多大,但是程式大小的增長速度比記憶體容量的增長速度要快的多</code>。下面我們就來探讨一下作業系統是如何建立記憶體并管理他們的。
經過多年的探讨,人們提出了一種 <code>分層存儲器體系(memory hierarchy)</code>,下面是分層體系的分類
頂層的存儲器速度最高,但是容量最小,成本非常高,層級結構越向下,其通路效率越慢,容量越大,但是造價也就越便宜。
作業系統中管理記憶體層次結構的部分稱為<code>記憶體管理器(memory manager)</code>,它的主要工作是有效的管理記憶體,記錄哪些記憶體是正在使用的,在程序需要時配置設定記憶體以及在程序完成時回收記憶體。
下面我們會對不同的記憶體管理模型進行探讨,從簡單到複雜,由于最低級别的緩存是由硬體進行管理的,是以我們主要探讨主存模型和如何對主存進行管理。
最簡單的存儲器抽象是沒有存儲。早期大型計算機(20 世紀 60 年代之前),小型計算機(20 世紀 70 年代之前)和個人計算機(20 世紀 80 年代之前)都沒有存儲器抽象。每一個程式都直接通路實體記憶體。當一個程式執行如下指令:
計算機會把位置為 1000 的實體記憶體中的内容移到 <code>REGISTER1</code> 中。是以,那時呈現給程式員的記憶體模型就是實體記憶體,記憶體位址從 0 開始到記憶體位址的最大值中,每個位址中都會包含一個 8 位位數的單元。
是以這種情況下的計算機不可能會有兩個應用程式<code>同時</code>在記憶體中。如果第一個程式向記憶體位址 2000 的這個位置寫入了一個值,那麼此值将會替換第二個程式在該位置上的值,是以,同時運作兩個應用程式是行不通的,兩個程式會立刻崩潰。

不過即使存儲器模型就是實體記憶體,還是存在一些可選項的。下面展示了三種變體
在上圖 a 中,作業系統位于 <code>RAM(Random Access Memory)</code> 的底部,或像是圖 b 一樣位于 <code>ROM(Read-Only Memory)</code> 頂部;而在圖 c 中,裝置驅動程式位于頂端的 ROM 中,而作業系統位于底部的 RAM 中。圖 a 的模型以前用在大型機和小型機上,但現在已經很少使用了;圖 b 中的模型一般用于掌上電腦或者是嵌入式系統中。第三種模型就應用在早期個人計算機中了。ROM 系統中的一部分成為 <code>BIOS (Basic Input Output System)</code>。模型 a 和 c 的缺點是使用者程式中的錯誤可能會破壞作業系統,可能會導緻災難性的後果。
按照這種方式組織系統時,通常同一個時刻隻能有一個線程正在運作。一旦使用者鍵入了一個指令,作業系統就把需要的程式從磁盤複制到記憶體中并執行;當程序運作結束後,作業系統在使用者終端顯示提示符并等待新的指令。收到新的指令後,它把新的程式裝入記憶體,覆寫前一個程式。
在沒有存儲器抽象的系統中實作并行性的一種方式是使用多線程來程式設計。由于同一程序中的多線程内部共享同一記憶體映像,那麼實作并行也就不是問題了。
但是,即便沒有存儲器抽象,同時運作多個程式也是有可能的。作業系統隻需要把目前記憶體中所有内容儲存到磁盤檔案中,然後再把程式讀入記憶體即可。隻要某一時間隻有一個程式,那麼就不會産生沖突。
在額外特殊硬體的幫助下,即使沒有交換功能,也可以并行的運作多個程式。IBM 360 的早期模型就是這樣解決的
System/360是 IBM 在1964年4月7日,推出的劃時代的大型電腦,這一系列是世界上首個指令集可相容計算機。
在 IBM 360 中,記憶體被劃分為 2KB 的區域塊,每塊區域被配置設定一個 4 位的保護鍵,保護鍵存儲在 CPU 的特殊寄存器中。一個記憶體為 1 MB 的機器隻需要 512 個這樣的 4 位寄存器,容量總共為 256 位元組 (這個會算吧。) <code>PSW(Program Status Word, 程式狀态字)</code> 中有一個 4 位碼。一個運作中的程序如果通路鍵與其 PSW 碼不同的記憶體,360 硬體會發現這種情況,因為隻有作業系統可以修改保護鍵,這樣就可以防止程序之間、使用者程序和作業系統之間的幹擾。
這種解決方式是有一個缺陷。如下所示,假設有兩個程式,每個大小各為 16 KB
從圖上可以看出,這是兩個不同的 16KB 程式的裝載過程,a 程式首先會跳轉到位址 24,那裡是一條 <code>MOV</code> 指令,然而 b 程式會首先跳轉到位址 28,位址 28 是一條 <code>CMP</code> 指令。這是兩個程式被先後加載到記憶體中的情形,假如這兩個程式被同時加載到記憶體中從 0 位址處開始執行,記憶體的狀态就如上面 c 圖所示,程式裝載完畢開始運作,第一個程式首先從 0 位址處開始運作,執行 JMP 24 指令,然後依次執行後面的指令(許多指令沒有畫出),一段時間後第一個程式執行完畢,然後開始執行第二個程式。第二個程式的第一條指令是 28,這條指令會使程式跳轉到第一個程式的 <code>ADD</code> 處,而不是事先設定的跳轉指令 CMP,由于記憶體位址的不正确通路,這個程式可能在 1秒内就崩潰了。
上面兩個程式同時執行最核心的問題是都引用了絕對實體位址。這不是我們想要看到的。我們想要的是每一個程式都會引用一個私有的本地位址。IBM 360 在第二個程式裝載到記憶體中的時候會使用一種稱為 <code>靜态重定位(static relocation)</code> 的技術來修改它。它的工作流程如下:當一個程式被加載到 16384 位址時,常數 16384 被加到每一個程式位址上(是以 <code>JMP 28</code>會變為<code>JMP 16412</code> )。雖然這個機制在不出錯誤的情況下是可行的,但這不是一種通用的解決辦法,同時會減慢裝載速度。更近一步來講,它需要所有可執行程式中的額外資訊,以訓示哪些包含(可重定位)位址,哪些不包含(可重定位)位址。畢竟,上圖 b 中的 JMP 28 可以被重定向(被修改),而類似 <code>MOV REGISTER1,28</code> 會把數字 28 移到 REGISTER 中則不會重定向。是以,<code>裝載器(loader)</code>需要一定的能力來辨識位址和常數。
把實體記憶體暴露給程序會有幾個主要的缺點:第一個問題是,如果使用者程式可以尋址記憶體的每個位元組,它們就可以很容易的破壞作業系統,進而使系統停止運作(除非使用 IBM 360 那種 lock-and-key 模式或者特殊的硬體進行保護)。即使在隻有一個使用者程序運作的情況下,這個問題也存在。
第二點是,這種模型想要運作多個程式是很困難的(如果隻有一個 CPU 那就是順序執行),在個人計算機上,一般會打開很多應用程式,比如輸入法、電子郵件、浏覽器,這些程序在不同時刻會有一個程序正在運作,其他應用程式可以通過滑鼠來喚醒。在系統中沒有實體記憶體的情況下很難實作。
如果要使多個應用程式同時運作在記憶體中,必須要解決兩個問題:<code>保護</code>和 <code>重定位</code>。我們來看 IBM 360 是如何解決的:第一種解決方式是用保護密鑰标記記憶體塊,并将執行過程的密鑰與提取的每個存儲字的密鑰進行比較。這種方式隻能解決第一種問題,但是還是不能解決多程序在記憶體中同時運作的問題。
還有一種更好的方式是創造一個存儲器抽象:<code>位址空間(the address space)</code>。就像程序的概念建立了一種抽象的 CPU 來運作程式,位址空間也建立了一種抽象記憶體供程式使用。位址空間是程序可以用來尋址記憶體的位址集。每個程序都有它自己的位址空間,獨立于其他程序的位址空間,但是某些程序會希望可以共享位址空間。
最簡單的辦法是使用<code>動态重定位(dynamic relocation)</code>,它就是通過一種簡單的方式将每個程序的位址空間映射到實體記憶體的不同區域。從 <code>CDC 6600(世界上最早的超級計算機)</code>到 <code>Intel 8088(原始 IBM PC 的核心)</code>所使用的經典辦法是給每個 CPU 配置兩個特殊硬體寄存器,通常叫做<code>基址寄存器(basic register)</code>和<code>變址寄存器(limit register)</code>。當使用基址寄存器和變址寄存器使,程式會裝載到記憶體中連續的空間位置并且在裝載期間無需重定位。當一個程序運作時,程式的起始實體位址裝載到基址寄存器中,程式的長度則裝載到變址寄存器中。在上圖 c 中,當一個程式運作時,裝載到這些硬體寄存器中的基址和變址寄存器的值分别是 0 和 16384。當第二個程式運作時,這些值分别是 16384 和 32768。如果第三個 16 KB 的程式直接裝載到第二個程式的位址之上并且運作,這時基址寄存器和變址寄存器的值會是 32768 和 16384。那麼我們可以總結下
基址寄存器:存儲資料記憶體的起始位置
變址寄存器:存儲應用程式的長度。
每當程序引用記憶體以擷取指令或讀取或寫入資料字時,CPU 硬體都會自動将<code>基址值</code>添加到程序生成的位址中,然後再将其發送到記憶體總線上。同時,它檢查程式提供的位址是否等于或大于<code>變址寄存器</code> 中的值。如果程式提供的位址要超過變址寄存器的範圍,那麼會産生錯誤并中止通路。這樣,對上圖 c 中執行 <code>JMP 28</code> 這條指令後,硬體會把它解釋為 <code>JMP 16412</code>,是以程式能夠跳到 CMP 指令,過程如下
使用基址寄存器和變址寄存器是給每個程序提供私有位址空間的一種非常好的方法,因為每個記憶體位址在送到記憶體之前,都會先加上基址寄存器的内容。在很多實際系統中,對基址寄存器和變址寄存器都會以一定的方式加以保護,使得隻有作業系統可以修改它們。在 <code>CDC 6600</code> 中就提供了對這些寄存器的保護,但在 <code>Intel 8088</code> 中則沒有,甚至沒有變址寄存器。但是,Intel 8088 提供了許多基址寄存器,使程式的代碼和資料可以被獨立的重定位,但是對于超出範圍的記憶體引用沒有提供保護。
是以你可以知道使用基址寄存器和變址寄存器的缺點,在每次通路記憶體時,都會進行 <code>ADD</code> 和 <code>CMP</code> 運算。比較可以執行的很快,但是加法就會相對慢一些,除非使用特殊的加法電路,否則加法因進位傳播時間而變慢。
如果計算機的實體記憶體足夠大來容納所有的程序,那麼之前提及的方案或多或少是可行的。但是實際上,所有程序需要的 RAM 總容量要遠遠高于記憶體的容量。在 Windows、OS X、或者 Linux 系統中,在計算機完成啟動(Boot)後,大約有 50 - 100 個程序随之啟動。例如,當一個 Windows 應用程式被安裝後,它通常會發出指令,以便在後續系統啟動時,将啟動一個程序,這個程序除了檢查應用程式的更新外不做任何操作。一個簡單的應用程式可能會占用 <code>5 - 10MB</code> 的記憶體。其他背景程序會檢查電子郵件、網絡連接配接以及許多其他諸如此類的任務。這一切都會發生在<code>第一個使用者</code>啟動之前。如今,像是 <code>Photoshop</code> 這樣的重要使用者應用程式僅僅需要 500 MB 來啟動,但是一旦它們開始處理資料就需要許多 GB 來處理。從結果上來看,将所有程序始終保持在記憶體中需要大量記憶體,如果記憶體不足,則無法完成。
是以針對上面記憶體不足的問題,提出了兩種處理方式:最簡單的一種方式就是<code>交換(swapping)</code>技術,即把一個程序完整的調入記憶體,然後再記憶體中運作一段時間,再把它放回磁盤。空閑程序會存儲在磁盤中,是以這些程序在沒有運作時不會占用太多記憶體。另外一種政策叫做<code>虛拟記憶體(virtual memory)</code>,虛拟記憶體技術能夠允許應用程式部分的運作在記憶體中。下面我們首先先探讨一下交換
下面是一個交換過程
剛開始的時候,隻有程序 A 在記憶體中,然後從建立程序 B 和程序 C 或者從磁盤中把它們換入記憶體,然後在圖 d 中,A 被換出記憶體到磁盤中,最後 A 重新進來。因為圖 g 中的程序 A 現在到了不同的位置,是以在裝載過程中需要被重新定位,或者在交換程式時通過軟體來執行;或者在程式執行期間通過硬體來重定位。基址寄存器和變址寄存器就适用于這種情況。
交換在記憶體建立了多個 <code>空閑區(hole)</code>,記憶體會把所有的空閑區盡可能向下移動合并成為一個大的空閑區。這項技術稱為<code>記憶體緊縮(memory compaction)</code>。但是這項技術通常不會使用,因為這項技術回消耗很多 CPU 時間。例如,在一個 16GB 記憶體的機器上每 8ns 複制 8 位元組,它緊縮全部的記憶體大約要花費 16s。
有一個值得注意的問題是,當程序被建立或者換入記憶體時應該為它配置設定多大的記憶體。如果程序被建立後它的大小是固定的并且不再改變,那麼配置設定政策就比較簡單:作業系統會準确的按其需要的大小進行配置設定。
但是如果程序的 <code>data segment</code> 能夠自動增長,例如,通過動态配置設定堆中的記憶體,肯定會出現問題。這裡還是再提一下什麼是 <code>data segment</code> 吧。從邏輯層面作業系統把資料分成不同的<code>段(不同的區域)</code>來存儲:
代碼段(codesegment/textsegment):
又稱文本段,用來存放指令,運作代碼的一塊記憶體空間
此空間大小在代碼運作前就已經确定
記憶體空間一般屬于隻讀,某些架構的代碼也允許可寫
在代碼段中,也有可能包含一些隻讀的常數變量,例如字元串常量等。
資料段(datasegment):
可讀可寫
存儲初始化的全局變量和初始化的 static 變量
資料段中資料的生存期是随程式持續性(随程序持續性)
随程序持續性:程序建立就存在,程序死亡就消失
bss段(bsssegment):
存儲未初始化的全局變量和未初始化的 static 變量
bss 段中資料的生存期随程序持續性
bss 段中的資料一般預設為0
rodata段:
隻讀資料
比如 printf 語句中的格式字元串和開關語句的跳轉表。也就是常量區。例如,全局作用域中的 const int ival = 10,ival 存放在 .rodata 段;再如,函數局部作用域中的 printf("Hello world %d\n", c); 語句中的格式字元串 "Hello world %d\n",也存放在 .rodata 段。
棧(stack):
存儲的是函數或代碼中的局部變量(非 static 變量)
棧的生存期随代碼塊持續性,代碼塊運作就給你配置設定空間,代碼塊結束,就自動回收空間
堆(heap):
存儲的是程式運作期間動态配置設定的 malloc/realloc 的空間
堆的生存期随程序持續性,從 malloc/realloc 到 free 一直存在
下面是我們用 Borland C++ 編譯過後的結果
段定義( segment ) 是用來區分或者劃分範圍區域的意思。彙編語言的 segment 僞指令表示段定義的起始,ends 僞指令表示段定義的結束。段定義是一段連續的記憶體空間
是以記憶體針對自動增長的區域,會有三種處理方式
如果一個程序與空閑區相鄰,那麼可把該空閑區配置設定給程序以供其增大。
如果程序相鄰的是另一個程序,就會有兩種處理方式:要麼把需要增長的程序移動到一個記憶體中空閑區足夠大的區域,要麼把一個或多個程序交換出去,已變成生成一個大的空閑區。
如果一個程序在記憶體中不能增長,而且磁盤上的交換區也滿了,那麼這個程序隻有挂起一些空閑空間(或者可以結束該程序)
上面隻針對單個或者一小部分需要增長的程序采用的方式,如果大部分程序都要在運作時增長,為了減少因記憶體區域不夠而引起的程序交換和移動所産生的開銷,一種可用的方法是,在換入或移動程序時為它配置設定一些額外的記憶體。然而,當程序被換出到磁盤上時,應該隻交換實際上使用的記憶體,将額外的記憶體交換也是一種浪費,下面是一種為兩個程序配置設定了增長空間的記憶體配置。
如果程序有兩個可增長的段,例如,供變量動态配置設定和釋放的作為<code>堆(全局變量)</code>使用的一個<code>資料段(data segment)</code>,以及存放局部變量與傳回位址的一個<code>堆棧段(stack segment)</code>,就如圖 b 所示。在圖中可以看到所示程序的堆棧段在程序所占記憶體的頂端向下增長,緊接着在程式段後的資料段向上增長。當增長預留的記憶體區域不夠了,處理方式就如上面的<code>流程圖(data segment 自動增長的三種處理方式)</code>一樣了。
在進行記憶體動态配置設定時,作業系統必須對其進行管理。大緻上說,有兩種監控記憶體使用的方式
<code>位圖(bitmap)</code>
<code>空閑清單(free lists)</code>
下面我們就來探讨一下這兩種使用方式
使用位圖方法時,記憶體可能被劃分為小到幾個字或大到幾千位元組的配置設定單元。每個配置設定單元對應于位圖中的一位,0 表示空閑, 1 表示占用(或者相反)。一塊記憶體區域和其對應的位圖如下
圖 a 表示一段有 5 個程序和 3 個空閑區的記憶體,刻度為記憶體配置設定單元,陰影區表示空閑(在位圖中用 0 表示);圖 b 表示對應的位圖;圖 c 表示用連結清單表示同樣的資訊
配置設定單元的大小是一個重要的設計因素,配置設定機關越小,位圖越大。然而,即使隻有 4 位元組的配置設定單元,32 位的記憶體也僅僅隻需要位圖中的 1 位。<code>32n</code> 位的記憶體需要 n 位的位圖,是以1 個位圖隻占用了 1/32 的記憶體。如果選擇更大的記憶體單元,位圖應該要更小。如果程序的大小不是配置設定單元的整數倍,那麼在最後一個配置設定單元中會有大量的記憶體被浪費。
<code>位圖</code>提供了一種簡單的方法在固定大小的記憶體中跟蹤記憶體的使用情況,因為位圖的大小取決于記憶體和配置設定單元的大小。這種方法有一個問題是,當決定為把具有 k 個配置設定單元的程序放入記憶體時,<code>内容管理器(memory manager)</code> 必須搜尋位圖,在位圖中找出能夠運作 k 個連續 0 位的串。在位圖中找出制定長度的連續 0 串是一個很耗時的操作,這是位圖的缺點。(可以簡單了解為在雜亂無章的數組中,找出具有一大長串空閑的數組單元)
另一種記錄記憶體使用情況的方法是,維護一個記錄已配置設定記憶體段和空閑記憶體段的連結清單,段會包含程序或者是兩個程序的空閑區域。可用上面的圖 c 來表示記憶體的使用情況。連結清單中的每一項都可以代表一個 <code>空閑區(H)</code> 或者是<code>程序(P)</code>的起始标志,長度和下一個連結清單項的位置。
在這個例子中,<code>段連結清單(segment list)</code>是按照位址排序的。這種方式的優點是,當程序終止或被交換時,更新清單很簡單。一個終止程序通常有兩個鄰居(除了記憶體的頂部和底部外)。相鄰的可能是程序也可能是空閑區,它們有四種組合方式。
當按照位址順序在連結清單中存放程序和空閑區時,有幾種算法可以為建立的程序(或者從磁盤中換入的程序)配置設定記憶體。我們先假設記憶體管理器知道應該配置設定多少記憶體,最簡單的算法是使用 <code>首次适配(first fit)</code>。記憶體管理器會沿着段清單進行掃描,直到找個一個足夠大的空閑區為止。除非空閑區大小和要配置設定的空間大小一樣,否則将空閑區分為兩部分,一部分供程序使用;一部分生成新的空閑區。首次适配算法是一種速度很快的算法,因為它會盡可能的搜尋連結清單。
首次适配的一個小的變體是 <code>下次适配(next fit)</code>。它和首次比對的工作方式相同,隻有一個不同之處那就是下次适配在每次找到合适的空閑區時就會記錄當時的位置,以便下次尋找空閑區時從上次結束的地方開始搜尋,而不是像首次比對算法那樣每次都會從頭開始搜尋。<code>Bays(1997)</code> 證明了下次算法的性能略低于首次比對算法。
另外一個著名的并且廣泛使用的算法是 <code>最佳适配(best fit)</code>。最佳适配會從頭到尾尋找整個連結清單,找出能夠容納程序的最小空閑區。最佳适配算法會試圖找出最接近實際需要的空閑區,以最好的比對請求和可用空閑區,而不是先一次拆分一個以後可能會用到的大的空閑區。比如現在我們需要一個大小為 2 的塊,那麼首次比對算法會把這個塊配置設定在位置 5 的空閑區,而最佳适配算法會把該塊配置設定在位置為 18 的空閑區,如下
那麼最佳适配算法的性能如何呢?最佳适配會周遊整個連結清單,是以最佳适配算法的性能要比首次比對算法差。但是令人想不到的是,最佳适配算法要比首次比對和下次比對算法浪費更多的記憶體,因為它會産生大量無用的小緩沖區,首次比對算法生成的空閑區會更大一些。
最佳适配的空閑區會分裂出很多非常小的緩沖區,為了避免這一問題,可以考慮使用 <code>最差适配(worst fit)</code> 算法。即總是配置設定最大的記憶體區域(是以你現在明白為什麼最佳适配算法會分裂出很多小緩沖區了吧),使新配置設定的空閑區比較大進而可以繼續使用。仿真程式表明最差适配算法也不是一個好主意。
如果為程序和空閑區維護各自獨立的連結清單,那麼這四個算法的速度都能得到提高。這樣,這四種算法的目标都是為了檢查空閑區而不是程序。但這種配置設定速度的提高的一個不可避免的代價是增加複雜度和減慢記憶體釋放速度,因為必須将一個回收的段從程序連結清單中删除并插入空閑連結清單區。
如果程序和空閑區使用不同的連結清單,那麼可以按照大小對空閑區連結清單排序,以便提高最佳适配算法的速度。在使用最佳适配算法搜尋由小到大排列的空閑區連結清單時,隻要找到一個合适的空閑區,則這個空閑區就是能容納這個作業的最小空閑區,是以是最佳比對。因為空閑區連結清單以單連結清單形式組織,是以不需要進一步搜尋。空閑區連結清單按大小排序時,首次适配算法與最佳适配算法一樣快,而下次适配算法在這裡毫無意義。
另一種配置設定算法是 <code>快速适配(quick fit)</code> 算法,它為那些常用大小的空閑區維護單獨的連結清單。例如,有一個 n 項的表,該表的第一項是指向大小為 4 KB 的空閑區連結清單表頭指針,第二項是指向大小為 8 KB 的空閑區連結清單表頭指針,第三項是指向大小為 12 KB 的空閑區連結清單表頭指針,以此類推。比如 21 KB 這樣的空閑區既可以放在 20 KB 的連結清單中,也可以放在一個專門存放大小比較特别的空閑區連結清單中。
快速比對算法尋找一個指定代銷的空閑區也是十分快速的,但它和所有将空閑區按大小排序的方案一樣,都有一個共同的缺點,即在一個程序終止或被換出時,尋找它的相鄰塊并檢視是否可以合并的過程都是非常耗時的。如果不進行合并,記憶體将會很快分裂出大量程序無法利用的小空閑區。