1 ポイント 投稿者 GN⁺ 4 시간 전 | 1件のコメント | WhatsAppで共有
  • 未定義動作(UB) はコンパイラによる悪意ある最適化ではなく、コードが有効であるという前提のもとで、不可能な実行経路を処理しなくてもよいという規則である
  • ありふれた C/C++ コード には、double-free や境界外アクセスだけでなく、アラインメント、キャスト、初期化、型不一致のような微妙な UB が広く潜んでいる
  • アラインされていない int*std::atomic<int>* へのアクセスは、プラットフォームによって SIGBUS、カーネル補正、一見正常に動く結果などに分かれるが、標準上はすでに UB である
  • isxdigit() に signed char を渡したり、floatint に変換したり、NULL と可変長引数を誤って使ったりする一般的なコードも、容易に標準の外へ出てしまう
  • 既存のコードベースを捨てることはできないが、LLM ベースの UB 検出 と専門家の検証を組み合わせて大規模に修正する必要があり、ジュニアに任せるにはあまりに微妙すぎる

C/C++ の未定義動作は最適化の問題ではない

  • 未定義動作(UB) は、コンパイラが開発者のミスを「悪用」するという意味ではなく、プログラムが標準上有効だと仮定できるという意味である
  • 人間の目には意図が明白でも、コンパイラの段階やモジュール間では、その意図を表現するのが難しいことがある
  • コンパイラには「起こりえない」特殊ケースをコード生成で処理する義務がなく、ハードウェアまで含めた実行経路では、意図と異なる結果が出ることがある
  • 最適化を無効にしても UB が安全になるわけではなく、現在や将来のコンパイラ・アーキテクチャで同じ動作が維持される保証もない

UB は異常なコードにだけあるわけではない

  • double-free、use-after-free、オブジェクト境界外アクセス、未初期化メモリアクセスはよく知られた UB だが、業界全体で繰り返し現れている
  • さらに微妙で直感に反する UB も多く、一見普通に見える C/C++ コードでも簡単に標準の外へ出てしまう
  • C23 標準では “undefined” という語が 283 回登場し、明示されていないことで未定義になる場合まで含めると、その範囲はさらに広い
  • ひととおりの規模がある C/C++ コードには UB が至る所にあり、これを個々のプログラマの不注意だけに帰するのは難しい

アラインされていないオブジェクトへのアクセス

  • 次のように int* を逆参照する関数は、ポインタが正しくアラインされていないと UB になる
    int foo(const int* p) {
       return *p;
    }
    
  • アラインメント(alignment) は通常 sizeof(int) の倍数アドレスを意味しうるが、実際の要件はプラットフォームや実装によって異なることがある
  • Linux Alpha では、一部のケースでカーネルがトラップを受けてソフトウェアで意図したアクセスを模倣できたが、別のケースでは SIGBUS でプログラムが落ちることもある
  • SPARC では SIGBUS が発生し、x86/amd64 では概ね問題なく動作したり、原子的な読み取りのように見えたりすることもある
  • ARM、RISC-V、将来のアーキテクチャでは結果を一般化できず、将来のアーキテクチャが int* の下位ビットを使わない特殊レジスタを持つ可能性もある
  • コンパイラが別の load 命令を使えば、以前はカーネルが補正していたアクセスが、もはや補正されなくなる可能性がある
  • コンパイラには、アラインされていないポインタでも動作するアセンブリを生成する義務はなく、そのアクセス自体が UB である

原子型もアラインメントが狂っていればすでに UB

  • 次のように std::atomic<int>* に対して store()load() を呼んでも、オブジェクトが正しくアラインされていなければ動作は UB である
    void set_it(std::atomic<int>* p) {
            p->store(123);
    }
    int get_it(std::atomic<int>* p) {
            return p->load();
    }
    
  • 「アラインされていないオブジェクトでもこの演算は原子的か」という問いは、標準の観点では成立しない
  • 実際のハードウェアでは原子性が問題になるかもしれないが、標準上はその前にすでに UB である
  • 原子的に読んでいるつもりのオブジェクトがページをまたいでいる場合は問題がさらに複雑になるが、結論は「大丈夫」ではなく UB である

ポインタを作るだけでも問題になりうる

  • アラインされていないポインタは、逆参照する前であっても、特定の型のポインタへ キャスト するだけで問題になることがある
    bool parse_packet(const uint8_t* bytes) {
            const int* magic_intp = (const int*)bytes;   // UB!
            int magic_raw = foo(magic_intp);  // Probably crashes on SPARC.
            int magic = ntohl(magic_raw); // this is fine, at least.
            […]
    }
    
  • ここで問題なのは foo() の呼び出しではなく、(const int*)bytes というキャストである
  • コンパイラが int* の下位ビットに、ガベージコレクションやセキュリティ用タグビットのような意味を与えることも、標準上は可能である

isxdigit()char を渡す問題

  • 次のコードは単純に見えるが、char が signed なアーキテクチャでは、入力値が 0–127 の範囲を外れると UB になりうる
    bool bar(char ch) {
            return isxdigit(ch);
    }
    
  • isxdigit() は 16 進数文字かどうかを判定する関数で、EOF も引数に取りうる
  • C23 7.4p1 によれば、EOFint であり、unsigned char で表現できない値だと推論できる
  • isxdigit()char ではなく int を受け取り、char から int への変換自体は可能でも、signed char の負値が問題になる
  • C23 6.2.5 段落 20 によれば、char が signed かどうかは実装定義である
  • 次のように実装された isxdigit() は、負のインデックスで未知のメモリを読む可能性がある
    int isxdigit(int c) {
            if (c == EOF) {
                    return false;
            }
            return some_array[c];
    }
    
  • そのメモリが I/O マップ領域なら、任意の値やクラッシュを超えて、ハードウェア動作を引き起こす可能性さえある
  • デスクトップ OS 上のアプリケーションより、組み込みシステムで起こりやすいが、ユーザー空間ネットワークドライバのように、ユーザー空間だけでも保護が十分でない場合はありうる

float から int へのキャストの問題

  • 次のように秒単位の float 値をミリ秒の int に変換するコードはよくあるが、UB を含んでいる
    int milliseconds(float seconds) {
            int tmp = (int)(seconds * 1000.0); /* WRONG */
            return tmp + 1; /* WRONG separately (signed overflow is UB) */
    }
    
  • C23 6.3.1.4 は、有限の実数浮動小数点値を整数型に変換する際、その整数部が該当整数型で表現できないなら動作は未定義だと定めている
  • 非有限値についても明記がなく、UB になる
  • floatINT_MAX と比較すること自体も単純ではない
    • floatint にキャストすると、避けたかった UB が発生しうる
    • INT_MAXfloat にキャストすると、正確に表現されるか分からない
    • INT_MAXfloat に丸められて、int で表現できない値になると、比較は代表性を失う可能性がある
  • 安全にするには、isfinite() のチェック、INT_MIN + 1000INT_MAX - 1000 のような余裕を持たせた範囲比較、変換後に加算する前の追加チェックが必要になる
    int milliseconds(float seconds) {
            const float ftmp = seconds * 1000.0f;
            if (!isfinite(ftmp)) {
                    return 0;
            }
            if ((float)(INT_MIN + 1000) > ftmp) {
                    return 0;
            }
            if ((float)(INT_MAX - 1000) < ftmp) {
                    return 0;
            }
            const int tmp = (int)ftmp;
            if (INT_MAX == tmp) {
                    return 0;
            }
            return tmp + 1;
    }
    
  • 単に floatint にしたいだけなのに、安全なコードははるかに長くなる

アドレス 0 のオブジェクトと null pointer

  • OS カーネルや組み込みコードでは、アドレス 0 にオブジェクトを置きたくなる状況が生じうる
  • C 標準に従って、実際にアドレス 0 にオブジェクトを置く実用的な方法はないと考えてよい
  • C 6.3.2.3 では、ポインタに変換可能な整数定数 0 と nullptr は “null pointer constant” であり、ここでは NULL と呼べる
  • C は実際の NULL ポインタが機械アドレス 0 を指すとは規定していない
  • C 標準が扱うのはハードウェアではなく C の抽象機械であり、NULL と 0 を比較したときに等しいことだけを保証する
  • その等しさは、整数の 0 がそのプラットフォーム固有の native NULL 値へ変換されるからかもしれず、その値が 0xffff である可能性もある
  • null pointer を逆参照することは、その値が何であれ UB であり、C 3.4.3 の代表的な例でもある
  • したがって memset(&ptr, 0, sizeof(ptr));NULL ポインタが作られると仮定することはできない
  • 構造体を 0 で初期化して、メンバポインタが NULL だと仮定するやり方は、多くのプログラマにとって現実に問題になりうる
  • 歴史的には 0 以外の NULL ポインタを使った機械も存在した

アドレス 0 に関数があると仮定する問題

  • 現代のマシンで NULL がアドレス 0 を指し、実際にそのアドレスにオブジェクトや関数があったとしても、C 6.3.2.3 は NULL がいかなるオブジェクトや関数とも等しくないと規定している
  • したがって次のコードは UB である
    void (*func_ptr)() = NULL;
    func_ptr();
    
  • C の観点では「そこに関数はない」という意味になり、コンパイラ内部にこうした意図を表す方法がないかもしれない
  • 単に全ビット 0 のアドレスへ call 命令を出すと想定することはできない
  • 16 ビット x86 では、「すべて 0」が 0000:0000 なのか CS:0000 なのかも明確ではない

可変長引数と型不一致

  • execl() の最後の引数はポインタでなければならないため、NULL マクロや整数 0 をそのまま渡すと UB になりうる
    execl("/bin/sh", "sh", "-c", "date", NULL);  /* WRONG */
    execl("/bin/sh", "sh", "-c", "date", 0);     /* WRONG */
    
  • 正しい形は、明示的にポインタ型へキャストすることである
    execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
    
  • NULL マクロは整数 0 と解釈されうえ、可変長引数では必要な型情報が渡らない
  • printf() でも、フォーマット指定子と実際の引数型が一致しなければ UB である
    uint64_t blah = 123;
    printf("%ld\n", blah);  /* WRONG */
    
  • uint64_t の出力には PRIu64 を使うべきである
    uint64_t blah = 123;
    printf("%"PRIu64"\n", blah);
    
  • uid_t を出力するには uintmax_t へキャストして PRIuMAX を使う方法があるかもしれないが、uid_t が unsigned かどうかすら確実ではない
  • 最悪の場合、-1 の代わりに無意味な値が出力される可能性がある

0 で割ることとセキュリティ問題

  • 0 で割ることが UB であるのは広く知られているが、分母が信頼できない入力に由来するなら、これはセキュリティ問題になる
  • 単なるランタイムエラーではなく、入力検証の境界で UB が発生しうる点が重要である

UB ではないが整数昇格も危険

  • 整数昇格の規則は、コードを流し読みする速度では適用しづらく、直感と異なる結果を生みうる
  • 次のコードでは overflowed は 1 ではなく 0 になる
    unsigned char a = 0xff;
    unsigned char b = 1;
    unsigned char zero = 0;
    bool overflowed = (a + b) == zero;
    // overflowed is set to zero, not one.
    
  • 次のコードでは、すべての変数が unsigned に見えても、結果は 2147483648 (0x80000000) ではなく 18446744071562067968 (ffffffff80000000) になる
    unsigned char a = 0x80;
    uint64_t b = a << 24;     // Bonus UB(?)
    
  • UB ではなくても、C/C++ の整数規則は直感的ではなく、不具合を生みやすい

LLM を使った UB 検出

  • 最新の LLM は、任意の C コードから UB を探すよう求めると、ほぼ常に問題を見つけ出し、概ね正しい結果を返す
  • 個人のコードで UB を見つけたあと、同じ方法が、成熟して厳密に書かれた OpenBSD のコードにも適用された
  • 最初に思いついた対象である find に対して、いくつもの問題が見つかった
  • OpenBSD には、範囲外書き込みに対するパッチと、UB ではないロジックバグに対するパッチが送られた
  • 残っていた多くの UB については、パッチは送られなかった
    • OpenBSD プロジェクトが過去のバグ報告にあまり友好的でなかった経験があった
    • 実際には問題ないかもしれないという判断もあった
    • OpenBSD がコードベースから UB を除去するには、LLM とプロジェクトの間で個別パッチを受け渡しする方式より、大きなプロジェクトが必要になる

C/C++ コードベースを扱う現実的な方向性

  • 既存の C/C++ コードベースを捨てることはできないが、本質的に壊れた状態のまま放置するのも選択肢ではない
  • AI が作った低品質な変更をコミットせず、人間のレビュアーを圧倒もしない形で、UB を大規模に修正する必要がある
  • 2026 年に、LLM による UB 監督なしで C や C++ を書くことは、SOX 違反のように見なされ、無責任だと受け止められるかもしれない
  • OpenBSD の開発者ですら、30 年以上こうした問題をすべて見つけられなかったのなら、他のプロジェクトの可能性はさらに低い
  • 個人プロジェクトでは、LLM に UB を探させ、必要なら説明させ、修正させたうえで、人間が結果を確認するやり方が可能である
  • ただし結果を検証するには専門家が必要であり、その専門家はたいてい別の仕事で忙しい
  • この作業は掃除のようにも見えるが、伝統的にそうした仕事を担ってきたジュニアプログラマに任せるには、あまりにも微妙すぎる

関連資料

1件のコメント

 
GN⁺ 4 시간 전
Hacker Newsのコメント
  • C には驚くほど奇妙な未定義動作がたくさんあるが、この記事はそれをうまく示せておらず、表面を少しなぞった程度に見える。
    もっと奇妙な例として volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x); がある。x がただの int なら問題ないが、volatile だと未定義動作になる。C 標準では volatile へのアクセスは読むだけでも副作用であり、同じスカラーオブジェクトに対する順序付けのない副作用は未定義動作で、関数引数の評価順は互いに未規定だからだ。
    一般にデータ競合は別々のスレッドが同じオブジェクトに同時アクセスし、そのうち少なくとも一方が書き込みである場合を指すが、C では単一スレッドでも書き込みなしにデータ競合に似た状況が生じうる。

    • 筆者として同意する。この記事の目的は、標準で undefined という語が出てくる 283 箇所や、省略によって未定義になるあらゆる場合を列挙することではない。
      要点は避けられないということだ。少なくとも 1972 年に C が生まれて以来、人間がそれを完全に避けられたことはない、という意味だ。
      54 年間成功していないのなら、「もっと頑張れ」や「ミスをするな」は解決策ではない。Mythos が OpenBSD で見つけた悪用可能な欠陥の 1 つは OpenBSD 開発者たちにもかなり高く評価されたが、最も単純なコードにツールをかけても未定義動作が大量に見つかった。
      たとえば findwaitpid(&status) の後、waitpid() のエラー有無を確認する前に未初期化の自動変数 status を読むのも未定義動作だが、これが悪用可能になるアーキテクチャやコンパイラは想像しにくい。
      記事にも書いた通り、世の中のあらゆる未定義動作を列挙したいのではなく、あらゆる非自明な C/C++ コードには未定義動作があると言いたいのだ。
    • volatile型システムのハックだ。もっと原則的な解決をすべきだったし、現代の言語が「C がそうしたから良い考えだ」と真似すべきではない。
      初期の C コンパイラは常に値をメモリに書き戻していたので、ポインタをメモリマップド I/O ハードウェアに向けておけば、x を変えるたびに CPU 命令が実際のメモリ書き込みを行い、ドライバコードは動いていた。
      ところが最適化が入ると、コンパイラは x を更新し続けているだけだと見てレジスタに保持するようになり、ドライバが壊れた。C の volatile は「その最適化をするな」とコンパイラに伝えるハックであり、一方で本来の解決策であるライブラリレベルのメモリマップド I/O 組み込み関数の提供は、はるかに大きな仕事だったはずだ。
      組み込み関数が必要なのは、可能な動作と不可能な動作を正確に表現できるからだ。ある対象では 1 バイト、2 バイト、4 バイト書き込みがそれぞれ異なる動作で、ハードウェアもそれを区別する。4 バイトの RGBA 書き込みを期待するデバイスに 1 バイト書き込みを 4 回送ると、混乱するか動かないことがある。ビット単位の書き込みをサポートする対象もある。volatile だけでは何が起きるのか、その意味が何なのかを知る術がない。
    • 未定義動作と競合は区別すべきだ。この区別は未定義動作の議論でよく抜け落ちる。
      C プログラムをコンパイルして逆アセンブルすれば、未定義動作のないアセンブリプログラムになる。アセンブリには未定義動作という概念がないからだ。
      未定義動作はソースプログラムの属性であって、実行ファイルの属性ではない。つまり、そのソースが書かれた言語仕様がそのプログラムに意味を与えていないということだ。一方、コンパイル結果である実行ファイルには機械仕様が意味を与える。
      競合はプログラムの動作の属性だ。したがって C プログラムに未定義動作があるとは言えるが、実行ファイルに実際の競合が生じるとは言えない。もちろんコンパイラは未定義動作を含むプログラムを好きにコンパイルできるので競合を導入することもできるが、新しいスレッドを作らない形でコンパイルすれば競合はない。
    • volatile の意味はまさに、その値が何か別のものによって変わりうるということだ。グローバル変数なら、その「何か」は別スレッドだけでなく割り込みやシグナルハンドラかもしれない。特定アドレスを読むポインタなら、値が変化するハードウェアデバイスのレジスタかもしれない。
      volatile 変数という概念自体が問題なのではない。割り込みルーチンやメモリマップド I/O をサポートしたい言語なら、同じハードウェアレジスタを 2 回読むことが同じメモリ位置を 2 回読むのとは違うとコンパイラに伝える手段が必要だ。
      本当の問題は、言語機能と制約の相互作用が十分に整理されていないことにある。「この値はいつでも変わりうる」と明示しているのに、まさにその理由で一部の使い方を未定義動作とみなすのは愚かだ。volatile 変数については「順序付けのない副作用」の定義に例外があるべきだった。
    • 記事の核心は、未定義動作に遭遇するために奇妙なコードを書く必要すらないという点にある。
      多くの人は C や C++ が「やりたいことをやらせてくれるので本当に柔軟だ」と錯覚している。実際には、強力で格好良く見えるほとんどすべての手法が未定義動作の地雷原だ。
  • 非アラインメントなポインタの未定義動作はもっと悪い。非アラインメントなポインタはアクセス時だけでなく、ポインタそのものの存在だけで未定義動作になる。
    だから void* vint* i に暗黙キャストすること、たとえば C の i=v や、int* を受け取る f(v) も、結果のポインタが int のアラインメント要件を満たさなければ未定義動作だ。
    これが C レベルの問題だという点が重要だ。C プログラムに未定義動作があれば、その C プログラムは形式的には無効で誤ったプログラムになる。ハードウェアの問題ではなく、クラッシュや不具合とも無関係だ。
    void* から int* へのキャストは、ハードウェアコードとしては通常何も起きず、型は C にしかないので、ハードウェアがそのキャストでクラッシュすることもない。レジスタ中の整数値なら平気だと思うかもしれないが、要点はハードウェア上でポインタが実際に整数かどうかではなく、非アラインメントなポインタへキャストした瞬間に C プログラムが定義上壊れるということだ。

    • 筆者としてその通り。記事の “Actually, it was UB even before that” の節で扱った内容だ。
      未定義動作はハードウェアにあるものではなく、クラッシュや不具合とも無関係だという点も伝えたかった。同時に「ほら、ちゃんと動いているじゃないか」と言う人たちに例を示したかったが、実際にはそうではない。
    • 妥当で予測可能な話だ。まともなプログラマならポインタキャストが明らかに危険な領域だと知っている。
    • 非アラインメントなポインタ自体が未定義動作だという記述が標準のどこにあるのか教えてもらえる?
    • #pragma pack(push, 1) で構造体を作ると、偶然アラインしているメンバでない限り メンバポインタは使えないという意味か?
    • C における未定義動作という概念は元々、機械語命令がアーキテクチャごとに少しずつ違っていても、コンパイラがコードをハードウェアへマッピングする自由を与えるという意味だった。同じ C プログラムでも、実行するアーキテクチャによって異なる動作を表現できたわけだ。
      この種の未定義動作は問題なく、ハードウェア差異のためにバグが出ること自体を大問題視する人はほとんどいない。
      だが時が経つにつれて攻撃的な解釈が C を暗黙の契約による設計言語のように変えてしまい、制約条件が見えにくくなった。これは RAII における暗黙のデストラクタ呼び出しが見えにくいのと似た問題を生む。
      C でポインタをデリファレンスすると、コンパイラは関数シグネチャに暗黙の非 null 制約を追加する。null かもしれないポインタを関数に渡しても、チェックやアサートがないことをエラーとする代わりに、コンパイラはその非 null 制約をポインタに静かに伝播させる。その制約が偽だと証明できると関数を到達不能としてマークし、到達不能な関数呼び出しは呼び出し側の関数まで到達不能にしてしまう。
  • C で未定義動作を学ぶ 5 段階
    否認: 「自分のマシンで符号付きオーバーフローがどうなるかは分かっている」
    怒り: 「このコンパイラはゴミだ! なぜ言った通りに動かない?」
    取引: 「C を直すために wg14 にこの提案を出すぞ……」
    抑うつ: 「C コードで信頼できるものなんてあるのか?」
    受容: 「未定義動作なんて使うな」

    • 「コンパイラに未定義なものを定義させればいい」という段階はどこに入る?
      非アラインメントアクセスはpacked 構造体を使えばよい。コンパイラが魔法のように正しいコードを生成してくれる。実際にはコンパイラは最初から正しくできたのに、していなかっただけだ。
      strict aliasing は union による型変換を使えばよい。まともなコンパイラなら、標準に書いていなくても動くと文書化されている。あるいは -fno-strict-aliasing で無効化してもいい。メモリを好きなように再解釈すればよく、鋭い角は残るにしても、少なくともコンパイラ起因ではなくなる。
      オーバーフローは -fwrapv で定義すればいい。+-*__builtin_*_overflow に置き換えれば、明示的なエラーチェックも無料で手に入る。関数型インターフェースとしてもよく、効率的なコードも生成される。
      本当の受容は「まともな人は C 標準なんて気にしない」に近い。標準はひどく、重要なのはコンパイラだ。コンパイラには、こうした問題の多くを回避できる非常に有用な機能がたくさんある。人々が使わないのは「移植可能な」「標準」C を書きたがるからで、その発想から抜け出すのが本当の受容だ。
      この論理で freestanding 環境の C で Lisp インタプリタを作って、UBSan も通した。最初は壊れると思っていたが、そうはならなかったし、自分にできるなら誰にでもできる。
    • 筆者として、「未定義動作なんて使うな」が不可能だというのが記事の要点だ。
      人間がコードを書く限り、それが最終到達点にはなりえない。どんな人間でも C/C++ で未定義動作を完全には避けられない。
    • 「未定義動作なんて使うな」は、よくてもまだ取引の段階に聞こえる。
    • 自分のように組み込み機器で働けばいい。特定の CPU 向けにソフトウェアを書くのは本当に楽だ。
    • C における受容は、「自分は未定義動作を使うし、いつか悪いことが起きるだろう」に近い。
  • 例は実際の未定義動作というより、入力や状況次第で未定義動作になりうる事例に近い。
    そこまで広く取るなら、どんな関数呼び出しでもスタック領域を超える可能性があるので未定義動作だと言えてしまう。実際、ある意味ではどの言語でも似たような話になる。
    C には注目に値する本物の荒っぽい部分が十分あるのに、こういう扇情的な言い方は特に初心者の注意を逸らし、むしろ害になるかもしれない。

    • Ada 83 では呼び出しスタックのオーバーフローは未定義動作ではない。参照マニュアルに STORAGE_ERROR 例外が定義されている。
      http://archive.adaic.com/standards/83lrm/html/lrm-11-01.html
      「サブプログラム呼び出しの実行中に十分な記憶領域がない場合」にもこの例外が発生するとされている。
    • まったくその通りではない。
      まず、スタック領域を超えたときに何が起こるかを定義することはできる。また、すべてのプログラムが任意サイズのスタックを必要とするわけではなく、事前に計算可能な定数サイズだけを必要とするものもある。スタックをまったく使わない言語実装もある。
      言語が残りスタック領域を確認する手段を提供し、それに応じた保証を与えることもできる。あるいは、スタックが尽きたときに実行するハンドラを登録できるようにすることも可能だ。
    • 入力によって発生する未定義動作も悪用経路になりうる。
    • 例は明らかに未定義動作だ。それで終わりだ。
      正しい考え方は、未定義動作が発生した瞬間に、もはや言語標準の保護下にはいないということだ。しばらく、あるいは永遠にちゃんと動くかもしれない。だが実際には、知らないうちにツールチェーン、コンパイラの変更やアップグレード、アーキテクチャ、ランタイム、libc のバージョン差といった気まぐれに従属する。
      結局は砂の上に土台を築くことになり、それが未定義動作の危険だ。
    • この記事はほとんど FUD の定義そのものだ。
  • 未定義動作の問題は、あるアーキテクチャでクラッシュしうることではない。
    本当の問題は、コンパイラがそのようなコードは絶対に起こらないと期待することだ。それでも未定義動作のコードを書くと、コンパイラ、特に最適化器は、正常系に都合のいいどんな形にも翻訳できる。その「どんな形」が、ときには大きなコード塊の削除のような非常に予想外のものになりうる。

    • これに関連する例として、すべての関数は終了するか副作用を持たなければならないという条件がある。まだ自分で踏んだことはないが、うっかり無限ループや再帰を書いた結果、関数が削除される状況は十分想像できる。
      末尾再帰まで重なると、デバッグビルドでは無限ループに到達しないのに、最適化レベルを上げたときだけバグが表面化することもありそうだ。
    • クラッシュは未定義動作の中ではかなり穏当な部類だ。少なくとも目に見えやすいからだ。
      もっと悪い場合、プログラムは静かにゴミ値で動き続けたり、ハードディスクをフォーマットしたり、攻撃者に王国の鍵を渡したりしかねない。
    • その通りだが、これこそが未定義動作の最も有用な機能であり、存在理由でもある。
      単に定義済みにするか未規定動作にしようという人たちは、コンパイラがプログラムの大部分を削除できることこそ核心だと見落としている。
      特定入力で未定義動作になるコードを書くということは、その入力に対してプログラムがいかなる動作も持たないことを意図しているということだ。コンパイラには、その経路を最適化で消してほしいし、あるいは定義済みの別ケースの動作に役立つ何らかの処理をしてほしい。
      未定義動作を通してしか到達できないログ文字列を入れておき、バイナリにその文字列が残っていないのを見ると、かなり満足感がある。
    • 記事で最適化の問題ではないと書いていた部分が特に目に留まった。
      以前、変換パイプラインの最後に実行される前提で解析パスを書き、その前提が正しさに必要だった。これ以上最適化は起きないから安全だと思っていたが、今は自信がない。
    • それは問題ではなく機能だ。
  • 20 年間 C を使ってきたが、ここ最近 6 か月の Hacker News で見たほど未定義動作の話を聞いたことはない。
    実際の会話ではほとんど出てこなかった。コードを書き、動かなければデバッグして直すか回避すればいい。なぜ C の未定義動作という話題がこんなに継続的に 1 面に上がってくるのか分からない。

    • Hacker News は今でも、実際のプログラミングよりプログラミング言語への関心が強い方向に偏っている。Y Combinator の Lisp の遺産のようなものもあるのだろう。
      新しいプログラミング言語を開発したり使ったりすることがこの世で最も面白いと考える計算機科学専攻の少数派は昔からいて、その一部は今でもそう考えている。
      そういう人たちが言語設計の観点に関心を持つのは自然で、C の未定義動作はまさにその領域に属する。ただ、元々の多くは古い CPU アーキテクチャを性能低下なく受け入れようとした結果であり、車輪が丸いのと同じくらい「設計選択」と呼ぶのが微妙な面もある。
    • 何を言っているんだ? 20 年前にも C と C++ を使っていたし、その頃も未定義動作は会話や教育課程で大きな比重を占めていた。
      GCC 3.2 前後で、コンパイラが最適化において未定義動作をはるかに攻撃的に利用し始めたことで、かなり有名な「スキャンダル」がいくつもあった。そのため多くの人が長く GCC 2.95 にとどまった。GCC 3.2 は 2002 年に出た。
    • 昔のコンピュータは格好良く、今のコンピュータは危険になった。
      どの会社も安全と露出、つまりニュースになることを繰り返し強調するので、「安全でないこと」への反対の物語が過剰に大きくなっている。
      新しい世界は、生の自然を見たことのない都会の人が芝刈り機を見て怯えるのに似ている。刃が回っているって? あり得ない!
    • 運用環境がまったく別のアーキテクチャかもしれないので、こうした細部は非常に重要だ。
      実際の対象が辺境の通信塔の上にある小さな組み込みシステムなら、「自分のマシンでは動く」は役に立たない。もちろん大半の人はそんな仕事をしておらず、ここの開発者の多くもおそらく Web 開発者だろうが、自分で経験していなくても興味深い議論だ。むしろそういう場合のほうがそうかもしれない。
    • 正確には、架空の仕様に対して書くのではなく、ターゲット環境に合わせて書くということだ。仕様はターゲットが大まかに何をするかを予測するのには役立つが、規範ではない。
      コンパイラには、仕様上は動くはずなのにそうならないバグがありうるし、標準に対応物のない拡張も多い。また、標準では未定義でも実装ごとに意味のある結果が割り当てられている動作もある。
  • 導入部には概ね同意するが、例がよくなく、記事全体がLLM コーディングを推すための包装のように見える。

    • そうだ。例は 1 つ 1 つが移植可能なコードを書くなら避ける標準的なものか、アドレス 0 のオブジェクトアクセスのような不要なものだ。
      好き勝手にコードを書いて、どの環境でも同じように動いてほしいと思っている人のように見える。そんな言語にしてしまうと、必要なときにプラットフォームに合わせて書けるという利点が失われる。
    • どういう意味でよくないのか? もし本当なら、それはかなり深刻だ。
  • この記事の C++ コードの一部は、10 年以上前からイディオム的ではなく、今ではコードスメルと見なされてもおかしくない。
    言語は登場当初とはかなり違うものへ進化している。生ポインタや直接的なポインタアクセスが大量に出てきた時点で、記事の一部は多少割り引いて読むべきだと明らかだった。
    もう 1 つの明白な問題は、C と C++ をほぼ同じ言語のようにひとまとめにしている観点だ。いまや両者は実際かなり離れている。

    • コードが C++ ではなく C だと指摘しようとして見直したら、実際には atomic_int ではなく std::atomic になっていた。
  • C の未定義動作は、こう理解すればよいのか?
    プログラム P には、未定義動作を引き起こさない入力集合 A と、それを引き起こす補集合 B がある。
    正しいコンパイラは P を実行ファイル P' にコンパイルする。A に属するすべての入力に対して、P' は P と同じように動作しなければならない。
    しかし B に属するいかなる入力についても、P' の動作には何の要件もない

    • 直観的にはそうだ。プログラムは B の入力が絶対に来ないかのようにコンパイルされ、その中には B を検出しようとするコードの削除も含まれうる。
    • いい要約だ。
  • 非アラインメントなポインタによる未定義動作の具体例: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...

    • とりわけ問題なさそうだとよく思われている x86 での事例だ。