批量改文件名、把日志按行读进分析、每 5 秒探一次端口直到它通------自动化脚本里只要重复的事,就离不开循环。Shell 提供了 for、while、until 三种循环,配合 break 和 continue 控制流程。这一篇把它们的写法、适用场景和最容易踩的分词坑讲清楚。
一、for 循环:遍历一组值
基本写法
bash
for 变量 in 值1 值2 值3
do
命令
done
bash
#!/bin/bash
for name in Alice Bob Carol
do
echo "你好,$name"
done
预期输出:
text
你好,Alice
你好,Bob
你好,Carol
in 后面是一个列表,shell 按空格把它切成若干项,每轮把一项赋给 name。注意列表里如果有带空格的词,必须加引号:
bash
for city in "New York" "Los Angeles" London
do
echo "城市: $city"
done
预期输出:
text
城市: New York
城市: Los Angeles
城市: London
不加引号时 "New York" 会被切成 New 和 York 两个元素,这是 for 循环最常见的错误。
用 {a..b} 生成数字序列
bash
for i in {1..5}
do
echo "第 $i 次"
done
预期输出:
text
第 1 次
第 2 次
第 3 次
第 4 次
第 5 次
{1..100} 是 bash 的大括号展开,生成 1 到 100 的整数。想控制步长用 {1..10..2},输出 1、3、5、7、9。
C 风格的 for
熟 C 语言的人会喜欢这种写法:
bash
for ((i=1; i<=3; i++))
do
echo "i = $i"
done
预期输出:
text
i = 1
i = 2
i = 3
注意是双圆括号 (( )),里面 i++ 可以直接用,不用写 i=$((i+1))。这种写法适合"循环 N 次"这种明确计数的场景。
遍历命令的输出
把命令替换的结果放进列表,就能遍历文件、行内容:
bash
#!/bin/bash
for file in *.log
do
echo "处理日志: $file"
done
假设当前目录有 app.log、error.log、access.log,预期输出:
text
处理日志: access.log
处理日志: app.log
处理日志: error.log
更常用的是遍历 ls、find 的结果。但这里有个大坑:文件名带空格时,未加引号的 for 会把它拆碎。稳一点的做法是用 find -print0 配合 while IFS= read -r -d '',后面讲 while 时会给完整示例。
二、while 循环:条件成立就一直转
bash
while 条件
do
命令
done
和 if 用同一套条件写法,条件退出码为 0 就继续循环。
bash
#!/bin/bash
count=3
while [ $count -gt 0 ]
do
echo "倒计时: $count"
count=$((count - 1))
done
echo "发射!"
预期输出:
text
倒计时: 3
倒计时: 2
倒计时: 1
发射!
别忘了在循环体里改 count,否则条件永远成立,脚本会变成死循环,按 Ctrl+C 才能停。
逐行读取文件
这是 while 最实用的场景之一:
bash
#!/bin/bash
while IFS= read -r line
do
echo "读到一行: $line"
done < /etc/hostname
假设 /etc/hostname 内容是:
text
web-server-01
预期输出:
text
读到一行: web-server-01
三个细节都不能少:IFS= 防止行首尾空格被吃掉;read -r 防止反斜杠被转义;最后的 < 文件 把文件内容重定向进来。少一个,某些行就会读错。
等一个条件满足:轮询端口
bash
#!/bin/bash
echo "等待数据库 5432 端口就绪..."
until nc -z 127.0.0.1 5432
do
sleep 1
echo "还没好,再等 1 秒"
done
echo "数据库已就绪"
数据库启动需要 3 秒时预期输出:
text
等待数据库 5432 端口就绪...
还没好,再等 1 秒
还没好,再等 1 秒
还没好,再等 1 秒
数据库已就绪
这种"等服务起来"的脚本在容器启动、CI 流水线里天天用。
三、until 循环:条件成立就退出
until 和 while 正好相反:条件不成立 时循环,条件一成立就退出。上面那个轮询例子用 until 写比 while ! 更直观。
| 循环 | 继续循环的条件 |
|---|---|
while cond |
cond 退出码为 0 |
until cond |
cond 退出码非 0 |
bash
#!/bin/bash
n=1
until [ $n -gt 5 ]
do
echo "n = $n"
n=$((n + 1))
done
预期输出:
text
n = 1
n = 2
n = 3
n = 4
n = 5
实际项目里 until 用得比 while 少,主要就是"等待某个状态变成真"这一类场景。绝大多数循环用 while 就够了。
四、break 和 continue
break:立刻跳出整个循环
bash
#!/bin/bash
for i in 1 2 3 4 5
do
if [ $i -eq 3 ]; then
break
fi
echo "i = $i"
done
echo "循环结束"
预期输出:
text
i = 1
i = 2
循环结束
continue:跳过本轮,进入下一轮
bash
#!/bin/bash
for i in 1 2 3 4 5
do
if [ $i -eq 3 ]; then
continue
fi
echo "i = $i"
done
预期输出:
text
i = 1
i = 2
i = 4
i = 5
3 那一轮被 continue 跳过,后面的 echo 没执行,但循环继续了。
嵌套循环时想跳多层
break 和 continue 默认只跳最内层。后面跟数字可以跳多层:
bash
break 2 # 跳出两层循环
continue 2 # 跳过外层当轮
这个语法不常见,读别人脚本时知道有这回事就行,自己写时宁可把内层循环包成一个函数,也别用 break 2 这种难读的写法。
五、实战:批量改名 + 统计
把这一篇的知识凑成一个真实常用的脚本:把当前目录所有 .txt 文件改名为 .md,并统计改了多少个。
bash
#!/bin/bash
count=0
for f in *.txt
do
# 没有匹配文件时,*.txt 本身会被当成字面量
[ -e "$f" ] || continue
mv "$f" "${f%.txt}.md"
count=$((count + 1))
done
echo "共重命名 $count 个文件"
假设目录里有 note1.txt、note2.txt、readme.txt,预期输出:
text
共重命名 3 个文件
${f%.txt} 是字符串截断,把变量值末尾的 .txt 去掉,这是 bash 参数展开的常用技巧。前面那行 [ -e "$f" ] || continue 是防御:目录里没有任何 .txt 文件时,*.txt 不会展开,f 会是字面量 *.txt,这行检查把这种情况跳过。
六、知识扩展:for 为什么会把带空格的文件名拆碎
这背后是 shell 的分词机制。当你写:
bash
for f in *.txt
shell 处理这行要走几步:先做通配符展开,*.txt 变成 note1.txt note with space.txt note2.txt;然后做单词分词------按 IFS(默认是空格、制表符、换行)把这一长串切成若干独立单词;最后才进入 for 循环。
问题就出在分词那步:note with space.txt 里的空格被当成了分隔符,结果它被切成了三个独立项 note、with、space.txt。这就是为什么"脚本批量处理带空格的文件"经常出 bug。
正确的通用姿势是用 find 输出以 null 结尾的文件名,配合 read -d '' 读:
bash
find . -name "*.txt" -print0 | while IFS= read -r -d '' f
do
echo "处理: $f"
done
-print0 用 \0 字符分隔文件名,read -d '' 按 \0 读取。因为文件名里不可能出现 null 字节,这种组合对任意古怪文件名都安全。
另一个相关的冷知识:for 循环里 do 后面那行能不能省略?不能直接省略,但可以写成 for ...; do,do 和 for 放一行用分号隔开------这就是为什么前面例子里 if 写成 if ...; then,for 写成 for ...; do,都是同一套规则。while、until、case、for 的 do/then/) 后面都必须换行或加分号,否则 shell 报语法错误。
循环写多了你会发现,90% 的 bash 脚本都是"for 遍历一批文件,while 读一行配置,if 判断一下,case 分流"这四件套的组合。把这四件的语法记牢,剩下的都是业务逻辑。