UNIX V6の仮想記憶をソースコードから理解する

UNIX V6の仮想記憶をソースコードから理解する

PDP-11のアドレス変換、PPDA、u領域、プロセス切替を、図とソース構図で一気に整理する

Copyright © 2026 LWP 山中 一弘

本資料は、出典を明記いただければ、商用・非商用を問わず、ご自由に複製・改変・再配布していただけます。なお、著作権表示は改変せず、そのまま記載してご利用くださいますようお願いいたします。

記事要約

 仮想記憶は、OS学習の中でも特にわかりにくい分野です。理由は、仮想アドレス、物理アドレス、ページ、セグメント、レジスタ、プロセス、カーネルスタック、スワップ、共有テキストといった用語が一度に出てくるからです。しかも、どの用語も単独では理解できません。仮想アドレスはレジスタと一緒に見ないと意味がなく、PPDAは proc.p_addr と一緒に見ないと意味がなく、swtch() は retu() と sureg() を一緒に見ないと意味がありません。

 この記事では、エンジョイC 023回から026回までの仮想記憶メモを、UNIX V6のソースコードに寄せて全面的に再構成します。単なる文章要約ではなく、ASCII図、変換式、用語集、ソースコード上の関数別読みどころ、短いソース断片、疑似コード、確認用テストケースを使って、「仮想記憶とは何か」を実装の形で見えるようにします。

 この記事の中心は、PDP-11のメモリ管理機構です。16ビットの仮想アドレスを上位3ビットと下位13ビットに分け、上位3ビットでページアドレスレジスタを選び、選ばれたレジスタの値を64バイト単位の物理基準位置として使います。この変換機構を使って、UNIX V6はプロセスごとのPPDA、つまりu構造体とカーネルスタックを、カーネルから固定位置に見えるようにします。

 現代OSの仮想記憶は、ページテーブル、多段変換、TLB、ページフォルト、デマンドページング、copy-on-writeなどを含む巨大な仕組みです。UNIX V6はそのまま現代OSではありません。しかし、仮想アドレスを分解し、変換表を通し、プロセスごとに見える世界を切り替えるという基本は、小さい形で非常にはっきり見えます。この記事は、その小さい構造から仮想記憶の本質をつかむための記事です。

本記事の対象とゴール

想定読者

  • 仮想記憶を「物理メモリより大きく見せる仕組み」とだけ覚えている方。

  • UNIX V6やLions本を読みたいが、proc、u、p_addr、sureg()、retu() の関係で止まっている方。

  • PDP-11のページアドレスレジスタ、ページ記述レジスタ、8KB領域、64バイト単位という説明を、図で理解したい方。

  • 採用面接や技術説明で、仮想記憶を用語暗記ではなく、実装に近い言葉で説明したい方。

  • OSのソースコードを読む前に、どの関数をどの順番で見ればよいかを知りたい方。

本記事で得られること

  1. 16ビット仮想アドレスを、上位3ビットと下位13ビットに分けて説明できるようになります。

  2. ページアドレスレジスタが、なぜ64バイト単位の物理基準位置として働くかを理解できます。

  3. proc.p_addr、PPDA、u領域、カーネルスタックの関係を説明できるようになります。

  4. swtch()、retu()、sureg() を、プロセス切替と仮想記憶の接続点として読めるようになります。

  5. UNIX V6のソースコードを読むときの入口を、関数単位で持てるようになります。

  6. 仮想記憶の理解を確認するためのテストケースを作れるようになります。

本記事で扱わないこと

  • 現代OSの全機能としての仮想記憶。

  • x86やARMの多段ページテーブルの詳細。

  • PDP-11全機種の細かいMMU差分。

  • UNIX V6ソースコード全体の逐語解説。

  • 実機またはエミュレータでUNIX V6を起動する手順。

先に結論

 仮想記憶の核心は、プログラムが出したアドレスを、そのまま物理メモリの番地として使わないことです。CPUは仮想アドレスを分解し、変換用のレジスタまたは表を通して、別の物理アドレスへ変換します。

 UNIX V6をPDP-11の文脈で見ると、最小モデルはこうです。

text

16ビット仮想アドレス

15 14 13 12 0

+--------+----------------------------+

| 上位3 | 下位13ビット |

+--------+----------------------------+

|                  |
|                  +--> 8KB領域の中の位置
|
+--> 8個あるページアドレスレジスタのどれを使うか

 上位3ビットは、0から7までの番号になります。その番号でページアドレスレジスタを選びます。下位13ビットは、8KB領域内の位置として残します。選ばれたページアドレスレジスタの値を64倍し、そこへ下位13ビットを足したものが物理アドレスです。

text
selector = virtual_address >> 13
offset   = virtual_address & 017777

base = PAR[selector] * 64

physical = base + offset

 これだけなら、仮想記憶はただのアドレス変換の計算問題です。しかしUNIX V6で重要なのは、この変換機構をプロセス管理に使うことです。カーネルは、現在実行中のプロセスのPPDA、つまりu構造体とカーネルスタックを、固定の仮想番地から見ます。プロセスが切り替わると、同じ仮想番地が別のプロセスのPPDAを指すようになります。

text

カーネルから見る固定位置

   仮想アドレス C000 付近
          |
          v
    +-------------+
    | 現在プロセス |
    | のPPDA       |
    +-------------+

プロセスA実行中: C000 -> AのPPDA

プロセスB実行中: C000 -> BのPPDA

 同じ仮想番地なのに、実体が切り替わる。これが仮想記憶をOSが使う意味です。

第1章(まず用語を固定する)

 仮想記憶を理解する前に、用語を固定します。ここが曖昧なまま進むと、以降の説明はすべてぼやけます。

1.1 仮想アドレス

 仮想アドレスとは、プログラムやカーネルが命令の中で扱うアドレスです。CPUはこの値を受け取りますが、すぐに物理メモリへ出すわけではありません。PDP-11のメモリ管理機構が有効な場合、仮想アドレスは変換されます。

 この記事では、16ビットの値として扱います。16ビットなので、範囲は0から65535、つまり64KBです。

1.2 物理アドレス

 物理アドレスとは、実際の主記憶上の位置です。仮想アドレスが変換された後、最終的にメモリへ出ていくアドレスです。

 仮想記憶を理解するときに一番大事なのは、仮想アドレスと物理アドレスを同じものだと思わないことです。同じ値になる場合もありますが、それは「そういう変換設定にした場合」だけです。

1.3 ページまたはセグメント

 PDP-11の説明では、現代OSの4KBページとは違う粒度が出てきます。この記事では、講義メモに合わせて、64KBの仮想空間を8個の8KB領域に分けたものを「ページ」または「セグメント」と呼びます。

 厳密な用語としては、PDP-11ではセグメントレジスタという文脈で語られることが多いです。ただし、講義メモではページという言い方も使われています。この記事では、読者の混乱を避けるため、8KB領域という表現を併用します。

1.4 PAR

 PARは、Page Address Register、ページアドレスレジスタです。8個あります。仮想アドレスの上位3ビットが、どのPARを使うかを決めます。

 PARの値は、物理アドレスの開始位置を64バイト単位で表します。したがって、PARの値が1増えると、物理アドレスの基準位置は64バイト動きます。8KBではありません。ここを間違えると、講義メモの大事なところが見えなくなります。

1.5 PDR

 PDRは、Page Descriptor Register、ページ記述レジスタです。PARが「どこへ対応させるか」を持つのに対し、PDRは「その領域をどう使えるか」を持つ側です。長さ、アクセス権、伸長方向などに関係します。

 この記事の中心はPARによるアドレス変換です。ただし、実際のメモリ保護や領域の有効性にはPDRも関わります。つまり、PARだけで仮想記憶が完成するわけではありません。PARは場所、PDRは性質、という分け方で覚えるとよいです。

1.6 PPDA

 PPDAは、Per Process Data Area、プロセスごとのデータ領域です。UNIX V6の学習では、ここにu構造体とカーネルスタックが含まれる、と理解すると見通しがよくなります。

 ユーザープログラムから見えるデータ領域やスタックとは別に、カーネルがそのプロセスを処理するために必要な領域があります。システムコールに入ったとき、割り込みを処理するとき、スケジューラで切り替えるとき、カーネルは現在プロセス用の情報を必要とします。そのためのプロセスごとの領域がPPDAです。

1.7 u領域

 u領域は、struct user、つまり u 構造体を中心とする領域です。UNIX V6の user.h に定義される u は、現在のプロセスに関するカーネル側の作業領域です。

 u には、現在プロセスの proc 構造体へのポインタ、システムコールの引数、エラー番号、ファイルディスクリプタ、カレントディレクトリなどが入ります。proc が全プロセスの一覧表だとすれば、u は「今カーネルが相手にしているプロセスの詳細ファイル」です。

1.8 proc構造体

 proc 構造体は、全プロセスについて最低限の管理情報を持つ表です。プロセスがスワップアウトされていても、スケジューラが判断するために残しておく必要がある情報が入ります。

 UNIX V6の proc.h では、p_addr と p_size が特に重要です。p_addr はスワップ可能イメージの位置を表し、p_size はそのサイズを64バイト単位で表します。

1.9 有効アドレスと物理アドレス

 ユーザー文中の「有効増体」は、おそらく「有効アドレス」または「有効範囲」に近い論点だと考えます。ここでは、CPU命令が計算したアドレスを有効アドレス、その後にメモリ管理機構で変換されて実メモリへ出るものを物理アドレス、と分けます。

 重要なのは、命令が出したアドレスがそのままメモリへ行くわけではないことです。命令側から見ると有効なアドレスでも、PDRの設定上アクセスできない、あるいはPARの設定によって別の物理位置へ行く、ということが起こります。

第2章(全体構図を1枚で見る)

 細かい話へ入る前に、全体構図を置きます。

text

ユーザープロセスが見る世界

仮想アドレス空間 64KB

+--------+ 00000

| text |

| |

+--------+

| data |

| |

+--------+

| stack |

| |

+--------+ 177777

          |
          | CPUのメモリ管理機構
          v

物理メモリ

+------------------+

| process A image |

+------------------+

| process B PPDA |

+------------------+

| kernel |

+------------------+

| process A PPDA |

+------------------+

| free area |

+------------------+

 仮想アドレス空間は、プロセスから見る論理的な並びです。物理メモリは、実際にメモリ上に置かれている並びです。この2つは一致しなくてかまいません。

 仮想記憶の役割は、この2つの対応関係を作ることです。対応関係を作るのがPAR/PDRです。

text

仮想アドレス

 |
 v

+------------------------+

| 上位3ビットでPARを選ぶ |

+------------------------+

 |
 v

+------------------------+

| PAR値から物理基準位置 |

| を作る |

+------------------------+

 |
 v

+------------------------+

| 下位13ビットを足す |

+------------------------+

 |
 v

物理アドレス

 これが最小の流れです。

第3章(16ビット仮想アドレスを分解する)

 PDP-11の仮想アドレスは16ビットです。16ビットをそのまま1つの数として見ると、仮想記憶はわかりません。上位3ビットと下位13ビットに分けて見ます。

text

例: 仮想アドレス

bit 15 14 13 | 12 11 10 9 8 7 6 5 4 3 2 1 0

  ---------+--------------------------------
  selector | offset

selector: 0から7の番号

offset : 0から8191の位置

 上位3ビットがselectorです。これはPAR配列の添字です。下位13ビットがoffsetです。これは選ばれた8KB領域の中の何バイト目かを示します。

 なぜ13ビットかというと、8KBが2の13乗だからです。下位13ビットだけで、0から8191までの位置を表せます。

text

64KB = 65536 bytes

8KB = 8192 bytes

65536 / 8192 = 8

8個の領域を選ぶには3ビット必要

8KB内の位置を表すには13ビット必要

3 + 13 = 16

 この分解は、配列の添字と要素内位置を分ける感覚に近いです。1つの長い配列を、8個の箱に分けて考えます。

text

64KBを8つの箱に分ける

仮想ページ0: 00000 - 017777

仮想ページ1: 020000 - 037777

仮想ページ2: 040000 - 057777

仮想ページ3: 060000 - 077777

仮想ページ4: 100000 - 117777

仮想ページ5: 120000 - 137777

仮想ページ6: 140000 - 157777

仮想ページ7: 160000 - 177777

 上の範囲はPDP-11でよく使われる8進表記のイメージです。講義メモでは C000 という表現でページ6が説明されています。表記が16進風に見える場合でも、この記事で重要なのは、上位3ビットが 110 なら6番目のPARを選ぶ、という構造です。

第4章(PARは8KBページ番号ではなく64バイト単位の基準位置である)

 ここが最初の大きな山です。PARは、8KBページの番号そのものではありません。PARは、物理メモリ上の基準位置を64バイト単位で持ちます。

 変換式は、次のようになります。

text
selector = virtual_address >> 13
offset   = virtual_address & 017777

base = PAR[selector] * 64

physical = base + offset

 なぜ64を掛けるのでしょうか。UNIX V6のソースでは、プロセスサイズやメモリ確保の単位として64バイト単位が繰り返し出てきます。proc.h では、p_size が64バイト単位であることが示されています。sys1.c や text.c でも、バイト数を64バイト単位へ丸める計算が出てきます。

 つまり、UNIX V6のメモリ管理を読むときには、「バイト単位のアドレス」と「64バイト単位のクリック」を区別しなければなりません。

text

バイト単位:

0, 1, 2, 3, 4, ...

64バイト単位:

0, 1, 2, 3, 4, ...

ただし実際のバイト位置は

0, 64, 128, 192, 256, ...

 PARの値が1増えると、物理アドレスの基準位置は1バイトではなく64バイト動きます。

text

PAR[0] = 0 のとき

base = 0 * 64 = 0

仮想ページ0の先頭 -> 物理アドレス0

PAR[0] = 1 のとき

base = 1 * 64 = 64

仮想ページ0の先頭 -> 物理アドレス64

PAR[0] = 2 のとき

base = 2 * 64 = 128

仮想ページ0の先頭 -> 物理アドレス128

 講義メモで「下駄を履かせる」という感覚が出てくるのは、この基準位置をずらす働きです。仮想ページ内のoffsetはそのまま残し、ページ全体に物理側の下駄を履かせます。

text

仮想ページ内の形は保つ

仮想ページ

+-------------------+

| offset 0 |

| offset 1 |

| ... |

| offset 8191 |

+-------------------+

PARで物理側の開始位置をずらす

物理メモリ

+-------------------+

| 別の領域 |

+-------------------+

| base | <- ここから対応

| base + 1 |

| ... |

| base + 8191 |

+-------------------+

第5章(PDRは場所ではなく性質を見る)

 PARだけを見ると、「対応先さえ決めればよい」と思いがちです。しかし、実際のメモリ管理では、それだけでは足りません。

 PDRは、ページ記述レジスタです。PARが物理メモリ上の基準位置を持つのに対し、PDRはその領域をどう扱うかを持ちます。

text

PAR: どこに対応させるか

PDR: どの範囲を、どう使えるか

 たとえば、ある仮想領域が読み取り専用なのか、書き込み可能なのか、どこまでが有効なのか、スタックのように逆方向へ伸びるのか、といった情報は、単なる物理基準位置だけでは表せません。

 この記事の主役はPARです。なぜなら、講義メモで扱われている演習の中心が、仮想アドレスから物理アドレスを計算することだからです。ただし、実際のOSでは、PDRを無視して「PARだけで仮想記憶を説明できる」とは言えません。

 面接や説明では、次のように言うとよいです。

 「UNIX V6/PDP-11の学習では、まずPARで仮想アドレスがどの物理位置へ対応するかを見る。ただし、実際の保護や領域の有効性にはPDRも関わる。PARは場所、PDRは属性として分けて理解する。」

第6章(全PARゼロのテストで変換の意味を見る)

 最初のテストケースは、全てのPARを0にすることです。

text

PAR[0] = 0

PAR[1] = 0

PAR[2] = 0

PAR[3] = 0

PAR[4] = 0

PAR[5] = 0

PAR[6] = 0

PAR[7] = 0

 このとき、仮想ページ0も、仮想ページ1も、仮想ページ7も、選ばれるPARの値は0です。したがって、どの仮想ページも物理アドレス0から8191へ対応します。

text

仮想ページ0 -> 物理 0 - 8191

仮想ページ1 -> 物理 0 - 8191

仮想ページ2 -> 物理 0 - 8191

...

仮想ページ7 -> 物理 0 - 8191

 これは実用的な設定ではありません。しかし、変換を理解するテストとしては非常に重要です。上位3ビットでPARを選んでいるのに、結果が全部同じになるのは、選んだPARの中身が全部同じだからです。

text

仮想アドレス 00000

selector = 0

offset = 0

PAR[0] = 0

physical = 0 * 64 + 0

仮想アドレス 020000

selector = 1

offset = 0

PAR[1] = 0

physical = 0 * 64 + 0

仮想アドレス 160000

selector = 7

offset = 0

PAR[7] = 0

physical = 0 * 64 + 0

 このテストで確認できるのは、selectorとoffsetが正しく分離できているかです。

第7章(仮想アドレスと物理アドレスを一致させる)

 次のテストは、仮想アドレスと物理アドレスが一致するようにPARを設定することです。

 仮想ページ0は物理0から始めます。仮想ページ1は物理8192から始めます。仮想ページ2は物理16384から始めます。これをPARの値として入れるには、物理開始位置を64で割った値を使います。

text

物理開始位置 / 64 = PAR値

ページ0: 0 / 64 = 0

ページ1: 8192 / 64 = 128

ページ2: 16384 / 64 = 256

ページ3: 24576 / 64 = 384

 このように設定すると、下位13ビットのoffsetを足した結果、仮想アドレスと物理アドレスが一致します。

text

仮想 020000

selector = 1

offset = 0

PAR[1] = 128

base = 128 * 64 = 8192

physical = 8192

仮想 020123

selector = 1

offset = 0123

base = 8192

physical = 8192 + 0123

 講義メモで「PARの7、8、9ビットが仮想アドレスの13、14、15ビットに対応する」と説明されているのは、この同一対応を観察する話です。PAR側の該当ビットが仮想ページ番号と対応していれば、仮想ページの位置と物理ページの位置がそろいます。

第8章(ページの順序を逆にしてみる)

 仮想記憶の理解を進めるには、わざと順序を逆にすると効果的です。

text

仮想ページ0 -> 物理ページ7

仮想ページ1 -> 物理ページ6

仮想ページ2 -> 物理ページ5

仮想ページ3 -> 物理ページ4

仮想ページ4 -> 物理ページ3

仮想ページ5 -> 物理ページ2

仮想ページ6 -> 物理ページ1

仮想ページ7 -> 物理ページ0

 この設定では、仮想アドレス空間の先頭が物理メモリの後ろ側へ行きます。仮想空間の順序と物理メモリの順序は一致しません。

text

仮想空間

page0 page1 page2 page3 page4 page5 page6 page7

|     |     |     |     |     |     |     |
v     v     v     v     v     v     v     v

page7 page6 page5 page4 page3 page2 page1 page0

物理メモリ

 ここでわかるのは、仮想アドレスは「プログラムから見える順序」であり、物理アドレスは「実メモリ上の配置」であるということです。OSは対応関係を作り替えられます。

 仮想記憶の価値は、メモリを大きく見せることだけではありません。対応関係を変えられること自体が価値です。

第9章(PPDAを理解しないとUNIX V6の仮想記憶は見えない)

 ここからUNIX V6のソースコード寄りに入ります。

 PPDAは、Per Process Data Area、プロセスごとのデータ領域です。ここには、現在プロセスをカーネルが扱うための情報と、カーネルモードで使うスタックが含まれる、と考えると理解しやすくなります。

text

1つのプロセスに対応するPPDA

+--------------------------------+

| u構造体 |

| - u_procp |

| - u_error |

| - u_arg |

| - u_ofile |

| - u_cdir |

+--------------------------------+

| カーネルスタック |

| - システムコール中の戻り先 |

| - 割り込み処理中の保存領域 |

| - swtch/retu/aretu 関連の保存 |

+--------------------------------+

 ユーザープロセスには、ユーザー空間のtext、data、stackがあります。しかし、カーネルがそのプロセスを処理するときには、別にカーネル側の作業領域が必要です。たとえば、システムコール中にどのファイルを開いているか、どのエラーを返すか、どのディレクトリにいるか、といった情報は、プロセスごとに違います。

 この「プロセスごとに違うが、カーネルからは現在プロセスとして同じ名前で見たい」情報が、u領域です。

第10章(procとuは役割が違う)

 UNIX V6を読むとき、proc と u を混同すると先へ進めません。

text

proc

全プロセスの一覧表

スケジューラが見る

スワップアウト中でも必要

u

現在プロセスの詳細情報

カーネルがシステムコール処理で見る

プロセスのイメージと一緒に動く

 proc.h の先頭コメントでは、proc がプロセスがスワップアウトされている間も必要な情報を持ち、その他のプロセスごとの情報は user.h 側にあり、プロセスと一緒にスワップされる、という趣旨が書かれています。

 つまり、proc は「プロセス台帳」です。プロセスがCPUを使っていなくても、台帳は残ります。

 一方、u は「現在処理中のプロセスの詳細ファイル」です。カーネルは、いつも u という名前で現在プロセスの詳細を見ます。

text

全体イメージ

proc[]

+---------+ p_addr ---> process A image / PPDA

| proc A |

+---------+ p_addr ---> process B image / PPDA

| proc B |

+---------+ p_addr ---> process C image / PPDA

| proc C |

+---------+

現在CPUで動くプロセスがBなら

u ---> Bのu領域

 ここで仮想記憶が必要になります。カーネルは、プロセスAのときもBのときも、同じ u という場所から現在プロセスの情報を読みたいからです。

第11章(proc.hで見るp_addrとp_size)

 proc.h で特に見るべき部分は、p_addr と p_size です。公開ソースでは、p_addr はスワップ可能イメージのアドレス、p_size はそのサイズを64バイト単位で表す、という形で定義されています。

 ここで大事なのは、p_addr を「ただのバイトアドレス」と読まないことです。UNIX V6のメモリ管理では、64バイト単位の値が頻繁に出てきます。p_addr は、プロセスのスワップ可能イメージがどこにあるかを示す値であり、PARへ入れられる単位と相性がよい値です。

text

proc構造体の読み方

struct proc

p_stat : 状態

p_flag : フラグ

p_pri : 優先度

p_pid : プロセスID

p_addr : スワップ可能イメージの位置

p_size : スワップ可能イメージの大きさ

p_textp : 共有テキスト構造体へのポインタ

 p_size が64バイト単位であることを見れば、p_addr やメモリ確保も同じ粒度で考える必要があるとわかります。

 このときのソース構図は、次のようになります。

text

proc[] 主記憶またはスワップ領域

+------------------+ +----------------------+

| p_addr ----------+--------> | スワップ可能イメージ |

| p_size ----------+----+ | u領域 |

| p_textp ---------+--+ | | data |

+------------------+ | | | stack |

                  | |     +----------------------+
                  | |
                  | +-- 大きさは64バイト単位
                  |
                  +---- 共有テキスト情報へ

第12章(u領域は現在プロセスの詳細ファイルである)

 user.h の u 構造体は、現在プロセスの詳細を持ちます。u.u_procp は、現在プロセスの proc 構造体を指します。

text

u領域からprocへ戻る

u

+------------------+

| u_procp ---------+----> proc[] の現在プロセス

| u_error |

| u_arg |

| u_ofile |

| u_cdir |

+------------------+

 この構造を理解すると、sleep() の中で u.u_procp を使う理由が見えます。sleep() は、現在プロセスの状態を変えて、スケジューラへ制御を渡します。現在プロセスがどれかは、u.u_procp からわかります。

 ここで、u が「現在プロセスに固定された名前」であることが重要です。プロセスAの実行中は u がAのu領域を指し、プロセスBの実行中は u がBのu領域を指します。

text

同じ名前 u が、実行中プロセスによって違う実体を見る

プロセスA実行中

u -> Aのu領域

プロセスB実行中

u -> Bのu領域

プロセスC実行中

u -> Cのu領域

 これを実現するために、カーネル側の仮想アドレス対応が切り替えられます。

第13章(C000とPAR[6]の意味)

 講義メモでは、カーネルが現在プロセスのPPDAを見る固定仮想位置として C000 が説明されています。ここで大事なのは、C000 の上位3ビットが 110 になることです。

text

C000 の上位3ビット

110.............

|

+--> 6番目のPARを選ぶ

 つまり、カーネルが C000 付近へアクセスすると、ページアドレスレジスタ6番が使われます。ここに現在プロセスの p_addr を入れれば、カーネルのページ6が現在プロセスのPPDAへ対応します。

text

カーネル仮想空間

page0

page1

page2

page3

page4

page5

page6 C000付近 ----> 現在プロセスのPPDA

page7

page6を決めるのが PAR[6]

PAR[6] に p_addr 相当の値を入れる

 プロセス切替のたびに、この対応先が変わります。

text

プロセスAへ切替

PAR[6] = A.p_addr

C000 -> AのPPDA

プロセスBへ切替

PAR[6] = B.p_addr

C000 -> BのPPDA

 カーネルコードから見ると、アクセスする仮想番地は変わりません。しかし、背後の物理メモリが変わります。この性質があるから、カーネルは u という固定的な名前で、現在プロセスの情報を扱えます。

第14章(swtchは実行プロセスだけでなく見える世界を切り替える)

 UNIX V6の slp.c にある swtch() は、プロセス切替の中心です。単に「次に動かすプロセスを選ぶ関数」と見ると足りません。swtch() は、カーネルから見える現在プロセスの世界を切り替える関数です。

 slp.c の swtch() には、短い目印として次の流れがあります。

text

swtchの大枠

  1. 現在の戻り先を保存する

  2. スケジューラ用のスタックへ移る

  3. 実行可能で主記憶上にあるプロセスを探す

  4. 選んだプロセスのu領域へ切り替える

  5. ユーザー空間のセグメントレジスタを設定する

  6. 選んだプロセスとして戻る

 公開ソース上の短い目印は、savu(u.u_rsav)、retu(proc[0].p_addr)、retu(rp->p_addr)、sureg() です。長いコードを丸写しする必要はありません。この4つを順番に追うだけで、swtch() の見方が変わります。

text

swtchのソース構図

現在プロセス

|

| savu(u.u_rsav)

v

戻り先保存

|

| retu(proc[0].p_addr)

v

スケジューラ側へ

|

| runnable process を探す

v

次のプロセス rp

|

| retu(rp->p_addr)

v

次プロセスのu領域へ

|

| sureg()

v

次プロセスのユーザー空間設定

 retu(rp->p_addr) は、選ばれたプロセスのu領域、つまりカーネルスタック側へ切り替える入口として見ます。sureg() は、そのプロセスのユーザー空間用セグメント設定を作る入口として見ます。

 ここで注意すべきなのは、プロセス切替はCPUの実行主体を変えるだけではないことです。仮想アドレスの対応も変えます。どの u が見えるかも変えます。ユーザー空間のtext/data/stackの見え方も変えます。

第15章(retuはu領域側の切替として読む)

 retu() はアセンブリ側の低レベル処理に関係します。Cの通常関数のように、引数を受けて計算結果を返すものとして読むと、理解しにくくなります。

 この記事では、retu(addr) を次のように読みます。

text

retu(addr)

addrで示されるプロセスのu領域/カーネルスタックを、

カーネルから現在のu領域として見えるようにする切替処理。

 retu(proc[0].p_addr) は、スケジューラ側のスタックへ移る処理として見ます。retu(rp->p_addr) は、次に実行するプロセスのu領域へ移る処理として見ます。

text

swtch内の2回のretu

1回目:

retu(proc[0].p_addr)

-> スケジューラ用の場所へ移る

2回目:

retu(rp->p_addr)

-> 選ばれたプロセスの場所へ移る

 この2回の retu を区別できると、swtch() の読解はかなり進みます。

第16章(suregはユーザー空間の見え方を作る)

 retu() がu領域側の切替だとすれば、sureg() はユーザー空間側のセグメント設定です。

 プロセスには、text、data、stackがあります。共有テキストを持つ場合もあります。これらをユーザー仮想空間のどこへ対応させるかを、セグメントレジスタに設定する必要があります。

text

プロセスのユーザー空間

仮想空間

+----------------+

| text |

+----------------+

| data |

+----------------+

| stack |

+----------------+

   |
   | sureg()
   v

PAR/PDR設定

+----------------+

| textの対応 |

| dataの対応 |

| stackの対応 |

+----------------+

 retu() だけでは、現在プロセスのu領域は見えても、ユーザープログラムの仮想空間設定は完成しません。そこで sureg() が必要になります。

 このように分けると、swtch() の最後に retu(rp->p_addr) と sureg() が並ぶ意味が見えます。

text

retu(rp->p_addr)

-> カーネルが現在プロセスのu領域を見られるようにする

sureg()

-> 現在プロセスのユーザー空間を見られるようにする

第17章(sleepとwakeupはスケジューラへの入口である)

 slp.c には sleep() と wakeup() もあります。仮想記憶の主役ではありませんが、swtch() へ入る流れを見るには重要です。

 sleep() は、現在プロセスを待ち状態にして、CPUを手放します。現在プロセスは u.u_procp から得られます。そして、状態や待ちチャネルを proc に書き込み、swtch() を呼びます。

text

sleepの構図

u.u_procp

|

v

現在プロセスのproc

|

| p_wchan に待ち対象を書く

| p_stat を sleep 状態にする

| p_pri に優先度を書く

v

swtch()

 wakeup() は、指定された待ちチャネルで眠っているプロセスを探し、実行可能状態へ戻します。

text

wakeupの構図

proc[]を走査

|

+-- p_wchan が一致

      |
      v
   setrun()
      |
      v
   p_stat = runnable

 ここでも、proc と u の分担が見えます。待っているプロセスを探すには全プロセスの一覧表である proc[] を見ます。現在プロセスを処理するには u を見ます。

第18章(schedは主記憶とスワップ領域の交通整理をする)

 slp.c の sched() は、スワッピングの中心です。主記憶にいない実行可能プロセスを入れる、必要なら別のプロセスを出す、という交通整理をします。

 ここで重要なのは、p_addr が主記憶上の位置にも、スワップ領域上の位置にも関係することです。プロセスが主記憶上にいるかどうかは、SLOAD フラグで見ます。

text

プロセスが主記憶上にいる

p_flag に SLOAD が立っている

p_addr は主記憶上の位置として読む

プロセスがスワップアウトされている

SLOAD が立っていない

p_addr はスワップ領域上の位置として読む

 この二重性が、UNIX V6を読むときの難所です。p_addr は常に「主記憶上の物理アドレス」ではありません。プロセスの状態によって、主記憶上の位置を指したり、スワップ領域上の位置を指したりします。

 そのため、p_addr を読むときには、必ず p_flag の SLOAD と一緒に見ます。

text

p_addrを読むときの確認

  1. このプロセスは主記憶上にいるか

-> SLOADを見る

  1. p_addrは何の領域を指しているか

-> 主記憶か、スワップ領域か

  1. 単位は何か

-> 64バイト単位として読む

第19章(newprocはforkの見えにくさを作る)

 newproc() は、forkの内部処理です。UNIX V6のコメントでも、理解が難しいことが示されています。親プロセスの実行中に子プロセスを作り、あとで子プロセスが同じ場所から戻ってきたように見えるからです。

 仮想記憶との関係で見るべき点は、親プロセスのイメージを子プロセス用に複製することです。主記憶に余裕があれば、malloc(coremap, n) で場所を取り、copyseg() でコピーします。余裕がなければ、スワップを使います。

text

newprocの構図

親プロセス

  p_addr = a1
  p_size = n

子プロセス用に確保

  a2 = malloc(coremap, n)

コピー

copyseg(a1, a2)

copyseg(a1+1, a2+1)

...

子のproc

  p_addr = a2
  p_size = n

 ここでも、単位は64バイト単位です。copyseg() が扱う単位を、バイト単位のコピーと混同しないようにします。

 forkを理解するには、「Cの関数呼び出し」として見るだけでは足りません。プロセスのメモリイメージ、u領域、保存された戻り先、swtch() の戻り値が絡みます。この記事では深入りしませんが、仮想記憶を理解した後に読むと、newproc() のコメントの意味が見えやすくなります。

第20章(expandはdataとstackの大きさを変える)

 expand() は、プロセスのdata領域とstack領域の大きさを変える処理です。sbreak() などから呼ばれ、プロセスのスワップ可能イメージのサイズを変えます。

text

expand(newsize)

現在サイズ n

新サイズ newsize

縮小:

余った領域を coremap に返す

拡大:

新しい領域を malloc で取る

取れればコピーして移動

取れなければスワップアウトを準備

 ここで重要なのは、dataとstackが同じスワップ可能イメージの中で扱われることです。UNIX V6では、textが共有される場合、textは別扱いになります。一方、dataとstack、u領域はプロセスごとのイメージとして扱われます。

text

プロセスイメージの粗い見方

共有される可能性がある

text

プロセスごとに持つ

u領域

data

stack

 expand() を読むと、仮想記憶が「アドレス変換」だけでなく、「プロセスのメモリイメージを動かす処理」と結びついていることがわかります。

第21章(execとsbreakで64バイト単位が見える)

 sys1.c には、exec() と sbreak() があります。

 exec() は、新しいプログラムを現在プロセスの上に読み込む処理です。実行ファイルのヘッダからtext、data、bssなどのサイズを読み、必要なメモリサイズを計算します。このとき、バイト数を64バイト単位へ丸める処理が出てきます。

 sbreak() は、プログラムブレークを動かす処理です。Cでいうヒープ領域の拡張に近い位置づけです。ここでも、新しいデータサイズを64バイト単位へ丸め、expand() へつなげます。

text

バイト数を64バイト単位へ丸める考え方

bytes

|

| + 63

| 6ビット右シフト

v

64バイト単位の個数

 この計算は、64で割って切り上げる処理です。ビット演算で見ると、64は2の6乗なので、6ビット右シフトが使えます。

text

例

bytes = 1

(1 + 63) >> 6 = 1

bytes = 64

(64 + 63) >> 6 = 1

bytes = 65

(65 + 63) >> 6 = 2

 この丸めを理解すると、p_size、p_addr、PARの64バイト単位が同じ世界の話として見えてきます。

第22章(text.cで共有テキストを見る)

 text.c は、共有テキストセグメントを扱います。

 textは、プログラムの命令部分です。同じプログラムを複数プロセスが実行している場合、命令部分は共有できます。dataやstackはプロセスごとに違いますが、textは同じでよい場合があります。

text

共有テキストのイメージ

物理メモリ

+------------------+

| text: /bin/sh | <--- 複数プロセスが共有

+------------------+

| process A data |

+------------------+

| process A stack |

+------------------+

| process B data |

+------------------+

| process B stack |

+------------------+

 text.c の xalloc() は、プロセスが共有テキストを使うときの確保処理です。共有テキストがすでに存在するか、主記憶にあるか、スワップ領域にあるか、参照カウントがどうか、といった情報を扱います。

 ここで見るべきなのは、仮想記憶が「プロセスごとに全部を別々に持つ」だけではないことです。同じ物理テキストを、複数の仮想空間から参照させることもできます。

text

プロセスAの仮想空間 物理メモリ

text ----+

     |                     +------------------+
     +-------------------> | shared text      |
                           +------------------+
     +-------------------> | shared text      |

text ----+

プロセスBの仮想空間

 この共有の発想は、現代OSの共有ライブラリや読み取り専用ページ共有にもつながります。ただし、UNIX V6の仕組みをそのまま現代OSと同一視してはいけません。ここでは、対応表を使うと同じ物理領域を複数の仮想空間に見せられる、という原理を押さえます。

第23章(malloc.cはCのmallocではなく資源マップである)

 UNIX V6の malloc.c を読むときは、Cライブラリの malloc() と混同してはいけません。ここでの malloc() は、カーネル内の資源マップから連続領域を確保する処理です。

 coremap は主記憶の空き領域、swapmap はスワップ領域の空き領域を管理します。

text

coremap

+--------------------+

| 空き開始位置 |

| 空きサイズ |

+--------------------+

| 空き開始位置 |

| 空きサイズ |

+--------------------+

malloc(coremap, size)

-> 主記憶からsize単位の領域を取る

mfree(coremap, size, addr)

-> 主記憶へsize単位の領域を返す

 ここでも、sizeはバイト単位ではなく、OS側の管理単位として読みます。p_size と coremap のサイズが同じ粒度で扱われるから、sched() や expand() がつながります。

第24章(ソースコードを読む順番)

 UNIX V6の仮想記憶を読むときは、いきなり全部を追わない方がよいです。次の順番をおすすめします。

text
  1. proc.h

p_addr, p_size, p_flag, p_textp を見る

  1. user.h

u.u_procp と、現在プロセス情報を見る

  1. slp.c の sleep/wakeup

現在プロセスが待ち状態になる流れを見る

  1. slp.c の swtch

retu と sureg の位置を見る

  1. slp.c の sched

SLOAD, coremap, swapmap, p_addr の意味を見る

  1. sys1.c の exec/sbreak

64バイト単位への丸めを見る

  1. text.c

共有テキストと xalloc を見る

  1. malloc.c

coremap/swapmap の資源管理を見る

 この順で読むと、仮想アドレス変換、プロセス管理、スワップ、共有テキストが一つの構造としてつながります。

第25章(最小の疑似コードでアドレス変換を書く)

 講義演習としては、まず仮想アドレスから物理アドレスを返す関数を書きます。これはUNIX V6の完全再現ではなく、理解用の最小モデルです。

text
function translate(virtual_address, PAR):
selector = upper_3_bits(virtual_address)
offset   = lower_13_bits(virtual_address)
base     = PAR[selector] * 64
return base + offset

 C風に書くなら、考え方は次のようになります。

text
selector = virtual_address >> 13
offset   = virtual_address & 017777

base = PAR[selector] << 6

physical = base + offset

 ここで << 6 は64倍です。64は2の6乗なので、左へ6ビットシフトすれば64倍になります。

 ただし、学習用コードでは、変数名を短くしすぎない方がよいです。

text

par_index

page_offset

physical_base

physical_address

 仮想記憶の理解では、添字、offset、base、addressの区別が重要です。a、p、x のような名前で書くと、計算は合っていても説明できなくなります。

第26章(確認テストを設計する)

 仮想記憶は、テストケースを作ると理解が進みます。

26.1 全PARゼロ

text

PAR = [0,0,0,0,0,0,0,0]

期待:

どの仮想ページも物理0から8191へ対応する

 確認することは、selectorが違っても、PAR値が同じならbaseが同じになることです。

26.2 同一対応

text

PAR[0] = 0

PAR[1] = 128

PAR[2] = 256

PAR[3] = 384

...

 期待することは、仮想アドレスと物理アドレスが一致することです。

26.3 逆順対応

text

仮想ページ0 -> 物理ページ7

仮想ページ1 -> 物理ページ6

...

仮想ページ7 -> 物理ページ0

 期待することは、仮想空間の順序と物理メモリの順序が独立していることです。

26.4 64バイト単位のずれ

text

PAR[0] = 0

PAR[0] = 1

PAR[0] = 2

 期待することは、物理開始位置が0、64、128と動くことです。8KB単位ではないことを確認します。

26.5 境界値

text

各仮想ページについて確認する値

ページ先頭

ページ先頭 + 1

ページ末尾

次ページ先頭

 期待することは、ページ末尾までは同じPARが使われ、次ページ先頭でselectorが変わることです。

26.6 C000とPAR[6]

text

仮想 C000

selector = 6

offset = 0

PAR[6] = p_addr

  physical = p_addr * 64

 期待することは、ページ6がPPDAの基準位置へ対応することです。

第27章(読者が自分で説明できるようにする)

 この記事を読んだ後、仮想記憶を説明するなら、次の順番がよいです。

 第一に、仮想アドレスと物理アドレスは別物です。CPUが出した仮想アドレスは、メモリ管理機構で変換されてから物理メモリへ行きます。

 第二に、PDP-11/UNIX V6の学習では、16ビット仮想アドレスを上位3ビットと下位13ビットに分けます。上位3ビットで8個のPARから1つを選び、下位13ビットは8KB領域内の位置として使います。

 第三に、PARは物理基準位置を64バイト単位で持ちます。したがって、物理アドレスは PAR値 * 64 + offset で考えます。

 第四に、UNIX V6ではこの仕組みを使って、現在プロセスのPPDAをカーネルの固定仮想位置へ対応させます。プロセスが切り替わると、同じ仮想位置が別のプロセスのPPDAを指します。

 第五に、swtch() は次のプロセスを選ぶだけではありません。retu() でu領域側を切り替え、sureg() でユーザー空間側のセグメント設定を作ります。プロセス切替とは、実行主体とアドレス変換の両方を切り替える処理です。

 ここまで説明できれば、仮想記憶を「大きなメモリを使う仕組み」とだけ説明する段階から、一歩進めます。

第28章(現代OSとの接続)

 最後に、現代OSとの接続を整理します。

 現代OSでは、ページテーブル、多段ページング、TLB、ページフォルト、デマンドページング、copy-on-write、共有ライブラリ、メモリマップドファイルなどが出てきます。UNIX V6のPDP-11モデルは、それらをそのまま持っているわけではありません。

 しかし、次の基本は共通しています。

text

共通する基本

  1. プログラムが見るアドレスと物理メモリは別

  2. アドレスを分解して変換情報を選ぶ

  3. 変換情報によって物理位置が決まる

  4. プロセスごとに見えるアドレス空間を変えられる

  5. 同じ物理領域を複数の仮想空間から見せられる

  6. カーネルはこの機構をプロセス管理に使う

 UNIX V6を学ぶ価値は、仕組みが小さいことです。小さいから、アドレス変換、プロセス管理、スワップ、共有テキストの接続が見えます。現代OSへ進む前に、この小さい構造を理解しておくと、後から出てくる大きな仕組みを整理しやすくなります。

まとめ

 仮想記憶は、言葉だけで覚えるとわかりません。仮想アドレスを分解し、PARを選び、64バイト単位の基準位置を作り、offsetを足して物理アドレスを作る。この手順を図とテストケースで確認して、初めて実装のイメージが持てます。

 UNIX V6では、この変換機構がプロセス管理と直結します。proc は全プロセスの台帳です。u は現在プロセスの詳細ファイルです。PPDAは、現在プロセスのu領域とカーネルスタックを含むプロセスごとの領域です。カーネルは、固定の仮想番地から現在プロセスのPPDAを見ます。

 プロセス切替では、swtch() が次のプロセスを選び、retu(rp->p_addr) によってu領域側を切り替え、sureg() によってユーザー空間側のセグメント設定を作ります。つまり、プロセス切替とは、CPUの実行主体を変えるだけでなく、仮想アドレスの見え方を変える処理でもあります。

 この記事の最短結論は、次の一文です。

 UNIX V6の仮想記憶は、16ビット仮想アドレスを8KB単位に分け、PARで64バイト単位の物理基準位置へ対応させ、その対応をプロセス切替時に入れ替えることで、現在プロセスのu領域とユーザー空間をカーネルから一貫して扱えるようにする仕組みである。

出典メモ

 本記事は、次の4本のPosfie投稿を原型素材として、2026年5月14日に取得し、内容を再構成したものです。

  • エンジョイC023回目仮想記憶(2020-08-30): https://posfie.com/@hoehoe1234/p/oseS81v

  • エンジョイC024回目仮想記憶2(2020-09-06): https://posfie.com/@hoehoe1234/p/MuwUB9I

  • エンジョイC025回目仮想記憶3(2020-09-13): https://posfie.com/@hoehoe1234/p/tgLDYM5

  • エンジョイC026回目仮想記憶4(2020-09-20): https://posfie.com/@hoehoe1234/p/hXoPJkf

 UNIX V6ソースコード確認には、主に次の公開ソースを参照しました。

  • UNIX Sixth Edition proc.h: https://www.retro11.de/ouxr/u6ed/usr/sys/proc.h.html

  • UNIX Sixth Edition slp.c: https://www.retro11.de/ouxr/u6ed/usr/sys/ken/slp.c.html

  • TUHS UNIX V6 text.c: https://www.tuhs.org/cgi-bin/utree.pl?file=V6%2Fusr%2Fsys%2Fken%2Ftext.c

  • TUHS UNIX V6 sys1.c: https://www.tuhs.org/cgi-bin/utree.pl?file=V6%2Fusr%2Fsys%2Fken%2Fsys1.c

  • UNIX Sixth Edition Kernel Source Code index: https://pages.lip6.fr/Pierre.Sens/srcv6/