资讯动态

C++服务端必知:Linux文件系统接口open/read/write/lseek实战

发布时间:2026/10/15 5:40:01 来源:尧图企业网站定制
很多写 C 服务端的朋友文件操作一开始都是从fopen/fread/fwrite入的门。用顺手之后你会发现C 的fstream和 C 库的fopen系列能覆盖绝大多数日常需求读配置、写日志、存数据简单几行就搞定。但项目一旦复杂起来你迟早会碰到几个绕不过去的问题怎么保证追加写是原子的怎么知道read到底有没有把数据读完为什么明明文件里有 100 个字节fread返回值却怪怪的更有意思的是日志句柄泄露的时候lsof一查发现同一个文件挂了好几个描述符到底哪个才是根源这类问题最后都要回到操作系统提供的系统接口上找答案。这篇文章我打算把open/read/write/lseek/close这一组最核心的文件操作系统接口拆开来讲配合实际代码和踩坑记录把这几个接口从会调用讲到敢放心用。内容基于 Linux 环境默认你已经有基础的 C/C 编程经验但不要求你背过任何系统编程的书。1. 文件描述符整数的背后是一张进程级的文件表先说一个最基础但也最容易被忽略的事实Linux 里文件操作的系统接口几乎全部围绕文件描述符file descriptor简称 fd工作。fd 就是一个整数0、1、2 分别是标准输入、标准输出、标准错误之后你每次打开文件内核返回的一般是 3、4、5……依次递增。这个整数不是随便分配的。内核为每个进程维护着一张打开文件表open file table表里的每一项指向一个struct file对象。struct file里保存的是当前文件偏移量、打开时的标志flags、引用计数等信息。fd 就是这张表的下标open()返回的整数本质上是这张表第几项。理解这个概念很多后续问题都能解释得通。比如同一个文件被open两次会得到两个不同的 fd它们指向两个独立的struct file对象各有各的文件偏移量。也就是说int fd1 open(/tmp/data.txt, O_RDONLY); int fd2 open(/tmp/data.txt, O_RDONLY); char buf[16]; read(fd1, buf, 8); // 从偏移 0 读 read(fd2, buf, 8); // 也是从偏移 0 读互不影响如果你在写多线程或者多进程程序这里有个非常关键的分水岭两个 fd 即使指向同一个文件offset 也是各自独立的但同一个 fd 被 fork 或者 dup 复制之后offset 是共享的。后面第 5 节我会专门讲 dup 和 fork 的传染关系这里先记住结论——因为很多并发写文件的 bug 就是从这里冒出来的。还有一个日常排查有用的技巧为什么程序一启动lsof看到的第一个打开的 fd 往往是 3因为 0、1、2 被默认占用了。如果你写了一个 daemon 进程希望它完全不依赖标准输入输出你甚至可以手动 close 掉 0、1、2但绝大多数情况下没人这么干内核分配 fd 永远选当前最小的空闲整数这个规则倒是值得记一下因为后面讲到dup2做标准输出重定向时依赖的正是这个规律。回想一下实际开发中的场景文件句柄泄露十有八九不是忘 close而是close 的地方没走到。因为 fd 本质是进程资源每打开一个就占一个表项进程能打开的文件数有上限ulimit -n可以查。排查泄露问题时先看lsof -p pid | grep deleted再看代码里所有的open分支通常半小时能定位。2. open 的 flags 和 mode权限、创建、截断一个参数搞定一半的事open()的原型看起来很简单#include fcntl.h #include sys/stat.h int open(const char *pathname, int flags, ... /* mode_t mode */);但参数flags的讲究非常多。基础的三元组是访问模式O_RDONLY只读、O_WRONLY只写、O_RDWR读写。这三个值对应低 2 位不能叠加使用。其余都是修饰性的 flag按位或叠加。我最常用的组合和意图整理成了一张表flags 组合实际效果适用场景O_RDONLY只读打开不存在则失败读配置文件、读日志O_WRONLY | O_CREAT | O_TRUNC只写不存在就创建存在就清空覆盖写输出文件O_WRONLY | O_CREAT | O_APPEND只写不存在就创建每次写追加到末尾日志文件、追加记录O_RDWR | O_CREAT读写不存在就创建不清空需要原地修改的数据文件O_RDONLY | O_CLOEXEC只读并在 exec 时自动关闭防止句柄意外泄漏给子进程这里重点说三个容易被坑的点。第一个是O_CREAT搭配的mode参数会被umask过滤。很多新手写open(path, O_CREAT | O_WRONLY, 0666)以为文件权限一定是 0666结果一看ls -l是 0644。原因不是 open 有 bug而是进程的 umask 默认是 0022实际权限 mode ~umask。0666 ~0022 0644。如果你真的需要 0666比如要让同组的人也能写先umask(0)再 open或者接受这个事实不要在权限上死磕。第二个是O_CREAT | O_EXCL的原子性。这两个 flag 组合的含义是如果文件已经存在open 直接失败返回 -1且 errno 设为 EEXIST如果不存在则创建。它最值钱的地方在于检查存在性 创建是在内核里一步完成的中间没有时间窗口。如果你先stat()判断文件不存在再open()创建中间另一个进程恰好创建了同一个文件你的open就会静默打开那个别人刚建好的文件这就是典型的 TOCTOUTime-Of-Check to Time-Of-Use问题。所以需要只允许我创建、不允许覆盖已有文件的场景务必用O_CREAT | O_EXCL不要自己先 stat。第三个是O_CLOEXEC。这个 flag 在open时加上功能是当进程调用exec*族函数比如execve去执行另一个程序时内核会自动关闭这个 fd。不加的话fd 默认是继承的新 exec 出来的程序里也会看到同一个 fd等于把你的文件句柄泄漏给了完全不知情的子进程。比较典型的翻车现场是主进程打开了一个监听 socket 或者一个日志文件然后system()调了一个外部命令外部命令一直占着这个 fd导致你想 close 的时候发现引用计数总是不为 0。现在的代码规范里凡是面向可能被 exec的 fd我都会顺手带上O_CLOEXEC成本为零收益极大。3. 读写语义返回值与 offset 之间藏着大部分 bugread和write是文件操作的主干但它们的行为和很多人直觉里的fread/fwrite有一个显著差别系统接口尽力而为一次调用不一定处理完你请求的全部字节。先看read的返回值约定ssize_t read(int fd, void *buf, size_t count);返回 0实际读到的字节数可能小于 count。返回0表示到达文件末尾EOF。返回-1出错具体原因查 errno。对应的writessize_t write(int fd, const void *buf, size_t count);返回 0实际写入的字节数可能小于 count。返回-1出错。正常情况下 write 不会返回 0除非 count 传了 0。可能小于 count不是吓唬人。普通磁盘文件上read一次给足 4096 字节通常都能读满但只要是管道、socket、终端设备或者被信号打断短读short read非常常见。write更是如此向一个磁盘快满的文件系统写、向非阻塞 socket 写都可能只写了一半就返回。这也解释了为什么很多人在写网络代理时直接拿write的返回值当全写完用结果数据对不上。正确的做法是封装读满/写完的循环我通常会在工具库里放两个函数ssize_t read_full(int fd, void *buf, size_t count) { size_t total 0; while (total count) { ssize_t n read(fd, (char *)buf total, count - total); if (n 0) break; // EOF读不满就是读不满 if (n 0) { if (errno EINTR) continue; // 被信号打断重试 return -1; } total n; } return total; } ssize_t write_full(int fd, const void *buf, size_t count) { size_t total 0; while (total count) { ssize_t n write(fd, (const char *)buf total, count - total); if (n 0) { if (errno EINTR) continue; return -1; } total n; } return total; }顺带说一句EINTR 是什么我会在第 6 节详细讲。这里先把它当成调用被信号打断需要重试来处理就行。有了这两个函数大多数数据写不全的坑可以直接绕开。再说 offset 的推进。每次read或write成功之后内核都会自动把文件偏移量往后挪N 个字节N 就是函数返回值。这个动作是隐式的很多人没意识到直到有一天你用O_APPEND写日志发现数据总是追加到末尾才意识到原来 write 不是从当前位置写吗。这里的关键点在于普通write从当前 offset开始写写完把 offset 后移。如果你同时开了另一个 fd 读同一个文件读的位置只跟读 fd 自己的 offset 有关。O_APPEND模式每次write之前内核先把 offset 移到文件末尾再写而且这个移到末尾写入是一个原子操作。为什么日志所以必须用O_APPEND因为如果没有这个原子性两个进程各自 write 时都要先lseek到末尾再写。两个进程的 lseek 和 write 一旦交错后一次 lseek 会把前一次已经写好的内容覆盖掉。O_APPEND从内核层面保证了多进程追加也不打架这是日志类程序最基础的可靠性保障别自己用 lseekwrite 去模拟它。4. lseek文件游标与空洞文件的秘密lseek这个函数名容易让人误会它并不执行任何磁盘 IO纯粹就是修改文件描述符对应的struct file里的偏移量。原型#include unistd.h off_t lseek(int fd, off_t offset, int whence);whence有三个取值SEEK_SET把 offset 设为 offset。SEEK_CUR把 offset 设为当前 offset offset。SEEK_END把 offset 设为文件长度 offsetoffset 传 0 就是定位到文件末尾。返回值是定位之后的字节偏移量。所以一个常见的获取文件大小的方法是off_t size lseek(fd, 0, SEEK_END);这个技巧很实用但它有个副作用把当前偏移量改到了文件末尾。如果你想量完尺寸再回到原地记得先保存原来的偏移off_t saved lseek(fd, 0, SEEK_CUR); off_t size lseek(fd, 0, SEEK_END); lseek(fd, saved, SEEK_SET);真正有意思的是越过文件末尾写数据会怎样。假设文件当前长度为 0你先lseek(fd, 1 * 1024 * 1024, SEEK_SET)然后写一个字节。从逻辑上看文件大小变成了 1MB1 字节但中间那 1MB 并没有在磁盘上分配实际数据块它们被称为空洞。这样的文件叫稀疏文件sparse file最直接的验证方式是ls -lh sparse.img # 逻辑大小1.0M du -h sparse.img # 实际占用4K 左右ls显示的是文件的逻辑长度du显示的是真实占用的磁盘块数量。很多下载工具在下载大文件之前会先ftruncate或者 lseekwrite 把文件预分配成目标大小目的就是让文件系统知道这个文件会长这么大下载过程中随机写入时效率更高也避免磁盘被写满到一半才发现不够。另外稀疏文件还有一个好处逻辑上占很大的空间物理上几乎不占磁盘做镜像、做快照临时文件时特别好用。读稀疏文件的空洞区域不会报错read只会返回 0。也就是说你读到一个全是 0 的大文件时不一定是文件真的存了 0很可能是空洞。这个特性有时候会制造错觉——比如有人统计文件里的数据量发现文件大小 10GB内容却全是 0觉得磁盘损坏了其实只是稀疏文件而已。还有一个容易被混淆的点ftruncate可以把文件截断也可以把文件扩大。如果当前文件长度是 100ftruncate(fd, 1000)会让文件变成 1000 字节新增的 900 字节同样是空洞。很多预分配文件大小的代码其实根本不需要 lseekwrite 那一通操作一个ftruncate就完事。5. 系统接口与 C 库函数如何协作FILE*、缓冲区与 fd 混用的大坑你在 C 语言里用的fopen/fread/fwrite/fclose底层最终都会调用open/read/write/close。差别在于C 库在 fd 之上加了一层用户态缓冲。FILE*内部维护了一个缓冲区、一个 fd以及当前读写位置状态。fread不是你请求多少字节就从内核读多少字节而是会按缓冲区的策略一次性读一大批典型 4096 字节然后放在用户态缓冲区里再分小块给你。这就产生了一个经典的混用大坑先用fread读了一点内容然后直接拿fileno(fp)得到的 fd 去调用read你会发现read读到的是缓冲区之外的内核数据而你前脚fread读进来的那部分还在 FILE* 的用户态缓冲区里内核根本不知道它们已经被消费了。换句话说同一个文件位置一个由用户态缓冲区记账一个由内核 offset 记账两边对不上数据自然错乱。解决办法很简单同一个文件不要同时用 FILE和裸 fd 操作。* 如果非得在两种模式之间切换用fflush(fp)把用户态缓冲区的数据推给内核写方向或者用fseek(fp, offset, SEEK_SET)让用户态缓冲区和内核 offset 重新同步读方向。最彻底的兜底办法是fflush(NULL)它会把当前进程所有流缓冲全部刷新代价是性能差一点但排查问题时非常好用。现在说fileno和fdopen这两个桥接函数int fileno(FILE *fp)把 FILE* 转换成底层的 fd。FILE *fdopen(int fd, const char *mode)把已有 fd 包装成 FILE*之后用 fread/fwrite 操作。fdopen 最常见的用途是网络库只给了你 fd而你想用 C 库函数做格式化输出。比如把一个连接 fd 包装成 FILE*然后fprintf(fp, HTTP/1.1 200 OK\r\n)比手写write拼接方便得多。但用 fdopen 有个致命的注意点fdopen 出来的 FILE关闭时会同时关闭底层的 fd*。如果你在这个 fd 上又做了dup或者别的代码段也 close 了同一个 fdclose 两次就会出问题。我自己的习惯是一旦把 fd 包成 FILE*就把它当普通文件流管理所有操作走 FILE*不再直接碰原始 fd最后只fclose一次省心。再补充一下dup/dup2的语义它俩复制的是打开文件表项的引用不是复制 file 对象。所以dup出来的新 fd 和原 fd共享同一个 offset这一点和open两次完全不同。典型应用是dup2(fd, STDOUT_FILENO)把标准输出重定向到文件。顺着这个思路你会明白 fork 之后的场景子进程继承了父进程的 fd 表父子进程里的同一个 fd 指向同一个 file 对象offset 也共享。如果父进程在 fork 前后的日志轮转里先lseek再write很容易出现父子进程互相覆盖的情况。处理办法还是优先考虑O_APPEND或者pwrite指定偏移量的写别依赖当前 offset这种全局状态。6. 错误处理与实战排错别只在失败时打印一行字系统接口和库函数还有一个重要差别系统接口失败时返回 -1并且设置全局变量errno。好消息是 errno 在 Linux 上是线程局部的两个线程同时出错互不影响坏消息是它只在返回 -1 时才有意义成功时 errno 里的值不可靠。最常见的错误码打个表errno含义最常出现的场景EACCES权限不足mode 不对、目录不可写EBADFfd 无效或访问模式不符只读 fd 调 writeEEXIST文件已存在O_CREAT | O_EXCL创建失败EINTR系统调用被信号中断读阻塞时收到信号EAGAIN资源暂时不可用非阻塞模式下无数据可读ENOSPC磁盘空间不足写文件时磁盘满EISDIR对目录执行了读操作把目录当文件 open 后 read其中EINTR是新手最容易误判的一个。当你正在read一个慢速设备或者管道时突然来一个信号比如 SIGALRM内核可能让read直接返回 -1 并设置EINTR而实际上文件数据没问题、fd 也没问题纯粹是调用被信号打断了。这时候正确的姿势就是像第 3 节read_full那样检测到EINTR就重试。如果程序里做不到系统调用自动重启就必须在每个 read/write 调用点加这个判断。实战排查一条文件相关 bug 的完整思路大概是这样的先看返回值。凡是 -1立刻打印 errno 和对应的字符串我习惯用perror或者自己写一行fprintf(stderr, %s: %s\n, func, strerror(errno))。如果代码里没有打印 errno直接上strace -e tracefile,desc ./your_program能看到每一步 open/read/write/close 系统调用的参数、返回值和错误码。这是排查文件句柄问题的终极大杀器比看日志快得多。确认是不是 offset 问题。把文件打开前后前后各打一次lseek(fd, 0, SEEK_CUR)能很快发现偏移被谁动了。最后检查 fd 数量。程序运行一段后ls /proc/pid/fd | wc -l如果持续增长用lsof -p pid看具体 fd 落在哪些文件上再回代码里找 open 没关的位置。我现在的习惯是凡是需要控制偏移、设置非阻塞、或者要跨进程共享文件状态的场景一律直接用open/read/write/lseek而简单读配置文件、写纯文本日志就用 C 库函数或者 C 的fstream。两者混用的前提永远是同一个文件位置只让一个人管。文件操作这块大多 bug 不是接口复杂而是使用者对谁在维护 offset、谁在维护缓冲区、谁负责 close这三件事没想清楚。把这三条捋顺再复杂的文件逻辑也能有条不紊地写下去。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑