注:本分類下文章大多整理自《深入分析linux內核源代碼》一書,另有參考其他一些資料如《linux內核完全剖析》、《linux c 編程一站式學習》等,只是為了更好地理清系統編程和網絡編程中的一些概念性問題,並沒有深入地閱讀分析源碼,我也是草草翻過這本書,請有興趣的朋友自己參考相關資料。此書出版較早,分析的版本為2.4.16,故出現的一些概念可能跟最新版本內核不同。
此書已經開源,閱讀地址 http://www.kerneltravel.net
一、內存管理概述
(一)、虛擬內存實現結構


(1)內存映射模塊(mmap):負責把磁盤文件的邏輯地址映射到虛擬地址,以及把虛擬地址映射到物理地址。
(2)交換模塊(swap):負責控制內存內容的換入和換出,它通過交換機制,使得在物理內存的頁面(RAM 頁)中保留有效的頁 ,即從主存中淘汰最近沒被訪問的頁,保存近來訪問過的頁。
(3)核心內存管理模塊(core):負責核心內存管理功能,即對頁的分配、回收、釋放及請頁處理等,這些功能將被別的內核子系統(如文件系統)使用。
(4)結構特定的模塊:負責給各種硬件平台提供通用接口,這個模塊通過執行命令來改變硬件MMU 的虛擬地址映射,並在發生頁錯誤時,提供了公用的方法來通知別的內核子系統。這個模塊是實現虛擬內存的物理基礎。
(二)、內核空間和用戶空間
Linux 簡化了分段機制,使得虛擬地址與線性地址總是一致,因此,Linux 的虛擬地址空間也為0~4G 字節。Linux 內核將這4G 字節的空間分為兩部分。將最高的1G 字節(從虛擬地址0xC0000000 到0xFFFFFFFF),供內核使用,稱為“內核空間”。而將較低的3G 字節(從虛擬地址0x00000000 到0xBFFFFFFF),供各個進程使用,稱為“用戶空間”。因為每個進程可以通過系統調用進入內核,因此,Linux 內核由系統內的所有進程共享。於是,從具體進程的角度來看,每個進程可以擁有4G 字節的虛擬空間。圖 6.3 給出了進程虛擬空間示意圖。


Linux 使用兩級保護機制:0 級供內核使用,3 級供用戶程序使用。從圖6.3 中可以看出,每個進程有各自的私有用戶空間(0~3G),這個空間對系統中的其他進程是不可見的。最高的1G 字節虛擬內核空間則為所有進程以及內核所共享。
(三)、虛擬內存實現機制間的關系


首先內存管理程序通過映射機制把用戶程序的邏輯地址映射到物理地址,在用戶程序運行時如果發現程序中要用的虛地址沒有對應的物理內存時,就發出了請頁要求①;如果有空閑的內存可供分配,就請求分配內存②(於是用到了內存的分配和回收),並把正在使用的物理頁記錄在頁緩存中③(使用了緩存機制)。如果沒有足夠的內存可供分配,那么就調用交換機制,騰出一部分內存④⑤。另外在地址映射中要通過TLB(翻譯后援存儲器)來尋找物理頁⑧;交換機制中也要用到交換緩存⑥,並且把物理頁內容交換到交換文件中后也要修改頁表來映射文件地址⑦。
二、內存分配與釋放
在Linux 中,CPU 不能按物理地址來訪問存儲空間,而必須使用虛擬地址;因此,對於內存頁面的管理,通常是先在虛存空間中分配一個虛存區間,然后才根據需要為此區間分配相應的物理頁面並建立起映射,也就是說,虛存區間的分配在前,而物理頁面的分配在后。
(一)、伙伴算法(Buddy)
Linux 的伙伴算法把所有的空閑頁面分為10 個塊組,每組中塊的大小是2 的冪次方個頁面,例如,第0 組中塊的大小都為2^0(1 個頁面),第1 組中塊的大小都為2^1(2 個頁面),第9 組中塊的大小都為2^9(512 個頁面)。也就是說,每一組中塊的大小是相同的,且這同樣大小的塊形成一個鏈表。
我們通過一個簡單的例子來說明該算法的工作原理。
假設要求分配的塊的大小為128 個頁面(由多個頁面組成的塊我們就叫做頁面塊)。該算法先在塊大小為128 個頁面的鏈表中查找,看是否有這樣一個空閑塊。如果有,就直接分配;如果沒有,該算法會查找下一個更大的塊,具體地說,就是在塊大小256 個頁面的鏈表中查找一個空閑塊。如果存在這樣的空閑塊,內核就把這256 個頁面分為兩等份,一份分配出去,另一份插入到塊大小為128 個頁面的鏈表中。如果在塊大小為256 個頁面的鏈表中也沒有找到空閑頁塊,就繼續找更大的塊,即512 個頁面的塊。如果存在這樣的塊,內核就從512 個頁面的塊中分出128 個頁面滿足請求,然后從384 個頁面中取出256 個頁面插入到塊大小為256 個頁面的鏈表中。然后把剩余的128 個頁面插入到塊大小為128 個頁面的鏈表中。如果512 個頁面的鏈表中還沒有空閑塊,該算法就放棄分配,並發出出錯信號。
以上過程的逆過程就是塊的釋放過程,這也是該算法名字的來由。滿足以下條件的兩個塊稱為伙伴:
(1)兩個塊的大小相同;
(2)兩個塊的物理地址連續。
伙伴算法把滿足以上條件的兩個塊合並為一個塊,該算法是迭代算法,如果合並后的塊還可以跟相鄰的塊進行合並,那么該算法就繼續合並。
(二)、Slab 分配機制


可以根據對內存區的使用頻率來對它分類。對於預期頻繁使用的內存區,可以創建一組特定大小的專用緩沖區進行處理,以避免內碎片的產生。對於較少使用的內存區,可以創建一組通用緩沖區(如Linux 2.0 中所使用的2 的冪次方)來處理,即使這種處理模式產生碎
片,也對整個系統的性能影響不大。
硬件高速緩存的使用,又為盡量減少對伙伴算法的調用提供了另一個理由,因為對伙伴算法的每次調用都會“弄臟”硬件高速緩存,因此,這就增加了對內存的平均訪問次數。
Slab 分配模式把對象分組放進緩沖區(盡管英文中使用了Cache 這個詞,但實際上指的是內存中的區域,而不是指硬件高速緩存)。因為緩沖區的組織和管理與硬件高速緩存的命中率密切相關,因此,Slab 緩沖區並非由各個對象直接構成,而是由一連串的“大塊(Slab)”構成,而每個大塊中則包含了若干個同種類型的對象,這些對象或已被分配,或空閑,如圖6.10 所示。一般而言,對象分兩種,一種是大對象,一種是小對象。所謂小對象,是指在一個頁面中可以容納下好幾個對象的那種。例如,一個inode 結構大約占300 多個字節,因此,一個頁面中可以容納8 個以上的inode 結構,因此,inode 結構就為小對象。Linux 內核中把小於512 字節的對象叫做小對象。
實際上,緩沖區就是主存中的一片區域,把這片區域划分為多個塊,每塊就是一個Slab,每個Slab 由一個或多個頁面組成,每個Slab 中存放的就是對象。
三、地址映射機制
在進程的task_struct 結構中包含一個指向 mm_struct 結構的指針,mm_strcut 用來描述一個進程的虛擬地址空間。進程的 mm_struct 則包含裝入的可執行映像信息以及進程的頁目錄指針pgd。該結構還包含有指向 vm_area_struct 結構的幾個指針,每個 vm_area_struct 代表進程的一個虛擬地址區間。vm_area_struct 結構含有指向vm_operations_struct 結構的一個指針,vm_operations_struct 描述了在這個區間的操作。vm_operations 結構中包含的是函數指針;其中,open、close 分別用於虛擬區間的打開、關閉,而nopage 用於當虛存頁面不在物理內存而引起的“缺頁異常”時所應該調用的函數,當 Linux 處理這一缺頁異常時(請頁機制),就可以為新的虛擬內存區分配實際的物理內存。圖6.15 給出了虛擬區間的操作集。
C++ Code
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
struct mm_struct
{ struct vm_area_struct *mmap; /* list of VMAs */ struct rb_root mm_rb; struct vm_area_struct *mmap_cache; /* last find_vma result */ ... unsigned long start_code, end_code, start_data, end_data; unsigned long start_brk, brk, start_stack; ... }; struct vm_area_struct { struct mm_struct *vm_mm; /* The address space we belong to. */ unsigned long vm_start; /* Our start address within vm_mm. */ unsigned long vm_end; /* The first byte after our end address within vm_mm. */ .... /* linked list of VM areas per task, sorted by address */ struct vm_area_struct *vm_next; .... /* describe the permissable operation */ unsigned long vm_flags; /* operations on this area */ struct vm_operations_struct * vm_ops; struct file * vm_file; /* File we map to (can be NULL). */ } ; /* * These are the virtual MM functions - opening of an area, closing and * unmapping it (needed to keep files on disk up-to-date etc), pointer * to the functions called when a no-page or a wp-page exception occurs. */ struct vm_operations_struct { void (*open)(struct vm_area_struct *area); void (*close)(struct vm_area_struct *area); struct page * (*nopage)(struct vm_area_struct * area, unsigned long address, int unused); }; |






四、malloc 和 free 的實現
C++ Code
1
2 3 4 5 6 7 8 9 10 |
Normally, malloc() allocates memory from the heap, and adjusts the size of the heap as required, using sbrk(2). When
allocating blocks of memory larger than MMAP_THRESHOLD bytes, the glibc malloc() implementation allocates the memory as a private anonymous mapping using mmap(2). MMAP_THRESHOLD is 128 kB by default, but is adjustable using mallopt(3). Allo‐ cations performed using mmap(2) are unaffected by the RLIMIT_DATA resource limit (see getrlimit(2)). MAP_ANONYMOUS The mapping is not backed by any file; its contents are initialized to zero. The fd and offset arguments are ignored; however, some implementations require fd to be - 1 if MAP_ANONYMOUS ( or MAP_ANON) is specified, and portable applications should ensure this. The use of MAP_ANONYMOUS in conjunction with MAP_SHARED is only supported on Linux since kernel 2.4. |
(一)、使用brk()/ sbrk() 實現


圖中白色背景的框表示 malloc管理的空閑內存塊,深色背景的框不歸 malloc管,可能是已經分配給用戶的內存塊,也可能不屬於當前進程, Break之上的地址不屬於當前進程,需要通過 brk系統調用向內核申請。每個內存塊開頭都有一個頭節點,里面有一個指針字段和一個長度字段,指針字段把所有空閑塊的頭節點串在一起,組成一個環形鏈表,長度字段記錄着頭節點和后面的內存塊加起來一共有多長,以 8字節為單位(也就是以頭節點的長度為單位)。
1. 一開始堆空間由一個空閑塊組成,長度為 7×8=56字節,除頭節點之外的長度為 48字節。
2. 調用 malloc分配 8個字節,要在這個空閑塊的末尾截出 16個字節,其中新的頭節點占了 8個字節,另外 8個字節返回給用戶使用,注意返回的指針 p1指向頭節點后面的內存塊。
3. 又調用 malloc分配 16個字節,又在空閑塊的末尾截出 24個字節,步驟和上一步類似。
4. 調用 free釋放 p1所指向的內存塊,內存塊(包括頭節點在內)歸還給了 malloc,現在 malloc管理着兩塊不連續的內存,用環形鏈表串起來。注意這時 p1成了野指針,指向不屬於用戶的內存, p1所指向的內存地址在 Break之下,是屬於當前進程的,所以訪問 p1時不會出現段錯誤,但在訪問 p1時這段內存可能已經被 malloc再次分配出去了,可能會讀到意外改寫數據。另外注意,此時如果通過 p2向右寫越界,有可能覆蓋右邊的頭節點,從而破壞 malloc管理的環形鏈表, malloc就無法從一個空閑塊的指針字段找到下一個空閑塊了,找到哪去都不一定,全亂套了。
5. 調用 malloc分配 16個字節,現在雖然有兩個空閑塊,各有 8個字節可分配,但是這兩塊不連續, malloc只好通過 brk系統調用抬高 Break,獲得新的內存空間。在 [K&R]的實現中,每次調用 sbrk函數時申請 1024×8=8192個字節,在 Linux系統上 sbrk函數也是通過 brk實現的,這里為了畫圖方便,我們假設每次調用 sbrk申請 32個字節,建立一個新的空閑塊。
6. 新申請的空閑塊和前一個空閑塊連續,因此可以合並成一個。在能合並時要盡量合並,以免空閑塊越割越小,無法滿足大的分配請求。
7. 在合並后的這個空閑塊末尾截出 24個字節,新的頭節點占 8個字節,另外 16個字節返回給用戶。
8. 調用 free(p3)釋放這個內存塊,由於它和前一個空閑塊連續,又重新合並成一個空閑塊。注意, Break只能抬高而不能降低,從內核申請到的內存以后都歸 malloc管了,即使調用 free也不會還給內核。
(二)、使用mmap() / munmap() 實現
在Linux下面,kernel 使用4096 byte來划分頁面,而malloc的顆粒度更細,使用8 byte對齊,因此,分配出來的內存不一定是頁對齊的。而mmap 分配出來的內存地址是頁對齊的,所以munmap處理的內存地址必須頁對齊(Page Aligned)。此外,我們可以使用memalign或是posix_memalign來獲取一塊頁對齊的內存。
可以參考《
linux的內存管理模型(上)》這篇文章。
在weibo上看到梁大的這個貼子:
實際上這是一個內存方面的問題。要想研究這個問題,首先我們要將題目本身搞明白。由於我對Linux內核比較熟而對Windows的內存模型幾乎毫不了解,因此在這篇文章中針對Linux環境對這個問題進行探討。
在Linux的世界中,從大的方面來講,有兩塊內存,一塊叫做內核空間,Kernel Space,另一塊叫做用戶空間,即User Space。它們是相互獨立的,Kernel對它們的管理方式也完全不同。
首先我們要知道,現代操作系統一個重要的任務之一就是管理內存。所謂內存,就是內存條上一個一個的真正的存儲單元,實實在在的電子顆粒,這里面通過電信號保存着數據。
Linux Kernel為了使用和管理這些內存,必須要給它們分成一個一個的小塊,然后給這些小塊標號。這一個一個的小塊就叫做Page,標號就是內存地址,Address。
Linux內核會負責管理這些內存,保證程序可以有效地使用這些內存。它必須要能夠管理好內核本身要用的內存,同時也要管理好在Linux操作系統上面跑的各種程序使用的內存。因此,Linux將內存划分為Kernel Space和User Space,對它們分別進行管理。
只有驅動模塊和內核本身運行在Kernel Space當中,因此對於這道題目,我們主要進行考慮的是User Space這一塊。
在Linux的世界中,Kernel負責給用戶層的程序提供虛地址而不是物理地址。舉個例子:A手里有20張牌,將它們命名為1-20。這20張牌要分給兩個人,每個人手里10張。這樣,第一個人拿到10張牌,將牌編號為1-10,對應A手里面的1-10;第二個人拿到10張牌,也給編號為1-10,對應A的11-20。
這里面,第二個人手里的牌,他自己用的時候編號是1-10,但A知道,第二個人手里的牌在他這里的編號是11-20。
在這里面,A的角色就是Linux內核;他手里的編號,1-20,就是物理地址;兩個人相當於兩個進程,它們對牌的編號就是虛地址;A要負責給兩個人發牌,這就是內存管理。
了解了這些概念以后,我們來看看kernel當中具體的東西,首先是mm_struct這個結構體:
mm_struct負責描述進程的內存。相當於發牌人記錄給誰發了哪些牌,發了多少張,等等。那么,內存是如何將內存進行划分的呢?也就是說,發牌人手里假設是一大張未裁剪的撲克紙,他是怎樣將其剪成一張一張的撲克牌呢?上面的vm_area_struct就是基本的划分單位,即一張一張的撲克牌:
這個結構體的定義如下:
這樣,內核就可以記錄分配給用戶空間的內存了。
Okay,了解了內核管理進程內存的兩個最重要的結構體,我們來看看用戶空間的內存模型。
Linux操作系統在加載程序時,將程序所使用的內存分為5段:text(程序段)、data(數據段)、bss(bss數據段)、heap(堆)、stack(棧)。
text segment(程序段)
text segment用於存放程序指令本身,Linux在執行程序時,要把這個程序的代碼加載進內存,放入text segment。程序段內存位於整個程序所占內存的最上方,並且長度固定(因為代碼需要多少內存給放進去,操作系統是清楚的)。
data segment(數據段)
data segment用於存放已經在代碼中賦值的全局變量和靜態變量。因為這類變量的數據類型(需要的內存大小)和其數值都已在代碼中確定,因此,data segment緊挨着text segment,並且長度固定(這塊需要多少內存也已經事先知道了)。
bss segment(bss數據段)
bss segment用於存放未賦值的全局變量和靜態變量。這塊挨着data segment,長度固定。
heap(堆)
這塊內存用於存放程序所需的動態內存空間,比如使用malloc函數請求內存空間,就是從heap里面取。這塊內存挨着bss,長度不確定。
stack(棧)
stack用於存放局部變量,當程序調用某個函數(包括main函數)時,這個函數內部的一些變量的數值入棧,函數調用完成返回后,局部變量的數值就沒有用了,因此出棧,把內存讓出來給另一個函數的變量使用(程序在執行時,總是會在某一個函數調用里面)。
我們看一個圖例說明:
為了更好的理解內存分段,可以撰寫一段代碼:
編譯這個代碼,看看執行結果:
理解了進程的內存空間使用,我們現在可以想想,這幾塊內存當中,最靈活的是哪一塊?沒錯,是Heap。其它幾塊都由C編譯器編譯代碼時預處理,相對固定,而heap內存可以由malloc和free進行動態的分配和銷毀。
有關malloc和free的使用方法,在本文中我就不再多說,這些屬於基本知識。我們在這篇文章中要關心的是,malloc是如何工作的?實際上,它會去調用mmap(),而mmap()則會調用內核,獲取VMA,即前文中看到的vm_area。這一塊工作由c庫向kernel發起請求,而由kernel完成這個請求,在kernel當中,有vm_operations_struct進行實際的內存操作:
可以看到,kernel可以對VMA進行open和close,即收發牌的工作。理解了malloc的工作原理,free也不難了,它向下調用munmap()。
下面是mmap和munmap的函數定義:
這里面,addr是希望能夠分配到的虛地址,比如:我希望得到一張牌,做為我手里編號為2的那張。需要注意的是,mmap最后分配出來的內存地址不一定是你想要的,可能你請求一張編號為2的撲克,但發牌人控制這個編號過程,他會給你一張在你手里編號為3的撲克。
prot代表對進程對這塊內存的權限:
flags代表用於控制很多的內存屬性,我們一會兒會用到,這里不展開。
fd是文件描述符。我們這里必須明白一個基本原理,任何硬盤上面的數據,都要讀取到內存當中,才能被程序使用,因此,mmap的目的就是將文件數據映射進內存。因此,要在這里填寫文件描述符。如果你在這里寫-1,則不映射任何文件數據,只是在內存里面要上這一塊空間,這就是malloc對mmap的使用方法。
offset是文件的偏移量,比如:從第二行開始映射。文件映射,不是這篇文章關心的內容,不展開。
okay,了解了mmap的用法,下面看看munmap:
munmap很簡單,告訴它要還回去的內存地址(即哪張牌),然后告訴它還回去的數量(多少張),其實更准確的說:尺寸。
現在讓我們回到題目上來,如何部分地回收一個數組中的內存?我們知道,使用malloc和free是無法完成的:
因為無論是malloc還是free,都需要我們整體提交待分配和銷毀的全部內存。於是自然而然想到,是否可以malloc分配內存后,然后使用munmap來部分地釋放呢?下面是一個嘗試:
運行這段代碼輸出如下:
注意到munmap調用返回-1,說明內存釋放未成功,這是由於munmap處理的內存地址必須頁對齊(Page Aligned)。在Linux下面,kernel使用4096 byte來划分頁面,而malloc的顆粒度更細,使用8 byte對齊,因此,分配出來的內存不一定是頁對齊的。為了解決這個問題,我們可以使用memalign或是posix_memalign來獲取一塊頁對齊的內存:
運行上述代碼得結果如下:
可以看到,頁對齊的內存資源可以被munmap正確處理(munmap返回值為0,說明執行成功)。仔細看一下被分配出來的地址:
轉換到10進制是:140602658275328
試試看是否能被4096整除:140602658275328 / 4096 = 34326820868
可以被整除,驗證了分配出來的地址是頁對齊的。
接下來,我們試用一下mmap,來分配一塊內存空間:
注意上面mmap的使用方法。其中,我們不指定虛地址,讓內核決定內存地址,也就是說,我們要是要一張牌,但不關心給牌編什么號。然后PROT_READ|PROT_WRITE表示這塊內存可讀寫,接下來注意flags里面有MAP_ANONYMOUS,表示這塊內存不用於映射文件。下面是完整代碼:
運行結果如下:
注意munmap返回值為0,說明內存釋放成功了。因此,驗證了mmap分配出來的內存是頁對齊的。
okay,了解了所有這些背景知識,我們現在應該對給內存打洞這個問題有一個思路了。我們可以創建以Page為基本單元的內存空間,然后用munmap在上面打洞。下面是實驗代碼:
我們申請了3*4096 byte的空間,也就是3頁的內存,然后通過munmap,在中間這頁上開個洞 。運行上面的代碼,結果如下:
看到munmap的返回為0,說明內存釋放成功,我們在arr數組上成功地開了一個洞。
這種方法,最大的局限在於,你操作的內存必須是page對齊的。如果想要更細顆粒度的打洞,純靠User Space的API調用是不行的,需要在Kernel Space直接操作進程的VMA結構體來實現。實現思路如下:
1. 通過kernel提供的page map映射,找到要釋放的內存虛地址所對應的物理地址。
2. 撰寫一個內核模塊,幫助你user space的程序來將實際的物理內存放回free list。
我在本文的下篇中,將詳細介紹Kernel Space和User Space的結合編碼,實現更細顆粒度的內存操作。
參考資料
Experiments with the Linux Kernel: Process Segments
How to find the physical address of a variable from user-space in Linux?
Simplest way to get physical address from the logical one in linux kernel module
Page Map
anon_mmap.c
Mmap
mmap()--Memory Map a File
C_dynamic_memory_allocation
What are the differences between "brk()" and "mmap()"?
How to guarantee alignment with malloc and or new?
Understanding Memory Pages and Page Alignment

實際上這是一個內存方面的問題。要想研究這個問題,首先我們要將題目本身搞明白。由於我對Linux內核比較熟而對Windows的內存模型幾乎毫不了解,因此在這篇文章中針對Linux環境對這個問題進行探討。
在Linux的世界中,從大的方面來講,有兩塊內存,一塊叫做內核空間,Kernel Space,另一塊叫做用戶空間,即User Space。它們是相互獨立的,Kernel對它們的管理方式也完全不同。
首先我們要知道,現代操作系統一個重要的任務之一就是管理內存。所謂內存,就是內存條上一個一個的真正的存儲單元,實實在在的電子顆粒,這里面通過電信號保存着數據。
Linux Kernel為了使用和管理這些內存,必須要給它們分成一個一個的小塊,然后給這些小塊標號。這一個一個的小塊就叫做Page,標號就是內存地址,Address。
Linux內核會負責管理這些內存,保證程序可以有效地使用這些內存。它必須要能夠管理好內核本身要用的內存,同時也要管理好在Linux操作系統上面跑的各種程序使用的內存。因此,Linux將內存划分為Kernel Space和User Space,對它們分別進行管理。
只有驅動模塊和內核本身運行在Kernel Space當中,因此對於這道題目,我們主要進行考慮的是User Space這一塊。
在Linux的世界中,Kernel負責給用戶層的程序提供虛地址而不是物理地址。舉個例子:A手里有20張牌,將它們命名為1-20。這20張牌要分給兩個人,每個人手里10張。這樣,第一個人拿到10張牌,將牌編號為1-10,對應A手里面的1-10;第二個人拿到10張牌,也給編號為1-10,對應A的11-20。
這里面,第二個人手里的牌,他自己用的時候編號是1-10,但A知道,第二個人手里的牌在他這里的編號是11-20。
在這里面,A的角色就是Linux內核;他手里的編號,1-20,就是物理地址;兩個人相當於兩個進程,它們對牌的編號就是虛地址;A要負責給兩個人發牌,這就是內存管理。
了解了這些概念以后,我們來看看kernel當中具體的東西,首先是mm_struct這個結構體:
- struct mm_struct {
- struct vm_area_struct * mmap; /* list of VMAs */
- struct rb_root mm_rb;
- struct vm_area_struct * mmap_cache; /* last find_vma result */
- ...
- unsigned long start_code, end_code, start_data, end_data;
- unsigned long start_brk, brk, start_stack;
- ...
- };
mm_struct負責描述進程的內存。相當於發牌人記錄給誰發了哪些牌,發了多少張,等等。那么,內存是如何將內存進行划分的呢?也就是說,發牌人手里假設是一大張未裁剪的撲克紙,他是怎樣將其剪成一張一張的撲克牌呢?上面的vm_area_struct就是基本的划分單位,即一張一張的撲克牌:
- struct vm_area_struct * mmap;
這個結構體的定義如下:
- struct vm_area_struct {
- struct mm_struct * vm_mm; /* The address space we belong to. */
- unsigned long vm_start; /* Our start address within vm_mm. */
- unsigned long vm_end; /* The first byte after our end address
- within vm_mm. */
- ....
- /* linked list of VM areas per task, sorted by address */
- struct vm_area_struct *vm_next;
- ....
- }
這樣,內核就可以記錄分配給用戶空間的內存了。
Okay,了解了內核管理進程內存的兩個最重要的結構體,我們來看看用戶空間的內存模型。
Linux操作系統在加載程序時,將程序所使用的內存分為5段:text(程序段)、data(數據段)、bss(bss數據段)、heap(堆)、stack(棧)。
text segment(程序段)
text segment用於存放程序指令本身,Linux在執行程序時,要把這個程序的代碼加載進內存,放入text segment。程序段內存位於整個程序所占內存的最上方,並且長度固定(因為代碼需要多少內存給放進去,操作系統是清楚的)。
data segment(數據段)
data segment用於存放已經在代碼中賦值的全局變量和靜態變量。因為這類變量的數據類型(需要的內存大小)和其數值都已在代碼中確定,因此,data segment緊挨着text segment,並且長度固定(這塊需要多少內存也已經事先知道了)。
bss segment(bss數據段)
bss segment用於存放未賦值的全局變量和靜態變量。這塊挨着data segment,長度固定。
heap(堆)
這塊內存用於存放程序所需的動態內存空間,比如使用malloc函數請求內存空間,就是從heap里面取。這塊內存挨着bss,長度不確定。
stack(棧)
stack用於存放局部變量,當程序調用某個函數(包括main函數)時,這個函數內部的一些變量的數值入棧,函數調用完成返回后,局部變量的數值就沒有用了,因此出棧,把內存讓出來給另一個函數的變量使用(程序在執行時,總是會在某一個函數調用里面)。
我們看一個圖例說明:

為了更好的理解內存分段,可以撰寫一段代碼:
- #include <stdio.h>
- // 未賦值的全局變量放在dss段
- int global_var;
- // 已賦值的全局變量放在data段
- int global_initialized_var = 5;
- void function() {
- int stack_var; // 函數中的變量放在stack中
- // 放在stack中的變量
- // 顯示其所在內存地值
- printf("the function's stack_var is at address 0x%08x\n", &stack_var);
- }
- int main() {
- int stack_var; // 函數中的變量放在stack中
- // 已賦值的靜態變量放在data段
- static int static_initialized_var = 5;
- // 未賦值的靜態變量放在dss段
- static int static_var;
- int *heap_var_ptr;
- // 由malloc在heap中分配所需內存,
- // heap_var_ptr這個指針指向這塊
- // 分配的內存
- heap_var_ptr = (int *) malloc(4);
- // 放在data段的變量
- // 顯示其所在內存地值
- printf("====IN DATA SEGMENT====\n");
- printf("global_initialized_var is at address 0x%08x\n", &global_initialized_var);
- printf("static_initialized_var is at address 0x%08x\n\n", &static_initialized_var);
- // 放在bss段的變量
- // 顯示其所在內存地值
- printf("====IN BSS SEGMENT====\n");
- printf("static_var is at address 0x%08x\n", &static_var);
- printf("global_var is at address 0x%08x\n\n", &global_var);
- // 放在heap中的變量
- // 顯示其所在內存地值
- printf("====IN HEAP====\n");
- printf("heap_var is at address 0x%08x\n\n", heap_var_ptr);
- // 放在stack中的變量
- // 顯示其所在內存地值
- printf("====IN STACK====\n");
- printf("the main's stack_var is at address 0x%08x\n", &stack_var);
- function();
- }
編譯這個代碼,看看執行結果:

理解了進程的內存空間使用,我們現在可以想想,這幾塊內存當中,最靈活的是哪一塊?沒錯,是Heap。其它幾塊都由C編譯器編譯代碼時預處理,相對固定,而heap內存可以由malloc和free進行動態的分配和銷毀。
有關malloc和free的使用方法,在本文中我就不再多說,這些屬於基本知識。我們在這篇文章中要關心的是,malloc是如何工作的?實際上,它會去調用mmap(),而mmap()則會調用內核,獲取VMA,即前文中看到的vm_area。這一塊工作由c庫向kernel發起請求,而由kernel完成這個請求,在kernel當中,有vm_operations_struct進行實際的內存操作:
- struct vm_operations_struct {
- void (*open)(struct vm_area_struct * area);
- void (*close)(struct vm_area_struct * area);
- ...
- };
可以看到,kernel可以對VMA進行open和close,即收發牌的工作。理解了malloc的工作原理,free也不難了,它向下調用munmap()。
下面是mmap和munmap的函數定義:
- void *
- mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
這里面,addr是希望能夠分配到的虛地址,比如:我希望得到一張牌,做為我手里編號為2的那張。需要注意的是,mmap最后分配出來的內存地址不一定是你想要的,可能你請求一張編號為2的撲克,但發牌人控制這個編號過程,他會給你一張在你手里編號為3的撲克。
prot代表對進程對這塊內存的權限:
- PROT_READ 是否可讀
- PROT_WRITE 是否可寫
- PROT_EXEC IP指針是否可以指向這里進行代碼的執行
- PROT_NONE 不能訪問
flags代表用於控制很多的內存屬性,我們一會兒會用到,這里不展開。
fd是文件描述符。我們這里必須明白一個基本原理,任何硬盤上面的數據,都要讀取到內存當中,才能被程序使用,因此,mmap的目的就是將文件數據映射進內存。因此,要在這里填寫文件描述符。如果你在這里寫-1,則不映射任何文件數據,只是在內存里面要上這一塊空間,這就是malloc對mmap的使用方法。
offset是文件的偏移量,比如:從第二行開始映射。文件映射,不是這篇文章關心的內容,不展開。
okay,了解了mmap的用法,下面看看munmap:
- int
- munmap(void *addr, size_t len);
munmap很簡單,告訴它要還回去的內存地址(即哪張牌),然后告訴它還回去的數量(多少張),其實更准確的說:尺寸。
現在讓我們回到題目上來,如何部分地回收一個數組中的內存?我們知道,使用malloc和free是無法完成的:
- #include <stdlib.h>
- int main() {
- int *p = malloc(12);
- free(p);
- return 0;
- }
因為無論是malloc還是free,都需要我們整體提交待分配和銷毀的全部內存。於是自然而然想到,是否可以malloc分配內存后,然后使用munmap來部分地釋放呢?下面是一個嘗試:
- #include <sys/mman.h>
- #include <stdio.h>
- #include <stdlib.h>
- int main() {
- int *arr;
- int *p;
- p = arr = (int*) malloc(3 * sizeof(int));
- int i = 0;
- for (i=0;i<3;i++) {
- *p = i;
- printf("address of arr[%d]: %p\n", i, p);
- p++;
- }
- printf("munmap: %d\n", munmap(arr, 3 * sizeof(int)));
- }
運行這段代碼輸出如下:

注意到munmap調用返回-1,說明內存釋放未成功,這是由於munmap處理的內存地址必須頁對齊(Page Aligned)。在Linux下面,kernel使用4096 byte來划分頁面,而malloc的顆粒度更細,使用8 byte對齊,因此,分配出來的內存不一定是頁對齊的。為了解決這個問題,我們可以使用memalign或是posix_memalign來獲取一塊頁對齊的內存:
- #include <sys/mman.h>
- #include <stdio.h>
- #include <stdlib.h>
- int main() {
- void *arr;
- printf("posix_memalign: %d\n", posix_memalign(&arr, 4096, 4096));
- printf("address of arr: %p\n", arr);
- printf("munmap: %d\n", munmap(arr, 4096));
- }
運行上述代碼得結果如下:

可以看到,頁對齊的內存資源可以被munmap正確處理(munmap返回值為0,說明執行成功)。仔細看一下被分配出來的地址:
- 0x7fe09b804000
轉換到10進制是:140602658275328
試試看是否能被4096整除:140602658275328 / 4096 = 34326820868
可以被整除,驗證了分配出來的地址是頁對齊的。
接下來,我們試用一下mmap,來分配一塊內存空間:
- mmap(NULL, 3 * sizeof(int), PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0)
注意上面mmap的使用方法。其中,我們不指定虛地址,讓內核決定內存地址,也就是說,我們要是要一張牌,但不關心給牌編什么號。然后PROT_READ|PROT_WRITE表示這塊內存可讀寫,接下來注意flags里面有MAP_ANONYMOUS,表示這塊內存不用於映射文件。下面是完整代碼:
- #include <sys/mman.h>
- #include <stdio.h>
- #include <stdlib.h>
- int main() {
- int *arr;
- int *p;
- p = arr = (int*) mmap(NULL, 3 * sizeof(int), PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
- int i = 0;
- for (i=0;i<3;i++) {
- *p = i;
- printf("address of arr[%d]: %p\n", i, p);
- p++;
- }
- printf("munmap: %d\n", munmap(arr, 3 * sizeof(int)));
- }
運行結果如下:

注意munmap返回值為0,說明內存釋放成功了。因此,驗證了mmap分配出來的內存是頁對齊的。
okay,了解了所有這些背景知識,我們現在應該對給內存打洞這個問題有一個思路了。我們可以創建以Page為基本單元的內存空間,然后用munmap在上面打洞。下面是實驗代碼:
- #include <sys/mman.h>
- #include <stdio.h>
- #include <stdlib.h>
- int main() {
- void *arr;
- printf("posix_memalign: %d\n", posix_memalign(&arr, 4096, 3 * 4096));
- printf("address of arr: %p\n", arr);
- printf("address of arr[4096]: %p\n", &arr[4096]);
- printf("munmap: %d\n", munmap(&arr[4096], 4096));
- }
我們申請了3*4096 byte的空間,也就是3頁的內存,然后通過munmap,在中間這頁上開個洞 。運行上面的代碼,結果如下:

看到munmap的返回為0,說明內存釋放成功,我們在arr數組上成功地開了一個洞。
這種方法,最大的局限在於,你操作的內存必須是page對齊的。如果想要更細顆粒度的打洞,純靠User Space的API調用是不行的,需要在Kernel Space直接操作進程的VMA結構體來實現。實現思路如下:
1. 通過kernel提供的page map映射,找到要釋放的內存虛地址所對應的物理地址。
2. 撰寫一個內核模塊,幫助你user space的程序來將實際的物理內存放回free list。
我在本文的下篇中,將詳細介紹Kernel Space和User Space的結合編碼,實現更細顆粒度的內存操作。
參考資料
Experiments with the Linux Kernel: Process Segments
How to find the physical address of a variable from user-space in Linux?
Simplest way to get physical address from the logical one in linux kernel module
Page Map
anon_mmap.c
Mmap
mmap()--Memory Map a File
C_dynamic_memory_allocation
What are the differences between "brk()" and "mmap()"?
How to guarantee alignment with malloc and or new?
Understanding Memory Pages and Page Alignment