1. 程式人生 > >Linux核心分析(四)系統呼叫,使用者態及核心態

Linux核心分析(四)系統呼叫,使用者態及核心態

禹曉博+ 原創作品轉載請註明出處 + 歡迎加入《Linux核心分析》MOOC網易雲課堂學習

一、什麼是系統呼叫

我們知道由於種種原因(就是安全穩定性大部分)的考慮,作業系統是不能讓使用者直接進行一些有可能破換系統的行為,實際上還有另外一部分原因及時基於封裝性的考慮。作業系統往往需要進行很多硬體的適配工作,而程式設計師關心的是如何正確執行一個功能。這兩者之間有很多矛盾(同樣的功能實際上由於硬體的不同可能會在實現上大相徑庭)。所以系統會向上給使用者(就是程式設計師)提供一些函式介面(就是一些功能模組,你用他們就可以忽略種種差異,完成相同的功能)有一些很和平就是沒什麼危險了比如你算個sin(x)一般不會把系統搞崩潰。但是有些就比較恐怖,比如檔案操作,記憶體訪問。這些會導致系統進入一種不安全可能的狀態。實際上大多數情況下我們也並不是要做什麼壞事,但是這個高級別的訪問如果程式碼 出現錯誤和bug那麼就很容易引起系統錯誤(CSAPP中有描述利用輸入堆疊的漏洞來串改程式執行路徑,詳見CSAPP書上的官方實驗)。所以這些時候系統會給使用者一個許可權告訴系統使用者要做的這種操作(系統呼叫號和跳轉地址也就是所謂的中斷向量)的種類和操作所需要的輸入的引數(入口引數),然後具體的功能讓系統核心中的一些能夠實現這個功能系統函式來實現。這樣會避免不必要的麻煩,同時也體現了對不同硬體的透明性。(就是我只要open了,無論是在PC上open還是嵌入式裝置上open我都是開啟一個檔案的意思,open就是個系統呼叫實際上)

         那麼重點來了我們來總結一下:“作業系統為使用者態程序與硬體裝置進行互動提供了一組介面——系統呼叫”。其作用是什麼呢?1、把使用者從底層的硬體程式設計中解放出來;2、極大的提高了系統的安全性;3、使使用者程式具有可移植性。

        那麼問題來了使用者如何通知系統進行這種呼叫呢?我們有一個叫做使用者程式介面(application program interface, API)的專門就是用來給系統說明用什麼功能的。和系統呼叫不同的是他僅僅在我們開來就是一個函式,而系統呼叫是通過軟中斷向核心發出一個明確的功能請求。(稍後的實驗我們可以看到這一點)。

        那麼還有一個問題就是所謂的軟中斷的入口引數是什麼呢,實際上就和函式的引數列表一樣,我們除了知道他的中斷號還要知道功能程式碼還有就是要知道他的輸入是什麼(見Linux核心分析一)這就是入口引數。那麼系統呼叫 通過軟中斷或系統呼叫指令向核心發出一個明確的請求,核心將呼叫核心相關函式來實現(如sys_read() , sys_write() , sys_fork())。使用者程式不能直接呼叫這些Sys_read,sys_write等函式。這些函式執行在核心態。

二、系統呼叫與API之間的關係

        通常API函式庫(如glibc)中的函式會呼叫封裝例程,封裝例程負責發起系統呼叫(通過發軟中斷或系統呼叫指令),這些都執行在使用者態。核心開始接收系統呼叫後,cpu從使用者態切換到核心態(cpu處於什麼狀態,程式就叫處於什麼狀態,所以很多地方也說程式從使用者態切換到核心態,實際是cpu執行級別的切換,通常cpu 執行在3級表示使用者態,cpu 執行在0級表示核心態),核心呼叫相關的核心函式來處理再逐步返回給封裝例程,cpu進行一次核心態到使用者態的切換,API函式從封裝例程拿到結果,再處理完後返回給使用者。

         但是PI函式不一定需要進行系統呼叫,如某些數學函式,沒有必要進行系統呼叫,直接glibc裡面就給處理了,整個過程執行在使用者態。所以作為我們編寫linux使用者程式的時候,是不能直接呼叫核心裡面的函式的,核心裡面的函式位於程序虛擬地址空間裡面的核心空間,使用者空間函式及函式庫都處於程序虛擬地址空間裡面的使用者空間,使用者空間呼叫核心空間的函式只有一個通道,這個通道就是系統呼叫指令,所以通常要呼叫glibc等庫的介面函式,glibc也是使用者空間的,但glibc自己實現了呼叫特殊的巨集彙編系統呼叫指令進行cpu執行狀態的切換,把程序從使用者空間切換到核心空間。

      下面我們看一張比較經典的圖:


        上面這張圖可以比較清楚地看到這一過程:使用者態xyz()函式,核心最終一般會呼叫形如sys_xyz()的服務例程來處理(當然了名字肯定不會這麼對應,我們只是想表達xyz()在核心中有對應的系統呼叫)  函式xyz()是提供給使用者程式設計使用的。系統則是通過sys_xyz()來實現這個過程的。圖中“SYSCALL”,“S Y S E X I T”表示真正的彙編指令(彙編指令具體呼叫的是哪個暫時不關心,我們只需在此關注發起和退出了一個系統呼叫)。

        在發起系統呼叫的時候我們看到,xyz()函式執行的過程中會執行SYSCALL彙編指令,此指令將會把cpu從使用者態切換到核心態。SYACALL彙編指令中會包含將要呼叫的核心函式的系統呼叫號和引數,核心在上圖系統呼叫處理程式中去查一個sys_call_talbe陣列來找到這個系統呼叫號對應的服務例程(如sys_xyz())函式的地址,然後呼叫這個地址的函式執行。(這裡glibc裡面的系統呼叫號和核心裡面的系統呼叫號必須完全相等,當然,這是約定好的)

        系統用返回:服務例程(如sys_xyz())函式返回值一般返回正數和0表示系統呼叫成功結束,而負數表示一個出錯條件。緊接著S Y S E X I T退出系統呼叫,此指令將cpu從核心態切換到使用者態,glibc針對系統呼叫返回值如果出錯則需要設定好errno(通常在c庫標頭檔案/usr/include/errno.h中),然後返回一個值做為glibc封裝例程的返回值(如xyz()的返回值)。這裡errno是libc自己用來定義的出錯碼,不一定是最後gblic封裝例程的返回值。

三、系統呼叫實現分析

         實際上偶爾們可以去看看這個sys_call_talbe,他就在./include/sys.h中


        這就是個那個陣列實際上每個都指向了一個系統呼叫功能,我們知道系統呼叫是一個軟中斷,中斷號是0x80,它是上層應用程式與Linux系統核心進行互動通訊的唯一介面。這個中斷的設定在kernel/sched.c中。


        最後一句就將0x80中斷與system_call(系統呼叫)聯絡起來。通過int 0x80,就可使用核心資源。不過,通常應用程式都是使用具有標準介面定義的C函式庫間接的使用核心的系統呼叫,即應用程式呼叫C函式庫中的函式,C函式庫中再通過int 0x80進行系統呼叫。所以,系統呼叫過程是這樣的:應用程式呼叫libc中的函式->libc中的函式引用系統呼叫巨集->系統呼叫巨集中使用int 0x80完成系統呼叫並返回。而之前的那個sys_call_table的型別就是個函式指標型別,其中sys_call_tabal[0]元素就是sys_setup,他的型別也是一個函式指標,實際上函式指標就是一個函式的入口地址,函式從哪裡開始執行(那個記憶體地址)。

         下面的程式碼有助於我們進一步瞭解函式指標:


         我們看到了上面我們定義了一個MyFunc的函式指標。它指向了Func2這樣我們就可以利用它來執行Func2了。實際上核心中好多的程式碼都是這種技術。尤其是在一些驅動程式碼的編寫上。實際上我們可以結合我們之前學習的彙編知識分析一下我們的system_call():

//int 0x80 --linux 系統呼叫入口點(呼叫中斷int 0x80,eax 中是呼叫號)。  
.align 2  
_system_call:  
cmpl $nr_system_calls-1,%eax //呼叫號如果超出範圍的話就在eax 中置-1 並退出。  
ja bad_sys_call  
push %ds                     //儲存原段暫存器值。  
push %es  
push %fs  
pushl %edx                   //ebx,ecx,edx 中放著系統呼叫相應的C 語言函式的呼叫引數。  
pushl %ecx                   //push %ebx,%ecx,%edx as parameters  
pushl %ebx                   //to the system call  
movl $0x10,%edx              //set up ds,es to kernel space  
mov %dx,%ds                  //ds,es 指向核心資料段(全域性描述符表中資料段描述符)。  
mov %dx,%es  
movl $0x17,%edx              //fs points to local data space  
mov %dx,%fs                  //fs 指向區域性資料段(區域性描述符表中資料段描述符)。  

/*下面這句運算元的含義是:呼叫地址 = _sys_call_table + %eax * 4。參見列表後的說明。  
 * 對應的C 程式中的sys_call_table 在include/linux/sys.h 中,其中定義了一個包括72 個  
 *系統呼叫C 處理函式的地址陣列表。
 */ 
call _sys_call_table(,%eax,4)  
pushl %eax                   //把系統呼叫號入棧。  
movl _current,%eax           //取當前任務(程序)資料結構地址??eax。  


/*下面行檢視當前任務的執行狀態。如果不在就緒狀態(state 不等於0)就去執行排程程式。  
 *如果該任務在就緒狀態但counter[??]值等於0,則也去執行排程程式。
 */ 
 
cmpl $0,state(%eax)           //state  
jne reschedule  
cmpl $0,counter(%eax)         //counter  
je reschedule  
//以下這段程式碼執行從系統呼叫C 函式返回後,對訊號量進行識別處理。  
ret_from_sys_call:  
//首先判別當前任務是否是初始任務task0,如果是則不必對其進行訊號量方面的處理,直接返回。  
//103 行上的_task 對應C 程式中的task[]陣列,直接引用task 相當於引用task[0]。  
movl _current,%eax            //task[0] cannot have signals  
cmpl _task,%eax  
je 3f                         //向前(forward)跳轉到標號3。  
/*通過對原呼叫程式程式碼選擇符的檢查來判斷呼叫程式是否是超級使用者。如果是超級使用者就直接  
 *退出中斷,否則需進行訊號量的處理。這裡比較選擇符是否為普通使用者程式碼段的選擇符0x000f  
 *(RPL=3,區域性表,第1 個段(程式碼段)),如果不是則跳轉退出中斷程式。
 */ 
cmpw $0x0f,CS(%esp)           //was old code segment supervisor ?  
jne 3f  
//如果原堆疊段選擇符不為0x17(也即原堆疊不在使用者資料段中),則也退出。  
cmpw $0x17,OLDSS(%esp)        //was stack segment = 0x17 ?  
jne 3f  
/*下面這段程式碼(109-120)的用途是首先取當前任務結構中的訊號點陣圖(32 位,每位代表1 種訊號),  
 *然後用任務結構中的訊號阻塞(遮蔽)碼,阻塞不允許的訊號位,取得數值最小的訊號值,再把  
 *原訊號點陣圖中該訊號對應的位復位(置0),最後將該訊號值作為引數之一呼叫do_signal()。  
 *do_signal()在(kernel/signal.c,82)中,其引數包括13 個入棧的資訊。 
 */ 
movl signal(%eax),%ebx         //取訊號點陣圖??ebx,每1 位代表1 種訊號,共32 個訊號。  
movl blocked(%eax),%ecx        //取阻塞(遮蔽)訊號點陣圖??ecx。  
notl %ecx                      //每位取反。  
andl %ebx,%ecx                 //獲得許可的訊號點陣圖。  
bsfl %ecx,%ecx                 //從低位(位0)開始掃描點陣圖,看是否有1 的位,  
//若有,則ecx 保留該位的偏移值(即第幾位0-31)。  
je 3f                          //如果沒有訊號則向前跳轉退出。  
btrl %ecx,%ebx                 //復位該訊號(ebx 含有原signal 點陣圖)。  
movl %ebx,signal(%eax)         //重新儲存signal 點陣圖資訊??current->signal。  
incl %ecx                      //將訊號調整為從1 開始的數(1-32)。  
pushl %ecx                     //訊號值入棧作為呼叫do_signal 的引數之一。  
call _do_signal                //呼叫C 函式訊號處理程式(kernel/signal.c,82)  
popl %eax                      //彈出訊號值。  
3: popl %eax  
popl %ebx  
popl %ecx  
popl %edx  
pop %fs  
pop %es  
pop %ds  
iret  

四、實驗過程

下面我們來體驗一下這個過程,首先使用API的方式完成一個fpid的獲取。首先我們看一下實驗程式碼:
        上面這個過程很簡單了首先是fork()了一個程序結果在fpid。這個是使用者態的fork的使用,下面我們可以看以下執行結果。
        下面我們看看我們用中斷的方式如何完成相同功能。
這裡我們使用論文終端號是2號的系統呼叫實際上他就說fork對應的系統呼叫sys_fork()/stub32_fork()首先是將終端號傳遞到eax暫存器中然後指向int0x80程式就會進入核心態開始呼叫sys_fork()這個系統呼叫。我們可以看一下程式的事項結果。
        我們看到實際上效果是一樣的這裡面實際上沒有引數的傳遞,是因為fork不需要這些引數,所以就不用瞭如果用了別的就需要引數傳遞了。我們下面來看一個簡單的例子。使用軟中斷實現一個Hello的程式碼來體會引數傳遞的過程。

五、總結

        下面我們總結一下這次實驗的過程,前面已經較為詳細的敘述了系統呼叫這一過程,所以這段我們來總結一下有關使用這個中斷的一些需要知道的東西。        首先是系統呼叫號:         核心通過自己的系統呼叫分派表sys_call_table(可以理解為一個系統呼叫號,對應一個函式入口地址)找到這個具體的系統呼叫服務例程對應的函式入口地址,如上面sys_read,sys_write等。        然後是傳遞的引數規則:         在發起系統呼叫前,eax暫存器裡面儲存了系統呼叫號。如使用者程式fork()函式,glibc 發出int 0x80或sysenter指令前,eax暫存器就會設定好核心的sys_fork函式對應的系統呼叫號,這是glibc裡面的封裝例程會自動設定好的,程式設計師無需關心。 有些系統呼叫可能呼叫很多引數(除了系統呼叫號之外),普通c函式的引數傳遞是通過把引數值寫入活動的程式棧(使用者態棧或者核心態棧)實現的。因為系統呼叫是一種跨使用者態和核心態的特殊函式,所以這兩個棧都不能用。在發出系統呼叫之前,系統呼叫的引數寫入了cpu的暫存器(如glibc去寫好這些暫存器),然後發出系統呼叫之後,而在核心呼叫服務例程(如sys_fork()服務例程)之前,核心再把存放在cpu中的引數拷貝的核心態的堆疊中(因為sys_fork只是普通的c函式,前面說過普通c函式的引數傳遞是通過把引數值寫入活動的程式棧(使用者態棧或者核心態棧)實現的)。核心為什麼不直接把使用者態的棧拷貝到核心態的棧而要去通過暫存器來傳呢?首先,同事操作兩個棧是比較複雜的,其次,暫存器的使用使得系統呼叫處理程式的結構與其它異常處理程式的結構類似。
        使用暫存器傳遞引數,必須滿足兩個條件:
        每個引數的長度不能超過暫存器的長度(比如暫存器長度32位,那引數長度就不能超過32位);
        引數的個數不能超過6個(除了eax中傳遞的系統呼叫號),因為80x86處理器的暫存器的數量是有限的。
        第一個條件總能成立,因為POSIX標準規定,如果暫存器裡面裝不下那個長度的引數,那麼必須改用引數的地址來傳遞。
        第二個條件有的系統呼叫引數大於6個,這種情況下,必須用一個單獨的暫存器執行程序地址空間的這些引數所在的一個記憶體區。
        最後是SYSCALL,S Y S E X I T: int0x80/ iret
        向量128(0x80)對應於核心入口點,在核心初始化期間呼叫的函式trap_init(0,用以下方式建立對應於向量128的中斷描述符表項set_system_gate(0x80,&system_call).
        當用戶態程序發出int $0x80指令時,cpu切換到核心態並開始從地址system_call處開始執行指令。System_call()函式首先把系統呼叫號和這個異常處理程式可以用到的所有cpu暫存器儲存到相應的核心棧中,不包括由控制單元已自動儲存的eflags,cs,eip,ss,esp暫存器。隨後,在ebx中存放當前程序的thread_info資料結構的地址,這是通過獲得核心棧指標的值並把它取整到4kb或8kb的倍數而完成的。然後檢查thread_info結構flag欄位的TIF_SYSCALL_TRACE和TIF_SYSCALL_AUDIT標識之一是否被設定為1,也就是檢查是否有某一除錯程式正在跟蹤執行程式對系統呼叫的呼叫。如果被置1,那麼system_call()函式兩次呼叫do_syscall_trace()函式:一次正好在這個系統呼叫服務例程執行之前,一次在其之後。Do_syscall_trace函式停止current,並因此允許除錯程序收集關於current的資訊。
        系統呼叫退出:
        (1)使用者態的暫存器剛進來到系統呼叫的時候被儲存到了核心棧中,錯誤的返回值會被寫的剛開始傳遞系統呼叫號的那個eax暫存器所在棧的位置。(那麼將來當用戶態恢復執行的時候,eax暫存器裡面的內容就是系統呼叫的返回碼了。)
        (2)禁止本地中斷,並檢查current的thread_info結構中的標誌。如果有任何標誌被設定,那麼在返回到使用者態之前還需要完成一些工作。

參考

nodeathphoenix的部落格  http://blog.csdn.net/nodeathphoenix/ xiaochen77的部落格          http://blog.csdn.net/liuxiaochen77 閆明的部落格                      http://blog.csdn.net/geekcome/