
Linux 基础 IO 系列(二):C 语言标准库 IO 接口实战
系统讲解了 C 语言标准库 IO 的核心接口,包括 fopen、fclose、fread、fwrite 及 feof 函数的用法与参数细节。文章对比了 6 种文件打开模式,介绍了 stdin、stdout、stderr 三个默认流,并提供了 errno、perror 等错误处理技巧。通过实现简化版 cat 命令和文件拷贝工具,帮助开发者掌握跨平台文件操作的规范与原理。

系统讲解了 C 语言标准库 IO 的核心接口,包括 fopen、fclose、fread、fwrite 及 feof 函数的用法与参数细节。文章对比了 6 种文件打开模式,介绍了 stdin、stdout、stderr 三个默认流,并提供了 errno、perror 等错误处理技巧。通过实现简化版 cat 命令和文件拷贝工具,帮助开发者掌握跨平台文件操作的规范与原理。


在学具体接口前,我们得先明白一个核心问题:操作系统已经提供了 open/read/write 等系统调用,为什么还要 C 库 IO(fopen/fread/fwrite)?
答案有两个:
fopen("test.txt", "r") 就能打开文件,不用关心底层 open 函数的 flags 位标志位、mode 权限位。open 和 Windows 的 CreateFile),但 C 库 IO 是 '标准接口'—— 只要你的代码用的是 fopen/fread,在 Linux、Windows、macOS 上都能跑(前提是不依赖系统特有功能)。简单说,C 库 IO 是 '用户友好型中间商':它封装了系统调用,给我们提供简单、通用的文件操作方式。
C 语言标准库提供了一套完整的文件操作接口,核心是 '打开→读写→判断结束→关闭' 的流程。我们逐个拆解每个接口的用法、参数细节和注意事项。
要操作文件,第一步必须 '打开文件'—— 用 fopen 函数建立程序与文件的关联,就像 '打开房门才能进房间'。
#include <stdio.h>
FILE *fopen(const char *filename, const char *mode);
| 参数名 | 含义 | 示例 |
|---|---|---|
filename | 要打开的文件路径(相对路径 / 绝对路径) | "test.txt"(当前目录)、"/home/user/log.txt"(绝对路径) |
mode | 打开模式(决定文件可读写性、是否创建、是否追加等) | "r"(只读)、"w"(只写)、"a"(追加) |
FILE* 类型的指针(称为 '文件句柄'),后续所有操作都要靠这个指针定位文件;NULL(比如文件不存在、权限不足),此时需要用 perror 或 strerror 查看错误原因。如果 filename 不带路径(比如 "test.txt"),fopen 会默认在**进程的当前工作目录(CWD)**下找文件。进程的 CWD 从哪里来?—— 来自进程 PCB(进程控制块)中的 cwd 字段(上一篇提到的 /proc/[PID]/cwd 符号链接就是它的映射)。
比如你的程序在 /home/user 目录下运行,fopen("test.txt", "r") 会去找 /home/user/test.txt;如果程序在 /tmp 目录下运行,就会找 /tmp/test.txt。
#include <stdio.h>
#include <string.h> // 用于 strerror
#include <errno.h> // 用于 errno
int main() {
// 以只读模式打开当前目录下的 test.txt
FILE *fp = fopen("test.txt", "r");
// 关键:判断打开是否成功(必须做!否则 fp 为 NULL 会导致后续操作崩溃)
if (fp == NULL) {
// 方法 1:用 perror 直接打印错误(简单)
perror("fopen failed");
// 方法 2:用 strerror 获取错误描述(更灵活)
// printf("fopen failed: %s\n", strerror(errno));
return 1; // 打开失败,退出程序
}
printf("文件打开成功!\n");
fclose(fp); // 记得关闭文件(后续讲)
return 0;
}
如果 test.txt 不存在,运行结果会显示:
fopen failed: No such file or directory
文件操作完成后,必须用 fclose 关闭文件—— 就像 '离开房间要关门',否则会导致资源泄漏(比如文件描述符被占用、缓冲区数据丢失)。
#include <stdio.h>
int fclose(FILE *stream);
stream:fopen 返回的 FILE* 指针(要关闭的文件句柄);0;EOF(通常是 -1,比如文件已经被关闭),错误原因存放在 errno 中。fclose 会自动刷新缓冲区 —— 如果不关闭,缓冲区中未写入磁盘的数据会丢失。#include <stdio.h>
int main() {
FILE *fp = fopen("test.txt", "w");
if (fp == NULL) {
perror("fopen failed");
return 1;
}
// 写入数据(后续讲 fwrite)
const char *msg = "hello C lib IO!";
fwrite(msg, strlen(msg), 1, fp);
// 关闭文件并判断是否成功
if (fclose(fp) != 0) {
perror("fclose failed");
return 1;
}
printf("文件关闭成功!\n");
return 0;
}
fwrite 用于将内存中的数据以二进制形式写入文件,支持写入文本、整数、结构体等任意数据(注意:写入的是 '原始二进制',不是 '文本格式')。
#include <stdio.h>
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
fwrite 的参数设计很巧妙,用 '单个数据块大小 × 数据块个数' 来描述要写入的数据,而不是直接传 '总字节数'—— 这样更灵活(比如写入数组时不用手动算总字节数)。
| 参数名 | 含义 | 示例 |
|---|---|---|
ptr | 指向 '要写入数据' 的内存起始地址(比如数组名、变量地址) | &num(变量地址)、msg(字符串数组名) |
size | 单个数据块的字节数(比如 sizeof(int)、sizeof(char)) | 写入 int 时传 sizeof(int),写入字符时传 1 |
nmemb | 要写入的数据块个数 | 写入 5 个 int 时传 5,写入字符串时传 strlen(msg) |
stream | 目标文件的 FILE* 指针 | fp(之前 fopen 返回的指针) |
nmemb);nmemb(比如磁盘满了、文件被意外关闭),此时需要结合 ferror 判断是否为错误。很多人会用 fwrite 写入文本,但容易忽略 '二进制' 特性 —— 我们通过两个例子对比:
#include <stdio.h>
#include <string.h>
int main() {
FILE *fp = fopen("text.txt", "w");
if (fp == NULL) {
perror("fopen");
return 1;
}
const char *msg = "hello fwrite!";
// 字符串,末尾有隐藏的 '\0'
// 写入字符串:单个字符 1 字节,共 strlen(msg) 个字符(不含 '\0',避免乱码)
size_t write_cnt = fwrite(msg, 1, strlen(msg), fp);
if (write_cnt != strlen(msg)) {
printf("写入失败!实际写入%d个字符\n", write_cnt);
} else {
printf("写入成功!共写入%d个字符\n", write_cnt);
}
fclose(fp);
return 0;
}
运行后用 cat text.txt 查看,会显示 hello fwrite! —— 因为我们写入的是 '字符的 ASCII 码',文本编辑器能正常解析。
#include <stdio.h>
int main() {
FILE *fp = fopen("binary.bin", "wb"); // 用 "wb" 模式(二进制写)
if (fp == NULL) {
perror("fopen");
return 1;
}
int num = 1234567; // 整数在内存中占 4 字节(32 位系统)
// 写入整数:单个 int 4 字节,共 1 个数据块
size_t write_cnt = fwrite(&num, sizeof(int), 1, fp);
if (write_cnt != 1) {
printf("整数写入失败!\n");
} else {
printf("整数写入成功!\n");
}
fclose(fp);
return 0;
}
运行后用 cat binary.bin 查看,会显示乱码 —— 因为 fwrite 写入的是 1234567 的原始二进制(00010010 11010110 10000111),不是文本格式的 '1234567'。如果要让整数以文本形式写入,需要先用 sprintf 转成字符串,再用 fwrite 写入。
fread 与 fwrite 对应,用于从文件中读取二进制数据到内存缓冲区,同样支持读取文本、整数、结构体等数据。
#include <stdio.h>
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
| 参数名 | 含义 | 示例 |
|---|---|---|
ptr | 指向 '存储读取数据' 的内存缓冲区(比如数组、变量地址) | buf(字符数组名)、&num(int 变量地址) |
size | 单个数据块的字节数 | 读 int 时传 sizeof(int),读字符时传 1 |
nmemb | 计划读取的数据块个数 | 计划读 1024 个字符时传 1024 |
stream | 源文件的 FILE* 指针 | fp(fopen 返回的指针) |
fread 的返回值是 '实际读取的数据块个数',有三种常见情况:
nmemb:读取成功,获取到了期望的所有数据;nmemb 但大于 0:部分读取(比如文件剩余数据不足,或非阻塞 IO 场景);feof 和 ferror 区分)。#include <stdio.h>
#include <string.h>
#define BUF_SIZE 1024 // 缓冲区大小,每次读 1024 字节
int main() {
// 1. 打开文件(只读模式)
FILE *fp = fopen("test.txt", "r");
if (fp == NULL) {
perror("fopen");
return 1;
}
// 2. 循环读取文件
char buf[BUF_SIZE] = {0}; // 存储读取数据的缓冲区
while (1) {
// 每次读 1 字节,最多读 BUF_SIZE 个字符(即 1024 字节)
size_t read_cnt = fread(buf, 1, BUF_SIZE - 1, fp); // 留 1 字节存 '\0'
if (read_cnt > 0) {
buf[read_cnt] = '\0'; // 给字符串加结束符,避免乱码
printf("读取到%d字节:%s", read_cnt, buf);
}
// 3. 判断是否读取结束或出错
if (feof(fp)) {
// 到达文件末尾
printf("\n文件读取完毕!\n");
break;
}
if (ferror(fp)) {
// 读取错误
perror("fread failed");
break;
}
}
// 4. 关闭文件
fclose(fp);
return 0;
}
运行后会逐段打印 test.txt 的内容 —— 这就是 cat 命令的核心逻辑之一。
很多人会误用 feof:以为 'feof 返回真就是读取失败',但实际它的作用是判断 '上一次读取操作是否到达文件末尾'(不是 '是否即将到达末尾')。
#include <stdio.h>
int feof(FILE *stream);
错误写法(先判断 feof,再读取):
// 错误示例:先判断 feof,再读
while (!feof(fp)) {
fread(buf, 1, BUF_SIZE, fp); // 可能多读一次空数据
printf("%s", buf);
}
为什么错?因为 feof 只有在 '读取到 EOF 后' 才会返回真 —— 第一次进入循环时,文件还没读,feof 返回假,执行 fread;如果 fread 刚好读到文件末尾,feof 还是假,会再次进入循环,执行 fread(此时 fread 返回 0,读取空数据),最后 feof 才返回真,跳出循环。
正确写法:先读取,再用 feof 判断是否结束(就像前面 fread 示例那样):
// 正确示例:先读,再判断
while (1) {
size_t read_cnt = fread(buf, 1, BUF_SIZE, fp);
if (read_cnt > 0) {
/* 处理数据 */
}
if (feof(fp)) {
// 上一次 read 到达末尾
break;
}
if (ferror(fp)) {
// 上一次 read 出错
perror("read failed");
break;
}
}
fopen 的 mode 参数决定了文件的 '操作权限' 和 '行为特性',这是最容易混淆的部分 —— 我们用表格清晰对比 6 种核心模式,再结合场景说明用法。
| 模式 | 读写权限 | 文件不存在时 | 文件存在时 | 写入行为 | 适用场景 |
|---|---|---|---|---|---|
r | 只读 | 打开失败 | 保留内容 | 不允许写入 | 读取已存在的文本 / 配置文件 |
r+ | 读写 | 打开失败 | 保留内容 | 从文件开头覆盖写入 | 读写已存在的文件(不创建) |
w | 只写 | 创建文件 | 清空内容(截断) | 从文件开头写入 | 创建新文件或覆盖旧文件 |
w+ | 读写 | 创建文件 | 清空内容(截断) | 从文件开头写入 | 读写新文件或覆盖旧文件 |
a | 只写(追加) | 创建文件 | 保留内容 | 从文件末尾追加写入 | 写日志(不覆盖旧内容) |
a+ | 读写(追加) | 创建文件 | 保留内容 | 写入:末尾追加;读取:从头开始 | 读写日志(既能读历史,又能追加) |
w 和 a 的区别:
w:无论文件是否存在,都会清空内容(即使文件有 100MB,用 w 打开后也会变成 0KB);a:不会清空文件,新数据永远追加到末尾(比如日志文件必须用 a 模式,否则会覆盖历史日志)。r+ 和 w+ 的区别:
r+:文件必须已存在,否则打开失败(适合修改已有的文件);w+:文件不存在则创建,存在则清空(适合创建新的读写文件)。b 后缀):在 Windows 下,文本文件和二进制文件的换行符处理不同(文本用 \r\n,二进制用 \n),所以需要加 b 后缀(比如 rb/wb);但在 Linux 下,文本和二进制文件的换行符处理一致,b 后缀可加可不加 ——为了跨平台兼容,建议写二进制文件时加 b(比如 wb/rb)。你可能没注意过:C 程序启动时,会默认打开 3 个 '特殊文件'—— 它们是标准输入(stdin)、标准输出(stdout)、标准错误输出(stderr),对应的 FILE* 指针由 C 库预先定义,直接就能用。
| 流名称 | 对应设备 | 文件描述符(底层) | 作用 | 常用接口 |
|---|---|---|---|---|
stdin | 键盘 | 0 | 接收用户输入(比如 scanf) | fscanf(stdin, ...)、fread(..., stdin) |
stdout | 显示器 | 1 | 输出正常信息(比如 printf) | printf(...)(等价于 fprintf(stdout, ...)) |
stderr | 显示器 | 2 | 输出错误信息(比如 perror) | fprintf(stderr, "错误:...") |
因为程序的核心是 '数据处理',而数据处理需要 '输入(来源)' 和 '输出(去向)'——stdin 是默认输入源(键盘),stdout/stderr 是默认输出目标(显示器),这样程序启动就能直接交互,不用手动打开。
我们可以用 printf、fprintf、fwrite 三种方式输出到 stdout(最终都显示在显示器上),代码如下:
#include <stdio.h>
#include <string.h>
int main() {
// 方法 1:printf(默认输出到 stdout)
printf("hello printf!\n");
// 方法 2:fprintf(显式指定 stdout)
fprintf(stdout, "hello fprintf!\n");
// 方法 3:fwrite(二进制写入到 stdout)
const char *msg = "hello fwrite!\n";
fwrite(msg, strlen(msg), 1, stdout);
return 0;
}
运行结果:
hello printf! hello fprintf! hello fwrite!
三种方法的本质:printf 是 fprintf 的简化版(默认传 stdout),fwrite 是直接写入 stdout 对应的文件 —— 最终都通过 stdout 输出到显示器。
光学接口不够,我们通过两个实战案例巩固知识点:实现 cat 命令(读取文件并输出)、实现文件拷贝工具(读源文件→写目标文件)。
cat 命令cat 命令的核心逻辑是:打开指定文件→循环读取内容→输出到 stdout→关闭文件。我们还要加上 '命令行参数校验'(用户必须传入文件名)。
#include <stdio.h>
#include <string.h>
#define BUF_SIZE 1024
int main(int argc, char *argv[]) {
// 1. 校验命令行参数(argc=程序名 + 文件名,共 2 个参数)
if (argc != 2) {
// 提示用法:./mycat 文件名
fprintf(stderr, "用法:%s <文件名>\n", argv[0]);
return 1;
}
// 2. 打开文件(只读模式)
FILE *fp = fopen(argv[1], "r");
if (fp == NULL) {
perror("fopen failed");
return 1;
}
// 3. 循环读取文件,输出到 stdout
char buf[BUF_SIZE] = {0};
while (1) {
size_t read_cnt = fread(buf, 1, BUF_SIZE - 1, fp);
if (read_cnt > 0) {
buf[read_cnt] = '\0';
fwrite(buf, 1, read_cnt, stdout); // 输出到显示器
}
// 4. 判断结束或错误
if (feof(fp)) {
break;
}
if (ferror(fp)) {
perror("fread failed");
break;
}
}
// 5. 关闭文件
fclose(fp);
return 0;
}
# 编译代码
gcc mycat.c -o mycat
# 创建测试文件
echo "hello mycat!" > test.txt
# 运行自定义 cat 命令
./mycat test.txt
# 输出结果:hello mycat!
文件拷贝的逻辑是:打开源文件(读)→ 打开目标文件(写)→ 读源文件→写目标文件→关闭两个文件。
#include <stdio.h>
#include <string.h>
#define BUF_SIZE 1024
int main(int argc, char *argv[]) {
// 1. 校验参数(argc=程序名 + 源文件 + 目标文件,共 3 个参数)
if (argc != 3) {
fprintf(stderr, "用法:%s <源文件> <目标文件>\n", argv[0]);
return 1;
}
const char *src_file = argv[1]; // 源文件路径
const char *dest_file = argv[2]; // 目标文件路径
// 2. 打开源文件(只读)和目标文件(只写,不存在则创建,存在则覆盖)
FILE *src_fp = fopen(src_file, "r");
if (src_fp == NULL) {
perror("fopen src failed");
return 1;
}
FILE *dest_fp = fopen(dest_file, "w");
if (dest_fp == NULL) {
perror("fopen dest failed");
fclose(src_fp); // 别忘了关闭已打开的源文件
return 1;
}
// 3. 循环拷贝:读源文件→写目标文件
char buf[BUF_SIZE] = {0};
size_t total_copy = 0; // 统计拷贝的总字节数
while (1) {
size_t read_cnt = fread(buf, 1, BUF_SIZE, src_fp);
if (read_cnt > 0) {
// 写入目标文件
size_t write_cnt = fwrite(buf, 1, read_cnt, dest_fp);
if (write_cnt != read_cnt) {
fprintf(stderr, "写入目标文件失败!\n");
break;
}
total_copy += write_cnt;
}
// 4. 判断结束或错误
if (feof(src_fp)) {
printf("拷贝成功!共拷贝%d字节\n", total_copy);
break;
}
if (ferror(src_fp)) {
perror("读源文件失败");
break;
}
if (ferror(dest_fp)) {
perror("写目标文件失败");
break;
}
}
// 5. 关闭文件(先关目标文件,再关源文件)
fclose(dest_fp);
fclose(src_fp);
return 0;
}
# 编译代码
gcc mycopy.c -o mycopy
# 创建 100KB 的测试文件
dd if=/dev/zero of=src.bin bs=1024 count=100
# 拷贝文件
./mycopy src.bin dest.bin
# 验证拷贝结果(比较两个文件是否一致)
diff src.bin dest.bin
# 无输出表示文件一致,拷贝成功
写文件操作代码时,'忽略错误' 是最常见的 bug 来源 —— 比如 fopen 返回 NULL 不处理,程序会崩溃;fwrite 返回值小于 nmemb 不处理,会导致数据丢失。这里教你一套完整的错误处理方法。
**errno**:全局变量(定义在 <errno.h> 中),存储最近一次系统调用 / 库函数的错误码(0 表示无错误);**perror(const char *s)**:打印错误信息,格式是 's: 错误描述'(比如 perror("fopen") 会输出 fopen: No such file or directory);**strerror(int errnum)**:将错误码转为字符串描述(比如 strerror(2) 返回 No such file or directory)。#include <stdio.h>
#include <errno.h>
#include <string.h>
int main() {
FILE *fp = fopen("test.txt", "r");
// 1. 检查 fopen 错误
if (fp == NULL) {
// 方法 1:perror(简单直接)
perror("fopen failed");
// 方法 2:strerror(灵活,可自定义格式)
// fprintf(stderr, "fopen failed: %s (错误码:%d)\n", strerror(errno), errno);
return 1;
}
char buf[1024] = {0};
// 2. 检查 fread 错误
size_t read_cnt = fread(buf, 1, 1024, fp);
if (read_cnt == 0) {
if (feof(fp)) {
printf("已到达文件末尾\n");
} else if (ferror(fp)) {
perror("fread failed");
}
}
// 3. 检查 fclose 错误
if (fclose(fp) != 0) {
perror("fclose failed");
return 1;
}
return 0;
}
fopen 后必须对应 fclose,尤其是在函数中途返回时(比如 fopen 目标文件失败,要先关闭源文件再返回)。fwrite 写入字符串时带 \0:字符串末尾的 \0 是 C 语言的 '字符串结束符',不是内容的一部分 —— 用 fwrite 写入时,要传 strlen(msg) 而不是 strlen(msg)+1,否则文件会多一个 \0(文本编辑器打开可能显示乱码)。feof 判断读取循环:记住 '先读取,再判断 feof',不要 '先判断 feof,再读取',避免多读一次空数据。a 模式,不要用 w 模式;修改已有文件用 r+ 模式,不要用 w+ 模式(w+ 会清空文件)。这篇我们系统学习了 C 语言标准库 IO 的核心接口:
fopen/fclose(记得判断返回值);fread/fwrite(理解 '数据块大小 × 个数' 的参数设计);feof(先读再判断,避免误用);w/a、r+/w+ 的差异)。通过实战案例(实现 cat、文件拷贝),我们把这些接口串起来,理解了 '文件操作的全流程'。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online