题目:如果不检查 fopen 的返回值是否出错,会发生什么?

1. 先给结论

fopen 打开文件可能失败。失败时它不会报错停下,而是悄悄返回一个空指针(NULL,C++ 里是 nullptr)。
如果你不检查,直接把这个空指针交给 fgetc、fgets、fprintf、fread、fclose 等函数,就等于让它们去操作"一个不存在的 FILE"。这属于未定义行为,常见后果有:

情况后果
最常见程序当场崩溃(段错误、访问违例)
有时程序不崩溃,但读到的是垃圾数据,或者什么都没读到
写文件时以为写成功了,其实数据根本没保存,用户的数据悄悄丢失
最坏崩溃发生在离出错点很远的地方,很难排查

一句话:不检查,就是把"文件打不开"这个很容易处理的小问题,变成一个难以追查的大问题。

2. 为什么 fopen 会失败

从零理解:打开文件要靠操作系统帮忙,而操作系统可能拒绝你,原因很多:

fopen 失败的常见原因
|
+-- 文件不存在         (用 "r" 模式打开一个没有的文件)
|
+-- 没有权限           (文件只读,或者你无权访问该目录)
|
+-- 路径写错           (目录不存在、文件名拼错)
|
+-- 打开的文件太多     (超过 FOPEN_MAX 或系统上限)
|
+-- 磁盘或系统问题     (磁盘满、设备不可用)

同样的树,用 Mermaid 画一遍:

fopen 失败的原因

文件不存在

没有权限

路径写错

打开文件太多

磁盘或系统问题

这些情况在实际运行中一定会遇到,所以"文件打不开"不是例外,而是正常情况之一。

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;
}

代码讲解

  1. fopen("no_such_file_12345.txt", "r"):以只读方式打开一个不存在的文件,必然失败,返回 nullptr。
  2. 第一个 printf 想把 fp 的值打印出来。实测时程序在 fgetc 处崩溃(退出码 139,表示段错误),而这行输出还留在 stdout 的缓冲区里,没来得及写出,所以屏幕上什么都看不到。这正好说明缓冲的副作用:崩溃时没写出的数据会丢失,让排错更难。
  3. fgetc(fp):把空指针交给读函数。它要去访问 fp 指向的内存,而那里不属于程序,于是崩溃。
  4. 后面的代码根本执行不到,这就是"出错点"和"崩溃点"可能分开的原因:fopen 出了错,但程序在 fgetc 才倒下。
    关键点:崩溃只是可能的结果之一。标准规定这是未定义行为,所以换一个编译器或系统,现象可能完全不同。千万不要因为"某次没崩溃"就认为没问题。
    时序图
操作系统fgetcfopenmain操作系统fgetcfopenmainfopen 打开不存在的文件返回 nullptr 但没人检查fgetc 传入 nullptr访问地址 0 附近的内存拒绝访问 触发段错误程序被强制终止

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;
}

代码讲解

  1. 和错误写法相比,唯一的区别就是多了 if (fp == nullptr) 这一段。
  2. perror(name) 会告诉用户为什么失败,比如 No such file or directory,用户一看就知道是文件名写错了。
  3. exit(EXIT_FAILURE) 让程序有秩序地结束,而不是被系统强行杀掉。
  4. 检查通过后,后面的 fgetc、fclose 才可以放心使用。
    关键点:检查的代价只有一行 if,不检查的代价可能是崩溃、数据丢失和很长的排错时间。
    时序图
exitperrorfopenmainexitperrorfopenmainfopen 打开文件返回 nullptr判断 fp 等于 nullptr 成立perror 传入文件名输出具体的错误原因exit EXIT_FAILURE程序有秩序地结束

6. 对比总结


对比项不检查检查
文件打不开时空指针继续往下传立刻发现并处理
程序表现崩溃或行为不可预测给出清楚的错误信息
用户体验不知道哪里错了知道是文件问题
排错难度很大很小
数据安全可能悄悄丢数据不会误以为写成功

7. 一句话记住

每次 fopen(以及 freopen)之后,立刻检查返回值是否为空指针,这是使用文件的第一条习惯。

对从未打开的流做 I/O 会怎样

0. 一句话结论

结果取决于你用的是哪种"流":

  • C 风格的 FILE *:对一个从没成功打开的流(通常是 NULL 或没初始化的指针)做读写,是未定义行为。最常见的结果是程序直接崩溃(段错误),但标准不保证,也可能"碰巧没事"、也可能悄悄写坏内存。
  • C++ 的 ifstream / ofstream:对象存在但没有 open,读写不会崩溃,只是操作失败,流的出错状态位会被置上。
    两种情况的共同教训:打开之后先检查是否成功,再做 I/O。

1. 从零理解:什么叫"流没打开"

"流"可以想成一根管子,一头连着你的程序,另一头连着文件(或键盘、屏幕)。fopen 或 open 的作用就是把管子接到文件上。
没接上管子就往里灌水,会发生什么?取决于管子长什么样:

  • C 的 FILE * 只是一个指针。没打开成功,指针要么是 NULL,要么是随机垃圾值。你拿它去读写,等于去访问一块不存在或不属于你的内存。
  • C++ 的流是一个完整的对象,自己带着状态位。没打开时它清楚地知道"我没连着文件",所以会礼貌地拒绝操作。
    先看整体结构,用 Mermaid 树表示:

对未打开的流做 I/O

C 风格 FILE 指针

C++ 流对象

指针为 NULL
或未初始化

未定义行为
常见结果是崩溃

对象存在
但没有 open

操作失败
状态位被置上
程序不崩溃

同样的结构用 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++ 名称用途
标准输入stdinstd::cin读键盘
标准输出stdoutstd::cout写屏幕
标准错误stderrstd::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 时序图

ifstream 对象ofstream 对象mainifstream 对象ofstream 对象main创建对象不调用 openis_open返回 false写入 hello发现没有底层文件置 failbit 和 badbit返回 不写出任何数据读取到字符串 word发现没有数据可读置 failbit 和 eofbitword 保持原值检查流状态流不可用程序停止写入

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 时序图

标准库safe_writemain标准库safe_writemainfopen 一个不存在的目录下的文件返回 NULL传入 NULL 流判断为 NULL输出错误提示返回 false没有做 I/O传入 stdoutfprintf 写入成功返回 truefopen demo_q2.txt w返回有效指针传入有效指针fprintf 写入返回 truefclose

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 时序图

子进程操作系统父进程子进程操作系统父进程fork返回子进程编号子进程开始fork 返回 0fp 设为 NULLfprintf 写入 NULL 流访问非法地址发送 SIGSEGV终止子进程waitpid 等待返回状态被信号终止打印 子进程被信号 11 终止

6. 总结

  • 对从未打开的 C 流做 I/O:未定义行为,常见是崩溃。
  • 对从未打开的 C++ 流做 I/O:不崩溃,但操作失败,状态位被置上。
  • 预防办法只有一条:打开后立刻检查成功与否。

场景检查方式
C 的 fopenif (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 实际做了三件事:

  1. 把缓冲区里还没写出去的数据真正写到磁盘(这一步最容易失败)。
  2. 释放缓冲区和 FILE 对象。
  3. 关闭底层的文件描述符。
    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;
}

代码是怎么实现的

  1. 打开 /dev/full,这个设备永远提示"空间不足"。
  2. fputs 只是把字符串放进缓冲区,所以返回成功。这就是危险所在:写入"成功"并不等于数据已存盘。
  3. fclose 才会把缓冲区真正写出去,这时碰到"磁盘满",返回 EOF。
  4. strerror(errno) 把错误码转成可读的文字(例如"No space left on device")。
  5. 注释掉的那行是典型的错误写法:不看返回值,程序就以为一切正常。

时序图

设备 dev/full缓冲区main设备 dev/full缓冲区mainfputs 写入数据返回成功 数据只在内存fclose刷新缓冲区错误 磁盘已满返回 EOF检查返回值 发现保存失败

5. 正确做法:每个环节都检查

想确保数据真的落盘,要检查三个位置:

  1. 每次写入(fputs、fprintf、fwrite)的返回值。
  2. fflush 或 ferror 的结果(可以提前发现错误)。
  3. 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;
}

代码是怎么实现的

  1. write_text 用一个布尔变量 ok 累计各步是否成功,任何一步失败都会让它变成 false,但仍然会继续执行 fclose,保证资源被释放。
  2. fflush 提前刷新,让写盘错误在 fclose 之前就暴露。ferror 查看流上是否已经记录了错误。
  3. errno 可能被后续调用改写,所以在 fclose 之前先保存一份。
  4. save_atomic 先把内容写进 路径.tmp。只有临时文件完整写好,才用 rename 一步换成正式文件。因为 rename 在同一个文件系统内是原子的,正式文件要么是旧的完整版本,要么是新的完整版本,不会出现写了一半的情况。
  5. main 里一个成功、一个失败,让你对比两种结果。

流程示意

write_text 内部:
  fopen --失败--> 报错返回
    |
    v
  fputs --失败--> ok = false
    |
    v
  fflush --失败--> ok = false
    |
    v
  ferror --有错--> ok = false
    |
    v
  fclose --失败--> ok = false
    |
    v
  ok 为 true 才算保存成功

时序图

文件系统write_textsave_atomicmain文件系统write_textsave_atomicmain保存 demo_fclose.txt写入临时文件fopen fputs fflushfclose全部成功返回 truerename 临时文件为正式文件成功返回 true写入 dev/fullfclose失败 磁盘已满返回 false 并打印原因

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

程序

data.txt

stdin

程序

其他程序输出

stdin

程序

无论哪一种,程序看到的都是同一个 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;
}

代码讲解

  1. isatty(STDIN_FILENO):直接问系统,标准输入是不是终端。程序没有读取任何数据,只是"问"。
  2. struct stat info 加 fstat(...):让系统把标准输入对应的文件信息填进 info。info.st_mode 里编码了文件类型,用 S_ISREG、S_ISFIFO、S_ISCHR 这几个宏来判断。
  3. std::ftell(stdin):试着获取当前位置。失败返回 −1-1−1,所以用 pos != -1L 判断是否成功。
  4. 三种办法都没有消耗输入数据,只是查询,所以检测完之后,程序依然可以正常读取输入。
    关键点
  • 标准 C 自己做不到"明确区分",isatty 和 fstat 都是系统提供的额外函数,换平台要换对应的函数。
  • isatty 分不出文件和管道,想分清要用 fstat。
  • 检测不是为了好玩,常见用途是:交互时(终端)显示提示符和彩色输出,从文件读入时(重定向)就不显示提示,让输出更干净。
    时序图
操作系统程序 mainshell用户命令行操作系统程序 mainshell用户命令行输入 a.out 重定向自 data.txt把 stdin 改接到 data.txt启动程序isatty 查询 0 号描述符返回 0 不是终端fstat 查询 0 号描述符返回类型 普通文件ftell 查询 stdin 位置返回 0 可以定位判断为被重定向到文件

5. 实际运行效果

在 Linux 下编译运行:

g++ -std=c++17 demo_detect_stdin.cpp -o detect

三种运行方式,对应的输出判断如下:

运行方式isattyfstatftell
./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 的值最多读几个字符缓冲区里最终的样子
11−1=01-1=01−1=0 个只有 '\0'
22−1=12-1=12−1=1 个1 个字符加 '\0'
88−1=78-1=78−1=7 个最多 7 个字符加 '\0'

2. 大小为 1:一个字符也读不了

缓冲区一共只有 1 个字节,而结尾的 '\0' 必须占掉它,没有空间留给任何有用的字符了。
所以会发生这些事:

  1. buf[0] 被设成 '\0',得到一个空字符串。
  2. 流里的数据一个都没被取走,文件位置不动。
  3. 返回值是 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

代码是怎么实现的

  1. 用 tmpfile 造一个内容为 AB 加换行的临时文件,rewind 回到开头。
  2. 缓冲区只有 1 字节,先放 'X',这样如果 fgets 改动了它,就能看出来。
  3. 连续调用 3 次 fgets(buf, 1, fp)。每次都返回非 NULL,buf[0] 变成 0(即 '\0'),ftell 一直是 0,说明没读走任何数据,而且位置没变。
  4. 如果写成 while (fgets(...) != NULL),这里就会死循环,所以演示里特意用了有限次数的 for。
  5. 最后用大小为 2 的缓冲区读一次,拿到第一个字符 A,作为对比。

时序图

文件流fgetsmain文件流fgetsmain文件位置不动 仍是0若用while判断NULL 将永远循环调用 n=1最多读0个字符buf放入结尾符 返回非NULL再次调用 n=1结果相同 返回非NULL

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 次

代码是怎么实现的

  1. 文件内容 Hi、换行、Yo,一共 5 个字符。
  2. 缓冲区大小为 2,所以 fgets 每次最多取 1 个字符。
  3. while (fgets(...) != NULL) 在 n=2n=2n=2 时是安全的:每次都会前进至少一个字符,到文件末尾时返回 NULL,循环结束。
  4. 因为每次只拿到 1 个字符,所以 buf[0] 要么是普通字符,要么是 '\n',代码里分别处理。
  5. 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,循环结束

时序图

文件流fgetsmain文件流fgetsmain之后依次读 Y 和 o调用 n=2取1个字符 H返回 H再次调用取1个字符 i返回 i再次调用取换行符返回换行第6次调用已到末尾 返回NULL

4. 两种情况对比


项目n = 1n = 2
一次最多读几个字符0 个1 个
缓冲区内容只有 '\0'1 个字符加 '\0'
文件位置不前进每次前进 1 个字符
返回值非 NULL(常见实现)读到数据返回非 NULL,到末尾返回 NULL
配合 while 循环死循环正常结束
实用价值没有能用,但效率很低

5. 补充:n 小于等于 0 呢

当 n≤0n \le 0n≤0 时,标准把它当作未定义的错误用法:没有任何空间可以放结尾符,行为不可预测(有的实现返回 NULL,有的直接出错)。永远不要这样用。

6. 实际写代码怎么避免

  1. 缓冲区长度用 sizeof buf 传入,不要手写数字,免得写错。
  2. 缓冲区至少开到能装下一整行的合理大小,常见做法是 256 或 1024 字节。
  3. 如果行可能很长,就循环读取,直到缓冲区末尾出现换行符或遇到文件末尾。
  4. 如果缓冲区大小来自变量(例如用户配置),先检查它是否至少为 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 则把结果"打印"到一个字符数组里。
关键事实有两个:

  1. sprintf 不会检查数组够不够大。你给多小的数组,它照写不误,写多了就写到数组外面,这就是缓冲区溢出,是危险的未定义行为。
  2. 字符串末尾还要放一个结尾符 \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 汇总


部分最长字符数说明
%d6-32768
空格1第一个空格
%c1单个字符
空格1第二个空格
%x4ffff
可见字符小计13前面五项相加
结尾符1\0
缓冲区大小1413 + 1

总公式:
6+1+1+1+4+1=14 6 + 1 + 1 + 1 + 4 + 1 = 14 6+1+1+1+4+1=14
用一棵 Mermaid 树表示:

缓冲区大小 14

可见字符 13

结尾符 1

%d 最多 6
-32768

空格 1

%c 固定 1

空格 1

%x 最多 4
ffff

再用 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 时序图

buffer 14字节sprintfmainbuffer 14字节sprintfmain准备最坏值a 为 -32768b 为 xc 为 0xFFFF调用 sprintf格式 %d %c %x写入 -32768 共 6 字符写入空格 x 空格写入 ffff 共 4 字符写入结尾符返回 1313 加 1 等于 14刚好等于缓冲区大小

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 字节 int4 字节 int
%d 最长-32768(6 个)-2147483648(11 个)
%c1 个1 个
%x 最长ffff(4 个)ffffffff(8 个)
两个空格2 个2 个
可见字符合计1322
缓冲区大小1423

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 时序图

堆内存snprintfmain堆内存snprintfmain缓冲区 NULL 大小 0只想知道长度返回 22计算 size 等于 22 加 1malloc 23 字节返回有效指针缓冲区 buf 大小 23写入 22 字符加结尾符返回 22打印结果free 释放

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 至少需要说明
""(空串)01只放一个结束符
"A"121 个字符加结束符
"hello"565 个字符加结束符
"你好"(UTF-8)67每个汉字占 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;
}

代码讲解

  1. std::strlen(a) + 1:这就是题目的答案,5+1=65 + 1 = 65+1=6。strlen 不计结束符,所以要手动加 111。
  2. std::vector<char> buffer(need):开一块刚好 666 字节的空间,用 buffer.data() 取得指向它的指针,交给 sprintf。
  3. small[4]:故意太小的数组。snprintf 只会往里写 333 个字符 hel 加一个 '\0',所以不会溢出,只是内容被截断。
  4. 返回值 would:值是 555,表示"完整结果有 5 个字符"。判断 would >= sizeof small 就能知道有没有被截断。
  5. snprintf(nullptr, 0, ...):一个常用技巧,不写内容,只返回需要的长度,然后再按这个长度精确分配。
    关键点
  • 答案记一个式子:strlen(a)+1\text{strlen}(a) + 1strlen(a)+1,别忘了结束符。
  • sprintf 不检查边界,长度全靠你自己保证;能用 snprintf 就用 snprintf。
  • strlen 数的是字节,不是字符个数。
    时序图
snprintfsprintfvector 缓冲区strlenmainsnprintfsprintfvector 缓冲区strlenmainstrlen 计算 hello 的长度返回 5分配 5 加 1 共 6 字节sprintf 写入 hello写入 5 个字符和结束符返回 5snprintf 写入只有 4 字节的 small只存 3 个字符和结束符 返回 5返回值大于等于容量 判断为被截断snprintf 传空指针和 0 只算长度返回 5按 5 加 1 分配 exactsnprintf 写入 exact写入 hello 和结束符

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 是奇数,进位成偶数 80.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

代码是怎么实现的

  1. 数组 v 放了 7 个测试值,n 用 sizeof 算出元素个数。
  2. 每行打印三样东西:原始值、%.2f 的结果、以及用 %.20f 打印出来的精确存储值。多打印到 20 位,就能看到数在内存里真正的样子。
  3. 看 1.005 和 2.675:精确值都略小于一半,所以不进位,得到 1.00 和 2.67。
  4. 看 0.125 和 0.375:精确值恰好是一半,按取偶数处理,分别得到 0.12 和 0.38。
  5. 最后一行用 %.0f 展示整数位的舍入:0.5 变 0,1.5 变 2,2.5 变 2,3.5 变 4,正好验证"恰好一半取偶数"。
  6. 格式串里要输出百分号,必须写成 %%。

时序图

二进制双精度数printfmain二进制双精度数printfmain打印 2.675 保留2位取出真实存储值2.67499999999999982236第3位后不足一半 舍掉输出 2.67打印 0.125 保留2位取出真实存储值恰好 0.125恰好一半 取偶数输出 0.12

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

代码是怎么实现的

  1. truncate2 先乘 100,用 trunc 去掉小数部分,再除 100,这就是"只舍不入"。
  2. 同一批数,一列用 %.2f(舍入),一列用 truncate2(截断),并排打印。
  3. 3.14999:printf 给出 3.15,截断给出 3.14,两者不同,说明 %f 不是截断。
  4. 9.999:printf 给出 10.00,进位一直传到整数部分;截断给出 9.99。
  5. 2.71828:printf 给出 2.72,截断给出 2.71。
  6. 3.14159 的第三位是 1,不需要进位,所以两种方法结果恰好相同。
  7. 需要 <cmath> 才能用 trunc。

时序图

truncate2 截断printf 舍入maintruncate2 截断printf 舍入main两者结果不同 说明 printf 是舍入3.14999 保留2位3.153.14999 保留2位乘100 得314.999trunc 得314除100 得3.143.14

进位过程示意(以 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 树画出关系:

errno 错误编号

系统内部的
编号到文字对照表

strerror
返回文字给你

perror
直接打印到 stderr

遍历编号
得到全部信息

用 ASCII 再画一遍:

errno 错误编号
|
+-- 系统内部的"编号到文字"对照表
    |
    +-- strerror  : 返回文字给你
    |   |
    |   +-- 遍历编号,得到全部信息
    |
    +-- perror    : 直接打印到 stderr

所以要列出 perror 能打印的全部信息,只要把对照表里的文字都读出来,而 strerror 正是读取这张表的接口。

1.2 几种办法对比


办法做法优点缺点
遍历 strerror循环 000 到某个上限,逐个调用标准函数,最通用要自己判断哪些编号没有定义
看头文件阅读 errno.h 及其包含的文件能看到错误名字各系统位置不同,不能自动列出文字
查手册在 Linux 上执行 man errno有名字和解释内容因系统而异
sys_errlist 数组直接读系统的字符串数组简单已经过时,很多系统不再提供

最推荐第一种。

1.3 要注意的三件事

  1. 标准只保证少数几个:C 标准只规定了 EDOM、ERANGE、EILSEQ。其余的错误名和编号、文字,都由具体系统决定,所以不同系统打印出的列表不同。
  2. 编号可能不连续:有的编号没定义。glibc 对没定义的编号返回形如 Unknown error 数字 的文字,我们据此跳过。
  3. 文字可能随语言环境变化:设置了本地化后,同一个编号可能打印出中文或其他语言。

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 时序图

系统对照表strerrormain系统对照表strerrormainalt[文字以Unknownerror 开头][正常文字]loop[编号 0 到 199]传入编号查表返回文字返回文字指针跳过打印编号和文字计数加 1打印总条数

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)文字
EPERM1Operation not permitted
ENOENT2No such file or directory
EINTR4Interrupted system call
EIO5Input/output error
EACCES13Permission denied
EEXIST17File exists
ENOMEM12Cannot allocate memory
EINVAL22Invalid argument
EDOM33Numerical argument out of domain
ERANGE34Numerical result out of range

编号在别的系统上可能不同,比如 EDOM 在有的系统是 333333,有的是别的值。这也是为什么代码里要写宏名。

3.5 时序图

系统对照表strerrorperrormain系统对照表strerrorperrormainloop[遍历常见错误数组]errno 设为 ENOENT调用 perror按 errno 查表No such file or directory输出到 stderrstrerror 传入 ENOENT查表No such file or directory返回同样的文字传入编号返回文字打印名字 编号 文字

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 画一遍:

FILE 档案卡

缓冲区的位置

缓冲区剩余数据量

当前读写位置

错误标志

文件末尾标志

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;
}

代码讲解

  1. struct MiniStream:用两个成员模仿 FILE,data 是流里的内容,pos 是读到哪儿的状态。
  2. getByValue(MiniStream s):参数是结构本身,调用时会复制。s.pos++ 加的是复制品里的 pos,函数一结束复制品就消失了。所以主函数里连续调用三次,每次读到的都是同一个字符 A。
  3. getByPointer(MiniStream* s):参数是指针。s->pos++ 通过地址直接改原件,三次调用依次读到 A、B、C。
  4. 实验三:把结构复制成 copy 后,只有 original 前进了,copy 还停在原地。两份状态不一致,说明复制"档案卡"一定会出问题。
  5. 最后一行对比大小,说明传指针的复制成本很小。
    关键点
  • 想让函数修改调用者手里的东西,C 语言只有一个办法:传指针。
  • FILE 里的状态必须只有一份,所以必须通过 FILE* 访问。
  • 这也解释了为什么 fclose 接收指针:关闭的是那一个具体的流,而不是某个复制品。
    时序图
原件 MiniStreamgetByPointergetByValuemain原件 MiniStreamgetByPointergetByValuemain原件 pos 仍为 0原件 pos 变成 1传入 a 的复制品复制品 pos 加 1返回字符 A传入 b 的地址通过指针读取当前字符通过指针让 pos 加 1返回字符 A

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+ 的使用要点

  1. 文件必须已经存在。 不存在时 fopen 返回 NULL,一定要检查。
  2. 读和写互相切换时,中间必须插入 fseek 或 fflush。 否则缓冲区状态混乱,结果不可预测。如果只想"原地切换",可以用 fseek(fp, 0, SEEK_CUR),位置不变,但完成了切换。
  3. 覆盖写入,不是插入。 写入的字符会替换掉原位置的字符,后面的内容不会往后挪。
  4. 只能覆盖,不能删除或插入。 如果写入的内容更短或更长,多余部分不会自动处理,需要自己重写文件。

覆盖写入示意

原文件:     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)

代码是怎么实现的

  1. show_file 是个辅助函数:以只读方式把整个文件读出来打印。字符范围不在 32 到 126 之间(不可见字符)就用 . 代替,这样后面能看出异常的字节。
  2. 先用 "w" 创建文件并写入 ABCDEFGH,这是准备工作。
  3. 用 "r+" 打开,立刻检查是否为 NULL。
  4. fseek(fp, 2, SEEK_SET) 跳到下标 2,fputs("xy", fp) 覆盖写入,C D 变成 x y。
  5. 写完之后想接着读,先用 fseek(fp, 0, SEEK_CUR) 完成"写转读"切换,再 getc 读到 E。
  6. 再 fseek(fp, 0, SEEK_SET) 回到开头,fputc('#', fp) 把 A 改成 #。
  7. fclose 后重新读出文件,得到 #BxyEFGH,原内容除了被覆盖的位置都还在。
  8. 最后演示对不存在的文件用 "r+" 会失败。

时序图

磁盘文件r+ 打开的文件流main磁盘文件r+ 打开的文件流mainfopen r+打开 不清空内容 ABCDEFGHfseek 到下标2fputs xy覆盖第3第4个字符fseek 切换为读getc返回 Efseek 回开头 fputc 井号fclose写入 井号BxyEFGH

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

代码是怎么实现的

  1. reset 每次把文件恢复成 ABCDEFGH,保证五次测试起点相同。
  2. try_mode 用传入的模式打开文件,fseek 到下标 2,写入 xy,关闭,再读回来打印。
  3. r+:得到 ABxyEFGH,旧内容保留,只在下标 2 处被覆盖。这就是我们要的效果。
  4. w 和 w+:打开时文件被清空,文件变空了,再 fseek 到下标 2 写入,前面两个字节变成了不可见的 \0(显示为 .),旧内容全丢。
  5. a 和 a+:内容保留,但 xy 被加到了末尾,fseek 完全不起作用。

时序图

文件main文件mainr+ 打开 写入xyABxyEFGH 保留旧内容 位置可控w 打开 写入xy变成 点点xy 旧内容丢失a 打开 写入xyABCDEFGHxy 只能加在末尾

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 的作用是:让一个已经存在的流,改为连接到另一个文件。 它之所以必要,主要有两个原因:

  1. stdin、stdout、stderr 这三个标准流是系统预先准备好的,你不能简单地写 stdout = fopen(...) 来换掉它们。要改它们的去向,就需要 freopen。
  2. 很多现成代码(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 *流);

它按顺序做三件事:

  1. 关闭这个流原来连接的目标(必要时先把缓冲区写出去)。
  2. 打开新的文件。
  3. 把同一个流接到新文件上,返回这个流的指针。失败则返回 NULL。
    关键在于:流的"身份"(stdout 这个名字)没变,变的只是它背后连着的目标。所以所有用 stdout 的代码都自动跟着改变了。
    用 Mermaid 树整理它的用途:

freopen 的用途

改标准流的去向

复用同一个 FILE 指针

stdin 改读文件

stdout 改写文件

stderr 改写日志

换一个文件继续用

换一种打开模式

用 ASCII 再画一遍:

freopen 的用途
|
+-- 改标准流的去向
|   |
|   +-- stdin  改读文件
|   |
|   +-- stdout 改写文件
|   |
|   +-- stderr 改写日志
|
+-- 复用同一个 FILE 指针
    |
    +-- 换一个文件继续用
    |
    +-- 换一种打开模式

1.4 fopen 和 freopen 对比


对比项fopenfreopen
做什么新建一个流让已有的流换目标
返回值新的 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 时序图

out.txt屏幕标准库mainout.txt屏幕标准库mainprintf 第一行写到屏幕fflush stdoutfreopen out.txt w stdout关闭 stdout 原目标打开 out.txt返回 stdoutprintf 第二行写到 out.txtfclose stdout写出缓冲区并关闭

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 时序图

in.txt键盘标准库mainin.txt键盘标准库mainloop[直到读不到]fopen 写入 3 5 7 9 11freopen in.txt r stdin放弃原来的键盘连接打开 in.txt返回 stdinscanf 读一个整数读取返回 3cin 读一个整数读取返回下一个数累加到 sum打印 sum 等于 32

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
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐