1. 程式人生 > >深入理解 Linux 核心---程序地址空間

深入理解 Linux 核心---程序地址空間

講述:
程序是怎樣看待動態記憶體的。
程序空間的基本組成。
缺頁異常處理程式在推遲給程序分配頁框中所起的作用。
核心怎樣建立和刪除程序的整個地址空間。
與程序的地址空間管理有關的 API 和系統呼叫。

程序的地址空間

程序的地址空間由允許程序使用的全部線性地址組成。
每個程序看到的線性地址集合是不同的。
核心可通過增加或刪除某些線性地址區間來動態地修改程序的地址空間。

核心通過線性區來表示線性地址區間,由起始線性地址、長度和一些訪問許可權描述。
為效率起見,起始地址和長度都必須是 4096 的倍數,這樣線性區的資料就可以完全填滿分配給它的頁框。
下面是獲得新線性區的一些典型情況:

  • 使用者在控制檯輸入一條命令時,shell 程序建立一個新程序區執行該命令,一組線性區會分配給新程序。
  • 正在執行的程序可能會裝入一個完全不同的程式,程序識別符號保持不變,但該程式使用的線性區會替換。
  • 正在執行的程序可能對一個檔案(或它的一部分)執行”記憶體對映“,核心會給該程序分配一個新的線性區來對映該檔案。
  • 程序可能持續向它的使用者態堆疊增加資料,直到對映該堆疊的線性區用完,這時,核心也許會擴充套件該線性區的大小。
  • 程序可能建立一個 IPC 共享線性區來與其他何作程序共享資料,這時,核心會給該程序分配一個新的線性區。
  • 程序可能通過呼叫類似 malloc() 的函式擴充套件自己的動態區(堆),這時,核心可能決定擴充套件分配給該堆的線性區。

確定一個程序當前所擁有的線性區(即程序的地址空間)是核心的基本任務,因為可讓缺頁異常處理程式有效地區分兩種不同的無效線性地址:

  • 由程式設計錯誤引發的無效線性地址。
  • 由缺頁引發的無效線性地址:即使該線性地址屬於程序的地址空間,但對應於該地址的頁框仍有待分配。

記憶體描述符

記憶體描述符包含與程序地址空間有關的全部資訊,該結構型別為 mm_struct,程序描述符的 mm 欄位指向它。

所有的記憶體描述符存放在一個雙向連結串列。
每個描述符的 mmlist 欄位存放連結串列相鄰元素的地址。
連結串列的第一個元素是 init_mm 的 mmlist 欄位,init_mm 是初始化階段程序 0 所使用的記憶體描述符。
mmlist_lock 自旋鎖保護多處理器系統堆連結串列的同時訪問。

mm_users 欄位存放共享 mm_struct 資料結構的輕量級程序的個數。
mm_count 欄位是記憶體描述符的主使用計數器,值為 0 時,解除該記憶體描述符。
mm_users 次使用計數器中的所有使用者在 mm_count 中值作為一個單位。

如果核心向確保記憶體描述符在一個長操作的中間不被釋放,應該增加 mm_users 欄位而不是 mm_count 欄位的值,最終結果是相同的。

mm_alloc() 獲得一個新的記憶體描述符。
由於記憶體描述符被儲存在 slab 分配器快取記憶體中,因此,mm_alloc() 呼叫 kmem_cache_alloc() 初始化新的記憶體描述符,並將 mm_count 和 mm_users 都設為 1。

mmput() 遞減記憶體描述符的 mm_users 欄位。
如果 mm_users 變為 0,釋放區域性描述符表、線性區描述符、由記憶體描述符所引用的頁表,並呼叫 mmdrop()。
mmdrop() 將 mm_count 欄位減 1,如果變為 0,釋放 mm_struct 資料結構。

核心執行緒的記憶體描述符

核心執行緒僅執行在核心態,因此擁有不會訪問低於 TASK_SIZE 的地址。
與普通程序相反,核心執行緒不使用線性區,因此記憶體描述符的很多欄位對核心執行緒無意義。

因為大於 TASK_SIZE 線性地址的相應頁表項應該總是相同的,所以,一個核心執行緒使用什麼樣的頁表都可以。
為了避免無用的 TLB 和快取記憶體重新整理,核心執行緒使用一組最近執行的普通程序的頁表。
因此,每個程序描述符中包含了兩種記憶體描述符指標:mm 和 active_mm。

程序描述符中的 mm 欄位指向程序所擁有的記憶體描述符,active_mm 欄位指向程序執行時所使用的記憶體描述符。
對於普通程序,這兩個欄位存放相同的指標。
但對於核心執行緒,不擁有記憶體描述符,mm 欄位總為 NULL。
核心執行緒執行時,active_mm 欄位被初始化為前一個執行程序的 active_mm 值。

然而,事情更復雜,只要處於核心態的一個程序為”高階“線性地址(高於 TASK_SIZE) 修改了頁表項,那麼,它也應當更新系統中所有程序頁表集合中的相應表項。
但觸及所有程序的頁表集合比較費時,因此,Linux 採用一種延遲方式。

延遲方式:每當一個高階地址必須被重新對映時(一般是通過 vmalloc() 或 vfree()),核心就更新根目錄在 swapper_pg_dir 主核心頁全域性目錄中的常規頁表集合。
該頁全域性目錄由主記憶體描述符的 pgd 欄位所指向,主記憶體描述符存放於 init_mm 變數。

線性區

vm_area_struct 物件實現線性區。

每個線性區描述符表示一個線性地址區間。
vm_start 欄位包含區間的第一個線性地址。
vm_end 欄位包含區間之外的第一個線性地址。
vm_mm 欄位指向擁有該區間的程序的 mm_struct 記憶體描述符。

程序所擁有的線性區從不重疊,且核心盡力把新分配的線性區與緊鄰的現有線性區進行合併。
如果兩個相鄰區的訪問許可權相匹配,就能把它們合併在一起。
在這裡插入圖片描述
vm_ops 欄位指向 vm_operations_struct 資料結構,該結構中存放的是線性區的方法。

線性區資料結構

程序所擁有的所有線性區是通過一個簡單的連結串列連結在一起的。
連結串列中的線性區按照記憶體地址升序排列,但每兩個線性區可由未用的記憶體地址區隔開。
vm_area_struct 的 vm_next 欄位指向連結串列的下一個元素。
核心通過程序的記憶體描述符的 mmap 欄位指向連結串列中的第一個線性區描述符。

記憶體描述符的 map_count 欄位存放所有程序所擁有的線性區數目。
預設,一個程序最多可擁有 65536 個不同的線性區,可通過 /proc/sys/vm/max_map_count 檔案修改該限定值。

在這裡插入圖片描述
但是,當程序的線性區非常少時,比如一二十個,使用連結串列才比較法方便,為提高效率,存放程序的線性區時,Linux 既使用了連結串列,也使用了紅黑樹。
這兩種資料結構包含指向同一線性區描述符的指標,當插入或刪除一個線性區描述符時,核心通過紅黑樹搜尋前後元素,並用搜尋結果快速更新連結串列而不用掃描連結串列。

紅黑樹的首部由記憶體描述符的 mm_rb 欄位指向。
任何線性區物件都在在型別為 rb_node 的 vm_rb 欄位中存放節點顏色以及指向雙親、左孩子和右孩子的指標。

一般,紅黑樹用來確定含有指定線性地址的線性區,而連結串列通常用於掃描整個線性區集合。

線性區訪問許可權

頁和線性區之間的關係:每個線性區都由一組號碼連續的頁構成。

與頁有關的標誌:

  • 由 80x86 硬體用來檢查能否指向所請求的定址型別,如 Read/Write,Present 或 User/Supervisor。
  • 由 Linux 用於許多不同的目的,flags 欄位中的一組標誌。
  • 與線性區的頁相關的標誌,存放在 vm_area_struct 描述的 vm_flags 欄位。一些標誌給核心提供有關該線性區全部頁的資訊,如包含有什麼內容,程序訪問每個頁的許可權是什麼。另外的標誌描述線性區自身,如應如何增長。

線性區描述符所包含的頁訪問許可權可任意組合。
頁訪問許可權表示何種型別的訪問應該產生一個缺頁異常。

頁表標誌的初值存放在 vm_area_struct 描述符的 vm_page_prot 欄位。

不能把線性區的訪問許可權直接轉換成頁保護位,因為

  • 某些情況下,即使 vm_flags 欄位允許對該頁訪問,但訪問時還是會產生一個缺頁異常,如”寫時複製“。
  • 80x86 處理器的頁表僅有兩個保護位,即 Read/Write 和 User/Supervisor 標誌。線性區的頁的 User/Supervisor 標誌總為 1,因為使用者態程序必須總能訪問該其中的頁。
  • 啟用 PAE 時,所有 64 位頁表項支援 NX 標誌。

如果核心沒有被編譯成 PAE,採用以下規則克服 80x86 微處理器的硬體限制:

  • 讀訪問許可權總是隱含著執行訪問許可權,反之亦然。
  • 寫訪問許可權總是隱含著讀訪問許可權。

但如果核心被編譯成支援 PAE,且 CPU 有 NX 標誌,採用不同的規則:

  • 執行訪問許可權總是隱含著讀訪問許可權。
  • 寫訪問許可權總是隱含著讀訪問許可權。

根據以下規則精簡由讀、寫、執行核共享訪問許可權的 16 種可能組合:

  • 如果頁具有寫和共享兩種訪問許可權,Read/Write 位被設定為 1。
  • 如果頁具有讀或執行訪問許可權,但沒有寫或共享訪問許可權,則 Read/Write 位被清 0。
  • 如果支援 NX 位,且頁沒有執行訪問許可權,則把 NX 位設定為 1.
  • 如果頁沒有任何訪問許可權,Present 位被清 0,以便每次訪問都產生一個缺頁異常。但為了與真正的頁框不存在的情況區分,Page size 置 1。

訪問許可權的每種組合所對應的精簡後的保護位存放在元素個數為 16 的 protection_map 陣列中。

線性區的處理

對線性區描述符進行操作的底層函式可被看作簡化了的 do_map() 和 do_unmap()。
但函式所處的層次更高一些,它們的引數不是線性區描述符,而是一個線性地址區間的起始地址、長度和訪問許可權。

查詢給定地址的最鄰近區:find_vma()

引數:

  • 程序記憶體描述符的地址 mm
  • 線性地址 addr

它查詢第一個 vm_end 欄位大於 addr 的線性區,並返回該線性區描述符的地址。

記憶體描述符的 mmap_cache 欄位儲存程序最後一次引用線性區的描述符地址。
該附加欄位時為了減少查詢一個給定線性地址所線上性區而花費的時間。

// 函式一開始就檢查 mmap_cache 所指定的線性區是否包含 addr
// 如果是,就返回該線性區描述符的指標
vma = mm->mmap_cache;
if(vma && vma->vm_end > addr && vma->vm_start <= adddr)
	return vma;

// 否則,必須掃描程序的線性區,並在紅黑樹中查詢線性區
rb_node = mm->mm_rb.rb_rbnode;
vma  = NULL;
while(rb_node)
{
	// 從指向紅黑樹中的一個節點的指標匯出相應線性區描述符的地址
	vma_tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);  

	if(vmap_tmp->vm_end > addr)
	{
		vma = vma_tmp;
		if(vma_tmp->vm_start <= addr)
			break;
		rb_node = rb_node->rb_left;
	}
	else
		rb_node = rb_node->rb_right;
}
if(vma)
	mm->mmap_cache = vma;
return vma;

find_vma_prev() 與 find_vma() 類似,但它把函式選中的前一個線性區描述符的指標賦給附加欄位 ppre。

最後,find_vma_prepare() 確定新葉子節點在與給定線性地址對應的紅黑樹中的位置,並返回前一個線性區的地址和要插入的葉節點的父節點地址。

查詢一個與給定的地址區間相重疊的線性區:find_vma_intersection()

查詢與給定的線性地址區間重疊的第一個線性區。

引數:

  • mm 引數指向程序的記憶體描述符。
  • 線性地址 start_addr 和 end_addr 指定該區間。
// 如果返回一個有效的地址,但所找到的線性區位於給定線性區之後,vma 就被置為 NULL
vma = find_vma(mm, start_addr); 
if(vma && end_addr <= vma->vmstart)
	vma = NULL;
return vma;

查詢一個空閒的地址區間:get_unmapped_area()

搜查程序的地址空間以找到一個可以使用的線性地址區間。

引數:

  • len,指定區間的長度
  • addr,從哪個地址開始查詢

如果查詢成功,返回新區間的起始地址;否則,返回錯誤碼 -ENOMEM。

addr 不等於 NULL 時,檢查 addr 是否在使用者態空間,並與頁邊界對齊。
根據線性地址區間是用於檔案記憶體對映,還是匿名記憶體對映,呼叫兩個方法:

  • 前一種情況下,執行 get_unmapped_area 檔案操作。
  • 後一種情況下,執行記憶體描述符的 get_unmapped_area 方法。
    根據程序線性區型別,由 arch_get_unmapped_area() 或 arch_get_unmapped_area_topdown() 實現 get_unmapped_area方法。
    通過呼叫 mmap(),每個程序可能獲得兩種不同形式的線性區:一種從線性地址 0x40000000 向高階地址增長,另一種從使用者態堆疊開始向低端地址增長。

arch_get_unmapped_area() 分配從低端地址向高階地址的線性區:

 // 檢查區間的長度是否在使用者態下線性地址區間的限長 TASK_SIZE 之內
if(len > TASK_SIZE) 
	return -ENOMEM;
	
addr = (addr + 0xfff) & 0xfffff000;  // 調整為 4KB 的倍數
if(addr && addr + len <= TASK_SIZE)
{
	vma = find_vma(current->mm, addr);  // 試圖從 addr 開始分配區間

	// 找到一個足夠大的空閒區,返回 addr
	if(!vma || addr + len <= vma->vm_start)
		return addr;
}

// addr == NULL 或前面的搜尋失敗
// 掃描使用者態線性地址空間以查詢一個可以包含新區的足夠大的線性地址範圍
// 但任何已有的線性區都不包含該地址範圍
// 為提高搜尋速度,從最近被分配的線性區後面的地址開始
// 把記憶體描述符欄位 mm->free_area_cache 初始化為使用者態線性地址空間的三分之一(通常為 1GB)
// 並在以後建立新線性區時對齊更新
// 使用者態線性地址空間的三分之一是為有預定義起始線性地址的線性區保留的
start_addr = addr = mm->free_area_cache;
for(vma = find_vma(current->mm, addr); ; vma = vma->vm_next) 
{
	// 如果所請求的區間大於待掃描的線性地址空間部分
	// 就從使用者態地址空間的三分之一處重新搜尋
	if(addr + len > TASK_SIZE)
	{
		// 如果已經完成第二次搜尋,返回 -ENOMEM
		if(start_addr == (TASK_SIZE/3 + 0xfff) & 0xfffff000)
			return -ENOMEM;
			
		start_addr = addr = (TASK_SIZE/3 + 0xfff) & 0xfffff000;
		vma = find_vma(current->mm, addr);
	}
	
	// 找到一個足夠大的空閒區,返回 addr
	if(!vma || addr + len <= vma->vm_start)
	{
		mm->free_area_cache = addr + len;
		return addr;
	}

	// 剛剛掃描過的線性區後面的空閒區太小,繼續考慮下一個線性區
	addr = vma->vm_end;
}

向記憶體描述符連結串列中插入一個線性區:insert_vm_struct()

線上性區物件連結串列和記憶體描述符的紅黑樹中插入一個 vm_area_struct 結構。

引數:

  • mm 指定程序描述符地址
  • vmp 指定要插入的 vm_area_struct 物件的地址。線性區物件的 vm_start 和 vm_end 欄位已經初始化過。

呼叫 find_vma_prepare() 在紅黑樹 mm->mm_rb 中查詢 vma 應該位於何處。

然後,insert_vm_struct() 呼叫 vma_link() 執行以下操作:

  1. 在 mm->mmap 指向的連結串列中插入線性區。
  2. 在紅黑樹 mm->mmrb 中插入線性區。
  3. 如果線性區是匿名的,就把它插入以相應的 anon_vma 資料結構為頭節點的連結串列中。
  4. mm->map_count++;

如果線性區包含一個記憶體對映檔案,vma_link() 執行相應任務。

__vma_unlink() 引數:

  • 記憶體描述符地址 mm
  • 兩個線性區物件地址 vma 和 prev,都屬於 mm。

__vma_link() 從記憶體描述符連結串列和紅黑樹中刪除 vma,如果 mm->mmap_cache 指向剛被刪除的線性區,還需對 mm->mmap_cache 更新。

分配線性地址區間

do_mmap() 為當前程序建立並初始化一個新的線性區。
分配成功後,可與程序已有的其他線性區進行合併。

引數:

  • file、offset,如果新的線性區將把一個檔案對映到記憶體,則使用檔案描述符指標 file 和檔案偏移量 offset
  • addr,從何處查詢一個空閒的區間
  • len,線性地址區間的長度
  • prot,指定線性區所包含頁的訪問許可權
  • flag,指定線性區的其他標誌

do_mmap() 對 offset 進行初步檢查,然後執行 do_mmap_pgoff()。
假設新的線性地址區間對映的不是磁碟檔案,這裡僅對匿名線性區的 do_mmap_pgoff() 函式進行說明。

  1. 檢查引數的值是否正確,所提的請求是否能被滿足,如果不滿足,則返回一個負值。如果線性區地址區間的長度為 0,則函式不執行任何操作就返回。
    尤其檢查以下不能滿足請求的條件:
  • 線性地址區間的長度為 0 或包含的地址大於 TASK_SIZE。
  • 程序已經映射了過多的線性區,因此 mm 記憶體描述符的 map_count 欄位的值超過了允許的最大值。
  • flag 引數指定新線性地址區間的頁必須被鎖在 RAM 中,但不允許程序建立上鎖的線性區,或者程序加鎖頁的總數超過了儲存在程序描述符中的閾值 signal->rlim[RLIMIT_MEMLOCK].rlim_cur。
  1. get_unmapped_area() 獲得新線性區的線性地址區間。
  2. 通過將存放在 prot 和 flags 引數中的值進行組合來計算新線性區描述符的標誌:
vm_flags = calc_vm_prot_bits(prot, flags) | calc_vm_flag_bits(prot, flags) | mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
if(flags & MAP_SHARED)
	vm_flags |= VM_SHARED | VM_MAYSHARE;
  1. find_vma_prepare() 確定線性區的物件的位置,應該位於新區間之前,以及新線性區在紅黑樹中的位置。
for(;;)
{
	vma = find_vma_prepare(mm, addr, &prev, &rb_link, &rv_parent);
	if(!vma || vma->vm_start >= addr + len)  
		break;

	// 如果找到的線性區位於新區間結束地址之前
	// 說明與新區間存在重疊的線性區
	// 刪除新的區間
	if(do_munmap(mm, addr, len))  
		return -ENOMEM;
}
  1. 檢查插入新的線性區是否導致程序地址空間的大小 (mm->total_vm<<PAGE_SHIFT)+len 超過了程序描述符 signal->rlim[RLIMIT_AS].rlim_cur 欄位中的閾值。
  2. 如果 flags 引數沒有設定 MAP_NORESERVE 標誌,且新的線性區包含私有可寫頁,且沒有足夠的空閒頁框,返回錯誤碼 -ENOMEM;這最後一個檢查由 security_vm_enough_memory() 實現。
  3. 如果新區間是私有的(沒有設定 VM_SHARED),且對映的不是磁碟上的一個檔案,則呼叫 vma_merge() 檢查前一個線性區是否以此種方式擴充套件以包含新的區間。
    前一個線性區必須前一個線性區必須與在 vm_flags 區域性變數中存放標誌的那些線性區具有完全相同的標誌。
    如果前一個線性區可以擴充套件,那麼 vma_merge() 將它與隨後的線性區合併(發生在新區間填充兩個線性區之間的空洞,且三個線性區具有全部相同的標誌時)。
    擴充套件前一個線性區成功則跳到第 12 步。
  4. 呼叫 slab 分配函式 kem_cache_alloc() 為新的線性區分配一個 vm_area_struct 資料結構。
  5. 初始化 vma 指向的新的線性區物件:
vma->vm_mm = mm;
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = protection_map[vm_flags &  0x0f];
vma->vm_ops = NULL;
vma->vm_pgoff = pgoff;
vma->vm_file = NULL;
vma->vm_private_data = NULL;
vma->vm_next = NULL;
INIT_LIST_HEAD(&vma->shared);
  1. 如果 MAP_SHARED 標誌被設定(以及新的線性區不對映磁碟上的檔案),則該線性區是一個共享匿名區:呼叫 shmem_zero_setup() 對它進行初始化。共享匿名區主要用於程序通訊。
  2. 呼叫 vma_link() 將新線性區插入到線性區連結串列和紅黑樹中。
  3. 增加存放在記憶體描述符 total_vm 欄位中的程序地址空間的大小。
  4. 如果設定了 VM_LOCKED 標誌,呼叫 make_pages_present() 連續分配線性區的所有頁,並把它們鎖在 RAM 中:
if(vm_flags & VM_LOCKED)
{
	mm->locked_vm += len >> PAGE_SHIFT;
	make_pages_present(addr, addr + len);
}

make_pages_present() 呼叫 get_user_pages():

write = (vma->vm_flags & VM_WRITE) != 0;
get_user_pages(current, current->mm, addr, len, write, 0, NULL, NULL);
  • get_user_pages() 在 addr 和 addr_len 之間的頁的所有起始線性地址上迴圈;對於每個頁,呼叫 follow_page() 檢查在當前頁表中是否有到物理頁的對映。如果不存在這樣的物理頁,則 get_user_pages() 呼叫 handle_mm_fault() 分配一個頁框並根據記憶體描述符的 vm_flags 欄位設定它的頁表項。
  1. 返回新線性區的地址。

總結:對線性區間進行檢查,獲得,檢查,描述,得到插入位置,檢查,擴充套件,插入,更新,決定是否鎖在 RAM 中。

釋放線性地址區間

do_munmap() 從當前程序的地址空間中刪除一個線性地址區間。要刪除的區間可能是一個線性區,可能是線性區的一部分,可能是多個線性區。

引數:

  • 程序記憶體描述符的地址 mm
  • 地址區間的起始地址 start 及其長度 len

do_munmap() 函式

第一階段,1~6 步,掃描程序所擁有的線性區連結串列,並刪除包含在程序地址空間的線性地址區間。
第二階段,7~12 步,更新程序的頁表,並刪除在第一階段找到的線性區。
會用到後面要說明的 split_vma() 和 unmap_region() 函式。

執行如下步驟:

  1. 對引數進行初步檢查:如果線性地址區間所含的地址大於 TASK_SIZE,或 start 不是 4096 的倍數,或線性地址區間的長度為 0,則返回錯誤程式碼 -EINVAL。
  2. 確定要刪除的線性地址區間之後的第一個線性地址區 mpnt 的位置:
mpnt = find_vma_prev(mm, start, &prev);
  1. 如果 mpnt 不存在,或與線性地址區間不重疊,則什麼都不做,因為該區間上沒有線性區:
end = start + len;
if(!mpnt || mpnt->vm_start >= end)
	return 0;
  1. 如果線性區的起始地址在 mpnt 內,就呼叫 split_vma() 將 mpnt 劃分成兩個較小的區:一個區線上性地址區間外部,另一個在內部。
if(start > mpnt->vm_start)
{
	if(split_vma(mm, mpnt, start, 0))
		return -ENOMEM;
	prev = mpnt;  // prev 指向要刪除的第一個線性區前面的那個線性區
}
  1. 如果線性區的結束地址在一個線性區內部,再次呼叫 split_vma() 將最後重疊的那個線性區同樣劃分成兩個較小的區。
last  = find_vma(mm, end);
if(last && end > last->vm_start))
{
	if(split_vma(mm, last, start, end, 1))
		return -ENOMEM;
}
  1. 更新 mpnt 的值,使其指向線性地址區間的第一個線性區。
mpnt = prev ? prev->vm_next : mm->mmap;
  1. 呼叫 detach_vmas_to_be_unmapped() 從程序的線性地址空間中刪除位於線性地址區間中的線性區。
vma = mpnt;  // 要刪除的線性區的描述符放在一個排好序的連結串列中,mpnt 指向該連結串列的頭
insertion_point = (prev ? &prev->vm_next : &mm->mmap);
do
{
	rb_erase(&vma->vm_rb, &mm->mm_rb);
	mm->map_count--;
	tail_vma = vma;
	vma = vma->next;
}while(vma && vma->start < end);
*insertion_point = vma;
tail_vma->vm_next = NULL;
mm->map_cache = NULL;
  1. 獲得 mm->page_table_lock 自旋鎖。
  2. 呼叫 unmap_region() 清除與線性地址區間對應的頁表項並釋放相應的頁表:
unmap_region(mm, mpnt, prev, start, end);
  1. 釋放 mm->page_table_lock 自旋鎖。
  2. 釋放在第 7 步建立連結串列時收集的線性區描述符:
do
{
	struct vm_area_struct *next = mpnt->vm_next;
	unmap_vma(mm, mpnt);
	mpnt = next;
}while(mpnt != NULL);
  • 對連結串列中的所有線性區呼叫 unmap_vma() 函式,本質上執行下述步驟:
    a. 更新 mm->total_vm 和 mm->locked_vm 欄位。
    b. 執行記憶體描述符的 mm->unmap_area 方法。根據程序線性區的型別選擇 arch_unmap_area() 或 arch_unmap_topdown(),必要時更新 mm->free_area_cache 欄位。
    c. 呼叫線性區的 close 方法。
    d. 如果線性區是匿名的,則將其從 mm->anon_vma 指向的匿名線性區連結串列中刪除。
    e. 呼叫 kmem_cache_free() 釋放線性區描述符。
  1. 成功,返回 0。

split_vma()

把與線性地址區間交叉的線性區劃分成兩個較小的區,一個線上性地址外部,另一個在區間的內部。

引數:

  • 記憶體描述符指標 mm
  • 線性區描述符指標 vma
  • 區間與線性區之間交叉點的地址 addr
  • 表示區間和線性區之間交叉點在區間起始處還是結束處的標誌 new_below
  1. 呼叫 kmem_cache_alloc() 獲得線性區描述符 vm_area_struct,並將它的地址存在新的區域性變數中,如果沒有可用的空閒空間,返回 -ENOMEM。
  2. 用 vma 描述符的欄位值初始化新描述符的欄位。
  3. 如果標誌 new_below 為 0,說明線性地址區間的起始地址在 vma 線性區的內部,因此應將新線性區放在 vma 線性區之後,所以將 new->vm_start = addr; vma->vm_end = addr。
  4. 如果標誌 new_below 為 1,說明線性地址區間的結束地址在 vma 線性區的內部,因此應將新線性區放在 vma 線性區之前,所以將 new->vm_start = addr; vma->vm_start = addr。
  5. 如果定義了新線性區的 open 方法,就執行它。
  6. 把新線性區描述符連結到線性區連結串列 mm->mmap 和紅黑樹 mm->mm_rb。根據線性區 vma 的最新大小對紅黑樹進行調整。
  7. 成功,返回 0。

unmap_region()

遍歷線性區連結串列並釋放它們的頁框。

引數:

  • 記憶體描述符指標 mm
  • 指向第一個被刪除線性區描述符的指標 vma
  • 指向程序連結串列中 vma 前面的線性區的指標 prev
  • 兩個地址 start 和 end,用來界定被刪除線性地址區間的範圍
  1. 呼叫 lru_add_drain()。
  2. 呼叫 tlb_gather_mm() 初始化每 CPU 變數 mmu_gathers。mm_gathers 通常存放更新程序頁表項所需的所有資訊。80x86 體系結構中,tlb_gather_mmu() 只是把 mm 賦給本地 CPU 的 mm_gathers 變數。
  3. 把 mmu_gathers 變數的地址儲存在區域性變數 tlb 中。
  4. 呼叫 unmap_vmas() 掃描線性地址空間的所有頁表項:如果只有一個有效 CPU,則呼叫 free_swap_and_cache() 釋放相應頁;否則,mm_gathers = 相應頁描述符的指標。
  5. free_pgtables(tlb, prev, start, end) 回收在上一步已經清空的程序頁表。
  6. tlb_finish_mmu(tlb, start, end) 結束 unmap_region() 的工作,tlb_finish_mmu(tlb, start, end) 執行下面的操作:
    a. flush_tlb_mm() 重新整理 TLB。
    b. 多處理器系統中,free_pages_and_swap_cache() 釋放頁框,這些頁框的指標已經集中存放在 mmu_gather 資料結構中了。1

缺頁異常處理程式

Linux 的缺頁異常處理程式必須區分以下兩種情況:

  • 由程式設計錯誤所引起的異常
  • 引用屬於程序地址空間但還尚未分配物理頁框所引起的異常

線性區描述符可以讓缺頁異常處理程式非常有效地完成其工作。
do_page_fault() 是 80x86 上的缺頁中斷服務程式,它將引起缺頁的線性地址和當前程序的線性區比較,從而根據圖 9-4 所示的方案處理該異常。
在這裡插入圖片描述
實際情況會更復雜一些,如圖 9-5 所示。
在這裡插入圖片描述

do_page_fault() 接收以下引數:

  • pt_regs 結構的地址 regs,該結構包含當異常發生時的微處理器暫存器的值。
  • 3 位的 error_code,當異常發生時由控制單元壓入棧中,這些位的含義如下:
    • 如果第 0 位被清 0,則異常由訪問一個不存在的頁所引起;否則,異常由無效的訪問許可權引起。
    • 如果第 1 位被清 0,則異常由讀訪問或執行訪問所引起;否則,異常由寫訪問引起。
    • 如果第 2 位被清 0,則異常發生在處理器處於核心態時;否則,異常發生在處理器處於使用者態時。

do_page_fault() 執行下述步驟:

讀取引起缺頁的線性地址 address

// 當異常發生時,CPU 將 address 存放在 cr2 控制暫存器中
asm("movl %%cr2, %0":"=r" (address));

// 如果缺頁發生之前或 CPU 執行在虛擬 8086 模式時本地中斷是可開啟的
// 則函式還需要確保本地中斷是可開啟的
if(regs->eflags & 0x00020200)
	local_irq_enable();

// 將指向 current 程序描述符的指標儲存在 tsk 區域性變數中
tsk = current;

檢查引起缺頁的線性地址是否屬於第 4 個 GB

info.si_code = SEGV_MAPERR;
if(address >= TASK_SIZE)
{
	if(!(error_code & 0x101))
		goto vmalloc_fault;   // 處理非連續記憶體訪問
	goto bad_area_nosemaphore;  // 處理地址空間以外的錯誤地址
}

檢查異常發生時核心是否正在一些一些關鍵例程或正在執行核心執行緒。

// in_atomic() 巨集等於 1 的情況:
// 1. 核心正在指向中斷處理程式或可延遲函式
// 2. 核心在禁用核心搶佔的情況下執行臨界區程式碼
// 程序描述符的 mm 欄位為 NULL 時,說明為核心執行緒
if(in_atomic() || !tsk->mm)
	goto bad_area_nosemaphore;

假定缺頁沒有發生在中斷處理程式、可延遲函式、臨界區或核心執行緒中,於是,必須檢查程序所擁有的線性區,以確定引起缺頁的線性地址是否包含在程序的地址空間中,為此必須獲得程序的 mmap_sem 讀/寫訊號量:

// 如果核心 bug 和硬體故障可被排除,缺頁發生時,當前程序還沒有獲得 mmap_sem 寫訊號量
// 但還是要確定一下,真的沒有獲得該訊號量,否則會發生死鎖
if(!down_read_trylock(&tsk->mm-> mmap_sem))
{
	// 缺頁時由核心 bug 或嚴重的故障引起的
	if((error_code & 4) == 0 && !search_exception_table(regs->eip))
		goto bad_area_nosemaphore;   

	// 獲得 mmap_sem 讀訊號量
	down_read(&tsk->mm->mmap_sem);
}

假設獲得了 mmap_sem 讀訊號量,開始搜尋錯誤線性地址所在的線性區。

vma = find_vma(tsk->mm, address);
if(!vma)  // 說明 address 後面沒有線性區,因此這個錯誤的地址無效
	goto bad_area;   // 處理地址空間以外的錯誤地址

if(vma->vm_start <= address)  // 如果在 address 之後的第一個線性區包含 address
	goto good_area;   // 處理地址空間內的錯誤地址

如果兩個“if”條件都不滿足,則函式已確定 address 沒有包含在任何線性區中,還需進一步檢查,因為該錯誤可能是由 push 或 pusha 指令在程序的使用者態堆疊上的操作引起的。

可能是 push 引用了該線性區以外的一個地址(即引用一個不存在的頁框)。該異常不是由程式的錯誤引起的,因此必須由缺頁處理程式單獨處理。

// 向低地址擴充套件的棧所在的區,它的 VM_GROWSDOWN 標誌被設定
// 這樣,當 vm_start 欄位的值減少時,vm_end 欄位的值保持不變
if(!(vma->vm_flags & VM_GROWSDOWN))
	goto bad_area;   // 處理地址空間以外的錯誤地址

// 如果線性區的 VM_GROWSDOWN 標誌被設定,且異常發生在使用者態,且 address < regs->esp 棧指標
if(error_code & 4 && address + 32 < regs->esp)
	goto bad_area;  // 處理地址空間以外的錯誤地址

// 如果地址足夠高(在允許範圍內)
// 則檢查是否允許程序既擴充套件棧頁擴充套件它的地址空間
// 如果一切都可以,將 vma 的 vm_start 欄位設為 address,並返回 0;否則,返回 -ENOMEM
if(expand_stack(vma, address))
	goto bad_area;
	
goto good_area;   // 處理地址空間內的錯誤地址

處理地址空間以外的錯誤地址

如果 address 不屬於程序的地址空間,則 do_page_fault() 繼續執行 bad_area 標記處的語句。
如果錯誤發生在使用者態,則向 current 程序發生一個 SIGSEGV 訊號並結束函式:

bad_area:
up_read(&tsk->mm->mmap_sem);
bad_area_nosemaphore:
if(error_code & 4)  // 使用者模式
{
	tsk->thread.cr2 = address;
	tsk->thread.error_code = error_code | (address >= TASK_SIZE);
	tsk->thread.trap_no = 14;
	info.si_signo = SIGSEGV;
	info.si_errno  = 0;
	info.si_addr = (void *)address;

	// 確信程序不忽略或阻塞 SIGSEGV 訊號
	// 並通過 info 區域性變數傳遞附加資訊的同時傳送給使用者態程序
	// info.si_code 欄位已被設定為 SEGV_MAPERR 或 SEGV_ACCERR
	force_sig_info(SIGSEGV, &info, tsk);  

	return;
}

如果異常發生在核心態(error_code 的第 2 位被清 0),有兩種情況:

  • 異常的引起是由於把某個線性地址作為系統呼叫的引數傳遞給核心。
  • 異常是因一個真正的核心缺陷引起的。

區分兩種情況:

no_context:
if((fixup = search_exception_table(regs->eip)) != 0)
{
	regs->eip = fixup;
	return;
}

第一種情況中,程式碼跳到一段“修正程式碼”處,向當前程序傳送 SIGSEGV 訊號,或用一個適當的出錯碼終止系統呼叫處理程式。

第二種情況中,函式將 CPU 暫存器和核心態堆疊的所有轉儲列印到控制檯,並輸出到一個系統訊息緩衝區,方便程式設計人員定位錯誤,然後呼叫 do_exit() 殺死當前程序。

處理地址空間內的錯誤地址

如果 addr 地址屬於程序的地址空間,則 do_page_fault() 轉到 good_area 標記處的語句執行:

good_area:
info.si_code = SEGV_ACCERR;
write = 0;
if(error_code & 2)  // 異常由寫訪問引起
{
	// 檢查該線性區是否可寫
	// 不可寫,跳到 bad_area
	// 可寫, write++
	if(!(vma->vm_flags & VM_WRITE))  
		goto bad_area;   
	write++;
}
else  // 異常由讀或執行訪問引起
{
	// 檢查這一頁是否已存在於 RAM
	// 存在時,異常發生是由於程序試圖訪問使用者態下的一個有特權的頁框,跳到 bad_area
	// 不存在時,檢查該線性區是否可讀或可執行
	if((error_code & 1) || !(vma->vm_flags & (VM_READ | VM_EXEC)))  
		goto bad_area;   
}

如果該線性區的訪問許可權與引起異常的訪問型別相匹配,則呼叫 handle_mm_fault() 分配一個新的頁框:

survive:

// 如果成功地分配給程序一個頁框,則返回 VM_FAULT_MINOR 或 VM_FAULT_MAJOR
ret = handle_mm_fault(tsk->mm, vma, address, write);  
if(ret == VM_FAULT_MINOR || ret = VM_FAULT_MAJOR)
{
	if(ret == VM_FAULT_MINOR)  // 沒有阻塞當前程序的情況下處理了缺頁
		tsk->min_flt++;  
	else  // 迫使當前程序睡眠
		tsk->maj_flt++;
	up_read(&tsk->mm->mmap_sem);
	return;
}

如果 handle_mm_fault() 返回 VM_FAULT_SIGBUG,則向程序傳送 SIGBUS 訊號:

if(ret == VM_FAULT_SIGBUS)
{
do_sigbus:
	up_read(&tsk->mm->mmap_sem);
	if(!(error_code & 4))  // 核心態
		goto no_context;
	tsk->thread.cr2 = address;
	tsk->thread.error_code = error_code;
	tsk->thread.trap_no = 14;
	info.si_signo = SIGBUS;
	info.si_errno = 0;
	info.si_code = BUS_ADRERR;
	info.si_addr = (void *)address;
	force_sig_info(SIGBUS, &info, tsk);
}

如果 handle_mm_fault() 不分配新的頁框,則返回 VM_FAULT_OOM,此時核心通常殺死當前程序。
但如果當前程序是 init 程序,則只是將它放在執行佇列的末尾並呼叫排程程式;
一旦 init 恢復執行,則 handle_mm_fault() 繼續執行:

if(ret == VM_FAULT_OOM)
{
out_of_memory:
	up_read(&tsk->mm->mmap_sem);
	if(tsk->pid != 1)
	{
		if(error_code & 4)  // 使用者模式
			do_exit(SIGKILL);
		goto no_context;
	}
	yield();
	down_read(&tsk->mm->mmap_sem);
	goto survive;
}

handle_mm_fault() 引數:

  • mm,指向異常發生時正在 CPU 上執行的程序的記憶體描述符
  • vma,指向引起異常的線性地址所線上性區的描述符
  • address,引起異常的線性地址
  • write_access,如果 tsk 試圖向 address 寫,則置為 1;如果 tsk 試圖在 address 讀或執行,則置為 0

首先檢查用來對映 address 的頁中間目錄和頁表是否存在,不存在的話分配。

// pgd 區域性變數包含了引用 address 的頁全域性目錄項
pgd = pgd_offset(mm, address);   

spin_lock(&mm->page_table_lock);
pud = pud_alloc(mm, pgd, address);  // 分配新的頁上級目錄
if(pud)
{
	pmd = pmd_alloc(mm, pud, address);  // 分配頁中間目錄
	if(pmd)
	{
		pte = pte_alloc_map(mm, pmd, address);  // 分配一個新的頁表
		if(pte)
			return handle_pte_fault(mm, vma, address, wrie_access, pte, pmd);  
	}
}
spin_unlock(&mm->page_table_lock);
return VM_FAULT_OOM;

handle_pte_fault() 檢查 address 地址對應的頁表項,並決定如何為程序分配一個新頁框:

  • 如果被訪問的頁不存在,即沒有存放在任何一個頁框中,則核心分配一個新的頁框並適當初始化,稱為請求調頁。
  • 如果被訪問的頁存在但是標記為只讀,即已經被存放在一個頁框中,則核心分配一個新的頁框,並將舊頁框的資料拷貝到新頁框來初始化它的內容,稱為寫時複製。

請求調頁

是一種動態記憶體分配技術,它將頁框的分配推遲到程序要訪問的頁不在 RAM 中時為止,由此引起一個缺頁異常。

被訪問的頁可能不在主存中,原因或者是程序從沒訪問過該頁,或者核心已經回收了相應的頁框。

缺頁處理程式為程序分配新的頁框後,如何初始化該頁框取決於哪一種頁以及頁是否被程序訪問過。特殊情況下:

  1. 或者該頁從未被程序訪問到且沒有對映磁碟檔案,或者頁映射了磁碟檔案。
    核心識別該種情況的依據:頁表相應的表項被填充為 0,即 pte_none 巨集返回 1。
  2. 頁屬於一個非線性磁碟檔案對映。
    核心識別該種情況的依據:Present 標誌被清 0 且 Dirty 標誌被置 1,即 pte_file 巨集返回 1。
  3. 程序已經訪問過該頁,但其內容被臨時儲存在磁碟上。
    核心識別該種情況的依據:相應的頁表項沒被填充為 0,但 Present 和 Dirty 標誌被清 0。

handle_pte_fault() 通過檢查 address 對應的頁表項區分那三種情況:

entry = *pte;
if(!pte_present(entry))
{
	// 第一種情況
	if(pte_none(entry))
		return do_no_page(mm, vma, address, write_access, pte, pmd);
	if(pte_file(entry))
		return do_file_page(mm, vma, address, write_access, pte, pmd);
	return do_swap_page(mm, vma, address, pte, pmd, entry, write_access);
}

第 1 種情況下,呼叫 do_no_page()。有兩種方法裝入所缺的頁,取決於該頁是否被對映到一個磁碟檔案。
通過檢查 vma 線性區描述符的 nopage 欄位來確定,如果頁被對映到一個檔案,nopage 就指向一個函式,該函式將所缺的頁從磁碟裝入到 RAM。
可能的情況是:

  • vma->vm_ops->nopage != NULL,說明線性區映射了一個磁碟檔案,nopage 指向裝入頁的函式。
  • vma->vm_ops == NULL || vma->ops->nopage == NULL, 說明線性區沒有對映磁碟檔案,即它是一個匿名對映。因此,呼叫 do_anonymous_page() 獲得一個新的頁框:
if(!vma->vm_ops || !vma->vm_ops->nopage)
	return do_anonymous_page(mm, vma, page_table, pmd, write_access, address);

do_anonymous_page() 分別處理寫請求和讀請求:
if(write_access)
{
	// pte_unmap 巨集釋放一種臨時核心對映,
	// 該臨時對映映射了在呼叫 handle_pte_fault() 前由 pte_offset_map 巨集所建的頁表項的高階記憶體實體地址
	// 臨時核心對映必須在呼叫 alloc_page() 之前釋放,因為該函式可能阻塞當前程序
	pte_unmap(page_table);

	spin_unlock(&mm->page_table_lock);
	
	page = alloc_page(GFP_HIGHUSER | __GFP_ZERO);
	
	spin_lock(&mm->page_table_lock);
	page_table = pte_offset_map(pmd, addr);

	mm->rss++;  // 記錄分配給程序的頁框總數

	// 相應的頁表項設定為頁框的實體地址,該頁框被標記為既髒又可寫的
	entry = maybe_mkwrite(pte_mkdirty(mk_pte(page, vma->vm_page_prot)), vma);

	lru_cache_add_active(page);  // 把新頁框插入與交換相關的資料結構

	SetPageReferenced(page);
	set_pte(page_table, entry);
	pte_unmap(page_table);
	spin_unlock(&mm->page_table_lock);
	
	return VM_FAULT_MINOR;
}

處理讀訪問時,可以分配一個現有的稱為零頁的頁,以推遲頁框的分配。
零頁在核心初始化期間被靜態分配,並存放在 empty_zero_page 變數中。

用零頁的實體地址設定頁表項:

entry = pte_wrprotect(mk_pte(virt_to_page(empty_zero_page), vma->vm_page_prot));
set_pte(page_table, entry);
spin_unlock(&mm->page_table_lock);
return VM_FAULT_MINOR;

該頁標記為不可寫,如果程序試圖寫該頁,則啟用寫時複製機制。

寫時複製

第一代 Unix 系統實現了傻瓜式的程序建立,fork() 比較耗時,因為需要:

  • 為子程序的頁表分配頁框
  • 為子程序的頁分配頁框
  • 初始化子程序的頁表
  • 把父程序的頁複製到子程序相應的頁中

現在的 Unix 實現了寫時複製。
父程序和子程序共享頁框。
無論父程序還是子程序何時試圖寫一個共享的頁框,就產生一個異常,這時核心就把該頁複製到一個新的頁框中並標記為可寫。
原來的頁框仍然是防寫的,當其他程序試圖寫入時,核心檢查寫程序是否是該頁框的唯一屬主,如果是,就把該頁框標記為對該程序是可寫的。

頁描述符的 _count 欄位跟蹤共享相應頁框的程序數。
只要程序釋放一個頁框或在它上面執行寫時複製,它的 _count 欄位就減小。
只有 _count 變為 -1 時,該頁框才被釋放。

handle_pte_fault() 確定異常是由訪問記憶體中現有的一個頁而引起時,執行以下指令:

if(pte_present(entry))  // 如果頁是存在的
{
	if(write_access)  // 訪問許可權是寫允許的
	{
		if(!pte_write(entry))  // 頁框是防寫的
		{
			return do_wp_page(mm, vma, address, pte, pmd, entry);
		}
		entry = pte_mkdirty(entry);
	}
	entry = pte_mkyong(entry);
	set_pte(pte, entry);
	flush_tlb_page(vma, address);
	pte_unmap(pte);
	spin_unlock(&mm->page_table_lock);
	return VM_FAULT_MINOR;
}

do_wp_page():
首先獲取與缺頁異常相關的頁框描述符。
然後讀取頁描述符的 _count 欄位:如果等於 0(僅有一個程序擁有該頁),寫時複製就不必進行。
實際上,檢查要複雜一些, 為當頁插入到交換快取記憶體,且當設定了頁描述符的 PG_private 標誌時,_count 欄位也增加。
當寫時複製不進行時,就將該頁框標記為可寫的,以面試圖寫時引起進一步的缺頁異常:

set_pte(page_table, maybe_mkwrite(pte_mkyoung(pte_mkdirty(pte)), vma));
flush_tlb_page(vma, address);
pte_unmap(page_table);
spin_unlock(&mm->page_table_lock);
return VM_FAULT_MINOR;

如果多個程序通過寫時複製共享頁框,那麼函式就把舊頁框的內容複製到新分配的頁框中。
為避免競爭條件,在開始複製前呼叫 get_page() 將 old_page 的使用計數加 1:

old->page = pte_page(pte);
pte_unmap(page_table);
get_page(old_page);  // old_page 的使用計數加 1
spin_unlock(&mm->page_table_lock);
if(old_page == virt_to_page(empty_zero_page))  // 如果舊頁框是零頁
{
	new_page = alloc_page(GFP_HIGHUSR | __GFP_ZERO);  // 就在分配新頁框時將它填充為 0
}
else
{
	new_page = allock_page(GFP_HIGHUSER);
	vfrom = kmap_atomic(old_page, KM_USER0);  
	vto = kmap_atomic(new_page, KM_USER1);
	copy_page(vto, vfrom);   // 如果舊頁框不是零頁,複製頁框的內容
	kunmap_atomic(vfrom, KM_USER0);
	kunmap_atomic(vto, KM_USER0);
}

因為頁框的分配可能阻塞程序,因此,檢查自從函式開始執行以來是否已經修改了頁表項。
如果是,新的頁框被釋放,old_page 的使用計數器被減少,函式結束。

如果所有進展順利,新頁框的實體地址最終被寫進頁表項,且使相應的 TLB 暫存器無效:

spin_lock(&mm->page_table_lock);
entry = maybe_mkwrite(pte_mkdirty(mk_pte(new_page, vma->vm_page_prot)), vma);
set_pte(page_table, entry);
flush_tlb_page(vma, address);
lru_cache_add_active(new_page);  // 將新頁框插入到與交換相關的資料結構
pte_unmap(page_table);
spin_unlock(&mm->page_table_lock);	

最後,do_wp_page() 把 old_page 的使用計數器減少兩次。
第一次的減少是取消複製頁框內容之前進行的安全性增加。
第二次的減少是反映當前程序不再擁有該頁框。

處理非連續記憶體區訪問

核心在更新非連續記憶體區對應的頁表項時是非常懶惰的。
vmalloc() 和 vfree() 只把自己限制在更新主核心頁表。

一旦核心初始化階段結束,任何程序或核心執行緒便都不直接使用主核心頁表。
核心態程序對非連續記憶體區第一次訪問時,會產生缺頁異常,缺頁異常認識該情況,因為異常發生在核心態且產生缺頁的線性地址大於 TASK_SIZE。
因此,do_page_fault() 檢查相應的主核心頁表項:

vmalloc_fault:

// 將 cr3 暫存器中的當前程序頁全域性目錄的實體地址賦給區域性變數 pgd_paddr
asm("movl %%cr3, %0":"=r" (pgd_paddr));  

// 將與 pgd_addr 相應的線性地址賦給區域性變數 pgd
pgd = pgd_index(address) + (pgd_t *)__va(pgd_paddr);

// 將主核心頁全域性目錄的線性地址賦給 pgd_k 區域性變數
pgd_k = init_mm.pgd + pgd_index(address);

// 如果產生缺頁的線性地址所對對應的主核心頁全域性目錄項為空,跳到 no_context 程式碼處
if(!pgd_present(*pgd_k))
	goto no_context;

// 檢查與錯誤線性地址相對應的主核心頁上級目錄項
pud = pud_offset(pgd, address);
pud_k = pud_offset(pgd_k, address);
if(!pud_present(*pud_k))
	goto no_context;

// 檢查與錯誤線性地址相對應的主核心頁中間目錄項
pmd = pmd_offset(pud, address);
pmd_k = pmd_offset(pud_k, address);
if(!pmd_present(*pmd_k))
	goto no_context;

// 將主目錄項複製到程序頁中間目錄的相應項
set_pmd(pmd, *pmd_k);
pte_k = pte_offset_kernel(pmd_k, address);
if(!pte_present(*pte_k))
	goto no_context;
return;

建立和刪除程序的地址空間

建立程序的地址空間

clone()、fork() 和 vfork() 建立一個新的程序時,核心呼叫 copy_mm()。
該函式通過建立新程序的所有頁表和記憶體描述符來建立程序的地址空間。

如果通過 clone() 已經建立了新程序,且 flag 引數的 CLONE_VM 標誌被設定,則 copy_mm() 將父程序 current 地址空間給子程序 tsk:


if(clone_flags & CLONE_VM)
{
	atomic_inc(&current->mm->mm_users);

	// 如果其他 CPU 持有程序頁表自旋鎖,就保證在釋放鎖之前,缺頁處理程式不會結束
	// 自旋鎖除了保護頁表外,還必須程序建立新的輕量程序,因為它們共享 current->mm 描述符
	spin_unlock_wait(&current->mm->page_table_lock);

	tsk->mm = current->mm;
	tsk->active_mm = current->mm;
	return 0;
}

如果沒有設定 CLONE_VM 標誌,copy_mm() 就必須建立一個新的地址空間。
分配一個新的記憶體描述符,把它的地址存放在新程序描述符 tsk 的 mm 欄位,並把 current->mm 的內容複製到 tsk->mm 中,然後改變新程序描述符的一些欄位:

tsk->mm = kmem_cache_alloc(mm_cachep, SLAB_KERNEL);
memcpy(tsk->mm, current->mm, sizeof(*tsk->mm));
atomic_set(&tsk->mm->mm_users, 1);
atomic_set(&tsk->mm->mm_count, 1);
init_rwsem(&tsk->mm->mmap_set);
tsk->mm->core_waiters = 0;
tsk->mm->page_table_lock = SPIN_LOCK_UNLOCKED;
tsk->mm->ioctx_list_lock = RW_LOCK_UNLOCKED;
tsk->mm->ioctx_list = NULL;
tsk->mm->default_kioctx = INIT_KIOCTX(tsk->mm->default_kioctx, *tsk->mm);
tsk->mm->free_area_cache = (TASK_SIZE/3 + 0xfff) & 0xfffff000;
tsk->mm->pgd = pgd_alloc(tsk->mm);
tsk->mm->def_flags = 0;

然後呼叫依賴於體系結構的 init_new_context():對於 80x86,檢查當前程序是否擁有定製的區域性描述符,如果是,複製一份 current 的區域性描述符表,並將其插入 tsk 地址空間。

最後,呼叫 dup_mmap() 複製父程序的線性區和頁表:

  • 將新記憶體描述符 tsk->mm 插入到記憶體描述符的全域性連結串列中。
  • 從 current->mm->mmap 指向的線性區開始掃描父程序的線性區連結串列。
  • 複製遇到的每個 vm_area_struct 線性區描述符
  • 將複製品插入到子程序的線性區連結串列和紅黑樹中
  • 插入一個新的線性區描述符後,如果需要,立即呼叫 copy_page_range() 建立必要的頁表來對映該線性區所包含的一組頁,並初始化新表的表項,尤其是,與私有的、可寫的頁所對應的任一頁框都標記為對父子程序是隻讀的。

刪除程序的地址空間

程序結束時,核心呼叫 exit_mm() 釋放程序的地址空間:

// 喚醒在 tsk->vfork_done 補充原語上睡眠的任一程序
// 典型的,只有當現有程序通過 vfork() 被建立時,相應的等待佇列才會為非空
mm_release(tsk, tsk->mm);

// 如果正在被終止的程序是核心執行緒
if(!(mm = tsk->mm))  
	return;

// 如果正在被終止的程序不是核心執行緒,就必須釋放記憶體描述符和所有相關的資料結構
// 首先,檢查 mm->core_waiters 標誌是否被置位
// 如果是,就把記憶體的所有內容解除安裝到一個轉儲檔案中
// 為避免轉儲檔案的混亂,利用 mm->core_done 和 mm->core_startup_done 補充原語使共享同一記憶體描述符 mm 的輕量級程序執行序列化
down_read(&mm->mmap_sem);

接下來,遞增記憶體描述符的主使用計數器,重新設定程序描述符的 mm 欄位,並使處理器處於懶惰 TLB 模式:

atomic_inc(&mm->mm_count);
spin_lock(tsk->alloc_lock);
tsk->mm = NULL;
up_read(&mm->map_sem);
enter_lazy_tlb(mm, current);
spin_unlock(tsk->alloc_lock);
mmput(mm);

最後,呼叫 mmput() 釋放區域性描述符表、線性區描述符和頁表。
但因為 exit_mm() 已經遞增了主使用計數器,所以不釋放記憶體描述本身。
當要把正在被終止的程序從本地 CPU 撤銷時,由 finish_task_switch() 釋放記憶體描述符。

堆的管理

每個 Unix 程序都擁有一個特殊的線性區—堆,滿足程序的動態記憶體請求。
記憶體描述符的 start_brk 與 brk 欄位分別限定了該區的開始地址和結束地址。

程序呼叫以下 API 請求和釋放動態記憶體:

  • malloc(size) 請求 size 個位元組的動態記憶體。分配成功則返回所分配記憶體單元的第一個位元組的線性地址。
  • calloc(n, size) 請求含有 n 個大小為 size 的元素的一個數組。分配成功則將陣列元素初始化為 0,並返回第一個元素的線性地址。
  • realloc(ptr, size) 改變由 malloc() 或 calloc() 分配的記憶體區欄位的大小。
  • free(addr) 釋放由 malloc() 或 calloc() 分配的起始地址為 addr 的線性區。
  • brk(addr) 直接修改堆的大小。addr 指定 current->mm->brk 的新值,返回線性區新的結束地址。
  • sbrk(incr) 類似於 brk(),但 incr 指定是增加還是減少以位元組為單位的堆的大小。

brk() 是唯一以系統呼叫的方式實現的函式,而其他函式是使用 brk() 和 mmap() 系統呼叫實現的 C 語言庫函式。

使用者態程序呼叫 brk() 時,核心執行 sys_brk(addr):

// 首先檢驗 addr 是否位於程序程式碼所在的線性區
// 如果是,立即返回,因為堆不能與程序程式碼所在的線性區重疊
mm = current->mm;
down_write(&mm->mmap_sem);
if(addr < mm->end_code)
{
out:
	up_write(&mm->mmap_sem);
	return mm->brk;
}

// 由於 brk() 作用於某一個線性區,分配和釋放完整的頁
// 因此,將 addr 的值調整為 PAGE_SIZE 的倍數,然後將調整結果於記憶體描述符的 brk 欄位比較
newbrk = (addr + 0xfff) & 0xfffff000;
oldbrk = (mm->brk + 0xfff) & 0xfffff000;
if(oldbrk == newbrk)
{
	mm->brk = addr;
	goto out;
}

// 如果程序要求縮小堆,則呼叫 do_munmap() 完成並返回
if(addr <= mm->brk)
{
	if(!do_munmap(mm, newbrk, oldbrk-newbrk))
		mm->brk = addr;
	goto out;
}

// 如果程序請求擴大堆,首先檢查是否允許
// 如果程序企圖分配限制範圍之外的記憶體,只簡單返回 mm->brk 的原有值
rlim = current->signal->rlim[RLIMIT_DATA].rlim_cur;
if(rlim < RLIM_INFINITY && addr - mm->start_data > rlim)
	goto out;

// 然後,檢查擴大後的堆是否可程序的其他線性區重疊,如果是,直接返回
if(find_vma_intersection(mm, oldbrk, newbrk+PAGE_SIZE))
	goto out;

// 如果一切順利,呼叫 do_brk()
// 如果它返回 oldbrk,則分配成功,返回 addr;否則,返回舊的 mm->brk
if(do_brk(oldbrk, newbrk-oldbrk) == oldbrk)
	mm->brk = addr;
goto out;

do_brk() 實際上是 do_mmap() 的進化版,僅處理匿名線性區,等價於:

do_mmap(NULL, oldbrk, newbrk-oldbrk, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_FIXED|MAP_PRIVATE, 0)

do_brk() 假定線性區不對映磁碟上的檔案,從而避免了檢查線性區物件的幾個欄位,因此比 do_mmap() 稍快。