Linux(23):文件操作入门——从 C 文件接口到文件描述符

文件操作可以从两层接口理解:C 标准库提供FILE *流,Linux/POSIX 接口使用整数形式的文件描述符。两层接口最终都与进程访问文件有关,但它们提供的抽象和数据处理方式不同。

本文从fopen、fprintf、fclose出发,再衔接open、read、write、close,最后整理文件描述符表。代码保留示例原有的变量名和操作顺序;从注释中恢复的阶段片段会明确标出,边界问题另外说明。

一、文件包含内容,也包含属性

文件大小为 0,并不代表文件不存在或不需要任何存储空间。除了内容,文件还有权限、所有者、大小等元数据。访问文件内容和查看文件属性,是不同的操作。

对文件执行操作的主体是进程。进程通过文件接口请求操作系统完成访问,内核维护打开文件所需的状态。

进程中的程序

C 标准 I/O 库

底层文件接口

内核管理的打开文件对象

底层文件或设备

打开文件是建立访问关系、取得访问入口,不能直接理解成把整个文件内容一次性读入内存。后续读写才涉及具体数据;普通文件数据还可能经过内核页缓存。

log.txt是相对路径,通常相对于进程的当前工作目录解析。程序放在哪个目录,与程序启动后的工作目录,并不必然相同。这与简易 Shell 中chdir改变工作目录的概念相衔接。

二、C 文件接口:先打开,再读写,最后关闭

下面是追加写入的完整示例,保留原代码及其中用于切换观察的注释:

#include<stdio.h>#include<string.h>intmain(){FILE*fp=fopen("log.txt","a");if(fp==NULL){perror("fopen");return1;}constchar*msg="hello bite!\n";fprintf(fp,"%s",msg);fclose(fp);//printf("hello world\n");//fprintf(stdout, "hello fprintf\n");//const char *msg = "hello fwrite\n";//fwrite(msg, strlen(msg), 1, stdout);return0;}

可以按三步阅读:

  1. fopen("log.txt", "a"):打开或创建文件,获得流指针;失败返回NULL。
  2. fprintf(fp, "%s", msg):把格式化输出写到fp对应的流。
  3. fclose(fp):完成流的收尾并关闭关联文件描述符。

fp指向库管理的流对象,不是磁盘内容的起始地址。程序中的msg是要写出的文本,fp是操作入口,两者不要混淆。

保存为myfile.c,编译与观察方式为:

gcc myfile.c-omyfile ./myfilecatlog.txt

按代码分析,每次成功运行都会追加一行hello bite!。这里给的是观察方法,不是当前环境中的实测记录。

w 和 a 的区别

模式文件不存在时文件已存在时
r打开失败允许读取
w创建截断为 0,再写入
a创建保留内容,写入时追加到末尾

因此“写文件”并不自动表示“追加”。将示例中的a改为w,已有内容就会在打开时被清空。这是打开方式决定的,与后面选用fprintf还是fwrite是两回事。

三、标准流与输出接口

程序通常具有三个标准流:

标准流含义常见终端场景
stdin标准输入从终端读取输入
stdout标准输出向终端输出结果
stderr标准错误向终端输出诊断信息

它们的类型都是FILE *。连接终端只是常见情况,重定向后可以连接文件或其他对象。

上面代码注释中的三种输出分别是:

printf("hello world\n");fprintf(stdout,"hello fprintf\n");constchar*msg="hello fwrite\n";fwrite(msg,strlen(msg),1,stdout);

这是恢复注释后的观察片段,应分别放入合适的作用域测试,避免与已有msg重复定义。printf默认使用stdout,fprintf显式指定流并按格式输出,fwrite按指定大小和数量写出数据。

fwrite(msg, strlen(msg), 1, stdout)表示写一个数据项,每项长度为strlen(msg)字节。fwrite返回的是完整写出的项数,不是固定返回字节数。同理,fread的返回值也按项计数。

fread 读文件片段中的一个细节

另一个读取示例里有这样的原语句:

intn=fread(buffer,sizeof(buffer)-1,1,fp);if(n>0){printf("%s",buffer);}

这里尝试读取一个较大的数据项。若文件末尾剩余内容不足一整项,可能已有部分字节读入数组,而返回的完整项数仍为 0;只在n > 0时打印,就可能漏掉末尾数据。因此不能把这个片段当作已经完善的cat实现。它还需要区分 EOF 与读取错误。

此处保留原语句用于解释参数含义,不将改写后的读取程序混入示例主线。

四、open:用文件描述符操作文件

从 C 标准库向下看,打开文件可以使用:

intfd=open("log.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);

相关头文件包括<sys/types.h>、<sys/stat.h>、<fcntl.h>;read、write、close声明在<unistd.h>中。

open成功返回非负整数,也就是文件描述符;失败返回-1。原代码使用if(fd < 0)检查失败,不能改成fd <= 0,因为0也是有效编号。

flags:访问模式与附加选项

标志含义
O_RDONLY只读
O_WRONLY只写
O_RDWR读写
O_CREAT不存在时创建
O_TRUNC在可写打开普通文件时截断内容
O_APPEND每次写入时定位到文件末尾

前三种访问模式选择一个,再通过按位或|组合附加选项。位标志示例中的ONE_FLAG | TWO_FLAG用来组合选项,flags & ONE_FLAG用来检查某一位;但文件访问模式不能全部当成独立开关,特别是O_RDONLY的值为 0。

两个关键对应关系是:

// 对应 w 的创建、截断写语义intfd=open("log.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);// 对应 a 的创建、追加写语义intfd=open("log.txt",O_CREAT|O_WRONLY|O_APPEND,0666);

两行是可替换的观察语句,不是在同一作用域连续定义两次fd。O_CREAT只表示允许创建,不自动截断已有内容;清空与追加分别由O_TRUNC和O_APPEND决定。

mode 与 umask

使用O_CREAT时需要提供创建权限参数。0666是八进制数;在未涉及默认 ACL 的通常情形下,新文件权限由mode & ~umask得到。例如0666与0022的掩码组合后为0644。

示例中的umask(0)用于观察不屏蔽权限位的结果,并非每次创建文件都必须调用。打开已有文件时,0666也不会重新设置原文件权限。

五、read 与 write:接口传递的是字节

原示例先把整数转换为文本,再写出:

inta=1234567;charbuffer[16];snprintf(buffer,sizeof(buffer),"%d",a);write(fd,buffer,strlen(buffer));

这段摘录省略了外围循环,保留原来的变量和转换方式。写出的内容是字符1234567,不是整数对象在内存中的原始表示。文本表示如何生成,是程序的选择;write接收地址和长度,按字节传输。

长度采用strlen(buffer),不包含 C 字符串末尾的\0。文件不要求以空字符作为结束标志。

下面是从注释中恢复的原read循环片段,前提是fd已经成功以可读方式打开:

while(1){charbuffer[64];intn=read(fd,buffer,sizeof(buffer)-1);if(n>0){buffer[n]=0;printf("%s",buffer);}elseif(n==0){break;}}

理解时区分三种返回值:

  • n > 0:实际读到了n个字节。代码预留一字节,并设置buffer[n] = 0后按字符串输出。
  • n == 0:到达文件末尾,退出循环。
  • n < 0:发生错误,原循环没有处理这个分支,可能反复重试。

write成功返回实际写入的字节数,也可能少于请求长度。原示例没有完整处理短写和中断,这些边界应与接口基本流程分开理解。

六、为什么打开文件常得到 3、4、5、6

下面恢复的是连续打开四个文件的原片段,需要放在包含相应头文件的main中观察:

umask(0);intfd1=open("log1.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);intfd2=open("log2.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);intfd3=open("log3.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);intfd4=open("log4.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);if(fd1<0)exit(1);if(fd2<0)exit(1);if(fd3<0)exit(1);if(fd4<0)exit(1);printf("fd1: %d\n",fd1);printf("fd2: %d\n",fd2);printf("fd3: %d\n",fd3);printf("fd4: %d\n",fd4);close(fd1);close(fd2);close(fd3);close(fd4);

在常见启动环境中,进程的0、1、2已经分别用于标准输入、标准输出和标准错误。因此后续打开文件时,经常依次得到3、4、5、6。

描述符常见含义对应的 C 标准流
0标准输入stdin
1标准输出stdout
2标准错误stderr

分配原则是当前进程中最小的未使用编号。返回3是常见现象,而非固定规则。关闭某个编号后,后续打开可以复用它;不同进程里的相同 fd,也不一定指向同一个文件。

原代码还通过stdin->_fileno等成员观察关联编号。这属于特定库的实现细节,不是通用 C 接口。作为补充,POSIX 环境中可用fileno(stdin)等公开接口取得关联描述符。

七、文件描述符是表的索引

从内核角度,可以先建立这样的简化模型:

进程 task_struct

files_struct:管理文件描述符表

表项 0、1、2、3……

内核 struct file 对象

文件位置、打开标志及底层对象

用户态 FILE 流对象

关联的整数 fd

这是概念关系图,省略了具体内核结构层级。进程用 fd 索引表项,表项关联内核打开文件对象。整数 fd 不是struct file,也不是文件内容地址。

一个进程可以打开多个文件;同一个文件也可以被多次打开。文件名、文件描述符、打开文件对象,属于不同层次,不能相互等同。

C 标准库在用户态维护流状态和缓冲等信息,最终借助底层接口完成数据传输。因此一次fprintf调用,不一定立刻对应一次write系统调用。标准 C I/O 的跨平台接口,也由各平台的库实现去衔接不同底层系统。

八、衔接重定向:为什么仍能使用 fgets

后续代码将输入文件接到标准输入,关键原语句是:

intfd=open(argv[1],O_RDONLY);if(fd<0)exit(1);dup2(fd,0);close(fd);while(1){charbuffer[64];if(!fgets(buffer,sizeof(buffer),stdin))break;printf("%s",buffer);}

这是已有命令行参数检查之后的原代码片段。它把fd所指向的打开文件关联到描述符0,因此之后fgets(..., stdin)的输入来源变成该文件。改变的是描述符关联,读取调用本身没有换成另一套接口。

在通常open得到3等其他编号且dup2成功的情况下,关闭原fd后,编号0仍保留关联。如果打开时0本来就是空位,fd可能正好等于0,此时直接close(fd)就会关闭标准输入;原示例没有处理这种情况,也未检查dup2失败。重定向的完整行为留到下一篇展开。

九、复习时把三层分开

层次主要对象关注的问题
C 标准库FILE *流、格式化输出与缓冲
进程文件接口整数 fd用哪个编号读取、写入或关闭
内核管理打开文件对象打开状态、读写位置与底层对象

先沿着fopen → fprintf → fclose理解一次写入,再沿着open → read/write → close理解按字节访问。看到0、1、2时想到标准输入输出的约定,看到 fd 时想到进程的描述符表索引。

本文保留的是各阶段示例,多个片段不能直接无差别拼接成一个main。返回值检查、读写错误、短写和持久化等问题,没有通过另写一个完整程序替换原有学习顺序。