本文由 简悦SimpRead 转码,原文地址 bogo.wtf
原来ARM64的动态库也可以在M1的Mac上运行--而且比static......,更容易。
2021-02-17
由Bogo Giertler 提供的9分钟阅读。
注意:(bogo) 这篇文章主要是关于 动态 库。这是一篇单独的文章的后续文章,解释了如何在iOS模拟器上运行ARM64 静态 库。
自从我发表了最初的ARM64黑客文章后,有几个人联系我,询问类似的技术是否可以应用于动态框架,例如PSPDFKit或Google的交互式媒体广告SDK。
我最初的项目并没有考虑到动态框架的存在,所以黑掉它们似乎是一个很好的学习机会。在这个过程中,我发现让一个典型的ARM64 dylib在iOS模拟器中运行实际上是非常简单的--而且需要的Mach-O杂技比静态库少得多
🧑🔬 静态vs动态
就像我们上周处理的静态库一样,动态库被用作代码共享的一种形式。其核心区别在于链接过程。静态库在构建时被链接进来(通过 ld),并成为应用程序二进制的一个组成部分。动态库是在运行时连接的(通过dyld3),(理论上)可以在任何时候被替换掉--甚至在应用程序启动后。
在苹果平台上,这对提供多种扩展的应用程序特别有用。开发者,而不是在每个目标中运送相同的代码和资产,可以将共享内容捆绑到一个单一的框架中,然后将应用程序捆绑中的任何二进制文件与之链接。
如果是一个大型的应用程序,节省的空间可以轻松达到数百兆。一个名为Emerge的在线工具提供了Dropbox应用程序的有趣的可视化 - 有八个不同的 "appex",Dropbox应用程序捆绑包保持合理的大小,这要归功于(相当笨重的)DropboxExtensions动态框架。
虽然动态库似乎是模块化复杂应用程序的银弹,但它们的加载在启动时产生了巨大的成本,苹果建议开发人员将动态库的总数保持在最低限度的 "几个"。
在外壳下,动态库是真正的Mach-O胖binaries。与我们上周工作的胖档案不同,我们的动态库以CAFE BABE开头--Mach-O胖二进制的神奇数字,而不是!<arch>。
$ od -N 4 -t x1 Example.framework/Example
0000000 ca fe ba be
所有这些意味着我们可以通过对我们的 "arm64-to-sim "转换器进行一些简单的修改来处理动态库!
✨ 我的上帝,它充满了0!
我们先把框架 "lipo "到ARM64。
$ lipo -thin arm64 Example.framework/Example -output Example.framework/Example.arm64
$ od -N 4 -t x1 Example.framework/Example.arm64
0000000 cf fa ed fe
让我们用otool -fahl读取这个ARM64切片。我们可以立即注意到,与典型的静态二进制文件不同,LC_SEGMENT_64们似乎对整个文件进行了分割。我们可以通过查看filesize和fileoff参数来确认,因为它们只存在于LC_SEGMENT_64中。
$ stat -f%z Example/Example.arm64
6645432
$ otool -l Example/Example.arm64 | grep -E "filesize | fileoff "
fileoff 0
filesize 851968
fileoff 851968
filesize 196608
fileoff 1048576
filesize 4816896
fileoff 5865472
filesize 779960
如果我们加上所有的filesize字段,我们的猜测就得到了证实。
$ stat -f%z Example/Example.arm64
6645432
$ otool -l Example/Example.arm64 | grep filesize | grep -Eo "(\d+)" | paste -sd+ - | bc
6645432
这意味着我们偏移 "LC_SEGMENT_64 "的方法可能根本不起作用--加载命令很可能已经在第一段中得到考虑。让我们放大一点,检查LC_SEGMENT_64指向哪些部分。
$ otool -l Example/Example.arm64
Load command 0
cmd LC_SEGMENT_64
cmdsize 952
segname __TEXT
vmaddr 0x0000000000000000
vmsize 0x00000000000d0000
fileoff 0
filesize 851968
maxprot 0x00000005
initprot 0x00000005
nsects 11
flags 0x0
Section
sectname __text
segname __TEXT
addr 0x0000000000007210
size 0x00000000000aa300
offset 29200
align 2^2 (4)
reloff 0
nreloc 0
flags 0x80000400
reserved1 0
reserved2 0
...
有趣的是! 第一段(__text)的第一节似乎在文件的很远处,在0x7210(==29,200)字节。这似乎很奇怪--我们从上周知道,加载命令通常要短得多。让我们通过总结所有load_command们的长度来检查我们的直觉是否正确。
$ otool -l Example/Example.arm64 | grep -E "cmdsize " | grep -Eo "(\d+)" | paste -sd+ -
| bc
4736
哇塞! 这确实比__text的偏移量短得多。让我们进一步研究这个问题,用xxd来看看我们的动态库在4,736字节中隐藏了什么......
$ xxd -s 4736 -l 256 Example/Example.arm64
00001288: 2600 0000 1000 0000 98f3 5900 800d 0000 &.........Y.....
00001298: 2900 0000 1000 0000 1801 5a00 0000 0000 ).........Z.....
000012a8: 1d00 0000 1000 0000 c066 6500 5012 0100 .........fe.P...
000012b8: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000012c8: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000012d8: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000012e8: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000012f8: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00001308: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00001318: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00001328: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00001338: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00001348: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00001358: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00001368: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00001378: 0000 0000 0000 0000 0000 0000 0000 0000 ................
大量的完全空的负载命令填充! 这是一个很好的消息。由于这些预先存在的填充物必须已经在所有的加载命令中得到了考虑,我们可以用它来存储用LC_VERSION_MIN_IPHONEOS替换LC_BUILD_VERSION所造成的 "多余 "字节。
为此,让我们编辑上一周的源代码,"啃 "掉加载命令后的8个字节。由于零不是加载命令的一部分,我们现有的代码使用FileHandle对象上的readToEnd()函数将其读入programData变量。为了有效地覆盖我们需要的8个字节,以适应新的命令,我们只需在最后的读取之前提前寻找。
// discard the empty 8 bytes that we will use for our longer load command
let bytesToDiscard = abs(MemoryLayout<build_version_command>.stride - MemoryLayout<version_min_command>.stride)
_ = handle.readData(ofLength: bytesToDiscard)
由于我们不需要重建二进制中的任何偏移量,我们也不需要处理任何其他的加载命令更新。我们可以安全地将它们从我们的map()中移除,显然除了build_version_command的替换。
🪄 vtool的方法
原来还有另一个解决方案,它既简单又省去了上面的大部分shell-fu - vtool!
vtool是由苹果公司提供的,作为Xcode 11的命令行工具的一部分。虽然它似乎是旨在帮助公证macOS 10.9之前的框架,但我们也可以利用它编辑加载命令的能力来达到我们的目的。
vtool不需要我们执行任何lipo,因为它直接在指定的平台片上操作。让我们先用它来检查文件中的相关加载命令是什么。
$ xcrun vtool -arch arm64 \
-show \
Example.framework/Example
Example.framework/Example (architecture arm64):
Load command 9
cmd LC_VERSION_MIN_IPHONEOS
cmdsize 16
version 8.0
sdk 14.0
Load command 10
cmd LC_SOURCE_VERSION
cmdsize 16
version 0.0
很好,我们可以看到我们的老朋友,LC_VERSION_MIN_IPHONEOS。既然我们知道LC_BUILD_VERSION长了8个字节,让我们看看二进制文件是否有足够的填充空间来容纳它。
$ xcrun vtool -arch arm64 \
-show-space \
Example.framework/Example
Example.framework/Example (architecture arm64):
Mach header size: 32
Load command size: 4736
Available space: 24432
Total: 29200
太好了! 我们似乎有24,432字节的可用空间 - 所有我们使用xxd看到的0。这对我们的替换来说是足够了。
$ xcrun vtool -arch arm64 \
-set-build-version 7 13.0 13.0 \
-replace \
-output Example.framework/Example.reworked \
Example.framework/Example
让我们把这个命令分解一下。
通过 "set-build-version "和 "replace "参数,我们要求 "vtool "为我们设置一个新的 "LC_BUILD_VERSION",并替换之前的 "LC_VERSION_MIN_IPHONE_OS "条目。如果我们不指定 "替换",实际上我们将在Mach-O头中同时出现两个加载命令,编译和运行时的欢闹将随之而来。
构建版本 "被指定为一个<平台> <minos> <sdk>的元组。注意vtool不接受字符串作为平台值--在我们的调用中看似随机的数字7实际上代表XNU的Mach-O加载器中的IOSSIMULATOR条目。
让我们通过确认vtool正确地修改了二进制文件来总结一下。
$ xcrun vtool -arch arm64 \
-show \
Example.framework/Example.reworked
Example.framework/Example.reworked (architecture arm64):
Load command 9
cmd LC_SOURCE_VERSION
cmdsize 16
version 0.0
Load command 34
cmd LC_BUILD_VERSION
cmdsize 24
platform IOSSIMULATOR
minos 13.0
sdk 13.0
ntools 0
🔑王国的钥匙
在我们运行修改过的transmogrifier后,我们将需要遵循熟悉的步骤,lipo-将产生的ARM64 dylib与x86片断结合起来,并将库组装成XCFramework。如果我们使用 "vtool "方法,我们甚至不需要 "lipo "回库。(尽管将其细化到相关平台是一个合理的举措)
无论哪种情况,在原始Xcode项目中进行直接的框架替换后,构建应该会成功!
不幸的是,现在还不是庆祝的时候--由于链接发生在运行时,我们的应用程序几乎肯定会在模拟器启动后立即崩溃。Xcode的调试器控制台应该包含一个类似于下面的神秘信息。
dyld: Library not loaded: @rpath/Example.framework/Example
Referenced from: <snip>/Example.app/Example
Reason: no suitable image found. Did find:
<snip>/Example.framework/Example: code signature in (<snip>/Example.framework/Example) not valid for use in process using Library Validation: Trying to load an unsigned library
与英特尔的Mac相比,M1的动态库验证政策似乎更加严格--它们不会将无符号的ARM64库加载到内存中,即使我们在Xcode中将其标记为 Embed & Sign。幸运的是,我们可以很容易地解决这个问题。
$ xcrun codesign --sign - Example.xcframework/ios-arm64-x86_64-simulator/Example.framework/Example
让我们再次尝试运行该应用程序......
Voilà, 一个在iOS模拟器上运行的本地动态框架! 🎉
🤓学习和死胡同
我总共花了一个小时才想出了这个方案。一路走来,我明白了。
- 头部填充是dylibs的典型做法--我看的每一个第三方动态框架都有至少几百字节的✨ 0s ✨ 紧跟在加载命令之后。
- "vtool "正是我黑进ARM64所需要的工具--但它也需要用加载命令填充的二进制文件
¯_(ツ)_/¯来构建。 - LIEF和macho-ruby是用来处理动态库的 - 它们都需要在加载命令后进行填充,以便能够编辑二进制。
- 有些动态库包含
LC_ENCRYPTION_INFO加载命令;幸运的是,加密对填充物的变化不敏感,我们的黑客继续工作。
🤓未来的改进
有几项潜在的改进留给读者作为练习。这些可能是。
- 探索 "LC_CODE_SIGNATURE "属性的影响,该属性被一些第三方框架使用(例如PSPDFKit)--我还没有测试它是否会影响像本文所述的二进制编辑。
- 检测标题填充的启发式方法 - 这将是一个很好的功能,可以包含在
arm64-to-sim中,使其成为ARM64转换的一站式服务。
🙌 参考文献
有几个人和项目值得特别感谢。
- myeyesareblind on Twitter建议使用
vtool来编辑加载命令--事实证明,只要有padding,它就是最合适的工具。 - Samantha Demi写了一篇关于静态框架和动态框架之间的区别的精彩解释,我会把它推荐给任何想要了解这个话题的人。
- Julia项目似乎也在M1上遇到了代码签名问题,这证实了使用
codesign来解决这个问题。 - Allegro的Kamil Borzym在2018年进行了一系列的基准测试,以确认过度加载dylibs确实会在启动时带来性能损失。
- Louis Gerbarg在WWDC17上做了一个关于
dyld3的精彩演讲,4年过去了,它仍然值得一看。