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:

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

2 Likes

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;
}
2 Likes

UPDATE Apr 14 - Termux full LLVM build

Fixed the panic from Apr 13. Changes:

  • rename custom install step to install-bin
  • use llvm-strip instead of GNU strip
  • robust compiler_rt caching
  • full arch detection

tested on Zig 0.15.2, clang 21.1.8, Android API 34 (Termux)

Verification:

$ zig build run
→ Android API 34 | git:b4e6b98
using compiler_rt: /data/.../libclang_rt.builtins-aarch64-android.a
CACHE HIT :...
termux-elf-cleaner: Changing TLS alignment...
[16:27:00] INFO(default): Game loaded from save.bin
[16:27:00] INFO(default): WebSocket server on ws://0.0.0.0:8080/ws

Full 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 api_level = detectApiLevel(b);
    const optimize = b.standardOptimizeOption(.{});
    const build_all = b.option(bool, "all", "Build for all Android architectures") orelse false;
    const git_hash = getGitHash(b);

    std.debug.print("→ Android API {d} | git:{s}\n", .{ api_level, git_hash });

    const opts = b.addOptions();
    opts.addOption([]const u8, "git_hash", git_hash);
    opts.addOption(u32, "android_api", api_level);

    if (build_all) {
        const all_step = b.step("build-all", "Build for aarch64, x86_64, arm, i686");
        const arches = [_]std.Target.Cpu.Arch{ .aarch64, .x86_64, .arm, .x86 };
        for (arches) |cpu| {
            const info = archInfo(cpu);
            const step = buildOne(b, prefix, info, api_level, optimize, true, opts);
            all_step.dependOn(step);
        }
        b.default_step.dependOn(all_step);
        return;
    }

    const info = detectArch(b) catch @panic("arch detect failed");
    const final_step = buildOne(b, prefix, info, api_level, optimize, false, opts);
    b.default_step.dependOn(final_step);

    const run_cmd = b.addSystemCommand(&.{ "./zig-out/bin/server" });
    run_cmd.step.dependOn(final_step);
    const run_step = b.step("run", "Build and run");
    run_step.dependOn(&run_cmd.step);

    const watch_cmd = b.addSystemCommand(&.{ "sh", "-c", "while inotifywait -qq -r -e modify,close_write,create,delete src build.zig 2>/dev/null; do clear; echo '=== rebuild ==='; zig build run; done" });
    const watch_step = b.step("watch", "Watch src/ and auto-run");
    watch_step.dependOn(&watch_cmd.step);

    const rel_step = buildOne(b, prefix, info, api_level, .ReleaseSmall, false, opts);
    const strip_cmd = b.addSystemCommand(&.{ "llvm-strip", "zig-out/bin/server" });
    strip_cmd.step.dependOn(rel_step);
    const size_cmd = b.addSystemCommand(&.{ "sh", "-c", "du -h zig-out/bin/server" });
    size_cmd.step.dependOn(&strip_cmd.step);
    const release_step = b.step("release", "ReleaseSmall + strip");
    release_step.dependOn(&size_cmd.step);

    const install_cmd = b.addSystemCommand(&.{ "cp", "-f", "zig-out/bin/server", b.fmt("{s}/bin/server", .{prefix}) });
    install_cmd.step.dependOn(final_step);
    const install_step = b.step("install-bin", "Install to $PREFIX/bin");
    install_step.dependOn(&install_cmd.step);

    const deps_cmd = b.addSystemCommand(&.{ "sh", "-c", "pkg list-installed 2>/dev/null | grep -q '^clang/' || pkg install -y clang lld ndk-sysroot termux-elf-cleaner inotify-tools" });
    const deps_step = b.step("deps", "Install Termux deps");
    deps_step.dependOn(&deps_cmd.step);

    const clean_cache = b.addSystemCommand(&.{ "rm", "-rf", "zig-cache" });
    b.step("clean-cache", "Clear rt cache").dependOn(&clean_cache.step);
    const clean_all = b.addSystemCommand(&.{ "rm", "-rf", "zig-out", "zig-cache" });
    b.step("clean-all", "Nuke everything").dependOn(&clean_all.step);
}

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 archInfo(cpu: std.Target.Cpu.Arch) ArchInfo {
    return switch (cpu) {
        .aarch64 => .{ .cpu_arch = .aarch64, .triple = "aarch64", .rt_name = "libclang_rt.builtins-aarch64-android.a", .linker = "/system/bin/linker64", .lib_dir = "/system/lib64", .lld_emul = "aarch64linux" },
        .x86_64 => .{ .cpu_arch = .x86_64, .triple = "x86_64", .rt_name = "libclang_rt.builtins-x86_64-android.a", .linker = "/system/bin/linker64", .lib_dir = "/system/lib64", .lld_emul = "elf_x86_64" },
        .x86 => .{ .cpu_arch = .x86, .triple = "i686", .rt_name = "libclang_rt.builtins-i686-android.a", .linker = "/system/bin/linker", .lib_dir = "/system/lib", .lld_emul = "elf_i386" },
        .arm => .{ .cpu_arch = .arm, .triple = "arm", .rt_name = "libclang_rt.builtins-arm-android.a", .linker = "/system/bin/linker", .lib_dir = "/system/lib", .lld_emul = "armelf_linux_eabi" },
        else => @panic("unsupported"),
    };
}

fn detectArch(b: *std.Build) !ArchInfo {
    const out = b.run(&.{ "uname", "-m" });
    const arch = std.mem.trim(u8, out, " \n\r\t");
    if (std.mem.eql(u8, arch, "aarch64")) return archInfo(.aarch64);
    if (std.mem.eql(u8, arch, "x86_64")) return archInfo(.x86_64);
    if (std.mem.eql(u8, arch, "x86") or std.mem.eql(u8, arch, "i386") or std.mem.eql(u8, arch, "i686")) return archInfo(.x86);
    if (std.mem.startsWith(u8, arch, "arm")) return archInfo(.arm);
    return archInfo(.aarch64);
}

fn detectApiLevel(b: *std.Build) u32 {
    const r = std.process.Child.run(.{
        .allocator = b.allocator,
        .argv = &.{ "getprop", "ro.build.version.sdk" },
    }) catch return 34;
    defer b.allocator.free(r.stdout);
    defer b.allocator.free(r.stderr);
    const s = std.mem.trim(u8, r.stdout, " \n\r\t");
    return std.fmt.parseInt(u32, s, 10) catch 34;
}

fn getGitHash(b: *std.Build) []const u8 {
    const r = std.process.Child.run(.{
        .allocator = b.allocator,
        .argv = &.{ "git", "rev-parse", "--short", "HEAD" },
    }) catch return "nogit";
    defer b.allocator.free(r.stdout);
    defer b.allocator.free(r.stderr);
    const h = std.mem.trim(u8, r.stdout, " \n\r\t");
    return b.allocator.dupe(u8, h) catch "nogit";
}

fn buildOne(b: *std.Build, prefix: []const u8, info: ArchInfo, api: u32, optimize: std.builtin.OptimizeMode, use_subdir: bool, opts: *std.Build.Step.Options) *std.Build.Step {
    const target = b.resolveTargetQuery(.{
        .cpu_arch = info.cpu_arch,
        .os_tag = .linux,
        .abi = .android,
        .android_api_level = api,
    });

    const mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = false,
        .pic = true,
    });
    mod.addOptions("build_info", opts);

    const obj = b.addObject(.{
        .name = b.fmt("server-{s}", .{info.triple}),
        .root_module = mod,
    });
    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_dir = if (use_subdir) b.fmt("zig-out/{s}/bin", .{info.triple}) else "zig-out/bin";
    const out = b.fmt("{s}/server", .{out_dir});

    const mkdir = b.addSystemCommand(&.{ "mkdir", "-p", out_dir });
    mkdir.step.dependOn(&obj.step);

    const rt_path = getCachedRtPath(b, prefix, info.rt_name, info.triple) catch {
        std.debug.print("\nERROR: compiler_rt missing for {s}\nRun: pkg install clang lld\n", .{info.triple});
        @panic("cannot continue");
    };
    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", info.lld_emul, "-pie",
        "-dynamic-linker", info.linker,
        "-o", out,
        b.fmt("{s}/lib/crtbegin_dynamic.o", .{prefix}),
    });
    link.addFileArg(obj.getEmittedBin());
    link.addArgs(&.{ b.fmt("-L{s}", .{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);

    return &cleaner.step;
}

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_{s}.txt", .{ cache_dir, arch });
    std.fs.cwd().makePath(cache_dir) catch {};

    const clang_base = b.fmt("{s}/lib/clang", .{prefix});
    var current_max: u32 = 0;
    if (std.fs.openDirAbsolute(clang_base, .{ .iterate = true })) |tmp| {
        var dir = tmp;
        defer dir.close();
        var it = dir.iterate();
        while (it.next() catch null) |e| {
            if (e.kind != .directory) continue;
            const v = std.fmt.parseInt(u32, e.name, 10) catch continue;
            if (v > current_max) current_max = v;
        }
    } else |_| {}

    if (std.fs.cwd().readFileAlloc(b.allocator, cache_file, 1024)) |cached| {
        defer b.allocator.free(cached);
        var it = std.mem.splitScalar(u8, cached, '\n');
        const path = std.mem.trim(u8, it.first(), " \n\r\t");
        const ver = std.fmt.parseInt(u32, std.mem.trim(u8, it.next() orelse "0", " \n\r\t"), 10) catch 0;
        if (ver == current_max and ver != 0) {
            std.fs.accessAbsolute(path, .{}) catch return scanRt(b, prefix, rt_name, cache_file, current_max);
            std.debug.print("CACHE HIT [{s}]: {s}\n", .{ arch, path });
            return b.allocator.dupe(u8, path);
        }
    } else |_| {}
    return scanRt(b, prefix, rt_name, cache_file, current_max);
}

fn scanRt(b: *std.Build, prefix: []const u8, rt_name: []const u8, cache_file: []const u8, max_ver: u32) ![]u8 {
    var best: ?[]u8 = null;
    var best_ver: u32 = 0;
    const base = b.fmt("{s}/lib/clang", .{prefix});
    if (std.fs.openDirAbsolute(base, .{ .iterate = true })) |tmp| {
        var dir = tmp;
        defer dir.close();
        var it = dir.iterate();
        while (it.next() catch null) |e| {
            if (e.kind != .directory) continue;
            const v = std.fmt.parseInt(u32, e.name, 10) catch continue;
            const cand = b.fmt("{s}/{s}/lib/linux/{s}", .{ base, e.name, rt_name });
            std.fs.accessAbsolute(cand, .{}) catch continue;
            if (v > best_ver) {
                best_ver = v;
                if (best) |o| b.allocator.free(o);
                best = try b.allocator.dupe(u8, cand);
            }
        }
    } else |_| {}
    const path = best orelse return error.NotFound;
    const data = b.fmt("{s}\n{d}", .{ path, if (best_ver == 0) max_ver else best_ver });
    std.fs.cwd().writeFile(.{ .sub_path = cache_file, .data = data }) catch {};
    std.debug.print("using compiler_rt: {s}\n", .{path});
    return path;
}
1 Like

Quick update to my April 14 post

That version still worked, but it relied on termux-elf-cleaner and manual ld.lld flags. With Zig 0.15.2 I wanted something self-contained, so I rewrote the build to do the fix inside Zig.

The only real change is a tiny FixElf step that runs after linking. It just opens the binary and patches the PT_GNU_RELRO alignment to 64, which Android Bionic needs. No external tools anymore.

just works, and the output is way smaller too:

  • Debug: ~3.6 MB
  • ReleaseFast: ~221 KB
  • ReleaseSmall: ~130 KB

Full build.zig I’m using now:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.resolveTargetQuery(.{
    .cpu_arch =.aarch64,
    .os_tag =.linux,
    .abi =.android,
    });
    const optimize = b.standardOptimizeOption(.{});
    const embed_self = b.option(bool, "embed-self", "Embed source tar into binary") orelse false;

    const opts = b.addOptions();
    opts.addOption([]const u8, "git", getGit(b));
    opts.addOption([]const u8, "date", "2026-04-14");
    opts.addOption([]const u8, "zig_version", @import("builtin").zig_version_string);

    const wf = b.addWriteFiles();
    _ = wf.addCopyFile(b.path("client/index.html"), "index.html");
    _ = wf.addCopyFile(b.path("client/style.css"), "style.css");

    const js_gz_cmd = b.addSystemCommand(&.{ "sh", "-c", "gzip -c9 client/app.js 2>/dev/null || cat client/app.js" });
    const js_gz = js_gz_cmd.captureStdOut();
    _ = wf.addCopyFile(js_gz, "app.js.gz");

    const assets_zig = wf.add("assets.zig",
        \\pub const index = @embedFile("index.html");
        \\pub const style = @embedFile("style.css");
        \\pub const app_js_gz = @embedFile("app.js.gz");
    );
    const assets_mod = b.addModule("assets",.{.root_source_file = assets_zig });

    const proto_zig = wf.add("protocol.zig",
        \\pub const Packet = packed struct { x: f32, y: f32, hp: u8, id: u16 };
        \\pub const SIZE = @sizeOf(Packet);
    );
    const proto_mod = b.addModule("proto",.{.root_source_file = proto_zig });

    const ws_dummy_build = wf.add("ws_build.zig", "");
    const ws_build_mod = b.addModule("build",.{.root_source_file = ws_dummy_build });
    const ws_mod = b.addModule("websocket",.{
   .root_source_file = b.path("libs/websocket/src/websocket.zig"),
    });
    ws_mod.addImport("build", ws_build_mod);

    const modes = [_]std.builtin.OptimizeMode{.Debug,.ReleaseSmall,.ReleaseFast };
    var exes: [3]*std.Build.Step.Compile = undefined;
    var fixes: [3]*FixElf = undefined;

    for (modes, 0..) |m, i| {
        const exe = b.addExecutable(.{
       .name = b.fmt("server-{s}",.{@tagName(m)}),
       .root_module = b.createModule(.{
           .root_source_file = b.path("src/main.zig"),
           .target = target,
           .optimize = m,
           .strip = m!=.Debug,
            }),
        });
        exe.pie = true;
        exe.root_module.addOptions("build_info", opts);
        exe.root_module.addImport("assets", assets_mod);
        exe.root_module.addImport("proto", proto_mod);
        exe.root_module.addImport("websocket", ws_mod);
        exe.step.dependOn(&wf.step);

        if (embed_self) {
            const tar = b.addSystemCommand(&.{ "sh", "-c", "tar czf - src build.zig" });
            const self_wf = b.addWriteFiles();
            _ = self_wf.addCopyFile(tar.captureStdOut(), "self.tgz");
            const self_zig = self_wf.add("self.zig", "pub const bundle = @embedFile(\"self.tgz\");\n");
            const self_mod = b.addModule("self",.{.root_source_file = self_zig });
            exe.root_module.addImport("self", self_mod);
            exe.step.dependOn(&self_wf.step);
        }

        const fix = FixElf.create(b, exe);
        fix.step.dependOn(&exe.step);
        b.getInstallStep().dependOn(&fix.step);
        b.installArtifact(exe);
        exes[i] = exe;
        fixes[i] = fix;

        if (m == optimize) {
            const install = b.addInstallFile(exe.getEmittedBin(), "bin/server");
            b.getInstallStep().dependOn(&install.step);
        }
    }

    const bench_step = b.step("bench", "Show sizes");
    for (exes, fixes) |exe, fix| {
        const show = b.addSystemCommand(&.{"ls","-lh"});
        show.addArtifactArg(exe);
        show.step.dependOn(&fix.step);
        bench_step.dependOn(&show.step);
    }
}

fn getGit(b: *std.Build) []const u8 {
    const child = std.process.Child.run(.{
   .allocator = b.allocator,
   .argv = &.{ "git", "rev-parse", "--short", "HEAD" },
    }) catch return "dev";
    defer b.allocator.free(child.stdout);
    defer b.allocator.free(child.stderr);
    return b.dupe(std.mem.trim(u8, child.stdout, " \r\n"));
}

const FixElf = struct {
    step: std.Build.Step,
    b: *std.Build,
    exe: *std.Build.Step.Compile,
    pub fn create(b: *std.Build, e: *std.Build.Step.Compile) *FixElf {
        const s = b.allocator.create(FixElf) catch @panic("oom");
        s.* =.{.step = std.Build.Step.init(.{.id =.custom,.name = "fix-elf",.owner = b,.makeFn = make}),.b = b,.exe = e};
        return s;
    }
    fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions)!void {
        const self: *FixElf = @fieldParentPtr("step", step);
        const path = self.exe.getEmittedBin().getPath(self.b);
        var f = try std.fs.cwd().openFile(path,.{.mode =.read_write }); defer f.close();
        var hdr: [64]u8 = undefined; _ = try f.preadAll(&hdr, 0); if (hdr[4]!=2) return;
        const phoff = std.mem.readInt(u64, hdr[32..40],.little);
        const phentsize = std.mem.readInt(u16, hdr[54..56],.little);
        const phnum = std.mem.readInt(u16, hdr[56..58],.little);
        var i: usize = 0; while (i < phnum) : (i += 1) { var ph: [56]u8 = undefined; _ = try f.preadAll(&ph, phoff + i * phentsize); if (std.mem.readInt(u32, ph[0..4],.little) == 7) { std.mem.writeInt(u64, ph[48..56], 64,.little); try f.pwriteAll(&ph, phoff + i * phentsize); break; } }
    }
};

Much cleaner than before, and it’s 100% Zig now. No more hunting for compiler_rt or running cleaner by hand. Thank God :sob:

3 Likes

Termux + Zig 0.15.2, simplified stable form

It’s basically a minimal Android build.zig, and I deleted almost everything.

The change

  • Removed multi-arch build system
  • Removed runtime probing and git/env logic
  • Removed asset pipeline and extra build orchestration
  • Kept single deterministic Android target + ELF fix only

Build.zig

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const resolved_target = if (target.query.os_tag == null)
        b.resolveTargetQuery(.{
            .cpu_arch = .aarch64,
            .os_tag = .linux,
            .abi = .android,
            .android_api_level = b.option(u32, "android-api", "Android API level") orelse 29,
        })
    else target;

    const exe = b.addExecutable(.{
        .name = "server",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = resolved_target,
            .optimize = optimize,
        }),
    });

    exe.pie = true;
    exe.root_module.link_libc = false;
    exe.root_module.strip = optimize != .Debug;
    exe.link_z_max_page_size = 16384;

    const build_options = b.addOptions();
    build_options.addOption(bool, "is_windows", false);
    build_options.addOption(bool, "is_darwin", false);
    build_options.addOption(bool, "is_linux", true);
    const build_mod = build_options.createModule();

    const websocket_mod = b.createModule(.{
        .root_source_file = b.path("libs/websocket/src/websocket.zig"),
        .target = resolved_target,
        .optimize = optimize,
    });

    websocket_mod.addImport("build", build_mod);
    exe.root_module.addImport("websocket", websocket_mod);

    b.installArtifact(exe);

    const fix = AndroidElfFix.create(b, exe);
    fix.step.dependOn(&exe.step);
    b.getInstallStep().dependOn(&fix.step);

    const check = b.step("check", "type check");
    check.dependOn(&exe.step);
}

const AndroidElfFix = struct {
    step: std.Build.Step,
    b: *std.Build,
    exe: *std.Build.Step.Compile,

    pub fn create(b: *std.Build, exe: *std.Build.Step.Compile) *AndroidElfFix {
        const self = b.allocator.create(AndroidElfFix) catch @panic("oom");
        self.* = .{
            .step = std.Build.Step.init(.{
                .id = .custom,
                .name = "fix elf",
                .owner = b,
                .makeFn = make,
            }),
            .b = b,
            .exe = exe,
        };
        return self;
    }

    fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void {
        const self: *AndroidElfFix = @fieldParentPtr("step", step);
        const path = self.exe.getEmittedBin().getPath(self.b);

        var file = try std.fs.cwd().openFile(path, .{ .mode = .read_write });
        defer file.close();

        var hdr: [64]u8 = undefined;
        _ = try file.preadAll(&hdr, 0);
        if (hdr[4] != 2) return;

        const phoff = std.mem.readInt(u64, hdr[32..40], .little);
        const phentsize = std.mem.readInt(u16, hdr[54..56], .little);
        const phnum = std.mem.readInt(u16, hdr[56..58], .little);

        var i: usize = 0;
        while (i < phnum) : (i += 1) {
            const off = phoff + i * phentsize;
            var ph: [56]u8 = undefined;
            _ = try file.preadAll(&ph, off);

            if (std.mem.readInt(u32, ph[0..4], .little) == 0x6474e552) {
                const old = std.mem.readInt(u64, ph[48..56], .little);
                if (old >= 16384) return;

                std.mem.writeInt(u64, ph[48..56], 16384, .little);
                try file.pwriteAll(&ph, off);
                return;
            }
        }
    }
};

Known issues:

  • #23906: Bionic libc not officially supported
  • #23813: dynamic linker skipped when link_libc = false
  • #20117: getcontext error when linking libc on Android
  • #31306: 16K page size should be automatic

Apr 14, 2026

1 Like

update: this is the end :joy_cat: turns out my kernel is 4.19 and zig 0.16.0 dropped support for anything below 5.10. staying on 0.15.2 until i either get a new phone or touch grass.

0.16.0 Release Notes

2 Likes

Providing a libc file actually worked for me, even with link_libc = true

Although i am doing this from linux and macos maybe something to do with it? Anyways here is the code(my build script is by no means perfect but it works): https://codeberg.org/MeKaLu/Kut/src/branch/master/build.zig

Take a look at Android.zig as well.

3 Likes

Thanks for sharing build.zig and Android.zig, that’s clean.

Now I get why link_libc = true worked for you. Generating a libc file on the fly pointing to NDK paths makes total sense. You’re basically handing Zig a full map of the toolchain so the linker doesn’t have to guess. Smart.

Also makes it obvious why our setups are completely different. Yours is the right way, cross-compiling from a real machine with the full NDK, building proper APKs and all. Mine’s a hacked together mess because I’m compiling directly on a phone in Termux with no space, no NDK, and a kernel from the stone age.

Just wanted to drop by and say your code is solid reference material. Learned a lot from it. Appreciate you putting it out there :folded_hands::sleeping_face:

2 Likes

me too. which means ‘zig build –watch’ crashes with ENOSYS