本章演示scanf()函数。
#include<stdio.h>
intmain()
{
int x;
printf ("Enter X:\n");
scanf ("%d", &x);
printf ("You entered %d...\n", x);
return 0;
};
好吧,我承认时下的程序如果大量使用scanf()就不会有什么前途。本文仅用这个函数演示整形数据的指针的传递过程。
在计算机科学里,“指针”属于最基础的概念。如果直接向函数传递大型数组、结构体或数据对象,程序的开销就会很大。毫无疑问,使用指针将会降低开销。不过指针的作用不只如此:如果不使用指针,而是由调用方函数直接传递数组或结构体这种大型数据(同时还要返回这些数据),那么参数的传递过程将会复杂得出奇。所以,调用方函数只负责传递数组或结构体的地址,让被调用方函数处理地址里的数据,无疑是最简单的做法。
在C/C++的概念中,指针就是描述某个内存地址的数据。
x86系统使用体系32位数字(4字节数据)描述指针;x64系统则使用64位数字(8字节数据)。从数据空间来看,64位系统的指针比32位系统的指针大了一倍。当人们逐渐从x86平台开发过渡到x86-64平台开发的时候,不少人因为难以适应而满腹牢骚。
程序人员确实可在所有的程序里仅仅使用无类型指针这一种指针。例如,在使用C语言memcpy()函数、在内存中复制数据的时候,程序员完全不必知道操作数的数据类型,直接使用2个void*指针复制数据。这种情况下,目标地址里数据的数据类型并不重要,重要的是存储数据的空间大小。
指针还广泛应用于多个返回值的传递处理。本书的第10章会详细介绍这部分内容。scanf()函数就可以返回多个值。它不仅能够分析调用方法传递了多少个参数,而且还能读取各个参数的值。
在C/C++的编译过程里,编译器只会在类型检查的阶段才会检查指针的类型。在编译器生成的汇编程序里,没有指针类型的任何信息。
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()函数。
现在使用OllyDbg调试上述例子。加载程序之后,一直按F8键单步执行,等待程序退出ntdll.dll、进入我们程序的主文件。然后向上翻滚滚轴,查找main()主函数。在main()里面点中第一条指令“PUSH EBP”,并在此处按下F2键设置断点。接着按F9键,运行断点之前的指令。
我们一起来跟随调试器查看变量x的计算指令,如图7.1所示。
图7.1 OllyDbg:局部变量x的赋值过程
在这个界面里,我们在寄存器的区域内用右键单击EAX寄存器,然后选择“Follow in stack”。如此一来,OllyDbg就会在栈窗口里显示栈地址和栈内数据,以便我们清楚地观察栈里的局部变量。图中红箭头所示的就是栈里的数据。其中,在地址0x6E494714处的数据就是脏数据。在下一时刻,PUSH指令会把数据存储到栈里的下一个地址。接下来,在程序执行完scanf()函数之前,我们一直按F8键。在执行scanf()函数的时候,我们要在运行程序的终端窗口里输入数据,例如123,如图7.2所示。
图7.2 控制台窗口
scanf()函数的执行之后的情形如图7.3所示。
图7.3 OllyDbg:运行scanf()之后
EAX寄存器里存有函数的返回值1。这表示它成功地读取了1个值。我们可以在栈里找到局部变量的地址,其数值为0x7B(即数字123)。
这个值将通过栈传递给ECX寄存器,然后再次通过栈传递给printf()函数,如图7.4所示。
图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++源代码中,只要两个相邻语句之间没有其他的表达式,那么在生成的机器码中对应的指令之间就不会有其他的指令,而且其执行顺序也与源代码各语句的书写顺序相符。
在编译面向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
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()函数。
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
在本章前文的那个程序里,如果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;
};
_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]。但是在可执行文件里,这些未初始化的变量不占用内存空间。为了方便使用巨型数组之类的大型数据,人们刻意做了这种设定。
我们可以在OllyDbg观察程序的数据段里的变量。如图7.5所示。
图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域。
图7.6 OllyDbg:进程内存映射
在汇编指令层面,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.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指令能够正确处理数据类型。
.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.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行)计算它的指针。
无未初始值的全局变量
以变量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的值。
大家都知道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?
使用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则不需要使用寄存器保存运算结果。
现在来让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所示。
图7.7 IDA的图解模式
判断语句会分出两个箭头,一条是红色、一条是绿色。当判断条件表达式的值为真时,程序会走绿色箭头所示的流程;如果判断条件表达式不成立,程序会采用红色箭头所标示的流程。
图解模式下,也可以对各分支节点命名和收缩。图7.8处理了3个模块。
图7.8 IDA图解模式下的收缩操作
这种图解模式非常实用。逆向工程工作经验不多的人,可使用这种方式来大幅度地减少他需要处理的信息量。
我们使用OllyDbg调试刚才的程序,在scanf()异常返回的情况下强制其继续运行余下的指令。
在把本地变量的地址传递给scanf()的时候,变量本身处于未初始化状态,其值应当是随机的噪声数据。如图7.9所示,变量x的值为0x6E494714。
图7.9 使用OllyDbg观察scanf()的变量传递过程
在执行scanf()函数的时候,我们输入非数字的内容,例如 “asdasd”。这时候scanf()会通过EAX寄存器返回0,如图7.10所示。这个零意味着scanf()函数遇到某种错误。
图7.10 使用OllyDbg观察scanf()的异常处理
执行scanf()前后,变量x的值没有发生变化。在上述情况下,scanf()函数仅仅返回0,而没对变量进行赋值。
现在来“hack”一下。右键单击EAX,选中“Set to 1”。
在此之后EAX寄存器存储的值被人为设定为1,程序将完成后续的操作,printf()函数会在控制台里显示数据栈里变量x的值。
我们使用F9键继续运行程序。此后控制台的情况如图7.11所示。
图7.11 控制台窗口
1850296084是十进制值,其十六进制值就是我们刚才看到的0x6E494714。
下面,我们一起修改可执行文件。所谓的“修改”可执行文件,就是对其打补丁(即人们常说的“patch”)。通过打补丁的方法,我们可以强制程序在所有情况下都进行输出。
首先,我们要启用MSVC的编译选项/MD。这样编译出来的可执行文件将把标准函数链接到MSVCR*.DLL,以方便我们在可执行文件的文本段里找到main()函数。此后,我们使用Hiew工具打开可执行文件,找到.text段开头部分的main()函数(依次使用Enter, F8, F6, Enter, Enter)。
然后会看到图7.12所示的界面。
图7.12 使用Hiew观察main()函数
Hiew 能够识别并显示ASCIIZ,即以null为结束字节的ASCII字符串。它还能识别导入函数的函数名称。
如图7.13所示,将鼠标光标移至.0040127处(即JNZ指令的所在位置,我们使其失效),按F3键,然后输入“9090”。“9090”是两个连续的NOP(No Operation,空操作)的opcode。
图7.13 在Hiew中把JNZ替换为两条NOP指令
然后按F9键(更新),把修改后的可执行文件保存至磁盘。
连续的9090是典型的修改特征,有的人会觉得不甚美观。此外还可以把这个opcode的第二个字节改为零(opcode的两个字节代表jump offset),令jcc指令跳转的偏移量为0,从而继续运行下一条指令。
刚才的修改方法可使转移指令失效。除此以外我们还可以强制程序进行转我们可以把jcc对应的opcode的第一个字节替换为“EB”,不去修改第二字节(offset)。这种修改方法把条件转移指令替换为了无条件跳转指令。经过这样的调整之后,本例中的可执行文件都会无条件地显示错误处理信息“What you entered? Huh?”。
本例使用的变量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
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.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处继续执行指令。
JNE/JNZ指令可被修改为JE/JZ指令,而且后者也可被修改为前者。BNE和BEQ之间也有这种关系。不过,在进行这种替代式修改之后,还要对程序的基本模块进行修改。请多进行一些有关练习。
这段代码在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)。