"|| exit /b"和"|| exit /b !errorlevel!"之间的区别是什么?

14

我们有一堆.bat构建脚本,由基于PowerShell的GitLab runner调用,最近这些脚本从以下形式进行了重构:

program args
if !errorlevel! neq 0 exit /b !errorlevel!

更简洁地说:

program args || exit /b

今天我调查了一个构建作业,显然如果你看错误日志,该作业失败了,但被报告为成功。经过多次尝试,我发现这种模式并不总是按预期工作:

program args || exit /b

但是当前面的方法不起作用时,这个方法似乎有效:

program args || exit /b !errorlevel!

我已经阅读了这个Windows批处理中关于选项b的退出方式,带或不带错误级别的SO问题以及来自https://www.robvanderwoude.com/exit.php的下面陈述,但仍然不能完全解释我观察到的现象。

DOS在线帮助(HELP EXIT)并未明确说明/B参数退出当前脚本实例,但并不一定是退出当前脚本。也就是说,如果脚本在被CALL的代码段中,EXIT /B将退出CALL而不是脚本本身。


这是我用来探索这个问题的最小批处理文件:

@echo off
setlocal EnableDelayedExpansion
cmd /c "exit 99" || exit /b
:: cmd /c "exit 99" || exit /b !errorlevel!

这是我如何调用批处理文件的方式(模拟GitLab PowerShell Runner调用它的方式):

& .\test.bat; $LastExitCode

这取决于批处理文件中执行哪一行,以下是输出结果:

PS> & .\test.bat; $LastExitCode
0
PS> & .\test.bat; $LastExitCode
99

还有另一种获得正确行为的方式,即在PowerShell中使用CALL完整地调用批处理文件:

PS> & cmd.exe /c "call .\test.bat"; $LastExitCode
99

虽然我欣赏这可能是从PowerShell调用批处理文件的正确方式,但根据我看到的许多示例,这似乎不是常识。此外,如果这是“正确的方法”,我想知道为什么PowerShell不会以这种方式调用批处理文件。最后,当省略call时,我仍然不明白为什么行为会因我们是否将!errorlevel!添加到exit / b 语句而发生更改。


更新: 谢谢大家的讨论,但我觉得问题可能已经偏离了轨道,这可能是我在原始问题中表述不够清晰导致的。如果可能的话,我想知道以下语句中errorlevel在何时(被认为)被评估:

program || exit /b

它是否真的像这样在早期(即程序运行之前)进行评估:

program || exit /b %errorlevel%

或者是惰性评估的(即在执行program并在内部更新了errorlevel后,在执行exit时进行评估),更类似于这种情况:

program || exit /b !errorlevel!

因此,除非情况不容乐观,否则我并不追求猜测。即使这样,知道没有确定的答案或者是一个错误也可以成为我的可接受答案: o)。

因此,我并不追求猜测。如果逼不得已,知道没有明确的答案或者是个bug也是可以接受的 :o)。


1
你需要使用ErrorLevel的延迟扩展,否则你将得到先前行的值,这在你的情况下将是零。 - jwdonahue
1
没错,它不会改变 errorlevel 的值,但是它必须对其进行评估,并且在 prog || exit 语句中,评估的关键点就是我的问题所在。 - Chris Oldwood
1
我要补充的是,在command.comcmd.exe中存在许多意外行为,尽管微软不愿修复其中大部分,但他们似乎也不想记录这些问题。例如,使用::作为注释而导致的坏标签错误。因此,我逐渐收集了一些脚本,其唯一目的就是阐明实际行为。 - jwdonahue
1
关于您的更新:明确的陈述是:program || exit /b 的工作方式类似于 program || exit /b !errorlevel!(假设启用了setlocal enabledelayedexpansion),但是 - 关于设置_cmd.exe_进程退出代码 - 在使用call调用批处理文件时才有效。如果没有callexit /b 的错误级别意外地不确定cmd.exe的进程退出代码(它最终变为0,即使在会话内部错误级别已经被设置,但这在您的情况下不相关)。但是,exit /b <code> - 即一个 _ 明确的_退出代码 - 总是会设置_cmd.exe_进程的退出代码。 - mklement0
1
所以,“在没有call的情况下,exit /b的错误级别意外地不能确定cmd.exe的进程退出代码”是我从中得出的结论。非常感谢您的耐心! - Chris Oldwood
显示剩余10条评论
5个回答

8

解决方法:

  • 通过 cmd /c "<batch-file> ... & exit" 调用您的批处理文件,在这种情况下,|| exit /b 解决方案没有显式退出代码也能按预期工作:

    • cmd /c ".\test.bat & exit"

      • 如果需要,在批处理文件路径和包含空格的传递参数周围转义任何嵌入的"字符,例如:
        cmd /c ".\test.bat `"quoted argument`" & exit"
      • 或者,如果您不需要使用PowerShell的字符串插值来嵌入调用中的变量值,可以使用'...'引号,此时嵌入的"可以原样使用:
        cmd /c '.\test.bat "quoted argument" & exit'
    • 从外部调用批处理文件时,即使批处理文件没有显式的exit /b(或exit)调用,使用cmd /c "<batch-file> ... & exit"通常是明智的,否则可能会出现意外行为 - 参见此答案

  • 或者 - 仅当您的批处理文件从另一个批处理文件中不需要调用并且它永远不会成为cmd /c 多个命令行的一部分,而不是最后一个命令时,您可以使用|| exit代替|| exit /b - 这将立即将正在执行的cmd.exe进程整体退出,但是(错误级别)退出代码仍然可靠地报告(至少在<command> || exit语句的上下文中),甚至可以通过PowerShell从外部直接调用,例如:& .\test.bat(或在这种简单情况下,只需.\test.bat)。

setlocal EnableDelayedExpansionexit /b !ERRORLEVEL!结合使用也可以(除非(...)中—请参见此帖子),因为它使用了一个显式的退出代码,但显然更加麻烦,并且可能会产生副作用,特别是悄悄地从命令中删除!字符,如echo hi!命令(虽然通过在exit /b调用之前的行上放置setlocal EnableDelayedExpansion调用可以最小化该问题,但如果有多个退出点,则需要进行复制)。


cmd.exe在这种情况下的行为不幸,但无法避免。

从外部cmd.exe调用批处理文件时:

  • exit /b - 不带 退出码(错误级别)参数 - 仅将 cmd.exe进程退出代码设置为预期值 - 即批处理文件中最近执行的命令的退出代码如果您在批处理文件调用之后使用 & exit ,即作为 cmd /c <batch-file> ... `& exit

    • 没有 & exit 的补救措施时,批处理文件中没有参数的 exit /b 调用会反映在 intra-cmd.exe-session 中的 %ERRORLEVEL% 变量中,但这不会转换为 cmd.exe进程退出代码,此时默认为0[1]

    • 使用 & exit 补救措施, 批处理文件中没有参数的 exit /b 可以正确设置 cmd.exe 的退出代码,即使在 <command> || exit /b 语句中也是如此,此时 <command> 的退出代码会像预期的那样传递。

  • exit /b <code>,即显式传递退出代码<code>,总是有效的[2],此时不需要使用 & exit 补救措施。

  • 这种区别是一个晦涩不清的不一致性,可以称之为一个错误Jeb的有用答案提供了演示此行为的示例代码(在撰写本文时使用了不太全面的cmd/c call...补救措施,但同样适用于cmd/c "...& exit")。

[1] 使用cmd /c,可以传递多个语句进行执行,并且它是决定cmd.exe进程退出代码的最后一条语句。例如,cmd /c "ver & dir nosuch"报告退出代码为1,因为不存在的文件系统项目nosuch导致dir将错误级别设置为1,无论之前的命令(ver)是否成功。不一致之处在于,对于名为test.bat的批处理文件,如果没有明确指定退出代码参数,使用exit /b退出时,cmd /c test.bat总是报告0退出代码,而cmd /c test.bat `& exit能够正确报告批处理文件退出之前执行的最后一条语句的退出代码

[2] 退出代码可以直接指定或通过变量指定,但陷阱在于,由于cmd.exe会提前扩展变量,因此<command> || exit /b %ERRORLEVEL%不能按预期工作,因为此时%ERRORLEVEL%扩展为在此语句之前设置的错误级别,而不是由<command>设置的错误级别;这就是为什么在这种情况下需要使用延迟扩展,通过运行setlocal enabledelayedexpansion或使用/V选项调用cmd.exe来实现:<command> || exit /b !ERRORLEVEL!


1
我编译了示例代码,因为我不相信你的说法,但是你是对的! - jeb

8

exit /bexit /b <code>之间有区别。

正如mklement0所述,当使用或不使用CALL调用批处理文件时,差异变得明显。

在我的测试中,我使用了(call)来强制错误级别为1。

test1.bat

@echo off
(call)
exit /b

test2.bat

@echo off
(call)
exit /b %errorlevel%

使用test-all.bat进行测试:

cmd /c "test1.bat" & call echo      Test1 %%errorlevel%%
cmd /c "call test1.bat" & call echo call Test1 %%errorlevel%%
cmd /c "test2.bat" & call echo      Test2 %%errorlevel%%
cmd /c "call test2.bat" & call echo call Test2 %%errorlevel%%

输出:

     Test1 0  
调用 Test1 1  
     Test2 1  
调用 Test2 1

为了获得始终可靠的错误级别,您应该使用 exit /b <code> 的显式形式。
如果使用构造 <command> || exit /b !errorlevel!,则需要延迟扩展或使用以下形式:

<command> || call exit /b %%errorlevel%%

另一种解决方案

<command> || call :exit
...

:exit
(
   (goto) 2>nul
   exit /b
)    

这使用了批量异常处理
Windows批处理支持异常处理吗?


1
我已经更新了我的答案,推荐使用 cmd /c "<batch-file> ... & exit" 作为最健壮的解决方法,因为 cmd /c call <batch-file> ... 在参数中无法避免地将 ^ 字符加倍(正如您所知道的那样)- 有关详细信息,请参见此帖子 - mklement0

5

让我们看看三种可能的情况:

cmd /c "exit 99" || exit /b

返回0,因为cmd /c "exit 99"正确执行了。

cmd /c "exit 99" || exit /b !errorlevel!

返回 99 是因为 cmd /c "exit 99" 命令将 errorlevel 设置为 99,而我们正在返回执行 cmd /c "exit 99" 命令所产生的 errorlevel

cmd /c "exit 99" || exit /b %errorlevel%

返回? - 当解析cmd /c "exit 99"行时,%errorlevel%被评估的错误级别。

如果delayedexpansion没有被设置,则唯一的区别在于!errorlevel!情况试图将字符串赋值给错误级别,这很可能不会很好地工作。

至于Powershell-它是一个不太常用的角落案例。设计师希望使用此功能执行.exe等,因此并未对其进行全面测试。即使有问题报告,也不会得到修复,因为有解决方法,即使这个方法并不完美。

我认为,这是失败到失败的场景,因为很少满足导致其失败的正确条件。

同样地,

echo %%%%b

这段话的含义是有歧义的。它是指输出字面值"%%b"还是输出以元变量"b"为前缀的"%b"?(答案:后者)。这种情况并不经常遇到。解决这种歧义的机会有多大 - 永远吗?我们甚至无法在date命令上实现 /u 以使用通用格式提供日期,这将解决在 batch标签中发布的绝大多数日期相关问题。至于是否可能添加一个开关以允许date提供从某个时代日期开始算起的天数 - 我甚至都没有想过建议它,因为尽管邀请建议cmd修改,但关于提供的工具设施没有做任何事情,只有用户界面发生了变化。当 ol' faithful 被锁在旧卫生间的底部,并且门上贴着“小心豹子”的标牌时,所有人都在玩耍。


执行 cmd /c "exit 99" 命令时,虽然能够正确执行,但是它仍会将其进程退出代码显式地设置为 99。您可以在运行后通过 echo %ERRORLEVEL% 查看此退出代码。exit /b 命令本身确实按预期传递了此退出代码,但整个 cmd.exe 进程却不可理解地没有报告它,除非使用 call 调用封闭的批处理文件(当您从 cmd.exe 本身交互式调用批处理文件时,它会被 隐式 使用)。PowerShell 并不应受责备,它背后使用的 .NET API 也不应受责备。 - mklement0
如果是这样的话,我会期望 exit /bexit /b !errorlevel! 表现相同,因为在这两种情况下都没有使用 call 调用 .bat 文件。 - Chris Oldwood
@ChrisOldwood,不,与exit /b相关的显式使用退出代码是区别的关键:_使用_显式退出代码(无论是直接指定还是通过变量指定),进程退出代码_被正确地设置_;_没有_退出代码时,进程退出代码总是最终为0,而不是报告最近执行命令的退出代码(即使在||的左侧)——后者将只发生在call中,并且这就是手头的不一致性,这可以说是一个缺陷。 - mklement0

1
消除与PowerShell和Cmd.exe无关的调用复杂性,以便我们可以看到cmd解释器的工作原理:
@setlocal EnableExtensions EnableDelayedExpansion
@set prompt=$G

call :DefaultExit
@echo %ErrorLevel%

call :ExplicitExit
@echo %ErrorLevel%

@rem ErrorLevel is now 2

call :DefaultExit || echo One-liner ErrorLevel==%ErrorLevel%, not zero!
@echo %ErrorLevel%

@rem Reset the errorlevel to zero
call :DefaultExit
@echo %ErrorLevel%

call :ExplicitExit || echo One-liner ErrorLevel==!ErrorLevel!
@echo %ErrorLevel%

@exit /b

:DefaultExit
exit /b

:ExplicitExit
exit /b 2

产生:

> test

>call :DefaultExit

>exit /b
0

>call :ExplicitExit

>exit /b 2
2

>call :DefaultExit   || echo One-liner ErrorLevel==2, not zero!

>exit /b
One-liner ErrorLevel==2, not zero
2

>call :DefaultExit

>exit /b
2

>call :ExplicitExit   || echo One-liner ErrorLevel==!ErrorLevel!

>exit /b 2
One-liner ErrorLevel==2
2

正如你所看到的,exit /bexit /b %ErrorLevel%完全等效,这不是你在使用||失败执行路径时想要的结果。你必须调用延迟扩展来获取||运算符左侧的结果。

我对上述脚本进行了一次快速测试,并在脚本退出后通过控制台窗口交互式地检查了错误级别:

> echo %errorlevel%
2

正如您所看到的,单独使用exit /b返回了之前设置的错误级别2。因此我的直觉是正确的。你肯定需要某种形式的延迟扩展!ErrorLevel!%^ErrorLevel%(如@aschipfl所述)。

1
在批处理文件中,exit /b 不等同于 exit /b %ErrorLevel%:当批处理文件在没有使用 call 的情况下被调用时,它实际上等同于 exit /b 0(在交互式从 cmd.exe 调用时隐含使用 call)。当使用 call 调用时,它会正确动态地获取 || 操作符左侧操作数的退出代码。 - mklement0
1
@mklement0,我会让经验证据自己说话。 - jwdonahue
@jwdonahue,你提供的证据与问题本身无关,问题在于从cmd.exe之外调用的复杂性(特别是由PowerShell调用),而这只是附带的。 - mklement0
@mklement0,我不同意。 OP的示例从脚本启动cmd.exe。 从批处理脚本中的if ...格式转换为||运算符样式,由于误解了cmd.exe如何在脚本中处理该行而引发了问题。 - jwdonahue
1
@mklement0,实际上,我认为你和Jeb比我讲得更好。 - jwdonahue
显示剩余3条评论

1
当运行批处理文件时,PowerShell的&运算符在内部调用cmd.exe /C。我认为$LastExitCode是指对cmd.exe的调用,而不是批处理文件。在这种情况下,callErrorLevel从批处理文件传递到cmd.exe
同样的问题也出现在cmd.exe中(与PowerShell无关),运行.\test.bat & call echo %^ErrorLevel%不会调用cmd.exe /C来运行批处理文件,所以这个命令可以正常工作(始终返回99)。然而,cmd /C .\test.bat & call echo %^ErrorLevel%就像你的PowerShell尝试一样失败了(按原样返回0),而cmd /C call .\test.bat & call echo %^ErrorLevel%成功并因此返回99
顺便说一句,call echo %^ErrorLevel%使我们不必使用延迟扩展。

PowerShell 的 & 运算符内部调用 cmd.exe /C -- 它只使用 cmd /c 还是提供其他任何开关?我没有找到 PowerShell 所做的事情的明确来源,只是想检查我的“仿真”是否正确。 - Chris Oldwood
aschipfl,是的,$LASTEXITCODE 中报告的是 cmd.exe 进程的退出代码,这也适用于直接调用批处理文件的情况,即将其路径(可能令人惊讶地)“原样”分配为 PowerShell 在幕后使用的 System.Diagnostics.ProcessStartInfo 实例的 FileName 属性。也就是说,不使用 cmd /C,但如果使用了,结果将是相同的。@ChrisOldwood,这也意味着没有传递其他开关。 - mklement0
call is indeed what makes the difference here, and causes cmd.exe's exit code and therefore $LASTEXITCODE to be set as expected even with an exit /b call without an explicit exit code, so cmd /c call .\test.bat; $LASTEXITCODE will do. As an aside re your attempt to have cmd.exe echo the exit code: your command doesn't actually work from PowerShell: first, you'd have to escape the & , but neither %^ERRORLEVEL% nor %ERRORLEVEL% would work as expected; you'd have to use cmd /v /c call test.bat \& echo !ERRORLEVEL!` - mklement0
@mklement0,cmd /C call .\test.bat & call echo %^ErrorLevel% 不是为了在 PowerShell 中使用而设计的,我只是用它来证明这个问题与 PowerShell 无关。您的建议 cmd /V /C call test.bat \& echo !ErrorLevel!构成了不同的情况,因为echocmd /V /C的上下文中执行,当然仍然存在ErrorLevel,但问题是将 ErrorLevel传递到cmd` 之外... - aschipfl
确实,这个问题与PowerShell无关,只与cmd.exe在使用其CLI(/c)执行批处理文件时如何设置其进程退出代码有关 - 除非显式使用call,否则它会出现异常行为。我现在明白你的命令是做什么的了:它只是另一种展示相同问题的方式,只不过是在cmd.exe会话的上下文中。尽管这个问题并不是特定于PowerShell的,但OP正在寻找一个在PowerShell上下文中的解决方案(即cmd /c call test.bat,这将正确设置$LASTEXITCODE),所以我对你的示例命令感到困惑。 - mklement0
顺便提一下:我后来意识到,在使用CLI调用时设置cmd.exe进程退出代码的不一致性更为根本,这让我得出结论,使用cmd /c call通常是调用批处理文件最安全的方式-请参见此答案 - mklement0

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