Shell Script 写作方法与 Linux 命令备忘录

这是一个个人笔记的收录处,乐观地看,我会不断更新它来收录新内容。

本文先从入门使用的角度介绍 Shell script 的常见骨架(控制语法)和填充(Linux 命令)。然后,我们阐述 Shell Script 的实际运行效果和用什么 Shell 来运行它、把它放到哪里息息相关。

Shell Script 中的控制语法

命令替换 Command Substitution

命令替换的方法为反引号 `command` 或者$(command),将 command 的执行结果(输出)放在原处。例如:

get_username() {
  echo "john" 
}
username=$(get_username)
DATE=$(date)

算术求值

形如 expr 11 / 2 或者 $((1 + 3))。将算术求值和命令替换结合在一起的例子:

calculate_sum() {
  echo $(( $1 + $2 )) # arithmetic evaluation, not command substitution.
}
result=$(calculate_sum 5 7) # command substitution
echo "The sum is: $result" # 12

注意,算术求值默认使用整型运算。所以 expr 11 / 2 会打印出 5。

接收参数

$2 或者 ${2} 用来代指第二个参数,这在上面的例子中已经体现了。对于 shell script 来说,$0 是 shell script 的文件名。$@ 是所有参数。

echo $0
for arg in "$@"; do
    echo "argument" ${arg}
    if [[ ${arg} == "--task_dir" ]]; then
        echo "\${2}" "${2}"
    fi
done

如果运行上述脚本 ./loop_all.sh C --task_dir A B,则预期的输出为

./loop_all.sh
argument C
argument --task_dir
${2} --task_dir
argument A
argument B

变量替换与通配符扩展

上面的例子中用到了变量替换(variable expansion):$name 或者 ${name} 都会将变量名称替换为变量内容,与命令替换的 $(command) 相对照。注意命令替换即使是在双引号内也会发生,不过在单引号内不会发生:

$ echo "I am $USER"
I am cloud-user
$ echo 'I am $USER'
I am $USER

如果在双引号内替换,且在变量后面并不刚好是空格,则就必须使用 ${name} 的形式了:

for subset in "science" "tech"; do
  if [ -f "/absolute/path/here/encode-${subset}.pkl"]; then
    echo "encode-${subset}.pkl exists"
  else
    echo "encode-${subset}.pkl does not exist"
  fi
done

通配符扩展(globbing)不会在双引号中发生,这在下面的例子中比较显然:

$ ls *.txt
a.txt b.txt
$ ls "*.txt"
ls: cannot access '*.txt': No such file or directory

正因如此,可以通过双引号避免通配符扩展,这在下面的例子中就不那么显然了:

$ python example.py --resource_files encoding.*.pkl
example.py 会接收到通配符展开后的所有路径
$ python example.py --resource_files "encoding.*.pkl"
example.py 中 args.resource_files 为字符串 "encoding.*.pkl"

常见运算符

整型比大小:-eq -ne -gt -lt -ge -le。注:只支持作为条件,在 square bracket 中使用,如下例:

if [$a -ne $b] then
  echo "not equal"
fi

逻辑运算符:-o (or), -a (and), !, &&, ||。例:if [[ $a -lt 100 && $b -gt 100 ]]

[ -z $a ] 检测字符串长度是否为零,[ -n “$a” ] 检查字符串长度是否不为零。单括号和双括号的区别在这里(简而言之:单括号功能更少,兼容性更好,见后文)。

还有文件测试运算符:-b (is block device?), -c (is character device?), -d (is directory?), -f (is regular file nor dir or device?), -g, -k, -p, -u, -{r,w,x}, -s, -e (file exists?)

一个例子:使用 -eq 来检查上一个命令是否无错退出:

if [ $? -eq 0 ]; then
  echo "previous command successful"
else
  echo "previous command error, returned $?"
fi

输入输出重定向与管道

输入输出重定向和管道 (pipe) 是强大的 Shell 编程和 Linux 命令技巧,值得深入理解其原理。关于其前置知识和基础用法就不在此论述了。

考虑下面的输出重定向方法:

  • command 1> file 可以将 stdout 重定向到文件。1> 可以简写为 >
  • command 2> file 将 stderr 重定向到文件
  • command 2>&1 可以将 stderr 重定向到 stdout 当前的输出目标,而非将 stderr 一了百了地输出到 stdout。为了便于理解,可以理解为使用了 dup2(int oldfd, newfd) 这一系统调用,让 newfd = 2 指向和 oldfd = 1 一样的 【Open File Table 表项】或者说【Open File Description】或者说【File handle (在这里意译为读写头)】。注:POSIX 标准规定的是行为,而非具体实现 – 未必一定要用 dup2 实现这个功能,只不过具体实现表现出的行为就好比是 dup2 产生的。

Q:command 2>&1 1>file 会发生什么?
A:结论是,stdout 被重定向到文件,stderr 向终端输出。重定向从左到右执行,stderr 会重定向到 stdout 当前所在的位置,即终端,鉴于 stderr 本身已经指向终端,这相当于一个 No-op;1>file 将 stdout 重定向到文件。

Q:command 1>file 2>&1 会发生什么?
A:结论是,stdout 和 stderr 都被重定向到文件。从左到右,1>file 将 stdout 重定向到 file;2>&1 将 stderr 重定向到 stdout 当前所指向的目标,即 file。由此可以看出,重定向的顺序很关键。

Q:command 1>file 2>&1command 1>file 2>file 的表现一样吗?
A:不一样。在前者中,只进行了一次 open() 系统调用,清空(truncate)file 宫一次,产生了一个 Open File Table 表项和一个 file 的 “写连接 / 读写头 / handle”;在后者中,进行了两次 open 系统调用,清空了 file 两次,产生了两个 Open File Table 表项和两个 file 的 “写连接 / 读写头 / handle”。用两个读写头读写同一个文件,由于相互覆盖的原因,几乎一定会出问题。

复杂重定向:验证

我们验证一下后者:bash -c 'echo out1; echo err1 >&2; echo err2 >&2; echo out2' >tmp 2>tmp,命令结束后 tmp 的内容是 err1 out2。分析两个 handle 前进的时机,可以发现这是符合预期的行为。一个复杂一点的例子,bash -c 'echo HEL; echo WO >&2; echo RLD >&2; echo LO' >tmp 2>tmp,tmp 的内容是 WO RLO。

相比之下,bash -c 'echo out1; echo err1 >&2; echo err2 >&2; echo out2' >tmp 2>&1 给我们的就是共用 handle 的预期中的行为:tmp 的内容为 out1 err1 err2 out2。(不确定运行结果是否会因平台和随机因素而异)

pipe 默认只重定向 stdout

pipe 时,默认只传递 stdout,不传递 stderr(stderr 会打印到终端)。如果不想要这种行为,可以使用 2>&1 的技巧,将 stderr 也重定向到和 stdout 相同的目标:pipe 的写入端。可以试试如下的例子,

  • openssl s_client -help | grep -i "ssl" 会把所有 help 信息每行都输出到屏幕上,难道是 grep 用法不对?
  • 发现 openssl s_client -help | wc -l 最后一行是 0,换言之,没有东西到达 pipe 右端。我们因此怀疑左侧终端打印出的是 stderr,而非 stdout。可以通过 openssl s_client -help 1>tmp.stdout 2>tmp.stderr ,而后检查文件内容来验证这一点。
  • openssl s_client -help 2>&1 | grep -i "ssl" 成功抓取帮助信息中所有包含 SSL 的行。

通过这个例子,可以发现有些工具的帮助信息是默认输出到 stderr 而非 stdout 的 – 在书写 shell 脚本时特别是 pipe 时要额外注意这一点。

输入重定向

最简单的例子就是 wc -l < myfile.txt。复杂一点,例如 sha256sum <file >sum。它可以等价地写为 >sum <file sha256sum,不过这样写并不规范。

杂项

如果想要一并忽略某个程序的所有标准错误输出流(stderr),可以将其重定向,如 program -arg argument 2> /dev/null

在管道中串接 Python 命令也许是可行的:program -arg argument | python -c "import sys; result=sys.stdin.readlines()[-1]; print(result.split()[-1], end=',')" | tee -a out.csv

对于 echo 输出后跟随的换行,可以用 -n 命令行参数去掉。

常用 Linux 命令备忘录

cd 和 sudo

cd 是 shell 内置的命令,并不对应非可执行文件(type cd 输出 builtin,whereis cd 没有输出可执行文件)。

sudo 工作的原理是 fork 一个进程出来,execve 执行要执行的可执行文件。/usr/bin/sudo 的权限位是 -rwsr-xr-x,拥有者是 root:root,这保证任何用户可以执行 sudo,而执行 sudo 产生的进程的 UID 为 root,拥有足够的权限。

由于 cd 不对应可执行文件,因此 sudo cd 会报错 sudo: cd: command not found。

Q: 为什么 cd 不对应可执行文件?
A: 因为 CWD(current working directory)是进程层面的属性。假如 cd 真的是一个可执行文件,叫它 /bin/cd 好了,那么 cd 大概需要调用 chdir() 系统调用,不过这改变的是 exec /bin/cd 产生的进程的 CWD,不是 shell 进程的 CWD。事实上,shell 进程的 CWD 只能由它自己变更,不能靠 fork+exec 来让其它可执行文件代为处理。

find

find [-H] [-L] [-P] [-D debugopts] [-Olevel] [starting-point...] [expression]

概要:以 starting-point 为根出发遍历文件树,依照 expression 中列出的标准匹配文件或文件夹,并对成功匹配的文件或文件夹做出相应行为。

starting-point 是一个列表。例如你可以执行 find dir1 dir2

expression 由测试(Tests)、行为(Actions)、运算符(Operators)等构成。

find 中的测试

测试形如 -testname,通常需要传入参数。对于数值参数 n,传入 +n 代表要求大于 n,-n 代表要求小于 n,n 代表刚好等于 n

-amin n: 最近访问时间距离当前的分钟数
-atime n: 最近访问日期距离今天的天数
-cmin n: 最近一次元信息修改时间距离当前的分钟数
-ctime n: 最近一次元信息修改日期距离今天的天数
-empty: 文件或文件夹为空
-executable:是可执行文件或可穿过的文件夹
-iname pattern:等价于不区分大小写的 -name
-mmin n: 最近一次文件内容修改时间距离当前的分钟数
-mtime n: 最近一次文件内容修改日期距离今天的天数
-name pattern: 用不带路径的文件名匹配 pattern
-path pattern: 用带路径的文件名匹配 pattern
-perm mode: 权限位等于 mode
-size n[cwbkMG]: 文件大小
-type c: 文件类型

注意区分上述选项中的 amin, cmin, mmin。这组区别还在 stat filename 时出现,即对应”access”, “change”, “modify” 三个时间戳:

  • 用 vim 编辑文件内容,三项都会更新
  • 重命名文件,change 会更新
  • 用 cat 列出文件内容,access 会更新
  • 用 echo > file 的方式向文件追加内容,change 和 modify 会更新

总而言之,当调用 read 系统调用读取文件时,根据定义 access 会更新;调用 write 系统调用写入文件内容时,根据定义 modify 会更新,同时由于文件大小这一元信息改变,change 会更新;移动或重命名文件时,change 会更新。

find 中的行为

-delete 删除
-exec command; 执行命令
-printf format 按格式打印

find 举例

find root_path -type d -iname '*lib*' 列出所有文件夹名包含 lib(不区分大小写)的文件夹

find root_path -name '*.py' -not -path '*/site-packages/*' 列出所有 .py 文件,排除路径中有 site-packages 的文件

find root_path -maxdepth 1 -size +500k -size -10M 下降至多一层,找到所有大小在 500k~1M 之间的文件

find . -not -type d -exec wc -l {} \; 对所有文件数行数

sed

sed,全称 stream editor,是处理文本流的编辑器。

最简单的例子:

$ echo "I love apples" | sed 's|apple|mango|g'
I love mangos

更复杂一些的例子:

wslcd() {
        p1=$(echo "$1" | sed 's/\\/\//g')
        wsl_path=$(echo "$p1" | sed -r 's|^([A-Za-z]):|/mnt/\L\1|')
        cd "$wsl_path" || echo $wsl_path
}

这个函数可以试图让 WSL shell cd 到一个 Windows 文件路径指定的文件夹。比如

$ wslcd "C:\Users\alice\Desktop\Some Folder\"
/mnt/c/Users/alice/Desktop/Some Folder$ ...

(注意路径名外要加双引号,防止空格将路径分割为两个参数或者字符被转义)

先看第一个 sed,s/\\/\//g 中的 s 表示替换,g 表示全局替换。依据 sed 的格式,表示匹配模式的部分是 “\\”(sed 内转移后为反斜杠),表示替换字符串的部分是 “\/”(sed 内转义后为斜杠)。它的作用是将所有 Windows 路径名使用的反斜杠 (backslash) 替换为 Unix 类系统路径名使用的斜杠 (forward slash)。

第二个 sed,-r 启用扩展正则表达式,s|^([A-Za-z]):|/mnt/\L\1| 仍然表示正则表达式替换。

  • 匹配模式(pattern)是 ^[A-Za-z]^ 代表匹配行开头,A-Za-z 匹配 Windows 驱动器号的那个大写或小写字母。额外添加的圆括号将其中内容作为到第一个捕获组以便后续使用。
  • 替换字符串(replacement string)是 /mnt/\L\1,/mnt 即字面意义是替换字符串中的前四个字符。\L 是 GNU sed 的一个方法,用于将接下来的字符转换为小写,以符合 WSL 对路径的要求。\1 是第一个捕获组。

env

env 的概述是 set the environment for command invocation,理解为为了执行下一个命令设置环境变量。概要 (synopsis) 是 env [-i] [name=value]... [utility [argument...]]

一个并不体现 env 目标用法的例子:env python3 -c "print(1+1)",能够打印 2。原理是,env 在 PATH 中找到了可执行文件 python3,并带着参数运行了它:/usr/bin/python3 -c "print(1+1)"

观察这个概要,[] 表示可选项。都有哪些可选项?

  • -i 表示 ignore,忽略从当前执行环境继承的环境变量
  • name=value 表示设置环境变量
  • utility,既要运行的命令,加上给该命令的参数。也可以不指定要运行的命令,此时 env 会打印所有环境变量(既继承的环境变量和用 name=value 设置的环境变量)

Shell script 的标准和扩展

如同早期 C 语言在不同编译器下有不同语法扩展和行为方式一样,Shell Script 也是经历了标准化的过程,在不同的执行环境中有不同的扩展。它的标准行为是 POSIX 标准一部分,在 “Shell and Utilities” 一节定义。关于 POSIX,它全称 Portable Operating System Interface(X 无实际含义),翻译为 “可移植操作系统接口”。POSIX 是 IEEE 标准之一,它的一个版本是 IEEE Std 1003.1-2024
2024。

POSIX 中关于 Shell 的标准大体上是依据 Bourne Shell (sh)(Bourne 来自它的作者 Stephen Bourne)作为基础来编写的。因此,实操中常常约定,标记为 #!/bin/sh 开头的 Shell script 遵循 POSIX 的标准,不使用具体的 Shell 的扩展语法。

Bourne Shell 产生了诸多后续变种,比如 Bourne Again SHell (bash), KornShell (ksh), Z shell (zsh),它们支持诸多 POSIX 标准之外扩展语法。举例来说,前文中既用了 [ ... ],又用了 [[ ... ]],两者都用来标注分支语法中的条件,不过前者在 POSIX 标准中,里面支持的算符更有限;而后者是 bash 定义的扩展,额外支持 && || 等算符,替代前者中的 -a -o 算符。例如,形如 if [[ $x && $y ]] 的 Bash script 就不符合 POSIX 标准,很可能不能在 dash 中工作。

把 Shell Script 放在哪里

放在一个可执行文件里:shebang

可以把 Shell Script 放到文本文件 prog 中,赋予 prog 可执行权限,并运行它: ./prog。当一个文本文件以 shebang 开头,它就能够被当作可执行文件执行。

顾名思义,shebang (#!) 就是 hash (#) 和 bang (!) 的组合。操作系统中的加载器(program loader) 看到 prog 前两个字符是 shebang 后,会分析其后的第一行的内容(通常是一个解释器程序 + 若干参数),转而执行它。在执行时,会额外将 prog 的路径作为参数加入参数列表。

举一个非典型但简单的例子,对于含有下面内容的文件 /home/alice/bin/runcat

#!/bin/cat
Hello world

运行 runcat 相当于运行 /bin/cat /home/alice/bin/runcat,会打印出 prog 文件本身的文本。

解释器程序会忽略 shebang

shebang 的妙处在于 prog 第一行的内容通常是一个解释器程序 X;而约定将 prog 的路径传入,相当于调用解释器 X 把 prog 当成脚本执行。推论:若如此做,prog 必须是 X 脚本。然而 prog 的第一行是 shabang,它未必是合法的 X 脚本内容。实际中,这个问题通常不影响使用,因为很多解释器如 sh 和 python 都会把 # 开头的行当作注释。

举一个利用这个特性的例子,对于含有下面内容的文件 /home/alice/bin/runpython

#!/usr/bin/python3
import time

current_time = time.time()
print(f"Seconds since the epoch: {current_time}")

运行 runpython,会调用 Python 解释器执行整个脚本,输出形如 “Seconds since the epoch: 1760813025.3144577” 。

shebang + env

在上个例子中,是否可以不硬编码 python3 的位置?可以,只要把第一行换成 #!/usr/bin/env python3

回顾 env 命令的语义,可以得知,runpython 会调用 /usr/bin/env python3 /home/alice/bin/runpython,执行 env,它会依据 PATH 找到 python3 的位置,转而调用 /usr/bin/python3 /home/alice/bin/runpython

回到 Shell Script,举一个完全同理的例子: /home/alice/bin/greet

#!/usr/bin/env bash
if ["$#" -ne 1]; then
  echo "Usage: $0 yourname"
  exit 1
fi
echo "Hello $1"

运行 greet Bob,同理分析,系统会用 bash 运行整个脚本,打印出 “Hello Bob”。

当我们使用不同 Shell 的扩展语法,并选择将 shell script 做成一个可执行文件时,可以用 Shebang + env 的方法指定它的运行环境,明确兼容性需求:使用 bash 的,标注 #!/usr/bin/env bash;使用 zsh 的,标注 #!/usr/bin/env zsh;符合 POSIX 标准的,标注 #!/bin/sh

放在系统配置文件中

这里记录两个 Bash 常用的配置文件:bash profile 和 bashrc。

bash_profile 位于 /etc/profile 等位置,每次用户登录 Shell 后得到执行。具体而言,依次执行 /etc/profile, ~/.bash_profile, ~/.bash_login, ~/.profile 中的内容。

bashrc 位于 ~/.bashrc 等位置,每次用户开启一个非登录的 Shell 后得到执行。具体而言,依次执行 /etc/bashrc, ~/.bashrc 中的内容

可以通过 echo $0 命令来判断自己是否位于登录 shell 中:

  • 输出 “-bash”,则为登录 Shell。登录的过程通常伴随输入密码等鉴权;在 Linux 下每次用 Ctrl + Alt + Fn 切换虚拟终端(Virtual Console,VC),或者在每开一个新的 WSL 窗口,要做一次登录,进入的都是登录 Shell。
  • 输出 “bash”,则为非登录 Shell。在 Linux 图形化桌面环境中用 Terminal 等桌面应用进入伪终端(PTY)、在 Shell 中执行 bash 进入嵌套的 Shell 等,进入的都是非登录 Shell。

一个反直觉的现象是你放在 ~/.bashrc 中的内容未必会在你登录系统后得到执行。为了避免这种反直觉的现象,默认配置中 ~/.bash_profile 或者 ~/.profile 通常会 source ~/.bashrc,让用户自定义的配置、别名和函数在第一次登录时就导入 shell 中。


Posted

in

by

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *