Segmentation fault when calling exported function from dynamic Zig library opened with dlopen

I’m getting a segmentation fault when trying to call an exported function from a dynamically loaded Zig library. The same code works when I use std.fs.File.stdout().writeAll() instead of std.debug.print(). It also works fine if i link it directly with zig build-exe main.zig -L./ -ltest -lc.

// main.zig
const std = @import("std");

const TestFn = *const fn () callconv(.c) void;

pub fn main() !void {
	var test_so = try std.DynLib.open("./libtest.so");
	defer test_so.close();
	const test_fn: TestFn = test_so.lookup(TestFn, "test_fn") orelse @panic("Can't find test_fn");
	test_fn();
}

// test.zig
const std = @import("std");

pub export fn test_fn() void {
	std.debug.print("Hello, world!", .{});
}

Then i compile them with

zig build-lib test.zig -dynamic
zig build-exe main.zig -lc

And that’s the output

Segmentation fault at address 0x0
/nix/store/*hash*-zig-0.15.2/lib/zig/std/Progress.zig:660:34: 0x7ff0927030c8 in unlockStderrWriter (std.zig)
pub fn unlockStderrWriter() void {
								 ^
/nix/store/*hash*-zig-0.15.2/lib/zig/std/debug.zig:219:36: 0x7ff0926d6f9c in unlockStderrWriter (std.zig)
	std.Progress.unlockStderrWriter();
								   ^
/nix/store/*hash*-zig-0.15.2/lib/zig/std/debug.zig:230:29: 0x7ff0927a5db0 in print__anon_22560 (std.zig)
	defer unlockStderrWriter();
							^
/home/jerpo/prog/zig/aoc2025/test.zig:4:20: 0x7ff0927a5cb1 in test_fn (test.zig)
	std.debug.print("Hello, world!", .{});
				   ^
/home/jerpo/prog/zig/aoc2025/main.zig:9:12: 0x1139d6f in main (main.zig)
	test_fn();
		   ^
/nix/store/*hash*-zig-0.15.2/lib/zig/std/start.zig:627:37: 0x113a241 in main (std.zig)
			const result = root.main() catch |err| {
									^
???:?:?: 0x7ff09282a4d7 in ??? (libc.so.6)
Unwind information for `libc.so.6:0x7ff09282a4d7` was not available, trace may be incomplete

???:?:?: 0x7ff09282a59a in ??? (libc.so.6)
/nix/store/*hash*-zig-0.15.2/lib/zig/libc/glibc/sysdeps/x86_64/start.S:115:0: 0x115fe50 in ??? (/nix/store/*hash*-zig-0.15.2/lib/zig/libc/glibc/sysdeps/x86_64/start.S)
 call *__libc_start_main@GOTPCREL(%rip)

aborted (core dumped)

My environment:

  • Zig version: 0.15.2

  • OS: NixOS

I don’t really know, haven’t really built dynamic libraries lately but I think maybe you need -lc for this command too?

Thanks, that helped!

EDIT: Well, it did not, i just forgot to change the code back after linking with the library directly.

I tried it on debian trixie using zig version 0.15.2 and it works fine.

❯ file libtest.so
libtest.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, with debug_info, not stripped

❯ file main
main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, with debug_info, not stripped

❯ ./main
Hello, world!⏎

Whats the ldd / readelf output for your binary and shared lib? I feel like this is NativePaths.zig in the compiler messing up things again. Zig has some nixos specific code that i personally dont agree with.

Here’s the output:

$ ldd main 
        linux-vdso.so.1 (0x00007ff9242b8000)
        libc.so.6 => /nix/store/*hash*-glibc-2.40-66/lib/libc.so.6 (0x00007ff924000000)
        /nix/store/*hash*-glibc-2.40-66/lib/ld-linux-x86-64.so.2 => /nix/store/776irwlgfb65a782cxmyk61pck460fs9-glibc-2.40-66/lib64/ld-linux-x86-64.so.2 (0x00007ff9242ba000)
$ ldd libtest.so 
        linux-vdso.so.1 (0x00007fbd5103d000)
        libc.so.6 => /nix/store/*hash*-glibc-2.40-66/lib/libc.so.6 (0x00007fbd50c00000)
        /nix/store/*hash*-glibc-2.40-66/lib/ld-linux-x86-64.so.2 => /nix/store/*hash*-glibc-2.40-66/lib64/ld-linux-x86-64.so.2 (0x00007fbd5103f000)

That does look correct to me, do you get same issue if you target -Dtarget=x86_64-linux-gnu explicitly. You could try x86_64-linux-musl as well, but you need to have shell with musl or point to it with LD_LIBRARY_PATH

Try to run it using strace. The interesting part is at the end when the exe opens libtest.so.

❯ strace ./main
...
openat(AT_FDCWD, "./libtest.so", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0\0\0\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0775, st_size=7845112, ...}) = 0
getcwd("/home/din/tmp", 128)            = 14
mmap(NULL, 1651520, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f59f2ad8000
mmap(0x7f59f2ad9000, 110592, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x58000) = 0x7f59f2ad9000
mmap(0x7f59f2af4000, 1458176, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x5a1000) = 0x7f59f2af4000
mmap(0x7f59f2c58000, 77824, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x7c000) = 0x7f59f2c58000
mmap(0x7f59f2c6b000, 832, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f59f2c6b000
close(3)                                = 0
gettid()                                = 2128507
writev(2, [{iov_base="Hello, world!", iov_len=13}], 1Hello, world!) = 13
munmap(0x7f59f2ad8000, 1651520)         = 0
exit_group(0)                           = ?
+++ exited with 0 +++

Building both the executable and the library with explicit targets, unfortunately, did not help

I think that’s the part.

$ strace ./main
...
openat(AT_FDCWD, “./libtest.so”, O_RDONLY|O_CLOEXEC) = 3
read(3, “\\177ELF\\2\\1\\1\\0\\0\\0\\0\\0\\0\\0\\0\\0\\3\\0>\\0\\1\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0”…, 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=7295032, …}) = 0
getcwd(“/home/jerpo/prog/zig/aoc2025”, 128) = 29
mmap(NULL, 1659712, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f906526a000
mmap(0x7f906526b000, 110592, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x292000) = 0x7f906526b000
mmap(0x7f9065286000, 1466368, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x518000) = 0x7f9065286000
mmap(0x7f90653ec000, 77824, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x143000) = 0x7f90653ec000
mmap(0x7f90653ff000, 832, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f90653ff000
close(3)                                = 0
gettid()                                = 99299
— SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=NULL} —
...

Your library is completely loaded with correct setup for data, code, etc.
Whatever happens seems unrelated to the loader and related to the code in lib/std/Progress.zig
Since there is no writev call for Hello World, it must crash during the zig flush call for stderr. Using a debugger might help you find what is wrong.

Well, I tried to compile everything with -fllvm and it worked! I guess that the self-hosted backend is the problem here somehow.

$ zig build-lib ./test.zig -lc -dynamic -fllvm
$ zig build-exe ./main.zig -lc -fllvm
2 Likes

Why do you not agree with the Nix-specific code? It seems rather understandable given that NixOS and Nix do things that many programs don’t expect.

I wrote about this on zulip already:

I’m slightly concerned the amount of nix specific code in NativePaths.zig creeping up. The proper way in nix to inject dependencies is to use pkg-config. The interpretation of nixpkgs specific flags in NativePaths.zig is often more harmful than good.

I’m questioning the purpose of the nixpkgs specific flag handling and why is it necessary?

unknown NIX_CFLAGS_COMPILE flag causes compliation to fail · Issue #18998 · ziglang/zig · GitHub
Broken rpath output when building Zig with Nix · Issue #18612 · ziglang/zig · GitHub
Zig fails to get the include paths on NixOS Unstable · Issue #8269 · ziglang/zig · GitHub
Unexpected arguments in NIX_LDFLAGS and NIX_CFLAGS_COMPILE on Darwin · Issue #19879 · ziglang/zig · GitHub
meta: fix 'zig build -Denable-llvm' on nixos 24 by nektro · Pull Request #22728 · ziglang/zig · GitHub

If the main purpose is to have rpaths added to binary automatically so that it “just works” in dev envs. I think this should be done by linkSystemLibrary instead when zig isn’t given a custom --prefix. When a --prefix is given the binaries should be clean as possible. This alone would allow to remove all the nix specific code from NativePaths.zig for this use case.

I’d also deprecate the current linkSystemLibrary behaviour where both search paths and pkg-config are tried. I’d default to using pkg-config on linux (and maybe bsd, they have pkg-config drop-in alternative called pkgconf). Search paths only work in well known environment, and the current auto behavior can give you false impression of your build being portable when it actually isn’t.
example: https://github.com/raysan5/raylib/pull/4123 https://github.com/raysan5/raylib/pull/4406

On macos, pkg-config is still useful if you want to integrate with homebrew or nixpkgs for example, but other than that system libraries in macos should be read from --sysroot, so search path is good default there I think. On macos + nix, nix actually ships their own macos sdk, and their own xcbuild system. I was about to note about the absolute paths that prevent nix’s xcbuild system to be used by zig, but it seems it was already fixed https://github.com/ziglang/zig/commit/92b20e42162675d35ffd27845e66b0f9213c00c2 :+1:

When everything else fails, the final escape hatch for environments that don’t play nice with pkg-config is often LDLIBS, LDFLAGS, CFLAGS and friends…

3 Likes

Another one to the shame list:

1 Like