(Unix)一切皆文件

"一切皆文件"承诺了 55 年,Unix 只兑现了一半 ------ 那个叫 fd 的整数

bash 复制代码
echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward

一行。这台 Linux 机器从此会转发 IP 包,可以当路由器用了。

没有配置 API,没有 SDK,没有 daemon 需要 reload。你只是往一个"文件"里写了个 1。

这是"一切皆文件"最漂亮的一刻:内核网络栈的转发开关,被一个 1971 年的文件抽象给按住了。

然后,同一个系统、同一个"一切皆文件",会干脆地拒绝你:

c 复制代码
int fd = open("/var/run/docker.sock", O_RDWR);
// 返回 -1,errno = ENXIO(No such device or address)

那个 socket 文件明明白白躺在 ls -l 里,权限也对,可你就是 open 不了。POSIX 和 Linux 的 VFS 是刻意 禁止对 socket inode 调用 open() 的。

所以问题来了:

一个从来没真正兑现过的承诺,凭什么被讲了五十年,还成了操作系统史上被抄得最多的设计?


一、先看清它到底统一了什么

这套东西第一次落到纸面上,是 1971 年第一版 Unix 手册的 Section 4。那时它不叫哲学,就叫 "special files",纯机制描述,没有一句宣言。

1974 年,Ritchie 和 Thompson 在 CACM 上发表《The UNIX Time-Sharing System》。关于设备,原文是这么写的:

Special files constitute the most unusual feature of the UNIX filesystem. Each supported I/O device is associated with at least one such file. Special files are read and written just like ordinary disk files, but requests to read or write result in activation of the associated device. ... Thus, for example, to write on a magnetic tape one may write on the file /dev/mt.

There is a threefold advantage in treating I/O devices this way: file and device I/O are as similar as possible; file and device names have the same syntax and meaning, so that a program expecting a file name as a parameter can be passed a device name; finally, special files are subject to the same protection mechanism as regular files.

三条好处,说得非常克制:接口像、命名像、权限同一套。

注意它没说"一切皆文件",也没说这是什么哲学。它说的是 "special files constitute the most unusual feature"------一个特性,不是一句宣言。

而且当年的实现,粗糙得让人不敢相信:

早期的设备文件根本不用 major/minor 号,用的是硬编码的 inode 编号。 控制台是 inode 5,纸带穿孔机是 inode 6,依此类推 ------ 哪怕这些 inode 号同时也还对应着普通文件。

这个细节很有价值。它说明"设备即文件"从第一天起就不是一个优雅的抽象,而是一个够用的偷懒:既然打开文件这条路已经铺好了,设备就搭个便车。

〔分析〕这也是理解整个 Unix 设计的关键:它几乎从不发明新机制,它只把新东西塞进旧机制的形状里。优点是零学习成本,代价是所有不合适的部分都得硬塞。

后来才演化出真正的机制 ------ inode 里存 12 个设备地址字,第一个被解读成一对数字:设备类型(major)和子设备号(minor)。major 决定"由哪个驱动例程处理这次 I/O",minor 决定"是那块盘上的哪一台"。

于是同一个 inode 有了两种解读方式:

inode 类型 那几个地址字怎么用 read() 时发生什么
普通文件 指向磁盘数据块 从磁盘读字节
特殊文件(设备) major + minor 调用对应驱动的函数

统一发生在 inode 这一层,不在"文件"这一层。 这个区别很重要,后面会用到。


二、把它拆开看,只有一半是真的

"一切皆文件"这句话其实是两件事捆在一起说的,拆开之后你会发现它们的分量完全不同:

  1. 一切都在目录树里有名字(pathname)
  2. 一切都用同一套动词操作(open/read/write/close,靠 fd)

大多数人以为第一件事是核心。事实是第二件才是。

对象 在目录树里? 有 fd? 能 open()? 能 lseek()? 能 poll()?
普通文件 ✅ ✅ ✅ ✅ 形式上可以,但永远就绪
/dev/null、/dev/kvm ✅ ✅ ✅ 视设备 视设备
管道 / FIFO 匿名管道 ❌,FIFO ✅ ✅ FIFO 可以 ❌ ✅
socket ❌ ✅ ❌ ENXIO ❌ ✅
eventfd / signalfd / pidfd ❌ ✅ ❌ ❌ ✅
/proc、/sys 里的虚拟文件 ✅ ✅ ✅ 多数不支持 多数无语义

看前两列。目录树那一半满是缺口,fd 那一半一个不缺。

  • pipe() 给你两个 fd,但这两个 fd 不在任何目录里。
  • socket() 给你一个 fd,它活在 sockfs 里,而 sockfs 没有可见的挂载点 ------ 所以 open() 永远找不到它。
  • eventfd()、signalfd()、pidfd_open() 全是这样:只有 fd,没有名字。

〔分析〕所以准确的说法是:Unix 兑现的不是"一切皆文件",而是**"一切皆文件描述符"**。名字(pathname)是可选的装饰,句柄(fd)才是硬内核。

而"文件"这个词,只是这个句柄最常用、也最容易理解的一种长相。


三、那个整数到底白送了什么

fd 是什么?就是进程持有的一个小整数,指向内核里一张每进程私有的表。

text 复制代码
进程 A 的 fd 表                    内核里的打开文件表
┌────┬────────────────┐
│ 0  │ ───────────────┼──▶ 终端(stdin)
│ 1  │ ───────────────┼──▶ app.log
│ 2  │ ───────────────┼──▶ 终端(stderr)
│ 3  │ ───────────────┼──▶ 管道写端
│ 4  │ ───────────────┼──▶ socket
└────┴────────────────┘

关键设计是:fd 是一个值语义的句柄,不是对象引用。 它是个整数,可以拷贝、可以传递、可以继承、可以覆盖。

这一个决定,白送了四样东西:

1. fork() 自动继承。 子进程不用做任何事,就拿到了父进程的整张表。ls | grep 里那两个进程能对话,靠的就是这个。

2. dup2() 可以覆盖表项。 shell 的 >、<、2>&1、| 全部实现于此。所谓"重定向",本质就是把你表里的 1 号槽,换成另一个对象。这也是上一篇里管道能成立的全部机关。

3. 可以跨进程传递。 sendmsg() 带一个 SCM_RIGHTS 辅助消息,就能把一个 fd 递给另一个进程。nginx 的 master 进程监听端口,然后把连接 fd 分给 worker;systemd 的 socket activation,也是提前开好 fd 再交给服务。

4. 权限只查一次。 所有检查都在 open() 那一刻做完,此后 read/write 不再判权。这就是为什么 1974 年那句"special files 和普通文件适用同一套保护机制"是真能成立的。

〔分析〕"抽象一次到位"的真实含义,不是"这个抽象表达了所有东西",而是**"所有东西都能被同一套机制调度"**。fd 提供了那个调度点。它不完美,但它是唯一的。


四、五道缝:每次抽象漏了,就长出一个新系统调用

4.1 socket:连 open 都不行

这是最深的一道缝,因为它不是"文件抽象不够完整",而是"这东西从一开始就没被纳进文件抽象"。

socket 不是 open 出来的,是 socket() 造出来的。bind、connect、accept、listen 这四个动作,在文件接口里没有任何对应的操作 。你不能 cat 一个 socket,也不能往 socket 里 echo。

这就是 netcat 和 socat 这类工具存在的全部理由:它们是在给一个"本该能用 shell 一行搞定"的事情,造一个专用程序。

但请注意一个关键细节:socket 拿到的仍然是一个 fd。

所以 fork() 照样继承它,dup2() 照样能重定向它,select/poll/epoll 照样能等它,SCM_RIGHTS 照样能把它传给别的进程。

〔分析〕这一条几乎是整个论点的判决性证据:socket 在"文件"这一层是彻底失败的(连 open 都不支持),在"fd"这一层是彻底成功的(所有句柄能力一个不少)。 哪一层才是真抽象,一目了然。

4.2 ioctl:抽象漏了,于是开了个后门

read/write 只能搬字节。可设备要干的事远不止搬字节:磁盘要能查容量,终端要能设回显,网卡要能改 MTU,光驱要能弹托盘。

这些操作在"字节流"里无处安放。于是有了 ioctl(fd, request, ...)。

它就是个逃生舱:所有无法用读写表达的东西,全塞进去。代价极其昂贵:

  • request 是一个裸数字,各驱动自行定义,冲突和混乱不可避免
  • 第三个参数是 void *,传错结构体就是内存越界,没有任何类型检查
  • 无法自省。你拿到一个 fd,没有任何办法问它"你支持哪些命令" ------ 只能一个个试,靠错误码猜
  • 不可移植。同一个数字在不同架构、不同内核版本上可能含义不同

规模可以自己数:

bash 复制代码
grep -c 'define.*_IO' /usr/include/linux/*.h | sort -t: -k2 -rn | head -5

在 Linux 6.5 的头文件里,这类命令定义超过两千个,光网络子系统就占一百多个。你以为"一切皆文件"意味着接口收敛,实际上 Unix 只是把发散的部分,从动词挪到了参数里。

4.3 "就绪"表达不了 → select / poll / epoll

read() 是阻塞的。想知道 1000 个 fd 里哪个来数据了,你没法用 read() 问 ------ 一问就卡住。

1983 年 4.2BSD 给了 select(),2002 年 Linux 给了 epoll。它们解决的是同一个问题:"哪个 fd 现在能读了"这个信息,read 本身表达不了。

注意 select 出现在 Unix 诞生 14 年之后。这不是补丁,这是承认:文件抽象里没有"状态"这个概念。

4.4 "异步"表达不了 → io_uring

epoll 只负责告诉你"可以读了",读完还得你自己再调一次 read()。所以严格说,它是伪异步:就绪通知 + 同步拷贝。

2019 年 5 月,Linux 5.1 引入了 io_uring(作者 Jens Axboe)。它的做法是彻底绕开 read/write:在共享内存里放两个环形队列(提交队列 SQ、完成队列 CQ),你把请求写进 SQ,内核自己去执行,结果写进 CQ。批量提交、甚至可以让内核线程轮询 SQ 从而完全不进系统调用。

值得玩味的是它的处境:性能极强,但 2023-2024 年 Google 安全团队接连披露通过它触发的内核提权漏洞,Android 和 ChromeOS 默认关闭,Docker 等容器运行时用 seccomp 直接禁掉。

为了绕开 read/write,我们造了一个更复杂、更难审计、默认不敢开的新接口。 这就是抽象不够用时的真实代价:不是补一补,是另起一层。

4.5 /proc 的文本原罪 → sysfs 重做

/proc 是"一切皆文件"最激进的实践:把内核状态直接变成文件。1984 年由 Tom J. Killian 在 Unix V8 实现,论文标题就叫《Processes as Files》,初衷是替代 ptrace。

Linux 1992 年(v0.97.3)引入它,同年底(v0.98.6)就开始往里面塞非进程数据。然后失控了:/proc 变成了内核信息的万能接口,各种格式各异、缺乏规范的文件堆在一起。

结果 Linux 2.6 引入了 sysfs,重新定了一条更严的规矩:一个文件只放一个值。

更狠的是别人。macOS 从来没实现过 procfs,一律用 sysctl。OpenBSD 在 5.7 版(2015 年 5 月)直接把 procfs 删了,理由写在发布说明里:它一直受竞态问题困扰,而且已经没人用了。

〔分析〕这是全文最值得停下来想的地方:"一切皆文件"最彻底的实践,在好几个系统里被移除或边缘化了。 不是因为它错,是因为把结构化信息压成可读文本之后,解析成本被永久地转嫁给了每一个读取者 ------ 而且解析失败时不会有任何编译错误。


五、Plan 9:把口号当真的一次实验,和它的结局

如果真有人把"一切皆文件"做到底会怎样?贝尔实验室自己试过一次。

Plan 9 是 Unix 原班人马(Pike、Thompson、Presotto 等)在 1980 年代后期重写的新系统。它的态度很明确:Unix 说了一切皆文件,然后自己先破了例 ------ 网络用 socket(一整套独立 API),窗口系统用 X 协议,设备控制用 ioctl。Plan 9 决定不破例。

于是网络变成了文件树:

text 复制代码
/net/tcp/clone        打开它 → 分配一个新连接,read 得到编号 N
/net/tcp/N/ctl        写 "connect 127.0.0.1!80" 建立连接
/net/tcp/N/data       全双工数据流,就是 read/write
/net/tcp/N/status     连接状态
/net/tcp/N/listen     读它会阻塞,直到有新连接进来

没有 socket(),没有 connect(),没有 accept()。你能用 shell 脚本开一个 TCP 连接。

设备控制也一样:ioctl 被换成往 ctl 文件里写文本。于是「用 echo 驱动硬件、用 cat 检查硬件」在 Plan 9 里不是段子,是日常。

进程也一样:/proc/N 下有 ctl(写信号)、mem(读写内存)、note、status。给进程发信号就是往文件里写个字。

〔分析〕这一节的关键不是它多优雅,而是它输了。

Plan 9 从来没成为主流。但它那套东西活下来了:9P 协议(只有十几种消息)今天还在跑 ------ Linux 有 v9fs 客户端,QEMU 用 virtio-9p 给虚拟机共享目录,WSL 用 9P 把 Linux 文件暴露给 Windows。

为什么最彻底的实现输给了半成品?

〔分析〕我的判断是:Plan 9 把"统一"的成本,从内核开发者身上,转移给了每一个用户态程序员。

写一个 Unix 程序,你调 socket() 就行,语义精确、参数清楚。写一个 Plan 9 程序,你得先 open 一个 clone 文件、read 出一个编号、open 一个 ctl、写一串带 ! 分隔的文本、再 open data......每一步都可能失败,每一步都要解析字符串。

统一了接口,牺牲了语义。而这个交易,绝大多数程序员不接受。


六、那又怎样

一、看一个抽象好不好,算的是「覆盖对象数 ÷ 新增动词数」。

Unix 的选择极端清晰:动词只有五六个(open/read/write/close/lseek,外加一个后门 ioctl),覆盖对象从磁盘一路到终端、管道、设备、socket、信号、定时器。

分母压到最小,分子才能随便长。反过来,每次你觉得"这个接口再加个方法就好",你就是在放大分母 ------ 而分母的膨胀是不可逆的,ioctl 两千个命令就是活证据。

二、真正的遗产不是"文件",是"能力句柄"。

今天所有现代语言的 I/O 接口,形状都还是这个:

语言/平台 接口 本质
Go io.Reader / io.Writer 一个 Read([]byte) (int, error)
Rust std::io::Read / Write 同构
Node.js Readable / Writable stream 同构,加背压
Web fetch() 返回的 body stream 同构,跨网络

它们统一的东西和 Unix 一模一样:一个"能流出字节的东西",加一个最小的动词集。 没有一个是"文件",但全都是 fd 的精神后代。

三、但也别把话说满:什么时候不该"皆文件"。

  • 需要事务时(多个对象的原子修改)------ 文件接口里没有事务这个概念
  • 需要类型时 ------ 字节流不带 schema,切错列也不会报错(上一篇讲过这个坑)
  • 需要真正的背压时 ------ 管道有阻塞,但表达不了"我处理不过来,请降速"这种语义,只能靠堵住流
  • 需要异步完成通知 时 ------ 得另起一层,见 io_uring

还有一条容易被忽略的边界:这层抽象是单向的。

你可以从资源造出文件(/proc、/dev、/sys),但你不能从文件造出资源 ------ 在 /dev 下 touch 一个文件不会变出一块硬盘,mkdir /proc/99999 也不会变出一个进程。例外只有 cgroup、configfs 这类特意设计的子系统,它们允许 mkdir 创建内核对象。这条单向性说明了本质:文件接口是"观察与控制"的窗口,不是"存在"本身。

四、如果你在设计"一切皆 X",先用这五个问题自检。

检查项 通过标准
动词是否收敛 核心操作 ≤ 6 个。超了,通常说明抽象没找对,而不是场景太复杂
逃生舱是否可枚举 后门接口(类 ioctl)的数量能不能数清、能不能自省
失败是否可区分 错误码能否区分"对象不支持"和"参数写错了"
状态是否可表达 就绪、阻塞、异步这三件事,有没有地方安放
反向是否成立 只能从资源造句柄,还是也能从句柄造资源

Unix 在第一行拿满分,在第二行输得最惨(两千个 ioctl),第三、四行靠外挂补(errno、epoll),第五行本来就是单向的。

一个抽象不可能五行全过。真正的问题是:你愿意在哪一行认输。


写在最后

回到那两行代码。

一边是 echo 1 > /proc/sys/net/ipv4/ip_forward,漂亮得不像五十五年前的设计。

一边是 open("/var/run/docker.sock") 返回 ENXIO,干脆地告诉你:这个也不行。

同一个系统,同一个口号,两种结果。

〔分析〕但这不矛盾。因为 Unix 从来没有承诺过"一切皆文件"。1971 年第一版手册的 Section 4 只是老老实实记录了"特殊文件可以用 read/write 操作";1974 年的论文把它写成一句描述性的特性说明,没有当成原则;1978 年贝尔实验室那期专刊的前言,讲的是管道,同样没把文件抽象单列出来。

据对历代手册与主要文献的梳理,"一切皆文件"这句口号要到 1990 年代 Plan 9 推广时才逐渐成形 ------ 比它描述的技术晚了大约二十年。 2003 年 Eric Raymond 在《The Art of UNIX Programming》里引用它时,已经把它当成既成共识,没有追溯出处。

口号是后来的人为了方便讲故事,给一段真实历史补上的标题。

而真实的那件事,比口号小,也比口号狠:

Unix 从来没有把一切变成文件。它只是把所有东西,变成了一张表里的一个整数。名字是装饰,文件是长相,那个整数才是本体。


参考资料

  1. D. M. Ritchie, K. Thompson, The UNIX Time-Sharing System, Communications of the ACM, 17(7), 1974 ------ 特殊文件段落、"threefold advantage" 原文出处
  2. Poul-Henning Kamp, Rethinking /dev and devices in the UNIX kernel, USENIX BSDCon 2002 ------ 引用 1974 年原文,并记录早期用硬编码 inode 号实现设备文件(控制台 = inode 5)
  3. UNIX Programmer's Manual, First Edition, 1971, Section 4 ------ special files 的最早书面记录
相关推荐
Marst Code1 小时前
(Unix) fork-exec 进程创建与程序加载解耦
服务器·unix
福建佰胜张工11 小时前
SMC 薄型气爪 MHF2-16D1 深度解析:平行开闭、低剖面结构在自动化装配中的选型与落地
java·服务器·安全·自动化
xuefeiniao12 小时前
PHP2Shell漏洞修复
运维·服务器·php
便利店102413 小时前
系统活动一抓留存!深入 Linux C/C++ 调试工具之 sysdig
linux·运维·服务器·sysdig
g3voip14 小时前
工业防水电话到底怎么选?隧道、管廊与码头的需求差异
服务器·网络·信息与通信·ip
.Bryce.15 小时前
从强化学习到Agentic RL:让Agent在“干活“中自己变聪明
服务器·网络·agent·rag·agentic rl
ZYH_060115 小时前
rhcsa课程复习题
服务器·bash
用户5651903944915 小时前
高危 编号99999漏洞情报(26年8-10月) 多个严重漏洞:PHP执行远程控制代码 / Linux虚拟机-宿主越界(CVE-2026-99999...
服务器
云飞云共享云桌面15 小时前
广东东莞制造工厂10人共享一台三维设计服务器
运维·服务器·3d·自动化·制造