系统编程中的文件 I/O:标准 IO 与系统调用 1. 什么是系统编程中的文件 I/O系统编程关注的是如何直接与操作系统打交道其中「文件 I/O」是最核心的主题之一。在 Linux 中「一切皆文件」普通磁盘文件、目录、键盘、显示器、管道、套接字、设备终端等都统一抽象为文件通过文件接口完成输入输出。理解 I/O 时首先要明确输入输出的参考点输入数据从外部设备写入内存称为输入。默认的输入设备是键盘即标准输入stdin对应/dev/input。输出数据从内存写出到外部设备称为输出。默认的输出设备是显示器即标准输出stdout。可以说Linux 操作系统中的 I/O 都是对文件的操作。在 C 语言里与文件操作相关的函数主要分为两套标准 I/OC 库提供的、跨平台的文件操作函数例如fopen、fgetc、fwrite操作对象是FILE*文件流指针。文件 I/OLinux 提供的系统调用例如open、read、write、lseek操作对象是文件描述符。本文以 Linux/C 环境为主先介绍标准 I/O 函数再深入文件 I/O 系统调用最后补充文件描述符、缓冲区与目录操作。关于这些函数不需要死记硬背。Linux 提供 man 手册需要时可以随时查询man 1 xxx查看某个命令的使用手册。man 2 xxx查看某个 Linux 系统调用函数。man 3 xxx查看某个标准 C 库函数例如man 3 fopen、man 3 stat。2. 标准 I/OC 库文件操作函数标准 I/O 是 C 库中关于文件操作的一套函数。它不直接与内核交互而是对系统调用进行了封装并自带用户态缓冲区因此具备跨平台特性。使用标准 I/O 操作文件一般遵循三个步骤打开文件调用fopen拿到文件流指针FILE*后续所有操作都通过该指针进行。读写文件根据数据粒度选择fputc/fgetc、fputs/fgets或fread/fwrite。关闭文件调用fclose释放相关资源。当不再需要对文件进行读写操作时就可以关闭文件。2.1 fopen打开文件并建立流FILE*fopen(constchar*path,constchar*mode);功能打开一个文件并建立一个流。参数path要打开文件的路径。mode打开模式。返回值成功返回建立的文件流指针失败返回NULL。mode的常见取值如下模式含义r只读文件不存在则报错存在则只读打开r读写文件不存在则报错存在则读写打开w只写文件不存在则创建存在则清空后只写打开w写读文件不存在则创建存在则清空后写读打开a追加可写文件不存在则创建存在则从末尾追加只写打开a追加读写文件不存在则创建存在则从末尾追加读写打开2.2 字符读写fputc 与 fgetcintfputc(intc,FILE*stream);intfgetc(FILE*stream);fputc向流中写入一个字符成功返回写入字符的 ASCII 码值失败返回EOF。fgetc从流中读取一个字符成功返回读到字符的 ASCII 码值读到文件末尾返回EOF。2.3 行读写fputs 与 fgetsintfputs(constchar*s,FILE*stream);char*fgets(char*s,intsize,FILE*stream);fputs从s所在的本地内存中获取一行数据写入stream对应的文件流对象成功返回非负数失败返回-1。fgets从stream关联的文件中获取最多size字节的文本数据存储到s指向的本地内存成功返回指向有效数据的首地址失败或到达文件末尾返回NULL。当stream为stdin时fgets会阻塞等待键盘输入。2.4 块读写fread 与 fwritesize_tfread(void*ptr,size_tsize,size_tnmemb,FILE*stream);size_tfwrite(constvoid*ptr,size_tsize,size_tnmemb,FILE*stream);fread从指定流中获取nmemb个大小为size字节的数据块存储到ptr所在的内存成功返回实际获取的数据块个数该值小于等于nmemb读到文件末尾返回0。fwrite从ptr所在内存取出nmemb个大小为size字节的数据块写入stream对应的文件流对象成功返回实际写入的数据块个数。fread/fwrite常用于读写结构体数组或二进制数据当stream为stdin或stdout时也可以从键盘读取或向屏幕写入。2.5 文件定位fseek、ftell 与 rewindintfseek(FILE*stream,longoffset,intwhence);longftell(FILE*stream);voidrewind(FILE*stream);fseek将文件流中的文件指针从whence位置开始偏移offset字节。offset为正数向文件末尾偏移为负数向文件开头偏移whence可取SEEK_SET文件开头、SEEK_CUR当前位置、SEEK_END文件末尾。注意fseek一般用于普通文件不支持设备文件成功返回0失败返回-1。ftell获取当前文件流指针的位置返回从文件开头到当前指针的字节数失败返回-1。rewind把文件读写位置复位到文件开头。下面演示用fputs写入一行文本再用rewind回到开头后fgets读取#includestdio.hintmain(void){FILE*fpfopen(log.txt,w);if(fpNULL){perror(fopen);return1;}fputs(hello, standard io\n,fp);rewind(fp);charbuf[64]{0};fgets(buf,sizeof(buf),fp);printf(read: %s,buf);fclose(fp);return0;}3. 文件 I/O 系统调用open / write / read / lseek标准 I/O 的fopen等函数最终会调用操作系统提供的函数。操作系统为了方便用户使用系统功能对外提供的一组系统函数称为系统调用system call。C 库是为了通用性跨平台兼容 Linux、Windows 等而设计的函数接口文件 I/O 系统调用则可以直接使用 Linux 提供的功能。因此可以理解为C 库是对系统调用的封装C 库的函数内部会调用系统调用中的函数。3.1 标准 I/O 与文件 I/O 的区别两者最明显的区别有两点缓冲标准 I/O 自带用户态缓冲区文件 I/O 没有用户态缓冲区因此文件 I/O 更适合操作设备文件、网络 socket 等需要实时读写的场景。操作对象标准 I/O 操作的是文件流指针FILE*文件 I/O 操作的是文件描述符fd它是一个很小的非负整数取值范围通常为01023。每个程序启动时操作系统默认打开三个描述符并与标准流对象一一匹配文件描述符宏流对象含义0STDIN_FILENOstdin标准输入1STDOUT_FILENOstdout标准输出2STDERR_FILENOstderr标准错误3.2 open获得文件描述符intopen(constchar*pathname,intflags,intmode);功能打开文件并获得一个文件描述符。参数pathname文件名路径。flags打开标志常用取值如下。mode当flags中包含O_CREAT时用来指定新文件权限例如0644。返回值成功返回文件描述符失败返回-1。常用flags标志含义O_RDONLY只读O_WRONLY只写O_RDWR读写O_CREAT文件不存在时创建O_EXCL与O_CREAT同时使用表示文件必须不存在否则open失败O_NOCTTY不把设备当作控制终端O_TRUNC打开时清空文件内容O_APPEND追加写O_ASYNC异步 I/OO_NONBLOCK非阻塞3.3 write 与 readssize_twrite(intfd,constvoid*buf,size_tcount);ssize_tread(intfd,void*buf,size_tcount);write通过文件描述符fd向文件中写入一串数据。buf为待写入数据的首地址count为待写入字节数成功返回实际写入的字节数失败返回-1。read通过文件描述符fd读取数据。buf为存放数据的缓冲区首地址count为期望读到的字节数可以比文件有效数据更长实际长度由返回值提供成功返回实际读到的字节数失败返回-1读到文件末尾返回0。3.4 lseek定位文件偏移off_tlseek(intfd,off_toffset,intwhence);功能定位文件读写位置。参数offset偏移量正数向后偏移负数向前偏移0不偏移。whenceSEEK_SET文件开头、SEEK_CUR当前位置、SEEK_END文件末尾。返回值成功返回新的偏移量失败返回-1。3.5 完整示例下面的示例演示open创建文件、write写入、lseek重新定位、read读取以及close关闭#includestdio.h#includestdlib.h#includefcntl.h#includeunistd.h#includestring.hintmain(void){constchar*pathdemo.txt;constchar*msghello, file io\n;// 打开文件不存在则创建权限 0644intfdopen(path,O_CREAT|O_WRONLY|O_TRUNC,0644);if(fd-1){perror(open);returnEXIT_FAILURE;}// 写入ssize_twrittenwrite(fd,msg,strlen(msg));if(written-1){perror(write);close(fd);returnEXIT_FAILURE;}close(fd);// 重新以只读方式打开fdopen(path,O_RDONLY);if(fd-1){perror(open read);returnEXIT_FAILURE;}// 将文件偏移定位到开头if(lseek(fd,0,SEEK_SET)-1){perror(lseek);close(fd);returnEXIT_FAILURE;}charbuf[64]{0};ssize_tnread(fd,buf,sizeof(buf)-1);if(n-1){perror(read);close(fd);returnEXIT_FAILURE;}printf(read %ld bytes: %s,(long)n,buf);close(fd);returnEXIT_SUCCESS;}几个容易忽略的细节read返回0表示读到文件末尾返回-1表示出错。read不保证一次读完请求的全部字节循环读取是常见写法。write同样可能只写入部分数据尤其在管道、套接字等场景下。每次open都会消耗文件描述符资源忘记close可能导致 FD 泄漏。4. 文件描述符一切皆文件的基石在 Linux 中每个进程都维护一张「文件描述符表」。打开文件时内核返回一个非负整数称为文件描述符File Descriptor简称 FD后续对该文件的读写都由这个数字标识而不是文件名。前面已经看到每个进程默认拥有三个标准文件描述符文件描述符常量含义0STDIN_FILENO标准输入1STDOUT_FILENO标准输出2STDERR_FILENO标准错误文件描述符的本质是「进程打开文件表」的下标。它指向内核中的打开文件描述open file description后者再指向具体的文件、偏移量、访问模式等信息。这意味着同一个文件被打开多次会得到多个不同的文件描述符而dup系列调用则可以让多个文件描述符共享同一个打开文件描述从而共享文件偏移量。5. 缓冲区用户态缓冲与内核页缓存系统调用的代价不低因为它需要从用户态切换到内核态。为了减少切换次数引入缓冲是必要的。缓冲发生在两个层面用户态缓冲C 标准库的FILE*结构如fopen、fprintf内部维护缓冲区只有缓冲区满或调用fflush时才触发write系统调用。内核态缓冲内核维护页缓存page cachewrite通常只把数据从用户态拷贝到内核缓存就返回真正落盘由内核在合适时机完成。这也解释了常见现象printf(no newline);_exit(0);// 数据可能永远都看不到printf默认行缓冲没有换行符时数据停留在用户态缓冲区_exit直接终止进程不刷新缓冲区因此内容丢失。换用exit或补一个fflush(stdout)才可靠。write系统调用写入内核页缓存后数据尚未持久化到磁盘。若需要保证落盘应调用fsync或fdatasyncwrite(fd,data,len);fsync(fd);// 强制刷盘包含元数据数据库、日志系统对持久性要求高时会显式调用fsync这也是很多存储性能优化中需要谨慎权衡的点。6. 目录操作opendir / readdir / closedir在 Linux 中目录也被当作文件处理。对目录的操作同样遵循「打开 → 读取 → 关闭」三个步骤但使用的函数不同打开目录调用opendir获得DIR指针目录流指针它表示整个目录的信息。读取目录内容调用readdir获得struct dirent指针表示目录中的一个内容项。关闭目录调用closedir。注意「写入目录」如新建文件、删除文件、新建目录、删除目录等需要使用独立函数。6.1 核心函数DIR*opendir(constchar*name);structdirent*readdir(DIR*dirp);intclosedir(DIR*dirp);opendir打开一个目录获得目录流指针成功返回目录流指针失败返回NULL。readdir从目录流中读取文件信息并将保存信息的结构体地址返回。出错或读到目录流末尾返回NULL。closedir关闭之前已经打开的目录流对象成功返回0失败返回-1。readdir返回的struct dirent常用字段如下字段类型含义d_namechar[]目录项名称例如文件名或子目录名d_inoino_t目录项对应的 inode 号d_typeunsigned char目录项类型例如DT_REG、DT_DIR其中d_name最常用。d_type可以快速判断当前目录项是普通文件还是目录常见的宏有DT_REG普通文件DT_DIR目录DT_LNK符号链接DT_FIFO命名管道DT_SOCK套接字6.2 用 opendir/readdir 实现目录遍历下面的示例读取当前目录并打印所有条目核心思路与ls类似#includestdio.h#includedirent.hintmain(void){DIR*diropendir(.);if(dirNULL){perror(opendir);return1;}structdirent*entry;while((entryreaddir(dir))!NULL){printf(%s\n,entry-d_name);}closedir(dir);return0;}结合d_type可以区分文件类型while((entryreaddir(dir))!NULL){if(entry-d_typeDT_DIR){printf([目录] %s\n,entry-d_name);}elseif(entry-d_typeDT_REG){printf([文件] %s\n,entry-d_name);}else{printf([其他] %s\n,entry-d_name);}}6.3 递归遍历目录上面的示例只遍历一层目录。要实现类似find的递归遍历需要结合stat判断某个目录项是不是子目录并排除.和..#includestdio.h#includestring.h#includedirent.h#includesys/stat.hvoidlist_dir(constchar*path){DIR*diropendir(path);if(dirNULL){perror(opendir);return;}structdirent*entry;while((entryreaddir(dir))!NULL){// 跳过 . 和 ..if(strcmp(entry-d_name,.)0||strcmp(entry-d_name,..)0){continue;}charfull_path[1024];snprintf(full_path,sizeof(full_path),%s/%s,path,entry-d_name);printf(%s\n,full_path);structstatst;if(stat(full_path,st)0S_ISDIR(st.st_mode)){// 递归遍历子目录list_dir(full_path);}}closedir(dir);}intmain(void){list_dir(.);return0;}递归遍历时需要注意必须跳过.和..否则会无限递归。stat会跟随符号链接若链接指向目录也会被当作目录处理。如果担心符号链接成环可以使用lstat判断链接本身。6.4 目录辅助函数除了遍历目录系统编程中还经常用到以下辅助函数它们都属于系统调用intchdir(constchar*path);// 切换当前工作路径char*getcwd(char*buf,size_tsize);// 获取当前路径intmkdir(constchar*pathname,mode_tmode);// 新建目录intrmdir(constchar*pathname);// 删除目录被删除的目录必须为空intstat(constchar*path,structstat*buf);// 获取文件属性各函数使用要点chdir切换进程的当前工作目录成功返回0失败返回-1。切换后程序中使用相对路径的地方都会以新目录为基准。getcwd把当前工作目录的绝对路径写入buf缓冲区长度由size指定成功返回buf失败返回NULL。mkdir新建目录mode指定权限例如0755成功返回0失败返回-1。父目录必须已经存在。rmdir删除空目录目录不为空时删除失败需要先递归删除内部内容。stat获取文件属性最常用的成员是st_size表示文件大小字节st_mode则可用于判断文件类型和权限。下面演示创建目录、切换目录并打印当前路径#includestdio.h#includeunistd.h#includesys/stat.hintmain(void){if(mkdir(subdir,0755)-1){perror(mkdir);return1;}if(chdir(subdir)-1){perror(chdir);return1;}charcwd[1024]{0};if(getcwd(cwd,sizeof(cwd))!NULL){printf(cwd: %s\n,cwd);}return0;}stat获得文件大小的示例#includestdio.h#includesys/stat.hintmain(void){structstatst;if(stat(demo.txt,st)0){printf(size %ld bytes\n,(long)st.st_size);}return0;}借助st_mode和系统提供的宏可以判断文件类型宏含义S_ISREG普通文件S_ISDIR目录S_ISLNK符号链接S_ISFIFO命名管道S_ISSOCK套接字7. 性能优化实践建议围绕文件 I/O以下几个实践方向在工程中反复被验证合并小 I/O避免频繁调用小字节的read/write尽量批量处理。使用合理缓冲区用户态缓冲区不宜过小常见 4KB 到 64KB 之间需要结合业务实测。减少系统调用次数批量 I/O 能降低系统调用次数但对实时性敏感场景要权衡。善用页缓存顺序读、重复读可充分利用内核页缓存对绕过缓存的场景可用O_DIRECT降低缓存污染。按需刷盘不要无差别调用fsync应在数据一致性和吞吐之间找到平衡。按场景选择接口普通文本处理可用标准 I/O 借助缓冲提升性能操作设备文件或网络套接字时优先使用文件 I/O 系统调用。8. 总结系统编程中的文件 I/O 并不只是「读写文件」它串联起标准 I/O、文件描述符、内核缓冲与目录操作等一连串操作系统核心机制。理解这些内容后再看框架与中间件的设计会更加清晰日志库为何批量刷盘、数据库为何管理自己的写缓冲、文件传输服务为何选择零拷贝以及各类工具为何都要实现目录遍历背后都是对系统级 I/O 成本的精确控制。写出正确、高效的文件 I/O 代码核心在于理解标准 I/O 与文件 I/O 的区别、每一层缓冲的边界以及每一次系统调用的真实成本。