一篇文章彻底搞懂用户态、内核态和中断处理

49 阅读25分钟

1. 引言

1.1 一段广为人知的代码

这段代码我敢保证每一个接触过编程的人都写过:

#include <stdio.h>int main()
{
    printf("Hello World!\n");
    while(1);
    return 0;
}

这段代码表面上看起来极其清晰明了:让 CPU 执行printf函数,然后 CPU 就乖乖的把字符串打印到了屏幕上面。

但是,事情并没有这么简单。

如果我们把视角无限放大,进入到 CPU 内部,就会发现:这段程序压根就没有访问显示器的资格,它甚至连访问内存的权利都是被严格限制的。

如果我们在程序中试图直接向显示器的内存地址写数据,或者试图控制硬盘来读取文件,CPU 就会立刻抛出异常,接着操作系统会直接杀死这个程序。

为什么会这样呢?这是因为我们写的程序运行在用户态,而硬件资源是掌握在内核态手中的。

1.2 深入 glibc 内部

善于思考的人可能已经发现问题了:既然我们上面提到硬件资源掌握在内核态手中,那么显示器作为硬件资源,我们的程序是怎么让字符串显示在上面的呢?

实际上,printf并不是操作系统的核心功能,它只是 C 标准库提供的一个封装好的函数。提到封装我们生活中其实有很多与它息息相关的场景,就拿送快递来说把,在快递分拣中心,一般会把目的地相同的快递都集中起来,然后统一放入一辆运输车中,这辆运输车把这些快递送往目的地。而printf这个函数,就像是快递分拣中心的一个分拣员,我们要打印的字符串是很多字符的组合,我们把这些字符看作要送往相同目的地的快递,printf函数作为分拣员工作就是将这些字符全部放入缓冲区中,相当于把快递装进运输车。

而对于这段程序,最重要的就是隐藏在printf函数后面的一次系统调用

为了透过表面现象看到本质,我们需要用到 Linux 中一个强大的工具——strace

简单来说,strace是 Linux 系统下一个用来分析和调试的工具。我们可以抽象的理解为它是一个装在用户态程序内核之间的监控器,专门负责记录程序对内核发起的所有系统调用

现在,我们用 strace 去追踪上面那个编译好的程序,命令行执行下面命令:

strace ./demo

demo是我编译上面代码生成的可执行文件,这个文件名可以根据你们实际情况来定。

执行这条命令后屏幕上的内容很杂乱,由于我们写了一个while永久循环,程序现在就卡在这里,我们直接去strace输出内容的最后几行找,看到的应该是下面的内容(只截取了关键部分):

1. strace 结果.png

看到了吗?并没有printf,真正出现的是write

到这里,我们心中已经有一个比较清晰的认知了,printf只是应用层封装好的一个函数,而write才是通往操作系统核心的大门。

如下图,内核收到write系统调用的请求后,会调用相应的硬件驱动程序,从而让屏幕上显示我们要打印的字符串。

2. 框图.png

1.3 CPU 的硬件机制

像这种在用户态执行系统调用然后使 CPU 陷入内核态的机制,并不是 Linux 操作系统指定的规则,而是刻在 CPU 硬件架构里面的一条铁律。

以常见的 x86 架构为例,CPU 硬件设计了 4 个特权级别,分别为 Ring 0 到 Ring 3

▸ Ring 0(内核态):拥有最高权限并且可以执行所有 CPU 指令,可以访问所有内存地址,包括外设的寄存器,Linux 内核就在这里。

▸ Ring 1 和 Ring 2 :通常用于驱动程序,但在现代 Linux 中主要只用了 0 和 3,这两层基本空置。

▸ Ring 3(用户态):拥有最低权限,只能执行普通的运算指令,不能直接操作硬件,不能访问 Ring 0 的内存空间,所有的应用程序都在这里。

ARM 架构中,这种划分被称为 EL0 用户态EL1 内核态,原理完全一致。

1.4 为什么要这么麻烦?

读完 1.3 节,可能有人会想:为什么不让所有的程序都在 Ring 0 状态下运行,这样效率不是大大提升了吗?

请想象一下,如果所有程序都在 Ring 0:你由于疏忽大意写的一个指针程序可能会不小心覆盖掉操作系统的内存,从而导致电脑死机。你打开的一个网站上面如果有一个恶意脚本,它可以肆无忌惮的扫描你的硬盘,因为没有任何屏障可以阻挡它。

因此,将用户态与内核态隔离,是现代计算机系统稳定运行的基础。

既然我们的用户态和内核态之间存在一堵高墙将它们隔离,那么我们运行在用户态的程序,怎样才能跨越这堵高墙,去内核态办事呢?

欲知后事如何,请移步至下一章。

2. 内存布局

本章,我们要搞懂一个核心问题:用户态和内核态在内存里是怎么划分的? 弄明白这一点,我们上一章末尾留下的那个问题自然就迎刃而解了。

2.1 虚拟内存

要弄懂内存怎么划分,我们首先要理解 Linux 进程的内存模型。

当一个程序在运行时,Linux 内核会为它分配一个虚拟的内存空间。在 32 位 Linux 系统下,每个进程都觉得自己拥有完整的 4GB 内存空间,因为

2 ^ 32 = 4 GB

不论我们的物理内存有 1GB 还是16GB,进程看到的永远都是虚拟的 4GB。对于 64 位系统,这个虚拟内存的空间更大,但是原理是一致的,为了方便,我们以 32 位模型为例。

也就是说当我们运行上面那个只有几行的 Hello World 程序时,Linux 内核会欺骗它,让这个进程以为自己独占了从 0x000000000xFFFFFFFF 的完整 4GB 内存空间。

进程 A 觉得自己有 4GB,进程 B 也觉得自己有 4GB。

但是实际上,它们的数据被分散地存储在物理内存的各个角落,甚至会被挤到硬盘的 Swap 分区里面。

2.2 3:1的分割策略

在 32 位 Linux 系统中,通常将这 4GB 的空间划分为两个部分,界限通常位于0xC0000000,即 3GB 处。

  1. 用户空间:

    低 3GB ,即0x00000000 ~ 0xBFFFFFFF

    应用程序代码、全局变量、堆、栈都在这里。

    权限 Ring 3,应用程序只能操作这部分内存,随便怎么折腾,哪怕是内存泄露了,也只会祸害自己,而不会影响到外面的空间。

  2. 内核空间:

    高 1GB ,即0xC0000000 ~ 0xFFFFFFFF

    Linux 内核代码、驱动程序、内核数据结构等都在这里。

    权限 Ring 0,这部分空间只有特权代码才可以访问。

2.3 私有与共享

这两部分空间还有一个本质的区别:3GB 的用户空间是每个进程私有的,1GB 的内核空间是所有进程共享的。

两个用户进程 A 和 B 在用户态是完全隔绝的:A 看不到 B 的用户空间内容,B 也看不到 A 的,这就是进程隔离

但不管是 A 还是 B,它们的页表中高 1GB(0xc0000000 ~ 0xffffffff)的映射内容是完全相同的,都指向同一份内核物理内存

因此当进程 A 或 B 执行系统调用陷入内核时,它们使用的是当前进程的页表,但因为内核部分的映射是全局一致的,所以实际上进入的是同一个内核执行环境同一份内核数据结构

现在概括一下:对于每个进程而言,他们都有一个自己的页表,负责管理整个 4GB 地址空间,前 3GB 是每个进程私有的用户空间,后 1GB 是所有进程共享的内核空间。而对于每个进程的页表而言,页表上面的地址其实是虚拟内存,这些虚拟内存都会映射到物理内存的真实地址上。就映射而言,不同进程的页表上的用户空间(私有)会映射到物理内存的不同位置,而所有进程的页表上的内核空间(共享)映射到了相同的物理内存区域。

当然,这样设计肯定是有原因的。

想象一下,如果不将内核映射到每个进程的虚拟空间里面,而是单独独立出去。那么当进程调用 write 时,CPU 就必须切换整个虚拟地址空间,也就是切换到内核专用的页表,这样效率是极低的。

而按照目前的这种设计来看,当进程陷入内核态时,CPU 只需要切换一下权限等级,就可以直接访问同一块虚拟地址空间的高 1GB 区域,不需要切换页表,访问效率高。

2.4 内存越界的监管者

既然虚拟内存已经划分好了,那么谁来充当这个监管者,来监管内存操作是否越界呢?如果我在用户态的代码里强行写上一句 int *p = 0xC0000000; *p = 1; 试图去修改内核空间的数据,会发生什么?

答案是 CPU 硬件会直接报错,从而使操作系统杀死这个进程。

我们在上一节中提到过页表,但页表的功能可不仅仅是将虚拟地址和真实物理地址来回翻译这么简单,它还存储了极其重要的权限位。

在 x86 架构的页表项(PTE) 中,有一个专门的U/S标志位

  1. S(Supervisor) :只有运行在 Ring 0、1、2 特权等级时才能访问该页面。
  2. U(User) :任何特权级都能访问。

Linux 内核在初始化时,会将高 1GB 空间对应的页表项设置为U/S = 0,标记为 Supervisor ,只有内核态可访问。而低 3GB 设置 U/S = 1,标记为 User

然后当 CPU 的内存管理单元 (MMU) 进行地址翻译时,它会同时检查当前 CPU 的特权级目标页面的权限位

如果当前进程的特权等级为 Ring 3,并且试图访问一个标记为 Supervisor 的页面,此时 MMU 会立刻触发一个缺页异常(Page Fault) ,并且把错误码标记为权限违规,而 Linux 内核会捕获这个异常,并向这个进程发送SIGSEGV信号,然后这个进程就会以 Segmentation Fault 崩溃结束了。

因此,我们完全可以认为内存管理单元(MMU) 是保障内存访问不越界的强制手段。

2.5 一个进程两个栈

我们知道,函数的调用和局部变量的存储都依赖于。既然进程的内存被分成了两部分,那么一个进程运行时,它的栈到底在哪呢?

事实上,每个进程都有两个栈,分别为用户栈内核栈

用户栈位于用户空间,当进程在用户态运行时,CPU 的栈指针寄存器指向用户栈,用户栈空间极其庞大,并且可以动态增长。

内核栈位于内核空间,每个进程(严格来讲其实是每个线程)在创建时,内核都会为它分配一个专属的,固定大小的栈,平时内核栈是空的,只有当进程陷入内核态时,CPU 才会在这里干活。

当执行系统调用的一瞬间,CPU 会从用户态切换到内核态,这个过程中硬件会自动完成栈指针的切换,细节如下:

  1. CPU 将当前的用户栈指针保存到内核栈的底部。
  2. CPU 将栈寄存器指向内核栈的顶部。
  3. 开始执行内核代码。
  4. 当系统调用执行完毕,要返回时,再从内核栈把刚才保存的用户栈指针恢复回来,然后继续在用户态运行。

进程内存的布局图如下:

3. 内存布局.png

3. 跨越内存边界的三种方法

明白了用户态和内核态的内存划分后,我们又面临着一个实际的问题:处于 Ring 3 特权等级的应用程序,经常需要读写硬盘、发送网络数据、或者获取系统时间,而这些资源都由 Ring 0 的内核掌控着。

我们的应用程序怎样才能安全的跨越内存边界,去寻求内核的帮助呢?

要知道,CPU 并不是随意让程序切换状态的,它只设计了三个特定的行为,其他任何越界行为都会被视为非法入侵。

3.1 系统调用

系统调用是最常见,也是最合法的越界行为,指用户态进程主动要求切换到内核态,去申请特定的操作系统服务。

举几个常见的例子:openreadwriteforksocket等都是系统调用。

假如我们的代码调用了read来读取文件,在 x86(64位) 系统下,CPU 提供了专用的特权指令,即syscall,下面描述一下系统调用的细节:

  1. 用户程序将系统调用号放入 RAX 寄存器,将参数放入 RDIRSI 等寄存器。系统调用号是 Linux 内核用来标识具体系统调用的唯一数字编号,用户程序在发起系统调用时,需要把这个号传给内核,内核根据它找到对应的处理函数。
  2. 做好准备后,用户程序执行syscall指令。
  3. MSR 是一组位于 CPU 内部的特殊寄存器,其中有一个最重要的寄存器叫 MSR_LSTARMSR_LSTAR 指向的是一个叫做 entry_SYSCALL_64 的用汇编写的总入口函数。用户程序执行 syscall 指令后,会先读取 MSR_LSTAR 寄存器,同时 CPU 自动将权限从 Ring 3 提升为 Ring 0,此外 CPU 还会将 RIP 指针指向 entry_SYSCALL_64 这个总入口函数。
  4. 进入 entry_SYSCALL_64 后,内核代码会先保存现场,然后读取我们前面传入 RAX 寄存器的系统调用号,将这个系统调用号作为数组下标传入一个名为sys_call_table的数组,这个数组是系统调用表,里面存放的是所有系统调用对应的函数指针。到此,我们就通过系统调用号找到了该系统调用对应的函数地址
  5. 系统调用执行完毕后需要返回,这时执行 sysret 指令,权限降回 Ring 3,返回用户程序下一条指令继续执行。

3.2 异常

异常是指 CPU 在执行指令期间,自己发现程序没法继续往下执行了,因此必须暂停寻求帮助。

我们拿常见的缺页异常来举例,当程序申请了一块内存但还没有写入,此时操作系统还没给这块虚拟内存分配真正的物理内存,现在当我们试图写入这块内存时:

  1. CPU 执行 mov 指令写入内存时,MMU 硬件检测到对应的页表项是空的。
  2. 这时 CPU 会立即暂停当前指令的执行,产生一个编号为 14 的异常,也就是 Page Fault 缺页异常
  3. 然后 CPU 拿着异常号 14 查找 IDT (中断描述符表) ,IDT 的第 14 项里面记录了处理函数的地址(CS:RIP) ,更重要的是,这一项的段选择子(Segment Selector) 明确标记了目标代码段是 Ring 0 权限。就在 CPU 读取 IDT 并加载新的代码段寄存器的这一瞬间,CPU 的权限等级从 Ring 3 切换到了 Ring 0。
  4. 权限提升后,CPU 硬件会自动从 TSS (任务状态段) 中找到当前进程的内核栈指针并将栈寄存器从用户栈切换到内核栈,然后往内核栈里压入原来的用户态现场信息。
  5. 之后 CPU 跳转到 do_page_fault 函数开始执行内核代码,执行这个处理函数时。如果内核发现只是没分配物理页,那就分配物理内存,更新页表,然后执行 iret 指令,CPU 恢复特权级的同时重新执行刚才那条失败的 mov 指令,这次就成功了。如果内核发现你访问了非法地址,就会直接发送 SIGSEGV 信号,导致进程崩溃。

3.3 中断

中断是唯一跟当前正在执行的指令毫无关系的事件,它完全由 CPU 外部的信号触发。

举个例子,假如网卡收到了一个数据包:

  1. 网卡产生一个电信号,发送给 中断控制器 (APIC)
  2. 中断控制器判断优先级后,通过一根引脚 INTR 给 CPU 发送信号。
  3. CPU 每执行完一条指令后,都会检查一下引脚看有没有中断信号。
  4. 当 CPU 发现这个信号时,会自动关闭中断响应,这时为了防止套娃。然后 CPU 将当前程序的运行状态(寄存器、返回地址等)压入内核栈保存起来。
  5. 然后根据中断号查 IDT 中断描述符表,同样地,在加载 IDT 中记录的中断处理函数地址(CS:RIP) 时,CPU 依据段描述符的属性,瞬间将权限从 Ring 3 提升为 Ring 0。
  6. 硬件自动查找 TSS,加载内核栈指针,将用户态的 SS, RSP, EFLAGS, CS, RIP 统统压入内核栈
  7. 执行 iret 中断返回指令,恢复刚才被中断的程序继续运行,就像什么都没发生过一样。

3.4 小结

现在我们可以简单总结一下跨越内存边界的三种方法了。

系统调用 syscall 是代码写出来的,处理完,继续执行下一条指令。

异常 Exception 是CPU 指令执行出错引发的,故障处理完,要重新执行当前这条指令。

中断 Interrupt 是外设硬件发出的,处理完,继续执行下一条指令。

4. 一次系统调用的全过程

在理解了内存布局和跨越边界的原理之后,让我们把视线拉回现实。

当我们在代码里写下一行简单的 read(fd, buf, 1024) 时,操作系统内部实际上发生了一场复杂的惊心动魄的接力赛,这不仅仅是权限的切换,更是涉及 CPU、内存、硬盘和 DMA 的协同工作。

4.1 从用户态进入内核态

假如用户进程申请了一个 1KB 的缓冲区 buf ,这个缓冲区位于用户空间的中,现在我们调用read试图读取硬盘上的一个文件。

Glibc 封装函数将系统调用号填入 RAX,将 fdbuf 指针、1024 长度填入参数寄存器。

然后执行syscall指令。

再往后就是我们前面讲过的,CPU 权限提升到 Ring 0,RSP 从用户栈切换到内核栈。

虽然 syscall 很快,但随之而来的 TLB页表缓存刷新CPU 缓存(L1/L2 Cache) 才是影响性能的关键因素,因为进入内核态后,CPU 执行的是内核代码,用户态的热数据会被挤出缓存。

4.2 等待调度

内核根据该系统调用对应的函数地址,进入 sys_read 函数,文件系统发现数据不在内存中,因此必须去读硬盘。

这时,内核向硬盘控制器发起指令,让它把这块数据读出来并放在内存里面。

但是,硬盘是机械设备或 SSD,速度比 CPU 慢几十万倍,CPU 不可能在这里干等着,于是内核会将当前进程的状态标记为 睡眠,该进程从正在运行的进程列表中移除,不会再被 CPU 调度

然后调度器会介入,把 CPU 让给别的进程,注意,这里发生了一次进程上下文切换,比单纯的系统调用切换开销大得多。

在 CPU 忙别的事时,DMA (Direct Memory Access) 控制器负责把数据从硬盘搬运到内核空间的缓冲区。这里非常关键,硬盘数据不能直接搬到用户空间的 buf 里,因为用户空间的 buf 对应的页可能会被换页到磁盘 swap 分区 中,或者硬盘控制器根本就没有权限访问用户空间,此外,内核需要控制缓存一致性,page cache 是全局共享的,而用户缓冲区是私有的。

4.3 中断唤醒

DMA 控制器干完活之后,会向 CPU 发出一个中断信号。

这时 CPU 可能在执行别的进程,收到中断信号后会被打断,暂停执行当前进程,保存现场。

然后 CPU 执行硬盘驱动的 ISR 中断服务程序,当驱动程序确认数据已经就绪后,会通知调度器,可以唤醒刚才读取这个文件的进程了。

唤醒之后,该进程重新加入正在运行的进程列表,等待 CPU 的调度。

4.4 完成读取

当调度器选中我们读取文件的进程之后,会继续执行 sys_read 剩下的代码。

目前,数据位于内核空间的 Page Cache 中,内核必须使用copy_to_user(buf, kernel_data, 1024)把它拷贝到我们在 4.1 提供的用户空间 缓冲区buf 中。这时 CPU 会严格检查 buf 指针是否合法,防止内核向非法地址写入导致崩溃。

sys_read 执行完毕后,执行 sysret

权限降回 Ring 3,栈切回用户栈,指令跳转回用户程序。

最后read() 函数返回读取的字节数,整个过程到此结束。

如下,是整个过程的流程图:

4. 完整流程.png

4.5 性能分析

从整个流程中,我们可以看出 I/O 操作慢的真相,这不仅仅是因为硬盘慢,更是因为软件层面的繁琐手续。

一次涉及硬盘读取的 read 操作,如果不凑巧发生了阻塞,将引发:

  1. 多次模式切换:CPU 会在用户态与内核态之间反复横跳,虽然每次模式切换很快(纳秒级),但这会伴随着 CPU 流水线的打断。
  2. 昂贵的进程上下文切换:这里的耗时是十分严重的,上面我们已经提到过,当进程 A 休眠时,CPU 会去运行进程 B。这意味着 CPU 必须把进程 A 的页表撤下来,换上进程 B 的。后果是 TLB(页表缓存) 全部失效,CPU 的 L1/L2 缓存中的数据也可能被进程 B 的数据覆盖掉,当后面进程 A 醒来时,面临的是一个冷的 CPU,访问内存速度变慢。
  3. 两次数据拷贝:硬盘到内核缓冲区(DMA),内核缓冲区到用户缓冲区。

在高性能网络编程提到的 4 次上下文切换,通常是指一次 Read + 一次 Write 的全过程,而在涉及磁盘阻塞 I/O 的场景下,真实的调度开销远比那个模型更复杂。

内核态到用户态的切换和拷贝是极其耗时的,因此,后来才发明了 mmap 内存映射sendfile 零拷贝技术。

这里只做简单介绍:

mmap:让用户空间和内核空间共享同一块物理内存,免除了后面那次数据拷贝。

sendfile:直接在内核内部把数据从磁盘发往网卡,完全绕过用户态,实现了零拷贝。

5. 总结

写到这里,我们已经从printf开始,了解了进程的 4GB 内存布局以及跨越内存边界的方法,还分析了一次系统调用的完整流程。

现在,让我们先放下细节,将目光投向 I/O 优化的演进,看看 Linux 是如何解决我们通篇分析的核心痛点——昂贵的上下文切换多余的数据拷贝

5.1 传统 I/O 优化的极限

io_uring 出现之前,高性能网络服务器的基石是 I/O 多路复用,其中效率最高的无疑是 epoll

epoll 相比于古老的 selectpoll 已经有了很大的效率提升,它解决了惊群效应无效轮询的问题。但是epoll 依然存在着无法弥补的缺陷:

epoll 的工作模式是:先通过 epoll_wait() 这一个系统调用,去查询哪些文件描述符准备好了,然后需要在用户态 for 循环遍历这些就绪的文件描述符,再挨个调用 read()write()办理真正的 I/O。

这意味着,即使有 1000 个连接同时就绪,你依然需要发起 1 次 epoll_wait + 1000 次 read/write 系统调用,在高并发场景下,这依然会导致频繁的用户态与内核态之间的切换。

epoll 已经把同步 I/O优化到了极致,但它仍然是同步的。内核只是帮忙看了看谁准备好了,真正的读写动作还是需要自己一次次地去发起。

5.2 io_uring

io_uring 的核心思想是:让应用和内核成为异步的生产者-消费者,它的实现依赖于两个位于共享内存中的环形缓冲区

  1. 提交队列 (Submission Queue - SQ) :应用层是生产者,内核是消费者。应用想发起 I/O 时,不再执行 syscall,而是构造一个请求,然后把它扔进 SQ 队列里,可以一次性扔 1 个,也可以一次性扔 1000 个。
  2. 完成队列 (Completion Queue - CQ) :内核是生产者,应用层是消费者。内核在后台默默地处理完 SQ 里的请求后,把结果放回 CQ 队列,应用层只需要在自己方便的时候去 CQ 队列里拿即可。

那么io_uring是如何解决核心痛点的呢?

  1. 应用层把请求扔进 SQ 队列,这个动作全程在用户态,只是简单的内存写入,没有任何 syscall。在最极致的模式下(内核轮询 ),内核会有一个专门线程帮忙盯着 SQ,应用层甚至都不需要通知内核来取任务。
  2. 我们可以提交的不仅仅是读写,可以是 open, connect, accept 等几乎所有 I/O 相关的操作,只需要把任务清单一次性交给内核,然后就可以彻底撒手不管了。
  3. io_uring 支持固定缓冲区。你可以提前把一块内存注册给内核,这样内核可以直接在这块内存上进行 I/O,避免了数据在内核缓冲区和用户缓冲区之间的额外拷贝

5.3 构建性能优化观念

回顾全文,从 syscall 的微观实现,到阻塞 I/O 的宏观流程,再到 io_uring 的终极形态,我们能构建起一套坚实的性能优化世界观:

性能瓶颈的根源往往在于跨越边界的成本太大,无论是用户态与内核态的边界,还是 CPU 与 I/O 设备的边界。

我们优化的方向,始终围绕着两个核心目标,就是减少切换减少拷贝

从同步阻塞,到 I/O 多路复用,再到真正的异步,本质上都是在提升 CPU 的利用率,让它从极其耗时的等待切换中解脱出来,去做真正有价值的计算。

最终,这些不经意间深入脑海的底层知识,会内化为我们编写高性能代码的直觉。当我们下次遇到性能瓶颈时,脑海里浮现的将不再是模糊的感觉起来很慢,而是具体的性能损耗点,比如:

是不是系统调用太频繁了?是不是 copy_to_user 拷贝的数据量太大了?是不是线程切换过于频繁,导致 CPU 缓存命中率下降了?在这个场景下,能不能用 io_uring 把它变成零切换?

这才是我们探索用户态与内核态底层原理的意义。