當(dāng)你在終端上按下一個鍵時會發(fā)生什么?
我對終端Terminal是怎么回事困惑了很久。
但在上個星期,我使用 ??xterm.js??? 在瀏覽器中顯示了一個交互式終端,我終于想到要問一個相當(dāng)基本的問題:當(dāng)你在終端中按下鍵盤上的一個鍵(比如 ??Delete?
??,或 ??Escape?
??,或 ??a?
?),發(fā)送了哪些字節(jié)?
像往常一樣,我們將通過做一些實驗來回答這個問題,看看會發(fā)生什么 : )
遠(yuǎn)程終端是非常古老的技術(shù)
首先,我想說的是,用 ??xterm.js?
? 在瀏覽器中顯示一個終端可能看起來像一個新事物,但它真的不是。在 70 年代,計算機很昂貴。因此,一個機構(gòu)的許多員工會共用一臺電腦,每個人都可以有自己的 “終端” 來連接該電腦。
例如,這里有一張 70 年代或 80 年代的 VT100 終端的照片。這看起來像是一臺計算機(它有點大!),但它不是 —— 它只是顯示實際計算機發(fā)送的任何信息。
DEC VT100終端
當(dāng)然,在 70 年代,他們并沒有使用 Websocket 來做這個,但來回發(fā)送的信息的方式和當(dāng)時差不多。
(照片中的終端是來自西雅圖的 ??活電腦博物館???Living Computer Museum,我曾經(jīng)去過那里,并在一個非常老的 Unix 系統(tǒng)上用 ??ed?
? 編寫了 FizzBuzz,所以我有可能真的用過那臺機器或它的一個兄弟姐妹!我真的希望活電腦博物館能再次開放,能玩到老式電腦是非??岬摹#?/p>
發(fā)送了什么信息?
很明顯,如果你想連接到一個遠(yuǎn)程計算機(用 ??ssh?
?? 或使用 ??xterm.js?
? 和 Websocket,或其他任何方式),那么需要在客戶端和服務(wù)器之間發(fā)送一些信息。
具體來說:
客戶端 需要發(fā)送用戶輸入的鍵盤信息(如 ??ls -l?
?)。 服務(wù)器 需要告訴客戶端在屏幕上顯示什么。
讓我們看看一個真正的程序,它在瀏覽器中運行一個遠(yuǎn)程終端,看看有哪些信息會被來回發(fā)送!
我們將使用 goterm 來進行實驗
我在 GitHub 上發(fā)現(xiàn)了這個叫做 ??goterm??? 的小程序,它運行一個 Go 服務(wù)器,可以讓你在瀏覽器中使用 ??xterm.js?
? 與終端進行交互。這個程序非常不安全,但它很簡單,很適合學(xué)習(xí)。
我 ??復(fù)刻了它???,使它能與最新的 ??xterm.js?
? 一起工作,因為它最后一次更新是在 6 年前。然后,我添加了一些日志語句,以打印出每次通過 WebSocket 發(fā)送/接收的字節(jié)數(shù)。
讓我們來看看在幾個不同的終端交互過程中的發(fā)送和接收情況吧!
示例:ls
首先,讓我們運行 ??ls?
??。下面是我在 ??xterm.js?
? 終端上看到的情況:
~:/play$ ls
file
~:/play$
以下是發(fā)送和接收的內(nèi)容:(在我的代碼中,我記錄了每次客戶端發(fā)送的字節(jié):??sent: [bytes]?
??,每次它從服務(wù)器接收的字節(jié):??recv: [bytes]?
?)
sent: "l"
recv: "l"
sent: "s"
recv: "s"
sent: "\r"
recv: "\r\n\x1b[?2004l\r"
recv: "file\r\n"
recv: "\x1b[~:/play$ "
我在這個輸出中注意到 3 件事:
- 回顯:客戶端發(fā)送?
?l?
??,然后立即收到一個??l?
?? 發(fā)送回來。我想這里的意思是,客戶端真的很笨 —— 它不知道當(dāng)我輸入??l?
?? 時,我想讓??l?
? 被回顯到屏幕上。它必須由服務(wù)器進程明確地告訴它來顯示它。 - 換行:當(dāng)我按下回車鍵時,它發(fā)送了一個?
?\r'(回車)符號,而不是?
?\n'(換行)。 - 轉(zhuǎn)義序列:?
?\x1b?
?? 是 ASCII 轉(zhuǎn)義字符,所以??\x1b[?2004h?
? 是告訴終端顯示什么或其他東西。我想這是一個顏色序列,但我不確定。我們稍后會詳細(xì)討論轉(zhuǎn)義序列。
好了,現(xiàn)在我們來做一些稍微復(fù)雜的事情。
示例:Ctrl+C
接下來,讓我們看看當(dāng)我們用 ??Ctrl+C?
? 中斷一個進程時會發(fā)生什么。下面是我在終端中看到的情況:
~:/play$ cat
^C
~:/play$
而這里是客戶端發(fā)送和接收的內(nèi)容。
sent: "c"
recv: "c"
sent: "a"
recv: "a"
sent: "t"
recv: "t"
sent: "\r"
recv: "\r\n\x1b[?2004l\r"
sent: "\x03"
recv: "^C"
recv: "\r\n"
recv: "\x1b[?2004h"
recv: "~:/play$ "
當(dāng)我按下 ??Ctrl+C?
?? 時,客戶端發(fā)送了 ??\x03?
??。如果我查 ASCII 表,??\x03?
?? 是 “文本結(jié)束”,這似乎很合理。我認(rèn)為這真的很酷,因為我一直對 ??Ctrl+C?
?? 的工作原理有點困惑 —— 很高興知道它只是在發(fā)送一個 ??\x03?
? 字符。
我相信當(dāng)我們按 ??Ctrl+C?
?? 時,??cat?
?? 被中斷的原因是服務(wù)器端的 Linux 內(nèi)核收到這個 ??\x03?
?? 字符,識別出它意味著 “中斷”,然后發(fā)送一個 ??SIGINT?
? 到擁有偽終端的進程組。所以它是在內(nèi)核而不是在用戶空間處理的。
示例:Ctrl+D
讓我們試試完全相同的事情,只是用 ??Ctrl+D?
?。下面是我在終端看到的情況:
~:/play$ cat
~:/play$
而這里是發(fā)送和接收的內(nèi)容:
sent: "c"
recv: "c"
sent: "a"
recv: "a"
sent: "t"
recv: "t"
sent: "\r"
recv: "\r\n\x1b[?2004l\r"
sent: "\x04"
recv: "\x1b[?2004h"
recv: "~:/play$ "
它與 ??Ctrl+C?
?? 非常相似,只是發(fā)送 ??\x04?
?? 而不是 ??\x03?
??。很好!??\x04?
? 對應(yīng)于 ASCII “傳輸結(jié)束”。
Ctrl + 其它字母呢?
接下來我開始好奇 —— 如果我發(fā)送 ??Ctrl+e?
?,會發(fā)送什么字節(jié)?
事實證明,這只是該字母在字母表中的編號,像這樣。
- ?
?Ctrl+a?
? => 1 - ?
?Ctrl+b?
? => 2 - ?
?Ctrl+c?
? => 3 - ?
?Ctrl+d?
? => 4 - ...
- ?
?Ctrl+z?
? => 26
另外,??Ctrl+Shift+b?
?? 的作用與 ??Ctrl+b?
?? 完全相同(它寫的是??0x2?
?)。
鍵盤上的其他鍵呢?下面是它們的映射情況:
- ?
?Tab?
?? -> 0x9(與??Ctrl+I?
? 相同,因為 I 是第 9 個字母) - ?
?Escape?
?? ->??\x1b?
? - ?
?Backspace?
?? ->??\x7f?
? - ?
?Home?
?? ->??\x1b[H?
? - ?
?End?
?? ->??\x1b[F?
? - ?
?Print Screen?
?? ->??\x1b\x5b\x31\x3b\x35\x41?
? - ?
?Insert?
?? ->??\x1b\x5b\x32\x7e?
? - ?
?Delete?
?? ->??\x1b\x5b\x33\x7e?
? - 我的?
?Meta?
? 鍵完全沒有作用
那 ??Alt?
?? 呢?根據(jù)我的實驗(和一些搜索),似乎 ??Alt?
?? 和 ??Escape?
?? 在字面上是一樣的,只是按 ??Alt?
?? 本身不會向終端發(fā)送任何字符,而按 ??Escape?
? 本身會。所以:
- ?
?alt + d?
?? =>??\x1bd?
?(其他每個字母都一樣) - ?
?alt + shift + d?
?? =>??\x1bD?
?(其他每個字母都一樣) - 諸如此類
讓我們再看一個例子!
示例:nano
下面是我運行文本編輯器 ??nano?
? 時發(fā)送和接收的內(nèi)容:
recv: "\r\x1b[~:/play$ "
sent: "n" [[]byte{0x6e}]
recv: "n"
sent: "a" [[]byte{0x61}]
recv: "a"
sent: "n" [[]byte{0x6e}]
recv: "n"
sent: "o" [[]byte{0x6f}]
recv: "o"
sent: "\r" [[]byte{0xd}]
recv: "\r\n\x1b[?2004l\r"
recv: "\x1b[?2004h"
recv: "\x1b[?1049h\x1b[22;0;0t\x1b[1;16r\x1b(B\x1b[m\x1b[4l\x1b[?7h\x1b[39;49m\x1b[?1h\x1b=\x1b[?1h\x1b=\x1b[?25l"
recv: "\x1b[39;49m\x1b(B\x1b[m\x1b[H\x1b[2J"
recv: "\x1b(B\x1b[0;7m GNU nano 6.2 \x1b[44bNew Buffer \x1b[53b \x1b[1;123H\x1b(B\x1b[m\x1b[14;38H\x1b(B\x1b[0;7m[ Welcome to nano. For basic help, type Ctrl+G. ]\x1b(B\x1b[m\r\x1b[15d\x1b(B\x1b[0;7m^G\x1b(B\x1b[m Help\x1b[15;16H\x1b(B\x1b[0;7m^O\x1b(B\x1b[m Write Out \x1b(B\x1b[0;7m^W\x1b(B\x1b[m Where Is \x1b(B\x1b[0;7m^K\x1b(B\x1b[m Cut\x1b[15;61H"
你可以看到一些來自用戶界面的文字,如 “GNU nano 6.2”,而這些 ??\x1b[27m?
? 的東西是轉(zhuǎn)義序列。讓我們來談?wù)勣D(zhuǎn)義序列吧!
ANSI 轉(zhuǎn)義序列
上面這些 ??nano?
?? 發(fā)給客戶端的 ??\x1b[?
?? 東西被稱為“轉(zhuǎn)義序列”或 “轉(zhuǎn)義代碼”。這是因為它們都是以 “轉(zhuǎn)義”字符 ??\x1b?
?? 開頭。它們可以改變光標(biāo)的位置,使文本變成粗體或下劃線,改變顏色,等等。??維基百科介紹了一些歷史??,如果你有興趣的話可以去看看。
舉個簡單的例子:如果你在終端運行
echo -e '\e[0;31mhi\e[0m there'
它將打印出 “hi there”,其中 “hi” 是紅色的,“there” 是黑色的。??本頁?? 有一些關(guān)于顏色和格式化的轉(zhuǎn)義代碼的例子。
我認(rèn)為有幾個不同的轉(zhuǎn)義代碼標(biāo)準(zhǔn),但我的理解是,人們在 Unix 上使用的最常見的轉(zhuǎn)義代碼集來自 VT100(博客文章頂部圖片中的那個老終端),在過去的 40 年里沒有真正改變。
轉(zhuǎn)義代碼是為什么你的終端會被搞亂的原因,如果你 ??cat?
?? 一些二進制數(shù)據(jù)到你的屏幕上 —— 通常你會不小心打印出一堆隨機的轉(zhuǎn)義代碼,這將搞亂你的終端 —— 如果你 ??cat?
?? 足夠多的二進制數(shù)據(jù)到你的終端,那里一定會有一個 ??0x1b?
? 的字節(jié)。
可以手動輸入轉(zhuǎn)義序列嗎?
在前面幾節(jié)中,我們談到了 ??Home?
?? 鍵是如何映射到 ??\x1b[H?
?? 的。這 3 個字節(jié)是 ??Escape + [ + H?
??(因為 ??Escape?
?? 是??\x1b?
?)。
如果我在 ??xterm.js?
?? 終端手動鍵入 ??Escape?
?? ,然后是 ??[?
??,然后是 ??H?
??,我就會出現(xiàn)在行的開頭,與我按下 ??Home?
? 完全一樣。
我注意到這在我的電腦上的 Fish shell 中不起作用 —— 如果我鍵入 ??Escape?
??,然后輸入 ??[?
??,它只是打印出 ??[?
??,而不是讓我繼續(xù)轉(zhuǎn)義序列。我問了我的朋友 Jesse,他寫過 ??一堆 Rust 終端代碼??,Jesse 告訴我,很多程序為轉(zhuǎn)義代碼實現(xiàn)了一個 超時 —— 如果你在某個最小的時間內(nèi)沒有按下另一個鍵,它就會決定它實際上不再是一個轉(zhuǎn)義代碼了。
顯然,這在 Fish shell 中可以用 ??fish_escape_delay_ms?
?? 來配置,所以我運行了 ??set fish_escape_delay_ms 1000?
?,然后我就能用手輸入轉(zhuǎn)義代碼了。工作的很好!
終端編碼有點奇怪
我想在這里暫停一下,我覺得你按下的鍵被映射到字節(jié)的方式是非常奇怪的。比如,如果我們今天從頭開始設(shè)計按鍵的編碼方式,我們可能不會把它設(shè)置成這樣:
- ?
?Ctrl + a?
?? 和??Ctrl + Shift + a?
? 做的事情完全一樣。 - ?
?Alt?
?? 與??Escape?
? 是一樣的 - 控制序列(如顏色/移動光標(biāo))使用與?
?Escape?
?? 鍵相同的字節(jié),因此你需要依靠時間來確定它是一個控制序列還是用戶只是想按??Escape?
?。
但所有這些都是在 70 年代或 80 年代或什么時候設(shè)計的,然后需要永遠(yuǎn)保持不變,以便向后兼容,所以這就是我們得到的東西 :smiley:
改變窗口大小
在終端中,并不是所有你能做的事情都是通過來回發(fā)送字節(jié)發(fā)生的。例如,當(dāng)終端被調(diào)整大小時,我們必須以不同的方式告訴 Linux 窗口大小已經(jīng)改變。
下面是 ??goterm?? 中用來做這件事的 Go 代碼的樣子:
syscall.Syscall(
syscall.SYS_IOCTL,
tty.Fd(),
syscall.TIOCSWINSZ,
uintptr(unsafe.Pointer(&resizeMessage)),
)
這是在使用 ??ioctl?
?? 系統(tǒng)調(diào)用。我對 ??ioctl?
? 的理解是,它是一個系統(tǒng)調(diào)用,用于處理其他系統(tǒng)調(diào)用沒有涉及到的一些隨機的東西,通常與 IO 有關(guān),我猜。
??syscall.TIOCSWINSZ?
?? 是一個整數(shù)常數(shù),它告訴 ??ioctl?
? 我們希望它在本例中做哪件事(改變終端的窗口大?。?。
這也是 xterm 的工作方式。
在這篇文章中,我們一直在討論遠(yuǎn)程終端,即客戶端和服務(wù)器在不同的計算機上。但實際上,如果你使用像 xterm 這樣的終端模擬器,所有這些工作方式都是完全一樣的,只是很難注意到,因為這些字節(jié)并不是通過網(wǎng)絡(luò)連接發(fā)送的。
文章到此結(jié)束啦
關(guān)于終端,肯定還有很多東西要了解(我們可以討論更多關(guān)于顏色,或者原始與熟化模式,或者 Unicode 支持,或者 Linux 偽終端界面),但我將在這里停止,因為現(xiàn)在是晚上 10 點,這篇文章有點長,而且我認(rèn)為我的大腦今天無法處理更多關(guān)于終端的新信息。