bash的配置与自定义
-
- [12、使用辅助提示符: PS2、 PS3、 PS4](#12、使用辅助提示符: PS2、 PS3、 PS4)
- 13、在会话间同步shell历史记录
- 14、设置shell的历史选项
- 15、创建更好的cd命令
- 16、一次性创建并切换到新目录
- 17、直达底部
- 18、用可装载的内建命令为bash添加新特性
- 19、改善可编程补全
- 20、正确使用初始化文件
- 21、创建自包含的可移植rc文件
12、使用辅助提示符: PS2、 PS3、$ PS4
你想知道提示符 $PS2、$PS3、$PS4 的用途。
$PS2 被称为辅助提示符,交互式输入某个尚未结束的命令时,就会出现该提示符。其形式通常为 >,不过你也可以重新定义。例如:
bash
$ export PS2='Secondary: '
$ for i in $(ls)
Secondary: do
Secondary: echo $i
Secondary: done
colors
deepdir
trunc_PWD
$PS3 是 select 提示符,select 语句用它来提示用户输入值。其默认形式为 #?,看起来并不怎么直观。你应该在使用 select 语句前先做修改。例如:
bash
$ select i in $(ls)
Secondary: do
Secondary: echo $i
Secondary: done
1) colors
2) deepdir
3) trunc_PWD
#? 1
colors
#? ^C
$ export PS3='Choose a directory to echo: '
$ select i in $(ls); do echo $i; done
1) colors
2) deepdir
3) trunc_PWD
Choose a directory to echo: 2
deepdir
Choose a directory to echo: ^C
$PS4 会在跟踪输出期间显示。其首个字符会根据需要出现多次,以指明嵌套深度。该提示符默认形式为 +。例如:
bash
$ select i in $(ls)
Secondary: do
Secondary: echo $i
Secondary: done
1) colors
2) deepdir
3) trunc_PWD
#? 1
colors
#? ^C
$ export PS3='Choose a directory to echo: '
$ select i in $(ls); do echo $i; done
1) colors
2) deepdir
3) trunc_PWD
Choose a directory to echo: 2
deepdir
Choose a directory to echo: ^C
$PS4 会在跟踪输出期间显示。其首个字符会根据需要出现多次,以指明嵌套深度。该提示符默认形式为 +。例如:
bash
$ cat demo
#!/usr/bin/env bash
set -o xtrace
alice=girl
echo "$alice"
ls -l $(type -path vi)
echo line 10
ech0 line 11
echo line 12
$ ./demo
+ alice=girl
+ echo girl
girl
++ type -path vi
+ ls -l /usr/bin/vi
-r-xr-xr-x 6 root wheel 285108 May 8 2005 /usr/bin/vi
+ echo line 10
line 10
+ ech0 line 11
./demo: line 11: ech0: command not found
+ echo line 12
line 12
$ export PS4='+xtrace $LINENO: '
$ ./demo
+xtrace 5: alice=girl
+xtrace 6: echo girl
girl
++xtrace 8: type -path vi
+xtrace 8: ls -l /usr/bin/vi
-r-xr-xr-x 6 root wheel 285108 May 8 2005 /usr/bin/vi
+xtrace 10: echo line 10
line 10
+xtrace 11: ech0 line 11
./demo: line 11: ech0: command not found
+xtrace 12: echo line 12
line 12
提示符 PS4 用到了 LINENO 变量,该变量会返回函数所在的当前行号。还要注意单引号,它会将变量扩展推迟到显示时。
13、在会话间同步shell历史记录
你同时运行了多个 bash 会话,希望能够在这些会话间共享历史记录。另外,你还想阻止最后关闭的会话破坏其他会话的历史记录。
使用 history 命令手动或自动在多个会话间同步历史记录。
使用默认设置的话,最后一个顺利退出的 shell 会覆盖历史文件,除非将其与同时打开的其他 shell 同步,否则这些 shell 的历史记录就会被破坏。
手动同步历史记录涉及编写别名,将当前历史记录追加到历史文件(history -a),然后重新将该文件中的新记录读入当前 shell 的历史记录(history -n):
bash
alias hs='history -a ; history -n'
这种方法的缺点是,如果你想同步历史记录,则必须在各 个 shell 中手动执行命令。为了实现自动化,不妨利用$PROMPT_COMMAND 变量:
bash
PROMPT_COMMAND='history -a ; history -n'
PROMPT_COMMAND 的值被视为要在每次显示交互式提示符 PS1 之前执行的命令。这种方法的缺点是,每次显示 $PS1 时都会执行指定的命令。执行次数非常频繁,在负载重或者速度慢的系统中,这会显著拖慢 shell,尤其历史记录数量众多时。
14、设置shell的历史选项
你希望能够更多地控制命令行历史记录。
根据需要,设置 $HIST* 变量以及相关的 shell 选项。
$HISTFILESIZE 变量设置了 $HISTFILE 中允许出现的行数。$HISTFILESIZE 的默认值为 500,$HISTFILE 默认为~/.bash_history,如果处于 POSIX 模式,则为~/.sh_history。增加 $HISTFILESIZE 的值也许管用,如果将其删除,$HISTFILE 的长度将不受限制。修改$HISTFILE 并非必要,除非该变量尚未设置或者对应的文件不可写,无法将历史记录写入磁盘。$HISTSIZE 变量设置了内存中历史记录栈(history stack)所允许的行数。
$HISTIGNORE 和 $HISTCONTROL 控制着哪些内容会被保存到历史记录。$HISTIGNORE 要更灵活,因为它允许你指定模式,以决定需要保存的命令行。$HISTCONTROL的局限性更大,仅支持下列几个关键词(其他值均会被忽略)。
ignorespace:以空格起始的命令行不会保存进历史记录。ignoredups:与先前的历史记录项重复的命令行不会保存进历史记录。ignoreboth:ignorespace 和 ignoredups 的简写。erasedups:在保存当前命令行前,先删除之前所有与其匹配的历史记录项。
如果没有设置 $HISTCONTROL 或者其中不包含以上关键字,那么所有符合 $HISTIGNORE 指定模式的命令行都会保存进历史记录。多行复合命令的第二行以及后续行不参与测试,会被直接添加到历史记录中,无论$HISTCONTROL 包含什么值。
HISTTIMEFORMAT 可用于 bash 3 及后续版本,如果该变量已设置且不为空,则指定了显示或写入历史记录时所使用的 strftime 格式字符串。如果你的 bash 版本不符,但所用的终端带有回滚缓冲区,那么在提示符中加入日期和时间戳也能帮上大忙。需要注意的是,bash 并不会自动在 HISTTIMEFORMAT 之后添加空格,有些系统(如 Debian)对此作了修补:
bash
$ history
1 ls -la
2 help history
3 help fc
4 history
# 难看
$ export HISTTIMEFORMAT='%Y-%m-%d_%H:%M:%S'
$ history
1 2006-10-25_20:48:04ls -la
2 2006-10-25_20:48:11help history
3 2006-10-25_20:48:14help fc
4 2006-10-25_20:48:18history
5 2006-10-25_20:48:39export HISTTIMEFORMAT='%Y-%m-%d_%H:%M:%S'
6 2006-10-25_20:48:41history
# 美观
$ HISTTIMEFORMAT='%Y-%m-%d_%H:%M:%S; '
$ history
1 2006-10-25_20:48:04; ls -la
2 2006-10-25_20:48:11; help history
3 2006-10-25_20:48:14; help fc
4 2006-10-25_20:48:18; history
5 2006-10-25_20:48:39; export HISTTIMEFORMAT='%Y-%m-%d_%H:%M:%S'
6 2006-10-25_20:48:41; history
7 2006-10-25_20:48:47; HISTTIMEFORMAT='%Y-%m-%d_%H:%M:%S; '
8 2006-10-25_20:48:48; history
# 需要点技巧了
$ HISTTIMEFORMAT=': %Y-%m-%d_%H:%M:%S; '
$ history
1 : 2006-10-25_20:48:04; ls -la
2 : 2006-10-25_20:48:11; help history
3 : 2006-10-25_20:48:14; help fc
4 : 2006-10-25_20:48:18; history
5 : 2006-10-25_20:48:39; export HISTTIMEFORMAT='%Y-%m-%d_%H:%M:%S'
6 : 2006-10-25_20:48:41; history
7 : 2006-10-25_20:48:47; HISTTIMEFORMAT='%Y-%m-%d_%H:%M:%S; '
8 : 2006-10-25_20:48:48; history
最后一个例子用内建命令 : 和元字符 ; 将日期戳包围起来,形成了一个"什么都不干"的命令(如 : 2006-10-25_20:48:48;)。这允许你重用历史记录文件中的文字行,不必费心解析日期戳。注意,别漏了 : 之后的空格。
还有一些 shell 选项也可以配置历史记录文件处理。如果设置 histappend 选项,shell 会将历史记录追加到历史文件;否则,历史文件会被覆盖。注意,它仍然受限于$HISTFILESIZE。如果设置 cmdhist,多行命令会保存为单行形式,同时根据需要添加分号。如果设置 lithist,则多行命令与嵌入的换行符一起被保存。
15、创建更好的cd命令
你进入了一个层级很深的目录,希望能够通过输入 cd ...向上跳转 4 层,而不是费力地输入 cd .../.../.../...。
使用下例中给出的函数。
bash
# 实例文件:func_cd
# 允许使用cd ...向上跳转2层,使用cd ....向上跳转3层,以此类推。(就像4NT/4DOS)
# 用法:cd ...,以此类推
function cd {
local option= length= count= cdpath= i= # 局部变量,设置好初始状态
# 如果存在-L或-P选项,将其保存下来,然后删除
if [ "$1" = "-P" -o "$1" = "-L" ]; then
option="$1"
shift
fi
# 我们是否使用了这种特殊的语法?确保$1不为空,
# 然后匹配$1的前3个字符,看看是否为'...',
# 接着尝试替换,以确保不存在斜线;如果失败,则说明没有斜线
if [ -n "$1" -a "${1:0:3}" = '...' -a "$1" = "${1%/*}" ]; then
# 我们使用了特殊语法
length=${#1} # 假定$1只包含点号,统计点号数量
count=2 # 'cd ..'仍代表向上跳转1层,因此忽略前2个点号
# 只要点号尚未用完,就继续向上跳转1层
for ((i=$count;i<=$length;i++)); do
cdpath="${cdpath}../" # 构建cd命令用到的路径
done
# 执行cd命令
builtin cd $option "$cdpath"
elif [ -n "$1" ]; then
# 未使用特殊语法,只是普通的cd命令
builtin cd $option "$*"
else
# 未使用特殊语法,只是单独的普通cd命令,可以切换到主目录
builtin cd $option
fi
} # 函数cd定义完毕
cd 命令接受可选的 -L 或 -P 选项,分别表示使用逻辑路径或文件系统的物理路径。无论是哪个选项,要想重新定义cd 命令的工作方式,必须将其考虑在内。
然后,确保 $1 不为空并匹配其前 3 个字符,测试是否为...。接着尝试替换,确保不存在斜线;如果失败,则说明没有斜线。这两处字符串操作均要求 bash 的版本为2.0+。在此之后,使用可移植的 for 循环构建实际的 cd 命令,最后通过 bulitin 使用 shell 内建的 cd 命令,避免调用 cd 函数时陷入死循环。如果 -L 或 -P 选项存在,我们也会将其传入并处理。
16、一次性创建并切换到新目录
你经常需要创建新目录并立即进入这些目录中执行某些操作,逐个输入命令的过程太枯燥了。
在适合的配置文件(如 ~/.bashrc)中加入下例所示的函数,并将其读入 shell 环境。
bash
# 实例文件:func_mcd
# 创建并切换到新目录
# 用法:mcd (<mode>) <dir>
function mcd {
local newdir='_mcd_command_failed_'
if [ -d "$1" ]; then # 目录已存在,提醒用户......
echo "$1 exists..."
newdir="$1"
else
if [ -n "$2" ]; then # 已经指定了目录的权限模式(mode)
command mkdir -p -m $1 "$2" && newdir="$2"
else # 普通的mkdir命令
command mkdir -p "$1" && newdir="$1"
fi
fi
builtin cd "$newdir" # 直接进入
} # 函数mcd定义完毕
例如:
bash
$ source mcd
$ pwd
/home/jp
$ mcd 0700 junk
$ pwd
/home/jp/junk
$ ls -ld .
drwx------ 2 jp users 512 Dec 6 01:03 .
该函数允许你在使用 mkdir 命令创建目录时指定可选的权限模式。如果该目录已经存在,则提醒用户,但仍切换进去。我们用 command 命令确保忽略名为 mkdir 的 shell函数,用 builtin 命令确保只使用 shell 内建的 cd 命令。
我们还将字符串_mcd_command_failed_ 赋给了一个局部变量,以防 mkdir 失败。如果一切顺利,该变量中保存的是正确的新目录名。如果出现问题,在试图执行 cd 命令时,它会显示出非常有帮助的消息,前提是没有大量的_mcd_command_failed_ 目录存在。
bash
$ mcd /etc/junk
mkdir: /etc/junk: Permission denied
-bash: cd: _mcd_command_failed_: No such file or directory
$
你可能认为,在 mkdir 失败时,我们可以用 break 或 exit来轻松地改进代码。然而,break 仅适用于 for、while 或 until 循环,exit 实际上会退出 shell,因为被读入的函数就是在当前 shell 进程中运行的(可以使用 return,我们将此作为练习留给你来实践)。
bash
command mkdir -p "$1" && newdir="$1" || exit 1 # 这会退出当前shell
command mkdir -p "$1" && newdir="$1" || break # 这会出错
你也可以使用下列这种更简单的形式,但我们显然更喜欢现有解决方案中给出的更稳健的版本。
bash
function mcd { mkdir "$1" && cd "$1"; }
17、直达底部
你要在很多又窄又深的目录结构中开展工作,所有的工作内容都位于目录的最底部,你厌倦了手动输入大量的 cd命令。
用以下别名直达目录底部。
bash
alias bot='cd $(dirname $(find . | tail -n 1))'
在大型目录结构(如 /usr)中,find 命令的这种用法会花费大量时间,不推荐这么做。
取决于具体的目录结构,该方法可能并不管用,你得自己动手试试看。find . 会列出当前目录及其下层目录中的所有文件和子目录,tain -n 1 会获取最后一行输出,dirname 只提取路径部分,cd 负责切换到特定目录。你可能需要对上述命令进行调整,以便能够到达正确的位置。例如:
bash
alias bot='cd $(dirname $(find . | sort -r | tail -n 5 | head -1))'
或者:
bash
alias bot='cd $(dirname $(find . | sort -r | grep -v 'X11' | tail -n 3 \
| head -1))'
不断尝试调整最内部括号中的部分,尤其是 find 命令,直至获得你想要的结果。目录结构底部可能有一个密钥文件或子目录,这种情况下,下列函数也许能派上用场。
bash
function bot { cd $(dirname $(find . | grep -e "$1" | head -1)); }
注意,别名无法使用参数,因此必须采用函数形式。我们没用 find 命令的 -name 选项,而是改用了 grep,因为后者更加灵活。根据目录结构,你可能要用 tail 代替head。再次强调,先测试 find 命令。
18、用可装载的内建命令为bash添加新特性
你希望 bash 能够帮你完成某项任务,但是并没有适合的内建命令。考虑到效率问题,你想在 shell 中内建该功能,而不是依靠外部程序。或者你已经有用 C 语言编写好的源代码,不想或没法重写。
使用 bash 2.0 版本中引入的动态可装载内建命令。bash安装包的 ./examples/loadables/ 目录下(尤其是作为规范的 hello.c)包含了一些预先编写好的内建命令。可以将文件 Makefile 中与你所在系统相关的那些行的注释去掉,然后输入 make 来构建这些内建命令。我们选择其中的内建命令 tty 作为一般性的案例研究。
以下是 bash 4.4 版本中 ./examples/loadables/ 目录下的内建命令清单。
bash
basename.c id.c necho.c printenv.c strftime.c uname.c
cat.c ln.c pathchk.c push.c sync.c unlink.c
dirname.c loadables.h perl realpath.c tee.c whoami.c
finfo.c logname.c perl/bperl.c rmdir.c template.c head.c
mkdir.c perl/iperl.c setpgid.c truefalse.c hello.c mypid.c
对于支持动态装载的系统,你可以用 C 语言编写自己的内建命令,将其编译成共享对象,随时在 shell 中通过enable 内建命令来装载。
接下来我们将简要讨论如何在 bash 下编写及装载内建命令。该讨论假设你具备编写、编译、链接 C 语言程序的经验。
tty 模仿标准的 Unix 命令 tty,它会输出连接在标准输入上的终端名称。和其他命令一样,如果设备是 TTY,它会返回真;否则,返回假。另外,tty 还能接受 -s 选项,以指定是否采用静默模式工作(也就是什么都不输出,只返回结果)。
内建命令的 C 语言代码分为 3 个截然不同的部分:实现该内建命令功能的代码、定义帮助信息、该内建命令的描述结构,以便 bash 得以访问。
描述结构非常直观,其形式如下所示。
bash
struct builtin builtin_name_struct = {
"builtin_name",
function_name,
BUILTIN_ENABLED,
help_array,
"usage",
0
};
第一行必须包含尾随的 _struct,以便内建命令 enable 能够找到符号名。builtin_name 是出现在 bash 中的内建命令名称。下一个字段 function_name 是实现该内建命令的 C 语言函数名称。我们一会再来探讨这个函数。BUILTIN_ENABLED 是该内建命令的初始状态,表明是否启用。这个字段应该始终设置为 BUILTIN_ENABLED。help_array 是一个字符串数组,对内建命令使用 help 时会被输出。usage 是形式更简短的帮助信息,仅包括命令名称及其选项。最后一个字段应该设置为 0。
在我们的示例中,内建命令名为 tty,对应的 C 函数为tty_builtin,帮助数组为 tty_doc。用法字符串为 tty -s。最终的描述结构如下所示。
bash
struct builtin tty_struct =
"tty",
tty_builtin,
BUILTIN_ENABLED,
tty_doc,
"tty [-s]",
0
};
接下来是内建命令的实现代码。
bash
tty_builtin (list) WORD_LIST *list;
{
int opt, sflag;
char *t;
reset_internal_getopt ( );
sflag = 0;
while ((opt = internal_getopt (list, "s")) != -1)
{
switch (opt)
{
case 's':
sflag = 1;
break;
default:
builtin_usage ( );
return (EX_USAGE);
}
}
list = loptend;
t = ttyname (0);
if (sflag == 0)
puts (t ? t : "not a tty");
return (t ? EXECUTION_SUCCESS : EXECUTION_FAILURE);
}
内建命令的实现函数始终会得到一个指向 WORD_LIST 类型列表的指针。如果内建命令不接受任何选项,则必须调用 no_options(list) 并在继续处理前先检查其返回值。如果返回值为非 0,那么函数应该立即返回值 EX_USAGE。
在处理内建命令选项时,必须坚持使用internal_getopt,而不是标准 C 函数库中的 getopt。另外,还必须先调用 reset_internal_getopt 来重置选项处理。
选项处理以标准方式执行,除非选项不正确,这种情况下应返回 EX_USAGE。处理完选项后,剩下的所有参数都由loptend 指向。执行完函数后,应该返回值EXECUTION_SUCCESS 或 EXECUTION_FAILURE。
对于我们自己的内置命令 tty 来说,只需要调用标准 C 函数库例程 ttyname,如果未指定 -s 选项,则输出 TTY 的名称(如果设备并非 TTY,输出"not a tty")。然后该函数会根据 ttyname 的调用结果返回成功或失败。
最后一个主要部分是定义帮助信息。这其实就是一个字符串数组而已,其中最后一个数组元素为 NULL。在内建命令上运行 help 时,所有字符串会逐个输出到标准输出。因此,你应该将字符串长度限制为 76 个字符或更少(80个字符的标准显示宽度减去 4 个字符的边距)。tty 中的帮助信息如下所示。
bash
char *tty_doc[] = {
"tty writes the name of the terminal that is opened for standard",
"input to standard output. If the `-s' option is supplied, nothing",
"is written; the exit status determines whether or not the standard",
"input is connected to a tty.",
(char *)NULL
};
最后一件事是,在代码中添加必要的 C 头文件,包括stdio.h 以及 bash 的头文件 config.h、builtins.h、shell.h、bashgetopt.h。
下例展示了完整的 C 程序。
bash
# 实例文件:builtin_tty.c
#include "config.h"
#include <stdio.h>
#include "builtins.h"
#include "shell.h"
#include "bashgetopt.h"
extern char *ttyname ( );
tty_builtin (list)
WORD_LIST *list;
{
int opt, sflag;
char *t;
reset_internal_getopt ( );
sflag = 0;
while ((opt = internal_getopt (list, "s")) != -1)
{
switch (opt)
{
case 's':
sflag = 1;
break;
default:
builtin_usage ( );
return (EX_USAGE);
}
}
list = loptend;
t = ttyname (0);
if (sflag == 0)
puts (t ? t : "not a tty");
return (t ? EXECUTION_SUCCESS : EXECUTION_FAILURE);
}
char *tty_doc[] = {
"tty writes the name of the terminal that is opened for standard",
"input to standard output. If the `-s' option is supplied, nothing",
"is written; the exit status determines whether or not the standard",
"input is connected to a tty.",
(char *)NULL
};
struct builtin tty_struct = {
"tty",
tty_builtin,
BUILTIN_ENABLED,
tty_doc,
"tty [-s]",
0
};
现在我们需要将该程序编译并链接成一个动态共享对象。遗憾的是,不同的系统有不同的动态共享对象编译方法。
configure 脚本应该自动将正确的命令放入 Makefile。如果由于某种原因未能实现,下表列出了在一些常见系统中编译及链接 tty.c 所需要的命令。用 bash 安装包的顶层路径替换 archive。



编译及链接完毕后,你应该能够得到一个名为 tty 的共享对象。输入 enable -f tty tty 就可以将其载入 bash。可以随时用 -d 选项删除已装载的内建命令,例如,enable -dtty。
可以将多个内建命令放入单个共享对象,每个内建命令的3 个主要部分都位于相同的 C 文件即可。但是,bash 是将 共享对象作为整体载入的,因此,如果你要求 bash 从包含 20 个内建命令的共享对象中装载其中一个,它会将这20 个内建命令全部载入(但只启用一个)。最好保持较小的内建命令数量,以节省不必要的内存占用,同时将功能相似的内建命令(如 pushd、popd、dirs)进行分组,如果用户启用其中一个,其他的也会全部被载入内存,以备启用。
19、改善可编程补全
你对 bash 的可编程补全钟爱有加,但希望该功能可以更多地考虑到上下文,尤其针对经常使用的那些命令。
查找并安装其他可编程补全库,或者自己写一个。bash tarball 的 ./examples/complete 中也提供了一些示例。有些发行版(如 SUSE)在 /etc/profile.d/complete.bash中有自己的版本。但毫无疑问,最大且最广为人知的第三方库出自 Ian Macdonald 之手,你可以从 GitHub 网站scop/bash-completion 页面下载 tarball 或 RPM 格式。该库已经包含在 Debian(以及 Ubuntu 和 Mint 等衍生版本)中,而且也能在 Fedora Extras 和其他第三方仓库中找到。
在撰写本书之时,可编程补全库 bashcompletion-
20060301.tar.gz 提供了 103 个模块。其中包括:
bash
bash alias completion
bash export completion
bash shell function completion
chown(1) completion
chgrp(1) completion
Red Hat & Debian GNU/Linux if{up,down} completion
cvs(1) completion
rpm(8) completion
chsh(1) completion
chkconfig(8) completion
ssh(1) completion
GNU make(1) completion
GNU tar(1) completion
jar(1) completion
Linux iptables(8) completion
tcpdump(8) completion
ncftp(1) bookmark completion
Debian dpkg(8) completion
Java completion
PINE address-book completion
mutt completion
Python completion
Perl completion
FreeBSD package management tool completion
mplayer(1) completion
gpg(1) completion
dict(1) completion
cdrecord(1) completion
yum(8) completion
smartctl(8) completion
vncviewer(1) completion
svn completion
可编程补全是 bash 2.04 引入的特性。通过提供补全机制钩子,它扩展了内建的文本补全功能。这意味着你几乎可以编写出任何需要的补全功能。例如,如果能够在输入man 命令时按下 Tab 键,并列出手册中的各节,岂不是很妙?可编程补全能做到的远不止如此。
本实例仅着眼于可编程补全的基础部分。如果想要进一步深入研究,编写自己的补全代码,可以先检查其他人开发的补全命令库,看看有没有现成或者可用的样例。要是你需要自己动手,我们只概述使用补全机制所需要的基本命令和过程。
要想以特定方式补全文本,你得先告诉 shell 按下 Tab 键时应该怎么做。这是通过 complete 命令完成的。
complete 的主要参数可以是命令名称或者其他需要进行文本补全的内容。例如,我们来看一下可以解压各种压缩文件的实用工具 gunzip。通常,如果输入:
bash
gunzip [TAB][TAB]
可以得到一系列补全后的备选文件,其中包括各种并不适合 gunzip 处理的文件。其实你真正想要的是 gunzip 能够处理的那部分。可以用 complete 进行如下设置:
bash
complete -A file -X '!*.@(Z|gz|tgz)' gunzip
注意,要想让 @(Z|gz|tgz) 发挥作用,需要通过 shopt -sextglob 启用扩展模式匹配。
这里我们告诉补全机制,希望能在输入 gunzip 命令时做一些特殊处理。-A 选项代表操作(action),能够接受各种参数。这个例子使用了 file 参数,以要求补全机制提供 有可能补全的文件列表。接下来从中选择 gunzip 能够处理的那些文件。这一步通过 -X 选项来实现,该选项以过滤器模式为参数。应用到补全列表时,过滤器会删除与该模式匹配的所有文件名,也就是说,剩下的都是与模式不匹配的。gunzip 能解压包括扩展名 .Z、.gz、.tgz 在内的多种文件类型。我们希望能够匹配这三种扩展名之一。然后还得使用 ! 将含义取反(记住,过滤器删除的是匹配指定模式的文件名)。
用 complete 安装该补全功能前,可以先通过 compgen命令试验一下,看看效果如何。
bash
compgen -A file -X '!*.@(Z|gz|tgz)'
这会产生一系列补全字符串(假设当前目录下有一批采用这种扩展名的文件)。compgen 有助于测试过滤器的效果,看看都能产生什么样的补全字符串。如果需要更复杂的补全,同样少不了它。随后我们还会看到另一个例子。
无论是通过读取包含该命令的脚本,还是在命令行上直接执行,一旦执行上面的 complete 命令,就可以将扩展后的补全机制与 gunzip 命令一起使用。
bash
$ gunzip [TAB][TAB]
archive.tgz archive1.tgz file.Z
$ gunzip
你可能会看到还有其他可以实现的操作。如何为某个命令的特定选项提供可能的参数列表?例如,kill 命令可以接受进程 ID,但也能够接受以连字符(-)起始的信号名称或出现在 -n 之后的信号名称。我们可以补全 PID,但如果有连字符或 -n,那就得补全信号名称。
这要比先前那个单行的例子略复杂些。我们需要编写一些代码来区分已经输入的内容。除此之外,还得获得 PID 和信号名称。我们将相关代码放入函数,并通过补全机制调用该函数。调用函数的代码如下所示,我们称其为 _kill。
bash
complete -F _kill kill
-F 选项告知 complete 要在执行 kill 命令的文本补全时调用函数 _kill。接下来是编写函数,如下例所示。
bash
# 实例文件:func_kill
_kill() {
local cur
local sign
COMPREPLY=( )
cur=${COMP_WORDS[COMP_CWORD]}
if (($COMP_CWORD == 2)) && [[ ${COMP_WORDS[1]} == -n ]]; then
# 返回可用的信号列表
_signals
elif (($COMP_CWORD == 1 )) && [[ "$cur" == -* ]]; then
# 返回可用的信号列表
sign="-"
_signals
else
# 返回可用的PID列表
COMPREPLY=( $( compgen -W '$( command ps axo pid | sed 1d )' $cur ) )
fi
}
除了用到一些特殊的环境变量及调用了函数 _signals(我们马上会讲到该函数),这段代码非常标准。
变量 $COMPREPLY 用于保存返回给补全机制的结果。该变量是包含一系列补全字符串的数组,其初始值为空。
局部变量 $cur 是一个用于提高代码可读性的便利变量,因为其中的值会在多处用到。这个值来自于数组$COMP_WORDS 的某个元素。该数组保存的是当前命令行中的各个单词。$COMP_CWORD 是指向其的索引,可以通过它获得当前光标的位置。$cur 的值是当前光标所在的单词。
第一个 if 语句测试 kill 命令之后是否为 -n 选项。如果首个单词是 -n 且当前位于第二个单词,那么我们需要为补全机制提供信号名称列表。
第二个 if 语句也差不多,只不过这次关注的是补全当前单词,该单词以连字符起始,之后可以是任何内容。这个 if语句的主体部分还是调用 _signals,但这次将变量 sign 设置为连字符。介绍 _signals 函数时,你就明白为什么要这么做了。
剩下的 else 语句块返回进程 ID 列表。它用 compgen 命令帮助创建补全字符串数组。首先,执行 ps 命令来获得PID 列表。接着通过管道将其传给 sed,以删除第一行(也就是标题行)。然后,将结果作为参数传给compgen 的 -W 选项,该选项接受的是一个单词列表。compgen 返回与变量 cur 的值匹配的所有补全字符串,得到的数组会保存在 COMREPLY。
这里 compgen 非常重要,因为不能只是将 ps 所提供的整个 PID 列表返回。也许用户已经输入了一部分 PID,正尝试将其补全。由于这部分 PID 会保存在变量 cur 中,因此 compgen 会将结果限制为匹配或部分匹配该变量值的那些 PID。例如,如果 cur 的值是 5,那么 compgen 仅返回以 5 起始的值,如 5、59 或 562。
终于可以讲讲 _signals 函数了(参见下例)。
bash
# 实例文件:func_signals
_signals() {
local i
COMPREPLY=( $( compgen -A signal SIG${cur#-} ))
for (( i=0; i < ${#COMPREPLY[@]}; i++ )); do
COMPREPLY[i]=$sign${COMPREPLY[i]#SIG}
done
}
虽然可以用 compgen 的 -A 信号来获得信号名称列表,但遗憾的是,这些名称所采用的形式并没有多大的用武之地,我们无法通过其直接生成信号名称数组。compgen生成的信号名称以字母"SIG"开头,但 kill 命令不需要这3 个字母。_signals 函数负责将信号名称数组赋给$COMPREPLY,还可以有选择地在名称前加上一个连字符。
首先,我们用 compgen 生成信号名称列表。每个名称均以字母"SIG"开头。为了使 complete 能够在用户开始输入名称时提供正确的子集,我们在 $cur 的值前添加了"SIG"。另外,我们还借机删除了出现在该值前面的连字符,以便得以匹配。
接着,遍历数组,删除每个元素的"SIG"并根据需要添加连字符(变量 sign 的值)。
complete 和 compgen 都有许多其他选择和操作,远不止此处介绍的这些。要想进一步了解可编程补全,建议你查阅 Bash Reference Manual,还可以从 Internet 或 Bash tarball 的 ./examples/complete 中查看一些示例。
20、正确使用初始化文件
你想知道所有的初始化(rc5)文件都是怎么回事。
以下是这些文件及其用途的备忘清单。根据具体的设置,有些文件可能并不存在于你的系统中。默认使用 bash 的系统(如 Linux)通常不会少什么文件,而默认使用其他shell 的系统往往会缺其中一部分。
/etc/profile
用于 Bourne 和类似登录 shell 的全局登录环境文件。我们建议不要碰这个文件,除非你是系统管理员,清楚地知道自己在干什么。/etc/bashrc(Red Hat)或 /etc/bash.bashrc(Debian)
用于交互式 bash 子 shell 的全局环境文件。我们建议不要碰这个文件,除非你是系统管理员,清楚地知道自己在干什么。/etc/bash_completion
如果存在,这个文件几乎肯定是 Ian Macdonald 的可编程补全库的配置文件。建议了解一下该文件,它非常酷。/etc/profile.d/bash_completion.sh 和/etc/bash_completion.d
其他可能存在的特定发行版的 bash 补全文件。如今多见于 Fedora,也有可能是其他系统。/etc/inputrc
GNU readline 的全局配置文件。如果应用范围是整个系统,建议调校该文件(假如你是系统管理员);如果只用于个人,则建议调校 ~/.inputrc(参见 16.22 节)。此文件不会直接执行或被读入,而是通过 readline、INPUTRC 以及 include(或 bind -f)被读取。注意,其中也许会包含引入其他 readline 文件的 include 语句。~/.bashrc
交互式 bash 子 shell 的个人环境文件。建议在其中放置别名、函数以及各式各样的命令行提示符。~/.bash_profile
bash 登录 shell 的个人配置文件。建议一定要在其中读入 ~/.bashrc,剩下的就可以忽略了。- `~/.profile``
Bourne 登录 shell 的个人配置文件。仅当~/.bash_profile 和 ~/.bash_login 均不存在时,bash 才会使用该文件。建议忽略,除非你使用的其他 shell 要用到它。 ~/.bash_history
shell 命令历史记录的默认存储文件。建议你使用历史记录工具来处理该文件,不要尝试直接编辑。这只是一个数据文件,不会被执行或读入。~/.bash_logout
在用户注销时执行。建议你将所有的清理例程都放入其中。该文件仅在干净注销时才会执行(如果会话因广域网连接掉线而中止,则不会执行)。~/.inputrc
GNU readline 的个人定制文件。建议根据需要调校该文件。此文件不会直接执行或被读入,而是通过 readline、INPUTRC 以及 include(或 bind -f)被读取。注意,其中也许会包含引入其他 readline 文件的 include 语句。
我们意识到要遵循这份清单有点棘手;每种操作系统或发行版可能都有所不同,这完全取决于厂商如何编写这些文件。要想真正了解你的系统是如何运作的,需要将上面列出的所有文件逐个读一遍。你还可以将 echoname_of_file_>&2 临时添加到所有会被执行或读入的文件(也就是跳过 /etc/inputrc、~/.inputrc 以及/.bash_history)的第一行。注意,这可能会干扰到某些程序(尤其是 scp 和 rsync),它们会被 STDOUT 或STDERR 上的额外输出搞晕,因此要在完成测试后删除这些语句。
在 Unix 或 Linux 中,其中一个棘手的问题是,当你偶尔想对整个系统进行改动时,弄清楚在哪里修改像 PATH或提示符这样的东西。不同的操作系统(甚至不同的版本)有不同的设置位置。下列命令基本上能帮你找出在系统的什么地方设置 PATH。
bash
grep 'PATH=' /etc/{profile,*bash*,*csh*,rc*}
如果行不通,那就只能用 grep 搜索 /etc 下的所有文件了。
bash
find /etc -type f | xargs grep 'PATH='
注意,不像书中的大部分代码,上面这行最好以 root 身份运行。普通用户身份不是不能运行,虽然也能得到一部分结果,但可能会有所遗漏,而且几乎肯定会得到 "Permission denied"(权限被拒绝)错误。
另一件棘手的事情是搞明白你能改动什么、在哪里进行个人设置。我们希望本章在这方面给你带来了更多的好想法。
21、创建自包含的可移植rc文件
你在多台机器上工作,对其中有些机器拥有有限的或完全的 root 控制权,而有些则没有,你想要复制一致的 bash环境,但同时仍然允许根据操作系统、机型或其他(如工作、家庭)标准进行自定义设置。
将所有的自定义内容放进 settings 子目录下的文件中,将该目录复制或同步到 ~/ 或 /etc 等位置,并在必要时使用 include 语句和符号链接(如 ln -s~/settings/screenrc~/.screenrc)。在自定义文件中按照逻辑考虑各种条件,如操作系统、位置等。
或许你还会选择不给文件名添加前导点号,以便更容易管理自定义文件。我们在 1.7 节中已经看到过,ls 默认不显示带有前导点号的文件,这样一来,显示目录内容时会更整洁一些。因为我们使用了专门存放配置文件的目录,所以就没必要再加点号了。注意,/etc 通常也不使用点号文件的原因也在于此。
我们来看看研究此解决方案时使用的假设和准则。首先, 假设如下所示。
- 你所在的环境比较复杂,仅对其中的部分机器拥有控制权。
- 对于能够控制的机器,用一台机器导出 /opt/bin,其他机器通过 NFS 将其挂载,因此所有的配置文件都在其中。之所以选择 /opt/bin,是因为它形式简短,相较于 /usr/local/bin,它与已有目录发生冲突的可能性较小,你也可以随意使用其他合理的目录。
- 对于只有部分控制权的机器,使用 /etc 中具有系统范围的配置。
- 对于没有控制权的机器,使用~/ 中的点号文件。
- 一些设置随机器和环境(例如,家庭或工作)不同而异。
准则如下所示。
- 在操作系统和环境之间转移配置文件时,尽可能少做修改。
- 补充但不替代操作系统默认的或系统管理员提供的配置。
- 提供足够的灵活性来处理设置冲突所带来的需求(例如,工作和家庭 CVS)。
虽然可能倾向于在配置文件中加入 echo 语句,以了解发生了什么,但要小心。这样做的话,scp、rsync 以及其他类似 rsh 的程序会运行失败,产生莫名其妙的错误信息。
bash
# scp
# protocol error: bad mode
# rsync
# protocol version mismatch - is your shell clean?
# (see the rsync manpage for an explanation)
# rsync error: protocol incompatibility (code 2) at compat.
# c(62)
ssh 能够正常工作的原因是,它实际上是交互式的,输出信息显示在屏幕上,这不会扰乱数据流。
为了进行调试,将下面两行代码放在 /etc/profile 或~/.bash_profile 的顶部附近,但记得看一下我们刚刚给出的有关扰乱数据流的警告。
bash
export PS4='+xtrace $LINENO: '
set -x
也可以用 set -x 作为替代(或补充),将下列代码键入任意或全部的配置文件中。
bash
# 例如,在~/.bash_profile中
case "$-" in
*i*) echo "$(date '+%Y-%m-%d_%H:%M:%S_%Z') Interactive" \
"~/.bash_profile ssh=$SSH_CONNECTION" >> ~/rc.log ;;
* ) echo "$(date '+%Y-%m-%d_%H:%M:%S_%Z') Noninteractive" \
"~/.bash_profile ssh=$SSH_CONNECTION" >> ~/rc.log ;;
esac
# 在~/.bashrc中
case "$-" in
*i*) echo "$(date '+%Y-%m-%d_%H:%M:%S_%Z') Interactive" \
"~/.bashrc ssh=$SSH_CONNECTION" >> ~/rc.log ;;
* ) echo "$(date '+%Y-%m-%d_%H:%M:%S_%Z') Noninteractive" \
"~/.bashrc ssh=$SSH_CONNECTION" >> ~/rc.log ;;
esac
因为不会向终端产生任何输出,所以也不会干扰到命令。在一个会话中执行 tail -f ~/rc.log 命令,在其他地方执行有麻烦的命令,以判断使用了哪个配置文件。这样就可以轻松地追溯到问题的源头。
如果要修改配置文件,我们强烈建议你打开两个会话。在其中一个会话中完成所有改动,注销该会话,接着再返回。如果出现问题导致无法重新登录,就在另一个会话中修复,然后在第一个会话再尝试登录。不要将两个会话都注销了,直到百分之百确定能够正常登录。如果所做的改动会影响到 root 用户,那么这个过程还得再重复一遍。
你确实需要注销并重新登录。读入(sourcing)修改后的文件是可行的,先前环境中残留的配置或许还能让一切暂时正常,等到干净启动(start clean)时,问题就来了。可以根据需要改动运行环境,但在准备好测试前,别更改文件;否则,你有可能会忘了这茬,要是哪个地方出了岔子,搞不好就把自己关在系统外了。