人人都能成为汇编高手 —— 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

相关推荐
拭心11 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王13 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡13 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道14 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库15 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道15 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe15 小时前
Android Hook - 动态加载so库
android
居居飒16 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He19 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗19 小时前
Android笔试面试题AI答之Android基础(1)
android