ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Shell脚本编程入门:从命令到自动化实操指南

Shell脚本编程入门:从命令到自动化实操指南 1. Shell到底是什么从“敲命令”到“写程序”的认知转变我最早接触Linux的时候和大多数新手一样每天的工作就是打开终端一条一条敲命令cd进目录、ls看文件、cp复制、rm删除。当时觉得这玩意儿就是个“高级点的命令行”完全没意识到这背后藏着一门完整的编程语言。直到有一天我需要给服务器上两百多个目录做同样的事情——逐个进入、找日志、压缩、按日期归档。那天我敲了将近一个小时重复命令累得眼冒金星突然反应过来我为什么不能让机器替我做这件事这就是Shell编程的核心价值。Linux里面的Shell首先是命令解释器——你敲一条命令它负责调用系统程序并显示结果同时它又是一门完整的编程语言支持变量、条件判断、循环、函数甚至能用正则表达式做文本处理。我后来带过不少新人发现大家普遍会犯同一个认知错误今天学一个grep参数明天背一个find选项却从来没有把这些命令“串起来”的能力。真正的Shell编程不是背更多命令而是学会把已经掌握的命令当作积木用编程语法把它们组装成自动化工具。这篇文章是写给真正的Linux新手看的。不管你是刚装了虚拟机准备入坑还是工作中被分配了服务器运维的活只要你能在终端里敲出ls和cd这篇文章就能带你跨出Shell脚本的第一步。我会用大量可以直接复制的例子配合真实的执行结果让你知道每段代码“为什么要这么写”而不是死记语法。学到后面你会发现写Shell脚本本质上就是把你手动操作电脑的过程原封不动地用语言描述给机器听。2. 第一个脚本的三大拦路虎文件、首行和权限很多新手学Shell一开始就卡在“写出来的脚本根本跑不起来”。我观察下来问题通常不在语法而在于三个最基础但最容易忽略的点。咱们一个个过。2.1 脚本文件怎么建、叫什么名第一个问题居然有人问过我“Shell脚本是不是必须在某个特殊目录里写”。完全不是。你就用终端里最顺手的编辑器创建文件就行。我习惯用vim你如果更适应nano也可以甚至用图形界面的文本编辑器也没问题。文件名有个不成文的约定最好以.sh结尾比如backup.sh、test.sh。虽然Shell执行脚本并不要求后缀名但加上去好处很实在——一眼能分清这是脚本文件还是普通文本用编辑器打开时也会自动启用shell语法高亮。这里我要提醒一个新手特别容易踩的坑脚本文件编码和换行符。如果你在Windows上用记事本、Notepad这类的工具写过脚本再传到Linux上运行十有八九会报一个诡异的错误比如$\r: command not found。这是因为Windows文本的换行是\r\n而Linux只需要\n多余的\r被当成了一条命令。解决办法就是——老实写脚本别在Windows上折腾或者用sed -i s/\r$// yourscript.sh清理一下。2.2 第一行到底写什么脚本文件创建好之后第一件事是选中第一行写下这串字符#!/bin/bash这行东西专业术语叫shebang。#!后面跟着的解释器路径告诉系统“当你执行这个脚本的时候请用/bin/bash这个程序来解读后续的内容”。这不是Shell脚本自己规定的而是Linux内核在执行“可执行脚本文件”时读取前几个字节后决定的。这行必须出现在第一行吗必须而且必须顶格前面不能有空格。如果哪天你把#!/bin/bash写成了#! /bin/bash中间多了一个空格部分系统也能兼容但我不推荐养成这个习惯。还有得确认一下你的系统用的到底是bash还是别的Shell。绝大多数Linux发行版默认都是bash位置就在/bin/bash。如果你拿不准在终端里敲一句which bash它会返回bash的实际路径看到什么就填什么。2.3 三种执行方式坑都藏在细节里这是新手最容易迷糊的地方。脚本写好了怎么让它运行常见的有三种方式区别非常大我整理成一张表执行方式写法是否依赖当前用户执行权限是否开启新Shell进程典型使用场景解释器直接调用bash test.sh不依赖只要有读权限是刚写完快速测试赋予执行权限后运行chmod x test.sh然后./test.sh依赖但只要求有执行权限是常规交付的正式脚本当前Shell内执行source test.sh或. test.sh不依赖否就在当前Shell里运行希望脚本修改当前环境变量很多人第一次执行./test.sh会看到Permission denied然后整个人就懵了。别慌这不是语法错误只是文件没有执行权限。执行一下chmod x test.sh ./test.sh就通了。这里有一个容易混淆的概念chmod x给的是“执行权限”但你仍然需要文件对当前用户有“读权限”因为脚本的本质是文本文件系统要先读进来再解释执行。再说source和bash的区别。写bash test.sh的时候系统会开一个全新的bash进程去跑脚本脚本里cd到任何目录、设置任何变量都是在新进程里做的跑完就结束不会影响你当前的终端。而source test.sh是在当前终端进程里直接执行脚本里设置的环境变量、切换的工作目录执行完全都保留下来。最典型的例子是修改/etc/profile或~/.bashrc之后用source ~/.bashrc让配置立刻生效而不是重新登录。理解了这一点后面学函数和变量导入时就不会犯糊涂。2.4 让脚本说点什么echo的引号细节脚本能跑通了第一件事当然是让它说话。echo命令是最直接的输出工具#!/bin/bash echo Hello, Shell!保存为hello.sh执行bash hello.sh屏幕上会打出Hello, Shell!。这里有个新手经常翻车的细节单引号和双引号行为不同。echo hello $USER会输出hello root如果你当前用户是root因为双引号会解析变量而echo hello $USER会原样输出hello $USER因为单引号包住的内容会完全字面化。记住一个口诀双引号解释变量单引号全部字面。后面变量那一节还会跟它打交道。3. 变量与输入脚本里的“小仓库”和“接话茬”命令敲得再溜也只是把命令行当计算器用。脚本真正开始有“程序”的样子是从引入变量开始的。变量这名字听着抽象其实就是给数据贴个标签后面想用的时候叫它名字就行。3.1 变量定义等号两边不要乱来Shell变量定义非常简单#!/bin/bash nameLinux echo Hello, $name执行结果是Hello, Linux。就这么简单。但注意我写的是nameLinux等号两边绝对不能有空格。写成name Linux的话Shell会把name当成一条命令后面的是它的参数直接报command not found。我见过太多新手在这里翻车还以为自己不会写变量。变量名也有一些讲究。只能用字母、数字、下划线并且不能以数字开头。比如name1合法1name不合法first_name合法first-name不行——中间那个横杠会被解释成减法运算。取值的时候用$name或者${name}两种方式都行。我强烈建议在涉及字符串拼接的场合用后者原因看个例子#!/bin/bash nameLinux echo Hello, ${name}er这样你会得到Hello, Linuxer。如果不加大括号$nameer会被当成一个名字叫nameer的变量取出来自然是空的。这就是为什么业内写Shell有个共识能带花括号就带花括号留给编译器一个明确的边界。变量还能随时重新赋值用readonly nameLinux可以声明一个只读变量后面再想改系统会报错。如果你觉得某个变量在脚本结束前不应该被改动加上readonly是个不错的好习惯防止脚本写长了之后不小心覆盖。3.2 位置参数脚本怎么接收“外部输入”脚本要想有用不能每次都把参数写死在代码里得允许使用者在运行的时候传值。比如我要写一个hello.sh想让它对任何名字打招呼运行方式应该是bash hello.sh ZhangSan脚本内部就能用位置参数变量来拿这个值。规则是$0代表脚本本身的文件名$1代表第一个参数$2代表第二个以此类推。改写后的脚本#!/bin/bash echo Hello, $1 echo 你一共传了 $# 个参数第一次运行bash hello.sh ZhangSan LiSi就会输出Hello, ZhangSan 你一共传了 2 个参数这里的$#代表参数个数是个非常常用的特殊变量。还有一个$?——上一条命令的返回值。Linux里命令执行成功返回0否则返回非0。在脚本里写if [ $? -ne 0 ]判断上一步是否失败是排查问题的基本功。另外$代表所有参数组成的列表后面写循环遍历参数时会频繁用到。3.3 让脚本“等”输入read命令位置的参数是从外部“投喂”给脚本的但有时候脚本需要运行期间主动问用户要数据这时候用read#!/bin/bash echo 请输入你的名字 read username echo 你好$username欢迎入坑Shell编程交给你一个真实场景我写过一个初始化脚本运行后问用户“要安装哪个软件包”“日志路径在哪里”把这些动态信息读进变量后续所有操作都依赖这些变量来跑。好处是同一个脚本能在不同环境、不同人手里复用不用每到一个环境就打开脚本改内容。read还可以配合-p参数把提示文字直接写到同一行例如read -p 请输入 username比先echo再read少占一行也更直观。3.4 变量和grep的组合拳很多初学者学grep只会这么用grep 关键字 文件名。放到脚本里grep的威力会成倍放大因为它可以和变量、条件判断一起工作。举个我经常用的例子写一个脚本去日志里找错误并把错误行数统计出来#!/bin/bash logfile/var/log/nginx/error.log patternPHP Warning count$(grep -c $pattern $logfile) echo 日志中 $pattern 出现的次数$count这里的$(...)是命令替换意思是先执行括号内的命令把输出结果赋值给变量。注意变量名和路径我全都用双引号包住了真实路径里如果带空格不包引号就炸了。新手经常忽略这一点等脚本遇到带空格的文件路径时轻则参数错乱重则误删东西。4. 条件判断让脚本学会“看情况办事”没有条件判断的脚本本质还是一段顺序执行的命令列表。加了if脚本才真正开始有了逻辑能根据不同的情况走不同的分支。这是从“敲命令的人”升级为“写程序的人”的关键一步也是很多新手开始觉得Shell“像一门正经语言”的分水岭。4.1 最基本的if写法#!/bin/bash score85 if [ $score -ge 60 ]; then echo 及格了不错 else echo 没及格加油 fi注意几个细节if后面必须跟一个命令[ ]就是test命令的简写形式。中括号前后必须有空格$score和-ge之间也要有空格。条件写完后要先写then整个结构最后以反写的fi结束。else是可选的没有else时条件不满足就什么都不做。你可能会疑惑为什么[后面要有空格因为[本身就是一个命令的名字你写[$score -ge 60]相当于命令名和参数挤在一起了结果必然是语法错误。我见过很多新手改了半天都找不到问题所在最后发现就是空格的事。记住这个原则[是一个命令不是语法符号和ls、grep是同类所以它后面一定要有空格。4.2 判断运算符整数、字符串和文件要分开记Shell里的比较运算符和别的语言不太一样新手最容易搞混的是整数比较和字符串比较。记住下表基本够用场景运算符含义整数比较-eq等于整数比较-ne不等于整数比较-gt大于整数比较-lt小于整数比较-ge大于等于整数比较-le小于等于字符串比较相等字符串比较!不相等字符串比较-z字符串为空字符串比较-n字符串不为空文件判断-f是否为普通文件文件判断-d是否为目录文件判断-e是否存在文件判断-r/-w/-x是否可读/可写/可执行新人最容易踩的坑就是拿和比较整数。我得仔细说说在[...]里是重定向符号不是大于。如果你写了[ $score 60 ]Shell会先把 60当成输出重定向把$score的结果写进一个叫60的文件里——这不是报错而是让你的脚本产生一个诡异的新文件。所以记住整数比较一律用-eq、-gt这套字符串比较才用和!。还有一种写法是用双括号[[ ... ]]它是bash的扩展特性里面可以用、、、||这类更自然的运算符而且对空格相对宽容写起来更像现代编程语言。比如if [[ $score -gt 60 $score -lt 90 ]]; then echo 中等偏上 fi[[ ]]里比较字符串时还支持模糊匹配和正则~在bash脚本里我实际上更推荐这种方式。注意[[ ]]是bash特有的写法在sh里不一定兼容好在我们开头就指定了#!/bin/bash所以可以放心用。4.3 用条件判断保护你的操作条件判断在脚本里的第一大用途是“犯错前的安全检查”。举个例子我之前写过一个清理临时文件的脚本用来删除7天前的缓存。如果目标目录不存在rm -rf大概率不会出什么大事但如果路径变量因为某种原因变成了空事情就麻烦了——rm -rf /的惨剧都是这么发生的。所以我会在第一行就加一道防线#!/bin/bash backup_dir/opt/backup/tmp if [ ! -d $backup_dir ]; then echo 出错了目录 $backup_dir 不存在脚本退出 exit 1 fi find $backup_dir -type f -mtime 7 -delete这里! -d表示“不是目录”exit 1表示以非0状态退出脚本相当于“我这边出错了请上层程序注意”。这个习惯请务必养成——凡是后面会用力操作的脚本删除、覆盖、格式化前面先把条件检查做扎实能保住你很多头发。5. 循环批量重复劳动的彻底解放命令行里最烦的事情是什么想象你给20台服务器各传一个配置文件给100个日志文件按照日期重命名给30个目录打包压缩。手动做第一遍觉得新鲜做第十遍就想砸键盘。Shell编程最让人“真香”的部分到这里就来了。5.1 for循环的三种打开方式最经典的写法是“遍历一个列表”比如打印当前目录下所有.txt文件名#!/bin/bash for file in *.txt; do echo 发现文件$file done*.txt会被Shell自动展开成当前目录下所有txt文件名的列表循环体对每个名字执行一次。这个例子可能太简单了再看个实用版的批量把目录下的.log文件压缩并移动到archive目录。#!/bin/bash mkdir -p archive for file in *.log; do tar -czf ${file}.tar.gz $file mv ${file}.tar.gz archive/ done循环还能遍历显式的列表比如批量创建用户时#!/bin/bash for user in alice bob charlie; do useradd $user echo $user 创建完成 done这里每个名字之间以空格隔开循环自动切分。如果你想从一篇文件里读取每一行作为循环元素可以用$(cat userlist.txt)命令替换#!/bin/bash for user in $(cat userlist.txt); do useradd $user done还有一种写法是C语言风格的计数循环适合让变量从1走到10的情况#!/bin/bash for ((i1; i10; i)); do echo 第 $i 次迭代 done新人纠结最多的是“到底该用哪种”。我给你一条经验法则如果你能自然地说出“对每一个XX做一遍”用第一种列表遍历如果你要的是“从1到N按序号执行”用C风格循环。此外如果想要生成连续的数字序列seq 1 10也可以配合循环使用但注意它要启动外部命令循环量非常大的时候性能略逊于内置语法。5.2 while循环读文件的神器while循环特别适合处理“需要逐行读取直到结束”的场景尤其是日志分析。读数据时非常典型的写法是#!/bin/bash while read line; do echo 行内容$line done access.log这里 access.log把文件内容重定向为循环的输入每次读一行赋值给line变量。这段代码的实用场景太多了分析日志中的IP统计出现次数筛选异常状态码。举个例子找出访问日志中返回码是404的行并计数#!/bin/bash count0 while read line; do if echo $line | grep -q 404 ; then count$((count 1)) fi done access.log echo 404错误出现了 $count 次注意这里用到了$((...))做算术运算这是Shell里“算数”的正确姿势直接写count$count1是不对的——那会把字符串01赋给变量。-q参数是让grep安静模式不输出匹配内容只根据返回值配合if判断。5.3 break和continue循环的刹车和绕行循环内部有时候需要提前退出或者跳过某些不合条件的项。break用于立即终止整个循环continue用于跳过本次循环、进入下一次。比如批量启动服务时遇到服务不存在就立刻停止整个操作#!/bin/bash for service in nginx mysql redis; do if ! systemctl status $service /dev/null 21; then echo $service 不存在终止操作 break fi systemctl start $service done如果只是想跳过某个特定项用continue。比如循环处理所有.conf文件时跳过名为backup.conf的那个#!/bin/bash for file in *.conf; do if [ $file backup.conf ]; then continue fi echo 正在处理 $file done这两个关键词本身不难难的是判断该在哪一层用。我的经验是宁可break之后多确认一遍条件也不要让脚本一路莽到底。循环越多样出问题后排查越费劲条件写严谨一点是值得的。6. 函数把常用操作变成自己的命令如果你写了一段时间脚本你会发现很多代码段反复出现检查目录是否存在、备份文件、打印带时间戳的日志。每次都复制粘贴一遍脚本会越来越臃肿。这时候就该用函数了——把一段逻辑打包成一个名字用时直接调用。6.1 定义一个函数叫“打招呼”函数定义的语法很简单#!/bin/bash greet() { echo 你好$1 } greet Linux greet Shell函数体内用$1、$2表示调用时传入的参数调用时函数名后面跟上对应参数即可。函数必须先定义后调用Shell是逐行解释执行的如果你在定义之前就调用它会报“command not found”。函数还可以return一个数字状态码供if判断。比如#!/bin/bash check_dir() { if [ -d $1 ]; then echo 存在 return 0 else echo 不存在 return 1 fi } if check_dir /etc; then echo 目录存在继续处理 else echo 目录不存在走异常分支 fi6.2 参数和shift处理不定数量的参数函数里处理参数的基本方式和脚本主体一模一样$1是第一个参数$2是第二个。但如果你要处理“若干个”参数而不确定具体有几个就需要shift命令登场了。shift的作用是把参数列表整体“左移一位”原来的$2变成$1$1被丢弃。于是可以用循环逐个消费#!/bin/bash process_all() { while [ $# -gt 0 ]; do echo 正在处理$1 shift done } process_all alpha beta gamma执行后会依次输出正在处理alpha 正在处理beta 正在处理gamma这个模式在写命令行工具类脚本时非常常用比如你希望脚本支持./install.sh -n nginx -p /opt/nginx这样带选项的调用就可以用shift配合case来解析参数。虽然bash本身有getopts这样的内置解析工具但理解shift的手工解析过程能让你对“参数到底怎么流动”有更透彻的把握。6.3 变量的作用域函数内部的“隐藏变量”新手写函数最容易忽略的是作用域陷阱。默认情况下函数里定义的变量是全局变量函数执行完依然存在会污染脚本主体的命名空间。看这个例子#!/bin/bash myfunc() { resultinside } myfunc echo $result输出是inside因为result在函数里直接定义默认就在全局生效了。如果我想让变量只在函数内部使用用local修饰myfunc() { local resultinside }这一行local非常关键。写复杂脚本时函数之间如果共享同一批变量名很容易相互覆盖出bug时找得头皮发麻。我的习惯是函数内部用到的临时变量全部加local除非我明确要修改全局变量。这和在别的语言里“尽量缩小变量作用域”是同一个道理。函数还有一个实用技巧——把通用函数单独写在一个脚本文件里然后通过source引入复用。比如我有个common.sh放着日志打印、时间获取等通用函数其他脚本开头写source /opt/scripts/common.sh就能直接调用这些函数。这才是脚本“工程化”的起点。7. 我踩过的壳坑新手最容易翻车的几个细节写了几年Shell回头看真正让我头疼的从来不是复杂的awk命令或高级的进程管理反而是那些细小到让人抓狂的语法细节。这些坑有个共同点报错信息不直观甚至完全不报错只是默默产生错误结果。我把它们集中写出来给后来的朋友提前排雷。7.1 空格Shell语法里的隐形判官Shell对空格极度敏感前面反复提到过。这大概是“空格引发的血案”的重灾区变量赋值nameLinux可以name Linux不行if条件if [ $a -eq $b ]所有空格一个都不能少[后面、]前面必须空格函数定义func() {这里的{前必须有东西{后要空格比较运算符-eq两边都要空格给新手的建议是写脚本时不要猛敲空格也不要完全不打空格遵循“每个语法元素之间以空格区分”的思路来理解就不会犯糊涂。我当初就是跨越了“把[当成括号”这道坎才真正理解了Shell的语法逻辑。7.2 变量没加引号看起来没问题实际定时炸弹前面提到过变量双引号的重要性。这里再强化一个场景非常多的新手写条件判断时都喜欢写[ $var yes ]。如果$var刚好是空值这一行展开后会变成[ yes ][命令看到第一个操作数就是直接把语法错误丢到你脸上。但如果写成[ $var yes ]空值展开后是[ yes ]这是一个合法的比较结果就是false程序流程正常。更危险的场景是变量值带空格。假设某个file变量值是/opt/my files/backup.tar不加引号传给rm就会变成删除/opt/my和files/backup.tar两个路径跟预期的行为天差地别。我的原则就一句话凡是可能包含空格或可能为空的值走到任何命令前必须加双引号。虽然写起来烦但这个习惯救过我太多次了。7.3 反引号和$()老式命令替换的坑获取命令的输出有两种写法resultgrep error log.txt result$(grep error log.txt)第一种是反引号第二种是$()。两者的区别在于反引号里的内容如果有嵌套处理规则极其诡异转义也不直观$()支持嵌套括号会正确配对而且可读性更好。我建议新人所有新写的脚本一律用$()不要问为什么反引号还存在反正不用它。7.4 CRLF换行和权限问题前面提到的Windows换行问题其实并不少见。如果你和我一样偶尔会用虚拟机里共享的文件夹传文件难免碰到从Windows编辑的脚本。遇到command not found这种报错先检查是不是\r在捣乱。另外一个更隐蔽的问题是终端里明明给脚本加了执行权限但在挂载的NTFS或FAT格式盘上运行时还是报Permission denied那通常是挂载选项里没有exec参数这属于文件系统挂载的范畴也别真以为是自己权限看错了。7.5 用shellcheck做一次免费“体检”有没有一个工具能自动把这些坑挑出来有强烈推荐shellcheck。这是一个Shell脚本静态检查工具会告诉我们哪里有空格问题、哪里有未加引号的变量、哪里用了过时的语法。安装很简单# Ubuntu/Debian系 sudo apt install shellcheck # CentOS/RHEL系 sudo yum install shellcheck然后对脚本执行检查shellcheck myscript.sh它会输出类似这样的提示In myscript.sh line 5: if [ $name root ] ^--- SC2086: Double quote to prevent globbing and word splitting.看明白了吗我们踩过的那些坑shellcheck全都能自动识别。我现在的习惯是写完任何一个超过十行的脚本发布之前先跑一遍shellcheck把提示的问题全部修正或确认无碍再交付。这个工具堪称新手查错路上最省心的助力也是老手保证脚本质量的最低成本手段。8. 下一步怎么练三个拿来就能上手的练手项目这一篇写到这里语法层面的主体内容已经带完了。但只看不练等于白看。我给你们规划的下一步很具体不止是写一个“hello world”而是直接写三个能在真实工作里派上用场的小工具哪怕一开始只是糙版也能极大加深对Shell的理解。8.1 练手一自动备份脚本场景描述把/var/www/html下的站点文件打包压缩加上时间戳放到/backup/web目录只保留最近7天的备份。这个练习覆盖的知识点变量定义、$(date %Y%m%d)命令替换、mkdir -p创建目录、tar打包、find -mtime 7 -delete清理旧文件。写之前先在终端里手动执行一遍相关命令再组合进脚本。完成后用crontab定时每天凌晨执行就是一个真正能用的备份系统。8.2 练手二日志错误扫描器场景描述有一个应用日志app.log每天增长很快你希望每天扫一遍其中出现ERROR关键字的行统计出错误总数并把错误行单独保存到errors_today.log。这个练习覆盖grep的使用、wc -l统计行数、if判断是否有错误、for或while循环的运用。进阶版可以统计每种错误类型出现的次数用sort加uniq -c实现。这个练习做完你对“文本处理三板斧”grep、sort、uniq会形成非常踏实的肌肉记忆。8.3 练手三一键环境初始化场景描述换了一台新机器你要手动装一堆软件、创建用户、改配置文件。写一个init.sh用read询问是否安装某几个软件包用for循环依次处理用函数封装“安装单个软件”的逻辑。这个练习能帮你们把本篇学过的函数、判断、循环、输入串起来形成完整的“写脚本”思维链路。完成之后你就算真正入门了Shell编程——不是会背几个命令而是知道如何把一堆命令组织成你自己需要的自动化工具。至于系列文章的后续我也提前说一下方向变量再深挖一层环境变量、特殊变量、字符串处理截取、替换、正则、数组和关联数组、case多分支、awk和sed的实用章节都会按这个节奏一步步展开。刚上路的朋友不用急先把今天这篇的每一个例子自己在终端里敲一遍体会一下“写程序替代手工操作”的感觉。那个“啪一条命令全部搞定”的爽感才是支撑你继续学下去的最大动力。
返回列表