linux copy_from/to_user原理
轉載地址:http://www.poluoluo.com/server/201107/138420.html
在研究dahdi驅動的時候,見到了一些get_user,put_user的函數,不知道其來由,故而搜索了這篇文章,前面對Linux內存的框架描述不是很清晰,描述的有一點亂,如果沒有剛性需求,建議不用怎么關注,倒不如直接看那幾個圖片。對我非常有用的地方就是幾個函數的介紹,介紹的比較詳細,對應用有需求的可以著重看一個這幾個函數。
Linux 內存
在 Linux 中,用戶內存和內核內存是獨立的,在各自的地址空間實現。地址空間是虛擬的,就是說地址是從物理內存中抽象出來的(通過一個簡短描述的過程)。由于地址空間是虛擬的,所以可以存在很多。事實上,內核本身駐留在一個地址空間中,每個進程駐留在自己的地址空間。這些地址空間由虛擬內存地址組成,允許一些帶有獨立地址空間的進程指向一個相對較小的物理地址空間(在機器的物理內存中)。不僅僅是方便,而且更安全。因為每個地址空間是獨立且隔離的,因此很安全。
但是與安全性相關聯的成本很高。因為每個進程(和內核)會有相同地址指向不同的物理內存區域,不可能立即共享內存。幸運的是,有一些解決方案。用戶進程可以通過 Portable Operating System Interface for UNIX? (POSIX) 共享的內存機制(shmem)共享內存,但有一點要說明,每個進程可能有一個指向相同物理內存區域的不同虛擬地址。
虛擬內存到物理內存的映射通過頁表完成,這是在底層軟件中實現的(見圖 1)。硬件本身提供映射,但是內核管理表及其配置。注意這里的顯示,進程可能有一個大的地址空間,但是很少見,就是說小的地址空間的區域(頁面)通過頁表指向物理內存。這允許進程僅為隨時需要的網頁指定大的地址空間。
圖 1. 頁表提供從虛擬地址到物理地址的映射?
由于缺乏為進程定義內存的能力,底層物理內存被過度使用。通過一個稱為 paging(然而,在 Linux 中通常稱為 swap)的進程,很少使用的頁面將自動移到一個速度較慢的存儲設備(比如磁盤),來容納需要被訪問的其它頁面(見圖 2 )。這一行為允許,在將很少使用的頁面遷移到磁盤來提高物理內存使用的同時,計算機中的物理內存為應用程序更容易需要的頁面提供服務。注意,一些頁面可以指向文件,在這種情況下,如果頁面是臟(dirty)的,數據將被沖洗,如果頁面是干凈的(clean),直接丟掉。
圖 2. 通過將很少使用的頁面遷移到速度慢且便宜的存儲器,交換使物理內存空間得到了更好的利用?
MMU-less?架構
不是所有的處理器都有 MMU。因此,uClinux 發行版(微控制器 Linux)支持操作的一個地址空間。該架構缺乏 MMU 提供的保護,但是允許 Linux 運行另一類處理器。
選擇一個頁面來交換存儲的過程被稱為一個頁面置換算法,可以通過使用許多算法(至少是最近使用的)來實現。該進程在請求存儲位置時發生,存儲位置的頁面不在存儲器中(在存儲器管理單元 [MMU] 中無映射)。這個事件被稱為一個頁面錯誤 并被硬件(MMU)刪除,出現頁面錯誤中斷后該事件由防火墻管理。該棧的詳細說明見 圖 3。
Linux 提供一個有趣的交換實現,該實現提供許多有用的特性。Linux 交換系統允許創建和使用多個交換分區和優先權,這支持存儲設備上的交換層次結構,這些存儲設備提供不同的性能參數(例如,固態磁盤 [SSD] 上的一級交換和速度較慢的存儲設備上的較大的二級交換)。為 SSD 交換附加一個更高的優先級使其可以使用直至耗盡;直到那時,頁面才能被寫入優先級較低的交換分區。
圖 3. 地址空間和虛擬 - 物理地址映射的元素?
并不是所有的頁面都適合交換。考慮到響應中斷的內核代碼或者管理頁表和交換邏輯的代碼,顯然,這些頁面決不能被換出,因此它們是固定的,或者是永久地駐留在內存中。盡管內核頁面不需要進行交換,然而用戶頁面需要,但是它們可以被固定,通過 mlock(或 mlockall)函數來鎖定頁面。這就是用戶空間內存訪問函數的目的。如果內核假設一個用戶傳遞的地址是有效的且是可訪問的,最終可能會出現內核嚴重錯誤(kernel panic)(例如,因為用戶頁面被換出,而導致內核中的頁面錯誤)。該應用程序編程接口(API)確保這些邊界情況被妥善處理。
內核 API
現在,讓我們來研究一下用戶操作用戶內存的內核 API。請注意,這涉及內核和用戶空間接口,而下一部分將研究其他的一些內存 API。用戶空間內存訪問函數在表 1 中列出。
表 1. 用戶空間內存訪問 API
| 函數 | 描述 |
| access_ok | 檢查用戶空間內存指針的有效性 |
| get_user | 從用戶空間獲取一個簡單變量 |
| put_user | 輸入一個簡單變量到用戶空間 |
| clear_user | 清除用戶空間中的一個塊,或者將其歸零。 |
| copy_to_user | 將一個數據塊從內核復制到用戶空間 |
| copy_from_user | 將一個數據塊從用戶空間復制到內核 |
| strnlen_user | 獲取內存空間中字符串緩沖區的大小 |
| strncpy_from_user | 從用戶空間復制一個字符串到內核 |
正如您所期望的,這些函數的實現架構是獨立的。例如在 x86 架構中,您可以使用 ./linux/arch/x86/lib/usercopy_32.c 和 usercopy_64.c 中的源代碼找到這些函數以及在 ./linux/arch/x86/include/asm/uaccess.h 中定義的字符串。
當數據移動函數的規則涉及到復制調用的類型時(簡單 VS. 聚集),這些函數的作用如圖 4 所示。
圖 4. 使用 User Space Memory Access API 進行數據移動?
access_ok 函數
您可以使用 access_ok 函數在您想要訪問的用戶空間檢查指針的有效性。調用函數提供指向數據塊的開始的指針、塊大小和訪問類型(無論這個區域是用來讀還是寫的)。函數原型定義如下:
access_ok( type, addr, size );
type 參數可以被指定為 VERIFY_READ 或 VERIFY_WRITE。VERIFY_WRITE 也可以識別內存區域是否可讀以及可寫(盡管訪問仍然會生成 -EFAULT)。該函數簡單檢查地址可能是在用戶空間,而不是內核。
get_user 函數
要從用戶空間讀取一個簡單變量,可以使用 get_user 函數,該函數適用于簡單數據類型,比如,char 和 int,但是像結構體這類較大的數據類型,必須使用 copy_from_user 函數。該原型接受一個變量(存儲數據)和一個用戶空間地址來進行 Read 操作:
get_user( x, ptr );
get_user 函數將映射到兩個內部函數其中的一個。在系統內部,這個函數決定被訪問變量的大小(根據提供的變量存儲結果)并通過 __get_user_x 形成一個內部調用。成功時該函數返回 0,一般情況下,get_user 和 put_user 函數比它們的塊復制副本要快一些,如果是小類型被移動的話,應該用它們。
put_user 函數
您可以使用 put_user 函數來將一個簡單變量從內核寫入用戶空間。和 get_user 一樣,它接受一個變量(包含要寫的值)和一個用戶空間地址作為寫目標:
put_user( x, ptr );
和 get_user 一樣,put_user 函數被內部映射到 put_user_x 函數,成功時,返回 0,出現錯誤時,返回 -EFAULT。
clear_user 函數
clear_user 函數被用于將用戶空間的內存塊清零。該函數采用一個指針(用戶空間中)和一個型號進行清零,這是以字節定義的:
clear_user( ptr, n );
在內部,clear_user 函數首先檢查用戶空間指針是否可寫(通過 access_ok),然后調用內部函數(通過內聯組裝方式編碼)來執行 Clear 操作。使用帶有 repeat 前綴的字符串指令將該函數優化成一個非常緊密的循環。它將返回不可清除的字節數,如果操作成功,則返回 0。
copy_to_user 函數
copy_to_user 函數將數據塊從內核復制到用戶空間。該函數接受一個指向用戶空間緩沖區的指針、一個指向內存緩沖區的指針、以及一個以字節定義的長度。該函數在成功時,返回 0,否則返回一個非零數,指出不能發送的字節數。
copy_to_user( to, from, n );
檢查了向用戶緩沖區寫入的功能之后(通過 access_ok),內部函數 __copy_to_user 被調用,它反過來調用 __copy_from_user_inatomic(在 ./linux/arch/x86/include/asm/uaccess_XX.h 中。其中 XX 是 32 或者 64 ,具體取決于架構。)在確定了是否執行 1、2 或 4 字節復制之后,該函數調用 __copy_to_user_ll,這就是實際工作進行的地方。在損壞的硬件中(在 i486 之前,WP 位在管理模式下不可用),頁表可以隨時替換,需要將想要的頁面固定到內存,使它們在處理時不被換出。i486 之后,該過程只不過是一個優化的副本。
copy_from_user 函數
copy_from_user 函數將數據塊從用戶空間復制到內核緩沖區。它接受一個目的緩沖區(在內核空間)、一個源緩沖區(從用戶空間)和一個以字節定義的長度。和 copy_to_user 一樣,該函數在成功時,返回 0 ,否則返回一個非零數,指出不能復制的字節數。
copy_from_user( to, from, n );
該函數首先檢查從用戶空間源緩沖區讀取的能力(通過 access_ok),然后調用 __copy_from_user,最后調用 __copy_from_user_ll。從此開始,根據構架,為執行從用戶緩沖區到內核緩沖區的零拷貝(不可用字節)而進行一個調用。優化組裝函數包含管理功能。
----------------------------------------------------------------------------------------------------------------------------------
copy_to_user分析
在學習Linux內核驅動的時候,一開始就會碰到copy_from_user和copy_to_user這兩個常用的函數。這兩個函數在內核使用的非常頻繁,負責將數據從用戶空間拷貝到內核空間以及將數據從內核空間拷貝到用戶空間。在4年半前初學Linux內核驅動程序的時候,我只是知道這個怎么用,并沒有很深入的分析這兩個函數。這次研究內核模塊掛載的時候,又碰到了它們。決定還是認真跟蹤一下函數。首先這兩個函數的原型在arch/arm/include/asm/uaccess.h文件中:
這兩個函數從結構上來分析,其實都可以分為兩個部分: 1、首先檢查用戶空間的地址指針是否有效(難點) 2、調用__copy_from_user和__copy_to_user函數
在這個分析中,我們先易后難。首先看看具體數據拷貝功能的__copy_from_user和__copy_to_user函數
對于ARM構架,沒有單獨實現這兩個函數,所以他們的代碼位于include/asm-generic/uaccess.h
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 好了如何拷貝數據我們已經了解了,現在我們來看看前面的用戶空間指針檢測函數access_ok,這其實是一個宏定義,位于arch/arm/include/asm/uaccess.h文件中:
現在我們來仔細分析__range_ok這個宏:
(1)unsigned long flag, roksum;\\定義兩個變量
(2)__chk_user_ptr(addr);\\定義是一個空函數
? ? ?但是這個函數涉及到__CHECKER__宏的判斷,__CHECKER__宏在通過Sparse(Semantic Parser for C)工具對內核代碼進行檢查時會定義的。在使用make C=1或C=2時便會調用該工具,這個工具可以檢查在代碼中聲明了sparse所能檢查到的相關屬性的內核函數和變量。
? ? 如果定義了__CHECKER__,在網上的資料中這樣解釋的:__chk_user_ptr和__chk_io_ptr在這里只聲明函數,沒有函數體,目的就是在編譯過程中Sparse能夠捕捉到編譯錯誤,檢查參數的類型。
? ? 如果沒有定義__CHECKER__,這就是一個空函數。
(3)接下來的匯編,我適當地翻譯如下:
? ? ?adds %1, %2, %3
roksum =?addr +?size 這個操作影響狀態位(目的是影響是進位標志C)
以下的兩個指令都帶有條件CC,也就是當C=0的時候才執行。
如果上面的加法指令進位了(C=1),則以下的指令都不執行,flag就為初始值current_thread_info()->addr_limit(非零值),并返回。
如果沒有進位(C=0),就執行下面的指令
? ? sbcccs %1, %1, %0?
roksum =?roksum -?flag,也就是(addr +?size)- (current_thread_info()->addr_limit),操作影響符號位。
如果(addr +?size)>=(current_thread_info()->addr_limit),則C=1
如果(addr +?size)<(current_thread_info()->addr_limit),則C=0
當C=0的時候執行以下指令,否則跳過(flag非零)。 ? ??movcc %0, #0 ? ? flag = 0,給flag賦值0
?(4)flag;?
? ? 返回flag值
綜上所訴:__range_ok宏其實等價于:
如果(addr +?size)>=(current_thread_info()->addr_limit),返回非零值
如果(addr +?size)<(current_thread_info()->addr_limit),返回零
而access_ok就是檢驗將要操作的用戶空間的地址范圍是否在當前進程的用戶地址空間限制中。這個宏的功能很簡單,完全可以用C實現,不是必須使用匯編。個人理解:由于這兩個函數使用頻繁,就使用匯編來實現部分功能來增加效率。
? ??從這里再次可以認識到,copy_from_user與copy_to_user的使用是結合進程上下文的,因為他們要訪問“user”的內存空間,這個“user”必須是某個特定的進程。通過上面的源碼就知道,其中使用了current_thread_info()來檢查空間是否可以訪問。如果在驅動中使用這兩個函數,必須是在實現系統調用的函數中使用,不可在實現中斷處理的函數中使用。如果在中斷上下文中使用了,那代碼就很可能操作了根本不相關的進程地址空間。
? ? 其次由于操作的頁面可能被換出,這兩個函數可能會休眠,所以同樣不可在中斷上下文中使用。
用戶進程傳來的地址是虛擬地址,這段虛擬地址可能還未真正分配對應的物理地址。對于用戶進程訪問虛擬地址,如果還未分配物理地址,就會觸發內核缺頁異常,接著內核會負責分配物理地址,并修改映射頁表。這個過程對于用戶進程是完全透明的。但是在內核空間發生缺頁時,必須顯式處理,否則會導致內核oops。
-----------------------------------------------------------------------------------
關于access_ok中的檢查如果(addr +?size)<(current_thread_info()->addr_limit),返回零,閱讀如下內容有助于理解
#include?<linux/kernel.h>
#include?<linux/module.h>
#include?<linux/init.h>
#include?<linux/fs.h>
#include?<linux/string.h>
#include?<linux/mm.h>
#include?<linux/syscalls.h>
#include?<asm/unistd.h>
#include?<asm/uaccess.h>
#define?MY_FILE?"/root/LogFile"
char?buf[128];
struct?file?*file?=?NULL;
static?int?__init init(void)
{
????????mm_segment_t old_fs;
????????printk("Hello, I'm the module that intends to write messages to file.\n");
????????if(file?==?NULL)
????????????????file?=?filp_open(MY_FILE,?O_RDWR?|?O_APPEND?|?O_CREAT,?0644);
????????if?(IS_ERR(file))?{
????????????????printk("error occured while opening file %s, exiting...\n",?MY_FILE);
????????????????return?0;
????????}
????????sprintf(buf,"%s",?"The Messages.");
????????old_fs?=?get_fs();
????????set_fs(KERNEL_DS);
????????file->f_op->write(file,?(char?*)buf,?sizeof(buf),?&file->f_pos);
????????set_fs(old_fs);
????????return?0;
}
static?void?__exit fini(void)
{
????????if(file?!=?NULL)
????????????????filp_close(file,?NULL);
}
module_init(init);
module_exit(fini);
MODULE_LICENSE("GPL");
其中:
????typedef?struct?{
?????????unsigned?long?seg;
????}?mm_segment_t;
????#define?KERNEL_DS????MAKE_MM_SEG(0xFFFFFFFFUL)
????#define?MAKE_MM_SEG(s)????((mm_segment_t)?{?(s)?})
基本思想:
???一個是要記得編譯的時候加上-D__KERNEL_SYSCALLS__ ??
? 另外源文件里面要#include ? <linux/unistd.h> ??
? 如果報錯,很可能是因為使用的緩沖區超過了用戶空間的地址范圍。一般系統調用會要求你使用的緩沖區不能在內核區。這個可以用set_fs()、get_fs()來解決。在讀寫文件前先得到當前fs: ??
? mm_segment_t ? old_fs=get_fs(); ??
? 并設置當前fs為內核fs:set_fs(KERNEL_DS); ??
? 在讀寫文件后再恢復原先fs: ? set_fs(old_fs); ??
? set_fs()、get_fs()等相關宏在文件include/asm/uaccess.h中定義。 ??
? 個人感覺這個辦法比較簡單。 ??
? ??
? 另外就是用flip_open函數打開文件,得到struct file *的指針fp。使用指針fp進行相應操作,如讀文件可以用fp->f_ops->read。最后用filp_close()函數關閉文件。 filp_open()、filp_close()函數在fs/open.c定義,在include/linux/fs.h中聲明。??
解釋一點:
????系 統調用本來是提供給用戶空間的程序訪問的,所以,對傳遞給它的參數(比如上面的buf),它默認會認為來自用戶空間,在->write()函數中, 為了保護內核空間,一般會用get_fs()得到的值來和USER_DS進行比較,從而防止用戶空間程序“蓄意”破壞內核空間;
?? 而現在要在內核空間使用系統調用,此時傳遞給->write()的參數地址就是內核空間的地址了,在USER_DS之上(USER_DS ~ KERNEL_DS),如果不做任何其它處理,在write()函數中,會認為該地址超過了USER_DS范圍,所以會認為是用戶空間的“蓄意破壞”,從 而不允許進一步的執行; 為了解決這個問題; set_fs(KERNEL_DS);將其能訪問的空間限制擴大到KERNEL_DS,這樣就可以在內核順利使用系統調用了!
補充:
??? 我看了一下源碼,在include/asm/uaccess.h中,有如下定義:?
????#define MAKE_MM_SEG(s) ((mm_segment_t) { (s) })?
??? #define KERNEL_DS MAKE_MM_SEG(0xFFFFFFFF)?
??? #define USER_DS MAKE_MM_SEG(PAGE_OFFSET)?
??? #define get_ds() (KERNEL_DS)?
??? #define get_fs() (current->addr_limit)?
??? #define set_fs(x) (current->addr_limit = (x))?
而它的注釋也很清楚:?
/*?
* The fs value determines whether argument validity checking should be?
* performed or not. If get_fs() == USER_DS, checking is performed, with?
* get_fs() == KERNEL_DS, checking is bypassed.?
*?
* For historical reasons, these macros are grossly misnamed.?
*/?
因此可以看到,fs的值是作為是否進行參數檢查的標志。系統調用的參數要求必須來自用戶空間,所以,當在內核中使用系統調用的時候,set_fs(get_ds())改變了用戶空間的限制,即擴大了用戶空間范圍,因此即可使用在內核中的參數了
----------------------------------------------------------------------------------------------------
下文介紹了內核處理缺頁中斷的機制。 利用異常表處理 Linux 內核態缺頁異常
前言
在程序的執行過程中,因為遇到某種障礙而使 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 <stdio.h>
#include <string.h>
#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 <.fixup>:
8048530: 8d 4c 88 00 ? ? ? ? ?lea ? ?0x0(%eax,%ecx,4),%ecx
8048534: e9 79 ff ff ff ? ? ??jmp ? ?80484b2 <main+0x42>
由此可見,.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內核中的異常處理方法。在系統軟件的設計中,異常表也應該成為一種提高系統穩定性的重要手段
總結
以上是生活随笔為你收集整理的linux copy_from/to_user原理的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: vmalloc 实现
- 下一篇: driver: linux2.6 内核模