在現代作業系統裡,由於系統資源可能同時被多個應用程式訪問,如果不加保護,那各個應用程式之間可能會產生衝突,對於惡意應用程式更可能導致系統奔潰。這裡所說的系統資源包括檔案、網路、各種硬體裝置等。比如要操作檔案必須藉助作業系統提供的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。這裡主要是兩個步驟:
- 設定eax暫存器的值為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_VECTOR 0x80
複製程式碼
所以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
想寫這篇文章的原因主要是年前在看《《程式設計師的自我修養》》這本書,之前對於系統呼叫這塊有一些瞭解但很零碎和模糊,看完本書系統呼叫這一章後消除了我許多疑問。總體來說這是一本不錯的書,但我相關的基礎比較薄弱,所以收穫不多。