ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

16 KiB 页面与 TPIDR_EL0:Husk 如何让 Android 原生库在 iPhone 上直接执行

16 KiB 页面与 TPIDR_EL0:Husk 如何让 Android 原生库在 iPhone 上直接执行

【免费下载链接】Husk

Run Android on your iPhone. Natively or Emulated.

项目地址:https://gitcode.com/gh_mirrors/husk2/Husk
点击查看免费下载

想让 Android 应用和游戏直接在 iPhone 上运行?开源项目Husk做到了。它包含两种运行方式,其中Translation Layer(翻译层)不启动 Android 系统,而是让 APK 自带的 arm64 原生库(.so文件)直接在 iPhone 的 CPU 上执行——不经过任何模拟。但这背后有两个容易被忽视的硬骨头:iOS 的内存页大小是 Android 的 4 倍,以及 Android 代码依赖的一个线程寄存器TPIDR_EL0。本文将带你拆解 Husk 是如何逐一解决它们的。

在 Husk 的 Library 里,翻译层模式可以让《GTA: San Andreas》这类经典安卓游戏在 iPhone 上直接跑起来:

翻译层:不启动 Android 的运行方式

Husk 的两种模式(见 README.md):

  • Translation Layer(翻译层):游戏自带的 64 位 Android 代码直接在 iPhone 处理器上运行,Husk 在周围"扮演"Android——C 库、Java 调用、OpenGL ES(通过 ANGLE)、Vulkan(通过 MoltenVK)、声音、触摸与手柄。不启动 Android,游戏几秒内就能打开,代码也从未被模拟。
  • Emulation(模拟):完整 Android 系统在 QEMU 内启动,用于翻译层尚不能运行的应用,速度明显更慢。

翻译层的架构规划记录在 docs/04-translation-layer.md。它的核心是一个自研加载器,负责把 APK 里的 arm64.so库搬进 iPhone 上只有调试器才能授予的可执行内存,并在搬运过程中完成下面要讲的那些改造。

16 KiB 页面:iOS 与 Android 的页大小之争 🧩

问题:代码和数据挤在同一页里

iOS 的内存页是16 KiB(16384 字节,见 husk-tl-elf.c 中的TL_PAGE 16384u),而 Android 的原生库传统上按4 KiB页对齐构建。页大了 4 倍意味着:一个按 4 KiB 对齐的库,可能把可执行代码和可写数据塞进了同一个 16 KiB 页。而 iOS 严格执行 W^X 策略——同一页不能既可执行又可写,这类库无法原样映射。

解法:加载前先做一份 16 KiB 页规划

Husk 的 ELF 解析器逐字节、小端读取库文件(iOS SDK 没有<elf.h>,一切按 ELF 规范手工解析),核心是页规划函数 tl_page_plan:

  • 把每个可加载段(PT_LOAD)投到 16 KiB 页网格上,为每页标记需要的权限:只读、可执行、可写;
  • PT_GNU_RELRO覆盖的可写字节不计入可写标记——它们只在重定位阶段被写入,而写入是通过加载器自己的可写别名完成的;
  • 输出一个紧凑的页布局摘要,形如R1 X4 R1 W2(1 页只读、4 页代码……),冲突页数会被单独统计。

页类型定义在 husk-tl-internal.h 的注释里讲得很直白:"一个页可能装着多个段的一部分——在 4 KiB 对齐下,一个 16 KiB 页经常如此"。

扫描器:加载之前就知道命运

husk-tl-scan.c 会对 APK 里每个 arm64 库生成一份 JSON 报告,字段包括pages、execPages、conflictPages、layout,以及指令级统计。判定逻辑一目了然(lib_status):

判定条件
blocked解析失败或存在加载器不支持的重定位类型
work有 16 KiB 页权限冲突、直接系统调用、文本重定位、或自行写入线程寄存器
ok其余情况,可直接映射

值得留意的是:自 2025 年 11 月起,Google Play 要求面向 Android 15+ 的新应用和更新必须携带 16 KiB 兼容的原生代码。也就是说,从 Play 上下载的现代 APK 大多天然通过这道检查;而老应用若冲突,报告显示为"需要处理",对策是对这几页做写模拟(详见 docs/04-translation-layer.md 的 "16 KiB pages" 一节)。

TPIDR_EL0:Android 代码藏在线程寄存器里的秘密 🧵

栈保护 cookie 住在哪里?

TPIDR_EL0是 AArch64 架构的线程指针寄存器——指向当前线程的"家"。Android 的 clang 默认开启栈保护(stack protector),每个受保护函数的入口和出口都会生成这样一条读取(测试套件在 fixture 上确认过):

mrs x8, tpidr_el0 ldr x9, [x8, #0x28] ; 读取栈保护 cookie

问题在于:Darwin(macOS/iOS)把自己的线程指针放在了只读寄存器TPIDRRO_EL0里,TPIDR_EL0可能根本没人用。所以关键问题是:Android 代码读TPIDR_EL0时,这个寄存器能不能安全地由 Husk 自己控制?

先在真机上做实验

文档中明确区分"已测量/已处理/仍开放"。Husk 没有拍脑袋,而是写了设备检查程序 husk-tl-probe.c 在真机上验证,tpidr检查(标题 "Thread register (TPIDR_EL0)")依次测量:

  1. 启动时TPIDR_EL0的初始值(是否为零、是否被系统占用);
  2. 在4 个线程上各写入不同值,往返150 轮,检查值在数百次上下文切换后是否存活;
  3. 值能否穿越信号处理器(信号打断时寄存器值是否还在)。

结论是 Android 代码的这条读取要么原样可用,要么由加载器统一改写——下一节讲改写方案。

加载时补丁:把寄存器读变成内存读

如果真机检查判定TPIDR_EL0不可用,Husk 的加载器(husk-tl-ld.c 的补丁策略说明)会做两件事:

  1. 造一个"假线程块":分配一块页,在偏移0x28处写入一个固定的栈保护 cookie(ensure_tcb)。所有线程共享同一个 cookie 是无害的——cookie 只需要在同一个函数的入口和出口保持一致,而它确实一致;
  2. 逐条重写指令:把每条mrs Xt, tpidr_el0改写成adrp Xt, <假线程块>。这样原来的ldr x9, [x8, #0x28]无需任何改动,就从"读寄存器再取 cookie"变成了"读一块普通内存",取到的正是那个固定 cookie。

补丁统计会写进加载日志,例如patch: rewritten N 'mrs Xt, tpidr_el0' -> adrp stack guard(husk-tl-load.c)。同时,加载器对同一份可写视图还会重写指向可写页的adrp(代码通过 PC 相对寻址找全局变量,而可执行视图不可写)、把svc #0改写为对 Linux 系统调用桩的跳转——这些都是"直接执行"背后的常规动作。

一个容易翻车的地方是:不少库会把常量表直接放在.text段里(OpenSSL 的 SHA 常量表就是典型),这些数据词"长得像"指令。补丁前会先利用 unwind 表(.eh_frame_hdr)划出真实函数的范围,范围之外的词一律按数据处理、绝不动手,避免把哈希常量"重写"成分支指令。

从 APK 到运行:加载器要做的事 📦

把上面拼起来,一个库从 APK 到执行的路径是:

  1. 扫描:tl_elf_analyze解析 ELF,输出页规划、指令统计、重定位统计(含 Android 特有的 APS2 打包重定位和 RELR 解码器,见 husk-tl-elf.c);
  2. 规划:16 KiB 页布局确定每个库需要多少页、哪些页可执行/可写、是否有冲突页;
  3. 映射:库被搬进 JIT 内存——这块可执行内存必须由调试器授予,且 双映射的 RW/RX 地址都必须 16 KiB 页对齐;
  4. 补丁:tpidr_el0读取、指向可写页的adrp、svc、以及 Apple 保留的 x18 寄存器(用 pthread 特定存储模拟出一个"虚拟 x18"槽位)逐一改写;
  5. 执行:重定位完成后,JNI_OnLoad之类的入口直接运行在 iPhone 的 CPU 上。

页面规划的正确性由测试守护:tests/translation-layer/run.sh 用人工推演的布局测页规划器,用 clang/lld 真实构建的 arm64 库测扫描器,并与llvm-readelf、llvm-objdump的统计交叉核对;两个解析器在 ASan/UBSan 下跑过 30 万轮模糊测试没有崩溃。

动手体验:添加应用并读懂兼容性报告

翻译层入口在Settings › Experimental › Android Translation Layer(界面代码:TranslationLayer.swift)。把 APK 添加进去后,每个应用会得到一份"运行它需要什么"的报告,页面冲突、svc系统调用、tpidrReads/tpidrWrites等字段都会在报告里出现——这正是本文讲的两个机制在用户侧的投影。设备检查(tpidr、x18、JIT 内存布局)的结果也会在同一界面展示。

需要完整构建源码的话:

git clone https://gitcode.com/gh_mirrors/husk2/Husk cd Husk ./scripts/ci_build.sh

构建需要 Mac + Xcode,外加meson、ninja、pkg-config、python3、xcodegen和带aarch64-apple-ios目标的 Rust 工具链。首次构建约两小时。

关键源码导读

模块路径
ELF 解析与 16 KiB 页规划(tl_page_plan)husk-tl-elf.c
页类型定义与报告结构husk-tl-internal.h
逐库扫描与判定(ok / work / blocked)husk-tl-scan.c
真机探测:tpidr、x18、JIT 内存husk-tl-probe.c
加载器与tpidr_el0补丁日志husk-tl-load.c
假线程块、指令重写(tpidr / adrp / svc / x18)husk-tl-ld.c
翻译层设计文档docs/04-translation-layer.md
整体架构(模拟模式对比)docs/00-architecture.md
页面规划与扫描器测试tests/translation-layer/run.sh

Minecraft Dungeons 这类重 3D 游戏同样走这套原生路径,加载器的每一项改造都只发生在启动瞬间:

写在最后

"让 Android 库在 iPhone 上直接执行"听起来只是一句口号,落到工程上是:用 16 KiB 页规划解决页大小差异,用真机探测加指令重写驯服TPIDR_EL0,再配合 JIT 可执行内存与 bionic 模拟层,才凑齐"代码从不被模拟"这句话的全部前提。Husk 的文档把每项工作都标注了测量状态,这种"先验证再宣称"的工程习惯,是读这个项目源码时最值得借鉴的地方。

【免费下载链接】Husk

Run Android on your iPhone. Natively or Emulated.

项目地址:https://gitcode.com/gh_mirrors/husk2/Husk
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进