Unix 系统调用:用户态的边界

系统调用是应用程序与 Unix 内核之间的唯一合法通道。全集三百多个,听着吓人,但日常高频的不过三十来个。本文先概括这层边界到底在干什么,再逐个细讲那些真正必须知道的——文件 IO、fd 复制、mmap、进程四件套。

这层边界在干什么

现代 CPU 分特权级:用户态代码跑在你的进程里,碰不到硬件、看不到内核数据结构;内核态代码什么都能碰。系统调用就是两者之间那扇唯一的门——你的程序把”我想读文件”的请求号和参数塞进寄存器,执行一条 trap 指令,CPU 切到内核态,内核验证一切之后替你干活,把结果放回去。参数不合法直接打回,没有商量。

所以 libc 里的函数大多是这层之上的薄封装:printf 排版完最终调 writemalloc 大块找 mmap 要、小块自己切,fopen 就是 open 加一层缓冲。这也是 man 手册分节的原因——man 2 write 是系统调用,man 3 printf 是库函数,查文档时走错楼层是新手最常见的迷路方式。判断依据简单粗暴:函数名加 (),行为围绕 fd 和字节的,多半是 2 楼。

系统调用有一套统一的行为约定,记住它能省掉一半排错时间。返回值出错就是 -1,具体原因写在全局 errno 里;调用被信号打断会返回 EINTR,惯用法是重试;磁盘慢、网络抖动时还会出现 short read——read 要 4096 字节只给了 2000,这不是错误,循环接着读就行。

fd 与文件四件套

Unix 世界观的根基是”一切皆文件”,落到系统调用上就是:open 拿到一个 int 型的 fd(文件描述符),之后 read、write、close 全认这个数字,不认文件名。fd 在内核里指向一个打开的文件对象,锁、偏移量、状态标志都挂在那上面——flock 整篇讲的就是这件事。

四件套的用法用一段 C 就能说尽,Python 里把 os.open 换进来一字不差:

int fd = open("data.bin", O_RDWR | O_CREAT, 0644);  // 0644 只在创建时生效
char buf[4096];
ssize_t n = read(fd, buf, sizeof buf);              // 返回实际读到的字节数
write(fd, buf, n);
close(fd);                                          // fd 归还池子,供下次 open 复用

每个进程开局自带三个 fd:0 标准输入、1 标准输出、2 标准错误。这不是规定出来的常量,就是 convention——shell 在启动你之前把它们准备好,cmd > file 之所以能重定向,靠的正是后面要讲的 dup2。

两个 flag 值得单独记。O_TRUNC 打开即清空,很多人在”只想覆盖头几行”的场景里被它坑过整文件;O_APPEND 让每次 write 原子地追加到末尾,多个进程写同一个日志文件不打架,靠的就是它而不是什么文件锁。

还有一个缓冲层差异,坑过无数从 printf 过来的人:write 是系统调用,数据直达内核;printf 带 stdio 缓冲,fork 前没 flush,缓冲区会被子进程原样复制一份,同一行日志打两遍。排查这类问题,记住口诀”fork 前 fflush,或干脆用 write”。

dup2 与 pipe:shell 语法的地基

dup2(oldfd, newfd) 干的事一句话:把 oldfd 复制到 newfd 这个编号上,newfd 原来指向的东西被顺手关掉。听着平平无奇,但 shell 的整套重定向语法都是它的应用——cmd > file 展开来就是三步:open(file) 拿到新 fd,dup2(新fd, 1),close(新fd)。此后进程往 1 写的每个字节都进了 file。2>&1 更直白:dup2(1, 2)。

pipe() 造一对 fd,写端进、读端出,容量有限(默认 64 KB),读端没数据时 read 阻塞,写端全关时 read 返回 0——这个 0 就是”流结束”的信号。管道加 fork 加 dup2,就是 | 的全部实现:父进程 pipe,fork 出子进程,左边命令 dup2 写端到 1,右边命令 dup2 读端到 0,两端各自 exec。细节在 Linux Pipe(管道)完整详解 里有整篇展开,Linux dev-null 是什么? 则是这套机制里”黑洞”角色的注脚。

mmap:把文件当数组用

read/write 的世界是”把数据从内核缓冲区拷进你的缓冲区”;mmap 提供另一条路:把文件(或一段匿名内存)直接映射进你的地址空间,之后像访问数组一样访问它,缺页时内核才把对应的页读进来。不拷贝、按需加载、几百 GB 的文件也敢只映射其中一段。

它同时是三个常见设施的地基:malloc 申请大块时底层就是匿名 mmap;两个进程 MAP_SHARED 同一段映射,得到的就是共享内存;而 MAP_PRIVATE + 写时复制,正是 fork 语义在内存上的同款设计。用 Python 体验门槛极低:

import mmap
 
with open("data.bin", "rb") as f:
    with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
        print(mm[1024:1080])   # 像切片数组一样读文件,没有 read() 的拷贝

fork / exec / wait:进程的一生

Unix 造进程的方式初看很怪:拆成两个调用。fork 把当前进程原样复制一份(COW,只复制页表不复制内存),子进程从 fork 的返回点继续跑,返回值 0;父进程拿到的是子进程 pid。exec 则把当前进程的程序整个换掉——代码、数据、堆栈全扔,换上新程序的镜像,pid 不变。两者合起来才是”启动一个新程序”,拆开的自由度在于:fork 之后、exec 之前,你有一扇窗可以布置环境——重定向几个 fd、关掉不相关的 fd、设几个环境变量。shell 能做 cmd 2>&1 | tee log 这种组合,全拜这扇窗所赐。你每次在终端敲一个命令,shell 就是 fork + 布置 + exec + wait 这一套。

exec 家族名字唬人(execl、execv、execve、execvp……),差异只在参数怎么传和要不要搜 PATH,底层全是 execve 一个系统调用。最容易忽略的点是:exec 只有失败才会返回——成功了进程已经不存在了。所以 exec 后面直接跟的错误处理代码,只在”没换成功”时执行,比如文件不存在;但此时你还在原进程里,必须 exit,否则会带着旧程序继续往下跑,很多守护进程的双进程残留就是这么来的。

wait 负责收尸。子进程退出后内核保留它的退出状态,直到父进程 wait 来取——在此之前它是僵尸进程(zombie),占了 pid 却不占内存。父进程先死则由 init 接管代收。Python 的 subprocess 把这套封装得很好,但 多进程——multiprocessing深入剖析Python multiprocessing:spawn vs fork 进程启动方式的核心差异与实践指南 里那些 spawn/fork 行为差异,根子全在这几个系统调用的语义上。

认得出就够了的一批

剩下的系统调用不必个个会写,但要在日志和 strace 里认得出:fcntlioctl 管 fd 标志与设备控制,fcntl 有整篇;kill 发信号、rt_sigaction 注册处理函数,两者合起来是信号机制的全部接口;socket 五件套 socket/bind/listen/accept/connect 是网络的入口;brk 调整堆顶,strace 里看到频繁 brk 基本可以断定是 malloc 在活动。至于 getpidnanosleepgettimeofday 这类,见名知义,遇到再查。

怎么往下挖

最好的教材是 strace。随便挑一个命令跟一遍,几十个系统调用的全景图就有了:

strace -f -e trace=openat,dup2,write,execve,exit_group bash -c '/bin/echo hi > /tmp/x'
# 能亲眼看到:bash fork 子进程 → 子进程 openat 打开 /tmp/x 并 dup2 到 1
# → execve 加载 /bin/echo → write("hi\n") → exit_group 退出
# 注意用 /bin/echo 而不是 builtin echo——后者不会触发 execve

配合 man 2 <名字> 逐个精读,errno -l 对照错误码,一圈下来比读任何教材都扎实。POSIX 划定的接口子集则是跨 Linux/macOS/BSD 可移植的那部分——写跨平台代码时,守住 POSIX 名单,就守住了底线。

收尾

系统调用值得学的不是那几百个函数签名,而是它背后的世界观:内核只认 fd 和字节,其他一切抽象都是用户态自己搭的。语言运行时、框架、容器,层层叠叠下来,底下的合同始终是这一份。读懂这份合同,往上任何一层的”灵异现象”——缓冲丢数据、fork 后日志翻倍、进程残留、锁神秘失效——都能顺着 fd 一路挖到根。三百个系统调用不用背,守住常用的三十个,剩下的知道去哪查,就够受用很多年。