CSAPP学习笔记
前言:
本人非常遗憾地告诉您。由于某些原因,更新到第三章过程(函数调用)结束就停止更新了.目前没有任何计划重新更新.
Chapter 1: A Tour of Computer System (计算机系统漫游)
这一章内容主要是通过介绍 hello world
这个程序的生命周期,对计算机系统的主要概念做了一个概述.
其中会涉及很多计算机领域的专有名词.对于初学者来讲,可能是第一次听到.
想要完全理解所有的知识点,需要一个较长时间的过程.因此,很长地方不理解也是很正常的.
Program Structure and Execution(程序结构和执行)
首先,我们看一下这个 hello
程序,非常简单的一个打印输出的程序.
源代码编写完成之后,保存得到一个后缀名为 .c 的文件 — hello.c
接下来通过这条简单的命令gcc -o hello hell.c
,即可完成对源代码的编译,生成可执行程序 hello
.
这个过程虽然是通过一条命令完成的,然而编译系统的处理过程却是非常复杂的.
大致分为一下四个阶段,分别是预处理、编译、汇编以及链接
首先看一下,预处理,预处理器会根据以#开头的代码,来修改原始程序.
例如 hello
程序中引入来头文件—stdio.h 预处理器会读取该头文件中的内容.将其中的内容直接插入到源程序中,结果就得到来另外一个C程序.
这个经过预处理器处理后得到的文件通常以 .i 结尾. 这个 hello.i
仍是一个文本文件.
第二阶段是编译阶段.编译器将 hello.i
文件翻译成 hello.s
文件.这一过程我们称为编译.其中编译这一阶段包括词法分析、语法分析、语义分析、中间代码生成以及优化等等一系列的中间操作.有关编译的细节知识不会在本文中进行详细地讲解.感兴趣的同学可以了解一下《编译原理》
对于编译阶段,输入文件 hello.i
经过编译得到输出文件—hello.s
第三阶段是汇编,汇编器根据指令集将汇编程序 hello.s
翻译成机器指令.
并把这一系列的机器指令按照固定的规则进行打包,得到可重定位目标文件—hello.o
.
至于可重定位是什么意思,稍后在链接阶段会讲,此时 hello.o
虽然是一个二进制文件,但是还不能执行.还要经历最后一个阶段—链接.
在 hello
这个程序中,我们调用了 printf
函数.这个函数是标准C库中的一个函数.
这个 printf
函数是在名为 printf.o
的文件中.这个文件是一个提前编译好的目标文件.链接器(ld)负责把 hello.o
和 printf.o
进行合并.
当然这个合并是需要遵循一定规则的,正是因为链接器要对 hello.o
和 printf.o
进行调整.
所以 hello.o
才会被称之为可重定位目标文件.最终经过链接阶段得到可执行目标文件—hello
.此时得到的hello
就可以被加载到内存中执行了.
为什么要理解编译系统是如何工作的呢?
Running Programs on a System(在系统上运行程序)
我们来看一下计算机的硬件组成.(Hardware Organization of a System)
CPU中的PC(Program Count)就是一个4字节(32-bit)或8字节(64-bit)的存储空间(实质上是一个大小为一个字的存储区域),里面存放某一条指令的地址
寄存器文件,它就是CPU内部的一个存储设备,是由一些单字长的寄存器构成,每个寄存器都有自己唯一的一个名字,通俗来说,寄存器可以理解为一个临时存放数据的空间
(在第四章中会详细讲述处理器是如何实现的)
主存,也称为内存,处理器在执行程序时,内存主要存放程序指令以及数据,从物理上讲,内存是由随机动态存储器芯片组成,从逻辑上讲,内存可以看成一个从零开始的大数组,每个字节都有相应的地址 (关于内存更多的详细内容,在第六章中有详细的讲解)
内存和处理器之间通过总线来进行数据传递,实际上,总线贯穿了整个计算机系统,它负责将信息从一个部件传递到另外一个部件,
通常总线被设计成传送固定长度的字节块,也就是字(word),这个字取决于操作系统.
控制器与适配器的主要区别是在于它们的封装方式,他们的功能都是在I/O设备与I/O总线之间传递数据 (第六章和第十章进行详细的讲解)
这个结构的主要思想就是上一层存储设备是下一层存储设备的高速缓存(关于缓存的更多内容将在第六章有更加详细的讲解)
Interaction and Communication between Programs(进程间的交互和通信)
操控硬件的是操作系统,我们可以把操作系统看作是应用程序和硬件之间的中间层
所有的应用程序对硬件的操作必须通过操作系统来完成.
这样设计的目的主要有2个:
一是防止硬件被失控的应用程序滥用
二是操作系统提供统一的机制来控制这些复杂的底层硬件.
文件是对I/O设备的抽象,虚拟内存是对内存和硬盘的I/O的抽象,进程是对处理器、内存以及I/O设备的抽象
(第八章将详细讲述关于进程的知识,在第十二章中,将讲述如何编写多线程程序)
如下图所示,为Linux系统中进程的虚拟地址空间,从下往上看,地址是增大的.最下面的是0地址,自下而上地介绍一下虚拟地址空间的分布
为什么程序内存栈是从高地址往低地址分配内存的,而其它的内存地址是从低到高分配内存?
因为栈设计为后进先出的特性(栈需要存储函数中的局部变量和参数,函数又是最后调用的最先销毁,栈的后进先出正好满足这一点)。
其次,栈是连续分配内存的,如果给一个数组或对象分配内存,栈会选择还没分配的最小的内存地址给数组,在这个内存块中,数组中的元素从低地址到高地址依次分配(不要和栈的从高到低弄混了)。所以数组中第一个元素的其实地址对应于已分配栈的最低地址。
第一个区域是用来存放程序的代码和数据的,这个区域的内容是从可执行目标文件中加载而来的,对于所有的进程来说,代码都是从固定的地址开始,(关于这个区域的详细内容会在第七章介绍)
顺着地址增大的方向往上看就是堆(heap),堆也可以在运行时动态的扩展和收缩(第九章研究虚拟内存的时候,会详细介绍堆)
接下来,就是共享库的存放区域,这个区域主要存放像C语言的标准库和数学库这种共享库的代码和数据(在第七章介绍链接时,会详细介绍共享库事如何工作的)
我们,继续往上看,这个区域被称为用户栈(user stack),函数调用的本质就是压栈,每一次当出现进行函数调用的时候,栈就会增长,函数执行完毕返回时,栈就会收缩,栈的增长方向是从高地址到低地址到的,(在第三章,会详细介绍编译器是如何使用栈来实现函数的调用的)
最后,我们看一下虚拟空间的最顶部区域,这个区域是为内核保留的区域,应用程序代码不能读写这个区域的数据,也不能直接调用内核中定义的函数,也就是说,这个区域对应用程序是不可见的(关于更多虚拟内存的知识,将在第九章进行详细的讲述)
Linux系统的哲学思想是:一切皆为文件. 如何理解呢?一切I/O设备,包含键盘,磁盘,显示器,甚至网络,这些可以看成文件,系统中所有的输入输出都可以通过读写文件来完成,虽然文件的概念非常简单,但却非常强大 (将在第十章详细讲述Unix IO)
(在第十一章中,将介绍如何创建一个简单的web服务器)
为了定量地看系统加速比,我们首先介绍一下阿姆达尔定律,这个定律的主要思想是,当我们对系统的某一部分进行加速时,被加速部分的主要性是影响整体系统性能的关键因素
(第十二章会深入探讨并发的相关知识)
现代处理器可以同时执行多条指令的属性被称为指令级并行,每条指令从开始到结束大概需要20个时钟周期或者更多,但是处理器采用了非常多的技巧可以同时处理多达100条指令,因此,近几年的处理器可以保持每个周期2~4条指令的执行速率 (在第四章,将介绍流水线技术)
现代处理器拥有特殊的硬件部件,允许一条指令产生多个并行操作,这种方式称为单指令多数据(Single Instruction Multiple Data)
SIMD的指令多是为了提高处理水平、以及声音这类数据的执行速度
Chapter 2: Representing and Manipulating Information (信息的表示和处理)
Information Storage(信息存储)
信息在计算机系统内是如何存储的?
通常情况下,程序将内存视为一个非常大的数组,数组的元素是由一个个多字节组成, 每个字节都由一个唯一的数字来表示,我们称为地址(address),这些所有地址的集合就称为虚拟地址空间(virtual address space)
字节,信息存储的基本单元
一个字节是由8个位(bit)组成,在二进制表示法中,每个位的值可能有两种状态,0或1,当这8个位全为0时,表示这个字节的最小值,当全为1时,表示最大值,如果用十进制来表示,那么一个字节的取值范围就在0~255(包含0和255)之间,我们把这种一位一位表示数据的方式称为位模式
使用二进制表示法比较冗长,而十进制表示法与位模式之间的切换又比较麻烦,因此,我们引入了十六进制数来表示位模式
在C语言中,十六进制数是以0x开头,这个X可以是小写,也可以是大写,字母部分既可以全部是大写,也可以全部是小写,甚至混合都没问题
二进制到十六进制转换:
十进制与十六进制之间的转换需要采用除法或者乘法来处理:
用辗转相除法将一个十进制数转成十六进制数
字长决定了虚拟地址空间的最大值的可以到多少
对于我们需要存储的数据,我们需要搞清楚该数据的地址是什么,以及数据在内存中如何排布的,
通常二种规则
大端法,最高有效字节存储在最前面,也就是低地址处
小端法,最高有效字节存储在最后面,也就是高地址处
大多数Intel兼容机采用小端模式,IBM和Sun公司的机器大多数采用大端法,对于很多新处理器,支持双端法,可以配置成大端或者小端运行,例如基于ARM架构的处理器,支持双端法,但是Android系统和iOS系统却只能运行在小端模式
布尔代数定义的几种运算:
C语言中的一个特性就是支持 按位 进行布尔运算
位运算的一个常见用法就是实现掩码运算,通俗点讲,通过位运算可以得到特定的位序列
除了位级运算之外,C语言还提供了一组逻辑运算,逻辑运算的运算符容易与位级运算混淆
逻辑运算认为所有非零的参数都表示true,只有参数0表示false
逻辑运算的几个例子:
事实上,逻辑运算的结果只有两种,true或者false,而位运算只有在特殊的数值条件下才会得到0或1
除此之外,C语言还提供了一组移位运算
左移情况比较简单,对于右移,分为逻辑右移和算术右移
逻辑右移和左移只是在方向上存在差异
算术右移:当算术右移到操作对象的最高位等于0时,算术右移和逻辑右移是一样的,没有任何差别
但当操作数的最高位为1时,算术右移之后,右端需要补1,而不是补0
Interger Representations(整数表示)
关于long类型的大小需要注意一下,这个类型的取值范围是与机器字长相关的.当变量声明带有unsigned关键字时,限制了表示的数字只能为非负数(无符号数).
首先,我们看一下计算机是如何对无符号数进行编码的.
假设有个整数的数据类型有w位,用向量x来表示.
如果把向量x看成一个二进制表示的数.向量x中的每个元素表示一个二进制位,其中每个位的取值为0或1.
我们用一个函数B2U来表示一个长度为w的0,1串是如何映射到无符号数的.
如果这个映射过程不好理解,可以类比一下十进制的表示方式.其中二者之间的一个差别就是$x_i$的取值是0或1,而$y_i$是取0~9之间的整数,另外一个差别就是$x_i$对应的权重是$2^i$,而$y_i$对应的是$10^i$.
为了更加清楚地解释无符号数的表示方法.
CSAPP的原书中还介绍了一种图形化的表示方法来帮助读者理解无符号数的编码规则.
对于向量第$i$位,我们用一个长度为$2^i$的蓝色条状图来表示.每个位向量所对应的值就等于所有值为1的位,所对应条状图的长度之和.
例如编码0101,就是长度为4($2^2$)的条状图 加上长度为1($2^0$)的条状图.
因此4位编码所能表示的无符号数的取值范围是$0$~$15$.
我们可以发现这种编码方式,只能表示非负数,具有相当大的局限性,那么负数是如何编码的呢?
关于符号位,需要理解负权重的概念,而不能简单的当成一个负号
在了解了无符号数和有符号数的编码规则之后,我们分别看一下不同字长可以表示整数的范围
无论是字长为8,还是字长为64,最小值的表示都是符号位为1,其他位等于0
对于-1,这个需要特别注意一下,无论字长是8位,还是64位,有符号数-1的补码是一个全为1的串
-1的补码与无符号数的最大值有着相同的二进制位表示
虽然C语言的标准中并没有要求用补码来表示有符号数
但是几乎所有的机器都是用补码来表示有符号数
为了更好地理解补码的表示,我们再看一个例子:
其中1234被定义为short类型,将它展开为二进制表示,12345位模式表示如上图所示
根据补码的编码定义,相应矩形框内的权重值相加可以得到12345
我们再来看一下-12345的位模式表示,这里需要注意的是最高符号位等于1
与-12345相同的位模式的无符号数又是多少呢?
对于无符号数,最高位的1表示的不是负权重,根据无符号数的编码定义可以得到53191
对于相同的位模式,映射函数不同,得到的数值也不同
C语言允许数据类型之间做强制类型转换
例如图中的代码示例,变量a是short类型,通过强制类型转换成无符号数,那么变量b的数值是多少呢?
我们来看一下运行结果
-12345经过强制类型转换后得到的无符号数是53191
从十进制的表示来看,很难看出二者的关系
将十进制表示转换成二进制表示,我们可以发现,二者的位模式是一样的
对于大多数C语言的实现,有符号数和无符号数之间的转换的规则是:
位模式不变,但是解释这些位的方式改变了
接下来我们看一下,对于相同的位模式,不同函数映射所导致的数值差异
无符号数和有符号数的函数映射关系如图所示
我们将B2U与B2T做差,得到的结果就是二者的数值的差异
然后我们对这个式子进行移项处理
B2T从等式的左边移到等式的右边,对于相同的位模式,无符号数与有符号数的数值关系如图所示
我们用T2U来表示有符号数到无符号数到函数映射
当最高位X~w-1~等于1时,此时有符号数x表示一个负数,经过转换后得到的无符号数等于该有符号数加上2^w^
当最高位X~w-1~等于0时,此时有符号数x表示一个非负数,得到的无符号数与有符号数是相等的
以上为有符号数转无符号数,接下来介绍无符号数转有符号数
同样,还是对这个等式移项
我们用U2T来表示无符号数到有符号数的函数映射
当最高位等于0时,无符号数可以表示的数值小于有符号数的最大值,此时转换后的数值不变
当最高位等于1时,无符号数可以表示的数值大于有符号数的最大值,此时转换后得到的有符号数等于该无符号数减去2^w^
图中T2U和U2T这两个函数映射关系体现了有符号数与无符号数转换的数值关系
为什么要解释二者的转换关系呢?
在C语言中,在执行一个运算时,如果一个运算数是有符号数,另外一个运算数是无符号数
那么C语言会隐式地将有符号数强制转换为无符号数来执行运算
例如图中的例子,我们希望得到-1小于0的输出
但是在执行时,却得到了-1比0大的结果
由于第二个操作数0U是无符号数,第一个操作数就隐式地转换成无符号数
这个表达式实际上比较的是4294967295(2^32^-1)<0
C语言中还有一个常见的运算是在不同字长的整数之间进行转换
将一个较大的数据类型转换成一个较小的数据类型
由于目标类型太小,想要保持数值不变是不可能的
然而将一个较小的数据类型转换成较大的数据类型时,保持数值不变是可以的
我们先来看一下把无符号数转成一个更大的数据类型
例如,我们将一个unsigned char类型变量,转换成unsigned short类型
变量a占8个bit位,而变量b占16个bit位
对于无符号数的转换比较简单,只需要在扩展的数位进行补零即可
我们将这种运算称为零扩展,具体表示如图所示
根据无符号数的编码定义,零扩展之后的数值不变
与无符号数相比,将有符号数转换成一个更大的数据类型
需要执行符号位扩展,这个符号为就是最高位,对于符号为扩展该如何理解呢?
当符号数表示表示非负数时,最高位是0,此时扩展的数位进行补零即可
当符号数表示表示负数时,最高位是1,此时扩展的数位进行补1,具体表示如图所示
看到这里,相信有些读者会对补1的情况有些疑问
我们来看一下原书中给出的数学证明
对于一个w位的有符号数,我们用B2T~w~来表示
对这个有符号数进行k位的符号扩展,具体过程如图所示
经过扩展之后的有符号数用B2T~w+k~来表示
为了方便描述,我们将两个函数映射分别记为(1)和(2)
假如可以证明表达式(1)和(2)相等,那么经过符号位扩展确实可以保持数值不变
接下来我们的任务是证明表达式(1)和(2)相等
假如能过证明符号位扩展一位,可以保持数值不变,那么扩展任意位,就都能保持这种属性
因此,我们只要能过证明B2T~w+1~等于B2T~w~,就可以确定B2T~w+k~等于B2T~w~
根据补码的编码规则,B2T~w~和B2T~w+1~的展开式如图所示
然后将二者做差,经过简单的合并之后,得到图中的表达式,由于X~w~是由X~w-1~扩展得到的,因此做差的结果等于0
综上所述,我们通过数学的方法证明了当有符号数从一个较小的数据类型转换成较大数据类型时
进行符号位扩展,可以保持数值不变.
看完较小数据类型到较大数据类型的转换
我们再看看较大数据类型换成较小数据类型的情况
将int类型强制转换成short类型时,int类型高16数据被丢弃,留下底16位数据
因此截断一个数字,可能会改变它原来的值
将一个w位的无符号数,截断成k位时,丢弃最高的w-k位,截断操作可以对应取模运算
对于二进制取模运算,通俗的说法就是除以2^k^之后得到的余数
我们可以通过一个十进制的例子类比理解一下
例如十进制数123456,截取底三位数456,可以通过10^3^进行取模运算得到
我们再来看一下截断有符号数
虽然原书中给出了以下的表达式,这个式子乍一看有点吓唬人
其实并不难理解,我们可以分成两部分来看
第一步,我们用无符号数多函数映射来解释底层的二进制位,这样一来我们可以使用与无符号数相同的截断方式,得到最低K位
第二步,我们将第一步得到的无符号数转换成有符号数
经过这两步,我们就得到了有符号数截断之后的值
Integer Arithmetic(整数运算)
首先,我们看一个无符号数加法的例子
图中的两个无符号数相加,我们期望得到的结果是256,然而实际的运行结果却是零
产生这个结果的原因是因为a加b的和超过了unsigned char所能表示的最大值255
我们将这种情况称为溢出
接下来,我们看一下无符号数的加法原理
这里我们引入一个符号来表示w位的无符号数加法,其中u是unsigned的缩写,表示无符号数
对于操作数x和y,二者的取值范围都是大于等于0,小于2^w^
我们重点来看一下,发生溢出时,这个结果是如何得到的
首先,我们看一下变量a,b的二进制表示,具体如图所示
当执行加法运算后,得到结果的二进制如上图所示,为了使得运算结果的数据位保持w位不变,最高位的1会被丢弃
因此得到的结果相当于减去2^w^
在C语言的执行过程中,对于溢出的情况并不会报错,但是我们希望判定运算结果是否发生了溢出
下面我们看一下C语言中是如何判断溢出的:
由于x,y都是大于等于0的,因此二者之和一定大于等于其中的任意一个数,因此,我们可以用图中的代码来判断是否发生了溢出
如果返回值为1,表示运算结果正常,如果返回值为0,则表示发生了溢出,当然图中的if-else的书写方式是为了方便理解,实际可以简化为一句
对于这个判断条件是否正确,参考图中右边简单的数学推导,因此可以证明,当发生溢出时,得到的和小于其中的任意一个数(x或者y)
看完了无符号数加法,我们再来看一下有符号数加法
有符号数x,y,它们的取值范围如图所示
对于补码的加法运算,我们同样引入一个符号来表示,其中t就是补码的首字母缩写
想要准确地表示有符号数相加的结果需要w+1位,为了避免数据大小的扩张,最终结果将截断位w位来表示
与无符号数相加不同的是,有符号数的溢出分为正溢出和负溢出
接下来,我们看一个代码实例,例如 图中所示的代码,x加上y,我们期望得到的结果是 128,然而运行结果却是 -128
我们可以通过二进制的表示,来看一下结果为什么是-128
对于x和y的二进制表示如图所示,x与y相加之和,运算结果二进制表示如图所示,根据之前学过的有符号数的表示方式,最高位的1解释为负权重,因此运行结果就等于 -128,这个运行结果与通过公式计算的结果也是一致的,对于负溢出的情况,也是类似的
对于如何检测有符号数相加是否发生了溢出比较简单
当两个正数相加,得到的结果为负,则说明发生了正溢出,当两个负数相加,得到的结果为正,则说明发生了负溢出
看完了加法运算,我们再来看一下减法是如何实现的
CSAPP原书中提到了一个加法逆元(additive inverse)的概念
乍一听这一词比较陌生,其实也非常好理解,对于一个给定的x,存在x’,使得 x加上x’ 等于 x’加上x,并且等于0
我们称x’为x的加法逆元,实际上x与x’互为相反数,加法逆元也可以称为相反数
对于减法运算y-x,我们可以转换成y加上x的相反数,那么我们看一下对于无符号数x,它的相反数x’一个如何表示?
根据相反数的定义需要满足x’+x=0,但是x’和x都是非负数,那么x’应该如何来表示呢?
当x’加x等于2^w^时,导致溢出,同样结果也等于0,因此对于任意x大于等于0,小于2^w^,其w位的无符号逆元x’的表示如上图所示
我们再来看一下有符号数的逆元
对于补码表示的有符号数的逆元比较简单,当x大于最小值的情况,x的逆元就是负的x
唯一需要注意的地方就是当x取最小值时,由于补码表示的最大值与最小值是非对称的,最大值的绝对值比最小值的绝对值要小
关于最小值的逆元需要通过负溢出的方式来实现,补码最小值的逆元就是本身,具体数学公式表示如下图所示.
接下来会讲述乘法和除法.
首先看一下无符号数的乘法运算,对于w位的无符号数x和y,二者的乘积可能需要2w位来表示
在C语言中,定义了无符号数乘法所产生的结果是w位,因此,运行结果中会截取2w位中的低w位.
我们知道,截断操作所对应的数学运算——取模
因此,运行结果等于x与y乘积并对2^w^取模
接下来,我们看一下补码的乘法
无论是无符号数乘法,还是补码乘法,运算结果的位级表示都是一样的,只不过补码乘法比无符号数乘法多一步,需要将无符号数转换成补码(有符号数),具体数学表达式如图所示
接下来我们看几个例子,图中展示了3位无符号数和补码的乘法示例,通过表格中所列举的三组示例,我们可以看到
虽然完整的乘积结果的位级表示可能会不同,但是截断后的位级表示都是相同的
接下来,我们通过数学的方法来证明一下
假设x和y表示有符号数,x’和y’表示无符号数,x和x’的二进制表示相同,y与y’的二进制表示相同
根据之前我们讲过无符号数与有符号数的定义,对于相同的二进制表示,无符号数x’与有符号数x之间的关系如图所示
同样,无符号数y’与有符号数y之间的关系也如此.
对于x’乘以y’,然后对2^w^取模运算的具体推导过程如图所示
由于取模运算的原因,所有带有权重2^w^和2^2w^的项都丢掉了
虽然无符号数和补码两种乘积的完整位表示不同,但是截断之后结果的位级表示却相同
由于乘法指令的执行需要多个时钟周期
很多C语言的编译器试图用移位、加法和减法来代替整数乘法的操作
首先我们看一下乘以2的幂的情况
x乘以2^k^,就对应左移k位
相信看到这里,很多同学会有疑问,为什么乘以2的幂可以转换成左移操作?
接下来,我们看一下简单的数学证明.
例如,w位的无符号数x的二进制表示如图所示.
根据无符号数的定义,该二进制位所对应的无符号数可以通过图中的数学表达式计算得到
我们将$x$左移$k$位,移位后的二进制数位由$w$位增加到$w+k$位.
同样根据无符号数的定义,移位后的二进制所对应的无符号数如图所示.
通过对比移位前后二者之间的变化,我们可以发现移位后,无符号数$x’$等于无符号数 $x * 2^k$
接下来,我们通过一个例子来看看乘以任意常数的情况.
例如,$x$ $14$,$14$的二进制表示如图所示.$x$ $14$ 等于$x$ * $(2^3+2^2+2^1)$
根据刚才讲到的,乘以$2$的幂可以等效为左移操作
这样一来,一个乘法操作可以使用三个移位操作和两个加法操作来代替.
更好的情况,编译器甚至可以把$14$分解成$16-2$.这样一个乘法操作可以用两个移位和一个减法来代替
看完了乘法运算,我们再来看一下除法.
对于除以$2$的幂也可以用移位来实现,不过除法的移位采用的是右移,而不是左移.
关于右移到情况.这里需要注意一下.对于无符号数采用的是逻辑右移,而有符号数采用的是算术右移 (它们的差别在2.1中有)
整数的除法,还会遇到除不尽的情况,总是朝向$0$的方向进行舍入
例如,$3.14$向零舍入的结果是$3$,$-3.14$向零舍入的结果是$-3$
对于$x\geq0,y>0$的情况,结果会向下舍入
对于$x<0,y>0$的情况,结果是向上舍入
首先,我们看一下无符号数除以$2$的幂的情况, $x$ 表示 $w$ 位的无符号数,对$x$进行右移操作$k$位的结果如图所示.
为了方便描述,这里我们引入两个变量,$x_1和x_2$
其中$x_1$是$w-k$位的无符号数,$x_2$是$k位$的无符号数,我们将$x_1$左移$k$位
根据前面讲到的,左移$k$位等于$x_1*2^k$
$x_1$左移$k$位与$x_2$相加之和与$x$相等
由于$x_2$的长度为$k$位,因此$x_2$的取值范围为$0\leq x_2<2^k$,因此$x$除以$2^k$,取整的结果就等于$x_1$,这与$x$逻辑右移k位得到的结果是一样的
看完了无符号数的除法,再看一下补码的除法,当补码的最高位等于0时,对于非负数的来讲,算术右移与除以$2^k$是一样的.
对于负数来讲,需要特别注意一下,例如对 -12340对16位表示进行算术右移不同数位的结果
当需要舍入时,移位导致-771.25向下舍入位-772
根据整数除法向零舍入的原则,我们期望的得到的结果是-771
因此,需要在移位之前加入一个偏置,来修正这种不合适的舍入,其中偏置的值等于1左移k位减去1.如图所示,当右移4位时,偏置量为$15((1<<4)-1)$ 通过加入偏置之和,再进行算术右移,即可得到向零舍入的结果  对于补码除以$2^k$的情况,当 $x<0$ 时,需要加上偏置,再进行算术右移.当 $x>0$ 时,可以直接进行算术右移.
不幸的是,这种方法并不能推广到除以任意常数.
与乘法不同,我们不能用除以2的幂的方法来表示除以任意常数k的除法.
Floating Point(浮点数)
理解浮点数的第一步是考虑含有小数值的二进制数
我们先来看一下熟悉的十进制表示法,其中每个十进制位$d_i$的取值范围是$0$~$9$,这种表示的数学表达式如图所示,以小数点为分界线,小数点左边数字的权重是$10$的正幂,小数点右边数字的权重是10的负幂.
类比十进制的表示,我们看一下二进制的是如何表示小数的,其中$b_i$的取值是$0或1$.具体关于二进制权重的理解可以看一下这张图.
对于这种定点表示的方法,并不能很有效地表示非常大的数.
接下来,我们看一下IEEE的关于浮点数的表示
表达式$V = (-1)^s$ $M$ $2^E$,涉及三个变量$s$,$E$和$M$
下面我们通过以单精度浮点数为例,看一下二进制位与浮点数之前的关系
例如C语言中float类型的变量占4个字节,32个比特位.这32个比特位被划分成3个字段来解释,具体表示如图所示.
其中最高位31位表示符号位为$s$,当$s=0$时,表示正数,当$s=1$时,则表示负数
从第23位到30位,这8个二进制位与阶码的值是相关的,剩余的23位与尾数$M$是相关的.
对于64位双精度浮点数,其二进制位与浮点数的关系如图所示.
与单精度浮点数相比,双精度浮点数的符号位也是1位.但是阶码字段的长度为11位,小数字段的长度为52位.
浮点数的数值可以分为三类.
第一类是规格化的值,第二类是非规格化的值,第三类是特殊值
其中阶码的值决定了这个数是属于其中哪一类
当阶码字段的二进制位不全为0,且不全为1时,此时的是规格化的值
当阶码字段的二进制位全为0时,此时表示的是非规格化的值.
当阶码字段的二进制位全为1时,表示的数值为特殊值.
特殊值分为两类,一类表示无穷大或者无穷小,另外一类表示“不是一个数”
接下来,我们详细解释一下这三类数值的表示.当表示规格化的值时,其中阶码字段的取值范围如图所示.最小值是1,最大值是254.
为了方便描述,我们用小写字母e来表示这个8位二进制数.
需要注意的是阶码的值并不等于$e$(8个二进制位)所表示的值,而是$e$的值减去一个偏置量,偏置量的值与阶码的字段的位数是相关的.
当表示单精度的值时,阶码字段的长度为8,偏置量等于127.
当表示双精度的值时,阶码字段的长度为11,偏置量等于1023.
因此,对于单精度浮点数,阶码的最小值是-126,最大值是127.
我们再来看一下小数字段
尾数M被定义为1+f,尾数M的二进制表示如图所示.
因为我们可以调整E的取值,使得尾数M的取值范围为$1\leq M\leq2$
既然第一位总是1,那么就没有必要显示的表示出来.
这就是为什么尾数M的值需要加1,这个加1的地方需要特别记住
在了解了符号位、阶码字段以及小数字段所表示的数值后
就可以根据浮点数的计算公式来计算出对应的数值
接下来,我们看一下第二类数值——非规格化的值
当阶码字段的二进制位全为0时,所表示的是非规格化的值
关于非规格化的数有两个用途
一、提供了表示数值0的方法
当符号位s等于0,阶码字段全为0,小数字段也全为0时,此时表示正零.
当符号位s等于1,阶码字段全为0,小数字段也全为0时,此时表示负零.
根据IEEE的浮点规则,正零和负零只是在某些方面被认为是不同的.
二、可以表示非常接近0的数.
当阶码字段全为0时,阶码E的值等于1-bias ,而尾数的值M等于f,不包含隐藏的1.这与规格化的值的解释方法不同,这里需特别注意一下.
我们再来看一下特殊值是如何表示的
当阶码字段全为1,且小数字段全为0时,表示无穷大的数.
无穷大也分两种,正无穷大和负无穷大
如果符号位s等于0时,表示正无穷大.
如果符号位s等于1时,表示负无穷大.
此外,还会遇到一些运算结果不为实数或者用无穷也无法表示的情况.
这里引入一个新概念——“不是一个数”(Not a Number)
例如,我们对-1进行开方运算,无穷减无穷的运算,此时得到的结果就会返回NaN.
当阶码字段全为1,且小数字段不为0时,可以表示NaN(Not a Number)
以上便是浮点数三类数值的表示规则.
为了更加直观地理解,我们看一个8位浮点数的表示例子.
这个示例假定符号位长度为1,阶码字段的长度为4,小数字段长度为3.
对于非规格化数0的表示,我们可以看到阶码字段和小数字段全都为0.
对于其他非常接近0的非规格化数,我们可以看到其中阶码字段全部为0.小数字段的取值范围从001~111,这个小数字段所对应的尾数的值如图所示
最终,这个8位浮点数的数值是由阶码的幂与尾数相乘之后得到的
看完了非规格化的数,我们再来看看规格化的数.
首先,看一下规格化的数的最小值是如何表示的,阶码字段的二进制数值为0001,长度为4的阶码所对应的偏置量是7,根据计算公式可以得到阶码E的值为-6.
由于小数字段为0,因此尾数M等于1.
最终得到最小值$V=1*2^{-6}$
不断地增加阶码,会获得更大的规格化的值
当阶码字段为1110,小数字段为111时,此时可以得到最大的规格化的值为240.
接下来,看一下特殊值的表示.
当阶码字段全为1且小数字段全为0时,可以表示无穷大.
接下来,我们将整型数12345转换成浮点数12345.0
通过转换过程,我们将会了解这段匹配数位是如何产生的
整型数12345,其二进制数表示如图所示
虽然int类型变量占32个比特位,由于该数的高18位都等于0.
因此,我们可以将高18位忽略,只看低14位
根据规格化数的表示规则,我们可以将12345用图中的方式来表示
根据IEEE浮点数的编码规则,我们将小数点左边的1丢弃.
由于单精度的小数字段长度为23,我们还需要在末端增加10个零
这样我们就得到了浮点数的小数字段
从12345的规格化表示可以发现阶码E的值等于13.
由于单精度浮点数的bias(偏置)等于127.
因此根据公式$E=e-bias$,可以计算出$e=140$,其二进制表示为$1000\,\,1100$,这样一来,浮点数的阶码字段也得到了. 最后,再加上符号位的0,整个单精度浮点数的二进制表示就构造完了.
通过这个构造过程,我们可以发现之前提到的匹配的字段是如何产生的.由于表示方法的原因,限制了浮点数的范围和精度,所以浮点运算只能近似地表示实数运算.
对于值x,可能无法用浮点数形式来精确表示.
因此我们希望可以找到”最接近的值” $x’$ 来代替x.这就是舍入操作的任务.
一个关键的问题就是在两个可能的值中间确定舍入方向
例如一个数值1.5,想把该数舍入到最接近的整数,舍入结果应该是1还是2呢?
IEEE浮点格式定义来四种不同的舍入方式,分别是:
1.Round-to-even(向偶数舍入)
2.Round-toward-zero(向零舍入)
3.Round-down(向下舍入)
4.Round-up(向上舍入)
向下舍入和向上舍入的情况比较简单,向下舍入总是朝着小的方向进行舍入.
比如 1.4,1.5,1.6这三个数向下舍入的结果都是1.
而向上舍入总是朝着大的方向进行舍入.那么上面这三个数结果都为2.
之前提到过向零舍入,这里不再赘述.
第四种舍入方式为 向偶数舍入,也被称为向最接近的值舍入.
例如: 1.4的舍入结果是1, 1.6的舍入结果是2.
需要注意的是当遇到两个可能结果的中间数值时
舍入的结果应该如何计算?
例如: 1.5,2.5这类数据,向偶数舍入的舍入结果要遵循 最低有效数字是偶数的规则.
因此1.5的舍入结果究竟是1还是2,取决于1和2哪个数是偶数,对于2.5同理,因此,1.5和2.5向偶数舍入的结果都是2.
为什么要偏向取偶数呢?
如果总是才用向上舍入,会导致结果的平均值相对于真实值略高
如果总是才用向下舍入,会导致结果的平均值相对于真实值略低向偶数舍入就减少了这种统计偏差.
向偶数舍入除了十进制小数上使用也可以用在二进制小数上.
将最低有效位的值0认为是偶数,1认为是奇数
例如图中这个二进制小数,当舍入需要精确到小数点右边2位时.
由于这个数是两个可能值的中间值,根据向偶数舍入的规则
舍入结果为11.00
接下来,我们看一下浮点数加法的例子
例如图中的两个表达式,其中表达式1的计算结果等于0.0
而表达式二的计算结果等于3.14.
这是由于表达式1在计算3.14与1e10相加时,对结果进行了舍入,值3.14会丢失.
因此,对于浮点数的加法是不具有结合性的.
同样由于计算结果可能发生溢出,或者由于舍入而失去精度
导致浮点数的乘法也不具有结合性
例如图中的两个表达式,表达式3的结果为正无穷大,而表达式4的结果为$1*19^{20}$
此外,浮点乘法在加法上不具备分配性.
例如在单精度浮点的情况下,图中两个表达式的计算结果也不同
对于从事科学计算的程序员以及编译器的开发人员来说
缺乏结合性和分配性是一个比较严重的问题
C语言提供了两种不同的浮点数据类型,单精度float类型和双精度double类型.当int,float,double不同数据类型之间进行强制类型转换时.
得到的结果可能会超出我们的预期
当int类型转换成float类型时,数字不会发生溢出,但可能会被舍入.
这是由于单精度浮点数的小数字段是23位的,可能会出现无法保留精度的情况.
当int类型或者float类型转换成double类型时,由于double类型具有更大的范围,所以可以保留精确的数值.
从double类型转换成float类型,由于float类型所表示的数值范围更小,所以可能会发生溢出.
此外,float类型的精度相对于double较小,转换后可能被舍入.
当float类型或者double类型的浮点数转换成int类型,一种可能的情况是值会向零舍入.
例如: 1.9会被转化成1,-1.9会被转换成-1
另外一种可能的情况则是发生溢出.
Chapter 3: Machine-Level Representation of Programs (程序的机器级表示)
在第一章讲述计算机系统漫游时,我们曾提到过编译系统的工作流程.
这一节我们详细介绍一下C语言、汇编代码以及机器代码之间的关系.
理解汇编代码与原始C代码之间的关系,是理解计算机如何执行程序的关键一步.
因此,稍微花一些时间来学习一下汇编语言,对理解计算机系统是非常有必要的.
我们先来简单地看一下英特尔(Intel)处理器的发展历史。
接下来,我们看一个C代码的例子.
这个示例中包含两个源文件,一个是main.c,另外一个是mstore.c
我们可以通过图中的命令编译这些代码.其中gcc指的是GCC编译器,它是Linux系统上默认的编译器.其中编译选项-Og是用来告诉编译器生成符合原始C代码整体结构的机器代码.
在实际项目中,为了获得更高的性能,会使用-O1或者-O2,甚至更高的编译优化选项.但是使用高级别的优化产生的代码会严重变形.导致产生的机器代码与最初的源代码之间的关系难以理解.
这里方便理解,因此选择-Og这个优化选项,-o 后面跟的参数prog表示生成可执行文件的文件名.关于更多链接方面的知识,我们将在第七章做详细地讲解.
首先,我们以源文件mstore.c为例,看一下C代码和汇编代码之间的关系.
使用这条命令(gcc -Og -S mstore.c
)可以生成mstore.c所对应的汇编文件mstore.s . 其中-S这个编译选项就是告诉编译器GCC产生的文件为汇编文件
我们可以用编辑器(vim)等打开这个汇编文件.由于篇幅限制,我们省略来部分内容.
其中以“.”开头的行都是指导汇编器和链接器工作的伪命令.也就是说我们完全可以忽略这些以“.”开头的行.
删除了无关的信息之后,剩余这些汇编代码与源文件C代码是相关的.
接下来,我们看一下这段C程序所对应的第一条汇编代码.
pushq这条指令的意思是将寄存器rbx的值压入程序栈进行保存.
相信大家对一开始的这个操作会有疑问.为什么程序一开始要保存寄存器rbx的内容?
这里我们需要简单介绍一下寄存器的背景知识。
在Intel x86-64的处理器中包含了16个通用目的的寄存器
这些寄存器用来存放整数数据和指针.图中显示的这16个寄存器,它们的名字都是以%r开头的.在详细介绍寄存器功能之前,我们首先需要搞清楚两个概念.
调用者保存寄存器 和 被调用者保存寄存器
例如图中的这个例子函数A中调用了函数B.因此,函数A称为调用者,函数B称为被调用者.由于调用了函数B,寄存器rbx在函数B中被修改了.逻辑上,寄存器rbx的内容在调用函数B的前后应该保持一致.
解决这个问题有两个策略.
一个是函数A在调用函数B之前提前保存寄存器rbx的内容(save register rbx).执行完函数B之后,再恢复寄存器rbx原来存储的内容(restore register rbx). 这种策略称之为 调用者保存
另外一个策略是函数B在使用寄存器rbx之前,先保存寄存器rbx的值,在函数B返回之前,先恢复寄存器rbx原来存储的内容. 这种策略被称之为 被调用者保存
对于具体使用哪一种策略,不同的寄存器被定义成不同的策略.
具体如图所示.
寄存器rbx被定义为 被调用者保存寄存器(callee-saved register)
因此,pushq就是用来保存寄存器rbx的内容.
在函数返回之前,使用了pop指令,恢复寄存器rbx的内容.
第二行汇编代码的含义是将寄存器rdx的内容复制到寄存器rbx.
根据寄存器用法的定义,函数multstore的三个参数分别保存在寄存器rdi,rsi和rdx中.
这条指令结束后,寄存器rbx与寄存器rdx的内容一致.都是desk指针所指向的内存地址.
move指令的后缀“q“表示数据的大小.由于早期的机器是16位,后来才扩展到32位.
因此,Intel用字(word)来表示16位的数据类型.
所以,32位的数据类型称为双字,64位的数据类型就称为四字.
图中的表格给出来C语言的基本类型对应的汇编后缀表示.其中需要特别注意这一列的内容
大多数GCC生成的汇编指令都有一个字符后缀来表示操作数的大小
例如数据传送指令就有四个变种.
分别为movb、movw、movl以及movq.分别对应Move byte的缩写,表示传送字节,Move word的缩写,表示传送字,Move double word的缩写,表示传送双字,其中l是long word的缩写,传送四字.
call指令对应于C代码中的函数调用,这一行代码比较容易理解.
该函数的返回值会保存到寄存器rax中.
因此,寄存器rax中保存来x和y的乘积的结果.
下一条指令将寄存器rax的值送到内存中保存,内存的地址就存放在寄存器rbx中.
最后一条指令ret就是函数返回.关于更多寄存器与汇编语言的知识,后续会有更加详细的介绍.
接下里,我们看一下C代码是如何翻译成机器代码的
我们只需要将编译选项-S替换成-c ,执行 gcc -Og -c mstore.c
这条命令,即可产生mstore.c所对应的机器代码文件mstore.o
由于该文件是二进制格式的,所以无法直接查看.
这里我们需要借助一个反汇编工具—objdump
汇编器将汇编代码翻译成二进制的机器代码,那么反汇编器就是机器代码翻译成汇编代码.通过命令objdump -d mstore.o
,我们可以查看mstore.o中的相关信息.具体内容如图所示.
通过对比反汇编得到的汇编代码与编译器直接产生的汇编代码,可以发现二者存在细微的差别.
反汇编代码省略来很多指令的后缀的“q”,但在call和ret指令添加后缀“q”.由于q只是表示大小的指示符,大多数情况下是可以省略的.
通过上面的描述,我们大概了解了C代码、汇编代码、机器代码之间的关系.这些介绍就先在这里.
寄存器与数据传送指令
在第一章计算机系统漫游时,提到过计算机系统中信息的存储部件.相对于内存和硬盘,大家对寄存器可能会陌生些.
接下来,我们详细介绍一下寄存器的相关知识
最早8086的处理器中包含八个16位的通用寄存器,具体如图所示.
每个寄存器都有特殊的功能,它们的名字就反映了不同的用途。当处理器从16位扩展到32位时,寄存器的位数也随之扩展到了32位.
直到今天64位的处理器中,原来8个16位寄存器已经扩展成了64位.
除此之外,还增加了八个新的寄存器,在一般的程序中不同的寄存器扮演着不同的角色,相应的编程规范规定了如何使用这些寄存器.
例如寄存器rax用来保存函数的返回值
寄存器rsp用来保存程序栈的结束位置
除此之外,还有6个寄存器可以用来传递函数参数。
在了解了这些寄存器的用法之后, 再去理解汇编代码就会容易多了.
接下来我们看一下指令的相关知识。
大多数指令包含两部分操作码和操作数
例如图中的这几条指令,movq、addq、subq这部分被定义为操作码.它决定了cpu所执行操作的类型.操作码之后的这部分是操作数.大多数指令具有一个或者多个操作数
不过像ret返回指令,是没有操作数的.
不同指令的操作数大致可以分为三类,分别为立即数、寄存器以及内存引用。
在AT&T格式的汇编中,立即数是以$符号开头的,后面跟一个整数.
不过这个整数需要满足标准C语言的定义.
操作数是寄存器的情况也比较容易理解,即使在64位的处理器上,不仅64位的寄存器可以作为操作数.
32位、16位甚至8位的寄存器都可以作为操作数。
需要注意的是,图中这种寄存器带了小括号的情况,它所表示的是内存引用,
接下来我们重点看一下内存引用的情况
我们通常将内存抽象成一个字节数组。当需要从内存中存取数据时,需要获得目的数据的,起始地址addr,以及数据长度b
我们使用图中的符号来表示内存引用。
为了简便,通常会省略下标b
最常用的内存引用包含四部分,分别是一个立即数、一个基址寄存器,一个变址寄存器和一个比例因子。
引用数组元素时,会使用到这种通用的形式.
有效地址是通过立即数与基址寄存器的值相加,再加上变址寄存器与比例因子的乘积,具体的计算方法如上图所示
关于比例因子s的取值必须是1、2、4或者8
看到这里,相信大家都能猜出比例因子的取值为什么是这四个数中的一个.
实际上比例因子的取值是与源代码中定义的数组的类型是相关的。编译器会根据数组的类型来确定比例因子的数值
例如定义char类型的数组,比例因子就是1,int类型,比例因子就是4,至于double类型比例因子就是8.
其他形式的内存引用都是这种普通形式的变种,省略了其中的某些部分
图中列出了内存引用的其他形式,需要特别注意的两种写法是不以$符号开头的立即数和了括号的寄存器。
在前面内容中,我们提到过mov类指令.它包含movb、movw、movl以及movq这四条指令,这些指令执行相同的操作,都是把数据从源位置复制到目的位置,主要区别在于它们操作的数据大小不同,具体如图所示
对于move类指令,含有两个操作数,一个称为源操作数,另外一个称为目的操作数
对于源操作数,可以是一个立即数、一个寄存器,或者是内存引用.
由于目的操作数是用来存放源操作数的内容
所以目的操作数要么是一个寄存器,要么是一个内存引用。
注意目的操作数不能是一个立即数。
除此之外,x86-64的处理器有一条限制
就是move指令的源操作数和目的操作数不能都是内存的地址
那么当需要将一个数从内存的一个位置复制到另外一个位置时
应该如何做呢?
此时需要两条move指令来完成.
第一条指令,将内存源位置的数据加载到寄存器
第二条指令,再将该寄存器的值写入内存的目的位置
接下来,我们看几个关于move指令的例子。
图中的指令给出了不同类型的源操作数和目的操作数的组合
第一个是源操作数,第二个是目的操作数
move指令的后缀与寄存器的大小一定得是匹配的
例如寄存器eax是32位与双字 l 对应
寄存器al是8位,与字节 b 对应
除此之外,move指令还有几个特殊的情况需要了解一下
当movq指令的源操作数是立即数时,该立即数只能是32位的补码表示,然后对该数值进行符号位扩展
将得到了64位数传送到目的位置。
这个限制会带来一个问题,当立即数是64位时应该如何处理?
这里引入了一个新的指令—movabsq
该指令的源操作数可以是任意的64位立即数
需要注意的是,目的操作数只能是寄存器。
接下来,我们通过一个例子来看一下使用move指令进行数据传送时
对目的寄存器的修改结果是怎样的?
首先使用movabsq指令将一个64位的立即数复制到寄存器rax
此时,寄存器rax内保存的数值如图所示接下来,使用movb指令将立即数-1复制到寄存器al
寄存器all的长度为8,与movb指令所操作的数据大小一致
此时,寄存器rax的低8位发生了变化,第三条指令movw是将立即数-1复制到寄存器ax
此时寄存器rax的低16位发生了改变
当指令movl将立即数-1复制到寄存器eax时
此时寄存器rax不仅仅是低32位发生了变化,而且高32位也发生了变化。
当movl的目的操作数是寄存器时,它会把该寄存器的高4字节设置为0。这是x86-64位处理器的一个规定
即任何对寄存器生成32位值的指令,都会把该寄存器的高位部分置为零
以上介绍的都是源操作数与目的操作数的大小一致的情况。
当源操作数的数位小于目的操作数时
我们需要对目的操作数剩余的字节进行零扩展或者符号位扩展
零扩展数据传送指令有5条,其中字符z是zero的缩写
指令最后的两个字符都是大小指示符
第一个字母表示源操作数的大小
第二个字母表示目的操作数的大小
符号为扩展传送指令有6条,其中字符s是sign的缩写
同样指令最后的两个字符也是大小指示符
对比零扩展和符号扩展,我们可以发现符号扩展比零扩展多一条4字节到8字节的扩展指令
为什么零扩展没有movzlq指令呢?
是因为这种情况的数据传送可以通过movl指令来实现
最后,符号位扩展还有一条没有操作数的特殊指令cltq
该指令的源操作数总是寄存器eax,目的操作数总是寄存器rax
cltq指针的效果与图中这条指针(movslq %eax,%rax)的效果是一致的,只不过编码更紧凑一些。
栈与数据传送指令
为了更好地理解寄存器与数据传送指令。
我们先从计算机系统的视角,看一下程序执行时数据传送的情况
在第一章,我们提到过 helloworld 程序加载运行的大致过程。
最初可执行文件是保存在硬盘上。通过shell程序,将可执行程序从硬盘加载的内存
此时,程序指令以及数据都保存在内存中。
CPU要执行程序,需要从内存中读取指令和数据
实际上,在一些程序的执行过程中,需要在CPU和内存之间进行频繁的数据存取
例如CPU执行一个简单的加法操作,那么首先CPU通过执行数据传送指令,将a和b的值从内存读到寄存器
寄存器就是CPU内一种数据存储的部件,只不过容量比较小
我们以x86-64位处理器为例,寄存器rax的大小是64个比特位,也就是8个字节
如果变量a是long类型,需要占用8个字节
因此,寄存器rax全部的数据位都用来保存变量a
如果变量a是int类型,那么只需要用4个字节来存储该变量
那么只需要用到寄存器的低32位就够了
如果变量a是short类型,则只需要用到寄存器的低16位
对于寄存器rax如果使用全部的64位,用符号%rax来表示
如果只用到低32位,可以用符号%eax来表示
对于低16位和低8位,分别用%ax和%al来表示
虽然用了不同的表示符号,但实际上只是针对同一寄存器的不同数位进行操作
处理器完成加法运算之后,再通过一条数据传送指令将计算结果保存到内存。
正是因为数据传送在计算机系统中是一个非常频繁的操作
所以了解一下数据传送指令对理解计算机系统会有很大的帮助
接下来,我们看一个数据传送的代码示例。
main函数中定义了变量a,并且赋值为4,随后调用了一个exchange函数。
该函数执行返回后,变量a的值会替换成3,变量b将保存变量a原来的值4
我们重点看一下函数exchange所对应的汇编指令
函数exchange由三条指令实现,包括两条数据传送指令和一条返回指令。
根据寄存器使用的惯例,寄存器rdi和rsi分别用来保存函数传递的第一个参数和第二个参数
因此寄存器rdi中保存了xp的值.寄存器rsi保存了变量y的值。
这段汇编代码并没有显式的将这部分表现出来,需要注意一下
第一条mov指令从内存中读取数值到寄存器
内存地址保存在寄存器rdi中.
目的操作数是寄存器rax,这条指令对应于图中的这行代码(x = xp)
由于最后exchange函数需要返回变量x的值,所以这里直接将变量x放到寄存器rax中
第二条mov指令将变量y的值写到内存里。变量y存储在寄存器rsi中。内存地址保存在寄存器rdi中,也就是xp指向的内存地址,这条指令对应于函数exchange中的这行代码(xp = y)
通过这个例子,我们可以看到C语言中所谓的指针,其实就是地址。
此外,还有两个数据传送指令需要借助程序栈
学过数据结构的同学都知道,栈是一种数据结构
通过push操作把数据压入栈,通过pop操作删除数据
栈具有一个特性,就是弹出的数据永远是最近被压入的且仍然在栈中。
这个程序栈本质上是内存的一个区域。
在第一栈计算机系统漫游中,我们曾经提到过程序的运行时内存分布
图中的这个区域就是程序栈(User Stack)
栈的增长方向是从高地址向低地址
因此,栈顶的元素是所有栈中元素地址中最低的。
根据惯例,栈是倒过来画的.栈顶在图中的底部,栈底在顶部
例如我们需要保存寄存器rax内存储的数据0x123,可以使用pushq指令把数据压入栈内。
该指令执行的过程可以分解为两步。
首先指向栈顶的寄存器rsp进行一个减法操作
例如压栈之前,栈顶指针rsp指向栈顶的位置,此处的内存地址是0x108
压栈的第一步就是寄存器rsp的值减8,此时指向的内存地址是0x100,然后将需要保存的数据复制到新的栈顶地址
此时,内存地址0x100处将保存寄存器rax内存储的数据0x123
实际上,pushq指令等效于图中的这两条指令(subq movq)
它们之间的区别在于pushq这一条指令只需要一个字节
而图中的这两条指令需要8个字节.说到底,push指令的本质还是将数据写入到内存里。
那么,与之对应的pop指令就是从内存中读取数据,并且修改栈顶指针。例如图中这条popq指令就是将栈顶保存的数据复制到寄存器rbx中
pop指令的操作也可以分解为两步:
首先从栈顶的位置读出数,据复制到寄存器rbx
此时栈顶指针rsp指向的内存地址是0x100,然后将栈顶指针加8,pop后栈顶指针rsp指向的内存地址是0x108,因此pop操作也可以等效成图中的这两条指令(movq addq)
实际上,pop指令是通过修改栈顶指针所指向的内存地址来实现数据删除的
此时,内存地址0x100内所保存的数据0x123仍然存在,直到下次push操作此处保留的数值才会被覆盖。
至此,数据传输指令的内容就介绍完了
算术和逻辑运算指令
我们来看一下有关算术和逻辑操作的指令。
首先,我们看一下指令leaq,它实现的功能是加载有效地址
q表示地址的长度是四个字。
由于x64-64位处理器上,地址长度都是64位,因此不存在leab、leaw这类有关大小的变种
例如图中的这条指令,它表示的含义是把有效地址复制到寄存器rax中
这个源操作数看上去与内存引用的格式类似
有效地址的计算方式与之前讲到的内存地址的计算方式是一致的
可以通过图中的公式计算得到.
假设寄存器rdx内保存的数值为x,那么有效地址的值为5x+7。
注意,对于leaq指令所执行的操作并不是去内存地址(5x+7)处读取数据
而是将有效值(5x+7)这个值直接写入到目的寄存器rax
因此,这个地方需要特别注意一下,除了加载有效地址的功能,leaq指令还可以用来表示加法和有限的乘法运算。
例如图中的这段C代码经过编译后这段代码是通过三条leaq指令来实现的,接下来我们看一下如何通过leaq指令实现算术运算
根据计算机的使用惯例参数x,y,z分别保存在寄存器rdi,rsi以及rdx中.
还是根据内存引用的计算公式,第一条指令的源操作数就对应于x+4*y,具体过程如图所示
指令leaq将该数值保存到目的寄存器rax中
接下来,关于z*12的乘法运算会有一些复杂,需要分成两步。
第一步首先计算3*z的数值.具体过程如图所示
第二条leaq指令执行完毕,此时寄存器rdx中保存的值是3*z
第二步再把(3*z)作为一个整体乘以4
通过这两步运算最终得到z*12
此时,相信大家会有疑惑,为什么不能使用图中的这条指令(最后一条)直接一步得到我们期望的结果
这里主要是由于比例因子的取值只能是1,2,4,8这四个数中的一个,因此需要将12进行分解。
接下来我们看一组一元操作指令这一组指令只有一个操作数,因此该操作数既是源操作数,也是目的操作数。
操作数可以是寄存器,也可以是内存地址
我们再来看一组二元操作指令这一组操作指令包含两个操作数
第一个操作数是源操作数,这个操作数可以是立即数、寄存器或者内存地址.
第二个操作数既是源操作数,也是目的操作数.这个操作数可以是寄存器或者是内存地址,但不能是立即数
接下来我们看一组例子
一开始,内存以及寄存器中所保存的数据如图所示
加法指令addq是将内存地址0x100内的数据与寄存器rcx相加,二者之和再存储到内存地址0x100处。
该指令执行完毕后,内存地址0x100处所存储的数据由0xFF变成0x100
具体过程如图所示
减法指令subq是将内存地址0x108内的数据减去寄存器rdx内的数据,二者之差存储到内存地址0x108处。
该指令执行完毕后,内存地址0x108处所存储的数据由0xAB变成0xA8
对于加一指令,就是将内存地址0x110内的存储数据加1
结果是内存地址0x100处所存储的数据由0x13变成了0x14,最后一条减法指令是将寄存器rax内的值减去寄存器rdx内的值
最终寄存器rax的值由0x100变成0xFD
更多指令的相关练习,大家可以做一做原书中的习题。
在之前的内容中,我们讲过移位操作.
图中的这一组指令是用来进行移位运算的。
左移指令有两个.,分别为SAL和SHL
二者的效果是一样的,都是在右边填零.
右移指令不同,分为算术右移和逻辑右移.算术右移需要填符号位,逻辑右移需要填零。
这与C语言中所讲述的移位操作是一致的
对于移位量k,可以是一个立即数,或者是存放在寄存器cl中的数
对于移位指令只允许特定的寄存器cl作为操作数,其他的寄存器不行,这里需要特别注意一下。
由于寄存器cl的长度为8,原则上移位量的编码范围可以达到$2^8-1(255)$
实际上,对于w位的操作数进行移位操作,移位量是由寄存器cl的低m位来决定
也就是说,对于指令salb.当前目的操作数是8位,移位量由寄存器cl的低8位来决定
对于指令salw,移位量则是由寄存器cl的低4位来决定
以此类推,双字对应的就是低5位,四字对应的就是低6位
接下来,我们通过一个例子来讲述一下移位指令的用途图中的这段代码,涉及了多种操作
我们重点看一下$z*48$这段代码所对应的汇编指令
这个计算过程被分解成了两步。
第一步,首先计算$3*z$,由指令leaq来实现.计算结果保存到了寄存器rax中
第二步,将寄存器rax进行左移4位,左移4位的操作是等效于乘以$2^4$,也就是乘以16
通过一条leaq指令和一条左移指令,来实现乘法操作
也许大家会有这样的疑惑,为什么编译器不直接使用乘法指令来实行这个运算的呢?
主要是因为乘法指令的执行需要更长的时间
因此,编译器在生成汇编指令时,会优先考虑更高效的方式。
此外,还有一些特殊的算术指令,这里就不一一展开讲述了感兴趣的同学可以去了解一下
对于汇编指令的学习,最关键的是了解指令相关的基本概念,并不需要去记住指令的细枝末节
学会查阅指令手册,能够找到需要的信息即可。
指令与条件码
在C语言中,有一类语句需要满足条件才能执行
例如条件语句,循环语句等,他们需要根据数据测试的结果来决定操作执行的顺序。
这部分内容,我们来看一下与控制流相关的指令。
在之前的内容中,我们提到过算术和逻辑运算指令
例如下图中的这条减法指令的执行需要用到算术逻辑单元。简称ALU
ALU从寄存器中读取数据后,执行相应的运算,然后将运算结果返回到目的寄存器rdx
为了方便理解,用这个简单的示意图来表示.ALU除了执行算术和逻辑运算指令外,还会根据该运算的结果去设置条件码寄存器,
接下来,我们详细介绍一下条件码寄存器的相关知识
条件寄存器,它是由CPU来维护的,长度是单个比特位,它描述了最近执行操作的属性
例如ALU执行两条连续的算术指令。$t_1$时刻执行指令1,$t_2$执行指令2
$t_1$时刻条件寄存器中保存的是指令1的执行结果的属性
$t_2$时刻,条件码寄存器中的内容将被下一条指令所覆盖。
接下来,我们介绍几个常用的条件码
CF—进位标志,当CPU最近执行的一条指令最高位产生了近进位。
进位标志(CF)会被置1,它可以用来检查无符号数操作的溢出
例如,无符号数a和b相加,当a=255,b=1时,由于最高位会发生进位操作
相加的结果发生溢出,此时进位标志(CF)被置1
ZF—零标志,当最近操作结果等于零时,零标志(ZF)会被置1
SF—符号标志,当最近的操作结果小于零时,符号标识(SF)会被置1
OF—溢出标志,针对有符号数.最近的操作导致正溢出或者负溢出时,溢出标志(OF)会被置1
以上我们提到了这几个标志位是比较常用的条件码寄存器.
条件码寄存器的值是由ALU在执行算术和逻辑运算指令时写入的
图中的这些算术和逻辑运算指令会改变条件码寄存器的内容,对于不同的指令也定义了相应的规则来设置条件码寄存器.
例如逻辑操作指令xor,进位标志(CF)和溢出标志(OF)会置0
对于加一指令和减一指令,会设置溢出标志(OF)和零标志(ZF),但不会改变进位标志
具体原因,我们就不做深入的探讨了。
除此之外,还有两类指令可以设置条件码寄存器.cmp指令和test指令
cmp指令是根据两个操作数的差来设置条件码寄存器,cmp指令和减法指令类似,也是根据两个操作数的差来设置条件码
二者不同的是,cmp指令只是设置条件码寄存器,并不会更新目的寄存器的值
test指令和and指令类似,同样test指令只是设置条件码寄存器,而不改变目的寄存器的值
讲完了条件码的设置,我们再来看一下条件码的使用
为了方便理解,我们通过一个例子来看一下。
图中C 代码的含义是比较a和b的大小
当a等于b时函数返回1,否则返回0.这段代码对应的汇编指令,如图所示
根据寄存器的使用惯例,参数a存放在寄存器rdi中,参数b存放在寄存器rsi中.指令cmp对应的操作如图所示(a-b)
根据(a-b)的结果设置条件码寄存器.当a和b的值相等时,指令cmp会将零标志位设置为1
接下来的这条指令sete看起来有点费解了,这是因为通常情况下,并不会直接去读条件码寄存器,其中一种方式是根据条件码的某种组合.通过set类指令,将一个字节设置为0或者1
在这个例子,指令sete根据零标志位(ZF)的值对寄存器al进行赋值,后缀是equal的缩写.
如果零标志等于1,指令sete将寄存器al设置为1
如果零标志等于0,指令sete将寄存器al设置为0
然后mov指令对寄存器al进行零扩展,最后返回判断结果
相等的情况比较简单,也容易理解。
接下来我们看一个稍微复杂的例子
例如图中示例的判断条件由a等于b变成了a小于b
经过编译器生成的汇编指令也发生了改变.通过对比之前相等的情况,我们可以发现指令sete变成了指令setl.指令setl的含义是如果a小于b,将寄存器al设置为1
其中后缀l是less的缩写,表示“在小于时设置”,而不是表示大小long word.这里特别注意一下
相对于相等的情况,判断小于的情况要稍微复杂一点,需要根据符号标识(SF)和溢出标志(OF)的异或结果来判定。
为了方便理解,我们还是通过简单的例子来解释一下.
两个有符号数相减,当没有发生溢出时.
如果a小于b,结果为负数,那么符号标识(SF)被置为1
如果a大于b,结果为正数,那么符号标识(SF)就不会被置为1
那么是不是根据符号标志(SF)就能给出判断结论了呢?
我们来看一个例子,
当 $a=-2,b=127$ 时.由于发生负溢出,结果t=120期大于零。此时符号标志(SF)不会被置1,但溢出标志(OF)会置1,因此仅仅通过符号标识无法判断a是否小于b
我们再来看一个例子,当 $a=1,b=-128$ 时,由于发生了正溢出,结果 $t=-127$ .虽然$a>b$,但是由于溢出导致了结果$t<0$.此时符号标识(SF)和溢出标志(OF)都会被置为1.
综上所述所有的情况,根据符号标识和溢出标志的异或结果,可以对a小于b是否为真做出判断.
对于其他的判断情况,我们可以通过条件码的组合来实现。
虽然看上去相对复杂一点,不过原理都是一致的。
对于无符号数的比较情况,需要注意一下.
指令cmp会设置进位标志,因而针对无符号数的比较,采用的是进位标志和零标识的组合,具体的条件码组合如图所示.
关于这些条件,把它组合并不需要去记住.了解条件语句的底层实现,这对我们深入理解整个计算机系统会有一定的帮助.
跳转指令与循环
首先,我们看一段C代码.
图中的这个函数是用来计算两数之差的绝对值。这段c代码对应的汇编指令如图所示.
根据前面内容所讲的知识,条件语句x小于y由指令cmp来实现
指令cmp会根据(x-y)的结果来设置符号标志(SF)和溢出标志(OF)
图中的跳转指令jl,根据符号标志(SF)和溢出标志(OF)的异或结果来判断究竟是顺序执行,还是跳转到L4处执行
当x大于y时,指令顺序执行,然后返回执行结果,L4处的指令不会被执行
当x小于y时,程序跳转到L4处执行,然后返回执行结果
跳转指令会根据条件寄存器的某种组合来决定是否进行跳转.与前文讲的set指令的设置条件是一样的,具体如图所示,这里就不再一一展开讲述了.
对于图中的if-else语句
当满足条件时,程序沿着一条路径执行
当不满足条件是就走另外一条路径。
这种机制比较简单和通用,但是在现代处理器上,它的执行效率可能会比较低。针对这种情况,有一种替代的策略,就是使用数据的条件转移来代替控制的条件转移。
还是针对这两个数的差的绝对值问题
我们给出了另外一种实现方式,具体如图所示。
我们既要计算y-x的值,也要计算x-y的值
分别用两个变量来记录结果,然后再判断x与y的大小。根据测试情况来判断是否更新返回值。
这两种写法看上去差别不大,为什么说右侧写法的执行效率就高呢?
我们来看一下右侧的代码所对应的汇编指令前面的几条指令都是普通的数据传送和减法操作,接下来我们重点看一下这条指令(cmovge)
指令cmove是根据条件码的某种组合来进行有条件的传送数据。
当满足规定的条件时,将寄存器rdx内的数据复制到寄存器rax内。
在这个例子中只有当$x\geq y$时,才会执行这条指令。
更多条件传送指令,如图所示
为什么基于条件传送的代码会比基于跳转指令的代码效率高,这里涉及到现代处理器是通过流水线来获得高性能。
当遇到跳转指令时,处理器会根据分支预测器来猜测每条跳转指令是否执行。当发生错误预测时,会浪费大量的时间,导致程序性能严重下降。
关于分支的更多内容,在第四章流水线的实现中会有更加详细的讲解。
C语言中提供了三种循环结构,即do-while、while以及for语句
汇编语言中没有定义专门的指令来实现循环结构。
循环语句是通过条件测试与跳转的组合来实现的。
接下来,我们分别用这三种循环结构来实现N的阶乘。
首先看一下do-while的实现方式,具体如图所示
我们可以发现指令cmp与跳转指令组合实现了循环操作。
当n大于1时,程序跳转到L2处执行循环,直到n的值减小到1,循环结束。对比do-while循环,while循环的实现方式如图所示
通过C代码的对比,我们可以发现这两种循环的差别在于N大于1这个循环测试的位置不同,do-while循环是先执行循环体的内容,然后再进行循环测试。while循环则是先进行循环测试,根据测试结果是否执行循环体的内容,
接下来,我们看一下For循环的基本形式,除了一种特殊情况外,这样的for循环与图中的while循环的功能是一致的。
下面,我们看一下for循环是如何实现n的阶乘的
图中的C代码采用了最自然的方式,从2一直乘到n。这与之前do-while循环以及while循环的实现方式有较大的差别。
根据刚才提到的模板,我们将这个for循环转换成while循环,具体代码如图所示。
对比for循环和while循环产生的汇编代码可以发现
除了这一句跳转指令不同,其他部分都是一致的
需要注意一下,这两个汇编代码是采用-Og选项产生的。
综上所述,三种形式的循环语句都是通过条件测试和跳转指令来实现
C语言还提供了switch语句,它可以根据一个整数的索引值进行多重的分支。
在针对一个测试有多种可能的结果时,switch语句特别有用。
switch语句通过跳转表这种数据结构,使得实现更加有效。
接下来我们看一下这段代码所对应的汇编指令。
指令cmp判断参数n与立即数6的大小,如果$n>6$程序跳转到default所对应的L8程序段
对于case 0~case 6的情况,可以通过跳转表来访问不同的分支。
C代码将跳转表声明为一个长度为7的数组,每个元素都是一个指向代码位置的指针,具体对应关系如图所示。
数组的长度为7,是因为需要覆盖case 0~case 6的情况,对于重复的情况case 4和case 4,使用了相同的标号
对于缺失的case 1和case 5的情况,使用默认情况的标号。
在这个例子中,程序使用跳转表来处理多重分支,甚至当switch有上百种情况时,虽然跳转表的长度会有增加,但是程序的执行只需要一次跳转也可以处理复杂的分支情况。
与使用一组很长的if-else相比,使用跳转表的优点是执行switch语句的时间与case的数量是无关的
因此,在处理多重分支时,与一组很长的if-else相比,switch语句的执行效率要高。
过程(函数调用)
在大型软件的构建中,需要对复杂的功能进行切分。
过程提供了一种封装代码的方式,它可以隐藏某种行为的具体实现,同时提供清晰简洁的接口定义。
在不同的编程语言中,过程的具体实现又是多种多样的
例如,C语言中的函数,Java语言中的方法等。
接下来,我们以C语言中的函数调用为例,介绍一下过程的机制。
为了方便讨论,假设函数P调用函数Q,函数Q执行完返回函数P
这一系列操作包括图中的一个或者多个机制。
接下来,我们将详细介绍一下过程的相关内容
在之前的内容中,我们提到过程序运行时的内存分布,其中栈为函数调用提供了后进先出的内存管理机制
在函数P调用函数Q的例子中,当函数Q正在执行时,函数P以及相关调用链上的函数都会被暂时挂起。
我们先来介绍一下栈帧的概念,当函数执行所需要的存储空间超出寄存器能过存放的大小时,就会借助栈上的存储空间
我们把这部分存储空间称为函数的栈帧,对于函数P调用函数Q的例子,包括较早的帧、调用函数P的帧,还有正在执行函数Q的帧,具体如图所示。
当函数P调用函数Q时,会把返回地址压入栈中,该地址指明了当函数Q执行结束返回时,要从函数P的哪个位置继续执行。
这个返回地址的压栈操作并不是由指令push来执行的,而是由函数调用指令call来实现的
我们以main函数调用multore函数为例,来解释一下指令call和指令ret的执行情况。
由于涉及地址的操作,我们需要查看这两个函数的反汇编代码,具体内容可以通过图中的命令得到。
我们节选了相关部分的反汇编代码,具体如图所示。
这一条call指令对于multstore函数的调用
指令call不仅仅是将函数multstore的第一条指令的地址写入到程序指令寄存器rip中,以此实现函数调用,同时还要将返回地址压入栈中,这个返回地址就是函数multistore调用执行完毕后,下一条指令的地址。
当函数multistore执行完毕,指令ret从栈中将返回地址弹出,写入到指令寄存器rip中,函数返回,继续执行main函数中的相关操作。
以上整个过程就是函数调用与返回所涉及的操作
说完了返回地址,我们再来看一下参数传递。
如果一个函数的参数数量大于6,超出的部分就要通过栈来传递。
假设参数P有n个整形参数,当n的值大于60时,参数7至参数n需要用到栈来传递。参数1至参16的传递可以使用相应的寄存器,具体如图所示。
接下来,我们看一个参数传递的示例。
图中的函数proc有8个参数,包括字节数、不同的整数以及不同类型的指针。
参数1至参数6是通过寄存器来传递的,参数7和参数8是通过栈来传递的
这里有两点,需要注意一下。第一通过栈来传递参数时,所有数据的大小都是向8的倍数对齐。虽然变量a4只占一个字节,但是仍然为其分配了8个字节的存储空间。由于返回地址占用了栈顶的位置。
所以这两个参数距离栈顶指针的距离分别为8和16
具体如图所示
另外还有一点需要注意,就是使用寄存器进行参数传递时,寄存器的使用是有特殊顺序规定的。
此外,寄存器名字的使用取决于参数传递的大小,如果第一个参数大小是4字节,需要用寄存器edi来保存。当代码中对一个局部变量使用地址运算时,此时,我们需要在栈上为这个局部变量开辟相应的存储空间。
接下来,我们看一个与地址运算符相关的例子。函数caller定义了两个局部变量arg1和arg2。
函数swap的功能是交换两个变量的值,最后返回二者之和,我们通过分析函数caller的汇编代码来看一下地址运算符的处理方式
第一条减法指令将栈顶指针减去16,它表示的含义是在栈上分配16个字节的空间,具体如图所示。
根据这两条mov指令可以推断出变量arg1和arg2存储在函数caller的栈帧上
接下来分别计算变量的arg1和arg2存储地址。
参数准备完毕。执行call指令调用swap函数,最后函数caller返回之前。通过栈顶指针加上16的操作来释放栈帧。
我们再来看一个稍微复杂的例子,
根据图中的C代码,我们来画一个这个函数的栈帧。
根据变量类型可知$x_1$占8个字节,$x_2$占4个字节,$x_3$占2个字,$x_4$占一个字节。
因此这四个变量在栈帧中的空间分配如图所示。
由于函数proc需要8个参数,因此参数7和参数8需要通过栈帧来传递。
注意,传递的参数需要8字节对齐,而局部变量是不需要对齐的。
从上面的例子我们可以看到,当函数运行需要局部存储空间时,栈提供了内存的分配与回收机制。
在程序执行的过程中,寄存器是被所有函数共享的一种资源。为了避免寄存器使用的过程中出现数据覆盖的问题,处理器规定了寄存器的使用惯例,所有的函数调用都必须遵守这个惯例,对于16个通用寄存器。除了寄存器rsp之外,其他15个寄存器被定义为调用者保存和被调用者保存,具体如图所示
接下来我们看一个栈保存寄存器数值的例子。
由于调用函数Q需要使用寄存器rdi来传递参数,因此函数P需要保存寄存器rdi中的参数x
保存参数x使用了寄存器rbq,根据寄存器使用规则寄存器rbq被定义为被调用者保存寄存器
所以便有了开头的这条指令不是pushq %rbp
至于pushq %rbx也是类似道理
在函数P返回之前使用pop指令恢复寄存器rbp和rbx的值
由于栈的规则是后进先出,所以弹出的顺序与压入的顺序相反
最后,我们再来看一个递归调用的例子。图中的这段代码是关于N的阶乘的递归实现。
我们假设n=3时,看一下汇编代码的执行情况,
由于使用寄存器rbx来保存n的值。
根据寄存器的使用惯例,首先保存寄存器rbx的值,由于n=3,所以调整指令jle不会跳转到L35处执行。
指令leaq是用来计算n-1,然后再次调用该函数。
注意,此时寄存器rbx内保存的值是3,指令pushq执行完毕后,栈的状态如图所示,继续执行,直到n=1时,程序跳转到L35处执行pop操作。
通过这个例子,我们可以看到递归调用一个函数的本身与调用其他函数是一样的,
每次函数调用都有它自己私有的状态信息,栈分配与释放的规则与函数调用返回的顺序也是匹配的。
不过当n的值非常大时,并不建议使用递归调用。至于原因应该是一目了然了。