逆向基礎(四)

wyzsk發表於2020-08-19
作者: reverse-engineering · 2014/05/23 14:59

第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。

enter image description here

圖12.1: OllyDbg main()開始

enter image description here

圖12.2: OllyDbg: 迴圈體剛剛遞增了i,現在i=6

enter image description here

圖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

生成的程式碼可以在此使用:

enter image description here

圖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,然後將它作為返回值返回,退出函式。

enter image description here

圖13.1: 第一次迴圈迭代起始位置

enter image description here

圖13.2:第二次迴圈迭代開始位置

enter image description here

圖13.3: 現在要計算二者的差了

enter image description here

圖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的這個語法真的很難讓人記住。下為具體敘述:

enter image description here

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暫存器中。

本文章來源於烏雲知識庫,此映象為了方便大家學習研究,文章版權歸烏雲知識庫!

相關文章