前言 在程序的執行過程中,因為遇到某種障礙而使 CPU 無法最終訪問到相應的物理內存單元,即無法完成從虛擬地址到物理地址映射的時候,CPU 會產生一次缺頁異常,從而進行相應的缺頁異常處理。基於 CPU 的這一特性,Linux 采用了請求調頁(Demand Paging)和寫時復制(Copy On Write)的技術 1. 請求調頁是一種動態內存分配技術,它把頁框的分配推遲到不能再推遲為止。這種技術的動機是:進程開始運行的時候並不訪問地址空間中的全部內容。事實上,有一部分地址也許永遠也不會被進程所使用。程序的局部性原理也保證了在程序執行的每個階段,真正使用的進程頁只有一小部分,對於臨時用不到的頁,其所在的頁框可以由其它進程使用。因此,請求分頁技術增加了系統中的空閒頁框的平均數,使內存得到了很好的利用。從另外一個角度來看,在不改變內存大小的情況下,請求分頁能夠提高系統的吞吐量。當進程要訪問的頁不在內存中的時候,就通過缺頁異常處理將所需頁調入內存中。 2. 寫時復制主要應用於系統調用fork,父子進程以只讀方式共享頁框,當其中之一要修改頁框時,內核才通過缺頁異常處理程序分配一個新的頁框,並將頁框標記為可寫。這種處理方式能夠較大的提高系統的性能,這和Linux創建進程的操作過程有一定的關系。在一般情況下,子進程被創建以後會馬上通過系統調用execve將一個可執行程序的映象裝載進內存中,此時會重新分配子進程的頁框。那麼,如果fork的時候就對頁框進行復制的話,顯然是很不合適的。 在上述的兩種情況下出現缺頁異常,進程運行於用戶態,異常處理程序可以讓進程從出現異常的指令處恢復執行,使用戶感覺不到異常的發生。當然,也會有異常無法正常恢復的情況,這時,異常處理程序會進行一些善後的工作,並結束該進程。也就是說,運行在用戶態的進程如果出現缺頁異常,不會對操作系統核心的穩定性造成影響。那麼對於運行在核心態的進程如果發生了無法正常恢復的缺頁異常,應該如何處理呢?是否會導致系統的崩潰呢?是否能夠解決好內核態缺頁異常對於操作系統核心的穩定性來說會產生很大的影響,如果一個誤操作就會造成系統的Oops,這對於用戶來說顯然是不能容忍的。本文正是針對這個問題,介紹了一種Linux內核中所采取的解決方法。 在讀者繼續往下閱讀之前,有一點需要先說明一下,本文示例中所選的代碼取自於Linux-2.4.0,編譯環境是gcc-2.96,objdump的版本是2.11.93.0.2,具體的版本信息可以通過以下的命令進行查詢: $ gcc -v Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.96/specs gcc version 2.96 20000731 (Red Hat Linux 7.3 2.96-110) $ objdump -v GNU objdump 2.11.93.0.2 20020207 Copyright 2002 Free Software Foundation, Inc.
GCC的擴展功能 由於本文中會用到GCC的擴展功能,即匯編器as中提供的.section偽操作,在文章開始之前我再作一個簡要的介紹。此偽操作對於不同的可執行文件格式有不同的解釋,我也不一一列舉,僅對我們所感興趣的Linux中常用的ELF格式的用法加以描述,其指令格式如下: .section NAME[, "FLAGS"] 大家所熟知的C程序一般由以下的幾個部分組成:代碼段(text section)、初始化數據段(data section)、非初始化數據段(bss section)、棧(heap)以及堆(stack),具體的地址空間布局可以參考《UNIX環境高級編程》一書。 在Linux內核中,通過使用.section的偽操作,可以把隨後的代碼匯編到一個由NAME指定的段中。而FLAGS字段則說明了該段的屬性,它可以用下面介紹的單個字符來表示,也可以是多個字符的組合。 'a' 可重定位的段 'w' 可寫段 'x' 可執行段 'W' 可合並的段 's' 共享段 舉個例子來說明,讀者在後面會看到的:.section .fixup, "ax" 這樣的一條指令定義了一個名為.fixup的段,隨後的指令會被加入到這個段中,該段的屬性是可重定位並可執行。
內核缺頁異常處理 運行在核心態的進程經常需要訪問用戶地址空間的內容,但是誰都無法保證內核所得到的這些從用戶空間傳入的地址信息是"合法"的。為了保護內核不受錯誤信息的攻擊,需要驗證這些從用戶空間傳入的地址信息的正確性。 在老版本的Linux中,這個工作是通過函數verify_area來完成的: extern inline int verify_area(int type, const void * addr, unsigned long size) 該函數驗證了是否可以以type中說明的訪問類型(read or write)訪問從地址addr開始、大小為size的一塊虛擬存儲區域。為了做到這一點,verify_read首先需要找到包含地址addr的虛擬存儲區域(vma)。一般的情況下(正確運行的程序)這個測試都會成功返回,在少數情況下才會出現失敗的情況。也就是說,大部分的情況下內核在一些無用的驗證操作上花費了不算短的時間,這從操作系統運行效率的角度來說是不可接受的。 為了解決這個問題,現在的Linux設計中將驗證的工作交給虛存中的硬件設備來完成。當系統啟動分頁機制以後,如果一條指令的虛擬地址所對應的頁框(page frame)不在內存中或者訪問的類型有錯誤,就會發生缺頁異常。處理器把引起缺頁異常的虛擬地址裝到寄存器CR2中,並提供一個出錯碼,指示引起缺頁異常的存儲器訪問的類型,隨後調用Linux的缺頁異常處理函數進行處理。 Linux中進行缺頁異常處理的函數如下: asmlinkage void do_page_fault(strUCt pt_regs *regs, unsigned long error_code) { …………………… __asm__("movl %%cr2,%0":"=r" (address)); …………………… vma = find_vma(mm, address); if (!vma) goto bad_area; if (vma->vm_start <= address) goto good_area; if (!(vma->vm_flags & VM_GROWSDOWN)) goto bad_area; if (error_code & 4) { if (address + 32 < regs->esp) goto bad_area; …………………… bad_area: …………………… no_context: /* Are we prepared to handle this kernel fault? */ if ((fixup = search_exception_table(regs->eip)) != 0) { regs->eip = fixup; return; } ……………………… } 首先讓我們來看看傳給這個函數調用的兩個參數:它們都是通過entry.S在堆棧中建立的(arch/i386/kernel/entry.S),參數regs指向保存在堆棧中的寄存器,error_code中存放著異常的出錯碼,具體的堆棧布局參見圖一(堆棧的生成過程請參考《Linux內核源代碼情景分析》一書) 該函數首先從CPU的控制寄存器CR2中獲取出現缺頁異常的虛擬地址。由於缺頁異常處理程序需要處理的缺頁異常類型很多,分支也很復雜。基於本文的主旨,我們只關心以下的幾種內核缺頁異常處理的情況: 1. 程序要訪問的內核地址空間的內容不在內存中,先跳轉到標號vmalloc_fault,如果當前訪問的內容所對應的頁目錄項不在內存中,再跳轉到標號no_context; 2. 缺頁異常發生在中斷或者內核線程中,跳轉到標號no_context; 3. 程序在核心態運行時訪問用戶空間的數據,被訪問的數據不在內存中 a) 出現異常的虛擬地址在進程的某個vma中,但是系統內存無法分配空閒頁框(page frame),則先跳轉到標號out_of_memory,再跳轉到標號no_context; b) 出現異常的虛擬地址不屬於進程任一個vma,而且不屬於堆棧擴展的范疇,則先跳轉到標號bad_area,最終也是到達標號no_context。 從上面的這幾種情況來看,我們關注的焦點最後集中到標號no_context處,即對函數search_exception_table的調用。這個函數的作用就是通過發生缺頁異常的指令(regs->eip)在異常表(exception table)中尋找下一條可以繼續運行的指令(fixup)。這裡提到的異常表包含一些地址對,地址對中的前一個地址表示出現異常的指令的地址,後一個表示當前一個指令出現錯誤時,程序可以繼續得以執行的修復地址。 如果這個查找操作成功的話,缺頁異常處理程序將堆棧中的返回地址(regs->eip)修改成修復地址並返回,隨後,發生異常的進程將按照fixup中安排好的指令繼續執行下去。當然,如果無法找到與之匹配的修復地址,系統只有打印出出錯信息並停止運作。 那麼,這個所謂的修復地址又是如何生成的呢?是系統自動生成的嗎?答案當然是否定的,這些修復指令都是編程人員通過as提供的擴展功能寫進內核源碼中的。下面我們就來分析一下其實現機制。
異常表的實現機制 筆者取include/asm-i386/uAccess.h中的宏定義__copy_user編寫了一段程序作為例子加以講解。 /* hello.c */ #include #include #define __copy_user(to,from,size) do { int __d0, __d1; __asm__ __volatile__( "0: rep; movsl\n" " movl %3,%0\n" "1: rep; movsb\n" "2:\n" ".section .fixup,\"ax\"\n" "3: lea 0(%3,%0,4),%0\n" " jmp 2b\n" ".previous\n" ".section __ex_table,\"a\"\n" " .align 4\n" " .long 0b,3b\n" " .long 1b,2b\n" ".previous" : "=&c"(size), "=&D" (__d0), "=&S" (__d1) : "r"(s