目录
3.1程序编码
1、编译器优化等级
编译选项-Og告诉编译器使用会生成符合原始 C代码整体结构的机器代码的优化等级。使用较高级别优化产生的代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。实际中,从得到的程序的性能考虑,较高级别的优化(例如,以选项-01或-02 指定)被认为是较好的选择。
优化级别 | 说明 | 备注 |
-O0 | 关闭所有优化 | 代码空间大,执行效率低 |
-O1 | 基本优化等级 | 编译器在不花费太多编译时间基础上,试图生成更快、更小的代码 |
-O2 | O1的升级版,推荐的优化级别 | 编译器试图提高代码性能,而不会增大体积和占用太多编译时间 |
-O3 | 最危险的优化等级 | 会延长代码编译时间,生成更大体积、更耗内存的二进制文件,大大增加编译失败的几率和不可预知的程序行为,得不偿失 |
-Og | O1基础上,去掉了那些影响调试的优化 | 如果最终是为了调试程序,可以使用这个参数。不过光有这个参数也是不行的,这个参数只是告诉编译器,编译后的代码不要影响调试,但调试信息的生成还是靠 -g 参数的 |
-Os | O2基础上,进一步优化代码尺寸 | 去掉了那些会导致最终可执行程序增大的优化,如果想要更小的可执行程序,可选择这个参数。 |
-Ofast | 优化到破坏标准合规性的点(等效于-O3 -ffast-math ) | 是在 -O3 的基础上,添加了一些非常规优化,这些优化是通过打破一些国际标准(比如一些数学函数的实现标准)来实现的,所以一般不推荐使用该参数。 |
2、汇编代码
汇编代码不区分有符号数和无符号数,不区分各种类型的指针、不区分指针和整数。
3、编译
编译截断 | 指令参数 | 生成的文件后缀 |
预处理 | ||
汇编 | -S | .s(汇编文件) |
编译 | -c | .o(二进制文件) |
链接 | -o |
4、反汇编器
反汇编器:根据机器代码产生一种类似于汇编代码的格式。。在 Linux 系统中,带-d’命令行标志的程序OBJDUMP(表示“object dump”)可以充当这个角色。
linux> objdump -d sum // 反汇编可执行程序 sum
还有一种是使用gdb反汇编调试程序。
gdb sum
disassemble sumstore
x/14xb sumstore //显示函数multstore所处地址开始的14个十六进制
3.2数据格式
Intel用术语“字(word)”表示16位数据类型。因此,称32位数为“双字(double words)”,称64位数为“四字(quad words)”。
大多数 GCC 生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如,数据传送指令有四个变种: movb(传送字节)、movw(传送字)、movl(传送双字)和 movq(传送四字)。后缀1’用来表示双字,因为 32 位数被看成是“长字(longword)”。注意,汇编代码也使用后缀1’来表示 4 字节整数和8 字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
3.3访问信息
1、寄存器
一个x86-64的CPU包含一组16个存储64位值的通用目的寄存器,这些寄存器用来存储整数数据和指针。由于历史原因,这些寄存器的名字和大小都经过演变。使用%r开始的名称获得64位,使用%e开始的名称获得低32位,图中依次往右的名称表示获得低16位和低8位。其中最特别的是栈指针%rsp,用来指明运行时栈的结束位置。
很多指令,复制和生成 1字节、2 字节、4 字节和 8字节值。当这些指令以寄存器作为目标时,对于生成小于8字节结果的指令,寄存器中剩下的字节会怎么样,对此有两条规则:生成1字节和2字节数字的指令会保持剩下的字节不变;生成 4 字节数字的指令会把高位 4 个字节置为 0。
2、操作数类型
操作数类型 | 表现形式 |
立即数 | $0x400,$-533 |
寄存器(表示某个寄存器的内容) | %rax, %r13 |
内存引用(根据有效地址访问某个内存位置) | 如下图 |
有多种不同的寻址模式,允许不同形式的内存引用。
3、数据传送指令mov
mov类指令,将数据从源位置复制到目的位置的指令,由四条指令组成:movb(传送字节)、movw(传送字)、movl(传送双字)、movq(传送四字)。这些指令的寄存器操作数可以是16个寄存器中的任意一个,但是寄存器部分的大小必须与指令最后一个字符(b、w、l、q)指定的大小匹配。
传送指令的两个操作数不能都指向内存位置,将一个值从内存位置复制到另一个内存位置需要两个指令,第一条指令将源值加载到寄存器中,第二条将该寄存器值写入到目的位置。
4、加载有效地址lea
它的指令形式是从内存读数据到寄存器,但实际上根本没有引用内存。目的操作数只能是寄存器类型。
5、算术和逻辑操作
3.4控制
1、条件码
除了整数寄存器,CPU 还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。条件码不能直接设置,只能根据其他指令操作后的结果设置。
CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
ZF:零标志。最近的得出的结果为0。
SF:符号标志。最近的操作得到的结果为负数。
OF:溢出标志。最近的操作导致一个补码溢出--正溢出或夫溢出。
只设置条件码而不改变其他寄存器的的指令有cmp指令和test指令。cmp的指令与sub指令的行为是一样的,如果两个操作数相等,零标志位会被置为1,其他标志可以用来却动两个操作数之间的大小关系。test指令的行为与add指令一样,除了他们只设置条件码而不改变目的寄存器的值。典型的用法是,两个操作数是一样的,例如 test %rax, %rax 用来检查%rax是负数、零、还是正数。
2、访问条件码
set指令根据条件码的某种组合,将一个字节设置为0或1。其作用通常是基于条件码的值将单个寄存器的单个字节设置为0或1 而不改变其他7个字节的值。
一个计算C语言表达式a< b的典型指令序列如下所示,这里 a和b都是long 类型:
movzbl 指令不仅会把eax的高3个字节清零,还会把整个寄存器rax的高4个字节都清零。
3、跳转指令
4、switch语句
switch通过跳转表来更高效的实现,通过case后面的数值来计算表项跳转到代码块。case后面跟有负数的话,可以通过加上一个整数是其从0开始。如果case的情况很少且跨度较大,编译器可能会将其优化成if-else结构。
3.5过程
1、栈相关指令
push src:%rsp减8,将数据放入(%rsp)中。
pop dest:从(%rsp)中读取数据,%rsp加8,将数据存储到寄存器中。
当我们想创建一个新的栈顶元素,就需要先减少栈指针,然后再写入。想读取目前的栈顶元素,读完之后,增加栈指针来释放空间,这里的释放空间仅仅增加栈指针,不做其他操作,原来的值可以被覆盖。
2、过程数据流程
3、运行时栈
x86-64 的栈向低地址方向增长,而栈指针rsp 指向栈顶元素。可以用 pushq 和 popq 指令将数据存人栈中或是从栈中取出。将栈指针减小一个适当的量可以为没有指定初始值的数据在栈上分配空间。类似地,可以通过增加栈指针来释放空间。
当x86-64 过程需要的存储空问超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分称为过程的栈(stack fram)。上图当给出了运行时栈的通用结构,包括把它划分为栈帧。当前正在执行的过程的帧总是在栈顶,当过程 P调用过程Q时,会把返回地址压入栈中,指明当Q返回时,要从 P科序的哪个位置继续执行。我们把这个返回地址当做 P的栈的一部分,因为它存放的是与 P 相关的状态。Q的代码会扩展当前栈的边界,分配它的栈帧所需的空间。在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。大多数过程的栈帧都是定长的,在过程的开始就分配好了。但是有些过程需要变长的帧。通过寄存器,过程 P 可以传递最多 6 个整数值(也就是指针和整数),但是如果Q需要更多的参数,P可以在调用Q之前在自己的栈里存储好这些参数。
4、栈上的局部存储
大多数过程都不需要超出寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括。
- 寄存器不足够存放所有的本地数据
- 对一个局部变量使用地址运算符“。’,因此必须能够为它产生一个地址
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到
5、寄存器中的局部存储空间
寄存器组是唯一被所有过程共享的资源。虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值。为此,x86-64 采用了一组统一的寄存器使用惯例,所有的过程(包括程序库)都必须遵循。
根据惯例,寄存器%rbx、%rbp 和号%r12~%r15 被划分为被调用者保存寄存器。当过程 P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回到 P时与Q被调用时是一样的。过程Q保存一个寄存器的值不变,要么就是根本不去改变它,要么就是把原始值压入栈中,改变寄存器的值,然后在返回前从栈中弹出旧值。压人寄存器的值会在栈帧中创建标号为“保存的寄存器”的一部分。有了这条惯例,P 的代码就能安全地把值存在被调用者保存寄存器中(当然,要先把之前的值保存到栈上),调用 Q,然后继续使用寄存器中的值,不用担心值被破坏。
所有其他的寄存器,除了栈指针%rsp,都分类为调用者保存寄存器。这就意味着任何函数都能修改它们。可以这样来理解“调用者保存”这个名字:过程 P在某个此类寄存器中有局部数据,然后调用过程 Q。因为 Q可以随意修改这个寄存器,所以在调用之前首先保存好这个数据是 P(调用者)的责任。
3.6数据结构
1、结构体struct
将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。
2、数据对齐
- 对齐原则是任何 K 字节的基本对象的地址必须是 K 的倍数。
- 对于包含结构体的代码,编译器可能需要在字段的分配中间插入间隙,以保证每个结构元素都保证对齐要求,而结构体本身对它的起始地址也有一些对齐要求。
- 编译器必须保证任何struct S1* 类型的指针p都满足4字节对齐要求。
- 结构体的末尾也可能需要一些填充,这样数据结构中的每个元素都会满足它的对齐要求。
3、联合体union
允许以多种类型来引用一个对象,用不同的字段来引用相同的内存块,根据最大的元素分配内存。
应用union完成位变换:
typedef union{
float f;
unsigned u;
}bit_float_u
float bit2float(unsigned u){
bit_float_u arg;
arg.u = u;
return arg.f;
}
unsigned float2bit(float f){
bit_float_u arg;
arg.f = f;
return arg.u;
}
应用union区分字节序:
3.7缓冲区溢出
造成缓冲区溢出的情况有:对数组不设置边界检查,而且局部变量、寄存器值和返回地址都保存在栈中。对越界的数组元素进行写操作时会破坏存储在栈中的状态信息,导致程序被破环,发生缓冲区溢出时程序会发生段错误你退出,还有可能以一种错误的状态继续运行。
1、使用GDB调试器
2、造成缓冲区溢出原因
使用不安全的库函数,例如gets()、strcpy()、strcat()、scanf()、fsacnf()、sscanf()。对于程序员来说可以使用更安全的函数,例如fgets()、strncpy()。
/* Get string from stdin */
char *gets(char *dest)
{
// 没有对输入字符长度进行检查,字符串长度可能超过缓冲区长度
int c = getchar();
char *p = dest;
while (c != EOF && c != '\n') {
*p++ = c;
c = getchar();
}
*p = '\0';
return dest;
}
3、对抗缓冲区溢出攻击
蠕虫:可以自己运行并从一个地方繁衍到另一个程序。
病毒:不能自己存活、攻击一个程序并改变程序的行为。
现代编译器和操作系统实现了很多机制避免遭受这样的攻击。
- 栈随机化:栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行同样的代码,它们的栈地址都是不同的。实现的方式是:程序开始时,在栈上分配一段0~n 字节之间的随机人小的空间,例如,使用分配函数 alloca 在上分配指定字节数量的空间。程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化分配的范围” 必须足够大,才能获得足够多的栈地址变化,但是又要足够小,不至于浪费程序人多的空间。
- 栈破坏检测(金丝雀):最近的 GCC版本在产生的代码中加入了一种栈保护者(stack protector)机制来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀(canary)值,如图所示[26,97]。这个金丝雀值,也称为哨兵值(guard value),是在程序每次运行时随机产生的,因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了。如果是的,那么程序异常终止。
- 限制可执行区域代码:之前代码只有两种权限区分:可读、可写。后来AMD在硬件上做了一些工作,实现了三种权限区分:可读、可写、可执行。限制哪些内存区域能够存放可执行代码,消除攻击者向系统中插入可执行代码的能力。