web-dev-qa-db-ja.com

コードで呼び出されていない関数は実行時に呼び出されます

次のプログラムがコードで呼び出されなかった場合、どのようにしてformat_diskを呼び出すことができますか?

#include <cstdio>

static void format_disk()
{
  std::puts("formatting hard disk drive!");
}

static void (*foo)() = nullptr;

void never_called()
{
  foo = format_disk;
}

int main()
{
  foo();
}

これはコンパイラごとに異なります。最適化をオンにしてClangでコンパイルすると、関数never_calledが実行時に実行されます。

$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!

ただし、GCCでコンパイルすると、このコードはクラッシュします。

$ g++ -std=c++17 -O3 a.cpp && ./a.out
Segmentation fault (core dumped)

コンパイラーのバージョン:

$ clang --version
clang version 5.0.0 (tags/RELEASE_500/final)
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /usr/bin
$ gcc --version
gcc (GCC) 7.2.1 20171128
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
24
Mário Feroldi

Nullポインターの逆参照(つまり、事前に有効なアドレスを割り当てずにmainでfoo()を呼び出す)はUBであるため、プログラムには未定義の動作が含まれているため、標準によって要件は課されていません。

実行時に_format_disk_を実行することは、未定義の動作が発生した場合の完全な有効な状況であり、(GCCでコンパイルした場合のように)クラッシュするのと同じくらい有効です。わかりました、でもClangはなぜそうしているのですか?最適化をオフにしてコンパイルすると、プログラムは「フォーマット中のハードディスクドライブ」を出力せず、クラッシュするだけです。

_$ clang++ -std=c++17 -O0 a.cpp && ./a.out
Segmentation fault (core dumped)
_

このバージョン用に生成されたコードは次のとおりです。

_main:                                   # @main
        Push    rbp
        mov     rbp, rsp
        call    qword ptr [foo]
        xor     eax, eax
        pop     rbp
        ret
_

fooが指す関数を呼び出そうとしますが、foonullptrで初期化されているため(または、初期化が行われていない場合でも、これは当てはまります)、その値はゼロになります。ここでは、未定義の動作が発生しているため、何でも起こり、プログラムは役に立たなくなります。通常、このような無効なアドレスを呼び出すと、セグメンテーションフォールトエラーが発生するため、プログラムの実行時に表示されるメッセージです。

次に、同じプログラムを調べますが、最適化を使用してコンパイルします。

_$ clang++ -std=c++17 -O3 a.cpp && ./a.out
formatting hard disk drive!
_

このバージョン用に生成されたコードは次のとおりです。

_never_called():                         # @never_called()
        ret
main:                                   # @main
        Push    rax
        mov     edi, .L.str
        call    puts
        xor     eax, eax
        pop     rcx
        ret
.L.str:
        .asciz  "formatting hard disk drive!"
_

興味深いことに、何らかの方法で最適化によってプログラムが変更され、mainが_std::puts_を直接呼び出すようになりました。しかし、なぜClangはそれをしたのですか?そして、なぜ_never_called_が単一のret命令にコンパイルされるのですか?

しばらく標準(具体的にはN4660)に戻りましょう。未定義の動作についてそれは何を言いますか?

3.27未定義の動作 [defns.undefined]

このドキュメントが要件を課さない動作

[注:未定義の動作が予期される可能性がありますこのドキュメントで動作の明示的な定義が省略されている場合、またはプログラムが誤った構成または誤ったデータを使用している場合許容される未定義の動作の範囲状況を完全に無視する予測できない結果を伴う翻訳中の動作または、(診断メッセージの発行の有無にかかわらず)環境に特徴的な文書化された方法でプログラムを実行し、翻訳または実行(診断メッセージの発行を伴う)。エラーのあるプログラム構造の多くは、未定義の動作を引き起こしません。診断する必要があります。定数式の評価は、未定義([expr.const])として明示的に指定された動作を示すことはありません。 — end note]

鉱山を強調します。

未定義の動作を示すプログラムは、これまでに行ったすべてのこと、および誤ったデータや構造が含まれている場合はそれ以上のことは何も意味がないため、役に立たなくなります。このことを念頭に置いて、未定義の動作が発生した場合、コンパイラは完全に無視する可能性があることを忘れないでください。これは、プログラムを最適化するときに発見された事実として実際に使用されます。たとえば、_x + 1 > x_(xは符号付き整数)のような構成は、コンパイル時にtrueの値が不明な場合でも、定数xに最適化されます。推論は、コンパイラーが有効なケースに対して最適化することを望んでいることであり、その構造が有効になる唯一の方法は、算術オーバーフローをトリガーしない場合です(つまり、x != std::numeric_limits<decltype(x)>::max())。これは、オプティマイザで新しく学んだ事実です。これに基づいて、構成は常にtrueと評価されることが証明されています。

:オーバーフローするものがUBではないため、符号なし整数の場合、これと同じ最適化を行うことはできません。つまり、オーバーフローが発生したときに評価が異なる可能性があるため、コンパイラーは式をそのまま保持する必要があります(符号なしはモジュール2です)。N、ここでNはビット数です)。符号なし整数に対して最適化すると、標準に準拠しなくなります(ascheplerに感謝)。

これは トンの最適化を開始する を可能にするので便利です。これまでのところ、とても良いですが、xが実行時に最大値を保持するとどうなりますか?まあ、それは未定義の動作なので、何が起こっても標準が要件を課さないので、それについて推論しようとするのはナンセンスです。

これで、問題のあるプログラムをより詳しく調べるために十分な情報が得られました。 nullポインターへのアクセスは未定義の動作であることは既にわかっています。それが実行時におかしな動作を引き起こしているのです。それでは、Clang(または技術的にはLLVM)がプログラムを最適化した理由を理解してみましょう。

_static void (*foo)() = nullptr;

static void format_disk()
{
  std::puts("formatting hard disk drive!");
}

void never_called()
{
  foo = format_disk;
}

int main()
{
  foo();
}
_

mainエントリが実行を開始する前に_never_called_を呼び出すことが可能であることを覚えておいてください。たとえば、トップレベルの変数を宣言するときは、その変数の値を初期化しながら呼び出すことができます。

_void never_called();
int x = (never_called(), 42);
_

このスニペットをプログラムに書き込むと、プログラムは未定義の動作を示さなくなり、メッセージ "formatting hard disk drive!"が表示され、最適化がオンまたはオフになります。

それで、このプログラムが有効である唯一の方法は何ですか? _never_caled_のアドレスをfooに割り当てるこの_format_disk_関数があるため、ここで何かが見つかるかもしれません。 foostaticとしてマークされていることに注意してください。これは、内部リンケージがあり、この翻訳単位の外部からアクセスできないことを意味します。対照的に、関数_never_called_には外部リンケージがあり、外部からアクセスできます。別の翻訳単位に上記のようなスニペットが含まれている場合、このプログラムは有効になります。

かっこいいですが、外部から_never_called_を呼び出す人はいません。これは事実ですが、オプティマイザーは、このプログラムが有効である唯一の方法は、_never_called_がmainの実行前に呼び出された場合のみであり、それ以外の場合は、未定義の動作です。これは新しく学んだ事実なので、コンパイラは_never_called_が実際に呼び出されると想定します。その新しい知識に基づいて、起動する他の最適化がそれを利用する可能性があります。

たとえば、 定数の折りたたみ が適用されている場合、fooを適切に初期化できる場合にのみ、構造体foo()が有効であることがわかります。これが発生する唯一の方法は、_never_called_がこの変換単位の外部で呼び出された場合に_foo = format_disk_を呼び出すことです。

デッドコードの削除 および 手続き間の最適化 は、_foo == format_disk_の場合、_never_called_内のコードが不要であるため、関数の本体が単一のret命令。

インライン展開 最適化はその_foo == format_disk_を参照するため、fooの呼び出しをその本体で置き換えることができます。最終的には、次のような結果になります。

_never_called():
        ret
main:
        mov     edi, .L.str
        call    puts
        xor     eax, eax
        ret
.L.str:
        .asciz  "formatting hard disk drive!"
_

これは、最適化をオンにしたClangの出力と多少同等です。もちろん、Clangが実際に行ったことは異なる場合があります(ただし異なる場合もあります)が、最適化を行っても同じ結論に達することができます。

最適化をオンにしてGCCの出力を調べると、調査する必要がなかったようです。

_.LC0:
        .string "formatting hard disk drive!"
format_disk():
        mov     edi, OFFSET FLAT:.LC0
        jmp     puts
never_called():
        mov     QWORD PTR foo[rip], OFFSET FLAT:format_disk()
        ret
main:
        sub     rsp, 8
        call    [QWORD PTR foo[rip]]
        xor     eax, eax
        add     rsp, 8
        ret
_

そのプログラムを実行するとクラッシュ(セグメンテーション違反)が発生しますが、mainが実行される前に別の変換単位で_never_called_を呼び出すと、このプログラムは未定義の動作を示さなくなります。

最適化がますます設計されるにつれて、これらすべてが狂ったように変化する可能性があるため、コンパイラが未定義の動作を含むコードを処理するという仮定に依存しないでください。 )


すべてのCプログラマーが未定義の動作について知っておくべきこと および CおよびC++での未定義の動作のガイド を読むことをお勧めします。両方の記事シリーズは非常に有益であり、最先端の技術を理解する。

36
Mário Feroldi

実装がnull関数ポインターを呼び出そうとする効果を指定しない限り、それは任意のコードへの呼び出しとして動作する可能性があります。このような任意のコードは、関数「foo()」の呼び出しのように完全に適切に動作する可能性があります。 C標準のAnnex Lは、「クリティカルUB」と「非クリティカルUB」を区別するための実装を招待し、一部のC++実装は同様の区別を適用する場合がありますが、無効な関数ポインターを呼び出すと、いずれの場合もクリティカルUBになります。

この質問の状況は、たとえば.

_unsigned short q;
unsigned hey(void)
{
  if (q < 50000)
    do_something();
  return q*q;
}
_

後者の状況では、「分析可能」であると主張しないコンパイラーは、実行がreturnステートメントに到達したときにqが46,340より大きい場合にコードが呼び出されることを認識するため、do_something()無条件。 Annex Lはひどく書かれていますが、そのような「最適化」を禁止する意図があるように思われます。ただし、無効な関数ポインターを呼び出す場合、ほとんどのプラットフォームで直接生成されたコードでさえ、任意の動作をする可能性があります。

0
supercat