はじめに
Windows、Linux、macOSの各プラットフォームで動作する.NETのS.M.A.R.T情報取得ライブラリを作ったので、そのコア技術の解説記事です。
Windows版については以前の記事でも書いていますが、同様のものをLinuxとmacOS用にも作成しました。
そこで、各プラットフォーム毎のS.M.A.R.T情報の取得方法について解説します。
作ったもの
各プラットフォーム用のライブラリは以下、Windows版、Linux版、Mac版になります。
プラットフォーム毎のAPI概要
S.M.A.R.T情報の取得はOSが提供する低レベルAPIを使用する必要があり、プラットフォーム毎にアプローチが異なります。
🔗使用するAPIの一覧
| プラットフォーム | SATA | NVMe | USB |
|---|---|---|---|
| Windows | DeviceIoControl (DFP_RECEIVE_DRIVE_DATA) | DeviceIoControl (IOCTL_STORAGE_QUERY_PROPERTY) | DeviceIoControl (IOCTL_SCSI_PASS_THROUGH) |
| Linux | ioctl (SG_IO) | ioctl (NVME_IOCTL_ADMIN_CMD) | - |
| macOS | IOKit Plugin (kIOATASMARTInterface) | IOKit Plugin (kIONVMeSMARTInterface) | - |
WindowsではいずれもDeviceIoControl()を使用しますが、IOCTL制御コードとデータ構造がインターフェース毎に異なります。
LinuxではSCSI Generic(sg)ドライバとNVMe admin commandのioctlを使用します。
macOSではIOKitフレームワークのプラグインインターフェースを使用し、COM-likeなvtableを操作します。
💽デバイスの検出方法
各プラットフォームでのディスク列挙とバスタイプ判定の方法は以下になります。
| プラットフォーム | ディスク列挙 | バスタイプ判定 |
|---|---|---|
| Windows | WMI (Win32_DiskDrive) | STORAGE_DEVICE_DESCRIPTORのBusTypeフィールド |
| Linux | /sys/block配下のスキャン | デバイスのメジャー番号 (259=NVMe, 8=SCSI等) |
| macOS | IOKit (IOBlockStorageDevice) | IORegistryの"Physical Interconnect"プロパティ |
NVMe SMART取得のコア実装
NVMeのSMART情報は、NVMe仕様のLog Page 02h (SMART / Health Information)から取得します。
取得されるデータは512バイトの固定構造で、プラットフォームに関わらずデータフォーマットは共通です。
📊NVMe SMART / Health Information Log(主要フィールド)
| オフセット | サイズ | フィールド |
|---|---|---|
| 0 | 1 byte | Critical Warning |
| 1-2 | 2 bytes | Temperature (Kelvin) |
| 3 | 1 byte | Available Spare (%) |
| 4 | 1 byte | Available Spare Threshold (%) |
| 5 | 1 byte | Percentage Used (%) |
| 32-47 | 16 bytes | Data Units Read (128bit LE) |
| 48-63 | 16 bytes | Data Units Written (128bit LE) |
| 112-127 | 16 bytes | Power Cycles (128bit LE) |
| 128-143 | 16 bytes | Power On Hours (128bit LE) |
| 144-159 | 16 bytes | Unsafe Shutdowns (128bit LE) |
| 160-175 | 16 bytes | Media Errors (128bit LE) |
🪟Windows: DeviceIoControl + IOCTL_STORAGE_QUERY_PROPERTY
WindowsではNVMeのSMART情報をSTORAGE_QUERY_BUFFER構造体にNVMeプロトコル固有のパラメータを設定し、IOCTL_STORAGE_QUERY_PROPERTYで取得します。
まず、デバイスハンドルの取得にはWin32 APIのCreateFile()を使用します。
デバイスパスはWMIのSELECT * FROM Win32_DiskDriveで取得した結果のDeviceIDプロパティの値を使用します。
using var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_DiskDrive");
foreach (var obj in searcher.Get())
{
var deviceId = (string)obj["DeviceID"]; // e.g. "\\\\.\\PHYSICALDRIVE0"
using var handle = CreateFile(
deviceId,
FileAccess.ReadWrite,
FileShare.ReadWrite,
IntPtr.Zero,
FileMode.Open,
FileAttributes.Normal,
IntPtr.Zero);
if (handle.IsInvalid)
{
continue;
}
// handle を使って SMART 情報を取得する
}
デバイスハンドルを取得した後、SMART情報の取得を行います。
var bufferSize = Marshal.SizeOf<STORAGE_QUERY_BUFFER>();
var queryBufferOffset = Marshal.OffsetOf<STORAGE_QUERY_BUFFER>(nameof(STORAGE_QUERY_BUFFER.Buffer)).ToInt32();
var buffer = Marshal.AllocHGlobal(bufferSize);
try
{
new Span<byte>(buffer.ToPointer(), bufferSize).Clear();
var query = (STORAGE_QUERY_BUFFER*)buffer;
query->ProtocolSpecific.ProtocolType = StorageProtocolType.ProtocolTypeNvme;
query->ProtocolSpecific.DataType = (uint)StorageProtocolNvmeDataType.NVMeDataTypeLogPage;
query->ProtocolSpecific.ProtocolDataRequestValue = (uint)NvmeLogPage.HealthInfo;
query->ProtocolSpecific.ProtocolDataOffset = (uint)Marshal.SizeOf<STORAGE_PROTOCOL_SPECIFIC_DATA>();
query->ProtocolSpecific.ProtocolDataLength = (uint)(bufferSize - queryBufferOffset);
query->PropertyId = StoragePropertyId.StorageAdapterProtocolSpecificProperty;
query->QueryType = StorageQueryType.PropertyStandardQuery;
if (!DeviceIoControl(handle, IOCTL_STORAGE_QUERY_PROPERTY,
buffer, bufferSize, buffer, bufferSize, out _, IntPtr.Zero))
{
return false;
}
var log = (NVME_HEALTH_INFO_LOG*)IntPtr.Add(buffer, queryBufferOffset);
var temperature = KelvinToCelsius(*(ushort*)log->CompositeTemp);
var availableSpare = log->AvailableSpare;
var powerCycles = *(ulong*)log->PowerCycles;
var powerOnHours = *(ulong*)log->PowerOnHours;
...
}
finally
{
Marshal.FreeHGlobal(buffer);
}
STORAGE_PROTOCOL_SPECIFIC_DATA構造体でProtocolTypeNvmeとNVMeDataTypeLogPageを指定し、NVME_LOG_PAGE_HEALTH_INFO (0x02)をリクエストします。
応答は同じバッファの後方にNVME_HEALTH_INFO_LOG構造体として返されます。
🐧Linux: ioctl + NVME_IOCTL_ADMIN_CMD
LinuxではNVMeデバイスを直接openし、NVMe Admin Commandのioctlで取得します。
デバイスのオープンはlibcのopen()をP/Invokeで呼び出します。
デバイスパスは/sys/block配下のディレクトリを走査して取得します。
NVMeデバイスであればnvme0n1のようなエントリが見つかるので、NVMe Admin Commandはnamespaceデバイスではなくコントローラデバイスに対して発行するため/dev/nvme0をオープンします。
foreach (var entry in Directory.GetDirectories("/sys/block"))
{
var deviceName = Path.GetFileName(entry);
if (!Regex.IsMatch(deviceName, @"^nvme\d+n\d+$"))
{
continue;
}
var devicePath = $"/dev/{deviceName}";
var ctrlPath = Regex.Replace(devicePath, @"n\d+$", ""); // /dev/nvme0n1 -> /dev/nvme0
var fd = open(ctrlPath, O_RDONLY);
if (fd < 0)
{
continue;
}
// fd を使って SMART 情報を取得する
}
デバイスを開いた後、NVMe Admin Commandのioctlで SMART情報を取得します。
nvme_smart_log smartLog = default;
var cmd = new nvme_admin_cmd
{
opcode = NVME_ADMIN_OP_GET_LOG_PAGE,
nsid = NVME_NSID_ALL,
addr = (ulong)(&smartLog),
data_len = (uint)sizeof(nvme_smart_log),
cdw10 = NVME_LOG_PAGE_HEALTH_INFO | ((uint)((sizeof(nvme_smart_log) / 4) - 1) << NVME_CDW10_NUMD_SHIFT)
};
if (ioctl(fd, NVME_IOCTL_ADMIN_CMD, ref cmd) < 0)
{
return false;
}
var temperature = (short)((ushort)(smartLog.temperature[0] | (smartLog.temperature[1] << 8)) - 273);
var powerCycles = Le128ToUInt64(smartLog.power_cycles);
var powerOnHours = Le128ToUInt64(smartLog.power_on_hours);
...
cdw10フィールドには下位にLog Page ID (0x02 = Health Info)、上位にデータサイズ(DWORD数-1)を指定します。
Linuxカーネルが直接NVMeコントローラへAdmin Commandを発行してくれるため、Windows版のようなストレージクエリの仕組みは不要です。
🍎macOS: IOKit Plugin Interface
macOSではIOKitのプラグインインターフェースを使用します。
COMライクなvtableを介してSMART読み取り関数を呼び出す仕組みです。
var matching = IOServiceMatching(IOBlockStorageDeviceClassName);
var iterator = 0u;
if ((IOServiceGetMatchingServices(0, matching, ref iterator) != KERN_SUCCESS) || (iterator == 0))
{
return false;
}
var service = IOIteratorNext(iterator);
if (service == 0)
{
return false;
}
IntPtr ppPlugin;
int score;
var kr = IOCreatePlugInInterfaceForService(service, IONvmeSmartUserClientTypeId, IOCfPlugInInterfaceId, &ppPlugin, &score);
if ((kr != KERN_SUCCESS) || (ppPlugin == IntPtr.Zero))
{
return false;
}
var vtable = *(IntPtr*)ppPlugin;
var qiFn = (delegate* unmanaged<IntPtr, CFUUIDBytes, IntPtr*, int>)(*((IntPtr*)vtable + 1));
var pSmartInterface = IntPtr.Zero;
var hr = qiFn(ppPlugin, IONvmeSmartInterfaceId, &pSmartInterface);
if ((hr != S_OK) || (pSmartInterface == IntPtr.Zero))
{
return false;
}
var smartVtable = *(IntPtr*)pSmartInterface;
var readDataFn = (delegate* unmanaged<IntPtr, byte*, int>)(*(IntPtr*)((byte*)smartVtable + VTableSmartReadDataOffset));
var buffer = stackalloc byte[SmartDataSize];
kr = readDataFn(pSmartInterface, buffer);
if (kr != KERN_SUCCESS)
{
return false;
}
var criticalWarning = buffer[0];
var temperature = (short)((ushort)(buffer[NvmeTemperatureOffset] | (buffer[NvmeTemperatureOffset + 1] << 8)) - 273);
var powerCycles = Le128ToUInt64(buffer + NvmePowerCyclesOffset);
var powerOnHours = Le128ToUInt64(buffer + NvmePowerOnHoursOffset);
...
macOS版でのポイントは以下です。
-
IOCreatePlugInInterfaceForService()でIOKitプラグインを取得 - COMのQueryInterfaceと同様の手順で、kIONVMeSMARTInterfaceIDを指定してSMART専用インターフェースを取得
- vtableのオフセット+40にあるSMARTReadData関数ポインタを直接呼び出す
NVMe用のプラグインはkIONVMeSMARTUserClientTypeID、SATA用はkIOATASMARTUserClientTypeIDとUUIDが異なります。
なお、Apple Siliconの内蔵SSDは"Apple Fabric"というバスタイプで認識されますが、NVMeと同じインターフェースでSMART情報を取得できます。
SATA SMART取得のコア実装
SATAのSMART情報はATA仕様のSMART READ DATA (Feature 0xD0)コマンドで取得します。
512バイトのデータ領域に最大30個のSMARTアトリビュートが格納されます。
📊SMARTアトリビュートの構造(各12バイト)
| オフセット | サイズ | フィールド |
|---|---|---|
| 0 | 1 byte | Attribute ID |
| 1-2 | 2 bytes | Flags |
| 3 | 1 byte | Current Value |
| 4 | 1 byte | Worst Value |
| 5-10 | 6 bytes | Raw Value |
| 11 | 1 byte | Reserved |
512バイトのデータは先頭2バイトがヘッダで、オフセット2から12バイト×最大30個のアトリビュートが並びます。
代表的なアトリビュートIDとしては、0x09 (Power On Hours)、0x0C (Power Cycle Count)、0xC2 (Temperature)等があります。
🪟Windows: DeviceIoControl + DFP_RECEIVE_DRIVE_DATA
WindowsではIDEレジスタを模した構造体にATAコマンドを設定し、DeviceIoControlで発行します。
SMARTの使用にはまずENABLE_SMART (0xD8)を発行してから、SMART_READ_DATA (0xD0)で値を読み取ります。
デバイスハンドルの取得方法はNVMeと同じで、WMIのWin32_DiskDriveからDeviceIDを取得してCreateFile()で開きます。
SATAでは加えて、WMIのIndexをdeviceNumberとしてATAコマンドに渡します。
var enableParam = new SENDCMDINPARAMS
{
DriveNumber = deviceNumber,
DriveRegs =
{
FeaturesReg = SmartFeatures.EnableSmart,
CylLowReg = SMART_LBA_MID,
CylHighReg = SMART_LBA_HI,
CommandReg = AtaCommand.AtaSmart
}
};
var enableOut = default(SENDCMDOUTPARAMS);
if (!DeviceIoControl(handle, DFP_SEND_DRIVE_COMMAND, ref enableParam, Marshal.SizeOf<SENDCMDINPARAMS>(), ref enableOut, Marshal.SizeOf<SENDCMDOUTPARAMS>(), out _, IntPtr.Zero))
{
return false;
}
var bufferLength = Marshal.SizeOf<ATTRIBUTECMDOUTPARAMS>();
var attributesOffset = Marshal.OffsetOf<ATTRIBUTECMDOUTPARAMS>(nameof(ATTRIBUTECMDOUTPARAMS.Attributes)).ToInt32();
var buffer = Marshal.AllocHGlobal(bufferLength);
var parameter = new SENDCMDINPARAMS
{
DriveNumber = deviceNumber,
DriveRegs =
{
FeaturesReg = SmartFeatures.SmartReadData,
CylLowReg = SMART_LBA_MID,
CylHighReg = SMART_LBA_HI,
CommandReg = AtaCommand.AtaSmart
}
};
if (!DeviceIoControl(handle, DFP_RECEIVE_DRIVE_DATA, ref parameter, Marshal.SizeOf<SENDCMDINPARAMS>(), buffer, bufferLength, out _, IntPtr.Zero))
{
return false;
}
var attr = (SMART_ATTRIBUTE*)IntPtr.Add(buffer, attributesOffset);
var currentValue = attr->CurrentValue;
var rawValue = ((ulong)*(ushort*)(attr->RawValue + 4) << 32) + *(uint*)attr->RawValue;
ATAのSMARTコマンドでは、CylLow=0x4F、CylHigh=0xC2がSMARTシグネチャとして固定です。
🐧Linux: ioctl + SG_IO (SCSI Generic)
LinuxではSCSI Genericドライバ経由でATA PASS-THROUGHコマンドを発行します。
SATAディスクもLinuxカーネルのlibataドライバによりSCSIデバイスとして見えるため、この方式で統一的にアクセスできます。
デバイスのオープン方法はNVMeと同じでopen()を使います。
違いは、NVMeではコントローラデバイス(/dev/nvme0)を開くのに対し、SATAでは/dev/sdaや/dev/hdaのようなブロックデバイスをそのまま開く点です。
var data = stackalloc byte[512];
var cdb = stackalloc byte[12];
cdb[0] = ATA_PASS_THROUGH_12;
cdb[1] = (byte)(ATA_PIO_DATA_IN_PROTOCOL << 1);
cdb[2] = ATA_PASS_THROUGH_FLAGS;
cdb[3] = SMART_READ_DATA;
cdb[4] = ATA_SMART_SECTOR_COUNT;
cdb[5] = 0x00; // lba_low
cdb[6] = SMART_LBA_MID;
cdb[7] = SMART_LBA_HI;
cdb[8] = 0x00; // device
cdb[9] = ATA_SMART;
var sense = stackalloc byte[64];
var io = new sg_io_hdr_t
{
interface_id = 'S',
cmdp = cdb,
cmd_len = 12,
dxferp = data,
dxfer_len = 512,
dxfer_direction = SG_DXFER_FROM_DEV,
sbp = sense,
mx_sb_len = 64,
timeout = 5000
};
if (ioctl(fd, SG_IO, &io) < 0)
{
return false;
}
if (((io.info & SG_INFO_OK_MASK) != SG_INFO_OK) || (io is not { status: 0, host_status: 0, driver_status: 0 }))
{
return false;
}
var id = data[2];
var current = data[5];
var rawValue = Raw48ToU64(data, 7);
🍎macOS: IOKit ATA SMART Plugin
macOSのSATA SMART取得もNVMe同様にIOKitプラグインを使用しますが、vtableのレイアウトが異なります。
サービス列挙、IOCreatePlugInInterfaceForService()、QueryInterface()までの流れはNVMeと同じです。
SATAではNVMe用のUUIDではなく、IOAtaSmartUserClientTypeIdとIOAtaSmartInterfaceIdを使ってATA SMART用インターフェースを取得します。
var smartVtable = *(IntPtr*)pSmartInterface;
var enableFn = (delegate* unmanaged<IntPtr, byte, int>)(*(IntPtr*)((byte*)smartVtable + VTableSmartEnableDisableOperationsOffset));
kr = enableFn(pSmartInterface, AtaSmartOperationsEnabled);
if (kr != KERN_SUCCESS)
{
return false;
}
var readDataFn = (delegate* unmanaged<IntPtr, byte*, int>)(*(IntPtr*)((byte*)smartVtable + VTableAtaSmartReadDataOffset));
var buffer = stackalloc byte[SmartDataSize];
kr = readDataFn(pSmartInterface, buffer);
if (kr != KERN_SUCCESS)
{
return false;
}
var id = buffer[AtaSmartTableOffset];
var current = buffer[AtaSmartTableOffset + 3];
var rawValue = Raw48ToU64(buffer, AtaSmartTableOffset + 5);
ATA SMART Pluginのvtableレイアウト(64bit)は以下のようになっています。
| オフセット | 関数 |
|---|---|
| 0 | _reserved |
| 8 | QueryInterface |
| 16 | AddRef |
| 24 | Release |
| 32 | version(2) + revision(2) + pad(4) |
| 40 | SMARTEnableDisableOperations |
| 72 | SMARTReadData |
NVMe版ではオフセット+40がSMARTReadDataですが、ATA版ではオフセット+40がSMARTEnableDisableOperationsで、SMARTReadDataはオフセット+72になる点が異なります。
🏷️アトリビュートの取得
SATA SMARTのデータパースはプラットフォーム共通で、512バイトのバッファからアトリビュートを読み取ります。
各プラットフォームのGetAttribute()の実装は共通のロジックです。
// 512バイトバッファの構造:
// オフセット0-1: ヘッダ
// オフセット2以降: 12バイト × 最大30アトリビュート
public SmartAttribute? GetAttribute(byte id)
{
for (var i = 0; i < MaxAttributes; i++)
{
var offset = TableOffset + (i * EntrySize); // 2 + (i * 12)
if (buffer[offset] == id)
{
return new SmartAttribute
{
Id = buffer[offset],
Flags = (short)(buffer[offset + 1] | (buffer[offset + 2] << 8)),
CurrentValue = buffer[offset + 3],
WorstValue = buffer[offset + 4],
RawValue = Raw48ToU64(buffer, offset + 5)
};
}
}
return null;
}
Raw Value(6バイト)の解釈はベンダー固有であるため、ライブラリとしては生の値をそのまま返し、使用側で判断する設計としています。
例えばTemperature (0xC2)の場合は最下位バイトのみが温度値です。
USB接続
🪟Windows: DeviceIoControl + IOCTL_SCSI_PASS_THROUGH
Windows版ではUSB接続のHDDについても、SAT(SCSI/ATA Translation) 12によるSMART取得をサポートしています。
SCSI PASS THROUGHコマンドのCDBの中にATAコマンドを埋め込むことで、USB-SATA変換ブリッジを経由してSMART情報を取得します。
var swb = (SCSI_PASS_THROUGH_WITH_BUFFERS*)buffer;
swb->Spt.Length = SptSize;
swb->Spt.CdbLength = 12;
swb->Spt.DataIn = SCSI_IOCTL_DATA_IN;
// SAT12 CDB: SCSI CDBの中にATAのSMARTコマンドを埋め込む
swb->Spt.Cdb[0] = 0xA1; // SCSI ATA PASS-THROUGH(12)
swb->Spt.Cdb[1] = 0x08; // Protocol
swb->Spt.Cdb[2] = 0x0E; // Flags
swb->Spt.Cdb[3] = READ_ATTRIBUTES; // 0xD0
swb->Spt.Cdb[4] = 0x01;
swb->Spt.Cdb[5] = 0x01;
swb->Spt.Cdb[6] = SMART_LBA_MID; // 0x4F
swb->Spt.Cdb[7] = SMART_LBA_HI; // 0xC2
swb->Spt.Cdb[9] = SMART_CMD; // 0xB0
return DeviceIoControl(handle, IOCTL_SCSI_PASS_THROUGH, buffer, BufferSize, buffer, BufferSize, out var returnedBytes, IntPtr.Zero);
ただし、USB接続はブリッジチップの対応状況によって取得の可否が異なるという問題があります。
CrystalDiskInfoのように個別のブリッジチップ対応を入れるのはハードルが高いため、SAT12で取得できる範囲のサポートとしています。
まとめ
各プラットフォーム・インターフェースの実装方式を改めてまとめます。
| プラットフォーム | NVMe | SATA | 特記事項 |
|---|---|---|---|
| Windows | DeviceIoControl + STORAGE_QUERY_BUFFER (NVMe LogPage) | DeviceIoControl + SENDCMDINPARAMS (ATA SMART) | USB (SAT12) もサポート |
| Linux | ioctl + nvme_admin_cmd (NVME_IOCTL_ADMIN_CMD) | ioctl + sg_io_hdr_t (SG_IO + ATA PASS-THROUGH) | PT12/PT16フォールバック |
| macOS | IOKit Plugin (kIONVMeSMARTInterface, vtable+40) | IOKit Plugin (kIOATASMARTInterface, vtable+72) | Apple Fabricもサポート |
3プラットフォームともSMART情報の取得にはP/Invokeで各OSのネイティブAPIを呼び出す必要がありますが、取得されるSMARTデータ自体のフォーマットはNVMe/SATA仕様で統一されているため、パース部分はほぼ共通のロジックとなります。
処理の詳細についてはGitHubのソースを参照してください。
うさコメ
これらのライブラリを3プラットフォーム分作成したことで、Avalonia UIなんかでマルチプラットフォームのCrystalDiskInfoモドキも作れるようになりました( ˙ω˙)
