人人都能成为汇编高手 —— Android ARM64调试 从入门到入土

人人都能成为汇编高手 ------ 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指令集的一些特点:

blog.51cto.com/u_15278218/...

如果需要了解某个指令,可以直接查看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,.bssdata,text等,还有一些特殊的section如pltgot用于进行符号查找,ef_frame用与栈回溯和debug,在程序执行时,加载器会对各个section以VMA作为偏移量重新生成地址。文章后面会可看到objdump反汇编文件和lldb调试器执行的代码在地址(代码本身的地址、跳转/加载指令的地址)上有所不同,这些地址在ld装载程序阶段被修正为真实地址:

深入了解GOT,PLT和动态链接

更多重要的段如eh_frameinit/fini_arraydebug等,有机会再探讨,再学下去,这篇博客要写不完了。

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内核还是普通用户进程,操作的内存空间都是虚拟地址空间

www.kernel.org/doc/html/v5...

aarch64 Linux 4级页表的虚拟地址内存布局下,单个页表是4KB,由MMU转换为物理页+页内偏移找到真实内存地址,不管是Linux内核还是普通进程,都是在虚拟地址上跑,这里就不展开,有兴趣可以访问上述查看:

每个物理页都用4级页表的方式查询,这样多次访存效率太低,于是CPU引入了硬件TLB缓存查表结果,具体可看:

www.cnblogs.com/luoahong/p/...

3.4.2、adrp指令

armv8作为RISC指令集,指令定长32bit,除去指令编码(opcode)、寄存器编码(Rx)等等内容,留给立即数(imm)的位宽已经很小了,如何实现大范围内存访问就成了一个问题,通过adrp指令可以找到答案。

绝对地址寻址:

ldr基于寄存器的基址寻址,寻址整个64bit地址空间。一般无法单独使用,查看反汇编代码也可以看出,这个指令一般是通过sp加载栈上的数据,或者通过adrp先定位地址放寄存器上(一般用X8及更大编号),再用这个指令访存。

aarch64体系结构与编程5--汇编指令的一些坑

相对地址寻址

adradrp以当前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:

What is a 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 对抗调试与逆向

Reference

相关推荐
梓仁沐白14 分钟前
Android清单文件
android
董可伦2 小时前
Dinky 安装部署并配置提交 Flink Yarn 任务
android·adb·flink
每次的天空3 小时前
Android学习总结之Glide自定义三级缓存(面试篇)
android·学习·glide
恋猫de小郭3 小时前
如何查看项目是否支持最新 Android 16K Page Size 一文汇总
android·开发语言·javascript·kotlin
flying robot5 小时前
小结:Android系统架构
android·系统架构
xiaogai_gai5 小时前
有效的聚水潭数据集成到MySQL案例
android·数据库·mysql
鹅鹅鹅呢6 小时前
mysql 登录报错:ERROR 1045(28000):Access denied for user ‘root‘@‘localhost‘ (using password Yes)
android·数据库·mysql
在人间负债^6 小时前
假装自己是个小白 ---- 重新认识MySQL
android·数据库·mysql
Unity官方开发者社区6 小时前
Android App View——团结引擎车机版实现安卓应用原生嵌入 3D 开发场景
android·3d·团结引擎1.5·团结引擎车机版
进击的CJR9 小时前
MySQL 8.0 OCP 英文题库解析(三)
android·mysql·开闭原则