SSD Nodes Learn 🎉 VPS từ $4.99/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Bash history expansion: dùng !! và !$

Hiểu 6 history expansion Bash đáng dùng trên server: !!, !$, !n, !!:p và cách xem lệnh trước mà không chạy, tránh lỗi khi nhập lại command dài.

Bash history expansion thực hiện gì

Bash history expansion dựng lại một dòng lệnh trước đó từ shell history trước khi bash chạy lệnh. !! lặp lại lệnh trước đó. !$ chèn đối số cuối của lệnh trước đó. Việc thay thế được thực hiện dưới dạng văn bản và diễn ra trước khi bash tách dòng thành các word, nên kết quả là chính xác các ký tự bạn đã nhập lần đầu.

Sáu dạng dưới đây bao quát gần như mọi thao tác bạn sẽ thực hiện trên server:

  • !! lặp lại toàn bộ dòng trước đó, còn sudo !! chạy lại dòng đó với quyền root.
  • !$ là đối số cuối của dòng trước đó.
  • !* là toàn bộ đối số của dòng trước đó.
  • !n chạy history entry có số n, còn !-n đếm ngược từ vị trí hiện tại.
  • !string chạy lệnh gần nhất bắt đầu bằng string.
  • ^old^new chạy lại dòng trước đó và thay old đầu tiên bằng new.

Toàn bộ nội dung bên dưới được nhập tại interactive prompt trên server của bạn. History expansion bị tắt trong script; phần cuối sẽ giải thích điều này.

Một đường dẫn dài, chỉ cần nhập một lần

Đây là trường hợp giúp tính năng này phát huy hiệu quả. Bạn đang chuẩn bị một thư mục release, và đường dẫn đủ dài để việc nhập lại lần thứ hai dễ gây lỗi chính tả.

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

Trước khi chạy từng dòng, bash in ra dòng lệnh sau khi đã thực hiện expansion:

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

Dòng echo đó là bước kiểm tra của bạn. Hãy đọc nó trước khi xem output của command, vì đây là cơ hội duy nhất để thấy bash đã hiểu !$ là gì.

Chuỗi này hoạt động vì bash lưu dòng đã được expanded vào history, không lưu !$ mà bạn đã nhập. Vì vậy, dòng 3 lấy argument cuối từ dòng 2 đã expanded, còn dòng 4 lấy nó từ dòng 3. Dòng 4 cũng cho thấy text có thể đứng sau một designator: !$/config.env trở thành đường dẫn có thêm /config.env ở cuối, vì word designator dừng tại /.

Một ví dụ khác trong cùng session. Sau khi chỉnh sửa file, bạn muốn liệt kê thư mục chứa file đó:

ls -l !$:h

:h là head modifier. Nó loại bỏ component cuối cùng khỏi một đường dẫn, thực hiện công việc giống dirname. :t chỉ giữ lại component cuối cùng, :r loại bỏ extension, còn :e chỉ giữ lại extension.

Lặp lại lệnh cuối bằng !! và sudo !!

Bạn quên sudo, và service manager báo rõ điều đó:

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

Bash thay !! bằng nội dung của dòng trước đó, nên shell chạy sudo systemctl restart nginx.

!! luôn chính xác là dòng ngay trước đó, bất kể dòng đó là gì. Đây là lúc xảy ra lỗi kinh điển. Bạn nghĩ lệnh cuối là lệnh đã fail, nhưng sau đó bạn đã chạy cd hoặc history, hoặc lệnh bị fail nằm cách đó 2 dòng. Khi đó, sudo !! sẽ chạy nhầm lệnh với quyền root. Nếu không chắc, hãy in lệnh ra trước:

sudo !!:p

:p in dòng lệnh sau khi thay thế nhưng không chạy lệnh. Dòng đã in được thêm vào history. Nếu nội dung đúng, chạy !! trên dòng tiếp theo để thực thi lệnh.

Dùng lại đối số cuối với !$

!$ là từ cuối cùng của dòng trước đó. Đây là dạng bạn sẽ dùng nhiều nhất, vì từ cuối thường là đối tượng bạn đang xử lý: một đường dẫn hoặc tên service.

sudo systemctl status nginx
sudo systemctl reload !$

Có hai điểm thường gây bất ngờ.

Thứ nhất, !$từ cuối cùng, không phải đối số cuối mà bạn đang nghĩ đến. Nếu dòng trước đó kết thúc bằng một phép chuyển hướng, từ cuối cùng sẽ là đích của phép chuyển hướng:

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

Trường hợp đó cho kết quả đúng như mong đợi. Nhưng sau journalctl -u nginx > /tmp/log.txt, !$/tmp/log.txt chứ không phải nginx. Hãy đọc dòng được echo.

Thứ hai, phép mở rộng này hoạt động trên văn bản, nên một biến sẽ được đưa trở lại ở dạng chưa mở rộng. Sau ls $HOME/backups, !$ cho bạn các ký tự $HOME/backups, rồi bash tiếp tục mở rộng chúng như một parameter thông thường. Hãy nhớ thứ tự này: history expansion chạy trước parameter expansion và trước command substitution với $( ), nên nó không nhìn thấy giá trị, chỉ nhìn thấy văn bản.

Bạn cũng nên biết các dạng liền kề. !^ là đối số thứ nhất, !:2 là đối số thứ hai, còn !:2-4 là một dải đối số. !!:$ là cách viết dài của !$.

Nếu muốn xem văn bản trước khi thực thi, hãy nhấn Alt-. (hoặc Esc rồi nhấn .). Readline chèn đối số cuối của command trước đó trực tiếp vào prompt, để bạn có thể chỉnh sửa. Nhấn lại để lùi về đối số cuối của command đứng trước đó nữa. Chưa có gì chạy cho đến khi bạn nhấn Enter.

Chuyển tiếp mọi đối số bằng !*

!* là mọi từ của dòng trước, ngoại trừ từ đầu tiên.

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

Cụm “ngoại trừ từ đầu tiên” có ý nghĩa quan trọng. !* bỏ từ 0 và không bỏ gì khác, nên các tùy chọn vẫn được truyền cùng với các đường dẫn. Sau ls -l file1 file2, !*-l file1 file2, nên sudo chmod 600 !* bị lỗi vì chmod nhận -l làm đối số. Hãy đánh số các từ bắt đầu từ 0 rồi lấy một lát cắt thay thế: !!:2* có nghĩa là từ 2 đến hết.

Bẫy tương tự cũng xảy ra với sudo. Sau sudo chown deploy:deploy /srv/www/app, từ 0 là sudo và từ 1 là chown, nên !* truyền chown deploy:deploy /srv/www/app cho lệnh tiếp theo. Đây hiếm khi là điều bạn muốn.

Chọn lệnh theo vị trí bằng !n và !-n

history in danh sách, với một số đứng trước mỗi mục.

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 chạy lại mục 513. !-2 chạy mục cách dòng hiện tại hai dòng về trước, vì vậy !-1!! có cùng ý nghĩa.

Kiểm tra các số ngay trước khi sử dụng. !-2 trỏ đến một mục khác mỗi khi bạn chạy bất kỳ lệnh nào, kể cả ls mà bạn thực hiện mà không suy nghĩ. Số tuyệt đối ổn định trong một session, nhưng không giữ nguyên giữa hai session trên cùng một máy. Chúng cũng thay đổi sau lần đăng nhập tiếp theo, khi shell đọc lại file history. Một số bạn ghi nhớ hôm qua có thể trỏ đến lệnh khác hôm nay.

Chạy lại lệnh theo tiền tố bằng !string

!string chạy lệnh gần nhất bắt đầu bằng string.

!ss

Lệnh này chạy lại ss -tulpn trong danh sách trên, tức lệnh kiểm tra socket đang listen được mô tả trong các cổng nào đang mở trên máy chủ Linux. !?string? khớp với bất kỳ vị trí nào trong dòng thay vì chỉ ở đầu dòng. Cách này hữu ích khi bạn nhớ một đối số nhưng không nhớ tên lệnh.

Hãy dùng tiền tố đủ dài. !s sẽ khớp với ss, sudo, systemctl hoặc shutdown, tùy lệnh nào được chạy gần đây nhất. Bạn sẽ không biết lệnh nào được chọn cho đến khi nó chạy. !string:p in ra kết quả khớp mà không chạy lệnh. Nếu không có kết quả khớp, bash in bash: !ss: event not found và không chạy gì cả. Đây là hành vi an toàn.

Sửa một lỗi gõ bằng ^old^new

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

Shell chạy lại dòng trước đó và thay ngnix đầu tiên bằng nginx. Chỉ kết quả khớp đầu tiên được thay đổi. Để thay đổi mọi kết quả khớp, dùng dạng đầy đủ !!:gs/ngnix/nginx/, trong đó s có nghĩa là substitute và g có nghĩa là thực hiện trên toàn bộ dòng.

In nội dung mở rộng trước khi chạy

Hai thói quen giúp ngăn sự cố trong đó nội dung mở rộng chạy một lệnh không đúng ý bạn.

Thứ nhất là :p, bạn đã gặp ở phần trước. Thêm nó vào nội dung mở rộng, bash sẽ in kết quả thay vì chạy: !!:p hoặc !systemctl:p. Dòng được in sẽ được lưu vào history, vì vậy chạy !! sau đó sẽ thực thi nội dung bạn vừa đọc.

Cách thứ hai mạnh hơn vì áp dụng cho mọi nội dung mở rộng mà bạn không cần nhớ thêm gì:

shopt -s histverify

Thêm dòng đó vào ~/.bashrc. Khi histverify được đặt, nội dung mở rộng sẽ không chạy ngay khi bạn nhấn Enter. Bash ghi dòng đã mở rộng trở lại prompt để bạn đọc và chỉnh sửa, sau đó bạn nhấn Enter lần thứ hai để chạy. Cách này chỉ thêm một lần nhấn phím và loại bỏ toàn bộ nhóm sự cố này. Nó cần readline, nên chỉ hoạt động tại prompt tương tác và không hoạt động ở nơi khác.

Ctrl-R là cách làm việc khác và không bao giờ mở rộng nội dung nào. Nhấn Ctrl-R rồi nhập một phần lệnh. Prompt chuyển thành prompt reverse-i-search và hiển thị kết quả khớp gần nhất khi bạn nhập. Nhấn Ctrl-R lần nữa để chuyển về các kết quả khớp cũ hơn. Nhấn Enter để chạy dòng đang hiển thị. Ctrl-G hủy tìm kiếm và khôi phục prompt ban đầu. Phím mũi tên trái kết thúc tìm kiếm và giữ dòng khớp trong prompt để bạn chỉnh sửa.

Dùng Ctrl-R khi bạn muốn xem lệnh trước. Dùng !!!$ khi lệnh vừa chạy ở dòng trước và bạn vẫn còn đọc được nó trên màn hình.

Vì sao echo "done!" báo lỗi event not found

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

History expansion chạy trước khi shell xử lý việc phân tách chuỗi, và dấu ngoặc kép không bảo vệ ký tự !. Chỉ dấu nháy đơn và dấu gạch chéo ngược mới làm được việc đó. Vì vậy, echo 'deploy done!' sẽ in ra đúng nội dung bạn mong muốn. Dấu gạch chéo ngược cũng ngăn expansion, nhưng bên trong dấu ngoặc kép, bash giữ lại dấu gạch chéo ngược trong output. Vì thế, dùng dấu nháy đơn là cách sửa gọn nhất.

Lỗi này đặc biệt dễ gặp với password, vì password mạnh thường chứa !. Một command như mysql -u app -p"S3cret!pass" có thể fail với lỗi event not found. Hoặc nếu tình cờ khớp với một history entry, command sẽ âm thầm gửi đi một chuỗi khác. Hãy dùng dấu nháy đơn. Tốt hơn nữa, hãy để tool prompt bạn nhập password để secret không xuất hiện trên command line. Thói quen này cũng thuộc nhóm các biện pháp bảo vệ account: xem mười phút đầu tiên trên một VPS mới để biết cách xử lý key và password trên một máy mới.

Nếu bạn sắp paste một block text chứa nhiều ký tự !, hãy tắt expansion trong session bằng set +H và bật lại bằng set -H.

HISTSIZE, HISTFILESIZE và nơi lưu history

Có 2 biến. Tên gần giống nhau nên chúng thường bị nhầm.

  • HISTSIZE là số lệnh mà shell đang chạy giữ trong memory.
  • HISTFILESIZE là số dòng được giữ trong file trên disk, ~/.bash_history trừ khi HISTFILE chỉ định giá trị khác.

File được ghi khi shell thoát. Khi đó file bị cắt còn HISTFILESIZE dòng. Mặc định của Ubuntu, ~/.bashrc đặt giá trị 1000 và 2000. Kiểm tra giá trị thực tế trên hệ thống của bạn:

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

Tăng cả 2 giá trị nếu các lần tìm bằng !string thường không tìm thấy những lệnh bạn biết là đã chạy. Giá trị 10000 và 20000 khá phổ biến và gần như không tốn tài nguyên vì file chỉ là plain text. Giá trị âm nghĩa là không giới hạn.

Timestamp giúp bạn biết thời điểm chạy lệnh khi quay lại server sau một tuần:

export HISTTIMEFORMAT='%F %T '

Sau đó history sẽ in ngày và giờ trước mỗi entry. bash thực hiện việc này bằng cách ghi một dòng comment chứa epoch seconds trước mỗi command trong file.

Một vấn đề thường gặp là history bị thiếu khi bạn dùng nhiều terminal. Mỗi shell giữ một danh sách riêng trong memory và ghi danh sách đó khi thoát. Vì vậy, nếu không có histappend, shell thoát sau cùng sẽ ghi đè lên những gì các shell khác đã lưu. Có 2 thiết lập khắc phục việc này:

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

histappend khiến shell nối danh sách của nó vào cuối file thay vì thay thế file. history -a ghi thêm các dòng mới sau mỗi prompt, nên một session bị kill thay vì thoát đúng cách vẫn lưu lại các lệnh đã chạy. Mặc định của Ubuntu, ~/.bashrc đã đặt histappend. Điều này càng quan trọng khi bạn quản lý nhiều máy hơn, vì history được lưu riêng theo từng user trên từng machine. Vì vậy, khi bạn quản lý nhiều server từ một workstation, !$ bạn cần có thể chỉ nằm trên một host khác.

Đưa secret ra khỏi bash history

HISTCONTROL quyết định dòng nào được lưu.

  • ignorespace bỏ qua mọi dòng bắt đầu bằng khoảng trắng.
  • ignoredups bỏ qua dòng giống hệt dòng ngay trước đó.
  • ignoreboth thực hiện cả hai việc trên.
  • erasedups xóa mọi bản sao cũ hơn của dòng đang được lưu.
export HISTCONTROL=ignoreboth

Khi bật ignorespace, một khoảng trắng ở đầu dòng sẽ giữ command khỏi danh sách trong bộ nhớ, nên command cũng không bao giờ được ghi vào file. Kiểm tra giá trị này trước khi dựa vào nó bằng echo "$HISTCONTROL". User mặc định của Ubuntu là .bashrc và đặt ignoreboth, nhưng root shell hoặc image tối giản có thể không đặt biến này. Khi đó, khoảng trắng ở đầu dòng không có tác dụng và dòng được lưu như mọi dòng khác.

Hãy xác định rõ cơ chế này bảo vệ được gì. Dòng đó không xuất hiện trong ~/.bash_history. Trong lúc process chạy, nó vẫn hiển thị trong output của ps, nên bất kỳ user nào khác trên máy cũng có thể đọc được tại đó. sudo ghi command đã chạy vào system log. History file là plain text, vì vậy hãy đặt mode 600 cho file này và nhớ rằng bất kỳ ai có quyền đọc home directory của bạn đều có thể đọc vài nghìn command gần nhất.

Nếu một giá trị nhạy cảm đã nằm trong danh sách, hãy xóa entry đó rồi ghi lại file:

history
history -d 517
history -w

history -d xóa entry đó khỏi bộ nhớ, còn history -w ghi danh sách hiện tại đè lên file. history -c xóa toàn bộ danh sách. HISTIGNORE là tùy chọn liên quan dành cho các dòng gây nhiễu thay vì secret: HISTIGNORE='ls:pwd:history:clear' giữ những dòng đó khỏi danh sách để kết quả tìm kiếm chỉ trả về các kết quả hữu ích.

Vì sao history expansion không hoạt động trong shell script

History expansion chỉ dùng cho shell tương tác. Script chạy trong shell không tương tác, nơi history list không được bật và expansion bị tắt, nên !!!$ vẫn nằm trong dòng dưới dạng văn bản thông thường. sudo !! trong script yêu cầu sudo chạy một command có tên đúng là !!, nên command đó thất bại.

Kiểm tra shell hiện tại:

echo $-

Output là tập các option flag hiện tại, thường có dạng himBHs. i cho biết shell đang tương tác và H cho biết history expansion đang được bật. Chạy cùng dòng lệnh bên trong script thì cả hai chữ cái này đều không xuất hiện.

Đó là ranh giới giữa hai cách làm việc với shell. Tại prompt, !$ và Ctrl-R giúp giảm thao tác gõ khi bạn vẫn còn nhìn thấy các command. Trong file, bạn phải đặt tên cho dữ liệu: lưu path vào một variable hoặc lấy output bằng command substitution. Nếu script phụ thuộc vào history riêng của bạn, nó sẽ hoạt động khác với người chạy nó tiếp theo. Điều đó trái với mục đích của script.

Vì vậy, mọi ví dụ trên trang này đều được viết để chạy tại prompt đang hoạt động. Chúng không hoạt động theo cách tương tự khi được paste vào file .sh.

FAQ

!! trong bash dùng để làm gì?

!! mở rộng thành toàn bộ nội dung của dòng lệnh trước đó, nên sudo !! sẽ chạy lại lệnh gần nhất với quyền root. Việc mở rộng được thực hiện dưới dạng văn bản trước khi bash phân tích cú pháp dòng lệnh, và bash in dòng lệnh hoàn chỉnh ngay trước khi chạy. Nếu không chắc dòng trước đó là gì, hãy nhập sudo !!:p trước. :p in phần mở rộng mà không chạy, đồng thời thêm dòng đã in vào history, nên lệnh !! tiếp theo sẽ chạy dòng đó.

Làm thế nào để dùng lại đối số cuối của lệnh trước?

Dùng !$. Sau sudo mkdir -p /srv/www/app/releases, dòng ls -ld !$ trở thành ls -ld /srv/www/app/releases. Lệnh này lấy word cuối cùng của dòng, nên target của redirection ở cuối dòng được tính là word cuối. Bạn cũng có thể nối thêm văn bản vào đó: !$/config.env nối thêm vào path, vì word designator dừng tại /. Cách tương tác thay thế là Alt-., chèn cùng đoạn văn bản vào prompt để bạn xem trước khi nhấn Enter.

Tại sao bash báo "event not found" khi văn bản có dấu chấm than?

Dấu ngoặc kép không ngăn ! khỏi history expansion, nên echo "done!" khiến bash tìm một history event rồi in bash: !": event not found. Dấu nháy đơn ngăn được việc này, vì vậy hãy viết echo 'done!'. Dấu gạch chéo ngược cũng dừng expansion, nhưng bên trong dấu ngoặc kép, bash giữ lại dấu gạch chéo ngược trong output. Để dán một block dài có chứa !, hãy tắt expansion cho session bằng set +H.

Tại sao !!echo $- không hoạt động trong shell script?

History expansion chỉ được bật trong interactive shell. Script chạy non-interactive, nên shell không tạo history list và !! được giữ nguyên trong dòng dưới dạng văn bản. Chạy echo $- để xem bạn đang ở loại shell nào: interactive shell in các flag gồm iH, còn script không in flag nào trong hai flag này. Trong script, hãy dùng biến hoặc command substitution.

Làm thế nào để không lưu password vào bash history?

Đặt HISTCONTROL=ignorespace hoặc HISTCONTROL=ignoreboth trong ~/.bashrc, sau đó bắt đầu lệnh bằng một dấu cách thì lệnh sẽ không được lưu. Trước tiên hãy xác nhận giá trị bằng echo "$HISTCONTROL", vì nếu biến chưa được set thì dấu cách ở đầu không có tác dụng. Cách này chỉ loại dòng lệnh khỏi ~/.bash_history. Lệnh vẫn hiển thị trong ps khi đang chạy, và sudo vẫn ghi lại những gì nó đã thực thi. Nếu secret đã được lưu, hãy tìm số của dòng đó bằng history, rồi chạy history -d <number> theo sau là history -w để ghi lại file.

#bash#shell#history#productivity#cli