Skip to content

05 · 内核态与系统调用:用户程序怎么"申请"做事 ​

这篇篇幅最短,但它是前四篇的"总闸门"——进程、内存、锁、IO,用户程序想用任何一样,都必须从这里过。

一句话本质 ​

硬件把 CPU 分成两个特权级:用户态只能算数,内核态才能碰硬件和别人的内存。系统调用就是那道唯一合法的门。

生活类比:餐厅的前厅与后厨 ​

  • 用户态:前厅。客人能看菜单、能点菜、能聊天,但进不了后厨。
  • 内核态:后厨。能开火、能碰刀、能拿食材,也能碰所有客人的东西。
  • 系统调用:按铃叫服务员。这是唯一的通道,你不能自己推门进去。
  • 特权级检查:服务员会核对"你点的这道菜后厨能不能做",不合法就退回(返回 EPERM)。
  • 中断:后厨的定时器响了,厨师必须停下手上活去处理(客人无法拒绝)。

关键:后厨只有一个,前厅有很多桌。所以后厨必须极其可靠——它一崩,所有客人一起完。

核心机制 ​

一、特权级:为什么需要两态 ​

x86 有 4 个环(Ring 0 ~ Ring 3),实际只用两个:
  Ring 3  用户态:不能执行特权指令(改页表、关中断、直接读写端口)
  Ring 0  内核态:全部权限

一条指令能不能执行,由 CPU 硬件判断当前特权级,不是软件判断
> 所以用户程序"想"绕过也没用,硬件直接给你一个 #GP 异常

哪些指令是特权的:修改 CR3(页表基址)、关中断(cli)、读写 IO 端口(in/out)、加载 IDT(中断向量表)、halt CPU。这些恰好都是"能破坏整个系统"的操作。

二、一次系统调用的完整过程 ​

以 read(fd, buf, 100) 为例:

用户态
  |
  ① 把系统调用号(read 是 0)放进寄存器 eax/rax
  |  参数放进 ebx/ecx/edx(或 rdi/rsi/rdx)
  |
  ② 执行 syscall 指令(或 int 0x80)
  |  < 这一条指令让 CPU 硬件完成:切换特权级、跳到内核预设的入口
  v
内核态
  |
  ③ 硬件自动保存:返回地址、标志寄存器、栈指针 > 切到内核栈
  |  (注意:不是保存"所有寄存器",是按需保存,这是优化的关键)
  |
  ④ 查系统调用表 sys_call_table[0] > 找到 sys_read
  |
  ⑤ 检查参数合法性(fd 是否有效?buf 是否在用户地址空间?有没有读权限?)
  |  < 这一步至关重要,防止用户传一个内核地址让内核帮忙读
  |
  ⑥ 执行真正的读操作(可能阻塞,此时进程进入阻塞态 > 触发调度)
  |
  ⑦ 把结果放进返回值寄存器
  |
  ⑧ 执行 sysret / iret 返回
  v
用户态
  ⑨ 继续执行下一条指令,read 的返回值就是读到的字节数

必考细节:

  • 用户态和内核态用的是两个栈。切换时栈指针要换(从用户栈换到内核栈),否则用户程序能通过栈溢出影响内核。
  • 不是所有寄存器都保存。x86-64 的 syscall 只保存必要的几个(rcx、r11),其余由内核自己决定要不要存。这比"保存全部寄存器"快得多。
  • 参数不能直接传指针就完事。内核必须验证这个指针指向用户空间(用 access_ok),否则用户传个内核地址就能读内核数据——这是经典漏洞来源。

三、中断 / 异常 / 陷阱:三种"打断" ​

类型来源同步/异步例子
中断外部硬件异步网卡收到包、磁盘读完、定时器到点
异常CPU 执行指令时出错同步除零、缺页、非法指令、段错误
陷阱(trap)程序主动触发同步系统调用、断点 int3

共同点:都会让 CPU 从用户态切到内核态,都走"中断向量表 > 处理程序"这条路。

差别:中断随时可能来(异步),异常和陷阱是"执行到某条指令"才发生(同步)。

四、为什么系统调用"慢" ​

一次系统调用的开销:
  ① 特权级切换(约几十纳秒)
  ② 保存/恢复寄存器现场
  ③ 切换栈
  ④ 可能污染 TLB 和 CPU 缓存(内核代码和数据挤掉了用户的热数据)
  ⑤ 参数校验

现代 CPU 上大约 几百纳秒到 1 微秒。听起来不多,但如果你在一个循环里调 100 万次,就是 1 秒。

三个优化手段:

  1. 批量:readv/writev 一次传多个缓冲区;epoll_wait 一次返回多个事件。
  2. vDSO:把 gettimeofday、clock_gettime 这类"只读内核数据"的调用做成用户态可直接读的共享内存,完全不走系统调用。
  3. 减少拷贝:mmap、零拷贝(见 04 篇)。

五、宏内核 vs 微内核(了解即可,但常被问) ​

宏内核(Linux):所有服务(文件系统、驱动、网络协议栈)都在内核态
  优点:模块间直接函数调用,快
  缺点:一个驱动有 bug,整个系统崩

微内核(QNX、seL4):内核只保留最基础的东西(调度、IPC、内存),其他都做成用户态服务
  优点:隔离好,驱动崩了不影响内核
  缺点:服务之间要靠 IPC 通信,频繁切换,性能差

Linux 走的是宏内核 + 模块化(驱动可以动态加载),实际工程上更实用。

为什么这么设计 ​

反事实 1:如果所有程序都在内核态运行 ​

  • A 程序一个野指针,改掉 B 程序的数据 > 数据错乱,且无法追查是谁干的。
  • A 程序死循环且关掉中断 > 整个系统永久卡死,只能重启。
  • 任何程序都能读你的密码、改你的文件,操作系统毫无意义。

反事实 2:如果没有系统调用(用户程序直接操作硬件) ​

  • 每个程序都要自己写网卡驱动、磁盘驱动 > 不可能。
  • 两个程序同时操作同一块磁盘 > 数据损坏。
  • 没有统一的资源分配 > 谁先抢到算谁的,无法公平。

系统调用的本质是**"把危险的、需要协调的操作收归中央"**。中央就是内核,它同时扮演三个角色:权限检查员、资源调度员、设备代理。

反事实 3:如果系统调用不需要切换特权级 ​

那就等于取消隔离。所以"系统调用慢"不是设计缺陷,是隔离的代价。所有优化(vDSO、批量、零拷贝)都是在"不放弃隔离"的前提下省开销。

面试怎么问 ​

Q:用户态和内核态的区别?为什么要分?

区别在特权级:用户态不能执行特权指令(改页表、关中断、访问 IO 端口),内核态可以。 为什么要分:隔离与保护。不分的话,一个程序的 bug 能搞崩整个系统,任何程序都能读别人的数据。

Q:系统调用的过程?

按上面 ①~⑨ 说,重点突出三件事:传调用号 > 硬件切特权级 > 查表执行 > 校验参数 > 返回。 加分点:用户栈和内核栈是两个栈;参数里的指针必须校验。

Q:中断和异常的区别?

中断是外部硬件异步触发(网卡、定时器),异常是 CPU 执行指令时同步产生(缺页、除零)。系统调用属于陷阱,是程序主动触发的同步事件。

Q:为什么系统调用开销大?能优化吗?

开销来自特权级切换 + 保存现场 + 切栈 + 缓存污染。优化手段:vDSO(把只读调用做成用户态直接读)、批量调用、mmap 减少拷贝、io_uring 把多次调用合并成一次提交。

追问链:

系统调用怎么进入内核?
  > 为什么不能直接 call 内核函数?(特权级不同,硬件不允许)
    > 切换时保存了什么?(返回地址、标志位、栈指针 > 切内核栈)
      > 内核怎么知道要执行哪个调用?(eax 里的系统调用号 > 查表)
        > 用户传个内核地址进来会怎样?(必须校验,否则是提权漏洞)
          > 每次调用都这么贵,怎么优化?(vDSO / 批量 / 零拷贝)

和其他知识的连接 ​

自测 3 问 ​

Q1:为什么系统调用时用户态和内核态必须用两个不同的栈?

点开看答案

因为用户栈不可信。

如果内核继续用用户栈,用户程序可以:

  • 故意把栈指针指到一个精心构造的位置,让内核的局部变量覆盖到内核数据;
  • 或者栈空间不够时,内核写入会踩到用户的其他内存。

用独立的内核栈,等于给内核一块用户碰不到的工作区。这也是为什么内核栈通常很小(8~16KB)且不能递归太深——栈溢出会直接崩内核。

Q2:gettimeofday 为什么可以不走系统调用?

点开看答案

因为内核把"当前时间"这块数据放在一页只读的共享内存里,映射到每个进程的地址空间(vDSO)。

用户程序调用 gettimeofday 时,库函数直接读这块内存 + 用 CPU 的 rdtsc 指令补偿,完全在用户态完成,一次特权级切换都没有。

前提是:这个操作只读、不需要内核做任何决策。一旦需要内核参与(比如改时间、需要加锁),就必须走真正的系统调用。

Q3:容器为什么"隔离性不如虚拟机"?

点开看答案

因为容器共享宿主机的内核,只是用 namespace 让进程"看不见"别的进程和网络,用 cgroup 限制资源用量。

推论:

  1. 内核漏洞(提权漏洞)一旦被利用,容器里能直接影响宿主机内核 > 逃逸。
  2. 容器里的进程看到的 /proc、内核版本、系统调用表都是同一个。
  3. 无法运行不同内核版本(比如在 Linux 上跑 Windows 容器)。

虚拟机有独立的 Guest OS 内核,跑在 hypervisor 之上,隔离边界是硬件虚拟化,强得多——代价是启动慢、资源开销大。

类比失效处 / 易错点 ​

  • "前厅后厨"会让人以为切换是"走过去"。实际是 CPU 硬件在一条指令内完成特权级切换,没有"走过去"的时间,但有保存现场和缓存污染的开销。
  • "系统调用就是函数调用"是错的。普通函数调用只压栈跳转,系统调用要换特权级、换栈、查表、校验。
  • printf 不是系统调用。它是 C 库函数,内部可能调 write。malloc 也不是,它内部用 brk/mmap。区分"库函数"和"系统调用"是常见考点。
  • 中断不等于异常,也不等于信号。信号是内核发给进程的软件通知(SIGSEGV),是异常处理之后可能产生的结果,不是同一层的东西。
  • "内核态可以做任何事"要加限定。内核态也不能违反硬件约束(比如访问不存在的物理地址仍会崩),只是它有权限配置这些约束。

✅ 验收清单 ​

  • 能说出用户态和内核态各不能做什么,并举 3 个"特权指令"的例子
  • 能按顺序说出系统调用的 6 个关键步骤(传号 > 陷入 > 保存现场 > 查表 > 校验 > 返回)
  • 能解释"为什么用户栈和内核栈必须分开"
  • 能区分中断、异常、陷阱,各举一个例子
  • 能说出系统调用慢在哪,以及至少两种优化手段
  • 能用"共享内核"解释容器隔离性弱于虚拟机的原因
  • 合上书,用自己的话讲 3 分钟,中途不卡壳