【Storage存储测试】07-WPF 通过 WMI + NVMe SMART 实现 SSD 规格自动验证

【存储测试】07-WPF 通过 WMI + NVMe SMART 实现 SSD 规格自动验证

系列:《WPF 产线功能测试实战》第七篇
关键词WPF C# WMI NVMe SMART P/Invoke SSD检测 产线测试 .NET Framework CrystalDiskMark


一、前言与应用场景

在笔记本电脑的产线测试中,SSD 的规格验证是一个比内存更复杂的环节。内存规格可以直接从 SMBIOS 读取,SSD 的情况则更多样:需要验证容量是否符合订单、固件版本是否为指定版本(固件版本决定了盘的稳定性和兼容性)、SMART 健康数据是否正常(排查翻新盘/超时使用的设备)。

本文介绍如何通过 WMI Win32_DiskDrive 读取磁盘基本信息,通过 NVMe SMART 接口读取通电时长和通电次数,实现三项覆盖不同失效场景的存储规格自动验证。Demo 用本地 JSON 文件替代生产线 MES,开箱即用。


二、基础概念科普

WMI 是什么?

WMI(Windows Management Instrumentation)是 Windows 内置的系统管理接口,可以理解成操作系统提供的"硬件信息查询服务"。C# 通过 System.Management 命名空间(或封装好的查询类)即可调用,不需要额外的驱动或 DLL,适合获取磁盘型号、序列号、固件版本、容量等基础信息。

SSD 容量为什么要留 5% 容差?

磁盘厂商用十进制 标注容量:1GB = 1,000,000,000 字节;而 Windows 用二进制 (GiB)报告:1GiB = 1,073,741,824 字节。WMI 返回的 Size 字段单位是字节,换算成 GiB 后,一块标称"512GB"的硬盘实际显示约 476 GiB

如果不留容差直接比对,几乎所有机器都会失败。5% 的容差(约 25GB)覆盖了这种单位换算偏差,同时也能容忍不同批次盘的微小出厂差异。

固件版本(Firmware Revision)是什么?

固件是 SSD 控制器内置的"操作系统",决定了盘的读写调度策略、错误修复算法、与主机兼容性等。产线测试指定固件版本,是为了确保出厂设备的固件统一,避免老固件存在的已知 Bug 流入市场。

NVMe SMART 是什么?

SMART(Self-Monitoring, Analysis and Reporting Technology)是硬盘自我健康监测机制。NVMe SSD 的 SMART 数据通过 NVMe 协议的 Get Log Page 命令获取,其中最关键的两个字段是:

字段 含义 产测用途
PowerOnHours 累计通电时长(小时) 排查翻新盘 / 库存积压设备
PowerCycle 累计通电次数 排查异常开关机次数过多的设备

Windows 没有原生 .NET API 直接读取 NVMe SMART,需要通过自定义 DLL 封装 DeviceIoControl 的 NVMe passthrough 命令来实现。


三、方案设计与架构

测试流程

Step 3 速度测试在生产环境中已注释掉------速度测试耗时较长(约 2-3 分钟),根据实际需要决定是否启用。

MVVM 职责分层

文件 职责
View MainWindow.xaml 显示测试状态和日志
ViewModel MainVM.cs 编排三步测试,更新 UI
工具类 DiskInfoReader.cs WMI 磁盘查询封装
工具类 NvmeSmartReader.cs NVMe SMART 读取(P/Invoke DLL)
配置 StorageConfig.cs 读写本地 JSON 配置文件

核心依赖

  • System.Management(.NET Framework 内置,需在项目中添加程序集引用)
  • Newtonsoft.Json(NuGet:Install-Package Newtonsoft.Json
  • NVMeSMARTInformation.dll(自定义封装 NVMe 命令的原生 DLL,Demo 中提供优雅降级)

四、核心代码详解

4.1 WMI 读取磁盘基本信息

csharp 复制代码
using System.Management;

public class DiskInfoReader
{
    public class DiskInfo
    {
        public string Model { get; set; }
        public string SerialNumber { get; set; }
        public string FirmwareRevision { get; set; }
        public double SizeGB { get; set; }  // 单位:GiB(Windows 二进制换算后)
    }

    public List<DiskInfo> GetAllDisks()
    {
        var result = new List<DiskInfo>();

        // WMI 查询 Win32_DiskDrive,返回所有物理磁盘
        using var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_DiskDrive");
        foreach (ManagementObject obj in searcher.Get())
        {
            var info = new DiskInfo
            {
                Model = obj["Model"]?.ToString() ?? string.Empty,
                SerialNumber = obj["SerialNumber"]?.ToString()?.Trim() ?? string.Empty,
                FirmwareRevision = obj["FirmwareRevision"]?.ToString()?.Trim() ?? string.Empty,
                // Size 字段单位为字节,/1024^3 换算成 GiB,保留2位小数
                SizeGB = obj["Size"] != null
                    ? Math.Round(Convert.ToUInt64(obj["Size"]) / 1024.0 / 1024 / 1024, 2)
                    : 0
            };
            result.Add(info);
        }
        return result;
    }
}

Win32_DiskDriveFirmwareRevision 字段对应设备管理器里显示的固件版本号。注意 Trim() 是必要的------WMI 返回的字符串可能带有尾部空格,直接比较会导致匹配失败。

4.2 容量对比:5% 容差处理

csharp 复制代码
public bool CheckSize(double actualGiB, double expectedGiB)
{
    if (expectedGiB <= 0) return false;
    double tolerance = expectedGiB * 0.05;  // 5% 容差
    return Math.Abs(actualGiB - expectedGiB) <= tolerance;
}

为什么是 5%? 以 512GB 为例:标称 512GB(厂商十进制)→ WMI 报告约 476 GiB(Windows 二进制)。如果 JSON 配置填 476,则容差覆盖 ±23.8 GiB,足够应对批次差异。实际上 MES 通常存储的也是 GiB 值(与 Windows 显示一致),两者相差不超过几 GiB,5% 绰绰有余。

4.3 固件版本对比:不区分大小写

csharp 复制代码
public bool CheckFirmware(string actualFW, string expectedFW)
{
    if (string.IsNullOrEmpty(expectedFW)) return false;
    // 忽略大小写,避免因厂商固件号大小写不一致导致误判
    return string.Equals(actualFW, expectedFW, StringComparison.OrdinalIgnoreCase);
}

固件版本号如 KPCS2135kpcs2135 本质上是同一版本,不区分大小写可以防止因此产生的误报。

4.4 读取 NVMe SMART 数据(通电时长 + 次数)

NVMe SMART 的完整数据结构(NVME_HEALTH_INFO_LOG)由 NVMe 规范定义,通过 P/Invoke 调用自定义封装 DLL:

csharp 复制代码
[StructLayout(LayoutKind.Sequential)]
public struct NVME_HEALTH_INFO_LOG
{
    public byte CriticalWarning;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)]
    public byte[] Temperature;

    public byte AvailableSpare;
    public byte AvailableSpareThreshold;
    public byte PercentageUsed;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 26)]
    public byte[] Reserved0;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
    public byte[] DataUnitRead;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
    public byte[] DataUnitWritten;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
    public byte[] HostReadCommands;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
    public byte[] HostWrittenCommands;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
    public byte[] ControllerBusyTime;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
    public byte[] PowerCycle;      // 通电次数(128位小端整数,取低32位即可)

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
    public byte[] PowerOnHours;    // 通电时长(128位小端整数,取低32位即可)

    // ... 其余字段省略
}

// P/Invoke 声明:调用 NVMeSMARTInformation.dll
[DllImport("NVMeSMARTInformation.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern bool GetNVMeSMARTInfo(int diskIndex, ref NVME_HEALTH_INFO_LOG outInfo);

解析通电数据:

csharp 复制代码
public (int powerOnHours, int powerCycle) ReadSmartData(int diskIndex = 0)
{
    var smartInfo = new NVME_HEALTH_INFO_LOG();
    bool ok = GetNVMeSMARTInfo(diskIndex, ref smartInfo);
    if (!ok) return (-1, -1);

    // NVMe 规范中 PowerOnHours 和 PowerCycle 是 128 位小端整数
    // 实际小时数不会超过 int 范围,取低 32 位(前4字节)即可
    int hours = smartInfo.PowerOnHours?.Length >= 4
        ? BitConverter.ToInt32(smartInfo.PowerOnHours, 0) : 0;
    int cycles = smartInfo.PowerCycle?.Length >= 4
        ? BitConverter.ToInt32(smartInfo.PowerCycle, 0) : 0;

    return (hours, cycles);
}

为什么用 128 位整数? NVMe 1.2 规范将这些计数器定义为 128 位,是为了支持极长寿命的企业级 SSD(理论上可记录到几十亿小时)。消费级 SSD 的实际值远不会超过 int 范围,直接取低 32 位就够用。


五、完整可运行 Demo

Demo 项目结构

复制代码
StorageTestDemo/
├── App.xaml
├── MainWindow.xaml          ← 测试结果 UI
├── MainWindow.xaml.cs
├── MainVM.cs                ← 编排三步测试
├── DiskInfoReader.cs        ← WMI 磁盘信息封装
├── StorageConfig.cs         ← JSON 配置读写
└── storage_config.json      ← 用户可编辑(自动生成)

NVMe SMART 部分:若运行目录下不存在 NVMeSMARTInformation.dll,程序会自动跳过 SMART 检测并在日志中注明,不会崩溃。

NuGet 依赖Newtonsoft.JsonInstall-Package Newtonsoft.Json
程序集引用System.Management(项目右键 → 添加引用 → 程序集 → System.Management)


storage_config.json(首次运行自动生成)

json 复制代码
{
  "ExpectedSizeGiB": 476.0,
  "SizeTolerance": 0.05,
  "ExpectedFirmwareVersion": "KPCS2135",
  "MaxPowerOnHours": 200,
  "MaxPowerCycleCount": 2000
}
字段 说明
ExpectedSizeGiB 期望容量(GiB,与 Windows 显示一致)。512GB SSD 填 476,1TB SSD 填 931
SizeTolerance 容差比例,默认 0.05(即 5%)
ExpectedFirmwareVersion 固件版本号,填 "" 可跳过固件检测
MaxPowerOnHours 通电时长上限(小时),超出则失败
MaxPowerCycleCount 通电次数上限,超出则失败

StorageConfig.cs

csharp 复制代码
using System;
using System.IO;
using System.Text;
using Newtonsoft.Json;

namespace StorageTestDemo
{
    public class StorageConfig
    {
        public double ExpectedSizeGiB { get; set; } = 476.0;
        public double SizeTolerance { get; set; } = 0.05;
        public string ExpectedFirmwareVersion { get; set; } = "KPCS2135";
        public int MaxPowerOnHours { get; set; } = 250;
        public int MaxPowerCycleCount { get; set; } = 2500;

        private static readonly string ConfigPath =
            Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "storage_config.json");

        public static StorageConfig Load()
        {
            if (!File.Exists(ConfigPath))
            {
                var def = new StorageConfig();
                def.Save();
                return def;
            }
            string json = File.ReadAllText(ConfigPath, Encoding.UTF8);
            return JsonConvert.DeserializeObject<StorageConfig>(json) ?? new StorageConfig();
        }

        public void Save()
        {
            File.WriteAllText(ConfigPath,
                JsonConvert.SerializeObject(this, Formatting.Indented), Encoding.UTF8);
        }
    }
}

DiskInfoReader.cs

csharp 复制代码
using System;
using System.Collections.Generic;
using System.Management;

namespace StorageTestDemo
{
    public class DiskInfoReader
    {
        public class DiskInfo
        {
            public string Model { get; set; }
            public string FirmwareRevision { get; set; }
            public double SizeGiB { get; set; }
        }

        public List<DiskInfo> GetAllDisks()
        {
            var result = new List<DiskInfo>();
            try
            {
                using var searcher = new ManagementObjectSearcher(
                    "SELECT Model, FirmwareRevision, Size FROM Win32_DiskDrive");
                foreach (ManagementObject obj in searcher.Get())
                {
                    result.Add(new DiskInfo
                    {
                        Model = obj["Model"]?.ToString() ?? string.Empty,
                        FirmwareRevision = obj["FirmwareRevision"]?.ToString()?.Trim() ?? string.Empty,
                        SizeGiB = obj["Size"] != null
                            ? Math.Round(Convert.ToUInt64(obj["Size"]) / 1024.0 / 1024 / 1024, 2)
                            : 0
                    });
                }
            }
            catch { /* WMI 查询失败则返回空列表 */ }
            return result;
        }

        public double GetTotalSizeGiB(List<DiskInfo> disks)
        {
            double total = 0;
            foreach (var d in disks) total += d.SizeGiB;
            return Math.Round(total, 2);
        }
    }
}

MainVM.cs

csharp 复制代码
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Media;

namespace StorageTestDemo
{
    public class MainVM : INotifyPropertyChanged
    {
        private string _statusText = "点击"开始测试"";
        private Brush _statusColor = Brushes.Gray;
        private string _logText = string.Empty;

        public string StatusText { get => _statusText; set { _statusText = value; OnChanged(); } }
        public Brush StatusColor { get => _statusColor; set { _statusColor = value; OnChanged(); } }
        public string LogText { get => _logText; set { _logText = value; OnChanged(); } }

        private readonly DiskInfoReader _diskReader = new DiskInfoReader();
        private readonly StringBuilder _log = new StringBuilder();

        // NVMe SMART P/Invoke(若 DLL 不存在则 DllNotFoundException,跳过)
        [StructLayout(LayoutKind.Sequential)]
        struct NVME_HEALTH_INFO_LOG
        {
            public byte CriticalWarning;
            [MarshalAs(UnmanagedType.ByValArray, SizeConst = 57)] public byte[] Padding;
            [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] PowerCycle;
            [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] public byte[] PowerOnHours;
            [MarshalAs(UnmanagedType.ByValArray, SizeConst = 382)] public byte[] Rest;
        }

        [DllImport("NVMeSMARTInformation.dll", CallingConvention = CallingConvention.Cdecl)]
        static extern bool GetNVMeSMARTInfo(int index, ref NVME_HEALTH_INFO_LOG outInfo);

        public async Task StartTestAsync()
        {
            _log.Clear();
            LogText = string.Empty;

            StorageConfig config = StorageConfig.Load();
            Log($"期望容量:{config.ExpectedSizeGiB} GiB(容差 {config.SizeTolerance * 100:F0}%)");
            Log($"期望固件:{(string.IsNullOrEmpty(config.ExpectedFirmwareVersion) ? "(跳过)" : config.ExpectedFirmwareVersion)}");
            Log($"SMART 阈值:通电 < {config.MaxPowerOnHours}h,开关次数 < {config.MaxPowerCycleCount} 次");
            Log("---");

            // Step 1:磁盘信息检测
            SetStatus("正在读取磁盘信息...", Brushes.Yellow);
            var disks = _diskReader.GetAllDisks();
            if (disks.Count == 0) { SetStatus("未检测到任何磁盘设备", Brushes.Red); return; }

            foreach (var d in disks)
                Log($"发现磁盘:{d.Model} | 固件:{d.FirmwareRevision} | 容量:{d.SizeGiB} GiB");

            double totalSize = _diskReader.GetTotalSizeGiB(disks);
            Log($"合计容量:{totalSize} GiB");

            // 容量对比
            double tolerance = config.ExpectedSizeGiB * config.SizeTolerance;
            if (Math.Abs(totalSize - config.ExpectedSizeGiB) > tolerance)
            {
                SetStatus($"容量不符!期望 {config.ExpectedSizeGiB} GiB,实际 {totalSize} GiB", Brushes.Red);
                return;
            }
            Log("✔ 容量检测通过");

            // 固件版本对比(配置为空则跳过)
            if (!string.IsNullOrEmpty(config.ExpectedFirmwareVersion))
            {
                bool fwMatch = disks.Any(d =>
                    string.Equals(d.FirmwareRevision, config.ExpectedFirmwareVersion,
                        StringComparison.OrdinalIgnoreCase));
                if (!fwMatch)
                {
                    SetStatus($"固件版本不符!期望 {config.ExpectedFirmwareVersion}", Brushes.Red);
                    return;
                }
                Log($"✔ 固件版本检测通过({config.ExpectedFirmwareVersion})");
            }

            // Step 2:NVMe SMART 检测
            SetStatus("正在读取 NVMe SMART 数据...", Brushes.Yellow);
            try
            {
                var smartInfo = new NVME_HEALTH_INFO_LOG();
                bool ok = GetNVMeSMARTInfo(0, ref smartInfo);
                if (!ok)
                {
                    Log("⚠ SMART 读取返回失败,跳过此项");
                }
                else
                {
                    int hours = smartInfo.PowerOnHours?.Length >= 4
                        ? BitConverter.ToInt32(smartInfo.PowerOnHours, 0) : 0;
                    int cycles = smartInfo.PowerCycle?.Length >= 4
                        ? BitConverter.ToInt32(smartInfo.PowerCycle, 0) : 0;

                    Log($"通电时长:{hours} 小时(上限 {config.MaxPowerOnHours})");
                    Log($"通电次数:{cycles} 次(上限 {config.MaxPowerCycleCount})");

                    if (hours >= config.MaxPowerOnHours)
                    {
                        SetStatus($"SMART 失败:通电时长 {hours}h 超出阈值 {config.MaxPowerOnHours}h", Brushes.Red);
                        return;
                    }
                    if (cycles >= config.MaxPowerCycleCount)
                    {
                        SetStatus($"SMART 失败:通电次数 {cycles} 超出阈值 {config.MaxPowerCycleCount}", Brushes.Red);
                        return;
                    }
                    Log("✔ SMART 检测通过");
                }
            }
            catch (DllNotFoundException)
            {
                // 没有 NVMeSMARTInformation.dll 时优雅跳过,不影响其他测试项
                Log("⚠ 未找到 NVMeSMARTInformation.dll,SMART 检测已跳过");
            }

            SetStatus($"全部通过!磁盘 {totalSize} GiB", Brushes.YellowGreen);
        }

        private void SetStatus(string text, Brush color)
        {
            StatusText = text;
            StatusColor = color;
            Log(text);
        }

        private void Log(string msg)
        {
            _log.AppendLine(msg);
            LogText = _log.ToString();
        }

        public event PropertyChangedEventHandler PropertyChanged;
        private void OnChanged([CallerMemberName] string name = "") =>
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

MainWindow.xaml

xml 复制代码
<Window x:Class="StorageTestDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="存储规格检测 Demo" Height="440" Width="560"
        Background="#1E1E2E">
    <Grid Margin="20">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <TextBlock Grid.Row="0" Text="{Binding StatusText}"
                   Foreground="{Binding StatusColor}"
                   FontSize="18" FontWeight="Bold"
                   TextWrapping="Wrap" Margin="0,0,0,10"/>

        <TextBlock Grid.Row="1"
                   Text="📄 规格配置:storage_config.json(与 exe 同目录,可手动编辑)"
                   Foreground="#888" FontSize="11" Margin="0,0,0,8"/>

        <ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
            <TextBox Text="{Binding LogText, Mode=OneWay}"
                     Background="#12121C" Foreground="#CDD6F4"
                     FontFamily="Consolas" FontSize="12"
                     IsReadOnly="True" TextWrapping="Wrap"
                     BorderThickness="0"/>
        </ScrollViewer>

        <Button Grid.Row="3" Content="开始测试" Margin="0,12,0,0"
                Height="36" FontSize="14"
                Background="#89B4FA" Foreground="#1E1E2E"
                BorderThickness="0"
                Click="StartButton_Click"/>
    </Grid>
</Window>

MainWindow.xaml.cs

csharp 复制代码
using System.Windows;

namespace StorageTestDemo
{
    public partial class MainWindow : Window
    {
        private readonly MainVM _vm = new MainVM();

        public MainWindow()
        {
            InitializeComponent();
            DataContext = _vm;
        }

        private async void StartButton_Click(object sender, RoutedEventArgs e)
        {
            await _vm.StartTestAsync();
        }
    }
}

典型输出(通过)

复制代码
期望容量:476 GiB(容差 5%)
期望固件:KPCS2135
SMART 阈值:通电 < 200h,开关次数 < 2000 次
---
发现磁盘:KIOXIA-EXCERIA G2 | 固件:KPCS2135 | 容量:476.93 GiB
合计容量:476.93 GiB
✔ 容量检测通过
✔ 固件版本检测通过(KPCS2135)
通电时长:3 小时(上限 200)
通电次数:12 次(上限 2000)
✔ SMART 检测通过
全部通过!磁盘 476.93 GiB

六、总结与扩展

本文核心要点

  • WMI Win32_DiskDrive 是读取磁盘基础信息的最简途径,无需额外依赖,FirmwareRevision 对应固件版本,Size 单位为字节需手动换算 GiB
  • 5% 容差是解决厂商 GB(十进制)与 Windows GiB(二进制)单位差异的工程实践,而非随意设定
  • NVMe SMARTPowerOnHoursPowerCycle 字段是产线排查翻新盘的重要手段,字段为 128 位整数,取低 32 位即可满足实际需求
  • DLL 降级 :将 DllNotFoundException 单独 catch,让没有特定 DLL 的环境也能跑通其他测试项,是产测工具健壮性的常见设计

可以扩展的方向

  • 速度测试集成:通过 Win32 消息自动控制 CrystalDiskMark 完成基准测试并抓取结果,适合对出厂读写性能有硬性要求的产品
  • SATA SSD 支持 :SATA 硬盘的 SMART 数据通过 DeviceIoControl + IOCTL_ATA_PASS_THROUGH 获取,结构与 NVMe 不同

如有问题欢迎评论区交流。觉得有用的话点个赞和关注下,系列会持续更新 😃

相关推荐
Bofu-6 小时前
【键盘测试】05-WPF 可视化键盘布局配置 + 全局钩子按键检测实战
wpf·键盘测试·全局键盘钩子·scancode·组合键检测
bugcome_com6 小时前
WPF 路径动画完全指南:自绘制控件实战
wpf
不会编程的懒洋洋2 天前
WPF 性能优化+异步+渲染
开发语言·笔记·性能优化·c#·wpf·图形渲染·线程
求学中--3 天前
状态管理一文通:@State、@Prop、@Link、@Provide/Consume全解析
人工智能·小程序·uni-app·wpf·harmonyos
雨浓YN4 天前
GKTGD 工业监控系统-00设计文档
wpf
秋の本名5 天前
第一章 鸿蒙生态架构与开发理念
华为·wpf·harmonyos
Bofu-5 天前
【音频测试】03-WPF 实现声道自动验证 + Whisper 语音识别录音检测
c#·whisper·wpf·音视频·音频测试·naudio 声道控制