Pointers On C 学习:15.20 习题 1:fopen 的返回值不检查会怎样
题目:如果不检查 fopen 的返回值是否出错,会发生什么?
1. 先给结论
fopen 打开文件可能失败。失败时它不会报错停下,而是悄悄返回一个空指针(NULL,C++ 里是 nullptr)。
如果你不检查,直接把这个空指针交给 fgetc、fgets、fprintf、fread、fclose 等函数,就等于让它们去操作"一个不存在的 FILE"。这属于未定义行为,常见后果有:
| 情况 | 后果 |
|---|---|
| 最常见 | 程序当场崩溃(段错误、访问违例) |
| 有时 | 程序不崩溃,但读到的是垃圾数据,或者什么都没读到 |
| 写文件时 | 以为写成功了,其实数据根本没保存,用户的数据悄悄丢失 |
| 最坏 | 崩溃发生在离出错点很远的地方,很难排查 |
一句话:不检查,就是把"文件打不开"这个很容易处理的小问题,变成一个难以追查的大问题。
2. 为什么 fopen 会失败
从零理解:打开文件要靠操作系统帮忙,而操作系统可能拒绝你,原因很多:
fopen 失败的常见原因
|
+-- 文件不存在 (用 "r" 模式打开一个没有的文件)
|
+-- 没有权限 (文件只读,或者你无权访问该目录)
|
+-- 路径写错 (目录不存在、文件名拼错)
|
+-- 打开的文件太多 (超过 FOPEN_MAX 或系统上限)
|
+-- 磁盘或系统问题 (磁盘满、设备不可用)
同样的树,用 Mermaid 画一遍:
这些情况在实际运行中一定会遇到,所以"文件打不开"不是例外,而是正常情况之一。
3. 不检查时,内部发生了什么
FILE* 本质上是一个指针,指向保存缓冲区和状态的那块内存。fopen 失败时返回空指针,也就是指向"地址 0"。
后面的读写函数拿到这个指针,会去访问它指向的内存,也就是读写地址 000 附近的数据。操作系统不允许程序碰地址 000 这一带,于是直接把程序杀掉。
4. 完整代码:错误写法(会崩溃)
// 文件名:demo_unchecked.cpp
// 演示:不检查 fopen 返回值的后果(运行后程序会崩溃,这是故意的)
#include <cstdio> // std::FILE, std::fopen, std::fgetc, std::fclose, std::printf
int main() {
// 打开一个不存在的文件,fopen 会失败并返回 nullptr
std::FILE* fp = std::fopen("no_such_file_12345.txt", "r");
// 错误:完全没有检查 fp 是否为 nullptr
std::printf("fp 的值是 %p\n", static_cast<void*>(fp)); // 本应打印空指针,但崩溃时缓冲区里的输出可能来不及写出
// 把空指针交给 fgetc:这是未定义行为,通常在这里崩溃
int c = std::fgetc(fp);
// 如果没崩溃(行为不确定),才会走到这里
std::printf("读到的字符: %d\n", c);
std::fclose(fp); // 对空指针调用 fclose 同样是未定义行为
return 0;
}
代码讲解
fopen("no_such_file_12345.txt", "r"):以只读方式打开一个不存在的文件,必然失败,返回nullptr。- 第一个
printf想把fp的值打印出来。实测时程序在fgetc处崩溃(退出码 139,表示段错误),而这行输出还留在 stdout 的缓冲区里,没来得及写出,所以屏幕上什么都看不到。这正好说明缓冲的副作用:崩溃时没写出的数据会丢失,让排错更难。 fgetc(fp):把空指针交给读函数。它要去访问fp指向的内存,而那里不属于程序,于是崩溃。- 后面的代码根本执行不到,这就是"出错点"和"崩溃点"可能分开的原因:
fopen出了错,但程序在fgetc才倒下。
关键点:崩溃只是可能的结果之一。标准规定这是未定义行为,所以换一个编译器或系统,现象可能完全不同。千万不要因为"某次没崩溃"就认为没问题。
时序图
5. 完整代码:正确写法
// 文件名:demo_checked.cpp
// 演示:正确检查 fopen 的返回值,出错时报告原因并安全退出
#include <cstdio> // std::FILE, std::fopen, std::fgetc, std::fclose, std::perror, std::printf
#include <cstdlib> // std::exit, EXIT_FAILURE
int main() {
const char* name = "no_such_file_12345.txt";
std::FILE* fp = std::fopen(name, "r");
// 正确:先检查,再使用
if (fp == nullptr) {
std::perror(name); // 输出:文件名: 具体错误原因
std::exit(EXIT_FAILURE); // 无法继续,干净地结束程序
}
// 只有打开成功才会走到这里,使用 fp 是安全的
int c = std::fgetc(fp);
std::printf("读到的第一个字符: %d\n", c);
std::fclose(fp);
return 0;
}
代码讲解
- 和错误写法相比,唯一的区别就是多了
if (fp == nullptr)这一段。 perror(name)会告诉用户为什么失败,比如No such file or directory,用户一看就知道是文件名写错了。exit(EXIT_FAILURE)让程序有秩序地结束,而不是被系统强行杀掉。- 检查通过后,后面的
fgetc、fclose才可以放心使用。
关键点:检查的代价只有一行if,不检查的代价可能是崩溃、数据丢失和很长的排错时间。
时序图
6. 对比总结
| 对比项 | 不检查 | 检查 |
|---|---|---|
| 文件打不开时 | 空指针继续往下传 | 立刻发现并处理 |
| 程序表现 | 崩溃或行为不可预测 | 给出清楚的错误信息 |
| 用户体验 | 不知道哪里错了 | 知道是文件问题 |
| 排错难度 | 很大 | 很小 |
| 数据安全 | 可能悄悄丢数据 | 不会误以为写成功 |
7. 一句话记住
每次 fopen(以及 freopen)之后,立刻检查返回值是否为空指针,这是使用文件的第一条习惯。
对从未打开的流做 I/O 会怎样
0. 一句话结论
结果取决于你用的是哪种"流":
- C 风格的
FILE *:对一个从没成功打开的流(通常是NULL或没初始化的指针)做读写,是未定义行为。最常见的结果是程序直接崩溃(段错误),但标准不保证,也可能"碰巧没事"、也可能悄悄写坏内存。 - C++ 的
ifstream/ofstream:对象存在但没有open,读写不会崩溃,只是操作失败,流的出错状态位会被置上。
两种情况的共同教训:打开之后先检查是否成功,再做 I/O。
1. 从零理解:什么叫"流没打开"
"流"可以想成一根管子,一头连着你的程序,另一头连着文件(或键盘、屏幕)。fopen 或 open 的作用就是把管子接到文件上。
没接上管子就往里灌水,会发生什么?取决于管子长什么样:
- C 的
FILE *只是一个指针。没打开成功,指针要么是NULL,要么是随机垃圾值。你拿它去读写,等于去访问一块不存在或不属于你的内存。 - C++ 的流是一个完整的对象,自己带着状态位。没打开时它清楚地知道"我没连着文件",所以会礼貌地拒绝操作。
先看整体结构,用 Mermaid 树表示:
同样的结构用 ASCII 画一遍:
对未打开的流做 I/O
|
+-- C 风格 FILE 指针
| |
| +-- 指针为 NULL 或未初始化
| |
| +-- 未定义行为(常见结果:崩溃)
|
+-- C++ 流对象
|
+-- 对象存在但没有 open
|
+-- 操作失败,状态位被置上,程序不崩溃
对比表:
| 对比项 | C 风格 FILE * | C++ ifstream / ofstream |
|---|---|---|
| "没打开"的样子 | 指针是 NULL 或垃圾值 | 对象存在,is_open() 为 false |
| 对它做 I/O | 未定义行为 | 操作失败,不崩溃 |
| 常见现象 | 段错误、数据损坏 | failbit 被置位 |
| 怎么发现问题 | 检查 fopen 是否返回 NULL | 检查 is_open() 或 if (!stream) |
2. 补充:哪些流不需要自己打开
程序一启动,下面三个流就已经自动打开了,可以直接用:
| 流 | C 名称 | C++ 名称 | 用途 |
|---|---|---|---|
| 标准输入 | stdin | std::cin | 读键盘 |
| 标准输出 | stdout | std::cout | 写屏幕 |
| 标准错误 | stderr | std::cerr | 写错误信息 |
只有你自己创建的文件流,才需要先打开再用。
3. 示例一:C++ 流对象没打开
3.1 完整代码
// 示例1:C++ 流对象从未打开就读写 —— 不会崩溃,但操作会失败
#include <iostream> // std::cout
#include <fstream> // std::ifstream, std::ofstream
#include <string> // std::string
// 打印一个流当前的四个状态位,方便观察
static void show_state(const char *name, const std::ios &s) {
std::cout << name
<< ": good=" << s.good() // 一切正常
<< " eof=" << s.eof() // 到达末尾
<< " fail=" << s.fail() // 操作失败
<< " bad=" << s.bad() // 严重错误
<< '\n';
}
int main() {
// 只创建对象,不调用 open,也不在构造时给文件名
std::ofstream out;
std::ifstream in;
std::cout << "out.is_open() = " << out.is_open() << '\n'; // 0,表示没打开
show_state("out(刚创建)", out); // 还没做任何I/O,状态正常
// 对没打开的流写数据:什么都不会写出去,只会让 failbit 置位
out << "hello";
show_state("out(写入后)", out); // fail=1 且 bad=1(没有底层文件可写)
// 对没打开的流读数据:同样失败,变量保持原值
std::string word = "原值";
in >> word;
show_state("in(读取后)", in); // fail=1 且 eof=1(没有任何数据可读)
std::cout << "word 仍然是: " << word << '\n';
// 正确的做法:操作之后(或之前)检查流的状态
if (!out) {
std::cout << "检测到 out 不可用,不再继续写入\n";
}
return 0;
}
3.2 关键点解析
std::ofstream out;只创建对象,没有给文件名,所以它没连着任何文件。out.is_open()返回0(即false)。show_state打印四个状态位,它们是流的"健康报告":
| 状态位 | 含义 |
|---|---|
good() | 一切正常 |
eof() | 已经读到末尾 |
fail() | 刚才的操作失败了 |
bad() | 底层出了严重问题 |
- 对没打开的
out写数据后,输出显示fail=1 bad=1:没有底层文件可写,所以失败。 - 对没打开的
in读数据后,输出显示fail=1 eof=1:没有任何数据可读。变量word保持原值,没有被破坏。 if (!out):流对象可以直接当条件判断,不正常时为真。这是检查流状态最简洁的写法。
3.3 它是怎么实现的
流对象内部有一个"底层缓冲区"指针。没打开文件时,这个指针是空的。每次读写前,流会先检查这件事:发现没有缓冲区,就不去碰它,而是直接把 failbit 等状态位置上并返回。所以不会崩溃。一旦状态位被置上,后面所有读写都会被直接跳过,直到你调用 clear() 清除。
3.4 时序图
4. 示例二:C 风格 FILE 指针,用检查避免危险
4.1 完整代码
// 示例2:C 风格 FILE* 的"从未打开"情形 —— 用检查来避免未定义行为
#include <cstdio> // FILE, fopen, fprintf, fclose
// 安全的写入函数:先判断指针是否有效,再做I/O
static bool safe_write(std::FILE *fp, const char *text) {
if (fp == NULL) { // 从未成功打开的流,指针是 NULL
std::fprintf(stderr, "错误:流没有打开,放弃写入\n");
return false;
}
return std::fprintf(fp, "%s\n", text) >= 0;
}
int main() {
// 情形A:fopen 失败,得到 NULL(目录不存在,所以必然失败)
std::FILE *fp = std::fopen("/no_such_dir/a.txt", "w");
std::printf("fopen 之后 fp 是否为 NULL: %d\n", fp == NULL);
safe_write(fp, "不会真的写出去"); // 被检查拦住,程序安全
// 情形B:标准流 stdout 是程序启动时自动打开的,可以直接用
safe_write(stdout, "stdout 无需 fopen,可以直接写");
// 情形C:正常打开,正常写,正常关
fp = std::fopen("demo_q2.txt", "w");
if (safe_write(fp, "正常写入")) {
std::printf("正常写入成功\n");
}
if (fp != NULL) std::fclose(fp);
return 0;
}
4.2 关键点解析
fopen("/no_such_dir/a.txt", "w"):目录不存在,所以必然失败,返回NULL。这就是"一个从未打开的流"的真实来源。safe_write:在做任何 I/O 之前先判断fp == NULL。这一个if就把危险挡在门外。stdout:是系统自动打开的,不用fopen,直接传给safe_write就能写。- 最后
fclose之前也判断了fp != NULL,因为对NULL调用fclose同样是未定义行为。
4.3 它是怎么实现的
核心思路:把"检查指针"作为每次 I/O 前的固定动作,并且集中放进一个小函数。这样调用者只要用这个函数,就不会忘记检查。
4.4 时序图
5. 示例三:亲眼看看不检查的后果
直接在主程序里对 NULL 写数据会让主程序崩溃,没法接着观察。所以这里用 fork 派一个子进程去"做坏事",父进程等它结束,再报告它是怎么结束的。
5.1 完整代码
// 示例3:亲眼看看对 NULL 的 FILE* 做 I/O 会怎样
// 为了不让主程序崩溃,这里用 fork 派一个子进程去"做坏事",父进程负责观察结果
// 注意:fork 只在 Linux/macOS 等类 Unix 系统可用
#include <cstdio>
#include <cstdlib>
#include <sys/types.h> // pid_t
#include <sys/wait.h> // waitpid, WIFSIGNALED, WTERMSIG
#include <unistd.h> // fork
#include <csignal> // SIGSEGV
int main() {
pid_t pid = fork(); // 复制出一个子进程
if (pid < 0) {
std::perror("fork");
return 1;
}
if (pid == 0) {
// ---- 子进程:故意对 NULL 流写数据,这是未定义行为 ----
std::FILE *fp = NULL; // 模拟"从未打开"的流
std::fprintf(fp, "boom\n"); // 未定义行为,常见结果是崩溃
std::_Exit(0); // 如果没崩溃才会走到这里
}
// ---- 父进程:等子进程结束,看它是怎么结束的 ----
int status = 0;
waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) { // 被信号杀死
std::printf("子进程被信号 %d 终止", WTERMSIG(status));
if (WTERMSIG(status) == SIGSEGV) {
std::printf("(段错误 SIGSEGV)");
}
std::printf("\n");
} else {
std::printf("子进程正常结束,这次碰巧没有崩溃(行为仍是未定义的)\n");
}
return 0;
}
5.2 关键点解析
fork():把当前进程复制一份。返回值0的是子进程,大于0的是父进程,小于0表示创建失败。- 子进程里
FILE *fp = NULL;然后fprintf(fp, ...):这就是"对从未打开的流做 I/O"。标准库会去读取fp指向的内部结构,而地址 000 不属于程序,操作系统便发出SIGSEGV信号终止它。 std::_Exit(0):正常情况下走不到这里,写上是为了让代码逻辑完整。- 父进程
waitpid等待子进程结束,再用WIFSIGNALED判断是不是被信号杀死,WTERMSIG取出信号编号。在 Linux 上SIGSEGV的编号是 111111,所以会看到"子进程被信号 11 终止"。 - 注意:这个结果是常见表现,不是标准保证。未定义行为的意思就是什么都可能发生。
5.3 它是怎么实现的
把危险操作隔离进另一个进程:子进程崩溃,不影响父进程。父进程通过 waitpid 拿到子进程的"死因",从而把一次崩溃变成了一次可观察的实验。
5.4 时序图
6. 总结
- 对从未打开的 C 流做 I/O:未定义行为,常见是崩溃。
- 对从未打开的 C++ 流做 I/O:不崩溃,但操作失败,状态位被置上。
- 预防办法只有一条:打开后立刻检查成功与否。
| 场景 | 检查方式 |
|---|---|
C 的 fopen | if (fp == NULL) |
| C++ 的文件流 | if (!stream) 或 stream.is_open() |
| 每次读写之后 | 看返回值,或看流的状态位 |
编译运行方式(示例三只能在类 Unix 系统上运行):
g++ -std=c++17 -Wall -Wextra demo.cpp -o demo
./demo
fclose 失败了但程序没检查返回值,会发生什么
一句话答案:程序会以为数据已经安全存盘,实际上可能一个字节都没写进去,而且没有任何报错。 下面从零讲清楚为什么,并给出可以直接运行的演示和正确写法。
编译方式:
g++ -std=c++17 -Wall -Wextra demo.cpp -o demo && ./demo
1. 先搞懂:fclose 到底做了什么
写文件时,fputs、fprintf 并不会马上写到磁盘,而是先放进内存里的缓冲区,攒一批再写。
程序 缓冲区(内存) 磁盘
+------+ fputs +-------------+ 真正写入 +------+
| 数据 | ------> | 暂存的数据 | ---------> | 文件 |
+------+ +-------------+ +------+
^
|
fclose 时把剩下的内容全部写出去
所以 fclose 实际做了三件事:
- 把缓冲区里还没写出去的数据真正写到磁盘(这一步最容易失败)。
- 释放缓冲区和
FILE对象。 - 关闭底层的文件描述符。
fclose成功返回 000,失败返回EOF(通常是 −1-1−1),并设置errno说明原因。
2. 为什么 fclose 会失败
前面的 fputs 看上去都成功了,只是因为它们只写进了内存。真正碰到磁盘,是在最后刷新缓冲区的时候。这时才暴露出问题:
| 失败原因 | 说明 |
|---|---|
| 磁盘已满 | 缓冲区里的数据没有空间落地 |
| 超出配额 | 用户的磁盘配额用完了 |
| 网络文件系统出错 | 远端服务器断开,最后一批数据没送达 |
| 设备出现 I/O 错误 | U 盘被拔掉、硬件故障 |
| 文件描述符已失效 | 极少见,多半是别处的 bug |
3. 不检查会有什么后果
| 后果 | 解释 |
|---|---|
| 数据悄悄丢失 | 最后一批缓冲数据没写进文件,程序却显示"保存成功" |
| 文件被截断或损坏 | 文件只有前半部分,下次读取时才发现不对 |
| 错误无法追查 | 程序已经继续往下跑,几个小时后才发现文件坏了,很难定位 |
| 误删旧数据 | 如果程序"先写新文件、再删旧文件",新文件其实是坏的,旧数据却没了 |
还有一个容易忽略的点:即使 fclose 失败,这个 FILE* 也已经失效,不能再用它,也不能再 fclose 一次。 失败后能做的只有报告错误、清理现场、或者换个办法重新写。
4. 亲眼看到:用 /dev/full 模拟磁盘满
Linux 里有个特殊设备 /dev/full:往里写任何东西都会报"磁盘已满"(ENOSPC)。拿它正好可以稳定复现 fclose 失败。
完整代码
#include <cstdio>
#include <cerrno>
#include <cstring>
using namespace std;
int main() {
// 打开一个"永远写不进去"的设备(Linux 专用)
FILE* fp = fopen("/dev/full", "w");
if (fp == NULL) { // 非 Linux 系统上可能没有这个设备
perror("无法打开 /dev/full,跳过演示");
return 0;
}
// fputs 只是写进内存缓冲区,所以它会"成功"
int r1 = fputs("重要数据\n", fp);
printf("fputs 返回值: %d (非负数表示成功)\n", r1);
// 关键:不检查 fclose 的错误写法
// fclose(fp); // 如果这样写,程序会安静地继续,完全不知道数据丢了
// 正确写法:检查返回值,并读取 errno 查看原因
int r2 = fclose(fp); // 这时才真正刷新缓冲区,会失败
if (r2 == EOF) { // EOF 表示关闭失败
printf("fclose 失败! 原因: %s\n", strerror(errno));
printf("也就是说,上面那行\"重要数据\"根本没有保存。\n");
} else {
printf("fclose 成功\n");
}
return 0;
}
代码是怎么实现的
- 打开
/dev/full,这个设备永远提示"空间不足"。 fputs只是把字符串放进缓冲区,所以返回成功。这就是危险所在:写入"成功"并不等于数据已存盘。fclose才会把缓冲区真正写出去,这时碰到"磁盘满",返回EOF。strerror(errno)把错误码转成可读的文字(例如"No space left on device")。- 注释掉的那行是典型的错误写法:不看返回值,程序就以为一切正常。
时序图
5. 正确做法:每个环节都检查
想确保数据真的落盘,要检查三个位置:
- 每次写入(
fputs、fprintf、fwrite)的返回值。 fflush或ferror的结果(可以提前发现错误)。fclose的返回值(最后一道关口,绝对不能省)。
再进一步,保存重要文件时有个常用套路:先写到临时文件,确认全部成功后再用rename替换旧文件。 这样即使中途失败,旧文件原封不动。
完整代码
#include <cstdio>
#include <cerrno>
#include <cstring>
using namespace std;
// 把文本写入文件,每一步都检查。成功返回 true
static bool write_text(const char* path, const char* text) {
FILE* fp = fopen(path, "w");
if (fp == NULL) { // 1. 打开要检查
printf("打开失败: %s\n", strerror(errno));
return false;
}
bool ok = true;
if (fputs(text, fp) == EOF) ok = false; // 2. 写入要检查
if (fflush(fp) == EOF) ok = false; // 3. 主动刷新,提前暴露错误
if (ferror(fp)) ok = false; // 4. 流上有没有记录错误
int saved = errno; // 先保存错误码,免得被 fclose 覆盖
if (fclose(fp) == EOF) { // 5. 关闭也要检查
ok = false;
saved = errno;
}
if (!ok) printf("写入 %s 失败: %s\n", path, strerror(saved));
return ok;
}
// 安全保存:先写临时文件,成功后再替换正式文件
static bool save_atomic(const char* path, const char* text) {
char tmp[256];
snprintf(tmp, sizeof tmp, "%s.tmp", path); // 临时文件名,例如 a.txt.tmp
if (!write_text(tmp, text)) { // 写失败:删掉临时文件,旧文件不受影响
remove(tmp);
return false;
}
if (rename(tmp, path) != 0) { // 替换也要检查
perror("rename 失败");
remove(tmp);
return false;
}
return true;
}
int main() {
// 正常文件:应当成功
printf("保存到普通文件: %s\n",
save_atomic("demo_fclose.txt", "hello\n") ? "成功" : "失败");
// /dev/full:应当失败,而且我们能知道
printf("写入 /dev/full: %s\n",
write_text("/dev/full", "hello\n") ? "成功" : "失败");
remove("demo_fclose.txt"); // 清理
return 0;
}
代码是怎么实现的
write_text用一个布尔变量ok累计各步是否成功,任何一步失败都会让它变成false,但仍然会继续执行fclose,保证资源被释放。fflush提前刷新,让写盘错误在fclose之前就暴露。ferror查看流上是否已经记录了错误。errno可能被后续调用改写,所以在fclose之前先保存一份。save_atomic先把内容写进路径.tmp。只有临时文件完整写好,才用rename一步换成正式文件。因为rename在同一个文件系统内是原子的,正式文件要么是旧的完整版本,要么是新的完整版本,不会出现写了一半的情况。main里一个成功、一个失败,让你对比两种结果。
流程示意
write_text 内部:
fopen --失败--> 报错返回
|
v
fputs --失败--> ok = false
|
v
fflush --失败--> ok = false
|
v
ferror --有错--> ok = false
|
v
fclose --失败--> ok = false
|
v
ok 为 true 才算保存成功
时序图
6. 小结
fclose不只是"关文件",它还负责把缓冲区里最后一批数据写到磁盘,所以它是会失败的。- 不检查返回值,程序会误以为保存成功,数据可能悄悄丢失、文件可能被截断,而且几乎无法事后追查。
fclose失败后,FILE*已经不能再用,不要重复关闭。- 好习惯:写入、
fflush、ferror、fclose都检查;重要文件用"临时文件加 rename"的方式保存。 - 读文件时
fclose失败影响小,但写文件时必须检查。
15.20 习题 4:标准输入被重定向到文件,程序能检测出来吗
题目:如果程序运行时,标准输入被重定向成从一个文件读取,程序怎么发现这件事?
1. 先给结论
用标准 C 的函数,程序发现不了。
原因很简单:stdin 只是一个流。对 getchar、fgets、scanf 来说,数据是从键盘来的,还是从文件来的,还是从另一个程序的输出来的,它们完全不关心,用起来一模一样。这正是"流"这个设计的优点:程序不用管数据来源。
但是,如果你确实需要知道,可以借助下面两类办法:
| 办法 | 属于标准 C 吗 | 原理 | 可靠程度 |
|---|---|---|---|
判断 stdin 是否连着终端(isatty) | 不是,属于 POSIX 系统函数 | 终端是特殊设备,文件不是 | 能区分"键盘"和"非键盘",但分不出文件和管道 |
查询 stdin 的文件类型(fstat) | 不是,属于 POSIX 系统函数 | 直接问系统这是普通文件、管道还是终端 | 最准确 |
试着在 stdin 上定位(ftell / fseek) | 是,标准 C 函数 | 文件能定位,键盘和管道不能 | 常用的小技巧,但标准不保证 |
下面一步步讲清楚。
2. 先弄懂什么是"重定向"
在命令行里:
./a.out 标准输入连着键盘
./a.out < data.txt 标准输入改连到 data.txt(重定向)
cat data.txt | ./a.out 标准输入连着另一个程序的输出(管道)
重定向由命令行外壳(shell)完成:它在启动你的程序之前,就把 stdin 换成了文件。你的程序启动时,stdin 已经是接好的了,程序自己并没有被告知。
用 ASCII 图表示三种情况:
情况一:键盘
键盘 ---> stdin ---> 程序
情况二:重定向到文件
data.txt ---> stdin ---> 程序
情况三:管道
其他程序的输出 ---> stdin ---> 程序
同样的内容,用 Mermaid 画一遍:
无论哪一种,程序看到的都是同一个 stdin,这就是标准 C 无法区分它们的根本原因。
3. 三种检测办法的原理
3.1 办法一:isatty,判断是不是终端
isatty(fd) 问系统:“文件描述符 fd 连着终端吗?”。标准输入的描述符编号固定是 000。
- 连着键盘(终端):返回非零,表示"是终端"。
- 重定向到文件、管道:返回 000,表示"不是终端"。
缺点:它只能告诉你"是不是键盘",分不出是文件还是管道。
3.2 办法二:fstat,直接问文件类型
fstat(fd, &info) 会把文件的详细信息填进 info,其中有文件类型。用宏 S_ISREG(info.st_mode) 判断是不是普通文件,S_ISFIFO 判断是不是管道,S_ISCHR 判断是不是字符设备(终端就是这类)。
这是最准确的办法,能把三种情况分清楚。
3.3 办法三:试着定位,用 ftell
文件有"位置"的概念,可以前后跳;键盘和管道只能一路往前读,不能跳。所以:
- 对文件调用
ftell(stdin),会成功,返回当前位置,刚启动时通常是 000。 - 对键盘或管道调用,会失败,返回 −1-1−1。
这个办法只用了标准 C 的函数,但标准没有保证一定这样,只是大多数系统上管用。
4. 完整代码
// 文件名:demo_detect_stdin.cpp
// 演示:用三种办法检测 stdin 是键盘、普通文件还是管道
// 说明:isatty 和 fstat 是 POSIX 函数,适用于 Linux 和 macOS。
// Windows 上对应的是 _isatty 和 _fstat,这里不展开。
#include <cstdio> // std::printf, std::ftell, stdin
#include <unistd.h> // isatty, STDIN_FILENO
#include <sys/stat.h> // fstat, struct stat, S_ISREG, S_ISFIFO, S_ISCHR
int main() {
// ---------- 办法一:isatty ----------
// STDIN_FILENO 就是数字 0,代表标准输入的文件描述符
if (isatty(STDIN_FILENO)) {
std::printf("[isatty] stdin 连着终端(键盘)\n");
} else {
std::printf("[isatty] stdin 不是终端(可能是文件或管道)\n");
}
// ---------- 办法二:fstat ----------
struct stat info; // 用来接收文件信息的结构体
if (fstat(STDIN_FILENO, &info) == 0) { // 返回 0 表示查询成功
if (S_ISREG(info.st_mode)) { // 是普通文件
std::printf("[fstat ] stdin 是普通文件(被重定向到了文件)\n");
} else if (S_ISFIFO(info.st_mode)) { // 是管道
std::printf("[fstat ] stdin 是管道\n");
} else if (S_ISCHR(info.st_mode)) { // 是字符设备,终端属于这一类
std::printf("[fstat ] stdin 是字符设备(通常是终端)\n");
} else {
std::printf("[fstat ] stdin 是其他类型\n");
}
} else {
std::perror("fstat"); // 查询失败,输出原因
}
// ---------- 办法三:ftell,标准 C 的小技巧 ----------
long pos = std::ftell(stdin); // 文件能返回位置,键盘和管道返回 -1
if (pos != -1L) {
std::printf("[ftell ] stdin 可以定位,当前位置 = %ld(像是文件)\n", pos);
} else {
std::printf("[ftell ] stdin 不能定位(像是键盘或管道)\n");
}
return 0;
}
代码讲解
isatty(STDIN_FILENO):直接问系统,标准输入是不是终端。程序没有读取任何数据,只是"问"。struct stat info加fstat(...):让系统把标准输入对应的文件信息填进info。info.st_mode里编码了文件类型,用S_ISREG、S_ISFIFO、S_ISCHR这几个宏来判断。std::ftell(stdin):试着获取当前位置。失败返回 −1-1−1,所以用pos != -1L判断是否成功。- 三种办法都没有消耗输入数据,只是查询,所以检测完之后,程序依然可以正常读取输入。
关键点
- 标准 C 自己做不到"明确区分",
isatty和fstat都是系统提供的额外函数,换平台要换对应的函数。 isatty分不出文件和管道,想分清要用fstat。- 检测不是为了好玩,常见用途是:交互时(终端)显示提示符和彩色输出,从文件读入时(重定向)就不显示提示,让输出更干净。
时序图
5. 实际运行效果
在 Linux 下编译运行:
g++ -std=c++17 demo_detect_stdin.cpp -o detect
三种运行方式,对应的输出判断如下:
| 运行方式 | isatty | fstat | ftell |
|---|---|---|---|
./detect(键盘) | 是终端 | 字符设备 | 不能定位 |
./detect < data.txt(文件) | 不是终端 | 普通文件 | 可以定位,位置 0 |
echo hi | ./detect(管道) | 不是终端 | 管道 | 不能定位 |
其中第一行(键盘)需要在真正的终端里运行才能看到。
6. 一句话记住
标准 C 的输入函数不关心数据从哪来,所以单靠标准库发现不了重定向;真要检测,就用系统提供的 isatty 或 fstat,或者用 ftell 试探 stdin 能不能定位。
fgets 的缓冲区大小是 1 或 2 时会怎样
一句话答案:大小为 1 时,fgets 一个字符也读不了,只会往缓冲区放一个结尾符
'\0',而且每次都"成功"返回,文件位置原地不动,很容易写出死循环。大小为 2 时,每次只能读 1 个字符,能用,但效率很低,一行有多少个字符就要调用多少次。
编译方式:
g++ -std=c++17 -Wall -Wextra demo.cpp -o demo && ./demo
1. 先回忆 fgets 的规则
函数原型:
char* fgets(char* buf, int n, FILE* fp);
规则可以用一句话记住:最多读 n−1n-1n−1 个字符,再在后面补一个 '\0'。 读到换行符会提前停下,换行符也会被放进缓冲区。
所以缓冲区大小 nnn 决定了一次能读多少:
一次最多读到的字符数=n−1
\text{一次最多读到的字符数} = n - 1
一次最多读到的字符数=n−1
套进 n=1n=1n=1 和 n=2n=2n=2:
| n 的值 | 最多读几个字符 | 缓冲区里最终的样子 |
|---|---|---|
| 1 | 1−1=01-1=01−1=0 个 | 只有 '\0' |
| 2 | 2−1=12-1=12−1=1 个 | 1 个字符加 '\0' |
| 8 | 8−1=78-1=78−1=7 个 | 最多 7 个字符加 '\0' |
2. 大小为 1:一个字符也读不了
缓冲区一共只有 1 个字节,而结尾的 '\0' 必须占掉它,没有空间留给任何有用的字符了。
所以会发生这些事:
buf[0]被设成'\0',得到一个空字符串。- 流里的数据一个都没被取走,文件位置不动。
- 返回值是
buf(非 NULL),也就是说,函数告诉你"成功了"。(C 标准对这种情况的描述比较模糊,常见实现如 glibc 就是这么做的。)
最危险的是第 3 点。很多人习惯这样写读文件的循环:
while (fgets(buf, n, fp) != NULL) { ... }
当 n=1n=1n=1 时,fgets 永远返回非 NULL,文件位置又永远不前进,循环永远不会结束。
完整代码
#include <cstdio>
using namespace std;
int main() {
FILE* fp = tmpfile(); // 临时文件,关闭后自动删除
if (fp == NULL) return 1;
fputs("AB\n", fp); // 文件内容:A B 换行
rewind(fp); // 回到开头
char buf[1]; // 只有 1 个字节
buf[0] = 'X'; // 先放个标记,看 fgets 会不会改它
// 为了避免死循环,这里只调用 3 次,而不是 while 循环
for (int i = 1; i <= 3; ++i) {
char* r = fgets(buf, 1, fp); // n = 1:最多读 0 个字符
long pos = ftell(fp); // 当前文件位置
printf("第 %d 次: 返回%s, buf[0]的值=%d, 文件位置=%ld\n",
i,
r == NULL ? "NULL" : "非NULL",
(int)buf[0], // 0 表示被写成了 '\0'
pos);
}
// 对比:换成 n = 2,就能读到真正的字符了
char buf2[2];
fgets(buf2, 2, fp);
printf("n=2 时读到的字符: %c\n", buf2[0]);
fclose(fp);
return 0;
}
预期输出:
第 1 次: 返回非NULL, buf[0]的值=0, 文件位置=0
第 2 次: 返回非NULL, buf[0]的值=0, 文件位置=0
第 3 次: 返回非NULL, buf[0]的值=0, 文件位置=0
n=2 时读到的字符: A
代码是怎么实现的
- 用
tmpfile造一个内容为AB加换行的临时文件,rewind回到开头。 - 缓冲区只有 1 字节,先放
'X',这样如果fgets改动了它,就能看出来。 - 连续调用 3 次
fgets(buf, 1, fp)。每次都返回非 NULL,buf[0]变成 0(即'\0'),ftell一直是 0,说明没读走任何数据,而且位置没变。 - 如果写成
while (fgets(...) != NULL),这里就会死循环,所以演示里特意用了有限次数的for。 - 最后用大小为 2 的缓冲区读一次,拿到第一个字符
A,作为对比。
时序图
3. 大小为 2:每次只能读 1 个字符
缓冲区有 2 个字节:1 个放字符,1 个放 '\0'。可以正常工作,但读得非常"碎":
- 每次调用最多取 1 个字符。
- 一行包含换行共 LLL 个字符,就要调用 LLL 次才能读完。
- 换行符也要单独占一次调用。
因此,一个长度为 LLL 的行,需要的调用次数是:
调用次数=⌈Ln−1⌉ \text{调用次数} = \left\lceil \frac{L}{n-1} \right\rceil 调用次数=⌈n−1L⌉
当 n=2n=2n=2 时,分母是 1,所以调用次数就是 LLL。
再看一个更常见的问题:你想判断"是否读完一整行",看缓冲区末尾是不是换行符。n=2n=2n=2 时,缓冲区里可能是"A"、也可能是"\n",每次都要判断,很繁琐。
完整代码
#include <cstdio>
#include <cstring>
using namespace std;
int main() {
FILE* fp = tmpfile();
if (fp == NULL) return 1;
fputs("Hi\nYo", fp); // 两行:Hi 换行 Yo(第二行没有换行)
rewind(fp);
char buf[2]; // 2 个字节:1 个字符加 '\0'
int calls = 0; // 统计调用次数
// n = 2:每次最多读 1 个字符,读到文件末尾返回 NULL,循环能正常结束
while (fgets(buf, 2, fp) != NULL) {
++calls;
if (buf[0] == '\n') {
printf("第 %d 次调用: 读到换行符\n", calls);
} else {
printf("第 %d 次调用: 读到字符 '%c'\n", calls, buf[0]);
}
}
printf("总共调用了 %d 次\n", calls); // 共 5 个字符,所以是 5 次
fclose(fp);
return 0;
}
预期输出:
第 1 次调用: 读到字符 'H'
第 2 次调用: 读到字符 'i'
第 3 次调用: 读到换行符
第 4 次调用: 读到字符 'Y'
第 5 次调用: 读到字符 'o'
总共调用了 5 次
代码是怎么实现的
- 文件内容
Hi、换行、Yo,一共 5 个字符。 - 缓冲区大小为 2,所以
fgets每次最多取 1 个字符。 while (fgets(...) != NULL)在 n=2n=2n=2 时是安全的:每次都会前进至少一个字符,到文件末尾时返回 NULL,循环结束。- 因为每次只拿到 1 个字符,所以
buf[0]要么是普通字符,要么是'\n',代码里分别处理。 - 5 个字符调用了 5 次,正好等于前面公式算出的 L=5L=5L=5。
流的读取过程示意
文件内容: H i \n Y o
位置: 0 1 2 3 4
第1次 取 H 位置 -> 1
第2次 取 i 位置 -> 2
第3次 取 \n 位置 -> 3 (换行也单独占一次)
第4次 取 Y 位置 -> 4
第5次 取 o 位置 -> 5
第6次 到末尾,返回 NULL,循环结束
时序图
4. 两种情况对比
| 项目 | n = 1 | n = 2 |
|---|---|---|
| 一次最多读几个字符 | 0 个 | 1 个 |
| 缓冲区内容 | 只有 '\0' | 1 个字符加 '\0' |
| 文件位置 | 不前进 | 每次前进 1 个字符 |
| 返回值 | 非 NULL(常见实现) | 读到数据返回非 NULL,到末尾返回 NULL |
| 配合 while 循环 | 死循环 | 正常结束 |
| 实用价值 | 没有 | 能用,但效率很低 |
5. 补充:n 小于等于 0 呢
当 n≤0n \le 0n≤0 时,标准把它当作未定义的错误用法:没有任何空间可以放结尾符,行为不可预测(有的实现返回 NULL,有的直接出错)。永远不要这样用。
6. 实际写代码怎么避免
- 缓冲区长度用
sizeof buf传入,不要手写数字,免得写错。 - 缓冲区至少开到能装下一整行的合理大小,常见做法是 256 或 1024 字节。
- 如果行可能很长,就循环读取,直到缓冲区末尾出现换行符或遇到文件末尾。
- 如果缓冲区大小来自变量(例如用户配置),先检查它是否至少为 2,最好更大,再调用
fgets。
7. 小结
fgets(buf, n, fp)最多读 n−1n-1n−1 个字符。- n=1n=1n=1:什么也读不到,
buf变成空字符串,文件位置不动,返回值还显示成功,配合while会死循环。 - n=2n=2n=2:每次只读 1 个字符,可以用但效率低,换行符也要占一次调用。
- n≤0n \le 0n≤0:未定义行为,不要使用。
- 好习惯:缓冲区用
sizeof buf,大小开到足够一整行。
sprintf 的缓冲区到底要多长
题目:机器的 int 是 2 字节,执行
sprintf(buffer, "%d %c %x", a, b, c);
buffer 至少要多长,才保证不会溢出?
0. 答案
14 个字节。
其中 13 个是可见字符,1 个是结尾的空字符 \0。下面从零开始,把这个数字是怎么来的讲清楚。
1. 从零理解:sprintf 在做什么
printf 把结果打印到屏幕,sprintf 则把结果"打印"到一个字符数组里。
关键事实有两个:
sprintf不会检查数组够不够大。你给多小的数组,它照写不误,写多了就写到数组外面,这就是缓冲区溢出,是危险的未定义行为。- 字符串末尾还要放一个结尾符
\0,它也占 1 个字节,很容易忘。
所以我们要做的事是:算出最坏情况下输出有多长,再加 1。
"最坏情况"的意思是:每个格式项都取它能打印出的最长的值。
2. 逐项算最长
格式串是 "%d %c %x",里面有三个格式项和两个空格。
2.1 %d:有符号整数
2 字节就是 16 位。有符号 16 位整数的取值范围是:
−215≤a≤215−1
-2^{15} \le a \le 2^{15} - 1
−215≤a≤215−1
也就是 −32768-32768−32768 到 327673276732767。
- 最长的是 −32768-32768−32768:5 个数字加 1 个负号,共 6 个字符。
- 正数最长是 327673276732767,只有 5 个字符,更短。
2.2 %c:单个字符
不管传什么,%c 都只打印 1 个字符。
2.3 %x:十六进制
%x 把数当成无符号数,用小写十六进制打印。16 位无符号数最大是:
216−1=65535=ffff16
2^{16} - 1 = 65535 = \text{ffff}_{16}
216−1=65535=ffff16
为什么是 4 位?因为一个十六进制数字对应 4 个二进制位,所以
164=4
\frac{16}{4} = 4
416=4
最长就是 4 个字符。
如果传进去的是负数(比如 −1-1−1),%x 会把它按无符号 16 位解释,也只是 ffff,同样是 4 个字符,不会更长。
2.4 两个空格和结尾符
格式串里有 2 个空格,各占 1 个字符。结尾符 \0 占 1 个字节。
2.5 汇总
| 部分 | 最长字符数 | 说明 |
|---|---|---|
%d | 6 | -32768 |
| 空格 | 1 | 第一个空格 |
%c | 1 | 单个字符 |
| 空格 | 1 | 第二个空格 |
%x | 4 | ffff |
| 可见字符小计 | 13 | 前面五项相加 |
| 结尾符 | 1 | \0 |
| 缓冲区大小 | 14 | 13 + 1 |
总公式:
6+1+1+1+4+1=14
6 + 1 + 1 + 1 + 4 + 1 = 14
6+1+1+1+4+1=14
用一棵 Mermaid 树表示:
再用 ASCII 画出最坏情况下字符在数组里的样子,每个格子是一个字节:
下标: 0 1 2 3 4 5 6 7 8 9 10 11 12 13
内容: [-][3][2][7][6][8][ ][x][ ][f][f][f][f][\0]
|__ %d: 6个 ___| | | | | %x: 4 _| |
| | | |
空格%c 空格 结尾符
一共 14 个格子,刚好装满,没有多余。
3. 示例一:验证 14 字节刚好够用
3.1 完整代码
// 示例1:模拟"2字节整数"的最坏情况,验证缓冲区需要 14 字节
#include <cstdio> // sprintf, printf
#include <cstring> // strlen
int main() {
// 用 short 模拟 2 字节整数(绝大多数平台上 short 就是 16 位)
short a = -32768; // %d 能打印出的最长整数:-32768,共 6 个字符
char b = 'x'; // %c 永远只打印 1 个字符
unsigned short c = 0xFFFF; // %x 能打印出的最大值:ffff,共 4 个字符
// 缓冲区大小 = 6 + 1 + 1 + 1 + 4 + 1(结尾的 '\0') = 14
char buffer[14];
// 参数会被提升为 int 传给 sprintf:
// a -> -32768,b -> 120,c -> 65535(在 int 里是正数,%x 打印为 ffff)
int n = std::sprintf(buffer, "%d %c %x", a, b, c);
// sprintf 的返回值是"写入的字符个数",不含结尾的 '\0'
std::printf("buffer = \"%s\"\n", buffer);
std::printf("返回值 n = %d (有效字符数)\n", n);
std::printf("strlen = %zu\n", std::strlen(buffer));
std::printf("占用总字节 = %d (有效字符 + 1 个结尾符)\n", n + 1);
std::printf("缓冲区大小 = %zu\n", sizeof buffer);
return 0;
}
3.2 关键点解析
- 我们用
short模拟 2 字节整数。绝大多数平台上short就是 16 位。 a = -32768是%d的最长情况,c = 0xFFFF是%x的最长情况,b是一个字符。三个都取最坏值。- 参数传给
sprintf时,short和char会先被提升成int。unsigned short的 65535 提升后还是正数 65535,%x打印出ffff。 sprintf的返回值是写入的有效字符个数,不含结尾符。输出显示是 13,再加 1 就是 14,和缓冲区大小吻合。
3.3 它是怎么实现的
思路是"取极值验证":把每个格式项都推到最长,算出实际输出长度,和理论计算对照。运行结果 13+1=1413 + 1 = 1413+1=14,说明手算是对的。
3.4 时序图
4. 示例二:不用手算,让程序告诉你
现实中 int 往往是 4 字节,手算的数字会变。更稳妥的办法是让 snprintf 先算出"需要多少字节"。
4.1 完整代码
// 示例2:任何平台都能用的算缓冲区方法 —— 先问 snprintf "需要多少字节"
#include <cstdio> // snprintf, printf
#include <climits> // INT_MIN, UINT_MAX
#include <cstdlib> // malloc, free
int main() {
int a = INT_MIN; // 当前平台上最长的 %d
char b = 'x';
unsigned int c = UINT_MAX; // 当前平台上最长的 %x
// 关键技巧:缓冲区传 NULL、大小传 0,snprintf 不写任何东西,
// 只返回"完整输出需要多少个字符"(不含结尾符)
int need = std::snprintf(NULL, 0, "%d %c %x", a, b, c);
if (need < 0) { // 返回负数表示格式出错
std::printf("snprintf 出错\n");
return 1;
}
// 实际要分配 need + 1,多出的 1 个给结尾的 '\0'
std::size_t size = static_cast<std::size_t>(need) + 1;
char *buf = static_cast<char *>(std::malloc(size));
if (buf == NULL) { // 分配也可能失败,要检查
std::printf("内存不足\n");
return 1;
}
// 用 snprintf 而不是 sprintf:多一道"最多写 size 字节"的保险
std::snprintf(buf, size, "%d %c %x", a, b, c);
std::printf("sizeof(int) = %zu 字节\n", sizeof(int));
std::printf("需要的有效字符数 = %d\n", need);
std::printf("需要的缓冲区大小 = %zu\n", size);
std::printf("内容 = \"%s\"\n", buf);
std::free(buf); // 用完释放
return 0;
}
4.2 关键点解析
snprintf(NULL, 0, ...):缓冲区给NULL、大小给 0,函数就不会写任何东西,只返回"完整输出需要多少个有效字符"。这是个非常好用的技巧。- 实际分配时要
+ 1,给结尾符留位置。 malloc返回NULL表示分配失败,要检查。- 真正写入时用
snprintf(buf, size, ...),它最多只写size个字节,即使算错了也不会溢出。 - 运行结果:本机
int是 4 字节,最长输出是-2147483648 x ffffffff,共 22 个字符,所以需要 23 字节。
4.3 两种整数大小对比
| 项目 | 2 字节 int | 4 字节 int |
|---|---|---|
%d 最长 | -32768(6 个) | -2147483648(11 个) |
%c | 1 个 | 1 个 |
%x 最长 | ffff(4 个) | ffffffff(8 个) |
| 两个空格 | 2 个 | 2 个 |
| 可见字符合计 | 13 | 22 |
| 缓冲区大小 | 14 | 23 |
4 字节时的 %x 位数是 32÷4=832 \div 4 = 832÷4=8,%d 最长是 −231=−2147483648-2^{31} = -2147483648−231=−2147483648,共 11 个字符。
4.4 它是怎么实现的
流程是"先量后装":先用 snprintf(NULL, 0, ...) 量出需要的长度,再按量到的长度分配内存,最后写入。这样就不依赖 int 的大小,换到任何平台都正确。
4.5 时序图
5. 总结
- 2 字节
int时,缓冲区需要 14 字节:6+1+1+1+4+1=146 + 1 + 1 + 1 + 4 + 1 = 146+1+1+1+4+1=14。 - 做法是"每项取最长再相加,最后加 1 给结尾符"。
sprintf不检查边界,所以在实际代码里更推荐snprintf,并且用snprintf(NULL, 0, ...)先量出需要的长度。
编译运行方式:
g++ -std=c++17 -Wall -Wextra demo.cpp -o demo
./demo
15.20 习题 7:sprintf 的缓冲区要多长才不会溢出
题目:下面这句话里,buffer 至少要多长,才能保证 sprintf 生成的字符串不会把它撑破?
sprintf( buffer, "%s", a );
1. 先给结论
buffer 的长度至少要是:
buffer 长度≥strlen(a)+1
\text{buffer 长度} \ge \text{strlen}(a) + 1
buffer 长度≥strlen(a)+1
也就是:字符串 a 的字符个数,再加 111。
这个多出来的 111 是给字符串结束符 '\0' 用的。
2. 从零理解:sprintf 到底干了什么
sprintf 和 printf 很像,区别只是:printf 把结果输出到屏幕,sprintf 把结果写进一个内存里的字符数组。
这条语句里的格式串是 "%s",意思非常简单:把字符串 a 原样复制过去,没有任何多余的字符。所以结果的长度,就等于 a 本身的长度。
再看 C 语言的字符串规则:一个字符串在内存里,除了所有可见字符,末尾还必须有一个 '\0' 当结束标志。sprintf 写完字符后,会自动补上这个 '\0'。
举个例子,a 是 "hello":
下标: 0 1 2 3 4 5
内容: 'h' 'e' 'l' 'l' 'o' '\0'
|<----- 5 个字符 ----->| |
+-- 结束符,也要占一格
一共要 5+1=65 + 1 = 65+1=6 个字节。如果 buffer 只有 5 个字节,结束符就会写到数组外面一格,这就是溢出。
3. 为什么是 strlen(a) + 1,用数字验证
a 的内容 | strlen(a) | buffer 至少需要 | 说明 |
|---|---|---|---|
""(空串) | 0 | 1 | 只放一个结束符 |
"A" | 1 | 2 | 1 个字符加结束符 |
"hello" | 5 | 6 | 5 个字符加结束符 |
"你好"(UTF-8) | 6 | 7 | 每个汉字占 3 字节,共 6 字节加结束符 |
注意最后一行:strlen 数的是字节数,不是"字"的个数。UTF-8 编码下一个汉字通常占 333 个字节。
4. 一个容易忽略的前提
这个答案成立有个前提:a 本身必须是一个合法的、以 '\0' 结尾的字符串。
- 如果
a是字符数组,且数组长度为 NNN,那么a里的字符串最多占 NNN 个字节(含结束符),所以只要buffer的长度也 ≥N\ge N≥N,就一定够。 - 如果
a没有结束符,sprintf会一直复制下去,直到碰巧遇到一个 000 字节为止,那时想算多长都没有意义了,必然溢出。
还有一点:sprintf自己不知道buffer有多大,它不会检查。所以长度必须由你来保证,这也是它危险的地方。
5. 如果格式串不只是 “%s”,该怎么算
题目里格式串很简单。如果换成别的,长度要把所有部分加起来。比如:
sprintf(buffer, "name=%s, age=%d", a, n);
需要的长度是:
长度=strlen("name=")+strlen(a)+strlen(", age=")+整数 n 的最大位数+1
\text{长度} = \text{strlen("name=")} + \text{strlen}(a) + \text{strlen(", age=")} + \text{整数 } n \text{ 的最大位数} + 1
长度=strlen("name=")+strlen(a)+strlen(", age=")+整数 n 的最大位数+1
整数的位数不好估,所以实际编程中更推荐用下一节的 snprintf。
6. 更安全的做法:snprintf
snprintf(buffer, size, format, ...) 多了一个 size 参数,表示 buffer 的总容量。它最多只写 size - 1 个字符,再补一个结束符,所以永远不会越界。
它的返回值是:如果空间足够,本来应该写出的字符个数(不含结束符)。利用这个返回值,可以先问清楚需要多大。
7. 完整代码
// 文件名:demo_sprintf_len.cpp
// 演示:sprintf 需要 strlen(a)+1 的空间;snprintf 的截断与返回值
#include <cstdio> // std::sprintf, std::snprintf, std::printf
#include <cstring> // std::strlen
#include <vector> // std::vector,用来动态分配刚好够用的缓冲区
int main() {
const char* a = "hello"; // 要复制的字符串
// ---------- 一、按公式分配:strlen(a) + 1 ----------
std::size_t need = std::strlen(a) + 1; // 5 个字符 + 1 个结束符 = 6
std::vector<char> buffer(need); // 刚好 6 字节的缓冲区
// 缓冲区够大,安全;sprintf 返回写入的字符个数(不含结束符)
int written = std::sprintf(buffer.data(), "%s", a);
std::printf("需要的长度 = %zu\n", need);
std::printf("sprintf 写入了 %d 个字符,结果是 \"%s\"\n", written, buffer.data());
// ---------- 二、snprintf 的截断保护 ----------
char small[4]; // 故意开得太小,只有 4 字节
// 最多写 3 个字符 + 结束符;返回值是"本来应该写出的长度",这里是 5
int would = std::snprintf(small, sizeof small, "%s", a);
std::printf("small 里实际存的是 \"%s\"(被截断了)\n", small);
std::printf("snprintf 返回 %d,表示完整内容需要 %d 个字符\n", would, would);
// 判断是否被截断:返回值 >= 缓冲区大小,就说明放不下
if (would >= static_cast<int>(sizeof small)) {
std::printf("提示:缓冲区太小,内容被截断,至少需要 %d 字节\n", would + 1);
}
// ---------- 三、用 snprintf 先问需要多大,再分配 ----------
// 传入空指针和 0 大小,snprintf 只计算长度,不写任何内容
int len = std::snprintf(nullptr, 0, "%s", a);
std::vector<char> exact(static_cast<std::size_t>(len) + 1); // 长度加 1 给结束符
std::snprintf(exact.data(), exact.size(), "%s", a);
std::printf("先算长度再分配:len=%d,分配 %zu 字节,内容 \"%s\"\n",
len, exact.size(), exact.data());
return 0;
}
代码讲解
std::strlen(a) + 1:这就是题目的答案,5+1=65 + 1 = 65+1=6。strlen不计结束符,所以要手动加 111。std::vector<char> buffer(need):开一块刚好 666 字节的空间,用buffer.data()取得指向它的指针,交给sprintf。small[4]:故意太小的数组。snprintf只会往里写 333 个字符hel加一个'\0',所以不会溢出,只是内容被截断。- 返回值
would:值是 555,表示"完整结果有 5 个字符"。判断would >= sizeof small就能知道有没有被截断。 snprintf(nullptr, 0, ...):一个常用技巧,不写内容,只返回需要的长度,然后再按这个长度精确分配。
关键点
- 答案记一个式子:strlen(a)+1\text{strlen}(a) + 1strlen(a)+1,别忘了结束符。
sprintf不检查边界,长度全靠你自己保证;能用snprintf就用snprintf。strlen数的是字节,不是字符个数。
时序图
8. 一句话记住
sprintf(buffer, "%s", a) 要求 buffer 至少有 strlen(a)+1\text{strlen}(a) + 1strlen(a)+1 个字节;多出来的 111 是结束符。拿不准时,用 snprintf。
printf 的 %f 最后一位是四舍五入还是直接截断
一句话答案:是四舍五入,不是截断。 不过因为计算机用二进制存小数,"看起来该进位"的数有时并不会进位(例如
2.675打印成2.67),这不是 bug,而是数本身就不是你以为的那个值。
编译方式:
g++ -std=c++17 -Wall -Wextra demo.cpp -o demo && ./demo
1. 先看现象
printf("%.2f", x) 表示保留 2 位小数。例如 x = 3.14159:
- 后面没打印的部分是
159,第三位是 1,比 5 小,所以舍掉,得到3.14。 - 如果是
3.14999,第三位是 9,要进位,得到3.15。如果是"截断",结果会是3.14。
所以%f做的是舍入:根据没打印的那部分,决定最后一位是否加 1。
C 语言标准要求:转换结果要和数值的真实大小对得上,也就是按当前舍入方式取最接近的值。常见实现(例如 glibc)都按"最接近"处理。
2. 舍入的规则
设要保留的最后一位的下一位及之后的部分为 rrr,以保留位的一个单位为 111,则:
结果={舍掉,r<0.5进一位,r>0.5取偶数,r=0.5(恰好一半)
\text{结果} =
\begin{cases}
\text{舍掉}, & r < 0.5 \\
\text{进一位}, & r > 0.5 \\
\text{取偶数}, & r = 0.5 \text{(恰好一半)}
\end{cases}
结果=⎩⎨⎧舍掉,进一位,取偶数,r<0.5r>0.5r=0.5(恰好一半)
最后一行是常见实现的做法:恰好在正中间时,选"最后一位是偶数"的那个结果,这叫"银行家舍入"。因为这样平均下来不会偏大也不会偏小。
| 例子 | 说明 | 结果 |
|---|---|---|
%.2f 打印 0.125 | 恰好一半,前一位 2 是偶数 | 0.12 |
%.2f 打印 0.375 | 恰好一半,前一位 7 是奇数,进位成偶数 8 | 0.38 |
%.0f 打印 2.5 | 恰好一半,取偶数 | 2 |
%.0f 打印 3.5 | 恰好一半,取偶数 | 4 |
注意:不同平台的处理可能不同。有的老编译器(例如较老版本的 Windows 运行库)会把恰好一半一律进位,得到 0.13。如果你的程序依赖这一点,要在目标平台上实测。
3. 为什么 2.675 打印出来是 2.67
这是最容易让人困惑的地方。计算机用二进制存小数,很多十进制小数无法精确表示。就像十进制里 1/31/31/3 写不尽,二进制里 0.10.10.1、2.6752.6752.675 也写不尽,只能存成最接近的近似值。
2.675 实际存的是:
2.67499999999999982236…
2.67499999999999982236\ldots
2.67499999999999982236…
它比 2.675 略小一点点,第三位之后不到一半,所以舍掉,得到 2.67。打印函数没有错,它舍入的是真实存的那个数。
相反,0.125 和 0.375 恰好能被二进制精确表示(0.125=2−30.125 = 2^{-3}0.125=2−3),所以才会真的"恰好一半",触发取偶数规则。
完整代码
#include <cstdio>
using namespace std;
int main() {
// 每一行:原始值、%.2f 的结果、这个二进制小数的精确值(取 20 位小数)
double v[] = {3.14159, 3.14999, 9.999, 1.005, 2.675, 0.125, 0.375};
int n = (int)(sizeof v / sizeof v[0]);
for (int i = 0; i < n; ++i) {
printf("原值=%-8g %%.2f -> %-6.2f 精确值=%.20f\n", v[i], v[i], v[i]);
}
printf("%%.0f: 0.5->%.0f 1.5->%.0f 2.5->%.0f 3.5->%.0f\n", 0.5, 1.5, 2.5, 3.5);
return 0;
}
预期输出:
原值=3.14159 %.2f -> 3.14 精确值=3.14158999999999988262
原值=3.14999 %.2f -> 3.15 精确值=3.14998999999999984567
原值=9.999 %.2f -> 10.00 精确值=9.99900000000000055422
原值=1.005 %.2f -> 1.00 精确值=1.00499999999999989342
原值=2.675 %.2f -> 2.67 精确值=2.67499999999999982236
原值=0.125 %.2f -> 0.12 精确值=0.12500000000000000000
原值=0.375 %.2f -> 0.38 精确值=0.37500000000000000000
%.0f: 0.5->0 1.5->2 2.5->2 3.5->4
代码是怎么实现的
- 数组
v放了 7 个测试值,n用sizeof算出元素个数。 - 每行打印三样东西:原始值、
%.2f的结果、以及用%.20f打印出来的精确存储值。多打印到 20 位,就能看到数在内存里真正的样子。 - 看
1.005和2.675:精确值都略小于一半,所以不进位,得到1.00和2.67。 - 看
0.125和0.375:精确值恰好是一半,按取偶数处理,分别得到0.12和0.38。 - 最后一行用
%.0f展示整数位的舍入:0.5变0,1.5变2,2.5变2,3.5变4,正好验证"恰好一半取偶数"。 - 格式串里要输出百分号,必须写成
%%。
时序图
4. 和"截断"直接对比
为了更直观,下面用手动截断的函数和 printf 的结果放在一起比。
截断的做法:先把数放大 100 倍,丢掉小数部分,再缩小回去:
截断结果=trunc(x×100)100
\text{截断结果} = \frac{\operatorname{trunc}(x \times 100)}{100}
截断结果=100trunc(x×100)
完整代码
#include <cstdio>
#include <cmath>
using namespace std;
// 手动截断到 2 位小数:先放大 100 倍,丢掉小数部分,再缩小
static double truncate2(double x) {
return trunc(x * 100.0) / 100.0;
}
int main() {
double v[] = {3.14159, 3.14999, 9.999, 2.71828};
int n = (int)(sizeof v / sizeof v[0]);
printf("%-10s %-10s %-10s\n", "原值", "printf四舍五入", "手动截断");
for (int i = 0; i < n; ++i) {
printf("%-10g %-14.2f %-10.2f\n", v[i], v[i], truncate2(v[i]));
}
return 0;
}
预期输出:
原值 printf四舍五入 手动截断
3.14159 3.14 3.14
3.14999 3.15 3.14
9.999 10.00 9.99
2.71828 2.72 2.71
代码是怎么实现的
truncate2先乘 100,用trunc去掉小数部分,再除 100,这就是"只舍不入"。- 同一批数,一列用
%.2f(舍入),一列用truncate2(截断),并排打印。 3.14999:printf给出3.15,截断给出3.14,两者不同,说明%f不是截断。9.999:printf给出10.00,进位一直传到整数部分;截断给出9.99。2.71828:printf给出2.72,截断给出2.71。3.14159的第三位是 1,不需要进位,所以两种方法结果恰好相同。- 需要
<cmath>才能用trunc。
时序图
进位过程示意(以 9.999 为例)
9 . 9 9 9 保留 2 位小数
9 . 9 9 | 9 第 3 位是 9,要进位
|
+--> 最后一位 9 + 1 = 10,向前进位
9 . 9 9 + 0.01 = 10.00
5. 小结
%f是四舍五入(更准确地说,是"取最接近的值"),不是截断。- 恰好在正中间时,常见实现取"最后一位为偶数"的结果,例如
0.125得0.12,0.375得0.38,但老平台可能一律进位。 - 看起来该进位的数(
2.675、1.005)不进位,原因是它们在二进制里的真实值略小于一半,不是函数出错。 - 想验证到底存了什么,用
%.20f多打印几位。 - 如果确实需要"截断",要自己写,例如 trunc(x×100)/100\operatorname{trunc}(x \times 100)/100trunc(x×100)/100;如果要精确的十进制舍入(例如金额),建议用整数存"分",不要用浮点数。
怎样得到 perror 能打印的全部错误信息
0. 答案
perror 打印的文字,和 strerror(errno) 返回的文字是同一份。所以最直接的办法是:
从编号 000 开始,逐个编号调用 strerror,把返回的文字都打印出来,就得到整张表。
下面从零讲清楚这是为什么、怎么做、有什么要注意的。
1. 从零理解
1.1 错误编号和错误文字
系统调用或库函数出错时,会把一个整数写进全局变量 errno,这个整数叫"错误编号"。比如 222 表示"没有这个文件或目录"。
人看不懂数字,所以系统内部有一张"编号 → 文字"的对照表。
strerror(n):查表,返回编号 nnn 对应的文字。perror("前缀"):读取当前errno,查同一张表,把"前缀: 文字"打印到stderr。
用 Mermaid 树画出关系:
用 ASCII 再画一遍:
errno 错误编号
|
+-- 系统内部的"编号到文字"对照表
|
+-- strerror : 返回文字给你
| |
| +-- 遍历编号,得到全部信息
|
+-- perror : 直接打印到 stderr
所以要列出 perror 能打印的全部信息,只要把对照表里的文字都读出来,而 strerror 正是读取这张表的接口。
1.2 几种办法对比
| 办法 | 做法 | 优点 | 缺点 |
|---|---|---|---|
遍历 strerror | 循环 000 到某个上限,逐个调用 | 标准函数,最通用 | 要自己判断哪些编号没有定义 |
| 看头文件 | 阅读 errno.h 及其包含的文件 | 能看到错误名字 | 各系统位置不同,不能自动列出文字 |
| 查手册 | 在 Linux 上执行 man errno | 有名字和解释 | 内容因系统而异 |
sys_errlist 数组 | 直接读系统的字符串数组 | 简单 | 已经过时,很多系统不再提供 |
最推荐第一种。
1.3 要注意的三件事
- 标准只保证少数几个:C 标准只规定了
EDOM、ERANGE、EILSEQ。其余的错误名和编号、文字,都由具体系统决定,所以不同系统打印出的列表不同。 - 编号可能不连续:有的编号没定义。glibc 对没定义的编号返回形如
Unknown error 数字的文字,我们据此跳过。 - 文字可能随语言环境变化:设置了本地化后,同一个编号可能打印出中文或其他语言。
2. 示例一:遍历 strerror,列出全部信息
2.1 完整代码
// 示例1:枚举 perror 能打印的全部错误信息
// 思路:perror 打印的文字,和 strerror(errno) 返回的文字是同一份。
// 所以从 0 开始逐个编号调用 strerror,就能列出整张表。
#include <cstdio> // printf
#include <cstring> // strerror, strncmp
int main() {
const int MAX_CODE = 200; // 一个足够大的上限,各平台实际的错误编号都比它小
int count = 0; // 统计有效的错误信息条数
for (int code = 0; code < MAX_CODE; ++code) {
const char *msg = std::strerror(code); // 编号 -> 文字
// 编号没有对应错误时,glibc 返回 "Unknown error 数字",
// 其他平台写法可能略有不同,这里只处理这种常见写法
if (std::strncmp(msg, "Unknown error", 13) == 0) {
continue; // 跳过没有定义的编号
}
std::printf("%3d : %s\n", code, msg);
++count;
}
std::printf("\n共 %d 条错误信息(上限扫描到 %d)\n", count, MAX_CODE - 1);
return 0;
}
2.2 关键点解析
MAX_CODE = 200:扫描的上限,即编号范围是 0≤n<2000 \le n < 2000≤n<200。选得比任何平台的实际最大编号都大,保证不漏。std::strerror(code):把编号变成文字,返回一个指向字符串的指针。std::strncmp(msg, "Unknown error", 13):判断文字是不是以Unknown error开头,这个词一共 13 个字符。是的话说明编号没定义,用continue跳过。%3d:把编号按 3 位宽度右对齐,输出整齐。- 在我这台 Linux(glibc)上运行,编号 000 是
Success,一共有 132 条有效信息。你的系统结果可能不同。
2.3 它是怎么实现的
整个办法就是一个循环:编号 → 查表 → 过滤掉"未知" → 打印。因为 perror 和 strerror 共用同一张表,所以这个循环打印出来的就是 perror 能打印的内容。
2.4 时序图
3. 示例二:证明 perror 与 strerror 一致,并列出常见错误名
3.1 完整代码
// 示例2:验证 perror 和 strerror 是同一份文字,并列出常见错误的"名字 编号 文字"
#include <cstdio> // printf, perror
#include <cerrno> // errno 以及 ENOENT 等错误名
#include <cstring> // strerror
// 一行表:错误名(字符串)、编号、说明文字
struct Item {
const char *name;
int code;
};
int main() {
// 1) 手动给 errno 赋值,调用 perror,看它打印什么
errno = ENOENT; // 假装刚刚发生了"文件不存在"
std::perror("perror 的输出"); // 输出到 stderr: 前缀 + 冒号 + 错误文字
std::printf("strerror 的输出: %s\n\n", std::strerror(ENOENT)); // 文字应当相同
// 2) 列出一批常见错误:名字、编号、文字
// 宏名 ENOENT 等只是整数常量,编号在不同系统上可能不同,所以要用宏而不是写死数字
const Item items[] = {
{"EPERM", EPERM}, // 操作不被允许
{"ENOENT", ENOENT}, // 没有这个文件或目录
{"EINTR", EINTR}, // 系统调用被信号打断
{"EIO", EIO}, // 输入输出错误
{"EACCES", EACCES}, // 权限不够
{"EEXIST", EEXIST}, // 已经存在
{"ENOMEM", ENOMEM}, // 内存不足
{"EINVAL", EINVAL}, // 参数无效
{"EDOM", EDOM}, // 数学函数参数超出定义域(标准规定)
{"ERANGE", ERANGE}, // 结果超出范围(标准规定)
};
std::printf("%-8s %4s %s\n", "名字", "编号", "文字");
for (const Item &it : items) {
std::printf("%-8s %4d %s\n", it.name, it.code, std::strerror(it.code));
}
return 0;
}
3.2 关键点解析
errno = ENOENT;:手动给errno赋值,假装刚刚发生了"文件不存在"。然后perror会打印它对应的文字。- 输出里两行文字都是
No such file or directory,证明二者查的是同一张表。 perror输出到stderr,printf输出到stdout,所以在终端里两者显示的先后顺序可能与代码顺序略有不同,这是正常的缓冲区差异。Item结构体把"名字、编号"配成一对。宏ENOENT等是编译器根据系统头文件提供的整数常量,所以我们写宏名而不写死数字,编号变了代码也不用改。- 最后的循环对每个编号调用
strerror,把"名字 编号 文字"整齐地打印出来。 EDOM和ERANGE是 C 标准保证存在的两个,其他在不同系统上不一定都有。
3.3 它是怎么实现的
先设置 errno,让 perror 与 strerror 并排输出做对比;再用"名字加编号"的数组,把常见错误的名字和文字连起来。这样你既能看到文字,也能知道它对应哪个宏。
3.4 示例输出里的常见错误
| 名字 | 编号(Linux) | 文字 |
|---|---|---|
EPERM | 1 | Operation not permitted |
ENOENT | 2 | No such file or directory |
EINTR | 4 | Interrupted system call |
EIO | 5 | Input/output error |
EACCES | 13 | Permission denied |
EEXIST | 17 | File exists |
ENOMEM | 12 | Cannot allocate memory |
EINVAL | 22 | Invalid argument |
EDOM | 33 | Numerical argument out of domain |
ERANGE | 34 | Numerical result out of range |
编号在别的系统上可能不同,比如 EDOM 在有的系统是 333333,有的是别的值。这也是为什么代码里要写宏名。
3.5 时序图
4. 补充:想要更完整的信息怎么办
- Linux:执行
man errno,能看到每个错误名的含义,比strerror的短句更详细。 - 有的 Linux 发行版:安装
moreutils后可以用errno -l一次列出所有名字、编号和文字。 - 多线程程序:
strerror返回的是共享缓冲区的指针,可能不是线程安全的,多线程中应改用strerror_r。 - 想让文字固定为英文:程序里不调用
setlocale,默认就是 C 语言环境,文字是英文。
5. 总结
perror与strerror共用一张"编号到文字"的表。- 列出全部信息的办法:循环调用
strerror,跳过未定义的编号。 - 具体有哪些错误、编号是多少、文字怎么写,由系统决定,标准只保证
EDOM、ERANGE、EILSEQ。
编译运行方式:
g++ -std=c++17 -Wall -Wextra demo.cpp -o demo
./demo
15.20 习题 10:为什么这些函数接收 FILE 指针,而不是 FILE 结构本身
题目:为什么 fprintf、fscanf、fputs、fclose 都是接收一个指向 FILE 的指针,而不是直接接收 FILE 结构?
1. 先给结论
核心原因只有一句话:
这些函数需要读取并且修改"那一个真正的 FILE",而 C 语言传参默认是复制一份。如果传结构本身,函数改的只是复制品,真正的流一点变化都没有。
传指针,函数拿到的是"这个 FILE 在内存里的地址",顺着地址就能直接改到原件。
具体理由一共有四条:
| 编号 | 理由 | 一句话解释 |
|---|---|---|
| 1 | 要修改原件 | 读写时位置、缓冲区、错误状态都要更新,改复制品没用 |
| 2 | 复制会产生两份"状态" | 两份拷贝各记各的,很快互相对不上,数据会乱 |
| 3 | 效率 | FILE 里装着缓冲区信息等一堆内容,每次调用都复制太浪费 |
| 4 | 一个流只有一个身份 | 指针能唯一标识"是哪个流",复制则变成了"另一个东西" |
下面一条条从零讲清楚。
2. 先弄懂:FILE 里装了什么
回忆一下:FILE 是一个数据结构,管着一个流的缓冲区和I/O 状态。可以把它想象成一张"档案卡":
FILE(一个流的档案卡)
|
+-- 缓冲区的位置
|
+-- 缓冲区里还剩多少数据
|
+-- 当前读写到文件的哪个位置
|
+-- 是否出过错
|
+-- 是否到达文件末尾
每读一个字符、每写一行,这张卡上的内容都要更新。
同样的内容,用 Mermaid 画一遍:
3. 理由 1:函数必须修改原件
C 语言里,普通参数是按值传递:调用函数时,把实参复制一份交给形参。函数里改的只是这份复制品,函数结束后,原来的变量不受影响。
假设 fgetc 的参数是 FILE 本身:
调用 fgetc(f)
main 里的 f(原件) fgetc 里的形参(复制品)
位置 = 0 --复制--> 位置 = 0
读一个字符
位置 = 1 <- 只改了复制品
位置 = 0 (没变) <-- 函数结束,复制品丢掉
结果:你读了一个字符,但原来的流以为什么都没发生。下次再读,还是读到同一个字符,永远读不到后面的内容。
如果传指针:
调用 fgetc(&f)
main 里的 f(原件) fgetc 里的指针 p
位置 = 0 <--指向-- p 保存的是 f 的地址
通过 p 读一个字符
位置 = 1 (原件被改了) <-- 通过 p 直接修改原件
这样每一次读写,状态都真正记在了原件上,流才能一路往前走。
4. 理由 2:复制会让状态"分叉"
哪怕只是复制,不修改,也有问题。假如你复制了一份 FILE 给另一个变量,现在有两份"档案卡":
- 它们共用同一个文件和同一块缓冲区;
- 但各自记着自己的位置和剩余数据量。
一份读了 101010 个字符,另一份还以为自己在位置 000。两份状态互相矛盾,缓冲区里的数据就会被重复处理或者漏掉,fclose时还可能把缓冲区写出两次、释放两次,造成数据错乱甚至崩溃。
所以标准库不允许也不期望你复制FILE。用指针,永远只有一份,不会分叉。
5. 理由 3:效率
FILE 里的信息不少,而 I/O 函数调用得非常频繁,比如循环里每读一个字符就调用一次 fgetc。如果每次都把整个结构复制一遍,白白浪费时间。
指针只有一个地址那么大(通常 888 字节),复制它几乎没有成本。
用公式表示:假设 FILE 大小为 SSS 字节,调用 nnn 次函数,按值传递要复制的总字节数是:
总复制量(传结构)=n×S
\text{总复制量(传结构)} = n \times S
总复制量(传结构)=n×S
而传指针只需要:
总复制量(传指针)=n×8
\text{总复制量(传指针)} = n \times 8
总复制量(传指针)=n×8
当 SSS 远大于 888 时,差距很大。
6. 理由 4:指针代表"那一个流"
指针是一个身份证:同一个 FILE* 值,永远代表同一个流。你把它保存在变量里、传给不同的函数,大家操作的都是同一个对象。
这也是为什么 fopen 返回的是 FILE*,而不是 FILE:FILE 是库在内部创建和管理的,使用者只拿到一个"句柄"(指针),不需要,也不应该知道里面的细节。
用 ASCII 图表示:
你的代码 库的内部
FILE* fp ----------> [ FILE 档案卡 ]---> 缓冲区
(只是一个地址) ---> 文件
7. 用代码对比:传结构 与 传指针
为了不去复制真正的 FILE(那样是不合法的),下面做一个简化版的"流",模仿 FILE 的行为,对比两种传法。
// 文件名:demo_file_pointer.cpp
// 演示:为什么读取函数必须接收指针,而不是结构本身
#include <cstdio> // std::printf
#include <cstring> // std::strlen
// 一个迷你版的"流",模仿 FILE:内部记着数据和当前位置
struct MiniStream {
const char* data; // 流里的全部数据
std::size_t pos; // 当前读到哪个位置(状态)
};
// 错误做法:按值接收结构。形参是复制品,改 pos 只改了复制品
int getByValue(MiniStream s) {
if (s.data[s.pos] == '\0') return -1; // 读完了,返回 -1 模拟 EOF
int ch = s.data[s.pos]; // 取当前字符
s.pos++; // 只改了复制品的 pos!
return ch;
}
// 正确做法:接收指针。通过指针直接修改原件的 pos
int getByPointer(MiniStream* s) {
if (s->data[s->pos] == '\0') return -1; // 读完了
int ch = s->data[s->pos]; // 取当前字符
s->pos++; // 修改的是原件
return ch;
}
int main() {
// ---------- 实验一:按值传递 ----------
MiniStream a = { "ABC", 0 }; // 数据是 ABC,位置从 0 开始
std::printf("按值传递,连续读三次:");
for (int i = 0; i < 3; ++i) {
std::printf("%c ", getByValue(a)); // 每次传的都是 a 的复制品
}
std::printf("\n最终位置 pos = %zu(原件根本没动)\n\n", a.pos);
// ---------- 实验二:传指针 ----------
MiniStream b = { "ABC", 0 };
std::printf("传指针,连续读三次:");
for (int i = 0; i < 3; ++i) {
std::printf("%c ", getByPointer(&b)); // 传的是 b 的地址,修改的是原件
}
std::printf("\n最终位置 pos = %zu(原件随着读取一路前进)\n\n", b.pos);
// ---------- 实验三:复制结构会让状态分叉 ----------
MiniStream original = { "XYZ", 0 };
MiniStream copy = original; // 复制出一份,现在有两份"档案卡"
getByPointer(&original); // 只有 original 往前读了一个
std::printf("复制后各读各的:original.pos = %zu,copy.pos = %zu\n",
original.pos, copy.pos);
std::printf("两份状态不一致,这就是复制 FILE 会出问题的原因\n\n");
// ---------- 顺便比较大小 ----------
std::printf("MiniStream 本身 %zu 字节,指针只有 %zu 字节\n",
sizeof(MiniStream), sizeof(MiniStream*));
return 0;
}
代码讲解
struct MiniStream:用两个成员模仿FILE,data是流里的内容,pos是读到哪儿的状态。getByValue(MiniStream s):参数是结构本身,调用时会复制。s.pos++加的是复制品里的pos,函数一结束复制品就消失了。所以主函数里连续调用三次,每次读到的都是同一个字符A。getByPointer(MiniStream* s):参数是指针。s->pos++通过地址直接改原件,三次调用依次读到A、B、C。- 实验三:把结构复制成
copy后,只有original前进了,copy还停在原地。两份状态不一致,说明复制"档案卡"一定会出问题。 - 最后一行对比大小,说明传指针的复制成本很小。
关键点
- 想让函数修改调用者手里的东西,C 语言只有一个办法:传指针。
FILE里的状态必须只有一份,所以必须通过FILE*访问。- 这也解释了为什么
fclose接收指针:关闭的是那一个具体的流,而不是某个复制品。
时序图
8. 一张表总结
| 对比项 | 传 FILE 结构本身 | 传 FILE 指针 |
|---|---|---|
| 函数改动的是 | 复制品 | 原件 |
| 读写后流的位置 | 不会前进 | 正常前进 |
| 内存复制量 | 整个结构 | 一个地址 |
| 状态份数 | 可能分叉成多份 | 始终只有一份 |
fclose 能否真正关闭流 | 不能确定 | 可以 |
9. 一句话记住
因为 FILE 记着一个流不断变化的状态,必须只有一份,并且让每个函数都能直接修改它;C 语言只有传指针才能做到这一点。
想写文件、又不丢旧内容、还要能写在任意位置,该用什么模式
一句话答案:用
"r+"(二进制文件用"r+b")。 它以"读写"方式打开已存在的文件,不会清空内容,也可以用fseek跳到任何位置去写。
编译方式:
g++ -std=c++17 -Wall -Wextra demo.cpp -o demo && ./demo
1. 先把两个条件拆开看
- 条件一:不能丢掉原来的内容。 说明打开时不能"清空"文件。
- 条件二:能在文件任意位置写。 说明写入位置要能自己控制,不能被强行固定在末尾。
fopen有 6 种常用模式,逐个按这两个条件检查,就能找出答案。
2. 逐个模式检查
先记住每个字母的含义:
r:read,读。文件必须已经存在。w:write,写。文件不存在就创建,存在就清空。a:append,追加。文件不存在就创建,写入永远发生在末尾。+:加上"另一种操作",也就是读写都可以。
| 模式 | 文件已存在时会清空吗 | 能在任意位置写吗 | 能读吗 | 文件不存在时 | 满足两个条件吗 |
|---|---|---|---|---|---|
r | 不清空 | 不能写 | 能 | 失败 | 否,不能写 |
r+ | 不清空 | 能 | 能 | 失败 | 是 |
w | 清空 | 能 | 不能 | 创建 | 否,丢内容 |
w+ | 清空 | 能 | 能 | 创建 | 否,丢内容 |
a | 不清空 | 不能,只能在末尾 | 不能 | 创建 | 否,不能任意位置 |
a+ | 不清空 | 不能,只能在末尾 | 能 | 创建 | 否,不能任意位置 |
只有 r+ 同时满足两个条件。
一个容易记的口诀:想改旧文件的中间某处,用 r+。
3. 为什么 a 和 a+ 不行
追加模式下,不管你怎么 fseek,写入的内容都会被放到文件末尾。 这是为了防止多个进程同时往日志里写时互相覆盖,所以故意这样设计的。它保护了旧内容(条件一),但违反了条件二。
4. r+ 的使用要点
- 文件必须已经存在。 不存在时
fopen返回NULL,一定要检查。 - 读和写互相切换时,中间必须插入
fseek或fflush。 否则缓冲区状态混乱,结果不可预测。如果只想"原地切换",可以用fseek(fp, 0, SEEK_CUR),位置不变,但完成了切换。 - 覆盖写入,不是插入。 写入的字符会替换掉原位置的字符,后面的内容不会往后挪。
- 只能覆盖,不能删除或插入。 如果写入的内容更短或更长,多余部分不会自动处理,需要自己重写文件。
覆盖写入示意
原文件: A B C D E F G H
下标: 0 1 2 3 4 5 6 7
fseek(fp, 2, SEEK_SET) 把位置移到下标 2
fputs("xy", fp) 从下标 2 开始覆盖 2 个字符
结果: A B x y E F G H
^ ^
被覆盖的位置,后面的 E F G H 不动
5. 完整演示一:r+ 的典型用法
完整代码
#include <cstdio>
using namespace std;
// 把文件内容打印出来,看不见的字符(例如 '\0')用 '.' 代替
static void show_file(const char* name) {
FILE* fp = fopen(name, "rb"); // 只读方式打开
if (fp == NULL) { printf("(打不开 %s)\n", name); return; }
int c;
printf("文件内容: ");
while ((c = getc(fp)) != EOF) {
putchar((c >= 32 && c < 127) ? c : '.');
}
printf("\n");
fclose(fp);
}
int main() {
const char* name = "demo11.txt";
// 先准备一个有内容的文件:ABCDEFGH
FILE* w = fopen(name, "w");
if (w == NULL) return 1;
fputs("ABCDEFGH", w);
fclose(w);
show_file(name); // ABCDEFGH
// 用 "r+" 打开:可读可写,不清空,文件必须已存在
FILE* fp = fopen(name, "r+");
if (fp == NULL) { // 文件不存在时 "r+" 会失败,必须检查
perror("fopen r+ 失败");
return 1;
}
// 1. 跳到下标 2(也就是字符 C 的位置),覆盖写入 xy
fseek(fp, 2, SEEK_SET);
fputs("xy", fp); // 把 C D 改成 x y
// 2. 从写切换到读之前,必须先 fseek 或 fflush
fseek(fp, 0, SEEK_CUR); // 位置不变,但完成了"写转读"的切换
int c = getc(fp); // 接着读下一个字符,应该是 E
printf("紧接着读到: %c\n", c);
// 3. 再跳到文件开头,改第一个字符
fseek(fp, 0, SEEK_SET);
fputc('#', fp); // A 变成 #
fclose(fp); // 关闭时数据才真正写到磁盘
show_file(name); // #BxyEFGH
// 对比:对不存在的文件使用 "r+" 会失败
FILE* none = fopen("no_such_file_11.txt", "r+");
printf("对不存在的文件用 r+: %s\n", none == NULL ? "失败(返回NULL)" : "成功");
if (none != NULL) fclose(none);
remove(name);
return 0;
}
预期输出:
文件内容: ABCDEFGH
紧接着读到: E
文件内容: #BxyEFGH
对不存在的文件用 r+: 失败(返回NULL)
代码是怎么实现的
show_file是个辅助函数:以只读方式把整个文件读出来打印。字符范围不在 32 到 126 之间(不可见字符)就用.代替,这样后面能看出异常的字节。- 先用
"w"创建文件并写入ABCDEFGH,这是准备工作。 - 用
"r+"打开,立刻检查是否为NULL。 fseek(fp, 2, SEEK_SET)跳到下标 2,fputs("xy", fp)覆盖写入,C D变成x y。- 写完之后想接着读,先用
fseek(fp, 0, SEEK_CUR)完成"写转读"切换,再getc读到E。 - 再
fseek(fp, 0, SEEK_SET)回到开头,fputc('#', fp)把A改成#。 fclose后重新读出文件,得到#BxyEFGH,原内容除了被覆盖的位置都还在。- 最后演示对不存在的文件用
"r+"会失败。
时序图
6. 完整演示二:五种模式对比
同一个文件,同样的操作(跳到下标 2 写 xy),换五种模式,看结果有什么不同。
完整代码
#include <cstdio>
using namespace std;
// 把文件重置成 ABCDEFGH
static void reset(const char* name) {
FILE* w = fopen(name, "w");
if (w == NULL) return;
fputs("ABCDEFGH", w);
fclose(w);
}
// 用指定模式打开,跳到下标 2,写入 xy,再把结果打印出来
static void try_mode(const char* mode) {
const char* name = "demo11b.txt";
reset(name); // 每次都从同样的内容开始
FILE* fp = fopen(name, mode); // 用要测试的模式打开
if (fp == NULL) { printf("%-4s 打开失败\n", mode); return; }
fseek(fp, 2, SEEK_SET); // 想在下标 2 处写
fputs("xy", fp);
fclose(fp);
// 读回来看结果('.' 表示不可见字符)
fp = fopen(name, "rb");
if (fp == NULL) return;
printf("%-4s -> ", mode);
int c;
while ((c = getc(fp)) != EOF) putchar((c >= 32 && c < 127) ? c : '.');
printf("\n");
fclose(fp);
remove(name);
}
int main() {
printf("原内容: ABCDEFGH,在下标 2 处写 xy\n");
try_mode("r+"); // 想要的效果
try_mode("w"); // 清空
try_mode("w+"); // 清空
try_mode("a"); // 只能追加到末尾
try_mode("a+"); // 只能追加到末尾
return 0;
}
预期输出:
原内容: ABCDEFGH,在下标 2 处写 xy
r+ -> ABxyEFGH
w -> ..xy
w+ -> ..xy
a -> ABCDEFGHxy
a+ -> ABCDEFGHxy
代码是怎么实现的
reset每次把文件恢复成ABCDEFGH,保证五次测试起点相同。try_mode用传入的模式打开文件,fseek到下标 2,写入xy,关闭,再读回来打印。r+:得到ABxyEFGH,旧内容保留,只在下标 2 处被覆盖。这就是我们要的效果。w和w+:打开时文件被清空,文件变空了,再fseek到下标 2 写入,前面两个字节变成了不可见的\0(显示为.),旧内容全丢。a和a+:内容保留,但xy被加到了末尾,fseek完全不起作用。
时序图
7. 补充:文本模式与二进制模式
- 在 Windows 上,文本模式会把换行符转换(
\n与\r\n互转),这会让fseek的位置和实际字节对不上。 - 如果要精确按字节位置读写,建议用二进制模式:
"r+b"。 - Linux 和 macOS 上文本模式与二进制模式没有区别,但写上
b让代码更可移植。
8. 小结
| 需求 | 选择 |
|---|---|
| 读写已存在的文件,保留内容,任意位置写 | "r+" 或 "r+b" |
| 全部重写,不在乎旧内容 | "w" |
| 只往末尾加(比如日志) | "a" |
- 不丢旧内容加上任意位置写入,答案只有
"r+"。 - 它要求文件已经存在,打开后必须检查是否为
NULL。 - 读写切换之间要有
fseek或fflush。 - 写入是覆盖,不是插入;位置用
fseek控制。
为什么需要 freopen 函数
(题目里的 frepoen 是 freopen 的拼写笔误,这里按 freopen 来讲。)
0. 答案
freopen 的作用是:让一个已经存在的流,改为连接到另一个文件。 它之所以必要,主要有两个原因:
stdin、stdout、stderr这三个标准流是系统预先准备好的,你不能简单地写stdout = fopen(...)来换掉它们。要改它们的去向,就需要freopen。- 很多现成代码(
printf、scanf、cin、cout)默认用的就是标准流。有了freopen,一行代码都不用改,就能让它们改读文件、改写文件。
1. 从零理解
1.1 先搞清楚"流"和"文件"
FILE * 是一个"流",可以想成一根水管,一头接你的程序,另一头接某个目标(键盘、屏幕、文件)。
程序一启动,系统就接好了三根管子:
| 流 | 默认连接 | 常见用法 |
|---|---|---|
stdin | 键盘 | scanf、getchar、cin |
stdout | 屏幕 | printf、puts、cout |
stderr | 屏幕 | 错误信息 |
printf("...") 其实就是 fprintf(stdout, "...") 的简写,scanf 同理是从 stdin 读。
1.2 问题来了
假如我想让 printf 把内容写进文件,而不是屏幕,怎么办?
- 办法一:把所有
printf都改成fprintf(fp, ...)。代码量大时很麻烦,第三方代码根本没法改。 - 办法二:直接给
stdout赋值,比如stdout = fopen("a.txt", "w");。但stdout在有的实现里是一个宏,不一定是可赋值的变量,所以这样写不可移植。 - 办法三:用
freopen。标准保证它能用,这就是它存在的原因。
1.3 freopen 做了什么
函数形式:
FILE *freopen(const char *文件名, const char *模式, FILE *流);
它按顺序做三件事:
- 关闭这个流原来连接的目标(必要时先把缓冲区写出去)。
- 打开新的文件。
- 把同一个流接到新文件上,返回这个流的指针。失败则返回
NULL。
关键在于:流的"身份"(stdout这个名字)没变,变的只是它背后连着的目标。所以所有用stdout的代码都自动跟着改变了。
用 Mermaid 树整理它的用途:
用 ASCII 再画一遍:
freopen 的用途
|
+-- 改标准流的去向
| |
| +-- stdin 改读文件
| |
| +-- stdout 改写文件
| |
| +-- stderr 改写日志
|
+-- 复用同一个 FILE 指针
|
+-- 换一个文件继续用
|
+-- 换一种打开模式
1.4 fopen 和 freopen 对比
| 对比项 | fopen | freopen |
|---|---|---|
| 做什么 | 新建一个流 | 让已有的流换目标 |
| 返回值 | 新的 FILE * | 传入的那个 FILE * |
能用于 stdin/stdout/stderr | 不能直接换掉它们 | 可以 |
| 失败时 | 返回 NULL,没有副作用 | 返回 NULL,而且原来的流已经被关闭 |
2. 示例一:重定向 stdout 和 stderr
2.1 完整代码
// 示例1:用 freopen 把 stdout 和 stderr 重定向到文件
#include <cstdio> // freopen, printf, fprintf, fopen, fgets, fclose
int main() {
// 重定向前:printf 输出到屏幕
std::printf("这一行在屏幕上\n");
std::fflush(stdout); // 先把已有内容刷出,避免和重定向后的内容混在一起
// freopen(文件名, 模式, 要改造的流)
// 作用:关闭 stdout 原来连接的目标,再把 stdout 接到 out.txt 上
// 成功返回传入的那个流指针,失败返回 NULL
if (std::freopen("out.txt", "w", stdout) == NULL) {
std::perror("freopen stdout"); // 注意:失败时原来的 stdout 已经被关闭了
return 1;
}
if (std::freopen("err.txt", "w", stderr) == NULL) {
return 1; // stderr 已不可用,只能直接退出
}
// 下面的代码一个字都没改,但输出都进了文件
std::printf("这一行进了 out.txt\n");
std::printf("printf 不知道也不关心目标变了\n");
std::fprintf(stderr, "这一行进了 err.txt\n");
// 关掉两个流,保证内容写入文件
std::fclose(stdout);
std::fclose(stderr);
// 用普通 fopen 把文件读回来检查(此时 stdout/stderr 已关,不能再用 printf)
// 为了能看到结果,把结果写到一个新文件 report.txt 里
std::FILE *rep = std::fopen("report.txt", "w");
if (rep == NULL) return 1;
const char *names[] = {"out.txt", "err.txt"};
for (const char *name : names) {
std::FILE *fp = std::fopen(name, "r");
if (fp == NULL) continue;
char line[100];
std::fprintf(rep, "== %s ==\n", name);
while (std::fgets(line, sizeof line, fp) != NULL) {
std::fputs(line, rep);
}
std::fclose(fp);
}
std::fclose(rep);
return 0;
}
2.2 关键点解析
- 重定向之前,
printf输出到屏幕,所以你能看到第一行。 std::fflush(stdout):先把缓冲区里的内容刷出去,避免它们被混进文件里。std::freopen("out.txt", "w", stdout):把stdout接到out.txt上。之后所有printf都写进这个文件,代码一点没改。- 同样把
stderr接到err.txt,错误信息就分开存放。 fclose(stdout):关闭,确保内容真正写到磁盘。- 因为
stdout已经关闭,程序没法再用printf在屏幕上说话,所以把读回来的结果写到report.txt。运行结束后,report.txt里是两个文件的内容,说明重定向成功。 - 注意失败处理:
freopen失败时,原来的stdout已经被关掉了,所以示例里失败时只能打印到stderr或直接退出。
2.3 它是怎么实现的
freopen 让标准库内部把这个 FILE 对象里的"目标文件"换成新的。printf 每次写入时,只认 stdout 这个对象,不关心里面具体连着什么,所以重定向对它是透明的。
2.4 时序图
3. 示例二:重定向 stdin
这个用法在做算法题、写测试程序时特别常见:平时手敲输入很麻烦,把输入放进文件,程序里加一行 freopen 即可。
3.1 完整代码
// 示例2:用 freopen 把 stdin 重定向到文件,让原本读键盘的代码改读文件
#include <cstdio> // freopen, scanf, fopen, fprintf, fclose
#include <iostream> // std::cin, std::cout
int main() {
// 先准备一个输入文件,内容是 5 个整数
std::FILE *w = std::fopen("in.txt", "w");
if (w == NULL) { std::perror("fopen in.txt"); return 1; }
std::fprintf(w, "3 5 7 9 11\n");
std::fclose(w);
// 把 stdin 改接到 in.txt:此后 scanf 和 cin 都从这个文件读
if (std::freopen("in.txt", "r", stdin) == NULL) {
std::perror("freopen stdin");
return 1;
}
// 下面是"本来读键盘"的写法,完全不用改
int first = 0;
std::scanf("%d", &first); // C 风格读第一个数
std::cout << "scanf 读到: " << first << '\n';
// C++ 的 cin 默认和 C 的 stdin 同步,所以也会读到文件里剩下的数
int sum = 0, x = 0;
while (std::cin >> x) { // 读到文件末尾时条件为假,循环结束
sum += x;
}
std::cout << "cin 读到的其余数之和: " << sum << '\n'; // 5+7+9+11 = 32
return 0;
}
3.2 关键点解析
- 先用
fopen写出in.txt,内容是3 5 7 9 11。 freopen("in.txt", "r", stdin):把stdin接到这个文件。“r” 表示读模式,因为是输入。scanf("%d", &first)读到 333,和平时读键盘的写法完全一样。std::cin默认和 C 的stdin保持同步,所以cin也会从文件读,读到剩下的 5,7,9,115, 7, 9, 115,7,9,11。while (std::cin >> x):读到文件末尾时cin变为失败状态,条件为假,循环结束。- 求和结果是 5+7+9+11=325 + 7 + 9 + 11 = 325+7+9+11=32,和运行输出一致。
3.3 它是怎么实现的
重定向之后,stdin 背后是一个文件。scanf 和 cin 的读取最终都落到 stdin 上,所以它们读到的就是文件内容。cin 能工作,靠的是它默认没有关闭和 C 流的同步(sync_with_stdio 的默认状态)。
3.4 时序图
4. 使用 freopen 的注意事项
- 一定要检查返回值。失败返回
NULL,而且原来的流已经关闭,后续再用它是未定义行为。 - 想恢复原来的屏幕输出很不方便。标准 C 没有给出统一的恢复办法。常见做法:在 Linux 上重新
freopen("/dev/tty", "w", stdout),在 Windows 上用"CON",或者在类 Unix 系统上提前用dup/dup2保存原来的描述符。这些都和平台有关。 - 想要灵活、可恢复的重定向,在 C++ 里更推荐用流缓冲区替换
rdbuf,或者直接让命令行完成重定向(例如./程序 < in.txt > out.txt)。 - 用
freopen只改模式:有的实现支持把文件名写成NULL来改变已打开流的模式,但这属于实现相关的行为,不要依赖。
5. 总结
freopen= “让已有的流换一个目标”。- 必要的原因:标准流不能可靠地用赋值来替换;而用
freopen,所有使用标准流的现成代码无需修改就被重定向。 - 用完记得检查返回值,必要时
fclose。
编译运行方式:
g++ -std=c++17 -Wall -Wextra demo.cpp -o demo
./demo
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)