HelloSilicon
苹果芯片Mac汇编语言入门。
简介
在这个仓库中,我将按照《64位ARM汇编语言程序设计》这本书进行编程,并调整所有示例代码以适用于苹果的ARM64系列计算机。虽然苹果的营销材料似乎避免为这个平台命名,只谈论M1处理器,但开发者文档使用了"Apple silicon"这个术语。我将在下文中使用这个术语。
原始源代码可以在这里找到。
先决条件
虽然我基本假设能找到这里的人都满足大部分(如果不是全部)必要的先决条件,但列出它们也无妨。
-
你需要Xcode 12.2或更高版本,为了方便起见,应该安装命令行工具。这确保了工具能在默认位置(即
/usr/bin
)找到。如果你不确定工具是否已安装,请在Xcode中查看"偏好设置→位置"或运行xcode-select --install
。 -
所有应用程序示例还需要至少macOS Big Sur、iOS 14或它们各自的watchOS或tvOS等效版本。特别是对于后三个系统,这并非绝对必要(Xcode 12.2也不是),但它会使事情变得简单得多。
-
最后,虽然所有示例都可以调整为在iPhone和苹果所有其他ARM64设备上工作,但为了获得最佳结果,你应该有权访问一台Apple silicon Mac。
致谢
我要感谢@claui、@jannau、@jrosengarden、@m-schmidt、@saagarjha和@zhuowei!当我遇到困难时,他们帮助了我,或者提出了让我改进内容的问题。
与书中的变化
除了现有的iOS示例外,这本书基于Linux操作系统。苹果的操作系统(macOS、iOS、watchOS和tvOS)实际上只是Darwin操作系统的不同版本,所以它们共享一组通用的核心组件。
Linux和Darwin,它们都受到AT&T Unix System V的启发,在我们关注的层面上有显著不同。对于书中的列表,这主要涉及系统调用(即当我们想让内核为我们做些什么时),以及Darwin访问内存的方式。
本文件的组织方式使你可以阅读这本书,同时了解苹果芯片的差异。本文档中的标题与书中的标题一致。
第1章:入门
计算机和数字
macOS上的默认计算器应用也有"程序员模式"。你可以通过"查看→程序员"(⌘3)来启用它。
CPU寄存器
苹果对寄存器做出了特定的平台选择:
- 苹果保留X18供自己使用。不要使用这个寄存器。
- 帧指针寄存器(FP,X29)必须始终指向有效的帧记录。
关于GCC汇编器
这本书使用Linux GNU工具,如GNU as
汇编器。虽然macOS上有as
命令,但它默认会调用集成的LLVM Clang汇编器。即使有-Q
选项可以使用基于GNU的汇编器,这也仅是x86_64的选项——而且在撰写本文时已经被弃用。
% as -Q -arch arm64
/usr/bin/as: can't specifiy -Q with -arch arm64
因此,GNU汇编器语法不是一个选项,代码将需要调整以适应Clang汇编器语法。
同样,虽然macOS上有gcc
命令,但这只是调用Clang C编译器。为了透明起见,所有对gcc
的调用将被替换为clang
。
% gcc --version
Configured with: --prefix=/Applications/Xcode-beta.app/Contents/Developer/usr --with-gxx-include-dir=/Applications/Xcode-beta.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/c++/4.2.1
Apple clang version 12.0.0 (clang-1200.0.32.27)
Target: arm64-apple-darwin20.1.0
Thread model: posix
InstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin
Hello World
如果你正在阅读这个,我假设你已经知道macOS终端可以在"应用程序→实用工具→终端.app"中找到。但如果你不知道,我很荣幸告诉你,并祝你在这个旅程中玩得开心!不要害怕提问。 要在 Apple Silicon 上运行"Hello World",首先需要应用第78页(第3章)的更改,以适应 Darwin 和 Linux 内核之间的差异。
为了消除警告,我插入了 .align 4
(或 .p2align 2
),因为 Darwin 喜欢将内容对齐到偶数边界。书中在第5章第114页的"对齐数据"部分提到了这一点。
Linux 和 macOS 的系统调用因各系统的独特约定而存在几个差异。以下是一些主要区别:
- 函数编号:两个系统的函数编号不同,Linux 使用64,而 macOS 使用4。Darwin(Apple)系统调用的表格可以在这个链接找到:Darwin 系统调用。请注意,这是特定版本(撰写时的最新版本),更新的版本可以在这里找到。
[!注意] Darwin 函数编号被 Apple 视为私有,可能会发生变化。此处提供仅供教育目的使用。
- 存储函数编号的地址:用于存储函数编号的地址也不同。在 Linux 中,它在 X8 上,而在 macOS 中,它在 X16 上。
- 中断调用:Linux 中的中断调用是0,而在 Apple Silicon 上是 0x80。
要使链接器工作,还需要一些额外的设置,其中大部分对 Mac/iOS 开发者来说应该很熟悉。这些更改需要应用到 makefile
和 build
文件中。完整的链接器调用如下所示:
ld -o HelloWorld HelloWorld.o \
-lSystem \
-syslibroot `xcrun -sdk macosx --show-sdk-path` \
-e _start \
-arch arm64
我们已经知道 -o
开关,让我们来看看其他的:
-lSystem
告诉链接器将我们的可执行文件与libSystem.dylib
链接。我们这样做是为了向可执行文件添加LC_MAIN
加载命令。通常,Darwin 不支持静态链接的可执行文件。虽然不太优雅,但可以创建不使用libSystem.dylib
的可执行文件。时间允许的话,我会深入讨论这个话题。对于阅读过《Mac OS X 内部原理》的人,我只补充说这从 MacOS X 10.7 开始取代了LC_UNIXTHREAD
。-sysroot
:为了找到libSystem.dylib
,必须告诉我们的链接器在哪里找到它。在 macOS 10.15 上似乎不需要这样做,因为"从 macOS Big Sur 11 测试版开始,系统附带了所有系统提供的库的内置动态链接器缓存。作为此更改的一部分,动态库的副本不再存在于文件系统中。"我们使用xcrun -sdk macosx --show-sdk-path
来动态使用当前活跃的 Xcode 版本。-e _start
:Darwin 期望一个入口点_main
。为了尽可能保持示例与书中相近,并允许在《第3章》的 C 示例中使用它,我选择保留_start
并告诉链接器这是我们想要使用的入口点。-arch arm64
为了万无一失,让我们加入从 Intel Mac 交叉编译的选项。在 Apple silicon 上运行时可以省略这个选项。
逆向工程我们的程序
虽然 objdump
命令行程序在 Darwin 上同样有效并产生预期的输出,但也可以尝试使用 --macho
(或 -m
)选项,这会使 objdump 使用 Mach-O 特定的对象文件解析器。
第2章:加载和相加
需要应用第1章的更改(makefile、对齐、系统调用)。
寄存器和移位
gcc 汇编器接受 MOV X1, X2, LSL #1
,这在 ARM 编译器用户指南中未定义,而是使用 LSL X1, X2, #1
(等)。毕竟,这两者都只是指令 ORR X1, XZR, X2, LSL #1
的别名。
寄存器和扩展
Clang 要求源寄存器为32位。这是有道理的,因为使用这些扩展时,64位寄存器的高32位永远不会被触及:
ADD X2, X1, W0, SXTB
GNU 汇编器似乎忽略了这一点,允许你指定64位源寄存器。
第3章:工具准备
开始使用 GDB
在 macOS 上,gdb
已被 LLVM 项目的 LLDB 调试器 lldb
取代。语法并不总是相同,所以我会在这里注明差异。
要开始调试我们的 movexamps 程序,输入以下命令:
lldb movexamps
这会产生简略的输出:
(lldb) target create "movexamps"
Current executable set to 'movexamps' (arm64).
(lldb)
像 run
或 list
这样的命令工作方式相同,还有一个很好的 GDB 到 LLDB 命令映射。
要反汇编我们的程序,lldb 使用略有不同的语法:
disassemble --name start
请注意,由于我们正在链接动态可执行文件,列表会很长,并包含其他start
函数。我们的代码将列在movexamps`start
行下。
同样,lldb希望断点名称不带下划线:b start
要在lldb中获取寄存器信息,我们使用register read(或re r)。如果不带参数,此命令将打印所有寄存器,或者您可以指定只想查看的寄存器,如re r SP X0 X1
。
我们可以使用breakpoint list(或br l)查看所有断点。我们可以使用breakpoint delete(或br de)删除断点,指定要删除的断点编号。
lldb甚至有更强大的机制来显示内存。主要命令是memory read(或m read)。首先,以下是本书使用的参数:
memory read -fx -c4 -s4 $address
其中
- -f是显示格式
- -s是数据大小
- -c是计数
清单3-1
作为练习,我添加了代码来查找macOS上的默认Xcode工具链。在书中,他们使用这个来后续从Linux切换到Android工具链。对于macOS和iOS来说,这个过程有很大不同:它通常不涉及不同的工具链,而是涉及不同的软件开发工具包(SDK)。你可以在清单1-1中看到,其中设置了-sysroot
。
话虽如此,虽然可以使用命令行构建iOS可执行文件,但这并不是一个简单的过程。因此,对于构建应用程序,我将坚持使用Xcode。
Apple Xcode
由于第10章专注于构建一个可以在iOS上运行的应用程序,我选择在这里简单地创建一个命令行工具,现在使用相同的HelloWorld.s
文件。
请注意,函数编号不仅不同,而且在Darwin上,它们被视为私有的,可能会发生变化。
第4章:控制程序流程
除了常见的变化外,我们还面临一个在书中第5章描述的新问题:Darwin不喜欢LDR X1, =symbol
,它会产生错误ld: Absolute addressing not allowed in arm64 code
。如果我们使用书中第3章建议的ASR X1, symbol
,我们的数据必须在只读的.text
部分。然而,在这个示例中,我们希望数据是可写的。
Apple文档告诉我们,在Darwin上:
所有大型或可能非本地的数据都通过全局偏移表(GOT)条目间接访问。GOT条目使用RIP相对寻址直接访问。
默认情况下,在Darwin上,包含在.data
部分的所有数据(数据可写)都是"可能非本地的"。
完整的答案可以在这里找到:
ADRP
指令加载当前指令+/-4GB(33位)范围内任何4KB页面的地址(占用偏移量的21个高位)。这由@PAGE
运算符表示。然后,我们可以使用LDR
或STR
来读取或写入该页面内的任何地址,或使用ADD
使用偏移量的剩余12位(由@PAGEOFF
表示)计算最终地址。
所以这个:
LDR X1, =outstr // 输出字符串的地址
变成了这个:
ADRP X1, outstr@PAGE // 输出字符串4k页面的地址
ADD X1, X1, outstr@PAGEOFF // 页面内outstr的偏移量
练习
有人问我如何读取命令行,我很乐意回答这个问题。
示例代码可以在第4章的case.s
文件中找到。
第5章:感谢内存
Darwin的内存寻址重要区别已在上文讨论。
清单5-1
对于llvm汇编器,quad
、octa
和fill
关键字必须是小写。(见本文件底部)
清单5-10
变化与第4章相同。
第6章:函数和栈
正如我们在第5章所学,所有汇编器指令(如.equ
)必须是小写。
第7章:Linux操作系统服务
Apple SDK中不存在asm/unistd.h
,可以使用sys/syscalls.h
代替。
**警告:**请注意,Darwin中的系统调用号在官方上被视为私有的,可能会发生变化。这里仅出于教育目的展示它们。
同样重要的是要注意,虽然调用和定义看起来相似,但Linux和Darwin并不相同:AT_FDCWD
在Linux上是-100,但在Darwin上必须是-2。
与Linux不同,错误通过设置进位标志来表示,错误代码是非负的。因此,我们将结果MOV
到所需的寄存器中,而不是ADDS
(我们不需要检查负数,并需要保留条件标志),并使用B.CC跳转到成功路径。
第8章:编程GPIO引脚
这一章专门针对Raspberry Pi 4,所以这里没有需要做的事情。
第9章:与C和Python交互
为了透明度,我将 gcc
替换为 clang
。
代码清单9-1
除了常规更改外,Apple在可变参数函数上偏离了ARM64标准ABI(即函数调用约定)。可变参数函数是接受不定数量参数的函数,printf
就是其中之一。在Linux中,参数可以通过寄存器传递,但在Darwin中我们必须将它们传递到栈上。
str X1, [SP, #-32]! // 将栈指针向下移动四个双字(32字节)并将X1压入栈
str X2, [SP, #8] // 将X2压入当前栈指针上方一个双字
str X3, [SP, #16] // 将X3压入当前栈指针上方两个双字
adrp X0, ptfStr@PAGE // printf格式字符串
add X0, X0, ptfStr@PAGEOFF // 添加格式字符串偏移
bl _printf // 调用printf
add SP, SP, #32 // 清理栈
首先,我们将栈向下扩展32字节,为三个64位值腾出空间。我们为第四个值创建了空间用于填充,因为正如书中第137页指出的,ARM硬件要求栈指针始终保持16字节对齐。
在同一条指令中,X1被存储在栈指针的新位置。
然后,我们填充刚刚创建的剩余空间,将X2存储在高8字节处,将X3存储在高16字节处。注意,X2和X3的str指令并不移动SP。
我们可以用不同方式填充栈;重要的是printf
函数期望参数按顺序以双字值的形式从当前栈指针向上排列。因此,在debug.s
文件中,它期望%c
的参数位于SP位置,%32ld
的参数位于上方一个双字,最后%016lx
的参数位于当前栈指针上方两个双字(16字节)处。
我们实际上做的是在栈上分配内存。作为调用者,我们"拥有"该内存,因此需要在函数分支之后释放它,在这种情况下,只需将栈(向上)收缩32字节即可。指令add SP, SP, #32
就是这样做的。
代码清单9-5
mytoupper
前缀加了_
,因为这对Darwin上的C来说是必要的,以便找到它。
代码清单9-6
无需更改。
代码清单9-7
不是创建共享的.so
ELF库,而是创建动态Mach-O库。更多信息可以在这里找到:创建动态库
代码清单9-8
在内联汇编中,我们必须通过在cont
标签前加前缀L
来将其声明为局部标签。虽然在纯汇编中(如第5章)这不是必需的,但llvm C前端会自动将指令.subsections_via_symbols
添加到代码中:
Darwin的特殊技巧:此标志告诉链接器没有全局符号包含落入其他全局符号的代码(例如,多入口点的明显实现)。如果不发生这种情况,链接器可以安全地执行死代码剥离。由于LLVM从不生成这样的代码,因此始终可以安全地设置它。 (来自llvm源代码)
虽然我们使用LLVM工具链,但在汇编(包括内联汇编)中,所有安全检查都被关闭,因此我们必须采取额外的预防措施,特别声明前向标签为局部标签。
此外,一个变量的大小必须从int改为long,以使编译器完全满意并消除所有警告。
从Python调用汇编
代码清单9-9
虽然uppertst5.py
文件只需要做最小的更改,但调用代码却更具挑战性。在Apple silicon Mac上,Python是一个包含两种架构的Mach-O通用二进制文件,分别是x86_64和arm64e:
% lipo -info /usr/bin/python3
Architectures in the fat file: /usr/bin/python3 are: x86_64 arm64e
值得注意的是,我们之前一直在构建的arm64架构不存在。这使得我们的dylib无法与Python一起使用。
arm64e是Armv-8架构,Apple自A12芯片以来一直在使用。如果你想支持A12之前的设备,你必须坚持使用arm64。第一批使用ARM64的Mac运行在基于A14架构的M1 CPU上,因此Apple决定利用新特性。
那么,我们该怎么办?我们可以将所有内容编译为arm64e,但这会使库在iPhone X或更旧的设备上无法使用,而我们希望也能支持这些设备。 上面,你读到了关于"通用二进制"的内容。长期以来,Mach-O可执行文件格式一直支持在单个文件中包含多种处理器架构。这包括但不限于Motorola 68k(在NeXT计算机上)、PowerPC、Intel x86以及ARM代码,每种架构都有其适用的32位和64位变体。在这种情况下,我正在构建一个包含arm64和arm64e代码的通用动态库。更多信息可以在这里找到。
虽然大多数适用于Linux的Python IDE也可用于macOS,但截至本文撰写时,唯一以arm64架构运行(因此将加载arm64库)的Python IDE是Python.org的IDLE,版本3.10或更新。
图9-1. 我们的Python程序在IDLE IDE中运行
另外,你也可以使用命令行来测试程序。(从macOS 12.3开始,Apple移除了Python 2,开发者应该使用Python 3)
% python3 uppertst5.py
b'This is a test!'
b'THIS IS A TEST!'
16
最后注意:虽然Apple的python3二进制文件是arm64e架构,但IDLE使用的Python框架是arm64架构。本章构建的库是包含这两种架构的通用二进制文件,因此可以在任一环境中使用。
第10章:与Kotlin和Swift的接口
核心代码无需更改,但我不仅创建了iOS应用,还创建了一个可在macOS、iOS、watchOS(Series 4及以后)和tvOS上运行的SwiftUI应用。
第11章:乘法、除法和累加
到这里,所做的更改应该是不言自明的。通常的makefile调整、.align 4
、地址模式更改和_printf
调整。
第12章:浮点运算
与第11章一样,所有更改都已经介绍过了。这里没有什么新内容。
第13章:Neon协处理器
示例使用了非标准语法来引用单个向量元素,GNU汇编器接受这种语法,但Clang不接受。原示例使用V3.4H[0]
来引用第一个16位元素,正确的标准语法是V3.H[0]
,这种语法既被GNU汇编器接受,也被Clang接受。
此时,对代码的所有其他更改应该都是微不足道的。
第14章:代码优化
这里没有不寻常的更改。
第15章:阅读和理解代码
复制一页内存
在Darwin内核中阅读ARM64代码的一个起点可以在bcopy.s中找到。该目录和整个仓库中还有更多内容。
GCC创建的代码
无需更改。Mach-O可执行文件不支持"tiny"代码模型:
% clang -O3 -mcmodel=tiny -o upper upper.c
fatal error: error in backend: tiny code model is only supported on ELF
第16章:代码黑客
可以说的是,clang自动启用位置无关可执行文件,而-no-pie
选项不起作用。因此,无法复现upper.s
文件中展示的漏洞利用。
附加参考
- 为Apple平台编写ARM64代码,介绍了Apple平台如何与标准64位ARM架构有所不同的文档
- Mach-O编程主题,对Mach-O可执行文件格式及其与ELF的区别进行了出色的介绍。尽管它仍然提到PowerPC 64位架构,并未提及ARM,但大部分内容仍然适用。
- Mach-O可执行文件加载需要哪些条件?
- Mac OS X内部原理:系统方法 Amit Singh著,2007年。无论好坏,这仍然是macOS及其兄弟系统核心的权威汇编。
- WWDC20:探索Apple silicon Mac的新系统架构 新Apple silicon机器的系统概述
- Darwin源代码
- ARM架构参考手册
最后一件事…
"C语言是区分大小写的。编译器也区分大小写。Unix命令行、ufs和nfs文件系统都区分大小写。我也对大小写敏感,尤其是对产品名称。这个IDE叫做Xcode。大写X,小写c。不是XCode或xCode或X-Code。请记住这一点。" — Chris Espinosa