终端 Terminal 的一些实现细节分享

在日常开发中,相信很多小伙伴好奇过以下一些问题,尤其是经常使用命令行工具的…

  • 为什么 AndroidStudio/IDEA 能打印各种颜色的日志
  • 各种 CLI 在执行时,底下的进度条是怎么实现的
  • Vim 在 Terminal 中是如何做到清屏和恢复屏幕的
  • StartAgent 运维平台的 Web Terminal 是怎么做到跟 Terminal 类似的
  • SpringBoot 启动 Logo 是怎么打印出来的

最近在建设云真机平台时,为了便于对 宿主机服务器 & Android设备 的控制,增加了Web Terminal 的功能,通过终端指令控制设备,包含 Web SSH、Android 远程 Shell等,实现过程还蛮有趣的,所以也重新整理了一下 Terminal 的一些细节原理。

终端(Terminal)

通常来讲,操作系统分为两部分,内核 和 用户交互界面。

内核

支持各种命令,负责操作系统底层复杂的操作

用户交互界面

展示在屏幕的交互界面,例如各种应用程序窗口

终端

负责输入输出,为内核和用户交互界面搭桥,我们通常说的终端(Terminal),是指处理终端指令的应用程序,比如 Mac的Terminal、Windows的cmd.exe,实际上是终端命令行界面,在图形界面普及之前使用最广泛,通常不支持鼠标操作,用户通过键盘输入指令,Shell翻译并交给系统执行。正因为通过终端,可以轻易的和内核通信,即便Linux系统无图形界面,也不妨碍它成为优秀的操作系统。

Shell

Shell常常运行在终端中,是操作系统内核和用户交互的接口程序,可以理解为命令行解释器。例如 Bash、Zsh 都属于 Shell。Linux、Mac早期版本自带的 Shell 就是 bash,正常情况下 Terminal 输入的指令,是交给 bash 执行。

那为什么 Terminal 可以执行 ll 指令,但是 bash 却执行不了呢?原因是 ll 并不是一个标准的 bash 指令,而是 Terminal 在 bash 之上做的扩展,定义了一个别名(alias),实际上相当于执行 ls -l ,所以我们也可以在 bash 里定义一个别名。

macOS 从 Catalina 版开始,使用 Zsh 作为默认 Shell,Zsh 比 bash 有更好的交互体验和兼容性,比如从上图也可以看出来,Zsh 可以设置丰富多彩的颜色。

控制字符&转义序列

控制字符

在 ASCII 码表中,前32个字符编码是不能用于打印的,而是用于控制终端行为,这些字符被称为控制字符。如下所示:

例如我们常用的控制字符有:

1
2
3
4
\n(换行):将光标移动到下一行的开头。
\r(回车):将光标移动到当前行的开头。
\b(退格):将光标向后移动一个字符。
\t(制表符):将光标向右移动到下一个制表符位置。

从图上可以看出,例如Tab 键的 ASCII 码为 9 ,可以用 \t 表示;Enter键的 ASCII 码为 10,可以用 \n 表示。

转义字符

转义字符是以反斜杠(\)开头的字符序列,它们表示一些特殊的字符。转义字符可以用于在字符串中插入一些特殊的字符,例如引号或换行符。

以下是一些常见的转义字符及其作用:

1
2
3
4
5
\'(单引号):用于在字符串中插入一个单引号。
"\(双引号):用于在字符串中插入一个双引号。
\\(反斜杠):用于在字符串中插入一个反斜杠。
\n(换行):用于表示一个换行符。
\t(制表符):用于表示一个制表符。

ANSI 转义序列

ANSI 转义序列是由 ESC (ASCII 码表中的 27)开头的一些字符序列,用于表示一些特殊的字符或控制序列。转义序列通常用于在终端中显示一些特殊的效果,例如颜色、光标位置等。

转义序列通常以 \033[……m 开头,以 \033[0m 结尾。

例如我们通过 Python 打印一些内容:

1
print("\033[31;1;4mHello\033[0m")

或者通过 Java 在控制台打印一些内容:

1
2
System.out.println("\u001b[30mHello\u001b[0m");
System.out.println("\u001b[31;1;4mHello\u001b[0m")

或者通过 JS 在浏览器控制台里打印一些内容:

1
2
console.log("\u001b[91mHello\u001b[0m")
console.log("\u001b[96;1;3mHello\u001b[0m")

或者直接在终端通过 Shell 脚本打印:

1
2
echo "\u001b[91mHello\u001b[0m" 
echo "\u001b[96;1;3mHello\u001b[0m"

样式说明

\033、\x1b、\u001b 三种是一样的,分别代表 8进制、16进制、unicode 16进制 的 27,也就是对应上面提到的 ESC 的 ASCII 码。

左边部分的31代表红色 1代表粗体 4代表下划线

右边部分的0代表清空第一部分的所有效果,如果不清空,则样式会一直延续,如下所示:

当然也可为文字添加背景色,有以下两种形式:

1
2
3
\u001b[前景色代码;背景色代码m

\u001b[前景色代码m\u001b[背景色代码m

例如:

1
2
3
4
5
6
# \u001b[黑字;绿底m
print("\u001b[37m\u001b[42mHello\u001b[0m")
# \u001b[黑字;灰底m
print("\u001b[30;47mHello\u001b[0m")
# \u001b[黑字;灰底;加粗;下划线m
print("\u001b[30;47;1;4mHello\u001b[0m")

效果如下所示:

3/4位颜色表示法

起初ANSI只支持3位表示法,也就是只有 2^3=8 种。

07:设置字体
30
37:设置前景色
40~47:设置背景色

后来为了实现更明亮的字体,扩展到4位,有 2^4=16 种。

07:设置字体
30
37 9097:设置前景色
40
47 100~107:设置背景色

1
2
3
4
print("\u001b[91mHello\u001b[0m")
print("\u001b[96mHello\u001b[0m")
# \u001b[亮青字;亮黄底m
print("\u001b[96m\u001b[103mHello\u001b[0m")

每一种终端对颜色代码的实现效果上有一些差异。颜色代码对照表如下所示:

8位颜色表示法

随着显卡支持256色查找表,相应的转义序列也增加到 2^8=256 种。

015: 标准颜色 & 高强度色
16
231: 216种 RGB 颜色
232~255: 24种灰度色

1
2
3
print("\u001b[48;5;177mHello\u001b[0m")
print("\u001b[38;5;177mHello\u001b[0m")
print("\u001b[38;5;177m\u001b[48;5;159mHello\u001b[0m")

8位颜色对照表如下所示:

24位颜色表示法

后来”真彩色“显卡普及,支持24位颜色,转义序列也增加到 2^24=16777216 种,也就是我们常用的 RGB 颜色。
但是 Mac 的终端目前不支持,浏览器控制台支持。

1
2
3
console.log("\u001b[38;2;255;0;0mHello\u001b[0m")
console.log("\u001b[38;2;255;0;0;1;3mHello\u001b[0m")
console.log("\u001b[38;2;255;255;0;1;3m\u001b[48;2;0;0;255mHello\u001b[0m")

光标移动

前面提到 ANSI 转义序列,只是控制了颜色,实际上转义序列还有其他许多能力,例如光标移动,转义序列如下:

上: \u001b[{n}A

下: \u001b[{n}B

右: \u001b[{n}C

左: \u001b[{n}D

注:{n} 表示移动n个字符

通常来讲,我们打印内容时如果不做特殊处理,光标会自动移动到最后,例如:

那如果想在打印进入的时候,不断刷新前面的进度数字呢?那就需要依靠光标移动,例如:

1
2
3
4
5
6
7
8
9
10
11
12
import time, sys

def loading():
print("Loading...")
for i in range(0, 100):
time.sleep(0.05)
sys.stdout.write(u"\u001b[1000D" + str(100-i + 1) + "%")
sys.stdout.flush()
print("\nDone!")


loading()

打印 1%~100% 进度的时候,通过 sys.stdout.write 标准输出,打印在同一行,在每次打印进度之前,通过 \u001b[1000D 指令,把光标移动到行首,之后打印的值会覆盖前面的。但这里有其实个细节,比如光标移动到行首并打印3个字符,就只会覆盖前三个字符,假设原来这一行里面有4个字符,那么第4个字符就不会被清除。不过这里 1-100 刚好是字符数越变越大,所以也没问题。

进度条光展示数字显然不够骚气,平时各种 CLI 命令行的进度条是怎么实现的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
import time, sys

def loading():
print("Loading...")
for i in range(0, 100):
time.sleep(0.05)
width = int((i + 1) / 4)
bar = "[" + "▆" * width + " " * (25 - width) + "]" # ▆表示当前进度 空格表示其余部分
sys.stdout.write(u"\u001b[1000D" + bar)
sys.stdout.flush()
print("\nDone!")

loading()


每次根据当前进度,光标移到行首,覆盖打印对应的小方块,其余部分打印空格。

很多时候,CLI 背后可能在同时执行多个任务,那么通过光标移动也可以实现同时打印多个进度条,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import time, sys, random

def loading(count):
all_progress = [0] * count
sys.stdout.write("\n" * count)
while any(x < 100 for x in all_progress): # 等待所有进度条 100%
time.sleep(0.01)
# 随机为其中一个还没完成的进度条 进度+1
unfinished = [(i, v) for (i, v) in enumerate(all_progress) if v < 100]
index, _ = random.choice(unfinished)
all_progress[index] += 1
# 光标向左移动到行首
sys.stdout.write(u"\u001b[1000D")
# 光标向上移动到对应进度条的那一行
sys.stdout.write(u"\u001b[" + str(count) + "A")
for i in range(len(all_progress)):
progress = all_progress[i]
width = int(progress / 4)
# 打印对应进度
print("progress" + str(i + 1) + ": [" + "▇" * width + " " * (25 - width) + "]")
print("Done!")

loading(3)

自定义命令行工具

如何自定义一个命令行工具?核心就是 接收输入数据->交给系统执行->打印输出->继续接收输入数据…..
前面提到,当键盘摁下的键属于控制字符时(ASCII码 < 32),例如 回车、删除、方向键、Tab键,不做特殊处理是无法识别成对应操作的,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import sys, tty

def command_line():
tty.setraw(sys.stdin)
while True:
input_text = ""
index = 0
while True:
char = ord(sys.stdin.read(1))

if char == 3: # CTRL + C
return
elif 32 <= char <= 126: # 正常字符输入
input_text = input_text[:index] + chr(char) + input_text[index:]
index += 1
elif char in {10, 13}: # 回车换行
sys.stdout.write(u"\u001b[1000D") # 光标回到行首
print("\nechoing... ", input_text)
input_text = ""

sys.stdout.write(u"\u001b[1000D")
sys.stdout.write(input_text)
sys.stdout.flush()

command_line()

在接收输入的时候,只处理了 CTRL+C 和 ENTER 键,当我们摁下方向键时,上下左右 就会变成 [A[B[D[C,这样就无法实现光标移动,输入数据插入到前面的效果。

解析方向键&删除键

从输入的字符里面,解析是否是方向键或删除键(这里没有处理 上键和下键,一般对应获取历史输入的命令)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import sys, tty


def command_line():
tty.setraw(sys.stdin)
while True:
input_text = ""
index = 0
while True:
char = ord(sys.stdin.read(1))

if char == 3: # CTRL + C
return
elif 32 <= char <= 126: # 正常字符输入
input_text = input_text[:index] + chr(char) + input_text[index:]
index += 1
elif char in {10, 13}: # 回车换行
sys.stdout.write(u"\u001b[1000D") # 光标回到行首
print("\nechoing... ", input_text)
input_text = ""
index = 0
elif char == 27:
next1, next2 = ord(sys.stdin.read(1)), ord(sys.stdin.read(1))
if next1 == 91:
if next2 == 68: # 左方向键ANSI:\u001b[D 27表示ESC,91表示[ 68表示D
index = max(0, index - 1)
elif next2 == 67: # 右方向键ANSI:\u001b[D 27表示ESC,91表示[ 67表示C
index = min(len(input_text), index + 1)
elif char == 127: # 退格键
input_text = input_text[:index - 1] + input_text[index:]
index -= 1
sys.stdout.write(u"\u001b[1000D") # 光标移动到行首
sys.stdout.write(u"\u001b[0K") # 清除当前行
sys.stdout.write(input_text) # 重新打印当前行的数据
sys.stdout.write(u"\u001b[1000D") # 移动光标到行首
if index > 0:
sys.stdout.write(u"\u001b[" + str(index) + "C") # 移动光标到当前位置
sys.stdout.flush()


command_line()

可能涉及的其他常用指令:

  • 清除屏幕:\u001b[{n}J
    • n=0:清除光标到屏幕末尾的所有字符。
    • n=1:清除屏幕开头到光标的所有字符。
    • n=2:清除整个屏幕的字符。
  • 清除行:\u001b[{n}K
    • n=0:清除光标到当前行末所有的字符。
    • n=1:清除当前行到光标的所有字符。
    • n=2:清除当前行。
  • 光标按行向下移动:\u001b[{n}E 将光标向下移动n行并且将光标移至行首。
  • 光标按行向上移动:\u001b[{n}F 将光标向上移动n行并且将光标移至行首。
  • 设置光标所在列:\u001b[{n}G 将光标移至当前行的第n列。
  • 设置光标所在位置:\u001b[{n};{m}H 将光标移至第n行m列。

Vim 清屏实现原理

保存/恢复 Terminal屏幕:通过 smcup、rmcup 控制码
smcup (Start Cursor Position) 用于启用终端屏幕上的复杂图形和窗口环境。当 smcup 被发送到终端时,终端会将当前屏幕内容保存到缓冲区中,并进入图形环境。
rmcup (Reset Cursor Position) 用于禁用终端屏幕上的复杂图形和窗口环境。当 rmcup 被发送到终端时,终端会从缓冲区中恢复上一个屏幕状态,结束图形环境并返回到普通终端模式。

以下通过 Shell 脚本模拟一个类似 Vim 清屏->输入数据->恢复屏幕 的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# !/bin/bash  

# 保存屏幕内容
tput smcup
# 清除屏幕内容
clear

read -p "Enter your name:" name # 等待输入信息

# 输入完成,按任意键结束
read -n1 -p "Press any key to continue..."

# 恢复屏幕内容
tput rmcup

Tab 键自动补全原理

在 Terminal 中,Tab 键可以用于自动补全命令和文件名。当用户在 Terminal 中输入部分命令或文件名并按下 Tab 键时,终端程序会根据当前所在目录以及用户输入的内容,搜索与之匹配的命令和文件名,并在屏幕上显示匹配的结果。

摁下Tab键之后,如果有多个匹配项,继续摁Tab可以实现在这些匹配项里面顺序选择。里面实际上也是不断修改输入的内容,调整光标位置,当前选中的项加上背景色。

SpringBoot 启动广告如何实现


这个跟 Terminal 关系不大,其实就是一种艺术字符号转换。
在线生成链接,里面有很多花里胡哨的字体:https://tooltt.com/art-ascii/

生成后复制下来保存到文件(因为有些艺术字包含特殊字符 直接放到代码里会报错)里,读取并打印到控制台。也可通过前面提到的 ANSI 为其加上字体颜色。

1
2
3
4
5
import os.path

if __name__ == "__main__":
print("\033[5;36m{}\033[0m".format(open(os.path.dirname(__file__) + "/banner", 'r').read()))
# ...