
先问个问题你在Shell里敲过的最长一条命令是什么是一长串管道还是带了一堆awk的ps aux | grep xxx如果你能熟练拆解这些命令却始终写不出一份像样的Shell脚本那大概率卡在同一个小坎上——你一直把Shell当作那个黑框框而不是一门真正的编程语言。我见过太多开发者的Linux技能树是断的命令敲得飞起脚本写得抓狂。一个用来批量处理几百个日志文件的sh写得像拼图一样勉强能跑文件名一旦带空格、变量忘了加引号、管道中间某段失败了立刻崩给你看。这篇我不打算给你列命令字典而是把Shell脚本这条线从头捋一遍先搞清楚Shell到底是什么再把for循环、shift、引号和退出码这些老熟人掰开揉碎接着带你看一场真实的排错事故现场最后把hbase shell、adb shell、EFI Shell这些藏在特定工具里的Shell变体也一并讲透。适合刚入门脚本的新手也适合那些命令会敲、脚本不会写的准老手。1. Shell到底是什么一个被你用成黑框框的编程语言1.1 本质你和内核之间的那个传话筒很多人对Shell的理解停留在一个可以敲命令的窗口这是最要命的误解。Shell的准确身份有两个。第一个身份它是用户态的一个程序负责接收你输入的字符串解析成命令然后拉起别的执行进程再把结果交还给你。换句话说你敲的ls -l实际上是Shell替你fork出了一个ls进程并给它传了-l参数。第二个身份它是一门完整的编程语言。这门语言里可以定义变量、使用数组、切分字符串、声明函数、写条件和循环甚至在Bash里还有关联数组哈希表。你完全可以把几十个文件操作、网络请求、文本处理命令编排在一起让它自动跑完。一句话总结Shell既是命令执行的环境也是把这些命令编织成逻辑的线。我习惯把这两个身份称为调度员和胶水——它自己不怎么干活但所有干活的人都要听它安排。这里有个很多人没意识到的点你在Shell里敲mpv video.mp4以为Shell在播放视频其实Shell只是拉起了一个mpv进程。视频解码、音频输出全是mpv干的Shell唯一的功劳是把启动mpv这个意图传达给了内核。这个概念在后面讲hbase shell和adb shell时还会用到只要环境里存在交互式命令解释器不管它长在Linux、Android还是数据库里你的Shell思维都能直接迁移过去。1.2 Bash是老大但它不是唯一的ShellLinux世界默认的Shell是Bash但你在网上查资料时常会看到sh、zsh、dash、fish、PowerShell它们都是Shell差异还挺大。我整理了一个简表Shell特点典型场景BashLinux默认语法功能全面有数组、关联数组、[[ ]]绝大多数Linux脚本首选Zsh交互体验好补全能力强向下兼容BashmacOS默认Shell、日常终端使用Dash轻量POSIX兼容没有Bash的高级语法Debian/Ubuntu系的/bin/shFish开箱即用语法更友好日常交互不推荐做跨机器脚本PowerShell面向对象输出是对象而非文本Windows自动化运维最容易踩的坑是shebang。脚本第一行写#!/bin/bash和#!/bin/sh在绝大多数Linux发行版上都能跑但在Debian系Ubuntu就是里/bin/sh其实是指向dash的软链。dash是一个只支持POSIX语法的最小Shell它没有Bash的数组、没有[[ ]]、没有${var//替换}这些扩展语法。我当年在Ubuntu上写过一个脚本本地跑得好好的发给同事一执行就报[[: not found查了半天才发现同事机器上/bin/sh是dash而我的脚本用了Bash专有的[[ ]]。从那以后我自己的脚本一律写#!/bin/bash除非明确要求POSIX兼容。顺带回答一个Windows下很常见的问题Windows环境用什么Shell工具好如果只是想在Windows上写点跨平台脚本Git Bash最省事如果有Linux开发、部署、AI工具链需求WSL才是正解它给你的是一个完整的Linux用户态。PowerShell是另一条路线对象管道设计很惊艳但和本文讲的Bash脚本语法不通用你只需要知道它也是一个Shell即可。1.3 Shell这个词在不同语境下是好几张脸你搜Shell时经常会被完全无关的内容带偏我一次给你区分清楚第一张脸是命令行Shell也就是本文主角。第二张脸是特定软件的内嵌Shell比如hbase shell、adb shell、EFI Shell、vCenter/ESXi Shell它们的本质是某个软件自带的命令交互环境语法不一定和Bash一样但交互逻辑完全相通。第三张脸是名字里恰好带Shell、但跟命令行八竿子打不着的东西最典型的就是GNOME桌面里的Tiling Shell扩展那是一个增强型平铺窗口管理器核心定位是让GNOME原生的窗口平铺逻辑更接近专业平铺WM解决的是键盘流用户窗口排列全靠拖鼠标的痛点适合喜欢纯键盘操作桌面的人——它跟Shell脚本是两个世界。还有一个情况你在FreeCAD这类三维建模软件里看到The reference marker of an extrusion, revolution, or shell must belong这种报错这里的shell是壳体特征是建模术语同样和脚本无关。再比如有些在线平台明确提示does not provide shell access意思是这个环境没给你开交互式Shell权限你只能通过配置文件或接口去操作别想着能拿到一个终端慢慢敲命令。搞清楚这些语境能省下很多翻资料的冤枉时间。2. 从for循环到shift命令把脚本写成能用十年的样子2.1 脚本骨架不是把命令抄进文件就叫脚本先解决最基础的一件事一段能跑的脚本长什么样。它通常包含三部分解释器声明、变量定义、主逻辑。比如你想检查某几个服务是否在运行可以这样写#!/bin/bash namenginx if systemctl is-active $name /dev/null 21; then echo $name 正在运行 else echo $name 未运行 fi这里的#!/bin/bash是shebang告诉内核用什么解释器执行这份文件。/dev/null 21表示把标准输出和错误输出都丢弃因为我们只关心命令的退出状态码。if systemctl is-active $name这句话看起来是在判断文本其实是在判断退出码systemctl is-active如果服务在运行返回0否则返回非0。Shell里的if判断的永远是命令的返回状态不是布尔表达式本身这个认知是新手的第一个分水岭。执行时有两种姿势bash script.sh或者先chmod x script.sh再./script.sh。前者不管文件有没有执行权限都能跑后者更接近独立可执行程序的感觉。脚本不一定非要加.sh后缀我很多脚本直接就放在~/bin目录下起名叫ipinfo、deploy这种放进PATH里就能像普通命令一样调用这个习惯我强烈推荐。2.2 for循环四种写法以及为什么我很少用ls遍历for循环是Shell脚本里出现频率最高的结构基本可以归纳成四种写法# 1. C风格用数字控制次数 for ((i 1; i 10; i)); do echo 第 $i 次 done # 2. 显式列表遍历固定集合 for env in dev test prod; do echo 环境: $env done # 3. 通配符展开遍历当前目录下的.log文件 for f in /var/log/*.log; do echo 处理 $f done # 4. 命令替换不推荐后面讲原因 for f in $(ls /var/log/*.log); do echo 处理 $f done第一种适合精确控制循环次数第二种适合枚举固定环境名第三种适合处理文件集合第四种是我最不推荐的——$(ls ...)会把ls的输出交给Shell再做一次词切分和路径展开等于对结果做了二次处理。文件名里一旦有空格第四种会直接把一个文件名拆成多个词导致循环体拿到残缺路径。在命令行上ls是为了给人类看的在脚本里直接使用通配符或find才是正经做法。顺带说一个遍历文件内容的小技巧很多人写for line in $(cat file)这个同样是词切分陷阱正确姿势是while IFS read -r line; do ...; done fileIFS是为了避免行首行尾空白被吞-r是为了防止反斜杠被转义。这个写法初看有点别扭但它是处理按行读取的标准答案值得直接背下来。2.3 shift命令让脚本优雅地消费参数脚本的定位参数从$1开始一共$#个$0是脚本名$是所有参数组成的列表。shift的语义非常直观每次执行所有位置参数左移一位原来的$2变成新的$1$#减一。它和while组合在一起就能实现一个最朴素的参数解析器。#!/bin/bash while [ $# -gt 0 ]; do case $1 in -h|--help) echo usage: $0 --env dev|prod exit 0 ;; --env) env$2 shift 2 ;; *) echo 未知参数: $1 exit 1 ;; esac done echo 当前环境: ${env:-dev}注意这里有个关键细节--env dev用了shift 2一次性跳过两个参数因为dev已经被env$2消费掉了。如果不小心只写shift 1下一次循环就会把dev当成新的$1掉进*分支。我刚开始写参数解析时犯过这个错表现是明明传对了参数程序却报未知参数。如果脚本参数结构很复杂建议直接用getopts它支持-a、-b value这种短选项语法解析规则更规范。但getopts不支持长选项如果你需要--env这种风格树fudge一个caseshift的方案或者用zsh/外部工具但就我个人经验90%的脚本用shift就能解决而且可读性更好。2.4 实战用Shell批量重命名文件附空格文件名避坑日常里最常用的场景就是批量重命名。假设你有一堆照片要加前缀#!/bin/bash cd ~/photos for f in *.jpg; do mv $f 2025_${f} done这段代码能跑但有两个前提一是照片文件确实在~/photos目录下二是不存在2025_A.jpg这种已经处理过的文件否则会重复加前缀。实际上我更推荐在写任何处理文件的循环前先打印一遍将要执行的命令确认无误再真正执行#!/bin/bash for f in *.jpg; do echo mv $f 2025_${f} done多了一个echo就能防止九成的手滑误操作。这种先预演再执行的思路在删文件、改权限、批量上传等危险操作上尤其重要我已经把它变成了肌肉记忆。批量改名还有一个更强力的工具rename命令。这里的rename是Perl版在Debian/Ubuntu上要单独装语法是正则替换rename s/\.jpg$/\.png/ *.jpg它在批量替换后缀、批量加编号、按正则过滤文件名时比手写formv高效得多。但正则可读性差如果你只是简单加前缀写for循环反而更清楚。另外注意文件名以-开头时mv会把文件名当成选项正确姿势是mv -- $f 2025_${f}--表示后面不再是选项。这个细节在脚本里很容易被忽略等线上出问题才想起来那已经是爆过的雷了。3. 踩过的那些Shell坑一场与空格、退出码和引号的战争3.1 坑一变量没加双引号等价于让Shell对你做词切割先做一个思想实验dir/home/me/My Photos cd $dir你会得到报错因为不加引号的$dir展开后Shell按空白字符把它切成了两个词/home/me/My和Photoscd只收到第一个词自然找不到目录。加上引号cd $dir它才被当成一个完整的路径。这个切割规则叫IFS内部字段分隔符默认是空格、制表符、换行。Shell世界里所有变量展开、命令替换、算术展开的结果都会在执行前经历一次IFS切割。所以变量要加双引号这条规则不是洁癖是保命。当年我在生产环境写过一个清理临时文件的脚本文件名里带日期和空格rm $file直接把我同事好几个重要文件挪进了错误目标还好没发生灾难性后果但从那以后我写任何变量引用都默认加引号包括$1、$?这种看着不可能有空格的。处理文件名的标准姿势是这样的# 错误路径带空格就炸 for f in $(find . -name *.mp4); do ffmpeg -i $f ... done # 正确用read按行接收IFS留空 find . -name *.mp4 -print0 | while IFS read -r f; do ffmpeg -i $f ... done-print0让find用\0结尾而不是换行read再按\0切分这样不管文件名里有什么妖魔鬼怪都能被完整传递。3.2 坑二管道返回的退出码可能根本不是你想的那个写脚本判断服务日志里有没有关键字时常见写法是grep ERROR /var/log/app.log | head -n 5 echo $?这段代码返回的$?其实是head的退出码不是grep的。head成功读到数据就返回0哪怕grep一条都没匹配到管道整体看起来还是成功。于是你精心设计的检测到错误就告警逻辑在日志没有ERROR时反而静悄悄等于告警系统形同虚设。两个解法一是set -o pipefail它让管道的最终退出码变成最后一个非零退出码整条管道只要有命令失败就失败二是用${PIPESTATUS[]}单独取每个命令的状态但这只在当前管道执行后立刻读取才准确。set -o pipefail if grep ERROR /var/log/app.log | head -n 5 /dev/null; then echo 找到ERROR记录 else echo 没有ERROR记录或已处理 fi加上pipefail之后grep失败、head即使成功读取整个if判断也走false分支。这个坑特别隐蔽因为它不会报错只是静默地给出错误结论在告警、计数、数据校验场景里危害巨大。3.3 坑三set -e的好心办坏事很多教程都在吹脚本第一行写set -e让脚本遇错即停。但set -e有一个很反直觉的行为它会在普通命令失败时退出却不会在if判断条件里退出也不会在while、until条件里退出。这本身是合理设计却导致一个经典翻车set -e grep PATTERN app.log echo 脚本继续干活如果grep没匹配到返回非0set -e会让脚本直接退出后面的echo永远不执行。你心想没匹配到就继续走匹配到才处理结果变成了没匹配到就直接退出完全相反。解决办法不是不用set -e而是把允许失败的意图写清楚set -e if grep -q PATTERN app.log; then echo 匹配到了 fi echo 无论是否匹配脚本都继续或者用grep -q PATTERN app.log || true|| true把这个失败吞掉。我的习惯是默认开set -e凡是明确失败了也无所谓的命令统一补上|| true或者包一层if不让Shell替我做决定。这个取舍搞明白后set -e就从捣乱鬼变成了真正的保险丝。3.4 完整排查链路一个批量重命名脚本从报错到修复所有坑凑在一起时会是什么样我拿一个真实发生过的小事故给你完整走一遍排查链路。需求很朴素把当前目录下所有包含中文未命名字样的文件统一改名为2025_前缀。第一版脚本长这样#!/bin/bash for f in $(ls); do mv $f 2025_$f done实际目录里有这些文件未命名 文档.txt 未命名-方案.md readme.md执行后报错而且只有部分文件被改名。排查第一步用bash -x rename.sh看执行轨迹。-x会打印每条命令展开后的真实样子这是Shell调试最重要的手段。屏幕上立刻暴露问题 ls mv 未命名 文档.txt 2025_未命名 文档.txt mv: 目标 文档.txt 不是目录两个问题同时出现$(ls)把未命名 文档.txt按空格切成了两个词2025_$f同样被切割导致mv收到了五个参数把它当成把多个源文件移动到不存在的目标目录直接报错。排查第二步确认是IFS在作祟之后把遍历方式从$(ls)改成通配符展开#!/bin/bash for f in *.txt *.md; do mv $f 2025_${f} done然后保留bash -x再跑一次看到 for f in *.txt *.md mv 未命名 文档.txt 2025_未命名 文档.txt单引号出现了说明这次Shell把整个文件名当成了一串引号保护生效。文件也成功改名。但真正的教训还没结束——由于第一次脚本已经部分执行过目录里可能残留了2025_未命名-方案.md这种半成品。修复脚本前我先用ls盘点了所有文件手动把之前误改名、误操作产生的文件调整回原样再重新跑修正版脚本。这种先修复现场再重跑逻辑的顺序比直接闷头改脚本重要得多。从那次以后我处理批量操作的第一原则是先备份再预演后执行。4. 出了LinuxShell还有一堆变体hbase shell、adb shell和藏在固件里的Shell4.1 hbase shellHBase数据库的专属遥控器HBase是一个分布式列族数据库它自带一个交互式Shell名字就叫hbase shell。注意这个Shell不是Bash是一个用JRuby实现的交互环境语法更接近Ruby和Java的内部DSL。你启动它hbase shell然后执行建表、查看region、切分表等操作。最值得讲的是预分区与自动拆分。HBase一张表的数据是按rowkey范围分布在多个region上的如果所有数据都写同一个region那就是典型的热点机器再快也扛不住。手动预分区可以在建表时指定create user_info, cf, {NUMREGIONS 10, SPLITALGO HexStringSplit}NUMREGIONS 10表示预建10个regionSPLITALGO指定拆分算法。这里有个选型细节新手最容易抄错拆分算法适用rowkey类型说明HexStringSplit十六进制字符串最常见适合MD5类散列rowkeyDecimalStringSplit十进制数字字符串适合自增数字类的rowkeyUniformSplit二进制字节通用但分布均匀性一般如果你在建表之后发现某个region还是过大可以手动切分split user_info, rowkey_10000这条命令会把包含rowkey_10000这个边界的region在指定rowkey处切开。我见过不少人在HBase里建了表就万事大吉等压测时某个region写穿磁盘才反应过来预分区的重要性。写数据前按业务量估算好region数量是HBase Shell实操里最值钱的一个习惯。4.2 adb shellAndroid调试桥里的Shell世界adb是Android调试桥adb shell可以让你进入Android设备内置的命令行环境通常是一个叫toybox的轻量工具集语法接近但不完全等于BusyBox。进入之后很多在Linux上跑得飞起的Bash高级语法在这里都不存在写循环、变量展开时要保守一点。热搜里有个词是adb shell vm这里必须提个醒正确命令是adb shell wm不是vm。wm是window manager窗口管理器的缩写它负责控制Android窗口系统vm在IT里一般指虚拟机这里根本没有这个命令。实操中最常用的是用wm命令模拟屏幕分辨率和密度做适配测试# 设置分辨率为1080x1920 adb shell wm size 1080x1920 # 恢复默认分辨率 adb shell wm size reset # 设置DPI为440模拟高密度屏幕 adb shell wm density 440 # 恢复默认DPI adb shell wm density reset热搜里还有adb shell wm设置应用屏幕方向控制方向的关键是旋转设置# 关闭自动旋转 adb shell settings put system accelerometer_rotation 0 # 设置为横屏rotation值0竖屏、1横屏逆时针90度、2竖屏反向、3横屏反向 adb shell settings put system user_rotation 1这套操作在跑UI自动化、做多机型适配测试时是刚需。你可以在命令行直接执行adb shell settings put ...不用先进Shell再敲但理解adb shell后面跟的是目标设备里的命令这一点很重要。它的内部逻辑和Bash脚本完全不同但进入环境、执行命令、拿到输出的交互模型还是我们熟悉的Shell思维。4.3 EFI Shell、vCenter Shell这些藏在角落里的大哥除了数据库和Android很多固件和虚拟化平台也有自己的Shell。EFI Shell就是UEFI固件内置的迷你Shell老X79主板的时代特别流行刷BIOS、加载NVMe驱动、看内存映射表都靠它。你开机按特定键进入UEFI Shell后看到的命令是ls、map、fs0:、edit这种只能访问挂在固件下的设备但它依然是一个命令解释器脚本执行器同样有变量、有循环只是语法精简到了极限。如果你没接触过记住一点在EFI Shell里map是查看文件系统映射的fs0:是切换到第一个文件系统逻辑和Linux的挂载点切换没什么本质区别。虚拟化场景里也经常碰Shell。vCenter/ESXi主机默认不开Shell需要先在服务里启用SSH或ESXi Shell。如果你在操作时遇到starting with root...cant open root shell, try again...still not :(这种报错大概率是TSM-SSH相关服务没启动或者当前用户没有shell访问权限。命令行里能用的工具主要是esxcli和vsish前者负责配置管理后者负责深度的内核态信息查看。它们的语法和Bash完全不同但你能明显感觉到是同一套交互式命令解释的骨架。5. 从能跑到不容易跑坏脚本习惯与调试三板斧5.1 我的工作流先在命令行试通再变脚本很多人写脚本是打开编辑器直接写一堆命令写完一遍跑报错再改来回折腾。我的习惯刚好相反所有逻辑都先在命令行里手工敲一遍确认输出符合预期再搬到脚本里。具体流程是这样的在终端手敲命令注意观察每一步输出和报错。用history或CtrlR找回刚才成功的命令把它复制到脚本文件。把硬编码的路径、IP、文件名抽象成变量。在脚本开头加上set -euo pipefail和错误分支。用bash -n做语法检查跑一遍shellcheck最后放一个带空格的测试文件实际跑一次。这套流程最大的价值在于它把命令是否可行和脚本逻辑是否正确两个问题拆开了。如果你在脚本里试命令错误一来你就分不清是命令本身有问题还是脚本结构有问题在命令行试通后再搬脚本阶段专注处理变量、循环、退出码排错范围一下子缩小一半。5.2 给脚本加保险丝set -euo pipefail到底做了什么很多脚本模板第一行是#!/bin/bash第二行是set -euo pipefail这三个开关各管一摊开关作用典型场景-e有命令返回非0就退出防止中间失败后继续执行造成连锁错误-u使用未定义变量时报错防止变量名拼错后静默变成空串-o pipefail管道中任一段失败都算整体失败防止grep被head吞掉退出码-u这个开关很多人忽略但它特别有用。比如你本想用$directoy写成了$directoryShell在默认情况下会把未定义变量当成空字符串脚本继续跑直到某条命令因为目录不存在而失败你还要反向跟踪到底是哪里拼错了。开了-u它当场报错直接指出省下大量排查时间。我推荐的标准模板是#!/bin/bash set -Eeuo pipefail trap echo 出错位置: $LINENO, 命令: $BASH_COMMAND ERRtrap ... ERR是另一个保险丝脚本一旦出错会打印出错行号和执行到一半的命令。在复杂脚本里这行代码能让你从对着报错反复猜直接跳到打开对应行看看。不过要记住set -e不是万能的它管不了命令确实失败了但恰好被if接住了这种场景所以你要做的不是依赖它而是理解它在允许失败的命令后面显式补充|| true或if。5.3 shellcheck与bash -n写脚本也要像写代码一样做体检bash -n yourscript.sh可以快速做语法检查只检查语法不执行任何命令写在管道前的变量有没有闭合引号、括号有没有配对都能查出来。但它不检查逻辑也不检查你引号用得对不对所以真正有重大价值的是shellcheck。shellcheck是一个静态分析工具Debian系直接apt install shellcheckmacOS用brew install shellcheck。安装后执行shellcheck yourscript.sh它会以警告的形式告诉你脚本哪里有问题。最常见的警告是SC2086In rename.sh line 5: mv $f 2025_$f ^-- SC2086: Double quote to prevent globbing and word splitting.这条警告就是我在3.1里讲的引号问题。shellcheck还有好几百条规则但新手期只要盯住SC2086变量加引号、SC2045不要解析ls、SC2164cd失败后要检查这三条脚本质量就已经超过大部分网上的示例了。我现在的流程是写完脚本先shellcheck它报零警告了才敢进测试。这个习惯帮我拦下了无数个看起来能跑、一跑就炸的雷。5.4 调试三板斧echo、set -x和PS4工具再全真正的调试还是靠这三板斧。第一斧是echo。想确认某段逻辑有没有执行在关键位置加一行echo debug: 当前文件名$f跑完看输出。这个打法虽然土但效率极高尤其适合逻辑分支多的脚本。第二斧是set -x它会打印每一条命令展开后的真实样子变量值、引号、路径全部展示出来。你可以在脚本顶部加也可以用bash -x script.sh临时启用不用改文件。3.4节里我就是靠它定位到mv命令参数被拆成五段的。set -x输出有时候很吵但调试时那条最刺眼的输出往往就是问题本身。第三斧是PS4它配合set -x使用给输出的每一行加前缀。我常用的配置是export PS4${LINENO}: 这样-x打印的每一行都会带上行号你能直接定位到脚本的哪一行执行出了问题。如果再配合5.2节的trap ERR一个脚本出错时行号、命令、上下文全部齐活基本不用猜。我个人不太推荐在脚本里用read -p中断式调试就是那种脚本跑一半停下来等你按键的玩法。它看起来直观但调试完很容易忘记删脚本一旦交给cron定时任务跑卡在那里等输入直接挂到天荒地老。真要调试复杂数据流优先考虑把中间结果写临时文件或者直接用bash -x看轨迹而不是在脚本里埋交互点。4.2节提到过Python的交互式解释器也自带一个playground那个技术上也叫Python Shell。你可以在里面输入import webbrowser、webbrowser.open(https://example.com)来拉起默认浏览器本质上就是用Python Shell作为入口去调用外部程序这和我们在Bash里输入mpv video.mp4调用播放器一模一样——Shell不负责干活它只负责按你的指令去请人干活。想明白这个模型以后你碰到任何新的Shell都敢上手。