计算机组成原理第四章--指令系统第二部分:基本的汇编语言和函数调用

为什么要学汇编,要学到什么程度

最新的408考纲规定,学生需要能看懂简单的汇编语言,不需要做到把C语言翻译成汇编语言或者机器语言的程度,但是要求学生能够看懂这一段汇编是在干什么,是在循环,是在分支,还是说在进行函数调用,同时也需要学生能够根据当前的汇编语言,看懂每一行用的是什么指令,是什么寻址方式(汇编语言和机器码是一一对应的,所以一行汇编就是一行指令)。也不就是不要求你会写,但是要求你会看,这个其实是很容易实现的,学习一门语言,我们学会其循环,变量的赋值等操作之后,就能看个大概了,这也是这个章节需要掌握的另一个知识点。而且也不要求掌握全部架构的汇编语言,我们只需要掌握x86的汇编语言即可。

1.汇编中地址的表示方法

以mov指令为例,来介绍汇编里面的立即数,内存地址和寄存器如何表示。

1.1 什么是x86的汇编语言

之所以叫x86不是说机器的指令有86个字节,而是说这些指令都能在英特尔生产的一块叫做8086的CPU上运行。现在8086已经是一个远古的CPU了,但是现在的x86指令可以兼容8086以上的全部CPU,所以一直叫做x86系统。x86也是国内电脑用的最多的架构,只要你是windows系统, 就是x86的。再提一句,8086CPU后,因特尔出品的CPU包括了80286,80386等都是以86结尾的(但现在不是了,最新的是i5,i6,i7等,但是都支持x86指令集)。

1.2 什么是计算机的指令

指令的作用有两种,一种是改变程序的执行流,一种是处理数据

处理数据的指令又往往由操作码和地址码组成(前面已经描述过),操作码代表了怎么处理数据,地址码代表了数据的位置,数据的位置在计算机里面可能会放在三个地方,一个地方是主存(这里把Cache也归为主存),一个地方是寄存器,还有一个就是直接在指令中(在指令中本质上也是主存),同时,与之对应的也就是直接寻址,间接寻址等,对应寄存器的就是寄存器寻址,寄存器间接寻址等。所以想要写出对应的汇编指令,我们首先需要知道寄存器有哪些,名字是啥?怎么写主存的地址?怎么指明哪些部分是地址?立即数怎么写? 这几个问题。

现在就带着疑问来学习几个常见的汇编指令。

1.3 mov指令

mov指令的相当于一个复制粘贴的指令,它可以把一个地址码里面的操作数给复制下来,用来覆盖另一个地址码里面的内容。 我们通常使用d来表示目的操作数(destination),用s来表示源操作数(source)
使用方法就是:mov d, s这个结构的含义其实就是d=s,mov在这里代替了中间的等号,在这个指令中,mov是操作码,d和s就是地址码,这是一个二地址指令。
根据操作数存放的位置不同,mov后面的地址的写法也就不一样:
(1)把数据从一个寄存器放到另一个寄存器里面:

mov eax, ebx #将寄存器ebx的值放到eax中

(2)把立即数6放到寄存器里面:

mov eax, 6   #将立即数6放入到寄存器eax中

(3)把操作数从16位的内存放入到寄存器里面:

mov eax, dword ptr[af996h] # 把内存地址为af996h的32bit数据(dword是双字的意思)复制到寄存器eax

(4)把立即数放进内存

mov word ptr[af996h], 6 # 把立即数6放入内存地址为af996h的16bit数据中

1.4 内存地址的表示方法

从上面的四种读取方式中,我们看到,凡是涉及到内存的,都需要指明需要读取多少字节的数据,一般情况而言,只需要记得三种即可,分别是:
(1) dword ptr:双字读取(每次读取两个机器字长)
(2) word ptr:单字读取(每次读取一个机器字长)
(3) byte ptr: 每次读取一个字节。

在x86架构中,默认一个字长是16bit,所以题目没有说字长是多少,默认就是16bit,同时一个字节是8bit也需要注意。
内存的地址一般都是用16进制表示的,所以我们可以看见末尾有h或者H,同时需要用中括号包裹(需要注意一点,如果说在看指令的地址里面没有ptr也没有中括号,直接就是一个十六进制的数字,那么这个数字是一个立即数)。

1.5 寄存器的表示方法

我们只需要记住8个就可以,说是8个,其实不需要记住那么多,如下:
在这里插入图片描述
我们首先可以看到,寄存器都是以E开头的(小写e也可以),以E开头的寄存器都默认是32位的寄存器,64位寄存器以R开头,其余部分均一致,但是一般不考。
在上面的图中把寄存器分为了3组:
(1)第一组的四个都是通用寄存器。通用寄存器的特征就是,以E开头,中间可以是任意的字母,最后以X结尾,表示可以存任何数据。
(2)第二组的两个都是变址寄存器,在x86架构中,ESI和EDI是专门用于数据传输的变址寄存器。它们主要用于字符串操作、数组拷贝和内存复制等操作。

1 ◯ \textcircled{1} 1ESI(Extended Source Index)寄存器: ESI寄存器通常用作源操作数的索引。在字符串操作和数组拷贝时,它存储源数据的起始地址,通过增加或减少ESI的值,可以逐个访问源数据中的不同元素。简单来说就是用来存需要复制的那个数的地址。

2 ◯ \textcircled{2} 2EDI(Extended Destination Index)寄存器: EDI寄存器通常用于目标操作数的索引。在字符串操作和数组拷贝时,它存储目标数据的起始地址,在数据传输过程中,通过增加或减少EDI的值,可以将源数据复制到目标数据的不同位置。简单来说就是用来存需要被新复制的数字覆盖的位置的地址。

记不住上面这两个寄存器的作用也没有关系,考试不会考,你只需要知道它们其实是寄存器就行了,到时候选择题可以选出它使用了寄存器寻址即可
(3)最后两个就是我们的堆栈寻址需要用到的寄存器,ESP就是SP,但是它访问的是栈顶部元素(也可以说是栈顶的栈帧的顶部),这里还多了一个EBP可以访问栈顶的栈帧底部元素,使对堆栈的访问更加灵活。常用于函数调用,这个后面讲函数调用的汇编指令的时候(包括什么是栈帧)。
(4)如果我们把E去掉(无论是什么寄存器)就代表只有16bit了
在这里插入图片描述
通用寄存器甚至还能再分成更小的8bit寄存器:
在这里插入图片描述
这个时候就以H或者L结尾, 我们在做选择题时需要看清除,不能把16位的数据转入到8位的寄存器里。当然,考试主要还是考32位的,只是出现16位甚至是8位的也不要蒙圈。

1.6 补充几个mov的指令

(1)寄存器间接寻址:

mov eax, dword ptr[ebx]

显然,这行指令的意思就是ebx里面存放了操作数在主存里面的地址,所以要先去ebx里面取出内存的地址,然后再去这个地址取出32bit的数据,转移到eax里面。
(2)没有指明读取长度的主存地址:

mov eax, [ebx]
mov [af671h], eax

像这种没有明确指明主存里面需要读取多少bit的时候,我们就默认它读32bit(有中括号就说明是从主存里面读取数据)。
(3)偏移寻址:

mov eax,byte  ptr [ebx+9]
mov word ptr [af123-2h], eax

这种,内存地址里面出现加减法的,就是偏移寻址,第一行明显是从ebx所指向的地址+9,第二行显然是从af123h的位置-2进行偏移(代表16进制的h写在式子最后就行,不需要每个数字后都写)。

2.常用的汇编操作指令

在前面已经学习了mov指令,现在继续学习其他指令。汇编的指令可以分为算术运算,逻辑运算和其他操作三大类,我们依次学习。

2.1 算术运算的指令

常用的算术运算包括:
这里还是用d和s来表示目的操作数和源操作数,需要注意的是,操作完成以后,如果有d则把数据存在d的位置,这也要求d不能是常量类型的操作数,其实说白了,就是这个d不能是立即数,立即数怎么存数据。
(1)加法add d, s 计算d=d+s;

(2)减法sub d, s 计算d=d-s;

(3)无符号数乘法mul d, s 计算无符号数 d=d*s;

(4)有符号数乘法imul d,s 计算有符号数 d=d*s;

(5)无符号数除法div s 计算无符号数 edx:eax/s, 商存入eax,余数存入edx;

(6)有符号数除法idiv s 计算有符号数 edx:eax/s,商也是存入eax,余数存入edx;

(7)取负数neg d 使 d = -d;

(8)自增: inc d 等同于d++;

(9)自减: dec d 等同于d- -。

这里虽然看着很多,但是其实都是四级大纲里面的词汇,我们把英文背下来,然后取其前三个字母就组成了指令,这些英文分别是:
(1) add 加;(2)subtract 减少;(3)multiply 乘以;(4)divide 除以;(5)negative 负的;
(6) increase 增加;(7)decrease 减少

同时,在上面的指令中,我们可以看见乘法和除法分为两种,有符号数前面有i,无符号数前面没有i,这里的i代表integer整数的意思,所以这里的有符号指的是有符号整数。

最后,对于除法,我们会提前把被除数放到一个通用寄存器eax里面,汇编指令只需要写除数即可。并且,我们在进行除法时,被除数的的位数需要被扩展成除数的两倍,也就是如果除数是32位的,那么被除数就需要被扩展成64位,这里的"edx:eax"其实就是把两个寄存器拼接起来,形成位扩展(因为一个通用寄存器是32位),扩展的原因是,进行除法操作,既需要存除法除出来的商,也需要存余数,扩展之后,商和余数正好分别放在eax和edx之中

2.2 三种地址的简单写法

我们有时候用<>加地址名称的前三个字母来表示地址,比如:
< reg >表示寄存器register, < mem > 表示内存memory, < con >表示常数constant(其实就是立即数)。

在x86的汇编指令中,是不允许两个数同时来自主存的。 这是为了减少主存的访问,加快速度。

最后,主存间接寻址里面,可以写成word ptr [var],这里的var就是一个容器,可以是寄存器,也可以是主存,反正它指向一个地址,要读取的数据就在这个地址里面。

2.3 逻辑运算的指令

常见的逻辑运算汇编指令如下:
(1) and d,s 把d和s逐位相与,把结果放入d;

(2) or d, s 把d和s逐位相或,把结果放入d;

(3) not d 把d中的数字每一位都逐位的取反,再放回d中;

(4)异或 xor d,s 把d和s中的每一位都逐位的异或,把结果放回d中;

(5)左移 shl d,s 把d中的数全部逻辑左移s位,把结果放入到d中,这里的s往往是一个常量,也就是立即数;

(6)右移 shr d,s 把d中的数全部逻辑右移s位,把结果放入到d中,这里的s也常常是一个常量(如果是内存或者寄存器里存的一个数也行,但是没有必要,立即数就可以解决,即便是移动64位,6位的二进制位就可以表示)。

同样的,这里也需要把这几个英文单词背下来:
(1)and 与;(2)or 或;(3)not 非;(4)exclusive or 异或;(5)shift left 左移;(6)shift right 右移。

2.4 NT&T汇编语言

其他指令很多,我们一会细谈,这里先引入一个新的指令系统,NT&T指令的x86汇编语言,刚刚我们学习的是Inter的汇编语言,它们的区别是,NT&T汇编语言常常在Linux和Unix里面使用,而Inter的汇编语言是在Windows里面使用的。一般而言,408要考的就是Inter的汇编语言,目前还没有考过NT&T的汇编语言,但是考纲也没有说不考,所以有备无患,还是来学习一下。

其实一理通则百理通,我们可以对照着Inter的指令来学习,这里记住王道PPT上面的这个表即可:
在这里插入图片描述
我们来梳理一下上面的表格:
(1)在AT&T汇编语言中,目的操作数是在右边的,也就是运算完成的结果放在右边。

(2)在AT&T汇编语言中,寄存器名称和英特尔的一样的,但是需要在前面加上一个%加以标识。

(3)在AT&T汇编语言中,主存地址用()包裹,未指明读取多少字节时,和因特尔一样默认读取32bit的数据。

(4)在AT&T汇编语言中,读写长度不需要用指针表示,而是在指令后面用b,w,l这三个字母表示即可,需要注意的是双字是l不是d。

(5)在AT&T汇编语言中,立即数前要加$符号表示。

(6)在AT&T汇编语言中,偏移多少需要写在()外面。

最后一行的这种偏移方式,因特尔格式的我们也没有讲过,现在来讲解一下:
在这里插入图片描述
这里可以举一个实际的例子来说明:
比如现在我们定义了一个两层的嵌套数组A[a[…],b[…],c[…]],然后我们需要读取的是这个A里面的第3层的元素里面的第5个元素,那么首先,ebx里面存放的是A的起始地址,也就是a[0]的位置,假设里面每一个数组的大小都是32位,我们需要读取的是c的数据,所以,需要偏移2个32位才能读取到c[0],这里的2就是变址,存在ecx里,32是一个数组的大小,最后我们要读取的是第5个元素,所以需要从0开始移动4位,于是需要加4。
无论是类结构,还是结构体,还是字典类,里面的任何一个元素其实都可以用这种[基地址+变址*一个个体的大小(也就是比列因子)+偏移量]的方式访问到。做题不用慌,一步步来即可。在AT&T格式里面省去了标点符号,变成了偏移量(基地址,变址,比例因子)的形式

2.5 其他指令

因为其他指令很重要,所以这里来分层次论述。在这里需要强调一下,PC程序计数器在指令中往往被写作IP(Instraction point 指令指针),两者是一样的

2.5.1 选择语句

选择语句有很多,比如if–else语句,原本的程序可能是顺序执行的,但是现在如果不符合则需要进行程序的跳转,常用的相关指令有:
(1)无条件转移指令jmpjmp x。jmp指令其实之前我们就已经见过了,它的功能是无条件跳转到x的位置,x代表的是要跳转的具体地址,可以是立即数,可以在一个寄存器里也可以在一个主存的某个块里。它的缺点是,程序员很多时候,并不知道x的值具体是多少,因为你的程序被装在什么地方是随机的,所以jmp指令后面往往会跟一个“标号”来锚定位置这个标号的名称并不固定,当执行jmp指令之后,计算机会快速地遍历全部指令,找到这个标号的位置进行执行。如下图所示,这个“NEXT”就是一个标号。
在这里插入图片描述
标号的缺点就是跑起来会慢点(以现在的计算机性能,人类根本不会觉得慢),但是好处就是灵活。这里的jmp语句就类似于C++的goto语句(但是确实不希望大家使用goto语句,因为写的程序可能会因为一点小改动出大问题)。
(3)比较指令cmp:cmp a,b 比较指令可以比较两个数字a和b的大小,以及是否相等等,它不能单独使用,往往和条件转移指令一起组合出现(因为cmp单独出现计算机是不知道要比较什么的)。学到这里其实就可以理解上一部分的比较的硬件实现部分的知识了。
(2)条件转移指令jxx:和上面的无条件指令相对应的,有条件转移指令必须在满足一定条件的时候才能跳转,这些条件包括了等于,不等于,小于,大于,小于等于和大于等于,使用条件转移指令的前提是在前面有一条cmp指令,条件转移指令有:
1 ◯ \textcircled{1} 1 je x a==b的时候跳转到x;
2 ◯ \textcircled{2} 2jne x a!=b的时候跳转到x;
3 ◯ \textcircled{3} 3jl x a<b 的时候跳转到x;
4 ◯ \textcircled{4} 4jg x a>b的时候跳转到x;
5 ◯ \textcircled{5} 5jle x a<=b的时候跳转到x;
6 ◯ \textcircled{6} 6jge x a>=b的时候跳转到x。

这里其实并不难区分,e是equal也就是等于的意思,ne是not equal 不等于,g是greater than 大于的缩写,l是less than 小于的缩写,j是jump肯定不需要再说了,j和这几个就可以组合出这些不同的跳转语句来。
再次强调,cmp和jxx都不能单独使用,需要一起用,像这样:
在这里插入图片描述
从例子中,我们也可以看到,跳转的位置不一定是一个具体的地址,也可以是我们在无条件转移时学习的标号。强烈建议回去上一部分,找到cmp和jxxx指令的硬件实现部分,好好重新体会一下。

学习到这里,我们可以尝试着翻译一小段简单的C++代码成汇编语言了:

                                                             int a=7;
                                                             int b=6;
                                                             if(a>b){
                                                               int c=a;}
                                                             else{
                                                               int c=b;}

现在假设我们把a存在寄存器eax中,b存在寄存器ebx中,c存在寄存器ecx中。假设不看下面的解释,请先试着写一下它的汇编指令。(提示:需要用到有条件转移指令,无条件转移指令,mov指令,比较指令,还需要使用标号)











公布答案(答案其实不唯一,你如果写明了地址,不用标号也行):

mov eax,7
mov ebx,6
cmp eax,ebx
jg NEXT
mov ecx,ebx  # 注意,这里是不满足就执行c=b
jmp END # 如果要说出错,很多人肯定会把这个END漏了,但是如果不加这个END,程序会顺序执行下面的语句,也就是用b的值去再次覆盖a的值,那就白判断了
NEXT:
mov ecx,eax # 满足才执行c=a
END: # 后面空着就行,计算机检测到后面是空的也就不会继续执行了

除了不加END以外,很大一部分人会因为惯性思维,按照程序的顺序,先写个mov ecx,eax,NEXT的位置写mov ecx,ebx,但其实正确的写法是反过来的。你非要按照程序那样顺序去写也不是不行,但是在汇编语句中就需要把大于改成小于等于(也就是总有地方要反着,多写一下,习惯就好),至于为什么,那需要去学习逻辑学,相信大家多数都能一眼明白。

mov eax,7
mov ebx,6
cmp eax,ebx
jle NEXT  # 这里变了
mov ecx,eax  
jmp END 
NEXT:
mov ecx,ebx 
END: 

值得注意的是,计算机里面实际编译的程序,往往就是第二种写法,就是把小于变成大于等于,或者把大于变成小于等于,这样的好处就是能让后面的机器指令和程序员写的程序保持一致性,如果不改比较号,则需要改两处甚至更多处(因为if下面可能是一串指令),要更加麻烦。

写完一个题目以后,肯定信心大增,这个时候就来看个真题,这里还有没有学过的东西,那就是在把高级语言翻译成汇编语言的时候,往往用函数名来作标号,这个标号指向的就是这个函数的起始地址。题目如下,相信你已经可以看懂大半了:
在这里插入图片描述
里面这个f1其实就是函数f1的起始地址,因为我们看到了[],所以我们可以明确这个是一个因特尔的汇编指令,不是NT&T的指令,所以你一定会对里面的三个括号产生疑问,其实这是出题人的善意,括号里面的就是偏移以后的地址,比如第12行,f1代表了00401000,那么偏移35以后就是0041035。里面的push指令后ret指令看不懂没有关系,我们马上就学。

2.6 和循环语句相关的汇编指令

(1)用已经学习过的条件跳转指令完成循环语句:

其实,用我们目前已经学过的知识完全可以表示一段循环的程序了:
在这里插入图片描述
同样的,这里写法不唯一,比如你可以不用自增指令inc,而是使用add edx,1(但是这样效率不如inc),又比如,这里不要cmp edx,100 jle L1,而是直接跳转到cmp edx,100之前(但是这样需要多运行一行指令)。这里把王道的PPT放这里,它给出了如何通过条件转移指令来写出最好的循环指令:
在这里插入图片描述
(2)使用无条件循环指令loop指令完成循环:
loop指令常在明确知道需要循环多少次的情况下使用。 这是王道PPT上的例子:
在这里插入图片描述
可以看到,loop一条指令可以完成三条指令的内容:首先把ecx寄存器里面的值减一,然后用ecx的值和0进行比较,如果小于等于0就停止循环,否则跳回循环的起点。 能用loop实现的也可以用条件跳转指令实现,但是它就相当于是帮你打包好的一个包(把三条打包成一条),直接用就更加方便。

在x86里面,只有ecx寄存器来作为循环的计数器,所以使用loop的前提是ecx里面必须有正值。

(3)有条件循环指令loopx
loop全称是无条件循环指令,那么相对应的就有有条件循环指令(类比着jmp指令看)。常见的有条件循环指令有:
1 ◯ \textcircled{1} 1loopnz:在loop指令的上面(也就是循环体里面),需要加一个计算的指令(加减乘除都可以),当ecx等于0,或者这个计算的指令计算的结果为0的时候,就停止循环(直观的说,就是ecx!=0&&ZF==0才进行循环)。比如:

int b=3;
for(int a=10;a>0;a--){
	b--;
	if (b==0){
		break;}
	}
mov ebx, 3
mov ecx, 10
looptop:
dec ebx
loopnz looptop # 在判断ebx是否等于零的同时,也在判断ebx是否为零(但是判断ebx是否为0不是去ebx看,而是在PSW看)

这里只是一个简单的例子,实际上像字符串匹配等匹配算法是最常用loopnz指令的,了解即可。

2 ◯ \textcircled{2} 2loopz: 和loopnz相反的是,它会在最近一次运算不为0,或者ecx的值为0的时候停止循环。比如我们要从一个5位数66266里面找出它第一个不为6的数字:

int a = 66266;
for(int i=5;i>0;i--) //五位数当然最多循环五次
{
	int b=a%10;  // 取个位数
	if (b != 6){
		break;}
	a = a/10;
}

写成汇编语言就是:

mov eax, 66266
mov ecx, 5
looptop:
idiv 10 # 除法操作,用edx:eax除以10,商放入eax,余数放入edx
sub edx, 6
loopz looptop  # edx-6不是0或者ecx是0的时候停止循环

3 ◯ \textcircled{3} 3其他的loopx指令还有很多,这里无法一一列举其含义,但是我们在考试的时候,完全是可以根据C++代码来反推loopx指令的含义的,因为我们知道带loop就说明它是循环,不同的loopx指令的区别只是在于循环停止的条件不一样而已,所以我们只要从C++代码中找出其停止循环的条件,我们就可以知晓题目里面loopx的含义(同样的道理也适用于jxxx,对于jxxx,只需要找到C++代码里面跳转的条件就可以知晓)。

2.7 和函数调用相关的汇编指令

函数调用就是一个函数调用另一个函数的过程。

2.7.1 函数调用的底层逻辑

学习函数调用的汇编指令之前,必须先明白函数调用的底层逻辑。
在计算机运行的时候,会自动地把当前进程里面所运行的函数(的局部变量和函数调用的相关信息)放入一个栈中(这个栈在内存里面),方便在运行的时候函数之间互相调用,这个栈也被称为函数调用栈
当然,也不是一开始就把全部函数都入栈的(万一一个进程里面的函数很多很多也是会装不下的),而是随着运行,逐步入栈。用王道PPT的例子举例,要执行这样的一段程序:
在这里插入图片描述

入栈的过程如下:
(1)最初是空的:
在这里插入图片描述
(2)哪个进程都是先被硬件以及操作系统调度的,所以先有一些相关的其他信息(比如进程的ID等,这些是操作系统的内容,这里不用太在意),之后都是先从main函数开始运行,所以main函数先入栈,在程序调用栈里面的main函数的信息称为main函数的栈帧
在这里插入图片描述
(3)接下来mian调用P函数,所以P入栈,同样的,函数在函数调用栈里面的信息就叫做栈帧
在这里插入图片描述
(4)之后P调用Q,Q入栈:
在这里插入图片描述
(5)因为P没有在调用其他函数,P运行完以后,P从函数调用栈栈顶里面删除,之后计算机会继续运行栈顶的函数(因为一个函数如果不是主函数,则其局部变量是会随着函数的出栈而被删除的,所以定义在函数里面的局部变量并不能在主函数里面保留,所以需要用指针):
在这里插入图片描述
(6)P又调用了caller函数:
在这里插入图片描述
(7)caller执行add():
在这里插入图片描述
(8)之后就是依次出栈了。

总结一下:每一次进行函数调用的时候,这个函数的局部变量和函数调用的相关信息(函数里面的全部变量等不会进去,函数调用相关的信息是指这个函数在进行函数调用之前,它执行到了哪里等信息)都会进入到函数调用栈中称为栈帧,计算机只会执行处于函数调用栈栈顶的函数,并且当一个函数执行完成(执行到return)的时候,就把这个函数的栈帧从栈顶删除,然后计算机继续执行下一个栈顶的栈帧函数。

2.7.2 函数调用的两个重要汇编指令

要通过汇编语言实现函数调用其实十分简单,只需要两个指令即可,它们分别是:
(1)函数调用指令 call 函数名称这里的"函数名称"其实是一个标号,使用以后它会无条件地跳转到标号所在的位置执行(这时候其实会把标号所代表的函数的相关信息入函数调用栈,同时保存call所在的位置)。
(2)函数返回指令 ret:ret其实就是return的缩写,它会自动返回最近一次call指令的下一行继续执行。

这里补充一下,还需要一个leave指令让已经运行完成的函数的栈帧从函数调用栈出栈(因为这个操作不难,但是有点绕,所以在讲完这两个指令之后再单独的讲讲,这里知道它是让运行完的函数栈帧出栈的就行)。
举个例子,现在有两个函数A()和B():

void B(int a){
a=2;
}
void A(){
int a=0;
B(a); 
}

那么它们的汇编指令就分别是:

A:  # 汇编里面常常用高级语言的函数名作为这个函数起始地址的标号
mov eax, 0
call B
leave # leave指令其实是让这个函数的栈帧从函数调用栈出栈
ret # 返回上一级函数(这里我没有把上一级函数写出来,默认是main函数吧)
B:
mov ebx, eax # 函数的传值不加&则只是传递一个副本
mov ebx, 2
leave
ret # 返回A的call的下面一行 

更低层的看,其实call和ret指令,改变的IP寄存器(也就是程序计数器PC)的值(因为PC总是指向下一条要运行的指令)。所以call、ret和jmp是一样的,都能无条件改变IP寄存器里面的数字。

所以,我们可以对这两个指令有更加底层的理解:
(1) call指令首先把当前IP寄存器的值(就是下一条指令的地址)保存到内存里面的函数调用栈里,栈顶函数的栈帧的顶部(有点绕口)。随后, 无条件地更改IP寄存器的值为指定的标号位置。
(2)ret指令从当前的函数调用栈里面,找到当前栈帧的栈顶的值(因为leave指令已经使运行结束的函数出栈,所以这个值其实就是原本的IP寄存器的旧值,leave指令的详细解释在下面),把这个值设为当前IP寄存器的值

学到这里其实还没有把函数调用学完,因为我们举的例子是void函数,它没有返回值,但是如果是一个有返回值的函数怎么表示呢?栈帧里面的内容我们也只是简单的说它包括了函数的局部变量和函数调用的相关数据,这些“相关数据到底是什么”?我们要怎么访问这些数据呢?这些问题还没有弄明白,所以现在先去洗把脸,我们再继续学习函数调用的底层实现。

2.7.3 函数调用栈和栈帧怎么用汇编访问

(1)首先,我们需要知道这个函数调用栈的硬件是什么样子的。其实,每一个进程都被操作系统分配了一部分的内存空间(这里是虚拟内存,不一定真实存在于主存中,但是如果内存足够,一般而言就是在主存里面分出一部分空间来存的),对于32位机而言,会在主存里面分个4GB来存一个当前的程序,拿1GB来作操作系统内核管理这个程序,剩下3GB存这个程序的数据(包括了函数调用栈和许多控制这个程序的数据包括这个程序的库函数等等)。在这里,函数调用栈的栈底是存在内存的高地址处,栈顶存在内存的低地址处(因为栈底的元素不常访问,而栈顶的元素常访问,更低的地址的地址码就更小,访问起来容易),所以这个栈看着是倒着的。如图所示:
在这里插入图片描述

(2)现在如果记不得最上面1.5关于寄存器的内容,可以回去看一眼,这里我们就可以理解EBP和ESP这两个寄存器是干嘛的了。如图:
在这里插入图片描述
这两个寄存器其实标记了当前栈帧(也就是函数调用栈的栈顶栈帧)的范围。 同时,因为x86系统里面默认最小的读写单位是4bit,所以图里面的一小格是4bit。所以ebp指向当前栈顶栈帧的底部的4bit,esp指向当前栈帧的顶部的4bit。 当当前的函数执行结束(也就是执行ret指令之后),ebp和esp就指向下一个函数的底部和顶部。

(3)现在就可以通过EBP和ESP来访问当前要运行的这个函数的栈帧的内容了。
1 ◯ \textcircled{1} 1先说ESP:pop和push指令可以控制ESP里面的值。 用法如下:

pop指令是弹出指令pop x,它可以把ESP指向的4bit内容弹出来,并且存在x里面 (x可以是主存的地址,也可以是一个寄存器,但不能是立即数,因为立即数不能存数据)。这个时候,ESP指向的东西就为空了,所以ESP需要加上4,往上移动(因为栈是从上往下的,所以数据减少了反而要往上挪)。从这里我们就可以把栈帧理解为一个栈,函数调用栈就是一个大栈包含了许许多多的小栈。

push指令是入栈的指令 push x,它可以在ESP指向的位置前面加入4bit的操作数x(x可以是立即数,或者来自主存或寄存器),同时把ESP减去4(数据增加往下挪)。前面学习的call指令其实就是先push IP(IP被放入当前的栈帧顶部),然后再 jmp 目标函数。

如果你觉得pop和push只能控制栈帧顶部的元素,太局限,你可以用回mov指令:
使用mov指令配合逻辑运算指令,也可以实现pop和push的效果,但是需要写两行代码。mov指令的功能甚至强大到可以随意地挪动ESP,并且访问当前栈帧里面的任意元素
用mov实现pop指令:

mov eax, [esp] # 把栈帧顶部元素存入eax寄存器
add esp, 4 # esp的值加4 

用mov实现push指令:

sub esp, 4 # 先把esp挪动到下一个位置
mov [esp], x # 把x存入栈帧顶部

用mov访问栈顶的第二个元素,但是不破坏原本的栈:

mov eax, [esp-4] # 就这样简单的把第二个元素取到了eax里面 

用add/sub也可以轻松地删除或者恢复栈:比如刚刚pop以后,你后悔了,这时候只需要紧接着让sub esp, 4就可以恢复,同理:add esp, 4n可以快捷地删除前n个栈帧元素(注意不要让esp比ebp还大)。

学到这里,不得不说一个事实,那就是一门编程语言,你其实不需要学完所有的语句,你时间有限就先学会最基本的几个就行了,其他的扩展语句后面慢慢学,因为这些扩展语句都可以用最基本的语句来实现。比如你不会用C++的string类,没有关系,用基本的操作完全可以定义一个属于你自己的string出来,所以学习什么语言都不用害怕,很多时候学会基本的操作,已经可以完成全部的工作了,只是写的快与慢的问题,关键是要去写,大胆地写。这其实就是2-8原则(只有20%的东西会被经常使用)。

2 ◯ \textcircled{2} 2EBP:EBP其实没有像pop和push那样的快捷指令,使用mov加上add和sub指令可以完成全部的操作。

2.7.4 如何切换栈帧

当执行完一个函数之后,或者是进行新的函数调用时,我们需要把esp和ebp给定位到新的栈帧的顶部和底部,这就是栈帧的切换。
(1)先说进行新函数调用时的切换,这里很复杂,需要结合王道的PPT来进行理解:
在这里插入图片描述
从图上,我们可以看到,每一次进行函数调用时,都需要用到两行汇编代码:

push ebp
mov  ebp,esp

这两行代码前其实可以加一句代码组成连贯的三句代码:

call 函数名称
push ebp
mov  ebp,esp

这三句代码完成了新增栈帧的时候,两个栈帧之间的切换:
1 ◯ \textcircled{1} 1首先,call指令,把当前的esp值压入栈中(等同于执行了push IP),这个时候原函数的栈帧的栈帧顶部就存了旧的IP的值:
在这里插入图片描述
这个时候esp也同步的减4,下移一格。

2 ◯ \textcircled{2} 2接下来就相当于执行jmp指令,跳转到新的函数去指令,首先要执行的是push ebp,这个操作把当前栈帧的值再扩展了一格,用来存当前的ebp的值。

3 ◯ \textcircled{3} 3要注意的是这个扩展的一格其实是不是原本栈帧的顶部,而是新的栈帧的尾部,因为我们紧接着执行mov ebp, esp就把ebp给挪上来了,通常ebp是不移动的,所以ebp才是当前栈和之前的栈帧之间的分界线,这里ebp的挪动代表着产生了一个新的栈帧。从这里我们也可以看到,每一个函数栈帧的尾部(也就是ebp所指向的地方),存储的是上一个函数栈帧的ebp的值,这样就方便在结束当前栈帧之后,快速地把ebp返回回去。
在这里插入图片描述
4 ◯ \textcircled{4} 4最后,操作系统自动地往里面push上要运行函数的栈帧,一个新的栈帧就被入栈了。
在这里插入图片描述
总结一下,call之后,每一个新的函数正式执行前,都必须把ebp寄存器的值给挪到新的栈帧的尾部位置来作为新的栈帧和旧栈帧的分界线,挪动需要两个指令:push ebp mov ebp esp。在ebp所指向的位置存放的永远是上一个函数的ebp的位置。最后,如果你嫌写着麻烦,那么这两个指令其实也被打包好,变成了enter指令,它是一个零地址指令,在新的函数头写一个enter就可以起到相同的效果。 esp的位置不用管,它会随着栈帧中数据的变化自己挪动。

(2)回到旧函数:理解了新函数怎么放,那么回去就很好理解了。首先,把esp给收回去:mov esp, ebp 收到尾部这里。然后把ebp给放回去,这里可以有两种同样的收法:mov ebp , [ebp]或者mov ebp, [esp],因为当前ebp和esp指向的内存都存了上一个函数的ebp的值,所以都可以。最后,把这个尾巴删掉就行sub esp,4
当然,后面这两步其实就是一个pop ebp的操作,所以可以把代码简化为:

mov esp, ebp # ebp不动,把esp挪到尾部这里
pop ebp # 在把esp移动到原栈帧顶部的同时,把ebp的值改成原本的ebp值

这里把三行代码简化为了两行代码,但是其实汇编程序设计师也把这两行代码简化了(因为经常用),使用一个零地址指令leave就可以起到一样的作用(在上面的call和ret那里就出现过了)。在ret指令执行前,必须先执行一下leave指令。 现在也就可以更好地理解ret指令为什么可以在栈顶找到原本的IP寄存器旧值了,ret其实就相当于pop IP,这样原本的函数就可以正常地继续执行。

王道已经总结好了栈帧的切换方法,如图:
在这里插入图片描述
把这个图背下来,这里的内容就没有问题了,任何main函数以外的函数都是按照这样的方式执行的(main函数的return就代表进程结束了,所以要更复杂一些,但是mian函数调用其他的函数也是这套结构)。当你读汇编代码的时候,你看到有call,enter,有leave,ret,那么你就需要知道它在进行函数调用。

2.7.8 一个函数的栈帧里面都有些什么

学了这么久的函数调用,反复提及这么多次栈帧,我们已经对栈帧这个名字很熟悉了。但是,我们目前只知道栈帧的尾部存的是上一个函数栈帧的ebp的值,以及栈帧里面会存一些当前函数的局部变量,还不知道栈帧还会存什么,现在就来详细地了解一下。
(1)首先,函数的局部变量不是被随意存储的,而是集中地存储在栈帧的底部(也就是ebp后面),所以在运行的时候,你常看见有mov eax,[ebp-x]的操作,这其实大概率就是在取存在里面的局部变量,也可以通过mov [ebp-x],n来更改局部变量的值。附上王道PPT的例子:
在这里插入图片描述
在这里插入图片描述
我们可以从图里面发现,局部变量的存储顺序和在栈帧里面的好像不一样,越是最先出现的局部变量就越靠近栈顶,这是因为越早出现就越有可能需要先出栈。所以看到[ebp-4],就应该知道它是最后一个局部变量。

(2)当前函数进行函数调用时,传递给下一个函数的参数,全部存在栈帧的顶部,这样存的好处是,当运行新的函数的时候,可以通过mov [ebp-4n], [ebp+4n]的方式给当前栈帧里面的局部变量赋值(如果里面不涉及赋值,可以不mov,直接取)。总之看见有[ebp+8]的代码,你可以大概率确定它在调用被传递的参数(ebp+4是IP旧值,不要混淆)
比如这段代码里面被传递的x和y:
在这里插入图片描述
在这里插入图片描述
(3)剩下的部分就是空闲的
在这里插入图片描述
因为编译器,比如gcc总是在设置一个函数的栈帧的时候,把空间设置为16B的整数倍,当空间不够时只会再加nx16B,所以总是会有装不完的情况,这是无法避免的。

总结:函数的栈帧里面,最底部也就是ebp的位置存放了上一个函数的ebp旧值,之后紧接着存放了当前函数的局部变量,并且越后被定义的局部变量反而被放在前面,之后是一段空白区域,再后面是这个函数要传递给其他函数的参数,最前面是IP旧值(如果进行了新的函数调用)。
在这里插入图片描述
如果当前函数没有进行函数调用(也就是没有执行call指令),最前面的IP是不存在的。调用参数是在call指令执行前被依次写入栈帧的。

现在跟我一起尝试翻译下面这两段代码为汇编指令:
在这里插入图片描述
在这里插入图片描述
首先是caller:
(1)先要移动好ebp和esp,划出一个函数栈帧的范围,这里注意的是esp的移动是操作系统帮忙做的,你只需要明白它在划范围就行。

caller
enter # 因为caller也是被调用的,所以需要push ebp 然后mov ebp,esp。用enter可以直接完成。
sub esp, 24 # 划分了6块的存储区域给这个函数,这个过程是操作系统来分的,它会预估需要多少空间

(2)这里有三个局部变量temp1,temp2和sum,我们在汇编里面不需要把这几个英文写出来,只需要把它们的值放入栈帧就行,操作系统也会自动帮我们把这三个名称和对应的位置联系起来。按照最先出现的放最后的原则,我们知道temp1和temp2分别应该放在[ebp-12]和[ebp-8]的位置,sum就放在[ebp-4]的位置。但是sum需要函数调用后获得,所以先进行把前面两个放了。

mov [ebp-12], 125 # temp1=125
mov [ebp-8], 80 # temp2=80

(3)现在开始函数调用,所以需要执行call指令,但是我们发现它有两个参数需要传递,所以先把这两个参数放到栈帧的顶部:

mov [esp+4], [ebp-8] # 把temp2放到栈帧顶的倒数第二格 

但是这是一条错误的mov指令,为了效率,mov指令不能让两个数据同时来自主存,所以必须借助寄存器中转(其实也不单单是效率问题,多数内存块不能同时又读又写,要实现同时读写,需要先把内容取出来到寄存器里,再写进去)。
于是正确的写法是借用一个空闲的通用寄存器(哪个都行):

mov eax, [ebp-8] # 先取出temp2的值
mov [esp+4], eax # 再放到对应位置

mov eax, [ebp-12] # temp1如法炮制,上面用完eax就闲着,所以可以继续用
mov [esp] , eax # 放到栈帧顶部

这里写的过程,我们要注意:函数栈帧中存放传递的参数的顺序和存放局部变量是一致的,都是先出现的放在最靠近栈帧顶部的位置。

(4)参数有了就可以call了:

call add # push IP 之后jmp到add:

(5)add这里开始也是一样的,但是因为代码是直接返回,所以操作系统就不会给它划更多栈帧范围,enter之后就开始操作了:

add:
enter

(6)这里注意的是,传递进来的参数不是这个函数的局部变量,所以不需要为x和y开空间存(因为已经在上一个函数那里存好了),直接使用即可。但是同样的,这里进行加法,但是两个数据都存在内存,所以需要和上面一样,先把一个数据放入寄存器才能运行add指令:

mov eax,[ebp+12] # x的值
add eax, [ebp+8] # x+y的值

当然,不同的编译器可能会编译出不一样,比如gcc编译器会把两个值都放到寄存器里:

mov eax,[ebp+12] # x的值
mov ebx, [ebp+8] # y的值
add eax, ebx

(7)计算完成以后,这个add函数就结束了,所以返回值其实是暂存在一个通用寄存器里面:

leave # 把esp和ebp指回原来的位置(mov esp, ebp 和 pop ebp)
ret  # 恢复原本的IP寄存器(pop IP)

(8)现在回到caller函数:
要做到只有把sum赋值,然后因为sum的值是返回值,所以又把sum的值加入到寄存器里面,然后结束即可。

mov [ebp-4], eax # 因为sum最后出现,反而在最接近ebp的位置
mov eax, [ebp-4] # 又存回寄存器,作为返回值
leave # 把ebp和esp指回上一个函数的栈帧

2.7.9 C++函数调用怎么返回多个值(熟悉C++的这里可以直接跳过)

学习到这里,我们可以发现因为函数的返回值需要用寄存器去传递,所以C++里面是不允许有多个返回值的。 如果你有多个返回值,你可以这样做:把这些值定义成一个数组,然后返回这个数组的起始地址。但是因为函数调用结束以后,原本函数栈帧里面的数据虽然还在,但是已经被标识为了无效数据,在没有进行任何操作的时候,可以从原本的地址读取到对应的数据,但是一旦进行了任何的操作,原本位置存的可能就存的不是原本数据了。例如:

// 作者:段鹏浩
#include <iostream>
#include "sqlist.h"
using namespace std;

int *p() {
	int a[10] = {};
	for (int i = 0; i < 10; i++) {
		a[i] = i;
}
	return &a[0]; //传回a[0]的地址
}


int main()
{
	int* s = p();//搞个指针指向这个地址
	cout << *(s+4)<<endl; // 这里可以读到4
	cout << *(s + 7) << endl; //这里读取的就不是7而是一个任意值了
}

感兴趣可以自己去尝试一下。正确的调用方法是使用静态数组,使用静态数组以后,它就不是局部变量了,它的值也就不是存在当前函数的栈帧里面了,而是内存里面额外划分的区域,不会因为函数调用的结束而被出栈:

#include <iostream>
using namespace std;

int* createArray() {
    static int arr[] = { 10, 20, 30, 40, 50 }; // 静态局部数组
    return &arr[0]; // 返回数组名
}

int main() {
    int* ptr = createArray(); // 接收函数返回的数组名

    for (int i = 0; i < 5; i++) {
        cout << *(ptr+i) << " ";
    }
    cout << endl;

    return 0;
}

返回的也可以是arr(因为arr=&arr[0]),读取的时候也可以是ptr[i],都是一样的。

#include <iostream>
using namespace std;

int* createArray() {
    static int arr[] = { 10, 20, 30, 40, 50 }; // 静态局部数组
    return arr; // 返回数组名
}

int main() {
    int* ptr = createArray(); // 接收函数返回的数组名

    for (int i = 0; i < 5; i++) {
        cout << ptr[i] << " ";
    }
    cout << endl;

    return 0;
}

缺点就是static会一直占用内存,所以推荐使用动态变量,这样程序员可以在不需要的时候把它delete掉:

#include <iostream>
using namespace std;

int* createArray() {
    int *arr = new int[5];
    for (int i = 0; i < 5; i++) {
        arr[i] = i;
    }
    return arr; // 返回数组名
}

int main() {
    int* ptr = createArray(); // 接收函数返回的数组名

    for (int i = 0; i < 5; i++) {
        cout << ptr[i] << " ";
    }
    cout << endl;
    delete[] ptr; //别忘记释放内存

    return 0;
}

当然也可以用vector等容器类来作为返回值,这里只是在学到这部分内容时提出的一点小小的扩展,不要太较真。只需要明白,在C++函数调用结束以后,里面定义的局部变量就不可以用了,并且返回值只能是一个(python可以打包成数组,当python打包成数组作为返回值的时候,python会把这个数组加到当前函数的栈帧中去,所以才说python更方便,但是慢),如果需要返回多个则要变成数组,或者一个结构体,同时这个结构体或者数组不能作为这个函数的局部变量,必须是静态或者动态的全局变量。再扩展一下,C++的容器类vector是动态的容器,它会自动delete,所以可以直接拿来作为返回值。
还有一种方式,就是你把它封装成一整个结构体进行返回,这个时候操作系统会把它完整的放到新的栈帧里面:

#include <iostream>

struct ArrayWrapper {
    int arr[5];
};

ArrayWrapper createArray() {
    ArrayWrapper arr;
    for (int i = 0; i < 5; i++) {
        arr.arr[i] = i + 1;
    }
    return arr; //返回这个结构体
}

int main() {
    ArrayWrapper nums = createArray(); // 原本的结构体的数据被复制到这个新的结构体里面,然后原本的arr被出栈销毁
    for (int i = 0; i < 5; i++) {
        std::cout << nums.arr[i] << " ";
    }
    return 0;
}

这其实是更常用和好用的返回多个变量的形式,因为一个结构体可以包含不同类型的变量。

2.7.10 函数栈帧中的其他值

因为函数的计算时,有一些计算的中间结果会被放在通用寄存器里,所以在进行函数调用前,会提前把所有寄存器里面的中间值放到函数栈帧里面,在调用结束以后再恢复计算,如图所示。
在这里插入图片描述
当然,这是操作系统决定的,因为中间变量不由程序员来定义。
王道考研已经帮我们贴心地整理好了本章的全部内容,现在可以看着这个图来检验一下学会没有:
在这里插入图片描述

  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值