Native Android (Bionic) Build on Termux with Zig 0.15.2

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:

  1. Compile to object with b.addObject() and .pic = true — this avoids Zig’s libc check entirely
  2. Link manually with ld.lld against /system/lib64
  3. Use NDK Clang’s compiler_rt (not Zig’s cache — the cache version is non-PIC and causes R_AARCH64_ABS64 errors)
  4. Run termux-elf-cleaner at 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.getenv is gone, use std.posix.getenv
  • b.dupe() no longer returns an error union — remove the catch
  • openDirAbsolute result must be captured as |tmp| then var dir = tmp
  • Dir and iterators must be var, not const
  • .pic = true is mandatory for Android PIE — without it you get R_AARCH64_ABS64 relocation errors
  • Use NDK Clang’s libclang_rt.builtins-aarch64-android.a, not libcompiler_rt.a from 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 :ghost:

6 Likes

you didn’t mention it, so I am curious if you tried artifact.setLibcFile(b.path("to/file/describing/libc")

the file is in the same format you get from zig libc, it describes where to find all the things zig needs to link to a libc.

Yes, I tried exe.setLibCFile() early on! Created a libc.txt pointing to Termux’s Bionic paths:

include_dir=/data/data/com.termux/files/usr/include
sys_include_dir=/data/data/com.termux/files/usr/include
crt_dir=/data/data/com.termux/files/usr/lib

Unfortunately, Zig 0.15.2 still internally injects -lc -lm -ldl flags and fails with “unable to provide libc for target”. Even with link_libc = false in the module, the flags persist. That’s what ultimately forced the manual ld.lld approach.

If you know a way to truly disable those flags, I’m all ears! :ghost:

1 Like

Update - Apr 13, 2026

No one asked for this update, but I’ve been using it every day anyway. Here’s a nicer version. Still no official Bionic support from Zig — we wait.

Changes:

  • auto-detects arch via uname -m and picks the right compiler_rt, linker, and lib dir for aarch64, x86_64, arm, and i686
  • adds a tiny per-arch cache for the compiler_rt path, so the first build scans /lib/clang once, later builds reuse it
  • on my Termux device this dropped incremental builds from ~20s to ~0.7s

No behavior change otherwise, still needs termux-elf-cleaner.

Verification:

$ zig build
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 in 'zig-out/bin/server'

Full updated 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 arch_info = detectArch(b) catch @panic("failed to detect arch");
    defer b.allocator.free(arch_info.triple);
    defer b.allocator.free(arch_info.rt_name);
    defer b.allocator.free(arch_info.linker);
    defer b.allocator.free(arch_info.lib_dir);
    defer b.allocator.free(arch_info.lld_emul);

    const target = b.resolveTargetQuery(.{
       .cpu_arch = arch_info.cpu_arch,
       .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,
        }),
    });
    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);

    const rt_path = getCachedRtPath(b, prefix, arch_info.rt_name, arch_info.triple) catch @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", arch_info.lld_emul, "-pie",
        "-dynamic-linker", arch_info.linker,
        "-o", out,
        b.fmt("{s}/lib/crtbegin_dynamic.o",.{prefix}),
    });
    link.addFileArg(obj.getEmittedBin());
    link.addArgs(&.{ b.fmt("-L{s}",.{arch_info.lib_dir}), 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.default_step.dependOn(install);
}

const ArchInfo = struct {
    cpu_arch: std.Target.Cpu.Arch,
    triple: []const u8,
    rt_name: []const u8,
    linker: []const u8,
    lib_dir: []const u8,
    lld_emul: []const u8,
};

fn detectArch(b: *std.Build)!ArchInfo {
    const result = b.run(&.{ "uname", "-m" });
    const arch = std.mem.trim(u8, result, " \n\r\t");

    if (std.mem.eql(u8, arch, "aarch64")) {
        return.{
           .cpu_arch =.aarch64,
           .triple = try b.allocator.dupe(u8, "aarch64"),
           .rt_name = try b.allocator.dupe(u8, "libclang_rt.builtins-aarch64-android.a"),
           .linker = try b.allocator.dupe(u8, "/system/bin/linker64"),
           .lib_dir = try b.allocator.dupe(u8, "/system/lib64"),
           .lld_emul = try b.allocator.dupe(u8, "aarch64linux"),
        };
    } else if (std.mem.eql(u8, arch, "x86_64")) {
        return.{
           .cpu_arch =.x86_64,
           .triple = try b.allocator.dupe(u8, "x86_64"),
           .rt_name = try b.allocator.dupe(u8, "libclang_rt.builtins-x86_64-android.a"),
           .linker = try b.allocator.dupe(u8, "/system/bin/linker64"),
           .lib_dir = try b.allocator.dupe(u8, "/system/lib64"),
           .lld_emul = try b.allocator.dupe(u8, "elf_x86_64"),
        };
    } else if (std.mem.eql(u8, arch, "i686") or std.mem.eql(u8, arch, "i386")) {
        return.{
           .cpu_arch =.x86,
           .triple = try b.allocator.dupe(u8, "i686"),
           .rt_name = try b.allocator.dupe(u8, "libclang_rt.builtins-i686-android.a"),
           .linker = try b.allocator.dupe(u8, "/system/bin/linker"),
           .lib_dir = try b.allocator.dupe(u8, "/system/lib"),
           .lld_emul = try b.allocator.dupe(u8, "elf_i386"),
        };
    } else if (std.mem.startsWith(u8, arch, "arm")) {
        return.{
           .cpu_arch =.arm,
           .triple = try b.allocator.dupe(u8, "arm"),
           .rt_name = try b.allocator.dupe(u8, "libclang_rt.builtins-arm-android.a"),
           .linker = try b.allocator.dupe(u8, "/system/bin/linker"),
           .lib_dir = try b.allocator.dupe(u8, "/system/lib"),
           .lld_emul = try b.allocator.dupe(u8, "armelf_linux_eabi"),
        };
    } else {
        std.debug.print("Unknown arch '{s}', falling back to aarch64\n",.{arch});
        return.{
           .cpu_arch =.aarch64,
           .triple = try b.allocator.dupe(u8, "aarch64"),
           .rt_name = try b.allocator.dupe(u8, "libclang_rt.builtins-aarch64-android.a"),
           .linker = try b.allocator.dupe(u8, "/system/bin/linker64"),
           .lib_dir = try b.allocator.dupe(u8, "/system/lib64"),
           .lld_emul = try b.allocator.dupe(u8, "aarch64linux"),
        };
    }
}

fn getCachedRtPath(b: *std.Build, prefix: []const u8, rt_name: []const u8, arch: []const u8)![]u8 {
    const cache_dir = "zig-cache";
    const cache_file = b.fmt("{s}/rt_path_{s}.txt",.{ cache_dir, arch });
    std.fs.cwd().makePath(cache_dir) catch {};

    if (std.fs.cwd().readFileAlloc(b.allocator, cache_file, 1024)) |cached| {
        defer b.allocator.free(cached);
        const path = std.mem.trim(u8, cached, " \n\r\t");
        std.fs.accessAbsolute(path,.{}) catch {
            return scanRtPath(b, prefix, rt_name, cache_file);
        };
        std.debug.print("CACHE HIT [{s}]: {s}\n",.{ arch, path });
        return b.allocator.dupe(u8, path);
    } else |_| {
        return scanRtPath(b, prefix, rt_name, cache_file);
    }
}

fn scanRtPath(b: *std.Build, prefix: []const u8, rt_name: []const u8, cache_file: []const u8)![]u8 {
    var best_path:?[]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/{s}",.{ clang_base, entry.name, rt_name });
            std.fs.accessAbsolute(cand,.{}) catch continue;
            if (ver > best_ver) {
                best_ver = ver;
                if (best_path) |old| b.allocator.free(old);
                best_path = try b.allocator.dupe(u8, cand);
            }
        }
    } else |_| {}

    const path = best_path orelse return error.NotFound;
    std.fs.cwd().writeFile(.{.sub_path = cache_file,.data = path }) catch {};
    std.debug.print("using compiler_rt: {s}\n",.{path});
    return path;
}