shell 入门指南
Shell 基础速查:Shell 种类与区别 → 变量与参数 → 执行模型 → 函数与流程 → 错误处理 → 实用片段 → 性能与最佳实践。
# Shell 简介
# 什么是 Shell
Shell 是命令行解释器,负责接收用户输入的命令并调用操作系统执行。它既是交互式的命令行环境,也是脚本语言的解释器。
# 常见 Shell 种类
| Shell | 全称 | 特点 | 适用场景 |
|---|---|---|---|
| sh | Bourne Shell | 最原始的 shell,POSIX 标准 | 跨平台脚本,最小兼容 |
| bash | Bourne Again Shell | GNU 项目的 shell,sh 的增强版 | Linux 默认,功能丰富 |
| zsh | Z Shell | bash 的增强版,补全更强 | macOS 默认,交互式体验好 |
| fish | Friendly Interactive Shell | 开箱即用,语法不同 | 新手友好,交互式 |
| dash | Debian Almquist Shell | 轻量 POSIX shell | Ubuntu 的 /bin/sh,启动快 |
# POSIX 是什么
POSIX(Portable Operating System Interface)是一组操作系统接口标准。
- POSIX shell 是 shell 语言的标准子集
- 符合 POSIX 的脚本可以在任何 POSIX 系统上运行
- 常见的 POSIX shell:
sh、dash、bash --posix、zsh --emulate sh
# GNU 是什么
GNU 是一个自由软件项目,目标是创建一套完全自由的类 Unix 操作系统。
bash是 GNU 项目的产物coreutils(ls、cp、mv等)也是 GNU 项目的- GNU 工具通常比 BSD 版本功能更多,但兼容性稍差
GNU 与 BSD 工具差异的典型实战见 [sed 用法与 BSD/GNU 兼容性踩坑](../../07.tools/07.cli_tools/21.sed.md#BSD sed vs GNU sed 兼容性):同一脚本本地能跑、Xcode 里报错的全过程。
# Shebang(#!/)
脚本第一行的特殊注释,告诉系统用什么解释器来跑这个脚本。
| Shebang | 说明 | 兼容性 |
|---|---|---|
#!/bin/sh | 用 POSIX shell 跑 | 最好,跨平台 |
#!/bin/bash | 用 bash 跑 | Linux 默认,macOS 也有 |
#!/usr/bin/env bash | 自动找 bash 路径 | 更灵活,推荐 |
#!/usr/bin/env python3 | 用 python3 跑 | Python 脚本 |
选择建议:
- 写跨平台脚本 →
#!/bin/sh - 用 bash 特有功能 →
#!/usr/bin/env bash - 用
env更灵活,不用硬编码路径
# 脚本头部模板
#!/usr/bin/env bash
set -euo pipefail # 严格模式(-e 出错即退、-u 未定义变量报错、-o pipefail 管道任一失败即失败)
IFS=$'\n\t' # 收紧分词边界,减少空格误展开
2
3
# 变量与参数
# 特殊变量
| 变量 | 含义 |
|---|---|
$0 | 脚本名(按调用方式可能是相对路径、绝对路径或文件名) |
$1 $2 … | 位置参数 |
$# | 参数个数 |
$@ | 全部参数;加引号 "$@" 时每个参数独立成词 |
$* | 全部参数;加引号 "$*" 时合并为一个字符串(以 IFS 首字符连接) |
$? | 上一命令退出码(0 成功,非 0 失败) |
$$ | 当前 shell 的 PID |
$! | 最近一个后台进程的 PID |
$_ | 上一命令的最后一个参数 |
$PWD / $OLDPWD | 当前 / 上一个工作目录 |
$HOME $USER $SHELL | 用户环境 |
$HOSTNAME | 主机名(bash 自设但不保证导出,脚本中用 hostname 命令更稳) |
$RANDOM | 0~32767 随机数(bash/zsh) |
$SECONDS | shell 已运行秒数(bash/zsh) |
$LINENO | 当前行号 |
$FUNCNAME | 函数名数组([0] 为当前函数,构成调用栈)(bash) |
$BASH_SOURCE | 源文件路径数组(被 source 时与 $0 不同)(bash) |
标注(bash)的变量是 bash 特有的,POSIX sh 里没有。
$* 与 $@ 的区别只在加双引号时体现:
set -- "a b" c
printf '[%s]' "$*" # [a b c] —— 一个词
printf '[%s]' "$@" # [a b][c] —— 两个词
2
3
遍历参数永远用 "$@"。
$0 的常见取值:
./script.sh→$0 = ./script.sh;/path/to/script.sh→$0 = /path/to/script.sh- 纯文件名:
basename "$0"或${0##*/} - 完整路径:
realpath "$0"或readlink -f "$0"(Linux)
$_ 实用技巧:
mkdir -p /path/to/dir && cd $_ # 建目录后直接切进去
# 变量默认值
| 语法 | 含义 |
|---|---|
${var:-default} | var 未定义或为空时用 default(不赋值) |
${var-default} | 仅 var 未定义时用 default(为空不用) |
${var:=default} | 未定义或为空时赋值为 default |
${var:?message} | 未定义或为空时报错退出 |
${var:+alternative} | 已定义且非空时用 alternative(否则为空) |
export ZSH_CUSTOM="${ZSH_CUSTOM:-~/.oh-my-zsh/custom}" # 未定义则用默认值
remote_name="${1:-origin}" # 无第一个参数则用 origin
2
# 字符串操作
| 语法 | 含义 |
|---|---|
${#string} | 长度 |
${string:position} ${string:position:length} | 从 position 起取子串 |
${string#pattern} / ${string##pattern} | 从头删除最短 / 最长匹配前缀 |
${string%pattern} / ${string%%pattern} | 从尾删除最短 / 最长匹配后缀 |
${string/pat/rep} / ${string//pat/rep} | 替换第一个 / 全部匹配 |
${string/#pat/rep} / ${string/%pat/rep} | 仅在前缀处 / 后缀处替换 |
script="${0##*/}" # 纯文件名:从左删最长 "*/" 前缀
name="${file%.*}" # 去扩展名:从右删最短 ".*" 后缀
ext="${file##*.}" # 只取扩展名
out="${file%.*}_compressed.jpg"
lower="${var,,}" upper="${var^^}" # 转小写 / 大写(bash 4+)
2
3
4
5
# 数组
arr=("apple" "banana" "cherry")
echo "${arr[0]}" # 第一个元素
echo "${arr[@]}" # 所有元素
echo "${#arr[@]}" # 数组长度
arr+=("date") # 追加元素
for item in "${arr[@]}"; do
echo "$item"
done
2
3
4
5
6
7
8
9
10
数组是 bash 特有的,POSIX sh 里没有。
# 关联数组(bash 4+)
declare -A colors
colors["red"]="#FF0000"
colors["green"]="#00FF00"
echo "${colors["red"]}"
for key in "${!colors[@]}"; do # ${!arr[@]} 取全部键
echo "$key: ${colors[$key]}"
done
2
3
4
5
6
7
8
9
# 执行模型
# 父子 Shell
- 普通命令在当前 shell 中执行,变量与
cd直接生效 (...)创建子 shell:内部的cd、变量定义只在子 shell 内生效,不影响父 shell。适合临时切换环境- 管道的每一段命令也各自运行在子 shell 中——管道内修改变量、
cd,结束后全部丢失 - 进程替换
<(...)同样是子 shell
经典陷阱:管道接 while 循环,循环内的计数在子 shell 里,循环外拿不到:
# ✗ count 在子 shell 中自增,循环结束后丢失
cat file.txt | while IFS= read -r line; do ((count++)); done
echo "$count" # 空
# ✓ 用重定向代替管道,循环在当前 shell 执行
count=0
while IFS= read -r line; do ((count++)); done < file.txt
echo "$count"
2
3
4
5
6
7
8
# 进程替换
<(...) 把命令输出包装成临时文件,供只接受文件参数的命令使用(bash/zsh):
diff <(ls /path1) <(ls /path2) # 比较两个命令的输出
cat <(echo "Hello World")
paste <(cut -d: -f1 /etc/passwd) <(cut -d: -f3 /etc/passwd)
2
3
# Here Document
<<TAG 把多行文本作为命令输入,TAG 为自定义结束标记(文本中未出现即可):
cat << END
这是一段
多行文本
END
2
3
4
关键区别:tag 加不加引号决定是否做变量展开——
cat << EOF # 展开 $var
$(date) $USER
EOF
cat << 'EOF' # 不展开,原样输出
$(date) $USER
EOF
2
3
4
5
6
7
写文件与赋值多行字符串:
cat << END > config.pbtx # 多行文本写入文件
这是一段配置
END
info=$(cat << 'EOF' # 多行字符串赋值给变量(引号防展开)
line1
line2
EOF
)
echo "$info"
2
3
4
5
6
7
8
9
10
# 重定向
| 写法 | 含义 |
|---|---|
cmd > file / cmd >> file | stdout 覆盖 / 追加到文件 |
cmd 2> file / cmd 2>> file | stderr 重定向 |
cmd > file 2>&1 | stdout 和 stderr 都进 file(bash 可简写 &>file) |
cmd > /dev/null 2>&1 | 丢弃全部输出 |
cmd 2>&1 | less | 合并后进管道 |
2>&1 拆解:2 是 stderr 的文件描述符,> 重定向,&1 表示"引用 fd 1(stdout)",即把 stderr 合并到 stdout 当前指向的位置。
顺序很重要:cmd 2>&1 > file 先把 stderr 指向当时的 stdout(终端),再把 stdout 指向 file——stderr 仍打到终端。要合并进文件必须 > file 2>&1。
# IFS 与逐行读取
IFS(Internal Field Separator)决定 shell 分词的边界。逐行读取的标准写法:
while IFS= read -r path; do
[ -z "$path" ] && continue
...
done < "$input_file"
2
3
4
IFS=(置空):不裁剪行首尾空白,整行作为单个变量值-r:反斜杠不做转义,路径等特殊字符原样保留
按分隔符拆分(如空格):
while IFS=' ' read -r word; do
echo "$word"
done <<< "abc ddd eee fff"
2
3
# 函数与流程
# 函数
function_name() {
local var1="$1" var2="$2" # local 限制作用域;参数用位置变量
echo "参数1: $var1, 参数2: $var2"
return 0 # 退出码 0~255,不是返回值
}
function_name "hello" "world"
echo "退出码: $?"
2
3
4
5
6
7
8
函数"返回值"有两种,不要混淆:
return N/$?:退出码(0~255),表示成功失败- 返回数据:
echo输出 + 命令替换捕获
get_dir() {
echo "/data/local/tmp" # 数据用 echo
}
dir=$(get_dir) # 捕获
2
3
4
process_files() {
local count=0
for file in "$@"; do
[ -f "$file" ] && ((count++))
done
echo "共处理 $count 个文件"
}
2
3
4
5
6
7
# test 命令
| 类别 | 操作符 |
|---|---|
| 数值 | -eq -ne -gt -ge -lt -le |
| 字符串 | -z(空) -n(非空) = != |
| 文件 | -e(存在) -f(普通文件) -d(目录) -r -w -x -s(非空) -c -b |
[ -f "$file" ] # 文件存在
[ -d "$dir" ] # 目录存在
[ -n "$var" ] # 字符串非空
[ "$num1" -gt "$num2" ]
2
3
4
bash 中推荐内建 [[ ]]:不做词分割、变量可不加引号、支持 &&/||/=~ 正则(bash/zsh):
if [[ -f "$file" && "$name" =~ ^v[0-9]+$ ]]; then ...; fi
# 参数验证
# 参数数量
[ $# -lt 2 ] && { echo "用法: $0 <参数1> <参数2>" >&2; exit 1; }
# 文件存在
[ ! -f "$1" ] && { echo "错误: 文件 '$1' 不存在" >&2; exit 1; }
# 目录可写
[ ! -w "$(dirname "$output")" ] && { echo "错误: 无法写入目录" >&2; exit 1; }
2
3
4
5
6
7
8
# 错误处理与健壮性
# 严格模式
set -e # 命令失败(非0退出码)立即退出
set -u # 引用未定义变量报错
set -o pipefail # 管道中任一段失败则整条管道失败
2
3
# 错误捕获与降级
if ! command_that_might_fail; then
echo "命令执行失败" >&2
exit 1
fi
error_output=$(command 2>&1) # 捕获 stdout+stderr
result=$(command 2>/dev/null) || result="default" # 忽略错误并降级
command 2>> error.log # 错误追加到日志
2
3
4
5
6
7
8
# 信号与清理
temp_file=$(mktemp)
trap 'rm -f "$temp_file"' EXIT # 单引号:退出时才展开变量
cleanup() {
echo "收到退出信号,清理中..."
rm -f /tmp/temp_file
exit 0
}
trap cleanup SIGINT SIGTERM
2
3
4
5
6
7
8
9
# 作业控制
command & # 后台运行
jobs -l # 列出后台任务
fg %1 # 移到前台
bg %1 # 前台任务转后台继续
kill %1 # 杀死任务
2
3
4
5
# 实用片段
# PATH 修改
# 追加到末尾(低优先级);变量未定义时会拼出 "/bin",注意先确认变量存在
export PATH="$PATH:$ANDROID_BUILD_TOOL/bin"
# 前插到开头(高优先级,优先命中)
export PATH="/usr/local/opt/postgresql@16/bin:$PATH"
2
3
4
5
# 常用小命令
printf "%x\n" 102 # 十进制转 16 进制 → 66
printf "%d\n" 0x66 # 16 进制转十进制 → 102
mktemp # 创建临时文件
basename /a/b/c.txt # → c.txt(取文件名)
dirname /a/b/c.txt # → /a/b(取目录名)
readlink -f file # 符号链接的真实路径
type cmd # 显示命令类型(别名/函数/内建/文件)
command -v cmd # 查找命令路径(POSIX 标准,脚本判断命令存在的首选)
2
3
4
5
6
7
8
# 交互配置
set -o vi:bash 切到 vi 编辑模式,光标移动同 vi
# 输出着色
color_echo() {
echo -e "\033[${1}m${2}\033[0m"
}
color_echo "0;31" "红色文本" # 0;31 红色,0;32 绿色,0;33 黄色
2
3
4
# 调试
set -x # 打印执行的每条命令
set +x # 关闭(脚本内局部调试)
bash -x script.sh # 从外部启动调试
trap 'echo "错误发生在第 $LINENO 行" >&2' ERR
2
3
4
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
}
2
3
# 性能与最佳实践
# 性能要点
# 1. 拒绝无用 cat
grep "pattern" file.txt # ✓ 直接读文件
cat file.txt | grep "pattern" # ✗ 多余一次 fork
# 2. 循环内避免调用外部命令——用参数扩展替代
for f in *.txt; do
base="${f%.*}" # ✓ 内置,零开销
base=$(basename "$f" .txt) # ✗ 每轮 fork 一个进程
done
# 3. 统计类操作交给专用命令,一次调用远快于 shell 循环
count=$(wc -l < file.txt) # ✓ 一次调用
while read; do ((count++)); done # ✗ 逐行解释执行,大文件慢几个数量级
2
3
4
5
6
7
8
9
10
11
12
13
# 变量引用
echo "$variable" # 总是加引号:防词分割与通配展开
echo "${array[@]}" # 数组同样加引号
2
# 临时文件
temp_file=$(mktemp)
trap 'rm -f "$temp_file"' EXIT # 注意单引号,退出时才展开
echo "data" > "$temp_file"
2
3
# 常用工具函数
# 安全备份
backup_file() {
local file="$1"
[ -f "$file" ] && cp "$file" "${file}.bak.$(date +%Y%m%d_%H%M%S)"
}
# 目录可写且(文件不存在或文件可写)
is_writable() {
local file="$1"
[ -w "$(dirname "$file")" ] && { [ ! -f "$file" ] || [ -w "$file" ]; }
}
# 去除前后空格(纯内置实现)
trim() {
local var="$1"
var="${var#"${var%%[![:space:]]*}"}" # 去前导
var="${var%"${var##*[![:space:]]}"}" # 去尾随
echo "$var"
}
to_lower() { echo "${1,,}"; } # bash 4+
to_upper() { echo "${1^^}"; }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# link
- runoob.com (opens new window) linux shell 学习
- Bash Guide (opens new window) - 全面的Bash指南
- ShellCheck (opens new window) - Shell脚本静态分析工具
- Google Shell Style Guide (opens new window) - Shell编程规范