Article

操作系统-CH1.6-过程调用和系统调用

操作系统-CH1.6-过程调用和系统调用,待补充摘要。

June 11, 2026 修考 17 min read

过程调用(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 栈帧的栈顶。 权威修正:在标准 x86x86 架构中,当 main 函数被调用时,其返回地址(返回给操作系统启动代码如 __libc_start_main)会被压入调用 main 之前的栈顶。接着建立 main 的栈帧。而当 main 内部调用 caller() 时,caller() 的返回地址(即 main 准备执行的下一条指令地址)是由 call 指令压栈的,它物理上紧邻 caller 的栈帧基址,处于 main 栈帧与 caller 栈帧的交界处。

核心指针:

  • ESP(Stack Pointer,栈指针):始终指向当前活动栈帧的栈顶(低地址端)。随着 pushpop 动态移动。
  • EBP(Base Pointer,基址/帧指针):指向当前活动栈帧的栈底(高地址端)。在整个函数执行期间保持不变。

为什么需要 EBP 指针?

因为 ESP 会随着临时数据的压栈/出栈不断移动,如果仅靠 ESP 寻址局部变量,编译器计算偏移量会极其困难。通过 EBP,编译器可以使用固定的相对偏移来稳定寻址:

  • 局部变量寻址:使用负偏移,如 [ebp4][ebp - 4], [ebp8][ebp - 8]
  • 入口参数寻址:使用正偏移,如 [ebp+8][ebp + 8], [ebp+12][ebp + 12](在标准 x86x86 汇编中,ebpebp 指向保存的旧 ebpebp 值,ebp+4ebp+4 处为返回地址,ebp+8ebp+8 及其以上为参数)。

4. 过程调用的 6 步生命周期(以 caller 调用 add 为例)

我们将整个调用流程拆解如下:

步骤 1:主函数 main 调用 caller 并初始化局部变量

  1. caller 没有入口参数,调用时直接将 main 的返回地址压栈。

  2. 控制权转移到 caller

  3. caller 通过修改 espesp 的值,在栈上为其局部变量 temp1temp1, temp2temp2, sumsum 分配空间。

  4. 执行汇编指令,初始化局部变量:

    mov [ebp4],125(对应 temp1)\text{mov } [ebp - 4], 125 \quad (\text{对应 } temp1)

    mov [ebp8],80(对应 temp2)\text{mov } [ebp - 8], 80 \quad (\text{对应 } temp2)

高地址 ──> ┌──────────────────────────┐
           │     main() 的栈帧        │
           ├──────────────────────────┤
           │     main 函数返回地址     │ <── caller 栈帧起始 (EBP)
           ├──────────────────────────┤
           │       temp1 = 125        │ [ebp - 4]
           ├──────────────────────────┤
           │       temp2 = 80         │ [ebp - 8]
           ├──────────────────────────┤
           │       sum (未初始化)      │ [ebp - 12] <── 当前 ESP 
低地址 ──> └──────────────────────────┘

步骤 2:参数传递与备份

  1. 调用者 caller 将准备传递给 add 的参数通过寄存器中转,或者直接压入栈中的参数临时存储区
  2. 此时,栈顶被抬高(向低地址延伸),参数 temp2=80temp2=80temp1=125temp1=125 被压入栈。

步骤 3:执行 call 指令并保存返回地址

  1. 执行 call add 指令。
  2. CPU 自动将当前的程序计数器(PCPC/IPIP,即 call 的下一条指令地址)压入栈顶,作为 add 执行完毕后的返回地址
  3. PCPC 指针修改为 add 函数的入口地址,控制权转移给 add
高地址 ──> ┌──────────────────────────┐
           │         ...              │
           ├──────────────────────────┤
           │       temp2 = 80         │ (传递给 add 的参数 y)
           ├──────────────────────────┤
           │       temp1 = 125        │ (传递给 add 的参数 x)
           ├──────────────────────────┤
           │    caller 函数返回地址    │ <── 压入返回地址,PC 跳转
低地址 ──> └──────────────────────────┘

步骤 4:执行被调用者 add 过程体

  1. add 开始执行:它不需要复杂的局部变量空间,直接通过寄存器(如 EAXEAX, EDXEDX)读取栈中的参数 xxyy

  2. 执行加法运算并将计算结果 205205 存入累加寄存器 EAXEAX 中:

    add eax,edx(结果 205 暂存于 EAX)\text{add } eax, edx \quad (\text{结果 } 205 \text{ 暂存于 } EAX)

步骤 5:执行 ret 指令,清理栈帧并返回

  1. add 函数执行完毕,发出 ret 指令。
  2. 返回地址出栈:从栈顶弹出保存的 caller 函数返回地址,将其写入程序计数器 PCPC,CPU 控制权回到 caller
  3. 清理参数区,add 的栈空间被释放。

步骤 6:写入返回值,恢复调用者现场

  1. callerEAXEAX 中取出返回值 205205,并将其写入局部变量 sumsum 的内存位置 [ebp12][ebp - 12]
  2. caller 准备结束时,通过修改栈指针(espesp 回退)释放其局部变量空间,弹出 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 库函数会按照约定:

  1. 系统调用号 存入特定的寄存器(在 Linux x86x86 中,read 的系统调用号为 33,存入 EAXEAX)。
  2. 将系统调用的各个参数依次存入通用寄存器中:
    • EBXfdEBX \leftarrow fd(文件描述符)
    • ECXbufECX \leftarrow buf(缓冲区首地址)
    • EDXcountEDX \leftarrow count(读取字节数)

步骤 2:执行陷阱/自陷指令(Trap Instruction)

库函数执行一条特殊的机器指令,如 int 0x80(传统 x86x86 软中断机制)或 syscall / sysenter(现代 CPU 快速通道指令)。

  • 非显式性:该指令不会直接出现在高级语言用户代码中,而是由库函数在底层执行。

步骤 3:硬件变态(Mode Switch)

CPU 捕获到自陷异常:

  1. 自动将 程序状态字寄存器(PSW) 中的模式位(Mode Bit)从 用户态(1) 切换为 内核态(0)
  2. 保存断点现场:硬件自动将当前的程序计数器 PCPC 和程序状态字 PSWPSW 压入内核栈(Kernel Stack)。
  3. 根据中断向量表,跳转到系统调用总入口(异常处理程序)。

步骤 4:内核服务程序运行

  1. 内核读取 EAXEAX 寄存器中的值(即系统调用号 33),通过查询系统调用表(System Call Table),定位到真实的内核服务函数 sys_read()
  2. 运行内核函数,完成实际的硬件驱动操作和数据读取。
  3. 执行结果放入 EAXEAX 中。

步骤 5:返回用户态

内核发出特殊的返回指令(如 iretsysret),CPU 自动从内核栈弹出 PCPCPSWPSW,恢复用户态特权级,控制权移交回 C 库函数,最终返回用户程序。

第三部分:过程调用 vs 系统调用深度对比

对比维度过程调用 (Procedure Call)系统调用 (System Call)
工作特权级全程处于同一模式(通常是用户态,或内核模块内部的调用)。特权级切换:必须从用户态(Mode=1)跃升到内核态(Mode=0)。
触发方式/指令编译器编译生成的普通跳转指令:calljmp特殊的自陷/软中断指令:int 0x80syscallsysenter
返回指令ret(普通栈出栈,修改 PCPC)。iretsysret(恢复 PCPC 的同时强制恢复 PSWPSW 模式位)。
硬件保存的现场仅保存 PCPC(返回地址)必须同时保存 PCPC PSWPSW(程序状态字)
为什么保存 PSW?状态字不随调用改变,或状态字改变本身就是计算逻辑的一部分,无需硬件备份。因为运行模式发生改变(用户态 \rightarrow 内核态),必须备份原状态标志、中断屏蔽位等特权信息,否则无法安全重入。
代码可见性被调用函数的代码直接编译进用户程序的地址空间,完全可见被调用内核代码驻留在内核安全区,对用户程序完全透明/不可见
运行栈全程在**用户栈(User Stack)**中进行。涉及用户栈(准备寄存器参数)与内核栈(Kernel Stack)(保存内核执行现场)的协同。
嵌套与递归支持高度的嵌套与递归调用。不允许用户态方向的直接嵌套调用(即不能在系统调用服务程序执行中,再次由用户发起系统调用)。

第四部分:精选例题与考研(408)真题演练

例题 1:栈帧结构与 EBP 寻址计算(计组/系统开发重点)

【题目】 某 32 位系统采用 x86x86 架构,函数调用时参数通过栈传递(从右至左压栈)。现有如下 C 代码:

void func(int a, int b) {
    int local1 = 10;
    int local2 = 20;
}

已知进入 func 并执行完 mov ebp, esp 后,此时 EBPEBP 的值为 0xBFFFF0A0

  1. 请问局部变量 local1 和参数 a 的内存地址分别是什么?
  2. 在该函数执行期间,若执行 push eax,控制栈顶的寄存器 ESPESP 的值将如何变化?

【详细解析】x86x86 架构的标准函数入口(Prologue)中,栈帧的典型结构如下(每个成员占 4 字节):

内存地址存储内容相对 EBP 偏移
0xBFFFF0AC参数 b[ebp+12][ebp + 12]
0xBFFFF0A8参数 a[ebp+8][ebp + 8]
0xBFFFF0A4返回地址 (Return Address)[ebp+4][ebp + 4]
0xBFFFF0A0旧的 EBP 值 (Saved EBP)[ebp][ebp] (当前 EBP 指向这里)
0xBFFFF09C局部变量 local1[ebp4][ebp - 4]
0xBFFFF098局部变量 local2[ebp8][ebp - 8]
  1. 计算地址

    • local1 位于 ebp4ebp - 4 处:

      Addr(local1)=0xBFFFF0A04=0xBFFFF09C\text{Addr(local1)} = \text{0xBFFFF0A0} - 4 = \text{0xBFFFF09C}

    • 参数 a 位于 ebp+8ebp + 8 处:

      Addr(a)=0xBFFFF0A0+8=0xBFFFF0A8\text{Addr(a)} = \text{0xBFFFF0A0} + 8 = \text{0xBFFFF0A8}

  2. ESP 寄存器变化

    • 栈是向低地址增长的,32 位系统下一个 push 指令会将数据写入栈顶,并将 ESPESP 减 4。
    • 因此,ESPESP 的值会减少 4

例题 2:系统调用执行流程多维分析(OS/计组综合题)

【题目】 下列关于系统调用的叙述中,错误的是( )。 A. 用户程序设计时,可以通过执行“int 0x80”指令直接从内核态执行系统调用。 B. 在执行系统调用的过程中,CPU 至少发生两次特权级切换。 C. 系统调用执行时,保存现场的栈通常是该进程对应的内核栈,而不是用户栈。 D. 操作系统通过系统调用号来识别具体请求的是哪一个系统调用。

【答案】 A

【解析】

  • A 选项错误:虽然底层的确是通过 int 0x80(或 syscall)实现切换的,但用户程序不能直接在内核态下执行它。执行该指令前 CPU 仍处于用户态,正由于该指令的运行才使得 CPU 陷入并转为内核态。此外,高级语言用户程序是通过标准库函数间接调用的,不推荐也不需要直接写汇编。
  • B 选项正确:在执行系统调用时,CPU 至少需要经历:用户态 \rightarrow 内核态(请求服务)和 内核态 \rightarrow 用户态(服务完成返回),共 2 次特权级切换。
  • C 选项正确:特权级跃升时,为了防止用户空间代码恶意篡改控制信息,CPU 硬件自动将 PCPCPSWPSW 压入高特权级的内核栈中。
  • D 选项正确:内核通过累加寄存器 EAXEAX 中保存的系统调用号作为索引去检索系统调用表。

例题 3:PSW 的保存问题

【题目】 为何在系统调用中断处理时必须由硬件保护程序状态字寄存器(PSW),而在普通的过程调用中却不需要?请从系统安全与重入的角度进行分析。

【详细解析】

  1. 控制边界与特权级改变
    • 过程调用发生在同一个特权级(通常是用户态空间内)。由于不改变 CPU 的特权模式,不涉及中断允许位的修改,所以 PSWPSW 中的核心特权控制位不需要改变。
    • 系统调用伴随着特权级的跃升(从用户态进入内核态)。内核态下需要屏蔽部分中断、修改地址映射规则,这直接导致 PSWPSW 中的运行模式位、中断屏蔽位必须被强制改变。
  2. 异步性与非自愿性(针对中断与异常)
    • 普通过程调用是程序员自愿且可预测的,编译器在编译时已预知执行流的去向,并主动做好了寄存器(除 PCPC 外)的分配与保存。
    • 系统调用或外部中断往往是异步或强迫性发生的。为了保证被中断的用户程序在返回时能够“无缝”继续运行,必须将反映 CPU 核心算术状态(如进位标志、溢出标志)和系统控制标志的 PSWPSW 完整保存,待内核服务结束、利用 iret 指令恢复现场时,硬件再将原 PSWPSW 复位,确保用户程序的运行环境不受任何损害。

总结:一分钟对比口诀

函数调用走用户栈,call指令去,ret指令还,特权级不变,只存PC指针; 系统调用跨内核关,自陷触发,iret指令返,模式位变零,PSW、PC全存盘。