为什么我的 shell 脚本会因为空格或其他特殊字符而卡住?

为什么我的 shell 脚本会因为空格或其他特殊字符而卡住?

…或者是关于强大的文件名处理和 shell 脚本中其他字符串传递的介绍性指南。

我写了一个 shell 脚本,大部分时间都运行良好。但它会因某些输入而阻塞(例如某些文件名)。

我遇到了如下问题:

  • 我有一个包含空格的文件名hello world,它被视为两个单独的hello文件world
  • 我有一个带有两个连续空格的输入行,它们在输入中缩小为一个。
  • 输入行中的前导和尾随空格消失。
  • 有时,当输入包含其中一个字符时\[*?,它们会被一些文本替换,这些文本实际上是某些文件的名称。
  • 输入中有一个撇号'(或双引号),在那之后事情变得很奇怪。"
  • 输入中有一个反斜杠(或者:我正在使用 Cygwin 并且我的一些文件名具有 Windows 样式的\分隔符)。

这是怎么回事?我该如何解决这个问题?

答案1

始终在变量替换和命令替换周围使用双引号:"$foo","$(foo)"

如果您使用不带引号的内容,您的脚本将因包含空格或 的输入或参数(或命令输出,带有 )而$foo阻塞。$(foo)\[*?

在那里,你可以停止阅读。好吧,这里还有一些:

  • read要使用内置函数逐行读取输入read,请使用while IFS= read -r line; do …
    Plainread特别处理反斜杠和空格。
  • xargs避免xargs。如果你必须使用xargs,那就做吧xargs -0。代替find … | xargs更喜欢find … -exec …。特别
    xargs对待空白和字符\"'

这个答案适用于 Bourne/POSIX 风格的 shell ( sh, ash, dash, bash, ksh, mksh, yash...)。 Zsh 用户应该跳过它并阅读结尾什么时候需要双引号?反而。如果你想要完整的细节,阅读标准或者你的 shell 手册。


请注意,下面的解释包含一些近似值(在大多数情况下都是正确的,但可能会受到周围上下文或配置的影响)。

为什么我需要写"$foo"?没有引号会发生什么?

$foo并不意味着“取变量的值foo”。这意味着更复杂的事情:

  • 首先,获取变量的值。
  • 字段拆分:将该值视为以空格分隔的字段列表,并构建结果列表。例如,如果变量包含,foo * bar ​则此步骤的结果是 3 元素列表foo, *, bar
  • 文件名生成:将每个字段视为一个全局变量,即作为通配符模式,并将其替换为与该模式匹配的文件名列表。如果该模式与任何文件都不匹配,则不会对其进行修改。在我们的示例中,这会导致列表包含foo,后面是当前目录中的文件列表,最后是bar。如果当前目录为空,则结果为foo, *, bar

请注意,结果是一个字符串列表。 shell 语法中有两种上下文:列表上下文和字符串上下文。字段分割和文件名生成仅发生在列表上下文中,但大多数情况下都是如此。双引号分隔字符串上下文:整个双引号字符串是单个字符串,不能拆分。 (例外:"$@"扩展到位置参数列表,例如"$@"相当于"$1" "$2" "$3"如果有三个位置参数。请参阅$* 和 $@ 有什么区别?

$(foo)用或 用替换命令也会发生同样的情况`foo`。顺便说一句,不要使用`foo`:它的引用规则很奇怪并且不可移植,并且所有现代 shell 都支持$(foo)除了具有直观的引用规则之外绝对等效的。

算术替换的输出也经历相同的扩展,但这通常不是问题,因为它只包含不可扩展的字符(假设IFS不包含数字或-)。

什么时候需要双引号?有关可以省略引号的情况的更多详细信息。

除非您想让所有这些繁琐的事情发生,否则请记住始终在变量和命令替换周围使用双引号。请注意:省略引号不仅会导致错误,还会导致安全漏洞

如何处理文件名列表?

如果您编写myfiles="file1 file2", 用空格分隔文件,则这不适用于包含空格的文件名。 Unix 文件名可以包含除/(始终是目录分隔符)和空字节(不能在大多数 shell 的 shell 脚本中使用)之外的任何字符。

同样的问题myfiles=*.txt; … process $myfiles。当您执行此操作时,变量myfiles包含 5 个字符的字符串*.txt,并且当您写入时$myfiles,通配符会展开。这个示例实际上会起作用,直到您将脚本更改为myfiles="$someprefix*.txt"; … process $myfiles.如果someprefix设置为final report,则此操作不起作用。

要处理任何类型的列表(例如文件名),请将其放入数组中。这需要 mksh、ksh93、yash 或 bash(或 zsh,它不存在所有这些引用问题);普通的 POSIX shell(例如 ash 或 dash)没有数组变量。

myfiles=("$someprefix"*.txt)
process "${myfiles[@]}"

Ksh88 具有具有不同赋值语法的数组变量set -A myfiles "someprefix"*.txt(请参阅不同ksh环境下的赋值变量如果您需要 ksh88/bash 可移植性)。 Bourne/POSIX 风格的 shell 有一个数组,"$@"即您设置的位置参数数组set,并且它是函数的本地参数:

set -- "$someprefix"*.txt
process -- "$@"

以 开头的文件名怎么样-

在相关说明中,请记住文件名可以以-(破折号/减号)开头,大多数命令将其解释为表示选项。某些命令(如shsetsort)也接受以 开头的选项+。如果您的文件名以可变部分开头,请务必--在其前面传递,如上面的代码片段所示。这向命令表明它已到达选项末尾,因此之后的任何内容都是文件名,即使它以-或开头+

或者,您可以确保文件名以 . 以外的字符开头-。绝对文件名以 开头/,您可以./在相对名称的开头添加。以下代码片段将变量的内容转换f为引用同一文件的“安全”方式,保证不以-nor开头+

case "$f" in -* | +*) "f=./$f";; esac

关于此主题的最后一点是,请注意某些命令会解释-为标准输入或标准输出,即使在--.如果您需要引用名为 的实际文件-,或者如果您正在调用这样的程序并且您不希望它从 stdin 读取或写入 stdout,请确保-按上述方式重写。看“du -sh *”和“du -sh ./*”有什么区别?以供进一步讨论。

如何将命令存储在变量中?

“命令”可以表示三件事:命令名称(可执行文件的名称,带或不带完整路径,或函数名称,内置或别名),带参数的命令名称,或一段 shell 代码。因此有不同的方式将它们存储在变量中。

如果您有命令名称,只需存储它并像平常一样使用带有双引号的变量即可。

command_path="$1"
"$command_path" --option --message="hello world"

如果您有一个带参数的命令,则问题与上面的文件名列表相同:这是一个字符串列表,而不是一个字符串。您不能将参数填充到一个中间有空格的字符串中,因为如果这样做,您将无法区分作为参数一部分的空格和分隔参数的空格之间的区别。如果您的 shell 有数组,则可以使用它们。

cmd=(/path/to/executable --option --message="hello world" --)
cmd=("${cmd[@]}" "$file1" "$file2")
"${cmd[@]}"

如果您使用的 shell 没有数组怎么办?如果您不介意修改位置参数,您仍然可以使用它们。

set -- /path/to/executable --option --message="hello world" --
set -- "$@" "$file1" "$file2"
"$@"

如果您需要存储复杂的 shell 命令(例如重定向、管道等)怎么办?或者如果您不想修改位置参数?然后您可以构建一个包含该命令的字符串,并使用eval内置命令。

code='/path/to/executable --option --message="hello world" -- /path/to/file1 | grep "interesting stuff"'
eval "$code"

请注意 定义中的嵌套引号code:单引号'…'分隔字符串文字,因此变量的值code是字符串/path/to/executable --option --message="hello world" -- /path/to/file1。内置函数eval告诉 shell 解析作为参数传递的字符串,就像它出现在脚本中一样,因此此时会解析引号和管道等。

使用起来eval很棘手。仔细考虑什么时候解析什么。特别是,您不能只将文件名填充到代码中:您需要引用它,就像它在源代码文件中一样。没有直接的方法可以做到这一点。code="$code $filename"如果文件名包含任何 shell 特殊字符(空格、、、、、、等$),则类似的内容会中断。仍然断断续续。如果文件名包含.有两种解决方案。;|<>code="$code \"$filename\"""$\`code="$code '$filename'"'

  • 在文件名周围添加一层引号。最简单的方法是在其周围添加单引号,并将单引号替换为'\''

     quoted_filename=$(printf %s. "$filename" | sed "s/'/'\\\\''/g")
     code="$code '${quoted_filename%.}'"
    
  • 将变量扩展保留在代码内部,以便在计算代码时而不是在构建代码片段时查找它。这更简单,但只有在执行代码时变量仍然具有相同值的情况下才有效,例如如果代码是在循环中构建的,则不起作用。

     code="$code \"\$filename\""
    

最后,您真的需要一个包含代码的变量吗?为代码块命名的最自然的方法是定义一个函数。

怎么了read

没有-r,read允许连续行——这是输入的单个逻辑行:

hello \
world

read将输入行拆分为由 中的字符分隔的字段$IFS(如果没有-r,反斜杠也会转义这些字段)。例如,如果输入是包含三个单词的行,则read first second third设置first为输入的第一个单词、second第二个单词和third第三个单词。如果还有更多单词,则最后一个变量包含设置前面的单词后剩下的所有内容。前导和尾随空白被修剪。

设置IFS为空字符串可以避免任何修剪。看为什么如此频繁地使用“while IFS= read”,而不是“IFS=;”在阅读时..`?以获得更长的解释。

有什么问题吗xargs

的输入格式xargs是空格分隔的字符串,可以选择单引号或双引号。没有标准工具输出这种格式。

xargs -L1或者xargs -l不分割输入线,但每行输入运行一个命令(该行仍然拆分以组成参数,如果以空格结尾,则继续下一行)。

xargs -I PLACEHOLDER确实使用一行输入来替换,PLACEHOLDER但仍会处理引号和反斜杠并修剪前导空格。

您可以xargs -r0在适用的情况下使用(以及可用的情况:GNU(Linux、Cygwin)、BusyBox、BSD、OSX,但不在 POSIX 中)。这是安全的,因为空字节不能出现在大多数数据中,特别是在文件名和外部命令参数中。要生成以空分隔的文件名列表,请使用find … -print0(或者您可以find … -exec …按如下所述使用)。

如何处理 找到的文件find

find … -exec some_command a_parameter another_parameter {} +

some_command需要是外部命令,不能是 shell 函数或别名。如果需要调用 shell 来处理文件,请sh显式调用。

find … -exec sh -c '
  for x do
    … # process the file "$x"
  done
' find-sh {} +

我还有其他问题

浏览在此网站上标记,或或者。 (单击“了解更多...”查看一些一般提示和手动选择的常见问题列表。)如果您进行了搜索但找不到答案,问走

答案2

虽然吉尔斯的回答非常好,但我对他的主要观点有异议

始终在变量替换和命令替换周围使用双引号:“$foo”、“$(foo)”

当您开始使用类似 Bash 的 shell 进行分词时,当然,安全的建议是始终使用引号。然而,分词并不总是执行

§ 分词

这些命令可以正常运行

foo=$bar
bar=$(a command)
logfile=$logdir/foo-$(date +%Y%m%d)
PATH=/usr/local/bin:$PATH ./myscript
case $foo in bar) echo bar ;; baz) echo baz ;; esac

我并不鼓励用户采用这种行为,但如果有人坚定地理解何时发生分词,那么他们应该能够自己决定何时使用引号。

答案3

据我所知,只有两种情况需要双引号扩展,这些情况涉及两个特殊的 shell 参数"$@""$*"- ,它们被指定在用双引号括起来时以不同的方式扩展。在所有其他情况下(也许排除特定于 shell 的数组实现)扩展的行为是可配置的——有一些选项。

当然,这并不是说应该避免双引号 - 相反,它可能是 shell 必须提供的界定扩展的最方便、最可靠的方法。但是,我认为,由于已经熟练地阐述了替代方案,因此这是讨论 shell 扩展值时会发生什么的绝佳场所。

外壳,在它的心脏和灵魂中(对于那些有这样的人), 是一个命令解释器 - 它是一个解析器,就像一个大型的交互式sed.如果你的 shell 语句是窒息空白或类似的情况,那么很可能是因为您还没有完全理解 shell 的解释过程 - 特别是它如何以及为何将输入语句转换为可操作的命令。 shell 的工作是:

  1. 接受输入

  2. 解释和分裂它正确地进入标记化输入

    • 输入是 shell 语法项,例如$wordorecho $words 3 4* 5

    • 总是在空格上分割 - 这只是语法 - 但只有在其输入文件中提供给 shell 的文字空白字符

  3. 如有必要,将其扩展为多个领域

    • 领域结果来自单词扩展 - 它们构成最终的可执行命令

    • 除了"$@"$IFS 场分裂, 和路径名扩展一个输入单词必须始终评估为单个场地

  4. 然后执行结果命令

    • 在大多数情况下,这涉及以某种形式传递其解释结果

人们常说外壳是胶水,并且,如果这是真的,那么它是什么粘着是参数列表 - 或领域- 一个或另一个进程(当它exec是它们时)。大多数 shell 都不能NUL很好地处理字节——如果有的话——这是因为它们已经在字节上进行了分裂。外壳必须exec 很多并且它必须使用NUL当时传递给系统内核的分隔参数数组来完成此操作exec。如果您将 shell 的分隔符与其分隔数据混合在一起,那么 shell 可能会把它搞砸。它的内部数据结构 - 像大多数程序一样 - 依赖于该分隔符。zsh值得注意的是,这并没有搞砸。

这就是$IFS出现的地方。$IFS是一个始终存在且同样可设置的 shell 参数,它定义 shell 应如何将 shell 扩展从单词场地- 特别是关于什么价值这些领域应划定。$IFS在除NUL- 之外的分隔符上分割 shell 扩展,或者换句话说,shell 替换由与其内部数据数组中的$IFSwith值相匹配的扩展产生的字节。NUL当你这样看时,你可能会开始发现每一个场分裂shell 扩展是一个$IFS- 分隔的数据数组。

重要的是要明白,$IFS只有划定界限扩展是不是已经以其他方式分隔 - 您可以使用"双引号来完成。当您引用扩展时,您会在头部对其进行定界,并且至少到其值的尾部。在这些情况下$IFS不适用,因为没有可分隔的字段。事实上,双引号展开式表现出相同的效果场分裂IFS=当设置为空值时,行为为不带引号的扩展。

除非引用,否则$IFS它本身就是一个$IFS定界 shell 扩展。它默认为指定值<space><tab><newline>- 所有三个值包含在 中时都表现出特殊属性$IFS。而 的任何其他值$IFS被指定为评估为单个场地每个扩展发生,$IFS 空白- 这三个中的任何一个 - 指定为每次扩展时删除到单个字段顺序并且前导/尾随序列被完全省略。通过示例这可能最容易理解。

slashes=///// spaces='     '
IFS=/; printf '<%s>' $slashes$spaces
<><><><><><     >
IFS=' '; printf '<%s>' $slashes$spaces
</////>
IFS=; printf '<%s>' $slashes$spaces
</////     >
unset IFS; printf '<%s>' "$slashes$spaces"
</////     >

但这只是$IFS- 只是分词或空白正如所问,那么特殊字符

shell - 默认情况下 - 也会扩展某些未加引号的标记(如此?*[处其他地方所述)分成多个领域当它们出现在列表中时。这就是所谓的路径名扩展, 或者通配。它是一个非常有用的工具,而且,因为它发生在场分裂在 shell 的解析顺序中,它不受$IFS-领域由路径名扩展生成的文件名在文件名本身的头/尾部进行分隔,无论其内容是否包含当前$IFS.此行为默认设置为打开 - 但也可以很容易地以其他方式进行配置。

set -f

这指示外壳不是全局。至少在该设置以某种方式撤消之前,路径名扩展不会发生 - 例如,如果当前 shell 被另一个新的 shell 进程替换,或者......

set +f

...被发送到 shell。双引号 - 就像它们一样$IFS 场分裂- 每次扩展都不需要此全局设置。所以:

echo "*" *

...如果当前启用路径名扩展,每个参数可能会产生非常不同的结果 - 因为第一个参数只会扩展为其字面值(单个星号字符,也就是说,根本没有)如果当前工作目录不包含可能匹配的文件名,则第二个仅相同(它几乎匹配所有这些)。但是,如果您这样做:

set -f; echo "*" *

...两个参数的结果是相同的 -*在这种情况下不会扩展。

答案4

考虑到上面提到的所有安全隐患,并假设您信任并控制您扩展的变量,则可以使用eval.不过要小心!

$ FILES='"a b" c'
$ eval ls $FILES
ls: a b: No such file or directory
ls: c: No such file or directory
$ FILES='a\ b c'
$ eval ls $FILES
ls: a b: No such file or directory
ls: c: No such file or directory

相关内容