关注小程序 找一找教程网-随时随地学编程

C/C++教程

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 获取和配置。

系统调用传递的参数有限,很多时候,在内核中处理系统调用的时候,需要访问进程的用户空间地址。内核中有许多用于在内核空间访问用户空间数据的宏,在下面的表格中列出它们。其中,带有双下划线的表示访问前不做地址校验。

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