3.4.3 数据传送示例
- 间接引用指针就是将该指针放在一个寄存器中,然后在内存中使用这个寄存器。
- 局部变量通常保存在寄存器中
3.4.4 压入和弹出栈数据
- 栈指针%rsp保存栈顶元素地址
- 因为栈和程序代码以及其它形式的程序数据都是存放在同一内存中,所以程序可以用标准的内存殉职的方法访问栈内的任何位置。如movq 8(%rsp),%rdx
3.5 算数和逻辑操作
- 指令被分为四组:加载有效地址、一元操作、二元操作、移位操作
- ATT格式的汇编代码操作顺序一般与直觉相反
3.5.1 加载有效地址(leaq)
- 加载有效地址指令leaq实际上使movq的变形。他的指示行事是从内存读数据到寄存器,但实际上他并没有引用内存。
leaq (%rdi,%rsi,4),%rax
第一个操作数看上去是一个内存引用,但是该指令并不是从指定位置读入数据,而是将有效地址写入到目的操作数。 - 它可以简洁的描述普通的算数运算
- 目的操作数必须是寄存器
3.5.2 一元操作二元操作
incq (%rsp)
会使栈顶的8字节元素+1- 二元:第一个操作数可以是立即数寄存器或者内存位置。第二个操作数可以是寄存器或者内存位置。注意,当第二个操作数使内存位置时,处理器必须从内存中读数据执行操作,再把结果存到内存中。
3.5.3 移位操作
- 移位操作,先给出移位量,然后给出要移位的数。移位量可以是一个立即数,也可以放在单字节寄存器%cl中(只允许%cl,并且移位量是由寄存器的低m位决定的)
- 算术右移(sar):
- 逻辑右移(shr):
- 算数左移和逻辑左移无区别:
3.5.5 特殊的算术操作(会查-暂时空)
3.6 控制
3.6.1 条件码
- 条件吗寄存器描述最近的算数或者逻辑操作的属性
- 常用的条件码有(CF,ZF,SF,OF)
- CF:最近的操作使最高位产生了进位,可用来检查无符号操作的溢出
- ZF:最近的操作得出的结果为0
- SF:最近操作得到的结果为负数
- OF:最近的操作导致一个补码溢出——正溢出或负溢出
- leaq不改变任何条件码,因为他是用来进行地址计算的。除此之外其他的操作都设置条件码。但是不是所有条件码都操作的影响。
- CMP指令根据两个操作数之差来设置条件码。不更新寄存器。(~SUB)
- TEST指令与AND指令一样,除了只设置条件码而不改变目的寄存器的值
- 经典用法:
testq %rax,%rax
检查%rax是负数 、0 、正数 - 其中的一个操作数是一个掩码,用来指示哪些位应该被测试
- 经典用法:
3.6.2 访问条件码
- 条件码通常不会直接读取,三种方法:
- 可以通过条件码的某种组合将一个字节设置成0或者1(SET指令,这些指令的后缀表示不同的条件而不是操作数的大小*,如指令setl和setb表示“小于时设置(set less)”和“低于时设置(set below)”,而不是“设置长字(setlong word)”和“设置字节(set byte)”。)
- 可以条件跳转到程序的某个其他的部分?
- 可以有条件的传送数据?
- 当OF被设置成1时,当且仅当SF被设置成0,有a 小于b
3.6.3 跳转指令
- 跳转指令会导致执行切换到程序中一个全新的位置
- 直接跳转:“.L1"
- 间接跳转:写法是“*”后面跟一个操作数指示符
- 条件跳转只能是直接跳转
`jmp *%rax 用寄存器%rax中的值作为跳转目标,而指令
jmp *(%rax) 以%rax中的值作为读地址,从内存中读出跳转目标。
`
3.6.4跳转指令的编码
(对理解链接很重要——重定位)
- 跳转指令最常用的编码是PC相对的即 他们会将给目标指令的地址与姐跟在跳转指令后面的那条指令的地址的差作为编码。这些地址偏移量可以编码为1、2或4个字节第二种编码方式是绝对地址,用四个字节直接指定目标。
- 我们可以选择无视rep和repz,他们就是空操作,防ret指令通过跳转指令到达时,处理器不能正确的处理。
3.6.5 用 条件控制来实现条件分支
- 当条件满足,程序沿着一条执行路径执行,当条件不满足时,就走另外一条路径。
- 缺点,非常低效,因为有副作用的存在,所以不能提前将不同分支的结果计算好再选择
3.6.6 用条件传送来实现条件分支
- 这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个(条件传送指先把结果执行,在根据条件结果选择结果值)
- 只有在一些受限制的情况中,这种策略才可行,但是如果可行,就可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理器的性能特性。
- 为什么基于条件数据传送比基于条件控制转移性能好?
- 处理器通过流水线技术提高性能,当遇到分支时,处理器会采用一个非常精密的分支预测逻辑来猜测每条跳转指令会不会执行。只有猜的可靠,才不至于猜错导致要中的惩罚。
- 分支预测错误处罚主导函数的性能。
- 条件传送指令:每条指令都有两个操作数:源寄存器或者内存地址S,和目的寄存器R。与不同的SET(3.6.2节)和跳转指令(3.6.3节)一样,这些指令的结果取决于条件码的值。源值可以从内存或者源寄存器中读取,但是只有在指定的条件满足时,才会被复制到目的寄存器中。
- 源和目的的值可以是16位、32位或64位长。不支持单字节的条件传送。 无条件指令的操作数的长度显式地编码在指令名中(例如 movw和movl),汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个的指令名字。(也就是说用w,l,q来显示的表示出操作数的长度)
SET指令:根据条件码的某种组合,将一个字节设置成0或者1(sete D) 跳转指令:有条件跳转(je。。),无条件跳转(jmp *%rax) 条件传送指令(cmovne。。)
- 同条件跳转不同,处理器无需预测测试的结果就可以执行条件传送。处理器只是读源值(可能是从内存中),检查条件码,然后要么更新目的寄存器,要么保持不变。我们会在第4章中探讨条件传送的实现。
3.6.7 循环
- 可以用条件测试和跳转的组合来实现循环的效果
- do-while循环
body-statement至少执行一次
- 理解产生的汇编代码与原始代码之间的关系,关键是找到程序值和寄存器之间的映射关系
- while循环
- 第一种翻译方法是跳转到中间
- 第二种翻译方式(guarded-do).
- 首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为do-while。使用比较高的优化等级编译时,-o1
利用上面的实现策略,编译器常常可以优化初始的测试,例如认测试条件总是满足的
注意汇编代码的第九行,第九行的效果等价于小于等于
- for循环
- 程序首先对初始表达式init-expr求值,然后进人循环﹔在循环中它先对测试条件testex pr求值,如果测试结果为“假”就会退出,否则执行循环体 body-statement;最后对更新表达式update-expr求值。
- for的循环产生的翻译和while一样
- 综上所述,c语言中的三种形式的循环都可以用一种简单的策略来翻译,产包含一个或者多个条件分支的代码,控制的条件转移提供了将循环翻译成机器代码的基本机制
- 在c语言中执行continue语句会导致程序跳转到当前循环迭代的结尾
3.6.8 switch语句
switch语句可以根据一个整数索引进行多重分支
- 与if-else相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关。GCC根据开关情况的数量和开关情况值的稀疏程度来翻译开关语句。当开关情况数量比较多(例如4个以上),并且值的范围跨度比较小时,就会使用跳转表。
图3-22a是一个C语言switch语句的示例。这个例子有些非常有意思的特征,包括情况标号(case label)跨过一个不连续的区域(对于情况101和105没有标号),有些情况有多个标号(情况104和106),而有些情况则会落入其他情况之中(情况102),因为对应该情况的代码段没有以break语句结尾。
图3-23是编译switch_eg时产生的汇编代码。这段代码的行为用C语言来描述就是图3-22b中的过程switch_eg_impl。这段代码使用了GCC提供的对跳转表的支持,这是对C语言的扩展。数组jt包含7个表项,每个都是一个代码块的地址。这些位置由代码中的标号定义,在jt的表项中由代码指针指明,由标号加上‘&& '前缀组成。(回想运算符&创建一个指向数据值的指针。在做这个扩展时,GCC的作者们创造了一个新的运算符&&,这个运算符创建一个指向代码位置的指针。)
- 补码表示的负数会映射成无符号表示的大整数,利用这一个事实将index看作无符号值,从而简化了分支的可能性。因此可以通过测试index是否大于六来判定index是否在0~6的范围之外
- switch的关键是通过跳转表来访问代码位置
- jmp指令的操作数有前缀“*” 说明这是一个简介跳转,操作数指定一个内存地址。
这条指令的.L4
指的是跳转表的起始地址,%rsi是跳转表的索引地址,*
表明这是一个简介跳转
- 跳转表好像没有
.L1
3.7 过程
过程P调用过程Q,Q执行之后返回到P。这些动作包括下面一个或者多个机制
- 传递控制:在进人过程Q的时候,程序计数器必须被设置为o的代码的起始地址,然后在返回时,要把程序计数器设置为Р中调用o后面那条指令的地址。
- 传递数据。Р必须能够向Q提供一个或多个参数,Q必须能够向Р返回一个值。
- 分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
下面分别描述控制,数据传递,内存管理
3.7.1 运行时栈
-
return 地址指明了当函数Q执行结束返回时要从函数P的那个位置继续执行,这个返回地址的压栈操作并不是由push来执行的,而是由函数调用指令Call来实现的
-
栈帧:当函数执行所需要的存储空间炒熟寄存器能够存放的大小时,就会借助栈上的存储空间,我们把这部分存储空间称为函数的栈帧
-
实际上很多函数甚至根本不需要栈帧,当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其他函数时,就可以这样处理。
3.7.2 转移控制
将控制从函数Р转移到函数α只需要简单地把程序计数器(PC)设置为o的代码的起始位置。不过,当稍后从Q返回的时候,处理器必须记录好它需要继续Р的执行的代码位置。在x86-64机器中,这个信息是用指令call Q调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。
call指令有一个目标,即指明被调用过程起始的指令地址。同跳转一样,调用可以是直接的,也可以是间接的。在汇编代码中,直接调用的目标是一个标号,而间接调用的目标是*后面跟一个操作数指示符,使用的是图3-3中描述的格式之一。
- 过程见传递控制的例子
3.7.3 数据传送(参数传递)
当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。x86-64 中,大部分过程间的数据传送是通过寄存器实现的。例如,我们已经看到无数的函数示例,参数在寄存器%rdi、%rsi和其他寄存器中传递。当过程Р调用过程Q时,Р的代码必须首先把参数复制到适当的寄存器中。类似地,当Q返回到P时,Р的代码可以访问寄存器%rax中的返回值。
-
参数传递用到的六个寄存器(%rdx,%rsi)
-
通过寄存器,过程P可以传递最多6个整数值(也就是指针和整数),但是如果Q需要更多的参数,P可以再调用Q之前在自己的栈帧里存储好这些参数
- 如果一个函数大于六个整数参数,超出六个的福分就要通过栈来传递。
- 注意:
- 通过栈来传送数据时,所有的大小都向8的倍数看齐
- 虽然a4只占一个字节,但是仍然为其分配8个字节的存储空间,
- 入栈的顺序时从后往前
3.7.4 栈上的局部存储
- 局部变量有些时候需要存放在内存中,常见的情况包括
- 寄存器不足够存放所有的本地数据
- 对一个局部变量使用地址运算符‘&’,因此必须能够为他产生一个地址
- 某些局部变量师叔祖或结构,因此必须能够通过数据或者结构引用被访问到
- 示例
3.7.5 寄存器中的局部存储空间
- 避免数据覆盖
- 示例
在调用Q时,Q要保存P用的寄存器的值,进而保证在Q返回时,过程P状态下寄存器的值都不变,所以在实例中%rbp %rbx是被调用者保存的寄存器
3.7.6 递归调用
3.8 数组分配和访问
int **p
p所指向的地址里面存放的是一个指向int类型的指针
3.8.1 基本原则
3.8.2 指针运算
单操作数操作符‘&’
和*'
可以产生指针和间接引用指针。也就是,对于一个表示某个对象的表达式Expr
,&Expr
是给出该对象地址的一个指针。对于一个表示地址的表达式AExpr
,*AExpr
给出该地址处的值。因此,表达式Expr
与* &Expr
是等价的。可以对数组和指针应用数组下标操作。数组引用A[i]
等同于表达式* (A+ i)
。
这个例子可以表明可以计算同一个数据结构中,两个指针之差,结果的数据类型为long
,值等于两个地址之差除以该数据类型的大小
3.8.3 嵌套的数组
3.8.4 定长的数组
- 矩阵乘法
3.8.5 变长数组
malloc和callc函数分配空间
- 与定长的数组的不同
- 由于增加了参数n,寄存器的使用发生了变化
- 用了乘法指令来计算n*i,而不是用leaq指令来计算3i
动态的版本必须用乘法指令对i进行伸缩n倍,而不能用一系列的移位和加法,在一些处理器中,乘法会招致严重的性能处罚,但是无可避免
异质的数据结构
联合(union)允许几种不同的类型来引用一个对象
3.9.1 结构体
- 要产生一个指向结构内部对象的指针,我们只需要将结构的地址加上该字段的偏移量
例如,只用加上偏移量8+4×1=12,就可以得到指针& (r->a [1])。要注意里面的1
- 结构的各个字段的选取完全是在编译时处理的。机器代码不包含关于字段声明或字段名字的信息。
3.9.2 联合
对于类型union U3*
的指针p
,p-> c
、p-> i[0]
和 p-> v
引用的都是数据结构的起始位置。还可以观察到,一个联合的总的大小等于它最大字段的大小。
- 如果结构体里有互斥访问的,就可以用联合
- 可以用来访问不同数据类型的位模式
3.9.3数据对齐
宗旨:后面一个和前面一个对齐
3.10 在机器级程序中将控制与数据结合起来
(符号调试器GDB)
3.10.1 理解指针
- 每个指针都对应一个类型
char **cpp
:指向的对象本身就是一个指向char类型对象的指针 - 每个指针都有一个值
- 指针用
&
运算符创建 - *操作符用于间接引用指针
- 数组与指针密切联系
- 将指针从一种类型强制转换成另外一种类型,值改变它的类型,而不改变它的值。
(int *)p+7=p+28
- 指针也可以指向函数
3.10.2 使用GDB调试器
这是一个应用,详细看书
3.10.3 内存越界引用和缓冲区溢出
原因:C对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起就能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。
- 缓冲区溢出:在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。
gets
这个函数是可以一直输入的,所以可能会缓冲区溢出
fgets
函数包含一个参数限制读入的最大字节数
-
09的ACSII代码是ox30ox39
-
缓冲区溢出的一个更加致命的使用就是让程序执行他本来不愿意执行的代码,利用这个可以将可执行代码的字节编码插入栈中,这种行为称之为攻击代码。还有一些字节会用一个指向攻击代码的指针覆盖返回地址,跳转到攻击代码
3.10.4对抗缓冲区溢出
- 栈随机化
- 每次都使栈的位置不一样。
- 实现方式:程序开始时,在栈上分配一段0~n字节之间的随机大小的空间,例如,使用分配函数 alloca在栈上分配指定字节数量的空间。程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化。分配的范围n必须足够大,才能获得足够多的栈地址变化,但是又要足够小,不至于浪费程序太多的空间。
- 在Linux系统中,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这类技术称为地址空间布局随机化(Address-Space Layout Randomization),或者简称ASLR[99]。采用ASLR,每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量和堆数据,都会被加载到内存的不同区域。这就意味着在一台机器上运行一个程序,与在其他机器上运行同样的程序,它们的地址映射大相径庭。这样才能够对抗一些形式的攻击。
- 并不是完全安全(空操作雪橇)
- 不会
- 栈破坏检测
- 思想:来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金·丝雀( canary)值,如图3-42所示[26,97]。这个金丝雀值,也称为哨兵值(guard value),是在程序每次运行时随机产生的,因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了。如果是的,那么程序异常中止、
- 栈保护很好地防止了缓冲区溢出攻击破坏存储在程序栈上的状态。它只会带来很小的性能损失,特别是因为GCC只在函数中有局部 char类型缓冲区的时候才插人这样的代码。当然,也有其他一些方法会破坏一个正在执行的程序的状态,但是降低栈的易受攻击性能够对抗许多常见的攻击策略。
- 限制可执行代码区域
限制能够存放可执行代码的区域。许多系统有三种访问形式:读 ,写和 执行
- 以前:x86体系结构将读和执行控制合并成一个1位标志,这样任何被标记为可读的页也是可执行的。栈必须是可读可写的因而栈上的字节也都是可执行的,这就很容易被插入,恶意代码。已经实现了很多机制,能够限制一些页可读但是不可执行,但是性能上有很大损失
- AMD引入了不可执行位,将读和执行访问模式分开。有了这个特性栈就可以被标记为可读和可写但是不可执行的,检查页是否可执行是通过硬件实现的效率上没有损失
3.10.5 支持变长栈帧
到目前为止,我们已经检查了各种函数的机器级代码,但它们有一个共同点,即编译器能够预先确定需要为栈帧分配多少空间。但是有些函数,需要的局部存储是变长的。例如,当函数调用alloca时就会发生这种情况。alloca是一个标准库函数,可以在栈上分配任意字节数量的存储。当代码声明一个局部变长数组时,也会发生这种情况。