03 机器级别的表示

机器级别的表示

程序编码

  • 第一步:C预处理器扩展源代码,插入所有用#include引入的文件,并扩展所有用#define声明的宏。
  • 第二步:编译器 产生源文件的汇编代码,以.s结尾。
  • 第三步:汇编器 将汇编代码转成二进制目标代码文件,以.o结尾,此时还没填入全局值的地址。
  • 第四步:链接器 将目标代码文件与库文件函数的代码合并,最终产生可执行文件,以.p结尾。

机器级代码

对于机器级编程,有两种抽象:

  1. 指令集体系结构或指令集架构(ISA):定义了处理器状态,指令的格式,以及每条指令对状态的影响。指令宏观上按顺序执行,微观上并发的执行。
  2. 内存地址使用虚拟地址

指令集体系结构的一些概念:

  • 程序计算器:给出将要执行的下一条指令的内存地址。
  • 整数寄存器文件:可以存储地址或者整数数据。有的用来记录程序状态,其它的用来保存临时数据,譬如过程的参数和局部变量,以及函数的返回值。
  • 条件码寄存器:保存最近执行的算术或者逻辑指令的状态信息。
  • 向量寄存器:存放一个或者多个整数或者浮点数。

程序的执行都离不开这些寄存器的使用,后面会了解其作用。

代码示例

C语言代码文件mstore.c如下:

1
2
3
4
5
long mult2(long,long);
void multstore(long x,long y,long *dest){
long t = mult2(x,y);
* dest = t;
}

命令行运行:linux> gcc -Og -S mstore.c 生成.s文件。

1
2
3
4
5
6
7
multstore:
pushq %rbx
movq %rdx,%rbx
call mult2
movq %rax,(%rbx)
popq %rbx
ret

命令行运行:linux> gcc -Og -c mstore.c 生成.o文件。他是二进制的,无法阅读。1368字节的文件mstore.o中有一段14字节的文件,如下:

1
53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3

机器对产生指令的源代码一无所知。

-Og 是一种符合C代码整体结构的代码优化级别,采用更高的代码优化级别会使机器代码和源码之间的关系非常难以理解,因此我们采用-Og来进行学习。

反编译时,命令行运行linux> objdumo -d mstore.o即可生成对应的汇编文件。

关于格式的注解

执行该 linux> gcc -Og -S mstore.c 命令行时,会包含很多其他的无关信息,我们省略掉这些信息,并给我们需要的信息加上固定格式的注解。

数据格式

8位是一个字节,16位称为 .因此,32位数称为“双字”,64位数称为“四字”,下入给出了C语言对应的x86-64表示。

计算机系统原理:第三章-程序的机器级别表示

访问信息

CPU中包含16个存储64位值的通用目的寄存器。

计算机系统原理:第三章-程序的机器级别表示

指令可以对每个寄存器的低位部分中存放的不同大小的数据进行操作。对于生成小于8字节的指令,寄存器中剩下的字节会有两条处理规则:

  1. 生成1字节或者2字节时,剩下的字节保持不变。
  2. 生成4字节时,高位的4个字节被设置为0。

操作数指示符

大多数指令有一个或者多个操作数,指示出一个操作中要用到的源数据值,以及放置结果的目的位置。有如下三种类型。

  1. 立即数(inmediate):表示常数值。书写方式是”$”后面跟一个值,只作为源操作数。
  2. 寄存器(register):表示某个寄存器的内容。书写方式是r_a表示任意寄存器a。用R[r_a]表示寄存器里的值。
  3. 内存引用(memmery):根据计算出来的地址访问某个内存位置。

内存地址的计算方式是imm(r_a,r_i,s)

  • imm:地址偏移量
  • r_a:基址寄存器
  • r_i:变址寄存器
  • s:比例
    计算公式:imm+R[r_a]+R[r_i]*s

计算机系统原理:第三章-程序的机器级别表示

上述操作数值M后都省略了下标b,表示对开始地址后的b个字节值的引用。还要注意地址是64位的。

M表示数据必然是在内存中

数据传送指令

指令是将源操作数的值,存放到目的操作数中。
这里的值可以是数据,也可以是地址。
具体是什么根据指令来判断。
一般都是地址对应数据,也有些奇葩存在,比如leap,由此也导致leap和指针有着密切的联系。

最简单形式的数据传送指令——MOV类,这些指令把源数据复制到目标位置,不做任何变化。

计算机系统原理:第三章-程序的机器级别表示

x86-64中规定,源操作数和目的操作数不能同时为一个内存地址。因此内存到内存需要两个指令。

movq和movabsq的区别是:movq的源数据是32位补码,然后扩展得到的64位值。movabsq的数据是任意的64位立即数。

根据之前提过的寄存器剩下字节处理规则,对于movl指令,会把高4位字节设置为0:

1
2
3
4
5
movabsq $0011223344556677 %rax %rax=0011223344556677
movb -1,%rax %rax=00112233445566FF
movw -1,%rax %rax=001122334455FFFF
movl -1,%rax %rax=00000000FFFFFFFF
movq -1,%rax %rax=FFFFFFFFFFFFFFFF

当把较小的数值复制到较大的目的时,使用MOVZ和MOVS指令:

计算机系统原理:第三章-程序的机器级别表示
计算机系统原理:第三章-程序的机器级别表示

不一样的是,MOVZ在高位作零扩展,MOVS作符号扩展,案例如下:

计算机系统原理:第三章-程序的机器级别表示

数据传送示例

计算机系统原理:第三章-程序的机器级别表示

我们可以看到:

  1. 所谓指针,其实就是地址。
  2. 局部变量通常保存在寄存器中,而不是内存中。访问寄存器比访问内存要快很多。

圧入和弹出栈数据

什么是内存栈?

内存栈是一种存储结构
栈指针是一个寄存器(%rsp),保存着指向栈顶的地址。

入栈和出栈的指令如下:

计算机系统原理:第三章-程序的机器级别表示

在内存的操作对应如下:

计算机系统原理:第三章-程序的机器级别表示

pushq的指令也可以使用subq+movq来代替,区别是前者只需要1个字节,后者需要8个字节。

算术和逻辑指令

计算机系统原理:第三章-程序的机器级别表示

除了leaq之外,每个指令类都有4种不同大小的指令(1,2,4,8位)

加载有效地址指令leaq

leaq是movq的变种,与movq不同的是,leaq不是存储源操作数的值,而是存储它的地址。

  • movq (%rdi),%rax 值将%rdi寄存器中的地址的值存放到%rax寄存器中
  • leaq (%rdi),%rax 是将%rdi寄存器中的地址存放到%rax寄存器中

leaq指令就类似c中的指针

讨论

从图中可以看到,除了位移指令,大部分的指令既可用于无符号运算,也可用于补码运算。这个特性也导致使用补码作为有符号整数参与运算.
区分有符号无符号是通过指令来判断的,指令值针对有符号的操作就表示数据有符号,指令值针对无符号的操作就表示数据有无符号。

特殊的算术操作

下面的这些指令支持两个64位的指令相乘得到128位的结果

计算机系统原理:第三章-程序的机器级别表示

要求%rax寄存器(这其实就是一个累加器)中存在一个值
imulq这个指令可以用于两个不同的操作,汇编器能够通过计算操作数的数目来分辨。

案例:

特殊算术操作案例
特殊算术操作案例

高位字节存储在大地址

如果我们不想按照指令顺序一步一步执行?

控制

使用jump指令可以改变指令的执行顺序
条件,循环,分支。

条件码

除了整数寄存器,CPU还维护着一组单个位条件码寄存器,描述了最近的算术或逻辑操作属性。
常用的条件码有:

  • CF:进位标志,最近的操作是否使最高位产生进位,可用来检查无符号操作的溢出。
  • ZF:零标志,最近的操作计算的结果为零
  • SF:符号标志,最近的操作结果为负
  • OF:溢出标志,最近的操作导致补码溢出

如有一个表达式t=a+b,条件码会根据这个表达式设置值如下:

  • CF:(unsigned)t < (unsignded) a
  • ZF:(t==0)
  • SF:(t<0)
  • OF:(a<0 == b<0)&&(t<0 != a<0)

    leap指令不会改变条件码

有些指令不会改变寄存器的值,而是简单的改变条件码寄存器。
cmp类似sub,test类似and

只改变条件码寄存器的指令

访问条件码

条件码有什么用呢?

用作条件

条件码如何使用呢?

一般并不直接访问单个条件码寄存器

  1. 可以通过条件码寄存器的组合结果来将一个字节设置为0或者1。
  2. 可以通过条件码寄存器的组合结果来跳转到其它地方
  3. 可以通过条件码寄存器的组合结果来传输数据

常用的指令组合(大于小于等于)有:

常用的SET指令

跳转

jump是直接跳转,其它的是结合条件码的组合来跳转。

跳转指令

jump也有分为两种:

  • 直接跳转:目的地是指令编码中指定的地点,如jmp .L1
  • 间接跳转:目的地是寄存器或内存地址,如jmp *%rax,*(%rax)

跳转指令的编码

对于“目的地”如何机器编码?

  1. PV相对寻址:以下一地址的偏移量(负表示到之前)所得作为目的地
  2. 绝对寻址:直接给出跳转的目的地址

案例:
跳转目标的机器表示

实现条件分支

如何实现条件分支?

  1. 条件跳转

条件跳转实现条件分支

  1. 条件传送(条件赋值)

条件赋值实现条件分支

两种方式有何区别?

  • 在分支比较简单时,使用条件赋值较好:
    一条指令的处理要经过一系列阶段(从内存取指令、确定指令类型、从内存取数据、执行算术运算、从内存写数据、更新程序计数器)
    处理器通过使用 流水线(pipelining) 来提高性能
    大概就是通过同时执行指令的不同阶段
    这要求指令是顺序的,因此当遇到分支(条件跳转)时,要么选择等待,要么采用非常精密的分支预测逻辑来预测执行
    这就意味着要承担错误预测的风险(消耗)

    x84_64可用的条件赋值指令有:

    条件赋值指令

  • 在分支比较复杂时,使用条件跳转较好:
    在条件赋值的处理下,无论最后走哪一条分支,两条分支的逻辑都会被执行
    这就会出现:

    1. 如果有一条分支需要大量的计算,当条件不满足时,那就相当于做了无用功。
    2. 可能在一条分支上出现了错误(技师在条件永不满足),也会被执行报错

实现循环

如何实现循环分支?

使用条件码(比较、测试)和条件跳转可以实现循环,如下:

  1. do-while

    do-while

  2. while
    有两种

    1. jump to middle

    jump to middle

    1. jguarded-do

    guarded-do

  3. for
    for语句可以转换为while语句来实现

    1
    2
    3
    4
    5
    6
    7
    8
    for(init-expr;test-expr;update-expr)
    body-statement
    ===========================
    init-expr;
    while(test-expr){
    body-statement
    update-expr;
    }

switch语句

如何实现switch分支?

同样是使用条件(无条件)跳转来实现的
跳转的地址用一个数组保存(&表示指向数据值的地址,&&表示指向标记代码位置的地址)
数组索引是分支的开关,通过计算得到

switch

对应的汇编代码如下:

switch汇编

jmp *.L4(,%rsi,8)
表示一个间接跳转,地址为 8 * R[%rsi] + .L4

跳转表数组对应的内存:

跳转表

过程

什么是过程?

指令集合
可被调用
函数、方法、子例程、处理函数等等

一个过程涉及到哪些步骤?

假设过程P调用过程Q,过程Q执行后返回到P。
这个动作包括下面的一个或多个机制:

  1. 传递控制:程序计数器必须设置为Q代码的起始地址,然后在返回时,要把程序计数器指向调用Q的下一行指令地址。
  2. 传递数据:P可以向Q提供参数,Q可以向P返回结果值
  3. 分配和释放额内存:Q需要为局部变量分配空间,返回后又需要释放这些空间

接来下一一说明这些机制如何实现;

运行时栈

大多数语言都使用一种内存结构——栈帧——来控制过程

栈帧

传递控制

传递控制如何实现?

使用call和ret指令实现传递控制
call Q调用过程Q,同时把返回地址A压入栈

callq和retq表明是x86-64版本

call和ret

具体是如何退出?

当过程需要退出时,会释放当前的内存
这时候栈帧会指向返回地址A
ret会取出该地址,并将程序计数器(%rpi)修改为该地址

案例:

过程案例

下图是这个案例中call和ret对栈的操作栈过程:

call和ret的操作栈过程

%rip:程序计数器(寄存器)
%rsp:栈顶指针(寄存器)

传递数据

传递数据如何实现?

大部分过程间数据传递都是通过寄存器实现的
有6个寄存器被用来参数传递,寄存器使用的按照参数的顺序,寄存器的名字取决于要传递的数据类型的大小
用%rax来返回结果值

用来传参的寄存器

多于6个参数怎么办?

多于6个的参数会被放到栈帧中,通过帧指针(存放在%rbp中)调用来使用
每个参数都占用8个字节

用来传参的栈帧

这些参数在被调用的过程中如何使用?

  1. 寄存器中的参数:直接使用寄存器
  2. 存放在栈帧中的参数:通过上一过程最后一帧的地址(帧指针)来访问

分配和释放栈帧内存

之前说过,寄存器是可以用来存放过程中的局部数据
那么,在什么时候需要用栈帧来存储参数和局部数据?

寄存器不够用,常见的情况有:

  1. 本地数据太多
  2. 对局部变量使用了地址运算符‘&’(意味着必须存放到内存地址中)
  3. 使用了数组或者结构体(这些连续的空间必须在内存上才能实现)

多的局部数据如何保存在栈帧中?

这也可以通过分配栈帧和释放栈帧空间来实现:

  1. 进入一个新的过程,分配内存空间
  2. 局部意味着只在当前过程中有效,因此离开的时候需要释放这部分空间

案例:

分配和释放栈帧内存

寄存器中的局部数据

存放在过程栈帧中的局部数据是当前过程一直有效的

但是寄存器是所有过程共享的,这意味着在过程调用中,寄存器中的值可能会被改变,那么,如何确保存当前过程寄存器中的局部数据是一致的?

  1. 分析有哪些寄存器数据是在调用其他过程后会被修改的
  2. 把这些寄存器数据存起来
  3. 调用其他过程返回后恢复数据

如何保存这些寄存器?

x86-64采用了一组统一的寄存器使用惯例(%rbx、%rbp、%r12~%r15):
被调用者保存寄存器:被调用者在开始前必须保存这些寄存器(会发生变化的)中的数据,以返回时恢复这些数据
把之前谈到的变化数据存放到被调用者保存寄存器中即可
保存的方式是在分配栈空间就先把这些数据压入栈中,释放空间后再弹出。

案例:
被调用者保存寄存器

当被调用者保存寄存器不够用了怎么办?

递归过程

栈规则提供了一种机制,每次函数调用都有它自己私有的状态信息。
调用本身和调用其他函数是一样的

案例:
递归过程

数组分配和访问

数组是如何存储的?

C语言:
数组是内存上的连续空间
标志符作为指向数组的开头指针

如何使用指令来访问数组?

假设是int型数组(4字节)
将开头指针存放在寄存器%rdx中
索引存放在寄存器%rcx中
可以使用来访问

指针运算

C语言允许指针运算:
p+i = p的地址 + i * p的数据类型大小

数组嵌套

二维数组、三维数组如何表示?

数组嵌套

T D[R][C];
计算公式:&D[i][j] = D + L(C * i + j);

L是数据类型的大小(字节)

定长数组和变长数组

什么是定长和变长?

定和变是相对编译前后而言的

定长:数组的长度编译之后是固定的
变长:数组的长度编译之后 可能会不同

变长数组性能上会大打折扣,为什么?

  1. 需要额外的寄存器来存放数组长度参数
  2. 在计算C*i的时候必须使用乘法指令,而不能使用leaq指令来实现,乘法指令会有性能处罚。

异质的数据结构

C语言提供给了两种把不同的数据类型组合到一起创建新的数据类型的机制:结构和联合

机器代码不包含关于字段声明和字段名字的信息,这些都在编译时期被处理。

结构体

和数组一样,结构体是内存上一段连续的空间。

案例:

1
2
3
4
5
6
struct rec{
int i;
int j;
int a[2];
int *p;
};

结构体

数据对齐

为什么需要数据对齐?

CPU为了提高读写效率,一般都是一次读写多个字节,如8字节
如果能确保一个数据能在一次内存读写中获取,是提高性能的一个方式

如何保证?

确保K字节的数据类型地址(偏移)是K的倍数
如:
1字节的char地址(偏移)是1的倍数
2字节的short地址(偏移)是2的倍数
4字节的int地址(偏移)是4的倍数
8字节的double地址(偏移)是8的倍数

因此在一个结构体中,如下:

1
2
3
4
5
6
struct S1{
char c;
int i;
char f;
double j;
};

sizeof(S1) = 4 + 4 + 8 + 8 = 24

一般编译器会替我们选择适合目标平台的对齐策略

.align 8 可以指定全部数据的对齐

机器级程序中控制和数据的互动

越底层的操作,越能看清数据和控制的互动
譬如C中指针

理解指针

  1. 每个指针都对应一个类型:
  2. 每一个指针都有一个值:数据的地址
  3. 使用‘&’获取数据的指针
  4. 使用‘*’使用指针
  5. 数组和指针关系密切:数组标志符就是指针
  6. 指针的强转只改变类型,不改变值
  7. 指针也可以指向函数

内存越界和缓存区溢出

对指针的操作不会有任何的边界检查,因此会操作一些溢出问题

溢出会有什么影响?

譬如数组,C中的gets(char[])就会有溢出问题
首先,数组是存储在栈上的
那么数组越界操作会直接导致修改了栈上的其他数据
更严重的影响是:修改栈中返回地址,指向一段被越界操作篡改了的攻击代码,也就是所谓的“病毒”和“蠕虫”

如何预防?

修改返回地址需要知道当前字符串存放的栈地址
这在以往的机器上都是固定的,因此可以模拟出攻击代码

由此一个预防的方法是栈随机化
每次运行程序都有一个随机的栈地址
32位计算机栈随机范围是2^23
64位计算机栈随机范围是2^32

但是,与此同时,又有一个更暴力的攻击方式,对返回地址进个遍历修改
在实际的攻击代码之前插入一段‘nop指令’
这个指令的作用是:对程序计数器加一,没有其他的效果
这样,运用暴力方法,最终能执行到攻击代码

第二种预防方式解决了这个问题:
C中是无法防止对数组越界写的,但是可以检查出是否越界
在缓冲区之前存储一个特殊值(金丝雀值)
在离开过程前检查这个值,如果发现该值被修改
则程序异常终止

现在的C大部分都默认采用后者进行保护

第三种方案是区别对待内存上的区域:读、写和可执行
JAVA等语言采用的就是这种方式

支持变长的帧栈

如何理解变长的栈帧?

大部分代码是在编译之后就能确定需要分配多少栈帧空间
但是也有特殊的情况
譬如alloca函数,可以在栈上分配任意字节的数量
又譬如变长数组

如何实现变长数组的内存分配?

使用subq分配栈帧空间时 无法使用立即数 ,而是需要通过参数计算出来。

浮点代码

如何存储和访问浮点数?
浮点数作为参数和返回的规则?
对浮点数据操作的指令?
浮点数和整数之间的转换?
浮点数之间的转换?

本文标题:03 机器级别的表示

文章作者:Sun

发布时间:2019年05月07日 - 19:05

最后更新:2019年05月16日 - 18:05

原始链接:https://sunyi720.github.io/2019/05/07/系统原理/03 程序的机器级别表示/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。