上一篇记的是日常命令,这篇把脚本这一层补上。
先说一个坑。脚本手动跑得好好的,丢进 crontab 里一行输出都没有,多半是环境变量不一样:cron 的 PATH 比登录 shell 短一截,脚本里调用的命令就找不到了。这类问题跟语法无关,光看代码看不出来,所以这篇的记法是语法和踩过的坑放一起写。
顺序按实际用到的来:解释器、执行方式、变量、运算、判断、循环、函数,最后是 cut、awk 和正则这几个处理文本的工具。
一、Shell 是什么
Shell 处在用户和内核中间。敲下去的命令由它接住,翻成内核能识别的东西,再把结果送回终端。它同时是一门脚本语言,能判断、能循环、能写函数。
Ubuntu 上能当解释器的程序不止一个,列一下:
cat /etc/shells我机器上的输出:
/bin/sh /bin/bash /usr/bin/bash /bin/rbash /usr/bin/rbash /usr/bin/sh /bin/dash /usr/bin/dash默认用哪个,看$SHELL就知道:
echo $SHELL # /bin/bashUbuntu 默认是 bash。后面的脚本都按 bash 写,第一行统一#!/bin/bash。
二、第一个脚本怎么跑
脚本开头那行#!/bin/bash是指定解释器用的,写在文件第一行。
#!/bin/bash echo "Hello shell!"保存成helloworld.sh,执行有两条路。
一条是把脚本当参数交给 bash,脚本本身不需要执行权限:
sh ./helloworld.sh # 相对路径 sh ~/helloworld.sh # 绝对路径 bash ./helloworld.sh bash ~/helloworld.sh另一条是自己执行,得先加 x 权限:
chmod +x helloworld.sh ./helloworld.sh ~/helloworld.sh两者的区别在于谁在执行这个文件。第一种是 bash 读文件内容照着做,文件有没有执行权限无所谓;第二种是内核直接执行文件,缺 x 权限就报 Permission denied。调试的时候用bash 脚本名省事,不用反复 chmod;要交给 cron 或者别人用,再 chmod +x。
三、变量
3.1 系统自带的变量
常用的几个是 PATH、HOME、PWD、SHELL、USER。取值写成$变量名,美元符和变量名之间不能有空格。
echo $PATH # /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin echo $HOME # /home/ubuntu想看当前 shell 里的全部变量,直接敲set。
3.2 自定义变量
A=5 # 等号两边不能有空格 echo $A # 5 A=myname # 重新赋值 unset A # 撤销 readonly B=2 # 只读,改不了也 unset 不掉几条规则都是踩过才知道的:
- 变量名由字母、数字、下划线组成,不能以数字开头,环境变量习惯大写
- 等号两侧不能有空格,
A = 5会报错 - bash 的变量默认是字符串,不能直接算数
- 值里带空格必须加引号
C=1+2 echo $C # 1+2,它就是个字符串 D=I love linux # 报错:找不到命令 love D="I like linux" # 这样才行 echo $D默认情况下变量只在当前 shell 有效,执行脚本时子进程看不到:
B=3 ./helloworld.sh # 脚本里 echo $B,什么都不打印 export B ./helloworld.sh # 这次能打印 3测试的时候注意在同一个窗口里做,换个终端就是另一个进程了。
3.3 特殊变量
写脚本离不开这一组。
| 变量 | 含义 |
|---|---|
| $n | n 是数字,$0 是脚本名,$1 到 $9 是前九个参数,十个以上写成 ${10} |
| $# | 参数的个数 |
| $* | 所有参数,当一个整体看 |
| $@ | 所有参数,每个单独看 |
| $? | 上一条命令的退出状态,0 是成功,非 0 是失败 |
$*和$@的差别在 for 循环那节展开,这里先放着。
四、算术运算
bash 只做整数运算,两种写法:
S=$[(2+3)*4] echo $S # 20 S=$(((4+3)*4)) echo $S # 28$[ ]和$(( ))效果一样,后者现在更常见。
五、条件判断
两种写法,test和[ ],后者中括号和条件之间必须有空格,不然直接语法错误。
这里有个和 C 语言相反的地方,刚开始很容易搞混:条件成立、数据非空时返回 0,成立才是 0,不成立返回 1。
test abc; echo $? # 0,非空算真 test; echo $? # 1,空算假 [ abc ]; echo $? # 0 [ ]; echo $? # 1判断条件分三类。
整数比较用字母缩写,因为[ ]里不能直接写>、<:
| 运算符 | 含义 |
|---|---|
| -eq | 等于 |
| -ne | 不等于 |
| -lt | 小于 |
| -le | 小于等于 |
| -gt | 大于 |
| -ge | 大于等于 |
文件权限的判断是 -r 可读、-w 可写、-x 可执行;文件类型是 -e 存在、-f 是普通文件、-d 是目录。
多条件用&&和||,前者前一条成功才走后面,后者失败才走后面:
[ abc ] && echo OK || echo notOK # OK [ ] && echo OK || echo notOK # notOK六、流程控制
6.1 if
单分支和多分支的骨架:
if [ 条件 ]; then 程序 fi if [ 条件 ]; then 程序 elif [ 条件 ]; then 程序 else 程序 fi两个细节:[和条件之间要有空格;if后面也要有空格。
一个判断年龄的例子,顺手把$#用上:
#!/bin/bash if [ $# -eq 0 ]; then echo '请携带年龄' elif [ $1 -lt 18 ]; then echo '未成年人' elif [ $1 -lt 60 ]; then echo '成年人' else echo '老年人' fi./if.sh 12 # 未成年人 ./if.sh 34 # 成年人 ./if.sh 66 # 老年人 ./if.sh # 请携带年龄6.2 case
选项多的时候 case 比一串 elif 清楚:
case $变量名 in "值1") 程序1 ;; 值2) 程序2 ;; *) 默认程序 ;; esaccase 行尾是in,每个模式以右括号结束,;;相当于 break,最后的*)是兜底。
#!/bin/bash case $1 in "1") echo "start" ;; 2) echo "stop" ;; *) echo "unknown" ;; esac6.3 for
第一种是 C 语言风格,次数或者范围明确时用它:
for ((初始值; 循环控制条件; 变量变化)) do 程序 done从 1 加到 100:
#!/bin/bash sum=0 for ((i=1; i<=100; i++)) do sum=$[$sum+$i] done echo $sum # 5050第二种是遍历一组值:
for 变量 in 值1 值2 值3 ... do 程序 done#!/bin/bash for i in apple banana orange do echo "i like $i" done6.4 $* 和 $@ 的差别
不加双引号的时候,这两个看起来没区别,都是把参数一个个列出来:
./for3.sh apple banana orange # =============$*============= # i like apple # i like banana # i like orange # =============$@============= # i like apple # i like banana # i like orange加上双引号就分出高下了。"$*"把所有参数当成一个整体,循环只转一圈;"$@"把每个参数当独立的,有几个转几圈:
#!/bin/bash for i in "$*" do echo "i like $i" done # 只输出一次:i like apple banana orange for j in "$@" do echo "i like $j" done # 输出三次:apple、banana、orange 各一次传参的脚本里这个坑最常见,需要逐条处理参数就得用"$@"。
6.5 while
while [ 条件 ] do 程序 done同样是 1 加到 100:
#!/bin/bash sum=0 i=1 while [ $i -le 100 ] do sum=$[$sum+$i] i=$[$i+1] done echo $sum # 5050七、read:让脚本接收输入
read 把终端输入读进变量:
read (选项) (参数)常用的两个选项,-p给提示符,-t是等待秒数,不加就一直等。
#!/bin/bash read -t 7 -p "Enter your name in 7 seconds :" NAME echo $NAME七秒内不输入就跳过。放到无人值守的脚本里,加个-t比卡在那里强。
八、函数
Shell 命令是脚本的基本单位,函数是把一串操作打包起来反复用。
[ function ] funname[()] { Action; [return int;] }function和()可以省一个,但不能两个都省。
有两点和其他语言不一样。函数必须先声明再调用,shell 是逐行执行的,不像别的语言先编译一遍。返回值只能通过$?取,可以显式return n(0 到 255),不写就返回最后一条命令的结果。
#!/bin/bash sum() { SUM=$[$1+$2] echo $SUM } read -p "请输入第一个数值: " n1 read -p "请输入第二个数值: " n2 sum $n1 $n2./fun.sh # 请输入第一个数值: 2 # 请输入第二个数值: 5 # 7九、cut 和 awk
9.1 cut:按列切
cut 是从每行里按位置切数据出来。
cut [选项参数] filename| 选项 | 作用 |
|---|---|
| -f | 取第几列 |
| -d | 指定分隔符,默认是制表符,只能是一个字符 |
拿 PATH 试一下,-f 3-表示从第三列取到结尾:
echo $PATH | cut -d ":" -f 3- # /usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin从 ifconfig 的输出里抠 IP,也是这么切:
ifconfig ens33 | grep netmask | cut -d "i" -f 2 | cut -d " " -f 2 # 192.168.10.150ifconfig 要先装 net-tools,新版 Ubuntu 默认不带。
9.2 awk:按行分析
awk 逐行读文件,默认以空格把每行切开,再对切开的部分做处理。它比 cut 灵活的地方在于能写条件和计算。
awk [选项参数] '/pattern1/{action1} /pattern2/{action2}...' filename| 选项 | 作用 |
|---|---|
| -F | 指定分隔符 |
| -v | 定义一个变量传进去 |
几个例子,由浅到深:
# 找 root 开头的行,输出第 7 列 awk -F : '/^root/{print $7}' passwd # /bin/bash # 输出第 1 列和第 7 列,中间加逗号 awk -F : '/^root/{print $1","$7}' passwd # root,/bin/bash # BEGIN 在读数据之前执行一次,END 在之后执行一次 awk -F : 'BEGIN{print "user, shell"} {print $1","$7} END{print "ubuntu,/bin/zsh"}' passwd # user, shell # daemon,/usr/sbin/nologin # ... # ubuntu,/bin/zsh # -v 把 i=1 传进去,给用户 id 加 1 awk -v i=1 -F : '{print $3+i}' passwd内置变量三个最常用,做日志统计离不开:
| 变量 | 含义 |
|---|---|
| FILENAME | 文件名 |
| NR | 当前行号 |
| NF | 切开后的列数 |
awk -F : '{print "filename:" FILENAME ",linenum:" NR ",col:" NF}' passwd # filename:passwd,linenum:1,col:7 # filename:passwd,linenum:2,col:7 # 找空行的行号 ifconfig | awk '/^$/{print NR}' # 9 # 18 # 抠 IP 的 awk 版 ifconfig ens33 | awk -F " " '/inet /{print $2}' # 192.168.10.150十、正则表达式
正则就是一个字符串去描述一类字符串。grep、sed、awk 都靠它匹配内容。
| 符号 | 含义 | 例子 |
|---|---|---|
| ^ | 行首 | grep ^a 匹配 a 开头的行 |
| $ | 行尾 | grep n$ 匹配 n 结尾的行 |
| . | 任意一个字符 | r..t 能匹配 root、rabt |
| * | 前一个字符出现 0 次或多次 | ro*t 匹配 rt、rot、root |
| [ ] | 范围内的一个字符 | [0-9] 数字,[68] 是 6 或 8 |
| \ | 转义,匹配符号本身 | grep a\$b 匹配含 a$b 的行 |
两个常见的组合:^$匹配空行,.*匹配任意内容。
写校验脚本时可以直接抄的两条:
邮箱^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$
手机号^1[3-9]\d{9}$
十一、注释、引号和字符串
单行注释以#开头。注意第一行的#!/bin/bash不是注释,是给系统看的。
#!/bin/bash # 这是注释 A=1 # 行尾也能写多行注释用 Here Document 的形式包起来:
:<<EOF 这一整段 都会被注释掉 EOFEOF 换成别的标记也行,成对就可以。
11.1 三种引号的区别
| 写法 | 特点 |
|---|---|
| 单引号 'str' | 原样输出,里面的变量不解析,中间也不能出现单个单引号 |
| 双引号 "str" | 变量会解析,转义字符有效 |
| 不加引号 str | 不能有空格,有空格会被当成命令切分 |
your_name="myname" echo 'Hello, $your_name!' # Hello, $your_name! echo "Hello, $your_name!" # Hello, myname11.2 字符串的常用操作
# 拼接:挨着写,或者用双引号包变量 greeting="hello, "$your_name" !" greeting_1="hello, ${your_name} !" # 长度 string="abcd" echo ${#string} # 4 # 从下标 1 开始取 4 个 echo ${string:1:4} # bcd # 查字符位置 echo `expr index "$string" io`${变量名}这层大括号是界定边界的。echo $Abar会把 Abar 当成变量名,写成echo ${A}bar才输出 "A 的值" 加 bar。
11.3 declare:给变量加类型
declare -i num=10 # 声明成整数 declare -A site # 声明关联数组,后面数组那节用十二、参数还有三个
前面记的$n、$#、$*、$@、$?之外,实际写脚本还有三个会用上:
| 变量 | 含义 |
|---|---|
| $$ | 当前脚本的进程号 |
| $! | 最后一个后台进程的进程号 |
| $- | 当前 shell 的选项,跟 set 命令看到的一样 |
十三、数组
bash 只有一维数组,下标从 0 开始,长度不限。
# 定义:括号里空格分开 array_name=(value0 value1 value2 value3) # 也可以逐个赋 array_name[0]=value0 array_name[1]=value1echo ${array_name[0]} # 第一个元素 echo ${array_name[@]} # 全部元素 echo ${array_name[*]} # 全部元素,另一种写法 echo ${#array_name[@]} # 元素个数 echo ${#array_name[0]} # 第一个元素的长度 echo ${!array_name[@]} # 所有下标用字符串当键的叫关联数组,得先 declare:
declare -A site site["email"]="mail.example.com" site["blog"]="www.example.com" echo ${site["blog"]} # www.example.com echo ${!site[@]} # email blog十四、运算符汇总
前面条件判断只记了 -eq 那几个,这里把算术、关系、布尔、字符串、文件测试一次写全。所有比较都放在[ ]或[[ ]]里做。
14.1 算术运算符
| 运算符 | 说明 | 举例 |
|---|---|---|
| + | 加法 | expr $a + $b |
| - | 减法 | expr $a - $b |
| * | 乘法,要转义 | expr $a \* $b |
| / | 除法 | expr $b / $a |
| % | 取余 | expr $b % $a |
| = | 赋值 | a=$b |
| == | 相等返回 true | [ $a == $b ] |
| != | 不等返回 true | [ $a != $b ] |
expr 是外部命令,所以运算符两边必须有空格,乘号得写成\*,整个表达式要用反引号或者$()包起来才拿得到值。
14.2 关系运算符
只对数字有效,就是 -eq、-ne、-gt、-lt、-ge、-le 那六个。
a=10 b=20 if [ $a -eq $b ]; then echo "$a -eq $b: a 等于 b" else echo "$a -eq $b: a 不等于 b" fi14.3 布尔运算符
| 运算符 | 说明 |
|---|---|
| ! | 非 |
| -o | 或 |
| -a | 与 |
if [ $a -lt 100 -a $b -gt 15 ]; then echo "$a 小于 100 且 $b 大于 15" fi14.4 字符串运算符
| 运算符 | 说明 |
|---|---|
| = | 两个字符串相等 |
| != | 两个字符串不相等 |
| -z | 长度为 0 返回 true |
| -n | 长度不为 0 返回 true |
| $ | 不为空返回 true |
a="abc" if [ -z $a ]; then echo "长度为0"; else echo "长度不为0"; fi if [ $a ]; then echo "字符串不为空"; fi14.5 文件测试
| 运算符 | 说明 |
|---|---|
| -b | 块设备文件 |
| -c | 字符设备文件 |
| -d | 目录 |
| -f | 普通文件 |
| -g | 设置了 SGID 位 |
| -k | 设置了粘着位 |
| -p | 有名管道 |
| -u | 设置了 SUID 位 |
| -r | 可读 |
| -w | 可写 |
| -x | 可执行 |
| -s | 文件不为空 |
| -e | 文件存在 |
| -S | socket |
| -L | 符号链接 |
14.6 let 和 (( ))
no1=2 let no1++ # 自增,等价于 no1=$[$no1+1] let no1+=2 # 加 2 (( a++ )) # (( )) 里不用加 $做算术我倾向用(( )),比 let 和 expr 少踩坑,不用管反引号也不用管空格。
十五、echo 和 printf
15.1 echo
| 参数 | 作用 |
|---|---|
| -n | 输出完不换行 |
| -e | 开启转义,\n 换行,\t 制表,\c 不换行也不输出后面的内容 |
echo -n "不换行" echo -e "换行\n制表\t结束" echo -e "第一行\c"几种常见用法:
# 原样输出,不解析 echo '$name"' # 显示命令结果,用 $() 比反引号清楚 echo $(date) # 写进文件,> 覆盖,>> 追加 echo "It is a test" > myfile.txt echo "It is a test" >> myfile.txt带颜色的提示:
echo -e "\033[31m这是红色\033[0m" echo -e "\033[32m这是绿色\033[0m"15.2 printf
printf 和 C 里的用法一样,做对齐的表格比 echo 好使。
printf format-string [arguments...]| 格式符 | 说明 |
|---|---|
| %s | 字符串 |
| %d | 整数 |
| %f | 浮点数 |
| %c | 字符 |
| %x | 十六进制 |
| %o | 八进制 |
| %-10s | 宽度 10 个字符,减号表示左对齐 |
| %.2f | 保留两位小数 |
printf "%-10s %-8s %-4s\n" 姓名 性别 体重kg printf "%-10s %-8s %-4.2f\n" 小王 男 66.12 printf "%-10s %-8s %-4.2f\n" 小李 男 48.65 printf "10 + 20 = %d\n" $((10+20))和 echo 有一点不同:printf 不会自动换行,结尾得自己加\n。格式符给少了,格式串会重复使用。
十六、test 的几种写法
test和[ ]是最常见的,其实还有[[ ]]和(( )),能力一层比一层强。
| 写法 | 能做什么 | 注意 |
|---|---|---|
| test 条件 | 原始写法 | 等价于 [ 条件 ] |
| [ 条件 ] | 最常用 | 不支持 && 和 ||,要用 -a、-o |
| [[ 条件 ]] | 支持 &&、||,支持 =~ 正则和通配符 | bash 扩展,不保证 POSIX 兼容 |
| (( 表达式 )) | 纯算术判断,内部可以直接写 > < >= | 结果为 0 才算 true |
# [[ ]] 支持通配符 if [[ "abc" == a* ]]; then echo "以 a 开头"; fi if [[ "13800138000" =~ ^1[3-9][0-9]{9}$ ]]; then echo "是手机号"; fi # (( )) 里直接用比较符号 a=10 if (( a > 5 )); then echo "大于 5"; fi按场景选:纯数字比较用(( )),字符串和文件判断用[[ ]],要兼容老 shell 才退回[ ]。
十七、流程控制补充
if 里 else 后面不能空着,什么都不写会直接语法报错。没内容就别写 else。
# 这样是错的 if [ $a -gt 5 ]; then echo "大于" else fi简单的逻辑可以写成一行:
if [ $a -gt 5 ]; then echo "大于"; fi for i in 1 2 3; do echo $i; done无限循环常用这几种:
while : do command done while true do command done for (( ; ; )) do command doneuntil 和 while 反过来,条件成立才退出,不成立就一直做:
until [ 条件 ] do 程序 donea=0 until [ ! $a -lt 10 ] do echo $a a=`expr $a + 1` donebreak 跳出整个循环,continue 跳过本次剩下的语句进下一轮:
while : do echo -n "输入 1 到 5 之间的数字:" read aNum case $aNum in 1|2|3|4|5) echo "你输入的数字为 $aNum!" ;; *) echo "不在 1 到 5 之间,结束" break ;; esac done按行读文件用 while read,比for i in $(cat file)稳,后者遇到空格会切词:
while read line do echo $line done < filename.txt十八、输入输出重定向
三个文件描述符先记住:0 是标准输入,1 是标准输出,2 是标准错误。
| 命令 | 说明 |
|---|---|
| command > file | 输出重定向到 file,覆盖 |
| command >> file | 输出追加到 file 末尾 |
| command < file | 从 file 读输入 |
| n > file | 把描述符 n 的输出写到 file |
| n >> file | 同上,追加 |
| n >& m | 输出描述符 m 和 n 合并 |
| n <& m | 输入描述符 m 和 n 合并 |
| << tag | tag 之间的内容作为输入 |
输出重定向:
who > users # 结果写进 users,终端不再显示 cat users echo "hello" > users # 覆盖 echo "hello" >> users # 追加输入重定向,注意wc -l users会带文件名,wc -l < users只从标准输入读,不带:
wc -l users # 2 users wc -l < users # 2 command1 < infile > outfile # 输入输出同时替换标准错误的处理:
command 2>file # 只把 stderr 写进 file command 2>>file # 追加 command > file 2>&1 # stdout 和 stderr 一起写进 file command < file1 > file2 # stdin 和 stdout 都重定向2>是一体的,2 和>之间加了空格含义就变了。
Here Document 把两个标记之间的内容当输入喂给命令:
command << delimiter document delimiter两个规矩:结尾的标记必须顶格,前面后面都不能有字符,包括空格和 Tab;开头的标记前后的空格会被忽略。
wc -l << EOF 第一行 第二行 第三行 EOF # 3#!/bin/bash cat << EOF 第一行 第二行 第三行 EOF不想看输出就丢给 /dev/null:
command > /dev/null # 丢掉标准输出 command > /dev/null 2>&1 # 标准输出和标准错误一起丢/dev/null 是个特殊文件,写进去的东西都消失,读它什么也读不到。写 crontab 的时候加一句,免得邮箱被日志塞满。
十九、文件包含
Shell 可以把别的脚本引进来,两种写法效果一样:
. filename # 点和文件名之间有空格 source filename被包含的脚本会在当前脚本里直接执行,所以它自己不需要执行权限。
# test1.sh #!/bin/bash url="http://www.example.com" # test2.sh #!/bin/bash . ./test1.sh echo "网址:$url"chmod +x test2.sh ./test2.sh # 网址:http://www.example.com实际项目里一般拿它放公共变量和公共函数,几个脚本都 source 同一个 config.sh,省得每份都抄一遍。
二十、两个值得早点养成的习惯
一个是关键步骤打印。逻辑一多,就在容易出问题的地方加 echo,把变量、参数、路径打出来,脚本跑挂了能直接看出停在哪一步。
另一个是在脚本开头写上set -euo pipefail。默认情况下脚本里的命令失败了也照样往下走,最后返回 0,看起来像成功。加上这行,命令失败、变量未定义都会立刻退出。调试的时候可以再配合set -x,每条命令执行前会先打印出来。
#!/bin/bash set -euo pipefail语法这块基本就这些。变量、判断、循环、函数是骨架,cut、awk 和正则处理文本,重定向和文件包含把脚本跟系统接起来。后面 grep 和 sed 单独再写一篇。