🙀

メモリ管理って結局どうなってんねん

に公開

概要

メモリをどのように利用しているか気になる人はいるでしょうか?
メモリがあるおかげでプログラムのロードや、ArrayやMap で使いたい可変の領域を確保できているんですよね。
メモリには物理メモリ、仮想メモリが存在し、それらを支える仕組みが多数存在します。
それらが高度に抽象化されているおかげで普段意識せずにプログラミングできています💪

そんなメモリを支える技術の概要を調べてまとめてみましたので、入口として参考になれたらと思います。

対象読者

  • メモリがどのように管理されているのか知りたい人

物理メモリって?

物理メモリといえば長方形で細長いやつです。
Amazon にも売っていますし、購入できる電気屋も多いのではないでしょうか。
この物理メモリがないと、プログラムで処理中のデータを保存することができません。


物理メモリ

https://www.amazon.co.jp/内蔵メモリ-通販/b?ie=UTF8&node=2151941051

これを何個もPCに取り付けることでいわゆる "メモリの増設" をしている人もいるでしょう。

1本に見せる

実は物理メモリは抽象化され、常に1本のメモリのように見せられています。
そのためメモリが増えるほどメモリアドレスの範囲が広がります。


物理メモリの抽象化

抽象化されている理由は、OS側がメモリが常に1つであることを前提としているからです。
OSが物理メモリのアドレス範囲知るための機構がUEFI/BIOSのようなファームウェアに備わっています。

OS「自分が使えるアドレス範囲ってどんなもん?」
ファームウェア「これ、今の物理メモリのアドレス範囲っす」つ📝
OS 「🤟」

というやりとりを行い、利用できるアドレス範囲を把握しているのです。

忘れちゃいけないディスク

物理メモリの役割はデータの入れ物に過ぎません。
入れ物に入れるものは例えばディスクのデータがあげられます(もちろんそれ以外もあります)。
ディスクとはHDD、SSD などのデータを保存するハードウェアです。
ディスクの内容を物理メモリにロードすることによって、物理アドレスを介したデータのアクセスができるようになります。


物理メモリとディスク

ディスクにはファイルなどが保存されますので、例えば実行可能なファイルの .data セクションに所属しているグローバル変数などはディスクから読み込まれた値となるでしょう。
この辺りについて深堀はしませんが、以下のキーワードをもとに調べてみると面白いかと思います。

  • ELF
  • readelf
  • objdump
  • セクションヘッダ
  • プログラムヘッダ

仮想メモリって?

各プロセスに割り当てられる固有のアドレス空間です。
仮想メモリとその周辺を簡易的に整理したものが以下の図です。


仮想メモリと物理メモリ

仮想メモリを支える技術

仮想メモリはプログラムの実行にとって欠かせない空間です。
そんな仮想メモリはざっくり以下のような仕組みによって支えられています。

ページテーブル

ページテーブルは仮想アドレス空間と物理アドレス空間の対応表です。
少々わかりにくいですが、範囲と範囲を関連付けているので name: "ドラえもん" のような単純な形式ではありません。
例えば以下のように空間同士を関連付けています。

仮想アドレス空間 物理アドレス空間
0x1000 ~ 0x1fff 0x1000 ~ 0x1fff
0x3000 ~ 0x3fff 0x5000 ~ 0x5fff
... ...

この空間は ページ と呼ばれています。
さらにページテーブルの各レコードは ページテーブルエントリ と呼ばれます。

ページ

ある単位で区切られた仮想アドレス空間を ページ(仮想ページ) と呼びます。
私の端末の場合は x86 のため、 4KiB という単位でページが作成されます。
同様に物理アドレス空間も 4KiB の ページ(物理ページ) という単位で区切られます。
この区切られた者同士をページテーブルで関連付けています。

MMU(メモリ管理ユニット)

仮想アドレスを物理アドレスへ変換する CPUの機能です

TLB

最近変換を行った結果を保存しておくキャッシュ機構です。
キャッシュヒットした場合、ページテーブルを見に行く必要がないため TLB だけで完結します。
キャッシュミスした場合、ページテーブルが参照されます。

ページフォールト

対応する物理アドレスがない場合に発生する例外です。
この例外が発生した際は以下2通りの分岐が行われます。

  • カーネルが ページフォールトハンドラ を実行。
  • アクセスそのものが不正なら セグメンテーション違反 というシグナルを送出。

ページフォールトハンドラが実行されれば、ディスクから必要なページを確保して、物理メモリへロードします。
その後仮想アドレスと物理アドレスの対応をページテーブルに記録することで、プロセスが参照できるようにします。

スワップ(スワップ領域)

物理メモリがすでに枯渇しているときに、ハードディスクに一部を逃がすことで、物理メモリをもっと使えるようにするため仕組みです。
その仕組みによってハードディスクに用意された領域をスワップ領域といいます。
スワップ領域へ逃がすことを ページアウト といいます。(スワップアウトともいうらしい)
何を逃がすかはカーネルが判断しています。(使用頻度の低いページを逃がします)
その反対として、スワップ領域へ逃がした内容を戻すことを ページイン といいます。
物理メモリに余裕が出た際に、ページアウトした領域へアクセスすると再び物理メモリへ読み込まれます。

ASLR あるいは KASLR

実際にマッピングされる仮想アドレス空間をランダムにする仕組みです。
リンクされた状態のファイルで事前に想定されている仮想アドレスの位置からランダムな offset 移動させます。
これはセキュリティのためです。
外部からアドレス位置を容易に特定できてしまうことは、自分の口座番号とパスワードをマスキングかけずに晒しているのと大差ありません。

例えば実行可能なファイルに readelf コマンドでプログラムヘッダを表示すると以下のように VirtAddr に値がセットされていますが、あくまで想定位置という内容です。

K(ASLR) によってランダムな位置となるため、実行ごとに仮想アドレスの位置はことなるでしょう。
これはプログラム中の変数のアドレスなどもそうです。

$ readelf -l -W /usr/bin/ls

Elf file type is DYN (Position-Independent Executable file)
Entry point 0x6d30
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R   0x8
  INTERP         0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R   0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x0036f8 0x0036f8 R   0x1000
  LOAD           0x004000 0x0000000000004000 0x0000000000004000 0x014db1 0x014db1 R E 0x1000
  LOAD           0x019000 0x0000000000019000 0x0000000000019000 0x0071b8 0x0071b8 R   0x1000
  LOAD           0x020f30 0x0000000000021f30 0x0000000000021f30 0x001348 0x0025e8 RW  0x1000
  DYNAMIC        0x021a38 0x0000000000022a38 0x0000000000022a38 0x000200 0x000200 RW  0x8
  NOTE           0x000338 0x0000000000000338 0x0000000000000338 0x000030 0x000030 R   0x8
  NOTE           0x000368 0x0000000000000368 0x0000000000000368 0x000044 0x000044 R   0x4
  GNU_PROPERTY   0x000338 0x0000000000000338 0x0000000000000338 0x000030 0x000030 R   0x8
  GNU_EH_FRAME   0x01e170 0x000000000001e170 0x000000000001e170 0x0005ec 0x0005ec R   0x4
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0x10
  GNU_RELRO      0x020f30 0x0000000000021f30 0x0000000000021f30 0x0010d0 0x0010d0 R   0x1

 Section to Segment mapping:
  Segment Sections...
   00
   01     .interp
   02     .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
   03     .init .plt .plt.got .plt.sec .text .fini
   04     .rodata .eh_frame_hdr .eh_frame
   05     .init_array .fini_array .data.rel.ro .dynamic .got .data .bss
   06     .dynamic
   07     .note.gnu.property
   08     .note.gnu.build-id .note.ABI-tag
   09     .note.gnu.property
   10     .eh_frame_hdr
   11
   12     .init_array .fini_array .data.rel.ro .dynamic .got

ページが確保される様子を確認しよう

プログラムがメモリを確保する様子を確認して、ページが増える瞬間を見てみましょう。
以下検証は デマンドページング という仕組みを観察する内容となっています。
新規メモリ領域を確保したが、まだ物理アドレスが存在しない状態で最初にアクセスされた際にページ(4KiB)の割り当てが行われます。

検証環境

  • zig 0.14.0
  • gdb (GNU gdb) 16.3

サンプルコード

今回の記事では zig を使って検証します。
勿論 zig を動かす環境がなくとも、分かるように話は進めていきます。

main.zig
const std = @import("std");

pub fn main() !void {
    //ページアロケータを使って、仮想メモリのページが増える様子を確認する
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();

    const allocator = arena.allocator();

    /// 1つ目のページ(4KiB)が作成されることを確認する///

    //NOTE: 4KiB(4096byte) より少し小さめに確保する
    //8byte(sizeOf(u64)) * 510 = 4080byte;
    //1ページ目の上限近くなると2ページ目がこの時点で確保されてしまうため
    var buf = try allocator.alloc(u64, 510);
    for (0..510) |i| {
        buf[i] = 1;
    }
    std.debug.print("write\n", .{});

    /// 2つ目のページ(4KiB)が作成されることを確認する ///

    //2ページ目(4097byte~)のサイズとなるようにメモリを確保する
    buf = try allocator.realloc(buf, 513);
    for (510..513) |i| {
        buf[i] = 2;
    }

    std.debug.print("done\n", .{});
}

なぜ zig なのか

zigは利用者にメモリ管理を任せるようになっています。
たとえばこれをGoで検証しようとすると、おそらく期待通りの動作をするかは怪しいでしょう。
任意のタイミングでメモリの確保と再確保を行えるzigの方が低レイヤの動きを知るのに適しています。

デバッグビルド行い実行ファイルの準備ができたらデバッグを始めましょう。

$ zig build-exe main.zig -O Debug

本検証ではウィンドウを2つ使います。片方がデバッグ用で、もう片方はページが増える様子を確認するために使います。

ページの確保

片方のウィンドウで gdb を main にアタッチします。
以下の内容に沿ってデバッグまで開始します。

デバッグ開始 ~ main関数まで
$ gdb main

#ソースコードをTUIで表示します
(gdb) layout src

#main関数へブレークポイントを貼ります
(gdb) br main.main

#デバッグを開始します
(gdb) run


main関数まで実行された様子

run を実行したら今度は別ウィンドウでメモリの様子を確認します。

メモリの観察
$ PP=`pgrep main`; watch -n 1 pmap -x $PP;


1秒おきにpmapが再実行される

再び gdb のウィンドウへ戻り、いくつかブレークポイントを貼ります。
画像の赤枠の位置に貼りましょう。


メモリアロケーションとアサインをする箇所

添付が終わったら c (continue) でデバッグを再開します。

検証箇所にブレークポイントを張る
#ブレークポイントを貼る
(gdb) br 12
(gdb) br 14
(gdb) br 18
(gdb) br 20

#最初のブレークポイントまで進める
(db) c

デバッガ上では alloc(u64, 510) している箇所でストップされています。

//現在のステップはココ
var buf = try allocator.alloc(u64, 510);

このステップを実行します。

bufの次のステップへ
(gdb) c

メモリの確保のステップが実行されました。
ですがメモリを監視するウィンドウに変化はありません。
これは検証前の説明で記載した通り デマンドページング という仕組みにより、まだ値のアクセス等がないためです。

//領域を確保しただけ
var buf = try allocator.alloc(u64, 510);
for (0..510) |i| {
    //現在のステップはココ
    buf[i] = 1;
}

現在はまだ1回目のloopも実行されていないため、buf[i] = 1 で値のアサインをしてページが確保される様子を確認してみましょう。

buf[i] = 1 へ
(gdb) c

するとメモリが増えたことが確認できます。
該当するアドレスは私の環境だと 00007ffff7ff7000 です。
RSSが物理メモリに乗ったサイズです。ページ でも記載したように 4KiB 単位でページは確保されます。


メモリが増えた

これは値の割り当ての際以下の デマンドページング の仕組みが実行されたことに起因します。

  1. alloc(u64, 510)により(8byte x 510)分のメモリを確保し、ページテーブルエントリに登録
  2. buf[i] = 0; 割り当ての際に、対応する物理メモリが確保されていない
  3. ページフォールト が発生
  4. ページフォールトハンドラ を実行し、物理メモリを割り当てる

同様の操作も realloc(buf, 513) に対して行いましょう。
現在 loop の中にいるため一旦現在のブレークポイントを削除して抜けてから行います。

#現在のブレークポイント一覧を表示
(gdb) i b

## 番号付きで表示される ##

# 対象の番号を削除すれば、ブレークポイントを削除される
(gdb) d <番号>

#同様の操作を行う
(gdb) c

RSSサイズが更に4増えて、8となっていることが確認できます。


2度目のデマンドページング

確認が終わったら gdb は終了しましょう。

終了
(gdb) quit

まとめ

メモリ抽象化されて扱われるため普段から意識することはないかもしれません。
ですがプログラムの裏側ではこのようにしてデータの一時保存領域を確保し動いているんだなというイメージが以前より湧くようになりましたら幸いです。
ここで紹介した内容はメモリ管理に関する内容の一部でしかありませんので、興味が湧いた方は引き続き調べてみると新たな発見があるかと思います。

GitHubで編集を提案

Discussion