自作OSでできる!NVDIMMのつかいかた

これは、自作OS Advent Calendar 2018 の7番目の素数日の記事です。

はじめに

みなさん、NVDIMMって知っていますか?知っている人はぜひ仲良くなりましょうー。

NVDIMMとは、Non-Volatile DIMMの略で、要するにDIMMスロットに刺さる不揮発性の記憶モジュールのことです。 通常のDRAM DIMMは、電源を切るとデータが消えてしまう揮発性の記憶素子なのですが、なんとNVDIMMは電源を切ってもデータが消えません。すごいね!

(NVDIMMの実現方法にはいくつか種類があって…という、NVDIMM自体の細かい話はここではしません。)

さて、自作OSを書いている皆様はよくわかると思うのですが、自作OSで何らかのデータを保存するのはとても大変です。メモリにあるデータは電源を切ると消えてしまいますから、HDDやSSDやSDカードに書き出さないといけません。そうすると、まずはそれらのデバイスのドライバを書かなくてはいけませんし、しかもこれらのデバイスはブロック単位でしか書き込みができませんから、何らかのファイルシステムのような仕組みも用意しなくてはいけません。…大変ですね。

そういうわけで、多くのみなさんはデータを保存する機構を実装せず、電源を切ったらデータは全部消えてしまってもいいという割り切りをすることになります。これはかなしい。

ところが!NVDIMMはCPUからみたとき、メモリとして認識されるので、ドライバなしにCPUから直接読み書きすることができます。つまり、DIMMにマッピングされたアドレスに書いたデータは、起動後もそのまま読み出せるのです!

ということで、この記事では、自作OSでNVDIMMを使うにはどうすればいいかを解説していきます。

検証環境

残念ながら、NVDIMMの実機を個人で買うのはすこしたいへんです。なので、代わりにQEMUのNVDIMMエミュレーションを利用することにします。

利用したバージョンはコミット7c69b7c849をソースビルドしたものです。

QEMU emulator version 3.0.50 (v3.0.0-1143-g7c69b7c849)

コマンドラインオプション

公式ドキュメントは以下にあります。

qemu/docs/nvdimm.txt

これを参考に、QEMUの起動コマンドラインオプションは以下のようにしました。

-bios $(OVMF) \
-machine q35,nvdimm -cpu qemu64 -smp 4 \
-monitor stdio \
-m 8G,slots=2,maxmem=10G \
-drive format=raw,file=fat:rw:mnt -net none \
-object memory-backend-file,id=mem1,share=on,mem-path=pmem.img,size=2G \
-device nvdimm,id=nvdimm1,memdev=mem1

これらのオプションのうち、NVDIMMをエミュレーションする上で重要なポイントは以下の通りです。

  • -machinenvdimm を追加する。
  • -m のサイズはDRAMのサイズを設定する。
    • slots は、(メインのDRAMスロット数+NVDIMMスロット数)に設定する。
      • (今回の場合 1 + 1 = 2)
    • maxmem は、(メインのDRAM容量 + NVDIMMの容量)に設定する。
      • (今回の場合 8G + 2G = 10G)
  • -object-device の組で、ひとつのファイルバックエンドNVDIMMデバイスを作成できる。
    • -objectid と、-devicememdevの値を一致させる。
    • -mem-path にはqemu-imgで作成したデータイメージのパスを指定する。
      • ここでは、qemu-img create pmem.img 2G と実行して作成されたイメージを利用した。
    • -sizeには、上記データイメージ作成時に渡したパラメータと同じサイズを指定する。

また、今回はUEFIを利用するため、-biosにはOVMFのコミットb9cee524e6からビルドしたbiosイメージを指定しています。

Linux以外でのエミュレーションができなかった問題

ちなみに、Linux以外でNVDIMMエミュレーション(正確にはhostmem-file)が正しく動作しない問題があったので、私がパッチを送っておきました。すでにmasterにはマージされているので、最新のQEMUコンパイルしてご利用ください。

https://github.com/qemu/qemu/commit/d5dbde4645fe56a1bcd678f85fa26c5548bcf552

実装の指針

さて、これでエミュレーションの準備は整いました。つぎは実装の計画を立てましょう。

いかにしてNVDIMMのマップされている物理アドレスを取得するか

NVDIMMはメモリバスの配下に接続されているので、物理アドレス空間のどこかにNon-volatileなメモリ空間が生い茂っているはずです。 しかし、私たちはそれがどこにあるのかまだ知りません。知るためにはどうすればよいか…ACPIのNFITを読みます。

ACPI NFIT

ACPI Revision 6.0 より、NFITというPlatform Capabilities Structureが追加されました。 NFITとは、NVDIMM Firmware Interface Table の略です。このテーブルを参照することで、実行中のプラットフォーム上にあるNVDIMMの情報を取得できます。

packed_struct ACPI_NFIT {
  char signature[4];
  uint32_t length;
  uint8_t revision;
  uint8_t checksum;
  uint8_t oem_id[6];
  uint64_t oem_table_id;
  uint32_t oem_revision;
  uint32_t creator_id;
  uint32_t creator_revision;
  uint32_t reserved;
  uint16_t entry[1];
};

今回はそのNFITに含まれる情報の中でも、SPA(System Physical Address) Range Structures に知りたい情報があります。

packed_struct ACPI_NFIT_SYSTEM_PHYSICAL_ADDRESS_RANGE_STRUCTURE {
  uint16_t type;
  uint16_t length;
  uint16_t spa_range_structure_index;
  uint16_t flags;
  uint32_t reserved;
  uint32_t proximity_domain;
  uint64_t address_range_type_guid[2];
  uint64_t system_physical_address_range_base;
  uint64_t system_physical_address_range_length;
  uint64_t address_range_memory_mapping_attribute;
};

話はわかった。ところで、そのNFITっていうのはどうやったら読めるの?

NFITへのポインタは、ACPIのXSDT(eXtended System Descriptor Table)に格納されています。

packed_struct ACPI_XSDT {
  char signature[4];
  uint32_t length;
  uint8_t revision;
  uint8_t checksum;
  uint8_t oem_id[6];
  uint64_t oem_table_id;
  uint32_t oem_revision;
  uint32_t creator_id;
  uint32_t creator_revision;
  void* entry[1];
};

XSDTへのポインタはRSDT(Root System Description Table)に格納されています。

packed_struct ACPI_RSDT {
  char signature[8];
  uint8_t checksum;
  uint8_t oem_id[6];
  uint8_t revision;
  uint32_t rsdt_address;
  uint32_t length;
  ACPI_XSDT* xsdt;           // <<< HERE!
  uint8_t extended_checksum;
  uint8_t reserved;
};

で、このRSDTへのポインタは…EFI Configuration Table にあります。(EFI_ACPI_TABLE_GUIDから引ける。)

というわけで、実際の順番としては

EFIConfigurationTable 
-> ACPI_RSDT 
-> ACPI_XSDT 
-> ACPI_NFIT 
-> SPARangeStructure

と辿っていけば、NVDIMMのマップされているアドレス system_physical_address_range_base がわかるわけです。

実装

さあ、これでもうあとは実装するだけですね!

ということで、実装してみた例がこちらです。

github.com

軽く実装について説明します。

src/liumos.cc のMainForBootProcessor()が、起動後に最初に実行される関数です。

この関数内の、

  ACPI_RSDT* rsdt = static_cast<ACPI_RSDT*>(
      EFIGetConfigurationTableByUUID(&EFI_ACPITableGUID));
  ACPI_XSDT* xsdt = rsdt->xsdt;

という部分で、EFIConfigurationTableからRSDTを取得し、そこからXSDTを取得しています。

そして、以下のようにしてXSDTからNFITを見つけ出し、

  ACPI_NFIT* nfit = nullptr;
  ...
  for (int i = 0; i < num_of_xsdt_entries; i++) {
    const char* signature = static_cast<const char*>(xsdt->entry[i]);
    if (IsEqualStringWithSize(signature, "NFIT", 4))
      nfit = static_cast<ACPI_NFIT*>(xsdt->entry[i]);
   ...
  }

NFITが存在していれば、適当にSPARange structureを見つけ出して、適宜書き込んだり読み込んだりしてみてその結果を表示しています。

 if (nfit) {
    PutString("NFIT found\n");
    PutStringAndHex("NFIT Size", nfit->length);
    PutStringAndHex("First NFIT Structure Type", nfit->entry[0]);
    PutStringAndHex("First NFIT Structure Size", nfit->entry[1]);
    if (static_cast<ACPI_NFITStructureType>(nfit->entry[0]) ==
        ACPI_NFITStructureType::kSystemPhysicalAddressRangeStructure) {
      ACPI_NFIT_SPARange* spa_range = (ACPI_NFIT_SPARange*)&nfit->entry[0];
      PutStringAndHex("SPARange Base",
                      spa_range->system_physical_address_range_base);
      PutStringAndHex("SPARange Length",
                      spa_range->system_physical_address_range_length);
      PutStringAndHex("SPARange Attribute",
                      spa_range->address_range_memory_mapping_attribute);
      PutStringAndHex("SPARange TypeGUID[0]",
                      spa_range->address_range_type_guid[0]);
      PutStringAndHex("SPARange TypeGUID[1]",
                      spa_range->address_range_type_guid[1]);

      uint64_t* p = (uint64_t*)spa_range->system_physical_address_range_base;
      PutStringAndHex("\nPointer in PMEM Region: ", p);
      PutStringAndHex("PMEM Previous value: ", *p);
      (*p)++;
      PutStringAndHex("PMEM value after write: ", *p);

      uint64_t* q = reinterpret_cast<uint64_t*>(page_allocator.AllocPages(1));
      PutStringAndHex("\nPointer in DRAM Region: ", q);
      PutStringAndHex("DRAM Previous value: ", *q);
      (*q)++;
      PutStringAndHex("DRAM value after write: ", *q);
    }
  }

(本当はきちんと見つけ出さなければいけないのですが、QEMUの場合NFIT中の0番目のエントリに運良くSPARangeStructureがあったので手抜きしています。ごめんなさい!)

ね!テーブルをたどるだけの簡単なお仕事でしょ!

実行結果

リポジトリのこのハッシュをクローンしてきてmake runすると、最初にpmem.imgが作成されてからQEMUが起動します。

$ make run
make -C src
make[1]: Nothing to be done for `default'.
qemu-img create pmem.img 2G
Formatting 'pmem.img', fmt=raw size=2147483648
mkdir -p mnt/EFI/BOOT
cp src/BOOTX64.EFI mnt/EFI/BOOT/
qemu-system-x86_64 -bios ovmf/bios64.bin -machine q35,nvdimm -cpu qemu64 -smp 4 -monitor stdio -m 8G,slots=2,maxmem=10G -drive format=raw,file=fat:rw:mnt -net none -object memory-backend-file,id=mem1,share=on,mem-path=pmem.img,size=2G -device nvdimm,id=nvdimm1,memdev=mem1
QEMU 3.0.50 monitor - type 'help' for more information
(qemu)

画面としてはこのような感じになります。

f:id:hikalium:20181217181025p:plain
最初の起動

今回は比較のため、PMEM領域に含まれるアドレスと、DRAM領域に含まれるアドレスにある8バイト整数を起動毎にそれぞれインクリメントしていくように実装しました。

ひとまず最初は、どちらも運良く0で初期化されていたので、それぞれインクリメントしたら1になっていますね。

では、qemuのコンソールにqと打ち込んで終了させ、もう一度make runしてみましょう。

すると…!

f:id:hikalium:20181217231241p:plain
2回目の起動

DRAMとPMEM、どちらも最初の起動時と同じポインタを読み書きしているのですが、DRAMでは最初の起動時にインクリメントした1は忘れ去られてまた0からやりなおしになっている一方、PMEMでは前回のインクリメント結果である1が再起動後も残っていて、今度は2が書き込まれました!

念の為もう一回再起動してみると…

f:id:hikalium:20181217231640p:plain
3回目の起動

やっぱりDRAMはデータが消えてしまっていますが、PMEMは残っていますね!すごい!

まとめ

というわけで、NVDIMMを使えば、自作OSでも簡単にデータを保存して再起動後にも残しておけるということがわかりました! とはいえ今回は簡単な説明しかしておらず、実装は手抜きですし、キャッシュをフラッシュしなければデータが消える可能性があるなど、細かい点で注意しなければならないことが山積みです。

これらを考慮しつつ、liumOSはNVDIMMを有効活用した新しいOSを目指して開発をしてゆきますので、今後にご期待ください!

では皆様、よいOS自作ライフを!

参考文献

編集履歴

  • ACPI SpecおよびUEFI Specについて、最新バージョンを参照するよう参考文献を変更しました。
  • ACPI SpecにNFITが追加されたのは6.2Aではなく6.0からとの指摘を受けましたので、該当箇所を修正しました。