第3章 Hello,world!

现在,我们开始演示《C语言编程》一书[1]中著名的程序:

#include <stdio.h>

int main() 
{
    printf("hello, world\n");
    return 0;
}

3.1 x86

3.1.1 MSVC

接下来我们将通过下述指令,使用MSVC 2010编译下面这个程序。

cl 1.cpp /Fa1.asm

其中/Fa选项将使编译器生成汇编指令清单文件(assembly listing file),并指定汇编列表文件的文件名称是1.asm。

上述命令生成的1.asm内容如下。

指令清单3.1 MSVC 2010

CONST   SEGMENT
$SG3830 DB       'hello, world', 0AH, 00H
CONST   ENDS
PUBLIC  _main
EXTRN   _printf:PROC
; Function compile flags: /Odtp
_TEXT   SEGMENT
_main   PROC
        push    ebp
        mov     ebp, esp
        push    OFFSET $SG3830
        call    _printf
        add     esp, 4
        xor     eax, eax
        pop     ebp
        ret     0
_main   ENDP
_TEXT   ENDS

MSVC生成的汇编清单文件都采用了Intel语体。汇编语言存在两种主流语体,即Intel语体和AT&T语体。本书将在3.1.3节中讨论它们之间的区别。

在生成1.asm之后,编译器会生成1.obj再将之链接为可执行文件1.exe。

在hello world这个例子中,文件分为两个代码段,即CONST和_TEXT段,它们分别代表数据段和代码段。在本例中,C/C++程序为字符串常量“hello,world”分配了一个指针(const char[]),只是在代码中这个指针的名称并不明显(参照下列Bjarne Stroustrup. The C++ Programming Language, 4th Edition. 2013的第176页,7.3.2节)。

接下来,编译器进行了自己的处理,并在内部把字符串常量命名为$SG3830。

因此,上述程序的源代码等效于:

#include <stdio.h>

const char *$SG3830[]="hello, world\n";

int main() 
{
    printf($SG3830);
    return 0; 
}

在回顾1.asm文件时,我们会发现编译器在字符串常量的尾部添加了十六进制的数字0,即00h。依据C/C++字符串的标准规范,编译器要为这个字符串常量添加结束标志(即数值为零的单个字节)。有关标准请参照本书的57.1.1节。

在代码段_TEXT只有1个函数,即主函数main()。在汇编指令清单里,主函数的函数体有标志性的函数序言(function prologue)和函数尾声(function epilogue)。实际上所有的函数都有这样的序言和尾声。在函数的序言标志之后,我们能够看到调用printf()函数的指令: CALL _printf。

通过PUSH指令,程序把字符串的指针推送入栈。这样,printf()函数就可以调用栈里的指针,即字符串“hello, world!”的地址。

在printf()函数结束以后,程序的控制流会返回到main()函数之中。此时,字符串地址(即指针)仍残留在数据栈之中。这个时候就需要调整栈指针(ESP寄存器里的值)来释放这个指针。

下一条语句是“add ESP,4”,把ESP寄存器(栈指针/Stack Pointer)里的数值加4。

为什么要加上“4”?这是因为x86平台的内存地址使用32位(即4字节)数据描述。同理,在x64系统上释放这个指针时,ESP就要加上8。

因此,这条指令可以理解为“POP某寄存器”。只是本例的指令直接舍弃了栈里的数据而POP指令还要把寄存器里的值存储到既定寄存器[2]

某些编译器(如Intel C++编辑器)不会使用ADD指令来释放数据栈,它们可能会用POP ECX指令。例如,Oracle RDBMS(由Intel C++编译器编译)就会用POP ECX指令,而不会用ADD指令。虽然POP ECX命令确实会修改ECX寄存器的值,但是它也同样释放了栈空间。

Intel C++编译器使用POP ECX指令的另外一个理由就是,POP ECX对应的OPCODE(1字节)比ADD ESP的OPCODE(3字节)要短。

指令清单3.2 Oracle RDBMS 10.2 Linux (摘自app.o)

.text:0800029A      push    ebx
.text:0800029B      call    qksfroChild
.text:080002A0      pop     ecx

本书将在讨论操作系统的部分详细介绍数据栈。

在上述C/C++程序里,printf()函数结束之后,main()函数会返回0(函数正常退出的返回码)。即main()函数的运算结果是0。

这个返回值是由指令“XOR EAX, EAX”计算出来的。

顾名思义,XOR就是“异或” [3]。编译器通常采用异或运算指令,而不会使用“MOV EAX,0”指令。主要是因为异或运算的opcode较短(2字节:5字节)。

也有一些编译器会使用“SUB EAX,EAX”指令把EAX寄存器置零,其中SUB代表减法运算。总之,main()函数的最后一项任务是使EAX的值为零。

汇编列表中最后的操作指令是RET,将控制权交给调用程序。通常它起到的作用就是将控制权交给操作系统,这部分功能由C/C++的CRT[4]实现。

3.1.2 GCC

接下来,我们使用GCC 4.4.1编译器编译这个hello world程序。

gcc 1.c -o 1

我们使用反汇编工具IDA(Interactive Disassembler)查看main()函数的具体情况。IDA所输出的汇编指令的格式,与MSVC生成的汇编指令的格式相同,它们都采用Intel语体显示汇编指令。

此外,如果要让GCC编译器生成Intel语体的汇编列表文件,可以使用GCC的选项“-S -masm=intel”。

指令清单3.3 在IDA中观察到的汇编指令

Main         proc near
var_10       = dword ptr -10h

             push    ebp
             mov     ebp, esp
             and     esp, 0FFFFFFF0h
             sub     esp, 10h
             mov     eax, offset aHelloWorld ; "hello, world\n"
             mov     [esp+10h+var_10], eax
             call    _printf
             mov     eax, 0
             leave
             retn
main         endp

GCC生成的汇编指令,与MSVC生成的结果基本相同。它首先把“hello, world”字符串在数据段的地址(指针)存储到EAX寄存器里,然后再把它存储在数据栈里。

其中值得注意的还有开场部分的“AND ESP, 0FFFFFFF0h”指令。它令栈地址(ESP的值)向16字节边界对齐(成为16的整数倍),属于初始化的指令。如果地址位没有对齐,那么CPU可能需要访问两次内存才能获得栈内数据。虽然在8字节边界处对齐就可以满足32位x86 CPU和64位x64 CPU的要求,但是主流编译器的编译规则规定“程序访问的地址必须向16字节对齐(被16整除)”。人们还是为了提高指令的执行效率而特意拟定了这条编译规范。[5]

“SUB ESP,10h”将在栈中分配0x10 bytes,即16字节。我们在后文看到,程序只会用到4字节空间。但是因为编译器对栈地址(ESP)进行了16字节对齐,所以每次都会分配16字节的空间。

而后,程序将字符串地址(指针的值)直接写入到数据栈。此处,GCC使用的是MOV指令;而MSVC生成的是PUSH指令。其中var_10是局部变量,用来向后面的printf()函数传递参数。

随即,程序调用printf()函数。

GCC和MSVC不同,除非人工指定优化选项,否则它会生成与源代码直接对应的“MOV EAX, 0”指令。但是,我们已经知道MOV指令的opcode肯定要比XOR指令的opcode长。

最后一条LEAVE指令,等效于“MOV ESP, EBP”和“POP EBP”两条指令。可见,这个指令调整了数据栈指针ESP,并将EBP的数值恢复到调用这个函数之前的初始状态。毕竟,程序段在开始部分就对EBP和EBP进行了操作(MOVEBP, ESP/AND ESP, ...),所以函数要在退出之前恢复这些寄存器的值。

3.1.3 GCC:AT&T语体

AT&T语体同样是汇编语言的显示风格。这种语体在UNIX之中较为常见。

接下来,我们使用GCC4.7.3编译如下所示的源程序。

指令清单3.4 使用GCC 4.7.3 编译源程序

gcc –S 1_1.c

上述指令将会得到下述文件。

指令清单3.5 GCC 4.7.3生成的汇编指令

        .file   "1_1.c"
        .section       .rodata
.LC0:
        .string  "hello, world\n"
        .text
        .globl    main
        .type     main, @function
main: 
.LFB0:
        .cfi_startproc
        pushl    %ebp
        .cfi_def_cfa_offset 8
        .cfi_offset 5, -8
        movl     %esp, %ebp
        .cfi_def_cfa_register 5
        andl     $-16, %esp
        subl     $16, %esp
        movl     $.LC0, (%esp)
        call     printf
        movl     $0, %eax
        leave
        .cfi_restore 5
        .cfi_def_cfa 4, 4
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3"
        .section        .note.GNU-stack,"",@progbits

在上述代码里,由小数点开头的指令就是宏。这种形式的汇编语体大量使用汇编宏,可读性很差。为了便于演示,我们将其中字符串以外的宏忽略不计(也可以启用GCC的编译选项-fno-asynchronous-unwind-tables,直接预处理为没有cfi宏的汇编指令),将会得到如下指令。

指令清单3.6 GCC 4.7.3生成的指令

.LC0:
        .string "hello, world\n"
main:
        pushl   %ebp
        movl    %esp, %ebp
        andl    $-16, %esp
        subl    $16, %esp
        movl    $.LC0, (%esp)
        call    printf
        movl    $0, %eax
        leave
        ret

在继续解读这个代码之前,我们先介绍一下Intel语体和AT&T语体的区别。

现在再来阅读hello world的AT&T语体指令,就会发现它和IDA里看到的指令没有实质区别。有些人可能注意到,用于数据对齐的0FFFFFFF0h在这里变成了十进制的$-16——把它们按照32byte型数据进行书写后,就会发现两者完全一致。

此外,在退出main()时,处理EAX寄存器的指令是MOV指令而不是XOR指令。MOV的作用是给寄存器赋值(load)。某些硬件框架的指令集里有更为直观的“LOAD”“STORE”之类的指令。

3.2 x86-64

3.2.1 MSVC-x86-64

若用64位MSVC编译上述程序,则会得到下述指令。

指令清单3.7 MSVC 2012 x64

$SG2989  DB      'hello, world', 0AH 00H

main     PROC
         sub     rsp, 40
         lea     rcx, OFFSET FLAT:$SG2989
         call    printf
         xor     eax, eax
         add     rsp, 40
         ret     0
main     ENDP

在x86-64框架的CPU里,所有的物理寄存器都被扩展为64位寄存器。程序可通过R-字头的名称直接调用整个64位寄存器。为了尽可能充分地利用寄存器、减少访问内存数据的次数,编译器会充分利用寄存器传递函数参数(请参见64.3节的fastcall约定)。也就是说,编译器会优先使用寄存器传递部分参数,再利用内存(数据栈)传递其余的参数。Win64的程序还会使用RCX、RDX、R8、R9这4个寄存器来存放函数参数。我们稍后就会看到这种情况:printf()使用RCX寄存器传递参数,而没有像32位程序那样使用栈传递数据。

在x86-64硬件平台上,寄存器和指针都是64位的,存储于R-字头的寄存器里。但是出于兼容性的考虑,64位寄存器的低32位,也要能够担当32位寄存器的角色,才能运行32位程序。

在64位x86兼容的CPU中,RAX/EAX/AX/AL的对应关系如下。

7th(字节号)
6th
5th
4th
3rd
2nd
1st
0th
RAXx64
EAX
AX
AH
AL

main()函数的返回值是整数类型的零,但是出于兼容性和可移植性的考虑,C语言的编译器仍将使用32位的零。换而言之,即使是64位的应用程序,在程序结束时EAX的值是零,而RAX的值不一定会是零。

此时,数据栈的对应空间里仍留有40字节的数据。这部分数据空间有个专用的名词,即阴影空间(shadow space)。本书将在8.2.1节里更详细地介绍它。

3.2.2 GCC-x86-64

我们使用64位Linux的GCC编译器编译上述程序,可得到如下所示的指令。

指令清单3.8 GCC 4.4.6 x64

.string "hello, world\n"
main:
         sub      rsp, 8
         mov      edi, OFFSET FLAT:.LC0 ; "hello, world"
         xor      eax, eax  ; number of vector registers passed
         call     printf
         xor      eax, eax
         add      rsp, 8
         ret

Linux、BSD和Mac OS X系统中的应用程序,会优先使用RDI、RSI、RDX、RCX、R8、R9这6个寄存器传递函数所需的头6个参数,然后使用数据栈传递其余的参数。[6]

因此,64位的GCC编译器使用EDI寄存器(寄存器的32位)存储字符串指针。EDI不过是RDI寄存器中地址位较低的32位地址部分。为何GCC不直接使用整个RDI寄存器?

需要注意的是,64位汇编指令MOV在写入R-寄存器的低32位地址位的时候,即对E-寄存器进行写操作的时候,会同时清除R-寄存器中的高32位地址位[7]。所以, “MOV EAX, 011223344h”能够对RAX寄存器进行正确的赋值操作,因为该指令会清除(置零)高地址位的内容。

如果打开GCC生成的obj文件,我们就能看见全部的opcode。[8]

指令清单3.9 GCC 4.4.6 x64

.text:00000000004004D0                   main  proc near
.text:00000000004004D0 48 83 EC 08       sub     rsp, 8
.text:00000000004004D4 BF E8 05 40 00    mov     edi, offset format ; "hello, world\n"
.text:00000000004004D9 31 C0             xor     eax, eax
.text:00000000004004DB E8 D8 FE FF FF    call    _printf
.text:00000000004004E0 31 C0             xor     eax, eax
.text:00000000004004E2 48 83 C4 08       add     rsp, 8
.text:00000000004004E6 C3                retn
.text:00000000004004E6                   main  endp

在地址0x4004D4处,程序对EDI 进行了写操作,这部分代码的opcode占用了5个字节;相比之下,对RDI进行写操作的opcode则会占用7个字节。显然,出于空间方面的考虑,GCC进行了相应的优化处理。此外,因为32位地址(指针)能够描述的地址不超过4GB,我们可据此判断这个程序的数据段地址不会超过4GB。

在调用printf()之前,程序清空了EAX寄存器,这是x86-64框架的系统规范决定的。在系统与应用程序接口的规范中,EAX寄存器用来保存用过的向量寄存器(vector registers)。[9]

3.3 GCC的其他特性

只要C语言代码里使用了字符串型常量(可参照3.1.1节的范例),编译器就会把这个字符串常量置于常量字段,以保证其内容不会发生变化。不过GCC有个有趣的特征:它可能会把字符串拆出来单独使用。

我们来看下面这段程序:

#include <stdio.h>
void f1() 
{
         printf ("world\n");
}

void f2() 
{
         printf ("hello world\n");
}

int main() 
{
         f1();
         f2(); 
}

多数的C/C++编译器(包括MSVC编译器)会分配出两个直接对应的字符串,不过GCC 4.8.1的编译结果则更为可圈可点。

指令清单3.10 在IDA中观察GCC 4.8.1 的汇编指令

f1                  proc near

s                   = dword ptr -1Ch

                    sub     esp, 1Ch
                    mov     [esp+1Ch+s], offset s ; "world\n"
                    call    _puts
                    add     esp, 1Ch
                    retn
f1                  endp

f2                  proc near

s                   = dword ptr -1Ch

                    sub     esp, 1Ch
                    mov     [esp+1Ch+s], offset aHello ; "hello "
                    call    _puts
                    add     esp, 1Ch
                    retn
f2                  endp

aHello              db  'hello'
s                   db  'world', 0xa, 0

在打印字符串“hello world”的时候,这两个词的指针地址实际上是前后相邻的。在调用puts()函数进行输出时,函数本身不知道它所输出的字符串分为两个部分。实际上我们在汇编指令清单中可以看到,这两个字符串没有被“切实”分开。

在f1()函数调用puts()函数时,它输出字符串“world”和外加结束符(数值为零的1个字节),因为puts()函数并不知道字符串可以和前面的字符串连起来形成新的字符串。

GCC编译器会充分这种技术来节省内存。

3.4 ARM

根据我个人的经验,本书将通过以下几个主流的ARM编译器进行演示。

除非特别标注,否则本书中的ARM程序都是32位ARM程序。在介绍64位的ARM程序时,本书会称其为ARM64程序。

3.4.1 Keil 6/2013——未启用优化功能的ARM模式

请使用下述指令,用Keil编译器把hello world程序编译为ARM指令集架构的汇编程序:

armcc.exe --arm --c90 -O0 1.c

虽然armcc编译器生成的汇编指令清单同样采用了Intel语体,但是程序所使用的宏却极具ARM处理器的特色[11]。眼见为实,我们一起用IDA来看看它们的本来面目吧。

指令清单3.11 使用IDA观察Non-optimizing Keil 6/2013 (ARM模式)

.text:00000000               main
.text:00000000 10 40 2D E9    STMFD   SP!, {R4,LR}
.text:00000004 1E 0E 8F E2    ADR     R0, aHelloWorld ; "hello, world"
.text:00000008 15 19 00 EB    BL      __2printf
.text:0000000C 00 00 A0 E3    MOV     R0, #0
.text:00000010 10 80 BD E8    LDMFD   SP!, {R4,PC}

.text:000001EC 68 65 6C 6C+aHelloWorld  DCB "hello, world",0    ; DATA XREF: main+4

在本节的例子里,每条指令都占用4个字节。正如您所见到,我们确实要把源程序编译为ARM模式指令集的应用程序,而不是把它编译为以Thumb模式的应用程序。

现在回顾上面的代码,第一句“STMFD  SP!, {R4,LR}”[12]相当于x86的PUSH指令。它把R4寄存器和LR(Link Register)寄存器的数值放到数据栈中。此处,本文的措辞是“相当于”,而非“完全是”。这是因为ARM模式的指令集里没有PUSH指令,只有Thumb模式里的指令集里才有“PUSH/POP”指令。在IDA中可以清楚地看到这种差别,所以本书推荐使用IDA分析上述程序。

这条指令首先将SP[13]递减,在栈中分配一个新的空间以便存储R4和LR的值。

STMFD指令能够一次存储多个寄存器的值,Thumb模式的PUSH指令也可以这样使用。实际上x86指令集中并没有这样方便的指令。STMFD指令可看作是增强版本的PUSH指令,它不仅能够存储SP的值,也能够存储任何寄存器的值。换句话说,STMFD可用来在指定的内存空间存储多个寄存器的值。

接下来的指令是“ADR R0, aHelloWorld”。它首先对PC[14]进行取值操作,然后把“hello, world”字符串的偏移量(可能为负值)与PC的值相加,将其结果存储到R0之中。有些读者可能不明白此处PC寄存器的作用。严谨地说,编译器通常帮助PC把某些指令强制变为“位置无关代码/position-independent code”。在(多数)操作系统把程序加载在内存里的时候,OS分配给程序代码的内存地址是不固定的;但是程序内部既定指令和数据常量之间的偏移量是固定的(由二进制程序文件决定)。这种情况下,要在程序内部进行指令寻址(例如跳转等情况),就需要借助PC指针[15]。ADR将当前指令的地址与字符串指针地址的差值(偏移量)传递给R0。程序借助PC指针可找到字符串指针的偏移地址,从而使操作系统确定字符串常量在内存里的绝对地址。

“BL __2printf”[16]调用printf()函数。BL实施的具体操作实际上是:

当printf()完成工作之后,计算机必须知道返回地址,即它应当从哪里开始继续执行下一条指令。所以,每次使用BL指令调用其他函数之前,都要把BL指令的下一个指令的地址存储到LR寄存器。

这便是CISC(复杂指令集)处理器与RISC(精简指令集)处理器在工作模式上的区别。在拥有复杂指令集的x86体系里,操作系统可以利用栈存储返回地址。

顺便说一下,ARM模式跳转指令的寻址能力确实存在局限性。单条ARM模式的指令必须是32位/4字节,所以BL指令无法调用32位绝对地址或32位相对地址(容纳不下),它只能编入24位的偏移量。不过,既然每条指令的opcode必须是4字节,则指令地址必须在4n处,即偏移地址的最后两位必定为零,可在opcode里省略。在处理ARM模式的转移指令时,处理器将指令中的opcode的低24位左移2位,形成26位偏移量,再进行跳转。由此可知,转移指令B/BL的跳转指令的目标地址,大约在当前位置的±32MB区间之内[17]

下一条指令“MOV R0,#0”将R0寄存器置零。Hello World的C代码中,主函数返回零。该指令把返回值写在R0寄存器中。

最后到了“LDMFD SP!, {R4,PC}”这一条指令[18]。它与STMFD成对出现,做的工作相反。它将栈中的数值取出,依次赋值给R4和PC,并且会调整栈指针SP。可以说这条指令与POP指令很相似。main()函数的第一条指令就是STMFD指令,它将R4寄存器和LR寄存器存储在栈中。main()函数在结尾处使用LDMFD指令,其作用是把栈里存储的PC的值和R4寄存器的值恢复回来。

前面提到过,程序在调用其他函数之前,必须把返回地址保存在LR寄存器里。因为在调用printf()函数之后LR寄存器的值会发生改变,所以主函数的第一条指令就要负责保存LR寄存器的值。在被调用的函数结束后,LR寄存器中存储的值会被赋值给PC,以便程序返回调用者函数继续运行。当C/C++的主函数main()结束之后,程序的控制权将返回给OS loader,或者CRT中的某个点,或者作用相似的其他地址。

数据段中的DCB是汇编语言中定义ASCII字符数组/字节数组的指令,相当于x86汇编中的DB指令。

3.4.2 Thumb模式下、未开启优化选项的Keil

现在以Thumb模式编译前面的源代码:

armcc.exe --thumb --c90 -O0 1.c

我们会在IDA中看到如下指令。

指令清单3.12 使用IDA观察Non-optimizing Keil 6/2013 (Thumb模式)

.text:00000000              main
.text:00000000 10 B5          PUSH    {R4,LR}
.text:00000002 C0 A0          ADR     R0, aHelloWorld;"hello, world"
.text:00000004 06 F0 2E F9    BL      _2printf
.text:00000008 00 20          MOVS    R0, #0
.text:0000000A 10 BD          POP     {R4, PC}

.text:00000304 68 65 6C 6C  +aHelloWorld  DCB "hello, world",0    ; DATA XREF: main+2

Thumb 模式程序的每条指令,都对应着2个字节/16位的opcode,这是Thumb模式程序的特征。但是Thumb模式的跳转指令BL“看上去”占用了4个字节的 opcode,实际上它是由2条指令组成的。单条16位opcode传递的信息太有限,不足以向被调用函数传递PC和偏移量信息。所以,上面BL指令分为2条16位opcode。第一条16位指令可以传递偏移量的高10位,第二条指令可以传递偏移量的低11位。而Thumb模式的opcode都是固定的2 个字节长,目标地址位最后一个位必定是0(Thumb模式的opcode的启始地址位必须是2n),因而会被省略。在执行Thumb模式的转移指令时,处理器会将目标地址左移1位,形成22位的偏移量。即Thumb的BL跳转指令将无法跳到奇数地址,而且跳转指令仅仅能偏移到到当前地址 ±2MB(22位有符号整数的取值区间)附近的范围之内。

程序主函数的其他指令,PUSH和POP工作方式与STMFD/LDMFD相似。虽然表面上看不出来,但是实际上它们也会调整SP指针。ADR指令与前文的作用相同。而MOVS 指令负责把返回值(R0寄存器)置零。

3.4.3 ARM模式下、开启优化选项的Xcode

如果不启用优化选项,Xcode 4.6.3 将会产生大量的冗余代码,所以不妨开启优化选项,让其生成最优的代码。请指定编译选项-O3,使用Xcode编译Hello world程序。这将会得到如下所示的汇编代码。

指令清单3.13 Optimizing Xcode 4.6.3 (LLVM) (ARM模式)

__text:000028C4             _helloworld
__text:000028C4 80 40 2D E9   STMFD         SP!, {R7, LR}
__text:000028C8 86 06 01 E3   MOV           R0, #0x1686
__text:000028CC 0D 70 A0 E1   MOV           R7, SP
__text:000028D0 00 00 40 E3   MOVT          R0, #0
__text:000028D4 00 00 8F E0   ADD           R0, PC, R0
__text:000028D8 C3 05 00 EB   BL            _puts
__text:000028DC 00 00 A0 E3   MOV           R0, #0
__text:000028E0 80 80 BD E8   LDMFD         SP!, {R7, PC}

__cstring:00003F62 48 65 6C 6C+aHelloWorld_0  DCB "Hello World!", 0

我们就不再重复介绍STMFD/LDMFD指令了。

第一个MOV指令将字符串“Hello World!”的偏移量,0x1686 赋值到R0寄存器。

根据Apple ABI 函数接口规范[19],R7寄存器担当帧指针(frame pointer)寄存器。

“MOVT R0, #0”将0写到R0寄存器的高16位地址。在ARM模式里,常规的MOV指令只能操作寄存器的低16位地址,而单条ARM指令最多是32位/4字节。当然,寄存器之间传递数据没有这种限制。所以,对寄存器的高位(第16位到第31位)进行赋值操作的MOVT指令应运而生。然而此处的这条MOVT指令可有可无,因为在执行上一条指令“MOV R0, #0x1686”时,R0寄存器的高16位本来就会被清零。这或许就是编译器智能方面的缺陷吧。

“ADD R0,PC,R0”将PC和R0进行求和,计算得出字符串的绝对地址。前文介绍过了“位置无关代码”,我们知道程序运行之后的启始地址并不固定。此处,程序对这个地址进行了必要的修正。

然后,程序通过BL指令调用puts()函数,而没有像前文那样调用printf()函数。这种差异来自于GCC编译器[20],编译器将第一个printf()函数替换为puts()函数(这两个函数的作用几乎相同)。

所谓“几乎”就意味着它们还存在差别事实上,如printf()函数支持“%”开头的控制符,而puts()函数则不支持这类格式化字符串。如果参数里有这类控制符,那么这两个函数的输出结果还会不同。

为什么GCC编译器会做这种替换?大概是由于这种情况下puts()的效率更高吧。由于puts()函数不处理控制符(%)、只是把各个字符输出到stdout设备上,所以puts()函数的运行速度更快[21]

后面的“MOV R0, #0”指令将R0寄存器置零。

3.4.4 Thumb-2模式下、开启优化选项的Xcode(LLVM)

默认情况下,Xcode 4.6.3 会启用优化模式,并以Thumb-2模式编译源程序。

指令清单3.14 Optimizing Xcode 4.6.3 (LLVM) (Thumb-2模式)

   __text:00002B6C                   
                                   _hello_world 
   __text:00002B6C 80 B5         PUSH                {R7,LR}
   __text:00002B6E 41 F2 D8 30   MOVW                R0, #0x13D8
   __text:00002B72 6F 46         MOV                 R7, SP
   __text:00002B74 C0 F2 00 00   MOVT.W              R0, #0
   __text:00002B78 78 44         ADD                 R0, PC
   __text:00002B7A 01 F0 38 EA   BLX                 _puts
   __text:00002B7E 00 20         MOVS                R0, #0
   __text:00002B80 80 BD         POP                 {R7, PC}
   __cstring:00003E70 48 65 6C   6F 20+aHelloWorld   DCB "Hello word!",0xA,0

上文提到过,thumb模式的BLX和BL指令以2个16位指令的形式成对出现的。在Thumb-2模式下,BL和BLX指令对应的伪opcode有明显的32位指令特征,其对应的opcode都以0xFx或者0xEx开头。

在显示Thumb和Thumb-2模式程序的opcode时,IDA会以两个字节为单位对调。在显示ARM 模式的指令时,IDA以字节为单位、依次逆序显示其opcode。这是字节序的排版差异。

简要地说,在IDA显示ARM平台的指令时,其显示顺序为:

在IDA中,我们可观察到上述MOVW 、MOVT.W、BLX指令都以0xFx开头。

之后的“MOVW R0,#0x13D8”将立即数写到R0寄存器的低16位地址,同时清除寄存器的高16位。

“MOVT.W R0, #0”的作用与前面一个例子中Thumb模式的MOVT的作用相同,只不过此处是Thumb-2的指令。

在这两个例子中,最显著的区别是Thumb-2模式“BLX”指令。此处的BLX与Thumb模式的BL指令有着根本的区别。它不仅将puts()函数的返回地址RA存入了LR寄存器,将控制权交给了puts()函数,而且还把处理器从Thumb/Thumb-2模式调整为ARM模式;它同时也负责在函数退出时把处理器的运行模式进行还原。总之,它同时实现了模式转换和控制权交接的功能,相当于执行了下面的ARM模式的指令:

__symbolstub1:00003FEC _puts            ; CODE XREF: _hello_world+E
__symbolstub1:00003FEC 44 F0 9F E5      LDR PC, =__imp__puts

聪明的读者可能会问,此处为什么不直接调用puts()函数?

直接调用的空间开销更大。

几乎所有的程序都会用到动态链接库,详细说来Windows的程序基本上都会用到DLL文件、Linux程序差不多都会用到.SO文件、MacOSX系统的程序多数也会用到.dylib文件。常用的库函数通常都放在动态链接库里。本例用到的标准C函数——puts()函数也不例外。

可执行的二进制文件(Windows的PE可执行文件,ELF或Mach-O)都有一个输入表段(import section)。输入表段声明了该程序需要通过外部模块加载的符号链接(函数名称和全局变量),并且含有外部模块名称等信息。

在操作系统执行二进制文件的时候,它的加载程序(OS loader)会依据这个表段加载程序所需要的模块。在它加载该程序主模块的时候,对导入的符号链接进行枚举,逐一分配符号链接的地址。

在本例中,_imp_puts是操作系统加载程序(OS loader)为hello world程序提供的外部函数地址,属于32位变量。程序只需要使用LDR指令取出这个变量,并且将它赋值给PC寄存器,就可以调用puts()函数。

可见,一次性地给每个符号链接分配独立的内存地址,可以大幅度地减少OS loader在加载方面的耗时。

前文已经指出,如果只能靠单条指令、而不借助内存的读取操作,CPU就无法把32位数值(指针或立即数)赋值给寄存器。所以,可以建立一个以ARM模式运行的独立函数,让它专门处理动态链接库的接口问题。此后Thumb模式的代码就可以跳转到这个处理接口功能的单指令专用函数。这种专用函数称为(运行模式的)形实转换函数(thunk function)。

前面有一个ARM模式的编译例子,它就使用BL指令实现相同功能的形实转换函数。但是那个程序使用的指令是BL而不是BLX,可见处理器并没有切换运行模式。

形实转换函数(thunk function)的由来

形实转换函数,是“形参与实参互相转换的函数”的缩写。它不仅是缩写词,而且是外来词。这一专用名词的出处可参见:http://www.catb.org/jargon/html/T/thunk.html

P. Z. Ingerman在1961年首次提出了thunk的概念,这个概念沿用至今:在编译过程中,为满足当时的过程(函数)调用约定,当形参为表达式时,编译器都会产生thunk,把返回值的地址传递给形参。

微软和IBM都对“thunk”一词有定义,将从16位到32位和从32位到16位的转变叫作“thunk”。

3.4.5 ARM64

GCC

使用GCC 4.8.1将上述代码编译为ARM64程序,可得到如下所示的代码。

指令清单3.15 Non-optimizing GCC 4.8.1 + objdump

 1  0000000000400590 <main>:
 2    400590:        a9bf7bfd     stp     x29, x30, [sp,#-16]!
 3    400594:        910003fd     mov     x29, sp
 4    400598:        90000000     adrp    x0, 400000 <_init-0x3b8>
 5    40059c:        91192000     add     x0, x0, #0x648
 6    4005a0:        97ffffa0     bl      400420 <puts@plt>
 7    4005a4:        52800000     mov     w0, #0x0          //  #0
 8    4005a8:        a8c17bfd     ldp     x29, x30, [sp],#16
 9    4005ac:        d65f03c0     ret
10
11  ...
12  
13  Contents of section .rodata:
14   400640 01000200 00000000 48656c6c 6f210000  ........Hello!..

一方面,ARM64的CPU只可能运行于ARM模式、不可运行于Thumb 或 Thumb-2模式,所以它必须使用32位的指令。另一方面,64位平台的寄存器数量也翻了一翻,拥有了32个X-字头寄存器(请参见附录B.4.1)。当然,程序还可以通过W-字头的名称直接访问寄存器的低32位空间。

上述程序的STP(Store Pair)指令把两个寄存器(即X29,X30)的值存储到栈里。虽然这个指令实际上可以把这对数值存储到内存中的任意地址,但是由于该指令明确了SP寄存器,所以它就是通过栈来存储这对数值。ARM64平台的寄存器都是64位寄存器,每个寄存器可存储8字节数据。所以程序要分配16字节的空间来存储两个寄存器的值。

这条指令中的感叹号标志,意味着其标注的运算会被优先执行。即,该指令先把SP的值减去16,在此之后再把两个寄存器的值写在栈里。这属于“预索引/pre-index”指令。此外还有“延迟索引/post-index”指令与之对应。有关两者的区别,请参见本书28.2节。

以更为易懂的x86指令来解读的话,这条指令相当于PUSH X29和PUSH X30两条指令。在ARM64平台上,X29寄存器是帧指针FP,X30起着LR的作用,所以这两个寄存器在函数的序言和尾声处成对出现。

第二条指令把SP的值复制给X29,即FP。这用来设置函数的栈帧。

ADRP和ADD指令相互配合,把“Hello!”字符串的指针传递给X0寄存器,继而充当函数参数传递给被调用函数。受到指令方面的限制,ARM无法通过单条指令就把一个较大的立即数赋值给寄存器(可参见本书的28.3.1节)。所以,编译器要组合使用数条指令进行立即数赋值。第一条ADRP把4KB页面的地址传递给X0,而后第二条ADD进行加法运算并给出最终的指针地址。详细解释请参见本书28.4节。

0x400000 + 0x648 = 0x400648。这个数是位于.rodata数据段的C字符串“Hello!”的地址。

接下来,程序使用BL指令调用puts()函数。这部分内容的解读可参见3.4.3节。

MOV指令用来给W0寄存器置零。W0是X0寄存器的低32位,如下图所示。

高32位
低32位
X0
W0

main()函数通过X0寄存器来传递函数返回值0。程序后续的指令依次制备这个返回值。为什么这里把返回值存储到X0寄存器的低32位,即W0寄存器?这种情况和x86-64平台相似:出于兼容性和向下兼容的考虑,ARM64平台的int型数据仍然是32位数据。对于32位的int型数据来说,X0寄存器的低32位足够大了。

为了进行演示,我对源代码进行了小幅度的修改,使main()返回64位值。

指令清单3.16 main()返回uint64_t 型数据

#include <stdio.h>
#include <stdint.h>

uint64_t main()
{
         printf ("Hello!\n");
         return 0;
}

返回值虽然相同,但是对应的MOV指令发生了变化。

指令清单3.17 Non-optimizing GCC 4.8.1 + objdump

  4005a4:    d2800000    mov     x0, #0x0           // #0

在此之后,LDP (Load Pair)指令还原X29和X30寄存器的值。此处的这条指令没有感叹号标记,这意味着它将率先进行赋值操作,而后再把SP的值与16进行求和运算。这属于延时索引(post-index)指令。

RET指令是ARM64平台的特色指令。虽然它的作用与BX LR相同,但是它实际上是按照寄存器的名称进行跳转的(默认使用X30寄存器指向的地址),通过底层指令提示CPU此处为函数的返回指令、不属于普通转移指令的返回过程。RET指令经过了面向硬件的优化处理,它的执行效率较高。

开启优化功能之后,GCC生成的代码完全一样。本文不在对它进行介绍。

3.5 MIPS

3.5.1 全局指针Global pointer

全局指针是MIPS软件系统的一个重要概念。我们已经知道,每条MIPS指令都是32位的指令,所以单条指令无法容纳32位地址(指针)。这种情况下MIPS就得传递一对指令才能使用一个完整的指针。在前文的例子中,GCC在生成文本字符串的地址时,就采用了类似的技术。

从另一方面来说,单条指令确实可以容纳一组由寄存器的符号、有符号的16位偏移量(有符号数)。因此任何一条指令都可以构成的表达式,访问某个取值范围为“寄存器−32768”~“寄存器+32767”之间的地址(总共69KB)。为了简化静态数据的访问操作,MIPS平台特地为此保留了一个专用的寄存器,并且把常用数据分配到了一个大小为64KB的内存数据空间里。这种专用的寄存器就叫作“全局指针”寄存器。它的值是一个指针,指向64KB(静态)数据空间的正中间。而这64KB空间通常用于存储全局变量,以及printf()这类由外部导入的的外部函数地址。GCC的开发团队认为:获取函数地址这类的操作,应当由单条指令完成;双指令取址的运行效率不可接受。

在ELF格式的文件中,这个64KB的静态数据位于.sbss 和.sdata之中。“.sbss”是small BSS(Block Started by Symbol)的缩写,用于存储非初始化的数据。“.sdata”是small data的缩写,用于存储有初始化数值的数据。

根据这种数据布局编程人员可以自行决定把需要快速访问的数据放在.sdata、还是.sbss数据段中。

有多年工作经验的人员可能会把全局指针和MS-DOS内存(参见本书第94章)、或者MS-DOS的XMS/EMS内存管理器联系起来。这些内存管理方式都把数据的内存存储空间划分为数个64KB区间。

全局指针并不是MIPS平台的专有概念。至少PowerPC平台也使用了这一概念。

3.5.2 Optimizing GCC

下面这段代码显示了“全局指针”的特色。

指令清单3.18 Optimizing GCC 4.4.5 (汇编输出)

 1 $LC0:
 2 ; \000 is zero byte in octal base:
 3          .ascii "Hello, world!\012\000"
 4 main:
 5 ; function prologue.
 6 ; set the GP:
 7          lui    $28,%hi(__gnu_local_gp)
 8          addiu  $sp,$sp,-32
 9          addiu  $28,$28,%lo(__gnu_local_gp)
10 ; save the RA to the local stack:
11          sw     $31,28($sp)
12 ; load the address of the puts() function from the GP to $25:
13          lw     $25,%call16(puts)($28)
14 ; load the address of the text string to $4 ($a0):
15          lui    $4,%hi($LC0)
16 ; jump to puts(), saving the return address in the link register:
17          jalr   $25
18          addiu  $4,$4,%lo($LC0) ; branch delay slot
19 ; restore the RA:
20          lw     $31,28($sp)
21 ; copy 0 from $zero to $v0:
22          move   $2,$0
23 ; return by jumping to the RA:
24          j      $31
25 ; function epilogue:
26          addiu $sp,$sp,32 ; branch delay slot

主函数序言启动部分的指令初始化了全局指针寄存器GP寄存器的值,并且把它指向64KB数据段的正中央。同时,程序把RA寄存器的值存储于本地数据栈。它同样使用puts()函数替代了printf()函数。而puts()函数的地址,则通过LW(Load Word)指令加载到了$25寄存器。此后,字符串的高16位地址和低16位地址分别由LUI(Load Upper Immediate)和ADDIU(Add Immediate Unsigned Word)两条指令加载到$4寄存器。LUI中的Upper一词说明它将数据存储于寄存器的高16位。与此相对应,ADDIU则把操作符地址处的低16位进行了求和运算。ADDIU指令位于JALR指令之后,但是会先于后者运行[22]。$4寄存器其实就是$A0寄存器,在调用函数时传递第一个参数[23]

JALR (Jump and Link Register)指令跳转到$25寄存器中的地址,即puts()函数的启动地址,并且把下一条LW指令的地址存储于RA寄存器。可见,MIPS系统调用函数的方法与ARM系统相似。需要注意的是,由于分支延迟槽效应,存储于RA寄存器的值并非是已经运行过的、“下一条”指令的地址,而是更后面那条(延迟槽之后的)指令的地址。所以,在执行这条JALR指令的时候,写入RA寄存器的值是PC+8,即ADDIU后面的那条LW指令的地址。

第19行的LW (Load Word)指令,用于把本地栈中的RA值恢复回来。请注意,这条指令并不位于被调用函数的函数尾声。

第22行的MOVE指令把$0($ZERO)的值复制给$2($V0)。MIPS有一个常量寄存器,它里面的值是常量0。很明显,因为MIPS的研发人员认为0是计算机编程里用得最多的常量,所以他们开创了一种使用$0寄存器提供数值0的机制。这个例子演示了另外一个值得注意的现象:在MIPS系统之中,没有在寄存器之间复制数值的(硬件)指令。确切地说,MOVE DST, SRC是通过加法指令ADD DST,SRC, $ZERO变相实现的,即DST=SRC+0,这两种操作等效。由此可见,MIPS研发人员希望尽可能地复用opcode,从而精简opcode的总数。然而这并不代表每次运行MOVE指令时CPU都会进行实际意义上的加法运算。CPU能够对这类伪指令进行优化处理,在运行它们的时候并不会用到ALU(Arithmetic logic unit)。

第24行的J指令会跳转到RA所指向的地址,完成从被调用函数返回调用者函数的操作。还是由于分支延迟槽效应,其后的ADDIU指令会先于J指令运行,构成函数尾声。

我们再来看看IDA生成的指令清单,熟悉一下各寄存器的伪名称。

代码清单3.19 Opimizing GCC4.4.5(IDA)

 1 .text:00000000 main:
 2 .text:00000000
 3 .text:00000000 var_10       = -0x10
 4 .text:00000000 var_4        = -4
 5 .text:00000000
 6 ; function prologue.
 7 ; set the GP:
 8 .text:00000000             lui        $gp, (__gnu_local_gp >> 16)
 9 .text:00000004             addiu      $sp, -0x20
10 .text:00000008             la         $gp, (__gnu_local_gp & 0xFFFF)
11 ; save the RA to the local stack:
12 .text:0000000C             sw         $ra, 0x20+var_4($sp)
13 ; save the GP to the local stack:
14 ; for some reason, this instruction is missing in the GCC assembly output:
15 .text:00000010             sw         $gp, 0x20+var_10($sp)
16 ; load the address of the puts() function from the GP to $t9:
17 .text:00000014             lw         $t9, (puts & 0xFFFF)($gp)
18 ; form the address of the text string in $a0:
19 .text:00000018             lui        $a0, ($LC0 >> 16) # "Hello, world!"
20 ; jump to puts(), saving the return address in the link register:
21 .text:0000001C             jalr       $t9
22 .text:00000020             la         $a0, ($LC0 & 0xFFFF) # "Hello, world!"
23 ; restore the RA:
24 .text:00000024             lw         $ra, 0x20+var_4($sp)
25 ; copy 0 from $zero to $v0:
26 .text:00000028             move       $v0, $zero
27 ; return by jumping to the RA:
28 .text:0000002C             jr         $ra
29 ; function epilogue:
30 .text:00000030             addiu      $sp, 0x20

第15行的指令使用局部栈保存GP的值。令人感到匪夷所思的是,GCC的汇编输出里看不到这条指令,或许这是GCC自身的问题[24]。严格地说,此时有必要保存GP的值。毕竟每个函数都有着自己的64KB数据窗口。

程序中保存puts()函数地址的寄存器叫作$T9寄存器。这类T-开头的寄存器叫作“临时”寄存器,用于保存代码里的临时值。调用者函数负责保存这些寄存器的数值(caller-saved),因为它有可能会被被调用的函数重写。

3.5.3 Non-optimizing GCC

代码清单3.20 Non-optimizing GCC 4.4.5 (汇编输出)

 1 $LC0:
 2          .ascii "Hello, world!\012\000"
 3 main:
 4 ; function prologue.
 5 ; save the RA ($31) and FP in the stack:
 6          addiu  $sp,$sp,-32
 7          sw     $31,28($sp)
 8          sw     $fp,24($sp)
 9 ; set the FP (stack frame pointer):
10          move   $fp,$sp
11 ; set the GP:
12          lui    $28,%hi(__gnu_local_gp)
13          addiu  $28,$28,%lo(__gnu_local_gp)
14 ; load the address of the text string:
15          lui    $2,%hi($LC0)
16          addiu  $4,$2,%lo($LC0)
17 ; load the address of puts() using the GP:
18          lw     $2,%call16(puts)($28)
19          nop
20 ; call puts():
21          move  $25,$2
22          jalr  $25
23          nop; branch delay slot
24
25 ; restore the GP from the local stack:
26          lw    $28,16($fp)
27 ; set register $2 ($V0) to zero:
28          move  $2,$0
29 ; function epilogue.
30 ; restore the SP:
31          move  $sp,$fp
32 ; restore the RA:
33          lw    $31,28($sp)
34 ; restore the FP:
35          lw    $fp,24($sp)
36          addiu $sp,$sp,32
37 ; jump to the RA:
38          j     $31
39          nop; branch delay slot

未经优化处理的GCC输出要详细得多。此处,我们可以观察到程序把FP当作栈帧的指针来用,而且它还有3个NOP(空操作)指令。在这3个空操作指令中,第二个、第三个指令都位于分支跳转指令之后。

笔者个人认为(虽然目前无法肯定),由于这些地方都存在分支延迟槽,所以GCC编译器会在分支语句之后都添加NOP指令。不过,在启用它的优化选项之后,GCC可能就会删除这些NOP指令。所以,此处仍然存在这些NOP指令。

使用IDA程序观察下面这段代码。

指令清单3.21 Non-optimizing GCC 4.4.5 (IDA)

 1 .text:00000000 main:
 2 .text:00000000
 3 .text:00000000 var_10      = -0x10
 4 .text:00000000 var_8       = -8
 5 .text:00000000 var_4       = -4
 6 .text:00000000
 7 ; function prologue.
 8 ; save the RA and FP in the stack:
 9 .text:00000000             addiu      $sp, -0x20
10 .text:00000004             sw         $ra, 0x20+var_4($sp)
11 .text:00000008             sw         $fp, 0x20+var_8($sp)
12 ; set the FP (stack frame pointer):
13 .text:0000000C             move       $fp, $sp
14 ; set the GP:
15 .text:00000010             la         $gp, __gnu_local_gp
16 .text:00000018             sw         $gp, 0x20+var_10($sp)
17 ; load the address of the text string:
18 .text:0000001C             lui        $v0, (aHelloWorld >> 16) # "Hello, world!"
19 .text:00000020             addiu      $a0, $v0, (aHelloWorld & 0xFFFF) # "Hello, world!"
20 ; load the address of puts() using the GP:
21 .text:00000024             lw         $v0, (puts & 0xFFFF)($gp)
22 .text:00000028             or         $at, $zero ; NOP
23 ; call puts():
24 .text:0000002C             move       $t9, $v0
25 .text:00000030             jalr       $t9
26 .text:00000034             or         $at, $zero ; NOP
27 ; restore the GP from local stack:
28 .text:00000038             lw         $gp, 0x20+var_10($fp)
29 ; set register $2 ($V0) to zero:
30 .text:0000003C             move       $v0, $zero
31 ; function epilogue.
32 ; restore the SP:
33 .text:00000040             move       $sp, $fp
34 ; restore the RA:
35 .text:00000044             lw         $ra, 0x20+var_4($sp)
36 ; restore the FP:
37 .text:00000048             lw         $fp, 0x20+var_8($sp)
38 .text:0000004C             addiu      $sp, 0x20
39 ; jump to the RA:
40 .text:00000050             jr         $ra
41 .text:00000054             or         $at, $zero ; NOP

在程序的第15行出现了一个比较有意思的现象——IDA识别出了LUI/ADDIU指令对,把它们显示为单条的伪指令LA(Load address)。那条伪指令占用了8个字节!这种伪指令(即“宏”)并非真正的MIPS指令。通过这种名称替换,IDA帮助我们对指令的作用望文思义。

NOP的显示方法也构成了它的另外一种特点。因为IDA并不会自动地把实际指令匹配为NOP指令,所以位于第22行、第26行、第41行的指令都是“OR $AT, $ZERO”。表面上看,它将保留寄存器$AT的值与0进行或运算。但是从本质上讲,这就是发送给CPU的NOP指令。MIPS和其他的一些硬件平台的指令集都没有单独的NOP指令。

3.5.4 栈帧

本例使用寄存器来传递文本字符串的地址。但是它同时设置了局部栈,这是为什么呢?由于程序在调用printf()函数的时候由于程序必须保存RA寄存器的值和GP的值,故而此处出现了数据栈。如果此函数是叶函数,它有可能不会出现函数的序言和尾声,有关内容请参见本书的2.3节。

3.5.5 Optimizing GCC: GDB的分析方法

指令清单3.22 GDB的操作流程

root@debian-mips:~# gcc hw.c -O3 -o hw
root@debian-mips:~# gdb hw
GNU gdb (GDB) 7.0.1-debian
Copyright (C) 2009 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "mips-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /root/hw...(no debugging symbols found)...done.
(gdb) b main
Breakpoint 1 at 0x400654
(gdb) run
Starting program: /root/hw

Breakpoint 1, 0x00400654 in main ()
(gdb) set step-mode on
(gdb) disas
Dump of assembler code for function main:
0x00400640 <main+0>:     lui     gp,0x42
0x00400644 <main+4>:     addiu   sp,sp,-32
0x00400648 <main+8>:     addiu   gp,gp,-30624
0x0040064C <main+12>:    sw      ra,28(sp)
0x00400650 <main+16>:    sw      gp,16(sp)
0x00400654 <main+20>:    lw      t9,-32716(gp)
0x00400658 <main+24>:    lui     a0,0x40
0x0040065c <main+28>:    jalr    t9
0x00400660 <main+32>:    addiu   a0,a0,2080
0x00400664 <main+36>:    lw      ra,28(sp)
0x00400668 <main+40>:    move    v0,zero
0x0040066c <main+44>:    jr      ra
0x00400670 <main+48>:    addiu   sp,sp,32
End of assembler dump.
(gdb) s
0x00400658 in main ()
(gdb) s
0x0040065c in main ()
(gdb) s
0x2ab2de60 in printf () from /lib/libc.so.6
(gdb) x/s $a0
0x400820:          "hello, world"
(gdb)

3.6 总结

x64和x86指令的主要区别体现在指针上,前者使用64位指针而后者使用32位指针。近年来,内存的价格在不断降低,而CPU的计算能力也在不断增强,当计算机的内存增加到一定程度时,32位指针就无法满足寻址的需要了,所以指针也随之演变为64位指针。

3.7 练习题

3.7.1 题目1

请描述下述32位函数的功能。

main:
     push 0xFFFFFFFF
     call MessageBeep
     xor  eax,eax
     retn
3.7.2 题目2

请描述Linux函数的功能,这里使用了AT&T汇编语言语法。

main:
     pushq   %rbp
     movq    %rsp, %rbp
     mov1    %2, %edi
     call    sleep
     popq    %rbp
     ret

[1] Brian W. Kernighan. The C Programming Language. Ed. by Dennis M. Ritchie. 2nd. Prentice Hall Professional Tech- nical Reference, 1988. ISBN: 0131103709。

[2] 但是CPU 标志位会发生变化。

[3] 参见http://en.wikipedia.org/wiki/Exclusive_or。

[4] C runtime library:sec:CRT,参见本书68.1节。

[5] 参考Wikipedia:Data structure alignment http://en.wikipedia.org/wiki/Data_structure_ alignment。

[6] 参考Mit13。

[7] 参考Int13。

[8] 可通过菜单“Options Number of opcode bytes”启用有关选项。

[9] 请参考Mit13。

[10] Apple公司的Xcode 4.6.3使用的前段编译器是开源的GCC程序,代码生成程序(code generator)使用的是LLVM。

[11] 例如,ARM模式的指令集里没有PUSH/POP指令。

[12] STMFD是Storage Multiple Full Descending 的缩写。

[13] stack pointer,栈指针。x86/x64框架中的SP是SP/ESP/RSP,而ARM框架的SP就是SP。

[14] Program Counter,中文叫做指令指针或程序计数器。x86/x64里的PC叫作IP/EIP/RIP,ARM里它就叫PC。

[15] 本书介绍操作系统的部分有更详细的说明。在不同框架的汇编语言中,PC很少会是当前指令的指针地址+1,这和CPU的流水/pipeline模式有关。如需完整的官方介绍,请参阅http://www.arm. com/pdfs/comparison-arm7-arm9-v1.pdf。

[16] BL是Branch with Link的缩写,相当于x86的call指令。

[17] 这是二进制里26位有符号整型数据(26 bits signed int)的数值范围。

[18] LDMFD是Load Multiple Full Descending的缩写。

[19] 参照参考文献App10。

[20] Xcode 4.6.3是基于GCC的编译器。

[21] 请参考http://www.ciselant.de/projects/gcc_printf/gcc_printf.html。

[22] 请参考前文介绍的分支延迟槽(Branch delay slot)效应。

[23] 有关MIPS各寄存器的用途,请参见附录C.1。

[24] 很明显,对于GCC的用户来说,查看汇编指令的功能不是那么重要。所以,GCC输出的汇编指令之中仍然可能存在一些(在生成汇编指令的阶段)未被修正的错误。