
很多人第一次打开终端黑底白字光标一闪一闪随手敲了个命令回车屏幕上哗啦啦滚出一堆英文然后本能地关掉窗口心里默念这玩意儿不适合我。我特别理解这种感觉——命令行给人的第一印象确实不太友好像一扇没有把手的门。但只要跨过最初那半小时的心理门槛你会发现 shell 是少数几个投入产出比高得离谱的技能它不挑行业做开发、做运维、做数据、做测试、甚至只是日常整理文件都能立刻用上它也不需要你学一整门语言掌握十几个命令加几套固定套路就能把大量重复劳动压缩成一行命令。这篇内容我打算把 shell 基础从头到尾捋一遍包括它到底是什么、变量和引号为什么这么反直觉、重定向和管道怎么理解、脚本怎么写才不至于把自己坑死。写给谁看完全没碰过命令行的小白能顺着往下读用过几年但一直靠搜索拼命令的老手也能在中间几节找到一些原来这里一直理解错了的细节。1. 先搞清楚shell 到底是个什么角色1.1 用翻译官这个类比建立第一层认知我习惯把 shell 理解成一个翻译官。你在终端窗口里敲的那串东西本质上是一种给人看的、语法松散的自然语言式指令而操作系统内核只认系统调用这种机器语言。中间必须有个角色负责把你说的话翻译成内核能执行的动作这个角色就是 shell。终端窗口只是电话机——它提供你和翻译官对话的通道本身不做任何翻译工作bash、zsh、dash 这些才是具体的翻译官性格不同、方言略有差异但基本词汇是互通的。这层区分很关键因为它直接解释了很多新手困惑。为什么教程里写ls你敲出来也正常因为ls是一个独立的小程序放在 PATH 目录里任何 shell 都能调用它。为什么[[ ]]在某个环境里报错因为那是 bash 特有的语法换了个翻译官就听不懂了。为什么同一个脚本在你的机器上跑得欢换台机器就崩大概率是翻译官的版本或设置不一样。所以学 shell 的第一课不是背命令而是分清三个层次终端负责显示和输入shell负责解析和执行外部命令是 shell 调用的一个个独立工具。绝大多数命令其实既不是终端的功能也不是 shell 内置的语法而是一个个躺在磁盘上的可执行文件。你敲type ls就能看到它到底属于哪一类——type这个内置命令是排查问题的第一把钥匙后面我还会反复用到它。1.2 学它能换来什么三个真实场景我不想吹shell 是程序员的必修课这种空话直接说三个我自己反复受益的场景。第一个是批量操作的降维打击。文件夹里有两千个后缀混乱的图片要按修改日期重命名并归到不同子目录。图形界面里这是一下午的活命令行里就是一行for循环加一个mv。这个能力没有替代品因为你没法用鼠标表达逻辑。第二个是环境的一致性。一个新环境拿到手装依赖、改配置、拉代码、跑初始化十几次点击和复制粘贴每次都可能漏一步。写成脚本之后别人跑和你跑的流程完全一致出了问题也有据可查。这也是为什么 CI 流水线里到处是 shell 片段——它是最没有依赖的胶水。第三个是排障的底层视角。服务起不来日志只说失败图形化工具往往只能告诉你结果命令行能告诉你过程。进程在不在、端口通不通、文件权限对不对、环境变量有没有传进去四条命令查完90% 的问题就定位了。适合谁做服务端开发的、做测试的、做数据的、管机器的这是刚需做前端、做客户端的写构建脚本、批量处理素材也用得上不写代码的人学个文件整理和文本搜索也值回票价。反过来讲如果你的工作完全不涉及重复操作和远程环境那不学也没损失别被焦虑推着走。1.3 学习路线的取舍别一上来就啃语法手册我见过太多人卡在这一步找一份几百页的 shell 教程从什么是操作系统开始读读到第三章遇到正则表达式就放弃了。shell 是一门实用优先的技能正确路径是用起来——遇到问题——补基础不是学完再做。我的建议是分三段走。第一段只学十个命令cd、ls、cat、less、cp、mv、rm、grep、find、chmod配合 Tab 补全和历史记录这一阶段的目标是能自己操作文件。第二段学管道、重定向和变量目标是能把两三个命令串起来解决问题。第三段才碰流程控制和函数目标是能写出一个自己愿意重复用的脚本。每一段大约几个小时就能走过真正花时间的是后面在真实任务里的反复使用。顺带提一个心态问题不要试图记住所有参数。我自己写 shell 写了这么多年tar的解压参数每次还得查。真正需要记住的是思路——这件事应该用什么工具组合完成参数交给man和--help。把记忆负担放在思维模型上才是可持续的。2. 环境准备十分钟把地基打对2.1 术语对照表先把概念对齐在动手之前用一张表把常见名词的位置关系钉死后面看文档就不会晕。名词它是什么类比你需要注意的点终端 / Terminal输入输出的窗口程序电话机关闭窗口前台进程可能被终止shell解析命令的程序翻译官bash、zsh、dash 语法有差异bash最常见的一种 shell某位具体的翻译官不同版本特性不同老版本没有关联数组命令 / Command内置函数或磁盘上的可执行文件词典里的词条靠type区分是内置还是外部脚本 / Script存成文件的命令序列写好的台词稿需要执行权限和正确的首行声明环境变量传给程序的键值配置翻译官的工作备忘子进程继承父进程不受影响表格里最后一行那个子进程继承是很多人踩坑的根源。你在脚本里export一个变量脚本内部的所有子命令都能看到但脚本执行完退出你当前的 shell 里什么也没变。这就是为什么我在脚本里改了 PATH怎么出来还是老样子——因为它改的是自己那份副本。2.2 配置文件到底谁在什么时候加载这是 shell 基础里最容易被含糊带过、又最容易出问题的一块。你往~/.bashrc里写了一堆别名结果通过某些方式执行脚本时全部失效就是没搞清加载时机。bash 启动时按是不是登录 shell是不是交互式两个维度分四种情况加载的配置文件不同交互式登录 shell比如刚登入系统、或者用登录方式切换用户依次读/etc/profile然后从~/.bash_profile、~/.bash_login、~/.profile中读第一个存在的读完就停。交互式非登录 shell在图形界面里新开一个终端窗口通常属于这类读/etc/bash.bashrc部分发行版才有这个文件再读~/.bashrc。非交互式 shell执行脚本时一般什么都不读只会去看环境变量BASH_ENV指向的文件没设置就跳过。sh 兼容模式读$ENV指向的文件。这套规则带来的实际后果是如果你只在~/.bash_profile里写了别名和函数那么在图形界面新打开的终端里它们是不生效的。业界通行的做法是在~/.bash_profile里加一句[ -f ~/.bashrc ] . ~/.bashrc把两套入口合并到~/.bashrc一个文件里维护。这个技巧几乎所有配置为什么偶尔不生效的问题都能解决。注意脚本里不要依赖~/.bashrc里定义的东西。脚本执行时是非交互式 shell不读这些文件别名更是默认不展开。别名只服务于你在终端里手敲的场景写进脚本就是定时炸弹。2.3 交互式操作的五组快捷键效率翻倍环境弄好之后先别急着写脚本把交互操作练熟日常收益最大。第一组是Tab 补全。按一下补全按两下列出所有候选。路径、命令名、甚至某些命令的参数都能补这是减少打字错误最有效的手段。第二组是历史检索。history看全部!$取上一条命令的最后一个参数!!重跑上一条CtrlR反向增量搜索。CtrlR这个我每天用几十次敲几个字母就能翻出几个月前用过的那条长命令。第三组是行内编辑。CtrlA跳行首CtrlE跳行尾CtrlU删到行首CtrlK删到行尾CtrlW删一个词。改长命令中间某个参数时比按几十下方向键舒服太多。第四组是进程控制。CtrlC发中断信号CtrlZ挂起到后台jobs看后台任务fg %1拿回前台bg让它在后台继续跑。第五组是清屏与纠错。CtrlL清屏比clear快CtrlD发 EOF 退出当前 shell。这两个动作每天几百次值得形成肌肉记忆。3. 变量与引号shell 最反直觉的一块3.1 三种引号的区别值得单独背下来如果说 shell 有一个最该搞懂的知识点那就是引号。搞不清引号你会遇到一堆看起来完全一样但结果不同的诡异现象。先看定义。不加引号的字符串会经历分词按空格切开、通配符展开*、?被文件名替换、变量替换。双引号保留整体为一个参数只做变量替换和命令替换不做分词和通配符展开。单引号里的一切都是字面量连$都不解释。具体差别看这段namehello world printf [%s]\n $name # 输出两行[hello] 和 [world] printf [%s]\n $name # 输出一行[hello world] printf [%s]\n $name # 输出一行[$name]第一种写法里变量展开后产生的空格被当作参数分隔符一个变量变成了两个参数。这就是为什么脚本里有一条铁律变量引用永远加双引号。我见过的脚本 bug 里因为漏写双引号导致路径含空格时崩掉的占了相当大的比例。文件名里带空格在桌面环境里太常见了我的文档这种目录名就是最好的例子。提示需要输出字面量$时用单引号最省事单引号内部没法再嵌套单引号遇到这种情况用\这种拼接写法或者改用双引号加反斜杠转义。3.2 参数展开让变量有默认值光会$var是不够的。真实脚本里变量可能为空、可能未定义你需要给它兜底。bash 提供了一整套参数展开语法我把最常用的四个整理成表写法含义典型用法${var:-默认}var 为空或未定义时用默认值var 本身不变取环境变量的兜底值${var:默认}同上但会把默认值赋值给 var需要后续复用该值时${var:?错误信息}var 为空时打印错误并退出必填参数校验${var:替代值}var 非空时才用替代值条件拼接我最常用的是第二个和第三个。写脚本时开头放几行参数校验比后面出错再排查舒服得多: ${OUTPUT_DIR:./output} : ${THREADS:?必须通过环境变量 THREADS 指定并发数}第一行如果没传就用默认目录第二行如果没传直接报错退出错误信息清楚使用者一看就知道缺什么。这比后面在某个深层逻辑里报一个莫名其妙的文件不存在友好一百倍。3.3 命令替换、算术与数组命令替换是把一个命令的输出当作字符串使用两种写法老式的反引号cmd和现代的$(cmd)。我强烈建议只用后者因为前者不能嵌套而且和单引号容易混淆。today$(date %F)这种写法你应该很熟悉注意赋值号两边不能有空格这是 shell 的硬规定a 1会被理解成执行名为 a 的命令参数是 和 1。算术运算有两种。$(( ))用于求值(( ))用于条件判断。要特别注意 shell 默认只做整数运算$((1/3))结果是 0 不是 0.33。需要小数就得请出awk或者bcecho scale2; 10/3 | bc awk BEGIN{printf %.2f\n, 10/3}数组分索引数组和关联数组。索引数组arr(a b c)取全部元素用${arr[]}——这里的双引号和[]组合是标准写法能保证每个元素独立成参写成${arr[*]}会被拼成一个字符串。关联数组需要先declare -A map声明然后像字典一样用map[key]value。关联数组在老版本 bash 里不支持这就是前面提到的版本差异会咬人的地方脚本里用之前最好确认一下目标环境的版本。4. 流程控制让脚本开始有逻辑4.1 if 的三种判断写法别混着用shell 里做条件判断有四种括号新手最容易在这里迷失。我按推荐程度排个序。[[ ]]是 bash 的增强版条件表达式首选。它内部不做分词和通配符展开所以变量不加引号通常也安全支持、||、~正则匹配写法自然。缺点是 POSIX 标准里没有只有 bash、zsh、ksh 这类支持。[ ]是 POSIX 标准的 test 命令需要兼容 sh 时用。它本质上是一个外部命令虽然多数 shell 内置了所以对空格极其敏感——[ $a $b ]里每个空格都不能少$a必须加引号否则变量为空时直接报语法错误。(( ))专用于整数比较和算术写if (( a b ))比[ $a -gt $b ]直观得多而且变量名前面不用加$。test是[ ]的另一种写法功能完全一样没什么理由主动用它。字符串和数字的比较符号也不一样这是新手高频错误字符串用、!、-z空、-n非空数字用-eq、-ne、-gt、-lt、-ge、-le。写成[ $a $b ]在 bash 里能用但在 sh 里不行写成[ $a -eq $b ]而 a 是字符串就会报错。我的习惯是数字比较一律用(( ))字符串比较一律用[[ ]]两套泾渭分明不容易错。文件测试也是高频项-f普通文件、-d目录、-e存在、-r可读、-w可写、-x可执行、-s非空。这几个配合if能做大量的前置校验。4.2 三种循环的适用场景for是遍历型循环有两种写法。C 语言风格for ((i0; i10; i))适合计数for item in ${list[]}适合遍历列表或数组。要注意默认的for f in *在文件名含空格时会出问题虽然 shell 通配符展开本身是安全的每个文件名独立成参但如果你在循环体里又做了$(ls)这种操作就会重新引入分词风险——永远别用ls的输出做循环这是 shell 脚本的经典反模式。while适合条件满足就一直做最典型的用法是逐行读文件while IFS read -r line; do printf 处理: %s\n $line done input.txt这段代码有三个细节值得说。IFS清空内部字段分隔符防止行首尾空格被吃掉-r禁止反斜杠转义防止路径里的\被吞重定向写在done后面而不是| while是为了避免子 shell 问题——后面第 5 节会专门讲这个坑。case适合多分支匹配比一长串if-elif清爽得多还支持通配符case $1 in start|run) do_start ;; stop) do_stop ;; *.log) echo 日志文件参数 ;; *) echo 未知参数: $1 2; exit 2 ;; esac注意最后那个*)分支一定要写并且一定要exit非零码否则参数传错时脚本会默默成功这是运维脚本里最危险的一类问题。4.3 函数、局部变量与返回值的真相shell 函数语法简单name() { ... }。但有两个和主流语言完全不同的地方必须提前说清。第一函数没有参数列表声明。调用myfunc a b c之后函数内部用$1、$2、$3取参数用$#取个数用$取全部。这带来一个隐患函数内会覆盖外层的$1、$2所以脚本主流程如果依赖位置参数调用函数后要小心。第二return返回的不是值是退出状态码只能 0 到 255。想要真正返回值得用echo输出然后命令替换接住或者用全局变量。这是个设计上的历史包袱习惯就好get_size() { local f$1 [[ -f $f ]] || { echo 0; return 1; } wc -c $f | tr -d } size$(get_size ./data.csv)local关键字一定要加用来声明函数内局部变量。不加的话所有变量默认全局函数之间互相污染脚本一长就没法维护了。我见过一个脚本里三个函数都用了i做循环变量结果互相踩排查了半小时——加上local一行搞定。另外注意return之后的数字0 表示成功非 0 表示失败这个约定和 C 语言相反也是 shell 脚本里最容易写反的地方。if command; then判断的正是退出码为 0。5. 重定向与管道shell 的灵魂所在5.1 文件描述符与重定向完整地图Linux 下每个进程默认开三个文件描述符0 标准输入、1 标准输出、2 标准错误。重定向就是把这几个水管接到别的地方去。写法效果cmd file标准输出覆盖写入文件cmd file标准输出追加cmd 2 file标准错误覆盖写入cmd file标准输出和错误都写入bash 写法cmd file 21同上POSIX 写法cmd file从文件读标准输入cmd 21 file顺序陷阱错误仍打到屏幕最后一行是经典面试题也是真实会踩的坑。重定向是从左到右依次生效的21先把错误指向当前的标准输出此时还是屏幕然后 file才把标准输出改到文件。所以错误依然在屏幕上。要合并到文件里必须 file 21先定好输出目标再让错误跟着走。理解这个顺序比背结论重要。还有exec的用法值得单独提一句。exec $logfile 21会把当前 shell 自身的输出重定向掉之后脚本里所有命令的输出都进日志不用每条都写重定向。这个技巧在写整个脚本输出都落盘的场景里非常好用。Here Document用来在脚本里嵌入多行文本cmd EOF ... EOF。默认会做变量替换写成EOF加引号就关闭替换适合嵌入代码模板。Here String是把字符串当标准输入grep foo $text比echo $text | grep foo少一个进程。这两个语法用熟了脚本可读性会上一个台阶。5.2 管道、xargs 与那个著名的子 shell 坑管道|把前一个命令的标准输出接到后一个命令的标准输入。它的价值在于组合——每个工具只做一件事串起来就能完成复杂任务。这也是 shell 哲学的核心不追求一个大而全的工具追求一堆能拼装的小工具。但管道有个必须知道的副作用管道中的每一段都在子 shell 里执行。子 shell 是当前 shell 的副本里面的变量修改不会传回父 shell。看这段count0 cat data.txt | while read -r line; do ((count)) done echo $count # 输出 0不是行数这段代码的结果永远是 0因为while在子 shell 里跑count改的是副本。修复方式有三种把重定向写到done后面done data.txt、用进程替换done (cat data.txt)、或者改用mapfile/awk。我首选第一种最清晰。xargs是把标准输入转成命令行参数的工具配合find用find . -name *.tmp -print0 | xargs -0 rm -f-print0和-0这对组合是为了处理含空格和换行的文件名用\0而不是换行做分隔符。不加的话遇到我的 文件.tmp就会被拆成两个参数删错东西。这种细节不注意某天就会在真实数据上出事。5.3 grep / sed / awk 的最小可用集这三个工具合称文本处理三剑客其实普通人 20% 的用法就能覆盖 80% 的需求不必一上来啃完整手册。grep负责找。-i忽略大小写-v反向匹配-n显示行号-r递归目录-c只计数-E用扩展正则-o只输出匹配部分。grep -rn 关键词 .大概是我职业生涯敲得最多的一条命令。sed负责改。核心就一个替换语法s/旧/新/标志标志g表示全局不加只替换每行第一个。sed -i s/foo/bar/g file.txt原地修改注意加上备份后缀更安全sed -i.bak。-n加p可以只打印匹配行-e可以串联多个表达式。sed 的坑在于正则方言和替换内容里的特殊字符路径里的/会冲突通常改用|或#当分隔符sed s|/old/path|/new/path|g。awk负责按列处理。它的模型是逐行读入按分隔符切字段$1、$2是第几列$0是整行NR是行号NF是字段数-F指定分隔符。awk -F, NR1 $3 100 {sum$3; n} END{printf 平均: %.2f\n, sum/n} data.csv这一行就完成了跳过表头、筛选第三列大于 100 的行、求平均。同样的逻辑用其他语言写要七八行。awk 的价值在于它天生就是为表格型文本设计的日志分析、CSV 统计这类活儿用它最省事。除了三剑客还有几个小工具值得知道cut按字符或分隔符取列、sort排序-n数字序、-k指定列、-u去重、uniq -c统计相邻重复、tr字符替换、wc -l计数、tee分流到文件和屏幕。这些组合起来的表达力非常强比如统计访问量 Top 10awk {print $1} access.log | sort | uniq -c | sort -rn | head -10一行命令五个工具每个都只做一件事——这就是 shell 组合思想的完整体现。6. 写出能上生产的脚本健壮性比语法重要6.1 set -euo pipefail 到底做了什么我见过太多能跑就行的脚本出问题的时候一片静默查半天才发现是某个中间命令失败了但脚本继续往下跑。脚本的专业度很大程度上体现在出错时是否及时停下并说清楚。set -euo pipefail这行应该出现在每个正式脚本的第二行第一行是 shebang。拆开看-e任何命令返回非零就退出。听起来很好但有陷阱——它在if、while、、||语境里不生效因为那些地方本来就允许失败。另外((i))当 i 为 0 时返回码是 1会误触发退出需要写成((i)) || true或者前置i0后用i$((i1))。-u使用未定义变量时报错退出。这个太有用了能防住大量拼写错误。要用可能为空的变量时改成${var:-}显式声明默认空值。-o pipefail管道的退出码取最右边非零的那个。默认情况下管道只看最后一个命令curl ... | head里前面的网络错误会被head的成功掩盖。加上这个开关中间任何一段失败都能被发现。我个人的经验是-e和-o pipefail用来兜住意外失败-u用来兜住手滑拼错。三个一起上脚本的可靠性立刻不一样。如果某个命令允许失败就显式写成cmd || true或放进if里让意图明确。6.2 trap临时文件与信号处理脚本里创建临时文件、开锁、改配置最怕中途被打断留下垃圾。trap就是用来注册退出时一定要做的事#!/usr/bin/env bash set -euo pipefail tmp$(mktemp) lockfile/tmp/myscript.lock cleanup() { rm -f $tmp $lockfile } trap cleanup EXIT INT TERM exec 9$lockfile flock -n 9 || { echo 已有实例在运行 2; exit 1; }这段做了三件事用mktemp生成唯一临时文件名绝不要用固定的/tmp/tmp.txt多个实例会互相覆盖、用trap ... EXIT保证无论正常退出还是异常退出都清理、用flock加文件锁防止同一脚本被并发跑两遍。trap能捕获的信号里EXIT最常用shell 退出时触发包括 CtrlC 后退出INT对应 CtrlCTERM对应kill。捕获到之后可以选择清理再退出也可以忽略某些信号。定时任务里尤其要注意加锁——两个实例同时跑同一份数据处理结果往往是数据错乱比不跑还糟。6.3 参数解析别让脚本只能靠记忆使用用命令行工具最烦的是参数全靠记。给自己的脚本加一个usage是专业度的直接体现。参数少的时候手写while就够了usage() { cat EOF 用法: backup.sh -s 源目录 -d 目标目录 [-k 保留份数] -s 需要备份的源目录 -d 备份文件存放目录 -k 保留最近几份默认 5 EOF exit ${1:-0} } while getopts :s:d:k:h opt; do case $opt in s) src$OPTARG ;; d) dst$OPTARG ;; k) keep$OPTARG ;; h) usage 0 ;; :) echo 选项 -$OPTARG 需要参数值 2; usage 2 ;; \?) echo 未知选项 -$OPTARG 2; usage 2 ;; esac done [[ -n ${src:-} -n ${dst:-} ]] || usage 2getopts的几个细节选项字符串开头加冒号表示自己处理错误这样:)和\?)分支才能拿到自定义错误信息OPTARG是当前选项的值OPTIND记录解析位置需要重置时手动设回 1。这套写法不算优雅但足够稳比一堆手工if [ $1 -s ]可靠得多。6.4 调试三板斧bash -x、PS4、shellcheck脚本不按预期跑的时候别靠猜。第一招bash -x script.sh它会打印每条命令执行前的展开结果一眼就能看到变量到底展开成了什么。输出太杂可以配合PS4加上行号和函数名PS4[${LINENO}:${FUNCNAME[0]:-main}] 这样每行前面都带行号定位很快。第二招局部调试在脚本里插set -x和set x包住可疑的那几行比全量输出清爽。第三招静态检查有个社区工具叫 shellcheck能提前发现大量引用未加引号、变量未定义、不可移植语法之类的问题。我每次写完稍长的脚本都会过一遍它指出的问题里大概有一半是我确实会犯的。再补一个习惯写脚本时用#!/usr/bin/env bash而不是#!/bin/bash。前者会走 PATH 找 bash可移植性更好后者在 bash 装在非标准路径的环境里会直接失败。7. 常见问题与排查技巧实录7.1 高频报错速查表下面这张表是我这几年被问最多、自己也踩过的问题按出现频率排序。现象根本原因解决方式command not found命令不在 PATH或拼写错误type、which确认检查 PATH 拼接Permission denied文件没有执行权限chmod x script.shbad interpreter: No such fileshebang 写错或文件是 Windows 换行改首行dos2unix转换换行符syntax error near unexpected token引号、括号不配对或中文字符混入检查配对确认没有全角符号变量赋值后循环里取不到管道导致子 shell把重定向移到done后文件名含空格时出错变量引用缺双引号全面加$var用-print0unbound variable开了-u但变量可能为空改用${var:-}脚本在终端能跑定时任务不行环境不同PATH 不完整用绝对路径脚本内显式设置 PATHargument list too long通配符展开参数过多改用find ... -exec或xargs倒数第二条值得展开说。定时任务执行时的环境极其干净PATH 可能只有/usr/bin:/bin你在交互式 shell 里能直接用的命令在定时任务里就找不到。我的习惯是脚本开头显式写一行export PATH/usr/local/bin:/usr/bin:/bin:$PATH省去大量为什么手动跑可以、定时跑不行的排查时间。7.2 几个只有踩过才知道的细节关于删除。写rm -rf的脚本变量拼错一次就可能是不可逆的事故。我现在的固定写法是先做非空校验[[ -n $dir $dir ! / ]] || exit 1再从rm -rf $dir换成一个更克制的操作。这类防御性编程在 shell 里不是过度设计而是必需品因为 shell 给你的保护本来就少。关于通配符与隐藏文件。*默认不匹配以点开头的文件需要.*但.*又会匹配到.和..这两个特殊目录复制删除时可能出事。安全写法是shopt -s dotglob之后仍用*或者干脆用find精确控制。关于cd失败。cd /some/path rm -rf *这种写法里如果cd失败会阻止后面的命令这是安全习惯。但更稳妥的是在脚本开头用cd $(dirname $0)切到脚本所在目录让所有相对路径都有确定基准避免从哪里调用脚本影响结果。关于 locale 与排序。sort的排序结果受LC_ALL影响数字和大小写顺序可能和你想的不一样。做数据处理时在脚本里临时设LC_ALLC能得到稳定可预期的字节序结果这在跨环境比对时特别重要。关于输出流向。正常信息打到标准输出错误信息打到标准错误这个约定要守住。echo 处理完成是输出echo 文件不存在 2是错误。守住这个边界脚本才能被别的脚本安全地调用和组合——你把错误混进标准输出下游的管道就会收到脏数据。关于可读性。shell 是少数写完三天自己都看不懂的语言。我的经验是超过 50 行的脚本就加函数拆分每个函数做一件事变量名写全拼不缩写关键逻辑加一行注释说明为什么这么做而不是这行做了什么。这些看似拖慢速度的习惯在三个月后回来改脚本时会加倍回报给你。我自己在实际操作中的体会是shell 这东西的瓶颈从来不在语法而在对系统的理解。同样一个需求有人写五行的管道有人写五十行的循环差别不在谁背的命令多而在谁更清楚每个工具到底在做什么、数据在每一步变成了什么形状。所以别急着收集一百个实用命令找三五个自己每天都要做的重复劳动用脚本把它们干掉遇到坑就查、就试、就记录三四轮下来基础自然就扎实了。