65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 C# 弹出 USB 磁盘

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.76/5 (83投票s)

2006年3月22日

CPOL

7分钟阅读

viewsIcon

625608

downloadIcon

19170

本文将介绍如何使用 .NET 以编程方式弹出 USB 可移动磁盘驱动器,并提供一个示例 GUI 应用程序。

引言

尽管使用 Windows Shell 弹出 USB 闪存驱动器很容易,但以编程方式实现却相当困难。需要理解许多底层概念,这些概念涉及到内核驱动程序开发。当你开始这项工作时,我并没有真正想到它会导向何处。我当然也没想到需要切换内核驱动程序控制代码、Windows Setup 和 Configuration Manager API、WMI 等等。

嗯,这正是本文的主要原因,我认为这个主题确实值得一篇独立的文章。最终结果是一个演示 .NET Winforms 项目,其中包含可重用的源代码。

另外,作为一项附加好处,我还将解释如何读取这些磁盘的硬件序列号,这是新闻组和论坛上经常被问到的问题。

背景

首先,我们来谈谈这里涉及的各种 Windows API,除了 Win32 之外,还有很多。

  1. Windows DDK(驱动程序开发工具包):一个驱动程序开发工具包,提供了一个构建环境、工具、驱动程序示例和文档,以支持 Windows 的驱动程序开发。
  2. Setup API:一个使用不足且不太为人所知的 Windows API。它之所以使用不足,部分原因是因为它属于 DDK,但实际上,这些是通用的 Setup 例程,用于安装应用程序,其中包含许多非常有趣的功能,例如 CM_Request_Device_Eject 函数,它是 UsbEject 项目的核心。
  3. DeviceIoControl 函数:“芝麻开门”式的用户模式函数,可以访问内核模式深处。使用此函数可以完成大量工作(其中很大一部分是未公开或未充分公开的)。
  4. WMI(Windows Management Instrumentation)UsbEject 项目实际上并不使用 WMI,因为据我所知,WMI 不处理设备弹出。我本可以使用一种混合方法,将 WMI 用于磁盘管理,其他部分用于设备弹出。留给读者作为练习。
  5. Windows 消息:WM_DEVICECHANGE 窗口消息在此示例 GUI 应用程序中使用,以便在设备管理器树发生变化时刷新 UI。

现在,让我们介绍几个术语。这里的定义是我自己的,并非官方定义(实际上很难找到所有这些术语的官方定义)。

  1. 物理磁盘:顾名思义,这是最终用户操作的真实硬件。对于 USB 磁盘,就是 U 盘本身。
  2. :实际上有两种类型的卷:Windows 理解的卷,以及我们所知的卷,即驱动器号(从 A:Z:),也称为逻辑磁盘。一个卷可以跨越多个物理磁盘。
  3. 设备:Windows DDK 将卷和物理磁盘都定义为设备。代码反映了这一点。设备是基类型,卷继承自它。

 

使用代码

该项目是一个 Visual Studio 2005(.NET 2.0)Windows 窗体项目。其中有一个名为 Library 的文件夹,其中包含核心部分,即用于处理 USB 磁盘的面向对象 API。这些类在以下类图中有描述:

Sample Image - usbeject2.gif

如您所见,有 5 个主要类(每个类都定义在其各自的 .cs 文件中):

  1. DeviceClass:一个抽象类,代表一个物理设备类别。它有一个属于该类别的设备列表。
  2. DiskDeviceClass:代表系统中的所有磁盘设备。
  3. VolumeDeviceClass:代表系统中的所有卷设备。
  4. Device:代表任何类型的通用设备(磁盘、卷等)。请注意,没有 Disk 类,因为在此项目中,与 Device 相比,Disk 没有特定属性。另请注意,代码设计为可以扩展到其他设备,而不仅仅是卷和磁盘。
  5. Volume:代表一个 Windows 卷。一个卷有一个 LogicalDrive 属性,如果分配了驱动器号,则会填充该属性。

以下是如何弹出所有 USB 卷的示例:

VolumeDeviceClass volumeDeviceClass = new VolumeDeviceClass();
foreach (Volume device in volumeDeviceClass.Devices)
{
    // is this volume on USB disks?
    if (!device.IsUsb)
        continue;

    // is this volume a logical disk?
    if ((device.LogicalDrive == null) || (device.LogicalDrive.Length == 0))
        continue;

    device.Eject(true); // allow Windows to display any relevant UI
}

 

关注点

CM_Request_Device_Eject 函数

这是 SetupApi 函数,用于弹出设备(任何可弹出的设备)。它接受设备实例句柄(或 devInst)作为输入。如果提供了第二个参数 pVetoType,函数的行为会有所不同。如果提供了该参数,Windows Shell 不会向最终用户显示任何对话框,并且弹出操作将成功或失败(并返回错误代码,称为 Veto),而不会显示任何提示。如果未提供,Windows Shell 可能会显示传统的提示或对话框,或者气球窗口来告知最终用户(注意:在某些情况下,Windows 2000 可能会一直显示消息,即使未提供第二个参数),告知他们发生了有趣的事情。

因此,主要问题是“对于给定的磁盘字母,要弹出哪个设备?”

要弹出的设备必须是磁盘设备,而不是卷设备(至少对于 USB 磁盘是这样)。因此,通用算法如下:

  1. 使用 .NET 的 Environment.GetLogicalDrives 确定所有可用的逻辑磁盘。
  2. 对于每个逻辑磁盘(驱动器号),使用 Win32 的 GetVolumeNameForVolumeMountPoint 确定实际卷名。
  3. 对于每个卷设备,确定是否存在匹配的逻辑磁盘。
  4. 对于每个卷设备,确定组成该卷设备的物理磁盘设备(使用它们的编号)(因为一个卷可能包含多个物理磁盘),使用 DDK 的 IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS IO 控制码。
  5. 由于即插即用(PNP)配置管理器会构建设备层次结构,因此物理磁盘设备通常不是要弹出的设备,所以对于每个物理磁盘设备,需要确定在其祖先设备中要弹出哪个设备。要弹出的设备是层次结构中第一个具有 CM_DEVCAP_REMOVABLE 功能的设备。

 

GetVolumeNameForVolumeMountPoint 函数

为了将 Windows 卷与逻辑磁盘匹配(算法步骤 3),有一个诀窍。首先,我想说的是,所有 Windows 卷都可以用一种特定的语法“\\?\Volume{GUID}\”来唯一寻址,其中 GUID 是标识卷的 GUID

在枚举卷设备时(使用卷设备类 Guid GUID_DEVINTERFACE_VOLUME,不用担心,VolumeDeviceClass 已经封装了它),卷设备路径可以直接用于调用 GetVolumeNameForVolumeMountPoint,如果在后面附加“\”,尽管这严格来说不是卷名。

枚举给定类别的所有设备

我在此处重现了一个代码片段,展示了 .NET P/Invoke 互操作如何用于枚举给定类型的所有物理设备。

int index = 0;
while (true)
{
    // enumerate device interface for a given index
    SP_DEVICE_INTERFACE_DATA interfaceData = new SP_DEVICE_INTERFACE_DATA();
    if (!SetupDiEnumDeviceInterfaces(
        _deviceInfoSet, null, ref _classGuid, index, interfaceData))
    {
        int error = Marshal.GetLastWin32Error();
        // this is not really an error...
        if (error != Native.ERROR_NO_MORE_ITEMS)
            throw new Win32Exception(error);
        break;
    }

    SP_DEVINFO_DATA devData = new SP_DEVINFO_DATA();
    int size = 0;
    // get detail for all the device interface
    if (!SetupDiGetDeviceInterfaceDetail(
        _deviceInfoSet, interfaceData, IntPtr.Zero, 0, ref size, devData))
    {
        int error = Marshal.GetLastWin32Error();
        if (error != Native.ERROR_INSUFFICIENT_BUFFER)
        throw new Win32Exception(error);
    }

    // allocate unmanaged Win32 buffer
    IntPtr buffer = Marshal.AllocHGlobal(size);
    SP_DEVICE_INTERFACE_DETAIL_DATA detailData =
                    new SP_DEVICE_INTERFACE_DETAIL_DATA();
    detailData.cbSize = Marshal.SizeOf(
                        typeof(Native.SP_DEVICE_INTERFACE_DETAIL_DATA));

    // copy managed struct buffer into unmanager win32 buffer
    Marshal.StructureToPtr(detailData, buffer, false);

    if (!SetupDiGetDeviceInterfaceDetail(
        _deviceInfoSet, interfaceData, buffer, size, ref size, devData))
    {
        Marshal.FreeHGlobal(buffer); // don't forget to free memory
        throw new Win32Exception(Marshal.GetLastWin32Error());
    }

    // a bit of voodoo magic. This code is not 64 bits portable :-)
    IntPtr pDevicePath = (IntPtr)((int)buffer + Marshal.SizeOf(typeof(int)));
    string devicePath = Marshal.PtrToStringAuto(pDevicePath);
    Marshal.FreeHGlobal(buffer);
    index++;
}

 

插入或移除磁盘时刷新 UI

这有多种实现方式。我选择了最简单的一种:捕获 WM_DEVICECHANGE Windows 消息。您只需要覆盖应用程序中任何 Winform 的默认窗口过程,如下所示:

protected override void WndProc(ref Message m)
{
    if (m.Msg == Native.WM_DEVICECHANGE)
    {
        if (!_loading)
        {
            LoadItems(); // do the refresh work here
        }
    }
    base.WndProc(ref m);
}

 

关于序列号

这严格来说与弹出主题无关,但我见过很多关于此的问题,所以我也将讨论一下硬盘的“序列号”。当谈论 Windows 卷和磁盘时,实际上(至少)有两个序列号:

  1. 卷软件序列号,在格式化过程中分配。这个 32 位值可以使用常规的 Win32 函数 GetVolumeInformation 轻松读取。
  2. 磁盘供应商的硬件序列号:此序列号由供应商在制造过程中设置。它是一个字符串。当然,它无法更改。不幸的是,您需要知道 USB 设备是可选序列号的,因此 USB 存储设备可能没有序列号,事实上,很多都没有。

好的,那么问题是“如何读取硬件序列号?”。至少有两种方法:

  1. WMI:到目前为止,这是最简单的方法,尽管一开始看起来像黑魔法。以下是此类代码的示例(未包含在项目 .zip 包中)。

     

    // browse all USB WMI physical disks
    foreach(ManagementObject drive in new ManagementObjectSearcher(
        "select * from Win32_DiskDrive where InterfaceType='USB'").Get())
    {
        // associate physical disks with partitions
        foreach(ManagementObject partition in new ManagementObjectSearcher(
            "ASSOCIATORS OF {Win32_DiskDrive.DeviceID='" + drive["DeviceID"]
              + "'} WHERE AssocClass = 
                    Win32_DiskDriveToDiskPartition").Get())
        {
            Console.WriteLine("Partition=" + partition["Name"]);
    
            // associate partitions with logical disks (drive letter volumes)
            foreach(ManagementObject disk in new ManagementObjectSearcher(
                "ASSOCIATORS OF {Win32_DiskPartition.DeviceID='"
                  + partition["DeviceID"]
                  + "'} WHERE AssocClass =
                    Win32_LogicalDiskToPartition").Get())
            {
                Console.WriteLine("Disk=" + disk["Name"]);
            }
        }
    
        // this may display nothing if the physical disk
        // does not have a hardware serial number
        Console.WriteLine("Serial="
         + new ManagementObject("Win32_PhysicalMedia.Tag='"
         + drive["DeviceID"] + "'")["SerialNumber"]);
    }
    
  2. 使用此处描述的原始技术 here。我没有将其移植到 C#,尽管完全可行。

 

备注

  1. 源代码是使用 .NET Framework 2.0 设计的,但应该可以在 .NET Framework 1.1 上工作,尽管尚未测试。我认为它无法按原样在 64 位 CLR 上工作,因为互操作结构的大小可能会有所不同。不过,可以很容易地移植。
  2. 该代码已在 Windows XP SP2 和 Windows Server 2003 上进行了测试,但在 Windows 2000 上未测试,但应该可以工作。我认为该代码无法在 Windows 9x 上工作或移植。
  3. 我没有研究所有这些的安全影响。特别是,调用 API 的 Windows 用户显然必须拥有某些权限才能访问物理设备。我已经将这些实验留给读者作为练习。
© . All rights reserved.