llvm / llvm/llvm-project

[lld-macho] Spurious __unwind_info entry with encoding 0 shadows a real entry, breaking exception unwinding

Open
#216,154 1 comment 0 reactions 0 assignees View on GitHub
lld:MachO
Dominant language
LLVM
Stars
40.5k
Forks
18.7k
PR merge metrics
PR metrics pending

Description

Disclaimer: This report was created with LLM assistance:
- Determining the exact link error in my codebase
- Creating a reproducible sample
- First draft of this report

While I tried my best to verify and spot-check, I'm very unfamiliar with the LLVM code; neither the robots nor I should be taken as final authority or even confirmation on this.

## Overview

Spurious __unwind_info entries with encoding 0 may be created. These shadow a real entry, potentially breaking exception unwinding

Discovered with LLD 22.1.8. Confirmed on LLD 24.0.0 via container image. The two versions produce byte-identical (and identically wrong) `__unwind_info` tables for the reproducer below.

- LLD 24.0.0 — apt.llvm.org development-branch package lld-24, cross-linking Mach-O from arm64 Debian trixie. Affected.
- LLD 22.1.8 — Homebrew, arm64 macOS (Darwin 24.6.0, Xcode SDK MacOSX26.2). Affected.
- Apple ld (ld-1230.1) — same input objects. Correct, emits one entry.

The responsible code in lld/MachO/UnwindInfoSection.cpp is unchanged on main, and its comment documents an invariant this bug violates (see Suspected cause).

## Summary

When an object file has a zero-length `__text` section whose only symbol is a local label (e.g. `ltmp0`, as clang emits for a data-only translation unit), ld64.lld emits two `__unwind_info` second-level entries at the same output address: the real entry for the next function, and a second entry with encoding `0x00000000` ("no unwind info").

libunwind binary-searches for the last entry whose function offset is `<= pc`, so it finds the zero encoding, concludes the frame has no unwind information, and returns `_URC_END_OF_STACK` during phase 1. `_Unwind_RaiseException` then returns to `__cxa_throw`, which calls `__cxxabiv1::failed_throw` -> `std::terminate`. A throw inside the affected function can no longer be caught by any caller.

Apple's ld emits a single, correct entry for the same inputs.

## Reproducer

`empty.s` — a data-only translation unit: `__text` present but empty, only symbol is `ltmp0`,
no `__compact_unwind`:

```asm
.section __TEXT,__text,regular,pure_instructions
ltmp0:
.section __TEXT,__cstring,cstring_literals
l_.str:
.asciz "data only"
.subsections_via_symbols
```

`func.s` — an ordinary function with a `__compact_unwind` entry carrying a personality and LSDA:

```asm
.section __TEXT,__text,regular,pure_instructions
.globl _foo
_foo:
ret
Lfunc_end0:

.section __TEXT,__gcc_except_tab
Lexception0:
.byte 0xff

.section __LD,__compact_unwind,regular,debug
.quad _foo
.long Lfunc_end0-_foo
.long 0x04000000
.quad ___gxx_personality_v0
.quad Lexception0
.subsections_via_symbols
```

The link needs no SDK, no libSystem, and no compiler driver, so it can be run from any host —
`ld64.lld` cross-links Mach-O:

```sh
clang -arch arm64 -c empty.s -o empty.o # or any assembler targeting arm64 Mach-O
clang -arch arm64 -c func.s -o func.o

ld64.lld -arch arm64 -platform_version macos 15.0 15.0 \
-dylib -undefined dynamic_lookup -o out.dylib empty.o func.o

llvm-objdump --unwind-info out.dylib
```

Actual — identical output from ld64.lld 24.0.0 (`main`) and 22.1.8. Note two entries at the same
offset, the second with encoding 0:

```
[0]: function offset=0x000003c0, encoding[0]=0x14000000
[1]: function offset=0x000003c0, encoding[1]=0x00000000
```

Expected (Apple ld, same input objects):

```
[0]: function offset=0x00000458, encoding[0]=0x14000000
```

The `main` result above was produced without a macOS host, since `ld64.lld` cross-links Mach-O:

```sh
# in a debian:trixie container
echo "deb http://apt.llvm.org/trixie/ llvm-toolchain-trixie main" > /etc/apt/sources.list.d/llvm.list
apt-get update && apt-get install -y lld-24
ld64.lld-24 -arch arm64 -platform_version macos 15.0 15.0 \
-dylib -undefined dynamic_lookup -o out.dylib empty.o func.o
```

## lit test

The same reproducer as an in-tree test, using only LLVM tools (no clang driver and no macOS SDK),
suitable for `lld/test/MachO/`. It fails today at the `CHECK-NOT` and should pass once the
duplicate entry is gone:

```
# REQUIRES: aarch64
## An object file whose __text section is empty (a data-only translation unit)
## contributes a symbol at offset 0 of a zero-length section. That symbol ends up
## at the same output address as the next real function, and lld emits a second
## __unwind_info entry for it with encoding 0 ("no unwind info"), which shadows
## the real entry and breaks unwinding out of that function.

# RUN: rm -rf %t; split-file %s %t
# RUN: llvm-mc -filetype=obj -triple=arm64-apple-macos %t/empty.s -o %t/empty.o
# RUN: llvm-mc -filetype=obj -triple=arm64-apple-macos %t/func.s -o %t/func.o
# RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -o %t/out.dylib %t/empty.o %t/func.o
# RUN: llvm-objdump --unwind-info %t/out.dylib | FileCheck %s

# CHECK: Contents of __unwind_info section:
# CHECK: encoding[0]=0x14000000
# CHECK-NOT: encoding{{.*}}=0x00000000

#--- empty.s
.section __TEXT,__text,regular,pure_instructions
ltmp0:
.section __TEXT,__cstring,cstring_literals
l_.str:
.asciz "data only"
.subsections_via_symbols

#--- func.s
.section __TEXT,__text,regular,pure_instructions
.globl _foo
_foo:
ret
Lfunc_end0:

.section __TEXT,__gcc_except_tab
Lexception0:
.byte 0xff

.section __LD,__compact_unwind,regular,debug
.quad _foo
.long Lfunc_end0-_foo
.long 0x04000000
.quad ___gxx_personality_v0
.quad Lexception0
.subsections_via_symbols
```

Current failure output:

```
error: CHECK-NOT: excluded string found in input
# CHECK-NOT: encoding{{.*}}=0x00000000
^
:26:35: note: found here
[1]: function offset=0x000003c0, encoding[1]=0x00000000
```

### Suspected cause

`UnwindInfoSection::addSymbol` deduplicates same-address symbols using a key built from the
*input* section and the offset within it, rather than the final output address
(`lld/MachO/UnwindInfoSection.cpp`, unchanged on `main` as of 2026-08-13):

```cpp
void UnwindInfoSection::addSymbol(const Defined *d) {
if (d->unwindEntry())
allEntriesAreOmitted = false;
// We don't yet know the final output address of this symbol, but we know that
// they are uniquely determined by a combination of the isec and value, so
// we use that as the key here.
auto p = symbols.insert({{d->isec(), d->value}, d});
// If we have multiple symbols at the same address, only one of them can have
// an associated unwind entry.
if (!p.second && d->unwindEntry()) {
assert(p.first->second == d || !p.first->second->unwindEntry());
p.first->second = d;
}
}
```

The documented assumption — that `(isec, value)` uniquely determines an address — does not hold
when a preceding input section has zero length. `ltmp0` lives in `empty.o`'s zero-length `__text`
and `_foo` lives in a different input section, so the two symbols produce different keys and both
survive dedup, even though they occupy the same output address.

A second observation is consistent with this: if `ltmp0` and `_foo` are placed in the **same**
input section at the same offset, dedup works correctly and only one entry is emitted.

```asm
; same.s -- produces exactly one entry, no duplicate
.section __TEXT,__text,regular,pure_instructions
ltmp0:
.globl _foo
_foo:
ret
...
```

So the trigger is specifically a zero-length input section preceding a function, not
same-address symbols in general.

## Real-world impact

Found in a large C++ project (~24 MB test binary, 188 link inputs). One data-only translation
unit consisting solely of string tables produced the zero-length `__text`, and the affected
function was the project's assertion handler, which throws. Every unit test asserting that
invalid input triggers an assertion aborted with:

```
libc++abi: terminating due to uncaught exception of type AssertionFailed
```

instead of the exception being caught one frame up. 77 of 170 test binaries in that build
contained at least one shadowed entry; only the one that landed on a throwing function failed
visibly, so the rest are latent.

The failure is also layout-sensitive, which made it awkward to diagnose; reordering
archives on the link line moved the bad entry and the visible symptom
disappeared while leaving the corruption in place. It has also appeared sensitive to general
rebuilds in the past.

## Potentially Related

- [D109945](https://reviews.llvm.org/D109945) ("[lld-macho] Simplify the handling of 'no unwind
info' functions") introduced this design.
- [#45295](https://github.com/llvm/llvm-project/issues/45295) reports duplicate `.pdata` entries
in lld/COFF — same shape, but a different backend and cause (ICF folding vs. address dedup).

## Attachments
(please forgive the names, apparently I can't upload with the .s extension)

[empty.s.txt](https://github.com/user-attachments/files/31042046/empty.s.txt)
[func.s.txt](https://github.com/user-attachments/files/31042045/func.s.txt)
[dedup-zero-length-section.s.txt](https://github.com/user-attachments/files/31042089/dedup-zero-length-section.s.txt)

Contributor guide

Open the contributing guide

Research direction

Start in lld/MachO/UnwindInfoSection.cpp, especially UnwindInfoSection::addSymbol, and run the proposed lit test under lld/test/MachO/ with llvm-mc, %lld, and llvm-objdump. Done means the reproducer emits one real __unwind_info entry and the FileCheck CHECK-NOT for encoding 0 passes.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, shell
Domain
compilers
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.