目录

一、操作系统如何接管CPU

1. 核心问题

2. 中断概念

二、硬件中断

1. 中断本质

2. 中断号的传递机制

3. 中断流程

4. 中断意义

三、中断初始化

1. trap_init

2. rs_init

四、时钟中断与调度

1. 时钟中断

2. 调度初始化与时钟中断设置

3. 调度执行流

4. 任务切换

5. 时间片调度全流程

6. 操作系统的本质

7. 时间片与主频

五、系统调用

1. 系统调用概念

2. int 0x80

3. sys_call_table

六、系统调用流程

1. 用户层如何传递系统调用号

2. unistd.h 源码解析

3. 完整调用链

七、异常补充

1. 陷阱与异常

2. 缺页中断

八、用户态与内核态

1. 权限模型

2. 地址空间

多个进程如何找到同一个内核

总结


一、操作系统如何接管CPU

在探讨了信号的保存与捕捉后,我们必须面对一个更底层的逻辑问题:操作系统作为一个软件,是如何在程序运行期间突然跳出来接管 CPU 的?

本篇将深入分析中断、调度与系统调用的协作机制,揭示操作系统控制权的底层流转


1. 核心问题

在单核 CPU 架构中,任何时刻只能执行一条指令。当操作系统调度一个用户进程运行后,CPU 的 程序计数器(PC/IP) 便指向了该进程的地址空间。此时,CPU 只是在机械地执行用户代码

核心矛盾在于: 如果用户程序是一个死循环(while(1)),且没有任何主动交出控制权的行为,操作系统该如何重新获得 CPU 的控制权,进而执行调度逻辑或响应其他硬件需求?

如果 OS 无法拿回控制权,多任务处理和进程管理将无从谈起。操作系统必须通过一种强行打断当前执行流的机制来介入,这种机制就是中断


2. 中断概念

中断是现代计算机架构中最重要的物理特性。它的本质是硬件向 CPU 发送的一个电信号,要求 CPU 暂时停止当前任务,转而执行一段特定的程序

从控制流的角度看,中断是改变 CPU 执行路径的异步手段。它不需要 CPU 在指令中预先安排,而是由外部硬件或内部异常在任意时刻触发

二、硬件中断

1. 中断本质

硬件中断是外部设备(如磁盘、网卡、键盘、时钟)与 CPU 沟通的唯一方式

  • 物理触发: 当外设完成任务(如磁盘读取完毕)或发生特定事件(如键盘被按下)时,会通过中断控制器向 CPU 的中断引脚发送电信号

  • CPU 响应: CPU 在每执行完一条指令后,都会检测中断引脚的电平。如果发现有中断请求(且未被屏蔽),CPU 会在完成当前指令周期后,自动保存当前的关键寄存器状态(上下文),并强制跳转到预定义的入口地址


2. 中断号的传递机制

硬件握手

当外部设备产生中断请求时,控制权的转移遵循以下物理过程:

  1. IRQ 发起:外设向中断控制器(如 8259A PIC 或 APIC)发送一个电信号

  2. 中断确认:CPU 执行完当前指令后,向中断控制器发送一个中断确认信号

  3. 向量投递:中断控制器收到确认后,通过数据总线向 CPU 发送一个 8 位的数字(这就是 中断向量/中断号

    • 例如:时钟中断对应的向量通常是 0x20

CPU 的隐式获取

此时,CPU 的硬件逻辑会接管一切,操作系统此时尚未感知到这个号码:

  1. CPU 内部的硬件电路以这个 0x20 为索引,去 IDT(中断描述符表) 中寻找第 32 项

  2. 加载 CS:EIP:CPU 自动从该项中提取出内核预设的函数地址,并加载到程序计数器中

在这一步,OS 并不需要拿到中断号,因为硬件已经根据中断号自动把 CPU 的执行流放进了对应的处理函数里

汇编层

在内核处理硬件中断的汇编段,内核会为每一个中断 IRQ 生成一段小的跳板代码。它的逻辑是

# 伪代码:内核为 IRQ 0x20 生成的入口
irq_0x20_entry:
    pushl $0x20 - 256  # 1. 显式将中断号压入栈中
    jmp common_interrupt # 2. 跳转到通用处理逻辑

C 语言层的参数获取

当执行流进入通用的 C 函数(如 do_IRQ)时,这个被压入栈的中断号就变成了函数的参数

  • Linux 2.6.18 路径:arch/i386/kernel/irq.c 中的 do_IRQ

// 内核通过寄存器或栈获取刚才压入的中断号
unsigned int do_IRQ(struct pt_regs *regs) {
    // regs 结构体中包含了刚才汇编压入的 original_eax/orig_rax
    // 这个值就是中断号
    int irq = regs->orig_rax & 0xff; 
    
    // 然后 OS 拿着这个 irq 去查找对应的驱动程序
    handle_irq(irq, regs);
    ...
}

总结:OS 是如何拿到中断号的?

  1. 在底层(硬件层):CPU 通过数据总线从中断控制器获取 8 位向量,用于寻找 IDT

  2. 在入口层(汇编层):内核在进入通用处理程序前,手动执行一次 push 指令,将这个号码压入栈中

  3. 在逻辑层(C 语言层):内核从栈中读取这个值,从而得知本次触发的是哪个设备

核心哲学:操作系统之所以能拿到中断号,是因为它在编写中断入口代码时,就预先写好了把这个号存起来的指令。硬件负责触发,软件则主动上报自身标识


3. 中断流程

一个典型的硬件中断处理流程遵循以下步骤:

  1. 中断请求: 硬件发起信号

  2. 中断判优: 中断控制器决定多个同时到达的中断哪个优先级更高

  3. 现场保护: CPU 硬件自动将当前的标志寄存器(EFLAGS/RFLAGS)、代码段寄存器(CS)和指令指针(IP/RIP)压入内核栈

  4. 查表跳转: CPU 根据中断号,在 中断描述符表(IDT, Interrupt Descriptor Table) 中查找对应的 中断服务程序地址

  5. 执行处理: 进入内核态,执行对应的驱动程序逻辑

  6. 现场恢复: 执行 IRET 指令,从内核栈恢复寄存器,回到用户态断点处

中断向量表是操作系统的重要组成部分,系统启动时便已加载至内存。借助硬件中断机制,操作系统无需周期性地轮询检测外设状态


4. 中断意义

中断机制的引入,解决了计算机系统中的三个关键问题:

  • 异步事件处理: 无需 CPU 轮询等待外设状态,极大地提高了 CPU 利用率

  • 分时多任务: 通过时钟中断(下文详述),OS 可以在固定时间间隔内强行取回控制权,从而实现多进程之间的公平切换

  • 故障隔离与响应: 当硬件发生错误或电源掉电时,OS 能第一时间介入处理

如果没有中断,操作系统将沦为一个普通的函数库,只能被动等待用户程序调用,而无法实现对系统的全局掌控

三、中断初始化

在学习内核源码时,通常参考两个经典版本:一个是用于教学的 Linux 0.11,另一个是工业级的 Linux 2.6.x

CPU 响应中断前,操作系统需先在内存中建立中断向量表,该表明确指定:当发生特定编号的中断时,CPU 应跳转执行的内核代码位置。


1. trap_init

trap_init 的主要职责是初始化 IDT,将系统预定义的异常(如除 0 错误、缺页异常等)与对应的内核处理函数关联起来

  • Linux 0.11 路径:

    • 定义地址:kernel/traps.c

    • 调用位置:init/main.c 中的 main() 函数

  • Linux 2.6.18 路径:

    • 定义地址:arch/i386/kernel/traps.c(针对 x86 32位架构)

    • 调用位置:init/main.c 中的 start_kernel()

其核心动作是执行一系列宏或函数,如 set_trap_gate 或 set_intr_gate,在内存的特定位置写入中断处理程序的入口地址


2. rs_init

rs_init 主要负责初始化串行端口。在早期计算机中,串行口是重要的输入输出设备(终端)。通过初始化串口,操作系统可以利用串口中断来接收键盘输入或输出调试信息

  • Linux 0.11 路径:

    • 定义地址:kernel/chr_drv/serial.c

    • 调用位置:init/main.c(紧随 trap_init 之后)

  • Linux 2.6.18 说明:

    • 在 2.6 及以后的内核中,rs_init 这个函数名已经淡出。它被整合进了更复杂的设备驱动模型中。对应的功能逻辑主要在 drivers/serial/ 或 drivers/tty/serial/ 目录下,且通常通过 module_init 机制在更晚的阶段被加载

底层逻辑: trap_init 用于 CPU 异常处理,而 rs_init 则负责处理外部设备输入

四、时钟中断与调度

硬件中断负责响应外部突发事件,而时钟中断则是操作系统的"心跳",作为系统夺回控制权最稳定且频繁的机制


1. 时钟中断

在计算机主板上有一个可编程间隔定时器。它会以固定的频率(如 100Hz 或 1000Hz)向 CPU 发送电信号

  • 强制接管: 无论用户程序正在执行多么紧密的逻辑,时钟中断一旦触发,硬件会强制 CPU 保存现场并跳转到内核的处理程序

  • 内核特权: 在时钟中断的处理函数中,内核会更新系统时间、处理定时器,并最重要地——检查当前进程的时间片


2. 调度初始化与时钟中断设置

在操作系统启动初期,必须先配置好调度相关的基础数据结构,并接管硬件时钟产生的信号

(1) sched_init()

  • 代码路径:kernel/sched.c

  • 职责:调度系统的总初始化

  • 作用:手动设置进程 0(idle 进程)的状态

    • GDT(全局描述符表) 中设置进程 0 的 TSS(任务状态段)LDT(局部描述符表)

    • 清空所有任务数组,初始化信号相关的位图

    • 最重要的一步:调用 set_intr_gate 来挂接时钟中断处理程序

(2) set_intr_gate(0x20, &timer_interrupt)

  • 代码路径:在 kernel/sched.c 中被调用,宏定义于 include/asm/system.h

  • 职责:设置中断描述符表(IDT)

  • 作用:将硬件时钟中断的入口地址指向 timer_interrupt

    • 这意味着每当硬件时钟振荡产生信号,CPU 都会强制跳转到 kernel/system_call.s 中的 timer_interrupt 汇编入口


3. 调度执行流

硬件触发 0x20 号中断后,控制流依次经过三个阶段:从汇编入口点跳转至 C 语言处理逻辑,最终完成决策切换流程

(1) timer_interrupt(汇编入口)

  • 代码路径:kernel/system_call.s

  • 作用: 这是时钟中断的底层入口。它负责压栈保存现场,然后调用 C 语言编写的 do_timer。执行完 do_timer 后,它会检查是否需要调用 schedule

(2) do_timer(long cpl)

  • 代码路径:kernel/sched.c

  • 职责:维护系统时间与处理更新当前进程的时间片

  • 作用:更新全局时间变量 jiffies(系统滴答数)

    • 通过 cpl(当前特权级)判断中断发生时进程处于内核态还是用户态,累加对应的 CPU 耗时

    • 关键动作: 对当前进程的 counter(时间片)进行递减。如果 counter 减为 0,则说明该进程配额耗尽

(3) schedule()

  • 代码路径:kernel/sched.c

  • 职责:决策算法,选择进程

  • 作用:遍历任务数组,处理所有任务的信号

    • 核心算法:比较所有处于 TASK_RUNNING(就绪态)任务的 counter 值,找到其中 counter 最大的进程

    • 如果所有就绪进程的 counter 都为 0,则根据每个进程的 priority(优先级)重新计算 counter

    • 确定下一个要运行的进程 next 后,调用 switch_to(next)


4. 任务切换

这是调度流程的最后一步,也是控制权真正发生转移的时刻

switch_to(next)

  • 代码路径:include/linux/sched.h(通常定义为宏)

  • 职责: 硬件上下文切换

  • 作用:寄存器替换,将当前进程的所有 CPU 寄存器保存到当前进程的 TSS 中

    • 加载新进程: 将目标进程 next 的 TSS 中的数据加载回 CPU 寄存器

    • 实质: 一旦寄存器 ESP(栈指针)和 EIP(指令指针)被替换,CPU 的执行流就瞬间切换到了新进程的代码空间中


5. 时间片调度全流程

我们将整个过程分为五个核心阶段:

第一阶段:硬件触发与现场保护

  1. 时钟硬件产生电信号,CPU 收到 0x20 号中断信号,立即打断当前执行的用户指令

  2. CPU 硬件自动将当前用户进程的 EFLAGS、CS、EIP(以及在权限切换时的 SS、ESP)压入该进程的内核栈

  3. 查表跳转:CPU 根据 IDT 找到 0x20 对应的入口地址

第二阶段:进入汇编入口 (timer_interrupt)

  1. 手动将通用寄存器压栈

  2. 通过 call _do_timer 指令进入 C 语言环境

第三阶段:时间片结算 (do_timer)

  1. 系统全局滴答计数加 1

  2. 执行 current->counter--。这是最关键的一步,当前进程的时间片减少了

  3. 判断逻辑

    • 如果 counter > 0:进程还有时间片,直接返回

    • 如果 counter == 0:进程配额用尽

  4. 函数结束,返回 system_call.s

第四阶段:调度决策 (schedule)

  1. 在汇编代码准备恢复现场返回用户态之前,会检查 counter 是否为 0

  2. 如果 counter 为 0 且当前处于可抢占状态,则调用 schedule

  3. 寻找下一个进程

    • schedule() 遍历所有任务数组。

    • 寻找状态为 TASK_RUNNING 且 counter 最大的进程

    • 如果所有进程 counter 都为 0,则根据优先级重新计算

  4. 选出下一个要运行的进程指针 next 

第五阶段:上下文切换 (switch_to)

  1. 利用 ljmp(长跳转)指令

  2. TSS 切换

    • 将 CPU 当前所有的寄存器状态保存到当前进程的 TSS(任务状态段)结构中

    • next 进程 的 TSS 中将保存的寄存器数值强行加载回 CPU 硬件

  3. 一旦 EIP 指向了 next 进程上次被打断的指令,ESP 指向了 next 进程的栈,CPU 就变身成了新进程

  4. 新进程顺着它自己的 timer_interrupt 剩下的指令执行(恢复现场寄存器),最后执行 iret 返回到它自己的用户态

流程图总结

硬件时钟 (0x20)

$\downarrow$

timer_interrupt (汇编:存寄存器)

\downarrow

do_timer (counter--)

$\downarrow$

判断 counter 是否为 0

$\downarrow$ 

schedule() (决定谁是 next)

$\downarrow$

switch_to(next) (彻底切换 CPU 寄存器)

$\downarrow$

新进程执行...


6. 操作系统的本质

在计算机科学中,我们常说 "程序在运行",但如果没有任何用户程序运行,CPU 在做什么?答案是:它在执行操作系统最核心的死循环

(1) 操作系统是一个被动的工具集

操作系统本身并不会主动执行任务:

  • 如果没有外设中断(如键盘、磁盘),如果没有时钟中断(心跳),如果没有用户主动调用(系统调用),操作系统就会一直停留在那个死循环里

  • 所谓功能扩展:操作系统的扩展性极强,本质上就是因为它的架构极其简单——如果你需要操作系统支持一个新的硬件,你只需要写好对应的驱动方法,然后将该方法的入口地址填入中断向量表(IDT)对应的编号位置即可

(2) 源码中的死循环

在 Linux 内核完成所有初始化工作(如 trap_init, sched_init 等)后,它会启动第一个用户进程(init 进程)。而内核的主线逻辑(即进程 0,Idle 进程)会进入一个永不退出的循环

Linux 0.11 的 init/main.c 中,你可以清晰地看到这段代码

为什么是 pause()

  • pause() 的本质:它是一个系统调用,会将当前进程(进程 0)设置为可中断的睡眠状态,然后主动调用 schedule() 切换到其他进程

  • 低功耗:在现代内核中,这个死循环通常包含一个 hlt 指令。这个指令会让 CPU 进入低功耗状态并停止执行,直到下一个硬件中断将其唤醒


7. 时间片与主频

在操作系统的宏观管理中,时间片与主频是决定系统响应速度与吞吐量的核心物理参数。它们共同决定了操作系统运行的基本节奏,以及每个时间周期内所能执行的任务量

(1) 系统主频与 HZ

在 Linux 内核中,系统主频并非指 CPU 的时钟频率(如 3.0GHz),而是指内核时钟中断的频率,由常量 HZ 定义

  • 物理含义: HZ 代表定时器硬件每秒钟向 CPU 发起中断的次数。例如,若 HZ=100,则每 10 毫秒(10ms)产生一次时钟中断;若 HZ=1000,则每 1 毫秒(1ms)产生一次中断

(2) 时间片

时间片是操作系统分配给每个进程在被剥夺 CPU 控制权之前所能运行的最大时长

  • 数据结构: 在 task_struct 中,counter 字段通常用来记录剩余的时间片

  • 单位: 时间片的单位通常是滴答数

  • 动态演变:在 Linux 0.11 中,初次分配的时间片等于进程的优先级 priority

    • 每次时钟中断,do_timer 执行 current->counter--

    • 当 counter 减至 0 时,内核在中断返回前触发 schedule()

五、系统调用

系统调用是用户程序主动请求操作系统代为处理特定操作

在 Linux 的世界里,用户程序严格限制在用户态。当它想要触碰硬件(写文件、开网络、分配内存)时,必须通过特定接口向内核发起请求。这一机制的核心,是一组精心设计的函数指针数组


1. 系统调用概念

用户程序无法直接执行内核代码,因为这会触发 CPU 的硬件保护。为了实现跨越权限
(Ring 3 -> Ring 0)的调用,Linux 采用了软中断机制

  • 本质:系统调用是一次主动触发的异常机制。用户程序通过特定的指令自陷进入内核,并传递系统调用号


2. int 0x80

在 x86 架构的早期 Linux 中,系统调用的唯一入口就是 0x80 号软中断

  • 寄存器分工

    • EAX:存放系统调用号

    • EBX, ECX, EDX...:存放传给系统调用的参数

  • 动作:执行 int 0x80 后,CPU 会查 IDT(中断描述符表),发现 0x80 对应的是内核的 system_call 汇编入口


3. sys_call_table

当进入内核时,内核并不知晓具体的操作意图。它会根据存储在 EAX 寄存器中的系统调用号,在系统调用表(sys_call_table)中查找对应的处理函数

在内核源码路径 include/linux/sys.h 中,定义了系统调用表

为什么需要 sys_call_table

这种设计实现了接口的解耦

  • 地址固定:无论内核函数在内核内存的哪个位置,用户程序只需要记住它的编号是 3 即可

  • 安全可控:用户不能执行内核里的任意代码,只能执行 sys_call_table 中暴露出来的、经过内核验证的函数

为什么开发者感知不到 int 0x80?

在应用层开发中,我们几乎从不直接手写汇编指令来触发中断,原因主要有以下三点:

(1) 标准 C 库的封装与屏蔽

当你调用 read() 时,你实际上调用的是 glibc 提供的一个同名函数。这个函数内部干了三件事:

  1. 将你传入的参数按照内核的规矩,依次放入指定的寄存器(如 EBX, ECX, EDX)

  2. 设置调用号:将 __NR_read(即 read 的系统调用号)放入 EAX 寄存器

  3. 执行 int 0x80 或 syscall 指令,让 CPU 陷入内核

处理完后,包装函数还会检查内核返回的 EAX 值。如果是个负数(表示报错),它会把这个值取反存入全局变量 errno 中,并给用户返回 -1

(2) 硬件架构差异

不同的 CPU 架构,进入内核的指令是不一样的:

  • x86 (32位):使用 int 0x80

  • x86_64 (64位):使用更现代、更快的 syscall 指令

如果让开发者直接写汇编,那你的程序换一个 CPU 就跑不起来了。glibc 屏蔽了底层的指令差异,让同一份代码可以在任何平台上编译运行

(3) API 与 ABI 的分离

  • API (应用编程接口):你看到的 open()、read(),这是给程序员看的接口

  • ABI (应用二进制接口):寄存器怎么放、中断号是多少,这是给 CPU 运行看的规范

通过 glibc 这一中间层,开发者只需要维护稳定的 API,而内核开发者可以优化底层的 ABI。这种解耦极大地增强了系统的稳定性

六、系统调用流程

系统调用的本质在于实现可控的上下文切换。在此过程中,CPU寄存器发挥着关键作用


1. 用户层如何传递系统调用号

当用户程序(通过 glibc)决定发起系统调用时,必须按照内核的 ABI 规范来摆放数据

  • 寄存器约定 (x86_64)

    • RAX:存放系统调用号。这是内核识别调用功能的唯一标识符

    • RDI, RSI, RDX, R10, R8, R9:依次存放系统调用的第 1 到 第 6 个参数

  • 动作:一切准备就绪后,执行 syscall 指令(64位)或 int 0x80(32位兼容)

核心逻辑: syscall 指令会触发 CPU 硬件逻辑:将当前的特权级从 Ring 3 提升到 Ring 0,并强行将 PC 指针跳转到内核预设的系统调用总入口

内核处理完具体的系统调用函数后,返回值需要原路返回

  • 寄存器载体:内核统一将函数的返回值存入 RAX(或 32 位下的 EAX)寄存器中

  • 返回指令:内核执行 sysret(64位)或 iret(32位)指令,将寄存器状态还原,并降权回到用户态

  • 用户层处理

    • 如果 RAX 的值在 [-4095, -1] 之间,glibc 会认为这是一个错误码

    • glibc 会将该值取绝对值存入全局变量 errno

    • 然后将函数原本的返回值改为 -1,交给用户程序

    • 如果 RAX 是正数,则直接作为函数结果返回


2. unistd.h 源码解析

系统调用号并不是随机生成的,它们被严格定义在内核头文件中。在 Linux 2.6.18 架构中,对于 x86_64 平台,具体的对应关系就在这个文件里

路径:linux-2.6.18/include/asm-x86_64/unistd.h

在该文件中,你会看到大量的宏定义,它们构成了 sys_call_table 的索引

逻辑关系:

  1. __NR_xxxx:这是暴露给用户层的宏。当你在 C 代码里包含 <unistd.h> 并调用 read() 时,编译器和库最终会把 0 这个数字填入 RAX

  2. __SYSCALL 宏:在内核编译时,这个宏会将编号与内核函数名(如 sys_read)关联起来,用于生成我们上一节看到的 sys_call_table

(1) 这些编号是给谁看的?

答案是:给两边看的,但作用阶段不同

  • 给用户看(编译时):当你在用户态编写 C 程序或编译 glibc 时,编译器需要知道 read 对应的数字是 0。没有这个宏定义,glibc 的汇编代码就不知道该往 RAX 寄存器里填什么

  • 给内核看(编译时):内核在编译自己时,也需要这个文件。内核会利用这些宏和 __SYSCALL 扩展成一个巨大的函数指针数组(即 sys_call_table)。这样,内核才能确保下标为 0 的位置存的是 sys_read 的地址

结论:unistd.h 是用户空间与内核空间彼此独立运行之前达成的一份协议契约

(2) OS 拿到 RAX 里的号了,还需要这个文件吗?

在执行系统调用的那一瞬间,OS 确实不再需要读取这个 .h 文件了

  • 运行时:此时 unistd.h 已经功成身退。内核在启动时就已经把 sys_call_table 加载到了内存的静态存储区。当 CPU 陷入内核,内核直接拿着 RAX 里的 0 去内存里的数组查表。它不需要再去磁盘加载头文件,那太慢了

  • 编译时:如果没有这个文件,内核在编译阶段就无法生成那张表


3. 完整调用链

以 read(fd, buf, count) 为例,整个流程如下:

  1. 用户态 (User Space)

    • read() -> glibc 封装函数

    • 设置 RAX = 0,RDI = fd,RSI = buf,RDX = count

    • 执行 syscall 触发软中断陷入内核

  2. 内核态 (Kernel Space)

    • 进入 system_call 汇编入口

    • 查表:sys_call_table[RAX] 即 sys_call_table[0]

    • 调用对应的内核函数 sys_read()

    • sys_read() 执行完毕,将结果存入 RAX

    • 执行 sysret 返回

  3. 用户态 (User Space)

    • 回到 glibc 封装函数

    • 检查 RAX,若为负则设置 errno,返回 -1

    • 若为正,返回读取的字节数

七、异常补充

在深入讨论了系统调用和时钟中断之后,我们需要理清两个经常被混淆的概念:陷阱异常

虽然它们最终都进入内核,但它们的触发动机和执行逻辑有着本质的区别


1. 陷阱与异常

陷阱(Trap)

陷阱是进程主动执行的一条特殊指令,目的是让 CPU 陷入内核态以获取某种服务

  • 典型代表:int 0x80 或 syscall

  • 同步性:它是同步发生的。你在代码的哪一行写了 iint 0x80,CPU 执行到这一行时就会跳入内核

  • 目的获取权限。用户程序明知道自己没权限写磁盘,所以它设下一个 "陷阱" 让自己陷进去,请求内核帮忙

  • 执行结果:内核处理完后,返回到陷阱指令的下一条指令继续执行

异常(Exception):

异常是 CPU 在执行指令过程中遇到了错误无法处理的情况,被迫停下来请求内核处理

  • 典型代表:除零错误、非法指令、缺页中断

  • 非自愿性:没有程序员会主动写一个除零操作,这是指令运行时的意外

  • IDT 映射:Intel 规定 IDT 的前 32 个中断号(0-31)专门用于异常。例如:

    • 0 号:除零

    • 14 号:缺页中断(Page Fault)

  • 执行结果:内核处理完后,可能会尝试重新执行刚才那条触发异常的指令(如缺页中断),或者直接干掉进程(如段错误)


2. 缺页中断

缺页中断(Page Fault)是最复杂、也是最重要的异常(IDT 14 号)。它是支撑现代操作系统虚拟内存技术的基石

底层背景

在现代 Linux 中,进程看到的所有地址都是虚拟地址。当你声明一个 1GB 的数组时,内核并没有真的给你 1GB 的物理内存

  • 页表:这是一张映射表,记录了 "虚拟地址 A -> 物理地址 B"

  • 有效位(Present Bit):页表项里有一个关键的比特位。如果为 1,表示已分配物理内存;如果为 0,则表示该地址尚未映射实际物理内存

缺页流程

当 CPU 执行一条指令,访问某个虚拟地址时,硬件逻辑如下:

  1. MMU 检查:内存管理单元(MMU)去查页表

  2. 触发异常:如果发现该地址对应的页表项 Present Bit = 0,或者权限不匹配,CPU 无法继续,立即触发 14 号异常

  3. 压栈与寄存器:CPU 自动将发生故障的虚拟地址存入 CR2 寄存器

  4. 内核介入

    • 内核从 IDT[14] 找到处理函数 do_page_fault

    • 内核读取 CR2,知道是谁出事了

    • 内核检查:这个地址合法吗?

      • 非法(段错误):给进程发 SIGSEGV,进程崩溃

      • 合法(只是还没分配内存):内核赶紧在物理内存里找一个空闲页,填到页表里,把Present Bit 改为 1

为什么不直接把内存给全?

  • 延迟分配:只有在你真正读写那一页内存时,内核才会触发缺页中断并分配物理内存。这极大地节省了宝贵的物理内存资源

  • 写时拷贝 (COW):父子进程共享同一块物理内存。只有当其中一个进程尝试写时,会触发权限异常(缺页中断的一种),内核此时才复制一份物理内存

八、用户态与内核态

在前几章中,我们探讨了控制权如何通过中断和系统调用在用户态与内核态之间切换。现在,让我们从内存空间的角度重新审视这一机制:CPU 是如何识别权限级别的,以及一个程序如何从磁盘上的二进制文件,最终形成内存中的用户空间与内核空间


1. 权限模型

在硬件层面,x86 架构提供了四个特权级(Ring 0 到 Ring 3)。Linux 为了跨平台通用性,只使用了其中两个:

  • Ring 0(内核态):拥有最高权限,可以执行任何 CPU 指令(如 hlt 停机、cli 关中断),直接访问所有物理硬件

  • Ring 3(用户态):受限权限。不能直接操作硬件,不能访问内核地址空间。一旦 "越权",CPU 就会触发通用保护异常,也就是我们常见的段错误


2. 地址空间

每一个 Linux 进程都有自己独立的 虚拟地址空间。以 32 位系统为例,寻址空间共 4GB:

  • 用户空间 (0 到 3GB):每个进程独享,互不干扰。这里存放着你的代码、堆栈和库。

  • 内核空间 (3GB 到 4GB):这是所有进程共享的区域。无论你切换到哪个进程,这高位的 1GB 映射的都是同一片物理内存(内核代码和数据)

既然是共享的,为什么用户进程不能读写它?

这全靠 页表权限位。在 MMU(内存管理单元)查表时,不仅查地址,还会检查当前的特权级(CPL)。如果 CPL 为 3,但页表项标注该内存仅限 Ring 0 访问,硬件会直接拦截访问请求

执行系统调用时,地址空间切换了吗?

答案是:没有。

当你执行 int 0x80 进入内核态时,进程的地址空间并没有发生切换(那是进程调度才干的事)

  • 状态变了:CPU 从 Ring 3 变成了 Ring 0

  • 可见度变了:原本 "看不见" 的 3GB - 4GB 内核区域,现在变得可读写了

  • 栈变了:CPU 不再使用用户栈,而是自动切换到该进程在内核中对应的内核栈

这就好比你进了一家银行:

  • 用户态:你在大厅(用户空间),只能看大厅里的报纸(用户代码)

  • 内核态:你凭证件(系统调用)进了柜台(内核空间)。此时你依然在同一家银行里,但你现在可以触碰柜台里的钱(内核数据)了


多个进程如何找到同一个内核

当你从进程 A 切换到进程 B,虽然用户空间的 0 ~ 3GB(代码、堆、栈)发生了变化,但高位的 3GB ~ 4GB 内核空间映射是完全一致的

  • 物理唯一性:尽管每个进程都有自己的页表,但所有进程页表中关于 3GB ~ 4GB 的条目,最终都指向物理内存中同一块存放内核镜像的区域

  • 逻辑稳定性:这意味着,无论当前 CPU 正在运行哪个进程,内核代码(如 sys_read)和内核数据结构(如 task_struct 数组、IDT 表)在虚拟地址空间里的绝对位置永远不变

无论你走进哪一间教室(进程),抬头看天花板(内核空间),上面的灯和风扇(系统调用函数)永远在同一个位置

系统调用

一个极其重要的认知:系统调用的执行,并不是跳转到了另一个名为 "操作系统" 的进程里,而是在当前进程的上下文中进行的

当用户程序执行 int 0x80 后:

  1. CPU 的指令指针(EIP/RIP)只是从用户空间跳转到了内核空间

  2. 当前进程身份:此时 CPU 虽然在执行内核代码,但它依然代表的是当前进程。它访问的内核数据结构,也往往是当前进程对应的 task_struct

  3. 内核栈的切换:为了安全和独立,内核不会使用用户态的栈。每个进程在创建时,内核都会为其分配一个私有的内核栈。系统调用期间产生的临时数据,都压在这个属于该进程的内核栈里

总结

综上所述,从硬件中断到时钟中断,再到系统调用与软中断机制,我们逐步理解了操作系统是如何真正“运行起来”的:外设通过中断打断 CPU,CPU 借助 IDT 跳转到对应的中断处理例程,内核再通过调度器重新分配执行资源,最终完成从用户态到内核态、再返回用户态的整个闭环。

与此同时,无论是 int 0x80、sys_call_table,还是时钟中断触发的 schedule,本质上都说明了一件事:

操作系统并不是一直主动运行,而是在各种中断与异常的驱动下不断重新获得 CPU 控制权

至此,我们已经基本打通了“进程”这一主线。但进一步思考会发现:相比进程,现代系统中更常被频繁调度与执行的,其实是线程

在下一篇中,我们将正式进入线程机制,深入理解 Linux 是如何实现线程、线程与进程之间的关系,以及所谓轻量级进程背后的真正含义

Logo

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

更多推荐