Rustのメモリあれこれ

あまり理解しないで使ってたのでまとめる。
個人的に理解している内容をまとめてみる。
ELFについて
この先の話をするにあたって、前提知識としてELFについて軽く知っている必要がある。
ELFとはExecutable and Linkable Format
の略であり、ファイルフォーマットの一種である。
rustだとcargo build
したときにtarget/debug
配下に生成されているファイルなどを指している認識。
このファイルを実行することでユーザーはプログラムを実行することができる。
内部的には以下のような構造になっている。
+----------------------------+
| ELF Header | <- ELF ファイルの先頭。ファイル形式やエントリーポイントを記載
+----------------------------+
| Program Header Table | <- 実行時にカーネルが読む情報。ロードアドレスやメモリマッピング
+----------------------------+
| Section Header Table | <- コンパイル時にリンカやデバッガが読む。各セクションの情報
+----------------------------+
| .text | <- 実行可能コード
| .rodata | <- 読み取り専用データ(定数文字列など)
| .data | <- 初期化済みグローバル変数
| .bss | <- 初期化されていないグローバル変数
| .debug_* | <- DWARF デバッグ情報
| .symtab / .strtab | <- シンボルテーブル
| .eh_frame | <- stack unwinding 用情報
| .comment / .note | <- コンパイラ情報、ビルド情報
+----------------------------+
簡単に各ヘッダーの補足をする。
1. ELF Header
他2つのヘッダへのオフセットやarchの情報を持っている。
$ readelf -h ./target/debug/dap-setup
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x78d0
Start of program headers: 64 (bytes into file)
Start of section headers: 3982280 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 12
Size of section headers: 64 (bytes)
Number of section headers: 40
Section header string table index: 39
2. Section Header
複数のオブジェクトファイルを結合するときにリンカが使用する。
また、逆アセンブルやデバックを行う際にもこの情報を使用する。
実行可能バイナリを作るために使用される情報。
また、ELFに対する解析を行う差異にもこの情報を参照する。
3. Program Header
OSカーネルがELFをロードして実行するときに使用する。
実行可能バイナリの作り方
ELFが作成される流れについて理解するために、コンパイル言語がソースコードからどのように実行可能バイナリを作るか確認する。
1. コンパイル
ソースコードに対して、C/C++ならgcc/clang
, Rustならrustc
を用いてコンパイルを行う。
この時に、ソースコードはアセンブリ(.s
)に変換される。
*通常コンパイルとアセンブルは一括で行われるので、.s
がディスク上に残ることはない。
2. アセンブル
コンパイラが生成したアセンブリファイル(.s
)をアセンブラが機械語バイト列に変換し、ELF形式へと変換する。
この段階での生成物を**Object File(.o
)**と呼ぶ。
ELF形式にはなっていますがProgram Headerを持っていないため実行することはできない。
3. リンク
上記のコンパイル・アセンブルによって複数のオブジェクトファイルが生成される。
リンカは全てのオブジェクトファイルのセクションをまとめて配置し直す。
これにより、各ファイルに分かれていた.text
や.data
が結合され、1つの大きなセクションになる。
また、リンカは複数の.o
を結合する際に以下を行いセグメントを作成する
- どのセクションをまとめて1つのセグメントにするかを決定する
- セグメントのファイルオフセット、仮想アドレス、アクセス権限などを決定する
このセグメントの情報をまとめたものがProgram Header Tableになっている。
これにより、最終的にリンカは1つの実行可能バイナリを生成する。
メモリ領域について理解する
実行可能バイナリを実行したときの動きを理解するために、各メモリ領域について簡単に確認する。
1. 静的領域
プログラム実行時に一度だけ確保され、終了まで解放されないメモリ領域。
Rust的にはstaticで宣言された変数
や文字列リテラル
、グローバル変数
はライフタイムが切れることがないので、静的領域に配置され、メモリが解放されることがない。
2. Stack領域
プロセス起動時にあらかじめ確保しているメモリ領域。
Rust的には変数のライフタイムが切れたときにスタックポインタを返却する。
syscall的にはOSがプロセス起動時に固定サイズをmmapする。
3. Heap領域
スタックのように自動管理されていないメモリ領域。
プロセス起動時にあらかじめ領域を確保しているわけではないが、必要に応じてOSから割り当てることができる。
memory allocator関連の話で、実際にはmmap
などで大きな領域を確保しておいて、その中をmallocで管理する。
これにより小さなメモリ領域の確保にはsyscallをする必要が無くなる。
実行可能バイナリを実行する
上のメモリ領域の内容を前提として、実行ファイルを動かした時のメモリ領域の動きについて考える。
execve()
syscallの呼び出し
1. プロセスが新しいプログラムを実行するためにexecve(2)
を呼ぶ。
これによって、OSカーネルは実行ファイルを読み込んで、プロセス用のアドレス空間を作り始める。
2. ELFヘッダとプログラムヘッダを読み込む
カーネルはELFの先頭にあるELFヘッダから、実行可能なファイルかを確認する。
(プログラムヘッダの情報がなければ実行できない)
その後プログラムヘッダの情報を読み込み、各セグメントをどのメモリにマッピングするべきかを確認する。
3. メモリマッピング
プログラムヘッダに従ってセグメント単位でメモリを割り当てる
- コードセグメント(
.text
,.rodata
)-
R-X
でマッピング - 実際にはmmap(2)でファイルのページをそのまま仮想メモリに対応付けする?
-
- データセグメント(
.data
,.bss
)-
RW-
でマッピング -
.bss
はゼロ初期化領域なので、ファイル上に存在せずゼロクリアされたページが割り当てられる?
-
- スタック
- カーネルが領域をmmap
- プロセスごとにOSが上限サイズ(rlimit)を決めて用意
- ヒープ
-
malloc
からmmap
が呼ばれることでメモリ確保
-
4. 動的リンカ
*動的リンカはRustで使ったことないので説明を飛ばします...
5. スタックの初期化
カーネルがスタックに以下をセットする。
- argc(引数の数)
- argv(引数の文字列配列)
- envp(環境変数配列)
- auxv(補助ベクタ:ELF のロード情報や CPU 機能フラグなど)
// TODO: あまり分かってないので調べる
6. エントリポイントへジャンプ
ELF ヘッダの e_entry にあるアドレス(エントリポイント)に制御が移る
動的リンクなし: いきなり main へ行かず、まず C ランタイム初期化コード (_start) へジャンプ
動的リンクあり: まずリンカがライブラリを解決して _start を呼ぶ
// TODO: あまり分かってないので調べる

(memo) Virtual Function Tableについて
arg: &dyn TraitObject
のような、TraitObjectを実装したオブジェクトを引数として取る関数がある場合、
この引数argは data pointer
と virtual function table
の16byteをもつfat pointerになる。
- data pointer
- TraitObjectを実装しているオブジェクトの先頭アドレスが入る
- 8byte(先頭アドレス)
- Object自体のアドレスなので、Stack領域上を指すことが多い
- virtual function table
- TraitObjectを実装したObjectを引数として受け取る場合、TraitObjectの持っている関数群に対するアドレスを受け取る必要がある。
- TraitObjectは複数の関数interfaceを持っている可能性があるが、関数なのでこのデータは静的領域上に配置されることになる。
- このTraitObjectが持っている関数interfaceを実装した実態(静的領域上にある)をTraitごとにまとめたものを
Vritula Function Table
と呼んでいる -
Virtual Functin Table
は静的領域上に配置されている。 - 引数としてはこのアドレス(8byte)を受け取ることになる。
備考として、
- 関数は静的領域上に配置され、その関数を実行する度に引数となっている変数がStack領域(もしくはHeap領域)上でメモリの確保を行う
wip

ELFにおけるSection Header Tableの詳細
Section Header Tableについて、個人的な認識を再度まとめると
- ソースコードのコンパイルをしたときのアセンブリファイル(
.s
)やオブジェクトファイル(.o
), リンクした後の実行バイナリについている。 - 各セクションがファイルの中のどのオフセット、アドレスに配置されているかが書かれている。
Section Header Tableの構成
$ readelf -S ./target/debug/dap-setup
There are 40 section headers, starting at offset 0x3cc3c8:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .interp PROGBITS 00000000000002e0 000002e0
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.gnu.build-i NOTE 00000000000002fc 000002fc
0000000000000024 0000000000000000 A 0 0 4
[ 3] .note.ABI-tag NOTE 0000000000000320 00000320
0000000000000020 0000000000000000 A 0 0 4
[ 4] .gnu.hash GNU_HASH 0000000000000340 00000340
0000000000000024 0000000000000000 A 5 0 8
[ 5] .dynsym DYNSYM 0000000000000368 00000368
0000000000000630 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 0000000000000998 00000998
000000000000040d 0000000000000000 A 0 0 1
[ 7] .gnu.version VERSYM 0000000000000da6 00000da6
0000000000000084 0000000000000002 A 5 0 2
[ 8] .gnu.version_r VERNEED 0000000000000e30 00000e30
0000000000000100 0000000000000000 A 6 4 8
[ 9] .rela.dyn RELA 0000000000000f30 00000f30
0000000000004938 0000000000000018 A 5 0 8
[10] .rela.plt RELA 0000000000005868 00005868
0000000000000060 0000000000000018 AI 5 27 8
[11] .init PROGBITS 0000000000006000 00006000
000000000000001b 0000000000000000 AX 0 0 4
[12] .plt PROGBITS 0000000000006020 00006020
0000000000000050 0000000000000010 AX 0 0 16
[13] .plt.got PROGBITS 0000000000006070 00006070
0000000000000008 0000000000000008 AX 0 0 8
[14] .text PROGBITS 0000000000006080 00006080
0000000000040973 0000000000000000 AX 0 0 16
[15] .fini PROGBITS 00000000000469f4 000469f4
000000000000000d 0000000000000000 AX 0 0 4
[16] .rodata PROGBITS 0000000000047000 00047000
0000000000005517 0000000000000000 A 0 0 16
[17] .debug_gdb_script PROGBITS 000000000004c517 0004c517
0000000000000022 0000000000000001 AMS 0 0 1
[18] .eh_frame_hdr PROGBITS 000000000004c53c 0004c53c
000000000000105c 0000000000000000 A 0 0 4
[19] .eh_frame PROGBITS 000000000004d598 0004d598
0000000000005a50 0000000000000000 A 0 0 8
[20] .gcc_except_table PROGBITS 0000000000052fe8 00052fe8
0000000000001134 0000000000000000 A 0 0 4
[21] .tdata PROGBITS 0000000000055e08 00054e08
0000000000000020 0000000000000000 WAT 0 0 8
[22] .tbss NOBITS 0000000000055e28 00054e28
0000000000000040 0000000000000000 WAT 0 0 8
[23] .init_array INIT_ARRAY 0000000000055e28 00054e28
0000000000000010 0000000000000008 WA 0 0 8
[24] .fini_array FINI_ARRAY 0000000000055e38 00054e38
0000000000000008 0000000000000008 WA 0 0 8
[25] .data.rel.ro PROGBITS 0000000000055e40 00054e40
0000000000002738 0000000000000000 WA 0 0 8
[26] .dynamic DYNAMIC 0000000000058578 00057578
0000000000000230 0000000000000010 WA 6 0 8
[27] .got PROGBITS 00000000000587a8 000577a8
0000000000000858 0000000000000008 WA 0 0 8
[28] .data PROGBITS 0000000000059000 00058000
0000000000000068 0000000000000000 WA 0 0 8
[29] .bss NOBITS 0000000000059068 00058068
0000000000000100 0000000000000000 WA 0 0 8
[30] .comment PROGBITS 0000000000000000 00058068
0000000000000057 0000000000000001 MS 0 0 1
[31] .debug_aranges PROGBITS 0000000000000000 000580bf
00000000000072d0 0000000000000000 0 0 1
[32] .debug_info PROGBITS 0000000000000000 0005f38f
00000000000fd35d 0000000000000000 0 0 1
[33] .debug_abbrev PROGBITS 0000000000000000 0015c6ec
00000000000011ed 0000000000000000 0 0 1
[34] .debug_line PROGBITS 0000000000000000 0015d8d9
000000000006640e 0000000000000000 0 0 1
[35] .debug_str PROGBITS 0000000000000000 001c3ce7
0000000000149898 0000000000000001 MS 0 0 1
[36] .debug_ranges PROGBITS 0000000000000000 0030d57f
00000000000ac9d0 0000000000000000 0 0 1
[37] .symtab SYMTAB 0000000000000000 003b9f50
0000000000005070 0000000000000018 38 524 8
[38] .strtab STRTAB 0000000000000000 003befc0
000000000000d27c 0000000000000000 0 0 1
[39] .shstrtab STRTAB 0000000000000000 003cc23c
000000000000018b 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
セクションヘッダはプログラムの実行にはあまり関係してこないので、一旦説明を飛ばす。

ELFにおけるProgram Header Tableの詳細
$ readelf -l ./target/debug/dap-setup
Elf file type is DYN (Shared object file)
Entry point 0x78d0
There are 12 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x00000000000002a0 0x00000000000002a0 R 0x8
INTERP 0x00000000000002e0 0x00000000000002e0 0x00000000000002e0
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x00000000000058c8 0x00000000000058c8 R 0x1000
LOAD 0x0000000000006000 0x0000000000006000 0x0000000000006000
0x0000000000040a01 0x0000000000040a01 R E 0x1000
LOAD 0x0000000000047000 0x0000000000047000 0x0000000000047000
0x000000000000d11c 0x000000000000d11c R 0x1000
LOAD 0x0000000000054e08 0x0000000000055e08 0x0000000000055e08
0x0000000000003260 0x0000000000003360 RW 0x1000
DYNAMIC 0x0000000000057578 0x0000000000058578 0x0000000000058578
0x0000000000000230 0x0000000000000230 RW 0x8
NOTE 0x00000000000002fc 0x00000000000002fc 0x00000000000002fc
0x0000000000000044 0x0000000000000044 R 0x4
TLS 0x0000000000054e08 0x0000000000055e08 0x0000000000055e08
0x0000000000000020 0x0000000000000060 R 0x8
GNU_EH_FRAME 0x000000000004c53c 0x000000000004c53c 0x000000000004c53c
0x000000000000105c 0x000000000000105c R 0x4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x0000000000054e08 0x0000000000055e08 0x0000000000055e08
0x00000000000031f8 0x00000000000031f8 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .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 .text .fini
04 .rodata .debug_gdb_scripts .eh_frame_hdr .eh_frame .gcc_except_table
05 .tdata .init_array .fini_array .data.rel.ro .dynamic .got .data .bss
06 .dynamic
07 .note.gnu.build-id .note.ABI-tag
08 .tdata .tbss
09 .eh_frame_hdr
10
11 .tdata .init_array .fini_array .data.rel.ro .dynamic .got

dlopen
, dlsym
と動的リンカ
dlopen
, dlsym
を使用する場合と動的リンカを設定する場合の使い分けについて
1. 動的リンカを設定する
例えばコンパイルによって動的リンクを設定する場合、
コンパイルする環境と実行環境の両方に、その動的リンカのDT_NEEDED
に書かれているライブラリが必要になる。
*DT_NEEDED
は動的リンカによって読み込まれる共有ライブラリの情報が書かれている。動的リンカもEFLであり、Program Headerのdynamic Segmentの中に存在している。
dlopen
, dlsym
2. dlopen
とdlsym
を使用する場合、コンパイル環境に使用する共有ライブラリがなくてもコンパイルを行うことができる。
これによって、実行環境に応じて柔軟に共有ライブラリを変更することができる。
LD_PRELOAD
LD_PRELOAD
は環境変数であり、共有ライブラリの情報を設定しておく。
この環境変数を使用することで、動的リンカに設定されている共有ライブラリの読み込み時に割り込むことができる。
処理の流れとしては、
- 動的リンカが設定されている実行可能バイナリを実行する
- 設定されている動的リンカの解決が行われる
- 動的リンカのELF Headerの解決を行った後にProgram Headerの解決を行う。
- Program Headerのdynamic segmentに書かれている共有ライブラリの解決を行う。
この共有ライブラリの解決の先頭に割り込んで、LD_PRELOAD
に設定されている共有ライブラリの解決を行う。
基本的に、動的リンカのシンボル解決は先に設定されているものが優先されるので、LD_PRELOAD
で割り込みを行うことで、共有ライブラリの実装を上書きすることができる。
これによって、malloc
の実装をwrapしたり、__libc_start_main
の上書きを行うことができる。

この機能を使用することで、malloc
を差し替えてログを出したりするとこができる。
また、コンパイル済みバイナリのmain関数を置き換えたり、main関数の前に処理を追加することができる。
プログラム的にはmain
関数が一番初めに実行されているように見えるが、実際にはその前に初期化処理を行なっている。 -> __libc_start_main
- コンパイル済みバイナリを実行すると、ELFのエントリポイントである
_start
が呼ばれる -
_start
の中では最初に__libc_start_main
が実行される -
__libc_start_main
実行後にユーザーのmain
関数が実行される
*gnuではなくmuslを使用する場合、musl独自の__libc_start_mainが使用される。(muslは軽量版)
__libc_start_main
__libc_start_main
は以下のことを行なっている。
- 引数を受け取る
-
main
の関数ポインタやargc
,argv
,envp
を渡される
-
-
libc
の内部初期化-
errno
の設定やTLS(Thread Local Strage)の設定
-

TLS
(Thread Local Storage
)について
大枠として、
1. プロセス
実行中のプログラムのまとまりのこと。
vim
を開いたら1プロセス。firefox
を開いたら1プロセスになる。
各プロセスは独立したアドレス空間を持つ。
2. スレッド
プロセスの中で走る処理の流れ。
1プロセスの中には1つ以上のスレッドが存在し、最初のスレッドをメインスレッドという。
複数のスレッドは同じアドレス空間を共有する。
複数のスレッドが同じアドレス空間を共有することで、グローバル変数などを実現することができている。
しかし、各スレッドごとに独立した変数を持ちたくなることがある。
このときにTLS
(Thrad Local Straga
)を使用する。
TLSを使用することで、共有しているアドレス空間を仮想的に分割して、各スレッドごとにisolateすることができる。

以下間違っている内容
Threadごとにメモリ領域の確保・分割を行う
以下の2つで内部処理が変わっている。
-
Thread::spawn
を使用するようなコードを実行する場合 - 各スレッドごとにグローバル変数を要するようなコードを実行する場合
thread::spawn
を使用するコードを実行する場合
1. rustのthread::spawn
を使用する場合、内部的にはlibcのpthread_create
を使用している。
この関数は、内部的にmmap
とclone
システムコールを実行している。
これによって、スレッドが増えるたびにスタック領域の確保が行われている。
malloc
でも大きなメモリの確保ではmmap
を使用している。今回のpthread_create
でもmmap
を使用しているので、ヒープ領域とスタック領域のメモリの違いについてわからなくなった。
実際のところ、ヒープ領域もスタック領域も同じメモリを使用している。違うのは管理の方法。
ヒープ領域はmalloc
で管理され、メモリのライフサイクルもmalloc
で管理されている。
それに対して、スタック領域はOSが管理している。
具体的には、threadが終了したときにメモリの解放が行われる
。
スレッド内部でグローバル変数を使用したい場合
このときにTLS
(Thread Local Storage
)の仕組みが使用される。
1. コンパイル時
TLSを使用するような変数を宣言した場合、コンパイラ・リンカによって、該当の実装はELFのPT_TLS
セクションに配置される。
- ファイルサイズ
- メモリサイズ
- 仮想アドレス
- アラインメント制約
2. バイナリ実行時
PT_TLS
に応じて、メインスレッド用のTLS領域を確保し、

スレッドを生成する処理を実行する際のメモリ領域
例えば、rustでthreadを新たに生成するような処理だと、thread::spawn()
を使用する。
この関数を実行したすると内部ではlibcのpthread_create
を実行しており、pthread_create
では以下のことを行っている。
-
mmap
,clone
システムコールを実行するして、thread用のスタック領域を確保する - libcの
malloc
を読んで、各スレッドごとに存在するTLS用の領域を確保する
スレッドを生成する際には、各スレッドごとにスタックフレームが必要になるので、新たにスタック領域を確保する必要がある。
また、各スレッドごとに独立したグローバル変数を使用でいるようにするために、TLS用のメモリ領域を確保する必要がある。
この領域はサイズが大きくなく、いちいちmmap
を使用してメモリ領域の確保を行なっていると無駄なので、malloc
で確保しているメモリTLS用のメモリ領域を確保することが一般的である。