你真的知道什麼是系統呼叫嗎?
在現代作業系統裡,由於系統資源可能同時被多個應用程式訪問,如果不加保護,那各個應用程式之間可能會產生衝突,對於惡意應用程式更可能導致系統奔潰。這裡所說的系統資源包括檔案、網路、各種硬體裝置等。比如要操作檔案必須藉助作業系統提供的api(比如linux下的fopen)。
系統呼叫在我們工作中無時無刻不打著交道,那系統呼叫的原理是什麼呢?在其過程中做了哪些事情呢?
本文將闡述系統呼叫原理,讓大家對於系統呼叫有一個清晰的認識。
更多文章見個人部落格: github.com/farmerjohng…
概述
現代cpu通常有多種特權級別,一般來說特權級總共有4個,編號從Ring 0(最高特權)到Ring 3(最低特權),在Linux上之用到Ring 0和RIng 3,使用者態對應Ring 3,核心態對應Ring 0。
普通應用程式執行在使用者態下,其諸多操作都受到限制,比如改變特權級別、訪問硬體等。特權高的程式碼能將自己降至低等級的級別,但反之則是不行的。而系統呼叫是執行在核心態的,那麼執行在使用者態的應用程式如何執行核心態的程式碼呢?作業系統一般是通過 中斷 來從使用者態切換到核心態的。學過作業系統課程的同學對中斷這個詞肯定都不陌生。
中斷一般有兩個屬性,一個是中斷號,一個是中斷處理程式。不同的中斷有不同的中斷號,每個中斷號都對應了一箇中斷處理程式。在核心中有一個叫中斷向量表的陣列來對映這個關係。當中斷到來時,cpu會暫停正在執行的程式碼,根據中斷號去中斷向量表找出對應的中斷處理程式並呼叫。中斷處理程式執行完成後,會繼續執行之前的程式碼。
中斷分為硬體中斷和軟體中斷,我們這裡說的是軟體中斷,軟體中斷通常是一條指令,使用這條指令使用者可以手動觸發某個中斷。例如在i386下,對應的指令是int,在int指令後指定對應的中斷號,如int 0x80代表你呼叫第0x80號的中斷處理程式。
中斷號是有限的,所有不會用一箇中斷來對應一個系統呼叫(系統呼叫有很多)。Linux下用int 0x80觸發所有的系統呼叫,那如何區分不同的呼叫呢?對於每個系統呼叫都有一個系統呼叫號,在觸發中斷之前,會將系統呼叫號放入到一個固定的暫存器,0x80對應的中斷處理程式會讀取該暫存器的值,然後決定執行哪個系統呼叫的程式碼。
在Linux2.5(具體版本不是很確定)之前的版本,是使用int 0x80這樣的方式實現系統呼叫的,但其實int指令這樣的形式效能不太好,原因如下(出自這篇文章):
在 x86 保護模式中,處理 INT 中斷指令時,CPU 首先從中斷描述表 IDT 取出對應的門描述符,判斷門描述符的種類,然後檢查門描述符的級別 DPL 和 INT 指令呼叫者的級別 CPL,當 CPL<=DPL 也就是說 INT 呼叫者級別高於描述符指定級別時,才能成功呼叫,最後再根據描述符的內容,進行壓棧、跳轉、許可權級別提升。核心程式碼執行完畢之後,呼叫 IRET 指令返回,IRET 指令恢復使用者棧,並跳轉會低級別的程式碼。 其實,在發生系統呼叫,由 Ring3 進入 Ring0 的這個過程浪費了不少的 CPU 週期,例如,系統呼叫必然需要由 Ring3 進入 Ring0(由核心呼叫 INT 指令的方式除外,這多半屬於 Hacker 的核心模組所為),許可權提升之前和之後的級別是固定的,CPL 肯定是 3,而 INT 80 的 DPL 肯定也是 3,這樣 CPU 檢查門描述符的 DPL 和呼叫者的 CPL 就是完全沒必要。 複製程式碼
正是由於如此,在linux2.5開始支援一種新的系統呼叫,其基於Intel 奔騰2代處理器就開始支援的一組專門針對系統呼叫的指令 sysenter
/ sysexit
。 sysenter
指令用於由 Ring3 進入 Ring0, sysexit
指令用於由 Ring0 返回 Ring3。由於沒有特權級別檢查的處理,也沒有壓棧的操作,所以執行速度比 INT n/IRET 快了不少。
本文分析的是int指令,新型的系統呼叫機制可以參見下面幾篇文章:
基於int的系統呼叫
觸發中斷
我們以系統呼叫 fork
為例,fork函式的定義在glibc(2.17版本)的 unistd.h
/* Clone the calling process, creating an exact copy. Return -1 for errors, 0 to the new process, and the process ID of the new process to the old process.*/ extern __pid_t fork (void) __THROWNL; 複製程式碼
fork
函式的實現程式碼比較難找,在 nptl\sysdeps\unix\sysv\linux\fork.c
中有這麼一段程式碼
weak_alias (__libc_fork, __fork) libc_hidden_def (__fork) weak_alias (__libc_fork, fork) 複製程式碼
其作用簡單的說就是將 __libc_fork
當作 __fork
的別名,所以fork函式的實現是在 __libc_fork
中,核心程式碼如下
#ifdef ARCH_FORK pid = ARCH_FORK (); #else # error "ARCH_FORK must be defined so that the CLONE_SETTID flag is used" pid = INLINE_SYSCALL (fork, 0); #endif 複製程式碼
我們分析定義了 ARCH_FORK
的情況, ARCH_FORK
定義在 nptl\sysdeps\unix\sysv\linux\i386\fork.c
中,程式碼如下:
#define ARCH_FORK() \ INLINE_SYSCALL (clone, 5,\ CLONE_CHILD_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD, 0,\ NULL, NULL, &THREAD_SELF->tid) 複製程式碼
INLINE_SYSCALL程式碼在 sysdeps\unix\sysv\linux\i386\sysdep.h
#undef INLINE_SYSCALL #define INLINE_SYSCALL(name, nr, args...) \ ({\ unsigned int resultvar = INTERNAL_SYSCALL (name, , nr, args);\ if (__builtin_expect (INTERNAL_SYSCALL_ERROR_P (resultvar, ), 0))\ {\ __set_errno (INTERNAL_SYSCALL_ERRNO (resultvar, ));\ resultvar = 0xffffffff;\ }\ (int) resultvar; }) 複製程式碼
INLINE_SYSCALL
主要是呼叫同文件下的 INTERNAL_SYSCALL
# define INTERNAL_SYSCALL(name, err, nr, args...) \ ({\ register unsigned int resultvar;\ EXTRAVAR_##nr\ asm volatile (\ LOADARGS_##nr\ "movl %1, %%eax\n\t"\ "int $0x80\n\t"\ RESTOREARGS_##nr\ : "=a" (resultvar)\ : "i" (__NR_##name) ASMFMT_##nr(args) : "memory", "cc");\ (int) resultvar; }) 複製程式碼
#define __NR_clone 120 複製程式碼
這裡是一段內聯彙編程式碼, 其中 __NR_##name
的值為 __NR_clone
即120。這裡主要是兩個步驟:
int $0x80
int $0x80
指令會讓cpu陷入中斷,執行對應的0x80中斷處理函式。不過在這之前,cpu還需要進行 棧切換 。
因為在linux中,使用者態和核心態使用的是不同的棧(可以看看這篇文章),兩者負責各自的函式呼叫,互不干擾。在執行 int $0x80
時,程式需要由使用者態切換到核心態,所以程式當前棧也要 從使用者棧切換到核心棧 。與之對應,當中斷程式執行結束返回時,當前棧要 從核心棧切換回使用者棧 。
這裡說的當前棧指的就是ESP暫存器的值所指向的棧。ESP的值位於使用者棧的範圍,那程式的當前棧就是使用者棧,反之亦然。此外暫存器SS的值指向當前棧所在的頁。因此,將使用者棧切換到核心棧的過程是:
- 將當前ESP、SS等暫存器的值存到核心棧上。
- 將ESP、SS等值設定為核心棧的相應值。
反之,從核心棧切換回使用者棧的過程:恢復ESP、SS等暫存器的值,也就是用儲存在核心棧的原ESP、SS等值設定回對應暫存器。
中斷處理程式
在切換到核心棧之後,就開始執行中斷向量表的 0x80
號中斷處理程式。中斷處理程式除了系統呼叫( 0x80
)還有如除0異常( 0x00
)、缺頁異常( 0x14
)等等,在 arch\i386\kernel\traps.c
檔案的 trap_init
方法中描述了中斷處理程式向中斷向量表註冊的過程:
void __init trap_init(void) { #ifdef CONFIG_EISA void __iomem *p = ioremap(0x0FFFD9, 4); if (readl(p) == 'E'+('I'<<8)+('S'<<16)+('A'<<24)) { EISA_bus = 1; } iounmap(p); #endif #ifdef CONFIG_X86_LOCAL_APIC init_apic_mappings(); #endif set_trap_gate(0,÷_error); set_intr_gate(1,&debug); set_intr_gate(2,&nmi); set_system_intr_gate(3, &int3); /* int3-5 can be called from all */ 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_DOUBLEFAULT_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_intr_gate(14,&page_fault); set_trap_gate(15,&spurious_interrupt_bug); set_trap_gate(16,&coprocessor_error); set_trap_gate(17,&alignment_check); #ifdef CONFIG_X86_MCE set_trap_gate(18,&machine_check); #endif set_trap_gate(19,&simd_coprocessor_error); set_system_gate(SYSCALL_VECTOR,&system_call); /* * Should be a barrier for any external CPU state. */ cpu_init(); trap_init_hook(); } 複製程式碼
SYSCALL_VECTOR
定義如下:
#define SYSCALL_VECTOR0x80 複製程式碼
所以 0x80
對應的處理程式就是 system_call
這個方法,該方法位於 arch\i386\kernel\entry.S
ENTRY(system_call) //code 1: 儲存各種暫存器 SAVE_ALL ... jnz syscall_trace_entry //如果傳入的系統呼叫號大於最大的系統呼叫號,則跳轉到無效呼叫處理 cmpl $(nr_syscalls), %eax jae syscall_badsys syscall_call: //code 2: 根據系統呼叫號(儲存在eax中)來呼叫對應的系統呼叫程式 call *sys_call_table(,%eax,4) //儲存系統呼叫返回值到eax暫存器中 movl %eax,EAX(%esp)# store the return value ... restore_all: //code 3:恢復各種暫存器的值 以及執行iret指令 RESTORE_ALL ... 複製程式碼
主要分為幾步:
1.儲存各種暫存器
2.根據系統呼叫號執行對應的系統呼叫程式,將返回結果存入到eax中
3.恢復各種暫存器
其中儲存各種暫存器的 SAVE_ALL
定義在entry.S中:
#define SAVE_ALL \ cld; \ pushl %es; \ pushl %ds; \ pushl %eax; \ pushl %ebp; \ pushl %edi; \ pushl %esi; \ pushl %edx; \ pushl %ecx; \ pushl %ebx; \ movl $(__USER_DS), %edx; \ movl %edx, %ds; \ movl %edx, %es; 複製程式碼
sys_call_table
定義在entry.S中:
.data ENTRY(sys_call_table) .long sys_restart_syscall/* 0 - old "setup()" system call, used for restarting */ .long sys_exit .long sys_fork .long sys_read .long sys_write .long sys_open/* 5 */ ... .long sys_sigreturn .long sys_clone/* 120 */ ... 複製程式碼
sys_call_table
就是系統呼叫表,每一個long元素(4位元組)都是一個系統呼叫地址,所以 *sys_call_table(,%eax,4)
的含義就是 sys_call_table
上偏移量為 0+%eax*4
元素所指向的系統呼叫,即第 %eax
個系統呼叫。上文中 fork
系統呼叫最終設定到eax的值是120,那最終執行的就是 sys_clone
這個函式,注意其實現和第2個系統呼叫 sys_fork
基本一樣,只是引數不同,關於fork和clone的區別可以看這裡,程式碼如下:
//kernel\fork.c asmlinkage int sys_fork(struct pt_regs regs) { return do_fork(SIGCHLD, regs.esp, ®s, 0, NULL, NULL); } asmlinkage int sys_clone(struct pt_regs regs) { unsigned long clone_flags; unsigned long newsp; int __user *parent_tidptr, *child_tidptr; clone_flags = regs.ebx; newsp = regs.ecx; parent_tidptr = (int __user *)regs.edx; child_tidptr = (int __user *)regs.edi; if (!newsp) newsp = regs.esp; return do_fork(clone_flags, newsp, ®s, 0, parent_tidptr, child_tidptr); } 複製程式碼
一次系統呼叫的基本過程已經分析完,剩下的具體處理邏輯和本文無關就不分析了,有興趣的同學可以自己看看。
整體呼叫流程圖如下:

End
想寫這篇文章的原因主要是年前在看《《程式設計師的自我修養》》這本書,之前對於系統呼叫這塊有一些瞭解但很零碎和模糊,看完本書系統呼叫這一章後消除了我許多疑問。總體來說這是一本不錯的書,但我相關的基礎比較薄弱,所以收穫不多。