美文网首页
程序的机器级表示

程序的机器级表示

作者: 刚子来简书啦 | 来源:发表于2020-09-11 09:25 被阅读0次

    计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理存储器、读写存储设备上的数据,以及利用网络通信。编译器基于变成语言的原则、目标机器的指令集和操作系统遵循的规则,经过一系列的阶段产生机器代码。GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后GCC调用汇编器和链接器,从而根据汇编代码生成可执行的机器代码。

    Intel系列有好几个名字,包括IA32,也就是“Intel 32 位体系结构”(Intel Architecure 32-bit),以及后来的Intel64,即IA32的64位扩展,也称为x86-64,最常用的名字是“x86”,用它指代整个系列。

    计算是机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要。第一种是机器级程序的格式和行为,定义为指令集体系结构(Instruction set architecture,ISA),它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA,将程序的行为描述成好像每条指令是按顺序执行的,一条指令结束后,下一条指令开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行完全一致。第二种抽象是,机器级程序使用的存储器地址是虚拟地址,提供的存储器模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来进行操作的。

    int accum=0;
    
    int sum(int x, int y) {
        int t = x + y;
        accum++;
        return t;
    }
    

    在命令行上使用“-S”选项,就能得到C语言编译器产生的汇编代码,产生一个汇编文件sum.s。

    $ gcc -O1 -S sum.c
    

    编译选项-O1告诉编译器使用第一级优化。通常,提高优化级别会使最终程序运行得更快,但是编译时间可能会变长,用调试工具对代码进行调试会更困难。

    sum.s文件内容如下:

        .section    __TEXT,__text,regular,pure_instructions
        .build_version macos, 10, 15    sdk_version 10, 15
        .globl  _sum                    ## -- Begin function sum
        .p2align    4, 0x90
    _sum:                                   ## @sum
        .cfi_startproc
    ## %bb.0:
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset %rbp, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register %rbp
                                            ## kill: def $esi killed $esi def $rsi
                                            ## kill: def $edi killed $edi def $rdi
        leal    (%rdi,%rsi), %eax
        incl    _accum(%rip)
        popq    %rbp
        retq
        .cfi_endproc
                                            ## -- End function
        .globl  _accum                  ## @accum
    .zerofill __DATA,__common,_accum,4,2
    
    .subsections_via_symbols
    

    GCC产生的汇编代码对我们来说有点难懂。一方面,它包含一些我们不需要关心的信息;另一方面,它不提供任何程序的描述或它是如何工作的描述。所有以“.”开头的行都是汇编器和链接器的命令,我们在解读时通常可以忽略这些行,专注到机器指令上来。

    再进一步,在命令行上使用“-c”选项,GCC会编译并汇编该代码:

    $ gcc -O1 -c sum.c
    

    这就会产生目标代码文件sum.o,它是二进制格式,不能直接以文本形式查看。在Linux系统中,可以通过xxd来查看字节编码。

    $ xxd sum.o
    00000000: cffa edfe 0700 0001 0300 0000 0100 0000  ................
    00000010: 0400 0000 0802 0000 0020 0000 0000 0000  ......... ......
    00000020: 1900 0000 8801 0000 0000 0000 0000 0000  ................
    00000030: 0000 0000 0000 0000 0000 0000 0000 0000  ................
    00000040: 7400 0000 0000 0000 2802 0000 0000 0000  t.......(.......
    00000050: 7000 0000 0000 0000 0700 0000 0700 0000  p...............
    00000060: 0400 0000 0000 0000 5f5f 7465 7874 0000  ........__text..
    00000070: 0000 0000 0000 0000 5f5f 5445 5854 0000  ........__TEXT..
    00000080: 0000 0000 0000 0000 0000 0000 0000 0000  ................
    00000090: 0f00 0000 0000 0000 2802 0000 0400 0000  ........(.......
    000000a0: 9802 0000 0100 0000 0004 0080 0000 0000  ................
    000000b0: 0000 0000 0000 0000 5f5f 636f 6d6d 6f6e  ........__common
    000000c0: 0000 0000 0000 0000 5f5f 4441 5441 0000  ........__DATA..
    000000d0: 0000 0000 0000 0000 7000 0000 0000 0000  ........p.......
    000000e0: 0400 0000 0000 0000 0000 0000 0200 0000  ................
    000000f0: 0000 0000 0000 0000 0100 0000 0000 0000  ................
    00000100: 0000 0000 0000 0000 5f5f 636f 6d70 6163  ........__compac
    00000110: 745f 756e 7769 6e64 5f5f 4c44 0000 0000  t_unwind__LD....
    00000120: 0000 0000 0000 0000 1000 0000 0000 0000  ................
    00000130: 2000 0000 0000 0000 3802 0000 0300 0000   .......8.......
    00000140: a002 0000 0100 0000 0000 0002 0000 0000  ................
    00000150: 0000 0000 0000 0000 5f5f 6568 5f66 7261  ........__eh_fra
    00000160: 6d65 0000 0000 0000 5f5f 5445 5854 0000  me......__TEXT..
    00000170: 0000 0000 0000 0000 3000 0000 0000 0000  ........0.......
    00000180: 4000 0000 0000 0000 5802 0000 0300 0000  @.......X.......
    00000190: 0000 0000 0000 0000 0b00 0068 0000 0000  ...........h....
    000001a0: 0000 0000 0000 0000 3200 0000 1800 0000  ........2.......
    000001b0: 0100 0000 000f 0a00 000f 0a00 0000 0000  ................
    000001c0: 0200 0000 1800 0000 a802 0000 0200 0000  ................
    000001d0: c802 0000 1000 0000 0b00 0000 5000 0000  ............P...
    000001e0: 0000 0000 0000 0000 0000 0000 0200 0000  ................
    000001f0: 0200 0000 0000 0000 0000 0000 0000 0000  ................
    00000200: 0000 0000 0000 0000 0000 0000 0000 0000  ................
    00000210: 0000 0000 0000 0000 0000 0000 0000 0000  ................
    00000220: 0000 0000 0000 0000 5548 89e5 8d04 37ff  ........UH....7.
    00000230: 0500 0000 005d c300 0000 0000 0000 0000  .....]..........
    00000240: 0f00 0000 0000 0001 0000 0000 0000 0000  ................
    00000250: 0000 0000 0000 0000 1400 0000 0000 0000  ................
    00000260: 017a 5200 0178 1001 100c 0708 9001 0000  .zR..x..........
    00000270: 2400 0000 1c00 0000 b0ff ffff ffff ffff  $...............
    00000280: 0f00 0000 0000 0000 0041 0e10 8602 430d  .........A....C.
    00000290: 0600 0000 0000 0000 0900 0000 0000 001d  ................
    000002a0: 0000 0000 0100 0006 0600 0000 0f02 0000  ................
    000002b0: 7000 0000 0000 0000 0100 0000 0f01 0000  p...............
    000002c0: 0000 0000 0000 0000 005f 7375 6d00 5f61  ........._sum._a
    000002d0: 6363 756d 0000 0000                      ccum....
    
    

    xxd默认会以十六进制的形式来进行显示,两个字节为一组。可以通过指定 -b 选项来显示二进制,-g n 选项来指定每组显示n个字节的编码。

    要查看目标代码文件的所代表的内容,最有价值的是反汇编器(disassembler)。这些程序根据目标代码产生一种类似于汇编代码的格式。在Linux系统中,带“-d”命令行标志的程序objdump可以充当这个角色。

    $ objdump -d sum.o
    sum.o:  file format Mach-O 64-bit x86-64
    
    Disassembly of section __TEXT,__text:
    _sum:
           0:   55  pushq   %rbp
           1:   48 89 e5    movq    %rsp, %rbp
           4:   8d 04 37    leal    (%rdi,%rsi), %eax
           7:   ff 05 00 00 00 00   incl    (%rip)
           d:   5d  popq    %rbp
           e:   c3  retq
    

    objdump -d 可以直接作用于可执行文件,用于反汇编出文件代码,比如hello的可执行文件。

    数据格式

    由于是从16位体系结构扩展成32位的,Intel用术语“字”(word)表示16位数据类型。因此,称32位数为“双字”(double words),称64位数为“四字”(quad words)。

    C声明 Intel数据类型 汇编代码后缀 大小(字节)
    char 字节 b 1
    short w 2
    int 双字 l 4
    long int 双字 l 4
    long long int 4
    char * 双字 l 4
    float 单精度 s 4
    double 双精度 l 8
    long double 扩展精度 t 10/12

    上表是C语言数据类型在IA32中的大小。IA32不支持64位整数运算。编译带有 long long 数据的代码,需要产生一些操作序列,以32位块为单位执行运算。

    大多数GCC生成的汇编代码指令都有一个字符后缀,表明操作数的大小。例如,数据传送指令有三个变种:movb(传送字节)、movew(传送字)、movel(传送双字)。后缀 “l” 用来表示双字,因为将32位数看成是“长字”(long word),这是由于沿用了16位字为标准那个时代的习惯。汇编代码也使用后缀 “l” 来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。

    一个IA32中央处理单元(CPU)包含一组8个存储32位值的寄存器。这些寄存器用来存储整数数据和指针。这8个寄存器的名字都以 %e 开头,不过它们都另有特殊的名字。在大多数情况,前6个寄存器都可以看成通用寄存器,对它们的使用没有限制。最后两个寄存器(%ebp和%esp)保存着指向程序栈中重要位置的指针,只有根据栈管理的标准才能修改这两个寄存器中的值。

    IA32的整数寄存器

    所有8个寄存器都可以作为16位(字)或32位(双字)来访问。字节操作指令可以独立地读或者写前4个寄存器的2个低位字节。当一条字节指令更新这些单字节“寄存器元素”中的一个时,该寄存器余下的3个字节不会改变。类似地,字操作指令可以读或者写每个寄存器的低16位。这个特性源自IA32从16位微处理器演化而来的这个传统,当对大小指示符为short的整数运算时,也会用到这个特性。

    大多数指令有一个或多个操作数,指示出执行一个操作中要引用的源数据值,以及放置结果的目标位置。源数据值可以以常数形式给出,或是从寄存器或存储器中读出。结果可以存放在寄存器或存储器中。各种不同的操作数的可能性被分为三种类型:立即数、寄存器和存储器引用。

    立即数(immediate)也就是常数值。在ATT格式的汇编代码中,立即数的书写方式是“$”后面跟一个用标准C表示法表示的整数,比如$-577或$0x1F。任何能放进一个32位的字里的数值都可以用作立即数,不过汇编器在可能时会使用一个或两个字节的编码。
    寄存器(register)表示某个寄存器的内容。我们用符号E_a来表示任意寄存器a,用引用R[E_a]来表示它的值,这是将寄存器集合看成一个数组R,用寄存器标识符作为索引。
    存储器(memory)引用会根据计算出来的有效地址访问某个存储器位置。因为将存储器看成一个很大的字节数组,我们用符号M_b[Addr]表示对存储在存储器中从地址Addr开始的b个字节的引用。为了简便,我们通常省去下方的b

    有多种不同的寻址模式,允许不同形式的存储器引用。表中底部用语法 Imm(E_b,E_i,s) 表示的是最常用的形式。这样的引用由四个部分组成:一个立即数偏移 Imm,一个基址寄存器 E_b,一个变址寄存器 E_i 和一个比例因子 s,这里 s 必须是 1、2、4或者8。然后,有效地址被计算为 Imm+R[E_b]+R[E_i]\cdot s。引用数组元素时,会用到这种通用形式。

    类型 格式 操作数值 名称
    立即数 $Imm Imm 立即数寻址
    寄存器 E_a R[E_a] 寄存器寻址
    存储器 Imm M[Imm] 绝对寻址
    存储器 (E_a) M[R[E_a]] 间接寻址
    存储器 Imm(E_b) M[Imm+R[E_b]] (基址+偏移量)寻址
    存储器 (E_b,E_i) M[R[E_b]+R[E_i]] 变址寻址
    存储器 Imm(E_b,E_i) M[Imm+R[E_b]+R[E_i]] 变址寻址
    存储器 (,E_i,s) M[R[E_i]\cdot s] 比例变址寻址
    存储器 Imm(,E_i,s) M[Imm+R[E_i]\cdot s] 比例变址寻址
    存储器 (E_b,E_i,s) M[R[E_b]+R[E_i]\cdot s] 比例变址寻址
    存储器 $Imm(E_b,E_i,s) M[Imm+R[E_b]+R[E_i]\cdot s] 比例变址寻址
    数据传送指令

    以下所有指令表中的S代表Source,即源数据,D代表Destination,即结果数据,或者单独使用时,仅仅是作为一个整数的代数符号。

    指令 效果 描述
    MOV S,D D \leftarrow S 传送
    movb 传送字节
    movw 传送字
    movl 传送双字
    MOVS S,D D \leftarrow 符号扩展(S) 传送符号扩展的字节
    movsbw 将做了符号扩展的字节传送到字
    movsbl 将做了符号扩展的字节传送到双字
    movswl 将做了符号扩展的字传送到双字
    MOVZ S,D D \leftarrow 零扩展(S) 传送零扩展的字节
    movzbw 将做了零扩展的字节传送到字
    movzbl 将做了零扩展的字节传送到双字
    movzwl 将做了零扩展的字传送到双字
    pushl S R[\text%esp] \leftarrow R[\text%esp]-4;
    M[R[\text%esp]] \leftarrow S
    将双字压栈
    popl D D \leftarrow M[R[\text%esp];
    R[\text%esp] \leftarrow R[\text%esp]+4;
    将双字出栈

    栈可以实现为一个数组,总是从数组的一端插入和删除元素,这一端称为栈顶。在IA32中,程序栈存放在存储器中某个区域,并且栈是从高地址向低地址方向增长的;所以压栈是减小栈指针(寄存器%esp)的值,并将数据存放在存储器中,而出栈是从存储器中读,并增加栈指针的值。

    算术和逻辑操作
    指令 效果 描述
    leal S,D D \leftarrow \&S 加载有效地址
    INC D D \leftarrow D+1 加1
    DEC D D \leftarrow D-1 减1
    NEG D D \leftarrow -D 取负
    NOT D D \leftarrow ~D 取补
    ADD S,D D \leftarrow D+S
    SUB S,D D \leftarrow D-S
    IMUL S,D D \leftarrow D*S
    XOR S,D D \leftarrow D \text^ S 异或
    OR S,D D \leftarrow D | S
    AND S,D D \leftarrow D \& S
    SAL k,D D \leftarrow D<<k 左移
    SHL k,D D \leftarrow D<<k 左移(等同于SAL)
    SAR k,D D \leftarrow D>>_Ak 算术右移
    SHR k,D D \leftarrow D>>_Lk 逻辑右移

    加载有效地址(load effective address)指令 leal 实际上是movl的变形,它的指令形式是从存储器读数据到寄存器,但实际上它根本就没有引用存储器。它的第一个操作数看上去是一个存储器引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。这条指令可以为后面的存储器引用产生指针。另外,它还可以简洁地描述普通的算术操作。例如,如果寄存器%edx的值为x,那么指令 leal 7(%edx,%edx,4),%eax 将设置寄存器%eax的值为5x+7。(采用寻址规则,7+R[E_d]+R[E_d] \cdot 4=7+x+x\cdot4=5x+7

    一元操作只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是一个存储器位置。

    二元操作的第二个操作数既是源又是目的。第一个操作数可以是立即数、寄存器或是存储器位置,第二个操作数可以是寄存器或是存储器位置。不过,同movl指令一样,两个操作数不能同时是存储器位置。

    移位操作,先给出移位量,然后第二项给出的是要移位的位数。移位量是单个字节编码,因为只允许进行0到31位的移位。移位量可以是一个立即数,或者放在单字节寄存器元素%cl中。(这些指令很特别,因为只允许以这个特定的寄存器作为操作数。)两个左移指令的效果是一样的,都是将右边填上0。右移指令不同,算术右移是填上符号位,逻辑右移是填上0。移位操作的目的操作数可以是一个寄存器或是一个存储器位置。

    特殊的算术操作
    指令 效果 描述
    imull S R[\%edx]:R[\%eax]\leftarrow S \times R[\%eax] 有符号全64位乘法
    mull S R[\%edx]:R[\%eax]\leftarrow S \times R[\%eax] 无符号全64位乘法
    cltd S R[\%edx]:R[\%eax]\leftarrow SignExtend(R[\%eax]) 转为4字
    idivl S R[\%edx]\leftarrow R[\%edx]:R[\%eax] mod S;
    R[\%eax]\leftarrow R[\%edx]:R[\%eax] \div S
    有符号除法
    divl S R[\%edx]\leftarrow R[\%edx]:R[\%eax] mod S;
    R[\%eax]\leftarrow R[\%edx]:R[\%eax] \div S
    无符号除法

    这些操作提供了有符号和无符号的全64位乘法和除法。一对寄存器 %edx 和 %eax 组成一个64位的四字。

    imull和mull两条指令都要求一个参数必须在寄存器%eax中,而另一个作为指令的源操作数给出。然后乘积存放在寄存器%edx(高32位)和%eax(低32位)中。

    有符号除法指令idivl将寄存器%edx(高32位)和%eax(低32位)中的64位数作为被除数,而除数作为指令的操作数给出。指令将商存储在寄存器%eax中,将余数存储在寄存器%edx中。无符号除法使用的是divl指令,通常会事先将寄存器%edx设置为0。

    cltd指令将%eax符号扩展到%edx,通常用来设置被除数。

    条件码

    除了整数寄存器,CPU还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:

    • CF:进位标志。最近的操作使最高位产生了进位。可以用来检查无符号操作数的溢出。
    • ZF:零标志。最近的操作得到的结果是0。
    • SF:符号标志。最近的操作得到的结果为负数。
    • OF:溢出标志。最近的操作导致一个补码溢出(正溢出或负溢出)。

    leal指令不改变任何条件码,因为它是用来进行地址计算的。其它整数算术操作指令都会设置条件码。对于逻辑操作,例如XOR,进位标志和溢出标志会设置成0。对于移位操作,进位标志将设置为最后一个被移出的位,而溢出标志设置为0。INC和DEC指令会设置溢出和零标志,但是不会改变进位标志。

    指令 基于 描述
    CMP S_2,S_1 S_1-S_2 比较
    cmpb 比较字节
    cmpw 比较字
    cmpl 比较双字
    TEST S_2,S_1 S_1\&S_2 测试
    testb 测试字节
    testw 测试字
    testl 测试双字

    比较和测试指令只设置条件码而不改变任何其他寄存器。所以,除了不设置寄存器,CMP指令和SUB指令的行为是一样的,TEST和ADD指令的行为也是一样的。

    访问条件码

    条件码通常不会直接读取。每条SET指令根据条件码的某个组合,将一个字节设置为0或1。SET指令名字的不同后缀指明了它们所考虑的条件码的组合,而不是操作数的大小,请注意与其他组指令在名字上的这点区别。

    指令 同义名 效果 设置条件
    sete D setz D \leftarrow ZF 相等/零
    setne D setnz D \leftarrow \text~ZF 不等/非零
    sets D D \leftarrow SF 负数
    setns D D \leftarrow \text~SF 非负数
    setg D setnle D \leftarrow \text~(SF\text^OF)\&\text ~ZF 大于(有符号>
    setge D setnl D \leftarrow \text~(SF\text^OF) 大于等于(有符号\geqslant
    setl D setnge D \leftarrow SF\text^OF 小于(有符号<
    setle D setng D \leftarrow (SF\text^OF)|ZF 大于等于(有符号\leqslant
    seta D setnbe D \leftarrow \text~CF\&\text~ZF 超过(无符号>
    setae D setnb D \leftarrow \text~CF 超过或相等(无符号\geqslant
    setb D setnae D \leftarrow CF 低于(无符号<
    setbe D setna D \leftarrow CF|ZF 低于或相等(无符号\leqslant

    一条SET指令的目的操作数是8个单字节寄存器元素之一,或是存储一个字节的存储器位置,将这个字节设置成0或1。为了得到一个32位结果,我们必须对最高的24位清零。某些底层的机器指令可能有多个名字,我们称为“同义名”(synonym)。比如,setg(设置大于)和setnle(设置不小于等于)指的就是同一条机器指令。

    跳转指令

    正常执行的情况下,指令按照它们出现的顺序一条一条地执行。跳转(jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(label)指明。

    指令 同义名 跳转条件 描述
    jmp Label 1 直接跳转
    jmp *Operand 1 间接跳转
    je Label jz ZF 相等/零
    jne Label jnz \text~ZF 不等/非零
    js Label SF 负数
    jns Label \text~SF 非负数
    jg Label jnle \text~(SF\text^OF)\&\text ~ZF 大于(有符号>
    jge Label jnl \text~(SF\text^OF) 大于等于(有符号\geqslant
    jl Label jnge SF\text^OF 小于(有符号<
    jle Label jng (SF\text^OF)|ZF 大于等于(有符号\leqslant
    ja Label jnbe \text~CF\&\text~ZF 超过(无符号>
    jae Label jnb \text~CF 超过或相等(无符号\geqslant
    jb Label jnae CF 低于(无符号<
    jbe Label jna CF|ZF 低于或相等(无符号\leqslant

    jmp指令是无条件跳转,它可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或存储器位置中读出的。

    汇编语言中,直接跳转是给出一个标号作为跳转目标的。

      jmp  .L1
      movl  (%eax),%edx
    .L1:
      popl  %edx
    

    而间接跳转的写法是“*”后面紧跟一个操作数指示符。

      jmp  *%eax      // 用寄存器%eax中的值作为跳转目标
      jmp  *(%eax)    // 以%eax中的值作为读地址,从存储器中读出跳转目标
    

    其它跳转指令都是有条件跳转,它们根据条件码的组合,或者跳转,或者继续执行代码序列中的下一条指令。这些指令的名字和它们的跳转条件与SET指令是相匹配的。

    过程

    一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。大多数机器,只提供转移控制到过程中和从过程中转移出控制这种简单的命令。数据传递、局部变量的分配和释放通过操纵程序栈来实现。

    栈帧结构

    机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。为单个过程分配的那部分栈称为栈帧(stack frame)。栈帧的最顶端以两个指针界定,寄存器%ebp为帧指针,而寄存器%esp为栈指针。当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于帧指针的。

    假设过程P(调用者)调用过程Q(被调用者),则Q的参数放在P的栈帧中。另外,当P调用Q时,P中的返回地址被压入栈中,形成P的栈帧的末尾。返回地址就是当程序从Q返回时应该继续执行的地方。Q的栈帧从保存的帧指针的值(例如%ebp)开始,后面是保存的其他寄存器的值。

    转移控制
    指令 描述
    call Label 过程调用
    call *Operand 过程调用
    leave 为返回准备栈
    ret 从过程调用中返回

    call指令有一个目标,即指明被调用过程起始的指令地址。同跳转一样,调用可以直接的,也可以是间接的。call指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。返回地址是在程序中紧跟在call后面的那条指令的地址,这样当被调用过程返回时,执行会从此处继续。

    ret指令从栈中弹出地址,并跳转到这个位置。leave指令可以使栈做好准备,使栈指针指向前面call指令存储返回地址的位置。

      leave
    # 等价于
      movl %ebp,%esp
      popl %ebp
    

    程序寄存器组是唯一能被所有过程共享的资源。虽然在给定时刻只能有一个过程是活动的,但是我们必须保证当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖某个调用者稍后会使用的寄存器的值。为此,IA32采用了一组统一的寄存器使用惯例,所有的过程都必须遵守,包括程序库中的过程。

    根据惯例,寄存器%eax、%edx、%ecx被划分为调用者保存寄存器。当过程Q调用Q时,Q可以覆盖这些寄存器,而不会破坏任何P所需要的数据。另一方面,寄存器%ebx、%esi、%edi被划分为被调用者保存寄存器。这意味着Q必须在覆盖这些寄存器的值之前,先把它们保存到栈中,并在返回前恢复它们,因为P(或某个更高层次的过程)可能会在后面的计算中需要这些值。此外,根据惯例,必须保存寄存器%ebp和%esp。

    栈和链接惯例使得过程可以递归地调用它们自身。因为每个调用在栈中都有它自己的私有空间,多个未完成调用的局部变量还不会相互影响。此外,栈的原则很自然地提供了适当的策略,当过程被调用时分配局部变量,当返回时释放存储。

    递归调用一个函数本身与调用其他函数是一样的。栈规则提供了一种机制,每次函数调用都有它自己私有的状态信息(保存的返回位置、栈指针和被调用者保存寄存器的值)存储。如果需要,它还可以提供局部变量的存储。分配和释放的栈规则很自然地就与函数调用—返回的顺序匹配。这种实现函数调用和返回的方法甚至对于更复杂的情况也适用,包括相互递归调用(例如,当过程P调用Q,Q再调用P)。

    将IA32扩展到64位

    以下是x86-64的标准数据类型大小,与IA32比较,长整数和指针需要8个字节,而IA32只需要4个字节。

    C声明 Intel数据类型 汇编代码后缀 x86-64大小(字节) IA32大小
    char 字节 b 1 1
    short w 2 2
    int 双字 l 4 4
    long int 四字 q 8 4
    long long int 四字 q 8 8
    char * 四字 q 8 4
    float 单精度 s 4 4
    double 双精度 l 8 8
    long double 扩展精度 t 10/16 10/12

    Intel进入64位计算机领域打响的第一枪是Itanium处理器,它基于一种全新的指令集,称为“IA64”。Intel的一贯的策略是每次引入新一代微处理器时还要维持后向兼容性,而这次不同,IA64是基于与Hewlett-Packard一起开发的一种崭新的方法。IA64的实现是很难的,因此第一批Itanium芯片直到2001年才出现,而且在真实应用上没有达到预期的性能。大多数用户宁愿使用比较便宜并且常常更快的基于IA32的系统。
    同时,Intel的主要竞争对手,Advanced Micro Devices(AMD)看到一个机会去利用Intel在IA64上的失败。2003年,AMD推出了基于“x86-64”指令集的64位微处理器。顾名思义,x86-64是Intel指令集到64位的一个演化。它保持了与IA32完全的后向兼容性,并且又增加了新的数据格式,以及其他一些特性,使得能力更强,性能更高。通过x86-64,AMD获得了以前属于Intel的一些高端市场。AMD后来将这个指令集更名为AMD64,但是大家还是对“x86-64”这个名字更喜欢一些。

    x86-64下的通用寄存器组,与IA32相比,有以下区别:

    • 寄存器的数量翻倍至16个。
    • 所有的寄存器都是64位长。IA32寄存器的64位扩展分别命名为%rax、%rcx、%rdx、%rbx、%rsi、%rdi、%rbp和%rsp,新增加的寄存器命名为%r8~%r15。
    • 可以直接访问每个寄存器的低32位。这就给了我们IA32中熟悉的那些寄存器:%eax、%ecx、%edx、%ebx、%esi、%edi、%ebp和%esp,以及8个新32位寄存器:%r8d~%r15d。
    • 可以直接访问每个寄存器的低16。新寄存器的字大小版本命名为 %r8w~%r15w。
    • 可以直接访问每个寄存器的低8位,在IA32中只有对前4个寄存器(%al、%cl、%dl和%bl)才可以这样。其他IA32寄存器的字节大小版本命名为%sil、%dil、%bpl和%spl。新寄存器的字节大小版本命名为%r8b~%r15b。
    • 为了向后兼容,具有单字节操作数的指令可以直接访问%rax、%rcx、%rdx和%rbx的第二个字节,即%ah、%ch、%dh和%bh。
    x86-64整数寄存器

    同IA32一样,大多数寄存器可以互换使用,但是有一些特殊情况。寄存器%rsp有特殊的状态,它会保存指向栈顶元素的指针。与IA32不同的是,没有帧指针寄存器,可以将%rbp作为通用寄存器使用。

    指令 类型 描述
    movabsq I,R R \leftarrow I 传送绝对四字
    MOV S,D D \leftarrow S 传送
    movq 传送四字
    MOVS S,D D \leftarrow SignExtend(S) 符号扩展传递
    movsbq 符号扩展字节传送四字
    movswq 符号扩展字传送四字
    movslq 符号扩展双字传送四字
    MOVZ S,D D \leftarrow ZeroExtend(S) 零扩展传递
    movzbq 零扩展字节传送四字
    movzwq 零扩展字传送四字
    pushq S R[\%rsp] \leftarrow R[\%rsp]-8;
    M[R[\%rsp]] \leftarrow S
    将四字压栈
    popq D D \leftarrow M[R[\%rsp]];
    R[\%rsp] \leftarrow R[\%rsp]+8;
    将四字出栈

    这些指令是对IA32传送指令的补充。movabsq只允许立即数(用I表示)作为源值,其他指令允许立即数、寄存器或存储器(用S表示)。有些指令要求目的是寄存器(用R表示),而其他的指令允许用寄存器或存储器作为目的(用D表示)。

    同样地,算术运算指令和控制指令也增加了对四字的扩展支持。

    指令 效果或根据 描述
    imulq S R[\%rdx]:R[\%rax] \leftarrow S \times R[\%rax] 有符号全乘法
    mulq S R[\%rdx]:R[\%rax] \leftarrow S \times R[\%rax] 无符号全乘法
    cltq R[\%rax] \leftarrow SignExtend(R[\%eax]) \%eax转换成四字
    cqto R[\%rdx]:R[\%rax] \leftarrow SignExtend(R[\%rax]) \%rax转换成八字
    idivq S R[\%rdx] \leftarrow R[\%rdx]:R[\%rax] mod S;
    R[\%rdx] \leftarrow R[\%rdx]:R[\%rax] \div S
    有符号除法
    divq S R[\%rdx] \leftarrow R[\%rdx]:R[\%rax] mod S;
    R[\%rdx] \leftarrow R[\%rdx]:R[\%rax] \div S
    无符号除法
    cmpq S_2,S_1 S_1-S_2 比较四字
    testq S_2,S_1 S_1-S_2 测试四字

    过程调用的x86-64实现与IA32实现有很大的不同,通过将寄存器组翻倍,程序不再需要依赖于栈来存储和获取过程信息。这极大地减少了过程调用和返回的开销。以下是x86-64实现过程的一些重点:

    • 参数(最多是前六个)通过寄存器传递到过程,而不是在栈上。这消除了在栈上存储和检索值的开销。
    • callq指令将一个64位返回地址存储在栈上。
    • 许多函数不需要栈帧。
    • 函数最多可以访问超过当前栈指针值128个字节的栈上存储空间(地址低于当前栈指针的值)。这允许一些函数将信息存储在栈上而无需修改栈指针。
    • 没有帧指针。作为替代,对栈位置的引用相对于栈指针。大多数函数在调用开始时分配所需要的整个栈存储,并保存栈指针指向固定位置。
    • 同IA32一样,有些寄存器被指定为被调用者保存寄存器。任何要修改这些寄存器的过程都必须保存并恢复它们。

    函数需要栈帧的原因如下:

    • 局部变量太多,不能都放在寄存器中。
    • 有些局部变量是数组或者结构。
    • 函数用取地址操作符(&)来计算一个局部变量的地址。
    • 函数必须将栈上的某些参数传递到另一个函数。
    • 在修改一个被调用者保存寄存器之前,函数需要保存它的状态。
      当上述条件有任何一条被满足时,函数编译出来的代码就会创建栈帧。

    相关文章

      网友评论

          本文标题:程序的机器级表示

          本文链接:https://www.haomeiwen.com/subject/xvvhuhtx.html