早年间我刚开始正经写 shell 脚本的时候,遇到过一件让我怀疑人生的事:在终端里敲echo 1 + 2,满心期待它给我输出个 3,结果它原封不动打印了一行1 + 2。后来才明白,shell 骨子里是个“字符串搬运工”,它默认把所有内容都当成文本,压根没打算替你做加法。从那一刻起我才意识到,在 shell 里做数学计算,第一课不是背命令,而是搞清楚“怎么让 shell 知道你想算数”。
这件事说起来简单,但在实际脚本里,计数器、耗时统计、阈值判断、批量序号生成、百分比计算……几乎每个自动化脚本都躲不开数学运算。用的方式不对,轻则结果诡异,重则整个脚本逻辑崩掉。这篇文章就从我踩过的坑出发,把 shell 里做算术运算的几种常见姿势、适用场景和隐藏陷阱一次说清楚,适合刚入门 shell 脚本的新手,也适合写过一阵子但被各种奇怪现象折磨过的同学参考。
1. 先把工具盘明白:五种数学计算姿势对比
shell 生态里做数学计算的方式其实不少,但很多人翻来覆去只会用其中一两种,遇到小数就直接抓瞎。这里把最常见的五种做法一次性盘清楚,后面所有案例都建立在这些基础上。
1.1$((...)):日常最顺手的口袋计算器
bash 里最推荐、也最常用的数学计算方式,就是算术展开$(())。解释器看到这组符号,会先把里面的内容当成数学表达式计算好,再把结果替换到当前位置。前面那个echo 1 + 2,只要改成echo $((1 + 2)),就能老老实实输出 3 了。
它有几个用起来非常舒服的细节:
- 变量在括号里面可以省略
$符号:a=5; echo $((a + 1)),写起来干净得多; - 支持
+ - * / %以及幂运算**(bash 4.2 之后支持),还支持括号改变优先级; - 结果是纯整数,
echo $((1 / 3))输出的是 0,不会给你小数; - 未定义变量或者空变量在算术表达式里会被当成 0,很多场景下能救命;
- 还支持类似 C 语言的赋值写法:
((cnt++))、((total += n)),非常适合做循环计数。
因为$((...))是 POSIX 标准规定的语法,sh、dash、busybox 上的 sh 基本都支持,跨环境兼容性最好。所以我的建议是:凡是整数运算,优先用它。
1.2let命令:老牌内置写法
let是 bash 内置命令,本质功能和$((...))差不多,但它走的是“命令式”路线,直接执行let a=a+1就能完成变量值的修改,不用再把结果重新赋值回去。老脚本里经常看到这种风格,读起来有点像 C 语言的语句。
但let有两个特别容易踩的坑:
- 表达式两边不能有空格,
let a = 1会直接报错,必须写成let a=1; - 如果整个表达式的结果是 0,
let的退出状态码是 1,不要把它当成“失败了”。假如你写let "flag=0",紧接着用$?判断执行是否成功,十有八九会被误导。
新写的脚本里我很少单独用let,但它能帮你读懂大量老项目,所以还是建议搞明白。
1.3expr:兼容老系统的老黄牛
expr是最传统的数学计算命令,几乎出现在所有上古 shell 环境里。它是外部命令,每执行一次都要启动一个独立进程,性能在循环场景下非常难看,但好处是“哪儿都有它”,在极度精简的老系统上,expr可能是你唯一的选择。
用expr时有个容易被新手忽略的规则:参数之间要有空格。expr 1 + 2里的1、+、2必须分开;乘号更要小心,expr 3 * 2里的星号会被 shell 当成通配符展开,必须写成expr 3 \* 2或者expr 3 '*' 2,否则轻则报错,重则把当前目录下的文件名全列出来当成参数。括号同理,要用就得转义成\(和\)。
1.4bc:小数和精度问题的正路
前面三种方式遇到除法就有点尴尬,因为echo $((1 / 3))在 bash 里输出 0。要是计算 CPU 使用率、百分比、平均值这类需求,整数结果根本不够用。最正统的小数计算工具是bc,通过管道把表达式传进去:
echo "scale=2; 1 / 3" | bcscale是小数点后保留几位,不给的话它默认按整数处理,又会得到 0。bc还支持变量、函数和条件表达式,配合bc -l开启数学库后,可以用sqrt()、正弦余弦等数学函数,处理浮点计算基本够用。
1.5awk:计算和格式化一步到位
awk本身是一门完整的文本处理语言,很多人只拿它取列,其实它做数学计算也非常顺手。它可以在BEGIN模式里直接算,不需要任何输入文件:
awk 'BEGIN{printf "%.2f%%", 1/3*100}'这条命令能直接输出带两位小数的百分比结果,还带百分号。它比bc多了一个好处:格式化输出能力极强,printf想怎么控制格式都行,不用算完之后再套一层printf。凡是“既要算数、又要按指定格式输出”的场景,我基本都直接请awk上场。
1.6 五种方式速查对比
| 方式 | 类型 | 支持浮点 | 性能 | 适用场景 |
|---|---|---|---|---|
$(( )) | shell 内置 | 否 | 快 | 整数计算、循环计数、条件判断 |
let | shell 内置 | 否 | 快 | 老式脚本中的变量修改 |
expr | 外部命令 | 否 | 慢 | 老系统兼容 |
bc | 外部命令 | 是 | 慢 | 浮点数、数学函数 |
awk | 外部命令 | 是 | 中等 | 计算加格式化输出 |
2. 场景选型:什么时候该用哪一种
工具都认识了,接下来要解决的是“我到底该用哪个”的问题。这里不是简单的一句话推荐,要把场景、性能、可移植性都考虑进去,否则很容易在特定环境下翻车。
2.1 循环内计数,优先内置,别频繁调外部命令
写遍历脚本时最常见的需求是计数:扫描一个网段里有多少台机器在线、统计一个日志文件里有几行满足条件的记录、计算批量处理完成了多少条。这种场景下,循环里用cnt=$((cnt + 1))就够了,纯粹靠 shell 内置计算,不会额外创建进程。
如果写成cnt=$(expr $cnt + 1),问题就来了:每循环一次,都要 fork 出一个expr进程,循环 10000 次就是 10000 次进程创建和销毁。我在自己机器上简单对比过,同样跑 10000 次累加,$(( ))几乎是瞬间完成,expr要好几秒。对批量脚本来说,这个性能差距会直接决定你是等 5 秒还是等 5 分钟。
2.2 要小数的时候,别硬凑,直接上 bc 或 awk
算内存占用率、算超时比例、算任务成功百分比,这些场景都会遇到“两个整数的除法结果不是整数”的问题。到底该用bc还是awk?我的经验是:只是简单算一两步,两个都行;如果后面还要对同一批数值做多次四则运算,或者要做格式化输出,优先awk,因为它可以一次把计算和输出都搞定。
还有一种思路是用整数放大法:比如要计算 1/3 的百分比,在不需要精确小数时,可以直接算100 * 1 / 3,得到整数 33,凑合能用。这种做法的好处是省掉外部命令调用,在循环里尤其有价值。
2.3 可移植性陷阱:sh、dash、busybox 环境差异
如果脚本将来要丢到不同机器上跑,不能默认所有环境都是完整 bash。$(( ))是 POSIX 标准,大多数 sh 都支持,可以放心用;let不是 POSIX 标准语法,dash 环境下根本没有这个命令;bc在精简系统里不一定默认安装;expr几乎一定存在;awk基本都有,但不同发行版可能用的是 mawk 或者 gawk,数学函数支持的完整度有细微差别。
我建议的保守组合是:整数运算全部用$(( )),浮点运算优先awk,需要极强兼容的老系统才考虑expr。
2.4 面试官爱考的几个点
很多 shell 面试题会围着数学计算打转,常见的有三问:第一,为什么expr 3 * 2会报错?这是在考察你有没有被通配符坑过;第二,在 bash 里echo $((1 / 3))输出什么?这是在考你对整数除法的认知;第三,在 for 循环里做 1 到 100 的累加,怎么写得又快又优雅?大部分人会用expr硬算,水平高低从这题能明显拉开差距。把这些原理弄懂,其实面试题也只是纸老虎。
3. 实操环节:四个可以直接抄的脚本示例
理论讲再多,不如实际跑几个例子。下面这几个脚本都是我在日常工作里用过的、改造过的,你可以直接拿来当模板,改成自己的场景。
3.1 批量重命名文件,序号自动补零
场景:某台设备导出了一批照片,文件名是乱七八糟的英文加数字,想按顺序重命名成 001.jpg、002.jpg 这种风格,方便排序和归档。
#!/bin/bash n=0 for f in *.jpg; do n=$((n + 1)) new=$(printf "%03d" "$n") mv "$f" "${new}.jpg" done这里printf "%03d"负责补零,$((n + 1))负责递增序号。补零属于格式化,序号生成属于数学计算,两者配合极其常见。注意如果文件名带空格,mv命令里的变量一定要加引号,否则会按空格拆成多个参数。
3.2 统计存活主机数量并计算比例
场景:内网有人报告某网段不稳定,需要快速统计前 20 个 IP 里有多少台能 ping 通,顺便算个存活百分比。
#!/bin/bash ip=192.168.1 ok=0 total=20 for i in $(seq 1 $total); do if ping -c 1 -W 1 "$ip.$i" >/dev/null 2>&1; then ok=$((ok + 1)) fi done echo "存活主机: $ok / $total" if [ "$ok" -gt 0 ]; then rate=$(awk "BEGIN{printf \"%.1f\", $ok/$total*100}") else rate=0 fi echo "存活比例: ${rate}%"这段代码里有两个值得注意的点。一是$((ok + 1))可以用来做条件统计,比用一堆 if 套字符串拼接干净得多;二是计算百分比时用了awk,直接从整数跨度到了小数输出,避免了“1/20 直接得 0”的尴尬。这里把 awk 表达式放进双引号再传给-v的方式要注意转义,实际生产环境建议用awk -v o="$ok" -v t="$total" 'BEGIN{...}'的方式,更安全也更易读。
3.3 计算内存使用率
场景:写监控脚本时经常要输出当前内存使用占比,free命令读到的值都是 KB 或 MB 级别的整数,直接除会把小数点丢掉。
#!/bin/bash total=$(free -m | awk '/Mem:/{print $2}') used=$(free -m | awk '/Mem:/{print $3}') awk -v t="$total" -v u="$used" 'BEGIN{printf "内存使用率: %.1f%%\n", u/t*100}'这里先用free -m拿到内存总量和已使用量,再通过-v把变量传给 awk 做浮点除法和格式化输出。这种写法的好处是逻辑分离得很清楚,外部命令只负责取数,数学计算交给专门的工具,脚本可读性比在一堆管道里搅来搅去高多了。
3.4 经典练手:1 到 100 求和、九九乘法表、斐波那契
这几个例子经常被拿来当 shell 教学题,也是面试里出现频率很高的题型。
1 到 100 求和,最直接的循环写法:
#!/bin/bash sum=0 for ((i = 1; i <= 100; i++)); do sum=$((sum + i)) done echo $sum更聪明的做法是直接用等差数列求和公式:echo $((100 * (100 + 1) / 2)),一步出结果。两种写法都能得到 5050,但后者少了一层循环,在数据量大时优势明显。
九九乘法表:
#!/bin/bash for ((i = 1; i <= 9; i++)); do for ((j = 1; j <= i; j++)); do printf "%dx%d=%-2d " "$j" "$i" "$((i * j))" done echo done这里%-2d是让输出左对齐,方便排版。$((i * j))在 ANSI-C for 循环里直接用,简洁明了。
斐波那契数列前 10 项:
#!/bin/bash a=0 b=1 for ((i = 0; i < 10; i++)); do echo "$a" t=$((a + b)) a=$b b=$t done这三个例子本身不难,但它们能帮你把“变量赋值”“循环结构”“数学表达式”这三块拼图完整地接起来。能把它们一遍写对,说明你对 shell 数学计算的基本功已经过关了。
4. 常见坑与排查技巧实录
这部分是我最想写的,因为 shell 数学计算报错时的诡异程度,真的能让人怀疑是不是放错地方了。整理几个高频问题,搭配一套排查方法,至少能省你半天时间。
4.1 高频坑位速查表
| 错误表现 | 根因 | 正确姿势 |
|---|---|---|
echo 1 + 2输出1 + 2 | shell 没有解释数学表达式 | 改成echo $((1 + 2)) |
let a = 1报错 | let等号两侧不能有空格 | 写成let a=1 |
expr 3 * 2报错或列出文件名 | 星号被当成通配符展开 | 写成expr 3 \* 2 |
echo $((1 / 3))输出 0 | bash 整数除法截断 | 改用bc或awk |
| 空变量参与计算时结果诡异 | 变量未定义被当 0 | 文件开头加set -u |
bc计算结果末尾少 0 | scale设置或输出格式问题 | 用printf "%.2f"包一层 |
echo $((2#1010))在新旧环境差异 | 进制转换语法依赖 shell | 确认目标 shell 支持 |
4.2 字符串和数字的边界
很多坑的根源在于:shell 变量本质是字符串,数学计算只是在特定语法块里临时把它当数字用。所以会出现“明明赋值了,一算就变成 0”的问题,往往是因为变量的值根本不是一个干净的整数,比如带了换行符、空格或者%.这样的格式化残留。
排查这种问题时,我习惯先echo "[$var]"看变量到底长什么样,再用declare -p var查看类型和值。别嫌麻烦,这一步能帮你避免 80% 的“为什么算出来是 0”类问题。
4.3 用bash -x和set -u自检
调试数学计算最有效的工具是bash -x,它会把每一条命令实际执行的样子打印出来,包括变量展开后的结果。你很快就能看到到底是a没赋值,还是$((a + 1))被展开成了$(( + 1))。
另一个强烈推荐的习惯是脚本第一行写上set -u,开启未定义变量报错。默认情况下,空变量在数学表达式里会被当成 0,这个特性有时候是方便,有时候会掩盖逻辑错误。开了set -u之后,任何没定义就使用的变量都会直接报错,逼你把问题在早期暴露出来。
4.4 性能排查:能用内置别乱 fork
如果脚本跑得慢,先怀疑循环里有没有反复调用expr、bc这些外部命令。我的经验是:把循环内部的浮点运算挪到循环结束后统一处理,或者在循环内用整数放大法,性能能提升 10 倍以上。这也是为什么我在写大批量数据处理脚本时,会刻意避免在循环里秀“花活”的原因。
5. 几个进阶但很实用的写法
掌握了基础和排坑,再补充几个我平时用得很顺手的小技巧,它们不算复杂,但能明显提升脚本的健壮性和表达力。
5.1 浮点数比较
bash 的数字比较运算符-gt、-lt只支持整数,如果直接[ "3.14" -gt "3.0" ]会报 invalid number。处理小数比较,我一般用awk:
awk -v a=3.14 -v b=3.0 'BEGIN{exit !(a > b)}' && echo "a 大"这里awk的退出码拿来当判断条件,逻辑清晰,也不会被 bash 的整数限制卡住。
5.2 进制转换
处理网络配置或二进制掩码时,偶尔要做进制转换。bash 支持前缀式写法:
echo $((2#1010)) # 二进制 1010 转十进制,输出 10 echo $((16#FF)) # 十六进制 FF 转十进制,输出 255 printf "%x\n" 255 # 十进制转十六进制,输出 ff注意$((2#...))这类写法依赖 shell 是否启用 ARITH_FOR 等扩展功能,不是所有 POSIX sh 都支持,跨环境使用前先确认目标环境。
5.3 随机数生成
shell 里生成随机数,bash 有内置变量$RANDOM:
echo $((RANDOM % 100)) # 0 到 99 之间的随机数 echo $((RANDOM % 50 + 1)) # 1 到 50 之间的随机数这在测试脚本、模拟请求分发时很常用。注意$RANDOM不是加密级随机,不能用于安全相关场景。
5.4 超时判断
脚本里经常要判断“某个操作是否超时”,可以结合date +%s和数学计算:
start=$(date +%s) # 执行某些操作 sleep 3 end=$(date +%s) if (( end - start > 2 )); then echo "超时了" fidate +%s取出的是 Unix 时间戳,两者相减就是实际耗时秒数,属于最经典的数学计算应用。
从我这些年的实际操作体会来看,shell 数学计算的难点从来不在“语法记不住”,而在于“你在哪种场景下选了哪种工具”。能熟练在整数和浮点之间切换、能在循环内外做性能取舍,很多奇怪的问题根本不会出现。最后再分享一个小技巧:接手别人的脚本时,先全局搜一下expr和bc,把它们全部改成$(( ))或awk,脚本的可维护性会立刻上一个台阶,排查问题时也能少绕很多弯路。