批处理中使用goto命令会导致错误级别丢失

13

考虑以下批处理文件test.bat(PC01已关闭):

mkdir \\PC01\\c$\Test || goto :eof

如果我在命令行窗口中运行那个批处理文件:

> test.bat || echo 99
> if ERRORLEVEL 1 echo 55

输出仅为55,没有99。存在一个错误级别,但是|| 运算符没有检测到它。

如果我使用 cmd /c - 运行该批处理文件

> cmd /c test.bat || echo 99
> if ERRORLEVEL 1 echo 55

输出为空白。Errorlevel为0。

如果我移除 || goto :eof,所有内容都将按预期工作 — 即输出将会是:

99 55

有人知道为什么会发生这种半烤不熟的ERRORLEVEL行为吗?

3个回答

30
在大多数情况下,||是检测错误最可靠的方法。但是你遇到了一种罕见的情况,其中ERRORLEVEL有效但||无效。
问题出在你的错误是在批处理脚本中引发的,||响应于最近执行命令的返回代码。你认为test.bat是一个单独的"命令",但实际上它是一系列命令。脚本中执行的最后一个命令是GOTO :EOF,并且执行成功。因此,你的test.bat || echo 99响应于GOTO :EOF的成功。
当你从脚本中删除||GOTO :EOF时,那么你的test.bat || echo99会看到失败的mkdir结果。但是,如果你在test.bat的末尾添加一个REM命令,那么test.bat || echo 99将响应于REM的成功,并且错误将再次被掩盖。 test.bat || echo 99之后,ERRORLEVEL仍然不为零,因为像GOTOREM这样的命令在成功时不会清除任何先前非零的ERRORLEVEL。这是ERRORLEVEL和返回代码并不完全相同的证据之一。这肯定会让人感到困惑。
你可以使用CALL将test.bat视为一个单元命令,并获得所需的行为。
C:\test>call test.bat && echo OK || echo FAIL
FAIL

C:\test>if ERRORLEVEL 1 (echo FAIL2) else echo OK2
FAIL2

这个方法有效是因为CALL命令会临时将控制权转移到所调用的脚本。当脚本终止时,控制权会返回到CALL命令,并返回当前的ERRORLEVEL。因此,||echo 99响应的是CALL命令本身返回的错误,而不是脚本中最后一个命令的错误。
现在来讲一下CMD /C的问题。 CMD /C返回的代码是执行的最后一个命令的返回代码。
这个可以正常工作:
C:\test>cmd /c call test.bat && echo OK || echo FAIL
FAIL

C:\test>if ERRORLEVEL 1 (echo FAIL2) else echo OK2
FAIL2

因为CMD /C返回CALL语句返回的ERRORLEVEL。

但这种方法完全失败:

C:\test>cmd /c test.bat && echo OK || echo FAIL
OK

C:\test>if ERRORLEVEL 1 (echo FAIL2) else echo OK2
OK2

没有使用CALL时,CMD /C返回最后一个执行命令的返回代码,即GOTO :EOFCMD /C还将ERRORLEVEL设置为相同的返回代码,因此现在没有证据表明脚本中曾经出现过错误。
我们需要深入探究一下。R.L.H.在他的答案和评论中提到,||有时会清除ERRORLEVEL。他提供了似乎支持他结论的证据。但情况并不那么简单,事实证明,||是检测错误最可靠(但仍不完美)的方法。
正如我之前所述,所有外部命令在退出时返回的返回代码与cmd.exe ERRORLEVEL不同。
ERRORLEVEL是在cmd.exe会话本身中维护的状态,与返回代码完全不同。
这甚至在EXIT帮助文档的exitCode定义中有所说明(help exitexit /?)。
EXIT [/B] [exitCode]

  /B          specifies to exit the current batch script instead of
              CMD.EXE.  If executed from outside a batch script, it
              will quit CMD.EXE

  exitCode    specifies a numeric number.  if /B is specified, sets
              ERRORLEVEL that number.  If quitting CMD.EXE, sets the process
              exit code with that number.

当CMD.EXE运行外部命令时,它会检测可执行文件的返回代码并将ERRORLEVEL设置为相应的值。需要注意的是,0表示成功、非零表示错误只是一种约定俗成的说法,并不是所有外部命令都遵循这个约定。例如,HELP命令(help.exe)就没有遵循这个约定 - 当你输入一个无效的命令如“help bogus”时,它返回0,但当你请求一个有效命令的帮助信息时,如“help rem”,它返回1。
当执行外部命令时,“||”运算符不会清除ERRORLEVEL。如果进程退出代码是非零的,则检测到并触发“||”,而ERRORLEVEL仍然与退出代码匹配。话虽如此,出现在“&&”和/或“||”后面的命令可能会修改ERRORLEVEL,因此必须小心。
但是除了外部命令以外,开发人员还关注许多其他情况的成功/失败和返回码/ERRORLEVEL,包括:
- 内部命令的执行 - 重定向操作符“<”、“>”和“>>” - 批处理脚本的执行 - 无效命令执行失败
不幸的是,CMD.EXE在处理这些情况的错误条件时不太一致。CMD.EXE有多个内部点必须检测错误,可能通过某种形式的内部返回代码来检测,而不一定是ERRORLEVEL,在这些点上,CMD.EXE有能力根据它所发现的情况来设置ERRORLEVEL。
对于下面的测试用例,请注意,带空格的“(call )”是清除每个测试前的ERRORLEVEL的晦涩语法。稍后,我还将使用不带空格的“(call)”将ERRORLEVEL设置为1.
同时请注意,我通过使用“cmd /v: on”在命令会话中启用了延迟扩展。大多数内部命令在失败时将ERRORLEVEL设置为非零值,并且错误条件也会触发“||”。在这些情况下,“||”永远不会清除或修改ERRORLEVEL。以下是几个示例:
C:\test>(call ) & set /a 1/0
Divide by zero error.

C:\test>echo !errorlevel!
1073750993

C:\test>(call ) & type notExists
The system cannot find the file specified.

C:\test>echo !errorlevel!
1

C:\test>(call ) & set /a 1/0 && echo OK || echo ERROR !errorlevel!
Divide by zero error.
ERROR 1073750993

C:\test>(call ) & type notExists.txt && echo OK || echo ERROR !errorlevel!
The system cannot find the file specified.
ERROR 1

那么至少有一个命令RD(可能还有其他),以及重定向运算符,当出现错误时触发||,但不会设置ERRORLEVEL,除非使用||

C:\test>(call ) & rd notExists
The system cannot find the file specified.

C:\test>echo !errorlevel!
0

C:\test>(call ) & echo x >\badPath\out.txt
The system cannot find the path specified.

C:\test>echo !errorlevel!
0

C:\test>(call ) & rd notExists && echo OK || echo ERROR !errorlevel!
The system cannot find the file specified.
ERROR 2

C:\test>(call ) & echo x >\badPath\out.txt && echo OK || echo ERROR !errorlevel!
The system cannot find the path specified.
ERROR 1

请查看"rd" exits with errorlevel set to 0 on error when deletion fails, etcFile redirection in Windows and %errorlevel%获取更多信息。
我知道一些内部命令(可能还有其他的)和基本失败的I/O操作可以向stderr发出错误消息,但它们不会触发||,也不会设置非零的ERRORLEVEL。
如果文件是只读的或不存在,DEL命令可以打印错误,但它不会触发||或将ERRORLEVEL设为非零。
C:\test>(call ) & del readOnlyFile
C:\test\readOnlyFile
Access is denied.

C:\test>echo !errorlevel!
0

C:\test>(call ) & del readOnlyFile & echo OK || echo ERROR !errorlevel!
C:\test\readOnlyFile
Access is denied.
OK

请参考https://stackoverflow.com/a/32068760/1012053,了解更多与DEL错误相关的信息。
同样地,当标准输出已成功重定向到USB设备上的文件,但在ECHO尝试写入设备之前设备被移除时,ECHO将失败并出现错误消息到stderr,但是||不会触发,ERRORLEVEL也不会被设置为非零。请参见http://www.dostips.com/forum/viewtopic.php?f=3&t=6881以获取更多信息。
接下来是批处理脚本执行的情况 - 这是OP问题的实际主题。如果没有CALL||运算符将响应脚本内执行的最后一个命令。使用CALL||运算符将响应CALL命令返回的值,这是批处理终止时存在的最终ERRORLEVEL。
最后,我们有R.L.H.报告的情况,其中无效命令通常报告为ERRORLEVEL 9009,但如果使用||,则报告为ERRORLEVEL 1。
C:\test>(call ) & InvalidCommand
'InvalidCommand' is not recognized as an internal or external command,
operable program or batch file.

C:\test>echo !errorlevel!
9009

C:\test>(call ) & InvalidCommand && echo OK || echo ERROR !errorlevel!
'InvalidCommand' is not recognized as an internal or external command,
operable program or batch file.
ERROR 1

我不能证明这一点,但我怀疑在命令执行过程中,命令失败的检测和ERRORLEVEL设置为9009发生得非常晚。我猜想,||在9009被设定之前拦截了错误检测,并将其设置为1。所以我认为||并没有清除9009的错误,而是另一种处理和设置错误的替代路径。

这种行为的另一种机制是:无效的命令始终将ERRORLEVEL设置为9009,但返回代码不同为1。随后,||可以检测到1的返回代码并设置ERRORLEVEL以匹配,从而覆盖9009。

不管怎样,我不知道有任何其他情况,其中非零ERRORLEVEL结果因是否使用||而不同。

这就解决了命令失败时会发生什么。但是当内部命令成功时呢?不幸的是,CMD.EXE甚至比错误更不一致。它因命令而异,还可能取决于它是从命令提示符、带有.bat扩展名的批处理脚本还是从带有.cmd扩展名的批处理脚本中执行。

以下所有讨论都基于Windows 10的行为。我怀疑在使用cmd.exe的早期Windows版本中存在差异,但这是可能的。

以下命令始终在成功时将ERRORLEVEL清除为0,而不考虑上下文:

  • CALL:如果被调用的命令没有设置ERRORLEVEL,则清除ERRORLEVEL。
    示例:call echo OK
  • CD
  • CHDIR
  • COLOR
  • COPY
  • DATE
  • DEL:即使DEL失败也始终清除ERRORLEVEL
  • DIR
  • ERASE:即使ERASE失败也始终清除ERRORLEVEL
  • MD
  • MKDIR
  • MKLINK
  • MOVE
  • PUSHD
  • REN
  • RENAME
  • SETLOCAL
  • TIME
  • TYPE
  • VER
  • VERIFY
  • VOL

下一组命令在成功时永远不会将ERRORLEVEL清除为0,而是保留任何现有的非零值ERRORLEVEL:

  • BREAK:停止当前的FOR循环或者某个标签下的命令行。
  • CLS:清除屏幕上的所有文本。
  • ECHO:打印消息或开启/关闭命令回显。
  • ENDLOCAL:结束对局部变量的使用,将控制权返回到调用程序。
  • EXIT:退出当前批处理程序或命令提示符。 EXIT /B 0 可以清除ERRORLEVEL,但是没有值的EXIT /B会保留之前的ERRORLEVEL。
  • FOR:执行一个指定操作的命令序列,可在文件集合上进行迭代。
  • GOTO:将控制转移到有标签的行。
  • IF:根据条件执行一条或多条命令。
  • KEYS:显示当前的键盘映射。
  • PAUSE:暂停批处理程序并显示消息"Press any key to continue..."。
  • POPD:更改当前目录为先前使用PUSHD命令推送的目录。
  • RD:删除目录。
  • REM:添加注释到批处理文件中。
  • RMDIR:删除目录,该目录必须为空。
  • SHIFT:向左移位命令行参数。
  • START:启动单独的窗口以运行指定的程序或命令。
  • TITLE:设置命令提示符窗口的标题。

此外,这些命令在从带有.cmd扩展名的脚本中调用时,不会清除ERRORLEVEL,但是如果从带有.bat扩展名的脚本或命令行中调用,则会将ERRORLEVEL清除为0。详情请参考https://dev59.com/43VC5IYBdhLWcg3w51ny#148991https://groups.google.com/forum/#!msg/microsoft.public.win2000.cmdprompt.admin/XHeUq8oe2wk/LIEViGNmkK0J

  • ASSOC:显示或修改文件扩展名关联。
  • DPATH:设置或显示搜索路径。
  • FTYPE:显示或修改文件类型关联。
  • PATH:设置或显示可执行文件搜索路径。
  • PROMPT:更改命令提示符。
  • SET:设置、显示或删除Windows环境变量。

无论ERRORLEVEL的值如何,&&操作符都会检测前一个命令是否成功,并且只有在成功时才执行后续命令。 &&操作符忽略ERRORLEVEL的值,永远不会修改它。

以下是两个示例,演示了&&始终在前一个命令成功时触发,即使ERRORLEVEL为非零值。 CD命令是一个清除任何先前ERRORLEVEL的示例,而ECHO命令是一个不清除先前ERRORLEVEL的示例。 注意,我使用(call)强制将ERRORLEVEL设置为1,然后才执行成功的命令

C:\TEST>(call)

C:\TEST>echo !errorlevel!
1

C:\test>(call) & cd \test

C:\test>echo !errorlevel!
0

C:\test>(call) & cd \test && echo OK !errorlevel! || echo ERROR !errorlevel!
OK 0

C:\test>(call) & echo Successful command
Successful command

C:\test>echo !errorlevel!
1

C:\test>(call) & echo Successful command && echo OK !errorlevel! || echo ERROR !errorlevel!
Successful command
OK 1

在我所有关于错误检测的代码示例中,我都依赖于ECHO从不清除先前存在的非零ERRORLEVEL的事实。但是下面的脚本就是一个例子,说明当在&&||之后使用其他命令时可能会发生什么。

@echo off
setlocal enableDelayedExpansion
(call)
echo ERRORLEVEL = !errorlevel!
(call) && echo OK !errorlevel! || echo ERROR !errorlevel!
(call) && (echo OK !errorlevel! & set "err=0") || (echo ERROR !errorlevel! & set "err=1" & echo ERROR !errorlevel!)
echo ERRORLEVEL = !errorlevel!
echo ERR = !ERR!

以下是脚本扩展名为.bat时的输出结果:
C:\test>test.bat
ERRORLEVEL = 1
ERROR 1
ERROR 1
ERROR 1
ERRORLEVEL = 1
ERR = 1

当脚本的扩展名为.cmd时,以下是输出结果:

C:\test>test.cmd
ERRORLEVEL = 1
ERROR 1
ERROR 1
ERROR 0
ERRORLEVEL = 0
ERR = 1

请记住,每个执行的命令都有改变ERRORLEVEL的潜力。因此,即使&&||是检测命令成功或失败最可靠的方式,如果您关心ERRORLEVEL值,那么在这些运算符后使用哪些命令就需要小心了。
现在是时候爬出这个臭氧洞并呼吸新鲜空气了!
那么我们学到了什么?
没有单一完美的方法来检测任何任意命令是否成功或失败。然而,&&||是检测成功和失败最可靠的方法。
通常,&&||都不会直接修改ERRORLEVEL。但是有一些罕见的例外情况。
  • ||会正确设置ERRORLEVEL,否则RD或重定向失败时可能会错过该值。
  • ||在无效命令执行失败时设置不同的ERRORLEVEL,然后如果未使用||,则会发生(1 vs. 9009)。
最后,||不会将批处理脚本返回的非零ERRORLEVEL作为错误检测,除非使用CALL命令。
如果您严格依赖if errorlevel 1 ...if %errorlevel% neq 0 ...来检测错误,那么您可能会错过RD和重定向(等等)可能引发的错误,并且您也可能会错误地认为某些内部命令失败了,而实际上它可能是先前命令失败的遗留问题。

不仅仅是调用批处理文件。我在单个批处理文件中使用错误级别1获得相同的测试结果。 <failed command> || echo %errorlevel% 然后下一行 echo %errorlevel%,第一个与第二个不同。 - RLH
1
@Patrick - 我不知道。我认为你的问题实际上是一个非常好的问题,所以我刚刚给它点了赞。 - dbenham
1
DEL仅在参数错误、无效路径格式和不存在的驱动器时设置错误级别。我的SO答案 - Explorer09
此外,for /F 命令在解析的文件/字符串/命令输出为空的情况下将退出代码设置为非零值,但在这种情况下它不会设置 ErrorLevel。这再次证明了退出代码和 ErrorLevel 是两个不同的概念。 - aschipfl
1
@MichaelBlake - 在那段代码中,DATE和TIME都将ERRORLEVEL清零,但你在输出中没有看到这一点,因为DATE、TIME和ECHO命令都在同一个代码块中。百分号扩展在解析时发生,并且整个块一次性解析。所以你看到的%ERRORLEVEL%是进入该块之前存在的值(在DATE或TIME执行之前)。如果你在块关闭后ECHO %ERRORLEVEL%,那么你将会看到更新为0的值。或者如果你启用了延迟扩展并使用ECHO !ERRORLEVEL!,那么你会在代码块内看到0。 - dbenham
显示剩余14条评论

3

要为批处理文件构建一种解决方案,以在使用goto:eof时设置返回代码,您可以稍微更改脚本。

mkdir \\\failure || goto :EXIT
echo Only on Success
exit /b

:exit
(call)

现在您可以使用:
test.bat || echo Failed

唯一的缺点在于丢失错误级别。
在这种情况下,返回代码设置为false,但错误级别始终设置为1,由于无效的(call)
目前我没有找到任何可能的方法来同时设置错误级别和返回代码为用户定义的值。


谢谢jeb。是的,对我来说解决方法是始终使用cmd /c call xx.bat调用bat文件。'call'是魔法胶水。我的情况是bat的调用在我的控制之下,但bat文件的内容不在我的控制之内。 - Patrick
1
+1,谢谢jeb。你的回答迫使我重新审视了||在没有CALL的批处理脚本中的响应方式。它让我重新发现了||在没有CALL的情况下响应于脚本中最后执行的命令。这是我以前知道但已经忘记的事情。我已经编辑了我的答案,以正确解释使用CALL和不使用CALL之间的区别。 - dbenham

1
真正的错误级别在双竖线(失败时)运算符中无法保存。
要使用逻辑代替。例如,以下是一种可行的方法:
mkdir \\PC01\c$\Test
if "%errorlevel%" == "0" (
echo success
) else (
echo failure
)

编辑:

如果前面的命令不是单个命令,则需要执行更多操作以跟踪错误级别。例如,如果您调用一个脚本,则必须在该脚本中管理将错误级别传递回此代码。

编辑2:

以下是错误级别应为“9009”且其输出的测试场景。

1)无故障管道,使用if逻辑。

setlocal enableDelayedExpansion
mybad.exe 
if "!errorlevel!" == "0" (
echo success !errorlevel!
) else (
echo Errorlevel is now !errorlevel!
echo Errorlevel is now !errorlevel!
)

"mybad.exe"未被识别为内部或外部命令、可操作程序或批处理文件。 Errorlevel现在为9009 Errorlevel现在为9009
2) 管道传输失败,回显。
setlocal enableDelayedExpansion
mybad.exe || echo Errorlevel is now !errorlevel!
echo Errorlevel is now !errorlevel!

'mybad.exe'不被视为内部或外部命令、可操作的程序或批处理文件。

错误级别现在为1

错误级别现在为1

3) 失败管道,跳转

setlocal enableDelayedExpansion
mybad.exe || goto :fail
exit
:fail
echo Errorlevel is now !errorlevel!

'mybad.exe' 不被识别为内部或外部命令,可执行程序或批处理文件。

错误等级现在为1

所以如果你只关心"某个东西"失败了,那么好吧。代码1跟其他任何代码一样好。但是如果你需要知道"什么"失败了,那么你不能只是简单地将失败的结果输出并让它覆盖实际结果。


test.bat 中 mkdir 后面的 || 运算符完美地发挥了作用。 - Patrick
1
这个答案的所有内容都是错误的。||不会清除ERRORLEVEL,GOTO :EOF也不会。修改后的代码也无法解决问题。 - dbenham
1
@dbenham但如果test.bat只包含mkdir命令而没有任何||处理,那么99、55的测试就可以正常工作,所以它肯定像是被||“吃掉”了?我不知道。所有这些都毫无意义,显然^_^ - Patrick
@dbenham,你上面的说法“这个答案的所有内容都是错误的。||不会清除ERRORLEVEL,也不会执行GOTO :EOF。修改后的代码也没有解决问题。”是完全错误的。看看我的例子吧。错误级别在失败的管道命令中并没有被保留下来,而是被清除为“1”。 - RLH
2
@Patrick 和 R L H - 我已经更新了我的答案,解释了为什么我认为这个答案是错误/误导的。我告诉过你们这很复杂!我理解你们得出结论的原因,在没有其他信息的情况下,你们的推理是正确的。但是,不幸的是,批处理并不那么简单。请不要把我的反对票当作个人攻击。它严格反映了答案的内容,而不是你们的努力。你们实际上做了一些好的测试,但导致了错误的结论。 - dbenham
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接