批处理 (.bat, .cmd) 是 Windows 系统上古老的脚本语言,其后又有 PowerShell 等脚本出现,虽然批处理的应用已经渐渐减少,但是仍有许多旧有的批处理脚本需要维护。这里记录了我在处理批处理时的一些经验总结,包含了资料手册最佳实践经验技巧陷阱与缺陷以及常用代码片段五个部分。

资料手册

最佳实践

  • 将批处理中定义的变量局限于批处理中,避免污染批处理调用者的环境。

    SETLOCAL
    ...
    ENDLOCAL
    

    其中 ENDLOCAL 是可选的,当执行到批处理文件末的时候,默认就是 ENDLOCAL

  • 比较变量时,为其加上引号,避免空字符串或含空白符的字符串等造成语法或语义上的错误。

    REM 如果一个变量被赋值为空字符串,或未被定义,
    REM 在两种情况下,这个变量在批处理中都会被展开为空字符串。
    SET foo=
    IF "%foo%" == "bar" echo 逻辑可以正常执行
    IF %foo% == bar echo 执行会报错
    	
    SET dir=a b
    REM 以下语句会创建 a, b 两个文件夹
    mkdir %dir%
    REM 以下语句只会创建 a b 这一个文件夹
    mkdir "%dir%"
    
  • 使用 REM 来添加对于 debug 有帮助的信息或文本,使用 @REM:: 来添加 debug 时不需要,但提供其他信息的注释文本。

    编写批处理时,我们通常会在批处理顶部加上 @echo off 以关闭命令回显,而在 debug 时,我们通常会删掉或注释此行。此时,添加在 REM 后的注释信息,也会被打印到屏幕上,如果这些信息对于 debug 有帮助的话,则是合适的;如果 debug 时不需要这些信息的话,则应该使用 @REM:: 来注释,这样在开启命令回显时,也不会被打印出来。

  • 把对于 GOTO 标签的注释放在标签后,这样可以在 debug (echo on) 的时候看到该标签相关的注释信息。

    :Label
    REM Label 相关的注释
    REM ...
    
  • 使用 pushd, popd 切换目录。

    在批处理中使用 pushd popd 来切换目录,保证在正常退出批处理时,用户所在的目录不会因为执行批处理而被改变。

    pushd dir1
        echo Do this
        echo Do that
    
        pushd dir2
            echo Do other stuff
        popd
    popd
    

    另外,对 pushdpopd 中的代码进行适当的缩进,可以显著提高代码的可阅读性。

  • 使用 GOTO Label 而不是 GOTO :Label

    对于大部分 GOTO 标签来说,GOTO LabelGOTO :Label 是等效的。

    有一个例外则是 EOF 标签,GOTO EOFGOTO :EOF 有着不同的意思。其中 GOTO EOF 跳转到用户定义的 EOF 标签,而 GOTO :EOF 则是跳转到批处理文件的末尾。

    所以,为了保持一致性,使用 GOTO Label 来跳转到用户定义的标签,只在跳转到批处理末时使用 GOTO :EOF

  • 不要定义名为 EOF 的标签。

    由于已有在 CMD 中预定义的 :EOF 标签,所以尽管在代码中可以区分预定义的 :EOF 标签与自定义的 EOF 标签,最好还是避免去自定义 EOF 标签,以免造成混淆。

  • 总是使用 cmd /c 来调用外部批处理,除非期望外部批处理改变当前执行中的批处理的变量时才使用 CALL 来调用外部批处理。

    由于 CALL 会引入被调用批处理对变量的改变,而 cmd /c 则不会。因此我们看到 cmd /c 时,就可以有自信调用者所在的批处理中的变量不会因为调用外部批处理而被改变。

  • 永远使用 %~dp0\dir 来访问相对于被调用批处理所在位置的文件。

    使用 %~dp0\dir 而不是 .\dir 或者 dir 形式来访问相对于被调用批处理所在位置的文件,可以保证批处理无论是双击执行,还是通过 cmd 在批处理所在文件夹中执行,或是通过 cmd 在任意文件夹中执行时,都能访问到正确的文件。

    %~dp0 与 linux shell 中的 dirname "$0" 是相对应的。

  • 避免使用路径格式符 (a, d, f, n, p, s, t, x) 作为 for 循环的循环变量名。

    FOR %%G IN (C:\Windows\*) DO echo %%~nG
    

    以免在对循环变量做路径格式处理时造成阅读上的混淆。

经验技巧

  • :label 标签后 (空格), \t (制表符), : (冒号) 字符后的内容会被忽略。

    因而我们可以在 :label 后添加注释,或一些对于编辑器有用的标记符号。

    如 vim 中的代码折叠标记为 {{{ 符号,我们就可以将其添加在 :label 标签后,来创建一个折叠片段。

    :label {{{
    echo ...
    

陷阱与缺陷

  • 对批处理使用 ANSI 编码,CRLF 换行符。

    如果批处理中包含非 ASCII 字符,那么务必需要对批处理使用 ANSI 编码,而不是 UTF-8,或其他 OEM 字符集,否则会导致批处理运行异常。

    同样的,批处理文件无论如何都应该使用 CRLF (\r\n) 换行符,否则可能导致批处理运行异常。

  • 转义不同字符的引导字符是不同的。

    下表列出了在批处理中所有可能需要转义的字符:

    被转义字符 转义形式 意义
    ^ 转义空格,从而避免使用引号。
    如:C:\Program^ Files\Notepad++\notepad++.exe
    % %% 使用变量。
    ^ ^^ 转义字符本身。
    & ^& & - 依次执行两条命令;
    && - 若前一条命令执行成功(返回 0),则执行后一条命令。
    < ^< 重定向输入。
    > ^> 重定向输出。
    | ^| | - 管道输出;
    || - 若前一条命令执行失败(返回非 0),则执行后一条命令。
    ) ^) 用于 IF, FOR 等语句的 () 中时,需要转义。 (*)
    换行符 ^ + 换行符 将一条较长的命令分为多行书写。

    (*) 值得注意的是,即使 ) 是保存在变量(%var%)中的,如果该变量被用在一组括号中,则仍需对其进行转义。

  • 在 cmd 中与在批处理中执行代码的不同:

    • 批处理中 for 循环的循环变量采用 %%G 的形式,而直接在 cmd 中执行的 for 循环的循环变量采用 %G 的形式。

    • 批处理中不存在的变量会被展开为空字符串,而在 cmd 中不存在的变量则不会被展开。

      分别在命令行中和批处理中执行以下命令来验证:

      echo %nonexistent%
      
  • 使用变量替换时务必确认变量已被定义。

    所谓变量替换指对变量中的字符串进行查找替换或删除,语法如下:

    %variable:StrToFind=NewStr%
    

    例:

    set foo=ABCDEFGHI
    
    echo %foo:DEF=%
    rem 以上语句输出 ABCGHI
    
    echo %foo:DEF=def%
    rem 以上语句输出 ABCdefGHI
    

    但是如果要替换的变量未定义的话,输出结果就不会是所期望的空字符串,如下所示(注该例子需编写 .bat 脚本后执行验证):

    set foo=
    
    echo %foo:DEF=%
    rem 以上语句输出 DEF=
    
    echo %foo:DEF=def%
    rem 以上语句输出 DEF=def
    

    因此,作变量替换时,务必确认该变量已定义:

    IF DEFINDED foo (
        echo 变量 foo 已定义
    )
    
  • FOR 循环的循环变量只能采用单字母的名字。

  • FOR 循环的循环变量名是大小写敏感的。

    Windows 下的名字多为大小写不敏感的,如:

    • 路径 C:\WINDOWSc:\windows 是等效的;
    • 命令及其参数 ECHO ONecho on 是等效的;
    • 变量名 %WINDIR%%windir% 是等效的;

    然而在批处理中有个例外是 for 循环的循环变量,%%G%%g 是不同的。

  • FOR 循环 IN 语句中重定向输出需要转义。

    否则会报语法错误:> was unexpected at this time.2> was unexpected at this time.

    正确形式如下:

    FOR /F %%G IN ('command ^>nul 2^>nul') DO (
        echo %%G
    )
    
  • FOR 循环中的路径里 /\ 并不相同。

    通过以下代码测试:

    FOR %%G IN (C:\Windows\*) DO echo %%G
    PAUSE
    FOR %%G IN (C:/Windows/*) DO echo %%G
    PAUSE
    FOR %%G IN (\Windows\*) DO echo %%G
    PAUSE
    FOR %%G IN (/Windows/*) DO echo %%G
    PAUSE
    

    可以发现,在路径中使用 / 会文件的完整导致路径丢失,只留下盘符和文件名。

  • 路径名称的替换 (Parameter Extension) 中通过 %~p0 可以得到该文件的不包含盘符的路径,这个路径的最后是包含一个 \ 的。

    如果不需要 \ 的话,可以对字符串再进行一次截取:

    SET dirname=%~p0
    SET dirname=%dirname:~0,-1%
    
  • IF-ELSE 里条件执行的代码是作为一个整体解释后再执行的。

    示例如下:

    SET base=base
    IF %RANDOM% LSS 16384 (
        SET a1=a1
        SET a2=%a1%\a2
        SET a3=%base%\a3
    ) ELSE (
        SET b1=b1
        SET b2=%b1%\b2
        SET b3=%base%\b3
    )
    	
    SET c1=c1
    SET c2=%c1%\c2
    SET c3=%base%\c3
    	
    echo a1=%a1%
    echo a2=%a2%
    echo a3=%a3%
    echo b1=%b1%
    echo b2=%b2%
    echo b3=%b3%
    echo c1=%c1%
    echo c2=%c2%
    echo c3=%c3%
    

    执行以上代码,可以看到在 IF-ELSE 中进行的变量赋值是无法访问到同一 IF-ELSE 中刚刚完成的赋值的,因为对整个 IF-ELSE 语句的解释是在语句中的赋值开始前就已经作为一个整体完成了的。

    要想解决这个问题,我们可以使用命令 Setlocal EnableDelayedExpansion 来推迟变量的展开到使用该变量的命令的执行阶段,而不是在命令的解释阶段展开变量。相应地,需要被推迟展开的变量使用如下形式 !foo!

    Setlocal EnableDelayedExpansion
    SET base=base
    IF %RANDOM% LSS 16384 (
        SET a1=a1
        SET a2=!a1!\a2
        SET a3=%base%\a3
    ) ELSE (
        SET b1=b1
        SET b2=!b1!\b2
        SET b3=%base%\b3
    )
    	
    echo a1=%a1%
    echo a2=%a2%
    echo a3=%a3%
    echo b1=%b1%
    echo b2=%b2%
    echo b3=%b3%
    
  • IF ERRORLEVEL number 不等于 IF %ERRORLEVEL%==number

    实际上,

    • IF ERRORLEVEL number 等效于 IF %ERRORLEVEL% GEQ number

    • IF NOT ERRORLEVEL number 等效于 IF %ERRORLEVEL% LSS number

  • GOTO EOFGOTO :EOF 不等效。

    当在批处理中有用户定义的 :EOF 标签时,GOTO EOF 会跳转到该标签,而在没有用户定义的 :EOF 标签时则报错;而 GOTO :EOF 则无论批处理中有什么标签,都会跳转到批处理最后。

    而对于其他名字的标签,GOTO LabelGOTO :Label 是等效的。

    GOTO EOF
    	
    echo 标签 :EOF 前
    :EOF
    echo 标签 :EOF 后 1
    	
    GOTO :EOF
    	
    echo 标签 :EOF 后 2
    

    如在上面的代码片段中,最终只会打印出以下内容:

    标签 :EOF 后 1
    
  • 在批处理中调用别的批处理时根据需要使用 CALLcmd /c

    如果在一个批处理中,需要调用别的批处理执行任务,并在完成后返回之前的批处理继续执行的话,应当使用 CALLcmd /c。否则,如果直接调用其他的批处理的话,调用完之后是不会返回到之前调用者的批处理继续执行的。

    如:

    Windows 下调用 Node.js 的程序时,并非直接调用了一个 .exe 程序,而是调用了一个包装 Node.js 程序的 .cmd 批处理脚本。

    因此,在批处理中如果想要调用这些程序的话,需要使用 CALLcmd /c 来执行这些程序,否则被调用程序执行完后是不会返回调用者的批处理继续执行的。

    此外,如果需要被调用的批处理和当前批处理同步执行、不阻塞的话,可以使用 start 命令。

  • 在一个批处理中不通过 CALLcmd /c 而直接调用另一个批处理时,可能出现意料之外的行为。

    在一个批处理中不通过 CALLcmd /c 而直接调用另一个批处理时,如果在调用另外的批处理之前,正处于 CALL :Label 跳转后的代码执行过程中的话,那么在执行另外的批处理时,也会跳转到那个批处理中同样的 Label,如果在另外的批处理中这个 Label 不存在的话,则调用会报错 系统找不到指定的批处理标签The system cannot find the batch label specified,被调用的批处理也不会得到执行。

    参考:Call | Windows CMD | SS64.com & Windows batch file - The system cannot find the batch label specified - Stack Overflow

  • CALLcmd /c 并不总是等效。

    CALLcmd /c 并不总是等效。

    当在一个批处理中需要调用另一个批处理执行任务时,我们通常可以使用 CALL 或者 cmd /c,他们在大部分时候作用都是相同的:执行另外的批处理,完成后返回调用者的批处理。

    这两者最大的不同是,CALL 会引入被调用批处理对变量的改变,而 cmd /c 则不会。

    我们可以通过以下两个批处理来验证:

    REM bat1.bat
    SET foo=foo
    cmd /c bat2
    echo %foo%
    CALL bat2
    echo %foo%
    
    REM bat2.bat
    SET foo=%foo%-bar
    

    因此,我们有关于调用外部的批处理的最佳实践,即:

    总是使用 cmd /c 来调用外部的批处理,除非需要外部的批处理来改变当前批处理的变量时才采用 CALL

    这样,我们看到 cmd /c 时,就可以有自信调用者所在的批处理中的变量不会因为调用外部批处理而被改变。

    此外,通过 cmd /c 来调用别的批处理时,会产生一个新的进程 cmd.exe;而通过 CALL 来调用别的批处理时,则不会产生新进程。这也是 CALL 会引入被调用批处理中对变量的改变,而 cmd /c 不会的原因。

    不过,也正因为通过 cmd /c 来调用别的批处理时,会产生一个新的 cmd.exe 进程,当我们通过 Ctrl + C 来强制结束批处理时,也需要两次确认,一次确认终止在我们批处理中调用的另外的批处理,另一次确认终止我们自身的批处理。而通过 CALL 来调用别的批处理时,则只需一次确认即可。

    另外,如果通过 cmd /c 来调用的是 .exe 程序时,则会产生两个新的进程: cmd.exe 及被调用的 .exe 程序;而通过 CALL 来调用别的 .exe 程序时,则只会产生一个新的进程,即该 .exe 进程。

    参考:Batch files - CALL

  • 使用 start 命令启动的程序路径如果包含在引号中,需为 start 命令提供标题参数

    start notepadstart C:\Windows\notepad.exe 都能启动记事本,而 start "C:\Windows\notepad.exe" 则只会启动一个空的命令行窗口。

    究其原因,在于 start 命令的语法限制。其正规语法如下:

    START ["title"] [/D path] [/I] [/MIN] [/MAX] [/SEPARATE | /SHARED]
          [/LOW | /NORMAL | /HIGH | /REALTIME | /ABOVENORMAL | /BELOWNORMAL]
          [/NODE <NUMA node>] [/AFFINITY <hex affinity mask>] [/WAIT] [/B]
          [command/program] [parameters]
    

    可以看到,如果 command/program 参数是包含在引号中的话,那么会被 start 命令看作是 "title" 参数。因此,要解决这个问题,需要为 start 命令额外提供一个 "title" 参数来消除这种解释上的歧义。

    即需执行 start "" "C:\Windows\notepad.exe" 来启动路径包含引号的程序。

  • 在批处理的执行过程中对批处理修改会影响到批处理的正确执行。

    在批处理的执行过程中,批处理会记录下自己当前正执行的位置,即当前文件的第几个字节。因此,如果在批处理的执行过程中,批处理被修改,则批处理后续执行的代码就相当于被改变了,可能影响到批处理的正常执行。

    如执行如下批处理:

    echo foo
    pause
    

    随后,在批处理执行到 pause 时,将批处理修改为如下内容:

    echo foo
    pause
    echo bar
    

    可以看到,在批处理中新添加的内容也被在修改之前就执行了的批处理执行了。

    类似地,如果在批处理执行到 pause 时,将批处理修改为如下内容:

    echo foobar
    pause
    

    则会发现后续的执行会报 use 命令找不到的错误。

常用代码片段

  • 避免脚本中的命令回显在屏幕上:

    @echo off
    

    其中 echo off 关闭该命令之后的命令回显;@ 符号关闭其所引导的命令的回显。

  • 为批处理中的变量设置默认值:

    IF NOT DEFINED foo SET foo=blahblah
    
  • 提前结束批处理:

    GOTO :EOF
    

    其中,GOTO :EOF 会自动设置 %ERRORLEVEL%

    :EOF 标签是在 CMD 中预定义的,不区分大小写,在启用 CMD 命令扩展时可用(默认启用)。

    EXIT /B
    

    其中,EXIT /B 会自动设置 %ERRORLEVEL%,也可以指定一个 %ERRORLEVEL% 返回。

  • 检查是否以管理员权限运行:

    net session >nul 2>&1
    IF ERRORLEVEL 1 (
        echo 请以管理员权限运行此脚本。
        pause
        exit /b 1
    )
    

    参考:windows - Batch script: how to check for admin rights - Stack Overflow

  • 等待指定秒数:

    下面的例子中以等待 5 秒为例,详细说明可以参考Linux 下 sleep 命令在 Windows 中的对应

    REM 方法一:使用 timeout
    timeout /t 5 /nobreak > nul
    REM 方法二:使用 Windows Server 2003 Resource Kit Tools 中的 sleep
    sleep 5
    REM 方法三:使用 ping 来模拟等待
    ping -n 6 -w 1000 localhost > nul
    REM 方法四:使用 choice 来模拟等待
    choice /T 5 /C X /D X /N > nul
    
  • 输出文本且不换新行

    一般当我们使用 echo 命令来输出文本后,也会输出一个换行符。如果想要只输出文本而不换行,可以使用如下命令:

    echo | set /p=输出文本
    

    <nul set /p=输出文本
    

    参考:Windows batch: echo without new line - Stack Overflow

  • 如果一个文件夹不存在,则创建该文件夹:

    SET dir=C:\path\to\directory
    IF NOT EXIST "%dir%" mkdir "%dir%"
    
  • 循环处理某路径下的文件夹(不包含文件):

    FOR /D %%G IN (C:\*) DO echo %%G
    
  • 循环处理某路径下的所有文件(不包含文件夹):

    FOR %%G IN (C:\*) DO echo %%G
    
  • 通用 do while 循环:

    :label
        REM 逻辑代码 ...
    IF ... GOTO label
    
  • 通用 while 循环:

    :label
    IF .... (
        REM 逻辑代码 ...
        GOTO label
    )
    
  • 检查批处理所需的外部依赖是否存在:

    首先定义 :require 例程,其中使用 where 命令来检查命令是否存在:

    :require
    where %1 >nul 2>nul
    IF ERRORLEVEL 1 (
        echo %1 not installed.
    ) >&2
    GOTO :EOF
    

    调用 :require 例程,根据返回值 ERRORLEVEL 来判断外部依赖是否存在:

    CALL :require command
    IF ERRORLEVEL 1 EXIT /B %ERRORLEVEL%
    

    或者,在 :require 例程中添加逻辑,如果所需的外部依赖不存在,则直接退出 CMD.EXE

    :require
    where %1 >nul 2>nul
    IF ERRORLEVEL 1 (
        echo %1 not installed.
        EXIT %ERRORLEVEL%
    ) >&2
    GOTO :EOF
    

    这样,调用者就无需做任何检查,如果代码能够继续执行,则说明所需的外部依赖是存在的:

    CALL :require command
    
  • 字符串匹配:

    findstr 本是用于在文件中查找字符串的,但我们也可将其用于两个字符串的比较。

    echo %var% | findstr ... > nul
    IF ERRORLEVEL 1 (
        echo 不符合指定的匹配规则
    ) ELSE (
        echo   符合指定的匹配规则
    )
    

    上述代码也可以写成:

    echo %var% | findstr ... > nul && (
        echo   符合指定的匹配规则
    ) || (
        echo 不符合指定的匹配规则
    )
    

    findstr 具体的使用请参照 findstr /?

    另外,将一个变量与字符串进行比较时,如果只想截取变量的一部分字符串进行比较,可以使用语法 %var:~offset,length%,如 %var:~2,3% 会从 %var% 变量的第 2 个字符(从 0 开始编号)开始截取一个长度为 3 的字符串。

  • 测试变量是否为整型数字:

    方案一:

    echo %var%| findstr /r "^[1-9][0-9]*$" > nul
    if %ERRORLEVEL% equ 0 (
        echo 输入为整型数字
    ) else (
        echo 输入非整型数字
    )
    

    其中,%var% 根据需要可以换为其他变量名,或者 %1, %~1 等参数;

    管道符 | 前不能有空格;

    正则表达式 ^[1-9][0-9]*$ 匹配非零开头的数字;如果想允许以零开头的数字,则可使用正则 ^[0-9][0-9]*$,但不可使用 ^[0-9]+$,因为 findstr 命令不支持 +

    参考:windows - Check for only numerical input in batch file - Super User

    方案二:

    set var=
    for /f "delims=0123456789" %%i in ("%var%") do set var=%%i
    if not defined ver (
        echo 输入为整型数字
    ) else (
        echo 输入非整型数字
    )
    

    其中,%var% 根据需要可以换为其他变量名,或者 %1, %~1 等参数。

    参考:how to check if a parameter (or variable) is numeric in windows batch file - Stack Overflow

  • 捕获命令的输出至变量:

    方案一:

    command arg1 arg2 > temp.txt
    set /p var= < temp.txt
    del temp.txt
    

    如果命令返回多行结果,此法会保存返回的第一行结果。

    或方案二:

    for /f %%G in ('command arg1 arg2') do set var=%%G
    

    如果命令返回多行结果,此法会保存返回的最后一行结果。