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 $a和echo "$a"在很多情况下看似一样,但一旦变量里有空格、通配符、分号等特殊字符,不加引号就会出问题。比如:
a="*.txt" echo $a如果当前目录下有a.txt、b.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" doneshopt -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" doneC风格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多分支结构
题目:写一个脚本,模拟服务的启动停止操作。接收start、stop、restart三个参数,分别输出对应的操作日志;传入其它参数时提示用法。
用case实现:
#!/bin/bash case "$1" in start) echo "正在启动服务..." ;; stop) echo "正在停止服务..." ;; restart) echo "正在重启服务..." ;; *) echo "用法: $0 {start|stop|restart}" exit 1 ;; esaccase的匹配规则有两个技巧:
一是*)分支是兜底项,任何不匹配的情况都会走到这里。做命令行参数解析时,这个分支通常用来打印帮助信息,不要省略。
二是模式支持通配符和|多模式匹配:
case "$1" in start|begin|go) echo "启动" ;; esaccase比if链的代码可读性好很多,参数一旦超过两个,优先考虑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的统计,需要组合awk、sort、uniq:
#!/bin/bash awk '/" 404 /{print $1}' /var/log/nginx/access.log \ | sort \ | uniq -c \ | sort -rn \ | head -3管道从左到右的作用分别是:提取所有出现404的行并取第一个字段(IP)、排序、统计出现次数、按次数降序排列、取前3行。这个思路几乎可以套用到所有日志统计场景,建议背下来。
需要注意一点:uniq只能去除相邻的重复行,所以必须先sort再uniq -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这里用到了-print0和read -d '',目的是处理文件名中的空格和换行。如果文件名里没有空格,简化写法也够用:
find "$log_dir" -type f -name "*.log" -mtime +30 -delete但要注意,find -delete是直接把匹配到的文件删掉,没有任何确认过程。生产环境里,我习惯的做法是先用不带-delete的find把文件列表列出来人工确认,再加上-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。多半是if和then没写在同一行还少了个分号,或者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脚本的知识点其实不多,但细节密布,多写几遍就会发现,那些曾经让你头疼的语法,慢慢都会变成肌肉记忆。