December 31, 2025 ⢠Mason Remaley ⢠techzig

I recently helped Tuple solve an interesting problem. Theyâre working on a Linux version of their pair programming tool, and they want to make it a fully static executable so that they can ship the same build everywhere.
Alright no big deal, just link any dependencies statically and use musl. Easy, right?
Unfortunately, itâs not quite that easy. Tuple needs to talk to PipeWire for screen capture, and PipeWire makes heavy use of [dlopen](https://wwwâŚ.
December 31, 2025 ⢠Mason Remaley ⢠techzig

I recently helped Tuple solve an interesting problem. Theyâre working on a Linux version of their pair programming tool, and they want to make it a fully static executable so that they can ship the same build everywhere.
Alright no big deal, just link any dependencies statically and use musl. Easy, right?
Unfortunately, itâs not quite that easy. Tuple needs to talk to PipeWire for screen capture, and PipeWire makes heavy use of dlopen, so statically linking it isnât an optionâŚor is it?
Iâve open sourced a build.zig configuration that generates a static build of PipeWire here. The rest of this post elaborates on why this is challenging, why solving it is possible, and how my solution works.
Quick Disclaimer
The build works, and you can use it today! However, depending on your use case, you may need to add more plugins to the library table. See the README for more info.
Table of Contents
The Challenge

To talk to your systemâs PipeWire daemon, youâre expected to speak PipeWireâs protocol. The most straightforward way to do this is to link with their shared library which implements the protocol.
We donât want to do this, because that introduces a dependency on the systemâs dynamic linker path, and the dynamic linker path can vary across distros.
Thereâs nothing fundamental about the PipeWire client libraryâs goals that require dynamic linking, but unfortunately for us, it was implemented assuming a dynamic linker would always be present. In particular, its implementation includes two separate plugin systems that implement much of the core functionality:
Both of these plugin systems use dlopen to dynamically load shared libraries at runtime. All of the plugins are provided by PipeWire itself, so they donât need to be in separate shared libraries, but they are, and the implementation relies on this in various ways and thereâs quite a bit of code here so we donât want to try to rework it manually.
Running the video-play example requires loading two Spa Plugins, and six PipeWire Modules. The audio-src example contributed by jmstevers (thanks!) is similar. In practice these are a small fraction of the plugins and modules available, and as you use more of PipeWireâs functionality youâll need more of the plugins.
This leaves us in a difficult situation. We could sidestep all of this by writing our own PipeWire clientâand we may do that eventuallyâbut in the short term, is there any way to leverage the existing code to get a quicker solution in the hands of users?
Well, you probably guessed from the title, there is.
How I Built PipeWire Statically
Build System
Letâs raise the stakes a little higher. Not only do we want to build PipeWire statically. We want to build PipeWire statically without editing the upstream code. If we can pull this off, itâll be much easier to maintain, since we wonât have to maintain a forkâjust some configuration.
To do this, weâre going to use Zigâs build system. Zigâs build system is well suited to building C code. Our build will produce both a Zig module, and a .a paired with some headers in case you want to use the result from a non-Zig based application.
Since we arenât modifying the upstream source, we wonât copy it into our source tree. Weâll just fetch it on first build by adding it to our build.zig.zon:
.dependencies = .{
.upstream = .{
.url = "git+https://gitlab.freedesktop.org/pipewire/pipewire.git#1.5.81",
.hash = "N-V-__8AAKYw2AD301ZQsWszbYSWZQF5y-q4WXJif0UGRvFh",
},
}
This has the added benefits of making our build easy to audit, and making it easy to upstream any patches we make to Pipewire without having to first remove our build config.
High Level Approach

At the end of the day, PipeWire can only do anything dynamic by talking to the system itâs running on through a well defined API. We donât need to change the internal guts of PipeWire, we just need to give it an alternate implementation of that API that never calls into the dynamic linker.
In particular, we need to replace the following functions provided by dlfcn.h:
dlopendlclosedlsymdlerrordlinfo
If we can reimplement these functions in a way that doesnât require a dynamic linker, then nothing should be able to stop us from building PipeWire statically!
Building A Fake Dynamic Linker
No more of this!
Hereâs our strategy. For exampleâs sake, assume we have a C library that tries to load âmylib.soâ and âotherlib.soâ at runtime. Once it loads them, itâll try to access the symbols âfooâ, âbarâ and âbaz.â
First, letâs define our own fake dynamic library type that maps symbol names to opaque pointers:
pub const Lib = struct {
symbols: std.StaticStringMap(?*anyopaque),
};
Next, letâs create a table of available libraries and fill it with our data:
pub const libs: std.StaticStringMap(Lib) = .initComptime(.{
.{
"mylib.so",
Lib{
.symbols = .initComptime(.{
.{ "foo", @ptrCast(@constCast(&foo)) },
.{ "bar", @ptrCast(@constCast(&bar)) },
}),
},
},
.{
"otherlib.so",
Lib{
.symbols = .initComptime(.{
.{ "baz", @ptrCast(@constCast(&baz)) },
}),
},
},
};
Next letâs implement dlopen. dlopen takes a path, and returns either a pointer sized opaque handle to the library at that path, or null if it doesnât exist.
There are some other details weâre ignoringâweâll come back and make our implementation more conformant once the basics work.
Since our opaque handle could be anything, letâs make it a pointer to our library object:
fn dlopen(path: [*:0]const u8, mode: std.c.RTLD) ?*anyopaque {
const span = std.mem.span(path);
const lib = if (libs.getIndex(span)) |index|
&libs.kvs.values[index] else null;
return @ptrCast(@constCast(lib));
}
Now that dlopen does something potentially useful, letâs try to implement dlsym. dlsym is supposed to take a library handle and symbol name, and return a pointer to the given symbol. Again, weâll do a pass later to make this more conformant, but hereâs the basic version:
fn dlsym(
noalias handle: ?*anyopaque,
noalias name: [*:0]u8,
) ?*anyopaque {
const lib: *const Lib = @ptrCast(@alignCast(handle.?));
const span = std.mem.span(name);
return lib.symbols.get(span);
}
dlclose is allowed to do nothing, and in our case, thereâs nothing to do. Easy. Weâre going to ignore dlerror and dlinfo for now.
This should be good enough for a first pass, but how do we get the C library to actually call our methods instead of those provided by libc?
First, letâs rename our methods, specify a calling convention, and export them:
pub export fn __wrap_dlopen(
path: [*:0]const u8,
mode: std.c.RTLD,
) callconv(.c) ?*anyopaque {
// ...
}
pub export fn __wrap_dlsym(
noalias handle: ?*anyopaque,
noalias name: [*:0]u8,
) callconv(.c) ?*anyopaque {
// ...
}
// ... others follow ...
Next, weâre going to use every C programmerâs favorite trick: the preprocessor. We just redfine every reference to these methods to point to our wrappers.
const flags: []const []const u8 = &.{
"-Ddlopen=__wrap_dlopen",
"-Ddlclose=__wrap_dlclose",
"-Ddlsym=__wrap_dlsym",
"-Ddlerror=__wrap_dlerror",
"-Ddlinfo=__wrap_dlinfo",
};
And thatâs it, now our C library will call into our fake dlopen API instead of the real one!
Making Our Fake Linker More Conformant
This all might feel like a bit of a hack, but remember, thereâs no way for Pipewire to know whether or not our linker is ârealâ. As long as we follow the spec for the methods weâre replacing, weâre good.
However, weâve ignored some things in the spec up until this point. Letâs fix that up now. If you donât care about the details of dlopen/dlsym, skip this section, the main ideas donât change.
First off, youâre allowed to pass a null path to dlopen. When you do this, youâre supposed to get a handle to the main program. Weâll handle this case by creating a constant Lib.main_program_name and then adding a new library of that name to our symbol table. Then, in dlopen, we return that library when the path is null. Weâll also add some debug logs for good measure.
Weâll continue to ignore the mode flags, none of these matter to us.
pub export fn __wrap_dlopen(
path: ?[*:0]const u8,
mode: std.c.RTLD,
) callconv(.c) ?*anyopaque {
const span = if (path) |p| std.mem.span(p) else Lib.main_program_name;
const lib = if (libs.getIndex(span)) |index|
&libs.kvs.values[index]
else
null;
log.debug("dlopen(\"{f}\", {f}) -> {?f}", .{
std.zig.fmtString(span),
fmtFlags(mode),
lib,
});
return @ptrCast(@constCast(lib));
}
Next, dlsym is allowed to accept two special handles:
RTLD_DEFAULTRTLD_NEXTRTLD_SELF
Pipewire doesnât use RTLD_DEFAULT/RTLD_SELF so weâre just going to panic if theyâre passed in, but it does make use of RTLD_NEXT. You can read the spec for what this feature does here, weâre going to just add another library to our table for it.
Lastly, null is a valid value for a symbol, so we need to also set an error flag when we fail to find a symbol so that itâs possible to disambiguate these cases.
Hereâs our improved dlsym:
pub export fn __wrap_dlsym(
noalias handle: ?*anyopaque,
noalias name: [*:0]u8,
) callconv(.c) ?*anyopaque {
const lib: *const Lib = if (handle == c.RTLD_DEFAULT)
@panic("unimplemented")
else if (@hasDecl(c, "RTLD_SELF") and handle == c.RTLD_SELF)
@panic("unimplemented")
else if (handle == c.RTLD_NEXT)
&libs.get(Lib.rtld_next_name).?
else
@ptrCast(@alignCast(handle.?));
const span = std.mem.span(name);
var msg: ?[:0]const u8 = null;
const symbol = lib.symbols.get(span) orelse b: {
msg = "symbol not found";
break :b null;
};
log.debug("dlsym({f}, \"{f}\") -> 0x{x} ({s})", .{
lib,
std.zig.fmtString(span),
@intFromPtr(symbol),
if (msg) |m| m else "success",
});
if (msg) |m| err = m;
return symbol;
}
I also wired up dlinfo to panic since PipeWire doesnât use this feature. You can see the full implementation at the time of writing here.
Wiring Up Our Fake Linker To PipeWire
Now that we have a fake dynamic linker, we just need to wire it up to PipeWire by adding the real PipeWire symbols to our table!
Unfortunately, we immediately hit an issue. Every Spa Plugin defines spa_handle_factory_enum and spa_log_topic_enum, and every PipeWire module defines pipewire__module_init. This is gonna result in a lot of duplicate symbol errors if we try to statically link all of these implementations.
No worries, weâll just use the preprocessor again, this time to namespace these symbols. For example, libpipewire-module-protocol-native.soâs pipewire__module_init becomes pipewire_module_protocol_native__pipewire__module_init.
If we do this, weâll find that PipeWire is still failing to load its modules and plugins. What gives?
Well, it turns out that PipeWire is checking for the existance of the shared libraries with stat before calling dlopen. Iâm not sure why itâs doing this, this check seems redundant, but we can easily work around it by wrapping stat the same way we wrapped dlopen.
Our wrapper will look for the given path in our symbol table. If it finds it, it will write to statbuf indicating that the file exists. Otherwise it will forward the call to the real stat:
pub export fn __wrap_stat(
noalias pathname_c: [*:0]const u8,
noalias statbuf: *std.c.Stat,
) callconv(.c) c_int {
const pathname = std.mem.span(pathname_c);
const result, const strategy = b: {
if (dlfcn.libs.get(pathname) != null) {
statbuf.* = std.mem.zeroInit(std.c.Stat, .{
.mode = std.c.S.IFREG,
});
break :b .{ 0, "faked" };
} else {
break :b .{ std.c.stat(pathname_c, statbuf), "real" };
}
};
log.debug("stat(\"{f}\", {*}) -> {} (statbuf.* == {f}) ({s})", .{
std.zig.fmtString(pathname),
statbuf,
result,
fmtFlags(statbuf.*),
strategy,
});
return result;
}
While weâre here, itâs not strictly necessary, but we can also wrap fstat mmap and mmunmap to trick PipeWire into reading the client config from an embedded file instead of searching around on the filesystem for it.
And thatâs it! We now have a working fully statically linked build of Pipewire.
> ldd video-play
not a dynamic executable
Closing Thoughts

The power of interfaces is that they can be replaced.
According to cloc PipeWire is 500k+ lines of code. Iâm confident none of those lines were written with our use case in mind. However, we were still able to satisfy our requirements, because at the end of the day PipeWireâlike almost all other softwareâhas to eventually call into a well defined API.
Donât underestimate the power of interfaces!