本篇要回答的问题:在保护模式下,操作系统与我们的软件不在同一进程,软件怎么以接近函数调用的低成本使用操作系统的能力?这就是"系统调用"与编程接口的故事。
上一讲讲清了软件如何被运行起来(内存管理、虚拟内存、进程独立地址空间)。今天聊软件如何利用它依赖的基础架构。我们开发软件时依赖三层基础设施,各自的编程接口很不一样:
| 基础设施 | 编程接口 | 特点 |
|---|---|---|
| CPU(冯·诺依曼) | CPU 指令 | 面向机器,非常难用 |
| 编程语言 | 语法 + 库 | 面向人类,编译器翻译成 CPU 指令;会演化出基础算法库 |
| 操作系统 | 系统调用 | 基础软件,与我们的软件不在同一进程 |
系统调用
操作系统属于基础软件,和我们的软件不在同一进程。保护模式下二者地址空间独立,软件怎么用 OS 能力?需要一种成本极低、性能接近函数调用的机制——答案是上一讲提到的中断(软中断),专门为此引入术语系统调用(syscall)。
保护环与用户态/内核态
| 概念 | 说明 |
|---|---|
| 保护环(Protection Rings) | 代码有执行权限等级,权限不够则某些 CPU 指令不能执行 |
| Ring 0(内核态) | 操作系统内核运行于此,管理所有硬件与进程 |
| Ring 3(用户态) | 常规软件进程运行于此 |
| Ring -1 | 虚拟化引入,仅宿主 OS 可调用 |
关键判断:一次系统调用(软中断)不只是"间接函数调用",更改变了执行权限——从用户态(Ring 3)跃迁到内核态(Ring 0)。这是保护模式下与普通函数调用的本质差异。
为什么传地址有意义:内核共享地址空间
软件与内核地址空间独立,可系统调用时传给内核的内存地址却有意义。原因:
从虚拟内存视角,操作系统内核与所有进程在同一地址空间,内核是所有进程共享的内存,且在所有进程中拥有相同地址。 无论哪个进程请求过来,对内核都像是一次本进程内的请求。
那能否跳过中断直接调用内核函数(哪怕该函数不含特权指令)?不能。这涉及内存页保护机制:内存页有"可读、可写、可执行"三个标记位,内核地址空间虽有内容,但对用户进程被设为不可读/写/执行——是个"黑洞"。
补注·机制精确化:挡住用户态的其实是页表项里的 U/S(User/Supervisor)位,不是"可读/可写/可执行"三个位。W 位管写、NX 位管执行确实存在,但把内核整片地址对 Ring 3 关死的是 U/S 这一个位——置为 supervisor 后,用户态对该页读、写、执行全部禁止,硬件在地址翻译阶段就抛缺页异常(
#PF)。所以原文"不可读不可写不可执行"描述的是效果,机制上是一个位而非三个。
补注·这条前提在 2018 年被打了个洞:Meltdown 漏洞证明用户态能借侧信道读到那片"黑洞"里的内容——因为 CPU 的权限检查与乱序预取不是原子的,数据先被读进缓存、权限异常后补。于是 Linux 4.15 上了 KPTI(内核页表隔离):用户态页表里只保留一小段跳板代码,其余内核映射全部撤掉,进内核时真的要换 CR3。代价是 syscall 慢了约 5%–30%。
值得记的对照:当年为性能才做的"内核同址映射",被安全反向收了回去。"共享地址空间省掉页表切换"这个设计逻辑仍然成立,但它在今天已不是无条件成立的了。
编程接口
「编程接口」和「动态库」这两节是同一件事的两面:内核只肯给最小的一组原语(几百个 syscall),而且每次调用都要付权限跃迁的钱。所以好不好用、跨不跨平台,全靠用户态往上堆的那几层。本节讲这几层堆成了什么样,下一节讲它们靠什么载体堆起来。
完整调用链:整条链上只有一步是系统调用
原文没画这张图,但不画就串不起来。以 printf("hi\n") 为例:
你的代码 printf("hi\n")
│ ← 普通函数调用(同进程、用户态、几个 ns)
libc.so.6 格式化 → 写进 stdout 缓冲区
缓冲满 / 遇到 \n → 调用 write()
│ ← 还是普通函数调用
libc 的 syscall mov rax, 1 ; SYS_write 的编号
包装函数 mov rdi, 1 ; fd = stdout
syscall ; ★★★
│ ← 整条链上唯一一次 Ring 3 → Ring 0
══════════════════════════════════════════════
内核 sys_write() → VFS → tty 驱动 → 显示
关键判断:从
printf到syscall之间全是自己地址空间里的普通函数调用,只有最后一跳跨越用户态/内核态。之所以要堆这么多层,正因为那一跳贵——能在用户态解决的就别进内核。
最原始的调用方式是汇编软中断指令 int <中断号>(如 Linux 是 0x80,int 是 interrupt 不是 integer)。但用汇编写软件不是好主意,高级语言都做了封装。
补注·
int 0x80早已不是主流:x86-64 上用的是专门的syscall指令(32 位对应sysenter),它省掉了 IDT 查表和一堆门描述符/特权检查,比走中断快得多;int 0x80如今只作为 32 位兼容路径保留。原理没变——仍是"用户态发起 + 权限跃迁"——但入口指令换了一条更专用、更便宜的。
六大子系统的标准库封装
OS 内核六大子系统中,除"安全管理"是润物细无声外,其余都会被语言标准库包装:
这张表别当 API 速查手册看——当手册看是浪费的,它真正的价值是当证据看:证明"标准库对 OS 能力的封装不是 1:1 转录,而是有主张的取舍"。四行最说明问题:
| 表里的一行 | 它其实在说什么 |
|---|---|
内存:Go"语言支持 GC,不手工管理"vs C malloc/free |
这不是"包装得好一点",是改变了编程模型。底下都是 brk/mmap 这两个 syscall,Go 把它藏到你意识不到有这回事。封装的极限是让你忘记它存在。 |
| 窗口系统/GDI:两边都写"跨平台难,标准库中缺失" | 标准库的边界在这里露了出来——不是内核有什么就包什么,而是"能跨平台抽象的才包"。窗口系统在三大平台模型完全不同,抽象不动,就不进。 |
HTTP:Go 有 net/http,C"标准库中缺失" |
HTTP 跟内核半点关系没有,纯应用层协议。Go 收它是因为 Go 生在互联网时代——语言定位决定的,不是技术必然。同一个"标准库该收什么",1972 年的 C 和 2009 年的 Go 给了不同答案。 |
| 信号量:Go 写"认为过时,标准库中缺失" | 最有性格的一行:标准库可以有观点,可以拒绝提供操作系统明明提供了的东西。 |
读法:横着看是"C 和 Go 怎么包",竖着看是"什么该包、什么不该包"。后者才是架构课要讲的东西。
goroutine vs thread:本节埋的最大伏笔
表里"执行体"一行 Go 写 goroutine、C 写 thread,原文说"后面再讨论"。但它恰恰把本节的主题推到了极致,值得先记梗概:
| OS thread | goroutine | |
|---|---|---|
| 是什么 | 内核对象,内核知道它、调度它 | 用户态对象,内核完全不知道它存在 |
| 栈 | 固定,Linux 默认 8MB 虚拟地址 | 初始 2KB,不够了自动搬家扩容 |
| 创建成本 | 要陷内核,数十 µs 量级 | 用户态分配,数百 ns 量级 |
| 切换成本 | 陷内核 + 存完整上下文,µs 量级 | 用户态改几个寄存器,百 ns 量级 |
| 开十万个 | 内存和调度器都会崩 | 日常操作 |
Go 干的事叫 M:N 调度:N 个 goroutine 复用到 M 个 OS thread 上,调度逻辑自己在用户态实现(GMP 模型)。遇阻塞式系统调用时 runtime 把线程让出去,其他 goroutine 换个线程继续跑;网络 I/O 则由 runtime 用 epoll/kqueue 接管,让你写着同步代码、底下跑的是异步。
关键判断:内核给的抽象(thread)不够便宜,那就在用户态自己造一个更便宜的(goroutine),只在不得不进内核时才进。 和"少发 syscall"是同一个逻辑。
动态库
OS 只提供最原始系统调用不够,很多业务逻辑在用户态封装更合适,但 OS 无法为所有语言一一开发基础库。解法是动态库:
| 平台 | 动态库 |
|---|---|
| Windows | dll(Dynamic Link Library) |
| Linux/Android | so(shared object) |
| Mac/iOS | dylib(Mach-O Dynamic Library) |
关键判断:动态库是语言无关的二进制级(而非代码级)复用机制,大大降低各语言标准库的工作量。
先补上原文缺的对照物:静态库
这节难懂的最大原因是它上来就讲动态库,没讲它在跟谁对比:
静态库 .a / .lib |
动态库 .so / .dll / .dylib |
|
|---|---|---|
| 库代码何时并进来 | 编译链接期,复制进可执行文件 | 运行时,由动态链接器(Linux ld.so / Mac dyld)加载 |
| 硬盘与内存 | 每个程序各带一份副本 | 全系统一份,代码段多进程共享物理页 |
| 修安全漏洞 | 所有依赖它的程序全部重编重发 | 换掉那一个文件即可 |
| 交付 | 单文件,拿走就能跑 | 得连整棵依赖树一起交付 |
落到实处,原文那句"OS 无法穷举所有语言一一开发基础库"就是:操作系统只发布一个 libc.so,然后 C / Python / Ruby / PHP / Node 全去链它——一份二进制,喂饱所有语言。
"二进制级复用"到底什么意思:ABI
这是本节核心句,但很抽象,翻译一下:
| 含义 | 前提 | |
|---|---|---|
| 代码级复用 | 我给你源码,你用你的编译器重编一遍 | 你得有能编这门语言的编译器(Python 想复用 C++ 源码?没门) |
| 二进制级复用 | 我给你编译好的机器码,你直接调 | 双方遵守同一套 ABI |
ABI(Application Binary Interface)是这里唯一的门槛,它规定的都是些极其机械的事(以 x86-64 Linux 为例):
| ABI 规定什么 | 具体答案 |
|---|---|
| 参数放哪儿 | 前 6 个整型参数依次进 rdi, rsi, rdx, rcx, r8, r9,多的压栈 |
| 返回值放哪儿 | rax |
| 谁负责恢复寄存器 | rbx, rbp, r12–r15 由被调用方保存 |
| 栈怎么对齐 | 调用前 16 字节对齐 |
| 结构体怎么排布 | 字段偏移与 padding 规则 |
| 函数在库里叫什么名 | 符号名(C 用原名,C++ 会 mangling) |
关键判断:只要生成的机器码遵守这几条,是什么语言写的完全无所谓。 Rust / Python / Go 能调 C 库,靠的不是共享源码或共享编译器,只是共享这张"寄存器怎么摆"的表。
顺带解一个以后一定会撞上的现象——为什么跨语言的库都要
extern "C"? C++ 为支持重载会把void foo(int)的符号名 mangling 成_Z3fooi这类东西,而且各家编译器规则还不一样;C 没有重载,符号名就是foo,谁都认。C ABI 事实上成了跨语言的世界语——不是因为 C 优秀,是因为它简单到没有分歧空间。
原理两件事
| 核心 | 说明 |
|---|---|
| 浮动地址 | 在进程地址空间动态加载代码片段,加载时固定浮动地址(实模式加载进程就用此技术) |
| 导出函数表 | 记录哪些函数被 export,用户用函数名取得函数地址 |
为什么编译时定不了地址? 因为 libfoo.so 落在你地址空间哪一段是运行时才知道的:这个进程可能已经装了 libc、libssl、libz……哪块空地放得下,编译 libfoo 时没人能预知;何况还有 ASLR(地址随机化) 故意每次换位置。两代解法:
| 解法 | 做法 | 问题 / 好处 |
|---|---|---|
| 重定位(老) | 库里留一张清单"这些位置填的是地址,加载时统统加基址",加载器逐条改写代码 | 代码段被改写过,就没法在多进程间共享物理内存了——动态库省内存的好处直接没了 |
| PIC 位置无关代码(今) | 一律用"相对当前指令的偏移"寻址(x86-64 的 RIP-relative 让这几乎零成本);真需要绝对地址的统一走 GOT(全局偏移表) 跳板 | 只改数据段那张表,代码段一个字节不动,于是代码段可被所有进程共享 |
导出函数表(ELF 里是 .dynsym)撑起的是"按名字要地址"这套接口:
// Linux / macOS
void *h = dlopen("libfoo.so", RTLD_NOW);
void (*f)(int) = dlsym(h, "foo"); // ← 用字符串 "foo" 换回一个函数指针
f(42);
// Windows
HMODULE h = LoadLibrary("foo.dll");
FARPROC f = GetProcAddress(h, "foo");
"用字符串换到函数指针"这件事,就是插件系统、Python 的 C 扩展、游戏 mod、数据库 UDF 的全部技术底座——程序编译时完全不知道有这个插件存在,运行时读个配置
dlopen一下,新能力就进来了。
原文没说的另一半:动态库的代价
这节通篇在夸动态库,但工程上它有实打实的成本:
| 代价 | 具体表现 |
|---|---|
| 依赖地狱 | 你要 libfoo.so.2,系统装的是 .so.3,跑不起来。Windows 上有专名 DLL Hell;glibc 搞出符号版本(GLIBC_2.34),新系统编译、老系统运行就报 version not found |
| 启动变慢 | 每次启动都要解析依赖、加载几十个 .so、修 GOT——所以才有"延迟绑定"(首次调用才解析) |
| 部署变重 | 交付一个程序 = 交付它 + 整棵依赖树。Docker 镜像动辄几百 MB,很大一块是这个 |
关键判断:近十年静态链接回潮——Go 默认静态链接产出单文件二进制,
scratch空镜像就能跑;Rust 基本同路。硬盘内存已经很便宜,而"在我机器上是好的"变得更贵了。动态库不是"更先进",它是 1990 年代资源约束下正确的取舍;约束变了,结论就得重算。
那句"大部分语言会选动态库"——Go 是个响亮的反例
原文结尾说大部分语言选择调用动态库而非直接 syscall。话对,但 Go 恰是那个例外,而且这个例外极其说明问题:
| 平台 | 纯 Go 程序怎么走 | 为什么 |
|---|---|---|
| Linux | 不链接 libc,runtime 自己写汇编发 syscall 指令(所以能产出真·静态单文件) |
Linux 承诺 syscall 编号与 ABI 永不改变(Linus"绝不破坏用户态"铁律) |
| macOS / Windows | 必须走 libSystem.dylib / kernel32.dll |
Apple 从不承诺 syscall 稳定,说改就改——历史上 Go 绕过 libSystem 直接发 syscall,在 macOS 升级后集体崩过 |
关键判断:系统的稳定接口边界画在哪,是各家操作系统自己的选择。 Linux 画在 syscall 这一层(所以你可以绕过 libc),macOS/Windows 画在动态库这一层(所以你必须走 dylib/dll)。"用动态库还是直接 syscall"不是语言的自由选择,是操作系统替你定的。
两节合起来的一句话
内核 ── 给一组最小、最贵、但绝对可靠的原语(syscall)
↑
动态库 ── 语言无关的二进制载体,把原语包成人能用的东西(libc.so / kernel32.dll)
↑
标准库 ── 有主张地筛选和再包装,决定什么该有、什么不配有(收 net/http,不收信号量)
↑
你的代码
越往上越好用,越往下越贵越通用。架构师的活儿,就是决定每一层的边界画在哪。
操作系统与编程语言
编程语言与操作系统是两条独立又交融的演化线,像"孪生关系":OS 诞生离不开语言,但 OS(和 CPU 一样)又是语言依赖的基础设施。三个有趣问题:
| 问题 | 答案 |
|---|---|
| 先有语言还是先有 OS? | 先有语言。软件跑起来很容易(看 BIOS 把控制权交给谁);C 这类系统级语言可写出不依赖任何内核的程序 |
| 语言怎么自举? | 机器码写第一个汇编编译器 → 汇编编译器编出第一个 C 编译器 → 用 C 反过来重写两者;交叉编译避免每出新 CPU/OS 都重演进化史 |
| OS 能自举吗? | 能。新 OS 先用交叉编译做出来,稳定后再自举(用本 OS 迭代本 OS) |
总结
本讲讲清了 CPU、编程语言、操作系统三者对应用软件开放的编程接口,重点是系统调用:它基于软中断,既改变执行权限(用户态↔内核态)、又因内核共享地址空间而让传址有意义,并由内存页保护机制堵死了"绕过中断直接调内核"的可能。
后半程的「编程接口」与「动态库」则是同一件事的两面:内核只给最小最贵的一组原语,好不好用全靠用户态往上堆的几层——动态库是这几层的二进制载体,标准库是这几层有主张的筛选。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| 系统调用(syscall) | 软件经软中断使用 OS 能力的基础机制,成本接近函数调用 |
| 保护环 | CPU 的执行权限等级(Ring 0 内核态 / Ring 3 用户态) |
| 操作系统内核 | 向应用提供系统服务、管理全部硬件与进程的子系统集合 |
| 内存页保护 | 页表项的 U/S 位置为 supervisor,把内核地址空间对用户态设为"黑洞"(读写执行全禁) |
| 静态库 | 编译期把库代码复制进可执行文件;单文件可交付,但改一处要全部重编 |
| 动态库 | 语言无关的二进制级代码复用(dll/so/dylib),运行时由动态链接器加载 |
| ABI | 参数放哪个寄存器、结构体怎么排布、符号名怎么起——二进制级复用的唯一门槛 |
| PIC / GOT | 位置无关代码:只改数据段的跳板表、不动代码段,动态库才能被多进程共享 |
| 交叉编译 | 在一种"CPU+OS"下生成另一种"CPU+OS"的软件,使语言/OS 可自举 |
一句话速记
系统调用 = 软中断 + 用户态到内核态的权限跃迁 + 内核共享地址空间,在保住隔离与安全的同时把使用 OS 能力的成本压到接近一次函数调用。
几条值得记住的判断
- 一次系统调用的精髓不是"调函数",而是改变执行权限。
- 内核对所有进程共享且同地址,所以传给它的指针才有效。
- 动态库 = 二进制级复用,让语言标准库不必为每个 OS 重写。
- 先有语言后有 OS;交叉编译让语言与 OS 都能自举。
- 一次
printf里只有一跳是系统调用,其余全是用户态普通函数调用——堆那么多层就是为了少进内核。 - 标准库不是内核能力的转录,是有主张的取舍:能跨平台抽象的才收,收不收 HTTP 由语言的时代定位决定。
- ABI 是跨语言复用的唯一门槛;C ABI 成为世界语不是因为 C 优秀,是因为它简单到没有分歧空间。
- 动态库是 1990 年代资源约束下的正确取舍,约束变了结论要重算——静态链接近十年正在回潮。
- 稳定接口边界画在哪由 OS 决定:Linux 画在 syscall,macOS/Windows 画在动态库,语言只能顺从。
思考题
操作系统内核与你的应用同处一个地址空间却被"内存页保护"设成黑洞——这是用硬件机制守住边界。回到你的系统:模块间的边界,是靠"约定/自觉"守住,还是有像内存页保护这样强制不可逾越的机制?哪种更可靠?



