第三章 程序的机器表示

第三章 程序的机器级表示

代码示例

文件 mstore.c

long mult2(long,long);

void mulstore(long x,long y,long *dest){
    long t = mult2(x,y);
    *dest = t;
}

通过 gcc−Og −S mstore.c 得到源文件对应的汇编代码 mstore.s

mulstore:
	pushq	%rbx
	movq	%rdx, %rbx
	call	mult2@PLT
	movq	%rax, (%rbx)
	popq	%rbx
	ret

通过 gcc -Og -c mstore.c 得到二进制目标文件 mstore.o,再通过反汇编 objdump -d mstore.o >1.s 可以机器指令和汇编代码,代码从左到右依此为:行号,机器指令,汇编代码

0000000000000000 <mulstore>:
   0:	f3 0f 1e fa          	endbr64 
   4:	53                   	push   %rbx
   5:	48 89 d3             	mov    %rdx,%rbx
   8:	e8 00 00 00 00       	callq  d <mulstore+0xd>
   d:	48 89 03             	mov    %rax,(%rbx)
  10:	5b                   	pop    %rbx
  11:	c3                   	retq   

mstore.c 并不能执行,我们补全主函数文件 main.c

# include <stdio.h>

void mulstore(long,long,long *);

int main(int argc,char* argv[])
{
   long d;
   mulstore(2,3,&d);
   printf("2 * 3 = %ld\n",d);
   return 0;
}

long mult2(long a,long b){
   long s = a * b;
   return s;
}

编译:gcc -Og -o prog main.c mstore.c,反汇编:objdump -d prog > prog.asm,其中 mulstore 函数的汇编代码如下:

00000000000011d5 <mulstore>:
    11d5:	f3 0f 1e fa          	endbr64 
    11d9:	53                   	push   %rbx
    11da:	48 89 d3             	mov    %rdx,%rbx
    11dd:	e8 e7 ff ff ff       	callq  11c9 <mult2>
    11e2:	48 89 03             	mov    %rax,(%rbx)
    11e5:	5b                   	pop    %rbx
    11e6:	c3                   	retq   
    11e7:	66 0f 1f 84 00 00 00 	nopw   0x0(%rax,%rax,1)
    11ee:	00 00 

和之前的汇编代码主要两点不同:

  • 调用 mult2 给出了该函数地址 \(0x11c9\)
  • 最后加了 nopw 指令,这个指令对程序没有影响,只是调整该函数的字节数,让其更好存储

注:以上用的汇编格式是 ATT,和 intel 格式不同,如 mov %rdx,%rbx 中在 ATT 中表示复制 rdx 寄存器的内容到 rbx,这和 intel 的顺序正好相反。

数据格式

64 位机器下,x86-64 指令集针对字,字节,双字的指令

c 声明 数据类型 汇编代码后缀 字节
char 字节 b 1
short w 2
int 双字 l 4
long 四字 q 8
char* 四字 q 8
float 单精度 s 4
double 双精度 l 8

如数据转送指令:movb(传送字节),movw(传送字),movl(传送双字)

寄存器与操作数指示符

寄存器

x86-64 的中央处理器包含 16 个 64 位通用寄存器。下面的单个寄存器的 64,32,16,8 位表示。

图片名称

此外 rdx 保存第 3 个参数,r9 寄存器保存第 6 个参数,r10-r15 被调用者保存

操作数指示符

  • $ 后面跟着 c 语言中定义的数据
  • \(R[r_a]\) 表示寄存器 \(r_a\) 的值
  • \(M[addr]\) 表示内存地址 addr
类型 格式 操作数值 名称
立即数 \(\$\text{Imm}\)
|\(\text{Imm}\)
立即数寻址
寄存器 \(r_a\) \(R[r_a]\) 寄存器寻址
存储器 \(\text{Imm}\) \(M[\text{Imm}]\) 绝对寻址
存储器 \(\text{Imm}(r_b)\) \(M[\text{Imm}+R[r_b]]\)
基址 + 偏移量寻址
存储器 \((r_b,r_i)\) \(M[R[r_b]+R[r_i]]\) 变址寻址
存储器 \(\text{Imm}(r_b,r_i)\) \(M[\text{Imm} + R[r_b]+R[r_i]]\) 变址寻址
存储器 \((r_b,s)\)
\(M[s \times R[r_b]]\) 比例变址寻址
存储器 \(\text{Imm}(r_b,s)\) \(M[\text{Imm} + R[r_b]\times s]\) 比例变址寻址
存储器 \(\text{Imm}(r_b,r_i,s)\) \(M[\text{Imm} +R[r_b] + R[r_i]\times s]\) 比例变址寻址

汇编常用指令

mov

作用:将数据从源复制到目的地。根据操作的数据大小,有 movb,movw,movlmovq,分别对应 1,2,4,8 字节。注意:第一个数源操作数,第二个数目的操作数。movs 用符号位填充不足的字节,movz 用 0 填充。

movl $0x01, %eax   
movw %bp, %sp
movsbw S,R   #将做了符号位扩容的字节传送到字

从汇编代码的角度理解 C 语言的指针

long exchange(long *xp,long y){
    long x = *xp;
    *xp = y;
    return x;
}

通过和 main.c 联合编译得到目标文件,再 objdump -d 反汇编,exchange 的汇编代码如下:

mov    (%rdi),%rax    // rax = M[rdi]
mov    %rsi,(%rdi)   // M[rdi] = y
retq  

指针就是放在寄存器里的地址,对寄存器而言它并不知道这是地址还是普通数据,区别在于怎么使用它。

压入,弹出栈数据

栈:先进后出的数据结构。x86-64 中栈是向低地址增长的,故一般是倒过来画,栈指针 %rsp 记录着栈顶元素的地址

pushq S    #四字压栈,效果:寄存器rsp的值减去8,且数S存储于rsp记录的地址位置
popq D     #四字出栈,效果:rsp记录的地址位置的数复制到D,寄存器rsp的值加上8

算术和逻辑操作

图片名称

leaq 加载有效地址到寄存器,看下面的例子

long scale(long x,long y,long z){
    long t = x + 4*y + 12*z;
    return t;
}

x%rdi,y%rsi,z 在 %rdx

lea    (%rdi,%rsi,4),%rax   # x + 4y
lea    (%rdx,%rdx,2),%rdx   # rdx *= 3 --> 3z
lea    (%rax,%rdx,4),%rax   # x + 4y + 4*(3z)
retq   

控制码

CPU 中有一组条件码寄存器,值为 1 时寄存器含义如下:

  • CF:进位标志,检查无符号数操作溢出
  • ZF:零标志,结果为 0
  • SF:符号标志,结果为负数
  • OF:溢出标志,补码的正溢出与负溢出

两个改变标志位但不改变寄存器的指令:

  • CMP:和 sub 减法指令相同,但不记录结果。
  • TEST:和 AND 指令相同,不记录结果

跳转指令

  • jmp L1:无条件跳转到标签 L1 处
  • jmp *%eax: 跳转到 %eax 对应的值
  • je:相等 ZF=0 就跳转

还有其他的,结合英文单词 above,blow,,less,greater 理解就好了。

过程

运行时的栈

图片名称

大多数过程的栈帧都是定长的,在开始就分配好了,但有些过程则变长的帧。以一个例子看看返回地址是啥

刚进入 getSum 函数时,打印寄存器 %rsp 的值得到返回地址存储的内存地址,进一步发现存储的是 main 函数调用 getSum 处下一条指令的地址。

转移控制

从函数 P 的控制转移到函数 Q,只需要将程序计数器 PC 指针设置为 Q 代码的起始位置,从 Q 返回,处理器必须记录它需要继续执行代码的位置。

  • call Q 将返回地址 A 压入栈,且 PC 设置为 Q 的起始地址

  • 对应的 ret 指令会从栈中弹出地址 A,并将 PC 设置为 A

数据传递

x86-64 中,最多可通过寄存器传递 6 个参数,若函数参数大于 6 个,超出部分就要借用栈来传递。

long proc(long x1,long *x1p,
         int x2,int *x2p,
         short x3,short *x3p,
         char x4,char *x4p ){
      *x1p += x1;
      *x2p += x2;
      *x3p += x3;
      *x4p += x4;
}

其对应的汇编程序

0000000000001149 <proc>:
    1149:	f3 0f 1e fa          	endbr64 
    114d:	48 8b 44 24 10       	mov    0x10(%rsp),%rax   
    1152:	48 01 3e             	add    %rdi,(%rsi)
    1155:	01 11                	add    %edx,(%rcx)
    1157:	66 45 01 01          	add    %r8w,(%r9)
    115b:	8b 54 24 08          	mov    0x8(%rsp),%edx
    115f:	00 10                	add    %dl,(%rax)
    1161:	c3                   	retq   

其中 x1%rdix1p%rsix2%edxx2p%rcxx3%r8wx3p%r9。而 x4%rsp + 0x8 地址处,x4p%rsp+0x10 处。

数据对齐

计算机系统规定,某些数据对象的地址必须是某个值(通常为 2,4,8)的整数倍,从而提高数据读取效率。如处理器从内存中每次取出 8 个字节,地址为 8 的倍数就可保证 double 类型的数据用一个内存操作完成,否则数据可能放在两个 8 字节内存块中,导致需要两次。

struct A{
   int a;
   char b;
   int c;
};

struct A{
   int a;
   char b;
   int c;
}__attribute__ ((packed));

上面的结构,尽管包含 2 个 4 字节的 int 类型和 1 个 1 字节的 char 类型数据,但是通过 sizeof 得到该结构体需要 12 个字节,因为需要按 4 字节以及结构体成员最大字节数对齐,而通 __attribute__ ((packed)) 取消对齐(1 字节对齐),该结构体就变为 9 字节了。

机器级程序中控制和数据结合起来

理解指针

  • 每个指针都有对应的类型,如对象类型是 T,那么指针类型为 T*,指针类型是 C 语言提供的抽象,而不是机器代码的一部分。
  • 每个指针都有一个值,特殊值 NULL(0) 表示指针不指向任何地方
  • & 运算符创建,机器代码中常用 leaq 计算内存引用的地址
  • * 间接引用指针,如 p+i 得到的地址是 \(p+L*i\)
  • 指针转为为另一种类型,不改变值,而只是改变类型(伸缩量 \(L\))
  • 指针也可指向函数,如 int func(int x,int *p) 可通过声明指针 int (*fp)(int,int*); fp = func 实现

内存越界引用和缓冲区溢出

完成实验 attck lab 会对此有更深刻的理解。C 语言不对数组进行任何边界检查,变量和状态信息都存放在震中,对越界的数组进行写操作可能破坏栈中的状态信息导致严重错误。

数据越界

#include <stdio.h>
#include <stdlib.h>

typedef struct 
{ 
    int a[2]; 
    double d; 
} struct_t; 

double fun(int i) 
{ 
    volatile struct_t s; 
    s.d = 3.14; 
    s.a[i] = 1073741824; /* Possibly out of bounds */ 
    return s.d; 
}

int main(int argc,char*argv[]){
    if(argc != 2){
        exit(1);
    }
    int x = atoi(argv[1]);
    printf("%lf\n",fun(x));
    return 0;
}
//执行
 ./main 1
3.140000
 ./main 2
3.140000
 ./main 3
2.000001
 ./main 4
3.140000
 ./main 5
3.140000
 ./main 6
*** stack smashing detected ***: terminated
Aborted (core dumped)
 ./main 8
3.140000
Segmentation fault (core dumped)

缓存区溢出

如分配给 dest 的内存缓冲区可能不够导致状态信息被覆盖

char* gets(char *dest) 
{ 
    int c = getchar(); 
    char *p = dest; 
    while (c != EOF && c != '\n') 
    { 
        *p++ = c;
        c = getchar(); 
    } 
    *p = '\0'; 
    return dest; 
}

保护措施

栈随机化

程序的栈地址被随机化,那么各种状态变量等的地址将很难预测。实现方式:程序开始时,在栈上随机分配一块内存,程序不适用这段空间,但它会导致程序每次执行的栈地址发生变化。缺点:可通过暴力破解该随机化。

栈破坏检测

GCC 中加入了栈保护机制来检查缓冲区越界。思想:通过在栈帧任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值(随机化),在状态寄存器状态和返回函数前,检查这个金丝雀值是否被改变,若是则程序异常终止。可通过 -fno-stack-protector 取消栈保护。

图片名称
posted @ 2022-07-01 09:25  wenchu1995  Views(141)  Comments(0Edit  收藏  举报