1. 程式人生 > >Linux核心:基於int指令的經典系統呼叫過程分析

Linux核心:基於int指令的經典系統呼叫過程分析

眾所周知,程式碼執行可以存在多種不同的特權級別,而針對Linux系統,即為:使用者模式(user mode)和核心模式(kernel mode)。在使用者模式下,CPU的功能空間受到極大的限制,是沒有權利訪問多少系統資源的,諸多關鍵資源的使用無法直接調配,如:硬體裝置讀取、磁碟檔案讀取等;諸多情況無法直接處理,如開關中斷、頁中斷、斷電等。這些資源的調配和硬體中斷的響應處理都是核心態下由系統核心程式碼進行應對。之所以在使用者態程式和底層的系統資源之間新增一層核心態,主要目的還是起到封裝和保護的作用,系統資源畢竟有限,若是直接暴露給多個不同的應用程式,則很可能出現衝突。再一次驗證了計算機行業的萬能規則,“沒有增加一層抽象封裝層解決不了的計算機問題,如果有,那就再加一層

”。

系統呼叫是執行在核心態的,而上層的應用程式則執行在使用者態,使用者態的程式需要使用系統資源,顯然必須使用系統呼叫。
Q:那麼使用者態的程式是如何執行核心態的核心程式碼的?
A:通過中斷機制,來觸發作業系統從使用者態切換到核心態。根據中斷的型別,主要又可以分為軟中斷和硬中斷,軟中斷主要是指軟體程式碼執行過程中人為預設觸發的系統呼叫,如Linux系統下,是通過顯式的int 0x80指令觸發系統呼叫響應;硬中斷則包括的更多是外設響應、硬體失效、斷電等情況,核心特徵是硬體主動啟用傳送電訊號。兩者的核心區別總結如下:

  • 1.軟中斷是由程式工作流安排好的,而硬中斷的發生是有硬體觸發的,具有突發性;
  • 2.硬中斷的處理優先順序更高,需要CPU立刻停下當前的工作,轉向處理;軟中斷則並不打斷CPU,依舊附屬在相應的程序,等待CPU時間片輪轉輪訓處理。

中斷機制是另一個話題,系統呼叫則是屬於軟中斷中的一個特例,int指令(軟中斷還有其他指令,如yield)是程式用來顯式宣告軟中斷的,故而所謂的“基於int指令的系統呼叫”便是來源於此。軟中斷int指令發出後,顯然需要同時傳遞一箇中斷型別的ID,用來告知核心啟動相應的中斷處理程式(缺頁、硬體驅動、系統呼叫等),在核心中,系統呼叫即申請核心提供系統資源調配服務system_call對應的便是0x80中斷號,核心中通過中斷向量表(IVT, interrupt vector table),存放中斷號和中斷處理程式入口地址對應關係。0x80中斷號告知了核心當前程序需要核心提供系統資源調配服務system_call,但是依舊沒有告訴核心自己想要具體幹什麼(讀檔案、寫檔案還是建立子程序),這意味著還需再提供一個引數,用來告知核心具體使用的系統呼叫函式,這個系統呼叫服務號存放在eax暫存器中。下圖便是對這一過程的詳細展示。

Fig.1 基於int指令的系統呼叫過程示意圖

系統呼叫的使用者態部分

1. 無引數的系統呼叫巨集函式的程式碼填充

//以fork()為例介紹Linux系統下基於int的經典系統呼叫實現
/*fork()函式是對一個對系統呼叫fork的封裝,但fork()呼叫時並沒有傳遞引數
顯然是需要利用一個引數對fork()函式進行引數封裝。*/

_syscall0(pid_t, fork); 
/*_syscall0是一個巨集函式,用於定義一個沒有引數的系統呼叫的封裝,pid_t代表當前程序的id,是Linux自定義型別;第二個引數是系統呼叫的名稱*/

/*_syscall0巨集函式的定義如下*/

#define _syscall0(type, name)
        type name(void){                   \
        long __res;                        \
        __asm__ volatile ("int $0x80"      \
            :"=a" (__res)                  \
            :"0"  (__NR_##name));          \
        __syscall_return (type, __res);    \
    }

//故而對於_syscall0(pid_t, fork)巨集展開如下:
pid_t  fork(void){
    long __res;
    __asm__ volatile ("int $0x80" 
        :"=a"(__res)
        :"0" (__NR_fork));
    __syscall_return(pid_t, __res);
    }

/*
“__asm__”C語言內嵌彙編,volatile關鍵字告訴GCC編譯器該段程式碼不進行任何優化
"int $0x80"代表呼叫0x80號中斷
"=a" (__res)表示用eax暫存器來儲存系統呼叫的返回值,並輸出返回資料並存儲在__res裡
"0" (__NR_##name)表示__NR_##name為輸入,"0"指示有編譯器選擇和輸出相同的暫存器(即eax)來傳遞引數
所以直觀的看上述fork函式的可讀虛擬碼如下
*/

pid_t  fork(void) {
    long __res;
    $eax = __NR_fork; //__NR_fork巨集代表fork系統呼叫的二層系統呼叫號
    int $0x80;
    __res = $eax;
    __syscall_return(pid_t, __res);
}

對於x86體系而言,系統呼叫號都是通過巨集來封裝,這些巨集具體的定義可在/usr/include/asm-generic/unist.h找到,以下為了示意考慮,便人為地設定前5個巨集的定義,實際意義需要在unist.h查詢,如exit實際對應的是#define __NR_exit 93

#define __NR_restart_syscall  0
#define __NR_exit         1
#define __NR_fork         2
#define __NR_read         3
#define __NR_write        4
...

2. 系統呼叫的返回值處理
__syscall_return是另外一個巨集,定義如下

#define  __syscall_return (type, res)                                
    do {                        
        if ((unsigned long) (res) >= (unsigned long)(-125)){ 
            errno = -(res);       
            res = -1;                    
        }                            
        return (type) (res);                     
    }while (0)

這個巨集是用於檢查系統呼叫的返回值,並把它相應地轉換為C語言的errno錯誤碼。在Linux裡系統呼叫時通過使用返回值傳遞錯誤碼,如果返回值為負數,那麼表明呼叫失敗,返回值的絕對值就是錯誤碼,而在C語言裡則不然,C語言裡的大多數函式都以返回-1表示呼叫失敗,而將出錯資訊儲存在一個名為errno的全域性變數(在多執行緒庫中,errno儲存於TLS中)。所以__syscall_return就負責將系統呼叫的返回資訊儲存在errno中

這樣fork函式在彙編後就形成類似如下彙編程式碼

fork:
mov  eax,2
int 0x80
cmp eax, 0xFFFF FF83
jmp  syscall_noerror
neg eax
mov errno, eax
mov eax,0xFFFF FFFF
syscall_noerror:
ret

3. 帶有引數的系統呼叫巨集函式
以上是針對無引數輸入的系統呼叫,如果系統呼叫本身有引數,則需要用到另一個帶有1個引數的系統呼叫

#define _syscall12(type, name, type1, arg1)
    type name (type1 arg1)
{
    long __res;
    __asm__ volatile ("int $0x80"  \
        :"=a" (__res)       \
        :"0"  (__NR__##name), "b" ((long)(arg1)) );\
    --syscall_return (type, __res); \
}

上述程式碼中的”b” ((long) (arg1))代表先將arg1強制轉換為long,然後用EBX暫存器儲存編譯器會在使用EBX暫存器時進行現場保護,使得返回後原來的EBX的值不被破壞。

x86體系下,Linux系統支援的系統呼叫引數至多有6個,分別可用6個暫存器來傳遞,按照順序分別是:
EBX、ECX、EDX、ESI、EDI和EBP

4. 系統呼叫之前的現場保護和棧切換
上圖中中現場保護和系統呼叫引數準備的過程對應的彙編程式碼如下

push ebx      //反彙編常看到push ebx; push esi; push edi現場保護
eax = __NR_##name
ebx = arg1
int 0x80
__res = eax  //eax儲存這系統呼叫返回值
pop ebx      //彈出原先的ebx值

當用戶呼叫摸個系統呼叫的時候,實際上便是執行這些彙編程式碼。在CPU執行到int 0x80系統服務請求之時,便會先進行現場保護(正常的子函式呼叫時也會有現場保護),接著將程式碼執行的特權狀態從使用者態切換到核心態,依次開始檢視中斷向量表(Interrupt Vector Table)、系統呼叫服務表。

所謂的“當前棧”,可以通過檢視ESP的值所在的棧空間來判斷,如果ESP的值位於使用者棧的範圍內(0 ~ 0xC000 0000),那麼程式當前棧便是使用者棧,反之亦然。此外暫存器SS的值還應該指向當前棧所在的頁。使用者棧切換到核心棧的實際過程是:
1.儲存當前的ESP、SS的值; //將使用者棧的資訊儲存在核心棧上,顯然不能儲存使用者棧上,不然怎麼返回
2.將ESP、SS的值設定為核心棧的相應值;
3.核心程式執行完後,從核心棧上彈出原使用者棧ESP、SS的值。

當0x80號中斷髮生的時候,CPU除了切入核心態之外,還會自動完成下列幾件事:
1.找到當前程序的核心棧(0xC000 0000 ~ 0xFFFF FFFF)
2.在核心棧中依次壓入使用者態的暫存器SS、ESP、EFLAGS、CS、EIP
當核心從系統呼叫中返回的時候,需要呼叫“iret”指令來返回使用者態,顯然iret代表的是核心棧中一系列的暫存器SS、ESP、EFLAGS、CS、EIP彈出操作。

系統呼叫的核心部分

1. 中斷向量表查詢
上面的程式碼還是侷限在int指令執行之前,即仍停留在使用者態,下面正式進入int指令在核心態的完成過程中斷向量表示int指令要完成的系統呼叫任務的第一站。Linux/arch/i386/kernel/traps.c存在一個函式trap_init用來初始化中斷向量表,為每個中斷號繫結相應的中斷處理程式的函式指標。

void __init trap_init(void)
{
    ...
    set_trap_gate(0, &divide_error);
    set_intr_gate(1, &debug);
    set_intr_gate(2, &nmi);
    set_system_intr_gate(3, &int3);
    set_system_gate(4, &overflow);
    set_system_gate(5, &bounds);
    set_trap_gate(6, &invalid_op);
    set_trap_gate(7, &device_not_available);
    set_task_gate(8, GDT_ENTRY_DOUBLEDEFAULT_TSS);
    set_trap_gate(9, &coprocessor_segment_overrun);
    set_trap_gate(10, &invalid_TSS);
    set_trap_gate(11, &segment_not_present);
    set_trap_gate(12, &stack_segment);
    set_trap_gate(13, &general_protection);
    set_trap_gate(14, &page_fault);
    set_trap_gate(15, &spurious_interrupt_bug);
    set_trap_gate(16, &coprocess_error);
    set_trap_gate(17, &alignment_check);

#ifdef COFIG_X86_MCE
    set_trap_gate(18, &machine_check);
#endif

    set_trap_gate(19, &simd_coprocessor_error);

    set_system_gate(SYSCALL_VECTOR, &system_call); //在Linux/include/asm-i386/mach-default/irq_vectors.h中可以找到#define  SYSCALL_VECTOR  0x80,這便意味著int 0x80觸發執行的中斷處理函式是system_call
    ...
}

到此可以看到程式的工作流已經可以梳理到:main -> fork ->int 0x80觸發中斷響應 -> 系統呼叫system_call

2. 系統呼叫表查詢
既然工作流已經到了system_call,那麼參考《程式設計師的自我修養》中的內容補足system_call函式對應的工作內容。

ENTRY(system_call)
    ...
    SAVE_ALL //巨集,將各種暫存器壓入棧中,以免它們被後續執行的程式碼修改
    ...
    cmpl $(nr_syscall), %eax //比較eax系統呼叫號和"當前系統最大有效系統呼叫號+1"的大小,如果是則執行下面這句syscall_badsys應對“無效的系統呼叫”這一情況
    jar  syscall_badsys //無效的系統呼叫號
    jar  syscall_call
    ...

/*SAVE_ALL主要的工作是為了後續的系統呼叫函式提供引數*/
#define SAVE_ALL \
    ...
    push  %eax
    push  %ebp
    push  %edi 
    push  %esi
    push  %edx
    push  %ecx
    push  %ebx //暫存器入棧順序是各暫存器儲存系統呼叫引數的優先裝配順序的倒序
    mov $(KERNEL_DS), %edx
    mov %edx, %ds
    mov %edx, %es
    ...

/*
入棧的操作意味著後面將被啟用的sys_XXX()只能從核心棧上取引數,不像gcc編譯器還可以通過__fastcall修飾來使函式可以通過暫存器來取引數加速。這種情況下,顯然必須通過強制措施告訴系統呼叫函式必須從棧上按順序取引數,這就引出了asmlinkage巨集標識,
*/
#define asmlinkage __attribute__((regparm(0))) //擴充套件關鍵字的意思是讓這個函式只能從棧上取引數

可以看到這一步中通過cmpl $(nr_syscall), %eax判斷接下來的工作方向,如果系統呼叫號有效,則正常進入syscall_call塊,去往系統呼叫表中查詢具體的系統呼叫函式

    syscall_call:
        call *sys_call_table(0, %eax, 4)//系統呼叫號有效,查詢
        ...
        RESTORE_REGS //巨集,恢復之前由SAVE_ALL壓棧的暫存器
        ...
        iret//從終端處理程式system_call中返回

Linux的i386呼叫表中,記錄著syscall_call_table的詳細情況,每個元素對應的都是相應的系統呼叫函式的入口地址。可以看到call *sys_call_table(0, %eax, 4)中的是標準的陣列定址(index = 0+ eax * 4),如果eax為2,則顯然sys_fork()函式將被呼叫。

ENTRY (sys_call_table)
    .long  sys_restart_syscall
    .long  sys_exit
    .long  sys_fork
    .long  sys_read
    .long  sys_write
Fig.2 fork()系統呼叫流程

至此,可以看到基於int指令的經典系統呼叫的全部過程。但是基於int指令的系統呼叫效率較低,Linux在2.5之後便採用了一種新型的系統呼叫機制,雖然如此,但是基於int指令的系統呼叫依舊可以很好的展示出系統呼叫的過程和流程。

“`