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

Bash globbing: wildcard, nullglob, dotglob và extglob

Hiểu Bash mở rộng wildcard thành tên file trước khi chạy lệnh, cùng nullglob, dotglob, globstar và phủ định extglob để tránh lỗi khi xử lý file.

Bash thực hiện globbing như thế nào trước khi chạy lệnh

Bash globbing là quá trình shell chuyển một pattern như *.log thành danh sách tên file thực tế đã được sắp xếp trước khi lệnh bắt đầu. rm *.log không bao giờ truyền một pattern cho rm. Bash đọc thư mục, giữ lại các tên khớp với pattern, sắp xếp chúng, rồi chạy rm với mỗi tên là một argument riêng. Chương trình rm không thể biết trước đó đã có một pattern được sử dụng.

Hầu hết hành vi khó đoán của globbing đều bắt nguồn từ thực tế này. Shell thực hiện việc khớp pattern dựa trên các file tồn tại tại thời điểm đó, còn lệnh chỉ nhận danh sách đã hoàn tất. Tên gọi chính thức của quá trình này là pathname expansion. Globbing là cách gọi thông dụng; hai thuật ngữ này có cùng nghĩa.

Tạo một cây thư mục nhỏ để thực hành

Mọi ví dụ bên dưới đều chạy trên cùng một nhóm file. Hãy tạo chúng trong một thư mục tạm thời để không ảnh hưởng đến các file quan trọng và có thể dọn dẹp bằng một lệnh.

lab=$(mktemp -d)
cd "$lab"
mkdir -p logs archive/2025 archive/2026
touch app.log app.log.1 app.log.2 app.log.10 error.log debug.LOG
touch notes.txt notes.md README 'weekly report.log'
touch .env .apprc
touch logs/nginx.log logs/nginx.log.1
touch archive/2025/dec.log archive/2026/jan.log

mktemp -d tạo một thư mục mới rồi trả về đường dẫn của thư mục đó, còn $(...) lưu đường dẫn này vào biến lab. Nếu cú pháp này còn mới với bạn, cách command substitution nhận output của một lệnh giải thích đầy đủ.

Hãy xem những gì bạn đã tạo và kiểm tra shell, vì một lưu ý ở cuối phụ thuộc vào phiên bản:

ls -a
bash --version

Mọi nội dung ở đây đều hoạt động trên bash 4.0 trở lên. Lưu ý về globskipdots gần cuối áp dụng cho bash 5.2 trở lên, là phiên bản được các bản phát hành Ubuntu và Debian hiện tại cung cấp tính đến tháng 8 năm 2026.

Lệnh preview được dùng xuyên suốt là printf '%s\n' PATTERN. Lệnh này in mỗi kết quả khớp trên một dòng và không thay đổi gì trên disk, nên an toàn khi chạy với mọi pattern mà bạn chưa chắc chắn.

Ba wildcard: dấu sao, dấu hỏi và dấu ngoặc vuông

  • * khớp với một chuỗi ký tự bất kỳ, kể cả chuỗi rỗng.
  • ? khớp chính xác một ký tự.
  • [...] khớp chính xác một ký tự thuộc tập ký tự bên trong dấu ngoặc vuông.
printf '%s\n' *.log
printf '%s\n' app.log.?
printf '%s\n' app.log.[0-9]
printf '%s\n' notes.*
printf '%s\n' *

So sánh dòng thứ hai với danh sách file bạn đã tạo. ? đại diện cho đúng một ký tự và không hơn, nên hậu tố gồm hai chữ số không khớp với mẫu đó. Mở rộng thành app.log.[0-9]* rồi chạy lại để thấy sự khác biệt.

Có hai quy tắc áp dụng cho cả ba wildcard. Wildcard không bao giờ khớp với /, vì vậy một mẫu chỉ nằm trong một cấp thư mục. Ngoài ra, . ở đầu tên phải được nhập theo nghĩa đen, nên mẫu bắt đầu bằng * sẽ bỏ qua mọi tên ẩn. Đây là lý do rm * không tác động đến các dotfile.

Kết quả glob được sắp xếp theo thứ tự collation của locale hiện tại, tức là sắp xếp văn bản theo từng ký tự. Vì vậy, hậu tố 10 được sắp xếp trước hậu tố 2, vì phép so sánh không đọc hậu tố nào dưới dạng số. Chạy printf '%s\n' app.log.* rồi chạy ls -v app.log.* và so sánh hai kết quả: ls -v sắp xếp các số nằm trong chuỗi theo giá trị, còn sort -V thực hiện việc tương tự trong pipeline.

Lớp ký tự và khoảng

Bên trong dấu ngoặc vuông, bạn có thể viết một danh sách, một khoảng hoặc một điều kiện phủ định.

  • [ch] khớp với một ký tự, có thể là c hoặc h.
  • [a-f] khớp với một ký tự trong khoảng từ a đến f.
  • [!0-9] khớp với một ký tự không phải chữ số. [^0-9] cũng có cùng ý nghĩa.
printf '%s\n' [dn]*
printf '%s\n' *.[A-Z]*
printf '%s\n' [A-Z]*

Khoảng là phần gây ra khác biệt giữa các máy. Shell xác định khoảng theo thứ tự collation của locale hiện tại thay vì ASCII thuần, nên trên một số hệ thống [a-z] cũng khớp với các chữ cái viết hoa. Bash có tùy chọn globasciiranges để buộc dùng thứ tự ASCII thuần. Các bản build gần đây bật tùy chọn này theo mặc định, vì vậy hãy kiểm tra bằng shopt globasciiranges thay vì tự giả định.

Một script phải khớp giống nhau trên mọi hệ thống có thể tự cố định thứ tự bằng cách đặt LC_ALL=C trên một dòng riêng, gần đầu file. Cách này đưa mọi khoảng về lại thứ tự byte thuần.

Phép gán đó phải là một command độc lập. Khi được viết làm tiền tố, LC_ALL=C ls [a-z]* chỉ đặt locale cho ls, không ảnh hưởng đến phần nào khác. Shell đã mở rộng pattern bằng locale của chính nó trước khi ls bắt đầu. Đây chính là quy tắc trong phần đầu được áp dụng lại.

Mở rộng dấu ngoặc nhọn không phải globbing

Dấu ngoặc nhọn trông giống wildcard nhưng hoạt động hoàn toàn khác. Brace expansion chạy sớm hơn globbing rất nhiều và không bao giờ kiểm tra filesystem.

echo file{1,2,3}.txt
echo {01..10}
echo {a..e}
echo {0..20..5}

Không file nào trong số đó tồn tại nhưng quá trình mở rộng vẫn diễn ra. Đây là khác biệt cốt lõi: glob hỏi filesystem, còn brace chỉ tạo ra văn bản. Vì vậy, brace phù hợp để tạo đối tượng hơn là tìm đối tượng.

mkdir -p site/{css,js,img}
cp app.log{,.bak}
touch report-{2025,2026}-{01,02}.csv

cp app.log{,.bak} mở rộng thành 2 từ, app.logapp.log.bak, vì mục rỗng trước dấu phẩy giữ lại văn bản ban đầu. {01..10} giữ nguyên các số 0 ở đầu, vì khi một trong hai endpoint có số 0 ở đầu, bash sẽ đệm mọi số được tạo ra để có cùng độ rộng.

Thứ tự mở rộng cũng giải thích một lỗi thường gặp. Bash mở rộng brace trước, sau đó mở rộng biến, rồi cuối cùng mới thực hiện pathname expansion.

n=5
echo {1..$n}

Brace expansion đã hoàn tất trước khi $n trở thành một số, nên không có range nào được tạo. Hãy dùng seq 1 "$n" hoặc vòng lặp theo kiểu C được viết là for ((i=1; i<=n; i++)).

Điều gì xảy ra khi pattern không khớp với tệp nào

Đây là lỗi đầu tiên, và ai cũng sẽ gặp ít nhất một lần. Theo mặc định, bash giữ nguyên pattern không khớp. Chuỗi đó được truyền đến command đúng như bạn đã nhập, vì vậy command nhận các ký tự *, ., t, mp rồi cố xử lý chúng như một filename.

printf '%s\n' *.tmp

Điều này không gây hại với printf. Nhưng với command tạo đối tượng thì không như vậy. touch *.tmp trong một directory không có tệp .tmp nào sẽ tạo một file có tên đúng nghĩa là *.tmp. Để xóa file đó, bạn phải dùng dấu nháy để shell không expand pattern lần nữa: rm -- '*.tmp'.

Có 2 shell option thay đổi quy tắc này. Hãy thử từng option với một pattern không khớp tệp nào, rồi unset option đó.

shopt -s nullglob
printf '%s\n' *.tmp
shopt -u nullglob

shopt -s failglob
printf '%s\n' *.tmp
shopt -u failglob

nullglob khiến pattern không khớp được expand thành không có gì, nên command chạy với ít argument hơn số bạn đã viết. Đây là hành vi bạn cần khi dùng trong loop. Khi tắt nullglob, for f in *.tmp chạy phần thân 1 lần với f chứa pattern literal. Đây là lỗi trong gần như mọi script viết theo cách này. Khi bật nullglob, phần thân chạy 0 lần.

nullglob có rủi ro riêng, vì command vẫn chạy ngay cả khi tất cả argument đã bị loại bỏ. ls *.nope trở thành một ls trống, nên liệt kê toàn bộ directory. grep -l needle *.nope trở thành grep -l needle, không có file nào để đọc nên chờ trên standard input và trông như bị treo. Hãy bật nullglob quanh loop cần option này, rồi tắt lại sau đó.

failglob chọn cách xử lý ngược lại: pattern không khớp được xem là lỗi, bash báo lỗi và command không chạy. Đây là thiết lập phù hợp cho interactive shell, vì nó ngăn pattern nhập sai được truyền cho rm dưới dạng tên literal.

Tệp ẩn: dotglob

shopt -s dotglob
printf '%s\n' *
shopt -u dotglob

Chạy block đó rồi so sánh danh sách với printf '%s\n' * thông thường ở phần trước. dotglob loại bỏ quy tắc chấm ở đầu tên, vì vậy * cũng khớp với tên ẩn. Các mục ... luôn bị loại khỏi kết quả khi dùng dotglob.

Không dùng dotglob, .* là cách thông thường để truy cập tệp ẩn, nhưng trước đây cách này nguy hiểm. Trên bash trước phiên bản 5.2, .* cũng khớp với ..., vì vậy một lệnh đệ quy như chmod -R 755 .* sẽ đi thẳng vào thư mục cha. Bash 5.2 bổ sung tùy chọn globskipdots và bật tùy chọn này theo mặc định, nhờ đó ... luôn bị loại khỏi mọi phép mở rộng. Chạy shopt globskipdots trước khi dựa vào tùy chọn này, vì các máy cũ hơn sẽ không có nó.

globstar: khớp toàn bộ cây thư mục

shopt -s globstar
printf '%s\n' **/*.log
printf '%s\n' **/
shopt -u globstar

Khi bật globstar, ** tạo thành một thành phần hoàn chỉnh của đường dẫn sẽ khớp với file và thư mục ở mọi độ sâu, kể cả độ sâu 0. Vì vậy, **/*.log bao phủ cả thư mục hiện tại, logs/ và mọi thứ bên dưới archive/. **/ có dấu gạch chéo ở cuối chỉ khớp với thư mục, nên đây là cách nhanh để xem cấu trúc của một cây thư mục.

Khi chưa bật globstar, ** hoạt động như một * thông thường và chỉ tìm trong một thư mục. Hãy chạy cùng pattern khi tắt tùy chọn này rồi so sánh: nếu một pattern đệ quy được sao chép từ nơi khác trả về ít kết quả hơn nhiều so với dự kiến, nguyên nhân thường là vậy. globstar mặc định bị tắt trong mọi phiên bản bash.

Có một giới hạn cần biết: ** không đi theo symbolic link trỏ đến thư mục. Nếu cây thư mục được nối bằng symbolic link, hãy dùng find -L thay thế.

nocaseglob, cho tên không phân biệt chữ hoa chữ thường

shopt -s nocaseglob
printf '%s\n' *.log
shopt -u nocaseglob

nocaseglob khiến việc khớp tên file không phân biệt chữ hoa chữ thường, vì vậy hậu tố .LOG viết hoa sẽ khớp với mẫu .log viết thường. Hãy so sánh danh sách này với danh sách ở section đầu tiên. Tắt tùy chọn ngay sau đó, vì nếu để bật, tùy chọn này sẽ thay đổi việc khớp cho mọi command tiếp theo trong shell đó và sẽ gây khó khăn khi debug sau một giờ.

nocaseglob chỉ áp dụng cho việc mở rộng tên file. Tùy chọn liên quan đến việc khớp pattern bên trong câu lệnh case hoặc phép kiểm tra ngoặc kép là nocasematch, và hai tùy chọn này được thiết lập riêng.

extglob: các pattern có thể nói “không phải cái này”

Các pattern mở rộng mặc định bị tắt. Bật chúng trên một dòng riêng:

shopt -s extglob

Có 5 dạng, mỗi dạng nhận một danh sách pattern được phân tách bằng |:

  • ?(list) khớp 0 hoặc 1 lần xuất hiện.
  • *(list) khớp 0 hoặc nhiều lần.
  • +(list) khớp 1 hoặc nhiều lần.
  • @(list) khớp chính xác 1 trong các lựa chọn.
  • !(list) khớp mọi thứ không khớp với bất kỳ lựa chọn nào.

Shell đọc pattern mở rộng phải là shell đã bật tùy chọn này. Vì vậy, batch bên dưới được đặt trong một script nhỏ và khởi chạy bằng bash -O extglob. Flag -O đặt một tùy chọn shopt trước khi shell mới đọc dòng đầu tiên của file. Nhờ đó, mọi pattern trong file đều được parse đúng. Đoạn cuối của section này giải thích vì sao thứ tự lại quan trọng.

cat > patterns.sh <<'EOF'
printf '%s\n' @(app|error).log
printf '%s\n' app.log.+([0-9])
printf '%s\n' *.@(md|txt)
printf '%s\n' !(*.log)
printf '%s\n' !(*.log|*.md|README)
EOF
bash -O extglob patterns.sh

+([0-9]) nghĩa là 1 hoặc nhiều chữ số, nên khớp hậu tố được đánh số với độ dài bất kỳ. Đây là cách khắc phục giới hạn ? đã nêu trước đó. Bạn nên chạy cả 2 pattern liên tiếp để thấy sự khác biệt.

Dạng phủ định là cách xử lý yêu cầu “mọi thứ ngoại trừ”, và đó cũng là lý do chính để bật extglob. Có 2 điểm thường gây bất ngờ.

Dạng này khớp cả directory lẫn file. Vì vậy, ls !(*.log) sẽ liệt kê nội dung của mọi directory trong kết quả thay vì tên directory. Dùng ls -d hoặc bản xem trước printf để xem chính tên các directory.

Dạng này cũng tuân theo quy tắc dot như mọi glob khác. Vì vậy, !(*.log) khớp mọi tên hiển thị không kết thúc bằng .log. Để khớp tên ẩn, bạn cũng phải bật dotglob.

! trong !(...) là phép phủ định của glob. Nó không liên quan đến mở rộng history và ký tự bang, đây là một tính năng riêng và chạy ở một giai đoạn khác.

Còn một bẫy nữa, và bẫy này xảy ra khi parse. Bash parse toàn bộ command trước khi chạy bất kỳ phần nào. Vì vậy, bật extglob và dùng pattern mở rộng trên cùng một dòng sẽ thất bại: pattern được parse khi tùy chọn vẫn đang tắt. Đặt shopt -s extglob trên một dòng riêng, trước các pattern và gần đầu script. Bash parse phần thân của function khi function được định nghĩa, nên tùy chọn phải được bật trước phần định nghĩa, không phải trước lúc gọi function. Khởi chạy shell bằng bash -O extglob như batch bên trên sẽ đảm bảo đúng thứ tự trước khi bắt đầu parse.

Shell mở rộng pattern, không phải command

Đây là failure mode thứ hai và là nguyên nhân của cả một nhóm lỗi.

grep -l needle *.log
find . -name '*.log'
find . -name *.log

Dòng đầu tiên để bash thực hiện việc khớp, còn grep nhận một danh sách tên file. Dòng thứ hai đặt pattern trong dấu quote, nên find nhận 5 ký tự *.log và tự thực hiện việc khớp ở mọi cấp bên dưới thư mục bắt đầu. Dòng thứ ba là lỗi: bash mở rộng pattern trước, chỉ dựa trên thư mục hiện tại, nên find được yêu cầu tìm một tên cụ thể. Trong thư mục chỉ có một kết quả khớp, lệnh sẽ âm thầm tìm sai đối tượng. Khi có từ 2 kết quả khớp trở lên, find báo lỗi cú pháp sử dụng vì các tên thừa được đặt vào vị trí mà lệnh chờ một expression.

Quy tắc rất ngắn gọn. Nếu pattern dành cho command, hãy đặt nó trong dấu quote. Nếu pattern dành cho shell, để nguyên không quote. Cách phân biệt này cũng áp dụng cho các pattern --exclude trong tarrsync, cũng như các bộ lọc --include trong grep.

Glob cũng không phải regular expression, dù chúng dùng chung một số ký tự. Trong glob, * có nghĩa là một chuỗi ký tự bất kỳ. Trong regular expression, * có nghĩa là 0 hoặc nhiều lần lặp của phần tử đứng trước nó, nên grep '*.log' yêu cầu một mẫu hoàn toàn khác với vẻ ngoài của nó.

Variable cũng tuân theo cùng thứ tự xử lý. Globbing chạy sau khi mở rộng variable, nên một variable không được quote chứa pattern sẽ bị mở rộng dựa trên thư mục hiện tại.

pat='*.log'
printf '%s\n' $pat
printf '%s\n' "$pat"

Khi không được quote, bash thực hiện glob trên kết quả của variable. Khi được quote, pattern vẫn giữ nguyên dạng literal. Có một hệ quả liên quan mà bạn có thể dựa vào: các từ do globbing tạo ra sẽ không bị tách lại theo dấu cách. Vì vậy, for f in *.log xử lý một tên file có chứa dấu cách như một tên duy nhất, còn for f in $(ls *.log) tách tên đó thành 2 từ. Hãy lặp trực tiếp trên glob.

Xem trước pattern có tính hủy trước khi chạy

Không bao giờ dùng rm làm lệnh đầu tiên để thử một pattern mới. Hãy chạy pattern với một lệnh vô hại, đọc danh sách kết quả, rồi chỉ thay lệnh ở đầu dòng.

target='!(*.log)'
printf '%s\n' $target
ls -ld -- $target
rm -- $target

Viết pattern vào target một lần giúp ba dòng không bị lệch nhau. $target được cố ý để không đặt trong dấu nháy, vì đó là điều cho phép bash expand nó. printf '%s\n' in mỗi tên trên một dòng và không tác động đến gì. ls -ld hiển thị từng entry thay vì liệt kê nội dung của directory. Cột mode cho biết entry nào là directory; đây là cột cần kiểm tra trước khi recursive delete. Đọc các bit quyền drwxr-xr-x giải thích cột này.

Dùng phím mũi tên lên để gọi lại dòng trước, rồi chỉ sửa command để pattern giữ nguyên. Gõ lại pattern theo trí nhớ là lúc lỗi dễ xuất hiện.

Đặt -- trước pattern. Nó đánh dấu phần kết thúc options, nên filename bắt đầu bằng - sẽ được xử lý như một tên thay vì một flag.

Nếu muốn tắt globbing trong một đoạn script, set -f sẽ tắt tính năng này và set +f sẽ bật lại.

Thói quen này quan trọng vì rm không có undo. Khôi phục file đã bị xóa bằng rm -rf mất nhiều thời gian và thường không đầy đủ, nên preview rẻ hơn rất nhiều so với hậu quả.

Dọn directory thực hành khi hoàn tất. Hãy in path và đọc lại trước, vì rm -rf trỏ nhầm vào variable chính là sự cố mà section này đề cập.

cd ~
echo "$lab"
rm -rf -- "$lab"

FAQ

Vì sao find . -name *.log tìm nhầm file?

Vì bash mở rộng *.log trước khi find thực sự chạy. Shell đối chiếu pattern với thư mục hiện tại rồi truyền tên file thu được cho find. Sau đó find tìm tên đó ở mọi cấp thư mục. Nếu có từ 2 file trở lên khớp, find nhận thêm các đối số tại vị trí cần expression và báo lỗi cách dùng. Đặt pattern trong dấu nháy như find . -name '*.log' để find tự đối chiếu pattern theo các quy tắc và cơ chế đệ quy riêng.

Pattern xuất hiện dưới dạng văn bản nguyên nghĩa có nghĩa là gì?

Điều đó có nghĩa là không có gì khớp. Theo mặc định, bash giữ nguyên pattern không khớp rồi truyền các ký tự thô đó cho command. Command sẽ xử lý chúng như một tên file. Đặt shopt -s failglob để biến pattern không khớp thành lỗi và dừng command, hoặc đặt shopt -s nullglob để pattern mở rộng thành chuỗi rỗng. Khi dùng nullglob, hãy kiểm tra xem command có còn đúng khi hoàn toàn không có đối số hay không. Khi đó ls *.nope trở thành ls đứng riêng và liệt kê toàn bộ thư mục.

Làm thế nào để khớp mọi file ngoại trừ một pattern trong bash?

Bật extended pattern bằng shopt -s extglob trên một dòng riêng, rồi dùng dạng phủ định. !(*.log) khớp các tên không kết thúc bằng .log, còn !(*.log|*.md) loại trừ cả hai. File ẩn vẫn không xuất hiện trong kết quả trừ khi đồng thời đặt dotglob. Các thư mục cũng được đưa vào kết quả, vì vậy hãy xem trước pattern bằng printf '%s\n' !(*.log) trước khi truyền nó cho rm.

Vì sao * bỏ qua file ẩn?

Dấu chấm ở đầu tên phải được khớp tường minh, nên * không bao giờ khớp tên bắt đầu bằng dấu chấm. Dùng .* để truy cập tên ẩn, hoặc đặt shopt -s dotglob để đưa chúng vào mọi pattern thông thường. Các entry ... luôn bị loại trừ khi dùng dotglob. Bash 5.2 trở lên cũng loại chúng khỏi .* thông qua tùy chọn globskipdots, vốn được bật theo mặc định.

** có cần globstar để hoạt động không?

Có. Nếu không có shopt -s globstar, bash xem ** như một * thông thường. Nó chỉ khớp bên trong một thư mục rồi dừng lại. Khi bật globstar, ** tạo thành một path component hoàn chỉnh sẽ khớp ở mọi độ sâu. Vì vậy **/*.log truy cập thư mục hiện tại và mọi thư mục con bên dưới, còn **/ đứng riêng chỉ khớp các thư mục. Lưu ý rằng ** không đi theo symbolic link trỏ đến thư mục. Với một cây thư mục được tạo từ symlink, hãy dùng find -L.

#bash#globbing#shell#linux#scripting