剖析Linux系統調用的執行路徑

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函數的調用為例,說明該函數是如何一步一步最終落在內核函數上去的。


剖析Linux系統調用的執行路徑

圖1:應用程序、庫函數和內核系統調用之間的關係

printf函數是C語言的一個庫函數,它並不是真正的系統調用,在Unix下,它是通過調用write函數來完成功能的。

write函數內部就是調用了int中斷。一般的系統調用都是調用0x80號中斷。而操作系統中一般不會的顯式的寫出write的實現代碼,而是通過_syscall3宏展開的實現。_syscall3是專門用來處理有3個參數的系統調用的函數的實現。同理還有_syscall0、_syscall1和_syscall2等,目前最大支持的參數個數為3個,這三個參數是通過ebx, ecx,edx傳遞的。如果有系統調用的參數超過了3個,那麼可以通過一個參數結構體來進行傳遞。

// linux/lib/write.c
#define __LIBRARY__
#include
//
_syscall3(int,write,int,fd,const char *,buf,off_t,count)
// 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函數的實現實現為:

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項通常由軟件(中斷服務程序)完成。


剖析Linux系統調用的執行路徑

圖2:系統調用中斷處理流程

那0x80號中斷的處理程序是什麼呢,我們可以看一下操作系統是如何設置這個中斷向量表的。在操作系統初始化時shecd_init函數里,調用了

set_system_gate(0x80, &system_call);

我們深入看一下set_system_gate函數做了什麼

#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做了什麼事情。

# 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()等函數進行。

[1] 《Linux內核完全剖析基於0.12內核》 趙炯著。

[2] 網易雲課堂,哈爾濱工業大學《操作系統之應用》 李治軍。


分享到:


相關文章: