Dynamic linking without libc adventures

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.

// root.zig

pub export fn add(a: i32, b: i32) i32 {
    return a + b;
}
// 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});
}
// 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 :

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:

// 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:

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

Hmm… almost, but not today.

Debugging time:

* 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:

info: 69

It works perfectly.

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

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:

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:

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:

...

        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:

// 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:

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):


...

        // 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:

// 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:

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

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

* 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?

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:

// 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:

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 !

16 Likes

Somethin something link compiler_rt?

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

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.

1 Like

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

1 Like

Top notch first post. Bravo!

2 Likes

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

Very late to this thread, but:

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

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).

6 Likes

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

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.

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`, and an old discussion I found while researching this topic: static linking and dlopen

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

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

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.

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.

2 Likes

Here it is : `-dynamic-linker=...` linker's option is skipped when libc is not linked · Issue #23813 · ziglang/zig · GitHub

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

1 Like

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.

Here’s the branch so you can see what I mean: Commits · alexrp/zig · GitHub

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 :


// 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:

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:


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.

2 Likes

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.

2 Likes

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”.

From No-Libc Zig Now Outperforms Glibc Zig

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

1 Like

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