linux內存管理淺析

image

 


[ 地址映射](圖:左中)
linux內核使用頁式內存管理,應用程序給出的內存地址是虛擬地址,它須要通過若干級頁表一級一級的變換,才變成真正的物理地址。
想一下,地址映射仍是一件很恐怖的事情。當訪問一個由虛擬地址表示的內存空間時,須要先通過若干次的內存訪問,獲得每一級頁表中用於轉換的頁表項(頁表是存放在內存裏面的),才能完成映射。也就是說,要實現一次內存訪問,實際上內存被訪問了N+1次(N=頁表級數),而且還須要作N次加法運算。
因此,地址映射必需要有硬件支持,mmu(內存管理單元)就是這個硬件。而且須要有cache來保存頁表,這個cache就是TLB(Translation lookaside buffer)。
儘管如此,地址映射仍是有着不小的開銷。假設cache的訪存速度是內存的10倍,命中率是40%,頁表有三級,那麼平均一次虛擬地址訪問大概就消耗了兩次物理內存訪問的時間。
因而,一些嵌入式硬件上可能會放棄使用mmu,這樣的硬件可以運行VxWorks(一個很高效的嵌入式實時操做系統)、linux(linux也有禁用mmu的編譯選項)、等系統。
可是使用mmu的優點也是很大的,最主要的是出於安全性考慮。各個進程都是相互獨立的虛擬地址空間,互不干擾。而放棄地址映射以後,全部程序將運行在同一個地址空間。因而,在沒有mmu的機器上,一個進程越界訪存,可能引發其餘進程莫名其妙的錯誤,甚至致使內核崩潰。
在地址映射這個問題上,內核只提供頁表,實際的轉換是由硬件去完成的。那麼內核如何生成這些頁表呢?這就有兩方面的內容,虛擬地址空間的管理和物理內存的管理。(實際上只有用戶態的地址映射才須要管理,內核態的地址映射是寫死的。)

[ 虛擬地址管理](圖:左下)
每一個進程對應一個task結構,它指向一個mm結構,這就是該進程的內存管理器。(對於線程來講,每一個線程也都有一個task結構,可是它們都指向同一個mm,因此地址空間是共享的。)
mm->pgd指向容納頁表的內存,每一個進程有自已的mm,每一個mm有本身的頁表。因而,進程調度時,頁表被切換(通常會有一個CPU寄存器來保存頁表的地址,好比X86下的CR3,頁表切換就是改變該寄存器的值)。因此,各個進程的地址空間互不影響(由於頁表都不同了,固然沒法訪問到別人的地址空間上。可是共享內存除外,這是故意讓不一樣的頁表可以訪問到相同的物理地址上)。
用戶程序對內存的操做(分配、回收、映射、等)都是對mm的操做,具體來講是對mm上的vma(虛擬內存空間)的操做。這些vma表明着進程空間的各個區域,好比堆、棧、代碼區、數據區、各類映射區、等等。
用戶程序對內存的操做並不會直接影響到頁表,更不會直接影響到物理內存的分配。好比malloc成功,僅僅是改變了某個vma,頁表不會變,物理內存的分配也不會變。
假設用戶分配了內存,而後訪問這塊內存。因爲頁表裏面並無記錄相關的映射,CPU產生一次缺頁異常。內核捕捉異常,檢查產生異常的地址是否是存在於一個合法的vma中。若是不是,則給進程一個"段錯誤",讓其崩潰;若是是,則分配一個物理頁,併爲之創建映射。

[ 物理內存管理](圖:右上)
那麼物理內存是如何分配的呢?
首先,linux支持NUMA(非均質存儲結構),物理內存管理的第一個層次就是介質的管理。pg_data_t結構就描述了介質。通常而言,咱們的內存管理介質只有內存,而且它是均勻的,因此能夠簡單地認爲系統中只有一個pg_data_t對象。
每一種介質下面有若干個zone。通常是三個,DMA、NORMAL和HIGH。
DMA:由於有些硬件系統的DMA總線比系統總線窄,因此只有一部分地址空間可以用做DMA,這部分地址被管理在DMA區域(這屬因而高級貨了);
HIGH:高端內存。在32位系統中,地址空間是4G,其中內核規定3~4G的範圍是內核空間,0~3G是用戶空間(每一個用戶進程都有這麼大的虛擬空間)(圖:中下)。前面提到過內核的地址映射是寫死的,就是指這3~4G的對應的頁表是寫死的,它映射到了物理地址的0~1G上。(實際上沒有映射1G,只映射了896M。剩下的空間留下來映射大於1G的物理地址,而這一部分顯然不是寫死的)。因此,大於896M的物理地址是沒有寫死的頁表來對應的,內核不能直接訪問它們(必需要創建映射),稱它們爲高端內存(固然,若是機器內存不足896M,就不存在高端內存。若是是64位機器,也不存在高端內存,由於地址空間很大很大,屬於內核的空間也不止1G了);
NORMAL:不屬於DMA或HIGH的內存就叫NORMAL。
在zone之上的zone_list表明了分配策略,即內存分配時的zone優先級。一種內存分配每每不是隻能在一個zone裏進行分配的,好比分配一個頁給內核使用時,最優先是從NORMAL裏面分配,不行的話就分配DMA裏面的好了(HIGH就不行,由於還沒創建映射),這就是一種分配策略。
每一個內存介質維護了一個mem_map,爲介質中的每個物理頁面創建了一個page結構與之對應,以便管理物理內存。
每一個zone記錄着它在mem_map上的起始位置。而且經過free_area串連着這個zone上空閒的page。物理內存的分配就是從這裏來的,從 free_area上把page摘下,就算是分配了。(內核的內存分配與用戶進程不一樣,用戶使用內存會被內核監督,使用不當就"段錯誤";而內核則無人監督,只能靠自覺,不是本身從free_area摘下的page就不要亂用。)

[ 創建地址映射]
內核須要物理內存時,不少狀況是整頁分配的,這在上面的mem_map中摘一個page下來就行了。好比前面說到的內核捕捉缺頁異常,而後須要分配一個page以創建映射。
說到這裏,會有一個疑問,內核在分配page、創建地址映射的過程當中,使用的是虛擬地址仍是物理地址呢?首先,內核代碼所訪問的地址都是虛擬地址,由於 CPU指令接收的就是虛擬地址(地址映射對於CPU指令是透明的)。可是,創建地址映射時,內核在頁表裏面填寫的內容倒是物理地址,由於地址映射的目標就是要獲得物理地址。
那麼,內核怎麼獲得這個物理地址呢?其實,上面也提到了,mem_map中的page就是根據物理內存來創建的,每個page就對應了一個物理頁。
因而咱們能夠說,虛擬地址的映射是靠這裏page結構來完成的,是它們給出了最終的物理地址。然而,page結構顯然是經過虛擬地址來管理的(前面已經說過,CPU指令接收的就是虛擬地址)。那麼,page結構實現了別人的虛擬地址映射,誰又來實現page結構本身的虛擬地址映射呢?沒人可以實現。
這就引出了前面提到的一個問題,內核空間的頁表項是寫死的。在內核初始化時,內核的地址空間就已經把地址映射寫死了。page結構顯然存在於內核空間,因此它的地址映射問題已經經過「寫死」解決了。
因爲內核空間的頁表項是寫死的,又引出另外一個問題,NORMAL(或DMA)區域的內存可能被同時映射到內核空間和用戶空間。被映射到內核空間是顯然的,由於這個映射已經寫死了。而這些頁面也可能被映射到用戶空間的,在前面提到的缺頁異常的場景裏面就有這樣的可能。映射到用戶空間的頁面應該優先從HIGH 區域獲取,由於這些內存被內核訪問起來很不方便,拿給用戶空間再合適不過了。可是HIGH區域可能會耗盡,或者可能由於設備上物理內存不足致使系統裏面根本就沒有HIGH區域,因此,將NORMAL區域映射給用戶空間是必然存在的。
可是NORMAL區域的內存被同時映射到內核空間和用戶空間並無問題,由於若是某個頁面正在被內核使用,對應的page應該已經從free_area被摘下,因而缺頁異常處理代碼中不會再將該頁映射到用戶空間。反過來也同樣,被映射到用戶空間的page天然已經從free_area被摘下,內核不會再去使用這個頁面。

[ 內核空間管理](圖:右下)
除了對內存整頁的使用,有些時候,內核也須要像用戶程序使用malloc同樣,分配一塊任意大小的空間。這個功能是由slab系統來實現的。
slab至關於爲內核中經常使用的一些結構體對象創建了對象池,好比對應task結構的池、對應mm結構的池、等等。
而slab也維護有通用的對象池,好比"32字節大小"的對象池、"64字節大小"的對象池、等等。內核中經常使用的kmalloc函數(相似於用戶態的malloc)就是在這些通用的對象池中實現分配的。
slab除了對象實際使用的內存空間外,還有其對應的控制結構。有兩種組織方式,若是對象較大,則控制結構使用專門的頁面來保存;若是對象較小,控制結構與對象空間使用相同的頁面。
除了slab,linux 2.6還引入了mempool(內存池)。其意圖是:某些對象咱們不但願它會由於內存不足而分配失敗,因而咱們預先分配若干個,放在mempool中存起來。正常狀況下,分配對象時是不會去動mempool裏面的資源的,照常經過slab去分配。到系統內存緊缺,已經沒法經過slab分配內存時,纔會使用 mempool中的內容。

[ 頁面換入換出](圖:左上)(圖:右上)
頁面換入換出又是一個很複雜的系統。內存頁面被換出到磁盤,與磁盤文件被映射到內存,是很類似的兩個過程(內存頁被換出到磁盤的動機,就是從此還要從磁盤將其載回內存)。因此swap複用了文件子系統的一些機制。
頁面換入換出是一件很費CPU和IO的事情,可是因爲內存昂貴這一歷史緣由,咱們只好拿磁盤來擴展內存。可是如今內存愈來愈便宜了,咱們能夠輕鬆安裝數G的內存,而後將swap系統關閉。因而swap的實現實在讓人難有探索的慾望,在這裏就不贅述了。(另見:《 linux內核頁面回收淺析》)

[ 用戶空間內存管理]
malloc是libc的庫函數,用戶程序通常經過它(或相似函數)來分配內存空間。
libc對內存的分配有兩種途徑,一是調整堆的大小,二是mmap一個新的虛擬內存區域(堆也是一個vma)。
在內核中,堆是一個一端固定、一端可伸縮的vma(圖:左中)。可伸縮的一端經過系統調用brk來調整。libc管理着堆的空間,用戶調用malloc分配內存時,libc儘可能從現有的堆中去分配。若是堆空間不夠,則經過brk增大堆空間。
當用戶將已分配的空間free時,libc可能會經過brk減少堆空間。可是堆空間增大容易減少卻難,考慮這樣一種狀況,用戶空間連續分配了10塊內存,前9塊已經free。這時,未free的第10塊哪怕只有1字節大,libc也不可以去減少堆的大小。由於堆只有一端可伸縮,而且中間不能掏空。而第10 塊內存就死死地佔據着堆可伸縮的那一端,堆的大小無法減少,相關資源也無法歸還內核。
當用戶malloc一塊很大的內存時,libc會經過mmap系統調用映射一個新的vma。由於對於堆的大小調整和空間管理仍是比較麻煩的,從新建一個vma會更方便(上面提到的free的問題也是緣由之一)。
那麼爲何不老是在malloc的時候去mmap一個新的vma呢?第一,對於小空間的分配與回收,被libc管理的堆空間已經可以知足須要,沒必要每次都去進行系統調用。而且vma是以page爲單位的,最小就是分配一個頁;第二,太多的vma會下降系統性能。缺頁異常、vma的新建與銷燬、堆空間的大小調整、等等狀況下,都須要對vma進行操做,須要在當前進程的全部vma中找到須要被操做的那個(或那些)vma。vma數目太多,必然致使性能降低。(在進程的vma較少時,內核採用鏈表來管理vma;vma較多時,改用紅黑樹來管理。)

[ 用戶的棧] 與堆同樣,棧也是一個vma(圖:左中),這個vma是一端固定、一端可伸(注意,不能縮)的。這個vma比較特殊,沒有相似brk的系統調用讓這個vma伸展,它是自動伸展的。 當用戶訪問的虛擬地址越過這個vma時,內核會在處理缺頁異常的時候將自動將這個vma增大。內核會檢查當時的棧寄存器(如:ESP),訪問的虛擬地址不能超過ESP加n(n爲CPU壓棧指令一次性壓棧的最大字節數)。也就是說,內核是以ESP爲基準來檢查訪問是否越界。 可是,ESP的值是能夠由用戶態程序自由讀寫的,用戶程序若是調整ESP,將棧劃得很大很大怎麼辦呢?內核中有一套關於進程限制的配置,其中就有棧大小的配置,棧只能這麼大,再大就出錯。 對於一個進程來講,棧通常是能夠被伸展得比較大(如:8MB)。然而對於線程呢? 首先線程的棧是怎麼回事?前面說過,線程的mm是共享其父進程的。雖然棧是mm中的一個vma,可是線程不能與其父進程共用這個vma(兩個運行實體顯然不用共用一個棧)。因而,在線程建立時,線程庫經過mmap新建了一個vma,以此做爲線程的棧(大於通常爲:2M)。 可見,線程的棧在某種意義上並非真正棧,它是一個固定的區域,而且容量頗有限。
相關文章
相關標籤/搜索