After way too many failed builds, I finally got a native Android binary — dynamically linked against Bionic libc — compiled directly on Termux using Zig 0.15.2, without musl.
Posting this because I couldn’t find a working solution anywhere, and I wasted a lot of time on dead ends.
Disclaimer: This is a workaround, not official support. It will break when Zig ships proper Bionic support (#23906). Use for on-device experiments only.
The Problem
Zig 0.15.2 doesn’t ship prebuilt Bionic libc. Any attempt to build an executable gives:
error: unable to provide libc for target 'aarch64-linux.5.10...6.16-android.34'
The frustrating part: even link_libc = false doesn’t help. Zig still injects -lc -lm -ldl internally for Android targets, and there’s no official API to stop it. zig cc -nostdlib also fails the internal target check.
The only way out is to bypass Zig’s linker entirely.
What Finally Worked
The trick is to split compilation and linking into two separate steps:
- Compile to object with
b.addObject()and.pic = true— this avoids Zig’s libc check entirely - Link manually with
ld.lldagainst/system/lib64 - Use NDK Clang’s
compiler_rt(not Zig’s cache — the cache version is non-PIC and causesR_AARCH64_ABS64errors) - Run
termux-elf-cleanerat the end — without this the binary crashes on Android 14 with a TLS alignment error
The build.zig scans $PREFIX/lib/clang/ for the highest installed Clang version to find compiler_rt dynamically, so it doesn’t break when Termux updates packages.
build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const prefix = std.posix.getenv("PREFIX") orelse "/data/data/com.termux/files/usr";
const is_termux = std.posix.getenv("TERMUX_VERSION") != null;
if (!is_termux) {
const exe = b.addExecutable(.{
.name = "server",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = b.standardTargetOptions(.{}),
.optimize = b.standardOptimizeOption(.{}),
}),
});
b.installArtifact(exe);
return;
}
const target = b.resolveTargetQuery(.{
.cpu_arch = .aarch64,
.os_tag = .linux,
.abi = .android,
.android_api_level = 34,
});
const optimize = b.standardOptimizeOption(.{});
const obj = b.addObject(.{
.name = "server",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.link_libc = false,
.pic = true, // required for PIE on Android
}),
});
obj.root_module.addImport("websocket", b.dependency("websocket", .{
.target = target, .optimize = optimize,
}).module("websocket"));
obj.addIncludePath(.{ .cwd_relative = b.fmt("{s}/include", .{prefix}) });
const out = "zig-out/bin/server";
const mkdir = b.addSystemCommand(&.{ "mkdir", "-p", "zig-out/bin" });
mkdir.step.dependOn(&obj.step);
// Find compiler_rt from highest installed Clang version
const rt_path = blk: {
var best_path: ?[]const u8 = null;
var best_ver: u32 = 0;
const clang_base = b.fmt("{s}/lib/clang", .{prefix});
if (std.fs.openDirAbsolute(clang_base, .{ .iterate = true })) |tmp| {
var dir = tmp;
defer dir.close();
var it = dir.iterate();
while (it.next() catch null) |entry| {
if (entry.kind != .directory) continue;
const ver = std.fmt.parseInt(u32, entry.name, 10) catch continue;
const cand = b.fmt("{s}/{s}/lib/linux/libclang_rt.builtins-aarch64-android.a", .{ clang_base, entry.name });
std.fs.accessAbsolute(cand, .{}) catch continue;
if (ver > best_ver) {
best_ver = ver;
if (best_path) |old| b.allocator.free(old);
best_path = b.dupe(cand);
}
}
} else |_| {}
if (best_path) |p| break :blk p;
@panic("compiler_rt not found - install ndk-sysroot");
};
defer b.allocator.free(rt_path);
const link = b.addSystemCommand(&.{
"ld.lld", "-EL", "-z", "now", "-z", "relro", "-z", "max-page-size=16384",
"--pack-dyn-relocs=android+relr", "--hash-style=gnu", "--eh-frame-hdr",
"-m", "aarch64linux", "-pie",
"-dynamic-linker", "/system/bin/linker64",
"-o", out,
b.fmt("{s}/lib/crtbegin_dynamic.o", .{prefix}),
});
link.addFileArg(obj.getEmittedBin());
link.addArgs(&.{ "-L/system/lib64", b.fmt("-L{s}/lib", .{prefix}) });
link.addArgs(&.{ "-lc", "-lm", "-ldl" });
link.addArg(rt_path);
link.addArg(b.fmt("{s}/lib/crtend_android.o", .{prefix}));
link.step.dependOn(&mkdir.step);
const cleaner = b.addSystemCommand(&.{ "termux-elf-cleaner", out });
cleaner.step.dependOn(&link.step);
const install = b.step("install-server", "Build for Termux");
install.dependOn(&cleaner.step);
b.step("run", "").dependOn(install);
}
Verification
$ zig build install-server
using compiler_rt: /data/data/com.termux/files/usr/lib/clang/21/lib/linux/libclang_rt.builtins-aarch64-android.a
termux-elf-cleaner: Changing TLS alignment for 'zig-out/bin/server' to 64, instead of 8
termux-elf-cleaner: Replacing unsupported DF_1_* flags 134217729 with 1
$ ./zig-out/bin/server
[INFO] WebSocket server on ws://0.0.0.0:8080/ws
[INFO] Game loop started (12.5 ticks/sec)
readelf -d confirms NEEDED libc.so, NEEDED libdl.so — proper dynamic linking against Bionic.
A Few Things That Tripped Me Up (Zig 0.15.2)
std.process.getenvis gone, usestd.posix.getenvb.dupe()no longer returns an error union — remove thecatchopenDirAbsoluteresult must be captured as|tmp|thenvar dir = tmpDirand iterators must bevar, notconst.pic = trueis mandatory for Android PIE — without it you getR_AARCH64_ABS64relocation errors- Use NDK Clang’s
libclang_rt.builtins-aarch64-android.a, notlibcompiler_rt.afrom Zig’s cache (it’s non-PIC)
Hopefully this saves someone else a long night. Waiting for proper Bionic support in a future Zig release.
Happy hacking ![]()