ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零把 Ubuntu 摸熟:一份写给自己的 Linux 命令手抄本(Shell 篇)

从零把 Ubuntu 摸熟:一份写给自己的 Linux 命令手抄本(Shell 篇)

上一篇记的是日常命令,这篇把脚本这一层补上。

先说一个坑。脚本手动跑得好好的,丢进 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/bash

Ubuntu 默认是 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 特殊变量

写脚本离不开这一组。

变量含义
$nn 是数字,$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 ;; *) 默认程序 ;; esac

case 行尾是in,每个模式以右括号结束,;;相当于 break,最后的*)是兜底。

#!/bin/bash case $1 in "1") echo "start" ;; 2) echo "stop" ;; *) echo "unknown" ;; esac

6.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" done

6.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.150

ifconfig 要先装 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 这一整段 都会被注释掉 EOF

EOF 换成别的标记也行,成对就可以。

11.1 三种引号的区别

写法特点
单引号 'str'原样输出,里面的变量不解析,中间也不能出现单个单引号
双引号 "str"变量会解析,转义字符有效
不加引号 str不能有空格,有空格会被当成命令切分
your_name="myname" echo 'Hello, $your_name!' # Hello, $your_name! echo "Hello, $your_name!" # Hello, myname

11.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]=value1
echo ${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" fi

14.3 布尔运算符

运算符说明
!非
-o或
-a与
if [ $a -lt 100 -a $b -gt 15 ]; then echo "$a 小于 100 且 $b 大于 15" fi

14.4 字符串运算符

运算符说明
=两个字符串相等
!=两个字符串不相等
-z长度为 0 返回 true
-n长度不为 0 返回 true
$不为空返回 true
a="abc" if [ -z $a ]; then echo "长度为0"; else echo "长度不为0"; fi if [ $a ]; then echo "字符串不为空"; fi

14.5 文件测试

运算符说明
-b块设备文件
-c字符设备文件
-d目录
-f普通文件
-g设置了 SGID 位
-k设置了粘着位
-p有名管道
-u设置了 SUID 位
-r可读
-w可写
-x可执行
-s文件不为空
-e文件存在
-Ssocket
-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 done

until 和 while 反过来,条件成立才退出,不成立就一直做:

until [ 条件 ] do 程序 done
a=0 until [ ! $a -lt 10 ] do echo $a a=`expr $a + 1` done

break 跳出整个循环,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 合并
<< tagtag 之间的内容作为输入

输出重定向:

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 单独再写一篇。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进