在编程中,“文件操作” 是绕不开的基础话题。无论是读写配置文件、处理用户输入,还是网络通信,本质上都离不开 “IO”(输入 / 输出)操作。本文将从 “文件是什么” 讲起,一步步带你吃透 C 语言文件操作、Linux 系统调用、文件描述符、重定向等核心概念,配合代码示例帮你真正做到 “知其然,更知其所以然”。
一、重新认识 “文件”:不止于磁盘中的数据
1.1 狭义与广义的文件
- 狭义文件:我们最熟悉的 “磁盘文件”—— 存在硬盘上的一组数据(比如文本、图片),断电后不会丢失。对这类文件的读写,本质是对 “磁盘” 这个外设的输入 / 输出操作。
- 广义文件:在 Linux 系统中,“一切皆文件”。键盘、显示器、网卡、进程甚至管道,都被抽象成了 “文件”。这意味着我们可以用一套统一的接口(比如
read
/write
)操作所有这些 “设备”,极大简化了编程。
1.2 文件的构成:不止于内容
一个完整的文件包含两部分:
- 内容:我们直观看到的数据(比如文本里的文字、图片的像素)。
- 属性(元数据):描述文件的信息,比如文件名、大小、权限(rwx)、创建时间等。
举个例子:你创建一个test.txt
,输入 “hello”,它的内容是 “hello”,属性包括大小 5 字节、权限-rw-r--r--
、创建者等。对文件的操作,要么改内容(比如追加文字),要么改属性(比如chmod
改权限)。
1.3 谁在操作文件?进程!
文件不会自己 “动”,所有操作都是由进程发起的。比如你用vim
编辑文件,本质是vim
进程在读写磁盘;用printf
打印内容,是当前进程在操作 “显示器” 这个文件。
而磁盘、显示器这些硬件由操作系统统一管理,所以进程对文件的操作必须通过系统调用(操作系统提供的接口),而非直接操作硬件。C 语言中的fopen
、fwrite
等函数,本质是对系统调用的 “封装”,让我们用起来更方便。
二、C 语言文件 IO:从熟悉的库函数说起
C 语言提供了一套文件操作库函数(属于stdio.h
),我们先从这些熟悉的函数入手,理解它们的工作方式。
2.1 打开文件:fopen
的路径在哪里?
用fopen
打开文件时,如果只写文件名(比如fopen("myfile", "w")
),文件会创建在进程的当前工作目录下。
怎么确定 “当前工作目录”?可以通过进程的proc
信息查看。比如运行下面的程序(故意用while(1)
让进程不退出):
#include <stdio.h>
int main() {
FILE *fp = fopen("myfile", "w"); // 创建myfile
if(!fp) printf("fopen error!\n");
while(1); // 让进程持续运行
fclose(fp);
return 0;
}
编译后运行(假设进程名为myproc
),另开一个终端:
- 用
ps ajx | grep myproc
找到进程 ID(比如533463
); - 查看
ls /proc/533463 -l
,其中cwd -> /home/hyb/io
就是当前工作目录 ——myfile
就创建在这里。
2.2 读写文件:fwrite
与fread
的使用
写文件示例
#include <stdio.h>
#include <string.h>
int main() {
FILE *fp = fopen("myfile", "w"); // 以只写方式打开(不存在则创建)
if(!fp) {
printf("fopen error!\n");
return 1;
}
const char *msg = "hello bit!\n";
int count = 5;
while(count--) {
// 写数据:msg是内容地址,strlen(msg)是每个数据块大小,1是块数,fp是目标文件
fwrite(msg, strlen(msg), 1, fp);
}
fclose(fp); // 关闭文件,必须调用(刷新缓冲、释放资源)
return 0;
}
运行后,myfile
里会有 5 行 “hello bit!”。
读文件示例
#include <stdio.h>
#include <string.h>
int main() {
FILE *fp = fopen("myfile", "r"); // 只读方式打开
if(!fp) {
printf("fopen error!\n");
return 1;
}
char buf[1024];
const char *msg = "hello bit!\n"; // 已知每行长度
while(1) {
// 读数据:buf存结果,1是每个字节大小,strlen(msg)是最大读取字节数,fp是源文件
ssize_t s = fread(buf, 1, strlen(msg), fp);
if(s > 0) { // 读到数据
buf[s] = '\0'; // 手动加结束符
printf("%s", buf);
}
if(feof(fp)) { // 判断文件是否读完
break;
}
}
fclose(fp);
return 0;
}
运行后会打印myfile
里的 5 行内容。
2.3 标准输入输出流:stdin
、stdout
、stderr
C 语言默认打开 3 个 “标准流”,无需手动fopen
:
stdin
:标准输入,对应键盘(fscanf
、fgets
默认从这里读);stdout
:标准输出,对应显示器(printf
、fprintf(stdout, ...)
默认输出到这里);stderr
:标准错误,也对应显示器(专门用于输出错误信息,比如perror
)。
它们的类型都是FILE*
,示例:
#include <stdio.h>
#include <string.h>
int main() {
const char *msg = "hello fwrite\n";
fwrite(msg, strlen(msg), 1, stdout); // 写标准输出(显示器)
printf("hello printf\n"); // 本质是fprintf(stdout, ...)
fprintf(stdout, "hello fprintf\n"); // 直接指定stdout
return 0;
}
运行后,三行内容都会显示在显示器上。
2.4 文件打开模式:r
、w
、a
有何区别?
fopen
的第二个参数指定打开模式,核心模式:
r
:只读,文件必须存在,指针在开头;w
:只写,文件不存在则创建,存在则清空(截断),指针在开头;a
:追加写,文件不存在则创建,指针在末尾(每次写都加到最后);- 带
+
的模式(如r+
、w+
):允许读写。
注意:w
模式会清空文件,慎用!a
模式无论怎么移动指针,写操作始终追加到末尾。
三、系统文件 IO:绕过库函数,直接调用系统接口
C 库函数(fopen
等)是对 “系统调用” 的封装。系统调用是操作系统提供的底层接口,比如open
、read
、write
,直接和内核交互。
3.1 系统调用与库函数的关系
- 库函数:比如
fopen
、fwrite
,属于 C 标准库(libc
),是 “用户态” 的函数,内部会调用系统调用; - 系统调用:比如
open
、write
,是 “内核态” 的接口,直接操作硬件资源。
简单说:库函数 = 系统调用 + 额外功能(比如缓冲区)。
3.2 核心系统调用接口
open
:打开 / 创建文件
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
// 成功返回文件描述符(非负整数),失败返回-1
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
pathname
:文件名(带路径则按路径找,否则在当前目录);flags
:打开标志(必须包含O_RDONLY
(只读)、O_WRONLY
(只写)、O_RDWR
(读写)中的一个,可搭配O_CREAT
(创建)、O_APPEND
(追加)等,用|
组合);mode
:当flags
含O_CREAT
时,指定新文件的权限(如0644
表示-rw-r--r--
)。
示例:用open
创建文件并写内容
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
umask(0); // 清除权限掩码(否则创建文件时权限会被屏蔽)
// 以只写+创建模式打开,权限0644
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
if(fd < 0) { // 失败
perror("open"); // 打印错误原因(依赖stderr)
return 1;
}
const char *msg = "hello bit!\n";
int len = strlen(msg);
int count = 5;
while(count--) {
// 写数据:fd是文件描述符,msg是内容,len是字节数
write(fd, msg, len);
}
close(fd); // 关闭文件,必须调用
return 0;
}
功能和fwrite
示例相同,但直接用了系统调用。
read
/write
:读写文件
write(fd, buf, count)
:向fd
对应的文件写count
字节,数据来自buf
,返回实际写入字节数;read(fd, buf, count)
:从fd
对应的文件读最多count
字节到buf
,返回实际读取字节数(0 表示文件结束,-1 表示错误)。
读文件示例:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main() {
int fd = open("myfile", O_RDONLY); // 只读打开
if(fd < 0) {
perror("open");
return 1;
}
const char *msg = "hello bit!\n";
char buf[1024];
while(1) {
ssize_t s = read(fd, buf, strlen(msg)); // 读数据
if(s > 0) {
printf("%s", buf);
} else {
break; // 读完或出错
}
}
close(fd);
return 0;
}
3.3 文件描述符(fd):系统给文件的 “编号”
open
的返回值是文件描述符(fd),本质是一个非负整数。它是系统给打开的文件分配的 “编号”,进程通过这个编号找到对应的文件。
为什么是 0、1、2?
Linux 进程默认打开 3 个文件描述符:
0
:对应stdin
(标准输入);1
:对应stdout
(标准输出);2
:对应stderr
(标准错误)。
这就是为什么printf
输出到显示器,本质是往fd=1
写数据;scanf
读键盘,本质是从fd=0
读数据。
文件描述符的分配规则
新打开文件时,系统会分配最小的未使用的 fd。比如:
#include <stdio.h>
#include <fcntl.h>
int main() {
close(0); // 关闭fd=0
int fd = open("myfile", O_RDONLY); // 此时最小未使用的fd是0
printf("fd: %d\n", fd); // 输出0
close(fd);
return 0;
}
四、重定向:让输出 “改道” 的秘密
4.1 什么是重定向?
默认情况下,stdout
(fd=1
)输出到显示器。如果让fd=1
指向一个文件,那么原本输出到显示器的内容就会写到文件里 —— 这就是输出重定向(比如ls > log.txt
)。
示例:
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
int main() {
close(1); // 关闭原来的stdout(fd=1)
// 创建文件,此时fd=1(因为1是最小未使用的)
int fd = open("myfile", O_WRONLY | O_CREAT, 0644);
printf("fd: %d\n", fd); // 本该输出到显示器,现在写到myfile
fflush(stdout); // 刷新缓冲区(否则可能不写入)
close(fd);
return 0;
}
运行后,myfile
里会有 “fd: 1”,而不是显示在屏幕上。
4.2 dup2
:更灵活的重定向工具
dup2(oldfd, newfd)
系统调用可以直接将newfd
指向oldfd
对应的文件,无需手动close
:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("log.txt", O_CREAT | O_WRONLY, 0644); // 创建文件,fd=3
dup2(fd, 1); // 让fd=1指向fd=3对应的文件(log.txt)
printf("hello redirect\n"); // 输出到log.txt
fflush(stdout);
close(fd);
return 0;
}
运行后,log.txt
里会有 “hello redirect”。
4.3 重定向的本质
进程用files_struct
结构体管理打开的文件,其中有一个指针数组,fd
就是数组下标。重定向的本质是修改数组下标对应的指针,让其指向新的文件。
比如,fd=1
原本指向 “显示器文件”,重定向后指向 “log.txt 文件”,所以输出就 “改道” 了。
五、缓冲区:提升效率的 “中转站”
5.1 为什么需要缓冲区?
如果每次输出都直接调用write
(系统调用),频繁的用户态→内核态切换会很低效。C 库函数在用户态维护了缓冲区,先把数据存到缓冲区,满足条件时再一次性调用write
—— 减少系统调用次数,提升效率。
5.2 缓冲区的三种类型
- 全缓冲:填满缓冲区才刷新(比如磁盘文件,默认缓冲区大小通常是 4096 字节);
- 行缓冲:遇到换行符
\n
或缓冲区满时刷新(比如stdout
输出到显示器); - 无缓冲:不缓冲,直接调用系统调用(比如
stderr
,确保错误信息立即显示)。
5.3 验证缓冲区的存在
示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
printf("hello printf"); // 行缓冲,没换行,暂存在缓冲区
fwrite("hello fwrite", 1, 12, stdout); // 同上
write(1, "hello write", 11); // 系统调用,无缓冲,直接输出
fork(); // 创建子进程(会复制父进程缓冲区)
return 0;
}
直接运行(输出到显示器,行缓冲):
hello writehello printfhello fwrite
重定向到文件(全缓冲,缓冲区没满,fork
复制缓冲):
./a.out > log.txt
cat log.txt
# 输出:
# hello write
# hello printfhello fwrite
# hello printfhello fwrite
原因:write
无缓冲,只输出一次;printf
和fwrite
的缓冲被父子进程各复制一次,所以输出两次。
六、“一切皆文件”:Linux 的设计哲学
6.1 为什么说 “一切皆文件”?
键盘、显示器、网卡等设备,在 Linux 中都被抽象成文件,用file
结构体描述。file
中有一个f_op
指针,指向file_operations
结构体(包含read
、write
等函数指针)。
不同设备的read
/write
实现不同(比如键盘的read
是读按键,显示器的write
是显示字符),但接口统一 —— 这就是 “一切皆文件” 的核心:用统一的接口操作不同的设备。
6.2 举例:读写不同 “文件”
- 读键盘(
fd=0
):read(0, buf, 1024)
; - 写显示器(
fd=1
):write(1, "hello", 5)
; - 读写磁盘文件:
read(fd, ...)
/write(fd, ...)
。
接口完全相同,只是内部实现不同。
七、总结:基础 IO 的核心脉络
- 文件:由内容和属性组成,操作文件的是进程,依赖操作系统;
- C 库函数:
fopen
、fwrite
等,封装系统调用,带用户态缓冲区; - 系统调用:
open
、write
等,直接操作硬件,无缓冲区; - 文件描述符:
fd
是进程管理文件的下标,默认 0(stdin)、1(stdout)、2(stderr); - 重定向:修改
fd
对应的文件指针,改变 IO 方向; - 缓冲区:C 库维护的用户态缓存,减少系统调用,分全缓冲、行缓冲、无缓冲;
- 一切皆文件:用统一接口(
read
/write
)操作所有设备,依赖file_operations
结构体。
掌握这些,你就真正理解了基础 IO 的核心逻辑。下次写文件操作时,不妨想想:数据从哪里来?到哪里去?经过了哪些缓冲区?是否发生了重定向?—— 想清楚这些,IO 操作就再也难不倒你了!