加载中...

本篇要回答的问题:在保护模式下,操作系统与我们的软件不在同一进程,软件怎么以接近函数调用的低成本使用操作系统的能力?这就是"系统调用"与编程接口的故事。

上一讲讲清了软件如何被运行起来(内存管理、虚拟内存、进程独立地址空间)。今天聊软件如何利用它依赖的基础架构。我们开发软件时依赖三层基础设施,各自的编程接口很不一样:

基础设施 编程接口 特点
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 驱动 → 显示

关键判断:printfsyscall 之间全是自己地址空间里的普通函数调用,只有最后一跳跨越用户态/内核态。之所以要堆这么多层,正因为那一跳贵——能在用户态解决的就别进内核

最原始的调用方式是汇编软中断指令 int <中断号>(如 Linux 是 0x80,int 是 interrupt 不是 integer)。但用汇编写软件不是好主意,高级语言都做了封装。

补注·int 0x80 早已不是主流:x86-64 上用的是专门的 syscall 指令(32 位对应 sysenter),它省掉了 IDT 查表和一堆门描述符/特权检查,比走中断快得多;int 0x80 如今只作为 32 位兼容路径保留。原理没变——仍是"用户态发起 + 权限跃迁"——但入口指令换了一条更专用、更便宜的。

六大子系统的标准库封装

OS 内核六大子系统中,除"安全管理"是润物细无声外,其余都会被语言标准库包装:

C 与 Go 语言对六大子系统能力的封装简表

这张表别当 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、编程语言、操作系统三者对应用软件开放的编程接口,重点是系统调用:它基于软中断,既改变执行权限(用户态↔内核态)、又因内核共享地址空间而让传址有意义,并由内存页保护机制堵死了"绕过中断直接调内核"的可能。

后半程的「编程接口」与「动态库」则是同一件事的两面:内核只给最小最贵的一组原语,好不好用全靠用户态往上堆的几层——动态库是这几层的二进制载体,标准库是这几层有主张的筛选。

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 画在动态库,语言只能顺从。

思考题

操作系统内核与你的应用同处一个地址空间却被"内存页保护"设成黑洞——这是用硬件机制守住边界。回到你的系统:模块间的边界,是靠"约定/自觉"守住,还是有像内存页保护这样强制不可逾越的机制?哪种更可靠?

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关