资讯动态

Shell编程循环语句精讲:for、while、until 与 break、continue、exit 实战指南

发布时间:2026/9/18 3:45:19 来源:尧图企业网站定制
写Shell脚本这件事很多人觉得能跑就行但真正到了批量处理文件、巡检服务器、写自动化任务的时候循环语句才是拉开脚本水平差距的关键。我做了七八年运维写过上千个脚本最深的体会是循环不只是“重复执行”那么简单它背后是遍历、重试、轮询、条件控制这一整套逻辑。今天这篇就把Shell编程之循环语句彻底讲透从for、while、until三种循环的选型思路到break、continue、exit的控制技巧再到几个我实际项目里改过无数遍的实战脚本一次说清楚。如果你正被各种循环报错折磨或者想写出更优雅、更不容易踩坑的脚本这篇值得你花十分钟看完。1. 循环语句的核心价值先搞懂三种循环怎么选1.1 循环能解决什么问题循环语句解决的场景其实可以归纳成三类遍历、重试、轮询。遍历是最高频的比如一个目录下躺着200个日志文件每个都要压缩归档或者一份IP清单里有300台服务器每台都要ping一遍确认存活。这种“一堆东西逐个处理”的需求本质就是遍历。重试稍微特殊一点典型场景是服务刚启动的时候端口还没就绪脚本要反复探测等到端口通了再往下走。轮询则是定时或周期性地检查某个状态比如监控CPU使用率、检查磁盘空间、清理过期文件。很多人学循环只记住了语法没搞明白自己到底属于哪类场景结果就是for、while、until乱用一气。其实选型很简单知道要处理哪些对象用for不知道要跑多久、只知道什么条件该停用while要“一直等到某个条件成立”就用until。1.2 for、while、until的定位差异三种循环的差异我用一张表来对比方便你快速定位循环类型语法关键字适用场景判断时机for循环for...in、for ((;;))遍历已知列表、固定次数循环进入时确定列表范围while循环while [ 条件 ]条件为真时持续执行每次进入前先判断until循环until [ 条件 ]条件为假时持续执行每次进入前先判断这里有个关键点while和until的判断时机都是在“进入循环体之前”。换句话说如果初始条件就不满足while循环体一次都不会执行如果初始条件一开始就满足until循环体一次都不会执行。这个特性和某些语言里的do-while不一样写脚本时要格外留意不然容易出现“我以为会执行一次结果根本没执行”的困惑。2. for循环的三种写法与避坑细节2.1 for...in列表遍历最基础的写法for循环最基础的形态是for...in直接从列表里取值#!/bin/bash for fruit in apple banana orange; do echo 当前水果是: $fruit done执行逻辑很直白shell会把in后面的字符串按分隔符拆成一个列表依次取出每个值赋给变量fruit然后执行do和done之间的代码块。这里的分隔符默认是空格准确说是环境变量IFSInternal Field Separator内部字段分隔符定义的值。这个写法最典型的坑也出在分隔符上。如果列表元素本身包含空格比如文件名是My Report.txt直接写在in后面会被拆成两个元素脚本逻辑瞬间崩了。解决办法有两个要么用双引号包住整个元素要么改用数组遍历#!/bin/bash files(My Report.txt Another File.txt) for file in ${files[]}; do echo 处理文件: $file done数组遍历这种写法在处理带空格路径时特别稳我写文件批量处理脚本时基本都这么干。2.2 C风格for循环索引遍历的利器写过C语言、Java的同学会更熟悉这种带索引的写法Shell也支持但语法上有些细节和C不一样#!/bin/bash for ((i 1; i 10; i)); do echo 第 $i 次循环 done注意三个细节变量i在for (( ))内部使用时不需要加$前缀条件表达式里的变量也不用加$自增写法支持i、i--、i2等多种形式。我见过不少同事把((i1; i10; i))写成((i1; $i10; i))结果直接报语法错误这个要特别注意。C风格for循环最适合的场景是“我明确知道要循环几次”比如生成10个测试文件、读取数组的第1到第5个元素用索引控制起来非常方便#!/bin/bash arr(web01 web02 db01 cache01) for ((i 0; i ${#arr[]}; i)); do echo 主机: ${arr[$i]} done${#arr[]}是获取数组元素个数这样写的好处是数组长度变化时循环范围会自动跟着变不用手动改数字。2.3 命令替换与通配符批量处理文件的正确姿势for循环最高频的实战用法是配合命令替换直接拿命令输出当列表#!/bin/bash for log in $(ls /var/log/*.log); do echo 日志文件: $log done这里要特别提醒一个坑用$(ls ...)这种方式解析命令输出一旦文件名里有空格输出会被拆成多个字段处理结果完全错乱。更稳妥的做法是直接利用shell的通配符展开让shell自己把匹配到的文件名当成一个整体#!/bin/bash for log in /var/log/*.log; do echo 日志文件: $log done通配符展开还有一个特点如果没有匹配到任何文件log变量的值会保持原样的通配模式比如直接输出/var/log/*.log这个字符串这在脚本里非常容易造成误判。所以处理时最好加一层存在性检查#!/bin/bash for log in /var/log/*.log; do [ -e $log ] || continue echo 日志文件: $log done[ -e $log ] || continue的意思是如果文件不存在就跳过这一轮。这个习惯我强烈建议你养成能省掉后面一大半排查时间。3. while与until循环条件驱动的循环逻辑3.1 while循环的执行逻辑与标准写法while循环的基本结构长这样#!/bin/bash count1 while [ $count -le 5 ]; do echo count $count count$((count 1)) done核心逻辑一句话讲完每次进入循环体之前shell先执行方括号里的test表达式拿返回值做判断。返回0表示条件成立继续执行循环体返回非0则退出循环。所以写while循环最关键的一点是——循环体里必须有一条语句能改变条件表达式的值否则条件永远成立就成了死循环。上面这个例子如果漏掉count$((count 1))这行脚本会无限输出count 1直到你CtrlC把它打断。这种低级错误在初学阶段特别常见排查起来又好气又好笑。3.2 死循环的两种写法和break兜底有些场景我们反而是故意写死循环的最常见的两种写法#!/bin/bash while true; do echo 持续运行中... sleep 2 done另一种等价写法是用冒号#!/bin/bash while :; do echo 持续运行中... sleep 2 donetrue和:都是shell内置命令什么都不做永远返回0所以循环条件永远成立。这种死循环一般不会裸奔里面通常会搭配一个跳出条件比如我之前写过的一个服务健康检查脚本#!/bin/bash while true; do if curl -s http://localhost:8080/health | grep -q OK; then echo 服务已就绪 break fi sleep 3 done逻辑是每隔3秒探测一次健康检查接口一旦返回内容里包含OK就用break跳出循环继续执行脚本后续步骤。这种“轮询等就绪”的模式在部署脚本里非常常见。3.3 逐行读取文件的正确姿势处理配置文件、用户清单、IP列表时逐行读取是绕不开的操作。我见过很多新手这样写#!/bin/bash cat /etc/passwd | while read line; do echo 读取到: $line done管道写法能用但有个隐含问题while跑在子shell里循环内部对变量的修改在循环结束后全部丢失。比如你想统计文件总行数循环里count$((count1))循环外面echo $count得到的还是0这种bug非常隐蔽。更靠谱的写法是用输入重定向#!/bin/bash while IFS read -r line; do echo 读取到: $line done /etc/passwd这里有两个细节必须解释清楚。IFS表示把字段分隔符设为空避免行首行尾的空格被read命令自动吞掉read -r表示禁用反斜杠转义防止行内出现\字符时被当成转义符处理。这两个细节平时不写可能没什么感觉但一旦遇到内容里有特殊字符的文件数据就会被悄悄改掉排查起来相当痛苦。3.4 until循环反向条件的妙用until和while的差异很好记while是“条件为真就一直跑”until是“条件为假才跑一旦条件变真就停”。看个例子#!/bin/bash wait_time0 until [ $wait_time -ge 30 ]; do sleep 1 wait_time$((wait_time 1)) echo 等待中... ${wait_time}秒 done这段脚本的逻辑是只要等待时间还没到30秒就继续等待一旦达到30秒立即退出。用while写也能实现但条件表达式要写成[ $wait_time -lt 30 ]语义上不如until直观。我实际项目里遇到过一个很适配until的场景数据库备份任务有依赖关系新任务必须等旧任务彻底结束后才能启动。用until直接写“一直等到锁文件消失”#!/bin/bash until [ ! -f /tmp/backup.lock ]; do echo 旧备份任务还在运行等待中... sleep 5 done echo 锁文件已释放可以启动新任务这种写法读起来就跟自然语言一样维护脚本的人一眼就能看懂意图。4. 循环控制三板斧break、continue、exit4.1 break跳出循环的层级控制break的作用是强行退出循环默认只跳出当前这一层。先看最基础的用法#!/bin/bash for i in {1..10}; do if [ $i -eq 5 ]; then echo 循环到第5个提前结束 break fi echo 当前: $i done输出1到4之后遇到i5满足条件直接跳出循环后面的6到10都不会再执行。在嵌套循环里break后面可以跟数字指定跳出几层。比如外层循环3次、内层循环3次想在内层命中某个条件时直接跳出两层#!/bin/bash for i in {1..3}; do for j in {1..3}; do if [ $i -eq 2 ] [ $j -eq 2 ]; then echo 命中(2,2)跳出两层 break 2 fi echo i$i j$j done donebreak 2的意思是跳出当前层以及上一层这个用法在矩阵遍历、二维数组处理时特别有用。不加数字的break等价于break 1。4.2 continue跳过本次迭代continue和break的区别在于continue不退出循环只是跳过当前这一轮直接进入下一轮。看例子#!/bin/bash for i in {1..5}; do [ $i -eq 3 ] continue echo 当前: $i done输出结果是1、2、4、5数字3那一次迭代被跳过了。continue在实际脚本里最常见的用途是“过滤不需要处理的对象”。比如批量处理文件时跳过临时文件#!/bin/bash for file in /tmp/data/*.txt; do case $file in *_bak.txt) continue ;; esac echo 处理正式文件: $file done这种过滤逻辑用if嵌套也能写但用continue可以让代码更扁平尤其是过滤条件多的时候提前continue能有效降低缩进层级让脚本更易读。4.3 exit循环内退出脚本与退出码break和continue都只作用于循环本身但如果脚本在执行过程中发现严重问题希望整个脚本立刻终止就要用exit。exit可以带一个退出码0表示成功非0表示失败#!/bin/bash for host in $(cat /etc/hosts | awk {print $2}); do ping -c 1 $host /dev/null 21 if [ $? -ne 0 ]; then echo 主机 $host 不通脚本终止 exit 1 fi done echo 所有主机均正常这里的$?是上一条命令的返回值ping成功返回0失败返回非0。一旦发现主机不通不仅退出循环整个脚本也终止并且给调用方返回1这个退出码。在自动化运维平台里上层调度系统就是靠这个退出码判断任务执行成功还是失败所以写脚本时善用exit码是个好习惯。5. 三个实战脚本拿来就能改5.1 批量重命名文件脚本场景很典型相机导出的照片都是IMG_001.jpg这种名字想统一改成photo_2024_001.jpg。这个需求我用一个for循环就搞定了#!/bin/bash cd /path/to/photos || exit 1 index1 for file in IMG_*.jpg; do [ -e $file ] || continue new_name$(printf photo_2024_%03d.jpg $index) mv $file $new_name echo 已将 $file 重命名为 $new_name index$((index 1)) done这里有个经验点printf的%03d是用来做数字补零的1变成001、10变成010这样生成的文件名在排序时是按字典序排的不会出现1、10、2这种乱序。[ -e $file ] || continue这行是防止目录下没有匹配的文件时通配符模式本身被当成文件名去执行mv必须加。如果只想保留原文件名的数字部分可以用参数扩展提取#!/bin/bash for file in IMG_*.jpg; do [ -e $file ] || continue num${file#IMG_} # 去掉IMG_前缀 num${num%.jpg} # 去掉.jpg后缀 mv $file photo_2024_${num}.jpg done${file#IMG_}和${num%.jpg}是shell的字符串裁剪语法前者从头删除匹配的最短前缀后者从尾删除匹配的最短后缀。这类参数扩展在处理文件名时非常好用强烈建议花十分钟学一下。5.2 批量ping检测主机存活脚本运维排查网络问题时经常要批量确认一批主机是否在线。下面这个脚本直接读取IP清单文件逐行ping最后汇总结果#!/bin/bash hosts_file/tmp/hosts.txt online_count0 offline_count0 while IFS read -r ip; do [ -z $ip ] continue if ping -c 1 -W 2 $ip /dev/null 21; then echo $ip 在线 online_count$((online_count 1)) else echo $ip 离线 offline_count$((offline_count 1)) fi done $hosts_file echo 在线主机: $online_count 台离线主机: $offline_count 台这个脚本里有几个细节值得学习[ -z $ip ] continue用来跳过空行ping -c 1 -W 2表示只发一个包超时2秒避免单台主机不通时整个脚本卡很久计数变量在while循环外声明配合重定向而不是管道保证循环结束后变量的值还在。最后输出的统计信息可以直接接进监控告警逻辑。5.3 日志清理脚本日志文件越攒越多是服务器上的老大难问题。下面这个脚本会扫描指定目录下的日志文件删掉超过7天且大于100MB的并记录清理明细#!/bin/bash log_dir/var/log/myapp threshold_days7 min_size104857600 # 100MB单位为字节 for logfile in $log_dir/*.log; do [ -e $logfile ] || continue file_size$(stat -c %s $logfile) mtime_days$(( ($(date %s) - $(stat -c %Y $logfile)) / 86400 )) if [ $mtime_days -gt $threshold_days ] [ $file_size -gt $min_size ]; then echo $(date %Y-%m-%d %H:%M:%S) 清理 $logfile (大小: $file_size 字节, 距今: ${mtime_days}天) /var/log/cleanup_history.log rm -f $logfile fi done这里的核心思路是先用stat拿到文件大小和最后修改时间然后用当前时间戳减去修改时间戳除以86400得到天数。日期计算在Shell里有个坑算术运算只支持整数所以/ 86400会把小数部分直接截掉如果文件是7天零1小时前修改的计算结果还是7天不会误删。这个脚本我建议你在清理策略里加上-f之前先跑一遍不带rm的调试版本确认匹配的文件确实是你想清理的那些。6. 循环踩坑实录与调试技巧6.1 常见问题速查表这些年我见过、踩过的循环相关坑整理成一张速查表建议保存常见现象根本原因解决方法文件名带空格被拆成多个默认按IFS空格分割用双引号包变量或用数组遍历while循环里改的变量外面读不到管道导致循环跑在子shell里改用输入重定向done file循环内$i的值总是最后一项变量引用没加引号或作用域问题确认循环体里变量引用为$i外层独立命名文本行首行尾空格丢失read默认裁剪空白写成IFS read -r line反斜杠被当成转义符少了-r参数写成read -r line通配符没匹配文件时出现脏字符串通配展开兜底行为加[ -e $file ]死循环刷屏循环体缺少条件更新语句检查是否有count$((count1))这类语句C风格for里变量多了$语法理解错误括号内变量一律不加$这些坑绝大多数都是“语法细节”和“子shell作用域”两个根源引发的写脚本时多留个心眼能省下大量排查时间。6.2 调试循环脚本的三个实操方法第一个方法是shell自带的跟踪模式。执行脚本时加-x参数shell会把每条命令展开后的真实执行情况打印出来bash -x myscript.sh也可以只在脚本内部开启在循环前面写set -x循环结束后写set x这样只跟踪循环部分不会被其他内容刷屏。-x模式下变量会展开成实际值打印条件判断的结果也一目了然是我排查循环逻辑问题的第一选择。第二个方法是在关键位置打标记。虽然土但非常有效for i in {1..10}; do echo DEBUG: 进入循环 i$i 2 # ...业务逻辑... done把调试输出重定向到2标准错误这样在脚本正常跑的时候调试信息不会混入标准输出的业务数据排查完删掉就行。第三个方法是把可能出问题的中间量打印出来。比如处理文件列表时先跑一遍只打印变量的循环确认列表内容符合预期再套上真正的业务逻辑。这种“分步验证”的思路比一次性写完整个脚本再查错要高效得多。我个人实际写循环脚本的体会是尽量不要一上来就写复杂的嵌套循环先用最简单的方式把数据处理逻辑跑通再加循环边界条件、过滤条件和错误处理。循环这玩意儿看着简单真正写出“不出错”的版本是需要反复打磨的。尤其是涉及到文件操作、变量作用域、特殊字符这三类场景建议每一步都验证宁可慢一点也别让脚本在半夜的定时任务里悄悄出错。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价