Shell脚本里那些让你抓狂的小细节,你真的全都搞懂了吗?
[[ $var == “hello” ]]和[ $var = “hello” ]到底谁是谁?
别急着回答,这个问题能问倒一半的Shell初学者。
先说那个方括号[ ],它实则是个命令!没错,[是一个内置命令,和ls、cd没什么本质区别。所以[ $var = “hello” ]实际上是在执行一个测试命令,检查变量是否等于字符串。注意这里只能用单个等号=,而且两边必须有空格。

双括号[[ ]]就高级多了,它是Bash的关键字,不是命令。这意味着它有自己的语法规则,不会像命令那样被解析。在[[ ]]里面,你可以用==、!=这些更直观的操作符,还能用正则表达式匹配!
最要命的是引号处理——[ ]对空变量特别敏感。试试这个:
var=""
[ $var = "hello" ]
报错!由于展开后变成了[ = “hello” ]
[[ $var == “hello” ]]
正常执行,返回false
**空变量在`[ ]`里会直接导致语法错误**,但在`[[ ]]`里完全没问题。这就是为什么现代脚本都推荐用双括号,安全多了。
$?这个神秘符号,居然能告知你命令成功与否?
每次执行命令,系统都会悄悄记录它的“退出状态”。0表明成功,非0表明失败。$?就是读取这个状态值的密码。

别小看这个功能,它在自动化脚本里简直是救命稻草。列如你要检测服务器是否在线:
ping -c 1 google.com > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "网络畅通!"
else
echo "糟糕,连不上网..."
fi
那个> /dev/null 2>&1是把所有输出都扔进黑洞,我们只关心成功与否。$?必定要立即使用,由于它只保存最近一条命令的状态。你要是中间插个echo,状态就被覆盖了。
更高级的用法是直接放在条件判断里:
if ping -c 1 google.com &> /dev/null; then
echo "一切正常"
fi
这样连$?都不用写了,简洁又直观。
set -u:让你的脚本不再容忍未定义变量
写脚本最怕什么?变量名打错还浑然不知!set -u就是治这个毛病的良药。
开启这个选项后,任何使用未定义变量的行为都会立即报错。列如:
!/bin/bash
set -u
name=”张三” echo “你好,$naem”
这里故意打错变量名
运行这个脚本,你会看到清晰的错误信息:“naem: 未绑定的变量”。没有`set -u`的话,脚本会默默输出“你好,”,留给你一脸懵逼。
**生产环境的脚本都应该加上`set -u`**,它能帮你提前发现许多低级错误。配合`set -e`(遇到错误立即退出)和`set -o pipefail`(管道中任意命令失败就失败),你的脚本健壮性会提升好几个档次。
不过要注意,检查变量是否为空要用正确姿势:
```bash
错误方式
if [ -z “$undefined_var” ]; then
触发set -u报错
echo “变量为空” fi
正确方式
if [ -z “${undefined_var:-}” ]; then
使用默认值语法
echo “变量为空或未定义” fi
遍历.sh文件,原来有这么多门道
“找出所有.sh文件”——听起来简单对吧?但新手常犯的错能让你笑出声。
最天真的写法:
for file in *.sh; do
echo "$file"
done
问题来了:如果当前目录没有.sh文件,*.sh不会被展开,而是直接把“.sh”当作文件名!循环会执行一次,输出“.sh”。

安全一点的写法是用find:
find . -name "*.sh" -type f | while read file; do
echo "找到脚本:$file"
done
但这里又有坑:管道创建的while循环是在子Shell里执行的,循环里修改的变量在外部无效。试试这个:
count=0
find . -name "*.sh" | while read file; do
count=$((count + 1))
done
echo "找到 $count 个文件"
输出0!由于count在子Shell里被修改
解决方法是用进程替换:
```bash
while read file; do
处理文件
done < <(find . -name “*.sh” -type f)
或者更现代的写法,用`globstar`选项(Bash 4.0+):
```bash
shopt -s globstar
for file in **/*.sh; do
[[ -f "$file" ]] && echo "$file"
done
1到100求和,Shell居然有五种写法?
你以为求和只能用循环?太天真了!
最经典的for循环:
sum=0
for ((i=1; i<=100; i++)); do
sum=$((sum + i))
done
echo "总和:$sum"
seq命令一行搞定:
echo $(seq 1 100 | tr '
' '+' | sed 's/+$/
/') | bc
这个魔法一样的一行命令,实则是生成了“1+2+3+…+100”的表达式,然后交给bc计算器。
用awk更简洁:
seq 1 100 | awk '{sum+=$1} END{print sum}'
甚至可以用数学公式:
n=100
sum=$((n * (n + 1) / 2))
echo "高斯算法:$sum"

最骚的是用Bash的brace expansion:
echo {1..100} | tr ' ' '+' | bc
不过要注意,大括号展开在数字太多时可能超出命令行长度限制。选择哪种方法取决于具体场景:小数字用循环,大数字用公式或awk,炫技时用那一行魔法命令。
既要看输出又要存日志,鱼和熊掌能兼得吗?
当然能!tee命令就是为这个而生的。
./some_script.sh | tee logfile.txt
这样屏幕上能看到实时输出,同时所有内容都保存到logfile.txt。但等等,错误输出怎么办?默认只捕获标准输出。
完整版应该是:
./some_script.sh 2>&1 | tee logfile.txt
2>&1把错误输出重定向到标准输出,这样错误信息也能被tee捕获。
更实用的是同时记录到日志文件:
./some_script.sh 2>&1 | tee -a app.log
-a参数表明追加,不会覆盖原有日志。生产环境常用这种模式,既方便调试,又保留历史记录。
如果你还想把输出同时发给多个地方:
./some_script.sh 2>&1 | tee >(grep ERROR > errors.log) >(grep WARN > warnings.log) > all.log
这个命令把输出同时送到错误日志、警告日志和完整日志,一箭三雕。
$0、$1、$@、$#:脚本参数的四大护法
写脚本总要处理参数吧?这几个特殊变量就是你的武器库。
$0:脚本自己的名字。但这里有个坑——它可能是相对路径、绝对路径,或者就是脚本名。想获取纯文件名?用basename “$0″。
$1、$2…:第1、2…个参数。必定要用双引号括起来,列如”$1″,否则带空格的参数会出问题。
$#:参数个数。判断是否有参数时别用`[ $
-gt 0 ]`,万一`set -u`开着会报错。用`[ $# -gt 0 ]`或者`(( $# > 0 ))`。

$@和$*的区别:这是经典面试题。不加引号时两者一样,加引号后”$@”是每个参数单独引号包围,”$*”是所有参数合并成一个字符串。
看例子:
脚本 test.sh:
echo "用$@:"
for arg in "$@"; do
echo "[$arg]"
done
echo "用$*:"
for arg in "$*"; do
echo "[$arg]"
done
运行:./test.sh "a b" c
输出:
用$@:
[a b]
[c]
用$*:
[a b c]
处理参数时永远用”$@”,这是唯一能正确处理所有情况的方式。
判断Shell脚本可执行?别只看x标志
你以为-x判断就够了?太年轻!
if [[ -x "$file" ]]; then
echo "文件可执行"
fi
这只能判断文件有执行权限。但有权限不必定是Shell脚本,可能是二进制程序、Python脚本,甚至是个文本文件被人误加了x权限。
真正的Shell脚本判断应该是:
if [[ -x "$file" ]] && [[ -f "$file" ]] && head -n 1 "$file" | grep -q "^#!.*bash|sh"; then
echo "这是一个可执行的Shell脚本"
fi
head -n 1读取第一行,检查是否有shebang(#!)。注意有些脚本可能写#!/usr/bin/env bash,所以模式要灵活些。
更严格的话还要检查文件类型:
if file "$file" | grep -q "shell script"; then
echo "file命令也认为是Shell脚本"
fi
实际开发中,综合使用多种判断最可靠。特别是处理用户上传的文件时,安全检查必须做足。
awk和cut:文本处理的双子星,谁更胜一筹?
awk '{print $1}'和cut -d' ' -f1看起来干一样的事,但细节决定成败。
cut是简单的列切割工具,按固定分隔符工作。空格分隔?没问题。但如果是多个连续空格呢?
echo "a b c" | cut -d' ' -f1
输出a
echo “a b c” | cut -d' ' -f2
输出空!由于-d' '只认单个空格
**awk默认用任意空白字符(空格、制表符)分隔**,而且会压缩连续的空白:
```bash
echo "a b c" | awk '{print $1}'
输出a
echo “a b c” | awk '{print $2}'
输出b,正确处理多个空格
awk更强劲的地方在于能处理复杂情况:
```bash
打印最后一列
awk '{print $NF}'
打印除了第一列的所有列
awk '{$1=””; print substr($0,2)}'
条件打印:只打印第二列大于10的行
awk '$2 > 10 {print $1, $2}'
**简单固定格式用cut,复杂处理用awk**。还有一个常用组合:`awk -F','`指定逗号分隔,处理CSV文件比cut方便多了。
sed替换全文件,这些陷阱你必定要知道
sed 's/old/new/g' file——教科书都这么写,但实际用起来坑不少。
第一个坑:原文件没被修改!sed默认输出到屏幕,要加-i参数才能原地修改:
sed -i 's/old/new/g' file.txt
第二个坑:特殊字符。如果old或new包含斜杠/怎么办?
错误
sed 's/path/to/file/path/to/new/g'
斜杠太多,解析错误
正确:用其他分隔符
sed 's|path/to/file|path/to/new|g' sed 's#old#new#g'
用#号也行
**第三个坑:贪婪匹配**:
```bash
echo "old old thing" | sed 's/old.*thing/NEW/g'
整个字符串被替换
**第四个坑:换行符**。sed默认按行处理,跨行匹配要特殊处理:
```bash
sed ':a;N;$!ba;s/old
old/NEW/g' file.txt
更安全的做法是先用备份:
sed -i.bak 's/old/new/g' file.txt
生成file.txt.bak备份
对于大文件,sed比一些图形编辑器快得多。**掌握sed的正则表达式**,你就能批量处理成千上万个文件,效率提升不是一点半点。
#!/bin/bash和#!/bin/sh:一字之差,天壤之别
脚本第一行这个shebang,写错的人太多了。
#!/bin/sh指向系统的默认Shell,可能是Bash,也可能是dash、ksh等其他Shell。在Ubuntu上,/bin/sh默认是dash,一个更精简、更严格的Shell。
#!/bin/bash明确要求用Bash执行,能用上Bash的所有扩展功能。
区别有多大?试试这个脚本:
!/bin/sh
array=(1 2 3) echo ${array[1]}
用`#!/bin/sh`(实际是dash)运行会报错:“Syntax error: "(" unexpected”。由于**数组语法是Bash的扩展**,标准Shell不支持。
其他Bash特有的功能:
双括号`[[ ]]`
进程替换`<(command)`
大括号展开`{1..10}`
正则表达式匹配`=~`
**安全提议**:如果你用了任何Bash特有功能,就必须写`#!/bin/bash`。如果想让脚本更通用,限制自己只用POSIX标准语法,然后写`#!/bin/sh`。
检查脚本兼容性可以用:
```bash
bash -n script.sh
检查语法
dash -n script.sh
用dash检查
source命令:为什么它能“污染”当前环境?
最后这个问题触及Shell的本质。
当你直接运行脚本时,Shell会创建一个子进程(子Shell)。脚本里的一切操作都在这个沙箱里进行:变量赋值、函数定义、目录切换…脚本结束,沙箱销毁,一切恢复原样。
但source script.sh(或. script.sh)完全不同。它说:别创建子进程了,直接在当前Shell里执行这些命令。
这意味着:
脚本设置的变量在当前Shell中可用
脚本改变目录,你的当前目录真的会变
脚本定义的函数,你可以直接调用
脚本设置的alias,你也能用

这既是强劲功能,也是危险来源。不小心source了一个有exit的脚本?你的终端直接关闭了!
常见用途:
加载配置:. ~/.bashrc
设置环境变量:许多软件安装脚本要你source一个setup文件
定义常用函数:把常用函数放在一个文件里,需要时source
最佳实践:除非明确需要影响当前环境,否则不要用source。特别是运行别人写的脚本前,先看一眼内容,别莫名其妙改了你的环境。
一个保护技巧:
在子Shell中source,避免污染
( source some_script.sh
这里可以检查脚本效果
)
回到这里时,some_script.sh的影响已经消失
Shell脚本就像一把瑞士军刀,小巧但功能丰富。每个细节背后都有设计逻辑,理解这些,你就能写出既健壮又优雅的脚本。别停留在“能用就行”,深入一点,你会发现另一个世界。
Shell脚本里的这些坑,你真的全都踩过吗?看完这篇就够了!
你是不是常常写Shell脚本,却总觉得有些地方不对劲?那些看似简单的语法背后,藏着多少不为人知的秘密?今天我们就来揭开这些谜底,让你彻底告别脚本bug!

[[ $var == “hello” ]]和[ $var = “hello” ]到底有什么区别?
这个问题看起来简单,却让无数人栽了跟头。你以为它们是一样的?大错特错!
先说那个方括号[ ],它是传统的test命令。没错,你没看错,[实则是一个命令!它需要遵循严格的空格规则。如果你写成[ $var = “hello” ],当$var为空时,这个命令就变成了[ = “hello” ],直接语法错误!
双括号[[ ]]就机智多了,它是Bash的内置关键字,不会拆分词。即使$var是空的,[[ $var == “hello” ]]也能正确处理,不会报错。而且[[ ]]支持模式匹配,列如[[ $file == *.txt ]],这在[ ]里是行不通的。
还有一个重大区别:[[ ]]里的==和=是完全一样的,都是字符串比较。但在[ ]里,=用于字符串比较,==在某些Shell里可能不被支持。
简单来说,能用[[ ]]就别用[ ],除非你要思考跨Shell兼容性。
$?的妙用:如何优雅地检测命令执行状态?
$?这个神奇的小东西,记录着上一条命令的退出状态。0表明成功,非0表明失败。但你知道怎么用好它吗?
看看这个常见的ping检测脚本:
!/bin/bash
ping -c 1 baidu.com > /dev/null 2>&1
if [ $? -eq 0 ]; then echo “网络连接正常!” else echo “网络连接失败!” fi
**但这样写实则不够优雅**!更好的做法是直接判断命令:
```bash
if ping -c 1 baidu.com &> /dev/null; then
echo "网络畅通无阻!"
else
echo "哎呀,网络好像断了..."
fi
看到区别了吗?直接在if里执行命令,让代码更简洁清晰。$?的真正价值在于需要具体错误码的时候,列如:
grep "pattern" file.txt
case $? in
0) echo "找到了!" ;;
1) echo "没找到..." ;;
2) echo "文件打不开!" ;;
*) echo "发生了未知错误" ;;
esac

set -u:让你的脚本告别”未定义变量”的噩梦!
你有没有遇到过这样的错误:line 10: var: unbound variable?这就是未定义变量惹的祸!
set -u就是来解决这个问题的。加上这行魔法咒语,脚本里任何未定义的变量都会立即报错,而不是默默地使用空值。
看看这个例子:
!/bin/bash
不加set -u
name=$USERNAME
假设USERNAME没定义
echo “你好,$name!”
运行结果:你好,!
空荡荡的,奇怪但不会报错
目前加上`set -u`:
```bash
!/bin/bash
set -u
name=$USERNAME
这里会立即报错!
echo “你好,$name!”
运行结果:line 3: USERNAME: unbound variable
立即发现问题所在!
**这个特性在调试时特别有用**。它能帮你快速定位那些由于拼写错误导致的变量未定义问题。想象一下,你写的是`$file_path`,不小心打成了`$file_paht`,没有`set -u`的话,这个bug可能隐藏很久都发现不了!
有时候你的确 需要变量为空,这时候可以这样写:`${var:-默认值}`,给变量一个默认值。
遍历.sh文件的几种姿势,你都会吗?
遍历文件是Shell脚本的日常操作,但方法可不止一种!
最直接的方法:
for file in *.sh; do
echo "处理文件:$file"
你的处理逻辑
done
但这个方法有个问题:如果当前目录没有.sh文件,`*.sh`不会被扩展,`file`的值就是字面量的"*.sh"。这时候需要加个判断:
```bash
for file in *.sh; do
if [ -f "$file" ]; then
echo "找到脚本:$file"
fi
done
更安全的方法是用find:
find . -name "*.sh" -type f | while read file; do
echo "处理:$file"
注意:这里是在子Shell中执行
done
**如果要处理带空格的文件名,必须用这个版本:**
```bash
find . -name "*.sh" -type f -print0 | while IFS= read -r -d '' file; do
echo "安全处理:$file"
done
看到-print0和read -d ''了吗?这是处理任意文件名的黄金组合,连换行符都能正确处理!
1到100求和:Shell脚本的数学之美
你以为Shell不能做数学?那就太小看它了!
方法一:用for循环
!/bin/bash
sum=0 for ((i=1; i<=100; i++)); do sum=$((sum + i)) done echo “1到100的和是:$sum”
**方法二:用seq和awk(更简洁)**
```bash
seq 1 100 | awk '{sum+=$1} END{print "和是:" sum}'
方法三:直接用数学公式
echo $((100*101/2))
但这里有个坑要注意:Shell默认只处理整数。如果你需要小数运算,得用bc:
echo "scale=2; 1/3" | bc
输出:.33
一箭双雕:如何同时输出到屏幕和文件?
这个需求太常见了!你运行一个脚本,既想在屏幕上看到实时输出,又想保存日志供后来查看。
最简单的方法:tee命令
./your_script.sh | tee logfile.txt
但这样只能保存标准输出,错误信息呢?看这个:
./your_script.sh 2>&1 | tee logfile.txt
2>&1把标准错误重定向到标准输出,这样所有输出都被tee捕获了。
如果你想追加到日志文件,而不是覆盖:
./your_script.sh 2>&1 | tee -a logfile.txt
更高级的用法:分离输出
有时候你想把正常输出和错误输出分开保存:
./your_script.sh > >(tee stdout.log) 2> >(tee stderr.log >&2)
这个语法有点复杂,但功能强劲。>(…)是进程替换,让tee能同时处理数据。
$0、$1、$@、$#:脚本参数的秘密语言
这几个符号天天见,但你真的清楚它们的区别吗?
$0:当前脚本的名字。但有个小秘密:如果脚本是通过符号链接执行的,$0是链接的名字,不是实际脚本名!
$1、$2…:位置参数。第一个参数、第二个参数…
$@和$*:所有参数。但它们有重大区别!看这个例子:
!/bin/bash
echo “用@:” for arg in “$@”; do echo “参数:[$arg]” done
echo “用*:” for arg in “$*”; do echo “参数:[$arg]” done
运行`./test.sh a b "c d"`,你会发现:
`"$@"`保留了参数边界,三个参数:a、b、"c d"
`"$*"`把所有参数合并成一个字符串:"a b c d"
`$#`:参数个数。判断是否有参数时特别有用:
```bash
if [ $
-eq 0 ]; then
echo “用法:$0 <文件名>…” exit 1 fi
如何判断一个文件是不是可执行的Shell脚本?
这个问题看似简单,实则暗藏玄机!
初级做法:检查扩展名
if [[ $file == *.sh ]]; then
echo "看起来是个Shell脚本"
fi
但这样不靠谱!文件可以没有.sh扩展名,或者有.sh扩展名但不是脚本。
中级做法:检查文件头
if head -1 "$file" | grep -q "^#!/bin/bash|^#!/bin/sh"; then
echo "这是个Shell脚本!"
fi
高级做法:综合判断
is_shell_script() {
local file="$1"
必须是普通文件
[ -f “$file” ] || return 1
必须有可执行权限
[ -x “$file” ] || return 1
检查文件头
case $(head -1 “$file”) in “#!/bin/bash”|”#!/bin/sh”|”#!/usr/bin/env bash”|”#!/usr/bin/env sh”) return 0 ;; *)
还可以检查文件内容
if grep -q “^[[:space:]]*function|^[[:space:]]*if|^[[:space:]]*for” “$file” 2>/dev/null; then return 0 fi return 1 ;; esac }
**最准确的方法:用file命令**
```bash
if file "$file" | grep -q "Bourne-Again shell script|POSIX shell script"; then
echo "确定是Shell脚本!"
fi
awk和cut:文本处理的双子星,你选哪个?
awk '{print $1}'和cut -d' ' -f1看起来功能类似,但差别大了去了!
cut的局限性:
分隔符只能是一个字符
对连续分隔符处理有问题:echo “a b” | cut -d' ' -f2 输出是空,不是b!
不能重新排列字段
awk的强劲之处:
分隔符可以是正则表达式:awk -F'[ ,]+' '{print $1}'
自动处理连续分隔符
能做计算:awk '{print $1, $2, $1+$2}'
能条件过滤:awk '$1 > 100 {print $0}'
看这个实际例子:
处理/etc/passwd
cut -d: -f1,7 /etc/passwd
只能输出两个字段
awk -F: '{print $1, $7}' /etc/passwd
同样功能
但awk还能这样:
awk -F: '{printf “用户%-10s 使用shell:%s
“, $1, $7}' /etc/passwd
**简单规则:简单字段提取用cut,复杂处理用awk**。不过目前许多人直接全用awk,由于功能更强劲。
sed替换技巧:不仅仅是s/old/new/g
s/old/new/g是最基本的sed用法,但sed的能力远不止于此!
基础替换:
sed 's/old/new/g' file.txt
只替换每行的第N次出现:
sed 's/old/new/2' file.txt
只替换第二次出现
**从第M行开始替换:**
```bash
sed '10,$ s/old/new/g' file.txt
从第10行到最后一行
**替换时忽略大小写(GNU sed):**
```bash
sed 's/old/new/gi' file.txt
更安全的替换:使用分隔符 当替换内容包含斜杠时:
错误做法:
sed 's/path/to/file//new/path//g'
语法错误!
正确做法:换分隔符
sed 's|path/to/file|/new/path|g' sed 's#path/to/file#/new/path#g'
**原地替换文件(危险但有用):**
```bash
sed -i.bak 's/old/new/g' file.txt
备份原文件为file.txt.bak
#!/bin/bash和#!/bin/sh:一字之差,天壤之别!
这个问题太重大了!选错了解释器,你的脚本可能到处报错。
#!/bin/sh:指向系统默认的Shell,可能是bash,也可能是dash、ksh等。在Debian/Ubuntu上,/bin/sh一般是dash,一个更轻量但功能较少的Shell。
#!/bin/bash:明确使用Bash,功能最强劲。
关键区别:
[[ ]]、(( ))在sh里可能不支持
数组在sh里可能不支持
进程替换<(cmd)在sh里可能不支持
安全做法:
如果你用了Bash特有功能,必定用#!/bin/bash
如果想写可移植脚本,用#!/bin/sh,但不要用任何扩展功能
更现代的写法:#!/usr/bin/env bash,这样会使用环境中的bash,更灵活
source的秘密:为什么它能改变当前Shell?
这个问题触及了Shell执行的核心机制!
普通执行:./script.sh 或 bash script.sh
启动新的子Shell
脚本在子Shell中运行
脚本结束后,子Shell退出
所有变量、函数、别名都消失
source执行:source script.sh 或 . script.sh
在当前Shell中直接执行脚本
相当于把脚本内容粘贴到当前位置执行
所有改变都保留在当前Shell中
看这个例子:
config.sh
export DATABASE_URL=”mysql://user:pass@localhost/db” alias ll=”ls -la”
普通执行
./config.sh echo $DATABASE_URL
输出空!变量没了
ll
命令找不到!别名没了
source执行
source config.sh echo $DATABASE_URL
输出正确!
ll
正常工作!
**这就是为什么环境配置脚本都要用source执行!** `.bashrc`、`.profile`这些文件,如果你改了之后想让配置立即生效,必须执行`source ~/.bashrc`。
**但要注意危险:** source会改变当前环境,如果脚本里有`exit`,它会直接退出当前Shell!所以source别人的脚本要特别小心。
看完这些,你是不是对Shell脚本有了全新的认识?那些曾经困扰你的问题,目前都有答案了吧!记住,Shell脚本不是玩具,而是强劲的自动化工具。用好了,它能帮你节省无数时间;用不好,它也能制造无数bug。
最好的学习方法就是动手实践。打开终端,把这些例子都敲一遍,感受一下它们的细微差别。信任我,当你真正理解这些概念后,写Shell脚本会变成一种享受!
别再盲目复制粘贴了,理解原理,写出优雅可靠的脚本,这才是程序员的自我修养。从今天开始,让你的Shell脚本水平提升一个档次!
