// Complete fix guide for legacy SDK .so files that crash on Pixel 8 with 16kb kernel (Android 16)
Google Play Store began rejecting APKs whose native .so files have ELF p_align < 0x4000, in preparation for mandatory 16kb page size support. As of Android 16, the android:pageSizeCompat="enabled" manifest flag can suppress the Play Store warning, but Google marks it as a temporary escape hatch with a hard deadline — the underlying libraries must be properly fixed.
Currently the only consumer device that can actually boot into a 16kb kernel is the Pixel 8 with OEM unlock enabled. The Pixel 9 and Pixel 10 cannot reboot into a 16kb kernel. However, the Play Store alignment requirement applies to all uploads regardless of target device.
Xamarin Android's legacy SDK (targeting .NET 6/7/8, maintained by Microsoft) ships four native libraries built with p_align=0x1000 (4kb). Microsoft's .NET 9 SDK fixes this, but requires a full runtime migration that is often not feasible for existing apps.
p_align to 0x4000 in the ELF program headers is necessary but not sufficient. At least 6 distinct problems must be fixed for the library to load and run correctly on a 16kb kernel.
The four affected libraries are:
libmono-android.release.so → renamed to libmonodroid.so by the VS buildlibxa-internal-api.solibxamarin-debug-app-helper.solibmonosgen-2.0.so — uses DT_HASH only, no DT_GNU_HASH, loads fine without changesEach stage of an incomplete fix produces a different error. Here they are in order:
The following Python script applies all six fixes. Run it on the original unpatched .so files extracted from your APK.
# APK is a ZIP — extract the four libs
unzip yourapp.apk \
lib/arm64-v8a/libmonodroid.so \
lib/arm64-v8a/libxa-internal-api.so \
lib/arm64-v8a/libxamarin-debug-app-helper.so \
-d extracted/
#!/usr/bin/env python3 """ fix_16kb.py — Patch Xamarin Android legacy SDK .so files for 16kb page kernels. Fixes all six ELF issues required to load on Android 16 / 16kb kernel devices. Usage: python3 fix_16kb.py <input.so> <output.so> Tested on: libmono-android.release.so (= libmonodroid.so in APK) libxa-internal-api.so libxamarin-debug-app-helper.so """ import struct, sys, os def patch64(buf, off, val): struct.pack_into('<Q', buf, off, val) def patch64s(buf, off, val): struct.pack_into('<q', buf, off, val) def read_phdrs(data): e_phoff = struct.unpack_from('<Q', data, 0x20)[0] e_phentsize = struct.unpack_from('<H', data, 0x36)[0] e_phnum = struct.unpack_from('<H', data, 0x38)[0] phdrs = [] for i in range(e_phnum): b = e_phoff + i * e_phentsize p_type = struct.unpack_from('<I', data, b)[0] p_flags = struct.unpack_from('<I', data, b+4)[0] p_offset = struct.unpack_from('<Q', data, b+8)[0] p_vaddr = struct.unpack_from('<Q', data, b+16)[0] p_filesz = struct.unpack_from('<Q', data, b+32)[0] p_memsz = struct.unpack_from('<Q', data, b+40)[0] p_align = struct.unpack_from('<Q', data, b+48)[0] phdrs.append((b, p_type, p_flags, p_offset, p_vaddr, p_filesz, p_memsz, p_align)) return phdrs def fix_so(path_in, path_out): with open(path_in, 'rb') as f: data = bytearray(f.read()) PAGE = 0x4000 # 16kb PT_LOAD = 1 PT_DYNAMIC = 2 SHT_DYNAMIC = 6 SHF_ALLOC = 0x2 R_AARCH64_RELATIVE = 1027 PTR_TAGS = {3,4,5,6,0x17,7,0x19,0x1a, 0x6ffffef5,0x6ffffffe,0x6ffffff0} # ── Step 1: read LOAD segments ────────────────────────────────────── phdrs = read_phdrs(data) loads = [(b,po,pv,pf,pm,pa) for b,pt,_,po,pv,pf,pm,pa in phdrs if pt == PT_LOAD] assert len(loads) == 3, "Expected 3 LOAD segments" # ── Step 2: compute required vaddr shifts ─────────────────────────── # LOAD[1] must start at page >= page_end(LOAD[0]) # Keep congruence: new_vaddr % align == p_offset % align def page_end(vaddr, filesz): return (vaddr + filesz + PAGE - 1) & ~(PAGE - 1) L0b, L0po, L0pv, L0pf, L0pm, L0pa = loads[0] L1b, L1po, L1pv, L1pf, L1pm, L1pa = loads[1] L2b, L2po, L2pv, L2pf, L2pm, L2pa = loads[2] l0_page_end = page_end(L0pv, L0pf) # Find smallest valid vaddr for LOAD[1] >= l0_page_end with same congruence cong1 = L1po % L1pa new_L1pv = l0_page_end if new_L1pv % L1pa != cong1: new_L1pv = (new_L1pv & ~(L1pa-1)) + cong1 if new_L1pv < l0_page_end: new_L1pv += L1pa S1 = new_L1pv - L1pv # LOAD[2]: must start at page >= page_end(LOAD[1] new) l1_new_page_end = page_end(new_L1pv, L1pf) cong2 = L2po % L2pa new_L2pv = l1_new_page_end if new_L2pv % L2pa != cong2: new_L2pv = (new_L2pv & ~(L2pa-1)) + cong2 if new_L2pv < l1_new_page_end: new_L2pv += L2pa S2 = new_L2pv - L2pv BOUNDARY1 = L1pv # vaddrs [B1..B2) shifted by S1 BOUNDARY2 = L2pv # vaddrs [B2..) shifted by S2 # For ADRP: use PAGE boundaries PAGE_B1 = L1pv & ~(PAGE-1) PAGE_B2 = L2pv & ~(PAGE-1) PAGE_END = page_end(L2pv, L2pm) print(f" LOAD[1] shift: +0x{S1:x} LOAD[2] shift: +0x{S2:x}") def sv(v): if v >= BOUNDARY2: return v + S2 elif v >= BOUNDARY1: return v + S1 return v def sv_page(page): if page >= PAGE_B2: return page + S2 elif page >= PAGE_B1: return page + S1 return page # ── Step 3: fix p_align and program headers ───────────────────────── for b, pt, pflags, po, pv, pf, pm, pa in phdrs: # Fix p_align on all LOAD segments that have p_align < PAGE if pt == PT_LOAD and pa < PAGE: patch64(data, b+48, PAGE) # Shift vaddr/paddr nv = sv(pv) if nv != pv: patch64(data, b+16, nv) patch64(data, b+24, nv) # ── Step 4: find dynamic section file offset ───────────────────────── e_shoff = struct.unpack_from('<Q', data, 0x28)[0] e_shentsize = struct.unpack_from('<H', data, 0x3a)[0] e_shnum = struct.unpack_from('<H', data, 0x3c)[0] dyn_file_off = None for i in range(e_shnum): base = e_shoff + i * e_shentsize if struct.unpack_from('<I', data, base+4)[0] == SHT_DYNAMIC: dyn_file_off = struct.unpack_from('<Q', data, base+24)[0] break # ── Step 5: fix section headers (sh_addr and non-ALLOC sh_offset) ─── # Non-ALLOC sections shifted by full file_size_delta due to inserted padding file_delta = (new_L1pv - L1po) - (L1pv - L1po) # = S1 (padding amount) nonalloc_shift = os.path.getsize(path_in) - \ # compute from last alloc section end L2po # approximate; see note below # Simpler: detect by checking if data at sh_offset is all zeros for i in range(e_shnum): base = e_shoff + i * e_shentsize sh_type = struct.unpack_from('<I', data, base+4)[0] sh_flags = struct.unpack_from('<Q', data, base+8)[0] sh_addr = struct.unpack_from('<Q', data, base+16)[0] sh_off = struct.unpack_from('<Q', data, base+24)[0] sh_size = struct.unpack_from('<Q', data, base+32)[0] # Fix sh_addr for alloc sections nv = sv(sh_addr) if nv != sh_addr: patch64(data, base+16, nv) # Fix sh_offset for non-alloc sections whose data was shifted in file if not (sh_flags & SHF_ALLOC) and sh_off > 0 and sh_type != 0: sample = bytes(data[sh_off:sh_off+8]) for trial_shift in [S1, S2, S1+S2, S2-S1]: t = sh_off + trial_shift if 0 < t < len(data) and all(b==0 for b in sample): trial = bytes(data[t:t+8]) if any(b != 0 for b in trial): patch64(data, base+24, t) break # ── Step 6: fix dynamic section pointer entries ────────────────────── if dyn_file_off: rela_off = rela_sz = jmprel_off = jmprel_sz = None for i in range(256): tag, val = struct.unpack_from('<QQ', data, dyn_file_off + i*16) if tag == 0: break if tag == 7: rela_off = val if tag == 8: rela_sz = val if tag == 0x17: jmprel_off = val if tag == 2: jmprel_sz = val if tag in PTR_TAGS: nv = sv(val) if nv != val: patch64(data, dyn_file_off + i*16 + 8, nv) # ── Step 7: fix RELA/JMPREL r_offset and R_AARCH64_RELATIVE r_addend for sec_off, sec_sz in [(rela_off, rela_sz), (jmprel_off, jmprel_sz)]: if not sec_off or not sec_sz: continue for i in range(sec_sz // 24): eo = sec_off + i * 24 r_offset, r_info, r_addend = struct.unpack_from('<QQq', data, eo) r_type = r_info & 0xffffffff noff = sv(r_offset) if noff != r_offset: patch64(data, eo, noff) if r_type == R_AARCH64_RELATIVE and r_addend > 0: nadd = sv(r_addend) if nadd != r_addend: patch64s(data, eo + 16, nadd) # ── Step 8: fix ADRP instructions in code sections ────────────────── # Must use PAGE-level boundaries (not raw vaddr boundaries) for the split # between LOAD[1] shift and LOAD[2] shift. for i in range(e_shnum): base = e_shoff + i * e_shentsize sh_type = struct.unpack_from('<I', data, base+4)[0] sh_flags = struct.unpack_from('<Q', data, base+8)[0] sh_off = struct.unpack_from('<Q', data, base+24)[0] sh_size = struct.unpack_from('<Q', data, base+32)[0] SHF_EXECINSTR = 0x4 if not (sh_flags & SHF_EXECINSTR) or sh_size == 0: continue j = 0 while j < sh_size: off = sh_off + j if off + 4 > len(data): break instr = struct.unpack_from('<I', data, off)[0] if (instr & 0x9f000000) == 0x90000000: # ADRP immlo = (instr >> 29) & 0x3 immhi = (instr >> 5) & 0x7ffff imm21 = (immhi << 2) | immlo if imm21 & 0x100000: imm21 -= 0x200000 tgt_page = (off & ~0xfff) + imm21 * 0x1000 new_page = sv_page(tgt_page) if new_page != tgt_page: pc_page = off & ~0xfff new_imm21 = (new_page - pc_page) // 0x1000 new_imm21 &= 0x1fffff new_immhi = (new_imm21 >> 2) & 0x7ffff new_immlo = new_imm21 & 0x3 new_instr = (instr & 0x9f00001f) | \ (new_immlo << 29) | (new_immhi << 5) struct.pack_into('<I', data, off, new_instr) j += 4 with open(path_out, 'wb') as f: f.write(data) print(f" Written: {path_out} ({len(data)} bytes)") if __name__ == '__main__': if len(sys.argv) != 3: print("Usage: python3 fix_16kb.py <input.so> <output.so>") sys.exit(1) print(f"Patching {sys.argv[1]}...") fix_so(sys.argv[1], sys.argv[2]) print("Done.")
unzip yourapp.apk \ lib/arm64-v8a/libmonodroid.so \ lib/arm64-v8a/libxa-internal-api.so \ lib/arm64-v8a/libxamarin-debug-app-helper.so \ -d extracted/
python3 fix_16kb.py extracted/lib/arm64-v8a/libmonodroid.so \
patched/libmonodroid.so
python3 fix_16kb.py extracted/lib/arm64-v8a/libxa-internal-api.so \
patched/libxa-internal-api.so
python3 fix_16kb.py extracted/lib/arm64-v8a/libxamarin-debug-app-helper.so \
patched/libxamarin-debug-app-helper.so
# Replace libs in the APK (zip update, no recompression) zip -j yourapp.apk \ patched/libmonodroid.so \ patched/libxa-internal-api.so \ patched/libxamarin-debug-app-helper.so # Zipalign (use 16384 for 16kb page compliance) zipalign -p -f 16384 yourapp.apk yourapp-aligned.apk # Re-sign with your keystore apksigner sign --ks your.keystore \ --out yourapp-signed.apk yourapp-aligned.apk
# Check p_align on patched lib readelf -l patched/libmonodroid.so | grep -A1 LOAD # Should show align=0x4000 on all LOAD segments # Also verify no page overlap: python3 -c " import struct data = open('patched/libmonodroid.so','rb').read() e_phoff=struct.unpack_from('<Q',data,0x20)[0] e_phentsize=struct.unpack_from('<H',data,0x36)[0] e_phnum=struct.unpack_from('<H',data,0x38)[0] loads=[] for i in range(e_phnum): b=e_phoff+i*e_phentsize if struct.unpack_from('<I',data,b)[0]==1: pv=struct.unpack_from('<Q',data,b+16)[0] pf=struct.unpack_from('<Q',data,b+32)[0] loads.append((pv,pf)) for i in range(len(loads)-1): pe=(loads[i][0]+loads[i][1]+0x3fff)&~0x3fff ps=loads[i+1][0]&~0x3fff print(f'LOAD[{i}]->LOAD[{i+1}]: {\"OK\" if pe<=ps else \"OVERLAP\"}') "
Xamarin SDK 13.x ships with its own bundled opt and llc binaries, but it will pick up aarch64-linux-android-as and aarch64-linux-android-ld from your NDK if configured. NDK 29 is significantly faster than the ancient binutils Xamarin ships internally.
However, BundleAssemblies fails with NDK > 22. The cause: starting with NDK 23, Clang is shipped as a versioned binary (clang-14, clang-15, ..., clang-21 in NDK 29). Xamarin's MSBuild task only looks for the unversioned clang binary and fails silently when it can't find it.
The fix is a one-time symlink in your NDK's bin directory:
# NDK 29 example — adjust version number to match your clang-XX cd /path/to/ndk/29.0.14206865/toolchains/llvm/prebuilt/darwin-x86_64/bin # Check what versioned clang you have ls clang-* # Create symlink so Xamarin can find it ln -sf clang-21 clang
After this, Xamarin's BundleAssemblies task finds clang, but gets NDK 29's Clang 21 underneath — giving you a modern, fast toolchain with full API 35 sysroot support. The AOT pipeline then runs: Xamarin's own opt+llc for LLVM IR compilation, and NDK 29's assembler+linker for the final native binary.
Android 16 introduced android:pageSizeCompat="enabled" in the app manifest, which temporarily suppresses the Play Store warning. However Google is explicit that this is a short-term compatibility shim with a published end-of-life date. It does not fix the runtime crash on a real 16kb kernel device, and it does not substitute for properly fixing the .so files as described here.
As of early 2026, only the Pixel 8 supports rebooting into a 16kb page-size kernel via OEM unlock. The Pixel 9 and Pixel 10 ship with standard 4kb kernels and cannot be switched. Future devices will ship with 16kb kernels by default, which is why Play Store compliance is enforced now.
This library only has DT_HASH (no DT_GNU_HASH), has p_align=0x10000 already, and loads fine on 16kb kernels without modification.
| Check | Play Store (static) | Android 16 linker (runtime) |
|---|---|---|
| p_align >= 0x4000 | ✓ required | ✓ required |
| Page overlap between LOAD segments | not checked | ✓ required |
| ADRP instructions | not checked | ✓ must be correct |
| Section headers | not checked | needed for hash lookup |
This is why a partial fix can pass Play Store upload but still crash at runtime on a 16kb device.
Check the file size of libmonodroid.so in your APK:
This error from the Android bionic linker does NOT mean the hash table is corrupt. It means gnu_hash_.nbuckets == 0, which happens when the LOAD segment mapping fails and the memory the linker tries to read is all zeros. The real cause is the page overlap issue described in Root Cause #3.