Article
操作系统-CH1.6-过程调用和系统调用
操作系统-CH1.6-过程调用和系统调用,待补充摘要。
- https://tingwu.aliyun.com/doc/transcripts/ro84nr7ydmvwqkb3?sl=1# 《1-6 过程调用vs系统调用 720P》
过程调用(Procedure Call)vs 系统调用(System Call)深度解析笔记
知识导图与核心引入
在计算机系统中,过程调用(如函数调用)和系统调用是程序执行流跳转的两种最基本机制。
- 过程调用:解决的是用户空间内代码复用与模块化的问题,不涉及特权级的改变。
- 系统调用:解决的是用户程序向操作系统内核请求受限资源/服务(如读写文件、分配内存)的问题,必须跨越安全边界,实现特权级跃升。
两者的底层设计、栈机制及 CPU 运行模式有着本质的区别。理解这两者的差异,是打通“计算机组成原理(计组)”与“操作系统(OS)”学科壁垒的关键。
第一部分:过程调用(Procedure Call)的底层机制
过程调用(在 C 语言中表现为函数调用或子程序调用)允许程序将代码组织成相对独立的块。
1. 经典代码案例
int add(int x, int y) {
return x + y;
}
int caller() {
int temp1 = 125;
int temp2 = 80;
int sum = add(temp1, temp2);
return sum;
}
2. 内存模型:用户栈(User Stack)的基本特性
过程调用极度依赖于主存中的用户栈结构:
- 增长方向:栈在内存中是从高地址向低地址增长的。
- 数据结构特性:遵循“后进先出(LIFO)”原则。
- 栈帧(Stack Frame):每个函数在被调用时,都会在栈上分配一块专属的空间,称为该函数的栈帧。栈帧用于保存当前函数的局部变量、入口参数、返回地址以及调用者的上下文现场。
3. 【重点修正与补充】栈帧控制指针与 EBP 相对寻址
📢 课件修正(对应 Slide 8 纠错): 课件 Slide 8 提到
main函数的返回地址在main栈帧的栈顶。 权威修正:在标准 架构中,当main函数被调用时,其返回地址(返回给操作系统启动代码如__libc_start_main)会被压入调用main之前的栈顶。接着建立main的栈帧。而当main内部调用caller()时,caller()的返回地址(即main准备执行的下一条指令地址)是由call指令压栈的,它物理上紧邻caller的栈帧基址,处于main栈帧与caller栈帧的交界处。
核心指针:
- ESP(Stack Pointer,栈指针):始终指向当前活动栈帧的栈顶(低地址端)。随着
push和pop动态移动。 - EBP(Base Pointer,基址/帧指针):指向当前活动栈帧的栈底(高地址端)。在整个函数执行期间保持不变。
为什么需要 EBP 指针?
因为 ESP 会随着临时数据的压栈/出栈不断移动,如果仅靠 ESP 寻址局部变量,编译器计算偏移量会极其困难。通过 EBP,编译器可以使用固定的相对偏移来稳定寻址:
- 局部变量寻址:使用负偏移,如 , 。
- 入口参数寻址:使用正偏移,如 , (在标准 汇编中, 指向保存的旧 值, 处为返回地址, 及其以上为参数)。
4. 过程调用的 6 步生命周期(以 caller 调用 add 为例)
我们将整个调用流程拆解如下:
步骤 1:主函数 main 调用 caller 并初始化局部变量
-
caller没有入口参数,调用时直接将main的返回地址压栈。 -
控制权转移到
caller。 -
caller通过修改 的值,在栈上为其局部变量 , , 分配空间。 -
执行汇编指令,初始化局部变量:
高地址 ──> ┌──────────────────────────┐
│ main() 的栈帧 │
├──────────────────────────┤
│ main 函数返回地址 │ <── caller 栈帧起始 (EBP)
├──────────────────────────┤
│ temp1 = 125 │ [ebp - 4]
├──────────────────────────┤
│ temp2 = 80 │ [ebp - 8]
├──────────────────────────┤
│ sum (未初始化) │ [ebp - 12] <── 当前 ESP
低地址 ──> └──────────────────────────┘
步骤 2:参数传递与备份
- 调用者
caller将准备传递给add的参数通过寄存器中转,或者直接压入栈中的参数临时存储区。 - 此时,栈顶被抬高(向低地址延伸),参数 和 被压入栈。
步骤 3:执行 call 指令并保存返回地址
- 执行
call add指令。 - CPU 自动将当前的程序计数器(/,即
call的下一条指令地址)压入栈顶,作为add执行完毕后的返回地址。 - 将 指针修改为
add函数的入口地址,控制权转移给add。
高地址 ──> ┌──────────────────────────┐
│ ... │
├──────────────────────────┤
│ temp2 = 80 │ (传递给 add 的参数 y)
├──────────────────────────┤
│ temp1 = 125 │ (传递给 add 的参数 x)
├──────────────────────────┤
│ caller 函数返回地址 │ <── 压入返回地址,PC 跳转
低地址 ──> └──────────────────────────┘
步骤 4:执行被调用者 add 过程体
-
add开始执行:它不需要复杂的局部变量空间,直接通过寄存器(如 , )读取栈中的参数 和 。 -
执行加法运算并将计算结果 存入累加寄存器 中:
步骤 5:执行 ret 指令,清理栈帧并返回
add函数执行完毕,发出ret指令。- 返回地址出栈:从栈顶弹出保存的
caller 函数返回地址,将其写入程序计数器 ,CPU 控制权回到caller。 - 清理参数区,
add的栈空间被释放。
步骤 6:写入返回值,恢复调用者现场
caller从 中取出返回值 ,并将其写入局部变量 的内存位置 。caller准备结束时,通过修改栈指针( 回退)释放其局部变量空间,弹出main 函数返回地址并返回到main函数。整个过程调用圆满结束。
第二部分:系统调用(System Call)的特权级跃升
当应用程序需要执行敏感操作(如调用 read(fd, buf, count) 读取硬盘数据)时,用户程序不能直接跳转至内核代码执行,否则将带来巨大的安全隐患。此时必须通过系统调用接口。
+-------------------------------------------------------------+
| 用户空间 (User Space) |
| 用户代码: read(fd, buf, count) |
| │ |
| ▼ (调用标准库封装函数) |
| C库函数 (如 glibc): |
| 1. 准备参数 (放入寄存器 EAX=系统调用号, EBX, ECX...) |
| 2. 执行陷阱指令 (int 0x80 / syscall) |
+────────┼────────────────────────────────────────────────────+
│ ◄─── 触发软中断 (CPU 硬件模式切换: 用户态 -> 内核态)
+────────▼────────────────────────────────────────────────────+
| 内核空间 (Kernel Space) |
| 系统调用分发器 (Syscall Dispatcher) |
| │ |
| ▼ (根据 EAX 中的调用号,查询系统调用表) |
| 内核实现函数: sys_read() |
| │ |
| ▼ (执行完毕,结果放入 EAX,执行 iret/sysret 返回) |
+-------------------------------------------------------------+
1. 核心步骤详解
步骤 1:库函数封装与参数准备
用户代码调用的 read() 其实只是 C 库中的一个封装函数(Wrapper Function),它运行在用户态。 为了向内核发起请求,C 库函数会按照约定:
- 将 系统调用号 存入特定的寄存器(在 Linux 中,
read的系统调用号为 ,存入 )。 - 将系统调用的各个参数依次存入通用寄存器中:
- (文件描述符)
- (缓冲区首地址)
- (读取字节数)
步骤 2:执行陷阱/自陷指令(Trap Instruction)
库函数执行一条特殊的机器指令,如 int 0x80(传统 软中断机制)或 syscall / sysenter(现代 CPU 快速通道指令)。
- 非显式性:该指令不会直接出现在高级语言用户代码中,而是由库函数在底层执行。
步骤 3:硬件变态(Mode Switch)
CPU 捕获到自陷异常:
- 自动将 程序状态字寄存器(PSW) 中的模式位(Mode Bit)从 用户态(1) 切换为 内核态(0)。
- 保存断点现场:硬件自动将当前的程序计数器 和程序状态字 压入内核栈(Kernel Stack)。
- 根据中断向量表,跳转到系统调用总入口(异常处理程序)。
步骤 4:内核服务程序运行
- 内核读取 寄存器中的值(即系统调用号 ),通过查询系统调用表(System Call Table),定位到真实的内核服务函数
sys_read()。 - 运行内核函数,完成实际的硬件驱动操作和数据读取。
- 执行结果放入 中。
步骤 5:返回用户态
内核发出特殊的返回指令(如 iret 或 sysret),CPU 自动从内核栈弹出 和 ,恢复用户态特权级,控制权移交回 C 库函数,最终返回用户程序。
第三部分:过程调用 vs 系统调用深度对比
| 对比维度 | 过程调用 (Procedure Call) | 系统调用 (System Call) |
|---|---|---|
| 工作特权级 | 全程处于同一模式(通常是用户态,或内核模块内部的调用)。 | 特权级切换:必须从用户态(Mode=1)跃升到内核态(Mode=0)。 |
| 触发方式/指令 | 编译器编译生成的普通跳转指令:call、jmp。 | 特殊的自陷/软中断指令:int 0x80、syscall、sysenter。 |
| 返回指令 | ret(普通栈出栈,修改 )。 | iret 或 sysret(恢复 的同时强制恢复 模式位)。 |
| 硬件保存的现场 | 仅保存 (返回地址)。 | 必须同时保存 和 (程序状态字)。 |
| 为什么保存 PSW? | 状态字不随调用改变,或状态字改变本身就是计算逻辑的一部分,无需硬件备份。 | 因为运行模式发生改变(用户态 内核态),必须备份原状态标志、中断屏蔽位等特权信息,否则无法安全重入。 |
| 代码可见性 | 被调用函数的代码直接编译进用户程序的地址空间,完全可见。 | 被调用内核代码驻留在内核安全区,对用户程序完全透明/不可见。 |
| 运行栈 | 全程在**用户栈(User Stack)**中进行。 | 涉及用户栈(准备寄存器参数)与内核栈(Kernel Stack)(保存内核执行现场)的协同。 |
| 嵌套与递归 | 支持高度的嵌套与递归调用。 | 不允许用户态方向的直接嵌套调用(即不能在系统调用服务程序执行中,再次由用户发起系统调用)。 |
第四部分:精选例题与考研(408)真题演练
例题 1:栈帧结构与 EBP 寻址计算(计组/系统开发重点)
【题目】 某 32 位系统采用 架构,函数调用时参数通过栈传递(从右至左压栈)。现有如下 C 代码:
void func(int a, int b) {
int local1 = 10;
int local2 = 20;
}
已知进入 func 并执行完 mov ebp, esp 后,此时 的值为 0xBFFFF0A0。
- 请问局部变量
local1和参数a的内存地址分别是什么? - 在该函数执行期间,若执行
push eax,控制栈顶的寄存器 的值将如何变化?
【详细解析】 在 架构的标准函数入口(Prologue)中,栈帧的典型结构如下(每个成员占 4 字节):
| 内存地址 | 存储内容 | 相对 EBP 偏移 |
|---|---|---|
0xBFFFF0AC | 参数 b | |
0xBFFFF0A8 | 参数 a | |
0xBFFFF0A4 | 返回地址 (Return Address) | |
0xBFFFF0A0 | 旧的 EBP 值 (Saved EBP) | (当前 EBP 指向这里) |
0xBFFFF09C | 局部变量 local1 | |
0xBFFFF098 | 局部变量 local2 |
-
计算地址:
-
local1位于 处: -
参数
a位于 处:
-
-
ESP 寄存器变化:
- 栈是向低地址增长的,32 位系统下一个
push指令会将数据写入栈顶,并将 减 4。 - 因此, 的值会减少 4。
- 栈是向低地址增长的,32 位系统下一个
例题 2:系统调用执行流程多维分析(OS/计组综合题)
【题目】 下列关于系统调用的叙述中,错误的是( )。 A. 用户程序设计时,可以通过执行“int 0x80”指令直接从内核态执行系统调用。 B. 在执行系统调用的过程中,CPU 至少发生两次特权级切换。 C. 系统调用执行时,保存现场的栈通常是该进程对应的内核栈,而不是用户栈。 D. 操作系统通过系统调用号来识别具体请求的是哪一个系统调用。
【答案】 A
【解析】
- A 选项错误:虽然底层的确是通过
int 0x80(或syscall)实现切换的,但用户程序不能直接在内核态下执行它。执行该指令前 CPU 仍处于用户态,正由于该指令的运行才使得 CPU 陷入并转为内核态。此外,高级语言用户程序是通过标准库函数间接调用的,不推荐也不需要直接写汇编。 - B 选项正确:在执行系统调用时,CPU 至少需要经历:用户态 内核态(请求服务)和 内核态 用户态(服务完成返回),共 2 次特权级切换。
- C 选项正确:特权级跃升时,为了防止用户空间代码恶意篡改控制信息,CPU 硬件自动将 和 压入高特权级的内核栈中。
- D 选项正确:内核通过累加寄存器 中保存的系统调用号作为索引去检索系统调用表。
例题 3:PSW 的保存问题
【题目】 为何在系统调用或中断处理时必须由硬件保护程序状态字寄存器(PSW),而在普通的过程调用中却不需要?请从系统安全与重入的角度进行分析。
【详细解析】
- 控制边界与特权级改变:
- 过程调用发生在同一个特权级(通常是用户态空间内)。由于不改变 CPU 的特权模式,不涉及中断允许位的修改,所以 中的核心特权控制位不需要改变。
- 系统调用伴随着特权级的跃升(从用户态进入内核态)。内核态下需要屏蔽部分中断、修改地址映射规则,这直接导致 中的运行模式位、中断屏蔽位必须被强制改变。
- 异步性与非自愿性(针对中断与异常):
- 普通过程调用是程序员自愿且可预测的,编译器在编译时已预知执行流的去向,并主动做好了寄存器(除 外)的分配与保存。
- 系统调用或外部中断往往是异步或强迫性发生的。为了保证被中断的用户程序在返回时能够“无缝”继续运行,必须将反映 CPU 核心算术状态(如进位标志、溢出标志)和系统控制标志的 完整保存,待内核服务结束、利用
iret指令恢复现场时,硬件再将原 复位,确保用户程序的运行环境不受任何损害。
总结:一分钟对比口诀
函数调用走用户栈,call指令去,ret指令还,特权级不变,只存PC指针; 系统调用跨内核关,自陷触发,iret指令返,模式位变零,PSW、PC全存盘。