Shell 不只是"打命令的地方",它是一门编程语言,也是你和内核之间的翻译官。
内核只认系统调用。人不可能直接敲 syscall。Shell 的职责:接收人类输入 → 解析成命令 → fork/exec 出程序 → 用管道把程序串起来。
理解 Shell 的两个身份,很多困惑会消失:
- 交互式解释器 —— 你敲一行,它执行一行。
- 脚本语言 —— 有变量、条件、循环、函数,能写程序。
| Shell | 特点 | 适合 |
|---|---|---|
| bash | 事实标准,几乎所有系统都有,脚本兼容性最好 | 写脚本、通用 |
| zsh | 功能强(补全、globbing、主题),macOS 默认 | 日常交互 |
| fish | 开箱友好,语法不兼容 POSIX | 新手、交互 |
| dash | 极简极快,POSIX 合规 | /bin/sh 实现(Debian 上) |
| ash / busybox sh | 极小 | 嵌入式、Alpine、initramfs |
$ echo $SHELL # 登录 shell(注意:不是你当前正在用的)
$ ps -p $$ -o comm= # 当前实际运行的 shell ← 更准
$ cat /etc/shells # 系统允许的合法 shell 列表
$ chsh -s /bin/zsh # 换默认 shell写脚本用
/bin/sh还是#!/bin/bash?
- 要可移植(Docker/Alpine/嵌入式)→
#!/bin/sh,只用 POSIX 语法。- 要用数组、
[[ ]]、${var//a/b}等 →#!/bin/bash,并明确声明。- 同一个脚本里
[[ ]]配#!/bin/sh,在 Debian 上(dash)会直接报错。这是常见坑。
这是最重要的认知:* 是 Shell 展开的,不是命令处理的。
$ echo *.md # shell 把 *.md 展开成文件名列表再传给 echo
$ ls ~/Doc* # 同理由此推出:命令收到的是已经展开的真实路径。如果需要传给命令让它自己处理,必须引号:
| 写法 | 结果 |
|---|---|
"$var" |
变量展开,不分词,不 glob ← 默认就该这么写 |
'$var' |
单引号,字面量,什么都不展开 |
`cmd` |
命令替换(老式,不推荐) |
$(cmd) |
命令替换(推荐,可嵌套) |
${var} |
界定变量名边界,${var}abc |
$ f="my file.txt"
$ rm $f # ❌ 被拆成两个参数,删错东西
$ rm "$f" # ✅ 永远加引号| 语法 | 名字 | 作用 |
|---|---|---|
{a,b,c} |
花括号展开 | 生成列表:file{1..3}.txt |
(cmd1; cmd2) |
子 shell | 在新 shell 中执行(变量改动不保留) |
{ cmd1; cmd2; } |
命令组 | 在当前 shell 执行(注意分号和空格) |
$((1+2)) |
算术展开 | 整数运算 |
$[...] [ ] |
test | 条件判断(POSIX) |
[[ ]] |
条件判断 | bash/zsh 扩展,更安全(不分词) |
(( )) |
算术判断 | (( i++ ))、if (( a > b )) |
Linux 的哲学:小工具 + 管道 = 无限组合。
$ cmd > out # stdout 覆盖到文件
$ cmd >> out # stdout 追加
$ cmd 2> err # stderr 到文件
$ cmd > all 2>&1 # stdout 和 stderr 都进 all(顺序不能反)
$ cmd &> all # bash 简写,同上
$ cmd > /dev/null 2>&1 # 全部丢弃
$ cmd1 | cmd2 # stdout 接到下一命令的 stdin
$ cmd | tee log # 同时输出到屏幕和文件三个标准流:
| 编号 | 名字 | 默认 |
|---|---|---|
| 0 | stdin | 键盘 |
| 1 | stdout | 终端 |
| 2 | stderr | 终端 |
其他有用的重定向:
$ exec 3< file # 打开文件描述符 3 用于读
$ while read -r line <&3; do echo "$line"; done
$ cmd <<< "string" # here-string
$ cmd <<EOF # here-document
多行内容
EOF
$ cmd |& grep err # bash 4+:stdout+stderr 同时管道管道的一个重要性质:管道中的每个命令都在独立子进程里跑。所以:
$ echo "hi" | read x; echo "$x" # ❌ 空,因为 read 在子 shell
$ read x <<< "hi"; echo "$x" # ✅$ cmd; echo $? # 上一条命令的退出码:0 成功,非 0 失败
$ cmd1 && cmd2 # cmd1 成功才跑 cmd2
$ cmd1 || cmd2 # cmd1 失败才跑 cmd2
$ cmd1 || { echo "failed"; exit 1; }
$ true; false; echo $? # 1
set -euo pipefail是脚本的第一行(在#!/bin/bash之后):
-e任一条命令失败立即退出(避免"错了还继续跑")-u使用未定义变量报错(避免rm -rf $DIR/里$DIR为空的灾难)-o pipefail管道中任一环节失败整条管道算失败(默认只看最后一条)
$ history # 历史
$ Ctrl+R # 反向搜索 ← 最高效的技能,务必练熟
$ !$ # 上一条命令的最后一个参数
$ sudo !! # 重跑上一条命令并加 sudo
$ Ctrl+A / Ctrl+E # 行首 / 行尾
$ Ctrl+W # 删掉前一个词
$ Ctrl+U / Ctrl+K # 删到行首 / 行尾
$ Ctrl+L # 清屏$ grep -rn "pattern" --include="*.py" . # 递归搜索
$ grep -v "noise" # 反选
$ grep -E "a|b" # 扩展正则
$ grep -C 2 "error" file # 带上下文
$ sed -n '10,20p' file # 打印 10-20 行
$ sed -i 's/old/new/g' file # 原地替换(⚠️ 先备份!)
$ sed -i.bak 's/old/new/g' file # 自动留 .bak
$ awk -F: '{print $1, $3}' /etc/passwd # 按字段处理
$ awk '$3 > 1000 {print $1}' /etc/passwd # 带条件
$ awk '{sum+=$1} END {print sum}' nums.txt # 累加
$ cut -d: -f1 /etc/passwd
$ sort file | uniq -c | sort -rn | head # "统计 TopN"万能组合
$ tr 'a-z' 'A-Z'
$ wc -l file
$ xargs -r -n1 cmd # 把 stdin 变成参数(-r 避免空输入)
$ find . -name "*.log" -mtime +30 -exec rm {} \;
$ find . -type f -size +100M -ls
find的-exec ... +比\;高效(批量传参而非逐条 fork)。且find -delete比管道到rm更安全(能处理文件名里的空格和换行)。
#!/usr/bin/env bash
# 用途:一句话说明这个脚本干什么
# 用法:./script.sh <参数>
set -euo pipefail
IFS=$'\n\t' # 防止文件名含空格时分词出错
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="/tmp/$(basename "$0").log"
log() { printf '[%s] %s\n' "$(date +'%F %T')" "$*" | tee -a "$LOG_FILE"; }
die() { log "ERROR: $*" >&2; exit 1; }
usage() { echo "用法: $0 [-h] [-f 文件]"; exit 0; }
main() {
[[ $# -eq 0 ]] && usage
log "开始执行"
# 主逻辑
}
main "$@"$ name="value" # 赋值不能有空格!(name = value 会报错)
$ readonly PI=3.14
$ export PATH="$PATH:/opt/bin" # 导出给子进程
$ unset name
$ local_var=1; local lv=2 # local 只能在函数内
# 参数展开(值得专门背)
${var:-默认值} # var 为空则用默认值
${var:=默认值} # 为空则赋值并返回
${var:?错误消息} # 为空则报错退出
${var#前缀} # 去最短前缀
${var##前缀} # 去最长前缀
${var%suffix} # 去后缀
${var//a/b} # 全部替换(bash 扩展)
${#var} # 长度
${var^^} # 转大写(bash 4+)# 数组
$ arr=(a b c)
$ echo "${arr[0]}" "${arr[@]}" "${#arr[@]}"
$ for x in "${arr[@]}"; do echo "$x"; done # 注意引号,否则元素会被拆开变量作用域的关键区分:
$ export VAR=1 # 环境变量:子进程可见
$ VAR=1 # shell 变量:只有当前 shell 可见
$ env | grep VAR # 验证是否真的导出了# 条件:注意 [ ] 里必须有空格
if [[ -f "$file" ]]; then
echo "是文件"
elif [[ -d "$file" ]]; then
echo "是目录"
else
echo "不存在"
fi
case "$1" in
start|up) do_start ;;
stop) do_stop ;;
*) echo "用法: $0 {start|stop}"; exit 1 ;;
esac
for i in {1..10}; do echo "$i"; done
for f in *.txt; do echo "$f"; done
while read -r line; do echo "$line"; done < file
until [[ -f /tmp/ready ]]; do sleep 1; done文件测试运算符(高频):
| 测试 | 含义 |
|---|---|
-f f |
是普通文件 |
-d f |
是目录 |
-e f |
存在 |
-r/-w/-x f |
可读/写/执行 |
-s f |
存在且非空 |
f1 -nt f2 |
f1 比 f2 新 |
-z "$s" / -n "$s" |
字符串为空 / 非空 |
-L f |
是符号链接 |
cleanup() { rm -f "$TMPFILE"; }
trap cleanup EXIT INT TERM # 无论怎么退出都清理 ← 好习惯
retry() {
local n=0 max=3
until "$@"; do
(( n++ )) || true # ← 注意:(( n++ )) 在 n=0 时返回假,set -e 会杀掉脚本
(( n < max )) || return 1
sleep 2
done
}
(( n++ ))与set -e的冲突是经典坑:(( 0 ))求值为假,返回码 1,set -e直接终止。安全写法:n=$((n+1))或((n++)) || true。
$ bash -n script.sh # 只做语法检查,不执行 ← 提交前的必做
$ bash -x script.sh # 打印每条执行的命令
$ set -x; ...; set +x # 局部开启
$ shellcheck script.sh # 静态分析,强烈推荐(apt install shellcheck)
shellcheck应该成为你写脚本的标准动作。它能抓出未加引号的变量、[ ]用法错误、ls解析等大量隐蔽问题。
"我改了 ~/.bashrc 为什么没生效" —— 这是最高频的问题。根因是加载流程分四种情况。
bash 的启动分类:
| 类型 | 触发 | 读哪些文件 |
|---|---|---|
| 交互式登录 shell | 本地登录、ssh 登录、su - |
/etc/profile → ~/.bash_profile → ~/.bashrc |
| 交互式非登录 shell | 打开的终端窗口、bash |
~/.bashrc |
| 非交互式 shell | 跑脚本 | $BASH_ENV(通常不读任何 rc) |
| 登录 + POSIX | bash --login --posix |
/etc/profile、$ENV |
$ echo $BASH_ENV # 非交互 shell 唯一会读的东西
$ shopt login_shell # 当前是不是登录 shellzsh 的差异:
| 文件 | 用途 |
|---|---|
/etc/zsh/zshenv |
总是读(所有 zsh) |
~/.zshenv |
总是读 |
/etc/zsh/zprofile / ~/.zprofile |
登录 shell |
/etc/zsh/zshrc / ~/.zshrc |
交互 shell ← 日常改这个 |
~/.zlogin |
登录 shell 最后 |
~/.zlogout |
退出时 |
fish:只有 ~/.config/fish/config.fish,用 fish_add_path 管理 PATH,不需要 export。
$ env | sort # 看所有环境变量
$ echo $PATH | tr ':' '\n' # 分行看 PATH(PATH 顺序 = 查找优先级)
$ type -a cmd # 命令来自哪(别名/函数/内置/文件)
$ command -v cmd # 别名和函数绕过的查找
$ hash -r # 清命令缓存(刚装的命令找不到时用)
$ source ~/.bashrc # 重新加载(不影响新开终端)
⚠️ 在~/.bashrc里放alias给非交互场景用是无效的。cron、systemd、docker exec 都不会读你的 rc 文件 —— 这就是"手动能跑、自动化不行"的根本原因。写服务脚本时把所有环境显式写出来。
$ which -a python3 # 按 PATH 顺序列出所有匹配
$ echo $PATH | tr ':' '\n' | nlPATH 里靠前的目录优先。conda、~/.local/bin、/usr/local/bin 常造成"我装的是新版,跑起来却是旧版"。
| 症状 | 排查 |
|---|---|
| 改了 rc 不生效 | 确认 shell 类型 + 登录/非登录;source 或重开终端 |
| 脚本手动行、cron 不行 | PATH 与变量缺失;用绝对路径;env 对比 |
Syntax error 但看着没错 |
检查是否 bash 语法跑在 sh 下;bash -n |
| 命令找不到 | which -a、hash -r、PATH 顺序、shell 哈希缓存 |
| 变量里全是空格问题 | 全部加引号;IFS 设置 |
| 中文/特殊字符乱码 | LANG/LC_ALL、终端编码(见 10) |
| 管道结果不对 | 注意子 shell 问题;set -o pipefail |
| 文件没了 | 检查是否被 -exec / xargs 的引号问题误伤 |
*由 shell 展开,不是命令。加引号能阻止展开。- 管道里的命令在子进程,变量赋值会丢失。
set -e不是万能的,if cmd里的失败不会退出。[ ]是命令(test的别名),所以空格是语法的必需部分。#!/bin/sh不等于#!/bin/bash,Debian 上是 dash。- 不要用
ls的输出做循环输入,文件名含空格会出错,用 glob 或find -print0。
- zsh 深度配置(oh-my-zsh / starship、补全系统、glob 限定符)
- fish 的 POSIX 差异对照表
- 正则表达式速查(BRE/ERE/PCRE 区别)
- 完整的日志分析实战(
awk/sed/sort/uniq组合拳) -
jq/yq处理结构化数据 -
xargs与parallel并行化 - 写一个生产级脚本的完整范例(含锁、日志、重试、dry-run)
-
shopt各选项与 bash 版本差异