程序在內存中運行的奧秘
內存管理是操作系統的核心功能,無論對于開發者還是系統管理員內存管理的重要性都是不言而喻的。我會在接下來的幾篇文章通過計算機的實際運行過程談談內存管理,當然在必要的時候我也會從底層原理去闡釋這個問題。我們提到的概念是不局限于平臺特性的通用概念,不過為了闡述這些概念我們選取的實例大多來源于Linux和基于x86架構的32位Windows操作系統。這篇文章,我們首先來看看程序是如何使用內存的。
多任務操作系統中,每一個進程都有它自己的內存“沙盒”。所謂“沙盒”,是指虛擬地址空間,在32位模式下,虛擬地址空間最多能表示4GB容量。通過頁表機制,虛擬地址空間能夠映射到物理內存。頁表由操作系統內核來管理,并可被處理器訪問。每個進程有著屬于自己的頁表,不過進程也不能隨心所欲。因為虛擬地址一旦投入使用,所有在計算機中運行的軟件都會占用虛擬地址空間,包括操作系統內核自身。也就是說,操作系統內核將保留一部分虛擬地址空間。
這并不意味著系統內核能夠肆無忌憚的使用物理內存,系統內核只能使用其管轄的虛擬地址空間所對應的物理內存。系統內核所使用的內存空間通過特權碼(privileged code,2級或者更低)來標記,以防止用戶模式的程序訪問到內核空間而發生頁面錯誤。在Linux中,內核始終占用著一定空間,并且每個內核進程映射的物理內存地址是固定的。因此,內核代碼與數據在內存中的地址總是能夠被準確定位,從而為時刻處理中斷以及系統調用做好了準備。與此相反,只要用戶進程狀態發生變化,其映射的地址空間也隨即改變。
圖中藍色區域表示虛擬地址中映射到物理內存的部分,白色區域則是未映射。在這個例子中,Firefox驚人的內存需求讓它使用的虛擬地址遠遠超過了其自身的地址空間。內存地址空間是由諸如堆、棧等段式內存管理方式進行管理的。需要指出的是,這里段的概念只不過是表示了一段內存地址,它和Intel段表機制(Intel-style segments)沒有任何關系。總的來說,我們在這里討論的是Linux系統進程標準的段式內存管理方法。
如果運行過程輕松愉快、準確無誤,那么上圖顯示的段式虛擬地址管理啟用過程對于計算機內幾乎所有進程都完全一致。而這種機制為遠程攻擊帶來了安全隱患。遠程攻擊往往需要參考絕對內存地址:諸如棧地址、庫函數地址等等。而遠程攻擊者們知道了這些地址空間是固定的,他們閉著眼睛都能找到他們需要的位置。倘若真的如此,那么人們毫無疑問就會被黑客攻擊了。正因為如此,隨即地址空間已經成為流行的內存地址管理方式。Linux隨機為棧(stack)、內存映射段(memorymapping segment)以及堆(heap )的起始地址添加偏移量。不幸的是,32位地址空間非常吃緊,限制了隨機分配地址的范圍和效率(hamperingits effectiveness)。
進程地址空間的首段地址便是棧,它儲存了局部變量以及大多數編程語言的函數參數。當調用方法或者函數時,會有一個新的元素進棧。一旦函數返回了值,那么該元素就會被銷毀。這種簡單的設計,很有可能是考慮到數據操作都符合后進先出(LIFO )規則,這意味著訪問棧的內容并不需要復雜的數據結構,一個簡單的棧頂指針就能搞定一切。進棧和出棧的操作方便快捷,不需要過多判斷。另外,棧的反復使用能夠使棧主流在CPU緩存(cpu caches)中,從而加快數據存取。每個進程中的每個線程都有屬于自己的棧。
如果映射的棧地址空間被壓入了超過棧容量的數據,那么棧便無法繼續工作了。這種情況會導致一個由expand_stack(),函數處理的頁面錯誤,這個函數會調用acct_stack_growth() 函數去檢查是否應該為這個棧增加容量。如果這個棧的容量低于RLIMIT_STACK (通常為 8MB)限定的值,那么棧的容量會正常增加,程序也會繼續正常運行,并且程序不會知道剛剛發生了什么。當然,這是根據實際需要來調整棧大小的一般機制,如果棧的容量達到了最大值上限,那么棧就會溢出,程序也會收到一個段出錯的信息。雖然在程序需要的時候映射的棧空間會增加,但是棧使用的空間減少時,棧卻不會釋放多于的空間。這就好像聯邦政府預算,只可能越來越多。
程序存取上圖所示的未映射區域,是唯一正常實現動態增加棧空間的情況,程序訪問其他未映射內存訪問將會出現頁面錯誤最終導致段錯誤。有些映射區域是只讀的,程序試圖寫入這些區域同樣會導致這種錯誤。
說到堆,我們就不得不提它的內存使用機制。堆支持運行時內存分配,和棧不同,大多數語言都允許程序使用堆管理內存。滿足內存需求是語言運行時和C語言核心間的聯結點,而堆的內存管理接口是通過malloc()及其友元函數來實現的,在C#這樣支持垃圾回收機制的語言中,其接口是新定義的關鍵字。
當堆的空間能夠滿足程序的內存請求時,那么請求的處理過程就可以直接由語言運行時來負責,而不必有系統內核參與。但是如果堆的空間不能滿足程序的內存申請,那么brk()函數會執行系統調用(implementation)來增加堆得內存空間以滿足程序的請求。堆管理的實現過程十分復雜,,面對程序內存分配變化莫測的情況,堆管理需要成熟的算法去提升請求的響應速度與內存利用率。系統響應堆的內存請求花費的時間往往變化很大。實時操作系統解決這個問題的方法是采用專用內存分配器( special-purposeal locators)。堆在內存中的分布情況和其他內存管理管理機制一樣充滿了碎片,如下圖所示:
最后,我們來聊聊剛才圖中位置最下方的幾個內存段:BSS段、數據段和程序段。在C語言中,BSS段和數據段存儲的都是靜態(全局)變量。這幾個段的不同之處在于BSS段存儲的靜態變量沒有初始化——程序員在源代碼中并沒有為這些靜態變量賦值。由于BSS段并沒有映射任何文件,所以BSS段在內存中是以匿名形式存在的。舉個例子,假設你定義了變量static int cntActiveUsers,那么cntActiveUsers 的數據就保存在BSS段中。
與BSS段不同的是,數據段儲存了在源代碼中經過了初始化的靜態變量。因此,數據段的內存區域并不是匿名的。數據段映射了程序二進制映像中源代碼給出靜態變量初值的部分。所以,如果你定義了static int cntWorkerBees = 10,那么cntWorkerBees變量會賦以初值10并在數據段中保存下來。盡管數據段映射了文件,但這種內存映射是私有的,也就是說,數據段的內存更新不會在其映射的文件中生效。這樣造成的結果就是,雖然全局變量的改變應用到了文件在內存中的二進制映像,但是文件本身卻不能作出相應的變化!
下面圖表中的示例由于使用了指針所以看起來不那么明了。在這個示例中,指針在數據段中占用了4個字節,但是指針所指向的字符串則不在數據段中。對于字符串,內存為它們準備了專門的文本段,文本段以只讀的形式存儲程序中諸如字符串類型等不會被直接執行的代碼。文本段同樣會將二進制文件映射到內存,但文件映射區域的寫入操作只能以程序收到段錯誤而告終。這種機制能有效防止指針的錯誤指向導致的誤操作,不過也不得不承認這種做法顯然沒有直接在C語言代碼中進行保護來的效率高。下面的圖表顯示了剛剛我們討論到的段以及變量示例:
如果你想了解Linux中的進程是如何使用內存的,你可以讀讀源代碼文件/proc/pid_of_process/maps。值得一提的是,一個內存段往往由多個區域組成。例如,每個正確映射到內存的文件都有屬于自己的段,動態庫文件則擁有另外的段,這些段類似于BSS段與數據段。下一篇文章我們將進一步探討“區域”的含義。另外,也會談談我個人對“數據段就是數據、BSS以及堆的總和”這種觀點的看法。
使用nm和objdump命令能夠顯示二進制映像的標識,映像的地址、段等等信息都可以查閱。最后要指出的是,上文討論的Linux虛擬地址管理機制是“靈活”的,該機制在Linux中作為首選已經沿用了幾年。使用這種機制要求程序為RLIMIT_STACK變量賦值,如果沒有,那么Linux則退回到“傳統”方式管理內存,如下圖所示:
該圖呈現了虛擬地址空間的管理方式。下一篇文章我們將討論系統內核是如何跟蹤這些內存區域的。進而我們會看看內存映射原理、與之相關的文件讀寫機制以及內存使用情況圖表所揭示的含義。