本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。
仮想メモリ空間へ!『いちばんやさしい!OS自作超入門』第7章のつまずき解消ガイド | UNIX Cafe

前々回の第4章では、Simple OSの上でオリジナルコマンドやタイマー割り込みを動かす楽しさを味わいました。続く第5章・第6章でマルチタスクの土台を整え、いよいよ今回は書籍の大きな山場である「第7章:仮想メモリ編」へと突入します。
第6章までは「どのタスクを動かすか」というCPUの切り替えが中心でしたが、第7章からはさらに一歩進んで、タスクごとに独立した「メモリ空間そのもの」を切り替えるエキサイティングな実装が始まります。タスク1〜3が、すべて同じ「論理アドレス 0x00000」を使いながら別々の物理メモリで平然と動くマジックの舞台裏です。
しかし、目に見えないメモリ空間を扱うだけに、コードの前提違いやレジスタの罠、アセンブルエラーなど、今回もあちこちに「つまずきポイント」が潜んでいました。
この記事では、手元の書籍本文とGitHubの最新コードとのギャップを整理しながら、初期PCの罠、ページテーブルの設定、そしてページフォルト発生の仕組みまで、実験ノートのように分かりやすく紐解いていきます。
使用環境
- macOS
- Python 3
- 対象コード: 著者公開の
os_book_code - 著者リポジトリ: https://github.com/sueyasu/os_book_code
- 補助リポジトリ: /lessons/07-virtual-memory-page-fault
- 作業ディレクトリ: ローカルに取得した
os_book_codeのディレクトリ
本文中のコマンドは、特に断りがない限り、ローカルに取得した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側が保存している SP や PC を切り替えることで、複数のタスクが並行に動いているように見えます。
第7章では、ここに仮想メモリの仕組みが加わります。タスクごとにページテーブルを持たせることで、タスク1からタスク3が同じ論理アドレス 0x00000 からプログラムを実行できるようになります。
つまり、第6章までは「どのタスクを動かすか」を切り替えていましたが、第7章ではさらに「そのタスクから見えるメモリ空間」も切り替えるようになります。
そのため、第7章で確認するポイントは、タスク切り替えそのものよりも、PC、PT、ページテーブル、論理アドレスの対応をどう読むかに集中します。
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 ; PCTask DATA からラベル定義を消した状態で、これらの参照だけが残っていると、asmx.py の実行時に未定義シンボルのエラーになります。未定義シンボルは、「その名前は使われていますが、定義が見つかりません」という意味です。
python asmx.py os.asmos.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の初期スタックに積む PC が idle_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の割り当てと、タスク本体の実行開始は別の確認ポイントです。
理由は、taskexec が hello_task.bin を各タスクの論理アドレス 0x00000 に読み込む一方で、タスクの初期 PC が idle_loop を指しているためです。つまり、プログラムは読み込まれていますが、タスク切り替え後に読み込んだ先頭アドレスへ飛びません。
初心者向けに言い換えると、hello_task.bin をメモリへ置くところまでは成功しています。しかし、タスクが最初に実行する場所が 0x00000 ではなく idle_loop のままなので、置いたプログラムを実行しません。
書籍175ページでは、この項目の該当ファイルとして、作者リポジトリの v1.1 タグを取得する手順が案内されています。
git fetch
git checkout v1.1この v1.1 の os.asm を確認すると、タスク1からタスク3の初期 PC は idle_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.1 の Task1 Setup から Task3 Setup に合わせて、初期 PC を 0 にします。
エミュレータ側にもページテーブルを用意する
テキスト 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 番地で動いているように見えます。しかし実際の物理メモリ上では、それぞれ 0x10000、0x20000、0x30000 に分かれて配置されます。
ページテーブル内の 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]この配列は、左から順に論理ページ 0、1、2、3 … の対応先を表します。配列の何番目を見るかが論理ページ番号で、そこに入っている値が物理ページ番号です。
そのため、タスク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
RETSYSCALL 40 は、R8 に指定したアドレスからメモリダンプを表示します。R9 に 1 を入れると論理アドレスとして読み、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 の exec は dir/<ファイル名> を読むため、ここでは出力先を dir/dump.bin にします。
python asmx.py dump.asm dir/dump.bin
Wrote dump.bin (93 bytes)OSを起動します。
python emu.pySimple 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 -> 0xFF0xFF は、対応する物理ページが割り当てられていないことを表します。つまり、論理ページ 2 は、どの物理ページにも対応していません。
この状態で、プログラムが論理ページ 2 に属するアドレスへアクセスすると、ページフォルトが発生します。
STDI R0, [0x23000]0x23000 は論理ページ 2 に含まれるアドレスです。先頭の 0x2 がページ番号に対応し、残りの 0x3000 がページ内の位置になります。
論理アドレス 0x23000
-> 論理ページ 2
-> ページテーブルの値が 0xFF
-> 物理ページが割り当てられていない
-> ページフォルト発生今回作っているOSでは、ページフォルトが起きたらメッセージを表示して停止するだけです。
Page Fault has occurred.
CPU halted.そのため、この章の範囲では「メモリに関するエラーを検出して、ページフォルトハンドラへ処理を移す仕組み」を確認している、と捉えると分かりやすいです。
ただし、一般的なOSでは、ページフォルトは必ずしも致命的なエラーとは限りません。
例えば、まだ物理メモリに読み込んでいないページへアクセスしたときにページフォルトを発生させ、OSが必要なページを読み込んでから、止まった命令を再開することもあります。
未読み込みのページへアクセス
-> ページフォルト
-> OSが必要なページを用意する
-> ページテーブルを更新する
-> 元の命令から再開するこの本では、まずその入口として、未割り当てページへのアクセスでページフォルトハンドラが呼ばれることを確認します。ここまで確認できれば、第7章で追加した PT、ページテーブル、論理アドレスから物理アドレスへの変換が、ページフォルトの発生条件にもつながっていることが分かります。
もう一度はじめから
この章の前提となるCPUエミュレータやアセンブラの基本構成は、第1回の記事「序盤のつまずき解消ガイド」で説明しています。最初から流れを追いたい場合は、以下の記事から読み進めてください









