• 沒有找到結果。

1 記憶體布局

N/A
N/A
Protected

Academic year: 2022

Share "1 記憶體布局 "

Copied!
7
0
0

加載中.... (立即查看全文)

全文

(1)

1 記憶體布局

先前我們在課程中提過,一旦使用遞迴且層數足夠多,就必須要考慮到 stack overflow 的問題。什麼是 stack overflow 呢?為了解釋這個概念,我們在以下順便介紹 一般 C 語言程式的記憶體布局 (memory layout)。

Memory layout 的實作方法事實上是仰賴於 OS (operating system, 作業系統) 的,

以 Linux (TOJ 的作業系統!) 而言,一個 C 程式的 memory layout 如圖 1。

Figure 1: Linux 上的一般執行檔記憶體布局

在圖中我們可以看到 C 程式的記憶體配置可以分成 5 個區塊 (segment),區塊在越 左邊,代表在記憶體中通常位於較高的位置 (high address, 可以想成所在記憶體的編 號通常比較大)。各個區塊的意義如下:

• Text Segment

又稱為 code segment,一般而言簡稱為 text,基本上就是存放程式指令碼的地 方。我們寫的 C 程序在經過編譯器優化、編譯之後,會轉變成只有電腦才看得 懂的機器指令碼,這些指令碼會形成最後的執行檔的一部分,並且在程式起跑時 載入到記憶體中,存在 text 區。一般而言,text 區都會被系統設定為唯讀 (read only),以避免被使用者不小心或者刻意修改,形成恐怖的不可預期的致命錯誤。

• Initialized Data Segment

有些變數是一執行就固定位置的,例如全域變數 (global variable)、靜態變數 (static variable),這些變數如果在程式碼中有被使用者手動初始化 (這邊的手動 初始化是指宣告時就順便賦值的初始化,事後自己賦值或者 memset 等等的並不 算),就會被配置於此區塊。

• Uninitialized Data Segment

(2)

此一區塊又被稱為 bss,意為「僅由符號起始的區塊」(block started by symbol),

在這個區塊內的變數都是使用者沒有在宣告時初始化內容的全域變數或靜態變 數。值得一提的是,儘管使用者並沒有手動初始化這些變數,這些變數都會被自 動初始化為 0。

• Heap

當我們直接或間接使用到 malloc 時,系統動態配給的記憶體的所在區塊。這個 區域通常是「往上長」的,也就是說當我們多次動態配置記憶體,新配置到的記 憶體通常會出現在較高的位置,疊加在先前配置的記憶體上面。

• Stack

本次的主角,原則上所有自動變數 (automatic variable),也就是所有在 function 中宣告的非靜態變數 (注意到 main 本身就是一個 function!) 都會位於這個區 塊。由於 C 語言支援函數的特性,函數內的變數並不是一開始就獨立存在的,

而是進入函數並且宣告後才真實存在。每當我們多宣告了一個變數,系統就會 把該變數推入到 stack 當中;而當我們呼叫 function 時,該 function 內的變數 們就會疊加到 stack 的最頂端。例如我們有三個函式 main(), f(), g(),然後我們 先進入 main(),接著呼叫 f(),f() 又呼叫 g(),g() 又遞迴自己一次,那麼當前的 stack 大概會長得像:

當然實際上不是真的把整個函式作為物件推入 stack,圖中的函式事實上是代表 該函式內的所有變數,包含一些非使用者自己宣告的系統變數,例如記錄當前 執行到函式哪一行的變數之類的。在記憶體配置的概念上,stack 和 heap 總是 反向的,例如 heap 如果是往 high address 長 (大部分如此),stack 就會往 low

(3)

address 長;heap 往 low address 長,stack 就往 high address 長,原因是因為這 樣可以避免兩者在還有記憶體可用的情況下就因為預留空間不足而相撞的問題。

瞭解記憶體的架構之後,考慮一個問題:如果 stack 的深度沒有上限,那麼當使用 者不小心寫出了一個無窮遞迴時,程序會發生什麼事情?這程序會一直把新的變數推 入 stack,直到把記憶體用光為止。這顯然不是我們希望發生的事情,通常而言一個

「正常」的遞迴不會深得很誇張才是,因此大多數時候系統會對 stack 區塊設定一個大 小限制,如果一個程序使用的 stack 記憶體量超過該限制就強制把該程序結束並且回 傳錯誤信號,這便是 stack overflow 的由來了。

在一些演算法中,我們有時候會遞迴得誇張地深 (通常發生在對一張圖 DFS),高 達數百萬層之類的。對一般的系統而言這個深度當然很可疑,因此許多系統中會強制 把這樣子的程序結束掉,形成 Runtime Error 的誤判。這種時候常見的處理方法有幾 種:

1. 如果本身就是系統管理者,可以透過修改設定解決此一問題。

2. 如果本身並不具有管理員權限,但是是特定 OS (如 Windows) 且只需要繳交可 執行檔,那麼可以透過修改編譯器設定解決此一問題 (P.S. Windows 中 stack 的 記憶體限制是記錄在可執行檔裡的,可以在編譯時期加設定修改)。

3. 如果以上兩者皆非,那麼有三種方式:

(a) 使用特殊指令改變 stack 使用的記憶體,如:組合語言。

(b) 換成一個不需要極深遞迴的演算法 (例如 DFS 換成 BFS,如果可行的話)。

(c) 自己實做 stack,模擬遞迴。

如果對組合語言不熟悉,又是在競賽期間或者是線上評測系統,最常使用的還是 3.(b) 或 3.(c),也因此有一兩次自己實做遞迴的經驗是很重要的。

2 虛擬記憶體

如果我們進一步地思考作業系統會怎麼實做上述的記憶體布局,我們很快就會發現 一個問題──到底 heap 的底部和 stack 的頂部要相隔多遠才好呢?假如記憶體從 0 編 號到 99,每一個程式 heap 底下最少要 10 單位記憶體,stack 以上最少要 15 單位記 憶體,而現在有一個程式 heap 的底部設定在 10,stack 的頂部設計在 49。那麼實際 上這個程式只要使用 heap 和 stack 兩個部份的記憶體總量超過 40 單位,整個程式就 會因為把 heap 和 stack 的記憶體用謦而產生記憶體錯誤或者不足的問題。明明記憶體 有 100 單位,怎麼只能使用 10 + 15 + 40 = 65 單位的記憶體呢?這樣子如果只有要跑 這個程式,剩下的 35 單位記憶體就形成了徹徹底底的浪費,花了錢買記憶體,結果 只買到了一場心碎的騙局。

如果想要避免這樣的問題,直接把兩者之間的距離拉到盡量遠,將 heap 的底部設 定在 10,stack 的頂部設定在 84,原先的問題就徹底解決了。然而,新的問題是,如 果同時這時有第二個程式要執行,我們該如何配置這個程式的記憶體位置呢?不管怎

(4)

麼配置,都一定會插在第一個程式的某些記憶體區間內,這樣不是造成第一個程式記 憶體錯誤,就是讓 heap 和 stack 的大小再次受到了實質限制 (總不能寫到第二個程式 的記憶體還繼續寫吧?)。

從上面兩段的例子我們就可以知道,記憶體布局實際上是相當困難的問題:我們 或者必須要自斷手腳,限制每個程序的記憶體使用量,好讓作業系統能輕易地配置空 間,或者就得要犧牲對多程序的支援,再不然就得要放棄記憶體連續配置的記憶體布 局,讓程式變得極度難以設計。這一切的萬惡來源都在於每個程序都直接操縱了實體 的記憶體,兩個程式之間彼此互相無法溝通,卻要爭奪有限的資源,自然會產生諸多 摩擦、激情與火花。

仔細想想就會發現,整個問題的癥結事實上在於我們要求「連續區間」的便利性。

如果概念上拿到的記憶體不是連續的區間,我們撰寫程式時就很難知道還有哪些記憶 體可以用,想要夠紮實地使用記憶體也變得困難。但就像情侶間的關係一樣,如果 A 和 B 是情侶,B 雖然實際上腳踏兩條船但卻能做到讓 A 渾然不覺,那麼到底 B 的腳 踏兩條船又有什麼關係呢?對於取用資源的一方而言,只要它所要求的條件和資源都 能夠達成,真相是什麼真的有那麼重要嗎?

這便是作業系統做的魔法了。作業系統一樣在每個程式執行時會依據程式指定的記 憶體位址對記憶體進行操作,但實際上真正被操作的記憶體位址是作業系統自行決定 的,它只要做到讓程式看起來事情有被順利執行即可。舉例而言,某個程式可能會以 為它自己可以操作的記憶體位址是 [0, 100] 這個區間,但是當它要求操作 0 這個位址 時,可能實際上被操作的位址是 2217;當它要求操作 1 這個位址時,可能實際上被 操作的位址是 514;當它要求操作 2 這個位址時,可能實際上被操作的位址是 689...

只要作業系統能夠保證兩個對程式而言不一樣的記憶體位址永遠都不會對應到一樣的 實體記憶體位址,且兩個對程式而言一樣的記憶體位址永遠都對應到一樣的實體記憶 體位址,實際上真正被操作的記憶體位址為何對程式而言根本就不重要了!而程式看 到的記憶體位址並非實體位址的特色,讓我們對應於實體記憶體稱呼這樣的記憶體為

「虛擬記憶體 (virtual memory)」。

虛擬記憶體可以怎樣解決事情呢?最明顯的功能是:

• 記憶體由預先靜態配置改為可動態配置

在原本的記憶體配置中,為了所有的可用記憶體區間都是連續的,作業系統必須一 口氣配置好連續的記憶體區塊,否則事後配置的記憶體就會和一開始的記憶體區間不 連續;在虛擬記憶體系統下,記憶體只是「看起來」是連續的,實際上是離散的,便 可以輕鬆地動態配置了,只要作業系統好好地維護虛擬與實體位址的對照表即可。而 動態配置,則可以在某個記憶體真正被需要時才進行配置,這樣一來一開始作業系統 就可以先「表面上」配置給每個程式跟總記憶體大小一樣大的空間,但卻不需要幫每 個程式都預留這麼大的空間。

舉例而言,一樣記憶體有 100 單位大小,現有兩個程式 A, B,各自先用了基本的 10 + 15單位記憶體 (承續上面的假設),那實體記憶體中可能作業系統會先分別配置那 基本的 50 單位在 [0, 49]。接著 A 可能呼叫了某個函式,需要用 2 單位的記憶體,作 業系統可以把這 2 單位配置在實體的 [50, 51];如果接下來 B 動態配置了 10 單位的記 憶體 (malloc, new 之類的語法),作業系統可以把這 10 單位配置在實體的 [52, 61]。對

(5)

作業系統而言,所有的程式的各種記憶體都交錯在一起,並沒有太大的分別,它只須 記得「某個程式口中的某個記憶體位址其實是某某位址」就可以了。

既然有了這樣子的對照表,我們還可以進一步做更多神奇的事情,例如:

• 不同的程序間共用相同的記憶體

一個很常見的情景是,兩個程序會共用同一份資料。這種情況下,如果我們在兩 個程序中都把這些資料載入記憶體,同一份資料不就佔了兩份空間嗎?有了虛擬記憶 體的概念,我們只需要把這兩個程序裡面資料儲存的位址通通都指向同一塊實體記憶 體,就可以在兩方都維持獨立性的情況下避免掉許多不必要的浪費了!(※ 想一想:如 果兩個程序互相不知道對方的存在,也不清楚記憶體可能會跟別人共用,會有什麼樣 的危險?怎樣的記憶體才能夠安心地共用呢?)

在原先的模型中,我們存取的都是同一塊記憶體,一般而言實體上對應的是 RAM (Random Access Memory)。RAM 雖然相當快速,但壞處是空間相當不充足,很容易 有用完的一天。記憶體如果用不夠了,程式當然跑不下去,難道就放任程式毀滅嗎?

虛擬記憶體再次挺身而出:

• 跨越不同的儲存裝置

與其讓程式崩壞,我們不如退而求其次使用比較慢但是容量更大的儲存裝置,如硬 碟。傳統的 RAM 要超過 128 GB 已經是非常昂貴的了,相較之下硬碟 2 TB 的價格可 能都還要比較便宜。如果我們可以把這麼巨大的容量拿來使用,當然彈性會大增,然 而使用原先直接操作實體記憶體的方法,我們很難做到「跨越不同的記憶體裝置」這 樣的事情。

Figure 2: 一個簡單的有無 虛擬記憶體的比較圖

有了虛擬記憶體,我們可以在作業系統這端進行管 理,超出實體記憶體負荷的部份我們就將其指向其他不 同儲存裝置,這樣便能夠在不影響程式的抽象性的情況 下,讓程式實質存取不同的記憶硬體。進一步地,使用 者通常很少會一口氣積極地使用很多個程式,多數的程 式在多數的時間都是處於停滯、等待或者低效能運算的 狀態。也因此一個常見的技術是,在 RAM 不足時,我 們總是把當前最頻繁存取的幾個程式放在 RAM 裡,其 餘的程式放在硬碟裡,並且在程式狀態改變時改變儲存 的位置 (例如原先 A 的多數區塊在 RAM,B 的多數區塊 在硬碟,但接下來要 B 要大量存取記憶體,所以把 B 的 多數區塊搬到記憶體來,A 的多數區塊搬到硬碟去,以 加快當前執行的 B 速度)。如果是直接存取實體記憶體的 架構,此時實體位址會有極大量的改變,A 和 B 都必須 要大規模地重整記憶體配置;但在虛擬記憶體的框架下,

對 A 和 B 這兩個程序而言,除了存取資料時不知道為什 麼速度產生了改變,整個世界看起來並無分別,大大減 輕了開發者的負擔。

(6)

習題

1. 閱讀完記憶體布局的方式後,請推論並回答下列問題:

(a) (10 pts) 下列敘述何者為真?

(A) 記憶體布局存在標準,所有作業系統都必須遵循標準的布局系統 (B) 在同一個作業系統上,不同的 C 語言程式可能會有不同的記憶體布局 (C) 有時候遇到遞迴過深的問題可以使用修改程式以外的方式解決

(D) 如果遞迴太深,會把 heap 的記憶體用光,導致 runtime error (b) (10 pts) 關於各個記憶體區塊的特性,下列何者為非?

(A) 一樣的 code 在不同編譯器下編譯後形成的執行檔大小不一定一樣,因為 不同編譯器轉換成機器碼的方式可能不同

(B) 靜態變數和區域變數如果沒有指定初始值,預設有可能會是任意的值 (C) 因為編程者指定的初始值會存在 initialized data segment,所以手動初始化

的變數越多,一般而言編譯後的執行檔會越大

(D) 在程式開始執行後,位於 initialized data segment 和 uninitialized data segment 的資料都還是有可能會被修改

(c) (10 pts) 下列基於上述記憶體布局的推論,何者正確?

(A) 如果我們同時運行兩個相同的程式 (但不一定做相同的操作,如同時開 兩個 skype 視窗跟不同的人聊天),那麼我們可以透過兩者共享相同的 initialized segment data 記憶體來節省空間

(B) 如果我們同時運行兩個相同的程式 (但不一定做相同的操作,如同時開兩 個 skype 視窗跟不同的人聊天),那麼我們可以透過兩者共享相同的 text segment 記憶體來節省空間

(C) 假如某個程式中有兩個 function main(), f (),並且執行順序為:

1. 執行 main() 2. 呼叫並執行 f ()

3. 退出 f (),回到 main() 4. 退出 main(),程式結束

那麼在執行期間,任意 f () 內的變數的記憶體位置一定都比任意 main() 內的變數的記憶體位置還要小

(D) 在 Linux 上我們可以透過修改編譯參數來增加 stack 深度的上限 2. 有份程式碼如下:

1 int var1;

2 static int var2;

3 int var3 = 3;

4

5 int f(int var4 ){

6 if( var4 <= 1 ) return 1;

7 else return f(var4 -1) + f(var4 -2);

8 }

(7)

9

10 int main (){

11 int var5;

12 static int var6;

13 int *var7 = (int*) malloc (sizeof(int)*100);

14 int var8 = 5;

15 return 0;

16 }

請問程式碼中,各個變數分別儲存於記憶體分佈中的哪個區塊呢?請使用代號 (A~E) 回答問題。

選項:

(A) Text Segment

(B) Initialized Data Segment (C) Uninitialized Data Segment (D) Heap

(E) Stack

(a) (5 pts) var1 (b) (5 pts) var2 (c) (5 pts) var3 (d) (5 pts) var4 (e) (5 pts) var5 (f) (5 pts) var6 (g) (5 pts) var7 (h) (5 pts) var8 (i) (5 pts) *var7 (j) (5 pts) main

參考文獻

相關文件

mov ax,var1 ;將其中一個記憶體內容先複製到暫存器 xchg ax,var2 ;分別執行記憶體與暫存器內容的交換動作 xchg ax,var1 ;完成交換。 Swap var1

• 長久的結合體,就是那種與〝自我〞相關的包含

所謂 是指最主要的部分會一直放在記憶體中,可是那些只有在特定時候才需要用到 的指令或資料,就只有被用到的時候才會放在記憶體中,不用到的時候可被蓋掉。.

遊記是一種以記述為主的文體。凡是我們到過的地方、發生的事情,以及遊歷期間

此位址致能包括啟動代表列與行暫存器的 位址。兩階段的使用RAS與CAS設定可以

電腦 CPU 讀取資料時,先向快取記憶體 (cache memory) 詢問,若無所需資 料則到 RAM 讀取,同時將讀取的資料存入快取記憶體中,下次讀取同樣的 資料時,會向快取記憶體讀取。.. (

省到不能再省----剛好只夠作業系統開機而已。譬如 Windows 95 作業系統的年 代,因為 Windows 95 需要 32MB 以上的記憶體才能執行,所以成套賣的電腦大 都只裝 32MB 的記憶體。Windows

我是一個很喜歡將事情拖延至最後一刻才做 的人。記得有一次,有一份作業,老師讓我們三天