# Dynamic linking without libc adventures

**URL:** <https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113>\
**Category:** Explain\
**Created:** [January 23, 2025, 5:11pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113 "2025-01-23T17:11:49Z")\
**Posts on this page:** 20\
**Page:** 1

<div class="post-metadata">

**Author:** ![TibboddiT](https://ziggit.dev/user_avatar/ziggit.dev/tibboddit/32/4339_2.png) [@TibboddiT](https://ziggit.dev/u/TibboddiT)\
**Post date:** [January 23, 2025, 5:11pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/1 "2025-01-23T17:11:49Z")

</div>

Hey everyone, first post here. And a big one, sorry if it feels unconventional, but this is the kind of post I like come across.

Also, I’m not as fluent as I would want to be in english, so sorry about that too.

I have what seems a rare use case, because I haven’t found many issues on gihtub revolving around the core problem I face.

Zig version is master, my host is `x86_64-linux-gnu`, and target is `native`.  
I need to build a shared library, and then an executable that link it, without linking libc.

Not a big deal you might think, so let’s try it.

```zig
// root.zig

pub export fn add(a: i32, b: i32) i32 {
    return a + b;
}

```

```zig
// main.zig

const std = @import("std");

extern fn add(i32, i32) i32;

pub fn main() !void {
    const result = add(34, 35);

    std.log.info("{d}", .{result});
}

```

```zig
// build.zig

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const lib_mod = b.createModule(.{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });

    const lib = b.addSharedLibrary(.{
        .name = "libz",
        .root_module = lib_mod,
    });

    b.installArtifact(lib);

    const exe_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    exe_mod.linkLibrary(lib);

    const exe = b.addExecutable(.{
        .name = "exez",
        .root_module = exe_mod,
    });

    // I like to see what's going on, too bad the output is
    // reported "like an error", but I can deal with it
    b.verbose_link = true;

    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());

    if (b.args) |args| {
        run_cmd.addArgs(args);
    }

    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

```

Pretty much standard. Let’s run `zig build` :

```zig
error: ld.lld: undefined reference: __tls_get_addr

```

Ok. As I have some background in compiling stuff, I know what’s going on: `__tls_get_addr` is usually provided by libc, and zig doesn’t provide an implementation.  
Which, to be fair, is understandable at first sight.  
But looking into the source code, I was amazed by all the provided utilities included to interpret ELF and prepare execution. I mean, look at `lib/std/os/linux/pie.zig`, `lib/std/os/linux/tls.zig`, or , `lib/std/elf.zig`. Very impressive.  
So, after 3 days of reading the parts of std library and compiler\_rt involved into those kind of things, not providing `__tls_get_addr` feels like a miss.

I’m a brutal programmer, so I will provide an implementation myself just to satisfy the linker:

```zig
// root.zig

pub export fn add(a: i32, b: i32) i32 {
    return a + b;
}

pub export fn __tls_get_addr(offset: *anyopaque) usize {
    _ = offset;

    // let's see what happens
    return 69;
}

```

`zig build`: no errors!

After a free shot of dopamine, it’s time to run the produced executable:

`./zig-out/bin/exez`:

```zig
[1] 13750 segmentation fault ./zig-out/bin/exez

```

Hmm… almost, but not today.

Debugging time:

```zig
* thread #1, name = 'exez', stop reason = signal SIGSEGV: address not mapped to object (fault address: 0x0)
    frame #0: 0x0000000000000000

```

This is what I discovered after stepping from the start:

- program entry point is `_start` in `start.zig:224`
- `posixCallMainAndExit` in `start.zig:224` is called
- `std.os.linux.tls.initStatic` is called, to initialize the executable TLS area in case some threadlocal variables ares used somewhere in the code
- `std.os.linux.tls.prepareArea` is called
- `@memset` is called
- program counter goes into the .plt section to call the memset builtin (provided by compiler\_rt)
- and… segfault

And it makes sense, kind of. After inspecting the executable image, it seems nothing has been done for the relocations involved in loading a dynamically linked executable.  
Like if /lib64/ld-linux.so.2 was not used at all. We can see from `readelf -l ./zig-out/bin/exez` that no `INTERP` header was set, and `file ./zig-out/bin/exez` report no interpreter.

Let’s call the executable through the dynamic linker directly, just to be sure:

`/lib64/ld-linux-x86-64.so.2 ./zig-out/bin/exez`:

```zig
info: 69

```

It works perfectly.

The linker call for the executable is (with args on different lines for clarity):

```zig
ld.lld
  --error-limit=0
  --entry _start
  -z stack-size=16777216
  --image-base=16777216
  --eh-frame-hdr
  -znow
  -m elf_x86_64
  -o /home/tibbo/Temp/ZigDynNoLibc/.zig-cache/o/dcb863ec1f0c28a714f96a0d8f6186a3/exez
  -rpath /home/tibbo/Temp/ZigDynNoLibc/.zig-cache/o/ccbb04d27259d135001cdad08ee7e7a2
  /home/tibbo/Temp/ZigDynNoLibc/.zig-cache/o/dcb863ec1f0c28a714f96a0d8f6186a3/exez.o
  --as-needed /home/tibbo/Temp/ZigDynNoLibc/.zig-cache/o/ccbb04d27259d135001cdad08ee7e7a2/liblibz.so
  /home/tibbo/.cache/zig/o/b31258b48dfe6887846bbea61e2106e2/libcompiler_rt.a

```

No mention of `--dynamic-linker /lib64/ld-linux-x86-64.so.2`, so it’s understandable that no interpreter is set on the executable.  
Let’s try to add it:

```zig
ld.lld
  --error-limit=0
  --entry _start
  -z stack-size=16777216
  --image-base=16777216
  --eh-frame-hdr
  -znow
  -m elf_x86_64
  --dynamic-linker /lib64/ld-linux-x86-64.so.2 // <= HERE
  -o /home/tibbo/Temp/ZigDynNoLibc/.zig-cache/o/dcb863ec1f0c28a714f96a0d8f6186a3/exez
  -rpath /home/tibbo/Temp/ZigDynNoLibc/.zig-cache/o/ccbb04d27259d135001cdad08ee7e7a2
  /home/tibbo/Temp/ZigDynNoLibc/.zig-cache/o/dcb863ec1f0c28a714f96a0d8f6186a3/exez.o
  --as-needed /home/tibbo/Temp/ZigDynNoLibc/.zig-cache/o/ccbb04d27259d135001cdad08ee7e7a2/liblibz.so
  /home/tibbo/.cache/zig/o/b31258b48dfe6887846bbea61e2106e2/libcompiler_rt.a

```

And then, `/home/tibbo/Temp/ZigDynNoLibc/.zig-cache/o/dcb863ec1f0c28a714f96a0d8f6186a3/exez`:

```zig
info: 69

```

Oh yeah, nice.

So it’s just a matter of how the args of the call to ld.ldd are computed. In `src/link/elf.zig:1883`, we see:

```zig
...

        if (comp.config.link_libc) {
            if (comp.libc_installation) |libc_installation| {
                try argv.append("-L");
                try argv.append(libc_installation.crt_dir.?);
            }

            if (have_dynamic_linker) {
                if (target.dynamic_linker.get()) |dynamic_linker| {
                    try argv.append("-dynamic-linker");
                    try argv.append(dynamic_linker);
                }
            }
        }

...

```

So no libc, no dynamic linker. Let’s try linking libc to check if this is true:

```zig
// build.zig

...

    const exe_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true, // <= HERE
    });

...

```

`zig build run`:

```zig
info: 69

```

Ok, we found the cause: when not linking libc, no dynamic interpreter is set, so no relocations are made.  
At this point, we could discuss why it is the case.  
I thought about opening an issue, but to be honest I feel like the issue tracker has became noisy lately, with a bunch of people opening issues with zero investigation and a touch of “I demand my niche use case to be addressed immediately, or I will tweet this zig trend is just propaganda”.  
I feel uncomfortable pinging some core contributor, potentially adding to the noise with what seems to be a use case nodbody cares about, just a few days before a release. Even if I think the debate about the rationale behind “no libc, no dynamic linker” is exactly the kind of thing I like in the chaos systems programming has become.

If by chance a core contributor or someone with good mastery of the zig source code see this post and can give a short explaination, I’ll just be happy with it.

And there is something else.

Remember the first segfault? We saw that entry point ends up in `posixCallMainAndExit`. There is something interesting in this function (start.zig:522):

```zig

...

        // Apply the initial relocations as early as possible in the startup process. We cannot
        // make calls yet on some architectures (e.g. MIPS) *because* they haven't been applied yet,
        // so this must be fully inlined.
        if (builtin.position_independent_executable) {
            @call(.always_inline, std.os.linux.pie.relocate, .{phdrs}); // <= HERE
        }

...

```

Wow, very cool. If the executable is PIE, zig will take care of the relocations itself. Again, very impressive.

So let’s remove the libc linking, and mark the executable as PIE:

```zig
// build.zig

...

    const exe_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        // .link_libc = true,
    });

    exe_mod.linkLibrary(lib);

    const exe = b.addExecutable(.{
        .name = "exez",
        .root_module = exe_mod,
    });

    exe.pie = true; // <= HERE

...

```

I will not tell the story of my journey seeking for complete explaination of the `.pic` and `.pie` usage through the source code, but it was wild.

Now, let’s run it. `zig build run`:

```zig
[1] 23395 segmentation fault ./zig-out/bin/exez

```

Too bad. But I’m curious, so let’s debug it:

```zig
* thread #1, name = 'exez', stop reason = signal SIGSEGV: address not mapped to object (fault address: 0xbab46)
  * frame #0: 0x00000000000bab46
    frame #1: 0x00007ffff7f6b43f exez`os.linux.tls.prepareArea(area=(ptr = "", len = 48)) at tls.zig:462:5
    frame #2: 0x00007ffff7f6989b exez`os.linux.tls.initStatic(phdrs=[]elf.Elf64_Phdr @ 0x00007fffffffd348) at tls.zig:528:33
    frame #3: 0x00007ffff7f68788 exez`start.posixCallMainAndExit(argc_argv_ptr=0x00007fffffffdb30) at start.zig:547:40
    frame #4: 0x00007ffff7f6844e exez`start._start at start.zig:271:5

```

It is not the same thing than the first segfault, when building without marking the executable as pie. This time, relocations has been resolved, but the program counter jumps to an unmapped address when calling @memset.  
In fact, it seems relocations are only one part of the job, effectively mapping the library into the process memory must be done too, and must be done before.

I wonder if zig could be taking this responsability… Wait, what is this crazyness in `test/standalone/load_dynamic_library/main.zig`?

```zig
const std = @import("std");

pub fn main() !void {
    var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const args = try std.process.argsAlloc(gpa.allocator());
    defer std.process.argsFree(gpa.allocator(), args);

    const dynlib_name = args[1];

    var lib = try std.DynLib.open(dynlib_name);
    defer lib.close();

    const Add = *const fn (i32, i32) callconv(.C) i32;
    const addFn = lib.lookup(Add, "add") orelse return error.SymbolNotFound;

    const result = addFn(12, 34);
    std.debug.assert(result == 46);
}

```

But does it works without linking libc? Let’s see the source:

```zig
// lib/std/dynamic_library.zig

const std = @import("std.zig");
const builtin = @import("builtin");
const mem = std.mem;
const testing = std.testing;
const elf = std.elf;
const windows = std.os.windows;
const native_os = builtin.os.tag;
const posix = std.posix;

/// Cross-platform dynamic library loading and symbol lookup.
/// Platform-specific functionality is available through the `inner` field.
pub const DynLib = struct {
    const InnerType = switch (native_os) {
        .linux => if (!builtin.link_libc or builtin.abi == .musl and builtin.link_mode == .static)
            ElfDynLib
        else
            DlDynLib,
        .windows => WindowsDynLib,
        .macos, .tvos, .watchos, .ios, .visionos, .freebsd, .netbsd, .openbsd, .dragonfly, .solaris, .illumos => DlDynLib,
        else => struct {
            const open = @compileError("unsupported platform");
            const openZ = @compileError("unsupported platform");
        },
    };

...

pub const ElfDynLib = struct {
    strings: [*:0]u8,
    syms: [*]elf.Sym,
    hashtab: [*]posix.Elf_Symndx,
    versym: ?[*]elf.Versym,
    verdef: ?*elf.Verdef,
    memory: []align(mem.page_size) u8,

...

    /// Trusts the file. Malicious file will be able to execute arbitrary code.
    pub fn open(path: []const u8) Error!ElfDynLib {
        const fd = try resolveFromName(path);
        defer posix.close(fd);

        const file: std.fs.File = .{ .handle = fd };
        const stat = try file.stat();
        const size = std.math.cast(usize, stat.size) orelse return error.FileTooBig;

        // This one is to read the ELF info. We do more mmapping later
        // corresponding to the actual LOAD sections.
        const file_bytes = try posix.mmap(
            null,
            mem.alignForward(usize, size, mem.page_size),
            posix.PROT.READ,
            .{ .TYPE = .PRIVATE },
            fd,
            0,
        );
        defer posix.munmap(file_bytes);

...

```

Mind blowing… zig provides a way to map a dynamic library without the help of libc (I didn’t test if it’s really working though, but the fact that a test exists is a good sign).  
It seems to me that most of the work has already been done to be able to do loading/mapping AND relocations on linux.

And judging by the existence of `test/cases/pic_linux.zig`:

```zig
const std = @import("std");

// Eventually, this test should be made to work without libc by providing our
// own `__tls_get_addr` implementation. powerpcle-linux should be added to the
// target list here when that happens.
//
// https://github.com/ziglang/zig/issues/20625
pub fn main() void {}

// run
// backend=stage2,llvm
// target=arm-linux,armeb-linux,thumb-linux,thumbeb-linux,aarch64-linux,aarch64_be-linux,loongarch64-linux,mips-linux,mipsel-linux,mips64-linux,mips64el-linux,powerpc-linux,powerpc64-linux,powerpc64le-linux,riscv32-linux,riscv64-linux,s390x-linux,x86-linux,x86_64-linux
// pic=true
// link_libc=true

```

I have good hope. I must confess, I’m already working on it, but as I said earlier, I feel adding a PR from a random dev with very specific use cases to the issue tracker is not a thing I want to do, at least not before I have a complete understanding of the side effects of my edits. Zig is a big project, I cannot torture it like it was just my personal window manager or something.

Let’s recap (this concerns linux):

- it’s not currently possible to link a shared library while not linking libc for two reasons:
  - `__tls_get_addr` is needed
    - workaround: provide an implementation yourself, and export it
    - solution I’m working on these days: implement it for linux in compiler\_rt
      - **question** : is this the right thing to do?

    - _note: `__tls_get_addr` is needed because std lib use threadlocal vars; if compiling with `--release=fast` you may not encounter this problem (you will if you make use of anything that call `std.Thread.getCurrentId()`, like `std.Thread.Mutex.lock()` in debug mode, because it is caching the thread id in a threadlocal var)_

  - if libc is not linked, no dynamic linker will be used, and no loading/mapping nor relocations will be made before `main`
    - **question** : why is that? Am I missing something?
    - workaround: I didn’t find any way to force the linker call to pass `-dynamic-linker` without modifying the zig source code
    - solution I’m working on these days: allow dynamic linker even without libc, changes mainly happens in `src/link/Elf.zig`
      - **question** : is it a good idea?

- currently, if a PIE executable linked to a shared library is produced without linking libc (which is almost as nice as static linking):
  - no dynamic linker will be used as per the above (good, one less dependency on the linker path)
  - zig can handle relocations, but does not load/map linked dynamic libraries, so execution will fail
  - in the hypothesis the loading/mapping become effective, I think distributing updates of a complex program consisting of a PIE libc-free dynamic-linker-free executable using dynamic libraries to organize its components can become a cool thing to do, and is in fact the motivation behind my use case
  - **question** : does someone have a good doc that is not ChatGPT diarrhea explaining precisely how and where (which address) is a dyanmic library mapped into process memory on linux?

Conclusion: in my ideal world (for linux), `__tls_get_addr` is provided by zig, and the start logic in `posixCallMainAndExit` handles all the dynamic linker tasks !

---

<div class="post-metadata">

**Author:** ![pachde](https://ziggit.dev/user_avatar/ziggit.dev/pachde/32/2485_2.png) [@pachde](https://ziggit.dev/u/pachde)\
**Post date:** [January 23, 2025, 7:18pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/2 "2025-01-23T19:18:05Z")

</div>

Somethin something link compiler\_rt?

---

<div class="post-metadata">

**Author:** ![gonzo](https://ziggit.dev/user_avatar/ziggit.dev/gonzo/32/54_2.png) [@gonzo](https://ziggit.dev/u/gonzo)\
**Post date:** [January 24, 2025, 8:44am UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/3 "2025-01-24T08:44:42Z")

</div>

Because you are declaring your function with `export`, perhaps you need to link against `libc`?

---

<div class="post-metadata">

**Author:** ![TibboddiT](https://ziggit.dev/user_avatar/ziggit.dev/tibboddit/32/4339_2.png) [@TibboddiT](https://ziggit.dev/u/TibboddiT)\
**Post date:** [January 24, 2025, 1:39pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/4 "2025-01-24T13:39:06Z")

</div>

I don’t think `export fn` requires libc to work, but yes, linking libc would solve everything.  
Here my goal is to produce an executable targetting linux without dependency on libc, and keep the possibility to link dynamic libraries.  
One thing I can do is using `std.DynLib.open` early in the execution and use loaded libraries, but in this specific use case (that I fully understand is not standard) I would prefer using normal dynamic linking.

---

<div class="post-metadata">

**Author:** ![cancername](https://ziggit.dev/user_avatar/ziggit.dev/cancername/32/8752_2.png) [@cancername](https://ziggit.dev/u/cancername)\
**Post date:** [April 24, 2025, 2:51pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/5 "2025-04-24T14:51:08Z")

</div>

This was super interesting! And yeah, Zig implementing its own dynamic linker would be pretty rad.

---

<div class="post-metadata">

**Author:** ![sbrow](https://ziggit.dev/user_avatar/ziggit.dev/sbrow/32/4970_2.png) [@sbrow](https://ziggit.dev/u/sbrow)\
**Post date:** [April 25, 2025, 12:50pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/6 "2025-04-25T12:50:55Z")

</div>

Top notch first post. Bravo!

---

<div class="post-metadata">

**Author:** ![Southporter](https://ziggit.dev/user_avatar/ziggit.dev/southporter/32/2500_2.png) [@Southporter](https://ziggit.dev/u/Southporter)\
**Post date:** [April 25, 2025, 2:41pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/7 "2025-04-25T14:41:50Z")

</div>

> [@TibboddiT](#):
>
> solution I’m working on these days: implement it for linux in compiler\_rt
> 
> - **question** : is this the right thing to do?

I am not a core contributor, but from what I’ve seen, this is indeed the right thing to do. From. What I’ve seen of the Zig repo, Andrew and friends are open to these kinds of PRs and work. If you have questions, the Zig Zulip (can’t find a link right now) for the compiler work might be a better place to have this conversation.

Edit: Found the Zulip referenced in the main repo for those interested: [Community · ziglang/zig Wiki · GitHub](https://github.com/ziglang/zig/wiki/Community#zulip-1)

---

<div class="post-metadata">

**Author:** ![alexrp](https://ziggit.dev/user_avatar/ziggit.dev/alexrp/32/2793_2.png) [@alexrp](https://ziggit.dev/u/alexrp)\
**Post date:** [April 25, 2025, 8:18pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/8 "2025-04-25T20:18:52Z")

</div>

Very late to this thread, but:

> [@TibboddiT](#):
>
> solution I’m working on these days: implement it for linux in compiler\_rt
> 
> - **question** : is this the right thing to do?

Just confirming what @Southporter said; a non-libc `__tls_get_addr` implementation in compiler-rt is definitely welcome.

> [@TibboddiT](#):
>
> if libc is not linked, no dynamic linker will be used, and no loading/mapping nor relocations will be made before `main`
> 
> - **question** : why is that? Am I missing something?

The reason for this is that the dynamic linker path is inherently tied to the choice of libc; glibc and musl use different dynamic linker paths, for example. The good news is that you can supply your own with `zig build -Ddynamic-linker=...` (and the underlying flags for `zig cc` / `zig build-exe`).

---

<div class="post-metadata">

**Author:** ![TibboddiT](https://ziggit.dev/user_avatar/ziggit.dev/tibboddit/32/4339_2.png) [@TibboddiT](https://ziggit.dev/u/TibboddiT)\
**Post date:** [May 5, 2025, 1:33pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/9 "2025-05-05T13:33:57Z")

</div>

Sorry for the delay, I didn’t notice the latest replies.

> [@alexrp](#):
>
> Just confirming what @Southporter said; a non-libc `__tls_get_addr` implementation in compiler-rt is definitely welcome.

Good to know (it is the path I chose to go for). It should be noted that I cannot spend more than a few hours a week on this, so please don’t expect anything in the short term. All of this is mostly an experiment.

> [@alexrp](#):
>
> The good news is that you can supply your own with `zig build -Ddynamic-linker=...` (and the underlying flags for `zig cc` / `zig build-exe`).

I tried that, but the final executable ends up with no `INTERP` header set. I can see the `--dynamic-linker /lib64/ld-linux-x86-64.so.2` flag passed to `zig build-exe`, but not to the subsequent `ld.lld` call. I did not investigate why, though.

The project’s goals have shifted a bit, and I decided to focus on implementing `dlopen` functionality from static position-independent executables on x86\_64-linux. I can take advantage of the fact that in this case, I can prepare execution if I need to, as initialization is in zig space (cf. `std.os.linux.pie.relocate` and `std.os.linux.tls.initStatic` calls in `lib/std/start.zig`).

* * *

Some notes:

- Dynamically loading shared libraries from a static executable is _NOT_ a good idea. The first thing to try when you feel the need to do that is probably to statically compile everything needed. But what do you do if you want to use OpenGL or Vulkan? You have no choice but to use the system-provided dynamic libraries which come with graphic drivers. I believe the same can be said as soon as hardware drivers are involved. That’s why today my main test case is a simple program that uses OpenGL with GLFW.

- Dynamic libraries will likely require the system’s libc. If the main executable also bundles libc (a static one like musl), a lot of care will need to be taken. Everything shared between dynamic libraries (and shared between the main executable and a dynamic library) should be carefully handled to not break any convention. This includes, for example, thread-local storage, static things like `brk` in malloc implementations, etc. I believe analyzing libraries and symbols loaded when the custom `dlopen` implementation is called and making smart decisions when doing relocations can help circumvent those difficulties. As I already can parse, load, and (almost) relocate dynamic libraries and their dependencies recursively, this is where I basically am today. My first milestone is to make my specific test case work.

- For reference, here is a post closely related to this one (at least in terms of goals): [Loading `libvulkan.so.1` on Linux with `std.ElfDynLib`](https://ziggit.dev/t/loading-libvulkan-so-1-on-linux-with-std-elfdynlib/8480), and an old discussion I found while researching this topic: [static linking and dlopen](https://musl.openwall.narkive.com/lW4KCyXd/static-linking-and-dlopen#post11)

---

<div class="post-metadata">

**Author:** ![alexrp](https://ziggit.dev/user_avatar/ziggit.dev/alexrp/32/2793_2.png) [@alexrp](https://ziggit.dev/u/alexrp)\
**Post date:** [May 5, 2025, 2:02pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/10 "2025-05-05T14:02:38Z")

</div>

> [@TibboddiT](#):
>
> I tried that, but the final executable ends up with no `INTERP` header set. I can see the `--dynamic-linker /lib64/ld-linux-x86-64.so.2` flag passed to `zig build-exe`, but not to the subsequent `ld.lld` call. I did not investigate why, though.

Would you mind filing an issue about this? That seems like a bug.

---

<div class="post-metadata">

**Author:** ![TibboddiT](https://ziggit.dev/user_avatar/ziggit.dev/tibboddit/32/4339_2.png) [@TibboddiT](https://ziggit.dev/u/TibboddiT)\
**Post date:** [May 5, 2025, 3:26pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/11 "2025-05-05T15:26:02Z")

</div>

I checked again, and the root cause seems to be the fact that libc is not linked, as mentioned in the first message:

> [@TibboddiT](#):
>
> So it’s just a matter of how the args of the call to ld.ldd are computed. In `src/link/elf.zig:1883`, we see:
> 
> ```zig
> ...
> 
> if (comp.config.link_libc) {
> if (comp.libc_installation) |libc_installation| {
> try argv.append("-L");
> try argv.append(libc_installation.crt_dir.?);
> }
> 
> if (have_dynamic_linker) {
> if (target.dynamic_linker.get()) |dynamic_linker| {
> try argv.append("-dynamic-linker");
> try argv.append(dynamic_linker);
> }
> }
> }
> 
> ...
> 
> ```
> 
> So no libc, no dynamic linker

The `dynamic-linker` option is not used even when explicitly given to `zig build` if libc is not linked.

I’m not sure if it is really a bug, but if you confirm it is, I will fill an issue.

---

<div class="post-metadata">

**Author:** ![alexrp](https://ziggit.dev/user_avatar/ziggit.dev/alexrp/32/2793_2.png) [@alexrp](https://ziggit.dev/u/alexrp)\
**Post date:** [May 5, 2025, 3:50pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/12 "2025-05-05T15:50:48Z")

</div>

I think you’re right that this isn’t technically a bug, actually. But I think there’s totally an argument to be made for the ability to create shared libraries using a custom dynamic linking setup, such as what you’re trying to do here. You could also imagine a pure-Zig operating system that has a dynamic linker, but doesn’t have a mandatory libc as part of the deal.

So I still think it’s worth filing an issue for this.

---

<div class="post-metadata">

**Author:** ![TibboddiT](https://ziggit.dev/user_avatar/ziggit.dev/tibboddit/32/4339_2.png) [@TibboddiT](https://ziggit.dev/u/TibboddiT)\
**Post date:** [May 6, 2025, 9:44am UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/13 "2025-05-06T09:44:54Z")

</div>

Here it is : [`-dynamic-linker=...` linker's option is skipped when libc is not linked · Issue #23813 · ziglang/zig · GitHub](https://github.com/ziglang/zig/issues/23813)

Feel free to rephrase if necessary, as I’m not a native english speaker.

---

<div class="post-metadata">

**Author:** ![alexrp](https://ziggit.dev/user_avatar/ziggit.dev/alexrp/32/2793_2.png) [@alexrp](https://ziggit.dev/u/alexrp)\
**Post date:** [June 4, 2025, 7:42am UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/14 "2025-06-04T07:42:52Z")

</div>

So I went and implemented `__tls_get_addr` in a branch. But then it occurred to me that I have no idea what good it would do for us to provide it. The implementation is inherently coupled with the actual TLS implementation that is in use at run time, and Zig’s TLS implementation in `std.os.linux` – which is used in an executable when libc is not linked – is fully static; that is, it does not support expanding the Dynamic Thread Vector (which is required for TLS in shared libraries to work).

Maybe you can convince me otherwise, but to my eyes, it seems like `__tls_get_addr` should be provided either by libc or by the dynamic linker. Providing it in the dynamic linker binary seems to be the norm on Linux and the BSDs, FWIW. It comes with the caveat that you need to link your shared libraries against your dynamic linker binary (or a stub binary at least), but again, that’s the norm.

---

<div class="post-metadata">

**Author:** ![alexrp](https://ziggit.dev/user_avatar/ziggit.dev/alexrp/32/2793_2.png) [@alexrp](https://ziggit.dev/u/alexrp)\
**Post date:** [June 4, 2025, 8:39am UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/15 "2025-06-04T08:39:48Z")

</div>

Here’s the branch so you can see what I mean: [Commits · alexrp/zig · GitHub](https://github.com/alexrp/zig/commits/tls-get-addr)

---

<div class="post-metadata">

**Author:** ![TibboddiT](https://ziggit.dev/user_avatar/ziggit.dev/tibboddit/32/4339_2.png) [@TibboddiT](https://ziggit.dev/u/TibboddiT)\
**Post date:** [June 6, 2025, 9:52am UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/16 "2025-06-06T09:52:14Z")

</div>

> [@alexrp](#):
>
> Maybe you can convince me otherwise, but to my eyes, it seems like `__tls_get_addr` should be provided either by libc or by the dynamic linker.

I don’t know if it will convince you, but here is a brief description of a potential use case:

- I want to produce libraries which make use of thread-local variables (either directly or by using parts of the zig standard library that does so)
- I want to produce executables with those libraries linked
- I don’t want to link libc, but I’m okay (for now) with setting the `INTERP` header of the final executables to make use of the system dynamic linker and delegate the execution’s initialization (loading libraries, setting TLS, etc.), which is facilitated by one of your recent contributions.

Here is a simple repro :

```zig

// build.zig

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{
        .default_target = .{ .dynamic_linker = .init("/lib64/ld-linux-x86-64.so.2") },
    });
    const optimize = b.standardOptimizeOption(.{});

    const lib_mod = b.createModule(.{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    });

    const lib = b.addSharedLibrary(.{
        .name = "libz",
        .root_module = lib_mod,
    });

    b.installArtifact(lib);

    const exe_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    exe_mod.linkLibrary(lib);

    const exe = b.addExecutable(.{
        .name = "exez",
        .root_module = exe_mod,
    });

    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());

    if (b.args) |args| {
        run_cmd.addArgs(args);
    }

    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

// main.zig

const std = @import("std");

extern fn set_tls_var(i32) void;
extern fn add_tls_var(i32) i32;

pub fn main() !void {
    var thread_1 = try std.Thread.spawn(.{}, run, .{10});
    var thread_2 = try std.Thread.spawn(.{}, run, .{20});
    var thread_3 = try std.Thread.spawn(.{}, run, .{30});
    var thread_4 = try std.Thread.spawn(.{}, run, .{40});
    var thread_5 = try std.Thread.spawn(.{}, run, .{50});

    thread_1.join();
    thread_2.join();
    thread_3.join();
    thread_4.join();
    thread_5.join();
}

fn run(val: u8) void {
    set_tls_var(val);
    for (0..10) |i| {
        const res = add_tls_var(@intCast(i));
        std.log.info("{d}: {d}", .{ std.Thread.getCurrentId(), res });
    }
}

// root.zig

threadlocal var test_tl: i32 = undefined;

pub export fn set_tls_var(a: i32) void {
    test_tl = a;
}

pub export fn add_tls_var(a: i32) i32 {
    return a + test_tl;
}

```

It compiles fine, but when running:

```sh
zig build run

/home/dev/repro/zig-out/bin/exez: symbol lookup error: .zig-cache/o/45df13f3758711ca9505016b43862271/liblibz.so: undefined symbol: __tls_get_addr

```

I made a very basic and incomplete implementation of `__tls_get_addr` for x86\_64-linux based on glibc, musl and fuschia docs and sources as a workaround for a similar use case I had:

````zig

comptime {
    @export(& __tls_get_addr_linux_x86_64, .{ .name = "__ tls_get_addr" });
}

const TlsIndex = extern struct {
    ti_module: usize,
    ti_offset: usize,
};

const DtvPointer = extern struct {
    val: *anyopaque,
    to_free: *anyopaque,
};

const Dtv = extern union {
    counter: usize,
    pointer: DtvPointer,
};

pub fn __tls_get_addr_linux_x86_64(tls_index: *TlsIndex) callconv(.c) *anyopaque {
    // Get dtv address without a `arch_prctl` syscall
    //
    // The aternative way would be:
    // ```
    // const ret = @call(.always_inline, linux.syscall2, .{ .arch_prctl, linux.ARCH.GET_FS, @intFromPtr(&addr) });
    // assert(ret == 0);
    // ```

    const addr = asm volatile ("mov %fs:0, %[ret]"
        : [ret] "=r" (-> usize),
    );

    // Use the general way of getting the requested variable address
    // We assume that information is layout like what standard dynamic linker does
    // We also assume that the dtv entry is already allocated
    const dtv: [*]Dtv = @ptrFromInt(addr);
    const var_addr = @intFromPtr(&dtv[tls_index.ti_module].pointer) + tls_index.ti_offset;

    return @ptrFromInt(var_addr);
}

````

With this the repro runs successfully. I use a custom local zig build with this implementation added to `compiler_rt` (only when on x86\_64-linux and no libc linked), and I plan to enhance it in the future. My goal is to be able to load dynamic libraries from zig without libc, even when those libraries expect a standard c environment. And TLS seems to me like one of the milestones.

If you find this unconvincing, be sure I totally understand.

---

<div class="post-metadata">

**Author:** ![alexrp](https://ziggit.dev/user_avatar/ziggit.dev/alexrp/32/2793_2.png) [@alexrp](https://ziggit.dev/u/alexrp)\
**Post date:** [June 6, 2025, 10:15am UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/17 "2025-06-06T10:15:29Z")

</div>

The basic problem here is: How do you get to the DTV in the ` __tls_get_addr` implementation? There isn’t a generic way to do this; it’s part of the ABI on a few targets, but on others, it’s completely up to the dynamic linker where the DTV is stored. The result is that the Zig standard library can’t actually provide a “one size fits all” `__ tls_get_addr` implementation.

I suppose we _could_ provide a `__tls_get_addr` implementation for targets where we know how to find the DTV because of ABI, but on the other targets, I think we’d strongly prefer letting a linker error happen rather than crossing our fingers and hoping that the dynamic linker agrees with what we’re doing.

---

<div class="post-metadata">

**Author:** ![TibboddiT](https://ziggit.dev/user_avatar/ziggit.dev/tibboddit/32/4339_2.png) [@TibboddiT](https://ziggit.dev/u/TibboddiT)\
**Post date:** [June 6, 2025, 11:31am UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/18 "2025-06-06T11:31:05Z")

</div>

> [@alexrp](#):
>
> I suppose we _could_ provide a `__tls_get_addr` implementation for targets where we know how to find the DTV because of ABI, but on the other targets, I think we’d strongly prefer letting a linker error happen rather than crossing our fingers and hoping that the dynamic linker agrees with what we’re doing.

I totally agree with this statement. Even the fact that the DTV concept is part of an ABI is debatable. I guess I just assumed that mechanisms like `%fs` on x86\_64 or `tpidr_el0` on aarch64 were standard enough. Since I can request a dynamic linker and provide a `__tls_get_addr` implementation from “userland”, I have no reason to insist.

As a side note, I think I will explore the `-femulated-tls` clang’s option (I think it isn’t exposed to the build system) to check if this use case can be made to work “more globally”.

---

<div class="post-metadata">

**Author:** ![g41797](https://ziggit.dev/user_avatar/ziggit.dev/g41797/32/3339_2.png) [@g41797](https://ziggit.dev/u/g41797)\
**Post date:** [June 6, 2025, 2:40pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/19 "2025-06-06T14:40:55Z")

</div>

From [No-Libc Zig Now Outperforms Glibc Zig](https://ziglang.org/devlog/2025/#2025-02-07)

> the language and standard library has become _strictly better_ to use than C and libc.  
> While other languages build on top of libc, Zig instead has conquered it!

It’s a pity that this isn’t entirely true

_Forewarned is forearmed_

Thanks for investigatioin

---

<div class="post-metadata">

**Author:** ![andrewrk](https://ziggit.dev/user_avatar/ziggit.dev/andrewrk/32/7011_2.png) [@andrewrk](https://ziggit.dev/u/andrewrk)\
**Post date:** [June 6, 2025, 9:31pm UTC](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113/20 "2025-06-06T21:31:38Z")

</div>

That’s a great point. I forgot about dynamic linking when I wrote that.

[Next page](https://ziggit.dev/t/dynamic-linking-without-libc-adventures/8113.md?page=2)
