OS徒然草 (3)

執筆者 : 小田 逸郎

※ 「OS徒然草」連載記事一覧はこちら


仮想空間

弊社は、Linuxカーネル(等基盤系OSS)の障害解析サービスを提供しています。サービスを提供する相手は、エンドユーザではなく、基盤の運用やサポートを行っている部署の方々となります。そうした方々に障害解析だけでなく、Linuxカーネルの内部構造だとかクラッシュダンプの解析の仕方だとかの勉強会を提供したりすることもあります。そうした方々は、普通のエンジニアに比べれば、コンピュータに詳しいはずなのですが、それでも仮想空間という概念を把握するのには苦労しているように見受けられます。今回は、仮想空間を巡るお話です。仮想空間というものは、OSを語る上で外せないものですので、本稿が、仮想空間を理解するための助けになれば幸いです。

仮想アドレス

基本命令セットの説明にあったように、CPUは、レジスタとメモリの間でロード、ストア命令による値のやりとりを行います。メモリには、バイト単位にアドレスが振られていて、メモリのロード、ストアはアドレスを指定して行います。

多くのコンピュータ言語では、メモリのアドレスを意識することはないです。と言うか、意識したくてもできないです。その点、C言語は便利です。アドレスを指定してアクセスしていることを実感できるプログラム例を以下に示します。

      1 /* SPDX-License-Identifier: BSD-2-Clause
      2  * Copyright(c) 2024 Itsuro Oda
      3  */
      4 #include <stdio.h>
      5 
      6 static int static_var;
      7 
      8 int main(void)
      9 {
     10         int local_var;
     11 
     12         printf("static_var address: %p\n", &static_var);
     13         printf("local_var address: %p\n", &local_var);
     14 
     15         *((int *)0x10280a8) = 10;
     16         printf("static_var: %d\n", static_var);
     17 
     18         return 0;
     19 }

注意: アドレスは、環境(OS、アーキテクチャ、コンパイラ等)によって異なります。

参考: コードは、GitHub(こちら)にも置いてあります。GitHubの方には、本ブログの補足的説明もありますので、参照してください。

実行すると、以下のような出力が得られます。

static_var address: 0x10280a8
local_var address: 0x40008004a4
static_var: 10

static_varという変数がメモリのアドレス(0x10280a8)に割り当てられており、そのアドレスに値を入れている様子が分かるかと思います。念のため、アセンブルコードも確認しましょう。Cプログラムの15行目のコードに当たる部分を取り出してみると、以下のようになっていました。コードの横に説明も加えています。

lui     a1, 4136       # レジスタa1に4136を入れる
li      a0, 10         # レジスタa0に10を入れる
sw      a0, 168(a1)    # メモリの「a1の値 << 12 + 168」番地にa0(==10)の値を入れる

「a1の値 << 12 + 168」は計算してみると、16941224(== 0x10280a8)なので、確かに命令レベルでもメモリの0x10280a8番地に書いていることが確認できました。

さて、問題は、このアドレスが本当のメモリのアドレスなのかどうかです。薄々気が付いてはいると思いますが、このアドレスは本当のアドレス(実アドレス(real address)、もしくは物理アドレス(physical address))ではなく、仮想アドレス(virtual address)なのです。実際にメモリにアクセスしているかというと、それはさすがにアクセスしている訳ですが、一体、どの実アドレスにアクセスしているのでしょうか。

アドレス変換機構

ハードウェアにはアドレス変換機構というものが装備されていて、命令で指定された仮想アドレスを自動的に実アドレスに変換してアクセスするようになっています。アドレス変換機構で使用する変換表を用意するのがOSの役目になります。

ここでは、RISC-VのSV39タイプ*1の仮想メモリシステムを例に取って説明します。

SV39では、仮想アドレスを39bit幅、実アドレスを56bit幅で取り扱います。すなわち、仮想アドレス空間の大きさが512GiB、実アドレス空間の大きさが64PiBということになります。筆者が仮想空間と言っているのは、正確には仮想アドレス空間のことなのですが、面倒なので省略して仮想空間と言っています。

ところで、RISC-Vの仕様書を見ていて気が付いたのですが、メモリの大きさを示すのにKiB、GiBなどとi付きで示しています。これは2のべき乗であることを示していて、例えば、Kiは、10の3乗ではなく、2の10乗であることを示しています。筆者は、慣習的にこれまでずっと、iなしで記述してきました。インテルの仕様書では、iはないですし、どちらかというとiがないのが一般的だったと思います。ディスク容量は、通常、10の累乗の方で表されていて、紛らわしいという話は昔からあったわけで、iを付けておいた方が間違いがないというのはその通りかもしれません。RISC-Vの仕様書に倣い、筆者もiを付けることにしてみました*2。それになんといっても、i(愛)は大事ですもんね。

閑話休題。

RISC-Vに限らず、アドレス空間を4KiB*3のページという単位で取り扱います。ページをアドレス空間の先頭(0番地)から、順番に数えたインデックスをページ番号と言います。仮想ページ番号と実ページ番号の対応をページテーブルという構造で保持します。さて、用語の準備はこれくらいにして、実際のアドレス変換の仕方を見てみましょう。

ページテーブルの構造とアドレス変換

上図を参照するとイメージし易いと思います。仮想アドレスを上位から、9bit、9bit、9bit、12bitで分割し、最初の3つをページテーブルのインデックスと見なします。ページテーブル自体も実ページ(1ページ)です。ページテーブルは、8バイトのページテーブルエントリ(PTE)、256個から成ります。ページテーブルは、図にあるように多段構成となっており、最上位のページテーブルをポイントするコントロールレジスタが存在しています。まず、コントロールレジスタを見て、最上位のページテーブルを把握します。次に仮想アドレスの最上位の9bitをインデックスとみなし、そのインデックスのPTEを見ます。PTEは、次のレベルのページテーブルをポイントしています。次は、仮想アドレスの2番目の9bitをインデックスと見なし、そのページテーブルのそのインデックスのPTEを見ます。同様にして3段目のページテーブルのPTEを見ます。図にleafと書いてありますが、それはポイントしているのがページテーブルではなく、最終的に得たかった実ページであることを意味しています。これで実ページが分かりました。その先頭アドレスに残りの12bit(すなわち、ページ内オフセット)を足せば、得たかった実アドレスとなります。「ポイントする」という言葉を使いましたが、正確には、実ページ番号が格納されています。PTEには、実ページ番号だけでなく、ページのアクセス権などのページに関する制御情報等も入っています。

上の説明をしたついでに、ラージページ(もしくはヒュージページ)の紹介をしておきましょう。上図では、3段目のページテーブルの中のPTEがleafとなっていますが、上位のページテーブルのPTEもleafになれます。2段目のページテーブルのPTEをleafにする場合、2MiB(256ページ)分の連続領域をポイントすることになります。

ラージページの正体

これをラージページと言っている訳です。最上段のページテーブルのPTEをleafにすると、1GiBの連続領域をポイントすることになります。2MiBや1GiBのラージページがサポートされていると言われたとき、どうして、その大きさなのかと言うことが、自然に理解できるようになったかと思います。

プログラムの配置

さて、今回仮想空間の話をしたのは、今後、複数のプログラムを扱う話題に進む前にこの話を済ませたかったからなのです。どういうことかと言うと、アドレス変換機構がなく、実アドレスで全てを扱わなければいけない場合、プログラムをどうメモリに配置するかを考えると、いろいろと頭の痛い問題がでてくるからなのです。以前の回でプログラムのレイアウトを説明しました。プログラムが要するメモリ量を決めるには、ヒープとスタックの間をどれだけ確保しておくかが問題となります。バイナリ作成時にコードとデータのアドレスをどうするかも問題です。固定にすると、その領域が使用されていたらロードできないことになりますし、他のプログラムとかぶらないようにするのも面倒です。単純には、OSが空き領域を見つけて、ロードするという実装になりそうですが、ロード時でないと、アドレスが確定できないので、ロード時にアドレスを設定する(バイナリを書き換える)とか、どこにロードされても良いような位置独立なバイナリを作成するようにするとか、対策を講じる必要があります。また、プログラムから他のプログラムやOSのメモリ領域にアクセスできないようなプロテクションも考える必要があります。

仮想空間の存在を前提とすると、これらの苦労がすべてなくなります。プログラムごとに仮想空間を割り当てることにより、コードやデータのアドレスは予め決めてしまって問題ありません。実メモリへの配置は、ページごとに行えば良いので、複数のプログラムをロードする際も頭を悩ます必要がありません。プロテクションも自然に実現できてしまっています。

仮想空間(アドレス変換機構)は、筆者がOSに関わり始めた当初から存在しているものです。組み込み系のプラットフォームでは、アドレス変換機構のないハードを取り扱うことがあるかもしれません。ただ、筆者が関わったプラットフォームは、メインフレームを手始めにスーパコンピュータやサーバ系なので、仮想空間がないというのは想像が付きません。これからもサーバ系を前提とした話に偏るかもしれませんが、ご了承ください。

さて、ページテーブルの説明が若干込み入っていたかもしれませんが、RISC-Vに限らず、大体どのアーキテクチャでも同様なので、参考になると思います。この構造はともかく、要は、仮想アドレスと実アドレスの対応表(ページ単位)を持っており、その対応表こそが仮想空間の正体だ、ということが理解できればよいです。下の図のようなイメージを持ってもらえれば良いかと思います。

仮想空間のイメージ

仮想空間により、いろいろ便利なことが考えられるようになっています。例えば、共有メモリ機能は、同じ実ページを複数の仮想空間からポイントすることで実装できるということが容易に想像できるかと思います。図の緑色のページがそのイメージです。

オンデマンド

もう一点、プログラムの配置に関わる話題として、オンデマンドページング*4というテクニックを紹介します。仮想空間は広大*5ですが、実際にプログラムが使用している領域は少ないです。使用している部分のみ実ページを割り当てれば良いので、実ページがあるだけ、いくらでもプログラムをロードすることができます。実のところ、プログラムに割り当てる実ページはもっと少なく済ますことができます。プログラムのロード時、実ページを全く割り当てないということが実は可能です。命令を実行する際、命令であれ、データであれ、その仮想アドレスにアクセスしようとすると、アドレス変換に失敗するため、例外が発生し、OSに制御が移ります。OSは、ここで初めて実ページの割り当てとページテーブルの設定を行い、例外が発生した命令から実行を再開させます。再開した命令は今度は仮想アドレスにアクセスできるので、実行に成功します。つまり、実際に使用されたことが分かってから割り当てを行うということです。あらかじめ気を利かせて用意しておくのではなく、要求があって初めて用意するというのが、「オンデマンド」の意味です。この用語もOSに携わるようになってから良く耳にするようになった用語です。はじめて耳にしたのもOSに携わるようになってからだという気がします。

筆者は、毎朝、オフィスで自分のコップにコーヒーを入れて飲んでいました。飲み終わった後にコップを洗う人もいましたが、筆者は飲む前にコップを洗っていました。OSで学んだオンデマンドを実践していたわけです。洗う回数が1回少なくなる可能性があると思っていましたが、実際、古くなってきたため、引っ越しを機会に捨てたので、まさにそのとおりになりました。

閑話休題。

OSに求められることとして、リソースをいかにケチるか、あるいは最大限利用するかとかが、結構重要視される傾向にあります。ですので、これに限らず、いろいろとリソースをケチるためのテクニックが使われていますが、機会があればこれからも紹介していこうと思います。また、仮想空間により、他にも様々なテクニックや面白い話題がありますが、こちらも機を改めて、メモリ管理を話題にする回で触れたいと思います。

実アドレス空間

仮想アドレス空間の話題のついでに実アドレス空間についても少し触れておきます。実アドレス空間は、そのすべてがメモリという訳ではなく、3つのタイプの領域に分けられます。

  • メモリ
    通常のメモリ
  • I/O
    デバイスレジスタ等。
  • 空き
    何もない。アクセスはできない。

例えば、4GiBのメモリを搭載していても、必ずしも、メモリ領域が0番地から始まっている訳でもなく、4GiB分連続している訳でもないです。すなわち、間にI/O領域や空き領域が挟まっている場合もあるということは、認識しておいてください。OSは、プラットフォームから何らかの方法で、領域の構成情報を取得することになります。

I/O領域には、デバイスにアクセスするための領域があります。ある特定のアドレスに何かを書くことにより、あるデバイスに指令を出したり、ある特定のアドレスを読むことにより、あるデバイスの状態を取得したりということができます。その内容はプラットフォームによります。

I/Oについては、アーキテクチャによって異なっており、I/O用の命令が用意されているものもありますが、実アドレス空間上のI/O領域にロード/ストア命令でアクセスするやり方も一般的で、RISC-Vでも後者を採用しています*6。I/O領域についても仮想アドレスでアクセス可能です。

実 vs 物理

さて、本ブログでは、仮想アドレス*7に対し、本物のアドレスのことを実アドレスと書きましたが、本当に本物なのでしょうか。

今ではすっかり当たり前になった仮想化ですが、話題になりだしたのは、2000年代初頭くらいでしょうか。弊社も2006年くらいからしばらくXenの開発に関わっていました*8。実は、メインフレームの世界では、もっと前から仮想化は実装されており、筆者がOS屋になったとき*9には、既に当たり前だったのです。当時からもっぱら、VM上で開発を行っていました。メインフレームは高価なものなので、いくつかのVMに分けて使用するのは、一般のユーザでも普通のことだったのですが、我々OS屋の開発中はシステムクラッシュは当たり前なので、なおのことVM運用が必須と言えます。いくつかのVMを共同利用の開発環境、いくつかのVMを動作確認・テスト用に分けて運用していました。テスト用VMは、各開発チームからの利用申請を元に時間割り当てされるのですが、貴重なマシンリソース故に当然のごとく、24時間制です。人間の都合よりもマシンの都合優先というわけで、結構徹夜もしたものです。

さて、話を元に戻すと、当時、筆者達は実アドレスという用語を使用していました。先輩から、実アドレスは、実は本物のアドレスじゃないんだよね、本物のアドレスは物理アドレスと言うんだよ、と教えられたことを懐かしく思い出しています。Xenが出てきたとき、OSが本物と思っている(けど実は違う)アドレスのことをそのまま物理アドレスと言い、本物のアドレスを指すのにマシンアドレスという用語を捻りだしました。それよりも、筆者達が使っていた、仮想アドレス、実アドレス、物理アドレスの組の方がスマートだな、と思ったものでした。

ハードの仕様書では、物理アドレスを使用していることが多いと思いますが、こちらは本当に本物のアドレスなので、それが自然と言えます。筆者も本当に本物のアドレスを指すときは物理アドレスを使います。OSの話題に関しては、多くの文脈で、物理アドレスという用語で問題ないと思いますが、OSが本物と思っているアドレスが実は本物ではないということもあるということを頭の片隅に置きつつ、筆者としては、慣れ親しんだ実アドレスという用語を使っています。

あとがき

メモリ上へプログラムをどう配置するかを気にする必要がなくなったので、前回のスレッドの実行の話題の続きとして、複数のプログラムを扱う話題に進みたいと思っています。ではまた次回。

*1:RISC-Vでは、仮想メモリシステムのタイプがいくつか用意されています。GitHubを参考にしてください。

*2:とは言え、依然、世の中では付けていないケースも多いので注意は必要です。

*3:筆者は4KiB以外のアーキテクチャを見たことがありません。

*4:デマンドページングともいう。

*5:昔は、確かに搭載しているメモリ容量に比べて大きかったのですが、最近は、そうでもなくなってきました。

*6:本ブログでも、今後I/Oについては、こちらの方式を前提に話をします。

*7:「アドレス」は「ページ」に置き換えても同様です

*8:ハイパーバイザについては、いずれ話題にしたいと思っています。まだ結構先になりそうですが。

*9:40年くらい前