Shell编程
Shell 简介
- Shell 就是「外壳」:内核(kernel)负责真正干活(读写文件、分配内存、驱动网卡),但它不接受自然语言;Shell 是站在内核外面的那一层解释器,把你敲的每一行文字翻译成的系统调用,所以它是你和操作系统之间的中介。
- Shell 同时扮演两个角色,初学时最容易把它们混在一起:
- 交互式命令解释器:你敲一条它跑一条,跑完把提示符还给你。
- 脚本语言:把一堆命令写进一个文本文件,让它按顺序(带条件、带循环)自己跑完。
- 判断当前这个终端跑的是哪个 Shell,看
$SHELL和$BASH_VERSION就够了;$SHELL只是登录时记下的那个路径,不代表当前进程,所以下面两个一起看更准。1
2
3echo "$SHELL" # 登录 Shell 的路径,例如 /bin/bash
echo "$BASH_VERSION" # 有值说明当前进程就是 bash;空值说明不是 bash
ps -p $$ -o comm= # 直接问操作系统:当前进程的可执行文件叫什么 - 本文所有示例都在下面这台机器上逐条跑过,输出就是真实输出,不是抄来的:
- 环境:WSL2 里的 Ubuntu 26.04.1 LTS,
bash 5.3.9(1)-release (x86_64-pc-linux-gnu)。 - 一个容易忽略的细节:Ubuntu 26.04 的
ls、test、[、seq、expr这些命令已经换成 Rust 写的 uutils coreutils(seq --version输出seq (uutils coreutils) 0.8.0),不再是 GNU coreutils。本文涉及的用法两边行为一致,但个别冷门参数会有差异,看到报错先命令 --version确认是谁。
- 环境:WSL2 里的 Ubuntu 26.04.1 LTS,
Shell 类型
- Linux 上有多种 Shell,各有各的来历和侧重。常见的这些,只需要记住「日常写脚本用 bash,
sh在 Debian/Ubuntu 上是 dash 不是 bash」这一条,其余知道存在即可:- Bash(Bourne Again Shell):Bourne Shell 的 GNU 重写版,绝大多数 Linux 发行版的默认登录 Shell,本文全部按它写。
- Sh(Bourne Shell):最早的 Unix Shell。注意:现在发行版里的
/bin/sh通常已经不是真正的 Bourne Shell 了,而是一个只实现 POSIX 子集的轻量实现。 - Dash(Debian Almquist Shell):Debian/Ubuntu 里
/bin/sh的实际指向,追求启动快、体积小,因此系统的开机脚本都用它跑。 - Ksh(Korn Shell):把 C Shell 的交互特性和 Bourne Shell 的编程特性合到一起,很多高级特性(如关联数组)最早出自它。
- Csh(C Shell):语法像 C 语言,主要由 Bill Joy 做出,今天基本只用于历史兼容。
- Tcsh(TENEX C Shell):C Shell 的增强版,补上了命令行编辑和历史功能。
- Zsh(Z Shell):吸收了 bash、ksh、tcsh 的优点,补全和纠错更强,因此常被当作日常交互 Shell(配上 oh-my-zsh)。
- Fish(Friendly Interactive Shell):新式交互 Shell,默认就带语法高亮和自动建议,上手最省事,但脚本语法不兼容 POSIX。
- 本机实测「装了哪些、没装哪些」:输出:
1
2
3for s in bash sh dash rbash zsh fish csh tcsh ksh; do
printf '%-6s %s\n' "$s" "$(command -v "$s" || echo 未安装)"
done1
2
3
4
5
6
7
8
9bash /usr/bin/bash
sh /usr/bin/sh
dash /usr/bin/dash
rbash /usr/bin/rbash
zsh 未安装
fish 未安装
csh 未安装
tcsh 未安装
ksh 未安装 - 查看当前登录 Shell:本机输出
1
echo $SHELL
/bin/bash。 - 列出系统上可用的 Shell——原笔记写的
chsh -l在 Ubuntu 上是跑不通的,实测两条都报错:1
2chsh -l
chsh --list-shells1
2chsh: invalid option -- 'l'
chsh: unrecognized option '--list-shells'- 原因是 Ubuntu/Debian 的
chsh来自 shadow-utils,只认-s和-u;带--list-shells的是 util-linux 版chsh(RHEL 系常见)。 getent shells在本机也不行(Unknown database: shells)。所以最稳的办法就是直接看那个文件:本机输出:1
cat /etc/shells
1
2
3
4
5
6
7
8
9
10# /etc/shells: valid login shells
/bin/sh
/usr/bin/sh
/bin/bash
/usr/bin/bash
/bin/rbash
/usr/bin/rbash
/usr/bin/dash
/usr/bin/screen
/usr/bin/tmux/etc/shells是「合法登录 Shell 名单」:chsh、ftp、sshd都拿它做校验,不在名单里的路径换不上去。名单里出现screen、tmux是因为它们也能当登录程序用,不代表它们是 Shell。
- 原因是 Ubuntu/Debian 的
sh和bash不是一回事,这是新手最容易踩的坑:脚本里用 bash 的写法,却用sh去跑,就会报一些「明明能跑」的错。实测把 bash 语法交给 dash:1
2
3
4dash -c 'a=(1 2 3)' # 数组
dash -c '[[ 1 -eq 1 ]]' # 双方括号测试
dash -c 'let x=1+1' # let
dash -c 'function g { :; }' # function 关键字写法1
2
3
4dash: 1: Syntax error: "(" unexpected
dash: 1: [[: not found
dash: 1: let: not found
dash: 1: Syntax error: "}" unexpected- 反过来,
$((1+1))这种 POSIX 算术扩展在 dash 里是好的(输出2),所以「哪些写法是 POSIX 通用」要靠实测或查手册,不能凭 bash 的经验猜。 - 结论:脚本第一行写
#!/bin/bash就用bash跑,别用sh 脚本名去跑它。
- 反过来,
如何执行 Shell 脚本
- 一个脚本文件能不能「直接执行」,取决于两件事:有没有执行权限、第一行的 shebang 指向谁。下面四种方式的区别就在这一点上。
- 先造一个最小脚本:
1
printf '#!/bin/bash\necho "ran by shebang: $0"\n' > script.sh
- 方式一:给执行权限后直接跑。
1
2chmod +x script.sh
./script.sh1
ran by shebang: ./script.sh
./表示当前目录。必须带这个前缀,因为.不在PATH里(本机的PATH确实没有.);不带前缀写成script.sh,bash 会去PATH的目录里找一个叫script.sh的命令,找不到就报command not found。- 没有执行权限时直接跑,报的是权限错,不是语法错:
1
bash: line 21: ./s1.sh: Permission denied
- 方式二:显式叫某个解释器来跑,这时不需要执行权限,只需要读权限。
1
2
3chmod -x script.sh
bash script.sh
sh script.sh1
2ran by shebang: script.sh
ran by shebang: script.sh- 注意
$0变成了script.sh(没有./):$0就是「解释器收到的第一个参数」,写法不同值就不同。 - 这种方式下 shebang 会被忽略——
sh script.sh就是用 dash 跑,哪怕文件第一行写着#!/bin/bash。
- 注意
- 方式三:靠 shebang 让内核选解释器。
#!那行不是给 bash 看的,是给内核execve看的:内核看到#!/bin/bash,就等价于执行/bin/bash ./script.sh。- shebang 换成别的程序,同一个文件就换个解释器跑,实测:
1
2
3
4
5
6printf '#!/usr/bin/python3\nprint("ran by python shebang")\n' > p1.py
chmod +x p1.py
./p1.py
printf '#!/bin/dash\necho "dash 里 BASH_VERSION=[$BASH_VERSION]"\n' > d1.sh
chmod +x d1.sh
./d1.sh1
2ran by python shebang
dash 里 BASH_VERSION=[]d1.sh那行输出为空,正说明它真的由 dash 执行,而不是被 bash 顺手接走。 - 没有 shebang 但给了执行权限时,内核认不出解释器,会退化成「让调用你的那个 Shell 自己读这个文件」,所以实际用哪个 Shell 取决于你在哪跑它。实测在 bash 里跑无 shebang 的
n1.sh能出结果:1
2
3printf 'echo "没有 shebang 但可执行: $0"\n' > n1.sh
chmod +x n1.sh
./n1.sh1
没有 shebang 但可执行: ./n1.sh
- shebang 换成别的程序,同一个文件就换个解释器跑,实测:
- 方式四:放进
PATH里的目录,之后在任何目录裸名字调用。1
2
3
4mkdir -p ~/bin
cp n1.sh ~/bin/mytool
export PATH="$PATH:$HOME/bin"
mytool1
没有 shebang 但可执行: /home/loong/bin/mytool
- 改完
PATH后如果同名命令还没生效,可以用hash -r清掉 bash 缓存的命令位置。
- 改完
- 注意事项:
- 脚本第一行写 shebang,写
#!/bin/bash而不是#!/bin/sh(除非你真的只用了 POSIX 写法)。 - 想调试可以用两个开关:
bash -n script.sh只检查语法不执行(本机输出「语法 OK」),bash -x script.sh把每条命令展开后打印再执行,看变量到底被替换成了什么最有用。 - 脚本从别的机器拷过来跑不起来,八成是两件事:行尾是 CRLF,或者少了执行权限。CRLF 的报错很典型,本机实测(
printf '#!/bin/bash\r\necho hi\r\n' > crlf.sh之后执行):1
bash: ./crlf.sh: /bin/bash^M: bad interpreter: No such file or directory
- 意思是内核拿着
#!/bin/bash\r这一整串去找解释器,当然找不到。修一行就好:sed -i 's/\r$//' crlf.sh(dos2unix更直观,但本机没装,需要sudo apt install dos2unix)。
- 意思是内核拿着
- 脚本第一行写 shebang,写
Shell 变量
-
简介
- Shell 变量就是一个「名字 → 字符串」的映射。Shell 里所有变量的值本质上都是字符串,
n=10存的是字符1和0,不是整数;这也是为什么后面「运算」一节要专门讲算术扩展。 - 变量名规则:字母和下划线开头,后面跟字母、数字、下划线;不能以数字开头,且区分大小写。写错的报错长这样:
1
1a=x
注意报的是1
bash: line 46: 1a=x: command not found
command not found而不是「变量名非法」——因为1a=x不是一个赋值,bash 直接把它当命令去执行了。
- Shell 变量就是一个「名字 → 字符串」的映射。Shell 里所有变量的值本质上都是字符串,
-
设置变量
- 用等号赋值,等号两侧不能有空格:
1
my_variable="Hello, World!"
- 加了空格就不是赋值了,bash 会拿第一个词当命令名去找,实测:
1
a = 1
这是新手最高频的报错之一,看到1
bash: line 45: a: command not found
xxx: command not found而xxx恰好是你的变量名,先检查等号旁边的空格。 - 值里没空格时引号可以省(
x=abc),有空格就必须加(x="a b"),否则x=a b会被拆成「赋值x=a」加「命令b」。
- 用等号赋值,等号两侧不能有空格:
-
访问变量
- 取值时在名字前加
$:1
echo $my_variable
${name}是等价的写法,加花括号是为了把变量名和后面紧贴的文字隔开,让解释器知道名字到哪结束:1
2v=World
echo "Hello, ${v}!"1
Hello, World!
- 如果写成
echo "$v!",bash 会去找一个叫v!的变量吗?不会——!不是合法变量名字符,所以这里恰好没事。但echo "$v1"就真会去找v1了,这种时候必须用${v}1。
- 如果写成
- 取一个不存在的变量不报错,只给空串:
1
echo "[$NOPE]"
脚本里这很危险(变量名打错,路径展开成空串,1
[]
rm -rf "$DIR"/就可能变成rm -rf /),所以正式脚本开头常写set -u,让未定义变量直接报错:1
bash -c 'set -u; echo "$NOPE"'
1
bash: line 1: NOPE: unbound variable
- 取值时在名字前加
-
环境变量
-
查看全部环境变量
env和printenv都能列出全部环境变量,本机两者的输出行数一样(23 行):1
2env
printenv- 区别在用法:
printenv 变量名能单独取一个值,env不行;env还能「带着指定变量去执行一条命令」,比如env PATH=/usr/bin:/bin ls,这在调试PATH时很好用。 - 取一个不存在的变量时
printenv的退出码是 1,可以用它当判断:1
printenv NOPE_XYZ > /dev/null 2>&1; echo $?
1
1
- 区别在用法:
-
打印指定环境变量
1
2echo $PATH
echo ${PATH}PATH是一个用冒号分隔的目录列表,你在终端敲ls时,bash 就是照着这个列表挨个目录去找叫ls的可执行文件。Windows 的PATH用分号分隔,这是两边唯一的形式差别。- 因为「按
PATH找命令」这个机制,当前目录默认不在PATH里,所以上一节才要写./script.sh。
-
修改环境变量
给
PATH追加一个目录(这是最常见的改法,追加而不是覆盖):1
export PATH=$PATH:/new/path
实测追加后最后一个字段就是新加的:
1
echo "${PATH}" | rev | cut -d: -f1 | rev
1
/new/path
- 千万别写成
PATH=/new/path,那会把整个PATH覆盖掉,之后连ls、cat都找不到。万一覆盖了,在当前会话里手工补回来即可:export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin。
- 千万别写成
-
设置新的环境变量
1
export MY_VARIABLE="my_value"
export干的事是把这个变量标成「环境(environment)」属性,这样子进程(新起的 bash、任何外部程序)能继承到它;不加export就只是当前 Shell 的局部变量。实测对比很明显:1
2
3
4export MY_VARIABLE="my_value"
plain=onlylocal
bash -c 'echo "${MY_VARIABLE:-空}"'
bash -c 'echo "${plain:-空}"'1
2my_value
空
-
持久化环境变量
-
export的效果只在当前会话里,关掉终端就没了。要长期生效,得把命令写进 Shell 的启动文件里:1
2echo 'export MY_VARIABLE="my_value"' >> ~/.bashrc
source ~/.bashrc -
该写哪个文件,取决于你用哪种方式打开终端(这是新手最容易搞混的一组概念):
打开方式 算哪种 Shell 读哪些启动文件 图形界面里开终端窗口 交互式非登录 ~/.bashrcssh登录、tty1 登录交互式登录 ~/.profile(Ubuntu 上它会再去读~/.bashrc)脚本、cron、systemd 起进程 非交互 基本都不读,所以脚本里要自己 export所有用户共享 — /etc/environment(KEY=VALUE格式,不支持export语法)与/etc/profile.d/*.sh -
source file(等价写法. file)的作用是「在当前 Shell 里执行 file 的内容」,而不是另起一个子 Shell,所以里面export的变量能留在当前会话。实测:1
2
3echo 'x=1' > /tmp/env.sh
source /tmp/env.sh
echo "$x"1
1
-
-
删除环境变量
1
2unset MY_VARIABLE
echo "${MY_VARIABLE:-已删除}"1
已删除
-
-
位置参数
- 脚本或函数被调用时带上的参数,用
$1、$2、$3依次取,$0是脚本名本身。 - 下面这段完整可跑,把常用的都一次打出来:用
1
2
3
4
5
6
7
8
9
10
# 文件名:pos.sh
echo "0(脚本名)=$0"
echo "1=$1 2=$2 3=$3"
echo "#=$#"
echo "$* 不加引号: $*"
echo "$@ 不加引号: $@"
echo "带引号计数 \"$*\" -> $(printf '%s\n' "$*" | wc -l)"
echo "带引号计数 \"$@\" -> $(printf '%s\n' "$@" | wc -l)"
echo "$$=$! $?"./pos.sh "a b" c d执行,实测输出:1
2
3
4
5
6
7
80(脚本名)=./pos.sh
1=a b 2=c 3=d
#=3
a b c d 不加引号: a b c d
a b c d 不加引号: a b c d
带引号计数 "a b c d" -> 1
带引号计数 "a b c d" -> 3
$$=13289 $!= $?=0$1拿到的是a b(带空格的完整一个参数),因为调用时加了引号,引号内的空格不会被当成参数分隔。$#是 3,不是 4——三个参数,第三个$3是d。- 倒数第四、五行就是
$*与$@的全部区别:不加引号时两者一样;一旦套上双引号,"$*"把所有参数并成一个词(wc -l得 1),"$@"保持每个参数独立(得 3)。
- 参数超过 9 个以后
$10是错的(它等于${1}0),要写${10}。 shift把参数整体往前挪一位,常用于手写参数解析:传1
2
3
4
echo "第1轮: $1 $#=$#"
shift
echo "shift 后: $1 $#=$#"one two three实测:1
2第1轮: one $#=3
shift 后: two $#=2
- 脚本或函数被调用时带上的参数,用
-
特殊变量
变量 含义 本机实测值( ./pos.sh "a b" c d)$0脚本名(用 bash x.sh跑时是x.sh,管道喂给bash -s时是bash)./pos.sh$#参数个数 3$1…$9第 1 到第 9 个参数 a b、c、d$*全部参数;不加引号时与 $@同,加引号后并成一个词"a b c d"(1 个词)$@全部参数;加引号后仍是各自独立的词 3 个词 $$当前 Shell 进程的 PID 13289$!最近一个放到后台运行的命令的 PID;没有后台任务就是空 空 $_上一条命令的最后一个参数 — $?上一条命令的退出状态; 0成功,非0失败0$?只保留「最近一条」,所以要用就得立刻取走:1
2
3ls /nope 2>/dev/null
code=$?
echo "刚才的退出码是 $code"1
刚才的退出码是 2
- 退出码是 0~255 的一个字节,
ls找不到文件是 2、命令找不到是 127、被信号N杀死是128+N(Ctrl-C是 130)。
-
变量扩展
- Shell 提供了一整套
${name...}写法来加工变量值,本机全部实测过:1
2
3
4
5
6
7
8
9
10
11
12
13s="Hello, World!"
echo "${#s}" # 值的长度 -> 13
echo "${s:7}" # 从下标 7 取到结尾 -> World!
echo "${s:0:5}" # 从下标 0 取 5 个 -> Hello
echo "${s: -6}" # 倒数 6 个字符 -> World!(冒号后必须有空格,否则 -6 会被当成算术)
echo "${s/World/Bash}" # 替换第一个匹配 -> Hello, Bash!
echo "${s//l/L}" # 替换全部匹配 -> HeLLo, WorLd!
echo "${s^^}" # 全部转大写 -> HELLO, WORLD!
echo "${s,,}" # 全部转小写 -> hello, world!
echo "${s#Hello, }" # 从头部删最短匹配 -> World!
echo "${s%!}" # 从尾部删最短匹配 -> Hello, World
echo "${s##*l}" # 从头部删最长匹配 -> d!
echo "${s%%l*}" # 从尾部删最长匹配 -> He${s: -6}里的空格是必需的:${s:-6}是另一回事(「s 为空时用 6 兜底」)。- 删前缀/后缀的四个记法可以这样记:
#从头删、%从尾删,双写(##/%%)就是贪婪匹配。文件名去扩展名就靠它们:1
2
3file="image.png"
echo "${file%.*}" # image
echo "${file##*.}" # png
- 给未定义或空变量兜底、以及顺带赋值:实测第三条的报错文本:
1
2
3echo "${NOPE:-兜底}" # 只影响这一次展开 -> 兜底
echo "${NOPE2:=写了}" # 顺便把值写回变量 -> 写了
echo "${NOPE3:?必须给值}" # 变量为空就直接报错退出1
bash: line 87: NOPE3: 必须给值
- 注意
:-、:=、:?会「在变量为空时」触发;只想管「未定义」而不管空串,用没有冒号的-、=、?(${NOPE-兜底})。 ${NOPE:=写了}在交互式命令行上会直接改到你的会话里,脚本里用它做「带默认值的配置项」很常见,但别把它当普通取值用。
- 注意
- Shell 提供了一整套
-
只读变量
- 用
readonly把变量锁死,相当于常量:1
2
3ro=const
readonly ro
readonly -p | grep 'ro='1
declare -r ro="const"
- 改它、删它都会报错,实测原文如下:
1
2ro=changed
unset ro1
2bash: line 11: ro: readonly variable
bash: line 13: unset: ro: cannot unset: readonly variable- 报错里出现的变量名就是你锁的那个名字(
ro)。原笔记注释里写的bash: constant: readonly variable是把「常量」这个概念误当成了变量名,照抄会以为自己的 bash 有问题。 - 赋值失败后变量保持原值,实测
echo "$ro"仍是const。
- 报错里出现的变量名就是你锁的那个名字(
- 一个容易忽略的点:
readonly是不跨进程的。子 Shell 里同名变量随便改:1
bash -c 'ro=changed; echo "$ro"'
所以别指望用它来「保护传给子进程的配置」。1
changed
- 用
-
取消变量
unset既能取消普通变量,也能取消函数(unset -f 名字):之后1
unset my_variable
my_variable就不存在了,取值得到空串。- 唯一取消不掉的是只读变量,见上一小节。
单、双、反引号的区别
-
单引号
- 单引号里的东西一律当字面量,不展开变量、不执行命令、不解释转义。
1
echo '$HOME'
1
$HOME
- 代价是单引号里没法出现单引号本身,写
echo 'it's'直接是语法错,实测:1
q1.sh: line 1: unexpected EOF while looking for matching `''
- 要输出带单引号的文字,标准做法是用反斜杠把引号「接出去」:
echo 'it'\''s'输出it's(拆开看是'it'+\'+'s'三段拼起来)。
- 要输出带单引号的文字,标准做法是用反斜杠把引号「接出去」:
- 单引号里的东西一律当字面量,不展开变量、不执行命令、不解释转义。
-
双引号
- 双引号里大部分字符是字面量,但
$、反引号、\、!(交互式下)这几个仍有特殊意义,所以变量展开和命令替换照常生效:1
2echo "$HOME"
echo "The date is $(date)" - 双引号能挡住通配符展开,这是它和「不加引号」最实用的区别:
1
2
3
4touch x1.txt x2.txt
echo *.txt
echo "*.txt"
echo '*'1
2
3x1.txt x2.txt
*.txt
* - 双引号里想写一个字面的
$,用\$:1
echo "Value of \$HOME is $HOME"
1
Value of $HOME is /home/loong
- 双引号里也不会把
\n变成换行,echo "a\nb"原样输出a\nb;要换行用printf 'a\nb\n'或echo -e(echo -e不是 POSIX,dash 下行为不同,脚本里推荐printf)。实测:1
2echo "a\nb" | tr '\n' '/'
printf 'a\nb\n' | tr '\n' '/'1
2a\nb/
a/b/ - 空格分词也是引号的活:
1
2set -- a b "c d"
echo "参数个数=$# 第三个=$3"1
参数个数=3 第三个=c d
- 双引号里大部分字符是字面量,但
-
反引号与命令替换
- 反引号里的内容会被当作命令执行,输出替换到原位:
1
echo `date +%F`
1
2026-11-03
- 现代写法是
$(),效果相同但可读性和嵌套能力更好:三条都正常,最后一条能嵌套成功是因为1
2
3echo $(date +%F)
echo "$(basename "$(pwd)")"
echo $(basename $(pwd))$()可以套$();同样逻辑用反引号就得写成`basename \`pwd\`` 这种要转义的怪样子,很容易出错。 - 反引号在双引号里仍然会执行(这跟单引号不同):
1
echo "值: $(echo 6*7 | bc)"
1
值: 42
- 命令替换拿到的是命令的标准输出,stderr 不会被捕获。所以
result=$(某命令)里如果命令报错,错误信息会直接打到屏幕上,result却是空串——判断成败要用$?,别只看结果是否为空。
- 反引号里的内容会被当作命令执行,输出替换到原位:
-
反斜线
- 反斜线主要有四个用途,逐个实测:
- 转义:去掉后面那个字符的特殊含义。
echo "\$HOME"输出$HOME,echo "a b"与echo a\ b都能表示带空格的单个词。 - 续行:行尾的
\让 Shell 忽略换行,把下一行接上来。注意\后面不能有任何字符(连空格都不行,否则它转义的是那个空格而不是换行):1
2echo "This is a very long line, \
split over two lines."1
This is a very long line, split over two lines.
- 在
sed/awk/printf里表示字符:\n换行、\t制表符、\.匹配字面点号。这是那些工具自己解释的,不是 Shell 解释的——所以才有「Shell 层写\\n,让工具收到\n」这种双层写法。 - 正则里引用下一个字符:
\.表示点本身,[0-9]里的-用\-保平安。
- 转义:去掉后面那个字符的特殊含义。
- 因为
\对 Shell 本身也是特殊字符,想输出一个字面反斜杠要写两个:echo 'a\b'输出a\b(单引号里\是字面的),echo "a\\b"也输出a\b。
- 反斜线主要有四个用途,逐个实测:
字符串操作
下面每一条都是「先说这是什么、再给能整段复制就跑的示例、最后附上本机真实输出」。Shell 里字符串就是字节序列,没有专门的字符串类型,所以这些操作全都长在「参数扩展」和几个小工具命令上。
-
字符串拼接
直接把两段放在一起就行,Shell 会自动连着展开:
1
2
3
4str1="Hello,"
str2=" World!"
greeting="${str1}${str2}"
echo "$greeting"1
Hello, World!
- 这里的花括号不是必需的,但加上更保险:
$str1_x会被理解成变量str1_x,${str1}_x才是「str1 后面接_x」。
- 这里的花括号不是必需的,但加上更保险:
-
获取字符串长度
用
${#字符串变量名},注意#后面直接跟变量名、不加$:1
2str="Hello"
echo ${#str}1
5
- 它数的是字符数还是字节数取决于当前 locale:
LANG=C.UTF-8下str="中文"得 2,LANG=C下得 6(UTF-8 一个汉字 3 字节)。所以处理中文时先确认 locale,本机是C.UTF-8。
- 它数的是字符数还是字节数取决于当前 locale:
-
字符串切片
用
${字符串:起始位置:长度},起始位置从 0 开始数:1
2
3
4str="Hello, World!"
echo ${str:7} # 从下标 7 一直取到结尾
echo ${str:7:5} # 从下标 7 取 5 个字符
echo ${str: -6} # 倒数 6 个字符1
2
3World!
World
World!str的值是Hello, World!,下标 7 正好是W,所以${str:7:5}得到World。- 倒数写法
${str: -6}里冒号和减号之间必须有空格(或者写成${str:0-6}),因为${str:-6}已经被「默认值」语法占了。
-
查找子字符串
- Shell 自己的参数扩展里没有「找位置」的操作,要查位置得用外部命令
expr或grep。expr的index和match分别用于「找字符位置」和「正则匹配开头」。 expr index "$字符串" "$字符集"返回的是字符集里任意一个字符在字符串中最早出现的位置,从 1 开始计数;一个都没有则返回 0。1
2
3
4str="Hello, World!"
expr index "$str" "W"
expr index "$str" "lo"
expr index "$str" "z"1
2
38
3
0W在Hello, World!的第 8 个位置(从 1 数),所以第一条得 8。- 第二条得 3 是因为
l出现在第 3 位,比o的第 5 位更早——它是「字符集合」而不是「子串」,把expr index "$str" "lo"当成「找lo这个子串」是常见误解。
expr match "$字符串" "$正则"(等价写法expr "$字符串" : "$正则")只从字符串开头匹配,返回匹配到的长度,不匹配返回 0。1
2
3
4str="Hello, World!"
expr match "$str" '.*World'
expr match "$str" '^Hello'
expr match "$str" 'World'1
2
312
5
0- 第一条匹配到的是
Hello, World,长度 12(Hello,是 7 个字符,加World5 个)。原笔记这里写的是 13,数错了。 - 第三条得 0,正因为
expr match只认开头;想匹配中间得把正则写成.*World。
- 第一条匹配到的是
- 注意事项:
expr的正则是不带+、?、|的基础正则(BRE),要写「一个或多个」得用\{1,\}或者转义。expr里*、(、)这些字符对 Shell 有特殊含义,得转义或加引号。- 做算术时
expr要求运算符两侧都有空格,expr 2+2不会报错,而是把2+2当字符串原样输出(实测输出2+2,退出码 0)——这种「不报错但不算」的行为比直接报错更难查。
- 现在更常用的是 bash 自带的
[[ =~ ]]和参数扩展,expr主要出现在老脚本里:1
2
3str="Hello, World!"
[[ $str =~ World ]] && echo 匹配成功
[[ "abc123" =~ ([a-z]+)([0-9]+) ]] && echo "${BASH_REMATCH[1]}+${BASH_REMATCH[2]}"1
2匹配成功
abc+123- 命中的分组会自动存进数组
BASH_REMATCH:${BASH_REMATCH[0]}是整体匹配,[1]、[2]是各捕获组。 =~右边的正则不要加引号,加了引号就被当字面字符串。实测:1
2[[ abc123 =~ [0-9]+ ]] && echo 不加引号:匹配
[[ abc123 =~ "[0-9]+" ]] || echo 加引号:不匹配1
2不加引号:匹配
加引号:不匹配
- 命中的分组会自动存进数组
- Shell 自己的参数扩展里没有「找位置」的操作,要查位置得用外部命令
-
替换字符串
${字符串/被替换/替换成}只换第一个匹配,${字符串//被替换/替换成}换全部。1
2
3str="Hello, World!"
echo ${str/World/Shell}
echo ${str//l/!}1
2Hello, Shell!
He!!o, Wor!d!- 想从头或从尾锚定替换,在模式前加
#(必须匹配开头)或%(必须匹配结尾):
1
2
3str="Hello, World!"
echo ${str/#Hello/Hi} # Hi, World!(开头匹配才换)
echo ${str/%\!/X} # Hello, WorldX(结尾匹配才换)1
2Hi, World!
Hello, WorldX -
删除子字符串
- 四个记法的分工:
${字符串#模式}:从开头删,删掉最短匹配。${字符串##模式}:从开头删,删掉最长匹配。${字符串%模式}:从结尾删,删掉最短匹配。${字符串%%模式}:从结尾删,删掉最长匹配。
- 模式里可以用通配符,这是它和「替换」最大的不同:
1
2
3
4file="image.png"
echo ${file%.*} # 从尾部删最短的 .* -> 去掉扩展名
echo ${file##*.} # 从头部删最长的 *. -> 只留扩展名
echo ${file#*.} # 从头部删最短的 *.1
2
3image
png
png- 处理「多级扩展名」时
#和##的差别就出来了:1
2
3
4
5name="archive.tar.gz"
echo ${name%.*} # archive.tar(从尾删最短匹配,只去掉最后一段)
echo ${name%%.*} # archive(从尾删最长匹配,一路删到第一个点)
echo ${name#*.} # tar.gz(从头删最短匹配,去掉第一个点之前)
echo ${name##*.} # gz(从头删最长匹配,只剩最后一段)1
2
3
4archive.tar
archive
tar.gz
gz
- 处理「多级扩展名」时
- 四个记法的分工:
-
转化大小写(需要 Bash 4.0 及以上版本)
1
2
3
4str="Hello World!"
echo ${str,,} # 全小写
echo ${str^^} # 全大写
echo ${str^} # 只有首字母大写1
2
3hello world!
HELLO WORLD!
Hello World!- 单个符号(
^/,)只管第一个字符,双写管全部。这是 bash 的扩展,dash 里没有。
- 单个符号(
-
比较字符串
==和!=用在[[ ]]里,=用在[ ]里:1
2
3
4
5
6
7str1="hello"
str2="world"
if [[ $str1 == $str2 ]]; then
echo "Strings are equal."
else
echo "Strings are not equal."
fi1
Strings are not equal.
- 大小写敏感;想让
case/[[ == ]]忽略大小写,先shopt -s nocasematch(实测case ABC in abc)能命中)。 - 按字典序比大小要用
[[ 字符串 < 字符串 ]],且只能在[[ ]]里裸写([ ]里的<是重定向,见「运算分类」一节)。实测[[ "abc" < "def" ]]为真,[[ "ABC" < "abc" ]]也为真(大写在前)。
- 大小写敏感;想让
-
字符串截断(
cut)cut是按列切文本的命令,常用来从一行里抠出某一段。三个常用选项:-d:指定字段分隔符,默认是制表符。-f:选第几个字段,编号从 1 开始。-c:按字符位置选,可以写范围。
- 用
-d和-f:想从name:john,age:30里取john,先用冒号切再逗号切:1
2
3str="name:john,age:30"
name=$(echo "$str" | cut -d':' -f2 | cut -d',' -f1)
echo "$name"1
john
- 第一个
cut用冒号切出第二段,得到john,age;第二个cut用逗号切出第一段,得到john。 - 分隔符不是制表符时必须显式给
-d,否则cut -f2会把整行当一个字段,什么都不输出。
- 第一个
- 用
-c按字符位置切:1
2str="12345"
echo "$str" | cut -c2-41
234
-c2-4是「从第 2 个字符到第 4 个」,两端都含。还可以写-c-3(前三个)、-c3-(第三个到结尾)。
cut在处理 CSV、日志这类固定分隔符的文本时很顺手,但它不理解引号,字段里带逗号就会切错,那种场景该用awk。- 顺带一提,字符串处理还常配这几个小工具,本机实测:
1
2
3
4
5
6
7echo "a🅱c" | cut -d: -f2 # b
echo "Hello" | tr 'a-z' 'A-Z' # HELLO
echo "a1b2c3" | tr -d '0-9' # abc
echo "Hello" | sed 's/Hello/Hi/' # Hi
echo "Hello" | awk '{print length}' # 5
echo "Hello" | rev # olleH
echo " a b " | xargs # "a b"(首尾空格去掉,中间空格保留)
数组
先说结论:bash 的数组是稀疏的整数下标数组,从 0 开始,可以跳着赋值;想要「字符串当键」得用 declare -A 声明的关联数组。dash 完全没有数组(实测 dash -c 'a=(1 2 3)' 报 Syntax error: "(" unexpected)。
-
定义数组
用圆括号,元素之间用空格分开:
1
array=(element1 element2 element3)
- 元素本身带空格必须加引号:
arr=("first element" "second element"),否则会被拆成两个元素。 - 也可以直接指定下标,跳号也行:实测就是上面三行注释里的值——下标不连续、访问没赋过的下标得到空串,都不报错。
1
2
3
4sparse=([2]=c [10]=k)
echo "${sparse[@]}" # c k
echo "${!sparse[@]}" # 2 10
echo "${#sparse[@]}" # 2
- 元素本身带空格必须加引号:
-
访问数组元素
用
${数组名[下标]},下标从 0 开始:1
2echo ${array[0]} # 第一个元素
echo ${array[1]} # 第二个元素1
2element1
element2- 只写数组名
${array}等价于${array[0]},实测arr2=(a b c); echo ${arr2}输出a。这个「看起来像拿到整个数组」的写法是隐藏坑。 - 下标是算术表达式,所以
${array[1+1]}取的是第 3 个元素。
- 只写数组名
-
获取全部数组元素
@和*都能展开全部元素:1
2echo ${array[@]} # element1 element2 element3
echo ${array[*]} # element1 element2 element3- 不加引号时两者完全一样,区别只在加双引号之后:
"${array[@]}":每个元素保持独立一个词,元素内部的空格不会被拆开。"${array[*]}":所有元素并成一个词,中间用IFS的第一个字符(默认空格)连接。
- 实测对比:
1
2
3
4
5
6
7
8
9
10
array=("first element" "second element" "third element")
echo "Using [@]:"
for item in "${array[@]}"; do
echo "$item"
done
echo "Using [*]:"
for item in "${array[*]}"; do
echo "$item"
done1
2
3
4
5
6Using [@]:
first element
second element
third element
Using [*]:
first element second element third element[@]循环了 3 次,[*]只循环 1 次,这就是全部区别。
- 传参给函数或命令时同理,实测:
1
2
3
4f() { echo "函数里 $# 个参数: $1 | $2 | $3"; }
arr2=(a b c)
f ${arr2[@]} # 3 个参数
f "${arr2[*]}" # 1 个参数,值是 "a b c"1
2函数里 3 个参数: a | b | c
函数里 1 个参数: a b c | | - 遍历时永远写
"${array[@]}"(带引号),不带引号遇到含空格的元素就会把元素拆开:1
2
3sp=("one two" three)
for x in ${sp[@]}; do echo "[$x]"; done # [one] [two] [three] 三次
for x in "${sp[@]}"; do echo "[$x]"; done # [one two] [three] 两次
- 不加引号时两者完全一样,区别只在加双引号之后:
-
获取数组的长度
1
2echo ${#array[@]} # 元素个数
echo ${#array[0]} # 第 0 个元素的字符串长度实测
array=(element1 element2 element3)时分别是3和8。- 稀疏数组里
${#array[@]}数的是实际存在的元素个数,不是最大下标加一。
- 稀疏数组里
-
修改数组元素
直接按索引赋值:
1
array[0]=newElement1
实测改完
echo ${array[0]}得到newElement1。 -
添加数组元素
追加用
+=,或者干脆指定一个越界下标:1
2array+=(newElement) # 尾部追加
array[100]=far # 直接写下标,中间的下标留空实测
(element1 element2 element3)追加后变成 4 个元素:newElement1 element2 element3 newElement。 -
删除数组元素
用
unset,但下标要写成不带$的形式(unset 'array[1]'或unset array[1]):1
unset array[1] # 正确:删掉下标 1
- 原笔记这里有个坑值得单独说:写成
unset ${array[1]}会被先展开成unset element2,于是删掉的是一个名字叫element2的普通变量,数组纹丝不动。实测:1
2
3
4
5array=(element1 element2 element3)
array+=(newElement)
unset ${array[1]} # 想删 element2
echo "${array[@]}" # 四个元素一个没少
echo "${!array[@]}" # 0 1 2 31
2newElement1 element2 element3 newElement
0 1 2 3 - 删对了以后,下标不会重新排列,被删的那个位置直接空出来:
1
2
3unset array[1]
echo "${array[@]}" # 剩下的元素
echo "${!array[@]}" # 剩下的下标1
2newElement1 element3 newElement
0 2 3 - 想紧凑重排只能自己重建:
array=("${array[@]}")。 - 整个数组删掉:
unset array(不带下标)。
- 原笔记这里有个坑值得单独说:写成
-
遍历数组
1
2
3
4for elem in "${array[@]}"
do
echo "$elem"
done- 用
"${array[@]}"才能保证「一个元素一次循环」,理由见上面那节。
- 用
-
切割数组
${array[@]:起始下标:个数},取出来还是一串元素:1
echo ${array[@]:1:2} # 从第二个元素开始取 2 个
实测
array=(newElement1 element2 element3 newElement)时输出element2 element3。 -
关联数组(类似其他语言里的字典)
Bash 4.0 引入,用字符串当键。必须先
declare -A,否则assoc=([name]=john)会被当成普通数组,name被当作算术表达式算成 0,值悄悄落到下标 0 上,实测不报错:1
2
3
4
5
6
7
8declare -A assoc
assoc=([name]=john [age]=30)
assoc[city]=Beijing
echo ${assoc[name]} # john
echo ${#assoc[@]} # 3
echo "${!assoc[@]}" # 键的列表(顺序不保证)
unset 'assoc[age]' # 删一个键,注意引号
echo ${#assoc[@]} # 21
2
3
4john
3
age city name
2${!assoc[@]}列出所有键,实测顺序是age city name(按哈希表内部顺序,不是插入顺序),别依赖它。- 取一个不存在的键得到空串,不报错;想区分「值为空」和「键不存在」用
${assoc[nope]-不存在}。
seq
-
简介
seq用来生成等差数列,一行一个数。写循环、造测试数据、给文件编号时特别省事。- 参数顺序是
seq [选项] [起始 步长] 结束:给两个数是「起始 结束」,给三个数是「起始 步长 结束」,只给一个数等价于seq 1 那个数。 - 本机
seq --version是seq (uutils coreutils) 0.8.0(Ubuntu 26.04 已换成 Rust 实现),下面这些用法与 GNU 版一致。
-
生成一个简单的数列
1
seq 1 10
从 1 到 10,每行一个:
1
2
3
4
5
6
7
8
9
101
2
3
4
5
6
7
8
9
10 -
指定一个增量(步长)
1
seq 1 2 10
从 1 开始、每次加 2、不超过 10:
1
2
3
4
51
3
5
7
9- 到 9 之后下一个是 11,超过 10 就不输出,所以结尾不一定是你给的那个数。
-
生成倒序数列
1
seq 10 -1 1
步长给负数就是倒数:
1
2
3
4
5
6
7
8
9
1010
9
8
7
6
5
4
3
2
1- 反过来写
seq 5 1 1(起点大于终点却给正步长)不报错,直接输出空、退出码 0。这种「静默空输出」在脚本里最难查,倒序一定记得步长是负的。
- 反过来写
-
使用固定的宽度格式化数字
1
seq -w 0 5 50
-w按最大的那个数的位数补零对齐:1
2
3
4
5
6
7
8
9
10
1100
05
10
15
20
25
30
35
40
45
50- 给文件批量编号(
img01.jpg、img02.jpg)就靠它。想固定成更宽可以写seq -f "%04g" 1 10。
- 给文件批量编号(
-
将序列输出到一个文件
1
2seq 1 100 > numbers.txt
wc -l numbers.txt实测文件里正好 100 行。
-
生成浮点数序列
1
seq 0.5 0.25 2
1
2
3
4
5
6
70.50
0.75
1.00
1.25
1.50
1.75
2.00- 小数位数由输入里最长的那个小数决定,所以
0.5会被写成0.50。
- 小数位数由输入里最长的那个小数决定,所以
-
常用选项与和循环的配合
1
2
3seq -s, 1 5 # -s 自定义分隔符,输出压成一行
seq 3 # 只给一个参数,等价于 seq 1 3
for i in $(seq 1 5); do printf '%s,' "$i"; done1
2
31,2,3,4,5
1,2,3
1,2,3,4,5,- 现代 bash 里定长整数循环可以直接用花括号展开
{1..5},不需要seq;但{1..5}是 Shell 在解析时展开的,边界是变量时不能用,这时必须用seq:1
2
3n=5
for i in {1..$n}; do echo "第 $i 个"; done # 错:原样输出 {1..5}
for i in $(seq 1 $n); do echo "第 $i 个"; done # 对
- 现代 bash 里定长整数循环可以直接用花括号展开
expr
-
简介
expr是一个独立的外部命令(本机/usr/bin/expr,属于 uutils coreutils),不是 bash 的关键字。它把参数拼成表达式求值,把结果打到标准输出。- 用它必须记住两件事:
- 运算符两侧都要有空格。
expr 5 + 3得 8;expr 5+3不报错,直接把5+3当字符串原样输出,退出码还是 0。 - 对 Shell 有特殊含义的字符要转义:
*、(、)、<、>。不转义的话*会先被通配符展开。
- 运算符两侧都要有空格。
- 退出码规则容易忽略:结果为
0或空串时退出码是 1,非零非空是 0,语法/参数错误是 2。所以expr偶尔能当判断用,但更推荐test。
-
基本数学运算
1
expr 5 + 3
1
8
-
整数乘法
*是通配符,必须转义:1
expr 5 \* 3
1
15
- 不转义会怎样?实测在有一个文件的目录里跑
expr 5 * 3,*先被展开成文件名:换成1
expr: syntax error: unexpected argument 'numbers.txt'
expr 5 '*' 3或expr "5 * 3"也能避免。
- 不转义会怎样?实测在有一个文件的目录里跑
-
整数除法
1
expr 10 / 2
1
5
- 只支持整数,
expr 7 / 2得3,小数部分直接砍掉(向零取整)。
- 只支持整数,
-
整数取模(求余数)
1
expr 10 % 3
1
1
-
比较运算
比较成立输出
1,不成立输出0。<、>要转义,否则被当成重定向:1
2
3expr 5 \< 10
expr 5 = 5
expr 5 \> 101
2
31
1
0 -
字符串长度
1
expr length "Hello World"
1
11
-
字符串索引
找字符集合里最早出现的位置,从 1 开始:
1
expr index "Hello" l
1
3
-
字符串截取
1
expr substr "Hello World" 1 5
1
Hello
- 第一个参数是起始位置,
expr里位置从 1 开始数(跟${str:0:5}从 0 开始不一样,这是两套 API 的历史包袱)。 - 第二个参数是长度。
- 第一个参数是起始位置,
-
正则表达式匹配
1
2expr "Hello World" : '.*'
expr "Hello World" : '\(.*\)'1
211
Hello World- 不带捕获组时返回匹配到的长度;带一对
\( \)时返回括号里匹配到的内容。 - 正则只从字符串开头匹配,所以
expr "Hello World" : 'World'得 0,写成'.*World'才有结果。 - 括号必须写成
\(\)(基础正则),写(...)会被 Shell 抢去当子 Shell 或者报语法错。
- 不带捕获组时返回匹配到的长度;带一对
-
现在还需要学它吗
- 算术用
$(( ))、比较用[[ ]]、字符串处理用参数扩展,都能替代expr,而且更快(不用起子进程)、更少转义。 - 但读老脚本绕不开它,所以认得
expr index/expr match/expr substr这三个仍然有用。
- 算术用
运算方法
- 同一个
3 + 5,Shell 里有六种写法,区别在「谁是内置、谁是外部程序、支不支持小数」。本机全部实测,结果都是 8:
-
内置的 Shell 算术扩展(
$((...)))1
2result=$((3 + 5))
echo $result1
8
- 推荐写法。内置、不启动进程、运算符不用转义、能嵌套、能直接用变量名。
-
使用
let命令1
2let result=3+5
echo $result1
8
let是 bash 内置命令(dash 里没有,实测报let: not found)。- 表达式里不能有空格,有空格就得整体加引号:
let "result = 3 + 5"同样得 8。
-
使用
expr命令1
2result=$(expr 3 + 5)
echo $result1
8
- 外部程序,要 fork 进程,还要转义和加空格,性能最差。
-
使用
bc(基础计算器)1
echo "3+5" | bc
1
8
bc是独立程序,本机版本bc 1.07.1。它从标准输入读表达式,所以习惯用管道把算式喂进去。- 它是唯一能自然算小数的(配合
scale),见下一节。
-
使用
awk进行计算1
awk 'BEGIN {print 3+5}'
1
8
awk是文本处理工具,BEGIN块在处理任何输入之前执行,所以不需要真的喂它文本。- 顺带一提,
$[3+5]这种老写法在本机 bash 5.3 上仍然能出 8,但它是被官方废弃的语法,别在新脚本里用。 - 还有一种「声明成整型变量再赋值」的写法,适合反复做算术:
1
2declare -i n=3+5
echo $n1
8
-
总结
写法 内置还是外部 支持小数 要转义/加空格 建议 $(( ))内置 否 都不用 整数运算首选 let内置(bash) 否 表达式内不能有空格 循环自增常用 ((i++))expr外部 否 都要 只在读老脚本时认它 bc外部 是(靠 scale)不用 浮点首选,精度可控 awk外部 是 不用 顺手算,注意输出格式 declare -i内置 否 不用 变量长期当整数用
运算分类
-
整数运算
-
简介
- Shell 的整数运算靠
$(( ))或let。乘号*在$(( ))里不需要转义,因为括号里的内容整体按算术规则解析,不会走通配符展开。 - 除法向零取整,小数部分直接丢掉;要浮点得用
bc或awk。 - 整数是 64 位有符号,超出范围会绕回(下面实测
1 << 63直接变成负数)。
- Shell 的整数运算靠
-
加法(+)
1
echo $(( 5 + 3 ))
1
8
-
减法(-)
1
echo $(( 5 - 3 ))
1
2
-
乘法(*)
1
echo $(( 5 * 3 ))
1
15
-
除法(/)
1
2
3echo $(( 15 / 3 ))
echo $(( 7 / 2 ))
echo $(( -7 / 2 ))1
2
35
3
-3- 后两条说明它是「向零取整」,不是向下取整(向下取整的话
-7/2会是-4)。
- 后两条说明它是「向零取整」,不是向下取整(向下取整的话
-
求余(%)
1
echo $(( 10 % 3 ))
1
1
-
幂运算与变量参与
1
2echo $(( 2 ** 10 ))
a=6; b=7; echo $(( a * b ))1
21024
42$(( ))里变量可以不带$直接写名字。- 自增有先后:
1
i=1; echo $(( i++ )); echo $(( ++i ))
第一条输出的是自增前的值 1(此时1
21
3i变 2),第二条是自增后的值 3。
-
-
浮点运算
-
简介
- Shell 本身不做浮点:
echo $(( 5.5 + 1 ))实测直接报错:1
bash: 5.5+1: arithmetic syntax error: invalid arithmetic operator (error token is ".5+1")
bc里scale决定小数位数,不写scale时默认是 0,所以echo "10/3" | bc得3而不是3.33…。bc的乘法不受scale约束,它按「两个操作数的小数位之和」与scale里取较大的那个再截断,结果是被截断不是四舍五入:5.75 * 2.50真实值是14.375,scale=2时得14.37。awk用浮点(双精度)算,输出按%.6g格式化,所以5.75 * 2.50得14.375、5.75 / 2.50得2.3(末尾的 0 不补)。
- Shell 本身不做浮点:
-
加法(+)
bc1
echo "scale=2; 5.75 + 2.50" | bc
1
8.25
awk1
awk 'BEGIN {print 5.75 + 2.50}'
1
8.25
-
减法(-)
bc1
echo "scale=2; 5.75 - 2.50" | bc
1
3.25
awk1
awk 'BEGIN {print 5.75 - 2.50}'
1
3.25
-
乘法(*)
bc1
echo "scale=2; 5.75 * 2.50" | bc
1
14.37
awk1
awk 'BEGIN {print 5.75 * 2.50}'
1
14.375
- 两条不一样不是谁算错了:
bc是截断到 2 位,awk是浮点原值。要bc给足精度就写scale=5。
-
除法(/)
bc1
2echo "scale=2; 5.75 / 2.50" | bc
echo "scale=5; 10 / 3" | bc1
22.30
3.33333- 注意
2.30:bc会补齐到scale指的位数,而awk给的是2.3。
- 注意
awk1
awk 'BEGIN {print 5.75 / 2.50}'
1
2.3
-
其他常用浮点操作
1
2
3
4echo "sqrt(2)" | bc -l
awk 'BEGIN {printf "%.3f\n", 5.75 / 2.50}'
echo "5.75 > 2.5" | bc
awk 'BEGIN {print (5.75 > 2.5) ? "真" : "假"}'1
2
3
41.41421356237309504880
2.300
1
真bc -l会加载数学库并把默认scale提到 20,所以开方才有小数。- 浮点比较别用
[ ]([ 5.75 \> 2.5 ]比的是字符串),要用bc或awk算完再判断。
-
-
比较运算
-
数值比较
数值比较必须用下面这六个带横线的运算符;用
>会变成重定向。-
等于:
-eq1
if [ 10 -eq 10 ]; then echo "Equal"; fi
1
Equal
-
不等于:
-ne1
if [ 10 -ne 20 ]; then echo "Not Equal"; fi
1
Not Equal
-
小于:
-lt1
if [ 5 -lt 10 ]; then echo "Less Than"; fi
1
Less Than
-
大于:
-gt1
if [ 20 -gt 10 ]; then echo "Greater Than"; fi
1
Greater Than
-
小于或等于:
-le1
if [ 10 -le 10 ]; then echo "Less Than or Equal"; fi
1
Less Than or Equal
-
大于或等于:
-ge1
if [ 10 -ge 5 ]; then echo "Greater Than or Equal"; fi
1
Greater Than or Equal
-
-
字符串比较
-
字符串相等:
=1
if [ "hello" = "hello" ]; then echo "Strings are equal"; fi
1
Strings are equal
-
字符串不相等:
!=1
if [ "hello" != "world" ]; then echo "Strings are not equal"; fi
1
Strings are not equal
-
字符串为空:
-z1
2str=""
if [ -z "$str" ]; then echo "String is empty"; fi1
String is empty
-z检查长度是否为零(zero length),为空返回真。 -
字符串非空:
-n1
2str="hello"
if [ -n "$str" ]; then echo "String is not empty"; fi1
String is not empty
-n检查长度是否非零,非空返回真。-n "$str"一定要给变量加引号,否则str为空时[ -n ]变成单参数测试,结果反而是真。 -
字符串小于:
<1
if [[ "abc" < "def" ]]; then echo "abc comes before def"; fi
1
abc comes before def
-
字符串大于:
>1
if [[ "def" > "abc" ]]; then echo "def comes after abc"; fi
1
def comes after abc
-
注意:
<和>只在[[ ]]里是比较运算符。放进[ ]时它们是 Shell 的重定向符号,实测:1
[ "abc" < "def" ]
1
bash: def: No such file or directory
报错内容是「找不到 def 这个文件」,说明 Shell 真的把
def当输入文件去打开了。要么改用[[ ]],要么在[ ]里转义成\<、\>(实测[ "abc" \< "def" ]为真)。 -
顺序按字符编码/排序规则走,
LC_ALL=C下大写字母排在小写前面,实测[[ "ABC" < "abc" ]]为真。
-
-
-
逻辑运算
-
简介
- Shell 里的逻辑与、或、非写成
&&、||、!。 [ ]/test内部还有-a(与)、-o(或)两个老运算符,能用但已属过时,POSIX 也不推荐,遇到多重条件建议改用多个[ ]加&&/||,或者换[[ ]]。&&、||有两副面孔:在[[ ]]里是「条件之间的逻辑运算符」,在命令之间是「控制运算符」(前一条成功才执行后一条)。两种用途下面都有实测。
- Shell 里的逻辑与、或、非写成
-
逻辑与(&&)
- 两个条件都真,整体才真:
1
[[ 3 -lt 5 && 7 -gt 2 ]] && echo "Both conditions are true"
1
Both conditions are true
- 作为控制运算符,只有前一条命令退出码为 0 才执行后一条,这是脚本里最常用的写法:
1
sudo apt update && sudo apt install -y package-name
- 同样的
&&放进[ ]里会直接语法错,实测:1
[ 1 -eq 1 && 2 -eq 2 ]
1
bash: [: missing `]'
[ ]把&&当成普通参数,于是找不到收尾的]。
- 两个条件都真,整体才真:
-
逻辑或(||)
- 两个条件至少一个真:
1
[[ 3 -gt 5 || 7 -gt 2 ]] && echo "At least one condition is true"
1
At least one condition is true
- 作为控制运算符用来「失败就兜底」:实测输出(
1
mkdir /tmp/shI/d || echo "目录已存在"
/tmp/shI/d已经建过一次):1
2mkdir: /tmp/shI/d: File exists
目录已存在- 第一行是
mkdir自己打到标准错误的信息,第二行才是||后面兜底的echo。
- 第一行是
- 两个条件至少一个真:
-
逻辑非(!)
- 条件取反:
1
[[ ! 3 -gt 5 ]] && echo "Condition is not true"
1
Condition is not true
- 判断「不存在才创建」:
1
[ ! -e /tmp/新文件 ] && touch /tmp/新文件
- 条件取反:
-
老写法
-a/-o(在[ ]或test里)1
2[ 3 -lt 5 -a 7 -gt 2 ] && echo "两个都真(-a 与)"
[ 3 -gt 5 -o 7 -gt 2 ] && echo "至少一个真(-o 或)"1
2两个都真(-a 与)
至少一个真(-o 或)- 能跑,但一旦操作数本身可能是空串或带空格,
-a/-o的解析就会歧义(这正是它被弃用的原因)。稳妥写法是拆开:1
[ 3 -lt 5 ] && [ 7 -gt 2 ]
- 能跑,但一旦操作数本身可能是空串或带空格,
-
组合使用
多个条件用括号明确优先级:
1
2
3if [ ! -e /path/to/file ] || [ ! -w /path/to/file ]; then
echo "File does not exist or is not writable"
fi- 在
[[ ]]里括号可以直接用;在test/[ ]里括号要转义,实测test \( 1 -eq 1 \) -a \( 2 -eq 2 \)能成立(写\(或'( )')。
- 在
-
-
位运算
-
简介
- 位运算在
$(( ))里做,支持&、|、~、^、<<、>>。 ~5得-6而不是一个大正数:bash 的整数是 64 位有符号补码,~把 64 个位全取反,最高位是 1 就表示负数。- 想要「8 位无符号取反得 250」,得自己加掩码:
$(( (~5) & 255 ))实测输出250。
- 位运算在
-
位与(AND)运算符
&1
echo $(( 5 & 3 ))
1
1
二进制
101 & 011 = 001。 -
位或(OR)运算符
|1
echo $(( 5 | 3 ))
1
7
二进制
101 | 011 = 111。 -
位非(NOT)运算符
~1
echo $(( ~5 ))
1
-6
~x恒等于-x-1,所以 5 变 -6。 -
位异或(XOR)运算符
^1
echo $(( 5 ^ 3 ))
1
6
二进制
101 ^ 011 = 110。 -
位左移(LEFT SHIFT)运算符
<<1
echo $(( 5 << 1 ))
1
10
左移一位等于乘 2。
-
位右移(RIGHT SHIFT)运算符
>>1
echo $(( 5 >> 1 ))
1
2
右移一位等于除 2 取整。
-
位数与溢出
1
2echo $(( 1 << 62 ))
echo $(( 1 << 63 ))1
24611686018427387904
-9223372036854775808- 第 63 位移进去就翻到符号位上了,这就是有符号 64 位整数的溢出表现,脚本里做掩码运算时要小心。
-
-
文件测试运算
-
下面每个运算符都在本机建了真实文件来测(空文件、1 字节文件、软链接、命名管道、套接字各一个),输出都是实测值。
-
检查文件是否存在:
-e1
2
3if [ -e /etc/hostname ]; then
echo "File exists."
fi1
File exists.
-
检查文件是否是常规文件:
-f1
2
3if [ -f /etc/hostname ]; then
echo "This is a regular file."
fi1
This is a regular file.
-
检查文件是否是目录:
-d1
2
3if [ -d /etc ]; then
echo "This is a directory."
fi1
This is a directory.
-
检查文件是否可读:
-r1
2
3if [ -r /etc/hostname ]; then
echo "File is readable."
fi-r、-w、-x判的是「当前这个进程能不能读/写/执行」,root 一律为真,普通用户则看权限位和属主,所以同一份脚本用sudo跑和不加sudo跑,结果可能不一样。实测以普通用户身份[ -r /etc/shadow ]为假(该文件权限640 root shadow)。
-
检查文件是否可写:
-w1
2
3if [ -w /etc ]; then
echo "Directory is writable."
fi- 以普通用户身份跑,这条什么都不输出(
/etc不可写);实测-w /etc为假、-w ~/自己的文件为真。
- 以普通用户身份跑,这条什么都不输出(
-
检查文件是否可执行:
-x1
2
3if [ -x /usr/bin/bash ]; then
echo "File is executable."
fi- 目录也常有执行位(表示「能进入」),所以
[ -x /etc ]实测为真,别把它理解成「/etc 能运行」。
- 目录也常有执行位(表示「能进入」),所以
-
检查文件是否非空:
-s1
2
3
4
5if [ -s /etc/hostname ]; then
echo "File is not empty."
else
echo "File is empty."
fi-s判的是「大小是否大于 0 字节」,所以「文件存在但为空」和「文件不存在」都走else分支。实测空文件为假、1 字节的文件为真。- 原笔记这个小标题写的是「检查文件是否为空」,方向反了,实际语义是「非空为真」。
-
检查文件是否是符号链接:
-L1
2
3
4ln -s /etc/hostname /tmp/link.txt
if [ -L /tmp/link.txt ]; then
echo "This is a symbolic link."
fi1
This is a symbolic link.
- 符号链接是一种特殊文件,里面存的是另一个路径;访问它时内核自动跳到目标。概念上像 Windows 的快捷方式,但它是文件系统层面的真实实体。
- 两种链接要分清:
- 软链接(符号链接):
ln -s 目标 名字创建。可以跨文件系统、可以指向目录、目标删掉后链接会悬空。 - 硬链接:
ln 目标 名字创建。同一文件系统内的同一个 inode,不能跨文件系统,也不能指向目录。
- 软链接(符号链接):
- 顺带:
-e、-f这些测试会跟着软链接看到目标,只有-L判的是链接本身。
-
关于
-a:它不是「最近被访问过」原笔记里写了一段
[ -a file ]表示「文件最近被访问过」,这是错的。实测:1
2[ -a /etc/hostname ] && echo "T"
[ -a /nope ] && echo "T" || echo "F"1
2T
F- 它跟
-e完全同义(test手册里-a file就是「file 存在」),跟访问时间没有任何关系。 - 它还有第二个身份:作为二元运算符时是逻辑与,
[ /etc/hostname -a /etc/hostname ]实测为真。同一个符号两种用法,正是-a被弃用的原因之一。 - 真想查访问时间,用
stat -c '%x' 文件或accessibility时间戳,别指望-a。
- 它跟
-
检查一个文件是否比另一个文件新:
-nt1
2
3
4
5touch -d '2020-01-01' /tmp/old.txt
touch /tmp/new.txt
if [ /tmp/new.txt -nt /tmp/old.txt ]; then
echo "new.txt is newer than old.txt."
fi1
new.txt is newer than old.txt.
-
检查一个文件是否比另一个文件旧:
-ot1
2
3if [ /tmp/old.txt -ot /tmp/new.txt ]; then
echo "old.txt is older than new.txt."
fi1
old.txt is older than new.txt.
-
检查一个文件是否为字符设备文件:
-c字符设备文件按「字符流」顺序访问,典型是终端、串口、键盘。
1
2
3if [ -c /dev/tty ]; then
echo "/dev/tty is a character device file."
fi1
/dev/tty is a character device file.
-
检查一个文件是否为块设备文件:
-b块设备文件按「固定大小的块」随机访问,典型是硬盘、loop 设备。
1
2
3if [ -b /dev/sda ]; then
echo "/dev/sda is a block device file."
fi1
/dev/sda is a block device file.
- 本机是 WSL2,
/dev下确实有sda、loop0这些块设备;换一台机器(比如容器里)可能就没有了,这条要按实际环境看。
- 本机是 WSL2,
-
其余几个常用测试
运算符 含义 本机实测 -p 文件是命名管道(FIFO) mkfifo fifo后为真-S 文件是套接字文件 bind一个 Unix socket 后为真-O 文件属主是当前用户 自己 touch的文件为真-G 文件属组包含当前用户 自己 touch的文件为真-t 描述符该描述符连着终端 脚本里跑为假(输出被管道接走) -N 文件自上次读取后又被改过 行为依赖文件系统时间戳精度,别当可靠判据
-
test 命令
-
简介
test是一个求值条件的命令:条件成立退出码 0,不成立退出码 1,它不打印任何东西。1
2test -e /etc/hostname; echo $?
test -e /nope; echo $?1
20
1[ ]不是语法符号,它就是test命令的另一个名字(本机/usr/bin/[和/usr/bin/test都指向 uutils coreutils 的同一套实现)。所以[和]两侧都必须有空格——它们本来就是命令行参数。1
[ -e /etc/hostname ]; echo $?
1
0
-
空格少了会怎样:静默恒真
这是
[ ]最阴的坑。少写空格时[ ]里变成一个参数,而「单参数测试」的规则是只要这个字符串非空就为真:1
2[ -e /nope ]; echo $?
[ -e/nope ]; echo $?1
21
0- 第二条明明文件不存在,退出码却是 0。因为
-e/nope被当成一个普通字符串,非空即为真。 - 所以「
if [ -e"$file" ]」这类写法不会报错,只会永远走真分支。检查这类 bug 最快的办法就是给[ ]里的每个部分都留空格,或者干脆改用[[ ]]`。
- 第二条明明文件不存在,退出码却是 0。因为
-
文件属性测试
- 检查文件是否存在:
test -e filename - 检查文件是否是目录:
test -d filename - 检查文件是否可读:
test -r filename - 检查文件是否可写:
test -w filename - 检查文件是否可执行:
test -x filename - 检查文件是否是普通文件:
test -f filename - 检查文件是否是符号链接:
test -L filename - 检查文件是否大小大于零:
test -s filename
- 检查文件是否存在:
-
字符串比较测试
- 检查字符串是否为空:
test -z string - 检查字符串是否非空:
test -n string - 检查两个字符串是否相等:
test "string1" = "string2" - 检查两个字符串是否不相等:
test "string1" != "string2"
- 检查字符串是否为空:
-
整数比较测试
- 检查两个数是否相等:
test num1 -eq num2 - 检查第一个数是否大于第二个数:
test num1 -gt num2 - 检查第一个数是否小于第二个数:
test num1 -lt num2 - 检查第一个数是否大于等于第二个数:
test num1 -ge num2 - 检查第一个数是否小于等于第二个数:
test num1 -le num2 - 检查两个数是否不相等:
test num1 -ne num2
- 检查两个数是否相等:
-
组合条件测试
- 逻辑与:
test condition1 -a condition2 - 逻辑或:
test condition1 -o condition2 - 逻辑非:
test ! condition - 括号分组要转义:
test \( 1 -eq 1 \) -a \( 2 -eq 2 \)(实测成立)
- 逻辑与:
-
示例
- 在脚本里最常见的用法就是放进
if:1
2
3
4
5if test -f "/etc/hostname"; then
echo "File exists."
else
echo "File does not exist."
fi1
File exists.
- 换成
[ ]的同义写法:1
2
3
4
5if [ -f "/etc/hostname" ]; then
echo "File exists."
else
echo "File does not exist."
fi注意:
[与]之间、以及它们和表达式之间都必须留空格,原因见上面「空格少了会怎样」。
- 在脚本里最常见的用法就是放进
-
[[ ]]比[ ]好在哪[[ ]]是 bash(和 ksh、zsh)的关键字,不是命令,所以解析规则不同,实测差异集中在四点:1
2
3
4[[ abc == a* ]] && echo "[[ ]] 里 = 是模式匹配"
[ abc = a* ] || echo "[ ] 里 = 是字符串比较,a* 是字面量"
[[ abc123 =~ [0-9]+ ]] && echo "[[ ]] 支持 =~ 正则"
[[ 3 -lt 5 && 7 -gt 2 ]] && echo "[[ ]] 里 && 可用"1
2
3
4[[ ]] 里 = 是模式匹配
[ ] 里 = 是字符串比较,a* 是字面量
[[ ]] 支持 =~ 正则
[[ ]] 里 && 可用[[ ]]里的变量不需要加引号也不会被拆词,[[ $str > abc ]]也不会被当成重定向,因此新写的 bash 脚本建议优先用[[ ]]。- 但
[[ ]]不是 POSIX,dash 里没有(实测dash -c '[[ 1 -eq 1 ]]'报[[: not found)。要写能被sh跑的脚本,就只能用[ ]。
分支结构
-
if 语句
-
if根据条件的退出码决定走哪个分支,常见形式为:1
2
3
4
5
6
7if [ condition ]; then
# 命令
elif [ another_condition ]; then
# 其他命令
else
# 默认命令
fi- 条件是「一条命令」,退出码 0 就走这个分支。
elif可以写多个,else可以省,最后的fi不能省。 - 三个关键字
if、then、fi各占一行或用;隔开,写成if [ -e x ] then会报语法错。
- 条件是「一条命令」,退出码 0 就走这个分支。
-
例如,判断一个数字是正数、负数还是零:
1
2
3
4
5
6
7
8
9
read -p "Enter a number: " num
if [ "$num" -gt 0 ]; then
echo "The number is positive."
elif [ "$num" -lt 0 ]; then
echo "The number is negative."
else
echo "The number is zero."
fi本机依次喂
7、-3、0三种输入,实测输出:1
2
3The number is positive.
The number is negative.
The number is zero.read是 Shell 内置命令,用来从标准输入读一行。-p "提示"让它先打印提示再等输入,读到的内容存进后面的变量num。-p的提示只在输入连着终端时才显示。实测把输入用管道喂进去(printf '42\n' | bash rd.sh),屏幕上只有读到 num=[42],那句Enter a number:根本没出现——不是 bug,是设计:非交互场景不该往日志里打提示。"$num"一定要加引号:不加引号时如果用户直接回车,[ -gt 0 ]只剩三个参数,实测报[: -gt: unary operator expected、退出码 2;加了引号则空串会走另一条报错[: : integer expected,同样退出码 2。两种报错都不致命,但都说明「条件根本没法判断」。- 用户输入非数字时
[ abc -gt 0 ]报[: abc: integer expected(退出码 2),条件为假,于是落到else分支——脚本看起来「正常跑完」,但结果毫无意义。做数值判断前最好先验证输入:1
[[ $num =~ ^-?[0-9]+$ ]] || { echo "请输入整数"; exit 1; }
-
if后面可以直接跟任何命令,不必是[ ]:1
2
3if grep -q '^root:' /etc/passwd; then
echo "root 用户存在"
fi1
root 用户存在
- 这就是「按退出码分支」的本质。
grep -q找到就返回 0。
- 这就是「按退出码分支」的本质。
-
-
case 语句
-
case拿一个值去依次匹配若干模式,命中哪个就执行哪段,语法如下:1
2
3
4
5
6
7
8
9
10
11case $variable in
pattern1)
# 命令
;;
pattern2)
# 命令
;;
*)
# 默认命令
;;
esac- 模式行顶格写是惯例(也可以缩进,但
esac必须单独一行)。 - 每个分支以
;;结束;*是「其他所有情况」,习惯放最后。 esac是case倒着写,跟fi之于if一样。
- 模式行顶格写是惯例(也可以缩进,但
-
例如,根据输入的字符做不同问候:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
read -p "Enter a letter (a/b/c): " letter
case $letter in
a)
echo "Hello, Apple!"
;;
b)
echo "Hello, Banana!"
;;
c)
echo "Hello, Cherry!"
;;
*)
echo "I don't know this letter."
;;
esac本机依次喂
a、b、x,实测输出:1
2
3a -> Hello, Apple!
b -> Hello, Banana!
x -> I don't know this letter. -
模式支持通配符、字符集、范围,还能用
|把多个模式并成一个分支:1
2
3
4
5
6
7for v in 1 2 3 other; do
case $v in
1|2) echo "$v -> 一或二" ;;
3) echo "$v -> 三" ;;
*) echo "$v -> 其他" ;;
esac
done1
2
3
41 -> 一或二
2 -> 一或二
3 -> 三
other -> 其他- 字符集和范围:
[0-9]匹配单个数字,[a-z]匹配单个小写字母,空串可以用""或'')作为模式。实测:1
2
3
4
5
6
7
8
9for v in 7 x ''; do
case $v in
[0-9]) r=数字 ;;
[a-z]) r=字母 ;;
"") r=空 ;;
*) r=其他 ;;
esac
printf '[%s=>%s] ' "$v" "$r"
done1
[7=>数字] [x=>字母] [=>空]
- 字符集和范围:
-
想匹配版本号这类结构,直接写模式比正则省事:
[0-9].[0-9].[0-9]能命中1.2.3,实测abc、空串、x分别落到「其他」「空串」「其他」。 -
两个进阶结束符(bash 特有,实测可用):
;&:命中后不判断下一个分支的条件,直接接着执行下一条的语句体。;;&:命中后继续判断后面所有分支的条件,可能多次执行。1
2
3
4
5
6v=abc
case $v in
a*) printf 'a开头\n' ;;&
*c) printf 'c结尾\n' ;;&
*) printf '结束\n' ;;
esac三条都打印,因为1
2
3a开头
c结尾
结束;;&让每个分支都参与判断,最后的*永远成立。普通;;只会在第一个命中的分支执行完就退出。
-
默认大小写敏感。想忽略大小写先开
shopt -s nocasematch,实测:1
2
3shopt -s nocasematch
case ABC in abc) echo 命中 ;; esac
shopt -u nocasematch1
命中
-
-
select 语句
-
select用来生成一个数字菜单,是case的一种特殊用法,只适用于交互式脚本。它会打印带编号的选项列表,把提示符(默认#?)交给用户,用户输入编号而不是名字,选中的值赋给变量,然后执行循环体;执行完再打印一次菜单,如此往复。 -
语法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17select item in option1 option2 option3; do
case $item in
option1)
echo "You picked $item."
;;
option2)
echo "You picked $item."
;;
option3)
echo "You picked $item."
;;
*)
echo "Invalid option."
break
;;
esac
done- 没有
break时它会无限重复打印菜单、无限等输入,所以一定要留一条退出的路(break、exit,或让输入结束)。 - 输入非法编号(比如菜单只有 4 项却输入 9)时,
$item是空串,于是落到*分支——这就是那个*分支存在的意义。
- 没有
-
一个完整可跑的菜单:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
echo "Please pick an option:"
select fruit in Apple Banana Cherry Quit; do
case $fruit in
Apple)
echo "Apples are delicious!"
;;
Banana)
echo "Bananas are full of potassium!"
;;
Cherry)
echo "Cherries are sweet!"
;;
Quit)
break
;;
*)
echo "Please choose a valid option."
;;
esac
done本机依次输入
2、9、4,实测输出:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16Please pick an option:
1) Apple
2) Banana
3) Cherry
4) Quit
#? Bananas are full of potassium!
1) Apple
2) Banana
3) Cherry
4) Quit
#? Please choose a valid option.
1) Apple
2) Banana
3) Cherry
4) Quit
#?- 输入
2直接得到 Banana 那句,然后菜单重打一次;输入9落到*分支;输入4命中Quit走break,循环结束。
- 输入
-
三个配套细节,实测:
- 用户敲的原始输入存在
REPLY里(不是select的变量),所以想拿到编号本身就读REPLY。 - 提示符由变量
PS3控制,实测PS3='choose> '后提示符就变了。 - 输入直接回车(空行)时菜单会重打一次,不会报错。
- 标准输入结束时(EOF)循环自动退出,实测
select x in a b; do echo got; done < /dev/null只打印一次菜单就结束,不会死循环。
- 用户敲的原始输入存在
-
循环结构
-
for 循环
for用来遍历一个列表里的每个元素。- 示例 1:数值范围实测输出
1
2
3for i in {1..5}; do
echo "Number $i"
doneNumber 1到Number 5各一行。{1..5}是 bash 的花括号展开,在命令行解析阶段就生成了 5 个词,所以里面不能放变量:实测n=5; for i in {1..$n}循环体只执行一次,$i的值是字面的{1..5}。边界是变量时改用seq或 C 风格for。- 支持步长(bash 4 起):
{1..10..3}实测得到1 4 7 10。
- 示例 2:遍历列表
1
2
3for fruit in apple banana cherry; do
echo "I like $fruit!"
done1
2
3I like apple!
I like banana!
I like cherry! - 示例 3:遍历命令输出或通配符
1
2for i in $(seq 1 5); do printf '%s,' "$i"; done
for f in /tmp/*.txt; do echo "$f"; done- 遍历
*.txt时如果没有匹配的文件,默认情况下$f会拿到字面的*.txt(通配符没展开成空),脚本就可能对着一个叫*.txt的文件操作。要么先[ -e "$f" ] || continue,要么shopt -s nullglob。
- 遍历
-
while 循环
- 条件为真就一直执行,先判断再执行。实测输出
1
2
3
4
5counter=1
while [ $counter -le 5 ]; do
echo "Counter is $counter"
((counter++))
doneCounter is 1到Counter is 5。((counter++))是算术自增,等价于counter=$((counter + 1)),但有个坑,见「break、continue 与几个必踩的坑」。while的条件同样可以是任何命令:1
2
3
4
5n=0
while grep -q '^root:' /etc/passwd; do
n=$((n + 1))
[ $n -ge 1 ] && break
done
- 条件为真就一直执行,先判断再执行。
-
until 循环
- 与
while相反:条件为假时执行,一旦为真就停,所以它适合「等到某件事发生」这种写法。实测同样输出 1 到 5。1
2
3
4
5counter=1
until [ $counter -gt 5 ]; do
echo "Counter is $counter"
((counter++))
done- 上面
while [ $c -le 5 ]和until [ $c -gt 5 ]是等价的,until的条件正好是while条件的反面。
- 上面
- 与
-
C 风格的 for 循环
- Bash 支持 C 语言风格的三段式循环:实测输出
1
2
3for ((i = 0; i <= 5; i++)); do
echo "Number $i"
doneNumber 0到Number 5(一共 6 行,因为条件是<= 5)。- 三段里可以省略任何一段,
for ((;;))就是死循环,靠循环体里的break退出:1
2
3
4
5for ((;;)); do
v=$((v + 1))
[ $v -ge 4 ] && break
done
echo "v=$v"1
v=4
- 步长自己控制:
for ((i = 0; i < 10; i += 3))实测得到0 3 6 9。
- 三段里可以省略任何一段,
- Bash 支持 C 语言风格的三段式循环:
-
break 和 continue
-
简介
break和continue用在for、while、until、select里控制循环流程。
-
break
- 立刻结束当前这一层循环,哪怕结束条件还没满足。实测输出 1 到 4,共 4 行。
1
2
3
4
5
6for i in {1..10}; do
if [ $i -eq 5 ]; then
break
fi
echo "Number $i"
done - 嵌套循环里
break只跳出最内层。想一次跳多层给它层数:break 2。实测只输出1
2
3
4
5
6for i in 1 2; do
for j in 1 2 3; do
[ $j -eq 2 ] && break 2
printf '(%s,%s)' "$i" "$j"
done
done(1,1)——break 2把两层一起退掉了。
- 立刻结束当前这一层循环,哪怕结束条件还没满足。
-
continue
- 跳过本次循环剩下的语句,直接进入下一次迭代。实测输出 1 到 10 里除了 5 的九个数字。
1
2
3
4
5
6for i in {1..10}; do
if [ $i -eq 5 ]; then
continue
fi
echo "Number $i"
done continue N是「继续外层循环的下一次」,很容易写错。实测:输出为空——因为内层每次的第一个1
2
3
4
5
6for i in 1 2 3; do
for j in 1 2 3; do
[ $j -eq 1 ] && continue 2
printf '(%s,%s)' "$i" "$j"
done
donej就把外层也推进到下一轮,循环体永远走不到打印那行。
- 跳过本次循环剩下的语句,直接进入下一次迭代。
-
-
几个必踩的坑(实测)
-
((i++))会把脚本弄死:算术表达式的值是 0 时,(( ))的退出码就是 1。((i++))是「先取旧值再自增」,第一次i=0时旧值就是 0,于是退出码 1。脚本开头如果写了set -e(遇错即停),这一句就直接把脚本终止:1
2
3
4bash -c 'set -e; c=0; ((c++)); echo 活着'
bash -c 'set -e; c=0; let c++; echo 活着'
bash -c 'set -e; c=0; ((c++)) || true; echo 活着'
bash -c 'set -e; c=0; c=$((c+1)); echo 活着'1
2活着
活着- 前两条(
((c++))和let c++)什么都没输出,脚本已经退出;后两条活下来了。 - 结论:在
set -e的脚本里自增,写c=$((c + 1)),或者((++c))(先加再取值,第一次就是 1),或者补一个|| true。
- 前两条(
-
while read会丢掉最后一行:只要文件末尾没有换行符,最后一行就读不到。实测一个内容为l1\nl2\nl3(结尾无换行)的文件:1
2
3
4
5
6
7n=0
while read -r line; do n=$((n + 1)); done < nonl.txt
echo "读了几行=$n"
n=0
while read -r line || [ -n "$line" ]; do n=$((n + 1)); done < nonl.txt
echo "读了几行=$n"1
2读了几行=2
读了几行=3read在返回假之前已经把内容赋给了变量,所以加一句|| [ -n "$line" ]就能把这半行补上。这是处理别人生成的文件时的标准写法。
-
read不加-r会吃掉反斜线:实测输入a\换行b,read x得到ab(反斜线被当成续行),read -r x得到a\。所以永远写read -r。 -
管道右侧的
while跑在子 Shell 里,累加的变量出来就没了:实测1
2printf '1\n2\n3\n' | while read -r n; do s=$((s + n)); done
echo "循环外用 s -> [$s]"1
循环外用 s -> []
- 换成把重定向放在循环上,或者用进程替换,就能拿到 6:
1
2
3
4
5
6
7s=0
printf '1\n2\n3\n' > f.txt
while read -r n; do s=$((s + n)); done < f.txt
echo "重定向后 s=[$s]"
s=0
while read -r n; do s=$((s + n)); done < <(printf '1\n2\n3\n')
echo "进程替换后 s=[$s]"1
2重定向后 s=[6]
进程替换后 s=[6] - 所以循环读文件要写
while read -r line; do ... done < 文件,别写cat 文件 | while read ...。
- 换成把重定向放在循环上,或者用进程替换,就能拿到 6:
-
函数
-
Shell 里的函数就是「给一段命令起个名字」,用来复用代码。定义有两种等价写法,
name() { ... }和function name { ... },后者那个空格可省可不省。 -
最简单的例子:
1
2
3
4
5
6
7
8
9
10
11
12
# 定义一个名为 greet 的函数
greet() {
echo "Hello, $1!"
}
# 调用函数 greet 并传递一个参数
greet "World"
# 再次调用函数 greet 并传递一个不同的参数
greet "User"实测输出:
1
2Hello, World!
Hello, User!- 函数体里的
$1、$2指的是传给函数的参数,不是脚本的参数;函数被调用时,$#、$@、$*也全部切换成函数自己的参数,实测函数里$#就是传进去的个数。
- 函数体里的
-
函数也能接收多个参数,用
$1、$2、$3逐个访问,用$@或$*访问全部。函数的「返回值」有两套完全不同的机制:return只能返回一个 0~255 的状态码,不是数值结果。- 想「返回数据」,就在函数里
echo出来,外面用命令替换$(...)捕获。
-
计算两个数之和的例子(用
echo+ 命令替换返回结果):1
2
3
4
5
6
7
8
9
10
11
12
13
# 定义一个名为 add 的函数,计算两个数的和
add() {
local sum=$(($1 + $2))
echo $sum
}
# 调用函数,并捕获输出(函数的返回值)
result=$(add 3 5)
# 打印结果
echo "The sum is: $result"实测输出:
1
The sum is: 8
local把sum限制在函数作用域内,函数返回后外面看不到,也不会覆盖同名全局变量。local只能在函数里用,写在函数外会报bash: local: can only be used in a function。
-
参数与
return状态码一起看:1
2
3
4
5
6
7
8
9
10
11
12
13
arg() {
echo $1
echo $2
echo $#
echo $@
echo $*
echo "$*"
return 123
}
arg 1 2
echo $?实测输出:
1
2
3
4
5
6
71
2
2
1 2
1 2
1 2
123echo $1打印第一个参数1。echo $2打印第二个参数2。echo $#打印传给函数的参数个数,这里是2。echo $@打印全部参数,原样输出1 2。echo $*与$@在这里一样,也是1 2;区别只在被双引号包住时——"$*"把所有参数并成一个整体,"$@"仍把它们视为独立参数。return 123把函数的退出状态设成123,调用方用$?取。
return的取值超出 0~255 会取模,实测:1
2
3f() { return 300; }; f; echo $?
f() { return 256; }; f; echo $?
f() { return -1; }; f; echo $?1
2
344
0
255- 函数里不写
return,退出码就是最后一条命令的退出码;写空的return也是这个效果。
-
$?反映的是「最近执行的那条命令」,函数调用也算一条命令:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 定义一个函数,它尝试执行一个不存在的命令
fakeCommand() {
non_existing_command
}
# 调用函数 fakeCommand
fakeCommand
# 打印 fakeCommand 调用后的退出状态
echo "The exit status of the function was: $?"
# 再执行一个成功的命令
ls >/dev/null
# 打印 ls 命令后的退出状态
echo "The exit status of 'ls' was: $?"实测输出(文件名是
fn4.sh):1
2
3fn4.sh: line 3: non_existing_command: command not found
The exit status of the function was: 127
The exit status of 'ls' was: 0fakeCommand里那条不存在的命令失败,退出码 127 由函数原样带出,所以$?是 127。- 紧接着执行
ls并把输出丢进/dev/null(只要不看结果,ls一般都能成功),所以第二条$?是 0。 - 报错的第一行是stderr,不会被
$(...)捕获。也就是说:out=$(函数)只能拿到 stdout,函数往 stderr 打的东西会直接出现在屏幕上,而$?要用就得紧跟在调用后面取,实测:1
2
3noisy() { echo 标准输出; echo 标准错误 >&2; return 7; }
out=$(noisy)
echo "捕获到的 out=[$out]"1
2标准错误
捕获到的 out=[标准输出]
-
其他几个实测过的行为:
- 变量默认是全局的,函数里赋值会影响外面:
1
2
3
4g=1
f() { g=2; }
f
echo "g=$g"1
g=2
- 函数可以递归,实测阶乘:
1
2
3
4
5
6
7
8fact() {
if [ "$1" -le 1 ]; then
echo 1
else
echo $(( $1 * $(fact $(( $1 - 1 ))) ))
fi
}
echo "6! = $(fact 6)"1
6! = 720
- 同名函数会优先于外部命令被调用,想绕过用
command:1
2
3
4
5ls() { echo "这是假的 ls"; }
ls /tmp | head -1
command ls /tmp | head -1
type ls
unset -f ls1
2
3这是假的 ls
inrel
ls is a functionunset -f ls之后type ls变回ls is /usr/bin/ls。 - 后定义的同名函数会覆盖先定义的,Shell 不报错。
export -f 函数名能把函数交给子 bash(实测子 bash 能调用,但sh/dash 起的子进程不认)。- 函数定义必须在使用之前完成,实测「先调用后定义」直接报
command not found。 - 参数超过 9 个要用
${10},实测传 11 个参数时$#是 11、${10}是10。
- 变量默认是全局的,函数里赋值会影响外面:
-
正式脚本里解析参数一般用
getopts,它比手写case稳:1
2
3
4
5
6
7
8
9
10
11
while getopts "ab:c" opt; do
case $opt in
a) echo "拿到 -a" ;;
b) echo "拿到 -b,值=$OPTARG" ;;
c) echo "拿到 -c" ;;
?) echo "未知选项" ;;
esac
done
shift $((OPTIND - 1))
echo "剩余位置参数: $* $#=$#"用
bash go.sh -a -b value c extra1 extra2执行,实测输出:1
2
3拿到 -a
拿到 -b,值=value
剩余位置参数: c extra1 extra2 $#=3- 模板串
"ab:c"里b:的冒号表示-b必须带值,值自动放进$OPTARG;c没冒号所以是开关。 - 解析完用
shift $((OPTIND - 1))把已处理的选项从位置参数里去掉,剩下的就是「非选项参数」。上面c前面没有-,所以它被当成非选项参数留下了。
- 模板串
服务管理
-
这一节讲「怎么在 Shell 脚本里操作服务」,systemd 本身的原理、单元文件写法、日志与定时任务在《Linux 系统服务》里已经逐条实测过,这里只保留脚本视角常用的部分,链接:https://suyin-blog.cn/2019/38HKRY8/。
-
本机是 WSL2 上的 Ubuntu 26.04,systemd 作为 init 跑起来了(
/etc/wsl.conf里boot.systemd=true),所以下面的systemctl全部能真跑。管理服务的命令大多需要 root,示例里统一带sudo。 -
管理服务
要做的事 命令 启动 sudo systemctl start 服务名停止 sudo systemctl stop 服务名重启 sudo systemctl restart 服务名重载配置(不中断服务) sudo systemctl reload 服务名看状态 systemctl status 服务名开机自启 sudo systemctl enable 服务名取消自启 sudo systemctl disable 服务名判断是否在跑 systemctl is-active 服务名判断是否自启 systemctl is-enabled 服务名列出所有服务 systemctl list-units --type service列出已自启的服务 systemctl list-unit-files --type=service --state=enabled看某个服务的日志 sudo journalctl -u 服务名- 服务名后面带不带
.service都行,systemctl start mysql和systemctl start mysql.service等价。 - 同一个服务在不同发行版上名字可能不同,这是脚本最容易挂掉的地方。本机实测 SSH:真正的单元文件是
/usr/lib/systemd/system/ssh.service,而sshd.service只是它的别名,所以systemctl cat sshd.service打印出来的路径仍然是ssh.service。1
systemctl cat sshd.service | head -1
1
# /usr/lib/systemd/system/ssh.service
- 本机
ssh.service是关着的,实测:1
2systemctl is-active ssh
systemctl is-enabled ssh1
2inactive
disabledis-active/is-enabled的退出码才是脚本里该用的东西:在跑返回 0,没在跑返回非 0。配合--quiet可以彻底不打印:本机实测输出1
2
3
4
5if systemctl is-active --quiet mysql; then
echo "mysql 在跑"
else
echo "mysql 没在跑"
fimysql 在跑。
reload不是所有服务都支持,不支持或未启动时会明确拒绝,实测:1
sudo systemctl reload ssh
1
ssh.service is not active, cannot reload.
- 列表命令的实测规模:
systemctl list-units --type service列出 39 行(含表头),list-unit-files --type=service --state=enabled列出 37 个自启服务。前者只列「已加载」的单元,所以没启动过的服务不在里面,要查全集得用后者。
- 服务名后面带不带
-
自建服务
- 写一个 systemd 服务,本质就是往
/etc/systemd/system/放一个.service文件,然后daemon-reload。下面这套在本机验证过可用(完整过程在《Linux 系统服务》里):1
2
3
4
5
6
7
8
9
10
11
12[Unit]
Description=My Custom Service
After=network.target
[Service]
ExecStart=/usr/local/bin/my-custom-script.sh
Restart=on-failure
User=nobody
Group=nogroup
[Install]
WantedBy=multi-user.targetAfter=network.target表示「网络起来之后再启动我」,它只管顺序,不管网络是否真的可用。Restart=on-failure是失败自动重启;User/Group决定以谁的身份跑,别用 root 跑能降权的服务。- 但
User=nobody会被 systemd 明确警告。本机建了一个这样的单元实测,服务确实起来了(is-active为active,进程以nobody身份跑),日志里同时留下一句:1
/etc/systemd/system/shm-demo.service:3: Special user nobody configured, this is not safe!
systemd-analyze verify 单元文件也会把这句报出来。正确做法是给服务建一个专用账号(useradd -r -s /usr/sbin/nologin myapp),而不是复用nobody。 - 脚本本体得是个不会自己退出的常驻程序,否则 systemd 会认为它结束了:记得给执行权限:
1
2
3
4
5
6
7
# 文件名:/usr/local/bin/my-custom-script.sh
while true; do
# 你的逻辑代码
sleep 60
donesudo chmod +x /usr/local/bin/my-custom-script.sh。
- 落地顺序(每步都要跑一遍):
1
2
3
4
5sudo systemctl daemon-reload
sudo systemctl start my-custom-service
sudo systemctl status my-custom-service
sudo systemctl enable my-custom-service
sudo journalctl -u my-custom-servicedaemon-reload是「让 systemd 重新读一遍单元文件」,改了.service文件必须做这一步,否则改的一直不生效。enable只管开机自启,不会顺手启动当前这次,所以start和enable是两件事。- 单元文件名要和
systemctl里写的服务名一致(my-custom-service.service)。 - 服务起不来时第一件事是看
journalctl -u 服务名,日志里通常直接写着ExecStart那行的报错。
- 写一个 systemd 服务,本质就是往
-
service命令service是老派写法,在没有 systemd 的机器上它去跑/etc/init.d/里的脚本。本机实测service是一个/bin/sh写的包装脚本(/usr/sbin/service),它内部会把参数翻译成systemctl调用,所以:实际打印的就是1
sudo service ssh status
systemctl status的格式:1
2○ ssh.service - OpenBSD Secure Shell server
Loaded: loaded (/usr/lib/systemd/system/ssh.service; disabled; preset: enabled)- 结论:新脚本一律直接用
systemctl,功能更全、行为更可预期;service只在「同一份脚本要兼容老系统」时才用。 - 如果目标机器真的是 SysVinit/Upstart,流程是:把脚本放进
/etc/init.d/、sudo chmod +x它、用update-rc.d配置自启,然后用service管理。现代发行版上已经很少见了。