Linux核心筆記004 - 從記憶體管理開始,認識Linux核心

jmpcall發表於2020-05-28
1. 系統初始化
    Linux核心筆記001Linux核心筆記002Linux核心筆記003,對應的是《Linux核心原始碼情景分析》第一章內容,在進入第二章學習之前,本篇筆記先跳躍到第10.1節——系統初始化(第一階段):
  • 真實模式
        記憶體是揮發性的儲存介質,斷電後,資料就沒有了,相應的,開機時,是透過"燒"在不揮發儲存介質上的初始載入程式(比如BIOS程式),將核心程式從磁碟引導區讀入記憶體的。在核心被載入到記憶體後,指令就會從BIOS跳轉到核心程式碼開始執行,這時CPU還是真實模式狀態,核心程式碼做好一些基礎準備後,再透過設定相關暫存器,將CPU切換到保護模式狀態。
        這個過程後續會詳細學習,本篇筆記暫不記錄,目前只需要知道一個gdt_table變數:
ENTRY(gdt_table)
	.quad 0x0000000000000000	/* NULL descriptor */
	.quad 0x0000000000000000	/* not used */
	.quad 0x00cf9a000000ffff	/* 0x10 kernel 4GB code at 0x00000000 */
	.quad 0x00cf92000000ffff	/* 0x18 kernel 4GB data at 0x00000000 */
	.quad 0x00cffa000000ffff	/* 0x23 user   4GB code at 0x00000000 */
	.quad 0x00cff2000000ffff	/* 0x2b user   4GB data at 0x00000000 */
	.quad 0x0000000000000000	/* not used */
	.quad 0x0000000000000000	/* not used */
	/*
	 * The APM segments have byte granularity and their bases
	 * and limits are set at run time.
	 */
	.quad 0x0040920000000000	/* 0x40 APM set up for bad BIOS's */
	.quad 0x00409a0000000000	/* 0x48 APM CS    code */
	.quad 0x00009a0000000000	/* 0x50 APM CS 16 code (16 bit) */
	.quad 0x0040920000000000	/* 0x58 APM DS    data */
	.fill NR_CPUS*4,8,0		/* space for TSS's and LDT's */
        以上程式碼,就是核心對這個變數的初始化內容,編譯後,這個內容會存入核心映象檔案的資料段,跟映象檔案的指令段內容一樣,也會在開機時,被載入到記憶體的指定位置。書中1.2節,介紹過GDTR/LDTR暫存器,透過gdt_table變數名,應該已經可以猜出,它就是GDTR指向的全域性描述符表,用於CPU切換到保護模式時進行段式定址。
        另外,在真實模式階段,核心會將CS暫存器設定為__KERNEL_CS,即0x10(二進位制:10|0|00),按照保護模式下段暫存器的含義,即:index=2、TI=0、RPL=0。
        "TI=0"表示使用全域性描述符表,即gdt_table,則"index=2"索引到的描述符為0x00cf9a000000ffff,轉換為二進位制:
0000 0000 1100 1111 1001 1010 0000 0000 0000 0000 0000 0000 1111 1111 1111 1111
        再結合段描述符的結構定義,得到:
        ① B0-B15、B16-B31都為0,即段基址為0
        ② L0-L15、L16-L19都為1,G位為1,即段長度為4G
        也就是說,CS暫存器"指向"的段,是從0地址開始,4G長度的整個記憶體。其實,"3、4、5"索引處的描述符,同樣是這種情況:"3"與"2"的區別,僅在於type欄位,分別表示程式碼段、資料段,用於賦值給CS和DS、ES、SS;"4、5"與"2、3"相比,又僅僅是RPL欄位有區別,表示許可權級別分別為0、3
        這是因為,Linux核心緊接著就會跳轉到startup_32程式碼處,開啟CPU的頁式管理功能,它根本沒打算使用段式對映的方式,進行記憶體管理,只是由於80386定址邏輯,總是會先經過段式對映過程,Linux核心這樣設定,一方面保證不會遇到CPU內部的檢查錯誤(越權、越界),另一方面,對映後邏輯地址也能保持不變,在軟體層,對於後續的頁式對映過程,讓段式對映過程變得"透明"了
  • 保護模式
        跳轉到startup_32時,CPU已經切換到保護模式狀態了,同進入保護模式前,要設定好段暫存器、段描述符表的道理一樣,在開啟頁式對映功能前,也要準備好一定量的目錄表、頁面表內容。
        我當初就有過一個疑問:開啟頁式管理時訪問記憶體,要事先由分配函式建立了對映關係才行,那麼剛開啟頁式管理時,分配函式本身需要訪問的記憶體,又是什麼時候建立對映關係的呢?
        其實,這裡就是源頭。可以理解成,頁式管理開啟前,指令中包含什麼地址,實際訪問到的也是這個地址,沒有"分配"的概念,頁式管理開啟後,指令中直接包含的地址,都要利用"分配"操作事先建立的頁式對映關係,才能得到實體地址。這裡就相當於,在為開啟頁式對映後緊接著的一些操作"分配"記憶體,跟應用程式開發中,執行一個演算法前,先呼叫malloc()分配一塊記憶體,是一樣的道理。
/*
 * swapper_pg_dir is the main page directory, address 0x00101000
 *
 * On entry, %esi points to the real-mode code as a 32-bit pointer.
 */
/*
 * 引導過程更之前的階段,會將startup_32程式碼片段,複製到0x100000實體地址處,並且跳轉語句為"ljmp 0x100000",而不是"ljmp startup_32":
 * 如果在程式裡,將跳轉語句寫成"ljmp startup_32",編譯後會生成形似"ff 2d XX XX XX CX"的二進位制指令,"XXXXXXX"部分,表示startup_32指令塊在二進位制檔案中的偏移,它受整個程式中定義變數的多少,以及其它函式的情況影響,增刪一個函式,或者在某個函式增刪一行程式碼,都有可能會影響startup_32的位置,另外,為了保證核心基本邏輯,訪問的都是核心空間,核心程式中所有符號的地址,都會加上鍊接指令碼中指定的偏移0xC0000000,從而最終生成到跳轉指令中的地址為0xCXXXXXXX
 * 而此時已經是保護模式,再加上Linux核心的設計,使得段式對映前後的地址保持不變,所以"ljmp startup_32"指令就會跳轉到真實記憶體的0xCXXXXXXX處,而不是startup_32真正所在的0x100000處
*/
ENTRY(stext)
ENTRY(_stext)
startup_32:
/*
 * Set segments to known values
 */
/*
 * DS、ES、FS、GS都設定為__KERNEL_DS
 * Linux核心真正希望使用的只有頁式管理,但由於80386硬體設計的原因,進入頁式對映前,要保證段式對映也能順利完成
*/
	cld    ; DF標誌位清0
	movl $(__KERNEL_DS),%eax
	movl %eax,%ds
	movl %eax,%es
	movl %eax,%fs
	movl %eax,%gs
#ifdef CONFIG_SMP
/*
 * BX在本段程式碼中,表示"是否為次cpu"
 * bx與自己相或,結果作為跳轉條件,如果bx為0,即主cpu執行到這,會跳轉到緊接著的第一個"1"標號處
*/
	orw %bx,%bx
	jz 1f

/*
 *	New page tables may be in 4Mbyte page mode and may
 *	be using the global pages. 
 *
 *	NOTE! If we are on a 486 we may have no cr4 at all!
 *	So we do not try to touch it unless we really have
 *	some bits in it to set.  This won't work if the BSP
 *	implements cr4 but this AP does not -- very unlikely
 *	but be warned!  The same applies to the pse feature
 *	if not equally supported. --macro
 *
 *	NOTE! We have to correct for the fact that we're
 *	not yet offset PAGE_OFFSET..
 */
/*
 * 次cpu進入startup_32前,bx被設定為1,執行這段程式碼,最關鍵的邏輯是,直接跳轉到"3"標號處,使用主cpu設定好的頁表,自己不再設定
*/
#define cr4_bits mmu_cr4_features-__PAGE_OFFSET
	cmpl $0,cr4_bits
	je 3f
        /* 如果支援PSE/PAE,設定CR4暫存器 */
	movl %cr4,%eax		# Turn on paging options (PSE,PAE,..)
	orl cr4_bits,%eax
	movl %eax,%cr4
	jmp 3f
1:
#endif
/*
 * Initialize page tables
 */
/*
 * 彙編程式碼中,可以透過.org讓編譯器將變數安排在指定偏移處,比如pg0、empty_zero_page,分別被安排在二進位制檔案中的0x2000、0x4000處,加上鍊接指令碼指定的偏移,編譯地址分別為0xC0002000、0xC0004000
 * "Linux核心筆記002"已經說明過,0-3G範圍的虛擬地址,在不同程式中,會對映到不同的實體地址,而3G-4G的虛擬地址為核心空間,對映關係不能因程式不同而不同,否則就跟使用者空間一樣屬於各個程式的私有空間了,從而,對於3G-3G+896MB範圍的所有虛擬地址,都是按照"減0xC0000000偏移"的規則,建立一個固定的對映關係(剩餘128MB核心空間屬於高階記憶體,後期學習)
 * 那麼,對於虛擬地址0xC0002000、0xC0004000,經過對映後,對應的實體地址就應該分別為0x2000、0x4000,然而程式碼執行到此處,還沒開啟頁式管理,也還沒有建立這樣的對映,所以透過指令本身減掉了0xC0000000(__PAGE_OFFSET)偏移,它跟開啟頁式管理後,cpu透過對映找到的實體地址是一樣的
 * "1"、"2"標號處程式碼結合一起,正是用於建立0xC0000000-0xC0800000這部分核心空間(開頭8MB)的對映,從0x2000開始,依次寫入頁表項0x007、0x1007、0x2007..,直到0x4000處結束,從而建立了2個PT(頁表),後續再指定好目錄表,並設定好目錄項,開啟項式管理後,就可以正常訪問0xC0000000-0xC0800000範圍內的虛擬地址了
*/
	movl $pg0-__PAGE_OFFSET,%edi /* initialize page tables */
	movl $007,%eax		/* "007" doesn't mean with right to kill, but
				   PRESENT+RW+USER */
2:	stosl    ; 將EAX值,複製到ES:DI,此時為保護模式狀態,擴充套件段為從0開始的整個4G空間,所以ES:DI從0x2000開始
	add $0x1000,%eax    ; 0x007、0x1007、0x2007 ..
	cmp $empty_zero_page-__PAGE_OFFSET,%edi
	jne 2b

/*
 * Enable paging
 */
/*
 * swapper_pg_dir由.org指定偏移為0x1000,程式將它的地址設定到CR3暫存器中,即用它指向的一頁內容,作為目錄表
 * "80386硬體API"會把CR3"引數"的值,直接當作實體地址,由於此時還沒有開啟頁式管理,仍然需要指令本身從編譯生成的虛擬地址中,減掉__PAGE_OFFSET偏移
 * 然後,將CR0最高位(PG標誌位)設定為1,開啟頁式管理
*/
3:
	movl $swapper_pg_dir-__PAGE_OFFSET,%eax
	movl %eax,%cr3		/* set the page table pointer.. */
	movl %cr0,%eax
	orl $0x80000000,%eax
	movl %eax,%cr0		/* ..and set paging (PG) bit */
/*
 * 以下這條跳轉指令,用於丟棄已經在"cpu的取指令流水線"中的內容(Intel在i386技術資料中的建議)
 * 另外,每執行一條指令,IP暫存器的值,就會加上這條指令的長度,指向下一條指令,到目前為止,IP暫存器都是在0x100000的基礎上加,接下來這條指令的地址大概為0x100XXX,由於上一條指令已經開啟頁式對映,所以這個地址也需要有對映關係,cpu才能得到它的實體地址,其實,稍後就可以看到,目錄表最開始2項,也有初始值,也指向上面建立的2個頁表,從而使得0-8MB虛擬地址也有對映關係,並且可以保持對映前後的值不變,目的就是用於這種過渡期
*/
	jmp 1f			/* flush the prefetch-queue */
1:
/*
 *  編譯後,"1f"標號的地址為0xCXXXXXXX,按照如下指令跳轉一下,IP暫存器的值就是核心空間的地址了,就不用依賴目錄表中最開始的2個目錄項了,另外,引用程式中的變數,也不用依賴指令本身減掉__PAGE_OFFSET,從而完全過渡到頁式管理
*/
	movl $1f,%eax
	jmp *%eax		/* make sure eip is relocated */
1:
	/* Set up the stack pointer */
	lss stack_start,%esp
        pg0、pg1、empty_zero_page位置安排:
/*
 * The page tables are initialized to only 8MB here - the final page
 * tables are set up later depending on memory size.
 */
.org 0x2000
ENTRY(pg0)

.org 0x3000
ENTRY(pg1)

/*
 * empty_zero_page must immediately follow the page tables ! (The
 * initialization loop counts until empty_zero_page)
 */

.org 0x4000
ENTRY(empty_zero_page)
        目錄表初始化內容:
/*
 * This is initialized to create an identity-mapping at 0-8M (for bootup
 * purposes) and another mapping of the 0-8M area at virtual address
 * PAGE_OFFSET.
 */
.org 0x1000
ENTRY(swapper_pg_dir)
        /* 用於0-8MB虛擬地址對映(屬於使用者空間,與核心空間開頭8MB對映到相同的實體地址,臨時用於過渡,最終會被撤消) */
	.long 0x00102007
	.long 0x00103007
        /* 接下來766個目錄項初始化為0 */
	.fill BOOT_USER_PGD_PTRS-2,4,0
	/* default: 766 entries */
        /* 用於 0xC0000000 - 0xC0000000+8MB 核心地址對映 */
	.long 0x00102007
	.long 0x00103007
        /* 接下來254個目錄項初始化為0 */
	/* default: 254 entries */
	.fill BOOT_KERNEL_PGD_PTRS-2,4,0
2. Linux記憶體管理的基本框架
    透過Linux核心對待80386段式管理特性的方式,很容易可以理解,軟體可以根據自己的需要,選擇性的使用硬體特性,比如剪刀一般是用於剪東西,但有些人也會用它松螺絲。換句話說,只要最終能將"硬體API"的"引數"設定正確,保證硬體內部不出錯,將"引數"設定成什麼,以及如何"設定",都由軟體自己決定。
    對於頁式管理特性的利用,Linux核心的做法如下:
Linux核心筆記004 - 從記憶體管理開始,認識Linux核心
  • 雖然看起來有些"特別"
        線性地址到實體地址的對映,80386的硬體邏輯是:線性地址前10位作為索引,到目錄表中找到目錄項,從而找到一張頁表;再根據接著的10位,到頁表中找到而表項,從而找到目標頁;根據最後12位的偏移值,最終在目標頁中對映到一個實體地址。
        那麼,Linux核心在建立對映關係時,也應該將線性地址看成3部分,只不過硬體是使用,軟體是設定,比如線性地址"0x80000000",如果核心按8位、8位、4位、12位劃分,建立對映時,它設定的就是0x80下標的目錄項,而硬體執行對映時,找到的是0x200下標的目錄項,顯然就會是空的或者其它虛擬地址的目錄項。
0x 1000000000 0000000000  000000000000  // 10,10,12
0x 10000000 00000000 0000 000000000000  // 8,8,4,12

  • 但也很容易理解
        首先,4層是邏輯劃分,如果劃分成10,0,10,12,那麼實際劃分仍然是3層,這是可行性;另外,Linux軟體不光只執行在80386上,還要支援其它型號的cpu,包括Intel的其它系列,以及其它廠商的cpu,不光要考慮當下,還要考慮將來,因為cpu都已經從8位發展到32位了,將來勢必會發展到更多位數,邏輯上支援4層,對於不同的cpu,簡單指定下位數的分配就能適應了,這是軟體設計的必要性。

3. 地址對映的全過程

    前面已經學習了10.1節,那麼這部分內容其實就非常好理解了,書裡面拿了一個使用者空間的地址舉例,目前為止,初學者可能還是不能完全體會使用者態和核心態的本質,為了保持節奏,可以先不用擔心這一點,學習完第三章——中斷、異常和系統呼叫,以及第四章——程式與程式排程,自然就能明白了。
    目前為止,只是學習了對映過程,關於記憶體管理的內容還很多:
  • 每個程式有4G虛擬空間,其中0-3G由各個程式獨自使用,一方面數量有上限,也屬於資源,另一方面對映關係還沒有撤消,就不能拿來對映到另外一個實體地址,所以核心要對每個程式的虛擬空間進行管理,另外,實體地址更加需要管理;
  • 虛擬空間又分為棧和堆,區域性變數使用的是棧記憶體,malloc()分配的是堆記憶體,核心對它們的管理,也有區別;
  • 換入換出技術,支援將記憶體的資料,臨時存入交換分割槽,需要時再從磁碟讀回記憶體,也涉及到複雜的管理邏輯。
    所以不要鬆懈,繼續加油!


相關文章