Skip to content
Published on

Clang 22 の MSVC ABI 変更 — vector deleting destructor と、22.1.x にまだ残る delete[] ヒープ破壊

シェア
Authors

はじめに — リリース翌日に上がったイシュー

LLVM/Clang 22.1.0 は 2026年2月24日にリリースされました。そしてその翌日、2月25日にこんなイシューが上がります — #183255 "[Clang-Cl] Wrong destructing code generated in Clang 22"

再現コードはこれ以上小さくできないほど小さいものです。ヘッダーで仮想デストラクタを持つクラスを宣言し、デストラクタの定義は別の.cppに置き、さらに別の.cppで配列として確保して削除します。

// inc.h
class Class1 {};
class Class2 : public Class1 {
public:
	Class2();
	virtual ~Class2();
};

// impl.cpp — コンストラクタ/デストラクタの定義はここにしかない
Class2::Class2() {}
Class2::~Class2() {}

// main.cpp
int main() {
	Class2* p1 = new Class2;
	delete p1;              // これは問題ない
	Class2* p2 = new Class2[1];
	delete [] p2;           // Debug: RtlValidateHeap assertion / Release: クラッシュ
	return 0;
}

報告者本人の言葉をそのまま移すと、カスタムのコンパイラフラグは一つも使っておらず、Clang 21 や 20 では起きないとのことです。そして後続のコメントで範囲がさらに絞られました — MSVCターゲット(x86・x86_64の両方)でのみ発生し、LinuxもMinGWも影響を受けないということです。

何が起きたかというと、Clang 22 が MSVC ABI をターゲットにするときのデストラクタのコード生成を2箇所変更しました。どちらも「修正」のための変更であり、どちらもABI変更でした。本稿では、その変更が何であるか、なぜ11年もかかったのか、なぜリンクは問題なく通るのに実行時にヒープが壊れるのか、そして今何を確認すべきかを整理します。

MSVC ABI では、デストラクタは1つではない

まず背景から。Itanium ABI(Linux・macOS・MinGW)に馴染みがあれば、デストラクタが complete/base/deleting の3種に分かれることをご存じでしょう。MSVC ABI にも複数の変種がありますが、名前も規約も異なります。Clang の MicrosoftMangle.cpp に、マングリングがコメント付きでそのまま書かれています。

// <operator-name> ::= ?_D # vbase destructor
case Dtor_Complete: Out << "?_D"; return;
// <operator-name> ::= ?_G # scalar deleting destructor
case Dtor_Deleting: Out << "?_G"; return;
// <operator-name> ::= ?_E # vector deleting destructor
case Dtor_VectorDeleting:
  Out << "?_E";
  return;

整理すると、??_D は vbase destructor、??_G は scalar deleting destructor、??_E は vector deleting destructor です。そして重要なのは、deleting デストラクタの2種類は、ASTには現れない隠しパラメータをもう1つ受け取るという点です。同じファイルのコメントがこれを説明しています — この追加引数は、オブジェクトのストレージを解放するかどうかと、単一オブジェクトを破棄するのか配列を破棄するのかを示しており、ASTには反映されないとのことです。64ビットで deleting デストラクタのマングリングが PEAXI@Z で終わる理由がこれです — その I の位置が、ASTにないその整数引数です。同じ位置で vbase destructor は XXZ で終わります。clang内部では、このパラメータは should_call_delete という名前で Context.IntTy 型として作られます。

そのフラグのビットの意味は、MicrosoftCXXABI.cpp の呼び出し箇所にそのまま表れています。

llvm::Value *ImplicitParam =
    CGF.Builder.getInt32((IsDeleting ? 1 : 0) | (IsGlobalDelete ? 4 : 0) |
                         (IsArrayDelete ? 2 : 0));
  • ビット0(値1)— ストレージを解放せよ
  • ビット1(値2)— 配列である
  • ビット2(値4)— グローバルの operator delete を呼べ(つまり ::delete だった)

そのため ::delete は 5(=1|4)、::delete[] は 7(=1|2|4) になります。

なぜこう作られているかというと、MSVCに拡張が一つあるからです。2014年に開かれたイシュー #19772 の説明が的確です — MSVCは配列の静的型と動的型が一致していなくても、多態的なオブジェクトの配列を delete[] できるようにする拡張をサポートしています。つまり A* として受け取ったものが実際には B[] であっても delete[] a が動作します。標準C++ではUBですが、MSVCはそれを動くようにしており、そのためvftableのデストラクタスロットに「配列も処理できる」??_E を入れます。

Clangが11年間入れられなかったもの

問題は、Clangがそのスロットに ??_G(スカラー版)を入れていたことです。イシュー #19772 は2014年4月11日に開かれました。閉じられたのは2025年11月13日 — 11年7ヶ月かかりました。

その間、Clangは MSVC と vftable の内容がずれたまま生きてきました。最終的にこの機能を入れた PR #170337 の説明が状況を要約しています — MSVCの仮想テーブルは、仮想デストラクタを持つクラスに対して常に vector deleting destructor ポインタを格納するため、この拡張を実装しなければ、clang は MSVC が生成したコードと互換性のないコードを出してしまうということです。clang は常に scalar deleting destructor ポインタを vtable に入れるからです。さらにおまけとして、多態的なオブジェクト配列の削除が MSVC とまったく同じように振る舞うようになるとも書かれています — メモリリークなく、正しいデストラクタが呼ばれるように。

なぜ11年かかったのかは、コミット履歴が物語っています。この機能は4回ランドし、3回リバートされました

PR / コミット日付何が起きたか
#1262402025-03-04初回ランド
(リバートコミット)2025-03-12リバート — Chromiumのsanitizer coverageリンクエラー
#1334512025-03-31リランド
#1356112025-04-14リバート — operator delete[] の探索がセキュリティ上の危険
#1655982025-11-13リランド(イシュー #19772 が閉じる)
#1691162025-11-22リバート — Chromiumビルドが壊れる
#1703372025-12-12リランド(最終)
#1725132025-12-17delete[] に渡すサイズを修正

それぞれのリバート理由は読む価値があります。

最初のリバート(2025-03-12)の理由は Chromium でした。 リランドPRである #133451 の説明にそのまま書かれています — sanitizer coverage を有効にした Chromium でリンク時エラーが出たためリバートされ、それは別のPR #131929 で修正されたとのことです。同じ説明はさらに、このリランドの2つ目のコミットには、最初のPRのコメントで報告された Chromium のランタイム失敗に対する修正も含まれていると付け加えています。

2回目のリバート(#135611、2025-04-14)の理由はセキュリティでした。 operator delete[] を見つけることが依然として問題であり、それなしではこの拡張がセキュリティ上の危険(security hazard)になるため、operator delete[] の問題が解決するまでリバートすると書かれています。

3回目のリバート(#169116、2025-11-22)の理由もまた Chromium でした。 実際にマージされたリバートは #169116 で、その本文は問題のコミットとそれに依存する2つのコミットをまとめてリバートすると述べ、#165598 の議論コメントを指しています。同じ日に開かれたもののマージされなかったリバートPR #169063 は、理由をもっと直截に書いています — /Zc:DllexportInlines- を使う Chromium ビルドを壊したためです。

つまり Chromium がこの機能を2回阻止したことになります。3月は sanitizer coverage を有効にしたリンクエラーで、11月は /Zc:DllexportInlines- で。大きな実運用コードベースが、事実上この ABI 変更の統合テストの役割を果たしたわけです。

そして最終リランドの5日後に出た #172513 は、EmitDeleteCall にパラメータが抜けていたため、delete[] に配列全体ではなく要素1つ分のサイズしか渡されていなかったと述べています。これはリランドPRのコメントで見つかりました。

11年7ヶ月かかった理由は「誰もやらなかったから」ではありません。この変更が触れる範囲が広いからです。

Clang 22 が変えた2つのこと

Clang 22 のリリースノート「Potentially Breaking Changes」には、この話が2つの項目に分かれて載っています。

第一に、scalar deleting destructor の整合(PR #139566、2025-09-17マージ)。リリースノートをそのまま移すと、clang は以前 ::delete を Itanium ABI と同じように complete object destructor を呼んでから適切なグローバル delete 演算子を呼ぶ形で実装していましたが、今では scalar deleting destructor がオブジェクトを破棄し、ストレージの解放まで行うということです。

PR作者が語る発見の経緯が面白いです。vector deleting destructor の作業をしている最中に、MSVC がクラスが独自の operator delete を定義しているかどうかで異なるコードを出すことに気づいたというのです。MSVC は ::delete のとき、フラグの3ビット目を立てて渡します(スカラーなら5、ベクターなら7)。一方、以前の clang はフラグとして0を渡し、呼び出し箇所で直接グローバル delete を呼んでいました。問題は MSVC でコンパイルされたバイナリとリンクするときです — MSVC が生成した呼び出し箇所は「デストラクタがグローバル delete を呼ぶはずだ」と想定して自分では呼ばないのに、clang が生成したデストラクタ本体はそれを絶対に呼ばないのです。

リリースノートが挙げる例は明快です。仮想デストラクタとメンバー operator delete を宣言したクラス X があり、デストラクタはライブラリAに、::delete の呼び出しはライブラリBにあるとします。Aをclang 21で、Bをclang 22でビルドすると、Bの ::delete 呼び出しはAにある scalar deleting destructor にディスパッチされ、それは期待されるグローバル delete の代わりにメンバーの operator delete を誤って呼び出します。リリースノートの表現では、MSVC ABI向けにビルドされたプログラムの一部がclang 21以下で、一部がclang 22(またはMSVC)でコンパイルされると、メモリ破壊が起こりうるABI変更だとされています。

第二に、vector deleting destructor のサポート。 リリースノートをそのまま移すと、仮想デストラクタを持つクラスの vtable は、今や scalar deleting destructor の代わりに vector deleting destructor ポインタを格納するようになり、それは名前もリンケージも異なる別個のシンボルだということです。そのため、同じクラスを使う2つのバイナリが互いに異なる clang バージョンでコンパイルされると、ランタイム失敗が起こりうると書かれています。

逃げ道は一つだけです。 どちらも -fclang-abi-compat=21 でオフにできます。なぜ一つのスイッチで両方オフになるのかは、TargetInfo.cpp を見れば明らかです — 2つのフックがまったく同じ条件を見ているからです。

bool TargetInfo::callGlobalDeleteInDeletingDtor(
    const LangOptions &LangOpts) const {
  if (getCXXABI() == TargetCXXABI::Microsoft &&
      LangOpts.getClangABICompat() > LangOptions::ClangABI::Ver21)
    return true;
  return false;
}

bool TargetInfo::emitVectorDeletingDtors(const LangOptions &LangOpts) const {
  if (getCXXABI() == TargetCXXABI::Microsoft &&
      LangOpts.getClangABICompat() > LangOptions::ClangABI::Ver21)
    return true;
  return false;
}

getCXXABI() == TargetCXXABI::Microsoft という条件に注目してください。これらの変更は MSVC ABI ターゲットでのみ有効になります。Itanium ABI を使う Linux・macOS・MinGW は、そもそもこのコードパスに入ってきません。イシュー報告者が実験で絞り込んだ範囲と正確に一致します。

リリース後 — バグ2つ、バックポート1つ

22.1.0 が出てから2日の間に、イシューが2つ上がりました。この2つは別々のバグです。

#183621(2026-02-26 報告)Clang 22 incorrect code for delete[] with MS ABI。原因は devirtualization でした。修正PR #183741 の説明は簡潔です — vector deleting destructor は配列要素を巡るループを実行して delete[] を呼ぶため、その呼び出しを単純に devirtualize すると、メモリリークのある誤ったコードが出てしまうということです。修正は、vector deleting destructor への仮想呼び出しを出す前に devirtualize 可能かを確認し、可能であれば仮想呼び出しの代わりに配列要素を巡る通常のループを出す、というものでした。

これは2026年3月5日にマージされ、バックポートPR #184806release/22.x に入り、22.1.2(2026-03-24)に収録されましたllvmorg-22.1.1llvmorg-22.1.2 を比較すると、該当コミットがそのまま見えます。

#183255(2026-02-25 報告) — 本稿の冒頭のヒープ破壊です。メンテナの診断はこうです。デストラクタが export されていないため、clang は impl.cpp に対して scalar deleting destructor の定義を生成します — その TU には new[] 呼び出しがないからです。ところが main.cppdelete[] は vector deleting destructor の定義を期待しており、それはそこにはありません。続く観察が核心です — MSVC は main.cpp のオブジェクトファイルに vector deleting destructor の定義を生成することでこの状況を切り抜けており、それも new[] が呼ばれた場合にのみそうするのに対し、clang は main.cpp に対してデストラクタの定義をそもそも生成しないということです。

暫定の回避策も同時に示されました — デストラクタの定義をヘッダーに移すか、dllexport で export すればよいとのことです。

修正は PR #185653「Define vector deleting dtor body for declared-only dtor if needed」として2026年3月17日にマージされました。問題の条件はPR説明に正確に書かれています — 現在、vector deleting destructor 本体の生成は、その型に対して new[] が呼ばれ、かつデストラクタまたはクラス全体が dllexport 属性でマークされている場合にトリガーされます。問題は、new[] が呼ばれ、デストラクタが export されておらず、当該 TU には宣言だけがあって定義は別の場所にあるとき、生成されないことです。そのため vector deleting destructor 本体が欠落し、delete[] で実行時失敗が起きます。

そして、これが本稿でもっとも実務的な部分です — この修正は 22.x にバックポートされていません。

イシューに残されたメンテナのコメントが理由を語っています — イシューを修正するPRをちょうどマージしたところだが、22.xにチェリーピックするには十分に小さい変更かどうか確信が持てないので、いったんmainで寝かせておく、とのことです。

実際に確認すると、まさにその通りです。llvmorg-22.1.0 から release/22.x ブランチの末端(2026-06-15)までの180個のコミットのうち、デストラクタ関連のコミットは devirtualization の修正1つだけです。#185653 のコミットは release/23.x の祖先ですが、release/22.x とは枝分かれしています。2026年7月16日現在、最新リリースは 22.1.8(2026-06-16)で、release/23.x はつい昨日(2026-07-15)切られたばかりで、rcタグすらまだありません。

つまり 22.1.0 から 22.1.8 まで全部 が、このパターンでヒープを壊します。直っているのは LLVM 23(おおよそ9月予定)です。

なぜリンクは通るのに実行時に壊れるのか

ここがこのバグの本当に面白いところです。??_E が存在しないなら、リンカが unresolved symbol として捕まえてくれるのが普通ではないでしょうか。ところがリンクは静かに成功し、実行時にヒープが壊れます。なぜでしょうか。

答えは MicrosoftCXXABI.cpp にあります。

if (GD.getDtorType() == Dtor_VectorDeleting &&
    !getContext().classNeedsVectorDeletingDestructor(dtor->getParent())) {
  // Create GlobalDecl object with the correct type for the scalar
  // deleting destructor.
  GlobalDecl ScalarDtorGD(dtor, Dtor_Deleting);

  // Emit an alias from the vector deleting destructor to the scalar deleting
  // destructor.
  CGM.EmitDefinitionAsAlias(GD, ScalarDtorGD);
  return;
}

clang が「このクラスには本当の vector deleting destructor は要らない」と判断すると — つまり、そのTUで new[] を見ておらず dllexport もない場合 — ??_E??_G へのエイリアス(alias)として出力します。最適化としては筋が通っています。配列を使わないのに、ループの入った本体を2つ出す理由がないからです。

ところが、これが正確にリンクが成功する理由です。??_E シンボルは存在します。ただ、その本体がスカラーなのです。

そして、その本体に何が欠けているかは CGClass.cpp が示しています。

if (DtorType == Dtor_Deleting || DtorType == Dtor_VectorDeleting) {
    if (CXXStructorImplicitParamValue && DtorType == Dtor_VectorDeleting)
      EmitConditionalArrayDtorCall(Dtor, *this, CXXStructorImplicitParamValue);

配列分岐を出す EmitConditionalArrayDtorCallDtor_VectorDeleting のときにしか呼ばれません。その関数がやっていることはこうです。

llvm::Value *CheckTheBitForArrayDestroy = CGF.Builder.CreateAnd(
    ShouldDeleteCondition, llvm::ConstantInt::get(CondTy, 2));
llvm::Value *ShouldDestroyArray =
    CGF.Builder.CreateIsNull(CheckTheBitForArrayDestroy);
CGF.Builder.CreateCondBr(ShouldDestroyArray, ScalarBB, VectorBB);

フラグを2とANDして、dtor.scalardtor.vector かに分岐します。先に見た配列ビットです。

これでピースが揃います。

  1. impl.cpp が vtable を出す際、デストラクタスロットに ??_E を入れる。しかしそのTUには new[] がないため、??_E??_G(スカラー本体)へのエイリアスになる。
  2. main.cppdelete[] がそのスロットを通じて仮想呼び出しを行い、フラグに配列ビット(2)を立てて渡す。
  3. たどり着いた本体はスカラー本体である — 配列ビットを検査する分岐自体がコードに存在しない。
  4. そのため、配列クッキーを考慮しないままスカラーとして解放する。ヒープが壊れる。

リンカにはこれを捕まえる術がありません。シンボル名は一致し、シグネチャも一致していて、ただ意味だけが違うのです。型システムの外側にある規約 — 「このシンボルの本体は配列ビットを処理する」— が破られており、それはリンカが検査できる種類のものではありません。Debug ビルドで RtlValidateHeap が先に悲鳴を上げてくれるのが、むしろありがたい理由です。Release ではただクラッシュします。

#185653 の修正は、この判断を先延ばしにします。new[] を見たら、デストラクタが宣言のみであっても vector deleting destructor 本体の生成を強制します。PRの表現では、vector deleting destructor は weak linkage を持つため、そうしても安全だとのことです — 複数のTUがそれぞれ本体を出しても、リンカが1つに畳み込みます。MSVC がもともとやっていたのと同じ挙動です。

では、いま何を確認すべきか

まずは正直に範囲を絞りましょう。

影響なし。 Linux・macOS・MinGW などの Itanium ABI ターゲットは該当しません。コードパスが TargetCXXABI::Microsoft でガードされています。clang-cl や -target *-windows-msvc を使っていないなら、本稿は教養として読めば十分です。

確認が必要。 MSVC ABI をターゲットとする clang(clang-cl を含む)で 22.x を使っている、あるいは使おうとしている場合:

  • clang 21 以下でビルドされたオブジェクト/ライブラリと混ぜていないか。 仮想デストラクタを持つクラスがその境界をまたいでいるなら、リリースノートが言うメモリ破壊シナリオにそのまま該当します。全部再ビルドするか、それができないなら -fclang-abi-compat=21 で22側を旧動作に固定する必要があります。
  • 仮想デストラクタを持つクラスを new[]/delete[] しているが、そのデストラクタの定義が別のTUにあり dllexport でもないか。 これが #183255 のパターンで、22.1.8 まで生きています。回避策はデストラクタの定義をヘッダーに移すか dllexport することです。
  • 22.1.0 や 22.1.1 を使っているなら、最低でも 22.1.2 に上げてください。 devirtualization バグ(#183621)の修正がそこに入っています。

-fclang-abi-compat=21 について正直に言うと。 これは取り消しボタンであって、解決策ではありません。このフラグをオンにすると clang 21 以下とは互換になりますが、MSVC とは再びずれます — そもそもこの変更が直そうとしていたそのずれに戻るのです。MSVC でビルドされたライブラリとリンクする計画があるなら、それは解にはなりません。全部を clang でビルドする閉じた世界の中で、移行を先延ばしにする用途にしか使えません。

この変更自体は正しいです。 本稿がバグの話ばかりで埋まっているので誤解を招くかもしれないと思い付け加えると、Clang 22 の方向性は正しいです。以前の clang は MSVC が作った vftable と内容がずれたコードを出しており、多態的なオブジェクト配列の delete[] は静かにリークしたり、間違ったデストラクタを呼んだりしていました。11年来の非互換を直すにはABI変更が必要であり、ABI変更にはもともとこの種の代償が伴います。この話の教訓は「clang 22を使うな」ではなく、ABI境界を越える変更は、コンパイラバージョンを混ぜた瞬間、リンカが助けてくれない場所で失敗するという方です。

おわりに

まとめるとこうです。Clang 22.1.0(2026-02-24)は、MSVC ABI ターゲットのデストラクタコード生成を2箇所変更しました — ::delete の処理をデストラクタ本体の中で行うよう MSVC に合わせ、2014年に開かれたイシューを11年7ヶ月かけて閉じつつ vector deleting destructor を入れました。vftable のデストラクタスロットのシンボルが ??_G から ??_E に変わるABI変更であり、逃げ道は -fclang-abi-compat=21 の一つだけです。

そしてリリースの翌日から2日の間に、バグが2つ上がりました。devirtualization バグは 22.1.2 にバックポートされました。しかし ??_E??_G のエイリアスとして出力され、配列ビットを処理する分岐なしにヒープを壊す方は、2026年7月16日現在、22.1.8になっても直っていません — LLVM 23 を待つか、デストラクタをヘッダーに移すか、dllexport するしかありません。

この機能が4回ランドし3回リバートされた理由 — Chromium を2回(sanitizer coverage のリンクエラー、そして /Zc:DllexportInlines-)壊してリバートし、operator delete[] の探索がセキュリティ上の危険だとしてリバートし、最終リランドの5日後に配列サイズを誤って渡していたのを直した — を見れば、11年かかったのが怠慢ではなかったことがはっきりします。コンパイラのABI表面はリリースノート1行に要約されますが、その1行がカバーする失敗モードは、リリースが出てユーザーが自分のコードベースにぶつかってみて初めて明らかになります。今回は1日かかりました。

参考資料