第三章 程序的机器表示
第三章 程序的机器级表示
代码示例
文件 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
,movl
和 movq
,分别对应 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
在 %rdi
,x1p
在 %rsi
,x2
在 %edx
,x2p
在 %rcx
,x3
在 %r8w
,x3p
在 %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
取消栈保护。