Menu

C言語のセグメンテーション違反:原因と直し方

セグフォルトは、プログラムが所有していないメモリに触れたという意味です。ほぼすべてのセグフォルトを占める5つの原因を、最小の例とその修正とともに紹介し、さらに gdb と AddressSanitizer で正確な行を突き止める方法も示します。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

Segmentation fault (core dumped)

この一行はC言語で最も検索されているエラーであり、見た目ほど謎めいてはいません。プログラムがプロセッサにあるメモリアドレスを要求し、OSがそのプロセスにそのアドレスへ触れる権利があるか調べ、答えが「ない」だったのです。カーネルはそこで SIGSEGV シグナルによってプロセスを殺します。

肝心なのは、クラッシュは症状であり、その場所はしばしばバグではないということです。不正なポインタはたいてい別の場所でもっと早くに作られており、ここは単にそれが最初に使われた場所にすぎません。このページはほぼすべてのセグフォルトを占める5つの原因を扱い、続いて本当の行を数秒で見つける2つの道具を紹介します。

以下のクラッシュする例は意図的に実行可能なブロックにはしていません - 設計上クラッシュするからです。読んだうえで、その後に続く修正版を読んでください。

「所有していないメモリ」とは

プログラムが始まるとき、OSはそのアドレス空間へいくつかの領域を割り当てます。コード、グローバル変数、スタック、そしてヒープが伸びた分です。アドレス空間のそれ以外すべて - アドレス 0 を含みます - は未マップです。未マップのアドレスに触れるか、読み取り専用の場所へ書くと、ハードウェアがそれを捕捉します。

つまりセグフォルトはコンパイラがあなたを捕まえたのではありません。実行時のガードレールであり、しかも不正なアドレスがたまたまマップされたページの外にあるときだけ発動します。だから同じバグがあるマシンでクラッシュし、別のマシンでは動いているように見えることがあるのです。メモリ配置が違うからです。

原因1:NULL ポインタの参照外し

最もよくある原因で、最も直しやすいものです。アドレス 0 は決してマップされないので、ヌルポインタを通じた読み書きは必ず障害を起こします。

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = NULL;
    *p = 42;              /* クラッシュ: アドレス 0 への書き込み */
    printf("%d\n", *p);
    return 0;
}

現実的な版は、確認されていない確保です。

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *data = malloc(1000000000000UL * sizeof(int));  /* 失敗し NULL を返す */
    data[0] = 1;                                        /* クラッシュ */
    free(data);
    return 0;
}

malloc は要求を満たせないとき NULL を返します。ファイルが存在しないときの fopen も、文字が見つからないときの strchr も同じです。NULL を返しうるすべての関数について、結果を使う前に確認しましょう。

ポインタは未初期化のままにせず NULL で初期化しましょう。ヌルポインタは即座に、しかも明白にクラッシュします。ゴミの入ったポインタは何かを壊してずっと後でクラッシュするかもしれません。このパターンの詳細はヌルポインタにあります。

原因2:配列の末尾を越えて書く

Cは配列の範囲を検査しません。10要素の配列の添字 10 は単に配列の直後のメモリであり、コンパイラは文句も言わずそのアドレスを計算してくれます。

#include <stdio.h>

int main(void) {
    int arr[10];

    for (int i = 0; i <= 10; i++) {   /* < ではなく <= : 1回多い */
        arr[i] = i;
    }

    printf("done\n");
    return 0;
}

これがクラッシュするかどうかは運です。ローカル配列の4バイト先への書き込みはたいてい他のスタックデータ - 退避されたレジスタ、別の変数、戻り先アドレス - に着地するので、プログラムは自分自身を壊して、あとで無関係な場所でクラッシュします。大きくはみ出せばマップされたページを抜けて、その場でセグフォルトします。

もっと荒っぽい版は必ずクラッシュします。

#include <stdio.h>

int main(void) {
    int arr[10];
    arr[1000000] = 42;      /* マップされた領域からはるか外: クラッシュ */
    return 0;
}

対策は i < n の習慣と、2回打ち込むのではなく n を計算することです。

文字列にも同種の問題があります。終端の '\0' のための余地がないバッファです。

#include <string.h>

int main(void) {
    char name[5];
    strcpy(name, "Alexander");   /* 9文字 + 終端を 5 バイトへ */
    return 0;
}

strcpyname の大きさをまったく知りません。snprintf を使いましょう。こちらは教えてもらえるので知っています。

原因3:free のあとでポインタを使う(ダングリングポインタ)

free(p) のあと、そのメモリはアロケータに返されます。ポインタは古いアドレスを持ったままですが、そのアドレスはもうあなたのものではありません。

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof *p);
    *p = 42;
    free(p);

    printf("%d\n", *p);   /* use after free: ゴミを表示するかもしれないし、クラッシュするかもしれない */
    free(p);              /* 二重解放: たいてい abort するかヒープを壊す */
    return 0;
}

関連する罠が、ローカル変数のアドレスを返すことです。そのスタックフレームは関数が戻った瞬間に消えています。

#include <stdio.h>

int *make_number(void) {
    int value = 42;
    return &value;      /* フレームはここで死ぬ。ポインタは宙に浮く */
}

int main(void) {
    int *p = make_number();
    printf("%d\n", *p);   /* 未定義: ゴミか、クラッシュ */
    return 0;
}

意図に応じて2つの直し方があります。ポインタではなく値を返すか、ヒープに確保して呼び出し元に解放させるかです。

free の直後にポインタを NULL にするのは安上がりな防御の習慣です。静かな use-after-free を即座で明白なヌル参照外しのクラッシュに変え、2回目の free(p) を無害にします。free(NULL) は何もしないと定義されているからです。この話の確保する側は動的メモリとメモリリークにあります。

原因4:暴走した再帰によるスタックオーバーフロー

関数呼び出しはそれぞれスタックにフレームを置き、スタックは固定サイズの領域です(多くの場合 8 MB)。ベースケースのない再帰 - あるいは決して到達しないもの - はその末端を走り抜けます。

#include <stdio.h>

int countdown(int n) {
    printf("%d\n", n);
    return countdown(n - 1);    /* ベースケースなし: 決して止まらない */
}

int main(void) {
    return countdown(5);
}

再帰が跨いでしまうベースケースでも同じことが起きます。

int f(int n) {
    if (n == 0) return 1;
    return n * f(n - 2);    /* 奇数の n からは決して 0 にならない */
}

どの再帰関数にもあらゆる入力から到達可能なベースケースが必要です。

巨大なローカル配列も同じ結果を招きます - 関数の中の int buffer[10000000]; は 40 MB のスタックを要求し、最初の書き込みで障害を起こします。大きなバッファは malloc でヒープに確保しましょう。関係するサイズについてはスタックとヒープを、ベースケースの設計については再帰を参照してください。

原因5:文字列リテラルへの書き込み

これはコードが無害に見えるので人を驚かせます。

#include <stdio.h>

int main(void) {
    char *s = "hello";
    s[0] = 'H';          /* クラッシュ: 文字列リテラルは読み取り専用 */
    printf("%s\n", s);
    return 0;
}

文字列リテラルは実行ファイルの読み取り専用セクションにあります。char *s = "hello" はそこを指しており、そのポインタを通じた書き込みは保護違反で、OSはそれを未マップアクセスと同じようにセグフォルトとして報告します。

対策は配列を作ることです。配列は自分の書き換え可能なコピーを得ます。

リテラルを指すポインタを const char * と宣言すれば、この実行時クラッシュはコンパイル時エラーに変わり、明らかにそのほうが優れています。習慣にしましょう。

本当の行を見つける:gdb

実行ファイルがデバッグ情報を持つよう -g を付けてコンパイルし、デバッガの下で実行します。

gcc -g program.c -o program
gdb ./program

gdb の中で。

(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555151 in process_item (item=0x0) at program.c:14
14          return item->count * 2;

(gdb) backtrace
#0  process_item (item=0x0) at program.c:14
#1  0x000055555555518a in main () at program.c:23

(gdb) print item
$1 = (struct Item *) 0x0

3つのコマンドが仕事のほとんどをします。run はプログラムを起動し、障害の場所で止まります。backtrace(または bt)はそこへ至る呼び出しの連鎖を見せます - フレーム #1 がたいてい不正なポインタが実際に作られた場所です。print は変数を調べ、item = 0x0 が問題をはっきり名指しします。

macOS では相当するものが lldb ./program で、その後に runbt です。

もっと速く見つける:AddressSanitizer

さらに良いのは、コンパイラにプログラムを計装させることです。AddressSanitizer は無効なアクセスをそれが起きた瞬間に捕まえます - クラッシュしなかったはずのものも含めて。

gcc -g -fsanitize=address program.c -o program
./program
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
WRITE of size 4 at 0x602000000010 thread T0
    #0 0x4011f6 in main program.c:11

0x602000000010 is located 0 bytes inside of 4-byte region
freed by thread T0 here:
    #1 0x4011c9 in main program.c:10
previously allocated by thread T0 here:
    #2 0x4011a6 in main program.c:8

この報告はバグの種類、それを起こした行、メモリを解放した行、確保した行を名指しします。C言語のメモリバグに対して最も効果的な単一のデバッグ道具であり、LinuxとmacOSのGCCとclangで使え、実行時間はおよそ2倍になります - 開発中には無関係なコストです。

-fsanitize=undefined と組み合わせれば、符号付きオーバーフローその他の未定義動作も同時に捕まえられます。

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

valgrind ./program は再コンパイル不要の代替で、同じ種類のエラーに加えてリークも報告します。

遭遇したときのチェックリスト

  1. -g -Wall -Wextra -fsanitize=address,undefined で再ビルドして走らせ直す。 たいていの場合、報告が行を名指ししてそれで終わりです。
  2. サニタイザが使えないなら gdb の下で走らせて backtrace を取る。 フレーム0だけでなくフレーム1も見ましょう。
  3. クラッシュした行のすべてのポインタを調べる。 それぞれ表示してみましょう。0x0 はヌルを示し、0x7fff5fc01000 のような野放図な値はたいてい未初期化か解放済みを意味します。
  4. そのポインタがどこから来たか問う。 確認していない mallocfopen か? すでに戻った関数のローカル変数のアドレスか? free のあとで使われたポインタか?
  5. クラッシュ付近のすべてのループの境界を確認する。 < のつもりで <= になっていないか。
  6. スタックトレースが数千フレームの深さなら、それはポインタのバグではなく暴走した再帰です。

予防する

セグフォルトを稀にする習慣です。

  • 常に -Wall -Wextra を付けてコンパイルし、警告はバグとして扱う。
  • すべてのポインタを初期化する。他に良いものがなければ NULL で。
  • malloccallocreallocfopen の戻り値を確認する。
  • 解放した直後にポインタを NULL にする。
  • sprintfgets ではなく snprintffgets を使う。
  • 文字列リテラルを指すポインタは const char * と宣言する。
  • 長さをハードコードするより sizeof arr / sizeof arr[0] を選ぶ。
  • CIでテストスイートを AddressSanitizer の下で走らせる。

セグフォルトは親切な失敗の仕方です - 何かがおかしいと教えてくれます。隣の変数を静かに壊して3つ先の関数で間違った答えを出す同種のバグのほうがはるかに厄介で、上の道具はその両方を捕まえてくれます。

よくある質問

C言語のセグメンテーション違反とは何ですか?

プログラムが触れてはいけないメモリにアクセスしたときにOSが引き起こすクラッシュです - 無効なポインタを通じた読み書き、配列の末尾を越えてマップされていないページへ進むこと、スタックのあふれなどです。カーネルはプロセスに SIGSEGV シグナルを送り、それがプロセスを終了させて「Segmentation fault (core dumped)」と表示します。

セグメンテーション違反が起きた場所を見つけるには?

デバッグ情報を付けてコンパイルし、デバッガの下で実行します:gcc -g program.c -o program のあと gdb ./programrun、そしてクラッシュしたら backtrace。これが正確なファイルと行を表示します。メモリのバグならさらに速いのが gcc -g -fsanitize=address program.c -o program で、プログラムを走らせるだけで何がどこで起きたかの完全な報告が出ます。

なぜ私のCプログラムはときどきだけセグフォルトするのですか?

無効なアクセスは未定義動作であって、確実なクラッシュではないからです。配列の1つ先に書き込むことはしばしばプロセスが実際に所有するメモリに着地するので、何も止めてくれず - 代わりに隣の変数を壊します。セグフォルトになるのは、その不正なアドレスがたまたまマップされたページの外に落ちたときだけで、それはそのビルドと実行のメモリ配置次第です。

セグメンテーション違反はメモリリークがあるという意味ですか?

いいえ - それらは正反対の問題です。リークは確保したまま解放していないメモリで、プログラムは走り続けてゆっくり肥大します。セグフォルトは所有していないメモリに触れることです。二重解放や解放後のポインタの使用はセグフォルトを起こし、解放を忘れるとリークになります。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める