第3回 — C言語の連結リストとlldbによる検証
前回は、動的に確保した領域の有効期間と構造体の配置を説明した。本稿では両者を使って単方向連結リストを実装する。最後に、lldbでノードのメモリ内容を確認する。
実測例の環境はmacOSとApple clang、int は4バイト、ポインタは8バイト。アドレスの具体的な値は環境や実行ごとに異なる。
出発点: 配列では困ること
要素の列を配列で作ると、次の性質に縛られる。
| 配列の性質 | 帰結 |
|---|---|
| 連続した領域に並ぶ | 要素数100万なら「100万個ぶんの連続した空き」が必要 |
| サイズは最初に決める | 実行途中で増やせない |
| 並び = メモリの並び | 真ん中への挿入には、後ろの全要素を1つずつずらす |
連結リストでは、各ノードを別々に確保できる。ここで扱う単方向連結リストは、各ノードに次のノードへのポインタを保持する。ノードの移動を行わずに挿入・削除できるが、目的の位置を探すには走査が必要である。動的配列でも再確保による要素数の変更は可能であり、連結リストだけが選択肢ではない。
Q1. 散らばって置かれた要素たちが「列」として辿れるために、各要素が最低限持つべきものは何か。
この単方向連結リストでは、各ノードのnextに次のノードへのポインタを保持する。末尾のnextはNULLにする。順序は、ノードのアドレスの大小ではなくnextで決まる。
1. ノードをつなぐ
リストの各要素をノードと呼ぶ。ノードの型を次のように定義する。
struct node { int value; struct node *next; }; /* 自分と同じ型へのポインタをメンバーに持つ */この環境では、intのサイズとアラインメントは4、ポインタは8である。valueはオフセット0〜3に配置される。nextの先頭を8の倍数にするため、オフセット4〜7に4バイトのパディングが入る。nextはオフセット8〜15であり、構造体のサイズは16バイトとなる。
byte 0〜3 : value (int)
byte 4〜7 : パディング
byte 8〜15: next (ポインタ)
計 16バイトQ2.
struct node { int value; struct node *next; }の sizeof はいくつか(この環境)。
value@0〜3。nextは8バイトなので8の倍数アドレスへ: byte 4〜7がパディング、next@8〜15。計16バイト。「ポインタ1個分の8バイト」ではなく、valueとパディングが加わる。
まずスタック上のノードで、つなぎ方そのものを見る(まだ malloc は使わない)。
struct node c = {3, NULL}; /* 末尾: 次はない */
struct node b = {2, &c};
struct node a = {1, &b}; /* 先頭 */実測すると:
sizeof(struct node) = 16
a = 0x16f43e450 value=1 next=0x16f43e460 ← b のアドレス
b = 0x16f43e460 value=2 next=0x16f43e470 ← c のアドレス
c = 0x16f43e470 value=3 next=0x0 ← NULL(次はない)a.next の中に b のアドレスが入っている。ノードを連結するとは、nextに次のノードへのポインタを格納することに他ならない。
2. 走査: 列を辿る
列を先頭から末尾まで辿る(走査する)ループは、初期化・条件判定・更新の3つの処理でできている。
for (struct node *p = &a; p != NULL; p = p->next)
// ①先頭から始める ②NULLで止まる ③nextで1つ進む③の p = p->next は、pが指すノードのnextを読み、pへ代入する操作である。ノードの内容は変わらない。末尾のnextがNULLなので、次の条件判定でループが終了する。
通過: value=1 → value=2 → value=3Q3.
a→b→c(valueはそれぞれ1, 2, 3)とつないだ列で、p = &a;の後p = p->next;を2回実行した。p->valueはいくつか。
1回目でpはbへ、2回目でcへ切り替わる。p->value はcのvalue=3。p = p->next を1回実行するごとに列が1つ先へ進む — これが走査。
Q4. p が末尾の c を指している状態で
p = p->next;を実行すると、p はどうなるか。
c.next には NULL が入っているので p の中身は NULL になる。条件 p != NULL が偽になりループ終了。「末尾の next = NULL」が停止条件。終端の書き忘れは、文字列の終端忘れと同じく領域の外への読み進みを引き起こす。
3. malloc版: ノードは散らばる
実用のリストは malloc で作る。関数にすると:
struct node *make_node(int value) {
struct node *n = malloc(sizeof(struct node));
if (n == NULL) return NULL;
n->value = value;
n->next = NULL;
return n;
}3つのノードを続けて確保したところ、この実行では偶然16バイト刻みで並んだ。そこでノードの間に無関係な malloc(96) を挟んでみる:
a = 0x1017dd8b0
b = 0x1017dd920 ← a から112バイト離れている
c = 0x1017dd990 ← b から112バイト離れている
通過: value=1 → value=2 → value=3 ← それでも正しい順で辿れるノードは散らばり、間には無関係なデータが挟まっているのに、走査は正しい順で回る。走査する順序は、各ノードのnextで決まる。 配列の要素は連続して配置されるが、連結リストのノードは連続している必要がない。
Q5. ノードのアドレスが散らばっていても、走査が a→b→c の順になるのはなぜか。
走査は「今のノードのnextを読んで、そこへ進む」の繰り返し。順序の情報はnextだけが持ち、アドレスの大小・間隔は一切使われない。したがって、ノード間の距離が異なっていても走査できる。
4. lldbでノードのメモリ内容を確認する
ここでは、printfによる表示ではなく、デバッガで停止中のプロセスのメモリ内容を直接確認する。実行ファイルはデバッグ情報つきでビルドする。
$ clang -Wall -g -O0 -o list_lldb list_lldb.c
$ lldb list_lldb
(lldb) b list_lldb.c:17 # 17行目にブレークポイント
(lldb) run # そこまで実行して停止停止中に変数を見るには frame variable(略 v)、式を評価するには expr:
(lldb) p *a # a が指すノードを構造体として表示
(value = 1, next = 0x0000000100651a30)そして本題 — メモリのバイト列を読む。memory read に式の結果を渡すと、そのアドレスからダンプできる(expr a の結果は $0 に入る):
(lldb) expr a
$0 = 0x00000001006506a0
(lldb) memory read -c 4 -s 4 -f x $0 # 4バイト×4個、16進で
0x1006506a0: 0x00000001 0x00000000 0x00651a30 0x00000001
(lldb) memory read -c 2 -s 8 -f x $0 # 8バイト×2個
0x1006506a0: 0x0000000000000001 0x0000000100651a30このバイト列は、第1節 で計算したレイアウトと1バイトの狂いもなく一致している:
- byte 0〜3 =
01 00 00 00— value(1) - byte 4〜7 =
00 00 00 00— パディング。ソースコードに一切現れないのにメモリには実在する - byte 8〜15 =
0x0000000100651a30— b のアドレスそのもの。4バイト刻みの表示では0x00651a30と0x00000001に分けて見える(8バイトの値がリトルエンディアンで下位から並ぶ)
メモリダンプから、整数やポインタを含む構造体の配置を推定できる。ただし、この並びだけでstruct nodeと断定することはできない。命令のアクセス幅、参照するオフセット、ポインタの参照先も調べて仮説を検証する必要がある。パディングの値はゼロとは限らない。
この実測では、lldbで起動したときのコードのアドレスが同じであった。ASLRの扱いはデバッガの設定やOSに依存する。lldbでは settings show target.disable-aslr で設定を確認できる。動的に確保した領域のアドレスまで一定になる保証はない。
Q6. メモリダンプの byte 8〜15 にあった
0x0000000100651a30は何か。
byte 8〜15 は next フィールド。値はこの実行での b のアドレスで、a→b のつなぎがメモリ上にポインタ値として格納している。mallocの管理情報はこの16バイトの外側にある。
Q7. メモリダンプの byte 4〜7 が
0x00000000だったのはなぜか。
value@0〜3、next@8〜15 の間の4バイトは、next を8の倍数アドレスに置くために空けられたパディング。今回は値がたまたまゼロだが、保証はない — 誰も書き込まないだけ。
5. 挿入: 2本の付け替え
a → b → c の a と b の間に新しいノード x(値99)を挿入する。配列なら後ろを全部ずらすが、リストで変えるのは 2つのnextである。
x->next = a->next; // ① X の次を b へ(先に!)
a->next = x; // ② a の次を X へ順番が重要である。②を先に書くと a->next はすでに X を指しているので、その後の x->next = a->next は x->next = x — 自分自身を指すループになり、b以降へのリンクが失われる。ほかに有効なポインタがなければ、その領域を解放できなくなる。先にx->nextへ後続ノードへのポインタを保存し、その後でa->nextを更新する。
Q8. 挿入の2行はどの順で書くのが正しいか。
逆順では a->next がすでに X を指すため、X->next = a->next は自己ループになり b 以下が失われる。「今の次」を先に退避してから差し替える。
6. 削除: 「前のノード」を付け替える
削除も付け替えだが、落とし穴がある。a(1) → x(99) → b(2) → c(3) という列から b を消す場合、付け替えるべきは b の前のノード(x)の next である。
x->next = b->next; // ① x の次を c へ(b を迂回させる)
free(b); // ② bを解放するうっかり「先頭の next」を付け替えるコードを書くと何が起きるか。a->next = b->next; としてみると:
初期: 1 2 3
99を挿入: 1 99 2 3
2を削除: 1 3 ← 99 が消えた!挿入後の列では b の前は a ではなく x だった。a->next = b->next は「a の次」を c へ直接つなぎ替えるので、間のx(99)がリストの走査対象から外れる — ほかに有効なポインタがなく、領域を解放しなければメモリリークになる。削除対象の直前にあるノードは、宣言順ではなく現在のリストのリンクで決まる。
もう1つの順序の規則: free が先にならないこと。free(b) を先にすると b->next は解放後読み取り(UAF)になる。解放後の領域へのアクセスは未定義動作である。
Q9. 削除で付け替える next はどこか。
消すノード(b)を列から外すには、b を指しているノード(=前のノードx)の next を b->next(c)へ付け替える。前が誰かは列の現状で決まり、先頭とは限らない。
Q10.
free(b);の後にb->nextを読むと何が起きているか。
free後のb->nextは解放後使用(UAF)であり、未定義動作である。元の値が残っているかどうかに関係なく使用できない。リンクの更新に必要な値はfreeより前に読み出す。
7. 全ノードの解放
次のループは、各ノードを先頭から順に解放する。次のノードへのポインタの保存、現在のノードの解放、次のノードへの移動の順に処理する。
struct node *p = head;
while (p != NULL) {
struct node *nx = p->next; /* ① 次のノードへのポインタを保存してから */
free(p); /* ② このノードを解放する */
p = nx; /* ③ 保存した次へ進む */
}①がないと、②の後の p->next は解放後読み取りになる。この実装では、次のノードへのポインタをfreeより前に保存することで、解放済み領域の読み取りを避ける。
もうひとつの定番の罠はループ条件にある:
while (p->next != NULL) { ... } /* ← この条件だと最後のノードが解放されない */この条件は、末尾のノードを指した時点で偽になる。つまり末尾の1個だけ free されずに残る — ごく小さなリークだが、この関数を何百万回も呼ぶプログラムでは積み重なる。このループでは、p != NULLを条件にする。pがNULLになるまで、現在のノードを解放する。空のリストでp->nextを評価すると、NULLポインタの参照になる点にも注意する。
Q11. 全解放ループで「次を保存してから free する」のはなぜか。
free(p)の後はp->nextを読めない。次のノードへの有効なポインタを、領域を解放する前にローカル変数へ保存する。
Q12. 空でないリストで
while (p->next != NULL)を全解放の条件にすると何が起きるか。
この条件は、末尾のノードのnextがNULLなので、そのノードを解放する前に偽になる。末尾の1個が確保されたまま残るリーク。停止条件は p != NULL — 自分がNULLになったら列は終わり。
まとめ
| 本稿の要素 | 内容 |
|---|---|
| ノード | value + next の16バイト(パディング4バイトを含む) |
| つなぎ | next に「次のノードへのポインタ」を書く。終端は next = NULL |
| 走査 | for/while (p != NULL) ... p = p->next の3つの処理 |
| ノードの配置 | 走査順はnextで決まる。別々に確保したノードは連続配置でなくてよい |
| 挿入 | ①新しいノードの next に今の次を入れ、②前の next を差し替える |
| 削除 | 「消すノードの前の next」を付け替え → free。freeが先はUAF |
| 全解放 | 次を保存 → free → 進む。条件は p != NULL |
| メモリの確認 | lldb: b run p *a memory read。value/パディング/アドレスがバイト列に並ぶ |
連結リストは、次のノードへのポインタを持つ構造体から構成される。mallocでノードを確保し、nextを更新して挿入・削除を行う。次回は、p = p->nextに対応する機械語とレジスタの動作を確認する。
練習問題
- 走査を
forではなくwhileで書き直す(両方書けるようにする) - 先頭への挿入 —
new->next = head; head = new;を含む関数を書き、head がなぜ付け替わるのか言葉で説明する - 走査しながら value の合計を返す
list_sumを書く clang -gでビルドした自分のリストを lldb にかけ、先頭ノードのnextを読み、そのアドレスを使って2番目のノードをmemory readで確認する。別々に確保したノード間の距離はsizeofから求められない- (挑戦) リストをその場で逆順にする(ポインタ3つ
prev/cur/nxを使う古典パターン)
次回
p = p->next は機械語ではどうなるか — アセンブリ読解、レジスタ(register read)。その先はページテーブル(OS)と仮想→物理変換へ進む。