在C/C++的数据结构里结构体(structure)是由一系列数据简单堆积而成的数据类型。结构体中的各项数据元素,可以是相同类型的数据、也可以是不同类型的数据。[1]
本节以Win32描述系统时间的SYSTEMTIME结构体为例。
库函数对它的定义如下[2]。
指令清单21.1 WinBase.h
typedef struct _SYSTEMTIME {
WORD wYear;
WORD wMonth;
WORD wDayOfWeek;
WORD wDay;
WORD wHour;
WORD wMinute;
WORD wSecond;
WORD wMilliseconds;
} SYSTEMTIME, *PSYSTEMTIME;
根据上述声明,获取系统时间的程序大致如下:
#include <windows.h>
#include <stdio.h>
void main()
{
SYSTEMTIME t;
GetSystemTime (&t);
printf ("%04d-%02d-%02d %02d:%02d:%02d\n",
t.wYear, t.wMonth, t.wDay,
t.wHour, t.wMinute, t.wSecond);
return;
};
使用MSVC 2010(启用/GS-选项)编译这个程序,可得如下所示的代码。
指令清单21.2 MSVC 2010 /GS-
_t$ = -16 ; size = 16
_main PROC
push ebp
mov ebp, esp
sub esp, 16
lea eax, DWORD PTR _t$[ebp]
push eax
call DWORD PTR __imp__GetSystemTime@4
movzx ecx, WORD PTR _t$[ebp+12] ; wSecond
push ecx
movzx edx, WORD PTR _t$[ebp+10] ; wMinute
push edx
movzx eax, WORD PTR _t$[ebp+8] ; wHour
push eax
movzx ecx, WORD PTR _t$[ebp+6] ; wDay
push ecx
movzx edx, WORD PTR _t$[ebp+2] ; wMonth
push edx
movzx eax, WORD PTR _t$[ebp] ; wYear
push eax
push OFFSET $SG78811 ; ’%04d-%02d-%02d %02d:%02d:%02d’, 0aH, 00H
call _printf
add esp, 28
xor eax, eax
mov esp, ebp
pop ebp
ret 0
_main ENDP
函数在栈里为这个结构体申请了16字节空间。这个结构体由8个WORD型数据构成,每个WORD型数据占用2字节,所以整个结构体正好需要16字节的存储空间。
这个结构体的第一个字段是wYear。根据MSDN有关SYSTEMTIME结构体的相关声明[3],在使用GetSystemTime()函数时,传递给函数的是SYSTEMTIME结构体的指针。但是换个角度看,这也是wYear字段的指针。GetSystemTime()函数首先会在结构体的首地址写入年份信息,然后再把指针调整2个字节并写入月份信息,如此类推写入全部信息。
我们使用MSVC 2010(指定/GS- /MD 选项)编译上述程序,并用OllyDbg打开MSVC生成的可执行文件。找到传递给GetSystemTime()函数的指针地址,然后在数据观察窗口里观察这部分数据。此时数据如图21.1所示。
图21.1 OllyDbg:执行GetSystemTime()
在执行函数时精确的系统时间是“9 december 2014, 22:29:52”如图21.2所示。
图21.2 OllyDbg:printf()函数的输出结果
我们在数据窗口看到这个地址开始的16字节空间的值是:
DE 07 0C 00 02 00 09 00 16 00 1D 00 34 00 D4 03
这段空间的每2个字节代表结构体的一个字段。由于采用了小端字节序,所以就同一个WORD型数据而言,数权较小的一个字节在前,数权较大的字节在后。我们将其整理一下,看看它们的实际涵义:
十六进制数 |
十进制含义 |
字段名 |
---|---|---|
0x07DE |
2014 |
wYear |
0x000C |
12 |
wMonth |
0x0002 |
2 |
wDayOfWeek |
0x0009 |
9 |
wDay |
0x0016 |
22 |
wHour |
0x001D |
29 |
wMinute |
0x0034 |
52 |
wSecond |
0x03D4 |
980 |
wMilliSeconds |
在栈窗口里看到的值与此相同,不过栈窗口以32位数据的格式组织数据。
而后printf()函数从结构体中获取所需数据,并在屏幕上进行相应输出。
printf()函数并没有将所有的字段都打印出来。wDayOfWeek 和wMilliSeconds都未被输出,但是内存中确实有它们对应的值。
结构体中的各个元素,在内存里依次排列。为了验证它在内存中的存储状况和数组相同,我用数组替代了SYSTEMTIME结构体:
#include <windows.h>
#include <stdio.h>
void main()
{
WORD array[8];
GetSystemTime (array);
printf ("%04d-%02d-%02d %02d:%02d:%02d\n",
array[0] /* wYear */, array[1] /* wMonth */, array[3] /* wDay */,
array[4] /* wHour */, array[5] /* wMinute */, array[6] /* wSecond */);
return;
};
编译器会提示警告信息:
systemtime2.c(7) : warning C4133: ’function’ : incompatible types - from ’WORD [8]’ to ’LPSYSTEMTIME’
即使如此,MSVC 2010仍能够进行编译。它生成的代码如下所示。
指令清单21.3 Non-optimizing MSVC 2010
$SG78573 DB '%04d-%02d-%02d %02d:%02d:%02d', 0aH, 00H
_array$ = -16;size=16
_main PROC
Push ebp
mov ebp, esp
sub esp, 16
lea eax, DWORD PTR _array$[ebp]
push eax
call DWORD PTR __imp__GetSystemTime@4
movzx ecx, WORD PTR _array$[ebp+12] ; wSecond
push ecx
movzx edx, WORD PTR _array$[ebp+10] ; wMinute
push edx
movzx eax, WORD PTR _array$[ebp+8] ; wHoure
push eax
movzx ecx, WORD PTR _array$[ebp+6] ; wDay
push ecx
movzx edx, WORD PTR _array$[ebp+2] ; wMonth
push edx
movzx eax, WORD PTR _array$[ebp] ; wYear
push eax
push OFFSET $SG78573
call _printf
add esp, 28
xor eax, eax
mov esp, ebp
pop ebp
ret 0
_main ENDP
即使调整了数据类型,编译器生成的程序仍然没有什么变化。
这个现象说明,即使我们使用数组替代了原有结构体,编译器生成的汇编指令依旧完全相同。仅仅从汇编指令分析,很难判断出到底源程序使用的是多变量的结构体还是数组。
好在正常人不会做这种别扭的替换。毕竟结构体的可读性、易用性都比数组强,也方便编程人员替换结构体中的字段。
因为这个程序和前面的程序完全相同,本书就不再演示OllyDbg的调试过程了。
在某些情况下,使用堆(heap)来存储结构体要比栈(stack)容易一些:
#include <windows.h>
#include <stdio.h>
void main()
{
SYSTEMTIME *t;
t=(SYSTEMTIME *)malloc (sizeof (SYSTEMTIME));
GetSystemTime (t);
printf ("%04d-%02d-%02d %02d:%02d:%02d\n",
t->wYear, t->wMonth, t->wDay,
t->wHour, t->wMinute, t->wSecond);
free (t);
return;
};
现在启用MSVC的优化选项/Ox,编译上述程序,得到的代码如下所示。
指令清单21.4 Optimizing MSVC
_main PROC
push esi
push 16
call _malloc
add esp, 4
mov esi, eax
push esi
call DWORD PTR __imp__GetSystemTime@4
movzx eax, WORD PTR [esi+12] ; wSecond
movzx ecx, WORD PTR [esi+10] ; wMinute
movzx edx, WORD PTR [esi+8] ; wHour
push eax
movzx eax, WORD PTR [esi+6] ; wDay
push ecx
movzx ecx, WORD PTR [esi+2] ; wMonth
push edx
movzx edx, WORD PTR [esi] ; wYear
push eax
push ecx
push edx
push OFFSET $SG78833
call _printf
push esi
call _free
add esp, 32
xor eax, eax
pop esi
ret 0
_main ENDP
16就是sizeof(SYSTEMTIME),即malloc()分配空间的确切大小。malloc()函数根据参数指定的大小分配一块空间,并把空间的指针传递给EAX寄存器。而后ESI寄存器获取了这个指针。Win32的GetSystemTime()函数把返回值的各个项存储到esi指针指向的对应空间里,接下来几个寄存器依次读取这些返回值并将之依次推送入栈、给printf()函数调用。
此处的“MOVZX(Move with Zero eXtent)”是前文没有介绍过的指令。这个指令的作用和MOVSX(参见第14章)的相似。与MOVSX的不同之处是,MOVZX在进行格式转换的时候会将其他bit位清零。因为printf()函数需要的数据类型是32 位整型数据,而我们的结构体SYSTEMTIME里对应的字段是WORD型数据,所以此处要转换数据类型。WORD型数据是16位无符号数据,因而要把WORD型数据照抄到int型数据空间的低地址位,并把高地址位(第16位到第31位)清零。高地址位必须要清零,否则转换的int型数据很可能会受到脏数据问题的不良影响。
本例中,我们可以使用8个WORD型数组重新构造上述结构体:
#include <windows.h>
#include <stdio.h>
void main()
{
WORD *t;
t=(WORD *)malloc (16);
GetSystemTime (t);
printf ("%04d-%02d-%02d %02d:%02d:%02d\n",
t[0] /* wYear */, t[1] /* wMonth */, t[3] /* wDay */,
t[4] /* wHour */, t[5] /* wMinute */, t[6] /* wSecond */);
free (t);
return;
};
使用MSVC(启用优化选项)编译上述程序,可得到如下所示的代码。
指令清单21.5 Optimizing MSVC
$SG78594 DB '%04d-%02d-%02d %02d:%02d:%02d’, 0aH, 00H
_main PROC
push esi
push 16
call _malloc
add esp, 4
mov esi, eax
push esi
call DWORD PTR __imp__GetSystemTime@4
movzx eax, WORD PTR [esi+12]
movzx ecx, WORD PTR [esi+10]
movzx edx, WORD PTR [esi+8]
push eax
movzx eax, WORD PTR [esi+6]
push ecx
movzx ecx, WORD PTR [esi+2]
push edx
movzx edx, WORD PTR [esi]
push eax
push ecx
push edx
push OFFSET $SG78594
call _printf
push esi
call _free
add esp, 32
xor eax, eax
pop esi
ret 0
_main ENDP
这个代码和结构体生成的代码完全相同。我再次强调,这种“用数组替代结构体”的做法没有什么实际意义。除非有必要,否则不必做这种替换。
我们研究一下Linux源文件 time.h里的tm结构体。
#include <stdio.h>
#include <time.h>
void main()
{
struct tm t;
time_t unix_time;
unix_time=time(NULL);
localtime_r (&unix_time, &t);
printf ("Year: %d\n", t.tm_year+1900);
printf ("Month: %d\n", t.tm_mon);
printf ("Day: %d\n", t.tm_mday);
printf ("Hour: %d\n", t.tm_hour);
printf ("Minutes: %d\n", t.tm_min);
printf ("Seconds: %d\n", t.tm_sec);
};
使用GCC 4.4.1 编译,可得如下所示的代码。
指令清单21.6 GCC 4.4.1
main proc near
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
sub esp, 40h
mov dword ptr [esp], 0 ; first argument for time()
call time
mov [esp+3Ch], eax
lea eax, [esp+3Ch] ; take pointer to what time() returned
lea edx, [esp+10h] ; at ESP+10h struct tm will begin
mov [esp+4], edx ; pass pointer to the structure begin
mov [esp], eax ; pass pointer to result of time()
call localtime_r
mov eax, [esp+24h] ; tm_year
lea edx, [eax+76Ch] ; edx=eax+1900
mov eax, offset format ; "Year: %d\n"
mov [esp+4], edx
mov [esp], eax
call printf
mov edx, [esp+20h] ; tm_mon
mov eax, offset aMonthD ; "Month: %d\n"
mov [esp+4], edx
mov [esp], eax
call printf
mov edx, [esp+1Ch] ; tm_mday
mov eax, offset aDayD ; "Day: %d\n"
mov [esp+4], edx
mov [esp], eax
call printf
mov edx, [esp+18h] ; tm_hour
mov eax, offset aHourD ; "Hour: %d\n"
mov [esp+4], edx
mov [esp], eax
call printf
mov edx, [esp+14h] ; tm_min
mov eax, offset aMinutesD ; "Minutes: %d\n"
mov [esp+4], edx
mov [esp], eax
call printf
mov edx, [esp+10h]
mov eax, offset aSecondsD ; "Seconds: %d\n"
mov [esp+4], edx ; tm_sec
mov [esp], eax
call printf
leave
retn
main endp
可惜的是IDA未能在局部栈里将这些局部变量逐一识别出来,因此未能标记变量的别名。但是读到这里的读者都是有经验的逆向工程分析人员了,不再需要辅助信息仍然可以分析出数据的作用。
需要注意的是“lea edx, [eax+76Ch]”指令。它给EAX寄存器里的值加上0x76C(1900),而不会修改任何标志位。有关LEA指令的更多信息,请参见附录A.6.2。
GDB
我们使用GDB调试这个程序,可得到如下所示的代码。[4]
指令清单21.7 GDB
dennis@ubuntuvm:~/polygon$ date
Mon Jun 2 18:10:37 EEST 2014
dennis@ubuntuvm:~/polygon$ gcc GCC_tm.c -o GCC_tm
dennis@ubuntuvm:~/polygon$ gdb GCC_tm
GNU gdb (GDB) 7.6.1-ubuntu
Copyright (C) 2013 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 "i686-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /home/dennis/polygon/GCC_tm...(no debugging symbols found)...done.
(gdb) b printf
Breakpoint 1 at 0x8048330
(gdb) run
Starting program: /home/dennis/polygon/GCC_tm
Breakpoint 1, __printf (format=0x80485c0 "Year: %d\n") at printf.c:29
29 printf.c: No such file or directory.
(gdb) x/20x $esp
0xbffff0dc: 0x080484c3 0x080485c0 0x000007de 0x00000000
0xbffff0ec: 0x08048301 0x538c93ed 0x00000025 0x0000000a
0xbffff0fc: 0x00000012 0x00000002 0x00000005 0x00000072
0xbffff10c: 0x00000001 0x00000098 0x00000001 0x00002a30
0xbffff11c: 0x0804b090 0x08048530 0x00000000 0x00000000
(gdb)
在数据栈中,结构体的构造非常清晰。首先我们看看time.h里的定义。
指令清单21.8 time.h
struct tm
{
int tm_sec;
int tm_min;
int tm_hour;
int tm_mday;
int tm_mon;
int tm_year;
int tm_wday;
int tm_yday;
int tm_isdst;
};
Linux的tm结构体的每个元素都是int型数据。在数据类型上它就和Windows 的SYSTEMTIME结构体采用的WORD型数据不同。
我们继续分析局部栈的情况:
0xbffff0dc:0x080484c3 0x080485c0 0x000007de 0x00000000
0xbffff0ec:0x08048301 0x538c93ed 0x00000025 秒 0x0000000a 分
0xbffff0fc:0x00000012时 0x00000002 mday 0x00000005 月 0x00000072 年
0xbffff10c:0x00000001 wday 0x00000098 yday 0x00000001 isdst 0x00002a30
0xbffff11c:0x0804b090 0x08048530 0x00000000 0x00000000
整理一下,结果如下表所示。
十六进制数 |
十进制 |
字段 |
---|---|---|
0x00000025 |
37 |
tm_sec |
0x0000000a |
10 |
tm_min |
0x00000012 |
18 |
tm_hour |
0x00000002 |
2 |
tm_mday |
0x00000005 |
5 |
tm_mon |
0x00000072 |
114 |
tm_year |
0x00000001 |
1 |
tm_wday |
0x00000098 |
152 |
tm_yday |
0x00000001 |
1 |
tm_isdst |
这个结构体比SYSTEMTIME多了一些字段,例如tm_wday/ tm_yday/ tm_isdst字段,不过本例用不到这些字段。
Optimizing Keil 6/2013 (Thumb mode)
使用Keil 6(启用优化选项)编译上述结构体程序,可得到Thumb模式的程序如下所示。
指令清单21.9 Optimizing Keil 6/2013 (Thumb mode)
var_38 = -0x38
var_34 = -0x34
var_30 = -0x30
var_2C = -0x2C
var_28 = -0x28
var_24 = -0x24
timer = -0xC
PUSH {LR}
MOVS R0, #0 ; timer
SUB SP, SP, #0x34
BL time
STR R0, [SP,#0x38+timer]
MOV R1, SP ; tp
ADD R0, SP, #0x38+timer ; timer
BL localtime_r
LDR R1, =0x76C
LDR R0, [SP,#0x38+var_24]
ADDS R1, R0, R1
ADR R0, aYearD ; "Year: %d\n"
BL __2printf
LDR R1, [SP,#0x38+var_28]
ADR R0, aMonthD ; "Month: %d\n"
BL __2printf
LDR R1, [SP,#0x38+var_2C]
ADR R0, aDayD ; "Day: %d\n"
BL __2printf
LDR R1, [SP,#0x38+var_30]
ADR R0, aHourD ; "Hour: %d\n"
BL __2printf
LDR R1, [SP,#0x38+var_34]
ADR R0, aMinutesD ; "Minutes: %d\n"
BL __2printf
LDR R1, [SP,#0x38+var_38]
ADR R0, aSecondsD ; "Seconds: %d\n"
BL __2printf
ADD SP, SP, #0x34
POP {PC}
Optimizing Xcode 4.6.3 (LLVM) (Thumb-2 mode)
通过分析被调用方函数的函数名称(例如本例的localtime_r()函数),IDA能够“识别”出返回值为结构体型数据,并能给结构体中的字段重新标注名称。
指令清单21.10 Optimizing Xcode 4.6.3 (LLVM) (Thumb-2 mode)
var_38 = -0x38
var_34 = -0x34
PUSH {R7,LR}
MOV R7, SP
SUB SP, SP, #0x30
MOVS R0, #0 ; time_t *
BLX _time
ADD R1, SP, #0x38+var_34 ; struct tm *
STR R0, [SP,#0x38+var_38]
MOV R0, SP ; time_t *
BLX _localtime_r
LDR R1, [SP,#0x38+var_34.tm_year]
MOV R0, 0xF44 ; "Year: %d\n"
ADD R0, PC ; char *
ADDW R1, R1, #0x76C
BLX _printf
LDR R1, [SP,#0x38+var_34.tm_mon]
MOV R0, 0xF3A ; "Month: %d\n"
ADD R0, PC ; char *
BLX _printf
LDR R1, [SP,#0x38+var_34.tm_mday]
MOV R0, 0xF35 ; "Day: %d\n"
ADD R0, PC ; char *
BLX _printf
LDR R1, [SP,#0x38+var_34.tm_hour]
MOV R0, 0xF2E ; "Hour: %d\n"
ADD R0, PC ; char *
BLX _printf
LDR R1, [SP,#0x38+var_34.tm_min]
MOV R0, 0xF28 ; "Minutes: %d\n"
ADD R0, PC ; char *
BLX _printf
LDR R1, [SP,#0x38+var_34]
MOV R0, 0xF25 ; "Seconds: %d\n"
ADD R0, PC ; char *
BLX _printf
ADD SP, SP, #0x30
POP {R7,PC}
......
00000000 tm struc ; (sizeof=0x2C, standard type)
00000000 tm_sec DCD ?
00000004 tm_min DCD ?
00000008 tm_hour DCD ?
0000000C tm_mday DCD ?
00000010 tm_mon DCD ?
00000014 tm_year DCD ?
00000018 tm_wday DCD ?
0000001C tm_yday DCD ?
00000020 tm_isdst DCD ?
00000024 tm_gmtoff DCD ?
00000028 tm_zone DCD ? ; offset
0000002C tm ends
指令清单21.11 Optimizing GCC 4.4.5 (IDA)
1 main:
2
3 ; IDA is not aware of structure field names, we named them manually:
4
5 var_40 = -0x40
6 var_38 = -0x38
7 seconds = -0x34
8 minutes = -0x30
9 hour = -0x2C
10 day = -0x28
11 month = -0x24
12 year = -0x20
13 var_4 = -4
14
15 lui $gp, (__gnu_local_gp >> 16)
16 addiu $sp, -0x50
17 la $gp, (__gnu_local_gp & 0xFFFF)
18 sw $ra, 0x50+var_4($sp)
19 sw $gp, 0x50+var_40($sp)
20 lw $t9, (time & 0xFFFF)($gp)
21 or $at, $zero ; load delay slot, NOP
22 jalr $t9
23 move $a0, $zero ; branch delay slot, NOP
24 lw $gp, 0x50+var_40($sp)
25 addiu $a0, $sp, 0x50+var_38
26 lw $t9, (localtime_r & 0xFFFF)($gp)
27 addiu $a1, $sp, 0x50+seconds
28 jalr $t9
29 sw $v0, 0x50+var_38($sp) ; branch delay slot
30 lw $gp, 0x50+var_40($sp)
31 lw $a1, 0x50+year($sp)
32 lw $t9, (printf & 0xFFFF)($gp)
33 la $a0, $LC0 # "Year: %d\n"
34 jalr $t9
35 addiu $a1, 1900 ; branch delay slot
36 lw $gp, 0x50+var_40($sp)
37 lw $a1, 0x50+month($sp)
38 lw $t9, (printf & 0xFFFF)($gp)
39 lui $a0, ($LC1 >> 16) # "Month: %d\n"
40 jalr $t9
41 la $a0, ($LC1 & 0xFFFF) # "Month: %d\n" ; branch delay slot
42 lw $gp, 0x50+var_40($sp)
43 lw $a1, 0x50+day($sp)
44 lw $t9, (printf & 0xFFFF)($gp)
45 lui $a0, ($LC2 >> 16) # "Day: %d\n"
46 jalr $t9
47 la $a0, ($LC2 & 0xFFFF) # "Day: %d\n" ; branch delay slot
48 lw $gp, 0x50+var_40($sp)
49 lw $a1, 0x50+hour($sp)
50 lw $t9, (printf & 0xFFFF)($gp)
51 lui $a0, ($LC3 >> 16) # "Hour: %d\n"
52 jalr $t9
53 la $a0, ($LC3 & 0xFFFF) # "Hour: %d\n" ; branch delay slot
54 lw $gp, 0x50+var_40($sp)
55 lw $a1, 0x50+minutes($sp)
56 lw $t9, (printf & 0xFFFF)($gp)
57 lui $a0, ($LC4 >> 16) # "Minutes: %d\n"
58 jalr $t9
59 la $a0, ($LC4 & 0xFFFF) # "Minutes: %d\n" ; branch delay slot
60 lw $gp, 0x50+var_40($sp)
61 lw $a1, 0x50+seconds($sp)
62 lw $t9, (printf & 0xFFFF)($gp)
63 lui $a0, ($LC5 >> 16) # "Seconds: %d\n"
64 jalr $t9
65 la $a0, ($LC5 & 0xFFFF) # "Seconds: %d\n" ; branch delay slot
66 lw $ra, 0x50+var_4($sp)
67 or $at, $zero ; load delay slot, NOP
68 jr $ra
69 addiu $sp, 0x50
70
71 $LC0: .ascii "Year: %d\n"<0>
72 $LC1: .ascii "Month: %d\n"<0>
73 $LC2: .ascii "Day: %d\n"<0>
74 $LC3: .ascii "Hour: %d\n"<0>
75 $LC4: .ascii "Minutes: %d\n"<0>
76 $LC5: .ascii "Seconds: %d\n"<0>
这个案例再次演示了延时槽影响人工分析的严重程度。例如,位于第35行的“addiu $a1, 1900”指令,把返回值加上1900。千万别忘记它的执行顺序先于第34行的JALR指令。
在内存中,结构体是依次排列的一系列数据。为了演示它的这一特色,我们对指令清单21.8的程序进行少许修改,以使用数组替代tm结构体:
#include <stdio.h>
#include <time.h>
void main()
{
int tm_sec, tm_min, tm_hour, tm_mday, tm_mon, tm_year, tm_wday, tm_yday, tm_isdst;
time_t unix_time;
unix_time=time(NULL);
localtime_r (&unix_time, &tm_sec);
printf ("Year: %d\n", tm_year+1900);
printf ("Month: %d\n", tm_mon);
printf ("Day: %d\n", tm_mday);
printf ("Hour: %d\n", tm_hour);
printf ("Minutes: %d\n", tm_min);
printf ("Seconds: %d\n", tm_sec);
};
注解:指向结构体的指针,实际上指向结构体的第一个元素。
使用GCC 4.7.3编译的时候,会看到编译器提示如下所示。
指令清单21.12 GCC 4.7.3
GCC_tm2.c: In function ’main’:
GCC_tm2.c:11:5: warning: passing argument 2 of ’localtime_r’ from incompatible pointer type [ enabled↙
↘by default]
In file included from GCC_tm2.c:2:0:
/usr/include/time.h:59:12: note: expected ’struct tm *’ but argument is of type ’int *’
不过这些问题不影响正常编译,所得可执行程序的具体指令如下所示。
指令清单21.13 GCC 4.7.3
main proc near
var_30 = dword ptr -30h
var_2C = dword ptr -2Ch
unix_time = dword ptr -1Ch
tm_sec = dword ptr -18h
tm_min = dword ptr -14h
tm_hour = dword ptr -10h
tm_mday = dword ptr -0Ch
tm_mon = dword ptr -8
tm_year = dword ptr -4
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
sub esp, 30h
call __main
mov [esp+30h+var_30], 0 ; arg 0
call time
mov [esp+30h+unix_time], eax
lea eax, [esp+30h+tm_sec]
mov [esp+30h+var_2C], eax
lea eax, [esp+30h+unix_time]
mov [esp+30h+var_30], eax
call localtime_r
mov eax, [esp+30h+tm_year]
add eax, 1900
mov [esp+30h+var_2C], eax
mov [esp+30h+var_30], offset aYearD ; "Year: %d\n"
call printf
mov eax, [esp+30h+tm_mon]
mov [esp+30h+var_2C], eax
mov [esp+30h+var_30], offset aMonthD ; "Month: %d\n"
call printf
mov eax, [esp+30h+tm_mday]
mov [esp+30h+var_2C], eax
mov [esp+30h+var_30], offset aDayD ; "Day: %d\n"
call printf
mov eax, [esp+30h+tm_hour]
mov [esp+30h+var_2C], eax
mov [esp+30h+var_30], offset aHourD ; "Hour: %d\n"
call printf
mov eax, [esp+30h+tm_min]
mov [esp+30h+var_2C], eax
mov [esp+30h+var_30], offset aMinutesD ; "Minutes: %d\n"
call printf
mov eax, [esp+30h+tm_sec]
mov [esp+30h+var_2C], eax
mov [esp+30h+var_30], offset aSecondsD ; "Seconds: %d\n"
call printf
leave
retn
main endp
从汇编代码上看,采用数组的程序与先前使用结构体的程序最终竟会如此一致,以至于我们无法从汇编代码上区分出源程序的区别。
虽然上述程序可以照常运行,但是使用数组替换结构体的做法并不值得推荐。一般来说,“不启用优化编译选项”的编译器通常以代码声明变量的次序,在局部栈里分配变量的空间,但是无法保证每次编译的结果都严丝合缝。
如果使用GCC以外的编译器的话,部分编译器可能警告除变量tm_sec之外的tm_year、tm_mon、tm_mday、tm_hour、tm_min没有被初始化。这是因为编译器并不能分析出这些变量将被localtime_r()函数赋值。
在这个程序里,结构体各字段都是int型数据,所以本例十分直观。如果源程序中结构体的字段都是 16 位 WORD型数据,且采取了SYSTEMTIME那样的数据结构,因为局部变量向32位边界对齐的缘故,这将使GetSystemTime()无法正常赋值。有关结构体的字段封装问题,请参考21.4节。
由此可见,结构体就是一连串变量的封装体。在内存中结构体的各字段依次排列。我认为结构体就是语体上的糖块,使其内各个变量像糖分一样粘成一个统一体,以便编译器把它们分配到连续空间里。即使别人可能认为我是编程专家,但是我毕竟不是,所以这种说法很可能不够确切。顺便提一下,早期的(1972年之前)C语言不支持结构体structure。[5]
这个可执行程序完全和前面的程序一样,本书就不演示相关调试过程了。
#include <stdio.h>
#include <time.h>
void main()
{
struct tm t;
time_t unix_time;
int i;
unix_time=time(NULL);
localtime_r (&unix_time, &t);
for (i=0; i<9; i++)
{
int tmp=((int*)&t)[i];
printf ("0x%08X (%d)\n", tmp, tmp);
};
};
上述程序把同一地址(指针)的数据分别当作结构体和整型数据、分别进行写和读的访问操作,完全可以正常工作。在当前时间为“23:51:45 26-July-2014”的时刻,内存中的数据如下:
0x0000002D (45)
0x00000033 (51)
0x00000017 (23)
0x0000001A (26)
0x00000006 (6)
0x00000072 (114)
0x00000006 (6)
0x000000CE (206)
0x00000001 (1)
这些变量的排列数据,与结构体在源代码中的声明顺序相同。详细内容请参见本书第21章第8节。
编译而得的程序如下所示。
指令清单21.14 Optimizing GCC 4.8.1
main proc near
push ebp
mov ebp, esp
push esi
push ebx
and esp, 0FFFFFFF0h
sub esp, 40h
mov dword ptr [esp], 0 ; timer
lea ebx, [esp+14h]
call _time
lea esi, [esp+38h] ;tp
mov [esp+4], ebx
mov [esp+10h], eax
lea eax, [esp+10h]
mov [esp], eax ; timer
call _localtime_r
nop
lea esi, [esi+0] ; nop
loc_80483D8:
; EBX here is pointer to structure, ESI is the pointer to the end of it.
mov eax, [ebx] ; get 32-bit word from array
add ebx, 4 ; next field in structure
mov dword ptr [esp+4], offset a0x08xD ; "0x%08X (%d)\n"
mov dword ptr [esp], 1
mov [esp+0Ch], eax ; pass value to printf()
mov [esp+8], eax ; pass value to printf()
call ___printf_chk
cmp ebx, esi ; meet structure end?
jnz short loc_80483D8 ; no - load next value then
lea esp, [ebp-8]
pop ebx
pop esi
pop ebp
retn
main endp
栈空间内的数据依次被看作两种数据:先被当作结构体、再被当作数组。
本例表明,我们可以借助指针修改结构体中的各别字段。
本文再次强调:若不是以hack目的研究代码,就不必做这种处理。在编写生产环境下的程序时,不建议使用这种处理手段。
更进一步的实验表明,也可以让时间结构体与字节型数组共用一个指针。
#include <stdio.h>
#include <time.h>
void main()
{
struct tm t;
time_t unix_time;
int i, j;
unix_time=time(NULL);
localtime_r (&unix_time, &t);
for (i=0; i<9; i++)
{
for (j=0; j<4; j++)
printf ("0x%02X ", ((unsigned char*)&t)[i*4+j]);
printf ("\n");
};
};
0x2D 0x00 0x00 0x00
0x33 0x00 0x00 0x00
0x17 0x00 0x00 0x00
0x1A 0x00 0x00 0x00
0x06 0x00 0x00 0x00
0x72 0x00 0x00 0x00
0x06 0x00 0x00 0x00
0xCE 0x00 0x00 0x00
0x01 0x00 0x00 0x00
假如此时的系统时间和21.3.5节那个程序同时启动,它肯定会提示“23:51:45 26-July-2014”。需要注意的是,因为采用了小端字节序(参见第31章),数权较小的字节反而排在数权较大的字节之前。
指令清单21.15 Optimizing GCC 4.8.1
main proc near
push ebp
mov ebp, esp
push edi
push esi
push ebx
and esp, 0FFFFFFF0h
sub esp, 40h
mov dword ptr [esp], 0 ; timer
lea esi, [esp+14h]
call _time
lea edi, [esp+38h] ; struct end
mov [esp+4], esi ; tp
mov [esp+10h], eax
lea eax, [esp+10h]
mov [esp], eax ; timer
call _localtime_r
lea esi, [esi+0] ; NOP
; ESI here is the pointer to structure in local stack. EDI is the pointer to structure end.
loc_8048408:
xor ebx, ebx ; j=0
loc_804840A:
movzx eax, byte ptr [esi+ebx] ; load byte
add ebx, 1 ; j=j+1
mov dword ptr [esp+4], offset a0x02x ; "0x%02X "
mov dword ptr [esp], 1
mov [esp+8], eax ; pass loaded byte to printf()
call ___printf_chk
cmp ebx, 4
jnz short loc_804840A
; print carriage return character (CR)
mov dword ptr [esp], 0Ah ; c
add esi, 4
call _putchar
cmp esi, edi ; meet struct end?
jnz short loc_8048408 ; j=0
lea esp, [ebp-0Ch]
pop ebx
pop esi
pop edi
pop ebp
retn
main endp
结构体的字段封装方法构成了这种数据类型的一个重要特性。[6]
我们以下面的例子来加以说明。
#include <stdio.h>
struct s
{
char a;
int b;
char c;
int d;
};
void f(struct s s)
{
printf ("a=%d; b=%d; c=%d; d=%d\n", s.a, s.b, s.c, s.d);
};
int main()
{
struct s tmp;
tmp.a=1;
tmp.b=2;
tmp.c=3;
tmp.d=4;
f(tmp);
};
其中有2个单字节型char字段和2个4字节的int型变量。
使用MSVC 2012(启用选项/GS- /Ob0)编译,可得如下所示的代码。
指令清单21.16 MSVC 2012/GS-/O60
1 _tmp$ = -16
2 _main PROC
3 push ebp
4 mov ebp, esp
5 sub esp, 16
6 mov BYTE PTR _tmp$[ebp], 1 ; set field a
7 mov DWORD PTR _tmp$[ebp+4], 2 ; set field b
8 mov BYTE PTR _tmp$[ebp+8], 3 ; set field c
9 mov DWORD PTR _tmp$[ebp+12], 4 ; set field d
10 sub esp, 16 ; allocate place for temporary structure
11 mov eax, esp
12 mov ecx, DWORD PTR _tmp$[ebp] ; copy our structure to the temporary one
13 mov DWORD PTR [eax], ecx
14 mov edx, DWORD PTR _tmp$[ebp+4]
15 mov DWORD PTR [eax+4], edx
16 mov ecx, DWORD PTR _tmp$[ebp+8]
17 mov DWORD PTR [eax+8], ecx
18 mov edx, DWORD PTR _tmp$[ebp+12]
19 mov DWORD PTR [eax+12], edx
20 call _f
21 add esp, 16
22 xor eax, eax
23 mov esp, ebp
24 pop ebp
25 ret 0
26 _main ENDP
27
28 _s$ = 8 ; size = 16
29 ?f@@YAXUs@@@Z PROC ; f
30 push ebp
31 mov ebp, esp
32 mov eax, DWORD PTR _s$[ebp+12]
33 push eax
34 movsx ecx, BYTE PTR _s$[ebp+8]
35 push ecx
36 mov edx, DWORD PTR _s$[ebp+4]
37 push edx
38 movsx eax, BYTE PTR _s$[ebp]
39 push eax
40 push OFFSET $SG3842
41 call _printf
42 add esp, 20
43 pop ebp
44 ret 0
45 ?f@@YAXUs@@@Z ENDP ; f
46 _TEXT ENDS
虽然我们在代码里一次性分配了结构体tmp,并依次给它的四个字段赋值,但是可执行程序的指令有些不同:它将结构体的指针复制到临时地址(第10行指令分配的空间里),然后通过临时的中间变量把结构体的四个值赋值给临时结构体(第12~19行的指令),还把指针也复制出来供f()调用。这主要是因为编译器无法判断f()函数是否会修改结构体的内容。借助中间变量,编译器可以保证main()函数里tmp结构体的值不受被调用方函数的影响。我们也可以改动源程序、使用指针传递数据,那样编译器生成的汇编指令也基本相同,但是不会再复制数据了。
另外,这个程序里结构体的字段向4字节边界对齐。也就是说它的char型数据也和int型数据一样占4字节存储空间。这主要是为了方便CPU从内存读取数据,提高读写和缓存的效率。
这样做的缺点是浪费存储空间。
接下来我们启用编译器的/Zp1 (/Zp[n]表示向n个字节的边界对齐)选项。
使用MSVC 2012(启用/GS- /Zp1选项)编译上述程序,可得到如下所示的代码。
指令清单21.17 MSVC 2012 /GS- /Zp1
1 _main PROC
2 push ebp
3 mov ebp, esp
4 sub esp, 12
5 mov BYTE PTR _tmp$[ebp], 1 ; set field a
6 mov DWORD PTR _tmp$[ebp+1], 2 ; set field b
7 mov BYTE PTR _tmp$[ebp+5], 3 ; set field c
8 mov DWORD PTR _tmp$[ebp+6], 4 ; set field d
9 sub esp, 12 ; allocate place for temporary structure
10 mov eax, esp
11 mov ecx, DWORD PTR _tmp$[ebp] ; copy 10 bytes
12 mov DWORD PTR [eax], ecx
13 mov edx, DWORD PTR _tmp$[ebp+4]
14 mov DWORD PTR [eax+4], edx
15 mov cx, WORD PTR _tmp$[ebp+8]
16 mov WORD PTR [eax+8], cx
17 call _f
18 add esp, 12
19 xor eax, eax
20 mov esp, ebp
21 pop ebp
22 ret 0
23 _main ENDP
24
25 _TEXT SEGMENT
26 _s$ = 8 ; size = 10
27 ?f@@YAXUs@@@Z PROC ; f
28 push ebp
29 mov ebp, esp
30 mov eax, DWORD PTR _s$[ebp+6]
31 push eax
32 movsx ecx, BYTE PTR _s$[ebp+5]
33 push ecx
34 mov edx, DWORD PTR _s$[ebp+1]
35 push edx
36 movsx eax, BYTE PTR _s$[ebp]
37 push eax
38 push OFFSET $SG3842
39 call _printf
40 add esp, 20
41 pop ebp
42 ret 0
43 ?f@@YAXUs@@@Z ENDP ; f
经过这种处理之后,结构体只占用10字节空间,其中的char型数据占用1字节。这将提高代码的空间利用效率,不过这样做会同时降低CPU的IO读取效率。
main()函数里同样使用临时结构体复制了传入参数的信息,再把临时结构体传递给其他函数。不过这10字节数据并不是一个字段一个字段地、按变量声明的那样分4次复制过去的,编译器分配3对MOV指令复制它们。为什么不是4对赋值指令?这是因为编译器认为,在复制10字节数据时,3个MOV指令对的效率,比4对MOV指令对(按字段赋值)的效率要高。这是编译器常用的优化手段。编译器使用MOV指令直接实现(替代)memcpy()函数的情况十分普遍,主要就是因为在复制小型数据时memcpy()函数没有MOV指令的效率高。如需了解这方面详细知识,请参见本书43.1.5节。
当然,如果某个结构体被多个源文件、目标文件(object files)调用,那么在编译这些程序时,结构封装格式和数据对其规范(/Zp[n])必须完全匹配。
MSVC编译器有指定结构体字段对齐标准的/Zp选项。此外,还可以通过在源文件里设定#pragma pack的方法来指定这个选项。MSVC和GCC都支持这种代码级的宏指令。[7]
我们回顾一下使用16位型数据的结构体SYSTEMTIME。编译器如何知道要将它的字段向1字节边界对齐呢?
文件WinNT.h有如下声明。
指令清单21.18 WinNT.h
#include "pshpack1.h"
而且还有下述声明。
指令清单21.19 WinNT.h
#include "pshpack4.h"//默认情况下进行4字节边界对齐
PshPack1.h文件有下列内容。
指令清单21.20 PshPack1.h
#if ! (defined(lint) || defined(RC_INVOKED))
#if ( _MSC_VER >= 800 && !defined(_M_I86)) || defined(_PUSHPOP_SUPPORTED)
#pragma warning(disable:4103)
#if !(defined( MIDL_PASS )) || defined( __midl )
#pragma pack(push,1)
#else
#pragma pack(1)
#endif
#else
#pragma pack(1)
#endif
#endif /* ! (defined(lint) || defined(RC_INVOKED)) */
根据#pragma pack的信息,编译器会在封装结构体时向相应的边界对齐。
OllyDbg + 默认封装格式
现在打开OllyDbg,加载上述以默认封装格式(4字节边界对齐)的程序,如图21.3所示。
图21.3 OllyDbg:调用printf()函数之前
我们在数据窗口可以找到四个字段的值。但是,第一个字段(变量a)和第三个字段(变量c)空间之后的数据工具出现了随机值(0x30,0x37, 0x01)。它们是怎么产生的?在指令清单21.16的源程序中,变量a和变量c都是char型单字节数据。程序的第6、第8行,分别给它们赋值1、3。它们在内存里占用了4个字节,而其他32位存储空间里有3个字节并没有被赋值!在这3个字节空间里的数据,就是随机脏数据。因为在给printf()函数传递参数时,编译器会使用的是单字节数据赋值指令MOVSX(参见指令清单21.16的第34行和第38行)传递数据,所以这些脏数据并不会影响printf()函数的输出结果。
另外,因为a和c是char型数据,char型数据属于有符号(signed)型数据,所以复制操作所对应的汇编指令是MOVSX(SX是sign-extending的缩写)。如果它们是unsigned char或uint8_t型数据,那么此处就会是MOVZX指令。
OllyDbg + 字段向单字节边界对齐
这种情况下,整个结构体的各个变量在内存里依次排列,如图21.4所示。
图21.4 OllyDbg:在调用printf()函数之前
Optimizing Keil 6/2013 (Thumb mode)
使用Keil 6/2013(开启优化选项)、以Thumb模式编译上述程序可得如下所示的代码。
指令清单21.21 Optimizing Keil 6/2013 (Thumb mode)
.text:0000003E exit ; CODE XREF: f+16
.text:0000003E 05 B0 ADD SP, SP, #0x14
.text:00000040 00 BD POP {PC}
.text:00000280 f
.text:00000280
.text:00000280 var_18 = -0x18
.text:00000280 a = -0x14
.text:00000280 b = -0x10
.text:00000280 c = -0xC
.text:00000280 d =-8
.text:00000280
.text:00000280 0F B5 PUSH {R0-R3,LR}
.text:00000282 81 B0 SUB SP, SP, #4
.text:00000284 04 98 LDR R0, [SP,#16] ; d
.text:00000286 02 9A LDR R2, [SP,#8] ; b
.text:00000288 00 90 STR R0, [SP]
.text:0000028A 68 46 MOV R0, SP
.text:0000028C 03 7B LDRB R3, [R0,#12] ; c
.text:0000028E 01 79 LDRB R1, [R0,#4] ; a
.text:00000290 59 A0 ADR R0, aADBDCDDD ; "a=%d; b=%d; c=%d; d=%d\n"
.text:00000292 05 F0 AD FF BL __2printf
.text:00000296 D2 E6 B exit
本例向被调用方函数传递的是结构体型数据,不是结构体的指针。因为ARM会利用寄存器传递函数所需的前4个参数,所以编译器利用3R0~R3寄存器向printf()函数传递结构体的全部字段。
LDRB从内存中加载1个字节并转换为32位有符号数据,用来从结构体中读取字段a和字段c。它相当于x86指令集中的MOVSX指令。
请注意,在函数退出时,它借用了另一个函数的函数尾声!这种借助B指令跳转到其他完全不相干的函数、共用同一个函数尾声的现象,应当是因为两个函数的局部变量的存储空间完全相同。或许因为这两个函数在启动时分配的栈大小相同(都分配了4×5=0x14的数据栈),导致退出语句也完全相同的缘故,再加上它们在内存中的地址相近的因素,所以编译器进行了这种处理。确实,使用同一组退出语句不会影响程序的任何功能。这明显是Keil编译器出于经济因素而进行的指令复用。JMP指令只占用2个字节,而标准的函数尾声要占用4字节。
ARM + Optimizing Xcode 4.6.3 (LLVM) (Thumb-2 mode)
指令清单21.22 Optimizing Xcode 4.6.3 (LLVM) (Thumb-2 mode)
var_C = -0xC
PUSH {R7,LR}
MOV R7, SP
SUB SP, SP, #4
MOV R9,R1;b
MOV R1,R0;a
MOVW R0, #0xF10 ; "a=%d; b=%d; c=%d; d=%d\n"
SXTB R1, R1 ;制备a
MOVT.W R0, #0
STR R3, [SP,#0xC+var_C] ; 推送d 入栈,供 printf() 调用
ADD R0, PC ; 格式化字符串
SXTB R3, R2 ; 制备c
MOV R2,R9;制备b
BLX _printf
ADD SP, SP, #4
POP {R7,PC}
SXTB(Signed Extend Byte)对应x86的MOVSX指令,但是它只能处理寄存器的数据,不能直接对内存进行操作。程序中的其余指令与前例一样,本文不再进行重复说明。
指令清单21.23 Optimizing GCC 4.4.5 (IDA)
1 f:
2
3 var_18 = -0x18
4 var_10 = -0x10
5 var_4 = -4
6 arg_0 = 0
7 arg_4 = 4
8 arg_8 = 8
9 arg_C = 0xC
10
11 ; $a0=s.a
12 ; $a1=s.b
13 ; $a2=s.c
14 ; $a3=s.d
15 lui $gp, (__gnu_local_gp >> 16)
16 addiu $sp, -0x28
17 la $gp, (__gnu_local_gp & 0xFFFF)
18 sw $ra, 0x28+var_4($sp)
19 sw $gp, 0x28+var_10($sp)
20 ; prepare byte from 32-bit big-endian integer:
21 sra $t0, $a0, 24
22 move $v1, $a1
23 ; prepare byte from 32-bit big-endian integer:
24 sra $v0, $a2, 24
25 lw $t9, (printf & 0xFFFF)($gp)
26 sw $a0, 0x28+arg_0($sp)
27 lui $a0, ($LC0 >> 16) # "a=%d; b=%d; c=%d; d=%d\n"
28 sw $a3, 0x28+var_18($sp)
29 sw $a1, 0x28+arg_4($sp)
30 sw $a2, 0x28+arg_8($sp)
31 sw $a3, 0x28+arg_C($sp)
32 la $a0, ($LC0 & 0xFFFF) # "a=%d; b=%d; c=%d; d=%d\n"
33 move $a1, $t0
34 move $a2, $v1
35 jalr $t9
36 move $a3, $v0 ; branch delay slot
37 lw $ra, 0x28+var_4($sp)
38 or $at, $zero ; load delay slot, NOP
39 jr $ra
40 addiu $sp, 0x28 ; branch delay slot
41
42 $LC0: .ascii "a=%d; b=%d; c=%d; d=%d\n"<0>
结构体各字段首先被安置于$A0~$A3寄存器,然后又被重新安放于$A1~$A3寄存器以传递给printf()函数。较为特殊的是,上述程序使用了两次SRA(Shift Word Right Arithmetic)指令,而SRA指令用于制备char型数据的字段。这是为什么?MIPS默认采用大端字节序(big-endian,参见本书第31章),此外我用的Debian Linux也使用大端字节序。当使用32位空间存储字节型变量时,数据占用第31~24位。因此,当把char型数据扩展为32位数据时,必须右移24位。再加上char型数据属于有符号型数据,所以此处必须用算术位移指令而不能使用逻辑位移指令。
在向被调用方函数传递结构体时(不是传递结构体的指针),传递参数的过程相当于依次传递结构体的各字段。即是说,如果结构体各字段的定义不变,那么f()函数的源代码可改写为:
void f(char a, int b, char c, int d)
{
printf ("a=%d; b=%d; c=%d; d=%d\n", a, b, c, d);
};
即使经过上述改动,编译器生成的可执行程序也完全不会发生变化。
结构体套用另外一个结构体的情况大体如下:
#include <stdio.h>
struct inner_struct
{
int a;
int b;
};
struct outer_struct
{
char a;
int b;
struct inner_struct c;
char d;
int e;
};
void f(struct outer_struct s)
{
printf ("a=%d; b=%d; c.a=%d; c.b=%d; d=%d; e=%d\n",
s.a, s.b, s.c.a, s.c.b, s.d, s.e);
};
int main()
{
struct outer_struct s;
s.a=1;
s.b=2;
s.c.a=100;
s.c.b=101;
s.d=3;
s.e=4;
f(s);
};
这个程序把结构体inner_struct当作另一个结构体outer_struct 的字段来用,它和outer_struct的a、b、d、e一样,都是一个字段。
我们使用MSVC 2010(启用/Ox /Ob0选项)编译上述程序,可得到如下所示的代码。
指令清单21.24 Optimizing MSVC 2010 /Ob0
$SG2802 DB ’a=%d; b=%d; c.a=%d; c.b=%d; d=%d; e=%d’, 0aH, 00H
_TEXT SEGMENT
_s$ = 8
_f PROC
mov eax, DWORD PTR _s$[esp+16]
movsx ecx, BYTE PTR _s$[esp+12]
mov edx, DWORD PTR _s$[esp+8]
push eax
mov eax, DWORD PTR _s$[esp+8]
push ecx
mov ecx, DWORD PTR _s$[esp+8]
push edx
movsx edx, BYTE PTR _s$[esp+8]
push eax
push ecx
push edx
push OFFSET $SG2802 ; ’a=%d; b=%d; c.a=%d; c.b=%d; d=%d; e=%d’
call _printf
add esp, 28
ret 0
_f ENDP
_s$ = -24
_main PROC
sub esp, 24
push ebx
push esi
push edi
mov ecx, 2
sub esp, 24
mov eax, esp
mov BYTE PTR _s$[esp+60], 1
mov ebx, DWORD PTR _s$[esp+60]
mov DWORD PTR [eax], ebx
mov DWORD PTR [eax+4], ecx
lea edx, DWORD PTR [ecx+98]
lea esi, DWORD PTR [ecx+99]
lea edi, DWORD PTR [ecx+2]
mov DWORD PTR [eax+8], edx
mov BYTE PTR _s$[esp+76], 3
mov ecx, DWORD PTR _s$[esp+76]
mov DWORD PTR [eax+12], esi
mov DWORD PTR [eax+16], ecx
mov DWORD PTR [eax+20], edi
call _f
add esp, 24
pop edi
pop esi
xor eax, eax
pop ebx
add esp, 24
ret 0
_main ENDP
在汇编代码中,我们找不到内嵌结构体的影子。所以,我们可以断定,嵌套模式的结构体会被编译器展开,最终形成一维结构体。
当然,如果使用“struct inter_stuct *c”替代源程序中的“struct iner_struct c”,汇编指令会大不相同。
我们使用OllyDbg加载上述程序,观测outer_struct。如图21.5所示。
图21.5 OllyDbg:在执行printf()之前
它在内存中的构造如下:
(outer_struct.a)值为1的字节,其后3字节是随机脏数据。
(outer_struct.b) 32 位word型数据2。
(outer_struct.a)32 位word型数据0x64(100)。
(outer_struct.b)32 位word型数据0x65(101)。
(outer_struct.d)值为3的字节,以及其后3字节的脏数据。
(outer_struct.e)32 位word型数据4。
C/C++语言可以精确操作结构体中的位域。这能够帮助程序员大幅度地节省程序的内存消耗。例如,bool型数据就需要1位空间。但是时间开销和空间效率不可两全,如果要节约内存开销,程序的性能就会下降。
以CPUID指令为例。该指令用于获取CPU及其特性信息。[8]
如果在调用指令之前设置EAX寄存器的值为1,那么CPUID指令将会按照下列格式在EAX寄存器里存储CPU的特征信息。
3:0 (4 bits) 7:4 (4 bits) 11:8 (4 bits) 13:12(2 bits) 19:16(4 bits) 27:20(8 bits) |
Stepping Model Family Processor Type Extended Model Extended Family |
MSVC 2010 有CPUID宏,而GCC 4.4.1没有这个宏。所以,我们自己写一个函数,再用GCC编译它。[9]
#include <stdio.h>
#ifdef __GNUC__
static inline void cpuid(int code, int *a, int *b, int *c, int *d) {
asm volatile("cpuid":"=a"(*a),"=b"(*b),"=c"(*c),"=d"(*d):"a"(code));
}
#endif
#ifdef _MSC_VER
#include <intrin.h>
#endif
struct CPUID_1_EAX
{
unsigned int stepping:4;
unsigned int model:4;
unsigned int family_id:4;
unsigned int processor_type:2;
unsigned int reserved1:2;
unsigned int extended_model_id:4;
unsigned int extended_family_id:8;
unsigned int reserved2:4;
};
int main()
{
struct CPUID_1_EAX *tmp;
int b[4];
#ifdef _MSC_VER
__cpuid(b,1);
#endif
#ifdef __GNUC__
cpuid (1, &b[0], &b[1], &b[2], &b[3]);
#endif
tmp=(struct CPUID_1_EAX *)&b[0];
printf ("stepping=%d\n", tmp->stepping);
printf ("model=%d\n", tmp->model);
printf ("family_id=%d\n", tmp->family_id);
printf ("processor_type=%d\n", tmp->processor_type);
printf ("extended_model_id=%d\n", tmp->extended_model_id);
printf ("extended_family_id=%d\n", tmp->extended_family_id);
return 0;
};
在CPUID将返回值存储到EAX/EBX/ECX/EDX之后,程序使用数组b[]收集相关信息。然后,我们通过指向结构体CPUID_1_EAX的指针,从数组b[]中获取EAX寄存器里的值。
换而言之,我们把32位int型数据分解为结构体型数据,再从结构体中读取各项数值。
MSVC
使用MSVC 2008(启用/Ox 选项)编译上述程序,可得如下所示的代码。
指令清单21.25 Optimizing MSVC 2008
b$ = -16 ;size=16
_main PROC
sub esp, 16
push ebx
xor ecx, ecx
mov eax, 1
cpuid
push esi
lea esi, DWORD PTR _b$[esp+24]
mov DWORD PTR [esi], eax
mov DWORD PTR [esi+4], ebx
mov DWORD PTR [esi+8], ecx
mov DWORD PTR [esi+12], edx
mov esi, DWORD PTR _b$[esp+24]
mov eax, esi
and eax, 15
push eax
push OFFSET $SG15435 ; ’stepping=%d’, 0aH, 00H
call _printf
mov ecx, esi
shr ecx, 4
and ecx, 15
push ecx
push OFFSET $SG15436 ; ’model=%d’, 0aH, 00H
call _printf
mov edx, esi
shr edx, 8
and edx, 15
push edx
push OFFSET $SG15437 ; ’family_id=%d’, 0aH, 00H
call _printf
mov eax, esi
shr eax, 12
and eax, 3
push eax
push OFFSET $SG15438 ; ’processor_type=%d’, 0aH, 00H
call _printf
mov ecx, esi
shr ecx, 16
and ecx, 15
push ecx
push OFFSET $SG15439 ; ’extended_model_id=%d’, 0aH, 00H
call _printf
shr esi, 20
and esi, 255
push esi
push OFFSET $SG15440 ; ’extended_family_id=%d’, 0aH, 00H
call _printf
add esp, 48
pop esi
xor eax, eax
pop ebx
add esp, 16
ret 0
_main ENDP
SHR指令用于过滤位于EAX寄存器中右侧那些不参与运算的bit位。
而AND指令用于过滤位于寄存器左侧且不参与运算的相关位。换句话说,这两条指令用于筛选寄存器的特定bit位。
MSVC+OllyDbg
现在使用OllyDbg调试这个程序。如图21.6所示,在执行CPUID之后,EAX/EBX/ECX/EDX寄存器保存着返回值。
图21.6 OllyDbg:执行CPUID之后的寄存器情况
因为我使用的是Xeon E3-1200 CPU,所以EAX的值是0x000206A7 。它的二进制数值为00000000000000100000011010100111。运行结果如图29.7所示。
图21.7 OllyDbg:运行结果
各字段涵义如下表所示。
字段 |
二进制值 |
十进制值 |
---|---|---|
reserved2 |
0000 |
0 |
Extended_family_id |
00000000 |
0 |
Extended_model_id |
0010 |
2 |
reserved1 |
00 |
0 |
Processor_id |
00 |
0 |
Family_id |
0110 |
6 |
model |
1010 |
10 |
stepping |
0111 |
7 |
GCC
使用GCC 4.4.1(启用优化选项–O3)编译上述程序,可得如下所示的代码。
指令清单21.26 Optimizing GCC 4.4.1
main proc near ; DATA XREF: _start+17
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
push esi
mov esi, 1
push ebx
mov eax, esi
sub esp, 18h
cpuid
mov esi, eax
and eax, 0Fh
mov [esp+8], eax
mov dword ptr [esp+4], offset aSteppingD ; "stepping=%d\n"
mov dword ptr [esp], 1
call ___printf_chk
mov eax, esi
shr eax, 4
and eax, 0Fh
mov [esp+8], eax
mov dword ptr [esp+4], offset aModelD ; "model=%d\n"
mov dword ptr [esp], 1
call ___printf_chk
mov eax, esi
shr eax, 8
and eax, 0Fh
mov [esp+8], eax
mov dword ptr [esp+4], offset aFamily_idD ; "family_id=%d\n"
mov dword ptr [esp], 1
call ___printf_chk
mov eax, esi
shr eax, 0Ch
and eax, 3
mov [esp+8], eax
mov dword ptr [esp+4], offset aProcessor_type ; "processor_type=%d\n"
mov dword ptr [esp], 1
call ___printf_chk
mov eax, esi
shr eax, 10h
shr esi, 14h
and eax, 0Fh
and esi, 0FFh
mov [esp+8], eax
mov dword ptr [esp+4], offset aExtended_model ; "extended_model_id=%d\n"
mov dword ptr [esp], 1
call ___printf_chk
mov [esp+8], esi
mov dword ptr [esp+4], offset unk_80486D0
mov dword ptr [esp], 1
call ___printf_chk
add esp, 18h
xor eax, eax
pop ebx
pop esi
mov esp, ebp
pop ebp
retn
main endp
GCC生成的汇编指令与MSVC库函数基本相同。二者的主要区别是:GCC编译的程序在调用printf()指令之前把extended_model_id和extended_family_id 放在连续的内存块里一并处理了,而没有像MSVC编译的程序那样、在每次调用printf()之前逐一进行计算。
前文已经指出,每个单精度float或双精度double型浮点数,都由符号、小数和指数三部分组成。到底能否直接利用结构体构建浮点型数据呢?
符号位(第31位)
|
指数部分(23~30位)
|
小数部分(0~11位)
|
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <memory.h>
struct float_as_struct
{
unsigned int fraction : 23; // 小数
unsigned int exponent : 8; // 指数 + 0x3FF
unsigned int sign : 1;// 符号位
};
float f(float _in)
{
float f=_in;
struct float_as_struct t;
assert (sizeof (struct float_as_struct) == sizeof (float));
memcpy (&t, &f, sizeof (float));
t.sign=1; // set negative sign
t.exponent=t.exponent+2; // multiply d by 2^n (n here is 2)
memcpy (&f, &t, sizeof (float));
return f;
};
int main()
{
printf ("%f\n", f(1.234));
};
通过结构体构建而成的float_as_struct和单精度浮点数据占用相同大小的内存空间,即32位/4字节。
在程序里,我们设置符号位为负(1),并把指数增加2,以进行乘以4(2的n次方(n=2))的乘法运算。
使用MSVC 2008(不开启任何优化选项)编译上述程序,可得如下所示的代码。
指令清单21.27 Non-optimizing MSVC 2008
_t$ = -8 ; size = 4
_f$ = -4 ; size = 4
__in$ = 8 ; size = 4
?f@@YAMM@Z PROC ; f
push ebp
mov ebp, esp
sub esp, 8
fld DWORD PTR __in$[ebp]
fstp DWORD PTR _f$[ebp]
push 4
lea eax, DWORD PTR _f$[ebp]
push eax
lea ecx, DWORD PTR _t$[ebp]
push ecx
call _memcpy
add esp, 12
mov edx, DWORD PTR _t$[ebp]
or edx, -2147483648 ; 80000000H - set minus sign
mov DWORD PTR _t$[ebp], edx
mov eax, DWORD PTR _t$[ebp]
shr eax, 23 ; 00000017H - drop significand
and eax, 255 ; 000000ffH - leave here only exponent
add eax, 2 ; add 2 to it
and eax, 255 ; 000000ffH
shl eax, 23 ; 00000017H - shift result to place of bits 30:23
mov ecx, DWORD PTR _t$[ebp]
and ecx, -2139095041 ; 807fffffH - drop exponent
; add original value without exponent with new calculated exponent
or ecx, eax
mov DWORD PTR _t$[ebp], ecx
push 4
lea edx, DWORD PTR _t$[ebp]
push edx
lea eax, DWORD PTR _f$[ebp]
push eax
call _memcpy
add esp, 12
fld DWORD PTR _f$[ebp]
mov esp, ebp
pop ebp
ret 0
?f@@YAMM@Z ENDP ; f
这个程序略微臃肿。如果使用优化选项/Ox 程序就不会调用memcpy(),转而直接使用变量f。不过,不启用优化选项而编译出来的代码易于理解。
如果启用GCC 4.4.1的-O3选项又会如何?
指令清单21.28 Optimizing GCC 4.4.1
; f(float)
public _Z1ff
_Z1ff proc near
var_4 = dword ptr -4
arg_0 = dword ptr 8
push ebp
mov ebp, esp
sub esp, 4
mov eax, [ebp+arg_0]
or eax, 80000000h ; set minus sign
mov edx, eax
and eax, 807FFFFFh ; leave onlySign and significand in EAX
shr edx, 23 ; prepare exponent
add edx, 2 ; add 2
movzx edx, dl ; clear all bits except 7:0 in EAX
shl edx, 23 ; shift new calculated exponent to its place
or eax, edx ; join new exponent and original value without exponent
mov [ebp+var_4], eax
fld [ebp+var_4]
leave
retn
_Z1ff endp
public main
main proc near
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
sub esp, 10h
fld ds:dword_8048614 ; -4.936
fstp qword ptr [esp+8]
mov dword ptr [esp+4], offset asc_8048610 ; "%f\n"
mov dword ptr [esp], 1
call ___printf_chk
xor eax, eax
leave
retn
main endp
f()函数的指令几乎可以自然解释。有趣的是,尽管结构体的各个字段如同大杂烩一样复杂,但是GCC能够在编译阶段就计算出函数表达式f(1.234)的值,并且把这个结果传递给printf()函数!
Linux程序[10]:
请参见http://beginners.re/exercises/per_chapter/struct_exercise_Linux86.tar
MIPS程序[11]:
请参见http://beginners.re/exercises/per_chapter/struct_exercise_MIPS.tar
。
这个Linux x86程序能够打开文件并在屏幕上打印数字。请问它打印的是什么?
这个函数的输入变量是结构体。请尝试逆向推出结构体的各个字段,不必推敲函数的具体功能。
由MSVC 2010 /Ox 选项编译而得的代码如下所示。
指令清单21.29 Optimizing MSVC 2010
$SG2802 DB '%f', 0aH, 00H
$SG2803 DB '%c, %d', 0aH, 00H
$SG2805 DB 'error #2', 0aH, 00H
$SG2807 DB 'error #1', 0aH, 00H
__real@405ec00000000000 DQ 0405ec00000000000r ; 123
__real@407bc00000000000 DQ 0407bc00000000000r ; 444
_s$ = 8
_f PROC
push esi
mov esi, DWORD PTR _s$[esp]
cmp DWORD PTR [esi], 1000
jle SHORT $LN4@f
cmp DWORD PTR [esi+4], 10
jbe SHORT $LN3@f
fld DWORD PTR [esi+8]
sub esp, 8
fmul QWORD PTR __real@407bc00000000000
fld QWORD PTR [esi+16]
fmul QWORD PTR __real@405ec00000000000
faddp ST(1), ST(0)
fstp QWORD PTR [esp]
push OFFSET $SG2802 ; ’%f’
call _printf
movzx eax, BYTE PTR [esi+25]
movsx ecx, BYTE PTR [esi+24]
push eax
push ecx
push OFFSET $SG2803 ; ’%c, %d’
call _printf
add esp, 24
pop esi
ret 0
$LN3@f:
pop esi
mov DWORD PTR _s$[esp-4], OFFSET $SG2805 ; ’error #2’
jmp _printf
$LN4@f:
pop esi
mov DWORD PTR _s$[esp-4], OFFSET $SG2807 ; ’error #1’
jmp _printf
_f ENDP
指令清单21.30 Non-optimizing Keil 6/2013 (ARM mode)
f PROC
PUSH {r4-r6,lr}
MOV r4,r0
LDR r0,[r0,#0]
CMP r0,#0x3e8
ADRLE r0,|L0.140|
BLE |L0.132|
LDR r0,[r4,#4]
CMP r0,#0xa
ADRLS r0,|L0.152|
BLS |L0.132|
ADD r0,r4,#0x10
LDM r0,{r0,r1}
LDR r3,|L0.164|
MOV r2,#0
BL __aeabi_dmul
MOV r5,r0
MOV r6,r1
LDR r0,[r4,#8]
LDR r1,|L0.168|
BL __aeabi_fmul
BL __aeabi_f2d
MOV r2,r5
MOV r3,r6
BL __aeabi_dadd
MOV r2,r0
MOV r3,r1
ADR r0,|L0.172|
BL __2printf
LDRB r2,[r4,#0x19]
LDRB r1,[r4,#0x18]
POP {r4-r6,lr}
ADR r0,|L0.176|
B __2printf
|L0.132|
POP {r4-r6,lr}
B __2printf
ENDP
|L0.140|
DCB "error #1\n",0
DCB 0
DCB 0
|L0.152|
DCB "error #2\n",0
DCB 0
DCB 0
|L0.164|
DCB 0x405ec000
|L0.168|
DCB 0x43de0000
|L0.172|
DCB "%f\n",0
|L0.176|
DCB "%c, %d\n",0
指令清单21.31 Non-optimizing Keil 6/2013 (Thumb mode)
f PROC
PUSH {r4-r6,lr}
MOV r4,r0
LDR r0,[r0,#0]
CMP r0,#0x3e8
ADRLE r0,|L0.140|
BLE |L0.132|
LDR r0,[r4,#4]
CMP r0,#0xa
ADRLS r0,|L0.152|
BLS |L0.132|
ADD r0,r4,#0x10
LDM r0,{r0,r1}
LDR r3,|L0.164|
MOV r2,#0
BL __aeabi_dmul
MOV r5,r0
MOV r6,r1
LDR r0,[r4,#8]
LDR r1,|L0.168|
BL __aeabi_fmul
BL __aeabi_f2d
MOV r2,r5
MOV r3,r6
BL __aeabi_dadd
MOV r2,r0
MOV r3,r1
ADR r0,|L0.172|
BL __2printf
LDRB r2,[r4,#0x19]
LDRB r1,[r4,#0x18]
POP {r4-r6,lr}
ADR r0,|L0.176|
B __2printf
|L0.132|
POP {r4-r6,lr}
B __2printf
ENDP
|L0.140|
DCB "error #1\n",0
DCB 0
DCB 0
|L0.152|
DCB "error #2\n",0
DCB 0
DCB 0
|L0.164|
DCD 0x405ec000
|L0.168|
DCD 0x43de0000
|L0.172|
DCB "%f\n",0
|L0.176|
DCB "%c, %d\n",0
指令清单21.32 Optimizing GCC 4.9 (ARM64)
f:
stp x29, x30, [sp, -32]!
add x29, sp, 0
ldr w1, [x0]
str x19, [sp,16]
cmp w1, 1000
ble .L2
ldr w1, [x0,4]
cmp w1, 10
bls .L3
ldr s1, [x0,8]
mov x19, x0
ldr s0, .LC1
adrp x0, .LC0
ldr d2, [x19,16]
add x0, x0, :lo12:.LC0
fmul s1, s1, s0
ldr d0, .LC2
fmul d0, d2, d0
fcvt d1, s1
fadd d0, d1, d0
bl printf
ldrb w1, [x19,24]
adrp x0, .LC3
ldrb w2, [x19,25]
add x0, x0, :lo12:.LC3
ldr x19, [sp,16]
ldp x29, x30, [sp], 32
b printf
.L3:
ldr x19, [sp,16]
adrp x0, .LC4
ldp x29, x30, [sp], 32
add x0, x0, :lo12:.LC4
b puts
.L2:
ldr x19, [sp,16]
adrp x0, .LC5
ldp x29, x30, [sp], 32
add x0, x0, :lo12:.LC5
b puts
.size f, .-f
.LC1:
.word 1138622464
.LC2:
.word 0
.word 1079951360
.LC0:
.string "%f\n"
.LC3:
.string "%c, %d\n"
.LC4:
.string "error #2"
.LC5:
.string "error #1"
指令清单21.33 Optimizing GCC 4.4.5 (MIPS) (IDA)
f:
var_10 = -0x10
var_8 =-8
var_4 =-4
lui $gp, (__gnu_local_gp >> 16)
addiu $sp, -0x20
la $gp, (__gnu_local_gp & 0xFFFF)
sw $ra, 0x20+var_4($sp)
sw $s0, 0x20+var_8($sp)
sw $gp, 0x20+var_10($sp)
lw $v0, 0($a0)
or $at, $zero
slti $v0, 0x3E9
bnez $v0, loc_C8
move $s0, $a0
lw $v0, 4($a0)
or $at, $zero
sltiu $v0, 0xB
bnez $v0, loc_AC
lui $v0, (dword_134 >> 16)
lwc1 $f4, $LC1
lwc1 $f2, 8($a0)
lui $v0, ($LC2 >> 16)
lwc1 $f0, 0x14($a0)
mul.s $f2, $f4, $f2
lwc1 $f4, dword_134
lwc1 $f1, 0x10($a0)
lwc1 $f5, $LC2
cvt.d.s $f2, $f2
mul.d $f0, $f4, $f0
lw $t9, (printf & 0xFFFF)($gp)
lui $a0, ($LC0 >> 16) # "%f\n"
add.d $f4, $f2, $f0
mfc1 $a2, $f5
mfc1 $a3, $f4
jalr $t9
la $a0, ($LC0 & 0xFFFF) # "%f\n"
lw $gp, 0x20+var_10($sp)
lbu $a2, 0x19($s0)
lb $a1, 0x18($s0)
lui $a0, ($LC3 >> 16) # "%c, %d\n"
lw $t9, (printf & 0xFFFF)($gp)
lw $ra, 0x20+var_4($sp)
lw $s0, 0x20+var_8($sp)
la $a0, ($LC3 & 0xFFFF) # "%c, %d\n"
jr $t9
addiu $sp, 0x20
loc_AC: # CODE XREF: f+38
lui $a0, ($LC4 >> 16) # "error #2"
lw $t9, (puts & 0xFFFF)($gp)
lw $ra, 0x20+var_4($sp)
lw $s0, 0x20+var_8($sp)
la $a0, ($LC4 & 0xFFFF) # "error #2"
jr $t9
addiu $sp, 0x20
loc_C8: # CODE XREF: f+24
lui $a0, ($LC5 >> 16) # "error #1"
lw $t9, (puts & 0xFFFF)($gp)
lw $ra, 0x20+var_4($sp)
lw $s0, 0x20+var_8($sp)
la $a0, ($LC5 & 0xFFFF) # "error #1"
jr $t9
addiu $sp, 0x20
$LC0: .ascii "%f\n"<0>
$LC3: .ascii "%c, %d\n"<0>
$LC4: .ascii "error #2"<0>
$LC5: .ascii "error #1"<0>
.data # .rodata.cst4
$LC1: .word 0x43DE0000
.data # .rodata.cst8
$LC2: .word 0x405EC000
dword_134: .word 0
[1] 又称为“异构容器/heterogeneous container”。
[2] https://msdn.microsoft.com/en-us/library/ms724950(VS.85).aspx。
[3] https://msdn.microsoft.com/en-us/library/ee488017.aspx。
[4] 为了便于演示,作者略微调整了date的返回结果。在实际情况下,手动输入GDB指令的速度不会那么快,多次操作的返回结果不可能分毫不差。
[5] 请参见Dennis M. Ritchie撰写的<< The development of the c language>>,“SIGPLAN Not”的第28章第3节:201-208页,您也可以在作者的网站下载:http://yurichev.com/mirrors/C/ dmr-The%20Development%20of%20the%20C%20Language-1993.pdf.。
[6] 更多内容请参见https://en.wikipedia.org/wiki/Data_structure_alignment。
[7] 请参阅https://msdn.microsoft.com/en-us/library/ms253935.aspx和https://gcc.gnu.org/ onlinedocs/gcc/Structure-Packing-Pragmas.html。
[8] 请参见http://en.wikipedia.org/wiki/CPUID。
[9] 请参见http://www.ibiblio.org/gferg/ldp/GCC-Inline-Assembly-HOWTO.html。
[10] GCC 4.8.1 -O3。
[11] GCC 4.4.5-O3。