第6讲:函数调用也是任务——软件中断与系统调用(SysTick → SVC)

上一讲我们实现了信号量、互斥量和队列,任务们终于可以愉快地协作。但你有没有想过:
这些内核服务(sem_takequeue_send)本质上只是普通函数,任务可以直接调用它们。
如果某个任务故意传入了非法参数(比如空指针),或者恶意死循环占用内核锁,整个系统岂不是会崩溃?

更严重的是:如果一个任务正在执行内核服务时被中断打断,而中断里又调用了同一个内核服务,会发生什么?

为了隔离“普通任务”和“核心内核”,我们需要一种受控的入口——让任务只能通过软中断指令请求内核服务,这就是系统调用
从此,任务在“用户态”和“内核态”之间切换,增强了安全性和健壮性。


1. 从“直接调用”到“陷入内核”

回顾我们之前的设计,任务代码和内核代码在同一个地址空间同一个特权级下运行。任务随时可以:

  • 调用 sem_take,修改内核数据结构。
  • 直接篡改 TCB 链表,破坏调度器。
  • 关中断后永不开启,导致系统死锁。

这种“内核与任务不分家”的模型叫单体内核(Monolithic Kernel) 的简化版,在嵌入式 RTOS 中很常见(如 FreeRTOS 没有MMU,所有任务共享内核地址空间)。
但它有一个隐含假设:所有任务都是“可信的”

一旦任务可能来自第三方(比如手机 App 或动态加载的模块),我们就需要硬件手段隔离:用户态(Unprivileged Mode)内核态(Privileged Mode)

用户态 vs 内核态

  • 用户态:任务运行在这里,不能直接访问硬件寄存器、不能执行某些特权指令(如关中断、切换 MMU 页表)。
  • 内核态:操作系统运行在这里,可以做任何事。

当用户态任务需要执行特权操作(如发送队列、申请内存、创建新任务)时,它必须通过一条软中断指令(Software Interrupt)主动“陷入”内核。内核检查请求的合法性,然后执行相应服务,最后返回到用户态。这个过程称为系统调用(System Call)


2. 硬件基础:SVC / ECALL / INT 指令

不同架构提供了不同的软中断指令:

  • ARM Cortex-MSVC(Supervisor Call),以前叫 SWI
  • ARM Cortex-A (AArch64)SVC 同样。
  • RISC-VECALL(Environment Call)。
  • x86INT 0x80sysenter

执行这条指令时,CPU 会:

  1. 保存当前 PC 和 PSR(程序状态寄存器)到栈(或专用寄存器)。
  2. 跳转到预先设置的中断向量表中的“软中断处理函数”。
  3. 自动切换特权级(从用户态到内核态)。
  4. 在内核态执行完服务后,用专用返回指令(如 ERETIRET)恢复用户态。

在我们的 ARM Cortex-M 教学环境中,默认所有代码都运行在 Handler 模式(等同于内核态)或 Thread 模式(特权级)。为了演示系统调用,我们可以让任务代码运行在 Thread 模式(非特权级),而内核服务运行在 Handler 模式。但 Cortex-M 的 NVIC 使得在非特权级下依然可以触发 SVC,所以非常适合做实验。


3. 实现一个最简单的系统调用

我们先不引入复杂的用户态隔离(那需要 MPU 或 MMU),而是专注于调用约定:让任务通过 SVC 来请求 os_sleep 服务。

3.1 定义系统调用号

// syscall.h
#define SYS_SLEEP   1
#define SYS_SEMTAKE 2
#define SYS_SEMGIVE 3
// ...

3.2 用户态封装函数

任务调用 os_sleep(100) 时,实际上调用的是下面的封装函数,它触发 SVC 指令并传递参数。

// 用户态代码
void os_sleep(int ticks) {
    // 将系统调用号存入 r0(第一个参数),参数存入 r1
    __asm volatile (
        "mov r0, %0\n"
        "mov r1, %1\n"
        "svc 0\n"          // 触发 SVC 中断,立即数 0 可自由定义
        :
        : "i"(SYS_SLEEP), "r"(ticks)
        : "r0", "r1"
    );
}

ARM 的 SVC 指令会把 SVC 0 中的立即数(0)编码到指令中,中断处理函数可以通过读取 PC 来获取该立即数,从而知道用户请求的系统调用号。
但是为了简单,我们可以直接用寄存器传递调用号。

3.3 SVC 处理函数

在中断向量表中,SVC 的中断入口是 SVC_Handler。我们需要实现它:

void SVC_Handler(void) {
    // 获取当前任务在进入 SVC 前压入的寄存器的指针
    // 在 Cortex-M 中,进入异常时硬件自动压入了 R0-R3, R12, LR, PC, xPSR 到当前任务的栈
    unsigned int *stack_frame = (unsigned int *)current_task->sp;
    // 系统调用号通过 R0 传递(或者通过 SVC 指令中的立即数)
    int svc_num = stack_frame[0];   // R0 的内容
    
    switch (svc_num) {
        case SYS_SLEEP: {
            int ticks = stack_frame[1];  // R1
            // 调用真正的内核服务 os_sleep_internal
            os_sleep_internal(ticks);
            break;
        }
        // 其他系统调用...
        default:
            // 非法调用,可以杀死任务或报错
            break;
    }
    
    // 注意:os_sleep_internal 可能会阻塞当前任务(改变 current_task),
    // 并且会主动调用 schedule(). 返回后,我们不需要做额外清除。
    // 但正常从 SVC 返回时,硬件会从栈中恢复上下文。
}

os_sleep_internal 就是之前实现的 os_sleep 的升级版,它假设已经在内核态(关中断或持有调度锁),并且不会返回用户态直到任务被唤醒。

关键:SVC 处理函数中也可以进行任务切换。如果当前任务在系统调用中阻塞(比如等待信号量),那么 schedule() 会切换到另一个任务,而 SVC 处理函数会直接返回给新任务,而不是原来的任务。这是完全合法的,因为阻塞的任务已经不在就绪队列中。


4. 系统调用的开销与好处

引入系统调用后,调用内核服务不再是简单的 bl 指令,而是:

  1. 执行 SVC 指令(CPU 自动压栈、切特权级)。
  2. 执行 SVC_Handler 软件处理(查表、调用内部函数)。
  3. 可能触发调度。
  4. 执行 ERET 返回用户态。

相比直接函数调用,开销多了:异常进入/退出时的寄存器压栈/弹栈(约 10~30 个周期)。但在现代 CPU 上,这个开销通常可以接受。

好处:

  • 安全性:内核可以检查参数(例如指针是否越界)。
  • 可维护性:内核服务的实现与用户界面解耦,可以修改 ABI。
  • 可扩展性:将来如果引入 MPU(内存保护单元),可以限制用户态任务只能访问自己的内存,无法破坏内核。

5. 进阶:把系统调用集成到已有的信号量/队列中

我们可以把所有内核 API 改造成系统调用:

// 原来的 sem_take 函数改为内核内部版本 _sem_take
void _sem_take(sem_t *sem) {
    // 原来的实现,现在只在内核态调用
}

// 用户态封装
void sem_take(sem_t *sem) {
    // 传入系统调用号 SYS_SEMTAKE 以及 sem 指针
    __asm volatile (
        "mov r0, %0\n"
        "mov r1, %1\n"
        "svc 0\n"
        :
        : "i"(SYS_SEMTAKE), "r"(sem)
        : "r0", "r1"
    );
}

SVC_Handler 中根据调用号调用相应的 _ 函数。

注意sem_t 结构体本身需要放在内核数据区,用户态只有其句柄(比如一个索引或指针)。当用户传入指针时,内核需要检查该指针是否属于用户可访问的区域(如果启用了 MPU/MMU)。


6. 中断中的系统调用?—— 小心嵌套

通常,我们不允许在中断服务函数中执行系统调用(因为 ISR 运行在 Handler 模式,已经是特权态,不需要再 SVC)。
如果 ISR 想要唤醒一个任务,可以直接调用内核内部函数(比如 _sem_give),但需要小心临界区。

因此,通常的做法是:

  • 用户任务 → 通过 SVC 进入内核。
  • 中断服务函数 → 直接调用内核内部函数(但需确保不会与 SVC 产生死锁,例如使用不同的锁策略)。

7. 本讲小结 & 下集预告

今天我们做的升级看似“只增加了一层间接层”,但它标志着我们的操作系统从一个“函数库”向“真正的内核”迈出了一大步。

  • 系统调用是用户程序请求内核服务的唯一合法入口。
  • 软中断(SVC) 是实现系统调用的硬件机制,能够切换特权级。
  • 通过系统调用,内核可以检查参数、保护关键数据,为后续引入内存保护(MPU/MMU)铺平道路。

至此,我们完成了单核操作系统核心功能的全部拼图:调度、TCB、阻塞、同步、通信、系统调用。
接下来,我们将进入一个全新的维度:多核
单核时代的互斥机制(关中断)在多核下彻底失效,我们需要新的武器。

下一讲:当单身汉遇到双胞胎——多核启动与核间中断(IPI)
我们将启动第二个 CPU 核心,学习如何管理每个核心独立的任务队列,以及如何使用 IPI(核间中断)来协调不同核心的行为。


✍️ 思考与练习

  1. 比较开销:在开发板上编写一个测试程序,分别用直接函数调用和 SVC 方式调用一个空操作 10000 次,测量时间差。
  2. 参数检查:在 SVC_Handler 中增加对 sem_take 的合法性检查:判断用户传入的 sem 指针是否在有效内核符号区域内(可以定义一个全局 _kernel_data_start, _kernel_data_end)。如果非法,杀死任务(例如进入死循环或触发 HardFault)。
  3. 返回值:系统调用通常需要返回值(如 sem_take 是否超时)。修改封装函数,让 SVC 能够将返回值带回用户态(提示:通过栈帧中的 R0)。
  4. 思考:如果我们在 SVC_Handler 中修改了 current_task->sp 指向另一个任务的栈,那么从 SVC 返回时会怎样?这其实是调度器的实现核心,试着画出栈变化图。

欢迎在评论区分享你的系统调用实现截图或遇到的 Bug!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐