內存管理第二談:內存分配機制

(本帖不適合手機用戶)
上次我們討論了內存尋址相關內容--段式管理和頁式管理機制,只有搞懂了線性地址和物理地址兩者關係,才能再去學習真正的內存管理機制。這一節我們簡單的來了解linux內核的內存分配機制,工作中我使用的內核版本是linux-3.2.0,我也嘗試着跟這個版本的代碼來解析這塊知識,但是水平確實不夠,現代版本內核關於操作系統的幾個大體系太過龐大,我往往是跟着跟着就陷到另一個體系中去回不來了,所以決定以0.11版本代碼爲核心,分析內存管理的核心思想,然後穿插着現代內存管理機制中一些算法。


先來總結下內存尋址相關內容:
1.進程或者說APP運行的地址空間叫做線性地址,需要最終映射到物理地址才能真正運行程序
2.線性地址和物理地址之間是通過“頁目錄表”和“頁表”兩級映射進行轉換的
3.物理內存被分爲一個個4KB的空間,每個4KB的起始地址叫做頁框基地址
4.頁框地址是存在頁表中的

內存尋址中說到,通過32位線性地址尋址到頁表項後就能得到頁框地址,頁表項內容可以作爲一個“地址指針”來理解,那麼首先我們就要來看下這個指針的真實面目是什麼樣子(以0.11版本爲例)。
---------------------------------------------------------------------------------
---------------20位頁框地址--------------|AVAIL|00|D|A|00|U/S|R/W|P|      位含義
31--------------------------------------12|11---------------------------0|      位數

上圖就是一個頁表中的所有內容,0.11源碼好像沒有對這個結構進行抽象,而是直接用的位操作來實現邏輯,爲了下面好理解,我們可以把上面的頁框內容抽象爲一個結構體類型:

typedef unsigned int u32;  

struct page_frame_content
{
    u32 page_frame_address:22;
    u32 avail:2;
    u32 none1:2;
    u32 dirty:1;
    u32 accessed:1;
    u32 none2:2;
    u32 user_super:1;
    u32 read_write:1;
    u32 present:1;
}



上面結構體定義方法爲位域法,正好滿足我們的需求,冒號後邊的數字表示結構體中的成員佔的位數,也就說這個結構體總共佔用4字節-32位,其中頁框地址page_frame_address佔用22位,是否可讀寫佔用1位...
我們只關注和本節內容有關的成員含義:

present:佔據1位,用來指示當前的頁表項是否已經映射到了物理內存頁,1:已經映射 0:未映射
read_write:佔據1位,用來表示所指的物理內存頁是否可讀寫,1:可讀可寫 0:可讀不可寫
page_frame_address:佔據22位,表示代表的的物理內存頁,大家可能奇怪了,你不是說頁表內容代表物理地址嗎,22位才能表示4MB,難道0.11最大才支持4MB物理內存?前面我們講過,物理內存被分爲了一個個的4KB的存儲單元--頁框,頁表項中存儲的是頁框基地址,那麼也就是4KB、8KB、12KB、...、100KB,用16進製表示下這些地址就是0x1000,0x2000,0x3000,...,0x19000,發現沒有,由於是4KB邊界,低12位全爲0,那麼我們何必再去寫這12位呢,乾脆拿出來挪作它用節省空間豈不更好。所以加入頁表項中高22位page_frame_address的值爲1,實際代表的物理地址應該是1<<12=4KB,這樣實際上能表示的最高地址爲4GB。明白了吧!

本節不再像上一節一樣乾巴巴的講理論,下面將結合0.11版本內核源碼來探究內存管理機制,我們只抽取核心部分,對於細節不詳細解釋,有些代碼或者名字爲了核心內容展現並且讓大家好理解,我做了些許改動。
main()  //內核主函數
	memory_end = 16 * 1024 * 1024;  //0.11默認支持最大內存爲16MB,沒什麼好稀奇的,也許那時候你纔剛剛出生
	mem_init();
		HIGH_MEMORY = end_mem;	// 設置物理內存最高端。
		end_mem >>= 12;
		while (end_mem-- > 0)	
			mem_map[i++] = 0;

後三句我沒有註釋,需要單講。
首先,mem_map[]數組是做什麼用的呢?答:它是一個內存映射數組,用來標識物理內存頁映射的次數。
說的挺難理解的,舉個例子,假如有一本書有200頁,你看了一段時間後,怎麼區分那一頁看過,哪一頁沒看過呢?很簡單,只要在看過的頁上做個標記就可以了是吧。同樣道理,內存也一樣。它就是用這麼一個數組來標示哪個頁用了,哪個沒用。內核中定義是這樣的:
unsigned char mem_map[PAGING_PAGES] = { 0, };
假如mem_map[1]=0,mem_map[100]=1,mem_map[200]=2,就表示第1個物理內存頁沒用被佔用,第100個被一個頁表項佔用,第200個被兩個頁表項佔用或者說映射。這個數組當然是在內存初始化時候分配的,也是佔用內存空間的,那麼我們來計算下這個數據結構佔用多少總空間(注意單位):
設物理內存總大小爲x字節,那麼總的內存頁數就是x/4KB=x>>12,那麼mem_map總佔用大小=(x>>12)項 * 1字節(每個數組佔1字節)x/4096。也就說這塊基本上佔用總大小的1/4096。而發展到現在版本內核,那3.2.0來說,mem_map的每個數組項已經佔用32字節了,需要整個空間的差不多1%,當然,每個字節表示的信息量就更大了,而且現在內存動輒幾個G,這點無所謂啦。

其實內核啓動初始化過程對於內存管理就做了這麼點事情,其它的內存分配相關內容是通過異常中斷來進行的。我們現在不去深究異常中斷實現過程,你只需要知道如果訪問一個線性地址,如果出現以下情況,就會導致異常中斷產生:
1.到頁表項尋址時候發現根本沒有相應的物理內存頁與之相對應,引起缺頁異常
2.有物理內存頁與之對應,但是執行的是寫操作,但是物理內存權限中read_write標誌位爲0--只讀,會引起頁寫保護異常

下面只需要跟蹤三個函數就能理清楚內存分配機制:
1.缺頁異常處理函數--do_no_page
2.頁寫保護異常--do_wp_page 
3.fork(克隆進程)庫函數調用的--copy_page_tables

我們copy_page_tables開始,因爲這個函數是整個內存管理中最複雜的函數,但是隻要掌握這個函數的涉及內容,其它兩點就很容易理解了。
跟蹤代碼前先大體說一下它的功能:複製指定線性地址和長度,主要爲用戶空間克隆進程服務。什麼叫進程克隆呢,我們在用戶空間編程時候往往需要對某個進程複製,這就是進程克隆。可以想象一下,你同時打開了兩個qq,而這兩個qq其實程序是一樣的,但是都需要物理空間來運行。那麼改如何進行復制呢?需不需要把qq1的物理空間全部複製給qq2呢?答案是no,Linux運用了一種叫“寫時複製”的機制,即兩個qq運行在不同的線性地址,但是線性地址映射到的物理地址空間是相同的,直到某個進程發生了寫操作,纔會把相應的頁克隆,注意不是克隆全部。這樣就大大節省了物理內存。這裏邊還牽扯一些進程管理知識,我們不去深究,下面用一幅圖來表示這種機制。電腦畫實在沒那個精力,就用筆畫的,不忍直視,現在比較窮,就兩支筆,見諒見諒~



左邊是線性地址,兩個段運行的進程是qq1和qq2,不是991,992,汗~,0.11爲每個進程分配的空間是64MB,現在內核每個進程最多能用4GB。首先qq1運行時候,內核爲它分配了一個頁表(假如4MB夠它用的),併爲頁表中每一項都分配了物理地址。這時候qq2啓動了,內核不會重新分配相同的物理空間給qq2,而是通過複製一個和qq1運行相同的頁表,讓頁表中每一項指向和qq1相同的物理內存。這樣qq2就能運行起來了,夠清楚吧。假如某個時刻qq2要往原本共享的0x5空間寫東西,當然你不能亂寫,否則qq1再讀取這塊空間時候就亂了。所以這時候,爲qq2申請一塊新的物理內存空間0x6,把原理頁表項中的0x5改爲0x6,這樣對於這一頁數據,兩個qq進程就有不同的物理空間了,再執行寫操作時候就不會影響到另外一個了。

我們來跟一下源碼,其中需要上一節內存尋址的知識。
/*
	from:源線性地址
	to:目的線性地址
	size:拷貝的線性地址大小,字節爲單位
*/
int copy_page_tables (unsigned long from, unsigned long to, long size)
 	//取得源地址和目的地址的目錄項地址(from_dir 和to_dir),看起來比較難,其實代碼這麼寫比較容易理解
 	//from_dir = (unsigned long *) ((from / 4MB)*4B & 0xffc);每個目錄項表示4MB,每個目錄下4B,&0xffc是爲了4B對齊,自己想吧
	from_dir = (unsigned long *) ((from >> 20) & 0xffc);
	to_dir = (unsigned long *) ((to >> 20) & 0xffc);
	size = ((unsigned) (size + 0x3fffff)) >> 22; //爲什麼加個0x3fffff,你可以帶入幾個數想想,最後得到size大小是佔用目錄項個數
	for (; size-- > 0; from_dir++, to_dir++)  //開始複製每一項對應的頁表
	{
		from_page_table = (unsigned long *) (0xfffff000 & *from_dir);  //得到源頁表地址
		to_page_table = (unsigned long *) get_free_page (); //申請一頁新的頁表空間
		*to_dir = (unsigned long) to_page_table; //設置目錄項爲新頁表地址
		for(....)
		{
			//對新頁表中每一項進行復制原頁表操作
			for(i=0; i<1024; i++)
			{
				page_content = *from_page_table;  //取得源頁表地址處的第一項的內容(注意指針操作)
				page_content &= ~(1<<1);//把第1位清0,也就是置讀寫位爲只讀
				*to_page_table = page_content;  //把源頁表第一項的內容複製給目的頁表第一項,然後兩者就指向同一塊物理內存了
				
				from_page_table++;  //指針指向下一個地址,繼續複製
				to_page_table++;
				
				*from_page_table = this_page; //把原來頁表項物理地址權限也改爲只讀
				this_page >>= 12;  //取到內存管理數組的項
				mem_map[this_page]++;
			}
		}
	}


上面代碼爲了好理解我改了很多,也略去很多,基本框架就是這樣,如果明白思想,再去看源碼應該問題不大。這裏除了上面的複製操作之外,還有一點要注意,就是那個&操作,因爲進程地址空間克隆後,兩者實際上是共用的一塊物理內存,任何一個進程寫都會導致另一個進程出問題,所以必須複製後把這塊物理內存權限置爲只讀,當兩者中某個要發生寫操作時候,再利用下面要說的“頁寫保護異常”來處理。

總結下上邊這個函數功能:
1.拷貝連續的線性地址,一般是涉及到幾個頁表,給應用層進程克隆之類的接口使用,比如fork
2.拷貝的並不是真正的物理內存,只是把新建立的頁表和源頁表指向相同的物理內存
3.新拷貝的頁表指向的物理內存權限爲只讀,如果某個進程要寫這些內存,會發生“頁寫保護異常”

下面我們再來看“頁寫保護異常”函數處理,其實就是操作系統中常說的“寫時複製”機制,還是先說下思想,再跟源碼
首先,這個異常肯定是由於進程寫的線性地址指向的物理內存的權限是隻讀,於是內核會把進程訪問的線性地址重新映射到新的物理地址,並把源物理地址的內容拷貝到新的物理地址頁,這樣這個進程的寫操作就不會影響到其它進程了,就這麼簡單。我們看下源碼:

/*
	error_code:由CPU自己產生,不用管
	address: 造成異常的線性地址,也就是進程需要寫的線性地址
*/
void do_wp_page (unsigned long error_code, unsigned long address)
		//這個函數形參看着挺嚇人的,其實就是取了線性地址對應的頁表項地址,自己分析下
	  un_wp_page ((unsigned long *)(((address >> 10) & 0xffc) + (0xfffff000 &*((unsigned long*) ((address >> 20) & 0xffc)))));
			old_page = 0xfffff000 & *table_entry;	// 取原頁面對應的目錄項號。
			new_page = get_free_page ()  //先申請一頁新的物理內存頁
			mem_map[...]--;  //把這一頁標記爲佔用
			*table_entry = new_page | 7;  //訪問線性地址對應的頁表項權限設置爲可讀寫
			copy_page (old_page, new_page);

“頁寫保護”異常是內存管理裏比較簡單的一個處理,下面就剩下最後一個點--“缺頁異常”,這個異常是怎麼產生的呢?大家可以想象這麼一種情況,一個進程剛創建時候,它的運行空間還沒有,它只要訪問某個線性地址,肯定是沒有對應到物理地址的,或者說進程調用了malloc之類的函數申請內存並訪問,這時候需要的物理地址都是新的,沒有經過映射的。所以就會導致“缺頁異常”的產生。這個函數層次很簡單,但是涉及的體系太大,包括進程管理、文件系統、塊設備驅動,很多地方我也沒弄懂,所以只是給大家列一下基本思想。

首先,內核會試着從已經存在的進程,找一個可以與當前進程共享的頁面,如果找到了,萬事大吉,先用着,等有寫操作時候就不歸我管了,交給上面的“頁寫保護”異常處理。如果沒找到,就分配一頁新的物理頁,然後把塊設備上的代碼或者數據讀到這塊物理地址上,並和線性地址做映射,代碼框架:

/*
	error_code:由CPU自己產生,不用管
	address: 造成異常的線性地址
*/
void do_no_page (unsigned long error_code, unsigned long address)
	if(share_page() == 成功);  //試着找個共享的
		return;
	else
		page = get_free_page ();  //申請一頁新的
		
	bread_page (page, current->executable->i_dev, nr);  //從塊設備讀取到物理內存
	put_page (page, address);  //完成物理地址和線性地址的映射	


以我的水平,只能說到這樣了,剩下的只能把現代內核中找些內核分配算法整理下了,放到第三談吧,大家小年好!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章