Linux(23):文件操作入门——从 C 文件接口到文件描述符
文件操作可以从两层接口理解:C 标准库提供FILE *流,Linux/POSIX 接口使用整数形式的文件描述符。两层接口最终都与进程访问文件有关,但它们提供的抽象和数据处理方式不同。
本文从fopen、fprintf、fclose出发,再衔接open、read、write、close,最后整理文件描述符表。代码保留示例原有的变量名和操作顺序;从注释中恢复的阶段片段会明确标出,边界问题另外说明。
一、文件包含内容,也包含属性
文件大小为 0,并不代表文件不存在或不需要任何存储空间。除了内容,文件还有权限、所有者、大小等元数据。访问文件内容和查看文件属性,是不同的操作。
对文件执行操作的主体是进程。进程通过文件接口请求操作系统完成访问,内核维护打开文件所需的状态。
打开文件是建立访问关系、取得访问入口,不能直接理解成把整个文件内容一次性读入内存。后续读写才涉及具体数据;普通文件数据还可能经过内核页缓存。
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;}可以按三步阅读:
fopen("log.txt", "a"):打开或创建文件,获得流指针;失败返回NULL。fprintf(fp, "%s", msg):把格式化输出写到fp对应的流。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)等公开接口取得关联描述符。
七、文件描述符是表的索引
从内核角度,可以先建立这样的简化模型:
这是概念关系图,省略了具体内核结构层级。进程用 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。返回值检查、读写错误、短写和持久化等问题,没有通过另写一个完整程序替换原有学习顺序。