在程序的執行過程中,因為遇到某種障礙而使 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 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"(size & 3), "0"(size / 4), "1"(to), "2"(from) : "memory"); } while (0) int main(void) { const char *string = "Hello, world!"; char buf[20]; unsigned long n, m; m = n = strlen(string); __copy_user(buf, string, n); buf[m] = '\0'; printf("%s\n", buf); exit(0); }
先看看本程序的執行結果:
$ gcc hello.c -o hello $ ./hello Hello, world!
顯然,這就是一個簡單的"hello world"程序,那為什麼要寫得這麼復雜呢?程序中的一大段匯編代碼在內核中才能體現出其價值,筆者將其加入到上面的程序中,是為了後面的分析而准備的。
系統在核心態運行的時候,參數是通過寄存器來傳遞的,由於寄存器所能夠傳遞的信息有限,所以傳遞的參數大多數是指針。要使用指針所指向的更大塊的數據,就需要將用戶空間的數據拷貝到系統空間來。上面的__copy_user在內核中正是扮演著這樣的一個拷貝數據的角色,當然,內核中這樣的宏定義還很多,筆者也只是取其中的一個來講解,讀者如果感興趣的話可以看完本文以後自行學習。
如果讀者對於簡單的嵌入式匯編還不是很了解的話,可以參考《Linux內核源代碼情景分析》一書。下面我們將程序編譯成匯編程序來加以分析:
$ gcc -S hello.c /* hello.s */ movl -60(%ebp), %eax andl $3, %eax movl -60(%ebp), %edx movl %edx, %ecx shrl $2, %ecx leal -56(%ebp), %edi movl -12(%ebp), %esi #APP 0: rep; movsl movl %eax,%ecx 1: rep; movsb 2: .section .fixup,"ax" 3: lea 0(%eax,%ecx,4),%ecx jmp 2b .previous .section __ex_table,"a" .align 4 .long 0b,3b .long 1b,2b .previous #NO_APP movl %ecx, %eax
從上面通過gcc生成的匯編程序中,我們可以很容易的找到訪問用戶地址空間的指令,也就是程序中的標號為0和1的兩條語句。而程序中偽操作.section的作用就是定義了.fixup和__ex_table這樣的兩個段,那麼這兩段在可執行程序中又是如何安排的呢?下面就通過objdump給讀者一個直觀的概念:
$ objdump --section-headers hello hello: file format elf32-i386 Sections: Idx Name Size VMA LMA File off Algn 0 .interp 00000013 080480f4 080480f4 000000f4 2**0 CONTENTS, ALLOC, LOAD, READONLY, DATA ……………………………… 9 .init 00000018 080482e0 080482e0 000002e0 2**2 CONTENTS, ALLOC, LOAD, READONLY, CODE 10 .plt 00000070 080482f8 080482f8 000002f8 2**2 CONTENTS, ALLOC, LOAD, READONLY, CODE 11 .text 000001c0 08048370 08048370 00000370 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE 12 .fixup 00000009 08048530 08048530 00000530 2**0 CONTENTS, ALLOC, LOAD, READONLY, CODE 13 .fini 0000001e 0804853c 0804853c 0000053c 2**2 CONTENTS, ALLOC, LOAD, READONLY, CODE 14 .rodata 00000019 0804855c 0804855c 0000055c 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 15 __ex_table 00000010 08048578 08048578 00000578 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 16 .data 00000010 08049588 08049588 00000588 2**2 CONTENTS, ALLOC, LOAD, DATA CONTENTS, READONLY ……………………………… 26 .note 00000078 00000000 00000000 0000290d 2**0 CONTENTS, READONLY
上面通過objdump顯示出來的可執行程序的頭部信息中,有一些是讀者所熟悉的,例如.text、.data以及被筆者省略掉的.bss,而我們所關心的是12和15,也就是.fixup和__ex_table。對照hello.s中段的定義來看,兩個段聲明中的FLAGS字段分別為'ax'和'a',而objdump的結果顯示,.fixup段是可重定位的代碼段,__ex_table段是可重定位的數據段,兩者是吻合的。
那麼為什麼要通過.section定義獨立的段呢?為了解開這個問題的答案,我們需要進一步看看我們所寫的代碼在可執行文件中是如何表示的。
$objdump --disassemble --section=.text hello hello: file format elf32-i386 Disassembly of section .text: 8048498: 8b 45 c4 mov 0xffffffc4(%ebp),%eax 804849b: 83 e0 03 and $0x3,%eax 804849e: 8b 55 c4 mov 0xffffffc4(%ebp),%edx 80484a1: 89 d1 mov %edx,%ecx 80484a3: c1 e9 02 shr $0x2,%ecx 80484a6: 8d 7d c8 lea 0xffffffc8(%ebp),%edi 80484a9: 8b 75 f4 mov 0xfffffff4(%ebp),%esi 80484ac: f3 a5 repz movsl %ds:(%esi),%es:(%edi) 80484ae: 89 c1 mov %eax,%ecx 80484b0: f3 a4 repz movsb %ds:(%esi),%es:(%edi) 80484b2: 89 c8 mov %ecx,%eax
前面的hello.s中的匯編片斷在可執行文件中就是通過上面的11條指定來表達,讀者也許會問,由.section偽操作定義的段怎麼不見了?別著急,慢慢往下看,由.section偽操作定義的段並不在正常的程序執行路徑上,它們是被安排在可執行文件的其它地方了:
$objdump --disassemble --section=.fixup hello hello: file format elf32-i386 Disassembly of section .fixup: 08048530 : 8048530: 8d 4c 88 00 lea 0x0(%eax,%ecx,4),%ecx 8048534: e9 79 ff ff ff jmp 80484b2
由此可見,.fixup是作為一個單獨的段出現在可執行程序中的,而此段中所包含的語句則正好是和源程序hello.c中的兩條語句相對應的。
將.fixup段和.text段獨立開來的目的是為了提高CPU流水線的利用率。熟悉體系結構的讀者應該知道,當前的CPU引入了流水線技術來加快指令的執行,即在執行當前指令的同時,要將下面的一條甚至多條指令預取到流水線中。這種技術在面對程序執行分支的時候遇到了問題:如果預取的指令並不是程序下一步要執行的分支,那麼流水線中的所有指令都要被排空,這對系統的性能會產生一定的影響。在我們的這個程序中,如果將.fixup段的指令安排在正常執行的.text段中,當程序執行到前面的指令時,這幾條很少執行的指令會被預取到流水線中,正常的執行必然會引起流水線的排空操作,這顯然會降低整個系統的性能。
下面我們就可以看到異常表是如何形成的了:
$objdump --full-contents --section=__ex_table hello hello: file format elf32-i386 Contents of section __ex_table: 8048578 ac840408 30850408 b0840408 b2840408 ....0...........
由於x86使用小尾端的編址方式,上面的這段數據比較凌亂。讓我把上面的__ex_table中的內容轉變成大家通常看到的樣子,相信會更容易理解一些:
8048578 80484ac 8048530 80484b0 80484b2 ....0...........
上面的紅色部分就是我們最感興趣的地方,而這段數據是如何形成的呢?將前面objdump生成的可執行程序中的匯編語句和hello.c中的源程序結合起來看,就可以發現一些有趣的東西了!
先讓我們回頭看看hello.c中__ex_table段的語句 .long 0b,3b。其中標簽0b(b代表backward,即往回的標簽0)是可能出現異常的指令的地址。結合objdump生成的可執行程序.text段的匯編語句可以知道標簽0就是80484ac:
原始的匯編語句:
0: rep; movsl
鏈接到可執行程序後:
80484ac: f3 a5 repz movsl %ds:(%esi),%es:(%edi)
而標簽3就是處理異常的指令的地址,在我們的這個例子中就是80484b0。
原始的匯編語句:
3: lea 0(%eax,%ecx,4),%ecx
鏈接到可執行程序後:
8048530: 8d 4c 88 00 lea 0x0(%eax,%ecx,4),%ecx
因此,相應的匯編語句:
.section __ex_table,"a" .align 4 .long 0b,3b
就變成了:
8048578 80484ac 8048530 …………
這樣,異常表中的地址對(80484ac,8048530)就誕生了,而對於地址對(80484b0 80484b2)的生成,情況相同,不再贅述。
讀到這兒了,有一件事要告訴讀者的是,其實例子中異常表的安排在用戶空間是不會得到執行的。當運行在用戶態的進程訪問到標簽0處的指令出現缺頁異常時,do_page_fault只會將該指令對應的進程頁調入內存中,使指令能夠重新正確執行,或者直接就殺死該進程,並不會到達函數search_exception_table處。
也許有的讀者會問了,既然不執行,前面的例子和圍繞例子所展開的討論又有什麼作用呢?大家大可打消這樣的疑慮,我們前面的分析並沒有白費,因為真正的內核異常表中地址對的生成機制和前面講述的原理是完全一樣的,筆者通過一個運行在用戶空間的程序來講解也是希望讓讀者能夠更加容易的理解異常表的機制,不至於陷入到內核源碼的汪洋大海中去。現在,我們可以自己通過objdump工具查看一下內核中的異常表:
$objdump --full-contents --section=__ex_table vmlinux vmlinux: file format elf32-i386 Contents of section __ex_table: c024ac80 e36d10c0 e66d10c0 8b7110c0 6c7821c0 ……………………
做一下轉化:
c024ac80 c0106de3 c0106de6 c010718b c021786c
上面的vmlinux就是編譯內核所生成的內核可執行程序。和本文給出的例子相比,唯一的不同就是此時的地址對中的異常指令地址和修復地址都是內核空間的虛擬地址。也正是在內核中,異常表才真正發揮著它應有的作用。
總結
下面我對前面所講述的內容做一個歸納,希望讀者能夠對內核缺頁異常處理有一個清楚的認識:
進程訪問內核地址空間的"非法"地址c010718b,存儲管理部件(MMU)產生一個缺頁異常;CPU調用函數do_page_fault;do_page_fault調用函數search_exception_table(regs->eip == c010718b);search_exception_table在異常表中查找地址c010718b,並返回地址對中的修復地址c021786c; do_page_fault將堆棧中的返回地址eip修改成c021786c並返回;代碼按照缺頁異常處理程序的返回地址繼續執行,也就是從c021786c開始繼續執行。
將驗證用戶空間地址信息"合法"性的工作交給硬件來完成(通過缺頁異常的方式)其實就是一種Lazy Computation,也就是等到真正出現缺頁異常的時候才進行處理。通過本文的分析可以看出,這種方法與本文前面所提到的通過verify_area來驗證的方法相比,較好的避免了系統在無用驗證上的開銷,能夠有效的提高系統的性能。此外,在分析源碼的過程中讀者會發現,異常表並不僅僅用在缺頁異常處理程序中,在通用保護(General Protection)異常等地方,也同樣用到了這一技術。
由此可見,異常表是一種廣泛應用於Linux內核中的異常處理方法。在系統軟件的設計中,異常表也應該成為一種提高系統穩定性的重要手段。