SSD Nodes Learn 🎉 VPS $4.99/月起
指南 Matt Connor作者: Matt Connor

Bash 历史展开:!!、!$ 和 6 个常用用法

了解 Bash 中 !! 如何重复上一条命令、!$ 如何引用最后一个参数,以及 !:p 如何只打印命令不执行。涵盖 6 种实用历史展开写法与安全提示。

Bash 历史展开的作用

Bash 历史展开会在 Bash 执行命令行之前,根据 shell 历史记录重建之前的命令行。!! 会重复上一条命令。!$ 会插入上一条命令的最后一个参数。替换以文本方式进行,并且发生在 Bash 将命令行拆分为多个单词之前,因此展开结果与首次输入的字符完全一致。

以下 6 种形式几乎涵盖了您在服务器上执行的所有操作:

  • !! 会重复上一整行,sudo !! 会以 root 身份再次执行该命令。
  • !$ 表示上一行命令的最后一个参数。
  • !* 表示上一行命令的全部参数。
  • !n 会执行编号为 n 的历史记录,!-n 会从当前历史位置向前计数。
  • !string 会执行最近一条以 string 开头的命令。
  • ^old^new 会重新执行上一行命令,并将第一个 old 替换为 new

以下所有内容都在您自己的服务器上的交互式提示符中输入。脚本中默认关闭历史展开,最后一节会对此进行说明。

只输入一次的长路径

下面是一个能立即节省时间的场景。您正在准备发布目录,而路径很长,再输入一次很容易出错。

sudo mkdir -p /srv/www/app/releases/2026-08-07
sudo chown -R deploy:deploy !$
ls -ld !$
sudo -u deploy nano !$/config.env

bash 在执行每一行前,会打印展开后生成的命令行:

sudo chown -R deploy:deploy /srv/www/app/releases/2026-08-07

这行回显就是检查依据。先查看它,再查看命令输出,因为这是您唯一能看到 bash 将 !$ 解释成什么的机会。

之所以能连续使用,是因为 bash 会将展开后的命令行保存到历史记录,而不是保存您输入的 !$。因此,第 3 行会从展开后的第 2 行获取最后一个参数,第 4 行再从第 3 行获取该参数。第 4 行还说明,设计符后面可以继续跟文本:!$/config.env 会变成末尾带有 /config.env 的路径,因为单词设计符会在 / 处停止。

下面是同一会话中的另一个例子。编辑文件后,您希望列出存放该文件的目录:

ls -l !$:h

:h 是 head 修饰符。它会从路径中去掉最后一个组成部分,作用与 dirname 相同。:t 只保留最后一个组成部分,:r 删除扩展名,:e 只保留扩展名。

使用 !! 和 sudo !! 重复上一条命令

您忘记了 sudo,服务管理器也提示了这一点:

systemctl restart nginx
Failed to restart nginx.service: Interactive authentication required.
sudo !!

Bash 会将 !! 替换为上一行命令的文本,因此 shell 会执行 sudo systemctl restart nginx

!! 始终表示上一行命令,无论该行具体是什么。这正是经典误操作发生的地方。您以为上一条命令就是失败的命令,但之后可能又运行了 cdhistory,也可能失败的命令实际位于上两行。此时,sudo !! 会以 root 权限执行错误的命令。不确定时,先打印展开后的命令:

sudo !!:p

:p 会打印展开后的命令行,但不会执行它。打印出的命令会添加到历史记录中。如果内容正确,下一行直接运行 !! 即可执行它。

使用 !$ 重用上一个命令的最后一个参数

!$ 表示上一行的最后一个单词。这是最常用的形式,因为最后一个单词通常就是你正在处理的内容:路径或服务名称。

sudo systemctl status nginx
sudo systemctl reload !$

这里有两点容易让人意外。

首先,!$ 表示最后一个单词,而不是你认为的最后一个参数。如果上一行以重定向结尾,最后一个单词就是重定向目标:

sudo nginx -T > /tmp/nginx-dump.conf
less !$

这个例子结果正常。但在 journalctl -u nginx > /tmp/log.txt 之后,!$/tmp/log.txt,而不是 nginx。请查看回显的命令行。

其次,这种展开是文本展开,因此变量会以未展开的形式返回。在 ls $HOME/backups 之后,!$ 会得到字符 $HOME/backups,然后 bash 会再次将其作为普通参数展开。请记住这个顺序:历史展开先于参数展开,也先于使用 $( ) 的 命令替换,因此它看到的始终是文本,而不是值。

相邻的形式也值得了解。!^ 表示第一个参数,!:2 表示第二个参数,!:2-4 表示一个范围。!!:$!$ 的完整写法。

如果你想在执行前先查看文本,请按 Alt-.(或先按 Esc,再按 .)。Readline 会直接将上一条命令的最后一个参数插入提示符,供你编辑。再次按下该快捷键,可以切换到再上一条命令的最后一个参数。在按下 Enter 之前,不会执行任何命令。

使用 !* 传递所有参数

!* 表示上一行中除第一个单词以外的所有单词。

stat /srv/www/app/shared/config.env /srv/www/app/shared/secrets.env
sudo chmod 600 !*

“除第一个单词以外”这一点非常重要。!* 会删除单词 0,不会删除其他内容,因此选项也会随路径一起传递。执行 ls -l file1 file2 后,!*-l file1 file2,因此 sudo chmod 600 !* 会失败,因为 chmod 会将 -l 作为参数接收。将单词编号从 0 开始,然后使用切片:!!:2* 表示从单词 2 到末尾的所有内容。

sudo 也存在相同的问题。执行 sudo chown deploy:deploy /srv/www/app 后,单词 0 是 sudo,单词 1 是 chown,因此 !* 会将 chown deploy:deploy /srv/www/app 传递给下一个命令。这通常不是您想要的结果。

按位置使用 !n 和 !-n 选择命令

history 会在每条记录前显示编号。

history 5
  512  sudo nginx -t
  513  sudo systemctl reload nginx
  514  ss -tulpn
  515  sudo tail -f /var/log/nginx/error.log
  516  history 5

!513 会再次运行第 513 条记录。!-2 会运行从当前正在输入的这一行开始向前数两行的记录,因此 !-1!! 表示同一条记录。

使用编号前应立即检查它们。每次运行任何命令(包括不假思索执行的 ls)后,!-2 指向的位置都会变化。绝对编号只在当前会话中稳定;在同一台主机上的第二个会话中,编号会不同。下次登录并重新读取历史文件后,编号也会不同。您昨天记住的编号,今天可能指向另一条命令。

使用 !string 前缀重新运行命令

!string 会运行最近一条以 string 开头的命令。

!ss

这会重新运行上方列表中的 ss -tulpn,即 Linux 服务器上开放了哪些端口 中介绍的监听套接字检查。!?string? 会在整行的任意位置匹配,而不只是匹配开头;如果您记得某个参数但不记得命令名称,这会很有用。

前缀应尽量长。!s 可能匹配 sssudosystemctlshutdown,并运行其中最近执行的一条。在命令运行前,您无法知道具体匹配的是哪一条。!string:p 只会打印匹配结果,不会运行命令。如果没有匹配项,bash 会打印 bash: !ss: event not found,且不会运行任何命令,这是安全的结果。

使用 ^old^new 修正一个拼写错误

sudo systemctl status ngnix
Unit ngnix.service could not be found.
^ngnix^nginx

Shell 会重新执行上一行命令,并将第一个 ngnix 替换为 nginx。只会修改第一个匹配项。要修改所有匹配项,请使用完整形式 !!:gs/ngnix/nginx/,其中 s 表示替换,g 表示对整行执行替换。

在执行前打印展开结果

以下两种习惯可以避免展开执行了非预期命令。

第一种是 :p,前文已经介绍过。将它追加到展开内容后,bash 会打印结果,而不会执行它:!!:p!systemctl:p。打印出的命令会写入历史记录,因此随后运行 !! 就会执行刚才看到的命令。

第二种方式更可靠,因为它会应用于所有展开,无需记住任何操作:

shopt -s histverify

将这一行写入 ~/.bashrc。设置 histverify 后,按 Enter 不会立即执行展开内容。Bash 会将展开后的命令写回提示符,供您查看和编辑;再次按 Enter 才会执行。它只多占用一次按键操作,却可以消除整类误执行事故。该功能依赖 readline,因此只在交互式提示符下有效,在其他环境中不可用。

Ctrl-R 是另一种操作方式,并且它不会执行任何展开。按 Ctrl-R,然后输入命令的一部分。提示符会变为 reverse-i-search 提示符,并在您输入时显示最近的匹配项。再次按 Ctrl-R 可继续查看更早的匹配项。按 Enter 可执行当前显示的命令。按 Ctrl-G 可取消搜索并恢复原始提示符。按左箭头键可结束搜索,并将匹配到的命令保留在提示符中以供编辑。

如果您想先查看命令,请使用 Ctrl-R。如果命令只在一行之前执行过,并且仍显示在屏幕上,可以使用 !!!$

为什么 echo "done!" 会提示 event not found

echo "deploy done!"
bash: !": event not found

历史记录扩展会在解析引号之前执行,双引号不能保护 ! 字符。只有单引号和反斜杠可以。因此,echo 'deploy done!' 才会按预期输出。反斜杠也能阻止扩展,但在双引号中,bash 会将反斜杠保留在输出中,因此使用单引号是更简洁的解决方案。

密码最容易遇到这个问题,因为强密码通常包含 !。类似 mysql -u app -p"S3cret!pass" 这样的命令可能因 event-not-found 错误而失败;如果恰好匹配某条历史记录,还可能在不提示的情况下发送不同的字符串。请使用单引号。更好的做法是让工具提示您输入密码,这样机密内容就不会出现在命令行中。这也是账户安全管理的一部分:请参阅新 VPS 上线后的前十分钟,了解如何在新服务器上处理密钥和密码。

如果您准备粘贴一段包含大量 ! 字符的文本,请使用 set +H 在当前会话中关闭扩展,并使用 set -H 重新启用扩展。

HISTSIZE、HISTFILESIZE 以及历史记录的保存位置

这两个变量容易混淆,因为名称很相近。

  • HISTSIZE 表示运行中的 shell 在内存中保留的命令数量。
  • HISTFILESIZE 表示磁盘文件中保留的行数,文件路径为 ~/.bash_history,除非 HISTFILE 另有指定。

shell 退出时会写入该文件,并在写入时将其截断为 HISTFILESIZE 行。Ubuntu 的默认 ~/.bashrc 将这两个值分别设为 1000 和 2000。检查当前实际保存的值:

grep HIST ~/.bashrc
echo "$HISTSIZE $HISTFILESIZE $HISTFILE"

如果 !string 搜索经常找不到您确定执行过的命令,可以同时增大这两个值。10000 和 20000 很常见,几乎不会带来开销,因为该文件是纯文本。负值表示不限制数量。

当您离开服务器一周后再次登录时,时间戳会很有帮助:

export HISTTIMEFORMAT='%F %T '

之后,history 会在每条历史记录前显示日期和时间,因为 bash 会在文件中每条命令前写入一行包含 epoch 秒数的注释。

常见问题是,同时使用多个终端时历史记录会丢失。每个 shell 都在内存中维护自己的列表,并在退出时写入文件。因此,如果未设置 histappend,最后关闭的 shell 会覆盖其他 shell 保存的内容。以下两个设置可以解决此问题:

shopt -s histappend
export PROMPT_COMMAND='history -a'

histappend 让 shell 将自己的列表追加到文件末尾,而不是替换整个文件。history -a 会在每次显示提示符后追加新行,因此即使会话被强制终止而不是正常退出,已执行的命令也会保留下来。Ubuntu 的默认 ~/.bashrc 已设置 histappend。您运行的机器越多,这一点越重要,因为历史记录按每台机器上的每个用户分别存储。因此,当您从一台工作站管理多台服务器时,所需的 !$ 可能只保存在另一台主机上。

避免机密信息进入 bash 历史记录

HISTCONTROL 决定哪些行会被保存。

  • ignorespace 丢弃所有以空格开头的行。
  • ignoredups 丢弃与上一行完全相同的行。
  • ignoreboth 同时执行上述两项操作。
  • erasedups 删除正在保存的行的所有旧副本。
export HISTCONTROL=ignoreboth

启用 ignorespace 后,在命令开头添加一个空格即可使该命令不进入内存列表,因此也不会写入文件。依赖此行为前,请使用 echo "$HISTCONTROL" 检查其值。Ubuntu 的默认用户 .bashrc 会设置 ignoreboth,但 root shell 或精简镜像可能不会设置该变量。此时开头的空格不会生效,该行会像其他行一样被保存。

请准确了解此设置能保护什么。该行不会出现在 ~/.bash_history 中。进程运行期间,它仍会显示在 ps 输出中,因此系统上的其他用户可以在那里读取它。sudo 会将已执行的命令记录到系统日志中。历史记录文件是纯文本文件,因此应将其权限设为 600,并注意任何能够读取您主目录的用户都能读取最近几千条命令。

如果敏感信息已经在列表中,请删除该条目并重写文件:

history
history -d 517
history -w

history -d 会从内存中删除该条目,history -w 会将当前列表覆盖写入文件。history -c 会清空整个列表。HISTIGNORE 是用于减少无关记录的相关设置,而不是用于保护机密信息:HISTIGNORE='ls:pwd:history:clear' 会让这些行不进入列表,从而使搜索结果更有用。

为什么历史扩展在 shell 脚本中不起作用

历史扩展属于交互式 shell。脚本在非交互式 shell 中运行,此时历史列表未启用,扩展功能也处于关闭状态,因此 !!!$ 会作为普通文本保留在命令行中。脚本中的 sudo !! 会要求 sudo 运行名为 !! 的命令,因此会失败。

检查当前所在的 shell:

echo $-

输出内容是当前选项标志的集合,例如 himBHsi 表示 shell 处于交互模式,H 表示历史扩展已启用。在脚本中运行同一行时,这两个字母都不会出现。

这就是 shell 工作中两种场景的区别。在提示符下,!$ 和 Ctrl-R 可以减少输入仍可查看的命令所需的按键次数。在文件中,应直接命名所需内容:将路径放入变量,或使用命令替换捕获输出。依赖个人历史记录的脚本,对下一个运行它的人会产生不同结果,这与脚本的用途相反。

因此,本页中的每个示例都应在正在运行的提示符下输入。将这些内容粘贴到 .sh 文件中时,行为不会相同。

FAQ

bash 中的 !! 有什么作用?

!! 会展开为上一条命令行的完整文本,因此 sudo !! 会以 root 身份再次运行上一条命令。该展开是文本展开,发生在 bash 解析命令行之前;bash 会在运行前打印展开完成的命令行。如果无法确定上一行的内容,请先输入 sudo !!:p:p 只打印展开结果而不运行命令,并会将打印出的命令行加入历史记录,因此随后运行 !! 即可执行它。

如何重用上一条命令的最后一个参数?

使用 !$。在 sudo mkdir -p /srv/www/app/releases 之后,命令行 ls -ld !$ 会变为 ls -ld /srv/www/app/releases。它会取命令行的最后一个单词,因此如果末尾是重定向目标,该目标会被视为最后一个单词。您也可以在其后追加文本:!$/config.env 会追加到路径中,因为单词指示符在 / 处结束。交互式替代方式是 Alt-.,它会将相同的文本插入提示符,您可以在按 Enter 前查看内容。

为什么文本包含感叹号时,bash 会提示“event not found”?

双引号无法阻止 ! 触发历史展开,因此 echo "done!" 会让 bash 查找历史事件,并打印 bash: !": event not found。单引号可以保护其中的内容,因此应写为 echo 'done!'。反斜杠也可以阻止展开,但在双引号内,bash 会将反斜杠保留在输出中。要粘贴包含 ! 的长文本块,请使用 set +H 在本次会话中关闭历史展开。

为什么在 shell 脚本中,!! 和 !$ 不起作用?

历史展开仅在交互式 shell 中启用。脚本以非交互方式运行,因此 shell 不会建立历史记录列表,!! 会作为普通文本保留在命令行中。运行 echo $- 可查看当前 shell 类型:交互式 shell 会打印包含 iH 的标志,而脚本不会打印这两个标志。在脚本中,应改用变量或命令替换。

如何避免将密码保存到 bash 历史记录中?

~/.bashrc 中设置 HISTCONTROL=ignorespaceHISTCONTROL=ignoreboth,然后以单个空格开头运行命令,该命令就不会被保存。请先使用 echo "$HISTCONTROL" 确认设置值,因为如果该变量未设置,开头的空格不会产生作用。这只能将命令行从 ~/.bash_history 中排除。命令运行期间,它仍会显示在 ps 中,sudo 也会记录它执行的内容。如果机密信息已经保存,请使用 history 找到对应编号,然后运行 history -d <number>,再运行 history -w,以重写该文件。

#bash#shell#history#productivity#cli