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


那么 , 让我们通过实际代码来看一看 , 如何在机器指令层面使用系统调用 。 在下面这段代码中 , 我们直接使用机器指令调用了 open 系统调用函数 。
#include <unistd.h>
#include <fcntl.h>
int main(void) {
const char str[
= \"Enter some characters:\\";
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\\\t\"
\"mov %0 %%rdi\\\t\"
\"mov $66 %%rsi\\\t\" // 2 | 64 -> 66;
\"syscall\\\t\"
\"mov %%rax %1\\\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 标准库、系统调用 , 以及应用程序三者之间的依赖关系 , 如下图所示:

这个关系看起来比较清晰 , 但隐藏在操作系统背后的系统调用函数实现细节 , 以及调用细节却非常复杂 。 这里不讨论 。
危险的 gets 函数最后 , 我们再来聊聊标准 IO 与代码安全的相关话题 。
实际上 , C 语言提供的标准 IO 接口并非都是完备的 。 自 C90 开始 , 一个名为 gets 的 IO 函数被添加进标准库 。 该函数主要用于从标准输入流中读取一系列字符 , 并将它们存放到由函数实参指定的字符数组中 。 例如 , 你可以这样来使用这个函数:
【虚竹|C语言标准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 所能承载的最大容量时 , 超出的内容将会直接覆盖掉栈帧中位于高地址处的其他数据 。 而当别有用心的攻击者精心设计输入内容时 , 甚至可以在某些情况下直接“篡改”当前函数栈帧的返回地址 , 并将其指向另外的 , 事先准备好的攻击代码 。