☰
Linux bash let命令详解:算术运算、坑点与性能对比
2026/10/7 16:52:47 网站建设 项目流程

提到Linux命令里的算术运算,let绝对是最容易被人忽略却又值得好好聊的一个。它不是 ls、grep 那种每天都要用的命令,但在bash脚本里写计数器、累加器、字节偏移量这些逻辑时,let 的 C 语言风格能让你少敲很多字符。我刚入行那会儿,一直用 $(( )) 和 expr,直到接手一个老项目,脚本里全是 let i++ 这样的写法,才被迫把这命令从头到尾啃了一遍。这篇文章我会从语法、运算符、返回值这几个最容易出错的地方讲起,再拿 let 和 (())、expr、bc 做一组对比,最后把我的踩坑记录和几个面试常问点一并整理出来。无论你是刚学Linux的新手,还是写过一阵子脚本但没细究过这条命令的开发者,这篇应该都能给你省点时间。

1. 先搞清楚let到底是什么

1.1 let不是外部命令,而是shell内建命令

第一次查 let 的根底,多数人会疑惑:为什么which let什么都找不到?其实是因为 let 根本不在 /bin 或 /usr/bin 下,它是 bash、zsh、ksh 这类 shell 自己内置的命令。想知道当前 shell 认不认它,直接执行:

type -a let

如果输出let is a shell builtin,说明当前 bash 支持。如果输出command not found,那就要么换了 shell,要么用了 dash 这类极简环境。

这个身份很重要。内建命令意味着执行时不会 fork 出一个子进程,只是 shell 自己算一笔整数账,性能开销可以忽略。相比之下,expr 1 + 2是外部程序,每执行一次都要启动一个进程,循环里跑几万次,慢得能明显感受到差距。所以哪怕 let 的出场率不算高,在需要密集数值计算的脚本里,它的性价比反而比 expr 好很多。

1.2 为什么脚本里离不开算术运算

很多 Linux 新手学了 ls、cd、grep 就觉得脚本差不多了,直到写循环、统计、批处理才发现,数值计算是绕不过去的硬需求。比如统计目录里有多少个文件、循环产生 001 到 100 的序号、计算超时时间、判断某个数字是奇数还是偶数,这些都是典型场景。

let 的定位就是专门处理整数算术。它和$(( ))、(( ))一样,共用 bash 的算术求值引擎,但写法上更接近 C 语言。换句话说,你可以在 let 后面直接写a += 2、i++、flag |= 0x01,省去$和一堆括号。理解这一点,再看老脚本里密密麻麻的 let 表达式就不会犯怵了。

2. let的语法与基础用法拆解

2.1 基本格式与第一个例子

let 的基本格式非常简单:

let 表达式

表达式会直接在变量上做修改,不会打印任何结果。想要看结果,得自己echo $变量。例如:

a=10 let a=a+5 echo $a

输出是 15。这里注意,我不写$a也完全没问题,因为 let 的算术引擎会自动把裸变量名当成“取这个变量的值”,这是 C 风格写法的来源之一。当然你写let a=$a+5也正确,只是冗余。

let 还支持一次传多个表达式,按顺序依次执行:

let a=1 b=2 c=a+b echo $a $b $c

输出1 2 3。每个参数被当成独立表达式,一个一个算过去。这种写法适合把相关联的多个累加操作放在同一行,脚本看起来更紧凑。

2.2 let支持哪些运算符

let 支持的运算符比很多人想象中要多。除了常见的加减乘除取余,还有幂运算、位运算、逻辑运算和各种复合赋值运算符。我把常用的一份整理在下面:

运算符类型运算符示例
基本四则+ - * / %let r=7%3,结果 1
幂运算**let r=2**10,结果 1024
自增自减++ --let i++,等价于 i=i+1
复合赋值+= -= *= /= %=let n*=3,等价于 n=n*3
位运算<< >> & | ^ ~let r=5<<2,结果 20
逻辑运算&& || !let r=(5>3)&&(2<4),结果 1
比较运算< <= > >= == !=let r=5<3,结果 0

这里有两个细节要注意。一是/是整除,5/2得到 2,不会出现 2.5。二是**幂运算需要 bash 4.0 及以上版本,绝大多数现代发行版都满足,但如果你在非常老的嵌入式系统上干活,最好先确认 bash 版本。

复合赋值是 let 最顺手的功能。以前写count=$((count+1)),现在直接let count+=1,是不是清爽很多?同理let total+=size、let offset<<=2也都是合法写法。

2.3 空格与引号:新手必看

let 的语法坑集中在一个地方:空格。如果按习惯写let a = 1,几乎必挂,报错长这样:

-bash: let: a: syntax error in expression (error token is "a")

原因是 shell 会先把命令行按空格拆词,a、=、1成了三个独立参数。let 拿到第一个参数a,会对a做算术求值,但后面又跟着=这种不能独立成表达式的 token,于是炸了。

正确写法有三种:

let a=1 let "a = 1" let 'a = 1'

第一种最常用;第二种加了双引号,可读性最好;第三种单引号也能跑,因为 let 的算术引擎自己会解析变量名。我个人的建议是:既然是写脚本,尽量别用容易出错的空格风格,要么干脆不空格,要么整体用双引号包住。尤其在let "a = a + 1"这种场景,加引号后代码迁移到任何 bash 版本都稳。

另外补充一点:如果你更习惯用(( )),其实它和 let 是同一套东西。(( a = a + 1 ))不需要引号,因为复合命令自身有独立的解析规则。这也是为什么很多现代脚本更推荐(( )),后面细说。

3. 实操过程:从自增计数到综合脚本

3.1 计数器与累加器:let i++ 和 let sum+=n

最经典的用法就是循环计数。比如从 1 加到 100:

#!/bin/bash sum=0 for ((i=1; i<=100; i++)); do let sum+=i done echo "1 到 100 的和:$sum"

这里 let 出现在循环体内部,比起sum=$((sum+i)),读起来更接近自然语言:把 i 累加到 sum 上。如果你负责过老项目,一定见过更简练的写法:

count=0 for file in /etc/*; do [ -f "$file" ] && let count++ done echo "普通文件数量:$count"

let count++等价于count+=1。它没有表达式输出,也不适合写在$(...)里,但在“只管把变量加一”的场景下非常干脆。

还有一个容易混淆的点:let "b = a++"和let "b = ++a"不一样。前者先把 a 的旧值赋给 b,再自增 a;后者先自增 a,再把新值赋给 b。遇到这种表达式,先脑内模拟一遍 C 语言的执行顺序,再上机验证,别凭直觉猜。

3.2 进制与位运算:超出基础四则运算的用法

let 的算术引擎支持多种进制写法,这个很多人没用过。八进制用0开头,十六进制用0x开头,二进制可以用2#1010这种进制#数值的通用格式。例如:

let "octal = 0777" let "hex = 0xff" let "binary = 2#1010" echo $octal $hex $binary

输出为511 255 10。你可能会问:八进制 0777 转出来 511,这不是权限掩码吗?没错,处理 chmod 权限值、网络掩码、颜色值这类数据时,进制转换非常实用。

位运算在脚本里也有用武之地。判断一个数是奇数还是偶数,不用取模,直接看最低位:

n=7 let "odd = n & 1" echo $odd

输出 1,说明 7 是奇数。再比如给一个标志变量设置某些位:

flags=0 let "flags |= 0x01" # 打开第1个开关 let "flags |= 0x04" # 打开第3个开关 let "flags &= ~0x04" # 关闭第3个开关 echo $flags

这类技巧在处理配置开关、文件权限、协议字段时很常见。虽然普通脚本不一定会用到,但当你真遇到时,let 这种简洁的位运算写法比一堆$(( ))嵌套舒服得多。

3.3 真实案例:写一个文件大小统计脚本

光讲语法没意思,我写一个能直接跑的小脚本。目标:统计指定目录下所有普通文件的总字节数,并计算平均大小(整数)。

#!/bin/bash # 用法:./sizestat.sh [目录] dir="${1:-.}" total=0 count=0 for f in "$dir"/*; do if [ -f "$f" ]; then size=$(stat -c %s "$f") let total+=size let count+=1 fi done echo "文件数:$count" if [ "$count" -gt 0 ]; then let avg=total/count echo "总字节数:$total" echo "平均字节数:$avg" fi

打个比方,你有三千个配置文件,想快速知道平均每个多大,这脚本跑一下就有整数结果。要注意的是,size=$(stat -c %s "$f")取出来的字节数已经是整数,后续 let 累加不会有浮点问题。但平均数是整除,如果统计出来是 3.7 万字节的数,小数部分会被直接截断,这不算 bug,是脚本的设计取舍。

3.4 结合循环与条件判断:批量重命名

另一个接地气的场景是批量重命名。比如把一堆 png 文件改成 img_1.png、img_2.png 这种带序号的格式:

#!/bin/bash i=0 for f in *.png; do let "new = i + 1" mv "$f" "img_$new.png" let i+=1 done

这个脚本有几个细节值得注意。let "new = i + 1"这种写法放在 mv 命令前,是为了生成从 1 开始的序号。最后let i+=1再递增计数器。如果你把递增放在 mv 前面,序号就会从 2 开始,容易踩错。实际执行前最好先在当前目录放几个测试文件,跑一遍确认,再对真实目录动手。

4. let、(())、expr、bc的对比与选型

4.1 一张表看清差异

很多人的疑问是:既然有 let,为什么还有(( ))、$(( ))、expr、bc?它们到底谁替代谁?

我整理了一张对比表,方便你直接看差异:

方式类型是否支持浮点典型用法退出状态说明
let 表达式bash 内建否let count+=1最后一个表达式值为 0 时返回 1
(( 表达式 ))bash 内建否(( count += 1 ))与 let 相同,表达式值为 0 时返回 1
$(( 表达式 ))bash 内建否sum=$((a+b))用在赋值中,一般不看退出状态
expr 表达式外部命令否sum=$(expr $a + $b)结果为 0 或错误时返回非 0
bc 表达式外部命令是avg=$(echo "scale=2; $a/$b" | bc)正常计算返回 0

注意(( ))和$(( ))的区别:一个是算术命令,一个叫算术展开。(( ))可以直接修改变量,也能当条件判断;$(( ))的主要作用是“把计算结果作为一个字符串返回”,所以经常出现在变量赋值和 echo 里:

a=5 b=7 sum=$((a + b)) # 算术展开 (( total = a * b )) # 算术命令

let 和(( ))几乎等价,硬要说差异,就是 let 后面写表达式更接近普通命令语法,(( ))则把整个表达式框起来,规避了空格陷阱。

4.2 性能对比:内建命令为什么快

如果只在脚本里算个位数,性能差异你根本感觉不到。但批量循环时,外部命令的 fork 开销会迅速放大。我试过一个小测试:

# 用 let time for i in {1..50000}; do let c+=1; done # 用 expr time for i in {1..50000}; do c=$(expr $c + 1); done

在我自己的机器上,let 版本一瞬间跑完,expr 版本要好几秒。原因很简单:let 只是 shell 内部一次算术求值,expr 每次都要启动一个新进程。进程创建、初始化、返回结果,一套流程走下来,性能差距自然就出来了。

这不是说 expr 一无是处。在 POSIX 场景或某些精简 shell 环境中,expr 可能是唯一的选择之一。但只要能确定脚本跑在 bash 下,能内建就内建,没必要为了写个加法去启动几十个进程。

4.3 选型原则:我什么时候该用let

我现在的经验是这样:临时在命令行里快速算一个数,或者写一次性脚本,let 很顺手。尤其是let i++、let x+=2这种短写法,几乎不用动脑。但如果脚本要长期维护、给别人看,我会优先用(( ))或$(( )),因为空格和引号的坑更少,可读性更好。

需要浮点计算时,let 和(( ))都不能用,考虑 bc 或 awk。需要校验返回值、处理复杂逻辑时,(( ))更自然。需要跨 shell 兼容时,老老实实写$(( ))。这些原则组合起来,基本能应对绝大多数场景。

5. 踩坑实录与脚本调试技巧

5.1 最隐蔽的坑:表达式结果为0时let返回1

这是我刚开始用 let 时踩得最狠的坑。看一下这段代码:

let "a = 0" echo $?

你猜返回什么?我猜很多人以为是 0,但实际是 1。这是 let 和(( ))的共同行为:如果最后一个表达式的算术结果为 0,命令返回状态码 1;只有结果非 0,才返回 0。

这会导致什么后果?如果你写let "a = 0" && echo 成功,echo 永远不执行。更危险的是,在脚本里如果把let a=0当作正常赋值来用,后面又用$?判断是否成功了,就会逻辑错乱。

解决办法很简单。如果只是赋值,不关心返回值,可以追加|| true:

let "a = 0" || true

或者直接改用(( a = 0 )),它的逻辑一样,同样要留意返回值。如果你真的想用 let 做条件判断,比如判断一个数是否大于 10:

if let "a > 10"; then echo "a大于10" fi

这是合理用法,因为表达式的值就是比较结果,非 0 即真。要分清:赋值表达式和条件表达式,let 的返回逻辑是不同的。

5.2 空格引发的syntax error与排查

前面提到let a = 1会报错,实际报错信息可能带各种 token。比如:

-bash: let: a: syntax error in expression (error token is "a") -bash: let: =: syntax error in expression (error token is "=")

排查方法很简单:先看错误里的 token 是什么,如果是=、+、*这类符号,十有八九是空格把表达式拆碎了。确认一下有没有在表达式内部加无引号空格。另外,*在 shell 里是通配符,如果写let "a=3*5"没引号,*会被展开成当前目录的文件列表,错误更诡异。所以只要表达式里出现特殊字符,引号就别省。

5.3 前导零的经典坑:08和09不是合法数字

bash 算术表达式里,以 0 开头的数字会被当成八进制。问题来了:08、09里的 8 和 9 不是合法八进制数字,直接用会报错。最常踩这坑的场景是处理日期:

month=08 let month+=1

会报let: month+=1: value too great for base (error token is "08")。这是因为08被当成八进制,但 8 这个数字不行。解决办法是强制按十进制解析:

month=08 let "month = 10#$month + 1"

10#number表示把 number 按十进制读。如果变量是08,10#08就是 8,再加 1 得到 9。类似地,从 date 命令拿到的月份、日期,如果带前导零,都要考虑这个问题。

5.4 浮点数、空变量与整数溢出

let 只支持整数,遇到小数直接报语法错误:

let "r = 5 / 2" echo $r # 2,整除 let "r = 5.0 / 2" # 报错

需要小数结果,用 bc 或 awk。另外,未定义变量在算术表达式里会被当成 0,这一点有时会掩盖 bug:

unset b let "a = b + 1" echo $a # 输出 1,b 被当作 0

如果你希望脚本对未定义变量保持警觉,可以在脚本开头加set -u。这样再遇到裸变量,shell 会直接报错,而不是默默当成 0。

还有整数溢出。bash 的算术运算基于机器字长,64 位系统上最大整数是 9223372036854775807。超过这个数会回绕成负数:

let "x = 9223372036854775807 + 1" echo $x # 输出 -9223372036854775808

大多数脚本不会处理这么大的数,但如果你在写涉及时间戳、字节量的逻辑,最好心里有数。

5.5 写脚本时的调试技巧

调试 let 相关脚本,我的习惯是三步。第一步,在脚本开头加set -x,让 shell 打印每条实际执行的命令,这样你能清楚看到 let 表达式展开成了什么。第二步,在关键位置加declare -p 变量名,直接输出变量的类型和值,比echo $变量更直观。第三步,用echo $?检查 let 的返回码,特别是前面说的“表达式结果为 0 返回 1”的坑,一眼就能定位。

举个调试示例:

set -x a=08 let "a = 10#$a + 1" declare -p a

set -x模式下输出会显示实际执行的let 'a = 10#08 + 1',你立刻能看到 10#08 这样的写法。如果不去查资料,光看报错也能想到可能是进制问题。

5.6 面试高频小知识

顺便总结几个我见过面试常提的点。第一个:let 和 expr 的区别是什么?核心是内建命令对外部命令,性能、功能和可移植性的差异。第二个:(( ))和$(( ))的区别是什么?一个是算术命令,一个是算术展开,前者能当条件用,后者主要用于取结果。第三个:为什么let "a=0"返回 1?这就涉及到退出状态与表达式值之间的关系,回答的时候提到“结果为 0 返回 1”即可。第四个:如何在 POSIX sh 中实现算术运算?用$(( )),因为它是 POSIX 标准的一部分,dash 也支持。

6. 兼容性补充:let在dash和POSIX环境下的命运

6.1 bash专属,dash会报command not found

let 是 bash 提供的功能,不代表所有 shell 都有。Ubuntu、Debian 等系统把/bin/sh指向 dash,dash 设计得比 bash 精简得多,不支持 let。你拿sh -c 'let a=1'跑,会直接看到:

sh: 1: let: not found

所以脚本的 shebang 很关键。如果第一行写的是#!/bin/sh,脚本里出现 let 就是不兼容的写法。很多人在 Ubuntu 上写脚本,习惯性用#!/bin/sh,跑起来才意识到问题。遇到这种情况,要么把 shebang 改成#!/bin/bash,要么把 let 改成 POSIX 支持的$(( ))。

6.2 POSIX算术展开是最稳妥的替代

跨 shell 最没有争议的写法,就是$(( ))。它被 POSIX 标准收录,bash、dash、ksh、zsh 基本都支持。举例:

i=0 while [ "$i" -lt 10 ]; do echo "$i" i=$((i + 1)) done

这种写法放到任何符合 POSIX 的 shell 里都能跑。如果你的脚本需要在多种系统间迁移,优先用$(( ))而不是 let 或(( )),因为后两个虽然好用,却依赖 bash 或 ksh 这类具备完整算术命令的环境。

6.3 zsh、ksh的兼容情况

除了 bash,zsh 和 ksh 也支持 let 命令,语法基本一致。所以如果你日常用的是 zsh,写let "a=1+2"也能跑。但从可移植性角度,除非你确定目标环境,我不想推荐把 let 当成默认方案。它更像一个“熟悉 bash 的人在交互式环境下的便利工具”,而不是一套跨平台标准。维护长期项目时,用更通用的写法,能少很多不必要的麻烦。

最后补一句我自己的习惯。写一次性命令或是临时调试,let 确实顺手;但凡是会进版本库、要长期维护的脚本,我会优先写成(( ))或$(( ))。原因很简单:团队里新人不一定熟悉 let 的空格规则,而$(( ))在几乎所有 shell 里都能跑。let 不是不能用,而是要知道它的边界。如果你在维护老脚本时遇到 let,别急着改成别的,先按这个文章的思路确认退出状态和引号,再决定动刀。这是我踩过坑之后最想告诉你的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询