引用输入输出后面简称 “IO”是应用程序不可或缺的一种基本能力。为了保持设计上的精简C 语言并没有在核心语言层面提供对 IO 相关接口的支持相反采用了标准库的方式来实现。通过引用名为 stdio.h 的标准库头文件我们便可以快捷地为 C 程序添加读取用户键盘输入、输出内容到控制台乃至读写文件等一系列常规的 IO 功能。这一讲我将为你深入介绍 C 语言中的标准 IO 模型以及它背后的一些原理。快速回顾 IO 接口的使用方法首先让我们通过下面这段代码来快速回顾应该如何在 C 语言中使用这些由标准库提供的 IO 接口。#include stdio.h int main(void) { printf(Enter some characters:\n); FILE* fp fopen(./temp.txt, w); if (fp) { char ch; while (scanf(%c, ch)) { if (ch z) break; putc(ch, fp); } } else { perror(File open failed.); } fclose(fp); return 0; }这里在 main 函数内部我们通过多种不同的方式让程序与进程预设的 IO 流以及我们自行打开的 IO 流产生了交互。其中代码第 3 行通过 printf 函数我们可以将指定的文本传送至标准输出流stdout中。紧接着借助代码第 4 行的 fopen 函数我们得以在当前目录下打开名为 “temp.txt” 的文件并将其与一个特定的文件 IO 流相关联。而当文件打开失败时通过代码第 12 行的 perror 函数我们能够将特定的错误信息传送到标准错误流stderr。最后在代码的第 7 行scanf 函数的调用可以让我们从标准输入stdin流中读取从外部环境输入的信息。IO 接口的不同级别通常来说IO 接口可以被分为不同层次。其中C 语言提供的 IO 接口属于“标准 IO”的范畴。与其相对的是名为“低级 IO”的另一套编程模型。顾名思义低级 IO 会使用与具体操作系统相关的一系列底层接口来提供相应的 IO 能力比如常用于 Unix 与类 Unix 操作系统上的 POSIX 接口标准。如果我们将上面的示例程序完全用该标准进行重写将会得到如下所示的代码#include unistd.h #include fcntl.h int main(void) { const char str[] Enter some characters:\n; write(STDOUT_FILENO, str, sizeof(str)); const int fd open(./temp.txt, O_RDWR | O_CREAT); if (fd 0) { char ch; while (read(STDIN_FILENO, ch, 1)) { if (ch z) break; write(fd, ch, sizeof(ch)); } } else { const char errMsg[] File open failed.; write(STDERR_FILENO, errMsg, sizeof(errMsg)); } close(fd); return 0; }可以看到在使用低级 IO 接口进行编程时我们需要处理与所进行 IO 操作有关的更多细节。比如在调用 write 接口时你必须要指定不同的文件描述符File Descriptor才能够区分所要进行的操作是“向屏幕上输出字符”还是“向文件内写入数据”。相反在高级 IO 的实现中我们并不需要关注这些细节接口的名称可以直接反映其具体用途。两者之所以会在接口使用粒度上存在差异是由于“低级 IO 与操作系统实现紧密相关”。对于 POSIX 标准来说其所在系统会将绝大多数的 IO 相关资源比如文档、目录、键盘、网络套接字以及标准输入输出等以“文件”的形式进行抽象并使用相对统一的数据结构来表示。而在实际编码过程中每一个可用的 IO 资源都会对应于一个唯一的整型文件描述符值。该值将被作为“单一可信源The Single Source of Truth”供相关接口使用。而标准 IO 在接口设计与使用方式上却不会与某类特定的操作系统进行“绑定”。相反它会提供更加统一和通用的接口来屏蔽底层不同系统的不同实现细节做到“一次编写到处编译”。除此之外即使上述两段采用不同级别 IO 接口实现的 C 代码在实际的可观测执行效果方面基本一致但它们在程序运行时资源的背后使用逻辑上却有着较大的差异。带缓冲的标准 IO 模型那么这两种 IO 模型除了在接口使用方式上有不同外还有哪些重要差异呢简单来讲与低级 IO 相比标准 IO 会为我们提供带缓冲的输入与输出操作。事实上标准 IO 接口在实现时会直接使用所在平台提供的低级 IO 接口。而低级 IO 接口在每次调用时都会通过系统调用来完成相应的 IO 操作。关于系统调用的内容这一讲的后面还会提到。并且我也会在第 31 讲中再为你深入介绍。在这里你只需要知道系统调用的过程涉及到进程在用户模式与内核模式之间的转换其成本较高。为了提升 IO 操作的性能同时保证开发者所指定的 IO 操作不会在程序运行时产生可观测的差异标准 IO 接口在实现时通过添加缓冲区的方式尽可能减少了低级 IO 接口的调用次数。让我们再把目光移回到之前的两段示例代码上。不知道你在运行对应的两段程序时是否有观察到它们之间的差异呢实际上使用低级 IO 接口实现的程序会在用户每次输入新内容到标准输入流中时同时更新文件 “temp.txt” 中的内容。而使用标准 IO 接口实现的程序仅会在用户输入的内容达到一定数量或程序退出前再更新文件中的内容。而在此之前这些内容将会被存放到缓冲区中。当然C 标准中并未规定标准 IO 接口所使用的缓冲区在默认情况下的大小对于其选择将由具体标准库实现自行决定。除此之外标准 IO 还为我们提供了可以自由使用不同缓冲策略的能力。对于简单的场景我们可以使用名为 fflush 的接口来在任意时刻将临时存放在缓冲区中的数据立刻“冲刷”到对应的流中。而在相对复杂的场景中我们甚至可以使用 setvbuf 等接口来精确地指定流的缓冲类型、所使用的缓冲区以及可以使用的缓冲区大小。比如我们可以在上述标准 IO 实例对应 C 代码的第 4 行后面插入以下两行代码// ... char buf[1024]; setvbuf(fp, buf, _IOFBF, 5); // ...此时再次编译并运行程序其执行细节与之前相比会有什么不同欢迎在评论区告诉我你的发现。用于低级 IO 接口的操作系统调用接下来让我们再来看一看低级 IO 的相关实现细节。在前面的内容中我曾提到过低级 IO 接口在其内部会通过系统调用来完成相应的 IO 操作。那么这个过程是怎样发生的呢实际上你可以简单地将系统调用当作是由操作系统提供的一系列函数。只是相较于程序员在 C 源代码中自定义的“用户函数”来说系统调用函数的使用方式有所不同。与调用用户函数所使用的 call 指令不同在 x86-64 平台上我们需要通过名为 syscall 的指令来执行一个系统调用函数。操作系统会为每一个系统调用函数分配一个唯一的整型 ID这个 ID 将会作为标识符参与到系统调用函数的调用过程中。比如在 x86-64 平台上的 Linux 操作系统中open 系统调用对应的 ID 值为 2你会在接下来的例子中看到它的实际用法。同用户函数类似的是系统调用函数在被调用时也需要通过相应的寄存器来实现参数传递的过程。而正如我在第 05 讲 中提到的那样SysV 调用约定中规定系统调用将会使用寄存器 rdi、rsi、rdx、r10、r8、r9 来进行实参的传递。当然除此之外rax 寄存器将专门用于存放系统调用对应的 ID并接收系统调用完成后的返回值。那么让我们通过实际代码来看一看如何在机器指令层面使用系统调用。在下面这段代码中我们直接使用机器指令调用了 open 系统调用函数。#include unistd.h #include fcntl.h int main(void) { const char str[] Enter some characters:\n; write(STDOUT_FILENO, str, sizeof(str)); const char* fileName ./temp.txt; // Call to open starts: // const int fd open(./temp.txt, O_RDWR | O_CREAT); volatile int fd; asm(mov $2, %%rax\n\t mov %0, %%rdi\n\t mov $66, %%rsi\n\t // 2 | 64 - 66; syscall\n\t mov %%rax, %1\n\t : m (fileName) : m (fd)); // Call ended. if (fd 0) { char ch; while (read(STDIN_FILENO, ch, 1)) { if (ch z) break; write(fd, ch, sizeof(ch)); } } else { const char errMsg[] File open failed.; write(STDERR_FILENO, errMsg, sizeof(errMsg)); } close(fd); return 0; }可以看到在上述代码的第 10 行我们以内联汇编的形式在程序的执行流中插入了 5 条机器指令。其中第 1 条指令我们将系统调用 open 对应的整型 ID 值 2 放入到了寄存器 rax 中第 2 条指令我们将存放有目标文件名称的字节数组 fileName 的首地址放到了寄存器 rdi 中该参数也对应着低级 IO 接口 open 的第一个参数。接下来的一条指令我们将配置参数对应表达式 O_RDWR | O_CREAT 的计算结果值 66 放入到了寄存器 rsi 中。最后通过指令 syscall我们得以调用对应的系统调用函数。而当系统调用执行完毕后其对应的返回值将会被放置在寄存器 rax 中。因此你可以看到在代码的第 14 行我们将该寄存器中的值传送到了变量 fd 在栈内存中的位置。至此程序对系统调用 open 的使用过程便结束了是不是非常简单其实除了低级 IO 接口以外C 标准库中还有很多其他的功能函数它们的实际执行也都依赖于所在操作系统提供的系统调用接口。因此我们可以得到 C 标准库、系统调用以及应用程序三者之间的依赖关系如下图所示这个关系看起来比较清晰但隐藏在操作系统背后的系统调用函数实现细节以及调用细节却非常复杂。与此相关的更多内容我会在“C 程序运行原理篇”中再向你详细介绍。危险的 gets 函数最后我们再来聊聊标准 IO 与代码安全的相关话题。实际上C 语言提供的标准 IO 接口并非都是完备的。自 C90 开始一个名为 gets 的 IO 函数被添加进标准库。该函数主要用于从标准输入流中读取一系列字符并将它们存放到由函数实参指定的字符数组中。例如你可以这样来使用这个函数#include stdio.h void foo(void) { char buffer[16]; gets(buffer); } int main(void) { foo(); return 0; }可以看到函数的使用方式十分简单。在上述代码的第 3 行我们声明了一个 16 字节大小的字符数组。紧接着该数组作为实参被传递给了调用的 gets 函数。而此时所有来自用户的输入都将被存放到这个名为 buffer 数组中。一切看似美好但问题也随之而来。实际上gets 函数在其内部实现中并没有对用户的输入内容进行边界检查Bound Check。因此当用户实际输入的字符数量超过数组 buffer 所能承载的最大容量时超出的内容将会直接覆盖掉栈帧中位于高地址处的其他数据。而当别有用心的攻击者精心设计输入内容时甚至可以在某些情况下直接“篡改”当前函数栈帧的返回地址并将其指向另外的事先准备好的攻击代码。正因如此gets 函数已经在 C99 标准中被弃用并在 C11 及以后的标准中移除。不仅如此如今的主流编译器在遇到使用了 gets 函数的代码时也会给予相应的安全性提示。另外DEP、ASLR、Canary 等技术也在一定程度上降低了此类安全事故发生的风险。但无论如何请不要在代码中使用 gets 函数。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。今天我主要介绍了 C 标准库中与标准 IO 相关的内容包括 IO 接口的不同级别它们之间的区别以及背后的实现方式。根据对操作系统依赖关系的强弱IO 接口可以被分为“低级 IO”与“标准 IO”两种不同的层级。其中低级 IO 的使用依赖于具体的操作系统而标准 IO 则抽象出了通用的 IO 接口因此更具可移植性。标准 IO 一般会使用所在平台的低级 IO 接口来实现。而低级 IO 则通过调用操作系统内核提供的系统调用函数来完成相应的 IO 操作。在 x86-64 平台上系统调用通过 syscall 指令来执行。而在基于该平台的 Unix 与类 Unix 系统上系统调用函数的执行会使用寄存器 rdi、rsi、rdx、r10、r8、r9 来进行参数的传递rax 寄存器则用于传递系统调用 ID以及接收系统调用的返回值。最后由于设计实现原因标准库中的 gets 函数具有较大的安全风险因此要避免在程序中使用。
现代C:标准库:深入理解标准 IO
发布时间:2026/7/15 14:15:47
引用输入输出后面简称 “IO”是应用程序不可或缺的一种基本能力。为了保持设计上的精简C 语言并没有在核心语言层面提供对 IO 相关接口的支持相反采用了标准库的方式来实现。通过引用名为 stdio.h 的标准库头文件我们便可以快捷地为 C 程序添加读取用户键盘输入、输出内容到控制台乃至读写文件等一系列常规的 IO 功能。这一讲我将为你深入介绍 C 语言中的标准 IO 模型以及它背后的一些原理。快速回顾 IO 接口的使用方法首先让我们通过下面这段代码来快速回顾应该如何在 C 语言中使用这些由标准库提供的 IO 接口。#include stdio.h int main(void) { printf(Enter some characters:\n); FILE* fp fopen(./temp.txt, w); if (fp) { char ch; while (scanf(%c, ch)) { if (ch z) break; putc(ch, fp); } } else { perror(File open failed.); } fclose(fp); return 0; }这里在 main 函数内部我们通过多种不同的方式让程序与进程预设的 IO 流以及我们自行打开的 IO 流产生了交互。其中代码第 3 行通过 printf 函数我们可以将指定的文本传送至标准输出流stdout中。紧接着借助代码第 4 行的 fopen 函数我们得以在当前目录下打开名为 “temp.txt” 的文件并将其与一个特定的文件 IO 流相关联。而当文件打开失败时通过代码第 12 行的 perror 函数我们能够将特定的错误信息传送到标准错误流stderr。最后在代码的第 7 行scanf 函数的调用可以让我们从标准输入stdin流中读取从外部环境输入的信息。IO 接口的不同级别通常来说IO 接口可以被分为不同层次。其中C 语言提供的 IO 接口属于“标准 IO”的范畴。与其相对的是名为“低级 IO”的另一套编程模型。顾名思义低级 IO 会使用与具体操作系统相关的一系列底层接口来提供相应的 IO 能力比如常用于 Unix 与类 Unix 操作系统上的 POSIX 接口标准。如果我们将上面的示例程序完全用该标准进行重写将会得到如下所示的代码#include unistd.h #include fcntl.h int main(void) { const char str[] Enter some characters:\n; write(STDOUT_FILENO, str, sizeof(str)); const int fd open(./temp.txt, O_RDWR | O_CREAT); if (fd 0) { char ch; while (read(STDIN_FILENO, ch, 1)) { if (ch z) break; write(fd, ch, sizeof(ch)); } } else { const char errMsg[] File open failed.; write(STDERR_FILENO, errMsg, sizeof(errMsg)); } close(fd); return 0; }可以看到在使用低级 IO 接口进行编程时我们需要处理与所进行 IO 操作有关的更多细节。比如在调用 write 接口时你必须要指定不同的文件描述符File Descriptor才能够区分所要进行的操作是“向屏幕上输出字符”还是“向文件内写入数据”。相反在高级 IO 的实现中我们并不需要关注这些细节接口的名称可以直接反映其具体用途。两者之所以会在接口使用粒度上存在差异是由于“低级 IO 与操作系统实现紧密相关”。对于 POSIX 标准来说其所在系统会将绝大多数的 IO 相关资源比如文档、目录、键盘、网络套接字以及标准输入输出等以“文件”的形式进行抽象并使用相对统一的数据结构来表示。而在实际编码过程中每一个可用的 IO 资源都会对应于一个唯一的整型文件描述符值。该值将被作为“单一可信源The Single Source of Truth”供相关接口使用。而标准 IO 在接口设计与使用方式上却不会与某类特定的操作系统进行“绑定”。相反它会提供更加统一和通用的接口来屏蔽底层不同系统的不同实现细节做到“一次编写到处编译”。除此之外即使上述两段采用不同级别 IO 接口实现的 C 代码在实际的可观测执行效果方面基本一致但它们在程序运行时资源的背后使用逻辑上却有着较大的差异。带缓冲的标准 IO 模型那么这两种 IO 模型除了在接口使用方式上有不同外还有哪些重要差异呢简单来讲与低级 IO 相比标准 IO 会为我们提供带缓冲的输入与输出操作。事实上标准 IO 接口在实现时会直接使用所在平台提供的低级 IO 接口。而低级 IO 接口在每次调用时都会通过系统调用来完成相应的 IO 操作。关于系统调用的内容这一讲的后面还会提到。并且我也会在第 31 讲中再为你深入介绍。在这里你只需要知道系统调用的过程涉及到进程在用户模式与内核模式之间的转换其成本较高。为了提升 IO 操作的性能同时保证开发者所指定的 IO 操作不会在程序运行时产生可观测的差异标准 IO 接口在实现时通过添加缓冲区的方式尽可能减少了低级 IO 接口的调用次数。让我们再把目光移回到之前的两段示例代码上。不知道你在运行对应的两段程序时是否有观察到它们之间的差异呢实际上使用低级 IO 接口实现的程序会在用户每次输入新内容到标准输入流中时同时更新文件 “temp.txt” 中的内容。而使用标准 IO 接口实现的程序仅会在用户输入的内容达到一定数量或程序退出前再更新文件中的内容。而在此之前这些内容将会被存放到缓冲区中。当然C 标准中并未规定标准 IO 接口所使用的缓冲区在默认情况下的大小对于其选择将由具体标准库实现自行决定。除此之外标准 IO 还为我们提供了可以自由使用不同缓冲策略的能力。对于简单的场景我们可以使用名为 fflush 的接口来在任意时刻将临时存放在缓冲区中的数据立刻“冲刷”到对应的流中。而在相对复杂的场景中我们甚至可以使用 setvbuf 等接口来精确地指定流的缓冲类型、所使用的缓冲区以及可以使用的缓冲区大小。比如我们可以在上述标准 IO 实例对应 C 代码的第 4 行后面插入以下两行代码// ... char buf[1024]; setvbuf(fp, buf, _IOFBF, 5); // ...此时再次编译并运行程序其执行细节与之前相比会有什么不同欢迎在评论区告诉我你的发现。用于低级 IO 接口的操作系统调用接下来让我们再来看一看低级 IO 的相关实现细节。在前面的内容中我曾提到过低级 IO 接口在其内部会通过系统调用来完成相应的 IO 操作。那么这个过程是怎样发生的呢实际上你可以简单地将系统调用当作是由操作系统提供的一系列函数。只是相较于程序员在 C 源代码中自定义的“用户函数”来说系统调用函数的使用方式有所不同。与调用用户函数所使用的 call 指令不同在 x86-64 平台上我们需要通过名为 syscall 的指令来执行一个系统调用函数。操作系统会为每一个系统调用函数分配一个唯一的整型 ID这个 ID 将会作为标识符参与到系统调用函数的调用过程中。比如在 x86-64 平台上的 Linux 操作系统中open 系统调用对应的 ID 值为 2你会在接下来的例子中看到它的实际用法。同用户函数类似的是系统调用函数在被调用时也需要通过相应的寄存器来实现参数传递的过程。而正如我在第 05 讲 中提到的那样SysV 调用约定中规定系统调用将会使用寄存器 rdi、rsi、rdx、r10、r8、r9 来进行实参的传递。当然除此之外rax 寄存器将专门用于存放系统调用对应的 ID并接收系统调用完成后的返回值。那么让我们通过实际代码来看一看如何在机器指令层面使用系统调用。在下面这段代码中我们直接使用机器指令调用了 open 系统调用函数。#include unistd.h #include fcntl.h int main(void) { const char str[] Enter some characters:\n; write(STDOUT_FILENO, str, sizeof(str)); const char* fileName ./temp.txt; // Call to open starts: // const int fd open(./temp.txt, O_RDWR | O_CREAT); volatile int fd; asm(mov $2, %%rax\n\t mov %0, %%rdi\n\t mov $66, %%rsi\n\t // 2 | 64 - 66; syscall\n\t mov %%rax, %1\n\t : m (fileName) : m (fd)); // Call ended. if (fd 0) { char ch; while (read(STDIN_FILENO, ch, 1)) { if (ch z) break; write(fd, ch, sizeof(ch)); } } else { const char errMsg[] File open failed.; write(STDERR_FILENO, errMsg, sizeof(errMsg)); } close(fd); return 0; }可以看到在上述代码的第 10 行我们以内联汇编的形式在程序的执行流中插入了 5 条机器指令。其中第 1 条指令我们将系统调用 open 对应的整型 ID 值 2 放入到了寄存器 rax 中第 2 条指令我们将存放有目标文件名称的字节数组 fileName 的首地址放到了寄存器 rdi 中该参数也对应着低级 IO 接口 open 的第一个参数。接下来的一条指令我们将配置参数对应表达式 O_RDWR | O_CREAT 的计算结果值 66 放入到了寄存器 rsi 中。最后通过指令 syscall我们得以调用对应的系统调用函数。而当系统调用执行完毕后其对应的返回值将会被放置在寄存器 rax 中。因此你可以看到在代码的第 14 行我们将该寄存器中的值传送到了变量 fd 在栈内存中的位置。至此程序对系统调用 open 的使用过程便结束了是不是非常简单其实除了低级 IO 接口以外C 标准库中还有很多其他的功能函数它们的实际执行也都依赖于所在操作系统提供的系统调用接口。因此我们可以得到 C 标准库、系统调用以及应用程序三者之间的依赖关系如下图所示这个关系看起来比较清晰但隐藏在操作系统背后的系统调用函数实现细节以及调用细节却非常复杂。与此相关的更多内容我会在“C 程序运行原理篇”中再向你详细介绍。危险的 gets 函数最后我们再来聊聊标准 IO 与代码安全的相关话题。实际上C 语言提供的标准 IO 接口并非都是完备的。自 C90 开始一个名为 gets 的 IO 函数被添加进标准库。该函数主要用于从标准输入流中读取一系列字符并将它们存放到由函数实参指定的字符数组中。例如你可以这样来使用这个函数#include stdio.h void foo(void) { char buffer[16]; gets(buffer); } int main(void) { foo(); return 0; }可以看到函数的使用方式十分简单。在上述代码的第 3 行我们声明了一个 16 字节大小的字符数组。紧接着该数组作为实参被传递给了调用的 gets 函数。而此时所有来自用户的输入都将被存放到这个名为 buffer 数组中。一切看似美好但问题也随之而来。实际上gets 函数在其内部实现中并没有对用户的输入内容进行边界检查Bound Check。因此当用户实际输入的字符数量超过数组 buffer 所能承载的最大容量时超出的内容将会直接覆盖掉栈帧中位于高地址处的其他数据。而当别有用心的攻击者精心设计输入内容时甚至可以在某些情况下直接“篡改”当前函数栈帧的返回地址并将其指向另外的事先准备好的攻击代码。正因如此gets 函数已经在 C99 标准中被弃用并在 C11 及以后的标准中移除。不仅如此如今的主流编译器在遇到使用了 gets 函数的代码时也会给予相应的安全性提示。另外DEP、ASLR、Canary 等技术也在一定程度上降低了此类安全事故发生的风险。但无论如何请不要在代码中使用 gets 函数。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。今天我主要介绍了 C 标准库中与标准 IO 相关的内容包括 IO 接口的不同级别它们之间的区别以及背后的实现方式。根据对操作系统依赖关系的强弱IO 接口可以被分为“低级 IO”与“标准 IO”两种不同的层级。其中低级 IO 的使用依赖于具体的操作系统而标准 IO 则抽象出了通用的 IO 接口因此更具可移植性。标准 IO 一般会使用所在平台的低级 IO 接口来实现。而低级 IO 则通过调用操作系统内核提供的系统调用函数来完成相应的 IO 操作。在 x86-64 平台上系统调用通过 syscall 指令来执行。而在基于该平台的 Unix 与类 Unix 系统上系统调用函数的执行会使用寄存器 rdi、rsi、rdx、r10、r8、r9 来进行参数的传递rax 寄存器则用于传递系统调用 ID以及接收系统调用的返回值。最后由于设计实现原因标准库中的 gets 函数具有较大的安全风险因此要避免在程序中使用。