我们进行了实验来研究批处理脚本的语法。我们还研究了批处理模式和命令行模式之间的差异。
批处理行解析器:
以下是批处理文件行解析器中各个阶段的简要概述:
阶段 0) 读取线:
阶段 1) 扩张百分比:
阶段 2) 处理特殊字符、标记化并构建缓存的命令块:这是一个复杂的过程,受引号、特殊字符、标记分隔符和脱字符转义等因素的影响。
阶段 3)仅当命令块不以 开头@
且 ECHO 在上一步开始时为 ON 时才回显已解析的命令。
阶段 4) FOR%X
变量扩展:仅当 FOR 命令处于活动状态并且正在处理 DO 之后的命令时。
阶段 5) 延迟扩展:仅在启用延迟扩展的情况下
阶段 5.3) 管道处理:仅当命令位于管道的任一侧时
阶段 5.5) 执行重定向:
阶段 6) CALL 处理/插入符号加倍:仅当命令令牌为 CALL 时
阶段 7) 执行:命令被执行
以下是每个阶段的详细信息:
请注意,下面描述的阶段只是批处理解析器如何工作的模型。实际的 cmd.exe 内部可能无法反映这些阶段。但是这个模型在预测批处理脚本的行为方面是有效的。
阶段 0) 读取行:通过 first 读取输入行<LF>
。
- 读取要解析为命令的行时,
<Ctrl-Z>
(0x1A)被读取为<LF>
(LineFeed 0x0A)
- 当 GOTO 或 CALL 在扫描 :label 时读取行时,
<Ctrl-Z>
, 被视为自身 - 它不会转换为<LF>
阶段 1) 扩张百分比:
- 双倍
%%
被单倍取代%
- 参数的扩展(
%*
, %1
, %2
, 等)
- 的扩展
%var%
,如果 var 不存在,则将其替换为空
- 行首先被截断,
<LF>
不在%var%
扩展范围内
- 要获得完整的解释,请阅读 dbenham 的前半部分同一线程:Percent Phase
阶段 2) 处理特殊字符、标记化并构建缓存的命令块:这是一个复杂的过程,受引号、特殊字符、标记分隔符和脱字符转义等因素的影响。下面是这个过程的近似值。
在这个阶段,有些概念很重要。
- 令牌只是被视为一个单元的字符串。
- 标记由标记分隔符分隔。标准标记分隔符
<space>
<tab>
;
,
=
<0x0B>
<0x0C>
和<0xFF>
连续标记分隔符被视为一个 - 标记分隔符之间没有空标记
- 带引号的字符串中没有标记分隔符。整个带引号的字符串始终被视为单个标记的一部分。单个标记可能由带引号的字符串和不带引号的字符的组合组成。
根据上下文,以下字符在此阶段可能具有特殊含义:<CR>
^
(
@
&
|
<
>
<LF>
<space>
<tab>
;
,
=
<0x0B>
<0x0C>
<0xFF>
从左到右查看每个字符:
- 如果
<CR>
然后将其删除,就好像它从未存在过一样(奇怪的重定向行为除外)
- 如果是插入符号 (
^
),则转义下一个字符,并删除转义的插入符号。转义字符失去所有特殊含义(除了<LF>
)。
- 如果是引号 (
"
),则切换引号标志。如果报价标志处于活动状态,则只有"
和<LF>
是特殊的。在下一个引号将引号标志关闭之前,所有其他字符都会失去其特殊含义。无法逃避结束报价。所有带引号的字符始终在同一个标记内。
<LF>
总是关闭引用标志。其他行为因上下文而异,但引号永远不会改变<LF>
.
- 逃脱
<LF>
<LF>
被剥离
- 下一个字符被转义。如果在行缓冲区的末尾,则下一行由阶段 1 和 1.5 读取和处理,并在转义下一个字符之前附加到当前行。如果下一个字符是
<LF>
,则将其视为文字,这意味着此过程不是递归的。
- 未转义
<LF>
不在括号内
<LF>
被剥离并终止对当前行的解析。
- 行缓冲区中的任何剩余字符都将被忽略。
<LF>
在 FOR IN 带括号的块中
未转义
<LF>
被转换成一个<space>
- 如果在行缓冲区的末尾,则读取下一行并将其附加到当前行。
<LF>
在带括号的命令块中
未转义
<LF>
被转换为<LF><space>
,并且<space>
被视为命令块下一行的一部分。
- 如果在行缓冲区的末尾,则读取下一行并将其附加到空间中。
- 如果是特殊字符之一
&
|
<
或>
,则在此处拆分行以处理管道、命令连接和重定向。
- 在管道 (
|
) 的情况下,每一边都是一个单独的命令(或命令块),在 5.3 阶段得到特殊处理
- 在
&
, &&
, 或||
命令连接的情况下,连接的每一侧都被视为一个单独的命令。
- 在
<
, <<
, >
, 或>>
重定向的情况下,重定向子句被解析,暂时删除,然后附加到当前命令的末尾。重定向子句由可选的文件句柄数字、重定向运算符和重定向目标标记组成。
- 如果重定向运算符前面的标记是单个未转义的数字,则该数字指定要重定向的文件句柄。如果未找到句柄标记,则输出重定向默认为 1 (stdout),输入重定向默认为 0 (stdin)。
- 如果此命令的第一个标记(在将重定向移动到末尾之前)以 开头
@
,则@
具有特殊含义。(@
在任何其他情况下都不特殊)
- 特殊
@
的被删除。
- 如果 ECHO 为 ON,则此命令以及此行上的任何后续连接命令都将从阶段 3 回显中排除。如果
@
是在开头 之前(
,则整个带括号的块被排除在第 3 阶段回声之外。
- 过程括号(提供跨多行的复合语句):
- 如果解析器不是在寻找命令令牌,那么
(
它并不特殊。
- 如果解析器正在寻找命令标记并找到
(
,则开始一个新的复合语句并增加括号计数器
- 如果括号计数器大于 0,则
)
终止复合语句并递减括号计数器。
- 如果到达行尾且括号计数器 > 0,则下一行将附加到复合语句(从阶段 0 再次开始)
- 如果括号计数器为 0 并且解析器正在查找命令,则只要紧跟标记分隔符、特殊字符、换行符或文件结尾
,则其
)
功能类似于语句REM
- 所有特殊字符都失去了意义,除了
^
(行连接是可能的)
- 一旦到达逻辑行的末尾,就丢弃整个“命令”。
- 每个命令都被解析为一系列标记。第一个标记始终被视为命令标记(在
@
去除特殊标记并将重定向移至末尾之后)。
- 命令标记之前的前导标记分隔符被剥离
- 解析命令标记时,
(
除了标准标记分隔符外,还用作命令标记分隔符
- 后续令牌的处理取决于命令。
- 大多数命令只是简单地将命令标记之后的所有参数连接成单个参数标记。保留所有参数标记分隔符。参数选项通常直到第 7 阶段才被解析。
- 三个命令得到特殊处理 - IF、FOR 和 REM
- IF 被分成两个或三个独立处理的不同部分。IF 构造中的语法错误将导致致命的语法错误。
- 比较操作是一直流到阶段 7 的实际命令
- 所有 IF 选项都在第 2 阶段完全解析。
- 连续的标记分隔符折叠成一个空格。
- 根据比较运算符,将识别一两个值标记。
- True 命令块是条件之后的命令集,并且像任何其他命令块一样被解析。如果要使用 ELSE,则 True 块必须用括号括起来。
- 可选的 False 命令块是 ELSE 之后的命令集。同样,这个命令块被正常解析。
- True 和 False 命令块不会自动流入后续阶段。它们的后续处理由阶段 7 控制。
- FOR 在 DO 之后一分为二。FOR 构造中的语法错误将导致致命的语法错误。
- 通过 DO 的部分是贯穿第 7 阶段的实际 FOR 迭代命令
- 所有 FOR 选项都在第 2 阶段完全解析。
- IN 带括号的子句
<LF>
视为<space>
. 解析 IN 子句后,将所有标记连接在一起以形成单个标记。
- 连续的未转义/未加引号的标记分隔符在整个 FOR 命令中通过 DO 折叠成一个空格。
- DO 之后的部分是正常解析的命令块。DO 命令块的后续处理由阶段 7 中的迭代控制。
- 在阶段 2 中检测到的 REM 的处理方式与所有其他命令截然不同。
- 仅解析一个参数标记 - 解析器忽略第一个参数标记之后的字符。
- REM 命令可能出现在第 3 阶段的输出中,但该命令从未执行,并且原始参数文本被回显 - 转义的插入符号不会被删除,除了...
- 如果只有一个以非转义
^
结尾的参数标记结束该行,则丢弃该参数标记,并解析后续行并将其附加到 REM。重复此操作,直到有多个标记,或者最后一个字符不是^
。
- 如果命令令牌以 开头
:
,并且这是阶段 2 的第一轮(由于阶段 6 中的 CALL 而不是重新启动),那么
- 令牌通常被视为Unexecuted Label。
- 该行的其余部分已被解析,但是,
)
, <
,>
不再具有特殊含义。该行的整个剩余部分被认为是标签“命令”的一部分。&
|
- 继续是特殊的
^
,这意味着可以使用续行将后续行附加到标签。
- 带括号的块中的未执行标签将导致致命的语法错误,除非它紧跟在下一行
的命令或执行标签之后。
(
Unexecuted Label之后的第一个命令不再具有特殊含义。
- 标签解析完成后,该命令中止。标签不会发生后续阶段
- 有三个例外情况会导致在阶段 2 中找到的标签被视为在阶段 7 继续解析的执行标签。
- 在标签标记之前有重定向,并且行上有
|
管道或&
、、&&
或||
命令连接。
- 在标签标记之前有重定向,并且命令位于带括号的块内。
- 标签标记是括号内的一行中的第一个命令,上面的行以Unexecuted Label结束。
- 在第 2 阶段发现
执行标签时会发生以下情况
- 标签、它的参数和它的重定向都被排除在第 3 阶段的任何回显输出之外
- 该行上的任何后续连接命令都将被完全解析并执行。
- 有关已执行标签与未执行标签的更多信息,请参阅https://www.dostips.com/forum/viewtopic.php?f=3&t=3803&p=55405#p55405
阶段 3)仅当命令块不以 开头@
且 ECHO 在上一步开始时为 ON 时才回显已解析的命令。
阶段 4) FOR%X
变量扩展:仅当 FOR 命令处于活动状态并且正在处理 DO 之后的命令时。
- 此时,批处理的阶段 1 将已经将 FOR 变量转换
%%X
为%X
. 命令行对于阶段 1 有不同的百分比扩展规则。这就是命令行使用%X
但批处理文件%%X
用于 FOR 变量的原因。
- FOR 变量名区分大小写,但
~modifiers
不区分大小写。
~modifiers
优先于变量名。如果后面的字符~
既是修饰符又是有效的 FOR 变量名,并且存在作为活动 FOR 变量名的后续字符,则该字符被解释为修饰符。
- FOR 变量名是全局的,但仅在 DO 子句的上下文中。如果从 FOR DO 子句中调用例程,则在调用例程中不会扩展 FOR 变量。但是如果例程有自己的 FOR 命令,那么所有当前定义的 FOR 变量都可以被内部 DO 命令访问。
- FOR 变量名可以在嵌套的 FOR 中重复使用。内部 FOR 值优先,但一旦 INNER FOR 关闭,外部 FOR 值就会恢复。
- 如果在此阶段开始时 ECHO 为 ON,则在扩展 FOR 变量后重复阶段 3) 以显示已解析的 DO 命令。
---- 从现在开始,阶段 2 中确定的每个命令都将单独处理。
---- 完成一个命令的阶段 5 到 7,然后再进行下一个命令。
阶段 5) 延迟扩展:仅当延迟扩展打开时,命令不在管道两侧的带括号的块中,并且命令不是“裸”批处理脚本(脚本名称不带括号,CALL,命令连接,或管道)。
- 命令的每个标记都被独立解析以进行延迟扩展。
- 大多数命令解析两个或更多标记 - 命令标记、参数标记和每个重定向目标标记。
- FOR 命令仅解析 IN 子句标记。
- IF 命令仅解析比较值 - 一个或两个,具体取决于比较运算符。
- 对于每个解析的令牌,首先检查它是否包含任何
!
. 如果不是,则不解析令牌 - 对^
字符很重要。如果令牌确实包含!
,则从左到右扫描每个字符:
- 如果是插入符号 (
^
),则下一个字符没有特殊含义,插入符号本身将被删除
- 如果是感叹号,则搜索下一个感叹号(不再观察插入符号),展开到变量的值。
- 连续的开口
!
被折叠成一个单一的!
!
删除任何剩余的未配对
- 在这个阶段扩展 vars 是“安全的”,因为不再检测到特殊字符(甚至
<CR>
或<LF>
)
- 有关更完整的解释,请从 dbenham
相同的线程中阅读第二部分 - 感叹号阶段
阶段 5.3) 管道处理:仅当命令位于管道的任一侧时,管道的
每一侧都被独立且异步地处理。
- 如果 command 是 cmd.exe 内部的,或者它是一个批处理文件,或者它是一个带括号的命令块,那么它会在一个新的 cmd.exe 线程中通过 执行
%comspec% /S /D /c" commandBlock"
,因此命令块会重新启动,但是这次在命令行模式下。
- 如果是带括号的命令块,则所有
<LF>
带有命令的前后都将转换为<space>&
. 其他<LF>
被剥离。
- 管道命令的处理到此结束。
- 请参阅为什么在管道代码块中延迟扩展失败?有关管道解析和处理的更多信息
阶段 5.5) 执行重定向:现在执行在阶段 2 中发现的任何重定向。
阶段 6) CALL 处理/插入符号加倍:仅当命令标记是 CALL,或者如果第一个出现的标准标记分隔符之前的文本是 CALL。如果 CALL 是从更大的命令标记中解析出来的,那么在继续之前,未使用的部分会被添加到参数标记之前。
- 扫描参数标记以查找未引用的
/?
. 如果在令牌中的任何位置找到,则中止第 6 阶段并继续进行第 7 阶段,其中将打印呼叫的帮助。
- 删除第一个
CALL
,因此可以堆叠多个 CALL
- 将所有插入符号加倍
- 重新启动阶段 1、1.5 和 2,但不要继续进行阶段 3
- 只要没有引用,任何双插入符号都会减少回一个插入符号。但不幸的是,引用的插入符号仍然加倍。
- 阶段 1 发生了一些变化 - 步骤 1.2 或 1.3 中的扩展错误中止了 CALL,但错误不是致命的 - 批处理继续进行。
- 第 2 阶段的任务有所改变
- 检测到在第 2 阶段的第一轮中未检测到的任何新出现的未引用、未转义的重定向,但在未实际执行重定向的情况下将其删除(包括文件名)
- 任何新出现在行尾的未引用、未转义的插入符号都将被删除,而不执行续行
- 如果检测到以下任何一项,则 CALL 中止且没有错误
- 新出现的未引用、未转义
&
或|
- 生成的命令标记以未引用、未转义开头
(
- 删除 CALL 后的第一个令牌以
@
- 如果生成的命令是看似有效的 IF 或 FOR,则随后执行将失败并出现错误,指出
IF
或FOR
未被识别为内部或外部命令。
- 当然,如果生成的命令令牌是一个以
:
.
- 如果生成的命令令牌是 CALL,则重新开始第 6 阶段(重复直到不再有 CALL)
- 如果生成的命令令牌是批处理脚本或 :label,则 CALL 的执行完全由阶段 6 的其余部分处理。
- 将当前批处理脚本文件位置推送到调用堆栈上,以便在 CALL 完成时可以从正确的位置恢复执行。
- 使用所有结果标记为 CALL 设置 %0、%1、%2、...%N 和 %* 参数标记
- 如果命令标记是以 开头的标签
:
,则
- 重新启动第 5 阶段。这可能会影响 :label 被调用的内容。但由于 %0 等标记已经设置,它不会改变传递给 CALLed 例程的参数。
- 执行 GOTO 标签以将文件指针定位在子例程的开头(忽略可能跟在 :label 后面的任何其他标记)有关 GOTO 如何工作的规则,请参见第 7 阶段。
- 如果 :label 标记丢失,或 :label 未找到,则立即弹出调用堆栈以恢复保存的文件位置,并中止 CALL。
- 如果 :label 恰好包含 /?,则打印 GOTO 帮助而不是搜索 :label。文件指针不会移动,因此 CALL 之后的代码会执行两次,一次在 CALL 上下文中,然后在 CALL 返回后再次执行。请参阅为什么 CALL 在此脚本中打印 GOTO 帮助消息?为什么之后的命令会执行两次?了解更多信息。
- 否则将控制权转移到指定的批处理脚本。
- CALLed :label 或脚本的执行将继续,直到到达 EXIT /B 或文件结尾,此时弹出 CALL 堆栈并从保存的文件位置继续执行。
第 7 阶段不会针对 CALLed 脚本或 :labels 执行。
- 否则第 6 阶段的结果将进入第 7 阶段执行。
阶段 7) 执行:命令被执行
- 7.1 - 执行内部命令- 如果命令标记被引用,则跳过此步骤。否则,尝试解析出内部命令并执行。
- 进行以下测试以确定未引用的命令标记是否代表内部命令:
- 如果命令令牌与内部命令完全匹配,则执行它。
- 否则在第一次出现
+
/
[
]
<space>
<tab>
,
;
或之前中断命令标记=
如果前面的文本是内部命令,则记住该命令
- 如果在命令行模式下,或者如果命令来自带括号的块,IF真或假命令块,FOR DO命令块,或涉及命令串联,则执行内部命令
- 否则(必须是批处理模式下的独立命令)扫描当前文件夹和 PATH 以查找基本名称与原始命令令牌匹配的 .COM、.EXE、.BAT 或 .CMD 文件
- 如果第一个匹配文件是 .BAT 或 .CMD,则转到 7.3.exec 并执行该脚本
- 否则(未找到匹配项或第一个匹配项是 .EXE 或 .COM)执行记住的内部命令
- 否则在第一次出现
.
\
或之前中断命令标记:
如果前面的文本不是内部命令,则转到 7.2
否则前面的文本可能是内部命令。记住这个命令。
- 在第一次出现
+
/
[
]
<space>
<tab>
,
;
或之前断开命令标记=
如果前面的文本是现有文件的路径,则转到 7.2
否则执行记住的内部命令。
- 如果从较大的命令令牌中解析内部命令,则命令令牌的未使用部分包含在参数列表中
- 仅仅因为命令令牌被解析为内部命令并不意味着它会成功执行。每个内部命令对于如何解析参数和选项以及允许使用什么语法都有自己的规则。
/?
如果检测到,所有内部命令都将打印帮助而不是执行其功能。大多数人都知道/?
它是否出现在论点的任何地方。但是像 ECHO 和 SET 这样的一些命令只有在第一个参数标记以/?
.
- SET 有一些有趣的语义:
- 如果 SET 命令在启用变量名和扩展名之前有一个引号
set "name=content" ignored
--> value=content
则第一个等号和最后一个引号之间的文本将用作内容(第一个等号和最后一个引号除外)。最后一个引号之后的文本被忽略。如果等号后没有引号,则该行的其余部分用作内容。
set name="content" not ignored
如果 SET 命令在 name --> value=之前没有引号,"content" not ignored
则将 equal 之后的行的整个其余部分用作内容,包括可能存在的任何和所有引号。
- 评估 IF 比较,并根据条件是真还是假,从阶段 5 开始处理适当的已解析依赖命令块。
- FOR 命令的 IN 子句被适当地迭代。
- 如果这是一个迭代命令块输出的 FOR /F,那么:
- IN 子句通过 CMD /C 在新的 cmd.exe 进程中执行。
- 命令块必须第二次经历整个解析过程,但这次是在命令行上下文中
- ECHO 将启动,延迟扩展通常会启动禁用(取决于注册表设置)
- 一旦子 cmd.exe 进程终止,IN 子句命令块所做的所有环境更改都将丢失
- 对于每次迭代:
- FOR 变量值已定义
- 然后处理已解析的 DO 命令块,从阶段 4 开始。
- GOTO 使用以下逻辑来定位 :label
- RENAME 和 COPY 都接受源路径和目标路径的通配符。但是微软在记录通配符的工作方式方面做得很糟糕,特别是对于目标路径。可以在Windows RENAME 命令如何解释通配符中找到一组有用的通配符规则?
- 7.2 - 执行音量更改- 否则,如果命令标记不以引号开头,正好是两个字符,并且第二个字符是冒号,则更改音量
- 所有参数标记都被忽略
- 如果找不到第一个字符指定的卷,则中止并出现错误
-
除非使用 SUBST为定义卷,否则命令标记 of将
::
始终导致错误::
::
- 7.3 - 执行外部命令- 否则尝试将命令视为外部命令。
- 如果在命令行模式下并且命令没有被引用并且不以卷规范开头,则空格、
,
,;
或者=
然后+
在第一次出现<space>
,
;
or时中断命令标记,=
并将其余部分添加到参数标记之前。
- 如果命令标记的第 2 个字符是冒号,则验证第 1 个字符指定的卷是否可以找到。
如果找不到该卷,则中止并出现错误。
- 如果在批处理模式下并且命令标记以 开头
:
,则转到 7.4
请注意,如果标签标记以 开头::
,则不会达到此要求,因为前面的步骤将因错误而中止,除非使用 SUBST 为 定义卷::
。
- 确定要执行的外部命令。
- 这是一个复杂的过程,可能涉及当前卷、当前目录、PATH 变量、PATHEXT 变量和/或文件关联。
- 如果无法识别有效的外部命令,则中止并出现错误。
- 如果在命令行模式并且命令标记以 开头
:
,则转到 7.4
请注意,这很少会达到,因为除非命令标记以 开头,否则前面的步骤将中止并出现错误::
,并且 SUBST 用于定义卷::
,并且整个命令令牌是外部命令的有效路径。
- 7.3.exec - 执行外部命令。
- 7.4 - 忽略标签- 如果命令标记以 . 开头,则忽略命令及其所有参数
:
。
7.2 和 7.3 中的规则可能会阻止标签到达这一点。
命令行解析器:
像 BatchLine-Parser 一样工作,除了:
阶段 1) 扩张百分比:
- No
%*
等%1
参数扩展
- 如果 var 未定义,则
%var%
保持不变。
- 无特殊处理
%%
。如果 var=content,则%%var%%
扩展为%content%
.
阶段 3) 回显解析的命令
- 这不会在阶段 2 之后执行。它仅在 FOR DO 命令块的阶段 4 之后执行。
阶段 5) 延迟扩展:仅在启用 DelayedExpansion 时
阶段 7) 执行命令
- 尝试 CALL 或 GOTO a :label 会导致错误。
- 如第 7 阶段所述,已执行的标签可能会在不同情况下导致错误。
- 批量执行的标签只有以
::
- 命令行执行的标签几乎总是导致错误
解析整数值
cmd.exe从字符串中解析整数值的场景有很多,规则不一致:
SET /A
IF
%var:~n,m%
(可变子串扩展)
FOR /F "TOKENS=n"
FOR /F "SKIP=n"
FOR /L %%A in (n1 n2 n3)
EXIT [/B] n
这些规则的详细信息可以在CMD.EXE 如何解析数字的规则中找到
对于任何希望改进 cmd.exe 解析规则的人,DosTips 论坛上有一个讨论主题,可以报告问题并提出建议。
希望对您有所帮助
Jan Erik (jeb) - 阶段的原始作者和发现者
Dave Benham (dbenham) - 更多内容和编辑