Windows 系统中的文件路径格式

命名空间中 System.IO 许多类型的成员包括一个 path 参数,用于指定文件系统资源的绝对路径或相对路径。 然后,此路径将传递到 Windows 文件系统 API。 本主题讨论可在 Windows 系统上使用的文件路径的格式。

传统 DOS 路径

标准 DOS 路径可以包含三个组件:

  • 卷号或驱动器号,后跟卷分隔符 (:)。
  • 目录名称。 目录分隔符分隔嵌套目录层次结构中的子目录。
  • 可选文件名。 目录分隔符分隔文件路径和文件名。

如果所有三个组件都存在,则路径是绝对的。 如果未指定卷或驱动器号,并且目录名称以 目录分隔符开头,则路径相对于当前驱动器的根目录。 否则路径相对于当前目录。 下表显示了一些可能的目录和文件路径。

路径 DESCRIPTION
C:\Documents\Newsletters\Summer2018.pdf 驱动器C:根目录的绝对文件路径。
\Program Files\Custom Utilities\StringFinder.exe 当前驱动器根路径上的相对路径。
2018\January.xlsx 指向当前目录的子目录中的文件的相对路径。
..\Publications\TravelBrochure.pdf 指向从当前目录开始的目录中的文件的相对路径。
C:\Projects\apilibrary\apilibrary.sln 从驱动器 C:根目录到文件的绝对路径。
C:Projects\apilibrary\apilibrary.sln C: 驱动器的当前目录中的相对路径。

重要

请注意最后两个路径之间的差异。 两者都指定了可选的卷说明符(均为 C:),但前者以指定的卷的根目录开头,而后者不是。 因此,第一个是驱动器 C:根目录中的绝对路径,而第二个路径是当前驱动器 C:目录中的相对路径。 使用第二种形式而本应使用第一种形式是涉及 Windows 文件路径的常见 bug 源。

可以通过调用 Path.IsPathFullyQualified 方法来确定文件路径是否完全限定(也就是说,如果路径独立于当前目录,并且当前目录发生更改时不会更改)。 请注意,此类路径可以包含相对目录段(...),如果解析的路径始终指向同一位置,则仍完全限定。

下面的示例说明了绝对路径与相对路径之间的差异。 它假定目录 D:\FY2018\ 是存在的,并且在运行示例之前,您没有从命令提示符处为 D:\ 设置任何当前目录。

using System;
using System.Diagnostics;
using System.IO;
using System.Reflection;

public class Example2
{
    public static void Main(string[] args)
    {
        Console.WriteLine($"Current directory is '{Environment.CurrentDirectory}'");
        Console.WriteLine("Setting current directory to 'C:\\'");

        Directory.SetCurrentDirectory(@"C:\");
        string path = Path.GetFullPath(@"D:\FY2018");
        Console.WriteLine($"'D:\\FY2018' resolves to {path}");
        path = Path.GetFullPath(@"D:FY2018");
        Console.WriteLine($"'D:FY2018' resolves to {path}");

        Console.WriteLine("Setting current directory to 'D:\\Docs'");
        Directory.SetCurrentDirectory(@"D:\Docs");

        path = Path.GetFullPath(@"D:\FY2018");
        Console.WriteLine($"'D:\\FY2018' resolves to {path}");
        path = Path.GetFullPath(@"D:FY2018");

        // This will be "D:\Docs\FY2018" as it happens to match the drive of the current directory
        Console.WriteLine($"'D:FY2018' resolves to {path}");

        Console.WriteLine("Setting current directory to 'C:\\'");
        Directory.SetCurrentDirectory(@"C:\");

        path = Path.GetFullPath(@"D:\FY2018");
        Console.WriteLine($"'D:\\FY2018' resolves to {path}");

        // This will be either "D:\FY2018" or "D:\FY2018\FY2018" in the subprocess. In the sub process,
        // the command prompt set the current directory before launch of our application, which
        // sets a hidden environment variable that is considered.
        path = Path.GetFullPath(@"D:FY2018");
        Console.WriteLine($"'D:FY2018' resolves to {path}");

        if (args.Length < 1)
        {
            Console.WriteLine(@"Launching again, after setting current directory to D:\FY2018");
            Uri currentExe = new(Assembly.GetExecutingAssembly().Location, UriKind.Absolute);
            string commandLine = $"/C cd D:\\FY2018 & \"{currentExe.LocalPath}\" stop";
            ProcessStartInfo psi = new("cmd", commandLine); ;
            Process.Start(psi).WaitForExit();

            Console.WriteLine("Sub process returned:");
            path = Path.GetFullPath(@"D:\FY2018");
            Console.WriteLine($"'D:\\FY2018' resolves to {path}");
            path = Path.GetFullPath(@"D:FY2018");
            Console.WriteLine($"'D:FY2018' resolves to {path}");
        }
        Console.WriteLine("Press any key to continue... ");
        Console.ReadKey();
    }
}
// The example displays the following output:
//      Current directory is 'C:\Programs\file-paths'
//      Setting current directory to 'C:\'
//      'D:\FY2018' resolves to D:\FY2018
//      'D:FY2018' resolves to d:\FY2018
//      Setting current directory to 'D:\Docs'
//      'D:\FY2018' resolves to D:\FY2018
//      'D:FY2018' resolves to D:\Docs\FY2018
//      Setting current directory to 'C:\'
//      'D:\FY2018' resolves to D:\FY2018
//      'D:FY2018' resolves to d:\FY2018
//      Launching again, after setting current directory to D:\FY2018
//      Sub process returned:
//      'D:\FY2018' resolves to D:\FY2018
//      'D:FY2018' resolves to d:\FY2018
// The subprocess displays the following output:
//      Current directory is 'C:\'
//      Setting current directory to 'C:\'
//      'D:\FY2018' resolves to D:\FY2018
//      'D:FY2018' resolves to D:\FY2018\FY2018
//      Setting current directory to 'D:\Docs'
//      'D:\FY2018' resolves to D:\FY2018
//      'D:FY2018' resolves to D:\Docs\FY2018
//      Setting current directory to 'C:\'
//      'D:\FY2018' resolves to D:\FY2018
//      'D:FY2018' resolves to D:\FY2018\FY2018
Imports System.IO
Imports System.Reflection

Public Module Example2

    Public Sub Main(args() As String)
        Console.WriteLine($"Current directory is '{Environment.CurrentDirectory}'")
        Console.WriteLine("Setting current directory to 'C:\'")
        Directory.SetCurrentDirectory("C:\")

        Dim filePath As String = Path.GetFullPath("D:\FY2018")
        Console.WriteLine($"'D:\\FY2018' resolves to {filePath}")
        filePath = Path.GetFullPath("D:FY2018")
        Console.WriteLine($"'D:FY2018' resolves to {filePath}")

        Console.WriteLine("Setting current directory to 'D:\\Docs'")
        Directory.SetCurrentDirectory("D:\Docs")

        filePath = Path.GetFullPath("D:\FY2018")
        Console.WriteLine($"'D:\\FY2018' resolves to {filePath}")
        filePath = Path.GetFullPath("D:FY2018")

        ' This will be "D:\Docs\FY2018" as it happens to match the drive of the current directory
        Console.WriteLine($"'D:FY2018' resolves to {filePath}")

        Console.WriteLine("Setting current directory to 'C:\\'")
        Directory.SetCurrentDirectory("C:\")

        filePath = Path.GetFullPath("D:\FY2018")
        Console.WriteLine($"'D:\\FY2018' resolves to {filePath}")

        ' This will be either "D:\FY2018" or "D:\FY2018\FY2018" in the subprocess. In the sub process,
        ' the command prompt set the current directory before launch of our application, which
        ' sets a hidden environment variable that is considered.
        filePath = Path.GetFullPath("D:FY2018")
        Console.WriteLine($"'D:FY2018' resolves to {filePath}")

        If args.Length < 1 Then
            Console.WriteLine("Launching again, after setting current directory to D:\FY2018")
            Dim currentExe As New Uri(Assembly.GetExecutingAssembly().GetName().CodeBase, UriKind.Absolute)
            Dim commandLine As String = $"/C cd D:\FY2018 & ""{currentExe.LocalPath}"" stop"
            Dim psi As New ProcessStartInfo("cmd", commandLine)
            Process.Start(psi).WaitForExit()

            Console.WriteLine("Sub process returned:")
            filePath = Path.GetFullPath("D:\FY2018")
            Console.WriteLine($"'D:\\FY2018' resolves to {filePath}")
            filePath = Path.GetFullPath("D:FY2018")
            Console.WriteLine($"'D:FY2018' resolves to {filePath}")
        End If
        Console.WriteLine("Press any key to continue... ")
        Console.ReadKey()
    End Sub
End Module
' The example displays the following output:
'      Current directory is 'C:\Programs\file-paths'
'      Setting current directory to 'C:\'
'      'D:\FY2018' resolves to D:\FY2018
'      'D:FY2018' resolves to d:\FY2018
'      Setting current directory to 'D:\Docs'
'      'D:\FY2018' resolves to D:\FY2018
'      'D:FY2018' resolves to D:\Docs\FY2018
'      Setting current directory to 'C:\'
'      'D:\FY2018' resolves to D:\FY2018
'      'D:FY2018' resolves to d:\FY2018
'      Launching again, after setting current directory to D:\FY2018
'      Sub process returned:
'      'D:\FY2018' resolves to D:\FY2018
'      'D:FY2018' resolves to d:\FY2018
' The subprocess displays the following output:
'      Current directory is 'C:\'
'      Setting current directory to 'C:\'
'      'D:\FY2018' resolves to D:\FY2018
'      'D:FY2018' resolves to D:\FY2018\FY2018
'      Setting current directory to 'D:\Docs'
'      'D:\FY2018' resolves to D:\FY2018
'      'D:FY2018' resolves to D:\Docs\FY2018
'      Setting current directory to 'C:\'
'      'D:\FY2018' resolves to D:\FY2018
'      'D:FY2018' resolves to D:\FY2018\FY2018

UNC 路径

用于访问网络资源的通用命名约定 (UNC) 路径具有以下格式:

  • 服务器或主机名,其前面是 \\. 服务器名称可以是 NetBIOS 计算机名称或 IP/FQDN 地址(支持 IPv4 和 v6)。
  • 共享名称,与主机名通过 \ 分隔。 服务器名和共享名共同组成了卷。
  • 目录名称。 目录分隔符分隔嵌套目录层次结构中的子目录。
  • 可选文件名。 目录分隔符分隔文件路径和文件名。

下面是 UNC 路径的一些示例:

路径 DESCRIPTION
\\system07\C$\ C:system07 驱动器的根目录。
\\Server2\Share\Test\Foo.txt Foo.txt 卷的测试目录中的 \\Server2\Share 文件。

UNC 路径必须始终是完全限定的。 它们可以包含相对目录段(...),但这些段必须是完全限定路径的一部分。 只能通过将 UNC 路径映射至驱动器号来使用相对路径。

DOS 设备路径

Windows作系统具有指向所有资源(包括文件)的统一对象模型。 这些对象路径可以从控制台窗口访问,并通过符号链接的特殊文件夹向 Win32 层开放,这个文件夹将旧 DOS 和 UNC 路径映射到其上。 此特殊文件夹通过 DOS 设备路径语法进行访问,这是以下项之一:

\\.\C:\Test\Foo.txt \\?\C:\Test\Foo.txt

除了通过驱动器号识别驱动器以外,还可以使用卷 GUID 来识别卷。 这采用以下形式:

\\.\Volume{b75e2c83-0000-0000-0000-602f00000000}\Test\Foo.txt \\?\Volume{b75e2c83-0000-0000-0000-602f00000000}\Test\Foo.txt

注释

从 .NET Core 1.1 和 .NET Framework 4.6.2 开始,在 Windows 上运行的 .NET 实现支持 DOS 设备路径语法。

DOS 设备路径包含以下组件:

  • 将路径标识为 DOS 设备路径的设备路径说明符(\\.\\\?\)。

    注释

    \\?\ 在所有版本的 .NET Core 和 .NET 5+ 以及从 版本 4.6.2 起的 .NET Framework 中得到支持。

  • “实际”设备对象的符号链接(如果是驱动器名称则为 C:,如果是卷 GUID 则为卷{b75e2c83-0000-0000-0000-602f00000000})。

    设备路径说明符后的第一个 DOS 设备路径段标识了卷或驱动器。 (例如, \\?\C:\\\.\BootPartition\.)

    有一个专门用于UNC的链接,不出所料,叫作UNC。 例如:

    \\.\UNC\Server\Share\Test\Foo.txt \\?\UNC\Server\Share\Test\Foo.txt

    对于设备 UNC,服务器/共享部分构成了卷。 例如,在\\?\server1\utilities\\filecomparer\中,服务器/共享部分为server1\utilities。 使用相对目录段调用 Path.GetFullPath(String, String) 等方法时,这一点非常重要;决不可能越过卷。

DOS 设备路径按定义完全限定,不能以相对目录段(...)开头。 也不会包含当前目录。

示例:引用同一文件的方法

以下示例演示了在命名空间中使用 System.IO API 时可以引用文件的一些方法。 该示例实例化对象 FileInfo ,并使用其 NameLength 属性显示文件的文件名和长度。

using System;
using System.IO;

class Program
{
    static void Main()
    {
        string[] filenames = {
            @"c:\temp\test-file.txt",
            @"\\127.0.0.1\c$\temp\test-file.txt",
            @"\\LOCALHOST\c$\temp\test-file.txt",
            @"\\.\c:\temp\test-file.txt",
            @"\\?\c:\temp\test-file.txt",
            @"\\.\UNC\LOCALHOST\c$\temp\test-file.txt" };

        foreach (string filename in filenames)
        {
            FileInfo fi = new(filename);
            Console.WriteLine($"file {fi.Name}: {fi.Length:N0} bytes");
        }
    }
}
// The example displays output like the following:
//      file test-file.txt: 22 bytes
//      file test-file.txt: 22 bytes
//      file test-file.txt: 22 bytes
//      file test-file.txt: 22 bytes
//      file test-file.txt: 22 bytes
//      file test-file.txt: 22 bytes
Imports System.IO

Module Program
    Sub Main()
        Dim filenames() As String = {
                "c:\temp\test-file.txt",
                "\\127.0.0.1\c$\temp\test-file.txt",
                "\\LOCALHOST\c$\temp\test-file.txt",
                "\\.\c:\temp\test-file.txt",
                "\\?\c:\temp\test-file.txt",
                "\\.\UNC\LOCALHOST\c$\temp\test-file.txt"}

        For Each filename In filenames
            Dim fi As New FileInfo(filename)
            Console.WriteLine($"file {fi.Name}: {fi.Length:N0} bytes")
        Next
    End Sub
End Module

路径规范化

几乎所有传递到 Windows API 的路径都已规范化。 在规范化期间,Windows 执行以下步骤:

  • 标识路径。
  • 将当前目录应用于部分限定(相对)路径。
  • 规范化组件和目录分隔符。
  • 评估相对目录组件(. 当前目录和 .. 父目录)。
  • 剪裁特定字符。

这种规范化隐式发生,但可以通过调用 Path.GetFullPath 方法显式执行此作,该方法包装对 GetFullPathName() 函数的调用。 还可以使用 P/Invoke 直接调用 Windows GetFullPathName() 函数

标识路径

路径规范化的第一步是标识路径的类型。 路径分为以下几个类别之一:

  • 它们是设备路径;也就是说,它们以两个分隔符和问号或句点(\\?\\.)开头。
  • 它们是 UNC 路径;也就是说,它们以两个分隔符开头,没有问号或句点。
  • 它们是完全限定的 DOS 路径;也就是说,它们以驱动器号、卷分隔符和组件分隔符(C:\)开头。
  • 它们指定旧设备(CONLPT1)。
  • 它们相对于当前驱动器的根路径;就是说,它们的开头是单个组件分隔符 (\)。
  • 它们相对于指定驱动器的当前目录;就是说,它们的开头是驱动器号和卷分隔符,而没有组件分隔符 (C:)。
  • 它们相对于当前目录;就是说,它们的开头是上述情况以外的任何内容 (temp\testfile.txt)。

路径的类型确定是否以某种方式应用当前目录。 它还确定路径的“根”是什么。

处理旧设备

如果路径是旧版 DOS 设备,例如 CONCOM1LPT1,则通过在其前面添加 \\.\ 将该路径转换为设备路径,并返回。

在 Windows 11 之前,该方法始终将 Path.GetFullPath(String) 以旧设备名称开头的路径解释为旧设备。 例如,DOS 设备路径是CON.TXT\\.\CON,而 DOS 设备路径是COM1.TXT\file1.txt\\.\COM1。 由于这不再适用于 Windows 11,因此请指定旧版 DOS 设备的完整路径,例如 \\.\CON

应用当前目录

如果路径未完全限定,Windows 会将当前目录应用到该路径。 不会向 UNC 和设备路径应用当前目录。 带有分隔符的 C:\ 完整驱动器也不会应用当前目录。

如果路径以单个组件分隔符开头,则会应用当前目录中的驱动器。 例如,如果文件路径为 \utilities 当前目录 C:\temp\,则规范化将生成 C:\utilities

如果路径以驱动器号、卷分隔符和无组件分隔符开头,则应用从指定驱动器的命令行界面设置的最后一个当前目录。 如未设置最新当前目录,则只应用驱动器。 例如,如果文件路径是 D:sources,当前目录是 C:\Documents\,驱动器 D: 上最后一个已知的当前目录是 D:\sources\,那么结果是 D:\sources\sources。 这些“相对磁盘路径”是程序和脚本逻辑错误的常见来源。 假设以字母和冒号开头的路径不是相对路径,显然是不正确的。

如果路径以分隔符以外的内容开头,则应用当前驱动器和当前目录。 例如,如果路径为 filecompare 并且当前目录为 C:\utilities\,则结果为 C:\utilities\filecompare\

重要

相对路径在多线程应用程序中(即大多数应用程序)中很危险,因为当前目录是按进程设置。 任何线程随时都可以更改当前目录。 从 .NET Core 2.1 开始,可以调用 Path.GetFullPath(String, String) 方法,从想要据此解析绝对路径的相对路径和基础路径(当前目录)获取绝对路径。

规范化分隔符

所有正斜杠符号(/)都被转换成标准 Windows 分隔符,即反斜杠符号(\)。 如果存在斜杠,前两个斜杠后面的一系列斜杠都将折叠为一个斜杠。

注释

从基于 Unix 的作系统上的 .NET 8 开始,运行时不再将反斜杠 (\) 字符转换为目录分隔符(正斜杠 /)。 有关详细信息,请参阅 Unix 文件路径中的反斜杠映射

评估相对组件

处理路径时,会评估所有由一个或两个句点(...)组成的组件或分段:

  • 在单个时间段内,将删除当前段,因为它引用当前目录。

  • 如果是双句点,则删除当前分段和父级分段,因为双句点表示父级目录。

    仅当父目录未超过路径的根目录时,才会删除它们。 路径的根取决于路径的类型。 对于 DOS 路径,根是驱动器 (C:\);对于UNC,根是服务器/共享 (\\Server\Share);对于设备路径,则为设备路径前缀(\\?\\\.\)。

剪裁字符

随着分隔符的运行和相对段先遭删除,一些其他字符在规范化过程中也删除了:

  • 如果某段以单个句点结尾,则删除此句点。 (单个或两个句点的段在之前的步骤中已规范化。三个或更多句点的段未规范化,并且实际上是有效的文件/目录名称。)

  • 如果路径未以分隔符结尾,则删除所有尾随的句号和空白字符(U+0020)。 如果最后一段只是单个或双周期,则它属于上述相对组件规则。

    此规则意味着可以通过在空格后面添加尾随分隔符来创建包含尾随空格的目录名称。

    重要

    不应使用尾随空格创建目录或文件名。 尾随空格可能会使访问目录变得困难或不可能,在尝试处理名称包括尾随空格的目录或文件时,应用程序通常会失败。

跳过规范化过程

通常,传递给 Windows API 的任何路径(实际上)都传递给 GetFullPathName 函数 并规范化。 有一个重要例外:一个以问号而不是句点开头的设备路径。 除非路径完全以 \\?\ (请注意规范反斜杠的使用)开头,否则它已规范化。

为什么要跳过规范化? 有三个主要原因:

  1. 为了访问那些通常无法访问但合法的路径。 例如,调用 hidden.的文件或目录无法以任何其他方式访问。

  2. 为了在已规范化的情况下通过跳过规范化过程来提升性能。

  3. 为了跳过路径长度的 MAX_PATH 检查以允许多于 259 个字符的路径(仅在 .NET Framework 上)。 大多数 API 都允许这样做,但有一些例外情况。

注释

.NET Core 和 .NET 5+ 隐式处理长路径,不执行 MAX_PATH 检查。 此 MAX_PATH 检查仅适用于 .NET Framework。

跳过规范化和最大路径检查是两个设备路径语法之间的唯一区别:否则是相同的。 请谨慎跳过规范化,因为可以轻松创建难以处理“正常”应用程序的路径。

\\?\开头的路径如果被显式传递给GetFullPathName 函数,仍会进行规范化。

可以将超过 MAX_PATH 字符的路径传递给 GetFullPathName ,而无需 \\?\。 它支持任意长度路径,最多支持 Windows 可以处理的最大字符串大小。

案例和 Windows 文件系统

非 Windows 用户和开发人员发现令人困惑的 Windows 文件系统的特点是路径和目录名称不区分大小写。 也就是说,目录名和文件名反映的是创建它们时所使用的字符串的大小写。 例如,名为

Directory.Create("TeStDiReCtOrY");
Directory.Create("TeStDiReCtOrY")

创建名为 TeStDiReCtOrY 的目录。 如果重命名目录或文件以更改其大小写,目录或文件名将反映重命名目录或文件名时使用的字符串大小写。 例如,以下代码将名为 test.txt 的文件重命名为 Test.txt:

using System.IO;

class Example3
{
    static void Main()
    {
        var fi = new FileInfo(@".\test.txt");
        fi.MoveTo(@".\Test.txt");
    }
}
Imports System.IO

Module Example3
    Public Sub Main()
        Dim fi As New FileInfo(".\test.txt")
        fi.MoveTo(".\Test.txt")
    End Sub
End Module

但是,目录和文件名比较不区分大小写。 如果您搜索名为“test.txt”的文件,.NET 文件系统 API 在比较中将忽略大小写。 “Test.txt”、“TEST.TXT”、“test.TXT”以及任何其他大小写字母组合将匹配“test.txt”。