Theory · OS · Overview
内核态与用户态 · 系统调用 · 中断与异常 · 内核架构 —— 一切上层八股(进程 / 内存 / 文件 / I/O)的地基
CPU 用特权级把世界劈成两半:用户态跑应用,内核态独占危险指令——保护与共享并存
用户态进内核只有三条路:系统调用(主动)、中断(异步)、异常(同步)——所有切换都走这条窄门
宏内核要性能,微内核要隔离,混合内核两头都要——Linux 的选择决定了服务端开发的所有直觉
Why an OS at All
// 你写的代码只有一行 printf("hi"); // 但"让这两个字符出现在屏幕上"这件事, // 你的程序一步都做不了: // ① 它不知道终端/显卡在哪、怎么驱动 // ② 它不知道现在该不该轮到它用这块硬件 // ③ 它也无权直接去碰 —— 试了会被 CPU 拦下 // // 于是只能:写(1, "hi", 2) —— 请内核代劳 // 一次函数调用 ~1 纳秒 // 一次系统调用 ~100 纳秒(贵 100 倍)
每个程序都得自带全部硬件驱动,换一块网卡、换一台机器就要重写;而且必须独占整机(没法同时跑两个程序),因为没人替它们划地盘、排时间。早期计算机正是这么工作的:一次只跑一个作业。
① 抽象:把"磁盘扇区"包装成文件、把"物理内存"包装成虚拟内存、把"CPU 时间片"包装成进程——程序只跟抽象打交道,可移植性由此而来。② 仲裁:谁先用、用多久、能不能用,由内核统一裁决,程序之间彼此隔离。
凡是涉及共享资源的操作,都不能直接做,只能请求内核代做。进这道门要换特权级、保存现场、查表分发——大约一百纳秒,比一次普通函数调用贵两个数量级。本 deck 讲的就是这道门:它为什么存在、怎么进、有多贵、还有哪些别的入口。
先看清 OS 的两个角色与分层地图 → 再讲内核态/用户态这道隔离墙 → 然后完整走一遍一次系统调用(最重要的门)→ 补上中断与异常两扇侧门 → 算清切换成本 → 最后看内核架构之争与 Go 视角。
Prerequisites & Glossary
| 术语 | 一句话理解(先记住这个,细节后面展开) |
|---|---|
| 内核 kernel | 常驻内存、拥有全部权限的那段程序,负责替所有应用管理硬件 |
| 用户态 / 内核态 | CPU 的两种运行状态:用户态受限,内核态无所不能 |
| 特权级 ring | x86 硬件提供的权限分级(0–3),实际只用 ring 0(内核)与 ring 3(应用) |
| 系统调用 syscall | 应用主动请求内核做事的正式接口,进内核最主要的门 |
| 陷入 trap | CPU 从用户态切进内核态这个动作本身(不论因何而起) |
| 中断 interrupt | 异步事件:CPU 之外的设备(时钟、网卡)随时打断 CPU |
| 异常 exception | 同步事件:由当前正在执行的那条指令引发(缺页、除零、断点) |
| 上下文切换 | 把 CPU 从 A 交给 B:保存 A 的寄存器现场、装入 B 的 |
| 系统调用号 / 调用表 | 用整数编号指代要调用哪个服务;内核按号在一张表里查出对应函数 |
| 宏内核 / 微内核 | 宏内核把子系统全放进内核态(快);微内核只留最小核心,其余做用户态服务(隔离好) |
先读这两篇再回来,本 deck 默认你已经知道它们:
进程 · 线程 · 协程 → 被调度的"程序"到底是什么
虚拟内存 → "地址空间"是怎么把进程隔开的
平时你的代码在 CPU 上全速、直接地跑;只有当它要碰共享资源(读写文件、收发网络、申请内存)时,才通过唯一的一道门进内核,由内核代为执行并接受检查。——"直接执行"保证了快,"受限"保证了安全,本 deck 全部内容都是这两个词的实现细节。
进内核的路只有三条:你主动请求(系统调用)、设备打断你(中断)、你执行出错(异常)。除此之外没有任何第四条路。记住这三条,后面每一页的分类都能对号入座。
Two Roles of an OS · 回指开场:printf 做不了主
CPU、内存、磁盘、网卡在进程之间是稀缺且共享的。OS 负责分配、复用、隔离、回收——谁用多久 CPU(调度)、谁能用哪段内存(MMU + 页表)、文件落在磁盘哪里(文件系统)。
应用不直接碰硬件,而是使用 OS 给的"假象":进程抽象 CPU、虚拟内存抽象物理内存、文件抽象磁盘块、socket 抽象网卡。抽象换来了可移植性与保护。
进程管理、内存管理、文件管理、设备管理——本系列 12 个 deck 就是按这四块 + 前置概念展开的。
Kernel Mode vs User Mode
如果任何程序都能执行特权指令(关中断、写页表基址、直接 I/O),一个 bug 就能毁掉整机、恶意程序可以读任意内存。CPU 硬件提供特权级(x86 的 ring 0–3,实际只用 0 和 3),配合 MMU 让危险指令只在内核态生效——用户态执行会触发异常。
执行全部指令集,访问全部内存。内核代码与共享的内核地址空间(高半区)驻留在这里。
受限指令集 + 独立虚拟地址空间。进程之间互不可见,碰不到其他进程与内核的内存。
① 系统调用:进程主动请求服务;② 中断:外部设备异步打断(时钟、I/O 完成);③ 异常:指令执行出错或特殊事件(缺页、除零、断点)。
| 对比 | 用户态 | 内核态 |
|---|---|---|
| 特权级 | x86 ring 3 | x86 ring 0 |
| 特权指令 | 禁用(触发异常) | 全部可用 |
| 地址空间 | 各进程独立虚拟空间 | 内核空间 + 当前进程用户空间 |
| 崩溃影响 | 仅该进程挂掉 | 整个系统 panic |
| 典型代码 | 应用逻辑、glibc、Go runtime | 调度器、驱动、VFS、协议栈 |
Anatomy of a System Call
Calling Conventions · man 2 syscall
| 架构 | 陷入指令 | 调用号 | 参数寄存器 |
|---|---|---|---|
| x86-64 | syscall | rax | rdi rsi rdx r10 r8 r9 |
| i386(legacy) | int $0x80 | eax | ebx ecx edx esi edi ebp |
| arm64 | svc #0 | w8 | x0–x5 |
走软件中断:查中断向量表(IDT)+ 完整权限检查,路径长。x86-64 的 syscall/sysret 是专门的快速切换指令,入口地址存在 MSR 寄存器,硬件直跳——这是 x86-64 上系统调用比 32 位快的原因之一。
x86-64 调用号约 350 个(Linux 6.x,含少量占位未实现)。高频四件套:open / read / write / close;新趋势:io_uring 用共享环形缓冲把系统调用批量化、甚至零系统调用提交 I/O。
strace ./a.out 看每次系统调用的参数与耗时;perf top 看 sys 占比。Go 的 runtime 不走 glibc,直接汇编发 syscall,strace 照样能抓到。
// x86-64 上手动发起 write(1, "hi", 2) // nasm 语法:调用号入 rax,参数依次入 rdi/rsi/rdx mov rax, 1 ; sys_write 的调用号 mov rdi, 1 ; fd = stdout mov rsi, msg ; buf 地址 mov rdx, 2 ; count syscall ; 陷入内核,返回值在 rax // C 里等价的底层写法(man 2 syscall) #include <unistd.h> long r = syscall(SYS_write, 1, "hi", 2); // 失败返回 -1,errno 由库封装设置
r10 而不是普通函数调用约定的 rcx——因为 syscall 指令会用 rcx 保存返回地址、r11 保存 rflags,这两个寄存器被硬件征用了。
Interrupts & Exceptions
Cost of Mode & Context Switch
syscall/sysret 硬件快速路径,只保存/恢复少量现场、不换地址空间——往返在百纳秒量级。2018 年 Meltdown 漏洞后内核开启 KPTI(内核页表隔离),系统调用路径变长,代价可见地上升。
除寄存器现场外还要切换页表(重装 CR3)→ TLB 大面积失效(PCID/ASID 可缓解)→ cache 预热作废。直接成本 微秒量级,间接成本更高且不可直接观测。
同进程内的线程共享地址空间,不切页表,只切寄存器与栈——比进程切换便宜一个量级。这也是"多线程模型"长期流行的原因。
vmstat 1 看 cs(每秒上下文切换数);pidstat -wt 按线程看;perf 拆 sys / irq 占比。
| 切换类型 | 要做什么 | 量级 |
|---|---|---|
| 函数调用 | 压栈、跳转 | ~ns |
| 系统调用 | 特权级切换 + 少量现场保存 | 百 ns |
| 线程切换 | 寄存器 + 内核栈,同页表 | ~μs |
| 进程切换 | 线程切换 + 换页表 + TLB 失效 | μs + 间接成本 |
Monolithic · Micro · Hybrid
调度、内存、文件系统、驱动全部跑在内核态,子系统间是普通函数调用。快,但任何一处 bug 都可能 panic 整机。Linux 用可加载模块 LKM(驱动、文件系统按需加载)弥补臃肿——注意模块仍在内核态,只是"可插拔"。
内核只保留最小集:IPC、调度、基础内存管理;驱动、文件系统、协议栈都是用户态服务进程,靠消息传递协作。崩溃一个驱动,重启那个服务即可——隔离好,但每次跨服务通信都是两次上下文切换 + 消息拷贝,IPC 开销是性能瓶颈。
宏观是宏内核(服务都在内核态拿性能),架构上借鉴微内核思想(如 XNU = Mach 微内核消息机制 + BSD 内核)。
| 维度 | 宏内核 | 微内核 |
|---|---|---|
| 内核态里有什么 | 全部子系统 + 驱动 | 仅 IPC / 调度 / 基础内存 |
| 子系统间通信 | 函数调用(纳秒级) | 消息传递 IPC(微秒级) |
| 可靠性 | 一损俱损 | 故障隔离,可重启服务 |
| 代表 | Linux、传统 UNIX | MINIX 3、QNX、seL4、Fuchsia |
Kernel Subsystems Map
Go Runtime × Kernel
Go runtime 自带调度器(GMP),goroutine 之间的切换发生在用户态(几十 ns,只换少量寄存器),完全绕开内核线程切换的微秒级成本——这就是把本 deck"切换成本"推论工程化的范例。
① 网络 I/O:netpoller 底层用 epoll_create/ctl/wait(io-model deck 联动);② 锁竞争:sync.Mutex 饥饿路径用 futex 让线程睡眠;③ 内存:向 OS 批量申请新 arena 时 mmap/madvise。
goroutine 发起的长阻塞 syscall(如文件 I/O)会占住 M(内核线程),runtime 检测到 P 本地队列还有活,就唤醒/新建一个 M 接管 P——阻塞不拖累其他 goroutine,代价是线程数可能上涨。
| runtime 行为 | 背后的系统调用 | 对应 deck |
|---|---|---|
| netpoller 就绪等待 | epoll_wait(Linux) | io-model |
| 锁竞争线程挂起 | futex_wait / futex_wake | thread-sync |
| 堆扩容 / 归还内存 | mmap / madvise | memory-layout |
| 文件读写 | openat / read / write | file-system |
| 定时器到期前休眠 | 不 syscall:netpoll 超时复用 | process-scheduling |
GODEBUG=schedtrace=1000 每秒打印 G/M/P 状态;strace -c ./main 统计系统调用分布——Go 服务 sys 占比高时先想 netpoller 与文件 I/O。
Cheat Sheet
| 特权级 | 用户态 = x86 ring 3;内核态 = ring 0 |
| 能做什么 | 用户态只能执行受限指令集;内核态可执行全部指令、访问全部内存 |
| 地址空间 | 用户态各进程独立;内核态共享同一份内核空间 |
| 崩溃后果 | 用户态只死自己;内核态整机 panic |
rax、参数入 rdi/rsi/rdx → 执行 syscall 指令 → 换 ring 0 + 跳固定入口 → 保存现场到内核栈、查 sys_call_table[rax] → 返回值入 rax、sysret 回到用户态
| 函数调用 | 压栈 + 跳转 —— ~ns |
| 系统调用 | 特权级切换 + 少量现场保存,不换页表 —— 百 ns |
| 线程切换 | 寄存器 + 内核栈,同页表 —— ~μs |
| 进程切换 | 再加换页表(CR3)+ TLB 失效 —— μs + 间接成本 |
| 宏内核(Linux) | 子系统间是函数调用(ns);一损俱损;LKM 只解决可插拔,模块仍在内核态 |
| 微内核(QNX/seL4) | 子系统间是消息传递 IPC(μs);故障隔离可重启服务 |
| 本质权衡 | 调用开销 vs 隔离收益 |
strace -c ./main 看系统调用分布与耗时vmstat 1 的 cs 列 = 每秒上下文切换数top 的 sy 高 = 系统调用风暴;si 高 = 软中断风暴(看 /proc/softirqs 分摊)
Interview QA · Part 1
先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回速查页。
共享资源需要仲裁与隔离:CPU 用特权级(ring 0/3)+ MMU 把危险指令与关键数据锁在内核态。用户程序崩溃只死自己,内核 bug 才会拖垮系统——这是稳定性的基石。
普通调用是同特权级内的跳转(压栈 + jmp);系统调用要陷入内核:调用号放 rax、参数进寄存器、执行 syscall、切栈、查表分发——多出特权级切换与现场保存,开销高 1–2 个数量级。
CPU 完成特权级切换并跳到固定入口(如 entry_SYSCALL_64);内核把用户态寄存器现场保存到当前进程的内核栈(ptregs),然后执行内核代码;返回时恢复现场、回到触发点继续执行。
中断由 CPU 外部事件触发(时钟、I/O 完成),与当前指令无关,随时可能发生;异常由当前指令执行引发(缺页、除零、断点),同步且可复现。系统调用按实现归为 trap 类异常。
键盘控制器发中断 → APIC 路由到 CPU → 保存现场、查中断向量表 → 键盘驱动读扫描码(硬中断上半部)→ 软中断下半部交给 TTY 子层解析成字符 → 窗口系统/终端把字符写显存或经显示驱动刷新——全程大量"上半部快、下半部延后"。
关中断期间时钟中断也进不来,调度器失灵、系统像死机。所以硬中断处理必须短平快:只做最紧急的(应答硬件、拷数据),剩余工作丢给下半部(softirq/tasklet/workqueue)延后执行——吞吐与响应性的平衡。
Interview QA · Part 2
同样建议先自答。这一页的题都需要"结论 + 一句代价/边界"才完整——只答结论会被追问。
服务器场景追求吞吐:子系统间函数调用零开销,宏内核占优;LKM 提供可扩展性。要求强隔离/可验证的场景选微内核:车载(QNX)、安全关键(seL4 形式化验证)、Fuchsia 等新系统。
多一步换页表(重装 CR3),TLB 与各级 cache 的映射大面积失效,切换后要重新预热——直接成本差一个量级,间接成本差距更大。同进程线程共享地址空间,无需这一步。
说明 CPU 在内核态打转:典型原因是高频系统调用(大量小 read/write、频繁 gettimeofday 类调用)或软中断风暴(网络包)。优化方向:用户态缓冲合并 I/O、批量化(io_uring)、网卡多队列/RSS 分摊软中断。
只在内核态运行、没有用户地址空间的线程,由内核自己创建管理,如 ksoftirqd(软中断)、kswapd(内存回收)、kworker(工作队列)。用户看不到它们的可执行文件,ps 里名字带方括号。
softirq 是内核的下半部延迟处理机制,硬中断处理完后延后执行。si 高通常是网络包量大且分摊不均,单核被打满(旧内核没开 RSS)——调优看 /proc/softirqs 的各核分布。
内核入口地址预置在 MSR 中,用户态只能"跳进去",不能指定任意地址;进入后内核校验参数(如 copy_from_user 处理用户指针),调用号表是白名单——未实现的号返回 ENOSYS。
Related & References
进程、线程与协程 →(调度的对象是谁)
CPU 调度 →(内核怎么分配 CPU)
虚拟内存 →(缺页异常的主场)
I/O 模型与 epoll →(系统调用密集区)
GMP 调度模型 →(用户态消化切换成本)
TCP 连接管理 →(内核协议栈侧视角)
参考来源(本 deck 结论可溯源至下列一手材料)
| OSTEP ch.6 Limited Direct Execution | 内核态/用户态 + 受限直接执行的理论框架 |
| man7.org man 2 syscall | 各架构陷入指令与寄存器约定(x86-64 syscall / i386 int 0x80 / arm64 svc) |
| CSAPP ch.8 Exceptional Control Flow | 异常控制流:中断 / 陷阱 / 故障 / 终止四分类 |
| filippo.io/linux-syscall-table(Linux 6.16-rc1) | x86-64 调用号分布与数量 |
| xiaolincoding.com《图解系统》soft_interrupt / device / linux_vs_windows | 软中断、键盘中断链路、宏内核 vs 微内核的中文系统讲解 |
| kernel.org Documentation/admin-guide(KPTI) | Meltdown 缓解与系统调用开销变化 |