我們知道,在大部分程序運行的時候,幾乎都離不開堆(heap)和棧(stack),所有數據結構的分配也都是在堆和棧上進行的,堆和棧都是建立在內存之上的。
很多時候,內存幾乎對程序員來講是透明的,你只管使用,而不需要對其背後的管理機制做更加深入的瞭解,比如以 Java 爲代表的運行在虛擬機上的語言,都有內存管理器來進行垃圾回收的機制。但是不幸的是,很多時候我們還是會遇到一些內存溢出的問題(out-of-memory),真是聞者傷心,聽者流淚。這個世界沒有無緣無故的傷害,背後肯定是有原因的。
從某種角度來講,操作系統就是內存的加工商,它管理着物理內存,並對它進行一定的規格化加工,然後出售給上層應用,當然它自己有時候也會拿來使用。最後,它還會負責回收不需要的內存。只要我們搞清楚了 Linux 對內存的管理方法,那麼很多問題也就迎刃而解了,其他操作系統只是算法不同,但是思路都是大同小異的。
內存在計算機中應該是個比較重要且特殊的器件,重要是因爲內存是計算機中不可或缺的部件,是和 CPU 進行溝通的橋樑,所有的代碼都得裝載到內存之後才能讓 CPU 通過指令寄存器找到相應地址進行訪問。特殊性體現在以下幾點:
內存資源是稀缺資源,需要特殊管理和對待。
CPU 單獨設計了 MMU(內存管理單元)與內存進行溝通。
內存空間有限,而操作系統不只運行一個進程,直接進行物理地址尋址肯定會出現地址空間不夠的情況,需要專門的方案解決這個問題。
針對內存的管理不僅是 MMU,操作系統針對內存又單獨進行了很多管理的工作。
雖然操作系統已經對內存進行了管理工作,還有很多內存管理的應用程序在用戶態對內存進行管理。
本章我們將探討以下問題:
1)內存在計算機體系結構中的作用,解決什麼問題,如何使用,在使用中會遇到什麼問題,爲什麼需要管理。
2)MMU 本身對內存有管理機制,操作系統爲什麼還要在 MMU 的基礎上進行管理。
3)內存涉及的地址、線性地址空間、物理地址空間、虛擬地址空間之間是什麼關係,如何對應。
4)Linux 是如何進行內存管理的,整體架構如何,以及夥伴算法、slab 分配器、kmalloc、vmalloc、malloc 的實現。
5)Linux 棧內存如何分配,內核棧和線程棧 Linux 又是如何區分和管理的。
6)既然 Linux 內核已經管理了內存,Memcached、Redis 這樣的軟件爲何還要自己管理內存。
3.1 爲什麼需要內存管理
我們已經知道了內存在計算機中重要且特殊的地位,下面我們來分析爲什麼需要對內存進行管理。
首先還是先來研究一下歷史。在早期,計算機只能跑單個進程,就算是批處理程序,也是先來後到進行排隊。所以,對內存的使用也比較簡單,直接把物理地址拿來使用就可以了。
當支持多進程的系統出現之後,這種玩法就不適用了,多個進程都在同一個物理地址空間內玩耍,很容易互相影響而導致崩潰。
當然,可以簡單地把內存平均分成 N 塊,然後每個進程只能使用其中一塊。看起來解決了問題,但是每個進程能使用的物理範圍就很小了。
爲了解決這個問題,CPU 內存管理單元(MMU)幫我們引入了虛擬地址空間的概念,以32位的系統爲例(圖3-1),每個進程都可以擁有 4G 的尋址空間,當在該空間需要物理內存的時候,再通過相應的轉換技術和虛擬地址空間進行關聯。
這樣看起來解決了問題,但是仔細思考一下,又會有幾個問題:
既然物理內存是所有虛擬地址空間共享的,那麼如何分配,如何歸還,這些問題都得想辦法解決。
每次向物理地址申請的內存大小肯定不一樣,多次分配再歸還之後,導致內存碎片嚴重,無法申請到連續空間怎麼辦?
那麼多進程都擁有獨立的地址空間,但是物理地址再大還是有限的,難道不會出現物理地址不夠,然後進程申請不到引起掛掉的情況?
物理內存都是按照頁來組織的,頁的粒度雖然可以配置,但是最小也是 4K,假如應用程序需要的內存小於 4K,那麼不是存在浪費嗎?如何管理小塊內存的申請呢?
以上這4個問題肯定不是一個 MMU 就能搞定的,需要我們在系統層面再抽象一層,單獨進行內存管理的相應工作。
圖3-1 進程虛擬地址與物理地址空間映射
通過以上分析,參照圖3-2,我們可以對 Linux 內存管理體系結構進行如下分層:
內存管理單元(MMU),通過分段分頁的機制,提供虛擬地址到物理地址的映射方法。
段頁機制是 MMU 提供的,Linux 是使用者,搞清楚 Linux 如何進行段頁管理很重要。
Linux 物理地址管理,因爲物理地址空間有限,系統會統一對物理地址進行管理,便於申請和歸還。
Linux 內核態進程之間共享地址空間,如何進行管理?
Linux 用戶態進程之間的地址空間是隔離的,如何進行管理?
結合這個分層架構,本章後續部分具體分析每部分是怎麼做的。
圖3-2 Linux 內存管理體系結構
3.2 MMU 和地址空間
在瞭解操作系統如何進行內存管理之前,必須首先了解硬件(MMU)提供給我們的內存管理機制是怎樣的,管理的邊界能到哪裏。然後我們才能基於這些基本的認知進行深層次的探討。MMU 是 Memory Management Unit 的縮寫,是 CPU 的一部分,用來管理內存的控制線路,提供把虛擬地址映射爲物理地址的能力。
3.2.1 虛擬地址、線性地址、物理地址
在瞭解了什麼是 MMU 之後,理解虛擬地址、線性地址和物理地址這三個概念尤爲重要。在 x86 體系結構下,CPU 對內存的尋址都是通過分段的方式來進行的。在保護模式下對段的概念進行了擴展,一個段可以理解爲:
基地址+段的界限+類型
所以,在保護模式下的偏移就是在這個段中的偏移。
下面我們分別來理解3個地址:
虛擬地址:在段中的偏移地址。
線性地址:在某個段中“基地址+偏移地址”得出的地址。
物理地址:在 x86 中,MMU 還提供了分頁機制,假如未開啓分頁機制,那麼線性地址就等於物理地址;否則,需要經過分頁機制換算後,線性地址才能轉換成物理地址。
注意
保護模式是 Intel CPU 特有的一種工作模式。目的是在 Intel 新系列的產品升級到32位以上系統的時候,對老產品工作模式能兼容。所以老的模式又叫作實模式。
以 Intel 的80386爲例,當工作在實模式下的時候,CPU 最大可用的地址總線爲20位(0~19),因爲像8086這樣的 CPU 地址總線一共就20條,但是80386卻有32條地址總線,假如在實模式下只用20條,那麼最大尋址空間只有 1MB,若要擴大尋址範圍,就要充分利用剩餘的地址總線。這個時候 A20 地址總線(從0開始數第20根)就成爲是否可超越 1MB 尋址的開關。假如在實模式下,A20 地址總線是關閉的,在保護模式下則打開,這樣在保護模式下我們就可以進行 4GB 的尋址。
保護模式下做 IO 操作的時候,eflag 寄存器(見圖3-3)上有2個關鍵位12和13位爲 IOPL。只有當 CPL≤IOPL 的時候纔可以進行 IO 操作。
保護模式概括起來3句話:
1)突破了 1MB 的尋址,對實模式的兼容。
2)對數據和代碼的訪問提供了保護機制。
3)對 IO 的操作提供了保護機制。
總之,關鍵是保護二字。
圖3-3 eflag 寄存器結構
3.2.2 MMU 的內存管理機制
MMU 對內存的管理主要是分段和分頁,下面通過分析幾個問題來了解這兩個機制。
1.段存儲在什麼地方?
因爲一個段是由“基地址+段界限+類型”等數據組成,所以段是由全局描述符表(GDT)中的描述符結構來定義的(見圖3-4)。
注意
其中局部描述符表(LDT)和 GDT 的結構是一樣的,一般和 GDT 表項在一起。
圖3-4 全局描述符表(GDT)結構
GDT 表項的說明如下。
1)P:存在(Present)位。
P=1,表示描述符對地址轉換是有效的,或者說該描述符所描述的段存在,即在內存中。
P=0,表示描述符對地址轉換無效,即該段不存在。使用該描述符進行內存訪問時會引起異常。
2)DPL:描述符特權級(Descriptor Privilege level),共2位。它規定了所描述段的特權級,用於特權檢查,以決定對該段能否訪問。
3)S:說明描述符的類型。對於存儲段描述符而言,S=1,爲系統段描述符;S=0,爲門描述符。
4)TYPE:說明存儲段描述符所描述的存儲段的具體屬性。
數據段類型:
代碼段類型:
系統段類型:
5)G:段界限粒度(Granularity)位。
G=0 表示界限粒度爲字節。
G=1 表示界限粒度爲 4K 字節。
注意,界限粒度只對段界限有效,對段基地址無效,段基地址總以字節爲單位。
6)D:這是一個很特殊的位,在描述可執行段、向下擴展數據段或由 SS 寄存器尋址的段(通常是棧段)的三種描述符中的意義各不同。
a)在描述可執行段的描述符中,D 位決定了指令使用的地址及操作數所默認的大小。
D=1 表示默認情況下指令使用32位地址及32位或8位操作數,這樣的代碼段也稱爲32位代碼段。
D=0 表示默認情況下使用16位地址及16位或8位操作數,這樣的代碼段也稱爲16位代碼段,它不與80286兼容。可以使用地址大小前綴和操作數大小前綴分別改變默認的地址或操作數的大小。
b)在向下擴展數據段的描述符中,D 位決定段的上部邊界。
D=1 表示段的上部界限爲 4GB。
D=0 表示段的上部界限爲 64KB,這是爲了與80286兼容。
c)在描述符由 SS 寄存器尋址的段描述符中,D 位決定隱式的棧訪問指令(如 PUSH 和 POP 指令)使用何種棧指針寄存器。
D=1 表示使用32位棧指針寄存器 ESP。
D=0 表示使用16位棧指針寄存器 SP,這與80286兼容。
7)AVL:軟件可利用位。80386對該位的使用未做規定,Intel 公司也保證今後開發生產的處理器只要不與80386兼容,就不會對該位的使用做任何定義或規定。
上面的描述符定義看起來很複雜,其實主要目標有以下幾點:
指定段的起始地址。
確定段的界限(長度)
確定段的屬性,是否可讀,可寫,段的基本粒度單位,表述數據段還是代碼段等等。
2.段是如何裝載的?
我們通過圖3-5來分析兩種裝載段的場景。
圖3-5 裝載段的場景
1)通過 lgdt 指令,我們把全局描述符表的基地址和界限存入了 GDTR 寄存器(參見圖3-6),假如需要使用指定的某段,那麼段寄存器的 index 可以設置爲你想使用段的選擇子。
注意
下面舉例來說明一個 GDT 描述符:
gdt:.quad 0x0000000000000000
.quad 0x00c09a00000007ff #代碼段
.quad 0x00c09200000007ff #數據段
.quad 0x00c0920b80000002 #顯存段,界限爲2*4k
end_gdt:
.fill 128,4,0
上述代碼分別定義了代碼段、數據段、顯存段。
當需要裝載 GDT 的時候,執行如下命令:
lgdt lgdt_opcode
lgdt_opcode 內容定義如下:
lgdt_opcode:
.word (end_gdt-gdt)-1
.long gdt
圖3-6 GDTR 寄存器格式
2)假如是多個 task 切換的場景,可以通過 TSS(Task-State Stack)來設置指定 ldt 的選擇子,然後等到切換到該 task 的時候,ldtr 就會裝載在 tss 中設置的 LDT 選擇子 TSS 格式參見圖3-7。
圖3-7 TSS 格式
注意
低權限級向高權限級切換的時候,棧也發生了變化,所以 TSS 需要把低權限級的棧複製保存起來。以下是 TSS 數據結構的例子:
tss0: .long 0 // 上一個任務鏈接
.long krn_stk0, 0x10 // esp0, ss0
.long 0, 0, 0, 0, 0 // esp1, ss1, esp2, ss2, cr3
.long 0, 0, 0, 0, 0 // eip, eflags, eax, ecx, edx
.long 0, 0, 0, 0, 0 // ebx esp, ebp, esi, edi
.long 0, 0, 0, 0, 0, 0 // es, cs, ss, ds, fs, gs
.long LDT0_SEL, 0x8000000 /* ldt, trace bitmap 這邊 bitmap 的地址無效,而且
任務也沒有 IO 操作,隨便亂寫也無所謂,具體設置方
法可以參考 INTEL 開發手冊*/
3.分頁機制是怎樣的?
在 x86 系統中,MMU 支持多級的分頁模型,分爲三種情況:
32位系統,則爲2級分頁模型。
32位系統開啓了物理地址擴展模式(PAE),則爲3級分頁模型。
64位系統,則爲4級分頁模型。
我們以32位系統爲例(見圖3-8)來說明分頁機制的原理。還是通過問題出發:
1)分頁機制如何開啓?
80x86的分頁機制由 CR0 中的 PG 位開啓。如 PG=1,開啓分頁機制,並使用本節要描述的機制,把線性地址轉換爲物理地址。如 PG=0,禁用分頁機制,直接把前面段機制產生的線性地址當作物理地址使用。
2)線性地址如何組織?
32位的線性地址分爲三個部分:
22~31位指向頁目錄表中的某一項,頁目錄表中每一項存有4字節(32位)的地址,指向頁表。所以頁目錄表的大小是4*2^10=4K。
12~21位指向頁表中的某一項,頁表的大小和也目錄表一樣,也是 4K。
一個物理頁爲 4K。剛好0~11位指向頁表中的偏移,一個頁表正好爲 4K(2^12)。
圖3-8 32位系統分頁機制
3)頁目錄表和頁表存在哪裏?
頁目錄表和頁表可以存在內存的任何地方。當分頁機制開啓之後,需要讓 CR3 寄存器指向頁目錄表的起始地址,這樣整個分頁系統就可以正常進行工作了。
注意
CR0~CR4 這幾個寄存器是系統內的控制寄存器,與分頁機制密切相關,在進程管理及虛擬內存管理中會涉及這幾個寄存器,讀者要記住 CR0、CR2、CR3 及 CR4 這三個寄存器的內容。
CR0 控制寄存器是一些特殊的寄存器,可以控制 CPU 的一些重要特性,例如:
第0位是保護允許位(Protected Enable,PE),用於啓動保護模式,如果 PE=1,則啓動保護模式;如果 PE=0,則啓動實模式。
第1位是監控協處理位(Monitor coprocessor,MP),它與第3位一起決定:當 TS=1 時操作碼 WAIT 是否產生一個“協處理器不能使用”的出錯信號。
第3位是任務轉換位(Task Switch,TS),當一個任務轉換完成之後,TS=1,就不能使用協處理器。
第2位是模擬協處理器位(Emulate coprocessor,EM),如果 EM=1,則不能使用協處理器;如果 EM=0,則允許使用協處理器。
CR1 是未定義的控制寄存器,供將來的處理器使用。
CR2 是頁故障線性地址寄存器,保存最後一次出現頁故障的全32位線性地址。
CR3 是頁目錄基址寄存器,保存頁目錄表的物理地址,頁目錄表總是放在以 4K 字節爲單位的存儲器邊界上,因此,它的地址的低12位總爲0,不起作用,即使寫上內容,也不會被理會。
CR4 在 Pentium 系列(包括486的後期版本)處理器中才實現,處理的事務包括何時啓用虛擬8086模式等。
3.3 Linux 中的分段和分頁機制
在瞭解了 MMU 的工作原理之後,我們知道了其核心功能就是分段和分頁。下面我們來了解 Linux 如何利用 MMU 進行內存管理的。
3.3.1 分段機制
上一節我們已經瞭解了 MMU 在保護模式下的分段數據主要定義在 GDT 中,那麼我們先來看 Linux 中的 GDT 定義:
DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {
…
[GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xc09a, 0, 0xfffff),
[GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff),
[GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f2, 0, 0xfffff),
…
} };
EXPORT_PER_CPU_SYMBOL_GPL(gdt_page);
我們重點關注代碼段和數據段:
[GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xc09a, 0, 0xfffff),
[GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
這裏段都是通過 GDT_ENTRY_INIT 來定義的:
struct desc_struct {
union {
struct {
unsigned int a;
unsigned int b;
};
struct {
u16 limit0;
u16 base0;
unsigned base1: 8, type: 4, s: 1, dpl: 2, p: 1;
unsigned limit: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8;
};
};
#define GDT_ENTRY_INIT(flags, base, limit) { { { \
.a = ((limit) & 0xffff) | (((base) & 0xffff) << 16), \
.b = (((base) & 0xff0000) >> 16) | (((flags) & 0xf0ff) << 8) | \
((limit) & 0xf0000) | ((base) & 0xff000000), \
} } }
結合圖3-4中 GDT 結構的描述,我們可以得出如下結論:
內核代碼段的 flags 爲 0xc09a。
內核數據段的 flags 爲 0xc092。
a 的二進制爲1010說明 type 位爲代碼段。
2的二進制位0010說明 type 位爲數據段。
用戶數據段爲:0xc0f3,f3 轉換成二進制是11110011,說明是數據段,並且 DPL=3。
另外我們發現,這些段的基地址都是0,界限爲 4G。說明 Linux 只定義了一個段,並沒有真正利用分段機制,只是裝裝樣子糊弄一下硬件而已。
3.3.2 分頁機制
因爲 Linux 中只用了一個段,而且基地址從0開始,那麼在程序中使用的虛擬地址就是線性地址了,至於線性地址到物理地址的轉換就交給分頁機制來完成了。Linux 爲了兼容32位、64位系統,以及32位 PAE 擴展的情況,在代碼中通過4級分頁機制來做兼容(參見圖3-9)。
下面介紹幾個概念:
PGD:頁全局目錄,對應32位系統中的頁目錄號。
PUD:頁上級目錄,一般在64位系統中使用。
PMD:頁中間目錄,一般在開啓PAE功能後使用。
PTE:頁表項,對應32位系統中的頁號。
OFFSET:對應32位系統中的頁面偏移量。
注意
從 Pentiun Pro 處理器開始,Intel 引入一種叫作物理地址擴展(Physical Address Extension,PAE)的機制。通過設置 CR4 控制寄存器中的物理地址擴展(PAE)標誌,頁目錄項中的頁大小標誌 PS 啓用大尺寸頁(在 PAE 啓用時爲 2MB)。
假如一個32位的線性地址爲 0x08147258,換成二制進如下所示:
0000100000 0101000111 001001011000
在4級分頁機制下分別對應如下:
PGD = 0000100000
PUD = 0
PMD = 0
PTE = 0101000111
offset = 001001011000
這樣就很好地兼容了32位系統。
圖3-9 Linux 4級別分頁機制
爲了便於後續的計算和操作,Linux 又提出瞭如下三個概念(見圖3-10):
SHIFT
SIZE
MASK
圖3-10 SHIFT、SIZE、MASK 概念說明
以64位系統的 PGD 爲例子:
arch/x86/include/asm/pgtable_64_types.h
#define PGDIR_SHIFT 39
#define PTRS_PER_PGD 512
#define PGDIR_SIZE (_AC(1, UL) << PGDIR_SHIFT)
#define PGDIR_MASK (~(PGDIR_SIZE - 1))
比如要計算 PGD 對應的全局頁目錄表項的線性地址,計算方法如下:
#define pgd_index(address) (((address) >> PGDIR_SHIFT) & (PTRS_PER_PGD-1))
#define pgd_offset(mm, address) ((mm)->pgd+pgd_index(address))
其他關於 PUD、PMD、PTE 等計算也是大同小異,可以結合代碼對比分析。
3.4 Linux 的內存管理
現在,瞭解了 Linux 分段和分頁機制以及虛擬地址、線性地址和物理地址轉換的知識後,就可以去分析 Linux 如何進行內存管理了。
注意
因爲在 Linux 中虛擬地址就是線性地址,所以後面章節統一用線性地址這個概念,不再用虛擬地址這個概念了。
3.4.1 物理內存管理
不管線性地址如何擴展,真正的物理內存是有限的,假如我是 Linux 開發者,肯定會在系統啓動之後,先把物理內存統一放到一個地方(比如 map)管理和維護起來。
在這之前,我們先了解兩個概念:UMA 和 NUMA(如圖3-11所示)。
圖3-11 UMA 與 NUMA
我們經常使用的 SMP(Symmetric Multi-Processor)多核 CPU,將多個處理器與一個集中的存儲器和 I/O 總線相連。所有處理器只能訪問同一個物理存儲器,因此 SMP 系統有時也稱爲一致性存儲器訪問(UMA)體系結構,一致性意指無論在什麼時候,處理器只能爲內存的每個數據保持或共享唯一一個數值。很顯然,SMP 的缺點是可伸縮性有限,因爲在存儲器和 I/O 接口達到飽和的時候,增加處理器並不能獲得更高的性能。
與 SMP 對應的有 AMP 架構,不同核之間有主從關係,如一個核控制另外一個核的業務,可以理解爲多核系統中控制平面和數據平面。
NUMA 模式是一種分佈式存儲器訪問方式,處理器可以同時訪問不同的存儲器地址,大幅度提高了並行性。NUMA 模式下,處理器被劃分成多個“節點”(node),每個節點被分配了本地存儲器空間。所有節點中的處理器都可以訪問全部的系統物理存儲器,但是訪問本節點內的存儲器所需要的時間,比訪問某些遠程節點內的存儲器所花的時間要少得多。
NUMA 系統(尤其是具有超過八個 CPU 的系統)通常比一致性內存訪問系統更加經濟且性能更高。一致性內存訪問系統必須平等地爲所有 CPU 提供內存,而 NUMA 系統則能夠爲直接連接到 CPU 的內存提供高速互連,同時爲與 CPU 相隔較遠的內存提供較爲便宜但更高延遲的連接,爲能在 NUMA 系統中有效擴展,操作系統或應用程序必須瞭解節點拓撲結構,以便使計算過程能夠在包含計算數據和代碼的內存附近執行。
NUMA 的主要優點是可伸縮性。NUMA 體系結構在設計上已超越了 SMP 體系結構在伸縮性上的限制。通過 SMP,所有的內存訪問都傳遞到相同的共享內存總線。這種方式非常適用於 CPU 數量相對較少的情況,但不適用於具有幾十個甚至幾百個 CPU 的情況,因爲這些 CPU 會相互競爭對共享內存總線的訪問。NUMA 通過限制任何一條內存總線上的 CPU 數量並依靠高速互連來連接各個節點,從而緩解了這些瓶頸狀況。
因爲非一致性存儲架構的存在,內存可能不是單一的一個節點,爲了同時兼容 NUMA 和 UMA 架構的處理器,Linux 對物理內存的管理組織如圖3-12所示。
圖3-12 Linux 物理內存管理架構
1.pg_data
pg_data 代表一個存儲節點,在 NUMA 存儲結構下可能會存在多個 pg_data 結構。
該結構定義爲:
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES]; // 該節點管理的 zone,ZONE_DMA、
// ZONE_NOMAL,和 ZONE_HIGHMEM
struct zonelist node_zonelists[MAX_ZONELISTS];
// 按分配時的順序排列的 zone 列表
int nr_zones; // zone 總數
struct page *node_mem_map; // 指向該節點中的第一個頁面
int node_id; // 節點 id
unsigned long node_start_pfn; // 該節點起始物理頁框
unsigned long node_present_pages; // 該節點總共的物理頁面數量
unsigned long node_spanned_pages; // 該節點總共的物理頁面數量,包含空洞
…
} pg_data_t;
2.zone
在一個存儲節點下包含了 ZONE_DMA、ZONE_NOMAL 和 ZONE_HIGHMEM 三個管理區,其中:
ZONE_DMA 的範圍是 0~16M,該區域的物理頁面專門供 I/O 設備的 DMA 使用。之所以需要單獨管理 DMA 的物理頁面,是因爲 DMA 使用物理地址訪問內存,不經過 MMU,並且需要連續的緩衝區。爲了能夠提供物理上連續的緩衝區,必須從物理地址空間專門劃分一段區域用於 DMA。
ZONE_NORMAL 的範圍是 16M~896M,該區域的物理頁面是內核能夠直接使用的。
ZONE_HIGHMEM 的範圍是 896M~結束,該區域即爲高端內存,內核不能直接使用。
該結構定義爲:
struct zone {
…
#ifdef CONFIG_NUMA
int node; // 對應的管理節點 id
#endif
struct pglist_data *zone_pgdat; // 該區域對應的 pgdat 指針
unsigned long zone_start_pfn; // 該區域起始的物理頁框
…
struct free_area free_area[MAX_ORDER]; // 該區域物理頁空閒區域位圖,由夥伴系統使用
unsigned long spanned_pages; // 該區域中頁的總數,但並非所有的都可用,因爲有空洞
unsigned long present_pages; // 該區域中實際上可用的頁數目
…
const char *name; // 保存該內存域的慣用名稱,目前有3個選項可用:
NORMAL、DMA、HIGHMEM
} ____cacheline_internodealigned_in_smp;
3.page
page 代表每一個物理頁面,內核用數據結構 page 描述一個頁框的狀態信息,所有的頁描述符存放在 pgdata 的 node_mem_map 數組中,其數組的下標爲頁框號(pfn)。
該結構定義爲:
struct page {
unsigned long flags; // 這些標誌位用於描述頁面的狀態
atomic_t _count; // 頁面使用計數器
…
struct address_space *mapping; // 物理頁映射的線性地址空間
…
};
我們再來提個問題,內核如何把這些物理頁的信息獲取到,並且初始化 pg_data 這個結構的呢?
1)系統會在啓動的時候調用 detect_memory 函數,探測可用內存佈局:
// 本方法用於檢測內存可用大小和可用的區域
// 通過 int 0x15 BIOS 中斷來獲取內存參數
int detect_memory(void )
{
int err = -1;
if (detect_memory_e820() > 0)
err = 0;
if (!detect_memory_e801())
err = 0;
if (!detect_memory_88())
err = 0;
return err;
}
注意
探測一個 PC 機內存的最好方法是通過調用 INT 0x15,eax=0xe820 來實現。這個功能在2002年以後被所有 PC 機使用,這是唯一能夠探測超過 4GB 大小內存的方案,當然,也可以認爲這個方法是內存的最終檢測方法。
實際上,這個函數返回一個非排序列表,這個列表包含了那些沒有使用的項,並且可能返回存在覆蓋的區域。在 Linux 中每個列表項存放在 ES:EDI 指定的內存區域中,每個項均有一定的格式:即2個8字節字段,一個2字節字段。我們前面看見了,對於內存探測的實現由函數 detect_memory_e820 來實現,在這個函數中,使用了一個 do...while()循環來實現,並將所探測的內容寫入 boot_params.e820_map 數組中。
e820_map 中保存的數據結構爲:struct e820entry。該結構用來保存一個物理內存段的地址信息以及類型:
struct e820entry {
__u64 addr; // 該內存段的起始地址
__u64 size; // 該內存段的大小
__u32 type; // 該內存段的類型
} __attribute__((packed));
2)內核通過 start_kernel()->paging_init()->free_area_init_node()這個調用過程做了如下事情:
paging_init:初始化 pglist_data,初始化 zone,初始化 page 數據結構。
free_area_init_node:函數將內存節點各個域做相應的初始化,並初始化 page 數據結構。
3.4.2 進程地址空間管理
1.進程地址空間劃分
我們現在已經知道了 Linux 如何識別物理內存,並且對它進行管理。但是,我們在程序中直接操作的是線性地址,如何和物理地址進行轉換呢?
我們在第1章介紹進程的時候就已經知道,每個進程的地址空間是獨立的。內核是所有進程共享的,在內核態共享地址空間。
我們以32位的 x86 實現爲例(見圖3-13),Linux 給每個進程分配的線性地址空間都是 0~4GB。其中:
0~3GB 用於用戶態空間使用。
3GB~3GB+896MB 映射到物理地址的 0~896MB 處,作爲內核態空間。
3GB+896MB~4GB 之間的 128MB 空間,用於 vmalloc 保留區域,該區域用於 vmalloc、kmap 固定地址映射等功能,可以讓內核訪問高端物理地址空間。
內存的 0~8MB 之間保存了內核映象。
圖3-13 Liunx 進程線性地址空間管理
2.進程線性地址相關數據結構
進程的地址空間由 mm_struct 來描述,一個進程只會有一個 mm_struct:
struct mm_struct {
struct vm_area_struct *mmap; // 虛擬地址區間列表
struct rb_root mm_rb; // 用於虛擬地址區間查找的紅黑樹
…
pgd_t * pgd; // pgd 指針
atomic_t mm_users; // 訪問用戶空間的總用戶數
atomic_t mm_count; // 用戶使用計數器
atomic_long_t nr_ptes; // 頁表頁面總數
…
int map_count; // 正在被使用的 VMA 數量
…
struct list_head mmlist; // 所有的 mm_struct 都通過它鏈接在一起
…
unsigned long total_vm; // 所有 vma 區域加起來的內存綜合
…
unsigned long start_code, end_code, start_data, end_data;
// 代碼段起始地址,結束地址,數據段起始地址,結束地址
unsigned long start_brk, brk, start_stack;
// 堆起始地址,結束地址,棧起始地址
unsigned long arg_start, arg_end, env_start, env_end;
// 命令行參數起始地址和結束地址,環境變量起始地址和結束地址
…
};
在 mm_struct 中維護了所有虛擬地址空間的虛擬內存區域 vm_area_struct:
struct vm_area_struct {
unsigned long vm_start; // 該虛擬地址空間區域起始地址
unsigned long vm_end; // 該虛擬地址空間區域結束地址
struct vm_area_struct *vm_next, *vm_prev; // 下一塊虛擬地址空間區域,上一塊虛擬地址
空間區域
struct rb_node vm_rb; // 虛擬地址空間區域也維護了一顆紅黑樹
…
struct mm_struct *vm_mm; // 該地址空間所屬的 mm_struct
…
};
圖3-14描述了上述結構之間的關係,要讓線性地址空間有效,必須要設置分頁機制,mm->pgd 指向了 cr3 寄存器設置的全局頁目錄表起始地址,mm_struct 結構維護了進程下面的線性地址空間區域。
圖3-14 進程虛擬地址空間數據結構
注意
mm_struct 線性地址空間只有用戶線程才需使用,內核線程不需要,因爲內核態是共享的,不會發生缺頁或者訪問用戶空間。所以內核線程的 task_struct->mm 爲 NULL。
3.地址相關的重要概念
在瞭解了線性地址空間相關的數據結構之後,我們再通過幾個問題來整理幾個重要概念,便於後續理解。
1)內核線性地址如何找到物理地址?
在32位內核中,線性地址爲 3~4G 之間,並且和物理地址 0~1G 之間是直接對應的。所以內核的線性地址和物理地址的轉換關係爲:
#define __pa(x) ((unsigned long) (x) - PAGE_OFFSET) // 線性地址轉物理地址
#define __va(x) ((void *)((unsigned long) (x) + PAGE_OFFSET)) // 物理地址轉線性
地址
2)內核物理地址如何找到頁面(page 結構)?
在內核中,全局維護了一份物理地址頁面數組 vmem_map,所以只要獲得頁號(pfn)就能得到頁面結構,下面是一組系統提供的轉換頁號的宏定義:
pa(kaddr) >> PAGE_SHIFT 計算得到 pfn
# define pfn_to_page(pfn) (vmem_map + (pfn)) // 根據 pfn 得到 page
# define page_to_pfn(page) ((unsigned long) (page - vmem_map))// page 得到 pfn
# define __pfn_to_phys(pfn) PFN_PHYS(pfn) // 頁號轉物理地址,可以先通過頁號轉換成線性地址,然後減去 PAGE_OFFSET 就是物理地址了。大家可以自己去代碼中驗證
3)頁表什麼時候設置?
頁表的分配分爲兩個部分:
內核頁表,也就是系統在啓動中,最後會在 paging_init 函數中,把 ZONE_DMA 和 ZONE_NORMAL 區域的物理頁面與線性地址空間的 3G~3G+896M 進行直接映射。
內核高端地址(比如 vmalloc)和用戶態地址,都是通過 MMU 機制修改線性地址和物理地址的映射關係,然後刷新頁表緩存來達到目的的。
4)爲何用 TLB?
TLB(Translation Lookaside Buffer,轉換檢測緩衝區)是一個內存管理單元,用於改進虛擬地址到物理地址轉換速度的緩存。線性地址到物理地址每次轉換都需要通過多級分頁機制來轉換,開銷較大,所以 MMU 提供了後援緩衝區 TLB 來緩存這個映射關係,沒必要每次都映射了。由於內核的線性地址空間是固定的,映射的物理地址空間也是固定的,沒必要每次進程切換都刷新 TLB。所以 task_struct->active_mm 結構就是爲此增加的,每次進程切換到內核進程都是用前一個任務的 mm 來設置 active_mm。
5)頁表緩存的作用是什麼?
頁表的數據緩存到 CPU 的一級緩存當中,目的是提升性能。