逆向基礎(四)
第11章
選擇結構switch()/case/default
11.1 一些例子
#!bash
void f (int a)
{
switch (a)
{
case 0: printf ("zero
"); break;
case 1: printf ("one
"); break;
case 2: printf ("two
"); break;
default: printf ("something unknown
"); break;
};
};
11.1.1 X86
反彙編結果如下(MSVC 2010):
清單11.1: MSVC 2010
#!bash
tv64 = -4 ; size = 4
_a$ = 8 ; size = 4
_f PROC
push ebp
mov ebp, esp
push ecx
mov eax, DWORD PTR _a$[ebp]
mov DWORD PTR tv64[ebp], eax
cmp DWORD PTR tv64[ebp], 0
je SHORT [email protected]
cmp DWORD PTR tv64[ebp], 1
je SHORT [email protected]
cmp DWORD PTR tv64[ebp], 2
je SHORT [email protected]
jmp SHORT [email protected]
[email protected]:
push OFFSET $SG739 ; ’zero’, 0aH, 00H
call _printf
add esp, 4
jmp SHORT [email protected]
[email protected]:
push OFFSET $SG741 ; ’one’, 0aH, 00H
call _printf
add esp, 4
jmp SHORT [email protected]
[email protected]:
push OFFSET $SG743 ; ’two’, 0aH, 00H
call _printf
add esp, 4
jmp SHORT [email protected]
[email protected]:
push OFFSET $SG745 ; ’something unknown’, 0aH, 00H
call _printf
add esp, 4
[email protected]:
mov esp, ebp
pop ebp
ret 0
_f ENDP
輸出函式的switch中有一些case選擇分支,事實上,它是和下面這個形式等價的:
#!cpp
void f (int a)
{
if (a==0)
printf ("zero
");
else if (a==1)
printf ("one
");
else if (a==2)
printf ("two
");
else
printf ("something unknown
");
};
當switch()中有一些case分支時,我們可以看到此類程式碼,雖然不能確定,但是,事實上switch()在機器碼級別上就是對if()的封裝。這也就是說,switch()其實只是對有一大堆類似條件判斷的if()的一個語法糖。
在生成程式碼時,除了編譯器把輸入變數移動到一個臨時本地變數tv64中之外,這塊程式碼對我們來說並無新意。
如果是在GCC 4.4.1下編譯同樣的程式碼,我們得到的結果也幾乎一樣,即使你開啟了最高最佳化(-O3)也是如此。
讓我們在微軟VC編譯器中開啟/Ox最佳化選項: cl 1.c /Fa1.asm /Ox
清單11.2: MSVC
#!bash
_a$ = 8 ; size = 4
_f PROC
mov eax, DWORD PTR _a$[esp-4]
sub eax, 0
je SHORT [email protected]
sub eax, 1
je SHORT [email protected]
sub eax, 1
je SHORT [email protected]
mov DWORD PTR _a$[esp-4], OFFSET $SG791 ; ’something unknown’, 0aH, 00H
jmp _printf
[email protected]:
mov DWORD PTR _a$[esp-4], OFFSET $SG789 ; ’two’, 0aH, 00H
jmp _printf
[email protected]:
mov DWORD PTR _a$[esp-4], OFFSET $SG787 ; ’one’, 0aH, 00H
jmp _printf
[email protected]:
mov DWORD PTR _a$[esp-4], OFFSET $SG785 ; ’zero’, 0aH, 00H
jmp _printf
_f ENDP
我們可以看到瀏覽器做了更多的難以閱讀的最佳化(Dirty hacks)。
首先,變數的值會被放入EAX,接著EAX減0。聽起來這很奇怪,但它之後是需要檢查先前EAX暫存器的值是否為0的,如果是,那麼程式會設定上零標誌位ZF(這也表示了減去0之後,結果依然是0),第一個條件跳轉語句JE(Jump if Equal 或者同義詞 JZ - Jump if Zero)會因此觸發跳轉。如果這個條件不滿足,JE沒有跳轉的話,輸入值將減去1,之後就和之前的一樣了,如果哪一次值是0,那麼JE就會觸發,從而跳轉到對應的處理語句上。
(譯註:SUB操作會重置零標誌位ZF,但是MOV不會設定標誌位,而JE將只有在ZF標誌位設定之後才會跳轉。如果需要基於EAX的值來做JE跳轉的話,是需要用這個方法設定標誌位的)。
並且,如果沒有JE語句被觸發,最終,printf()函式將收到“something unknown”的引數。
其次:我們看到了一些不尋常的東西——字串指標被放在了變數裡,然後printf()並沒有透過CALL,而是透過JMP來呼叫的。 這個可以很簡單的解釋清楚,呼叫者把引數壓棧,然後透過CALL呼叫函式。CALL透過把返回地址壓棧,然後做無條件跳轉來跳到我們的函式地址。我們的函式在執行時,不管在任何時候都有以下的棧結構(因為它沒有任何移動棧指標的語句):
· ESP —— 指向返回地址
· ESP+4 —— 指向變數a (也即引數)
另一方面,當我們這兒呼叫printf()函式的時候,它也需要有與我們這個函式相同的棧結構,不同之處只在於printf()的第一個引數是指向一個字串的。 這也就是你之前看到的我們的程式碼所做的事情。
我們的程式碼把第一個引數的地址替換了,然後跳轉到printf(),就像第一個沒有呼叫我們的函式f()而是先呼叫了printf()一樣。 printf()把一串字元輸出到stdout 中,然後執行RET語句, 這一句會從棧上彈出返回地址,因此,此時控制流會返回到呼叫f()的函式上,而不是f()上。
這一切之所以能發生,是因為printf()在f()的末尾。在一些情況下,這有些類似於longjmp()函式。當然,這一切只是為了提高執行速度。
ARM編譯器也有類似的最佳化,請見5.3.2節“帶有多個引數的printf()函式呼叫”。
11.1.2 ARM: 最佳化後的 Keil + ARM 模式
#!bash
.text:0000014C f1
.text:0000014C 00 00 50 E3 CMP R0, #0
.text:00000150 13 0E 8F 02 ADREQ R0, aZero ; "zero
"
.text:00000154 05 00 00 0A BEQ loc_170
.text:00000158 01 00 50 E3 CMP R0, #1
.text:0000015C 4B 0F 8F 02 ADREQ R0, aOne ; "one
"
.text:00000160 02 00 00 0A BEQ loc_170
.text:00000164 02 00 50 E3 CMP R0, #2
.text:00000168 4A 0F 8F 12 ADRNE R0, aSomethingUnkno ; "something unknown
"
.text:0000016C 4E 0F 8F 02 ADREQ R0, aTwo ; "two
"
.text:00000170
.text:00000170 loc_170 ; CODE XREF: f1+8
.text:00000170 ; f1+14
.text:00000170 78 18 00 EA B __2printf
我們再一次看看這個程式碼,我們不能確定的說這就是原始碼裡面的switch()或者說它是if()的封裝。
但是,我們可以看到這裡它也在試圖預測指令(像是ADREQ(相等)),這裡它會在R0=0的情況下觸發,並且字串“zero ”的地址將被載入到R0中。如果R0=0,下一個指令BEQ將把控制流定向到loc_170處。順帶一說,機智的讀者們可能會文,之前的ADREQ已經用其他值填充了R0暫存器了,那麼BEQ會被正確觸發嗎?答案是“是”。因為BEQ檢查的是CMP所設定的標記位,但是ADREQ根本沒有修改標記位。
還有,在ARM中,一些指令還會加上-S字尾,這表明指令將會根據結果設定標記位。如果沒有-S的話,表明標記位並不會被修改。比如,ADD(而不是ADDS)將會把兩個運算元相加,但是並不會涉及標記位。這類指令對使用CMP設定標記位之後使用標記位的指令,例如條件跳轉來說非常有用。
其他指令對我們來說已經很熟悉了。這裡只有一個呼叫指向printf(),在末尾,我們已經知道了這個小技巧(見5.3.2節)。在末尾處有三個指向printf()的地址。 還有,需要注意的是如果a=2但是a並不在它的選擇分支給定的常數中時,“CMP R0, #2”指令在這個情況下就需要知道a是否等於2。如果結果為假,ADRNE將會讀取字串“something unknown ”到R0中,因為a在之前已經和0、1做過是否相等的判斷了,這裡我們可以假定a並不等於0或者1。並且,如果R0=2,a指向的字串“two ”將會被ADREQ載入R0。
11.1.3 ARM: 最佳化後的 Keil + thumb 模式
#!bash
.text:000000D4 f1
.text:000000D4 10 B5 PUSH {R4,LR}
.text:000000D6 00 28 CMP R0, #0
.text:000000D8 05 D0 BEQ zero_case
.text:000000DA 01 28 CMP R0, #1
.text:000000DC 05 D0 BEQ one_case
.text:000000DE 02 28 CMP R0, #2
.text:000000E0 05 D0 BEQ two_case
.text:000000E2 91 A0 ADR R0, aSomethingUnkno ; "something unknown
"
.text:000000E4 04 E0 B default_case
.text:000000E6 ;
-------------------------------------------------------------------------
.text:000000E6 zero_case ; CODE XREF: f1+4
.text:000000E6 95 A0 ADR R0, aZero ; "zero
"
.text:000000E8 02 E0 B default_case
.text:000000EA ;
-------------------------------------------------------------------------
.text:000000EA one_case ; CODE XREF: f1+8
.text:000000EA 96 A0 ADR R0, aOne ; "one
"
.text:000000EC 00 E0 B default_case
.text:000000EE ;
-------------------------------------------------------------------------
.text:000000EE two_case ; CODE XREF: f1+C
.text:000000EE 97 A0 ADR R0, aTwo ; "two
"
.text:000000F0 default_case ; CODE XREF: f1+10
.text:000000F0 ; f1+14
.text:000000F0 06 F0 7E F8 BL __2printf
.text:000000F4 10 BD POP {R4,PC}
.text:000000F4 ; End of function f1
正如我之前提到的,在thumb模式下並沒有什麼功能來連線預測結果,所以這裡的thumb程式碼有點像容易理解的x86 CISC程式碼。
11.2 許多例子
在有許多case分支的switch()語句中,對編譯器來說,轉換出一大堆JE/JNE語句並不是太方便。
#!cpp
void f (int a)
{
switch (a)
{
case 0: printf ("zero
"); break;
case 1: printf ("one
"); break;
case 2: printf ("two
"); break;
case 3: printf ("three
"); break;
case 4: printf ("four
"); break;
default: printf ("something unknown
"); break;
};
};
11.2.1 x86
反彙編結果如下(MSVC 2010):
清單11.3: MSVC 2010
#!bash
tv64 = -4 ; size = 4
_a$ = 8 ; size = 4
_f PROC
push ebp
mov ebp, esp
push ecx
mov eax, DWORD PTR _a$[ebp]
mov DWORD PTR tv64[ebp], eax
cmp DWORD PTR tv64[ebp], 4
ja SHORT [email protected]
mov ecx, DWORD PTR tv64[ebp]
jmp DWORD PTR [email protected][ecx*4]
[email protected]:
push OFFSET $SG739 ; ’zero’, 0aH, 00H
call _printf
add esp, 4
jmp SHORT [email protected]
[email protected]:
push OFFSET $SG741 ; ’one’, 0aH, 00H
call _printf
add esp, 4
jmp SHORT [email protected]
[email protected]:
push OFFSET $SG743 ; ’two’, 0aH, 00H
call _printf
add esp, 4
jmp SHORT [email protected]
[email protected]:
push OFFSET $SG745 ; ’three’, 0aH, 00H
call _printf
add esp, 4
jmp SHORT [email protected]
[email protected]:
push OFFSET $SG747 ; ’four’, 0aH, 00H
call _printf
add esp, 4
jmp SHORT [email protected]
[email protected]:
push OFFSET $SG749 ; ’something unknown’, 0aH, 00H
call _printf
add esp, 4
[email protected]:
mov esp, ebp
pop ebp
ret 0
npad 2
[email protected]:
DD [email protected] ; 0
DD [email protected] ; 1
DD [email protected] ; 2
DD [email protected] ; 3
DD [email protected] ; 4
_f ENDP
好的,我們可以看到這兒有一組不同引數的printf()呼叫。 它們不僅有記憶體中的地址,編譯器還給它們帶上了符號資訊。順帶一提,[email protected]
在函式最開始,如果a大於4,[email protected],這兒會有一個引數為“something unknown”的printf()呼叫。
如果a值小於等於4,然後我們把它乘以4,[email protected]址的方法,這樣可以正好指向我們需要的元素。比如a等於2。 那麼,2×4=8(在32位程式下,所有的函式表元素的長度都只有4位元組),[email protected][email protected] [email protected],然後跳轉向它。
這個函式表,有時候也叫做跳轉表(jumptable)。
然後,對應的,printf()的引數就是“two”了。 字面意思, JMP DWORD PTR [email protected][ECX*4] 指令意味著“ [email protected] + ecx * 4 地址上的雙字”。 npad(64)是一個編譯時語言宏,它用於對齊下一個標籤,這樣儲存的地址就會按照4位元組(或者16位元組)對齊。這個對於處理器來說是十分合適的,因為透過記憶體匯流排、快取從記憶體中獲取32位的值是非常方便而且有效率的。
讓我們看看GCC 4.4.1 生成的程式碼:
清單11.4: GCC 4.4.1
#!bash
public f
f proc near ; CODE XREF: main+10
var_18 = dword ptr -18h
arg_0 = dword ptr 8
push ebp
mov ebp, esp
sub esp, 18h ; char *
cmp [ebp+arg_0], 4
ja short loc_8048444
mov eax, [ebp+arg_0]
shl eax, 2
mov eax, ds:off_804855C[eax]
jmp eax
loc_80483FE: ; DATA XREF: .rodata:off_804855C
mov [esp+18h+var_18], offset aZero ; "zero"
call _puts
jmp short locret_8048450
loc_804840C: ; DATA XREF: .rodata:08048560
mov [esp+18h+var_18], offset aOne ; "one"
call _puts
jmp short locret_8048450
loc_804841A: ; DATA XREF: .rodata:08048564
mov [esp+18h+var_18], offset aTwo ; "two"
call _puts
jmp short locret_8048450
loc_8048428: ; DATA XREF: .rodata:08048568
mov [esp+18h+var_18], offset aThree ; "three"
call _puts
jmp short locret_8048450
loc_8048436: ; DATA XREF: .rodata:0804856C
mov [esp+18h+var_18], offset aFour ; "four"
call _puts
jmp short locret_8048450
loc_8048444: ; CODE XREF: f+A
mov [esp+18h+var_18], offset aSomethingUnkno ; "something unknown"
call _puts
locret_8048450: ; CODE XREF: f+26
; f+34...
leave
retn
f endp
off_804855C dd offset loc_80483FE ; DATA XREF: f+12
dd offset loc_804840C
dd offset loc_804841A
dd offset loc_8048428
dd offset loc_8048436
基本和VC生成的相同,除了少許的差別:引數arg_0的乘以4操作被左移2位替換了(這集合和乘以4一樣)(見17.3.1節)。 然後標籤地址從off_804855C處的陣列獲取,地址計算之後儲存到EAX中,然後透過JMP EAX跳轉到實際的地址上。
11.2.2 ARM: 最佳化後的 Keil + ARM 模式
#!bash
00000174 f2
00000174 05 00 50 E3 CMP R0, #5 ; switch 5 cases
00000178 00 F1 8F 30 ADDCC PC, PC, R0,LSL#2 ; switch jump
0000017C 0E 00 00 EA B default_case ; jumptable 00000178 default case
00000180 ; -------------------------------------------------------------------------
00000180
00000180 loc_180 ; CODE XREF: f2+4
00000180 03 00 00 EA B zero_case ; jumptable 00000178 case 0
00000184 ; -------------------------------------------------------------------------
00000184
00000184 loc_184 ; CODE XREF: f2+4
00000184 04 00 00 EA B one_case ; jumptable 00000178 case 1
00000188 ; -------------------------------------------------------------------------
00000188
00000188 loc_188 ; CODE XREF: f2+4
00000188 05 00 00 EA B two_case ; jumptable 00000178 case 2
0000018C ; -------------------------------------------------------------------------
0000018C
0000018C loc_18C ; CODE XREF: f2+4
0000018C 06 00 00 EA B three_case ; jumptable 00000178 case 3
00000190 ; -------------------------------------------------------------------------
00000190
00000190 loc_190 ; CODE XREF: f2+4
00000190 07 00 00 EA B four_case ; jumptable 00000178 case 4
00000194 ; -------------------------------------------------------------------------
00000194
00000194 zero_case ; CODE XREF: f2+4
00000194 ; f2:loc_180
00000194 EC 00 8F E2 ADR R0, aZero ; jumptable 00000178 case 0
00000198 06 00 00 EA B loc_1B8
0000019C ; -------------------------------------------------------------------------
0000019C
0000019C one_case ; CODE XREF: f2+4
0000019C ; f2:loc_184
0000019C EC 00 8F E2 ADR R0, aOne ; jumptable 00000178 case 1
000001A0 04 00 00 EA B loc_1B8
000001A4 ; -------------------------------------------------------------------------
000001A4
000001A4 two_case ; CODE XREF: f2+4
000001A4 ; f2:loc_188
000001A4 01 0C 8F E2 ADR R0, aTwo ; jumptable 00000178 case 2
000001A8 02 00 00 EA B loc_1B8
000001AC ; -------------------------------------------------------------------------
000001AC
000001AC three_case ; CODE XREF: f2+4
000001AC ; f2:loc_18C
000001AC 01 0C 8F E2 ADR R0, aThree ; jumptable 00000178 case 3
000001B0 00 00 00 EA B loc_1B8
000001B4 ; -------------------------------------------------------------------------
000001B4
000001B4 four_case ; CODE XREF: f2+4
000001B4 ; f2:loc_190
000001B4 01 0C 8F E2 ADR R0, aFour ; jumptable 00000178 case 4
000001B8
000001B8 loc_1B8 ; CODE XREF: f2+24
000001B8 ; f2+2C
000001B8 66 18 00 EA B __2printf
000001BC ; -------------------------------------------------------------------------
000001BC
000001BC default_case ; CODE XREF: f2+4
000001BC ; f2+8
000001BC D4 00 8F E2 ADR R0, aSomethingUnkno ; jumptable 00000178 default case
000001C0 FC FF FF EA B loc_1B8
000001C0 ; End of function f2
這個程式碼利用了ARM的特性,這裡ARM模式下所有指令都是4個位元組。
讓我們記住a的最大值是4,任何更大額值都會導致它輸出“something unknown ”。
最開始的“CMP R0, #5”指令將a的值與5比較。
下一個“ADDCC PC, PC, R0, LSL#2”指令將僅在R0<5的時候執行(CC = Carry clear , 小於)。所以,如果ADDCC並沒有觸發(R0>=5時),它將會跳轉到default _case標籤上。
但是,如果R0<5,而且ADDCC觸發了,將會發生下列事情:
R0中的值會乘以4,事實上,LSL#2代表著“左移2位”,但是像我們接下來(見17.3.1節)要看到的“移位”一樣,左移2位代表乘以4。
然後,我們得到了R0 * 4的值,這個值將會和PC中現有的值相加,因此跳轉到下述其中一個B(Branch 分支)指令上。
在ADDCC執行時,PC中的值(0x180)比ADDCC指令的值(0x178)提前8個位元組,換句話說,提前2個指令。
這也就是為ARM處理器通道工作的方式:當ADDCC指令執行的時候,此時處理器將開始處理下一個指令,這也就是PC會指向這裡的原因。
如果a=0,那麼PC將不會和任何值相加,PC中實際的值將寫入PC中(它相對之領先8個位元組),然後跳轉到標籤loc_180處。這就是領先ADDCC指令8個位元組的地方。
在a=1時,PC+8+a4 = PC+8+14 = PC+16= 0x184 將被寫入PC中,這是loc_184標籤的地址。
每當a上加1,PC都會增加4,4也是ARM模式的指令長度,而且也是B指令的長度。這組裡面有5個這樣的指令。
這5個B指令將傳遞控制流,也就是傳遞switch()中指定的字串和對應的操作等等。
11.2.3 ARM: 最佳化後的 Keil + thumb 模式
#!bash
000000F6 EXPORT f2
000000F6 f2
000000F6 10 B5 PUSH {R4,LR}
000000F8 03 00 MOVS R3, R0
000000FA 06 F0 69 F8 BL __ARM_common_switch8_thumb ; switch 6 cases
000000FA ;
-------------------------------------------------------------------------
000000FE 05 DCB 5
000000FF 04 06 08 0A 0C 10 DCB 4, 6, 8, 0xA, 0xC, 0x10 ; jump table for switch
statement
00000105 00 ALIGN 2
00000106
00000106 zero_case ; CODE XREF: f2+4
00000106 8D A0 ADR R0, aZero ; jumptable 000000FA case 0
00000108 06 E0 B loc_118
0000010A ;
-------------------------------------------------------------------------
0000010A
0000010A one_case ; CODE XREF: f2+4
0000010A 8E A0 ADR R0, aOne ; jumptable 000000FA case 1
0000010C 04 E0 B loc_118
0000010E ;
-------------------------------------------------------------------------
0000010E
0000010E two_case ; CODE XREF: f2+4
0000010E 8F A0 ADR R0, aTwo ; jumptable 000000FA case 2
00000110 02 E0 B loc_118
00000112 ;
-------------------------------------------------------------------------
00000112
00000112 three_case ; CODE XREF: f2+4
00000112 90 A0 ADR R0, aThree ; jumptable 000000FA case 3
00000114 00 E0 B loc_118
00000116 ;
-------------------------------------------------------------------------
00000116
00000116 four_case ; CODE XREF: f2+4
00000116 91 A0 ADR R0, aFour ; jumptable 000000FA case 4
00000118
00000118 loc_118 ; CODE XREF: f2+12
00000118 ; f2+16
00000118 06 F0 6A F8 BL __2printf
0000011C 10 BD POP {R4,PC}
0000011E ;
-------------------------------------------------------------------------
0000011E
0000011E default_case ; CODE XREF: f2+4
0000011E 82 A0 ADR R0, aSomethingUnkno ; jumptable 000000FA default
case
00000120 FA E7 B loc_118
000061D0 EXPORT __ARM_common_switch8_thumb
000061D0 __ARM_common_switch8_thumb ; CODE XREF: example6_f2+4
000061D0 78 47 BX PC
000061D0 ;
---------------------------------------------------------------------------
000061D2 00 00 ALIGN 4
000061D2 ; End of function __ARM_common_switch8_thumb
000061D2
000061D4 CODE32
000061D4
000061D4 ; =============== S U B R O U T I N E
=======================================
000061D4
000061D4
000061D4 __32__ARM_common_switch8_thumb ; CODE XREF:
__ARM_common_switch8_thumb
000061D4 01 C0 5E E5 LDRB R12, [LR,#-1]
000061D8 0C 00 53 E1 CMP R3, R12
000061DC 0C 30 DE 27 LDRCSB R3, [LR,R12]
000061E0 03 30 DE 37 LDRCCB R3, [LR,R3]
000061E4 83 C0 8E E0 ADD R12, LR, R3,LSL#1
000061E8 1C FF 2F E1 BX R12
000061E8 ; End of function __32__ARM_common_switch8_thumb
一個不能確定的事實是thumb、thumb-2中的所有指令都有同樣的大小。甚至可以說是在這些模式下,指令的長度是可變的,就像x86一樣。
所以這一定有一個特別的表單,裡面包含有多少個case(除了預設的case),然後和它們的偏移,並且給他們每個都加上一個標籤,這樣控制流就可以傳遞到正確的位置。 這裡有一個特別的函式來處理表單和處理控制流,被命名為__ARM_common_switch8_thumb。它由“BX PC”指令開始,這個函式用來將處理器切換到ARM模式,然後你就可以看到處理表單的函式。不過對我們來說,在這裡解釋它太複雜了,所以我們將省去一些細節。
但是有趣的是,這個函式使用LR暫存器作為表單的指標。還有,在這個函式呼叫後,LR將包含有緊跟著“BL __ARM_common_switch8_thumb”指令的地址,然後表單就由此開始。
當然,這裡也不值得去把生成的程式碼作為單獨的函式,然後再去重用它們。因此在switch()處理相似的位置、相似的case時編譯器並不會生成相同的程式碼。
IDA成功的發覺到它是一個服務函式以及函式表,然後給各個標籤加上了合適的註釋,比如jumptable 000000FA case 0。
第12章 迴圈結構
12.1 x86
在x86指令集中,有一些獨特的LOOP指令,它們會檢查ECX中的值,如果它不是0的話,它會逐漸遞減ECX的值(減一),然後把控制流傳遞給LOOP運算子提供的標籤處。也許,這個指令並不是多方便,所以,我沒有看到任何現代編譯器自動使用它。如果你看到哪裡的程式碼用了這個結構,那它很有可能是程式設計師手寫的彙編程式碼。
順帶一提,作為家庭作業,你可以試著解釋以下為什麼這個指令如此不方便。
C/C++迴圈操作是由for()、while()、do/while()命令發起的。
讓我們從for()開始吧。
這個命令定義了迴圈初始值(為迴圈計數器設定初值),迴圈條件(比如,計數器是否大於一個閾值?),以及在每次迭代(增/減)時和迴圈體中做什麼。
for (初始化; 條件; 每次迭代時執行的語句)
{
迴圈體;
}
所以,它生成的程式碼也將被考慮為4個部分。
讓我們從一個簡單的例子開始吧:
#!cpp
#include <stdio.h>
void f(int i)
{
printf ("f(%d)
", i);
};
int main()
{
int i;
for (i=2; i<10; i++)
f(i);
return 0;
};
反彙編結果如下(MSVC 2010):
清單12.1: MSVC 2010
#!bash
_i$ = -4
_main PROC
push ebp
mov ebp, esp
push ecx
mov DWORD PTR _i$[ebp], 2 ; loop initialization
jmp SHORT [email protected]
[email protected]:
mov eax, DWORD PTR _i$[ebp] ; here is what we do after each iteration:
add eax, 1 ; add 1 to i value
mov DWORD PTR _i$[ebp], eax
[email protected]:
cmp DWORD PTR _i$[ebp], 10 ; this condition is checked *before* each iteration
jge SHORT [email protected] ; if i is biggest or equals to 10, let’s finish loop
mov ecx, DWORD PTR _i$[ebp] ; loop body: call f(i)
push ecx
call _f
add esp, 4
jmp SHORT [email protected] ; jump to loop begin
[email protected]: ; loop end
xor eax, eax
mov esp, ebp
pop ebp
ret 0
_main ENDP
看起來沒什麼特別的。
GCC 4.4.1生成的程式碼也基本相同,只有一些微妙的區別。
清單12.1: GCC 4.4.1
#!bash
main proc near ; DATA XREF: _start+17
var_20 = dword ptr -20h
var_4 = dword ptr -4
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
sub esp, 20h
mov [esp+20h+var_4], 2 ; i initializing
jmp short loc_8048476
loc_8048465:
mov eax, [esp+20h+var_4]
mov [esp+20h+var_20], eax
call f
add [esp+20h+var_4], 1 ; i increment
loc_8048476:
cmp [esp+20h+var_4], 9
jle short loc_8048465 ; if i<=9, continue loop
mov eax, 0
leave
retn
main endp
現在,讓我們看看如果我們開啟了最佳化開關會得到什麼結果(/Ox):
清單12.3: 最佳化後的 MSVC
#!bash
_main PROC
push esi
mov esi, 2
[email protected]:
push esi
call _f
inc esi
add esp, 4
cmp esi, 10 ; 0000000aH
jl SHORT [email protected]
xor eax, eax
pop esi
ret 0
_main ENDP
要說它做了什麼,那就是:本應在棧上分配空間的變數i被移動到了暫存器ESI裡面。因為我們這樣一個小函式並沒有這麼多的本地變數,所以它才可以這麼做。 這麼做的話,一個重要的條件是函式f()不能改變ESI的值。我們的編譯器在這裡倒是非常確定。假設編譯器決定在f()中使用ESI暫存器的話,ESI的值將在函式的初始化階段被壓入棧儲存,並且在函式的收尾階段將其彈出(注:即還原現場,保證程式片段執行前後某個暫存器值不變)。這個操作有點像函式開頭和結束時的PUSH ESI/ POP ESI操作對。
讓我們試一試開啟了最高最佳化的GCC 4.4.1(-03最佳化)。
清單12.4: 最佳化後的GCC 4.4.1
#!bash
main proc near
var_10 = dword ptr -10h
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
sub esp, 10h
mov [esp+10h+var_10], 2
call f
mov [esp+10h+var_10], 3
call f
mov [esp+10h+var_10], 4
call f
mov [esp+10h+var_10], 5
call f
mov [esp+10h+var_10], 6
call f
mov [esp+10h+var_10], 7
call f
mov [esp+10h+var_10], 8
call f
mov [esp+10h+var_10], 9
call f
xor eax, eax
leave
retn
main endp
GCC直接把我們的迴圈給分解成順序結構了。
迴圈分解(Loop unwinding)對這些沒有太多迭代次數的迴圈結構來說是比較有利的,移除所有迴圈結構之後程式的效率會得到提升。但是,這樣生成的程式碼明顯會變得很大。
好的,現在我們把迴圈的最大值改為100。GCC現在生成如下:
清單12.5: GCC
#!bash
public main
main proc near
var_20 = dword ptr -20h
push ebp
mov ebp, esp
and esp, 0FFFFFFF0h
push ebx
mov ebx, 2 ; i=2
sub esp, 1Ch
nop ; aligning label loc_80484D0 (loop body begin) by 16-byte border
loc_80484D0:
mov [esp+20h+var_20], ebx ; pass i as first argument to f()
add ebx, 1 ; i++
call f
cmp ebx, 64h ; i==100?
jnz short loc_80484D0 ; if not, continue
add esp, 1Ch
xor eax, eax ; return 0
pop ebx
mov esp, ebp
pop ebp
retn
main endp
這時,程式碼看起來非常像MSVC 2010開啟/Ox最佳化後生成的程式碼。除了這兒它用了EBX來儲存變數i。 GCC也確信f()函式中不會修改EBX的值,假如它要用到EBX的話,它也一樣會在函式初始化和收尾時儲存EBX和還原EBX,就像這裡main()函式做的事情一樣。
12.1.1 OllyDbg
讓我們透過/Ox和/Ob0編譯程式,然後放到OllyDbg裡面檢視以下結果。
看起來OllyDbg能夠識別簡單的迴圈,然後把它們放在一塊,為了演示方便,大家可以看圖12.1。
透過跟蹤程式碼(F8, 步過)我們可以看到ESI是如何遞增的。這裡的例子是ESI = i = 6: 圖12.2。
9是i的最後一個迴圈制,這也就是為什麼JL在遞增的最後不會觸發,之後函式結束,如圖12.3。
圖12.1: OllyDbg main()開始
圖12.2: OllyDbg: 迴圈體剛剛遞增了i,現在i=6
圖12.3: OllyDbg中ESI=10,迴圈終止
12.1.2 跟蹤
像我們所見的一樣,手動在偵錯程式裡面跟蹤程式碼並不是一件方便的事情。這也就是我給自己寫了一個跟蹤程式的原因。
我在IDA中開啟了編譯後的例子,然後找到了PUSH ESI指令(作用:給f()傳遞唯一的引數)的地址,對我的機器來說是0x401026,然後我執行了跟蹤器:
#!bash
tracer.exe -l:loops_2.exe bpx=loops_2.exe!0x00401026
BPX的作用只是在對應地址上設定斷點然後輸出暫存器狀態。
在tracer.log中我看到執行後的結果:
#!bash
PID=12884|New process loops_2.exe
(0) loops_2.exe!0x401026
EAX=0x00a328c8 EBX=0x00000000 ECX=0x6f0f4714 EDX=0x00000000
ESI=0x00000002 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=PF ZF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000003 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF PF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000004 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF PF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000005 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000006 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF PF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000007 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000008 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF AF SF IF
(0) loops_2.exe!0x401026
EAX=0x00000005 EBX=0x00000000 ECX=0x6f0a5617 EDX=0x000ee188
ESI=0x00000009 EDI=0x00333378 EBP=0x0024fbfc ESP=0x0024fbb8
EIP=0x00331026
FLAGS=CF PF AF SF IF
PID=12884|Process loops_2.exe exited. ExitCode=0 (0x0)
我們可以看到ESI暫存器是如何從2變為9的。
甚至於跟蹤器可以收集某個函式呼叫內所有暫存器的值,所以它被叫做跟蹤器(a trace)。每個指令都會被它跟蹤上,所有感興趣的暫存器值都會被它提示出來,然後收集下來。 然後可以生成IDA能用的.idc-script。所以,在IDA中我知道了main()函式地址是0x00401020,然後我執行了:
#!bash
tracer.exe -l:loops_2.exe bpf=loops_2.exe!0x00401020,trace:cc
bpf的意思是在函式上設定斷點。
結果是我得到了loops_2.exe.idc和loops_2.exe_clear.idc兩個指令碼。我載入loops_2.idc到IDA中,然後可以看到圖12.4所示的內容。
我們可以看到ESI在迴圈體開始時從2變化為9,但是在遞增完之後,它的值從9(譯註:作者原文是3,但是揣測是筆誤,應為9。)變為了0xA(10)。我們也可以看到main()函式結束時EAX被設定為了0。
編譯器也生成了loops_2.exe.txt,包含有每個指令執行了多少次和暫存器值的一些資訊:
清單12.6: loops_2.exe.txt
#!bash
0x401020 (.text+0x20), e= 1 [PUSH ESI] ESI=1
0x401021 (.text+0x21), e= 1 [MOV ESI, 2]
0x401026 (.text+0x26), e= 8 [PUSH ESI] ESI=2..9
0x401027 (.text+0x27), e= 8 [CALL 8D1000h] tracing nested maximum level (1) reached,
skipping this CALL 8D1000h=0x8d1000
0x40102c (.text+0x2c), e= 8 [INC ESI] ESI=2..9
0x40102d (.text+0x2d), e= 8 [ADD ESP, 4] ESP=0x38fcbc
0x401030 (.text+0x30), e= 8 [CMP ESI, 0Ah] ESI=3..0xa
0x401033 (.text+0x33), e= 8 [JL 8D1026h] SF=false,true OF=false
0x401035 (.text+0x35), e= 1 [XOR EAX, EAX]
0x401037 (.text+0x37), e= 1 [POP ESI]
0x401038 (.text+0x38), e= 1 [RETN] EAX=0
生成的程式碼可以在此使用:
圖12.4: IDA載入了.idc-script之後的內容
12.2 ARM
12.2.1 無最佳化 Keil + ARM模式
#!bash
main
STMFD SP!, {R4,LR}
MOV R4, #2
B loc_368
; ---------------------------------------------------------------------------
loc_35C ; CODE XREF: main+1C
MOV R0, R4
BL f
ADD R4, R4, #1
loc_368 ; CODE XREF: main+8
CMP R4, #0xA
BLT loc_35C
MOV R0, #0
LDMFD SP!, {R4,PC}
迭代計數器i儲存到了R4暫存器中。
“MOV R4,#2”初始化i。
“MOV R0, R4”和”BL f”指令組成迴圈體,第一個指令為f()準備引數,第二個用來呼叫它。
“ADD R4, R4, #1”指令在每次迭代中為i加一。
“CMP R4,#0xA”將i和0xA(10)比較,下一個指令BLT(Branch Less Than,分支小於)將在i<10時跳轉。
否則, R0將會被寫入0(因為我們的函式返回0),然後函式執行終止。
12.2.2 最佳化後的 Keil + ARM模式
#!bash
_main
PUSH {R4,LR}
MOVS R4, #2
loc_132 ; CODE XREF: _main+E
MOVS R0, R4
BL example7_f
ADDS R4, R4, #1
CMP R4, #0xA
BLT loc_132
MOVS R0, #0
POP {R4,PC}
事實上,是一樣的。
12.2.3 最佳化後的 Xcode(LLVM) + thumb-2 模式
#!bash
_main
PUSH {R4,R7,LR}
MOVW R4, #0x1124 ; "%d
"
MOVS R1, #2
MOVT.W R4, #0
ADD R7, SP, #4
ADD R4, PC
MOV R0, R4
BLX _printf
MOV R0, R4
MOVS R1, #3
BLX _printf
MOV R0, R4
MOVS R1, #4
BLX _printf
MOV R0, R4
MOVS R1, #5
BLX _printf
MOV R0, R4
MOVS R1, #6
BLX _printf
MOV R0, R4
MOVS R1, #7
BLX _printf
MOV R0, R4
MOVS R1, #8
BLX _printf
MOV R0, R4
MOVS R1, #9
BLX _printf
MOVS R0, #0
POP {R4,R7,PC}
事實上,printf是在我的f()函式里呼叫的:
#!cpp
void f(int i)
{
// do something here
printf ("%d
", i);
};
所以,LLVM不僅僅是拆解了(unroll)迴圈,而且還把我的短函式f()給作為行內函數看待了,這樣,它把它的函式體內插了8遍,而不是用一個迴圈來解決。對於我們這種簡短的函式來說,編譯器這樣做是有可能的。
12.3 更多的一些事情
在編譯器生成的程式碼裡面,我們可以發現在i初始化之後,迴圈體並不會被執行,轉而是先檢查i的條件,在這之後才開始執行迴圈體。這麼做是正確的,因為,如果迴圈條件在一開始就不滿足,那麼迴圈體是不應當被執行的。比如,在下面的例子中,就可能出現這個情況:
#!cpp
for (i=0; i<total_entries_to_process; i++)
loop_body;
如果 total_entries_to_process 等於0,那麼迴圈體就不應該被執行。這就是為什麼應當在迴圈體被執行之前檢查迴圈條件。 但是,開啟編譯器最佳化之後,如果編譯器確定不會出現上面這種情況的話,那麼條件檢查和迴圈體的語句可能會互換(比如我們上面提到的簡單的例子以及Keil、Xcode(LLVM)、MSVC的最佳化模式)。
第13章 strlen()
現在,讓我們再看一眼迴圈結構。通常,strlen()函式是由while()來實現的。這就是MSVC標準庫中strlen的做法:
#!cpp
int my_strlen (const char * str)
{
const char *eos = str;
while( *eos++ ) ;
return( eos - str - 1 );
}
int main()
{
// test
return my_strlen("hello!");
};
13.1 x86
13.1.1 無最佳化的 MSVC
讓我們編譯一下:
#!bash
_eos$ = -4 ; size = 4
_str$ = 8 ; size = 4
_strlen PROC
push ebp
mov ebp, esp
push ecx
mov eax, DWORD PTR _str$[ebp] ; place pointer to string from str
mov DWORD PTR _eos$[ebp], eax ; place it to local varuable eos
[email protected]_:
mov ecx, DWORD PTR _eos$[ebp] ; ECX=eos
; take 8-bit byte from address in ECX and place it as 32-bit value to EDX with sign extension
movsx edx, BYTE PTR [ecx]
mov eax, DWORD PTR _eos$[ebp] ; EAX=eos
add eax, 1 ; increment EAX
mov DWORD PTR _eos$[ebp], eax ; place EAX back to eos
test edx, edx ; EDX is zero?
je SHORT [email protected]_ ; yes, then finish loop
jmp SHORT [email protected]_ ; continue loop
[email protected]_:
; here we calculate the difference between two pointers
mov eax, DWORD PTR _eos$[ebp]
sub eax, DWORD PTR _str$[ebp]
sub eax, 1 ; subtract 1 and return result
mov esp, ebp
pop ebp
ret 0
_strlen_ ENDP
我們看到了兩個新的指令:MOVSX(見13.1.1節)和TEST。
關於第一個:MOVSX用來從記憶體中取出位元組然後把它放到一個32位暫存器中。MOVSX意味著MOV with Sign-Extent(帶符號擴充套件的MOV操作)。MOVSX操作下,如果複製源是負數,從第8到第31的位將被設為1,否則將被設為0。
現在解釋一下為什麼要這麼做。
C/C++標準將char(譯註:1位元組)型別定義為有符號的。如果我們有2個值,一個是char,另一個是int(int也是有符號的),而且它的初值是-2(被編碼為0xFE),我們將這個值複製到int(譯註:一般是4位元組)中時,int的值將是0x000000FE,這時,int的值將是254而不是-2。因為在有符號數中,-2被編碼為0xFFFFFFFE。 所以,如果我們需要將0xFE從char型別轉換為int型別,那麼,我們就需要識別它的符號並擴充套件它。這就是MOVSX所做的事情。
請參見章節“有符號數表示方法”。(35章)
我不太確定編譯器是否需要將char變數儲存在EDX中,它可以使用其中8位(我的意思是DL部分)。顯然,編譯器的暫存器分配器就是這麼工作的。
然後我們可以看到TEST EDX, EDX。關於TEST指令,你可以閱讀一下位這一節(17章)。但是現在我想說的是,這個TEST指令只是檢查EDX的值是否等於0。
13.1.2 無最佳化的 GCC
讓我們在GCC 4.4.1下測試:
#!bash
public strlen
strlen proc near
eos = dword ptr -4
arg_0 = dword ptr 8
push ebp
mov ebp, esp
sub esp, 10h
mov eax, [ebp+arg_0]
mov [ebp+eos], eax
loc_80483F0:
mov eax, [ebp+eos]
movzx eax, byte ptr [eax]
test al, al
setnz al
add [ebp+eos], 1
test al, al
jnz short loc_80483F0
mov edx, [ebp+eos]
mov eax, [ebp+arg_0]
mov ecx, edx
sub ecx, eax
mov eax, ecx
sub eax, 1
leave
retn
strlen endp
可以看到它的結果和MSVC幾乎相同,但是這兒我們可以看到它用MOVZX代替了MOVSX。 MOVZX代表著MOV with Zero-Extend(0位擴充套件MOV)。這個指令將8位或者16位的值複製到32位暫存器,然後將剩餘位設定為0。事實上,這個指令比較方便的原因是它將兩條指令組合到了一起:xor eax,eax / mov al, [...]。
另一方面來說,顯然這裡編譯器可以產生如下程式碼: mov al, byte ptr [eax] / test al, al,這幾乎是一樣的,但是,EAX高位將還是會有隨機的數值存在。 但是我們想一想就知道了,這正是編譯器的劣勢所在——它不能產生更多能讓人容易理解的程式碼。嚴格的說, 事實上編譯器也並沒有義務為人類產生易於理解的程式碼。
還有一個新指令,SETNZ。這裡,如果AL包含非0, test al, al將設定ZF標記位為0。 但是SETNZ中,如果ZF == 0(NZ的意思是非零,Not Zero),AL將設定為1。用自然語言描述一下,如果AL非0,我們就跳轉到loc_80483F0。編譯器生成了少量的冗餘程式碼,不過不要忘了我們已經把最佳化給關了。
13.1.3 最佳化後的 MSVC
讓我們在MSVC 2012下編譯,開啟最佳化選項/Ox:
清單13.1: MSVC 2010 /Ox /Ob0
#!bash
_str$ = 8 ; size = 4
_strlen PROC
mov edx, DWORD PTR _str$[esp-4] ; EDX -> 指向字元的指標
mov eax, edx ; 移動到 EAX
[email protected]:
mov cl, BYTE PTR [eax] ; CL = *EAX
inc eax ; EAX++
test cl, cl ; CL==0?
jne SHORT [email protected] ; 否,繼續迴圈
sub eax, edx ; 計算指標差異
dec eax ; 遞減 EAX
ret 0
_strlen ENDP
現在看起來就更簡單點了。但是沒有必要去說編譯器能在這麼小的函式里面,如此有效率的使用如此少的本地變數,特殊情況而已。
INC / DEC是遞增 / 遞減指令,或者換句話說,給變數加一或者減一。
13.1.4 最佳化後的 MSVC + OllyDbg
我們可以在OllyDbg中試試這個(最佳化過的)例子。這兒有一個簡單的最初的初始化:圖13.1。 我們可以看到OllyDbg
找到了一個迴圈,然後為了方便觀看,OllyDbg把它們環繞在一個方格區域中了。在EAX上右鍵點選,我們可以選擇“Follow in Dump”,然後記憶體視窗的位置將會跳轉到對應位置。我們可以在記憶體中看到這裡有一個“hello!”的字串。 在它之後至少有一個0位元組,然後就是隨機的資料。 如果OllyDbg發現了一個暫存器是一個指向字串的指標,那麼它會顯示這個字串。
讓我們按下F8(步過)多次,我們可以看到當前地址的遊標將在迴圈體中回到開始的地方:圖13.2。我們可以看到EAX現在包含有字串的第二個字元。
我們繼續按F8,然後執行完整個迴圈:圖13.3。我們可以看到EAX現在包含空字元( )的地址,也就是字串的末尾。同時,EDX並沒有改變,所以它還是指向字串的最開始的地方。現在它就可以計算這兩個暫存器的差值了。
然後SUB指令會被執行:圖13.4。 差值儲存在EAX中,為7。 但是,字串“hello!”的長度是6,這兒7是因為包含了末尾的 。但是strlen()函式必須返回非0部分字串的長度,所以在最後還是要給EAX減去1,然後將它作為返回值返回,退出函式。
圖13.1: 第一次迴圈迭代起始位置
圖13.2:第二次迴圈迭代開始位置
圖13.3: 現在要計算二者的差了
圖13.4: EAX需要減一
13.1.5 最佳化過的GCC
讓我們開啟GCC 4.4.1的編譯最佳化選項(-O3):
#!bash
public strlen
strlen proc near
arg_0 = dword ptr 8
push ebp
mov ebp, esp
mov ecx, [ebp+arg_0]
mov eax, ecx
loc_8048418:
movzx edx, byte ptr [eax]
add eax, 1
test dl, dl
jnz short loc_8048418
not ecx
add eax, ecx
pop ebp
retn
strlen endp
這兒GCC和MSVC的表現方式幾乎一樣,除了MOVZX的表達方式。
但是,這裡的MOVZX可能被替換為mov dl, byte ptr [eax]
。
可能是因為對GCC編譯器來說,生成此種程式碼會讓它更容易記住整個暫存器已經分配給char變數了,然後因此它就可以確認高位在任何時候都不會有任何干擾資料的存在了。
之後,我們可以看到新的運算子NOT。這個運算子把運算元的所有位全部取反。可以說,它和XOR ECX, 0fffffffh效果是一樣的。NOT和接下來的ADD指令計算差值然後將結果減一。在最開始的ECX出儲存了str的指標,翻轉之後會將它的值減一。
請參考“有符號數的表達方式”。(第35章)
換句話說,在函式最後,也就是迴圈體後面其實是做了這樣一個操作:
ecx=str;
eax=eos;
ecx=(-ecx)-1;
eax=eax+ecx
return eax
這樣做其實幾乎相等於:
ecx=str;
eax=eos;
eax=eax-ecx;
eax=eax-1;
return eax
為什麼GCC會認為它更棒呢?我不能確定,但是我確定上下兩種方式都應該有相同的效率。
13.2 ARM
13.2.1 無最佳化 Xcode (LLVM) + ARM模式
清單13.2: 無最佳化的Xcode(LLVM)+ ARM模式
#!bash
_strlen
eos = -8
str = -4
SUB SP, SP, #8 ; allocate 8 bytes for local variables
STR R0, [SP,#8+str]
LDR R0, [SP,#8+str]
STR R0, [SP,#8+eos]
loc_2CB8 ; CODE XREF: _strlen+28
LDR R0, [SP,#8+eos]
ADD R1, R0, #1
STR R1, [SP,#8+eos]
LDRSB R0, [R0]
CMP R0, #0
BEQ loc_2CD4
B loc_2CB8
; ----------------------------------------------------------------
loc_2CD4 ; CODE XREF: _strlen+24
LDR R0, [SP,#8+eos]
LDR R1, [SP,#8+str]
SUB R0, R0, R1 ; R0=eos-str
SUB R0, R0, #1 ; R0=R0-1
ADD SP, SP, #8 ; deallocate 8 bytes for local variables
BX LR
無最佳化的LLVM生成了太多的程式碼,但是,這裡我們可以看到函式是如何在棧上處理本地變數的。我們的函式里只有兩個本地變數,eos和str。
在這個IDA生成的列表裡,我把var_8和var_4命名為了eos和str。
所以,第一個指令只是把輸入的值放到str和eos裡。
迴圈體從loc_2CB8標籤處開始。
迴圈體的前三個指令(LDR、ADD、STR)將eos的值載入R0,然後值會加一,然後存回棧上本地變數eos。
下一條指令“LDRSB R0, [R0]”(Load Register Signed Byte,讀取暫存器有符號字)將從R0地址處讀取一個位元組,然後把它符號擴充套件到32位。這有點像是x86裡的MOVSX函式(見13.1.1節)。因為char在C標準裡面是有符號的,所以編譯器也把這個位元組當作有符號數。我已經在13.1.1節寫了這個,雖然那裡是相對x86來說的。 需要注意的是,在ARM裡會單獨分割使用8位或者16位或者32位的暫存器,就像x86一樣。顯然,這是因為x86有一個漫長的歷史上的相容性問題,它需要和他的前身:16位8086處理器甚至8位的8080處理器相相容。但是ARM確是從32位的精簡指令集處理器中發展而成的。因此,為了處理單獨的位元組,程式必須使用32位的暫存器。 所以LDRSB一個接一個的將符號從字串內載入R0,下一個CMP和BEQ指令將檢查是否讀入的符號是0,如果不是0,控制流將重新回到迴圈體,如果是0,那麼迴圈結束。 在函式最後,程式會計算eos和str的差,然後減一,返回值透過R0返回。
注意:這個函式並沒有儲存暫存器。這是因為由ARM呼叫時的轉換,R0-R3暫存器是“臨時暫存器”(scratch register),它們只是為了傳遞引數用的,它們的值並不會在函式退出後儲存,因為這時候函式也不會再使用它們。因此,它們可以被我們用來做任何事情,而這裡其他暫存器都沒有使用到,這也就是為什麼我們的棧上事實上什麼都沒有的原因。因此,控制流可以透過簡單跳轉(BX)來返回撥用的函式,地址存在LR暫存器中。
13.2.2 最佳化後的 Xcode (LLVM) + thumb 模式
清單13.3: 最佳化後的 Xcode(LLVM) + thumb模式
#!bash
_strlen
MOV R1, R0
loc_2DF6 ; CODE XREF: _strlen+8
LDRB.W R2, [R1],#1
CMP R2, #0
BNE loc_2DF6
MVNS R0, R0
ADD R0, R1
BX LR
在最佳化後的LLVM中,為eos和str準備的棧上空間可能並不會分配,因為這些變數可以永遠正確的儲存在暫存器中。在迴圈體開始之前,str將一直儲存在R0中,eos在R1中。
“LDRB.W R2, [R1],#1”指令從R1記憶體中讀取位元組到R2裡,按符號擴充套件成32位的值,但是不僅僅這樣。 在指令最後的#1被稱為“後變址”(Post-indexed address),這代表著在位元組讀取之後,R1將會加一。這個在讀取陣列時特別方便。
在x86中這裡並沒有這樣的地址存取方式,但是在其他處理器中卻是有的,甚至在PDP-11裡也有。這是PDP-11中一個前增、後增、前減、後減的例子。這個很像是C語言(它是在PDP-11上開發的)中“罪惡的”語句形式ptr++、++ptr、ptr--、--ptr。順帶一提,C的這個語法真的很難讓人記住。下為具體敘述:
C語言作者之一的Dennis Ritchie提到了這個可能是由於另一個作者Ken Thompson開發的功能,因此這個處理器特性在PDP-7中最早出現了(參考資料[28][29])。因此,C語言編譯器將在處理器支援這種指令時使用它。
然後可以指出的是迴圈體的CMP和BNE,這兩個指令將一直處理到字串中的0出現為止。
MVNS(翻轉所有位,也即x86的NOT)指令和ADD指令計算cos-str-1.事實上,這兩個指令計算出R0=str+cos。這和原始碼裡的指令效果一樣,為什麼他要這麼做的原因我在13.1.5節已經說過了。
顯然,LLVM,就像是GCC一樣,會把程式碼變得更短或者更快。
13.2.3 最佳化後的 Keil + ARM 模式
清單13.4: 最佳化後的 Keil + ARM模式
#!bash
_strlen
MOV R1, R0
loc_2C8 ; CODE XREF: _strlen+14
LDRB R2, [R1],#1
CMP R2, #0
SUBEQ R0, R1, R0
SUBEQ R0, R0, #1
BNE loc_2C8
BX LR
這個和我們之前看到的幾乎一樣,除了str-cos-1這個表示式並不在函式末尾計算,而是被調到了迴圈體中間。 可以回憶一下-EQ字尾,這個代表指令僅僅會在CMP執行之前的語句互相相等時才會執行。因此,如果R0的值是0,兩個SUBEQ指令都會執行,然後結果會儲存在R0暫存器中。
相關文章
- 羽夏逆向——逆向基礎2021-11-19
- 逆向基礎(六)2020-08-19
- 逆向基礎(五)2020-08-19
- 逆向基礎(三)2020-08-19
- 逆向基礎(十一)2020-08-19
- 逆向基礎(十)2020-08-19
- 逆向基礎(十二)2020-08-19
- 逆向基礎(一)2020-08-19
- 逆向基礎(二)2020-08-19
- 逆向基礎(九)2020-08-19
- 逆向基礎(八)2020-08-19
- 逆向基礎(七)2020-08-19
- 逆向工程核心原理(1)逆向基礎2023-03-16
- 逆向基礎(十三) JAVA (一)2020-08-19Java
- iOS逆向之旅(基礎篇) — 彙編(四) — 彙編下的函式2018-10-25iOS函式
- iOS逆向-彙編基礎(一)2018-11-05iOS
- iOS逆向與安全:基礎篇2018-10-31iOS
- iOS逆向之旅(基礎篇) — 彙編(一)— 彙編基礎2018-10-25iOS
- 逆向WeChat(四)2024-05-28
- Dart基礎(四)2019-07-15Dart
- java基礎(四)2020-12-19Java
- Kotlin基礎四2018-06-13Kotlin
- Android 逆向(四) - adb常用逆向命令2024-03-20Android
- 前端基礎入門四(JavaScript基礎)2019-05-20前端JavaScript
- 測試基礎(四)Jmeter基礎使用2021-07-15JMeter
- 逆向基礎 Finding important/interesting stuff in the code (二)2020-08-19ImportREST
- 20192204-exp1-逆向與Bof基礎2022-03-17
- iOS逆向之旅(基礎篇) — Macho檔案2018-10-26iOSMac
- Java逆向基礎之靜態變數存取2021-09-09Java變數
- gRPC(四)基礎:gRPC流2022-11-09RPC
- Camera基礎知識四2024-03-28
- OC基礎-(四)KVC、KVO2018-11-02
- JavaSE基礎知識分享(四)2024-08-09Java
- JavaScript 基礎(四) – HTML DOM Event2019-01-28JavaScriptHTML
- Python基礎之四:Python3 基礎資料型別2020-10-20Python資料型別
- 逆向基礎——軟體手動脫殼技術入門2020-08-19
- 《MySQL 基礎篇》四:查詢操作2024-09-22MySql
- JavaScript夯實基礎系列(四):原型2019-03-18JavaScript原型