第三章 程序的机器级表示
编译器基于编程语言的规则、操作系统的惯例、目标机器的指令集生成机器代码。
汇编代码是机器代码的一种形式,它是机器代码的文本表示。
高级代码可移植性好,而汇编代码与特定机器密切相关。
能够阅读汇编代码:
-
好处:可以理解编译器的优化能力,并分析代码中隐含的低效率
-
条件:了解编译器将高级语言转换为机器代码的转换方式。
精通细节很重要,是理解更深和更基本概念的先决条件。要认真研究示例、完成练习。
32位机器可以使用约 4GB 的随机访问存储器,64位机器可以使用 256TB( 2 48 2^{48} 248) 的内存空间(这里说的是主存)。
3.1 历史观点
Intel处理器系列俗称x86,经历了一个长期的不断进化的发展过程。
- 8086(1978年,29K个晶体管)。它是第一代单芯片、16位微处理器之一。8088是8086的一个变种,在8086上增加了一个8位外部总线,构成最初的IBM个人计算机的心脏。1980年,Intel提出了8087浮点协处理器(45K晶体管),它与一个8086或8088处理器一同运行,执行浮点指令。8087建立了x86的浮点模型,通常被称为“x87”。
- 80286(1982年,134K晶体管)。
- i386(1985年,275K晶体管)。将体系结构扩展到32位。增加了平坦寻址模式,Linux和最近版本的Windows操作系统都是使用的这种模式。
- i486(1989年,1.2M个晶体管)。改善了性能,同时将浮点单元集成到处理器芯片上。
- Pentium(1993年,3.1M个晶体管)。
- PentiumPro(1995年,5.5M个晶体管)。引入了全新的处理器设计。指令集中增加了一类“条件传送(conditional move)”指令。
- Pentium/MMX(1997年,4.5M个晶体管)。添加了处理整数向量的指令。每个数据大小可以是1、2或4个字节。每个向量总长64位。
- Pentium Ⅱ(1997年,7M个晶体管)。
- Pentium Ⅲ(1999年,8.2M个晶体管)。引入了SSE,这是一类处理整数或浮点数向量的指令。每个数据大小可以是1、2或4个字节,打包成128位的向量。
- Pentium 4(2000年,42M个晶体管)。SSE扩展到了SSE2,增加了新的数据类型(包括双精度浮点数),以及针对这些格式的144条新指令。有了这些扩展,编译器可以使用SSE指令(而不是x87指令),来编译浮点代码。
- Pentium 4E(2004年,125M个晶体管)。增加了超线程(hyperthreading),这种技术可以在一个处理器上同时运行两个程序;还增加了EM64T,它是Intel对AMD提出的对IA32的64位扩展的视线,称为x86-64。
- Core 2(2006年,291M个晶体管)。Intel的第一个多核微处理器,即多处理器实现在一个芯片上。但不支持超线程。
- Core i7,Nehalem(2008年,781M个晶体管)。既支持超线程,也有多核,最初的版本支持每个核上执行两个程序,每个芯片上最多四个核。
- Core i7,Sandy Bridge(2011年,1.71G个晶体管)。引入了AVX,这是对SSE的扩展,支持把数据封装进256位的向量。
- Core i7,Haswell(2013年,1.4G个晶体管)。将AVX扩展到AVX2,增加了更多的指令和指令格式。
每个后继处理器的设计都是向后兼容的。
每个后继处理器的设计都是向后兼容的。
摩尔定律
晶体管数量以每年37%的速率增加,也就是说,晶体管数量每26个月就会翻一番。
3.2 程序编码
汇编器产生的目标代码是机器代码的一种形式,它包含二进制形式表示的所有指令,但还没有填入全局值的地址。
3.2.1 机器级代码
影响机器级程序的两种抽象:
- 指令集架构:定义了处理器状态、指令的格式、指令对状态的影响。
- 虚拟地址:机器代码将内存看成一个按字节寻址的数组。
对机器代码可见的处理器状态:
- 程序计数器
- 整数寄存器文件:保存临时数据或重要的程序状态
- 条件码寄存器:保存最近执行的算术或逻辑指令的状态信息。
- 一组向量寄存器:保存一个或多个整数或浮点数值
C 语言中的数组和结构,在机器代码中用一组连续的字节来表示。
汇编代码不区分有符号数和无符号数,不区分指针的不同类型,不区分指针和整数。
一条机器指令只执行一个非常基本的操作。
3.2.2 代码示例
反汇编
使用反汇编器可以根据机器代码产生汇编代码。如:48 89 d3 → mov %rdx,%rbx
机器代码与反汇编表示的特性:
- x86-64 的指令长度范围为 1~15 字节。常用指令和操作数少的指令所需字节少。
- 从十六进制字节值到汇编指令,格式为:某个数字唯一地对应某个汇编指令,比如 mov 指令以 48 开头。
- 指令结尾的 ‘q’ 是大小指示符,大多数情况下可以省略。
从源程序转换来的可执行目标文件中,除了程序过程的代码,还包含启动和终止程序的代码,与操作系统交互的代码。
链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置。
3.2.3 关于格式的注解
在汇编代码中,以 ‘.’ (点) 开头的行是指导汇编器和链接器工作的伪指令。
3.3 数据格式
字节:byte,8位;字:word,16位;双字:double words,32位;四字:quad words,64位。
对应的指令后缀:movb, movw, movl, movq。
这里说的都是整数,浮点数使用一组完全不同的指令和寄存器。
3.4 访问信息
一个 64 位 CPU 中包含一组 16 个存储 64 位值的通用目的寄存器,用来存储整数和指针。
16 个寄存器标号为 raxrbp,r8r15
16 个寄存器的低位部分都可以作为字节、字、双字、四字来单独访问。分别表示为 al, ax, eax, rax。
低位操作的规则:
- 将寄存器作为目标位置时,生成字节和字的指令会保持剩下的字节不变
- 生成双字的指令会把高位四字节置为 0
16个寄存器的作用
- rax:返回值
- rsp:栈指针
- rdi, rsi, rdx, rcx, r8, r9:第 1 到第 6 个参数
- rbx, rbp, r12~r15:被调用者保存
- r10, r11:调用者保存
3.4.1 操作数指示符
指令的操作数有三种类型:立即数,寄存器,内存引用
最常用的寻址方式:Imm(rb, ri, s):Imm + rb + ri*s
s 为比例因子,只能是 1,2,4,8 中的某一个
3.4.2 数据传送指令
mov类
mov 只会更新目的操作数指定的寄存器字节或内存位置。
mov 类是最简单的数据传送指令,mov 类有 5 种:
- movb, movw, movl:传送字节、字、双字
- movq:传送四字。如果源操作数是立即数,只能是双字,然后符号扩展到四字(假的四字)
- movabsq:传送绝对的四字。只能以立即数作为源操作数,以寄存器为目的。可以传送任意 64 位立即数。
movq 用来传送寄存器和内存引用中的四字,movabsq 用来传送四字的立即数
mov 类的源操作数和目的操作数不能同时为内存,即不能将值从内存复制到内存。
mov 指令中寄存器的大小必须与 mov 的后缀字符大小匹配。
movb $-17, %al
movz类
movz 系列和 movs 系列可以把较小的源值复制到较大的目的,目的都是寄存器。
movz 将目的寄存器剩余字节做零扩展,movs 做符号扩展
movz类:movzbw, movzbl, movzbq, movzwl, movzwq(movzbw 即从字节复制到字,其他类似)
movs类:movsbw, movsbl, movsbq, movswl, movswq, movslq, cltq
- **cltq:**没有操作数,将 eax 符号扩展到 rax,等价于 movslq %eax,%rax
3.4.3 数据传送示例
局部变量通常保存在寄存器中。
函数返回指令 ret 返回的值为寄存器 rax 中的值
强制类型转换是通过 mov 指令实现的。
当指针存在寄存器中时,*a = p 的汇编指令为: mov (rdi), rax
3.4.4 压入和弹出栈数据
栈向下增长,栈顶的地址是栈中元素地址中最低的。栈指针 rsp 保存栈顶元素的地址。
出入栈指令:
- pushq rax:压栈,栈指针减 8 并将 rax 中的值写入新的栈顶地址,等价于:subq $8, (rsp) ; movq rax,(rsp)。
- popq rax:出栈,栈指针加 8 并将出栈的值写入 rax 中,等价于:movq (rsp),rax ; add $8,(rasp)
使用 mov 指令和标准的内存寻址方法可以访问栈内的任意位置,而非仅限于栈顶。
3.5 算术和逻辑操作
x86-64 的每个指令类都有对应四种不同大小数据的指令
算术和逻辑操作共有四组:
-
加载有效地址
-
- leaq S, D:将 S 的地址保存到 D 中,D 必须是寄存器
-
一元操作
-
- inc D: D+1
- dec D: D-1
- neg D:取负
- not D:取补
-
二元操作(加减乘,与或异或,没有除法)
-
- add s, d: d=d+s
- sub s, d: d=d-s
- imul s, d: d=d*s 乘
- xor s, d: d=d^s 异或
- or s, d: d=d|s 或
- and s,d: d=d&s 与
-
移位
-
- sal k,d: d=d<
- shl k,d: d=d<
- sar k,d: d=d<
- shr k,d: d=d<
3.5.1 加载有效地址
leaq 实际上是 movq 指令的变形。操作是从内存读数据地址到寄存器。
leaq 在实际应用中常常不用来取地址,而用来计算加法和有限形式的乘法
leaq 9(rdi, rsi, 4), rax;//x in rdi,y in rsi。此操作实际上等于将 x+4*y+9 的结果存入 rax
3.5.2 一元和二元操作
一元操作中的操作数既是源又是目的。
二元操作中的第二个操作数既是源又是目的。
因为不能从内存到内存,因此当第二个操作数是内存地址时,要先从内存读出值,执行操作后再把结果写回去。
注意 sub s,d 是 d-s 而不是 s-d
3.5.3 移位操作
移位操作的移位量可以是一个立即数或放在单字节寄存器 cl 中。
当移位量大于目的数的长度时,只取移位量低字节中的值(小于目的数长度)来作为真实的移位量。
3.5.4 特殊的算术操作
两个 64 位数的乘积需要 128 位来表示,x86-64指令集可以有限的支持对 128 位数的操作,包括乘法和除法。
128 位数需要两个寄存器来存储,移动时也需要两个 movq 指令来移动。
这种情况对于有符号数和无符号数采用了不同的指令。
3.6 控制
条件语句、循环语句、分支语句都要求有条件的执行。
机器代码提供两种低级机制来实现有条件的行为:
- 测试数据值,然后根据测试的结果来改变控制流或数据流
- 使用 jump 指令进行跳转
3.6.1 条件码
条件码寄存器都是单个位的,是不同于整数寄存器的另一组寄存器。
条件码描述了最近的算术或逻辑操作的属性,可以通过检测这些寄存器来执行条件分支指令。
常用条件码:
- **CF:进位标志。**最近的操作使最高位产生了进位。可以用来检查无符号数的溢出
- **ZF:零标志。**最近的操作的结果为 0
- **SF:符号标志。**最近的操作的结果为负数。
- **OF:溢出标志。**最近的操作导致了补码溢出
除了 leaq 指令外,其余的所有算术和逻辑指令都会根据运算结果设置条件码。
此外还有两类特殊的指令,他们只设置条件码不更新目的寄存器:
- **cmp s1, s2:**除了不更新目的寄存器外与 sub 指令的行为相同
- **test s1, s2:**除了不更新目的寄存器外与 and 指令的行为相同
3.6.2 访问条件码
条件码一般不直接读取,常用的使用方法有 3 种:
- 根据条件码的某种组合,使用 set 指令类将一个字节设置为 0 或 1。
- 条件跳转到程序的某个其他部分
- 有条件地传送数据
set 指令类
set 指令的目的操作数是低位单字节寄存器元素或一个字节的内存位置。set 会将该字节设置为 0 或 1
set 指令类的后缀指明了所考虑的条件码的组合,如 setl (set less) 表示“小于时设置”
注意到上图中,set 指令对于大于、小于的比较分为了有符号和无符号两类。
大多数时候,机器代码对无符号和有符号两种情况使用一样的指令。
使用不同指令来处理无符号和有符号操作的情况:
- 不同的条件码组合:
- 不同版本的右移:sar 和 shr
- 不同的乘法和除法指令
汇编语言中数据本身不区分有符号和无符号,通过不同的指令来区分有符号操作和无符号操作。
注意在汇编代码中,8字节的操作数可能是 long,long long 或 指针
3.6.3 跳转指令
跳转指令的目的地由一个标号指明
jmp .L1 ;//跳转到 .L1 。在实际的跳转指令中,.L1 会直接编码为跳转目标的地址。
movq (rax),rdx
.L1:
popq rdx
jmp 可以是直接跳转,即操作数为标号。也可以间接跳转,即操作数是寄存器或内存引用,这种情况下跳转到寄存器中存储的地址处。
跳转指令分为有条件跳转和无条件跳转,只有 jmp 是无条件跳转。有条件跳转都只能是直接跳转。
有条件跳转类似 set 指令系列,根据条件码寄存器的值来判断是否进行跳转。
3.6.4 跳转指令的编码
跳转指令的机器编码(就是纯粹数字表示的机器语言)有几种方式,其中两种如下:
- PC 相对跳转:使用目标地址与跳转指令之后下一条指令的地址之间的差来编码。可以用 1、2 或 4 个字节来编码。
- 绝对地址编码:使用目标的绝对地址。用 4 个字节直接指出。
汇编器和链接器会自己选择适当的编码方式
3.6.5 用条件控制来实现条件分支
汇编代码层面的条件控制类似于 c 语言的 goto 语句。
汇编语言使用条件码和条件跳转来起到和 c 语言中 if 相似的作用
'C 语言'
if( x<y ) { i++ }
else { i-- }
'汇编'
cmpq rsi,rdi
jge .L2
incl rax;
.L2:
decl rax;
3.6.6 用条件传送来实现条件分支
下面是常见的条件传送指令:
- 条件传送指令相当于一个i f / e l s e if/elseif/else的赋值判断,一般情况下,条件传送指令的性能高于i f / e l s e if/elseif/else的赋值判断。
- 但是因为条件传送指令将对两个表达式都求值,因此如果两个表达式计算量很大时,那么条件传送指令的性能就可能不如i f / e l s e if/elseif/else的分支判断。
用条件传送实现条件分支示例:
3.6.7 循环
C语言提供了多种循环结构,do-while、while和for。汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。
1.do-while循环
do
body-statement
while (test-expr);
这个循环的效果就是重复执行body-statement,对test-expr求值。这种通用形式可被翻译如下的goto语句:
loop:
body-statement
t = test-expr;
if (t)
goto loop;
2.while循环
while循环的通用形式和goto代码:
while (test-expr)
body-statement
// -------------
// 跳转到中间(jump to middle)
goto test;
loop:
body-statement
test:
t = test-expr;
if (t)
goto loop;
// -------------
// guarded-do
// 当使用命令行选项-O1,GCC会采用这种策略
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if (t)
goto loop;
done:
3.for循环
for循环的通用格式如下:
for (init-expr; test-expr; update-expr)
body-statement
for循环与下面使用的while循环的代码行为一样:
init-expr;
while (test-expr) {
body-statement
update-expr;
}
GCC为for循环产生的代码是while循环的两种翻译之一,这取决于优化的等级。
3.6.8 switch语句
switch(开关)语句可以根据一个整数索引值进行多重分支(multiway branching)。
C代码将跳转表声明为一个有7个元素的数组,每个元素都是一个指向代码位置的指针。
3.7 过程
过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。
假设过程P调用过程Q,Q执行后返回到P。这种动作包括下面一个或多个机制:
- 传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P种调用Q后面那条指令的地址。
- 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。
- 分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
x86-64的过程实现包括一组特殊的指令和一些对机器资源(例如寄存器和程序内存)使用的约定规则。
3.7.1 运行时栈
C语言过程调用机制的一个关键特性(大多数其他语言也是如此)在于使用了栈数据结构提供的后进先出的内存管理原则。
- 机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。而为单个过程分配的那部分栈称为帧栈(stack frame)。
- 帧栈可以认为是程序栈的一段,它有两个端点,一个标识着起始地址,一个标识着结束地址,而这两个地址,则分别存储在固定的寄存器当中,即起始地址存在%ebp寄存器当中,结束地址存在%esp寄存器当中。也就是说寄存器 %ebp 为帧指针,寄存器 %esp 为栈指针。
- 当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于帧指针的。
- 每一个栈帧都建立在调用者的下方(也就是地址递减的方向),即栈朝低地址方向增长。
- 因此如果我们将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存。这个理解起来很简单,因为在栈指针向下移动以后(也就是变小了),帧指针和栈指针中间的区域会变长,这就是给栈帧分配了更多的内存。
3.7.2 转移控制
-
call指令:call 指令有一个目标,即指明被调用过程起始的指令地址。直接调用的目标可以是一个标号,间接调用的目标是 * 后面跟一个操作符。
它一共做两件事,第一件是将返回地址(也就是call指令执行时PC的值,即call指令的下一条指令的地址)压入栈顶(push),第二件是将程序跳转到当前调用的方法的起始地址。第一件事是为了为过程的返回做准备,而第二件事则是真正的指令跳转。
-
ret指令:它同样也是做两件事,第一件是将栈顶的返回地址弹出到PC(pop),第二件事则是按照PC此时指示的指令地址继续执行程序。这两件事其实也可以认为是一件事,因为第二件事是系统自己保证的,系统总是按照PC的指令地址执行程序。
-
示例
3.7.3 数据传送
前六个参数放置的寄存器
返回值放置在%rax中
不同方式的数据传递示例
3.7.4 栈上的局部存储
局部数据存放在内存中的常见情况包括:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变量使用地址运算符&,因此必须能够为它产生一个地址。
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分,标号为“局部变量”。
上图中,caller的代码开始时把栈指针减掉16,实际上这就是在栈上分配了16字节。汇编第11行把栈指针加16,释放栈帧。
上图中,第2-15行是为调用proc做准备。其中包括为局部变量和函数参数建立栈帧,将函数参数加载至寄存器。
3.7.5 寄存器中的局部存储空间
- 在 IA32 中,寄存器%eax,%edx和%ecx被划分为调用者保存寄存器。当过程 P 调用 Q 时,Q可以覆盖这些寄存器,而不会破坏 P 所需的数据。
- 寄存器%ebx,%esi和%edi被划分为被调用者保存寄存器。这里 Q 必须在覆盖这些寄存器的值之前,先把他们保存到栈中,并在返回前恢复它们,因为 P(或某个更高层次的过程)可能会在今后的计算中需要这些值。
3.7.6 递归过程
递归调用一个函数本身与调用其它函数是一样的。栈规则提供了一种机制,每次函数调用都有它自己的私有状态信息(保存的返回值、栈指针和被调用者保存寄存器的值)存储。如果需要,它还可以提供局部变量的存储。分配和释放的栈规则很自然的就与函数调用——返回的顺序匹配。
3.8 数组分配和访问
C语言中的数组是一种将标量数据聚集成更大数据类型的方式。C语言的一个不同寻常的特点是可以产生指向数组中元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算。
3.8.1 基本原则
我们知道数组是某种基本数据类型数据的集合,对于数据类型 T 和整型常数 N,数组的声明如下:
T A[N]
上面的 A 称为数组名称。它有两个效果:
①、它在存储器中分配一个 L*N 字节的连续区域,这里 L 是数据类型 T 的大小(单位为字节)
②、A 作为指向数组开头的指针,如果分配的连续区域的起始地址为 x a x x_{ax} xax ,那么这个指针的值就是 x a x x_{ax} xax
即当我们用 A[i] 去读取数组元素的时候,其实我们访问的是 x a + i ∗ s i z e o f ( T ) x_a + i * sizeof(T) xa+i∗sizeof(T)。 s i z e o f ( T ) sizeof(T) sizeof(T) 是获得数据类型T的占用内存大小,以字节为单位,比如如果T为int,那么 s i z e o f ( i n t ) sizeof(int) sizeof(int)就是4。因为数组的下标是从0开始的,当i等于0时,我们访问的地址就是 x a x_{a} xa
在IA32中,存储器引用指令可以用来简化数组访问。比如对于上面的 int a[10],我们想访问 a[i],这时候 a 的地址存放在寄存器 %edx 中,而 i 存放在寄存器 %ecx 中。然后指令计算如下:
movl (%edx,%ecx,4), %eax
这会执行地址计算 x a + 4 i x_a+4i xa+4i,读取这个存储器位置的值,并把结果存放在寄存器%eax中。
3.8.2 指针运算
C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。
3.8.3 嵌套的数组
当我们创建数组的数组时,数组分配和引用的一般原则也是成立的。例如,声明int A[5][3]
等价于下面的声明
typedef int row3_t[3];
row3_t A[5];
数据类型row3_t被定义为一个3个整数的数组。数组A包含5个这样的元素,每个元素需要12个字节来存储3个整数。整个数组的大小就是4×5×3=60字节。
数组元素在内存中按照“行优先”的顺序排序,意味着第0行的所有元素,可以写成A[0],后面跟着第1行的所有元素(A[1])。
要访问多维数组的元素,编译器会以数组起始为基地址,(可能需要经过伸缩的)偏移量为索引,产生计算期望的元素的偏移量,然后使用某种MOV指令。通常来说,对于一个声明如下的数组:T D[R][C]
,它的数组元素D[i][j]
的内存地址为&D[i][j]=x_D+L(C*i+j)
,这里L是数据类型T以字节为单位的大小。
3.8.4 定长数组
要理解定长和变长数组,我们必须搞清楚一个概念,就是说这个“定”和“变”是针对什么来说的。在这里我们说,这两个字是针对编译器来说的,也就是说,如果在编译时数组的长度确定,我们就称为定长数组,反之则称为变长数组
。
上图中的代码是计算矩阵A和矩阵B乘积,这段代码包含很多聪明的优化,它去掉了索引j,并把所有的数组引用都转换成了指针间接引用。
3.8.5 变长数组
历史上,C语言只支持大小在编译时就能确定的多维数组,程序员需要变长数组时不得不用malloc或calloc这样的函数为这些数组分配存储空间。ISO C99引入了一种功能,允许数组的维度是表达式,在数组被分配的时候才计算出来。
声明如下:int A[expr1][expr2]
。它可以作为一个局部变量,也可以作为一个函数的参数,然后在遇到这个声明的时候,通过对表达式expr1和expr2求值来确定数组的维度。
3.9 异质的数据结构
C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:结构(structure),用关键字struct来声明,将多个对象集合到一个单位中;联合(union),用关键字union来声明,允许用几种不同的类型来引用一个对象。
3.9.1 结构
结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。
3.9.2 联合
联合的总的大小等于它最大字段的大小。联合可以用来访问不同数据类型的位模式:
unsigned long double2bits(double d) {
union {
double d;
unsigned long u;
} temp;
temp.d = d;
return temp.u;
}
3.9.3 数据对齐
许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4或8)的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。
无论数据是否对齐,x86-64硬件都能正常工作。不过,Intel还是建议要对齐数据以提高内存系统的性能。对齐原则是任何K字节的基本对象的地址必须是K的倍数。
对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足它的对齐要求。
3.10 在机器级程序中将控制与数据结合起来
3.10.1 理解指针
指针是C语言的一个核心特色。它们以一种统一方式,对不同数据结构中的元素产生引用。
-
每个指针都对应一个类型。这个类型声明该指针指向的是哪一类对象。指针类型不是机器代码中的一部分,它们是C语言提供的一种抽象,帮助程序员避免寻址错误。
-
每个指针都有一个值。这个值是某个指定类型的对象的地址。特殊的NULL(0)值表示该指针没有指向任何地方。
-
指针用&运算符创建。这个运算符可以应用到包含变量以及结构、联合和数组的元素。leaq指令是设计用来计算内存引用的地址的,&运算符的机器代码实现常常用这条指令来计算表达式的值。
-
*操作符用于间接引用指针。其结果是一个值,它的类型与该指针的类型一致。间接引用是用内存引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。
-
数组与指针紧密联系。数组引用和指针运算都需要用对象大小对偏移量进行伸缩。
-
将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。强制类型转换的一个效果是改变指针运算的伸缩。
-
指针也可以指向函数。这提供了一个很强大的存储和向代码传递引用的功能,这些引用可以被程序的某个部分调用。
例如:
int fun(int x, int *p);
// 声明一个指针fp,将它赋值为这个函数
int (*fp)(int, int *);
fp = fun;
int y = 1;
int result = fp(3, &y);c
函数指针的值是该函数机器代码表示中第一条指令的地址。
3.10.2 内存越界引用和缓冲区溢出
C对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起会导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态并试图重新加载寄存器或执行ret指令时,就会出现很严重的错误。
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码(exploit code),另外,还有一些字节会用一个指向攻击代码的指针覆盖返回地址。
任何到外部环境的接口都应该是“防弹的”,这样,外部代理的行为才不会导致系统出现错误。
蠕虫和病毒
蠕虫和病毒都试图在计算机中传播它们自己的代码段。蠕虫(worm)可以自己运行,并且能够将自己的等效副本传播到其他机器。病毒(virus)能将自己添加到包括操作系统在内的其他程序中,但它不能独立运行。
3.10.3 对抗缓冲区溢出攻击
-
栈随机化
栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的栈地址都是不同的。实现的方式是在程序开始时,在栈上分配一段0~n字节之间的随机大小的空间。
在Linux系统重,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这种技术称为空间布局随机化(Address-Space Layout Randomization),简称ASLR。采用ASLR,每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量和堆数据,都会被加载到内存的不同区域。
然而,执著的攻击者可以用蛮力克服随机化,他可以反复地用不同的地址进行攻击。一种常见的把戏就是在实际的攻击代码前插入很长一段的nop指令,执行这种指令只会让PC加一。只要攻击者能够猜中这段序列的某个地址,就能到达攻击代码。这个序列常用的术语是“空操作雪橇(nop sled)”。栈随机化和其他一些ASLR技术能够增加成功攻击一个系统的难度,但不能提供完全的安全保障。
-
栈破坏检测
计算机的第二道防线是能够检测到何时栈已经被破坏。其思想是在栈帧中任意局部缓冲区与栈状态之间存储一个特殊的*金丝雀(canary)*值(哨兵值),是在程序每次运行时随机产生的。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了,如果是就让程序异常中止。
-
限制可执行代码区域
最后一招是消除攻击者向系统中插入可执行代码的能力。一种方法是限制哪些内存区域能够存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分可以被限制为只允许读和写。虚拟内存空间在逻辑上被分成了页(page),典型的每页是2048或4096字节。硬件支持多种形式的内存保护,能够指明用户程序和操作系统内核所允许的访问方式。许多系统允许控制三种访问形式:读、写和执行。
有些类型的程序要求动态产生和执行代码的能力。例如,“即时(just-in-time)”编译技术为解释语言编写的程序动态地产生代码,以提高执行性能。是否能够将可执行代码限制在由编译器在创建原始程序时产生的那个部分中,取决于语言和操作系统。
3.10.4 支持变长栈帧
大部分函数的机器级代码,编译器能够预先确定需要为栈帧分配多少空间。但有些函数,需要的局部存储是变长的。例如,当函数调用alloca为栈上分配任意字节数量的存储时就会发生这种情况,或者声明一个局部变长的数组。
为了管理变长栈帧,x86-64代码使用寄存器%rbp作为帧指针(frame pointer)(有时称为基指针(base pointer))。
上图可以看到代码必须把%rbp之前的值保存到栈中,因为它是一个被调用者保存寄存器。然后在函数的整个执行过程中,都使得%rbp指向那个时刻栈的位置,然后用固定长度的局部变量(例如i)相对于%rbp的偏移量来引用它们。
图3-43b中,在函数的开始,代码建立栈帧,并为数组p分配空间。然后,在栈上分配16个字节,其中前8个字节用于存储局部变量i,而后8个字节是未被使用的。在函数的结尾,leave指令将帧指针恢复到它之前的值。
3.11 浮点代码
AVX浮点体系结构允许数据存储在16个YMM寄存器中,它们的名字为%ymm0~%ymm15。每个YMM寄存器都是256位(32字节)。当对标量数据操作时,这些寄存器只保存浮点数,而且只使用低32位(对于float)或64位(对于double)。汇编代码用寄存器的SSE XMM寄存器名字%xmm0~%xmm15来引用它们,每个XMM寄存器都是对应的YMM寄存器的低128位(16字节)。
3.11.1 浮点传送和转换操作
代码优化规则建议32位内存数据满足4字节对齐,64位数据满足8字节对齐。
上图给出了在浮点数和整数数据类型之间以及不同浮点格式之间进行转换的指令集合。这些都是对单个数据值进行操作的标量指令。图3-47把浮点数转换成整数时,指令会执行截断(truncation),把值向0进行舍入,这是C和大多数其他编程语言的要求。
图3-48中的指令把整数转换成浮点数。它们使用的是不太常见的三操作数格式,有两个源和一个目的。源2的值只会影响结果的高位字节,一般源2和目的的操作数是一样的。
3.11.2 过程中的浮点代码
在x86-64中,XMM寄存器用来向函数传递浮点参数,以及从函数返回浮点值。如下规则:
- XMM寄存器%xmm0~%xmm7最多可以传递8个浮点参数。可以通过栈传递额外的浮点参数。
- 函数使用寄存器%xmm0来返回浮点值。
- 所有的XMM寄存器都是调用者保存。被调用者可以不用保存就覆盖这些寄存器中任意一个。
当函数包含指针、整数和浮点数混合的参数时,指针和整数通过通用寄存器传递,而浮点值通过XMM寄存器传递。也就是说,参数到寄存器的映射取决于它们的类型和排列的顺序。
3.11.3 浮点运算操作
上图中,第一个源操作数可以是一个XMM寄存器或一个内存位置,第二个源操作数和目的操作数都必须是XMM寄存器。每个操作都有一条针对单精度的指令和一条针对双精度的指令。结果存放在目的寄存器中。
3.11.4 定义和使用浮点常数
和整数运算操作不同,AVX浮点操作不能以立即数值作为操作数。相反,编译器必须为所有的常量值分配和初始化存储空间。然后代码把这些值从内存读入。
3.11.5 在浮点代码中使用位级操作
3.11.6 浮点比较操作
AVX2提供了两条用于比较浮点数值的指令:
3.11.7 对浮点代码的观察结论
。它们使用的是不太常见的三操作数格式,有两个源和一个目的。源2的值只会影响结果的高位字节,一般源2和目的的操作数是一样的。
[外链图片转存中…(img-QrPrUuHe-1722320198084)]
3.11.2 过程中的浮点代码
在x86-64中,XMM寄存器用来向函数传递浮点参数,以及从函数返回浮点值。如下规则:
- XMM寄存器%xmm0~%xmm7最多可以传递8个浮点参数。可以通过栈传递额外的浮点参数。
- 函数使用寄存器%xmm0来返回浮点值。
- 所有的XMM寄存器都是调用者保存。被调用者可以不用保存就覆盖这些寄存器中任意一个。
当函数包含指针、整数和浮点数混合的参数时,指针和整数通过通用寄存器传递,而浮点值通过XMM寄存器传递。也就是说,参数到寄存器的映射取决于它们的类型和排列的顺序。
3.11.3 浮点运算操作
[外链图片转存中…(img-unnnYA8t-1722320198084)]
上图中,第一个源操作数可以是一个XMM寄存器或一个内存位置,第二个源操作数和目的操作数都必须是XMM寄存器。每个操作都有一条针对单精度的指令和一条针对双精度的指令。结果存放在目的寄存器中。
3.11.4 定义和使用浮点常数
和整数运算操作不同,AVX浮点操作不能以立即数值作为操作数。相反,编译器必须为所有的常量值分配和初始化存储空间。然后代码把这些值从内存读入。
3.11.5 在浮点代码中使用位级操作
[外链图片转存中…(img-9COZRw1R-1722320198084)]
3.11.6 浮点比较操作
AVX2提供了两条用于比较浮点数值的指令:
[外链图片转存中…(img-CQywn7rv-1722320198084)]
3.11.7 对浮点代码的观察结论
用AVX2为浮点数上的操作产生的机器代码风格类似于为整数上的操作产生的代码风格。它们都使用一组寄存器来保存和操作数据值,也都使用这些寄存器来传递函数参数。