深色模式
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 秒。
三个优化手段:
- 批量:
readv/writev一次传多个缓冲区;epoll_wait一次返回多个事件。 - vDSO:把
gettimeofday、clock_gettime这类"只读内核数据"的调用做成用户态可直接读的共享内存,完全不走系统调用。 - 减少拷贝:
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 / 批量 / 零拷贝)和其他知识的连接
- < 来自 进程与线程:谁在跑,跑的时候带着什么:
fork/clone/execve都是系统调用;上下文切换发生在内核态 - < 来自 虚拟内存:让每个程序都以为自己独占一台机器:缺页是异常,处理在特权级切换后进行;页表只能内核改
- < 来自 IO 模型与零拷贝:等设备的时候,CPU 在干什么:所有 IO 系统调用都要过这道门;
epoll_create/epoll_wait都是系统调用 - > 影响 分层模型与一次请求的一生:
send/recv是系统调用,数据在用户缓冲区和内核 socket 缓冲区之间来回 - > 影响 工程实践:容器(Docker)的隔离靠 namespace + cgroup,但共享同一个内核——这就是"容器不如虚拟机隔离强"的根本原因
自测 3 问
Q1:为什么系统调用时用户态和内核态必须用两个不同的栈?
点开看答案
因为用户栈不可信。
如果内核继续用用户栈,用户程序可以:
- 故意把栈指针指到一个精心构造的位置,让内核的局部变量覆盖到内核数据;
- 或者栈空间不够时,内核写入会踩到用户的其他内存。
用独立的内核栈,等于给内核一块用户碰不到的工作区。这也是为什么内核栈通常很小(8~16KB)且不能递归太深——栈溢出会直接崩内核。
Q2:gettimeofday 为什么可以不走系统调用?
点开看答案
因为内核把"当前时间"这块数据放在一页只读的共享内存里,映射到每个进程的地址空间(vDSO)。
用户程序调用 gettimeofday 时,库函数直接读这块内存 + 用 CPU 的 rdtsc 指令补偿,完全在用户态完成,一次特权级切换都没有。
前提是:这个操作只读、不需要内核做任何决策。一旦需要内核参与(比如改时间、需要加锁),就必须走真正的系统调用。
Q3:容器为什么"隔离性不如虚拟机"?
点开看答案
因为容器共享宿主机的内核,只是用 namespace 让进程"看不见"别的进程和网络,用 cgroup 限制资源用量。
推论:
- 内核漏洞(提权漏洞)一旦被利用,容器里能直接影响宿主机内核 > 逃逸。
- 容器里的进程看到的
/proc、内核版本、系统调用表都是同一个。 - 无法运行不同内核版本(比如在 Linux 上跑 Windows 容器)。
虚拟机有独立的 Guest OS 内核,跑在 hypervisor 之上,隔离边界是硬件虚拟化,强得多——代价是启动慢、资源开销大。
类比失效处 / 易错点
- "前厅后厨"会让人以为切换是"走过去"。实际是 CPU 硬件在一条指令内完成特权级切换,没有"走过去"的时间,但有保存现场和缓存污染的开销。
- "系统调用就是函数调用"是错的。普通函数调用只压栈跳转,系统调用要换特权级、换栈、查表、校验。
printf不是系统调用。它是 C 库函数,内部可能调write。malloc也不是,它内部用brk/mmap。区分"库函数"和"系统调用"是常见考点。- 中断不等于异常,也不等于信号。信号是内核发给进程的软件通知(
SIGSEGV),是异常处理之后可能产生的结果,不是同一层的东西。 - "内核态可以做任何事"要加限定。内核态也不能违反硬件约束(比如访问不存在的物理地址仍会崩),只是它有权限配置这些约束。
✅ 验收清单
- 能说出用户态和内核态各不能做什么,并举 3 个"特权指令"的例子
- 能按顺序说出系统调用的 6 个关键步骤(传号 > 陷入 > 保存现场 > 查表 > 校验 > 返回)
- 能解释"为什么用户栈和内核栈必须分开"
- 能区分中断、异常、陷阱,各举一个例子
- 能说出系统调用慢在哪,以及至少两种优化手段
- 能用"共享内核"解释容器隔离性弱于虚拟机的原因
- 合上书,用自己的话讲 3 分钟,中途不卡壳