TypechoJoeTheme

主机评测

主机评测

专注云服务器评测和优惠码发布

Bash 陷阱(中文翻译)

2025-08-13
/
0 评论
/
846 阅读
/
正在检测是否收录...
08/13

Bash 陷阱原文

目录

  1. 未给变量加引号
  2. cp 缺少引号
  3. - 开头的文件名
  4. 变量未加引号导致语法错误
  5. 命令替换结果未加引号
  6. [ ] 中使用 && 错误
  7. > 是字符串比较而非数值比较
  8. 管道中变量值丢失
  9. if 语法误解
  10. 方括号内缺少空格
  11. 错误的条件组合
  12. read 变量名前加 $ 错误
  13. 读写同一文件导致破坏
  14. 变量未加引号触发通配符展开
  15. 变量赋值不能带 $
  16. 变量赋值等号两边不能有空格
  17. Here 文档用错命令
  18. su 缺少用户名参数
  19. 未检查 cd 是否成功
  20. POSIX [ 不支持 ==
  21. &; 多余分号错误
  22. && 和 || 不是 if…else 替代
  23. 交互模式下 ! 触发历史扩展
  24. for 循环应使用 "$@"
  25. 定义函数时混用 function 和 ()
  26. 波浪线展开规则
  27. local 与命令替换的陷阱
  28. export 波浪线展开不稳定
  29. 单引号内变量不展开
  30. tr 中的通配符和语言环境问题
  31. 查找进程的陷阱
  32. printf 格式化字符串漏洞
  33. 大括号展开不支持变量
  34. 模式匹配 vs 字符串比较
  35. =~ 引号导致失去正则匹配
  36. rm 未加引号
  37. 别名中的命令替换时机
  38. 算术扩展中非数字处理
  39. 变量值非数字时出错
  40. find 性能问题
  41. kill 空列表或超长参数风险
  42. PATH 追加目录的安全隐患
  43. 命令替换的单词拆分问题
  44. eval 命令注入风险
  45. 管道输入下 read 变量为空
  46. set -e 行为不可预测
  47. $0 在 source 时不可靠

1. for f in $(ls *.mp3)

程序员最常犯的错误之一,就是写出这样的循环:

for f in $(ls *.mp3); do    # 错误!
    some command $f         # 错误!
done

for f in $(ls)              # 错误!
for f in `ls`               # 错误!

for f in $(find . -type f)  # 错误!
for f in `find . -type f`   # 错误!

files=($(find . -type f))   # 错误!
for f in ${files[@]}        # 错误!

是的,把 lsfind 的输出当成文件名列表直接迭代看起来很方便,但你不能这样做。这种方法从根本上就有缺陷,没有任何技巧能让它安全工作,必须换一种思路。

至少有 6 个问题:

  1. 如果文件名中包含空格(或 $IFS 中的任意字符),会触发单词拆分(WordSplitting)。假设当前目录有个文件叫 01 - Don't Eat the Yellow Snow.mp3,循环会按 01-Don'tEat… 这样的“单词”来迭代。
  2. 如果文件名中包含通配符(glob)字符,会触发文件名扩展(globbing)。比如 ls 输出中带 *,这个 * 会当作模式去匹配所有符合的文件名。
  3. 如果命令替换返回多个文件名,没有办法区分第一个文件名结束和第二个开始的位置。路径名除了 NUL 之外可以包含任意字符,包括换行。
  4. ls 可能会篡改文件名。不同平台、不同参数、是否输出到终端,ls 都可能用 ? 替换某些字符,甚至不输出某些字符。永远不要用脚本去解析 ls 的输出,因为它是给人看的,不是给程序处理的。
  5. 命令替换会去掉所有尾部换行。如果最后一个文件名本身以换行结尾,$() 会把它移除。
  6. 如果 ls 的第一个文件名以 - 开头,会导致类似 #3 的问题。

双引号也救不了你:

for f in "$(ls *.mp3)"; do  # 错误!

这样会把整个 ls 的输出当作一个单词,循环只执行一次,f 会包含所有文件名拼在一起的字符串。

改变 $IFS 为换行符也没用,因为文件名也可能包含换行。

另一种常见误用是用 for 循环去(错误地)按行读取文件:

IFS=$'\n'
for line in $(cat file); do …     # 错误!

这样是行不通的,尤其当这些行是文件名时。

正确的做法:

如果不需要递归,可以直接用通配符(glob),而不是 ls

for file in ./*.mp3; do    # 更好!
    some command "$file"   # …总是给变量加双引号!
done

通配符是在所有展开操作的最后一步进行的,每个匹配到的文件名都是独立的单词,不会受到未加引号的变量展开副作用。

如果当前目录没有 .mp3 文件,这个 for 会执行一次,file="./*.mp3",这不是我们想要的行为。解决办法是先检测文件是否存在:

# POSIX
for file in ./*.mp3; do
    [ -e "$file" ] || continue
    some command "$file"
done

或者用 shopt -s nullglob,但要先仔细读文档,评估它对脚本中其它通配符的影响。

如果需要递归,可以用 find,并且要用对方法:

find . -type f -name '*.mp3' -exec some command {} \;

# 如果命令支持多个文件名:
find . -type f -name '*.mp3' -exec some command {} +

在 Bash 里还可以用 GNU/BSD find-print0 搭配 read -d ''

while IFS= LC_ALL=C read -r -d '' file; do
  some command "$file"
done < <(find . -type f -name '*.mp3' -print0)

或者在 Bash 4.0+ 用 globstar 支持递归通配符:

shopt -u failglob; shopt -s globstar nullglob # dotglob
for file in ./**/*.mp3; do
  some command "$file"
done

注意,变量展开时一定要加双引号

2. cp $file $target

这个命令有什么问题吗?如果你提前知道 $file$target 中不包含空格(并且没有修改 $IFS),或者文件名中没有通配符,这个命令是可以正常工作的。但实际上,展开结果会受到单词拆分路径名扩展的影响。所以,永远要给参数扩展加双引号。

cp -- "$file" "$target"

没有加双引号时,会得到类似 cp 01 - Don't Eat the Yellow Snow.mp3 /mnt/usb 这样的命令,结果会报错:cp: cannot stat '01': No such file or directory。如果 $file 包含通配符(例如 *?),它们会被展开,如果有匹配的文件,命令会误执行。加了双引号后,一切正常,除非 $file- 开头,这时 cp 会误把它当作命令选项(见下面的 #3)。

即便在一些特殊情况下你可以保证变量内容安全,良好的习惯是总是使用双引号,尤其是文件名这种容易出错的情况。经验丰富的脚本作者几乎会在所有地方加引号,除非在某些情况下完全明确该变量内容没有任何特殊字符。

3. 以短横线开头的文件名

以短横线开头的文件名可能会导致很多问题。像 *.mp3 这样的通配符会被扩展成一个列表,在大多数语言环境下,- 排在字母前面。然后这个列表会传给某个命令,而命令可能会误把 -filename 当作选项。解决办法有两种:

一种是通过在命令(如 cp)和参数之间加 --,告诉它不再查找选项,后面的文件名就是参数:

cp -- "$file" "$target"

另一种方法是确保文件名总是带上目录前缀,无论是相对路径还是绝对路径:

for i in ./*.mp3; do
    cp "$i" /target
    …
done

即使文件名以 - 开头,前面的 ./ 也能确保它不会被误解为选项。

4. [ $foo = "bar" ]

这个问题和上面 #2 类似,但我再重复一次,因为它非常重要。上面这个例子里,引号放错地方了。在 Bash 中,你不需要给字符串字面量加引号(除非它包含元字符或模式字符),但你应该给变量加引号,尤其当你不确定它们是否包含空格或通配符时。

这样写会出问题的原因:

  • 如果 [ 中引用的变量不存在或者为空,[ 会变成这样:

    [ = "bar" ] # 错误!

这会报错:unary operator expected= 操作符是二元的,而不是一元的,[ 看到它会很困惑。

  • 如果变量包含空格,它会被拆分成多个单词,然后传给 [,因此:

    [ multiple words here = "bar" ]

这虽然看起来没问题,但对于 [ 来说是语法错误。正确写法应该是:

bash# POSIX
[ "$foo" = bar ] # 正确!

这样即使 $foo- 开头也不会出问题,因为 POSIX 的 [ 根据参数个数来判断操作。只有非常古老的 shell 会遇到问题,你可以忽略这些情况。

和很多 ksh-like 的 shell 提供了一个更好的替代方法,使用 [[ ]]

lua# Bash / Ksh
[[ $foo == bar ]] # 正确!

[[ ]] 中,左侧的变量不需要加引号,因为它不会进行单词拆分或通配符扩展,空白变量也能正确处理。

5. cd $(dirname "$f")

这是另一个常见的引号错误。像变量展开一样,命令替换(Command Substitution)的结果会经历单词拆分路径名扩展,所以你应该给它加引号:

cd -P -- "$(dirname -- "$f")"

问题在于引号嵌套。C 程序员会期待第一对和第二对双引号在一起,第三对和第四对也是,但 Bash 会把命令替换中的引号当作一对,外部的双引号当作另一对。

6. [ "$foo" = bar && "$bar" = foo ]

不能在 [ ] 这种命令中使用 &&。Bash 解析器看到 && 就会把命令分成两部分,在 && 前后分别执行。应该改成以下两种之一:

[ bar = "$foo" ] && [ foo = "$bar" ] # 正确!(POSIX)
[[ $foo = bar && $bar = foo ]]       # 也对!(Bash / Ksh)

注意在 [ 中,我们交换了常量和变量的位置,这是因为有一些历史原因。你也可以交换 [[ 里面的顺序,但是展开的时候需要加引号防止被当作模式。

避免这种写法:

[ bar = "$foo" -a foo = "$bar" ] # 不符合规范。

-a-o 这些运算符是 XSI 扩展,在 POSIX-2008 中已标记为过时,应该避免在新代码中使用。

7. [[ $foo > 7 ]]

这里有几个问题。首先,[[ 命令不应该仅仅用于评估算术表达式。它应该用于包含测试运算符的表达式。虽然 technically 可以使用 [[ 做数学计算,但只有和非数学测试运算符结合时才有意义。如果你只想进行数值比较(或者任何其他的 shell 算术运算),最好直接使用 (( ))

lua# Bash / Ksh
((foo > 7))     # 正确!
[[ foo -gt 7 ]] # 也可以,但不常见。更推荐使用 ((…))。

如果在 [[ ]] 内部使用 > 运算符,它会把它当作字符串比较(按本地排序规则),而不是整数比较。这通常能正常工作,但有时候会失败。如果你在 [ ] 中使用它,就更糟了:它会被当作输出重定向符号。你会得到一个名为 7 的文件。

如果你需要严格的 POSIX 兼容,且不能使用 (( )),正确的做法是:

perl# POSIX
[ "$foo" -gt 7 ]       # 正确!
[ "$((foo > 7))" -ne 0 ] # POSIX 兼容的等效写法。

如果 $foo 的内容无法保证,或者它来自外部输入,你必须总是验证输入,再进行评估【详细见 Bash FAQ #054】。

8. grep foo bar | while read -r; do ((count++)); done

乍一看这个代码没问题,对吧?但它会有问题,因为管道中的每个命令都在子 Shell中执行。所以即使 count++ 会改变 count,也不会影响外部的 count 变量。

POSIX 并没有规定管道最后的命令是否在子 Shell 中执行。像 ksh93 和 Bash >= 4.2(启用了 shopt -s lastpipe)会在当前 Shell 中执行 while 循环,而其他一些 shell 则在子 Shell 中执行。因此,可移植的脚本应该避免依赖这种行为。

如果你需要解决这个问题,请参考 Bash FAQ #24。

9. if [grep foo myfile]

许多初学者对 if 语句存在错误的直觉,特别是看到 if 后面紧跟着 [[[ 时,容易误以为 [if 语句的一部分,就像 C 语言中的 if 语法一样。

但事实并非如此!if 接受的是命令[ 只是一个命令,而不是 if 语句的语法标记。它等同于 test 命令,只是最终参数必须是 ]

例如:

# POSIX
if [ false ]; then echo "HELP"; fi
if test false; then echo "HELP"; fi

这两个命令是等价的,都会检查参数 false 是否非空,并始终打印 HELP,这会让来自其他语言的程序员感到困惑。

if 语句的语法是:

if COMMANDS
then <COMMANDS>
elif <COMMANDS> # 可选
then <COMMANDS>
else <COMMANDS> # 可选
fi # 必须

if 是一个复合命令,包含两个或更多部分,每部分之间用 thenelifelse 分隔,最后用 fi 结束。第一个部分的最后一个命令及其后的每个 elif 部分决定了是否会执行相应的 then 部分。如果 then 部分没有执行,else 分支会被执行。

如果你想根据 grep 命令的输出做决策,不要把 grep 放在方括号内,而是直接将其作为命令使用:

nginxif grep -q fooregex myfile; then
…
fi

如果 grep 匹配了 myfile 中的某行,它会返回 0(真),然后执行 then 部分。如果没有匹配,grep 会返回非零值,if 命令会判断为假。

10. if [bar="$foo"]; then …

[bar="$foo"]     # 错误!
[ bar="$foo" ]   # 仍然错误!
[bar = "$foo"]   # 也错误!
[[bar="$foo"]]   # 错误!
[[ bar="$foo" ]] # 你猜怎么着? 错误!
[[bar = "$foo"]] # 还需要我说吗…

正如之前的例子所说,[ 是一个命令(可以通过 type -t [whence -v [ 证明),而不是语法标记。所以在 [ 后必须有空格,参数之间也需要空格。正确的写法是:

nginxif [ bar = "$foo" ]; then …
if [[ bar = "$foo" ]]; then …

在第一个写法中,[ 是命令名称,bar="$foo"] 是它的参数。每对参数之间必须有空格,这样 shell 才知道每个参数的开始和结束。第二种写法是类似的,只不过 [[ 是一个特殊的关键字,由 ]] 结束。

11. if [ [ a = b ] && [ c = d ] ]; then …

这里再次出现了问题。[ 是一个命令,而不是用于分组的语法标记。不能像在 C 语言中那样把 if 命令翻译成 Bash 的 if,仅仅通过替换括号为方括号。

如果你想写一个复合条件,应该这样做:

if [ a = b ] && [ c = d ]; then …

这里,if 后有两个命令,通过 && 连接。和这样写等效:

if test a = b && test c = d; then …

如果第一个 test 命令返回 falseif 的主体部分就不会执行。如果它返回 true,第二个 test 命令会被执行,只有第二个命令也返回 true 时,if 的主体部分才会执行。 Bash 使用的是短路求值(short-circuit evaluation)。

[[ 关键字也支持 &&,所以可以这么写:

if [[ a = b && c = d ]]; then …

12. read $foo

read 命令中,不需要在变量名前加 $。如果你想把输入存储到变量 foo 中,应该这样写:

read foo

或者更安全的写法:

IFS= read -r foo

read $foo 会读取一行输入,并将其存储到名为 $foo 的变量中。这个语法在少数情况下有用,但大多数时候它是一个 bug。

13. cat file | sed s/foo/bar/ > file

不能在同一个管道中从文件读取并写入文件。根据管道的内容,文件可能会被覆盖(变成 0 字节,或者可能是操作系统管道缓冲区大小的字节数),或者文件可能会不断增长,直到填满可用磁盘空间、达到操作系统的文件大小限制或配额等。

如果你想安全地修改文件,除了向文件末尾追加外,应该使用文本编辑器:

printf %s\\n ',s/foo/bar/g' w q | ed -s file

如果你的操作不能用文本编辑器做,可以创建一个临时文件:

sed 's/foo/bar/g' file > tmpfile && mv tmpfile file

这会创建一个临时文件,并将修改后的内容替换原文件。只有在 GNU sed 4.x 版本中,才会直接用 -i 选项修改文件:

sed -i 's/foo/bar/g' file(s)

14. echo $foo

这个看似无害的命令会导致极大的困惑。由于 $foo 没有加引号,它不仅会受到单词拆分的影响,还会进行文件通配符扩展。这会让 Bash 程序员误以为变量包含了错误的值,而实际上变量本身是正确的,问题出在扩展过程中。

 msg="Please enter a file name of the form *.zip"
 echo $msg

这条消息会被拆分成多个单词,任何匹配 .zip 的文件都会被扩展。用户看到的输出会是:

Please enter a file name of the form freenfss.zip lw35nfss.zip

示例:

php var="*.zip"   # var 包含星号、点和 zip
 echo "$var"   # 输出 *.zip
 echo $var     # 输出所有匹配的 .zip 文件

为了解决这个问题,应该使用 printf

printf "%s\n" "$foo"

15. $foo=bar

你不能在变量名之前加 $ 来进行赋值,这不是 Perl 的写法。

16. foo = bar

你不能在赋值时在等号两边加空格。这不是 C 语言。写成 foo = bar 会被 shell 拆成三个词,第一个词 foo 会被当作命令名,第二和第三个词会被当作命令的参数。

同样,下面的写法也是错误的:

 foo= bar    # 错误!
 foo =bar    # 错误!
 $foo = bar; # 完全错误!

正确的写法是:

ini foo=bar     # 正确。
 foo="bar"   # 更正确。

17. echo <<EOF

Here 文档是嵌入大块文本数据的一个有用工具,它会将文本重定向到命令的标准输入中。然而,echo 并不是一个读取标准输入的命令。

  # 错误的写法:
  echo <<EOF
  Hello world
  How's it going?
  EOF

  # 正确的做法:
  cat <<EOF
  Hello world
  How's it going?
  EOF

  # 或者,使用可以跨多行的引号(效率更高,echo 是内置命令):
  echo "Hello world
  How's it going?"

用双引号这样做是可以的,所有 shell 都支持,但它不能让你直接在脚本中插入一个文本块。你可以在第一行和最后一行使用语法标记。如果你不想使用 cat,也可以使用 printf

perl  # 或者使用 printf(也是内置的,效率更高):
  printf %s "\
  Hello world
  How's it going?
  "

printf 示例中,第一行的 \ 防止在文本块前添加额外的换行符。最后一行的换行符是因为最后的引号在新的一行。没有在 printf 格式参数中使用 \n,所以它不会在文本末尾添加额外的换行符。

18. su -c 'some command'

这个语法几乎正确。问题是,许多平台上的 su 确实接受 -c 参数,但你想传递的是 -c 'some command' 给一个 shell,也就是说你需要在 -c 前指定用户名。

su root -c 'some command' # 正确

如果不指定用户名,su 会默认使用 root,但当你想传递命令给 shell 时会失败。你必须在这种情况下明确指定用户名。

19. cd /foo; bar

如果你没有检查 cd 命令的错误,你可能会在错误的目录下执行 bar,这可能会导致灾难,尤其是当 bar 是一个危险命令时,比如 rm -f *

必须始终检查 cd 命令的错误。最简单的做法是:

cd /foo && bar

如果有多个命令在 cd 后执行,可以使用:

 cd /foo || exit 1
 bar
 baz
 bat … # 很多命令

cd 会报告目录切换失败,并显示类似 “bash: cd: /foo: No such file or directory” 的错误信息。如果你想输出自定义的错误信息,可以使用命令分组:

 cd /net || { echo >&2 "Can't read /net. Make sure you've logged in to the Samba network, and try again."; exit 1; }
 do_stuff
 more_stuff

这里需要注意 {echo 之间的空格,} 前的分号是必须的。你也可以写一个 die 函数来更简洁地处理。

20. [ bar == "$foo" ]

在 POSIX 的 [ 命令中,== 运算符是无效的。应该使用 =[[

 [ bar = "$foo" ] && echo yes
 [[ bar == $foo ]] && echo yes

在 Bash 中,[ "$x" == y ] 是被接受的扩展,这会让 Bash 程序员误以为它是正确的语法。实际上它是一个Bashism,如果你要使用这些扩展,直接使用 [[ 会更好。

21. for i in {1..10}; do ./something &; done

你不能在 & 后面直接加 ;。只需要去掉多余的 ;

for i in {1..10}; do ./something & done

或者这样写:

 for i in {1..10}; do
   ./something &
 done

& 已经充当了命令结束符,和 ; 是一样的,你不能混用两者。

一般来说, 可以用换行符替代,但并非所有换行符都能用 替代。

22. cmd1 && cmd2 || cmd3

有些人试图用 &&|| 作为 if … then … else … fi 的简写,可能是因为他们认为这样更简洁。例如:

lua # 错误的写法!
 [[ -s $errorlog ]] && echo "Uh oh, there were some errors." || echo "Successful."

这种写法并不等价于 if … fi,因为在 && 后的命令也会产生退出状态,如果它返回非零值(假),那么 || 后的命令也会被执行。比如:

 i=0
 true && ((i++)) || ((i--))  # 错误!
 echo "$i"                   # 输出 0

你可能会认为 i 应该是 1,但实际上它还是 0。原因是 i++ 的退出状态是 1(假),然后 i-- 会被执行。为了解决这个问题,你应该使用简单的 if … fi 语法:

 i=0
 if true; then
   ((i++))
 else
   ((i--))
 fi
 echo "$i"    # 输出 1

23. echo "Hello World!"

这个问题出现在交互式 Bash shell 中(特别是 4.3 版本之前),你会看到类似以下的错误:

bash: !": event not found

这是因为在默认设置下,Bash 会使用 C-shell 风格的历史扩展(history expansion),而 ! 会触发这个扩展。这会影响交互式 shell,在脚本中不会有问题。

最直接的解决办法是关闭历史扩展:

 set +H
 echo "Hello World!"

如果你不想在每次运行时关闭它,可以在 ~/.bashrc 中加入 set +H 来永久禁用。

24. for arg in $*

(和所有 Bourne shell)有一个特别的语法,用于逐个引用位置参数,而 $* 并不是正确的方式。$@ 也不行,它们会展开为参数列表的所有单词,而不是逐个参数。

正确的语法是:

for arg in "$@"

"$@" 会确保每个参数作为单个单词传递给循环体。$*$@ 展开时,必须加双引号以避免出现问题。

25. function foo()

这种写法在某些 shell 中可以运行,但在其他 shell 中会报错。永远不要在定义函数时同时使用 function 关键字和括号 ()

(某些版本)允许你混用这两种语法,但大多数 shell 不接受(zsh 4.x 及更新版本可以)。为了最大化可移植性,建议总是用下面的标准写法:

javascript foo() {
   …
 }

26. echo "~"

波浪线 ~ 的展开(tilde expansion)只有在它不加引号时才会发生。在这个例子中,echo 输出的是 ~,而不是用户主目录的路径。

如果要引用相对于用户主目录的路径,建议使用 $HOME 而不是 ~,比如:

perl "~/dir with spaces"       # 展开结果仍然是 "~/dir with spaces"
 ~"/dir with spaces"       # 展开结果仍然是 "~/dir with spaces"
 ~/"dir with spaces"       # 展开结果是 "/home/my photos/dir with spaces"
 "$HOME/dir with spaces"   # 展开结果是 "/home/my photos/dir with spaces"

27. local var=$(cmd)

在函数中声明局部变量时,local 是一个单独的命令。这种写法有几个问题:

  1. 如果你想捕获命令替换的退出状态 $?,会发现它被 local 的退出状态覆盖了。
  2. 在某些 shell(如 bash)中,local var=$(cmd) 被当作赋值处理,因此右边的命令会被特殊处理;但在另一些 shell 中,它不会被当作赋值,而是会进行单词拆分(如果没有加引号)。

为了避免这些问题,最好分开写:

javascript local var
 var=$(cmd)
 rc=$?

exportreadonly 也有同样的问题。


28. export foo=~/bar

波浪线展开(tilde expansion)只有在它出现在单词的开头时才会发生,或者出现在赋值语句中等号右边的开头位置。

但是 exportlocal 并不总是被 shell 当作赋值处理。在一些 shell(如 bash)中,export foo=~/bar 会进行波浪线展开;在另一些 shell 中则不会。

可移植的写法是:

 foo=~/bar; export foo      # 正确!
 export foo="$HOME/bar"     # 正确!

更好的写法(避免 $HOME/ 时出现双斜杠 //):

export foo="${HOME%/}/bar" # 更好!

29. sed 's/$foo/good bye/'

单引号内,bash 的变量展开(如 $foo)不会发生。这正是单引号的作用——保护 $ 不被展开。

如果你需要变量展开,请用双引号:

foo="hello"; sed "s/$foo/good bye/"

注意,使用双引号可能需要额外转义,具体可参考引号的相关内容。


30. tr [A-Z] [a-z]

这个写法至少有三个问题:

  1. [A-Z][a-z] 会被 shell 当作通配符(glob)处理。如果当前目录中有单字符文件名,命令就会出错。
  2. tr 中,这种写法会额外匹配 [],所以你不需要这对方括号。
  3. 不同的语言环境(locale)下,A-Za-z 的范围可能不只是 26 个 ASCII 字母。在某些语言环境中,字母 z 甚至可能排在中间位置。

解决办法取决于你需要的效果:

 # 如果只想处理 26 个拉丁字母:
 LC_COLLATE=C tr A-Z a-z

 # 如果想根据当前语言环境进行大小写转换:
 tr '[:upper:]' '[:lower:]'

第二种写法中的引号是必须的,用来防止通配符扩展。


31. ps ax | grep gedit

用进程名查找进程是不可靠的,原因包括:

  • 可能有多个同名进程。
  • 进程名可以伪造。
  • 输出中可能包含你自己的 grep 进程。

例如:

perl$ ps ax | grep gedit
10530 ?        S      6:23 gedit
32118 pts/0    R+     0:00 grep gedit

可以用以下方法避免匹配到 grep 自身:

perlps ax | grep -v grep | grep gedit   # 丑,但可用
ps ax | grep '[g]edit'              # 更好,grep 自己不会匹配到

更简洁的做法是在 GNU/Linux 上用:

mathematicaps -C gedit
pgrep gedit

如果需要杀进程,可以用 pkill


32. printf "$foo"

这种写法的问题不在于引号,而在于格式化字符串漏洞。如果 $foo 的内容不受你控制,里面的 %\ 可能会导致意外行为。

安全的做法是始终自己提供格式化字符串:

perlprintf %s "$foo"
printf '%s\n' "$foo"

33. for i in {1..$n}

解析器会在所有其他展开之前执行大括号展开(brace expansion)。因此它看到的只是字面量 $n,不是数字,所以不会展开。

如果循环次数是运行时才知道的,不能用 brace expansion,而应该用算术 for 循环:

for ((i=1; i<=n; i++)); do
…
done

算术循环在处理整数范围时更高效,也更安全。


34. if [[ $foo = $bar ]](取决于意图)

[[ … ]] 中,如果 = 右边没有加引号,Bash 会对它做模式匹配(pattern matching),而不是纯字符串比较。如果 $bar 中包含 *,匹配结果几乎总是 true

如果你要比较字符串,请加引号:

if [[ $foo = "$bar" ]]

如果你是想做模式匹配,请在变量命名或注释中明确说明。


35. if [[ $foo =~ 'some RE' ]]

=~ 右边加引号,会让它变成字符串匹配,而不是正则表达式匹配。

如果你要匹配复杂的正则表达式,又不想写很多反斜杠,可以把正则存到变量里:

luare='some RE'
if [[ $foo =~ $re ]]

这样还能避免 Bash 不同版本在 =~ 上的细微差异。

同样的问题也会出现在模式匹配中:

lua[[ $foo = "*.glob" ]]   # 错!被当作字符串
[[ $foo = *.glob ]]     # 对!被当作模式

36. rm $file(缺少引号)

这个问题在前面已经出现过(见第 2 节、14 节)。如果 $file 中有空格、通配符等特殊字符,没有加引号会导致错误甚至数据丢失。

正确做法:

rm -- "$file"

双引号保护变量不被拆分或扩展,-- 可以防止文件名以 - 开头被当作选项。


37. alias foo='cd $(dirname $BASH_SOURCE)'

在别名定义中使用 $() 会在定义别名时立即执行,而不是在调用时执行,所以 $BASH_SOURCE 会是当前定义别名的脚本文件,而不是调用别名的脚本。

如果你需要运行时执行命令替换,请用函数而不是别名:

foo() {
  cd -- "$(dirname "$BASH_SOURCE")" || return
}

38. echo $((foo=bar))

在算术扩展中,bar 必须是数字,否则会当作变量名引用。比如:

bar=7
echo $((foo=bar))   # foo=7

如果 bar 是字符串,会变成 0。算术扩展只支持整数,不能处理字符串。


39. echo $((foo=$bar+1))(未加引号的变量)

如果 $bar 为空或非数字,会导致算术错误。虽然算术扩展不会触发单词拆分,但它会将 $bar 当作变量引用,并且在某些情况下默默变成 0。

为了安全,最好先验证:

lua[[ $bar =~ ^[0-9]+$ ]] || bar=0
foo=$((bar+1))

40. find . -exec grep foo {} \;

这个写法没错,但性能较低,因为 grep 会被多次调用(每个文件一次)。如果 grep 支持同时处理多个文件,可以这样优化:

find . -exec grep foo {} +

或直接用 grep 的递归功能:

grep -r foo .

41. kill -9 $(ps -C foo -o pid=)

这种写法存在风险:

  1. 如果 ps 没有匹配到进程,会执行 kill -9 而不带 PID(可能会杀掉自己的 shell 进程)。
  2. 如果 $() 展开了很多 PID,可能会超出命令行参数长度限制。

更安全的做法是使用 pkill

pkill -9 foo

或者:

pids=$(ps -C foo -o pid=)
[ -n "$pids" ] && kill -9 $pids

42. export PATH=$PATH:/new/dir

这个写法的问题是如果 $PATH 为空,会在前面留下一个多余的冒号 :,而 : 在 PATH 中表示当前目录 .。这可能带来安全隐患。

更安全的写法:

PATH="${PATH:+$PATH:}/new/dir"
export PATH

43. echo $(<file)

虽然这看似是读取文件的简洁写法,但它会把整个文件内容读到命令替换中,然后再经过单词拆分和通配符扩展,这在大文件或包含特殊字符时会出错。

如果只是要输出文件内容:

cat file

如果要存到变量中:

var=$(<file)   # Bash 特性,比 $(cat file) 高效

44. eval $cmd

eval 会重新解析并执行字符串中的命令,这使它非常危险,尤其当 $cmd 来自用户输入时,可能导致命令注入漏洞。

如果你只是想执行变量中存储的命令及参数,请用数组:

cmd=(ls -l /tmp)
"${cmd[@]}"

这样就不会被 shell 重新解析,也更安全。


45. read foo; read bar

这种写法在交互式模式下没问题,但如果输入是从文件或管道来的,第二个 read 可能直接读到 EOF,导致 bar 为空。

如果要一次读取多个变量:

read foo bar

它会自动按 $IFS 分隔赋值。


46. set -e(误用)

set -e 会在命令返回非零状态时立即退出,但它有许多例外情况(比如在 ifwhileuntil 条件判断中)。这会让脚本行为难以预测。

如果你的目的是在错误时退出,推荐手动检查:

cmd || exit 1

或者:

if ! cmd; then
  echo "error" >&2
  exit 1
fi

47. cd $(dirname "$0")

$0 在被 source.) 运行的脚本中并不总是脚本路径,有时是调用者的 shell 名称。为了获得脚本所在路径,推荐用:

cd -- "$(dirname -- "${BASH_SOURCE[0]}")"

$BASH_SOURCE 在被 source 时仍然能正确指向文件路径。

bash陷阱
朗读

赞 · 0
赞赏
感谢您的支持,我会继续努力哒!
打开手机扫一扫,即可进行打赏哦!
版权属于:

主机评测

本文链接:

https://www.zjpc.cc/1377.html(转载时请注明本文出处及文章链接)

评论 (0)