以下为自用笔记,具体可看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
寄存器存放最近运算的状态,主要用来实现一些算逻运算、条件转移、流程控制等等,比如if
,while
- 有浮点寄存器专门存放浮点数
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)
这里也可以看出 CF
和OF
的区别,CF
把操作数当 无符号 数来看待;而OF
,判断 2 个同号的数运算结果是否异号,即溢出(正 + 正 = 负,负 + 负 = 正)。
186 页表 3.10 给出了相关判断指令,例如 cmp
和test
指令。表 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-expr
和else-expr
,然后判断取哪个值。而 conditional jump
需要预判计算 then-expr
还是else-expr
。
当然也不是所有的条件表达式都可以使用cmov
,有条件的。举例如下:
int cread(int *xp) {
return (xp ? *xp : 0);
}
翻译成 cmov
的时候,因为同时计算了 *xp
和0
,若 xp==NULL
的时候,*xp
是没有意义的,会出现 null pointer dereferencing error。
Switch Statements
switch
翻译除了用 if-else-if
形式,更高效还有 jmup table
,jump 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
里,word0
是 d
的低字节,而 word1
是d
的高字节,而 big-endian
刚好相反。
Data Alignment
关于数据对齐,要求数据的地址为必须为 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-64 的Pentium 4 Xeon处理器诞生了。因为之前 Intel 用IA64这个名字来表示 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指令集基于寄存器,和整数操作类似,只是指令不一样罢了。