世界を動かす技術を、日本語で。

Cにおけるすべては未定義動作である

概要

  • C/C++のコード は、どんなに熟練したプログラマでも 未定義動作(UB) を避けることが非常に難しい。
  • UBは 意図せず発生しやすく、現代の環境やアーキテクチャでますます問題となっている。
  • コンパイラやハードウェア の進化により、昔は問題なかったコードも将来動かなくなる可能性。
  • LLM(大規模言語モデル) は人間よりもUBの発見が得意になりつつある。
  • 既存のC/C++資産 をどう扱うかが今後の大きな課題。

C/C++における未定義動作(UB)の普遍性

  • Cardinal Richelieuの言葉 を借りれば、どんな優秀なCプログラマでも6行のコードでUBを引き起こすことができるという現実。
  • 30年以上C/C++を使ってきた筆者 でも、完全な正当性を保つことは不可能と断言。
  • C++の環境(1985年)やCの環境(1972年) は、現代の要件や環境と大きく異なる現実。
  • SOX違反 とまで言われるC++利用のリスク認識。
  • UBの種類 は想像以上に多く、明らかなもの(ダブルフリー、範囲外アクセス、未初期化メモリ参照)だけでなく、微妙で直感に反するものも多数。

UBの誤解とコンパイラの挙動

  • 最適化をオフ にしてもUBは回避できないという誤解。
  • UBとは「コンパイラが好き勝手する」ことではなく、「そもそもコンパイラにその状況を考慮する義務がない」こと を意味。
  • 人間が意図した動作 は、コンパイラやハードウェア間で伝達できない場合が多い。

代表的なUB事例と解説

  • アライメント違反のポインタ参照

    • 例:int foo(const int* p) { return *p; }
    • x86では許容されるが、SPARCやAlphaではクラッシュやカーネルトラップ。
    • 将来のアーキテクチャではさらに予測不能。
  • std::atomicの未アライメント問題

    • アトミック操作の前提が崩れるとUB。
    • ページをまたぐオブジェクトも危険。
  • キャスト時点でのUB

    • 例:const int* magic_intp = (const int*)bytes; の時点でUB。
    • ポインタの下位ビットに意味を持たせるアーキテクチャも存在。
  • isxdigit()へのchar型引数

    • charがsignedの場合、負の値で配列アクセス→UB。
    • 組込みやユーザ空間ドライバで思わぬ副作用。
  • floatからintへのキャスト

    • 範囲外・非有限値の変換はUB。
    • INT_MAXとの比較も罠が多い。
  • アドレス0のオブジェクト

    • C標準ではNULLの実体が0アドレスと限らない。
    • memsetでポインタを0初期化しても安全ではない。
  • 可変長引数と型不一致

    • printf等で型を間違えるとUB。
    • NULLの型にも注意が必要。
  • ゼロ除算

    • 単なるクラッシュだけでなく、攻撃ベクトルにもなりうる。
  • 整数昇格やシフト演算の罠

    • unsigned charの計算でも直感に反する結果やUBが発生。

LLMによるUB検出の現状

  • LLMはCコードのUB発見に非常に優秀
  • OpenBSDの成熟コードでもLLMが多数のUBを指摘。
  • プロジェクト単位でUB排除するには大規模な取り組みが必要。

今後の課題と道筋

  • 既存のC/C++資産 をすぐに捨てることは不可能。
  • しかし 放置も許されない ため、何らかの対応策が必須。
  • 安全な言語やツールの導入、コードベースの段階的移行 などが今後の検討課題。

C/C++コードベースの今後

  • C/C++の未定義動作 は避けられない現実。
  • 既存資産の保守と安全性向上 のための新たなアプローチが必要。
  • LLMや静的解析ツールの活用、言語移行の検討。
  • 業界全体での認識共有と教育 の強化。

Hackerたちの意見

UBの問題って、特定のアーキテクチャでクラッシュするかもしれないってことじゃないんだよね。実際の問題は、コンパイラがUBコードが発生しないことを前提にしているから、もしUBコードを書いちゃったら、コンパイラ(特に最適化ツール)がそれを便利な形に変換しちゃうってこと。で、その「便利な形」が予想外なことになることもある(例えば、大きなコードの塊を削除するみたいに)。

プログラマーがソースコードで明示的に示したコードパスを削除するのは、属性が付けられていない限り、厳しいコンパイルエラーにすべきだと思う(誰かCにunsafeキーワードを追加したい人いる?)。別のコメント者がLLMを使うことを提案してたけど、私は反対。clangdがチェックされてない操作(例えば、符号付き加算)に対して警告を出すのは良いスタートだと思う。

この道の一例として、すべての関数は終了するか副作用を持たなければならないってことがある。まだ私にはその影響は出てないけど、無限ループや再帰を書いちゃって関数が削除される可能性は十分にあるよね。あと、末尾再帰にボーナスポイント。デバッグ中に無限ループに引っかからなければ、このバグは高い最適化レベルでしか現れないかもしれない。

そうだね、クラッシュは最も無害な未定義動作の一つだよ。少なくとも目に見えるからね。もっとひどい場合だと、プログラムが静かにゴミデータを処理し続けたり、ハードディスクをフォーマットしたり、攻撃者に鍵を渡しちゃうこともある。

記事の「最適化の話じゃない」というポイントがすごく気になった。以前、変換パイプラインの最後に実行されるという前提で分析パスを書いたことがあって、これが正しさのために必要だったんだ。その前提は、これ以上の最適化が行われないから安全だというものだった。今はちょっと自信がなくなってきたな…。

それは問題じゃなくて、機能なんだよ。

そう、それは問題だけど、UBの最も有用な機能でもあるんだ。単に定義したり未定義にすることを提案する人は、コンパイラがプログラムの全体の部分を削除できることがポイントだってことを見落としてる。特定の入力に対してUBになるコードを書くときは、その入力に対してプログラムがどんな挙動を持つことも意図していないからなんだ。だから、コンパイラにはそれらを最適化してほしいし、他の定義されたケースの挙動から影響を受けることをしてほしい。ログ文字列をトリガーする条件を追加して、それがバイナリに現れないのを見るのはすごく満足感があるよ。なぜなら、それらはUBを通じてしか到達できないから。

イントロには同意するけど、これらの例は良くないし、全体の記事はLLMコーディングを推すためのカモフラージュに過ぎない。

どう悪いの?それは本当なの?もしそうなら、超ヤバいじゃん。

同意だね。これらはポータブルなコードを書くときに避けるべき標準的なことばかりだし(アドレス0のオブジェクトにアクセスするみたいに、必要ないこともある)。自分が好きなように書いて、どんな環境でも同じように動くことを望んでいる人からの意見に聞こえるよ。そういう言語にすることは、プラットフォームに合わせて書けるという利点を失うことになる。

これらの例は本当に未定義動作じゃないよ。入力や状況によってUBになる可能性がある例だよね。そんなに寛大に考えるなら、すべての関数呼び出しはUBだよ、スタックスペースを超える可能性があるから。これはどの言語でも基本的に真実だし(その言語のUBの定義に従って)。Cには注目すべき実際の粗い部分がたくさんあるから、こういうセンセーショナリズムは人々の注意を曇らせると思う(特に初心者にとって)し、逆に害を及ぼすこともある。

その例は間違いなくUBだよ。これを正しく考えるには、UBがあるときは言語標準の範囲外にいるってことを理解することが大事。しばらくは問題なく動くこともあるし、無限に動くこともある。でも、実際にはツールチェーン(コンパイラの入れ替えやアップグレード)、アーキテクチャ、ランタイム(libcのバージョン差)に振り回されることになる。結局、砂の上に基盤を築くことになるんだ。それがUBの危険性だよ。

全然違うよ。まず、スタック領域が超えたときに何が起こるかを定義できるし、すべてのプログラムが無制限のスタック領域を必要とするわけじゃない。事前に計算できる一定の量だけで済むプログラムもあるしね。(それに、実装によってはスタックを全く使わない言語もある。)君の言語も、残りのスタック領域を調べるツールを提供したり、それに基づいて保証をすることができるかもしれない。あるいは、スタック領域が足りなくなったときの処理を設定できるようにすることもできる。

入力に基づく未定義動作は、悪用される可能性があるよ。

Ada 83では、コールスタックのオーバーフローに関して未定義動作はないよ。リファレンスマニュアルから引用すると、「STORAGE_ERROR この例外は、以下のいずれかの状況で発生します:(...) または、サブプログラム呼び出しの実行中に、ストレージが不十分な場合。」

うん、この記事はまさにFUD(恐怖、不確実性、疑念)の定義だね。

CにおけるUBについての学びの5段階: -否認:「自分のマシンで符号オーバーフローがどうなるか知ってる。」 -怒り:「このコンパイラはクソだ!なんで言った通りに動かないんだ!?」 -交渉:「この提案をwg14に提出してCを修正する…」 -抑うつ:「Cコードを信頼できるの?」 -受容:「とにかくUBを書かないこと。」

-受け入れ: 「ただ未定義動作を書かないで。」もっとまともな言語に切り替えればいいじゃん。私がRustの信者だと攻撃される前に言っておくけど、Javaのことを言ってるんだよ :P 基準が低すぎて、地球の中心近くに浮いてるみたい。

ここに作者がいます。 > -受け入れ:「ただUBを書かないで。」私の記事のポイントは、これは不可能だということです。人間がコードを書く限り、これが最終的な状態にはなり得ません。C/C++でUBを書くのを避けられる人間はいません。

「ただコンパイラに未定義を定義させる」段階ってどの段階ですか?アラインされていないアクセス?パックされた構造体。コンパイラは魔法のように正しいコードを生成します。まるで最初から正しくやり方を知っていたかのように!実際、常に正しいやり方を知っていたんです。ただ、やらなかっただけ。厳密なエイリアス?ユニオン型のパニング。重要なコンパイラでは動作することが文書化されていますが、聖なるC標準はそれを言っていません。あるいは、単純に無効にしちゃうのもあり:-fno-strict-aliasing。好きなようにメモリを再解釈して楽しんでください。ここかしこで鋭い部分にぶつかるかもしれませんが、コンパイラから来ることは絶対ないです。オーバーフロー?ただ定義しちゃえばいい:-fwrapv。ついでに+、-、を__builtin__overflowに置き換えれば、明示的なエラーチェックも無料で手に入ります。いい機能的インターフェースだし、効率的なコードも生成します。「受け入れ」段階は実際には「正気の人はC標準なんて気にしない」ってことです。標準はゴミ、コンパイラだけが重要です。そして、コンパイラにはほとんどすべての問題を回避できる非常に便利な機能がたくさんあることがわかりました。人々は「ポータブル」な「標準」Cを書きたいから、これを使わないだけです。本当の受け入れは、その考え方から抜け出すことです。なんとかして、私はフリースタンディングCで完全なLispインタープリタを作り上げ、上記の論理に従うだけでUBSanを通過させることができました。最初は本当に驚きました。クラッシュすると思ってたのに、全然大丈夫でした。だから、私ができるなら、誰でもできるはずです。

Cでは、受け入れは「UBを書くことになるし、最終的には何か悪いことが起こる」ってことです。

-否認:「自分のマシンでの符号付きオーバーフローがどうなるかは知ってる。」それとも、Cの言語哲学やUBの理由が書かれた導入ページを飛ばさないで読んでみたら?確かにUBは難しいこともあるけど、最初の4つのステップは全く必要ないよ。それは、使っている言語のコアコンセプトを実際に理解していないことを意味していて、ちょっとバカみたいだね。

アライメントがずれたポインタの未定義動作はさらにひどいよ:アライメントがずれたポインタ自体が未定義動作で、アクセスすることだけじゃないんだ。だから、voidvをintiに暗黙的にキャストする(Cの'i=v'や、f()がintを受け入れるときの'f(v)'みたいに)も、キャストされたポインタがintにアラインされていなければ未定義動作になる。これはCレベルの問題だって理解することが重要だよ。Cプログラムに未定義動作があれば、そのプログラムは壊れてるってこと。つまり、正式には無効で間違ってるってことだよ。未定義動作はハードウェアの問題じゃなくて、クラッシュや故障とは関係ない。voidからint*へのキャストは、ハードウェア上にはコードが存在しない可能性が高いから、Cのレベルでの再解釈なんだ。だから、そのキャストでハードウェアがクラッシュすることはないよ(だって、そのためのコードすらないから)。レジスタ内の整数値は大丈夫だと思うかもしれないけど、実際にはポインタがレジスタ内の整数として存在することとは関係なく、キャストされたポインタがアラインされていない場合、Cプログラムは定義上壊れてるんだ。

それは明らかだよね。アライメントが合ってないアドレスから整数を読み込むことはできないし、Cレベルだけの問題じゃない。アーキテクチャ間での機械語の保証もないしね。

それは全然問題ないし、普通のプログラマーなら当然のことだよ。ポインタのキャストは明らかに危険な領域だし。

ここに作者がいます。 > アラインされていないポインタ自体は未定義動作(UB)です。そうですね。「実際、それはその前からUBでした」という部分に書いてあります。 > UBはハードウェアに関係ないし、クラッシュやエラーとも無関係です。そうそう、これも伝えようとしたんだけど、「でも、明らかに大丈夫だよ」と言う人たちにも例を挙げて説明してるんです。だって、そうじゃないから。

ってことは、#pragma pack(push, 1)で構造体を持ってる場合、アライメントが合ってないメンバーへのポインタを使えないってこと?

Cには驚くような変な未定義動作がたくさんあるけど、この記事はそれをうまく示してないね。表面的な部分しか触れてない。もっと変な例を挙げると、volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x); これはxが単なるintなら全然問題ないけど、volatileがあると未定義動作になるんだ。なんでかっていうと、5.1.2.4.1では、volatileなアクセス、つまり単に読むだけでも副作用があるって言ってる。6.5.1.2では、同じスカラーオブジェクト(この場合はx)に対する非順序の副作用は未定義動作だって。6.5.3.3.8では、関数引数の評価は互いに不確定に順序付けられているって教えてくれる。だから、一般的には「データレース」っていうのは、異なるスレッドから同じオブジェクトに対する同時アクセスのことで、少なくとも一つは書き込みがある場合を指すんだ。Cでは、単一スレッドでデータレースが発生することもあるし、書き込みなしでも起こるんだよ!

記事のポイントは、実際にはUB(未定義動作)に遭遇するために変なことをする必要はないってことだと思う。多くの人がCやC++は「すごく柔軟」だと勘違いしてるけど、実際は「やりたいことができる」だけじゃなくて、ほとんどの派手で強力なことはUBの絶対的な地雷原なんだよね。

そう、そこにはデータレースがあります。volatileの値は、現在のスレッドの外部で変更される可能性があります。それがvolatileの意味であり、存在理由です。編集: スレッド=実行のスレッドです。プログラム内のスレッドセーフについて言ってるわけじゃないです。

それは、非順序操作の概念を許可する限りは意味があるよ(確かにそれは少し珍しいけど;例えばSchemeではそういうものは順序で発生することが定義されているけど、どの特定の順序かは未定義で、毎回違う可能性がある)。「volatile」アノテーションは、変数をMMIOレジスタかその類のものとしてマークするもので、コンパイラの制御を超えた理由でいつでも変わる可能性があるものを示してる。もちろん、これには同時変更の危険が潜んでいる。とはいえ、「データレース」の「一般的な言葉」での定義はC標準で使われている定義とは違うから、最後の文は標準Cの議論では誤解を招くことになるよ。 > プログラムの実行は、異なるスレッドでの2つの対立するアクションを含む場合、少なくとも1つはアトミックでなく、どちらも他の前に起こらない場合、データレースを含む。こうしたデータレースはすべて未定義の挙動を引き起こす。(ここで「対立する」と「他の前に起こる」は前のテキストで定義されている。)

マイクロコントローラの周辺機器からレジスタを読み取ると、リセットされる可能性があるっていうのがここでの副作用の一例だね。そういう時にvolatileを使うんだ。

ボラティル(volatile)だと、リードの間に割り込みサービスルーチンで変更される可能性があるから、納得できるね。

ボラティルはシステムハックみたいなもんだよ。もっとちゃんとした修正をすべきだったし、現代の言語が「Cがやったから良いアイデア」みたいに振る舞うのはおかしい。ハックの理由は、初期のCコンパイラが常にスピル(spill)するから。だから、MMIOドライバコードを書くときにポインタをMMIOハードウェアに向けると、実際に動くんだ。xを変えるたびにCPU命令がメモリ書き込みを行うからね。Cコンパイラが基本的な最適化を行うようになると、その「賢い」トリックは機能しなくなる。コンパイラはxを何度も変更していることが分かるから、レジスタからxをスピルしなくなって、ドライバが正しく動かなくなる。Cの「volatile」キーワードは「コンパイラよ、その最適化を忘れろ」って言ってるハックで、実装には数分で済んだんだろうけど、MMIOのインストリンシックを関連ライブラリで提供する正しい修正はかなりの手間がかかる。インストリンシックが必要な理由は、実際に何が可能で何が不可能かを明示できるから。特定のターゲットでは1バイト、2バイト、4バイトの書き込みができるから、これらは異なる操作でハードウェアもそれを理解してる。例えば、あるデバイスが4バイトのRGBA書き込みを期待している場合、4つの1バイト書き込みを出すと混乱を招いて、動かないかもしれないから、やらない方がいい。特定のターゲットではビットレベルの書き込みも可能で、「0x1234のアドレスのビット4にMMIO書き込み」と言えば、単一のビットを書き込むことができる。ボラティルだけでは、何が起こるのか、何を意味するのか分からないんだ。

ここで書いてるのは私だよ。> ほんの表面をなぞるだけだね。 そう思う。この記事のポイントは、標準で「未定義」と使われている283の使い方を列挙して説明することじゃない。省略によって未定義になるものをすべて列挙することでもない。この記事のポイントは、それらを避けることは不可能だってこと。少なくとも、1972年にCが発明されて以来、誰もそれを避けられなかった。54年間成功してないのに、「もっと頑張れ」とか「ミスをしないようにしろ」ってのは、少なくとも解決策じゃない。MythosがOpenBSDで見つけた(唯一の!)悪用可能な欠陥は、OpenBSDの開発者たちにとって素晴らしい証拠だったけど、投稿でも言ってるように、私は彼らのコードの中で一番シンプルな部分を指摘して、たくさんのUBを見つけた。さて、findwaitpid(&status)の前に初期化されていない自動変数status(UB)を読み取るのは悪用可能なのか?(報告されてない)そんなアーキテクチャやコンパイラは想像できないな。要するに、> 以下は世界中のすべてのUBを列挙しようとする試みではない。UBはどこにでも存在するってことを示してるだけで、誰もそれを正しくできないなら、プログラマーを責めるのは公平なのか?私の言いたいことは、すべての非自明なCとC++コードにはUBがあるってこと。

CにおけるUBの理解はこれで合ってる? プログラムPにはUBを引き起こさない入力のセットAと、UBを引き起こす入力の補完セットBがある。正しいコンパイラはPを実行可能なP'にコンパイルする。Aのすべての入力に対して、P'はPと同じように振る舞うべきだけど、Bの入力に対してはP'の振る舞いに関する要件は全くないんだ。

直感的にはそうだね。プログラムはB入力が渡されないかのようにコンパイルされるし、それにはB入力を検出しようとするコードを排除することも含まれるかもしれない。

そうだね、いいまとめだ。

Cを書いて20年になるけど、Hacker Newsでのこの6ヶ月間ほど未定義動作について聞いたことはないよ。これまで会話に出てこなかったのに。コードを書いて、動かなかったらデバッグして修正や回避策を適用するだけなのに。Cの未定義動作のアイデアがどうしてこんなに一貫してフロントページに上がるの?

生産環境が全く異なるアーキテクチャかもしれないから、こういう詳細はめっちゃ重要だよね。「私のマシンでは動く」は、実際のターゲットがど田舎の携帯電話塔の上にある小さな組み込みシステムだったら役に立たない。確かに、ほとんどの人はそんなことやってないと思うけど、ここにいる開発者の大半はウェブ開発者だろうし、それでも自分が遭遇したことがなくても面白い議論だと思う。むしろ、そういう場合こそ興味深いかも。

コンピュータは昔はクールだったけど、今は危険だね。どの会社も安全性や露出(ニュースに出ること)についてうるさく言ってるから、「危険」への批判がすごいことになってる。新しい世界は、自然を見たことがない都会の人たちの集まりみたいで、芝刈り機を見せるとパニックになる。回転する刃?!?!?! まじで狂ってる!!

カラフルな比喩や驚くような挙動の例を引き出す機会なのかな。あと、常に議論を巻き起こすテーマでもあるしね。

え、何それ?20年前にCとC++を書いてたけど、その頃もUBは会話(やカリキュラム)の大きな部分だったよ。GCC 3.2の周りにはいくつかの有名な「スキャンダル」があった(確か)けど、コンパイラが最適化でUBをもっと積極的に使い始めたから、多くの人が長い間GCC 2.95を使い続けた理由でもあった。GCC 3.2は2002年に出たよ。

UBがなかったら、プログラマーは何をするんだろう?デバッグや修正することが足りなくなっちゃうの?

Hacker Newsは、実際にプログラミングするよりもプログラミング言語に興味がある人向けに偏ってる気がする。多分、Y-combinatorのLispの影響かな。新しいプログラミング言語の開発や使用が世界で最も魅力的だと思ってるCS卒の少数派もいるし、そういう人たちはその考えを持ち続けてるんだろうね。そういう人たちが言語のデザイン面にも興味を持つのは自然なことだし、CのUBもその分野に入る。だけど、元々は古いCPUアーキテクチャに合わせてパフォーマンスをあまり損なわないようにしてた部分が多いと思うし、車輪が丸いのと同じくらい「デザインの選択」って感じだよね…。

Rustの台頭以降、もっと注目されるようになったのかな?でも、昔からCに興味を持ってた人もいたよね。俺も、C標準についてお互いにペダントを競い合うような、どうしようもないIRCチャンネルにいたことがある。君の歴史的なCの使い方は、もっと生産的だったと信じてるよ。

そんなに簡単だったらいいのにね:https://silentsblog.com/2025/04/23/gta-san-andreas-win11-24h... 本当の答えは、Cのような言語の支持者がUBの危険性や修正の難しさを完全に無視しているように見えることだね。Rustの支持者は逆にそれを過大評価してるし。無意味な争いやドラマは読むのが楽しいし、クリックも増えるからね。

天と地には、ホレイショ、君の哲学で夢見られている以上のことがある。君は多分、何年も形が悪いコードを量産してきたんだろうね。今、君は自分の欠点に気づき始めてる。これは通常、中級から上級プログラマーへの移行と見なされるよ。

この記事を書いてる人たちのほとんどは、実際にはC言語の仕様を知らないし、そのデザインも理解してないんだよね。Cで3つの重要な概念をしっかり理解すれば、何が未定義動作(UB)を引き起こすか簡単に見分けられる。具体的には、1) 表現 2) 文 3) シーケンスポイントと「単一更新ルール」。ここで詳しく書いたから、さらなる読み物のリンクもあるよ - https://news.ycombinator.com/item?id=48144734

アラインされていないポインタによって引き起こされる未定義動作の具体例: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...

特にx86では、問題を引き起こさないと仮定されている。

この記事のC++コードは、もう10年以上もイディオムに沿ってないし、今ならコードスメルと見なされるだろうね。言語は最初に作られた時とはかなり違うものに進化した。生のポインタや直接ポインタアクセスを見た瞬間、この記事の一部は鵜呑みにしない方がいいなって思ったよ。もう一つの明らかな問題は、CとC++がまるでほぼ同じ言語のように一緒に扱われていることだけど、実際には今はかなり違うからね。

これがCじゃなくてC++だって指摘しようと思ったけど、確認したら実際にはstd::atomicって書いてあったんだ!atomic_intじゃなくてね。