Split View: Clang 22의 MSVC ABI 변경 — vector deleting destructor와 아직 22.1.x에 남아 있는 delete[] 힙 손상
Clang 22의 MSVC ABI 변경 — vector deleting destructor와 아직 22.1.x에 남아 있는 delete[] 힙 손상
- 들어가며 — 릴리스 다음 날 올라온 이슈
- MSVC ABI에서 소멸자는 하나가 아니다
- Clang이 11년 동안 못 넣은 것
- Clang 22가 바꾼 두 가지
- 릴리스 이후 — 두 개의 버그, 하나의 백포트
- 왜 링크는 되는데 런타임에 깨지나
- 그래서 지금 무엇을 확인해야 하나
- 마치며
- 참고 자료
들어가며 — 릴리스 다음 날 올라온 이슈
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를 타깃할 때의 소멸자 코드 생성을 두 군데 바꿨습니다. 둘 다 "고치는" 변경이었고, 둘 다 ABI 변경이었습니다. 이 글은 그 변경이 무엇이고, 왜 11년이 걸렸고, 왜 링크는 멀쩡히 되는데 런타임에 힙이 깨지는지, 그리고 지금 무엇을 확인해야 하는지를 정리합니다.
MSVC ABI에서 소멸자는 하나가 아니다
먼저 배경입니다. Itanium ABI(리눅스·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년에 열린 이슈 #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년이 걸렸는지는 커밋 히스토리가 말해 줍니다. 이 기능은 네 번 랜딩되고 세 번 되돌려졌습니다.
| PR / 커밋 | 날짜 | 무슨 일 |
|---|---|---|
| #126240 | 2025-03-04 | 최초 랜딩 |
| (되돌림 커밋) | 2025-03-12 | 되돌림 — Chromium의 sanitizer coverage 링크 에러 |
| #133451 | 2025-03-31 | 리랜드 |
| #135611 | 2025-04-14 | 되돌림 — operator delete[] 탐색이 보안 위험 |
| #165598 | 2025-11-13 | 리랜드 (이슈 #19772 닫힘) |
| #169116 | 2025-11-22 | 되돌림 — Chromium 빌드 깨짐 |
| #170337 | 2025-12-12 | 리랜드 (최종) |
| #172513 | 2025-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이 생성한 소멸자 본문은 절대 그걸 부르지 않습니다.
릴리스 노트가 드는 예가 명확합니다. 가상 소멸자와 멤버 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 포인터를 담게 되고, 그것은 이름도 링키지도 다른 별개의 심볼이라는 것입니다. 그래서 같은 클래스를 쓰는 두 바이너리가 서로 다른 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를 쓰는 리눅스·macOS·MinGW는 코드 경로 자체에 들어오지 않습니다. 이슈 보고자가 실험으로 좁힌 범위와 정확히 일치합니다.
릴리스 이후 — 두 개의 버그, 하나의 백포트
22.1.0이 나가고 이틀 사이에 이슈가 두 개 올라왔습니다. 둘은 별개의 버그입니다.
#183621 (2026-02-26 보고) — Clang 22 incorrect code for delete[] with MS ABI. 원인은 디버추얼라이제이션이었습니다. 수정 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.1과 llvmorg-22.1.2 사이를 비교하면 해당 커밋이 그대로 보입니다.
#183255 (2026-02-25 보고) — 이 글 맨 앞의 힙 손상입니다. 메인테이너의 진단은 이렇습니다. 소멸자가 export되지 않았기 때문에 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로 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개 커밋 중 소멸자 관련 커밋은 디버추얼라이제이션 수정 하나뿐입니다. #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)으로 내보냅니다. 최적화로는 말이 됩니다. 배열을 안 쓰는데 루프가 든 본문을 두 벌 낼 이유가 없으니까요.
그런데 이게 정확히 링크가 성공하는 이유입니다. ??_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와 AND해서 dtor.scalar냐 dtor.vector냐로 분기합니다. 앞에서 본 배열 비트입니다.
이제 조각이 맞춰집니다.
impl.cpp가 vftable을 내면서 소멸자 슬롯에??_E를 넣는다. 그런데 그 TU에는new[]가 없으므로??_E는??_G(스칼라 본문)로 가는 별칭이다.main.cpp의delete[]가 그 슬롯을 통해 가상 호출을 하면서 플래그에 배열 비트(2)를 세워 넘긴다.- 도착한 본문은 스칼라 본문이다 — 배열 비트를 검사하는 분기 자체가 코드에 없다.
- 그래서 배열 쿠키를 고려하지 않은 채 스칼라로 해제한다. 힙이 깨진다.
링커가 잡아 줄 방법이 없습니다. 심볼 이름은 맞고, 시그니처도 맞고, 다만 의미가 다릅니다. 타입 시스템 바깥에 있는 규약 — "이 심볼의 본문은 배열 비트를 처리한다" — 이 깨진 것이고, 그건 링커가 검사할 수 있는 종류의 것이 아닙니다. Debug 빌드에서 RtlValidateHeap이 먼저 비명을 지르는 게 차라리 다행인 이유입니다. Release에서는 그냥 크래시합니다.
#185653의 수정은 이 판단을 미룹니다. new[]를 보면 소멸자가 선언만 돼 있어도 vector deleting destructor 본문 생성을 강제합니다. PR의 표현으로는, vector deleting destructor가 weak linkage를 갖기 때문에 그렇게 해도 안전하다는 것입니다 — 여러 TU가 각자 본문을 내도 링커가 하나로 접습니다. MSVC가 원래 하던 것과 같은 동작입니다.
그래서 지금 무엇을 확인해야 하나
정직하게 범위부터 좁히겠습니다.
영향 없음. 리눅스·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로 올리세요. 디버추얼라이제이션 버그(#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 타깃의 소멸자 코드 생성을 두 군데 바꿨습니다 — ::delete를 소멸자 본문 안에서 처리하도록 MSVC와 맞췄고, 2014년에 열린 이슈를 11년 7개월 만에 닫으며 vector deleting destructor를 넣었습니다. vftable 소멸자 슬롯의 심볼이 ??_G에서 ??_E로 바뀌는 ABI 변경이고, 탈출구는 -fclang-abi-compat=21 하나입니다.
그리고 릴리스 다음 날부터 이틀 사이에 버그가 두 개 올라왔습니다. 디버추얼라이제이션 버그는 22.1.2에 백포트됐습니다. 하지만 ??_E가 ??_G의 별칭으로 나가면서 배열 비트를 처리하는 분기 없이 힙을 깨뜨리는 쪽은, 2026년 7월 16일 현재 22.1.8까지도 고쳐지지 않았습니다 — LLVM 23을 기다리거나, 소멸자를 헤더로 옮기거나 dllexport해야 합니다.
이 기능이 네 번 랜딩되고 세 번 되돌려진 이유 — Chromium을 두 번(sanitizer coverage 링크 에러, 그리고 /Zc:DllexportInlines-) 깨서 되돌리고, operator delete[] 탐색이 보안 위험이라 되돌리고, 최종 리랜드 닷새 뒤에 배열 크기를 잘못 넘기던 걸 고치고 — 를 보면, 11년이 걸린 게 게으름이 아니었다는 게 분명해집니다. 컴파일러의 ABI 표면은 릴리스 노트 한 줄로 요약되지만, 그 한 줄이 커버하는 실패 모드는 릴리스가 나가고 사용자가 자기 코드베이스를 들이받아 봐야 드러납니다. 이번엔 하루 걸렸습니다.
참고 자료
- Clang 22 릴리스 노트 — Potentially Breaking Changes (release/22.x 원본)
- Issue #19772 — Implement vector deleting destructors (2014-04-11 개설)
- Issue #183255 — [Clang-Cl] Wrong destructing code generated in Clang 22
- Issue #183621 — [clang] Clang 22 incorrect code for
delete[]with MS ABI - PR #139566 — [win][clang] Align scalar deleting destructors with MSABI
- PR #170337 — Reland [MS][clang] Add support for vector deleting destructors
- PR #133451 — 리랜드: 첫 되돌림이 Chromium sanitizer coverage 링크 에러였다는 설명
- PR #135611 — 되돌림: operator delete[] 탐색이 보안 위험
- PR #169116 — 실제로 머지된 세 번째 되돌림
- PR #169063 — 머지되지 않은 되돌림 PR: Chromium /Zc:DllexportInlines- 빌드 깨짐 기록
- PR #172513 — delete[]에 넘기는 크기 수정
- PR #183741 — 디버추얼라이제이션 수정 (22.1.2에 백포트)
- PR #185653 — 선언만 있는 소멸자에 대한 vector deleting dtor 본문 생성 (22.x 미백포트)
- MicrosoftCXXABI.cpp (release/22.x) — 플래그 비트와 별칭 생성
- CGClass.cpp (release/22.x) — EmitConditionalArrayDtorCall
- TargetInfo.cpp (release/22.x) — abi-compat 게이트
- LLVM 릴리스 목록 (22.1.0 = 2026-02-24, 22.1.8 = 2026-06-16)
Clang 22's MSVC ABI Change — the Vector Deleting Destructor and the delete[] Heap Corruption Still Alive in 22.1.x
- Introduction — An Issue Filed the Day After Release
- In the MSVC ABI, There Isn't Just One Destructor
- What Clang Couldn't Land for 11 Years
- The Two Things Clang 22 Changed
- After the Release — Two Bugs, One Backport
- Why It Links Fine but Breaks the Heap at Runtime
- So, What Should You Check Right Now
- Closing
- References
Introduction — An Issue Filed the Day After Release
LLVM/Clang 22.1.0 was released on February 24, 2026. The very next day, February 25, this issue was filed — #183255 "[Clang-Cl] Wrong destructing code generated in Clang 22".
The repro code could not be smaller. A class with a virtual destructor is declared in a header, the destructor is defined in a separate .cpp, and a different .cpp allocates it as an array and deletes it.
// inc.h
class Class1 {};
class Class2 : public Class1 {
public:
Class2();
virtual ~Class2();
};
// impl.cpp — the constructor/destructor definitions live only here
Class2::Class2() {}
Class2::~Class2() {}
// main.cpp
int main() {
Class2* p1 = new Class2;
delete p1; // this one is fine
Class2* p2 = new Class2[1];
delete [] p2; // Debug: RtlValidateHeap assertion / Release: crash
return 0;
}
In the reporter's own words, no custom compiler flags were used, and this does not happen on Clang 21 or 20. A follow-up comment narrowed the scope further — it only happens when targeting MSVC (both x86 and x86_64), and neither Linux nor MinGW is affected.
What happened is that Clang 22 changed destructor codegen in two places when targeting the MSVC ABI. Both were "fixing" changes, and both were ABI changes. This post lays out what those changes are, why they took 11 years, why the link succeeds cleanly but the heap breaks at runtime, and what you need to check right now.
In the MSVC ABI, There Isn't Just One Destructor
First, some background. If you're familiar with the Itanium ABI (Linux, macOS, MinGW), you'll know destructors come in three variants — complete/base/deleting. The MSVC ABI also has multiple variants, but with different names and conventions. Clang's MicrosoftMangle.cpp spells out the mangling verbatim, comments included.
// <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;
To sum up: ??_D is the vbase destructor, ??_G is the scalar deleting destructor, and ??_E is the vector deleting destructor. What matters is that the two deleting-destructor variants take one additional hidden parameter that isn't present in the AST. A comment in the same file explains it — this extra argument indicates whether to free the object's storage and whether it is destroying a single object or an array of objects, and it isn't reflected in the AST. That's why, on 64-bit, the mangling of a deleting destructor ends in PEAXI@Z — that I is the integer argument that isn't in the AST. In the same position, the vbase destructor ends in XXZ. Inside clang, this parameter is materialized as an argument named should_call_delete of type Context.IntTy.
The bit semantics of that flag are laid bare at the call site in MicrosoftCXXABI.cpp.
llvm::Value *ImplicitParam =
CGF.Builder.getInt32((IsDeleting ? 1 : 0) | (IsGlobalDelete ? 4 : 0) |
(IsArrayDelete ? 2 : 0));
- Bit 0 (value 1) — free the storage
- Bit 1 (value 2) — it's an array
- Bit 2 (value 4) — call the global
operator delete(i.e., it was::delete)
So ::delete becomes 5 (=1|4), and ::delete[] becomes 7 (=1|2|4).
Why it's built this way is because MSVC has an extension. Issue #19772, opened in 2014, describes it precisely — MSVC supports an extension that lets you delete[] an array of polymorphic objects even when the array's static type and dynamic type don't match. In other words, delete[] a works even when what you received as A* is actually a B[]. In standard C++ this is UB, but MSVC lets it work, and to do so it puts ??_E — which "knows how to handle arrays too" — into the destructor slot of the vftable.
What Clang Couldn't Land for 11 Years
The problem is that Clang put ??_G (the scalar one) in that slot. Issue #19772 was opened on April 11, 2014. It was closed on November 13, 2025 — it took 11 years and 7 months.
In the meantime, Clang lived with vftable contents that diverged from MSVC's. The description of PR #170337, which finally landed this feature, summarizes the situation — because MSVC's virtual table always carries a vector deleting destructor pointer for classes with a virtual destructor, not implementing this extension means clang emits code that is incompatible with code MSVC produces. That's because clang always puts a scalar deleting destructor pointer in the vtable. It adds, as a bonus, that deleting an array of polymorphic objects now behaves identically to MSVC — without memory leaks, and with the correct destructor called.
Why it took 11 years is a story the commit history tells. This feature landed four times and got reverted three times.
| PR / Commit | Date | What happened |
|---|---|---|
| #126240 | 2025-03-04 | Initial landing |
| (revert commit) | 2025-03-12 | Reverted — Chromium sanitizer coverage link error |
| #133451 | 2025-03-31 | Reland |
| #135611 | 2025-04-14 | Reverted — operator delete[] lookup was a security hazard |
| #165598 | 2025-11-13 | Reland (issue #19772 closed) |
| #169116 | 2025-11-22 | Reverted — broke Chromium build |
| #170337 | 2025-12-12 | Reland (final) |
| #172513 | 2025-12-17 | Fixed the size passed to delete[] |
Each revert's rationale is worth reading.
The first revert (2025-03-12) happened because of Chromium. It's spelled out plainly in the description of the reland PR, #133451 — it was reverted because Chromium, built with sanitizer coverage enabled, hit a link-time error, and that was fixed separately in PR #131929. The same description adds that the second commit of this reland also contains a fix for a Chromium runtime failure reported in the original PR's comments.
The second revert (#135611, 2025-04-14) happened for security reasons. It states that finding operator delete[] was still an unsolved problem, and without it this extension was a security hazard, so it was reverted until the operator delete[] problem got resolved.
The third revert (#169116, 2025-11-22) was, once again, because of Chromium. The revert that actually got merged is #169116, and its body points to a discussion comment on #165598, saying it reverts the offending commit along with two commits that depend on it. A same-day revert PR that never got merged, #169063, states the reason more bluntly — it broke Chromium builds that use /Zc:DllexportInlines-.
So Chromium blocked this feature twice. In March, with a link error from sanitizer coverage; in November, with /Zc:DllexportInlines-. A large real-world codebase effectively served as the integration test for this ABI change.
And #172513, which came five days after the final reland, states that EmitDeleteCall was missing a parameter, so delete[] was being passed the size of a single element rather than the whole array. This was found in a comment on the reland PR.
The reason it took 11 years and 7 months is not "nobody worked on it." It's because the surface this change touches is wide.
The Two Things Clang 22 Changed
In Clang 22's release notes, "Potentially Breaking Changes", this story is split into two entries.
First, aligning the scalar deleting destructor (PR #139566, merged 2025-09-17). To carry over the release notes: clang previously implemented ::delete the way the Itanium ABI does — calling the complete object destructor and then calling the appropriate global delete operator — but now the scalar deleting destructor destroys the object and frees its storage as well.
How the PR author describes discovering this is entertaining. While working on the vector deleting destructor, they noticed that MSVC emits different code depending on whether the class defines its own operator delete. MSVC sets the 3rd bit of the flag when it's ::delete (5 for scalar, 7 for vector). Previous clang, by contrast, passed 0 as the flag and called the global delete directly at the call site. The problem shows up when linking against MSVC-compiled binaries — an MSVC-generated call site assumes "the destructor will call the global delete" and doesn't call it itself, but clang-generated destructor bodies never call it.
The release notes' example is clear. Say there's a class X that declares a virtual destructor and a member operator delete, with the destructor in library A and the ::delete call in library B. If A is built with clang 21 and B with clang 22, B's ::delete call dispatches to the scalar deleting destructor in A, and that incorrectly calls the member operator delete instead of the expected global delete. In the release notes' words, this is an ABI change that can cause memory corruption if part of a program built for the MSVC ABI is compiled with clang 21 or earlier and part with clang 22 (or MSVC).
Second, vector deleting destructor support. To carry over the release notes: the vtable of a class with a virtual destructor now holds a vector deleting destructor pointer instead of a scalar deleting destructor one, and that is a distinct symbol with a different name and different linkage. So it states that runtime failures can occur if two binaries that use the same class are compiled with different clang versions.
There's exactly one escape hatch. Both can be turned off with -fclang-abi-compat=21. Why one switch turns off both becomes clear from TargetInfo.cpp — the two hooks check exactly the same condition.
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;
}
Notice the getCXXABI() == TargetCXXABI::Microsoft condition. These changes only turn on when targeting the MSVC ABI. Linux, macOS, and MinGW, which use the Itanium ABI, never enter this code path at all. This matches exactly the scope the issue reporter narrowed down experimentally.
After the Release — Two Bugs, One Backport
Within two days of 22.1.0 going out, two issues were filed. They are two separate bugs.
#183621 (reported 2026-02-26) — Clang 22 incorrect code for delete[] with MS ABI. The cause was devirtualization. The description of the fix, PR #183741, is concise — because the vector deleting destructor runs a loop over array elements and calls delete[], simply devirtualizing that call produces incorrect code that leaks memory. The fix was to check whether the call is devirtualizable before emitting a virtual call to the vector deleting destructor, and, if it is, emit a normal loop over array elements instead of a virtual call.
This was merged on March 5, 2026, and landed in release/22.x via backport PR #184806, shipping in 22.1.2 (2026-03-24). Diffing between llvmorg-22.1.1 and llvmorg-22.1.2 shows the commit right there.
#183255 (reported 2026-02-25) — the heap corruption from the top of this post. The maintainer's diagnosis goes like this. Because the destructor isn't exported, clang generates a scalar deleting destructor definition for impl.cpp — since that TU has no new[] call. But the delete[] in main.cpp expects a vector deleting destructor definition, and there isn't one there. The key observation that follows is that MSVC gets past this situation by generating a vector deleting destructor definition in main.cpp's object file — and only when new[] is called there — while clang generates no destructor definition at all for main.cpp.
A temporary workaround was offered alongside it — move the destructor definition into the header, or export it with dllexport.
The fix, PR #185653, "Define vector deleting dtor body for declared-only dtor if needed," was merged on March 17, 2026. The problematic condition is spelled out precisely in the PR description — currently, vector deleting destructor body generation is triggered when new[] is called for that type and the destructor or the whole class is marked with the dllexport attribute. The problem is that it is not generated when new[] is called, the destructor isn't exported, and the TU in question has only a declaration while the definition lives elsewhere. So the vector deleting destructor body ends up missing, and delete[] fails at runtime.
And this is the most practically important part of this post — this fix has not been backported to 22.x.
A maintainer comment left on the issue gives the reason — they had just merged the PR that fixes the issue, but weren't confident it was a small enough change to cherry-pick into 22.x, so they'd let it sit on main for a while first.
Checking it directly confirms exactly that. Of the 180 commits from llvmorg-22.1.0 to the tip of the release/22.x branch (2026-06-15), only one — the devirtualization fix — is destructor-related. The commit for #185653 is an ancestor of release/23.x, but it has diverged from release/22.x. As of July 16, 2026, the latest release is 22.1.8 (2026-06-16), and release/23.x was cut just yesterday (2026-07-15) and doesn't even have an rc tag yet.
In other words, every version from 22.1.0 through 22.1.8 breaks the heap in this pattern. It's fixed only in LLVM 23 (expected roughly in September).
Why It Links Fine but Breaks the Heap at Runtime
This is where the bug gets genuinely interesting. If ??_E doesn't exist, shouldn't the linker normally catch it as an unresolved symbol? And yet the link succeeds quietly and the heap breaks at runtime. Why?
The answer is in 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;
}
When clang decides "this class doesn't really need a vector deleting destructor" — that is, when it hasn't seen new[] in that TU and there's no dllexport — it emits ??_E as an alias pointing to ??_G. That makes sense as an optimization: there's no reason to emit two copies of a loop-carrying body when arrays aren't even used.
But this is precisely why the link succeeds. The ??_E symbol does exist. Its body is just scalar.
And what's missing from that body is shown by CGClass.cpp.
if (DtorType == Dtor_Deleting || DtorType == Dtor_VectorDeleting) {
if (CXXStructorImplicitParamValue && DtorType == Dtor_VectorDeleting)
EmitConditionalArrayDtorCall(Dtor, *this, CXXStructorImplicitParamValue);
EmitConditionalArrayDtorCall, which emits the array branch, is only called when it's Dtor_VectorDeleting. Here's what that function does.
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);
It ANDs the flag with 2 and branches into dtor.scalar or dtor.vector. That's the array bit we saw earlier.
Now the pieces fit together.
impl.cppemits the vtable and puts??_Ein the destructor slot. But since that TU has nonew[],??_Eis an alias pointing to??_G(the scalar body).- The
delete[]inmain.cppmakes a virtual call through that slot, setting the array bit (2) in the flag. - The body that gets reached is the scalar body — the branch that checks the array bit simply isn't in the code.
- So it frees the memory as a scalar, without accounting for the array cookie. The heap breaks.
There's no way for the linker to catch this. The symbol name matches, the signature matches, only the meaning is different. It's a contract that lives outside the type system — "this symbol's body handles the array bit" — that got broken, and that's not the kind of thing a linker can check. This is why it's actually fortunate that RtlValidateHeap screams first in Debug builds. In Release, it just crashes.
The fix in #185653 defers this decision. When it sees new[], it forces vector deleting destructor body generation even if the destructor is only declared. In the PR's words, this is safe to do because the vector deleting destructor has weak linkage — even if multiple TUs each emit a body, the linker folds them into one. This is the same behavior MSVC had all along.
So, What Should You Check Right Now
Let's honestly scope this down first.
No impact. Itanium ABI targets like Linux, macOS, and MinGW aren't affected. The code path is guarded by TargetCXXABI::Microsoft. If you don't use clang-cl or -target *-windows-msvc, this post is just trivia.
Worth checking. If you're using, or planning to use, 22.x with clang targeting the MSVC ABI (clang-cl included):
- Are you mixing in objects/libraries built with clang 21 or earlier? If a class with a virtual destructor crosses that boundary, you land exactly in the memory-corruption scenario the release notes describe. Either rebuild everything, or, if you can't, pin the 22 side to the old behavior with
-fclang-abi-compat=21. - Are you
new[]/delete[]-ing a class with a virtual destructor whose destructor definition lives in a different TU and isn'tdllexport? This is the #183255 pattern, and it's still alive as of 22.1.8. The workaround is to move the destructor definition into the header or mark itdllexport. - If you're on 22.1.0 or 22.1.1, upgrade to at least 22.1.2. The devirtualization bug (#183621) fix is in there.
An honest word on -fclang-abi-compat=21. This is an undo button, not a solution. Turning this flag on makes you compatible with clang 21 and earlier, but it puts you out of sync with MSVC again — right back to the very mismatch this change was meant to fix. If you have plans to link against MSVC-built libraries, this isn't the answer. It's only really useful for postponing the transition inside a closed world where everything is built with clang.
This change itself is correct. Since this post is filled with bug stories, let me add — worried it might mislead — that Clang 22's direction is right. Previous clang emitted code that diverged from the vftables MSVC produced, and delete[] on an array of polymorphic objects would silently leak or call the wrong destructor. Fixing an 11-year-old incompatibility required an ABI change, and ABI changes naturally come with this kind of cost. The lesson of this story isn't "don't use clang 22" — it's that a change that crosses an ABI boundary fails in a place the linker can't help you, the moment you mix compiler versions.
Closing
To sum up: Clang 22.1.0 (2026-02-24) changed destructor codegen for the MSVC ABI target in two places — it aligned ::delete handling with MSVC by moving it inside the destructor body, and it added the vector deleting destructor, closing an issue opened in 2014 after 11 years and 7 months. It's an ABI change that swaps the vftable destructor slot's symbol from ??_G to ??_E, and the one escape hatch is -fclang-abi-compat=21.
And within two days starting the day after release, two bugs surfaced. The devirtualization bug was backported into 22.1.2. But the one where ??_E goes out as an alias for ??_G and breaks the heap without the branch that handles the array bit — as of July 16, 2026, that one still hasn't been fixed even in 22.1.8. You either wait for LLVM 23, or move the destructor into the header, or mark it dllexport.
Looking at why this feature landed four times and got reverted three — reverted twice for breaking Chromium (a sanitizer coverage link error, and then /Zc:DllexportInlines-), reverted once because looking up operator delete[] was a security hazard, and fixed for passing the wrong array size five days after the final reland — makes it clear that the 11 years weren't laziness. A compiler's ABI surface can be summed up in one line of release notes, but the failure modes that one line covers only surface once the release ships and users run their own codebases into it. This time, it took a day.
References
- Clang 22 release notes — Potentially Breaking Changes (release/22.x source)
- Issue #19772 — Implement vector deleting destructors (opened 2014-04-11)
- Issue #183255 — [Clang-Cl] Wrong destructing code generated in Clang 22
- Issue #183621 — [clang] Clang 22 incorrect code for
delete[]with MS ABI - PR #139566 — [win][clang] Align scalar deleting destructors with MSABI
- PR #170337 — Reland [MS][clang] Add support for vector deleting destructors
- PR #133451 — Reland: explains the first revert was a Chromium sanitizer coverage link error
- PR #135611 — Revert: operator delete[] lookup was a security hazard
- PR #169116 — the third revert that actually got merged
- PR #169063 — the unmerged revert PR: records the Chromium /Zc:DllexportInlines- build breakage
- PR #172513 — fixes the size passed to delete[]
- PR #183741 — devirtualization fix (backported to 22.1.2)
- PR #185653 — generates the vector deleting dtor body for declaration-only destructors (not backported to 22.x)
- MicrosoftCXXABI.cpp (release/22.x) — flag bits and alias generation
- CGClass.cpp (release/22.x) — EmitConditionalArrayDtorCall
- TargetInfo.cpp (release/22.x) — the abi-compat gate
- LLVM release list (22.1.0 = 2026-02-24, 22.1.8 = 2026-06-16)