第13章 switch()/case/default

13.1 case陈述式较少的情况

本节将围绕这个例子进行讲解:

#include <stdio.h>

void f (int a)
{
    switch (a)
    {
    case 0: printf ("zero\n"); break;
    case 1: printf ("one\n"); break;
    case 2: printf ("two\n"); break;
    default: printf ("something unknown\n"); break;
    }; 
};

int main()
{
    f(2); //test
};
13.1.1 x86

Non-optimizing MSVC

使用MSVC 2010编译上述程序,可得到如下指令。

指令清单13.1 MSVC 2010

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 $LN4@f
    cmp    DWORD PTR tv64[ebp], 1
    je     SHORT $LN3@f
    cmp    DWORD PTR tv64[ebp], 2
    je     SHORT $LN2@f
    jmp    SHORT $LN1@f
$LN4@f:
    push   OFFSET $SG739 ; 'zero', 0aH, 00H
    call   _printf
    add    esp, 4
    jmp    SHORT $LN7@f
$LN3@f:
    push   OFFSET $SG741 ; 'one', 0aH, 00H
    call   _printf
    add    esp, 4
    jmp    SHORT $LN7@f
$LN2@f:
    push   OFFSET $SG743 ; 'two', 0aH, 00H
    call   _printf
    add    esp, 4
    jmp    SHORT $LN7@f
$LN1@f:
    push   OFFSET $SG745 ; 'something unknown', 0aH, 00H
    call   _printf
    add    esp, 4
$LN7@f:
    mov    esp, ebp
    pop    ebp
    ret    0 
_f     ENDP

上面这个函数的源程序相当于:

void f (int a)
{
    if (a==0)
        printf ("zero\n");
    else if (a==1)
        printf ("one\n");
    else if (a==2)
        printf ("two\n");
    else
        printf ("something unknown\n");
};

如果仅从汇编代码入手,那么我们无法判断上述函数是一个判断表达式较少的switch()语句、还是一组if()语句。确实可以认为,switch()语句是一种旨在简化大量嵌套if()语句而设计的语法糖[1]

上面的汇编代码把输入参数a代入了临时的局部变量tv64,其余部分的指令都很好理解。[2]

若用GCC 4.4.1编译器编译这个程序,无论是否启用其最大程度优化的选项“-O3”,生成的汇编代码也和MSVC编译出来的代码没有什么区别。

Optimizing MSVC

经指令“c1 1.c /Fa1.asm /Ox”编译上述程序,可得到如下指令。

指令清单13.2 MSVC

_a$ = 8 ; size = 4
_f     PROC
    mov    eax, DWORD PTR _a$[esp-4]
    sub    eax, 0
    je     SHORT $LN4@f
    sub    eax, 1
    je     SHORT $LN3@f
    sub    eax, 1
    je     SHORT $LN2@f
    mov    DWORD PTR _a$[esp-4], OFFSET $SG791 ; 'something unknown', 0aH, 00H
    jmp    _printf
$LN2@f:
    mov    DWORD PTR _a$[esp-4], OFFSET $SG789 ; 'two', 0aH, 00H
    jmp    _printf
$LN3@f:
    mov    DWORD PTR _a$[esp-4], OFFSET $SG787 ; 'one', 0aH, 00H
    jmp    _printf
$LN4@f:
    mov    DWORD PTR _a$[esp-4], OFFSET $SG785 ; 'zero', 0aH, 00H
    jmp    _printf
_f     ENDP

我们看到,它有以下两处不同。

第一处:程序把变量a存储到EAX寄存器之后,又用EAX的值减去零。似乎这样做并没有什么道理。但是这两条指令可以检查EAX寄存器的值是否是零。如果EAX寄存器的值是零,ZF标志寄存器会被置1(也就是说0−0=0,这就可以提前设置ZF标志位),并会触发第一条条件转移指令JE,使程序跳转到 $LN4@f,继而在屏幕上打印“Zero”。如果EAX寄存器的值仍然不是零,则不会触发第一条跳转指令、做“EAX=EAX-1”的运算,若计算结果是零则做相应输出;若此时EAX寄存器的值仍然不是零,就会再做一次这种减法操作和条件判断。

如果三次运算都没能使EAX寄存器的值变为零,那么程序会输出最后一条信息“something unknown”。

第二处:在把字符串指针存储到变量a之后,函数使用JMP指令调用printf()函数。在调用printf()函数的时候,调用方函数而没有使用常规的call指令。这点不难解释:调用方函数把参数推送入栈之后,的确通常通过CALL指令调用其他函数。这种情况下,CALL指令会把返回地址推送入栈、并通过无条件转移的手段启用被调用方函数。就本例而言,在被调用方函数运行的任意时刻,栈的内存存储结构为:

另一方面,在本例程序调用printf()函数之前、之后,除了制各第一个格式化字符串的参数问题以外,栈的存储结构其实没有发生变化。所以,编译器在分配JMP指令之前,把字符串指针存储到相应地址上。

这个程序把函数的第一个参数替换为字符串的指针,然后跳转到printf()函数的地址,就好像程序没有“调用”过f()函数、直接“转移”了printf()函数一般。当printf()函数完成输出的使命以后,它会执行RET返回指令。RET指令会从栈中读取(POP)返回地址RA、并跳转到RA。不过这个RA不是其调用方函数——f()函数内的某个地址,而是调用f()函数的函数即main()函数的某个地址。换而言之,跳转到这个RA地址后,printf()函数会伴随其调用方函数f()一同结束。

除非每个case从句的最后一条指令都是调用printf()函数,否则编译器就做不到这种程度的优化。某种意义上说这与longjmp()函数[3]十分相似。当然,这种优化的目的无非就是提高程序的运行速度。

ARM编译器也有类似的优化,请参见本书的6.2.1节。

OllyDbg

本节讲解使用OllyDbg调试这个程序的具体方法。

OllyDbg可以识别switch()语句的指令结构,而且能够自动地添加批注。最初的时候,EAX的值、即函数的输入参数为2,如图13.1所示。

..\TU\1301.tif{}

图13.1 OllyDbg:观察EAX 里存储的函数的第一个(也是唯一一个)参数

EAX的值(2)减去0。当然,EAX里的值还是2。此后ZF标志位被设为0,代表着运算结果不是零,如图13.2所示。

..\TU\1302.tif{}

图13.2 OllyDbg:执行第一个SUB指令

执行DEC指令之后,EAX里的值为1。但是1还不是零,ZF标志位还是0,如图13.3所示。

..\TU\1303.tif{}

图13.3 OllyDbg:执行第一条DEC指令

再次执行DEC指令,此时EAX里的值终成为是零了。因为运算结果为零,ZF标志位被置位为1,如图13.4所示。

..\TU\1304.tif{}

图13.4 OllyDbg:执行第二条DEC指令

OllyDbg提示将会触发条件转移指令,字符串“two”的指针即刻被推送入栈,如图13.5所示。

..\TU\1305.tif{}

图13.5 OllyDbg:函数的第一个参数被赋值为字符串指针

请注意:函数的当前参数是2,它位于0x0020FA44处的栈。

MOV指令把地址0x0020FA44的指针放入栈中,然后进行跳转。程序将执行文件MSVCR100.DLL里的printf()函数的第一条指令。为了便于演示,本例在编译程序时使用了/MD开关,如图13.6所示。

..\TU\1306.tif{}

图13.6 OllyDbg:文件MSVCR100.DLL中printf()函数的第一条指令

之后,printf()函数从地址0x00FF3010处读取它的唯一参数——字符串地址。然后函数会stdout设备(一般来说,就是屏幕)上在打印字符串。

printf()函数的最后一条指令如图13.7所示:

图13.7 OllyDbg:Printf()函数的最后一条指令

此时,字符串“two”就会被输出到控制台窗口(console)。

接下来,我们使用F7或F8键、单步执行这条返回指令。然而程序没有返回f()函数,而是回到了主函数main(),如图13.8所示。

图13.8 OllyDbg: 返回至main()函数

正如你所看到的那样,程序从printf()函数的内部直接返回到main()函数。这是因为RA寄存器里存储的返回地址确实不是f()函数中的某个地址,而是main()函数里的某个地址。请仔细观察返回地址的上一条指令,即“CALL 0X00FF1000”指令。它同样还是调用函数(即调用f())的指令。

13.1.2 ARM: Optimizing Keil 6/2013 (ARM mode)
.text:0000014C              f1:
.text:0000014C 00 00 50 E3    CMP     R0, #0
.text:00000150 13 0E 8F 02    ADREQ   R0, aZero ; "zero\n"
.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\n"
.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\n"
.text:0000016C 4E 0F 8F 02    ADREQ   R0, aTwo  ; "two\n"
.text:00000170
.text:00000170              loc_170 ; CODE XREF: f1+8
.text:00000170                      ; f1+14
.text:00000170 78 18 00 EA    B        __2printf

我们同样无法根据汇编指令判断源代码使用的是switch()语句还是if()语句。

此外,这段代码还出现了ADRQ指令之类的条件执行指令。第一条ADREQ指令会在R0=0的情况下,将字符串“zero \n”的地址传给R0。紧接其后都BEQ指令在相同的条件下把控制流转交给loc_170。或许有读者会问,ADREQ之后的BEQ指令还能读取到前面由CMP设置的标志吗?这当然不是问题。只有少数指令才会修改标志位寄存器的值,而一般的条件执行指令不会重设任何标志位。

其余的指令不难理解。程序只在尾部调用了一次printf()函数。前面的6.2.1节讲解过编译器的这种处理技术。最后,3条逻辑分支都会收敛于同一个printf()函数。

最后一条CMP指令是“CMP R0, #2”。它的作用是检查a是否为2。如果条件不成立,程序将通过ADRNE指令把“something unknown \n”的指针赋值给R0寄存器。在此之前,程序已经检查过a是否是0或1;所以运行到这里时,我们已经可以确定变量a不是这两个值。如果R0的值为2,那么ADREQ指令将把“two”的指针传递给R0寄存器。

13.1.3 ARM: Optimizing Keil 6/2013 (Thumb mode)
.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 \n"
.text:000000E4 04 E0        B        default_case

.text:000000E6              zero_case ; CODE XREF: f1+4
.text:000000E6 95 A0        ADR      R0, aZero ; "zero\n"
.text:000000E8 02 E0        B        default_case

.text:000000EA              one_case ; CODE XREF: f1+8
.text:000000EA 96 A0        ADR      R0, aOne ; "one\n"
.text:000000EC 00 E0        B        default_case

.text:000000EE              two_case ; CODE XREF: f1+C
.text:000000EE 97 A0        ADR      R0, aTwo ; "two\n"
.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}

前文介绍说过,条件执行指令是ARM模式指令的特色之一。Thumb模式的指令集里没有条件执行指令。所以Thumb模式的指令更为接近x86 CISC(复杂指令集)的指令,理解难度相对较低。

13.1.4 ARM64: Non-optimizing GCC (Linaro) 4.9
.LC12:
        .string "zero"
.LC13:
        .string "one"
.LC14:
        .string "two"
.LC15:
        .string "something unknown"
f12:
        stp     x29, x30, [sp, -32]!
        add     x29, sp, 0
        str     w0, [x29,28]
        ldr     w0, [x29,28]
        cmp     w0, 1
        beq     .L34
        cmp     w0, 2
        beq     .L35
        cmp     w0, wzr
        bne     .L38              ; jump to default label
        adrp    x0, .LC12         ; "zero"
        add     x0, x0, :lo12:.LC12
        bl      puts
        b       .L32 
.L34:
        adrp    x0, .LC13        ; "one"
        add     x0, x0, :lo12:.LC13
        bl      puts
        b       .L32
.L35:
        adrp    x0, .LC14        ; "two"
        add     x0, x0, :lo12:.LC14
        bl      puts
        b       .L32
.L38:
        adrp    x0, .LC15        ; "something unknown"
        add     x0, x0, :lo12:.LC15
        bl      puts
        nop 
.L32:
        ldp     x29, x30, [sp], 32
        ret

因为输入变量属于整型数据,所以ARM64编译器没有给它分配64位的X0寄存器而是为它分配了容量相当的X0寄存器。另外,本例用ADRP/ADD指令对向puts()函数传递字符串指针。前文3.4.5节中的“Hello,world!”也是这样传递参数的。

13.1.5 ARM64: Optimizing GCC (Linaro) 4.9
f12:
        cmp     w0, 1
        beq     .L31
        cmp     w0, 2
        beq     .L32
        cbz     w0, .L35
; default case
        adrp    x0, .LC15        ; "something unknown"
        add     x0, x0, :lo12:.LC15
        b       puts 
.L35:
        adrp    x0, .LC12        ; "zero"
        add     x0, x0, :lo12:.LC12
        b       puts
.L32:
        adrp    x0, .LC14        ; "two"
        add     x0, x0, :lo12:.LC14
        b       puts 
.L31:
        adrp    x0, .LC13        ; "one"
        add     x0, x0, :lo12:.LC13
        b       puts

优化编译的效果显著。CBZ(Compare and Branch on Zero)会在W0的值为零的情况下进行跳转。此外,在调用puts()函数的时候,本例使用的是JMP指令而非常规的call指令调用,这再现了13.1.1节出现过的函数调用方式。

13.1.6 MIPS

指令清单13.3 Optimizing GCC 4.4.5(IDA)

f:
                   lui     $gp, (__gnu_local_gp >> 16)
; is it 1?
                   Li      $v0, 1
                   beq     $a0, $v0, loc_60
                   la      $gp, (__gnu_local_gp & 0xFFFF) ; branch delay slot
; is it 2?
                   Li      $v0, 2
                   beq     $a0, $v0, loc_4C
                   or      $at, $zero ; branch delay slot, NOP
; jump, if not equal to 0:
                   bnez    $a0, loc_38
                   or      $at, $zero ; branch delay slot, NOP
; zero case:
                   lui     $a0, ($LC0 >> 16)  # "zero"
                   lw      $t9, (puts & 0xFFFF)($gp)
                   or      $at, $zero ; load delay slot, NOP
                   jr      $t9 ; branch delay slot, NOP
                   la      $a0, ($LC0 & 0xFFFF)  # "zero" ; branch delay slot
# ----------------------------------------------------------------
loc_38:                                        # CODE XREF: f+1C
                   lui     $a0, ($LC3 >> 16)  # "something unknown"
                   lw      $t9, (puts & 0xFFFF)($gp)
                   or      $at, $zero ; load delay slot, NOP
                   jr      $t9
                   la      $a0, ($LC3 & 0xFFFF)  # "something unknown" ; branch delay slot
# ----------------------------------------------------------------
loc_4C:                                        # CODE XREF: f+14
                   lui     $a0, ($LC2 >> 16)  # "two"
                   lw      $t9, (puts & 0xFFFF)($gp)
                   or      $at, $zero ; load delay slot, NOP
                   jr      $t9
                   la      $a0, ($LC2 & 0xFFFF)  # "two" ; branch delay slot
# ----------------------------------------------------------------
loc_60:                                        # CODE XREF: f+8
                   lui     $a0, ($LC1 >> 16)  # "one"
                   lw      $t9, (puts & 0xFFFF)($gp)
                   or      $at, $zero ; load delay slot, NOP
                   jr      $t9
                   la      $a0, ($LC1 & 0xFFFF)  # "one" ; branch delay slot

在汇编层面,每个case分支的最后一条指令都是调用puts()函数的指令。而且本例的每个case分支都通过跳转指令JR(Jump Register)调用puts()函数,完全没有使用常规的函数调用指令JAL(Jump And Link)。有关详细介绍,请参阅13.1.1节。

另外,参数LW指令之后有一条NOP指令。这种指令组合叫作“加载延迟槽/load delay slot”,是MIPS平台的另一种延迟槽。在LW指令从内存加载数据的时候,下面的那条指令可能和它并发执行。这样一来,LW后面的那条指令就无法使用LW读取的数据了。当今主流的MIPS CPU都针对这一问题进行了优化,在下一条指令LW的数据的情况下能够进行自动处理。虽然现在的MIPS处理器不再存在加载延时槽,但是GCC还是会颇为保守地添加加载延迟槽。总之,我们已经可以忽视这种延迟槽了。

13.1.7 总结

在case分支较少的情况下,switch()语句和if/else语句的编译结果基本相同。指令清单13.1可充分论证这个结论。

13.2 case陈述式较多的情况

在switch()语句存在大量case()分支的情况下,编译器就不能直接套用大量JE/JNE指令了。否则程序代码肯定会非常庞大。

#include <stdio.h>

void f (int a)
{
    switch (a)
    {
    case 0: printf ("zero\n"); break;
    case 1: printf ("one\n"); break;
    case 2: printf ("two\n"); break;
    case 3: printf ("three\n"); break;
    case 4: printf ("four\n"); break;
    default: printf ("something unknown\n"); break;
    }; 
};

int main()
{
    f(2); // test
};
13.2.1 x86

Non-optimizing MSVC

使用MSVC 2010编译上述程序,可得到如下指令。

指令清单13.4 MSVC 2010

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 $LN1@f
     mov    ecx, DWORD PTR tv64[ebp]
     jmp    DWORD PTR $LN11@f[ecx*4]
$LN6@f:
     push   OFFSET $SG739 ; 'zero', 0aH, 00H
     call   _printf
     add    esp, 4
     jmp    SHORT $LN9@f
$LN5@f:
     push   OFFSET $SG741 ; 'one', 0aH, 00H
     call   _printf
     add    esp, 4
     jmp    SHORT $LN9@f
$LN4@f:
     push   OFFSET $SG743 ; 'two', 0aH, 00H
     call   _printf
     add    esp, 4
     jmp    SHORT $LN9@f
$LN3@f:
     push   OFFSET $SG745 ; 'three', 0aH, 00H
     call   _printf
     add    esp, 4
     jmp    SHORT $LN9@f
$LN2@f:
     push   OFFSET $SG747 ; 'four', 0aH, 00H
     call   _printf
     add    esp, 4
     jmp    SHORT $LN9@f
$LN1@f:
     push   OFFSET $SG749 ; 'something unknown', 0aH, 00H
     call   _printf
     add    esp, 4
$LN9@f:
     mov    esp, ebp
     pop    ebp
     ret    0
     npad    2; align next label
$LN11@f:
     DD    $LN6@f ; 0
     DD    $LN5@f ; 1
     DD    $LN4@f ; 2
     DD    $LN3@f ; 3
     DD    $LN2@f ; 4
_f     ENDP

这段代码可被分为数个调用printf()函数的指令组,而且每组指令传递给printf()函数的参数还各不相同。这些指令组在内存中拥有各自的起始地址,也就被编译器分配到了不同的符号标签(symbolic label)之后。总的来看,程序通过$LN11@f处的一组数据调派这些符号标签。

函数最初把变量a的值与数字4进行比较。如果a大于4,函数则跳转到$LN1@f处,把字符串“something unknown”的指针传递给printf()函数。

如果变量a小于或等于4,则会计算a乘以4的积,再计算积与$LN11@f的偏移量的和(表查询),并跳转到这个结果所指向的地址上。以变量a等于2的情况来说,2×4=8(由于x86系统的内存地址都是32位数据,所以$LN11@f表中的每个地址都占用4字节)。在计算8与$LN11@f的偏移量的和之后,再跳转到这个和指向的标签——即$LN4@f处。JMP指令最终跳转到$LN4@f的地址。

$LN11@f标签(偏移量)开始的表,叫作“转移表jumptable”,也叫作“转移(输出)表branchtable”。[4]

a等于2的时候,程序分配给printf()的参数是“two”。实际上,此时的switch语句的分支指令等效于“jmp DWORD PTR $LN11@f[ecx*4]”。它会进行间接取值的操作,把指针“PTR{表达式}”所指向的数据读取出来,当作DWORD型数据传递给JMP指令。在这个程序里,表达式的值为$LN11@f+ecx*4。

此处出现的npad指令属于汇编宏,本书第88章会详细介绍它。它的作用是把紧接其后的标签地址向4字节(或16字节)边界对齐。npad的地址对齐功能可提高处理器的IO读写效率,通过一次操作即可完成内存总线、缓冲内存等设备的数据操作。

OllyDbg

接下来使用OllyDbg调试这个程序。我们在EAX=2的时候进行调试,如图13.9所示。

..\TU\1309.tif{}

图13.9 使用OllyDbg查看EAX获取输入值的情况

程序将检验输入值是否大于4。因为2<4,所以不会执行“default”规则的跳转,如图13.10所示。

..\TU\1310.tif{}

图13.10 OllyDbgEAX 4,不会触发default规则的跳转

然后就开始处理转移表,如图13.11所示。

..\TU\1311.tif{}

图13.11 利用转移表计算目标地址

在用鼠标选择“Follow in Dump”→“Address constant”功能之后,即可在数据窗口看见转移表。表里有5个32位的值[5]。现在ECX寄存器的值是2,所以对应表中的第2个元素(从0开始数)。另外,您还可以使用OllyDbg的“Follow in Dump→Memory address”功能查看JMP指令的目标地址。此时,这个目标地址为0x010B103A。

地址0x010B103A处的指令将会打印字符串“two”,如图13.12所示。

..\TU\1312.tif{}

图13.12 使用OllyDbg观察case:label的触发过程

Non-optimizing GCC

GCC 4.4.1编译出的代码如下。

指令清单13.5 GCC 4.4.1

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

这段代码与MSVC编译出来的代码几乎相同。参数arg_0被左移2位(数学上等同于乘以4,有关指令介绍请参阅16.2.1节),然后在转移表off_804855C的数组中获取相应地址,并将计算结果存储于EAX寄存器。最后通过JMP EAX指令进行跳转。

13.2.2 ARM: Optimizing Keil 6/2013 (ARM mode)

指令清单13.6 Optimizing Keil 6/2013 (ARM mode)

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               loc_180 ; CODE XREF: f2+4
00000180  03 00 00 EA      B        zero_case        ; jumptable 00000178 case 0

00000184
00000184               loc_184 ; CODE XREF: f2+4
00000184  04 00 00 EA      B        one_case         ; jumptable 00000178 case 1

00000188
00000188               loc_188 ; CODE XREF: f2+4
00000188  05 00 00 EA      B        two_case         ; jumptable 00000178 case 2

0000018C
0000018C               loc_18C ; CODE XREF: f2+4
0000018C  06 00 00 EA      B        three_case       ; jumptable 00000178 case 3

00000190
00000190               loc_190 ; CODE XREF: f2+4
00000190  07 00 00 EA      B        four_case        ; jumptable 00000178 case 4

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  04 00 00 EA      B       loc_1B8

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               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               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               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+4
000001B8                        ; f2+2C
000001B8  66 18 10 EA      B       __2printf

000001BC               default_case ; CODE XREF: f2+4
000001BC                            ; f2+8
000001BC D4 00 8F E2       ADR    R0, aSomethingUnkno ; jumptable default case
000001C0 FC FF FF EA       B      loc_1B8

这段代码充分体现了ARM模式下每条汇编指令占用4个字节的特点。

这个程序能够识别出4及4以下的自然数。当输入值是大于4的整数时,程序都会显示“something unknown \n”。

第一条指令是“CMP R0, #5”。它将输入变量与5做比较。

“ADDCC PC, PC, R0, LSL#2”会在R0寄存器的值小于5的时候进行加法计算,其中CC代表借位标志Carry Clear。如果R0寄存器的值不小于5,(即R0大于或等于5),则会直接跳转到标签default_case 处。

如果R0寄存器的值是5以下的整数,那么将会触发ADDCC,并且进行下列运算:

这是ARM处理器的pipeline/流水线决定的。当ARM处理器执行某条指令时,处理器正在处理(fetch取指)后面的第二条指令。实际上PC指向后面第二条(正在被fetch/取指的)指令的地址。[6]

后面的指令比较容易理解,5条B跳转指令接着完成各自赋值和打印的任务,完成switch()语句的功能。

13.2.3 ARM: Optimizing Keil 6/2013 (Thumb mode)

指令清单13.7 Optimizing Keil 6/2013 (Thumb mode)

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

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 ; jump table 000000FA case 0
00000108 06 E0             B       loc_118

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               two_case ; CODE XREF: f2+4
0000010E 8F A0             ADR     R0, aTwo ; jumptable 000000FA case 2
00000110 02 E0             B       loc_118

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

000061D2 00 00              ALIGN 4
000061D2               ; End of function __ARM_common_switch8_thumb
000061D2          
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程序的opcode长度并不固定。这一特征更接近x86系统的程序。

它们的程序代码里有一个专门用于存储case从句信息(default以外)的表。这个表负责记录case从句的数量、偏移量和标签,以便程序可以进行准确的寻址。程序通过这个表单进行相应的跳转,继而处理相应的分支case语句。

因为需要操作转移表并进行后续跳转,所以这个程序也使用了专用函数_ARM_common_ switch8_thumb。这个函数的第一条指令是“BX PC”,它将运行模式切换到32位的ARM模式,然后在32位模式下进行操作。然后函数着手表查询和分支转移单操作。具体指令非常复杂,本文在这里只是简单介绍一下,暂时不进行详解。

比较有趣的是,这个函数使用LR寄存器存储表的指针。在调用这个函数之后,LR寄存器存储着“BL __ARM_common_switch8_thumb”的后续指令的地址,也就是表开始的地址。

这个程序出现了每个switch()陈述句都会复用的专用函数,具有显著的结构化特征。可能是编译器为了避免生成重复代码而进行的处理。

IDA能够自动识别出这个函数和相应的转移表。IDA还能给相应的条目加上合理的注释。举例来说,IDA就给本例添加了“jumptable 000000FA case 0”这样的注释。

13.2.4 MIPS

指令清单13.8 Optimizing GCC 4.4.5 (IDA)

f:
                lui     $gp, (__gnu_local_gp >> 16)
; jump to loc_24 if input value is lesser than 5:
                sltiu   $v0, $a0, 5
                bnez    $v0, loc_24
                la      $gp, (__gnu_local_gp & 0xFFFF) ; branch delay slot
; input value is greater or equal to 5.
; print "something unknown" and finish:
                lui     $a0, ($LC5 >> 16)  # "something unknown"
                lw      $t9, (puts & 0xFFFF)($gp)
                or      $at, $zero ; NOP
                jr      $t9
                la      $a0, ($LC5 & 0xFFFF)  # "something unknown" ; branch delay slot

loc_24:                                        # CODE XREF: f+8
; load address of jumptable
; LA is pseudoinstruction, LUI and ADDIU pair are there in fact:
                la      $v0, off_120
; multiply input value by 4:
                sll     $a0, 2
; sum up multiplied value and jumptable address:
                addu    $a0, $v0, $a0
; load element from jumptable:
                lw      $v0, 0($a0)
                or      $at, $zero ; NOP
; jump to the address we got in jumptable:
                jr      $v0
                or      $at, $zero ; branch delay slot, NOP

sub_44:                                        # DATA XREF: .rodata:0000012C
; print "three" and finish
                lui     $a0, ($LC3 >> 16)  # "three"
                lw      $t9, (puts & 0xFFFF)($gp)
                or      $at, $zero ; NOP
                jr      $t9
                la      $a0, ($LC3 & 0xFFFF)  # "three" ; branch delay slot

sub_58:                                        # DATA XREF: .rodata:00000130
; print "four" and finish
                lui     $a0, ($LC4 >> 16)  # "four"
                lw      $t9, (puts & 0xFFFF)($gp)
                or      $at, $zero ; NOP
                jr      $t9
                la      $a0, ($LC4 & 0xFFFF)  # "four" ; branch delay slot
sub_6C:                                        # DATA XREF: .rodata:off_120
; print "zero" and finish
                lui     $a0, ($LC0 >> 16)  # "zero"
                lw      $t9, (puts & 0xFFFF)($gp)
                or      $at, $zero ; NOP
                jr      $t9
                la      $a0, ($LC0 & 0xFFFF)  # "zero" ; branch delay slot

sub_80:                                        # DATA XREF: .rodata:00000124
; print "one" and finish
                lui     $a0, ($LC1 >> 16)  # "one"
                lw      $t9, (puts & 0xFFFF)($gp)
                or      $at, $zero ; NOP
                jr      $t9
                la      $a0, ($LC1 & 0xFFFF)  # "one" ; branch delay slot

sub_94:                                        # DATA XREF: .rodata:00000128
; print "two" and finish
                lui     $a0, ($LC2 >> 16)  # "two"
                lw      $t9, (puts & 0xFFFF)($gp)
                or      $at, $zero ; NOP
                jr      $t9
                la      $a0, ($LC2 & 0xFFFF)  # "two" ; branch delay slot

; may be placed in .rodata section:
off_120:        .word sub_6C
                .word sub_80
                .word sub_94
                .word sub_44
                .word sub_58

上述代码出现了SLTIU(Set on Less Than lmmediate Unsigned)指令。它和SLTU(Set on Less Than Unsigned)的功能基本相同。请注意,这两个指令名称里差了一个“立即数(immediate)”字样。这说明前者需要在指令中指定既定的立即数。

BNEZ是“在非零情况下进行转移/Branche if Not Equal to Zero”的缩写。

上述代码和其他指令集的代码十分相近。SLL(Shift Word Left Logical)是逻辑左移的指令,本例用它进行“乘以4”的运算。毕竟这是一个面向32位MIPS CPU的程序,所有转移表里的所有地址都是32位指针。

13.2.5 总结

switch()的大体框架参见指令清单13.9。

指令清单13.9 x86

MOV REG,input
CMP REG,4 ; maximal number of cases
JA default 
SHL REG,3 ; find element in table.shift for 3bits in x64.
MOV REG, jump_table[REG]
JMP REG

case1;
     ; do something
     JMP exit
case2;
     ; do something
     JMP exit
case3;
     ; do something
     JMP exit
case4;
     ; do something
     JMP exit
Case5;
     ; do something
     JMP exit

defaule:

     …

exit:

     …

jump_table dd casel
           dd case2
           dd case3
           dd case4
           dd case5

若不使用上述指令,我们也可以在32位系统上使用指令JMP jump_table[REG*4]/在64位上使用JMP jump_table[REG*8],实现转移表中的寻址计算。

说到底,转移表只不过是某种指针数组它和18.5节介绍的那种指针数组十分雷同。

13.3 case从句多对一的情况

多个case陈述从句触发同一系列操作的情况并不少见,例如:

#include <stdio.h>

void f (int a)
{
         switch (a)
         {
         case 1:
         case 2:
         case 7:
         case 10:
                  printf ("1, 2, 7, 10\n");
                  break;
         case 3:
         case 4:
         case 5:
         case 6:
                  printf ("3, 4, 5\n");
                  break;
         case 8:
         case 9:
         case 20:
         case 21:
                  printf ("8 9, 21\n");
                  break;
         case 22:
                  printf ("22\n");
                  break;
         default:
                  printf ("default\n");
                  break;
         };
};

int main () 
{
         f(4); 
};

如果编译器刻板地按照每种可能的逻辑分支逐一分配对应的指令组,那么程序里将会存在大量的重复指令。一般而言,编译器会通过某种派发机制来降低代码的冗余度。

13.3.1 MSVC

使用MSVC 2010(启用/Ox选项)编译上述程序,可得到如下指令。

指令清单13.10 Optimizing MSVC 2010

 1 $SG2798 DB        '1, 2, 7, 10', 0aH, 00H
 2 $SG2800 DB        '3, 4, 5', 0aH, 00H
 3 $SG2802 DB        '8, 9, 21', 0aH, 00H
 4 $SG2804 DB        '22', 0aH, 00H
 5 $SG2806 DB        'default', 0aH, 00H
 6
 7 _a$ = 8
 8 _f      PROC
 9         mov       eax, DWORD PTR _a$[esp-4]
10         dec       eax
11         cmp       eax, 21
12         ja        SHORT $LN1@f
13         movzx     eax, BYTE PTR $LN10@f[eax]
14         jmp       DWORD PTR $LN11@f[eax*4]
15 $LN5@f:
16         mov       DWORD PTR _a$[esp-4], OFFSET $SG2798 ; '1, 2, 7, 10'
17         jmp       DWORD PTR __imp__printf
18 $LN4@f:
19         mov       DWORD PTR _a$[esp-4], OFFSET $SG2800 ; '3, 4, 5'
20         jmp       DWORD PTR __imp__printf
21 $LN3@f:
22         mov       DWORD PTR _a$[esp-4], OFFSET $SG2802 ; '8, 9, 21'
23         jmp       DWORD PTR __imp__printf
24 $LN2@f:
25         mov       DWORD PTR _a$[esp-4], OFFSET $SG2804 ; '22'
26         jmp       DWORD PTR __imp__printf
27 $LN1@f:
28         mov       DWORD PTR _a$[esp-4], OFFSET $SG2806 ; 'default'
29         jmp       DWORD PTR __imp__printf
30         npad      2 ; align $LN11@f table on 16-byte boundary
31 $LN11@f:
32         DD        $LN5@f ; print '1, 2, 7, 10'
33         DD        $LN4@f ; print '3, 4, 5'
34         DD        $LN3@f ; print '8, 9, 21'
35         DD        $LN2@f ; print '22'
36         DD        $LN1@f ; print 'default'
37 $LN10@f:
38         DB        0 ; a=1
39         DB        0 ; a=2
40         DB        1 ; a=3
41         DB        1 ; a=4
42         DB        1 ; a=5
43         DB        1 ; a=6
44         DB        0 ; a=7
45         DB        2 ; a=8
46         DB        2 ; a=9
47         DB        0 ; a=10
48         DB        4 ; a=11
49         DB        4 ; a=12
50         DB        4 ; a=13
51         DB        4 ; a=14
52         DB        4 ; a=15
53         DB        4 ; a=16
54         DB        4 ; a=17
55         DB        4 ; a=18
56         DB        4 ; a=19
57         DB        2 ; a=20
58         DB        2 ; a=21
59         DB        3 ; a=22
60 _f      ENDP

这个程序用到了两个表:一个是索引表$LN10@f;另一个是转移表$LN11@f。

第13行的movzx指令在索引表里查询输入值。

索引表的返回值又分为0(输入值为1、2、7、10)、1(输入值为3、4、5)、2(输入值为8、9、21)、3(输入值为22)、4(其他值)这5种情况。

程序把索引表的返回值作为关键字,再在第二个转移表里进行查询,以完成相应跳转(第14行指令的作用)。

需要注意的是,输入值为0的情况没有相应的case从句。如果a=0,则“dec eax”指令会继续进行计算,而$LN10@f表的查询是从1开始的。可见,没有必要为0的特例设置单独的表。

这是一种普遍应用的编译技术。

表面看来,这种双表结构似乎不占优势。为什么它不象前文(请参见13.2.1节)那样采用一个统一的指针结构呢?在这种双表结构中,索引表采用的是byte型数据,所以双表结构比前面那种单表结构更为紧凑。

13.3.2 GCC

在编译这种多对一的switch语句时,GCC会生成统一的转移表。其代码风格和前文13.2.1节的风格相同。

13.3.3 ARM64: Optimizing GCC 4.9.1

因为输入值为零的情况没有对应的处理方法,所以GCC会从输入值为1的特例开始枚举各个分支,以便把转移表压缩得尽可能小。

GCC 4.9.1for ARM64的编译技术更为优越。它能把所有的偏移量信息编码为8位字节型数据、封装在单条指令的opcode里。前文介绍过,ARM64程序的每条指令都对应着4个字节的opcode。在本例这种类型的小型代码中,各分支偏移量的具体数值不会很大。GCC能够充分利用这一现象,构造出单字节指针组成的转移表。

指令清单13.11 Optimizing GCC 4.9.1 ARM64

f14:
; input value in W0
        sub      w0, w0, #1
        cmp      w0, 21
; branch if less or equal (unsigned):
        bls      .L9
.L2:
; print "default":
        adrp     x0, .LC4
        add      x0, x0, :lo12:.LC4
        b        puts
.L9:
; load jumptable address to X1:
        adrp     x1, .L4
        add      x1, x1, :lo12:.L4
; W0=input_value-1
; load byte from the table:
        ldrb     w0, [x1,w0,uxtw]
; load address of the Lrtx label:
        adr      x1, .Lrtx4
; multiply table element by 4 (by shifting 2 bits left) and add (or subtract) to the address of lrtx
        add      x0, x1, w0, sxtb #2
; jump to the calculated address:
        br       x0
; this label is pointing in code (text) segment:
.Lrtx4:
        .section          .rodata
; everything after ".section" statement is allocated in the read-only data (rodata) segment:
.L4:
        .byte   (.L3 - .Lrtx4) / 4  ;case 1
        .byte   (.L3 - .Lrtx4) / 4  ;case 2
        .byte   (.L5 - .Lrtx4) / 4  ;case 3
        .byte   (.L5 - .Lrtx4) / 4  ;case 4
        .byte   (.L5 - .Lrtx4) / 4  ;case 5
        .byte   (.L5 - .Lrtx4) / 4  ;case 6
        .byte   (.L3 - .Lrtx4) / 4  ;case 7
        .byte   (.L6 - .Lrtx4) / 4  ;case 8
        .byte   (.L6 - .Lrtx4) / 4  ;case 9
        .byte   (.L3 - .Lrtx4) / 4  ;case 10
        .byte   (.L2 - .Lrtx4) / 4  ;case 11
        .byte   (.L2 - .Lrtx4) / 4  ;case 12
        .byte   (.L2 - .Lrtx4) / 4  ;case 13
        .byte   (.L2 - .Lrtx4) / 4  ;case 14
        .byte   (.L2 - .Lrtx4) / 4  ;case 15
        .byte   (.L2 - .Lrtx4) / 4  ;case 16
        .byte   (.L2 - .Lrtx4) / 4  ;case 17
        .byte   (.L2 - .Lrtx4) / 4  ;case 18
        .byte   (.L2 - .Lrtx4) / 4  ;case 19
        .byte   (.L6 - .Lrtx4) / 4  ;case 20
        .byte   (.L6 - .Lrtx4) / 4  ;case 21
        .byte   (.L7 - .Lrtx4) / 4  ;case 22
        .text
; everything after ".text" statement is allocated in the code (text) segment:
.L7:
; print "22"
        adrp     x0, .LC3
        add      x0, x0, :lo12:.LC3
        b        puts
.L6:
; print "8, 9, 21"
        adrp     x0, .LC2
        add      x0, x0, :lo12:.LC2
        b        puts
.L5:
; print "3, 4, 5"
        adrp     x0, .LC1
        add      x0, x0, :lo12:.LC1
        b        puts
.L3:
; print "1, 2, 7, 10"
        adrp     x0, .LC0
        add      x0, x0, :lo12:.LC0
        b        puts
.LC0:
        .string "1, 2, 7, 10"
.LC1:
        .string "3, 4, 5"
.LC2:
        .string "8, 9, 21"
.LC3:
        .string "22"
.LC4:
        .string "default"

把上述程序编译为obj文件,然后再使用IDA打开,可看到其转移表如下。

指令清单13.12 jumptable in IDA

.rodata:0000000000000064             AREA .rodata, DATA, READONLY
.rodata:0000000000000064             ; ORG 0x64
.rodata:0000000000000064  $d         DCB    9    ; case 1
.rodata:0000000000000065             DCB    9    ; case 2
.rodata:0000000000000066             DCB    6    ; case 3
.rodata:0000000000000067             DCB    6    ; case 4
.rodata:0000000000000068             DCB    6    ; case 5
.rodata:0000000000000069             DCB    6    ; case 6
.rodata:000000000000006A             DCB    9    ; case 7
.rodata:000000000000006B             DCB    3    ; case 8
.rodata:000000000000006C             DCB    3    ; case 9
.rodata:000000000000006D             DCB    9    ; case 10
.rodata:000000000000006E             DCB 0xF7    ; case 11
.rodata:000000000000006F             DCB 0xF7    ; case 12
.rodata:0000000000000070             DCB 0xF7    ; case 13
.rodata:0000000000000071             DCB 0xF7    ; case 14
.rodata:0000000000000072             DCB 0xF7    ; case 15
.rodata:0000000000000073             DCB 0xF7    ; case 16
.rodata:0000000000000074             DCB 0xF7    ; case 17
.rodata:0000000000000075             DCB 0xF7    ; case 18
.rodata:0000000000000076             DCB 0xF7    ; case 19
.rodata:0000000000000077             DCB    3    ; case 20
.rodata:0000000000000078             DCB    3    ; case 21
.rodata:0000000000000079             DCB    0    ; case 22
.rodata:000000000000007B ; .rodata ends

当输入值为1时,目标偏移量的技术方法是:9乘以4、再加上Lrtx4的偏移量。当输入值为22时,目标偏移量为:0乘以4、结果为0。在转移表Lrtx4之后就是L7的标签的指令了,这部分指令将负责打印数字22。请注意,转移表位于单独的.rodata段。编译器没有把它分配到.text的代码段里。

上述转移表有一个负数0xF7。这个偏移量指向了打印默认字符串(.L2标签)的相关指令。

13.4 Fall-through

Switch()语句还有一种常见的使用方法——fall-through。

 1 #define R 1
 2 #define W 2
 3 #define RW 3
 4
 5 void f(int type)
 6 {
 7          int read=0, write=0;
 8
 9          switch (type)
10          {
11          case RW:
12                   read=1;
13          case W:
14                   write=1;
15                   break;
16          case R:
17                   read=1;
18                   break;
19          default:
20                   break;
21          };
22          printf ("read=%d, write=%d\n", read, write);
23 };

如果type为1(参见第一行可知,这是读取权限R为真的情况),则read的值会被设置为1;如果type为2(W),则write被设置1;如果type为3(RW),则read和write的值都会被设置为1。

无论type的值是RW还是W,程序都会执行第14行的指令。type为RW的陈述语句里没有break指令,从而利用switch语句的fall through效应。

13.4.1 MSVC x86

指令清单13.13 MSVC 2012

$SG1305 DB       'read=%d, write=%d', 0aH, 00H

_write$ = -12    ; size= 4
_read$  = -8     ; size= 4
tv64  = -4       ; size= 4
_type$  = 8      ; size= 4
_f        PROC
          push   ebp
          mov    ebp, esp
          sub    esp, 12
          mov    DWORD PTR _read$[ebp], 0
          mov    DWORD PTR _write$[ebp], 0
          mov    eax, DWORD PTR _type$[ebp]
          mov    DWORD PTR tv64[ebp], eax
          cmp    DWORD PTR tv64[ebp], 1 ; R
          je     SHORT $LN2@f
          cmp    DWORD PTR tv64[ebp], 2 ; W
          je     SHORT $LN3@f
          cmp    DWORD PTR tv64[ebp], 3 ; RW
          je     SHORT $LN4@f
          jmp    SHORT $LN5@f
$LN4@f: ; case RW:
          mov    DWORD PTR _read$[ebp], 1
$LN3@f: ; case W:
          mov    DWORD PTR _write$[ebp], 1
          jmp    SHORT $LN5@f
$LN2@f: ; case R:
          mov    DWORD PTR _read$[ebp], 1
$LN5@f: ; default
          mov    ecx, DWORD PTR _write$[ebp]
          push   ecx
          mov    edx, DWORD PTR _read$[ebp]
          push   edx
          push   OFFSET $SG1305 ; 'read=%d, write=%d'
          call   _printf
          add    esp, 12
          mov    esp, ebp
          pop    ebp
          ret    0
_f        ENDP

上述汇编指令与C语言源代码几乎一一对应。因为在$LN4@f和$LN3@f之间没有转移指令,所以当程序执行了$LN4@f处的“令read的值为1”的指令之后,它还会执行后面那个标签的write赋值指令。这也是“fall through”(滑梯)这个名字的来源:当执行完前面那个陈述语句的指令(read赋值)之后,继续执行下一个陈述语句的指令(write赋值)。如果type的值为W,程序会执行$LN3@f的指令,而不会执行前面那个read赋值指令。

13.4.2 ARM64

指令清单13.14 GCC (Linaro) 4.9

.LC0:
        .string "read=%d, write=%d\n"
f:
        stp     x29, x30, [sp, -48]!
        add     x29, sp, 0
        str     w0, [x29,28]
        str     wzr, [x29,44]   ; set "read" and "write" local variables to zero
        str     wzr, [x29,40]
        ldr     w0, [x29,28]    ; load "type" argument
        cmp     w0, 2           ; type=W?
        beq     .L3
        cmp     w0, 3           ; type=RW?
        beq     .L4
        cmp     w0, 1           ; type=R?
        beq     .L5
        b       .L6             ; otherwise...
.L4: ; case RW
        mov     w0, 1
        str     w0, [x29,44]    ; read=1
.L3: ; case W
        mov     w0, 1
        str     w0, [x29,40]    ; write=1
        b       .L6
.L5: ; case R
        mov     w0, 1
        str     w0, [x29,44]    ; read=1
        nop
.L6: ; default
        adrp    x0, .LC0        ; "read=%d, write=%d\n"
        add     x0, x0, :lo12:.LC0
        ldr     w1, [x29,44]    ; load "read"
        ldr     w2, [x29,40]    ; load "write"
        bl      printf
        ldp     x29, x30, [sp], 48
        ret

Arm64程序的汇编指令与MSVC x86的汇编指令大致相同。编译器同样没有在标签.L4和标签.L3之间分配转移指令,从而形成了Switch()语句的fall-through效应。

13.5 练习题

13.5.1 题目1

13.2节有一段C语言源代码。请改写这个程序,并且在不改变程序功能的前提下,让编译器生成体积更小的可执行程序。


[1] 即syntactic sugar,指代增强代码可读性、降低编程出错率的语法改进措施。

[2] MSVC编译器在处理栈内的局部变量时,按照其需要,可能给这些内部变量分配以tv开头的宏变量。

[3] https://en.wikipedia.org/wiki/Setjmp.h。

[4] 这个名称来自于Fortran早期的GOTO算法。虽然现在保留了这个名称,但是已经和那个概念没什么关系了。详情请参见http://en.wikipedia.org/wiki/Branch_table。

[5] OllyDbg用下画线的格式显示这些值,因为它们也是FIXUPS。本书的68.2.6节会进行详细的解释。

[6] 虽然pipeline三级流水的解释较为直观,但是官方手册《ARM architecture reference manual》第1章第2节否认了这种硬件上的联系,它把PC与指令间opcode的增量关系解释为历史原因。