仮想メモリ空間へ!『いちばんやさしい!OS自作超入門』第7章のつまずき解消ガイド  | UNIX Cafe

* 当サイトでは、コンテンツの一部に広告を掲載しています。

System Note $ cat /proc/ai-disclosure

本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。

レトロな黒背景のUNIXターミナル画面を中心にしたブログ用アイキャッチ画像。画面には Simple OS のプロンプト、`taskexec hello_task.bin`、`T0_PT`、ページテーブル、論理アドレス `0x30000` から物理アドレス `0x70000` への変換、`Page Fault has occurred.` の文字が表示されている。周囲に32bit CPUエミュレータ、メモリマップ、仮想メモリの概念を示す控えめな技術図を配置する。 テキストはなし。 小さな喫茶店の一角。 warm brown の木製カウンター、 soft amber lighting の落ち着いた照明。 初心者向けで親しみやすく、 知的で静かな空気感。 少しレトロな UNIX の雰囲気。 背景に人物は無し。 写真とイラストの中間の上品なビジュアル。 軽いアニメ調。 リアルすぎない。 構図はシンプルで、 主題が一目で分かる。 横長16:9 キャラクターの特徴は、次のとおりです。 ミナちゃんは、白と淡いグリーンの花柄シャツ、深いグリーンのカフェエプロン、ショートパンツ、髪に白い花飾り。 ユニ先生は、丸メガネをかけた可愛いペンギン先生。淡いグリーン系の薄い夏用ストールを身につけている。

前々回の第4章では、Simple OSの上でオリジナルコマンドやタイマー割り込みを動かす楽しさを味わいました。続く第5章・第6章でマルチタスクの土台を整え、いよいよ今回は書籍の大きな山場である「第7章:仮想メモリ編」へと突入します。 

第6章までは「どのタスクを動かすか」というCPUの切り替えが中心でしたが、第7章からはさらに一歩進んで、タスクごとに独立した「メモリ空間そのもの」を切り替えるエキサイティングな実装が始まります。タスク1〜3が、すべて同じ「論理アドレス 0x00000」を使いながら別々の物理メモリで平然と動くマジックの舞台裏です。 

しかし、目に見えないメモリ空間を扱うだけに、コードの前提違いやレジスタの罠、アセンブルエラーなど、今回もあちこちに「つまずきポイント」が潜んでいました。 

この記事では、手元の書籍本文とGitHubの最新コードとのギャップを整理しながら、初期PCの罠、ページテーブルの設定、そしてページフォルト発生の仕組みまで、実験ノートのように分かりやすく紐解いていきます。 

一番ハードルが低いOS自作本。「OSってどう動くの?」の疑問がこれ1冊で解ける。
目次

使用環境

本文中のコマンドは、特に断りがない限り、ローカルに取得したos_book_codeのディレクトリで実行する前提です。

この記事では、著者リポジトリのGitHub版コードを手元に置いている前提で書きます。書籍本文とGitHub版コードが、常に同じ段階を前提にしているとは限らない点に注意します。

今回の記事で使う第7章の固定コードは、補助リポジトリの lessons/07-virtual-memory-page-fault/ でも試せるようにしています。

第5章・第6章から第7章への流れ

第5章と第6章では、Simple OS にタスク管理の仕組みを追加します。

第5章では sleep システム関数やタスク状態を扱い、第6章ではタイマー割り込みを使ったタスク切り替えと、仮想コンソールの切り替えを確認します。

この時点では、タスク1からタスク3はそれぞれ別の処理を持っています。OS側が保存している SPPC を切り替えることで、複数のタスクが並行に動いているように見えます。

第7章では、ここに仮想メモリの仕組みが加わります。タスクごとにページテーブルを持たせることで、タスク1からタスク3が同じ論理アドレス 0x00000 からプログラムを実行できるようになります。

つまり、第6章までは「どのタスクを動かすか」を切り替えていましたが、第7章ではさらに「そのタスクから見えるメモリ空間」も切り替えるようになります。

そのため、第7章で確認するポイントは、タスク切り替えそのものよりも、PCPT、ページテーブル、論理アドレスの対応をどう読むかに集中します。

7-1 仮想メモリー機能を有効にする

print_t1_message から print_t3_message の参照も削除する

テキスト P.162

第7章で仮想メモリを有効にするところでは、Task DATA を変更し、これまでタスクごとの表示に使っていた次のラベルを削除します。

この変更は、タスク1からタスク3を「最初からOS内に書いてある表示処理」ではなく、あとで読み込むタスク用プログラムとして動かすための準備です。

print_t1_message:
print_t2_message:
print_t3_message:

ここで注意が必要なのは、Task DATA 側のラベル定義を削除するだけでは足りない点です。ラベルは「ここに命令やデータがあります」という名前なので、定義を消したら、その名前を使っている行も一緒に見直します。

ファイル前半の Task1 Setup から Task3 Setup にも、初期スタックへ積む PC としてこれらのラベルを参照する行が残っています。PC は次に実行する命令の場所なので、ここに消したラベル名が残っていると、アセンブラはジャンプ先のアドレスを決められません。

; Task1 Setup
        MOVI    SP, T1_STACK_BTM
        MOVI    R0, print_t1_message
        PUSH    R0              ; PC

; Task2 Setup
        MOVI    SP, T2_STACK_BTM
        MOVI    R0, print_t2_message
        PUSH    R0              ; PC

; Task3 Setup
        MOVI    SP, T3_STACK_BTM
        MOVI    R0, print_t3_message
        PUSH    R0              ; PC

Task DATA からラベル定義を消した状態で、これらの参照だけが残っていると、asmx.py の実行時に未定義シンボルのエラーになります。未定義シンボルは、「その名前は使われていますが、定義が見つかりません」という意味です。

python asmx.py os.asm
os.asm:14: error: undefined symbol 'print_t1_message'
        MOVI    R0, print_t1_message
                    ^^^^^^^^^^^^^^^^

print_t1_message の行だけを消して再実行すると、次は print_t2_message、その次は print_t3_message が同じ理由でエラーになります。最初に出た1か所だけを見るのではなく、同じ役割のタスク1からタスク3をまとめて確認すると追いやすいです。

os.asm:36: error: undefined symbol 'print_t2_message'
os.asm:58: error: undefined symbol 'print_t3_message'

そのため、仮想メモリ用のタスク初期化に変更するタイミングで、Task1 Setup から Task3 Setup にある次の3行も削除します。

 ; Task1 Setup
         MOVI    SP, T1_STACK_BTM
-        MOVI    R0, print_t1_message
         PUSH    R0              ; PC

 ; Task2 Setup
         MOVI    SP, T2_STACK_BTM
-        MOVI    R0, print_t2_message
         PUSH    R0              ; PC

 ; Task3 Setup
         MOVI    SP, T3_STACK_BTM
-        MOVI    R0, print_t3_message
         PUSH    R0              ; PC

修正後にもう一度アセンブルします。

python asmx.py os.asm

次のように os.bin が出力されれば、この未定義シンボルは解消しています。ここでは実行結果まで確認せず、まず「アセンブルできる状態に戻った」ことを確認します。

Wrote os.bin (1046544 bytes)

7-2 タスクを論理アドレス空間で稼働させる

taskexec で起動したタスクの初期PCに注意する

テキスト P.174

第7章で taskexec hello_task.bin を実行するところでは、タスク1からタスク3として同じプログラムを起動します。ここでは、「ファイルを読み込めたか」と「読み込んだプログラムの先頭から実行できたか」を分けて確認します。

ここで、書籍の図19から図21の Task1 Setup から Task3 Setup をそのまま入力すると、タスク1からタスク3の初期スタックに積む PCidle_loop になります。初期 PC は、そのタスクが最初に実行を再開するアドレスです。

; Task2 Setup
        MOVI    SP, T2_STACK_BTM
        MOVI    R0, idle_loop
        PUSH    R0              ; PC

この状態でも、taskexec コマンド自体はタスクIDを割り当てます。そのため、プロンプトに戻ってくるだけを見ると、一見うまく動いているように見えます。

手元では、次のように3回まではプロンプトに戻り、4回目でタスクIDを割り当てられないことを確認します。

> taskexec hello_task.bin
> taskexec hello_task.bin
> taskexec hello_task.bin
> taskexec hello_task.bin
Could not assign tid.

ただし、この状態では仮想コンソール1から3に切り替えても、Hello, World! は表示されません。タスクIDの割り当てと、タスク本体の実行開始は別の確認ポイントです。

理由は、taskexechello_task.bin を各タスクの論理アドレス 0x00000 に読み込む一方で、タスクの初期 PCidle_loop を指しているためです。つまり、プログラムは読み込まれていますが、タスク切り替え後に読み込んだ先頭アドレスへ飛びません。

初心者向けに言い換えると、hello_task.bin をメモリへ置くところまでは成功しています。しかし、タスクが最初に実行する場所が 0x00000 ではなく idle_loop のままなので、置いたプログラムを実行しません。

書籍175ページでは、この項目の該当ファイルとして、作者リポジトリの v1.1 タグを取得する手順が案内されています。

git fetch
git checkout v1.1

この v1.1os.asm を確認すると、タスク1からタスク3の初期 PCidle_loop ではなく 0 になっています。ここでの 0 は、各タスクから見た論理アドレス 0x00000 です。

; Task2 Setup
        MOVI    SP, T2_STACK_BTM
        MOVI    R0, 0
        PUSH    R0              ; PC

この形にすると、taskexec で読み込まれた hello_task.bin が論理アドレス 0x00000 から実行されます。第7章ではタスクごとにページテーブルを切り替えるため、同じ 0x00000 でも、タスク1、タスク2、タスク3で別々の物理メモリを指せます。

実際に作者リポジトリの v1.1 で確認すると、taskexec hello_task.bin を3回実行したあと、仮想コンソール1から3にそれぞれ Hello, World! が表示されます。4回目は書籍の説明どおり Could not assign tid. になります。

そのため、ここは単純な入力ミスというより、書籍の図19から図21だけを追っていると不足しやすい箇所です。taskexec の動作確認まで進める場合は、書籍175ページで案内されている v1.1Task1 Setup から Task3 Setup に合わせて、初期 PC0 にします。

エミュレータ側にもページテーブルを用意する

テキスト P.166

第7章では、OS側で PT レジスタを使い、タスクごとのページテーブルを参照するようになります。PT には、いま動いているタスクのページテーブルの先頭アドレスを入れます。

ただし、emu.py 側でページテーブルの置き場所が初期化されていないと、タスクを切り替えても期待した論理アドレスから物理メモリへ変換されません。OS側で PT を切り替えても、エミュレータのメモリ上に対応表が置かれていなければ、変換に使う材料がないためです。

そこで、タスクごとのページテーブルアドレスを固定値に変更します。

-task_pt_addr = [ 0, 0, 0, 0 ]
+task_pt_addr = [ 0xFFF00, 0xFFF10, 0xFFF20, 0xFFF30 ]

さらに、エミュレータ起動時に各ページテーブルの内容を書き込むようにします。ここで書き込む内容は、「論理ページ番号を、どの物理ページ番号へ対応させるか」という表です。

書籍168ページに掲載されている最初のサンプルでは、タスク0のページテーブルは、論理ページ 0 から 15 がそのまま物理ページ 0 から 15 に対応する設定です。これは、論理アドレスと物理アドレスの見え方がほぼ同じになる、いちばん確認しやすい形です。

論理ページ 0 -> 物理ページ 0
論理ページ 1 -> 物理ページ 1
論理ページ 2 -> 物理ページ 2
...
論理ページ 15 -> 物理ページ 15

一方で、タスク1からタスク3は、それぞれのプログラムを論理アドレス 0x00000 から実行できるように、論理ページ 0 だけを別々の物理ページに対応させます。各タスクから見ると同じ 0x00000 ですが、実際に使う物理メモリはタスクごとに分かれます。

タスク1: 論理ページ 0 -> 物理ページ 1
タスク2: 論理ページ 0 -> 物理ページ 2
タスク3: 論理ページ 0 -> 物理ページ 3

これにより、タスク1、タスク2、タスク3は、どれも自分から見ると 0x00000 番地で動いているように見えます。しかし実際の物理メモリ上では、それぞれ 0x100000x200000x30000 に分かれて配置されます。

ページテーブル内の 0xFF は、その論理ページを使えないようにする印です。MMU が有効な状態で 0xFF のページにアクセスすると、ページフォルトとして扱われます。この印は、後半のページフォルト確認で使います。

書籍の流れでは、まずこの設定で論理アドレス 0x00000 と物理アドレス 0x00000 をダンプします。そのあとで、タスク0の論理ページ 3 を物理ページ 7 に対応させるようにページテーブルを書き換えて確認します。最初に同じアドレスで確認し、そのあとで対応先を変えると、変換されたことを比べやすくなります。

書籍169ページの次のコードは、その書き換え後の確認用コードです。

# ページテーブルを設定
t0_pt = task_pt_addr[0]
t1_pt = task_pt_addr[1]
t2_pt = task_pt_addr[2]
t3_pt = task_pt_addr[3]
memory[t0_pt:t0_pt + 0x10] = [0x00, 0x01, 0x02, 0x07, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F]
memory[t1_pt:t1_pt + 0x10] = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F]
memory[t2_pt:t2_pt + 0x10] = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F]
memory[t3_pt:t3_pt + 0x10] = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F]

この配列は、左から順に論理ページ 0123 … の対応先を表します。配列の何番目を見るかが論理ページ番号で、そこに入っている値が物理ページ番号です。

そのため、タスク0の4番目の値を 0x07 にすると、タスク0の論理ページ 3 が物理ページ 7 に対応します。0番目から数えるため、4番目の値が論理ページ 3 です。

memory[t0_pt:t0_pt + 0x10] = [
    0x00, 0x01, 0x02, 0x07,
    0x04, 0x05, 0x06, 0x07,
    0x08, 0x09, 0x0A, 0x0B,
    0x0C, 0x0D, 0x0E, 0x0F,
]

つまり、タスク0で MMU が有効な状態では、論理アドレス 0x30000 が物理アドレス 0x70000 に変換されます。上位の 3 が論理ページ 3 を表し、ページテーブルによって物理ページ 7 に置き換わる、と見ると追いやすいです。

動作確認用に、物理アドレス 0x70000 に分かりやすい値も入れておきます。

memory[0x70000:0x70004] = [0x12, 0x34, 0x56, 0x78]

dump.asm で論理アドレスと物理アドレスを比較する

テキスト P.167

ページテーブルによる変換を確認するため、dump.asm を追加します。ここでは、同じデータを論理アドレス側から読む場合と、物理アドレス側から読む場合を比べます。

本の最初の確認では、論理アドレスと物理アドレスの両方に 0x00000 を指定します。アドレスの数字は同じですが、R9 の値によって「論理アドレスとして読むか」「物理アドレスとして読むか」が変わります。

	MOVI	R8, lmem_msg
	SYSCALL 1

	MOVI	R8, 0x00000	; 論理アドレス
	MOVI	R9, 1
	SYSCALL	40

	MOVI	R8, pmem_msg
	SYSCALL 1

	MOVI	R8, 0x00000	; 物理アドレス
	MOVI	R9, 0
	SYSCALL	40

	RET

同じ 0x00000 でも、R9 の値によって読み方が変わります。

R9 = 1: 0x00000 を論理アドレスとして読む
R9 = 0: 0x00000 を物理アドレスとして読む

タスク0の論理ページ 0 が物理ページ 0 に対応している状態では、どちらも最終的には物理アドレス 0x00000 を読みます。

論理アドレス 0x00000
  -> 論理ページ 0
  -> 物理ページ 0
  -> 物理アドレス 0x00000

今回の実装では、exec dump.bin で実行するプログラムは物理メモリの先頭に読み込まれます。

memory[0:len(prog)] = prog

そのため、物理アドレス 0x00000 をダンプすると、空のメモリではなく、dump.bin 自身の機械語や文字列が表示されます。ダンプの中に次のような文字列が見えるのは、dump.asm に書いた文字列データが dump.bin の中に含まれているためです。これは異常ではなく、プログラム自身を読み返している状態です。

[Logical Memory Dump]
[Physical Memory Dump]

次に、マッピングを書き換えた状態を確認するため、dump.asm の対象アドレスも変更します。論理アドレス側では 0x30000 を読み、物理アドレス側では変換先である 0x70000 を直接読みます。

	MOVI	R8, lmem_msg
	SYSCALL 1

	MOVI	R8, 0x30000	; 論理アドレス
	MOVI	R9, 1
	SYSCALL	40

	MOVI	R8, pmem_msg
	SYSCALL 1

	MOVI	R8, 0x70000	; 物理アドレス
	MOVI	R9, 0
	SYSCALL	40

	RET

SYSCALL 40 は、R8 に指定したアドレスからメモリダンプを表示します。R91 を入れると論理アドレスとして読み、0 を入れると物理アドレスとして読みます。

elif syscall_num == 40:
    start_addr = value1 & 0xFFFFF
    flag_logical = value2 & 1
    memdata = []
    if flag_logical:
        for i in range(256):
            memdata +=  read_lmem(value1 + i, 1).to_bytes()
    else:
        memdata = memory[value1:value1+256]
    hexdump(memdata, start_addr)

dump.asm をアセンブルして、dir/ の下に配置します。Simple OS の execdir/<ファイル名> を読むため、ここでは出力先を dir/dump.bin にします。

python asmx.py dump.asm dir/dump.bin
Wrote dump.bin (93 bytes)

OSを起動します。

python emu.py

Simple OS のプロンプトが表示されたら、dump.bin を実行します。

exec dump.bin

論理アドレス 0x30000 の先頭に、物理アドレス 0x70000 に入れた 12 34 56 78 が見えれば、ページテーブルによるアドレス変換を確認できます。論理側と物理側で同じ値が見えることが確認ポイントです。

[Logical Memory Dump]
30000  12 34 56 78 ...

[Physical Memory Dump]
70000  12 34 56 78 ...

この実験では、物理ページ 0 ではなく物理ページ 7 に確認用データを置いているため、プログラム本体と混ざらず、変換結果を追いやすくなります。

ページフォルトの発生を確認

テキスト P.170

次に、メモリアクセス時の例外を確認します。

このテキストでは、ページフォルトは「未割り当てのメモリへアクセスしたときのエラー処理」と考えると分かりやすいです。

もう少し正確に言うと、ページフォルトは、メモリアクセス時に CPU が検出する例外です。命令を実行しようとした瞬間ではなく、メモリを読んだり書いたりするためにアドレス変換をしたタイミングで発生します。

例えば、ページテーブルが次のようになっているとします。

論理ページ 2 -> 0xFF

0xFF は、対応する物理ページが割り当てられていないことを表します。つまり、論理ページ 2 は、どの物理ページにも対応していません。

この状態で、プログラムが論理ページ 2 に属するアドレスへアクセスすると、ページフォルトが発生します。

STDI	R0, [0x23000]

0x23000 は論理ページ 2 に含まれるアドレスです。先頭の 0x2 がページ番号に対応し、残りの 0x3000 がページ内の位置になります。

論理アドレス 0x23000
  -> 論理ページ 2
  -> ページテーブルの値が 0xFF
  -> 物理ページが割り当てられていない
  -> ページフォルト発生

今回作っているOSでは、ページフォルトが起きたらメッセージを表示して停止するだけです。

Page Fault has occurred.
CPU halted.

そのため、この章の範囲では「メモリに関するエラーを検出して、ページフォルトハンドラへ処理を移す仕組み」を確認している、と捉えると分かりやすいです。

ただし、一般的なOSでは、ページフォルトは必ずしも致命的なエラーとは限りません。

例えば、まだ物理メモリに読み込んでいないページへアクセスしたときにページフォルトを発生させ、OSが必要なページを読み込んでから、止まった命令を再開することもあります。

未読み込みのページへアクセス
  -> ページフォルト
  -> OSが必要なページを用意する
  -> ページテーブルを更新する
  -> 元の命令から再開する

この本では、まずその入口として、未割り当てページへのアクセスでページフォルトハンドラが呼ばれることを確認します。ここまで確認できれば、第7章で追加した PT、ページテーブル、論理アドレスから物理アドレスへの変換が、ページフォルトの発生条件にもつながっていることが分かります。

まとめ:目に見えない空間を支配する楽しさ

第7章の「仮想メモリ編」は、これまでのCPUの切り替えから一歩進み、タスクごとに独立したメモリ空間を構築する本作最大の山場です。

本章で押さえるべき重要ポイントは以下の3点です。

  • 初期PCの指定
    • taskexecで起動するタスクの初期PCはidle_loopではなく0番地(論理アドレス)に合わせる
  • エミュレータの設定
    • emu.py側にページテーブルの物理アドレスを固定値で用意する
  • ページフォルトの理解
    • 未割り当てページ(0xFF)へのアクセスによって意図通り例外が発生するか確認する

アドレス変換の仕組みやページフォルトの挙動が、 dumpデータやエラーメッセージという「目に見える形」で繋がった瞬間は、OS自作ならではの大きな感動があります。コードの前提違いや小さな記述ミスで動かないときは、ぜひ本記事のチェックポイントを一つずつ見直してみてください。

もう一度はじめから

この章の前提となるCPUエミュレータやアセンブラの基本構成は、第1回の記事「序盤のつまずき解消ガイド」で説明しています。最初から流れを追いたい場合は、以下の記事から読み進めてください

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

のいのアバター のい UNIX Cafe マスター

Macintosh Color Classicから始まった旅は、長いWindows時代を経て、Windows10のサポート終了をきっかけにUNIXの世界へ戻ってきました。UNIX Cafeでは、UNIX・Linux・そしてMacな世界を、むずかしい言葉を使わず、物語のように書いています。プログラミングは、アイデアをコンピューターに伝えるための言葉です。簡単な単語と文法を覚えれば、誰でもコマンドを使えます。ぜひ一度、やさしいプログラミングの世界をのぞいてみてください。

目次