0x01 问题演示
下面是用rust写的一段测试程序,逻辑非常简单,就是读取用户输入,然后将其输出。
运行这个程序,然后按Ctrl-C:
由上图可见,该程序没有收到任何输入,当然也没有任何输出,这个程序就退出了。
为什么Ctrl-C会导致当前运行程序退出呢?
0x02 程序退出原因
上面的测试程序之所以会退出,是因为Ctrl-C会告诉linux内核,让其发送SIGINT信号给当前运行程序,该信号的默认行为是杀掉目标进程,所以就有了上面的现象。
但是,SIGINT信号,以及其他的各种信号,都是可以捕获的,这样我们就可以修改信号的默认行为,比如将SIGINT信号的默认行为,修改成输出一些日志,而不是杀掉当前进程。
将上面的测试代码改成下面的样子:
上图中我们捕获了SIGINT信号,并且在收到该信号后,输出 got SIGINT。
运行看下:
这次再按Ctrl-C,程序就不会退出了,而且还会输出 got SIGINT。
由上可见,Ctrl-C导致程序退出,确实是因为该按键使内核发送了SIGINT信号到目标进程,进而导致目标进程被杀死。
那为什么Ctrl-C会触发SIGINT信号呢?
在回答这个问题之前,我们要先了解下terminal emulator,即终端模拟器,的运行机制。
0x03 Terminal Emulator的运行机制
我当前使用的terminal emulator为 alacritty,后面如果涉及到terminal emulator的源码分析,就是基于这个项目。
当然,以下讲的terminal emulator的运行机制,对于其他terminal emulator也同样适用。
当我们在图形化界面,打开一个terminal emulator时,terminal emulator会调用openpty函数,向linux内核申请一个pty数据通道,当该pty数据通道创建成功后,linux内核会返回两个文件描述符,即两个fd,给terminal emulator,这两个fd,就代表了新创建pty数据通道的两端,分别为master端和slave端。
当向master端的fd写数据时,该数据就可以从slave端的fd读出来,当向slave端的fd写数据时,该数据就能从master端的fd读出来。
当pty数据通道创建完毕后,terminal emulator就会调用fork函数,启动一个子进程,该子进程用来运行shell程序,比如bash、zsh等,同时会将该shell的标准输入,标准输出,标准错误输出,都设置为上面通过openpty函数获取的slave端的fd。
shell在启动成功后,会一直等待着从标准输入,即slave fd,里接收要执行的命令。
当terminal emulator启动成功后,我们就可以在其内部输入命令了,我们输入的命令,会被terminal emulator写入到master fd里,这样在shell子进程中,就可以通过标准输入,即slave fd,接收到这个命令,并开始执行。
shell在执行接收到的命令时,也是通过fork函数,创建一个子进程,然后在子进程里执行该命令对应的程序。
不过,这里需要注意的是,子进程中运行的命令程序,其标准输入,标准输出、标准错误输出都是继承自shell,即它的标准输入,标准输出、标准错误输出的值都是slave fd。
这样,当命令程序写日志到标准输出时,其实际上是写到了slave fd里,如此,在terminal emulator里,就可以通过master fd,读取到这些日志信息,并在terminal emulator里显示出来。
同样的道理,当我们此时在terminal emulator里输入内容时,该内容会被terminal emulator写入到master fd,进而就可以被命令程序进程,从标准输入,即slave fd,里读出来。
这里大家可能会有个疑问,即shell进程和当前运行的命令程序进程,他们的标准输入都是slave fd,那为什么我们写入到terminal emulator里的内容,是被命令程序进程读出来,而不是被shell进程读出来呢?
这个就涉及到terminal emulator使用权的概念了。
当shell进程刚启动成功后,terminal emulator的使用权自然是shell的,此时我们在terminal emulator里输入的内容,会被shell从标准输入,即slave fd,里读出来。
当shell启动一个子进程,并用该进程运行命令程序时,它会把terminal emulator的使用权,转交给该命令程序进程,此时我们在terminal emulator里输入的内容,会被该命令程序进程从它的标准输入,即slave fd,里读出来的。
当该命令程序进程退出后,linux内核会通知shell进程,告知它启动的子命令进程已经结束了,此时shell会把terminal emulator的使用权转回给自己,进而shell又可以开始从terminal emulator接收新的命令了。
下面我们来看一些具体的例子:
上图是用ps命令输出的信息,看图中的选中行,第一行为alacritty进程,即我们最开始启动的terminal emulator,第二行为alacritty启动的子进程,在该子进程中,运行的是bash程序,第三行为bash启动的子进程,在该子进程中,运行的是我们文章最开始时使用的测试程序hello。
这三个进程的层级关系,和我们上面的描述是一致的。
我们再来看下alacritty启动bash子进程的相关代码:
上图中make_pty函数内会调用openpty函数获取master fd和slave fd,在获取到master fd和slave fd后,slave fd被赋值到builder的stdin, stdout, stderr里,这样,在下面执行builder.spawn函数启动shell子进程时,其标准输入、标准输入、标准错误输出就都指向slave fd了。
另外,在上图中,master fd被保存到了Pty里,并和其他信息一起返回给该函数的调用方,这样,alacritty如果想要发送数据给shell时,就从Pty里获取到master fd,然后将数据写入到master fd里就好了。
0x04 Ctrl-C是如何处理的
上面我们讲过,我们在terminal emulator中输入的内容,会被terminal emulator写到内核的pty数据通道中,进而这些数据会被转发给shell进程,或者是在shell中运行的子进程。
那在terminal emulator里按Ctrl-C,也是这么处理的吗?
首先,Ctrl-C确实是被当作一个字符来处理的,且terminal emulator在接收到这个字符后,会直接写入到内核的pty数据通道,并不做特殊处理。
但是,在内核的pty数据通道里,有一个组件叫做 line discipline,它会检查要被传输的字符,如果字符流中包含Ctrl-C,它就会把Ctrl-C这个特殊字符从字符流中移除掉,并生成一个SIGINT信号,发送给目标进程。
如果目标进程没有捕获该信号,内核就会执行该信号的默认行为,即杀掉目标进程。
以下是生成SIGINT信号的内核代码:
上图中,光标所在行就是在判断该字符是否是Ctrl-C,如果是,则发送SIGINT信号给目标进程。
由上图我们还可以看到,其实不止Ctrl-C这个特殊字符会转化成信号,QUIT字符Ctrl-\,SUSP字符Ctrl-Z等,都会被转化成对应的信号。
0x05 精致全景图
以上讲了很多理论,下面我们来画一幅图,来梳理下完整流程。
首先,在terminal emulator启动成功后,我们在其中输入./hello命令,该命令沿着terminal emulator中的第一个输出箭头,即第一条虚线,经由内核pty数据通道,到达bash进程的stdin。
然后,bash从标准输入中读取到要执行的命令./hello,fork一个新的子进程,并在子进程中开始执行hello程序,此时bash也把terminal emulator的使用权交给了hello进程。
hello程序在开始运行后,就尝试从标准输入中读取数据。
接着,我们在terminal emulator中再输入hello world,该数据会沿着terminal emulator中的第二个输出箭头,即第一条实线,经由pty数据通道,到达hello进程的stdin。
hello进程从标准输入中读到hello world字符串,然后直接将其写入到了标准输出,该数据又经由内核pty数据通道,到达terminal emulator的master fd端。
terminal emulator从master fd中读取到hello world字符串,并将其显示在界面中。
hello进程在写完hello world字符串后,自己主动退出,bash检测到hello进程退出后,又把terminal emulator的使用权转回给自己。
bash写命令提示符 > 到标准输出,该数据再经由pty数据通道到达terminal emulator的master fd端。
terminal emulator从master fd中读取到bash的命令提示符,并将其显示在界面上,提示用户可以输入下一条命令了。
以上就是在terminal emulator中执行hello程序的完整流程。
从上图中我们还可以看到,假设我们在terminal emulator中按Ctrl-C,该数据在到达内核pty数据通道时,line discipline组件会将其转换成SIGINT信号,并发给目标进程。
这个也解答了我们此篇文章的疑问,现在你应该豁然开朗了吧。
另外,内核中 line discripline 组件的能力也是可以被修改的,比如我们可以修改成按Ctrl-B触发SIGINT信号,甚至是直接关闭SIGINT信号的生成,具体方式,可以查看stty命令的man文档。
这篇文章就这些内容,希望对你有所帮助。
0x06 其他
如果有对linux及linux内核感兴趣的,可以扫描右侧二维码添加我的微信。
另外,我开设了一门 linux内核启动流程源码分析 课程,有对内核源码感兴趣的,欢迎报名。