人人都能成为汇编高手 ------ Android ARM64调试 从入门到入土
关键词:C/C++、调试、Linux、arm64
摘要:介绍汇编调试基础知识,如ARM64寄存器和指令集、Linux 进程内存布局、栈帧、寻址方式等。
前篇文章介绍了LLDB调试器的使用 使用lldb + voltron + tmuxinator 优雅的debug C/C++
本文辅以实战和绘图,把比较绕脑子的东西直观化,更好理解汇编调试中的一些重难点,属于是先抛个砖,难免有错漏,欢迎斧正。
1、ARM64寄存器
寄存器是CPU中存储和加工数据的最基础单元,说到CPU是多少位的就是指寄存器的位宽,以下是ARM64通用寄存器x0-x30(w0-w30表示以32位访问)的功能:
虽然叫做通用寄存器,有些寄存器使用习惯是编译器固定的,是ABI兼容性的部分要求,了解编译器风格能帮助我们更好理解反编译出的代码,自己写汇编代码可以不按照该约定写)。
x0-x7
:参数寄存器,参数超过8个压栈。
x0
:也通常用于返回结果,比如new后拿到堆内存地址。
x8
:间接寻址结果寄存器,当返回值(比如结构体size)大于16个字节的时候,该返回内容会被存到一个内存地址当中。
通过反汇编发现x8寄存器也经常作为计算将要调用函数地址,作为跳转指令(b)的跳板,cmp指令也习惯用这个寄存器。
x18
:平台寄存器,保留用于平台 ABI
x9-x15
:调用方保存的临时寄存器(由调用者保存在自己栈帧上,被调方可以修改,调用结束后由调用者自己弹栈恢复)。
x19-x28
:被调函数保持的寄存器(由被调方保存在自己栈帧上,返回前将其恢复)。
x16、x17
它们是内部过程调用(intra-procedure-call)临时寄存器。它们可以给调用胶水代码(Veneers)和类似的代码使用,或者作为子例程调用之间的中间值的临时寄存器。胶水代码(Veneers)是由链接器自动插入的一小段代码,例如当分支目标超出分支指令的范围时。
怎么理解这两个寄存器的作用,在3.4内存寻址 讲解adrp指令时详谈。
fp(x29)
:栈帧基址寄存器 / 栈底指针寄存器
lr(x30)
:链接寄存器,使用bl等带链接跳转指令,保存调用子程序后返回的地址,子程序ret后把lr赋值给pc。
以下寄存器不再上述图中但是也是关键寄存器:
sp(x31)
:栈顶指针寄存器,关于sp和fp后面(3.2 栈)会重点分析其功能。
wzr/xzr
: 32/64bit 0寄存器,写入此寄存器的数据被忽略,读出的数据全为0。
cpsr
:32bit程序状态寄存器,只需要了解高4bit分别代表(由高到低) N结果为负 Z结果为0 C结果进位 V结果溢出,该寄存器影响跳转指令分支。
pc
:程序计数器,指向下一个指令的内存地址,需要注意pc是64bit的,但是指令长度是32bit, pc偏移量为4(bytes)。
2、ARM64指令集
无非是算术运算、分支、访存等,就不再赘述,主要了解ARM64指令集的一些特点:
如果需要了解某个指令,可以直接查看ARM汇编手册:
VS Code 上ARM指令集参考文档插件: 32位Code4Leg ,64位ARM A64 Instruction Reference
相比市面上的授人以鱼的文章把常见的指令撸一遍,直接授渔帮助理解所有指令:
结合objdump生成的机器码和汇编代码对比,多看几个指令就能理解汇编文档内容,比如目标寄存器(Rd)、立即数(imm)、操作码(opcode)、条件码(cond)等对应指令的功能。
yaml
0000000000004ae8 <main>:
4ae8: d10143ff sub sp, sp, #0x50
4aec: a9047bfd stp x29, x30, [sp, #0x40]
4af0: 910103fd add x29, sp, #0x40
如stp x29, x30, [sp, #0x40]
,左边的机器码16进制为0xa9047bfd,二进制如下:
1010 1001 0000 0100 0111 1011 1111 1101
找到文档中的stp指令,以指令译码器的角度进行推导:
2.1、指令的作用是什么,opcode分析
高31-22位 1010 1001 00 表示该指令是一个带符号立即数偏移的俩64位寄存器入栈指令stp
2.2、指令中立即数如何解析,imm段分析
imm7 = 0b0000 1000
21-15位为7为有符号立即数,转为16进制是0x08,我们继续按照文档进行计算:
64位情况下需要对imm进行乘8的操作,也就是左移3位得到0b0100 0000 也就是#0x40
2.3、指令需要访问的寄存器,Rx段分析
14-0表示寄存器,一共有31个寄存器需要编址,每个寄存器占用5bit
Rn = 0b11111 表示x31
也就是sp
寄存器
Rt2 = 0b11110 表示x30
也就是lr
寄存器
Rt = 0b11101 表示x29
也就是fp
寄存器
2.4、根据文档伪代码拼凑指令功能
该指令的功能是将fp和lr两个寄存器压入栈底sp指针上方偏移量为0x40个字节(8个8bytes)的内存区域,更多的细节规则请看文档中的详细描述和伪代码。
PS:《程序员的自我修养:链接、装载与库》中作者谈到纸带打孔编程时代,链接器就出现了(程序太长分为两个纸带,需要进行链接),所以链接器比编译器出现的要早。理解完机器码后,你就可以用0和1手搓代码了,恭喜少侠掌握了
上古秘法之纸带打孔(ARMv8特别版)
,其实第一个汇编器就是芯片工程师用手搓的,这本书强推,虽然作者是以X86为例,但各个指令集架构层面区别不大,对ARM平台也具启发性。
3、ARM64 Linux 进程内存布局
下面解释阅读反汇编代码时理解上一些关键点,比如ELF、进程内存布局、栈帧、寻址方式等
3.1、ELF
arm64 ELF文件head信息如下(vtable文件可以见3.4.2节编译产物),一些是比较好理解的section(节)比如.rodata
,.bss
,data
,text
等,还有一些特殊的section如plt
、got
用于进行符号查找,ef_frame
用与栈回溯和debug,在程序执行时,加载器会对各个section以VMA作为偏移量重新生成地址。文章后面会可看到objdump反汇编文件和lldb调试器执行的代码在地址(代码本身的地址、跳转/加载指令的地址)上有所不同,这些地址在ld装载程序阶段被修正为真实地址:
更多重要的段如eh_frame
、init/fini_array
、debug
等,有机会再探讨,再学下去,这篇博客要写不完了。
3.2、Linux进程内存布局
摘自《Unix环境高级编程 第3版》,使用pmap命令或者cat /proc/<pid>/mmap 查看内存映射情况
3.3、栈
栈是线程执行上关键的内存区域,如参数传递、临时变量(数值、指针变量)、函数调用都要开辟栈空间,这些利用栈进行的操作同一起来叫做函数调用约定(PCS),每种处理器架构和其编译器会遵循其各自标准实现。
arm的栈为满递减型
(FD型)栈,栈顶指针寄存器sp指向栈顶最后一个元素,通过+sp来释放栈空间(push操作),-sp来开辟栈空间(pop操作),同样可以通过fp(栈底指针)+偏移来访问栈上的元素。
关于ARM64栈帧的形成,简单理解FP和SP所夹的内存区域就是一个栈帧,具体我们要看调用函数后,栈帧如何变化,关于栈帧的变化看了一些文章并不是很能理解,还是得亲自跑一遍调试以加深印象,在main函数里面调用createSurfaceExtServiceProxy函数为例
main.cpp
cpp
void* createSurfaceExtServiceProxy() {
//...
}
int main(int argc, char* const argv[]) {
// ...
mHandler = createSurfaceExtServiceProxy();
// ...
return 0;
}
可以看到反汇编的代码,在被调用端的函数会生成对栈操作的代码: 以下注释用#
表示偏移量
less
[disassembly]
surface_ext_cmd`main:
-> 0x555555739c <+108>: bl 0x5555557048 ; createSurfaceExtServiceProxy at main.cpp:12
surface_ext_cmd`createSurfaceExtServiceProx:
-> 0x5555557048 <+0>: sub sp, sp, #0x30 ; sp#-0x30, 开辟栈空间6个64bit(8bytes)空间
0x555555704c <+4>: stp x29, x30, [sp, #0x20] ; 把x29(FP) x30(LR)一对寄存器压栈,LR放入sp#0x28,FP 放入sp#0x20
0x5555557050 <+8>: add x29, sp, #0x20 ; FP寄存器赋值sp#0x20作为新的栈底
// 省略一系列调用balabala
0x555555712c <+228>: ldp x29, x30, [sp, #0x20] ; 弹出FP,LR
0x5555557130 <+232>: add sp, sp, #0x30 ; 释放栈空间
0x5555557134 <+236>: ret
执行0x5555557050
处的代码后,具体stack内存和寄存器的变化如下,注意SP和FP的变化:
执行跳转指令前的栈内存 (voltron是小端序的layout,看着有点不习惯,后面有时间修改为大端)
执行跳转指令前的寄存器
执行跳转指令,完成栈操后作的寄存器
执行跳转指令,完成栈操后作的栈内存
makefile
0x7FFFFFFD98: 01 00 00 00 00 00 00 00 | ........ |
0x7FFFFFFD90: 28 FE FF FF 7F 00 00 00 | (....... |
0x7FFFFFFD88: 00 00 00 00 00 00 00 00 | ........ |
0x7FFFFFFD80: 00 00 00 00 00 00 00 00 | ........ |
0x7FFFFFFD78: A0 FD FF FF 7F 00 00 00 | ........ |
0x7FFFFFFD70: 00 00 00 00 00 00 00 00 | ........ |
0x7FFFFFFD68: F0 27 65 A6 7E 00 00 B4 | .'e.~... |
0x7FFFFFFD60: 20 90 55 55 55 00 00 00 | .UUU... |
0x7FFFFFFD58: 58 76 7A F6 7F 00 00 00 | Xvz..... |
0x7FFFFFFD50: 00 10 00 00 00 00 00 00 | ........ |
栈增长了6个8bytes,其中2个用来入栈调用方的fp和lr
0x7FFFFFFD48: A0 73 55 55 55 00 00 00 | .sUUU... |
0x7FFFFFFD40: B0 FD FF FF 7F 00 00 00 | ........ |
从这里分开来,上面的内存到0x7FFFFFFDB0,都是main()函数的栈帧,
下面多个4个8bytes内存区域,是createSurfaceExtServiceProx()函数的栈帧
注意:栈内存内容不会被清0,栈的伸缩只影响sp、fp的移动,这些栈空间还未用到,继续执行后续代码会利用这些空间保存临时变量。
0x7FFFFFFD38: 38 FE FF FF 7F 00 00 00 | 8....... |
0x7FFFFFFD30: 01 00 00 00 00 00 00 00 | ........ |
0x7FFFFFFD28: 98 73 55 55 55 00 00 00 | .sUUU... |
0x7FFFFFFD20: B0 FD FF FF 7F 00 00 00 | ........ |
[stack]
最后用一张图总结:
栈帧是我们理解线程运行过程的一个关键点,这种FILO结构是一种活动记录,掌握后就可以进一步了解协程和栈回溯(stack unwind)了
3.4、堆
C++中使用new分配的内存被称之为堆。Android C++ new操作一般使用Scudo内存分配器:
source.android.com/docs/securi...
Android安全机制:揭开SCUDO的安全防御策略
通过pmap命令可以查看进程的堆(部分,a part of)在虚拟内存映射情况:
ini
rk3399_ROCKPI4B_Android11:/ # pmap 10943 | grep scudo
0000007de6687000 256K rw--- [anon:scudo:primary]
0000007df6684000 256K rw--- [anon:scudo:primary]
0000007e0668b000 256K rw--- [anon:scudo:primary]
堆没啥内容好讲的,堆属于进程级别的资源,对于调试来说一般跟踪的是线程上下文以了解执行细节,真正需要需要查看堆内存时再通过lldb调试器mem相关指令去查看指定内存区块内容。
3.4、内存寻址指令
3.4.1、虚拟存储系统
不管是Linux内核还是普通用户进程,操作的内存空间都是虚拟地址空间
aarch64 Linux 4级页表的虚拟地址内存布局下,单个页表是4KB,由MMU转换为物理页+页内偏移找到真实内存地址,不管是Linux内核还是普通进程,都是在虚拟地址上跑,这里就不展开,有兴趣可以访问上述查看:
每个物理页都用4级页表的方式查询,这样多次访存效率太低,于是CPU引入了硬件TLB缓存查表结果,具体可看:
3.4.2、adrp指令
armv8作为RISC指令集,指令定长32bit,除去指令编码(opcode)、寄存器编码(Rx)等等内容,留给立即数(imm)的位宽已经很小了,如何实现大范围内存访问就成了一个问题,通过adrp
指令可以找到答案。
绝对地址寻址:
ldr
基于寄存器的基址寻址,寻址整个64bit地址空间。一般无法单独使用,查看反汇编代码也可以看出,这个指令一般是通过sp加载栈上的数据,或者通过adrp先定位地址放寄存器上(一般用X8及更大编号),再用这个指令访存。
相对地址寻址:
adr
和adrp
以当前pc寄存器为基址进行偏移寻址,这两个指令在编译阶后的偏移量与运行时的不同,ELF装载完成后替换为真实的偏移量(这点可能不够严谨,通过实际结果反推的)。
adr
指令进行寻址只能基于pc寄存器进行±1MB范围内(21bit imm, 500个4kb page, 共2MB)的地址,如果调用地址比较远的函数,这个范围明显不够。
adrp
需要登场了!
我们先编写一段C++代码,调用库函数new,用于反汇编分析:
vtable.cpp
cpp
#include <iostream>
using namespace std;
class State {
public:
State(int id) {
this->mId = id;
}
virtual void dump() {
cout << "State mId: " << mId << endl;
}
public:
int mId;
};
class AudioState : public State {
public:
AudioState(int id)
: State(id) {
}
virtual void dump() {
cout << "AudioState mId: " << mId << endl;
}
void dump2() {
cout << "AudioState dump2 mId: " << mId << endl;
}
};
int main() {
cout << "AudioState size : " << sizeof(AudioState) << endl;
AudioState* ps = new AudioState(1);
ps->dump();
ps->dump2();
delete ps;
return 0;
}
执行得到可执行程序vtable:
ini
aarch64-linux-android27-clang++ -std=c++11 -g -O0 -o vtable vtable.cpp
执行得到反汇编代码,只看main函数到调用new AudioState(1)的地方:
llvm-objdump -d vtable > vtable64.S
yaml
0000000000004aa0 <main>:
4aa0: d10143ff sub sp, sp, #0x50
4aa4: a9047bfd stp x29, x30, [sp, #0x40]
4aa8: 910103fd add x29, sp, #0x40
4aac: b81fc3bf stur wzr, [x29, #-0x4]
4ab0: f0000020 adrp x0, 0xb000 <_ZTVN9libunwind12UnwindCursorINS_17LocalAddressSpaceENS_15Registers_arm64EEE+0x28>
4ab4: f9413000 ldr x0, [x0, #0x260]
4ab8: d503201f nop
4abc: 70fe5e81 adr x1, #-0x342d
4ac0: 94000029 bl 0x4b64 <_ZNSt6__ndk1lsB7v170000INS_11char_traitsIcEEEERNS_13basic_ostreamIcT_EES6_PKc>
4ac4: d2800201 mov x1, #0x10
4ac8: f9000be1 str x1, [sp, #0x10]
4acc: 9400149d bl 0x9d40 <_ZNSt6__ndk113basic_ostreamIcNS_11char_traitsIcEEElsEm@plt>
4ad0: d503201f nop
4ad4: 10000821 adr x1, #0x104
4ad8: 94000035 bl 0x4bac <_ZNSt6__ndk113basic_ostreamIcNS_11char_traitsIcEEElsB7v170000EPFRS3_S4_E>
4adc: f9400be0 ldr x0, [sp, #0x10]
4ae0: 9400149c bl 0x9d50 <_Znwm@plt>
... ...
以下是编译器生成的"胶水代码"
0000000000009d50 <_Znwm@plt>:
9d50: d0000010 adrp x16, 0xb000 <_ZTVN9libunwind12UnwindCursorINS_17LocalAddressSpaceENS_15Registers_arm64EEE+0x28>
9d54: f9415a11 ldr x17, [x16, #0x2b0]
9d58: 910ac210 add x16, x16, #0x2b0
9d5c: d61f0220 br x17
0000000000009d60 <_ZdlPv@plt>:
9d60: d0000010 adrp x16, 0xb000 <_ZTVN9libunwind12UnwindCursorINS_17LocalAddressSpaceENS_15Registers_arm64EEE+0x28>
9d64: f9415e11 ldr x17, [x16, #0x2b8]
9d68: 910ae210 add x16, x16, #0x2b8
9d6c: d61f0220 br x17
编译器为new函数生成的符号是_Znwm@plt
它的真正实现在libc++_shared.so 。(这些函数符号的规则令人费解,有兴趣可以自我修养那本书有提及不同编译器的实现,也属于比较有历史厚重感的东西了)
我们让调试器执行到调用new:
si 指令级步入:此时看到new位于vtable程序内,这是编译器生成胶水代码
当前PC = 0x0000 0055 5555 ED50
adrp x16, 2
执行细节:
- 第1步:将一个21位有符号立即数左移12位,得到一个64位数有符号数(0x000 0000 0000 2000)
- 第2步:将PC的低12位清零 (0x0000 0055 5555 E000)
- 第3步:前2步得到的数相加(0x0000 0055 5556 0000)赋值给x16
汇编代码图解:
adrp指令图解:
执行完成后x16 = 0000 0055 5556 0000
这3步操作的意义是以页(4KB)为单位(立即数左移12位操作),相对pc寄存器所在的页(低12位清0)进行寻址偏移(相加),总共能寻址的范围就是pc附近2^21个页一共 +/- 4GB的内存空间(±2^20 * 4KB),可惜的是这个例子中,立即数比较小,没有体现超过adr的大范围的偏移寻址。
需要说明这里的页和虚拟内存的页面大小不是一回事,这个指令的功能是固定的(虚拟内存的页也可以配置成3级索引64KB)
继续单步执行
ini
ldr x17, [x16, #0x2b8] ; 以x16为页,0x2b8为偏移 访存55555602B0,下图观察x17内容
add x16, x16, #0x2b8 ; 上一步只拿到了内容,x16保存地址,暂不清楚作用TODO
br x17 ; 跳转到x17的地址去执行代码
看下库函数所在mmap和x17内容比较:
bash
1|rk3399_ROCKPI4B_Android11:/ # pmap 30877 | grep libc++_shared.so
0000007ff6a55000 624K r---- /vendor/lib64/libc++_shared.so
0000007ff6af1000 640K r-x-- /vendor/lib64/libc++_shared.so ## x17 new函数地址所在区块
0000007ff6b91000 44K r---- /vendor/lib64/libc++_shared.so
0000007ff6b9c000 4K rw--- /vendor/lib64/libc++_shared.so
跳转x17后的汇编代码:
这才调用到真正的new了,也算是真正了解了x16、x17 作为内部过程调用临时寄存器作用
总结:在小范围寻址使用
adr
+ldr
定位,大范围寻址使用adrp
+ldr
定位,所有加载/存储指令类似。
补充说明:运行时的代码段里面的地址与反编译产物vtable64.S
相比发生了变化,adrp x16, 0xb000
变成了adrp x16, 2
、前面已经解释过,这个是运行可执行文件装载时重定位的,0xb000
其实是前面介绍ELF文件时图片里.fini_array
段的VMA起始地址,在本次运行中被替换为2
,执行ldr x17, [x16, #0x2b8]
再经过0x2b8
的偏移,本指令执行完成后x17地址应该会落到got/plt
节区(没细算),从而找到libc++库中的new函数。
3.5、浅析汇编视角下C++面向对象实现
这块对调试不是重点,只是刚好看到Binder的的一些native函数被标记为non-virtual thunk:
C++对象内存布局、非虚函数、虚表指针vptr的内容
主要探讨:
- 对象初始化父类与子类初始化顺序及流程
- 不同类型的函数重写、多态实现
- 父类指针变量指向子类时汇编代码区别
- 堆内存对象布局
- ... ...
可能需要精心设计一些C++代码,以分析典型案例(未完待续)
总结
通过本文的分析,你可以看到汇编本身并不难,在大脑中运行一个图灵机,一条条功能简单指令的构成了丰富多彩的应用程序,希望能对你Native Crash分析、操作系统原理、逆向分析等起到微薄帮助。
PS:ARM64特权特权级别以及Thumb指令的内容没有涉及,有兴趣可以自行搜索。
TODO
更多和平台以及操作系统相关的酌情考虑另开坑:
- 以ARM64为例分析屏障指令分析Android上Java Volatile(AOT编译产物)
- 链接器、程序装载和ASLR原理
- ARM64 C++ Exception实现,Unwind技术、DWARF
- ARM64 FP/SIMD
- ARM64 Linux内核 内存初始化分析
- Android eBPF
- linux-vdso.so分析
- Android 对抗调试与逆向