ROOT
ROOT
文章目录
  1. Program Encodings
  2. Data Formats
  3. Accessing Information
    1. Operand Specifiers
    2. Data Movement Instructions
  4. Arithmetic and Logical Operations
  5. Control
    1. Condition Codes
    2. Jump Instructions and Their Encodings
    3. Translating Conditional Branches
    4. Loops
      1. Do-While Loops
      2. While Loops
      3. For Loops
    5. Conditional Move Instructions
    6. Switch Statements
  6. Procedures
    1. Transferring Control
    2. Register Usage Conventions
    3. Procedure Example
    4. Recursive Procedures
  7. Array Allocation and Access
    1. Pointer Arithmetic
    2. Nested Arrays
    3. Fixed-Size Arrays
    4. Variable-Size Arrays
  8. Heterogeneous Data Structures
    1. Structures
    2. Unions
    3. Data Alignment
  9. Putting It Together: Understanding Pointers
  10. Life in the Real World: Using the gdb Debugger
  11. Out-of-Bounds Memory References and Buffer Overflow
  12. Thwarting Buffer Overflow Attacks
    1. Stack Randomization
    2. Stack Corruption Detection
    3. Limiting Executable Code Regions
  13. x86-64: Extending IA32 to 64 Bits
  14. Machine-Level Representations of Floating-Point Programs

CSAPP 第三章总结:Machine-Level Representation of Programs

以下为自用笔记,具体可看Computer Systems A Programmer’s Perspective

这章主要讲高级语言的汇编形式,如何通过汇编 / 反汇编来分析一个程序。要想深入理解操作系统,必须从底层开始,那就是汇编了。

因为我的机器是 64 位的,可以通过参数 -m32 来编译 32 位的程序,这样结果可和书中的尽可能吻合。建议用 -O1 或者 -O0 参数来汇编,这样使得汇编代码与源代码差别不是很大。

Program Encodings

这里的汇编格式为 ATT,而不是学校教的Intel 格式,最大区别就是指令目标操作数和源操作数相反,还有 ATT 汇编的同一条指令有 好几条 形式,对应于不同操作数数据类型的 长度,后面会说。

汇编code.c

$ gcc -O1 -S -m32 code.c
	.file	"code.c"
	.globl	accum
	.bss
	.align 4
	.type	accum, @object
	.size	accum, 4
accum:
	.zero	4
	.text
	.globl	sum
	.type	sum, @function
....

. 开头的标签主要是给汇编器、链接器使用的。

反汇编:

$ gcc -O1 -c -m32 code.c
$ objdump -d code.o
code.o:     file format elf32-i386
Disassembly of section .text:
00000000 <sum>:
   0:	55                   	push   %ebp
   1:	89 e5                	mov    %esp,%ebp
   3:	83 ec 10             	sub    $0x10,%esp
   6:	e8 fc ff ff ff       	call   7 <sum+0x7>
   b:	05 01 00 00 00       	add    $0x1,%eax
  10:	8b 4d 08             	mov    0x8(%ebp),%ecx
  13:	8b 55 0c             	mov    0xc(%ebp),%edx
  16:	01 ca                	add    %ecx,%edx
  18:	89 55 fc             	mov    %edx,-0x4(%ebp)
  1b:	8b 88 00 00 00 00    	mov    0x0(%eax),%ecx
  21:	8b 55 fc             	mov    -0x4(%ebp),%edx
  24:	01 ca                	add    %ecx,%edx
  26:	89 90 00 00 00 00    	mov    %edx,0x0(%eax)
  2c:	8b 45 fc             	mov    -0x4(%ebp),%eax
  2f:	c9                   	leave
  30:	c3                   	ret

第一列为指令 地址 ,第二列为指令的 二进制编码 形式,第三列为指令的 汇编 形式。

需要注意的是,链接的时候链接器会移动指令地址,还有由于 Intel 系列的是little-endian,高地址存放数据高位,低地址存放数据低位。

  • %eip寄存器为 PC 计数器,指向 下一条 要执行指令的地址。
  • 32 位汇编有 8 个整数寄存器,可以存放指针、数值、临时变量、局部变量、返回值(一般存到 %eax 作为程序返回值)
  • condition code寄存器存放最近运算的状态,主要用来实现一些算逻运算、条件转移、流程控制等等,比如ifwhile
  • 有浮点寄存器专门存放浮点数

Data Formats

167 页给出了数据格式。

最常用的就是 b 表示 1 字节。w表示双字节,因为早期 Intel 使用 word 来表示 16 位 数据类型,由于历史遗留,就这么表示了。l表示 4(long word)字节(8 字节的 double 也用 l 表示)。q表示 64 位(quad word),32 位机器用连续 2 块来实现。

Accessing Information

168 页图 3.2 给出了 8 个寄存器。分别为%eax, %ecx, %edx, %ebx, %esi, %edi, %esp, %ebp

每个寄存器都可以访问其低 16 位内容,例如 %eax%ax表示其低 16 位内容,这么做主要是 Intel 考虑向后兼容(兼容 16 位程序),而前四个寄存器可以访问到 2 个低 8 位内容,也是考虑向后兼容(backward compatibility)。

前 6 个寄存器为通用寄存器,大多数时候可以任意访问,其中前 3 个为 caller-save 寄存器,亦即函数调用的时候可随意使用,而后 3 个为 callee-save 寄存器,意味着函数调用的时候被调函数需要保存 / 恢复其值才能使用。

后 2 个寄存器,%esp为栈指针寄存器,%ebp为帧指针寄存器。

Operand Specifiers

169 页图 3.3 给出了一些操作数形式。

  • 立即数,例如 $123 表示 123 这个数。
  • 寄存器的值,例如%eax
  • 内存访问 Imm(Eb, Ei, s),例如 9(%eax,%edx, 4),表示内存 地址 M[%eax + 4*%edx +9] 的值。

Data Movement Instructions

171 页表 3.4 给出数据移动指令。

  • MOV 指令,有几种形式,例如 movl, movb, movw,其后缀l, b, w 指定了操作数的长度。
  • MOVS/MOVZ 指令,符号扩展 / 零扩展,主要是当操作数长度不一致的时候使用。例如源操作数只有 2 字节,而目标操作数有 4 字节,short(S) -> int(D),因为有符号,就movswl S, D
  • pushl S 指令,将 S 操作数存放到栈中。需要注意的是,栈指针 %esp(栈顶)存放的是 当前 入栈的操作数。栈往低地址分配栈空间。
  • popl D 指令,将栈顶元素弹出,存放到 D 中。需要注意的是,出栈亦即回收空间,栈指针向高地址移动。

这里有个重点,就是指令的目标操作数和源操作数,最多 只能有一个内存访问,例如 movl (%eax), (%edx) 是不允许的。这个我们操作系统田老师解释过了,如果 2 个操作数都是内存取值,那么效率大大减低,涉及多次内存访问,这是很费时的。如要实现上述效果,可以拆成 2 条:

movl (%eax), %ecx
movl %ecx, (%edx)

Arithmetic and Logical Operations

178 页表 3.7 给出了一系列算逻操作。

leal S,D指令,这个比较重要,和 mov 指令类似,主要是用于指针运算。举个例子,movl 9(%eax,%edx, 4), %eax,这个是把内存 地址 M[%eax + 4*%edx +9] 的值存放到 %eax 寄存器;而leal 9(%eax,%edx, 4), %eax,这个则是把%eax + 4*%edx +9 的结果存放到寄存器%eax。显然,前一个是引用内存的值,后一个是指针运算。

位移运算,有算数位移和逻辑位移 2 种,左移右移 2 种,一共 4 种指令。算数左移和逻辑左移的效果是一样的,右边填充 0。而逻辑右移,左边填充 0;算数右移,左边填充符号位。位移限制范围在 0 到 31,所以只考虑操作数低 5 位(亦即

关于乘除运算。有乘除运算 2 种,各自又有符号 / 无符号运算 2 种,一共 4 种指令。然而 2 个 32 位数相乘可能溢出(64 位),这里的乘法指令会将高 32 位存放到 %edx 寄存器,低 32 位存放到 %eax 寄存器;除法 %edx 存放模,%eax存放商。

其他算逻运算可看表,没什么好说的了,同理,每个指令也有好几条形式,对应不同的操作数,例如加法指令add,就有addl, addw,等等。

对了通常用 xor 指令来置零,因为它生成的机器码比 mov 要短。

Control

Condition Codes

前面提到,Condition Codes单位寄存器会存放最近算逻运算相关信息,最常用的有以下几个:

  • CF,主要用来反映运算是否产生进位或借位。可以用来检测 无符号 操作是否溢出。如果运算结果的最高位产生了一个进位或借位,那么,其值为 1,否则其值为 0。
  • ZF,运算结果是否为 0。如果运算结果为 0,则其值为 1,否则其值为 0。
  • SF,用来反映运算结果的符号位,它与运算结果的最高位相同。运算结果为正数时,SF 的值为 0,否则其值为 1。
  • OF,用于反映 补码 运算结果是否溢出。如果运算结果超过当前运算位数所能表示的范围,则称为溢出,OF 的值被置为 1,否则,OF 的值被清为 0。

根据一系列Condition Codes,可以实现条件判断。

举个例子。设 t=a+b,那么上面几个Condition Codes 将会设置如下:

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

这里也可以看出 CFOF的区别,CF把操作数当 无符号 数来看待;而OF,判断 2 个同号的数运算结果是否异号,即溢出(正 + 正 = 负,负 + 负 = 正)。

186 页表 3.10 给出了相关判断指令,例如 cmptest指令。表 3.11 给出了 set 指令。 cmp S,D,根据 D-S 的差,设置 Condition Codes。各大编程语言的cmp 比较函数也应该衍生于此吧。

test S,D,根据 S&D(按位且)的结果,设置 Condition Codes。典型应用就是,testl %eax, %eax,用来判断%eax 的正负、或者是否为 0。

set D,一系列 set 指令,根据 Condition Codes 的混合操作来设置 D。例如sete,当运算结果为 0,设置 D 为 1,这个表 3.11 说的很清楚了。因为不能直接访问Condition Codes,所以可以根据set 指令来间接访问!需要注意的是,这类指令的操作数对象长度为 1 字节,所以 setl 指的是 set less,而不是long word。还有些set 指令会有多个名字,汇编器 / 反汇编器随便选一个名字。

Jump Instructions and Their Encodings

190 页表 3.12 给出了一些列跳转指令。

其中 jmp 无条件跳转,操作数可以是直接跳转(标签),或者间接跳转(加个 * 号)

例如 jmp *%eax 跳转到 %eax 所指的地址,而 jmp *(%eax) 根据 %eax 所指的 内存 地址的值作为跳转地址。

其他 jmp 指令将根据条件进行跳转,例如 je D,当结果等于零(即 ZF=0)的时候,跳转到D 所指的标签。

虽然汇编跳转到的是 标签 ,而链接之后,将会对标签进行 编码。最常用的编码方式是 PC relative 的,即将目标指令的地址与当前指令的地址的差作为偏移量,这种编码通常用 1、2、4 字节,这样做的好处就是移到其他内存部分不需要修改相对地址。另一种常用的编码方式是绝对地址,用 4 字节存储目标的绝对地址。

Translating Conditional Branches

if (test-expr)
	then-statement
else
	else-statement

一般翻译(goto相当于jmp)成:

	t = test-expr;
	if (!t)
		goto false;
	then-statement
	goto done;
false:
	else-statement
done:

为什么翻译成这样呢?因为 if 语句可能没 else 而一定有then,上面这种形式是最简洁的。

书上有例子,可以看看。

Loops

C 语言循环有 do-while, while, for 三种,但是翻译的时候基本都是翻译成 do-while 形式。

Do-While Loops

do-while循环至少执行循环体一次。

do
	body-statement
	while (test-expr);

翻译如下:

loop:
	body-statement
	t = test-expr;
	if (t)
		goto loop;

While Loops

while循环根据条件来判断是否循环。

while (test-expr)
	body-statement

翻译的时候,转化成 do-while 形式:

	if (!test-expr)
		goto done;
	do
		body-statement
	while (test-expr);
done:

最终形式:

	t = test-expr;
	if (!t)
		goto done;
loop:
	body-statement
	t = test-expr;
	if (t)
		goto loop;
done:

For Loops

for循环如下:

for (init-expr; test-expr; update-expr)
	body-statement

也是翻译成 do-while 形式,首先转换成 while 形式,接着改成 do-while 形式就是了:

while形式:

init-expr;
while (test-expr) {
	body-statement
	update-expr;
}

do-while形式:

init-expr;
if (!test-expr)
	goto done;
do {
	body-statement
	update-expr;
} while (test-expr);
done:

最终形式:

	init-expr;
	t = test-expr;
	if (!t)
		goto done;
loop:
	body-statement
	update-expr;
	t = test-expr;
	if (t)
		goto loop;
done:

Conditional Move Instructions

汇编有一种叫做 Conditional Move 指令,亦即 cmov 指令,用于高效实现三目表达式,对于现代处理器来说非常高效。

书上 210 页表 3.17 给出了一系列 cmov 指令。

早在 95 年的时候,就出现了 cmov 指令,根据条件来判断是否复制源操作数到目标操作数。但是这些年来这条指令几乎不使用,因为它不向后兼容,尽管 97 年的时候所有 x86 处理器都支持这条指令了。因为我的机器是 64 位,编译的时候默认还是会使用的,毕竟可以提高处理器流水线性能。而在 32 位机器,如果要使用这条指令的话,需要加上编译参数 -march=i686 才行。

举个例子

cmpl %edx, %ecx
cmovl %ebx, %eax

上面这 2 条指令,主要就是判断 %ecx 的值是否小于 %edx,小于的话,把%ebx 寄存器的值拷贝到 %eax 中。同理 cmovl 的后缀并不代表操作数大小 long word,而是less than 的意思。

之所以 cmov 效率比 conditional jump(if-else 跳转)效率高,是因为处理器需要预判是否需要 jmp,而cmov 不需要预判,大大提高了处理器性能。

举个例子,如下代码

v = test-expr ? then-expr : else-expr;

翻译成 conditional jump 形式:

	if (!test-expr)
		goto false;
		v = true-expr;
		goto done;
false:
	v = else-expr;
done:

翻译成 cmov 形式:

vt = then-expr;
v = else-expr;
t = test-expr;
if (t) v = vt;

仔细对比可发现,cmov同时计算了 then-exprelse-expr,然后判断取哪个值。而 conditional jump 需要预判计算 then-expr 还是else-expr

当然也不是所有的条件表达式都可以使用cmov,有条件的。举例如下:

int cread(int *xp) {
	return (xp ? *xp : 0);
}

翻译成 cmov 的时候,因为同时计算了 *xp0,若 xp==NULL 的时候,*xp是没有意义的,会出现 null pointer dereferencing error。

Switch Statements

switch翻译除了用 if-else-if 形式,更高效还有 jmup tablejump table 是一个数组,存放了一些跳转地址,根据计算条件值作为索引(下标),来进行跳转到相应部分。

书上 214 页给出了一个 jump table 的例子。C 语言的标签地址,可以用 && 来获得,例如 &&label 表示 label 的地址,跳转到 label 可以这么写:goto *&&label

jump table虽然高效,但是也是有条件的,当条件的取值 范围 很小(为数不多),取值 间隔 较小,才会考虑使用jump table,毕竟开销比较大。

Procedures

IA32 位函数调用是通过栈帧来实现的,通过栈来传递参数、存储 / 还原寄存器的值、保存局部变量。分配给一个函数调用的栈空间叫栈帧,其中 %ebp 作为帧指针,指向函数调用的 开始 (其值保存的是调用它的函数的%ebp 的值,当函数返回的时候使得 %ebp 能正确恢复到调用它的函数的 开始 );%esp 作为栈指针,指向函数调用的末尾(220 页图 3.21 给出了栈帧空间结构)。注意栈往低地址分配空间,往高地址回收空间,所以当栈指针自减一个数表示分配空间,自加一个数表示回收空间。

Transferring Control

221 页给出了 3 个指令:call,leave,ret

call的操作数和 jmp 类似,接受一个标签(直接调用)或者地址(用 * 号表示,间接调用)作为调用目标。call一旦执行,会把下一条指令的 地址 pushl 到栈中,然后跳转到被调函数的开始地址,当被调函数返回的时候就能返回到这个地址。222 页图 3.22 给出了这个过程。

ret 指令,将会 popl 返回地址 ,接着跳转到这个地址,亦即返回到调用它的地址继续执行下去。所以正确的用法是,使得栈指针指向 返回地址 (通过多次popl 或者使用 leave),然后调用ret 返回。寄存器 **%eax** 通常作为返回值(指针、int)。

leave 指令,将栈指针 %esp 指向帧指针 %ebp,然后将%ebp 还原。

movl %ebp, %esp		Set stack pointer to beginning of frame
popl %ebp			Restore saved %ebp and set stack ptr to end of caller’s frame

Register Usage Conventions

由于寄存器存储局部变量、中间变量等等,函数调用过程中,难免被调函数也需要使用寄存器,那么有可能会把主掉函数的寄存器抹掉,这时候就会出现冲突,所以需要一系列约定来约束寄存器的使用。

前面提到了,%eax,%edx,%ecx作为 caller-save 寄存器,是可以随意使用的;而 %ebx,%esi,%edi 作为 callee-save 寄存器,被调函数需要保存(保存到栈上)这些值才能使用,返回的时候需要还原。

Procedure Example

224 页给出了程序调用的例子,可以看看,深入理解函数调用的细节。

简而言之,函数首先保存帧指针 %ebp 的值到栈上,然后使得帧指针 %ebp 指向函数的开始处。接着栈指针 %esp 向低地址分配空间,存储局部变量、中间变量等等。当要调用函数的时候,将函数参数存储到 上,然后调用函数。被调函数保存主调函数的 %ebp 指针,调整 %ebp 指针到被调函数开始处,若要使用参数,可以通过 %ebp 指针 +4*i 来获得(高地址已分配内容),被调函数可以通过多次 popl 或者 leave,恢复%ebp 的值,使得 %esp 栈指针指向 返回地址 ret 返回。

所以说被调函数返回一个局部结构体指针,局部数组将无效,就是因为主调函数可能将会分配栈空间而把这些内容给覆盖掉了。

一个函数调用通常为其分配 16 的整数倍空间,所以可能会有些栈空间浪费了,这么做也是考虑到数据对齐。

Recursive Procedures

229 页给出了递归调用的例子,和函数调用大同小异,可以看看。

Array Allocation and Access

C 语言数组翻译为汇编代码比较直接,毕竟数组名也代表了数组的首地址,而数组又是连续分配的空间。

声明定义一个数组,

T A[N];

将会在内存中连续分配 L*N 字节空间,这里的 L 代表 T 数据类型的长度。假设数组的起始地址为 ,那么标记符A 指向数组的首地址,数组下标范围在 0 到 N-1,第 个元素的地址为 或者A+i

Pointer Arithmetic

指针也可以做些简单的加减运算,而 &*分别可以取一个变量的地址和解引用。例如访问数组 E[i] 的值(int),也就是对指针 解引用:

movl (%edx,%ecx,4),%eax

Nested Arrays

二维数组可以看作是一维数组,其类型为一维数组,本质上多维数组和一维数组没什么区别,都是连续分配。

例如

int A[5][3];

相当于:

typedef int row3_t[3];
row3_t A[5];

声明如下数组:

T D[R][C];

元素 D[i][j] 的地址可以这样计算:

Fixed-Size Arrays

固定大小的数组,翻译成汇编形式通常会对其进行优化,书上 239 页给了一个计算 2 个定长矩阵的乘积的汇编优化。

Variable-Size Arrays

早些年 C 语言只支持固定大小的数组(编译时确定),那时候对于变量大小的数组(不确定的),需要使用 malloc 这类系统调用来实现。而现在支持变量大小的数组了,因为变量已经指定了数组的大小,那么仍然可以根据变量的值计算出地址的。

int var_ele(int n, int A[n][n], int i, int j) {
	return A[i][j];
}

同理,元素的地址可以这样计算:

因为变长数组大小没法在编译时确定,那么就不有定长数组那样优化了,毕竟那个可以在编译时确定的。240 页同样给出了计算 2 个变长矩阵的乘积,很明显没有定长那样的优化了。这个例子也介绍了 register spilling 的情况,就是当寄存器不够了,会考虑把那些只读的局部变量、中间变量存储至内存(栈)上。

Heterogeneous Data Structures

关于结构体 struct,也是在内存上分配一块连续的空间。而union 共用体则多种数据类型共同使用一块空间,取最大的数据类型作为总大小。

Structures

结构体指针指向结构体的第一个字节地址。

书上 243 页给出了访问结构体的汇编实现。虽然 C 语言访问结构体成员变量,使用名字就可以了,但是汇编形式是通过成员变量在数组首地址的偏移来定位的。

Unions

共用体总大小为其最大数据类型成员的大小。虽然共用体省空间,但是容易出 bug,因为它的成员变量是互斥访问的。

有时候还要考虑字节序问题,例如:

union {
	double d;
	unsigned u[2];
} temp;
temp.u[0] = word0;
temp.u[1] = word1;
return temp.d;

这在 little-endian 里,word0d 的低字节,而 word1d的高字节,而 big-endian 刚好相反。

Data Alignment

关于数据对齐,要求数据的地址为必须为 (2,4,8) 的整数倍。这么做主要是简化了处理器与内存的硬件接口设计,从而提高了性能。举个例子,假设处理器每次从内存中读取 8 个字节,那么得到地址必然是 8 的整数倍,假如 double 地址都是按 8 的倍数进行对齐,那么只要读取一次内存就可以了,不然的话,需要读取 2 次内存,然后拼接起来。

虽然 IA32 在不对齐的情况下也能正常工作,Intel还是建议内存对齐可以提高系统的性能。Linux是这么做的,当数据类型是 2 字节,那么地址必须按 2 的整数倍 对齐;而更大(int, int*, float, double)的数据类型,地址按照 4 的整数倍 进行对齐。这也意味着 short(*2) 的地址最后一位都是 0,类似地,int的地址最后两位都是 0(*4)。

编译器通常会放一些记号要求对其,例如下面汇编代码:

.align 4

确保了后面的数据地址为 4 的整数倍。

一些库函数,例如malloc,也会返回一个内存对齐的指针。

结构体也会插入一些空隙,以确保满足内存对齐的要求,250 页给出了一些例子。

结构体还会在末尾插入一些空隙,以确保内存对齐的要求。例如

struct S2 {
	int i;
	int j;
	char c;
};

看似对齐了,假设该结构体大小为 9 字节,那么当声明该类型的结构体数组时,i(9 的整数倍),j就不对齐了。所以该结构体大小为 12 字节,在末尾填充了 3 字节空白。

Putting It Together: Understanding Pointers

理解指针,其实很好理解,指针不就是变量的地址么,然后可以对变量取地址 &、解引用* 等等操作。

需要注意的是指针有类型,而 void* 类型指针是通用类型,malloc就会返回一个通用类型的指针,需要自己类型转换。机器语言其实是没有指针这个类型的,只是 C 语言的一个抽象罢了。

NULL指针不指向任何变量。

指针转换类型需要注意优先级。例如 char *p(int *)p + 7(int *)(p+7)是不一样的,前者计算p+28,后者计算p+7

指针可以指向函数,即函数指针。

Life in the Real World: Using the gdb Debugger

这节讲了 GDB 的常用参数,需要的时候可以查书,255 页。

Out-of-Bounds Memory References and Buffer Overflow

256 页这节给了一个例子,讲关于缓冲区溢出的问题,用了一个非常严重的 gets 函数,当输入的字符串长度大于缓冲区(定义的数组)大小时,将有可能覆盖(破坏)掉 %ebp 保存的帧指针,甚至覆盖掉返回地址!更有可能覆盖掉主调函数的内容!

虽然 C 语言代码看不出什么问题,但是这是一个非常严重的漏洞。所以现在编译器都会警告使用 gets 调用,可以考虑用fgets,因为它指定了缓冲区的大小,从而避免缓冲区溢出。不过,很多库函数都有这个问题,例如strcpy,strcat,sprintf,它们都不考虑缓冲区大小!从而导致缓冲区溢出!

前面提到有可能覆盖掉返回地址,那么就有人利用这个漏洞进行攻击,做一些不应该做的事情。比如把恶意代码注入内存,然后通过缓冲区溢出覆盖返回地址到恶意代码地址,那么当函数 ret 的时候,不是返回主调函数,而是执行恶意代码了!

书上举了一个 1988 年的网络蠕虫,通过互联网传播,缓冲区溢出导致被非法入侵。所以编程的时候需要注意对外接口应该刀枪不入。

Thwarting Buffer Overflow Attacks

这节讲了操作系统的 3 个手段用于防止缓冲区溢出攻击。

Stack Randomization

这种方式主要就是,每次程序运行的时候,栈变量的地址是不确定(随机,变化范围非常大)的。这样就能防止恶意代码注入系统了,因为恶意代码的地址也是不确定的了 - -

早些年,因为栈变量基本都是确定(哪怕不同机器,只要操作系统相同)的,那么非常容易受到攻击,例如一个网络服务器程序,攻击者攻破了,很容易将它传播到其他机器(例如服务器)上面。这个现象也叫做security monoculture

栈随机化也是address-space layout randomization(简称 ASLR)的一类,主要就是每次运行,程序的不同部分,例如代码块、库代码、栈、全局变量、堆数据加载到不同的内存区域。

然而随机化也不是最安全的,攻击者仍可以爆破,重复攻击。通常在恶意代码前加入一系列 nop 指令(这个指令啥都不做,除了 PC 会 +1),只要程序跳到这些nop 指令,那么就会滑到恶意代码(执行)里。这个术语叫做nop sled

Stack Corruption Detection

这个方式可以检测出缓冲区是否溢出。主要实现就是在汇编代码的缓冲区上面放一个 canary 值(存放到栈上)。263 页图 3.3 给出了 canary 在栈的位置。刚开始设置 canary 的值为随机(不容易被攻击)的,然后保存。函数返回前,检查一下 canary 的值是否变化,变化了就说明缓冲区溢出了,报错。最新版本的 gcc 会自动检测函数是否会产生缓冲区溢出,然后插入canary

具体可看 263 页例子。

Limiting Executable Code Regions

这个机制主要是限制代码的区域是否可执行、可读、可写。编译器生成的代码区域应该可执行,而其他区域应该不能执行。

由于历史原因,x86结构用 1 个比特位来判断是否可读和可执行。然而栈必须可读可写,那么也意味着可执行。如果要控制是否可执行,效率非常低。

AMD 在 64 位系统引入了 NX 位,来代表不可执行,很好地解决了这个问题,后来 Intel 也支持了,从而可以通过硬件判断是否可执行,提高了效率。

上面这三种机制,大大提高了系统的安全性。

x86-64: Extending IA32 to 64 Bits

这一节主要讲 IA32 位汇编衍生到 64 位,没怎么细看。

06 年的时候 32 位处理器使用的就很广泛了,然而从 1985 年刚从 16 为衍生出的 32 位 i386 微处理器,后来一系列处理器(i486,i586,i686)增加了很多特性,但是 gcc 默认都不使用这些特性,主要还是为了考虑向后兼容,比如前面提到的 cmov 指令,在 64 位系统才会默认使用。

32 位由于内存有限,很多应用都无法满足,比如大数据、数据库应用,都因为内存受限在编程上面遇到很多麻烦,这类应用需要 out-of-core 算法来实现,就是将内存中的数据暂存到硬盘上面存储。

最早引出 64 位处理器是在 92 年的由 Digital Equipment Corporation 引出的 Alpha 处理器,当时主要面对的是高端机器,Intel没怎么重视。

后来 Intel 第一次引出了 64 位处理器 Itanium 系列,基于全新的 IA64 指令集,没考虑向后兼容。IA64指令集可以将多条指令放到一块存储器中,可以提高机器的并行效率,但是实现起来很难,最后也没达到理想的性能。不过它可以在兼容模式下运行 32 位程序,但是性能非常糟糕,还没 32 位处理器的快,又贵。

紧接着 Intel 的对手 AMD 抓住了这个机会,03 年它引出了基于 x86-64 指令集的 64 位处理器,可以很好的 向后兼容 ,性能也非常好,从而也抓住了计算机高端市场,后来它把这个指令集改名为AMD64,最后还是x86-64 这个名字比较流行。

Intel意识到了从 IA32 衍生到 IA64 位是行不通的,所以后来 04 年也开始支持 x86-64Pentium 4 Xeon处理器诞生了。因为之前 IntelIA64这个名字来表示 Itanium,遇到了取名困难,最后,它们把x86-64 取名为IA32-EM64T

在编译器这边,gcc 一直保持 i386 的兼容性而没考虑用新特性(除非命令行参数指定)。直到 x86-64 出现了,gcc 才放弃向后兼容,开始挖掘这些新特性来提高处理器的运行性能。

简要说下 x86-64 的特点:

  • 指针、整数 64 位长度,支持 8,16,32 和 64 位数据类型
  • 通用寄存器从 8 个扩展到 16 个,由于寄存器增多了,很多局部变量、中间变量尽可能使用寄存器存储;函数参数(最多 6 个)也开始优先利用寄存器来传递,而不是利用栈空间了,大大减少了栈空间使用。
  • 尽可能使用 cmov 来代替 conditional 操作
  • 浮点数运算有专门的寄存器,使用专用的 SSEv2 指令集,而不像 IA32 基于栈来实现
  • 很多函数基本不用栈帧了,只有当寄存器无法保存所有局部变量的时候,才考虑使用栈帧。
  • 没有帧指针了,使用栈指针来定位变量。很多函数一开始就分配了它们所要的栈空间大小,栈指针 固定 位置,所以 %ebp 没必要存在了。在调用函数的时候,主调函数会 pushq 返回地址 到栈上,被调函数通过 ret 返回的时候,将 返回地址 popq 了,从而主调函数的栈指针位置不变。

Machine-Level Representations of Floating-Point Programs

浮点数实现有多种。其中一种是x87,至今仍在使用;另一种是SSE,为了支持多媒体而添加的。

x87是协处理器,有自己的寄存器和指令集,基于栈模型,专门做浮点运算。

SSE指令集基于寄存器,和整数操作类似,只是指令不一样罢了。

支持一下
扫一扫,支持Netcan
  • 微信扫一扫
  • 支付宝扫一扫