以下为自用笔记,具体可看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^5-1=31$)

关于乘除运算。有乘除运算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数据类型的长度。假设数组的起始地址为$x_A$,那么标记符A指向数组的首地址,数组下标范围在0到N-1,第$i$个元素的地址为$x_A + L\cdot i$或者A+i

Pointer Arithmetic

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

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]的地址可以这样计算:

$$ x_D + (i*C + j)*L $$

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];
}

同理,元素的地址可以这样计算:
$$ x_A + (i*n+j)*4 $$

因为变长数组大小没法在编译时确定,那么就不有定长数组那样优化了,毕竟那个可以在编译时确定的。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

关于数据对齐,要求数据的地址为必须为$K$(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指令集基于寄存器,和整数操作类似,只是指令不一样罢了。