剖析Linux系統呼叫的執行路徑
阿新 • • 發佈:2018-12-27
在[什麼是作業系統](http://www.cnblogs.com/ronny/p/7787105.html)這篇文章中,介紹過作業系統像是一個代理一樣,為我們去管理計算機的眾多硬體,我們需要計算機的一些計算服務、資料管理的服務,都由作業系統提供**介面**來完成。這樣做的好處是讓一般的計算機使用者不用關心硬體的細節。
# 1. 什麼是作業系統的介面
既然使用者是通過作業系統介面來使用計算機的,那到底是什麼是作業系統提供的介面呢?
**介面**(interface)這個詞來源於電氣工程學科,指的是插座與插頭的連線口,起到將電與電器連線起為的功能。後來延伸到軟體工程裡指軟體包向外提供的功能模組的函式介面。所以介面是用來連線兩個東西、訊號轉換和遮蔽細節。
那對於作業系統來說:作業系統通過介面的方式,建立了使用者與計算機硬體的溝通方式。使用者通過呼叫作業系統的介面來使用計算機的各種計算服務。為了使用者友好性,作業系統一般會提供兩個重要的介面來滿足使用者的一些一般性的使用需求:
1. 命令列:實際是一個叫`bash/sh`的端終程式提供的功能,該程式底層的實質還是呼叫一些作業系統提供的函式。
2. 視窗介面:視窗介面通過編寫的視窗程式接收來自作業系統訊息佇列的一些滑鼠、鍵盤動作,進而做出一些響應。
對於非一般性使用需求,作業系統提供了一系列的函式呼叫給軟體開發者,由軟體開發者來實現一些使用者需要的功能。這些函式呼叫由於是作業系統核心提供的,為了有別於一般的函式呼叫,被稱為**系統呼叫**。比如我們使用C語言進行軟體開發時,經常用的`printf`函式,它的內部實際就是通過`write`這個系統呼叫,讓作業系統核心為我們把字元列印在螢幕上的。
為了規範作業系統提供的系統呼叫,IEEE制定了一個標準介面族,被稱為`POSIX`(Portable Operating System Interface of Unix)。一些我們熟悉的介面比如:`fork`、`pthread_create`、`open`等。
# 2. 使用者模式與核心模式
計算機硬體資源都是作業系統核心進行管理的,那我們可以直接用核心中的一些功能模組來操作硬體資源嗎?可以直接訪問核心中維護的一些資料結構嗎? 當然不行!有人會說,為什麼不行呢?我買的電腦,核心程式碼在記憶體中,那記憶體不都是我自己買的嗎?,我自己不能訪問嗎?
現在我們執行的作業系統都是一個多工、多使用者的作業系統。如果每個使用者程序都可以隨便訪問作業系統核心的模組,改變狀態,那整個作業系統的穩定性、安全性都大大降低了。
為了將核心程式與使用者程式隔離開,在硬體層面上提供了一次機制,將程式執行的狀態分為了不同的級別,從0到3,數字越小,訪問級別越高。0代表核心態,在該特權級別下,所有記憶體上的資料都是可見的,可訪問的。3代表使用者態,在這個特權級下,程式只能訪問一部分的記憶體區域,只能執行一些限定的指令。
作業系統在建立GTD表的時候,將GTD的每個表項中的2位(4種特權級別)設定為特權位(DPL),然後作業系統將整個記憶體分為不同的段,不同的段,在GDT對應的表項中的DPL位是不同的。比如核心記憶體段的所有特權位都為`00`。而使用者程式訪存時,在保護模式下都是通過段暫存器+IP暫存器來訪問的,而段暫存器裡則用兩位表示當前程序的級別(CPL),是位於核心態還是使用者態。
既然如此,那我們還有什麼辦法可以呼叫作業系統的核心程式碼呢?作業系統為了實現系統呼叫,提供了一個主動進入核心的惟一方式:中斷指令`int`。`int`指令會將GDT表中的DPL改為3,讓我們可以訪問核心中的函式。所以所有的系統呼叫都必須通過呼叫`int`指令來實現,大致的過程如下:
1. 使用者程式中包含一段包含int指令的程式碼
2. 作業系統寫中斷處理,獲取相調程式的編號
3. 作業系統根據編號執行相應的程式碼
# 3. 剖析printf函式
下面我們以`printf`函式的呼叫為例,說明該函式是如何一步一步最終落在核心函式上去的。
圖1:應用程式、庫函式和核心系統呼叫之間的關係
printf函式是C語言的一個庫函式,它並不是真正的系統呼叫,在Unix下,它是通過呼叫`write`函式來完成功能的。
write函式內部就是呼叫了`int`中斷。一般的系統呼叫都是呼叫0x80號中斷。而作業系統中一般不會的顯式的寫出write的實現程式碼,而是通過`_syscall3`巨集展開的實現。`_syscall3`是專門用來處理有3個引數的系統呼叫的函式的實現。同理還有`_syscall0`、`_syscall1`和`_syscall2`等,目前最大支援的引數個數為3個,這三個引數是通過`ebx`, `ecx`,`edx`傳遞的。如果有系統呼叫的引數超過了3個,那麼可以通過一個引數結構體來進行傳遞。
```c
// linux/lib/write.c
#define __LIBRARY__
#include
//
_syscall3(int,write,int,fd,const char *,buf,off_t,count)
```
```asm
// linux/include/unistd.h
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -1; \
}
```
所以巨集展開後,write函式的實現實現為:
```c
int write(int fd, const char *buf, off_t count)
{
long __res;
__asm__ volatile ("int $0x80"
: "=a" (__res)
: "0" (__NR_write),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c)));
if (__res>=0)
return (type) __res;
errno=-__res;
return -1;
}
```
我們看到實際函式內部並沒有做太多的事情,主要就是呼叫`int 0x80`,將把相關的引數傳遞給一些通用暫存器,呼叫的結果通過`eax`返回。其中一個很重要的呼叫引數是`__NR_write`這個也是一個巨集,就是wirte的系統呼叫號,在linux/include/unistd.h中被定義為4,同樣還有很多其他系統呼叫號。因為所有的系統呼叫都是通過`int 0x80`,那怎麼知道具體需要什麼功能呢,只能通過系統呼叫號來識別。
下面我們來看看`int 0x80`是如何執行的。這是一個系統中斷,作業系統對於中斷處理流程一般為:
1. 關中斷:CPU關閉中段響應,即不再接受其它外部中斷請求
2. 儲存斷點:將發生中斷處的指令地址壓入堆疊,以使中斷處理完後能正確地返回。
3. 識別中斷源:CPU識別中斷的來源,確定中斷型別號,從而找到相應的中斷服務程式的入口地址。
4. 保護現場所:將發生中斷處理有關暫存器(中斷服務程式中要使用的暫存器)以及標誌暫存器的記憶體壓入堆疊。
5. 執行中斷服務程式:轉到中斷服務程式入口開始執行,可在適當時刻重新開放中斷,以便允許響應較高優先順序的外部中斷。
6. 恢復現場並返回:把“保護現場”時壓入堆疊的資訊彈回原暫存器,然後執行中斷返回指令(IRET),從而返回主程式繼續執行。
前3項通常由處理中斷的硬體電路完成,後3項通常由軟體(中斷服務程式)完成。
圖2:系統呼叫中斷處理流程
那0x80號中斷的處理程式是什麼呢,我們可以看一下作業系統是如何設定這個中斷向量表的。在作業系統初始化時`shecd_init`函式裡,呼叫了
```c
set_system_gate(0x80, &system_call);
```
我們深入看一下`set_system_gate`函式做了什麼
```asm
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
"movw %0,%%dx\n\t" \
"movl %%eax,%1\n\t" \
"movl %%edx,%2" \
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
"o" (*((char *) (gate_addr))), \
"o" (*(4+(char *) (gate_addr))), \
"d" ((char *) (addr)),"a" (0x00080000))
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)
```
通過上面的程式碼,我們可以看出,`set_system_gate`把第0x80中斷表的表項中中斷處理程式入口地址設定為&system_call。並且把那一項IDT表中的DPL設定了為3, 方便使用者程式可以去訪問這個地址。
所以`init 0x80`最終會被`system_call`這個函式地址處的程式碼來實際處理。讓我們看下`system_call`做了什麼事情。
```asm
# linux/kernel/system_call.s
nr_system_calls=72 # 最大的系統呼叫個數
.globl _system_call
system_call:
cmpl $nr_system_calls-1,%eax # eax中放的系統呼叫號,在write的呼叫過程中為__NR_write = 4
ja bad_sys_call
push %ds # 下面是一些暫存器保護,後面還要彈出
push %es
push %fs
pushl %edx
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的段標號設定為0001 0000(最後2位是特權級),所以段號為4,核心態資料段
mov %dx,%es
movl $0x17,%edx # 把fs的段標號設定為0001 0111(最後2位是特權級),所以段號為5,使用者態資料段
mov %dx,%fs
call sys_call_table(,%eax,4) # 實際的系統呼叫
pushl %eax
movl current,%eax
cmpl $0,state(%eax) # state 檢測是否為就緒狀態
jne reschedule # 進入排程程式
cmpl $0,counter(%eax) # counter 檢視訊號狀態
je reschedule
ret_from_sys_call:
movl current,%eax # task[0] cannot have signals
cmpl task,%eax
je 3f
cmpw $0x0f,CS(%esp) # was old code segment supervisor ?
jne 3f
cmpw $0x17,OLDSS(%esp) # was stack segment = 0x17 ?
jne 3f
movl signal(%eax),%ebx
movl blocked(%eax),%ecx
notl %ecx
andl %ebx,%ecx
bsfl %ecx,%ecx
je 3f
btrl %ecx,%ebx
movl %ebx,signal(%eax)
incl %ecx
pushl %ecx
call do_signal
popl %eax
3: popl %eax
popl %ebx
popl %ecx
popl %edx
pop %fs
pop %es
pop %ds
iret
```
我們可以發現,上面程式碼中大部分程式碼是暫存器狀態儲存與恢復,堆疊段的切換。核心程式碼為`call sys_call_table(,%eax,4)`,它是一個函式呼叫,函式的地址為`sys_call_table(,%eax,4) = sys_call_table + 4*%eax `說明`sys_call_table`為一個數組入口,陣列中的元素長度都為4個位元組,我們要訪問陣列中的第`%eax`個元素。而`%eax`即為系統呼叫號。`sys_call_table`就是所有系統呼叫的函式指標陣列。
```
// 定義在 linux/include/linux/sys.h
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid,sys_setregid };
```
到這裡,我們找到了最終真正的執行核心函式地址`sys_write`,這個是操作實現的核心程式碼,所有的螢幕列印就是由該函式最終實現。它裡面涉及IO的一些硬體驅動函式,我們在這裡就不再繼續深入了。
到此,我們已經通過printf這樣一個上層的函式介面,清楚作業系統是如何一步步為了我們提供了一個核心呼叫的方法。如此的精細控制,讓人感嘆。
# 4. 我們如何為作業系統新增一個系統呼叫
下面簡單說明一下,如何在作業系統原始碼中新增兩個我們自己的系統呼叫`whoami`和`iam`
- iam系統呼叫把我們指定的一個字串儲存在核心中。
- whoami把核心中的通過iam設定的那個字串讀取出來。
下面是具體的操作步驟。
1. 在linux/kernel資料夾加入一個自定義的檔案who.c
2. 在who.c中實現sys_iam和sys_whoami,需要注意的實現這兩個函式時,需要用於使用者棧資料與核心棧資料拷貝。
3. 在linux/include/linux/sys.h中的sys_call_table中新增兩個陣列項。
4. 修改linux/kernel/system_call.s中的系統呼叫個數nr_system_calls。
5. 用int 0x80實現iam和whoami函式。
6. 編寫使用者程式呼叫上面兩個函式。
要注意的是:在系統呼叫的過程中,段暫存器ds和es指向核心資料空間,而fs被設定指向使用者資料空間。因此在實際資料塊資訊傳遞過程中Linux核心就可以利用fs暫存器來執行核心資料空間與使用者資料空間之間的資料複製工作,並且在複製過程中核心程式不需要對資料邊界範圍作任何檢查操作。邊界檢查操作由CPU自動完成。核心程式的實際資料傳送工作可以使用`get_fs_byte()`和`puts_fs_bypte()`等函式進行。
# 5. 參考資料
[1] 《Linux核心完全剖析基於0.12核心》 趙炯著。
[2] 網易雲課堂,哈爾濱工業大學《作業系統之應用》 李治軍。