linux系统编程(七):文件描述符基础详解

📅 2026/8/8 10:24:19
linux系统编程(七):文件描述符基础详解
文件描述符基础详解 —— Linux一切皆文件的真正起点在 Linux 里文件、socket、管道、设备、定时器、信号 …… 几乎所有 I/O 资源都用一个小整数表示文件描述符file descriptorfd。这篇把open / read / write / close / lseek / dup / fcntl这套 fd 操作的基础讲清楚理解之后再看高级特性多路复用、mmap、io_uring都会顺很多。0. 引言一切皆文件是什么意思intfd1open(/tmp/data,O_RDONLY);/* 普通文件 */intfd2socket(AF_INET,SOCK_STREAM,0);/* 网络 socket */intfd3open(/dev/tty,O_RDWR);/* 终端设备 */intfd4;intpipefd[2];pipe(pipefd);/* 管道 */fd4pipefd[0];charbuf[100];read(fd1,buf,100);/* 读文件 */read(fd2,buf,100);/* 收网络包 */read(fd3,buf,100);/* 读键盘 */read(fd4,buf,100);/* 读管道 */四种完全不同的资源用同一个read函数和同一种 fd 类型操作。这就是 Unix 一切皆文件哲学的精髓 —— 用一套 API 屏蔽所有 I/O 资源的差异。1. fd 到底是什么fd是个小整数一般 0-1023是当前进程文件描述符表的下标。进程的 fd 表fd是整数索引内核中每个槽存一个struct file *fd指向0struct file(stdin)1struct file(stdout)2struct file(stderr)3struct file(/tmp/data)4struct file(socket)……每个槽指向内核的struct file里面包含偏移 / 引用计数 / 操作函数表 / 打开模式等。每个 fd 在用户空间是一个 int对应内核里的struct file。struct file又指向struct inode真正的文件元数据。多个 fd 可能指向同一个 filedup 出来的多个 file 可能指向同一个 inode不同进程 open 同一个文件。1.1 三个标准 fd#defineSTDIN_FILENO0#defineSTDOUT_FILENO1#defineSTDERR_FILENO2进程启动时由 shell / init 准备好分别连到终端的输入、输出、错误流。write(1,hello\n,6);/* 等价 printf(hello\n) flush */1.2 fd 是可继承的fork后子进程继承父进程所有 fd指向同一个 file共享偏移。exec后默认也保留除非设了O_CLOEXEC。2. open —— 打开文件拿到 fd#includefcntl.hintopen(constchar*path,intflags,.../* mode_t mode */);intopenat(intdirfd,constchar*path,intflags,...);2.1 必选 flags模式flag含义O_RDONLY只读O_WRONLY只写O_RDWR读写2.2 常用可选 flagsflag含义O_CREAT不存在则创建要配mode参数O_EXCL配合O_CREAT已存在则失败O_TRUNC已存在则截断到 0O_APPEND写时追加每次write自动lseek到末尾O_NONBLOCK非阻塞 IOO_CLOEXECexec时自动关闭O_DIRECTORY必须是目录O_PATH只用作目录 fd不读不写O_TMPFILE创建匿名临时文件Linux 扩展O_DSYNC/O_SYNC写后同步刷盘2.3 例子/* 只读打开 */intfdopen(/etc/passwd,O_RDONLY);/* 写、不存在就创建、权限 0644 */intfdopen(out.txt,O_WRONLY|O_CREAT|O_TRUNC,0644);/* 打开但 exec 后关闭避免泄漏到子进程 */intfdopen(/var/log/app.log,O_WRONLY|O_APPEND|O_CREAT|O_CLOEXEC,0644);/* 安全的创建独占模式避免覆盖 */intfdopen(lockfile,O_WRONLY|O_CREAT|O_EXCL,0600);if(fd0errnoEEXIST){/* 文件已存在 */}2.4 O_CLOEXEC 是必选多线程程序里 open 不带 O_CLOEXEC 是潜在 bug另一个线程可能正在 forkexec你刚 open 的 fd 会被泄漏到子进程。这是经典的安全漏洞来源。intfdopen(path,O_RDONLY|O_CLOEXEC);/* 写就对了 */3. read / write —— 数据搬运#includeunistd.hssize_tread(intfd,void*buf,size_tcount);ssize_twrite(intfd,constvoid*buf,size_tcount);返回值 0实际读/写的字节数0read 时表示 EOFwrite 时一般不返回 0-1出错看 errno3.1 短读 / 短写必须循环read不保证一次读满count字节文件可能读到末尾就提前返回管道 / socket对端没那么多数据先返回已有的信号中断EINTRwrite不保证一次写完count字节内核缓冲满pipe/socket信号中断磁盘满部分写入正确写 IO 必须循环ssize_twrite_all(intfd,constvoid*buf,size_tn){constchar*pbuf;while(n0){ssize_twwrite(fd,p,n);if(w0){if(errnoEINTR)continue;/* 信号打断重试 */return-1;}pw;n-w;}return0;}ssize_tread_all(intfd,void*buf,size_tn){char*pbuf;while(n0){ssize_trread(fd,p,n);if(r0){if(errnoEINTR)continue;return-1;}if(r0)break;/* EOF */pr;n-r;}returnp-(char*)buf;}3.2 pread / pwrite带偏移的版本ssize_tpread(intfd,void*buf,size_tcount,off_toffset);ssize_tpwrite(intfd,constvoid*buf,size_tcount,off_toffset);不修改 fd 的当前偏移。多线程并发读同一文件不同位置时必须用否则 read lseek 不原子。3.3 readv / writevscatter-gatherssize_treadv(intfd,conststructiovec*iov,intiovcnt);ssize_twritev(intfd,conststructiovec*iov,intiovcnt);structiovec{void*iov_base;size_tiov_len;};一次系统调用读/写多块不连续内存structioveciov[3];iov[0].iov_baseheader;iov[0].iov_lensizeof(header);iov[1].iov_basebody;iov[1].iov_lenbody_len;iov[2].iov_basefooter;iov[2].iov_lensizeof(footer);writev(fd,iov,3);/* 一次系统调用写完三块 */减少 syscall 次数适合协议封包。4. close —— 必须关但不一定立刻关intclose(intfd);关闭一个 fd。但是如果该 file 被多个 fd 共享dup 出来的只是引用计数 -1引用计数归 0 时才真的释放底层资源intfd1open(a,O_RDONLY);intfd2dup(fd1);close(fd1);/* 文件还活着fd2 可以继续用 */read(fd2,...);close(fd2);/* 现在文件真的关了 */close 失败也通常意味着已关。errno 可能是 EIO之前 write 的延迟错误现在才报但 fd 本身已无效不要重试 close。5. lseek —— 改文件偏移#includeunistd.hoff_tlseek(intfd,off_toffset,intwhence);/* whence: * SEEK_SET - 从文件开头 * SEEK_CUR - 从当前偏移 * SEEK_END - 从文件末尾 */例子lseek(fd,0,SEEK_END);/* 文件末尾返回文件大小 */off_tcurlseek(fd,0,SEEK_CUR);/* 拿当前偏移 */lseek(fd,100,SEEK_SET);/* 跳到 100 字节处 */lseek(fd,-1024,SEEK_END);/* 末尾前 1KB */5.1 lseek 不能用于 pipe/socket/fifo它们没有位置概念。返回 -1 ESPIPE。5.2 文件空洞lseek(fd,1*1024*1024*1024,SEEK_SET);write(fd,x,1);/* 中间 1GB 是空洞磁盘上不占空间 */读空洞会得到 0 字节。6. dup / dup2 / dup3 —— 复制 fdintdup(intoldfd);intdup2(intoldfd,intnewfd);intdup3(intoldfd,intnewfd,intflags);让两个 fd 指向同一个内核 file共享偏移、模式、锁。6.1 经典用法重定向 stdin/stdout/* 把 stdout 重定向到文件 */intfdopen(out.log,O_WRONLY|O_CREAT|O_TRUNC,0644);dup2(fd,STDOUT_FILENO);/* 关闭原 stdout让 1 指向 fd 指向的 file */close(fd);/* 不再需要原 fd文件还活着 */printf(this goes to out.log\n);6.2 shell 的重定向lsout.txtshell 内部pid_tpidfork();if(pid0){intfdopen(out.txt,O_WRONLY|O_CREAT|O_TRUNC,0644);dup2(fd,STDOUT_FILENO);close(fd);execvp(ls,argv);}6.3 shell 的管道|A | B内部intp[2];pipe(p);if(fork()0){/* 进程 A */dup2(p[1],STDOUT_FILENO);/* stdout → 管道写端 */close(p[0]);close(p[1]);execvp(A,...);}if(fork()0){/* 进程 B */dup2(p[0],STDIN_FILENO);/* stdin → 管道读端 */close(p[0]);close(p[1]);execvp(B,...);}close(p[0]);close(p[1]);wait(NULL);wait(NULL);6.4 dup3原子设 flagsdup3(oldfd,newfd,O_CLOEXEC);/* 原子 dup 设 close-on-exec */跟pipe2同思路避免 race window。多线程程序首选。7. fcntl —— fd 杂项操作#includefcntl.hintfcntl(intfd,intcmd,.../* arg */);万能 fd 操作工具常用的几个7.1 F_DUPFD / F_DUPFD_CLOEXECdup 加强版intnewfdfcntl(oldfd,F_DUPFD,100);/* 找 ≥100 的最小可用 fd */intnewfdfcntl(oldfd,F_DUPFD_CLOEXEC,0);/* 同时设 CLOEXEC */7.2 F_GETFD / F_SETFDfd 标志仅有 FD_CLOEXECintflagsfcntl(fd,F_GETFD);fcntl(fd,F_SETFD,flags|FD_CLOEXEC);/* 老办法今天用 O_CLOEXEC 更好 */7.3 F_GETFL / F_SETFL文件状态标志intflagsfcntl(fd,F_GETFL);fcntl(fd,F_SETFL,flags|O_NONBLOCK);/* 改非阻塞 */fcntl(fd,F_SETFL,flags~O_NONBLOCK);/* 改回阻塞 */7.4 F_SETLK / F_SETLKW建议性文件锁structflocklk{.l_typeF_WRLCK,/* 写锁 */.l_whenceSEEK_SET,.l_start0,.l_len0,/* 0 锁到末尾 */};fcntl(fd,F_SETLKW,lk);/* 阻塞获取 *//* ... */lk.l_typeF_UNLCK;fcntl(fd,F_SETLK,lk);仅在所有进程都用 fcntl 锁时才有效advisory lock建议性。7.5 F_SETPIPE_SZ / F_GETPIPE_SZ调 pipe 缓冲大小fcntl(pipe_fd,F_SETPIPE_SZ,1024*1024);/* pipe 缓冲调到 1MB */8. 文件描述符表 vs 打开文件表 vs i-node 表Linux 内核有三层数据结构理解这三层后下面这些行为就解释通了8.1 fork 后父子共享 file同一个 offsetintfdopen(a,O_RDONLY);fork();read(fd,buf,10);/* 父和子都读这个 fd谁先读谁拿前 10 字节 */read(fd,buf,10);/* 另一个再读从第 10 字节开始 */8.2 dup 后两个 fd 共享 file同一 offsetintfd2dup(fd1);read(fd1,buf,10);read(fd2,buf,10);/* 接着读offset 继续走 */8.3 两次 open 同一个文件 → 两个 file不同 offsetintaopen(x,O_RDONLY);intbopen(x,O_RDONLY);read(a,buf,10);/* a 的 offset 10 */read(b,buf,10);/* b 的 offset 10独立*/9. /proc/PID/fd —— 看进程打开了什么调试神器$ls-l/proc/$$/fd0-/dev/pts/01-/dev/pts/02-/dev/pts/03-/var/log/app.log4-socket:[123456]5-pipe:[789012]每个进程的 fd 都能在这里看到文件名也能反查。调fd 泄漏必备。lsof -p PID是更友好的工具。10. 几个常见坑10.1 fd 泄漏intfdopen(...);if(something_failed)return-1;/* ⚠️ fd 没 close */每个错误路径都要 close。C 没 RAII纪律 cleanup_push goto 错误处理。10.2 close 后 fd 立即可被复用close(3);/* 如果在其他地方还在用 fd 3跨线程、信号处理现在再 read(3, ...) 可能读到刚 open 的新文件*/intnewfdopen(...);/* 经常拿到 3 */close 后立刻fd -1避免 use-after-close。10.3 短读短写没循环90% 的 IO bug 来源。所有生产代码都要read_all/write_all包装。10.4 忘记 O_CLOEXEC多线程 forkexec 场景的隐藏 bug。10.5 用 lseek read 代替 pread多线程两个线程并发Alseek Blseek Aread BreadA 的偏移被 B 改了。多线程同 fd 用 pread/pwrite。10.6 不同进程 mmap 同一文件 vs readmmap 的更新对其他进程立即可见共享映射read 是拷贝快照。11. 总结表函数用途关键点open / openat打开文件返回 fdflags 必有模式推荐加 O_CLOEXECread / write字节搬运必须循环处理短读短写pread / pwrite带偏移版本多线程并发同 fd 必备readv / writevscatter-gather一次 syscall 多块内存close关闭失败也算关fd -1防 use-after-closelseek改文件偏移pipe/socket 不支持dup / dup2 / dup3复制 fdshell 重定向、管道用fcntl杂项操作改 flags、文件锁、pipe size 等/proc/PID/fd看进程打开了什么调试 fd 泄漏神器12. 收尾fd 是 Linux 系统编程的最核心抽象。所有更高级的概念都建立在 fd 之上多路复用select/poll/epoll操作 fd 集合mmap 把文件映射成内存但起点还是 fd网络编程的 socket fd异步 IOio_uring也是用 fd理解了 fd 的三层表fd 表 → file 表 → inode 表fork 共享、dup 复制、O_CLOEXEC 这些细节就一目了然。理解了短读短写要循环、所有错误路径都要 close写出来的 IO 代码就稳。