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

什麼是操做系統這篇文章中,介紹過操做系統像是一個代理同樣,爲咱們去管理計算機的衆多硬件,咱們須要計算機的一些計算服務、數據管理的服務,都由操做系統提供接口來完成。這樣作的好處是讓通常的計算機使用者不用關心硬件的細節。html

1. 什麼是操做系統的接口

既然使用者是經過操做系統接口來使用計算機的,那究竟是什麼是操做系統提供的接口呢?linux

接口(interface)這個詞來源於電氣工程學科,指的是插座與插頭的鏈接口,起到將電與電器鏈接起爲的功能。後來延伸到軟件工程裏指軟件包向外提供的功能模塊的函數接口。因此接口是用來鏈接兩個東西、信號轉換和屏蔽細節。數組

那對於操做系統來講:操做系統經過接口的方式,創建了用戶與計算機硬件的溝通方式。用戶經過調用操做系統的接口來使用計算機的各類計算服務。爲了用戶友好性,操做系統通常會提供兩個重要的接口來知足用戶的一些通常性的使用需求:安全

  1. 命令行:實際是一個叫bash/sh的端終程序提供的功能,該程序底層的實質仍是調用一些操做系統提供的函數。
  2. 窗口界面:窗口界面經過編寫的窗口程序接收來自操做系統消息隊列的一些鼠標、鍵盤動做,進而作出一些響應。

對於非通常性使用需求,操做系統提供了一系列的函數調用給軟件開發者,由軟件開發者來實現一些用戶須要的功能。這些函數調用因爲是操做系統內核提供的,爲了有別於通常的函數調用,被稱爲系統調用。好比咱們使用C語言進行軟件開發時,常常用的printf函數,它的內部實際就是經過write這個系統調用,讓操做系統內核爲咱們把字符打印在屏幕上的。bash

爲了規範操做系統提供的系統調用,IEEE制定了一個標準接口族,被稱爲POSIX(Portable Operating System Interface of Unix)。一些咱們熟悉的接口好比:forkpthread_createopen等。數據結構

2. 用戶模式與內核模式

計算機硬件資源都是操做系統內核進行管理的,那咱們能夠直接用內核中的一些功能模塊來操做硬件資源嗎?能夠直接訪問內核中維護的一些數據結構嗎? 固然不行!有人會說,爲何不行呢?我買的電腦,內核代碼在內存中,那內存不都是我本身買的嗎?,我本身不能訪問嗎?
如今咱們運行的操做系統都是一個多任務、多用戶的操做系統。若是每一個用戶進程均可以隨便訪問操做系統內核的模塊,改變狀態,那整個操做系統的穩定性、安全性都大大下降了。函數

爲了將內核程序與用戶程序隔離開,在硬件層面上提供了一次機制,將程序執行的狀態分爲了避免同的級別,從0到3,數字越小,訪問級別越高。0表明內核態,在該特權級別下,全部內存上的數據都是可見的,可訪問的。3表明用戶態,在這個特權級下,程序只能訪問一部分的內存區域,只能執行一些限定的指令。ui

操做系統在創建GTD表的時候,將GTD的每一個表項中的2位(4種特權級別)設置爲特權位(DPL),而後操做系統將整個內存分爲不一樣的段,不一樣的段,在GDT對應的表項中的DPL位是不一樣的。好比內核內存段的全部特權位都爲00。而用戶程序訪存時,在保護模式下都是經過段寄存器+IP寄存器來訪問的,而段寄存器裏則用兩位表示當前進程的級別(CPL),是位於內核態仍是用戶態。spa

既然如此,那咱們還有什麼辦法能夠調用操做系統的內核代碼呢?操做系統爲了實現系統調用,提供了一個主動進入內核的唯一方式:中斷指令intint指令會將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個,那麼能夠經過一個參數結構體來進行傳遞。

// linux/lib/write.c
#define __LIBRARY__
#include <unistd.h>
// 
_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項一般由軟件(中斷服務程序)完成。

圖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. 咱們如何爲操做系統添加一個系統調用

下面簡單說明一下,如何在操做系統源碼中添加兩個咱們本身的系統調用whoamiiam

  • 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] 網易雲課堂,哈爾濱工業大學《操做系統之應用》 李治軍。

相關文章
相關標籤/搜索