System-call 系統調用


一、系統調用過程

1. 用戶在進行系統調用時,通過傳遞一個系統調用編號,來告知內核,它所請求的系統調用,內核通過這個編號進而找到對應的處理系統調用的C函數。這個系統編號,在 x86 架構上,是通過 eax 寄存器傳遞的。

2. 系統調用的過程跟其他的異常處理流程一樣,包含下面幾個步驟:
(1) 將當前的寄存器上下文保存在內核 stack 中(這部分處理都在匯編代碼中)
(2) 調用對應的C函數去處理系統調用
(3) 從系統調用處理函數返回,恢復之前保存在 stack 中的寄存器,CPU 從內核態切換到用戶態

3. 在內核中用於處理系統調用的C函數入口名稱是 sys_xxx() ,xxx() 就是對應的系統調用,實際上會有宏在xxx()前面加上一個函數頭。 在 Linux 內核的代碼中,這樣的系統調用函數命名則是通過宏定義 SYSCALL_DEFINEx 來實現的,其中的 x 表示這個系統調用處理函數的輸入參數個數。(不同的架構會復寫這個宏定義,以實現不同的調用規則,其中 ARM64 的宏定義在 arch/arm64/include/asm/syscall_wrapper.h 文件中)

4. 將系統調用編號與這些實際處理C函數聯系起來的是一張系統調用表 sys_call_table 這個表具有 __NR_syscalls 個元素(目前kernel-5.10這個值是440)。表中對應的 n 號元素所存儲的就是 n 號系統調用對應的處理函數指針。__NR_syscalls 這個宏只是表示這個表的大小,並不是真正的系統調用個數,如果對應序號的系統調用不存在,那么就會用 sys_ni_syscall 填充,這是一個表示沒有實現的系統調用,它直接返回錯誤碼 -ENOSYS。

//arch/arm64/kernel/sys.c
#undef __SYSCALL
#define __SYSCALL(nr, sym)    asmlinkage long __arm64_##sym(const struct pt_regs *);
#include <asm/unistd.h> //<1>

#undef __SYSCALL
#define __SYSCALL(nr, sym)    [nr] = __arm64_##sym,

typedef long (*syscall_fn_t)(const struct pt_regs *regs);

const syscall_fn_t sys_call_table[__NR_syscalls] = {
    [0 ... __NR_syscalls - 1] = __arm64_sys_ni_syscall, //這個函數是防止沒有實現的,直接return -ENOSYS;
#include <asm/unistd.h> //<2>
};

<asm/unistd.h> 最終使用的是 <uapi/asm-generic/unistd.h> 它里面定義了 NR_xxx 和 相關函數,以 getpriority 系統調用的實現為例:

//include/uapi/asm-generic/unistd.h
#define __NR_getpriority 141
__SYSCALL(__NR_getpriority, sys_getpriority)

在位置<1>,展開為:asmlinkage long __arm64_sys_getpriority(const struct pt_regs *);
在位置<2>,展開為:[141] = __arm64_sys_getpriority,
最終 sys_call_table[] 下標為 141 的位置指向的函數為 __arm64_sys_getpriority

 

二、系統調用的進入和退出

1. 在 x86 的架構上,支持2種方式進入和退出系統調用:

(1) 通過 int $0x80 觸發軟件中斷進入,iret 指令退出
(2) 通過 sysenter 指令進入,sysexit指令退出

2. 在 ARM 架構上,則是通過 svc 指令進入系統調用。

ARM64 架構中,存在4個不同的運行級別,分別為 EL0、EL1、EL2、EL3,這4個級別運行的系統如下圖所示:

 

用戶態運行在 EL0 級別,我們討論的內核則是運行在 EL1 級別。svc 指令通過觸發一個同步異常,使得從 EL0 跳轉到 EL1 級別,也就是從用戶態跳轉到了內核態。這個同步異常的處理入口在 arch/arm64/kernel/entry.S
文件中的 el0_sync 它是通過 kernel_ventry 這樣一個宏在 ENTRY(vectors) 異常處理向量表中注冊的,其實就是匯編中的一個標號。當 svc 指令執行時,CPU 就會切換到 EL1 級別,並且跳轉到在異常向量表 vectors 中找到由宏 kernel_ventry 展開所在的地址。kernel_ventry 做了一個簡單的溢出檢測后,就跳轉到真正的異常處理入口 el0_sync 。

/*
 * EL0 mode handlers.
 */
    .align    6
SYM_CODE_START_LOCAL_NOALIGN(el0_sync) /*宏展開為: ; ; el0_sync: */
    kernel_entry 0
    mov    x0, sp
    bl    el0_sync_handler
    b    ret_to_user
SYM_CODE_END(el0_sync) /*宏展開為:.type el0_sync 0 ; .size el0_sync, .-el0_sync*/

在這段匯編指令中, kernel_entry 將寄存器入棧,保存現場。然后將當前的棧指針傳遞給 x0,作為 el0_sync_handler 的C函數入參。異常處理完成后,則通過 ret_to_user 回到用戶態。

由於所有的同步的異常都是這個入口,所以在 el0_sync_handler 中會讀取 ESR_EL1 寄存器獲取真正觸發同步異常的原因,然后進行對應的響應處理。此處,我們是 svc 指令觸發的異常,所以調用 el0_svc 進行處理。我們看 do_el0_svc 函數的處理:

asmlinkage void noinstr el0_sync_handler(struct pt_regs *regs) //arch/arm64/kernel/entry-common.c
{
    unsigned long esr = read_sysreg(esr_el1);

    switch (ESR_ELx_EC(esr)) { //取bit26-bit32
    case ESR_ELx_EC_SVC64: //0x15
        el0_svc(regs); //系統調用
        break;
    ...
    default:
        el0_inv(regs, esr);
    }
}

static void noinstr el0_svc(struct pt_regs *regs)
{
    enter_from_user_mode();
    do_el0_svc(regs);
}

void do_el0_svc(struct pt_regs *regs) //arch/arm64/kernel/syscall.c
{
    sve_user_discard();
    el0_svc_common(regs, regs->regs[8], __NR_syscalls, sys_call_table); //reg[8]也就是X8寄存器存儲的是系統調用號
}

static void el0_svc_common(struct pt_regs *regs, int scno, int sc_nr, const syscall_fn_t syscall_table[]) //syscall.c
{
    unsigned long flags = current_thread_info()->flags;

    regs->orig_x0 = regs->regs[0];
    regs->syscallno = scno;

    cortex_a76_erratum_1463225_svc_handler();
    local_daif_restore(DAIF_PROCCTX);

    if (flags & _TIF_MTE_ASYNC_FAULT) {
        regs->regs[0] = -ERESTARTNOINTR;
        return;
    }

    if (has_syscall_work(flags)) {
        if (scno == NO_SYSCALL)
            regs->regs[0] = -ENOSYS;
        scno = syscall_trace_enter(regs);
        if (scno == NO_SYSCALL)
            goto trace_exit;
    }

    /*跳轉到對應系統調用編號的處理函數中 */
    invoke_syscall(regs, scno, sc_nr, syscall_table);

    if (!has_syscall_work(flags) && !IS_ENABLED(CONFIG_DEBUG_RSEQ)) {
        local_daif_mask();
        flags = current_thread_info()->flags;
        if (!has_syscall_work(flags) && !(flags & _TIF_SINGLESTEP))
            return;
        local_daif_restore(DAIF_PROCCTX);
    }

trace_exit:
    syscall_trace_exit(regs);
}


static void invoke_syscall(struct pt_regs *regs, unsigned int scno, unsigned int sc_nr, const syscall_fn_t syscall_table[]) //syscall.c
{
    long ret;

    if (scno < sc_nr) {
        syscall_fn_t syscall_fn;
        syscall_fn = syscall_table[array_index_nospec(scno, sc_nr)]; //獲取 sys_call_table[] 中的回調函數
        ret = __invoke_syscall(regs, syscall_fn);
    } else {
        ret = do_ni_syscall(regs, scno);
    }

    if (is_compat_task())
        ret = lower_32_bits(ret);

    regs->regs[0] = ret; //將系統調用函數返回值保存在X0寄存器
}

static long __invoke_syscall(struct pt_regs *regs, syscall_fn_t syscall_fn)
{
    /* 
     * 調用kernel實現的系統調用函數,對於 getpriority()
     * 系統調用來說就是 __arm64_sys_getpriority()
     */
    return syscall_fn(regs);
}

在結束系統調用的時候,內核需要把 CPU 讓給用戶,不過在返回前,內核會檢查是否需要進行一次 schedule,如果需要,那么這次返回到用戶空間的時候,CPU 就會執行另一個進程,而不是觸發之前觸發系統調用的那個。返回的處理代碼在匯編函數 ret_to_user 中:

/*
 * "slow" syscall return path.
 */
SYM_CODE_START_LOCAL(ret_to_user)
    disable_daif
    gic_prio_kentry_setup tmp=x3
#ifdef CONFIG_TRACE_IRQFLAGS
    bl    trace_hardirqs_off
#endif
    ldr    x19, [tsk, #TSK_TI_FLAGS]
    and    x2, x19, #_TIF_WORK_MASK
    cbnz    x2, work_pending
finish_ret_to_user:
    user_enter_irqoff
    enable_step_tsk x19, x2
#ifdef CONFIG_GCC_PLUGIN_STACKLEAK
    bl    stackleak_erase
#endif
    kernel_exit 0

/*
 * Ok, we need to do extra processing, enter the slow path.
 */
work_pending:
    mov    x0, sp                // 'regs'
    mov    x1, x19
    bl    do_notify_resume
    ldr    x19, [tsk, #TSK_TI_FLAGS]    // re-check for single-step
    b    finish_ret_to_user
SYM_CODE_END(ret_to_user)

首先它會關閉 DAIF(D:進程D狀態的 mask,A:exception mask,I:IRQ,F:FIRQ)然后根據 task 的狀態,確定是否需要進入 work_pending,也就是代碼注釋所說的“slow” system call。在 work_pending 中,do_notify_resume 中判斷任務切換的標志如果有置位,就進行一次 schedule。最后就是 kernel_exit,這一處的匯編代碼比較長,不過這些剩下的事情就是為用戶進程做好恢復的准備,然后打開中斷之類的。所有的異常處理在返回前都是調用這個宏,此處先略過不提。

asmlinkage void do_notify_resume(struct pt_regs *regs, unsigned long thread_flags) //arch/arm64/kernel/signal.c
{
    do {
        /* Check valid user FS if needed */
        addr_limit_user_check();

        //若參數flag表示需要重新調度,就重新調度
        if (thread_flags & _TIF_NEED_RESCHED) {
            /* Unmask Debug and SError for the next task */
            local_daif_restore(DAIF_PROCCTX_NOIRQ);

            schedule();
        } else {
            local_daif_restore(DAIF_PROCCTX);

            if (thread_flags & _TIF_UPROBE)
                uprobe_notify_resume(regs);

            if (thread_flags & _TIF_MTE_ASYNC_FAULT) {
                clear_thread_flag(TIF_MTE_ASYNC_FAULT);
                send_sig_fault(SIGSEGV, SEGV_MTEAERR, (void __user *)NULL, current);
            }

            if (thread_flags & _TIF_SIGPENDING)
                do_signal(regs);

            if (thread_flags & _TIF_NOTIFY_RESUME) {
                tracehook_notify_resume(regs);
                rseq_handle_notify_resume(NULL, regs);
            }

            if (thread_flags & _TIF_FOREIGN_FPSTATE)
                fpsimd_restore_current_state();
        }

        local_daif_mask();
        thread_flags = READ_ONCE(current_thread_info()->flags);
    } while (thread_flags & _TIF_WORK_MASK);
}

 

三、系統調用的參數傳遞

1. 就像C函數一樣,系統調用也需要有輸入參數。在 X86 架構上,通常函數的參數是通過棧傳遞。不過由於系統調用,涉及到用戶和內核2個棧,為了使參數的處理相對簡單一些,系統調用的參數規定通過 CPU 寄存器傳遞。由於寄存器的數量有限,所以規定系統調用最多傳遞 6 個參數。如果有多的參數需要傳遞,那么就通過指針進行傳遞。

參數傳遞的實現在內核部分的代碼,可以看 SYSCALL_DEFINEx 宏的定義(基於ARM64架構):

//include/linux/syscalls.h
#define __MAP0(m,...)
#define __MAP1(m,t,a,...) m(t,a)
#define __MAP2(m,t,a,...) m(t,a), __MAP1(m,__VA_ARGS__)
#define __MAP3(m,t,a,...) m(t,a), __MAP2(m,__VA_ARGS__)
#define __MAP4(m,t,a,...) m(t,a), __MAP3(m,__VA_ARGS__)
#define __MAP5(m,t,a,...) m(t,a), __MAP4(m,__VA_ARGS__)
#define __MAP6(m,t,a,...) m(t,a), __MAP5(m,__VA_ARGS__)
#define __MAP(n,...) __MAP##n(__VA_ARGS__)

#define __SC_ARGS(t, a)    a

/*
 * 若x=2,按上面的宏展開后就是 " regs->regs[0], regs->regs[1] ", 可以使用 gcc -E 進行測試
 */
//arch/arm64/include/asm/syscall_wrapper.h
#define SC_ARM64_REGS_TO_ARGS(x, ...)                \
    __MAP(x,__SC_ARGS,,regs->regs[0],,regs->regs[1],,regs->regs[2],,regs->regs[3],,regs->regs[4],,regs->regs[5])


/*
 * __arm64_sys##name 就是填入到 sys_call_table 中的函數名,svc 同步異常就是跳轉到這個入口
 * 這個入口函數將CPU寄存器中值作為函數入參傳遞到下一級子函數中,如此即實現了系統調用的輸入
 * 參數傳遞.
 */
//arch/arm64/include/asm/syscall_wrapper.h
#define __SYSCALL_DEFINEx(x, name, ...)                        \
    asmlinkage long __arm64_sys##name(const struct pt_regs *regs);        \
    ALLOW_ERROR_INJECTION(__arm64_sys##name, ERRNO);            \
    static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__));        \
    static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__));    \
    asmlinkage long __arm64_sys##name(const struct pt_regs *regs)        \
    {                                    \
        return __se_sys##name(SC_ARM64_REGS_TO_ARGS(x,__VA_ARGS__));    \
    }                                    \
    static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__))        \
    {                                    \
        long ret = __do_sys##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 __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))

注意:系統調用最大只能傳入6個參數,使用X0-X5傳遞參數,在內核中可以全局檢索到 SYSCALL_DEFINE6,但是檢索不到 SYSCALL_DEFINE7。若是要多於6個參數要傳遞,就需要傳結構體指針,可以參考 sched_setattr() 的實現。

 

2. 以 int getpriority(int which, id_t who) 為例展示系統調用展開:

//kernel/sys.c
SYSCALL_DEFINE2(getpriority, int, which, int, who)
{
    //函數實現
}

上面宏展開:

/*
 * 就是填入到 sys_call_table 中的函數名,svc 同步異常就是跳轉到這個入口
 * 這個入口函數將CPU寄存器中值作為函數入參傳遞到下一級子函數中
 */
asmlinkage long __arm64_sys_getpriority(const struct pt_regs *regs); //參數是pt_regs是數組指針

static struct error_injection_entry __used __section("_error_injection_whitelist") _eil_addr___arm64_sys_getpriority = {
    .addr = (unsigned long)__arm64_sys_getpriority,
    .etype = EI_ETYPE_ERRNO, 
};;

static long __se_sys_getpriority(__SC_LONG(int,which), __SC_LONG(int,who));

static inline long __do_sys_getpriority(int which, int who);

asmlinkage long __arm64_sys_getpriority(const struct pt_regs *regs) {
    return __se_sys_getpriority(regs->regs[0], regs->regs[1]); //這里將寄存器根據SYSCAL_DEFINEx中的x拆開傳遞,傳參就是X0,X1寄存器
}

static long __se_sys_getpriority(__SC_LONG(int,which), __SC_LONG(int,who)) {
    long ret = __do_sys_getpriority((__force int) which, (__force int) who);
    __SC_TEST(int,which), __SC_TEST(int,who);
    return ret;
}

static inline long __do_sys_getpriority(int which, int who)
{
    //函數實現
}

可見 SYSCALL_DEFINEX(...) {...} 定義的系統調用響應函數就是宏展開部分加函數實現部分的拼接。

 

3. 沒有參數的系統調用宏有點特殊,以 pid_t fork(void) 系統調用為例展開:

//kernel/fork.c
SYSCALL_DEFINE0(fork)
{
    函數實現
}

使用gcc -E 宏展開后:

asmlinkage long __arm64_sys_fork(const struct pt_regs *__unused);

static struct error_injection_entry __used __section("_error_injection_whitelist") _eil_addr___arm64_sys_fork = {
    .addr = (unsigned long)__arm64_sys_fork,
    .etype = EI_ETYPE_ERRNO,
};
    
asmlinkage long __arm64_sys_fork(const struct pt_regs *__unused)
{
    函數實現
}

 

通過以上的宏分析,我們可以看到在 ARM64 架構中,系統調用的參數就是通過 x0~x5 這6個寄存器進行傳遞的,再加上之前用於傳遞系統調用編號的 x8 寄存器。

在 X86 架構中,系統調用編號是通過 eax 傳遞,參數則是由 ebx, ecx, edx, esi, edi, ebp 這6個寄存器實現的。系統調用函數定義的這個宏可以根據不同的架構進行重新定義,如此即可以滿足不同架構的系統調用規范要求。

系統調用的參數是用戶態傳遞到內核的,所以對它們都需要進行安全檢查。其中非常通用的是對地址的檢查,內核通過 access_ok 這個函數進行一個簡單的校驗,這個函數的定義根據CPU架構不同而不同,下面是 ARM64 的定義:

//arch/arm64/include/asm/uaccess.h
#define access_ok(addr, size)    __range_ok(addr, size)

//__range_ok 是使用匯編實現的函數,就是判斷 (u65)addr + (u65)size <= (u65)current->addr_limit + 1

在 ARM64 上,這個函數通過匯編指令實現的,不過看注釋就它所做的檢查非常地基礎,也就是看當前需要訪問的空間是否有超過 current->addr_limit 。這個值通常是用戶空間的最大地址,可以通過 get_fs 和 set_fs 獲取和配置。

系統調用傳遞的參數有限,很多時候,在內核中處理系統調用的時候,需要訪問進程的用戶空間地址。內核中有許多用於在內核空間訪問用戶空間數據的宏,在下面的表格中列出它們。其中,帶有雙下划線的表示訪問前不做地址校驗。

Function Function Action
get_user __get_user 從用戶空間讀取一個整數
put_user __put_user 寫入一個整數到用戶空間
copy_from_user __copy_from_user 從用戶空間拷貝一段數據
copy_to_user __copy_to_user 拷貝一段數據到用戶空間
strncpy_from_user __strncpy_from_user 從用戶空間拷貝一個字符串
strlen_user strlen_user 獲取一個用戶空間字符串的長度
clear_user __clear_user 將用戶空間的一段空間全部寫0

如前面所言,access_ok 只是一個非常粗糙的檢查,它能確保用戶傳遞的參數沒有染指到內核空間。除此以外,傳入的參數還是可能會存在錯誤,如果作為地址的入參並沒有在當前這個進程的地址空間中,那么就會觸發一個 page fault。下面是內核中產生 page fault 的一些原因:

(1) 內核訪問的地址屬於進程的地址空間,不過內存頁還不存在或者我們對一個只讀屬性的 page 進行寫操作。此時,在 page fault 中會初始化一個新的頁框
(2) 內核訪問的地址屬於進程的地址空間,不過對應的 PTE 還沒有建立,此時會新建對應地址的 PTE
(3) 內核函數的 bug,導致出現訪問異常,此時會觸發 kernel oops
(4) 系統調用傳遞下來的參數,地址不屬於進程的地址空間

前2種情況都是正常的流程,也很好區分,是否屬於地址空間,在進程的 VMA 中的進行查找即可知道,PTE 是否建立,查看對應地址的 PTE 是否為空即可。麻煩的是后面2中情況的區分。如果只是系統調用參數導致的錯誤,那么內核應該只是將這種錯誤反饋到用戶空間即可,不必大驚小怪地進行一次 oops。

為了把這2種情況區分開來,Linux 搞了一張 exception table。內核訪問進程的用戶空間都是通過前面列出的幾個宏進行的,如果是第四種 page fault 的情況,那么引發 page fault的指令地址肯定就是在那幾個訪問用戶空間地址的接口處。這樣我們只需要把這些接口中會觸發 page fault 的指令登記在這個 exception table 中,出現 page fault 的時候,就去這張表里找,如果能找到,那么就說明是第四種情況。

在 do_page_fault 中,通過函數 search_exception_tables 查找 exception table。而這個 exception table 在編譯階段由編譯器將它們存放在了 __ex_table 段,在加載內核的時候,這個段會被加載到內存中。指示這個段的起始地址和結束地址的符號是 __start___ex_table & __stop___ex_table。

在 exception table 中,每個元素由2個整數構成:

struct exception_table_entry
{
    int insn, fixup;
};

第一個就是產生異常的指令地址值,而第二個則是 do_page_fault 在匹配到這個地址時,可以跳轉繼續執行的地址,所有又叫做 fixup 。在 fixup 中,通常會設置好錯誤碼,以便返回給用戶空間,並且 fixup 這部分的指令也存放在一個名為 .fixup 的段。下面是 ARM64 架構中 get_user 接口中的對於 exception 的處理:

其中宏 _ASM_EXTABLE 的作用是往 __ex_table 段中添加元素,其中 from 就是異常發生時的指令地址,而 to 就是異常發生后跳轉到 fixup 的地址。在 get_user 中,from 對應着標號為 1 的指令所在地址,to 則對應着標號為 3 的指令所在地址。也即是 get_user 中,只有標號為 1 處的指令可能觸發 page fault,如果是它觸發了異常,那么就跳轉到 3 所在位置進行修補。在這里我們看到,它將 -EFAULT 傳遞給 w0 寄存器,並且將 0 賦值給輸入參數 x。這樣也就是當 get_user 在訪問一個異常地址時,do_page_fault 通過 exception table 將會讓它返回一個錯誤碼 -EFAULT,並且讀取到的值為0。

在內核中進行系統調用的宏 _syscall0 在最新的內核代碼中已經找不到了,這樣比較好,畢竟系統調用這個東西就是用戶空間與內核空間的一個交互,在內核空間觸發進入系統調用流程看起來不太優雅,也沒什么必要。不過在最新的代碼里找到了這樣一個頭文件 tools/include/nolibc/nolibc.h ,這個文件比較新,是用於給那些精簡到連C運行庫都不想用的系統。通過一個頭文件,這樣程序中真正用到的系統調用才會被編譯生成,其他不用的就可以不占用系統的空間了。(我想這個系統都這么扣了,那應該是不是考慮不用 Linux 系統了呢)

 

四、其它

1. 直接使用系統調用號

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>

void main()
{
    int fd;
    char r_buf[64] = {0};

    fd = open("./tmp.txt", O_RDWR|O_CREAT, S_IRUSR|S_IWUSR);
    if (fd < 0) {
        printf("open error, errno=%d: %s\n", errno, strerror(errno));
        return;
    }
    write(fd, "Hello ", strlen("Hello "));
    syscall(SYS_write, fd, "World!", strlen("World!")); //直接使用系統調用號
    lseek(fd, 0, SEEK_SET);
    read(fd, r_buf, sizeof(r_buf));
    close(fd);
}
/*
$ ./pp
r_buf: Hello World!
*/

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM