接 WPF 桌面开发的时候,几乎每个工具类项目都会遇到这个需求:程序跑起来,主界面要显示“当前电脑的储存和运存”。这里的“储存”指的是硬盘容量,也就是磁盘存储空间;“运存”是内存,也就是 RAM。用户口中经常说的是“我这电脑还有多少个 G”“内存是多大的”,但在程序里要拿到这些数据,存在好几条完全不同的技术路线,每一条都有它的坑。
这篇文章我会从需求拆解开始讲,然后对比三种主流取数方案,给出可以直接复制的完整代码,再把我实际开发中踩过的问题、用户问得最多的疑惑、以及隐藏在背后的系统机制全部讲清楚。不管是新手还是已经写过几个 WPF 界面的同行,读完之后都可以直接用在你的项目里。
1. 需求拆解:先搞清楚“储存”和“运存”到底是什么
1.1 两个热词背后的技术真相
“运存”这个词是典型的消费电子向说法,早年手机厂和电视厂用得多,现在已经渗透到电脑领域。技术文档里的正式叫法是 Random Access Memory,即随机存取存储器,也就是内存条容量。它决定了一台电脑能同时跑多少程序、能缓存多少数据。
“储存”在个人电脑语境里通常指外存,即硬盘(SSD 或机械盘)的容量。固态硬盘容量 512GB、1TB 甚至 2TB,机械硬盘 1TB、2TB、4TB 都有。从操作系统的角度看,它能被盘符(C:、D:)等抽象为逻辑分区,也可以按物理磁盘对象进行统计。
这两个指标在实际项目里经常一起出现,因为一个完整的系统体检页通常同时展示“总硬盘容量 + 剩余空间”和“总内存 + 可用内存”。从需求角度讲,用户要看的不是纯数字,而是“我的电脑还够不够用”这一判断依据。
1.2 典型使用场景
不要觉得这个功能很 trivial,它广泛潜伏在几类 WPF 应用里。
第一类是系统信息展示工具,比如“硬件检测助手”“电脑体检软件”,主界面几乎就是硬盘条、内存条、CPU 占用率的组合。
第二类是启动自检类的内部工具。很多公司内部客户端会在启动时读取当前机器的磁盘剩余空间和内存总量,低于阈值就提示运维人员,防止在低配置机器上继续运行重任务。
第三类是游戏、设计类工作站启动器。这类应用需要根据内存大小和磁盘剩余空间决定是否允许进入高负载模式,比如大数据处理工具,内存不够时直接禁用某些功能。
第四类是装机记录、IT 资产管理场景。管理员批量部署终端时,需要程序上报每台机器的存储与内存信息。
不管哪种场景,底层拿数据的方式都差不多。区别只在于展示形式、统计口径和是否要持久化。
1.3 动手写代码之前必须想清楚的三个口径问题
第一个是容量单位。磁盘厂商按十进制计算,1GB = 1,000,000,000 字节;操作系统按二进制计算,1GB = 1,073,741,824 字节。这段换算若不统一,界面显示会差出一道鸿沟。
第二个是“总容量”和“剩余容量”必须分开。用户问“内存多大”可能想知道物理内存总数,也可能想知道当前剩余多少。磁盘同理,用户更关心“还剩多少”。
第三个是逻辑盘符与物理磁盘的统计目标。同一个物理磁盘被分成 C 和 D 两个区后,DriveInfo 遍历盘符会把总容量加两次,但实际上你只插了一块硬盘。某些产品需要统计物理硬盘数量,某些只需要统计可用空间总和,口径必须先定下来。
想清楚这三个问题,后续代码才不会写歪。
2. 技术方案选型:三套取数打法各有取舍
2.1 DriveInfo 盘符遍历:轻量级首选
在 .NET 体系里,最简单粗暴的方案是 System.IO.DriveInfo.GetDrives()。它返回当前系统里所有可用驱动器,包括固定硬盘、U 盘、光驱和网络映射盘。你可以过滤 DriveType.Fixed 来只保留本地固定磁盘。
这套方案的优点是从 BCL 直接调用,没有额外依赖,代码量极省,跨平台可运行,出错概率很低。缺点是只能拿到“逻辑卷”级别信息,拿不到物理磁盘的型号、接口类型等细节。如果要按物理硬盘维度统计,或者要读序列号和固件信息,它办不到。
2.2 WMI 查询:信息完整但要注意性能
WMI 是 Windows 管理规范(Windows Management Instrumentation),在 .NET 里通过 System.Management 命名空间访问,查询类如 SQL 一样的 WQL 语句。
获取磁盘可以用 Win32_LogicalDisk(逻辑分区)或 Win32_DiskDrive(物理磁盘),获取内存可以用 Win32_OperatingSystem、Win32_ComputerSystem,返回的内存字段覆盖总容量、可用容量、页面文件大小等,粒度比 DriveInfo 细很多。
WMI 的算力成本也不低:首次查询会触发底层枚举,轻则几百毫秒,重则两三秒,而且同步调用会明显卡 UI。实际项目里必须配合缓存或异步方案,这个后面细讲。
2.3 Windows API:性能党看过来
如果你对性能有极端要求,可以直接 P/Invoke 调用原生 API。磁盘信息对应 GetDiskFreeSpaceEx,内存信息对应 GlobalMemoryStatusEx。这套方案没有 WMI 初始化开销,速度最快,也最接近操作系统真实视图。
代价是代码量明显变大,需要定义结构体 Marshal 结构布局,还要处理 IntPtr 和非托管资源。大多数 WPF 应用性能瓶颈根本不在这一处,值不值得引入,取决于你对项目品质的要求。
2.4 方案对比
| 方案 | 依赖 | 性能 | 信息粒度 | 代码量 | 适用场景 |
|---|---|---|---|---|---|
| DriveInfo | 无 | 快 | 逻辑磁盘,总/剩余容量 | 极少 | 常规展示、跨平台工具 |
| WMI | System.Management | 首次偏慢 | 逻辑磁盘、物理磁盘、内存、硬件细节 | 中 | 系统体检、IT 资产采集 |
| Windows API | 无(P/Invoke) | 最快 | 磁盘与内存的当前状态 | 较大 | 高频刷新、性能敏感场景 |
我的实际建议是:一般 WPF 工具,Disk 部分用 DriveInfo,内存部分用 API 或 WMI 均可;如果要做完整硬件信息面板,再上 WMI 全套。下面给的示例代码也会围绕这两条主线展开。
3. 核心代码实现:磁盘容量和内存容量的获取
3.1 先定义两个数据模型
为了让代码干净一点,我会先建两个 POCO 数据类,用来统一承载查询结果。这两个类可以直接当 WPF Binding 的源对象,后面数据绑定省去很多转换逻辑。
public class HddInfo { // 磁盘总容量(字节) public long TotalSize { get; set; } // 磁盘剩余容量(字节) public long FreeSize { get; set; } // 已用空间(字节) public long UsedSize => TotalSize - FreeSize; // 磁盘总数 public int DriveCount { get; set; } } public class MemoryInfo { // 物理内存总容量(字节) public long TotalSize { get; set; } // 当前可用物理内存(字节) public long FreeSize { get; set; } public long UsedSize => TotalSize - FreeSize; }这里统一用字节做存储单位,显示层再负责格式化。这样后续换界面、加图表,都不需要重新算数。
3.2 磁盘容量:DriveInfo 实现
先来最简单的一版。遍历固定磁盘,累加总容量和剩余空间,同时数一下有多少个固定分区。
using System; using System.IO; using System.Linq; public static class DiskInfoProvider { public static HddInfo GetHddInfo() { long totalSize = 0; long freeSize = 0; int driveCount = 0; foreach (DriveInfo drive in DriveInfo.GetDrives()) { // 只统计本地固定磁盘:系统盘、数据盘 if (drive.DriveType != DriveType.Fixed) { continue; } // 这一步很关键:让 DriveInfo 真正加载磁盘状态 // 不做一下“热身”,后面的 TotalSize 可能读到 0 _ = drive.DriveFormat; if (!drive.IsReady) { continue; } totalSize += drive.TotalSize; freeSize += drive.AvailableFreeSpace; driveCount++; } return new HddInfo { TotalSize = totalSize, FreeSize = freeSize, DriveCount = driveCount }; } }注意那个_ = drive.DriveFormat;,这不是多余的一句。DriveInfo 内部采用延迟加载,第一次创建对象时不会立刻向文件系统发起查询,而是在访问某个属性时才触发底层数据填充。如果你在 new 完之后直接读 TotalSize,某些机器上会得到 0。先触发一次属性访问,后面读 TotalSize、AvailableFreeSpace 就都稳定了。这个方法第一次读驱动格式也有极小概率触发“设备未就绪”的 IOException,现实工程中最好再包一层 try-catch,至少把 IsReady 判断放在前面。
3.3 磁盘容量:WMI 按物理磁盘统计
如果产品需要展示“这台电脑装了一块 1TB 物理硬盘”,或者需要按物理盘汇总容量,DriveInfo 就无能为力了。换用 Win32_DiskDrive 可以直接拿到系统物理磁盘列表,Size 字段代表物理磁盘总大小。
using System.Management; public static class DiskInfoWmiProvider { public static HddInfo GetPhysicalDiskInfo() { long totalSize = 0; int diskCount = 0; long freeSize = 0; using (var searcher = new ManagementObjectSearcher( "SELECT Size, MediaType, InterfaceType FROM Win32_DiskDrive")) { foreach (ManagementObject disk in searcher.Get()) { if (disk["Size"] == null) { continue; } totalSize += Convert.ToInt64(disk["Size"]); diskCount++; } } // 自由空间仍从逻辑盘拿,按物理盘聚合其实还得做分区映射,这里先简单汇总固定盘 using (var freeSearcher = new ManagementObjectSearcher( "SELECT FreeSpace FROM Win32_LogicalDisk WHERE DriveType = 3")) { foreach (ManagementObject partition in freeSearcher.Get()) { if (partition["FreeSpace"] != null) { freeSize += Convert.ToInt64(partition["FreeSpace"]); } } } return new HddInfo { TotalSize = totalSize, FreeSize = freeSize, DriveCount = diskCount }; } }这段代码里有两个值得注意的地方。
第一,物理磁盘数和逻辑分区数不是一回事。一块物理盘可以分成 C、D 两个区,所以上方分别统计了“物理盘数量”和“分区剩余空间”。如果你的 UI 同时展示“物理磁盘数量和总容量、各分区剩余空间”,这个结构正好一次拿全。
第二,Convert.ToInt64 而不是强转。WMI 返回的 Size 字段通常以字符串或 uint64 形式返回,直接 (long)disk["Size"] 在某些机器上会抛 InvalidCastException。用 Convert 系列最稳。
3.4 内存容量:WMI 实现
内存信息的 WMI 查询常用的是 Win32_OperatingSystem 类。里面有两个字段和一个容易混淆的字段:
- TotalVisibleMemorySize:操作系统可见的总物理内存,单位是 KB
- FreePhysicalMemory:当前可用的物理内存,单位是 KB
- TotalVirtualMemorySize:虚拟内存,不是物理内存条
public static class MemoryInfoWmiProvider { public static MemoryInfo GetMemoryInfo() { ulong totalSize = 0; ulong freeSize = 0; using (var searcher = new ManagementObjectSearcher( "SELECT TotalVisibleMemorySize, FreePhysicalMemory FROM Win32_OperatingSystem")) { foreach (ManagementObject os in searcher.Get()) { totalSize = Convert.ToUInt64(os["TotalVisibleMemorySize"]) * 1024; freeSize = Convert.ToUInt64(os["FreePhysicalMemory"]) * 1024; } } return new MemoryInfo { TotalSize = (long)totalSize, FreeSize = (long)freeSize }; } }WMI 返回的内存单位是 KB,所以统一乘 1024 转成字节。
顺带说一个洞察:为什么很多工具里显示“内存总数”比物理条子容量小?Win32_OperatingSystem.TotalVisibleMemorySize 代表操作系统“可见”的内存,硬件保留内存、核显占用的共享内存不计入这个字段。比如 16GB 内存但核显共享了 512MB,这里可能就是 15.5GB 左右。如果产品对“物理条容量”更敏感,应该改用 Win32_ComputerSystem.TotalPhysicalMemory,它返回的是物理内存条容量(字节),更接近用户买电脑时的“16GB 内存”概念。
3.5 内存容量:API 实现(GlobalMemoryStatusEx)
如果你担心 WMI 的性能,或者界面需要每秒钟刷新一次可用内存(比如做一个内存仪表盘),建议直接上 API。
using System; using System.Runtime.InteropServices; public static class MemoryInfoApiProvider { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] private struct MEMORYSTATUSEX { public uint dwLength; public uint dwMemoryLoad; public ulong ullTotalPhys; public ulong ullAvailPhys; public ulong ullTotalPageFile; public ulong ullAvailPageFile; public ulong ullTotalVirtual; public ulong ullAvailVirtual; public ulong ullAvailExtendedVirtual; } [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer); public static MemoryInfo GetMemoryInfo() { var status = new MEMORYSTATUSEX(); status.dwLength = (uint)Marshal.SizeOf(status); if (!GlobalMemoryStatusEx(ref status)) { throw new InvalidOperationException( "调用 GlobalMemoryStatusEx 失败,错误码: " + Marshal.GetLastWin32Error()); } return new MemoryInfo { TotalSize = (long)status.ullTotalPhys, FreeSize = (long)status.ullAvailPhys }; } }注意 dwLength 必须要在调用前设置为结构体大小,这是 Win32 结构体版本协商的惯例,漏掉这句会返回 false。GlobalMemoryStatusEx 返回的是物理内存当前真实占用状况,不包含页文件,也与进程占用无关,是系统级口径。
3.6 界面展示与数据绑定
WPF 里最见效率的盘法是直接在 XAML 里绑定一个视图模型属性,然后在窗口加载事件里异步填充数据。
先定义一个简单的 ViewModel:
using System.ComponentModel; using System.Runtime.CompilerServices; public class MainViewModel : INotifyPropertyChanged { private string _hddInfoText; private string _memoryInfoText; public string HddInfoText { get => _hddInfoText; set { _hddInfoText = value; OnPropertyChanged(); } } public string MemoryInfoText { get => _memoryInfoText; set { _memoryInfoText = value; OnPropertyChanged(); } } private void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } public event PropertyChangedEventHandler PropertyChanged; }XAML 层可以放两个干净整洁的系统信息区块:
<StackPanel Margin="24"> <TextBlock Text="储存空间" FontWeight="Bold" FontSize="16"/> <TextBlock Text="{Binding HddInfoText}" Margin="0,4,0,16"/> <TextBlock Text="运行内存" FontWeight="Bold" FontSize="16"/> <TextBlock Text="{Binding MemoryInfoText}" Margin="0,4,0,0"/> </StackPanel>加载的逻辑尽量别直接写在构造函数里,放 Window.Loaded 事件更适合,此时窗口 Handle 已经就绪,也方便做异常兜底。
private async void Window_Loaded(object sender, RoutedEventArgs e) { var vm = DataContext as MainViewModel; if (vm == null) return; // 磁盘读取用 DriveInfo 很快,可以直接跑 HddInfo hdd = DiskInfoProvider.GetHddInfo(); vm.HddInfoText = $"已用 {FormatSize(hdd.UsedSize)} / 总计 {FormatSize(hdd.TotalSize)}," + $"剩余 {FormatSize(hdd.FreeSize)}({hdd.DriveCount} 个本地分区)"; // WMI 初次查询较慢,放进 Task.Run,不要卡 UI MemoryInfo memory = await Task.Run(MemoryInfoWmiProvider.GetMemoryInfo); vm.MemoryInfoText = $"已用 {FormatSize(memory.UsedSize)} / 总计 {FormatSize(memory.TotalSize)}," + $"当前可用 {FormatSize(memory.FreeSize)}"; }把耗时查询放到 Task.Run 里是 WPF 开发避免界面卡顿的基本纪律,尤其是 WMI 路线,务必这么做。
格式化方法就顺手写在工具类里:
public static string FormatSize(long bytes) { string[] units = { "B", "KB", "MB", "GB", "TB" }; double size = bytes; int unitIndex = 0; while (size >= 1024 && unitIndex < units.Length - 1) { size /= 1024; unitIndex++; } return $"{size:0.##} {units[unitIndex]}"; }这套逻辑已经足够跑通从采集到展示的完整链路。接下来聊聊那些不打开调试器根本察觉不到的隐藏坑。
4. 实操细节与避坑经验
4.1 单位显示,别栽在十进制和二进制上
磁盘容量允换算的坑最常被刚入门的同行忽略。Windows 资源管理器里显示一块“512GB”的固态硬盘只有约 476GB可用,不是因为坑你,而是因为 Windows 按 1024 进换算,磁盘厂商按 1000 进“标称容量”。这里的核心口径其实是“字节数”永远准确,界面格式化时统一按 1024 处理即可。
我给显示层定的原则是:字节永远是唯一的原始单位,格式化函数统一 1024 进制。你说的“储存”和“运存”,在不同语境下可能有不同展示口径,但底层都能落到字节上来,任何换算都不该污染原始数据。
4.2 WMI 查询“冷启动”慢,别直接塞 UI 线程
System.Management 的第一次查询需要初始化 WMI 服务连接,普通电脑要 300ms 到 2s 不等。如果直接在窗口构造函数里同步调用,界面上就是一个白屏,观感极差。
我在项目里的处理方式是三层防御:
- 把 WMI 查询放进 Task.Run
- 在扇区闲置期做好结果缓存,第二次直接用缓存
- 页面展示先用 DriveInfo/API 的快速结果顶上去,WMI 完整数据到了再刷新
这样用户第一眼看到的是基本数据,几百毫秒后细节才补齐,体感几乎无卡顿。
4.3 盘符过滤:别把光驱、U 盘和网络盘统计进来
遍历 DriveInfo 时如果不做过滤,光驱、读卡器、网络映射盘全部会混进来,数字立刻失真。上面示例代码用的 DriveType.Fixed 是最关键的过滤条件。WMI 里对应的是 Win32_LogicalDisk 的 DriveType=3(本地固定磁盘),这个数字常量来自 Win32 API 的 DRIVE_FIXED。
有几种特殊情况需要预留思考:
- 移动硬盘在某些系统下表现为 Fixed 盘,统计时会混入,是否过滤看产品需求
- 网络映射盘通常是 DriveType.Network 或 WMI DriveType=4,默认不统计
- 虚拟光驱是 DriveType.CDRom,一定排除
4.4 物理内存数字对不上,先怀疑核显保留和硬件保留
你辛辛苦苦查出来的内存总数,用户拿任务管理器一对,发现少了 200MB 甚至 1GB,立刻就质疑程序有 bug。
这不是 bug,而是两个概念差异:
- Win32_ComputerSystem.TotalPhysicalMemory 是物理条子的电容量,硬件保留内存不算进去,但有些 BIOS 固件保留区也算在物理容量里
- Win32_OperatingSystem.TotalVisibleMemorySize 是操作系统可使用的内存,核显会把一部分系统内存划转成显存,这部分不计入可见内存;硬件保留(Hardware Reserved)也不计入
做产品时我建议把两个字段都查出来,UI 上优先展示操作系统可见容量,但在详情页标注“物理内存总数”。这样既符合用户对“16GB”的期待,也能解释任务管理器里的 15.4GB 差异。
4.5 同一块物理盘分区后,别重复计总容量
这个坑在“按物理磁盘统计”的场景里反复出现。你用 DriveInfo 遍历 C、D 分区并求 TotalSize 总和,如果一块 1TB 硬盘被分为 C 500GB + D 500GB,得到的结果是 1TB,没问题。但如果用户在两块盘上分了四个区,你得到的还是总逻辑容量,这不等于物理磁盘总容量。
需要区分“存储总容量”和“物理磁盘数”时,把 Win32_DiskDrive(物理对象)与 Win32_LogicalDisk(逻辑卷)两条查询线分开,界面也分开展示。不要试图从逻辑盘反向推断物理盘数量,中间隔着分区表,推算关系复杂而且极易出错。
4.6 32 位与 64 位进程的隐藏差异
如果你的 WPF 程序编译成了 x86(32位),读容量时 long(Int64)本身没有溢出风险,因为字节数仍然可以表示。问题出在 WMI 的某些内存字段定义是 uint32,单位还是 KB。8GB 内存换算过来也只有还不到 8.4 百万 KB,不会溢出。但到了 4GB 以上,某些 API 在设计上有历史遗留限制,GlobalMemoryStatusEx 是专门为 64 位设计的,ullTotalPhys 是 ulong,安全。
真正的坑是:编译器选了 AnyCPU,程序在老旧系统上以 32 位方式跑,然后你把 TotalPhysicalMemory 强转成 int 或 uint,数字就爆了。解决方案是全体成员都用 long/ulong,转换一律 Convert.ToInt64 / Convert.ToUInt64,不该用 int 绝不用。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 磁盘总容量比标称少很多 | 厂商 1000 进制 vs 系统 1024 进制 | 按 1024 格式化显示,或兼容说明 |
| DriveInfo 读 TotalSize 为 0 | 没有先触发属性刷新,或驱动器未就绪 | 先访问 DriveFormat,再判 IsReady |
| 内存总数比物理内存少几百 MB | 核显共享显存、硬件保留 | 改用 Win32_ComputerSystem.TotalPhysicalMemory 取物理总量 |
| WMI 查询导致 UI 卡顿 | 同步调用阻塞 UI 线程 | Task.Run 异步查询并缓存结果 |
| 拿到 U 盘/光驱容量 | 未过滤 DriveType | DriveType.Fixed 或 DriveType=3 |
| 虚拟机的“内存”与宿主机一致 | 查询的是虚拟机可见视图 | 在文档标注虚拟机环境限制 |
| dll 引用报错 | 未添加 System.Management 引用 | 项目右键添加程序集引用 |
5.2 排查思路:数字不对,怎么定位是谁的锅
面对“数值不对”的质疑,我的排查顺序固定是这样的。
第一步,先打印原始字节数,不经过任何格式化,肉眼确认数值是否稳定。第二步,与任务管理器或系统信息 msinfo32 对照,确认口径是否一致。第三步,意识到系统不同视图确有差异:任务管理器的“内存”显示的是“操作系统可见”和“当前可用”,不是“物理条容量”。第四步,把 DriveInfo、WMI 和 API 三个来源都查一遍,看在某个环节差异从哪冒出来的,基本就能定位是口径问题还是代码问题。
这套方法在实际开发中帮我排掉了至少两次“产品说数据不准,其实是口径不同”的拉扯。
5.3 做个小缓存,设计更讲究
我再给一个改进方案:把查询结果缓存起来,而不是每次打开页面都重新查。WMI 冷启动慢的问题,缓存是根治手段。
public static class SystemInfoCache { private static HddInfo _hdd; private static MemoryInfo _memory; private static DateTime _lastRefreshTime; private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(5); public static async Task<(HddInfo, MemoryInfo)> GetSystemInfoAsync() { if (_hdd != null && _memory != null && DateTime.Now - _lastRefreshTime < RefreshInterval) { return (_hdd, _memory); } HddInfo hdd = await Task.Run(DiskInfoProvider.GetHddInfo); MemoryInfo memory = await Task.Run(MemoryInfoWmiProvider.GetMemoryInfo); _hdd = hdd; _memory = memory; _lastRefreshTime = DateTime.Now; return (hdd, memory); } }这样用户在主界面、设置页、关于页反复切换时,只有第一次会走完整的查询链路,后面的请求瞬间返回。如果产品里有“手动刷新”按钮,清空缓存重查即可。这份小设计能直接提升工具的响应体感,属于花小钱办大事。
我个人的习惯是把磁盘和内存查询做成一次独立服务,不在 ViewModel 里散落裸调。这样界面、日志、上报逻辑都能复用同一份数据源,后续想加“按物理盘聚合”“历史趋势记录”也只需在服务层扩展,不用动界面一层。
另外强调一句:任何查询都要做好异常兜底。WMI 在某些精简版系统或权限受限环境可能抛异常,DriveInfo 读取网络盘可能超时,API 调用可能返回失败。我通常把异常处理放在服务层,返回值用默认值或“读取失败”占位,保证界面永远不死。最终交付给用户的,不只是一堆代码,而是一个在任何机器上都站得住的工具体验。