第7章 scanf()

本章演示scanf()函数。

7.1 演示案例

#include<stdio.h>

intmain()
{
        int x;
        printf ("Enter X:\n");

        scanf ("%d", &x);

        printf ("You entered %d...\n", x);

        return 0;
};

好吧,我承认时下的程序如果大量使用scanf()就不会有什么前途。本文仅用这个函数演示整形数据的指针的传递过程。

7.1.1 指针简介

在计算机科学里,“指针”属于最基础的概念。如果直接向函数传递大型数组、结构体或数据对象,程序的开销就会很大。毫无疑问,使用指针将会降低开销。不过指针的作用不只如此:如果不使用指针,而是由调用方函数直接传递数组或结构体这种大型数据(同时还要返回这些数据),那么参数的传递过程将会复杂得出奇。所以,调用方函数只负责传递数组或结构体的地址,让被调用方函数处理地址里的数据,无疑是最简单的做法。

在C/C++的概念中,指针就是描述某个内存地址的数据。

x86系统使用体系32位数字(4字节数据)描述指针;x64系统则使用64位数字(8字节数据)。从数据空间来看,64位系统的指针比32位系统的指针大了一倍。当人们逐渐从x86平台开发过渡到x86-64平台开发的时候,不少人因为难以适应而满腹牢骚。

程序人员确实可在所有的程序里仅仅使用无类型指针这一种指针。例如,在使用C语言memcpy()函数、在内存中复制数据的时候,程序员完全不必知道操作数的数据类型,直接使用2个void*指针复制数据。这种情况下,目标地址里数据的数据类型并不重要,重要的是存储数据的空间大小。

指针还广泛应用于多个返回值的传递处理。本书的第10章会详细介绍这部分内容。scanf()函数就可以返回多个值。它不仅能够分析调用方法传递了多少个参数,而且还能读取各个参数的值。

在C/C++的编译过程里,编译器只会在类型检查的阶段才会检查指针的类型。在编译器生成的汇编程序里,没有指针类型的任何信息。

7.1.2 x86

MSVC

使用MSVC 2010编译上述源代码,可得到下述汇编指令:

CONST     SEGMENT
$SG3831     DB     'Enter X:', 0aH, 00H
$SG3832     DB     '%d', 00H
$SG3833     DB     'You entered %d...', 0aH, 00H
CONST     ENDS
PUBLIC     _main
EXTRN      _scanf:PROC
EXTRN      _printf:PROC
; Function compile flags: /Odtp
_TEXT     SEGMENT
_x$=-4                ; size = 4 
_main     PROC
     push   ebp
     mov    ebp, esp
     push   ecx
     push   OFFSET $SG3831 ; 'Enter X: '
     call   _printf
     add    esp, 4
     lea    eax, DWORD PTR _x$[ebp]
     push   eax
     push   OFFSET $SG3832 ; '%d'
     call   _scanf
     add    esp, 8
     mov    ecx, DWORD PTR _x$[ebp]
     push   ecx
     push   OFFSET $SG3833 ; 'You entered %d...'
     call   _printf
     add    esp, 8

     ; return 0
     xor    eax, eax
     mov    esp, ebp
     pop    ebp
     ret   0
_main   ENDP
_TEXT   ENDS

变量x是局部变量。

C/C++标准要求:函数体内部应当可以访问局部变量,且函数外部应该访问不到函数内部的局部变量。演变至今,人们不约而同地利用数据栈来存储局部变量。虽然分配局部变量的方法不只这一种,但是所有面向x86平台的编译器都约定俗成般地采用这种方法存储局部变量。

在函数序言处有一条“PUSH ECX”指令。因为函数尾声处没有对应的“POP ECX”指令,所以它的作用不是保存ECX的值。

实际上,这条指令在栈内分配了4字节的空间、用来存储局部变量x

汇编宏_x$ (其值为−4)用于访问局部变量x,而EBP寄存器用来存储栈当前帧的指针。

在函数运行的期间,EBP一直指向当前的栈帧(stack frame)。这样,函数即可通过EBP+offset的方式访问本地变量、以及外部传入的函数参数。

ESP也可以用来访问本地变量,获取函数所需的运行参数。不过ESP的值经常发生变化,用起来并不方便。函数在启动之初就会利用EBP寄存器保存ESP寄存器的值。这就是为了确保在函数运行期间保证EBP寄存器存储的原始ESP值固定不变。

在32位系统里,典型的栈帧(stack frame)结构如下表所示。

…… ……
EBP-8 局部变量#2,IDA标记为var_8
EBP-4 局部变量#1,IDA标记为var_4
EBP EBP的值
EBP+4 返回地址Return address
EBP+8 函数参数#1,IDA标记为arg_0
EBP+0xC 函数参数#2,IDA标记为arg_4
EBP+0x10 函数参数#3,IDA标记为arg_8
…… ……

本例中的scanf()有两个参数。

第一个参数是一个指针,它指向含有“%d”的格式化字符串。第二个参数是局部变量x的地址。

首先,“lea eax, DWORD PTR _x$[ebp]”指令将变量x的地址放入EAX寄存器。“lea”是“load effective address”的缩写,能够将源操作数(第二个参数)给出的有效地址(offset)传送到目的寄存器(第一个参数)之中。[1]

此处,LEA将EBP寄存器的值与宏_x$求和,然后使用EAX寄存器存储这个计算结果;也就是等同于“ lea eax, [ebp−4]”。

就是说,程序会将EBP寄存器的值减去4,并把这个运算结果存储于EAX寄存器。把EAX寄存器的值推送入栈之后,程序才开始调用scanf()函数。

在调用printf()函数之前,程序传给它第一个参数,即格式化字符串“You entered %d...\n”的指针。

printf()函数所需的第二个参数由“mov ecx,[ebp−4]”指令间接取值。传递给ecx的值是ebp−4所指向的地址的值(即变量x的值),而不是ebp−4所表达的地址。

此后的指令把ECX推送入栈,然后启动printf()函数。

7.1.3 MSVC+OllyDbg

现在使用OllyDbg调试上述例子。加载程序之后,一直按F8键单步执行,等待程序退出ntdll.dll、进入我们程序的主文件。然后向上翻滚滚轴,查找main()主函数。在main()里面点中第一条指令“PUSH EBP”,并在此处按下F2键设置断点。接着按F9键,运行断点之前的指令。

我们一起来跟随调试器查看变量x的计算指令,如图7.1所示。

..\TU\0701.tif{}

图7.1 OllyDbg:局部变量x的赋值过程

在这个界面里,我们在寄存器的区域内用右键单击EAX寄存器,然后选择“Follow in stack”。如此一来,OllyDbg就会在栈窗口里显示栈地址和栈内数据,以便我们清楚地观察栈里的局部变量。图中红箭头所示的就是栈里的数据。其中,在地址0x6E494714处的数据就是脏数据。在下一时刻,PUSH指令会把数据存储到栈里的下一个地址。接下来,在程序执行完scanf()函数之前,我们一直按F8键。在执行scanf()函数的时候,我们要在运行程序的终端窗口里输入数据,例如123,如图7.2所示。

..\TU\0702.tif

图7.2 控制台窗口

scanf()函数的执行之后的情形如图7.3所示。

..\TU\0703.tif{}

图7.3 OllyDbg:运行scanf()之后

EAX寄存器里存有函数的返回值1。这表示它成功地读取了1个值。我们可以在栈里找到局部变量的地址,其数值为0x7B(即数字123)。

这个值将通过栈传递给ECX寄存器,然后再次通过栈传递给printf()函数,如图7.4所示。

..\TU\0704.tif{}

图7.4 OllyDbg:将数值传递给printf()

GCC

我们在Linux GCC 4.4.1下编译这段程序。

main         proc near

var_20       = dword ptr -20h
var_1C       = dword ptr -1Ch
var_4        = dword ptr -4

             push    ebp
             mov     ebp, esp
             and     esp, 0FFFFFFF0h
             sub     esp, 20h
             mov     [esp+20h+var_20], offset aEnterX ; "Enter X:"
             call    _puts
             mov     eax, offset aD  ; "%d"
             lea     edx, [esp+20h+var_4]
             mov     [esp+20h+var_1C], edx
             mov     [esp+20h+var_20], eax
             call    ___isoc99_scanf
             mov     edx, [esp+20h+var_4]
             mov     eax, offset aYouEnteredD___ ; "You entered %d...\n"
             mov     [esp+20h+var_1C], edx
             mov     [esp+20h+var_20], eax
             call    _printf
             mov     eax, 0
             leave
             retn
main         endp

GCC把printf()替换为puts(),这和3.4.3节中的现象相同。

此处,GCC通过MOV指令实现入栈操作,这点和MSVC相同。

其他

这个例子充分证明:在编译过程中,编译器会遵循C/C++源代码的表达式顺序和模块化结构。在C/C++源代码中,只要两个相邻语句之间没有其他的表达式,那么在生成的机器码中对应的指令之间就不会有其他的指令,而且其执行顺序也与源代码各语句的书写顺序相符。

7.1.4 x64

在编译面向x64平台的可执行程序时,由于这个程序的参数较少,编译器会直接使用寄存器传递参数。除此之外,编译过程和x86的编译过程没有太大的区别。

MSVC

指令清单7.1 MSVC 2012 x64

_DATA   SEGMENT
$SG1289 DB       'Enter X: ', 0aH, 00H
$SG1291 DB       '%d', 00H
$SG1292 DB       'You entered %d... ', 0aH, 00H
_DATA   ENDS

_TEXT   SEGMENT
x$ = 32
main    PROC
$LN3:
        sub      rsp, 56
        lea      rcx, OFFSET FLAT:$SG1289 ; 'Enter X: '
        call     printf
        lea      rdx, QWORD PTR x$[rsp]
        lea      rcx, OFFSET FLAT:$SG1291 ; '%d'
        call     scanf
        mov      edx, DWORD PTR x$[rsp]
        lea      rcx, OFFSET FLAT:$SG1292 ; 'You entered %d... '
        call     printf

        ; return 0
        xor      eax, eax
        add      rsp, 56
        ret      0
main    ENDP
_TEXT   ENDS

GCC

使用GCC 4.6(启用优化选项-O3)编译上述程序,可得到如下所示的汇编指令。

指令清单7.2 Optimizing GCC 4.4.6 x64

.LC0:
        .string "Enter X:"
.LC1:
        .string "%d"
.LC2:
        .string "You entered %d...\n"

main:
        sub     rsp, 24
        mov     edi, OFFSET FLAT:.LC0 ; "Enter X:"
        call    puts
        lea     rsi, [rsp+12]
        mov     edi, OFFSET FLAT:.LC1 ; "%d"
        xor     eax, eax
        call    __isoc99_scanf
        mov     esi, DWORD PTR [rsp+12]
        mov     edi, OFFSET FLAT:.LC2 ; "You entered %d...\n"
        xor     eax, eax
        call    printf

        ;return 0
        xor     eax, eax
        add     rsp, 24
        ret
7.1.5 ARM

Optimizing Keil 6/2013 (Thumb模式)

.text:00000042              scanf_main
.text:00000042
.text:00000042              var_8            = -8
.text:00000042
.text:00000042 08 B5                         PUSH      {R3,LR}
.text:00000044 A9 A0                         ADR       R0, aEnterX    ; "Enter X:\n"
.text:00000046 06 F0 D3 F8                   BL        __2printf
.text:0000004A 69 46                         MOV       R1, SP
.text:0000004C AA A0                         ADR       R0, aD          ; "%d"
.text:0000004E 06 F0 CD F8                   BL        __0scanf
.text:00000052 00 99                         LDR       R1, [SP,#8+var_8]
.text:00000054 A9 A0                         ADR       R0,aYouEnteredD___ ; "You entered%d…\n"
.text:00000056 06 F0 CB F8                   BL        __2printf
.text:0000005A 00 20                         MOVS      R0, #0
.text:0000005C 08 BD                         POP       {R3,PC}

scanf()函数同样要借助指针传递返回值。在本例里,编译器给它分配了一个整型变量的指针。整型数据占用4个字节的存储空间。但是返回数据的内存地址,可以直接放在CPU的寄存器中。在生成的汇编代码里,变量x存在于数据栈中,被IDA标记为var_8,然而,此时程序完全可以直接使用。栈指针SP指向的存储空间,没有必要像上述代码那样机械式地调整SP分配栈空间。此后,程序把栈指针SP(x的地址)存入R1寄存器,再把格式化字符串的偏移量存入R0寄存器,如此一来,scanf()函数就获得了它所需要的所有参数。在此之后,程序使用LDR指令把栈中的返回值复制到R1寄存器、以供printf()调用。

即使使用Xcode LLVM 程序以ARM模式编译这段程序,最终生成的汇编代码也没有实质性的区别,所以本书不再演示。

ARM64

指令清单7.3 Non-optimizing GCC 4.9.1 ARM64

 1 .LC0:
 2          .string "Enter X:"
 3 .LC1:
 4          .string "%d"
 5 .LC2:
 6          .string "You entered %d...\n"
 7 scanf_main:
 8 ; subtract 32 from SP, then save FP and LR in stack frame:
 9          stp     x29, x30, [sp, -32]!
10 ; set stack frame (FP=SP)
11          add     x29, sp, 0
12 ; load pointer to the "Enter X:" string:
13          adrp    x0, .LC0
14          add x0, x0, :lo12:.LC0
15 ; X0=pointer to the "Enter X:" string
16 ; print it:
17          bl      puts
18 ; load pointer to the "%d" string:
19          adrp    x0, .LC1
20          add     x0, x0, :lo12:.LC1
21 ; find a space in stack frame for "x" variable (X1=FP+28):
22          add     x1, x29, 28
23 ; X1=address of "x" variable
24 ; pass the address to scanf() and call it:
25          bl      __isoc99_scanf
26 ; load 32-bit value from the variable in stack frame:
27          ldr     w1, [x29,28]
28 ; W1=x
29 ; load pointer to the "You entered %d...\n" string
30 ; printf() will take text string from X0 and "x" variable from X1 (or W1)
31          adrp    x0, .LC2
32          add     x0, x0, :lo12:.LC2
33          bl      printf
34 ; return 0
35          mov     w0, 0
36 ; restore FP and LR, then add 32 to SP:
37          ldp     x29, x30, [sp], 32
38          ret

第22行用来分配局部变量x的存储空间。当scanf()函数获取这个地址之后,它就把用户输入的数据传递给这个内存地址。输入的数据应当是32位整型数据。第27行的指令把输入数据的值传递给printf()函数。

7.1.6 MIPS

MIPS编译器同样使用数据栈存储局部变量x。然后程序就可以通过$sp+24调用这个变量。scanf()函数获取地址指针之后,通过LW(Load Word)指令把输入变量存储到这个地址、以传递给printf()函数。

指令清单7.4 Optimizing GCC 4.4.5 (assembly output)

$LC0:
          .ascii  "Enter X:\000"
$LC1:
          .ascii  "%d\000"
$LC2:
          .ascii  "You entered %d...\012\000"
main:
; function prologue:
          lui     $28,%hi(__gnu_local_gp)
          addiu   $sp,$sp,-40
          addiu   $28,$28,%lo(__gnu_local_gp)
          sw      $31,36($sp)
; call puts():
          lw      $25,%call16(puts)($28)
          lui     $4,%hi($LC0)
          jalr    $25
          addiu   $4,$4,%lo($LC0) ; branch delay slot
; call scanf():
          lw      $28,16($sp)
          lui     $4,%hi($LC1)
          lw      $25,%call16(__isoc99_scanf)($28)
; set 2nd argument of scanf(), $a1=$sp+24:
          addiu   $5,$sp,24
          jalr    $25
          addiu   $4,$4,%lo($LC1) ; branch delay slot

; call printf():
          lw      $28,16($sp)
; set 2nd argument of printf(),
; load word at address $sp+24:
          lw      $5,24($sp)
          lw      $25,%call16(printf)($28)
          lui     $4,%hi($LC2)
          jalr    $25
          addiu   $4,$4,%lo($LC2) ; branch delay slot

; function epilogue:
          lw      $31,36($sp)
; set return value to 0:
          move    $2,$0
; return:
          j       $31
          addiu   $sp,$sp,40 ; branch delay slot

IDA将本程序的栈结构显示如下。

指令清单7.5 Optimizing GCC 4.4.5 (IDA)

.text:00000000 main:
.text:00000000
.text:00000000 var_18     = -0x18
.text:00000000 var_10     = -0x10
.text:00000000 var_4      = -4
.text:00000000
; function prologue:
.text:00000000            lui         $gp, (__gnu_local_gp >> 16)
.text:00000004            addiu       $sp, -0x28
.text:00000008            la          $gp, (__gnu_local_gp & 0xFFFF)
.text:0000000C            sw          $ra, 0x28+var_4($sp)
.text:00000010            sw          $gp, 0x28+var_18($sp)
; call puts():
.text:00000014            lw          $t9, (puts & 0xFFFF)($gp)
.text:00000018            lui         $a0, ($LC0 >> 16)  # "Enter X:"
.text:0000001C            jalr        $t9
.text:00000020            la          $a0, ($LC0 & 0xFFFF)  # "Enter X:" ; branch delay slot
; call scanf():
.text:00000024            lw          $gp, 0x28+var_18($sp)
.text:00000028            lui         $a0, ($LC1 >> 16)  # "%d"
.text:0000002C            lw          $t9, (__isoc99_scanf & 0xFFFF)($gp)
; set 2nd argument of scanf(), $a1=$sp+24:
.text:00000030            addiu       $a1, $sp, 0x28+var_10
.text:00000034            jalr        $t9  ; branch delay slot
.text:00000038            la          $a0, ($LC1 & 0xFFFF)  # "%d"
; call printf():
.text:0000003C            lw          $gp, 0x28+var_18($sp)
; set 2nd argument of printf(),
; load word at address $sp+24:
.text:00000040            lw          $a1, 0x28+var_10($sp)
.text:00000044            lw          $t9, (printf & 0xFFFF)($gp)
.text:00000048            lui         $a0, ($LC2 >> 16) # "You entered %d...\n"
.text:0000004C            jalr        $t9
.text:00000050            la          $a0, ($LC2 & 0xFFFF) # "You entered %d...\n" ; brach delay slot
; function epilogue: 
.text:00000054            lw          $ra, 0x28+var_4($sp)
; set return value to 0: 
.text:00000058            move        $v0, $zero
; return: 
.text:0000005C            jr          $ra
.text:00000060            addiu       $sp, 0x28 ; branch delay slot

7.2 全局变量

在本章前文的那个程序里,如果x不是局部变量而是全局变量,那会是什么情况?一旦x变量成为了全局变量,函数内部的指令、以及整个程序中的任何部分都可以访问到它。虽然优秀的程序不应当使用全局变量,但是我们应当了解它的技术特点。

#include <stdio.h>

//now x is global variable
int x;

int main() 
{
          printf ("Enter X:\n");

          scanf ("%d", &x);

          printf ("You entered %d...\n", x);

          return 0;
};
7.2.1 MSVC:x86
_DATA      SEGMENT
COMM      _x:DWORD
$SG2456      DB     'Enter X: ', 0aH, 00H
$SG2457      DB     '%d', 00H
$SG2458      DB     'You entered %d... ', 0aH, 00H
_DATA      ENDS
PUBLIC     _main
EXTRN     __scanf:PROC
EXTRN     __printf:PROC
; Function compile flags: /Odtp
_TEXT     SEGMENT
_main     PROC
     push    ebp
     mov     ebp, esp
     push    OFFSET $SG2456
     call    _printf
     add     esp, 4
     push    OFFSET _x
     push    OFFSET $SG2457
     call    _scanf
     add     esp, 8
     mov     eax, DWORD PTR _x
     push    eax
     push    OFFSET $SG2458
     call    _printf
     add     esp, 8
     xor     eax, eax
     pop     ebp
     ret     0
_main     ENDP
_TEXT     ENDS

与前文不同的是,x变量的存储空间是数据段(_data域),反而没有使用数据栈。因此整个程序的所有指令都可以直接访问全局变量x。在可执行文件中,未经初始化的变量不会占用任何存储空间。

在某些指令在变量访问这种未初始化的全局变量的时候,操作系统会分配一段数值为零的地址给它。这是操作系统VM(虚拟内存)的管理模式所决定的。

如果对上述源代码稍做改动,加上变量初始化的指令:

int x=10; //设置默认值

那么对应的代码会变为:

_DATA   SEGMENT
_x      DD      0aH
...

上述指令将初始化x。其中DD代表DWORD,表示x是32位的数据。

若在IDA里打开对x进行初始化的可执行文件,我们将会看到在数据段的开头部分看到初始化变量x。紧随其后的空间用于存储本例中的字符串。

用IDA打开7.2节里那个不初始化变量x的例子,那么将会看到下述内容。

.data:0040FA80 _x              dd ?        ; DATA XREF: _main+10
.data:0040FA80                             ; _main+22
.data:0040FA84 dword_40FA84    dd ?        ; DATA XREF: _memset+1E
.data:0040FA84                             ; unknown_libname_1+28
.data:0040FA88 dword_40FA88    dd ?        ; DATA XREF: ___sbh_find_block+5
.data:0040FA88                             ; ___sbh_free_block+2BC
.data:0040FA8C ; LPVOID lpMem
.data:0040FA8C lpMem           dd ?        ; DATA XREF: ___sbh_find_block+B
.data:0040FA8C                             ; ___sbh_free_block+2CA
.data:0040FA90 dword_40FA90    dd ?        ; DATA XREF: _V6_HeapAlloc+13
.data:0040FA90                             ; __calloc_impl+72
.data:0040FA94 dword_40FA94    dd ?        ; DATA XREF: ___sbh_free_block+2FE

这段代码里有很多带“?”标记的变量,这是未初始化的x变量的标记。这意味着在程序加载到内存之后,操作系统将为这些变量分配空间、并填入数字零[2]。但是在可执行文件里,这些未初始化的变量不占用内存空间。为了方便使用巨型数组之类的大型数据,人们刻意做了这种设定。

7.2.2 MSVC:x86+OllyDbg

我们可以在OllyDbg观察程序的数据段里的变量。如图7.5所示。

..\TU\0705.tif{}

图7.5 OllyDbug:运行scanf()函数之后的状态

全局变量x出现在数据段里。在调试器执行完PUSH指令之后,变量x的指针推即被推送入栈,我们就可在栈里右键单击x的地址并选择“Follow in dump”,并在左侧的内存窗口观察它。

在控制台输入123之后,栈里的数据将会变成0x7B(左下窗口的高亮部分)。

您有没有想过,为什么第一个字节是0x7B?若考虑到数权,此处应该是00 00 00 7B。可见,这是x86系统里低位优先的“小端字节序/LITTLE-ENDIAN”的典型特征。小端字节序属于“字节(顺)序/endianness”的一种,它的第一个字节是数权最低的字节,数权最高的字节会排列在最后。本书将在第31章将详细介绍字节序。

此后,EAX寄存器将存储这个地址里的32位值,并将之传递给printf()函数。

本例中,变量x的内存地址是0x00C53394。

在OllyDbg里,按下Alt+M组合键可查看这个进程的内存映射(process memory map)。如图7.6所示,这个地址位于程序PE段的.data域。

..\TU\0706.tif{}

图7.6 OllyDbg:进程内存映射

7.2.3 GCC:x86

在汇编指令层面,Linux与Windows的编译结果区别不大。它们的区别主要体现在字段(segament)名称和字段属性上:Linux GCC生成的未初始化变量会出现在_bss段,对应的ELF文件描述了这个字段数据的属性。

; Segment type: Uninitialized
; Segment permissions: Read/Write

如果给这个变量分配了初始值,比如说10,那么这个变量将会出现在_data段,并且具有下述属性。

; Segment type: Pure data
; Segment permissions: Read/Write
7.2.4 MSVC:x64

指令清单7.6 MSVC 2012 x64

_DATA       SEGMENT
COMM        x:DWORD
$SG2924     DB         'Enter X: ', 0aH, 00H
$SG2925     DB         '%d', 00H
$SG2926     DB         'You entered %d... ', 0aH, 00H
_DATA       ENDS

_TEXT       SEGMENT
main        PROC
$LN3:
            sub        rsp, 40

            lea        rcx, OFFSET FLAT:$SG2924 ; 'Enter X: '
            call       printf
            lea        rdx, OFFSET FLAT:x
            lea        rcx, OFFSET FLAT:$SG2925 ; '%d'
            call       scanf
            mov        edx, DWORD PTR x
            lea        rcx, OFFSET FLAT:$SG2926 ; 'You entered %d... '
            call       printf

            ; return 0
            xor        eax, eax

            add        rsp, 40
            ret        0
main        ENDP
_TEXT       ENDS

这段x64代码与x86的代码没有明显区别。请注意变量x的传递过程:scanf()函数通过LEA指令获取x变量的指针;而printf()函数则是通过MOV指令获取x变量的值。“DWORD PTR”与机器码无关,它是汇编语言的一部分,用来声明后面的变量为32位的值,以便MOV指令能够正确处理数据类型。

7.2.5 ARM: Optimizing Keil 6/2013 (Thumb模式)
.text:00000000 ; Segment type:  Pure code
.text:00000000                  AREA .text, CODE
...
.text:00000000 main
.text:00000000                  PUSH     {R4,LR}
.text:00000002                  ADR      R0, aEnterX            ; "Enter X:\n"
.text:00000004                  BL       __2printf
.text:00000008                  LDR      R1, =x
.text:0000000A                  ADR      R0, aD                 ; "%d"
.text:0000000C                  BL       __0scanf
.text:00000010                  LDR      R0, =x
.text:00000012                  LDR      R1, [R0]
.text:00000014                  ADR      R0, aYouEnteredD___    ; "You entered %d...\n"
.text:00000016                  BL       __2printf
.text:0000001A                  MOVS     R0, #0
.text:0000001C                  POP      {R4,PC}
...
.text:00000020 aEnterX          DCB "Enter X:",0xA,0           ; DATA XREF: main+2
.text:0000002A                  DCB 0
.text:0000002B                  DCB 0
.text:0000002C off_2C           DCD x                          ; DATA XREF: main+8
.text:0000002C                                                 ; main+10
.text:00000030 aD               DCB "%d",0                     ; DATA XREF: main+A
.text:00000033                  DCB 0
.text:00000034 aYouEnteredD___DCB "You entered %d...",0xA,0    ; DATA XREF: main+14
.text:00000047                  DCB 0
.text:00000047 ; .text          ends
.text:00000047
...
.data:00000048 ; Segment type: Pure data
.data:00000048                   AREA .data, DATA
.data:00000048                   ; ORG 0x48
.data:00000048                   EXPORT x
.data:00000048 x                 DCD 0xA                       ; DATA XREF: main+8
.data:00000048                                                 ; main+10
.data:00000048 ; .data           ends

因为变量x是全局变量,所以它出现于数据段的“.data”字段里。有的读者可能会问,为什么文本字符串出现在代码段(.text),但是x变量却出现于数据段(.data)?原因在于x是变量。顾名思义,变量的值可变、属于一种频繁变化的可变数据。在ARM系统里,代码段的程序代码可存储于处理器的ROM(Read-Only Memory),而可变变量存储于RAM(Random-Access Memory)中。与x86/x64系统相比,ARM系统的内存寻址能力很有限,可用内存往往十分紧张。在ARM系统存在ROM的情况下,使用RAM 内存存储常量则明显是一种浪费。此外,ARM系统在启动之后RAM里的值都是随机数;想要使用RAM存储常量,还要单独进行初始化赋值才行。

在后续代码段的指令中,程序给变量x分配了个指针(即off_2c)。此后,程序都是通过这个指针对x变量进行的操作。不这样做的话变量x可能被分配到距离程序代码段很远的内存空间,其偏移量有可能超过有关寻址指令的寻址能力。Thumb模式下,ARM系统的LDR指令只能够使用周边1020字节之内的变量;即使在32位ARM模式下,它也只能调用偏移量在±4095字节之内的变量。这个范围就是变量地址(与调用指令之间)的偏移量的局限。为了保证它的地址离代码段足够近、能被代码调用,就需要就近分配x变量的地址。由于在链接阶段(linker)x的地址可能会被随意分配,甚至可能被分配到外部内存的地址,所以编译器必须在前期阶段就把x的地址分配到就近的区域之内。

另外,如果声明某变量为常量/const,Keil编译程序会把这个变量分配到.constdata字段。这可能是为了便于后期linker把这个字段与代码段一起放入ROM。

7.2.6 ARM64

指令清单7.7 Non-optimizing GCC 4.9.1 ARM64

 1         .comm    x,4,4
 2 .LC0:
 3         .string "Enter X:"
 4 .LC1:
 5         .string "%d"
 6 .LC2:
 7         .string "You entered %d...\n"
 8 f5:
 9 ; save FP and LR in stack frame:
10         stp       x29, x30, [sp, -16]!
11 ; set stack frame (FP=SP)
12         add       x29, sp, 0
13 ; load pointer to the "Enter X:" string:
14         adrp      x0, .LC0
15         add       x0, x0, :lo12:.LC0
16         bl        puts
17 ; load pointer to the "%d" string:
18         adrp      x0, .LC1
19         add       x0, x0, :lo12:.LC1
20 ; form address of x global variable:
21         adrp      x1, x
22         add       x1,  x1, :lo12:x
23         bl        __isoc99_scanf
24 ; form address of x global variable again:
25         adrp      x0, x
26         add       x0, x0, :lo12:x
27 ; load value from memory at this address:
28         ldr       w1, [x0]
29 ; load pointer to the "You entered %d...\n" string:
30         adrp      x0, .LC2
31         add       x0, x0, :lo12:.LC2
32         bl        printf
33 ; return 0
34         mov       w0, 0
35 ; restore FP and LR:
36         ldp       x29, x30, [sp], 16
37         ret

在上述代码里变量x被声明为全局变量。程序通过ADRP/ADD 指令对(第21行和第25行)计算它的指针。

7.2.7 MIPS

无未初始值的全局变量

以变量x为全局变量为例。我们把它编译为可执行文件,然后使用IDA加载这个程序。因为程序在声明变量x的时候没有对它进行初始化赋值,所以在IDA中变量x出现在.sbss ELF里(请参见3.5.1节的全局指针)。

指令清单7.8 Optimizing GCC 4.4.5 (IDA)

.text:004006C0 main:
.text:004006C0
.text:004006C0 var_10       = -0x10
.text:004006C0 var_4        = -4
.text:004006C0
; function prologue:
.text:004006C0              lui     $gp, 0x42
.text:004006C4              addiu   $sp, -0x20
.text:004006C8              li      $gp, 0x418940
.text:004006CC              sw      $ra, 0x20+var_4($sp)
.text:004006D0              sw      $gp, 0x20+var_10($sp)
; call puts():
.text:004006D4              la      $t9, puts
.text:004006D8              lui     $a0, 0x40
.text:004006DC              jalr    $t9 ; puts
.text:004006E0              la      $a0, aEnterX  # "Enter X:" ; branch delay slot
; call scanf():
.text:004006E4              lw      $gp, 0x20+var_10($sp)
.text:004006E8              lui     $a0, 0x40
.text:004006EC              la      $t9, __isoc99_scanf
; prepare address of x: 
.text:004006F0              la      $a1, x
.text:004006F4              jalr    $t9 ; __isoc99_scanf
.text:004006F8              la      $a0, aD          # "%d" ; branch delay slot
; call printf():
.text:004006FC              lw      $gp, 0x20+var_10($sp)
.text:00400700              lui     $a0, 0x40
; get address of x:
.text:00400704              la      $v0, x
.text:00400708              la      $t9, printf
; load value from "x"  variable and pass it to printf() in $a1:
.text:0040070C              lw      $a1, (x - 0x41099C)($v0)
.text:00400710              jalr    $t9 ; printf
.text:00400714              la      $a0, aYouEnteredD___ # "You entered %d...\n" ; branch delay slot
; function epilogue: 
.text:00400718              lw      $ra, 0x20+var_4($sp)
.text:0040071C              move    $v0, $zero
.text:00400720              jr      $ra
.text:00400724              addiu   $sp, 0x20  ; branch delay slot
...

.sbss:0041099C  #  Segment type: Uninitialized
.sbss:0041099C              .sbss
.sbss:0041099C              .globl x
.sbss:0041099C x:           .space 4
.sbss:0041099C

IDA会精简部分指令信息。我们不妨通过objdump观察上述文件确切的汇编指令。

指令清单7.9 Optimizing GCC 4.4.5 (objdump)

 1 004006c0 <main>:
 2 ; function prologue:
 3   4006c0:        3c1c0042         lui       gp,0x42
 4   4006c4:        27bdffe0         addiu     sp,sp,-32
 5   4006c8:        279c8940         addiu     gp,gp,-30400
 6   4006cc:        afbf001c         sw        ra,28(sp)
 7   4006d0:        afbc0010         sw        gp,16(sp)
 8 ; call puts():
 9   4006d4:        8f998034         lw        t9,-32716(gp)
10   4006d8:        3c040040         lui       a0,0x40
11   4006dc:        0320f809         jalr      t9
12   4006e0:        248408f0         addiu     a0,a0,2288 ; branch delay slot
13 ; call scanf():
14   4006e4:        8fbc0010         lw        gp,16(sp)
15   4006e8:        3c040040         lui       a0,0x40
16   4006ec:        8f998038         lw        t9,-32712(gp)
17 ; prepare address of x:
18   4006f0:        8f858044         lw        a1,-32700(gp)
19   4006f4:        0320f809         jalr      t9
20   4006f8:        248408fc         addiu     a0,a0,2300 ; branch delay slot
21 ; call printf():
22   4006fc:        8fbc0010         lw        gp,16(sp)
23   400700:        3c040040         lui       a0,0x40
24 ; get address of x:
25   400704:        8f828044         lw        v0,-32700(gp)
26   400708:        8f99803c         lw        t9,-32708(gp)
27 ; load value from "x" variable and pass it to printf() in $a1:
28   40070c:        8c450000         lw        a1,0(v0)
29   400710:        0320f809         jalr      t9
30   400714:        24840900         addiu     a0,a0,2304 ; branch delay slot
31 ; function epilogue:
32   400718:        8fbf001c         lw        ra,28(sp)
33   40071c:        00001021         move      v0,zero
34   400720:        03e00008         jr        ra
35   400724:        27bd0020         addiu     sp,sp,32 ; branch delay slot
36 ; pack of NOPs used for aligning next function start on 16-byte boundary:
37   400728:        00200825         move      at,at
38   40072c:        00200825         move      at,at

第18行处的指令对全局指针GP和一个负数值的偏移量求和,以此计算变量x在64KB数据缓冲区里的访问地址。此外,三个外部函数(puts()、scanf()、printf())在64KB数据空间里的全局地址,也是借助GP计算出来的(第9行、第16行、第26行)。GP指向数据空间的正中央。经计算可知,三个函数的地址和变量x的地址都在数据缓冲区的前端。这并不意外,因为这个程序已经很短小了。

此外,值得一提的是函数结尾处的两条NOP指令。它的实际指令是空操作指令“MOVE$AT, $AT”。借助这两条NOP指令,后续函数的起始地址可向16字节边界对齐。

有初始值的全局变量

我们对前文的例子做相应修改,把有关行改为:

int x=10; // default value

则可得如下代码段。

指令清单 7.10 Optimizing GCC 4.4.5 (IDA)

.text:004006A0  main:
.text:004006A0
.text:004006A0  var_10      = -0x10
.text:004006A0  var_8       = -8
.text:004006A0  var_4       = -4
.text:004006A0
.text:004006A0              lui         $gp, 0x42
.text:004006A4              addiu       $sp, -0x20
.text:004006A8              li          $gp, 0x418930
.text:004006AC              sw          $ra, 0x20+var_4($sp)
.text:004006B0              sw          $s0, 0x20+var_8($sp)
.text:004006B4              sw          $gp, 0x20+var_10($sp)
.text:004006B8              la          $t9, puts
.text:004006BC              lui         $a0, 0x40
.text:004006C0              jalr        $t9 ; puts
.text:004006C4              la          $a0, aEnterX     # "Enter X:"
.text:004006C8              lw          $gp, 0x20+var_10($sp)
; prepare high part of x address:
.text:004006CC              lui         $s0, 0x41
.text:004006D0              la          $t9, __isoc99_scanf
.text:004006D4              lui         $a0, 0x40
; add low part of x address:
.text:004006D8              addiu       $a1, $s0, (x - 0x410000)
; now address of x is in $a1.
.text:004006DC              jalr        $t9 ; __isoc99_scanf
.text:004006E0              la          $a0, aD          # "%d"
.text:004006E4              lw          $gp, 0x20+var_10($sp)
; get a word from memory:
.text:004006E8              lw          $a1, x
; value of x is now in $a1.
.text:004006EC              la          $t9, printf
.text:004006F0              lui         $a0, 0x40
.text:004006F4              jalr        $t9 ; printf
.text:004006F8              la          $a0, aYouEnteredD___  # "You entered %d...\n"
.text:004006FC              lw          $ra, 0x20+var_4($sp)
.text:00400700              move        $v0, $zero
.text:00400704              lw          $s0, 0x20+var_8($sp)
.text:00400708              jr          $ra
.text:0040070C              addiu       $sp, 0x20

...

.data:00410920              .globl  x
.data:00410920 x:           .word   0xA

为何它处没有了.sdata段?这可能是受到了GCC选项的影响。不论如何,变量x出现在.data段里。这个段会被加载到常规的通用内存区域,我们可以在此看到变量的处理方法。

MTPS程序必须使用成对指令处理变量的地址。本例使用的是LUI(Load Upper Immediate)和ADDIU (Add Immediate Unsigned Word)指令时。

我们继续借助objdump观察确切地操作指令。

指令清单7.11 Optimizing GCC 4.4.5 (objdump)

004006a0 <main>:
  4006a0:        3c1c0042        lui       gp,0x42
  4006a4:        27bdffe0        addiu     sp,sp,-32
  4006a8:        279c8930        addiu     gp,gp,-30416
  4006ac:        afbf001c        sw        ra,28(sp)
  4006b0:        afb00018        sw        s0,24(sp)
  4006b4:        afbc0010        sw        gp,16(sp)
  4006b8:        8f998034        lw        t9,-32716(gp)
  4006bc:        3c040040        lui       a0,0x40
  4006c0:        0320f809        jalr      t9
  4006c4:        248408d0        addiu     a0,a0,2256
  4006c8:        8fbc0010        lw        gp,16(sp)
; prepare high part of x address:
  4006cc:        3c100041        lui       s0,0x41
  4006d0:        8f998038        lw        t9,-32712(gp)
  4006d4:        3c040040        lui       a0,0x40
; add low part of x address:
  4006d8:        26050920        addiu     a1,s0,2336
; now address of x is in $a1.
  4006dc:        0320f809        jalr      t9
  4006e0:        248408dc        addiu     a0,a0,2268
  4006e4:        8fbc0010        lw        gp,16(sp)
; high part of x address is still in $s0.  
; add low part to it and load a word from memory:
  4006e8:        8e050920        lw        a1,2336(s0)
; value of x is now in $a1.  
  4006ec:        8f99803c        lw        t9,-32708(gp)
  4006f0:        3c040040        lui       a0,0x40
  4006f4:        0320f809        jalr      t9
  4006f8:        248408e0        addiu     a0,a0,2272
  4006fc:        8fbf001c        lw        ra,28(sp)
  400700:        00001021        move      v0,zero
  400704:        8fb00018        lw        s0,24(sp)
  400708:        03e00008        jr        ra
  40070c:        27bd0020        addiu     sp,sp,32

这个程序使用 LUI和ADDIU指令对生成变量地址。地址的高地址位仍然存储于$S0寄存器,而且单条LW指令(Load Word)即可封装这个偏移量。所以,单条LW指令足以提取变量的值,然后把它交付给printf()函数。

T-字头的寄存器名称是临时数据寄存器的助记符。此外,这段程序还使用到了S-字头的寄存器名称。在调用其他函数之前,调用方函数应当保管好自身S-字头的寄存器的值,避免它们受到被调用方函数的影响,举例来说在0x4006cc处的指令对$S0寄存器赋值,而后程序调用了scanf()函数,接着地址为0x4006e8的指令然继续调用$S0据此我们可以判断。scanf()函数不会变更$S0的值。

7.3 scanf()函数的状态监测

大家都知道scanf()不怎么流行了,但是这并不代表它派不上用场了。在万不得已必须使用这个函数的时候,切记检查函数的退出状态是否正确。例如:

#include <stdio.h>

int main()
{
        int x;
        printf ("Enter X:\n");

        if (scanf ("%d", &x)==1)
                printf ("You entered %d...\n", x);
        else
                printf ("What you entered? Huh?\n");
        return 0; 
};

根据这个函数的功能规范[3],scanf()函数在退出时会返回成功赋值的变量总数。

就本例子而言,正常情况下:用户输入一个整型数字时函数返回1;如果没有输入的值存在问题(或为EOF/没有输入数据),scanf()则返回0。

为此我们可在C程序里添加结果检查的代码,以便在出现错误时进行相应的处理。

我们来验证一下:

C:\...>ex3.exe
Enter X:
123
You entered 123...

C:\...>ex3.exe
Enter X:
ouch
What you entered? Huh?
7.3.1 MSVC:x86

使用MSVC2010生成的汇编代码如下所示。

          lea     eax, DWORD PTR _x$[ebp]
          push    eax
          push    OFFSET $SG3833 ; '%d', 00H
          call    _scanf
          add     esp, 8
          cmp     eax, 1
          jne     SHORT $LN2@main
          mov     ecx, DWORD PTR _x$[ebp]
          push    ecx
          push    OFFSET $SG3834 ; 'You entered %d... ', 0aH, 00H
          call    _printf
          add     esp, 8
          jmp     SHORT $LN1@main
$LN2@main:
          push    OFFSET $SG3836 ; 'What you entered? Huh? ', 0aH, 00H
          call    _printf
          add     esp, 4
$LN1@main:
          xor     eax, eax

当被调用方函数callee(本例中是scanf()函数)使用EAX寄存器向调用方函数caller(本例中是main()函数)传递返回值。

之后,“CMP EAX,1”指令对返回值进行比对,检查其值是否为1。

JNE是条件转移指令其全称是“Jump if Not Equal”。在两值不相同时进行跳转。

就是说,如果EAX寄存器里的值不是1,则程序将跳转到JNE所指明的地址(本例中会跳到$LN1@main);在将控制权指向这个地址之后,CPU会执行其后的打印指令,显示“What you entered? Huh?”。另一种情况是scanf()成功读取指定数据类型的数据,其返回值就会是1,此时不会发生跳转,而是继续执行JNE以后的指令,显示‘You entered %d…’和变量x的值。

在scanf()函数成功地给变量赋值的情况下,程序会一路执行到JMP(无条件转移)指令。这条指令会跳过第二条调用printf()函数的指令,从“XOR EAX,EAX”指令开始执行,从而完成return 0的操作。

可见,“一般地说”条件判断语句会出现成对的“CMP/Jcc”指令。此处cc是英文“condition code”的缩写。比较两个值的CMP指令会设置处理器的标志位[4]。Jcc指令会检查这些标志位,判断是否进行跳转。

但是上述的说法容易产生误导,实际上CMP指令进行的操作是减法运算。确切地说,不仅是CMP指令所有的“数学/算术计算”指令都会设置标志位。如果将1与1进行比较,1−1=0,ZF标志位(“零”标识位,最终运算结果是0)将被计算指令设定为1。将两个不同的数值进行CMP比较时,ZF标志位的值绝不会是1。JNE指令会依据ZF标志位的状态判断是否需要进行跳转,实际上此两者(Jump if Not Zero)的同义指令。JNE和JNZ的opcode都相同。所以,即使使用减法运算操作指令SUB替换CMP指令,Jcc指令也可以进行正常的跳转。不过在使用SUB指令时,我们还需要分配一个寄存器保存运算结果,而CMP则不需要使用寄存器保存运算结果。

7.3.2 MSVC:x86:IDA

现在来让IDA大显身手。对于多数初学者来说,使用MSVC编译器的/MD选项是个值得推荐的好习惯。这个选项会要求编译器“不要链接(link)标准函数”,而是从MSVCR*.DLL里导入这些标准函数。总之,使用/MD选项编译出来的代码一目了然,便于我们观察它在哪里、调用了哪些标准函数。

在使用IDA分析程序的时候,应当充分利用它的标记功能。比如说,分析这段程序的时候,我们明白在发生错误的时候会执行JNE跳转。此时就可以用鼠标单击跳转的JNE指令,按下“n”键,把相应的标签(lable)改名为“error”;然后把正常退出的标签重命名为“exit”。这种修改就可大幅度增强代码的可读性。

.text:00401000 _main proc near
.text:00401000
.text:00401000 var_4 = dword ptr -4
.text:00401000 argc  = dword ptr  8
.text:00401000 argv  = dword ptr  0Ch
.text:00401000 envp  = dword ptr  10h
.text:00401000
.text:00401000        push     ebp
.text:00401001        mov      ebp, esp
.text:00401003        push     ecx
.text:00401004        push     offset Format ; "Enter X:\n"
.text:00401009        call     ds:printf
.text:0040100F        add      esp, 4
.text:00401012        lea      eax, [ebp+var_4]
.text:00401015        push     eax
.text:00401016        push     offset aD ; "%d"
.text:0040101B        call     ds:scanf
.text:00401021        add      esp, 8
.text:00401024        cmp      eax, 1
.text:00401027        jnz      short error
.text:00401029        mov      ecx, [ebp+var_4]
.text:0040102C        push     ecx
.text:0040102D        push     offset aYou ; "You entered %d...\n"
.text:00401032        call     ds:printf
.text:00401038        add      esp, 8
.text:0040103B        jmp      short exit
.text:0040103D
.text:0040103D error: ; CODE XREF: _main+27
.text:0040103D        push     offset aWhat ; "What you entered? Huh?\n"
.text:00401042        call     ds:printf
.text:00401048        add      esp, 4
.text:0040104B
.text:0040104B exit:  ; CODE XREF: _main+3B
.text:0040104B        xor      eax, eax
.text:0040104D        mov      esp, ebp
.text:0040104F        pop      ebp
.text:00401050        retn
.text:00401050 _main  endp

如此一来,这段代码就容易理解了。虽然重命名标签的功能很强大,但是逐一修改每条指令的标签则无疑是画蛇添足。

此外,IDA还有如下一些高级用法。

整理代码:

标记某段代码之后,按下键盘数字键的“-”减号,整段代码将被隐藏,只留下首地址和标签。下面的例子中,我隐藏了两段代码,并对整段代码进行了重命名。

.text:00401000 _text  segment para public 'CODE' use32
.text:00401000        assume cs:_text
.text:00401000        ;org 401000h
.text:00401000 ;  ask for X
.text:00401012 ;  get X
.text:00401024        cmp     eax, 1
.text:00401027        jnz     short error
.text:00401029 ;  print result
.text:0040103B        jmp     short exit
.text:0040103D
.text:0040103D error: ; CODE XREF: _main+27
.text:0040103D        push  offset aWhat ; "What you entered? Huh?\n"
.text:00401042        call  ds:printf
.text:00401048        add   esp, 4
.text:0040104B
.text:0040104B exit:  ; CODE XREF: _main+3B
.text:0040104B        xor   eax, eax
.text:0040104D        mov   esp, ebp
.text:0040104F        pop   ebp
.text:00401050        retn
.text:00401050 _main  endp

如需显示先前隐藏的代码,可以直接使用数字键盘上的“+”加号。

图解模式:

按下空格键,IDA将会进入图解的显示方式。其效果如图7.7所示。

..\TU\0707.tif{}

图7.7 IDA的图解模式

判断语句会分出两个箭头,一条是红色、一条是绿色。当判断条件表达式的值为真时,程序会走绿色箭头所示的流程;如果判断条件表达式不成立,程序会采用红色箭头所标示的流程。

图解模式下,也可以对各分支节点命名和收缩。图7.8处理了3个模块。

..\TU\0708.tif

图7.8 IDA图解模式下的收缩操作

这种图解模式非常实用。逆向工程工作经验不多的人,可使用这种方式来大幅度地减少他需要处理的信息量。

7.3.3 MSVC:x86+OllyDbg

我们使用OllyDbg调试刚才的程序,在scanf()异常返回的情况下强制其继续运行余下的指令。

在把本地变量的地址传递给scanf()的时候,变量本身处于未初始化状态,其值应当是随机的噪声数据。如图7.9所示,变量x的值为0x6E494714。

..\TU\0709.tif{}

图7.9 使用OllyDbg观察scanf()的变量传递过程

在执行scanf()函数的时候,我们输入非数字的内容,例如 “asdasd”。这时候scanf()会通过EAX寄存器返回0,如图7.10所示。这个零意味着scanf()函数遇到某种错误。

..\TU\0710.tif{}

图7.10 使用OllyDbg观察scanf()的异常处理

执行scanf()前后,变量x的值没有发生变化。在上述情况下,scanf()函数仅仅返回0,而没对变量进行赋值。

现在来“hack”一下。右键单击EAX,选中“Set to 1”。

在此之后EAX寄存器存储的值被人为设定为1,程序将完成后续的操作,printf()函数会在控制台里显示数据栈里变量x的值。

我们使用F9键继续运行程序。此后控制台的情况如图7.11所示。

..\TU\0711.tif

图7.11 控制台窗口

1850296084是十进制值,其十六进制值就是我们刚才看到的0x6E494714。

7.3.4 MSVC:x86+Hiew

下面,我们一起修改可执行文件。所谓的“修改”可执行文件,就是对其打补丁(即人们常说的“patch”)。通过打补丁的方法,我们可以强制程序在所有情况下都进行输出。

首先,我们要启用MSVC的编译选项/MD。这样编译出来的可执行文件将把标准函数链接到MSVCR*.DLL,以方便我们在可执行文件的文本段里找到main()函数。此后,我们使用Hiew工具打开可执行文件,找到.text段开头部分的main()函数(依次使用Enter, F8, F6, Enter, Enter)。

然后会看到图7.12所示的界面。

..\TU\0712.tif{}

图7.12 使用Hiew观察main()函数

Hiew 能够识别并显示ASCIIZ,即以null为结束字节的ASCII字符串。它还能识别导入函数的函数名称。

如图7.13所示,将鼠标光标移至.0040127处(即JNZ指令的所在位置,我们使其失效),按F3键,然后输入“9090”。“9090”是两个连续的NOP(No Operation,空操作)的opcode。

..\TU\0713.tif{}

图7.13 在Hiew中把JNZ替换为两条NOP指令

然后按F9键(更新),把修改后的可执行文件保存至磁盘。

连续的9090是典型的修改特征,有的人会觉得不甚美观。此外还可以把这个opcode的第二个字节改为零(opcode的两个字节代表jump offset),令jcc指令跳转的偏移量为0,从而继续运行下一条指令。

刚才的修改方法可使转移指令失效。除此以外我们还可以强制程序进行转我们可以把jcc对应的opcode的第一个字节替换为“EB”,不去修改第二字节(offset)。这种修改方法把条件转移指令替换为了无条件跳转指令。经过这样的调整之后,本例中的可执行文件都会无条件地显示错误处理信息“What you entered? Huh?”。

7.3.5 MSVC:x64

本例使用的变量x是int型整数变量。在x64系统里,int型变量还是32位数据。在64位平台上访问寄存器的(低)32位时,计算机就要使用助记符以E-头的寄存器名称。然而在访问x64系统的64位指针时,我们就需要使用R-字头的寄存器名称、处理完整的64位数据。

指令清单7.12 MSVC 2012 x64

_DATA   SEGMENT
$SG2924 DB        'Enter X: ', 0aH, 00H
$SG2926 DB        '%d', 00H
$SG2927 DB        'You entered %d... ', 0aH, 00H
$SG2929 DB        'What you entered? Huh? ', 0aH, 00H
_DATA   ENDS

_TEXT   SEGMENT
x$ = 32
main    PROC
$LN5:
        sub        rsp, 56
        lea        rcx, OFFSET FLAT:$SG2924 ; 'Enter X: '
        call       printf
        lea        rdx, QWORD PTR x$[rsp]
        lea        rcx, OFFSET FLAT:$SG2926 ; '%d'
        call       scanf
        cmp        eax, 1
        jne        SHORT $LN2@main
        mov        edx, DWORD PTR x$[rsp]
        lea        rcx, OFFSET FLAT:$SG2927 ; 'You entered %d... '
        call       printf
        jmp        SHORT $LN1@main
$LN2@main:
        lea        rcx, OFFSET FLAT:$SG2929 ; 'What you entered? Huh? '
        call       printf
$LN1@main:
        ; return 0
        xor        eax, eax
        add        rsp, 56
        ret        0
main    ENDP
_TEXT   ENDS
END
7.3.6 ARM

ARM: Optimizing Keil 6/2013 (Thumb 模式)

指令清单7.13 Optimizing Keil 6/2013 (Thumb模式)

var_8        = -8

             PUSH       {R3,LR}
             ADR        R0, aEnterX           ; "Enter X:\n"
             BL         __2printf
             MOV        R1, SP
             ADR        R0, aD                ; "%d"
             BL         __0scanf
             CMP        R0, #1
             BEQ        loc_1E
             ADR        R0, aWhatYouEntered   ; "What you entered? Huh?\n"
             BL         __2printf
loc_1A                                        ; CODE XREF: main+26
             MOVS       R0, #0
             POP        {R3,PC}
loc_1E                                        ; CODE XREF: main+12
             LDR        R1, [SP,#8+var_8]
             ADR        R0, aYouEnteredD___   ; "You entered %d...\n"
             BL         __2printf
             B          loc_1A

这里我们见到了未介绍过的CMP和BEQ指令。

ARM系统的CMP与x86系统的同名指令相似。它们都是将两个参数相减,并设置相应的标志位。

BEQ是条件转移指令(Branch if EQual),在CMP操作数相等的情况下进行跳转。如果CMP比较的两个值相同,则ZF标志寄存器的值就会是1,那么BEQ指令就会跳转到它指定的偏移量上去。它与x86的JZ指令作用相同。

其余的指令并不难理解:程序流有两个分支,这两个分支最终收敛于loc_1A处,通过“MOV R0, #0”指令把返回值保存于R0寄存器,然后退出。

ARM64

指令清单7.14 Non-optimizing GCC 4.9.1 ARM64

 1 .LC0:
 2         .string "Enter X:"
 3 .LC1:
 4         .string "%d"
 5 .LC2:
 6         .string "You entered %d...\n"
 7 .LC3:
 8         .string "What you entered? Huh?"
 9 f6:
10 ; save FP and LR in stack frame:
11         stp     x29, x30, [sp, -32]!
12 ; set stack frame (FP=SP)
13         add     x29, sp, 0
14 ; load pointer to the "Enter X:" string:
15         adrp    x0, .LC0
16         add     x0, x0, :lo12:.LC0
17         bl      puts
18 ; load pointer to the "%d" string:
19         adrp    x0, .LC1
20         add     x0, x0, :lo12:.LC1
21 ; calculate address of x variable in the local stack
22         add     x1, x29, 28
23         bl      __isoc99_scanf
24 ; scanf() returned result in W0.
25 ; check it:
26         cmp     w0, 1
27 ; BNE is Branch if Not Equal
28 ; so if W0<>0, jump to L2 will be occurred
29         bne    .L2
30 ; at this moment W0=1, meaning no error
31 ; load x value from the local stack
32         ldr     w1, [x29,28]
33 ; load pointer to the "You entered %d...\n" string:
34         adrp    x0, .LC2
35         add     x0, x0, :lo12:.LC2
36         bl      printf
37 ; skip the code, which print the "What you entered? Huh?" string:
38         b       .L3
39 .L2:
40 ; load pointer to the "What you entered? Huh?" string:
41         adrp    x0, .LC3
42         add     x0, x0, :lo12:.LC3
43         bl      puts
44 .L3:
45 ; return 0
46         mov     w0, 0
47 ; restore FP and LR:
48         ldp     x29, x30, [sp], 32
49         ret

上述程序通过CMP/BNE指令对控制分支语句。

7.3.7 MIPS

指令清单7.15 Optimizing GCC 4.4.5 (IDA)

.text:004006A0 main:
.text:004006A0
.text:004006A0 var_18       = -0x18
.text:004006A0 var_10       = -0x10
.text:004006A0 var_4        = -4
.text:004006A0
.text:004006A0              lui         $gp, 0x42
.text:004006A4              addiu       $sp, -0x28
.text:004006A8              li          $gp, 0x418960
.text:004006AC              sw          $ra, 0x28+var_4($sp)
.text:004006B0              sw          $gp, 0x28+var_18($sp)
.text:004006B4              la          $t9, puts
.text:004006B8              lui         $a0, 0x40
.text:004006BC              jalr        $t9 ; puts
.text:004006C0              la          $a0, aEnterX     # "Enter X:"
.text:004006C4              lw          $gp, 0x28+var_18($sp)
.text:004006C8              lui         $a0, 0x40
.text:004006CC              la          $t9, __isoc99_scanf
.text:004006D0              la          $a0, aD          # "%d"
.text:004006D4              jalr        $t9 ; __isoc99_scanf
.text:004006D8              addiu       $a1, $sp, 0x28+var_10  # branch delay slot
.text:004006DC              li          $v1, 1
.text:004006E0              lw          $gp, 0x28+var_18($sp)
.text:004006E4              beq         $v0, $v1, loc_40070C
.text:004006E8              or          $at, $zero       # branch delay slot, NOP
.text:004006EC              la          $t9, puts
.text:004006F0              lui         $a0, 0x40
.text:004006F4              jalr        $t9 ; puts
.text:004006F8              la          $a0, aWhatYouEntered  # "What you entered? Huh?"
.text:004006FC              lw          $ra, 0x28+var_4($sp)
.text:00400700              move        $v0, $zero
.text:00400704              jr          $ra
.text:00400708              addiu       $sp, 0x28

.text:0040070C loc_40070C:
.text:0040070C              la          $t9, printf
.text:00400710              lw          $a1, 0x28+var_10($sp)
.text:00400714              lui         $a0, 0x40
.text:00400718              jalr        $t9 ; printf
.text:0040071C              la          $a0, aYouEnteredD___  # "You entered %d...\n"
.text:00400720              lw          $ra, 0x28+var_4($sp)
.text:00400724              move        $v0, $zero
.text:00400728              jr          $ra
.text:0040072C              addiu       $sp, 0x28

scanf()函数通过$V0寄存器传递其返回值。地址为0x004006E4的指令负责比较$V0和$V1的值。其中,$V1在0x004006DC处被赋值为1。BEQ的作用是“Branch Equal”(在相等时进行跳转)。如果两个寄存器里的值相等,即成功读取了1个整数,那么程序将会从0x0040070C处继续执行指令。

7.3.8 练习题

JNE/JNZ指令可被修改为JE/JZ指令,而且后者也可被修改为前者。BNE和BEQ之间也有这种关系。不过,在进行这种替代式修改之后,还要对程序的基本模块进行修改。请多进行一些有关练习。

7.4 练习题

7.4.1 题目

这段代码在Linux x86-64上用GCC编译,运行的时候都崩溃了(段错误)。然而,它在Windows环境下用Msvc 2010 x86编译后却能工作,为什么?

#include <string.h>
#include <stdio.h>

void alter_string(char *s)
{
        strcpy (s, "Goodbye!");
        printf ("Result: %s\n", s);
};

int main()
{
        alter_string ("Hello, world!\n");
};

[1] 参见附录A.6.2。

[2] 请参阅ISO07,6.7.8节。

[3] 请参照http://msdn.microsoft.com/en-us/library/9y6s16x1(VS.71).aspx。

[4] processor flags,参见http://en.wikipedia.org/wiki/FLAGS_register_(computing)。