os操作系统——第6讲:函数调用也是任务
目录
第6讲:函数调用也是任务——软件中断与系统调用(SysTick → SVC)
上一讲我们实现了信号量、互斥量和队列,任务们终于可以愉快地协作。但你有没有想过:
这些内核服务(sem_take、queue_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-M:
SVC(Supervisor Call),以前叫SWI。 - ARM Cortex-A (AArch64):
SVC同样。 - RISC-V:
ECALL(Environment Call)。 - x86:
INT 0x80或sysenter。
执行这条指令时,CPU 会:
- 保存当前 PC 和 PSR(程序状态寄存器)到栈(或专用寄存器)。
- 跳转到预先设置的中断向量表中的“软中断处理函数”。
- 自动切换特权级(从用户态到内核态)。
- 在内核态执行完服务后,用专用返回指令(如
ERET、IRET)恢复用户态。
在我们的 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 指令,而是:
- 执行 SVC 指令(CPU 自动压栈、切特权级)。
- 执行 SVC_Handler 软件处理(查表、调用内部函数)。
- 可能触发调度。
- 执行
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(核间中断)来协调不同核心的行为。
✍️ 思考与练习
- 比较开销:在开发板上编写一个测试程序,分别用直接函数调用和 SVC 方式调用一个空操作 10000 次,测量时间差。
- 参数检查:在
SVC_Handler中增加对sem_take的合法性检查:判断用户传入的sem指针是否在有效内核符号区域内(可以定义一个全局_kernel_data_start,_kernel_data_end)。如果非法,杀死任务(例如进入死循环或触发 HardFault)。 - 返回值:系统调用通常需要返回值(如
sem_take是否超时)。修改封装函数,让 SVC 能够将返回值带回用户态(提示:通过栈帧中的 R0)。 - 思考:如果我们在 SVC_Handler 中修改了
current_task->sp指向另一个任务的栈,那么从 SVC 返回时会怎样?这其实是调度器的实现核心,试着画出栈变化图。
欢迎在评论区分享你的系统调用实现截图或遇到的 Bug!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)