Files
blog_os/blog/content/edition-2/posts/09-paging-implementation/index.ja.md
Shu W. Nakamura 4472eed23c Apply suggestions from code review
Co-authored-by: garasubo <garasubo@gmail.com>
2021-09-14 11:19:03 +09:00

85 KiB
Raw Blame History

+++ title = "ページングの実装" weight = 9 path = "ja/paging-implementation" date = 2019-03-14

[extra] chapter = "Memory Management" translation_based_on_commit = "27ab4518acbb132e327ed4f4f0508393e9d4d684" translators = ["woodyZootopia", "garasubo"] +++

この記事では私達のカーネルをページングに対応させる方法についてお伝えします。まずページテーブルの物理フレームにカーネルがアクセスできるようにする様々な方法を示し、それらの利点と欠点について議論します。次にアドレス変換関数を、ついで新しい対応付けを作るための関数を実装します。

このブログの内容は GitHub 上で公開・開発されています。何か問題や質問などがあれば issue をたててください (訳注: リンクは原文(英語)のものになります)。またこちらにコメントを残すこともできます。この記事の完全なソースコードはpost-09 ブランチにあります。

導入

1つ前の記事ではページングの概念を説明しました。セグメンテーションと比較することによってページングのメリットを示し、ページングとページテーブルの仕組みを説明し、そしてx86_64における4層ページテーブルの設計を導入しました。ブートローダはすでにページテーブルの階層構造を設定してしまっているので、私達のカーネルは既に仮想アドレス上で動いているということを学びました。これにより、不正なメモリアクセスは、任意の物理メモリを書き換えてしまうのではなくページフォルト例外を発生させるので、安全性が向上しています。

記事の最後で、ページテーブルにカーネルからアクセスできないという問題が起きていました。この問題は、ページテーブルは物理メモリ内に格納されている一方、私達のカーネルは既に仮想アドレス上で実行されているために発生します。この記事ではその続きとして、私達のカーネルからページテーブルのフレームにアクセスするための様々な方法を探ります。それぞれの方法の利点と欠点を議論し、カーネルに採用する手法を決めます。

この方法を実装するには、ブートローダーからの補助が必要になるので、まずこれに設定を加えます。その後で、ページテーブルの階層構造を移動して、仮想アドレスを物理アドレスに変換する関数を実装します。最後に、ページテーブルに新しい対応関係を作る方法と、それを作るための未使用メモリを見つける方法を学びます。

ページテーブルにアクセスする

私達のカーネルからページテーブルにアクセスするのは案外難しいです。この問題を理解するために、前回の記事の4層ページテーブルをもう一度見てみましょう

An example 4-level page hierarchy with each page table shown in physical memory

ここで重要なのは、それぞれのページテーブルのエントリは次のテーブルの物理アドレスであるということです。これにより、それらのアドレスに対しては変換せずにすみます。もしこの変換が行われたとしたら、性能的にも良くないですし、容易に変換の無限ループに陥りかねません。

問題は、私達のカーネル自体も仮想アドレスの上で動いているため、カーネルから直接物理アドレスにアクセスすることができないということです。例えば、アドレス4KiBにアクセスしたとき、私達は仮想アドレス4KiBにアクセスしているのであって、レベル4ページテーブルが格納されている物理アドレス4KiBにアクセスしているのではありません。物理アドレス4KiBにアクセスしたいなら、それに対応づけられている何らかの仮想アドレスを通じてのみ可能です。

そのため、ページテーブルのフレームにアクセスするためには、どこかの仮想ページをそれに対応づけなければいけません。このような、任意のページテーブルのフレームにアクセスできるようにしてくれる対応付けを作る方法にはいくつかあります。

恒等対応

シンプルな方法として、すべてのページテーブルを恒等対応させるということが考えられるでしょう:

A virtual and a physical address space with various virtual pages mapped to the physical frame with the same address

この例では、いくつかの恒等対応したページテーブルのフレームが見てとれます。こうすることで、ページテーブルの物理アドレスは仮想アドレスと同じ値になり、よってCR3レジスタから始めることで全ての階層のページテーブルに簡単にアクセスできます。

しかし、この方法では仮想アドレス空間が散らかってしまい、大きいサイズの連続したメモリを見つけることが難しくなります。例えば、上の図において、ファイルをメモリにマップするために1000KiBの大きさの仮想メモリ領域を作りたいとします。28KiBを始点として領域を作ろうとすると、1004KiBのところで既存のページと衝突してしまうのでうまくいきません。そのため、1008KiBのような、十分な広さで対応付けのない領域が見つかるまで更に探さないといけません。これはセグメンテーションの時に見た断片化の問題に似ています。

同様に、新しいページテーブルを作ることもずっと難しくなります。なぜなら、対応するページがまだ使われていない物理フレームを見つけないといけないからです。例えば、メモリマップト (に対応づけられた) ファイルのために1008KiBから1000KiBにわたって仮想メモリを占有したとしましょう。すると、物理アドレス1000KiBから2008KiBまでのフレームは、もう恒等対応を作ることができないので使用することができません。

固定オフセットの対応づけ

仮想アドレス空間を散らかしてしまうという問題を回避するために、ページテーブルの対応づけのために別のメモリ領域を使うことができます。ページテーブルを恒等対応させる代わりに、仮想アドレス空間で一定の補正値 (オフセット) をおいて対応づけてみましょう。例えば、オフセットを10TiBにしてみましょう

The same figure as for the identity mapping, but each mapped virtual page is offset by 10 TiB.

10TiBから10TiB+物理メモリ全体の大きさの範囲の仮想メモリをページテーブルの対応付け専用に使うことで、恒等対応のときに存在していた衝突問題を回避しています。このように巨大な領域を仮想アドレス空間内に用意するのは、仮想アドレス空間が物理メモリの大きさより遥かに大きい場合にのみ可能です。x86_64で用いられている48bit仮想アドレス空間は256TiBもの大きさがあるので、これは問題ではありません。

この方法では、新しいページテーブルを作るたびに新しい対応付けを作る必要があるという欠点があります。また、他のアドレス空間のページテーブルにアクセスすることができると新しいプロセスを作るときに便利なのですが、これも不可能です。

物理メモリ全体を対応付ける

これらの問題はページテーブルのフレームだけと言わず物理メモリ全体を対応付けてしまえば解決します:

The same figure as for the offset mapping, but every physical frame has a mapping (at 10TiB + X) instead of only page table frames.

この方法を使えば、私達のカーネルは他のアドレス空間を含め任意の物理メモリにアクセスできます。用意する仮想メモリの範囲は以前と同じであり、違うのは全てのページが対応付けられているということです。

この方法の欠点は、物理メモリへの対応付けを格納するために、追加でページテーブルが必要になるところです。これらのページテーブルもどこかに格納されなければならず、したがって物理メモリの一部を占有することになります。これはメモリの量が少ないデバイスにおいては問題となりえます。

しかし、x86_64においては、通常の4KiBサイズのページに代わって、大きさ2MiBのhuge pageを対応付けに使うことができます。こうすれば、例えば32GiBの物理メモリを対応付けるのにはレベル3テーブル1個とレベル2テーブル32個があればいいので、たったの132KiBしか必要ではありません。huge pagesは、トランスレーション・ルックアサイド・バッファ (TLB) のエントリをあまり使わないので、キャッシュ的にも効率が良いです。

一時的な対応関係

物理メモリの量が非常に限られたデバイスについては、アクセスする必要があるときだけページテーブルのフレームを一時的に対応づけるという方法が考えられます。そのような一時的な対応を作りたいときには、たった一つだけ恒等対応させられたレベル1テーブルがあれば良いです

A virtual and a physical address space with an identity mapped level 1 table, which maps its 0th entry to the level 2 table frame, thereby mapping that frame to page with address 0

この図におけるレベル1テーブルは仮想アドレス空間の最初の2MiBを制御しています。なぜなら、このテーブルにはCR3レジスタから始めて、レベル4、3、2のページテーブルの0番目のエントリを辿ることで到達できるからです。その8番目のエントリは、アドレス32KiBの仮想アドレスページをアドレス32KiBの物理アドレスページに対応付けるので、レベル1テーブル自体を恒等対応させています。この図ではその恒等対応を32KiBのところの横向きの(茶色の)矢印で表しています。

恒等対応させたレベル1テーブルに書き込むことによって、カーネルは最大511個の一時的な対応を作ることができます512から、恒等対応に必要な1つを除く。上の例では、カーネルは2つの一時的な対応を作りました

  • レベル1テーブルの0番目のエントリをアドレス24 KiBのフレームに対応付けることで、破線の矢印で示されているように0 KiBの仮想ページからレベル2ページテーブルの物理フレームへの一時的対応付けを行いました。
  • レベル1テーブルの9番目のエントリをアドレス4 KiBのフレームに対応付けることで、破線の矢印で示されているように36 KiBの仮想ページからレベル4ページテーブルの物理フレームへの一時的対応付けを行いました。

これで、カーネルは0 KiBに書き込むことによってレベル2ページテーブルに、36 KiBに書き込むことによってレベル4ページテーブルにアクセスできるようになりました。

任意のページテーブルに一時的対応付けを用いてアクセスする手続きは以下のようになるでしょう:

  • 恒等対応しているレベル1テーブルのうち、使われていないエントリを探す。
  • そのエントリを私達のアクセスしたいページテーブルの物理フレームに対応付ける。
  • そのエントリに対応付けられている仮想ページを通じて、対象のフレームにアクセスする。
  • エントリを未使用に戻すことで、一時的対応付けを削除する。

この方法では、同じ512個の仮想ページを対応付けを作成するために再利用するため、物理メモリは4KiBしか必要としません。欠点としては、やや面倒であるということが言えるでしょう。特に、新しい対応付けを作る際に複数のページテーブルの変更が必要になるかもしれず、上の手続きを複数回繰り返さなくてはならないかもしれません。

再帰的ページテーブル

他に興味深いアプローチとして、再帰的にページテーブルを対応付ける方法があり、この方法では追加のページテーブルは一切不要です。発想としては、レベル4ページテーブルのエントリのどれかをレベル4ページテーブル自体に対応付けるのです。こうすることにより、仮想アドレス空間の一部を予約しておき、現在及び将来のあらゆるページテーブルフレームをその空間に対応付けているのと同じことになります。

これがうまく行く理由を説明するために、例を見てみましょう:

An example 4-level page hierarchy with each page table shown in physical memory. Entry 511 of the level 4 page is mapped to frame 4KiB, the frame of the level 4 table itself.

この記事の最初での例との唯一の違いは、レベル4テーブルの511番目に、物理フレーム4 KiBすなわちレベル4テーブル自体のフレームに対応付けられたエントリが追加されていることです。

CPUにこのエントリを辿らせるようにすると、レベル3テーブルではなく、そのレベル4テーブルに再び到達します。これは再帰関数自らを呼び出す関数に似ているので、再帰的 (recursive) ページテーブルと呼ばれます。CPUはレベル4テーブルのすべてのエントリはレベル3テーブルを指していると思っているので、CPUはいまレベル4テーブルをレベル3テーブルとして扱っているということに注目してください。これがうまく行くのは、x86_64においてはすべてのレベルのテーブルが全く同じレイアウトを持っているためです。

実際に変換を始める前に、この再帰エントリを1回以上たどることで、CPUのたどる階層の数を短くできます。例えば、一度再帰エントリを辿ったあとでレベル3テーブルに進むと、CPUはレベル3テーブルをレベル2テーブルだと思い込みます。同様に、レベル2テーブルをレベル1テーブルだと、レベル1テーブルを対応付けられた物理フレームだと思います。CPUがこれを物理フレームだと思っているということは、レベル1ページテーブルを読み書きできるということを意味します。下の図はこの5回の変換ステップを示しています

The above example 4-level page hierarchy with 5 arrows: "Step 0" from CR4 to level 4 table, "Step 1" from level 4 table to level 4 table, "Step 2" from level 4 table to level 3 table, "Step 3" from level 3 table to level 2 table, and "Step 4" from level 2 table to level 1 table.

同様に、変換の前に再帰エントリを2回たどることで、階層移動の回数を2回に減らせます

The same 4-level page hierarchy with the following 4 arrows: "Step 0" from CR4 to level 4 table, "Steps 1&2" from level 4 table to level 4 table, "Step 3" from level 4 table to level 3 table, and "Step 4" from level 3 table to level 2 table.

ステップごとにこれを見てみましょうまず、CPUはレベル4テーブルの再帰エントリをたどり、レベル3テーブルに着いたと思い込みます。同じ再帰エントリを再びたどり、レベル2テーブルに着いたと考えます。しかし実際にはまだレベル4テーブルから動いていません。CPUが異なるエントリをたどると、レベル3テーブルに到着するのですが、CPUはレベル1にすでにいるのだと思っています。そのため、次のエントリはレベル2テーブルを指しているのですが、CPUは対応付けられた物理フレームを指していると思うので、私達はレベル2テーブルを読み書きできるというわけです。

レベル3や4のテーブルにアクセスするのも同じやり方でできます。レベル3テーブルにアクセスするためには、再帰エントリを3回たどることでCPUを騙し、すでにレベル1テーブルにいると思い込ませます。そこで別のエントリをたどりレベル3テーブルに着くと、CPUはそれを対応付けられたフレームとして扱います。レベル4テーブル自体にアクセスするには、再帰エントリを4回辿ればCPUはそのレベル4テーブル自体を対応付けられたフレームとして扱ってくれるというわけです下の青紫の矢印

The same 4-level page hierarchy with the following 3 arrows: "Step 0" from CR4 to level 4 table, "Steps 1,2,3" from level 4 table to level 4 table, and "Step 4" from level 4 table to level 3 table. In blue the alternative "Steps 1,2,3,4" arrow from level 4 table to level 4 table.

この概念を理解するのは難しいかもしれませんが、実際これは非常にうまく行くのです。

下のセクションでは、再帰エントリをたどるための仮想アドレスを構成する方法について説明します。私達のOSの実装には再帰的ページングは使わないので、これを読まずに記事の続きを読み進めても構いません。もし興味がおありでしたら、下の「アドレス計算」をクリックして展開してください。


アドレス計算

実際の変換の前に再帰的移動を1回または複数回行うことですべての階層のテーブルにアクセスできるということを見てきました。4つのテーブルそれぞれのどのインデクスが使われるかは仮想アドレスから直接計算されていましたから、再帰エントリを使うためには特別な仮想アドレスを作り出す必要があります。ページテーブルのインデクスは仮想アドレスから以下のように計算されていたことを思い出してください

Bits 0–12 are the page offset, bits 12–21 the level 1 index, bits 21–30 the level 2 index, bits 30–39 the level 3 index, and bits 39–48 the level 4 index

あるページを対応付けているレベル1テーブルにアクセスしたいとします。上で学んだように、このためには再帰エントリを1度辿ってからレベル432のインデクスへと続けていく必要があります。これをするために、それぞれのアドレスブロックを一つ右にずらし、レベル4のインデクスがあったところに再帰エントリのインデクスをセットします

Bits 0–12 are the offset into the level 1 table frame, bits 12–21 the level 2 index, bits 21–30 the level 3 index, bits 30–39 the level 4 index, and bits 39–48 the index of the recursive entry

そのページのレベル2テーブルにアクセスしたい場合、それぞれのブロックを2つ右にずらし、レベル4と3のインデクスがあったところに再帰エントリのインデクスをセットします

Bits 0–12 are the offset into the level 2 table frame, bits 12–21 the level 3 index, bits 21–30 the level 4 index, and bits 30–39 and bits 39–48 are the index of the recursive entry

レベル3テーブルにアクセスする場合、それぞれのブロックを3つ右にずらし、レベル432のインデクスがあったところに再帰インデクスを使います

Bits 0–12 are the offset into the level 3 table frame, bits 12–21 the level 4 index, and bits 21–30, bits 30–39 and bits 39–48 are the index of the recursive entry

最後に、レベル4テーブルにはそれぞれのブロックを4ブロックずらし、オフセットを除いてすべてのアドレスブロックに再帰インデクスを使うことでアクセスできます

Bits 0–12 are the offset into the level l table frame and bits 12–21, bits 21–30, bits 30–39 and bits 39–48 are the index of the recursive entry

これで、4つの階層すべてのページテーブルの仮想アドレスを計算できます。また、インデクスをページテーブルエントリのサイズ倍、つまり8倍することによって、特定のページテーブルエントリを指すアドレスを計算できます。

下の表は、それぞれの種類のフレームにアクセスするためのアドレス構造をまとめたものです:

……の仮想アドレス アドレス構造(8進
ページ 0o_SSSSSS_AAA_BBB_CCC_DDD_EEEE
レベル1テーブルエントリ 0o_SSSSSS_RRR_AAA_BBB_CCC_DDDD
レベル2テーブルエントリ 0o_SSSSSS_RRR_RRR_AAA_BBB_CCCC
レベル3テーブルエントリ 0o_SSSSSS_RRR_RRR_RRR_AAA_BBBB
レベル4テーブルエントリ 0o_SSSSSS_RRR_RRR_RRR_RRR_AAAA

ただし、AAAがレベル4インデクス、BBBがレベル3インデクス、CCCがレベル2インデクス、DDDが対応付けられたフレームのレベル1インデクス、EEEがオフセットです。RRRが再帰エントリのインデクスです。インデクス3ケタをオフセット4ケタに変換するときは、8倍ページテーブルエントリのサイズ倍しています。

SSSSSは符号拡張ビットで、すなわち47番目のビットのコピーです。これはx86_64におけるアドレスの特殊な要求の一つです。これは前回の記事で説明しました。

8進数を用いたのは、8進数の1文字が3ビットを表すため、9ビットからなるそれぞれのページテーブルをきれいに分けることができるためです。4ビットからなる16進ではこうはいきません。

Rustのコードでは……

これらのアドレスをRustのコードで構成するには、ビット演算を用いるとよいです

// この仮想アドレスに対応するページテーブルにアクセスしたい
let addr: usize = [];

let r = 0o777; // 再帰インデクス
let sign = 0o177777 << 48; // 符号拡張

// 変換したいアドレスのページテーブルインデクスを取得する
let l4_idx = (addr >> 39) & 0o777; // レベル4インデクス
let l3_idx = (addr >> 30) & 0o777; // レベル3インデクス
let l2_idx = (addr >> 21) & 0o777; // レベル2インデクス
let l1_idx = (addr >> 12) & 0o777; // レベル1インデクス
let page_offset = addr & 0o7777;

// テーブルアドレスを計算する
let level_4_table_addr =
    sign | (r << 39) | (r << 30) | (r << 21) | (r << 12);
let level_3_table_addr =
    sign | (r << 39) | (r << 30) | (r << 21) | (l4_idx << 12);
let level_2_table_addr =
    sign | (r << 39) | (r << 30) | (l4_idx << 21) | (l3_idx << 12);
let level_1_table_addr =
    sign | (r << 39) | (l4_idx << 30) | (l3_idx << 21) | (l2_idx << 12);

上のコードは、レベル4エントリの最後インデクス0o777すなわち511が再帰対応していると仮定しています。この仮定は正しくないのでこのコードは動作しません。ブートローダに再帰対応付けを設定させる方法については後述します。

ビット演算を自前で行う代わりに、x86_64クレートのRecursivePageTable型を使うこともできます。これは様々なページ操作の安全な抽象化を提供します。例えば、以下のコードは仮想アドレスを対応付けられた物理アドレスに変換する方法を示しています。

// in src/memory.rs

use x86_64::structures::paging::{Mapper, Page, PageTable, RecursivePageTable};
use x86_64::{VirtAddr, PhysAddr};

/// レベル4アドレスからRecursivePageTableインスタンスをつくる
let level_4_table_addr = [];
let level_4_table_ptr = level_4_table_addr as *mut PageTable;
let recursive_page_table = unsafe {
    let level_4_table = &mut *level_4_table_ptr;
    RecursivePageTable::new(level_4_table).unwrap();
}


/// 与えられた仮想アドレスの物理アドレスを取得する
let addr: u64 = []
let addr = VirtAddr::new(addr);
let page: Page = Page::containing_address(addr);

// 変換を実行する
let frame = recursive_page_table.translate_page(page);
frame.map(|frame| frame.start_address() + u64::from(addr.page_offset()))

繰り返しになりますが、このコード(が正しく実行される)には正しい再帰対応がなされていることが必要となります。この対応付けがあるのなら、空欄になっているlevel_4_table_addrは最初のコード例を使って計算すればよいです。


再帰的ページングは、ページテーブルのたった一つの対応付けがいかに強力に使えるかを示す興味深いテクニックです。比較的実装するのが簡単であり、ほとんど設定も必要でない(一つ再帰エントリを作るだけ)ので、ページングを使って最初に実装するのに格好の対象でしょう。

しかし、いくつか欠点もあります:

  • 大量の仮想メモリ領域512GiBを占有してしまう。私達の使っている48bitアドレス空間は巨大なのでこのことはさしたる問題にはなりませんが、キャッシュの挙動が最適でなくなってしまうかもしれません。
  • 現在有効なアドレス空間にしか簡単にはアクセスできない。他のアドレス空間にアクセスするのは再帰エントリを変更することで可能ではあるものの、もとに戻すためには一時的対応付けが必要。これを行う方法についてはカーネルをリマップする(未訳、また旧版のため情報が古い)という記事を読んでください。
  • x86のページテーブルの方式に強く依存しており、他のアーキテクチャでは動作しないかもしれない。

ブートローダによる補助

これらのアプローチはすべて、準備のためにページテーブルに対する修正が必要になります。例えば、物理メモリへの対応付けを作ったり、レベル4テーブルのエントリを再帰的に対応付けたりなどです。問題は、これらの必要な対応付けを作るためには、すでにページテーブルにアクセスできるようになっていなければいけないということです。

つまり、私達のカーネルが使うページテーブルを作っている、ブートローダの手助けが必要になるということです。ブートローダはページテーブルにアクセスできますから、私達の必要とするどんな対応付けも作れます。bootloaderクレートは上の2つのアプローチをどちらもサポートしており、現在の実装においてはcargoのfeaturesを使ってこれらをコントロールします。

  • map_physical_memory featureを使うと、全物理メモリを仮想アドレス空間のどこかに対応付けます。そのため、カーネルはすべての物理メモリにアクセスでき、上で述べた方法に従って物理メモリ全体を対応付けることができます。
  • recursive_page_table featureでは、ブートローダはレベル4ページテーブルのエントリを再帰的に対応付けます。これによりカーネルは再帰的ページテーブルで述べた方法に従ってページテーブルにアクセスすることができます。

私達のカーネルには、シンプルでプラットフォーム非依存かつページテーブルのフレームでないメモリにもアクセスできるのでより強力である1つ目の方法を採ることにします。必要なブートローダの機能 (feature) を有効化するために、map_physical_memory featureをbootloaderのdependencyに追加します。

[dependencies]
bootloader = { version = "0.9.8", features = ["map_physical_memory"]}

この機能を有効化すると、ブートローダは物理メモリの全体を、ある未使用の仮想アドレス空間に対応付けます。この仮想アドレスの範囲をカーネルに伝えるために、ブートローダはboot information構造体を渡します。

Boot Information

bootloaderクレートは、カーネルに渡されるすべての情報を格納するBootInfo構造体を定義しています。この構造体はまだ開発の初期段階にあり、将来の対応していないsemverのブートローダのバージョンに更新した際には、うまく動かなくなることが予想されます。map_physical_memory featureが有効化されているので、いまこれはmemory_mapphysical_memory_offsetという2つのフィールドを持っています

  • memory_mapフィールドは、利用可能な物理メモリの情報の概要を保持しています。システムの利用可能な物理メモリがどのくらいかや、どのメモリ領域がVGAハードウェアのようなデバイスのために予約されているかをカーネルに伝えます。これらのメモリ対応付けはBIOSやUEFIファームウェアから取得できますが、それが可能なのはブートのごく初期に限られます。そのため、これらをカーネルが後で取得することはできないので、ブートローダによって提供する必要があるわけです。このメモリ対応付けは後で必要となります。
  • physical_memory_offsetは、物理メモリの対応付けの始まっている仮想アドレスです。このオフセットを物理アドレスに追加することによって、対応する仮想アドレスを得られます。これによって、カーネルから任意の物理アドレスにアクセスできます。

ブートローダはBootInfo構造体を_start関数の&'static BootInfo引数という形でカーネルに渡します。この引数は私達の関数ではまだ宣言していなかったので追加します:

// in src/main.rs

use bootloader::BootInfo;

#[no_mangle]
pub extern "C" fn _start(boot_info: &'static BootInfo) -> ! { // 新しい引数
    []
}

今までこの引数を無視していましたが、x86_64の呼出し規約は最初の引数をCPUレジスタに渡していたため、これは問題ではありませんでした。つまり、引数が宣言されていなかったとき、それが単に無視されていたわけです。しかし、もし引数の型を間違えてしまうと、コンパイラが私達のエントリポイント関数の正しい型シグネチャがわからなくなってしまうので問題です。

entry_pointマクロ

私達の_start関数はブートローダから外部呼び出しされるので、私達の関数のシグネチャに対する検査は行われません。これにより、この関数はコンパイルエラーなしにあらゆる引数を取ることができるので、いざ実行時にエラーになったり未定義動作を起こしたりしてしまいます。

私達のエントリポイント関数が常にブートローダの期待する正しいシグネチャを持っていることを保証するために、bootloaderクレートはentry_pointマクロによって、Rustの関数を型チェックしたうえでエントリポイントとして定義する方法を提供します。私達のエントリポイント関数をこのマクロを使って書き直してみましょう

// in src/main.rs

use bootloader::{BootInfo, entry_point};

entry_point!(kernel_main);

fn kernel_main(boot_info: &'static BootInfo) -> ! {
    []
}

このマクロがより低レベルな本物の_startエントリポイントを定義してくれるので、extern "C"no_mangleをエントリポイントに使う必要はもうありません。kernel_main関数は今や完全に普通のRustの関数なので、自由に名前をつけることができます。そして重要なのは、この関数は型チェックされているので、間違った関数シグネチャ例えば引数を増やしたり引数の型を変えたりにするとコンパイルエラーが発生するということです。

lib.rsに同じ変更を施しましょう:

// in src/lib.rs

#[cfg(test)]
use bootloader::{entry_point, BootInfo};

#[cfg(test)]
entry_point!(test_kernel_main);

/// `cargo test`のエントリポイント
#[cfg(test)]
fn test_kernel_main(_boot_info: &'static BootInfo) -> ! {
    // 前と同じ
    init();
    test_main();
    hlt_loop();
}

こちらのエントリポイントはテストモードのときにのみ使用するので、#[cfg(test)]属性をすべての要素に付しています。main.rskernel_main関数と混同しないよう、test_kernel_mainという別の名前をつけました。いまのところBootInfo引数は使わないので、引数名の先頭に_をつけることでunused variable (未使用変数) 警告が出てくるのを防いでいます。

実装

物理メモリへのアクセスができるようになったので、いよいよページテーブルのコードを実装できます。そのためにまず、現在有効な、私達のカーネルが使用しているページテーブルを見てみます。次に、与えられた仮想アドレスが対応付けられている物理アドレスを返す変換関数を作ります。最後に新しい対応付けを作るためにページテーブルを修正してみます。

始める前に、memoryモジュールを作ります:

// in src/lib.rs

pub mod memory;

また、このモジュールに対応するファイルsrc/memory.rsを作ります。

ページテーブルにアクセスする

前の記事の最後で、私達のカーネルの実行しているページテーブルを見てみようとしましたが、CR3レジスタの指す物理フレームにアクセスすることができなかったためそれはできませんでした。この続きとして、active_level_4_tableという、現在有効 (アクティブ) なレベル4ページテーブルへの参照を返す関数を定義するところから始めましょう

// in src/memory.rs

use x86_64::{
    structures::paging::PageTable,
    VirtAddr,
};

/// 有効なレベル4テーブルへの可変参照を返す。
///
/// この関数はunsafeである全物理メモリが、渡された
/// `physical_memory_offset`(だけずらしたうえ)で
/// 仮想メモリへと対応付けられていることを呼び出し元が
/// 保証しなければならない。また、`&mut`参照が複数の
/// 名称を持つこと (mutable aliasingといい、動作が未定義)
/// につながるため、この関数は一度しか呼び出してはならない。
pub unsafe fn active_level_4_table(physical_memory_offset: VirtAddr)
    -> &'static mut PageTable
{
    use x86_64::registers::control::Cr3;

    let (level_4_table_frame, _) = Cr3::read();

    let phys = level_4_table_frame.start_address();
    let virt = physical_memory_offset + phys.as_u64();
    let page_table_ptr: *mut PageTable = virt.as_mut_ptr();

    &mut *page_table_ptr // unsafe
}

まず、有効なレベル4テーブルの物理フレームをCR3レジスタから読みます。その開始物理アドレスを取り出し、u64に変換し、physical_memory_offsetに足すことでそのページテーブルフレームに対応する仮想アドレスを得ます。最後に、as_mut_ptrメソッドを使ってこの仮想アドレスを*mut PageTable生ポインタに変換し、これから&mut PageTable参照を作りますここがunsafe&参照ではなく&mut参照にしているのは、後でこのページテーブルを変更するためです。

Rustはunsafe fnの中身全体を大きなunsafeブロックであるかのように扱うので、ここでunsafeブロックを使う必要はありません。これでは、unsafeを意図した最後の行より前の行に間違ってunsafeな操作を書いても気づけないので、コードがより危険になります。また、どこがunsafeな操作であるのかを探すのも非常に難しくなります。そのため、この挙動を変更するRFCが提案されています。

この関数を使って、レベル4テーブルのエントリを出力してみましょう

// in src/main.rs

fn kernel_main(boot_info: &'static BootInfo) -> ! {
    use blog_os::memory::active_level_4_table;
    use x86_64::VirtAddr;

    println!("Hello World{}", "!");
    blog_os::init();

    let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
    let l4_table = unsafe { active_level_4_table(phys_mem_offset) };

    for (i, entry) in l4_table.iter().enumerate() {
        if !entry.is_unused() {
            println!("L4 Entry {}: {:?}", i, entry);
        }
    }

    // as before
    #[cfg(test)]
    test_main();

    println!("It did not crash!");
    blog_os::hlt_loop();
}

まず、BootInfo構造体のphysical_memory_offsetVirtAddrに変換し、active_level_4_table関数に渡します。つぎにiter関数を使ってページテーブルのエントリをイテレートし、enumerateコンビネータをつかってそれぞれの要素にインデックスiを追加します。全512エントリを出力すると画面に収まらないので、 (から) でないエントリのみ出力します。

実行すると、以下の出力を得ます:

QEMU printing entry 0 (0x2000, PRESENT, WRITABLE, ACCESSED), entry 1 (0x894000, PRESENT, WRITABLE, ACCESSED, DIRTY), entry 31 (0x88e000, PRESENT, WRITABLE, ACCESSED, DIRTY), entry 175 (0x891000, PRESENT, WRITABLE, ACCESSED, DIRTY), and entry 504 (0x897000, PRESENT, WRITABLE, ACCESSED, DIRTY)

いくつかの空でないエントリがあり、いずれも異なるレベル3テーブルに対応づけられていることがわかります。このようにたくさんの領域があるのは、カーネルコード、カーネルスタック、物理メモリ対応、ブート情報が互いに離れたメモリ領域を使っているためです。

ページテーブルを更に辿りレベル3テーブルを見るには、エントリに対応するフレームを取り出し再び仮想アドレスに変換すればよいです

// src/main.rsのforループ内にて……

use x86_64::structures::paging::PageTable;

if !entry.is_unused() {
    println!("L4 Entry {}: {:?}", i, entry);

    // このエントリから物理アドレスを得て、それを変換する
    let phys = entry.frame().unwrap().start_address();
    let virt = phys.as_u64() + boot_info.physical_memory_offset;
    let ptr = VirtAddr::new(virt).as_mut_ptr();
    let l3_table: &PageTable = unsafe { &*ptr };

    // レベル3テーブルの空でないエントリを出力する
    for (i, entry) in l3_table.iter().enumerate() {
        if !entry.is_unused() {
            println!("  L3 Entry {}: {:?}", i, entry);
        }
    }
}

レベル2やレベル1のテーブルも、同じ手続きをレベル3とレベル2のエントリに対して繰り返すことで見ることができます。お察しの通りそれを書くとかなり長くなるので、コードの全てはここには示しません。

ページテーブルを手作業で辿ると、CPUが変換を行う仕組みを理解できて面白いです。しかし、多くの場合は与えられた仮想アドレスに対応する物理アドレスにのみ興味があるので、そのための関数を作りましょう。

アドレスの変換

仮想アドレスを物理アドレスに変換するには、4層のページテーブルを辿って対応するフレームにたどり着けばよいです。この変換を行う関数を作りましょう

// in src/memory.rs

use x86_64::PhysAddr;

/// 与えられた仮想アドレスを対応する物理アドレスに変換し、
/// そのアドレスが対応付けられていないなら`None`を返す。
///
/// この関数はunsafeである。なぜなら、呼び出し元は全物理メモリが与えられた
/// `physical_memory_offset`(だけずらした上)で対応付けられていることを
/// 保証しなくてはならないからである。
pub unsafe fn translate_addr(addr: VirtAddr, physical_memory_offset: VirtAddr)
    -> Option<PhysAddr>
{
    translate_addr_inner(addr, physical_memory_offset)
}

unsafeの範囲を制限するために、この関数は、すぐにunsafeでないtranslate_addr_inner関数に制御を渡しています。先に述べたように、Rustはunsafeな関数の全体をunsafeブロックとして扱ってしまいます。呼び出した非公開の (プライベートな) unsafeでない関数の中にコードを書くことで、それぞれのunsafeな操作を明確にします。

非公開な内部の関数に本当の実装を書いていきます:

// in src/memory.rs

/// `translate_addr`により呼び出される非公開関数。
///
/// Rustはunsafeな関数の全体をunsafeブロックとして扱ってしまうので、
/// unsafeの範囲を絞るためにこの関数はunsafeにしていない。
/// この関数をモジュール外から呼び出すときは、
/// unsafeな関数`translate_addr`を使って呼び出すこと。
fn translate_addr_inner(addr: VirtAddr, physical_memory_offset: VirtAddr)
    -> Option<PhysAddr>
{
    use x86_64::structures::paging::page_table::FrameError;
    use x86_64::registers::control::Cr3;

    // 有効なレベル4フレームをCR3レジスタから読む
    let (level_4_table_frame, _) = Cr3::read();

    let table_indexes = [
        addr.p4_index(), addr.p3_index(), addr.p2_index(), addr.p1_index()
    ];
    let mut frame = level_4_table_frame;

    // 複数層のページテーブルを辿る
    for &index in &table_indexes {
        // フレームをページテーブルの参照に変換する
        let virt = physical_memory_offset + frame.start_address().as_u64();
        let table_ptr: *const PageTable = virt.as_ptr();
        let table = unsafe {&*table_ptr};

        // ページテーブルエントリを読んで、`frame`を更新する
        let entry = &table[index];
        frame = match entry.frame() {
            Ok(frame) => frame,
            Err(FrameError::FrameNotPresent) => return None,
            Err(FrameError::HugeFrame) => panic!("huge pages not supported"),
                                                //huge pageはサポートしていません
        };
    }

    // ページオフセットを足すことで、目的の物理アドレスを計算する
    Some(frame.start_address() + u64::from(addr.page_offset()))
}

先程作ったactive_level_4_table関数を再利用せず、CR3レジスタからレベル4フレームを読み出すコードを再び書いています。これは簡単に試作するためであり、後でもっと良い方法で作り直すのでご心配なく。

Virtaddr構造体には、仮想メモリのインデクスから4つの階層のページテーブルを計算してくれるメソッドが備わっています。この4つのインデクスを配列に格納することで、これらをforループを使って辿ります。forループを抜けたら、最後に計算したframeを覚えているので、物理アドレスを計算できます。このframeは、forループの中ではページテーブルのフレームを指していて、最後のループのあとすなわちレベル1エントリを辿ったあとでは対応する物理フレームを指しています。

ループの中では、前と同じようにphysical_memory_offsetを使ってフレームをページテーブルの参照に変換します。次に、そのページテーブルのエントリを読み、PageTableEntry::frame関数を使って対応するフレームを取得します。もしエントリがフレームに対応付けられていなければNoneを返します。もしエントリが2MiBや1GiBのhuge pageに対応付けられていたら、今のところはpanicすることにします。

いくつかのアドレスを変換して、この変換関数がうまく行くかテストしてみましょう:

// in src/main.rs

fn kernel_main(boot_info: &'static BootInfo) -> ! {
    // 新しいインポート
    use blog_os::memory::translate_addr;

    [] // hello world と blog_os::init

    let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);

    let addresses = [
        // 恒等対応しているVGAバッファのページ
        0xb8000,
        // コードページのどこか
        0x201008,
        // スタックページのどこか
        0x0100_0020_1a10,
        // 物理アドレス "0" に対応付けられている仮想アドレス
        boot_info.physical_memory_offset,
    ];

    for &address in &addresses {
        let virt = VirtAddr::new(address);
        let phys = unsafe { translate_addr(virt, phys_mem_offset) };
        println!("{:?} -> {:?}", virt, phys);
    }

    [] // test_main(), "it did not crash" の出力, および hlt_loop()
}

実行すると、以下の出力を得ます:

0xb8000 -> 0xb8000, 0x201008 -> 0x401008, 0x10000201a10 -> 0x279a10, "panicked at 'huge pages not supported'

期待したとおり、恒等対応しているアドレス0xb8000は同じ物理アドレスに変換されました。コードページとスタックページは物理アドレスのどこかしかに変換されていますが、その場所はブートローダがカーネルの初期対応づけをどのようにつくったかによります。また、下から12ビットは変換のあとも常に同じであるということも注目に値しますこの部分はページオフセットであり、変換には関わらないためです。

それぞれの物理アドレスはphysical_memory_offsetを足すことでアクセスできるわけですから、physical_memory_offset自体を変換すると物理アドレス0を指すはずです。しかし、効率よく対応付けを行うためにここではhuge pageが使われており、これはまだサポートしていないので変換には失敗しています。

OffsetPageTableを使う

仮想アドレスから物理アドレスへの変換はOSのカーネルがよく行うことですから、x86_64クレートはそのための抽象化を提供しています。この実装はすでにhuge pageやtranslate_addr以外の様々な関数もサポートしているので、以下ではhuge pageのサポートを自前で実装する代わりにこれを使うことにします。

この抽象化の基礎となっているのは、様々なページテーブル対応付け関数を定義している2つのトレイトです。

  • Mapperトレイトはページサイズを型引数とする汎用型 (ジェネリクス) で、ページに対して操作を行う関数を提供します。例えば、translate_pageは与えられたページを同じサイズのフレームに変換し、map_toはページテーブルに新しい対応付けを作成します。
  • Translate トレイトはtranslate_addrや一般のtranslateのような、さまざまなページサイズに対して動くような関数を提供します。

これらのトレイトはインターフェイスを定義しているだけであり、その実装は何一つ提供していません。x86_64クレートは現在、このトレイトを実装する型を異なる要件に合わせて3つ用意しています。OffsetPageTable型は、全物理メモリがあるオフセットで仮想アドレスに対応していることを前提とします。MappedPageTableはもう少し融通が効き、それぞれのページテーブルフレームが(そのフレームから)計算可能な仮想アドレスに対応していることだけを前提とします。最後にRecursivePageTable型は、ページテーブルのフレームに再帰的ページテーブルを使ってアクセスするときに使えます。

私達の場合、ブートローダは全物理メモリをphysical_memory_offset変数で指定された仮想アドレスで物理メモリに対応付けているので、OffsetPageTable型が使えます。これを初期化するために、memoryモジュールに新しくinit関数を作りましょう:

use x86_64::structures::paging::OffsetPageTable;

/// 新しいOffsetPageTableを初期化する。
///
/// この関数はunsafeである全物理メモリが、渡された
/// `physical_memory_offset`(だけずらしたうえ)で
/// 仮想メモリへと対応付けられていることを呼び出し元が
/// 保証しなければならない。また、`&mut`参照が複数の
/// 名称を持つこと (mutable aliasingといい、動作が未定義)
/// につながるため、この関数は一度しか呼び出してはならない。
pub unsafe fn init(physical_memory_offset: VirtAddr) -> OffsetPageTable<'static> {
    let level_4_table = active_level_4_table(physical_memory_offset);
    OffsetPageTable::new(level_4_table, physical_memory_offset)
}

// これは非公開にする
unsafe fn active_level_4_table(physical_memory_offset: VirtAddr)
    -> &'static mut PageTable
{}

この関数はphysical_memory_offsetを引数としてとり、'staticライフタイムを持つOffsetPageTableを作って返します。このライフタイムは、私達のカーネルが実行している間この実体 (インスタンス) はずっと有効であるという意味です。関数の中ではまずactive_level_4_table関数を呼び出し、レベル4ページテーブルへの可変参照を取得します。次にOffsetPageTable::new関数をこの参照を使って呼び出します。このnew関数の第二引数には、物理メモリの対応付けの始まる仮想アドレスが入ることになっています。つまりphysical_memory_offsetです。

可変参照が複数の名称を持つと未定義動作を起こす可能性があるので、今後active_level_4_table関数はinit関数から一度呼び出されることを除いては呼び出されてはなりません。そのため、pub指定子を外してこの関数を非公開にしています。

これで、自前のmemory::translate_addr関数の代わりにTranslate::translate_addrメソッドを使うことができます。これにはkernel_mainを数行だけ書き換えればよいです:

// in src/main.rs

fn kernel_main(boot_info: &'static BootInfo) -> ! {
    // インポートが追加・変更されている
    use blog_os::memory;
    use x86_64::{structures::paging::Translate, VirtAddr};

    [] // hello worldとblog_os::init

    let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
    // 追加mapperを初期化
    let mapper = unsafe { memory::init(phys_mem_offset) };

    let addresses = []; // 前と同じ

    for &address in &addresses {
        let virt = VirtAddr::new(address);
        // 追加:`mapper.translate_addr`メソッドを使う
        let phys = mapper.translate_addr(virt);
        println!("{:?} -> {:?}", virt, phys);
    }

    [] // test_main(), "it did not crash" の出力, および hlt_loop()
}

translate_addrメソッドを使うために、それを提供しているTranslateトレイトをインポートする必要があります。

これを実行すると、同じ変換結果が得られますが、今度はhuge pageの変換もうまく行っています

0xb8000 -> 0xb8000, 0x201008 -> 0x401008, 0x10000201a10 -> 0x279a10, 0x18000000000 -> 0x0

想定通り、0xb8000やコード・スタックアドレスの変換結果は自前の変換関数と同じになっています。また、physical_memory_offsetは物理アドレス0x0に対応付けられているのもわかります。

MappedPageTable型の変換関数を使うことで、huge pageをサポートする手間が省けます。またmap_toのような他のページング関数も利用でき、これは次のセクションで使います。

この時点で、自作したmemory::translate_addr関数やmemory::translate_addr_inner関数はもう必要ではないので削除して構いません。

新しい対応を作る

これまでページテーブルを見てきましたが、それに対する変更は行っていませんでした。ページテーブルに対する変更として、対応のなかったページに対応を作ってみましょう。

これを実装するにはMapperトレイトのmap_to関数を使うので、この関数について少し見てみましょう。ドキュメントによると四つ引数があります:対応に使うページ、ページを対応させるフレーム、ページテーブルエントリにつかうフラグの集合、そしてframe_allocatorです。フレームアロケータ (frame allocator) (フレームを割り当てる (アロケートする) 機能を持つ)が必要な理由は、与えられたページを対応付けるために追加でページテーブルを作成する必要があるかもしれず、これを格納するためには使われていないフレームが必要となるからです。

create_example_mapping関数

私達が実装していく最初のステップとして、create_example_mapping関数という、与えられた仮想ページを0xb8000すなわちVGAテキストバッファの物理フレームに対応付ける関数を作ってみましょう。このフレームを選んだ理由は、対応付けが正しくなされたかをテストするのが容易だからです対応付けたページに書き込んで、それが画面に現れるか確認するだけでよいのですから。

create_example_mappingは以下のようになります:

// in src/memory.rs

use x86_64::{
    PhysAddr,
    structures::paging::{Page, PhysFrame, Mapper, Size4KiB, FrameAllocator}
};

/// 与えられたページをフレーム`0xb8000`に試しに対応付ける。
pub fn create_example_mapping(
    page: Page,
    mapper: &mut OffsetPageTable,
    frame_allocator: &mut impl FrameAllocator<Size4KiB>,
) {
    use x86_64::structures::paging::PageTableFlags as Flags;

    let frame = PhysFrame::containing_address(PhysAddr::new(0xb8000));
    let flags = Flags::PRESENT | Flags::WRITABLE;

    let map_to_result = unsafe {
        // FIXME: unsafeであり、テストのためにのみ行う
        mapper.map_to(page, frame, flags, frame_allocator)
    };
    map_to_result.expect("map_to failed").flush();
}

この関数は、対応付けるpageに加えOffsetPageTableのインスタンスとframe_allocatorへの可変参照を引数に取ります。frame_allocator引数はimpl Trait構文によりFrameAllocatorトレイトを実装するあらゆる型の汎用型になっています。FrameAllocatorトレイトはPageSizeトレイトを実装するならトレイト引数のサイズが4KiBでも2MiBや1GiBのhuge pageでも構わない汎用 (ジェネリック) トレイトです。私達は4KiBの対応付けのみを作りたいので、ジェネリック引数はSize4KiBにしています。

map_toメソッドは、呼び出し元がフレームはまだ使われていないことを保証しないといけないので、unsafeです。なぜなら、同じフレームを二度対応付けると例えば2つの異なる&mut参照が物理メモリの同じ場所を指すことで未定義動作を起こす可能性があるからです。今回、VGAテキストバッファのフレームという、すでに対応付けられているフレームを再度使っているので、この要件を破ってしまっています。しかしながら、create_example_mapping関数は一時的なテスト関数であり、この記事のあとには取り除かれるので大丈夫です。この危険性のことを忘れないようにするために、その行にFIXME (要修正) コメントをつけておきます。

map_to関数がpageunused_frameに加えてフラグの集合とframe_allocatorへの参照を取りますが、これについてはすぐに説明します。フラグについては、PRESENTフラグという有効なエントリ全てに必須のフラグと、WRITABLEフラグという対応するページを書き込み可能にするフラグをセットしています。フラグの一覧については、前記事のページテーブルの形式を参照してください。

map_to関数は失敗しうるので、Resultを返します。これは失敗しても構わない単なるテストコードなので、エラーが起きたときはexpectを使ってパニックしてしまうことにします。この関数は成功したときMapperFlush型を返します。この型のflushメソッドを使うと、新しく対応させたページをトランスレーション・ルックアサイド・バッファ (TLB) から簡単にflushすることができます。この型はResultと同じく#[must_use]属性を使っており、使用し忘れると警告を出します。

ダミーのFrameAllocator

create_example_mapping関数を呼べるようにするためには、まずFrameAllocatorトレイトを実装する型を作成する必要があります。上で述べたように、このトレイトは新しいページのためのフレームをmap_toが必要としたときに割り当てる役割を持っています。

単純なケースを考えましょう:新しいページテーブルを作る必要がないと仮定してしまいます。この場合、常にNoneを返すフレームアロケータで十分です。私達の対応付け関数をテストするために、そのようなEmptyFrameAllocatorを作ります。

// in src/memory.rs

/// つねに`None`を返すFrameAllocator
pub struct EmptyFrameAllocator;

unsafe impl FrameAllocator<Size4KiB> for EmptyFrameAllocator {
    fn allocate_frame(&mut self) -> Option<PhysFrame> {
        None
    }
}

FrameAllocatorを実装するのはunsafeです。なぜなら、実装する人は、実装したアロケータが未使用のフレームのみ取得することを保証しなければならないからです。さもなくば、例えば二つの仮想ページが同じ物理フレームに対応付けられたときに未定義動作が起こるかもしれません。このEmptyFrameAllocatorNoneしか返さないので、これは問題ではありません。

仮想ページを選ぶ

create_example_mapping関数に渡すための単純なフレームアロケータを手に入れました。しかし、このアロケータは常にNoneを返すので、対応を作る際に追加のページテーブルフレームが必要でなかったときにのみうまく動作します。いつ追加のページテーブルフレームが必要でありいつそうでないのかを知るために、例をとって考えてみましょう:

A virtual and a physical address space with a single mapped page and the page tables of all four levels

この図の左は仮想アドレス空間を、右は物理アドレス空間を、真ん中はページテーブルを示します。このページテーブルが格納されている物理フレームが破線で示されています。仮想アドレス空間は一つの対応付けられたページをアドレス0x803fe00000に持っており、これは青色で示されています。このページをフレームに変換するために、CPUは4層のページテーブルを辿り、アドレス36KiBのフレームに到達します。

また、この図はVGAテキストバッファの物理フレームを赤色で示しています。私達の目的は、create_example_mapping関数を使ってまだ対応付けられていない仮想ページをこのフレームに対応付けることです。私達のEmptyFrameAllocatorは常にNoneを返すので、アロケータからフレームを追加する必要がないように対応付けを作りたいです。これができるかは、私達が対応付けにどの仮想ページを使うかに依存します。

この図の仮想アドレス空間には、2つの候補となるページを黄色で示しています。ページのうち一つはアドレス0x803fe00000で、これは青で示された対応付けられているページの3つ前です。レベル4と3のテーブルのインデクスは青いページと同じですが、レベル2と1のインデクスは違います前の記事を参照。レベル2テーブルのインデクスが違うということは、異なるレベル1テーブルが使われることを意味します。そんなレベル1テーブルは存在しないので、もしこちらを使っていたら、使われていない物理フレームを追加でアロケートする必要が出てきます。対して、2つ目のアドレス0x803fe02000にある候補のページは、青のページと同じレベル1ページテーブルを使うのでこの問題は発生しません。よって、必要となるすべてのページテーブルはすでに存在しています。

まとめると、新しい対応を作るときの難易度は、対応付けようとしている仮想ページに依存するということです。作ろうとしているページのレベル1ページテーブルがすでに存在すると最も簡単で、エントリをそのページに一つ書き込むだけです。ページがレベル3のテーブルすら存在しない領域にある場合が最も難しく、その場合まずレベル321のページテーブルを新しく作る必要があります。

EmptyFrameAllocatorを使ってcreate_example_mappingを呼び出すためには、すべての階層のページテーブルがすでに存在しているページを選ぶ必要があります。そんなページを探すにあたっては、ブートローダが自分自身を仮想アドレス空間の最初の1メガバイトに読み込んでいるということを利用できます。つまり、この領域のすべてのページについて、レベル1テーブルがきちんと存在しているということです。したがって、試しに対応を作るときに、このメモリ領域のいずれかの未使用ページ、例えばアドレス0を使えばよいです。普通このページは、ヌルポインタの参照外しがページフォルトを引き起こすことを保証するために使用しないので、ブートローダもここを対応させてはいないはずです。

対応を作る

というわけで、create_example_mapping関数を呼び出すために必要なすべての引数を手に入れたので、仮想アドレス0を対応付けるようkernel_main関数を変更していきましょう。このページをVGAテキストバッファのフレームに対応付けると、以後、画面に書き込むことができるようになるはずです。実装は以下のようになります

// in src/main.rs

fn kernel_main(boot_info: &'static BootInfo) -> ! {
    use blog_os::memory;
    use x86_64::{structures::paging::Page, VirtAddr}; // 新しいインポート

    [] // hello worldとblog_os::init

    let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
    let mut mapper = unsafe { memory::init(phys_mem_offset) };
    let mut frame_allocator = memory::EmptyFrameAllocator;

    // 未使用のページを対応付ける
    let page = Page::containing_address(VirtAddr::new(0));
    memory::create_example_mapping(page, &mut mapper, &mut frame_allocator);

    // 新しい対応付けを使って、文字列`New!`を画面に書き出す
    let page_ptr: *mut u64 = page.start_address().as_mut_ptr();
    unsafe { page_ptr.offset(400).write_volatile(0x_f021_f077_f065_f04e)};

    [] // test_main(), "it did not crash" printing, および hlt_loop()
}

まず、mapperframe_allocatorインスタンスの可変参照を渡してcreate_example_mappingを呼ぶことで、アドレス0のページに対応を作っています。これはVGAテキストバッファのフレームに対応付けているので、これに書き込んだものは何であれ画面に出てくるはずです。

次にページを生ポインタに変更して、オフセット400に値を書き込みます。このページの最初に書き込むとVGAバッファの一番上の行になり、次のprintlnで即座に画面外に流れていってしまうので、それを避けています。値0x_f021_f077_f065_f04eは、白背景の"New!"という文字列を表します。VGAテキストモードの記事で学んだように、VGAバッファへの書き込みはvolatileでなければならないので、write_volatileメソッドを使っています。

QEMUで実行すると、以下の出力を得ます

QEMU printing "It did not crash!" with four completely white cells in the middle of the screen

画面の "New!" はページ0への書き込みによるものなので、ページテーブルへの新しい対応付けの作成が成功したということを意味します。

この対応付けが成功したのは、アドレス0を管轄するレベル1テーブルがすでに存在していたからに過ぎません。レベル1テーブルがまだ存在しないページを対応付けようとすると、map_to関数は新しいページテーブルを作るためにEmptyFrameAllocatorからフレームを割り当てようとしてエラーになります。0の代わりに0xdeadbeaf000を対応付けようとするとそれが発生するのが見られます。

// in src/main.rs

fn kernel_main(boot_info: &'static BootInfo) -> ! {
    []
    let page = Page::containing_address(VirtAddr::new(0xdeadbeaf000));
    []
}

これを実行すると、以下のエラーメッセージとともにパニックします:

panicked at 'map_to failed: FrameAllocationFailed', /…/result.rs:999:5

レベル1テーブルがまだ存在していないページを対応付けるためには、ちゃんとしたFrameAllocatorを作らないといけません。しかし、どのフレームが未使用で、どのフレームが利用可能かはどうすればわかるのでしょう?

フレームを割り当てる

新しいページテーブルを作成するためには、ちゃんとしたフレームアロケータを作る必要があります。このためには、ブートローダによって渡されるBootInfo構造体の一部であるmemory_mapを使います:

// in src/memory.rs

use bootloader::bootinfo::MemoryMap;

/// ブートローダのメモリマップから、使用可能な
/// フレームを返すFrameAllocator
pub struct BootInfoFrameAllocator {
    memory_map: &'static MemoryMap,
    next: usize,
}

impl BootInfoFrameAllocator {
    /// 渡されたメモリマップからFrameAllocatorを作る。
    ///
    /// この関数はunsafeである呼び出し元は渡された
    /// メモリマップが有効であることを保証しなければ
    /// ならない。特に、`USABLE`なフレームは実際に
    /// 未使用でなくてはならない。
    pub unsafe fn init(memory_map: &'static MemoryMap) -> Self {
        BootInfoFrameAllocator {
            memory_map,
            next: 0,
        }
    }
}

この構造体は2つのフィールドを持ちます。ブートローダによって渡されたメモリマップへの'staticな参照と、アロケータが次に返すべきフレームの番号を覚えておくためのnextフィールドです。

Boot Information節で説明したように、このメモリマップはBIOS/UEFIファームウェアから提供されます。これはブートプロセスのごく初期にのみ取得できますが、ブートローダがそのための関数を既に呼んでくれています。メモリマップはMemoryRegion構造体のリストからなり、この構造体はそれぞれのメモリ領域の開始アドレス、長さ、型(未使用か、予約済みかなど)を格納しています。

init関数はBootInfoFrameAllocatorを与えられたメモリマップで初期化します。nextフィールドは0で初期化し、フレームを割当てるたびに値を増やすことで同じフレームを二度返すことを防ぎます。メモリマップのusable (使用可能) とされているフレームが他のどこかで使われたりしていないかは知ることができないので、このinit関数はそれを呼び出し元に追加で保証させるためにunsafeでないといけません。

usable_framesメソッド

FrameAllocatorトレイトを実装していく前に、渡されたメモリマップをusableなフレームのイテレータに変換する補助メソッドを追加します

// in src/memory.rs

use bootloader::bootinfo::MemoryRegionType;

impl BootInfoFrameAllocator {
    /// メモリマップによって指定されたusableなフレームのイテレータを返す。
    fn usable_frames(&self) -> impl Iterator<Item = PhysFrame> {
        // メモリマップからusableな領域を得る
        let regions = self.memory_map.iter();
        let usable_regions = regions
            .filter(|r| r.region_type == MemoryRegionType::Usable);
        // それぞれの領域をアドレス範囲にmapで変換する
        let addr_ranges = usable_regions
            .map(|r| r.range.start_addr()..r.range.end_addr());
        // フレームの開始アドレスのイテレータへと変換する
        let frame_addresses = addr_ranges.flat_map(|r| r.step_by(4096));
        // 開始アドレスから`PhysFrame`型を作る
        frame_addresses.map(|addr| PhysFrame::containing_address(PhysAddr::new(addr)))
    }
}

この関数はイテレータのコンビネータメソッドを使って、最初に与えられるMemoryMapを使用可能な物理フレームのイテレータに変換します:

  • まずiterメソッドを使ってメモリマップをMemoryRegionのイテレータに変える。
  • 次にfilterメソッドを使って、予約済みなどの理由で使用不可能な領域を飛ばすようにする。ブートローダは作った対応付けに使ったメモリマップはきちんと更新するので、私達のカーネル(コード、データ、スタック)に使われているフレームやブート情報を格納するのに使われているフレームはすでにInUse (使用中) などでマークされています。そのためUsableなフレームは他の場所では使われていないはずとわかります。
  • つぎに、mapコンビネータとRustのrange構文を使って、メモリ領域のイテレータからアドレス範囲のイテレータへと変換する。
  • つぎに、アドレス範囲からstep_byで4096個ごとにアドレスを選び、flat_mapを使うことでフレームの最初のアドレスのイテレータを得る。4096バイト4KiBはページのサイズに等しいので、それぞれのフレームの開始地点のアドレスが得られます。ブートローダのページは使用可能なメモリ領域をすべてアラインするので、ここで改めてアラインや丸めを行う必要はありません。mapではなくflat_mapを使うことで、Iterator<Item = Iterator<Item = u64>>ではなくIterator<Item = u64>を得ています。
  • 最後に、開始アドレスの型をPhysFrameに変更することでIterator<Item = PhysFrame>を得ている。

この関数の戻り型はimpl Trait機能を用いています。こうすると、PhysFrameをitemの型として持つようなIteratorトレイトを実装する何らかの型を返すのだと指定できます。これは重要です――なぜなら、戻り値の型は名前のつけられないクロージャ型に依存し、具体的な名前をつけるのが不可能だからです。

FrameAllocatorトレイトを実装する

これでFrameAllocatorトレイトを実装できます:

// in src/memory.rs

unsafe impl FrameAllocator<Size4KiB> for BootInfoFrameAllocator {
    fn allocate_frame(&mut self) -> Option<PhysFrame> {
        let frame = self.usable_frames().nth(self.next);
        self.next += 1;
        frame
    }
}

まずusable_framesメソッドを使ってメモリマップからusableなフレームのイテレータを得ます。つぎに、Iterator::nth関数でself.next番目の(つまり(self.next - 1)だけ飛ばして)フレームを得ます。このフレームを返してリターンする前に、self.nextを1だけ増やして次の呼び出しで1つ後のフレームが得られるようにします。

この実装は割当てを行うごとにusable_framesアロケータを作り直しているので、最適とは言い難いです。イテレータを構造体のフィールドとして直接格納するほうが良いでしょう。するとnthメソッドを使う必要はなくなり、割り当てのたびにnextを使えばいいだけです。このアプローチの問題は、今の所構造体のフィールドにimpl Trait型(の変数)を格納することができないことです。いつの日か、named existential typeが完全に実装されたときにはこれが可能になるかもしれません。

BootInfoFrameAllocatorを使う

kernel_main関数を修正してEmptyFrameAllocatorのインスタンスの代わりにBootInfoFrameAllocatorを渡しましょう:

// in src/main.rs

fn kernel_main(boot_info: &'static BootInfo) -> ! {
    use blog_os::memory::BootInfoFrameAllocator;
    []
    let mut frame_allocator = unsafe {
        BootInfoFrameAllocator::init(&boot_info.memory_map)
    };
    []
}

ブート情報を使うフレームアロケータのおかげで対応付けは成功し、白背景に黒文字の"New!"が再び画面に現れました。舞台裏では、map_toメソッドが不足しているページテーブルを以下のやり方で作っています:

  • 渡されたframe_allocatorを使って未使用のフレームを割り当ててもらう。
  • フレームをゼロで埋めることで、新しい空のページテーブルを作る。
  • 上位のテーブルのエントリをそのフレームに対応付ける。
  • 次の層で同じことを続ける。

create_example_mapping関数はただのお試しコードにすぎませんが、今や私達は任意のページに対応付けを作れるようになりました。これは、今後の記事で行うメモリ割り当てやマルチスレッディングにおいて不可欠です。

で説明したような未定義動作を誤って引き起こしてしまうことのないよう、この時点でcreate_example_mapping関数を再び取り除いておきましょう。

まとめ

この記事ではページテーブルのある物理フレームにアクセスするための様々なテクニックを学びました。恒等対応、物理メモリ全体の対応付け、一時的な対応、再帰的ページテーブルなどです。このうち、シンプルでポータブル (アーキテクチャ非依存) で強力な、物理メモリ全体の対応付けを選びました。

ページテーブルにアクセスできなければ物理メモリを対応付けられないので、ブートローダの補助が必要でした。bootloaderクレートはcargoのfeaturesというオプションを通じて、必要となる対応付けの作成をサポートしています。さらに、必要となる情報をエントリポイント関数の&BootInfo引数という形で私達のカーネルに渡してくれます。

実装について。最初はページテーブルを辿る変換関数を自分の手で実装し、そのあとでx86_64クレートのMappedPageTable型を使いました。また、ページテーブルに新しい対応を作る方法や、そのために必要なFrameAllocatorをブートローダに渡されたメモリマップをラップすることで作る方法を学びました。

次は?

次の記事では、私達のカーネルのためのヒープメモリ領域を作り、それによってメモリの割り当てを行ったり各種のコレクション型を使うことが可能になります。