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 的新增项随下次发布进包。
290 lines
12 KiB
Python
290 lines
12 KiB
Python
#!/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))
|