はじめに
WindowsがNVMeドライブをどのように操作しているのかを深く知るには、WindowsがNVMeドライブに対して、どのようなコマンドを発行し、どのようにレジスタアクセスしているのか、を知る必要があります。
知りたいことに合わせたNVMeドライブ(実機)を用意するのはコストがかかりますので、こうゆう時は積極的に仮想マシンを使うべきです。
ということで、Qemu[1]を使用して、WindowsゲストにエミュレートされたNVMeドライブを接続する方法を、備忘録代わりに記します。
※ 本当は、BIOSによるアクセスとOS (Windows)によるアクセスを分けて考える必要がありますが、ここではまとめて扱います。
やりたいこととやったこと
-
やりたいこと:
- WindowsがNVMeドライブにどんなアクセスをするのか(特に起動時)を、NVMeドライブの実機なしで明らかにしたい
-
やったこと:
- Qemu(ホストOS:Linux、ゲストOS:Windows)を使い、QemuによるNVMeドライブエミュレーションコードを改造して、WindowsによるNVMeドライブへのアクセスの内容をコンソール出力させた
準備
今回用意したもの
- Linuxホスト
- x86_64ホスト+Ubuntu 18.04.4 LTS
- Qemuソース
- バージョン4.1.0
- Windows ISOイメージ
- Windows Insider Program[2]で取得したWindows 10 Insider Preview build 19041.84のISOイメージ
ここで仮定するディレクトリ構成
qemu --- qemu-4.1.0 :qemu-4.1.0のソースディレクトリ
|- win :ISOイメージを置いたり、ドライブイメージファイルを置くディレクトリ
手順
Qemuビルド
ホストがx86_64
ですので、configure
スクリプトの実行時に--target-list=x86_64-softmmu
オプションをつけて、必要なバイナリのみビルドします。時間とスペースの節約になります。
% tar Jxvf qemu-4.1.0.tar.xz
% cd qemu-4.1.0
% ./configure --target-list=x86_64-softmmu
% make
ビルドの結果、qemu-4.1.0/x86_64-softmmu
ディレクトリが作成されます。
ドライブイメージファイル作成
WindowsをインストールするドライブとNVMeドライブの2つを作成します。
Windowsをインストールするドライブのサイズが小さすぎるとWindowsがインストールできないので注意してください。以下の例では40 GBにしています。NVMeドライブのサイズは、必要がなければ小さくてもOKです。
% cd ../win
% ../qemu-4.1.0/qemu-img create -f qcow2 qemu-win.qcow2 40G
% ../qemu-4.1.0/qemu-img create -f qcow2 qemu-nvme.qcow2 32G
qemu-win.qcow2
がWindowsをインストールするドライブのイメージファイル、qemu-nvme.qcow2
がNVMeドライブのイメージファイルです。ファイル名は任意です。
Qemu起動用スクリプト作成
Qemuの起動コマンドを毎回打つのは面倒なので、シェルスクリプトを作成します。
作成するスクリプトは2つです。ひとつはWindowsのISOイメージから起動してWindowsをインストールするためのスクリプトで、もうひとつは、NVMeドライブを接続した状態で、WindowsをインストールしたドライブからWindowsを起動するためのスクリプトです。
一つ目のスクリプトはこんな感じです。色々と改良したい点はあるのですが、とりあえず現状をそのままで……
# !/bin/bash
../qemu-4.1.0/x86_64-softmmu/qemu-system-x86_64 \
-enable-kvm \
-boot d \
-cdrom Windows10_InsiderPreview_Client_x64_en-us_19041.iso \
-hda qemu-win.qcow2 \
-m 2048
二つ目のスクリプトはこんな感じです。
# !/bin/sh
../qemu-4.1.0/x86_64-softmmu/qemu-system-x86_64 \
-enable-kvm \
-boot c \
-hda qemu-win.qcow2 \
-m 2048 \
-device nvme,drive=nvme0,serial=xxxx \
-drive file=qemu-nvme.qcow2,if=none,id=nvme0 \
-monitor stdio
シリアル番号の部分(serial=xxxx
のxxxx
の部分)は何でも構いません。ちなみにここで設定したシリアル番号は、Crystal Disk Infoなどで表示されます。また、どちらのスクリプトでも、仮想マシンに割り当てるメモリサイズ(-m
オプションの値)などはホストシステムの環境に合わせて設定してください。
-monitor stdio
というオプションは、Qemuのモニタ(ディスプレイモニタのことではない)出力を標準出力に設定するオプションです。後ほどモニタ経由でコンソールに色々出力させるので、そのために指定しています。
Windowsインストール
先ほど作成した一つ目のスクリプト(install-win10-on-qemu.sh
)でQemuを起動します。
その後、別のターミナルでVNCビューワーを立ち上げて、Qemuが出力している仮想ディスプレイに接続します。例えば% gvncviewer localhost:0
みたいな感じです。
すると、以下のようにWindowsのインストールが始まっているのが見られます。
図1:Windowsのインストール開始画面
Windowsのインストール手順自体は通常のインストール手順と何も変わりません。インストールが終わったら一旦Windowsをシャットダウンします(Qemuも終了する)。
NVMeドライブを接続してWindowsを起動
作成した二つ目のスクリプト(launch-qemu-with-nvme-drive.sh
)を使用し、エミュレートされたNVMeドライブを接続した状態で、先ほどインストールしたWindowsの仮想マシンを起動します。VNCビューワーでQemuの出力先仮想ディスプレイに接続する必要があるのは、Windowsインストール時と同じです。
もしqemu
実行時に/dev/kvm
へのアクセスについてPermission Denied
と怒られる場合は、スクリプト内でsudo
をつけてqemu
を実行する、もしくはqemu-kvm
をインストールして、使用しているユーザをグループkvm
に追加すれば実行できます。
QemuがエミュレートしたNVMeドライブが正常に認識されているかどうかは、起動したWindowsの「デバイスマネージャ」や「ディスクの管理」メニューで確認できます。以下は、「ディスクの管理」メニューでと「デバイスマネージャ」で確認した結果です。
図2:エミュレートされたNVMeドライブを接続した状態のQemu上のWindows仮想マシン
QemuのNVMeエミュレーションを弄ってみる
これで、Windowsに、エミュレーションしたNVMeドライブを接続できるようになったので、Qemuのソースを弄ればWindowsの挙動を色々と知ることができます。
ここでは、手始めに、Windowsが起動時にNVMeドライブに対してどんなアクセスを行うのか調べてみます。
ソースファイルの改造内容
QemuにおけるNVMeドライブのエミュレーションはhw/block/nvme.c
で行われています。
例えば、PCIeデバイス、およびNVMeドライブとしての初期化はnvme_realize()
で行われています。前半はPCIeデバイスとしての設定、後半はNVMeドライブとしての設定(Identify
コマンドで返す内容や設定レジスタの内容を含む)に相当します。
static void nvme_realize(PCIDevice *pci_dev, Error **errp)
{
NvmeCtrl *n = NVME(pci_dev);
NvmeIdCtrl *id = &n->id_ctrl;
...(snip)...
pci_conf = pci_dev->config;
pci_conf[PCI_INTERRUPT_PIN] = 1;
pci_config_set_prog_interface(pci_dev->config, 0x2);
pci_config_set_class(pci_dev->config, PCI_CLASS_STORAGE_EXPRESS);
pcie_endpoint_cap_init(pci_dev, 0x80);
...(snip)...
n->num_namespaces = 1;
n->reg_size = pow2ceil(0x1004 + 2 * (n->num_queues + 1) * 4);
n->ns_size = bs_size / (uint64_t)n->num_namespaces;
n->namespaces = g_new0(NvmeNamespace, n->num_namespaces);
n->sq = g_new0(NvmeSQueue *, n->num_queues);
n->cq = g_new0(NvmeCQueue *, n->num_queues);
memory_region_init_io(&n->iomem, OBJECT(n), &nvme_mmio_ops, n,
"nvme", n->reg_size);
pci_register_bar(pci_dev, 0,
PCI_BASE_ADDRESS_SPACE_MEMORY | PCI_BASE_ADDRESS_MEM_TYPE_64,
&n->iomem);
msix_init_exclusive_bar(pci_dev, n->num_queues, 4, NULL);
id->vid = cpu_to_le16(pci_get_word(pci_conf + PCI_VENDOR_ID));
id->ssvid = cpu_to_le16(pci_get_word(pci_conf + PCI_SUBSYSTEM_VENDOR_ID));
...(snip)...
}
また、設定レジスタの読み書きは、それぞれnvme_mmio_write()
とnvme_mmio_read()
で実装されています。
nvme_mmio_read()
の実装はmemcpy()
するだけなのでちょっと面白くないのですが、nvme_mmio_write()
はnvme_write_bar()
を呼んで、その中で、NVMe仕様に基づいて書き込まれた値の解釈とドライブ機能の設定などを行っています。
以下は、nvme_write_bar()
の内容から、CC (Controller Configuration)レジスタへの書き込みを処理している部分を抜粋したものです。
static void nvme_write_bar(NvmeCtrl *n, hwaddr offset, uint64_t data, unsigned size)
{
...(snip)...
switch (offset) {
...(snip)...
case 0x14: /* CC */
trace_nvme_mmio_cfg(data & 0xffffffff);
qemu_printf( "[NVME] ==> CC "); ★
qemu_printf( "< EN(%ld), CSS(0x%lX), MPS(0x%ld), AMS(0x%lX), SHN(0x%lX) IOSQES(%ld) IOCQES(%ld) >\n", data&0x1, (data>>4)&0x7, (data>>7)&0x4, (data>>11)&0x7, (data>>14)&0x3, (data>>16)&0xF, (data>>20)&0xF); ★
/* Windows first sends data, then sends enable bit */
if (!NVME_CC_EN(data) && !NVME_CC_EN(n->bar.cc) &&
!NVME_CC_SHN(data) && !NVME_CC_SHN(n->bar.cc))
{
n->bar.cc = data;
}
if (NVME_CC_EN(data) && !NVME_CC_EN(n->bar.cc)) {
n->bar.cc = data;
if (unlikely(nvme_start_ctrl(n))) {
trace_nvme_err_startfail();
n->bar.csts = NVME_CSTS_FAILED;
} else {
trace_nvme_mmio_start_success();
n->bar.csts = NVME_CSTS_READY;
}
} else if (!NVME_CC_EN(data) && NVME_CC_EN(n->bar.cc)) {
trace_nvme_mmio_stopped();
nvme_clear_ctrl(n);
n->bar.csts &= ~NVME_CSTS_READY;
}
if (NVME_CC_SHN(data) && !(NVME_CC_SHN(n->bar.cc))) {
trace_nvme_mmio_shutdown_set();
nvme_clear_ctrl(n);
n->bar.cc = data;
n->bar.csts |= NVME_CSTS_SHST_COMPLETE;
} else if (!NVME_CC_SHN(data) && NVME_CC_SHN(n->bar.cc)) {
trace_nvme_mmio_shutdown_cleared();
n->bar.csts &= ~NVME_CSTS_SHST_COMPLETE;
n->bar.cc = data;
}
break;
...(snip)...
}
}
行末に「★」をつけた行は私が追加したものです。このいわゆるデバッグプリント文の出力内容は、Qemuのモニタに出力されます。起動スクリプト(launch-qemu-with-nvme-drive.sh
)で-monitor stdio
というオプションを付けましたので、このqemu_printf()
による出力は標準出力に出力されます。
実際に色々出力を追加したQemuを実行して、ゲストOSのWindowsにエミュレートされたNVMeドライブを接続すると、以下のスクリーンショットのように、Qemuのモニタに、追加したデバッグ出力が表示されます(右側のターミナル)。
図3:NVMeドライブのエミュレーションコードにデバッグプリントを追加したQemuの実行の様子
Qemuで捉えたWindowsによるNVMeドライブ接続時の処理
図3の右側のウィンドウ(コンソール)に出力されているメッセージは、Windows起動時のNVMeドライブに対するアクセスの内容です。せっかくですので、この内容を順に追ってみます。
[NVME] <== VS < MJR(1), MNR(2), TER(0) >
NVMeのバージョンを確認するために、Version (VS)レジスタを読み出しています。"1.2"相当の値をホストに返しています。
[NVME] <== CAP < MQES(255), CQR(1), AMS(0x0), TO(15), DSTRD(0), NSSRS(0), CSS(0x00), BPS(0), MPSMIN(0), MPSMAX(0) >
NVMeドライブが備える機能や設定を確認するために、Controller Capabilities (CAP)レジスタを読み出しています。
[NVME] ==> CC < EN(0), CSS(0x0), MPS(0x0), AMS(0x0), SHN(0x0) IOSQES(0) IOCQES(0) >
ここでController Configuration (CC)レジスタへ書き込んでいます。
NVMe仕様[3]では、CCレジスタのEnable (EN)ビットに0が書き込まれると、コントローラは、自身が再有効化(re-enable)可能になった時点でController Status (CSTS)レジスタのReady (RDY)ビットを0にすること、と定義されています。この仕様に基づいた書き込みだと思われます。
[NVME] <== CAP < MQES(255), CQR(1), AMS(0x0), TO(15), DSTRD(0), NSSRS(0), CSS(0x00), BPS(0), MPSMIN(0), MPSMAX(0) >
[NVME] <== CSTS < RDY(0), CFS(0), SHST(0x0), NSSRO(0), PP(0) >
CCレジスタのENビットに0を書き込んだのち、CAPレジスタを読んだ後で、CSTSレジスタを読んでいます。ここでコントローラがRDYビットが0のデータを返すので、ホストはコントローラを有効にする準備ができたことを知ります。
[NVME] <== CAP < MQES(255), CQR(1), AMS(0x0), TO(15), DSTRD(0), NSSRS(0), CSS(0x00), BPS(0), MPSMIN(0), MPSMAX(0) >
[NVME] ==> CC < EN(1), CSS(0x0), MPS(0x0), AMS(0x0), SHN(0x0) IOSQES(6) IOCQES(4) >
CSTSレジスタのRDYビットが0であることを知ったホストは、CCレジスタのENビットに1を書き込みます。これによって、NVMeドライブが(正確にはコントローラが)有効になり、Adminコマンドを受け付けられるようになります。
[NVME] <== CAP < MQES(255), CQR(1), AMS(0x0), TO(15), DSTRD(0), NSSRS(0), CSS(0x00), BPS(0), MPSMIN(0), MPSMAX(0) >
[NVME] <== CSTS < RDY(1), CFS(0), SHST(0x0), NSSRO(0), PP(0) >
[NVME] ==> CMD: Identify < CNS(1), CNTID(0), NSID(0) >
ホストはさっそくコントローラを指定したIdentify
コマンドを投げてきます。「コントローラを指定している」ことは、Identify
コマンドのCNSフィールドが1であることからわかります。
この時、Identify
コマンドはAdminコマンド用のSubmission Queueに投入されます。したがって、本当はコマンドを投入する前にAdminコマンド用のSubmission QueueとCompletion Queueのアドレスを取得するためにレジスタ(Admin Submission Queue Base (ASQB)レジスタとAdmin Completion Queue Base (ACQB)レジスタ)を読み出すはずなのですが...
[NVME] <== CAP < MQES(255), CQR(1), AMS(0x0), TO(15), DSTRD(0), NSSRS(0), CSS(0x00), BPS(0), MPSMIN(0), MPSMAX(0) >
[NVME] ==> CMD: Create IO Completion Queue: < QSIZE(255), QID(1), PC(0x1), IEN(0x0), IV(0) >
[NVME] <== CAP < MQES(255), CQR(1), AMS(0x0), TO(15), DSTRD(0), NSSRS(0), CSS(0x00), BPS(0), MPSMIN(0), MPSMAX(0) >
[NVME] ==> CMD: Create IO Submission Queue: < QSIZE(255), QID(1), CQID(1), PC(0x1), QPRIO(0x0) >
ホストは、その後、Create IO Submission Queue
コマンドとCreate IO Completion Queue
コマンドで、Adminコマンド以外のコマンド用のSubmission QueueとCompletion Queueを作成します。
これでやっとReadコマンドやWriteコマンドを発行できるようになります。
[NVME] ==> CMD: Identify < CNS(0), CNTID(0), NSID(1) >
ホストは、今度はNSIDが1のネームスペースに対してIdentify
コマンドを投げてきます。Identify
コマンドの対象がネームスペースであることは、コマンドのCNSフィールドが0であることからわかります。
この後ReadコマンドやWriteコマンドが発行されるはずですが、現時点ではReadコマンドやWriteコマンドの処理部分にデバッグプリントを仕込んでいませんので、ReadやWriteの様子を確認することはできていません。
まとめ
この記事では、Qemuを使用して、エミュレートされたNVMeドライブをWindowsゲストに接続する方法を、備忘録代わりにまとめました。
また、QemuのNVMeドライブエミュレーションコードを改造することで、WindowsがNVMeドライブをどのように操作しているのかを調べる方法も示しました。
現在のQemuのNVMeドライブエミュレーションは、準拠しているNVMeバージョンが1.2でちょっと古いですが(最新仕様は1.4)、基本的な動作の調査・確認であれば十分に利用できると思います。
References
[1] QEMU, https://www.qemu.org/
[2] Flight Hub - Windows Insider Program, https://docs.microsoft.com/en-us/windows-insider/flight-hub/
[3] NVM Express, "NVM ExpressTM Base Specification", Revision 1.3d, March 20, 2019
ライセンス表記
この記事はクリエイティブ・コモンズ 表示 - 継承 4.0 国際 ライセンスの下に提供されています。