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 日,就有人提交了这样一个 issue — #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 为目标时,在两处改动了析构函数的代码生成。这两处都是「修复性」的改动,也都是 ABI 改动。本文梳理这些改动到底是什么、为什么花了 11 年、为什么链接明明能顺利通过运行时堆却会崩溃,以及现在应该检查什么。

在 MSVC ABI 中,析构函数不止一种

先说背景。如果你熟悉 Itanium ABI(Linux、macOS、MinGW),会知道析构函数分为 complete/base/deleting 三种变体。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 析构函数都会多接收一个 AST 里没有的隐藏参数。同一份文件里的注释解释了这一点 — 这个额外参数表示是否要释放对象的存储空间,以及这是在销毁单个对象还是对象数组,并且不会反映在 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 年提出的 issue #19772 的描述很准确 — MSVC 支持一个扩展,允许在数组的静态类型和动态类型不一致的情况下,对多态对象数组做 delete[]。也就是说,即便以 A* 接收到的东西实际上是 B[],delete[] a 也能正常工作。在标准 C++ 里这是 UB,但 MSVC 让它能正常运作,为此它在 vftable 的析构函数槽位里放了「也知道怎么处理数组」的 ??_E

Clang 11 年都没能落地的东西

问题在于,Clang 在那个槽位里放的是 ??_G(标量版)。issue #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 年,提交历史给出了答案。这个功能落地了四次,又被回退了三次

PR / 提交日期发生了什么
#1262402025-03-04首次落地
(回退提交)2025-03-12回退 — Chromium 的 sanitizer coverage 链接错误
#1334512025-03-31重新落地
#1356112025-04-14回退 — operator delete[] 查找存在安全隐患
#1655982025-11-13重新落地(issue #19772 关闭)
#1691162025-11-22回退 — 弄坏了 Chromium 构建
#1703372025-12-12重新落地(最终)
#1725132025-12-17修复传给 delete[] 的大小

每一次回退的理由都值得细看。

第一次回退(2025-03-12)的原因是 Chromium。 重新落地的 PR #133451 的说明里原样写着 — 因为开启了 sanitizer coverage 的 Chromium 出现了链接期错误才被回退,而这个问题在另一个 PR #131929 里单独修好了。同一份说明还补充说,这次重新落地的第二个提交,还包含了对最初那个 PR 评论区里报告的 Chromium 运行时失败的修复。

第二次回退(#135611,2025-04-14)的原因是安全问题。 说明里写道,查找 operator delete[] 仍然是个未解决的问题,而如果不解决它,这个扩展就是一个安全隐患(security hazard),所以要回退,直到 operator delete[] 的问题被解决为止。

第三次回退(#169116,2025-11-22)的原因,同样是 Chromium。 实际被合并的回退是 #169116,它的正文说明会连带回退问题提交以及依赖它的另外两个提交,并指向了 #165598 讨论区的评论。同一天提出但没有被合并的回退 PR #169063 把理由写得更直白 — 因为它弄坏了使用 /Zc:DllexportInlines- 的 Chromium 构建。

也就是说,Chromium 两次挡住了这个功能。3 月是因为开启 sanitizer coverage 后的链接错误,11 月是因为 /Zc:DllexportInlines-。一个庞大的真实代码库,事实上扮演了这次 ABI 改动集成测试的角色。

而在最终重新落地五天之后出现的 #172513 写道,EmitDeleteCall 缺了一个参数,导致传给 delete[] 的不是整个数组的大小,而只是单个元素的大小。这是在重新落地那个 PR 的评论区里被发现的。

花了 11 年 7 个月的原因,并不是「没人做」。而是因为这个改动触及的范围很广。

Clang 22 改动的两件事

在 Clang 22 的发布说明 "Potentially Breaking Changes"里,这件事被分成了两条。

第一,对齐 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 生成的析构函数体却从来不会调用它。

发布说明举的例子很清楚。假设有一个类 X,声明了虚析构函数和成员 operator delete,析构函数在库 A 里,::delete 调用在库 B 里。如果 A 用 clang 21 构建,B 用 clang 22 构建,B 里的 ::delete 调用就会分派到 A 里的 scalar deleting destructor,而它错误地调用了成员 operator delete,而不是本该调用的全局 delete。用发布说明的话说,这是一处 ABI 改动 — 如果一个为 MSVC ABI 构建的程序,一部分用 clang 21 或更早版本编译,另一部分用 clang 22(或 MSVC)编译,就可能发生内存损坏。

第二,支持 vector deleting destructor。 照搬发布说明的话,带虚析构函数的类的 vtable,现在存放的不再是 scalar deleting destructor 指针,而是 vector deleting destructor 指针,而这是一个名字和链接方式都不同的独立符号。所以说明里写道,如果使用同一个类的两个二进制文件用不同的 clang 版本编译,就可能出现运行时失败。

逃生舱口只有一个。 两处都可以用 -fclang-abi-compat=21 关掉。为什么一个开关能同时关掉两处,看 TargetInfo.cpp 就很清楚 — 两个钩子检查的是完全相同的条件。

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 根本不会进入这条代码路径。这与 issue 报告者通过实验缩小出来的范围完全吻合。

发布之后 — 两个 bug,一次回移植

22.1.0 发布之后的两天里,冒出来两个 issue。这是两个各自独立的 bug。

#183621(2026-02-26 报告)Clang 22 incorrect code for delete[] with MS ABI。原因是去虚化(devirtualization)。修复 PR #183741 的说明很简洁 — 因为 vector deleting destructor 会执行一个遍历数组元素的循环并调用 delete[],如果简单地对这个调用做去虚化,就会产生带内存泄漏的错误代码。修复的做法是,在生成对 vector deleting destructor 的虚调用之前先检查是否可以去虚化,如果可以,就生成一个遍历数组元素的普通循环,而不是虚调用。

这个修复在 2026 年 3 月 5 日合并,并通过回移植 PR #184806 进入了 release/22.x,收录进了 22.1.2(2026-03-24)。对比 llvmorg-22.1.1llvmorg-22.1.2,可以原样看到这个提交。

#183255(2026-02-25 报告) — 就是本文开头那个堆损坏。维护者的诊断是这样的。因为析构函数没有被导出,所以 clang 会为 impl.cpp 生成一个 scalar deleting destructor 的定义 — 因为那个 TU 里没有 new[] 调用。可是 main.cpp 里的 delete[] 期望的是一个 vector deleting destructor 的定义,而那里并没有。接下来的观察才是关键 — MSVC 是靠在 main.cpp 的目标文件里生成 vector deleting destructor 定义来绕过这种情况的,而且也只在调用了 new[] 的时候才这样做;而 clang 对 main.cpp 根本就不生成任何析构函数定义。

同时也给出了一个临时绕过办法 — 把析构函数定义挪到头文件里,或者用 dllexport 导出它即可。

修复方案 PR #185653"Define vector deleting dtor body for declared-only dtor if needed",在 2026 年 3 月 17 日合并。有问题的条件在 PR 说明里写得很准确 — 目前,vector deleting destructor 函数体的生成,是在对该类型调用了 new[],并且析构函数或整个类被标记了 dllexport 属性时才会触发。问题在于:当 new[] 被调用、析构函数没有被导出、而所在的 TU 里只有声明、定义在别处时,函数体不会被生成。于是 vector deleting destructor 的函数体就缺失了,delete[] 在运行时就会失败。

而这正是本文中最具实务意义的一点 — 这个修复并没有回移植到 22.x。

issue 里维护者留下的评论说明了原因 — 他们刚合并了修复这个 issue 的 PR,但还不确定这是不是一个足够小、可以直接 cherry-pick 到 22.x 的改动,所以打算先让它在 main 分支上放一放。

亲自去核实一下,情况确实如此。从 llvmorg-22.1.0release/22.x 分支末端(2026-06-15)之间的 180 个提交里,和析构函数相关的提交只有那一个去虚化修复。#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 月)里才有。

为什么链接能过,运行时却会崩

这就是这个 bug 真正有意思的地方了。如果 ??_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) 输出。作为一种优化,这说得通:既然不用数组,就没有理由为一个带循环的函数体输出两份。

但这恰恰就是链接能够成功的原因。??_E 这个符号是存在的。只是它的函数体是标量版的。

而那个函数体里缺了什么,CGClass.cpp 展示得很清楚。

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

生成数组分支的 EmitConditionalArrayDtorCall,只会在 Dtor_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 做与运算,再据此分支到 dtor.scalar 还是 dtor.vector。这正是前面看到的那个数组位。

现在所有碎片都能拼起来了。

  1. impl.cpp 输出 vtable 时,会在析构函数槽位里放上 ??_E。但因为那个 TU 里没有 new[],所以 ??_E 是指向 ??_G(标量函数体)的别名。
  2. main.cpp 里的 delete[] 通过那个槽位发起虚调用,并在标志位里把数组位(2)置位后传过去。
  3. 最终到达的函数体是标量版函数体 — 检查数组位的那条分支根本就不在代码里。
  4. 于是就在没有考虑数组 cookie 的情况下按标量方式释放了内存。堆就此崩溃。

链接器没办法抓住这个问题。符号名对得上,签名也对得上,只是语义不同。这是一份存在于类型系统之外的约定 — 「这个符号的函数体会处理数组位」— 被打破了,而这不是链接器能够检查的那种东西。这也是为什么 Debug 构建里 RtlValidateHeap 先尖叫一声,反倒算是幸运的原因。Release 构建下,它就直接崩溃了。

#185653 的修复把这个判断推迟了。只要看到 new[],即便析构函数只有声明,也会强制生成 vector deleting destructor 的函数体。用 PR 的话说,这样做是安全的,因为 vector deleting destructor 具有 weak linkage — 就算多个 TU 各自输出一份函数体,链接器也会把它们折叠成一份。这和 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。 去虚化 bug(#183621)的修复就在里面。

关于 -fclang-abi-compat=21 的老实话。 这是一个撤销按钮,不是解决方案。打开这个标志之后,你和 clang 21 及更早版本是兼容了,但又和 MSVC 对不上了 — 回到了这次改动本来想修复的那种不一致。如果你有计划要和 MSVC 构建出来的库链接,这就不是答案。它只适合在一个全部用 clang 构建的封闭世界里,用来暂时推迟迁移。

这次改动本身是对的。 由于本文里全是 bug 的故事,担心造成误解,补充一句:Clang 22 的方向是对的。此前的 clang 生成的代码和 MSVC 产生的 vftable 内容不一致,对多态对象数组做 delete[] 会悄悄地泄漏内存,或者调用错误的析构函数。要修复这个存在了 11 年的不兼容,就需要一次 ABI 改动,而 ABI 改动本来就要付出这种代价。这个故事的教训不是「不要用 clang 22」,而是跨越 ABI 边界的改动,一旦混用编译器版本,就会在链接器帮不上忙的地方失败

结语

总结一下:Clang 22.1.0(2026-02-24)在 MSVC ABI 目标上的析构函数代码生成里改了两处 — 把 ::delete 的处理挪到析构函数体内部,与 MSVC 对齐;并且加入了 vector deleting destructor,让一个 2014 年开的 issue,花了 11 年 7 个月才关闭。这是一次 ABI 改动,把 vftable 析构函数槽位里的符号从 ??_G 换成了 ??_E,唯一的逃生舱口是 -fclang-abi-compat=21

而在发布次日起的两天里,冒出来两个 bug。去虚化那个 bug 已经回移植进了 22.1.2。但 ??_E 作为 ??_G 的别名输出、缺少处理数组位的分支从而弄崩堆的那一个,截至 2026 年 7 月 16 日,即便到了 22.1.8 也还没修复 — 要么等 LLVM 23,要么把析构函数挪到头文件里,要么给它加上 dllexport

看看这个功能为什么落地四次、回退三次 — 两次因为弄坏 Chromium 而回退(一次是 sanitizer coverage 的链接错误,另一次是 /Zc:DllexportInlines-),一次因为查找 operator delete[] 存在安全隐患而回退,又在最终重新落地五天后修复了传错的数组大小 — 就能看清楚,这 11 年不是因为偷懒。编译器的 ABI 表面可以被发布说明里的一行字概括,但那一行字所覆盖的失败模式,只有等版本发布、用户真的一头撞上自己的代码库之后才会显现出来。这一次,只花了一天。

参考资料