沒有記憶體,怎麼還能跑程式呢

cxuan發表於2020-02-26

主存(RAM) 是一件非常重要的資源,必須要小心對待記憶體。雖然目前大多數記憶體的增長速度要比 IBM 7094 要快的多,但是,程式大小的增長要比記憶體的增長還快很多。正如帕金森定律說的那樣:不管儲存器有多大,但是程式大小的增長速度比記憶體容量的增長速度要快的多。下面我們就來探討一下作業系統是如何建立記憶體並管理他們的。

經過多年的探討,人們提出了一種 分層儲存器體系(memory hierarchy),下面是分層體系的分類

沒有記憶體,怎麼還能跑程式呢

頂層的儲存器速度最高,但是容量最小,成本非常高,層級結構越向下,其訪問效率越慢,容量越大,但是造價也就越便宜。

作業系統中管理記憶體層次結構的部分稱為記憶體管理器(memory manager),它的主要工作是有效的管理記憶體,記錄哪些記憶體是正在使用的,在程式需要時分配記憶體以及在程式完成時回收記憶體。

下面我們會對不同的記憶體管理模型進行探討,從簡單到複雜,由於最低階別的快取是由硬體進行管理的,所以我們主要探討主存模型和如何對主存進行管理。

無儲存器抽象

最簡單的儲存器抽象是沒有儲存。早期大型計算機(20 世紀 60 年代之前),小型計算機(20 世紀 70 年代之前)和個人計算機(20 世紀 80 年代之前)都沒有儲存器抽象。每一個程式都直接訪問實體記憶體。當一個程式執行如下命令:

MOV REGISTER1, 1000
複製程式碼

計算機會把位置為 1000 的實體記憶體中的內容移到 REGISTER1 中。因此,那時呈現給程式設計師的記憶體模型就是實體記憶體,記憶體地址從 0 開始到記憶體地址的最大值中,每個地址中都會包含一個 8 位位數的單元。

所以這種情況下的計算機不可能會有兩個應用程式同時在記憶體中。如果第一個程式向記憶體地址 2000 的這個位置寫入了一個值,那麼此值將會替換第二個程式在該位置上的值,所以,同時執行兩個應用程式是行不通的,兩個程式會立刻崩潰。

沒有記憶體,怎麼還能跑程式呢

不過即使儲存器模型就是實體記憶體,還是存在一些可選項的。下面展示了三種變體

沒有記憶體,怎麼還能跑程式呢

在上圖 a 中,作業系統位於 RAM(Random Access Memory) 的底部,或像是圖 b 一樣位於 ROM(Read-Only Memory) 頂部;而在圖 c 中,裝置驅動程式位於頂端的 ROM 中,而作業系統位於底部的 RAM 中。圖 a 的模型以前用在大型機和小型機上,但現在已經很少使用了;圖 b 中的模型一般用於掌上電腦或者是嵌入式系統中。第三種模型就應用在早期個人計算機中了。ROM 系統中的一部分成為 BIOS (Basic Input Output System)。模型 a 和 c 的缺點是使用者程式中的錯誤可能會破壞作業系統,可能會導致災難性的後果。

按照這種方式組織系統時,通常同一個時刻只能有一個執行緒正在執行。一旦使用者鍵入了一個命令,作業系統就把需要的程式從磁碟複製到記憶體中並執行;當程式執行結束後,作業系統在使用者終端顯示提示符並等待新的命令。收到新的命令後,它把新的程式裝入記憶體,覆蓋前一個程式。

在沒有儲存器抽象的系統中實現並行性的一種方式是使用多執行緒來程式設計。由於同一程式中的多執行緒內部共享同一記憶體映像,那麼實現並行也就不是問題了。

執行多個程式

但是,即便沒有儲存器抽象,同時執行多個程式也是有可能的。作業系統只需要把當前記憶體中所有內容儲存到磁碟檔案中,然後再把程式讀入記憶體即可。只要某一時間只有一個程式,那麼就不會產生衝突。

在額外特殊硬體的幫助下,即使沒有交換功能,也可以並行的執行多個程式。IBM 360 的早期模型就是這樣解決的

System/360是 IBM 在1964年4月7日,推出的劃時代的大型電腦,這一系列是世界上首個指令集可相容計算機。

沒有記憶體,怎麼還能跑程式呢

在 IBM 360 中,記憶體被劃分為 2KB 的區域塊,每塊區域被分配一個 4 位的保護鍵,保護鍵儲存在 CPU 的特殊暫存器中。一個記憶體為 1 MB 的機器只需要 512 個這樣的 4 位暫存器,容量總共為 256 位元組 (這個會算吧。) PSW(Program Status Word, 程式狀態字) 中有一個 4 位碼。一個執行中的程式如果訪問鍵與其 PSW 碼不同的記憶體,360 硬體會發現這種情況,因為只有作業系統可以修改保護鍵,這樣就可以防止程式之間、使用者程式和作業系統之間的干擾。

這種解決方式是有一個缺陷。如下所示,假設有兩個程式,每個大小各為 16 KB

沒有記憶體,怎麼還能跑程式呢

從圖上可以看出,這是兩個不同的 16KB 程式的裝載過程,a 程式首先會跳轉到地址 24,那裡是一條 MOV 指令,然而 b 程式會首先跳轉到地址 28,地址 28 是一條 CMP 指令。這是兩個程式被先後載入到記憶體中的情形,假如這兩個程式被同時載入到記憶體中從 0 地址處開始執行,記憶體的狀態就如上面 c 圖所示,程式裝載完畢開始執行,第一個程式首先從 0 地址處開始執行,執行 JMP 24 指令,然後依次執行後面的指令(許多指令沒有畫出),一段時間後第一個程式執行完畢,然後開始執行第二個程式。第二個程式的第一條指令是 28,這條指令會使程式跳轉到第一個程式的 ADD 處,而不是事先設定的跳轉指令 CMP,由於記憶體地址的不正確訪問,這個程式可能在 1秒內就崩潰了。

上面兩個程式同時執行最核心的問題是都引用了絕對實體地址。這不是我們想要看到的。我們想要的是每一個程式都會引用一個私有的本地地址。IBM 360 在第二個程式裝載到記憶體中的時候會使用一種稱為 靜態重定位(static relocation) 的技術來修改它。它的工作流程如下:當一個程式被載入到 16384 地址時,常數 16384 被加到每一個程式地址上(所以 JMP 28會變為JMP 16412 )。雖然這個機制在不出錯誤的情況下是可行的,但這不是一種通用的解決辦法,同時會減慢裝載速度。更近一步來講,它需要所有可執行程式中的額外資訊,以指示哪些包含(可重定位)地址,哪些不包含(可重定位)地址。畢竟,上圖 b 中的 JMP 28 可以被重定向(被修改),而類似 MOV REGISTER1,28 會把數字 28 移到 REGISTER 中則不會重定向。所以,裝載器(loader)需要一定的能力來辨別地址和常數。

一種儲存器抽象:地址空間

把實體記憶體暴露給程式會有幾個主要的缺點:第一個問題是,如果使用者程式可以定址記憶體的每個位元組,它們就可以很容易的破壞作業系統,從而使系統停止執行(除非使用 IBM 360 那種 lock-and-key 模式或者特殊的硬體進行保護)。即使在只有一個使用者程式執行的情況下,這個問題也存在。

第二點是,這種模型想要執行多個程式是很困難的(如果只有一個 CPU 那就是順序執行),在個人計算機上,一般會開啟很多應用程式,比如輸入法、電子郵件、瀏覽器,這些程式在不同時刻會有一個程式正在執行,其他應用程式可以通過滑鼠來喚醒。在系統中沒有實體記憶體的情況下很難實現。

地址空間的概念

如果要使多個應用程式同時執行在記憶體中,必須要解決兩個問題:保護重定位。我們來看 IBM 360 是如何解決的:第一種解決方式是用保護金鑰標記記憶體塊,並將執行過程的金鑰與提取的每個儲存字的金鑰進行比較。這種方式只能解決第一種問題,但是還是不能解決多程式在記憶體中同時執行的問題。

還有一種更好的方式是創造一個儲存器抽象:地址空間(the address space)。就像程式的概念建立了一種抽象的 CPU 來執行程式,地址空間也建立了一種抽象記憶體供程式使用。地址空間是程式可以用來定址記憶體的地址集。每個程式都有它自己的地址空間,獨立於其他程式的地址空間,但是某些程式會希望可以共享地址空間。

基址暫存器和變址暫存器

最簡單的辦法是使用動態重定位(dynamic relocation),它就是通過一種簡單的方式將每個程式的地址空間對映到實體記憶體的不同區域。從 CDC 6600(世界上最早的超級計算機)Intel 8088(原始 IBM PC 的核心)所使用的經典辦法是給每個 CPU 配置兩個特殊硬體暫存器,通常叫做基址暫存器(basic register)變址暫存器(limit register)。當使用基址暫存器和變址暫存器使,程式會裝載到記憶體中連續的空間位置並且在裝載期間無需重定位。當一個程式執行時,程式的起始實體地址裝載到基址暫存器中,程式的長度則裝載到變址暫存器中。在上圖 c 中,當一個程式執行時,裝載到這些硬體暫存器中的基址和變址暫存器的值分別是 0 和 16384。當第二個程式執行時,這些值分別是 16384 和 32768。如果第三個 16 KB 的程式直接裝載到第二個程式的地址之上並且執行,這時基址暫存器和變址暫存器的值會是 32768 和 16384。那麼我們可以總結下

  • 基址暫存器:儲存資料記憶體的起始位置
  • 變址暫存器:儲存應用程式的長度。

每當程式引用記憶體以獲取指令或讀取或寫入資料字時,CPU 硬體都會自動將基址值新增到程式生成的地址中,然後再將其傳送到記憶體匯流排上。同時,它檢查程式提供的地址是否等於或大於變址暫存器 中的值。如果程式提供的地址要超過變址暫存器的範圍,那麼會產生錯誤並中止訪問。這樣,對上圖 c 中執行 JMP 28 這條指令後,硬體會把它解釋為 JMP 16412,所以程式能夠跳到 CMP 指令,過程如下

沒有記憶體,怎麼還能跑程式呢

使用基址暫存器和變址暫存器是給每個程式提供私有地址空間的一種非常好的方法,因為每個記憶體地址在送到記憶體之前,都會先加上基址暫存器的內容。在很多實際系統中,對基址暫存器和變址暫存器都會以一定的方式加以保護,使得只有作業系統可以修改它們。在 CDC 6600 中就提供了對這些暫存器的保護,但在 Intel 8088 中則沒有,甚至沒有變址暫存器。但是,Intel 8088 提供了許多基址暫存器,使程式的程式碼和資料可以被獨立的重定位,但是對於超出範圍的記憶體引用沒有提供保護。

所以你可以知道使用基址暫存器和變址暫存器的缺點,在每次訪問記憶體時,都會進行 ADDCMP 運算。比較可以執行的很快,但是加法就會相對慢一些,除非使用特殊的加法電路,否則加法因進位傳播時間而變慢。

交換技術

如果計算機的實體記憶體足夠大來容納所有的程式,那麼之前提及的方案或多或少是可行的。但是實際上,所有程式需要的 RAM 總容量要遠遠高於記憶體的容量。在 Windows、OS X、或者 Linux 系統中,在計算機完成啟動(Boot)後,大約有 50 - 100 個程式隨之啟動。例如,當一個 Windows 應用程式被安裝後,它通常會發出命令,以便在後續系統啟動時,將啟動一個程式,這個程式除了檢查應用程式的更新外不做任何操作。一個簡單的應用程式可能會佔用 5 - 10MB 的記憶體。其他後臺程式會檢查電子郵件、網路連線以及許多其他諸如此類的任務。這一切都會發生在第一個使用者啟動之前。如今,像是 Photoshop 這樣的重要使用者應用程式僅僅需要 500 MB 來啟動,但是一旦它們開始處理資料就需要許多 GB 來處理。從結果上來看,將所有程式始終保持在記憶體中需要大量記憶體,如果記憶體不足,則無法完成。

所以針對上面記憶體不足的問題,提出了兩種處理方式:最簡單的一種方式就是交換(swapping)技術,即把一個程式完整的調入記憶體,然後再記憶體中執行一段時間,再把它放回磁碟。空閒程式會儲存在磁碟中,所以這些程式在沒有執行時不會佔用太多記憶體。另外一種策略叫做虛擬記憶體(virtual memory),虛擬記憶體技術能夠允許應用程式部分的執行在記憶體中。下面我們首先先探討一下交換

交換過程

下面是一個交換過程

沒有記憶體,怎麼還能跑程式呢

剛開始的時候,只有程式 A 在記憶體中,然後從建立程式 B 和程式 C 或者從磁碟中把它們換入記憶體,然後在圖 d 中,A 被換出記憶體到磁碟中,最後 A 重新進來。因為圖 g 中的程式 A 現在到了不同的位置,所以在裝載過程中需要被重新定位,或者在交換程式時通過軟體來執行;或者在程式執行期間通過硬體來重定位。基址暫存器和變址暫存器就適用於這種情況。

沒有記憶體,怎麼還能跑程式呢

交換在記憶體建立了多個 空閒區(hole),記憶體會把所有的空閒區儘可能向下移動合併成為一個大的空閒區。這項技術稱為記憶體緊縮(memory compaction)。但是這項技術通常不會使用,因為這項技術回消耗很多 CPU 時間。例如,在一個 16GB 記憶體的機器上每 8ns 複製 8 位元組,它緊縮全部的記憶體大約要花費 16s。

有一個值得注意的問題是,當程式被建立或者換入記憶體時應該為它分配多大的記憶體。如果程式被建立後它的大小是固定的並且不再改變,那麼分配策略就比較簡單:作業系統會準確的按其需要的大小進行分配。

但是如果程式的 data segment 能夠自動增長,例如,通過動態分配堆中的記憶體,肯定會出現問題。這裡還是再提一下什麼是 data segment 吧。從邏輯層面作業系統把資料分成不同的段(不同的區域)來儲存:

  • 程式碼段(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++ 編譯過後的結果

_TEXT	segment dword public use32 'CODE'
_TEXT	ends
_DATA	segment dword public use32 'DATA'
_DATA	ends
_BSS	segment dword public use32 'BSS'
_BSS	ends
複製程式碼

段定義( segment ) 是用來區分或者劃分範圍區域的意思。組合語言的 segment 偽指令表示段定義的起始,ends 偽指令表示段定義的結束。段定義是一段連續的記憶體空間

所以記憶體針對自動增長的區域,會有三種處理方式

  • 如果一個程式與空閒區相鄰,那麼可把該空閒區分配給程式以供其增大。

  • 如果程式相鄰的是另一個程式,就會有兩種處理方式:要麼把需要增長的程式移動到一個記憶體中空閒區足夠大的區域,要麼把一個或多個程式交換出去,已變成生成一個大的空閒區。

  • 如果一個程式在記憶體中不能增長,而且磁碟上的交換區也滿了,那麼這個程式只有掛起一些空閒空間(或者可以結束該程式)

沒有記憶體,怎麼還能跑程式呢

上面只針對單個或者一小部分需要增長的程式採用的方式,如果大部分程式都要在執行時增長,為了減少因記憶體區域不夠而引起的程式交換和移動所產生的開銷,一種可用的方法是,在換入或移動程式時為它分配一些額外的記憶體。然而,當程式被換出到磁碟上時,應該只交換實際上使用的記憶體,將額外的記憶體交換也是一種浪費,下面是一種為兩個程式分配了增長空間的記憶體配置。

沒有記憶體,怎麼還能跑程式呢

如果程式有兩個可增長的段,例如,供變數動態分配和釋放的作為堆(全域性變數)使用的一個資料段(data segment),以及存放區域性變數與返回地址的一個堆疊段(stack segment),就如圖 b 所示。在圖中可以看到所示程式的堆疊段在程式所佔記憶體的頂端向下增長,緊接著在程式段後的資料段向上增長。當增長預留的記憶體區域不夠了,處理方式就如上面的流程圖(data segment 自動增長的三種處理方式)一樣了。

空閒記憶體管理

在進行記憶體動態分配時,作業系統必須對其進行管理。大致上說,有兩種監控記憶體使用的方式

  • 點陣圖(bitmap)
  • 空閒列表(free lists)

下面我們就來探討一下這兩種使用方式

使用點陣圖的儲存管理

使用點陣圖方法時,記憶體可能被劃分為小到幾個字或大到幾千位元組的分配單元。每個分配單元對應於點陣圖中的一位,0 表示空閒, 1 表示佔用(或者相反)。一塊記憶體區域和其對應的點陣圖如下

沒有記憶體,怎麼還能跑程式呢

圖 a 表示一段有 5 個程式和 3 個空閒區的記憶體,刻度為記憶體分配單元,陰影區表示空閒(在點陣圖中用 0 表示);圖 b 表示對應的點陣圖;圖 c 表示用連結串列表示同樣的資訊

分配單元的大小是一個重要的設計因素,分配單位越小,點陣圖越大。然而,即使只有 4 位元組的分配單元,32 位的記憶體也僅僅只需要點陣圖中的 1 位。32n 位的記憶體需要 n 位的點陣圖,所以1 個點陣圖只佔用了 1/32 的記憶體。如果選擇更大的記憶體單元,點陣圖應該要更小。如果程式的大小不是分配單元的整數倍,那麼在最後一個分配單元中會有大量的記憶體被浪費。

點陣圖提供了一種簡單的方法在固定大小的記憶體中跟蹤記憶體的使用情況,因為點陣圖的大小取決於記憶體和分配單元的大小。這種方法有一個問題是,當決定為把具有 k 個分配單元的程式放入記憶體時,內容管理器(memory manager) 必須搜尋點陣圖,在點陣圖中找出能夠執行 k 個連續 0 位的串。在點陣圖中找出制定長度的連續 0 串是一個很耗時的操作,這是點陣圖的缺點。(可以簡單理解為在雜亂無章的陣列中,找出具有一大長串空閒的陣列單元)

使用連結串列進行管理

另一種記錄記憶體使用情況的方法是,維護一個記錄已分配記憶體段和空閒記憶體段的連結串列,段會包含程式或者是兩個程式的空閒區域。可用上面的圖 c 來表示記憶體的使用情況。連結串列中的每一項都可以代表一個 空閒區(H) 或者是程式(P)的起始標誌,長度和下一個連結串列項的位置。

在這個例子中,段連結串列(segment list)是按照地址排序的。這種方式的優點是,當程式終止或被交換時,更新列表很簡單。一個終止程式通常有兩個鄰居(除了記憶體的頂部和底部外)。相鄰的可能是程式也可能是空閒區,它們有四種組合方式。

沒有記憶體,怎麼還能跑程式呢

當按照地址順序在連結串列中存放程式和空閒區時,有幾種演算法可以為建立的程式(或者從磁碟中換入的程式)分配記憶體。我們先假設記憶體管理器知道應該分配多少記憶體,最簡單的演算法是使用 首次適配(first fit)。記憶體管理器會沿著段列表進行掃描,直到找個一個足夠大的空閒區為止。除非空閒區大小和要分配的空間大小一樣,否則將空閒區分為兩部分,一部分供程式使用;一部分生成新的空閒區。首次適配演算法是一種速度很快的演算法,因為它會盡可能的搜尋連結串列。

首次適配的一個小的變體是 下次適配(next fit)。它和首次匹配的工作方式相同,只有一個不同之處那就是下次適配在每次找到合適的空閒區時就會記錄當時的位置,以便下次尋找空閒區時從上次結束的地方開始搜尋,而不是像首次匹配演算法那樣每次都會從頭開始搜尋。Bays(1997) 證明了下次演算法的效能略低於首次匹配演算法。

另外一個著名的並且廣泛使用的演算法是 最佳適配(best fit)。最佳適配會從頭到尾尋找整個連結串列,找出能夠容納程式的最小空閒區。最佳適配演算法會試圖找出最接近實際需要的空閒區,以最好的匹配請求和可用空閒區,而不是先一次拆分一個以後可能會用到的大的空閒區。比如現在我們需要一個大小為 2 的塊,那麼首次匹配演算法會把這個塊分配在位置 5 的空閒區,而最佳適配演算法會把該塊分配在位置為 18 的空閒區,如下

沒有記憶體,怎麼還能跑程式呢

那麼最佳適配演算法的效能如何呢?最佳適配會遍歷整個連結串列,所以最佳適配演算法的效能要比首次匹配演算法差。但是令人想不到的是,最佳適配演算法要比首次匹配和下次匹配演算法浪費更多的記憶體,因為它會產生大量無用的小緩衝區,首次匹配演算法生成的空閒區會更大一些。

最佳適配的空閒區會分裂出很多非常小的緩衝區,為了避免這一問題,可以考慮使用 最差適配(worst fit) 演算法。即總是分配最大的記憶體區域(所以你現在明白為什麼最佳適配演算法會分裂出很多小緩衝區了吧),使新分配的空閒區比較大從而可以繼續使用。模擬程式表明最差適配演算法也不是一個好主意。

如果為程式和空閒區維護各自獨立的連結串列,那麼這四個演算法的速度都能得到提高。這樣,這四種演算法的目標都是為了檢查空閒區而不是程式。但這種分配速度的提高的一個不可避免的代價是增加複雜度和減慢記憶體釋放速度,因為必須將一個回收的段從程式連結串列中刪除並插入空閒連結串列區。

如果程式和空閒區使用不同的連結串列,那麼可以按照大小對空閒區連結串列排序,以便提高最佳適配演算法的速度。在使用最佳適配演算法搜尋由小到大排列的空閒區連結串列時,只要找到一個合適的空閒區,則這個空閒區就是能容納這個作業的最小空閒區,因此是最佳匹配。因為空閒區連結串列以單連結串列形式組織,所以不需要進一步搜尋。空閒區連結串列按大小排序時,首次適配演算法與最佳適配演算法一樣快,而下次適配演算法在這裡毫無意義。

另一種分配演算法是 快速適配(quick fit) 演算法,它為那些常用大小的空閒區維護單獨的連結串列。例如,有一個 n 項的表,該表的第一項是指向大小為 4 KB 的空閒區連結串列表頭指標,第二項是指向大小為 8 KB 的空閒區連結串列表頭指標,第三項是指向大小為 12 KB 的空閒區連結串列表頭指標,以此類推。比如 21 KB 這樣的空閒區既可以放在 20 KB 的連結串列中,也可以放在一個專門存放大小比較特別的空閒區連結串列中。

快速匹配演算法尋找一個指定代銷的空閒區也是十分快速的,但它和所有將空閒區按大小排序的方案一樣,都有一個共同的缺點,即在一個程式終止或被換出時,尋找它的相鄰塊並檢視是否可以合併的過程都是非常耗時的。如果不進行合併,記憶體將會很快分裂出大量程式無法利用的小空閒區。

沒有記憶體,怎麼還能跑程式呢

相關文章