你真的知道什麼是系統呼叫嗎?

做個好人君發表於2019-02-17

在現代作業系統裡,由於系統資源可能同時被多個應用程式訪問,如果不加保護,那各個應用程式之間可能會產生衝突,對於惡意應用程式更可能導致系統奔潰。這裡所說的系統資源包括檔案、網路、各種硬體裝置等。比如要操作檔案必須藉助作業系統提供的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/sysexitsysenter 指令用於由 Ring3 進入 Ring0,sysexit指令用於由 Ring0 返回 Ring3。由於沒有特權級別檢查的處理,也沒有壓棧的操作,所以執行速度比 INT n/IRET 快了不少。

本文分析的是int指令,新型的系統呼叫機制可以參見下面幾篇文章:

www.ibm.com/developerwo…

www.jianshu.com/p/f4c04cf8e…

基於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。這裡主要是兩個步驟:

  1. 設定eax暫存器的值為120
  2. 執行int $0x80陷入中斷

int $0x80指令會讓cpu陷入中斷,執行對應的0x80中斷處理函式。不過在這之前,cpu還需要進行棧切換

因為在linux中,使用者態和核心態使用的是不同的棧(可以看看這篇文章),兩者負責各自的函式呼叫,互不干擾。在執行int $0x80時,程式需要由使用者態切換到核心態,所以程式當前棧也要從使用者棧切換到核心棧。與之對應,當中斷程式執行結束返回時,當前棧要從核心棧切換回使用者棧

這裡說的當前棧指的就是ESP暫存器的值所指向的棧。ESP的值位於使用者棧的範圍,那程式的當前棧就是使用者棧,反之亦然。此外暫存器SS的值指向當前棧所在的頁。因此,將使用者棧切換到核心棧的過程是:

  1. 將當前ESP、SS等暫存器的值存到核心棧上。
  2. 將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,&divide_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, &regs, 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, &regs, 0, parent_tidptr, child_tidptr);
}
複製程式碼

一次系統呼叫的基本過程已經分析完,剩下的具體處理邏輯和本文無關就不分析了,有興趣的同學可以自己看看。

整體呼叫流程圖如下:

1550410105156.png

End

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

相關文章