PowerShellスクリプトだけでNVMe SSDのS.M.A.R.T.ログを取得する

PowerShellスクリプトだけでNVMe SSDのS.M.A.R.T.ログを取得する

この記事で紹介している製品

おことわり

 この記事は、2021年5月にQiitaに投稿した記事を加筆修正したものです。

はじめに

 Windows上でNVMe SSDのS.M.A.R.T.ログを取得する方法としては、CrystalDiskInfoや当社のLiveMonitorPlusのように、独立したアプリケーションプログラムを使用する方法が一般的です。

 これらアプリケーションの利点は、データをGUI付きのわかりやすい画面で確認できる点です。

 一方でこの方法は、プログラムを常駐させたりせずに、かつ人の操作なしに1時間に1回などの間隔で定期的にデータを取得して異常値があれば通知する、などの用途には向きません。

 また、サーバ管理などでは「単機能のスクリプトを組み合わせて所望の処理を実現する」という方法が用いられることも多く、より簡便な方法でNVMe SSDのS.M.A.R.T.ログを取得する方法があると便利です。Unix系OSではnvme-cliパッケージsmartmontoolsパッケージ(smartctlコマンド)があります。

 そこでこの記事では、Windowsであれば標準で利用可能なスクリプト環境PowerShellとWindowsのMicrosoft標準NVMeデバイスドライバを用いてNVMe SSDのS.M.A.R.T.ログを取得する方法をご紹介します。この方法では専用プログラムのインストールや常駐が不要で、かつ必要な時に必要な情報のみ加工が容易なフォーマットで取得可能です。

 なお、記事内ではスクリプトそのものにつきましてはポイントのみ記載します。スクリプト全体はGitHubのリポジトリでご覧ください。

まとめ

  • PowerShellスクリプトだけで、Windowsの標準NVMeデバイスドライバを使いNVMeドライブにアクセスしてS.M.A.R.T.ログを取得できる
  • この方法はS.M.A.R.T.ログデータ以外の取得にも応用(カスタマイズ)可能
  • カスタマイズ時は、DeviceIoControl()のパラメータ設定と取得したデータの取扱いに注意

使いかた

 早速スクリプトの使用方法をご紹介します。スクリプト名はget-smart-log.ps1です。

 スクリプトを実行する際にS.M.A.R.T.ログを取得する対象NVMeドライブのドライブ番号が必要となります。この「ドライブ番号」は「ディスクの管理」ダイアログで確認可能です。

図1:「ディスクの管理」ダイアログを使用した「ドライブ番号」の確認方法

 今回はこの「ディスク0」のS.M.A.R.T.ログを取得します。この場合「ドライブ番号」は0となります。

 次に管理者権限付きのコンソール(コマンドプロンプトやPowerShellコンソール)を起動し、スクリプトが置かれているフォルダで以下のように入力します。フォルダ名はご使用の環境に合わせて読み替えてください。

PowerShellの場合:
PS C:\Users\hagisol\Downloads> .\get-smart-log.ps1 0

通常のコマンドプロンプトの場合:
C:\Users\hagisol\Downloads> powershell .\get-smart-log.ps1 0

 すると、次のように出力されます(PowerShellを使用した場合)。これがNVMeドライブから取得したS.M.A.R.T.ログです。これは当社製HシリーズNVMe SSDで試した結果です。

PS C:\Users\hagisol\downloads> .\get-smart-log.ps1 0
Critical Warning: 0x00
Composite Temperature: 311 (K)
Available Spare: 100 (%)
Available Spare Threshold: 10 (%)
Percentage Used: 0 (%)
Endurance Group Summary: 0x00
Data Unit Read: 0x000000000028F6A7
Data Unit Written: 0x000000000040665F
Host Read Commands: 0x0000000002EE9E5E
Host Write Commands: 0x00000000047DDD20
Controller Busy Time: 0x0000000000002946 (minutes)
Power Cycles: 0x0000000000000053
Power On Hours: 0x000000000000014F (hours)
Unsafe Shutdowns: 0x0000000000000011
Media and Data Integrity Errors: 0x0000000000000000
Number of Error Information Entries: 0x0000000000000000
Warning Composite Temperature Time: 0 (minutes)
Critical Composite Temperature Time: 0 (minutes)
Temperature Sensor 1: 331 (K)
Temperature Sensor 2: 328 (K)
Temperature Sensor 3: 311 (K)
Temperature Sensor 4: 0 (K)
Temperature Sensor 5: 0 (K)
Temperature Sensor 6: 0 (K)
Temperature Sensor 7: 0 (K)
Temperature Sensor 8: 0 (K)
Thermal Management Temperature 1 Transition Count: 0 (times)
Thermal Management Temperature 2 Transition Count: 0 (times)
Total Time For Thermal Management Temperature 1: 0 (seconds)
Total Time For Thermal Management Temperature 2: 0 (seconds)

 スクリーンショットは以下のようになります(Windows 11での実行結果)。

図2:S.M.A.R.T.ログ取得PowerShellスクリプトの出力スクリーンショット

 図2のように取得結果はプレーンテキストで出力されますので、ファイルへの記録(リダイレクト)や文字列検索による特定項目の抜き出しなどが容易です。

 さらに言えば、出力形式(文字列)変更などのカスタマイズも容易です。

スクリプトの説明

 ここからはこのスクリプトのポイントをご説明します。カスタマイズされる際やこのスクリプトを土台にご所望の処理を行うスクリプトを作成される際のご参考になれば幸いです。

ポイント

 このスクリプトのポイントは次の3点です。

  • CreateFile()DeviceIoControl()をPowerShellから呼び出せるようにする
  • 両APIに適切なパラメータを渡す
  • APIから返されたデータからS.M.A.R.T.ログデータを引き出す

 以下これら3つのポイントをご説明します。

必要なAPIを呼び出せるようにする

 これはRedditの投稿[1]を参考に、以下のようにすることで実現しました。

$KernelService = Add-Type -Name 'Kernel32' -Namespace 'Win32' -PassThru -MemberDefinition @"
    [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    public static extern IntPtr CreateFile(
        String lpFileName,
        UInt32 dwDesiredAccess,
        UInt32 dwShareMode,
        IntPtr lpSecurityAttributes,
        UInt32 dwCreationDisposition,
        UInt32 dwFlagsAndAttributes,
        IntPtr hTemplateFile);

    [DllImport("Kernel32.dll", SetLastError = true)]
    public static extern bool DeviceIoControl(
        IntPtr  hDevice,
        int     oControlCode,
        IntPtr  InBuffer,
        int     nInBufferSize,
        IntPtr  OutBuffer,
        int     nOutBufferSize,
        ref int pBytesReturned,
        IntPtr  Overlapped);
"@

$DeviceHandle = $KernelService::CreateFile("\\.\PhysicalDrive$PhyDrvNo", [System.Convert]::ToUInt32($AccessMask), $AccessMode, [System.IntPtr]::Zero, $AccessEx, $AccessAttr, [System.IntPtr]::Zero);
$CallResult = $KernelService::DeviceIoControl($DeviceHandle, $IoControlCode, $OutBuffer, $OutBufferSize, $OutBuffer, $OutBufferSize, [ref]$ByteRet, [System.IntPtr]::Zero);

 このように記述することで、別ライブラリ内のAPI(CreateFile()DeviceIoControl())を呼び出せるようになります。

両APIに適切なパラメータを渡す

 CreateFile()DeviceIoControl()を呼び出す際には適切なパラメータを渡す必要があります。ここでは3点注意が必要です。

 それは、「CreateFile()の引数をどう作るか」、「DeviceIoControl()の引数(構造体)をどう作るか」、そして「構造体へのポインタをどう取得するか」です。

CreateFile()の引数

 まずCreateFile()に渡す引数です。

 CreateFile()の2つ目の引数には、行いたいアクセスの内容(ReadやWrite)を指定します。ここにはGENERIC_READ (0x80000000)GENERIC_WRITE (0x40000000)の論理和である0xC0000000の指定が必要です。

 しかし、単に0xC0000000を指定すると以下のメッセージが表示されてエラーになります。変数に代入してその変数を引数に入れても同じ結果になります。

“CreateFile” の引数 “dwDesiredAccess” (値 “-1073741824”) を型 “System.UInt32” に変換できません: “値 “-1073741824” を型 “System.UInt32” に変換できません。エラー: “UInt32 型の値が大きすぎるか、または小さすぎます。””

 メッセージにある通り原因は数値が符号付きと解釈されていることであり(最上位ビット(符号ビット)が1なので)、文字列を経由すれば良いようです[2]

 以下のように0xC0000000相当の値の文字列をSystem.Convert::ToUInt32()で変換したところエラーは出なくなりました。

$AccessMask = "3221225472"; # = 0xC00000000 = GENERIC_READ (0x80000000) | GENERIC_WRITE (0x40000000)

$DeviceHandle = $KernelService::CreateFile("\\.\PhysicalDrive$PhyDrvNo", [System.Convert]::ToUInt32($AccessMask), $AccessMode, [System.IntPtr]::Zero, $AccessEx, $AccessAttr, [System.IntPtr]::Zero);

DeviceIoControl()の引数

 2つ目はDeviceIoControl()の引数です。

 DeviceIoControl()を使う際、「デバイスに対して何をリクエストしたいのか」をまとめた構造体へのポインタを渡すのですが、メモリの確保やパラメータの設定に悩みました。

 結局、以下のように構造体を宣言し、New-Objectでインスタンスを作成しました。

Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct NVMeStorageQueryProperty {
    public UInt32 PropertyId;
    public UInt32 QueryType;
    public UInt32 ProtocolType;
    public UInt32 DataType;
    public UInt32 ProtocolDataRequestValue;
    public UInt32 ProtocolDataRequestSubValue;
    public UInt32 ProtocolDataOffset;
    public UInt32 ProtocolDataLength;
    public UInt32 FixedProtocolReturnData;
    public UInt32 ProtocolDataRequestSubValue2;
    public UInt32 ProtocolDataRequestSubValue3;
    public UInt32 Reserved0;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 512)]
    public Byte[] SMARTData;
}
"@

 この構造体のメンバはC言語向けのヘッダファイルの中身を見て決めたものです。

 最後のSMARTDataというバイト列は、S.M.A.R.T.ログデータ本体が格納されるメモリ領域です。

 当初はNVMe仕様で定義されたS.M.A.R.T.ログデータの中身を書き下すことも考えたのですが、長くて可読性が悪くなるのと、S.M.A.R.T.ログデータ以外の取得に流用しやすくするため、規定サイズのメモリ領域を定義するのみとしました。

 S.M.A.R.T.ログデータのサイズはNVMe仕様において512バイトと定義されているため、

[MarshalAs(UnmanagedType.ByValArray, SizeConst = 512)]

と指定し、必要なサイズのメモリ領域を確保します[3]

 また、

[StructLayout(LayoutKind.Sequential, Pack = 1)]

という構造体メンバのメモリ配置方法指定も必要です[4]。これがないと、構造体のサイズが期待した値になりませんでした。

 これでDeviceIoControl()用の構造体を宣言したら、あとはこの構造体のインスタンスを作成して、S.M.A.R.T.ログデータの取得に必要なパラメータとしてC言語で実装した際と同じ値を設定します。

構造体のポインタをどう取得するか

 構造体を宣言し、New-Objectでインスタンスを作成してメンバ(パラメータ)を設定したら、あとはこれをDeviceIoControl()の引数として渡せばよいのですが、この際「構造体へのポインタ」を渡す必要があります。この「構造体のポインタ」を取得する方法がわかりませんでした。

 調べた結果、以下のようにMarshalクラスのメソッドMarshal::StructureToPtr()を使えば良いようです[5]

$OutBuffer     = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($OutBufferSize);

[System.Runtime.InteropServices.Marshal]::StructureToPtr($Property, $OutBuffer, [System.Boolean]::false);
$CallResult = $KernelService::DeviceIoControl($DeviceHandle, $IoControlCode, $OutBuffer, $OutBufferSize, $OutBuffer, $OutBufferSize, [ref]$ByteRet, [System.IntPtr]::Zero);

 このやりかたは「作成した構造体のポインタを取得する」のではなく「予め確保しておいたメモリ領域へ構造体の内容をコピー」していることに注意が必要です。

S.M.A.R.T.ログデータを取得する

 DeviceIoControl()の呼び出しが成功すると、先ほどDeviceIoControl()に渡したポインタが指すメモリ領域にS.M.A.R.T.ログデータが格納されます。

 とはいえDeviceIoControl()に渡したポインタが指すメモリ領域はただのバイト列として確保した領域ですので、このままでは構造体やクラスのようにはアクセスできません。

 先ほどとは逆に、ポインタが指す(ただのバイト列の)メモリ領域の内容を構造体にコピーする、という方法もあるようですが、面倒ですので、ポインタとオフセットを用いて直接アクセスしました。

 なお、下記のコードでWrite-Outputしているのは画面出力のためであり、ここでのポイントはMarshal::ReadInt16()などのメソッドです。

Write-Output( "Composite Temperature: {0} (K)" -F [System.Runtime.InteropServices.Marshal]::ReadInt16($OutBuffer, 49) );
Write-Output( "Available Spare: {0} (%)" -F [System.Runtime.InteropServices.Marshal]::ReadByte($OutBuffer, 51) );
Write-Output( "Available Spare Threshold: {0} (%)" -F [System.Runtime.InteropServices.Marshal]::ReadByte($OutBuffer, 52) );

 Marshal::ReadInt16()は指定位置から16ビット(2バイト)のデータを符号付き整数として読み取るメソッドで、Marshal::ReadByte()は指定位置から1バイト読み取るメソッドです。

 これらのメソッドはメモリ領域の先頭へのポインタとオフセットだけでアクセスでき、とても使い勝手が良いです。

 この方法は、S.M.A.R.T.ログデータのデータ構造(各データのサイズと配置)がNVMe仕様で規定されているからこそ採用できる方法です。 

さいごに

 この記事では、PowerShellスクリプトだけでNVMeドライブからS.M.A.R.T.ログデータを取得するスクリプトについて、使いかたをご説明し、スクリプトのポイントをまとめました。

 このスクリプトを応用すると、Windowsで運用しているNVMeドライブに対して、S.M.A.R.T.ログデータ以外の情報(例:Identifyデータ)の取得や特定の値のみの(自動)監視、さらにはデータの蓄積、などが可能となります。

References

[1] “Using DeviceIoControl and FSCTL_SRV_ENUMERATE_SNAPSHOTS in PowerShell“、2023年2月7日閲覧
[2] MURA、「PowerShell で UInt32 に最大値をセットする」、2023年2月7日閲覧
[3] Microsoft、Customize structure marshaling、2023年2月7日閲覧
[4] Microsoft、StructLayoutAttribute.Pack Field、2023年2月7日閲覧
[5] Microsoft、Marshal.StructureToPtr Method、2023年2月7日閲覧

他社商標について
記事中には登録商標マークを明記しておりませんが、記事に掲載されている会社名および製品名等は一般に各社の商標または登録商標です。

記事内容について
この記事の内容は、発表当時の情報です。予告なく変更されることがありますので、あらかじめご了承ください。

お問い合わせ