Files
opendance-android/build/check_native_libs.py
T
zijunchiang 95429e8ce3 test(native): APK native 库体检——ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐(T-AND-015)
T-AND-012 的结论是「ExceptionInInitializerError 的根因在集成配置(native 解压 +
R8 + ABI),不是 MediaPipe 本身不稳定」,但当时的核查是**手工一次性**的(unzip 看
lib/、grep seeds、strings 找类名)——下次照样可能漏,且漏了也没人发现,只会在用户
装上手机、点开相机那一刻暴露。

新增 build/check_native_libs.py(纯 Python 标准库解析 ELF,不需要 readelf / NDK),
检查三项**无设备也能查**的「一装上就崩」根因:
  * ABI 与 ELF 类型匹配(把 x86_64 的 .so 放进 lib/arm64-v8a/ → 一加载就崩);
  * DT_NEEDED 依赖闭包(非系统库必须同 ABI 目录内自带;MediaPipe 早期版本的
    libc++_shared.so 就是这么缺的);
  * PT_LOAD 页对齐 ≥ 16 KB(Android 15 起部分设备页 16 KB,4 KB 对齐会直接加载失败;
    Google Play 2025-11 起也要求)。
外加 zipalign -c -P 16 -v 4(有 build-tools 时)。已接入 verify.sh 与 accept-release.sh。

实测(已发布的 opendance-android-v0.2.0.zip 内 APK):
  双 ABI 下 6 个 .so 全部 ELF 类型匹配、依赖闭包完整(libmediapipe_tasks_jni.so 只依赖
  libdl/libjnigraphics/libEGL/libGLESv2/libm/libz/liblog/libandroid/libc,全是系统库)、
  PT_LOAD p_align=0x4000(16 KB 兼容)、zipalign -P 16 通过。
  结论:打包配置这一侧的「一装上就崩」风险已排除,剩下的只能靠真机实测。

与 readelf 交叉验证(6 个 .so 的 DT_NEEDED / p_align / e_machine 逐项一致)——
这次交叉验证抓到解析器自己的 bug:第一版程序头偏移写错(ELF64 的
p_offset/p_vaddr 之后第三个 QWORD 是 p_paddr 而非 p_filesz),导致 vaddr→offset
映射失败、DT_NEEDED 解析为空,页面照样打印「✓ 依赖闭包完整(系统库 0 项)」——
一句**假通过**,唯一破绽就是那个 0。教训:通过输出必须带可核对的数量/取值。

反向验证(三条分支都用真实负例证明会失败):
  * 注入宿主 x86_64 的 libz.so.1 到 lib/arm64-v8a/libbogus.so → 同时报 ELF 类型不符、
    缺依赖 libc.so.6、页对齐 0x1000 < 16384;
  * 把 libz.so 从系统库白名单去掉 → 报缺少依赖 libz.so(证明闭包分支是活的);
  * --expected-abis 传错 → 报 ABI 集合不符。

verify.sh 全绿;对**已发布资产**重新下载后跑完整 accept-release.sh 全部通过。
未新发版本:APK 本身未变(本任务是检查工具与文档),README 的新增项随下次发布进包。
2026-09-17 05:25:04 +08:00

290 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""APK native 库体检(T-AND-015):纯标准库解析 ELF,不需要 readelf / NDK。
为什么需要它(T-AND-012 的「先按 ABI / native / R8 排查」):
`ExceptionInInitializerError` / `UnsatisfiedLinkError` 这类「一装上就崩」的问题,
根因绝大多数落在 native 库上,而它们**都能在没有设备的情况下查出来**:
1. **ABI 与 ELF 机器类型不匹配** —— 把 x86_64 的 .so 放进 `lib/arm64-v8a/`,
安装能过、一加载就崩;
2. **DT_NEEDED 依赖缺失** —— 例如只打包了 `libmediapipe_jni.so` 却没带
`libc++_shared.so`(MediaPipe 早期版本的经典坑)。系统库由 Android 提供,
非系统库必须**同 ABI 目录内**自带;
3. **16 KB 页面对齐** —— Android 15 起部分设备页大小为 16 KB,`PT_LOAD` 的
`p_align` 若仍是 4 KB(0x1000),这些设备上会直接加载失败(Google Play
2025-11 起也要求 16 KB 对齐);
4. **路径/命名**:.so 必须直接位于 `lib/<abi>/` 下(子目录不会被安装)。
本脚本把这些一次查完,并给出可读结论。**它不能替代真机**(运行时行为、
GPU/驱动差异仍需设备),但能把「打包配置错误」这一类彻底挡在发布前。
用法:
python3 build/check_native_libs.py <apk> [--expected-abis arm64-v8a,armeabi-v7a]
[--min-page-align 16384] [--json]
退出码:0 通过 / 1 有问题 / 2 用法或文件问题。
"""
from __future__ import annotations
import argparse
import json
import struct
import sys
import zipfile
# Android 系统提供的库(不需要 App 自带)
SYSTEM_LIBS = {
"libc.so", "libm.so", "libdl.so", "liblog.so", "libz.so", "libstdc++.so",
"libandroid.so", "libjnigraphics.so", "libEGL.so", "libGLESv1_CM.so",
"libGLESv2.so", "libOpenSLES.so", "libOpenMAXAL.so", "libnativewindow.so",
"libvulkan.so", "libsync.so", "libcutils.so", "libutils.so", "libui.so",
"libgui.so", "libbinder.so", "libhardware.so", "libcamera2ndk.so",
"libmediandk.so", "libaaudio.so", "libneuralnetworks.so", "libOpenCL.so",
}
# ABI → 期望的 (ELF class, e_machine)
ABI_MACHINE = {
"arm64-v8a": (2, 183), # ELFCLASS64, EM_AARCH64
"armeabi-v7a": (1, 40), # ELFCLASS32, EM_ARM
"x86_64": (2, 62), # ELFCLASS64, EM_X86_64
"x86": (1, 3), # ELFCLASS32, EM_386
}
MACHINE_NAME = {183: "AArch64", 40: "ARM", 62: "x86-64", 3: "Intel 80386"}
class ElfError(Exception):
pass
# 人类可读输出;--json 模式下静音,保证 stdout 是**纯 JSON**(便于脚本消费)
EMIT = True
def emit(*parts) -> None:
if EMIT:
print(*parts)
def parse_elf(data: bytes) -> dict:
"""解析出 {elf_class, machine, is_64, needed[], soname, page_aligns[]}"""
if len(data) < 64 or data[:4] != b"\x7fELF":
raise ElfError("不是 ELF 文件(魔数 %r)" % data[:4])
elf_class = data[4] # 1=32bit, 2=64bit
data_encoding = data[5] # 1=LE, 2=BE
if data_encoding != 1:
raise ElfError("只支持小端 ELF(EI_DATA=%d)" % data_encoding)
is_64 = elf_class == 2
if not is_64 and elf_class != 1:
raise ElfError("未知的 EI_CLASS=%d" % elf_class)
machine = struct.unpack_from("<H", data, 18)[0]
if is_64:
e_phoff, = struct.unpack_from("<Q", data, 32)
e_phentsize, e_phnum = struct.unpack_from("<HH", data, 54)
e_shoff, = struct.unpack_from("<Q", data, 40)
else:
e_phoff, = struct.unpack_from("<I", data, 28)
e_phentsize, e_phnum = struct.unpack_from("<HH", data, 42)
e_shoff, = struct.unpack_from("<I", data, 32)
loads = [] # (vaddr, offset, filesz)
page_aligns = []
dynamic = None
for i in range(e_phnum):
base = e_phoff + i * e_phentsize
if base + e_phentsize > len(data):
raise ElfError("程序头越界")
p_type, = struct.unpack_from("<I", data, base)
# 程序头的字段顺序(ELF64):type@0 flags@4 offset@8 vaddr@16 paddr@24
# filesz@32 memsz@40 align@48
# 注意第三个 QWORD 是 **p_paddr** 而不是 p_filesz —— 第一版就是在这里
# 取错字段,导致 vaddr→offset 映射永远失败、DT_NEEDED 解析为空,
# 于是「依赖闭包完整」变成了一句**假通过**(靠和 readelf 交叉比对才发现)。
if is_64:
p_offset, p_vaddr = struct.unpack_from("<QQ", data, base + 8)
p_filesz, = struct.unpack_from("<Q", data, base + 32)
p_align, = struct.unpack_from("<Q", data, base + 48)
else:
# ELF32:type@0 offset@4 vaddr@8 paddr@12 filesz@16 memsz@20
# flags@24 align@28
p_offset, p_vaddr = struct.unpack_from("<II", data, base + 4)
p_filesz, = struct.unpack_from("<I", data, base + 16)
p_align, = struct.unpack_from("<I", data, base + 28)
if p_type == 1: # PT_LOAD
loads.append((p_vaddr, p_offset, p_filesz))
page_aligns.append(p_align)
elif p_type == 2: # PT_DYNAMIC
dynamic = (p_offset, p_filesz)
needed: list[str] = []
soname = ""
if dynamic is not None:
d_off, d_size = dynamic
entry_size = 16 if is_64 else 8
strtab_vaddr = strsz = None
entries = []
for off in range(d_off, min(d_off + d_size, len(data)), entry_size):
if is_64:
d_tag, d_val = struct.unpack_from("<QQ", data, off)
else:
d_tag, d_val = struct.unpack_from("<II", data, off)
if d_tag == 0:
break
entries.append((d_tag, d_val))
if d_tag == 5: # DT_STRTAB
strtab_vaddr = d_val
elif d_tag == 10: # DT_STRSZ
strsz = d_val
def vaddr_to_offset(vaddr: int) -> int | None:
for v, o, sz in loads:
if v <= vaddr < v + sz:
return o + (vaddr - v)
return None
strtab_off = vaddr_to_offset(strtab_vaddr) if strtab_vaddr is not None else None
if strtab_off is not None:
limit = strtab_off + (strsz or 0)
for d_tag, d_val in entries:
if d_tag not in (1, 14): # DT_NEEDED / DT_SONAME
continue
start = strtab_off + d_val
end = data.find(b"\0", start, limit if limit > start else len(data))
if end < 0:
continue
name = data[start:end].decode("utf-8", "replace")
if d_tag == 1:
needed.append(name)
else:
soname = name
return {
"elf_class": elf_class,
"machine": machine,
"needed": needed,
"soname": soname,
"page_aligns": page_aligns,
}
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument("apk")
parser.add_argument("--expected-abis", default="arm64-v8a,armeabi-v7a")
parser.add_argument("--min-page-align", type=int, default=16384)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv[1:])
expected_abis = [a.strip() for a in args.expected_abis.split(",") if a.strip()]
global EMIT
EMIT = not args.json
try:
zf = zipfile.ZipFile(args.apk)
except (OSError, zipfile.BadZipFile) as exc:
print("✗ 无法读取 APK:%s" % exc, file=sys.stderr)
return 2
libs: dict[str, list[str]] = {}
for name in zf.namelist():
parts = name.split("/")
if len(parts) == 3 and parts[0] == "lib" and name.endswith(".so"):
libs.setdefault(parts[1], []).append(parts[2])
for abi in libs:
libs[abi].sort()
problems: list[str] = []
notes: list[str] = []
if not libs:
print("✗ APK 里没有任何 lib/<abi>/*.so", file=sys.stderr)
return 1
found_abis = sorted(libs.keys())
if found_abis != sorted(expected_abis):
problems.append("ABI 集合为 %s,期望 %s" % (found_abis, sorted(expected_abis)))
report: dict = {"abis": {}}
emit(" APK native 库体检:%s" % args.apk)
for abi in found_abis:
emit(" [%s]" % abi)
entries = []
abi_files = set(libs[abi])
for lib in libs[abi]:
raw = zf.read("lib/%s/%s" % (abi, lib))
try:
info = parse_elf(raw)
except ElfError as exc:
problems.append("%s/%s:%s" % (abi, lib, exc))
emit(" ✗ %-32s %s" % (lib, exc))
continue
want_class, want_machine = ABI_MACHINE.get(abi, (None, None))
machine_name = MACHINE_NAME.get(info["machine"], str(info["machine"]))
if want_machine is not None and (info["elf_class"], info["machine"]) != (want_class, want_machine):
problems.append(
"%s/%s:ELF class=%d machine=%s,与该 ABI 不符(该 ABI 下会加载失败)"
% (abi, lib, info["elf_class"], machine_name)
)
emit(" ✗ %-32s ELF 类型不符:class=%d machine=%s" % (lib, info["elf_class"], machine_name))
else:
emit(" ✓ %-32s ELF%s %s" % (lib, "64" if info["elf_class"] == 2 else "32", machine_name))
# 依赖闭包:非系统库必须在同 ABI 目录内自带
missing = [
dep for dep in info["needed"]
if dep not in SYSTEM_LIBS and dep not in abi_files
]
if missing:
problems.append("%s/%s:缺少依赖 %s(同 ABI 目录内没有)" % (abi, lib, ", ".join(missing)))
emit(" ✗ 缺少依赖:%s" % ", ".join(missing))
else:
extra = [d for d in info["needed"] if d not in SYSTEM_LIBS]
emit(" ✓ 依赖闭包完整(需自带 %s;系统库 %d 项)" % (
", ".join(extra) if extra else "无", len(info["needed"]) - len(extra)))
# 16 KB 页面对齐
bad_align = [a for a in info["page_aligns"] if a < args.min_page_align]
if bad_align:
problems.append(
"%s/%s:PT_LOAD p_align=%s < %d,Android 15+ 的 16 KB 页设备会加载失败"
% (abi, lib, ", ".join(hex(a) for a in bad_align), args.min_page_align)
)
emit(" ✗ 页对齐不足:%s" % ", ".join(hex(a) for a in bad_align))
else:
aligns = sorted(set(info["page_aligns"]))
emit(" ✓ 页对齐 %s(≥ %d,16 KB 页兼容)" % (
" ".join(hex(a) for a in aligns), args.min_page_align))
entries.append({"lib": lib, **{k: info[k] for k in ("machine", "elf_class", "needed", "soname")},
"page_aligns": info["page_aligns"]})
report["abis"][abi] = entries
# libc++ 共享库的经典坑:若某个 .so 需要它,必须自带
for abi, libs_here in libs.items():
if "libc++_shared.so" in libs_here:
notes.append("%s 自带 libc++_shared.so" % abi)
report["problems"] = problems
report["notes"] = notes
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
if problems:
print("")
for p in problems:
print(" ✗ %s" % p, file=sys.stderr)
print(" ✗ native 库体检发现 %d 个问题" % len(problems), file=sys.stderr)
return 1
emit(" ✓ native 库体检通过:%s | ELF 类型匹配、依赖闭包完整、16 KB 页对齐" % ", ".join(found_abis))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))