[lld] [lld][MachO] Don't reject TLV relocations against dynamic-lookup symbols (PR #221364)

Peter Rong via llvm-commits llvm-commits at lists.llvm.org
Thu Sep 10 15:46:58 PDT 2026


https://github.com/DataCorrupted updated https://github.com/llvm/llvm-project/pull/221364

>From 3707c64af7894db636fcd2d2e80ad34bfab44e8a Mon Sep 17 00:00:00 2001
From: Peter Rong <PeterRong at meta.com>
Date: Fri, 4 Sep 2026 16:04:28 -0700
Subject: [PATCH 1/3] [lld][MachO] Don't reject TLV relocations against
 dynamic-lookup symbols

Reading an `extern _Thread_local` variable whose definition lives in
another image fails to link under `-undefined dynamic_lookup`. This
long-standing bug will be exposed more often by C++20's `constinit`, which
lets the compiler elide the wrapper function a cross-TU `thread_local`
access normally routes through: without the wrapper, the referencing
object emits a direct TLV relocation instead of an ordinary call.

```
$ echo 'extern _Thread_local int tlsVal; int read_tls(void) { return tlsVal; }' > /tmp/a.c
$ clang -target arm64-apple-macos11 -fuse-ld=lld -dynamiclib -nostdlib \
        -Wl,-undefined,dynamic_lookup /tmp/a.c -o /tmp/a.dylib
ld64.lld: error: /tmp/a-<hash>.o:(symbol read_tls+0x8): TLVP_LOAD_PAGE21
  relocation requires that symbol _tlsVal be thread-local
ld64.lld: error: /tmp/a-<hash>.o:(symbol read_tls+0xc): TLVP_LOAD_PAGEOFF12
  relocation requires that symbol _tlsVal be thread-local
```

`validateSymbolRelocation` requires that a relocation carrying the TLV
attribute point at a symbol that `isTlv()`, which is reasonable.
However, a dynamic-lookup symbol fails the check because it has no defining dylib, and Mach-O
has no field for thread-locality on an undefined symbol reference, so there is no way to know.
`addDynamicLookup` creates the symbol with `isTlv = false` because that is the only value available.

Therefore, we relax the check by skipping it if the symbol came from dynamic lookup.
---
 lld/MachO/Relocations.cpp           |  7 ++++-
 lld/test/MachO/tlv-dynamic-lookup.s | 45 +++++++++++++++++++++++++++++
 2 files changed, 51 insertions(+), 1 deletion(-)
 create mode 100644 lld/test/MachO/tlv-dynamic-lookup.s

diff --git a/lld/MachO/Relocations.cpp b/lld/MachO/Relocations.cpp
index 0945e7a96dee2..319443b631a66 100644
--- a/lld/MachO/Relocations.cpp
+++ b/lld/MachO/Relocations.cpp
@@ -68,7 +68,12 @@ bool macho::validateSymbolRelocation(const Symbol *sym,
         .str();
   };
 
-  if (relocAttrs.hasAttr(RelocAttrBits::TLV) != sym->isTlv())
+  // A dynamic-lookup symbol's thread-locality is unknown at link time but
+  // resolved by dyld at load time; rejecting it would refuse a valid link.
+  const auto *dysym = dyn_cast<DylibSymbol>(sym);
+  bool tlvKindIsKnown = !(dysym && dysym->isDynamicLookup());
+
+  if (tlvKindIsKnown && relocAttrs.hasAttr(RelocAttrBits::TLV) != sym->isTlv())
     error(message(Twine("requires that symbol ") + sym->getName() + " " +
                   (sym->isTlv() ? "not " : "") + "be thread-local"));
 
diff --git a/lld/test/MachO/tlv-dynamic-lookup.s b/lld/test/MachO/tlv-dynamic-lookup.s
new file mode 100644
index 0000000000000..5b1cb5706ebfe
--- /dev/null
+++ b/lld/test/MachO/tlv-dynamic-lookup.s
@@ -0,0 +1,45 @@
+# REQUIRES: aarch64
+# RUN: rm -rf %t; split-file %s %t
+
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/consumer.s -o %t/consumer.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/notlv.s -o %t/notlv.o
+
+# RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -o %t/dylookup.dylib %t/consumer.o
+# RUN: llvm-objdump --macho --section-headers --bind %t/dylookup.dylib | FileCheck %s
+
+# RUN: %lld -arch arm64 -dylib -U _tlv -o %t/u.dylib %t/consumer.o
+# RUN: llvm-objdump --macho --section-headers --bind %t/u.dylib | FileCheck %s
+
+# RUN: %lld -arch arm64 -dylib -flat_namespace -undefined suppress -o %t/flat.dylib %t/consumer.o
+# RUN: llvm-objdump --macho --section-headers --bind %t/flat.dylib | FileCheck %s
+
+# CHECK:      __thread_ptrs
+# CHECK-LABEL: Bind table:
+# CHECK:      __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+
+## The import gets the flat-lookup ordinal.
+# RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -fixup_chains \
+# RUN:   -o %t/chained.dylib %t/consumer.o
+# RUN: llvm-objdump --macho --chained-fixups %t/chained.dylib | FileCheck %s --check-prefix=CHAINED
+# CHAINED: lib_ordinal = -2 (flat-namespace)
+
+## A dylib that does define the symbol still tells us its thread-locality, so
+## the mismatch check keeps working there.
+# RUN: %lld -arch arm64 -dylib -install_name @rpath/libnotlv.dylib -o %t/libnotlv.dylib %t/notlv.o
+# RUN: not %lld -arch arm64 -dylib -o /dev/null %t/consumer.o %t/libnotlv.dylib 2>&1 | \
+# RUN:   FileCheck %s --check-prefix=ERR
+# ERR: error: {{.*}}TLVP_LOAD_PAGE21 relocation requires that symbol _tlv be thread-local
+
+#--- consumer.s
+.globl _readTlv
+.p2align 2
+_readTlv:
+  adrp x8, _tlv at TLVPPAGE
+  ldr  x8, [x8, _tlv at TLVPPAGEOFF]
+  ret
+
+#--- notlv.s
+.globl _tlv
+.data
+_tlv:
+  .quad 0

>From 88ff7755921281471fada24ef0b245c14df63c21 Mon Sep 17 00:00:00 2001
From: Peter Rong <PeterRong at meta.com>
Date: Thu, 10 Sep 2026 13:07:31 -0700
Subject: [PATCH 2/3] 2nd try

---
 lld/MachO/InputSection.cpp              |   9 +-
 lld/MachO/MapFile.cpp                   |   2 +-
 lld/MachO/Symbols.cpp                   |  10 +-
 lld/MachO/Symbols.h                     |  13 +-
 lld/MachO/SyntheticSections.cpp         |   7 +-
 lld/MachO/SyntheticSections.h           |  18 +-
 lld/test/MachO/tlv-dynamic-lookup-x86.s |  51 +++++
 lld/test/MachO/tlv-dynamic-lookup.s     | 241 ++++++++++++++++++++++--
 8 files changed, 324 insertions(+), 27 deletions(-)
 create mode 100644 lld/test/MachO/tlv-dynamic-lookup-x86.s

diff --git a/lld/MachO/InputSection.cpp b/lld/MachO/InputSection.cpp
index d977830161a8e..d2841a0f73c39 100644
--- a/lld/MachO/InputSection.cpp
+++ b/lld/MachO/InputSection.cpp
@@ -248,8 +248,13 @@ void ConcatInputSection::writeTo(uint8_t *buf) {
       }
       referentVA = minuendVA - fromSym->getVA();
     } else if (auto *referentSym = r.referent.dyn_cast<Symbol *>()) {
-      if (target->hasAttr(r.type, RelocAttrBits::LOAD) &&
-          !referentSym->isInGot())
+      // Relaxing a load means "there is no slot; compute the address
+      // directly". Ask about the section this relocation actually uses:
+      // __thread_ptrs for TLV relocations, __got for everything else.
+      bool hasSlot = target->hasAttr(r.type, RelocAttrBits::TLV)
+                         ? referentSym->isInTlvPointers()
+                         : referentSym->isInGot();
+      if (target->hasAttr(r.type, RelocAttrBits::LOAD) && !hasSlot)
         target->relaxGotLoad(loc, r.type);
       // For dtrace symbols, do not handle them as normal undefined symbols
       if (referentSym->getName().starts_with("___dtrace_")) {
diff --git a/lld/MachO/MapFile.cpp b/lld/MachO/MapFile.cpp
index 29ebcdcf9a832..4d5e543c24c8d 100644
--- a/lld/MachO/MapFile.cpp
+++ b/lld/MachO/MapFile.cpp
@@ -149,7 +149,7 @@ static void printNonLazyPointerSection(raw_fd_ostream &os,
   // associations.
   for (const Symbol *sym : osec->getEntries())
     os << format("0x%08llX\t0x%08zX\t[  0] non-lazy-pointer-to-local: %s\n",
-                 osec->addr + sym->gotIndex * target->wordSize,
+                 osec->addr + osec->getIndex(*sym) * target->wordSize,
                  target->wordSize, sym->getName().str().data());
 }
 
diff --git a/lld/MachO/Symbols.cpp b/lld/MachO/Symbols.cpp
index 27419caf9de1e..b622dbcb1901a 100644
--- a/lld/MachO/Symbols.cpp
+++ b/lld/MachO/Symbols.cpp
@@ -50,7 +50,15 @@ uint64_t Symbol::getLazyPtrVA() const {
   return in.lazyPointers->getVA(stubsIndex);
 }
 uint64_t Symbol::getGotVA() const { return in.got->getVA(gotIndex); }
-uint64_t Symbol::getTlvVA() const { return in.tlvPointers->getVA(gotIndex); }
+bool Symbol::isInTlvPointers() const {
+  return in.tlvPointers->getIndex(*this) != UINT32_MAX;
+}
+uint64_t Symbol::getTlvVA() const {
+  return in.tlvPointers->getVA(in.tlvPointers->getIndex(*this));
+}
+uint64_t Symbol::resolveTlvVA() const {
+  return isInTlvPointers() ? getTlvVA() : getVA();
+}
 
 Defined::Defined(StringRef name, InputFile *file, InputSection *isec,
                  uint64_t value, uint64_t size, bool isWeakDef, bool isExternal,
diff --git a/lld/MachO/Symbols.h b/lld/MachO/Symbols.h
index 9fc7d6b079058..6c89e7a77266a 100644
--- a/lld/MachO/Symbols.h
+++ b/lld/MachO/Symbols.h
@@ -65,9 +65,14 @@ class Symbol {
 
   virtual bool isTlv() const { return false; }
 
-  // Whether this symbol is in the GOT or TLVPointer sections.
+  // Whether this symbol has a __got slot.
   bool isInGot() const { return gotIndex != UINT32_MAX; }
 
+  // Whether this symbol has a __thread_ptrs slot. Unlike the GOT index this
+  // is kept in a side table owned by TlvPointerSection: thread-local imports
+  // are rare, and Symbol is instantiated in the millions.
+  bool isInTlvPointers() const;
+
   // Whether this symbol is in the StubsSection.
   bool isInStubs() const { return stubsIndex != UINT32_MAX; }
 
@@ -80,11 +85,9 @@ class Symbol {
     return isInStubs() ? getStubVA() : getVA();
   }
   uint64_t resolveGotVA() const { return isInGot() ? getGotVA() : getVA(); }
-  uint64_t resolveTlvVA() const { return isInGot() ? getTlvVA() : getVA(); }
+  uint64_t resolveTlvVA() const;
 
-  // The index of this symbol in the GOT or the TLVPointer section, depending
-  // on whether it is a thread-local. A given symbol cannot be referenced by
-  // both these sections at once.
+  // The index of this symbol in the GOT.
   uint32_t gotIndex = UINT32_MAX;
   uint32_t lazyBindOffset = UINT32_MAX;
   uint32_t stubsHelperIndex = UINT32_MAX;
diff --git a/lld/MachO/SyntheticSections.cpp b/lld/MachO/SyntheticSections.cpp
index c4277d54edd38..8503e0ed6a452 100644
--- a/lld/MachO/SyntheticSections.cpp
+++ b/lld/MachO/SyntheticSections.cpp
@@ -338,10 +338,11 @@ void macho::addNonLazyBindingEntries(const Symbol *sym,
 
 void NonLazyPointerSectionBase::addEntry(Symbol *sym) {
   if (entries.insert(sym)) {
-    assert(!sym->isInGot());
-    sym->gotIndex = entries.size() - 1;
+    assert(getIndex(*sym) == UINT32_MAX);
+    uint32_t index = entries.size() - 1;
+    setIndex(*sym, index);
 
-    addNonLazyBindingEntries(sym, isec, sym->gotIndex * target->wordSize);
+    addNonLazyBindingEntries(sym, isec, index * target->wordSize);
   }
 }
 
diff --git a/lld/MachO/SyntheticSections.h b/lld/MachO/SyntheticSections.h
index 9acbd73d277e8..5c9006d3104b6 100644
--- a/lld/MachO/SyntheticSections.h
+++ b/lld/MachO/SyntheticSections.h
@@ -120,9 +120,13 @@ class NonLazyPointerSectionBase : public SyntheticSection {
   }
   void writeTo(uint8_t *buf) const override;
   void addEntry(Symbol *sym);
-  uint64_t getVA(uint32_t gotIndex) const {
-    return addr + gotIndex * target->wordSize;
+  uint64_t getVA(uint32_t index) const {
+    return addr + index * target->wordSize;
   }
+  // This symbol's slot index within this section, or UINT32_MAX. __got and
+  // __thread_ptrs track indices separately so a symbol may appear in both.
+  virtual uint32_t getIndex(const Symbol &sym) const = 0;
+  virtual void setIndex(Symbol &sym, uint32_t index) = 0;
 
 private:
   llvm::SetVector<const Symbol *> entries;
@@ -131,11 +135,21 @@ class NonLazyPointerSectionBase : public SyntheticSection {
 class GotSection final : public NonLazyPointerSectionBase {
 public:
   GotSection();
+  uint32_t getIndex(const Symbol &sym) const override { return sym.gotIndex; }
+  void setIndex(Symbol &sym, uint32_t index) override { sym.gotIndex = index; }
 };
 
 class TlvPointerSection final : public NonLazyPointerSectionBase {
 public:
   TlvPointerSection();
+  uint32_t getIndex(const Symbol &sym) const override {
+    auto it = indices.find(&sym);
+    return it == indices.end() ? UINT32_MAX : it->second;
+  }
+  void setIndex(Symbol &sym, uint32_t index) override { indices[&sym] = index; }
+
+private:
+  llvm::DenseMap<const Symbol *, uint32_t> indices;
 };
 
 struct Location {
diff --git a/lld/test/MachO/tlv-dynamic-lookup-x86.s b/lld/test/MachO/tlv-dynamic-lookup-x86.s
new file mode 100644
index 0000000000000..80ef492218373
--- /dev/null
+++ b/lld/test/MachO/tlv-dynamic-lookup-x86.s
@@ -0,0 +1,51 @@
+# REQUIRES: x86
+# RUN: rm -rf %t; split-file %s %t
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/movq.s -o %t/movq.o
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/leaq.s -o %t/leaq.o
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/notlv.s -o %t/notlv.o
+
+# RUN: %lld -dylib -undefined dynamic_lookup -o %t/movq.dylib %t/movq.o
+# RUN: llvm-objdump --macho --section-headers --bind -d --no-show-raw-insn %t/movq.dylib | \
+# RUN:   FileCheck %s --check-prefix=MOVQ
+# MOVQ-LABEL: _f:
+# MOVQ-NEXT:  movq
+# MOVQ:       __thread_ptrs
+# MOVQ-LABEL: Bind table:
+# MOVQ:       __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+
+## FIXME: leaq computes the address of the __thread_ptrs slot, but the ABI
+## requires the descriptor the slot holds, so this is wrong code. It is accepted
+## because X86_64::relaxGotLoad's MOVQ check only runs on the relax path, and a
+## slot exists here. Pre-existing and not specific to dynamic lookup: the same
+## leaq against a dylib that really exports _tlv as thread-local miscompiles
+## identically on stock lld. Pinned as-is so the separate fix has a baseline.
+# RUN: %lld -dylib -undefined dynamic_lookup -o %t/leaq.dylib %t/leaq.o
+# RUN: llvm-objdump --macho -d --no-show-raw-insn %t/leaq.dylib | \
+# RUN:   FileCheck %s --check-prefix=LEAQ
+# LEAQ-LABEL: _f:
+# LEAQ-NEXT:  leaq
+
+# RUN: %lld -dylib -install_name @rpath/libnotlv.dylib -o %t/libnotlv.dylib %t/notlv.o
+# RUN: not %lld -dylib -o /dev/null %t/movq.o %t/libnotlv.dylib 2>&1 | \
+# RUN:   FileCheck %s --check-prefix=DEFINED
+# DEFINED: error: {{.*}}TLV relocation requires that symbol _tlv be thread-local
+
+#--- movq.s
+.globl _f
+_f:
+  movq _tlv at TLVP(%rip), %rax
+  retq
+.subsections_via_symbols
+
+#--- leaq.s
+.globl _f
+_f:
+  leaq _tlv at TLVP(%rip), %rax
+  retq
+.subsections_via_symbols
+
+#--- notlv.s
+.globl _tlv
+.data
+_tlv:
+  .quad 0
diff --git a/lld/test/MachO/tlv-dynamic-lookup.s b/lld/test/MachO/tlv-dynamic-lookup.s
index 5b1cb5706ebfe..96fb12e1541d1 100644
--- a/lld/test/MachO/tlv-dynamic-lookup.s
+++ b/lld/test/MachO/tlv-dynamic-lookup.s
@@ -1,35 +1,149 @@
 # REQUIRES: aarch64
 # RUN: rm -rf %t; split-file %s %t
-
 # RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/consumer.s -o %t/consumer.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/twotlv.s -o %t/twotlv.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/mixed.s -o %t/mixed.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/branch.s -o %t/branch.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/cfi.s -o %t/cfi.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/binder.s -o %t/binder.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/initoff.s -o %t/initoff.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/ep.s -o %t/ep.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/unsmix.s -o %t/unsmix.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/deftlv.s -o %t/deftlv.o
 # RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/notlv.s -o %t/notlv.o
 
 # RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -o %t/dylookup.dylib %t/consumer.o
 # RUN: llvm-objdump --macho --section-headers --bind %t/dylookup.dylib | FileCheck %s
+# CHECK:       __thread_ptrs
+# CHECK-LABEL: Bind table:
+# CHECK:       __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
 
-# RUN: %lld -arch arm64 -dylib -U _tlv -o %t/u.dylib %t/consumer.o
-# RUN: llvm-objdump --macho --section-headers --bind %t/u.dylib | FileCheck %s
+## -U is per-symbol: _tlv is exempt, _other is left undefined.
+# RUN: not %lld -arch arm64 -dylib -U _tlv -o /dev/null %t/twotlv.o 2>&1 | \
+# RUN:   FileCheck %s --check-prefix=USCOPE
+# USCOPE-NOT: _tlv
+# USCOPE:     error: undefined symbol: _other
+# USCOPE-NOT: _tlv
 
-# RUN: %lld -arch arm64 -dylib -flat_namespace -undefined suppress -o %t/flat.dylib %t/consumer.o
-# RUN: llvm-objdump --macho --section-headers --bind %t/flat.dylib | FileCheck %s
+# RUN: %lld -arch arm64 -dylib -U _tlv -U _other -o %t/u.dylib %t/twotlv.o
+# RUN: llvm-objdump --macho --bind %t/u.dylib | FileCheck %s --check-prefix=UBOTH
+# UBOTH-DAG: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# UBOTH-DAG: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _other
 
-# CHECK:      __thread_ptrs
-# CHECK-LABEL: Bind table:
-# CHECK:      __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# RUN: %no-fatal-warnings-lld -arch arm64 -dylib -flat_namespace -undefined suppress \
+# RUN:   -o %t/flat.dylib %t/consumer.o
+# RUN: llvm-objdump --macho --bind %t/flat.dylib | FileCheck %s --check-prefix=FLAT
+# FLAT: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
 
-## The import gets the flat-lookup ordinal.
+## Chained fixups must produce a __thread_ptrs slot, not just a flat import --
+## a GOT-only binary yields a byte-identical import table.
 # RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -fixup_chains \
 # RUN:   -o %t/chained.dylib %t/consumer.o
-# RUN: llvm-objdump --macho --chained-fixups %t/chained.dylib | FileCheck %s --check-prefix=CHAINED
-# CHAINED: lib_ordinal = -2 (flat-namespace)
+# RUN: llvm-objdump --macho --section-headers --chained-fixups %t/chained.dylib | \
+# RUN:   FileCheck %s --check-prefix=CHAINED
+# CHAINED:      __thread_ptrs
+# CHAINED-NOT:  __got
+# CHAINED:      lib_ordinal = -2 (flat-namespace)
+# CHAINED:      _tlv
 
-## A dylib that does define the symbol still tells us its thread-locality, so
-## the mismatch check keeps working there.
 # RUN: %lld -arch arm64 -dylib -install_name @rpath/libnotlv.dylib -o %t/libnotlv.dylib %t/notlv.o
+# RUN: %lld -arch arm64 -dylib -install_name @rpath/libtlv.dylib -undefined dynamic_lookup \
+# RUN:   -o %t/libtlv.dylib %t/deftlv.o
+
 # RUN: not %lld -arch arm64 -dylib -o /dev/null %t/consumer.o %t/libnotlv.dylib 2>&1 | \
 # RUN:   FileCheck %s --check-prefix=ERR
 # ERR: error: {{.*}}TLVP_LOAD_PAGE21 relocation requires that symbol _tlv be thread-local
 
+# RUN: %lld -arch arm64 -dylib -o %t/good.dylib %t/consumer.o %t/libtlv.dylib
+# RUN: llvm-objdump --macho --bind %t/good.dylib | FileCheck %s --check-prefix=GOOD
+# GOOD: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 libtlv _tlv
+
+## A dynamic-lookup symbol has no thread-locality to publish, so it must not be
+## re-exported -- a downstream link would read the missing flag as a definite
+## "not thread-local". _readTlv is the positive control: the trie is not simply
+## empty for every input.
+# RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -exported_symbol _tlv \
+# RUN:   -exported_symbol _readTlv -o %t/reexport.dylib %t/consumer.o
+# RUN: llvm-objdump --macho --exports-trie %t/reexport.dylib | \
+# RUN:   FileCheck %s --check-prefix=REEXPORT
+# REEXPORT:     _readTlv
+# REEXPORT-NOT: _tlv{{$}}
+
+## The remaining cases each reach NonLazyPointerSectionBase::addEntry by a route
+## that does not pass through prepareSymbolRelocation. None may abort the
+## linker. Where lld synthesizes the competing entry itself the user has no way
+## to act on a diagnostic, so these must link.
+
+## StubsSection::addEntry -> in.got->addEntry under chained fixups, which is the
+## default at iOS 16 / macOS 13.
+## Chained fixups records binds in LC_DYLD_CHAINED_FIXUPS, so --bind is empty
+## here; the stub's __got slot and the TLV's __thread_ptrs slot must coexist.
+# RUN: %no-arg-lld -arch arm64 -platform_version ios 16.0 16.0 -dylib \
+# RUN:   -undefined dynamic_lookup -o %t/branch-chained.dylib %t/branch.o
+# RUN: llvm-objdump --macho --section-headers --chained-fixups %t/branch-chained.dylib | \
+# RUN:   FileCheck %s --check-prefix=BRANCHC
+# BRANCHC-DAG: __got
+# BRANCHC-DAG: __thread_ptrs
+# BRANCHC:     lib_ordinal = -2 (flat-namespace)
+
+## Same source without chained fixups must not reach a different verdict.
+# RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -no_fixup_chains \
+# RUN:   -o %t/branch-lazy.dylib %t/branch.o
+# RUN: llvm-objdump --macho --bind %t/branch-lazy.dylib | FileCheck %s --check-prefix=BRANCH
+# BRANCH: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+
+## UnwindInfoSection::prepare, after the relocation scan.
+# RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -o %t/cfi.dylib %t/cfi.o
+# RUN: llvm-objdump --macho --bind %t/cfi.dylib | FileCheck %s --check-prefix=CFI
+# CFI: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _pers
+
+## StubHelperSection::setUp, which runs after Writer::run's errorCount() bail
+## and so can never be protected by an error() emitted earlier.
+# RUN: %no-lsystem-lld -arch arm64 -dylib -undefined dynamic_lookup -no_fixup_chains \
+# RUN:   -o %t/binder.dylib %t/binder.o
+# RUN: llvm-objdump --macho --bind %t/binder.dylib | FileCheck %s --check-prefix=BINDER
+# BINDER: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace dyld_stub_binder
+
+## InitOffsetsSection::setUp.
+# RUN: %no-arg-lld -arch arm64 -platform_version macos 13.0 13.0 \
+# RUN:   -syslibroot %S/Inputs/MacOSX.sdk -lSystem -dylib -undefined dynamic_lookup \
+# RUN:   -init_offsets -o %t/initoff.dylib %t/initoff.o
+# RUN: llvm-objdump --macho --section-headers --chained-fixups %t/initoff.dylib | \
+# RUN:   FileCheck %s --check-prefix=INITOFF
+# INITOFF-DAG: __got
+# INITOFF-DAG: __thread_ptrs
+# INITOFF:     lib_ordinal = -2 (flat-namespace)
+# INITOFF:     _ctor
+
+## The entry-point stub is synthesized before the relocation scan; the only
+## reference in the source is the TLV one, so there is nothing for a user to fix.
+# RUN: %no-arg-lld -arch arm64 -platform_version macos 13.0 13.0 \
+# RUN:   -syslibroot %S/Inputs/MacOSX.sdk -lSystem -e _ep -undefined dynamic_lookup \
+# RUN:   -o %t/ep.out %t/ep.o
+# RUN: llvm-objdump --macho --section-headers --chained-fixups %t/ep.out | \
+# RUN:   FileCheck %s --check-prefix=EP
+# EP-DAG: __got
+# EP-DAG: __thread_ptrs
+# EP:     lib_ordinal = -2 (flat-namespace)
+# EP:     _ep
+
+## JUDGMENT CALL. A data pointer and a TLV reference to one dynamic-lookup
+## symbol cannot both describe whatever dyld finds, but neither can lld tell
+## which is wrong, and the UNSIGNED path allocates no slot for a conflict check
+## to inspect. Accepting both binds matches how every other unverifiable
+## dynamic-lookup mismatch is already treated.
+# RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -o %t/unsmix.dylib %t/unsmix.o
+# RUN: llvm-objdump --macho --bind %t/unsmix.dylib | FileCheck %s --check-prefix=UNS
+# UNS-DAG: __DATA __data 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# UNS-DAG: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+
+## JUDGMENT CALL. Same question for a GOT reference, which does allocate a slot.
+## Treated the same way for consistency with UNS and BRANCH above.
+# RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -o %t/mixed.dylib %t/mixed.o
+# RUN: llvm-objdump --macho --bind %t/mixed.dylib | FileCheck %s --check-prefix=MIXED
+# MIXED-DAG: __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# MIXED-DAG: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+
 #--- consumer.s
 .globl _readTlv
 .p2align 2
@@ -37,6 +151,107 @@ _readTlv:
   adrp x8, _tlv at TLVPPAGE
   ldr  x8, [x8, _tlv at TLVPPAGEOFF]
   ret
+.subsections_via_symbols
+
+#--- twotlv.s
+.globl _two
+.p2align 2
+_two:
+  adrp x8, _tlv at TLVPPAGE
+  ldr  x8, [x8, _tlv at TLVPPAGEOFF]
+  adrp x9, _other at TLVPPAGE
+  ldr  x9, [x9, _other at TLVPPAGEOFF]
+  ret
+.subsections_via_symbols
+
+#--- mixed.s
+.globl _viaTlv, _viaGot
+.p2align 2
+_viaTlv:
+  adrp x8, _tlv at TLVPPAGE
+  ldr  x8, [x8, _tlv at TLVPPAGEOFF]
+  ret
+_viaGot:
+  adrp x8, _tlv at GOTPAGE
+  ldr  x8, [x8, _tlv at GOTPAGEOFF]
+  ret
+.subsections_via_symbols
+
+#--- branch.s
+.globl _g
+.p2align 2
+_g:
+  bl _tlv
+  adrp x8, _tlv at TLVPPAGE
+  ldr  x8, [x8, _tlv at TLVPPAGEOFF]
+  ret
+.subsections_via_symbols
+
+#--- cfi.s
+.globl _f
+.p2align 2
+_f:
+  .cfi_startproc
+  .cfi_personality 155, _pers
+  adrp x8, _pers at TLVPPAGE
+  ldr  x8, [x8, _pers at TLVPPAGEOFF]
+  ret
+  .cfi_endproc
+.subsections_via_symbols
+
+#--- binder.s
+.globl _f
+.p2align 2
+_f:
+  adrp x8, dyld_stub_binder at TLVPPAGE
+  ldr  x8, [x8, dyld_stub_binder at TLVPPAGEOFF]
+  bl _lazy
+  ret
+.subsections_via_symbols
+
+#--- initoff.s
+.globl _f
+.p2align 2
+_f:
+  adrp x8, _ctor at TLVPPAGE
+  ldr  x8, [x8, _ctor at TLVPPAGEOFF]
+  ret
+.section __DATA,__mod_init_func,mod_init_funcs
+.quad _ctor
+.subsections_via_symbols
+
+#--- ep.s
+.globl _main
+.p2align 2
+_main:
+  adrp x8, _ep at TLVPPAGE
+  ldr  x8, [x8, _ep at TLVPPAGEOFF]
+  ret
+.subsections_via_symbols
+
+#--- unsmix.s
+.globl _a
+.p2align 2
+_a:
+  adrp x8, _tlv at TLVPPAGE
+  ldr  x8, [x8, _tlv at TLVPPAGEOFF]
+  ret
+.data
+.globl _p
+_p:
+  .quad _tlv
+.subsections_via_symbols
+
+#--- deftlv.s
+.globl _tlv
+.section __DATA,__thread_data,thread_local_regular
+_tlv$tlv$init:
+  .quad 0
+.section __DATA,__thread_vars,thread_local_variables
+_tlv:
+  .quad __tlv_bootstrap
+  .quad 0
+  .quad _tlv$tlv$init
 
 #--- notlv.s
 .globl _tlv

>From 1c25f79626afaa15dac7f5187f022b82b3f675f5 Mon Sep 17 00:00:00 2001
From: Peter Rong <PeterRong at meta.com>
Date: Thu, 10 Sep 2026 13:50:36 -0700
Subject: [PATCH 3/3] [lld][MachO] Give a TLV symbol one non-lazy pointer slot,
 as ld64 does

A thread-local symbol's __thread_ptrs slot and its __got slot hold the
very same value -- the address of its TLV descriptor -- so a symbol
referenced both ways needs only one of them, and either reference kind
can read it. Route both through a single slot:

  - a known thread-local gets its slot in __thread_ptrs,
  - anything else, including a dynamic-lookup symbol whose
    thread-locality is unknowable at link time, gets one in __got.

This fixes two bugs at once.

Since 3707c64af789 relaxed validateSymbolRelocation for dynamic-lookup
symbols, a TLV and a GOT relocation against one such symbol could both
allocate a slot. Symbol::gotIndex served both sections, so the second
addEntry tripped `assert(!sym->isInGot())`, or in a release build
silently overwrote the first index. Sending both to __got makes them
coalesce. ld64 lowers this input identically -- one __got slot, both
references reading it -- so the output is now instruction-for-
instruction what the reference implementation emits.

Second, only one direction of the TLV/non-TLV relocation mismatch is
really an error. A GOT relocation against a thread-local is meaningful:
it asks for the descriptor's address, which is what the slot holds.
Rejecting it also left the reciprocal half of the original bug unfixed.
A TLV relocation against a symbol known not to be thread-local stays an
error, because the TLV sequence would call the referent's first word as
if it were a resolver function.

That second change deletes two long-standing tests, so the claim that
their diagnostic was overreach is measured rather than argued. Against
Apple's ld-1230.1 on macOS 26.6.2, a GOT relocation targeting a
thread-local links with no diagnostic in every configuration the deleted
tests covered:

  - defined in the same image (bad-got-to-tlv-reference.s): exit 0, no
    slot at all, the load relaxed to a direct address computation.
  - exported by a dylib (bad-got-to-dylib-tlv-reference.s): exit 0 on
    both x86_64 and arm64, one __got slot bound to libtlv/_foo.

lld now links both too. For the dylib case it puts the slot in __thread_ptrs rather than __got,
since unlike ld64 it does track that the symbol is thread-local; the two sections hold the same
descriptor address, and a two-thread harness confirms a GOT reference served out of a __thread_ptrs
slot yields a descriptor whose resolver returns the calling thread's storage.

The dynamic-lookup case was verified end to end the same way: lld emits
the same single __got slot and the same instructions as ld64, and dyld
binds it to the descriptor with per-thread reads resolving correctly.
---
 lld/MachO/InputSection.cpp                    | 18 ++---
 lld/MachO/MapFile.cpp                         |  2 +-
 lld/MachO/Relocations.cpp                     | 21 ++++--
 lld/MachO/Symbols.cpp                         | 10 +--
 lld/MachO/Symbols.h                           | 23 +++---
 lld/MachO/SyntheticSections.cpp               |  9 +--
 lld/MachO/SyntheticSections.h                 | 18 +----
 lld/MachO/Writer.cpp                          | 20 +++++-
 lld/test/MachO/got-to-tlv-reference.s         | 71 +++++++++++++++++++
 .../invalid/bad-got-to-dylib-tlv-reference.s  | 23 ------
 .../MachO/invalid/bad-got-to-tlv-reference.s  | 14 ----
 lld/test/MachO/tlv-dynamic-lookup-x86.s       | 15 ++--
 lld/test/MachO/tlv-dynamic-lookup.s           | 59 ++++++++-------
 13 files changed, 175 insertions(+), 128 deletions(-)
 create mode 100644 lld/test/MachO/got-to-tlv-reference.s
 delete mode 100644 lld/test/MachO/invalid/bad-got-to-dylib-tlv-reference.s
 delete mode 100644 lld/test/MachO/invalid/bad-got-to-tlv-reference.s

diff --git a/lld/MachO/InputSection.cpp b/lld/MachO/InputSection.cpp
index d2841a0f73c39..beeeb8e7d5c64 100644
--- a/lld/MachO/InputSection.cpp
+++ b/lld/MachO/InputSection.cpp
@@ -100,10 +100,11 @@ uint64_t macho::resolveSymbolOffsetVA(const Symbol *sym, uint8_t type,
     // function's layout, which an interposed replacement wouldn't preserve.
     // There's no meaningful way to "interpose" an interior offset.
     symVA = (offset != 0) ? sym->getVA() : sym->resolveBranchVA();
-  } else if (relocAttrs.hasAttr(RelocAttrBits::GOT)) {
-    symVA = sym->resolveGotVA();
-  } else if (relocAttrs.hasAttr(RelocAttrBits::TLV)) {
-    symVA = sym->resolveTlvVA();
+  } else if (relocAttrs.hasAttr(RelocAttrBits::GOT) ||
+             relocAttrs.hasAttr(RelocAttrBits::TLV)) {
+    // Both kinds read the symbol's single non-lazy pointer slot; for a
+    // thread-local that slot holds the address of its TLV descriptor.
+    symVA = sym->resolveNonLazyPtrVA();
   } else {
     symVA = sym->getVA();
   }
@@ -248,13 +249,8 @@ void ConcatInputSection::writeTo(uint8_t *buf) {
       }
       referentVA = minuendVA - fromSym->getVA();
     } else if (auto *referentSym = r.referent.dyn_cast<Symbol *>()) {
-      // Relaxing a load means "there is no slot; compute the address
-      // directly". Ask about the section this relocation actually uses:
-      // __thread_ptrs for TLV relocations, __got for everything else.
-      bool hasSlot = target->hasAttr(r.type, RelocAttrBits::TLV)
-                         ? referentSym->isInTlvPointers()
-                         : referentSym->isInGot();
-      if (target->hasAttr(r.type, RelocAttrBits::LOAD) && !hasSlot)
+      if (target->hasAttr(r.type, RelocAttrBits::LOAD) &&
+          !referentSym->isInGot())
         target->relaxGotLoad(loc, r.type);
       // For dtrace symbols, do not handle them as normal undefined symbols
       if (referentSym->getName().starts_with("___dtrace_")) {
diff --git a/lld/MachO/MapFile.cpp b/lld/MachO/MapFile.cpp
index 4d5e543c24c8d..29ebcdcf9a832 100644
--- a/lld/MachO/MapFile.cpp
+++ b/lld/MachO/MapFile.cpp
@@ -149,7 +149,7 @@ static void printNonLazyPointerSection(raw_fd_ostream &os,
   // associations.
   for (const Symbol *sym : osec->getEntries())
     os << format("0x%08llX\t0x%08zX\t[  0] non-lazy-pointer-to-local: %s\n",
-                 osec->addr + osec->getIndex(*sym) * target->wordSize,
+                 osec->addr + sym->gotIndex * target->wordSize,
                  target->wordSize, sym->getName().str().data());
 }
 
diff --git a/lld/MachO/Relocations.cpp b/lld/MachO/Relocations.cpp
index 319443b631a66..09032cea21820 100644
--- a/lld/MachO/Relocations.cpp
+++ b/lld/MachO/Relocations.cpp
@@ -68,14 +68,25 @@ bool macho::validateSymbolRelocation(const Symbol *sym,
         .str();
   };
 
-  // A dynamic-lookup symbol's thread-locality is unknown at link time but
-  // resolved by dyld at load time; rejecting it would refuse a valid link.
+  // Only one direction of the TLV/non-TLV mismatch is an error.
+  //
+  // A GOT relocation against a thread-local is fine: the slot holds the
+  // address of the TLV descriptor, which is what such a reference asks for.
+  // ld64 accepts it too.
+  //
+  // The reverse -- a TLV relocation against a symbol that is not
+  // thread-local -- is a real bug: the TLV sequence would call the referent's
+  // first word as if it were a descriptor's resolver function. But a
+  // dynamic-lookup symbol has no defining dylib and Mach-O cannot express
+  // thread-locality on an undefined reference, so `isTlv()` is false merely
+  // because nothing better is available. dyld resolves it at load time;
+  // rejecting it here would refuse a valid link.
   const auto *dysym = dyn_cast<DylibSymbol>(sym);
   bool tlvKindIsKnown = !(dysym && dysym->isDynamicLookup());
 
-  if (tlvKindIsKnown && relocAttrs.hasAttr(RelocAttrBits::TLV) != sym->isTlv())
-    error(message(Twine("requires that symbol ") + sym->getName() + " " +
-                  (sym->isTlv() ? "not " : "") + "be thread-local"));
+  if (tlvKindIsKnown && relocAttrs.hasAttr(RelocAttrBits::TLV) && !sym->isTlv())
+    error(message(Twine("requires that symbol ") + sym->getName() +
+                  " be thread-local"));
 
   return valid;
 }
diff --git a/lld/MachO/Symbols.cpp b/lld/MachO/Symbols.cpp
index b622dbcb1901a..27419caf9de1e 100644
--- a/lld/MachO/Symbols.cpp
+++ b/lld/MachO/Symbols.cpp
@@ -50,15 +50,7 @@ uint64_t Symbol::getLazyPtrVA() const {
   return in.lazyPointers->getVA(stubsIndex);
 }
 uint64_t Symbol::getGotVA() const { return in.got->getVA(gotIndex); }
-bool Symbol::isInTlvPointers() const {
-  return in.tlvPointers->getIndex(*this) != UINT32_MAX;
-}
-uint64_t Symbol::getTlvVA() const {
-  return in.tlvPointers->getVA(in.tlvPointers->getIndex(*this));
-}
-uint64_t Symbol::resolveTlvVA() const {
-  return isInTlvPointers() ? getTlvVA() : getVA();
-}
+uint64_t Symbol::getTlvVA() const { return in.tlvPointers->getVA(gotIndex); }
 
 Defined::Defined(StringRef name, InputFile *file, InputSection *isec,
                  uint64_t value, uint64_t size, bool isWeakDef, bool isExternal,
diff --git a/lld/MachO/Symbols.h b/lld/MachO/Symbols.h
index 6c89e7a77266a..93a4a453dfb2a 100644
--- a/lld/MachO/Symbols.h
+++ b/lld/MachO/Symbols.h
@@ -65,14 +65,11 @@ class Symbol {
 
   virtual bool isTlv() const { return false; }
 
-  // Whether this symbol has a __got slot.
+  // Whether this symbol has a non-lazy pointer slot. A symbol gets at most
+  // one: in __thread_ptrs if it is a known thread-local, in __got otherwise.
+  // See Writer::addNonLazyPointerEntry.
   bool isInGot() const { return gotIndex != UINT32_MAX; }
 
-  // Whether this symbol has a __thread_ptrs slot. Unlike the GOT index this
-  // is kept in a side table owned by TlvPointerSection: thread-local imports
-  // are rare, and Symbol is instantiated in the millions.
-  bool isInTlvPointers() const;
-
   // Whether this symbol is in the StubsSection.
   bool isInStubs() const { return stubsIndex != UINT32_MAX; }
 
@@ -84,10 +81,18 @@ class Symbol {
     assert(isa<Defined>(this) || isa<DylibSymbol>(this));
     return isInStubs() ? getStubVA() : getVA();
   }
-  uint64_t resolveGotVA() const { return isInGot() ? getGotVA() : getVA(); }
-  uint64_t resolveTlvVA() const;
+  // The address of this symbol's non-lazy pointer slot, or the symbol's own
+  // address if it has none. A thread-local's slot holds the address of its
+  // TLV descriptor, which is also what a GOT reference to it wants, so GOT
+  // and TLV relocations both resolve through here and share the one slot.
+  uint64_t resolveNonLazyPtrVA() const {
+    if (!isInGot())
+      return getVA();
+    return isTlv() ? getTlvVA() : getGotVA();
+  }
 
-  // The index of this symbol in the GOT.
+  // The index of this symbol's non-lazy pointer slot within whichever of
+  // __got / __thread_ptrs holds it.
   uint32_t gotIndex = UINT32_MAX;
   uint32_t lazyBindOffset = UINT32_MAX;
   uint32_t stubsHelperIndex = UINT32_MAX;
diff --git a/lld/MachO/SyntheticSections.cpp b/lld/MachO/SyntheticSections.cpp
index 8503e0ed6a452..3d57bedae342e 100644
--- a/lld/MachO/SyntheticSections.cpp
+++ b/lld/MachO/SyntheticSections.cpp
@@ -338,11 +338,12 @@ void macho::addNonLazyBindingEntries(const Symbol *sym,
 
 void NonLazyPointerSectionBase::addEntry(Symbol *sym) {
   if (entries.insert(sym)) {
-    assert(getIndex(*sym) == UINT32_MAX);
-    uint32_t index = entries.size() - 1;
-    setIndex(*sym, index);
+    // A symbol belongs to at most one non-lazy pointer section, so the index
+    // is unambiguous even though both sections share the field.
+    assert(!sym->isInGot());
+    sym->gotIndex = entries.size() - 1;
 
-    addNonLazyBindingEntries(sym, isec, index * target->wordSize);
+    addNonLazyBindingEntries(sym, isec, sym->gotIndex * target->wordSize);
   }
 }
 
diff --git a/lld/MachO/SyntheticSections.h b/lld/MachO/SyntheticSections.h
index 5c9006d3104b6..9acbd73d277e8 100644
--- a/lld/MachO/SyntheticSections.h
+++ b/lld/MachO/SyntheticSections.h
@@ -120,13 +120,9 @@ class NonLazyPointerSectionBase : public SyntheticSection {
   }
   void writeTo(uint8_t *buf) const override;
   void addEntry(Symbol *sym);
-  uint64_t getVA(uint32_t index) const {
-    return addr + index * target->wordSize;
+  uint64_t getVA(uint32_t gotIndex) const {
+    return addr + gotIndex * target->wordSize;
   }
-  // This symbol's slot index within this section, or UINT32_MAX. __got and
-  // __thread_ptrs track indices separately so a symbol may appear in both.
-  virtual uint32_t getIndex(const Symbol &sym) const = 0;
-  virtual void setIndex(Symbol &sym, uint32_t index) = 0;
 
 private:
   llvm::SetVector<const Symbol *> entries;
@@ -135,21 +131,11 @@ class NonLazyPointerSectionBase : public SyntheticSection {
 class GotSection final : public NonLazyPointerSectionBase {
 public:
   GotSection();
-  uint32_t getIndex(const Symbol &sym) const override { return sym.gotIndex; }
-  void setIndex(Symbol &sym, uint32_t index) override { sym.gotIndex = index; }
 };
 
 class TlvPointerSection final : public NonLazyPointerSectionBase {
 public:
   TlvPointerSection();
-  uint32_t getIndex(const Symbol &sym) const override {
-    auto it = indices.find(&sym);
-    return it == indices.end() ? UINT32_MAX : it->second;
-  }
-  void setIndex(Symbol &sym, uint32_t index) override { indices[&sym] = index; }
-
-private:
-  llvm::DenseMap<const Symbol *, uint32_t> indices;
 };
 
 struct Location {
diff --git a/lld/MachO/Writer.cpp b/lld/MachO/Writer.cpp
index 49415ccb70af8..152ef39b0933f 100644
--- a/lld/MachO/Writer.cpp
+++ b/lld/MachO/Writer.cpp
@@ -662,6 +662,22 @@ void Writer::treatSpecialUndefineds() {
   }
 }
 
+// Give a symbol its single non-lazy pointer slot. For a thread-local both a
+// __thread_ptrs slot and a __got slot would hold the very same value -- the
+// address of its TLV descriptor -- so a symbol referenced both ways needs
+// only one of them, and every reference can read it. ld64 likewise emits a
+// single slot for such a symbol.
+//
+// A dynamic-lookup symbol's thread-locality is unknowable at link time, so it
+// lands in __got even when reached through a TLV relocation; ld64 lowers that
+// case the same way. dyld binds the slot to the descriptor either way.
+static void addNonLazyPointerEntry(Symbol *sym) {
+  if (sym->isTlv())
+    in.tlvPointers->addEntry(sym);
+  else
+    in.got->addEntry(sym);
+}
+
 static void prepareSymbolRelocation(Symbol *sym, const InputSection *isec,
                                     const Relocation &r) {
   if (!sym->isLive()) {
@@ -682,10 +698,10 @@ static void prepareSymbolRelocation(Symbol *sym, const InputSection *isec,
       in.stubs->addEntry(sym);
   } else if (relocAttrs.hasAttr(RelocAttrBits::GOT)) {
     if (relocAttrs.hasAttr(RelocAttrBits::POINTER) || needsBinding(sym))
-      in.got->addEntry(sym);
+      addNonLazyPointerEntry(sym);
   } else if (relocAttrs.hasAttr(RelocAttrBits::TLV)) {
     if (needsBinding(sym))
-      in.tlvPointers->addEntry(sym);
+      addNonLazyPointerEntry(sym);
   } else if (relocAttrs.hasAttr(RelocAttrBits::UNSIGNED)) {
     // References from thread-local variable sections are treated as offsets
     // relative to the start of the referent section, and therefore have no
diff --git a/lld/test/MachO/got-to-tlv-reference.s b/lld/test/MachO/got-to-tlv-reference.s
new file mode 100644
index 0000000000000..25e1fd69f5631
--- /dev/null
+++ b/lld/test/MachO/got-to-tlv-reference.s
@@ -0,0 +1,71 @@
+# REQUIRES: x86
+# RUN: rm -rf %t; split-file %s %t
+
+## A GOT relocation against a thread-local asks for the address of that
+## symbol's TLV descriptor, which is what its non-lazy pointer slot holds.
+## lld used to reject this; ld64 links every case below without a diagnostic.
+
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/deftlv.s -o %t/deftlv.o
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/libtlv.s -o %t/libtlv.o
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/got.s -o %t/got.o
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/both.s -o %t/both.o
+
+## A definition in the same image needs no slot: the load relaxes to a direct
+## address computation.
+# RUN: %lld -dylib -o %t/deftlv.dylib %t/deftlv.o
+# RUN: llvm-objdump --macho --section-headers -d --no-show-raw-insn %t/deftlv.dylib | \
+# RUN:   FileCheck %s --check-prefix=DEF
+# DEF-NOT:   __got
+# DEF-NOT:   __thread_ptrs
+# DEF-LABEL: _main:
+# DEF-NEXT:  leaq _foo(%rip), %rax
+
+# RUN: %lld -dylib -install_name @executable_path/libtlv.dylib -lSystem \
+# RUN:   -o %t/libtlv.dylib %t/libtlv.o
+
+## Imported from a dylib, the reference binds through __thread_ptrs -- the
+## section lld uses for a symbol it knows to be thread-local. ld64 uses __got
+## here; both hold the descriptor's address.
+# RUN: %lld -dylib -lSystem -L%t -ltlv -o %t/got.dylib %t/got.o
+# RUN: llvm-objdump --macho --bind %t/got.dylib | FileCheck %s --check-prefix=GOT
+# GOT: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 libtlv _foo
+
+## Reaching one thread-local both ways must produce a single slot. A second
+## one would show up as a __got section alongside __thread_ptrs.
+# RUN: %lld -dylib -lSystem -L%t -ltlv -o %t/both.dylib %t/both.o
+# RUN: llvm-objdump --macho --section-headers --bind %t/both.dylib | \
+# RUN:   FileCheck %s --check-prefix=BOTH
+# BOTH-NOT:   __got
+# BOTH:       __thread_ptrs
+# BOTH-LABEL: Bind table:
+# BOTH:       __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 libtlv _foo
+# BOTH-NOT:   _foo
+
+#--- deftlv.s
+.text
+.globl _main
+_main:
+  movq _foo at GOTPCREL(%rip), %rax
+  ret
+.section __DATA,__thread_vars,thread_local_variables
+_foo:
+
+#--- libtlv.s
+.section __DATA,__thread_vars,thread_local_variables
+.globl _foo
+_foo:
+
+#--- got.s
+.text
+.globl _main
+_main:
+  movq _foo at GOTPCREL(%rip), %rax
+  ret
+
+#--- both.s
+.text
+.globl _main
+_main:
+  movq _foo at GOTPCREL(%rip), %rax
+  movq _foo at TLVP(%rip), %rcx
+  ret
diff --git a/lld/test/MachO/invalid/bad-got-to-dylib-tlv-reference.s b/lld/test/MachO/invalid/bad-got-to-dylib-tlv-reference.s
deleted file mode 100644
index 463a044465172..0000000000000
--- a/lld/test/MachO/invalid/bad-got-to-dylib-tlv-reference.s
+++ /dev/null
@@ -1,23 +0,0 @@
-# REQUIRES: x86
-# RUN: rm -rf %t; split-file %s %t
-
-# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/libtlv.s -o %t/libtlv.o
-# RUN: %lld -dylib -install_name @executable_path/libtlv.dylib \
-# RUN:   -lSystem -o %t/libtlv.dylib %t/libtlv.o
-
-# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/test.s -o %t/test.o
-# RUN: not %lld -lSystem -L%t -ltlv -o /dev/null %t/test.o 2>&1 | FileCheck %s -DFILE=%t/test.o
-
-# CHECK: error: [[FILE]]:(symbol _main+0x3): GOT_LOAD relocation requires that symbol _foo not be thread-local
-
-#--- libtlv.s
-.section __DATA,__thread_vars,thread_local_variables
-.globl _foo
-_foo:
-
-#--- test.s
-.text
-.globl _main
-_main:
-  movq _foo at GOTPCREL(%rip), %rax
-  ret
diff --git a/lld/test/MachO/invalid/bad-got-to-tlv-reference.s b/lld/test/MachO/invalid/bad-got-to-tlv-reference.s
deleted file mode 100644
index 8934bc892a474..0000000000000
--- a/lld/test/MachO/invalid/bad-got-to-tlv-reference.s
+++ /dev/null
@@ -1,14 +0,0 @@
-# REQUIRES: x86
-# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %s -o %t.o
-# RUN: not %lld -o /dev/null %t.o 2>&1 | FileCheck %s -DFILE=%t.o
-
-# CHECK: error: [[FILE]]:(symbol _main+0x3): GOT_LOAD relocation requires that symbol _foo not be thread-local
-
-.text
-.globl _main
-_main:
-  movq _foo at GOTPCREL(%rip), %rax
-  ret
-
-.section __DATA,__thread_vars,thread_local_variables
-_foo:
diff --git a/lld/test/MachO/tlv-dynamic-lookup-x86.s b/lld/test/MachO/tlv-dynamic-lookup-x86.s
index 80ef492218373..c39622c4fd169 100644
--- a/lld/test/MachO/tlv-dynamic-lookup-x86.s
+++ b/lld/test/MachO/tlv-dynamic-lookup-x86.s
@@ -9,15 +9,16 @@
 # RUN:   FileCheck %s --check-prefix=MOVQ
 # MOVQ-LABEL: _f:
 # MOVQ-NEXT:  movq
-# MOVQ:       __thread_ptrs
+# MOVQ-NOT:   __thread_ptrs
+# MOVQ:       __got
 # MOVQ-LABEL: Bind table:
-# MOVQ:       __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# MOVQ:       __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
 
-## FIXME: leaq computes the address of the __thread_ptrs slot, but the ABI
-## requires the descriptor the slot holds, so this is wrong code. It is accepted
-## because X86_64::relaxGotLoad's MOVQ check only runs on the relax path, and a
-## slot exists here. Pre-existing and not specific to dynamic lookup: the same
-## leaq against a dylib that really exports _tlv as thread-local miscompiles
+## FIXME: leaq computes the address of the slot itself, but the ABI requires the
+## descriptor the slot holds, so this is wrong code. It is accepted because
+## X86_64::relaxGotLoad's MOVQ check only runs on the relax path, and a slot
+## exists here. Pre-existing and not specific to dynamic lookup: the same leaq
+## against a dylib that really exports _tlv as thread-local miscompiles
 ## identically on stock lld. Pinned as-is so the separate fix has a baseline.
 # RUN: %lld -dylib -undefined dynamic_lookup -o %t/leaq.dylib %t/leaq.o
 # RUN: llvm-objdump --macho -d --no-show-raw-insn %t/leaq.dylib | \
diff --git a/lld/test/MachO/tlv-dynamic-lookup.s b/lld/test/MachO/tlv-dynamic-lookup.s
index 96fb12e1541d1..3c1a147ad7ea4 100644
--- a/lld/test/MachO/tlv-dynamic-lookup.s
+++ b/lld/test/MachO/tlv-dynamic-lookup.s
@@ -12,11 +12,14 @@
 # RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/deftlv.s -o %t/deftlv.o
 # RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/notlv.s -o %t/notlv.o
 
+## A dynamic-lookup symbol's thread-locality is unknown, so its slot goes in
+## __got. ld64 lowers this case identically.
 # RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -o %t/dylookup.dylib %t/consumer.o
 # RUN: llvm-objdump --macho --section-headers --bind %t/dylookup.dylib | FileCheck %s
-# CHECK:       __thread_ptrs
+# CHECK-NOT:   __thread_ptrs
+# CHECK:       __got
 # CHECK-LABEL: Bind table:
-# CHECK:       __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# CHECK:       __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
 
 ## -U is per-symbol: _tlv is exempt, _other is left undefined.
 # RUN: not %lld -arch arm64 -dylib -U _tlv -o /dev/null %t/twotlv.o 2>&1 | \
@@ -27,22 +30,21 @@
 
 # RUN: %lld -arch arm64 -dylib -U _tlv -U _other -o %t/u.dylib %t/twotlv.o
 # RUN: llvm-objdump --macho --bind %t/u.dylib | FileCheck %s --check-prefix=UBOTH
-# UBOTH-DAG: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
-# UBOTH-DAG: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _other
+# UBOTH-DAG: __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# UBOTH-DAG: __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _other
 
 # RUN: %no-fatal-warnings-lld -arch arm64 -dylib -flat_namespace -undefined suppress \
 # RUN:   -o %t/flat.dylib %t/consumer.o
 # RUN: llvm-objdump --macho --bind %t/flat.dylib | FileCheck %s --check-prefix=FLAT
-# FLAT: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# FLAT: __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
 
-## Chained fixups must produce a __thread_ptrs slot, not just a flat import --
-## a GOT-only binary yields a byte-identical import table.
+## Chained fixups must produce a real slot, not just a flat import.
 # RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -fixup_chains \
 # RUN:   -o %t/chained.dylib %t/consumer.o
 # RUN: llvm-objdump --macho --section-headers --chained-fixups %t/chained.dylib | \
 # RUN:   FileCheck %s --check-prefix=CHAINED
-# CHAINED:      __thread_ptrs
-# CHAINED-NOT:  __got
+# CHAINED-NOT:  __thread_ptrs
+# CHAINED:      __got
 # CHAINED:      lib_ordinal = -2 (flat-namespace)
 # CHAINED:      _tlv
 
@@ -71,38 +73,38 @@
 
 ## The remaining cases each reach NonLazyPointerSectionBase::addEntry by a route
 ## that does not pass through prepareSymbolRelocation. None may abort the
-## linker. Where lld synthesizes the competing entry itself the user has no way
-## to act on a diagnostic, so these must link.
+## linker: the symbol already has a __got slot by the time the TLV reference
+## asks for one, so addEntry must coalesce rather than allocate a second.
 
 ## StubsSection::addEntry -> in.got->addEntry under chained fixups, which is the
 ## default at iOS 16 / macOS 13.
 ## Chained fixups records binds in LC_DYLD_CHAINED_FIXUPS, so --bind is empty
-## here; the stub's __got slot and the TLV's __thread_ptrs slot must coexist.
+## here; the stub and the TLV reference share the one __got slot.
 # RUN: %no-arg-lld -arch arm64 -platform_version ios 16.0 16.0 -dylib \
 # RUN:   -undefined dynamic_lookup -o %t/branch-chained.dylib %t/branch.o
 # RUN: llvm-objdump --macho --section-headers --chained-fixups %t/branch-chained.dylib | \
 # RUN:   FileCheck %s --check-prefix=BRANCHC
-# BRANCHC-DAG: __got
-# BRANCHC-DAG: __thread_ptrs
+# BRANCHC-NOT: __thread_ptrs
+# BRANCHC:     __got
 # BRANCHC:     lib_ordinal = -2 (flat-namespace)
 
 ## Same source without chained fixups must not reach a different verdict.
 # RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -no_fixup_chains \
 # RUN:   -o %t/branch-lazy.dylib %t/branch.o
 # RUN: llvm-objdump --macho --bind %t/branch-lazy.dylib | FileCheck %s --check-prefix=BRANCH
-# BRANCH: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# BRANCH: __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
 
 ## UnwindInfoSection::prepare, after the relocation scan.
 # RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -o %t/cfi.dylib %t/cfi.o
 # RUN: llvm-objdump --macho --bind %t/cfi.dylib | FileCheck %s --check-prefix=CFI
-# CFI: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _pers
+# CFI: __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _pers
 
 ## StubHelperSection::setUp, which runs after Writer::run's errorCount() bail
 ## and so can never be protected by an error() emitted earlier.
 # RUN: %no-lsystem-lld -arch arm64 -dylib -undefined dynamic_lookup -no_fixup_chains \
 # RUN:   -o %t/binder.dylib %t/binder.o
 # RUN: llvm-objdump --macho --bind %t/binder.dylib | FileCheck %s --check-prefix=BINDER
-# BINDER: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace dyld_stub_binder
+# BINDER: __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace dyld_stub_binder
 
 ## InitOffsetsSection::setUp.
 # RUN: %no-arg-lld -arch arm64 -platform_version macos 13.0 13.0 \
@@ -110,8 +112,8 @@
 # RUN:   -init_offsets -o %t/initoff.dylib %t/initoff.o
 # RUN: llvm-objdump --macho --section-headers --chained-fixups %t/initoff.dylib | \
 # RUN:   FileCheck %s --check-prefix=INITOFF
-# INITOFF-DAG: __got
-# INITOFF-DAG: __thread_ptrs
+# INITOFF-NOT: __thread_ptrs
+# INITOFF:     __got
 # INITOFF:     lib_ordinal = -2 (flat-namespace)
 # INITOFF:     _ctor
 
@@ -122,8 +124,8 @@
 # RUN:   -o %t/ep.out %t/ep.o
 # RUN: llvm-objdump --macho --section-headers --chained-fixups %t/ep.out | \
 # RUN:   FileCheck %s --check-prefix=EP
-# EP-DAG: __got
-# EP-DAG: __thread_ptrs
+# EP-NOT: __thread_ptrs
+# EP:     __got
 # EP:     lib_ordinal = -2 (flat-namespace)
 # EP:     _ep
 
@@ -135,14 +137,17 @@
 # RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -o %t/unsmix.dylib %t/unsmix.o
 # RUN: llvm-objdump --macho --bind %t/unsmix.dylib | FileCheck %s --check-prefix=UNS
 # UNS-DAG: __DATA __data 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
-# UNS-DAG: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# UNS-DAG: __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
 
-## JUDGMENT CALL. Same question for a GOT reference, which does allocate a slot.
-## Treated the same way for consistency with UNS and BRANCH above.
+## Referencing one symbol both ways yields a single slot, as ld64 emits here.
 # RUN: %lld -arch arm64 -dylib -undefined dynamic_lookup -o %t/mixed.dylib %t/mixed.o
-# RUN: llvm-objdump --macho --bind %t/mixed.dylib | FileCheck %s --check-prefix=MIXED
-# MIXED-DAG: __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
-# MIXED-DAG: __DATA __thread_ptrs 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# RUN: llvm-objdump --macho --section-headers --bind %t/mixed.dylib | \
+# RUN:   FileCheck %s --check-prefix=MIXED
+# MIXED-NOT:   __thread_ptrs
+# MIXED:       __got
+# MIXED-LABEL: Bind table:
+# MIXED:       __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+# MIXED-NOT:   _tlv
 
 #--- consumer.s
 .globl _readTlv



More information about the llvm-commits mailing list