不少人在 Linux 里一看到“PS3”三个字母,第一反应是某款游戏主机。但在一线服务器的终端里,PS3 其实是 Bash 中最容易被忽略的一个环境变量:它是select命令循环显示菜单时,提示用户输入编号的那个小尾巴。默认情况下你只会看到一只灰扑扑的#?杵在菜单下面,和 PS1 那套花哨的定制玩法比起来,存在感低得可怜。
真正写过交互式菜单脚本的人都知道,这个#?一旦变成一句友好的“请选择要执行的操作:”,脚本的可用性会立刻上一个台阶。这篇文章就把 PS3 环境变量从头到尾讲透:它和 PS1、PS2、PS4 的分工是什么,默认值从哪里来,怎么自定义成带颜色的动态提示,在封装菜单、处理用户输入、做多级选择时要注意哪些坑,最后是我自己在实际项目里设置 PS3 的一些习惯。
1. 它不是游戏机:先搞清楚 PS3 在 shell 里的位置
1.1 提示符家族:PS1、PS2、PS3、PS4
Linux 用户大概都见过 PS1,就是命令行输入框左边那串包含用户名、路径和符号的内容。PS1 之外,Bash 其实还有 PS2、PS3、PS4,把它们放在一起看,才能理解 PS3 的位置。PS1 是主提示符,每当进入一条新命令时显示;PS2 是续行提示符,当一条命令没写完就回车,Bash 会在下一行给出>,这个>的背景就是 PS2;PS3 则只服务于select命令,在select把选项列表列出来之后,等待用户输入编号的那个提示符;PS4 最少见,它出现在bash -x调试模式下,给每一条跟踪输出行前加前缀,默认是+。
很多人以为 PS3 和 PS1 一样,可以随时决定命令行提示长什么样。其实不是。PS3 只有在执行到select关键字时才会被打印,普通命令行提示符由 PS1 管理,而命令没写完的续行提示由 PS2 管理。四者相互独立,互不覆盖。这也是提示符家族最容易混淆的地方。
这里有一个值得注意的细节:PS3 的默认值并不是空字符串。在我常用的 Bash 版本里,默认 PS3 值是#?,末尾带着一个空格。第一次跑select的人,往往看到的是数字列表下面冒出一个#?,输入数字之后又回到#?,如果不把select循环体后面的分支写好,甚至不知道怎么退出。这就是最原始的 PS3 体验。
| 变量 | 作用阶段 | 默认值 | 常见定制方式 |
|---|---|---|---|
| PS1 | 主提示符 | \s-\v\$ | 路径、时间、颜色、Git 分支等 |
| PS2 | 续行提示符 | > | 缩进、颜色 |
| PS3 | select菜单等待输入 | #? | 文字、颜色、动态值 |
| PS4 | xtrace 调试前缀 | + | 层级、函数名 |
1.2 触发 PS3 的命令:select 到底在做什么
select是 Bash 内置的一个交互式循环结构。光看语法它甚至有点怪:select 变量 in 列表; do 循环体; done。当 Bash 执行到这一行时,会先把列表里的每一项按顺序编号打印出来,每项前面是1)、2)这样的编号,然后打印 PS3 的值作为提示符,等待用户输入。用户输入的如果是列表范围内的数字,变量就被赋值为对应项;如果输入了范围外的数字或乱码,变量会被赋值为空字符串,但循环体还是会执行。正因为这样,select最常见的搭配是case:用户输入什么编号,就执行什么分支。
为了验证 PS3 出现的时机,可以写一个最简单的脚本:
#!/bin/bash select color in red green blue; do echo "selected: $color" done运行后:
1) red 2) green 3) blue #?输入2回车,输出selected: green,然后菜单重新打印,再次出现#?,等待下一次输入。直到你输入 Ctrl+C 或者想办法break,这个循环才会结束。注意,每次循环重新打印菜单之后,那个#?就是 PS3 变量的当前值。
所以select本质上是 Bash 给你造好的一个交互菜单框架,它把编号、打印提示、读输入、赋值这些重复劳动全部包办了。而 PS3 就是那个菜单框架唯一的视觉入口。如果你只在乎菜单好不好看、提示是否友好,不用改select的任何行为,只需要改 PS3。
2. 不设置 PS3 会发生什么:默认值的真实体验
2.1 跑一个最简单 select 菜单
先给一个最朴素的实践。把下面这段保存成demo.sh:
#!/usr/bin/env bash select action in "查看磁盘使用率" "查看内存占用" "退出"; do case $REPLY in 1) df -h ;; 2) free -m ;; 3) break ;; *) echo "请选择一个有效编号" ;; esac done什么环境变量都不设,直接bash demo.sh。屏幕上先显示出三项以数字做前缀的菜单,然后是一个突兀的#?。这里#?就是默认 PS3 的显示。你输入1、2、3会触发对应分支,输入其他数字会走*)分支,输入回车会看到菜单又重新列一遍——因为以空字符串作为 REPLY 时,并没有数字能匹配 case 分支,于是进入*)。整个交互过程里,#?在每一次等待输入前都会出现。
实际用下来,#?太有工程师随手写代码的味道,对普通用户不友好。尤其你的脚本可能要给别人用,对方看到#?的第一反应是:这是什么?我该输什么?本来一个很好的菜单,就卡在一个冷冰冰的提示符上。所以定制 PS3,几乎是我写任何交互式脚本的第一步。
2.2 为什么 Bash 偏偏给 select 单独设计了一个提示符变量
有人会问:为什么不直接复用 PS1?原因很简单:作用域不同。PS1 是用户在命令行敲普通命令时用的提示符,它应该出现在命令行输入的最前面;PS2 是续行提示符,它应该反映后面还有输入。如果select复用了 PS1,你会在菜单下面看到一堆对当前状态毫无意义的路径、时间、Git 分支信息,既干扰选择,又会把“请输入编号”这个真话淹没掉。Bash 把 PS3 独立出来,等于是在告诉你:这是一类特殊的用户输入,给它一个独立的外观。
这和编程里分离关注点的思路一致。菜单提示和命令行提示本质上承担不同的沟通任务:命令行提示需要告诉用户“我在哪一台机器、当前目录是什么”,而菜单提示只需要告诉用户“现在轮到你选择一个编号了”。混在一起,两边都没法达到最佳效果。我见过有开发者把 PS3 配置得和 PS1 一样花哨,菜单里全是路径和时间,反而让人不知道重点是什么。
另外,PS3 和 PS1 还有一个行为上的差异:PS3 虽然在显示前同样会做参数展开和反斜杠转义,但它的默认值约定、空格处理方式、以及在select中的重新展开时机,都和 PS1 不完全一样。这也是为什么不能简单地把 PS1 的配置搬到 PS3 上。单独的变量名,就是为了让它们各自保持自己的语义。
3. 把 PS3 改成你想要的样子
3.1 字符串赋值与空格陷阱
改 PS3 最简单的方法是给它一个字符串,比如:
PS3="请选择要执行的操作:"但注意我写了中文冒号,而且冒号后面故意留了一个空格。为什么要空格?因为 Bash 打印 PS3 后不会自动补空格,它会按变量原样输出。如果你写成请选择:,用户输入1后,屏幕上看起来就像请选择:1,没有明显分隔。加上一个空格或者分隔符,输入区域才能和提示文字分开。默认的#?后面带空格,同样是这个道理。
如果你希望提示符结尾有换行,可以直接在字符串里加\n:
PS3=$'请选择菜单项目:\n> '这样提示分成两行,当菜单信息较长时会更清晰。$'...'是 Bash 的 ANSI-C 引号语法,不加它,\n会被当成普通字符输出。这个小细节很容易让人困惑,我第一次用的时候也踩过。
3.2 用颜色让菜单提示更醒目
颜色定制在脚本里很受欢迎。在 PS3 里使用 ANSI 颜色转义的方法和 PS1 类似,可以直接这样写:
PS3=$'\e[1;32m请选择操作编号:\e[0m '这样提示文字会以粗体绿色显示,结束时恢复默认颜色。这里我用了$'\e'生成 ESC 字符,而不是在双引号里写\e,因为后者在部分 Bash 版本中会被当作普通字面量,颜色会失效。更保险的做法是直接用\033:
PS3=$'\033[1;32m>>> 请选择操作 >>> \033[0m '如果在提示里包含大量非打印字符,建议把这一段提示符用\[和\]包起来,告诉终端这些字符不计入宽度。对于 PS1,不包会导致命令换行错乱;对于 PS3,因为输入内容短、光标位置影响小,但遇到多字节中文或长提示时依然可能出现光标偏移,所以养成包起来的习惯没有坏处。
我自己的经验是:颜色代码只需要集中在 PS3 的首尾,中间不要嵌套太多,否则在终端里看起来又花又乱。菜单提示符的目的是降低阅读成本,不是艺术创作。
3.3 让 PS3 动态起来:在 select 循环内更新提示语
PS3 不是只能在select之前设置一次。在select循环内部修改 PS3,下一次等待输入时就会用新值显示。比如:
#!/usr/bin/env bash PS3="请选择云节点(1-3):" select node in "节点A" "节点B" "节点C"; do echo "当前选择:$node" PS3="确认使用 $node 吗?按数字继续,回车重新选择:" done第一次循环时的提示是“请选择云节点(1-3):”;当你选完任意节点,循环体把 PS3 改成了确认文案;下一次显示询问时,提示就变成了新的内容。这个特性很适合做出“先选对象、再选动作”的两级引导,甚至可以做动态进度提示。
在 PS3 字符串里也可以嵌入命令引用,比如把当前时间放进去:
PS3="$(date +%H:%M) 请选择操作:"这样每次显示菜单前都会执行date命令,提示会带时间戳。Bash 在显示 PS3 前会对字符串做参数展开、命令替换和路径展开,所以这种写法是合法的。但要注意:如果你在 PS3 里放大量命令替换,select每次循环都会重新执行一遍,性能开销虽然小,但脚本逻辑要尽量简单,不要让提示符本身承担复杂任务。
4. 在真实脚本里用好 PS3:菜单封装与交互逻辑
4.1 一个带校验和退出能力的通用菜单函数
直接写select循环,代码很容易重复。我一般把菜单逻辑抽成函数,参数之一是提示语,剩余参数是菜单项。函数内部设置 PS3,根据用户的输入返回选择结果,或者返回特定状态表示退出。
下面是一个简化但能直接用的版本:
#!/usr/bin/env bash select_from_list() { local prompt="$1" shift local items=("$@") local choice PS3="$prompt [q 退出] " select choice in "${items[@]}"; do case "$REPLY" in q|Q) echo "已退出选择。" >&2 return 1 ;; '') echo "输入不能为空,请重新选择。" >&2 ;; *) if [[ "$REPLY" =~ ^[0-9]+$ ]] && (( REPLY >= 1 && REPLY <= ${#items[@]} )); then printf '%s\n' "$choice" return 0 else echo "无效选择,请输入 1-${#items[@]} 之间的数字。" >&2 fi ;; esac done }核心逻辑有几点:第一,用local把items和choice限制在函数内,避免污染全局。第二,用户直接回车时 REPLY 是空字符串,单独判断。第三,REPLY 是数字但不在范围时,走 else 分支。第四,合法选择会直接把选项内容打印到 stdout 并 return,方便调用方捕获。
调用方式:
result="$(select_from_list "选择要启动的服务:" "Nginx" "MySQL" "Redis")" || exit 1 echo "选了:$result"注意,PS3 在函数里修改之后,函数返回不会自动恢复原来的值。如果你很在意,可以在函数开头保存原值,结尾再恢复:
local old_ps3="$PS3" ... PS3="$old_ps3"这个习惯在多个脚本互相调用时尤其重要。
4.2 用 case 将菜单选项分发到实际业务
菜单选完之后,真正的业务用 case 分发。我建议把每个选项对应一个函数,不要让 case 分支里堆大段命令。比如:
#!/usr/bin/env bash backup_current_dir() { tar czf "backup_$(date +%Y%m%d).tar.gz" . } show_open_ports() { ss -tlnp } restart_service() { systemctl restart "$1" } PS3="请选择功能:" select opt in "备份当前目录" "显示监听端口" "重启服务" "退出"; do case $REPLY in 1) backup_current_dir ;; 2) show_open_ports ;; 3) restart_service nginx ;; 4) break ;; *) echo "请输入 1-4 的数字" ;; esac done这里有个容易踩的坑:case 匹配的是$REPLY而不是$opt。$opt是列表项的文本,$REPLY才是用户敲进去的数字。如果你用$opt做 case,比如case $opt in "备份当前目录"),那么用户输入非数字或乱码时,$opt可能为空字符串,case 的匹配逻辑会变得很绕。用$REPLY做分支判断,再配合范围内的检测,清晰得多。
另外,因为select循环的一次循环只处理一次输入,而 case 分支执行完业务后,循环会再次列出菜单并等待新的输入,所以每个分支内部执行耗时命令时,用户会看到菜单在命令执行完之后重新打印。如果业务函数比较长,最好在开头加一句“开始执行……”,避免用户以为脚本卡住了。
4.3 多级菜单与子菜单的退出设计
多级菜单的本质就是在一个select分支里再调用另一个select。子菜单要能返回上一级,这要求每一级都有明确的 break 或 return 路径。
我常用的写法是每个子菜单做成函数,子菜单的select循环在“返回上一级”这一项上 break 出循环,然后函数 return。父菜单的子菜单分支调用这个函数后,继续自己的循环。例如:
submenu_log() { local sub PS3="日志子菜单(1-3):" select sub in "查看系统日志" "查看应用日志" "返回上级"; do case $REPLY in 1) tail -n 50 /var/log/syslog ;; 2) tail -n 50 /var/log/app.log ;; 3) return 0 ;; *) echo "无效选择" ;; esac done }注意,子菜单的“返回上级”分支用return而不是break,直接结束整个函数,这样就不会回到子菜单的循环里。父菜单中调用submenu_log的地方,只做一次调用,不做循环包裹。这样一级一级嵌套,每一级都保持“菜单 -> 函数 -> 返回”的对称结构,逻辑不会绕。
多级菜单一个隐蔽问题是 PS3 提示文字在层级切换时可能沿用上一次的值。如果你在每个子菜单函数开头都重新设置 PS3,就不会串。我在实际代码里甚至会把菜单标题同时放在 PS3 和菜单函数的第一行输出里,避免用户忘记自己身处哪一层。
5. 避坑记录:PS3 相关的边角问题与错误认知
5.1 PS3 到底能不能使用命令替换和颜色转义
关于 PS3 能不能用命令替换和颜色转义,我见过网上不少互相矛盾的说法。实际以 Bash 的扩展规则为准:Bash 会给 PS1、PS2、PS3、PS4 在显示前做展开,所以命令替换、参数展开、算术展开都有效;同时反斜杠转义序列也会被处理,比如\u、\w这类 PS1 常用的转义,在 PS3 中也会展开。
但这里有个容易区分不清的点:你看到的命令替换有没有被执行,取决于写进去的是单引号还是双引号。例如:
PS3="$(date +%H:%M) > "这行在赋值时就执行了date,之后 PS3 就一直固定为脚本运行那一刻的时间。如果你想每次显示菜单都刷新,应该写成:
PS3="\$(date +%H:%M) > "反斜杠让date命令在每次 PS3 展开时才执行。这个细节同样适用于 PS1。很多人以为 PS3 不支持动态内容,多半就是因为用了不带反斜杠的写法,只看到一次性的时间。
颜色方面,\e这类转义在双引号里不会自动变成 ESC 字符,直接用$'\e'最省心。如果转义序列在 Bash prompt expansion 中能被识别,那\e在某些情况也可能生效,但不同版本行为不一,不建议依赖。
5.2 TMOUT 超时对 select 菜单的真实影响
交互式脚本里经常需要处理“用户长时间不输入”的情况。Bash 的TMOUT变量确实会影响select命令:如果TMOUT是正整数,用户在指定秒数内没有输入,select会读取超时,并把输入视为空行,循环体仍然会执行一次。不同 Bash 版本对超时后的处理细节有差别,有的版本会在超时之后继续等待下一次输入,有的版本会直接结束循环。
所以我的建议是:如果你需要稳定的超时控制,不要直接依赖TMOUT的细节。可以把TMOUT当做一个兜底,比如设置为 300 秒,防止用户一直挂着不操作;但在逻辑上不要把超时当成一个必须准确捕获的分支。真正要精细控制时,要么自己用read -t写输入循环,要么在菜单说明里明确告诉用户“长时间不操作会自动退出”。最实用的做法,其实是在脚本开头检查标准输入是否为终端,非终端环境直接退出,避免后台任务被select卡死。
5.3 全局变量作用域:别让菜单函数污染外部状态
PS3 是全局变量。在函数里改了 PS3,函数结束之后 PS3 不会自动恢复。这个看起来无害,但如果你在脚本里先封装了一个“选择目录”的菜单,又接着封装了一个“选择文件”的菜单,第二个菜单使用的提示语可能还是第一个菜单设置的。如果两个菜单的 PS3 恰好都写成“请选择”,那没问题;如果写成不一样的,用户会看到提示和开场白对不上。
所以凡是有多个菜单函数的脚本,我都在函数入口保存旧值:
local old_ps3="$PS3"然后在函数返回前恢复:
PS3="$old_ps3"在 Bash 里也可以用trap 'PS3="$old_ps3"' RETURN,在函数返回时自动恢复。不过这个写法不够直观,我更推荐在函数开头显式保存、结尾显式恢复,虽然多两行,但一眼能看懂。
还有一个连带问题:PS3 作为环境变量,也会被子进程继承。你在脚本里设置了 PS3,然后调用了另一个 Bash 脚本,那个脚本如果也用到select,会继承外部 PS3。这可能是有意复用,也可能是意外干扰。如果你写的是会被他人调用的工具,提前在脚本开头重新设置 PS3,或者在退出前恢复默认值,是更礼貌的做法。
5.4 非交互环境下的连带问题
把带select菜单的脚本丢进后台执行,或者通过管道喂给 stdin,PS3 会照常打印提示,但用户根本看不到输入机会。比如echo 1 | menu.sh,select会从管道读取到1,执行完分支之后再次尝试读取时遇到 EOF,循环才会结束。这时菜单提示符就变成了刷屏噪音。
为了不让菜单在非交互环境里胡闹,可以在脚本开头加一个判断:
if [[ ! -t 0 ]]; then echo "该脚本需要交互式终端运行" >&2 exit 1 fi-t 0检查标准输入是否是终端。不是终端就退出。很多服务脚本就是因为忘了加这个保护,被定时任务或 CI 跑的时候卡在莫名的地方。
另外,如果菜单的输出要被日志捕获,PS3 的颜色转义会变成一串\033开头的乱码。所以我的经验是:只有确认用户会在终端里手动运行时才启用彩色 PS3,否则一律用纯文本。这也是为什么我习惯把提示色做成另外的变量,而不是直接写死在 PS3 赋值里。
6. 我的 PS3 使用习惯和一些小技巧
6.1 脚本开头统一初始化 PS3
我写交互式脚本,通常在最顶部和变量定义放在一起的位置设置 PS3。设置时要考虑三件事:提示文字是否说明输入格式、颜色是否需要、是否保留默认退出途径。一个典型的初始化长这样:
GREEN=$'\e[1;32m' RESET=$'\e[0m' PS3="${GREEN}请选择菜单项(输入编号后回车):${RESET} "把颜色存进变量,而不是直接在 PS3 里堆转义码,是为了脚本其他部分也能复用颜色。比如菜单标题、成功提示、错误提示。如果你不想让脚本有任何颜色,直接把GREEN和RESET定义为空字符串,PS3 就会退回纯文本,非常方便。
我在这个初始化里也不会写死某一种业务词。PS3 是菜单提示,但每个菜单希望问的问题不一样,所以底线是提示用户“输入编号”。至于具体选什么,交给每个菜单函数去改 PS3。
6.2 使用 read 命令替代 select 时的提示符处理
有人因为select的交互行为不好控制,喜欢用read写自己的菜单。read的命令提示不是 PS3,而是-p的字符串:read -p '选择:' option。这两者很容易混淆。如果你准备放弃select,就不要设置 PS3,而是把提示语装进read -p。否则,改 PS3 没有意义。
反过来,如果你还用select但没显示自己的提示,只靠说明文档里的文字引导用户,那是没把 PS3 当回事。我始终认为,select菜单的可用性很大程度上由 PS3 决定,因为它就是用户唯一会看到的输入引导。很多人抱怨select不友好,其实更多是没设置 PS3、或者提示语太含糊。
一个小技巧:在 PS3 里加上“按回车重新显示菜单”这句话。默认情况下回车会让select重新打印菜单,很多用户不知道。提示一下,能明显减少“怎么没反应”的困惑。
6.3 让帮助信息和 PS3 保持一致的风格
脚本里如果有help函数或者 usage 提示,最好和 PS3 使用同一套文案风格。比如 PS3 说“请选择功能:”,help 里就写“在提示符后输入数字并回车;回车重新显示菜单;Ctrl+D 或选择退出项结束。”这样用户从帮助到实际操作,思维是连续的。
最后说说我踩过的一个坑。有次我写了一个启动脚本,菜单里有“启动服务”和“停止服务”两项。PS3 我设置成了“请输入操作指令:”,结果用户真的往里输入了start、stop这类字符,而select只接受数字编号,所以每次都走进无效分支。后来我把 PS3 改成“请输入操作对应的编号:”,问题立刻消失了。所谓提示符,本质是用户的迷你操作说明书,你写得越明确,后续的校验分支就越少有人抱怨。
从那以后,我的 PS3 文案一律会明确指出“编号”二字,并尽量给出范围,比如“请输入 1-5 的编号:”。这不是什么高深技术,但对脚本的易用性提升非常明显。