補足授業7. 2次元配列をmallocで確保するとこんな弱点ができる話

今回は補足授業6で取り上げた10章の最後にごしょごしょっと書いていたことについて振り返ろう。今回はちょっとコンセプトを変えて、「当時わからなかったことを解決する」から、理解しきれていなかったことを穴埋めする補完計画にしよう。


似てるけど違うもの

int *p[15];

int (*p)[15];

は意味が違う。というのが当時流行った。媒体は何か忘れてしまったんだけど、何か結構流行ったという記憶だけ残っていて、当時もそれなりに調べてなーるほどーと思ったはずではあるが、じゃあ具体的に何がどう違うのかと言われると、うーん。わからない〜。

ってことで調べていく。

C言語の宣言は変数名から出発して右を見て、次に左を見て、という順番で読んでいく。

まず int *p[15] の方。

int *p[15];

p から出発 → 右に [15] がある → 「15個の配列」→ 左に * がある → 「ポインタの」→ 左に int がある → 「int型へのポインタの、15個の配列」

次に int (*p)[15] の方。() は読む順番を変えるグルーピングで、「ここから先に読め」という指示になる。

int (*p)[15];

() の中の p から出発 → () の中で左に * がある → 「ポインタ」→ () を出て右に [15] がある → 「15個の配列への」→ 左に int がある → 「int型15個の配列への、ポインタ」

int *p[15];    /* pは配列(15個)、中身はintへのポインタ */
int (*p)[15];  /* pはポインタ、指す先はint[15]の配列 */

() がなければ右の [] が先に読まれて配列が主語になる、() で囲めばポインタが先に読まれてポインタが主語になる。という感じだ。

似てるようで全然違うものだった。というのがそもそものネタで。
ちゃんと理解しておかないとえらいことになるやつだと思う。


2次元配列をmallocで確保する弱点

10章で2次元配列をmallocで動的に確保するプログラムを書いていて、こんなことが起きていた。

2次元配列をmallocで確保する

文字列は何個: 3
p[0] : fwagjfpawfhawh4t08awyhtg08awyharghawrgarga
p[1] : jfawfjawhfawfhawufh0a8hw04awhfa
p[2] : afhwhffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
p[0] = fwagjfpawfhawh4jfawfjawhfawfhaafhwhffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
p[1] = jfawfjawhfawfhaafhwhffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
p[2] = afhwhffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

入力したときと表示したときで内容が変わっている。p[0] の表示が p[1]p[2] の内容とつながっちゃってる。さらにプログラムを終了しようとすると、

*** glibc detected *** ./a.out: free(): invalid next size (fast): 0x0000000001088010 ***

こんなエラーが出てアボート。

これに関しては記憶も残ってなくて、なんじゃこりゃな認識だ。
改めてAIニキと一緒に見てみると、これはヒープバッファオーバーフローという現象だった。

何が起きているかというと、各行を固定の小さいサイズでmallocしていて、実際に入力された文字列がその確保したサイズを超えてしまっている。mallocで確保したメモリの領域を超えて書き込んでいるということは、隣の領域に侵入しているわけで、ヒープ上で隣り合っている p[1]p[2] の領域を踏み荒らしている、ということらしい。えらいことしてるな。

だから p[0] を表示すると p[1]p[2] の内容まで見えてしまう。メモリ上で地続きだから、ヌル文字に当たるまでズルズルと読み続けてしまうのだ。

そして最後の free() のエラー。これはglibcがメモリを管理するために使っているメタデータ、つまりヒープの管理情報が、オーバーフローによって壊されてしまったために起きている。freeしようとしたら管理情報がおかしくなっていて、「これ壊れてるんですけど」とglibcが怒っている、という構図。

整理すると、弱点というのはこういうことだと思う。2次元配列を行ごとにmallocで確保する方式は、各行のサイズを自分で管理しなければならない。確保したサイズより大きいデータを書き込んでもmallocは何も言わない。静かに隣の領域を壊す。配列のように添え字で範囲外アクセスすると警告が出る環境もあるけど、mallocで確保した領域にはそういう親切な仕組みがない。自由の代償というか、全部自己責任の世界。

なんでこんな関数が残ってるんだと尋ねてみると、速さと引き換えの自己責任みたいなんやろうか。

教訓としては、mallocで文字列用のメモリを確保するなら、入力の長さを先に調べてからそのサイズ分確保するか、十分なバッファサイズを確保するか、あるいは fgets みたいに読み込みサイズを制限する関数を使うか、何らかの対策が必要だったということ。


mallocとcallocの違い

ついでに、というか10章で出てきたのでメモしていたもの。

/* malloc */
#include <stdlib.h>
void *malloc(size_t size);

/* calloc */
#include <stdlib.h>
void *calloc(size_t nmemb, size_t size);

mallocは指定したバイト数のメモリを確保する。確保するだけ。中身は不定、つまりゴミが入っている可能性がある。なので確保したらまずNULLチェック、中身はmemsetでゼロ埋め、というのが定石になる。ゼロ値とnil。なんだGoか。

callocは「要素数 × 要素サイズ」でメモリを確保して、さらに全部ゼロで初期化してくれる。malloc が1979年ごろの関数。なるほどそれでcalloc ができたのかー。ふむふむとそういう流れになるはずなんだけれど、callocはVersion 6 Unix(1975年)の時点で既に存在していて、mallocが現在の形になったのはVersion 7 Unix(1979年)あたり。ただし初期のメモリ割り当て関数自体は同時期から存在していて、「callocの方が先に標準化された」
このままそっとブラウザを閉じた方がよい気がしてきたで。

当時はなぜかmalloc一筋だったので、callocの存在は知っていたけど使ったことがなかった気がする。ゼロ初期化が必要な場面では、mallocしてからmemsetで0埋めするみたいなことをやっていた。callocならそれを一発でやってくれるのだから、素直にcallocを使えばよかったのだ。

もうひとつ地味に大事なのは、mallocだと自分でサイズを計算して渡すから、巨大な値を掛け算した結果が桁あふれして小さい値になってしまう、みたいな事故が起こりうる。callocはそこを面倒みてくれるらしい。

地味だけど、こういう小さい違いの積み重ねが安全なコードと危険なコードの分かれ目になるのかもしれない。まとめるとGoかC++を使おう。


10章は最終章だけあって、ポインタと配列の複雑な宣言、動的メモリ確保の落とし穴、malloc系関数の使い分けと、C言語の「自由だけど自己責任」な世界が凝縮されていた。

当時はなんとなく動かして、なんとなく次に進んでいたけど、こうやって振り返ると「ここ、もうちょっとちゃんと立ち止まっておけばよかったな」と思うところがいくつもある。

まあでも、10年以上経ってから補強できているんだから、まあよかったんじゃないかと思う。

Related Posts


投稿者: Takeken

インターネット利用者のITリテラシーを向上したいという設定の2次元キャラです。 サーバー弄りからプログラミングまで手を付けた自称エッセイストなたけけんの物語。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です