第一次打开终端:提示符那行字在说什么

难度 ★☆☆☆☆ · 前置:Linux 是什么,以及你为什么老是装不上软件 | 一句话结论:提示符里的 user@host:~# 是 shell 拼出来的字符串而不是某种状态;路径只有绝对、相对、./.. 三条规则;Ctrl-C 和 kill 不是同一件事,前者由 tty 层产生信号。

我第一次用 SSH 连服务器是入职前照着教程试的,密码输完,屏幕刷出一行 root@web-01:~#,光标在后面闪,除此之外什么都没有。我愣了几秒,试着敲了 ls,回车,出来一堆文件名,然后又回到那一行。

当时我心里是有点发虚的。前面这些字是谁打的,为什么是 # 而不是 $,为什么换个教程截图里又是 $。这些问题当时我一个都没敢问,因为看起来太蠢了,蠢到像是应该自己知道的。后来我发现不是,它们背后是四件不相关的事被塞进了同一个字符串里,而且其中一件(最后那个 #)如果看漏了,删库事故就发生在你身上。我在很长一段时间里都把那行字当成一个整体,就像看红绿灯只看颜色不看是哪个方向。

那一行字,四个字段

拿 root@web-01:~# 拆,四段各管一摊:

片段 含义 从哪来
root 当前用户名 $USER,底下一层是 uid
web-01 主机名短名 hostname,等同 uname -n
~ 当前目录,~ 是家目录缩写 $PWD
# # 是 root,$ 是普通用户 PS1 里的 \$

最后那个符号值得单独说,因为它不是装饰,是当前权限的即时提示。同一个人、同一台机器,su 切到 root 之后它就变成 #,exit 回来又变回 $,这套切换是 PS1 里的一个转义 \$ 自己算的,不是你手动改的。很多删库事故的第一现场,是当事人根本不知道这个字符会变。

普通用户登进去长这样,alice@web-01:/etc/nginx$,目录段变成了完整路径。注意 ~ 只在当前目录恰好是家目录时才显示,它不是家目录的固定写法,是 bash 的一种省略($HOME 一样的时候用 ~,不一样就老实打印全路径)。

想看自己的提示符怎么配的,直接问变量就行:

1
echo "$PS1"

Ubuntu 默认输出大概是这么一坨:

1
\[\e]0;\u@\h: \w\a\]${debian_chroot:+($debian_chroot)}\u@\h:\w\$

\u 用户名、\h 主机名、\w 完整路径、\$ 那个会自己切换的 #/$。最前面 \e]0;...\a 那段跟正文无关,它是给终端窗口标题栏设文字的。这一段我当初学的时候被绕进去很久,因为它看起来像乱码混进了提示符,而网上讲 PS1 的文章大多懒得提它。

你敲的命令,shell 去哪找

ls 这个字符串敲进去,shell 得先把它变成一个可以执行的文件,规则写在 PATH 里:

1
echo "$PATH"
1
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

(这是桌面终端里的样子。SSH 登录进去的话通常不含 /sbin 和 /usr/sbin,为什么,后面讲目录树那篇会说。)

冒号分隔,从左往右扫,第一个撞上的就是它。type 能告诉你当前这个名字到底指向哪,而且它比 which 诚实:

1
type -a ls
1
2
3
ls is aliased to `ls --color=auto'
ls is /usr/bin/ls
ls is /bin/ls

type -a 会把所有候选都列出来,从前往后就是优先级,所以上面这个输出的意思是,你敲 ls 实际跑的是一个别名,别名的本体在 /usr/bin/ls。顺带一句,/bin 和 /usr/bin 在很多发行版里已经是同一个目录了,那是后面目录树那篇的话题。

这里有个新手必踩的坑,PATH 里没有 .,所以你写 run.sh 会报 command not found,得写 ./run.sh:

1
2
3
printf '#!/bin/bash\necho hi\n' > run.sh
chmod +x run.sh # 不 chmod 的话 ./run.sh 会报 Permission denied
./run.sh

为什么这么设计,假设 PATH 里有当前目录,你在 /tmp 下解开一个别人给的压缩包,里面有 ls、cd,你敲 ls,跑的是他的 ls。这在早期 Unix 上是真被利用过的攻击路径(那个年代还流行互相 write 到别人终端上恶作剧),所以后来的默认设置干脆就不给。./ 的作用就是绕过 PATH,明确告诉 shell 就在当前目录找这个文件。这个设计我第一次听说的时候觉得有点小题大做,毕竟谁会傻到在别人的目录里敲 ls。后来自己给项目写部署脚本,PATH 里加了个 ./bin 方便调试,过了一个月完全忘了这回事,再在 ~/Downloads 里解压一个来路不明的包、顺手敲了个 make,那种后背发凉的感觉记到现在。规则不是给小心的人准备的,是给忘了的人准备的,而人一定会忘。

路径只有三条规则

这一层看着复杂,其实规则少得可怜:

  • 以 / 开头的,是绝对路径,从根开始,/var/log/nginx
  • 不以 / 开头的,是相对路径,从当前目录开始,log/nginx
  • . 是当前目录,.. 是上一级,就这两个,没有例外

另外三个符号属于一年用不到几次、但看到别慌的那种:

写法 意思
~ 你的家目录
~alice alice 的家目录
- 上一个目录,只在 cd 里有效

cd - 有个小细节,它会把切换后的路径打印出来:

1
2
3
$ cd /tmp
$ cd -
/home/alice

因为这行输出,cd - 比手敲 cd .. 好检查,你能看见自己到底跳到哪了。我现在的习惯是目录跳多了之后一律用 cd - 在两个工作目录之间来回,比记路径可靠。

Tab 的价值不在省打字

按 Tab 补全谁都会,但有三个细节比这重要得多。第一,按一下没补全说明有歧义,按两下会把候选全列出来;第二,它补的不只是文件名,命令、用户名、主机名都补,ssh ro<Tab> 能补出 root,这一步在记不住机器上的用户名时很省事。

真正要命的是第三条,补全的结果当场就展开在屏幕上了,不是执行时才展开:

1
2
$ rm -rf /tmp/foo<Tab>
$ rm -rf /tmp/foo/

中间那一下,是你最后一次看清要删的是一批文件还是一个目录。rm -rf /tmp/foo* 和 rm -rf /tmp/foo/ 只差一个字符,后果差得很远,而 Tab 就是把那个字符提前摆到你眼前。刚学的时候我还嫌它慢,觉得手敲路径更有掌控感,直到有次在 ~/work 下面清空一个目录,按了 Tab 发现展开出来一串我压根没见过的目录名,才意识到自己其实站在哪都不确定。从那天起我养了个习惯,所有删东西的命令执行前先 pwd 一下,多花半秒,换来的确定感值这个价。

三个按键,三件不同的事

新手最容易在这里翻车,因为 Ctrl-C、Ctrl-D、Ctrl-Z 表面上都像取消,实际是三个毫不相干的机制。

按键 实际发生的事 结果
Ctrl-C 发出字节 \x03,终端驱动把它翻成 SIGINT 中断前台进程,进程可以捕获它做清理
Ctrl-D 发出字节 \x04,被解释成 EOF 输入结束;空行上按等于退出 shell
Ctrl-Z 发出字节 \x1a,翻成 SIGTSTP 挂起前台进程,进程还在,只是停住了

区别摊开说。Ctrl-C 会杀进程,Ctrl-Z 不会,挂起之后进程还活着,状态是 T,fg 拉回前台继续,bg 放到后台继续跑。Ctrl-D 根本不杀进程,它只是告诉读输入的那个程序没有更多了,cat 不带参数时按 Ctrl-D 能退出,是因为 cat 读到 EOF 就正常返回了,不是被杀。而 Ctrl-C 也未必杀得掉,进程可以捕获 SIGINT,可以忽略它,如果它根本没在终端前台跑,终端发的信号也到不了它身上。我最早分不清这三者是因为把它们当成三个快捷键,后来才明白只有 Ctrl-C 和 Ctrl-Z 是 tty 层翻译出来的信号,Ctrl-D 从头到尾就是个文件结束符,压根不在信号体系里。真正让我记住差别的是第一次跑数据库导入,手快按了 Ctrl-Z,进程挂了但连接没断,端口还占着,重启服务一直报 Address already in use,那会儿还不知道 jobs 和 fg 是什么,最后靠 ps 找到那个 T 状态的进程才收拾干净。

想看清这条链,跑一个傻等的东西,sleep 100,然后按 Ctrl-C,屏幕出现 ^C,命令结束。中间的链条是这样的,终端程序把你按的 \x03 交给内核的 tty 层,tty 层看到这个字节、且 ISIG 标志开着,就给前台进程组发 SIGINT,sleep 收到,默认动作是终止。所以 Ctrl-C 从来不是取消输入,它是发信号,这个认知差决定了你以后能不能看懂,为什么我按了好几遍它还不死。

那个 tty 层还能调

上面说的 tty 层把字节翻译成信号这件事,本身是可以关掉的,因为整个翻译规则就是一组终端设置,用 stty 就能看和改:

1
stty -a | head -3
1
2
3
speed 38400 baud; rows 24; columns 80; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = <undef>;
start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V;

intr = ^C 就是哪个字节算中断,susp = ^Z 同理。这些都能改,把中断键换成别的,或者干脆关掉:

1
2
3
stty intr ^X      # 以后 Ctrl-X 才是中断
stty -isig # 彻底关掉信号翻译,Ctrl-C 变成一个普通字节
stty sane # 一键全部恢复,忘了自己改过什么就敲这个

-isig 之后 Ctrl-C 就不再发 SIGINT 了,它只是一个字节 \x03 被送到前台程序,程序爱怎么处理怎么处理。vim 和 less 就是靠这套东西实现自己的键位(顺带说,ssh 也是,它进远程之前会把本地 tty 整个切成 raw 模式,isig、icanon、echo、ixon 一次全关掉,这样你按的 Ctrl-C 是以字符形式传过去、由远端 tty 翻译的,这就是为什么在 ssh 里按 Ctrl-C 不会杀掉 ssh 本身)。

同一层还管三样你迟早会遇到的东西:

设置 作用 典型症状
-echo 别把输入回显到屏幕 输密码时看不到星号
icanon 关掉行缓冲,逐字符读取 按了键不等回车就有反应
SIGTTIN/SIGTTOU 后台进程想读(写)终端时收到的信号 后台跑的程序莫名停住,ps 里是 T

最后那个值得单独记,bg 之后一个需要读终端输入的程序会立刻停住,不是它卡了,是内核发了 SIGTTIN 把它拦下来了。用 stty tostop 可以连写操作也一起拦。

改一次提示符

观察提示符和路径:

1
2
3
4
echo "$PS1"
pwd
cd /tmp && pwd
cd -

看 Ctrl-C 究竟发了什么信号。开两个终端,第一个跑 sleep 100,第二个查它的 PID:

1
pgrep -a sleep
1
48213 sleep 100

回到第一个终端按 Ctrl-C,再在第二个终端查一次,什么都没了。进程是被一个信号终结的,不是被按键本身终结的。

再验证 Ctrl-Z 只是冻结。重新跑 sleep 100,按 Ctrl-Z:

1
2
^Z
[1]+ Stopped sleep 100

Stopped 是关键词,jobs 里也还是它。进程还在,fg 拉回来,或者 kill %1 干掉。这个还在的性质值得记住,跑一个长任务时误按 Ctrl-Z,它不是死了,是冻住了,nohup 和 & 都救不了它,得 bg 让它继续。

终端和 shell 是两个进程

终端就是 shell?不是,终端是窗口程序,shell 是跑在窗口里的那个进程。Ctrl-C 那套是终端和 tty 层处理的,跟 shell 关系不大,shell 这时只是在等你回来。

Ctrl-C 一定能杀掉进程?它发的是 SIGINT,可以被捕获、被忽略。还有一种情况连 kill -9 都无效,进程处于 D 状态,不可中断睡眠,那是进程状态那篇要处理的事。

# 只是样式?它意味着你现在是 root。养成扫一眼的习惯,能省掉几次心跳。

Tab 补全是 shell 干的?大部分情况是 bash 的内建补全,但也可以被 bash-completion 这类包接管,行为会更丰富。到底谁在干活,complete -p 能看出来。

~ 永远是家目录?它是 shell 展开的,展开成 $HOME,$HOME 被改坏,~ 跟着坏。真正的家目录记在 /etc/passwd 里。

终端里没有撤销这回事,rm 删掉的东西不会进任何回收站,这也是为什么上面 Tab 那一条值得花两分钟记住。