美文网首页
让你理解ARM64汇编语言

让你理解ARM64汇编语言

作者: 黑色蚂蚁_MGL | 来源:发表于2020-06-23 16:26 被阅读0次

    咱们本篇文章讲的语法不多,因为语法已经有很多文章可以参考学习,本篇主要讲的是怎么去理解汇编。

    首先了解计算机结构

    • 总的来说计算机分为CPU、内存、硬盘、外设。因为咱们是前端开发,可以忽略外设,所以结构就如下图。手机的运行内存比较常见的是4G、8G,但是相对动辄就128G、256G的硬盘来说,还是很小的。为啥内存要那么小呢,和硬盘有啥区别呢

      1. CPU的处理速度比硬盘的读取速度快很多,比方说CPU一秒可以处理1000个数据,但是硬盘1秒只能读出10个数据,造成CPU的性能发挥不出来
      2. 这时候内存就出来了,因为内存的读取速度比硬盘快很多,所以可以先把硬盘的部分数据加载到内存,让CPU直接从内存读数据处理,这样性能就会好很多
      3. 因为内存的制造成本高,从手机分级就能看出来,比如低配4G内存 + 128G硬盘,但是高配就可以到8G内存 + 256G硬盘,差几百,硬盘可以升级那么多,内存就升级一点。正因为价格高,所以内存一般不大
    CPU、内存、硬盘
    • CPU的介绍,CPU由3部分组成,分别是运算器,控制器,寄存器。
      1. 运算器,顾名思义,就是处理数据的,比如运算1+1
      2. 控制器,就是控制着把数据从内存加载到CPU,并且解析这个数据是干啥的,比如是做加法的话就送到运算器进行处理
      3. 寄存器,重点来了咱们上面说了内存的存在是为了缩小和CPU处理速度的,但是很不幸的是,虽然内存的速度虽然比硬盘快,但是还是跟不上CPU的速度,所以,在CPU里也有存储数据的地方,他叫寄存器,作用呢和内存一样就是存储数据的,但是他的读取速度比内存更快,当然成本也更高。让CPU里的运算器直接从寄存器加载数据可以最大限度的发挥CPU的作用
        cpu1.png

    汇编要来了

    • 汇编为啥是底层语言

      1. 咱们知道,一台计算机或者手机可以工作,最核心的东西就是CPU

      2. 咱们平时的编程语言直接操作CPU了吗?答案是并没有。比如说用Swift、Java或者C语言定义了一个变量a = 10,b = 20,并且计算a+b,或者创建了一个对象,或者获取一个变量的地址,咱们脑海里想的都是在内存上开辟了一块空间,放了个变量a,或者在内存上创建了一个dog对象,或者获取内存区域某个变量的地址。我把平时的高级编程语言抽象为“面向内存编程”,因为咱们脑海里都想的是在内存上怎么着怎么着,如下图


        CPU、内存、硬盘.png
      3. 咱们上面说了,为了不让内存拖CPU的后腿,CPU里自带内存,也就是寄存器,汇编就是可以直接操作CPU里的寄存器和内存的语言,所以汇编是面向底层的语言。ARM64 有很多个寄存器,包括X0~X28、LR、SP、PC、CPSR,咱们列举几个,以下图六个寄存器X1、X2、X3、SP、PC、LR为例讲解汇编


        寄存器内存2.png
    • 汇编开始啦

      • 寄存器本身就是用于存储数据的,但是寄存器是在CPU内部

      • 寄存器和寄存器之间传数据

        1. 如果我想把寄存器X2的赋值给X1怎么操作呢,汇编的写法就是:MOV X1,X2
        2. 如果我想把X2寄存器里的值和X3里的加起来放到X1里怎么做呢,汇编的写法就是 ADD X1,X2,X3
        3. 如果我想用X3寄存器里的值减掉X2里的值放到X1里怎么做呢,汇编的写法就是 SUB X1,X3,X2
      • 寄存器和内存之间传数据

        1. 以上图为例,比如我想把地址是FFF0的内存单元的10取出来存放到寄存器X1怎么做呢,汇编的写法就是 LDR X1,[FFF0],但是平时见到的没有直接在中括号里写地址的,一般都是先把要取得内存的地址放到另一个寄存器,比如 MOV X2, FFF0,然后再
          LDR X1,[X2]。也就是先把地址放到一个寄存器里,再根据寄存器寻址

        2. 以上图为例,比如我想把寄存器X3的值写入到内存是FFF1的地址怎么写呢,先把地址放到另一个寄存器中 MOV X2, FFF0
          然后再用存储命令STR X3,[X2]

        3. 简单的总结:知道为啥汇编语言更底层了吧,因为这种语言可以直接操作CPU,咱们普通语言是做不到的。语法规则就是除了从CPU往内存里存数据用的STR相关的命令是从左往右读以外,其余的基本是和咱们高级语言一样,从右往左

      • 指令的加载

        1. 假如我定义了一个整型变量int a = 65,那么a在内存里的数据就是0100 0001(十进制就是65),如果我定义了一个字符变量 char a = 'A',那在内存里存的数是啥,因为计算机只能存储二进制0和1,所以需要先把'A'转成对应的ASCII码65,所以实际上,'A'在计算机上存的也是0100 0001(十进制就是65)。所有数据在内存上都是以0和1存储的,内存不知道他是啥类型,就看你把他当成啥类型处理,如下面。

             NSInteger a = 65;
             NSLog(@"%ld",a);
             NSLog(@"%c",a);
              -----------------打印结果如下-----------------------            
             OCTest[35482:8821755] 65    
             OCTest[35482:8821755] A
          
        2. 比如咱们开发了一个90M的软件,在运行的时候,CPU就开始处理,但是CPU需要从哪里开始执行呢,咱们90M的包里包含代码,也包含一些全局变量的数据等,代码是可执行的,数据是用来参与运算的。假如说 MOV X1,X0 对应的二进制是 0110 0100 , 恰巧可执行文件的第一行代码就是0110 0100, CPU怎么知道这行代码是命令还是数据,如果当成命令,CPU做的就是把寄存器X0的值赋值给X1,如果当成数据,那就代表十进制的100。其实咱们写的代码在编译链接后生成可执行文件时,就已经把这90M的包分好了,哪块是代码段,哪块是数据段。这样CPU处理的时候就不会混乱了,下图是用xcode编译生成的可执行文件,可以看出分的很详细,有代码段有数据段


          可执行文件2.png
          1. PC寄存器要出现了,PC寄存器俗称PC指针,CPU运行哪条指令取决于PC寄存器存的是哪条指令的地址,也就是说,PC寄存器指向哪,程序就运行哪。程序刚开始运行的时候PC寄存器会存储代码段的第一行所在内存的地址,后续每执行一条指令,PC寄存器会默认加4指向下一条指令(加4是因为ARM64汇编的每条指令占4个字节)。比如下面的程序,刚开始运行的时候,PC寄存器存储着地址是0x10004e1e0,当开始执行第一条指令时,会默认指向下一条指令的地址0x10004e1e4
            0x10004e1e0:  sub    sp, sp, #0x20             ; =0x20 
            0x10004e1e4:  stp    x29, x30, [sp, #0x10]
            0x10004e1e8:  add    x29, sp, #0x10            ; =0x10 
            0x10004e1ec:  adrp   x8, 3
            0x10004e1f0:  add    x8, x8, #0x3d0            ;   
            
      • 指令的跳转
        1. 函数的调用:看下面的OC代码,咱们都知道当执行到第二行时,会跳到另一个函数test3WithParamB,也就是第6行,当执行完第8行以后,会回到上面的第三行继续执行,也就是函数跳转。这个在汇编层面是怎么做到的呢。咱们上面不是刚说PC寄存器,执行完一条指令会默认加4。现在怎么还会跳了呢。

         1     - (void)test2WithParamA:(NSInteger)a b:(NSInteger)b c:(NSInteger)c {
         2          [self test3WithParamB:103 b:104 c:105];
         3          NSInteger total = a + b + c;
         4     }
         5
         6      - (void)test3WithParamB:(NSInteger)a b:(NSInteger)b c:            (NSInteger)c {
         7           NSInteger total = a + b + c;
         8      }
        
        1. 跳转指令BL
          咱们之前说,当开始执行一条指令的时候,PC寄存器会默认加4,指向下一条指令的地址。但是这说的是默认,如果遇到函数跳转就不会了,比如咱们当前执行的指令地址是0x10004e10,要跳转的函数地址是0x10004e1e怎么办,可以直接把PC寄存的值改成0x10004e1e就可以了。想法是对的,只是ARM64汇编不允许直接修改PC寄存器,但是可以通过跳转指令BL,比如 BL 0x10004e1e,这样就会把PC寄存器改成0x10004e1e,并且开始执行0x10004e1e处的指令
        2. 函数返回
          上面说了函数调用的跳转指令BL,就是直接拿到被调用函数的地址,然后跳过去。但跳过去,函数执行完了以后,怎么回去呢,还是以上面的代码为例,当执行第2行时,会跳到第6行,执行完第8行后,应该回到第3行,可是计算机怎么知道回到哪呢,如果不做特殊处理,按照之前的说法PC寄存器会默认执行第9行的代码。这时LR寄存器出场了,LR(x30)通常称X30为程序链接寄存器,保存子程序结束后需要执行的下一条指令,其实咱们在执行跳转指令BL时,CPU除了将PC寄存器里的值修改成要跳转的地址以外,还会存储跳转回来以后要执行的指令的地址(以上面的程序为例,就是保存第3行的地址),存到哪呢,就是存到了LR寄存器。当函数结束以后,就把PC寄存器的值,修改为LR寄存器的值,这样就相当于回到调用的地方了
      • 参数问题
        1.函数调用,在汇编层面就是修改PC寄存器的值达到跳转的目的,但是如果调用的函数需要传参,参数放哪呢?答案还是寄存器,只不过用的是普通的寄存器x0 ~ x7: 用于子程序调用时的参数传递,以下面的程序为例,调用test2Wit时,需要传参,参数分别是100、101、102,可以看地址是0x10001e1c0的相邻的三个指令,就是把0x64(十进制的100)、0x65、0x66存到X2、X3、X4。然后就调用 bl 0x10001e5d4跳走了

           - (void)test1 {
               [self test2WithParamA:100 b:101 c:102];
           }
             --------对应的汇编如下-------------
           OCTest`-[AppDelegate test1]:
           0x10001e19c <+0>:  sub    sp, sp, #0x20             ; =0x20 
           0x10001e1a0 <+4>:  stp    x29, x30, [sp, #0x10]
           0x10001e1a4 <+8>:  add    x29, sp, #0x10            ; =0x10 
           0x10001e1a8 <+12>: adrp   x8, 3
           0x10001e1ac <+16>: add    x8, x8, #0x3c0            ; =0x3c0 
           0x10001e1b0 <+20>: str    x0, [sp, #0x8]
           0x10001e1b4 <+24>: str    x1, [sp]
           0x10001e1b8 <+28>: ldr    x0, [sp, #0x8]
           0x10001e1bc <+32>: ldr    x1, [x8]
           0x10001e1c0 <+36>: mov    x2, #0x64
           0x10001e1c4 <+40>: mov    x3, #0x65
           0x10001e1c8 <+44>: mov    x4, #0x66
           0x10001e1cc <+48>: bl     0x10001e5d4               ; symbol stub for: objc_msgSend
           0x10001e1d0 <+52>: ldp    x29, x30, [sp, #0x10]
           0x10001e1d4 <+56>: add    sp, sp, #0x20             ; =0x20 
           0x10001e1d8 <+60>: ret 
        
        1. 那调到test2WithParamA后,怎么取参数的呢,看下图0x1000a2260地址对应的指令,会把X2、X3、X4的值先存到内存里,然后再从对应的内存地址取来来做加法,虽然没直接用X2、X3、X4做加法,但是用的值,是从最初的X2、X3、X4里的值。咱们发现每个函数结束后,都会有个ret指令,比如下面0x1000a2288对应的指令,这个ret的作用就是告诉CPU我这个函数结束了,如果需要返回到调用函数的地方,就把PC寄存器的值修改为LR寄存器里值,这样就可以调回去了
     - (void)test2WithParama:(NSInteger)a b:(NSInteger)b c:(NSInteger)c {
          NSInteger total = a + b + c;
     }
     -----------对应的汇编如下---------------
     OCTest`-[AppDelegate test2WithParama:b:c:]:
     0x1000a2254 <+0>:  sub    sp, sp, #0x30             ; =0x30 
     0x1000a2258 <+4>:  str    x0, [sp, #0x28]
     0x1000a225c <+8>:  str    x1, [sp, #0x20]
     0x1000a2260 <+12>: str    x2, [sp, #0x18]
     0x1000a2264 <+16>: str    x3, [sp, #0x10]
     0x1000a2268 <+20>: str    x4, [sp, #0x8]
     0x1000a226c <+24>: ldr    x0, [sp, #0x18]
     0x1000a2270 <+28>: ldr    x1, [sp, #0x10]
     0x1000a2274 <+32>: add    x0, x0, x1
     0x1000a2278 <+36>: ldr    x1, [sp, #0x8]
     0x1000a227c <+40>: add    x0, x0, x1
     0x1000a2280 <+44>: str    x0, [sp]
     0x1000a2284 <+48>: add    sp, sp, #0x30             ; =0x30 
     0x1000a2288 <+52>: ret    
    
    • SP寄存器
      在上面的汇编代码里,经常看到类似[sp, #0x18]的写法,这个咱们大概说一下,只要带着中括号[]的一般就是表示内存的某个地址。而SP指向的是内存中的栈顶,内存分为代码段和数据段,数据段里有一个栈的部分,就是先进后出的数据结构的内存。这种先进后出,后进先出的数据结构就是为了方便临时存储数据,比如函数跳转,参数太多的话,就可以先存到栈上,用完就销毁。比如函数跳转,如果连续跳转,比如A函数调到B,还没回到A呢,继续调到B,那LR寄存器就一个怎么办,没法保存好几个返回地址,那就可以先保存到内存的栈里,等用到的时候,再取出来。

    总结

    汇编语言是一门可以直接操作CPU和内存的语言

    相关文章

      网友评论

          本文标题:让你理解ARM64汇编语言

          本文链接:https://www.haomeiwen.com/subject/fwgcfktx.html