使用 C# 弹出 USB 磁盘






4.76/5 (83投票s)
本文将介绍如何使用 .NET 以编程方式弹出 USB 可移动磁盘驱动器,并提供一个示例 GUI 应用程序。
引言
尽管使用 Windows Shell 弹出 USB 闪存驱动器很容易,但以编程方式实现却相当困难。需要理解许多底层概念,这些概念涉及到内核驱动程序开发。当你开始这项工作时,我并没有真正想到它会导向何处。我当然也没想到需要切换内核驱动程序控制代码、Windows Setup 和 Configuration Manager API、WMI 等等。
嗯,这正是本文的主要原因,我认为这个主题确实值得一篇独立的文章。最终结果是一个演示 .NET Winforms 项目,其中包含可重用的源代码。
另外,作为一项附加好处,我还将解释如何读取这些磁盘的硬件序列号,这是新闻组和论坛上经常被问到的问题。
背景
首先,我们来谈谈这里涉及的各种 Windows API,除了 Win32 之外,还有很多。
- Windows DDK(驱动程序开发工具包):一个驱动程序开发工具包,提供了一个构建环境、工具、驱动程序示例和文档,以支持 Windows 的驱动程序开发。
Setup
API:一个使用不足且不太为人所知的 Windows API。它之所以使用不足,部分原因是因为它属于 DDK,但实际上,这些是通用的 Setup 例程,用于安装应用程序,其中包含许多非常有趣的功能,例如CM_Request_Device_Eject
函数,它是UsbEject
项目的核心。DeviceIoControl
函数:“芝麻开门”式的用户模式函数,可以访问内核模式深处。使用此函数可以完成大量工作(其中很大一部分是未公开或未充分公开的)。- WMI(Windows Management Instrumentation):
UsbEject
项目实际上并不使用 WMI,因为据我所知,WMI 不处理设备弹出。我本可以使用一种混合方法,将 WMI 用于磁盘管理,其他部分用于设备弹出。留给读者作为练习。 - Windows 消息:
WM_DEVICECHANGE
窗口消息在此示例 GUI 应用程序中使用,以便在设备管理器树发生变化时刷新 UI。
现在,让我们介绍几个术语。这里的定义是我自己的,并非官方定义(实际上很难找到所有这些术语的官方定义)。
- 物理磁盘:顾名思义,这是最终用户操作的真实硬件。对于 USB 磁盘,就是 U 盘本身。
- 卷:实际上有两种类型的卷:Windows 理解的卷,以及我们所知的卷,即驱动器号(从 A: 到 Z:),也称为逻辑磁盘。一个卷可以跨越多个物理磁盘。
- 设备:Windows DDK 将卷和物理磁盘都定义为设备。代码反映了这一点。设备是基类型,卷继承自它。
使用代码
该项目是一个 Visual Studio 2005(.NET 2.0)Windows 窗体项目。其中有一个名为 Library 的文件夹,其中包含核心部分,即用于处理 USB 磁盘的面向对象 API。这些类在以下类图中有描述:
如您所见,有 5 个主要类(每个类都定义在其各自的 .cs 文件中):
DeviceClass
:一个抽象类,代表一个物理设备类别。它有一个属于该类别的设备列表。DiskDeviceClass
:代表系统中的所有磁盘设备。VolumeDeviceClass
:代表系统中的所有卷设备。Device
:代表任何类型的通用设备(磁盘、卷等)。请注意,没有Disk
类,因为在此项目中,与Device
相比,Disk 没有特定属性。另请注意,代码设计为可以扩展到其他设备,而不仅仅是卷和磁盘。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 磁盘是这样)。因此,通用算法如下:
- 使用 .NET 的
Environment.GetLogicalDrives
确定所有可用的逻辑磁盘。 - 对于每个逻辑磁盘(驱动器号),使用 Win32 的
GetVolumeNameForVolumeMountPoint
确定实际卷名。 - 对于每个卷设备,确定是否存在匹配的逻辑磁盘。
- 对于每个卷设备,确定组成该卷设备的物理磁盘设备(使用它们的编号)(因为一个卷可能包含多个物理磁盘),使用 DDK 的
IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS
IO 控制码。 - 由于即插即用(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 卷和磁盘时,实际上(至少)有两个序列号:
- 卷软件序列号,在格式化过程中分配。这个 32 位值可以使用常规的 Win32 函数
GetVolumeInformation
轻松读取。 - 磁盘供应商的硬件序列号:此序列号由供应商在制造过程中设置。它是一个字符串。当然,它无法更改。不幸的是,您需要知道 USB 设备是可选序列号的,因此 USB 存储设备可能没有序列号,事实上,很多都没有。
好的,那么问题是“如何读取硬件序列号?”。至少有两种方法:
- 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"]); }
- 使用此处描述的原始技术 here。我没有将其移植到 C#,尽管完全可行。
备注
- 源代码是使用 .NET Framework 2.0 设计的,但应该可以在 .NET Framework 1.1 上工作,尽管尚未测试。我认为它无法按原样在 64 位 CLR 上工作,因为互操作结构的大小可能会有所不同。不过,可以很容易地移植。
- 该代码已在 Windows XP SP2 和 Windows Server 2003 上进行了测试,但在 Windows 2000 上未测试,但应该可以工作。我认为该代码无法在 Windows 9x 上工作或移植。
- 我没有研究所有这些的安全影响。特别是,调用 API 的 Windows 用户显然必须拥有某些权限才能访问物理设备。我已经将这些实验留给读者作为练习。