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


はじめに

 私は「Windowsの標準NVMeデバイスドライバを使ってNVMeドライブにアクセスする」というWindowsコンソールプログラム(ソースコード)をGithubに公開[1]しています。

 このコードを見た方から、「このコードをPowerShellスクリプトに埋め込んでNVMeドライブのS.M.A.R.T.ログデータを取得できない?(意訳)」という質問(リクエスト?)メールが届きました。

 メールを頂いた直後は、「わざわざPowerShellスクリプトに組み込まなくても、今公開しているコンソールプログラムのソースコードを流用してS.M.A.R.T.ログデータを取得するだけの(コンソール)プログラムを作り、そのプログラムをPowerShellスクリプトから呼び出せば目的は達成できるのでは?」と思いました。

 しかし、それではあまりにも芸がなく、また私はPowerShellでのプログラミング未体験でしたので、敢えて「PowerShellスクリプトだけでNVMeドライブからS.M.A.R.T.ログデータを取得する」にチャレンジして、成功しました。

 この記事はその顛末をまとめたものです。

 なお、作成したPowerShellスクリプトはGithubに置いておきました[2]。コードの全体像はそちらをご覧ください。

まとめ

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

今回のポイント

 Windowsコンソールプログラム開発の経験から、CreateFile()DeviceIoControl()という2つのAPIを呼び出すことができれば、PowerShellスクリプトからでもNVMeドライブにアクセスできるはずだと考えました。

 図1はDeviceIoControl()についてこのイメージを図示したものです。


図1:今回のポイントイメージ図(DeviceIoControl()の例)

 このことから、今回のポイントは以下の3つとなります。

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

 この記事ではこれら3つのポイントを説明します。

CreateFile()などを呼び出せるようにする

 Redditの投稿[3]を参考にして以下のようにしました。

$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);

 PowerShell(とC#)は初めてなので多少戸惑いましたが、このようにして別のライブラリのAPIを呼び出せるようにするのですね。

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

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

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

CreateFile()の引数

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

 CreateFile()の2つ目の引数には、行いたいアクセスの内容(ReadやWrite)を指定します。

 C言語の時はここに0xC0000000を指定しました。この値はGENERIC_READ (0x80000000)GENERIC_WRITE (0x40000000)の論理和です。

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

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

 どうやら符号ビットが1の値は直接渡せず、文字列を経由すれば良いようです[4]

 こんな感じで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.ログデータのサイズは512バイトと定義されているため、[MarshalAs(UnmanagedType.ByValArray, SizeConst = 512)]と指定し、必要なサイズのメモリ領域を確保します[5]

 また、[StructLayout(LayoutKind.Sequential, Pack = 1)]という構造体メンバのメモリ配置方法指定も必要です[6]。これがないと、構造体のサイズが期待した値になりませんでした。

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

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

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

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

$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) );

 メモリ領域の先頭へのポインタとオフセットだけでアクセスできるので、とても楽です。

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

 やはり、扱うデータに合わせて適切な方法を選択するのは重要です。

まとめ

 この記事では、PowerShellスクリプトだけでNVMeドライブからS.M.A.R.T.ログデータを取得するスクリプトを作成した際に躓いた点などをまとめました。

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

References

[1] Kenichiro Yoshii, "nvmetool-win: Sample program of accessing NVMe device using Windows' inbox NVMe driver"
[2] Kenichiro Yoshii, "nvmetool-win-powershell: Sample script of accessing NVMe drive using Windows' inbox NVMe driver"
[3] "Using DeviceIoControl and FSCTL_SRV_ENUMERATE_SNAPSHOTS in PowerShell"、2021年5月9日閲覧
[4] MURA、「PowerShell で UInt32 に最大値をセットする」、2021年5月9日閲覧
[5] Microsoft、"Customize structure marshaling"、2021年5月9日閲覧
[6] Microsoft、"StructLayoutAttribute.Pack Field"、2021年5月9日閲覧
[7] Microsoft、"Marshal.StructureToPtr Method"、2021年5月10日閲覧

ライセンス表記


この記事はクリエイティブ・コモンズ 表示 - 継承 4.0 国際 ライセンスの下に提供されています。