0x01 什么是标准输入输出

准确来讲,该问题应该是什么是标准输入、标准输出、标准错误输出。

在linux下,它们其实就是三个文件描述符,其中标准输入是0,标准输出是1,标准错误输出是2。

该定义我们也可以在各个编程语言中看到。

比如,这是rust标准输入、标准输出、标准错误输出的定义:

其中,libc::STDIN_FILENO, libc::STDOUT_FILENO, libc::STDERR_FILENO对应的值分别为:

比如,这是go标准输入、标准输出、标准错误输出的定义:

其中,syscall.Stdin, syscall.Stdout, syscall.Stderr对应的值分别为:

其他编程语言中也有类似的定义,感兴趣的话可以自己看下。

0x02 为什么是0/1/2

这只是一种约定,linux内核并不会对0/1/2文件描述符做任何特殊处理,之所以定义为0/1/2,是因为进程文件描述符是从0开始依次递增的,所以就用了前三个。

0x03 为什么要有这种约定

这是因为,只有有了这种约定,linux世界的各个组件之间才能相互合作。

比如,在terminal emulator,即终端模拟器,中启动bash子进程时,因为知道bash的标准输入/输出/错误输出分别是0/1/2,那它就可以把bash子进程的0/1/2文件描述符都指向自己,这样当bash做标准输入输出操作时,它其实都是在和terminal emulator交互。

又比如,当bash要执行某个程序时,因为它知道目标程序所使用的编程语言,把标准输入/输出/错误输出分别定义为0/1/2,这样当我们要求bash把目标程序的标准输入/输出/错误输出重定向到其他文件时,bash只需要修改目标程序进程的0/1/2文件描述符的文件指向就好了,非常简单。

诸如此类。

0x04 为什么是三个文件描述符而不是一个

上面我们讲到,bash进程的0/1/2文件描述符都指向terminal emulator,其实这个说法并不准确,它们真正指向的是terminal emulator向内核分配的pty数据通道的slave端,这个在上篇文章 为什么Ctrl-C会中断当前运行程序 中有详细讲过。

但不管怎样,bash进程的0/1/2文件描述符,都是指向内核中的同一个文件实例,而该文件实例最终又指向了terminal emulator。

同样,bash中运行的其他程序,默认情况下,其进程的0/1/2文件描述符,是继承自bash的,所以也同样都指向了同一个terminal emulator。

既然进程的0/1/2文件描述符,默认都指向同一个terminal emulator,那为什么不只用一个文件描述符来表示它们的标准输入/输出/错误输出,而是要用三个呢,这不是浪费了进程的文件描述符吗?

原因也很简单,这样做更灵活。

虽然默认情况下,进程的0/1/2都指向了同一个terminal emulator,但它给了我们一种能力,使我们可以把进程的标准输入/输出/错误输出指向不同的文件。

比如,我们在bash中执行 ./hello > a.log,就会把hello程序的标准输出重定向到了a.log里,而标准输入和标准错误输出,还是指向的原来的terminal emulator。

0x05 什么是文件描述符

在linux世界里,一切皆文件。

比如我们创建一个socket,创建一个epoll实例,又或者是打开一个普通文件,所有的这些操作创建的目标对象,在内核里最终都是以一个file实例来表示的,该file实例会存放到进程的一个文件数组中,而该数组的下标,就是文件描述符,即fd。

该fd值,会随着我们使用的系统调用,返回给用户程序,当用户程序想对目标文件进行各种操作时,执行该操作对应的系统调用,把fd值再传给内核,这样内核就可以根据该fd找到对应的file实例,进而就可以执行对应的操作了。

0x06 0/1/2具体指向哪里

一个进程的0/1/2文件描述符具体指向什么文件,是受执行环境及命令参数影响的,下面我们就举几个常见的例子,再配合一些图,来看看0/1/2具体指向哪里。

0x07 在terminal emulator中执行hello程序

上图中,我们在terminal emulator中执行hello程序,该操作产生的各种数据,是按图中实线箭头方向流动的。

我们在terminal emulator中输入./hello命令,该命令沿着内核pty数据通道,到达bash的标准输入。

bash从标准输入中读取./hello命令,然后调用fork函数,新建一个子进程,用于执行hello程序。

hello程序执行时,会先向标准输出写hello字符串,然后再向标准错误输出写world字符串。

这两个字符串会沿着内核pty数据通道,到达terminal emulator的pty master fd。

terminal emulator从pty master fd中读取这些字符串,并显示在界面上。

这种情况下,hello进程的0/1/2文件描述符,都是指向内核pty数据通道的slave端,并且通过该pty数据通道和terminal emulator交互。

0x08 在ssh中执行hello程序

上图中,我们先用ssh命令登陆到机器2,然后再在terminal emulator中输入./hello命令,图中实线表示该操作产生数据的流动方向。

当我们在terminal emulator中输入./hello命令后,该命令会沿着内核pty数据通道,到达ssh进程的标准输入。

ssh进程从标准输入中读取到./hello命令,然后将其写到socket fd里。

然后,该命令会沿着socket fd指向的tcp连接,到达机器2的对应socket端。

在机器2上,sshd进程从它的socket fd中读取到./hello命令,然后将其写到pty master fd中。

这样,该命令又会沿着机器2的内核pty数据通道,到达bash进程的标准输入。

机器2上的bash进程,从标准输入中读到该命令,然后调用fork函数,创建一个子进程,用于执行hello程序。

hello程序执行时,会写hello到标准输出,写world到标准错误输出,这两个字符串又会沿着机器2的内核pty数据通道,到达sshd进程的pty master fd。

sshd进程从pty master fd中读取到hello进程输出的内容,并写到socket fd里。

该数据又沿着socket fd指向的tcp连接,最终会到达机器1对应的socket端。

机器1中的ssh进程,从socket fd里读取到hello程序的输出内容,并将其写到标准输出。

该数据会沿着机器1的内核pty数据通道,到达terminal emulator的pty master fd。

terminal emulator从pty master fd中读取到对应的数据,最终将其显示在界面上。

以上就是这张图的完整流程。

在这种情况下,hello进程的0/1/2文件描述符,指向的都是机器2上的pty数据通道的slave端,该端会再经过一系列的链路,最终和机器1上的terminal emulator连接起来。

也就是说,我们在机器1上的terminal emulator中输入内容,最终会被机器2上的hello进程从标准输入读出来,而机器2上hello进程的各种输出,最终会被传送到机器1上的terminal emulator进程,并在其界面上显示出来。

0x09 在ssh中执行hello程序并重定向标准输出

这种情况和上面讲的情况基本类似,只是额外把hello进程的标准输出重定向到了a.log文件,这里就不多讲了,具体可见图中内容。

就这样,希望对你有所帮助。

0x0a 其他

如果有对linux及linux内核感兴趣的,可以扫描右侧二维码添加我的微信。

另外,我开设了一门 linux内核启动流程源码分析 课程,有对内核源码感兴趣的,欢迎报名。