Bash 歷史擴充:!! 與 !$ 用法
了解 Bash 中 6 種實用的歷史擴充,使用 !! 重複上一個命令、!$ 取出最後一個引數,並先用 :p 列印確認內容再執行。
Bash 歷史擴充的作用
Bash 歷史擴充會在 bash 執行命令前,從 shell 歷史記錄重建較早的命令列。!! 會重複上一個命令。!$ 會插入上一個命令的最後一個引數。這項替換是文字層級的,並且會先於 bash 將命令列拆分成單字,因此替換後的內容會保留你第一次輸入時的確切字元。
以下 6 種形式幾乎涵蓋你在伺服器上會使用的所有情況:
!!會重複上一整行,sudo !!會以 root 身分再次執行該命令。!$代表上一行的最後一個引數。!*代表上一行的所有引數。!n會執行編號為 n 的歷史記錄項目,!-n會從目前位置向前倒數。!string會執行最近一個以string開頭的命令。^old^new會重新執行上一行,並將第一個old替換為new。
以下所有內容都是在你自己的伺服器上,於互動式提示字元中輸入。指令碼中會關閉歷史擴充;最後一節會說明此設定。
只需輸入一次的長路徑
這是最能立即發揮效益的情境。你正在準備 release 目錄,而路徑很長,再輸入一次很容易打錯字。
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 會將展開後的內容儲存到 history,而不是你輸入的 !$。因此,第 3 行會從展開後的第 2 行取得最後一個引數,第 4 行則從第 3 行取得最後一個引數。第 4 行也顯示,designator 後面可以接文字:!$/config.env 會變成末尾加上 /config.env 的路徑,因為 word designator 會在 / 處停止。
以下是同一個工作階段中的另一個例子。編輯檔案後,你想列出包含該檔案的目錄:
ls -l !$:h:h 是 head modifier。它會移除路徑的最後一個元件,作用與 dirname 相同。:t 只保留最後一個元件,:r 移除副檔名,而 :e 只保留副檔名。
使用 !! 與 sudo !! 重複執行上一個指令
你忘記了 sudo,服務管理器也會明確指出這一點:
systemctl restart nginxFailed to restart nginx.service: Interactive authentication required.sudo !!Bash 會將 !! 替換成上一行文字,因此 shell 會執行 sudo systemctl restart nginx。
!! 就是上一行內容,無論該行原本是什麼。經典的意外通常發生在這裡。你以為上一個指令是失敗的指令,但之後可能又執行了 cd 或 history,也可能是失敗的指令位於前兩行。此時 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 會將其再次作為一般參數展開。請記住這個順序:history expansion 會在 parameter expansion 以及 使用 $( ) 的 command substitution 之前執行,因此它看不到值,只會看到文字。
相鄰的形式也值得了解。!^ 代表第一個引數,!:2 代表第二個引數,而 !:2-4 代表一個範圍。!!:$ 是 !$ 的完整寫法。
如果你想在確認執行前先查看文字,請按 Alt-.(或先按 Esc,再按 .)。Readline 會直接將上一個指令的最後引數插入提示字元,讓你編輯。再次按下該按鍵,可返回再前一個指令的最後引數。在按下 Enter 前,不會執行任何內容。
將所有引數透過 !* 傳遞
!* 是上一行中除了第一個字以外的所有文字。
stat /srv/www/app/shared/config.env /srv/www/app/shared/secrets.env
sudo chmod 600 !*「except the first one」這個條件很重要。!* 只會移除第 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 與 !! 的意思相同。
使用編號前,請立即確認編號內容。每次執行任何命令時,!-2 所指向的位置都會改變,包括你未經思考就執行的 ls。絕對編號只在同一個工作階段內固定;在同一台主機的第二個工作階段中,編號會不同。下次登入並重新讀入 history 檔案後,編號也可能不同。你昨天記住的編號,今天可能會指向另一個命令。
使用 !string 前置字串重新執行命令
!string 會執行最近一個以 string 開頭的命令。
!ss這會重新執行上方清單中的 ss -tulpn,也就是 Linux 伺服器上開啟哪些連接埠 中說明的 listening socket 檢查。!?string? 會在整行的任何位置比對,而不只是在開頭比對。當你記得某個引數,但忘記命令名稱時,這項功能很有幫助。
前置字串應盡量寫長。!s 可能會比對到 ss、sudo、systemctl 或 shutdown,並執行其中最近執行的一個。你必須等到命令執行後,才知道實際比對到哪一個。!string:p 只會顯示比對結果,不會執行命令。若沒有任何相符項目,bash 會顯示 bash: !ss: event not found,且不執行任何命令,這是較安全的結果。
使用 ^old^new 修正一個拼字錯誤
sudo systemctl status ngnixUnit ngnix.service could not be found.^ngnix^nginxShell 會重新執行上一行,並將第一個 ngnix 替換為 nginx。這只會變更第一個符合項目。若要變更所有符合項目,請使用長格式 !!:gs/ngnix/nginx/;其中 s 表示 substitute,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 啟用後的前 10 分鐘,瞭解如何在新伺服器上處理金鑰與密碼。
如果準備貼上包含大量 ! 字元的文字區塊,請使用 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會將執行過的命令記錄在系統日誌中。歷史紀錄檔是純文字檔,請將其權限設為 mode 600,並記住,任何能讀取 home directory 的人,都能讀取你最近幾千筆命令。
如果敏感資訊已經存在清單中,請移除該項目並重新寫入檔案:
history
history -d 517
history -whistory -d會從記憶體中移除該項目,history -w則會將目前的清單覆寫到檔案。history -c會清除整份清單。HISTIGNORE是與雜訊而非秘密資訊相關的設定:HISTIGNORE='ls:pwd:history:clear'會讓這些行不進入清單,使搜尋結果保留有用的命中項目。
Shell script 中的 history expansion 為何沒有作用
History expansion 僅適用於互動式 shell。指令碼會在非互動式 shell 中執行,此時不會啟用 history list,expansion 也處於關閉狀態,因此 !! 和 !$ 會以一般文字留在該行中。指令碼中的 sudo !! 會要求 sudo 執行名稱恰好為 !! 的指令,因此執行會失敗。
請在目前使用的 shell 中執行以下指令:
echo $-輸出內容是目前的選項旗標集合,可能類似 himBHs。i 表示 shell 具備互動模式,H 表示已啟用 history expansion。在指令碼中執行相同的行時,這兩個字母都不會出現。
這就是 shell 工作中兩種情境的差異。在提示字元中,!$ 和 Ctrl-R 可協助重複使用仍看得到的指令,減少重新輸入。在檔案中,則應明確指定內容:將路徑放入變數,或使用 指令替換 擷取輸出。若指令碼依賴個人的 history,其他人執行時就會產生不同結果,這與指令碼的用途相反。
因此,本頁的所有範例都必須在正在使用的提示字元中輸入。將內容貼入 .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 script 中沒有作用?
歷史展開只會在互動式 shell 中啟用。script 以非互動模式執行,因此 shell 不會建立歷史清單,而 !! 會以純文字形式保留在命令列中。執行 echo $- 可確認目前的 shell 類型:互動式 shell 會顯示包含 i 和 H 的旗標,script 則兩者都不會顯示。在 script 中,請改用變數或命令替換。
如何避免將密碼寫入 bash 歷史記錄?
在 ~/.bashrc 中設定 HISTCONTROL=ignorespace 或 HISTCONTROL=ignoreboth,接著讓命令以單一空格開頭,該命令就不會被儲存。請先使用 echo "$HISTCONTROL" 確認設定值,因為若該設定未設定,開頭的空格不會產生作用。這只能讓該命令不出現在 ~/.bash_history 中。命令執行期間仍可在 ps 中看到,而 sudo 會記錄其執行內容。若密密已經儲存,請使用 history 找出其編號,然後執行 history -d <number>,再執行 history -w 以改寫檔案。