本文由 简悦SimpRead 转码,原文地址 bogo.wtf
关于如何在iOS模拟器中直接启动原生ARM64二进制文件的入门读物,使用otool、Mach-O、a......
2021-02-10
Bogo Giertler17分钟的阅读(和来源)。
注意:(bogo) 这篇文章的重点是 静态 库。我写了一篇单独的文章,解释了如何使用这种技术让ARM64 动态 库在iOS模拟器上运行。
上面的截图看起来非常正常--直到你意识到,在这台M1 MacBook上运行的示例应用程序实际上是2017年的一个传统Spotify SDK演示。其专有的二进制框架从未被重建以支持M1 Macs,并且不能在苹果最新的计算机上运行,除非通过Rosetta 2启动Xcode。
如果你有一台M1 Mac,你可能已经遇到了这个问题。在你最喜欢的项目上点击 "运行 "几秒钟后(并想_哇,这些M1 Macs确实很快!_),你可能会看到这个。
ld: 在 ../.../SpotifyiOS.framework/SpotifyiOS(MPMessagePackReader.o)中,为iOS模拟器构建,但在为iOS构建的对象文件中链接,文件'.../.../SpotifyiOS.framework/SpotifyiOS' 为arm64架构
clang: error: linker command failed with exit code 1 (use -v to see invocation)
通俗地说,你在项目中使用的专有二进制框架没有被更新以支持iOS模拟器在M1 Mac上运行。在这种情况下,苹果的建议是与供应商联系,要求他们发布框架的更新版本--既要把它迁移到XCFramework格式,又要重建它以支持M1模拟器。
有很多原因导致你可能无法很快得到更新的框架--甚至根本无法得到。常见的情况是,第三方供应商反应迟钝,或者你因为兼容性的原因而钉在框架的前一个主要版本上。由于你可能没有原始库的源代码,你也不能自己重建它。这意味着没有模拟器构建,没有本地单元和UI测试。你似乎遇到了一个死胡同,在M1 Mac上的开发暂时会非常困难。或者你有吗?
上周,我在Spotify的iOS SDK遇到了这个问题。由于二进制版本已经一年多没有更新了,我不得不找到一种方法来破解原生的ARM64二进制文件以在模拟器上运行。在这个过程中,我学到了很多关于框架、二进制文件和加载器的知识。你可以在GitHub上找到arm64-to-sim的完整来源。下面是对ARM64转制的详细解释。
💡 一个想法生根发芽了
让我们再看一下错误信息。我们收到的错误实际上不是一个编译器错误--是一个链接器错误。ld抱怨说,我们正试图将一个为 native ARM64编译的二进制文件链接到一个为 iOS Simulator ARM64构建的二进制文件。
历史上,苹果产品线中的ARM/x86分叉意味着我们可以安全地假设,为 "i386 "和 "x86_64 "构建的代码是为模拟器设计的,而为 "armv7 "和 "arm64 "构建的代码是为本地设备设计的。这在fat (universal) binaries中得到了反映,它是一个广泛使用的黑客,用于分发苹果平台的框架,可以用于设备和模拟器。
随着M1 Macs的发布,这一假设不再成立--ARM64切片现在可以用于任何一种。在一个框架支持macOS、iOS、watchOS和tvOS的幌子下,2019年苹果发布了一种新的捆绑框架格式,XCFramework。
这应该给了我们一个想法:既然如ld错误所示,我们的库中已经有了一个原生ARM64分片,也许我们可以把它重新打包成一个支持iOS模拟器的XCFramework。没有技术上的原因,它不应该工作--编译后的二进制文件与其他框架和二进制文件的符号链接。由于iOS设备和M1 Macs使用相同的ARM64指令集,如果本地库和模拟器库的符号足够相似,该库应该可以正常工作。我们只需要运用大量的技巧。
🫀 (XC)框架的剖析
XCFramework是一种相当直接的格式,旨在成为原始Cocoa Frameworks的直接替代品。基本上,每个XCFramework都是一个目录,包含一个属性列表,告诉链接器在哪里找到每个框架的架构和格式的特定副本。
一个XCFramework的例子如下。
Example.xcframework/
|-- Info.plist
|-- ios-arm64/
| +-- Example.framework/
+-- ios-arm64_x86_64-simulator/
+-- Example.framework/
各个框架与平台的实际映射是在Info.plist文件中完成的。注意SupportedArchitectures、SupportedPlatform和SupportedPlatformVariant属性。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>Example.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-simulator</string>
<key>LibraryPath</key>
<string>Example.framework</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>
在创建了一个相关的文件夹结构,并将Info.plist和我们的传统.framework放在一起之后,我们现在应该有一个真正的.xcframework了。让我们把原来的.framework放到Xcode中,并尝试构建。当然,如果能成功,那就太容易了--相反,我们得到了以下结果。
ld: in /Users/bogo/Library/Developer/Xcode/DerivedData/NowPlayingView-aeukgqexpeqlsrdzslkpeehveixs/Build/Products/Debug-iphonesimulator/SpotifyiOS.framework/SpotifyiOS(MPMessagePackReader.o), building for iOS Simulator, but linking in object file built for iOS, file '/Users/bogo/Library/Developer/Xcode/DerivedData/NowPlayingView-aeukgqexpeqlsrdzslkpeehveixs/Build/Products/Debug-iphonesimulator/SpotifyiOS.framework/SpotifyiOS' for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
由于Cocoa框架来自 "DerivedData",我们可以确定我们的XCFramework组装正确。不过,我们还是回到了原点--尽管我们的包装很天真,链接器还是能看出我们带来了一个本地库。这就是我们的新目标:找到一种方法让ld相信这个库实际上是一个模拟器库。
🕵️进入二进制文件
让我们看看我们的框架内部,看看有哪些文件可以告诉它平台的情况。
Example.framework/
|-- Info.plist
|-- Example
|-- Headers/
| |-- A.h
| |-- B.h
| +-- C.h
+-- Modules/
+-- module.modulemap
粗略地浏览一下框架的可读内容并没有得到任何提示,所以链接器必须使用二进制文件本身的内容来推断模拟器的信息。既然,我们不知道该找什么,那就先挖掘一下外面其他XCFrameworks的二进制文件吧。
FirebaseAnalytics.xcframework是一个特别值得研究的XCFramework--它包含本地和模拟器的二进制文件。第一个明显的想法是在二进制文件的可读字符串中搜索模拟器引用。
# in the FirebaseAnalytics.xcframework directory
$ strings ios-arm64_i386_x86_64-simulator/FirebaseAnalytics.framework/FirebaseAnalytics | grep -i sim
结果是一堆相当无趣的字符串,其中没有一个提到模拟器。我们可以做一个有根据的猜测,模拟器的信息就这样被编码在二进制文件的机器可读部分。为了提取它,我们可以使用otool--一个旨在探索由LLVM产生的可执行文件的工具。-fahl参数会打印出相关的fat、archive和Mach-O头文件,以及加载命令。
# in the FirebaseAnalytics.xcframework directory
$ otool -fahl ios-arm64_i386_x86_64-simulator/FirebaseAnalytics.framework/FirebaseAnalytics
(...)
Load command 2
cmd LC_LINKER_OPTIMIZATION_HINT
cmdsize 16
dataoff 12464
datasize 760
Load command 3
cmd LC_SYMTAB
cmdsize 24
symoff 13224
nsyms 201
stroff 16440
strsize 5064
(...)
哎呀,这是个很大的数据量啊! 这些偏移量、地址和大小对我们没有任何好处,而且在不同的平台上可能是不同的。让我们把我们的搜索限制在加载命令,保存结果,并进行比较。
# in the FirebaseAnalytics.xcframework directory
$ otool -fahl ios-arm64_i386_x86_64-simulator/FirebaseAnalytics.framework/FirebaseAnalytics | grep -E 'cmd |\.o' > simulator_cmds
$ otool -fahl ios-arm64_armv7/FirebaseAnalytics.framework/FirebaseAnalytics | grep -E 'cmd |\.o' > native_cmds
$ diff -u native_cmds simulator_cmds
-ios-arm64_armv7/FirebaseAnalytics.framework/FirebaseAnalytics(FirebaseAnalytics_vers.o):
+ios-arm64_i386_x86_64-simulator/FirebaseAnalytics.framework/FirebaseAnalytics(FirebaseAnalytics_vers.o):
cmd LC_SEGMENT_64
- cmd LC_VERSION_MIN_IPHONEOS
+ cmd LC_BUILD_VERSION
cmd LC_SYMTAB
(...)
好了,我们找到了一个匹配点! 似乎模拟器的二进制文件包含一个 "LC_BUILD_VERSION "加载命令,而本地二进制文件在同一个地方包含一个 "LC_VERSION_MIN_IPHONEOS "加载命令。用otool对我们不支持的、只有本地的.framework进行测试,证实了这个理论。在谷歌上搜索了一下,发现这个特定的差异被LLDB用来区分模拟器和本地二进制文件。那么我们就在正确的轨道上了--看起来用 "LC_VERSION_MIN_IPHONEOS "代替 "LC_BUILD_VERSION "可能就足以骗过 "ld"。
📚 认识图书管理员
到目前为止,我们一直在玩一个胖的二进制文件,包含多个特定平台的切片。我们可以使用 file 命令查看二进制文件中可用的架构。
$ file Example.framework/Example
Example.framework/Example Mach-O universal binary with 4 architectures: [i386:current ar archive] [arm_v7] [x86_64] [arm64]
Example.framework/Example (for architecture i386): current ar archive
Example.framework/Example (for architecture armv7): current ar archive
Example.framework/Example (for architecture x86_64): current ar archive
Example.framework/Example (for architecture arm64): current ar archive
很明显,对于我们的目的,我们并不特别关心x86或ARMv7的切片。所以我们只抓取arm64一个。
$ lipo -thin arm64 Example.framework/Example -output Example.arm64
如果我们用十六进制编辑器(比如Hex Fiend)打开得到的Example.arm64文件,我们应该注意到该文件的magic number (file format identification pattern)在ASCII中拼写为!<arch>--这意味着,我们不是在处理一个单独的 二进制文件,而是一个UNIX的二进制文件档案--一个 资料库。我们可以很容易地解开它。
结果应该是一些看起来很熟悉的".o "文件。如果我们在十六进制编辑器中打开任何一个文件,我们应该看到CFFA EDFE作为最初的2个字节--一个小字节编码的FEED FACE + 1,这是ARM64 Mach-O二进制文件的神奇数字。(原来的 "FEED FACE "是ARM32的神奇数字)。
在这一点上,我们已经从XCFramework,到Cocoa Framework,到UNIX库,到我们最终可以编辑的单个Mach-O二进制对象--真正的,一个抽象层的matryoshka跨越了近40年的计算历史。
✂️剖析Mach-O二进制
虽然尝试在十六进制编辑器中阅读和编辑对象文件是可能的,但它很快就会被证明是一个愚蠢的错误。看一下Mach-O加载器的公开可用的XNU源,可以看到LC_VERSION_MIN_IPHONE和LC_BUILD_VERSION的大小不同。LC_BUILD_VERSION结构有两个额外的UInt32大小的字段,这意味着它比LC_VERSION_MIN_IPHONE正好长8字节。
虽然LLVM生成的机器码段是position independent,但头文件和加载命令不是 - 毕竟它们是加载器的 导航指令。为了在二进制文件中放置一个较长的加载命令,我们需要重新创建二进制文件,并将所有的引用调整到与二进制文件开头的绝对距离。(通常被称为 offsets)
要做到这一点,我们首先需要了解什么是Mach-O二进制文件的构成。根据官方Mach-O ABI docs和loader.h规范,Mach-O二进制文件可以分成4个基本部分。
- 一个Mach-O头,它包含神奇的数字(如
CAFE BABE或FEED FACE),支持的CPU类型,以及加载命令的数量和大小。 - 一个加载命令表,它是任意长的,通知链接器关于二进制文件和在哪里找到原始内容中感兴趣的部分。
- 一个可选择的填充物,可以由开发者添加,以简化对二进制文件的后续编辑。 原始内容,包含可执行代码、字符串和实际执行所需的其他一切--所有这些都在加载命令表中描述的偏移处。
所有这些组件在 "MachO "框架中被描述为字节对齐的C结构。有了这些知识,我们可以开始实现一个简单的命令行工具,将任何Mach-O二进制文件从本地文件转化为模拟器文件。
import Foundation
import MachO
extension Data {
func asStruct<T>(fromByteOffset offset: Int = 0) -> T {
return withUnsafeBytes { $0.load(fromByteOffset: offset, as: T.self) }
}
}
let path = CommandLine.arguments[1]
guard let handle = FileHandle(forReadingAtPath: path) else {
fatalError()
}
let headerData = try! handle.read(upToCount: MemoryLayout<mach_header_64>.stride)!
let header: mach_header_64 = headerData.asStruct()
if header.magic != MH_MAGIC_64 || header.cputype != CPU_TYPE_ARM64 {
fatalError()
}
如果我们给转换器提供了一个有效的Mach-O二进制文件,header结构现在将告知我们加载命令的数量和它们的总大小。要读取Mach-O头之后的单个加载命令,我们需要理解两件事:代表Mach-O命令的C结构不是明确的多态的--它们隐含地跟随load_command,并且单个加载命令的大小是不固定的。
换句话说,每个加载命令都以一个load_command结构开始。这个结构正好包含两个32位的整数--描述命令的 类型 和命令的 大小 。为了正确的读取命令,我们需要支持窥视我们的文件柄,并直接从原始数据对象中检查命令的大小和类型。
extension Data {
var loadCommand: UInt32 {
let lc: load_command = withUnsafeBytes { $0.load(as: load_command.self) }
return lc.cmd
}
var commandSize: UInt32 {
let lc: load_command = withUnsafeBytes { $0.load(as: load_command.self) }
return lc.cmdsize
}
}
extension FileHandle {
func peek(upToCount count: Int) throws -> Data? {
let originalOffset = offsetInFile
let data = try read(upToCount: count)
try seek(toOffset: originalOffset)
return data
}
}
有了这些函数,我们现在可以开始将单个命令提取到Data blobs中。
let loadCommandsData: [Data] = (0..<header.ncmds).map { _ in
let loadCommandPeekData = try! handle.peek(upToCount: MemoryLayout<load_command>.stride)
return try! handle.read(upToCount: Int(loadCommandPeekData!.commandSize))!
}
最难处理的单个加载命令是LC_SEGMENT_64。与Mach-O头相似,LC_SEGMENT_64实际上是由一个segment_command_64结构组成,后面是任意数量的section_64。
var segment: segment_command_64 = data.asStruct()
let sections: [section_64] = (0..<Int(segment.nsects)).map { index in
let sectionOffset = MemoryLayout<segment_command_64>.stride + index * MemoryLayout<section_64>.stride
return data.asStruct(fromByteOffset: sectionOffset)
}
有了所有的命令在内存中,我们现在可以通过保存剩余的命令来完成对二进制文件的读取,以便以后处理。
let programData = try! handle.readToEnd()!
try! handle.close()
最后,我们需要将整个文件持久化到磁盘上。由于我们要处理大量的Data数组,我们也要简化它们的处理。
extension Array where Element == Data {
var flattened: Data { reduce(into: Data()) { $0.append($1) } }
}
try! [
Data(bytes: &header, count: MemoryLayout<mach_header_64>.stride),
loadCommandsData.flattened
programData,
].flattened.write(to: URL(fileURLWithPath: "\(path).reworked.o"))
结果文件应该与输入文件完全相同。我们可以用cmp来确认这一点。
$ cmp -s input.o input.o.reworked.o || echo "Files are different!
如果我们没有看到任何错误,说明重新组装工作符合预期。我们现在可以安全地编辑二进制的加载命令。
🚀Raison d'Être
在这一点上,我们以外科手术般的精确度分解了二进制文件,并准备好编辑其各个组成部分。最大的变化当然是摆脱LC_VERSION_MIN_IPHONEOS命令,用LC_BUILD_VERSION的实例来代替它。一旦这样做了,我们需要重建以下加载命令中的偏移量。
LC_SEGMENT_64- 我们需要更新各个部分的offset和reloff属性,以及整个二进制文件的fileoff、filesize和vmsize属性。LC_DATA_IN_CODE和LC_LINKER_OPTIMIZATION_HINT- 使用相同的C结构表示,都需要更新dataoff属性。LC_SYMTAB- 我们需要改变stroff和symoff属性,分别用于字符串和符号表数据。
对加载命令进行所有更新的最直接的方法,是简单地使用Swift的map,并在辅助函数中处理更新。
let offset = UInt32(abs(MemoryLayout<build_version_command>.stride - MemoryLayout<version_min_command>.stride))
let editedCommandsData = loadCommandsData
.map { (lc) -> Data in
switch lc.loadCommand {
case LC_SEGMENT_64:
return updateSegment64(lc, offset)
case LC_VERSION_MIN_IPHONEOS:
return updateVersionMin(lc, offset)
case LC_DATA_IN_CODE, LC_LINKER_OPTIMIZATION_HINT:
return updateDataInCode(lc, offset)
case LC_SYMTAB:
return updateSymTab(lc, offset)
case LC_BUILD_VERSION:
fatalError()
default:
return lc
}
}
.merge()
对于处理LC_VERSION_MIN_IPHONE_OS,我们需要在辅助函数中返回一个包含build_version_command结构新实例的Data blob。对于其他的加载命令,我们只需更新C结构并将其作为数据对象返回。所有 "load_command "变化的单独实现都可以在项目的GitHub仓库中找到。
最后,也是最重要的,我们需要在二进制文件写回磁盘之前更新Mach-O头中的sizeofcmds属性。
header.sizeofcmds = UInt32(editedCommandsData.count)
在这一点上,运行我们的transmogrifier应该产生一个有效的ARM64模拟器文件。当然,更新一个二进制文件只能让我们走到这一步--我们仍然需要执行另外几个任务。
- 在每个对象文件上使用transmogrifier。
- 将这些对象归档到一个库中。
- 将库与原来的x86_64切片合并,形成一个适合模拟器的胖二进制文件。
- 在XCFramework中的Cocoa框架内替换原始库文件。
我们可以很容易地解决前两个问题。
$ for file in *.o; do arm64-to-sim $file; done;
$ ar crv ../Example.arm64-reworked *.reworked.o
作为组装库的一部分,ar试图从提供的二进制文件中构建一个索引。这个过程需要进行广泛的检查,以确认每个对象是有效的可执行文件,值得庆幸的是,会产生详细的错误。如果我们在重建偏移量时有任何错误或遗漏,ar会告诉我们哪个部分有问题,以及它与什么重合。从这里开始,我们只需要在我们的代码中不断地进行编辑。一旦ar满意了,我们就可以将我们转换后的ARM64片断与Intel的片断合并。
$ lipo -create -output Example Example.x86_64 Example.arm64-reworked
终于来了! 我们现在有了一个包含 ARM64 和 x86-64 模拟器片的胖二进制文件。在用我们黑掉的库文件替换了框架中的原始库文件后,是时候进行一次⌘+R和一个关键时刻了......
轰隆隆! 一个原生的ARM64框架被黑掉了,可以作为模拟器框架在M1 Macs上运行! 🎉
🤓学习和死角
我总共花了15个小时才想出这个方案。一路走来,我明白了。
- Cocoa框架、UNIX库和Mach-O二进制文件是如何构建的。
- ARM64代码比我想象的更普遍,而且苹果公司在不同平台上的代码是非常一致的。
- 二进制文件只是更高级的有限自动机。
如果没有一些死胡同,这就不是一个令人兴奋的项目。我徒劳的尝试包括。
- 使用开源安全研究工具,但无论是LIEF还是ruby-macho都不能可靠地编辑在Mach-O头中插入额外命令所需的偏移量--相反,它们要求在构建二进制文件时加入额外的填充。
- 通过从
LC_SEGMENT_64中删除80字节长的__cmdline部分,释放出 "缺失 "的8个字节--最初我不敢编辑加载命令的二进制偏移量,但这仍然导致ar抱怨偏移量。 - 通过剥离Bitcode(
xcrun bitcode_strip $input -m -o $output)在二进制文件中获得更多的 "空间"--产生的二进制文件不包含任何额外的填充。
👍 未来的改进
对该工具的一些潜在改进是留给读者的练习。这些改进可以是。
- 通过对加载命令进行两次处理,使偏移量的处理更加动态,并传递数据/偏移量对(`DOP')来代替。
- 在新的 "build_version_command "中保留原 "version_min_command "中的最小iOS和SDK值。
- 扩展二进制黑客技术,以支持其他苹果平台(tvOS、watchOS等)的本机和模拟器。
- 通过
module.modulemap公开伞状头文件,将传统框架变成真正的Clang模块;(以避免Swift桥接头文件的问题)
🙌 参考文献和贡献
有几个人和项目值得特别感谢,因为他们使这项工作变得更容易。
- Zac West - 感谢你为我指出了加载器命令的方向,特别是
LC_BUILD_VERSION。 - Hex Fiend 和 Mach-O Browser - 两种可靠的工具,分别用于探索和比较原始二进制文件,以及研究Mach-O二进制文件的加载命令。
- LIEF和macho-ruby - 两个伟大的开源库,用于独立于平台的Mach-O二进制文件的读取(编写时有点
¯\_(ツ)_/¯)。 - marzipanify - Steven Troughton-Smith在2018年试图解决一个类似的问题,尽管是在另一个方向。
- Macho-O内部介绍 - William Woodruff准备了一份关于Mach-O二进制文件如何组织的出色解释。