天天看點

《轉》linux記憶體管理

記憶體管理子產品是作業系統的心髒;它對應用程式和系統管理非常重要。今後的幾篇文章中,我将着眼于實際的記憶體問題,但也不避諱其中的技術内幕。由于不少概念是通用的,是以文中大部分例子取自32位x86平台的Linux和Windows系統。本系列第一篇文章講述應用程式的記憶體布局。

    在多任務作業系統中的每一個程序都運作在一個屬于它自己的記憶體沙盤中。這個沙盤就是虛拟位址空間(virtual address space),在32位模式下它總是一個4GB的記憶體位址塊。這些虛拟位址通過頁表(page table)映射到實體記憶體,頁表由作業系統維護并被處理器引用。每一個程序擁有一套屬于它自己的頁表,但是還有一個隐情。隻要虛拟位址被使能,那麼它就會作用于這台機器上運作的所有軟體,包括核心本身。是以一部分虛拟位址必須保留給核心使用:

《轉》linux記憶體管理

這并不意味着核心使用了那麼多的實體記憶體,僅表示它可支配這麼大的位址空間,可根據核心需要,将其映射到實體記憶體。核心空間在頁表中擁有較高的特權級(ring 2或以下),是以隻要使用者态的程式試圖通路這些頁,就會導緻一個頁錯誤(page fault)。在Linux中,核心空間是持續存在的,并且在所有程序中都映射到同樣的實體記憶體。核心代碼和資料總是可尋址的,随時準備進行中斷和系統調用。與此相反,使用者模式位址空間的映射随程序切換的發生而不斷變化:

《轉》linux記憶體管理

色區域表示映射到實體記憶體的虛拟位址,而白色區域表示未映射的部分。在上面的例子中,Firefox使用了相當多的虛拟位址空間,因為它是傳說中的吃記憶體大戶。位址空間中的各個條帶對應于不同的記憶體段(memory segment),如:堆、棧之類的。記住,這些段隻是簡單的記憶體位址範圍,與Intel處理器的段沒有關系。

       不管怎樣,下面是一個Linux程序的标準的記憶體段布局:

《轉》linux記憶體管理

        當計算機開心、安全、可愛、正常的運轉時,幾乎每一個程序的各個段的起始虛拟位址都與上圖完全一緻,這也給遠端發掘程式安全漏洞打開了友善之門。一個發掘過程往往需要引用絕對記憶體位址:棧位址,庫函數位址等。遠端攻擊者必須依賴位址空間布局的一緻性,摸索着選擇這些位址。如果讓他們猜個正着,有人就會被整了。是以,位址空間的随機排布方式逐漸流行起來。Linux 通過對棧記憶體映射段、堆的起始位址加上随機的偏移量來打亂布局。不幸的是,32 位位址空間相當緊湊,給随機化所留下的空當不大,削弱了這種技巧的效果。

    程序位址空間中最頂部的段是棧,大多數程式設計語言将之用于存儲局部變量和函數參數。調用一個局部變量或函數會将一個新的棧桢(stack frame)壓入棧中。棧桢在函數傳回時被清理。也許是因為資料嚴格的遵從LIFO的順序,這個簡單的設計意味着不必使用複雜的資料結構來追蹤棧的内容,隻需要一個簡單的指針指向棧的頂端即可。是以壓棧(pushing)和退棧(popping)過程非常迅速、準确。另外,持續的重用棧空間有助于使活躍的棧記憶體保持在CPU緩存中,進而加速通路。程序中的每一個線程都有屬于自己的棧。

      通過不斷向棧中壓入的資料,超出其容量就有會耗盡棧所對應的記憶體區域。這将觸發一個頁故障(page fault),并被 Linux 的expand_stack()處理,它會調用acct_stack_growth()來檢查是否還有合适的地方用于棧的增長。如果棧的大小低于RLIMIT_STACK(通常是8MB),那麼一般情況下棧會被加長,程式繼續愉快的運作,感覺不到發生了什麼事情。這是一種将棧擴充至所需大小的正常機制。然而,如果達到了最大的棧空間大小,就會棧溢出(stack overflow),程式收到一個段錯誤(Segmentation Fault)。當映射了的棧區域擴充到所需的大小後,它就不會再收縮回去,即使棧不那麼滿了。這就好比聯邦預算,它總是在增長的。

     動态棧增長是唯一一種通路未映射記憶體區域(圖中白色區域)而被允許的情形。其它任何對未映射記憶體區域的通路都會觸發頁故障,進而導緻段錯誤。一些被映射的區域是隻讀的,是以企圖寫這些區域也會導緻段錯誤。

     在棧的下方,是我們的記憶體映射段。此處,核心将檔案的内容直接映射到記憶體。任何應用程式都可以通過 Linux 的mmap()系統調用(實作)或 Windows 的CreateFileMapping()/MapViewOfFile()請求這種映射。記憶體映射是一種友善高效的檔案 I/O 方式,是以它被用于加載動态庫。建立一個不對應于任何檔案的匿名記憶體映射也是可能的,此方法用于存放程式的資料。在 Linux 中,如果你通過 malloc()請求一大塊記憶體,C 運作庫将會建立這樣一個匿名映射而不是使用堆記憶體。‘大塊’意味着比MMAP_THRESHOLD 還大,預設是 128KB ,可以通過mallopt()調整。

     說到堆,它是接下來的一塊位址空間。與棧一樣,堆用于運作時記憶體配置設定;但不同點是,堆用于存儲那些生存期與函數調用無關的資料。大部分語言都提供了堆管理功能。是以,滿足記憶體請求就成了語言運作時庫及核心共同的任務。在 C 語言中,堆配置設定的接口是malloc()系列函數,而在具有垃圾收集功能的語言(如 C# )中,此接口是 new 關鍵字。

     如果堆中有足夠的空間來滿足記憶體請求,它就可以被語言運作時庫處理而不需要核心參與。否則,堆會被擴大,通過brk()系統調用(實作)來配置設定請求所需的記憶體塊。堆管理是很複雜的,需要精細的算法,應付我們程式中雜亂的配置設定模式,優化速度和記憶體使用效率。處理一個堆請求所需的時間會大幅度的變動。實時系統通過特殊目的配置設定器來解決這個問題。堆也可能會變得零零碎碎,如下圖所示:

《轉》linux記憶體管理

    最後,我們來看看最底部的記憶體段:BSS,資料段,代碼段。在C語言中,BSS和資料段儲存的都是靜态(全局)變量的内容。差別在于BSS儲存的是未被初始化的靜态變量内容,它們的值不是直接在程式的源代碼中設定的。BSS記憶體區域是匿名的:它不映射到任何檔案。如果你寫static int cntActiveUsers,則cntActiveUsers的内容就會儲存在BSS中。

    另一方面,資料段儲存在源代碼中已經初始化了的靜态變量内容。這個記憶體區域不是匿名的。它映射了一部分的程式二進制鏡像,也就是源代碼中指定了初始值的靜态變量。是以,如果你寫static int cntWorkerBees = 10,則cntWorkerBees的内容就儲存在資料段中了,而且初始值為10。盡管資料段映射了一個檔案,但它是一個私有記憶體映射,這意味着更改此處的記憶體不會影響到被映射的檔案。也必須如此,否則給全局變量指派将會改動你硬碟上的二進制鏡像,這是不可想象的。

    下圖中資料段的例子更加複雜,因為它用了一個指針。在此情況下,指針gonzo(4位元組記憶體位址)本身的值儲存在資料段中。而它所指向的實際字元串則不在這裡。這個字元串儲存在代碼段中,代碼段是隻讀的,儲存了你全部的代碼外加零零碎碎的東西,比如字元串字面值。代碼段将你的二進制檔案也映射到了記憶體中,但對此區域的寫操作都會使你的程式收到段錯誤。這有助于防範指針錯誤,雖然不像在C語言程式設計時就注意防範來得那麼有效。下圖展示了這些段以及我們例子中的變量:

《轉》linux記憶體管理

你可以通過閱讀檔案/proc/pid_of_process/maps來檢驗一個Linux程序中的記憶體區域。記住一個段可能包含許多區域。比如,每個記憶體映射檔案在mmap段中都有屬于自己的區域,動态庫擁有類似BSS和資料段的額外區域。下一篇文章講說明這些“區域”(area)的真正含義。有時人們提到“資料段”,指的就是全部的資料段 + BSS + 堆。

你可以通過nm和objdump指令來察看二進制鏡像,列印其中的符号,它們的位址,段等資訊。最後需要指出的是,前文描述的虛拟位址布局在Linux 中是一種“靈活布局”(flexible layout),而且以此作為預設方式已經有些年頭了。它假設我們有值 RLIMIT_STACK。

     當情況不是這樣時, Linux 退回使用“經典布局”(classic layout),如下圖所示:

《轉》linux記憶體管理

對虛拟位址空間的布局就講這些吧。下一篇文章将讨論核心是如何跟蹤這些記憶體區域的。我們會分析記憶體映射,看看檔案的讀寫操作是如何與之關聯的,以及記憶體使用概況的含義。

預設情況下,棧和 mmap 映射區域并不是從一個固定位址開始,并且每次的值都不一樣,這是程式在啟動時随機改變這些值的設定,使得使用緩沖區溢出進行攻擊更加困難。在以前較老的核心中,這個位址是固定的,是以比較容易利用這點來進行緩沖區溢出攻擊。但是也可以改變這個預設的特性(位址不固定),方法是将 /proc/sys/kernel/randomize_va_space 的值改為 0,它預設為 1。 修改的方法是使用 sysctl 指令:
sysctl -w kernel/randomize_va_space=0

如果希望該值在系統啟動時也為 0,那麼需要将  randomize_va_space=0 這個值寫往 /etc/sysctl.conf 檔案中。從安全角度考慮,一般是不建議這麼做的,除非你運作在某些不同架構的 CPU 上,randomize_va_space=1 可能會造成系統系能低下。

繼續閱讀