未加引号的展开会悄悄把一个字符串拆成多个参数。

Shell 的字段拆分把未加引号的变量拆成多个参数

变量保存的是一个字符串,但没有引号的展开可能把它变成多个命令参数。Bash 的分词阶段使用 IFS 指定的分隔字符检查展开结果,因此,手动引用后能正常使用的路径,放入变量再传递时却可能突然找不到文件。排查时首先应观察接收命令实际拿到的参数数量和每个参数的内容,也要区分空字符串与完全没有参数,不能仅凭终端上看起来相同的一行文本判断输入一致。 是否分词取决于上下文 默认 IFS 包含空格、制表符和换行符,它们的连续组合会分隔字段,开头和结尾的这类空白也会被忽略。未引用变量里有两个单词时,命令通常收到两个参数;变量为空时,则通常连一个参数都没有。使用 "$value" 会保留一个参数,即使值是空字符串。对于命令行接口,显式传入空值和省略选项值可能触发完全不同的行为。 分词针对适用上下文中的未引用参数展开、命令替换和算术展开结果,并不是重新解析整条命令。普通赋值语句 copy=$value 会保留原字符串,不执行分词和文件名展开。但随后把 $copy 不加引号地作为命令参数使用,仍会重新引入两个问题。Bash 双方括号条件表达式也具有不同于普通命令参数的展开规则,所以赋值或条件判断成功,不能证明后面的外部程序调用也安全。 IFS 是一组分隔字符,不是多字符分隔串,也不是正则表达式。非空白分隔符和空白的处理并不相同,例如连续两个冒号可以产生一个空字段,而连续默认空白通常只是一个字段边界。把 IFS 设为空字符串会关闭分词,但不会把未引用的空展开变成显式空参数。若数据格式有引号、转义或嵌套结构,应使用理解该格式的解析器,不能把调整 IFS 当作通用解析方法。 坏掉的样子 关闭分词并没有关闭随后的文件名展开。即使 IFS 为空,变量里的星号仍可能匹配当前目录下的文件;目录里刚增加一个文件,同一条命令的参数数量就会改变。引用变量能够同时阻止这两种转换,而修改 IFS 只处理了其中一个阶段。控制通配符的 shell 选项也独立存在,不能把它们和分词配置混为一谈。 另一个常见问题是用一个字符串保存整组参数,然后往字符串内部塞引号。Bash 不会在变量展开后把这些字符重新当作 shell 源码里的引号,因此它们不能恢复原有参数边界。不要用 eval 补救:它会增加一次 shell 解析,让原本的数据获得执行命令的机会。需要在 Bash 中逐步组装命令时,使用数组保存各项,并通过 "${args[@]}" 传递,每项内容和空值都能独立保留。 ...

十二月 31, 2027 · 2 分钟 · XB Field Notes
脚本从命令行收到的、按编号排列的参数。

Shell 位置参数是一份有编号的清单

Shell 脚本接收的是有顺序的参数列表,不是需要重新按空格拆分的一整条命令字符串。位置参数通过 $1、$2 等编号暴露这份列表,$# 表示数量。固定且简单的接口可以直接使用位置,带可选设置或开关的接口则适合明确解析。无论采用哪种形式,都需要规定允许几个参数、是否允许空值,以及输入错误时怎样失败。 解释含义之前,先保住列表边界 $0 表示脚本名称等特殊身份,不属于位置参数,也不计入 $#。使用 bash -c 时,命令字符串之后的第一个参数会成为 $0,包装脚本若误以为它是 $1,就会看起来少收到一个参数。超过第九个位置必须使用花括号:${10} 是第十个参数,$10 却是第一个参数再接一个字面数字零,两种写法不能互换。 "$@" 会为每个传入参数保留一个词,包括空字符串和包含空格的值。未引用的展开可能再次分词并展开通配符,"$*" 则会把列表连成一个字符串。转发或遍历时应使用带引号的完整列表。set -- 替换当前的位置参数,shift 删除前面的若干项;这些操作在对应脚本或函数作用域中生效,不会反向改写外部调用者原来的参数列表。 空参数仍然占据一个位置。检查 $# 可以区分完全没有提供参数,与明确传入 '' 两种情况。${1:?message} 同时拒绝缺失和空值,只有接口要求非空时才合适。应先验证数量,再读取必需的位置,最后判断内容是否合法,避免在参数本来不存在时就触发与接口说明无关的展开错误。 getopts 检查语法,业务规则检查含义 可移植的 getopts 处理短选项。选项字符后面的冒号表示该选项需要一个值,但不会自动提供 --user 这类长选项支持。选项说明开头的冒号表示采用安静错误报告:未知选项产生问号,缺少值产生冒号,相关选项字符放在 OPTARG 中。脚本必须明确处理这些分支,不能因为解析循环继续运行就误以为输入合法。 OPTIND 记录解析进度。在同一 Shell 中开始解析另一份参数之前,应把它重置为一;解析完成后,使用 shift "$((OPTIND - 1))" 让剩余操作数成为当前列表。-- 结束选项处理,因此以连字符开头的文件名也能被当作操作数。普通的非选项同样会结束扫描,不能假定写在文件名后面的选项会被自动搬到前面。 ...

十一月 29, 2027 · 2 分钟 · XB Field Notes
在 Shell 运行命令前就把变量改写成新值的一批运算符。

Shell 参数展开改写变量值

参数展开根据 Shell 参数产生一个值,随后命令才收到展开后的参数。它可以提供默认值、拒绝缺失配置,或者删除字符串的一部分,不必为这些操作启动额外进程。但“执行命令前展开”并不等于“初次解析脚本时就求值”:函数在被调用时读取变量当前的内容,函数定义之后修改变量,会影响后续调用。区分解析、展开和执行,才能解释为什么相同的表达式在不同时间产生不同结果。 默认值要区分未设置与空字符串 ${setting:-default} 在变量未设置或者为空时使用默认值;${setting-default} 只处理未设置的情况,保留调用者明确传入的空字符串。冒号改变的是配置语义,不能当成随意省略的标点。例如空字符串可能表示关闭某个前缀,擅自用默认值替换就会改变用户的选择。${setting:+enabled} 只在变量已设置且非空时产生替代文本,去掉冒号后,已设置但为空也满足条件。 普通默认值展开不会修改变量本身。${setting:=default} 才会在选中默认分支时把结果存进变量,之后再读取就能看到新值。${setting:?message} 则在变量未设置或为空时把诊断写到标准错误,并让非交互 Shell 以非零状态退出。去掉冒号后,这两个运算符也只检查是否设置。应当先确定调用约定:允许为空、允许省略,还是必须提供,然后选择相应形式,而不是把所有配置问题都掩盖成一个默认值。 花括号还负责把变量名与相邻文本分开。"${prefix}_suffix" 是给变量内容追加后缀,"$prefix_suffix" 却读取另一个变量。用几个配置拼文件名时,看似缺值的问题可能只是变量名边界写错。应分别检查各个组成部分,再排查最终路径;一个语法完全合法的字符串,也可能混入来自错误变量的内容。 模式处理字符串,不访问文件 ${name#pattern} 与 ${name##pattern} 分别删除最短和最长匹配前缀;对应的后缀运算符是 % 与 %%。这里使用 Shell 模式,不是正则表达式。匹配不到时保留原字符串,匹配成功也不会自动回写原变量。斜杠在这里就是普通的文本内容,这些运算不会查询目录、确认文件存在或者解析符号链接,因此不能用一次字符串截取证明路径安全。 如果结果必须保持一个参数,就给整个展开加双引号。"${name##*/}" 仍然会应用花括号里的模式,删除直到最后一个斜杠的前缀;外层引号保护的是展开结果,不会让内部星号失去模式含义。这些形式属于可移植的 Shell 操作,而下标截取、全局替换等还可能依赖 Bash 扩展。脚本既然用 sh 启动,就不能仅凭作者的交互环境支持某种写法而假定所有机器都支持。 坏掉的样子 未加引号的展开结果可能继续经历分词与文件名展开。花括号只划定变量名,不保护参数边界;变量包含空格或星号时,命令可能收到额外参数,甚至收到当前目录里碰巧匹配的文件名。另外,默认表达式只有在对应分支被选中时才会展开。如果把命令替换放进默认值,那么缺值分支就可能启动进程,不能把这种表达式当成完全没有副作用的常量。 参数展开也不是命令模板系统。从变量得到的分号、引号等文本,不会自动重新解析成 Shell 语法。需要动态参数列表时,在 Bash 中使用数组,或在可移植 Shell 中明确构造位置参数;不要把整条命令存成字符串,再用 eval 解释。set -u 能发现部分未设置变量,却不能判断业务取值是否合理;pipefail 管理管道状态,也不会改变这里的字符串运算规则。 ...

十一月 16, 2027 · 1 分钟 · XB Field Notes
shell 在目标程序运行前把文件名模式展开。

Bash 的通配符在命令执行前就展开

Bash 命令中的通配符通常会在目标程序启动前变成一组路径。它让批量处理很方便,也让同一行命令受到当前目录和 shell 选项的影响。程序拿到的是已经形成的参数,不会收到标记来告诉它哪些参数来自星号。因此,写命令时应先确定:希望传入实际匹配的文件,还是希望把模式本身交给另一个程序解释。两者需要不同的引号写法,不能依靠目标程序猜测意图。 先匹配,再保留参数边界 Bash 先进行分词,再进行文件名展开。没有引用的星号、问号和方括号模式可能因此生成多个参数。但匹配到的文件名即使包含空格,也仍然是一个参数,因为文件名展开之后不会再执行一次分词。常见问题发生在下一步:循环拿到了完整路径,却用没有引号的变量把它传给另一个命令。循环体中的 "$file" 仍然必要,不能因为前一步通配成功就省掉。 如果接收方需要模式,就应引用模式。例如 find . -name '*.txt' 将匹配规则交给 find;去掉引号后,Bash 会先在当前目录展开它,find 得到的可能是多个文件名,已经不是原来要表达的条件。变量里包含星号时也一样:引用变量会传递字面星号,不引用则可能触发文件名展开。引号只控制当前 shell 如何形成参数,不会禁止接收方按自己的语法解释收到的文本。 通配符匹配目录项,并不保证结果都是普通文件。名为 archive.txt 的目录也能匹配 *.txt,只想处理普通文件时还需要判断类型。普通星号不会越过斜杠,默认也不匹配名字开头的点;需要隐藏文件时,应明确使用相应模式或开启 dotglob。遍历整棵目录树是另一个需求,不能把当前目录的一次展开误认为递归搜索。 环境变量也能改变结果集合。Bash 中非空的 GLOBIGNORE 会过滤匹配结果,并且产生开启 dotglob 的副作用。因此,从交互会话继承状态的脚本可能意外纳入隐藏文件。下面的实验会清除这个变量,并明确指定相关选项,让断言对应一套可重复的环境,而不是依赖读者终端的偶然配置。 坏掉的样子 默认情况下,没有匹配结果的模式会保留为原文。因此,遍历 *.png 的循环可能仍然运行一次,变量内容就是一个并不存在的星号文件名。开启 nullglob 后,未匹配的模式会直接消失,但这也可能让接收方变成零文件参数调用。某些程序此时等待标准输入,另一些程序则使用默认目标。未定义变量检查和管道失败检查都不能证明通配符至少找到了一个文件。 对于允许为空的输入,可以在开启 nullglob 后先收集到 Bash 数组,检查元素数量,再决定是否调用消费者。对于必须存在的输入,failglob 会在未匹配时报告展开错误,并阻止该命令执行。如果只想临时改变行为,把选项修改放在子 shell 中,避免影响调用者的后续命令。交互终端的启动文件可能已经修改选项,所以终端上一次成功不能替代脚本自己的前置条件。 ...

九月 8, 2027 · 1 分钟 · XB Field Notes
脚本可见、能直接把整份位置参数暴露出来的内建变量。

$@ 等特殊变量给出函数的完整入参清单

包装脚本应当把收到的参数按原有边界继续传递。在 Bash 的命令参数位置,"$@" 会让每个位置参数分别成为一个带引号的词,因此能保留空值、空格、换行和字面通配符。只用两个简单文件名测试成功,并不能证明包装脚本正确;一旦它把参数列表拼成字符串再拆开,真实调用者传入的边界就可能丢失。转发是否可靠,应当用能暴露这种差异的输入来验证。 参数列表与拼接文本不同 位置参数属于当前脚本或正在执行的函数。$# 返回参数数量,内容为空字符串的参数也计算在内。"$1" 读取第一个参数,"${10}" 读取第十个;后者需要花括号,以区别于第一个参数后面接字符零的写法。$0 表示启动的 shell 或脚本名称,不会作为额外元素出现在 "$@" 中,不能把它误算进业务参数列表。 在普通命令边界上,"$@" 为每个参数保留一个独立词;完全没有位置参数时,它不产生任何参数。相比之下,"$*" 始终形成一个字符串,用 IFS 的第一个字符连接各个值。IFS 未设置时使用空格,设为空字符串时则不插入分隔符。它并不会删除参数内部原有的空格,但各参数原来的边界通常已经无法恢复,因此不适合转发任意列表。 省略引号的 $@ 不是等价简写,展开后的内容仍可能被分词和文件名展开处理。调用者传入的字面星号,到了包装脚本所在目录可能突然匹配出一组文件。往拼接文本里补引号字符也不能恢复结构。需要把列表保存到之后使用时,在 Bash 中写成 args=("$@"),并通过 "${args[@]}" 转发,即使随后调整位置参数,保存的列表仍保留各个元素。 坏掉的样子 函数执行期间拥有自己的位置参数列表。调用时传入的参数暂时替换当前可见列表,返回时恢复调用者的列表。函数内部使用 set 或 shift 修改的是这次函数调用的参数,但这不代表其他所有 shell 状态都自动局部化。例如,没有局部声明就修改 IFS,可能影响调用者之后的命令。参数作用域和普通变量作用域应分别理解,不能互相推导。 Shift 会删除前面的若干参数,并对剩余参数重新编号。解析选项时,必须先确认剩余参数足够,再消费选项及其值;要求删除的数量超过现有参数时会失败。仅检查当前数量大于零,还不足以处理需要额外值的选项。如果之后的错误提示或转发还需要被消费的原始值,应在开始解析前保存数组,避免试图从拼接日志中重新恢复。 引号也不能阻止接收程序把完整参数解释为选项。例如,文件名恰好为 --help 时,"$@" 会完整保留它,但目标工具仍可能显示帮助而不读取文件。传入操作数时,应使用工具文档规定的选项终止符,或适当添加路径前缀。包装脚本必须明确自己的选项在哪里结束,以及哪些剩余参数仍是下游命令的选项,不能不加区分地自动插入分隔符。 ...

四月 16, 2027 · 2 分钟 · XB Field Notes
每个进程结束时会留下、用于说明它怎么收场的一个小整数。

退出码是用一个数字汇报成败

退出状态是 shell 用来决定下一步执行什么的小整数。对 shell 控制流来说,零表示成功,非零进入失败分支,但这并不意味着每个数字都有统一的业务含义。搜索没有匹配、输入格式错误和服务器不可用,都可能返回非零,却需要不同处理。决定重试、忽略或终止之前,应先阅读目标命令的契约。不能只因为状态不为零,就把所有结果归为同一种事故。 让状态始终对应原来的命令 Bash 通过 $? 提供最近前台命令的状态。需要保留时,必须在记录日志、执行另一个条件判断或清理操作之前保存;这些操作也有自己的状态,会覆盖原本想观察的证据。函数通常使用最后执行的命令状态作为返回值,因此,末尾一句成功的日志输出就可能把前面的失败包装成整个函数成功,调用者看到的结果与实际工作脱节。 当命令成功与否决定后续动作时,可以使用 if command; then ... else ... fi。如果需要精确退出码,在 else 分支第一步保存 $?,输出诊断或清理后再通过 return 或 exit 显式传播。不要写成 if ! command,却期待进入成功分支后的 $? 仍然是原始失败码:逻辑取反已经把非零结果转换成成功,数值含义在这里发生了变化。 Bash 在找不到命令时使用一百二十七,找到却无法执行时使用一百二十六。因致命信号终止时,它使用一百二十八加信号编号的约定。这些规则有助于理解 shell 的报告,但应用也可以主动返回相同数字。因此,只有一个状态值还不足以证明内部具体发生了哪件事;应同时保留失败操作的身份和标准错误信息,避免根据数字臆造故障原因。 坏掉的样子 管道默认使用最后一段的退出状态。上游生成器失败时,下游仍可能成功读取一个空流或不完整数据,从而让整体看起来成功。开启 pipefail 后,Bash 返回从左到右最靠右的非零状态;全部成功才为零。PIPESTATUS 能提供每一段的结果,但后续命令同样会覆盖这个数组,所以必须先完整保存观察结果,再进行打印或判断。 set -e 开启的 errexit 具有由语法和调用上下文决定的例外。作为 if 条件的失败、与或列表中的部分命令,以及其他文档规定的场景,都不会自动结束脚本。函数被用作条件时,函数体中的失败命令之后也可能继续执行。ERR trap 也有类似排除规则,并不是每次失败都会产生的通知机制。两者都不能证明整个脚本已经可靠处理所有错误。 ...

三月 27, 2027 · 1 分钟 · XB Field Notes
一段可复用、按位读取参数并管理局部变量的块。

Shell 函数像脚本一样读取自己的参数

Shell 函数把一组命令组织到同一个名称之下,并接收本次调用自己的位置参数。普通调用时,函数体在当前 Shell 中执行,不会自动启动独立进程。这方便复用操作,也意味着赋值、切换目录及其他 Shell 状态变化可能影响调用方。函数接口除了规定输入,还应明确输出、退出状态和有意产生的副作用,不能只因为把几行命令包进了函数,就认为它们已经与外层隔离。 每次调用都有自己的参数列表 调用 demo_func first second 后,这次函数内部的前两个位置分别是传入的两个值,参数数量为二。再次调用其他函数,会暂时安装下一层自己的参数列表;返回后,调用者的列表重新可见。因此,在被调用函数中移位,不会消耗调用者的位置参数。Bash 的 $0 也不会因此变成当前函数名称,不能用它判断自己正在哪个函数里面。 使用 "$@" 转发整份列表,才能保留空参数、空格以及通配符字符各自的边界。应先检查数量,再读取必需的位置,同时区分省略参数与明确传入空字符串。Shell 函数没有自动执行这些规则的参数声明,调用时语法上能写出一串值,并不意味着这些值已经满足函数的业务约定。 局部变量使用动态作用域 Bash 的局部声明会创建一个变量,遮住外层的同名绑定;函数返回之后,原来的绑定再次可见。若修改调用者变量并不是接口的一部分,应为临时值、循环计数器等声明局部变量。面向多个解释器的可移植脚本,不能假设每个 Shell 都实现了 Bash 的这套局部变量设施,解释器要求必须与使用的语法保持一致。 但 Bash 采用动态作用域:被另一个函数调用的函数,可以看到调用者已经声明的局部变量。如果它没有声明自己的局部变量,就直接给同名变量赋值,修改的可能正是调用者的局部绑定。最外层的全局值依然受保护,但中间那层函数的值已经改变。因此,局部变量不是其他函数完全不能接触的词法私有对象,沿真实调用链测试才看得见这种影响。 局部声明也不会自动恢复工作目录、Shell 选项、陷阱,或函数体已经改写的文件。这些效果都需要各自的约定。如果把函数放进普通命令替换,它会在子 Shell 环境中运行,变量和目录变化不会回到外层,但文件写入仍可能保留。捕获输出改变的是执行边界,不是为任意操作提供一套安全沙箱。 状态与数据走不同通道 没有显式返回时,函数通常采用最后执行命令的状态。标准输出应承载约定的数据,诊断则写到标准错误。return 7 把一个状态交给调用者,不是返回文本数字作为数据;exit 会结束正在执行的 Shell,而不只是离开当前函数。设计辅助函数时,需要让调用者知道究竟应读取哪个通道,以及失败后还能不能使用已经打印出的部分结果。 当生产者退出状态重要时,把局部声明与命令替换赋值分开。写成 local result=$(producer) 时,观察到的是声明内建命令的状态,它可能掩盖生产者失败;先声明,再单独赋值,赋值语句才能反映命令替换的状态。之后仍需在日志或清理覆盖它之前保存。把两句合成一行虽然简短,却可能改变错误传播,不能当作纯粹的排版差异。 ...

三月 16, 2027 · 1 分钟 · XB Field Notes
一种在词内启动子进程、以其 stdout 顶替原内容的运行时展开。

命令替换把一条命令的输出嵌进另一行

命令替换会收集一个命令的标准输出,再把得到的文本放入外层表达式。常见的 $(command) 适合获取时间戳、计算出的标签或其他较小的文本值,但它并不是任意字节的透明传输通道,也不是可靠的文件名列表容器。使用前需要理解三个边界:末尾换行如何处理、内部命令在哪个执行环境运行,以及失败状态如何传递。只看到变量里有内容,不能证明整个操作成功。 先收集文本,再决定怎样使用 本文讨论的标准命令替换形式会让 Bash 在子 shell 环境中运行内部命令,收集标准输出,并删除末尾所有换行符。位于文本中间的换行仍然保留。双引号能阻止后续分词和文件名展开,却不能恢复已经删除的尾部换行。如果需要精确保留最后几个字节,应该使用文件或数据流,而不是依靠 shell 变量原样往返。 赋值语句 result=$(producer) 会把处理后的文本存进变量,不在此处拆成多个参数。后续使用时仍应写成 "$result",保留它的参数边界。类似地,consumer "$(producer)" 会传递一个参数,即使结果为空。去掉引号可能得到多个参数,也可能完全没有参数;输出中的星号还可能匹配当前目录的文件,让同样的生产者输出在不同目录产生不同调用结果。 标准错误和标准输出彼此独立,除非主动重定向,诊断信息仍可能显示在终端,而变量只收集结果正文。将两路输出合并后,警告文本可能污染本应供程序读取的值。因此,应先确定生产者的输出契约,再决定是否合并;直接吞掉所有诊断信息也可能让空结果失去可解释的原因。输出内容、诊断信息和退出状态应分别检查。 坏掉的样子 子 shell 内修改变量或执行 cd,不会改变调用者对应的变量和工作目录。但文件系统或网络副作用并不会因此消失:在命令替换里创建的文件是真实文件,发出的请求也是真实请求。执行环境隔离不能被误解为事务回滚,也不能保证内部操作没有外部影响。 有输出也不代表生产者成功。仅包含赋值的命令会使用最后一个命令替换的退出状态,因此 if result=$(producer); then 可以检查生产者结果。相反,printf '%s' "$(producer)" 的状态通常来自 printf,而不是内部命令。生产者可以先输出部分内容,再失败退出;应立即保存退出码,并明确失败时是否允许使用这些不完整内容。后面的打印日志命令会覆盖 $? 所指向的最近状态。 多条命令组成的替换尤其需要明确失败传播:前一步失败后,最后一步仍可能成功,从而使整个替换返回成功。重要操作应在生产者内部显式判断,并返回预期状态。函数中如果需要检查捕获赋值的状态,也应把 local 声明和赋值分成两步,避免声明内建命令掩盖生产者退出码。不能仅凭脚本开启了某个全局选项,就假设每种上下文都会按同样方式中止。 命令替换还会在外层命令运行前收集输出,因此不适合无限输出或需要逐步消费的交互数据。不要通过未引用的 find 输出构造任意文件名列表,空白和通配字符会破坏原有路径边界。可以改用 find 的 exec 接口直接传递路径,或者选择明确定义边界的记录协议。是否需要一个文本值,还是一组独立记录,应在选择语法前确定。 ...

三月 3, 2027 · 1 分钟 · XB Field Notes