Skip to main content
 首页 » 操作系统

Linux System-call 系统调用

2022年07月19日23jackei

一、系统调用过程

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! 
*/

本文参考链接:https://www.cnblogs.com/hellokitty2/p/15659162.html