Shell脚本初级测试题精选:变量、循环、条件与避坑解析
2026/9/9 19:16:28 网站建设 项目流程

Shell脚本这个东西,属于典型的“看着简单、一写就废”的技能。语法书翻过一遍,变量会了,循环会了,可真到了自己动手写一个能用的脚本时,各种奇奇怪怪的问题全冒出来了:引号没加报错、空格多了报错、for循环遍历的文件多了一个隐藏目录……我接触过很多刚入门的朋友,最常问的一句话就是“有没有shell脚本练习题能练练手”。所以这一篇就干脆整理一套Shell脚本初级测试题,从变量、条件、循环到综合实战,全部带解析和避坑说明。不管你是刚接触Linux的新手,还是想补一补基本功的运维或开发,都可以拿来自测一遍,看看到底哪些知识点是真会了,哪些只是“眼会了”。

1. 测试题的设计思路与知识范围

1.1 为什么用“做题”的方式学Shell

看教程和写脚本之间,差距远比想象中要大。很多人在终端里敲echo "hello world"没问题,但让他写一个判断目录是否存在、不存在就创建的脚本,就开始卡壳了。刷题的本质,是把零散的知识点逼进实操场景里:你必须在限定条件下完成一个目标,过程中会踩坑,踩了坑才会真正记住。

用测试题的方式去学Shell,还有一个好处是能快速暴露盲区。比如变量替换、命令替换、退出码、位置参数这些东西,光看解释会觉得“这有什么难的”,可一放到题目里,经常会出现 “为什么我这么写没效果”“为什么明明文件存在判断却是假”这类问题。这些盲区靠阅读是补不起来的,必须靠输出倒逼输入。

这套题的花样不算多,但每个题都经过筛选:尽量贴近日常运维和开发场景,不考偏门语法。做完一遍,脚本的编写思路会清晰很多。

1.2 题目难度与考察点编排逻辑

整套题大致分四个模块,难度递进:

模块考察范围对应章节
基础语法变量、引用、位置参数、算术运算第2章
条件与循环if判断、for/while、case分支第3章
综合实践文件处理、管道组合、日志清理、函数第4章
调试排错语法检查、运行追踪、常见报错第5章

每一章的题目都采用“题目 + 常见错误演示 + 正确写法 + 原理说明”的结构。你先自己做,再看答案,重点看我标注的“坑”和“为什么”,这两个才是这套题最值钱的部分。

2. 基础题型详解:变量、参数与算术运算

2.1 变量赋值与引用的两个经典坑

题目:写一个Shell脚本,定义一个变量,内容为hello world(中间有空格),然后用echo输出这个变量。

很多人一开始会这么写:

#!/bin/bash a=hello world echo $a

执行之后报错:

world: command not found

原因很明确:Shell的赋值语法中,等号两边不能有空格,但变量值本身也会做“分词”处理。它看到world是一个独立单词,就把它当成一条命令来执行了。

正确写法是加引号:

#!/bin/bash a="hello world" echo "$a"

这里还有一个进阶版坑:输出的时候,echo $aecho "$a"在很多情况下看似一样,但一旦变量里有空格、通配符、分号等特殊字符,不加引号就会出问题。比如:

a="*.txt" echo $a

如果当前目录下有a.txtb.txt,输出会变成a.txt b.txt,而不是*.txt。所以我的习惯是:所有变量引用一律加双引号,除非你明确希望Shell做分词和通配符展开。

再补充一个小知识:单引号和双引号的区别。单引号内部的所有字符都是字面量,$、反引号、双引号都不会被解释;双引号则允许变量展开和命令替换。实际写脚本时,绝大多数场景用双引号就够了。

2.2 位置参数与特殊变量

题目:写一个脚本,传入三个参数,分别输出脚本名、参数个数、所有参数、第一个参数和最后一个参数。

考察的是这几个特殊变量:

  • $0:脚本名
  • $#:参数个数
  • $@:所有参数,每个参数独立
  • $*:所有参数,合在一起
  • $1$2$3:按位置取参数
  • $?:上一条命令的退出码

一个标准参考写法:

#!/bin/bash echo "脚本名: $0" echo "参数个数: $#" echo "全部参数: $@" echo "第一个参数: $1" echo "最后一个参数: ${!#}"

注意最后一个参数的写法。如果直接用$!,那表示的是“后台执行的上一个进程PID”,不是最后一个参数。正确方式是${!#},这个知识点很多人不知道。

$@$*在没有双引号包裹时表现一样,但加了双引号就有区别:

for arg in "$@"; do echo "$arg" done

"$@"遍历,每个参数都是独立的;用"$*"遍历,所有参数会先合并成一个字符串,再被当作一个整体。你在脚本里看到“为什么循环只执行了一次”,大概率就是这里写错了。

2.3 算术运算的三种标准写法

题目:定义一个变量i=1,让它自增,分别用三种方式实现并输出结果。

Shell里的算术运算不像C语言那么自由,写i=i+1会被当成字符串拼接,或者直接报错。常用的三种写法:

#!/bin/bash i=1 # 写法一:双小括号 i=$((i + 1)) echo "$i" # 写法二:let let i++ echo "$i" # 写法三:expr(注意空格) i=$(expr $i + 1) echo "$i"

我最推荐的是第一种$((...)),语法清晰、支持变量名直接使用、还支持++--+=这些操作符。不推荐用expr,因为空格要求严格,容易踩坑。

算术运算还有一个隐蔽问题:空变量。如果某个变量没赋值,$((a + 1))不会报错,会按0处理。这在很多场景下是好事,但也可能导致逻辑错误——你以为变量有值,实际一直是0。严谨的写法是在使用前先做一次默认值设置:

a=${a:-0}

这样如果a未定义或为空,就自动补成0。

3. 条件与循环核心题型:if、for、while实战

3.1 if判断与测试表达式

题目:写一个脚本,判断/tmp/test.txt是否存在并且可读,存在则输出可以读取,否则输出文件不存在或不可读

先看一个容易犯的错:

#!/bin/bash if [ -f /tmp/test.txt ] && [ -r /tmp/test.txt ]; then echo "可以读取" else echo "文件不存在或不可读" fi

这个写法本身没问题,但有一个细节:[后面、]前面必须有空格。写成[-f /tmp/test.txt][ -f /tmp/test.txt]都会报command not found,因为[其实是一个命令(test命令的别名),命令和参数之间必须有空格。

为了兼容性和可读性,条件测试我通常推荐用[[ ]]而不是[ ]。区别在于:

  • [[ ]]是关键字,解析更宽松,支持&&||,不需要转义
  • [[ ]]支持=~正则匹配
  • [ ]里写&&必须拆成多个[ ],或者用-a

上面的题改成[[ ]]风格:

#!/bin/bash if [[ -f /tmp/test.txt && -r /tmp/test.txt ]]; then echo "可以读取" else echo "文件不存在或不可读" fi

这里顺便补充一个高频考点:文件测试运算符。-e判断存在,-f判断普通文件,-d判断目录,-r判断可读,-w判断可写,-x判断可执行,-s判断文件非空。试着自己写一遍这些判断,比背表格有用得多。

3.2 for循环的三种遍历写法

题目:分别实现三种for循环——遍历当前目录下所有.txt文件、遍历1到10、遍历显式给出的单词列表。

先看第一种,也是最容易出错的:

#!/bin/bash for file in *.txt; do echo "找到文件: $file" done

坑在哪里?如果当前目录下没有.txt文件,*.txt不会自动变成“空”,而是会原样保留为字符串*.txt,然后循环体执行一次,输出“找到文件: *.txt”。这是Shell通配符的典型陷阱。严谨的写法是在循环前判断一下:

#!/bin/bash shopt -s nullglob for file in *.txt; do echo "找到文件: $file" done

shopt -s nullglob的作用是让没有匹配到的通配符展开为空,循环体一次都不执行。这个选项在日常脚本里很实用,建议记下来。

第二种,遍历1到10:

#!/bin/bash for i in {1..10}; do echo "$i" done

花括号扩展{1..10}很简洁,但注意它只支持字面量,不支持变量。如果上限是变量,就得用seq或C风格写法:

#!/bin/bash max=10 for ((i=1; i<=max; i++)); do echo "$i" done

C风格for循环的好处是条件灵活,支持变量,也支持步长控制。

第三种,遍历单词列表:

#!/bin/bash for name in tom jack lucy; do echo "名字: $name" done

如果把列表换成“从文件读取”,更常见的做法是配while read,放到下一节的综合题里。

3.3 while read逐行读取文件

题目:用while read逐行读取/etc/passwd,统计总行数,并打印每一行的第一列用户名。

参考实现:

#!/bin/bash count=0 while IFS= read -r line; do user=$(echo "$line" | cut -d: -f1) echo "用户: $user" count=$((count + 1)) done < /etc/passwd echo "总行数: $count"

这里有两个关键点,新手特别容易踩:

第一是IFS=IFS是Shell的字段分隔符,默认包含空格、Tab、换行。read读取一行时,会用IFS把行左右两端的空白字符去掉。如果某一行以空格结尾,不加IFS=就会出现内容丢失。所以读取文件内容时,习惯性写成IFS= read -r line是安全的。

第二是-r选项。它禁止反斜杠转义。如果不加-r,文件中出现\n\t这样的内容时,read会把它们当作转义符处理,导致内容失真。

还有一个容易出现的问题:while read和管道的子Shell。如果你写:

cat /etc/passwd | while read line; do count=$((count + 1)) done echo "$count"

输出的count大概率是0。原因在于管道右边的while运行在子Shell里,子Shell中的变量修改不会影响到父Shell。解决办法就是我上面演示的输入重定向写法done < /etc/passwd,让循环运行在当前Shell中。

3.4 case多分支结构

题目:写一个脚本,模拟服务的启动停止操作。接收startstoprestart三个参数,分别输出对应的操作日志;传入其它参数时提示用法。

case实现:

#!/bin/bash case "$1" in start) echo "正在启动服务..." ;; stop) echo "正在停止服务..." ;; restart) echo "正在重启服务..." ;; *) echo "用法: $0 {start|stop|restart}" exit 1 ;; esac

case的匹配规则有两个技巧:

一是*)分支是兜底项,任何不匹配的情况都会走到这里。做命令行参数解析时,这个分支通常用来打印帮助信息,不要省略。

二是模式支持通配符和|多模式匹配:

case "$1" in start|begin|go) echo "启动" ;; esac

caseif链的代码可读性好很多,参数一旦超过两个,优先考虑case

4. 综合实践题:从单句命令到完整脚本

4.1 文件批量改名与移动

题目:当前目录下有一批以.jpg结尾的图片文件,把它们统一改名为.jpg.bak,并统计一共改了多少个文件。

参考实现:

#!/bin/bash count=0 for f in *.jpg; do [[ -e "$f" ]] || continue mv "$f" "${f}.bak" count=$((count + 1)) done echo "共修改 $count 个文件"

这里我特意加了一行[[ -e "$f" ]] || continue,用来处理“没有.jpg文件”的情况。上一节说过,没有匹配时*.jpg会原样保留,循环体执行一次;加上存在性判断后,如果这个“文件名”实际上不存在,就直接跳过。

进阶一点的问题:把文件名中的空格替换成下划线。这个题目很常见,因为批量处理从Windows拷贝过来的文件时经常遇到空白字符。实现方案:

#!/bin/bash for f in *.mp4; do [[ -e "$f" ]] || continue newname="${f// /_}" mv "$f" "$newname" done

${f// /_}是Shell的字符串替换语法:把变量f中所有空格替换成下划线。注意写法和正则类似,但这里不是正则,中间的空格就是字面空格。

4.2 管道组合与文本统计

题目:统计/var/log/nginx/access.log中状态码为404出现的次数,并输出次数最多的前3个IP。

先看统计次数的实现:

#!/bin/bash grep '" 404 ' /var/log/nginx/access.log | wc -l

这个比较简单。关键是前3个IP的统计,需要组合awksortuniq

#!/bin/bash awk '/" 404 /{print $1}' /var/log/nginx/access.log \ | sort \ | uniq -c \ | sort -rn \ | head -3

管道从左到右的作用分别是:提取所有出现404的行并取第一个字段(IP)、排序、统计出现次数、按次数降序排列、取前3行。这个思路几乎可以套用到所有日志统计场景,建议背下来。

需要注意一点:uniq只能去除相邻的重复行,所以必须先sortuniq -c。如果不排序,出现次数会被拆成多段,统计结果完全错误。这是我见过最多人踩的坑。

4.3 日志清理脚本综合题

题目:写一个脚本,清理/var/log/myapp/下30天前的.log文件,保留目录结构,输出删除的文件名和总共释放的空间。

参考实现:

#!/bin/bash log_dir="/var/log/myapp" find "$log_dir" -type f -name "*.log" -mtime +30 -print0 | while IFS= read -r -d '' file; do size=$(du -sk "$file" | cut -f1) rm "$file" echo "已删除: $file (${size}KB)" done

这里用到了-print0read -d '',目的是处理文件名中的空格和换行。如果文件名里没有空格,简化写法也够用:

find "$log_dir" -type f -name "*.log" -mtime +30 -delete

但要注意,find -delete是直接把匹配到的文件删掉,没有任何确认过程。生产环境里,我习惯的做法是先用不带-deletefind把文件列表列出来人工确认,再加上-delete执行。确认无误后还可以加上-print输出删除日志。

如果担心删错,可以用find先移动到备份目录,确认运行正常后再清理备份目录:

find "$log_dir" -type f -name "*.log" -mtime +30 \ -exec mv {} /backup/log_archive/ \;

这种“先备份再删除”的思路比直接删除安全得多,适合放到定时任务里。

4.4 函数编写与退出状态码

题目:写一个函数,传入一个端口号,判断该端口是否被占用。被占用返回0,未被占用返回1。

参考实现:

#!/bin/bash check_port() { local port=$1 if ss -tln | awk '{print $4}' | grep -q ":${port}$"; then return 0 else return 1 fi } if check_port 8080; then echo "端口8080被占用" else echo "端口8080空闲" fi

这里涉及到两个重点:

第一个是local变量。函数内部用local声明变量的作用域限在函数内,避免污染全局变量。写比较长的脚本时,这是保持变量清晰的重要手段。

第二个是返回值。Shell函数没有真正的返回值,靠return返回0~255的整数,调用方用$?if 函数名来判断。return 0代表成功,非0代表失败,这个约定和命令执行状态码是一致的。

端口判断那里,不同系统的命令不一样。CentOS 7以前用netstat,新版本用ss,写脚本时最好先判断命令是否存在。这里给一个兼容版本:

check_port() { local port=$1 if command -v ss >/dev/null 2>&1; then ss -tln | grep -q ":${port} " else netstat -tln | grep -q ":${port} " fi }

command -v是检查命令是否存在的最稳妥方式,输出重定向到/dev/null是为了避免把命令路径打印出来干扰脚本输出。

5. 常见问题与调试技巧实录

5.1 语法检查与运行追踪:bash -n 和 bash -x

我在实际调试脚本时,第一件事永远是用bash -n script.sh做语法检查。这个命令只检查语法错误,不实际执行脚本,能把“少了个fi”“if后面多了个空格”这类低级错误一次性找出来。

举例来说,下面这段脚本有一个常见的语法错误:

#!/bin/bash if [ -f /tmp/a.txt ]; then echo "exists"

运行bash -n会报:

syntax error: unexpected end of file

看到这个报错,十有八九是if没有对应fi

语法检查通过后,如果逻辑还有问题,就用bash -x script.sh执行。它的作用是把每条命令展开执行的过程打印到终端,前缀+表示命令展开后的结果。比如:

+ a=10 + echo 10 10

这样你能清晰地看到变量展开成了什么、命令实际执行的是什么。定位“变量没值”“条件判断不对”这类问题特别有效。小脚本可以直接bash -x跑,复杂脚本可以在脚本内部临时加set -x打开追踪,调试完再set +x关闭。

5.2 最容易出现的三类报错解读

第一类是command not found。出现原因一般是三种:脚本没有加#!/bin/bash或编辑器里带了不可见字符;变量赋值时等号两边有空格;命令名拼写错误。排查时可以执行bash -x script.sh,加上追踪输出后,到底哪一行出了问题一目了然。

第二类是syntax error near unexpected token。多半是ifthen没写在同一行还少了个分号,或者case分支缺少;;。如果你是从其他语言转过来写Shell,最容易犯的就是把Python的行结构套到Shell上——Shell对换行和空格的处理逻辑完全不一样。

第三类是integer expression expected。这个报错出现在test[ ]做整数比较时,变量不是纯数字。比如:

a="abc" if [ "$a" -gt 5 ]; then echo "yes" fi

就会报这个错。排查思路:先确认变量确实被正确赋值,再确认变量没有包含换行或空格。可以从echo "[$a]"开始看,把变量用中括号包起来,立刻能发现是不是有多余空白。

5.3 新手自查清单

我见过太多新人在刷题时问“为什么我写的和答案一样却报错”,大部分时候问题出在一些容易被忽视的细节上。把这套检查清单放在手边,能省很多事:

检查项正确做法
等号两侧赋值时a=1,等号两侧不能有空格
条件语句空格[ "$a" = "1" ],方括号内侧必须有空格
变量引用统一用双引号包裹,如"$a"
整数比较-eq-gt,不是==>
if/fi配对每个if必须有fi,每个case必须有esac
执行权限脚本需chmod +x,或使用bash script.sh
换行符在Windows编辑过的文件需转成Unix换行,否则会报错

关于换行符的问题多提一句:很多同学在Windows上用记事本或某些编辑器写脚本,传到Linux后执行就报bad interpreter,这是因为文件带了\r结尾。用sed -i 's/\r$//' script.sh就能处理掉,或者用dos2unix script.sh一步到位。

最后再分享一个小技巧。做练习题的时候不要只满足于“答案能跑通”,我一般会建议给每个题目的答案加上注释,写清楚“这段的作用是什么”“这个参数为什么这么设”。过两周再回头看,能看懂自己写的东西,才算真正掌握了。Shell脚本的知识点其实不多,但细节密布,多写几遍就会发现,那些曾经让你头疼的语法,慢慢都会变成肌肉记忆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询