1 历史观点
Intel
处理器系列俗称x86
,经历了一个长期的、不断进化的发展过程。开始时它是第一代单芯片、16位微处理器之一,由于当时集成电路技术水平十分有限,其中做了很多妥协。此后,它不断地成长,利用进步的技术满足更高性能和支持更高级操作系统的需求。
1.1 8086
处理器
1.2 80286
处理器
1.3 i386
处理器
1.4 其他处理器
1.5 Core i7
处理器
1.6 IA32
-
IA32
-
就是“
Intel 32
位体系结构”(Intel Architecture 32-bit)
-
-
Intel64
-
IA32
的64位扩展,称为x86-64
,常用名字为x86
。
-
1.7 各处理器晶体管数量增长以及发展年份
2 程序编码
本章基于IA32
指令集上。结尾处了解x86-64
的64位扩展。假设有两个C程序文件p1.c
、p2.c
。使用GCC
编译器对文件进行编译:
Linux> gcc -O1 -o p p1.c p2.c
实际上gcc
命令调用了一整套的程序,将源代码转化成可执行代码:
-
C预处理器:扩展源代码,插入所有用
#include
命定指定的文件,扩展所有用#define
声明指定的宏。 -
编译器:产生两个源文件的汇编代码,名字分别为
p1.s
和p2.s
。 -
汇编器:将汇编代码转化成二进制目标代码文件
p1.o
和p2.o
。 -
链接器:将两个目标代码文件与实现库函数(例
printf
)的代码合并,并产生最终的可执行代码文件p
2.1 机器级代码
机器级代码是计算机程序的最低层次,直接在硬件上执行的指令集。它是一种与特定处理器架构密切相关的代码,以二进制形式编写,并且对应于底层硬件的指令和寄存器。计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。
其中两种抽象很重要:
-
指令集体系结构
( Instruction set architecture, ISA )
-
它定义了处理器状态、指令的格式,以及每条指令对状态的影响。
-
大多的
ISA
,包括IA32
或者x86-64
将程序的行为描述成每条指令是按顺序执行的,一条指令执行完成后,在执行下一条指令。 -
处理器硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与
ISA
指定的顺序执行完全一致。
-
-
虚拟地址
(virtual address)
-
机器级程序使用的存储地址,它提供的存储器模型看上去是一个非常大的字节数组。
-
存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
-
在整个编译过程中,编译器会完成绝大部分的工作,将把用C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码有一个的主要特点,即它用可读性更好的文本格式来表示。
IA32
机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的可见的处理器状态是:
-
程序计数器:(在
IA32
中,通常叫做”PC
“,用”%eip
“表示)-
表示将要执行的下一条指令在存储器 中的位置。
-
-
整数寄存器文件:
-
包含8个命名的位置,分别存储32位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。
-
有的寄存器被用来记录某些重要的程序状态,而其他的寄存器则用来保存临时数据,例如过程的局部变量和函数的返回值。
-
-
条件码寄存器:
-
保存着最近执行的算术或逻辑指令的状态信息。
-
它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语句。
-
-
一组浮点寄存器:
-
存放浮点数据。
-
注意!一条机器指令只执行一条非常基本的操作。
2.2 代码示例
假设写一个C语言代码文件code.c
,内容为:①创建code.c
int accum = 0;
int sum(int x, int y){
int t = x + y;
accum += t;
return t;
}
在命令行上使用”-S
“选项,将源代码变成汇编代码:②汇编得到code.s
指令:gcc -O1 -S code.c -o code.s
③查看code.s
汇编文件内容
.file "code.c"
.text
.globl sum
.def sum; .scl 2; .type 32; .endef
.seh_proc sum
sum:
.seh_endprologue
leal (%rcx,%rdx), %eax
addl %eax, accum(%rip)
ret
.seh_endproc
.globl accum
.bss
.align 4
accum:
.space 4
.ident "GCC: (x86_64-win32-seh-rev0, Built by MinGW-W64 project) 8.1.0"
④生成目标文件
指令:gcc -O1 -c code.c -o code.o
⑤查看目标文件内容
它是二进制文件没办法看,所以使用十六进制表示如下图所示:
⑥反汇编目标文件(反汇编器)
有一类称为反汇编器的程序非常有用。在Linux系统中,带“-d
”命令行标志的程序OBJDUMP
可以充当这个角色
指令:objdump -d code.o
结果如下所示:
code.o: file format pe-x86-64
Disassembly of section .text:
0000000000000000 <sum>:
0: 8d 04 11 lea (%rcx,%rdx,1),%eax
3: 01 05 00 00 00 00 add %eax,0x0(%rip) # 9 <sum+0x9>
9: c3 retq
a: 90 nop
b: 90 nop
c: 90 nop
d: 90 nop
e: 90 nop
f: 90 nop
我们看到按照前面的字节顺序排列的17个十六进制字节值,每组有1~6
个字节。每组都是一条指令,右边是等价的汇编语言。 IA32
指令长度从1到15个字节不等,常用的指令以及操作数较少的指令所需的字节数少。不常用的指令所需字节数较多。
常见的指令:
-
数据传输指令
-
MOV:将数据从一个位置复制到另一个位置。
-
PUSH:将数据压入栈中。
-
POP:从栈中弹出数据。
-
-
算术和逻辑指令
-
ADD、SUB、MUL、DIV:执行加法、减法、乘法和除法操作。
-
AND、OR、XOR、NOT:执行逻辑的与、或、异或和非操作。
-
-
跳转和条件分支指令
-
JMP:无条件跳转到指定的地址。
-
JZ、JNZ、JG、JL:条件分支指令,根据指定条件进行跳转。
-
-
循环指令
-
LOOP:循环执行一段代码块,根据计数器的值进行判断是否继续循环。
-
-
标志位控制指令
-
CMP:对两个值进行比较,并设置标志位。
-
TEST:对两个值进行按位与操作,并设置标志位。
-
-
过程调用指令
-
CALL:调用一个过程或子程序。
-
RET:从过程返回。
-
-
立即数操作指令
-
IMM:将立即数加载到寄存器或内存中。
-
-
移位指令
-
SHL、SHR、SAL、SAR:执行左移、右移和算术右移操作。
-
-
字符串指令
-
MOVS、CMPS、LODS、STOS:用于处理字符串操作的指令。
-
3 数据格式
由于是从16位体系结构扩展成32位的,Intel 用术语“字”(word
)表示16
位数据类型。因此,称32
位数为“双字”(double words
),称64
位数为“四字”(quad words
)。我们后面遇到的大多数指令都是对字节或双字操作的。
数据传送指令有三个变种: movb
(传送字节)、movw
(传送字)和movl
(传送双字)。
4 访问信息
一个IA32
中央处理单元(CPU
)包含一组8个存储32位值的寄存器。这些寄存器用来存储整数数据和指针。图3-2显示了这8个寄存器。它们的名字都以旨e
开头,不过它们都另有特殊的名字。在最初的8086中,寄存器是16位的,每个都有特殊的用途。名字的选择就是用来反映这些不同的用途。在平坦寻址中,对特殊寄存器的需求已经极大降低。在大多数情况,前6个寄存器都可以看成通用寄存器,对它们的使用没有限制。我们说“在大多数情况”,是因为有些指令以固定的寄存器作为源寄存器和/或目的寄存器。
另外,在过程处理中,对前3个寄存器( %eax
、%ecx
和号%edx
)的保存和恢复惯例不同于接下来的三个寄存器(%ebx
、%edi
和%esi
)。我们会在3.7节中对此加以讨论。最后两个寄存器(%ebp
和%esp
)保存着指向程序栈中重要位置的指针。只有根据栈管理的标准惯例才能修改这两个寄存器中的值。
4.1 操作数指示符(寻址方式)
大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。 操作数分为三种类型:
-
立即数(
immediate
):-
用来表示常数值。立即数的书写方式是"
$
"后面跟一个用标准表示法表示的整数,如-577
或$0x1F
。
-
-
寄存器(
register
):-
使用符号
R[E
b]
表示某个寄存器的内容。例如%eax
-
-
存储器/内存引用(
memory
):-
它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。
-
使用符号
M
b[Addr]
表示对存储在存储器中从地址Addr
开始的b
个字节值的引用。为了简便,我们通常省去下方的b。 -
注意!使用
(R[E
b])
,带了小括号的寄存器也是内存引用。
-
4.2 数据传输指令
MOV
指令将数据从一个位置复制到另一个位置的指令是最频繁使用的指令。将源操作数的值复制到目的操作数中。
mov指令有一个数据格式和两个操作数,因此一般的形式为[movx S D]。其中x为数据格式,S为源操作数,D为目的操作 MOV 源操作数,目标操作数
MOV
类由三条指令组成: movb
、movw
和movl
。这些指令都执行同样的操作,不同的只是它们分别是在大小为1、2和4个字节的数据上进行操作。
指令 | 效果[字节大小] | 描述 |
---|---|---|
MOV S, D | D<--S | 传送 |
movb | move byte[1字节] | 传送字节 |
movw | move word[2字节] | 传送字 |
movl | move long/double word[4字节] | 传送双字 |
其中目的操作数不能是立即数,源操作数可以是立即数、寄存器、内存地址等。
注意!mov
指令的源操作数和目的操作数不能都是内存的地址,那么将需要将一个数从内存中的一个位置复制到另一个位置时,需要两条mov
指令来执行:
-
(1)将内存源位置的数值加载到寄存器,例如:
movl [%eax], %edx
-
(2)将寄存器的值写入内存的目的位置。例如:
movl %edx, [%eax]
下面的MOV指令示例给出了源和目的类型的五种可能的组合:
注意!在源操作数小于目的操作数的时候,需要对目的操作数剩余的字节进行零扩展或者符号扩展,
-
MOVS
符号扩展-
movs
指令的作用是将源操作数S中的数据做符号扩展后,再复制到目的操作数D
中,movs
指令有两个数据格式和两个操作数,因此一般的形式为[movsxy S, D]
。 -
其中
x、y
为数据格式,S
为源操作数,D
为目的操作数。其中x、y
的组合一共有三种,分别是bw、bl、wl
,这三个组合代表的意思分别是单字节到双字节,单字节到双字以及双字节到双字。 -
指令最后两个字符都是大小指示符,第一个字母代表源操作数的大小,第二个字母代表目标操作数的大小。
-
注意!目的位置的所有高位用源值的最高位数值进行填充。
-
指令 | 效果[字节大小] | 描述 |
---|---|---|
MOVS S, D | D<-- 符号扩展(S ) | 传送符号扩展的字节 |
movsbw | 将做了符号扩展的字节传送到字 | |
movsbl | 将做了符号扩展的字节传送到双字 | |
movswl | 将做了符号扩展的字传送到双字 |
-
MOVZ
零扩展-
与
MOVS
用法一致。 -
注意!所有高位都用零填充。
-
指令 | 效果[字节大小] | 描述 |
---|---|---|
MOVZ S, D | D<-- 零扩展(S ) | 传送零扩展的字节 |
movzbw | 将做了零扩展的字节传送到字 | |
movzbl | 将做了零扩展的字节传送到双字 | |
movzwl | 将做了零扩展的字传送到双字 |
-
压栈/出栈
指令 | 效果[字节大小] | 描述 |
---|---|---|
pushl S | M[R[%esp]] <-- S | 将双字压栈 |
popl D | D <-- M[R[%esp]] | 将双字出栈 |
4.3 数据传输示例
5 算术和逻辑操作
大多数的指令有一些“变种”例如数据传输指令{movb,movl,movw}
等等,而有一种指令没有“变种”。这个指令就是leal
指令,每个指令都有都有对字节、字和双字数据进行操作的指令。这些操作被分为四组:加载有效地址、一元操作、二元操作和移位。二元操作有两个操作数,而一元操作有一个操作数。
5.1 加载有效地址
加载有效地址(load effective address)
指令leal
实际上是movl
指令的变形。它的指令形式是从存储器读数据到寄存器,它没有引用存储器。它的第一个操作数看上去是一个存储器引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。如图下所示:
5.2 一元操作和二元操作
-
一元操作
-
只有一个操作数,既是源操作数又是目的操作数,只涉及一个操作数的操作,可以是寄存器也可以是内存地址。
-
# 例如
取反操作(Negation):将布尔值取反。例如,对于布尔值 true,取反操作后得到 false。
负数操作(Negation):将数字取负。例如,对于数字 3,负数操作后得到 -3。
自增操作(Increment):将数字加一。例如,对于数字 5,自增操作后得到 6。
-
二元操作
-
涉及两个操作数的操作,源操作数是第一个,目的操作数是第二个,第二个操作数可以既是源操作数又是目的操作数。
-
# 例如
加法操作(Addition):将两个数字相加。例如,3 + 4 = 7。
乘法操作(Multiplication):将两个数字相乘。例如,3 * 4 = 12。
逻辑 AND 操作:将两个布尔值进行与操作。例如,true && false 的结果是 false。
逻辑 OR 操作:将两个布尔值进行或操作。例如,true || false 的结果是 true。
5.3 移位操作
移位量可以是立即数。x86-64中,移位操作对w位长的数据值进行操作,移位量是由%cl寄存器的低m位决定的。
左移指令有两个名字: SAL
和SHL
。两者的效果是一样的,都是将右边填上0。
右移指令不同,SAR
执行算术移位(填上符号位),而SHR
执行逻辑移位(填上0)。移位操作的目的操作数可以是一个寄存器或是一个存储器位置。图3-7中用>>
A (算术)和>>
L (逻辑)来表示这两种不同的右移运算。
5.4 特殊的算术操作
6 控制
上面的内容都是在直线代码的行为下进行的顺序操作,那么一些条件语句,循环语句,分支语句要如何进行操作?所以需要有条件的执行
6.1 条件码
CPU除了整数寄存器,还维护着一组单个位的条件码(condition code
)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。
-
最常用的条件码有:
-
CF :进位标志。最近的操作使最高位产生了进位。可以用来检查无符号操作数的溢出。
-
ZF :零标志。最近的操作得出的结果为0。
-
SF :符号标志。最近的操作得到的结果为负数。
-
OF:溢出标志。最近的操作导致-一个补码溢出一正溢出或负溢出。
-
# 例如 t=a+b , C语言表达式设置的条件码如下:
CF: (unsigned) t < (unsigned) a 无符号溢出
ZF: (t == 0) 零
SF: (t<0) 负数
OF: (a<0==b<0)&&(t<0!=a<0) 有符号溢出
6.2 访问条件码
根据条件码cmp
指令的结果来设置访问条件码
-
条件码通常不会直接读取,常见的使用方法有三种: 1)可以根据条件码的某种组合,将一个字节设置为0或者1; 2)可以条件跳转到程序的某个其他的部分; 3)可以有条件的传送数据。
6.3 跳转指令
-
无条件跳转指令
-
jmp
指令:无条件跳转指令没有条件判断,直接跳转
-
-
有条件跳转指令
-
根据条件寄存器的组合(
SF^OF
等多种组合)来决定是否跳转
-
-
跳转指令的编码呈现
6.4 条件传送指令
-
条件跳转/控制转移
-
实现条件操作的传统方法是利用控制的条件转移。条件满足则继续进行下去,条件不满足则进入另一条语句或者结束。
-
- 分支预测
-
条件传送指令
-
基于条件数据传送的代码要比基于条件转移的代码的性能要强。
-
处理器使用流水线来获得高性能
-
6. 5循环
汇编语没有专门的循环指令进行循环操作的,是通过条件与跳转指令组合来实现循环操作的。
do-while循环
while循环
for循环
Switch
Switch
语句根据一个整数索引值进行多重分支。通过使用跳转表(jump table
)这种数据结构使得实现更高效(在于执行开关语句的时间与开关情况的数量无关)。 跳转表: 跳转表是一个数组,表项 i
是一个代码段的地址,这个代码段实现当开关索引值等于 i 时程序应该采取的动作。 程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。
7 过程(函数)
一个过程(函数)调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。
7.1 栈帧结构
I32
程序用程序栈来处理过程调用。用栈来传递过程参数,存储返回信息,保存寄存器以便于后面的恢复和本地存储。
栈帧的最顶部分为两个指针界定:①寄存器%ebp
帧指针,②%esp
为栈指针。
过程P (调用者)调用过程Q (被调用者),则Q的参数放在P的栈帧中。另外,当P调用Q时,P中的返回地址被压人栈中,形成P的栈帧的末尾。返回地址就是当程序从Q返回时应该继续执行的地方【P的末尾】。Q的栈帧从保存的帧指针的值开始,后面是保存的其他寄存器的值。【看一个过程示例:《深入理解计算机系统(原书第二版)》第三章程序的机器级表示-->3.7过程-->3.7.4过程示例】
7.2 转移控制
过程的调用和返回需要一个指令:call
,具体如下:
call
指令有一个目标,即指明被调用过程起始的指令地址。它跟调转指令jmp
一样,可以直接调用或者间接调用。
call
指令的效果是将返回地址入栈,并跳转到被调用过程的起始处【P的末尾处】。如下图所示:
7.3 递归过程
每个过程调用在栈中都有它自己的私有空间,多个未完成调用的局部变量不会相互影响。此外,栈的原则很自然地就提供了适当的策略,当过程被调用时分配局部存储,当返回时释放存储
8 数组的分配和组成
C语言中的数组是一种将标量数据聚集成更大的数据类型的方式【顺序表】
8.1 数组的基本原则
-
一维数组
对于数据类型 T 和整型常数 N,声明如下:
T A[N]
它有两个效果:
①首先,它在存储器中分配一个L*N
字节的连续区域;这里L
是数据类型T的大小(单位为bytes
),用x
A来表示起始位置。 ②其次,它引人了标识符A
;可以用A
作为指向数组开头的指针,这个指针的值就是x
A。可以用从0
到N-1
之间的整数索引来访问数组元素。【取值】: 数组元素i
会被存放在地址为x
a+L*i
的地方。&A[i]
= x
a+L*i
例如下面这些声明的意思: char A[12];
char *B[8];
double C[6];
double *D[5];
解释:
数组 | 元素大小 | 总的大小 | 起始地址 | 元素i |
---|---|---|---|---|
A | 1 | 12 | x A | x A+i |
B | 4 | 32 | x B | x B+4i |
C | 8 | 48 | x C | x C+8i |
D | 4 | 20 | x D | x D+4i |
数组A由12个单字节(char
) 元素组成。数组C由6个双精度浮点值组成,每个值需要8个字节。B和D都是指针数组,因此每个数组元素都是4个字节。注意!【任意类型的指针类型都是4个字节】
-
二维数组
对于数据类型 T 和整型常数 R与C,声明如下
T A [R] [C]
数据类型 T , R 行, C 列 假设类型 T 的元素需要 L字节 数组大小 (R*C*L
) bytes
排列方式:以行为主序, 在内存中是连续分配的 访问数组(取值):&A[i][j]
= x
A+L*(C*i*j)
例如: int a[5][3]
则: &A[i][j]
= x
A + 4 * (3*i+j)
9 异质的数据结构
C语言提供了两种结合不同类型的对象来创建数据类型的机制:结构(structure
),用关键字《struct
》声明,将多个对象集合到一个单位中;联合(union
),用关键字union
声明,允许用几种不同的类型来引用一个对象。
9.1 结构
C语言的struct
声明创建一个数据类型,可以将不同的数据类型的对象聚合在一个对象中。结构的各个组成部分用用字来引用。
# 使用struct类型,创建一个长方形
struct rect
{
int x; // 左下角的X坐标
int y; // 左下角的Y坐标
int colorl; // 颜色
int width; // 宽
int height; // 高
}
# 引用--使用组成的名字来引用
# 首先,声明一个struct rect类型的变量r
struct rect r;
# 再设置字段
r.x = r.y = 0;
r.color = 0xFFF;
r.width = 10;
r.height = 20;
# 这里可以看出是初始化,那就可以写成
struct rect r = {0, 0, 0xFFF, 10, 20};
结构体的字节大小怎么算?
例如结构声明:
struct rec {
int i;
int j;
int a[3];
int *p;
}
这个结构体有两个int
类型占用4个字节,一个int
数组占用3*4=12字节,一个指针类型占用4个字节。
9.2 联合
允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,但是语义相差比较大。它们用不同的字段来引用相同的存储器块。
union U3{
char c;
int i[2];
double b;
}
一个联合的总的大小等于它最大字段的大小。
这里int i[2]
占用8字节,double b
也是占用8字节,所以U3联合的大小为8字节
10 理解指针
什么是指针?
-
指针也就是内存地址,指针变量是用来存放内存地址的变量
-
指针允许直接访问和操作内存中的数据
-
在计算机内存中,每个变量都存储在唯一的内存位置,该位置由一个标识符地址组成。
C语言中声明指针:
// 声明一个整数类型的指针【变量名前面加上星号(*)】
int *ptr;
// 获得数据地址【使用获取地址符操作(&)来获取变量的内存地址,将其变量给指针】
int x = 10;
int *ptr = &x; // 将变量x的地址赋给指针ptr
int y = *ptr; // 获取ptr指针指向的内存位置的值,将其存储在y中
// 动态内存分配【malloc()用于动态分配内存的函数,它返回一个指向内存分配的指针。】
void* malloc(size_t size);
// 分配内存之后还需要释放内存【free()该函数用于释放先前由malloc()分配的内存,以便将其返回给系统供其他程序使用。】
void free(void* pointer);
例如分配五个int
类型的内存
int *ptr;
ptr = (int*)malloc(5 * sizeof(int)); // 分配5个整数的内存
if (ptr == NULL) {
printf("内存分配失败\n");
} else {
// 现在可以在ptr指向的内存中存储整数
// 记得最后使用free()来释放内存
}
例如释放内存
free(ptr); // 释放先前分配的内存