汇编学习笔记
花了一周的时间,对汇编做了一次深刻的复习和再学习,想记下来的东西有很多,我尽量把总结写全。
0x01.8086汇编
个人认为学习汇编从8086入手对之后的汇编理解有非常大的帮助,这时候很想推荐一本书,王爽的《汇编语言》
,深入浅出地讲解了8086汇编,我自己也是从头刷了一遍,对汇编的世界有了更清楚的认识。总的来说,我认为入门汇编要学的东西,真的不多,因为所有的函数调用来来回回都是这么点东西,接下来我会做一些总结
0x011.总线
总线分为地址总线、数据总线和控制总线,上图是CPU从内存中读取数据的过程,它们很形象地反映了关于他们的各自的职责。当然每根线都他们各自的算法,这里不展开,网上有很多基础资料。
0x012.寄存器
我们都知道当CPU做大部分运算的时候,都是将内存的值读到CPU中,就是存在我们所说的寄存器
中,再在CPU中进行加减乘除与或非这些基本的逻辑运算。所有的程序都是基于这成千上万次上述的操作。
8086CPU有14个寄存器:AX、BX、CX、DX、SI、DI、SP、BP、IP、CS、SS、DS、ES、PSW都是16位。这其中又分为通用寄存器和段寄存器、标志寄存器等。
AX、BX、CX、DX这四个被称为通过寄存器,他们主要是存储数据的,因为是16位的,所有只有"可怜"的2字节可以存。这其中又分为高八位和低八位,主要是为了空间的合理利用。
而段寄存器包括CS(Code segment)和IP(偏移)合成指令的物理地址、DS(Data segment)存放要访问数据的段地址、SS(Stack segment)和SP(偏移)。另外一个BP寄存器一般用来存放栈的基址来配合操作栈
。
标志寄存器(flag)记录当前指令结果的状态。
零标志位(Zero Flag)
如果结果为0,那么zf = 1(表示结果是0);如果结果不为0,那么zf = 0。
奇偶标志位 (PF)
,如果1的个数为偶数,pf = 1,如果为奇数,那么pf = 0。
符号标志位(Symbol Flag)
,记录其结果是否为负。如果结果为负,sf = 1;如果非负,sf = 0。
进位标志位(CF)
在进行无符号数运算
的时候,它记录了运算结果的最高有效位向更高位的进位值,或从更高位的借位值。
溢出标志位(OF)
,记录了有符号数运算的结果是否发生了溢出。如果发生溢出,OF = 1;如果没有,OF = 0。
0x013.栈
栈的开口一般是向上的,栈底为高地址,栈顶为低地址,开栈的操作是从高地址往低地址走。
总的来说,上图红线间隔区域就是一个函数调用栈
也称为一个栈帧
,即使是现在方法调用的实现原理也依然是大同小异。在一个栈帧当中,包含了函数所需要的环境。比如参数、局部变量、以及返回地址(回家的路)。函数和函数之间就是这样一段接着一段进行执行。首先参数入栈,该参数是下个函数调用需要使用的参数,然后记录方法下一条的执行地址,方便方法返回后继续执行,之后用BP存一下栈帧的基地址和拉伸SP开启栈帧内部的域,之后在函数体内的局部变量都存在这个栈帧中,如果还有下一个方法那么继续入参和保存返回地址重复上述操作。如果方法调用完毕,所有的环境都会被POP完,回到上一个函数继续执行,所以这也就是为什么局部变量过了方法,再访问就会出现很大的危险,因为该域已经是一个释放掉的域。
PS:在开发过程中经常碰到Stack over flow(栈溢出)就是因为开发者不停地在开栈帧,却得不到释放,栈的空间被用光了,比如你写了一个没有边界限制的递归,反正我日常开发能不写递归就不写,如果写递归我会尤其要注意边界的条件,及时return。我一直觉得递归本身效率就不高,只是代码看起来易读、易理解罢了。
0x014.栈平衡
栈平衡又分为外平栈和内平栈,其实没有什么特别的东西,就是当函数调用完毕之后需要把SP拉回来
,否则栈迟早会被用完、因为所有的开栈操作都是通过SP为基址进行的。内平栈就是在栈帧内部做平衡,外平栈就是函数调用完进行平栈。
0x02.ARM64
之后来到现代社会,看看如今的arm64和8086的区别
0x021.寄存器
ARM64也拥有有31个64位的通用寄存器 x0 到 x30,他们也是通常用来存储数据,w0 到 w28 这些是32位的. 因为64位CPU可以兼容32位.所以可以只使用64位寄存器的低32位.比如 w0 就是 x0的低32位!
PC寄存器(program counter)为指令指针寄存器,它指示了CPU当前要读取指令的地址
浮点寄存器 64位: D0 - D31 32位: S0 - S31,向量寄存器 128位:V0-V31
SP寄存器其实是X31寄存器,他在任意时刻会保存我们栈顶的地址.
(是不是和8086的SP一模一样)
FP寄存器也称为x29寄存器属于通用寄存器,一般我们利用它保存栈底的地址(是不是和8086的BP很像)
x30寄存器也称为LR寄存器,存放的是函数的返回地址.当ret指令执行时刻,会寻找x30寄存器保存的地址值!
ARM64同样有状态寄存器,这和8086大同小异。
0x022.高速缓存
CPU每执行一条指令前都需要从内存中将指令读取到CPU内并执行。而寄存器的运行速度相比内存读写要快很多,为了性能,CPU开始有了一个高速缓存存储区域.当程序在运行时,先将要执行的指令代码以及数据复制到高速缓存中去(由操作系统完成).CPU直接从高速缓存依次读取指令来执行,iPhoneX上搭载的ARM处理器A11它的1级缓存的容量是64KB,2级缓存的容量8M。
0x023.关于内存读写指令
注意:读/写 数据是都是往高地址读/写
str(store register)指令
将数据从寄存器中读出来,存到内存中.
ldr(load register)指令
将数据从内存中读出来,存到寄存器中
此ldr 和 str 的变种ldp 和 stp 还可以操作2个寄存器.
0x024.函数调用栈
关于函数调用时对栈的处理其实基本是和8086差不多,只要懂了8086汇编,理解起ARM64函数调用就非常容易了,只是在规则上略有不同。
- 一般来说 arm64上 x0 - x7 分别会存放方法的前 8 个参数。
- 如果参数个数超过了8个,多余的参数会存在栈上,新方法会通过栈来读取。因此少量的参数可以帮助我们提升性能,CPU计算,肯定比先读内存再计算来的快。
- 方法的返回值一般都在 x0 上。
- 如果方法返回值是一个较大的数据结构时,结果会存在 x8 执行的地址上。
0x03.思考与感悟
下面聊一下反思与感悟。
0x031.ARM64在进行栈拉伸的时候到底是如何计算的
开始的时候我一直很不理解的一件事就是:它是怎么知道每个栈需要开辟多少空间的?比如我们在方法内部用到的局部变量一多,他就会开辟多一些的栈空间(因为需要字节对齐的缘故,基本是以0x10、0x20、0x30..规则拉伸),当入参变多时,他同样会开辟多一些的空间,即使你在内部没有用到,还有当遇到可变参数时,他的规则又是怎么样的。找了很久,终于在公司内网找到了汇编大牛指引了方向。其实在arm的开发者官网上都有说明 https://developer.arm.com/documentation/ihi0055/latest/ ,英文不好的同学可以查看译文arm64程序调用规则。
总而言之,言而总之。对于调用者而言,即使他认为在调用之前已经分配了足够的堆栈空间来容纳的参数,但实际上,只有在参数封装处理之后才能知道所需的堆栈空间量到底需要多少。(因为会牵涉到指针变量、结构体、可变参数等等)。因此参数传递过程大致分为3个阶段:
-
阶段A – 初始化 (在开始处理参数之前,该阶段仅执行一次)
- NGRN = 0
- NSRN = 0
- NSAA = SP(NSAA设置为当前的SP)
-
阶段B - 预填充和扩展参数 (把参数列表中的每一个参数,去匹配下面规则,第一个被匹配到的规则,应用到该参数上。如果没有规匹配到规则,那么参数不修改)
- 如果参数类型是复合类型,调用者和被调用者都不能确定其大小,则将参数复制到内存中,并将参数替换为指向该内存的指针。 (C / C ++语言中没有这样的类型,其它语言存在。)
- 如果参数是HFA或HVA类型,则参数不修改。
- 如果参数是大于16个字节的复合类型,调用者申请一个内存,将参数复制到内存里去,并将参数替换为指向该内存的指针。
- 如果参数是复合类型,则参数的大小向上舍入为最接近8个字节的倍数。(例如参数大小为9字节,修改为16字节)
-
阶段C- 把参数放到寄存器或栈里 (参数列表中的每个参数,将依次应用以下规则,直到参数放到寄存器或栈里,此参数处理完成,然后再从参数列表中取参数。注: 将参数分配给寄存器时,寄存器中未使用的位的值不确定。 将参数分配给栈时,未填充字节的值不确定。)
- (1) 如果参数是half(16bit),single(16bit),double(32bit)或quad(64bit)浮点数或Short Vector Type,并且NSRN小于8,则将参数放入寄存器v[NSRN]的最低有效位。 NSRN增加1。 此参数处理完成。
- (2) 如果参数是HFA(homogeneous floating-point aggregate)或HVA(homogeneous short vector aggregate)类型,且NSRN + (HFA或HVA成员个数) ≤ 8,则每个成员依次放入SIMD and Floating-point 寄存器,NSRN=NSRN+ HFA或HVA成员个数。此参数处理完成。
- (3) 如果参数是HFA(homogeneous floating-point aggregate)或HVA(homogeneous short vector aggregate)类型,但是NSRN已经等于8(说明v0-v7被使用完毕)。则参数的大小向上舍入为最接近8个字节的倍数。(例如参数大小为9字节,修改为16字节)
- (4) 如果参数是HFA(homogeneous floating-point aggregate)、HVA(homogeneous short vector aggregate)、quad(64bit)浮点数或Short Vector Type,NSAA = NSAA+max(8, 参数自然对齐大小)。
- (5) 如果参数是half(16bit),single(16bit)浮点数,参数扩展到8字节(放入最低有效位,其余bits值不确定)
- (6) 如果参数是HFA(homogeneous floating-point aggregate)、HVA(homogeneous short vector aggregate)、half(16bit),single(16bit),double(32bit)或quad(64bit)浮点数或Short Vector Type,参数copy到内存,NSAA=NSAA+size(参数)。此参数处理完成。
- (7) 如果参数是整型或指针类型、size(参数)<=8字节,且NGRN小于8,则参数复制到x[NGRN]中的最低有效位。 NGRN增加1。 此参数处理完成。
- (8) 如果参数对齐后16字节,NGRN向上取偶数。(例如:NGRN为2,那值保持不变;假如NGRN为3,则取4。 注:iOS ABI没有这个规则)
- (9) 如果参数是整型,对齐后16字节,且NGRN小于7,则把参数复制到x[NGRN] 和 x[NGRN+1],x[NGRN]是低位。NGRN = NGRN + 2。 此参数处理完成。
- (10) 如果参数是复合类型,且参数可以完全放进x寄存器(8-NGRN>= 参数字节大小/8)。从x[NGRN]依次放入参数(低位开始)。未填充的bits的值不确定。NGRN = NGRN + 此参数用掉的寄存器个数。此参数处理完成。
- (11) NGRN设为8。
- (12) NSAA = NSAA+max(8, 参数自然对齐大小)。
- (13) 如果参数是复合类型,参数copy到内存,NSAA=NSAA+size(参数)。此参数处理完成。
- (14) 如果参数小于8字节,参数设置为8字节大小,高位bits值不确定。
- (15) 参数copy到内存,NSAA=NSAA+size(参数)。此参数处理完成。
从上面规则,可以得到经验:
- 处理完参数列表中所有的参数后,调用者通过特定的规则一定知道传递参数用了多少栈空间。(NSAA - SP)
- 浮点数和short vector types通过v寄存器和栈传递,不会通过r寄存器传递。(除非是小复合类型的成员)
- 寄存器和栈中,参数未填充满的部分的值,不可确定。
0x032.AT&T汇编和 Intel汇编的区别
AT&T 就是我们在开发的ARM系列的汇编,Intel汇编就是我们说的x86汇编,其实在实现思路上,他们大致还是相同的。只是在指令集和寄存器的设计上略有不同。
所以Xcode模拟器(Intel)和我们的真机调试(ARM)还是有一些略微不同,在调试的时候也要略微的注意语法。比如MOV的赋值操作,操作数和被操作数需要反一反。
0x033.msgSend的Hook
有了之前的基础之后,我们可以用汇编来做一些很trick的事,比如在iOS的开发过程中,我们可以hook objc的msgSend
进行一些耗时方法的统计。其实msgSend
本身是可以用fishhook
去做hook的,我们知道像msgsend
这类的系统函数,是需要在启动时动态绑定的(和NSlog
一样),因此可以成功hook。难点在于,msgSend的入参是可变参数,还有是否有返回值、返回值类型等诸多问题,所以用常见的开发语言是无法实现此类的函数的,所以他的实现本身就是用汇编实现的,不能当简单的函数hook来处理。但是要强行hook还是能做到的,github也已经有较现成的方法:https://github.com/czqasngit/objc_msgSend_hook,在这里讲一下作者大致的思路。
1.首先使用fishhook
去hook msgsend
,用hook_objc_msgSend
去替换origin_objc_msgSend
。
2.在hook_objc_msgSend
第一步首先我们需要保存函数的环境,所以将x0-x7依次入栈。
3.这时候作者是开始执行了自己的函数(beforeCall),比如当前时间打点,保存类名信息等等,之后通过线程私有共享缓存进行保存。
4.依次将寄存器数据出栈,恢复环境,进行原始方法origin_objc_msgSend
的调用。
5.重复2,3的操作,执行自己的函数(afterCall),比如记录方法的耗时操作等等。
6.调用ret,继续执行X30中的函数地址,让程序继续执行下去。
以上就是该作者的大致思路,实现上确实不难,能想到怎么实现才是真的难。
类似这样的实现思路还能做很多事,比如hook全局的block,思路差不多,先用fishhook hook系统函数,然后用汇编处理函数环境 比如这位大佬 https://github.com/youngsoft/YSBlockHook。
0x04.总结
通过本次的学习,对汇编的实现有了新的认识,也学到了用汇编去帮助我们去实现一些技术方案,收获也是颇丰。其实当我们深入去看日常的汇编代码的时候,我们会发现日常接触到的所有函数,搞来搞去就这么点操作,入栈出栈、存储读取等等,只要熟练掌握规则,其实看汇编真的只是烦,但他不难!
五一颓废了,5天基本都在玩,没学习,挤出这么点学习成果自我安慰下。
0x05.引用
https://github.com/czqasngit/objc_msgSend_hook