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 的新增项随下次发布进包。
This commit is contained in:
2026-09-17 05:25:04 +08:00
parent c207e7dadd
commit 95429e8ce3
7 changed files with 430 additions and 0 deletions
+26
View File
@@ -118,6 +118,32 @@ for abi in arm64-v8a armeabi-v7a; do
fi
done
# ---- 4b. native 库体检(T-AND-015:ABI / ELF 类型 / 依赖闭包 / 16 KB 页对齐)----
log "native 库体检(ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐):"
NPY="${PYTHON_BIN:-}"
if [ -z "$NPY" ]; then
for candidate in python3 python; do command -v "$candidate" >/dev/null 2>&1 && { NPY="$candidate"; break; }; done
fi
if [ -n "$NPY" ]; then
"$NPY" "$PROJECT_DIR/build/check_native_libs.py" "$APK" \
--expected-abis "$(echo "$ABIS" | tr '\n' ',')" || fail "native 库体检未通过"
else
fail "未找到 python3,无法做 native 库体检(apt-get install -y python3)"
fi
# 16 KB 页对齐的 zip 层检查(Android 15 起部分设备页 16 KB)
SDK_FOR_ZIPALIGN="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
ZIPALIGN="$(ls -d "$SDK_FOR_ZIPALIGN"/build-tools/*/zipalign 2>/dev/null | sort -V | tail -1)"
if [ -n "$ZIPALIGN" ]; then
if "$ZIPALIGN" -c -P 16 -v 4 "$APK" >"$WORK_DIR/zipalign.txt" 2>&1; then
echo " ✓ zipalign -c -P 16 -v 4 通过(4 字节对齐 + 16 KB 页对齐)"
else
fail "zipalign -P 16 校验失败(见 $WORK_DIR/zipalign.txt)"
fi
else
log "未找到 zipalign,跳过 zip 对齐检查(不影响结论)"
fi
# ---- 5. 模型资源与 R8 保留 ----
log "MediaPipe 模型与 R8 保留:"
if unzip -l "$APK" | grep -q "assets/pose_landmarker_lite.task"; then
+289
View File
@@ -0,0 +1,289 @@
#!/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))
+19
View File
@@ -118,6 +118,25 @@ else
fi
done
# ---- T-AND-015:native 库体检(ABI / ELF 类型 / 依赖闭包 / 16 KB 页对齐)----
NPY="${PYTHON_BIN:-python3}"
if command -v "$NPY" >/dev/null 2>&1; then
# 期望 ABI 用上面已经算好的 EXPECTED_ABIS(项目声明的真机 ABI),
# 而不是 APK 自己的 ABI 列表 —— 否则「ABI 是否符合预期」这条就自我循环了
if "$NPY" "$PROJECT_DIR/build/check_native_libs.py" "$RELEASE_APK" \
--expected-abis "$(echo "$EXPECTED_ABIS" | tr '\n' ',' | sed 's/,$//')" \
>/tmp/apk_native.txt 2>&1; then
grep -E "✓ native 库体检通过" /tmp/apk_native.txt | sed 's/^/ /'
else
echo "✗ native 库体检未通过:"
sed 's/^/ /' /tmp/apk_native.txt | tail -20
FAILED=1
fi
else
echo "✗ 未找到 $NPY,无法做 native 库体检"
FAILED=1
fi
# ---- T-AND-012:R8 不得裁掉 MediaPipe(seeds 由 -printseeds 输出) ----
SEEDS="app/build/outputs/mapping/release/seeds.txt"
if [ -f "$SEEDS" ]; then