批处理文件路径为什么会在更改目录时用 %~dp0 引用有时会发生变化?

22
我有一个批处理文件,其内容如下:
echo %~dp0
CD Arvind
echo %~dp0

即使改变了目录值,%~dp0的值仍然相同。 但是,如果我从CSharp程序运行此批处理文件,则在CD之后,%~dp0的值会改变。现在它指向新的目录。以下是我使用的代码:

Directory.SetCurrentDirectory(//Dir where batch file resides);
ProcessStartInfo ProcessInfo;
Process process = new Process();
ProcessInfo = new ProcessStartInfo("mybatfile.bat");
ProcessInfo.UseShellExecute = false;
ProcessInfo.RedirectStandardOutput = true;
process = Process.Start(ProcessInfo);
process.WaitForExit();
ExitCode = process.ExitCode;
process.Close();

为什么用不同的方式执行相同的脚本输出会有差异?

我有遗漏什么吗?


7
如果你通过在cmd中使用带引号的"mybatfile.cmd"批处理文件来运行批处理时,可以复制这种行为。这正是通过Process.Start运行时获得的调用方式,你可以通过echo %0进行验证。 - Joey
非常感谢你的建议,Joey。它对我有用了,现在它可以正常工作了。 - sunny days
嗯,这只是一种观察;我仍然无法解释这种行为 :-) - Joey
这对我也是发生的。在某些情况下(我还没有完全确定),如果我从命令行启动“xyz.bat”,“%dp0”值可以正常工作,但是如果我通过Java ProcessBuilder启动相同的bat文件,“%dp0”会给出CWD。使用“cmd /c xyz.bat”似乎可以解决问题,但为什么呢? - Rich
6个回答

16

这个问题引起了关于这一点的讨论,并进行了一些测试以确定原因。因此,在cmd.exe内进行了一些调试...(这是为一个32位Windows XP cmd.exe,但是由于行为在新系统版本上保持一致,可能使用相同或类似的代码)

Jeb的答案中指出:

It's a problem with the quotes and %~0.
cmd.exe handles %~0 in a special way

这里Jeb是正确的。

在当前批处理文件的上下文中,有一个对当前批处理文件的引用,即“变量”,包含运行中的批处理文件的完整路径和文件名。

当访问变量时,其值将从可用变量列表中检索,但如果请求的变量是%0,并且已经请求了一些修改器(使用~),则将使用运行批处理引用“变量”的数据。

但是,在变量中使用~还会产生另一种影响。如果该值被引用,则引号将被删除。这里的代码存在一个bug。它被编写成以下形式(这里是简化后的汇编语言伪代码):

value = varList[varName]
if (value && value[0] == quote ){
    value = unquote(value)
} else if (varName == '0') {
    value = batchFullName
}

是的,这意味着当批处理文件被引用时,将执行if的第一部分,并且不会使用批处理文件的完整引用,而是检索用于调用它的字符串。

那么会发生什么?如果在调用批处理文件时使用了完整路径,那么就不会有问题。但是,如果在调用中未使用完整路径,则需要检索路径中不存在于批处理调用中的任何元素。此检索假定为相对路径。

一个简单的批处理文件(test.cmd)

@echo off
echo %~f0

当使用test(无扩展名,无引号)调用时,我们得到c:\somewhere\test.cmd

当使用"test"(无扩展名,有引号)调用时,我们得到c:\somewhere\test

在第一种情况下,没有引号,使用正确的内部值。在第二种情况下,由于调用被引用,用于调用批处理文件的字符串("test")将被取消引用并使用。因为我们正在请求完整路径,所以它被视为对称为test的相对引用。

这就是原因。如何解决?

C#代码中

  • 不要使用引号:cmd /c batchfile.cmd

  • 如果需要引号,请在调用批处理文件时使用完整路径。这样%0包含所有所需信息。

从批处理文件中

批处理文件可以从任何地方以任何方式调用。检索当前批处理文件的信息的唯一可靠方法是使用子例程。如果使用任何修改器(~),则%0将使用内部“变量”来获取数据。

@echo off
    setlocal enableextensions disabledelayedexpansion

    call :getCurrentBatch batch
    echo %batch%

    exit /b

:getCurrentBatch variableName
    set "%~1=%~f0"
    goto :eof

无论如何调用文件,带引号或不带引号,都会在控制台上输出当前批处理文件的完整路径。

注意: 为什么它可以工作?为什么子例程内的% ~f0引用返回不同的值?从子例程内访问的数据不同。当执行call时,在内存中创建了一个新的批处理文件上下文,并使用内部“变量”初始化此上下文。


6

我将尝试解释为什么它的行为如此奇怪。这是一个相当技术性和冗长的故事,我将尝试保持简洁。这个问题的起点是:

   ProcessInfo.UseShellExecute = false;

你会发现,如果省略此语句或分配true,它将按预期工作。
Windows提供了两种基本的启动程序的方式,ShellExecuteEx()和CreateProcess()。UseShellExecute属性在这两者之间选择。前者是“智能友好”的版本,它对shell的工作方式了解得很多。例如,你可以传递任意文件路径,比如“foo.doc”。它知道如何查找.doc文件的文件关联,并找到知道如何打开foo.doc的.exe文件。 CreateProcess()是低级winapi函数,它与本地内核函数(NtCreateProcess)之间几乎没有粘合剂。请注意函数的前两个参数,lpApplicationName和lpCommandLine,你可以轻松地将它们匹配到两个ProcessStartInfo属性中。
不太明显的是,CreateProcess()提供了两种不同的启动程序的方式。第一种方式是将lpApplicationName设置为空字符串,使用lpCommandLine提供整个命令行。这使CreateProcess变得友好,它在定位可执行文件后会自动展开应用程序名称为完整路径。因此,例如,“cmd.exe”会被扩展为“c:\windows\system32\cmd.exe”。但是,当你使用lpApplicationName参数时,它不会这样做,而是按原样传递字符串。
这个怪癖对依赖于指定命令行方式的程序有影响。特别是对于C程序,它们假设argv[0]包含其可执行文件的路径。它还会影响到%~dp0,因为它也使用该参数。在你的情况下,它会失败,因为它处理的路径只是“mybatfile.bat”,而不是例如“c:\temp\mybatfile.bat”。这使它返回当前目录,而不是“c:\temp”。
因此,你应该做的是将完整路径名传递给文件,这在.NET Framework文档中被严重低估。因此,正确的代码应该像这样:
   string path = @"c:\temp";   // Dir where batch file resides
   Directory.SetCurrentDirectory(path);
   string batfile = System.IO.Path.Combine(path, "mybatfile.bat");
   ProcessStartInfo = new ProcessStartInfo(batfile);

你会发现 %~dp0 现在按照你的期望进行扩展了。它使用的是 path 而非当前目录。


如果您不知道完整路径(即您想要进行%PATH%查找),那么正确的解决方法是什么?您需要在C#中手动进行%PATH%查找吗? - Rich
1
嗯,是的。当然使用cmd.exe /c变得非常有吸引力 :) - Hans Passant

4
乔伊的建议很有帮助。仅仅通过替换

标签
ProcessInfo = new ProcessStartInfo("mybatfile.bat"); 

使用

ProcessInfo = new ProcessStartInfo("cmd", "/c " + "mybatfile.bat");

做到了。

5
我很愿意理解正在发生的事情。 - Kevin Gosse

3

这是与引号和%~0有关的问题。

cmd.exe以一种特殊的方式处理%~0(不同于%~1)。
它会检查%0是否是相对文件名,然后在其前面加上起始目录。

如果找到了一个文件,它将使用这个组合;否则,它将在实际目录之前加上它。
但是,当名称以引号开头时,在添加目录之前似乎无法删除引号。

这就是为什么cmd /c myBatch.bat有效的原因,因为此时调用了没有引号的myBatch.bat
您也可以使用完全合格的路径启动批处理,然后它也有效。

或者您可以在更改目录之前在批处理中保存完整路径。

一个小的test.bat可以演示cmd.exe的问题。

@echo off
setlocal
echo %~fx0 %~fx1
cd ..
echo %~fx0 %~fx1

在C:\temp中调用它

test test

输出应该是:
C:\temp\test.bat C:\temp\test
C:\temp\test.bat C:\test

所以,cmd.exe 能够找到 test.bat,但只有对于 %~fx0 它才会在开始目录前加上前缀。

如果通过以下方式调用:

"test" "test"

出现以下错误

C:\temp\test C:\temp\test
C:\test C:\test

cmd.exe 无法在目录更改之前找到批处理文件,它无法将名称扩展到c:\temp\test.bat 的完整名称。

编辑:FixIt,在%~0有引号时检索完整名称

有一个使用函数调用的解决方法。

@echo off
echo This can be wrong %~f0
call :getCorrectName
exit /b

:getCorrectName
echo Here the value is correct %~f0
exit /b

看起来C#的“ProcessStartInfo”和Java的“ProcessBuilder”都在可执行文件名上添加引号,或者它们共享的底层操作系统机制正在这样做。这是否可以控制? - Rich
@Rich,cmd的引号管理存在一个 bug。请参见此处。在调用时使用完整路径或者在批处理文件中添加代码以确保您获得正确的值。 - MC ND

1

命令行解释器cmd.exe在获取批处理文件路径时存在一个bug,如果批处理文件是通过双引号和相对于当前工作目录的路径调用的,则会出现问题。

创建目录C:\Temp\TestDir。在此目录中创建一个名为PathTest.bat的文件,并将以下代码复制并粘贴到此批处理文件中:

@echo off
set "StartIn=%CD%"
set "BatchPath=%~dp0"
echo Batch path before changing working directory is: %~dp0
cd ..
echo Batch path after  changing working directory is: %~dp0
echo Saved path after  changing working directory is: %BatchPath%
cd "%StartIn%"
echo Batch path after restoring working directory is: %~dp0

接下来打开一个命令提示符窗口,并使用以下命令将工作目录设置为C:\Temp\TestDir

cd /D C:\Temp\TestDir

现在以以下方式调用 Test.bat 文件:
  1. PathTest
  2. PathTest.bat
  3. .\PathTest
  4. .\PathTest.bat
  5. ..\TestDir\PathTest
  6. ..\TestDir\PathTest.bat
  7. \Temp\TestDir\PathTest
  8. \Temp\TestDir\PathTest.bat
  9. C:\Temp\TestDir\PathTest
  10. C:\Temp\TestDir\PathTest.bat

所有10个测试用例的输出结果是预期值的四倍,路径为C:\Temp\TestDir\

测试用例7和8使用相对于当前驱动器根目录的路径启动批处理文件。

现在让我们看一下使用双引号包围批处理文件名称后执行与之前相同操作的结果。

  1. "PathTest"
  2. "PathTest.bat"
  3. ".\PathTest"
  4. ".\PathTest.bat"
  5. "..\TestDir\PathTest"
  6. "..\TestDir\PathTest.bat"
  7. "\Temp\TestDir\PathTest"
  8. "\Temp\TestDir\PathTest.bat"
  9. "C:\Temp\TestDir\PathTest"
  10. "C:\Temp\TestDir\PathTest.bat"

对于测试用例5到10,输出结果是期望值的四倍,路径为C:\Temp\TestDir\

但是对于测试用例1到4,第二行输出只有C:\Temp\而不是C:\Temp\TestDir\

现在使用cd ..命令将工作目录更改为C:\Temp,并运行以下命令:PathTest.bat

  1. "TestDir\PathTest.bat"
  2. ".\TestDir\PathTest.bat"
  3. "\Temp\TestDir\PathTest.bat"
  4. "C:\Temp\TestDir\PathTest.bat"
测试用例1和2的第二个输出结果为C:\TestDir\,但该路径根本不存在。
如果不使用双引号启动批处理文件,则4个测试用例的输出都是正确的。
这很明显是由一个bug引起的。
每当使用双引号以及相对于当前工作目录的路径启动批处理文件时,%~dp0无法可靠地获取批处理文件的路径,特别是在批处理执行期间更改当前工作目录时。
Windows shell bug with how %~dp0 is resolved报道,这个bug也已经向Microsoft报告了。
可以通过使用上面的代码将批处理文件的路径立即分配给环境变量来解决此问题,然后在更改工作目录之前引用此变量的值,需要时使用双引号。像%BatchPath%这样的东西总是比%~dp0更易读。
另一种解决方法是始终使用完整路径(包括文件扩展名),并像 Process 类一样使用双引号来启动批处理文件。

1
是的,有一个bug。如果感兴趣,请查看我的答案 - MC ND

-3

通过ProcessStart调用的批处理中的每一行都被独立地视为一个新的cmd命令。

例如,如果您尝试像这样执行:

echo %~dp0 && CD Arvind && echo %~dp0

它运行正常。


1
不是你想的那个原因。只是因为在执行该行之前,%~dp0会被实际值替换,因此第二个echo已经输出静态文本,甚至在cd运行之前就已经输出了。 - Joey
@Joey:所以ProcessStart或双引号执行使脚本逐行执行,而不是作为整体执行,因此替换%~dp0在两种不同情况下会给出不同的值? - LaGrandMere
1
批处理文件总是逐行执行,并且始终由单个实例的 cmd 执行。您所谓的解决方案只是变量扩展的产物。 - Joey
@Joey:好的,我错了也没关系,只是想理解一下 :) 所以解释一下,变量替换在一个情况下是在整个脚本执行之前完成的,在另一个情况下则是在每行执行之前完成的?我正在网上寻找有关这种行为的原因,但找不到任何信息 :/ - LaGrandMere

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