Chương 07 — Shell scripting
Tới đây bạn đã quen với từng lệnh đơn lẻ. Chương này gói chúng lại thành
script — file .sh chạy được, lặp lại được, share được.
Mục tiêu: viết script ngắn nhưng vững, không cần chuyển sang Python cho mấy việc tự động.
1. Script đầu tiên
Một shell script chỉ là file text chứa các lệnh bạn vẫn gõ trên terminal, cộng thêm 1 dòng đầu tiên đặc biệt gọi là shebang.
# file: hello.sh
#!/usr/bin/env bash
echo "Xin chào, $(whoami)!"
echo "Hôm nay là $(date +%Y-%m-%d)"
Lưu file rồi cấp quyền chạy và gọi nó:
$ chmod +x hello.sh # cấp quyền execute
$ ./hello.sh # chạy bằng path tương đối
Xin chào, vietanh!
Hôm nay là 2026-05-08
Vì sao phải có ./?
Shell chỉ tự tìm lệnh trong các thư mục liệt kê ở biến $PATH. Folder hiện tại không nằm trong đó (vì lý do bảo mật) → bạn phải nói rõ "lấy file ở thư mục hiện tại" bằng ./.
Shebang: bash vs zsh vs sh
| Shebang | Ý nghĩa | Khi nào dùng |
|---|---|---|
#!/bin/sh | POSIX shell — chuẩn tối thiểu, thiếu nhiều tính năng | Script cần chạy trên mọi Unix (cả container Alpine) |
#!/bin/bash | Bash ở path cố định | Linux thì OK; macOS có bash cũ ở /bin/bash |
#!/usr/bin/env bash | Tìm bash mới nhất trong $PATH | Khuyến nghị — portable nhất |
#!/usr/bin/env zsh | Dùng zsh | Khi script cần tính năng riêng của zsh (hiếm khi cần) |
2. Biến & quoting
Khai báo & đọc biến
name="Việt" # KHÔNG có space quanh dấu =
greeting="Xin chào, $name"
echo "$greeting" # → Xin chào, Việt
name = "Việt" SAI — bash hiểu là "chạy lệnh tên name với 2 arg = và "Việt"". Phải viết liền: name="Việt".
Single quote vs double quote
| Loại | Hành vi | Ví dụ |
|---|---|---|
'...' (single) | Literal — KHÔNG expand biến | echo '$name' → $name |
"..." (double) | Expand biến và $(...) | echo "$name" → Việt |
| Không quote | Expand + word-splitting + glob (nguy hiểm) | echo $name → vỡ khi có space |
Command substitution: $(...)
Chạy 1 lệnh và thay thế bằng output của nó:
today="$(date +%Y-%m-%d)"
files="$(ls *.txt | wc -l)"
echo "Hôm nay $today, có $files file txt"
Cú pháp cũ `...` (backtick) vẫn chạy nhưng không lồng được và khó đọc — luôn ưu tiên $(...).
Default value: ${var:-default}
name="${1:-stranger}" # nếu $1 trống → name = "stranger"
echo "Hello, $name"
| Cú pháp | Ý nghĩa |
|---|---|
${var:-x} | Nếu var trống/chưa set → trả về x (không gán) |
${var:=x} | Nếu var trống → gán var=x rồi trả về |
${var:?msg} | Nếu var trống → in msg và exit lỗi |
${var:+x} | Nếu var có giá trị → trả về x |
"my notes.txt". Gõ rm $file (không quote) → bash split thành 2 arg: my và notes.txt, lệnh xóa nhầm 2 file khác. Đúng: rm "$file". Nguyên tắc vàng: mọi biến trong script đều bọc "..." trừ khi có lý do rất rõ ràng để không quote.
3. Đối số & exit code
Biến đặc biệt
| Biến | Ý nghĩa |
|---|---|
$0 | Tên của script (thường là path đã gọi) |
$1, $2, ..., $9 | Đối số thứ 1, 2, ..., 9 |
${10}, ${11}... | Đối số ≥10 phải có ngoặc nhọn |
$# | Số lượng đối số |
$@ | Tất cả đối số (mảng — khi quote: giữ nguyên từng arg) |
$* | Tất cả đối số gộp thành 1 chuỗi (ngăn cách bằng $IFS) |
$? | Exit code của lệnh vừa chạy xong |
$$ | PID của script hiện tại |
Ví dụ
# file: greet.sh
#!/usr/bin/env bash
echo "Script: $0"
echo "Số arg: $#"
echo "Arg đầu: $1"
echo "Tất cả: $@"
$ ./greet.sh hello world
Script: ./greet.sh
Số arg: 2
Arg đầu: hello
Tất cả: hello world
Exit code: 0 = success
Mọi lệnh khi kết thúc đều trả về 1 số nguyên 0–255. Convention cứng trên Unix:
0— thành công1–255— có lỗi (1 = lỗi chung; 2 = sai cú pháp; 127 = command not found; 130 = bị Ctrl+C...)
$ ls /etc
... (danh sách file)
$ echo $?
0
$ ls /khong-ton-tai
ls: /khong-ton-tai: No such file or directory
$ echo $?
1
Trong script, exit N kết thúc sớm với code N:
#!/usr/bin/env bash
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <name>" >&2 # in lỗi ra stderr
exit 1
fi
echo "Hello, $1"
exit 0
4. Điều kiện & test
Cú pháp if
if [[ -f "$file" ]]; then
echo "File tồn tại"
elif [[ -d "$file" ]]; then
echo "Là folder"
else
echo "Không tồn tại"
fi
[ ] vs [[ ]] — khác biệt quan trọng
[ ] (test) | [[ ]] (bash builtin) | |
|---|---|---|
| Có ở | Mọi POSIX shell (sh, dash...) | Bash, zsh, ksh |
| Word-splitting biến chưa quote | Có — vỡ khi biến có space | Không — an toàn hơn |
| So sánh chuỗi | = | == hoặc =; còn match pattern: [[ "$f" == *.txt ]] |
| Regex | Không | Có: [[ "$s" =~ ^[0-9]+$ ]] |
| Logic AND/OR | -a, -o (deprecated) | &&, || |
[[ ]]. An toàn hơn, dễ đọc hơn, hỗ trợ regex. Chỉ dùng [ ] khi shebang là #!/bin/sh (cần chạy POSIX thuần).
Test operators thường dùng
| Operator | Đúng khi |
|---|---|
-f path | Là file thường (regular file) và tồn tại |
-d path | Là folder và tồn tại |
-e path | Tồn tại (kiểu gì cũng được) |
-r/-w/-x path | Có quyền đọc/ghi/chạy |
-s path | File tồn tại và không rỗng |
-z "$s" | Chuỗi rỗng |
-n "$s" | Chuỗi không rỗng |
"$a" == "$b" | Chuỗi bằng nhau |
"$a" != "$b" | Chuỗi khác nhau |
$x -eq $y | Số bằng nhau |
$x -ne $y | Số khác nhau |
$x -lt $y / -le | Nhỏ hơn / nhỏ hơn hoặc bằng |
$x -gt $y / -ge | Lớn hơn / lớn hơn hoặc bằng |
Shortcut && và ||
Đã biết từ Chương 1: A && B = chạy B nếu A thành công; A || B = chạy B nếu A thất bại. Trong script, dùng để viết guard ngắn gọn:
[[ -d "$dir" ]] || mkdir -p "$dir" # tạo nếu chưa có
[[ -f "$f" ]] && echo "OK" || echo "missing"
5. Vòng lặp
for trên list cố định
for i in 1 2 3; do
echo "Lần $i"
done
for name in alice bob charlie; do
echo "Hello, $name"
done
for trên glob (mạnh nhất)
# in tên + số dòng cho mọi file .txt
for f in *.txt; do
lines="$(wc -l < "$f")"
echo "$f: $lines lines"
done
Ví dụ thực: rename hàng loạt .txt → .md
for f in *.txt; do
mv "$f" "${f%.txt}.md"
done
${f%.txt} = "lấy $f rồi cắt đuôi .txt ở cuối". Đây là parameter expansion — bash có rất nhiều dạng khác (%, %%, #, ##, /...).
for ((...)) kiểu C
for ((i=1; i<=5; i++)); do
echo "i = $i"
done
while đọc từng dòng file
while read -r line; do
echo "Đọc: $line"
done < urls.txt
for line in $(cat file)$IFS (mặc định: space + tab + newline) → vỡ với dòng có space. Đúng: while read -r line; do ... done < file.
6. Hàm + set -euo pipefail
Định nghĩa hàm
# 2 cú pháp đều hợp lệ — chọn 1, dùng nhất quán
greet() {
echo "Hello, $1"
}
function add() {
echo $(($1 + $2))
}
greet "Việt" # Hello, Việt
result="$(add 3 5)" # 8
Trong hàm: $1, $2... là arg của hàm (không phải arg script). return N trả exit code (0–255), không trả giá trị — muốn trả giá trị thì echo rồi capture bằng $(...).
Biến local
count() {
local n="$1" # local: chỉ tồn tại trong hàm
echo "Count: $n"
}
Best practice: mở đầu mọi script bằng set -euo pipefail
#!/usr/bin/env bash
set -euo pipefail
# ... script bắt đầu từ đây
| Cờ | Tác dụng |
|---|---|
-e (errexit) | Fail fast: nếu bất kỳ lệnh nào trả exit ≠ 0, script dừng ngay. Mặc định bash chạy tiếp như chưa có gì xảy ra — rất nguy hiểm. |
-u (nounset) | Đụng tới biến chưa được set → báo lỗi và dừng. Ngăn lỗi typo (vd $HOEM) lặng lẽ thành chuỗi rỗng. |
-o pipefail | Trong A | B | C, mặc định bash chỉ check exit code của C. Có pipefail: nếu bất kỳ lệnh nào trong pipe fail → cả pipe fail. Cần thiết khi dùng set -e. |
set -euo pipefail gần như bắt buộcset -euo pipefail biến bash từ "ngôn ngữ giấy" thành "ngôn ngữ có kỷ luật". Đặt ngay sau shebang.
Bonus: trap dọn dẹp
Khi script bị Ctrl+C giữa chừng hoặc thoát do lỗi, bạn vẫn muốn xóa file tạm:
#!/usr/bin/env bash
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT # chạy khi script kết thúc (kiểu nào cũng vậy)
echo "data" > "$tmp"
# ... xử lý $tmp ...
# khi script thoát: trap tự gọi rm -f $tmp — không leak file tạm
EXIT là pseudo-signal chạy mọi trường hợp thoát. Có thể trap riêng INT (Ctrl+C), TERM (kill), ERR (lỗi).
7. Thử ngay trong terminal giả lập
Mở terminal thật, tạo file demo.sh rồi thử các bước:
- Tạo file:
touch demo.sh && chmod +x demo.sh - Mở bằng editor (
nano demo.shhoặccode demo.sh), paste:#!/usr/bin/env bash set -euo pipefail name="${1:-bạn}" echo "Xin chào, $name!" echo "Số arg nhận được: $#" - Chạy:
./demo.shrồi./demo.sh Việt→ so sánh output. - Thử biến chưa set: thêm dòng
echo "$undefined_var"→ script fail vì-u. - Tự viết 1 vòng
for f in *.sh; do echo "$f"; donetrong script.
8. Bài tập về nhà
Mỗi script nhớ: shebang #!/usr/bin/env bash + set -euo pipefail + chmod +x.
- hello.sh — nhận 1 arg là tên, in
"Hello, <tên>". Nếu không có arg, in usage vàexit 1. - backup.sh — nhận 1 arg là path folder, tạo file
folder-YYYYMMDD-HHMMSS.tar.gzchứa folder đó. Gợi ý:tar -czf "name.tar.gz" "$1"; timestamp lấy từ$(date +%Y%m%d-%H%M%S). - count-lines.sh — đếm tổng số dòng code trong project: tham số là folder + extension (vd
./count-lines.sh ~/myproj js). Dùngfind+wc -l. Bonus: in từng file kèm số dòng, sắp xếp giảm dần. - check-url.sh — nhận 1 URL, dùng
curl -s -o /dev/null -w "%{http_code}" "$url"để lấy HTTP status. In"OK"nếu code 200,"DOWN"ngược lại. Exit code phản ánh kết quả (0 nếu sống, 1 nếu chết). - rename-ext.sh — đổi đuôi hàng loạt. Vd
./rename-ext.sh txt mdđổi mọi*.txttrong folder hiện tại sang.md. - greet-all.sh — nhận nhiều arg, in lời chào cho từng người trên 1 dòng riêng. Sau đó in tổng số người. Dùng
for x in "$@". - safe-rm.sh — wrapper an toàn cho
rm: hỏiy/Ntrước khi xóa, log file đã xóa vào~/.safe-rm.log. Gợi ý:read -r answer. - disk-watch.sh — kiểm tra ổ cứng còn dưới 10% trống thì in cảnh báo. Dùng
df -h /+awk(Chương 5). - (thử thách) deploy-mock.sh — mô phỏng deploy: tạo file tạm bằng
mktemp, viết log vào đó, dùngtrapđể LUÔN xóa file tạm khi thoát (kể cả khi Ctrl+C giữa chừng). Test bằng cách thêmsleep 10rồi nhấn Ctrl+C.
9. Quiz — Kiểm tra kiến thức
10. Tổng kết
- ✅ Shebang
#!/usr/bin/env bash+chmod +x+ chạy bằng./script.sh - ✅ Biến không space quanh
=; LUÔN double-quote khi expand - ✅ Đối số:
$1,$@,$#,$?; exit 0 = success - ✅
[[ ]]an toàn hơn[ ]; biết-f,-d,-z,-n,-eq... - ✅
fortrên glob,while readđể đọc file dòng-by-dòng - ✅ Hàm +
local; mọi script bắt đầu bằngset -euo pipefail - ✅
trap ... EXITđể cleanup chắc chắn
Chương 8 chúng ta sẽ học Git & SSH — version control và remote access. Có script đã rồi, bạn sẽ thấy git hooks và deploy script tự nhiên hơn nhiều.