csapp读书笔记3-程序的机器级表示

程序编码

gcc -Og -o p p1.c p2.c

采用gcc编译c语言java

  • Og:优化级别较低,产生和原始c语言代码相符的机器代码,适合学习阅读
  • O1或O2:优化级别较高,产生代码和原始代码差距较大,适合实际使用

gcc命令会调用一些列程序:算法

  • C预处理器:给源文件插入#include文件,扩展#define宏
  • 编译器:源文件->汇编代码p1.s和p2.s
  • 汇编器:汇编代码->二进制目标文件p1.o和p2.o
  • 连接器:合并目标文件与函数库二进制文件,生成可执行文件p(根据-o p指定)

其中目标代码和可执行文件都是机器代码编程

机器级代码

计算机系统对复杂的机器级编程进行抽象:数组

  • 使用指令集架构(ISA)定义机器级程序的指令的做用
  • 机器级程序使用的内存地址是虚拟地址,即一个大字节数组抽象了底层多个存储设备组合

汇编代码接近机器代码,但提供了更好的可读性安全

x86-64机器代码与c差距巨大,处理器中不少状态在c语言隐藏可是在x86-64中均可见:数据结构

  • 程序计数器:也称PC,用%rip表示。表示下一个要执行的指令的内存地址
  • 整数寄存器文件:包含16个命名位置,分别存储64位值,可存储地址(c语言指针)、整数。部分存储程序状态,部分存储临时数据(参数、局部变量、返回值)
  • 条件码寄存器:存储最近执行的算术或逻辑指令的状态,如if、while的状态
  • 一组向量寄存器:存储一个或多个整数或浮点数

程序内存包含了下面几个部分:架构

  • 程序可执行的机器代码
  • 操做系统须要的一些信息
  • 管理函数调用、返回的运行时栈
  • 用户分配的内存块(malloc等分配)

机器代码须要知道程序的虚拟内存地址便可,由操做系统将虚拟内存地址转为物理内存地址函数

在机器代码中,内存中不存在数据结构和对象,只有一个大字节数组和其中的虚拟地址性能

操做示例

源代码mstore.c学习

gcc -Og -S mstore.c:生成汇编文件mstore.s

gcc -Og -c mstore.c:生成二进制目标代码文件mstore.o

objdump -d mstore.o:经过反汇编器将机器代码翻译成汇编代码

数据格式

因为最先从16位体系结构发展起来,Intel称(word)表示16位数据类型,32位和64位就成为双字和四字

c语言的数据类型与Intel数据类型的映射关系:

汇编代码中操做指令后跟上这些数据类型的后缀,能够代表是操做哪一种类型的数据。如movb表示传送字节

访问信息

16个整数寄存器分别能够存储64位值,具体用来存整数和指针

  • 8位操做能够访问字节
  • 16位操做能够访问字
  • 32位操做能够访问双字
  • 64位操做能够访问四字

操做数

大多指令有N个操做数,表明一个操做指令所使用的源数据值和放置的目的位置。操做数有三种类型:

  • 当即数:常量值
  • 寄存器:某个寄存器标识符
  • 存储器:内存地址

  • 格式表明在汇编中的显示方式
  • 操做数值表明计算公式,也就是根据指定格式算出来的具体地址或者数值

数据传送指令

将数据从源位置复制到目的位置的指令,有三种mov,movz,movs:前者作普通拷贝,高位保持不变;后二者用于将较小的源值拷贝到较大的目的地址,并采用0或者符号位扩展高位

mov

源和目的地能够是当即数、寄存器或内存地址,但不能同时是内存地址,拷贝以后不作任何变更(例外:movl的目的地是寄存器时,高四位字节置零)

movz

movs

压栈和出栈

程序栈存放在内存的某个区域,将栈视为一个大树组的话,栈顶在数组低位,并向“下”扩展

  • pushq:将四字数据压入栈顶,有一个操做数表明源数据
  • popq:将栈顶的四字数据弹出,并放置到目的位置,有一个操做数表明目的位置

算术和逻辑操做

分为三种类型:

  • 加载有效地址
  • 一元和二元操做
  • 移位操做

加载有效地址

leaq:将内存数据读取到寄存器,相似movq

  • movq的源为内存地址时,会首先根据公式算出表达式,而后解析内存地址中的数据并填充到表达式中,生成结果并存到目的地
  • leaq不会解析内存地址的数据,只是单纯的根据源操做数和公式计算出一个表达式,而后将表达式做为结果存入目的地

示例:

通常使用leaq运算的指令会比movq的指令更少,所以每每leaq更高效

一元和二元操做

  • 一元操做:只有一个操做数,既是源又是目的
  • 二元操做:有两个操做数,第二个既是源又是目的

移位操做

有两个操做数:

  • 第一个是移位量:能够是当即数或者单字节寄存器%cl(数值存在该寄存器中)
  • 第二个是要移位的数:能够是寄存器或者内存地址,既是源又是目的

特殊算术操做

描述64位相乘(得128位数字)和128位整除的指令,思想是将128位数采用两个64位寄存器存储

控制

上述全部指令都是顺序执行,为了支持c语言条件语句、循环语句、分支语句,引入jump指令

### 条件码

除了整数寄存器,cpu还维护一组单个位条件码寄存器,记录最近算术和逻辑运算的状态。检测这些属性来执行条件分支指令,经常使用的四个:

  • CF:进位标志。最近的操做使最高位产生进位,检测无符号运算溢出
  • ZF:零标志。最近的操做结果为0
  • SF:符号标志。最近的操做结果为负数
  • OF:溢出标志。最近的操做致使一个补码溢出-正溢出或负溢出,检测有符号运算溢出

上述指令除了leaq,都会设置条件码

上述指令,CMP相似SUB,TEST相似AND,区别在CMP和TEST只设置条件码而不修改寄存器的值

访问条件码

一般不会直接读取条件码,经常使用的使用方法为:

  • 能够根据条件码的某种组合,设置目的地的低位单字节设置为0或1(SET指令)

  • 能够根据条件跳转到程序的某个位置(JMP指令)

间接跳转中,区分跳转须要区分跳转目标是从寄存器仍是内存地址读出来的

jmp *%rax:用寄存器%rax的值做为跳转目的地

jmp *(%rax):用寄存器%rax中的值做为地址,读出该地址内存的值,做为跳转目的地

  • 能够有条件的传送数据

跳转指令的编码

汇编中jmp到目标位置,在机器码中,实现方式为:

  • 采用PC相对的编码方式。指定地址偏移量(能够是一、2或4个字节),加上PC(程序计数器)中下一条指令的地址,算出目的地址,进行跳转
  • 采用绝对地址编码。指定一个绝对地址(4个字节),直接跳转到该地址

示例采用PC相对的编码方式:

  • 第2行跳转:4004d5(PC存储的地址)+ 03(单字节相对编码,十进制3)= 4004d8,跳转到目标地址4004d8
  • 地5行跳转:4004dd(PC存储的地址)+ f8(单字节相对编码,十进制-8) = 4004d5,跳转到目标地址4004d5

PC相对编码更经常使用,相比绝对地址编码的优点在于:

  • 因为是计算差值,PC相对编码须要比起绝对地址编码,须要的指令更少(上面两行跳转只用了2个字节,若是用绝对地址编码共要8个字节)
  • 目标代码能够不作改变就移到内存的不一样位置

用条件控制来实现条件分支

设置条件码,并经过JUMP类型的指令跳转到指定分支。这种方式简单、通用

用条件传送来实现条件分支

计算一个条件操做的两种结果,而后根据条件是否知足从中选取一个。这种使用受限,可是性能更优,GCC只要在两个表达式都只是很简单的指令时,才会选择条件传送,不然,绝大多数状况都是采用条件控制

上图列举了x86-64的一些条件传送指令

对比两种条件分支实现

  • 条件控制更简单、通用。GCC绝大多数状况都采用此策略
  • 条件传送更高效。因为须要计算两个分支的结果,因此当其中一个分支指令出现错误的时候,或者两个分支操做比较复杂的时候,都没法使用这个策略,只有在两个分支都是很是简单的指令运算的时候,才可以使用

条件传送高效的秘密:处理器的流水线机制。流水线机制就是将一条指令拆成多个步骤(从内存取指令、肯定指令类型、从内存读数据、执行算术运算、向内存写数据、更新PC),经过重叠连续指令的步骤(执行指令运算的同时取下一条指令),保证流水线充满了待执行的指令,提升了高性能。这样就必须预测后续指令,若是出现条件分支,可能致使预测错误,致使必须丢弃取到的指令并从新获取,致使加大指令执行的时钟周期,致使性能降低

循环

do-while循环:

while循环:

gcc -Og模式下翻译方式:

gcc -O1模式下翻译方式:

for循环:

翻译成while循环的两种模式(取决于优化等级)

switch语句

switch会利用跳转表来实现高效跳转到正确的分支。跳转表就是一个数组,存储了全部分支代码段的地址

上图汇编代码,就是一个跳转表的声明:

  1. 在名为.rodata的目标代码中,声明8个元素的地址段(数组元素)
  2. 将第一个地址值设为代码标号为.L4的指令地址,后面元素值陆续设为为.L3 ~ .L7的指令地址

上图表是switch语句从原始C代码到汇编代码的翻译过程:

  1. 获取数值,并计算偏移量
  2. 根据偏移量生成跳转表索引,依次定位跳转表的地址
  3. 根据定位的地址,跳转到指定代码段,执行代码逻辑
  4. 若是代码段结尾存在jmp,jmp到指定位置(C语言break语句);若是代码段结尾不存在jmp,继续执行下一个代码块

在分支量较大,并且跳转表索引密集的时候,GCC会自动选择跳转表来优化switch语句。经过跳转表直接定位代码段,致使分支较多的状况下,switch语句相比if-else更高效

过程

过程调用的实现机制涉及三个方面(假设过程P调用过程Q,Q执行后返回P):

  • 传递控制:进入过程Q时,PC被设为Q代码起始地址,Q执行后返回时,PC被设为P中调用Q的后面那条指令的地址
  • 传递数据:P可以给Q提供一个或多个参数,Q可以给P提供一个返回值
  • 内存管理:开始时,Q可能须要为局部变量分配空间;返回前,必须释放这些空间

运行时栈

  • 系统为过程分配的栈空间就是栈帧,过程返回会释放栈帧
  • 分配栈帧的操做就是将栈指针减少一个适当值,释放栈帧就是将栈指针增长一个适当值
  • 当函数参数不超过6个时而且没有在其中调用其余函数时,全部参数能够放到寄存器,而且该函数不须要栈帧
  • 大多数过程的栈帧都是定长的,过程刚开始就分配好
  • P调用Q时,会先将返回地址压入P的栈帧,以后才执行Q代码段的指令,返回时会弹出返回地址,做为下一步执行指令

控制转移

涉及过程调用、控制转移的指令:

带星号表示间接调用,就是获取给操做数的值做为地址,进行调用

假设存在程序,main函数调用multstore函数:

main

multstore

运行时栈

流程说明(%rip为程序计数器PC的值,%rsp为栈指针的值):

  • main函数中,程序指令移动到0x400563,将%rip修改成该地址,此时%rsp指向0x7fffffffe840
  • 执行callq,调用multstore。幕后的操做是:将后续返回地址0x400568压入栈(致使%rsp减少8个字节,0x7fffffffe840到0x7fffffffe838),而后跳转到multstore函数的第一个指令,地址为0x400540(致使%rip指向该地址)
  • 执行mulstore函数的指令直到retq
  • 执行retq指令,返回到main函数。幕后的操做是:从栈中弹出8个字节的地址0x400568(%rsp增大8个字节,0x7fffffffe838到0x7fffffffe840),而后跳转到该地址(%rip修改成该地址)

数据传送

P调用Q,P须要将前6个参数复制到寄存器,从Q返回时,Q须要将惟一的返回值写入%rax

图表中列出参数在指定的寄存器中的存放顺序

当参数N>6时,P须要为7 ~ N个参数分配栈空间,并按N到7的前后顺序压入栈,保证第7个参数在栈顶,只有分配了栈空间,才能将控制转移给Q,Q来访问栈空间上的参数

如图展现,前六个参数在寄存器,后两个在栈

这里最后一个参数不在栈顶而是在%rsp+8的位置,是由于在参数入栈以后,返回地址也入栈了

栈上的局部存储

虽然不少时候局部变量能够存在寄存器,可是如下这些状况须要必须为局部变量分配栈空间:

  • 寄存器不足以存下全部局部变量
  • 对局部变量使用了地址运算符‘&’,代表该变量数据应该被存到内存,经过栈指针引用这片内存
  • 某些变量是数组或结构(暂不讨论)

实例解析1

  1. 在caller中对arg1,arg2使用了运算符&,须要分配16字节栈帧
  2. 将arg1,arg2的值存入栈的栈帧
  3. 因为须要调用swap_add并传参(参数为arg1,arg2的地址),将参数拷贝到寄存器
  4. caller ret以前,须要释放给局部变量arg1,arg2分配的栈帧

实例解析2

  1. 在call_proc中对x1-x4使用了运算符&,分配32字节栈帧
  2. 将x1-x4值存入栈帧
  3. 调用proc须要传参,将前六个拷贝到寄存器,后两个存入栈中剩余位置
  4. call_proc ret以前,会释放给x1-x4分配的栈帧

寄存器中的局部存储空间

寄存器是全部过程的共享空间,须要保证过程P调用过程Q的时候,Q不会篡改P等会要用的寄存器

  • P:调用者
  • Q:被调用者

为防止上述状况,x86-64定义了一组规范:

其中着重关注:

  • 调用者保存的寄存器:P调用Q,P要保存这些寄存器的值不变,保证调用结束以后还能用上。Q能够随意修改
  • 被调用者保存的寄存器:P调用Q,Q要保存这些寄存器的值不变,保证过程返回后P以后还能用这些值

保存这些寄存器值的方案:

  • 不作修改
  • 将寄存器的原值入栈,这时能够随意修改该寄存器,最后弹出原值并赋值给该寄存器便可

实例讲解:

函数P中有两个值在汇编代码中存在被修改的风险:

  • x:进入P时,x是做为第一个参数,放在%rdi。执行Q(y)时,y为第一个参数,须要存入%rdi,这时x的值会被篡改。须要提早保存x的值,策略是使用%rbp保存
  • Q(y)返回值:Q(y)的值会存入%rax,Q(x)的值也会存入%rax,致使前者被篡改。须要提早保留Q(y),策略是使用%rbx保存

上述两个保存操做使用了%rbp和%rbx这两个被调用者保存的寄存器,为保证P返回后还能正常使用,P须要提早保存它的值

递归过程

保证递归正常工做的机制:

  • 每一个过程调用在栈中都有本身的私有空间,这些私有空间在用完之后会被释放
  • x86-64有防止寄存器值遭篡改的策略

实例讲解:

每次调用都会将n减1,因此须要提早保留n的值保证后续正常使用

数组分配和访问的基本原则

最右边一、八、四、8这些因子,表明char、char指针、int、double指针的字节大小

设E为int类型数组,想要获取第i的元素E[i]。E的地址存放在%rdx,i的值存放在%rcx,则获取E[i]的方式为:

movl (%rdx,%rcx,4),%eax

指针运算

  • &配合对象表达式:&Expr表示获取对象Expr的指针
  • *配合地址表达式:*Expr表示获取地址Expr的值

嵌套的数组

int A[5][3]

等价于

typedef int row3_t[3];
row3_t A[5];

即:A是个包含5个元素的数组,每一个元素都是一个存储了3个元素的数组

这个嵌套数组A也可视为5行3列的二维数组

A的占用字节为5*3*4=60

公式:对任意二维数组T D[R][C],元素D[i][j]的内存地址为&D[i][j]=Xd + L*(C*i + j)

  • Xd为数组D初始位置
  • i,j为要访问的元素
  • L为数组元素的数据类型的字节大小

编译器访问二维数组中元素的方式为根据上述公式计算出元素内存地址,而后使用MOV命令读取该地址的值

给出汇编代码访问A[i][j]的方式:

该算法将12i这个乘法表达式拆成多个加法表达式,这是利用了第二章乘以常数这一节的特性

定长数组

根据上图定义的定长二维数组(矩阵),给出下面矩阵乘法的算法:

每次计算矩阵元素时,都得使用上一小节给出的公式,GCC会针对这种状况进行优化,减小对公式的直接使用,转而依赖指针运算

变长数组

int A[n1][n2],容许n一、n2是表达式,编译期没法得知该数组的大小。该功能在C99才引入

声明变长数组时,须要将参数n放到参数A[n][n]以前

上图为汇编代码访问A[n][n]数组中,A[i][j]元素的方式。相比定长数组中,因为编译期间没法知晓n的值,没法对4(n*j)进行优化(拆分红多个加法)

上图为GCC对变长数组的矩阵相乘算法的优化,并无给出汇编代码。跟定长数组中的优化同样,避免使用公式,转而采用指针运算(变长数组直接使用公式的效果更差,由于不知道n的大小,没法对4(n*j)进行优化)

结构

假设struct rec*类型的变量r存放在寄存器%rdi,编译器会将r->j=r->i翻译成:

更复杂的实例:

综上,结构的各字段在编译时会转换成内存地址

联合

  • 结构:将多种数据类型分红多块,存在连续的内存区域
  • 联合:将多种数据类型存放在一块内存区域中,每次提取的时候只能按照一种类型获取该区域的数据

结构和联合:

在内存占用上的区别:

  • 结构:内存占用量为其中全部数据大小的总和
  • 联合:内存占用量为其中全部数据最大的那一个

上图中展现的占用量包含了数据对齐,这部分下一节介绍

相比结构,联合在存储多个字段的时候占用内存更小。但改善空间不大的时候,使用联合绕过了C语言类型系统,容易致使bug产生

实例:访问不一样数据类型的位模式

强转生成的u,值与d同样,可是二者位表示大相径庭(0.0除外)

使用联合生成的u,位模式跟d如出一辙,可是值不一样(0.0除外)。参考java库中的Double.doubleToRawLongBits

实例:分段表示位模式,须要注意机器字节顺序

  • 小端法机器:word0取低4位,word1取高4位
  • 大端法机器:word0取高4位,word1取低4位

数据对齐

为了简化访问基本数据类型的方式,一般须要让基本数据类型的地址是其字节大小的整数倍。

针对结构:

不对齐:

对齐:

  • 不对齐:节省内存,可是须要执行两次访问才能读出j(第一次访问区域4-8,第二次访问区域8-12)
  • 对齐:浪费一部份内存,换取更快的访问速度(须要执行一次8-12的访问便可读出j)

理解指针

  • 每一个指针对应一个类型。该类型代表该指针指向的对象的类型
  • 每一个指针都有一个值。该值表名指向的对象的地址,NULL(0)代表该指针没有指向任何地址
  • 指针用&运算符建立。&运算符的机器码经常使用leaq指令来实现
  • *操做符用于间接引用指针。其结果是一个值,该值是该指针地址所存储的值
  • 数组与指针关系紧密。数组名能够像指针变量同样引用,数组引用和指针间接引用效果同样
  • 将指针从一种类型强转成另外一种类型,只改变类型不改变值
  • 指针能够指向函数。该函数指针的值是该函数第一条指令的地址

缓冲区溢出

存在溢出风险的程序:

该程序的栈空间为:

若是用户输入的字节超过24字节,就会破坏调用者的栈帧。根据用户输入的字节数,程序会受到以下破坏:

这个破坏容易致使程序受到攻击。具体方式为:

  • 输入一些字符串,其中包含可执行代码的字节编码,成为攻击代码
  • 输入的特定字符超过24字节,并保证恰好将返回地址修改成攻击代码的地址
  • echo ret时会跳转到攻击代码

这样程序的意图就遭到了篡改

对抗缓冲区溢出攻击

对抗溢出攻击的方式有两种:

  • 栈随机化。想要攻击程序,就须要知道攻击代码的地址,栈随机化使得程序开始时会在栈上随机分配0-N字节的占位空间,保证攻击代码的地址每次运行都不一样,增大攻击难度
  • 栈破坏检测。在缓冲区与返回地址的空隙中,插入一个特殊值(金丝雀值),每次运行随机产生。程序返回前检测这个值有没有被修改

  • 限制可执行代码的区域。保证编译期代码使用的内存空间才是可执行的,运行时分配的内存空间不可执行。对java不适用,java支持动态生成可执行代码

支持变长栈帧

以前的机器代码,在编译期就能肯定须要为栈帧分配多少空间。有些函数(如alloca)在运行期才会在栈上(malloc是在堆上)分配空间

上图代码是一个实例:存在变长数组,编译期没法肯定数组分配空间(n未知),因此这部分空间须要运行时分配,可是分配后该如何释放这部分空间,并正确的返回到返回地址所在的位置?

x86-64采用寄存器%rbp做为帧指针(帧指针只会在实现变长栈帧时才会用),用来保存动态分配以前栈指针的位置,在函数返回前经过leave指令释放栈指针和恢复帧指针

leave等价于:

浮点体系结构

介绍了程序计数器、整数寄存器、条件码寄存器,如今只有向量寄存器没有介绍,这部分关联了浮点数的机器级操做方式

处理器定义了浮点体系结构,关系着浮点数据操做如何映射到机器上,这个结构包含:

  • 如何存储和访问浮点数。一般用某种寄存器来完成
  • 对浮点数操做的指令
  • 向函数传递浮点数参数和从函数返回浮点数结果的规则
  • 函数调用过程当中寄存器的保存规则。一部分寄存器是调用者保存,一部分为被调用者保存

本文档基于AVX2,在Core i7 Haswell处理器引入。该体系结构容许数据存在16个YMM寄存器,在对标量数据操做时,这些寄存器只保存浮点数,并只是用低32位(float)或64位(double)

浮点数传送和转换操做

传送:

传送实例:

其中都是对float类型数据进行赋值(传递)。之因此出现%rdi和%rsi这两个整数寄存器,是由于它们记录了某个浮点数的内存地址,这个内存地址是整数

转换:

  • 通用寄存器即整数寄存器
  • 浮点数转成整数,指令会执行截断,并向0舍入
  • 双操做数用来将浮点转成整数
  • 三操做数用来将整数转成浮点数。每每只须要源1和目的两个操做数,这时将源2设成目的操做数便可

下面两个代码是GCC在单精度和双精度作转换所生成的,意图不明:

过程当中的浮点代码

结合浮点体系结构一节图示,XMM寄存器就能够处理向函数传递浮点参数,以及从函数返回浮点值

  • 利用%xmm0 ~ %xmm7最多能够传递8个参数,更多参数依赖栈
  • 函数使用%xmm0返回浮点值
  • 全部寄存器都是调用者保存,被调用者能够随意修改
  • 参数包含指针、整数、浮点数时,指针和整数经过整数寄存器传递,浮点数经过XMM寄存器传递

浮点运算操做

实例:

经过实例观察到:

  • 浮点数参数经过XMM寄存器传递,整数参数经过整数寄存器传递
  • 运算前须要将float和int转为double类型

浮点常量

  • 整数运算中,使用当即数做为操做数,能够很好的处理常量
  • 浮点运算中,AXV浮点操做没法使用当即数为操做符

因此浮点运算中,编译器须要为常量提早分配内存空间,以后将浮点指令会使用内存地址做为操做符,来使用常量

上图,编译器在标号为.LC2和.LC3的内存地址存储了常量1.8和32.0

浮点数位操做

比较浮点数

上述浮点比较指令会设置三个条件码:

  • 零标志位ZF
  • 进位标志位CF
  • 奇偶标志位PF

无序状况就是两个操做数中存在NAN的状况,这是会将PF设为1

条件码的设置条件为:

小结

  • 机器级编程,寄存器和运行时栈都是可见的
  • 编译器须要使用多条指令来产生和操做各类数据结构和控制结构
  • C语言确认边界检查,致使C程序容易出现缓冲区溢出而致使被攻击
  • 现代编译器使用各类手段为运行时系统提供了安全保护
  • 本书只介绍了C到x86-64的映射,对C++也相似
  • java实现方式彻底不一样,它的目标代码是特殊的二进制代码,即java字节码。java字节码经过虚拟机(软件)解释处理,而不是直接由硬件实现。它的优点在于可移植性
相关文章
相关标签/搜索