44
32

【C言語】ハァ… 困ったなァ 「未定義動作」にもう疲れちゃって 全然動けなくてェ…

Last updated at Posted at 2024-08-27

はじめに

どうも、y-tetsuです。

以前、こんな記事でお世話になりました。

お久しぶりです。あい変わらずC言語の学びなおしにいそしんでおります!

いやー、C言語ってムズカシイですよね。最初は文法がとっつきづらかったり、ポインタでつまずいたり、慣れてきたら慣れてきたで色んな災いが出てきたり…で、結果「もう疲れちゃって 全然動けなくてェ…」っておもわずヘコたれそうになります…。

最近、筆者がC言語を学びなおしてみて新しく知った事としまして、「未定義動作」というものがありました。なんだこれはァ!?という事で少しずつ調べる中で、これがまァー「もう疲れちゃって 全然動けなくてェ…」を引き起こす要素が"満載"な代物でした。

そこで今回は、様々な未定義動作にフォーカスした内容を、ご紹介していきたいと思います。

(正直なところ、ご存じの方からすると何番煎じだよ!?という中身なのですが、筆者自身の自学習と備忘録をかねてお許しを…)

未定義動作ってなァに?

未定義動作について、Wikipediaには以下のように記載されております。

コンピュータプログラミングにおいて、未定義動作(みていぎどうさ、英: undefined behavior, UB)とは、コンピュータ言語が準拠する言語仕様において動作(振る舞い)が予測できないと規定されたプログラムを実行した結果のことである。

なるほど。言葉通りといった感じですかね。本記事の主役であるC言語においても、「プログラムのルール内と、ルール外」が規定されております。ルール外の場合には、未定義動作UB)を引き起こしてしまい保証されませんよ!とされおります。(これからは"UB"ときたらすぐにピンと来るよう、早く慣れたいです)

…そして、さらに何やらキナ臭いことがサラリと書かれておりました。

「未定義のコードを実行した結果コンパイラは何をしてもいい。鼻から悪魔が飛び出しても仕様に反しない」

???……なんと独創的な表現でしょうか!未定義のコードを実行すると、それこそ「鼻から魔王ガノンド○フ」が出てきても文句はいえないってことですかァ!?おそろしい…。

実はC言語のコードって、書けるからといって何をしても許されるわけではない、ということが裏にあったんですね。長年やっているのに露知らず…。それでは、魔王を呼び出す──「未定義のコードを実行する」──ためには、一体何を捧げればよいのでしょう?(ちょっとファンタジー感出てきましたかね!?)

本記事での動作確認の結果は、Windows10にてGCC 14.1.0を用いたものとなっております。

本記事で紹介するコードの実行結果は、いずれも何が起こるか分からないものとなっており、動作の保証は一切ありません。

本記事のコードを使用することによって生じた損害や不具合について、筆者は一切の責任を負いません。使用者は自己の責任において、このコードを実行してください。

また、コードの使用に際しては、適切な環境で安全に実行されるよう、くれぐれもご注意ください。

もう疲れちゃって全然動けなくてェ…を引き起こす未定義動作たち

ここからは、C言語に携わる人々をことごとく疲れさせ動けなくしていく(そしてコード自体も動かなくしてしまう)、様々な未定義動作をいくつかご紹介していきます。

C言語でおなじみの、すでに知ってるよ!というものから、ちょっとマニアックそう?なものまで、なるべくまんべんなくご用意したつもりです。

もし本記事を読んでいて、それこそ「もう疲れちゃって」というようなことがありましたら、その時は大変申し訳ございません(先に謝っておきます)。ただ、このような問題をあらかじめ認識しておくことで、イザ何かあった時の考えるきっかけになるのかなと思っております。

それでは見ていきましょう!(先は長いですので、じっくり or 飛ばし飛ばし、お好きにお願いします)

ゼロ除算

未定義動作と聞いて、皆さんは真っ先に何を思い浮かべましたでしょうか。筆者の場合は「変数の値を 0 で割る」という、ゼロ除算でした。押しも押されもせぬ、おなじみの1つですよね。

コードの例を以下に示します。

(整数型の場合)

div0_int.c
#include <stdio.h>

int main()
{
    int x = 10;
    int y = 0;

    printf("x , y = %d , %d\n", x, y);
    printf("x / y = %d\n", x / y);  // ゼロ除算

    return 0;
}

x10y0 で割った結果を表示させようとしています。0 で割るって、算数でもやったらアカンやつですね。

それでは怖いもの見たさで実行してみましょう。はたして、鼻からガノ○様はおいでになりますでしょうか…。

$ gcc ./div0_int.c -o div0_int

$ ./div0_int
x , y = 10 , 0

$

行頭に $ がついている部分はコマンドの実行部分で、上からそれぞれコンパイルとプログラムの実行を行っています。$ がない部分はプログラムの標準出力(printf の出力)になります。(ちなみに、$ のみの行はコマンド入力待ちの状態を示しています)

どうやらゼロ除算の直後に、プログラムが強制終了してしまっているようですね。除算の結果が表示されずにプログラムが終了し、コマンド入力待ちとなっています。

「なんだ、その程度か」と拍子抜けされちゃいましたかね…。ただ、もうちょっと詳しい状況が知りたいですよね。

そんな時は gdb を使ってみるといいかもしれません。

(1.デバッグ情報をいれてコンパイル)

$ gcc ./div0_int.c -o div0_int_g -g

(2.gdb起動)

$ gdb ./div0_int_g
GNU gdb (GDB) 14.2
Copyright (C) 2023 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-w64-mingw32".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from div0_int_g...
(gdb)

(3.プログラム実行)

(gdb) run
Starting program: C:\div0\div0_int_g.exe
x , y = 10[New Thread 2136.0x21b8]
 , 0

Thread 1 received signal SIGFPE, Arithmetic exception.
0x00007ff70ba814dd in main () at div0_int.c:9
9           printf("x / y = %d\n", x / y);  // ゼロ除算
(gdb)

↑の出力結果の下記メッセージより、ゼロ除算のタイミングで算術例外が発生していました。

Thread 1 received signal SIGFPE, Arithmetic exception.

今回の場合、プログラムがゼロ除算をしようとした際に、CPUが異常を検知して、それを受けOSがプログラムを止めてくれたようです。周りの賢者(裏方)の皆様のおかげで事なきを得たのかもしれませんね。

そういえば、先ほどの例で"整数型の場合"としたのは、浮動小数点数型の場合ですと挙動が異なることもあるからです。続いて、浮動小数点数型の例も見てみましょう。

(浮動小数点数型の場合)

div0_double.c
#include <stdio.h>

int main()
{
    double x = 10.0;
    double y = 0.0;

    printf("x , y = %f , %f\n", x, y);
    printf("x / y = %f\n", x / y);  // ゼロ除算

    return 0;
}

整数型と違い、浮動小数点数型の場合は、多くの実装にて標準規格のIEEE754に従っております。その場合のゼロ除算の結果は、"無限"を表わす INFINITY ──定義済みの動作──となるそうです。

実行結果は以下となりました。

$ gcc ./div0_double.c -o div0_double

$ ./div0_double
x , y = 10.000000 , 0.000000
x / y = inf

$

確かに今回のGCCの場合は、標準規格と同様に INFINITYinf) の扱いとなるようですね。(ただこの結果を、さらに整数型にキャストして使ったりしてしまうと、また未定義動作となる問題が出てきますのでご注意を…)

ところでこの件に限らず、未定義動作の厄介な点の1つには、「発生しても動作し続ける場合がある」ということが挙げられます。

テストした範囲では、"たまたまそれっぽく"動いていたけれど、いざ運用開始してみたら肝心な時に落雷⚡(例えば、クラッシュ)発生!!のような事態も…。

きっと、そんな数えきれないドラマやタブーなど「もう疲れちゃって 動けなくてェ…」なエピソードをお持ちの方はたくさんおられることでしょうね…。

/ による 0 除算と同様に % による 0 モジュロ(余りを求める剰余)演算も未定義動作となっております。

領域外アクセス

さて続いてもC言語でおなじみ、配列の領域外アクセスのご紹介です。こちらも未定義動作となります。

皆さんも一度は以下のような、インデックスの指定ミスをされたことがあるのではないかと思います。

out_of_bounds_idx.c
#include <stdio.h>

int main()
{
    int arr[3] = {1, 2, 3};

    printf("%d\n", arr[3]);  // 領域外アクセス

    return 0;
}

arr は、3つの要素を持った配列ですので、先頭から arr[0], arr[1], arr[2] までの領域が確保されています。ですが arr[3] は、残念ながら確保された領域の"外"になりますね。

それではこのコードの挙動を見てみましょう。

$ gcc ./out_of_bounds_idx.c -o out_of_bounds_idx

$ ./out_of_bounds_idx
10490752

$ ./out_of_bounds_idx
1905536

$ ./out_of_bounds_idx
13374336

$

たわむれに3回ほど実行してみましたが、いずれもでたらめな値が表示されていますね。参照したアドレスの値は、初期値設定を"していない領域"ですので、不定値となっているようです。

もう1つだけ、ポインタ加算で配列にアクセスするサンプルを示したいと思います。

ここで、突然ですがクイズです。

以下は arr の3つの要素を、ポインタ加算を使って表示させようとしているコードです。しかし、ちょっと"マズい"ところがあります。はたしてそれはどこなのか、お気づきになりますでしょうか。

out_of_bounds_ptr.c
#include <stdio.h>

int main()
{
    char arr[3] = {1, 2, 3};
    int i;
    int *ptr;

    ptr = (int*)arr;
    for (i=0; i<sizeof(arr); i++)
    {
        printf("%d\n", *ptr);
        ptr++;
    }

    return 0;
}





clock_1200.png

はい、時間です。

実行してみます。

$ gcc ./out_of_bounds_ptr.c -o out_of_bounds_ptr

$ ./out_of_bounds_ptr
218300929
25086
268435456

$

げげっ!デタラメな値になってしまいました。(1, 2, 3 とはならなかったですね…)

……はい、そうです、ご名答でございます!アクセスする際の、ポインタ型が誤っていますね。

    int *ptr;

    ptr = (int*)arr;

↑の部分を、

    char *ptr;

    ptr = arr;

↑とすべきでした。(わざとらしくキャストしてますし、バレバレでしたかね…)

本来1byteずつアクセスしたかったところが、意図せず4byte(筆者環境の場合)ずつのアクセスとなってしまっておりました。

状況をまとめると、以下の図のようなイメージになります。

out_of_bounds.png

型を少しミスしただけで未定義動作に。まだこれは「もう疲れちゃって」には早いですかね…。

試しに、以下のように、コンパイルオプションを付けてgdbで実行してみて下さい。

$ gcc ./out_of_bounds_ptr.c -o out_of_bounds_ptr_g -fsanitize=undefied  -fsanitize-undefined-trap-on-error -g

$ gdb ./out_of_boundes_ptr_g

すると、以下のように「未定義動作を検出した」旨のメッセージが表示されます。

(gdb) run
Starting program: C:\out_of_bounds\out_of_bounds_ptr_g.exe
[New Thread 1932.0x37a0]

Thread 1 received signal SIGILL, Illegal instruction.
0x00007ff66c2414e8 in main () at out_of_bounds_ptr.c:12
12              printf("%d\n", *ptr);
(gdb)

上記は未定義動作のサニタイザを簡単に試す例です。

筆者の環境(MinGW-W64)では、libubsan (Undefined Behavure Sanitizerのライブラリ)が不足していましたため、-fsanitize-undefined-trap-on-error を付けてエラー発生時の捕捉のみとしています。

ライブラリを用意できれば、コンパイル時にワーニングが表示されるようになり、さらに未定義動作を発見しやすくなると思われます。(ライブラリを用意する方法については、紙面の都合で今回は割愛させていただきます)

未初期化変数の使用

お次もあるあるネタです。自動変数(テンポラリ変数)を、初期化する前に使ってしまうと、未定義動作となります。

uninitialized.c
#include <stdio.h>

int global_var;
static int static_var;

int main()
{
    int local_var1;
    int local_var2 = local_var1;

    printf("global : %d\n", global_var);
    printf("static : %d\n", static_var);
    printf("local1 : %d\n", local_var1);  // 未初期化の自動変数へアクセス
    printf("local2 : %d\n", local_var2);  // 未初期化の自動変数へアクセス

    return 0;
}

上記は、グローバル変数(global_var)と、static変数(static_var)、そして自動変数(local_var1 および local_var2)をそれぞれ未初期化の状態で読みだす処理をしています。

この場合、自動変数である local_var1local_var2 については未定義動作となります。先の2つは仕様上ゼロとなるため、未定義動作とはならないようです。

local_var2 に至っては、未初期化の local_var1 で初期化しているというワケノワカラナイ状態ですね。

では実行してみます。

$ gcc ./uninitialized.c -o uninitialized

$ ./uninitialized
global : 0
static : 0
local1 : 0
local2 : 0

$

幸い、自動変数もゼロ初期化されているようです。今回はGCCか、もしかすると周りの賢者様?が気を利かせてくれたようですね。

ちなみに、-Wall オプションを付けると、GCCが未初期化の変数へのアクセスを警告してくれました。

$ gcc ./uninitialized.c -o uninitialized -Wall
uninitialized.c: In function 'main':
uninitialized.c:9:9: warning: 'local_var1' is used uninitialized [-Wuninitialized]
    9 |     int local_var2 = local_var1;
      |         ^~~~~~~~~~
uninitialized.c:8:9: note: 'local_var1' was declared here
    8 |     int local_var1;
      |         ^~~~~~~~~~

すがれるものには、すがっておきましょう!(うっかり、ガノ○様を起こしてしまわぬように…)

符号付き整数オーバーフロー

まだまだあるある続きですが、符号付き整数のオーバーフローも未定義動作となります。

オーバーフローとは、演算結果がその型で表現できる範囲に収まらないときに発生する現象です。

overflow.c
#include <stdio.h>
#include <limits.h>

int main()
{
    int x;

    x= INT_MAX;  // int 型の最大値を格納
    printf("before    : %11d\n", x);

    x += 1;  // 最大値側のオーバーフローが発生
    printf("overflow1 : %11d\n", x);

    x -= 1;  // 最小値側のオーバーフローが発生
    printf("overflow2 : %11d\n", x);

    return 0;
}

int 型の型サイズがあふれる前後で、値がどう変わるか見てみます。

$ gcc ./overflow.c -o overflow

$ ./overflow
before    :  2147483647
overflow1 : -2147483648
overflow2 :  2147483647

$

型のサイズがあふれた結果、最大の正の値から一気に最小の負の値に変わってしまいました(また、その逆も)…。

この挙動は未定義動作ですので、環境が変わると異なる結果が出てもおかしくないものになります。

ここで、最初に"符号付き"と入れていたことにも意味はありまして、それは、符号なし整数の場合は型のサイズがあふれても、未定義動作とはならずに"定義済みの動作"とされているからになります。

符号なし整数型の例を見てみます。

overflow_unsigned.c
#include <stdio.h>
#include <limits.h>

int main()
{
    unsigned int x;

    x= UINT_MAX;  // unsigned int 型の最大値を格納
    printf("before    : %11u\n", x);

    x += 1;  // 最大値側のオーバーフローが発生
    printf("overflow1 : %11u\n", x);

    x -= 1;  // 最小値側のオーバーフローが発生
    printf("overflow2 : %11u\n", x);

    return 0;
}

先ほどのサンプルから、型に付随して変わる部分を変更しました。動作も見てみましょう。

$ gcc ./overflow_unsigned.c -o overflow_unsigned

$ ./overflow_unsigned
before    :  4294967295
overflow1 :           0
overflow2 :  4294967295

$

最大値を超えると 0 になったり、0 を下回ると最大値になったりと、循環する挙動となりました。これは、除数が"型の最大値+1"のモジュロ演算として扱われる仕様となっております。念を押しますが、これは定義済みの動作となります。

(追記 : @fujitanozomu さんより、符号付き整数のオーバーフローのさらに異常な動作を示すサンプルをご提示いただきました。よければコメントもご参照ください)

さてここで、なぜにCの標準規格は「未定義動作」なんて厄介とも思えるものを残しているのか、少し補足したいと思います。

理由の一つには、C言語のパフォーマンス(処理の効率)が挙げられます。

オーバーフロー時の挙動を"定義済み"とする場合、コンパイラはオーバーフローの発生有無に応じて何らかの"対処"が必要となります。例えば演算のたびにオーバーフローが発生するかチェックし、もし発生する場合には定義に応じた値の"制御"を行わないといけなくなってきます…。これには多大な演算のコストがかかります。

そこで、あらかじめオーバーフロー発生時の挙動を未定義動作と定義し、起こりえないものとすることで、余計な処理を省いたシンプルな実装と高速な動作を実現しています。(これはオーバーフローに限らず、他の未定義動作についても同様の事が言えるのかなと思います)

一方で、コンパイラが楽?をする分、プログラマの肩にはその責任が重くのしかかっております。結局、C言語はプログラマ側の技量がとても問われる言語なんだと思います。そうです、プログラムの動きを完全に見切り、(たとえば)オーバーフローや領域外アクセスを"ジャスト回避"できるだけの技量が求められるのだと。

人によっては、これは「もう疲れちゃって 全然動けなくてェ…」となってしまうのもやむなしかなと思います…。

おまけになりますが、この符号付き整数のオーバーフローにまつわる課題として「2038年問題」というものがあるそうです。

少し先ですが、来たるべき時に備え皆さんも是非知っておきましょう!

ちなみに、整数型の最小値側に桁あふれする際もオーバーフローと呼び、アンダーフローとは呼ばないそうです(筆者は完全に誤解しておりました…)。

アンダーフローという用語は、通常、浮動小数点数(floatdouble)に関連して使用されます。浮動小数点数の場合、数値が表現できる最小の非ゼロ値よりも小さい絶対値を持つ場合、数値が 0 に「下回る」現象がアンダーフローだそうです。

解放済みポインタ値の読み込み

一度解放されたポインタ(メモリ)は、読み込んだ時点で未定義動作となります。

malloc などで確保したメモリを一旦解放すると、そのアドレスは別のメモリブロックとして再割り当てされる可能性があります。そのため解放後のメモリにアクセスしてしまうと、何が起こるかわからない状況に陥ります。

サンプルコードを示します。

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

int main()
{
    int *ptr = (int *)malloc(sizeof(int));

    *ptr = 569;
    printf("before : %d\n", *ptr);

    free(ptr);
    printf("after  : %d\n", *ptr);

    return 0;
}

int 型のメモリを確保し 569 を書き込んで表示させた後、メモリを解放します。そして、再度そのアドレスに格納されている値を表示させてみます。

$ gcc ./freed.c -o freed

$ ./freed
before : 569
after  : 12480576

$

見事に解放後のメモリの値は、意図しない値が読みだされました。

一度解放したメモリは、他の処理(当該プログラム以外も含む)から使用される可能性があり、別の用途の値が書き込まれているかもしれません。また、うっかり書き込んだりしてしまうと他の処理に影響を及ぼしてしまう事にもなりますね。

幸いこのケースも -Wall オプションを付けることで警告を出してくれそうです。

$ gcc ./freed.c -o freed -Wall
freed.c: In function 'main':
freed.c:12:5: warning: pointer 'ptr' used after 'free' [-Wuse-after-free]
   12 |     printf("after  : %d\n", *ptr);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~
freed.c:11:5: note: call to 'free' here
   11 |     free(ptr);
      |     ^~~~~~~~~

何もしない無限ループ

外部(メモリ、I/O、グローバル変数など)に影響を与えず終了もしないループは、未定義動作となります。

以下のサンプルは、 loop() 関数内の while 文が、何もしない無限ループになっています。

useless_loop.c
#include <stdio.h>

int loop()
{
    int i;

    i = 1;
    while (i)
    {
        i = 1;
    }

    return 0;
}

int main()
{
    printf("start...\n");

    if (!loop())
    {
        printf("end.\n");
    }

    return 0;
}

試しに上記を実行してみます。

$ gcc ./useless_loop.c -o useless_loop -O3

$ ./useless_loop
start...

この場合は、start... を表示したっきり、応答が返ってこなくなり無限ループ入りする結果となりました。(アレ?意図通り…)

外部に何の影響も及ぼさない──サイドエフェクト(副作用)のない──ループは、コンパイラの判断で終了させてもよいそうです。

これは、最適化の際にコンパイラの判断で不要な処理として"削除"される場合がある事を意味しています。そのため、このようなコードはどうなるか分からない未定義動作となっているようです。(本コードはループ内で i を操作してはいますが、値の変更はなく実質無意味のため、副作用なしと判断できます)

GCCでは最適化オプションを付けても、無限ループが削除されることは確認できませんでした。ところが、clang/LLVM(version 16.0.0)の場合ですと、ちょっと異なる挙動が確認できました。

$ clang ./useless_loop.c -o useless_loop_clang -O3

$ ./useless_loop_clang
start...

$

残念ながら、差が分かりにくいですね。実際に実行してみるとすぐに分かるのですが、この場合は無限ループ入りせず応答が返って来ました。先ほどとは異なり、プログラムが途中で終了してしまうんですね。

なぜそうなるのかをもう少しよく知るために、アセンブリコード(機械語を人が読めるようにした低水準なコード)を覗いてみましょう。

(clangのアセンブリコード)

loop:

main:
        push    rax
        lea     rdi, [rip + .Lstr]
        call    puts@PLT

.Lstr:
        .asciz  "start..."

なんと! loop() 関数内の while ループ処理がゴッソリ取り除かれていました。さらに、loop() をコールした後の処理まで消し去られてます。

ガノ○様、遂にお怒りに!?ごめんなさい…。

ちなみに、GCCの場合は、以下のように jmp 命令を使った無限ループが残されておりました。ただし、無限ループ以降の処理はclangと同様に、削除される結果となっておりました。(やはり無限ループ以降の処理は実行される見込みがないので、実質不要との判断ですかね)

(GCCのアセンブリコード)

loop:
.L2:
        jmp     .L2
.LC0:
        .string "start..."
main:
        sub     rsp, 8
        mov     edi, OFFSET FLAT:.LC0
        call    puts
.L5:
        jmp     .L5

以下の記事はC++の場合にはなりますが、こちらもコンパイラの大胆な挙動がよりよく見えて大変参考になりました。

また、以下はいずれも英語の記事ではありますが、無限ループに対するコンパイラの挙動を知る上で知見が広がる興味深い内容でした。

(うまく翻訳ツールなりで読んでいただければと思います)

アライメント違反

このあたりから少しずつ馴染みが薄い事例になるかもしれません。

まずアライメント(alignment)とは、メモリ上のデータの配置に関する制約の事を指します。具体的には、特定のデータ型がメモリの特定のアドレスに配置される際、そのアドレスが特定の"境界"に整列されている必要があるという制約です。

例えば、4バイトの整数型(int)は4の倍数のアドレスに配置される必要がある、といったものです。そして、そのような配置に"ない"ものはミスアライメントとよばれるそうで、それらにアクセスすると見事、アライメント違反となります。

補足として、以下に図を示します。

alignment_ok.png

上記は、4byte型、2byte型、1byte型を、各々の境界の制約通りに配置した場合です。上図以外の配置のデータにアクセスすると、全てアライメント違反となります。

例えば以下のようなものがあります。(1byte型は、どこにおいてもミスアライメントにはなりません)

alignment_ng.png

アライメント違反の結果から生じる問題には、以下のようなものがあります。

問題点 内容
性能の低下 多くのプロセッサは、アライメントされたアドレスからデータを読み書きする方が"高速"です。アライメントが崩れると、プロセッサは複数回のメモリアクセスを行う必要があるため、性能が低下します。
クラッシュや例外 一部のプロセッサでは、アライメント違反が発生すると例外(アライメント例外)がスローされ、プログラムがクラッシュすることがあります。
予期しない動作 アライメント違反が原因で、メモリに保存されたデータに正しくアクセスできず、予期しない動作が発生することがあります。

C11(WG14 N1570 Committee Draft ? April 12, 2011)には以下の記載がありました。

( 6.3.2.3 Pointersの7より)

A pointer to an object type may be converted to a pointer to a different object type. If the resulting pointer is not correctly aligned for the referenced type, the behavior is undefined.

オブジェクト型へのポインタは、別のオブジェクト型へのポインタに変換することができます。変換後のポインタが参照される型に対して正しくアラインされていない場合、その動作は未定義です。

この戒めに背くコードを書いて動かしてみます。

(サンプルコード)

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

int main()
{
    char *mblock = malloc(sizeof(int) + 1);  // メモリ確保(通常4バイト境界に配置)
    int *ptr = (int*)(mblock + 1);           // 1バイト分のオフセットを入れる

    // アライメント違反の可能性あり
    *ptr = 569;
    printf("%d\n", *ptr);

    return 0;
}

(実行結果)

$ gcc ./alignment.c -o alignment

$ ./alignment
569

$

4バイト境界から1バイトずらして int としてアクセスしてみましたが、幸い筆者の環境では、正しく書き込めて意図通りの値が読みだせました。これは、ところ変わればいつ動かなくなるかわからない、大変危ういものとなっています。

C言語では、構造体のパディングやコンパイラのアライメント指示子(__attribute__((aligned(n)))など)を使用することで、アライメント違反を回避することができるそうです。(残念ながら、詳細は紙面の都合で割愛させていただきます)

以下はやや難易度が高い(かつ英語)ですが、アラインメント違反によるx86での挙動について、非常に興味深い読み物でした。

strict aliasing

こちらも随分聞きなれないものかもしれませんね。筆者に至っては今回初めて名前を知りました(やってしまうと危ないモノというのは感じていましたが)…。

strict aliasing(厳密なエイリアシング)とは、C言語のメモリアクセスに関するルールの一つです。このルールにより、基本的に異なる型のポインタを使用して同じメモリアドレスにアクセスしてはいけないとされています。

これは、特定の型のポインタを介してメモリを参照する際に、コンパイラが行う最適化を安全に行うための前提条件として定められております。

領域外アクセスの章でご紹介した、ポインタ型の変数経由で配列にアクセスする例や、先ほどのミスアライン違反の例でも、型の異なるポインタを使用してしまったことが元になって、災いが起こりました。そこからも、なんとなくそのヤバさが伝わるかもしれませんね。

それでは、簡単なサンプルコードとその実行結果をご紹介します。

(下記サンプルは@fujitanozomuさんよりご提示いただき、使わせていただきました。大変ありがとうございました!)

(サンプルコード)

strict_aliasing.c
#include <stdio.h>

int hoge(int* ptr)
{
    int a = *ptr;

    *(short*)ptr = 20;
    printf("*ptr = %d\n", *ptr);
    printf("*ptr = %d\n", *ptr);

    return a;
}

int main()
{
    int x = 10;

    hoge(&x);

    return 0;
}

(最適化オプションなしでの実行結果)

$gcc ./strict_aliasing.c -o strict_aliasing

$ ./strict_aliasing
*ptr = 20
*ptr = 20

$

(最適化オプションありでの実行結果)

$gcc ./strict_aliasing.c -o strict_aliasing -O2

$ ./strict_aliasing
*ptr = 10
*ptr = 20

$

最適化オプションを有効にした際、同じ参照を2回行っているだけなのに、なぜか異なる値になるという、とんでもない結果になっていますね!strict aliasingに違反しているため、最適化の安全性が崩れ去っております。

なぜこうなるのでしょうか?

大まかに言うと、以下が起こっております。

  • 最初の printf は、最適化によりキャッシュされた値 10 を使用しています
  • 2回目の printf では、コンパイラがメモリの内容を再度読み込み、実際のメモリの変化 20 を反映しています

(アセンブリコード抜粋)

hoge:
    push    rbp                 ; 呼び出し元のベースポインタ(rbp)をスタックに保存
    mov     eax, 20             ; eax レジスタに値 20 を設定
    push    rbx                 ; 呼び出し元の rbx をスタックに保存
    mov     rbx, rdi            ; rbx に rdi(引数として渡されたポインタ)をコピー
    sub     rsp, 8              ; スタックポインタ(rsp)を 8 バイト減らしてスタックフレームを調整
    mov     ebp, DWORD PTR [rdi]; rdi(引数)が指すメモリ位置から 32ビットの整数を読み取り、ebp に保存
    mov     WORD PTR [rdi], ax  ; rdi が指すメモリ位置に eax の下位 16ビット(値 20)を書き込む
    mov     edi, OFFSET FLAT:.LC0; printf のフォーマット文字列のアドレスを edi に設定
    xor     eax, eax            ; eax レジスタを 0 にクリア
    mov     esi, ebp            ; esi に ebp の値を設定(最初に読み取った整数値)
    call    printf              ; printf を呼び出し、最初の printf の出力を行う
    mov     esi, DWORD PTR [rbx]; rbx(引数)から 32ビットの整数値を読み取り、esi に保存(変更後の値)
    mov     edi, OFFSET FLAT:.LC0; printf のフォーマット文字列のアドレスを edi に設定
    xor     eax, eax            ; eax レジスタを 0 にクリア
    call    printf              ; printf を呼び出し、2回目の printf の出力を行う
    add     rsp, 8              ; スタックポインタを 8 バイト戻して、スタックフレームを元に戻す
    mov     eax, ebp            ; eax に最初に読み取った整数値(ebp)を戻す
    pop     rbx                 ; スタックから rbx を復元
    pop     rbp                 ; スタックから rbp を復元
    ret                         ; 呼び出し元に戻る

最初の printf では、mov esi, ebpebp の値を使っています(7行目)。この ebp には変更前の値 10 が格納されています。

2回目の printf では、mov esi, DWORD PTR [rbx] によってメモリから変更後の値 20 を読み込み、表示しています(13行目)。

以下は、strict aliasingについてさらに知るための参考になりました。

データ競合

データ競合(Data Race)とは、2つ以上のスレッドが同じメモリ領域に同時にアクセスし、そのうち少なくとも1つが書き込みを行う場合に発生するものです。やはりこれも未定義動作です。(同時に読み出す分には問題ないです)

また、スレッドとは一連のプログラムの流れの事です。主にパフォーマンスや応答性を上げるために複数のスレッドを作成することで、並行して処理を行うことができるようになります。

今までご紹介したプログラムは、いずれも単一のスレッドで処理するものばかりでした。今回ご紹介するものは、1回のプログラム実行から、複数のスレッドを作成し手分けして処理を行って、結果を返すプログラムになります。

data_race100.c
#include <stdio.h>
#include <pthread.h>

int shared_data = 0;

void *thread_func(void *arg)
{
    for (int i = 0; i < 100; i++)
    {
        shared_data++;  // 共有データへの書き込み
    }

    return NULL;
}

int main()
{
    pthread_t thread1, thread2;

    // 2つのスレッドを作成
    pthread_create(&thread1, NULL, thread_func, NULL);
    pthread_create(&thread2, NULL, thread_func, NULL);

    // スレッドの終了を待機
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    // 結果を表示
    printf("shared_data: %d\n", shared_data);

    return 0;
}

上記のプログラムは2つのスレッドを作成し、それぞれのスレッドにて共通の変数(shared_data)に対して加算を行う処理です。

thread_func を2つのスレッドからコールし、それぞれのスレッドにて100回ずつ shared_data に加算をしています。100 * 2の 200 が出力結果の期待値になります。

では、実行結果を見てみましょう。

$ gcc ./data_race100.c -o data_race100

$ ./data_race100
shared_data: 200

$

期待通り、200 が得られましたね。??…じゃあ、どのあたりが未定義動作にあたるんでしょうね?

実は確かに、未定義動作発生の条件は満たしているのですが、運良くまだ顕在化していないようです。もう少し分かりやすくするため、回数を大幅に増やして試してみます。

変更箇所のみ示しておきます。

data_race1000000.c
void *thread_func(void *arg)
{
    for (int i = 0; i < 1000000; i++)
    {
        shared_data++;  // 共有データへの書き込み
    }

    return NULL;
}

回数を 100 -> 1000000 に増やしてみました。

もう一度結果を確認してみましょう。

$gcc ./data_race1000000.c -o data_race1000000

$ ./data_race1000000
shared_data: 1426094

$

なんと!1000000 * 2 の 2000000 となるはずの出力結果が、1426094 と、期待よりも少ない中途半端な結果となっていますね。ようやくあの後ろ髪をつかんでやりました。

これはどういうことでしょうか?

当然のことながら、ポイントは二つのスレッドが共有する shared_data への操作です。

        shared_data++;  // 共有データへの書き込み

上記の処理を紐解いてみると、以下の3ステップとなります。

  1. shared_data を読み出す
  2. shared_data に加算(+1)する
  3. shared_data に2.の結果を書き込む

マルチスレッドで処理を行った際は、下図の右側のように、片方のスレッドの処理途中で、別スレッドが同時に処理すると、後に終了した方の結果に上書きされる現象が発生してしまいます。つまり、先に処理が完了したスレッドの加算の結果は無かったことになります。

data_race.png

たくさん実行することで、このような上書きが発生しやすくなり、カウンタの数が合わなくなっていた、というオチでした。

データ競合を防ぐためには、shared_data へのアクセスを同期化する必要があります。たとえば、ミューテックス(pthread_mutex_t)を使用することで、同時に1つのスレッドだけが shared_data へアクセスできるようになります。ちなみに、後からアクセスしようとしたスレッドは、前のスレッドの処理が終わるまで待たされることになります。

(防止策を施したコード)

data_race1000000_mutex.c
#include <stdio.h>
#include <pthread.h>

int shared_data = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;  // ミューテックスの初期化

void *thread_func(void *arg)
{
    for (int i = 0; i < 1000000; i++)
    {
        pthread_mutex_lock(&mutex);    // クリティカルセクションの開始
        shared_data++;                 // 共有データへの書き込み
        pthread_mutex_unlock(&mutex);  // クリティカルセクションの終了
    }

    return NULL;
}

int main()
{
    pthread_t thread1, thread2;

    // 2つのスレッドを作成
    pthread_create(&thread1, NULL, thread_func, NULL);
    pthread_create(&thread2, NULL, thread_func, NULL);

    // スレッドの終了を待機
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    // 結果を表示
    printf("shared_data: %d\n", shared_data);

    // ミューテックスの破棄
    pthread_mutex_destroy(&mutex);

    return 0;
}

(実行結果)

$gcc ./data_race1000000_mutex.c -o data_race1000000_mutex

$ ./data_race1000000_mutex
shared_data: 2000000

$

shared_data への同時アクセスを防ぐことで、カウンタの値が意図通り 2000000 となりました。

こういったケースは意図せずやってしまいそう&すぐに気付けない事も多そうで、「もう疲れちゃって 全然動けなくてェ…」を量産していそうです…。

マルチスレッドプログラミングについてもう少し詳しく知りたい場合は、以下が大変参考になりました。

クリティカルセクション(Critical Section)とは、複数のスレッドが同時にアクセスすると競合が発生する可能性のある共有リソース(データやデバイスなど)へのアクセス部分を指します。

決められていない演算順序の中で同一変数を操作

やけに長い見出しとなってしまいましたが、本事例にトリを飾っていただこうと思います。

演算式や条件式、関数の引数などにおいては、処理の順序が明確に仕様で決められていない未規定のものが存在します。

そのような、順序が未規定の一連の処理の中で、特定のオブジェクト(変数)に対して複数の操作(副作用)を実行してしまうと、未定義動作となります。(未規定に未定義動作にと、怪しい雰囲気がプンプンしております)

なるべく分かり易くと思い言葉にしてはみたのですが、どうもイメージしづらくてムズカシイですね(語弊もありそう)…。論より証拠、具体例をサンプルコードで見てみましょう。

(サンプルコード)

unclear_sequence.c
#include <stdio.h>

int main()
{
    int i = 284;

    i = i++ + i--;  // 未定義動作
    printf("%d\n", i);

    i = (i++, i--, i);  // 定義済み動作
    printf("%d\n", i);

    printf("%d, %d, %d, %d\n", i--, i--, i--, i--);  // 未定義動作

    return 0;
}

(実行結果)

$ gcc ./unclear_sequence.c -o unclear_sequence

$ ./unclear_sequence
569
569
566, 567, 568, 569

$

上記は3つのサンプルを実行した結果を示しています。

1つずつ紐解いていきます。まずは、最初の処理です。

    i = i++ + i--;  // 未定義動作

この処理の何が未定義を引き起こすのかというと、+ 演算の左と右でどちらを先に評価すべきかが決められていないという事があります。

i++ を先に評価するのか、それとも i-- の方を先に評価するのかは、「コンパイラの自由」ということになっております。

実際の足し算を思い浮かべてみると、左と右、どちらを先に評価しても(なんなら入れ替えても)最終結果は変わらないですよね?足し算的にはそうなのですが、プログラム的にはどちらを先にするかで結果が変わってくる場合があり得ます。

少し細かく書いてみます。

i++ を先に評価した場合)

  1. i++ の評価を行う。まず 284 を返し、その後 i をインクリメントし i285 となる
  2. i-- の評価を行う。まず 285 を返し、その後 i をデクリメントし i284 となる
  3. 1.と2.の戻り値を + 演算した結果(284 + 285)を i に代入する
  4. i569 となる

i-- を先に評価した場合)

  1. i-- の評価を行う。まず 284 を返し、その後 i をデクリメントし i283 となる
  2. i++ の評価を行う。まず 283 を返し、その後 i をインクリメントし i284 となる
  3. 1.と2.の戻り値を + 演算した結果(284 + 283)を i に代入する
  4. i567 となる

今回のGCCの結果は 569 となることから、前者の i++ を先に評価する場合でした。これは環境によって結果が変わってしまう、とても脆いものだということになります。

続いての処理についてです。

    i = (i++, i--, i);  // 定義済み動作

これは、カンマ演算子を使った処理でして、, の左から右へ順に処理をするという、順序がはっきり決まった処理になります。そのため、これは未定義動作ではなく定義済みの動作です。

最終的な i を求めるまでの処理は以下となります。

  1. , 演算子の1つ目の式、i++にて 569 を+1して 570 になる
  2. , 演算子の2つ目の式、i--にて 570 を-1して 569 になる
  3. , 演算子の3つ目の式、i を最終結果とするため、569i へ代入する

3つ目の最後の処理についてです。

    printf("%d, %d, %d, %d\n", i--, i--, i--, i--);  // 未定義動作

これはとても意外かもしれませんが(筆者は知りませんでした)、関数の引数の順序もどの順序で処理すべきか決まりはないとのことです。

そのため、この処理は未定義動作となります。(それぞれの引数は , で区切られていますが、先ほどのカンマ演算子とは別物です)

今回の場合は、右から左への順に処理されている(デクリメントされている)事が、以下の結果から分かるかと思います。(この動作も十分驚きでした…)

566, 567, 568, 569

ソースコードは左から右へ読んでいくのが個人的な感覚で自然かなと思い、ついついその順序で処理されるものと考えてしまいましたが、実はそうとは"限らない"のですね。

知らないとまさに「もう疲れちゃって 全然動けなくてェ…」な事例だったかなと思います。未定義動作の恐ろしさ、存分に味わっていただけましたでしょうか??

それではこれにて、めでたくラストとなります。ここまでとても長かったかなと思いますが、大変お疲れ様でした~!!

まとめ

最後の締めくくりとしまして、ここまで本記事でご紹介した未定義動作を一覧にまとめておきます。

Undefined Behavior in 2017(パスカル・クオックとジョン・レガー著)によるC言語の未定義動作の分類にもとづいて表にしました。(その他、紹介しきれなかったものについても、簡単な記載だけ残しています)

分類 未定義動作となる例
spatial memory safety violations
(空間的メモリ安全性違反)
領域外アクセス未初期化変数の使用、NULLポインタのデリファレンス、オーバーラップするメモリのコピー、ポインタ経由でconst変数を変更、文字列リテラルの変更、戻り値のある関数にreturn文なし、printfの書式指定ミス
temporal memory safety violations
(時間的メモリ安全性違反)
解放済みポインタ値の読み込み、メモリの2回解放
integer overflow
(整数オーバーフロー)
ゼロ除算符号付き整数オーバーフロー、型サイズ以上のシフト演算、負の値でシフト演算
strict aliasing violations
(厳密なエイリアシング違反)
strict aliasing
alignment violations
(アライメント違反)
アライメント違反
unsequenced modifications
(非逐次的変更)
決められていない演算順序の中で同一変数を操作
data races
(データ競合)
データ競合
loops that neither perform I/O nor terminate
(入出力も終了も行わないループ)
何もしない無限ループ

先の「Undefined Behavior in 2017」で見つけた以下の記載の通り、一口に未定義動作(UB)といっても、実際は約200もの種類があるそうです。(英語ですが、最後の方にリストがありました)

Goal 1: Every UB (yes, all ~200 of them, we’ll give the list towards the end of this post) must either be documented as having some defined behavior, be diagnosed with a fatal compiler error, or else — as a last resort — have a sanitizer that detects that UB at runtime.

目標1:すべてのUB(そう、約200のUBがある。この記事の最後にリストを示す)は、何らかの定義された動作があることが文書化されているか、致命的なコンパイラー・エラーと診断されるか、あるいは最後の手段として、実行時にそのUBを検出するサニタイザーを備えていなければならない。

未定義の動作について、日本語の記載で一覧化されたものは、以下が大変参考になりました。

本記事で紹介したものは、まだまだ氷山の一角にすぎませんね…。

また、他にも未定義動作のサンプルをもっと見てみたい!という方には、以下が参考になりそうです。

ちなみに、↑(上記リンクの下側)は英文の記事なのですが、アセンブリコードのサンプルが確認できたり、コンパイラ別で挙動の差が確認出来たり、な点が良かったです。

おわりに

C言語で「もう疲れちゃって 全然動けなくてェ…」な方々の、何らかの足しになれたら幸いです。

その際はぜひ、💛(実……じゃなくて、いいね)をいただけると嬉しいです!

またネェ~

leaf.png

44
32
16

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
32