はじめに
こんにちは、42tokyo Advent Calendar 2021 の18日目を担当する、在校生のrmatsukaです。
42Tokyoでは主にツッコミをしています。
この記事について
この記事では、C言語でプログラムを書く上で知っておきたい実行時エラーについて解説していこうと思います。
実行時エラーの原因を理解することで、通常の3倍で原因特定ができると艦長はいいます。
ぜひ最後までお付き合いください。
そもそも実行時エラーって?
プログラムの文法に誤りがない場合、ビルドによって実行可能なプログラムが作られます。
そのプログラムを実行したときに発生するエラーを総称して実行時エラーとよびます。
今回扱う内容としては、
- Segmentation Fault
- Abort
- Bus Error
となっています。
実行環境
- ProductName: macOS
- ProductVersion: 12.1
- BuildVersion: 21C52
- Apple clang version 13.0.0 (clang-1300.0.29.30)
- Target: x86_64-apple-darwin21.2.0
- Thread model: posix
- InstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin
Segmentation Fault
通称 セグフォ
アクセス禁止のメモリを扱った場合や、読み込み専用のエリアに書きこもうとした場合などに発生します。
よくある原因は、
- NULLポインタや未初期化アドレスへのアクセス
- 巨大な配列の確保
- 深すぎる再帰処理
などが理由として挙げられます。
1. NULLポインタや未初期化アドレスへのアクセス
#include <stdio.h>
int main()
{
char *p = NULL;
p[0] = 'a'; // NULLポインタへのアクセス
return 0;
}
int main()
{
char *p;
char c = *p; // 未初期化のアドレスへのアクセス
return 0;
}
gcc test.c
./a.out
zsh: segmentation fault ./a.out
対策としては、ポインタのNULLチェック、宣言と同時に初期化するなどがあります。
使用例
#include <string.h>
#include <stdlib.h>
int main()
{
char *p = strdup("hello"); // 宣言時に初期化
if (p == NULL) // 失敗してNULLが返ってないかチェックする
return 1;
free(p);
return 0;
}
ローカル変数を宣言時に初期化することは安全なコーディングを行う上で推奨されているみたいです。
また動的にメモリ確保する関数を使用する場合は、NULLが返っていないかを確認しましょう。
2. 巨大な配列を確保しようとした場合
int main()
{
int arr[10000*10000]; // メモリが足りずにセグフォする
return 0;
}
こちらはスタック・オーバーフローと呼ばれるものです。
スタック領域を超えたメモリの読み書きをしてしまうことでセグフォが発生します。
スタック領域のサイズはせいぜい数MiB程度なので、大容量の配列の宣言には向いていないようです。
そのため対策としては
- 頭にstaticをつける or グローバル変数として定義し、静的領域に確保する。
- mallocを使用し、ヒープ領域に動的確保する。
の2つがあります。
使用例
int g_arr[10000*10000]; // グローバル変数
int main()
{
static int arr[10000*10000]; // 静的変数
int *p = (int *)malloc(sizeof(int) * (10000*10000)); // ヒープ領域に確保
free(p);
return 0;
}
この場合は、正常にプログラムが終了します。
領域によって扱えるメモリの量が違うことを知っていると便利かもしれません。
3. 深すぎる再帰処理
// 呼ぶごとに i を増やす
void func(int i)
{
if (i == 1000000)
return ;
func(i + 1);
}
int main()
{
func(0);
return 0;
}
こちらも2番目と同じようにスタック・オーバーフローです。
自分の環境だと関数を100,000回くらい再帰的に呼び出したらセグフォしました。
再帰を使う場合は終了条件に気をつけたいですね。
Abort
Abortとは、異常事態が発生してプログラムを中断させることを意味します。
またabort()関数を使うことで意図的に異常終了させることもできます。
#include <stdlib.h>
#include <stdio.h>
int main()
{
puts("hello");
abort();
puts("world");
return 0;
}
gcc test.c
./a.out
hello
zsh: abort ./a.out
よくある原因は、
- ダブルフリーした場合
- 動的メモリ以外の領域をフリーした場合
- free関数に渡すアドレスの位置がずれている場合
- バッファ・オーバーランが発生した場合
などが理由として挙げられます。
主にフリー関連が多い印象ですね。
1. ダブルフリー
#include <stdlib.h>
int main()
{
void *p = malloc(10);
free(p);
free(p); // すでに解放済みのメモリをフリーしている
return 0;
}
2. 動的メモリ以外の領域をフリー
#include <stdlib.h>
int main()
{
char *s = "hello";
free(s); // 動的に確保していない領域をフリーしている
return 0;
}
3. free関数に渡すアドレスの位置がずれている
#include <stdlib.h>
int main()
{
void *p = malloc(42);
p++; // ポインタの位置を動かす
free(p);
return 0;
}
フリーで渡すべきポインタはmallocで確保したメモリ領域の先頭である必要があります。
そのためメモリ領域の途中をfree関数に渡すと異常終了します。
4. バッファ・オーバーランが発生した場合
#include <unistd.h>
int main()
{
char buf[10];
read(STDIN_FILENO, buf, 42); // サイズ以上の読み込み
return 0;
}
10バイトのバッファに対して、サイズ以上の読み込みをしようとしています。
エラー終了する原因としては、read()によるバッファオーバーランです。
私の環境だとデフォルトのコンパイルオプションで-fstack-protector
がついているため、オーバーランを検知してくれます。
GCCのデフォルトオプションを知るには
具体的には、ローカル変数領域と関数のリターンアドレスの間にカナリアとよばれるランダムな値を挟むことで検知しています。オーバーランした場合、カナリアの値が書き換わるので判定できます。
カナリアの話
また-fno-stack-protector
オプションをつけてコンパイルすることで、カナリアの仕組みを使わないこともできます。
gcc -fno-stack-protector test.c
./a.out
012345678901234567890123456789
zsh: segmentation fault ./a.out
その結果、オーバーランを検知せずに不正なセグメントにアクセスするのでsegmentation faultが発生します。
どのパターンも実行するとabortが発生します。
gcc test.c
./a.out
zsh: abort ./a.out
Bus Error
Bus Errorは、バスへのアクセスに問題があった場合に発生するエラーです。
主にソフトウェア的な原因とハードウェア的な原因の2種類があるそうです。
ソフトウェア的な原因としては、
- 物理メモリにアクセスする際にエラーが起きた場合
- アラインメント違反した場合
の2点が挙げられます。
1. 物理メモリにアクセスする際にエラーが起きた場合
int main()
{
char *s = "hello";
s[1] = 'a'; // 書き込み禁止の領域に書き込む
return 0;
}
gcc test.c
./a.out
zsh: bus error ./a.out
基本的にコード・レベルで操作するのは、仮想メモリなので物理メモリに直接アクセスする機会は少ないと思います。
また仮想アドレスに対する不正アクセスに関しては、先程紹介したSegmentation Faultが発生します。
そのため上記の例ではセグフォするように見えますが、これはOSによって違ってくるみたいです。
macOSの場合はBus Error、Linuxの場合はSegmentation Faultが発生します。
macOSを利用している方は、まずはセグメント違反がないか確認すると良いかもしれません。
2. アライメント違反した場合
#include <stdlib.h>
int main()
{
// アラインメントをチェックする機能がオフになっている場合があるのでオンにする
asm("pushf\n\torl $0x40000,(%rsp)\n\tpopf");
int *int_ptr;
char *char_ptr;
char_ptr = (char *)malloc(13);
char_ptr++; // ポインタを動かして不正列のアドレスにする
int_ptr = (int *)&char_ptr[1]; // intのポインタに不整列のアドレスを代入する
return 0;
}
gcc test.c
./a.out
Bus error
アラインメント違反とは、本来整列されているはずのビットが正しく並んでいないときに発生します。
例えば、偶数番地にしかアクセスできないプロセッサに奇数番地でアクセスした場合などです。
そのため、サイズが異なる型のキャストやポインタの移動には注意しましょう。
ちなみにmacOSの場合は上記のアラインメント違反があっても正常終了します。
最初に紹介した例のように、このあたりはOSによって違うみたいです。
さいごに
今回解説した以外にも実行時エラーは存在するので、興味ある方はぜひ調べてみてください。
明日は、@kkawano_42さんが42Tokyoの校舎について書いてくれる予定です。
噂によると最近筋トレグッズが追加されたとか。。お楽しみに!