找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1538

积分

0

好友

193

主题
发表于 2025-12-30 01:46:15 | 查看: 26| 回复: 0

在前面的章节中,我们已经分析了 glibc 是如何封装系统调用,并最终在 ARM64 平台上通过 svc #0 指令触发内核陷入的。从这一刻开始,系统调用流程正式离开用户态代码,进入 CPU 和内核共同完成的一段执行路径。

svc #0 到系统调用返回用户态,这个过程主要有以下几个阶段:

  1. CPU对异常的路由
  2. 内核汇编入口对异常的接管
  3. 内核 C 代码对系统调用的分发与执行

下面我们就按照这个顺序,对整个流程进行一次完整梳理。
图1:从用户态到内核的系统调用流程概览
系统调用流程:用户态、汇编入口与C语言处理阶段

1. 异常向量表:entry.S 如何接住异常

我们上节提到过触发系统调用的 svc #0 是一个同步异常,发生异常后程序会从用户态切换到内核态。一个明确的结论是:进入到Linux内核的第一行代码是在 entry.S

那么 entry.S 是如何接住这个异常的呢?这个过程中发生了什么?这其中涉及到CPU架构对于异常的路由机制。

ARMv8-A 架构中定义了 VBAR_EL1 -- VBAR_EL3 共三个异常向量基址寄存器,用于存放EL1 - EL3的异常向量基地址。那么什么是异常向量以及异常向量表?

异常向量(Exception Vector)是处理器架构中用于识别和处理不同类型异常的一个机制。每个异常类型都有一个唯一的异常向量,处理器通过该向量确定如何处理特定的异常。

异常发生时,处理器必须执行与异常相对应的处理程序代码。内存中存储处理程序的位置称为异常向量。在ARM架构中,异常向量存储在一个表中,称为异常向量表

简单来说,异常向量是一个内存地址,这个地址中存放了异常处理程序,ARM架构定义了这个异常向量表。我将异常向量以及异常向量表的定义放在下面,内容来自于官网ARM v8架构手册。

图2:AArch64异常向量表定义
ARMv8架构手册定义的异常向量表分类

图3:异常类型对应的偏移量表
不同异常类型在向量表中的具体偏移量

可以看到armv8中根据发生时异常的位置与异常类型定义了4组,形成了一共16个异常的入口,并为每个入口都指定了offset。对于这个向量表官方的定义与翻译网上有很多资料,我就不再复制了,较为抽象也不便于理解,我直接代入我们具体的案例来分析。

第一组表示如果发生异常并不会导致异常等级切换,并且使用的栈指针是 SP_EL0 在 Linux ARM64 中。内核始终在 EL1 使用 SP_EL1,因此该入口在实际系统中几乎不会被使用。其实几乎所有的操作系统都不会支持在用户态处理异常,ARM定义这组异常向量只是为了保证系统对称性,所以这一组可以忽略。

第四组表示的是32位的异常处理,我们主要分析的是Aarch64架构,所以常用的就是第二和第三组。第二第三组都表示会在 EL1 中也就是内核态来处理异常,区别在于:

前者表示当前等级发生也就是内核态发生的异常,对应的例如有空指针,缺页异常等

后者表示低一级也就是用户态发生的异常,我们这章讲的系统调用就属于这一类

这两者本质区别在于发生异常后是否需要切换异常级 / 栈 / 地址空间语义,对应的后续流程会有所区别。

Linux内核中对于异常向量表也是和ARMv8手册中的定义是一一对应的,提供了16个异常入口。

Linux 在 entry.S 中定义了异常向量表 vectors,并在内核启动早期由初始化代码将 vectors 的地址写入 VBAR_EL1。此后当异常发生时,CPU 根据架构规则,以 VBAR_EL1 为基址并加上固定的 offset,跳转到对应的异常入口执行。关于 Kernel 和系统底层的更多知识,你可以在 网络/系统 板块找到深入的讨论。

图4:内核异常向量入口定义代码片段
内核entry.S文件中定义的异常向量入口

对于我们arm64架构则目录是 arch/arm64/kernel/entry.S来自 EL0 的 64 位同步异常 会命中这一项:

/* arch/arm64/kernel/entry.S */

SYM_CODE_START(vectors)
    ...
   kernel_ventry  0, t, 64, sync      // Synchronous 64-bit EL0
    kernel_ventry  0, t, 64, irq
    kernel_ventry  0, t, 64, fiq
    kernel_ventry  0, t, 64, error
    ...
SYM_CODE_END(vectors)

2. 汇编的处理:保存上下文的具象理解

异常向量入口做一些准备工作如对齐边界、预留占空间、检查栈溢出的工作后,调用:

b  el\el\ht\()_\regsize\()_\label

这是汇编宏的参数拼接语法。对于 kernel_ventry 0, t, 64, sync,参数拼接如下:

  • \el = 0
  • \ht = t
  • \regsize = 64
  • \label = sync

拼接结果:el + 0 + t + + 64 + + sync = el0t_64_sync所以展开后就是:

b        el0t_64_sync

el0t_64_sync 又会进一步展开,展开过程就不再讨论了,直接展示最终的结果:

el0t_64_sync:
    kernel_entry 0, 64      // 保存所有寄存器到 pt_regs
    mov x0, sp              // 将 pt_regs 指针传入 x0(作为第一个参数)
    bl el0t_64_sync_handler // 调用 C 函数
    b ret_to_user           // 返回用户态

kernel_entry 主要工作是保存各类寄存器,这个操作其实就是我们常说的保存上下文,我将部分代码放在下方,并加了注释,有兴趣的稍作了解即可。

        .macro        kernel_entry, el, regsize = 64
        ...

        // 1. 保存所有通用寄存器
        stp        x0, x1, [sp, #16 * 0]
        stp        x2, x3, [sp, #16 * 1]
        stp        x4, x5, [sp, #16 * 2]
        stp        x6, x7, [sp, #16 * 3]
        ......
        ......
        stp        x26, x27, [sp, #16 * 13]
        stp        x28, x29, [sp, #16 * 14]

        .if        \el == 0
        // 2. EL0 特殊处理
        clear_gp_regs
        mrs        x21, sp_el0              ← 保存用户栈指针
        ldr_this_cpu        tsk, __entry_task, x20
        msr        sp_el0, tsk
        ...
        .endif

        // 3. 保存异常相关寄存器
        mrs        x22, elr_el1             ← 读取异常返回地址
        mrs        x23, spsr_el1            ← 读取保存的处理器状态
        stp        lr, x21, [sp, #S_LR]     ← 保存 LR 和用户栈指针

        // 4. 创建栈帧记录
        .if \el == 0
        stp        xzr, xzr, [sp, #S_STACKFRAME]
        ...
        .endif

        // 5. 保存 PC 和 PSTATE
        stp        x22, x23, [sp, #S_PC]    ← 保存 ELR 和 SPSR 到 pt_regs

        // 6. 初始化系统调用号
        .if        \el == 0
        mov        w21, #NO_SYSCALL
        str        w21, [sp, #S_SYSCALLNO]
        .endif
        ...
        .endm

保存完上下文之后就会进入到 el0t_64_sync_handler,最后处理完成后通过 ret_to_user 再返回用户态。

3. C阶段:系统调用的分发与执行

el0t_64_sync_handler 之后就进入 C 代码了,系统调用流程正式进入内核通用逻辑阶段。通过读取esr寄存器可以获取到本次异常的类型,实现异常处理分发,对于我们的系统调用就进入到 el0_svc

图5:根据ESR寄存器进行异常分发的C代码
el0t_64_sync_handler函数中的switch分发逻辑

3.1 入口函数:do_el0_svc()

void do_el0_svc(struct pt_regs *regs)
{
    el0_svc_common(regs, regs->regs[8], __NR_syscalls, sys_call_table);
}

el0_svc_common
|--invoke_syscall(regs, syscall_fn);
  • regs->regs[8] 包含系统调用号,x8寄存器的值,对于我们举例write来说就是64,这在上节glibc中的已经做了介绍了
  • sys_call_table 是系统调用表,包含所有系统调用的函数指针

do_el0_svc 调用了 el0_svc_common 函数并将系统调用表以及调用号传递进去,el0_svc_common 是系统调用的核心处理逻辑函数,其中最关键的是 invoke_syscall,我们重点看一下:

static void invoke_syscall(struct pt_regs *regs, unsigned int scno,
                           unsigned int sc_nr,
                           const syscall_fn_t syscall_table[])
{
    ......
    // 假设 scno = 64 (__NR_write)
    if (scno < sc_nr) {
        syscall_fn_t syscall_fn;

        // 1. array_index_nospec(64, __NR_syscalls) 返回 64(安全处理)
        // 2. sys_call_table[64] 获取数组第 64 个元素
        // 3. 这个元素的值是 __arm64_sys_write 函数指针
        syscall_fn = syscall_table[array_index_nospec(scno, sc_nr)];

        // syscall_fn 现在指向 __arm64_sys_write 函数
        ret = __invoke_syscall(regs, syscall_fn);
    } else {
        ret = do_ni_syscall(regs, scno);
    }
    ......
}

这里通过系统调用号(64)从 sys_call_table 数组中索引到对应的系统调用处理函数。系统调用表通过包含头文件自动初始化,初始化展开后是这样的:

const syscall_fn_t sys_call_table[__NR_syscalls] = {
    [0] = __arm64_sys_ni_syscall,
    [1] = __arm64_sys_ni_syscall,
    ...
    [63] = __arm64_sys_read,
    [64] = __arm64_sys_write,  // ← write 系统调用
    [65] = __arm64_sys_readv,
    ...
};

对于write系统调用,会找到 __arm64_sys_write 会。

3.2 系统调用实现

但其实 __arm64_sys_write 也是一个封装函数,经过编译和展开之后最后会调用到 __do_sys_write

__do_sys_write 来源于:SYSCALL_DEFINE3(write, ...)

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
        size_t, count)
{
    return ksys_write(fd, buf, count);
}

SYSCALL_DEFINE3 宏会展开为:

static inline long __do_sys_write(unsigned int fd, const char __user *buf, size_t count)
{
    return ksys_write(fd, buf, count);  // 调用 ksys_write
}

ksys_write 最终内部会调用到 vfs_write,后面就到了文件系统的工作了,我们这章就不去涉及了。

总结

在这一节中,我们从 ARM64 架构视角,完整梳理了一次系统调用在内核中的真实执行路径。

从用户态执行 svc #0 开始,系统调用首先以同步异常的形式被 CPU 接管,通过 VBAR_EL1 跳转到 entry.S 中对应的异常向量入口。随后在汇编阶段完成上下文保存,将通用寄存器、用户栈指针以及异常相关寄存器统一整理为 pt_regs,为后续内核逻辑提供完整的执行现场。

在进入 C 代码后,内核根据 ESR_EL1 判断异常类型,将系统调用分发到 do_el0_svc,并通过系统调用号索引 sys_call_table,最终找到对应的系统调用实现函数。以 write 为例,系统调用会从 __arm64_sys_write 逐步展开,最终进入 ksys_write,并继续向文件系统层传递。

系统调用我想到这里就暂时告一段落了,这章虽然只有三节,但是我认为这些内容充分掌握了对于我们BSP工程师来说在系统调用这一块已经够用了。如果关于系统调用大家还有什么感兴趣的话题,欢迎在技术社区如 云栈社区 进行更深入的讨论。

我们这个系列后面大的模块还会包括文件系统、平台总线、中断、设备树、并发与竞争等BSP工程师必须掌握的内核知识。




上一篇:终端工具盘点:2026年五款现代SSH客户端替代PuTTY方案
下一篇:高质量C++单头文件库精选指南与13个项目实战推荐
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-10 18:36 , Processed in 0.365158 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表