1. 程式人生 > >Linux系統調用過程分析

Linux系統調用過程分析

policy 用戶空間 抽象接口 保護 name ack for 內嵌 驅動程序

參考:

《Linux內核設計與實現》

0 摘要

linux的系統調用過程:
層次例如以下:
用戶程序------>C庫(即API):INT 0x80 ----->system_call------->系統調用服務例程-------->內核程序
先說明一下,我們常說的用戶API事實上就是系統提供的C庫。
系統調用是通過軟中斷指令 INT 0x80 實現的,而這條INT 0x80指令就被封裝在C庫的函數中。


(軟中斷和我們常說的硬中斷不同之處在於,軟中斷是由指令觸發的,而不是由硬件外設引起的。)
INT 0x80 這條指令的運行會讓系統跳轉到一個預設的內核空間地址,它指向系統調用處理程序。即system_call函數。
(註意:。。!系統調用處理程序system_call 並非系統調用服務例程,系統調用服務例程是對一個詳細的系統調用的內核實現函數。而系統調用處理程序是在運行系統調用服務例程之前的一個引導過程。是針對INT 0x80這條指令,面向全部的系統調用的。

簡單來講,運行不論什麽系統調用。都是先通過調用C庫中的函數,這個函數裏面就會有軟中斷 INT 0x80 語句。然後轉到運行系統調用處理程序 system_call ,
system_call 再依據詳細的系統調用號轉到運行詳細的系統調用服務例程。)
system_call函數是怎麽找到詳細的系統調用服務例程的呢?通過系統調用號查找系統調用表sys_call_table!軟中斷指令INT 0x80運行時,系統調用號會被放入 eax 寄存器中,system_call函數能夠讀取eax寄存器獲取,然後將其乘以4,生成偏移地址,然後以sys_call_table為基址。基址加上偏移地址,就能夠得到詳細的系統調用服務例程的地址了!


然後就到了系統調用服務例程了。

須要說明的是。系統調用服務例程僅僅會從堆棧裏獲取參數,所以在system_call運行前。會先將參數存放在寄存器中。system_call運行時會首先將這些寄存器壓入堆棧。

system_call退出後。用戶能夠從寄存器中獲得(被改動過的)參數。

另外:系統調用通過軟中斷INT 0x80陷入內核。跳轉到系統調用處理程序system_call函數,然後運行對應的服務例程。可是因為是代表用戶進程,所以這個運行過程並不屬於中斷上下文,而是進程上下文。因此。系統調用運行過程中,能夠訪問用戶進程的很多信息,能夠被其它進程搶占,能夠休眠。
當系統調用完畢後,把控制權交回到發起調用的用戶進程前。內核會有一次調度。

假設發現有優先級更高的進程或當前進程的時間片用完,那麽會選擇優先級更高的進程或又一次選擇進程運行。

1 系統調用意義
linux內核中設置了一組用於實現系統功能的子程序,稱為系統調用。

系統調用和普通庫函數調用很相似,僅僅是系統調用由操作系統核心提供,執行於核心態。而普通的函數調用由函數庫或用戶自己提供。執行於用戶態。

一般的,進程是不能訪問內核的。它不能訪問內核所占內存空間也不能調用內核函數。CPU硬件決定了這些(這就是為什麽它被稱作"保護模式")。為了和用戶空間上執行的進程進行交互,內核提供了一組接口。透過該接口,應用程序能夠訪問硬件設備和其它操作系統資源。這組接口在應用程序和內核之間扮演了使者的角色,應用程序發送各種請求。而內核負責滿足這些請求(或者讓應用程序臨時擱置)。

實際上提供這組接口主要是為了保證系統穩定可靠。避免應用程序肆意妄行,惹出大麻煩。



系統調用在用戶空間進程和硬件設備之間加入了一個中間層。該層主要作用有三個:
(1) 它為用戶空間提供了一種統一的硬件的抽象接口。

比方當須要讀些文件的時候,應用程序就能夠不去管磁盤類型和介質,甚至不用去管文件所在的文件系統究竟是哪種類型。
(2)系統調用保證了系統的穩定和安全。作為硬件設備和應用程序之間的中間人,內核能夠基於權限和其它一些規則對須要進行的訪問進行裁決。

舉例來說,這樣能夠避免應用程序不對地使用硬件設備,竊取其它進程的資源,或做出其它什麽危害系統的事情。
(3) 每一個進程都執行在虛擬系統中,而在用戶空間和系統的其余部分提供這樣一層公共接口。也是出於這樣的考慮。假設應用程序能夠任意訪問硬件而內核又對此一無所知的話,差點兒就沒法實現多任務和虛擬內存,當然也不可能實現良好的穩定性和安全性。

在Linux中。系統調用是用戶空間訪問內核的惟一手段。除異常和中斷外,它們是內核惟一的合法入口。

2 API/POSIX/C庫的關系
普通情況下,應用程序通過應用編程接口(API)而不是直接通過系統調用來編程。

這點非常重要,由於應用程序使用的這樣的編程接口實際上並不須要和內核提供的系統調用一一相應。一個API定義了一組應用程序使用的編程接口。它們能夠實現成一個系統調用,也能夠通過調用多個系統調用來實現,而全然不使用不論什麽系統調用也不存在問題。實際上,API能夠在各種不同的操作系統上實現,給應用程序提供全然同樣的接口,而它們本身在這些系統上的實現卻可能迥異。

在Unix世界中。最流行的應用編程接口是基於POSIX標準的,其目標是提供一套大體上基於Unix的可移植操作系統標準。POSIX是說明API和系統調用之間關系的一個極好樣例。在大多數Unix系統上。依據POSIX而定義的API函數和系統調用之間有著直接關系。



Linux的系統調用像大多數Unix系統一樣,作為C庫的一部分提供例如以下圖所看到的。

C庫實現了 Unix系統的主要API。包含標準C庫函數和系統調用。全部的C程序都能夠使用C庫,而因為C語言本身的特點,其它語言也能夠非常方便地把它們封裝起來使用。


從程序猿的角度看,系統調用無關緊要。他們僅僅須要跟API打交道就能夠了。相反。內核僅僅跟系統調用打交道;庫函數及應用程序是怎麽使用系統調用不是內核所關心的。



關於Unix的界面設計有一句通用的格言“提供機制而不是策略”。

換句話說,Unix的系統調用抽象出了用於完畢某種確定目的的函數。至幹這些函數怎麽用全然不須要內核去關心。差別對待機制(mechanism)和策略(policy)是Unix設計中的一大亮點。大部分的編程問題都能夠被分割成兩個部分:“須要提供什麽功能”(機制)和“如何實現這些功能”(策略)。


3 系統調用的實現
3.1 系統調用處理程序
您也許疑惑: “當我輸入 cat /proc/cpuinfo 時。cpuinfo() 函數是怎樣被調用的?”內核完畢引導後,控制流就從相對直觀的“接下來調用哪個函數?”改變為取決於系統調用、異常和中斷。

用戶空間的程序無法直接運行內核代碼。它們不能直接調用內核空間中的函數,由於內核駐留在受保護的地址空間上。

假設進程能夠直接在內核的地址空間上讀寫的話,系統安全就會失去控制。

所以,應用程序應該以某種方式通知系統,告訴內核自己須要運行一個系統調用,希望系統切換到內核態,這樣內核就能夠代表應用程序來運行該系統調用了。

通知內核的機制是靠軟件中斷實現的。首先,用戶程序為系統調用設置參數。當中一個參數是系統調用編號。參數設置完畢後,程序運行“系統調用”指令。x86系統上的軟中斷由int產生。

這個指令會導致一個異常:產生一個事件,這個事件會致使處理器切換到內核態並跳轉到一個新的地址,並開始運行那裏的異常處理程序。此時的異常處理程序實際上就是系統調用處理程序。

它與硬件體系結構緊密相關。

新地址的指令會保存程序的狀態。計算出應該調用哪個系統調用,調用內核中實現那個系統調用的函數,恢復用戶程序狀態,然後將控制權返還給用戶程序。

系統調用是設備驅動程序中定義的函數終於被調用的一種方式。
3.2 系統調用號
在Linux中。每一個系統調用被賦予一個系統調用號。

這樣,通過這個獨一無二的號就能夠關聯系統調用。當用戶空間的進程運行一個系統調用的時候。這個系統調用號就被用來指明究竟是要運行哪個系統調用。進程不會提及系統調用的名稱。

系統調用號相當關鍵。一旦分配就不能再有不論什麽變更,否則編譯好的應用程序就會崩潰。Linux有一個“未實現”系統調用sys_ni_syscall(),它除了返回一ENOSYS外不做不論什麽其它工作,這個錯誤號就是專門針對無效的系統調用而設的。

由於全部的系統調用陷入內核的方式都一樣,所以不過陷入內核空間是不夠的。因此必須把系統調用號一並傳給內核。在x86上,系統調用號是通過eax寄存器傳遞給內核的。在陷人內核之前,用戶空間就把相應系統調用所相應的號放入eax中了。

這樣系統調用處理程序一旦執行,就能夠從eax中得到數據。其它體系結構上的實現也都類似。

內核記錄了系統調用表中的全部已註冊過的系統調用的列表。存儲在sys_call_table中。它與體系結構有關,一般在entry.s中定義。

這個表中為每個有效的系統調用指定了惟一的系統調用號。sys_call_table是一張由指向實現各種系統調用的內核函數的函數指針組成的表:
ENTRY(sys_call_table)
.long SYMBOL_NAME(sys_ni_syscall) /* 0 - old "setup()" system call*/
.long SYMBOL_NAME(sys_exit)
.long SYMBOL_NAME(sys_fork)
.long SYMBOL_NAME(sys_read)
.long SYMBOL_NAME(sys_write)
.long SYMBOL_NAME(sys_open) /* 5 */
.long SYMBOL_NAME(sys_close)
.long SYMBOL_NAME(sys_waitpid)
。。

。。。
.long SYMBOL_NAME(sys_capget)
.long SYMBOL_NAME(sys_capset)      /* 185 */
.long SYMBOL_NAME(sys_sigaltstack)
.long SYMBOL_NAME(sys_sendfile)
.long SYMBOL_NAME(sys_ni_syscall) /* streams1 */
.long SYMBOL_NAME(sys_ni_syscall) /* streams2 */
.long SYMBOL_NAME(sys_vfork)      /* 190 */

system_call()函數通過將給定的系統調用號與NR_syscalls做比較來檢查其有效性。

假設它大於或者等於NR syscalls,該函數就返回一ENOSYS。

否則。就運行對應的系統調用。


call *sys_ call-table(,%eax, 4)
因為系統調用表中的表項是以32位(4字節)類型存放的,所以內核須要將給定的系統調用號乘以4,然後用所得的結果在該表中查詢其位置

3.3 參數傳遞
除了系統調用號以外。大部分系統調用都還須要一些外部的參數輸人。所以。在發生異常的時候。應該把這些參數從用戶空間傳給內核。最簡單的辦法就是像傳遞系統調用號一樣把這些參數也存放在寄存器裏。

在x86系統上,ebx, ecx, edx, esi和edi依照順序存放前五個參數。須要六個或六個以上參數的情況不多見,此時,應該用一個單獨的寄存器存放指向全部這些參數在用戶空間地址的指針。



給用戶空間的返回值也通過寄存器傳遞。在x86系統上。它存放在eax寄存器中。接下來很多關於系統調用處理程序的描寫敘述都是針對x86版本號的。

但不用操心,全部體系結構的實現都非常類似。

3.4 參數驗證
系統調用必須細致檢查它們全部的參數是否合法有效。

舉例來說。與文件I/O相關的系統調用必須檢查文件描寫敘述符是否有效。

與進程相關的函數必須檢查提供的PID是否有效。必須檢查每一個參數。保證它們不但合法有效,並且正確。



最重要的一種檢查就是檢查用戶提供的指針是否有效。試想,假設一個進程能夠給內核傳遞指針而又無須被檢查,那麽它就能夠給出一個它根本就沒有訪問權限的指針,哄騙內核去為它拷貝本不同意它訪問的數據,如原本屬於其它進程的數據。

在接收一個用戶空間的指針之前,內核必須保證:
2 指針指向的內存區域屬於用戶空間。進程決不能哄騙內核去讀內核空間的數據。
2 指針指向的內存區域在進程的地址空間裏。進程決不能哄騙內核去讀其它進程的數據。
2 假設是讀,該內存應被標記為可讀。假設是寫。該內存應被標記為可寫。進程決不能繞過內存訪問限制。



內核提供了兩個方法來完畢必須的檢查和內核空間與用戶空間之間數據的來回拷貝。註意。內核不管何時都不能輕率地接受來自用戶空間的指針!這兩個方法中必須有一個被調用。

為了向用戶空間寫入數據,內核提供了copy_to_user(),它須要三個參數。第一個參數是進程空間中的目的內存地址。

第二個是內核空間內的源地址。

最後一個參數是須要拷貝的數據長度(字節數)。

為了從用戶空間讀取數據,內核提供了copy_from_ user(),它和copy-to-User()相似。該函數把第二個參數指定的位置上的數據復制到第一個參數指定的位置上,拷貝的數據長度由第三個參數決定。



假設運行失敗,這兩個函數返回的都是沒能完畢拷貝的數據的字節數。

假設成功,返回0。當出現上述錯誤時,系統調用返回標準-EFAULT。



註意copy_to_user()和copy_from_user()都有可能引起堵塞。當包括用戶數據的頁被換出到硬盤上而不是在物理內存上的時候,這樣的情況就會發生。此時。進程就會休眠,直到缺頁處理程序將該頁從硬盤又一次換回物理內存。

3.5 系統調用的返回值
系統調用(在Linux中常稱作syscalls)通常通過函數進行調用。

它們通常都須要定義一個或幾個參數(輸入)並且可能產生一些副作用。比如寫某個文件或向給定的指針拷貝數據等等。

為防止和正常的返回值混淆,系統調用並不直接返回錯誤碼,而是將錯誤碼放入一個名為errno的全局變量中。

通經常使用一個負的返回值來表明錯誤。返回一個0值通常表明成功。假設一個系統調用失敗,你能夠讀出errno的值來確定問題所在。通過調用perror()庫函數,能夠把該變量翻譯成用戶能夠理解的錯誤字符串。

errno不同數值所代表的錯誤消息定義在errno.h中,你也能夠通過命令"man 3 errno"來察看它們。

須要註意的是,errno的值僅僅在函數錯誤發生時設置,假設函數不錯誤發生,errno的值就無定義,並不會被置為0。另外,在處理errno前最好先把它的值存入還有一個變量。由於在錯誤處理過程中。即使像printf()這種函數出錯時也會改變errno的值。

當然,系統調用終於具有一種明白的操作。

舉例來說,如getpid()系統調用。依據定義它會返回當前進程的PID。內核中它的實現很easy:
asmlinkage long sys_ getpid(void)
{
return current-> tgid;
}

上述的系統調用雖然很easy,但我們還是能夠從中發現兩個特別之處。首先,註意函數聲明中的asmlinkage限定詞,這是一個小戲法。用於通知編譯器僅從棧中提取該函數的參數。全部的系統調用都須要這個限定詞。其次,註意系統調用get_pid()在內核中被定義成sys_ getpid。

這是Linux中全部系統調用都應該遵守的命名規則

4 加入新系統調用
給Linux加入一個新的系統調用是件相對easy的工作。如何設計和實現一個系統調用是難題所在,而把它加到內核裏卻無須太多周折。讓我們關註一下實現一個新的Linux系統調用所需的步驟。



實現一個新的系統調用的第一步是決定它的用途。

它要做些什麽?每一個系統調用都應該有一個明白的用途。

在Linux中不提倡採用多用途的系統調用(一個系統調用通過傳遞不同的參數值來選擇完畢不同的工作)。ioctl()就應該被視為一個反例。



新系統調用的參數、返回值和錯誤碼又該是什麽呢?系統調用的接口應該力求簡潔。參數盡可能少。

設計接口的時候要盡量為將來多做考慮。

你是不是對函數做了不必要的限制?系統調用設計得越通用越好。

不要如果這個系統調用如今怎麽用將來也一定就是這麽用。

系統調用的目的可能不變,但它的使用方法卻可能改變。

這個系統調用可移植嗎?

別對機器的字節長度和字節序做如果。

當你寫一個系統調用的時候,要時刻註意可移植性和健壯性。不但要考慮當前,還要為將來做打算。

當編寫完一個系統調用後。把它註冊成一個正式的系統調用是件瑣碎的工作:
在系統調用表的最後增加一個表項。

每種支持該系統調用的硬件體系都必須做這種工作。從0開始算起,系統調用在該表中的位置就是它的系統調用號。
對於所支持的各種體系結構,系統調用號都必須定義於<asm/unistd.h>中。
系統調用必須被編譯進內核映象(不能被編譯成模塊)。這僅僅要把它放進kernel/下的一個相關文件裏就能夠。



讓我們通過一個虛構的系統調用f00()來細致觀察一下這些步驟。

首先,我們要把sys_foo增加到系統調用表中去。對於大多數體系結構來說,該表位幹entry.s文件裏。形式例如以下:
ENTRY(sys_ call_ table)
·long sys_ restart_ syscall/*0*/
.long sys_ exit
·long sys_ fork
·long sys_ read
.long sys_write
我們把新的系統調用加到這個表的末尾:
.long sys_foo
盡管沒有明白地指定編號,但我們增加的這個系統調用被依照次序分配給了283這個系統調用號。對於每種須要支持的體系結構,我們都必須將自己的系統調用加人到其系統調用表中去。每種體系結構不須要相應同樣的系統調用號。

接下來,我們把系統調用號增加到<asm/unistd.h>中,它的格式例如以下:
/*本文件包括系統調用號*/
#define_ NR_ restart_ syscall
#define NR exit
#define NR fork
#define NR read
#define NR write
#define NR- mq getsetattr 282
然後,我們在該列表中增加以下這行:
#define_ NR_ foo 283

最後,我們來實現f00()系統調用。不管何種配置,該系統調用都必須編譯到核心的內核映象中去,所以我們把它放進kernel/sys.c文件裏。你也能夠將其放到與其功能聯系最緊密的代碼中去

asmlinkage long sys-foo(void)
{
return THREAD SIZE
)
就是這樣!嚴格說來,如今就能夠在用戶空間調用f00()系統調用了。

建立一個新的系統調用很easy。但卻絕不提倡這麽做。通常模塊能夠更好的取代新建一個系統調用。

5 訪問系統調用
5.1 系統調用上下文
內核在運行系統調用的時候處於進程上下文。

current指針指向當前任務,即引發系統調用的那個進程。



在進程上下文中,內核能夠休眠而且能夠被搶占。這兩點都非常重要。首先。能夠休眠說明系統調用能夠使用內核提供的絕大部分功能。

休眠的能力會給內核編程帶來極大便利。在進程上下文中能夠被搶占,事實上表明,像用戶空間內的進程一樣。當前的進程相同能夠被其它進程搶占。由於新的進程能夠使用相同的系統調用。所以必須小心。保證該系統調用是可重人的。

當然。這也是在對稱多處理中必須相同關心的問題。

當系統調用返回的時候,控制權仍然在system_call()中。它終於會負責切換到用戶空間並讓用戶進程繼續運行下去。



5.2 系統調用訪問演示樣例
操作系統使用系統調用表將系統調用編號翻譯為特定的系統調用。系統調用表包括有實現每一個系統調用的函數的地址。比如,read() 系統調用函數名為 sys_read。read() 系統調用編號是 3,所以 sys_read() 位於系統調用表的第四個條目中(由於系統調用起始編號為0)。從地址 sys_call_table + (3 * word_size) 讀取數據,得到 sys_read() 的地址。

找到正確的系統調用地址後,它將控制權轉交給那個系統調用。

我們來看定義 sys_read() 的位置,即 fs/read_write.c 文件。這個函數會找到關聯到 fd 編號(傳遞給 read() 函數的)的文件結構體。那個結構體包括指向用來讀取特定類型文件數據的函數的指針。進行一些檢查後,它調用與文件相關的 read() 函數,來真正從文件裏讀取數據並返回。

與文件相關的函數是在其它地方定義的 —— 比方套接字代碼、文件系統代碼,或者設備驅動程序代碼。這是特定內核子系統終於與內核其它部分協作的一個方面。

讀取函數結束後,從 sys_read() 返回,它將控制權切換給 ret_from_sys。它會去檢查那些在切換回用戶空間之前須要完畢的任務。假設沒有須要做的事情,那麽就恢復用戶進程的狀態。並將控制權交還給用戶程序。
5.3 從用戶空間直接訪問系統調用
通常。系統調用靠C庫支持。

用戶程序通過包括標準頭文件並和C庫鏈接,就能夠使用系統調用(或者調用庫函數,再由庫函數實際調用)。但假設你只寫出系統調用,glibc庫恐怕並不提供支持。值得慶幸的是,Linux本身提供了一組宏,用於直接對系統調用進行訪問。它會設置好寄存器並調用陷人指令。這些宏是_syscalln(),當中n的範圍從0到6。代表須要傳遞給系統調用的參數個數,這是因為該宏必須了解究竟有多少參數依照什麽次序壓入寄存器。

舉個樣例,open()系統調用的定義是:
long open(const char *filename, int flags, int mode)
而不靠庫支持,直接調用此系統調用的宏的形式為:
#define NR_ open 5
syscall3(long, open, const char*。filename, int, flags, int, mode)
這樣,應用程序就能夠直接使用open()

對於每一個宏來說,都有2+ n個參數。第一個參數相應著系統調用的返回值類型。第二個參數是系統調用的名稱。再以後是依照系統調用參數的順序排列的每一個參數的類型和名稱。

_NR_ open在<asm/unistd.h>中定義,是系統調用號。該宏會被擴展成為內嵌匯編的C函數。由匯編語言運行前一節所討論的步驟,將系統調用號和參數壓入寄存器並觸發軟中斷來陷入內核。

調用open()系統調用直接把上面的宏放置在應用程序中就能夠了。

讓我們寫一個宏來使用前面編寫的foo()系統調用,然後再寫出測試代碼炫耀一下我們所做的努力。
#define NR foo 283
_sysca110(long, foo)
int main()
{
long stack size;
stack_ size=foo();
printf("The kernel stack
size is 81d\n",stack_ size);
return;
}

6 實際使用的註意

(1)系統調用是須要提前編譯固化到內核中的。並且須要官方分配一個系統調用號

(2)須要將系統調用註冊到支持的每一種體系結構中

(3)系統調用一般不能在腳本中直接訪問

(4)盡量避免新建系統調用,可用創建設備結點的方法取代。

Linux系統調用過程分析