sudo cat /dev/input/event0 跑完这条命令,敲几个键,终端会刷出一堆乱码。
不是真乱码,是键盘的原始事件包,带微秒级时间戳、事件类型和键码。用 cat 读的不是某个日志文件,是键盘硬件本身。这才是"一切皆文件"讲的事,跟可执行程序是不是二进制没关系。ELF 是 Linux 可执行文件的格式,PE 是 Windows 的,两边都是二进制,放一起讨论是把两件不同的事混在一起了。
/dev:硬件伪装成文件
/dev 下面住的都是这类东西。
/dev/null 是黑洞,往里写什么都消失,读它永远返回 EOF。脚本里的 command 2>/dev/null 就是把报错扔进去。
/dev/urandom 是随机数生成器:
dd if=/dev/urandom bs=16 count=1 | xxd
# 00000000: a3f2 178c 6d2b 99e4 0cbe fa31 4d7a 1193
read 它就能拿密码学级别的随机字节,不需要调任何额外 API。
/dev/sda 是硬盘本身。dd if=/dev/sda of=disk.img 直接在做硬盘镜像,strings /dev/sda 能搜整块硬盘的明文字符串,操作方式和读一个普通文本文件完全一样。
字符设备、块设备、管道、socket,背后接的东西各不相同,但对上层来说,open / read / write / close 这套接口形状一致。
/proc:内核状态伪装成文件
/proc 比 /dev 更奇怪。
cat /proc/cpuinfo # CPU 型号、核心数、缓存大小
cat /proc/meminfo # 内存占用详情
cat /proc/$$/maps # 当前 shell 的内存映射表
这个目录里没有任何东西真正存在磁盘上。整棵目录树是内核运行时虚构出来的,读 /proc/cpuinfo 的时候,内核实时生成那段文字,读完就扔了,不占磁盘块。
再极端一点是 /proc/self/mem:有权限的话,打开它、seek 到某个内存地址再写,就是在改当前进程的运行时内存。gdb 这类工具在特定场景下会走这个接口,但日常调试更多还是 ptrace,不是随便哪个进程都能读写别人的 /proc/PID/mem。
还有 /sys,挂的是硬件和内核拓扑的实时状态:
cat /sys/class/hwmon/hwmon0/temp1_input
# 45000,单位 millidegree,就是主板上 45°C
没有驱动 SDK,没有厂商 API,cat 就能读传感器。
open() 返回的那个整数
/dev 的设备、/proc 里的虚构文件、管道、网络 socket,这些东西能被同一套工具操作,根本是因为 Linux 在它们背后放了同一个抽象:文件描述符(fd)。
open()、socket()、pipe()、epoll_create1() 返回的都是整数 fd,read(fd, buf, n) 和 write(fd, buf, n) 接受这个整数,不管背后是什么。磁盘文件、设备节点、管道、Unix socket、timerfd(定时器)、epoll 实例,对这两个调用来说全一样。
那 read 一个磁盘文件和 read 一个 socket,内核内部怎么知道该走哪套逻辑?靠的是 VFS 这层。每个打开的 fd 在内核里对应一个 struct file,里面挂着一个 file_operations 函数指针表,.read、.write、.poll 这些指针,指向具体那类东西自己的实现。
// 简化版:内核读 fd 时干的事
structfile *f = fdtable[fd];
return f->f_op->read(f, buf, count, &pos);
磁盘文件的 f_op 指向 ext4 的读实现,socket 的指向网络栈的,/proc/cpuinfo 的指向那个"读的时候现生成文字"的函数。read() 系统调用本身只做一件事:查到这个 fd 的 f_op,调它的 .read。所谓"一切皆文件",落到代码上就是这张函数表,上层看到统一的 read/write,下层每类资源填自己的指针。写一个字符设备驱动,核心工作很大一部分就是填这张表。
shell 管道就是这么运作的:
ls | grep ".md"
内核把 ls 的 stdout fd 换成了管道写端,把 grep 的 stdin fd 换成了管道读端。ls 以为在往终端输出,grep 以为在从终端读,两个程序一行代码没改,数据就在内部流通了。重定向同理,ls > out.txt 就是把 ls 的 stdout fd 指向那个文件。grep 从来没写过"支持管道"的特殊逻辑,它只管从 fd 0 读、往 fd 1 写,内核在外面换了 fd 指向,剩下的全适配了。
一个 Nginx worker 为什么能扛几千个连接
fd 统一到极致就是 epoll。
int epfd = epoll_create1(0); // epoll 实例本身也是个 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, &ev); // 监听网络连接
epoll_ctl(epfd, EPOLL_CTL_ADD, timer_fd, &ev); // 监听定时器
epoll_ctl(epfd, EPOLL_CTL_ADD, signal_fd, &ev); // 监听信号
epoll_wait(epfd, events, MAX_EVENTS, -1); // 一次等所有
网络连接、定时器、signalfd 收的信号,三种完全不同的事件源,一个 epoll_wait 全收了。能这么干,是因为它们都是 fd,都能被 epoll 管。Nginx 每个 worker 里的单线程事件循环就是建在这上面的,fd 这个抽象从 /dev/urandom 一路用到了 epoll 实例本身。
但这套其实不彻底
说"一切皆文件",Linux 远没做到底。
最明显的就是网络。建连接用的是 socket() / bind() / connect() / setsockopt() 一套独立的系统调用,而不是 open("/net/tcp/...")。socket 拿到手之后能当 fd 来 read/write,但创建和配置阶段完全在文件抽象之外。System V 的共享内存、信号量也是另起炉灶的 shmget / semget,跟文件不沾边。
真把这个理念贯彻到底的是 Plan 9,贝尔实验室那帮人在 Unix 之后做的系统。那上面网络连接是 /net/tcp/ 下的文件,往里写 connect 1.2.3.4!80 就建好了连接,进程、窗口、甚至远程机器的资源全是文件,跨网络也一样。Linux 把这个理念用到七八成就停了,网络、IPC 这些历史包袱留在了外面。
知道这条边界,比单纯记住"一切皆文件"有用:碰到一个资源不确定能不能当文件操作时,先看它有没有独立的系统调用。有,就说明它在抽象外面,得按它自己那套来。
生产上最直接的用处
一个后台服务怀疑有 fd 泄漏,连接数一直在涨,但流量没有对应增长:
# 当前进程开了多少 fd
ls /proc/$(pgrep 服务名)/fd | wc -l
# 每个 fd 指向什么
ls -la /proc/$(pgrep 服务名)/fd
输出大概长这样:
lrwx------ 1 app app 64 Jun 26 09:30 15 -> socket:[123456]
lrwx------ 1 app app 64 Jun 26 09:30 16 -> /tmp/lock.tmp (deleted)
lrwx------ 1 app app 64 Jun 26 09:30 17 -> socket:[123457]
lrwx------ 1 app app 64 Jun 26 09:30 18 -> socket:[123458]
fd 16 指向一个已被删除的临时文件,文件删了但没调 close(),fd 还开着,磁盘空间不释放。socket fd 一行接一行,通常是连接建了没关。两种坑,查法一样,修法完全不一样。
"一切皆文件"听上去像句口号,生产里它具体的意思是:进程当前打开了什么、指向哪里,内核已经摊开在 /proc/PID/fd/ 里。APM 还没挂上、代码一行没动,很多时候 ls /proc/PID/fd 已经把方向收窄了。pipe 到 epoll 到 io_uring,接口换了几轮,这一层没动过。
排 fd 泄漏的时候,大家通常是先看到什么异常才把怀疑落到这上面的,连接数、磁盘占用,还是别的信号,评论区说说。