【补基础系列】初识汇编

409 阅读17分钟

前言

为了能看懂CSAPP第三章,卡在那个汇编部分好久了,再加上忙毕业论文,一直耽搁着,今天准备看一下汇编的基础,重要的参考文章如下:

本文还参考了一些其他的文章,不过没有列举,重点是前两篇中文文章以及b站up-九曲阑干的视频

首先直接列出对我有帮助的一些帖子或者评论,当做是本文的注释,省得万一有读者以后读到的时候,发现问题想不通的时候还得去搜,先把我学习中遇到的问题和答案直接摆出来,带着这些再去看本文就简单一些

调用者策略和被调用者策略

CSAPP-深入理解计算机系统第p31-《程序的机器级表示中》,视频第五分钟左右,知道了调用者策略和被调用者策略

简而言之该策略如下:

  1. 一个函数中引用了另一个函数,那么因为某些寄存器有固定用法的原因,可能被多次用到;

  2. 在上层函数中可能已经用到了某个寄存器,比如rbx,那么此时rbx中保存着上层函数的数据,此处假设保存了1

  3. 在程序运行到内层函数的时候,有可能会再次需要用到rbx,所以为了数据不被覆盖,需要先保存之前的1,再运行该内层函数的逻辑,完事之后再恢复之前暂存的数据1rbx中。

image.png

比如通常被调用者保存用的是如下汇编代码,push 来保存 寄存器rbx中的数据, pop 来恢复

_fun_B:
   push   %rbx
   ******
   函数具体功能
   ******
   pop    %rbx 
   ret  
  • 那么就出现了困扰我的一个问题,这个暂存的数据,存到哪了 ???

《深入理解计算机系统》 练习题3.27-3.28 被调用者保存寄存器 栈指针这篇文章让我知道了,这些调用者策略或者被调用者策略中,用来暂时保存的寄存器中的数据,放到哪了,原来是放进了里面,怪不得是叫pushpop。如下图所示:

image.png

所以应该也是会在栈中占据比如4个字节的大小

栈中的帧

给我的感觉就是两个指针—ebp(帧底)和esp(帧顶),确定了一个区域(范围),比如一个main函数,各种数据占据了内存的一块区域,此处我们把这种存储看成管子,即是一条线,那么这条线中的哪一段才是表示该函数的,就是由这俩指针来确定的,俩指针中间的区域就是main的帧,而这个帧是处于栈中的。

阮一峰老师的文章中的评论有一条如下,我觉得就是说的还原_main函数帧底的操作

image.png image.png

所以按我的理解,在保留上一段帧的ebp的时候,存在了栈里,是会占据比如4个字节的大小的,这点与阮一峰老师文章里面的说法不太一样,是看的参考2文章里得到的结论,也能说得通,目前只是初识,暂时这样认为,后期深入了,如有错误再更正。

正文

本文先描述基本的概念和语法,然后结合阮一峰的帖子里的例题,画一遍自己的理解,跟他帖子里略有不同,结合了一些其他帖子,所以不保证是完全正确的,但是大部分能对得上,能自圆其说,若有错误欢迎指正

高级语言与汇编语言

现在我们在vscode里写的都是易于理解的高级语言,但是计算机能运行的代码,并不是Java、Js这些语言,计算机只认高低电平,高电平电路怎么走,低电平电路又怎么走,前人总结后,对应下来也就是用 0 1 0 1 的形式来体现高低电平,所以计算机只认识机器码

机器码全是二进制的0和1,不容易理解,不是不容易,是人根本就不能理解,就需要来一个中间的桥梁,进行转换,一边是人能理解的语言,一边是机器码。

具体机器码如何运作,需要结合寄存器,通过学习这个视频可以有个初步的认识—# 从0到1设计一台计算机,举个例子,简单来说就是让一个由01组成的32位的数字串,其中分成几部分,比如其中一部分的意思是从第x位到第y位表示xxx寄存器的内存读取地址(之类的意思),这样的一串 0 1 机器码组成的数字串就是指令。

image.png

一个指令可以让cpu内的各个寄存器互相配合完成一个加法运算,并存放结果到目标内存上去的一个操作,多个指令在一起就是指令集,我们所写的所有Js代码,最终目的都是要让计算机做出相应的操作,比如说我们写了一段加法的代码

    let a = 1 , b = 2
    let c = a + b

高级语言通过编译程序变成汇编语言,再通过汇编程序,翻译到目标机器码的指令,指令作用到计算机上,就能运行相应的代码了。

以上是举的一个例子,实际情况不是完全一致,比如还要细分解释型语言和编译型语言,刚刚写的Js代码就是解释型,而C语言就是编译型,解释型语言需要解释程序(解释器),而编译型语言需要编译程序(编译器),具体可以看看这篇文章—# 编译型语言和解释型语言的区别,而Js是解释型语言,不需要经过编译程序,它只需要浏览器就可以运行,相当于浏览器充当解释器的角色,浏览器把Js的高级语言转换成了计算机能识别的机器码,其中可能是V8引擎或者什么浏览器内核之类的,这是我猜的,具体的以后有机会再去了解。

image.png

汇编语言

如果说高级语言是平时写的,人易读懂的语言,那么机器码就是人完全读不懂的语言,而汇编语言则是介于之间的一种,能读懂,但是却没那么容易读懂的语言,并且每一种 CPU 的机器指令都是不一样的,因此对应的汇编语言也不一样。本文介绍的是目前最常见的 x86 汇编语言,即 Intel 公司的 CPU 使用的那一种。

寄存器

上述讲到 0 1 组成的指令会让cpu进行一系列操作,而内部的操作,一般是通过寄存器来实现的,所以就需要了解寄存器的一些概念了,我的理解里,寄存器可以看作是初学代码的时候,那个中间变量,用于存放临时变量的作用,为什么要了解寄存器呢,因为汇编语言里面会用到。

    比如交换ab的值
    let temp
    temp = a
    a = b
    b = temp

以上是我比较浅显的比喻,而实际上,因为cpu的运算速度极快,cache(缓存)或者RAM(内存)等都会拖慢速度,所以要用到更快的存储设备,这就是register(寄存器),cpu的一系列操作,比如产生的temp数据都先交给寄存器,再由寄存器交给内存,内存是靠地址区分数据的,而寄存器是靠名称,下面是CSAPP书中关于寄存器的截图

image.png

可以看到每个寄存器都有各自的作用,比如看到被调用者保存和调用者保存,就可以联系到刚刚讲到的调用者策略和被调用者策略

内存模型:Heap 与 Stack

虽然寄存器的速度很快,但是存放的数据很少,所以在程序运行的时候,操作系统还会分配一段内存,用来存储程序和运行产生的数据,这里的内存就可以联系之前提到的帧了,一会儿再讲如何联系。借鉴阮一峰帖子里的图来表达:

首先内存中分配如下一段内存

image.png

1、内存模型:Heap(堆)

这里的堆跟数据结构里面的堆不一样,程序运行过程中,对于动态的内存占用请求(比如新建对象,或者使用malloc命令),系统就会从预先分配好的那段内存之中,划出一部分给用户,具体规则是从起始地址开始划分(实际上,起始地址会有一段静态数据,这里忽略)。举例来说,用户要求得到10个字节内存,那么从起始地址0x1000开始给他分配,一直分配到地址0x100A,如果再要求得到22个字节,那么就分配到0x1020

image.png

这种因为用户主动请求而划分出来的内存区域,叫做 Heap(堆)。它由起始地址开始,从低位(地址)向高位(地址)增长。Heap 的一个重要特点就是不会自动消失,必须手动释放,或者由垃圾回收机制来回收。

以前就一直听说的什么数组啊、对象啊、引用类型的数据都是放到堆里的,如此看来就是这个堆了,栈里面保存下数组array的引用地址,指向的就是堆里的某一段内存地址,而堆里的这段地址就保存下该数组具体的数据,以后的查询就能顺着栈里的地址找过来,在堆里面查询具体的索引对应的数据了,而那种数值之类的简单数据就放到栈里,下面介绍栈。

2、内存模型:Stack(栈)

在刚才的那段内存地址中,除了堆,剩下的就是栈,它由高位地址开始,从高位(地址)向低位(地址)递减来扩容分配的但这仅仅是确定stack里面比如某个帧需要多少容量的时候从高到低的读取,实际上读取里面的具体数据的时候还是从低到高的读取,并且当程序开始的时候,会在stack里面创建一个主函数的帧,有其他函数的时候,会在运行到那个函数的时候再去创建那个帧,比如:

    int main() {
       int a = 2;
       int b = 3;
    }

那么一开始就会有一个main的帧,之前已经说过了,帧有帧顶和帧底,一起来确定这个main函数在stack中的哪个位置,在一开始,帧顶(ebp寄存器)和帧底(esp寄存器)是同一个位置,如下图,其中main包含了ebp和esp,而且在这个函数里的一系列操作,都是包含在main帧中的。

(1)ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。

(2)EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

image.png

如果调用了其他的函数,就会有新的帧被创建,然后入栈,寄存器ebp和esp就会记录新的帧顶和帧底

    int main() {
       int a = 2;
       int b = 3;
       return add_a_and_b(a, b);
    }

image.png 那么问题来了,当这个新函数add_a_and_b运行完毕后,如何return到上级函数里面,所以需要记录下上级函数(此处是main函数),此时上一个帧底的位置会保存好上一个帧的帧顶和帧底的内存地址,为了在后面ebp和esp进行回滚操作。

等到add_a_and_b运行结束,它的帧就会被回收,系统会回到函数main刚才中断执行的地方,继续往下执行。通过这种机制,就实现了函数的层层调用,并且每一层都能使用自己的本地变量。

Stack 是由内存区域的结束地址开始,从高位(地址)向低位(地址)分配。比如,内存区域的结束地址是0x8000,第一帧假定是16字节,那么下一次分配的地址就会从0x7FF0开始;第二帧假定需要64字节,那么地址就会移动到0x7FB0

实例

直接引用阮一峰老师帖子的例子,加上自己的理解来记录这一节

了解寄存器和内存模型以后,就可以来看汇编语言到底是什么了。下面是一个简单的程序example.c

    int add_a_and_b(int a, int b) {
       return a + b;
    }

    int main() {
       return add_a_and_b(2, 3);
    }

gcc 将这个程序转成汇编语言。

    $ gcc -S example.c

上面的命令执行以后,会生成一个文本文件example.s,里面就是汇编语言,包含了几十行指令。这么说吧,一个高级语言的简单操作,底层可能由几个,甚至几十个 CPU 指令构成。CPU 依次执行这些指令,完成这一步操作。

example.s经过简化以后,大概是下面的样子。

_add_a_and_b:
   push   %ebx
   mov    %eax, [%esp+8] 
   mov    %ebx, [%esp+12]
   add    %eax, %ebx 
   pop    %ebx 
   ret  

_main:
   push   3
   push   2
   call   _add_a_and_b 
   add    %esp, 8
   ret

可以看到,原程序的两个函数add_a_and_bmain,对应两个标签_add_a_and_b_main。每个标签里面是该函数所转成的 CPU 运行流程。

每一行就是 CPU 执行的一次操作。它又分成两部分,就以其中一行为例。

    push   %ebx

这一行里面,push是 CPU 指令,%ebx是该指令要用到的运算子。一个 CPU 指令可以有零个到多个运算子。

那么现在我把自己理解的以上汇编代码的各行具体做了什么写出来,并且各个步骤中内存的堆栈是什么情况也画一下

1、分配内存

第一步肯定是从main进入嘛,所以第一行是 push 3 ,但是在这之前刚刚讲过会分配一段内存来做堆栈等等操作,所以把第一步算作这个,为了方便演示,统一所有数据当做4字节长度

根据上面的代码可以看到,在整个过程中用到的寄存器有四个,分别是 eax ebx ebp esp ,再对应前面章节的各个寄存器用法,假设分配的内存是 0xFFF0 00000xFFFF FFFF ,那么应该有如下图所示:

image.png

虽然Stack是从高地址往低分配的,但是读取/存入数据依然是低地址往高地址走,只是从高往低预留空间

2、创建帧

创建main帧,将帧顶位置写入esp,此时帧底ebp也是0xFFFF FFFF

image.png

3、执行代码 — push指令

然后执行 push 3 ,为了成功的把3 push 进栈里面,在此之前还得有个操作,因为说是栈,但其实也是push到栈里的main帧里面,此时ebp和esp是相同的,所以帧是没有空间的,因此首先要esp往低地址移动,给main帧扩容,先将esp减4个字节再写入esp

image.png

然后执行 push 3,将3压入栈,放在esp存放的地址指针指向的地址开始—即0xFFFE FFFF

image.png

所以push 2同理,先是esp继续往下移动,然后再把2压入栈,

image.png

4、call指令

执行 call,此时会去寻找_add_a_and_b函数并调用,会给该函数创建新的帧

    call   _add_a_and_b

在创建新的帧之前,需要保存上一个帧的帧底和帧顶地址,以便该函数执行完毕后,回到上级函数的时候,进行回滚操作,不过我暂时不知道这个是不是对的,而且不知道是保存到main帧里面还是两个帧之间的栈里,此处就先写在stack之间吧

image.png

然后再继续之前的操作,创建一个_add_a_and_b的帧,具体还是先创建一个帧,然后esp和ebp都一样,在遇到push操作的时候,esp往低地址移动4字节.

image.png

随后遇到第一个这个函数的第一行代码,一个push指令,所以esp会在push操作之前先移动4字节

    push   %ebx

image.png

然后开始把ebx寄存器存储的数据压入栈,此处解释一下为什么要先这样,联系到之前的调用者策略与被调用者策略,这是因为在此函数里面会用到ebx寄存器,但是在上级函数里面,有可能已经用到了ebx,所以为了在调用函数完成之后回滚(恢复)ebx的数据,需要先保存一下,跟刚刚的保存main的ebp和esp是一个道理,在这里因为是被调用者在执行保存数据的操作,所以是被调用者策略,而且ebx也是专门用来做这个的寄存器。

image.png

5、move指令

接下来就是move了,这里的 [ ] 意思表示地址,比如刚刚esp里面存的是 0xFFFB FFFF ,[%esp+8]表示esp存的地址加上8个字节后的值,当做一个内存地址,并将这个内存地址里面所存的数据写入eax,(不写这个就代表esp寄存器的数据加8字节后的值直接存入eax,而不是再去当做内存地址找数据,如果是这样的话,那么存入eax的就是一串内存地址而不是这个内存地址所对应的数据了?暂时不知道以后学到了再回来纠正)

    mov    %eax, [%esp+8] 

从上面的图可以看出,从这里(0xFFFB FFFF)往前加8个字节,是0xFFFD FFFF,正是0x0000 0002存放的地址开始。

image.png

下一行,是同样的操作,这次找到的是0xFFFE FFFF,就是 0x0000 0003,放到ebx

    mov    %ebx, [%esp+12] 

image.png

6、add指令

add指令就很好理解了,就是俩寄存器的数据加起来,放到前面一个寄存器里,有点像高级语言比如JavaScript里的a+=b,所以在这里就是把ebx中的3加eax中的2,得到5再放入eax

    add    %eax, %ebx

image.png

7、pop指令

之前有吧ebx的数据push进帧里面,那么现在就有pop来恢复数据,有入栈就有出栈嘛,ebx恢复到之前的null

    pop    %ebx

image.png

在push之前有对esp减4字节的操作,那么pop之后会对esp加4字节,来回收该内存空间

image.png

8、ret指令

ret指令就是return的简写

    ret

这一步没有运算子,只需要回收当前帧并恢复到上层函数即可,所以是把之前的数据都恢复,比如main帧的esp和ebp从栈中读取回来,再把这部分内存空间回收

image.png

回收存储main帧的esp和ebp的内存空间

image.png

此时就回到了main函数里面,并开始继续执行main函数里的代码

    add    %esp, 8 

上面的代码表示,将 ESP 寄存器里面的地址,手动加上8个字节,再写回 ESP 寄存器。这里再回收8个字节,等于全部回收。

image.png

然后进行最后一步,ret,回收掉当前帧,此时就全都没有了,所以完了之后就退出程序,读取eax寄存器的值,因为eax是返回值的寄存器,所以整个程序的结果是eax的值—5

    ret

image.png

结语

至此,算是勉强搞懂了几条汇编语言的实际操作步骤,如有错误,欢迎指正,毕竟还没系统的看汇编,大多数是看帖子和自己推导,以后有机会学习了再来修改,毕业论文也快送审了,希望顺利,后面就是继续学习CSAPP还有vue+ts。