如果你在Linux世界里待得足够久,就会发现所有看似高大上的运维平台、自动化工具,底座几乎都是一个黑乎乎的终端窗口。我经常跟新人说,别急着去学什么容器、编排,先老老实实把Shell搞明白。原因很简单:Shell既能让你逐条敲命令快速验证想法,又能把一系列操作写成脚本固定下来,达到“一次编写、到处复用”的效果。本文就是一份从入门到进阶的Shell编程指南,涵盖变量、循环、函数、调试以及日常高频场景,适合被一堆命令搞得头大的新手,也适合想查漏补缺的老手。
1. Shell编程的核心价值与整体设计思路
1.1 为什么你值得花时间精通Shell
你可以把Shell理解成“翻译官”。用户在终端里敲的每一句命令,Shell都会先做语法解析、变量展开、通配符匹配,再交给内核去执行;而当我们把多行命令写进一个文件并加上执行权限时,它就成了一个脚本程序。这种“命令即代码”的模式,天然适合处理文本、管理进程、调度任务、批量操作文件等场景。
很多人觉得Shell只是“敲命令”,不值得深学,其实恰恰相反。我工作里遇到过大量重复劳动:每周都要把几十个日志文件按日期归档、把一批图片批量压缩、在几十台机器上检查服务状态。如果用图形界面一个个点,两小时就没了;而用Shell脚本十分钟写完,之后再跑只需要一条命令。尤其是Linux服务器环境往往没有图形界面,这时候Shell几乎是唯一高效的操作方式。哪怕是嵌入式开发、Android调试这类领域,也到处是adb shell、/system/bin/sh这种影子,不懂Shell只能干瞪眼。
1.2 脚本设计的通用方法论
刚开始写脚本的人最容易犯的毛病是“想到哪写到哪”。比如要重命名一批文件,直接写一行mv命令,发现文件名有空格、又去加引号,然后发现还有子目录,又加find……最后代码里全是补丁,过一个月自己都看不懂。
我的习惯是先花五分钟把需求拆清楚。一问:输入是什么?是当前目录下的一批文件,还是某个文件列表?二问:要对每一个对象做什么操作?是改名、移动、还是统计?三问:有没有例外情况?比如空文件、特殊字符、重复项目?四问:这个脚本是一次性的,还是以后要反复用?这些问题的答案决定了脚本的结构。
如果脚本要复用,我通常会做成三部分:开头定义变量和配置,中间放一个主流程,最后加若干函数封装具体操作。主流程尽量短,一眼能看出脚本在干什么;具体细节放进函数里,出问题好定位。注释不用写很多,但每个函数的入口参数和返回值必须写清楚。再就是在脚本开头加set -euo pipefail,这行命令能让脚本遇到未定义变量、命令失败、管道出错时当场退出,而不是带着错误继续跑,后面我会细讲。
2. 从基础语法到核心机制
2.1 变量、引号与作用域
Shell里的变量声明很简单,name="value"即可,但有几个点新人经常犯迷糊。第一是赋值等号两边绝对不能有空格:name = "value"会被当成三个词,Shell会尝试把name当作命令去执行,然后报错command not found。第二是取值时用$name或者${name},在字符串中拼接时最好用${name},比如${name}_suffix,否则Shell会认为变量名是name_suffix。
引号的作用我强调过无数次。单引号'...'里的内容原样保留,$、反引号、通配符统统不解释;双引号"..."会做变量展开和命令替换,但不会处理通配符。举个例子:
name="world" echo 'hello $name' # 输出 hello $name echo "hello $name" # 输出 hello world更关键的是,当你希望一个带空格的字符串被当成一个整体时,一定要加双引号。比如:
path="/tmp/my backup" mkdir $path # 错误!会被拆成 mkdir /tmp/my 和 backup mkdir "$path" # 正确这一点在长参数、文件路径、命令替换里尤其致命,我见过太多脚本因为少了一组引号导致删错文件。
关于作用域,Shell默认的变量是全局的,但注意“全局”只限于当前Shell进程。你在终端里定义一个变量,执行./script.sh时这个脚本会起一个子进程,子进程里看不到父进程的普通变量。要让子进程能看到,必须用export导出成环境变量:
export MY_CONFIG="/etc/myapp.conf"反过来,脚本内部的变量如果不想污染父Shell,可以在变量名前加local(在函数内)或者干脆在子Shell里执行。export本身只影响当前Shell及它之后启动的子进程,不会影响父Shell。我经常用env命令来验证环境变量到底有没有传过去,这个排查思路很实用。
2.2 条件判断与循环
Shell的if语句和主流编程语言相比稍显繁琐,但核心思想一致。一个典型的写法是:
if [ -f "$config" ]; then echo "配置文件存在" elif [ -d "$dir" ]; then echo "目录存在" else echo "都不存在" fi注意[后面、]前面必须有空格,then要换行或用分号。方括号里其实就是test命令的语法,常见的判断包括-f(文件存在)、-d(目录存在)、-z(字符串为空)、-n(字符串非空)、-eq/-ne/-gt/-lt(整数比较)、=/!=(字符串比较)。我个人的建议是:能用[[ ]]就用[[ ]],它比[ ]更宽容,支持正则和逻辑组合,例如:
if [[ "$file" == *.log && $count -gt 3 ]]; then echo "匹配" fi但要注意[[ ]]不是所有Shell都支持,写成#!/bin/bash就没问题,如果你要兼容sh,还是老实得用[ ]。
循环更是Shell脚本的日常主角。for循环最常见的是遍历一组值:
for i in 1 2 3 4 5; do echo "Number: $i" done也可以遍历一个命令的输出:
for file in $(ls *.txt); do echo "处理 $file" done但这里有个坑:如果文件名带空格,$(ls *.txt)会把空格也当作分隔符,文件名被拆得七零八落。更稳妥的是用find配合while read:
find . -name "*.txt" -print0 | while IFS= read -r -d '' file; do echo "处理 $file" donewhile循环常用于按行处理文件和计数。比如统计一个日志文件里每行的字符数:
while read -r line; do echo "${line} : ${#line}" done < access.logread -r表示不处理反斜杠转义,这是标准写法。如果你需要对一系列数字做循环,可以用seq或者{1..10},后者在Bash 3以上都支持。
2.3 参数处理:shift、$@、$*与函数
脚本跑起来后,外部传入的参数靠$1、$2、$3获取,$0是脚本名,$#是参数个数。但实际写脚本时,参数个数往往不固定,这时候就要理解$@和$*。简单说,$@把所有参数当作一个数组,逐个展开时保留边界;$*把所有参数拼成一个字符串,在循环里容易丢边界。所以遍历参数我永远用"$@":
for arg in "$@"; do echo "参数: $arg" doneshift命令的作用是“左移”参数列表:干掉当前$1,原来的$2变成新的$1,$#减一。这在写需要固定参数加可变参数的脚本时非常有用。比如一个脚本的使用方式是./script.sh -f file -n 10 -v,你想逐个解析选项:
while [ $# -gt 0 ]; do case "$1" in -f) file="$2"; shift 2 ;; -n) count="$2"; shift 2 ;; -v) verbose=1; shift ;; *) echo "未知参数 $1"; shift ;; esac done这里每处理一个选项就shift,把已经消费掉的参数挪走,循环才能干净利落地推进。很多新手不用shift,全靠$2``$3手动跳,结果参数一多就头晕。函数和脚本参数在写法上一致,函数里的$1``$2是函数的参数,不是脚本的全局参数。函数内部如果要返回值,我习惯用echo输出结果,然后调用方用$(func arg)捕获,而不是用return——return只能返回0~255的整数,更适合做状态码。
3. 实用脚本实操:文件处理与系统管理
3.1 批量重命名:Shell重命名文件的几种姿势
“用Shell重命名文件”是搜索热词里躲不开的一条,几乎所有人在某个阶段都会遇到“把100张图片从IMG_001.jpg改成photo_001.jpg”这种需求。我用三种方式解决,分别对应不同复杂度。
最简单的是用mv加循环:
for f in IMG_*.jpg; do mv "$f" "photo_${f#IMG_}" done${f#IMG_}是参数展开,表示去掉变量f的前缀IMG_。这个写法很巧妙,但只适合规则统一的前缀替换。
如果文件名里包含连续递增的编号,而且位数不齐,可以用rename命令。Debian/Ubuntu上的rename是Perl版本,直接支持正则:
rename 's/IMG_(\d+)/photo_$1/' IMG_*.jpg不过CentOS上的rename是util-linux版,不支持正则,只能做简单替换:rename IMG_ photo_ IMG_*.jpg。两边的参数完全不一样,用之前一定先看man rename。
最普适、最安全的方法是用find加while read:
find . -name "*.jpg" -print0 | while IFS= read -r -d '' original; do dir=$(dirname "$original") base=$(basename "$original") newname="$dir/new_$base" mv "$original" "$newname" done这样连子目录里的文件都能覆盖,还能通过basename和dirname自由组合路径。无论哪种方式,我都建议先在文件名上做echo "$newname"模拟一遍,确认无误再真正执行mv。批量操作加个echo是零成本保险,别偷懒。
3.2 自动备份与日志清理
服务器上最刚需的脚本就是备份和日志清理。备份的本质是把指定目录打包,再用日期做标识。我常用的备份脚本骨架如下:
#!/bin/bash set -euo pipefail SRC="/var/www/html" DEST="/backup/www" DATE=$(date +%Y%m%d_%H%M%S) BACKUP_FILE="$DEST/www_$DATE.tar.gz" mkdir -p "$DEST" tar czf "$BACKUP_FILE" -C "$(dirname "$SRC")" "$(basename "$SRC")" echo "备份完成: $BACKUP_FILE" find "$DEST" -name "*.tar.gz" -mtime +30 -delete第5行用date命令生成时间戳,这样每次备份都是新文件,不会互相覆盖。tar的-C参数是为了避免把绝对路径也存进去,恢复的时候更灵活。最后的find -mtime +30 -delete会删除30天之前的旧备份,保持磁盘不爆。
日志清理更简单,但要小心别把正在写入的日志删了。稳妥做法是先压缩再删除,给日志追加.bak:
find /var/log/myapp -name "*.log" -type f -mtime +7 | while read -r f; do gzip "$f" done注意gzip会生成.gz文件并删除原文件,同时它会在原文件基础上写临时文件,所以如果你使用的应用还开着旧日志文件描述符,压缩可能没问题,但不建议对正在写的文件做这类操作。规范做法是让应用自己轮转日志(logrotate),脚本只能作为补充。
3.3 嵌入式/Android调试中的Shell
很多搞安卓开发或嵌入式的小伙伴,也天天跟Shell打交道。adb shell本身就是进入设备Linux子系统的入口,里面用的是Android的mksh或toybox,很多命令和PC端略有差异,但脚本语法相通。比如在安卓上批量卸载用户应用:
adb shell pm list packages -3 | while read -r pkg; do adb shell pm uninstall --user 0 "$pkg" done注意热词里常出现adb shell pm uninstall --user0,正确写法是--user 0,表示卸载用户0空间下的应用。这类命令如果是管理员权限,谨慎使用,免得误删系统级应用。
有一次我帮人排查安卓设备上的手势操作问题,日志里出现adb -d shell sh /storage/emulated/0/android/data/...这类命令,本质上就是通过adb shell执行一个位于设备存储上的Shell脚本。这种做法在自动化测试和ROM定制里很常见。要记住的是,adb shell后面跟的命令,如果带重定向、管道、逻辑运算符,最好把整个命令用双引号包起来,否则你本地Shell会先把这些符号兼容掉,导致设备端收到的参数不对。我一般是这么做的:
adb shell "cat /proc/version && uname -a"尤其在Windows宿主机上,回车符CRLF会悄悄混进脚本文件,导致设备端报No such file or directory却怎么都找不到问题,这个坑几乎每个人都踩过。
4. 常见坑与排查技巧
4.1 高频踩坑点
我把这几年在Shell里踩过的坑集中总结一下,每一条都是血泪教训。
第一,变量不加引号。前面提过,rm $file和rm "$file"在多数普通路径下看起来一样,但只要路径里有空格、换行或通配符,结果立刻变成灾难。尤其是rm -rf配合未加引号的变量,极容易误删文件,这种事故不是新闻。所以所有变量展开处,拿不准就加引号,绝对没错。
第二,if [ $? -ne 0 ]的误用。很多人喜欢用$?判断上一条命令是否成功,但如果你在中间不小心插入了别的命令,$?早就被覆盖了。正确的做法是把需要检验的命令直接放在if的条件里:
if command; then echo "成功" else echo "失败" fi第三,使用#!/bin/sh却写了Bash专属语法。不同发行版的/bin/sh可能指向dash,它不支持[[ ]]、数组、source等。如果脚本里有这些语法,执行器会报错。我建议要么明确写#!/bin/bash,要么去认真学POSIX兼容写法。
第四,脚本没有换行符或没有执行权限。bash: ./script.sh: Permission denied这种错一看就是忘加chmod +x了。而“脚本在Windows上写好后放到Linux执行”会报\r command not found,用dos2unix或者sed -i 's/\r$//'可以修。
第五,cd进目录没做检查。脚本里cd "$DIR"如果失败,后面的命令全在错误路径下执行,后果不可预知。我一般会写:
cd "$DIR" || { echo "进入 $DIR 失败"; exit 1; }这个习惯救了我很多次。
4.2 调试三板斧:set -x、bash -n、shellcheck
写Shell脚本最怕的不是报错,而是“不报错但结果不对”。这时候就得靠三板斧走天下。
第一斧,在脚本开头加set -x。它会打印每一条实际执行的指令,变量已经被展开成具体值,能让你看清流程到底走了哪条路。set -e(出错即退)和set -u(变量未定义即退)也是我必加的,三兄弟往往一起出现:
set -euxo pipefail但注意set -e有例外,它不会捕获if条件里的命令失败、也不会捕获&&和||左侧的失败,所以别迷信它,它就是帮你尽早发现问题。
第二斧,用bash -n script.sh做语法检查。这个命令不会执行脚本,只解析语法,能检查出漏写done、fi、括号不配对等低级问题。对于循环或复杂函数,也能避免跑到一半才发现语法错误。
第三斧,用shellcheck做静态分析。shellcheck是社区公认的Shell检查工具,能指出变量未加引号、错误的比较写法、可能在sh下不兼容等数百种问题。安装一条命令搞定:apt install shellcheck或者yum install shellcheck。我写任何超过20行的脚本,发布前都会过一遍shellcheck,至少能清掉80%的坑。它给出的建议编号后面跟着简单说明,能力强的还可以用# shellcheck disable=SC2086在代码里选择性忽略。
4.3 高频问题速查表
这里我把平时被问得最多的Shell问题整理成一张速查表,每个都是热搜词背后的真实痛点。你对照着自己的脚本排查,基本可以一招定位。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
-bash: xxx: command not found | 命令不在PATH里,或变量拼写错误 | echo "$PATH",检查命令路径;或检查变量名 |
执行脚本提示Permission denied | 没有执行权限 | chmod +x script.sh |
脚本在Windows写的,跑起来报\r错 | 文件带了CRLF | sed -i 's/\r$//' script.sh或dos2unix |
for循环只处理了第一个文件 | 文件名含空格,循环分隔非法 | 用while read -d ''或find -print0 |
| 变量明明赋值了,但子进程取不到 | 没有export | export VAR或启动子进程前确认环境变量 |
[[ ]]语法报错 | sh下不支持[[ ]] | 改用[ ]或让脚本#!/bin/bash |
shift次数过多导致循环提前结束 | 参数数量少于预期 | 检查参数数量,加if [ $# -lt n ]保护 |
find -delete没反应 | 路径或条件不匹配 | 先去掉-delete,加-print看输出 |
tar备份出现Cant open file | 路径含通配符或软链问题 | 用绝对路径,tar -C避免相对路径 |
export后脚本里还是看不到 | 使用了子shell执行环境 | 用source(或.)而不是./执行脚本 |
这张表没办法覆盖所有场景,但高频问题就是这些。排查的时候记住一个原则:先用echo把变量打出来,再看看它是不是你预期的值,如果预期值有误,多半是语法或引号的问题;如果预期值正确但命令出错,那就是命令本身或权限的问题。一层层缩小范围,比瞎猜快得多。
最后再分享一点个人经验。我刚接触Shell时也总想炫技,用一行管道搞定别人十几行脚本干的事,写完特别得意。但后来发现,那种“聪明”的脚本往往是最难维护的——一行里塞了五六个管道,中间某个环节格式不对劲,结果就是一团乱麻。现在我宁可写十几行清爽的、带函数名和注释的代码,也不愿用一条火箭筒般的管道。Shell编程跟其他编程一样,首要目标是让人能看懂,其次才是让机器跑得快。希望这份指南能让你少走些弯路,把那些本该写脚本的时间省下来,去享受生活和代码的双重乐趣。