虚竹|C语言标准IO的理解( 二 )


而标准 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 , 并接收系统调用完成后的返回值 。