[lld] [lld][MachO] Remove `__thread_ptrs` section so TLV can relocate against dynamic-lookup symbols (PR #221364)

Peter Rong via llvm-commits llvm-commits at lists.llvm.org
Fri Sep 11 13:56:26 PDT 2026


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

>From 554fc86d71c4900ef20f06309297676cdb4d48ad 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/4] [lld][MachO] Match ld-prime non-lazy TLV pointers

ld-prime emits one __got entry for an imported TLV descriptor, regardless of whether it is reached through GOT, TLV, stub, or unwind references. Routing known TLVs to __thread_ptrs left direct GOT consumers able to collide with the shared gotIndex and also produced a different section layout.

Use __got as the sole non-lazy pointer section, including for TLV descriptors. Preserve valid descriptor references through GOT, branch, and unsigned relocations, while rejecting direct code relocations to imported TLVs, which have no link-time address.

Add arm64 and x86_64 coverage for pure and mixed references, chained-fixup stubs, compact-unwind personalities, unsigned descriptor pointers, and local versus imported direct references.
---
 lld/MachO/InputSection.cpp                    |   9 +-
 lld/MachO/InputSection.h                      |   1 -
 lld/MachO/MapFile.cpp                         |   5 +-
 lld/MachO/Relocations.cpp                     |  25 +-
 lld/MachO/Symbols.cpp                         |   1 -
 lld/MachO/Symbols.h                           |  18 +-
 lld/MachO/SyntheticSections.cpp               |  35 +--
 lld/MachO/SyntheticSections.h                 |  21 +-
 lld/MachO/Writer.cpp                          |  13 +-
 lld/test/MachO/arm64-reloc-tlv-load.s         |   2 +-
 lld/test/MachO/flat-namespace-interposable.s  |   5 +-
 lld/test/MachO/got-to-tlv-reference.s         | 128 +++++++++
 lld/test/MachO/indirect-symtab.s              |   9 +-
 .../invalid/bad-got-to-dylib-tlv-reference.s  |  23 --
 .../MachO/invalid/bad-got-to-tlv-reference.s  |  14 -
 lld/test/MachO/map-file.s                     |  12 +-
 lld/test/MachO/tapi-link.s                    |   2 +-
 lld/test/MachO/tlv-dylib.s                    |  17 +-
 lld/test/MachO/tlv-dynamic-lookup-x86.s       |  52 ++++
 lld/test/MachO/tlv-dynamic-lookup.s           | 265 ++++++++++++++++++
 lld/test/MachO/tlv-non-lazy-pointer.s         | 170 +++++++++++
 lld/test/MachO/weak-binding.s                 |  10 +-
 lld/test/MachO/weak-reference.s               |   8 +-
 23 files changed, 703 insertions(+), 142 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
 create mode 100644 lld/test/MachO/tlv-dynamic-lookup-x86.s
 create mode 100644 lld/test/MachO/tlv-dynamic-lookup.s
 create mode 100644 lld/test/MachO/tlv-non-lazy-pointer.s

diff --git a/lld/MachO/InputSection.cpp b/lld/MachO/InputSection.cpp
index d977830161a8e..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();
   }
diff --git a/lld/MachO/InputSection.h b/lld/MachO/InputSection.h
index 8fcd16a1de35f..49ea57a24129c 100644
--- a/lld/MachO/InputSection.h
+++ b/lld/MachO/InputSection.h
@@ -375,7 +375,6 @@ constexpr const char swift[] = "__swift";
 constexpr const char symbolTable[] = "__symbol_table";
 constexpr const char textCoalNt[] = "__textcoal_nt";
 constexpr const char text[] = "__text";
-constexpr const char threadPtrs[] = "__thread_ptrs";
 constexpr const char threadVars[] = "__thread_vars";
 constexpr const char unwindInfo[] = "__unwind_info";
 constexpr const char weakBinding[] = "__weak_binding";
diff --git a/lld/MachO/MapFile.cpp b/lld/MachO/MapFile.cpp
index 29ebcdcf9a832..f595852ddf81f 100644
--- a/lld/MachO/MapFile.cpp
+++ b/lld/MachO/MapFile.cpp
@@ -141,8 +141,7 @@ static void printStubsEntries(
                  sym->getName().str().data());
 }
 
-static void printNonLazyPointerSection(raw_fd_ostream &os,
-                                       NonLazyPointerSectionBase *osec) {
+static void printNonLazyPointerSection(raw_fd_ostream &os, GotSection *osec) {
   // ld64 considers stubs to belong to particular files, but considers GOT
   // entries to be linker-synthesized. Not sure why they made that decision, but
   // I think we can follow suit unless there's demand for better symbol-to-file
@@ -265,8 +264,6 @@ void macho::writeMapFile() {
                      osec->getSize());
       } else if (osec == in.got) {
         printNonLazyPointerSection(os, in.got);
-      } else if (osec == in.tlvPointers) {
-        printNonLazyPointerSection(os, in.tlvPointers);
       } else if (osec == in.objcMethList) {
         printIsecArrSyms(in.objcMethList->getInputs());
       }
diff --git a/lld/MachO/Relocations.cpp b/lld/MachO/Relocations.cpp
index 0945e7a96dee2..f06c5540829ec 100644
--- a/lld/MachO/Relocations.cpp
+++ b/lld/MachO/Relocations.cpp
@@ -68,7 +68,30 @@ bool macho::validateSymbolRelocation(const Symbol *sym,
         .str();
   };
 
-  if (relocAttrs.hasAttr(RelocAttrBits::TLV) != sym->isTlv())
+  // A GOT relocation against a thread-local is valid: the slot holds the
+  // address of the TLV descriptor, which is what such a reference asks for.
+  // Branch and unsigned relocations can likewise refer to the descriptor via
+  // a stub or pointer. ld-prime accepts all three kinds.
+  //
+  // A direct relocation against an imported TLV is not valid because an
+  // imported descriptor has no link-time address. Conversely, a TLV
+  // relocation against a symbol known not to be thread-local would interpret
+  // the referent's first word as a resolver function. Keep rejecting both.
+  //
+  // A dynamic-lookup symbol has no defining dylib, and Mach-O cannot express
+  // thread-locality on an undefined reference, so its kind is unknowable here.
+  // dyld resolves it at load time; rejecting it would refuse a valid link.
+  const auto *dysym = dyn_cast<DylibSymbol>(sym);
+  const bool tlvKindIsKnown = !(dysym && dysym->isDynamicLookup());
+  const bool isTlvReloc = relocAttrs.hasAttr(RelocAttrBits::TLV);
+  const bool permitsTlvDescriptor = isTlvReloc ||
+                                    relocAttrs.hasAttr(RelocAttrBits::GOT) ||
+                                    relocAttrs.hasAttr(RelocAttrBits::BRANCH) ||
+                                    relocAttrs.hasAttr(RelocAttrBits::UNSIGNED);
+  const bool isImportedTlv = isa<DylibSymbol>(sym) && sym->isTlv();
+
+  if (tlvKindIsKnown && ((isTlvReloc && !sym->isTlv()) ||
+                         (isImportedTlv && !permitsTlvDescriptor)))
     error(message(Twine("requires that symbol ") + sym->getName() + " " +
                   (sym->isTlv() ? "not " : "") + "be thread-local"));
 
diff --git a/lld/MachO/Symbols.cpp b/lld/MachO/Symbols.cpp
index 27419caf9de1e..c743697e4041d 100644
--- a/lld/MachO/Symbols.cpp
+++ b/lld/MachO/Symbols.cpp
@@ -50,7 +50,6 @@ 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); }
 
 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..6b5917e70cd81 100644
--- a/lld/MachO/Symbols.h
+++ b/lld/MachO/Symbols.h
@@ -65,7 +65,9 @@ class Symbol {
 
   virtual bool isTlv() const { return false; }
 
-  // Whether this symbol is in the GOT or TLVPointer sections.
+  // Whether this symbol has a non-lazy pointer slot. A symbol gets at most
+  // one, always in __got (including thread-local symbols).
+  // See Writer::addNonLazyPointerEntry.
   bool isInGot() const { return gotIndex != UINT32_MAX; }
 
   // Whether this symbol is in the StubsSection.
@@ -74,17 +76,19 @@ class Symbol {
   uint64_t getStubVA() const;
   uint64_t getLazyPtrVA() const;
   uint64_t getGotVA() const;
-  uint64_t getTlvVA() const;
   uint64_t resolveBranchVA() const {
     assert(isa<Defined>(this) || isa<DylibSymbol>(this));
     return isInStubs() ? getStubVA() : getVA();
   }
-  uint64_t resolveGotVA() const { return isInGot() ? getGotVA() : getVA(); }
-  uint64_t resolveTlvVA() const { return isInGot() ? getTlvVA() : getVA(); }
+  // 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 the same __got entry.
+  uint64_t resolveNonLazyPtrVA() const {
+    return isInGot() ? getGotVA() : getVA();
+  }
 
-  // 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's non-lazy pointer slot in __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..b8a51ee8b47bf 100644
--- a/lld/MachO/SyntheticSections.cpp
+++ b/lld/MachO/SyntheticSections.cpp
@@ -300,10 +300,10 @@ void RebaseSection::writeTo(uint8_t *buf) const {
   memcpy(buf, contents.data(), contents.size());
 }
 
-NonLazyPointerSectionBase::NonLazyPointerSectionBase(const char *segname,
-                                                     const char *name)
-    : SyntheticSection(segname, name) {
+GotSection::GotSection()
+    : SyntheticSection(segment_names::data, section_names::got) {
   align = target->wordSize;
+  flags = S_NON_LAZY_SYMBOL_POINTERS;
 }
 
 void macho::addNonLazyBindingEntries(const Symbol *sym,
@@ -336,8 +336,9 @@ void macho::addNonLazyBindingEntries(const Symbol *sym,
   }
 }
 
-void NonLazyPointerSectionBase::addEntry(Symbol *sym) {
+void GotSection::addEntry(Symbol *sym) {
   if (entries.insert(sym)) {
+    // Every symbol has at most one non-lazy pointer slot.
     assert(!sym->isInGot());
     sym->gotIndex = entries.size() - 1;
 
@@ -382,7 +383,7 @@ void macho::writeChainedFixup(uint8_t *buf, const Symbol *sym, int64_t addend) {
     writeChainedRebase(buf, sym->getVA() + addend);
 }
 
-void NonLazyPointerSectionBase::writeTo(uint8_t *buf) const {
+void GotSection::writeTo(uint8_t *buf) const {
   if (config->emitChainedFixups) {
     for (const auto &[i, entry] : llvm::enumerate(entries))
       writeChainedFixup(&buf[i * target->wordSize], entry, 0);
@@ -393,17 +394,6 @@ void NonLazyPointerSectionBase::writeTo(uint8_t *buf) const {
   }
 }
 
-GotSection::GotSection()
-    : NonLazyPointerSectionBase(segment_names::data, section_names::got) {
-  flags = S_NON_LAZY_SYMBOL_POINTERS;
-}
-
-TlvPointerSection::TlvPointerSection()
-    : NonLazyPointerSectionBase(segment_names::data,
-                                section_names::threadPtrs) {
-  flags = S_THREAD_LOCAL_VARIABLE_POINTERS;
-}
-
 BindingSection::BindingSection()
     : LinkEditSection(segment_names::linkEdit, section_names::binding) {}
 
@@ -1497,25 +1487,20 @@ IndirectSymtabSection::IndirectSymtabSection()
                       section_names::indirectSymbolTable) {}
 
 uint32_t IndirectSymtabSection::getNumSymbols() const {
-  uint32_t size = in.got->getEntries().size() +
-                  in.tlvPointers->getEntries().size() +
-                  in.stubs->getEntries().size();
+  uint32_t size = in.got->getEntries().size() + in.stubs->getEntries().size();
   if (!config->emitChainedFixups)
     size += in.stubs->getEntries().size();
   return size;
 }
 
 bool IndirectSymtabSection::isNeeded() const {
-  return in.got->isNeeded() || in.tlvPointers->isNeeded() ||
-         in.stubs->isNeeded();
+  return in.got->isNeeded() || in.stubs->isNeeded();
 }
 
 void IndirectSymtabSection::finalizeContents() {
   uint32_t off = 0;
   in.got->reserved1 = off;
   off += in.got->getEntries().size();
-  in.tlvPointers->reserved1 = off;
-  off += in.tlvPointers->getEntries().size();
   in.stubs->reserved1 = off;
   if (in.lazyPointers) {
     off += in.stubs->getEntries().size();
@@ -1535,10 +1520,6 @@ void IndirectSymtabSection::writeTo(uint8_t *buf) const {
     write32le(buf + off * sizeof(uint32_t), indirectValue(sym));
     ++off;
   }
-  for (const Symbol *sym : in.tlvPointers->getEntries()) {
-    write32le(buf + off * sizeof(uint32_t), indirectValue(sym));
-    ++off;
-  }
   for (const Symbol *sym : in.stubs->getEntries()) {
     write32le(buf + off * sizeof(uint32_t), indirectValue(sym));
     ++off;
diff --git a/lld/MachO/SyntheticSections.h b/lld/MachO/SyntheticSections.h
index 9acbd73d277e8..3b8bed5c21ddb 100644
--- a/lld/MachO/SyntheticSections.h
+++ b/lld/MachO/SyntheticSections.h
@@ -106,13 +106,11 @@ class PageZeroSection final : public SyntheticSection {
   void writeTo(uint8_t *buf) const override {}
 };
 
-// This is the base class for the GOT and TLVPointer sections, which are nearly
-// functionally identical -- they will both be populated by dyld with addresses
-// to non-lazily-loaded dylib symbols. The main difference is that the
-// TLVPointerSection stores references to thread-local variables.
-class NonLazyPointerSectionBase : public SyntheticSection {
+// The __DATA_CONST,__got section, populated by dyld with addresses to
+// non-lazily-loaded dylib symbols, including TLV descriptors.
+class GotSection final : public SyntheticSection {
 public:
-  NonLazyPointerSectionBase(const char *segname, const char *name);
+  GotSection();
   const llvm::SetVector<const Symbol *> &getEntries() const { return entries; }
   bool isNeeded() const override { return !entries.empty(); }
   uint64_t getSize() const override {
@@ -128,16 +126,6 @@ class NonLazyPointerSectionBase : public SyntheticSection {
   llvm::SetVector<const Symbol *> entries;
 };
 
-class GotSection final : public NonLazyPointerSectionBase {
-public:
-  GotSection();
-};
-
-class TlvPointerSection final : public NonLazyPointerSectionBase {
-public:
-  TlvPointerSection();
-};
-
 struct Location {
   const InputSection *isec;
   uint64_t offset;
@@ -845,7 +833,6 @@ struct InStruct {
   LazyBindingSection *lazyBinding = nullptr;
   ExportSection *exports = nullptr;
   GotSection *got = nullptr;
-  TlvPointerSection *tlvPointers = nullptr;
   LazyPointerSection *lazyPointers = nullptr;
   StubsSection *stubs = nullptr;
   StubHelperSection *stubHelper = nullptr;
diff --git a/lld/MachO/Writer.cpp b/lld/MachO/Writer.cpp
index 49415ccb70af8..4af7ed150f694 100644
--- a/lld/MachO/Writer.cpp
+++ b/lld/MachO/Writer.cpp
@@ -662,6 +662,14 @@ void Writer::treatSpecialUndefineds() {
   }
 }
 
+// Give a symbol its single non-lazy pointer slot. For a thread-local,
+// __thread_ptrs and __got would hold the same value: the address of its TLV
+// descriptor. ld-prime canonicalizes both GOT and TLV references to one
+// __got entry, which also makes the choice independent of which reference is
+// scanned first. This is especially important for dynamic-lookup symbols,
+// whose thread-locality is unknowable until dyld binds the slot.
+static void addNonLazyPointerEntry(Symbol *sym) { in.got->addEntry(sym); }
+
 static void prepareSymbolRelocation(Symbol *sym, const InputSection *isec,
                                     const Relocation &r) {
   if (!sym->isLive()) {
@@ -682,10 +690,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
@@ -1440,7 +1448,6 @@ void macho::createSyntheticSections() {
   }
   in.exports = make<ExportSection>();
   in.got = make<GotSection>();
-  in.tlvPointers = make<TlvPointerSection>();
   in.stubs = make<StubsSection>();
   in.objcStubs = make<ObjCStubsSection>();
   in.unwindInfo = makeUnwindInfoSection();
diff --git a/lld/test/MachO/arm64-reloc-tlv-load.s b/lld/test/MachO/arm64-reloc-tlv-load.s
index c525f2db18816..d4cccff25742f 100644
--- a/lld/test/MachO/arm64-reloc-tlv-load.s
+++ b/lld/test/MachO/arm64-reloc-tlv-load.s
@@ -31,7 +31,7 @@
 # DYLIB-EMPTY:
 # DYLIB-NEXT:  Sections:
 # DYLIB-NEXT:  Idx   Name          Size     VMA              Type
-# DYLIB:       [[#]] __thread_ptrs 00000010 {{0*}}[[#TLV]]   DATA
+# DYLIB:       [[#]] __got         00000010 {{0*}}[[#TLV]]   DATA
 
 #--- main.s
 .globl _main, _foo, _bar
diff --git a/lld/test/MachO/flat-namespace-interposable.s b/lld/test/MachO/flat-namespace-interposable.s
index 950de32726d12..0eabc49a5fc54 100644
--- a/lld/test/MachO/flat-namespace-interposable.s
+++ b/lld/test/MachO/flat-namespace-interposable.s
@@ -67,13 +67,12 @@
 # DYLIB-DAG:   __DATA       __data           0x[[#%.8X, EXTERN_REF]]  pointer
 # DYLIB-DAG:   __DATA       __data           0x[[#%.8X, LOCAL_REF]]   pointer
 # DYLIB-DAG:   __DATA       __data           0x[[#%.8X, WEAK_REF]]    pointer
-# DYLIB-DAG:   __DATA       __thread_ptrs    0x[[#%.8X, TLV_REF]]     pointer
 # DYLIB-EMPTY:
 # DYLIB-NEXT:  Bind table:
 # DYLIB-NEXT:  segment      section        address                 type     addend dylib            symbol
 # DYLIB-DAG:   __DATA_CONST __got          {{.*}}                  pointer       0 flat-namespace   dyld_stub_binder
 # DYLIB-DAG:   __DATA       __data         0x[[#%.8X, EXTERN_REF]] pointer       0 flat-namespace   _extern
-# DYLIB-DAG:   __DATA       __thread_ptrs  0x[[#%.8X, TLV_REF]]    pointer       0 flat-namespace   _tlv
+# DYLIB-DAG:   __DATA_CONST __got          0x[[#%.8X, TLV_REF]]    pointer       0 flat-namespace   _tlv
 # DYLIB-EMPTY:
 # DYLIB-NEXT:  Lazy bind table:
 # DYLIB-NEXT:  segment  section            address                  dylib           symbol
@@ -92,7 +91,7 @@
 # CHAINED-DYLIB-DAG: __DATA       __data        0x[[#%x, EXTERN_REF]] {{.*}}  bind  0x0    flat-namespace  _extern
 # CHAINED-DYLIB-DAG: __DATA       __data        0x[[#%x, WEAK_REF]]   {{.*}}  bind  0x0    weak            _weak_extern
 # CHAINED-DYLIB-DAG: __DATA       __data        0x[[#%x, LOCAL_REF]]  {{.*}}  rebase                       {{.*}}
-# CHAINED-DYLIB-DAG: __DATA       __thread_ptrs 0x[[#%x, TLV_REF]]    {{.*}}  bind  0x0    flat-namespace  _tlv
+# CHAINED-DYLIB-DAG: __DATA_CONST __got         {{.*}}                {{.*}}  bind  0x0    flat-namespace  _tlv
 # CHAINED-DYLIB-EMPTY:
 
 #--- foo.s
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..72186ada4b939
--- /dev/null
+++ b/lld/test/MachO/got-to-tlv-reference.s
@@ -0,0 +1,128 @@
+# 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 valid reference; ld-prime accepts it.
+
+# 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
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/tlv.s -o %t/tlv.o
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/branch.s -o %t/branch.o
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/direct.s -o %t/direct.o
+# RUN: llvm-mc -filetype=obj -triple=x86_64-apple-darwin %t/localtlv.s -o %t/localtlv.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 __got, as with ld-prime.
+# 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_CONST __got 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 second binding.
+# 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:   __thread_ptrs
+# BOTH:       __got
+# BOTH-LABEL: Bind table:
+# BOTH:       __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 libtlv _foo
+# BOTH-NOT:   _foo
+
+## A TLV-only reference also uses __got, matching ld-prime.
+# RUN: %lld -dylib -lSystem -L%t -ltlv -o %t/tlv.dylib %t/tlv.o
+# RUN: llvm-objdump --macho --section-headers --bind %t/tlv.dylib | \
+# RUN:   FileCheck %s --check-prefix=TLV
+# TLV-NOT:   __thread_ptrs
+# TLV:       __got
+# TLV-LABEL: Bind table:
+# TLV:       __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 libtlv _foo
+
+## A chained-fixup stub and a TLV relocation share the same GOT entry.
+# RUN: %lld -dylib -fixup_chains -lSystem -L%t -ltlv \
+# RUN:   -o %t/branch.dylib %t/branch.o
+# RUN: llvm-objdump --macho --section-headers --chained-fixups \
+# RUN:   %t/branch.dylib | FileCheck %s --check-prefix=BRANCH
+# BRANCH-NOT: __thread_ptrs
+# BRANCH:     __got
+# BRANCH:     _foo
+# BRANCH-NOT: _foo
+
+## A direct reference can address a same-image descriptor, but an imported
+## descriptor has no fixed address. ld-prime accepts the first and rejects the
+## second.
+# RUN: %lld -dylib -o %t/direct-local.dylib %t/direct.o %t/localtlv.o
+# RUN: not %lld -dylib -L%t -ltlv -o /dev/null %t/direct.o 2>&1 | \
+# RUN:   FileCheck %s --check-prefix=DIRECT
+# DIRECT: SIGNED relocation requires that symbol _foo not be thread-local
+
+#--- 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
+
+#--- tlv.s
+.text
+.globl _main
+_main:
+  movq _foo at TLVP(%rip), %rax
+  ret
+
+#--- branch.s
+.text
+.globl _main
+_main:
+  callq _foo
+  movq _foo at TLVP(%rip), %rax
+  ret
+
+#--- direct.s
+.text
+.globl _main
+_main:
+  leaq _foo(%rip), %rax
+  ret
+
+#--- localtlv.s
+.section __DATA,__thread_vars,thread_local_variables
+.globl _foo
+_foo:
+  .space 24
diff --git a/lld/test/MachO/indirect-symtab.s b/lld/test/MachO/indirect-symtab.s
index 52849ccb87455..20dc971c134f4 100644
--- a/lld/test/MachO/indirect-symtab.s
+++ b/lld/test/MachO/indirect-symtab.s
@@ -24,8 +24,10 @@
 # CHECK-NEXT: address            index name
 # CHECK-NEXT: _bar_fn
 # CHECK-NEXT: _foo_fn
-# CHECK-NEXT: Indirect symbols for (__DATA_CONST,__got) 4 entries
+# CHECK-NEXT: Indirect symbols for (__DATA_CONST,__got) 6 entries
 # CHECK-NEXT: address            index name
+# CHECK-NEXT: _bar_tlv
+# CHECK-NEXT: _foo_tlv
 # CHECK-NEXT: LOCAL
 # CHECK-NEXT: _bar
 # CHECK-NEXT: _foo
@@ -34,11 +36,6 @@
 # CHECK-NEXT: address            index name
 # CHECK-NEXT: _bar_fn
 # CHECK-NEXT: _foo_fn
-# CHECK-NEXT: Indirect symbols for (__DATA,__thread_ptrs) 2 entries
-# CHECK-NEXT: address            index name
-# CHECK-NEXT: _bar_tlv
-# CHECK-NEXT: _foo_tlv
-
 # DYSYMTAB: nindirectsyms 10
 
 #--- libfoo.s
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/map-file.s b/lld/test/MachO/map-file.s
index 1c1ba5a4a2d56..25bdcf8cea824 100644
--- a/lld/test/MachO/map-file.s
+++ b/lld/test/MachO/map-file.s
@@ -25,11 +25,10 @@
 # CHECK-NEXT:  3 __cstring       0000002b [[#%x,CSTR:]]     DATA
 # CHECK-NEXT:  4 __unwind_info   0000103c [[#%x,UNWIND:]]   DATA
 # CHECK-NEXT:  5 __eh_frame      00000038 [[#%x,EH_FRAME:]] DATA
-# CHECK-NEXT:  6 __got           00000010 [[#%x,GOT:]]      DATA
+# CHECK-NEXT:  6 __got           00000018 [[#%x,GOT:]]      DATA
 # CHECK-NEXT:  7 __la_symbol_ptr 00000010 [[#%x,LAZY:]]     DATA
 # CHECK-NEXT:  8 __data          00000008 [[#%x,DATA:]]     DATA
-# CHECK-NEXT:  9 __thread_ptrs   00000008 [[#%x,TLVP:]]     DATA
-# CHECK-NEXT: 10 __common        00000001 [[#%x,BSS:]]      BSS
+# CHECK-NEXT:  9 __common        00000001 [[#%x,BSS:]]      BSS
 
 # CHECK:      SYMBOL TABLE:
 # CHECK-DAG:  [[#%x,DYLD:]]    l     O __DATA,__data __dyld_private
@@ -61,7 +60,6 @@
 # CHECK-NEXT: 0x[[#%X,GOT]]       0x{{[0-9A-F]+}} __DATA_CONST  __got
 # CHECK-NEXT: 0x[[#%X,LAZY]]      0x{{[0-9A-F]+}} __DATA  __la_symbol_ptr
 # CHECK-NEXT: 0x[[#%X,DATA]]      0x{{[0-9A-F]+}} __DATA  __data
-# CHECK-NEXT: 0x[[#%X,TLVP]]      0x{{[0-9A-F]+}} __DATA  __thread_ptrs
 # CHECK-NEXT: 0x[[#%X,BSS]]       0x{{[0-9A-F]+}} __DATA  __common
 
 # CHECK-NEXT: # Symbols:
@@ -80,12 +78,12 @@
 ## Note: ld64 prints "CIE" and "FDE for: <function>" instead of "EH_Frame".
 # CHECK-NEXT: 0x[[#%X,EH_FRAME]]       0x00000018  [  2] EH_Frame
 # CHECK-NEXT: 0x[[#%X,EH_FRAME+0x18]]  0x00000020  [  2] EH_Frame
-# CHECK-NEXT: 0x[[#%X,GOT]]            0x00000008  [  0] non-lazy-pointer-to-local: _baz2
-# CHECK-NEXT: 0x[[#%X,GOT+8]]          0x00000008  [  0] non-lazy-pointer-to-local: dyld_stub_binder
+# CHECK-NEXT: 0x[[#%X,GOT]]            0x00000008  [  0] non-lazy-pointer-to-local: _baz_tlv
+# CHECK-NEXT: 0x[[#%X,GOT+8]]          0x00000008  [  0] non-lazy-pointer-to-local: _baz2
+# CHECK-NEXT: 0x[[#%X,GOT+0x10]]       0x00000008  [  0] non-lazy-pointer-to-local: dyld_stub_binder
 # CHECK-NEXT: 0x[[#%X,LAZY]]           0x00000008  [  5] _baz
 # CHECK-NEXT: 0x[[#%X,LAZY+8]]         0x00000008  [  2] _bar
 # CHECK-NEXT: 0x[[#%X,DYLD]]           0x00000000  [  0] __dyld_private
-# CHECK-NEXT: 0x[[#%X,TLVP]]           0x00000008  [  0] non-lazy-pointer-to-local: _baz_tlv
 # CHECK-NEXT: 0x[[#%X,BSS]]            0x00000001  [  2] _number
 # CHECK-EMPTY:
 
diff --git a/lld/test/MachO/tapi-link.s b/lld/test/MachO/tapi-link.s
index afe856f20cca4..09b4539a5386e 100644
--- a/lld/test/MachO/tapi-link.s
+++ b/lld/test/MachO/tapi-link.s
@@ -33,7 +33,7 @@
 # CHECK-DAG: __DATA __data {{.*}} pointer 0 libc++abi      ___gxx_personality_v0
 # CHECK-DAG: __DATA __data {{.*}} pointer 0 libNested3     _deeply_nested
 # CHECK-DAG: __DATA __data {{.*}} pointer 0 libTlvWeak     _weak
-# CHECK-DAG: __DATA __thread_ptrs {{.*}} pointer 0 libTlvWeak _tlv
+# CHECK-DAG: __DATA_CONST __got {{.*}} pointer 0 libTlvWeak _tlv
 
 # CHECK: Weak bind table:
 # CHECK-DAG: __DATA __data {{.*}} pointer 0 _weak
diff --git a/lld/test/MachO/tlv-dylib.s b/lld/test/MachO/tlv-dylib.s
index 21d051fffd93c..50cdd66fc88a6 100644
--- a/lld/test/MachO/tlv-dylib.s
+++ b/lld/test/MachO/tlv-dylib.s
@@ -29,8 +29,8 @@
 # CHECK-NEXT: movq [[#]](%rip), %rax ## 0x[[#%x, BAZ:]]
 
 # CHECK-LABEL: Bind table:
-# CHECK-DAG: __DATA       __thread_ptrs  0x{{0*}}[[#%x, FOO]] pointer 0   libtlv   _foo
-# CHECK-DAG: __DATA       __thread_ptrs  0x{{0*}}[[#%x, BAR]] pointer 0   libtlv   _bar
+# CHECK-DAG: __DATA_CONST __got          0x{{0*}}[[#%x, FOO]] pointer 0   libtlv   _foo
+# CHECK-DAG: __DATA_CONST __got          0x{{0*}}[[#%x, BAR]] pointer 0   libtlv   _bar
 # CHECK-DAG: __DATA_CONST __got          0x{{0*}}[[#%x, BAZ]] pointer 0   libtlv   _baz
 
 ## Check `type` on the various TLV sections, and check that
@@ -39,7 +39,7 @@
 # FLAGS:       sectname __got
 # FLAGS-NEXT:   segname __DATA_CONST
 # FLAGS-NEXT:      addr
-# FLAGS-NEXT:      size 0x0000000000000008
+# FLAGS-NEXT:      size 0x0000000000000018
 # FLAGS-NEXT:    offset
 # FLAGS-NEXT:     align 2^3 (8)
 # FLAGS-NEXT:    reloff 0
@@ -54,15 +54,6 @@
 # FLAGS-NEXT:    reloff 0
 # FLAGS-NEXT:    nreloc 0
 # FLAGS-NEXT:      type S_THREAD_LOCAL_VARIABLES
-# FLAGS:       sectname __thread_ptrs
-# FLAGS-NEXT:   segname __DATA
-# FLAGS-NEXT:      addr
-# FLAGS-NEXT:      size 0x0000000000000010
-# FLAGS-NEXT:    offset
-# FLAGS-NEXT:     align 2^3 (8)
-# FLAGS-NEXT:    reloff 0
-# FLAGS-NEXT:    nreloc 0
-# FLAGS-NEXT:      type S_THREAD_LOCAL_VARIABLE_POINTERS
 # FLAGS:       sectname __thread_data
 # FLAGS-NEXT:   segname __DATA
 # FLAGS-NEXT:      addr
@@ -119,7 +110,7 @@ _main:
   ret
 
 ## Add some TLVs to test too, so that we can test the ordering
-## of __thread_ptrs, __thread_data, and __thread_bss.
+## of __thread_data and __thread_bss.
 ## Also add a .bss and a .comm for good measure too. Since they
 ## are both zerofill, they end up after __thread_bss.
 .comm _com, 0x4000
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..c39622c4fd169
--- /dev/null
+++ b/lld/test/MachO/tlv-dynamic-lookup-x86.s
@@ -0,0 +1,52 @@
+# 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-NOT:   __thread_ptrs
+# MOVQ:       __got
+# MOVQ-LABEL: Bind table:
+# MOVQ:       __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+
+## 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 | \
+# 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
new file mode 100644
index 0000000000000..2b2d8e36fbf75
--- /dev/null
+++ b/lld/test/MachO/tlv-dynamic-lookup.s
@@ -0,0 +1,265 @@
+# 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
+
+## 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-NOT:   __thread_ptrs
+# CHECK:       __got
+# CHECK-LABEL: Bind table:
+# 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 | \
+# RUN:   FileCheck %s --check-prefix=USCOPE
+# USCOPE-NOT: _tlv
+# USCOPE:     error: undefined symbol: _other
+# USCOPE-NOT: _tlv
+
+# 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_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_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+
+## 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-NOT:  __thread_ptrs
+# CHAINED:      __got
+# CHAINED:      lib_ordinal = -2 (flat-namespace)
+# CHAINED:      _tlv
+
+# 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_CONST __got 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: 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 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-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_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_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_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 \
+# 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-NOT: __thread_ptrs
+# INITOFF:     __got
+# 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-NOT: __thread_ptrs
+# EP:     __got
+# 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_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace _tlv
+
+## 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 --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
+.p2align 2
+_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
+.data
+_tlv:
+  .quad 0
diff --git a/lld/test/MachO/tlv-non-lazy-pointer.s b/lld/test/MachO/tlv-non-lazy-pointer.s
new file mode 100644
index 0000000000000..7996a70d19ca9
--- /dev/null
+++ b/lld/test/MachO/tlv-non-lazy-pointer.s
@@ -0,0 +1,170 @@
+# REQUIRES: aarch64
+# RUN: rm -rf %t; split-file %s %t
+
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/libtlv.s -o %t/libtlv.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/tlv.s -o %t/tlv.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/got.s -o %t/got.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/direct.s -o %t/direct.o
+# RUN: llvm-mc -filetype=obj -triple=arm64-apple-darwin %t/unsigned.s -o %t/unsigned.o
+
+# RUN: %lld -arch arm64 -dylib -lSystem -install_name @rpath/libtlv.dylib \
+# RUN:   -o %t/libtlv.dylib %t/libtlv.o
+
+## ld-prime puts an imported TLV's descriptor pointer in __got, even when the
+## input only has TLV relocations.
+# RUN: %lld -arch arm64 -dylib -o %t/tlv.dylib %t/tlv.o %t/libtlv.dylib
+# RUN: llvm-objdump --macho --section-headers --bind %t/tlv.dylib | \
+# RUN:   FileCheck %s --check-prefix=TLV
+# TLV-NOT:   __thread_ptrs
+# TLV:       __got
+# TLV-LABEL: Bind table:
+# TLV:       __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 libtlv _foo
+
+## An ordinary GOT reference asks for the same descriptor address.
+# RUN: %lld -arch arm64 -dylib -o %t/got.dylib %t/got.o %t/libtlv.dylib
+# RUN: llvm-objdump --macho --section-headers --bind %t/got.dylib | \
+# RUN:   FileCheck %s --check-prefix=GOT
+# GOT-NOT:   __thread_ptrs
+# GOT:       __got
+# GOT-LABEL: Bind table:
+# GOT:       __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 libtlv _foo
+
+## GOT and TLV references must coalesce to one slot, independent of input
+## relocation kind.
+# RUN: %lld -arch arm64 -dylib -o %t/mixed.dylib %t/mixed.o %t/libtlv.dylib
+# 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 libtlv _foo
+# MIXED-NOT:   _foo
+
+## A stub needs a GOT entry under chained fixups. It and the TLV relocation
+## must share that entry rather than asserting while allocating two slots.
+# RUN: %lld -arch arm64 -dylib -fixup_chains -o %t/branch.dylib \
+# RUN:   %t/branch.o %t/libtlv.dylib
+# RUN: llvm-objdump --macho --section-headers --chained-fixups \
+# RUN:   %t/branch.dylib | FileCheck %s --check-prefix=BRANCH
+# BRANCH-NOT: __thread_ptrs
+# BRANCH:     __got
+# BRANCH:     _foo
+# BRANCH-NOT: _foo
+
+## Compact unwind personalities also request a GOT entry after the ordinary
+## relocation scan. That late request must find the same slot.
+# RUN: %lld -arch arm64 -dylib -o %t/cfi.dylib %t/cfi.o %t/libtlv.dylib
+# RUN: llvm-objdump --macho --section-headers --bind %t/cfi.dylib | \
+# RUN:   FileCheck %s --check-prefix=CFI
+# CFI-NOT:   __thread_ptrs
+# CFI:       __got
+# CFI-LABEL: Bind table:
+# CFI:       __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 libtlv _foo
+# CFI-NOT:   _foo
+
+## An unsigned relocation is a request for the descriptor's address. It binds
+## independently of the TLV reference's non-lazy pointer, as ld-prime does.
+# RUN: %lld -arch arm64 -dylib -o %t/unsigned.dylib %t/unsigned.o \
+# RUN:   %t/libtlv.dylib
+# RUN: llvm-objdump --macho --bind %t/unsigned.dylib | \
+# RUN:   FileCheck %s --check-prefix=UNSIGNED
+# UNSIGNED-DAG: __DATA_CONST __got  0x{{[0-9a-f]+}} pointer 0 libtlv _foo
+# UNSIGNED-DAG: __DATA       __data 0x{{[0-9a-f]+}} pointer 0 libtlv _foo
+
+## A direct address relocation is valid for a descriptor defined in the same
+## image, but an imported descriptor has no fixed link-time address. ld-prime
+## accepts the former and rejects the latter.
+# RUN: %lld -arch arm64 -dylib -lSystem -o %t/direct-local.dylib \
+# RUN:   %t/direct.o %t/libtlv.o
+# RUN: not %lld -arch arm64 -dylib -o /dev/null %t/direct.o \
+# RUN:   %t/libtlv.dylib 2>&1 | FileCheck %s --check-prefix=DIRECT
+# DIRECT: PAGE21 relocation requires that symbol _foo not be thread-local
+
+#--- libtlv.s
+.section __DATA,__thread_data,thread_local_regular
+_foo$tlv$init:
+  .quad 0
+.section __DATA,__thread_vars,thread_local_variables
+.globl _foo
+_foo:
+  .quad __tlv_bootstrap
+  .quad 0
+  .quad _foo$tlv$init
+
+#--- tlv.s
+.globl _viaTlv
+.p2align 2
+_viaTlv:
+  adrp x8, _foo at TLVPPAGE
+  ldr  x8, [x8, _foo at TLVPPAGEOFF]
+  ret
+.subsections_via_symbols
+
+#--- got.s
+.globl _viaGot
+.p2align 2
+_viaGot:
+  adrp x8, _foo at GOTPAGE
+  ldr  x8, [x8, _foo at GOTPAGEOFF]
+  ret
+.subsections_via_symbols
+
+#--- mixed.s
+.globl _viaTlv, _viaGot
+.p2align 2
+_viaTlv:
+  adrp x8, _foo at TLVPPAGE
+  ldr  x8, [x8, _foo at TLVPPAGEOFF]
+  ret
+_viaGot:
+  adrp x8, _foo at GOTPAGE
+  ldr  x8, [x8, _foo at GOTPAGEOFF]
+  ret
+.subsections_via_symbols
+
+#--- branch.s
+.globl _viaBranchAndTlv
+.p2align 2
+_viaBranchAndTlv:
+  bl _foo
+  adrp x8, _foo at TLVPPAGE
+  ldr  x8, [x8, _foo at TLVPPAGEOFF]
+  ret
+.subsections_via_symbols
+
+#--- cfi.s
+.globl _viaCfiAndTlv
+.p2align 2
+_viaCfiAndTlv:
+  .cfi_startproc
+  .cfi_personality 155, _foo
+  adrp x8, _foo at TLVPPAGE
+  ldr  x8, [x8, _foo at TLVPPAGEOFF]
+  ret
+  .cfi_endproc
+.subsections_via_symbols
+
+#--- direct.s
+.globl _direct
+.p2align 2
+_direct:
+  adrp x8, _foo at PAGE
+  add  x8, x8, _foo at PAGEOFF
+  ret
+.subsections_via_symbols
+
+#--- unsigned.s
+.globl _viaTlv
+.p2align 2
+_viaTlv:
+  adrp x8, _foo at TLVPPAGE
+  ldr  x8, [x8, _foo at TLVPPAGEOFF]
+  ret
+.data
+.globl _descriptor
+_descriptor:
+  .quad _foo
+.subsections_via_symbols
diff --git a/lld/test/MachO/weak-binding.s b/lld/test/MachO/weak-binding.s
index 682aa8dcd2537..2a218812c02ba 100644
--- a/lld/test/MachO/weak-binding.s
+++ b/lld/test/MachO/weak-binding.s
@@ -46,7 +46,7 @@
 # CHECK-DAG:   __DATA        __data          0x[[#%x,WEAK_DY:]]      pointer 0 libfoo    _weak_dysym
 # CHECK-DAG:   __DATA        __thread_vars   0x{{[0-9a-f]*}}         pointer 0 libSystem __tlv_bootstrap
 # CHECK-DAG:   __DATA        __thread_vars   0x{{[0-9a-f]*}}         pointer 0 libSystem __tlv_bootstrap
-# CHECK-DAG:   __DATA        __thread_ptrs   0x[[#WEAK_DY_TLV_ADDR]] pointer 0 libfoo    _weak_dysym_tlv
+# CHECK-DAG:   __DATA_CONST  __got           0x[[#WEAK_DY_TLV_ADDR]] pointer 0 libfoo    _weak_dysym_tlv
 ## Check that we don't have any other bindings
 # CHECK-EMPTY:
 
@@ -59,8 +59,8 @@
 # CHECK-DAG:   __DATA_CONST __got           0x[[#WEAK_DY_GOT_ADDR]]   pointer 0 _weak_dysym_for_gotpcrel
 # CHECK-DAG:   __DATA_CONST __got           0x[[#WEAK_EXT_GOT_ADDR]]  pointer 0 _weak_external_for_gotpcrel
 # CHECK-DAG:   __DATA       __data          0x[[#WEAK_DY]]            pointer 0 _weak_dysym
-# CHECK-DAG:   __DATA       __thread_ptrs   0x[[#WEAK_TLV_ADDR]]      pointer 0 _weak_tlv
-# CHECK-DAG:   __DATA       __thread_ptrs   0x[[#WEAK_DY_TLV_ADDR]]   pointer 0 _weak_dysym_tlv
+# CHECK-DAG:   __DATA_CONST __got           0x[[#WEAK_TLV_ADDR]]      pointer 0 _weak_tlv
+# CHECK-DAG:   __DATA_CONST __got           0x[[#WEAK_DY_TLV_ADDR]]   pointer 0 _weak_dysym_tlv
 # CHECK-DAG:   __DATA       __data          0x{{[0-9a-f]*}}           pointer 2 _weak_external
 # CHECK-DAG:   __DATA       __la_symbol_ptr 0x[[#WEAK_DY_FN]]         pointer 0 _weak_dysym_fn
 # CHECK-DAG:   __DATA       __la_symbol_ptr 0x[[#WEAK_EXT_FN]]        pointer 0 _weak_external_fn
@@ -84,8 +84,8 @@
 # CHAINED-DAG:   __DATA       __data        0x{{[0-9a-f]*}}          {{.*}}  rebase                  0x[[#%X,WEAK_INT]]
 # CHAINED-DAG:   __DATA       __thread_vars 0x{{[0-9a-f]*}}          {{.*}}  bind  0x0    libSystem  __tlv_bootstrap
 # CHAINED-DAG:   __DATA       __thread_vars 0x{{[0-9a-f]*}}          {{.*}}  bind  0x0    libSystem  __tlv_bootstrap
-# CHAINED-DAG:   __DATA       __thread_ptrs 0x[[#WEAK_DY_TLV_ADDR]]  {{.*}}  bind  0x0    weak       _weak_dysym_tlv
-# CHAINED-DAG:   __DATA       __thread_ptrs 0x[[#WEAK_TLV_ADDR]]     {{.*}}  bind  0x0    weak       _weak_tlv
+# CHAINED-DAG:   __DATA_CONST __got         0x[[#WEAK_DY_TLV_ADDR]]  {{.*}}  bind  0x0    weak       _weak_dysym_tlv
+# CHAINED-DAG:   __DATA_CONST __got         0x[[#WEAK_TLV_ADDR]]     {{.*}}  bind  0x0    weak       _weak_tlv
 # CHAINED-EMPTY:
 
 #--- libfoo.s
diff --git a/lld/test/MachO/weak-reference.s b/lld/test/MachO/weak-reference.s
index ff72cc3d505fb..21d5001e4a6b9 100644
--- a/lld/test/MachO/weak-reference.s
+++ b/lld/test/MachO/weak-reference.s
@@ -29,7 +29,7 @@
 # BIND-NEXT: segment       section          address         type     addend dylib   symbol
 # BIND-DAG:  __DATA        __data           0x{{[0-9a-f]+}} pointer       0 libfoo  _foo (weak_import)
 # BIND-DAG:  __DATA_CONST  __got            0x{{[0-9a-f]+}} pointer       0 libfoo  _foo (weak_import)
-# BIND-DAG:  __DATA        __thread_ptrs    0x{{[0-9a-f]+}} pointer       0 libfoo  _foo_tlv (weak_import)
+# BIND-DAG:  __DATA_CONST  __got            0x{{[0-9a-f]+}} pointer       0 libfoo  _foo_tlv (weak_import)
 # BIND-DAG:  __DATA        __data           0x{{[0-9a-f]+}} pointer       0 libfoo  _weak_foo (weak_import)
 # BIND-DAG:  __DATA        __la_symbol_ptr  0x{{[0-9a-f]+}} pointer       0 libfoo  _weak_foo_fn (weak_import)
 # BIND:      Lazy bind table:
@@ -40,7 +40,7 @@
 # CHAINED-NEXT: segment      section       address pointer type  addend dylib    symbol/vm address
 # CHAINED-DAG:  __DATA_CONST __got             {{.*}}      bind  0x0    libfoo   _foo (weak import)
 # CHAINED-DAG:  __DATA       __data            {{.*}}      bind  0x0    libfoo   _foo (weak import)
-# CHAINED-DAG:  __DATA       __thread_ptrs     {{.*}}      bind  0x0    libfoo   _foo_tlv (weak import)
+# CHAINED-DAG:  __DATA_CONST __got             {{.*}}      bind  0x0    libfoo   _foo_tlv (weak import)
 # CHAINED-DAG:  __DATA_CONST __got             {{.*}}      bind  0x0    libfoo   _foo_fn (weak import)
 # CHAINED-DAG:  __DATA       __data            {{.*}}      bind  0x0    weak     _weak_foo (weak import)
 # CHAINED-DAG:  __DATA_CONST __got             {{.*}}      bind  0x0    weak     _weak_foo_fn (weak import)
@@ -97,7 +97,7 @@
 # STRONG-BIND-DAG:  __DATA        __data           0x{{[0-9a-f]+}} pointer         0 libfoo  _foo{{$}}
 # STRONG-BIND-DAG:  __DATA        __data           0x{{[0-9a-f]+}} pointer         0 libfoo  _foo{{$}}
 # STRONG-BIND-DAG:  __DATA_CONST  __got            0x{{[0-9a-f]+}} pointer         0 libfoo  _foo{{$}}
-# STRONG-BIND-DAG:  __DATA        __thread_ptrs    0x{{[0-9a-f]+}} pointer         0 libfoo  _foo_tlv{{$}}
+# STRONG-BIND-DAG:  __DATA_CONST  __got            0x{{[0-9a-f]+}} pointer         0 libfoo  _foo_tlv{{$}}
 # STRONG-BIND-DAG:  __DATA        __data           0x{{[0-9a-f]+}} pointer         0 libfoo  _weak_foo{{$}}
 # STRONG-BIND-DAG:  __DATA        __data           0x{{[0-9a-f]+}} pointer         0 libfoo  _weak_foo{{$}}
 # STRONG-BIND-DAG:  __DATA        __la_symbol_ptr  0x{{[0-9a-f]+}} pointer         0 libfoo  _weak_foo_fn{{$}}
@@ -111,7 +111,7 @@
 # STRONG-CHAINED-DAG:  __DATA       __data           {{.*}}      bind  0x0    libfoo  _foo{{$}}
 # STRONG-CHAINED-DAG:  __DATA       __data           {{.*}}      bind  0x0    weak    _weak_foo{{$}}
 # STRONG-CHAINED-DAG:  __DATA       __data           {{.*}}      bind  0x0    weak    _weak_foo{{$}}
-# STRONG-CHAINED-DAG:  __DATA       __thread_ptrs    {{.*}}      bind  0x0    libfoo  _foo_tlv{{$}}
+# STRONG-CHAINED-DAG:  __DATA_CONST __got             {{.*}}      bind  0x0    libfoo  _foo_tlv{{$}}
 
 # STRONG-YAML-LABEL: WeakBindOpcodes:
 # STRONG-YAML:        - Opcode:          BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM

>From 09bbff9323d7a475e8ecdadb34184860acac70f4 Mon Sep 17 00:00:00 2001
From: Peter Rong <PeterRong at meta.com>
Date: Fri, 11 Sep 2026 09:37:03 -0700
Subject: [PATCH 2/4] [lld][MachO] Give the imported-TLV rejection its own
 diagnostic

validateSymbolRelocation now rejects two unrelated things, but reports both
with the message written for the first: "requires that symbol _foo not be
thread-local". For a direct relocation against an imported thread-local that
is actively misleading -- the symbol is supposed to be thread-local, and
making it otherwise is not the fix. The problem is that an imported TLV
descriptor has no address at link time.

Split the condition so each failure explains itself:

  TLVP_LOAD_PAGE21 relocation requires that symbol _other be thread-local
  PAGE21 relocation cannot reference imported thread-local symbol _foo;
    its TLV descriptor has no address at link time

Also reuse the dysym already computed above instead of a second
isa<DylibSymbol> test.
---
 lld/MachO/Relocations.cpp             | 17 +++++++++++------
 lld/test/MachO/got-to-tlv-reference.s |  2 +-
 lld/test/MachO/tlv-non-lazy-pointer.s |  2 +-
 3 files changed, 13 insertions(+), 8 deletions(-)

diff --git a/lld/MachO/Relocations.cpp b/lld/MachO/Relocations.cpp
index f06c5540829ec..b926d74bbded1 100644
--- a/lld/MachO/Relocations.cpp
+++ b/lld/MachO/Relocations.cpp
@@ -88,12 +88,17 @@ bool macho::validateSymbolRelocation(const Symbol *sym,
                                     relocAttrs.hasAttr(RelocAttrBits::GOT) ||
                                     relocAttrs.hasAttr(RelocAttrBits::BRANCH) ||
                                     relocAttrs.hasAttr(RelocAttrBits::UNSIGNED);
-  const bool isImportedTlv = isa<DylibSymbol>(sym) && sym->isTlv();
-
-  if (tlvKindIsKnown && ((isTlvReloc && !sym->isTlv()) ||
-                         (isImportedTlv && !permitsTlvDescriptor)))
-    error(message(Twine("requires that symbol ") + sym->getName() + " " +
-                  (sym->isTlv() ? "not " : "") + "be thread-local"));
+  const bool isImportedTlv = dysym && sym->isTlv();
+
+  if (tlvKindIsKnown) {
+    if (isTlvReloc && !sym->isTlv())
+      error(message(Twine("requires that symbol ") + sym->getName() +
+                    " be thread-local"));
+    else if (isImportedTlv && !permitsTlvDescriptor)
+      error(message(Twine("cannot reference imported thread-local symbol ") +
+                    sym->getName() +
+                    "; its TLV descriptor has no address at link time"));
+  }
 
   return valid;
 }
diff --git a/lld/test/MachO/got-to-tlv-reference.s b/lld/test/MachO/got-to-tlv-reference.s
index 72186ada4b939..62184d84af5b4 100644
--- a/lld/test/MachO/got-to-tlv-reference.s
+++ b/lld/test/MachO/got-to-tlv-reference.s
@@ -68,7 +68,7 @@
 # RUN: %lld -dylib -o %t/direct-local.dylib %t/direct.o %t/localtlv.o
 # RUN: not %lld -dylib -L%t -ltlv -o /dev/null %t/direct.o 2>&1 | \
 # RUN:   FileCheck %s --check-prefix=DIRECT
-# DIRECT: SIGNED relocation requires that symbol _foo not be thread-local
+# DIRECT: SIGNED relocation cannot reference imported thread-local symbol _foo; its TLV descriptor has no address at link time
 
 #--- deftlv.s
 .text
diff --git a/lld/test/MachO/tlv-non-lazy-pointer.s b/lld/test/MachO/tlv-non-lazy-pointer.s
index 7996a70d19ca9..d6c3ad5e80931 100644
--- a/lld/test/MachO/tlv-non-lazy-pointer.s
+++ b/lld/test/MachO/tlv-non-lazy-pointer.s
@@ -81,7 +81,7 @@
 # RUN:   %t/direct.o %t/libtlv.o
 # RUN: not %lld -arch arm64 -dylib -o /dev/null %t/direct.o \
 # RUN:   %t/libtlv.dylib 2>&1 | FileCheck %s --check-prefix=DIRECT
-# DIRECT: PAGE21 relocation requires that symbol _foo not be thread-local
+# DIRECT: PAGE21 relocation cannot reference imported thread-local symbol _foo; its TLV descriptor has no address at link time
 
 #--- libtlv.s
 .section __DATA,__thread_data,thread_local_regular

>From 8d4756c56a37b09ac525ec899f94fcf971f25d0f Mon Sep 17 00:00:00 2001
From: Peter Rong <PeterRong at meta.com>
Date: Fri, 11 Sep 2026 13:48:44 -0700
Subject: [PATCH 3/4] [NFC][lld][MachO] Correct comments left stale by the
 __got consolidation

Several comments describe the two-section world that no longer exists, or
state things the code contradicts:

  - GotSection was documented as "the __DATA_CONST,__got section", but the
    constructor two lines below passes segment_names::data; the move to
    __DATA_CONST happens later in initializeSectionRenameMap, and only when
    -no_data_const is absent.
  - addNonLazyPointerEntry argued against putting the slot in __thread_ptrs,
    a section lld no longer emits, and was the only remaining mention of the
    name. Keep the part that says something the code does not: one entry
    serves both reference kinds, so the outcome does not depend on which
    reference the relocation scan reaches first, which matters most for
    dynamic-lookup symbols.
  - resolveNonLazyPtrVA carried four lines of allocation rationale on a
    one-line accessor; point at addNonLazyPointerEntry instead. isInGot
    pointed at "Writer::addNonLazyPointerEntry", which is a file-static, not
    a member of Writer.
  - tlv-dynamic-lookup.s named NonLazyPointerSectionBase::addEntry, a class
    this change deletes, and labelled a case "InitOffsetsSection::setUp"
    without noting that it reaches a GOT entry only via in.stubs->addEntry.
  - Reworded the "JUDGMENT CALL" note on the unsigned+TLV case to state what
    is checked: two independent binds, both naming the descriptor, which is
    also what ld-prime emits.
---
 lld/MachO/Symbols.h                 |  8 ++------
 lld/MachO/SyntheticSections.h       |  4 ++--
 lld/MachO/Writer.cpp                | 10 ++++------
 lld/test/MachO/tlv-dynamic-lookup.s | 20 ++++++++++----------
 4 files changed, 18 insertions(+), 24 deletions(-)

diff --git a/lld/MachO/Symbols.h b/lld/MachO/Symbols.h
index 6b5917e70cd81..6d31b67e1d803 100644
--- a/lld/MachO/Symbols.h
+++ b/lld/MachO/Symbols.h
@@ -65,9 +65,7 @@ class Symbol {
 
   virtual bool isTlv() const { return false; }
 
-  // Whether this symbol has a non-lazy pointer slot. A symbol gets at most
-  // one, always in __got (including thread-local symbols).
-  // See Writer::addNonLazyPointerEntry.
+  // Whether this symbol has a non-lazy pointer slot.
   bool isInGot() const { return gotIndex != UINT32_MAX; }
 
   // Whether this symbol is in the StubsSection.
@@ -81,9 +79,7 @@ class Symbol {
     return isInStubs() ? getStubVA() : getVA();
   }
   // 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 the same __got entry.
+  // address if it has none.
   uint64_t resolveNonLazyPtrVA() const {
     return isInGot() ? getGotVA() : getVA();
   }
diff --git a/lld/MachO/SyntheticSections.h b/lld/MachO/SyntheticSections.h
index 3b8bed5c21ddb..d863758b56c50 100644
--- a/lld/MachO/SyntheticSections.h
+++ b/lld/MachO/SyntheticSections.h
@@ -106,8 +106,8 @@ class PageZeroSection final : public SyntheticSection {
   void writeTo(uint8_t *buf) const override {}
 };
 
-// The __DATA_CONST,__got section, populated by dyld with addresses to
-// non-lazily-loaded dylib symbols, including TLV descriptors.
+// The __got section, populated by dyld with addresses to non-lazily-loaded
+// dylib symbols, including TLV descriptors.
 class GotSection final : public SyntheticSection {
 public:
   GotSection();
diff --git a/lld/MachO/Writer.cpp b/lld/MachO/Writer.cpp
index 4af7ed150f694..c9254b7125d7f 100644
--- a/lld/MachO/Writer.cpp
+++ b/lld/MachO/Writer.cpp
@@ -662,12 +662,10 @@ void Writer::treatSpecialUndefineds() {
   }
 }
 
-// Give a symbol its single non-lazy pointer slot. For a thread-local,
-// __thread_ptrs and __got would hold the same value: the address of its TLV
-// descriptor. ld-prime canonicalizes both GOT and TLV references to one
-// __got entry, which also makes the choice independent of which reference is
-// scanned first. This is especially important for dynamic-lookup symbols,
-// whose thread-locality is unknowable until dyld binds the slot.
+// Give a symbol its single non-lazy pointer slot. A GOT reference and a TLV
+// reference to a thread-local both want the same value -- the address of its
+// TLV descriptor -- so one __got entry serves both, and the result no longer
+// depends on which reference the relocation scan reaches first. 
 static void addNonLazyPointerEntry(Symbol *sym) { in.got->addEntry(sym); }
 
 static void prepareSymbolRelocation(Symbol *sym, const InputSection *isec,
diff --git a/lld/test/MachO/tlv-dynamic-lookup.s b/lld/test/MachO/tlv-dynamic-lookup.s
index 2b2d8e36fbf75..c5c983addd1ee 100644
--- a/lld/test/MachO/tlv-dynamic-lookup.s
+++ b/lld/test/MachO/tlv-dynamic-lookup.s
@@ -71,10 +71,10 @@
 # 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: 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.
+## The remaining cases each reach GotSection::addEntry by a route that does not
+## pass through prepareSymbolRelocation. None may abort the 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.
@@ -106,7 +106,8 @@
 # RUN: llvm-objdump --macho --bind %t/binder.dylib | FileCheck %s --check-prefix=BINDER
 # BINDER: __DATA_CONST __got 0x{{[0-9a-f]+}} pointer 0 flat-namespace dyld_stub_binder
 
-## InitOffsetsSection::setUp.
+## InitOffsetsSection::setUp -> in.stubs->addEntry -> in.got->addEntry, i.e. a
+## GOT entry reached two levels deep from a section that scans its own relocs.
 # 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
@@ -129,11 +130,10 @@
 # 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.
+## An unsigned relocation binds the pointer in place rather than allocating a
+## slot, so a data pointer and a TLV reference to one symbol produce two
+## independent binds. Both name the descriptor, so neither is wrong, and
+## ld-prime emits the same pair.
 # 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

>From 900aea073f79a5feb73760a529778c0734fa5270 Mon Sep 17 00:00:00 2001
From: Peter Rong <PeterRong at meta.com>
Date: Fri, 11 Sep 2026 13:56:07 -0700
Subject: [PATCH 4/4] format

---
 lld/MachO/Writer.cpp | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lld/MachO/Writer.cpp b/lld/MachO/Writer.cpp
index c9254b7125d7f..b97203d656ebd 100644
--- a/lld/MachO/Writer.cpp
+++ b/lld/MachO/Writer.cpp
@@ -665,7 +665,7 @@ void Writer::treatSpecialUndefineds() {
 // Give a symbol its single non-lazy pointer slot. A GOT reference and a TLV
 // reference to a thread-local both want the same value -- the address of its
 // TLV descriptor -- so one __got entry serves both, and the result no longer
-// depends on which reference the relocation scan reaches first. 
+// depends on which reference the relocation scan reaches first.
 static void addNonLazyPointerEntry(Symbol *sym) { in.got->addEntry(sym); }
 
 static void prepareSymbolRelocation(Symbol *sym, const InputSection *isec,



More information about the llvm-commits mailing list