Linux 终端、tty、pty 和 shell
这是关于 Linux 终端的两篇文章中的第一篇。读完这两篇文章后,我们应该能够:
- 描述终端子系统的主要组件
- 了解 TTY、PTY 和 Shell 之间的区别
- 回答当我们在终端(如 Xterm 等)中按下某个键时会发生什么。
- 使用 golang 构建一个简单的远程终端应用程序
或者至少我希望如此:)
什么是终端?
一般来说,终端是一种相对笨重的机电设备,具有输入接口(例如键盘)和输出接口(例如显示器或纸张)。
它通过两个逻辑通道连接到另一个设备(例如计算机),它所做的就是:
- 将击键发送到第一行
- 从第二行开始读,并打印在一张纸上
这类设备的商用名称是电传打字机、Teletype 或 TTY(记住这个词,
后面还会用到)。这类机器为早期的大型计算机和小型计算机提供用户界面,将打字数据发送到计算机并打印响应。
作者:ArnoldReinhold -自己的作品,CC BY-SA 3.0,链接
要理解现代终端的工作原理,我们需要稍微回顾一下电传打字机过去的工作原理。
每台机器通过两根电缆连接:一根用于向计算机发送指令,另一根用于接收计算机的输出。
这些电缆通过插入通用异步收发器 (UART) 的串行电缆连接到计算机。
计算机有一个 UART 驱动程序用于读取硬件设备。
字符序列被传递给 TTY 驱动程序,该驱动程序应用了线路规程 (line discipline)。线路规程负责转换特殊字符(例如行尾符、退格符),并将接收到的内容回显给电传打字机,以便用户可以看到输入的内容(线路规程将在本系列的下一篇文章中讨论)。
它还负责缓冲字符。
按下回车键时,缓冲的数据将传递给与 TTY 关联的会话的前台进程。作为用户,您可以并行执行多个进程,但一次只能与一个进程交互,其他进程则在后台运行(或等待)。
上面定义的整个堆栈称为 TTY 设备。
前台进程是一个称为 Shell 的计算机程序。
提示:终端和 TTY 设备这两个词基本上可以互换,因为它们的意思是一样的
什么是 shell?
Shell 是用户空间应用程序,它使用内核 API 的方式与其他应用程序相同。Shell 通过提示用户输入、解释输入,然后处理来自底层操作系统的输出(类似于读取-求值-打印循环,REPL)来管理用户与系统的交互。
例如,如果输入是“cat file | grep hello”,bash 会解释该命令,并判断需要运行程序 cat,并将“file”作为参数传递,然后将输出通过管道传递给 grep。
它还控制程序的执行(称为作业控制的功能):终止程序(CTRL + C)、暂停程序(CTRL + Z)、将其设置为在前台运行(fg)或后台运行(bg)。
它们还可以通过包含一系列命令的脚本以非交互模式运行。
Bash、Zsh、Fish 和 sh 都是不同类型的 shell。
什么是终端仿真器?
让我们回到近代。计算机变得越来越小,所有部件都装在一个盒子里。
终端首次不再是通过 UART 连接到计算机的物理设备。它变成了内核中的一个计算机程序,可以直接向 TTY 驱动程序发送字符,并从中读取字符并打印到屏幕上。
也就是说,内核程序会模拟物理终端设备,因此得名终端仿真器。需要
注意的是,尽管是模拟的,但它们过去和现在都被称为电传打字机 (Teletype)。
不要被“模拟器”这个词迷惑。终端模拟器和以前的物理终端一样笨,它监听来自键盘的事件并将其发送给驱动程序。区别在于,它没有连接到 TTY 驱动程序的物理设备或线缆。
如何查看终端模拟的 TTY?
如果您的机器运行的是 Linux 操作系统,请按 Ctrl+Alt+F1。您将获得一个由内核模拟的 TTY!您可以结合使用 Ctrl+Alt 和功能键(F2 到 F6)来获取其他 TTY。按 Ctrl+Alt+F7 即可返回 GUI(X 会话)。
让我们回顾一下迄今为止的主要概念:
- 终端和TTY可以互换使用
- 电传打字机 (TTY) 是一种物理机电设备,最初设计用于电报,后来被改造用于从大型机发送输入和获取输出
- 电传打字机可以通过内核中作为模块运行的计算机程序来模拟
什么是伪终端(PTY)?
它是由运行在用户空间的计算机程序模拟的电传打字机。
与 TTY 相比,区别在于程序的运行位置;它不是内核程序,而是运行在用户空间的程序。
我不会(可能也无法)完整地描述内核态与用户态的区别,我只想说,在内核中运行的程序可以访问特权模式。这允许内核访问机器的硬件。
而用户态的程序只与内核交互,而不直接与硬件交互。
如果内核模块出现问题,整个系统都可能受到影响;而如果用户态的程序出现问题,则只有该程序受到影响;在最坏的情况下,重启系统就能恢复正常。
这无疑是将终端仿真移至用户态的一个很好的理由。开发人员可以更轻松地构建一个终端仿真。
我猜想 PTY 存在的主要原因是为了方便将终端仿真移入用户空间,同时仍然保持 TTY 子系统(会话管理和线路规则)的完整性。
它是如何工作的?(高级)
终端仿真器(或任何其他程序)可以向内核请求一对字符文件(称为 PTY 主程序和 PTY 从程序)。
主程序端有终端仿真器,而从程序端有 Shell。
主程序端和从程序端之间有一个 TTY 驱动程序(线路规程、会话管理等),用于在 PTY 主程序端和从程序端之间复制内容。
让我们看看当...时会发生什么。
您在用户空间的终端仿真器中输入一些内容,例如 XTerm 或任何其他用于获取终端的应用程序。
通常我们说打开“终端”或打开“bash”,但实际上发生的是:
- 模拟终端的 GUI 启动(如终端或 Xterm UI 应用程序)。
- 它将 UI 绘制到视频并向操作系统请求 pty。
- 将 bash 作为子进程启动
- bash 的标准输入、输出和错误将被设置为 pty 从属。
- XTerm 监听键盘事件并将字符发送给 pty master
- 线路规程获取字符并将其缓冲。只有当您按下 Enter 键时,它才会将字符复制到从属设备。它还会将其输入写回到主设备(回显)。请记住,终端是哑终端,只有来自 pty 主设备的内容才会在屏幕上显示。因此,线路规程会回显该字符,以便终端可以将其绘制到视频上,让您看到刚刚输入的内容。
- 当你按下回车键时,TTY 驱动程序(它只是一个内核模块)负责将缓冲数据复制到 pty 从属设备
- bash(它一直在等待标准输入)最终读取了这些字符(例如“ls -la”)。再次提醒,bash 的标准输入被设置为 PTY 从属。
- 此时,bash 解释该字符并认为它需要运行“ls”
- 它派生出一个进程并在其中运行 ls 命令。派生出的进程将拥有与 Bash 相同的 stdin、stdout 和 stderr,Bash 是 PTY 从属进程。
- ls 运行并打印到标准输出(再次强调,这是 pty 从属)
- tty 驱动程序将字符复制到主设备(不,线路规则不会在返回途中干预)
- XTerm 循环读取 pty master 中的字节并重新绘制 UI
我想我们成功了!这大概就是我们在终端模拟器中运行命令时发生的事情。这张图应该有助于巩固工作流程:
作为一个实验,在新的终端实例中运行“ps”命令。
如果您尚未运行任何进程,您将看到与终端关联的仅有的两个程序是“ps”和“bash”。
“ps”是我们刚刚启动的程序,“bash”是由终端启动的。您在结果中看到的“pts/0”是我们之前提到的PTY从属进程。
> ps
PID TTY TIME CMD
26113 pts/0 00:00:00 ps
30985 pts/0 00:00:00 bash
让我们看看当...时会发生什么
你启动一个程序,从终端仿真器读取标准输入。该程序可以简单到从标准输入读取数据,然后将其写入标准输出,如下所示。
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter text: ")
text, _ := reader.ReadString('\n')
fmt.Println(text)
}
> go build -o simple-program
> ./simpleprogram
Enter text:
尝试在程序等待输入时输入一些内容,但不要按回车键,而是按“CTRL + W”。
你会看到你写的单词被删除了。为什么?
“Ctrl + W”是分配给名为 werase 的线路规则规则的字符。回头看一下图纸。输入的字符会发送到 PTY 主机,然后从那里发送到实现线路规则的 TTY 驱动程序。当线路规则读取字符“CTRL + W”时,它会从其内部缓冲区中删除最后一个单词,并向 PTY 主机发送指令,将光标设置回 N 个位置(其中 N 是单词的长度),并从显示屏上删除沿途的字符
(我们稍后会看到这些指令的样子)。
尝试相同的实验,但这次不是输入字符,而是按箭头键。
那些奇怪的字符是什么^[A^[B^[C^[D
?
正如我们所说,终端只是将击键发送给主机。但是对于那些没有字符表示的键,比如我们的箭头键,该怎么办呢?
当发生这种情况时,终端会使用多个字符对它们进行编码;例如,向上的箭头用 编码^[A
,其中^[
称为转义序列。
因此,按下箭头键时发生的操作与按下任何其他键时发生的操作完全相同,只是它返回的内容看起来很奇怪,因为它在发送给 PTY 主机时就是以这种方式编码的。
我听到你说……但是等等,当我在没有运行任何程序的情况下在终端中按下方向键时,我得到的是 bash 历史记录!
这是因为^[A
bash 程序中的结束符将它们解释为获取作业历史记录中当前条目的请求。它会将清除当前行的代码打印到标准输出(删除到目前为止回显的所有内容,包括^[A
向上键的字符),然后打印 bash 历史记录行。我猜我们看不到编码字符,因为一切都发生得太快了。
这里的要点是线路规则不处理向上、向左、向下、向右等键。
程序如何控制终端
Shell、TTY 驱动程序以及我们编写的程序可以指示终端执行一些操作:将光标向后移动或向下移动一行、打印读取的下一行并清除屏幕。
程序控制终端的方式由 ANSI 转义码标准化。当终端从 pty master 读取这些转义码时,会执行与该转义码相关的操作。
想要更改程序中文本的颜色吗?
只需将 ANSI 转义码打印到标准输出即可为文本着色。
标准输出是 PTY 从属设备,TTY 驱动程序将字符复制到 PTY 主设备,终端获取该代码并理解需要设置颜色以在屏幕上打印文本。瞧!
这是一个简单的程序,它指示终端仿真器使用红色打印该行。正如您所见,它很简单,只需将用于将颜色更改为红色的 ANSI 代码 (\033[1;31m)、我们要写入的字符串以及用于重置颜色的 ANSI 代码 (\033[0m) 发送到标准输出即可。
package main
import "fmt"
const redColor = "\033[1;31m%s\033[0m"
func main() {
fmt.Printf(redColor, "Error")
fmt.Println("")
}
ANSI 代码本身可能就够写一篇单独的文章了,但我就此打住。想体验一下 ANSI 代码的乐趣吗?那就看看这篇文章吧。
结论
我认为到此为止就好了。
我们已经讨论了 TTY 和 PTY,并了解了它们与 Shell 的关系。
在本系列的下一篇文章中,我们将深入探讨线路规程,讨论在使用 vim 等程序时会发生什么,并最终编写一个简单的 Golang 程序来创建我们自己的远程终端。