CS:APP 深入理解计算机系统读书笔记
(๑°⌓︎°๑) 数据类型长度寄存器第三章练习题1第三章练习题2第三章读书笔记大端序小端序
第一章
- 源程序实际上就是一个由值0和1组成的位(又称为比特序列)8个位被组织成一组,称为字节。1 字节(byte)=8位(bit)
- ASCII使用一个唯一的单字节大小的整数值来表示每个字符。每个字节都有一个整数值。每个文本行都是以一个看不见的换行符 “\n” 来结束的 ,他所对应的整数值是10。
1.1 C语言程序编译过程
C源程序 —->可执行的目标文件
执行这四个阶段的程序(预处理器、编译器、汇编器和链接器)一起构成了编译系统(compilation systen)。
一、预处理阶段(将库文件引入程序:
.c-->.i
)第 1 行的
#include <stdio.h>
命令告诉预处理器读取系统头文件stdio.h
的内容,并把它直接插入程序文本中。结果就得到了另一个 C 程序,通常是以.i
作为文件扩展名。二、编译阶段(将源程序编译成汇编语言:
.i-->.s
)三、汇编阶段(将汇编语言汇编为机器语言(二进制):
.s-->.o
)四、链接阶段
请注意,hello程序调用了 printf 函数,它是每个 C 编译器都提供的标准 C 库中的一个函数。printf 函数存在于一个名为 printf.o 的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的 hello.o 程序中。链接器(ld)就负责处理这种合并。结果就得到 hello文件,它是一个可执行目标文件(或者简称为可执行文件),可以被加载到内存中,由系统执行。
1.2 系统的硬件组成
总线
贯穿整个系统的是一组电子管道,称作总线,它携带信息字节并负责在各个部件间传递。通常总线被设计成传送定长的字节块,也就是字(word)。字中的字节数(即字长)是个基本的系统参数,各个系统中都不尽相同。现在的大多数机器字长要么是 4 个字节(32 位),要么是 8 个字节(64 位)。
I/O设备
控制器和适配器区别主要在于它们的封装方式。控制器是I/O设备本身或者系统的主印制电路板(通常称作主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。无论如何,它们的功能都是在I/O总线和I/O设备之间传递信息。
主存
一般来说,组成程序的每条机器指令都由不同数量的字节构成。与 C 程序变量相对应的数据项的大小是根据类型变化的。比如,在运行 Linux 的 x86-64 机器上,short 类型的数据需要 2 个字节,int 和 float 类型需要 4 个字节,而 long 和 double 类型需要 8 个字节。
处理器(CPU)
是解释(或执行)存储在主存中指令的引擎。CPU的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数器(PC)。PC指向主存中下一条即将执行的指令的地址。
1WORD = 2byte = 16bit
CPU处理过程:
- 加载:从主存复制一个字节或者一个字到寄存器,以覆盖寄存器原来的内容。
- 存储:从寄存器复制一个字节或者一个字到主存的某个位置,以覆盖这个位置上原来的内容
- 操作:把两个寄存器的内容复制到 ALU, ALU 对这两个字做算术运算,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容。
- 跳转:从指令本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖 PC 中原来的值。
编译好的指令代码(机器指令)存放于磁盘,程序加载时,他们被反复复制到主存;当处理器运行时,指令又从主存复制到处理器。
存储器层次结构
存储器层次结构的主要思想是上层的存储器作为低一层存储器的高速缓存。
1.3 OS管理硬件
操作系统是介于硬件和应用程序之间的软件,操作系统有两个基本功能:
- (1) 防止硬件被失控的应用程序滥用;
- (2) 向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。操作系统通过几个基本的抽象概念(进程、虚拟内存、文件)来实现这两个功能。
- 文件是对I/O设备的抽象表示;
- 虚拟内存是对主存和磁盘I/O设备的抽象表示;
- 进程则是对处理器、主存和I/O设备的抽象表示(对一个正在运行程序的抽象)。
- 虚拟机,它提供对整个计算机的抽象,包括操作系统、处理器和程序。
内核是操作系统代码常驻主存的部分。注意,内核不是一个独立的进程。相反,它是系统管理全部进程所用代码和数据结构的集合。
三种不同类型的并发:并行可以在计算机系统的多个抽象层次上运用。在此,我们按照系统层次结构中由高到低的顺序重点强调三个层次。
- 线程级并发:使用线程,我们甚至能够在一个进程中执行多个控制流。多核处理器(CPU)和超线程出现。
- 超线程,有时称为同时多线程(simultaneous multi-threading),是一项允许一个 CPU 执行多个控制流的技术。它涉及 CPU 某些硬件有多个备份,比如程序计数器和寄存器文件,而其他的硬件部分只有一份,比如执行浮点算术运算的单元。常规的处理器需要大约 20000 个时钟周期做不同线程间的转换,而超线程的处理器可以在单个周期的基础上决定要执行哪一个线程。这使得 CPU 能够更好地利用它的处理资源。比如,假设一个线程必须等到某些数据被装载到高速缓存中,那 CPU 就可以继续去执行另一个线程。举例来说, Intel Core i7 处理器可以让每个核执行两个线程,所以一个 4 核的系统实际上可以并行地执行 8 个线程。
- 指令级并行:在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。如果处理器可以达到比一个周期一条指令更快的执行速率,就称之为超标量(super scalar)处理器。大多数现代处理器都支持超标量操作(如最近的处理器可以保持每个时钟周期2~4条指令的执行速率)。
- 单指令、多数据并行:在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,这种方式称为单指令、多数据,即SIMD并行。例如,较新几代的Intel 和AMD处理器都具有并行地对8对单精度浮点数(C数据类型float)做加法的指令。
1.4 计算机系统的层次
所有应用程序对硬件的操作尝试都必须通过操作系统。
操作系统有两个基本功能:(1)防止硬件被失控的应用程序滥用;(2)向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。操作系统通过几个基本的抽象概念(进程、虚拟内存、文件)来实现这两个功能。
在处理器里,指令集架构提供了对实际处理器硬件的抽象。使用这个抽象,机器代码程序表现得就好像运行在一个一次只执行一条指令的处理器上。
第二章
2.1 数据存储
大多数计算机使用8位的块,或者字节(byte),作为最小的可寻址的内存单位,而不是访问内存中单独的位。机器级程序将内存视为一个非常大的字节数组,称为虚拟内存 ( virtual memory)。内存的每个字节都由一个唯一的数字来标识,称为它的地址(address),所有可能地址的集合就称为虚拟地址空间( virtual address space)。
大多数计算机使用8位的块,或者字节(byte),作为最小的可寻址的内存单位,而不是访问内存中单独的位。机器级程序将内存视为一个非常大的字节数组,称为虚拟内存 (virtual memory)。内存的每个字节都由一个唯一的数字来标识,称为它的地址(address),所有可能地址的集合就称为虚拟地址空间(virtual address space)。
某些机器选择在内存中按照从最低有效字节到最高有效字节的顺序存储对象,而另一些机器则按照从最高有效字节到最低有效 字节的顺序存储。前一种规则一最低有效字节在最前面的方式,称为小端法(little endian)。 后一种规则 最高有效字节在最前面的方式,称为大端法(big endian)。即小端序和大端序。
假设变量x的类型为int,位于地址0x100 处,它的十六进制值为0x01234567(67为低字节,01为高字节)。 地 址范围0x100~0x103的字节顺序依赖于机器的类型:
注意,在字0x01234567中,高位字节的十六进制值为0x01,而低位字节值为0x67。小端序(Little-Endian):高字节存于高地址,低字节存于低地址
Android(来自Google)和IOS(来自Apple)只能运行于小端模式。
2.2 C语言扩展
C语言中的typedef
声明提供了一种给数据类型命名的方式。
1 | typedef int *int_pointer; int_ pointer ip; 等价于 |
printf
格式化输出在格式串里,每个以
%
开始的字符序列都表示如何格式化下一个参数。典型的示例包括:%d
是输出一个有符号十进制整数,%u
是输出一个无符号十进制整数,%x
是输出一个十六进制数字,%f
是输出一个浮点 数,而%c
是输出一个字符,其编码由参数给出。指针的创建和间接引用
C的 “取地址”运算符
&
创建一个指针。表达式&x
创建了一个指向保存变量x的位置的指针。这个指针的类型取决于x的类型,因此这三个指针的类型分别为int*
、float*
和void **
。(数据类型void *
是一种特殊类型的指针,没有相关联的类型信息。)这里给出的这些强制类型转换不会改变真实的指针,它们只是告诉编译器以新的数据类型来看待被指向的数据。
字符串表示
C语言中字符串被编码为一个以null(其值为0)字符结尾的字符数组。
移位运算
- 左移:
x<<k
表示x向左移动k位,丟弃最高的k位,并在右端补k个0。如x=[01100011],x<<4
为[00110000]。 - 逻辑右移:
x>>k
表示x向右移动k位并在左端补k个0。如x=[01100011],x>>4
(逻辑右移)为[00000110]。 - 算术右移:
x>>k
表示在左端补k个最高有效位的值,如x=[10010101],x>>4
(算术右移)为[11111001]。
- 左移:
实际上,几乎所有的编译器/机器组合都对有符号数使用算术右移,另一方面,对于无符号数,右移必须是逻辑的。与C相比,Java对于如何进行右移有明确的定义。表达是x>>k
会将x算术右移k个位置,而x>>>k
会对X做逻辑右移。
注意:当x<<k
移动k位时,k>w时(这里w是x的位数),因此实际上位移量就是通过计算(k mod w)得到的。
假设数据类型int
为w=32:
int lval = 0xFEDCBA98 << 32;
int aval = 0xFEDCBA98 >> 36;
unsigned uval = 0xFEDCBA98u >> 40 ;
当w=32时,上面三个移位运算分别是移动0、4和8位,得到结果:
lval 0xFEDCBA98
aval 0xFFEDCBA9
uval 0x00FEDCBA
不过这种行为对于C程序来说是没有保证的,所以应该保持位移量小于待移位值的位数。另一方面,Java特别要求位移数量应该按照我们求模的方法来计算。
C、C++t和Java中的有符号和无符号数
C和C++都支持有符号(默认)和无符号数。Java只支持有符号数。
当在int、float 和double格式之间进行强制类型转换时,程序改变数值和位模式 的原则如下(假设int是32位的):
- 从int转换成float,数字不会溢出,但是可能被舍入。
- 从int或float转换成double,因为double有更大的范围(也就是可表示值的范 围),也有更高的精度(也就是有效位数),所以能够保留精确的数值。
- 从double转换成float, 因为范围要小一些,所以值可能溢出成十∞或一∞。另 外,由于精确度较小,它还可能被舍人。
- 从float或者double转换成int,值将会向零舍人。例如,1.999将被转换成1, 而一1.999将被转换成一1。进一步来说,值可能会溢出。C语言标准没有对这种情 况指定固定的结果。与Intel兼容的微处理器指定位模式[10…00](字长为w时的 TMinw )为整数不确定(integer indefinite)值。一个从浮点数到整数的转换,如果不 能为该浮点数找到一个合理的整数近似值,就会产生这样一个值。因此,表达式 (int)+1e10会得到-21483648,即从一个正值变成了一个负值。
2.3 数据类型长度
图2-9和图2-10中一个很值得注意的特点是取值范围不是对称的一负数的范围比整数的范围大 1。
第三章
3.1 AT&T与Intel汇编语言的比较
Linux是Unix家族的一员,尽管Linux的历史不长,但与其相关的很多事情都发源于Unix。就Linux所使用的386汇编语言而言,它也是起源于Unix。Unix最初是为PDP-11开发的,曾先后被移植到VAX及68000系列的处理器上,这些处理器上的汇编语言都采用的是AT&T的指令格式。当Unix被移植到i386时,自然也就采用了AT&T的汇编语言格式,而不是Intel的格式。尽管这两种汇编语言在语法上有一定的差异,但所基于的硬件知识是相同的,因此,如果非常熟悉Intel的语法格式,那么也可以很容易地把它“移植“到AT&T来。下面通过对照Intel与AT&T的语法格式,以便于把过去的知识能很快地“移植”过来。
前缀
在Intel的语法中,寄存器和和立即数都没有前缀。但是在AT&T中,寄存器前冠以%
,而立即数前冠以$
。在Intel的语法中,十六进制和二进制立即数后缀分别冠以h
和b
,而在AT&T中,十六进制立即数前冠以0x
。
3.2 在C语言程序中嵌入汇编代码
使用asm伪代码可以在C程序中包含简短的汇编代码,如下。
1 |
|
3.3 数据格式
由于是从16位体系结构扩展成32位的,Intel 用术语”字(word
)“表示16位数据类型。因此,称32位数为 “双字(double words
)”,称64位数为“四字(quad words
)”。
C语言基本数据类型对应的x86-64 表示时(x86-64 指令集同样包括完整的针对字节、字 和双字的指令。):
- 标准int值存储为双字(32位)。
- 指针(在此用
char *
表示)存储为8字节的四字,64位机器本来就预期如此。 - 数据类型long实现为64位,允许表示的值范围较大。
如图所示,大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如,数据传送指令有四个变种:
movb
--> 传送字节movw
--> 传送字movl
--> 传送双字movq
-->传送四字
后缀l
用来表示双字,因为32位数被看成是“长字( long word
)”。注意,汇编代码也使用后缀l
来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
3.4 访问信息(寄存器使用)
一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。图3-2显示了这16个寄存器。它们的名字都以%r
开头,不过后面还跟着一些不同的命名规则的名字,这是由于指令集历史演化造成的。最初的8086中有8个16位的寄存器,即图3-2中的%ax
到%bp
。每个寄存器都有特殊的用途,它们的名字就反映了这些不同的用途。扩展到IA32架构时,这些寄存器也扩展成32位寄存器,标号从%eax
到%ebp
扩展到x86-64后,原来的8个寄存器扩展成64位,标号从号%rax
到%rbp
。除此之外,还增加了8个新的寄存器,它们的标号是按照新的命名规则制定的:从号%r8
到%r15
。
- 64位 -->
%r
- 32位 -->
%e
- 16位 -->
%
当指令以寄存器作为目标时,对于生成小于8字节结果的指令,寄存器中剩下的字节会怎么样,对此有两条规则:
- 生成1字节和2字节数字的指令会保持剩下的字节不变
- 生成4字节数字的指令会把高位4个字节置为0。
后面这条规则是作为从IA32到x86-64的扩展的一部分而采用的。
3.4.1 操作数指示符
大多数指令有一个或者是多个操作数,源数据值可以是常数、寄存器或是内存中读出;结果可以存放在寄存器或者内存中。操作数分为三种类型:
- 立即数:表是常数值,在ATT格式的汇编代码中,立即数的书写方式是
$
后面跟一个用标准C表示法表示的整数,比如,$-577
或$0x1F
。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。 - 寄存器(register),它表示某个寄存器的内容,用符号$r_a$来表示任意寄存器a,用引用$R[r_a]$来表示它的值,这是将寄存器集合看成一个数组R,用寄存器标识符作为索引。
- 内存引用:它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。因为将内存看成一个很大的字节数组,我们用符号$M_b[Addr]$表示对存储在内存中从地址Addr开始的b个字节值的引用。为了简便,通常省去下标b。内存引用的不同寻址方式:
- $M_b[Addr]$表示对存储在内存中从地址Addr开始的b个字节值的引用。
- $Imm(r_b,r_i,s)$这样的引用有四个组成部分:
- 立即数偏移 $Imm$
- 基址寄存器$r_b$
- 变址寄存器$r_i$
- 比例因子$s$
这里$s$必须是1、2、4或者 8。基址和变址寄存器都必须是64位寄存器。有效地址被计算为$Imm+R[r_b]+R[r_i]·s$。引用数组元素时,会用到这种通用形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。
3.4.2 数据传送指令
$MOV$作用:最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。这些指令把数据从源位置复制到目的位置,不做任何变化。
- 源操作数指定的值是一个立即数,存储在寄存器中或者内存中。
- 目的操作数指定一个位置,要么是一个寄存器,要么是一个内存地址。
- x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置,将一个值从一个内存位置复制到另一个内存位置需要两条指令:
- 第一条指令将源值加载到寄存器中
- 第二条将该寄存器值写人目的位置
这些指令的寄存器操作数可以是16个寄存器有标号部分中的任意一个,寄存器部分的大小必须与指令最后一个字符($b,w,1或q$)指定的大小匹配。大多数情况中,MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外是movl指令以寄存器作为目的时,它会把该寄存器的高位4字节设置为0。造成这个例外的原因是x86-64采用的惯例,即任何为寄存器生成32位值的指令都会把该寄存器的高位部分置成0。
举个MOV数据传送的例子movl
指令以寄存器作为目的时,它会把该寄存器的高位4字节设置为0。造成这个例外的原因是x86-64采用的惯例,即任何为寄存器生成32位值的指令都会把该寄存器的高位部分置成0。
这两类指令将较小的源值复制到较大的目的时使用,这两类指令都把源(在寄存器或内存中)复制到目的寄存器。上面的movl
指令以寄存器为目的时,会将目的寄存器的高32位都置0。
- MOVZ将目的寄存器的剩余的字节填充为0用于无符号数的扩展(习题3.4)
- MOVS通过符号扩展来填充,将目的寄存器的剩余的字节填充为源寄存器的最高位用于有符号数的扩展
下图可以观察到,每条指令名字的最后两个字符都是大小指示符:
- 第一个字符指定源的大小
- 第二个指明目的的大小
注意:
- 函数的参数通过寄存器传递给函数。
- 函数通过把值存储在寄存器
%rax
或该寄存器某个低位部分中返回。 - 间接引用指针就是将该指针放在一个寄存器中,则取得的操作数就是以寄存器的值为地址,对应取出的操作数。
- 局部变量通常是保存在寄存器中,而不是内存中。
3.4.3 将数据压栈和出栈
栈是一种数据结构,可以添加或者删除值,不过要遵循后进先出的原则。
- 通过push操作把数据压人栈中,通过pop操作删除数据
- 弹出的值永远是最近被压人而且仍然在栈中的值。
- 栈可以实现为一个数组,总是从数组的一端插人和删除元素。这一端被称为栈顶。
- 在x86-64中,程序栈存放在内存中某个区域。
- 栈向下增长,这样一来,栈顶元素的地址是所有栈中元素地址中最低的
- 栈指针%rsp保存着栈顶元素的地址
push
和pop
指令都会自动修改%rsp
的值,有两种方式调整%rsp
的值,一种是直接进行加减运算,另一种是push
和pop
指令
将一个四字值压人栈中,首先要将%rsp栈指针减8,然后将值写到新的栈顶地址
pushq %rbp指令等价于如下指令:1 | subq $8, %rsp //Decrement stack pointer |
它们之间的区别是在机器代码中pushq
指令编码为1个字节,而上面那两条指令一共需要 8个字节。
1 | movq (%rsp) , %rax //Read %rax from stack |
弹出一个四字的操作包括从栈顶位置读出数据,然后将栈指针加8。但是值0x123仍然会保持在内存位置0x100中,直到被覆盖(例如被另一条入栈操作覆盖)。无论如何,%rsp指向的地址总是栈顶。
3.5 算术和逻辑操作
整数算术操作可分为以下四组:
- 加载有效地址
- 一元操作(一个操作数)
- 二元操作(两个操作数)
- 移位
事实上,给出的每个指令类都有对这四种不同大小数据的指令(只有leaq没有其他大小的变种 )。
3.5.1 加载有效地址
加载有效地址(load effective address)指令leaq实际上是movq指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读人数据,而是将有效地址写人到目的操作数。另外,它还可以简洁地描述普通的算术操作。
leaq
既能进行取地址运算又能进行算术运算:
- 假设寄存器
%rdx
的值是一个地址,寄存器%eax
的值是一个整数数值,进行下列指令leaq 5(%rdx) %rax
:%rax
的值是一个地址,此处并没有进行内存引用去取值leaq 5(%eax) %rax
:%rax
的值是一个整数数值
例如,如果寄存器%rdx
的值为x
,那么指令leaq 7(%rdx,%rdx,4) %rax
将设置寄存器%rax
的值为5x + 7
。编译器经常发现leaq
的些灵活用法,根本就与有效地址计算无关,目的操作数必须 是一个寄存器。
3.5.2 一元操作和二元操作
- 一元操作,只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是一个内存位置。比如说,指令
incq(%rsp)
会使栈顶的8字节元素加1。 - 二元操作,第二个操作数既是源又是目的。
- 第一个操作数可以是立即数、寄存器或是内存位置。
- 第二个操作数可以是寄存器或是内存位置。(注意, 当第二个操作数为内存地址时,处理器必须从内存读出值,执行操作,再把结果写回内存。)
3.5.3 移位操作
- 移位操作,先给出移位量,然后第二项给出的是要移位的数。可以进行算术和逻辑右移。
- 移位量可以是一个立即数,或者放在单字节寄存器%cl中。(这些指令很特别,因为只允许以这个特定的寄存器作为操作数)
%cl
的值为一个字节,1个字节的移位量使得移位量的编码范围可以达到 $2^8-1=255$ 位。- x86-64中,对 $w$ 位长的数据值进行移位操作时,移位量是由寄存器
%cl
的低 $m$ 位决定的,这里$2^m=w$。高位会被忽略。即:对于 $w$ 位的数据进行移位, 并不是直接使用%cl
的值, 而是使用%cl
的低 $m$ 位的值,2的 $m$ 次幂等于当前操作位数的大小,大于 $m$ 的位会被忽略。 - 当寄存器
%cl
的十六进制值为 0xFF 时:
命令 | 等式 | 有效位 | 移位量 |
---|---|---|---|
salb | $2^3=8$ | 11111111 | 111=7 |
salw | $2^4=16$ | 1111 1111 | 1111=15 |
sall | $2^5=32$ | 11111111 | 11111=31 |
salq | $2^6=64$ | 11111111 | 111111=63 |
- 左移指令有两个名字:
SAL
和SHL
。两者的效果是一样的,都是将右边填上0。右移指令不同,SAR
执行算术移位(填上符号位),而SHR
执行逻辑移位(填上0)。移位操作的目的操作数可以是一个寄存器或是一个内存位置。 - 图3-10所示的大多数指令,既可以用于无符号运算,也可以用于补码运算。只有右移操作要求区分有符号和无符号数。这个特性使得补码运算成为实现有符号整数运算的一种比较好的方法的原因之一。
3.5.4 乘法和除法
注意,一个a位的操作数和一个b位的操作数,产生的乘积是a+b位的操作数
两个 64 位有符号或无符号整数相乘得到的乘积需要 128 位来表示。x86-64 指令集对 128 位(16字节)数的操作提供有限的支持。 故使用寄存器%rdx和%rax来组成一个128位的八字来保存乘法产生的128位积或者除法的被除数的高64位和低64位或者除法产生的商和余数 图 3-12 描述的是支持产生两个 64 位数字的全 128 位乘积以及整数除法的指令。
imulq
属于IMUL
指令类,该指令有两种不同的形式。其中一种,如图3-10 所示,是双操作数乘法指令。它从两个 64 位操作数产生一个 128 位乘积。而另一种属于单操作数乘法指令的有符号乘法(补码乘法)。x86-64
提供了两条单操作数乘法:
- imulq:补码乘法
- mulq:无符号乘法
这两条指令都要求一个参数必须在寄存器 %rax 中,而另一个作为指令的源操作数给出。
乘法中:乘积存放在寄存器%rdx(高64位)和 %rax(低64位)中。虽然imulq
这个名字可以用于两个不同的乘法操作,但是汇编器能够通过计算操作数的数目,分辨出想用哪条指令。
除法中:
- 有符号除法指令
idivl
将寄存器%rdx(高64位))和%rax(低64位)中的128 位数作为被除数,而除数作为指令的操作数给出。 - 指令将商存储在寄存器%rax中,将余数存储在寄存器%rdx中。
- 当一个64 位的数作为被除数时,需要将其扩展为 128 位,由于寄存器
%rdx
存放高64位,则需要分为有符号除法还是无符号除法。- 无符号运算:
%rdx
的位应该设置为全0 - 有符号运算:
%rdx
的位应该设置为%rax
的符号位,使用cqto
指令来完成这一操作,这条指令不需要操作数-->它隐含读出%rax
的符号位, 并将它复制到%rdx
的所有位。
- 无符号运算:
3.6 控制
机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。
3.6.1 条件码(标志寄存器)
除了整数寄存器,CPU还维护着一组单个位(一位,值为0或1)的条件码(condition code) 寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。
最常用 的条件码有:
- CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
- ZF:零标志。最近的操作得出的结果为0。
- SF:负数符号标志。最近的操作得到的结果为负数。
- OF:溢出标志。最近的操作导致一个补码溢出-->正溢出或负溢出。
正溢出与负溢出:
首先,一个正数与一个负数相加,不可能溢出,因为结果的绝对值一定小于两个加数的绝对值,既然两个加数能合理表示出来,结果一定也能合理表示出来。
- 正溢出是由于两个很大的正数相加,导致符号位变成1的情况。如 $0110+0011=1001$(假设最大只能运算4位)
- 负溢出则是两个很小的负数相加,导致符号位变成0的情况。如 1011(-5)+1011(-5)=10110->0110 (溢出);如 $1111(-1)+1111(-1)=11110->1110$ 则没溢出。
因此:
- 正溢出的判断标准是符号位或最高位有进位
- 负溢出的判断标准是符号位和最高位只有一个发生了进位。符号位和最高位同时发生进位则没溢出。(注意,这里的最高位指的是去掉符号位后的最高位,即符号位后面一位。)
leaq
指令不改变任何条件码,因为它是用来进行地址计算的。除此之外,图3-10中列出的所有指令都会设置条件码
对于逻辑操作:
XOR
,进位标志和溢出标志会设置成 0- 移位操作,进位标志将设置为最后一个被移出的位,而溢出标志设置为 0
INC
和DEC
指令会设置溢出和零标志,但是不会改变进位标志
CMP
和TEST
类指令只设条件码而不改变任何其他寄存器。
CMP
和SUB
指令的行为是一样的,但是CMP
指令只设条件码而不更新目的寄存器。如果两个操作数相等,这些指令会将零标志设置为1 ,而其他标志可以用来确定两个操作数的大小关系。TEST
和AND
指令的行为是一样的,但是TEST
指令只设条件码而不更新目的寄存器。典型的用法是,两个操作数是一样的(例如,testq %rax,%rax
用来检查%rax
是负数、零还是正数),或其中一个操作数是掩码,用来指示哪些位应该被测试。CMP
指令会设置进位标志,因为无符号比较使用的是进位标志(CF)和零标志(ZF)的组合。
指令 | 基于 | 描述 |
---|---|---|
CMP $S_1 , S_2$ | $S_2 - S_1$ | 比较 |
cmpb | 比较字节 | |
cmpw | 比较字 | |
cmpl | 比较双字 | |
cmpq | 比较四字 | |
TEST $S_1 , S_2$ | $S_1 $ & $S_2$ | 测试 |
testb | 测试字节 | |
testw | 测试字 | |
testl | 测试双字 | |
testq | 测试四字 |
3.6.2 访问条件码
条件码通常不会直接读取,常用的使用方法有三种:
- 可以根据条件码的某种组合, 将一个字节设置为0或者1
- 可以条件跳转到程序的某个其他的部分
- 可以有条件地传送数据
- 对于第一种情况,图 3-14 中描述的指令根据条件码的某种组合,将一个字节设置为 0 或者 1。我们将这一整类指令称为SET指令;
- 它们之间的区别就在于它们考虑的条件码的组合是什么,这些指令名字的不同后缀指明了它们所考虑的条件码的组合。
- 这些指令的后缀表示不同的条件而不是操作数大小,了解这一点很重要。例如,指令
setl
和setb
表示“小于时设置(set less)” 和“低于时设置(set below)”,而不是“设置长字(set long word)”和“设置字节(set byte)”。
一条SET
指令的目的操作数是低位单字节寄存器元素(图3-2)之一,或是一个字节的内存位置,指令会将这个字节设置成 0 或者 1。为了得到一个 32 位或 64 位结果,我们必须对高位清零。
CMP
指令会设置进位标志,因为无符号比较使用的是进位标志(CF)和零标志(ZF)的组合。
3.6.3 跳转指令
跳转指令(jump
)会使正在执行的指令切换到程序中一个全新的位置。跳转的目的地通常用一个标号(label)指明。
在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标(目的指令的地址)编码为跳转指令的一部分。
- 直接跳转:跳转目标是作为指令的一部分编码的,直接跳转是给出一个标号作为跳转目标的
- 间接跳转:跳转目标是从寄存器或内存位置中读出。间接跳转的写法是
jmp *Operand
,*
后跟一个操作数指示符
JCC:有条件跳转
JMP:无条件跳转
jmp .L1
:跳转到L1
标号jmp *%rax
:用寄存器%rax
中的值作为跳转目标jmp *(%rax)
:以%rax
中的值做为读地址,从内存中读出跳转目标
3.6.4 跳转指令的编码
这里的编码指的是机器码,研究跳转指令的汇编代码如何编译为机器代码,然后与地址联系起来。
jmp .L1
:.L1
是跳转目标,它的机器编码称为目标编码。这整条指令的编码 = jmp
的编码(eb)+.L1
的编码
跳转指令的编码分为:
- PC相对的(PC-relative)。将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码【跳转指令的目标编码(偏移量) = 目标指令的地址(要跳到的地址) $-$ 跳转指令后面那条指令的地址(程序计数器的值)】。这些地址偏移量可以编码为 1、2、4 个字节。程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。
- 绝对地址,用 4 个字节直接指定目标,汇编器和连接器会选择适当的跳转目的编码。
PC相对编码例子:
1 | (汇编代码) |
1 | (机器代码的反汇编版本) |
1 | (链接后的程序反汇编版本) |
对于机器代码的反汇编版本:
反汇编器以十六进制格式给出所有的数字
jmp
指令后的大小数字、机器代码或地址全都是以补码的形式给出右边反汇编器产生的注释中,第2行中跳转指令的跳转目标指明为
0x8
,第5行中跳转指令的跳转目标是0x5
。
根据计算公式:
- 第一条跳转指令的目标编码(在第二个字节中)为
0x3
(偏移量或目标编码)是由0x8 - 0x5
得到(0x8
是要跳到的地址,0x5
是跳转指令后面那条指令的地址或程序计数器的值) - 第二条跳转指令的目标编码
0xf8
是由0x5 - 0xd
得到。由于是以补码的形式给出,则0xf8
表示十进制的-8
。
对于第一条跳转指令机器代码的反汇编版本: 3: eb 03 jmp 8 <loop+0x8>
:
3:
:指令jmp 8 <loop+0x8>
装载前的地址,值为0x3
,是补码形式eb 03
:即为指令jmp 8 <loop+0x8>
的指令编码(只需要两个字节,eb
是低字节、03
是高字节)eb
:是jmp
指令的(机器)编码,是补码形式03
:jmp
指令跳转目标的编码,也叫做偏移量,是补码形式
注意:AMD 建议用rep
后面跟ret
的组合来避免使ret
成为条件跳转指令的目标。如果没有rep
,当分支不跳转时,jg
指令会继续到ret
指令。(rep
或repz
是同义名,retq
和ret
是同义名。以后遇到rep
或repz
就直接无视掉。)
3.6.5 用条件控制来实现条件分支
本节主要探讨C语言中if-else
到汇编的转换:一般使用测试语句或者比较语句 -->CMP
、TEST
C语言中if-else
语句的通用形式模版如下:
1 | if (test-expr) |
用C语言来描述汇编代码的程序控制流:
1 | t = test-expr; |
练习题3.18
,从如下形式的 C 语言代码开始:
1 | long test(long x, long y, long z) { |
GCC 产生如下的汇编代码:
1 |
|
填写 C 代码中缺失的表达式:
1 | long test(long x, long y, long z) { |
3.6.6 用条件传送来实现条件分支
3.6.5
中使用的是控制
的条件转移,虽然简单通用,但是条件码分支对于现代CPU效率比较低。所以3.6.6
使用数据
的条件转移,与使用控制的条件转移执行效果相同,但是会提前计算出结果的值然后进行分支预测。这种方法在条件计算量相对较小的情况下(两个表达式都很容易计算,例如分别都是一条加法指令),才会使用条件传送。
- 因为处理器通过使用“流水线”来获得高性能,而流水线需要事先确定要执行的指令序列,遇到条件分支时需要采用“分支预测逻辑”来猜测每条跳转指令是否会执行。若猜错,需要处理器丢掉这些已经做好的工作,浪费大约15~30个时钟周期。
- 假设预测错误的概率是$p$,如果没有预测错误,执行代码的时间是$T_{OK}$,否则是$T_{MP}$,则执行代码的平均时间$T_{avg} = T_{OK} + p * T_{MP}$,推导出$T_{MP}=2(T_{ran}-T_{OK})$
cmov*
:-->cmov
指条件传送,*
是指令后缀,与SET
、JMP
指令类一样。- 有两个操作数:第一个操作数是源操作数,可以是寄存器或者内存,第二个操作数是目的,只能是寄存器。
- 功能:只有在指定的条件满足时(结合
TEST
、JMP
),才会将源值复制到目的寄存器中。 - 指令类不支持单字节的条件传送 ,源和目的的值可以是16位、32位、64位。
- 汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个的指令名字。
条件控制转移编译与条件传送编译对比:
C语言形式:
1 | v = test-expr ? then-expr : else-expr; |
条件控制转移形式:
1 | if (!test-expr) |
条件传送形式:
1 | v = then-expr; |
基于条件传送的代码,会对$then-rexpr$和$else-expr$都求值,最终值的选择基于对$test-expr$的求值。
- 不是所有的条件表达式都可以用传送条件来编译。如果
then-expr
或else-expr
可能产生错误条件或副作用,会导致非法的行为。(如给全局变量赋值、return p ? *p : 0;
等) - 条件传送并不总是会提高代码的效率。编译器一般只在两个表达式都很容易计算且没有副作用时才会使用。
3.6.7 循环
C语言提的循环结构:
do-while
while
for
汇编中没有相应的指令存在,可以用条件测试 和 跳转 组合起来实现循环的效果。
do-while 循环
C语句:
1 | do |
汇编:
1 | loop: |
这个循环的效果就是重复执行$body-statement$,对$test-expr$求值,如果求值的结果为非零,就继续循环。可以看到,$body-statement$ 至少会执行一次。
while循环
有很多种方法将while
循环翻译成机器代码,GCC在代码生成中使用其中的两种方法。这两种方法使用同样的循环结构,与do-while
一样,不过它们实现初始测试的方法不同。
- 跳转到中间(jump to middle):它执行一个无条件跳转,跳到循环结尾处的测试,以此来执行初始的测试。可以用以下模板来表达这种方法,这个模板把通用的 while 循环格式翻译到 goto 代码:
1 | goto test; |
- guarded-do 模式:首先用条件分支,先进行判断条件分支,不成立就跳过循环,把代码变换为 do-while 循环。
1 | t = test-expr; |
for循环
C语言的for
循环:
1 | for(init-expr; test-expr; update-expr) |
实际上可以翻译成如下的while循环:
1 | init-expr: |
GCC为while
循环提供两种形式翻译:跳转到中间(goto
语句)、guarded-do 模式
3.6.8 switch 语句
- 使用 跳转表(jump table)这种数据结构提高效率,使得执行
switch
语句的时间与开关情况的数量无关。 - 跳转表是一个数组,表项
i
是一个代码段的地址,这个代码段实现当开关索引值等于i
时程序应该采取的动作。
在本例中跳转表可以用 GCC 对 C 的扩展的方式表示:
1 | // &&表示指向代码位置的指针。 |
- 数组
jt
包含7个表项,每个都是一个代码块的地址 - 这些位置由代码中的标号定义,在
jt
的表项中由代码指针指明,由标号加上&&
前缀组成。(回想运算符&
创建一个指向数据值的指针。在做这个扩展时,GCC的作者们创造了一个新的运算符&&
,这个运算符创建一个指向代码位置的指针) - 执行
switch
语句的关键步骤是通过跳转表来访问代码位置 - 第5行,
jmp
指令的操作数有前缀*
, 表明这是一个间接跳转,操作数指定一个内存位置,索引由寄存器rsi
给出,这个寄存器保存着index
的值 - 跳转表对重复情况的处理就是简单地对表项4和6用同样的代码标号(loc_D),而对于缺失的情况的处理就是对表项1和5使用默认情况的标号(loc_def)
其中L4
的结构如下:
这些声明表明,在叫做 rodata
(只读数据,Read-Only Data)的目标代码文件的段中,应该有一组7个“四”字(8个字节),每个字的值都是与指定的汇编代码标号(例如.L3
)相关联的指令地址。标号.L4
标记出这个分配地址的起始。与这个标号相对应的地址会作为间接跳转(第5行)的基地址。
3.7 过程
过程:用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。
过程的形式多样:
- 函数(function)
- 方法(method)
- 子例程(subroutine)
- 处理函数(handler)
- ….
假设过程 P 调用过程 Q,Q 执行后返回到 P。这些动作包括下面一个或多个机制:
- 传递控制:在进人过程 Q 的时候,程序计数器必须被设置为 Q 的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
- 传递数据:P 必须能够向 Q 提供一个或多个参数,Q 必须能够向 P 返回一个值。
- 分配和释放内存:在开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
3.7.1 运行时栈
- 当 x86-64 过程需要的存储空间超过寄存器能够存放的大小时,就会在栈上分配空间。这个部分成为过程的 栈帧(stack frame)。一般不超过 6 个的参数都可以通过寄存器传递。
- X86-64的栈向低地址方向增长。
- 大多数过程的栈帧都是定长的,在过程的开始就分配好了。但也有变长的帧。将在3.10.5节中讨论。
- 当过程具有 6 个或更少的参数时,那么所有的参数都可以通过寄存器来传递,也就是说,当所有的局部变量都可以保存在寄存器中,且该函数不会调用任何其他函数,则不需要栈帧。
- 存放参数的寄存器的使用是有顺序的,即第一个参数、第二个参数·····第六个参数存放在那个寄存器是预定义好的,将在3.7.3中讨论。
P调用过程Q时:
- 当Q在执行时,P以及所有在向上追溯到P的调用链中的过程,都是暂时被挂起的。
- 把返回地址压入栈中,表示从 Q 返回时,从 P 的哪个位置继续执行,它算作 P 的栈帧的一部分。因为他存放的是与 P 相关的状态。
- Q 会扩展当前栈的边界,在这个空间里,可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。
- 最多可以通过寄存器传递 6 个整数值(指针和整数),如果 Q 需要更多的参数,则 P 可以在 Q 调用之前在自己的栈帧里存储好这些参数。
- P 是调用者(caller),Q 是被调用者(callee)。
- x86-64 下函数调用及栈帧原理
下图是通用的栈帧结构:
3.7.2 转移控制与函数返回
本节主要研究:
- 转移控制指令
call
和ret
- 函数返回
call
指令用来调用函数,分为直接调用和间接调用,类似jmp
指令也有直接和间接跳转。
- 直接调用:
call Label
,目标Label
是标号 - 间接调用:
call *Operand
,目标*Operand
是*
后跟一个操作数指示符
call
的效果等价于 push 返回地址
+ jmp 被调函数
ret
的效果等价于 pop 返回地址
+ jmp 调用者函数
call
指令调用过程:
- 修改程序计数器 PC
- 压栈函数返回地址 A
- 跳转到标号的函数(跳转到被调函数)
在使用call
指令跳转到被调函数时,需要使用到程序计数器 PC(%rip
)(指令计数器、指令指针IP),为了使被调函数能成功返回到调用函数继续执行,需要将函数的返回地址A(跟在call
指令后面的那条地址)压入栈中,将 PC 的值设置为被调函数的起始地址。
ret
指令会从栈中弹出返回地址 A,并把PC的值设置为 A。
函数的返回关于PC、IP、IR以及寄存器的调用:程序计数器 PC, 指令寄存器IR、状态寄存器SR、通用寄存器GR
函数的调用和返回都涉及栈帧的变化,为了使函数返回到调用函数时能够继续执行返回地址的指令,要使栈指针%rbp
和%rsp
恢复到函数调用前的状态。
函数调用前栈帧调整:
- 保存当前栈帧状态值,以备后面恢复本栈帧时使用(
%rbp
入栈) - 将当前栈帧切换到新栈帧(将RSP值装入RBP,更新栈帧底部,
movq %rsp, %rbp
) - 给新栈帧分配空间(把RSP减去所需空间大小,抬高栈顶)
1 | pushq %rbp # 保存旧的帧指针,相当于创建新的栈帧 |
函数返回时栈帧调整:
- 将栈顶指针抬高到该栈帧的基地址处(使
%rsp
指向rbp
指针的位置) - 将栈帧底部已经保存的
%rbp
值弹出并赋值给%rbp
(使用pop
指令,更新%rbp
指向,使其恢复到函数调用前的位置,此时的%rsp
会上上移一个位置恢复到函数调用前的栈顶位置)
1 | movq %rbp, %rsp # 使 %rsp 和 %rbp 指向同一位置,即子栈帧的起始处 |
注意:%rsp
指向当前栈顶地址,即保存的值是栈顶的地址,而不是栈顶的值。(%rax)
则成了内存引用,会取出这个指针指向的数据。
本小节参考的其他资料:
- [读书笔记]CSAPP:7[VB]机器级表示:函数
- x86-64 下函数调用及栈帧原理
- CSAPP阅读笔记-栈帧-来自第三章3.7的笔记-P164-P176
- C函数调用过程原理及函数栈帧分析
- 程序计数器 PC, 指令寄存器IR、状态寄存器SR、通用寄存器GR
- 深入理解计算机系统(3.7)—汇编世界当中过程的经典(十分重要)(难度较高)
- 函数的调用过程(栈帧)
- 《深入理解计算机系统》 练习题3.27-3.28 被调用者保存寄存器 栈指针
- 深入理解计算机系统(3.3)—数据传送(或者说复制)指令详解
3.7.3 参数传递(参数构造区)
- x86-64 中,可以通过寄存器最多传递6个整型参数。多余的部分就要通过栈来传递。每个函数的栈帧都包含保存的寄存器、局部变量和参数构造区三个部分
- 寄存器的使用是有特殊顺序的(如下图所示),寄存器使用的名字取决于要传递的数据类型的大小。
- 使用栈传递参数时,所有的数据大小都向8的倍数对齐(仅在参数构造区中如此分配)。
假设函数 P 调用 Q ,Q 调用函数 R,P 调用 Q:
- 如果过程P要向Q传递超过 n>6 个参数,注意, 要在P的栈帧上分配 n-6 的空间用来保存参数。
- 在 P 调用 Q 之前,按照上边的规则,将 1-6 号参数复制到相应的寄存器, 把剩下的 n – 6个参数,倒序依次压入栈中,也就是说第7个参数位于栈顶。
- 在通过栈传递数据时,所有的大小都对齐到8的倍数。上边的所有参数都到位之后, 就可以执行 call 转移控制。 此时的栈顶更新为 call 的下一条指令的地址(返回地址)。
- 控制权移交给Q的指令后,Q 可以通过寄存器访问 1-6 号参数, 7~n 号参数则可以通过栈来访问。
Q 调用 R:
- 如果Q调用其他过程 R,也超过 6 个参数,也需要在自己的栈帧中分配空间。 为超出的参数分配的空间, 叫做参数构造区。
使用%rsp
或%rbp
来指示数据存放的位置:由于x86-64最小的寻址空间(一个地址块)是64位,即8个字节,在通过栈传递数据时,在参数构造区所有的大小都对齐到8的倍数,在局部变量区按实际数据大小分配,且需要对齐。通常一个数据块存放在栈中的某个连续块可以使用栈指针来指出它们的起始地址,然后这个地址块所占的空间大小是8的倍数个字节。
- 如图3-29所示,把 proc 当被调用函数看,此时传了8个参数进来,调用它的函数已经把超出的2个参数(a4和a4p)压进了栈,a1-a3p 被按顺序分别放在了上面说的6个寄存器内。
- 如图3-30。尽管a4(char)只有一个字节,但是参数a4要占8个字节空间。可以看到,作为过程调用的一部分,返回地址被压人栈中并位于栈顶位置。因而这两个参数(
a4与a4p
)位于相对于栈顶指针距离为8和16的位置。- a4 占8个字节空间,存在于
8(%rsp)~15(%rsp)
- a4p 存在于
16(%rsp)~23(%rsp)
- 返回地址存在于存在于
(%rsp)~7(%rsp)
- a4 占8个字节空间,存在于
调用者保存寄存器(Caller Save)和被调者保存寄存器(Callee Save):
为什么要区分调用者保存寄存器和被调者保存寄存器?这是因为 CPU 的寄存器是有限的,但是在函数调用时,调用的函数可以使用寄存器,被调用的函数也可以使用寄存器从而覆盖父函数寄存器的值,那么为了保证函数返回时,并不会造成数据的错乱,所以要区分开寄存器的值是由调用者保存还是被调用者保存。
调用者保存寄存器(Caller Save):当函数调用时,调用函数会将属于“调用者保存寄存器”的寄存器的值压入自己的栈中保存起来,然后这些寄存器就可以在被调用的函数中使用,在被调函数中尽管这些寄存器的值被修改覆盖,在函数返回时,栈指针恢复到调用者的栈帧中,这些寄存器的值由于之前就已经被压栈,所有值并不会错乱。
被调者保存寄存器(Callee Save):当函数被调用时,当使用到“被调用者保存寄存器”时,这些寄存器的值就需要被调函数来进行保存,即压入自己的栈中(使用push指令 ),然后这些寄存器就可以被修改覆盖。然后在函数返回时这些寄存器的值从栈上能够恢复到最初的值(使用pop指令 ),所以值不会错乱。被调者保存寄存器有%rbx 、%rbp 和 %r12~%r15 。
指针的理解:数据在计算机中是一个一个字节存的,而每个字节都有一个序号,我们称这些字节序号为地址,或指针。即可以理解为地址就是一个指针 。
栈指针%rsp
:
- 栈顶指针
%rsp
是随着函数运行不断变化的。 - 栈指针
%rsp
它其实不算是个寄存器,顾名思义,它是一个指针,指向当前栈顶地址。 - 比如
%rax
,它一个普通的寄存器,取%rax
时,就会取它装有的8字节数据;可能它是个指针,取(%rax)
时,则成了内存引用,会取出这个指针指向的数据;
而%rsp
,它是栈指针,取%rsp
时,返回的是当前栈指针的地址形如0x7fffffffe820
;取(%rsp)
时,则可能取出从 820~827 这 8 个字节里面存的数据;而一般来说,我们不需要取%rsp
,因为要栈顶地址来根本没用。
使用call
前会将局部变量入栈或寄存器(被调用者寄存器)中,然后将参数放入寄存器(调用者寄存器,0<n<7),然后执行call
,call
会将返回地址压入栈顶,此时该栈结束转移到新的栈中去。
在新的栈中,先将%rbp
压栈,若要使用到被调用者寄存器,则应先将寄存器的值压栈(这一步就是保存寄存器的值,使用被保存的寄存器区),然后是局部变量(看编译器是使用被调用者寄存器来保存局部变量还是使用栈,上述的三类必须使用栈,使用局部变量区),然后是参数构造区,先使用六个调用者寄存器,若参数大于6,则放到栈中。最后就是ret
指令会自动弹出本栈中的返回地址(位于栈顶),然后自动修改%rsp
的值。
3.7.4 局部变量区
函数中以下三类局部变量必须存放在内存中的栈里面:
- 寄存器不足够存放所有本地数据。
- 对一个局部变量使用地址运算符
&
,此时是需要得到变量的地址的,那么显然变量就不能存在寄存器中,只能放栈上。 - 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
对于2
,如果变量被存储在寄存器中,则没有内存地址,因此不能将地址操作符用于寄存器变量。
局部变量(Local Variable)是指作用域和生命周期都局限在所在函数或过程范围内的变量,它是相对于全局变量(Global variable)而言的。编译器在为局部变量分配空间时通常有两种做法:使用寄存器和使用栈。
关于使用寄存器保存还是用栈来保存局部变量:
- 上述的三类都是存放在栈上的,对于这三类中的
1
可以参考以下几点。 - 寄存器的访问速度快,但数量和空间有限,所以像字符串或数组不适合分配在寄存器中。编译器通常只会把频繁使用的临时变量分配在寄存器中,比如for循环中的循环变量。
- 当编译器的优化选项打开时,编译器会充分利用可用的寄存器来给临时变量使用,以提高程序的性能。对于调试版本,优化选项默认是关闭的,编译器会在栈上分配所有的变量。
- 在C/C++程序中,可以在声明变量时加上register关键字,请求编译器在可能的情况下将该变量分配在寄存器中,但不能保证所描述的变量一定被分配在就存寄存器中。大多数时候,编译器还是根据全局设置和编译器自身的逻辑来决定是否把一个变量分配在寄存器中。
- 编译器会在编译阶段根据变量的特征和优化选项为每个局部变量选择以上的两种分配方法之一。大多数的局部变量都是分配在栈上的。栈上的变量会随着函数的调用和返回而自动分配和释放,所以栈有时也称为自动内存。
[参考读书笔记_局部变量和栈帧.]
在局部变量区,局部变量放到栈里面需要进行数据对齐,如下图的四个局部变量x1
、x2
、x3
、x4
入栈(局部变量区)按顺序入栈。这里注意到,在同一块地址中数据对齐时,x2
放在高地址,也就是说局部变量按顺序地从高地址到低地址进行数据对齐的方式进行入栈。然后是参数x4
和&x4
入栈,由上一节已经知道,参数入栈按照GCC的规则是右边的参数先入栈,且参数构造区是不进行数据对齐的,每个参数都占8字节的倍数,如下图所示。(参数入栈可参考参数的调用约定)
3.7.5 被保存的寄存器区
通用寄存器中的寄存器被分为两类:调用者寄存器、被调用者寄存器
其中,调用者寄存器用来传参,被调用者寄存器用来保存不满足这三类和编译未优化时(大部分情况下)的局部变量。被调用者寄存器在使用前它的值需要进行压栈,保存上一个栈的状态,所以也可以把被保存的寄存器区分为局部变量区。
被调用者寄存器共六个:%rbx
、%rbp
、%r12
、%r13
、%r14
、%r15
当局部变量使用被调用者寄存器来保存时,应该先将寄存器的值进行压栈,以备后续栈帧恢复时能够正确恢复到之前的状态。
3.7.6 递归
递归是使用栈来实现的,这里我的理解是把局部变量使用的栈和被保存寄存器区部分统一起来看做所有的局部变量区域。
当局部变量使用寄存器时是使用被调用者寄存器,使用前须将寄存器值进行压栈保存,正是这一步骤使得函数在递归返回的时候能够继续使用调用函数的计算值。这样可以发现,每一个过程的栈区,都保留了需要还原给上一个栈的局部变量,自己的局部变量,交给下一个过程的参数这样一些内容。
函数的递归过程,假设递归函数为rfact
:
- 假设函数调用顺序为
main
-->rfact
-->rfact_1
-->rfact_2
-->rfact_3
--> ···· -->rfact_n-1
-->rfact_n
- 每一个函数过程都会建立一个运行时栈,且函数没执行到
ret
指令时栈是不会被销毁的,这个时候会调用下一轮循环函数,本栈数据会进行保存,然后跳转到新的栈中去 - 当执行到最后一个函数
rfact_n
时(判断条件成立),这个时候rfact_n
函数开始第一次执行ret
指令,先弹出被保存的寄存器的值,带着返回值%rax
进入到rfact_n-1
函数中去(rfact_n
函数栈释放),执行rfact_n-1
函数call
后面的那条指令,然后直到rfact_n-1
函数的ret
指令,又带着%rax
的值返回到rfact_n-2
函数,直到最后返回到rfact
函数 - 最后执行
rfact
的ret
指令返回到最初的调用函数main
,至此整个递归调用过程结束,所有的栈也得到释放
举例,C语言如下:
1 | long rfact(long n){ |
函数的汇编代码如下:
1 | long rfact(long n) |
- 如果我们在
main
函数中调用rfact(3)
,在rfact(3)
中%rax
的值是不知道的,由于程序要使用%rbx
,所以必须保存一下,假设这个值叫U
- 之后
rfact(3)
相关内容进入系统, 此时先把U
保存到了rfact(3)
的栈区中, 然后在%rbx
中存了 3, 之后使用%rdi = n-1
来调用rfact(2)
rfact(2)
一开始的时候,%rbx
中的值是 3,会把 3 保存到栈区, 然后把%rbx
中存了 2,之后去调用rfact(1)
rfact(1)
一开始的时候,%rbx
中的值是 2, 会把 2 保存到栈区, 然后在%rbx
中存了 1,之后去判断n<=1
, 结果发现成立,就把%rbx
中的值还原成 2, 然后ret 1
- 此时控制权交给
rfact(2)
调用后的语句, 此时%rbx
中的值是 2, 2 乘以rfact(1)
返回的 1, 结果是 2,然后还原%rbx
为栈区中的 3,之后返回 2 - 控制权又交回给
rfact(3)
调用后的语句,此时%rbx
中的值是 3,3 乘以rfact(2)
的返回值 2, 结果是 6,然后返回给main
函数,同时把栈区中的U
还原到%rbx
中。整个调用链就结束了
3.8 数组
3.8.1 一维数组
x86-64的内存引用指令用来简化数组引用。
一维数组的声明:$T\ A[N]$
- 分配
N
个T类型大小
的连续空间 - 起始位置表示为 $x_a$ 。这个声明有两个效果。
- 首先,它在内存中分配一个 $L * N$ 字节的连续区域,这里
L
是数据类型T
的大小(单位为字节)。 - 其次,它引入了标识符
A
,可以用A
来作为指向数组开头的指针,这个指针的值就是 $x_a$ 。可以用0~N-1
的整数索引来访问该数组元素。 - 数组元素
i
会被存放在地址为 $x_a+L·i$的地方,同时A[i]
的值存放在 $M[x_a+L·i]$的内存中。
- 首先,它在内存中分配一个 $L * N$ 字节的连续区域,这里
对于数组的计算中,常用的两个指令是mov
和leaq
:
- x84-64中任何指针指向的一个地址都是 8 个字节,如
char*
、short*
、int*
、double*
mov
类指令用来将一个数值复制到目的寄存器或内存中,对于指针指向的地址则必须使用leaq
来进行地址操作
x86-64的内存引用指令用来简化数组引用,例如,假设E
是一个int
型的数组, 而我们想计算 $E[i]$ 和 $\&E[i]$,在此,$E$ 的地址存放在寄存器%rdx
中,而i
存放在寄存器%rcx
中。
计算 $x_E十4i$ :
leaq (%rdx,%rcx,4) %rax
来获取地址,然后将地址存放在寄存器%rax
中。计算$M[x_E+4·i]$ :指令
movl (%rdx,%rcx,4), %eax
会执行地址计算 $x_E十4i$ ,读这个内存位置的值,并将结果存放到寄存器%eax
中。
指针运算
单操作数操作符&
和*
可以产生指针和间接引用指针,如&Expr
是给出该对象地址的一个指针,*Expr
是给出该地址处的值。
举个例子,假设整型数组E
的起始地址和整数索引i
分别存放在寄存器%rdx
和%rcx
中。下面给出了每个表达式的汇编代码实现,结果存放在寄存器%eax
(如果是数据)或寄存器%rax
(如果是指针)中。
3.8.2 二维数组
要访问多维数组的元素,编译器会以数组起始为基地址,(可能需要经过伸缩的)偏移量为索引,产生计算期望的元素的偏移量,然后使用某种MOV
指令。通常来说,对于一个声明如下的数组:
$T \ \ D[R][C]$
- 第
i
行的起始地址 $D[i]$:$D[i] = x_D+L*(C*i)$,$x_D$ 是 $D[0]$ 的地址,$C*L$ 是一行的地址长度(不包含数据类型时) - 第
n
行中的第j
个数组元素地址:$\&D[n][j] = D[n]+j*L$,其中 $D[n] = x_D+n*(L*C)$ - 它的数组元素 $D[i][j]$ 的地址为: $\&D[i][j] = x_D+L(C*i+j)$
3.8.3 定长数组和变长数组
关于定长数组和变长数组,是针对编译器来说的,也就是说,如果在编译时数组的长度确定,我们就称为定长数组,反之则称为变长数组。如:
- 定长:函数在使用数组
a[5][6]
时就是定长数组,因为数组长度已经在编译前确定,是静态的 - 变长:在使用数组
a[m][n]
时,m
和n
预先已经赋值正整数,但是计算机在编译前并不知道m
和n
的值,而是在编译的时候才知道,是动态的
还有一个区别:
- 由于变长是动态的编译,函数在编译后相对于定长的寄存器的使用变化了。
- 动态的版本必须用乘法指令对
i
伸缩n
倍,而不能用一系列的移位和加法。在一些处理器中,乘法会招致严重的性能处罚,但是在这种情况中无可避免。
如下两张图片,上图是静态编译(定长)的,下图是动态编译(变长)的:
3.9 结构体联合体数据对齐
结构(struct)和联合(union)可以将多个对象集合到一个单位中,且这个数据结构中的多个对象都存放在内存的连续区域。
3.9.1 结构
C语言的 struct
创建一种数据类型(称为结构体),将可能不同的数据类型的对象聚合到一个对象中。结构体中各个组成部分由名字来引用。类似于数组的实现,结构体的所有组成部分都存放在存储器中的一段连续的区域内,指向结构体的指针就是结构体第一个字节的地址。编译器维护每个结构类型的信息,指示每个字段(field)的字节偏移。以这些偏移作为存储器引用中指令的位移,从而产生对结构体元素的引用。
假设有个对象Pm
,它有个成员对象为hight
,则Pm->hight
等价于表达式*(Pm).hight
,且非法的写法为*Pm.hight
,它会被解释为*(Pm.hight)
。
假设有下面的结构体声明:
1 | struct rec{ |
要产生一个指向结构内部对象的指针,只需要将结构的起始地址加上该字段的偏移量。
下面的代码给出了类型 ELE 的结构声明以及函数 fun 的原型:
1 | struct ELE { |
当编译 fun 的代码时,GCC 会产生如下汇编代码:
1 |
|
A. 利用逆向工程技巧写出 fun 的 C 代码。
B. 描述这个结构实现的数据结构以及 fun 执行的操作。
A. fun 的 C 代码如下:
1 | long fun(struct ELE *ptr) { |
B. 每个结构都是一个单链表中的元素,字段 v 是元素的值,字段 p 是指向下一个元素的指针。函数 fun 计算列表中元素值的和。
3.9.2 联合体
联合体允许以多种类型来引用同一个对象,用不同的字段来引用相同的内存。
考虑下面的声明:
1 | struct S3{ |
在一台 IA64(x86-64) Linux 机器上编译时,字段的偏移量、数据类型S3和U3的完整大小如下:
类型 | 偏移量:c | 偏移量:i | 偏移量:v | 大小 |
---|---|---|---|---|
S3 | 0 | 4 | 12 | 20 |
U3 | 0 | 0 | 0 | 8 |
一个联合体的总的大小总是等于它的最大字段的大小。对于类型结构体 U 的指针 p
,p->c
、p->i[0]
、p->v
都是引用数据结构的起始位置。这里S3的i
的偏移量是4不是1以及v
的偏移量是12不是9主要是因为数据对齐(在下一节中讲解)。
关于课本的举例可参考【CSAPP笔记】8. 汇编语言——数据存储的联合体部分。
当用联合来将各种大小不同的数据类型结合到一起时,字节顺序的问题就变得很重要。
struct
和union
的区别:struct
为每个对象分配了单独的内存空间,而union
分配了共用的内存空间。
什么时候用union
什么时候用struct
:当你要信息同时存在时,就需要分配到不同的内存中,就要用struct
,否则用union
。
计算struct和union嵌套的数据类型的内存分布:
- 如果是包裹在struct内的,就按顺序按照对象大小依次排列下来
- 如果是包裹在union内的,就看最大的对象大小,直接分配一块内存就行
3.9.3 数据对齐(字节对齐)
x86-64虽然不需要对齐也能正常工作,但为了提高效率, Intel建议要对齐, 原则是K字节的基本对象的地址必须是K的倍数。
数据对齐为什们能提高效率,解释如下:
假如一次取4个字节,若有个int
存在0x01-0x04
,则一次就能取出,若存在0x03-0x06
,则需要分两次才能取到(第一次0x01-0x04
,第二次0x05-0x08
),这样会降低CPU效率,更何况还有像short
,char
之类的不是4个字节的数据。因此,编译器会对数据进行强制对齐。
对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足它的对齐要求。而结构本身对它的起始地址也有一些对齐要求。
编译器在汇编代码中放入命令,指明全局数据所需的对齐。例如.align 8
,表示其后分配地址的时候都以8位倍数分配。因为每个表项长8个字节,后面的元素都会遵守8字节对齐的限制。而且引用这些对象的指针,也必须以同样的倍数对齐。
数据对齐规则:
任何K字节的基本数据对象的偏移量必须是K的倍数,若不是,则对前一个数据对象进行数据填充。
在结构末尾根据需要会做一些填充,使其一旦被拓展为数组时可以满足条件1,而且拓展时是以数组中下一个结构体元素中数据大小最大那个元素为标准来对齐的。
我们可以画图把一个个对象依次填充进去,并且要求它的偏移量是满足K的倍数。然后考虑要在末尾填充多少字节能够使得总共大小是最大对象大小的倍数。最终最大对象的大小就是对初始地址的对齐要求。
举例说明偏移量和字节填充是如何进行数据对齐的:
struct P1{int i; char c; int j; char d; };
i
偏移量为0,是4的倍数,满足;c
偏移量为4,是1的倍数,满足;j
偏移量为5,不是4的倍数,需要填充3个字节,使得偏移量为8,才是4的倍数;d
偏移量为12,是1的倍数,满足;- 总共大小为13字节,而最大对象是
int
4字节,所以需要填充3字节,使其为4的倍数,所以该结构体为16字节。
struct P2{int i; char c; char d; long j; };
i
的偏移量为0,是4的倍数,满足;c
的偏移量为4,是1的倍数,满足;d
的偏移量为5,是1的倍数,满足;j
的偏移量为6,不是8的倍数,需要补充2个字节,使其偏移量为8;- 总共大小为16字节,是最大对象
long
的倍数,所以不用填充。
struct P3{short w[3]; char c[3]; };
- 数组
w
的偏移量为0,然后依次排列3个大小为2字节的数据,能够保证每个偏移量都是2的倍数,满足; - 数组
c
的第一个元素的偏移量为6,是1的倍数,而后依次排列3个大小为1字节的数据; - 总共大小为9字节,不是结构中最大对象
short
的倍数,所以还要填充1个字节,所以该结构大小为10字节。
- 数组
struct P4{short w[5]; char *c[3]; };
- 数组
w
的偏移量为0,然后依次排列5个大小为2字节的数据,能够保证每个偏移量都是2的倍数,满足; - 数组
c
的第一个元素偏移量为10,不是8的倍数,所以要填充6个字节,使其偏移量为16,是8的倍数,然后就能依次排列3个大小为8字节的数据了; - 总共大小为40字节,是2的倍数,所以对齐了。
- 数组
struct P5{struct P3 a[2]; struct P2 t; };
- 保存两个
struct P3
,一共需要20个字节,并且保证初始地址是2的倍数; struct P2
要求初始地址是8的倍数,但是第一个对象i
的偏移量是20,不是8的倍数,所以要填充4个字节,使得i
的偏移量为24,是4的倍数,然后直接把struct P2
的所有对象按上面的方式填充进去;- 总共大小是40字节(24+16=40),是8的倍数,所以对齐了。
- 保存两个
优化存储:
1
2
3
4
5
6
7
8
9
10struct{
char *a;
short b;
double c;
char d;
float e;
char f;
long g;
int h;
} rec;a
偏移量为0;b
偏移量为8;c
偏移量为10,不是8的倍数,填充6的字节,使得偏移量为16;d
偏移量为24;e
偏移量为25,不是4的倍数,填充3个字节,使得偏移量为28;f
的偏移量为32;g
的偏移量为33,不是8的倍数,填充7个字节,使得偏移量为40;h
的偏移量为48;- 最后的地址为52,不是第一个元素
a
要求的8的倍数,所以还要在末尾填充4个字节, 所以最终大小为56个字节。
我们可以改变声明的顺序,按照从大到小的形式进行声明,可以减少填充的字节数目,节省该结构的空间大小:
1
2
3
4
5
6
7
8
9
10struct {
char *a;
double c;
long g;
float e;
int h;
short b;
char d;
char f;
} rec;- 这样依次排列下来,只要40个字节,节省了28.5%的存储空间。
参考:
- [读书笔记]CSAPP:8[VB]机器级表示:数据
- 【CSAPP笔记】8. 汇编语言——数据存储
- CSAPP阅读笔记-struct, union, 数据对齐-来自第三章3.9的笔记-P183-P191