基础知识
前面一些基础知识就一笔带过了。后面关于内存部分篇幅会大些。
数据格式
大多数GCC生成的汇编代码指令都有一个字符后缀,表明操作数的大小。
访问信息
64位的CPU包含一组16个存储64位值的通用寄存器。%r8~%r15为64位CPU新加入的寄存器。
操作数指示符
立即数(Immediate):常数值
寄存器(Register):表示某个寄存器的内容
存储器(Memory):引用,它会根据计算出来的地址(有效地址)访问某个存储器位置
流程控制
数据传送指令
简单传送
传送零扩展的字节
传送符号扩展的字节
条件码
除整数寄存器外,CPU还维护着一组单个位的条件码寄存器
这些寄存器保存着最近算术或者逻辑操作所产生的一些效果 列如:
CF(carry flag):进位标志 描述了最近操作是否发生了进位(可以检查无符号操作是否溢出)
ZF(zero flag):零标志 最近操作结果为0(列如 逻辑操作 等)
SF(sign flag):符号标志最近操作结果为负数
OF(overflow flag):溢出标志最近操作导致一个补码溢出 补码溢出通常有两种结果(正溢出或者负溢出)
跳转指令
跳转(jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(lable)指明。
汇编对条件分支的优化
在特殊情况下,(分支相互不影响,没有副作用)可以先执行2种不同的条件分支,最后再判断属于那个条件分支返回对应的结果。多级流水线的应用
循环
C语言提供了多种循环结构,即do-While、While和for
switch:不同上面的循环结构的goto,switch内部维护一个跳转表
过程调用
过程(procedures)调用(也就是调用函数)具体在 CPU 和内存中是怎么实现的。理解之后,对于递归会有更加清晰的认识
在过程调用中主要涉及三个重要的方面:
传递控制:包括如何开始执行过程代码,以及如何返回到开始的地方
传递数据:包括过程需要的参数以及过程的返回值
内存管理:如何在过程执行的时候分配内存,以及在返回之后释放内存
栈结构
在 x86-64 中,存在一个运行时栈,它地址是倒过来,从内存最高位向低地址方向增长。而寄存器%rsp记录着栈顶的位置,所以也叫它栈指针。
调用方式
了解了栈的结构之后,我们先通过一个函数调用的例子来具体探索一下过程调用中的一些细节。
// multstore 函数
void multstore (long x, long, y, long *dest)
{
long t = mult2(x, y);
*dest = t;
}
// mult2 函数
long mult2(long a, long b)
{
long s = a * b;
return s;
}
对应的汇编代码为:
0000000000400540 <multstore>:
# x 在 %rdi 中,y 在 %rsi 中,dest 在 %rdx 中
400540: push %rbx # 通过压栈保存 %rbx
400541: mov %rdx, %rbx # 保存 dest
400544: callq 400550 <mult2> # 调用 mult2(x, y)
# t 在 %rax 中
400549: mov %rax, (%rbx) # 结果保存到 dest 中
40054c: pop %rbx # 通过出栈恢复原来的 %rbx
40054d: retq # 返回
0000000000400550 <mult2>:
# a 在 %rdi 中,b 在 %rsi 中
400550: mov %rdi, %rax # 得到 a 的值
400553: imul %rsi, %rax # a * b
# s 在 %rax 中
400557: retq # 返回
可以看到,过程调用是利用栈来进行的,通过 call label 来进行调用(先把返回地址入栈,然后跳转到对应的 label),返回的地址,将是下一条指令的地址,通过 ret 来进行返回(把地址从栈中弹出,然后跳转到对应地址)
过程调用的参数会放在哪里?
如果参数没有超过六个,那么会放在:%rdi, %rsi, %rdx, %rcx, %r8, %r9 中。如果超过了,会在调用方法前在自己的栈帧里储存好这些参数。而返回值会放在 %rax 中。
调用者保存与被调用者保存
函数A调用了函数B,寄存器rbx在函数B中被修改了,逻辑上%rbx内容在调用函数B的前后应该保持一致。解决这个问题有两个策略:
调用者保存:在函数A在调用函数B之前提前保存寄存器%rbx的内容,执行完函数B之后再恢复%rbx的内容,
被调用者保存:函数B在使用寄存器%rbx,先保存寄存器%rbx的值,在函数B返回之前,要恢复寄存器%rbx原来存储的内容。
根据惯例,寄存器%rbx%rbp和%r12~%r15被划分为被调用者保存寄存器,其他寄存器,除了栈指针%rsp,都分类为调用者保存寄存器。
递归
有了前面的的基础,要理解递归就简单很多了,直接上例子
long pcount_r(unsigned long x) {
if (x == 0)
return 0;
else
return (x & 1) + pcount_r(x >> 1);
}
对应的汇编代码为:
pcount_r:
mov $0, %eax
testq %rdi, %rdi
je .L6
push %rbx
movq %rdi, %rbx
andl $1, %ebx
shrq %rdi
call pcount_r
addq %rbx, %rax
popq %rbx
.L6:
rep; ret
实际执行的过程中,会不停进行压栈,直到最后返回,所以递归本身就是一个隐式的栈实现,但是系统一般对于栈的深度有限制(每次一都需要保存当前栈帧的各种数据),所以一般来说会把递归转换成显式栈来进行处理以防溢出。
数据存储
指针与数组的理解
总所周知,数组存储时是空间是连续的,不同的数据类型所需要的字节数是不同的,所以不同类型的数组也是同理:
既然是连续的地址空间,就有很多不同的访问方式,比方对于 int val[5] 来说
引用方式 | 类型 | 值 |
---|---|---|
val[4] | int | 5 |
val | int * | x |
val+1 | int * | x+4 |
&val[2] | int * | x+8 |
val[5] | int | ?? 越界 |
*(val+1) | int | 2 |
val+i | int * | x + 4i |
多维数组
对于多维的数组,基本形式是 T A[R][C],R 是行,C 是列,如果类型 T 占 K 个字节的话,那么数组所需要的内存是 RCK 字节。具体在内存里的排列方式如下:
还有另外一种组合数组的方式,不是连续分配,而是存储每个数组的起始地址。与之前连续分配唯一不同之处在于计算元素位置时候不同行对应不连续的起始地址(可能分散在内存的不同部分)。这两种方式在 C 语言中看起来差不多,但对应的汇编代码则完全不同。
结构体
结构体是 C 语言中非常常用的一种机制,具体在内存中是如何存放的呢?
例如我们有这样一个结构体:
struct rec
{
int a[4];
size_t i;
struct rect *next;
};
那么在内存中的排列是如果我们换一下结构体元素的排列顺序,可能就会出现和我们预想不一样的结果,比如
struct S1
{
char c;
int i[2];
double v;
} *p;
因为需要对齐的缘故,所以具体的排列是这样的:
对齐的原则是,如果数据类型需要 K 个字节,那么地址都必须是 K 的倍数,比方说这里 int 数组 i 需要是 4 的倍数,而 v 则需要是 8 的倍数。
我们会看到使用数据对齐的话内存会白白浪费很多空间,那为什么还要使用数据对齐呢?
其实无论数据是否对齐,x86-64硬件都能正确工作。使用数据对齐是因为内存访问通常来说是 4 或者 8 个字节位单位的,不对齐的话访问起来效率不高。
当然我们可以在结构体中使用不同的排列,尽可能使对齐部分的字节数少些。比如下面不同排列相同结构的2个结构体:
struct S4 {
char c;
int i;
char d;
} *p;
struct S5 {
int i;
char c;
char d;
} *p;
对应的排列是:
这样我们就通过不同的排列,节约了 4 个字节空间,如果这个结构体要被复制很多次,这也是很可观的内存优化。
联合体
一个联合体的总大小等于它最大字段的大小
举个例子
union U1 {
char c;
int i[2];
double v;
} *up;
下图是它的内存布局:
这个联合体的大小为8字节,等于它的最大字段double的长度。
可以看出,因为所有字段都集中在同一个内存空间,相比于结构体,十分节省空间,同时,联合体也非常容易出错。除非我们对内存的操控十分有自信,不然一般不推荐使用。
内存布局
#####内存大小
x86-64的内存地址为64位,但由于硬件的限制只能实际只能使用47位。47位其实足够用了,它可以表示256TB的内存。
最上面是运行时栈,有 8MB 的大小限制,一般用来保存局部变量。可以见到,栈在最上面,也就是说,栈再往上就是另一个程序的内存范围了,这种时候我们就可以通过这种方式修改内存的其他部分了。
然后是堆,动态的内存分配会在这里处理,例如 malloc(), calloc(), new() 等。栈和堆的增长方向相反。
然后是数据(data),指的是静态分配的数据,比如说全局变量,静态变量,常量字符串。最后是共享库等可执行的机器指令,这一部分是只读的。
内存分配的例子
char big_array[1L<<24]; /* 16 MB */
char huge_array[1L<<31]; /* 2 GB */
int global = 0;
int useless() { return 0; }
int main ()
{
void *p1, *p2, *p3, *p4;
int local = 0;
p1 = malloc(1L << 28); /* 256 MB */
p2 = malloc(1L << 8); /* 256 B */
p3 = malloc(1L << 32); /* 4 GB */
p4 = malloc(1L << 8); /* 256 B */
/* Some print statements ... */
}
上面代码在内存中的分配:
我们可以看到,P1和P3两个变量由于占用了较大的内存,被分配到了和栈较接近的上方,这可以看做是对大变量的优化。
缓冲区溢出
所谓缓冲区可以更抽象地理解为一段可读写的内存区域, 缓冲区溢出是指当计算机向缓冲区填充数据时超出了缓冲区本身的容量,溢出的数据覆盖在合法数据上。
黑客能利用缓冲区溢出这个特性,注入恶意代码,使系统无意间运行这个恶意代码,这种行为叫做缓冲区攻击。
如何防护缓冲区攻击
系统提供了三种防御缓冲区攻击的对策
1.栈随机化
为了攻击系统,黑客需要插入代码,也要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针就需要知道这个字符串的栈地址。那我们只要将栈地址在每次运行时都是不同的,黑客就预测不到字符串的栈地址,也就防御了攻击。
2.限制可执行代码区域
就如linux的777权限,将栈限制为可读可写但不可执行,黑客执行不了代码,就算注入代码,也无法攻击系统。
3. 栈破坏检测(canary)
简单来说,就是在超出缓冲区的位置加一个特殊的值,如果发现这个值变化了,那么就知道出问题了。