简介:面向Linux/Unix初学者的shell编程入门资料,以单个PDF文件封装,大小约805KB。这份PDF从建立第一个脚本开始,循序渐进地讲解#!/bin/sh声明、chmod +x赋予可执行权限、变量赋值与${}取值的写法、注释规范,并系统梳理echo、ls、grep、cut、sort、expr、find、sed、awk等常用Unix命令的典型场景;同时结合管道、重定向、反引号及if流程控制等示例,演示如何组合命令完成文本处理与数据传递,帮助零基础读者理解shell脚本的执行逻辑。资源共1个PDF文件,内容紧凑、结构清晰,适合离线阅读和实操对照。目前已有51人学习下载,既可作为首次接触命令行者的入门教材,也可当作日常编写小工具时的轻量速查手册。
1. 为什么《shell编程入门.pdf》这类资料,真正的价值在“折腾”
很多人把《shell编程入门.pdf》下载到本地就当自己在学 shell 了,结果三个月后写自动化任务还是只会 ctrl+r 搜历史命令。这很正常:shell 编程的知识密度藏在“你真正用得上的那 20%”里,而这 20% 又恰恰是交互式敲命令时最不容易察觉的部分,比如变量展开、位置参数、退出码和子 shell 边界。
本文不按 PDF 章节顺序讲命令手册,而是按“先建立底层直觉,再写好能跑的脚本,最后学会看脚本为什么挂”的路径走。新手可以跟着敲,干过两三年的运维或后端也能在 ${} 和 $() 的区别、shift 用法、set -euo pipefail 这些点上带走东西。目标只有一个:合上窗口,你愿意也在自己机器上从头写一个 shell 脚本。
2. 从“敲命令”到“写脚本”:shell 脚本基础知识的一层窗户纸
2.1 你在命令行里做的每一件事,都是在“当场执行一条脚本”
shell 脚本和交互式命令之间没有本质区别。你在终端里输入ls -la | grep 'error' | wc -l,其实就是在写一个一行三段的管道脚本;shell 负责把这一行拆成三个命令,把前一个命令的标准输出接给后一个的标准输入。所谓 shell 编程入门,入门的就是搞清楚 shell 在“拆这一行”时用了哪些规则,以及哪些规则会带来让你意外的行为。
我一般让学生做的第一个练习,不是写 hello,而是打开终端逐行敲下面这组命令,然后问自己:第 4 行和第 5 行,安全边界差在哪:
echo $HOME echo "$HOME" echo '$HOME' rm -rf $HOME/backup rm -rf "$HOME/backup"第 3 行不会删除 backup,但它会原样输出$HOME这几个字符;第 4 行如果$HOME为空,rm -rf /backup的后果谁执行谁知道。这段代码里,双引号决定了变量是否展开,决定了空格是否被当作分隔符,这是后面所有脚本安全性的地基。把它当成一行命令来看,你就在交互模式下做了一次最朴素的脚本基础知识训练。
2.2 最小可运行的 shell 脚本:文件头、执行权限与运行方式
新建一个first.sh,第一行必须是 shebang,我建议直接写#!/bin/bash而不是#!/bin/sh。Ubuntu 和 Debian 上/bin/sh实际指向 dash,它不是 bash,语法子集不同,${var,,}这种大小写转换写法在 dash 里会直接报错。脚本里凡是需要 bash 特性的地方,开头就写 bash,避免到生产环境才暴雷。
#!/bin/bash # 第一个 shell 脚本:输出当前用户和工作目录 echo "hello, $USER" echo "pwd: $(pwd)"保存后先chmod +x first.sh,然后有两种跑法:./first.sh和bash first.sh。前者依赖文件头里的#!/bin/bash,由内核调用对应解释器;后者是“我手动指定用 bash 解释这个文本”,文件头可以不存在甚至写错不报错。日常调试我会用bash first.sh,衔接接下来要讲的bash -x调试模式;交付给别人的脚本则一定要把权限和 shebang 配好,否则别人执行时只会得到一个 permission denied。
2.3 位置参数与特殊变量:$0、$1、$#、$@ 的关系
脚本像函数一样可以接收参数。写一个arg.sh:
#!/bin/bash echo "脚本名: $0" echo "第一个参数: $1" echo "参数个数: $#" for i in "$@"; do echo "遍历: $i" done执行bash arg.sh a "b c" d,你会看到$#是 3 而不是 4,"$@"会保留b c这个带空格的整体。这里最关键的一个细节是"$@"和$@的区别:加引号时每个参数保持独立,不加引号时参数会再次经过分词和通配符展开,遇到文件名带空格的场景就裂开。下面把特殊参数列成一张表,shell 面试题里经常考到:
| 符号 | 含义 | 典型使用场景 |
|---|---|---|
$0 | 脚本名(或当前 shell 名) | 日志里打印来源 |
$1~$9 | 第 1~9 个位置参数 | 从命令行取值 |
$# | 参数个数 | 校验必传参数 |
$@ | 全部参数,"$@"为安全形式 | for 遍历 |
$? | 上一条命令的退出码,0 表示成功 | 判断命令是否成功 |
$$ | 当前 shell 的 PID | 生成临时文件名 |
注意:
"$@"与$*的区别在于引号内是否保留参数边界。遍历参数时用"$@",要把所有参数拼成字符串时才用"$*"。
退出码这里补一个使用习惯:任何命令都有退出码,我用mkdir -p /tmp/xxx || exit 1这种写法保证失败时不会继续往下执行,而不会写完 mkdir 之后默认它一定成功。shell 编程入门阶段最划算的投资,就是把“命令失败会导致脚本继续跑”这条安全直觉练成本能。
3. 变量、引号与扩展:linux shell 里 ${} 和 $() 的区别,以及另外两个容易绕晕的语法点
3.1 变量赋值:等号两边不能有空格的真正原因
shell 里写变量的第一课通常是“定义变量时name = "x"是错的,必须写成name="x"”。这句话如果只背结论,下次遇到name= "x"还是懵。本质原因是:shell 的词法分析里,name=x是一个完整的词,解析器才能识别成赋值语句;一旦你写name = x,空格让解析器把它拆成三个词,那么name就被当成一个需要执行的命令,=和x变成它的参数。反过来说,这也是“命令、变量赋值、重定向都依赖词边界”的同一个原理。
除了赋值,还有两个修饰词的细节。readonly name="x"之后这个变量不能被重新赋值,适合放配置项;unset name把变量彻底删掉,删掉后再引用就是个空字符串而不是报错。变量引用时我统一建议写成${name}而不是$name,原因是当后面紧跟字母或数字时会吞噬边界,比如:
v=hello echo "$v_world" # 空,因为 shell 找的是 v_world 这个变量 echo "${v}_world" # 输出 hello_world这个现象叫“变量名边界吞噬”,是$var不带花括号时最常见的错误来源。写成${v}之后,shell 能明确知道变量名到哪里结束,后面接字母、数字、下划线都不会出错。这个套路在第 3.3 节会进一步延伸成子串处理。
3.2 单引号、双引号、反引号:引号决定“词”的边界
三种引号行为分别对应三种诉求。单引号内所有字符保持字面值,双引号内做变量展开和命令替换,但不再分词;反引号在旧代码里承担命令替换,现在推荐写成$(),因为反引号里嵌套$(...)或反斜杠时转义规则非常反直觉。一个容易被忽略的事实是:双引号里的$依然会被展开,所以密码、密钥这类含$的字符串要保存在单引号里,或者用反斜杠转义。
更实际的坑是“不写引号”。变量不加双引号时,它的值会被 shell 按 IFS(默认是空格、制表符、换行)做分词,还会对值里的*、?、[做文件名通配扩展。看下面这个片段:
dir="my docs" mkdir $dir # 实际创建了 my 和 docs 两个目录 mkdir "$dir" # 正确地只创建一个“my docs”我见过很多人在循环里犯的错是for i in $(ls *.txt),文件名稍微带个空格,一个文件就被拆成两行处理。正确写法是for i in *.txt,让通配符展开发生在正确的位置。引号这道关口,决定了脚本是“大部分时候能跑”还是“什么时候都能跑”。三种引号的对比表如下:
| 写法 | 变量展开 | 命令替换 | 分词 | 典型用途 |
|---|---|---|---|---|
"$var" | 是 | 是 | 否 | 传参时保留整体 |
'$var' | 否 | 否 | 否 | 字面字符串 |
$var | 是 | 是 | 是 | 不推荐,除非明确要分词 |
`cmd` | 否 | 是 | 结果会分词 | 旧代码,建议换$(cmd) |
3.3 ${} 与 $() 的区别:一个展开变量,一个执行命令
这是 shell 面试题和日常代码评审中最容易混的一组。${var}是参数展开,作用是取出变量 var 的值,属于“取值”;$(cmd)是命令替换,作用是先执行 cmd,再把它的标准输出作为结果拼回命令行,属于“执行+取值”。两者可以组合,但注意写成"${$(hostname)}"是错的,因为参数展开的括号里必须是一个变量名,不能再放一个命令替换。常见正确用法是先取到命令结果存进变量,再展开:
host=$(hostname) # 命令替换,把 hostname 输出存入 host echo "${host}" # 参数展开,取出 host 变量的值 file="/var/log/nginx/access.log" echo "${file##*/}" # 去掉最长前缀 => access.log echo "${file%.log}" # 去掉最短后缀 => /var/log/nginx/access echo "${file//access/error}" # 全部替换 access 为 error后三行是参数展开里的子串处理,虽然标题带“入门”,但这几个在写脚本时高频用到,值得提前掌握。${file##*/}用来从完整路径里取文件名,${file%/*}用来取目录名,这两个在打包、备份、日志处理脚本里几乎每份都会出现。注意#是删前缀,%是删后缀,一个符号表示最短匹配,双符号表示最长匹配。
提示:不用背全部参数展开用法,记下
##*/取文件名、%/*取目录名、//old/new做替换,已经能覆盖大多数场景。
4. shell 脚本 for 循环的三种写法、if 判断与 shift 命令:把脚本从“命令集合”变成“程序”
把 shell 脚本从“命令集合”变成“程序”,靠的是循环、判断和函数这三样流程控制。市面上那些“shell 脚本编程 100 例”的 PDF,去掉重复后剩下的骨架也就是这三个结构;把这三者组合好,绝大多数文件批处理、备份、部署前检查任务都够用了。
4.1 shell 脚本 for 循环的三种写法:列表、命令替换与 C 风格
for 循环是 shell 脚本里出现频率最高的流程控制,三种写法分别对应不同场景。第一种是列表式,适合处理固定的枚举值:
for env in dev staging prod; do echo "deploy to $env" done第二种是通配符展开,处理文件列表:
for log in /var/log/nginx/*.log; do [ -f "$log" ] && echo "rotate: $log" done这里*.log如果没有匹配文件,shell 会把原样字符串*.log作为唯一一次循环传给变量,所以循环体里先用[ -f "$log" ]判断文件确实存在,避免把通配符字符串当成真实路径去操作。第三种是 C 风格,适合需要步长和索引的场景:
for ((i=0; i<10; i+=2)); do echo "index: $i" done三种写法在面试手写题里都出现过,for i in "$@"遍历参数、for i in $(seq 1 100)造数字序列也属于列表式变体。写 for 时最常提醒的只有一个点:循环体内变量一定要加双引号,for i in "$@"而不是for i in $@,和第 3 章的分词陷阱完全一致。
4.2 if 判断与 test 表达式:文件、字符串、数字的常用条件
if 在 shell 里不是独立的语法结构,它更接近对“一条命令的退出码”做分支。if cmd; then等价于判断“cmd 退出码是否为 0”。所以if [ -f "$file" ]里的[实际上是一个名为[的命令,最后一个]只是它的参数,因此[和]周围的空格都是语法的一部分:写成[-f "$file"]时,shell 会把它当成一个名为[-f的命令去查找,直接报 command not found。
单中括号[ ]和双中括号[[ ]]的区别很值得讲:[[ ]]是 bash 的关键字,里面支持&&、||、正则=~,且内部不做分词和文件名扩展;[ ]是外部命令,写法更受限制。我写新脚本一律用[[ ]],同时接受[ ]在 POSIX 约束环境下的存在。条件表达式的常用项整理成下表:
| 表达式 | 含义 | 典型场景 |
|---|---|---|
-f "$file" | 文件存在且为普通文件 | 判断配置文件是否存在 |
-d "$dir" | 目录存在 | 建目录前检查 |
-x "$bin" | 文件存在且可执行 | 检查依赖命令 |
-n "$var" | 字符串长度非空 | 必填参数校验 |
-z "$var" | 字符串长度为 0 | 判空 |
"$a" == "$b" | 字符串相等([[ ]]内) | 模式匹配 |
"$n" -gt 5 | 数字大于,注意不是> | 数值比较 |
一个完整的参数校验片段通常是这样的,顺便看到$()、>&2和exit的配合:
if [[ -z "$1" ]]; then echo "用法: $0 <备份目录>" >&2 exit 1 fi if [[ -d "$1" ]]; then tar -czf "backup-$(date +%F).tar.gz" "$1" else echo "目录不存在: $1" >&2 exit 1 fi把>&2这个重定向说透:它把提示信息写到标准错误而不是标准输出,这样脚本被别的命令捕获输出时,提示信息不会混进数据流。报错往>&2走,是 shell 脚本与别的程序协作的好习惯。这段代码还把前面$()、$?的退出码思路串起来了:每个可能失败的步骤,都要想好“失败之后怎么办”。
4.3 函数与 shift 命令:位置参数的消费方式让参数解析变清晰
函数在 shell 里和脚本本身共享“位置参数”这套机制。定义时先给函数体加local声明局部变量,避免污染全局:local name="$1"。函数里的$1是函数自己的第一个参数,而不是脚本的第 1 个参数,这个作用域规则刚上手时最容易写串。
shift命令每次把位置参数整体左移一位:原来的$2变成$1,$#减一。它最常见的用途是手写参数解析,实现“支持一个开关、一个参数”的最小命令行工具:
#!/bin/bash verbose=0 output="" while [[ $# -gt 0 ]]; do case "$1" in -v|--verbose) verbose=1; shift ;; -o|--output) output="$2"; shift 2 ;; -h|--help) echo "用法: $0 [-v] [-o file]"; exit 0 ;; *) echo "未知参数: $1" >&2; exit 1 ;; esac done echo "verbose=$verbose, output=$output"这里每次shift都配合 case 消费掉已处理的参数,-o因为要带走一个值所以shift 2。对于只做入门用途,手写 while+case+shift 的解析方式比 getopts 更直观,也更容易在面试时现场讲清楚。之前提过的"$@"安全遍历在这里再次生效:循环条件[[ $# -gt 0 ]]和 case 里的"$1"都不会破坏带空格的参数值。到这里,一个脚本已经拥有了变量、循环、判断、函数四个完整零件,可以称为“程序化脚本”而不是“命令集合”了。
5. shell 脚本排错三板斧:bash -x、set -euo pipefail 和三个高频坑
5.1 bash -x 展开调试,一行一行看它到底跑了什么
脚本挂掉的第一反应不要是加 echo,先跑一遍bash -x ./your.sh --参数。这个模式会输出每条命令执行前的最终形态,也就是变量展开之后的样子,行首的+表示这一行正要执行。你在输出里能直接看到变量被替换成了什么值,文件名错、分词错、参数为空基本都藏不住。只调试某一段时,用set -x开启、set +x关闭,把探针圈定在可疑区域。读取顺序是“对照输出里的展开结果,反推是哪个变量变空了或参数变多了”,而不是盯着脚本原文猜。
5.2 三个高频坑:cd 失效、/bin/sh: wq、通配符落空
第一个坑是cd在脚本里失效。脚本里执行cd /opt/xxx只会改变这个脚本进程的工作目录,脚本退出后你的终端还在原目录;若想让 cd 影响当前 shell,必须source script.sh而不是bash script.sh。
第二个坑很有画面感:在 Vim 里写完脚本想保存退出,结果报了[no write since last change] /bin/sh: wq: command not found shell returned 1。这通常不是 shell 脚本的问题,而是 Ex 命令行模式下把:wq输成了:!wq:多按的一个!让 Vim 把wq当成外部命令交给 /bin/sh 去执行,自然找不到。保存退出要按:进入命令行模式再输入wq,别多按!,写 shell 脚本时顺手记住这个区分能省去不少困惑。
第三个坑是通配符没有匹配到任何文件时,*.log会以字面量参与命令。for 循环拿到*.log字符串后如果直接拿去删,后果不可预期。处理办法是循环里加[ -e "$file" ] || continue,或者先shopt -s nullglob让无匹配时展开为空,我偏好后者。
5.3 set -euo pipefail:脚本头部的一行安全配置
脚本头部建议写:
#!/bin/bash set -euo pipefail逐个拆开:-e让任意一条命令非 0 退出时整个脚本立即退出,阻止错误继续发酵;-u让使用未定义变量直接报错,把$name打成$nmae这类拼写错误暴露出来;-o pipefail让管道中任何一个命令失败都算失败,cmd1 | cmd2不再只看最后一条命令的退出码。这三项合起来,几乎把“脚本默默出错”最关键的来源堵住了。
有一个常见疑问是set -e下如果grep pattern file没匹配到(退出码 1),脚本会直接退出吗?答案是看它出现在哪里:出现在 if 条件、while 条件、&&或||的左侧时不会触发退出,因为这些位置的失败本来就是逻辑分支的一部分。理解了这条,就能解释很多“明明设置了 -e,脚本为什么还继续跑”的疑问。新写的脚本从第一行就带上这三项,比自己记住“每个命令都要判断退出码”靠得住得多。
本文还有配套的精品资源,点击获取