<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>howtf.io</title><description>Deep dives from the bottom of the systems stack: kernel, loader, GPU, virtual machine monitor. Every story starts with a bug and the question: howtf did that happen?</description><link>https://howtf.io/</link><language>en-us</language><item><title>howtf can a device be both present and not found?</title><link>https://howtf.io/blog/split-state-linking/</link><guid isPermaLink="true">https://howtf.io/blog/split-state-linking/</guid><description>A recurring production SEV, a device that was present and not found, and the decades-old linker rule that split one library&apos;s state into two. Part 2 of Linking &amp; Loading.</description><pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Linking &amp;amp; Loading, Part 2.&lt;/strong&gt; Part 1, &lt;a href=&quot;/blog/ELF-Linking-101/&quot;&gt;&lt;em&gt;howtf does &lt;code&gt;./app&lt;/code&gt; reach &lt;code&gt;main()&lt;/code&gt;?&lt;/em&gt;&lt;/a&gt;, traced the machinery: ELF, &lt;code&gt;ld.so&lt;/code&gt;, symbol resolution, the PLT/GOT, relocations. This one is about what that machinery does to you when the same symbol exists twice.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2&gt;1. Present, and not found&lt;/h2&gt;
&lt;p&gt;The failure that starts this story showed up the way these things do: in the logs and on the dashboards, as training jobs started crashing at startup. The line that mattered was the RDMA stack&apos;s classic no-device error, the moral equivalent of &lt;code&gt;ibv_get_device_list()&lt;/code&gt; coming back empty:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;No IB devices found
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Paste that string into a search engine and every hit is hardware troubleshooting. Check the cable. Check the firmware. Check that the driver is loaded. Which is exactly the rabbit hole it aims you down, because everything about that error says &lt;em&gt;the machine&lt;/em&gt;, and nothing about it says &lt;em&gt;the binary&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;But the machine was fine. The device was present by every check anyone could run: it showed up in enumeration, the driver was loaded. And the failing consumer was the last one anyone would suspect: NCCL, the most battle-tested RDMA consumer in the fleet, code nobody had touched, reporting &lt;code&gt;No IB devices found&lt;/code&gt; at startup, every time, in some binaries and not others. All of them built from the same torch commit.&lt;/p&gt;
&lt;p&gt;That&apos;s the howtf. Same source. Same fleet. Same device, verifiably there. Whether a binary could see it depended on the binary.&lt;/p&gt;
&lt;p&gt;Some context, because the shape of the build matters later. This was at Meta. The binaries were application training binaries composed by Buck, with PyTorch built in-house and statically linked: hermetic builds and fast startup are worth a great deal at that scale, &lt;a href=&quot;/blog/ELF-Linking-101/#61-why-hyperscalers-link-statically&quot;&gt;the exact case Part 1 made for why hyperscalers link statically&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&amp;quot;Composed by Buck&amp;quot; is doing real work in that sentence, and one fact about it decides this story, so here it is up front. Buck had two strategies for packaging a binary&apos;s native code, and the fleet was mid-migration between them. Binaries still on the older &lt;strong&gt;omnibus&lt;/strong&gt; strategy hid the bundled verbs symbols. Binaries migrated to &lt;strong&gt;link groups&lt;/strong&gt; published them.&lt;/p&gt;
&lt;p&gt;The two shapes, briefly. &lt;a href=&quot;https://buck.build/javadoc/com/facebook/buck/cxx/Omnibus.html&quot;&gt;Omnibus&lt;/a&gt; merges most of a binary&apos;s native code (torch and everything under it) &amp;quot;into a single giant shared library,&amp;quot; then hides what it merged: the blob is linked behind a version script ending in &lt;code&gt;local: *;&lt;/code&gt;, localizing every symbol except the exact set the Python-facing roots need. What omnibus swallows, it hides. Meta had written down the hazard of that kind of merge back in &lt;a href=&quot;https://engineering.fb.com/2018/01/23/android/android-native-library-merging/&quot;&gt;2018&lt;/a&gt;, in the Android sibling of this exact machinery: native-library merging &amp;quot;works great, as long as there are no common symbols between the libraries being merged.&amp;quot;&lt;/p&gt;
&lt;p&gt;But a merged blob at training scale eventually outgrows x86-64&apos;s ±2 GiB relocation reach, the same wall &lt;a href=&quot;/blog/ELF-Linking-101/#62-the-consequence-the-2-gib-relocation-barrier&quot;&gt;Part 1 hit at the end of its static-linking detour&lt;/a&gt;. The way past it is &lt;a href=&quot;https://github.com/facebook/buck2/blob/main/prelude/linking/link_groups_explained.md&quot;&gt;link groups&lt;/a&gt;: carve the binary&apos;s native dependency graph into several shared libraries, each under the limit. A link group is still a merge, but it lands with the opposite symbol posture. Each group is a genuine shared library, wired to the binary through &lt;code&gt;DT_NEEDED&lt;/code&gt;, and its boundary symbols are &lt;em&gt;exported&lt;/em&gt; into the process&apos;s dynamic symbol tables so the pieces can find each other at runtime. The full machinery (the version script, the relocation arithmetic, the linker flags, and one public torch casualty) is in &lt;a href=&quot;#appendix-a-the-buck-machinery-omnibus-link-groups-and-the-2-gib-wall&quot;&gt;Appendix A&lt;/a&gt;.&lt;/p&gt;
&lt;figure class=&quot;frame diagram&quot;&gt;
  &lt;span class=&quot;frame-title&quot;&gt;fig. 1 · one bundled copy, two postures&lt;/span&gt;
  &lt;div class=&quot;diagram-body&quot;&gt;
    &lt;svg viewBox=&quot;0 0 720 410&quot; role=&quot;img&quot; aria-label=&quot;Diagram: the same bundled verbs stack takes two postures depending on build strategy — merged into a libomnibus blob its symbols are localized by the version script and nothing reaches the process&apos;s global scope, while carved into a link group it becomes a real shared library whose symbols are exported into the global scope&quot;&gt;
      &lt;defs&gt;
        &lt;marker id=&quot;p2f1a&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--accent)&quot;/&gt;
        &lt;/marker&gt;
        &lt;marker id=&quot;p2f1m&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--muted)&quot;/&gt;
        &lt;/marker&gt;
      &lt;/defs&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot;&gt;
        &lt;!-- shared origin --&gt;
        &lt;rect x=&quot;200&quot; y=&quot;30&quot; width=&quot;320&quot; height=&quot;46&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.07&quot;/&gt;
        &lt;rect x=&quot;200&quot; y=&quot;30&quot; width=&quot;320&quot; height=&quot;46&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;360&quot; y=&quot;49&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;libibverbs + mlx5, bundled at build time&lt;/text&gt;
        &lt;text x=&quot;360&quot; y=&quot;66&quot; text-anchor=&quot;middle&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;same source, same commit&lt;/text&gt;
        &lt;!-- omnibus posture --&gt;
        &lt;rect x=&quot;28&quot; y=&quot;122&quot; width=&quot;306&quot; height=&quot;128&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.07&quot;/&gt;
        &lt;rect x=&quot;28&quot; y=&quot;122&quot; width=&quot;306&quot; height=&quot;128&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;44&quot; y=&quot;144&quot; fill=&quot;var(--seg)&quot;&gt;libomnibus.so — the merge&lt;/text&gt;
        &lt;text x=&quot;44&quot; y=&quot;160&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;version script ends: local: *;&lt;/text&gt;
        &lt;rect x=&quot;46&quot; y=&quot;174&quot; width=&quot;270&quot; height=&quot;58&quot; fill=&quot;none&quot; stroke=&quot;var(--muted)&quot; stroke-dasharray=&quot;3 3&quot;/&gt;
        &lt;text x=&quot;58&quot; y=&quot;197&quot; fill=&quot;var(--sec)&quot;&gt;verbs copy — localized&lt;/text&gt;
        &lt;text x=&quot;58&quot; y=&quot;218&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;hidden inside the blob&lt;/text&gt;
        &lt;!-- link-group posture --&gt;
        &lt;rect x=&quot;386&quot; y=&quot;122&quot; width=&quot;306&quot; height=&quot;128&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.07&quot;/&gt;
        &lt;rect x=&quot;386&quot; y=&quot;122&quot; width=&quot;306&quot; height=&quot;128&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;402&quot; y=&quot;144&quot; fill=&quot;var(--seg)&quot;&gt;a link group — the split&lt;/text&gt;
        &lt;text x=&quot;402&quot; y=&quot;160&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;a genuine .so, wired by DT_NEEDED&lt;/text&gt;
        &lt;rect x=&quot;404&quot; y=&quot;174&quot; width=&quot;270&quot; height=&quot;58&quot; fill=&quot;none&quot; stroke=&quot;var(--muted)&quot; stroke-dasharray=&quot;3 3&quot;/&gt;
        &lt;text x=&quot;416&quot; y=&quot;197&quot; fill=&quot;var(--sec)&quot;&gt;verbs copy — exported&lt;/text&gt;
        &lt;text x=&quot;416&quot; y=&quot;218&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;default visibility, boundary published&lt;/text&gt;
        &lt;!-- global scope --&gt;
        &lt;rect x=&quot;28&quot; y=&quot;312&quot; width=&quot;664&quot; height=&quot;52&quot; fill=&quot;var(--ldr)&quot; opacity=&quot;0.07&quot;/&gt;
        &lt;rect x=&quot;28&quot; y=&quot;312&quot; width=&quot;664&quot; height=&quot;52&quot; fill=&quot;none&quot; stroke=&quot;var(--ldr)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;44&quot; y=&quot;333&quot; fill=&quot;var(--ldr)&quot;&gt;the process&apos;s global dynamic scope&lt;/text&gt;
        &lt;text x=&quot;181&quot; y=&quot;352&quot; text-anchor=&quot;middle&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;no verbs names here&lt;/text&gt;
        &lt;text x=&quot;539&quot; y=&quot;352&quot; text-anchor=&quot;middle&quot; font-size=&quot;10&quot; fill=&quot;var(--accent)&quot;&gt;verbs_register_driver_&amp;lt;N&amp;gt; · ibv_* — on offer&lt;/text&gt;
      &lt;/g&gt;
      &lt;g stroke-width=&quot;1.5&quot; fill=&quot;none&quot;&gt;
        &lt;path d=&quot;M 300 80 C 260 96, 220 102, 190 118&quot; stroke=&quot;var(--muted)&quot; marker-end=&quot;url(#p2f1m)&quot;/&gt;
        &lt;path d=&quot;M 420 80 C 460 96, 500 102, 530 118&quot; stroke=&quot;var(--muted)&quot; marker-end=&quot;url(#p2f1m)&quot;/&gt;
        &lt;path d=&quot;M 181 254 L 181 308&quot; stroke=&quot;var(--muted)&quot; stroke-dasharray=&quot;4 4&quot; marker-end=&quot;url(#p2f1m)&quot;/&gt;
        &lt;path d=&quot;M 539 254 L 539 308&quot; stroke=&quot;var(--accent)&quot; marker-end=&quot;url(#p2f1a)&quot;/&gt;
      &lt;/g&gt;
      &lt;g font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot;&gt;
        &lt;text x=&quot;193&quot; y=&quot;284&quot; fill=&quot;var(--muted)&quot;&gt;exports nothing&lt;/text&gt;
        &lt;text x=&quot;551&quot; y=&quot;284&quot; fill=&quot;var(--accent)&quot;&gt;exports its symbols&lt;/text&gt;
      &lt;/g&gt;
      &lt;text x=&quot;360&quot; y=&quot;394&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;11&quot; fill=&quot;var(--accent)&quot;&gt;same copy, opposite postures: what omnibus hides, a link group publishes&lt;/text&gt;
    &lt;/svg&gt;
    &lt;p class=&quot;legend&quot;&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--sec)&quot;&gt;&lt;/span&gt;the bundled verbs copy&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--seg)&quot;&gt;&lt;/span&gt;build artifact&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--ldr)&quot;&gt;&lt;/span&gt;global scope&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--accent)&quot;&gt;&lt;/span&gt;exported names&lt;/span&gt;
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The migration between the two ran application by application: some training binaries still composed as one libomnibus, others already carved into link groups. That matters here for two reasons. First, either composition sweeps the binary&apos;s native dependencies into the artifact itself. Among them, in these binaries, was the RDMA user-space stack: libibverbs and the mlx5 provider, the code that enumerates RDMA devices, bundled at build time rather than taken from the host the binary lands on.&lt;/p&gt;
&lt;p&gt;Second, the &lt;em&gt;posture&lt;/em&gt; of that bundled copy (localized inside an omnibus blob, or exported from a link group) depended on which side of the migration a given binary stood. Hold both thoughts; the second is where the opening riddle will find its answer.&lt;/p&gt;
&lt;p&gt;Torch was one ingredient; the final artifact was each application&apos;s own training binary. And some of those binaries, depending on what they trained on, carried support for MTIA, Meta&apos;s own accelerator, served by an in-house collective-communication library newly enabled in the Torch backend. It was that library that pulled the verbs stack into the composition at all.&lt;/p&gt;
&lt;p&gt;So: two collective libraries in one process. NCCL/NCCLX for the GPUs, the in-house library for MTIA. Both walk the same device list at startup. The new MTIA path was the one that worked.&lt;/p&gt;
&lt;h2&gt;2. The investigation&lt;/h2&gt;
&lt;p&gt;The first guess, always, is hardware. That is what the error says, that is where on-call muscle memory goes, and that is where this one went. We checked the hardware: no issues, everything looked fine. We ran the basic IP and connectivity tests: passed. The device was cabled, enumerated, and reachable, and other binaries were using it happily on the same machine, which cleared the host too.&lt;/p&gt;
&lt;p&gt;Then came the fact that snapped the frame. The in-house library, running inside the very same processes, saw the full RDMA device list and worked. So the kernel was serving the device list correctly into the address space where NCCL reported it empty, and the hardware and driver stack were vouched for by a second, working consumer a few shared libraries away. That is the hinge of this whole story: the instant the bug stops being a hardware bug and starts being a linker bug, even though nobody is saying the word &amp;quot;linker&amp;quot; yet.&lt;/p&gt;
&lt;p&gt;One detail about how each consumer reaches the verbs stack turns out to be the whole story. The in-house library was &lt;em&gt;linked&lt;/em&gt; against the verbs copy bundled into the binary. NCCL in its stock build doesn&apos;t link the verbs library at all: it &lt;a href=&quot;https://github.com/NVIDIA/nccl/blob/master/src/misc/ibvsymbols.cc&quot;&gt;dlopens &lt;code&gt;libibverbs&lt;/code&gt; at runtime&lt;/a&gt; and takes every entry point from that handle by versioned &lt;code&gt;dlvsym&lt;/code&gt;, a handle-scoped lookup, the after-&lt;code&gt;main()&lt;/code&gt; loading machinery from &lt;a href=&quot;/blog/ELF-Linking-101/#appendix-h-runtime-loading-dlopendlsym&quot;&gt;Part 1&apos;s Appendix H&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;And every one of these binaries came from one torch commit, so the shared code was the same everywhere. Working versus broken didn&apos;t track hosts, and it didn&apos;t track that shared source. It tracked &lt;em&gt;binaries&lt;/em&gt;. That doesn&apos;t eliminate every difference (two binaries can still diverge in dependencies, configuration, environment), but it aims the suspicion at the step where all of those become bits: how each binary was composed and linked.&lt;/p&gt;
&lt;figure class=&quot;frame diagram&quot;&gt;
  &lt;span class=&quot;frame-title&quot;&gt;fig. 2 · the elimination ladder: everything cleared except the link&lt;/span&gt;
  &lt;div class=&quot;diagram-body&quot;&gt;
    &lt;svg viewBox=&quot;0 0 720 320&quot; role=&quot;img&quot; aria-label=&quot;Diagram: an elimination ladder of suspects — the hardware, the network, the host, the driver and kernel, and the shared source are each cleared by evidence, aiming suspicion at the link, the step where each binary&apos;s composition, dependencies, and flags become bits&quot;&gt;
      &lt;g font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;
        &lt;text x=&quot;36&quot; y=&quot;34&quot;&gt;suspect&lt;/text&gt;
        &lt;text x=&quot;220&quot; y=&quot;34&quot;&gt;evidence&lt;/text&gt;
        &lt;text x=&quot;600&quot; y=&quot;34&quot;&gt;verdict&lt;/text&gt;
      &lt;/g&gt;
      &lt;line x1=&quot;30&quot; y1=&quot;44&quot; x2=&quot;690&quot; y2=&quot;44&quot; stroke=&quot;var(--border)&quot;/&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot; fill=&quot;var(--text)&quot;&gt;
        &lt;text x=&quot;36&quot; y=&quot;70&quot;&gt;the hardware&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;106&quot;&gt;the network&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;142&quot;&gt;the host&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;178&quot;&gt;the driver + kernel&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;214&quot;&gt;the shared source&lt;/text&gt;
      &lt;/g&gt;
      &lt;g font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;
        &lt;text x=&quot;220&quot; y=&quot;70&quot;&gt;checked — cabled, enumerated, no issues found&lt;/text&gt;
        &lt;text x=&quot;220&quot; y=&quot;106&quot;&gt;IP and connectivity tests pass&lt;/text&gt;
        &lt;text x=&quot;220&quot; y=&quot;142&quot;&gt;other binaries use the same device, same machine&lt;/text&gt;
        &lt;text x=&quot;220&quot; y=&quot;178&quot;&gt;the in-house consumer sees every device, same process&lt;/text&gt;
        &lt;text x=&quot;220&quot; y=&quot;214&quot;&gt;every binary built from one torch commit&lt;/text&gt;
      &lt;/g&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot; fill=&quot;var(--ldr)&quot;&gt;
        &lt;text x=&quot;600&quot; y=&quot;70&quot;&gt;✓ cleared&lt;/text&gt;
        &lt;text x=&quot;600&quot; y=&quot;106&quot;&gt;✓ cleared&lt;/text&gt;
        &lt;text x=&quot;600&quot; y=&quot;142&quot;&gt;✓ cleared&lt;/text&gt;
        &lt;text x=&quot;600&quot; y=&quot;178&quot;&gt;✓ cleared&lt;/text&gt;
        &lt;text x=&quot;600&quot; y=&quot;214&quot;&gt;✓ cleared&lt;/text&gt;
      &lt;/g&gt;
      &lt;g stroke=&quot;var(--border)&quot;&gt;
        &lt;line x1=&quot;30&quot; y1=&quot;82&quot; x2=&quot;690&quot; y2=&quot;82&quot;/&gt;
        &lt;line x1=&quot;30&quot; y1=&quot;118&quot; x2=&quot;690&quot; y2=&quot;118&quot;/&gt;
        &lt;line x1=&quot;30&quot; y1=&quot;154&quot; x2=&quot;690&quot; y2=&quot;154&quot;/&gt;
        &lt;line x1=&quot;30&quot; y1=&quot;190&quot; x2=&quot;690&quot; y2=&quot;190&quot;/&gt;
        &lt;line x1=&quot;30&quot; y1=&quot;226&quot; x2=&quot;690&quot; y2=&quot;226&quot;/&gt;
      &lt;/g&gt;
      &lt;rect x=&quot;30&quot; y=&quot;244&quot; width=&quot;660&quot; height=&quot;40&quot; fill=&quot;var(--accent)&quot; opacity=&quot;0.1&quot;/&gt;
      &lt;rect x=&quot;30&quot; y=&quot;244&quot; width=&quot;660&quot; height=&quot;40&quot; fill=&quot;none&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.5&quot;/&gt;
      &lt;text x=&quot;36&quot; y=&quot;269&quot; font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot; fill=&quot;var(--accent)&quot;&gt;the link&lt;/text&gt;
      &lt;text x=&quot;220&quot; y=&quot;262&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot; fill=&quot;var(--text)&quot;&gt;where composition, dependencies, and flags&lt;/text&gt;
      &lt;text x=&quot;220&quot; y=&quot;276&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot; fill=&quot;var(--text)&quot;&gt;become bits — the step that differed&lt;/text&gt;
      &lt;text x=&quot;600&quot; y=&quot;269&quot; font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot; fill=&quot;var(--accent)&quot;&gt;← what&apos;s left&lt;/text&gt;
      &lt;text x=&quot;360&quot; y=&quot;308&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;11&quot; fill=&quot;var(--muted)&quot;&gt;working vs broken tracked binaries, not hosts, not source&lt;/text&gt;
    &lt;/svg&gt;
    &lt;p class=&quot;legend&quot;&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--ldr)&quot;&gt;&lt;/span&gt;suspect cleared&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--accent)&quot;&gt;&lt;/span&gt;the remaining suspect&lt;/span&gt;
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;I should be honest about the stakes, because they explain the depth of the eventual dig. This was a SEV, and a recurrence of an earlier SEV that had been mitigated without ever being fully root-caused. The failure had been here before, been made to go away, and come back.&lt;/p&gt;
&lt;p&gt;Running it to ground this time meant descending through layers that don&apos;t usually share a whiteboard: how shared libraries are loaded for a binary, how Python links native extensions, and how the RDMA user-space drivers initialize. The root cause, once it surfaced, fit in a sentence: a symbol collision, from double inclusion of the same shared library.&lt;/p&gt;
&lt;p&gt;Because once we looked inside those binaries, the constructors &lt;em&gt;had&lt;/em&gt; run. A verbs stack (libibverbs, the mlx5 provider) initialized and registered its devices; the in-house library discovered them and ran happily on top. NCCL&apos;s discovery, in the same process, still came back empty. The state that initialization filled and the state that NCCL&apos;s discovery read had the same symbol names, and were not the same memory.&lt;/p&gt;
&lt;h2&gt;3. What was actually happening&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Four ELF rules this section leans on&lt;/strong&gt; (all from &lt;a href=&quot;/blog/ELF-Linking-101/&quot;&gt;Part 1&lt;/a&gt;). One, the global scope wins a relocation lookup: for a newly loaded object, glibc searches the main program and its startup dependencies before the object&apos;s own group. Two, &lt;code&gt;RTLD_LOCAL&lt;/code&gt; is not a private namespace: it keeps a dlopened library&apos;s names out of the global scope, but does not stop that library from seeing global definitions already there. Three, a handle lookup changes the search root: &lt;code&gt;dlsym&lt;/code&gt;/&lt;code&gt;dlvsym&lt;/code&gt; on a handle searches that object and its own dependencies, nobody else&apos;s. Four, a pointer returned by &lt;code&gt;dlvsym&lt;/code&gt; is already an address: calling through it triggers no second lookup.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Two things were true at once, and they should not have been. Initialization had run: the verbs stack had walked the device list and written the results into its tables. And NCCL&apos;s discovery call, a moment later, found the tables it was reading empty. Both sides were using the same symbol names. They were not reaching the same state.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Evidence status.&lt;/strong&gt; I no longer have the internal diffs, so the graph below is a best-fit reconstruction, and it is worth being explicit about what rests on what. &lt;em&gt;Observed&lt;/em&gt; during the incident: the hardware was present, the in-house consumer saw the devices, NCCL did not, and working-versus-broken tracked the binary, not the host or the source. &lt;em&gt;Verified from public source:&lt;/em&gt; NCCL dlopens verbs and takes its entry points by &lt;code&gt;dlvsym&lt;/code&gt;, rdma-core keeps a per-instance driver registry, and glibc searches the global scope before a newly loaded object&apos;s own dependency group. &lt;em&gt;Reconstructed:&lt;/em&gt; the bundled registration symbol was exported from a link-group DSO and captured the system provider&apos;s registration. &lt;em&gt;Demonstrated separately:&lt;/em&gt; the two labs in section 4. &lt;em&gt;Unavailable:&lt;/em&gt; the original failing binary&apos;s own binding trace and link map.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In &lt;a href=&quot;/blog/ELF-Linking-101/#part-iii-the-loader-takes-control-user-mode&quot;&gt;Part 1&lt;/a&gt; we traced how a dynamically linked program comes to life: &lt;code&gt;ld.so&lt;/code&gt; maps the executable and its libraries, builds the lookup scope, and resolves every reference to exactly one definition, &lt;em&gt;per lookup&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;What Part 1 never had to confront is that &amp;quot;one definition per lookup&amp;quot; is not &amp;quot;one definition.&amp;quot; If a strong, non-weak C symbol is defined in two modules, both definitions exist in the process image, and which one a reference binds to depends on where that reference lives and how its module was built. No error fires, because the two definitions never enter the same link: one is compiled into the executable, the other into a shared library, and a shared library&apos;s definitions do not collide at link time with the executable&apos;s. C does have a one-definition rule (§6.9 requires exactly one external definition per name in the whole program), but violating it is undefined behavior with no diagnostic required, and nothing in the separately-linked ELF pipeline is positioned to enforce it across that boundary.&lt;/p&gt;
&lt;p&gt;Who wins when both copies exist? Whichever definition comes first in the global scope, and the scope is searched executable-first: this is Part 1&apos;s lookup-order rule doing exactly what it promised. So when the executable exports the name (and GNU ld puts it in the executable&apos;s dynamic symbol table as soon as a shared library on the link line references it, even without &lt;code&gt;-rdynamic&lt;/code&gt;), every dynamically resolved reference to the duplicated name lands on the executable&apos;s copy. That&apos;s interposition, the same feature that lets &lt;code&gt;LD_PRELOAD&lt;/code&gt; swap in a debugging allocator.&lt;/p&gt;
&lt;p&gt;Crucially, in a default build it applies to the shared library&apos;s &lt;em&gt;own&lt;/em&gt; references too: the library calls its own functions through the same lookup (the &lt;a href=&quot;/blog/ELF-Linking-101/#34-lazy-binding-watched-live&quot;&gt;PLT indirection from Part 1&lt;/a&gt;), gets the executable&apos;s copy like everyone else, and the process stays consistent on one winner. Wasteful, but coherent.&lt;/p&gt;
&lt;p&gt;The process can stop agreeing on one winner in more than one way. The cleanest trigger, the one the reproducer in section 4 uses to make the split happen on demand, is a library that opts out of being interposed. Build a shared library with &lt;code&gt;-Bsymbolic-functions&lt;/code&gt; (or protected visibility) and its internal references are bound to its own definitions at link time, skipping the runtime lookup. Now the two copies stop agreeing: the library&apos;s constructor runs against the library&apos;s copy, while every other module&apos;s references still resolve through the global scope to the executable&apos;s copy.&lt;/p&gt;
&lt;p&gt;The incident reached the same split through a different door: the migration from section 1. Its double inclusion, the one the root cause named, had two copies with names and a history. &lt;strong&gt;Copy A&lt;/strong&gt;, the bundled copy, arrived with the in-house collective library: enabling MTIA support pulled libibverbs and the mlx5 provider into the binary&apos;s composition. In a binary still composed as libomnibus, that copy was merged into the blob and &lt;em&gt;localized&lt;/em&gt;: section 1&apos;s &lt;code&gt;local: *;&lt;/code&gt; version script kept the verbs symbols out of the process&apos;s dynamic symbol tables entirely, and every verbs call the in-house library made resolved to that internal copy, end to end.&lt;/p&gt;
&lt;p&gt;(rdma-core is happy to live self-contained: a static build &lt;a href=&quot;https://github.com/linux-rdma/rdma-core/blob/master/libibverbs/dynamic_driver.c&quot;&gt;compiles the dlopen loader out entirely&lt;/a&gt; and pulls providers in through &lt;a href=&quot;https://github.com/linux-rdma/rdma-core/blob/master/libibverbs/static_driver.c&quot;&gt;&lt;code&gt;ibv_static_providers()&lt;/code&gt;&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Copy B&lt;/strong&gt; was the system&apos;s: the same stack reached at runtime through &lt;code&gt;dlopen&lt;/code&gt;. That is how NCCL gets its verbs, deliberately &lt;a href=&quot;https://github.com/NVIDIA/nccl/blob/master/src/misc/ibvsymbols.cc&quot;&gt;loading &lt;code&gt;libibverbs&lt;/code&gt; at runtime&lt;/a&gt; instead of linking it, with a bare-name &lt;code&gt;dlopen(&amp;quot;libibverbs.so.1&amp;quot;)&lt;/code&gt; that the loader&apos;s search resolved, on these hosts, to the system copy. And it is how that libibverbs in turn finds its hardware providers, &lt;a href=&quot;https://github.com/linux-rdma/rdma-core/blob/master/libibverbs/dynamic_driver.c&quot;&gt;dlopening the driver&lt;/a&gt; named in its config. Two instances, two consumers, zero contact: a hidden copy and a public copy have nothing to fight over. Every binary on that side of the migration worked.&lt;/p&gt;
&lt;p&gt;Then a binary migrated to link groups, and the bundled verbs changed posture. Carved into a link group, libibverbs stopped being a localized region of a blob and became a genuine shared library whose symbols were exported, default visibility, into the process&apos;s global dynamic scope.&lt;/p&gt;
&lt;p&gt;Precision matters here, because the naive next sentence (&amp;quot;and they collided with the system copy&apos;s identical names&amp;quot;) is wrong in an instructive way. The system copy&apos;s names never entered the global scope: &lt;code&gt;dlopen&lt;/code&gt; defaults to &lt;code&gt;RTLD_LOCAL&lt;/code&gt;, which keeps a loaded library&apos;s names out of the global lookup.&lt;/p&gt;
&lt;p&gt;A second precision, and it is load-bearing: why did that &lt;code&gt;dlopen&lt;/code&gt; map a fresh system copy at all, with a verbs stack already resident in the image? Because the loader reuses libraries by &lt;em&gt;name&lt;/em&gt;, not by contents. &lt;code&gt;dlopen(&amp;quot;libibverbs.so.1&amp;quot;)&lt;/code&gt; first walks the objects already loaded, comparing the request against each one&apos;s names and &lt;code&gt;SONAME&lt;/code&gt; (&lt;a href=&quot;https://github.com/bminor/glibc/blob/master/elf/dl-load.c&quot;&gt;glibc&apos;s &lt;code&gt;_dl_lookup_map&lt;/code&gt;&lt;/a&gt;), and maps a new file only when nothing answers. Had the bundled copy been shipped as a standalone &lt;code&gt;libibverbs.so.1&lt;/code&gt;, it would have answered: NCCL&apos;s &lt;code&gt;dlopen&lt;/code&gt; would have received the bundled copy, one instance, no split, no bug. But the bundled copy&apos;s symbols lived inside a link-group DSO carrying the group&apos;s own name. Nothing in the process answered to &lt;code&gt;libibverbs.so.1&lt;/code&gt;, so the loader mapped the system file, and the image now held two.&lt;/p&gt;
&lt;p&gt;That is the ledger to hold for everything that follows. &lt;strong&gt;Copy A&lt;/strong&gt;: bundled, exported into the global scope, the in-house library linked to it. &lt;strong&gt;Copy B&lt;/strong&gt;: the system copy, dlopened &lt;code&gt;RTLD_LOCAL&lt;/code&gt;, NCCL pinned to its handle. The surprise, and the whole bug: both providers register into A.&lt;/p&gt;
&lt;figure class=&quot;frame diagram&quot;&gt;
  &lt;span class=&quot;frame-title&quot;&gt;fig. 3 · the two rails: every registration ran left, NCCL read right&lt;/span&gt;
  &lt;div class=&quot;diagram-body&quot;&gt;
    &lt;svg viewBox=&quot;0 0 720 410&quot; role=&quot;img&quot; aria-label=&quot;Diagram of the incident topology: two live copies of libibverbs — the bundled copy A exported from a link group, and the system copy B dlopened RTLD_LOCAL. Both mlx5 provider constructors&apos; registration imports resolve through the global scope into copy A&apos;s registry, including the system provider&apos;s, which is captured. NCCL is pinned by handle-scoped lookup to copy B, whose registry stays empty, so it finds zero devices, while the in-house library reads copy A and sees both.&quot;&gt;
      &lt;defs&gt;
        &lt;marker id=&quot;p2f3w&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--ldr)&quot;/&gt;
        &lt;/marker&gt;
        &lt;marker id=&quot;p2f3a&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--accent)&quot;/&gt;
        &lt;/marker&gt;
        &lt;marker id=&quot;p2f3r&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;/marker&gt;
        &lt;marker id=&quot;p2f3s&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--seg)&quot;/&gt;
        &lt;/marker&gt;
      &lt;/defs&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot;&gt;
        &lt;!-- copy A --&gt;
        &lt;rect x=&quot;28&quot; y=&quot;36&quot; width=&quot;320&quot; height=&quot;118&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.07&quot;/&gt;
        &lt;rect x=&quot;28&quot; y=&quot;36&quot; width=&quot;320&quot; height=&quot;118&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;44&quot; y=&quot;58&quot; fill=&quot;var(--sec)&quot;&gt;bundled libibverbs — copy A&lt;/text&gt;
        &lt;text x=&quot;44&quot; y=&quot;74&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;link group: exported into the global scope&lt;/text&gt;
        &lt;rect x=&quot;46&quot; y=&quot;88&quot; width=&quot;284&quot; height=&quot;52&quot; fill=&quot;none&quot; stroke=&quot;var(--muted)&quot; stroke-dasharray=&quot;3 3&quot;/&gt;
        &lt;text x=&quot;58&quot; y=&quot;109&quot; fill=&quot;var(--sec)&quot;&gt;driver registry&lt;/text&gt;
        &lt;text x=&quot;58&quot; y=&quot;128&quot; font-size=&quot;10&quot; fill=&quot;var(--ldr)&quot;&gt;mlx5 registered — every write landed here&lt;/text&gt;
        &lt;!-- copy B --&gt;
        &lt;rect x=&quot;392&quot; y=&quot;36&quot; width=&quot;300&quot; height=&quot;118&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.07&quot;/&gt;
        &lt;rect x=&quot;392&quot; y=&quot;36&quot; width=&quot;300&quot; height=&quot;118&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;408&quot; y=&quot;58&quot; fill=&quot;var(--seg)&quot;&gt;system libibverbs — copy B&lt;/text&gt;
        &lt;text x=&quot;408&quot; y=&quot;74&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;dlopen&apos;d RTLD_LOCAL: names stay private&lt;/text&gt;
        &lt;rect x=&quot;410&quot; y=&quot;88&quot; width=&quot;264&quot; height=&quot;52&quot; fill=&quot;none&quot; stroke=&quot;var(--muted)&quot; stroke-dasharray=&quot;3 3&quot;/&gt;
        &lt;text x=&quot;422&quot; y=&quot;109&quot; fill=&quot;var(--seg)&quot;&gt;driver registry&lt;/text&gt;
        &lt;text x=&quot;422&quot; y=&quot;128&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;empty — no constructor could reach it&lt;/text&gt;
        &lt;!-- actors --&gt;
        &lt;rect x=&quot;24&quot; y=&quot;300&quot; width=&quot;152&quot; height=&quot;56&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;24&quot; y=&quot;300&quot; width=&quot;152&quot; height=&quot;56&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;36&quot; y=&quot;321&quot; fill=&quot;var(--sec)&quot;&gt;in-house lib&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;338&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;linked to copy A&lt;/text&gt;
        &lt;rect x=&quot;198&quot; y=&quot;300&quot; width=&quot;152&quot; height=&quot;56&quot; fill=&quot;var(--ldr)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;198&quot; y=&quot;300&quot; width=&quot;152&quot; height=&quot;56&quot; fill=&quot;none&quot; stroke=&quot;var(--ldr)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;210&quot; y=&quot;321&quot; fill=&quot;var(--ldr)&quot;&gt;bundled mlx5 ctor&lt;/text&gt;
        &lt;text x=&quot;210&quot; y=&quot;338&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;verbs_register_…_&amp;lt;N&amp;gt;&lt;/text&gt;
        &lt;rect x=&quot;372&quot; y=&quot;300&quot; width=&quot;152&quot; height=&quot;56&quot; fill=&quot;var(--ldr)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;372&quot; y=&quot;300&quot; width=&quot;152&quot; height=&quot;56&quot; fill=&quot;none&quot; stroke=&quot;var(--ldr)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;384&quot; y=&quot;321&quot; fill=&quot;var(--ldr)&quot;&gt;system mlx5 ctor&lt;/text&gt;
        &lt;text x=&quot;384&quot; y=&quot;338&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;dlopened by copy B&lt;/text&gt;
        &lt;rect x=&quot;546&quot; y=&quot;300&quot; width=&quot;150&quot; height=&quot;56&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;546&quot; y=&quot;300&quot; width=&quot;150&quot; height=&quot;56&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;558&quot; y=&quot;321&quot; fill=&quot;var(--seg)&quot;&gt;NCCL&lt;/text&gt;
        &lt;text x=&quot;558&quot; y=&quot;338&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;pinned to B&apos;s handle&lt;/text&gt;
      &lt;/g&gt;
      &lt;g stroke-width=&quot;1.5&quot; fill=&quot;none&quot;&gt;
        &lt;path d=&quot;M 100 296 L 100 158&quot; stroke=&quot;var(--sec)&quot; marker-end=&quot;url(#p2f3r)&quot;/&gt;
        &lt;path d=&quot;M 274 296 L 274 158&quot; stroke=&quot;var(--ldr)&quot; marker-end=&quot;url(#p2f3w)&quot;/&gt;
        &lt;path d=&quot;M 448 296 C 448 230, 330 220, 322 158&quot; stroke=&quot;var(--accent)&quot; marker-end=&quot;url(#p2f3a)&quot;/&gt;
        &lt;path d=&quot;M 621 296 L 621 158&quot; stroke=&quot;var(--seg)&quot; marker-end=&quot;url(#p2f3s)&quot;/&gt;
      &lt;/g&gt;
      &lt;g font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot;&gt;
        &lt;text x=&quot;108&quot; y=&quot;248&quot; fill=&quot;var(--sec)&quot;&gt;reads A — 2 devices ✓&lt;/text&gt;
        &lt;text x=&quot;282&quot; y=&quot;212&quot; fill=&quot;var(--ldr)&quot;&gt;registers&lt;/text&gt;
        &lt;text x=&quot;440&quot; y=&quot;252&quot; fill=&quot;var(--accent)&quot;&gt;captured — global scope first&lt;/text&gt;
        &lt;text x=&quot;613&quot; y=&quot;212&quot; text-anchor=&quot;end&quot; fill=&quot;var(--seg)&quot;&gt;reads B — 0 devices&lt;/text&gt;
      &lt;/g&gt;
      &lt;text x=&quot;360&quot; y=&quot;394&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;11&quot; fill=&quot;var(--accent)&quot;&gt;the import binds by scope, not by caller: order doesn&apos;t matter, so it failed the same way every time&lt;/text&gt;
    &lt;/svg&gt;
    &lt;p class=&quot;legend&quot;&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--sec)&quot;&gt;&lt;/span&gt;copy A: bundled, exported&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--seg)&quot;&gt;&lt;/span&gt;copy B: system, private&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--ldr)&quot;&gt;&lt;/span&gt;registration writes&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--accent)&quot;&gt;&lt;/span&gt;the captured import&lt;/span&gt;
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;Before the scope mechanics, the failure in one plain sentence: the system provider meant to register with the system libibverbs loaded right beside it, and the global-scope lookup sent that registration into the bundled copy instead. Here is how, step by step.&lt;/p&gt;
&lt;p&gt;Neither consumer was ever confused about which instance it was calling. NCCL takes its entry points by &lt;code&gt;dlvsym&lt;/code&gt; on its own handle, a lookup that sees only that handle&apos;s little world, so the pointers NCCL held were pinned to copy B, unreachable by interposition, by construction. The pinning covers the pointers NCCL holds, not the copy itself: copy B&apos;s own outward references still resolve like anyone else&apos;s, and that is about to matter. The in-house library was equally settled the other way: its verbs references resolved through the global scope to copy A, the only definition on offer there. Two consumers, each faithfully wired to one instance. So far, that is just the omnibus arrangement with the curtain open. The bug needs one more reference, one that has to &lt;em&gt;cross&lt;/em&gt; between the worlds.&lt;/p&gt;
&lt;p&gt;That reference lives in how rdma-core keeps its books. Every instance of libibverbs carries its own file-static state: the device list &lt;code&gt;ibv_get_device_list()&lt;/code&gt; hands back lives in &lt;a href=&quot;https://github.com/linux-rdma/rdma-core/blob/master/libibverbs/device.c&quot;&gt;a static inside &lt;code&gt;device.c&lt;/code&gt;&lt;/a&gt;, and the driver registry it is built from is &lt;a href=&quot;https://github.com/linux-rdma/rdma-core/blob/master/libibverbs/init.c&quot;&gt;a static &lt;code&gt;driver_list&lt;/code&gt; inside &lt;code&gt;init.c&lt;/code&gt;&lt;/a&gt;. And rdma-core&apos;s &lt;a href=&quot;https://github.com/linux-rdma/rdma-core/blob/master/libibverbs/libibverbs.map.in&quot;&gt;own version script&lt;/a&gt; marks the machinery around them local, so each instance&apos;s discovery is welded to its own tables at link time.&lt;/p&gt;
&lt;p&gt;Providers are not handed over; they announce themselves: &lt;a href=&quot;https://github.com/linux-rdma/rdma-core/blob/master/providers/mlx5/mlx5.c&quot;&gt;libmlx5&lt;/a&gt; runs an ELF constructor, the &lt;a href=&quot;https://github.com/linux-rdma/rdma-core/blob/master/libibverbs/driver.h&quot;&gt;&lt;code&gt;PROVIDER_DRIVER&lt;/code&gt; macro&lt;/a&gt;, that calls &lt;code&gt;verbs_register_driver_&amp;lt;N&amp;gt;()&lt;/code&gt; (rdma-core bakes its private-ABI number into the symbol name itself) to append the mlx5 driver to a registry. But &lt;em&gt;which&lt;/em&gt; registry is not the loading instance&apos;s choice to make.&lt;/p&gt;
&lt;p&gt;And ELF binds that call by lookup scope, not by which library loaded the caller. The constructor&apos;s call is a plain extern function call, not a function pointer, not a &lt;code&gt;dlsym&lt;/code&gt;: an undefined import that the dynamic linker resolves against the global scope &lt;em&gt;first&lt;/em&gt; and the dlopen&apos;s own dependency scope second. The provider&apos;s &lt;code&gt;DT_NEEDED&lt;/code&gt; on &lt;code&gt;libibverbs.so.1&lt;/code&gt; decides what gets loaded beside it (&lt;a href=&quot;/blog/ELF-Linking-101/#32-dependency-discovery&quot;&gt;Part 1&apos;s dependency-discovery step&lt;/a&gt;), not who wins the lookup. And &lt;code&gt;RTLD_DEEPBIND&lt;/code&gt;, the one switch that flips the order, is nowhere in this stack. The global scope now offered copy A&apos;s exported definition.&lt;/p&gt;
&lt;p&gt;So every registration in the process (the bundled provider&apos;s, the system provider&apos;s that copy B dlopened for NCCL, whichever fired first) resolved to copy A and filled &lt;em&gt;copy A&apos;s&lt;/em&gt; registry. Interposition, doing exactly what Part 1 promised, to the one call that crossed the boundary. Copy B, the instance NCCL was pinned to, sitting right there as the provider&apos;s own &lt;code&gt;DT_NEEDED&lt;/code&gt; dependency, kept a registry that no constructor could ever reach.&lt;/p&gt;
&lt;p&gt;Discovery then walks sysfs and matches what the kernel reports against its &lt;em&gt;own&lt;/em&gt; instance&apos;s registry, and a device that matches no registered driver is silently dropped from the result (the warning that would have named the problem hides behind an &lt;code&gt;IBV_SHOW_WARNINGS&lt;/code&gt; environment variable).&lt;/p&gt;
&lt;p&gt;Run the two consumers side by side. The in-house library reads copy A: the registrations landed there, every device matches, MTIA trains. NCCL reads copy B: registry empty, every device the kernel reported matches no driver, zero devices. &lt;code&gt;No IB devices found&lt;/code&gt;, from the fleet&apos;s most trusted RDMA consumer, on a machine where the library that had smuggled the second copy in could see them all.&lt;/p&gt;
&lt;p&gt;Note what the mechanism does &lt;em&gt;not&lt;/em&gt; depend on: load order among the consumers. Copy A&apos;s exports entered the global scope at startup (&lt;code&gt;DT_NEEDED&lt;/code&gt; wiring, in place before either consumer moved), so whichever consumer initializes first, the registration import binds by scope, not by caller. That is why every binary was consistent about failing, every time.&lt;/p&gt;
&lt;p&gt;(The stability is a property of this startup topology, mind. A second copy arriving &lt;em&gt;late&lt;/em&gt;, by an &lt;code&gt;RTLD_GLOBAL&lt;/code&gt; dlopen mid-run, would not rebind references already resolved. Here there was nothing to rebind: the scope was set before the first constructor fired.)&lt;/p&gt;
&lt;p&gt;And that is section 1&apos;s riddle solved: same commit, opposite behavior, because binaries still on omnibus carried a hidden copy that captured nothing, and binaries on link groups carried an exported copy that captured every registration. Whether NCCL could see the hardware depended on which side of a build-system migration its binary stood. (The dlopen road is also a preview: it is exactly the runtime-scope machinery that returns in section 7 as Route B.)&lt;/p&gt;
&lt;p&gt;That is the whole disease, and it is worth naming in its own right: &lt;strong&gt;split-state linking&lt;/strong&gt;, two live copies of one library&apos;s state in a single process, with references silently partitioned between them.&lt;/p&gt;
&lt;p&gt;The topology did the capturing; on top of it, two conditions specific to rdma-core still had to hold for the collision to land at all: the copies must agree on rdma-core&apos;s private ABI number (it is baked into the registration symbol&apos;s name), and symbol versioning must not block the cross-copy match (it does not, for reasons glibc&apos;s own source comments on). &lt;a href=&quot;#appendix-b-two-preconditions-for-the-capture&quot;&gt;Appendix B&lt;/a&gt; walks both.&lt;/p&gt;
&lt;h2&gt;4. Reproducing it&lt;/h2&gt;
&lt;p&gt;A claim like that is easy to state and easy to doubt, so here are two reproducers you can run in minutes, no special hardware needed. The first rebuilds section 3&apos;s binding topology directly and watches the registration get captured. The second strips the mechanism down to a model and builds the split six ways, to pin down exactly which ingredients produce it.&lt;/p&gt;
&lt;h3&gt;The production topology, reproduced&lt;/h3&gt;
&lt;p&gt;The first lab, &lt;a href=&quot;https://github.com/dshah133/howtf/tree/main/demo/rdma-symbol-collision/scope-capture&quot;&gt;&lt;code&gt;scope-capture/&lt;/code&gt;&lt;/a&gt; in the repo, restages the incident&apos;s own door: the split from scope alone, no self-binding flag, no copy in the executable. Three shared libraries, all default visibility:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;libbundle.so&lt;/code&gt; is copy A, a &lt;code&gt;DT_NEEDED&lt;/code&gt; dependency of the app, so its &lt;code&gt;register_driver&lt;/code&gt; sits in the global scope from startup.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;libregistryB.so.1&lt;/code&gt; is copy B, the system libibverbs, &lt;code&gt;dlopen&lt;/code&gt;ed &lt;code&gt;RTLD_LOCAL&lt;/code&gt; so its names stay private, reached only through its own handle.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;libproviderB.so&lt;/code&gt; is the provider. Its &lt;code&gt;DT_NEEDED&lt;/code&gt; is copy B, and its constructor calls &lt;code&gt;register_driver&lt;/code&gt; as a plain extern import.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That constructor call is the whole incident in one line. It is an undefined import, so the loader resolves it against the global scope first and the provider&apos;s own dependency group second. Copy A is global; copy B is only in the local group. The registration lands in copy A, captured, even though copy B is the provider&apos;s own dependency sitting right beside it. &lt;code&gt;LD_DEBUG=bindings&lt;/code&gt; says so directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;binding file libproviderB.so to libbundle.so: normal symbol `register_driver&apos; [VERB_1.0]
binding file libregistryB.so.1 to libregistryB.so.1: normal symbol `get_device_list&apos; [VERB_1.0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The provider binds &lt;code&gt;register_driver&lt;/code&gt; to libbundle, copy A. Copy B answers only its own &lt;code&gt;get_device_list&lt;/code&gt;. Then each consumer reads where it was always going to: the in-house side, linked to copy A, sees the device; NCCL, by &lt;code&gt;dlvsym&lt;/code&gt; on copy B&apos;s handle, reads copy B&apos;s empty registry.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;[bundle / copy A] register_driver(mlx5_from_providerB) -&amp;gt; registry A @0x73da35e88040 now holds 1 device(s)
    in-house consumer sees 1 device(s)   [OK -- reads the copy the registration landed in]
[registryB / copy B] get_device_list  &amp;lt;- registry B @0x73da35e83060 holds 0 device(s)
    NCCL consumer sees 0 device(s)   *** No IB devices found -- the registration went to the OTHER copy ***
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two registry addresses, the write on one and the read on the other, no self-binding flag anywhere in the build. &lt;code&gt;make fixed&lt;/code&gt; confirms the mechanism from the far side: localize copy A&apos;s &lt;code&gt;register_driver&lt;/code&gt; so it leaves the global scope, and the provider&apos;s import falls through to copy B. Registration and discovery reunite there, and NCCL sees the device. That is also the lesson section 6&apos;s naive visibility fix misses: the copy you hide has to be the one wrongly winning the global lookup, the bundled copy A, not the system copy B.&lt;/p&gt;
&lt;h3&gt;The trigger, isolated&lt;/h3&gt;
&lt;p&gt;The scope-capture lab reproduces the exact door but fixes the topology. To map the boundary, when a split happens and when it doesn&apos;t, the second lab strips out the hardware and the scope machinery and models the mechanism directly: a small &amp;quot;verbs&amp;quot; library present in two copies (one static in the executable, one shared), a collective that performs discovery, and a couple of device names the library registers. Nothing links &lt;code&gt;libibverbs&lt;/code&gt;. The point is the linker, not the hardware. &lt;code&gt;make matrix&lt;/code&gt; builds the same scenario six ways and prints, for each, the address of the table the constructor wrote and the address of the table discovery read. Same address, no split. Different address, split. Whether a given build splits is a comparison of two hexadecimal numbers, not a matter of interpretation. (A second variant in the repo restages this split on real soft-RoCE (&lt;code&gt;rdma_rxe&lt;/code&gt;) devices, through &lt;code&gt;ibv_open_device&lt;/code&gt;, for anyone who wants the hardware path.)&lt;/p&gt;
&lt;p&gt;This lab reaches the split through the cleanest switch that produces it on demand, a self-binding flag, rather than the scope-capture lab&apos;s dlopen scoping. Section 7 comes back to why that difference matters. Here is the splitting configuration&apos;s actual output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shellsession&quot;&gt;[constructor in copy=SHARED] registering rxe_train, rxe_store
[register -&amp;gt; copy=SHARED table@0xffff90340028] now holds 2 device(s)
[get_list &amp;lt;- copy=STATIC table@0xaaaad8f00018] this copy holds 0 device(s)
collective: discovered 0 device(s)   *** DEVICE NOT FOUND -- but the
constructor DID register devices, into the OTHER copy ***
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The constructor registered both devices. Discovery found zero. Different addresses, different copies. That is the production failure&apos;s shape, drawn small (the reproducer&apos;s simplified topology, not the production one: here the second copy is statically linked into the executable, where the incident&apos;s arrived by dlopen):&lt;/p&gt;
&lt;figure class=&quot;frame diagram&quot;&gt;
  &lt;span class=&quot;frame-title&quot;&gt;fig. 4 · the reproducer, simplified: the constructor filled one copy, discovery read the other&lt;/span&gt;
  &lt;div class=&quot;diagram-body&quot;&gt;
    &lt;svg viewBox=&quot;0 0 720 350&quot; role=&quot;img&quot; aria-label=&quot;Diagram: the application binary holds a static copy of the device table which stays empty, while the shared verbs library built with -Bsymbolic-functions holds its own copy which the constructor fills with two devices; the collective library&apos;s discovery call binds across the interposition boundary to the empty executable copy&quot;&gt;
      &lt;defs&gt;
        &lt;marker id=&quot;p2f1w&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--ldr)&quot;/&gt;
        &lt;/marker&gt;
        &lt;marker id=&quot;p2f1r&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;/marker&gt;
      &lt;/defs&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot;&gt;
        &lt;!-- executable side --&gt;
        &lt;rect x=&quot;28&quot; y=&quot;40&quot; width=&quot;306&quot; height=&quot;128&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.07&quot;/&gt;
        &lt;rect x=&quot;28&quot; y=&quot;40&quot; width=&quot;306&quot; height=&quot;128&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;44&quot; y=&quot;62&quot; fill=&quot;var(--sec)&quot;&gt;the application binary&lt;/text&gt;
        &lt;text x=&quot;44&quot; y=&quot;78&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;verbs stack statically linked in&lt;/text&gt;
        &lt;rect x=&quot;46&quot; y=&quot;92&quot; width=&quot;270&quot; height=&quot;60&quot; fill=&quot;none&quot; stroke=&quot;var(--muted)&quot; stroke-dasharray=&quot;3 3&quot;/&gt;
        &lt;text x=&quot;58&quot; y=&quot;115&quot; fill=&quot;var(--sec)&quot;&gt;vx_devices[] — the exe&apos;s copy&lt;/text&gt;
        &lt;text x=&quot;58&quot; y=&quot;137&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;0 devices — nobody wrote here&lt;/text&gt;
        &lt;!-- shared library side --&gt;
        &lt;rect x=&quot;386&quot; y=&quot;40&quot; width=&quot;306&quot; height=&quot;128&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.07&quot;/&gt;
        &lt;rect x=&quot;386&quot; y=&quot;40&quot; width=&quot;306&quot; height=&quot;128&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;402&quot; y=&quot;62&quot; fill=&quot;var(--seg)&quot;&gt;libverbs_shared.so&lt;/text&gt;
        &lt;text x=&quot;402&quot; y=&quot;78&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;built -Bsymbolic-functions: self-binding&lt;/text&gt;
        &lt;rect x=&quot;404&quot; y=&quot;92&quot; width=&quot;270&quot; height=&quot;60&quot; fill=&quot;none&quot; stroke=&quot;var(--muted)&quot; stroke-dasharray=&quot;3 3&quot;/&gt;
        &lt;text x=&quot;416&quot; y=&quot;115&quot; fill=&quot;var(--seg)&quot;&gt;vx_devices[] — the .so&apos;s copy&lt;/text&gt;
        &lt;text x=&quot;416&quot; y=&quot;137&quot; font-size=&quot;10&quot; fill=&quot;var(--ldr)&quot;&gt;rxe_train, rxe_store — 2 devices ✓&lt;/text&gt;
        &lt;!-- reader --&gt;
        &lt;rect x=&quot;60&quot; y=&quot;222&quot; width=&quot;230&quot; height=&quot;52&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;60&quot; y=&quot;222&quot; width=&quot;230&quot; height=&quot;52&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;76&quot; y=&quot;243&quot; fill=&quot;var(--sec)&quot;&gt;libcollective.so&lt;/text&gt;
        &lt;text x=&quot;76&quot; y=&quot;260&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;vx_get_device_list()&lt;/text&gt;
        &lt;!-- writer --&gt;
        &lt;rect x=&quot;430&quot; y=&quot;222&quot; width=&quot;230&quot; height=&quot;52&quot; fill=&quot;var(--ldr)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;430&quot; y=&quot;222&quot; width=&quot;230&quot; height=&quot;52&quot; fill=&quot;none&quot; stroke=&quot;var(--ldr)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;446&quot; y=&quot;243&quot; fill=&quot;var(--ldr)&quot;&gt;the verbs constructor&lt;/text&gt;
        &lt;text x=&quot;446&quot; y=&quot;260&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;runs at load, registers devices&lt;/text&gt;
      &lt;/g&gt;
      &lt;g stroke-width=&quot;1.5&quot; fill=&quot;none&quot;&gt;
        &lt;path d=&quot;M 175 218 L 175 158&quot; stroke=&quot;var(--sec)&quot; marker-end=&quot;url(#p2f1r)&quot;/&gt;
        &lt;path d=&quot;M 545 218 L 545 158&quot; stroke=&quot;var(--ldr)&quot; marker-end=&quot;url(#p2f1w)&quot;/&gt;
      &lt;/g&gt;
      &lt;g font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot;&gt;
        &lt;text x=&quot;187&quot; y=&quot;186&quot; fill=&quot;var(--sec)&quot;&gt;reads the exe&apos;s copy&lt;/text&gt;
        &lt;text x=&quot;187&quot; y=&quot;200&quot; fill=&quot;var(--sec)&quot;&gt;— empty&lt;/text&gt;
        &lt;text x=&quot;557&quot; y=&quot;192&quot; fill=&quot;var(--ldr)&quot;&gt;writes the .so&apos;s copy&lt;/text&gt;
      &lt;/g&gt;
      &lt;line x1=&quot;360&quot; y1=&quot;34&quot; x2=&quot;360&quot; y2=&quot;296&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.4&quot; stroke-dasharray=&quot;5 5&quot;/&gt;
      &lt;text x=&quot;360&quot; y=&quot;330&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;11&quot; fill=&quot;var(--accent)&quot;&gt;the interposition boundary: discovery bound left, the constructor ran right&lt;/text&gt;
    &lt;/svg&gt;
    &lt;p class=&quot;legend&quot;&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--sec)&quot;&gt;&lt;/span&gt;the exe&apos;s copy (read)&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--seg)&quot;&gt;&lt;/span&gt;the .so&apos;s copy&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--ldr)&quot;&gt;&lt;/span&gt;constructor writes&lt;/span&gt;
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The matrix pins down exactly when this happens and, just as important, when it doesn&apos;t. Config &lt;strong&gt;A&lt;/strong&gt;, the default build with no special flags, concedes the obvious objection first: no split. The shared library&apos;s own constructor is interposed onto the executable&apos;s copy, so everyone agrees on one winner. Config &lt;strong&gt;B&lt;/strong&gt; builds the shared library with &lt;code&gt;-Bsymbolic-functions&lt;/code&gt;: split, the constructor writes the .so&apos;s copy while discovery reads the executable&apos;s, and the collective reports &amp;quot;device not found.&amp;quot; Config &lt;strong&gt;C&lt;/strong&gt;, protected visibility on the library&apos;s internals, is an equivalent self-binding trigger: split again. The remaining rows probe the edges (hidden visibility, and two data-symbol variants where copy relocation rescues one case); &lt;a href=&quot;#appendix-c-the-reproducers-edge-rows-hidden-visibility-and-copy-relocation&quot;&gt;Appendix C&lt;/a&gt; walks them.&lt;/p&gt;
&lt;figure class=&quot;frame diagram&quot;&gt;
  &lt;span class=&quot;frame-title&quot;&gt;fig. 5 · the reproducer&apos;s gate: a split by this route needs all three conditions at once&lt;/span&gt;
  &lt;div class=&quot;diagram-body&quot;&gt;
    &lt;svg viewBox=&quot;0 0 720 330&quot; role=&quot;img&quot; aria-label=&quot;Truth-table diagram of the six reproducer configurations: only the rows where a duplicate copy, a self-binding shared library, and an interposing executable are all present produce a split; the default build and the copy-relocated data build do not&quot;&gt;
      &lt;text x=&quot;360&quot; y=&quot;26&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;12&quot; fill=&quot;var(--text)&quot;&gt;SPLIT = duplicate copy AND self-binding .so AND interposing exe&lt;/text&gt;
      &lt;g font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot; text-anchor=&quot;middle&quot;&gt;
        &lt;text x=&quot;300&quot; y=&quot;56&quot;&gt;duplicate&lt;/text&gt;
        &lt;text x=&quot;390&quot; y=&quot;56&quot;&gt;self-binds&lt;/text&gt;
        &lt;text x=&quot;480&quot; y=&quot;56&quot;&gt;interposes&lt;/text&gt;
        &lt;text x=&quot;600&quot; y=&quot;56&quot;&gt;result&lt;/text&gt;
      &lt;/g&gt;
      &lt;line x1=&quot;30&quot; y1=&quot;66&quot; x2=&quot;690&quot; y2=&quot;66&quot; stroke=&quot;var(--border)&quot;/&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot; fill=&quot;var(--text)&quot;&gt;
        &lt;text x=&quot;36&quot; y=&quot;92&quot;&gt;A — default build&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;130&quot;&gt;B — -Bsymbolic-functions&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;168&quot;&gt;C — protected visibility&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;206&quot;&gt;C′ — hidden visibility&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;244&quot;&gt;D1 — data: static in .so&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;282&quot;&gt;D2 — data: global on both&lt;/text&gt;
      &lt;/g&gt;
      &lt;!-- condition cells: filled amber = condition present, hollow = absent --&gt;
      &lt;g&gt;
        &lt;!-- A: yes / no / yes --&gt;
        &lt;rect x=&quot;294&quot; y=&quot;82&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;rect x=&quot;384&quot; y=&quot;82&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;none&quot; stroke=&quot;var(--muted)&quot;/&gt;
        &lt;rect x=&quot;474&quot; y=&quot;82&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;!-- B: yes / yes / yes --&gt;
        &lt;rect x=&quot;294&quot; y=&quot;120&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;rect x=&quot;384&quot; y=&quot;120&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;rect x=&quot;474&quot; y=&quot;120&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;!-- C: yes / yes / yes --&gt;
        &lt;rect x=&quot;294&quot; y=&quot;158&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;rect x=&quot;384&quot; y=&quot;158&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;rect x=&quot;474&quot; y=&quot;158&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;!-- C&apos;: yes / yes / n.a. --&gt;
        &lt;rect x=&quot;294&quot; y=&quot;196&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;rect x=&quot;384&quot; y=&quot;196&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;text x=&quot;480&quot; y=&quot;206&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot; fill=&quot;var(--muted)&quot;&gt;—&lt;/text&gt;
        &lt;!-- D1: yes / yes / yes --&gt;
        &lt;rect x=&quot;294&quot; y=&quot;234&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;rect x=&quot;384&quot; y=&quot;234&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;rect x=&quot;474&quot; y=&quot;234&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;!-- D2: yes / no / yes --&gt;
        &lt;rect x=&quot;294&quot; y=&quot;272&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
        &lt;rect x=&quot;384&quot; y=&quot;272&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;none&quot; stroke=&quot;var(--muted)&quot;/&gt;
        &lt;rect x=&quot;474&quot; y=&quot;272&quot; width=&quot;12&quot; height=&quot;12&quot; fill=&quot;var(--sec)&quot;/&gt;
      &lt;/g&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot;&gt;
        &lt;text x=&quot;552&quot; y=&quot;92&quot; fill=&quot;var(--ldr)&quot;&gt;no split&lt;/text&gt;
        &lt;rect x=&quot;545&quot; y=&quot;116&quot; width=&quot;60&quot; height=&quot;20&quot; fill=&quot;var(--accent)&quot; opacity=&quot;0.18&quot;/&gt;
        &lt;rect x=&quot;545&quot; y=&quot;116&quot; width=&quot;60&quot; height=&quot;20&quot; fill=&quot;none&quot; stroke=&quot;var(--accent)&quot;/&gt;
        &lt;text x=&quot;575&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--accent)&quot;&gt;SPLIT&lt;/text&gt;
        &lt;rect x=&quot;545&quot; y=&quot;154&quot; width=&quot;60&quot; height=&quot;20&quot; fill=&quot;var(--accent)&quot; opacity=&quot;0.18&quot;/&gt;
        &lt;rect x=&quot;545&quot; y=&quot;154&quot; width=&quot;60&quot; height=&quot;20&quot; fill=&quot;none&quot; stroke=&quot;var(--accent)&quot;/&gt;
        &lt;text x=&quot;575&quot; y=&quot;168&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--accent)&quot;&gt;SPLIT&lt;/text&gt;
        &lt;text x=&quot;552&quot; y=&quot;206&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;.so never loads*&lt;/text&gt;
        &lt;rect x=&quot;545&quot; y=&quot;230&quot; width=&quot;60&quot; height=&quot;20&quot; fill=&quot;var(--accent)&quot; opacity=&quot;0.18&quot;/&gt;
        &lt;rect x=&quot;545&quot; y=&quot;230&quot; width=&quot;60&quot; height=&quot;20&quot; fill=&quot;none&quot; stroke=&quot;var(--accent)&quot;/&gt;
        &lt;text x=&quot;575&quot; y=&quot;244&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--accent)&quot;&gt;SPLIT&lt;/text&gt;
        &lt;text x=&quot;552&quot; y=&quot;282&quot; fill=&quot;var(--ldr)&quot;&gt;no split†&lt;/text&gt;
      &lt;/g&gt;
      &lt;g font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;
        &lt;text x=&quot;36&quot; y=&quot;308&quot;&gt;* C′ — --as-needed drops the unreferenced .so; the constructor never runs (a different failure)&lt;/text&gt;
        &lt;text x=&quot;36&quot; y=&quot;322&quot;&gt;† D2 — copy relocation unifies every reference onto the executable&apos;s copy&lt;/text&gt;
      &lt;/g&gt;
    &lt;/svg&gt;
    &lt;p class=&quot;legend&quot;&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--sec)&quot;&gt;&lt;/span&gt;condition present&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--accent)&quot;&gt;&lt;/span&gt;split: two live copies&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--ldr)&quot;&gt;&lt;/span&gt;no split&lt;/span&gt;
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;So duplicate copies alone are not the bug; you need the self-binding trigger on top. And that trigger is not exotic: &lt;code&gt;-Bsymbolic-functions&lt;/code&gt; is used by some build systems to cut binding overhead and symbol preemption, with one nasty property. Unlike full &lt;code&gt;-Bsymbolic&lt;/code&gt;, which sets a &lt;code&gt;DF_SYMBOLIC&lt;/code&gt; flag in the output, &lt;code&gt;-Bsymbolic-functions&lt;/code&gt; leaves &lt;em&gt;no explicit marker in the binary&lt;/em&gt;. No flag, no dynamic tag. The linker simply resolves the internal calls and moves on. What remains is an absence, the relocations those calls no longer need, and you cannot grep a .so for an absence. (Section 7&apos;s scanner learns to read exactly that residue.) The dangerous shape is a &lt;em&gt;function&lt;/em&gt; (or state reached through one) in a &lt;em&gt;self-binding&lt;/em&gt; library.&lt;/p&gt;
&lt;p&gt;The reproducer also demonstrates the part that made the production incident so disorienting. Build the application twice from byte-identical source, once with the redundant static copy on the link line and once without:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shellsession&quot;&gt;### app_with_static    (redundant static copy linked):
  collective: discovered 0 device(s)   *** DEVICE NOT FOUND ***
### app_without_static (single copy):
  collective: discovered 2 device(s)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same source, opposite behavior, decided entirely by link composition. That pair echoes the omnibus-to-link-groups migration: a different trigger, the same phenotype. In a fleet where composition varies per application, it is precisely &amp;quot;some binaries fine, some not, same commit.&amp;quot;&lt;/p&gt;
&lt;p&gt;Toolchain, for the record: gcc 13.3.0, binutils 2.42, Ubuntu 24.04, kernel 6.17-aws for the soft-RoCE variant; reproduced on both aarch64 and x86_64, and re-validated on a clean EC2 instance from the scripts alone, fresh RDMA GUIDs and all. The repo is at &lt;a href=&quot;https://github.com/dshah133/howtf/tree/main/demo/rdma-symbol-collision&quot;&gt;the reproducer repo&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;5. Why nothing warned&lt;/h2&gt;
&lt;p&gt;The uncomfortable part is that every component behaved exactly to spec. The ELF gABI forbids multiple &lt;code&gt;STB_GLOBAL&lt;/code&gt; definitions only among the objects that &lt;em&gt;enter a link&lt;/em&gt;, and a shared library&apos;s definition never enters the executable&apos;s link. (Part 1&apos;s build-time flashback showed this from the other side: &lt;a href=&quot;/blog/ELF-Linking-101/#step-3-synthesis-plt--got&quot;&gt;the linker consulted &lt;code&gt;libmath.so&lt;/code&gt; only to verify the symbol existed and record &lt;code&gt;DT_NEEDED&lt;/code&gt;&lt;/a&gt;. It never took the code.) The GNU ld manual describes archive members being pulled lazily, left to right, once. lld&apos;s documentation states the situation without alarm: two links can &amp;quot;both succeed but they have selected different objects from different archives that both define the same symbols.&amp;quot; C&apos;s one-external-definition rule (§6.9) carries no required diagnostic and no COMDAT machinery to enforce it here; nothing even checks that the two definitions are the same code.&lt;/p&gt;
&lt;p&gt;So no diagnostic fires by default, and the opt-in diagnostics that exist each miss this class. &lt;code&gt;--warn-backrefs&lt;/code&gt; catches order-dependent archive resolution, not cross-boundary duplication. gold&apos;s &lt;code&gt;--detect-odr-violations&lt;/code&gt; is scoped to C++ mangled names and weak definitions, and needs debug info. &lt;code&gt;-z muldefs&lt;/code&gt; governs duplicates &lt;em&gt;within&lt;/em&gt; a link, and this pair never shares one.&lt;/p&gt;
&lt;p&gt;People have run into this before, of course. Sergei Trofimovich wrote up a shared-library collision breaking real programs and landed on the same verdict, that the toolchain does not help much here. What has been missing is the recognition that these one-off war stories are a single failure class with a describable trigger.&lt;/p&gt;
&lt;p&gt;A failure that produces a crash gets a stack trace. A failure that produces a wrong answer gets silence.&lt;/p&gt;
&lt;h2&gt;6. Fixes: the folklore one that fails, and the ones that work&lt;/h2&gt;
&lt;p&gt;The instinct, once you know two copies of a symbol are colliding, is to reach for visibility: rebuild the shared library with &lt;code&gt;-fvisibility=hidden&lt;/code&gt;, or slap a &lt;code&gt;local: *&lt;/code&gt; version script on it, and the duplicate should stop being exported. It does not fix this. Verified against the reproducer, both leave the split fully in place:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shellsession&quot;&gt;NAIVE FIXES THAT DO NOT WORK:
  nofix-visibility     :   collective: discovered 0 device(s)   *** DEVICE NOT FOUND ***
  nofix-version-script :   collective: discovered 0 device(s)   *** DEVICE NOT FOUND ***
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;They hide the wrong copy. Visibility controls what the shared library &lt;em&gt;exports&lt;/em&gt;; it does nothing about the executable&apos;s copy, which is the one discovery was binding to all along. (Hiding the library&apos;s symbols can also get it dropped by &lt;code&gt;--as-needed&lt;/code&gt; entirely, trading a split for a constructor that never runs.)&lt;/p&gt;
&lt;p&gt;What works, verified, is making the two copies stop being the same symbol, or stop being two:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shellsession&quot;&gt;FIXES THAT WORK:
  fix-drop-duplicate :   collective: discovered 2 device(s)
  fix-exclude-libs   :   collective: discovered 2 device(s)
  fix-prefix-rename  :   collective: discovered 2 device(s)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are not equal in durability. The root-cause fix is one canonical copy, so there is only ever one instance to bind to. Where two copies genuinely must coexist, make them different symbols outright with &lt;code&gt;objcopy --redefine-sym&lt;/code&gt;, so they can never collide. And a narrower, link-local measure is to stop the executable exporting its static copy: &lt;code&gt;-Wl,--exclude-libs,libverbs_static.a&lt;/code&gt;, naming the offending archive rather than &lt;code&gt;-Wl,--exclude-libs,ALL&lt;/code&gt;, which hides every archive&apos;s symbols and can suppress plugin or callback exports the process actually needs. The rename fix is not hypothetical. Meta&apos;s public &lt;a href=&quot;https://github.com/meta-pytorch/torchcomms&quot;&gt;torchcomms repository&lt;/a&gt; ships a &lt;a href=&quot;https://github.com/meta-pytorch/torchcomms/blob/e01f9bf0b44b37e35425c2250e040fca328557af/rename_symbols.sh&quot;&gt;&lt;code&gt;rename_symbols.sh&lt;/code&gt;&lt;/a&gt; that prefixes every &lt;code&gt;nccl*&lt;/code&gt; symbol, with a comment saying it exists to avoid conflicting with the OSS &lt;code&gt;nccl*&lt;/code&gt; bundled with PyTorch. The ecosystem shipped the rename fix years before the disease had a name.&lt;/p&gt;
&lt;p&gt;In the incident, both rungs got used, in the order SEV pressure dictates. The immediate mitigation was to make the in-house collective library opt-in: binaries that didn&apos;t need MTIA stopped pulling the verbs stack into the composition at all, so nothing exported a second copy into the global scope. The provider&apos;s registration import, with nothing left to capture it, fell through to the system libibverbs: registration and discovery reunited on the one copy NCCL was pinned to, and NCCL healed. The bug was defused by removing one of the two copies from most processes, not by fixing the collision.&lt;/p&gt;
&lt;p&gt;The principled fix came after: statically link libibverbs and libmlx5, one canonical copy for the image, kept out of the dynamic namespace. That is the posture the omnibus merge had been providing by accident, restored on purpose. In the fix ladder&apos;s terms it removes the duplicate from the scope where the registration was being captured (the first rung&apos;s spirit), shipped in production before the reproducer existed to validate it.&lt;/p&gt;
&lt;h2&gt;7. How common is this, really?&lt;/h2&gt;
&lt;p&gt;One of the layers the SEV dig descended through was Python native linking: how the interpreter &lt;code&gt;dlopen&lt;/code&gt;s extension modules and the libraries bundled alongside them. That detour turns out not to be a detour at all, because the wheel ecosystem lives under the same pressure that built the incident and pushes back the opposite way. The monorepo &lt;em&gt;merges&lt;/em&gt; (omnibus, link groups, one canonical copy per image) and breaks the day a canonical copy&apos;s symbols escape into a scope that already holds the same names, the hazard Meta&apos;s 2018 write-up warned about. The wheel ecosystem &lt;em&gt;vendors&lt;/em&gt; (auditwheel grafts a private copy of every native dependency into each wheel) and breaks the day two of those private copies co-load and each runs its own state. Same pressure, opposite mitigations, one disease.&lt;/p&gt;
&lt;p&gt;The two worlds also differ in route, and the distinction organizes everything measured below, because split-state linking arrives by &lt;em&gt;two&lt;/em&gt; routes, not one. &lt;strong&gt;Route A (interposition capture)&lt;/strong&gt; is the reproducer&apos;s shape: a duplicate strong symbol, a self-binding library, and an interposing module sharing one symbol scope. &lt;strong&gt;Route B (scope partition)&lt;/strong&gt; needs neither self-binding nor interposition. If two modules are loaded into separate local scopes (&lt;code&gt;RTLD_LOCAL&lt;/code&gt;, the default for every &lt;code&gt;dlopen&lt;/code&gt;, which is &lt;a href=&quot;/blog/ELF-Linking-101/#appendix-h-runtime-loading-dlopendlsym&quot;&gt;how Python loads extension modules&lt;/a&gt;) and each carries its own vendored copy of a library, then each side binds its own copy and runs its own state. Same disease, reached without any special flag at all. The incident stood with one foot in each route: Route A&apos;s interposition did the capturing (the registration import, resolved through the global scope into the bundled copy), and Route B&apos;s scope machinery did the isolating (the victim pinned by handle to an &lt;code&gt;RTLD_LOCAL&lt;/code&gt; copy the capture could never fill).&lt;/p&gt;
&lt;p&gt;Measuring either route takes more than a duplicate-symbol lister, because the trigger leaves no marker in the binary (section 4) and the base rate of benign duplication is enormous: in a sweep of 788 stock system binaries, 468 had duplicate symbols somewhere in their closures, and not one was a split. So I built &lt;code&gt;symsplit&lt;/code&gt;, a binding &lt;em&gt;simulator&lt;/em&gt;: it models what &lt;code&gt;ld.so&lt;/code&gt; actually does and flags a split only when two modules in one image would genuinely resolve the same name to different definitions. Against the reproducer matrix it flags exactly the splitting configuration and clears the rest. Against those 788 system binaries: zero flags (a quiet-corpus result rather than ground-truth validation; the corpus is presumed clean). &lt;a href=&quot;#appendix-d-symsplit-a-binding-simulator-not-a-duplicate-lister&quot;&gt;Appendix D&lt;/a&gt; covers what it models, how it infers self-binding from a relocation absence, and where its limits lie.&lt;/p&gt;
&lt;p&gt;Pointed at the manylinux ML-wheel ecosystem, the picture that comes back is specific.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Route B is live in stock wheels.&lt;/strong&gt; Import faiss, scikit-learn, and torch into one Python process and &lt;code&gt;/proc/self/maps&lt;/code&gt; shows two distinct builds each of libgomp, libgfortran, and libquadmath: two OpenMP runtimes, two Fortran runtimes, each with its own global state, resident in one process. And this is not merely structural: trace an actual compute workload under &lt;code&gt;LD_DEBUG=bindings&lt;/code&gt; and 206 duplicated compute symbols bind to two different definitions at once in the same process, almost all of them OpenBLAS kernels, with faiss&apos;s statically embedded copy answering faiss&apos;s calls while numpy&apos;s libopenblas answers numpy&apos;s (partitioned binding caught live, not a demonstrated wrong answer). The ecosystem half-knows this. It&apos;s the &amp;quot;multiple OpenMP runtimes&amp;quot; problem, and Intel ships a runtime kill-switch for it, &lt;code&gt;KMP_DUPLICATE_LIB_OK&lt;/code&gt;, silencing an error whose own text warns the duplication &amp;quot;can cause incorrect results.&amp;quot; The full survey numbers live in &lt;a href=&quot;#appendix-e-the-wheel-survey-full-numbers&quot;&gt;Appendix E&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Route A&apos;s exact trigger is absent from public wheels, which is itself the finding.&lt;/strong&gt; &lt;code&gt;DF_SYMBOLIC&lt;/code&gt; is set on zero of the 366 libraries examined, and &lt;code&gt;symsplit&lt;/code&gt; predicts zero Route A splits across all eight co-load configurations tested. The trigger lives where the incident lived: inside monorepo native-link builds (Buck, Bazel, symbolic-binding hardening, omnibus-to-link-group migrations) that you cannot download from PyPI. That inaccessibility is a good part of why the class went undiagnosed for so long. But the ingredient that &lt;em&gt;promotes&lt;/em&gt; Route A is one line away in software everyone runs: &lt;code&gt;import torch&lt;/code&gt; executes &lt;code&gt;ctypes.CDLL(&amp;quot;libtorch_global_deps.so&amp;quot;, RTLD_GLOBAL)&lt;/code&gt;, lifting torch&apos;s OpenMP into the global scope. An &lt;code&gt;LD_DEBUG&lt;/code&gt; probe shows the consequence directly: import faiss alone and its extension module&apos;s OpenMP references bind faiss&apos;s bundled libgomp; import torch first and every one of those traced references rebinds to torch&apos;s copy instead. Which copy of a runtime your library gets is decided by Python import order.&lt;/p&gt;
&lt;p&gt;The honest shape of the result: the preconditions are everywhere, the full Route A alignment is rare in public and lives behind corporate build systems, and Route B is quietly resident in the stock ML stacks tested here and, by the arithmetic of auditwheel&apos;s vendoring, in any process that co-loads two wheels carrying the same runtime. The training binary&apos;s disease, one &lt;code&gt;import&lt;/code&gt; away, and nothing warns at any tier. The ecosystem survives by paying a scattered tax: &lt;code&gt;KMP_DUPLICATE_LIB_OK&lt;/code&gt;, auditwheel&apos;s content-hashed sonames (which &lt;em&gt;enable&lt;/em&gt; coexisting copies rather than prevent them), torchcomms&apos; &lt;code&gt;rename_symbols.sh&lt;/code&gt;, conda&apos;s one-copy-per-environment discipline. Four patches for one disease, none of them labeled with what they treat.&lt;/p&gt;
&lt;h2&gt;8. What should change&lt;/h2&gt;
&lt;p&gt;The diagnostic nobody built already has a name in the record. A &lt;code&gt;--warn-interposition&lt;/code&gt; warning was floated on the GCC mailing list in May 2021 and never implemented in ld or lld. The reason it stalled is documented too: Fangrui Song (MaskRay), lld&apos;s maintainer, scoping the equivalent check, noted that the mechanics are easy but that &amp;quot;in the absence of an ignore list mechanism, this extension will not be useful&amp;quot;. Interposition is a load-bearing ELF feature, and the base rate of benign duplication is enormous.&lt;/p&gt;
&lt;p&gt;That missing ignore-list mechanism is exactly what &lt;code&gt;symsplit&lt;/code&gt; is. The allowlist for intentional interposers (allocators, sanitizers), the weak/versioned/hidden/symtab-only filtering, the self-binding inference: all of it demonstrated against real binaries, silent across a 788-binary sweep of presumed-clean system binaries. The tool stands alone today; the question worth putting to the linker maintainers, and I intend to, is whether an opt-in, allowlist-first version of the check belongs in lld or ld proper.&lt;/p&gt;
&lt;p&gt;Until then, the checklist for anyone shipping large statically-or-mixed-linked binaries. If a dependency is built &lt;code&gt;-Bsymbolic&lt;/code&gt; or &lt;code&gt;-Bsymbolic-functions&lt;/code&gt;, and a strong C symbol it defines also exists anywhere else in your image, you have a latent split-state hazard (it fires when the duplicated symbol guards state and both copies end up live in the same scope), and no default tool will flag it. Scan for it. Prefer one canonical copy, or make the copies different symbols outright. And file the lesson somewhere it will be found at 2 a.m.: &lt;code&gt;No IB devices found&lt;/code&gt; can mean the devices are right there — enumerated, registered, waiting — in the copy of the world you didn&apos;t ask.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Reproducer, scanner, and survey artifacts: &lt;a href=&quot;https://github.com/dshah133/howtf/tree/main/demo/rdma-symbol-collision&quot;&gt;the reproducer repo&lt;/a&gt; (scanner at &lt;a href=&quot;https://github.com/dshah133/howtf/tree/main/tools/symsplit&quot;&gt;&lt;code&gt;tools/symsplit&lt;/code&gt;&lt;/a&gt;). Everything quoted above (the scope-capture bindings, the address matrix, the fix ladder, the sweep, the wheel survey) is a captured artifact in the repo, rerunnable from scripts.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Appendices&lt;/h2&gt;
&lt;p&gt;Evidence lockers: the full dumps and gnarlier details the body text points at. Skip freely; return when a claim needs its receipts.&lt;/p&gt;
&lt;h3&gt;Appendix A: The Buck machinery (omnibus, link groups, and the 2 GiB wall)&lt;/h3&gt;
&lt;p&gt;Section 1 compressed the build story into one contrast: omnibus hides, link groups publish. Here is the machinery behind both halves, all of it readable in public tooling.&lt;/p&gt;
&lt;h4&gt;1) Omnibus: the merge&lt;/h4&gt;
&lt;p&gt;A Python training program pulls in an enormous amount of native code (torch and everything under it), and not all of it can be statically compiled into one executable. Buck&apos;s &lt;a href=&quot;https://buck.build/javadoc/com/facebook/buck/cxx/Omnibus.html&quot;&gt;omnibus&lt;/a&gt; strategy does the next-best merge: statically link most of the native code &amp;quot;into a single giant shared library&amp;quot; (a &lt;code&gt;libomnibus.so&lt;/code&gt;), leaving only the extensions Python imports directly as separate .so&apos;s. You pay the full static-link cost once, at build time; at runtime the binary &lt;code&gt;dlopen&lt;/code&gt;s roughly one big library instead of hundreds.&lt;/p&gt;
&lt;p&gt;The merge comes with a symbol discipline you can read in the open-source prelude. The omnibus body is linked behind a &lt;a href=&quot;https://github.com/facebook/buck2/blob/main/prelude/cxx/omnibus.bzl&quot;&gt;generated version script&lt;/a&gt; whose &lt;a href=&quot;https://github.com/facebook/buck2/blob/main/prelude/cxx/symbols.bzl&quot;&gt;last line is &lt;code&gt;local: *;&lt;/code&gt;&lt;/a&gt;, localizing every symbol merged into the blob except the exact set the Python-facing roots need.&lt;/p&gt;
&lt;h4&gt;2) The 2 GiB wall&lt;/h4&gt;
&lt;p&gt;On x86-64 a PC-relative reference reaches ±2 GiB (&lt;code&gt;R_X86_64_PC32&lt;/code&gt; spans [-2³¹, 2³¹)), and a merged native library at training scale eventually outgrows it. The link fails with &lt;code&gt;relocation truncated to fit&lt;/code&gt;. The medium and large PIC code models do exist on x86-64, but neither is a clean retrofit for an image this size: metadata like &lt;code&gt;.eh_frame&lt;/code&gt; keeps 32-bit &lt;code&gt;R_X86_64_PC32&lt;/code&gt; relocations even in &lt;code&gt;-mcmodel=large&lt;/code&gt; output, prebuilt small-model objects carry the same limit, and large-model code costs size and speed. So the documented way out is the move &lt;a href=&quot;https://maskray.me/blog/2023-05-14-relocation-overflow-and-code-models&quot;&gt;MaskRay&apos;s relocation-overflow survey&lt;/a&gt; prescribes: &amp;quot;partition the large monolithic executable into the main executable and a few shared objects.&amp;quot;&lt;/p&gt;
&lt;h4&gt;3) Link groups: the partition&lt;/h4&gt;
&lt;p&gt;In buck2, that partitioning is &lt;a href=&quot;https://github.com/facebook/buck2/blob/main/prelude/linking/link_groups_explained.md&quot;&gt;link groups&lt;/a&gt;: a &lt;code&gt;link_group_map&lt;/code&gt; carves the binary&apos;s native dependency graph into multiple shared libraries, each under the limit, with per-group control over what links statically and what dynamically. A link group is still a merge (each group statically links its members into one shared library), but its boundary symbols are exported: the &lt;a href=&quot;https://github.com/facebook/buck2/blob/main/prelude/linking/link_groups_explained.md&quot;&gt;same document&lt;/a&gt; describes public nodes linked &lt;code&gt;--whole-archive&lt;/code&gt; so all their symbols survive, and &lt;code&gt;--dynamic-list&lt;/code&gt; entries feeding names into the main binary&apos;s dynamic symbol table so the pieces can find each other at runtime.&lt;/p&gt;
&lt;p&gt;None of this machinery is gentle around torch. The public tracker has &lt;a href=&quot;https://github.com/facebook/buck2/issues/62&quot;&gt;buck2 #62&lt;/a&gt;, libomnibus turning a symbol undefined that libtorch_cpu, libc10, and libtorch_python all keep weak.&lt;/p&gt;
&lt;h3&gt;Appendix B: Two preconditions for the capture&lt;/h3&gt;
&lt;p&gt;Section 3 asserted that two preconditions had to hold for the registration to be captured. Here they are, with the receipts.&lt;/p&gt;
&lt;h4&gt;1) The copies must agree on rdma-core&apos;s private ABI number&lt;/h4&gt;
&lt;p&gt;The registration entry point is &lt;code&gt;verbs_register_driver_&amp;lt;N&amp;gt;&lt;/code&gt;: rdma-core bakes its private-ABI number into the symbol name itself. Copies that disagree on the number define &lt;em&gt;different&lt;/em&gt; symbols and cannot collide there at all. Same rdma-core lineage, same number: one name, two definitions, a collision waiting for a scope.&lt;/p&gt;
&lt;h4&gt;2) Symbol versioning does not block the match&lt;/h4&gt;
&lt;p&gt;The bundled definition had to be visible where the resolver looked, and symbol versioning (&lt;a href=&quot;/blog/ELF-Linking-101/#71-version-glibc_234-not-found&quot;&gt;the version contract Part 1 read out of &lt;code&gt;.gnu.version_r&lt;/code&gt;&lt;/a&gt;) does not prevent that. glibc&apos;s &lt;a href=&quot;https://github.com/bminor/glibc/blob/master/elf/dl-lookup.c&quot;&gt;&lt;code&gt;check_match&lt;/code&gt;&lt;/a&gt; takes an exact version match; failing that, an unversioned definition still gets in through two doors. If the defining object carries no version table at all, the symbol is accepted outright. The comment there reads, of all things, &amp;quot;This can happen during symbol interposition.&amp;quot; And if it does carry one, a definition sitting at the global, unversioned index is kept as the fallback that wins when no exact match exists anywhere. What gets skipped is a definition under a &lt;em&gt;different&lt;/em&gt; version tag. Same rdma-core lineage, same number, exported names: the capture follows.&lt;/p&gt;
&lt;h3&gt;Appendix C: The reproducer&apos;s edge rows (hidden visibility and copy relocation)&lt;/h3&gt;
&lt;p&gt;The body kept the matrix&apos;s headline rows: A (default, no split), B (&lt;code&gt;-Bsymbolic-functions&lt;/code&gt;, split), C (protected visibility, split). The full matrix has three more rows, and each edge teaches something:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;config&lt;/th&gt;
&lt;th&gt;what changed&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;A&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;default build, no special flags&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no split&lt;/strong&gt;: same address; the shared library&apos;s own constructor is interposed onto the executable&apos;s copy, so everyone agrees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;shared lib built &lt;code&gt;-Bsymbolic-functions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SPLIT&lt;/strong&gt;: constructor writes the .so copy, discovery reads the exe copy; &amp;quot;device not found&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;protected visibility on the lib&apos;s internals&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SPLIT&lt;/strong&gt;: an equivalent self-binding trigger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;C′&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;hidden visibility&lt;/td&gt;
&lt;td&gt;the DSO is dropped by &lt;code&gt;--as-needed&lt;/code&gt;, so the constructor never runs at all (a different failure); force it to load and the split reappears&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;D1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the colliding thing is a &lt;em&gt;data&lt;/em&gt; table, static in the .so, global in the exe&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SPLIT&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;D2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;data table, global on both sides&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no split&lt;/strong&gt;: copy relocation quietly unifies everyone onto the executable&apos;s copy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;C′ (hidden visibility)&lt;/strong&gt; is a reminder that &amp;quot;just hide the symbols&amp;quot; changes the failure rather than removing it: with nothing exported, &lt;code&gt;--as-needed&lt;/code&gt; drops the unreferenced DSO from the link, and the constructor never runs at all. Force the DSO to load and the split reappears.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;D2 (copy relocation)&lt;/strong&gt; is the trap turned inside out. The &amp;quot;obvious&amp;quot; version of this bug, a duplicated plain data global, is the one the toolchain saved us from here, because copy relocation unified the copies: the executable gets its own copy of the data, and the shared library&apos;s references are pointed at it. That rescue is itself configuration-dependent (it hinges on how the executable references the data, and flags like &lt;code&gt;-z nocopyreloc&lt;/code&gt; switch it off), not a law. D1 shows the same data table splitting the moment the .so side&apos;s copy is a file-static the rescue cannot reach.&lt;/p&gt;
&lt;h3&gt;Appendix D: symsplit (a binding simulator, not a duplicate lister)&lt;/h3&gt;
&lt;p&gt;The distinction is the whole tool. &lt;code&gt;nm | sort | uniq -d&lt;/code&gt; answers &amp;quot;does a duplicate exist,&amp;quot; and on any real system it screams constantly about things that are fine: 468 of the 788 stock system binaries in the sweep had duplicate symbols somewhere in their closures, and not one was a split. bash defines its own &lt;code&gt;getenv&lt;/code&gt; over libc&apos;s, which is benign because libc keeps an interposable reference to the name and unifies onto bash&apos;s copy. Thousands of weak libc aliases exist to be overridden. Versioned symbols with disjoint version sets can&apos;t collide.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;symsplit&lt;/code&gt; models what &lt;code&gt;ld.so&lt;/code&gt; actually does instead: &lt;code&gt;.dynsym&lt;/code&gt; versus &lt;code&gt;.symtab&lt;/code&gt; visibility, scope order, symbol versioning, and per-library self-binding inferred from relocations. The inference reads the absence section 4 described: a library that retains an interposable &lt;code&gt;JUMP_SLOT&lt;/code&gt; or &lt;code&gt;GLOB_DAT&lt;/code&gt; reference to one of its own exports demonstrably did &lt;em&gt;not&lt;/em&gt; self-bind; one with none probably did. It flags a split only when two modules in one image would genuinely resolve the same name to different definitions. When it fires, it says why:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;VERDICT  SEV     SYMBOL               WHY
SPLIT    MEDIUM  vx_get_device_list   libverbs_shared.so is probably
  self-binding (no JUMP_SLOT/GLOB_DAT to any own export =
  -Bsymbolic-functions signature); its own copy answers its constructor
  calls, while libcollective.so&apos;s reference resolves to app_B&apos;s copy
  -&amp;gt; two live copies diverge (split state)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is honest about its own limits, too. &lt;code&gt;-Bsymbolic-functions&lt;/code&gt; can&apos;t be proven from the ELF (a library with no self-references &lt;em&gt;looks&lt;/em&gt; self-bound), so that inference carries a confidence label in the output. And dlopen scope is a runtime property the ELF doesn&apos;t record, so Route B modeling takes the scope layout as input rather than pretending to know it.&lt;/p&gt;
&lt;h3&gt;Appendix E: The wheel survey (full numbers)&lt;/h3&gt;
&lt;p&gt;The survey behind section 7&apos;s headlines, all of it rerunnable from the scripts in &lt;a href=&quot;https://github.com/dshah133/howtf/tree/main/demo/rdma-symbol-collision&quot;&gt;the reproducer repo&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;1) Route B: resident duplicate runtimes&lt;/h4&gt;
&lt;p&gt;Import faiss, scikit-learn, and torch into one Python process and &lt;code&gt;/proc/self/maps&lt;/code&gt; shows two distinct builds each of libgomp, libgfortran, and libquadmath. numpy plus scipy alone maps two libgfortran and two libquadmath.&lt;/p&gt;
&lt;p&gt;Tracing an actual compute workload (numpy matmul, torch matmul, a faiss index search) under &lt;code&gt;LD_DEBUG=bindings&lt;/code&gt; shows 206 duplicated compute symbols binding to two different definitions at once in the same process, almost all of them OpenBLAS kernels, with faiss&apos;s statically embedded copy answering faiss&apos;s calls while numpy&apos;s libopenblas answers numpy&apos;s. Those kernels are code, not divergent state (partitioned binding caught live, not a demonstrated wrong answer), but the runtimes underneath them carry genuinely mutable state, thread pools and locks, and the same partition carries each side&apos;s runtime state with it. That is the substance behind Intel&apos;s &lt;code&gt;KMP_DUPLICATE_LIB_OK&lt;/code&gt; kill-switch: the error it silences warns that duplicate OpenMP runtimes &amp;quot;can cause incorrect results.&amp;quot;&lt;/p&gt;
&lt;h4&gt;2) Route A: the trigger census&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;DF_SYMBOLIC&lt;/code&gt; is set on zero of the 366 libraries examined across the surveyed wheels, and &lt;code&gt;symsplit&lt;/code&gt; predicts zero Route A splits across all eight co-load configurations tested (combinations of numpy, scipy, torch, faiss, and scikit-learn imported together).&lt;/p&gt;
&lt;h4&gt;3) The import-order probe&lt;/h4&gt;
&lt;p&gt;The ingredient that promotes Route A is observable directly. &lt;code&gt;import torch&lt;/code&gt; executes &lt;code&gt;ctypes.CDLL(&amp;quot;libtorch_global_deps.so&amp;quot;, RTLD_GLOBAL)&lt;/code&gt;, lifting torch&apos;s OpenMP into the global scope. Under &lt;code&gt;LD_DEBUG=bindings&lt;/code&gt;: import faiss alone and its extension module&apos;s OpenMP references bind faiss&apos;s bundled libgomp; import torch first and every one of those traced references rebinds to torch&apos;s copy instead.&lt;/p&gt;
</content:encoded></item><item><title>howtf does ./app reach main()?</title><link>https://howtf.io/blog/ELF-Linking-101/</link><guid isPermaLink="true">https://howtf.io/blog/ELF-Linking-101/</guid><description>Trace one command from keystroke to main(), through the kernel, loader, and PLT/GOT, until two classic deploy errors stop being mysterious.</description><pubDate>Mon, 16 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;We have all been there. You deploy a binary that worked perfectly on your development machine, but the production environment crashes with:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/lib64/libc.so.6: version &apos;GLIBC_2.34&apos; not found&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;or&lt;/p&gt;
&lt;p&gt;&lt;code&gt;error while loading shared libraries: libfoo.so: cannot open shared object file&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;You do a frantic search, blindly paste &lt;code&gt;export LD_LIBRARY_PATH=&lt;/code&gt; commands, and install random packages until the error disappears. We often treat the execution process as a black box, something that &amp;quot;just works&amp;quot; until it doesn&apos;t. These errors are symptoms of a system most engineers never look at closely, and that lack of understanding compounds when you are debugging at scale.&lt;/p&gt;
&lt;p&gt;In this post, we take a different approach. We trace the life of a command from the moment you hit &lt;code&gt;Enter&lt;/code&gt; until it reaches &lt;code&gt;main()&lt;/code&gt;, watching the kernel, linker, and loader coordinate to turn a file on disk into a running process. Then we flash back to build time (Parts V–VI) to see where the machinery was set up. At the end, &lt;strong&gt;we reproduce both errors above on purpose and read the diagnosis straight off the binary&lt;/strong&gt;. Every dump in this post comes from one reproducible container; the demo and a &lt;code&gt;regenerate.sh&lt;/code&gt; live &lt;a href=&quot;https://github.com/dshah133/howtf/tree/main/demo/elf-linking&quot;&gt;in the site repo&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scope &amp;amp; assumptions.&lt;/strong&gt; This walkthrough uses &lt;strong&gt;Linux on x86‑64&lt;/strong&gt; as the concrete reference, with the &lt;strong&gt;glibc dynamic loader&lt;/strong&gt; (&lt;code&gt;ld-linux-x86-64.so.2&lt;/code&gt;) as &amp;quot;the loader&amp;quot; we talk about. The big ideas transfer to other architectures and libcs, but some details (relocation types, syscall entry, loader internals, memory-ordering constraints etc.) might differ.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who is this for?&lt;/strong&gt; If you have ever wondered what actually happens between hitting Enter and your code running, this is for you. Some comfort with C helps, and we will touch on assembly and kernel internals in places, but the main narrative is designed to be followed without deep expertise in either. The appendices are where the really gnarly details live.&lt;/p&gt;
&lt;figure class=&quot;frame diagram&quot;&gt;
  &lt;span class=&quot;frame-title&quot;&gt;fig. 0 · the relay race, keystroke to main()&lt;/span&gt;
  &lt;div class=&quot;diagram-body&quot;&gt;
    &lt;svg viewBox=&quot;0 0 720 300&quot; role=&quot;img&quot; aria-label=&quot;Two-lane diagram: user mode hands off to the kernel at execve, the kernel maps segments and the loader, the loader relocates itself and resolves dependencies, then control passes through _start to main&quot;&gt;
      &lt;defs&gt;
        &lt;marker id=&quot;f0a&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--muted)&quot;/&gt;
        &lt;/marker&gt;
      &lt;/defs&gt;
      &lt;text x=&quot;14&quot; y=&quot;30&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;11&quot; fill=&quot;var(--muted)&quot;&gt;ring 3&lt;/text&gt;
      &lt;text x=&quot;14&quot; y=&quot;286&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;11&quot; fill=&quot;var(--muted)&quot;&gt;ring 0&lt;/text&gt;
      &lt;line x1=&quot;10&quot; y1=&quot;150&quot; x2=&quot;710&quot; y2=&quot;150&quot; stroke=&quot;var(--border)&quot; stroke-dasharray=&quot;5 5&quot;/&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot;&gt;
        &lt;rect x=&quot;46&quot; y=&quot;42&quot; width=&quot;104&quot; height=&quot;36&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;46&quot; y=&quot;42&quot; width=&quot;104&quot; height=&quot;36&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;98&quot; y=&quot;64&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;shell: ./app ⏎&lt;/text&gt;
        &lt;rect x=&quot;180&quot; y=&quot;42&quot; width=&quot;120&quot; height=&quot;36&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;180&quot; y=&quot;42&quot; width=&quot;120&quot; height=&quot;36&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;240&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;fork() then&lt;/text&gt;
        &lt;text x=&quot;240&quot; y=&quot;72&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;execve(2)&lt;/text&gt;
        &lt;rect x=&quot;160&quot; y=&quot;196&quot; width=&quot;270&quot; height=&quot;52&quot; fill=&quot;var(--krn)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;160&quot; y=&quot;196&quot; width=&quot;270&quot; height=&quot;52&quot; fill=&quot;none&quot; stroke=&quot;var(--krn)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;295&quot; y=&quot;218&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--krn)&quot;&gt;kernel: load_elf_binary()&lt;/text&gt;
        &lt;text x=&quot;295&quot; y=&quot;234&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--krn)&quot;&gt;map PT_LOADs · map ld.so via PT_INTERP&lt;/text&gt;
        &lt;rect x=&quot;380&quot; y=&quot;42&quot; width=&quot;150&quot; height=&quot;36&quot; fill=&quot;var(--ldr)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;380&quot; y=&quot;42&quot; width=&quot;150&quot; height=&quot;36&quot; fill=&quot;none&quot; stroke=&quot;var(--ldr)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;455&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--ldr)&quot;&gt;ld.so: self-relocate,&lt;/text&gt;
        &lt;text x=&quot;455&quot; y=&quot;72&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--ldr)&quot;&gt;load deps, fill GOT&lt;/text&gt;
        &lt;rect x=&quot;560&quot; y=&quot;42&quot; width=&quot;120&quot; height=&quot;36&quot; fill=&quot;none&quot; stroke=&quot;var(--muted)&quot; stroke-width=&quot;1.2&quot;/&gt;
        &lt;text x=&quot;620&quot; y=&quot;58&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot;&gt;_start →&lt;/text&gt;
        &lt;text x=&quot;620&quot; y=&quot;72&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot;&gt;__libc_start_main&lt;/text&gt;
        &lt;rect x=&quot;586&quot; y=&quot;112&quot; width=&quot;68&quot; height=&quot;30&quot; fill=&quot;var(--accent)&quot; opacity=&quot;0.18&quot;/&gt;
        &lt;rect x=&quot;586&quot; y=&quot;112&quot; width=&quot;68&quot; height=&quot;30&quot; fill=&quot;none&quot; stroke=&quot;var(--accent)&quot; stroke-width=&quot;1.8&quot;/&gt;
        &lt;text x=&quot;620&quot; y=&quot;131&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--accent)&quot;&gt;main()&lt;/text&gt;
      &lt;/g&gt;
      &lt;g stroke=&quot;var(--muted)&quot; stroke-width=&quot;1.4&quot; fill=&quot;none&quot; marker-end=&quot;url(#f0a)&quot;&gt;
        &lt;path d=&quot;M 150 60 L 176 60&quot;/&gt;
        &lt;path d=&quot;M 240 82 C 240 130, 240 160, 250 192&quot;/&gt;
        &lt;path d=&quot;M 380 192 C 400 150, 420 110, 445 82&quot;/&gt;
        &lt;path d=&quot;M 530 60 L 556 60&quot;/&gt;
        &lt;path d=&quot;M 620 82 L 620 108&quot;/&gt;
      &lt;/g&gt;
      &lt;text x=&quot;255&quot; y=&quot;136&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot; fill=&quot;var(--muted)&quot;&gt;the only trip below the line&lt;/text&gt;
    &lt;/svg&gt;
    &lt;p class=&quot;legend&quot;&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--sec)&quot;&gt;&lt;/span&gt;our code&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--krn)&quot;&gt;&lt;/span&gt;kernel&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--ldr)&quot;&gt;&lt;/span&gt;loader&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--accent)&quot;&gt;&lt;/span&gt;the destination&lt;/span&gt;
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/figure&gt;
&lt;hr&gt;
&lt;h2&gt;Follow Along&lt;/h2&gt;
&lt;p&gt;We will use a standard Linux environment. If you are on macOS or Windows, use Docker Desktop to get deterministic userspace behavior (specifically for x86‑64 relocation types).&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A Note on Architecture (Apple Silicon &amp;amp; Windows ARM):&lt;/strong&gt;
If you are running on an ARM chip (M1/M2/M3, etc), you can still follow along.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;macOS:&lt;/strong&gt; Docker Desktop can run &lt;code&gt;linux/amd64&lt;/code&gt; containers using Rosetta‑based translation wired through &lt;code&gt;binfmt_misc&lt;/code&gt; when configured to do so. This is &lt;a href=&quot;https://developer.apple.com/documentation/virtualization/running-intel-binaries-in-linux-vms-with-rosetta&quot;&gt;documented by Apple&lt;/a&gt; and by Docker Desktop &lt;a href=&quot;https://docs.docker.com/desktop/features/vmm/&quot;&gt;settings&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Windows (ARM):&lt;/strong&gt; the common mechanism for running &lt;code&gt;linux/amd64&lt;/code&gt; binaries under an ARM64 Linux environment (including WSL2-based backends) is &lt;strong&gt;QEMU user-mode emulation&lt;/strong&gt; wired through Linux&apos;s &lt;code&gt;binfmt_misc&lt;/code&gt;. Whether it&apos;s already configured &amp;quot;out of the box&amp;quot; depends on the Docker/WSL2 setup, versions, and registration state, but most likely it is.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Curious how this cross-architecture magic works under the hood? See &lt;em&gt;&lt;a href=&quot;#appendix-a-the-cross-architecture-magic-rosetta--qemu&quot;&gt;Appendix A&lt;/a&gt;&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on prompts:&lt;/strong&gt; &lt;code&gt;❯&lt;/code&gt; is my host machine; &lt;code&gt;root@container:/code#&lt;/code&gt; is inside the container. Every dump in this post was captured in the container described below (gcc 11.4, glibc 2.35), by &lt;a href=&quot;https://github.com/dshah133/howtf/tree/main/demo/elf-linking&quot;&gt;&lt;code&gt;demo/elf-linking/regenerate.sh&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. The Source Files&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Three files. One program, one shared library, nothing hidden:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;// main.c — the entire demo program. The interesting part is what links it.
#include &amp;lt;unistd.h&amp;gt;

extern int add(int a, int b);

int main(void) {
    int sum = add(5, 10);
    sleep(60); /* keeps the process alive so we can read /proc/&amp;lt;pid&amp;gt;/maps */
    return sum;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;// math.c
int add(int a, int b) {
    return a + b;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-make&quot;&gt;CC = gcc

all: libmath.so dynamic_app dynamic_app_lazy

libmath.so: math.c
	$(CC) -shared -fPIC -o libmath.so math.c

dynamic_app: main.c libmath.so
	$(CC) -o dynamic_app main.c -L. -lmath -Wl,-rpath,&apos;$$ORIGIN&apos;

# explicit lazy-binding variant for the PLT/GOT walkthrough (Part III)
dynamic_app_lazy: main.c libmath.so
	$(CC) -o dynamic_app_lazy main.c -L. -lmath -Wl,-z,lazy -Wl,-rpath,&apos;$$ORIGIN&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things here are load-bearing, and both will pay off later: we link with &lt;code&gt;-L. -lmath&lt;/code&gt; (&lt;strong&gt;not&lt;/strong&gt; by naming &lt;code&gt;./libmath.so&lt;/code&gt; directly, a difference that reproduces one of our two opening errors, as we&apos;ll see in Part VII), and we build a second binary with &lt;code&gt;-Wl,-z,lazy&lt;/code&gt; (Part III explains why we need to ask for lazy binding explicitly in 2026).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Start the container&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Force x86-64 to align with our assembly examples
❯ docker run --rm -it \
  --platform=linux/amd64 \
  --cap-add=SYS_PTRACE \
  --security-opt seccomp=unconfined \
  -v &amp;quot;$PWD&amp;quot;/code:/code -w /code \
  ubuntu:22.04 bash

# Install tools
root@container:/code# apt-get update &amp;amp;&amp;amp; apt-get install -y build-essential binutils gdb strace
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;3. Compile the project:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# make
gcc -shared -fPIC -o libmath.so math.c
gcc -o dynamic_app main.c -L. -lmath -Wl,-rpath,&apos;$ORIGIN&apos;
gcc -o dynamic_app_lazy main.c -L. -lmath -Wl,-z,lazy -Wl,-rpath,&apos;$ORIGIN&apos;

root@container:/code# ls
Makefile  dynamic_app  dynamic_app_lazy  libmath.so  main.c  math.c
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;You type &lt;code&gt;./dynamic_app&lt;/code&gt; and hit Enter.&lt;/p&gt;
&lt;p&gt;Your shell calls &lt;code&gt;fork()&lt;/code&gt; to create a child process. That child process calls &lt;code&gt;execve(&amp;quot;./dynamic_app&amp;quot;)&lt;/code&gt;, and your app starts running. Simple, as long as nobody asks what &lt;code&gt;execve&lt;/code&gt; actually did.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Part I: The Hardware Gate and Kernel Entry&lt;/h2&gt;
&lt;h3&gt;1.1 The Wake Up&lt;/h3&gt;
&lt;p&gt;Your shell (bash/zsh) was actually asleep, blocked on a &lt;code&gt;read()&lt;/code&gt; system call waiting for input. The kernel, tty, keyboard driver, etc. work together to let your shell know exactly what command the user executed.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;How your keystroke actually reaches the shell (PTYs, the line discipline, and why Ctrl+C sometimes can&apos;t save you) is a whole story of its own. It&apos;s coming as a separate post: &lt;em&gt;The keyboard dance&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;1.2 The &lt;code&gt;fork()&lt;/code&gt; syscall (cloning)&lt;/h3&gt;
&lt;p&gt;The shell parses your command and decides to run a new program. But first, it must duplicate itself. It calls &lt;code&gt;fork()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This triggers a hardware transition.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The Trap:&lt;/strong&gt; The CPU executes the syscall instruction (opcode &lt;code&gt;0F 05&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Switch:&lt;/strong&gt; The hardware instantly elevates privileges to Ring 0.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Lookup:&lt;/strong&gt; It consults the Model Specific Registers (MSRs) to jump straight into the kernel&apos;s entry point (&lt;code&gt;entry_SYSCALL_64&lt;/code&gt;), stashing the user return address in &lt;code&gt;RCX&lt;/code&gt; and the flags in &lt;code&gt;R11&lt;/code&gt; on the way. &lt;code&gt;SYSCALL&lt;/code&gt; touches no stack; the kernel&apos;s entry stub switches to a kernel stack in software before it pushes anything.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;The hardware gate deserves more than three bullets. IDT vs &lt;code&gt;SYSCALL&lt;/code&gt; entry, TSS/IST stack rules, and KPTI are a separate post: &lt;em&gt;Before the kernel answers&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It creates a near‑identical copy of the shell (the child process). In practice, the kernel does not duplicate physical memory. It marks the writable private pages as copy‑on‑write (COW), so the two processes share the same physical pages until one of them writes. This child is now running, but it is still running the shell&apos;s code.&lt;/p&gt;
&lt;h3&gt;1.3 The &lt;code&gt;execve&lt;/code&gt; Syscall&lt;/h3&gt;
&lt;p&gt;The transition for the syscall remains the same as fork, but the handler will be different. execve kernel handler discards the child&apos;s old memory map (the shell code) and prepares to load the new binary.&lt;/p&gt;
&lt;p&gt;The Operating System has taken the wheel. It is now sitting in Ring 0 with the file path ./dynamic_app and a mandate to start executing it.&lt;/p&gt;
&lt;h3&gt;1.4 Inside the Kernel: &lt;code&gt;fs/exec.c&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Once inside the kernel, execution eventually reaches &lt;code&gt;do_execveat_common&lt;/code&gt; in &lt;a href=&quot;https://elixir.bootlin.com/linux/v6.8/source/fs/exec.c#L1908&quot;&gt;fs/exec.c&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The kernel opens the file and iterates through a list of &amp;quot;binary handlers&amp;quot; to find one that understands the file format. Since this is an ELF file, it lands in &lt;code&gt;load_elf_binary&lt;/code&gt; in &lt;a href=&quot;https://elixir.bootlin.com/linux/v6.8/source/fs/binfmt_elf.c#L819&quot;&gt;fs/binfmt_elf.c&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;1.5 The Magic Check&lt;/h3&gt;
&lt;p&gt;First, the kernel validates that this is actually an ELF file. It reads the first 4 bytes. If they aren&apos;t &lt;code&gt;0x7F &apos;E&apos; &apos;L&apos; &apos;F&apos;&lt;/code&gt;, it rejects the file immediately.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;struct elfhdr *elf_ex = (struct elfhdr *)bprm-&amp;gt;buf;

if (memcmp(elf_ex-&amp;gt;e_ident, ELFMAG, SELFMAG) != 0)
    goto out;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;a href=&quot;https://elixir.bootlin.com/linux/v6.8/source/fs/binfmt_elf.c#L843&quot;&gt;view at v6.8&lt;/a&gt;)&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Part II: Mapping the Memory&lt;/h2&gt;
&lt;p&gt;The kernel does &lt;strong&gt;not&lt;/strong&gt; care about &amp;quot;sections&amp;quot; (like &lt;code&gt;.text&lt;/code&gt; or &lt;code&gt;.data&lt;/code&gt;). Those are build/link time constructions, mainly for the linker. The kernel cares about &lt;strong&gt;segments&lt;/strong&gt; (Program Headers), which tell the kernel what exactly to load and where.&lt;/p&gt;
&lt;figure class=&quot;frame diagram&quot;&gt;
  &lt;span class=&quot;frame-title&quot;&gt;fig. 1 · one file, two readings&lt;/span&gt;
  &lt;div class=&quot;diagram-body&quot;&gt;
    &lt;svg viewBox=&quot;0 0 640 330&quot; role=&quot;img&quot; aria-label=&quot;Diagram mapping ELF file sections to memory segments&quot;&gt;
      &lt;text x=&quot;120&quot; y=&quot;24&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;12&quot; fill=&quot;var(--muted)&quot;&gt;the file (offsets)&lt;/text&gt;
      &lt;text x=&quot;520&quot; y=&quot;24&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;12&quot; fill=&quot;var(--muted)&quot;&gt;memory (virtual addresses)&lt;/text&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;12&quot;&gt;
        &lt;rect x=&quot;40&quot; y=&quot;40&quot; width=&quot;160&quot; height=&quot;34&quot; rx=&quot;0&quot; fill=&quot;none&quot; stroke=&quot;var(--krn)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;120&quot; y=&quot;61&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--krn)&quot;&gt;ELF + program headers&lt;/text&gt;
        &lt;rect x=&quot;40&quot; y=&quot;82&quot; width=&quot;160&quot; height=&quot;40&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;40&quot; y=&quot;82&quot; width=&quot;160&quot; height=&quot;40&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;120&quot; y=&quot;106&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;.text&lt;/text&gt;
        &lt;rect x=&quot;40&quot; y=&quot;130&quot; width=&quot;160&quot; height=&quot;34&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;40&quot; y=&quot;130&quot; width=&quot;160&quot; height=&quot;34&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;120&quot; y=&quot;151&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;.rodata&lt;/text&gt;
        &lt;rect x=&quot;40&quot; y=&quot;172&quot; width=&quot;160&quot; height=&quot;34&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;40&quot; y=&quot;172&quot; width=&quot;160&quot; height=&quot;34&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;120&quot; y=&quot;193&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;.data / .bss&lt;/text&gt;
        &lt;text x=&quot;120&quot; y=&quot;235&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot;&gt;sections: the linker&apos;s view&lt;/text&gt;
      &lt;/g&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;12&quot;&gt;
        &lt;rect x=&quot;440&quot; y=&quot;40&quot; width=&quot;160&quot; height=&quot;46&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;440&quot; y=&quot;40&quot; width=&quot;160&quot; height=&quot;46&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;520&quot; y=&quot;60&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--seg)&quot;&gt;LOAD  R--&lt;/text&gt;
        &lt;text x=&quot;520&quot; y=&quot;76&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;10&quot;&gt;headers · .rodata&lt;/text&gt;
        &lt;rect x=&quot;440&quot; y=&quot;94&quot; width=&quot;160&quot; height=&quot;46&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;440&quot; y=&quot;94&quot; width=&quot;160&quot; height=&quot;46&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;520&quot; y=&quot;114&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--seg)&quot;&gt;LOAD  R-X&lt;/text&gt;
        &lt;text x=&quot;520&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;10&quot;&gt;.text&lt;/text&gt;
        &lt;rect x=&quot;440&quot; y=&quot;148&quot; width=&quot;160&quot; height=&quot;46&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;440&quot; y=&quot;148&quot; width=&quot;160&quot; height=&quot;46&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;520&quot; y=&quot;168&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--seg)&quot;&gt;LOAD  RW-&lt;/text&gt;
        &lt;text x=&quot;520&quot; y=&quot;184&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;10&quot;&gt;.data · .bss&lt;/text&gt;
        &lt;rect x=&quot;440&quot; y=&quot;210&quot; width=&quot;160&quot; height=&quot;38&quot; fill=&quot;var(--ldr)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;440&quot; y=&quot;210&quot; width=&quot;160&quot; height=&quot;38&quot; fill=&quot;none&quot; stroke=&quot;var(--ldr)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;520&quot; y=&quot;233&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--ldr)&quot;&gt;ld-linux-x86-64.so.2&lt;/text&gt;
        &lt;rect x=&quot;440&quot; y=&quot;256&quot; width=&quot;160&quot; height=&quot;30&quot; fill=&quot;none&quot; stroke=&quot;var(--border)&quot; stroke-dasharray=&quot;4 4&quot;/&gt;
        &lt;text x=&quot;520&quot; y=&quot;275&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot;&gt;[stack]&lt;/text&gt;
      &lt;/g&gt;
      &lt;g stroke=&quot;var(--krn)&quot; stroke-width=&quot;1.5&quot; fill=&quot;none&quot; marker-end=&quot;url(#arr)&quot;&gt;
        &lt;path d=&quot;M 204 102 C 320 92, 340 108, 436 114&quot;/&gt;
        &lt;path d=&quot;M 204 147 C 320 120, 330 58, 436 60&quot;/&gt;
        &lt;path d=&quot;M 204 189 C 320 186, 330 170, 436 168&quot;/&gt;
      &lt;/g&gt;
      &lt;defs&gt;
        &lt;marker id=&quot;arr&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--krn)&quot;/&gt;
        &lt;/marker&gt;
      &lt;/defs&gt;
      &lt;text x=&quot;320&quot; y=&quot;308&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;11&quot; fill=&quot;var(--krn)&quot;&gt;mmap&apos;d by the kernel from program headers · loader mapped via PT_INTERP&lt;/text&gt;
    &lt;/svg&gt;
    &lt;p class=&quot;legend&quot;&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--sec)&quot;&gt;&lt;/span&gt;file sections&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--seg)&quot;&gt;&lt;/span&gt;memory segments&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--ldr)&quot;&gt;&lt;/span&gt;loader&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--krn)&quot;&gt;&lt;/span&gt;kernel&lt;/span&gt;
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/figure&gt;
&lt;h3&gt;2.1 Iterating Segments (&lt;code&gt;load_elf_binary&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;The kernel loops over the program headers (&lt;code&gt;PT_LOAD&lt;/code&gt;) to figure out what to map. (&lt;a href=&quot;https://elixir.bootlin.com/linux/v6.8/source/fs/binfmt_elf.c#L1066&quot;&gt;View Source in &lt;code&gt;binfmt_elf.c&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;for(i = 0, elf_ppnt = elf_phdata; i &amp;lt; elf_ex-&amp;gt;e_phnum; i++, elf_ppnt++) {
    if (elf_ppnt-&amp;gt;p_type == PT_LOAD) {
        // Create the memory mapping
        error = elf_map(bprm-&amp;gt;file, load_bias + vaddr, elf_ppnt, ...);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It is common to &lt;em&gt;conceptually&lt;/em&gt; talk about &amp;quot;two main regions&amp;quot;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Code-ish mappings:&lt;/strong&gt; read + execute (your code + PLT stubs + some read-only metadata).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data-ish mappings:&lt;/strong&gt; read + write (globals, &lt;code&gt;.bss&lt;/code&gt;, GOT areas, dynamic data).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;However, and this matters for correctness, modern toolchains frequently emit &lt;strong&gt;more than two &lt;code&gt;PT_LOAD&lt;/code&gt; segments&lt;/strong&gt; (e.g., separate read-only segments for constants, plus layouts that support RELRO cleanly). In our demo binary, &lt;code&gt;readelf -l ./dynamic_app&lt;/code&gt; reveals &lt;strong&gt;four&lt;/strong&gt; distinct &lt;code&gt;PT_LOAD&lt;/code&gt; segments:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Read-Only Metadata (&lt;code&gt;R&lt;/code&gt;):&lt;/strong&gt; ELF headers and dynamic symbol tables.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Text Segment (&lt;code&gt;R E&lt;/code&gt;):&lt;/strong&gt; Your actual code (&lt;code&gt;.text&lt;/code&gt;) and the PLT stubs. This is the only memory executable by the CPU.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read-Only Data (&lt;code&gt;R&lt;/code&gt;):&lt;/strong&gt; Constants (&lt;code&gt;.rodata&lt;/code&gt;) and unwind info. Kept out of the executable mapping (&lt;code&gt;-z separate-code&lt;/code&gt; is another modern default) so constant data can never be fetched as instructions. That shrinks the attack surface, though it does not by itself prevent ROP.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Writable Data (&lt;code&gt;RW&lt;/code&gt;):&lt;/strong&gt; Global variables (&lt;code&gt;.data&lt;/code&gt;) and the Global Offset Table (GOT).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;See &lt;a href=&quot;#appendix-d-segments-deep-dive&quot;&gt;Appendix D&lt;/a&gt; for the full &lt;code&gt;readelf -l&lt;/code&gt; output and a detailed walkthrough.&lt;/p&gt;
&lt;h3&gt;2.2 Finding the Correct Address for the Segments&lt;/h3&gt;
&lt;p&gt;The code above already hints at the answer: each segment&apos;s virtual address is &lt;code&gt;load_bias + vaddr&lt;/code&gt;, where &lt;code&gt;vaddr&lt;/code&gt; comes straight from the program header&apos;s &lt;code&gt;p_vaddr&lt;/code&gt; field. But look at the actual values in our binary (from &lt;a href=&quot;#appendix-d-segments-deep-dive&quot;&gt;Appendix D&lt;/a&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000638 0x0000000000000638  R      0x1000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A &lt;code&gt;p_vaddr&lt;/code&gt; of 0x0? That would map over the NULL page. Something is off.&lt;/p&gt;
&lt;p&gt;The explanation is that our binary is not a traditional fixed-address executable (&lt;code&gt;ET_EXEC&lt;/code&gt;). On modern distros, GCC defaults to building &lt;strong&gt;Position-Independent Executables (PIE)&lt;/strong&gt;, which use type &lt;strong&gt;&lt;code&gt;ET_DYN&lt;/code&gt;&lt;/strong&gt; in the ELF header. This does not mean it is a shared library. It means the entire image can be loaded at an arbitrary base address, which is what enables ASLR.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# readelf -h ./dynamic_app | egrep &apos;Type:|Entry&apos;
  Type:                              DYN (Position-Independent Executable file)
  Entry point address:               0x1080
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is where the variable &lt;a href=&quot;https://elixir.bootlin.com/linux/v6.8/source/fs/binfmt_elf.c#L1087&quot;&gt;&lt;code&gt;load_bias&lt;/code&gt;&lt;/a&gt; in the kernel code is conceptually coming from:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;With PIE: the kernel chooses a randomized base address (ASLR).&lt;/li&gt;
&lt;li&gt;Runtime virtual address = &lt;code&gt;load_bias + p_vaddr&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Runtime entry point = &lt;code&gt;load_bias + e_entry&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;See &lt;a href=&quot;#appendix-d-segments-deep-dive&quot;&gt;Appendix D&lt;/a&gt; to see this load_bias in action for our demo app.&lt;/p&gt;
&lt;p&gt;At this point, assume that segments are loaded into the process&apos;s address space (or more precisely, mmapped).&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on &amp;quot;mapped&amp;quot; vs &amp;quot;loaded&amp;quot;:&lt;/strong&gt;
When we say &amp;quot;mapped,&amp;quot; we do not mean &amp;quot;copied to RAM.&amp;quot; The &lt;code&gt;elf_map&lt;/code&gt; call essentially creates a &lt;strong&gt;VMA (Virtual Memory Area)&lt;/strong&gt; that tells the kernel: &amp;quot;If the CPU asks for virtual address &lt;code&gt;X&lt;/code&gt;, the bytes live in this file at offset &lt;code&gt;Y&lt;/code&gt;.&amp;quot; The physical RAM can be &lt;strong&gt;empty&lt;/strong&gt;. When the CPU tries to execute the first instruction, a &lt;strong&gt;page fault&lt;/strong&gt; fires. The kernel catches it, fetches the page from disk (via the page cache), and resumes execution as if nothing happened. This is demand paging.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;2.3 The Fork in the Road: &lt;code&gt;PT_INTERP&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Then the kernel checks for a specific header: &lt;code&gt;PT_INTERP&lt;/code&gt;. (&lt;a href=&quot;https://elixir.bootlin.com/linux/v6.8/source/fs/binfmt_elf.c#L868&quot;&gt;View Source&lt;/a&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;if (elf_ppnt-&amp;gt;p_type == PT_INTERP) {
    interpreter = open_exec(interp_name); // e.g., /lib64/ld-linux-x86-64.so.2
    ...
    entry = load_elf_interp(&amp;amp;interp_elf_ex, interpreter, ...); // its own mapper, no recursion
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because &lt;code&gt;dynamic_app&lt;/code&gt; has this header, the kernel maps the dynamic loader (&lt;code&gt;ld-linux.so&lt;/code&gt;) into memory with a dedicated helper, &lt;code&gt;load_elf_interp()&lt;/code&gt;. The interpreter&apos;s own &lt;code&gt;PT_INTERP&lt;/code&gt;, if it had one, would be ignored (which is exactly why &lt;code&gt;ld.so&lt;/code&gt; must bootstrap itself, as we&apos;ll see in Part III). The kernel then sets the instruction pointer to the &lt;em&gt;loader&apos;s&lt;/em&gt; entry point, not your &lt;code&gt;dynamic_app&lt;/code&gt;&apos;s. (&lt;a href=&quot;https://elixir.bootlin.com/linux/v6.8/source/fs/binfmt_elf.c#L1200&quot;&gt;View Source&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;One more thing before the kernel leaves the stage: how will the loader know where &lt;em&gt;our&lt;/em&gt; binary landed? The kernel writes the answer onto the new process&apos;s stack as the &lt;strong&gt;auxiliary vector&lt;/strong&gt;: &lt;code&gt;AT_PHDR&lt;/code&gt; (where the program headers were mapped), &lt;code&gt;AT_ENTRY&lt;/code&gt; (the app&apos;s real entry point), &lt;code&gt;AT_BASE&lt;/code&gt; (where the interpreter itself landed), and friends. That auxv is the kernel→loader handshake; you can watch it with &lt;code&gt;LD_SHOW_AUXV=1 ./dynamic_app&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Part III: The Loader Takes Control (User Mode)&lt;/h2&gt;
&lt;p&gt;Control returns to User Mode. The program running is now the dynamic loader (&lt;code&gt;ld-linux.so&lt;/code&gt;), appearing in &lt;code&gt;glibc&lt;/code&gt; source as &lt;code&gt;elf/rtld.c&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;3.1 Self-Relocation (The Bootstrap)&lt;/h3&gt;
&lt;p&gt;The loader itself is also just a program, just a bit special one as it wakes up in a hostile environment. Because of ASLR, it has been loaded at a random address, meaning all its internal pointers to global variables are wrong. It cannot call functions or access static data yet. Before it can do anything else, the loader must fix these addresses. This happens in the &lt;code&gt;_dl_start&lt;/code&gt; path. See &lt;a href=&quot;#appendix-e-the-loaders-bootstrap-self-relocation&quot;&gt;Appendix E: The Loader&apos;s Bootstrap&lt;/a&gt; for more details.&lt;/p&gt;
&lt;h3&gt;3.2 Dependency Discovery&lt;/h3&gt;
&lt;p&gt;Once the loader has healed itself, it becomes a fully functional C program running inside your process. It can now inspect your &lt;code&gt;dynamic_app&lt;/code&gt;. It reads the &lt;code&gt;PT_DYNAMIC&lt;/code&gt; segment to find &lt;code&gt;DT_NEEDED&lt;/code&gt; tags (&lt;code&gt;libmath.so&lt;/code&gt; and &lt;code&gt;libc.so.6&lt;/code&gt; in our case), finds each library, and maps it into the process with &lt;code&gt;mmap&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Where does it look? The precedence is specific, and worth stating exactly because our second opening error lives here: &lt;code&gt;DT_RPATH&lt;/code&gt; (only honored if &lt;code&gt;DT_RUNPATH&lt;/code&gt; is absent) → &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt; → &lt;code&gt;DT_RUNPATH&lt;/code&gt; (which applies only to the object&apos;s &lt;em&gt;direct&lt;/em&gt; dependencies) → &lt;code&gt;/etc/ld.so.cache&lt;/code&gt; → the default dirs (&lt;code&gt;/lib&lt;/code&gt;, &lt;code&gt;/usr/lib&lt;/code&gt;, …). And one rule that overrides all of it: &lt;strong&gt;if the stored name contains a &lt;code&gt;/&lt;/code&gt;, it is treated as a path and no search happens at all.&lt;/strong&gt; Hold that thought for Part VII.&lt;/p&gt;
&lt;p&gt;You can watch the search happen. This is &lt;code&gt;LD_DEBUG=libs&lt;/code&gt; running our binary, showing the &lt;code&gt;RUNPATH&lt;/code&gt;-driven probe sequence for &lt;code&gt;libmath.so&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/# LD_DEBUG=libs /code/dynamic_app
      3932:	find library=libmath.so [0]; searching
      3932:	 search path=/code/glibc-hwcaps/x86-64-v3:...:/code		(RUNPATH from file /code/dynamic_app)
      3932:	  trying file=/code/glibc-hwcaps/x86-64-v3/libmath.so
      3932:	  trying file=/code/tls/haswell/libmath.so
      ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.3 Filling the GOT: now, or later?&lt;/h3&gt;
&lt;p&gt;With every library mapped, the loader must make cross-object calls work. Your &lt;code&gt;main()&lt;/code&gt; calls &lt;code&gt;add()&lt;/code&gt;, but &lt;code&gt;add&lt;/code&gt; lives in &lt;code&gt;libmath.so&lt;/code&gt; at an address nobody knew until two milliseconds ago. The fix-up table for this is the &lt;strong&gt;GOT (Global Offset Table)&lt;/strong&gt;: a table of pointers, one per external thing, that the loader fills in with the real addresses. Calls and data accesses go &lt;em&gt;through&lt;/em&gt; the GOT instead of embedding addresses directly.&lt;/p&gt;
&lt;p&gt;There are two strategies for &lt;em&gt;when&lt;/em&gt; the function-call slots get filled:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Eager (&lt;code&gt;BIND_NOW&lt;/code&gt;):&lt;/strong&gt; resolve every symbol at startup, before your code runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lazy:&lt;/strong&gt; leave function slots pointing at a resolver, and fix each one the &lt;em&gt;first time it&apos;s called&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Textbooks (and the previous version of this post) describe lazy as &amp;quot;the default.&amp;quot; &lt;strong&gt;On your distro, it probably isn&apos;t.&lt;/strong&gt; Look at what Ubuntu&apos;s gcc actually passed to the linker (this is from &lt;code&gt;gcc -v&lt;/code&gt;, Part V shows the full line): &lt;code&gt;-pie -z now -z relro&lt;/code&gt;. That &lt;code&gt;-z now&lt;/code&gt; means our default build is eager. The binary says so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# readelf -d ./dynamic_app | grep -E &apos;FLAGS&apos;
 0x000000000000001e (FLAGS)              BIND_NOW
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE

root@container:/code# readelf -d ./dynamic_app_lazy | grep -E &apos;FLAGS&apos;
 0x000000006ffffffb (FLAGS_1)            Flags: PIE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is why our Makefile builds &lt;code&gt;dynamic_app_lazy&lt;/code&gt; with &lt;code&gt;-Wl,-z,lazy&lt;/code&gt;: in 2026 you have to &lt;em&gt;ask&lt;/em&gt; for lazy binding to study it.&lt;/p&gt;
&lt;p&gt;The difference is also a security posture, and it&apos;s visible in RELRO. &lt;strong&gt;RELRO (RELocation Read-Only)&lt;/strong&gt; is the &lt;code&gt;GNU_RELRO&lt;/code&gt; segment: after the loader finishes its patches, it &lt;code&gt;mprotect&lt;/code&gt;s that region read-only. With &lt;code&gt;-z now&lt;/code&gt; you get &lt;strong&gt;full RELRO&lt;/strong&gt;: every GOT slot is resolved up front, so &lt;em&gt;all&lt;/em&gt; of them (including the function-call slots) sit inside the protected region. With lazy binding you get &lt;strong&gt;partial RELRO&lt;/strong&gt;: the function-call slots (&lt;code&gt;.got.plt&lt;/code&gt;) must stay writable so the resolver can patch them later. Our two builds show it directly: the &lt;code&gt;R_X86_64_JUMP_SLOT&lt;/code&gt; entries for &lt;code&gt;add&lt;/code&gt; and &lt;code&gt;sleep&lt;/code&gt; land at &lt;code&gt;0x3fc8/0x3fd0&lt;/code&gt; in the eager build, &lt;em&gt;inside&lt;/em&gt; its RELRO region &lt;code&gt;[0x3d90, 0x4000)&lt;/code&gt;, but at &lt;code&gt;0x4018/0x4020&lt;/code&gt; in the lazy build, &lt;em&gt;past the end&lt;/em&gt; of its RELRO region &lt;code&gt;[0x3dc8, 0x4000)&lt;/code&gt;. Same program, same symbols; one layout locks the slots, the other leaves them writable forever. (That writable-GOT window is exactly the classic GOT-overwrite target, a point we&apos;ll return to below.)&lt;/p&gt;
&lt;h3&gt;3.4 Lazy binding, watched live&lt;/h3&gt;
&lt;p&gt;Eager binding is easy to imagine: a loop over relocation entries at startup (Appendix F walks it record by record). Lazy binding is the clever one, so let&apos;s &lt;em&gt;watch&lt;/em&gt; it. Here is the machinery in the lazy binary, straight from &lt;code&gt;objdump&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;0000000000001169 &amp;lt;main&amp;gt;:
    ...
    1175:  be 0a 00 00 00        mov    $0xa,%esi
    117a:  bf 05 00 00 00        mov    $0x5,%edi
    117f:  e8 dc fe ff ff        call   1060 &amp;lt;add@plt&amp;gt;

0000000000001060 &amp;lt;add@plt&amp;gt;:                          ; .plt.sec
    1060:  f3 0f 1e fa           endbr64
    1064:  f2 ff 25 ad 2f 00 00  bnd jmp *0x2fad(%rip)   # 4018 &amp;lt;add&apos;s GOT slot&amp;gt;

0000000000001030 &amp;lt;.plt entry for add&amp;gt;:
    1030:  f3 0f 1e fa           endbr64
    1034:  68 00 00 00 00        push   $0x0             ; relocation index for &apos;add&apos;
    1039:  f2 e9 e1 ff ff ff     bnd jmp 1020            ; the common resolver stub
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;main&lt;/code&gt; doesn&apos;t call &lt;code&gt;add&lt;/code&gt;. It calls &lt;code&gt;add@plt&lt;/code&gt;, a tiny trampoline that jumps &lt;em&gt;through GOT slot &lt;code&gt;0x4018&lt;/code&gt;&lt;/em&gt;. And what does that slot contain before the first call? The file itself tells us. &lt;code&gt;readelf -x .got.plt&lt;/code&gt; shows slot &lt;code&gt;0x4018&lt;/code&gt; holding &lt;code&gt;0x1030&lt;/code&gt;: &lt;strong&gt;it points back into the PLT&lt;/strong&gt;, at the very next instruction of the dance. So the first call goes &lt;code&gt;main → add@plt → (through GOT) → push $0x0 → resolver&lt;/code&gt;, the resolver figures out which symbol relocation index 0 is, finds &lt;code&gt;add&lt;/code&gt; in &lt;code&gt;libmath.so&lt;/code&gt;, and &lt;strong&gt;patches the GOT slot&lt;/strong&gt; so every later call jumps straight there.&lt;/p&gt;
&lt;p&gt;Don&apos;t take my word for the patch: the demo binary can watch its own GOT slot change. &lt;code&gt;got_watch.c&lt;/code&gt; (in the demo repo) reads the slot for &lt;code&gt;add&lt;/code&gt; before and after the first call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# ./got_watch
GOT slot for add lives at 0x555555558018
  before first call : 0x0000555555555030  &amp;lt;- points back into our own .plt
  add(5, 10) returns: 15      &amp;lt;- first call takes the resolver detour
  after first call  : 0x00007fffff7bd0f9  &amp;lt;- patched!
  libmath.so code   : 7fffff7bd000-7fffff7be000 r-xp ... /code/libmath.so
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The before-value is our own image base plus &lt;code&gt;0x1030&lt;/code&gt;, exactly the &lt;code&gt;push $0x0&lt;/code&gt; stub in the &lt;code&gt;.plt&lt;/code&gt; dump above. The after-value lands inside &lt;code&gt;libmath.so&lt;/code&gt;&apos;s executable mapping: it&apos;s &lt;code&gt;add&lt;/code&gt; itself.&lt;/p&gt;
&lt;p&gt;One trap worth knowing (it bit this demo): &lt;code&gt;got_watch.c&lt;/code&gt; is careful &lt;strong&gt;never to take &lt;code&gt;&amp;amp;add&lt;/code&gt;&lt;/strong&gt;. The moment a program takes a function&apos;s address, the linker must guarantee pointer equality across all objects, so it resolves that symbol eagerly through &lt;code&gt;.plt.got&lt;/code&gt; and the lazy &lt;code&gt;JUMP_SLOT&lt;/code&gt; you wanted to watch never exists.&lt;/p&gt;
&lt;figure class=&quot;frame diagram&quot;&gt;
  &lt;span class=&quot;frame-title&quot;&gt;fig. 3 · the same stub, two routes&lt;/span&gt;
  &lt;div class=&quot;diagram-body&quot;&gt;
    &lt;svg viewBox=&quot;0 0 680 330&quot; role=&quot;img&quot; aria-label=&quot;Side-by-side panels showing the first call through the PLT going via the resolver which patches the GOT slot, and every later call going straight through the patched slot&quot;&gt;
      &lt;defs&gt;
        &lt;marker id=&quot;f3a&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--muted)&quot;/&gt;
        &lt;/marker&gt;
        &lt;marker id=&quot;f3l&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;
          &lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;var(--ldr)&quot;/&gt;
        &lt;/marker&gt;
      &lt;/defs&gt;
      &lt;text x=&quot;170&quot; y=&quot;24&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;12&quot; fill=&quot;var(--muted)&quot;&gt;first call (lazy)&lt;/text&gt;
      &lt;text x=&quot;510&quot; y=&quot;24&quot; text-anchor=&quot;middle&quot; font-family=&quot;var(--font-display)&quot; font-size=&quot;12&quot; fill=&quot;var(--muted)&quot;&gt;every call after&lt;/text&gt;
      &lt;line x1=&quot;340&quot; y1=&quot;14&quot; x2=&quot;340&quot; y2=&quot;316&quot; stroke=&quot;var(--border)&quot; stroke-dasharray=&quot;4 4&quot;/&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot;&gt;
        &lt;!-- left panel --&gt;
        &lt;rect x=&quot;110&quot; y=&quot;40&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;110&quot; y=&quot;40&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;170&quot; y=&quot;59&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;main: call add&lt;/text&gt;
        &lt;rect x=&quot;110&quot; y=&quot;100&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;110&quot; y=&quot;100&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;170&quot; y=&quot;119&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;add@plt stub&lt;/text&gt;
        &lt;rect x=&quot;110&quot; y=&quot;160&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;110&quot; y=&quot;160&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;170&quot; y=&quot;179&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--seg)&quot;&gt;GOT slot&lt;/text&gt;
        &lt;rect x=&quot;30&quot; y=&quot;222&quot; width=&quot;150&quot; height=&quot;34&quot; fill=&quot;var(--ldr)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;30&quot; y=&quot;222&quot; width=&quot;150&quot; height=&quot;34&quot; fill=&quot;none&quot; stroke=&quot;var(--ldr)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;105&quot; y=&quot;239&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--ldr)&quot;&gt;_dl_runtime_resolve&lt;/text&gt;
        &lt;text x=&quot;105&quot; y=&quot;252&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--ldr)&quot;&gt;finds add, patches slot&lt;/text&gt;
        &lt;rect x=&quot;210&quot; y=&quot;282&quot; width=&quot;100&quot; height=&quot;30&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;210&quot; y=&quot;282&quot; width=&quot;100&quot; height=&quot;30&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;260&quot; y=&quot;301&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;add()&lt;/text&gt;
        &lt;!-- right panel --&gt;
        &lt;rect x=&quot;450&quot; y=&quot;40&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;450&quot; y=&quot;40&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;510&quot; y=&quot;59&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;main: call add&lt;/text&gt;
        &lt;rect x=&quot;450&quot; y=&quot;100&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;450&quot; y=&quot;100&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;510&quot; y=&quot;119&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;add@plt stub&lt;/text&gt;
        &lt;rect x=&quot;450&quot; y=&quot;160&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;450&quot; y=&quot;160&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;510&quot; y=&quot;179&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--seg)&quot;&gt;GOT slot ✓ patched&lt;/text&gt;
        &lt;rect x=&quot;450&quot; y=&quot;282&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;450&quot; y=&quot;282&quot; width=&quot;120&quot; height=&quot;30&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;510&quot; y=&quot;301&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;add()&lt;/text&gt;
      &lt;/g&gt;
      &lt;g font-family=&quot;var(--font-display)&quot; font-size=&quot;10&quot;&gt;
        &lt;!-- left arrows: 5 numbered hops --&gt;
        &lt;g stroke=&quot;var(--muted)&quot; stroke-width=&quot;1.4&quot; fill=&quot;none&quot; marker-end=&quot;url(#f3a)&quot;&gt;
          &lt;path d=&quot;M 170 70 L 170 96&quot;/&gt;
          &lt;path d=&quot;M 170 130 L 170 156&quot;/&gt;
          &lt;path d=&quot;M 148 190 C 130 202, 122 208, 112 218&quot;/&gt;
        &lt;/g&gt;
        &lt;g stroke=&quot;var(--ldr)&quot; stroke-width=&quot;1.4&quot; fill=&quot;none&quot; marker-end=&quot;url(#f3l)&quot;&gt;
          &lt;path d=&quot;M 105 222 C 105 205, 128 192, 140 188&quot; stroke-dasharray=&quot;4 3&quot;/&gt;
          &lt;path d=&quot;M 180 250 C 215 262, 235 270, 252 278&quot;/&gt;
        &lt;/g&gt;
        &lt;text x=&quot;182&quot; y=&quot;88&quot; fill=&quot;var(--muted)&quot;&gt;1&lt;/text&gt;
        &lt;text x=&quot;182&quot; y=&quot;148&quot; fill=&quot;var(--muted)&quot;&gt;2 slot → back into stub&lt;/text&gt;
        &lt;text x=&quot;96&quot; y=&quot;208&quot; fill=&quot;var(--muted)&quot;&gt;3&lt;/text&gt;
        &lt;text x=&quot;150&quot; y=&quot;204&quot; fill=&quot;var(--ldr)&quot;&gt;4 patch&lt;/text&gt;
        &lt;text x=&quot;238&quot; y=&quot;262&quot; fill=&quot;var(--ldr)&quot;&gt;5&lt;/text&gt;
        &lt;!-- right arrows: 3 hops --&gt;
        &lt;g stroke=&quot;var(--muted)&quot; stroke-width=&quot;1.4&quot; fill=&quot;none&quot; marker-end=&quot;url(#f3a)&quot;&gt;
          &lt;path d=&quot;M 510 70 L 510 96&quot;/&gt;
          &lt;path d=&quot;M 510 130 L 510 156&quot;/&gt;
          &lt;path d=&quot;M 510 190 L 510 278&quot;/&gt;
        &lt;/g&gt;
        &lt;text x=&quot;522&quot; y=&quot;88&quot; fill=&quot;var(--muted)&quot;&gt;1&lt;/text&gt;
        &lt;text x=&quot;522&quot; y=&quot;148&quot; fill=&quot;var(--muted)&quot;&gt;2&lt;/text&gt;
        &lt;text x=&quot;522&quot; y=&quot;238&quot; fill=&quot;var(--muted)&quot;&gt;3 jmp *slot&lt;/text&gt;
      &lt;/g&gt;
    &lt;/svg&gt;
    &lt;p class=&quot;legend&quot;&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--sec)&quot;&gt;&lt;/span&gt;our code&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--seg)&quot;&gt;&lt;/span&gt;GOT (data)&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--ldr)&quot;&gt;&lt;/span&gt;loader&lt;/span&gt;
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/figure&gt;
&lt;p&gt;The full static evidence (the complete PLT disassembly, the initial &lt;code&gt;.got.plt&lt;/code&gt; bytes, the relocation table) is in &lt;a href=&quot;#appendix-f-loaders-relocation-mechanism&quot;&gt;Appendix F&lt;/a&gt;. (If you&apos;d rather drive this with gdb, use native x86-64 Linux: under Rosetta emulation &lt;code&gt;ptrace&lt;/code&gt; is unavailable, which is exactly why the self-inspecting approach exists.)&lt;/p&gt;
&lt;h3&gt;3.5 Why not just call through the GOT directly?&lt;/h3&gt;
&lt;p&gt;A fair question: if calls go through a GOT slot anyway, why bother with the PLT stub at all? Why doesn&apos;t the compiler emit &lt;code&gt;call *GOT_entry&lt;/code&gt; directly?&lt;/p&gt;
&lt;p&gt;It can (&lt;code&gt;-fno-plt&lt;/code&gt; does roughly that, and consequently forces eager binding). The traditional PLT exists to solve the &lt;em&gt;&amp;quot;who called me?&amp;quot;&lt;/em&gt; problem that lazy binding creates. If an unresolved &lt;code&gt;call *GOT_entry&lt;/code&gt; landed in the resolver, the resolver would have no idea &lt;em&gt;which&lt;/em&gt; symbol you wanted: &lt;code&gt;add&lt;/code&gt;? &lt;code&gt;sleep&lt;/code&gt;? The PLT stub&apos;s &lt;code&gt;push $0x0&lt;/code&gt; is the missing ID: it pushes the relocation index so the resolver can look up exactly the right &lt;code&gt;R_X86_64_JUMP_SLOT&lt;/code&gt; entry in &lt;code&gt;DT_JMPREL&lt;/code&gt; and resolve precisely the intended symbol.&lt;/p&gt;
&lt;p&gt;For the record-by-record version of everything above (how &lt;code&gt;PT_DYNAMIC&lt;/code&gt; maps out the string/symbol/relocation tables, how &lt;code&gt;R_X86_64_GLOB_DAT&lt;/code&gt; entries for things like &lt;code&gt;__libc_start_main&lt;/code&gt; get resolved, and the full transcripts), see &lt;a href=&quot;#appendix-f-loaders-relocation-mechanism&quot;&gt;Appendix F&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Part IV: The Handoff (Loader → User)&lt;/h2&gt;
&lt;p&gt;The loader is now ready to hand control to your application. But it doesn&apos;t just call &lt;code&gt;main()&lt;/code&gt;. In fact, it doesn&apos;t even know &lt;code&gt;main&lt;/code&gt; exists.&lt;/p&gt;
&lt;p&gt;The transition from the loader to your code happens in two steps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1: The loader&apos;s exit (&lt;a href=&quot;https://elixir.bootlin.com/glibc/glibc-2.42.9000/source/sysdeps/x86_64/dl-machine.h#L144&quot;&gt;&lt;code&gt;_dl_start_user&lt;/code&gt;&lt;/a&gt;)&lt;/strong&gt;
First, the loader runs the constructors (&lt;code&gt;.init&lt;/code&gt; / &lt;code&gt;.init_array&lt;/code&gt;) for all shared libraries (e.g., &lt;code&gt;libmath.so&lt;/code&gt;) to ensure they are ready.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2: The application&apos;s entry (&lt;code&gt;_start&lt;/code&gt;)&lt;/strong&gt;
The CPU lands at a function called &lt;code&gt;_start&lt;/code&gt;. This is not your code. It is a small assembly stub provided by the C runtime (&lt;code&gt;Scrt1.o&lt;/code&gt;, the position-independent sibling of the classic &lt;code&gt;crt1.o&lt;/code&gt;, since our binary is a PIE) that was linked into your binary at build time. Its job is to set up the stack and pass arguments (&lt;code&gt;argc&lt;/code&gt;, &lt;code&gt;argv&lt;/code&gt;) to the C library helper &lt;code&gt;__libc_start_main&lt;/code&gt;, which runs the constructors for your executable (the loader&apos;s &lt;code&gt;_dl_init&lt;/code&gt; already ran the shared libraries&apos; constructors) and finally calls your &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;(Curious what this assembly looks like? See &lt;a href=&quot;#appendix-g-the-assembly-handoff-_start&quot;&gt;Appendix G: The Assembly Handoff&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;That completes the relay from fig. 0: every leg of it has now crossed the page. Replay the whole thing, one step at a time; each caption should read as review, not news:&lt;/p&gt;
&lt;div class=&quot;frame diagram&quot; data-loader-stepper&gt;
  &lt;span class=&quot;frame-title&quot;&gt;fig. 0b · the relay, replayed step by step&lt;/span&gt;
  &lt;div class=&quot;diagram-body&quot;&gt;
    &lt;noscript&gt;&lt;p&gt;This figure is interactive and needs JavaScript; fig. 0 back in the intro tells the same story statically.&lt;/p&gt;&lt;/noscript&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;h2&gt;Part V: The Flashback (Build Time)&lt;/h2&gt;
&lt;p&gt;When we run &lt;code&gt;gcc -c main.c&lt;/code&gt;, GCC acts as a driver. It runs &lt;code&gt;cc1&lt;/code&gt; (compiler) and &lt;code&gt;as&lt;/code&gt; (assembler) to produce &lt;code&gt;main.o&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;At this stage, the compiler does not know where &lt;code&gt;add&lt;/code&gt; is. It creates a relocation entry, basically a &amp;quot;to‑do&amp;quot; note for the linker.&lt;/p&gt;
&lt;p&gt;Let&apos;s inspect &lt;code&gt;main.o&lt;/code&gt;&apos;s relocation table, and the machine code it refers to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# gcc -c main.c
root@container:/code# readelf -r main.o
Relocation section &apos;.rela.text&apos; at offset 0x1a0 contains 2 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000000017  000400000004 R_X86_64_PLT32    0000000000000000 add - 4
000000000024  000500000004 R_X86_64_PLT32    0000000000000000 sleep - 4

root@container:/code# objdump -d main.o
0000000000000000 &amp;lt;main&amp;gt;:
   0:	f3 0f 1e fa          	endbr64
   ...
   c:	be 0a 00 00 00       	mov    $0xa,%esi
  11:	bf 05 00 00 00       	mov    $0x5,%edi
  16:	e8 00 00 00 00       	call   1b &amp;lt;main+0x1b&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Look at offset &lt;code&gt;0x16&lt;/code&gt;: a &lt;code&gt;call&lt;/code&gt; instruction (&lt;code&gt;e8&lt;/code&gt;) whose 4-byte operand is &lt;strong&gt;all zeroes&lt;/strong&gt;. It &amp;quot;calls&amp;quot; the next instruction, because the compiler had nothing to put there. That&apos;s the hole.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Offset &lt;code&gt;0x17&lt;/code&gt;:&lt;/strong&gt; the relocation points at the &lt;em&gt;operand&lt;/em&gt;, one byte past the &lt;code&gt;e8&lt;/code&gt; opcode, the exact 4 bytes the linker must patch. (There are two entries because &lt;code&gt;main&lt;/code&gt; also calls &lt;code&gt;sleep&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Type &lt;code&gt;R_X86_64_PLT32&lt;/code&gt;:&lt;/strong&gt; tells the linker: &amp;quot;I need a 32-bit PC-relative address to a PLT entry for symbol &lt;code&gt;add&lt;/code&gt;.&amp;quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5.1 The Hidden Startup Files&lt;/h3&gt;
&lt;p&gt;In Section 4 we saw that the real entry point is &lt;code&gt;_start&lt;/code&gt;, not &lt;code&gt;main()&lt;/code&gt;, and that it comes from the C runtime&apos;s startup object. But we never asked GCC to link that file. Where did it come from?&lt;/p&gt;
&lt;p&gt;When you run &lt;code&gt;gcc&lt;/code&gt;, it silently injects several startup objects provided by glibc: &lt;code&gt;Scrt1.o&lt;/code&gt; (which contains &lt;code&gt;_start&lt;/code&gt;; the plain &lt;code&gt;crt1.o&lt;/code&gt; is used for non-PIE links), &lt;code&gt;crti.o&lt;/code&gt; (init prologue), and &lt;code&gt;crtn.o&lt;/code&gt; (init epilogue). The naming is historical: the original was called &lt;code&gt;crt0.o&lt;/code&gt; (C RunTime, file zero), and the split into multiple files came later as initialization grew more complex. The &lt;code&gt;S&lt;/code&gt; suffix marks the PIC/PIE variants.&lt;/p&gt;
&lt;p&gt;You can see this hidden injection by running GCC with verbose flags:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# gcc -v -o dynamic_app main.o -L. -lmath 2&amp;gt;&amp;amp;1 | grep collect2
 .../collect2 ... -pie -z now -z relro -o dynamic_app
   .../x86_64-linux-gnu/Scrt1.o .../x86_64-linux-gnu/crti.o .../11/crtbeginS.o
   -L. ... main.o -lmath ... -lc ... .../11/crtendS.o .../x86_64-linux-gnu/crtn.o
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things hide in that line. First, the startup files: since our binary is a PIE, gcc injects &lt;code&gt;Scrt1.o&lt;/code&gt; (the position-independent variant of &lt;code&gt;crt1.o&lt;/code&gt;; a non-PIE link would use &lt;code&gt;crt1.o&lt;/code&gt; itself), plus &lt;code&gt;crti.o&lt;/code&gt;/&lt;code&gt;crtn.o&lt;/code&gt; and gcc&apos;s own &lt;code&gt;crtbeginS.o&lt;/code&gt;/&lt;code&gt;crtendS.o&lt;/code&gt;. Second, look again at the flags gcc chose without asking us: &lt;strong&gt;&lt;code&gt;-pie -z now -z relro&lt;/code&gt;&lt;/strong&gt;. That&apos;s the paper trail for both PIE-by-default (Part II) and eager-binding-by-default (Part III), sitting in one &lt;code&gt;gcc -v&lt;/code&gt; invocation.&lt;/p&gt;
&lt;h3&gt;5.2 The Linker (&lt;code&gt;ld&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;Now &lt;code&gt;ld&lt;/code&gt; runs. It has &lt;code&gt;main.o&lt;/code&gt;, &lt;code&gt;Scrt1.o&lt;/code&gt;, and &lt;code&gt;libmath.so&lt;/code&gt;. It needs to create one file.&lt;/p&gt;
&lt;h4&gt;Step 1: The Blueprint (Linker Script)&lt;/h4&gt;
&lt;p&gt;The linker follows a script to decide memory layout.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# ld --verbose | grep -A 5 &amp;quot;SECTIONS&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Among many other directives, it tells the linker things like: &amp;quot;collect all input &lt;code&gt;.text&lt;/code&gt; sections into one output &lt;code&gt;.text&lt;/code&gt; section, all &lt;code&gt;.rodata&lt;/code&gt; into one &lt;code&gt;.rodata&lt;/code&gt;,&amp;quot; and so on. It also defines segment boundaries, alignment, and the order things appear in the final binary.&lt;/p&gt;
&lt;h4&gt;Step 2: Weaving Sections Together&lt;/h4&gt;
&lt;p&gt;The linker maps the output file into memory (using &lt;code&gt;mmap&lt;/code&gt;). It then performs a &amp;quot;scatter-gather&amp;quot; copy.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It copies &lt;code&gt;Scrt1.o&lt;/code&gt;&apos;s &lt;code&gt;.text&lt;/code&gt; to the beginning of the output buffer.&lt;/li&gt;
&lt;li&gt;It copies &lt;code&gt;main.o&lt;/code&gt;&apos;s &lt;code&gt;.text&lt;/code&gt; right after it.&lt;/li&gt;
&lt;li&gt;It updates its internal symbol map: &lt;code&gt;main&lt;/code&gt; is no longer at offset &lt;code&gt;0&lt;/code&gt;; it is now at some final virtual address (and in PIE, that address is a &lt;em&gt;relative&lt;/em&gt; virtual address that will receive a load bias at runtime).&lt;/li&gt;
&lt;/ol&gt;
&lt;h4&gt;Step 3: Synthesis (PLT &amp;amp; GOT)&lt;/h4&gt;
&lt;p&gt;The linker sees the &lt;code&gt;R_X86_64_PLT32&lt;/code&gt; relocation for &lt;code&gt;add&lt;/code&gt;. It checks &lt;code&gt;libmath.so&lt;/code&gt; and sees &lt;code&gt;add&lt;/code&gt; is a shared symbol.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Allocate:&lt;/strong&gt; it reserves space in &lt;code&gt;.plt&lt;/code&gt; and &lt;code&gt;.got&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write:&lt;/strong&gt; it writes the machine code instructions (&amp;quot;trampoline&amp;quot;) into the PLT section, and reserves a GOT slot for the symbol.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;But if &lt;code&gt;add&lt;/code&gt; is resolved at runtime, why did the linker need &lt;code&gt;libmath.so&lt;/code&gt; at all? It needs the file to verify that &lt;code&gt;add&lt;/code&gt; actually exists, to record symbol and version requirements, and to write the &lt;code&gt;DT_NEEDED&lt;/code&gt; tag so the loader knows to find and load &lt;code&gt;libmath.so&lt;/code&gt; at runtime. (Linker flags like &lt;code&gt;--allow-shlib-undefined&lt;/code&gt; or &lt;code&gt;--unresolved-symbols&lt;/code&gt; can relax the existence check, but the default is to fail fast if a symbol can&apos;t be found.)&lt;/p&gt;
&lt;h4&gt;Step 4: Patching the Holes (Relocations)&lt;/h4&gt;
&lt;p&gt;Remember the relocation entry we saw earlier in &lt;code&gt;main.o&lt;/code&gt;?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Offset 0x17    Type R_X86_64_PLT32    Symbol: add    Addend: -4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The linker now processes this. It does not scan the machine code looking for call instructions. It walks the &lt;code&gt;.rela.text&lt;/code&gt; table, and for each entry it knows exactly which byte to patch and how.&lt;/p&gt;
&lt;p&gt;For our &lt;code&gt;add&lt;/code&gt; entry, the process is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The linker looks at the &lt;strong&gt;Offset&lt;/strong&gt; (&lt;code&gt;0x17&lt;/code&gt;). That is where the placeholder bytes sit inside &lt;code&gt;.text&lt;/code&gt;, the operand of the &lt;code&gt;call&lt;/code&gt; at &lt;code&gt;0x16&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It knows from Step 3 that &lt;code&gt;add@plt&lt;/code&gt; now lives at some address in the PLT section.&lt;/li&gt;
&lt;li&gt;It computes: &amp;quot;how far is &lt;code&gt;add@plt&lt;/code&gt; from this call site?&amp;quot; That distance is a 32-bit relative offset, which is what &lt;code&gt;R_X86_64_PLT32&lt;/code&gt; asks for. (The addend &lt;code&gt;-4&lt;/code&gt; accounts for the fact that x86 measures the offset from the &lt;em&gt;end&lt;/em&gt; of the instruction, &lt;code&gt;0x17 + 4&lt;/code&gt;, not from the operand itself.)&lt;/li&gt;
&lt;li&gt;It writes that offset into the 4 bytes at position &lt;code&gt;0x17&lt;/code&gt;, replacing the placeholder.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You can see the patched result in the final binary: the same instruction that read &lt;code&gt;e8 00 00 00 00&lt;/code&gt; in &lt;code&gt;main.o&lt;/code&gt; reads &lt;code&gt;e8 dc fe ff ff&lt;/code&gt; in &lt;code&gt;dynamic_app_lazy&lt;/code&gt;: a PC-relative hop to &lt;code&gt;add@plt&lt;/code&gt; (we saw it in Part III&apos;s disassembly).&lt;/p&gt;
&lt;p&gt;Now when the CPU executes this &lt;code&gt;call&lt;/code&gt; instruction at runtime, the offset points straight to &lt;code&gt;add@plt&lt;/code&gt;.&lt;/p&gt;
&lt;h4&gt;Step 5: Sections to Segments&lt;/h4&gt;
&lt;p&gt;Finally, the linker maps output sections (&lt;code&gt;.text&lt;/code&gt;, &lt;code&gt;.data&lt;/code&gt;) to program headers (&lt;code&gt;PT_LOAD&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;It groups read-only sections (&lt;code&gt;.text&lt;/code&gt;, &lt;code&gt;.plt&lt;/code&gt;, &lt;code&gt;.rodata&lt;/code&gt;) into segments so the kernel can protect them efficiently, and it may emit multiple &lt;code&gt;PT_LOAD&lt;/code&gt; segments to match permissions and RELRO constraints.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Part VI: Static Linking&lt;/h2&gt;
&lt;p&gt;We have just spent multiple sections detailing the immense complexity of dynamic loading: the PLT, the GOT, the loader, runtime patching, and startup costs.&lt;/p&gt;
&lt;p&gt;For many teams, this complexity is a feature: when a security vulnerability is found in a shared library like &lt;code&gt;openssl&lt;/code&gt;, the OS can patch it once and every application that links against it picks up the fix without recompiling.&lt;/p&gt;
&lt;p&gt;But for hyperscalers (like Meta, Google, and Netflix), this complexity is often a liability. They may opt for static linking, where every dependency is merged into a single executable file.&lt;/p&gt;
&lt;h3&gt;6.1 Why Hyperscalers Link Statically&lt;/h3&gt;
&lt;p&gt;Companies like Google and Meta prefer to statically link their production services. The reasons are practical:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hermeticity (the &amp;quot;dependency hell&amp;quot; problem):&lt;/strong&gt;
Imagine a service that depends on PyTorch, which depends on &lt;code&gt;libcuda.so&lt;/code&gt;, which depends on &lt;code&gt;libgcc_s.so&lt;/code&gt;. If you deploy a dynamically linked binary to a production machine that has a slightly different version of &lt;code&gt;libgcc&lt;/code&gt;, your service crashes at 3 AM. With static linking, the binary is self-contained: if it works on the build machine, it works in production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Startup speed:&lt;/strong&gt;
Dynamic linking waits until runtime to resolve symbols, and for a large program this cost is not trivial. It can take seconds to calculate relocation mappings for large applications with hundreds of shared libraries. The loader must walk symbol hash tables, process relocations, and patch GOT entries, all the machinery we traced in Parts II through IV, before &lt;code&gt;main()&lt;/code&gt; even starts. Static linking eliminates this entirely: there is no loader, no symbol resolution, and no PLT/GOT patching at runtime.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-world example: Meta&apos;s build system (Buck2)&lt;/strong&gt;
Meta&apos;s build systems (Buck/Buck2) were designed to manage these trade-offs using build modes. (This is based on my experience working on Meta&apos;s build infrastructure.)&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@mode/dev&lt;/code&gt; (dynamic): used on developer laptops for fast iteration and quick incremental builds.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@mode/opt&lt;/code&gt; (static / more hermetic): used for production deployments to guarantee performance and hermeticity.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Buck2&apos;s open-source configuration system exposes &lt;code&gt;dev&lt;/code&gt; and &lt;code&gt;opt&lt;/code&gt; as standard constraint values, and the choice between them changes how every C++ dependency in the graph is linked.&lt;/p&gt;
&lt;h3&gt;6.2 The Consequence: The 2 GiB Relocation Barrier&lt;/h3&gt;
&lt;p&gt;Static linking sounds perfect until physics gets in the way. When you bundle an entire AI stack, or the transitive closure of a massive monorepo, into a single binary, it can grow to gigabytes. While working on Meta&apos;s build infrastructure, I regularly saw Buck2-built binaries exceed 25 GiB (including debug symbols) for large C++ services. At that point, a fundamental x86-64 limitation surfaces.&lt;/p&gt;
&lt;p&gt;Recall from Step 4 in Section 5.2: the &lt;code&gt;call&lt;/code&gt; instruction uses a 32-bit signed PC-relative offset. That gives it a reach of roughly &lt;strong&gt;±2 GiB&lt;/strong&gt;. If the linker cannot place a call target within that range, the link fails:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;relocation truncated to fit: R_X86_64_PC32
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This was not a theoretical problem. Large services with deep dependency graphs would hit this barrier, especially when built with instrumentation like &lt;code&gt;-fprofile-generate&lt;/code&gt; or sanitizers that inflate code and data sections. Engineers sometimes refer to it colloquially as the &amp;quot;4 GB trap,&amp;quot; but the underlying limit is the &lt;strong&gt;signed 32-bit reach of PC-relative relocations&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The brute-force fix is to compile with &lt;code&gt;-mcmodel=large&lt;/code&gt;, which replaces the 5-byte relative &lt;code&gt;call&lt;/code&gt; with a 12-byte &lt;code&gt;movabs&lt;/code&gt; + &lt;code&gt;call&lt;/code&gt; sequence that can reach any address. But this bloats every call site and increases register pressure, a steep price when you have millions of them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The practical solution: link groups&lt;/strong&gt;
Rather than choosing between 10,000 tiny shared objects (too slow to load) or 1 giant static binary (too big to link), hyperscalers split the difference. They group related code into &amp;quot;islands&amp;quot;: everything inside a group is statically linked together into one medium-sized &lt;code&gt;.so&lt;/code&gt;, and the main binary dynamically links against just a handful of these groups.&lt;/p&gt;
&lt;p&gt;Buck2 has this concept built directly into its C++ rules. A &lt;code&gt;prebuilt_cxx_library_group&lt;/code&gt; bundles related libraries that must be linked together, and the &lt;code&gt;auto_link_groups&lt;/code&gt; and &lt;code&gt;link_group_map&lt;/code&gt; attributes on &lt;code&gt;cxx_binary&lt;/code&gt; let the build system automatically partition the dependency graph into groups. The result: you might resolve 5 or 6 groups at startup instead of 50,000 individual DSOs, while keeping each group well within the 2 GiB barrier.&lt;/p&gt;
&lt;h3&gt;6.3 The Execution Flow (No Loader Involved)&lt;/h3&gt;
&lt;p&gt;If you build a fully static binary, the execution flow changes drastically. The loader (&lt;code&gt;ld-linux.so&lt;/code&gt;) is removed from the picture entirely.&lt;/p&gt;
&lt;p&gt;You can see this logic in the Linux kernel source &lt;code&gt;fs/binfmt_elf.c&lt;/code&gt;. When you run a binary, the kernel checks for the &lt;code&gt;PT_INTERP&lt;/code&gt; segment (which specifies the loader).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If &lt;code&gt;PT_INTERP&lt;/code&gt; is missing (static binary):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No interpreter:&lt;/strong&gt; the kernel does not map &lt;code&gt;ld-linux.so&lt;/code&gt; into memory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Direct entry:&lt;/strong&gt; instead of setting RIP to the loader&apos;s &lt;code&gt;_start&lt;/code&gt;, the kernel sets it directly to the binary&apos;s entry point (&lt;code&gt;e_entry&lt;/code&gt; from the ELF header).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The new beginning:&lt;/strong&gt; execution usually begins at &lt;code&gt;_start&lt;/code&gt; (from the startup object, &lt;code&gt;crt1.o&lt;/code&gt; for classic static builds), which sets up the stack and calls &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is no PLT indirection into shared libraries and no loader to wait for. (One nuance for the pedantic: even fully static glibc binaries perform a small amount of startup self-fixup, &lt;code&gt;R_X86_64_IRELATIVE&lt;/code&gt; relocations for IFUNC symbols like the optimized &lt;code&gt;memcpy&lt;/code&gt; variants, and a &lt;em&gt;static-PIE&lt;/em&gt; binary relocates itself the way &lt;code&gt;ld.so&lt;/code&gt; does, with no loader involved.) To a first approximation, though: the CPU just jumps straight into your code.&lt;/p&gt;
&lt;p&gt;Everything we have covered so far happens before &lt;code&gt;main()&lt;/code&gt; starts. But sometimes you need to load code &lt;em&gt;after&lt;/em&gt; the program is already running: plugins, optional features, or hot-loaded extensions. This is what &lt;code&gt;dlopen&lt;/code&gt; and &lt;code&gt;dlsym&lt;/code&gt; provide. See &lt;a href=&quot;#appendix-h-runtime-loading-dlopendlsym&quot;&gt;Appendix H: Runtime Loading (dlopen/dlsym)&lt;/a&gt; for how the loader handles this and why it reuses much of the same machinery we have already seen.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Part VII: The Payoff — Both Errors, Solved&lt;/h2&gt;
&lt;p&gt;We opened with two production errors and a promise. Everything needed to keep it is now on the table.&lt;/p&gt;
&lt;h3&gt;7.1 &lt;code&gt;version &apos;GLIBC_2.34&apos; not found&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Let&apos;s manufacture the error honestly: build the demo on a &lt;strong&gt;newer&lt;/strong&gt; distro, run it on an &lt;strong&gt;older&lt;/strong&gt; one.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# build on ubuntu:24.04 (glibc 2.39) ... then run on ubuntu:20.04 (glibc 2.31):
root@ubuntu20:/code# ./dynamic_app_glibc234
./dynamic_app_glibc234: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34&apos;
    not found (required by ./dynamic_app_glibc234)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Where did the binary get the nerve to &lt;em&gt;demand&lt;/em&gt; a specific glibc version? From the version tables we&apos;ve been stepping around all post. Every dynamic symbol can carry a &lt;strong&gt;version requirement&lt;/strong&gt;; &lt;code&gt;readelf -V&lt;/code&gt; reads them straight out of our own default build:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# readelf -V ./dynamic_app
Version symbols section &apos;.gnu.version&apos; contains 8 entries:
  000:   0 (*local*)       2 (GLIBC_2.34)    1 (*global*)      1 (*global*)
  004:   1 (*global*)      1 (*global*)      3 (GLIBC_2.2.5)   3 (GLIBC_2.2.5)

Version needs section &apos;.gnu.version_r&apos; contains 1 entry:
  000000: Version: 1  File: libc.so.6  Cnt: 2
  0x0010:   Name: GLIBC_2.2.5  Flags: none  Version: 3
  0x0020:   Name: GLIBC_2.34  Flags: none  Version: 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read it as a contract: &lt;em&gt;&amp;quot;I need &lt;code&gt;libc.so.6&lt;/code&gt;, and from it I need symbols at version &lt;code&gt;GLIBC_2.2.5&lt;/code&gt; (that&apos;s &lt;code&gt;sleep&lt;/code&gt;) and &lt;code&gt;GLIBC_2.34&lt;/code&gt; (that&apos;s &lt;code&gt;__libc_start_main&lt;/code&gt;).&amp;quot;&lt;/em&gt; At link time, the linker recorded the version each symbol had in the libc it linked against. glibc 2.34 restructured its startup symbols, so anything linked against glibc ≥ 2.34 requires &lt;code&gt;__libc_start_main@GLIBC_2.34&lt;/code&gt;. At load time, the loader checks &lt;code&gt;.gnu.version_r&lt;/code&gt; against what the target&apos;s &lt;code&gt;libc.so.6&lt;/code&gt; actually exports (&lt;code&gt;VERDEF&lt;/code&gt; tables), and refuses to start if a required version is missing. The error isn&apos;t mystical: it&apos;s the loader reading a table we can read ourselves.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix follows from the mechanism:&lt;/strong&gt; build against the &lt;em&gt;oldest&lt;/em&gt; glibc you must support (build in an old container, since glibc versions are backward-compatible, not forward), ship the runtime with the binary (containers), or take the loader out of the picture entirely (static linking, Part VI).&lt;/p&gt;
&lt;h3&gt;7.2 &lt;code&gt;cannot open shared object file&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Our Makefile&apos;s history contains this bug on purpose. Watch what happens if you link the &amp;quot;obvious&amp;quot; way, naming the file directly, instead of with &lt;code&gt;-L. -lmath&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# gcc -o dynamic_app_broken main.c ./libmath.so -Wl,-rpath,&apos;$ORIGIN&apos;
root@container:/code# readelf -d ./dynamic_app_broken | grep -E &apos;NEEDED|RUNPATH&apos;
 0x0000000000000001 (NEEDED)             Shared library: [./libmath.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000001d (RUNPATH)            Library runpath: [$ORIGIN]

root@container:/# cd / &amp;amp;&amp;amp; /code/dynamic_app_broken
/code/dynamic_app_broken: error while loading shared libraries: ./libmath.so:
    cannot open shared object file: No such file or directory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There&apos;s our second opening error, self-inflicted. The &lt;code&gt;DT_NEEDED&lt;/code&gt; entry became the literal string &lt;code&gt;./libmath.so&lt;/code&gt;. Remember the rule from Part III: &lt;strong&gt;a needed name containing &lt;code&gt;/&lt;/code&gt; is used as a path, and every search mechanism is skipped.&lt;/strong&gt; The &lt;code&gt;RUNPATH [$ORIGIN]&lt;/code&gt; we carefully asked for is dead code; the binary only works when your &lt;em&gt;current directory&lt;/em&gt; happens to contain the library. It ran fine in &lt;code&gt;/code&lt;/code&gt; during development, then broke in production the first time someone ran it from anywhere else. Sound familiar?&lt;/p&gt;
&lt;p&gt;The fixed link (&lt;code&gt;-L. -lmath&lt;/code&gt;) stores a bare &lt;code&gt;NEEDED [libmath.so]&lt;/code&gt;, the search machinery engages, &lt;code&gt;RUNPATH&lt;/code&gt; expands &lt;code&gt;$ORIGIN&lt;/code&gt; to the binary&apos;s own directory, and it runs from anywhere:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/# /code/dynamic_app &amp;amp;&amp;amp; echo &amp;quot;runs fine from /&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Triage order for this error in the wild:&lt;/strong&gt; &lt;code&gt;readelf -d&lt;/code&gt; the binary first. If &lt;code&gt;DT_NEEDED&lt;/code&gt; contains a slash, you have this exact bug. If it&apos;s a bare name, run with &lt;code&gt;LD_DEBUG=libs&lt;/code&gt; to watch the search and see which directories were probed (we did exactly this in Part III). Then fix it structurally (&lt;code&gt;-Wl,-rpath,&apos;$ORIGIN&apos;&lt;/code&gt; for relocatable bundles, or &lt;code&gt;ldconfig&lt;/code&gt; for system-wide installs) rather than exporting &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt; in your shell profile and hoping.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Conclusion: The Full Cycle&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Compiler:&lt;/strong&gt; generates &lt;code&gt;main.o&lt;/code&gt; with relocation entries (&amp;quot;holes&amp;quot;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Linker:&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;injects &lt;code&gt;Scrt1.o&lt;/code&gt; (the true entry point),&lt;/li&gt;
&lt;li&gt;weaves &lt;code&gt;.text&lt;/code&gt; sections together based on the script,&lt;/li&gt;
&lt;li&gt;synthesizes PLT/GOT for dynamic symbols,&lt;/li&gt;
&lt;li&gt;patches the holes using the relocation table.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Running the application is a dance between user apps (terminal) and the kernel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kernel:&lt;/strong&gt; maps segments, writes the auxv handshake, and invokes the interpreter (if &lt;code&gt;PT_INTERP&lt;/code&gt; exists).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loader:&lt;/strong&gt; loads DSOs, applies relocations (eagerly under &lt;code&gt;-z now&lt;/code&gt;, or lazily via the PLT), locks down RELRO. Calls &lt;code&gt;_start&lt;/code&gt; → &lt;code&gt;__libc_start_main&lt;/code&gt; → &lt;code&gt;main()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The &amp;quot;simple&amp;quot; act of running &lt;code&gt;./app&lt;/code&gt; is a relay race passing the baton between the compiler, linker, kernel, and dynamic loader. And the two errors we started with are just the baton being dropped at two specific hand-offs: a version contract the loader can&apos;t satisfy, and a library search that never ran.&lt;/p&gt;
&lt;p&gt;That follow-up now exists: &lt;a href=&quot;/blog/split-state-linking/&quot;&gt;Part 2, &lt;em&gt;howtf can a device be both present and not found?&lt;/em&gt;&lt;/a&gt; traces a production incident where this machinery failed at scale: two collective communication libraries in one binary, a symbol collision that silently split one library&apos;s state into two live copies, and RDMA devices that were present, registered, and &amp;quot;not found.&amp;quot; The resolution pipeline we just traced is the key to that diagnosis.&lt;/p&gt;
&lt;h2&gt;Appendices&lt;/h2&gt;
&lt;p&gt;Evidence lockers: the full dumps and gnarlier details the body text points at. Skip freely; return when a claim needs its receipts.&lt;/p&gt;
&lt;h3&gt;Appendix A: The Cross-Architecture Magic (Rosetta &amp;amp; QEMU)&lt;/h3&gt;
&lt;p&gt;If you ran this lab on an Apple Silicon Mac (M1/M2/M3) or a Windows ARM machine, you likely noticed that the x86-64 binary simply executed. It didn&apos;t crash, and it didn&apos;t require a manual emulator command.&lt;/p&gt;
&lt;p&gt;Three pieces coordinate to make it happen:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a translation or emulation layer (Rosetta or QEMU),&lt;/li&gt;
&lt;li&gt;the container/VM runtime (e.g., Docker Desktop / WSL2 / Apple&apos;s Virtualization Framework),&lt;/li&gt;
&lt;li&gt;and the Linux kernel&apos;s &lt;a href=&quot;https://docs.kernel.org/admin-guide/binfmt-misc.html&quot;&gt;&lt;code&gt;binfmt_misc&lt;/code&gt;&lt;/a&gt; dispatch mechanism.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;1) The Architecture Gap&lt;/h4&gt;
&lt;p&gt;Our host CPU speaks a different ISA than the guest binary. There are two broad approaches:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Emulation (QEMU-style):&lt;/strong&gt; interpret/translate instructions and emulate architectural effects in software.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Translation (Rosetta-style):&lt;/strong&gt; translate blocks of guest instructions into host instructions and cache/execute the translations.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Either way, the translator must preserve &lt;strong&gt;architectural semantics&lt;/strong&gt;, not just instruction-by-instruction behavior. One example is &lt;strong&gt;memory ordering&lt;/strong&gt;: x86&apos;s memory model is stronger (often described as TSO-like) than ARM&apos;s default. Translators must ensure the program observes x86-legal outcomes, which can require extra ordering constraints (i.e. inserting memory barriers) in the generated code or other clever mechanisms. That can affect performance.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Windows (QEMU Emulation):&lt;/strong&gt; On Windows ARM, Docker commonly runs Linux containers inside a Linux VM (via WSL2). Cross‑arch support is frequently implemented by registering QEMU handlers with &lt;code&gt;binfmt_misc&lt;/code&gt;, so that when the kernel encounters an x86‑64 ELF, it transparently invokes a QEMU interpreter (e.g., &lt;code&gt;qemu-x86_64&lt;/code&gt;) to run it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;macOS (Rosetta + Hardware TSO):&lt;/strong&gt; On macOS, Docker Desktop runs Linux containers inside a lightweight Linux VM and can integrate Rosetta into that VM so x86‑64 Linux binaries can run on Apple Silicon. Apple solved the memory ordering bottleneck at the silicon level. Their M-series chips include a hardware switch to enable &lt;strong&gt;Total Store Ordering (TSO)&lt;/strong&gt;. This allows the Rosetta translator to run without the heavy software barrier overhead, achieving near-native speeds.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;2) How Rosetta Gets into the VM (VirtioFS Injection)&lt;/h4&gt;
&lt;p&gt;The Linux kernel inside our Docker VM does not ship with Rosetta. It is injected from macOS. Docker uses the &lt;strong&gt;Apple Virtualization Framework (AVF)&lt;/strong&gt; to create the Linux VM. AVF exposes a specialized directory share called &lt;a href=&quot;https://developer.apple.com/documentation/virtualization/vzlinuxrosettadirectoryshare&quot;&gt;&lt;code&gt;VZLinuxRosettaDirectoryShare&lt;/code&gt;&lt;/a&gt;. This is not a standard network share; it is a high-performance channel handled via &lt;a href=&quot;https://virtio-fs.gitlab.io/&quot;&gt;&lt;strong&gt;VirtioFS&lt;/strong&gt;&lt;/a&gt; (Virtual I/O File System).&lt;/p&gt;
&lt;p&gt;When the VM boots, it detects this share and mounts it (usually to &lt;code&gt;/run/rosetta&lt;/code&gt;). This makes the macOS &lt;code&gt;rosetta&lt;/code&gt; binary visible and executable inside the Linux VM.&lt;/p&gt;
&lt;h4&gt;3) The Registration Command&lt;/h4&gt;
&lt;p&gt;Regardless of whether we use QEMU or Rosetta, the &lt;em&gt;Linux Kernel&lt;/em&gt; mechanism is identical. It uses &lt;strong&gt;&lt;code&gt;binfmt_misc&lt;/code&gt;&lt;/strong&gt; (Binary Formats Miscellaneous).&lt;/p&gt;
&lt;p&gt;When Docker Desktop starts (before our container is even created), its internal boot process sends a registration command to the kernel. Something equivalent of:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo &apos;:rosetta:M::\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00:\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/run/rosetta/rosetta:POCF&apos; &amp;gt; /proc/sys/fs/binfmt_misc/register
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(modeled on the registration example in &lt;a href=&quot;https://developer.apple.com/documentation/virtualization/running-intel-binaries-in-linux-vms-with-rosetta&quot;&gt;Apple&apos;s documentation&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;The kernel matches the file header against these bytes. The crucial part that identifies x86-64 is at &lt;strong&gt;Offset 18&lt;/strong&gt;, which is &lt;code&gt;0x3e&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To verify this ourselves on an M1 with Docker Desktop (after making sure Rosetta is enabled in Settings):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Start a privileged container
❯ docker run --rm -it --privileged ubuntu:22.04 bash

# Mount the binfmt filesystem
root@container:/# mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc

# Inspect the configuration
root@container:/# cat /proc/sys/fs/binfmt_misc/rosetta

interpreter /run/rosetta/rosetta
flags: POCF
magic 7f454c4602010100000000000000000002003e00
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The POCF flags are documented in the &lt;a href=&quot;https://docs.kernel.org/admin-guide/binfmt-misc.html&quot;&gt;kernel binfmt_misc docs&lt;/a&gt;: &lt;strong&gt;P&lt;/strong&gt; (preserve argv[0]), &lt;strong&gt;O&lt;/strong&gt; (open binary, pass an open fd to the interpreter), &lt;strong&gt;C&lt;/strong&gt; (credentials, use the binary&apos;s credentials, not the interpreter&apos;s), and &lt;strong&gt;F&lt;/strong&gt; (fix binary, keep the interpreter loaded so it works even inside mount namespaces/containers).&lt;/p&gt;
&lt;h3&gt;Appendix D: Segments Deep Dive&lt;/h3&gt;
&lt;p&gt;The full program-header dump behind Part II, plus the process&apos;s actual memory map.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;readelf -lW ./dynamic_app (full output)&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# readelf -lW ./dynamic_app

Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1080
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R   0x8
  INTERP         0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R   0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x000670 0x000670 R   0x1000
  LOAD           0x001000 0x0000000000001000 0x0000000000001000 0x0001a5 0x0001a5 R E 0x1000
  LOAD           0x002000 0x0000000000002000 0x0000000000002000 0x0000e4 0x0000e4 R   0x1000
  LOAD           0x002d90 0x0000000000003d90 0x0000000000003d90 0x000280 0x000288 RW  0x1000
  DYNAMIC        0x002da0 0x0000000000003da0 0x0000000000003da0 0x000210 0x000210 RW  0x8
  NOTE           0x000338 0x0000000000000338 0x0000000000000338 0x000030 0x000030 R   0x8
  NOTE           0x000368 0x0000000000000368 0x0000000000000368 0x000044 0x000044 R   0x4
  GNU_PROPERTY   0x000338 0x0000000000000338 0x0000000000000338 0x000030 0x000030 R   0x8
  GNU_EH_FRAME   0x002004 0x0000000000002004 0x0000000000002004 0x000034 0x000034 R   0x4
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0x10
  GNU_RELRO      0x002d90 0x0000000000003d90 0x0000000000003d90 0x000270 0x000270 R   0x1

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 
   03     .init .plt .plt.got .plt.sec .text .fini 
   04     .rodata .eh_frame_hdr .eh_frame 
   05     .init_array .fini_array .dynamic .got .data .bss 
   06     .dynamic 
   07     .note.gnu.property 
   08     .note.gnu.build-id .note.ABI-tag 
   09     .note.gnu.property 
   10     .eh_frame_hdr 
   11     
   12     .init_array .fini_array .dynamic .got
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;This output confirms that modern binaries are far more complex than the simple &amp;quot;Code vs. Data&amp;quot; model. The linker has split our binary into &lt;strong&gt;4 distinct memory regions (LOAD segments)&lt;/strong&gt; to maximize security and efficiency.&lt;/p&gt;
&lt;h4&gt;Explanation of the Output&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;1. The Header: &lt;code&gt;DYN (Position-Independent Executable)&lt;/code&gt;&lt;/strong&gt;
This confirms our binary is a &lt;strong&gt;PIE&lt;/strong&gt;. It has no fixed address. The Kernel will choose a random base address (ASLR) at runtime, and all &lt;code&gt;VirtAddr&lt;/code&gt; values below (like &lt;code&gt;0x1000&lt;/code&gt;) are just offsets relative to that random base.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. The &lt;code&gt;INTERP&lt;/code&gt; Header&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;INTERP ... Requesting program interpreter: /lib64/ld-linux-x86-64.so.2

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the first thing the kernel looks for. If found, the kernel maps this interpreter into memory and passes control to it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. The &lt;code&gt;LOAD&lt;/code&gt; Segments (The Real Memory Map)&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;These 4 segments tell the Kernel exactly how to set up the Virtual Memory Areas (VMAs).&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Segment&lt;/th&gt;
&lt;th&gt;Flags&lt;/th&gt;
&lt;th&gt;Offset&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LOAD #1&lt;/strong&gt; (Metadata)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;R&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ELF Header, Program Headers, dynamic linking metadata (&lt;code&gt;.hash&lt;/code&gt;, &lt;code&gt;.dynsym&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Needed by the Loader, but should never be executed (security) or written to (integrity).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LOAD #2&lt;/strong&gt; (Code)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;R E&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x1000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.text&lt;/code&gt; (your code), &lt;code&gt;.init&lt;/code&gt;, &lt;code&gt;.plt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The &lt;strong&gt;only&lt;/strong&gt; region where the CPU can fetch instructions. Executing code anywhere else triggers an NX fault.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LOAD #3&lt;/strong&gt; (Constants)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;R&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x2000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.rodata&lt;/code&gt; (string literals, constants), &lt;code&gt;.eh_frame&lt;/code&gt; (unwind info)&lt;/td&gt;
&lt;td&gt;Separated from executable code to prevent ROP gadgets from using data bytes as instructions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LOAD #4&lt;/strong&gt; (Data)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x3d90&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.data&lt;/code&gt; (globals), &lt;code&gt;.bss&lt;/code&gt;, &lt;strong&gt;GOT&lt;/strong&gt; (Global Offset Table)&lt;/td&gt;
&lt;td&gt;The only writable memory. Backed by the file on disk until written, then Copy-on-Write kicks in.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;4. The &lt;code&gt;GNU_RELRO&lt;/code&gt; Segment (Security)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;GNU_RELRO      0x...2d90 ... Flags R

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a security overlay. Notice that its address (&lt;code&gt;0x2d90&lt;/code&gt;) overlaps with the start of the &lt;strong&gt;LOAD #4 (RW)&lt;/strong&gt; segment. See the &lt;a href=&quot;#relro-relocation-read-only-partial-vs-full&quot;&gt;RELRO section in Appendix F&lt;/a&gt; for more details.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. &lt;code&gt;GNU_STACK&lt;/code&gt; (NX Bit)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;GNU_STACK ... Flags RW

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The absence of the &lt;code&gt;E&lt;/code&gt; flag here is critical. It tells the Kernel: &amp;quot;The stack is for data, not code.&amp;quot; This prevents code-injection attacks on the stack.&lt;/p&gt;
&lt;p&gt;Once the app starts running (that &lt;code&gt;sleep(60)&lt;/code&gt; in &lt;code&gt;main.c&lt;/code&gt; exists precisely so the process sticks around), we can read where everything actually landed. One honesty note: this capture comes from the emulated (Rosetta) container, where the kernel handed us &lt;code&gt;0x555555554000&lt;/code&gt; (the canonical &lt;em&gt;no-randomization&lt;/em&gt; PIE base) on every run. On native x86-64 Linux you&apos;ll see a different &lt;code&gt;0x55...&lt;/code&gt; bias per run; that per-run difference is ASLR, and &lt;code&gt;load_bias&lt;/code&gt; from Section 2.2 is whatever the kernel picked. The &lt;em&gt;structure&lt;/em&gt; below is identical either way: each LOAD segment became a VMA at &lt;code&gt;load_bias + p_vaddr&lt;/code&gt;.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;/proc/$pid/maps (rows for our binary, libmath, libc, ld-linux, stack)&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# ./dynamic_app &amp;amp; pid=$!
root@container:/code# grep -E &apos;dynamic_app|libmath|libc|ld-linux|stack&apos; /proc/$pid/maps
555555554000-555555555000 r--p 00000000 00:2d 86                         /code/dynamic_app
555555555000-555555556000 r-xp 00001000 00:2d 86                         /code/dynamic_app
555555556000-555555557000 r--p 00002000 00:2d 86                         /code/dynamic_app
555555557000-555555558000 r--p 00002000 00:2d 86                         /code/dynamic_app
555555558000-555555559000 rw-p 00003000 00:2d 86                         /code/dynamic_app
7fffff590000-7fffff5b8000 r--p 00000000 00:50 5451                       /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff5b8000-7fffff74d000 r-xp 00028000 00:50 5451                       /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff74d000-7fffff7a5000 r--p 001bd000 00:50 5451                       /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff7a5000-7fffff7a6000 ---p 00215000 00:50 5451                       /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff7a6000-7fffff7aa000 r--p 00215000 00:50 5451                       /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff7aa000-7fffff7ac000 rw-p 00219000 00:50 5451                       /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff7bc000-7fffff7bd000 r--p 00000000 00:2d 85                         /code/libmath.so
7fffff7bd000-7fffff7be000 r-xp 00001000 00:2d 85                         /code/libmath.so
7fffff7be000-7fffff7bf000 r--p 00002000 00:2d 85                         /code/libmath.so
7fffff7bf000-7fffff7c0000 r--p 00002000 00:2d 85                         /code/libmath.so
7fffff7c0000-7fffff7c1000 rw-p 00003000 00:2d 85                         /code/libmath.so
7ffffffc4000-7ffffffc6000 r--p 00000000 00:50 5433                       /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffffffc6000-7fffffff0000 r-xp 00002000 00:50 5433                       /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fffffff0000-7fffffffb000 r--p 0002c000 00:50 5433                       /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fffffffc000-7fffffffe000 r--p 00037000 00:50 5433                       /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fffffffe000-800000000000 rw-p 00039000 00:50 5433                       /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
ffffedd69000-ffffedd8a000 rw-p 00000000 00:00 0                          [stack]
&lt;/code&gt;&lt;/pre&gt;
&lt;figure class=&quot;frame diagram&quot;&gt;
  &lt;span class=&quot;frame-title&quot;&gt;fig. 4 · the finished address space (from the maps dump above)&lt;/span&gt;
  &lt;div class=&quot;diagram-body&quot;&gt;
    &lt;svg viewBox=&quot;0 0 640 360&quot; role=&quot;img&quot; aria-label=&quot;Vertical virtual-address map: the app&apos;s LOAD VMAs at the load bias, then libmath, libc, and ld-linux mapped higher, with the kernel-managed stack at the top&quot;&gt;
      &lt;g font-family=&quot;var(--font-mono)&quot; font-size=&quot;11&quot;&gt;
        &lt;text x=&quot;20&quot; y=&quot;30&quot; fill=&quot;var(--muted)&quot;&gt;low VA&lt;/text&gt;
        &lt;text x=&quot;20&quot; y=&quot;348&quot; fill=&quot;var(--muted)&quot;&gt;high VA&lt;/text&gt;
        &lt;line x1=&quot;150&quot; y1=&quot;16&quot; x2=&quot;150&quot; y2=&quot;352&quot; stroke=&quot;var(--border)&quot;/&gt;
        &lt;!-- app --&gt;
        &lt;rect x=&quot;170&quot; y=&quot;30&quot; width=&quot;300&quot; height=&quot;66&quot; fill=&quot;var(--sec)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;170&quot; y=&quot;30&quot; width=&quot;300&quot; height=&quot;66&quot; fill=&quot;none&quot; stroke=&quot;var(--sec)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;320&quot; y=&quot;52&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--sec)&quot;&gt;dynamic_app — 5 VMAs, one per LOAD&lt;/text&gt;
        &lt;text x=&quot;320&quot; y=&quot;68&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;10&quot;&gt;r-- · r-x · r-- · r-- · rw-  (RELRO turned the GOT page r--)&lt;/text&gt;
        &lt;text x=&quot;142&quot; y=&quot;40&quot; text-anchor=&quot;end&quot; fill=&quot;var(--muted)&quot; font-size=&quot;10&quot;&gt;0x555555554000&lt;/text&gt;
        &lt;text x=&quot;142&quot; y=&quot;54&quot; text-anchor=&quot;end&quot; fill=&quot;var(--sec)&quot; font-size=&quot;10&quot;&gt;= load_bias&lt;/text&gt;
        &lt;!-- gap --&gt;
        &lt;text x=&quot;320&quot; y=&quot;122&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot;&gt;… unmapped gap: touch it and you get SIGSEGV …&lt;/text&gt;
        &lt;!-- libmath --&gt;
        &lt;rect x=&quot;170&quot; y=&quot;140&quot; width=&quot;300&quot; height=&quot;44&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;170&quot; y=&quot;140&quot; width=&quot;300&quot; height=&quot;44&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;320&quot; y=&quot;160&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--seg)&quot;&gt;libmath.so — 5 VMAs&lt;/text&gt;
        &lt;text x=&quot;320&quot; y=&quot;176&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;10&quot;&gt;mapped by ld.so during dependency discovery&lt;/text&gt;
        &lt;text x=&quot;142&quot; y=&quot;152&quot; text-anchor=&quot;end&quot; fill=&quot;var(--muted)&quot; font-size=&quot;10&quot;&gt;0x7fffff7bc000&lt;/text&gt;
        &lt;!-- libc --&gt;
        &lt;rect x=&quot;170&quot; y=&quot;196&quot; width=&quot;300&quot; height=&quot;44&quot; fill=&quot;var(--seg)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;170&quot; y=&quot;196&quot; width=&quot;300&quot; height=&quot;44&quot; fill=&quot;none&quot; stroke=&quot;var(--seg)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;320&quot; y=&quot;216&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--seg)&quot;&gt;libc.so.6&lt;/text&gt;
        &lt;text x=&quot;320&quot; y=&quot;232&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;10&quot;&gt;every dynamic process maps one&lt;/text&gt;
        &lt;!-- ld --&gt;
        &lt;rect x=&quot;170&quot; y=&quot;252&quot; width=&quot;300&quot; height=&quot;44&quot; fill=&quot;var(--ldr)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;170&quot; y=&quot;252&quot; width=&quot;300&quot; height=&quot;44&quot; fill=&quot;none&quot; stroke=&quot;var(--ldr)&quot; stroke-width=&quot;1.5&quot;/&gt;
        &lt;text x=&quot;320&quot; y=&quot;272&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--ldr)&quot;&gt;ld-linux-x86-64.so.2&lt;/text&gt;
        &lt;text x=&quot;320&quot; y=&quot;288&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--muted)&quot; font-size=&quot;10&quot;&gt;the kernel mapped this one (PT_INTERP) — it mapped everything above&lt;/text&gt;
        &lt;text x=&quot;142&quot; y=&quot;264&quot; text-anchor=&quot;end&quot; fill=&quot;var(--muted)&quot; font-size=&quot;10&quot;&gt;0x7ffffffc4000&lt;/text&gt;
        &lt;!-- stack --&gt;
        &lt;rect x=&quot;170&quot; y=&quot;308&quot; width=&quot;300&quot; height=&quot;34&quot; fill=&quot;var(--krn)&quot; opacity=&quot;0.14&quot;/&gt;
        &lt;rect x=&quot;170&quot; y=&quot;308&quot; width=&quot;300&quot; height=&quot;34&quot; fill=&quot;none&quot; stroke=&quot;var(--krn)&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;5 3&quot;/&gt;
        &lt;text x=&quot;320&quot; y=&quot;329&quot; text-anchor=&quot;middle&quot; fill=&quot;var(--krn)&quot;&gt;[stack] — kernel-managed, grows down&lt;/text&gt;
      &lt;/g&gt;
    &lt;/svg&gt;
    &lt;p class=&quot;legend&quot;&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--sec)&quot;&gt;&lt;/span&gt;our app&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--seg)&quot;&gt;&lt;/span&gt;mapped libraries&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--ldr)&quot;&gt;&lt;/span&gt;loader&lt;/span&gt;
      &lt;span&gt;&lt;span class=&quot;k&quot; style=&quot;background:var(--krn)&quot;&gt;&lt;/span&gt;kernel&lt;/span&gt;
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/figure&gt;
&lt;/details&gt;
&lt;h3&gt;Appendix E: The Loader&apos;s Bootstrap (Self-Relocation)&lt;/h3&gt;
&lt;p&gt;In Section 3, we mentioned the loader must &amp;quot;fix itself.&amp;quot; Here are the details.&lt;/p&gt;
&lt;h4&gt;The &amp;quot;Chicken and Egg&amp;quot; Problem&lt;/h4&gt;
&lt;p&gt;Normal programs rely on the loader to fix their addresses before they run. But &lt;code&gt;ld-linux.so&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the loader. Who loads the loader? No one.&lt;/p&gt;
&lt;p&gt;When the kernel maps the loader, it just maps segments.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ASLR:&lt;/strong&gt; loader is at a random address (e.g., &lt;code&gt;0x7f34...&lt;/code&gt;) instead of its link-time base.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Broken GOT:&lt;/strong&gt; internal pointers may assume link-time addresses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No libc:&lt;/strong&gt; it can&apos;t call most libc routines yet.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;The Solution: &lt;code&gt;_dl_start&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;The entry point passes control to &lt;code&gt;_dl_start&lt;/code&gt; in &lt;code&gt;elf/rtld.c&lt;/code&gt;. This function is written with extreme care to avoid accesses that rely on unrelocated global state.&lt;/p&gt;
&lt;p&gt;A simplified sketch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;/* elf/rtld.c */
// https://elixir.bootlin.com/glibc/glibc-2.39/source/elf/rtld.c#L517
static ElfW(Addr) __attribute_used__
_dl_start (void *arg)
{
    /* 1. Calculate the load bias */
    ElfW(Addr) l_addr = elf_machine_load_address ();

    /* 2. Apply bootstrap relocations (self-patch) */
    elf_machine_rela (l_addr, ...);   /* x86-64 is a RELA architecture */

    /* 3. Now the loader can safely run complex code */
    return _dl_start_final (arg, ...);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Step 1 finds the bias (often via RIP-relative tricks). Step 2 applies &lt;code&gt;R_X86_64_RELATIVE&lt;/code&gt;-style relocations to itself. Once that&apos;s done, it becomes a &amp;quot;real program&amp;quot; and can load your app.&lt;/p&gt;
&lt;h3&gt;Appendix F: Loader&apos;s Relocation Mechanism&lt;/h3&gt;
&lt;h4&gt;1) High-Level Sequence (What We&apos;re About to Zoom Into)&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;The loader starts running, but it itself is at a different location due to ASLR than what the linker had in mind.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Loader does the self-relocation as explained in &lt;a href=&quot;#appendix-e-the-loaders-bootstrap-self-relocation&quot;&gt;Appendix E&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Now, it looks at the &lt;code&gt;PT_DYNAMIC&lt;/code&gt; segment of the binary (note that these sections were already mapped as part of &lt;code&gt;PT_LOAD&lt;/code&gt; &lt;code&gt;mmap()&lt;/code&gt;ing by the kernel).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;PT_DYNAMIC&lt;/code&gt; sort of creates a map of different entries (dynamic tags) that point the loader at the relevant tables/relocation lists:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;map[DT_STRTAB]&lt;/code&gt; -&amp;gt; address of &lt;code&gt;.dynstr&lt;/code&gt; (string table)
&lt;code&gt;map[DT_SYMTAB]&lt;/code&gt; -&amp;gt; address of &lt;code&gt;.dynsym&lt;/code&gt; (dynamic symbol table, not .symtab)
&lt;code&gt;map[DT_NEEDED]&lt;/code&gt; -&amp;gt; list of libraries to load (actually, its an array of offsets into &lt;code&gt;.dynstr&lt;/code&gt;, which contains these lib names)
&lt;code&gt;map[DT_RUNPATH]&lt;/code&gt; -&amp;gt; provided runpath (again, as an offset into &lt;code&gt;.dynstr&lt;/code&gt;)
&lt;code&gt;map[RELA]&lt;/code&gt; -&amp;gt; address of &lt;code&gt;.rela.dyn&lt;/code&gt; section, this has all the non-PLT relocations
&lt;code&gt;map[JMPREL]&lt;/code&gt; -&amp;gt; address of &lt;code&gt;.rela.plt&lt;/code&gt; section, this has all the PLT relocations
and a few more..&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;details&gt;
&lt;summary&gt;readelf -p .dynstr and readelf -d (string table + dynamic section)&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# see how the lib names (libmath.so, libc.so.6) and $ORIGIN live here
root@container:/code# readelf -p .dynstr ./dynamic_app
String dump of section &apos;.dynstr&apos;:
  [     1]  __cxa_finalize
  [    10]  _ITM_registerTMCloneTable
  [    2a]  _ITM_deregisterTMCloneTable
  [    46]  __gmon_start__
  [    55]  add
  [    59]  __libc_start_main
  [    6b]  sleep
  [    71]  libmath.so
  [    7c]  libc.so.6
  [    86]  GLIBC_2.2.5
  [    92]  GLIBC_2.34
  [    9d]  $ORIGIN

# inspect dynamic section
root@container:/code# readelf -d ./dynamic_app

Dynamic section at offset 0x2da0 contains 29 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libmath.so]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 0x000000000000001d (RUNPATH)            Library runpath: [$ORIGIN]
 0x000000000000000c (INIT)               0x1000
 0x000000000000000d (FINI)               0x1198
 0x0000000000000019 (INIT_ARRAY)         0x3d90
 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 0x000000000000001a (FINI_ARRAY)         0x3d98
 0x000000000000001c (FINI_ARRAYSZ)       8 (bytes)
 0x000000006ffffef5 (GNU_HASH)           0x3b0
 0x0000000000000005 (STRTAB)             0x498
 0x0000000000000006 (SYMTAB)             0x3d8
 0x000000000000000a (STRSZ)              165 (bytes)
 0x000000000000000b (SYMENT)             24 (bytes)
 0x0000000000000015 (DEBUG)              0x0
 0x0000000000000003 (PLTGOT)             0x3fb0
 0x0000000000000002 (PLTRELSZ)           48 (bytes)
 0x0000000000000014 (PLTREL)             RELA
 0x0000000000000017 (JMPREL)             0x640
 0x0000000000000007 (RELA)               0x580
 0x0000000000000008 (RELASZ)             192 (bytes)
 0x0000000000000009 (RELAENT)            24 (bytes)
 0x000000000000001e (FLAGS)              BIND_NOW
 0x000000006ffffffb (FLAGS_1)            Flags: NOW PIE
 0x000000006ffffffe (VERNEED)            0x550
 0x000000006fffffff (VERNEEDNUM)         1
 0x000000006ffffff0 (VERSYM)             0x53e
 0x000000006ffffff9 (RELACOUNT)          3
 0x0000000000000000 (NULL)               0x0
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
5. Iterates through `DT_NEEDED` entries. In our case: `libmath.so` and `libc.so.6`, as we can see in the output. (Note the `FLAGS: BIND_NOW` in this default build. The loader will resolve everything up front, per Part III.)
&lt;ol start=&quot;6&quot;&gt;
&lt;li&gt;
&lt;p&gt;For &lt;code&gt;libmath.so&lt;/code&gt;, the loader runs the search order from Part III: here, &lt;code&gt;RUNPATH&lt;/code&gt;&apos;s &lt;code&gt;$ORIGIN&lt;/code&gt; expands to the binary&apos;s directory and wins. It &lt;code&gt;mmap&lt;/code&gt;s the library into the process, performing &lt;code&gt;libmath&lt;/code&gt;&apos;s own relocations along the way, and does the same for &lt;code&gt;libc.so.6&lt;/code&gt;. (Had the stored name contained a &lt;code&gt;/&lt;/code&gt;, like Part VII&apos;s broken build, it would have been used as a literal path with no search at all.)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Then it will move to doing relocations for your executable. First it will look at &lt;code&gt;map[RELA]&lt;/code&gt; (&lt;code&gt;.rela.dyn&lt;/code&gt;) section.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h4&gt;2) Relocations for the Main Executable&lt;/h4&gt;
&lt;p&gt;First, let&apos;s see how the relocations information looks in our ELF binary.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;readelf -rW ./dynamic_app (relocation tables)&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# readelf -rW ./dynamic_app

Relocation section &apos;.rela.dyn&apos; at offset 0x580 contains 8 entries:

    Offset             Info             Type               Symbol&apos;s Value  Symbol&apos;s Name + Addend
0000000000003d90  0000000000000008 R_X86_64_RELATIVE                         1160
0000000000003d98  0000000000000008 R_X86_64_RELATIVE                         1120
0000000000004008  0000000000000008 R_X86_64_RELATIVE                         4008
0000000000003fd8  0000000100000006 R_X86_64_GLOB_DAT      0000000000000000 __libc_start_main@GLIBC_2.34 + 0
0000000000003fe0  0000000200000006 R_X86_64_GLOB_DAT      0000000000000000 _ITM_deregisterTMCloneTable + 0
0000000000003fe8  0000000400000006 R_X86_64_GLOB_DAT      0000000000000000 __gmon_start__ + 0
0000000000003ff0  0000000500000006 R_X86_64_GLOB_DAT      0000000000000000 _ITM_registerTMCloneTable + 0
0000000000003ff8  0000000700000006 R_X86_64_GLOB_DAT      0000000000000000 __cxa_finalize@GLIBC_2.2.5 + 0

Relocation section &apos;.rela.plt&apos; at offset 0x640 contains 2 entries:

    Offset             Info             Type               Symbol&apos;s Value  Symbol&apos;s Name + Addend
0000000000003fc8  0000000300000007 R_X86_64_JUMP_SLOT     0000000000000000 add + 0
0000000000003fd0  0000000600000007 R_X86_64_JUMP_SLOT     0000000000000000 sleep@GLIBC_2.2.5 + 0
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;It will go through each of the non-PLT relocations (i.e. in .rela.dyn) first.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Each entry is decoded into &lt;code&gt;r_info&lt;/code&gt; first: &lt;code&gt;r_info = index of this symbol into .dynsym (high 32 bits) || relocation type (low 32 bits)&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The relocation type decides how to apply the relocation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;R_X86_64_RELATIVE&lt;/code&gt; (0x8) are relatively straightforward. It simply says that at this &lt;code&gt;Offset + load_bias&lt;/code&gt; (remember load_bias from 2.2 Finding the Correct Address for the Segments?), put this value: &lt;code&gt;addend + load_bias&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;For &lt;code&gt;R_X86_64_GLOB_DAT&lt;/code&gt; (0x6), it will look at the symbol in &lt;code&gt;.dynsym&lt;/code&gt;. &lt;code&gt;.dynsym&lt;/code&gt; usually contains symbol information like name (sym_name), type, visibility, value (sym_value) etc. For the name it points to an offset in &lt;code&gt;.dynstr&lt;/code&gt;. Let&apos;s see it in action.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;For &lt;code&gt;__libc_start_main&lt;/code&gt;, it will look at the &lt;code&gt;.dynsym[1]&lt;/code&gt; entry. 1 because, &lt;code&gt;r_info = 0000000100000006&lt;/code&gt; (first 32 bits is the index as mentioned before).&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As we can see, index 1 has __libc_start_main.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;readelf -sW --dyn-syms ./dynamic_app (dynamic symbol table)&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;root@container:/code# readelf -sW --dyn-syms ./dynamic_app

Symbol table &apos;.dynsym&apos; contains 8 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.34 (2)
     2: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterTMCloneTable
     3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND add
     4: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
     5: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
     6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND sleep@GLIBC_2.2.5 (3)
     7: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@GLIBC_2.2.5 (3)
     ...
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Loader will see that &lt;code&gt;__libc_start_main&lt;/code&gt; is not defined (UND, usually indicated by sym_value being 0). It will try to find it. It will check where is this defined. Loader will get the symbol name (fetches it via &lt;code&gt;.dynstr[dynsym[1].symbol_name]&lt;/code&gt;).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Each DSO&apos;s ELF would have maintained some sort of metadata (hash table) which loader will use to see which DSO defines this symbol. We have not dug deep into this part yet (we know .gnu.hash section does some fancy bloom filter things, but let&apos;s keep it for later).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Loader will find that &lt;code&gt;libc.so&lt;/code&gt; defines &lt;code&gt;__libc_start_main&lt;/code&gt;. It will find its value &lt;code&gt;sym_value&lt;/code&gt; from &lt;code&gt;.dynsym&lt;/code&gt; of &lt;code&gt;libc.so&lt;/code&gt;, will add &lt;code&gt;base_address_of_libc + sym_value&lt;/code&gt; and return that value. Note that, &lt;code&gt;.dynsym&lt;/code&gt; contains a subset of symbols for that binary/DSO and not all symbols. It mainly contains the symbols used for import/export. The local symbols are not present/needed in &lt;code&gt;.dynsym&lt;/code&gt; as they can be already resolved during build.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Then it will patch this value (i.e. __libc_start_main&apos;s absolute address) at &lt;code&gt;(load_bias + 0000000000003fd8)&lt;/code&gt; address, which would be a GOT entry for this symbol.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Same thing for all the other &lt;code&gt;R_X86_64_GLOB_DAT&lt;/code&gt; entries.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;At runtime, the instruction will look like:&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;0000000000001080 &amp;lt;_start&amp;gt;:
    1080:   f3 0f 1e fa             endbr64
    1084:   31 ed                   xor    %ebp,%ebp
    1086:   49 89 d1                mov    %rdx,%r9
    ...
    1098:   48 8d 3d d9 00 00 00    lea    0xd9(%rip),%rdi        # 1178 &amp;lt;main&amp;gt;
    109f:   ff 15 33 2f 00 00       call   *0x2f33(%rip)        # 3fd8 &amp;lt;__libc_start_main@GLIBC_2.34&amp;gt;
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;this would automatically make a call to the address stored at relative &lt;code&gt;0x3fd8&lt;/code&gt; address, which would now have absolute address of &lt;code&gt;__libc_start_main&lt;/code&gt; in the process image.&lt;/li&gt;
&lt;/ul&gt;
&lt;ul&gt;
&lt;li&gt;Loader will go through PLT relocations next. &lt;code&gt;map[JMPREL]&lt;/code&gt; points to &lt;code&gt;.rela.plt&lt;/code&gt; table. Same way, we will go through these entries. For eager binding (when LD_BIND_NOW=1 is set) we will do the relocations at the startup time; for lazy binding (default) this sequence will take place at runtime when the first call is made. Regardless, the same sequence of events take place. &lt;code&gt;R_X86_64_JUMP_SLOT&lt;/code&gt; type of entry indicates PLT entry.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4&gt;3) The Lazy-Binding Path: the Full Evidence&lt;/h4&gt;
&lt;p&gt;Part III (§3.4) watches lazy binding happen live with &lt;code&gt;got_watch&lt;/code&gt;. Here is the complete static evidence from the &lt;code&gt;-Wl,-z,lazy&lt;/code&gt; build, so every number in that walkthrough can be checked against the file on disk.&lt;/p&gt;
&lt;p&gt;The PLT relocations the resolver will service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Relocation section &apos;.rela.plt&apos; at offset 0x640 contains 2 entries:
    Offset             Info             Type               Symbol&apos;s Value  Symbol&apos;s Name + Addend
0000000000004018  0000000300000007 R_X86_64_JUMP_SLOT     0000000000000000 add + 0
0000000000004020  0000000600000007 R_X86_64_JUMP_SLOT     0000000000000000 sleep@GLIBC_2.2.5 + 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The PLT machinery itself (note the modern &lt;code&gt;endbr64&lt;/code&gt;/&lt;code&gt;bnd&lt;/code&gt;, which is CET/IBT hardening, and the &lt;code&gt;.plt.sec&lt;/code&gt; split):&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;objdump -d dynamic_app_lazy, .plt and .plt.sec sections&lt;/summary&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;./dynamic_app_lazy:     file format elf64-x86-64


Disassembly of section .plt:

0000000000001020 &amp;lt;.plt&amp;gt;:
    1020:	ff 35 e2 2f 00 00    	push   0x2fe2(%rip)        # 4008 &amp;lt;_GLOBAL_OFFSET_TABLE_+0x8&amp;gt;
    1026:	f2 ff 25 e3 2f 00 00 	bnd jmp *0x2fe3(%rip)        # 4010 &amp;lt;_GLOBAL_OFFSET_TABLE_+0x10&amp;gt;
    102d:	0f 1f 00             	nopl   (%rax)
    1030:	f3 0f 1e fa          	endbr64 
    1034:	68 00 00 00 00       	push   $0x0
    1039:	f2 e9 e1 ff ff ff    	bnd jmp 1020 &amp;lt;_init+0x20&amp;gt;
    103f:	90                   	nop
    1040:	f3 0f 1e fa          	endbr64 
    1044:	68 01 00 00 00       	push   $0x1
    1049:	f2 e9 d1 ff ff ff    	bnd jmp 1020 &amp;lt;_init+0x20&amp;gt;
    104f:	90                   	nop

Disassembly of section .plt.sec:

0000000000001060 &amp;lt;add@plt&amp;gt;:
    1060:	f3 0f 1e fa          	endbr64 
    1064:	f2 ff 25 ad 2f 00 00 	bnd jmp *0x2fad(%rip)        # 4018 &amp;lt;add@Base&amp;gt;
    106b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

0000000000001070 &amp;lt;sleep@plt&amp;gt;:
    1070:	f3 0f 1e fa          	endbr64 
    1074:	f2 ff 25 a5 2f 00 00 	bnd jmp *0x2fa5(%rip)        # 4020 &amp;lt;sleep@GLIBC_2.2.5&amp;gt;
    107b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)
&lt;/code&gt;&lt;/pre&gt;
&lt;/details&gt;
&lt;p&gt;And the initial contents of &lt;code&gt;.got.plt&lt;/code&gt; in the file, before the loader has touched anything. The slot for &lt;code&gt;add&lt;/code&gt; (vaddr &lt;code&gt;0x4018&lt;/code&gt;) holds &lt;code&gt;0x1030&lt;/code&gt;, the address of its own PLT push-stub:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Hex dump of section &apos;.got.plt&apos;:
 NOTE: This section has relocations against it, but these have NOT been applied to this dump.
  0x00004000 d83d0000 00000000 00000000 00000000 .=..............
  0x00004010 00000000 00000000 30100000 00000000 ........0.......
  0x00004020 40100000 00000000                   @.......
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the chain at first call is exactly: &lt;code&gt;call add@plt&lt;/code&gt; → &lt;code&gt;jmp *slot(0x4018)&lt;/code&gt; → lands back at &lt;code&gt;0x1030&lt;/code&gt; → &lt;code&gt;push $0x0&lt;/code&gt; (the relocation index) → common stub at &lt;code&gt;0x1020&lt;/code&gt; → &lt;code&gt;_dl_runtime_resolve&lt;/code&gt;, which finds &lt;code&gt;add&lt;/code&gt; in &lt;code&gt;libmath.so&lt;/code&gt;, patches slot &lt;code&gt;0x4018&lt;/code&gt;, and jumps there. Every later call short-circuits: &lt;code&gt;call add@plt&lt;/code&gt; → &lt;code&gt;jmp *slot&lt;/code&gt; → &lt;code&gt;add&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;(If you want to poke this in gdb yourself, run it on native x86-64 Linux: under Rosetta emulation &lt;code&gt;ptrace&lt;/code&gt; is unavailable, which is why the in-process &lt;code&gt;got_watch&lt;/code&gt; approach exists.)&lt;/p&gt;
&lt;h4&gt;RELRO (RELocation Read-Only): partial vs full&lt;/h4&gt;
&lt;p&gt;At the end of relocation processing the loader &lt;code&gt;mprotect&lt;/code&gt;s the &lt;code&gt;GNU_RELRO&lt;/code&gt; region read-only. How much protection that buys depends entirely on the binding mode. Compare our two builds:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;# default build (-z now, eager):
GNU_RELRO      0x002d90 0x0000000000003d90 0x0000000000003d90 0x000270 0x000270 R   0x1
# lazy build (-Wl,-z,lazy):
GNU_RELRO      0x002dc8 0x0000000000003dc8 0x0000000000003dc8 0x000238 0x000238 R   0x1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both regions end at &lt;code&gt;0x4000&lt;/code&gt;. In the &lt;strong&gt;eager&lt;/strong&gt; build the &lt;code&gt;R_X86_64_JUMP_SLOT&lt;/code&gt; entries live at &lt;code&gt;0x3fc8&lt;/code&gt;/&lt;code&gt;0x3fd0&lt;/code&gt;, &lt;em&gt;inside&lt;/em&gt; the region. Everything is resolved before &lt;code&gt;main()&lt;/code&gt; runs, so the loader can seal the entire GOT: &lt;strong&gt;full RELRO&lt;/strong&gt;. In the &lt;strong&gt;lazy&lt;/strong&gt; build the jump slots live at &lt;code&gt;0x4018&lt;/code&gt;/&lt;code&gt;0x4020&lt;/code&gt;, &lt;em&gt;past the end&lt;/em&gt;. They must stay writable so the resolver can patch them at first call: &lt;strong&gt;partial RELRO&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The security consequence, stated precisely: under full RELRO, a memory-corruption bug cannot overwrite GOT entries to hijack library calls, because those pages are read-only by the time user code runs. Under partial RELRO, the &lt;em&gt;data&lt;/em&gt; GOT is sealed but &lt;code&gt;.got.plt&lt;/code&gt; remains writable for the process&apos;s lifetime, and a writable function-pointer table that the program jumps through is the canonical GOT-overwrite target. That trade, startup latency vs a locked GOT, is exactly why hardened distros ship &lt;code&gt;-z now&lt;/code&gt; by default.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Once these relocations are done, we are ready to handoff to &lt;code&gt;_start&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Appendix G: The Assembly Handoff (_start)&lt;/h3&gt;
&lt;p&gt;In Section 4, we glossed over the assembly handoff. Here are the exact mechanics of how the loader passes control to the user.&lt;/p&gt;
&lt;h4&gt;1) The Exit Stub (&lt;code&gt;_dl_start_user&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;The loader is written in C, but the final handoff requires assembly to manipulate registers precisely. This happens in architecture-specific glue (e.g., &lt;code&gt;sysdeps/x86_64/dl-machine.h&lt;/code&gt; in glibc).&lt;/p&gt;
&lt;p&gt;A schematic flow:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;_dl_start_user:
    mov %rsp, %rdi         # Save stack pointer (argc/argv live here)
    call _dl_init          # Run init functions for DSOs
    jmp *%r12              # Jump to user entry point (_start)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;2) The User Entry Point (&lt;code&gt;_start&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;The CPU lands at &lt;code&gt;_start&lt;/code&gt;. This is provided by &lt;code&gt;Scrt1.o&lt;/code&gt; (the PIE variant of &lt;code&gt;crt1.o&lt;/code&gt;). Its primary job is to align the stack (16‑byte alignment required by the x86‑64 ABI) and set up arguments for &lt;code&gt;__libc_start_main&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Conceptually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-asm&quot;&gt;_start:
    xor %ebp, %ebp         # End-of-stack marker for debuggers
    pop %rsi               # argc
    mov %rsp, %rdx         # argv
    and $-16, %rsp         # align stack
    call __libc_start_main
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;See the &lt;a href=&quot;https://elixir.bootlin.com/glibc/glibc-2.42.9000/source/sysdeps/x86_64/start.S#L57&quot;&gt;exact&lt;/a&gt; source code. Then &lt;code&gt;__libc_start_main&lt;/code&gt; runs constructors for this binary (remember that the loader (&lt;code&gt;_dl_init&lt;/code&gt;) already initialized shared libraries. &lt;code&gt;__libc_start_main&lt;/code&gt; only runs constructors for the main executable) and &lt;a href=&quot;https://elixir.bootlin.com/glibc/glibc-2.42.9000/source/sysdeps/nptl/libc_start_call_main.h#L58&quot;&gt;calls our &lt;code&gt;main&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Appendix H: Runtime Loading (dlopen/dlsym)&lt;/h3&gt;
&lt;p&gt;Everything in the main article happens before &lt;code&gt;main()&lt;/code&gt; starts. But many real programs need to load code later: a web server that loads authentication modules on demand, a game engine that loads renderer backends based on the GPU it detects, or a language runtime loading compiled extensions. The mechanism for this is &lt;code&gt;dlopen&lt;/code&gt; and &lt;code&gt;dlsym&lt;/code&gt;.&lt;/p&gt;
&lt;h4&gt;1) Loading a Library After Startup&lt;/h4&gt;
&lt;p&gt;Suppose your program has an optional plugin system. At runtime, you decide to load a plugin:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;void *handle = dlopen(&amp;quot;./libplugin.so&amp;quot;, RTLD_LAZY);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under the hood, this calls back into the same dynamic loader (&lt;code&gt;ld-linux.so&lt;/code&gt;) that set up your process at startup. The loader finds &lt;code&gt;libplugin.so&lt;/code&gt;, maps it into the process&apos;s address space with &lt;code&gt;mmap&lt;/code&gt;, resolves its dependencies (if &lt;code&gt;libplugin.so&lt;/code&gt; itself depends on other libraries), and performs relocations, the same machinery we saw in &lt;a href=&quot;#appendix-f-loaders-relocation-mechanism&quot;&gt;Appendix F&lt;/a&gt;, just happening after &lt;code&gt;main()&lt;/code&gt; instead of before it.&lt;/p&gt;
&lt;h4&gt;2) Initialization: Why &lt;code&gt;dlopen&lt;/code&gt; Can Be Slow (or Crash)&lt;/h4&gt;
&lt;p&gt;Before &lt;code&gt;dlopen&lt;/code&gt; returns, the loader must run the constructors (&lt;code&gt;.init_array&lt;/code&gt;) of &lt;code&gt;libplugin.so&lt;/code&gt; and all of its dependencies. This is the same initialization step the loader performs for startup libraries, but it happens synchronously inside your &lt;code&gt;dlopen&lt;/code&gt; call.&lt;/p&gt;
&lt;p&gt;This has a practical consequence: if &lt;code&gt;libplugin.so&lt;/code&gt; contains a C++ global like &lt;code&gt;MyClass instance;&lt;/code&gt;, that constructor runs inside &lt;code&gt;dlopen&lt;/code&gt;. If it crashes, allocates a lot of memory, or takes a long time, your &lt;code&gt;dlopen&lt;/code&gt; call inherits that behavior. The library must be fully initialized before you get the handle back.&lt;/p&gt;
&lt;h4&gt;3) Looking Up Symbols (&lt;code&gt;dlsym&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;Once &lt;code&gt;dlopen&lt;/code&gt; returns successfully, you have an opaque handle. Internally, this is a pointer to the &lt;code&gt;link_map&lt;/code&gt; structure the loader created when it mapped the library, the same structure it uses to track every shared library in the process.&lt;/p&gt;
&lt;p&gt;To call a function from the loaded library:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-c&quot;&gt;void (*func)() = dlsym(handle, &amp;quot;run_plugin&amp;quot;);
func();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The loader searches starting from that &lt;code&gt;link_map&lt;/code&gt; (the object itself and its dependency subtree, not just the single object) and returns the memory address of &lt;code&gt;run_plugin&lt;/code&gt;. From this point on, you call &lt;code&gt;func()&lt;/code&gt; like any other function pointer.&lt;/p&gt;
&lt;p&gt;This is conceptually how Python loads C extensions: &lt;code&gt;import numpy&lt;/code&gt; eventually triggers a &lt;code&gt;dlopen&lt;/code&gt; on the compiled NumPy shared object, and &lt;code&gt;dlsym&lt;/code&gt; is used to find the entry points that bridge Python calls to the C implementation.&lt;/p&gt;
</content:encoded></item></channel></rss>