Shell算术运算全攻略:从$(( ))到bc/awk,避开整数陷阱
很多人第一次在Shell里做数学计算时都会遇到同一个困惑明明在别的语言里a b写得好好的到了Shell里要么输出个23的字符串要么报command not found要么算出来的结果莫名其妙少个位数。这不是你写错了而是Shell本身对运算的理解和普通编程语言完全不一样——在Shell的世界里一切变量本质都是字符串加号、乘号对Shell来说只是普通字符你需要用专门语法告诉它这里要做算术运算。这里说的Shell指的是Linux/Unix环境下的命令解释器bash、sh、zsh不是EFI Shell也不是嵌入式领域那种面向单片机的Letter Shell。这篇内容围绕Shell数学计算算术运算加减乘除运算做一次完整梳理把$(( ))、expr、let、bc、awk这几条路线讲透并给出实战案例和面试中常出现的坑。适合刚入门Shell脚本的初学者也适合写过一段时间但总在算术上翻车的同学看完可以直接照着抄也可以当工具手册收藏。1. 先理清Shell算数的底层逻辑字符串、整数、浮点三个世界1.1 为什么ab会输出一个字符串很多人第一次写Shell算术都是这样的a2 b3 echo $a $b输出结果是2 3而不是5。原因很简单Shell变量没有类型a2存进去的是字符2$a $b在Shell看来就是三个独立的单词echo 把这三个单词原封不动打印出来而已。你从来没有告诉Shell请把2和3加起来Shell自然也不会主动去做数学题。所以要记住第一个结论Shell里的运算符只是普通字符只有放进特定的算术上下文里它们才会被当作真正的数学运算符来解析。这就是$(( ))、(( ))、let、expr存在的意义。1.2 整数与浮点是两个战场Shell内建的算术运算只支持整数。这一点特别坑人很多从Python、JavaScript转过来的人第一次写echo $((5 / 2))发现输出2当场懵了。小数呢被截断了不是四舍五入是直接丢掉小数部分。这不是bug是设计如此。Bash在整数计算上用的是64位有符号整型效率高、实现简单但代价就是不支持分数、小数。一旦你的脚本里出现百分比、平均值、费率计算这类需求就必须引入外部工具最常用的两个是bc和awk。所以做Shell算术之前先问自己一个问题我要算的是整数还是浮点这决定了你选哪条技术路线。整数走内建浮点走外挂两条路混着用也没问题但心里得先有这个分类。1.3 bash、sh、zsh之间的差异不能忽略同样是算术不同Shell的支持程度不一样。expr是外部命令任何Shell里都能用$(( ))是POSIX标准语法bash、sh、dash、zsh都支持但let、declare -i、(( ))里的一些扩展运算符比如**幂运算是bash专属在纯POSIX的dash里可能就不好使了。方案整数运算浮点运算POSIX兼容是否依赖外部命令$(( ))支持不支持支持否(( ))支持不支持基本兼容bash扩展否let支持不支持不支持bash内建否declare -i支持不支持不支持bash内建否expr支持不支持支持是/usr/bin/exprbc支持支持依赖管道是awk支持支持依赖管道是我个人的习惯是脚本开头#!/bin/bash就放心用bash特性但如果要写那种装在任何系统上都能跑的POSIX脚本老老实实用$(( ))最保险。2. 主推方案 $(( ))从加减乘除到赋值运算一次讲透2.1 基础语法与运算符合集$(( ))是Bash和POSIX Shell都支持的算术扩展语法用法非常简单echo $(( 2 3 )) # 输出5 echo $(( 10 - 4 )) # 输出6 echo $(( 3 * 4 )) # 输出12 echo $(( 7 / 2 )) # 输出3整数除法 echo $(( 7 % 2 )) # 输出1取余数 echo $(( 2 ** 10 )) # 输出1024幂运算bash支持核心运算符就是加减乘除和取模你在算术课上学的优先级在这里同样有效先乘除后加减括号可以改变顺序。echo $(( (1 2) * 3 )) # 输出9 echo $(( 1 2 * 3 )) # 输出7需要注意$(( ))里面空格是可选的$((23))和$(( 2 3 ))结果一样这点跟后面的expr完全不同。赋值给变量是日常最常用的姿势total$(( 5 5 )) echo $total # 输出102.2 变量参与计算加不加$的讲究变量参与算术可以用$也可以不用a10 b3 echo $(( a b )) # 输出13 echo $(( $a $b )) # 同样输出13我推荐不加$的写法。原因有两个第一$(( ))内部本身就是一个算术求值环境变量名会被自动解析成值没必要多写符号第二少了$你的表达式更接近普通编程语言阅读起来舒服。这种写法在C语言风格for循环里特别常见比如for ((i0; i10; i))从没人写$i。$(( ))还有一个隐藏特性如果变量的值本身是一个算术表达式参与计算时会被二次求值。a23 echo $(( a * 2 )) # 输出10等价于 (23)*2这个特性偶尔能派上用场但也容易让脚本变得难懂日常不建议刻意用。2.3 自增自减与复合赋值循环里免不了要写i i 1这种累加Shell提供了几种简写i0 (( i )) # i变为1作为语句使用 (( i 5 )) # i变为6复合赋值 (( i i * 2 )) # i变为12$(( ))和(( ))的区别要特别讲清楚这是新手最容易混的地方$(( ))会输出计算结果适合用来取值赋值。echo $(( 1 1 ))输出2。(( ))不会输出任何内容它只做计算并返回一个退出状态码结果非0则状态码为0真。(( 3 2 )) echo $? # 输出0表示条件为真所以(( ))天生适合做if判断a10 if (( a 5 )); then echo a大于5 fi$((a))和((a))的值也有区别。echo $((i))是先输出i的当前值再把i加1echo $((i))是先加1再输出。如果你只是想让变量自增、不需要输出直接用((i))最干净。2.4 位运算、逻辑运算与三元表达式除了加减乘除$(( ))还支持位运算和逻辑运算写权限判断、掩码计算时很实用echo $(( 5 3 )) # 按位与输出1 echo $(( 5 | 3 )) # 按位或输出7 echo $(( 5 ^ 3 )) # 按位异或输出6 echo $(( 1 4 )) # 左移4位输出16 echo $(( 8 2 )) # 右移2位输出2逻辑运算和三元表达式也支持echo $(( 3 2 4 3 )) # 输出1逻辑与 echo $(( 3 2 ? 100 : 200 )) # 三元运算输出1002.5 进制转换一个容易被忽视的隐藏功能$(( ))可以处理不同进制的数值语法是基数#数值echo $(( 2#1010 )) # 二进制1010输出10 echo $(( 16#FF )) # 十六进制FF输出255 echo $(( 8#17 )) # 八进制17输出15这个功能在处理配置文件、内存地址、权限掩码这类场景时特别好用。反过来的转换十进制转十六进制可以用printfprintf %x\n 255 # 输出ff printf %X\n 255 # 输出FF提示$((16#FF))这种方式是bash的算术扩展支持的POSIX严格模式下不一定都好使跨平台脚本慎用。3. expr、let、declare -i三种老牌写法的脾气与选型3.1 expr的三个反直觉约束expr是最古老的Shell算术工具之一很多老教程和面试题里都会出现。但它有三个特别反直觉的约束你没用过的话第一次几乎必踩坑。第一个约束操作数和运算符之间必须有空格。expr 2 3 # 输出5 expr 23 # 输出23原样打印第二个约束乘号必须转义。因为*在Shell里是通配符expr 2 * 3会把*展开成当前目录的文件列表所以必须写成\*expr 2 \* 3 # 输出6第三个约束括号也要转义expr \( 2 3 \) \* 4 # 输出20另外expr的计算结果是通过标准输出返回的想拿结果给变量赋值必须用命令替换result$(expr 2 3) echo $result # 输出5expr还有个隐藏的性能短板它是外部命令每次执行都要fork一个进程。在万次循环里用expr做累加速度比内建$(( ))慢几十倍甚至上百倍。所以我现在的脚本里基本不碰expr除非是在写极简POSIX脚本且不想依赖bash特性。3.2 letbash内建但存在感很低let是bash的内建命令专门用来做整数赋值运算let a23 echo $a # 输出5let和$(( ))的最大区别是let不输出计算结果只把结果赋给变量。它支持、-、、--这类复合运算let a5 let a3 # a变成8 let a # a变成9一个容易踩的坑let后面的表达式如果带空格会被Shell拆成多个参数必须用引号包起来let a 2 3 # 正确 let a 2 3 # 报错或行为异常let能干的事$(( ))和(( ))都能干所以它的定位比较尴尬。我在生产脚本里几乎不用它唯一的例外是写那种逗号风格的连续赋值比如let i0, j10。3.3 declare -i让变量变成整数declare -i可以声明一个整数类型的变量之后对这个变量的赋值会自动当作算术表达式求值declare -i num10 numnum5 echo $num # 输出15而不是num5注意这里赋值的时候不需要写$(( ))系统会自动做算术求值。如果你给整数变量赋一个非数字字符串它会变成0declare -i n nhello echo $n # 输出0declare -i在函数里可以作为local -i使用局部整数变量的语义很清楚。不过说实话我现在用到它的场景很少因为它的隐式求值机制对不熟悉的同事来说不够直观反而降低了脚本的可读性。3.4 选型建议什么时候用谁场景推荐方案理由通用脚本、跨Shell运行$(( ))POSIX标准语法干净循环累加、计数器(( i ))或i$((i1))内建命令性能好if条件判断(( 条件 ))退出状态码语义清晰老系统兼容脚本expr外部命令到处都有需要浮点运算bc或awk内建不支持小数处理文本列数据awk天然支持逐行处理和浮点一句话总结能用$(( ))的地方就别用expr能加bc的地方就别自己折腾整数逼近。4. 除法的整数陷阱引入bc与awk解决浮点运算4.1 为什么5/2等于2这是Shell算术里最经典的意外。在bash里echo $(( 5 / 2 )) # 输出2 echo $(( 5 % 2 )) # 输出1整数除法的规则是向零截断也就是直接丢掉小数部分不做四舍五入。负数的情况也遵循这个规则echo $(( -5 / 2 )) # 输出-2不是-3 echo $(( -5 % 2 )) # 输出-1余数符号跟随被除数如果你确实只需要整数商这个行为完全没问题。但如果你算的是平均值、百分比、速率就得分外小心。比如计算3个任务平均耗时$(( total / 3 ))把小数全吞了结果误差累积起来很离谱。判断奇偶是取模运算最经典的用法n7 if (( n % 2 0 )); then echo 偶数 else echo 奇数 fi4.2 bc最常用的浮点计算工具bc是一个任意精度的计算器语言通过管道把表达式喂给它就能得到结果echo 5 / 2 | bc # 输出2因为scale默认是0 echo scale2; 5 / 2 | bc # 输出2.50关键是scale这个参数它控制小数点后的保留位数。不设置的话整除和小数除法都只出整数部分。设置了scale除法就会保留指定位数的小数。echo scale4; 10 / 3 | bc # 输出3.3333bc -l会加载数学库同时把scale默认设为20适合做科学计算风格的运算echo scale2; sqrt(2) | bc -l # 输出1.41 echo scale2; 3.14 * 2 | bc -l # 输出6.28bc支持变量和多步计算管道里写多行也没问题echo x5; y2; x / y | bc我实际用得最多的场景是算百分比、算等待时间、算平均值。比如脚本里要根据文件总数动态计算每批处理的数量total100 batch7 per_batch$(echo scale2; $total / $batch | bc) echo 每批处理: $per_batch提示bc的scale只对除法、取模等部分运算生效乘法的小数位规则更复杂一点日常计算建议显式写scale不要依赖默认行为。4.3 awk另一个顺手的选择awk本身就是一个完整的文本处理计算语言浮点运算对它来说是家常便饭awk BEGIN { print 5 / 2 } # 输出2.5 awk BEGIN { printf %.2f\n, 10 / 3 } # 输出3.33要在awk里用Shell变量通过-v传进去a5 b2 awk -v num1$a -v num2$b BEGIN { printf %.2f\n, num1 / num2 }awk的另一大优势是天生面向按行处理数据。比如一个文件里每行一个数字我想算平均值awk { sum $1; count } END { printf 平均: %.2f\n, sum / count } numbers.txt这种场景如果用纯Shell写先while循环读文件、再做$(( ))累加、最后再用bc算除法中间要写好几行;awk一行搞定还自动支持浮点。所以我的建议是单次数值计算用bc批量处理文本数据里的计算用awk。4.4 实战百分比、平均值、限速值结合上面讲的给几个可以直接抄的计算片段。计算百分比并保留两位小数part37 total200 awk -v p$part -v t$total BEGIN { printf 占比: %.2f%%\n, p * 100 / t }计算一组数字的平均值数据在文件里awk { sum $1 } END { print 平均值:, sum / NR } data.txt给while循环的sleep算一个动态间隔比如1分钟分N次执行count6 interval$(echo scale2; 60 / $count | bc) while (( i count )); do echo 第 $i 次执行 sleep $interval (( i )) done5. 实战拆解循环计数器、累加求和与脚本计时5.1 for循环里的两种写法Shell数学计算在循环里用得最多。最常见的C风格for循环本质就是一个算术表达式for (( i 1; i 10; i )); do echo $i done另一个常见写法是用seqfor i in $(seq 1 10); do echo $i done两者区别在于seq是外部命令每次执行都要起进程数值大时性能差C风格for循环是bash内建的算术求值速度快很多。我写循环默认用C风格for。5.2 累加求和的标准姿势求和1到100是Shell脚本入门级别的经典题也是面试爱出的基础题sum0 for (( i 1; i 100; i )); do sum$(( sum i )) done echo 1到100的和: $sum # 输出5050用while循环写也一样sum0 i1 while (( i 100 )); do sum$(( sum i )) (( i )) done echo $sum这里的sum$(( sum i ))和(( sum i ))完全等价看个人偏好。我习惯用sum$(( sum i ))因为一眼就能看出把sum和i相加后重新赋给sum语义更直白。5.3 用时间戳计算脚本耗时算耗时是运维脚本里的高频需求核心就是结束时间减开始时间start$(date %s) # 中间是你要计时的逻辑比如sleep 3 sleep 3 end$(date %s) elapsed$(( end - start )) echo 耗时: ${elapsed}秒如果任务很快秒级不够用就用纳秒时间戳start$(date %s%N) # 执行某个命令 end$(date %s%N) elapsed_ms$(( (end - start) / 1000000 )) echo 耗时: ${elapsed_ms}毫秒这里date %s%N返回的是从1970年1月1日到现在的秒数纳秒数是一个很大的整数做减法属于纯整数运算正好是$(( ))的强项。5.4 批量重命名中的数学运算批量重命名文件时经常需要按序号重新编号。这里有个组合技巧用算术生成序号用printf补零n0 for f in *.jpg; do n$(( n 1 )) mv $f photo_$(printf %03d $n).jpg done运行后a.jpg、b.jpg、c.jpg会被重命名为photo_001.jpg、photo_002.jpg、photo_003.jpg。有个细节值得注意补零用printf的%03d不要试图用算术生成001这样的数字。因为Shell里$(( 001 1 ))会把001当作八进制处理一旦数字里有8或9就会直接报错。这就是下一章要讲的前导零陷阱。6. 踩坑与面试Shell算术的高频雷区和经典考法6.1 空变量与未定义变量在bash里未定义或空变量参与算术会被当作0处理unset x echo $(( x 1 )) # 输出1这个默认行为在大多数情况下很友善但也容易掩盖bug。比如你从配置文件里读了个数字结果字段拼错了读成了空值脚本不会报错只是计算结果是错的排查起来很费劲。如果你希望脚本更严格可以用set -u打开未定义变量即报错的模式。但要注意set -u对算术上下文里的变量引用也会生效稳妥的做法是显式给默认值echo $(( ${x:-0} 1 )) # x未定义时用0这个:-写法是参数扩展语法意思是如果x未定义或为空就使用0。在算术上下文中这么写既保证了安全又避免了报错是我写生产脚本时的固定习惯。6.2 前导零的八进制地狱先说结论在Shell算术里以0开头的数字会被当成八进制。echo $(( 010 )) # 输出8不是10 echo $(( 08 1 )) # 报错value too great for base (error token is 08)08、09之所以报错是因为八进制里根本没有8和9这两个数字。这个坑最容易出现在处理日期时比如从date %m或date %d拿到月份、日期系统会默认补零输出07、08、09month$(date %m) # 可能是08 echo $(( month 1 )) # 报错解决办法是用10#数字强制按十进制解析month08 echo $(( 10#$month 1 )) # 输出9如果变量可能为空再叠加:-默认值echo $(( 10#${month:-0} 1 ))这个坑在面试里也经常被拿来考人属于看起来很简单一写就翻车的典型。6.3 除以0、整数溢出除以0在bash里会直接报错并终止算术求值echo $(( 1 / 0 )) # bash: division by 0 (error token is 0)写脚本时如果除数是变量一定要先判断它是否为0divisor0 if (( divisor 0 )); then echo 除数不能为0 else echo $(( 100 / divisor )) fi另外一个进阶知识点是整数溢出。Bash的整数运算基于64位有符号整型超出范围会回绕echo $(( 9223372036854775807 1 )) # 输出-9223372036854775808正常脚本几乎碰不到这么大的数但写那种长跑监控脚本、累加计数器时心里有个数总没坏处。6.4 高频面试题与易错点下面是Shell算术相关的经典面试题我把考点拆开讲第一个expr 23输出什么为什么输出23。因为expr要求操作数和运算符之间必须有空格23被当成一个整体字符串expr拿到后无法识别运算符只能原样输出。第二个$(( ))和$( )有什么区别$(( ))是算术扩展做数学计算并返回数值$( )是命令替换执行里面的命令并返回命令的标准输出。echo $((11))输出2echo $(echo 11)输出11。这两个长得像含义完全不同是面试官最爱考的混淆点。第三个如何计算1/3并保留两位小数两种标准答案echo scale2; 1 / 3 | bc # 输出0.33 awk BEGIN { printf %.2f\n, 1 / 3 } # 输出0.33第四个let i、((i))、i$((i1))三者的区别三个都能让i加1。区别在于let i是bash内建命令不推荐但能用((i))是最简洁的自增写法适合单独作为语句i$((i1))语义最直白适合赋值场景。面试答出都会自增((i))最常用、$(( ))可以取返回值就够分了。第五个判断一个数是否为偶数的Shell写法if (( n % 2 0 )); then echo 偶数 fi第六个expr和$(( ))哪个快为什么$(( ))快。因为expr是外部命令每次调用都要fork一个子进程$(( ))是Shell内建求值不额外起进程。在循环里做大量计算时性能差距会非常明显。我在实际使用中的体会是Shell算术的上手门槛不在语法而在思维转换。你只要时刻记住变量是字符串整数浮点要分清前导零是八进制这三点90%的坑都可以绕开。剩下的就是多写多练把它变成肌肉记忆。