解析內(nèi)存中的程序(翻譯https://manybutfinite.com/post/anatomy-of-a-program-in-memory/)
內(nèi)存管理是操作系統(tǒng)的核心;它對(duì)于編程和系統(tǒng)管理都是至關(guān)重要的。在接下來的幾篇文章中,我將關(guān)注內(nèi)存的實(shí)際方面,但不會(huì)回避內(nèi)存的內(nèi)部特性。雖然這些概念是通用的,但是示例大多來自32位x86上的Linux和Windows。
第一篇文章描述了程序是如何在內(nèi)存中布局的。多任務(wù)操作系統(tǒng)中的每個(gè)進(jìn)程都在自己的內(nèi)存沙箱中運(yùn)行。這個(gè)沙箱是虛擬地址空間,在32位模式下總是4GB內(nèi)存地址塊。這些虛擬地址由頁表映射到物理內(nèi)存,頁表由操作系統(tǒng)內(nèi)核維護(hù)并由處理器查詢。每個(gè)進(jìn)程都有自己的一組頁表,但是有一個(gè)問題(Each process has its own set of page tables, but there is a catch)。一旦啟用了虛擬地址,它們將應(yīng)用于計(jì)算機(jī)中運(yùn)行的所有軟件,包括內(nèi)核本身。因此,虛擬地址空間的一部分必須保留給內(nèi)核:

這并不意味著內(nèi)核使用了那么多物理內(nèi)存,只是它有那部分地址空間可用來映射它希望映射的任何物理內(nèi)存。在頁表中,內(nèi)核空間被標(biāo)記為獨(dú)占特權(quán)代碼(第2層或更低),因此,如果用戶模式程序試圖訪問它,就會(huì)觸發(fā)缺頁異常。在Linux中,內(nèi)核空間一直存在,并在所有進(jìn)程中映射相同的物理內(nèi)存。內(nèi)核代碼和數(shù)據(jù)總是可尋址的,隨時(shí)可以處理中斷或系統(tǒng)調(diào)用。相反,每當(dāng)進(jìn)程切換發(fā)生時(shí),地址空間的用戶模式部分的映射就會(huì)改變:

藍(lán)色區(qū)域表示映射到物理內(nèi)存的虛擬地址,而白色區(qū)域未映射。在上面的例子中,F(xiàn)irefox使用了更多的虛擬地址空間,這是由于它對(duì)內(nèi)存的巨大需求。地址空間中不同的頻帶對(duì)應(yīng)于堆、堆棧等內(nèi)存段。請(qǐng)記住,這些段只是內(nèi)存地址的范圍,與Intel類型的段無關(guān)。無論如何,下面是Linux進(jìn)程中的標(biāo)準(zhǔn)段布局:

當(dāng)計(jì)算正常、安全的執(zhí)行時(shí),上面所示的段的起始虛擬地址對(duì)于計(jì)算機(jī)中的幾乎每個(gè)進(jìn)程都是完全相同的。這使得遠(yuǎn)程利用安全漏洞變得很容易。漏洞常常需要引用絕對(duì)內(nèi)存位置:堆棧上的地址、庫函數(shù)的地址等等。遠(yuǎn)程攻擊者必須盲目地選擇這個(gè)位置,因?yàn)榈刂房臻g都是相同的。正因?yàn)槿绱藭r(shí),程序容易被黑客攻破。因此,地址空間隨機(jī)化已經(jīng)變得很流行。Linux通過向它們的起始地址添加偏移量來隨機(jī)化堆棧、內(nèi)存映射段和堆。不幸的是,32位的地址空間非常緊張,幾乎沒有隨機(jī)化的空間,這阻礙了它的有效性。
進(jìn)程地址空間中最上面的部分是堆棧,它在大多數(shù)編程語言中存儲(chǔ)本地變量和函數(shù)參數(shù)。調(diào)用方法或函數(shù)會(huì)將新的堆棧幀推入堆棧。當(dāng)函數(shù)返回時(shí),堆棧幀被銷毀。這種簡單的設(shè)計(jì)可能的原因是因?yàn)閿?shù)據(jù)遵循嚴(yán)格的后進(jìn)先出(LIFO)順序,這意味著不需要復(fù)雜的數(shù)據(jù)結(jié)構(gòu)來跟蹤堆棧內(nèi)容——一個(gè)指向堆棧頂部的簡單指針就可以做到。往棧里push和pop數(shù)據(jù)因而是非??焖俸痛_定的。此外,堆棧區(qū)域的不斷重用往往會(huì)在cpu緩存中保持活動(dòng)的堆棧內(nèi)存,從而加快訪問速度。進(jìn)程中的每個(gè)線程都有自己的堆棧。
這有可能通過寫(push)入比它所能容納的更多的數(shù)據(jù)來耗盡映射堆棧的區(qū)域。這將觸發(fā)一個(gè)頁面錯(cuò)誤(page fault),該錯(cuò)誤在Linux中由expand_stack()處理,而expand_stack()又調(diào)用acct_stack_growth()來檢查是否適合擴(kuò)展堆棧。如果堆棧大小低于RLIMIT_STACK(通常為8MB),那么堆棧通常會(huì)增長,程序也會(huì)愉快地繼續(xù)運(yùn)行,不知道剛剛發(fā)生了什么。這是根據(jù)需要調(diào)整堆棧大小的正常機(jī)制。然而,如果達(dá)到最大堆棧大小,我們有一個(gè)堆棧溢出,并且程序收到段錯(cuò)誤(Segmentation Fault)。當(dāng)映射的堆棧區(qū)域擴(kuò)展以滿足需求時(shí),它不會(huì)在堆棧變小時(shí)收縮。就像聯(lián)邦政府預(yù)算一樣,它只會(huì)擴(kuò)大。
動(dòng)態(tài)堆棧增長是訪問未映射的內(nèi)存區(qū)域(如上圖中白色部分所示)可能有效的惟一情況。對(duì)未映射內(nèi)存的任何其他訪問都將觸發(fā)一個(gè)導(dǎo)致段錯(cuò)誤的頁面錯(cuò)誤。一些映射區(qū)域是只讀的,因此對(duì)這些區(qū)域的寫嘗試也會(huì)導(dǎo)致段錯(cuò)誤。
在堆棧下面,我們有內(nèi)存映射段。這里,內(nèi)核將文件的內(nèi)容直接映射到內(nèi)存。任何應(yīng)用程序都可以通過Linux mmap()系統(tǒng)調(diào)用(實(shí)現(xiàn))或Windows中的CreateFileMapping() / MapViewOfFile()請(qǐng)求這樣的映射。內(nèi)存映射是執(zhí)行文件I/O的一種方便且高性能的方法,因此它用于加載動(dòng)態(tài)庫。還可以創(chuàng)建不與任何文件對(duì)應(yīng)的匿名內(nèi)存映射,將其用于程序數(shù)據(jù)。在Linux中,如果通過malloc()請(qǐng)求大內(nèi)存塊,C庫將創(chuàng)建這樣的匿名映射,而不是使用堆內(nèi)存?!按蟆北硎敬笥贛MAP_THRESHOLD字節(jié),默認(rèn)為128 kB,可以通過mallopt()進(jìn)行調(diào)整。
說到堆,接下來是地址空間。與堆棧不同,堆提供運(yùn)行時(shí)內(nèi)存分配,這意味著數(shù)據(jù)必須比執(zhí)行分配的函數(shù)壽命長。大多數(shù)語言都為程序提供堆管理。因此,滿足內(nèi)存請(qǐng)求是語言運(yùn)行時(shí)和內(nèi)核之間的共同事務(wù)。在C語言中,堆分配的接口是malloc(),而在其它語言例如c#這樣的垃圾回收語言中,接口是new關(guān)鍵字。
如果堆中有足夠的空間來滿足內(nèi)存請(qǐng)求,那么語言運(yùn)行時(shí)可以在不涉及內(nèi)核的情況下處理它。否則,通過brk()系統(tǒng)調(diào)用(實(shí)現(xiàn))來擴(kuò)大堆,為請(qǐng)求的塊騰出空間。在面對(duì)我們程序的復(fù)雜分配模式時(shí),堆管理是復(fù)雜的,需要復(fù)雜的算法,以爭取速度和有效的內(nèi)存使用。為堆請(qǐng)求提供服務(wù)所需的時(shí)間可能有很大差異。實(shí)時(shí)系統(tǒng)有特殊用途的分配器來處理這個(gè)問題。堆也變得產(chǎn)生很多碎片,如下圖所示:

最后,我們討論內(nèi)存的最低段:BSS、數(shù)據(jù)和程序文本。在c語言中,BSS和數(shù)據(jù)都存儲(chǔ)靜態(tài)(全局)變量的內(nèi)容。不同的是,BSS存儲(chǔ)未初始化的靜態(tài)變量的內(nèi)容,這些變量的值在源代碼中沒有被程序員設(shè)置。BSS內(nèi)存區(qū)域是匿名的:它不映射任何文件。如果您說的是static int cntActiveUsers,那么cntActiveUsers的內(nèi)容就位于BSS中。
另一方面,數(shù)據(jù)段保存源代碼中初始化的靜態(tài)變量的內(nèi)容。這個(gè)內(nèi)存區(qū)域不是匿名的。它映射程序二進(jìn)制圖像中包含源代碼中給定的初始靜態(tài)值的部分。因此,如果您說static int cntWorkerBees = 10,那么cntWorkerBees的內(nèi)容就在數(shù)據(jù)段中,并從10開始。即使數(shù)據(jù)段映射一個(gè)文件,它也是一個(gè)私有內(nèi)存映射,這意味著對(duì)內(nèi)存的更新不會(huì)反映在底層文件中。必須如此,否則對(duì)全局變量的賦值將改變磁盤上的二進(jìn)制映像。不可思議!
圖中的數(shù)據(jù)示例比較復(fù)雜,因?yàn)樗褂昧艘粋€(gè)指針。在這種情況下,指針的內(nèi)容——一個(gè)4字節(jié)的內(nèi)存地址——在數(shù)據(jù)段中。然而,它所指向的實(shí)際字符串卻不是。字符串位于文本段中,該文本段是只讀的,除了字符串文本之類的花絮外,還存儲(chǔ)所有代碼。文本段也映射你的二進(jìn)制文件在內(nèi)存中,但寫入到這個(gè)區(qū)域使你的程序產(chǎn)生一個(gè)段錯(cuò)誤。這有助于防止指針錯(cuò)誤,盡管沒有首先避免C那么有效。這是一個(gè)圖表顯示這些部分和我們的例子變量:

您可以通過讀取/proc/pid_of_process/maps文件來檢查Linux進(jìn)程中的內(nèi)存區(qū)域。請(qǐng)記住,一個(gè)片段可能包含許多區(qū)域。例如,每個(gè)內(nèi)存映射文件通常在mmap段中有自己的區(qū)域,而動(dòng)態(tài)庫有類似于BSS和數(shù)據(jù)的額外區(qū)域。下一篇文章將闡明“區(qū)域”的真正含義。另外,有時(shí)人們說“數(shù)據(jù)段”,意思是所有的數(shù)據(jù)+ bss +堆。
您可以使用nm和objdump命令檢查二進(jìn)制圖像,以顯示符號(hào)、它們的地址、段等。最后,上面描述的虛擬地址布局是Linux中的“靈活”布局,這是幾年來的默認(rèn)布局。它假設(shè)我們有一個(gè)RLIMIT_STACK的值。如果不是這樣,Linux就會(huì)恢復(fù)到如下所示的“經(jīng)典”布局:

這就是虛擬地址空間布局。下一篇文章將討論內(nèi)核如何跟蹤這些內(nèi)存區(qū)域。接下來,我們將研究內(nèi)存映射,文件讀寫如何與所有這些聯(lián)系起來,以及內(nèi)存使用數(shù)據(jù)意味著什么。