Bash 脚本入门:变量、条件、循环与退出状态

Shell 脚本把命令和控制流程保存成可重复运行的程序。用 Bash 编写第一个脚本时,应给变量展开加双引号、检查输入,并结合实际输出与退出状态验证条件和循环。

下面创建一个列出目录内文件名的完整程序。变量、运算符和命令状态可先参考Shell 执行流程;失败处理、路径和并发限制则见Bash 错误处理与文件操作(英语)。

明确使用 Bash 并准备工作目录

本练习面向 Ubuntu、Rocky Linux 中的 Bash。[[ ... ]] 和数组等功能并非所有 sh 都支持,不要用 sh script.sh 代替 Bash。先运行 bash --version 确认环境,编辑器保存脚本时使用 LF 换行。

在同一个专用 Bash 练习会话中逐条执行以下命令,后面继续使用该会话。临时目录创建或切换失败会通过 exit 1 结束会话,因此先保存其他工作。其余命令报错也应停止排查,不能凭最后一条命令成功就继续。

lab=$(mktemp -d "$HOME/linux-script-lab.XXXXXX") || exit 1
cd "$lab" || exit 1
mkdir "sample files"
printf "one\n" > "sample files/alpha.txt"
printf "two\n" > "sample files/beta.txt"

创建并运行完整脚本

下面整段命令在当前新目录内创建 list-files.sh,然后检查语法并执行。脚本接收一个目录参数,验证后逐行显示其中的普通文件名,不修改被列举的文件。只在刚建立的练习目录中创建它,避免覆盖同名文件。

cat > list-files.sh <<'EOF'
#!/usr/bin/env bash
if (( $# != 1 )); then
  printf "Usage: %s DIRECTORY\n" "$0" >&2
  exit 2
fi
directory=$1
if [[ ! -d "$directory" ]]; then
  printf "Not a directory: %s\n" "$directory" >&2
  exit 1
fi
count=0
for path in "$directory"/*; do
  [[ -f "$path" ]] || continue
  printf "%s\n" "${path##*/}"
  count=$((count + 1))
done
printf "files=%s\n" "$count"
EOF
bash -n list-files.sh
bash list-files.sh "sample files"

预期输出包含 alpha.txt、beta.txt 和 files=2,文件名排列顺序可能受区域设置影响。bash -n 只检查语法,不检查运行时路径或权限。首行 #! 是指定解释器的 shebang,并不是应从命令前删除的终端提示符。

<<'EOF' 使用带引号的 here-document 分隔符:创建脚本时不展开块内变量,$1 和 $path 等会原样写入文件,留到执行脚本时处理。结束标记 EOF 要独占一行且不缩进。

变量与引号怎样保护文件路径

变量赋值写成 directory=$1,等号两侧不加空格。读取时用 "$directory",可把带空格的路径作为一个参数传递。单引号通常禁止展开;双引号允许参数展开,同时阻止对展开结果进行单词分割和文件名展开。无需用 eval 把输入再解释为命令。

"$directory"/* 保护目录部分,仅让星号参与文件名展开。Bash 默认设置下,没有匹配项时可能保留原模式字符串,[[ -f "$path" ]] || continue 会跳过不是普通文件的项。continue 直接进入下一轮循环,因此只有通过测试的项才会打印并计数。

该模式默认不包含点开头的隐藏文件,不递归遍历子目录;指向普通文件的符号链接也可能通过 -f。文件名可以包含换行,因此“一行一个名字”只适合本练习的简单显示,不能作为任意文件名的可靠机器交换格式。目录不可读、文件在检查期间变化等情况也不能靠这个简单计数完整判断。

理解参数、条件、循环和退出状态

语法 含义
$# / $1 参数数量/第一个参数
[[ -d ... ]] 检查目标是否为目录
for ...; do ...; done 对每个展开后的项执行循环体
${path##*/} 删除直到最后一个斜杠的前缀,留下文件名
exit 0 / 1 / 2 本例中的成功/目标错误/用法错误

if (( $# != 1 )) 检查参数数量,[[ ! -d "$directory" ]] 拒绝非目录。错误信息通过 >&2 写到标准错误,正常结果写到标准输出。count=$((count + 1)) 每次将计数加一。

最后的 printf 成功时,脚本以状态 0 结束;状态 1 和 2 的具体含义是本脚本的约定,不适用于所有命令。$? 只保存紧接着的上一条命令状态,必须在其他命令覆盖它之前读取。以下前两次调用预期分别显示用法错误及 exit=2、目标错误及 exit=1:

bash list-files.sh
printf "exit=%s\n" "$?"
bash list-files.sh missing-directory
printf "exit=%s\n" "$?"
chmod u+x list-files.sh
./list-files.sh "sample files"

chmod u+x 为所有者添加执行权限,之后通过 ./list-files.sh 直接启动;Linux 下还需要有效的解释器行,且所在挂载允许执行。bash list-files.sh 是由 Bash 读取脚本,即使没有脚本的执行位也可以工作,但仍需读取权限。这两种方式不应被理解为任意绕过环境限制的方法。

一次改变一个条件并复查结果

  1. 对原来的 sample files 目录执行,确认计数为 2。
  2. 只在该目录新增 gamma.txt,再次执行,确认计数为 3。
  3. 只删除刚新增的文件,再次执行,应回到 2。
  4. 新建一个空目录作为参数,预期为 files=0;不存在的路径则应产生错误和状态 1。

运行前检查当前路径,清理时只处理这次新目录内的练习文件。不要在系统目录执行批量删除。这个程序是变量、分支与循环的入门示例,不是具备完整访问错误处理、并发一致性或任意文件名协议的目录清单工具。

Windows 编辑后若出现 bash\r 或 ^M 错误,先检查 CRLF 换行;在 VS Code 状态栏可将 CRLF 改为 LF 后保存。也可在具有 GNU sed 的环境中,对本练习创建的脚本先备份再转换:

cp -i -- list-files.sh list-files.sh.crlf-backup
sed -i 's/\r$//' list-files.sh
bash -n list-files.sh
bash list-files.sh "sample files"

cp -i 在目标已存在时会询问是否覆盖。首次转换宜使用尚不存在的备份名,并在继续 sed 前确认备份确实保存了当前原稿;拒绝覆盖已有备份并不能保证它就是当前版本。sed 会原地修改脚本,去掉每行末尾的回车符。

转换后,语法检查预期无输出且状态为 0;只有最初两个示例文件时,执行应显示 files=2。需要撤销时先检查备份,再执行 cp -i -- list-files.sh.crlf-backup list-files.sh,并按提示决定是否恢复。

官方参考资料