news 2026/9/7 11:07:25

C#获取U盘VID/PID/序列号/盘符:USB设备信息识别指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#获取U盘VID/PID/序列号/盘符:USB设备信息识别指南

简介:面向需要获取U盘、移动硬盘、手机卡、MP3播放器等可移动设备底层标识信息的开发者,解决在Windows下读取盘符、厂商编号、产品编号与物理序列号的需求。源码采用VC6环境编写,可直接生成类似“盘符G、厂商编号0951、产品编号1623、序列号001CC0EC32CDEA10969B011D”的完整信息串,便于开发调试、设备管理以及自动化识别场景使用。压缩包共31个文件,约4.89MB,包含C/C++源程序、VC6工程配置、编译好的可执行程序,以及SetupAPI与配置管理器所需的静态库、头文件、调试符号和浏览信息文件,Release与Debug目录分别提供发布版与调试版程序,打开工程即可编译运行。已有1742人学习下载。通过这套源码可学习Windows下利用设备管理接口、配置管理接口及相关头文件查询可移动设备硬件信息的思路,也能直接运行示例程序查看本机U盘、移动硬盘等设备的识别结果,适合C/C++开发者对照练习底层API调用与设备信息解析,并在此基础上扩展更多设备类型与输出格式。 先说明一件事:很多人一搜“U盘 VID PID”,结果翻出来的全是自动控制领域的PID算法文章。这不是你搜错了,而是“PID”这个缩写被工业控制用得太频繁。但今天聊的是USB世界的PID——Product ID,也就是产品标识符。这篇文章我会一次性把U盘的VID、PID、盘符、物理序列号这四个信息讲透,并给出一份C#源码,直接在Windows上编译运行就能看到结果。

这个工具是在我维护一批工控机时被逼出来的。当时要限制U盘访问,但又不能靠盘符做判断,因为盘符每次插入都可能变;也不能靠卷序列号,格式化一次就换一套。折腾了一圈,真正稳定的方案是用“VID + PID + 物理序列号”做设备指纹。这个指纹能代表一块具体的U盘,能区分同一型号下不同个体,也能避免“插入设备管理器里看到一个名字,实际却定位不到盘符”这种尴尬。

下面我会从概念、原理、源码、踩坑、扩展这五条线展开,代码直接用Visual Studio或.NET环境跑起来就能看效果。

1. 为什么非要拿这四样:先搞清楚需求的本质,代码才不会白写

1.1 场景一:U盘白名单管控

公司机房或工业现场经常有“只允许特定U盘拷数据”的诉求。但Windows不会自动告诉你“哪一块U盘是合规的”,你需要自己定义合规。最常用的合规条件就是:VID匹配厂商、PID匹配型号、物理序列号匹配唯一个体。至于盘符,只是当前插入后的临时门牌号,不属于设备固有属性。

如果你只靠盘符做判断,换一个USB口、多插几块U盘,盘符可能就乱了。靠卷序列号更不行,格式化一次就变。所以做白名单,实质上是给每一块U盘发一张“身份证”,这张身份证就是VID+PID+物理序列号的三元组。

1.2 场景二:运维台账和设备追溯

维护记录里要登记“哪些U盘接触过这台机器”,不能只写“金士顿U盘一个”,因为同型号的U盘可能有几十个。你需要唯一标识。物理序列号是最合适的追溯依据,而VID/PID则能快速判断设备型号,盘符用于确认当前行为路径。四者合在一起,日志里就能精确描述“哪一厂商的哪一型号、哪一序列号的U盘,在哪个盘符下被读取”。

1.3 场景三:U盘授权软件的底层依赖

市面上不少U盘加密、U盘启动盘制作、U盘修复工具,底层都会先读取设备标识。很多工具表面上显示“设备已锁定”,实际上就是拿序列号做比对。如果序列号获取不可靠,软件就会出现“换了台电脑识别不了授权U盘”这种问题。

我的经验是:先把这个获取程序跑通、把结果打印出来,再决定后面做什么。基础信息都不准确,上层逻辑全是空中楼阁。这也是这篇文章能当项目模板用的原因——它不是只解决某一个软件的需求,而是解决了Windows下U盘信息获取的通用底座。

2. 概念拆解:VID、PID、盘符、物理序列号到底是什么

2.1 VID/PID:USB设备的厂商ID和产品ID

VID是Vendor ID,由USB-IF(USB Implementers Forum)统一分配给厂商,类似身份证里的地区码。PID是Product ID,由厂商自己定义,用于区分该厂商的不同产品型号。插上U盘后,Windows通过USB设备描述符里的idVendoridProduct字段识别设备。

这两个值不是从U盘磁盘扇区里读出来的,而是U盘主控芯片在USB枚举阶段上报给操作系统的。你可以在设备管理器里把U盘设备打开,切到“详细信息”标签,选“硬件ID”,会看到类似USB\VID_0781&PID_5583这样的字符串,其中0781是SanDisk的VID,5583是该型号的PID。

注意,U盘量产后VID/PID是可以被修改的。正规厂商不会随便改,但山寨盘或修复工具可能改成一个“通用值”。这就意味着VID/PID只能做“粗粒度筛选”,不能单独作为唯一凭证。

2.2 盘符:Windows挂载卷的“门牌号”

盘符像给一栋楼分配的门牌号,是Windows文件系统层的概念。物理U盘接入后,主控被识别为存储设备,系统再给它创建分区和卷,然后分配盘符。盘符记录在注册表的MountPointsDosDevices之下。

盘符最大的问题是会变化。同一个U盘,在这台电脑上是E:,插到另一台电脑可能是F:;即便在同一台电脑上,如果你先插了别的U盘,它也可能被挤到后面的字母。所以盘符是动态坐标,只适合“当前会话里定位卷”,不适合做长期标识。

2.3 物理序列号:U盘的硬件“身份证”

物理序列号是U盘主控固件里存的那串字符,理论上每个U盘出厂时都不一样。它在USB协议层面属于字符串描述符(String Descriptor)里的iSerialNumber字段,在SCSI存储设备层面也可能通过INQUIRY命令返回。

Windows里Win32_DiskDriveSerialNumber字段,以及在设备管理器“详细信息”里的“串行编号”属性,显示的就是物理序列号。它不会因为格式化、换电脑、换盘符而改变,是四样信息里最稳定的一个,也是我做设备绑定时最依赖的字段。

2.4 最容易混淆的卷序列号

必须单独提醒:Win32_LogicalDisk.VolumeSerialNumber是卷序列号,不是物理序列号。它是由文件系统在格式化时生成的一个32位随机值,每格式化一次就重新生成。很多人误把卷序列号当U盘ID,结果做好的授权软件在一键格式化后全部失效。

信息项来源层级是否唯一格式化后是否变化用途
VIDUSB设备描述符厂商级不变识别厂商
PIDUSB设备描述符型号级不变识别型号
盘符文件系统挂载不唯一不定当前访问路径
物理序列号主控固件/SCSI应答个体级不变唯一设备绑定
卷序列号文件系统格式化不一定唯一会变尽量不要用于授权

3. 获取路径:Windows设备树和WMI关联关系

3.1 一张设备树图看懂层级

U盘插进电脑后,Windows设备管理器里会出现一串设备节点,大致是:

USB控制器 → USB Hub → USB设备节点(USB\VID_xxxx&PID_yyyy\序列号) → USB存储设备节点(USBSTOR\Disk&Ven_xxx&Prod_xxx&Rev_xxx\序列号&0) → 磁盘驱动器(Win32_DiskDrive,如\\.\PHYSICALDRIVE1) → 磁盘分区(Win32_DiskPartition) → 逻辑磁盘卷(Win32_LogicalDisk,对应E:这样的盘符)。

注意,很多人直接拿Win32_DiskDrivePNPDeviceID去解析VID/PID,结果发现得到的是USBSTOR\Disk&Ven_SanDisk&Prod_Cruzer_Blade&Rev_1.00\...,里面根本没有VID_PID_。这是因为磁盘驱动器节点已经处于存储栈里,USB设备节点才是带VID/PID的那层。

3.2 为什么选择WMI而不是C++ SetupAPI

获取这类信息有两条路:底层用SetupAPI枚举设备信息集,或者用WMI查Win32_*类。SetupAPI更接近内核,能拿到很多底层信息,但代码量大,句柄管理复杂,对新手不友好。WMI封装好了设备管理公共信息,代码简洁,只是性能上有一定开销,但对这种“读取几个设备信息”的场景毫无压力。

我用WMI有三个理由:第一,Win32_DiskDrive能直接筛出InterfaceType = 'USB'的设备,省去遍历USB节点的麻烦;第二,GetRelated()方法能沿着设备树关联函数自动找到分区和逻辑盘;第三,后续做插拔监听、批量采集,WMI事件模型也能复用。

3.3 从磁盘到盘符的关联方式

获取盘符不能直接查Win32_LogicalDisk然后拍脑袋对应,因为系统里有可能是U盘、移动硬盘、本地硬盘混插。标准做法是三层跳跃:

第一步,从Win32_DiskDrive取到Index属性(比如1表示PHYSICALDRIVE1)。第二步,查Win32_DiskPartitionDiskIndex等于该值的所有分区。第三步,在关联类Win32_LogicalDiskToPartition里找到该分区对应的Dependent属性,也就是逻辑磁盘的DeviceID,盘符就在里面。

我代码里用的是WMI的GetRelated关联方法,直接传入关联类名就行,不用手写复杂的WQL字符串。

3.4 物理序列号的两种兜底来源

Win32_DiskDrive.SerialNumber读到的是WMI整理的序列号,大多数情况下是正确的。但部分U盘主控返回的序列号带空格,比如574D56 5349这种,需要去掉空格再使用。

如果SerialNumber字段返回空,还有一个兜底办法:从设备实例ID(PNPDeviceID)最后一段提取。USBSTOR节点的实例ID通常是序列号&0这种格式,去掉&0后剩下的字符串就是序列号。这个方法不依赖WMI对SerialNumber的解析,可靠性更高。我下面的源码里就是把两者结合起来用的。

4. 可运行源码:C#控制台版USB信息采集器

4.1 环境准备

这份代码面向Windows平台,.NET Framework 4.6.1以上可以直接编译。如果你用的是.NET 5/6/7/8,需要先安装NuGet包System.Management,然后在项目文件里加上<UseWindowsForms>true</UseWindowsForms>不一定需要,但必须确认目标平台是Windows。

开发环境推荐Visual Studio 2022,创建一个控制台应用,右键项目选择“添加引用”,勾选System.Management。如果创建的是.NET Core风格的项目,直接在NuGet里搜索System.Management安装。

4.2 完整源码

下面是我测试通过的完整代码,复制到Program.cs即可运行。

using System; using System.Collections.Generic; using System.Management; namespace UsbInfoTool { public class UsbDriveInfo { public string VID { get; set; } = ""; public string PID { get; set; } = ""; public string SerialNumber { get; set; } = ""; public string DriveLetters { get; set; } = ""; public string DeviceInstanceId { get; set; } = ""; } public static class UsbInfo { public static List<UsbDriveInfo> GetUsbStorageDevices() { var result = new List<UsbDriveInfo>(); using (var searcher = new ManagementObjectSearcher( "SELECT * FROM Win32_DiskDrive WHERE InterfaceType='USB'")) { foreach (ManagementObject disk in searcher.Get()) { string pnpDeviceId = disk["PNPDeviceID"]?.ToString() ?? ""; string serial = NormalizeSerial(disk["SerialNumber"]?.ToString() ?? ""); uint diskIndex = Convert.ToUInt32(disk["Index"]); var info = new UsbDriveInfo { SerialNumber = serial, DeviceInstanceId = pnpDeviceId, DriveLetters = string.Join(",", GetDriveLettersFromIndex(diskIndex)) }; var (vid, pid) = GetVidPid(pnpDeviceId, serial); info.VID = vid; info.PID = pid; result.Add(info); } } return result; } private static string NormalizeSerial(string serial) { return string.IsNullOrEmpty(serial) ? "" : serial.Replace(" ", ""); } private static List<string> GetDriveLettersFromIndex(uint diskIndex) { var letters = new List<string>(); using (var partSearcher = new ManagementObjectSearcher( "SELECT * FROM Win32_DiskPartition WHERE DiskIndex=" + diskIndex)) { foreach (ManagementObject partition in partSearcher.Get()) { using (ManagementObjectCollection logicalDisks = partition.GetRelated( "Win32_LogicalDisk", "Win32_LogicalDiskToPartition", null, null, null, null, false, null)) { foreach (ManagementObject logicalDisk in logicalDisks) { string letter = logicalDisk["DeviceID"]?.ToString() ?? ""; if (!string.IsNullOrEmpty(letter) && !letters.Contains(letter)) { letters.Add(letter); } } } } } return letters; } private static (string vid, string pid) GetVidPid(string diskPnpDeviceId, string diskSerial) { string pnpSerial = GetLastSegment(diskPnpDeviceId); using (var searcher = new ManagementObjectSearcher( "SELECT PNPDeviceID FROM Win32_PnPEntity WHERE PNPDeviceID LIKE 'USB\\\\VID_%'")) { foreach (ManagementObject entity in searcher.Get()) { string usbPnpId = entity["PNPDeviceID"]?.ToString() ?? ""; string usbSerial = GetLastSegment(usbPnpId); if (string.IsNullOrEmpty(usbSerial)) { continue; } bool matched = false; if (!string.IsNullOrEmpty(diskSerial) && (usbSerial.IndexOf(diskSerial, StringComparison.OrdinalIgnoreCase) >= 0 || diskSerial.IndexOf(usbSerial, StringComparison.OrdinalIgnoreCase) >= 0)) { matched = true; } else if (!string.IsNullOrEmpty(pnpSerial) && (usbSerial.IndexOf(pnpSerial, StringComparison.OrdinalIgnoreCase) >= 0 || pnpSerial.IndexOf(usbSerial, StringComparison.OrdinalIgnoreCase) >= 0)) { matched = true; } if (!matched) { continue; } string[] parts = usbPnpId.Split('\\'); if (parts.Length >= 2) { string[] ids = parts[1].Split('&'); string vid = "", pid = ""; foreach (string id in ids) { if (id.StartsWith("VID_", StringComparison.OrdinalIgnoreCase)) vid = id.Substring(4); else if (id.StartsWith("PID_", StringComparison.OrdinalIgnoreCase)) pid = id.Substring(4); } if (!string.IsNullOrEmpty(vid) && !string.IsNullOrEmpty(pid)) { return (vid, pid); } } } } return ("", ""); } private static string GetLastSegment(string pnpId) { if (string.IsNullOrEmpty(pnpId)) return ""; string last = pnpId.Substring(pnpId.LastIndexOf('\\') + 1); int ampIndex = last.IndexOf('&'); if (ampIndex >= 0) { last = last.Substring(0, ampIndex); } return last; } } class Program { static void Main(string[] args) { Console.WriteLine("正在扫描USB存储设备..."); Console.WriteLine(); var drives = UsbInfo.GetUsbStorageDevices(); if (drives.Count == 0) { Console.WriteLine("未检测到USB存储设备,请插入U盘后重试。"); } else { foreach (var d in drives) { Console.WriteLine("========================"); Console.WriteLine("VID: " + d.VID); Console.WriteLine("PID: " + d.PID); Console.WriteLine("物理序列号: " + d.SerialNumber); Console.WriteLine("盘符: " + d.DriveLetters); Console.WriteLine("设备实例ID: " + d.DeviceInstanceId); Console.WriteLine(); } } Console.WriteLine("按任意键退出..."); Console.ReadKey(); } } }

4.3 关键函数逻辑说明

GetUsbStorageDevices是入口,用WQL过滤InterfaceType='USB',保证只会拿到USB接口的磁盘,不会带上SATA、NVMe硬盘。这一步是整段代码的基石,如果不加条件,你会在结果里看到所有本地硬盘,区分起来很痛苦。

GetDriveLettersFromIndex负责从磁盘索引找盘符。它通过Win32_DiskPartitionDiskIndex属性找到分区,再调用GetRelated关联到Win32_LogicalDisk。这里不建议用Win32_LogicalDisk.DeviceID单独查询,因为本地硬盘、移动硬盘、虚拟磁盘都混在一起,不经过磁盘链路无法准确定位。

GetVidPid做的是“从USB节点反向匹配”。因为Win32_DiskDrivePNPDeviceIDUSBSTOR\Disk&Ven_...&Prod_...&Rev_...\...格式,不含VID/PID,所以我先生成两个候选序列号:diskSerial(WMI的SerialNumber字段)和pnpSerial(PNPDeviceID最后一段),然后遍历所有PNPDeviceID以USB\VID_开头的USB节点,用序列号模糊匹配。匹配成功的USB节点中,VID_PID_后面的四/五位十六进制数就是要找的值。

有人会问,为什么不用设备管理器里显示的“硬件ID”直接匹配?因为Win32_PnPEntity只暴露PNPDeviceID,没有现成的硬件ID数组,用序列号匹配是纯WMI方案里最稳的方式。

4.4 运行输出示例

插入一块SanDisk U盘后,程序输出大致如下:

正在扫描USB存储设备... ======================== VID: 0781 PID: 5583 物理序列号: 4C530001020830119272 盘符: E: 设备实例ID: USBSTOR\Disk&Ven_SanDisk&Prod_Cruzer_Blade&Rev_1.00\4C530001020830119272&0

如果同时插入两块U盘,会依次输出两段。需要确认哪块对应哪个盘符,看盘符字段就行。

5. 实测中的坑:这些问题不提前知道会被搞蒙

5.1 一块U盘出现两个盘符

部分U盘出厂时划分了两个分区,比如一个公共分区和一个加密分区。这种情况下,GetDriveLettersFromIndex会把两个盘符都收集回来,输出类似E:, F:。物理序列号完全相同,VID/PID也相同,只有盘符不同。

遇到这种设备,你在做白名单时要保留“一块U盘多个盘符”的数据结构,不要简单按盘符逐条判断。否则实际插入时只匹配到E:,而程序拿到的是F:,会误判为未授权。

5.2 读卡器+TF卡:序列号可能不是TF卡的

读卡器本身是一个USB设备,TF卡插进去后,系统识别到的“物理序列号”有相当概率来自读卡器的主控,而不是TF卡芯片。这意味着同一张TF卡换一个读卡器,序列号可能就变了。

如果你在做U盘授权,尽量避免用“读卡器+TF卡”方案做唯一凭证。如果非要用,至少要把读卡器的VID/PID也纳入绑定逻辑。更稳妥的做法是识别出设备属于读卡器后,提示用户换直插式U盘。

5.3 WMI序列号里的空格和乱码

正规U盘的SerialNumber一般是一串连续字符,但有些厂商会在内部用空格填充固定长度,比如“WD 1234 ABC 9876”,去掉空格后才是真实序列号。还有极少数主控会返回乱码或者全空白,这时候GetVidPid里的pnpSerial兜底方案就派上用场了。

我建议把NormalizeSerial这一行强制保留,不管读出来什么样,先去掉所有空格。如果SerialNumber为空,程序会自动走PNPDeviceID最后一段匹配,实测很多老U盘靠这个兜底能拿到正确的VID/PID。

5.4 U盘插入后没有盘符

有些U盘没有分区,或者分区表损坏,Windows不会分配盘符,但Win32_DiskDrive里仍然能看到这个设备。这种情况下盘符字段会打印为空字符串,但VID/PID/物理序列号都能正常获取。

反过来这也是个功能:你可以用这个工具先检测U盘是否存在,再决定是否提示用户去初始化分区。配合热词里的“U盘修复工具”“U盘检测真实容量”场景,这个能力很有用。

6. 从工具到产品:扩展成一个U盘白名单系统

6.1 白名单判定逻辑

拿到四样信息后,最直接的扩展是做一个白名单JSON配置,比如:

[ { "VID": "0781", "PID": "5583", "SerialNumber": "4C530001020830119272", "Description": "采购部数据导入盘" } ]

判定逻辑按顺序来:先匹配VID,再匹配PID,最后匹配物理序列号。前两项用来快速排除明显不相关的设备,序列号用来锁定唯一个体。盘符不进白名单,只作为当前会话的展示信息。

6.2 插拔事件实时监听

与其定时轮询,不如用WMI事件监听U盘插拔。核心代码只有几行,订阅__InstanceCreationEvent__InstanceDeletionEvent,过滤TargetInstance ISA 'Win32_DiskDrive',然后调用上面封装好的GetUsbStorageDevices刷新列表。

我习惯用ManagementEventWatcher做一个后台线程,插拔后触发回调。回调里不要做耗时操作,直接把新状态发到界面线程,更新白名单判断结果。

6.3 管控升级方向

白名单只是第一步。再往下走,可以在检测到非白名单U盘时执行策略:通过Windows组策略里的“禁止安装可移动设备”,或者用注册表HKLM\SYSTEM\CurrentControlSet\Services\USBSTORStart值禁用整个USB存储驱动。但要注意,禁用USBSTOR会连键盘鼠标等USB设备一起屏蔽,实际项目里要更精细地通过设备安装限制来拦截特定硬件ID。

还有一个可做的方向是把设备信息联动到日志系统。每次插拔都记录一条结构日志,字段就是文章标题里的四样东西——VID、PID、盘符、物理序列号,真正实现U盘行为可审计。

我在实际使用中的一个经验是:别把程序写在一次性脚本里,一定要把这套信息获取封装成一个独立的工具类。后续无论是做命令行工具、Windows服务,还是给上位机软件加U盘授权,都能直接复用。那几行关联查询和序列号匹配逻辑,是我调试了接近一下午才稳定下来的,遇到读卡器、量产盘、无盘符设备这些边缘情况时,代码里的兜底逻辑能省下大量排查时间。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 11:05:00

嵌入式AI生成代码:验证体系才是真正的效率瓶颈

搞嵌入式的人应该都有一个很深的体感&#xff1a;AI 生成代码的速度快到离谱&#xff0c;几秒钟就能给你吐出一段能编译通过的 C 代码&#xff0c;但把这段代码真正验证到能上车、能稳定跑的程度&#xff0c;测试验证和路试可能要半个月。这个时间差&#xff0c;恰恰是嵌入式场…

作者头像 李华
网站建设 2026/9/7 10:57:56

智选球员:运用动态规划提升棒球队的签约效益

目录 一、签约棒球自由球员 二、分析和理解 (一)问题背景回顾 (二)目标确定 (三)约束条件分析 (四)明确输出要求 三、动态规划(Dynamic Programming)解析 (一)状态定义和初始化 (二)状态转移分析 (三)状态转移的解释 四、具体代码实现 五、时间和空…

作者头像 李华
网站建设 2026/9/7 10:55:48

华硕弘道Ultra:论文校对与项目申报的智能工作流优化实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 10:52:59

IoT设备版本治理:固件、配置与设备模型的三层分离实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 10:52:50

GitHub开源效率工具盘点:Motrix、GenOffice与Qx启动器

很多读者问&#xff1a;GitHub 上每天上新几百个项目&#xff0c;到底哪些值得装到自己的电脑上&#xff1f;看了一圈 star 数&#xff0c;收藏了一大堆仓库&#xff0c;最后还是不知道该用哪个。 我的判断很直接&#xff1a;收藏夹里那些“看起来不错”的项目&#xff0c;价值…

作者头像 李华