Edited at

mallocの動作を追いかける(main_arenaとsbrk編)


はじめに

mall0c動画見てますか?>挨拶

Twitterで「malloc動画」で検索かけると、眠れない夜に見ると良いとか、健康に良いとかいろいろ出てきて、健康に良い上にmallocのこともわかるなんて、もう見ない理由はないですね。

というわけで、今回はmallocにおけるmain_arenaとsbrkの振る舞いを追います。


main_arenaとsbrkの動作を追いかける


gdbで追いかける

mallocは、メモリをアリーナ(arena)という単位で管理している。その管理に使われるのがmalloc_state構造体。普通はアリーナは一つだけで、それにmain_arenaという名前がついており、グローバル変数として宣言されている。malloc_state構造体の定義は例えばこちらを参照。

さて、プログラム実行開始直後はmain_arenaは何も管理していない、まっさらな状態になっている。最初にmallocが呼ばれた時、sbrkを呼ぶことでプログラムのヒープを拡張し、それで得たメモリプールからチャンクを切り取って、ユーザに返す。まずはそのあたりの振る舞いを見てみよう。

こんなコードを書いてみる。


test1.cpp

#include <cstdio>

#include <cstdlib>
int
main(void){
char *buf=(char*)malloc(1);
free(buf);
}

なんの変哲もない、ただmallocで1バイトを要求して、それをfreeするだけのコードであるが、このコードを実行すると、


  1. 初めてのmalloc実行なのでsrbkによりヒープ拡張が起きて

  2. 得たアドレスの最初をチャンクとして切り出し、

  3. ヘッダ部を除いた部分をユーザに返し、

  4. freeする時に、チャンクをfastbinsに登録する

という、およそmallocに必要な種々のことが起きるので動作確認には都合が良い。これを-gつきでコンパイルして、gdbで動作を追いかけよう。

まずはmainにブレークポイントを置いて、そこまで実行する。

$ g++ -g test1.cpp

$ gdb -q ./a.out
(gdb) break main
Breakpoint 1 at 0x4006b8: file test1.cpp, line 5.
(gdb) r
Starting program: /path/to/a.out
Breakpoint 1, main () at test1.cpp:5
5 char *buf=(char*)malloc(1);
(gdb)

この状態で、main_arenaにアクセスできる。ただし、今は中身は空っぽである。中身を表示させてみよう。

(gdb) p/x &main_arena

$1 = 0x2aaaab813e80
(gdb) x/24xw &main_arena
0x2aaaab813e80 <main_arena>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813e90 <main_arena+16>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813ea0 <main_arena+32>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813eb0 <main_arena+48>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813ec0 <main_arena+64>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813ed0 <main_arena+80>: 0x00000000 0x00000000 0x00000000 0x00000000

こんな感じで、main_arena構造体のアドレスは0x2aaaab813e80であり、中身は全部ゼロであることがわかる。

さて、このあと最初のmallocが呼ばれるため、sbrkによりヒープ拡張が行われる。sbrkとは、program break、つまりプログラムの一番最後(データセグメントの最後尾)を拡張するためのシステムコールである1。brkが位置の変更、sbrkがサイズを増やす要求をするシステムコールで、同名の関数が用意されている。とりあえずsbrkにブレークポイントを置いて続行してみよう。

(gdb) break sbrk

Breakpoint 2 at 0x2aaaab5728e0
(gdb) c
Continuing.

Breakpoint 2, 0x00002aaaab5728e0 in sbrk () from /lib64/libc.so.6
(gdb)

sbrkにひっかかった。バックトレースも表示してみる。

(gdb) bt

#0 0x00002aaaab5728e0 in sbrk () from /lib64/libc.so.6
#1 0x00002aaaab51c179 in __default_morecore () from /lib64/libc.so.6
#2 0x00002aaaab51896f in _int_malloc () from /lib64/libc.so.6
#3 0x00002aaaab51a1f7 in malloc () from /lib64/libc.so.6
#4 0x00000000004006c2 in main () at test1.cpp:5

malloc内部から呼ばれていることがわかる。

さて、sbrkは、引数として、ヒープを拡張するサイズを取る。したがって、sbrkにブレークポイントを置いて、ひっかかったら引数の値を表示させれば、mallocがどれくらいヒープ拡張を要求したかがわかる。第一引数は%rdiに入っている。

(gdb) p/x $rdi

$2 = 0x21000

0x21000(135168)バイト要求しているようだ。

さて、malloc実行直後まで続けよう。

(gdb) n

Single stepping until exit from function malloc,
which has no line number information.
main () at test1.cpp:6
6 free(buf);

mallocした直後まで来た。bufにどんなアドレスが入っているか調べる。

(gdb) p/x buf

$3 = 0x602010

bufには0x602010というアドレスが返された。したがってチャンクの先頭アドレスは0x602000である。

この状態で、再度main_arenaの中身を表示してみよう。

(gdb) x/24xw &main_arena

0x2aaaab813e80 <main_arena>: 0x00000000 0x00000001 0x00000000 0x00000000
0x2aaaab813e90 <main_arena+16>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813ea0 <main_arena+32>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813eb0 <main_arena+48>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813ec0 <main_arena+64>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813ed0 <main_arena+80>: 0x00000000 0x00000000 0x00602020 0x00000000

いくつかゼロでない値が入ったことがわかる。上記の表示対応する形で、malloc_state構造体の最初の方を図示するとこんな感じ。

まず、0x00000001はフラグ(main_arena->flags)であり、fastbinsにチャンクがあるかどうかを示す。その後fastbinYの配列が10個続くが、本稿では詳細を説明しない。0x00602020はmain_arena->topの値であり、次にメモリ要求があった場合に切り出す位置を示す。

さて、さらにプログラムを進め、free直後の状態を見てみる。

(gdb) n

7 }
(gdb) x/24xw &main_arena
0x2aaaab813e80 <main_arena>: 0x00000000 0x00000000 0x00602000 0x00000000
0x2aaaab813e90 <main_arena+16>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813ea0 <main_arena+32>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813eb0 <main_arena+48>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813ec0 <main_arena+64>: 0x00000000 0x00000000 0x00000000 0x00000000
0x2aaaab813ed0 <main_arena+80>: 0x00000000 0x00000000 0x00602020 0x00000000

先程1が入っていたflagsが0となり、fastbins対象のチャンクが存在することを示している。2次に、fastbinsの一番小さいメモリのビンに、先程のチャンクアドレス0x00602000が登録された。ヒープのトップは相変わらず0x00602020である。


プログラムから追いかける

先程みたいに全部gdbで追いかけても良いが、今後いちいちアドレス指定して値を読むものタルいので、malloc_state構造体を定義して使おう。あとで使いまわすためにヘッダにしてみよう。


malloc.h

struct malloc_state{

int mutex;
int flags;
size_t *fastbinsY[10];
size_t *top;
};
typedef malloc_state* mstate;
mstate main_arena = (mstate)0x2aaaab813e80;

いろいろ手抜きをしているが、とりあえずの目的にはこの程度で十分。最後にmain_arenaを先程gdbで見たアドレス決め打ちでゲットしている。もちろん、アドレス空間が毎回ランダマイズされるようなOSでこの手は使えない。

これを使ってこんなコードを書いてみる。


test2.cpp

#include <stdio.h>

#include <stdlib.h>
#include <unistd.h>
#include "malloc.h"
int
main(void){
printf("---start program---\n");
void *p = sbrk(0);
printf("program break: 0x%lx\n",p);
printf("---call malloc---\n");
char *buf=(char*)malloc(1);
p = sbrk(0);
printf("program break: 0x%lx\n",p);
printf("buf address: 0x%lx\n",buf);
printf("chunk top: 0x%lx\n",buf-0x10);
size_t csize = (size_t)(*(buf-8));
csize ^= 1;
printf("chunk size: 0x%lx\n",csize);
printf("main_arena->fastbinsY[0]:0x%lx\n",main_arena->fastbinsY[0]);
printf("main_arena->top:0x%lx\n",main_arena->top);
printf("---call free---\n");
free(buf);
p = sbrk(0);
printf("program break: 0x%lx\n",p);
printf("main_arena->fastbinsY[0]:0x%lx\n",main_arena->fastbinsY[0]);
printf("main_arena->top:0x%lx\n",main_arena->top);
}


実行するとこんな感じになる。

$ g++ test2.cpp   

$ ./a.out
---start program---
program break: 0x602000 // 1.
---call malloc---
program break: 0x623000 // 2.
buf address: 0x602010 // 3.
chunk top: 0x602000 // 4.
chunk size: 0x20 // 5.
main_arena->fastbinsY[0]:0x0 // 6.
main_arena->top:0x602020 // 7.
---call free---
program break: 0x623000 // 8.
main_arena->fastbinsY[0]:0x602000 //9.
main_arena->top:0x602020 // 10.

sbrkは、成功すると前回のプログラムブレーク、つまりヒープの最後尾を返す。したがって、要求サイズ0で実行すると、現在のヒープの最後尾のアドレスを返してくれる。それを使ってヒープの最後尾をモニタできる。

実際のプログラムの動作は以下の通り。


  1. プログラム開始直後、ヒープの最後尾は0x602000になっている。

  2. mallocを実行すると、ヒープが拡張されて、位置が0x623000になった(先程gdbで確認したとおり、0x21000バイトだけ拡張されている)。

  3. ユーザに返されたのは、その0x10バイト後の0x602010である

  4. その0x10バイト前がチャンクの先頭であり、拡張されたヒープの先頭アドレス0x602000に一致する。

  5. チャンクサイズは0x20(32)バイトである。

  6. この時点ではfastbinsY[0]にチャンクは登録されていない


  7. main_arena->topは、確保したチャンクの直後、つまり先頭アドレス0x602000にチャンクサイズ0x20を足した0x602020を指している

  8. freeした直後も、ヒープ位置は変わらない


  9. fastbinsY[0]に先程解放したチャンクが登録された


  10. main_arena->topの指す位置は(確保したメモリがfreeされたにもかかわらず)変わらない。

図解するとこんな感じ。

(1) 最初はアリーナがなかったのが、

(2) sbrkでヒープが拡張され、

(3) 拡張された領域の先頭からチャンクが切り出された

という感じ。


まとめ

mallocが初めて呼ばれたときにsbrkでヒープ領域が拡張され、チャンクが切り出される様子を確認した。また、main_arenaの内部の変化も観察してみた。全部「こうなるだろう」と思ったことが確認できただけなのだが、実際にプログラムを実行して確認したり、gdbで中身を見たりすると、なんとなくmallocがより身近に感じたり感じなかったりするかもしれない。

続く


参考





  1. より正確には、Linuxではsbrkは内部でbrkシステムコールを使っている。 



  2. 古いmallocでは、fastbinsの最大サイズmaxfastというメンバ変数を持ち、その値の最下位bitをこのフラグに使っていた模様。下位bitをフラグに使うの好きね・・・