Linux操作系統學習筆記(四)系統調用

前言

  通過前面幾篇文章,我們分析了從按下電源鍵到內核啓動、完成初始化的整個過程。在後面的文章中我們將分別深入剖析Linux內核各個重要部分的源碼。考慮到後面的部分我們會從用戶態的代碼開始入手一步一步深入,因此在分析這些之前,我們需要仔細看一看如何實現一個從用戶態到內核態再回到用戶態的系統調用的全過程,即系統調用的實現。

  本文的說明順序如下

  • 首先從一個簡單的例子開始分析glibc中對應的調用
  • 針對32位和64位中調用的結構不同會分開兩部分單獨介紹,會介紹整個調用至完成的過程。即用戶態->內核態->用戶態
  • 在整個調用過程中最重要的一步是中間訪問系統調用表,該部分爲了描述清楚單獨拉出來最後介紹

GLIBC標準庫的調用

  讓我們從一個簡單的程序開始

#include <stdio.h>

int main(int argc, char **argv)
{
   FILE *fp;
   char buff[255];

   fp = fopen("test.txt", "r");
   fgets(buff, 255, fp);
   printf("%s\n", buff);
   fclose(fp);

   return 0;
}

  如上所示的程序主要調用了glibc中的函數,然後在其上進行了封裝而成。比如fopen實際使用的是open,這裏我們就以該函數爲例來說明整個調用過程。首先open函數的系統調用在syscalls.list表中定義

# File name Caller Syscall name Args Strong name Weak names
open - open Ci:siv __libc_open __open open

  根據此配置文件,glibc會調用腳本make_syscall.sh將其封裝爲宏,如SYSCALL_NAME open的形式。這些宏會通過T_PSEUDO來調用(位於syscall-template.S),而實際上使用的則是DO_CALL(syscall_name, args)

T_PSEUDO (SYSCALL_SYMBOL, SYSCALL_NAME, SYSCALL_NARGS)
    ret
T_PSEUDO_END (SYSCALL_SYMBOL)

#define T_PSEUDO(SYMBOL, NAME, N)    PSEUDO (SYMBOL, NAME, N)

#define PSEUDO(name, syscall_name, args)      \
  .text;                                      \
  ENTRY (name)                                \
    DO_CALL (syscall_name, args);             \
    cmpl $-4095, %eax;                        \
    jae SYSCALL_ERROR_LABEL    

32位系統調用過程

  考慮到32位和64位代碼結構有一些區別,因此這裏需要分開討論。在32位系統中,DO_CALL()位於i386 目錄下的sysdep.h文件中

/* Linux takes system call arguments in registers:
  syscall number  %eax       call-clobbered
  arg 1    %ebx       call-saved
  arg 2    %ecx       call-clobbered
  arg 3    %edx       call-clobbered
  arg 4    %esi       call-saved
  arg 5    %edi       call-saved
  arg 6    %ebp       call-saved
......
*/
#define DO_CALL(syscall_name, args)               \
    PUSHARGS_##args                               \
    DOARGS_##args                                 \
    movl $SYS_ify (syscall_name), %eax;           \
    ENTER_KERNEL                                  \
    POPARGS_##args

  這裏,我們將請求參數放在寄存器裏面,根據系統調用的名稱,得到系統調用號,放在寄存器 eax 裏面,然後執行ENTER_KERNEL

# define ENTER_KERNEL int $0x80

  ENTER_KERNEL實際調用的是80軟中斷,以此陷入內核。這些中斷在trap_init()中被定義並初始化。在前文中對trap_init()已有一些簡單的敘述,後面再中斷部分會再詳細介紹。

  初始化好的中斷表會等待到中斷觸發,觸發的時候則調用對應的回調函數,這裏的話就是entry_INT80_32。該中斷首先通過pushSAVE_ALL保存所有的寄存器,存儲在pt_regs中,然後調用do_syscall_32_irqs_on()函數。該函數將系統調用號從eax裏面取出來,然後根據系統調用號,在系統調用表中找到相應的函數進行調用,並將寄存器中保存的參數取出來,作爲函數參數。最後調用INTERRUPT_RETURN,實際使用的是iret指令將原來用戶保存的現場包含代碼段、指令指針寄存器等恢復,並返回至用戶態執行。

ENTRY(entry_INT80_32)
        ASM_CLAC
        pushl   %eax                    /* pt_regs->orig_ax */
        SAVE_ALL pt_regs_ax=$-ENOSYS    /* save rest */
        movl    %esp, %eax
        call    do_syscall_32_irqs_on
.Lsyscall_32_done:
......
.Lirq_return:
  INTERRUPT_RETURN

      
......
      

static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)
{
  struct thread_info *ti = current_thread_info();
  unsigned int nr = (unsigned int)regs->orig_ax;
......
  if (likely(nr < IA32_NR_syscalls)) {
    regs->ax = ia32_sys_call_table[nr](
      (unsigned int)regs->bx, (unsigned int)regs->cx,
      (unsigned int)regs->dx, (unsigned int)regs->si,
      (unsigned int)regs->di, (unsigned int)regs->bp);
  }
  syscall_return_slowpath(regs);
}      

64位系統調用過程

  對於64位系統來說,DO_CALL位於x86_64 目錄下的 sysdep.h 文件中

/* The Linux/x86-64 kernel expects the system call parameters in
   registers according to the following table:
    syscall number  rax
    arg 1    rdi
    arg 2    rsi
    arg 3    rdx
    arg 4    r10
    arg 5    r8
    arg 6    r9
......
*/
#define DO_CALL(syscall_name, args)                \
  lea SYS_ify (syscall_name), %rax;                \
  syscall

  和之前一樣,還是將系統調用名稱轉換爲系統調用號,放到寄存器rax。這裏是真正進行調用,不是用中斷了,而是改用syscall指令了。並且,通過註釋我們也可以知道,傳遞參數的寄存器也變了。syscall指令還使用了一種特殊的寄存器,我們叫特殊模塊寄存器(Model Specific Registers,簡稱 MSR)。這種寄存器是 CPU 爲了完成某些特殊控制功能爲目的的寄存器,其中就有系統調用。

  在系統初始化的時候,trap_init() 除了初始化上面的中斷模式,這裏面還會調用 cpu_init->syscall_init()。這裏面有這樣的代碼:

wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

  rdmsr()wrmsr() 是用來讀寫特殊模塊寄存器的。MSR_LSTAR 就是這樣一個特殊的寄存器,當 syscall 指令調用的時候,會從這個寄存器裏面拿出函數地址來調用,也就是調用 entry_SYSCALL_64。在 arch/x86/entry/entry_64.S中定義了 entry_SYSCALL_64函數。

  該函數開始於一條宏:SWAPGS_UNSAFE_STACK,其定義如下,主要是交換當前GS基寄存器中的值和特殊模塊寄存器中包含的值,即進入內核棧

#define SWAPGS_UNSAFE_STACK    swapgs

  對於舊的棧,我們會將其存於rsp_scratch,並將棧指針移至當前進程的棧頂。

movq    %rsp, PER_CPU_VAR(rsp_scratch)
movq    PER_CPU_VAR(cpu_current_top_of_stack), %rsp

  下一步,我們將棧段和舊的棧指針壓入棧中

pushq    $__USER_DS
pushq    PER_CPU_VAR(rsp_scratch)

  接下來,我們需要打開中斷並保存很多寄存器到 pt_regs 結構裏面,例如用戶態的代碼段、數據段、保存參數的寄存器,並校驗當前線程的信息_TIF_WORK_SYSCALL_ENTRY,這裏涉及到Linux的debugging和tracing技術,會單獨在後文中詳細分析。該部分代碼具體如下所示。

ENTRY(entry_SYSCALL_64)
        /* Construct struct pt_regs on stack */
        pushq   $__USER_DS                      /* pt_regs->ss */
        pushq   PER_CPU_VAR(rsp_scratch)        /* pt_regs->sp */
        pushq   %r11                            /* pt_regs->flags */
        pushq   $__USER_CS                      /* pt_regs->cs */
        pushq   %rcx                            /* pt_regs->ip */
        pushq   %rax                            /* pt_regs->orig_ax */
        pushq   %rdi                            /* pt_regs->di */
        pushq   %rsi                            /* pt_regs->si */
        pushq   %rdx                            /* pt_regs->dx */
        pushq   %rcx                            /* pt_regs->cx */
        pushq   $-ENOSYS                        /* pt_regs->ax */
        pushq   %r8                             /* pt_regs->r8 */
        pushq   %r9                             /* pt_regs->r9 */
        pushq   %r10                            /* pt_regs->r10 */
        pushq   %r11                            /* pt_regs->r11 */
        sub     $(6*8), %rsp                    /* pt_regs->bp, bx, r12-15 not saved */
        movq    PER_CPU_VAR(current_task), %r11
        testl   $_TIF_WORK_SYSCALL_ENTRY|_TIF_ALLWORK_MASK, TASK_TI_flags(%r11)
        jnz     entry_SYSCALL64_slow_path

  各寄存器的作用如下所示:

  • rax:系統調用數目
  • rcx:函數返回的用戶空間地址
  • r11:寄存器標記
  • rdi:系統調用回調函數的第一個參數
  • rsi:系統調用回調函數的第二個參數
  • rdx:系統調用回調函數的第三個參數
  • r10:系統調用回調函數的第四個參數
  • r8 :系統調用回調函數的第五個參數
  • r9 :系統調用回調函數的第六個參數
  • rbp,rbx,r12-r15:通用的callee-preserved寄存器

  在此之後,其實存在着兩個處理分支:entry_SYSCALL64_slow_pathentry_SYSCALL64_fast_path,這裏是根據_TIF_WORK_SYSCALL_ENTRY判斷的結果進行選擇,這裏涉及到ptrace部分的知識,暫時先不介紹了,會在後面單獨開一文詳細研究。如果設置了_TIF_ALLWORK_MASK或者_TIF_WORK_SYSCALL_ENTRY,則跳轉至slow_path,否則繼續運行fast_path

#define _TIF_WORK_SYSCALL_ENTRY \
    (_TIF_SYSCALL_TRACE | _TIF_SYSCALL_EMU | _TIF_SYSCALL_AUDIT |   \
    _TIF_SECCOMP | _TIF_SINGLESTEP | _TIF_SYSCALL_TRACEPOINT |     \
    _TIF_NOHZ)

fastpath分支

  該分支主要分爲以下部分內容

  • 再次檢測TRACE部分,如果有標記則跳轉至slow_path
  • 檢測__SYSCALL_MASK,如果CONFIG_X86_X32_ABI未設置我們就比較rax寄存器的值和最大系統調用數__NR_syscall_max,否則則標記eax寄存器爲__x32_SYSCALL_BIT,再進行比較
  • ja指令會在CFZF設置爲0時進行跳轉,即如果不滿足條件則會跳轉至-ENOSYS,否則繼續執行
  • 將第四個參數從r10放入rcx以保持x86_64 C ABI編譯
  • 執行sys_call_table,去系統調用表中查找系統調用
entry_SYSCALL_64_fastpath:
	/*
	 * Easy case: enable interrupts and issue the syscall.  If the syscall
	 * needs pt_regs, we'll call a stub that disables interrupts again
	 * and jumps to the slow path.
	 */
	TRACE_IRQS_ON
	ENABLE_INTERRUPTS(CLBR_NONE)
	
#if __SYSCALL_MASK == ~0
	cmpq	$__NR_syscall_max, %rax
#else
	andl	$__SYSCALL_MASK, %eax
	cmpl	$__NR_syscall_max, %eax
#endif

	ja	1f				/* return -ENOSYS (already in pt_regs->ax) */
	movq	%r10, %rcx

	/*
	 * This call instruction is handled specially in stub_ptregs_64.
	 * It might end up jumping to the slow path.  If it jumps, RAX
	 * and all argument registers are clobbered.
	 */
	call	*sys_call_table(, %rax, 8)
    
......
# ifdef CONFIG_X86_X32_ABI
#  define __SYSCALL_MASK (~(__X32_SYSCALL_BIT))
# else
#  define __SYSCALL_MASK (~0)
# endif

#define __X32_SYSCALL_BIT    0x40000000

slow_path分支

  slow_path部分的源碼如下

entry_SYSCALL64_slow_path:
    /* IRQs are off. */
    SAVE_EXTRA_REGS
    movq    %rsp, %rdi
    call    do_syscall_64           /* returns with IRQs disabled */

  slow_path會調用entry_SYSCALL64_slow_pat->do_syscall_64(),執行完畢後恢復寄存器,最後調用USERGS_SYSRET64,實際使用sysretq指令返回。

return_from_SYSCALL_64:
    RESTORE_EXTRA_REGS
    TRACE_IRQS_IRETQ
    movq  RCX(%rsp), %rcx
    movq  RIP(%rsp), %r11
    movq  R11(%rsp), %r11
......
syscall_return_via_sysret:
    /* rcx and r11 are already restored (see code above) */
    RESTORE_C_REGS_EXCEPT_RCX_R11
    movq  RSP(%rsp), %rsp
    USERGS_SYSRET64

  在 do_syscall_64 裏面,從 rax裏面拿出系統調用號,然後根據系統調用號,在系統調用表 sys_call_table 中找到相應的函數進行調用,並將寄存器中保存的參數取出來,作爲函數參數。

__visible void do_syscall_64(struct pt_regs *regs)
{
        struct thread_info *ti = current_thread_info();
        unsigned long nr = regs->orig_ax;
......
        if (likely((nr & __SYSCALL_MASK) < NR_syscalls)) {
                regs->ax = sys_call_table[nr & __SYSCALL_MASK](
                        regs->di, regs->si, regs->dx,
                        regs->r10, regs->r8, regs->r9);
        }
        syscall_return_slowpath(regs);
}

  至此,32位和64位又回到了同樣的位置:查找系統調用表sys_call_table

系統調用表的生成

  32位和64位的sys_call_table均位於arch/x86/entry/syscalls/目錄下,分別爲syscall_32.tblsyscall_64.tbl。如下所示爲32位和64位中open函數的定義

5 i386 open sys_open compat_sys_open

2 common open sys_open

  第一列的數字是系統調用號。可以看出,32 位和 64 位的系統調用號是不一樣的。第三列是系統調用的名字,第四列是系統調用在內核的實現函數。不過,它們都是以 sys_ 開頭。系統調用在內核中的實現函數要有一個聲明。聲明往往在 include/linux/syscalls.h文件中。例如 sys_open 是這樣聲明的:

asmlinkage long sys_open(const char __user *filename,
                                int flags, umode_t mode);

  真正的實現這個系統調用,一般在一個.c 文件裏面,例如 sys_open 的實現在 fs/open.c 裏面。其中採用了宏的方式對函數名進行了封裝,實際拆開是一樣的。

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
    if (force_o_largefile())
                flags |= O_LARGEFILE;
    return do_sys_open(AT_FDCWD, filename, flags, mode);
}

......

asmlinkage long sys_open(const char __user * filename, int flags, int mode)
{
    long ret;
    
	if (force_o_largefile())
		flags |= O_LARGEFILE;
    
	ret = do_sys_open(AT_FDCWD, filename, flags, mode);
	asmlinkage_protect(3, ret, filename, flags, mode);
	return ret;
}

  其中SYSCALL_DEFINE3 是一個宏系統調用最多六個參數,根據參數的數目選擇宏。具體是這樣定義如下所示,首先使用SYSCALL_METADATA()宏解決syscall_metada結構體的初始化,該結構體包括了不同的有用區域包括系統調用的名字、系統調用表中對應的序號、系統調用的參數、參數類型鏈表等。

#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)

#define SYSCALL_DEFINEx(x, sname, ...)                          		\
        SYSCALL_METADATA(sname, x, __VA_ARGS__)                 		\
        __SYSCALL_DEFINEx(x, sname, __VA_ARGS__)

#define __PROTECT(...) asmlinkage_protect(__VA_ARGS__)
#define __SYSCALL_DEFINEx(x, name, ...)                                 \
        asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))       \
                __attribute__((alias(__stringify(SyS##name))));         \
        static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__));  \
        asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__));      \
        asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__))       \
        {                                                               \
                long ret = SYSC##name(__MAP(x,__SC_CAST,__VA_ARGS__));  \
                __MAP(x,__SC_TEST,__VA_ARGS__);                         \
                __PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__));       \
                return ret;                                             \
        }                                                               \
        static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__)

..........
    
#define SYSCALL_METADATA(sname, nb, ...)                             \
    ...                                                              \
    ...                                                              \
    ...                                                              \
    struct syscall_metadata __used                                   \
              __syscall_meta_##sname = {                             \
                    .name           = "sys"#sname,                   \
                    .syscall_nr     = -1,                            \
                    .nb_args        = nb,                            \
                    .types          = nb ? types_##sname : NULL,     \
                    .args           = nb ? args_##sname : NULL,      \
                    .enter_event    = &event_enter_##sname,          \
                    .exit_event     = &event_exit_##sname,           \
                    .enter_fields   = LIST_HEAD_INIT(__syscall_meta_##sname.enter_fields), 				   \
             };                                                                            																		\

    static struct syscall_metadata __used                            \
              __attribute__((section("__syscalls_metadata")))        \
             *__p_syscall_meta_##sname = &__syscall_meta_##sname;

  在編譯的過程中,需要根據 syscall_32.tblsyscall_64.tbl 生成自己的syscalls_32.hsyscalls_64.h。生成方式在 arch/x86/entry/syscalls/Makefile 中。這裏面會使用兩個腳本

  • 第一個腳本arch/x86/entry/syscalls/syscallhdr.sh,會在文件中生成 #define __NR_open;

  • 第二個腳本 arch/x86/entry/syscalls/syscalltbl.sh,會在文件中生成__SYSCALL(__NR_open, sys_open)

    這樣最終生成syscalls_32.hsyscalls_64.h 就保存了系統調用號和系統調用實現函數之間的對應關係,如下所示

__SYSCALL_COMMON(0, sys_read, sys_read)
__SYSCALL_COMMON(1, sys_write, sys_write)
__SYSCALL_COMMON(2, sys_open, sys_open)
__SYSCALL_COMMON(3, sys_close, sys_close)
__SYSCALL_COMMON(5, sys_newfstat, sys_newfstat)
...
...
...

  其中__SYSCALL_COMMON宏定義如下,主要是將對應的數字序號和系統調用名對應

#define __SYSCALL_COMMON(nr, sym, compat) __SYSCALL_64(nr, sym, compat)
#define __SYSCALL_64(nr, sym, compat) [nr] = sym,

  最終形成的表如下

asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
    [0 ... __NR_syscall_max] = &sys_ni_syscall,
    [0] = sys_read,
    [1] = sys_write,
    [2] = sys_open,
    ...
    ...
    ...
};

  最後,所有的系統調用會存儲在arch/x86/entry/目錄下的syscall_32.csyscall_64.c中,裏面包含了syscalls_32.hsyscalls_64.h 頭文件,其形式如下:

__visible const sys_call_ptr_t ia32_sys_call_table[__NR_syscall_compat_max+1] = {
        /*
         * Smells like a compiler bug -- it doesn't work
         * when the & below is removed.
         */
        [0 ... __NR_syscall_compat_max] = &sys_ni_syscall,
#include <asm/syscalls_32.h>
};

/* System call table for x86-64. */
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
  /*
   * Smells like a compiler bug -- it doesn't work
   * when the & below is removed.
   */
  [0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};

  其中__NR_syscall_max宏定義規定了最大系統調用數量,該數量取決於操作系統的架構,在X86下定義如下

#define __NR_syscall_max 547

  這裏還需要注意sys_call_ptr_t表示指向系統調用表的指針,定義爲函數指針

typedef void (*sys_call_ptr_t)(void);

  系統調用表數組中的每一個系統調用均會指向sys_ni_syscall,該函數表示一個未實現的系統調用(not-implement),從而系統調用表的初始化。

asmlinkage long sys_ni_syscall(void)
{
    return -ENOSYS;
}

ENOSYS Function not implemented (POSIX.1)

  由此,整個系統調用表的生成過程就全部說明完了,而在實際產生系統調用的時候,過程則剛好相反:

  • 用戶態調用syscall
  • syscall導致中斷,程序由用戶態陷入內核態
  • 內核C函數執行syscalls_32/64.c,並由此獲得對應關係最終在對應的源碼中找到函數實現
  • 針對對應的sys_syscall_name函數,做好調用準備工作,如初始化系統調用入口、保存寄存器、切換新的棧、構造新的task以備中斷回調等。
  • 調用函數實現
  • 切換寄存器、棧,返回用戶態

總結

  本文較爲深入的分析了系統調用的整個過程,並着重分析了系統調用表的形成和使用原理,如有遺漏錯誤還請多多指正。

源碼資料

[1] linux/arch/x86/entry/

[2] linux/arch/x86/kernel/cpu/common.c

[3] linux/include/linux/syscalls.h

[4] linux/arch/x86/include/asm/thread_info.h

參考資料

[1] Linux-insides

[2] 深入理解Linux內核源碼

[3] Linux內核設計的藝術

[4] 極客時間 趣談Linux操作系統

[5] Intel® 64 and IA-32 Architectures Software Developer Manuals

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章