65.9K
CodeProject 正在变化。 阅读更多。
Home

您可能不知道的 C/C++ 中的 switch 语句

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.86/5 (150投票s)

2010年8月9日

CPOL

13分钟阅读

viewsIcon

393019

downloadIcon

487

通过逆向工程 VC++ 讨论 switch/case 的执行方式

引言

许多编程语言,如 C/C++、C#、Java 和 Pascal 都提供了 switch 语句,以便我们实现选择逻辑。在某些情况下,它是 if-then-else 的一个很好的替代方案,使代码更清晰、更易读。在实践中使用 switch 时,您可能想知道

  • switch 块在运行时是如何执行的? 
  • 对于长列表的条件,它的运行速度是否比 if-then-else 快?
  • 对于 n 个条件,switch 的时间复杂度是多少?

C/C++ 标准定义了语言元素的规范,但并未说明如何实现 switch 语句。任何供应商都可以自由使用任何实现,只要它符合标准。本文讨论了在 Visual C++ 中运行 switch 语句时会发生什么,并提供了几个在不同条件下的示例。我们将使用 Microsoft Visual Studio IDE 分析这些示例,因为它可以在编译时生成相应的汇编列表。因此,假设您对 Intel (x86) 汇编语言有一般的了解。正如您很快会看到的,这里的所有结果都基于逆向工程,因此本文绝不是对编译器中 switch 实现的全面描述。如果您正在学习汇编语言编程,本文可能是一份学习材料。

我们的第一个示例是 switch1.cpp,一个常用的简单块,如下所示

#include "functions.h"

int main()
{
    int i =3;    // or i =20

    switch (i)
    {
        case 1: f1(); break;
        case 2: f2(); break;
        case 5: f1(); break;
        case 7: f2(); break;
        case 10: f1(); break;
        case 11: f2(); break;
        case 12: f2(); break;
        case 17: f1(); break;
        case 18: f1(); break;

        default: f3(); 
    }

    return 0;
}

其中三个函数定义在 functions.cpp

void f1() { cout  "f1 called\n"; }
void f2() { cout  "f2 called\n"; }
void f3() { cout  "f3 called\n"; }

最坏情况是否可以认为是 i=3i=20?它是如何执行的:它会耗尽所有九个 case 并最终到达 default 来调用 f3 吗?让我们从汇编翻译中找到答案。

要在 Visual Studio 中生成汇编列表,请打开 switch1.cpp 的属性对话框,然后在 C/C++ 下选择“输出文件”类别。在右侧窗格中,选择“带源代码的程序集”(/FAs) 选项,如下图所示

The option in the switch1.cpp Properties

然后,当您编译 switch1.cpp 时,将生成一个名为 switch1.asm 的汇编文件。使用此选项,列表将包含 C++ 源代码,并以分号和行号作为注释,如下节所示。

两级跳转表

让我们从上到下分析汇编列表。switch 从这里开始

; 5    :     int i =3;    // or i =20

    mov    DWORD PTR _i$[ebp], 3

; 6    : 
; 7    :     switch (i)

    mov    eax, DWORD PTR _i$[ebp]
    mov    DWORD PTR tv64[ebp], eax
    mov    ecx, DWORD PTR tv64[ebp]
    sub    ecx, 1
    mov    DWORD PTR tv64[ebp], ecx
    cmp    DWORD PTR tv64[ebp], 17            ; 00000011H
    ja     SHORT $LN1@main
    mov    edx, DWORD PTR tv64[ebp]
    movzx  eax, BYTE PTR $LN15@main[edx]
    jmp    DWORD PTR $LN16@main[eax*4]

假设符号 _i$[ebp]i 的别名,tv64[ebp]i2 的另一个名称,$LN15@main 被重命名为 table1$LN16@main 被重命名为 table2,而 $LN1@main 是一个名为 Default_Label 的标签。片段仅用伪代码表示

i2 = i;
i2 = i2-1;
if i2 > 17 goto Default_Label;
goto table2[4*table1[i2]];

在这里,17 表示最后一个 case 条件值,因为整数 1 到 18 被映射为 0 到 17。这就是为什么 i2 被减去,使其成为一个零基整数作为索引。现在,如果 i2 大于 17(例如,n=20),控制将转到 default。否则,它将转到 table2[4*table1[i2]] 所指向的位置。

i 为零时会怎样?那么 i2 将变成 -1。担心索引越界错误吗?不,它永远不会发生。回到汇编列表,您可以看到 -1 被保存为 DWORD,即双字(无符号 4 字节整数)。因此,它必须大于 17 并转到 default

让我们看看两个表,看看它们是如何协同工作的。table1 非常简单,起始地址为 $LN15@main,您可以将其视为一个数组名。

$LN15@main:
    DB    0
    DB    1
    DB    9
    DB    9
    DB    2
    DB    9
    DB    3
    DB    9
    DB    9
    DB    4
    DB    5
    DB    6
    DB    9
    DB    9
    DB    9
    DB    9
    DB    7
    DB    8

使用这个数组,table1[0] 是 0,table1[1] 是 1,table1[2]table1[3] 是 9,等等。这些值用于计算 table2 的索引,table2$LN16@main 开始

$LN16@main:
    DD    $LN10@main
    DD    $LN9@main
    DD    $LN8@main
    DD    $LN7@main
    DD    $LN6@main
    DD    $LN5@main
    DD    $LN4@main
    DD    $LN3@main
    DD    $LN2@main
    DD    $LN1@main

上述标签,从 $LN10@main$LN1@main,是 C++ 中的十个调用目标,对应九个 case 加上一个 default。请注意,DB 表示定义字节(8 位),而 DD 定义四字节(32 位)的双字类型。这就是为什么我们需要在 table2[4*table1[i2]] 中乘以 4。通过这个公式,我们通过 table1table2 计算调用地址

  • 如果 i 等于 1,i2 为 0 且 table1[0] 为 0,则通过 table2[0](第一个 case)跳转到 $LN10@main
  • 如果 i 等于 2,i2 为 1 且 table1[1] 为 1,则通过 table2[4*1](第二个 case)跳转到 $LN9@main
  • 如果 i 等于 3,i2 为 2 且 table1[2] 为 9,则通过 table2[4*9](默认 case)跳转到 $LN1@main
  • ... ...

现在我们来看标记为 LN10@main$LN1@main 的代码段,作为调用目标

$LN10@main:
; 8    :     {
; 9    :         case 1: f1(); break;

    call        ?f1@@YAXXZ            ; f1
    jmp    SHORT $LN11@main

$LN9@main:
; 10   :         case 2: f2(); break;

    call    ?f2@@YAXXZ                ; f2
    jmp    SHORT $LN11@main

$LN8@main:
; 11   :         case 5: f1(); break;

    call    ?f1@@YAXXZ                ; f1
    jmp    SHORT $LN11@main

; ... ...

$LN2@main:
; 17   :         case 18: f1(); break;

    call    ?f1@@YAXXZ                ; f1
    jmp    SHORT $LN11@main

$LN1@main:
; 18   : 
; 19   :         default: f3(); 

    call    ?f3@@YAXXZ                ; f3

$LN11@main:
; 20   :     }
; 21   : 
; 22   :     return 0;

函数 f1f2f3 被转换为修饰的汇编过程,并带有问号前缀,例如 ?f1@@YAXXZ?f2@@YAXXZ?f3@@YAXXZ。请注意,$LN10@main 是 case 1 的标签,用于调用 f1$LN9@main 是 case 2 的标签,用于调用 f2$LN1@maindefault 的标签,用于调用 f3

还有一个额外的标签 $LN11@main,指向最后一个 default 子句之后的位置。每个 case 操作完成后,控制将跳转到 $LN11@main。这实现了 break 语句。没有它,控制将转到下一个 case。这就是为什么 break 语句在 C/C++ switch 块中是必需的。

显然,基于这种两级表机制,我们进行了一次比较、一次乘法和两次地址跳转。此 switch 模式的时间复杂度应为 O(1)。把这一切放在一起,我们有一个大局如下

The Two-Level Jump Table Diagram

回想一下,我们在 table1 中使用 DB 定义索引值,在 table2 中使用 DD。在此示例中,table1 只有 18 个 case 值。但是 DB 定义 byte 意味着范围仅为零到 255。如果 switch 中有更多 case,并且值超过 255 怎么办?

一级跳转表

现在来看第二个示例,switch2.cpp,包含 1000 个 case。为简单起见,我们从条件值为零开始

int main2()
{
    int i =1000;

    switch (i)
    {
        case 0: f1(); break;
        case 1: f1(); break;
        case 2: f2(); break;
        case 3: f1(); break;
      // ... ...
        case 995: f1(); break;
        case 996: f1(); break;
        case 997: f1(); break;
        case 998: f2(); break;
        case 999: f1(); break;

        default: f3(); 
    }

    return 0;
}

不要认为这会使事情变得复杂。恰恰相反,它变得更简单了。这是 Visual Studio 为我们生成的 switch2.asm

; 5    :     int i =1000;

    mov    DWORD PTR _i$[ebp], 1000        ; 000003e8H

; 6    : 
; 7    :     switch (i)

    mov    eax, DWORD PTR _i$[ebp]
    mov    DWORD PTR tv64[ebp], eax
    cmp    DWORD PTR tv64[ebp], 999        ; 000003e7H
    ja     $LN1@main2
    mov    ecx, DWORD PTR tv64[ebp]
    jmp    DWORD PTR $LN1006@main2[ecx*4]

同样,假设符号 _i$[ebp]i 的别名。因为第一个 case 从零开始,所以不需要映射。因此 i2(来自 tv64[ebp])等于 i。数组 $LN1006@main2 是一个唯一的跳转表,标签 $LN1@main2Default_Label。这个片段只是这样做

i2 = i;
if i2 > 999 goto Default_Label;
goto table[4*i2];

通过在 switch2.asm 中搜索 $LN1006@main2,我们发现所有代码标签都由双字定义

$LN1006@main2:
    DD    $LN1001@main2
    DD    $LN1000@main2
    DD    $LN999@main2
    DD    $LN998@main2
    DD    $LN997@main2
    DD    $LN996@main2
   ; ... ...
    DD    $LN5@main2
    DD    $LN4@main2
    DD    $LN3@main2
    DD    $LN2@main2

总共有 1000 个调用目标,每个标签后面都有一个过程调用,从 $LN1001@main2$LN2@main2 向下排列

$LN1001@main2:
; 8    :     {
; 9    :         case 0: f1(); break;
    call    ?f1@@YAXXZ                ; f1
    jmp    $LN1002@main2

$LN1000@main2:
; 10   :         case 1: f1(); break;

    call    ?f1@@YAXXZ                ; f1
    jmp    $LN1002@main2

$LN999@main2:
; 11   :         case 2: f2(); break;

    call    ?f2@@YAXXZ                ; f2
    jmp    $LN1002@main2

; ... ...

$LN3@main2:
; 1106 :         case 998: f2(); break;

    call    ?f2@@YAXXZ                ; f2
    jmp    SHORT $LN1002@main2

$LN2@main2:
; 1107 :         case 999: f1(); break;

    call    ?f1@@YAXXZ                ; f1
    jmp    SHORT $LN1002@main2

$LN1@main2:
; 1108 : 
; 1109 :         default: f3(); 

    call    ?f3@@YAXXZ                ; f3
$LN1002@main2:

; 1110 :     }
; 1111 : 
; 1112 :     return 0;

在这里,$LN1@main2 指向 default,额外的标签 $LN1002@main 实现 break。虽然复杂度也为 O(1),但这种机制应该会更有效一些,因为它只需要一次地址跳转。下面是这个示例的图示

The One-Level Jump Table Diagram

到目前为止,我们看到了 switch 的出色执行示例。使用 switch 是否真的比 if-then-else 更好?对于 n 个条件不匹配的情况,if-then-else 必须检查每个条件直到最后一个,这是最坏情况 O(n)。但是对于 switch,我们是否总是期望它的复杂度为 O(1)?或者是否存在某种代码会导致其执行超出这个范围?

使用二分查找

我们将给出第三个示例,在 switch3.cpp 中展示 case 条件值之间的巨大差距,其中 switch 的执行行为类似于二分查找

int main3()
{
    int i =1;

    switch (i)
    {
        case 100: f1(); break;
        case 200: f2(); break;
        case 250: f2(); break;
        case 500: f1(); break;
        case 700: f2(); break;
        case 750: f2(); break;
        case 800: f2(); break;
        case 900: f1(); break;

        default: f3(); 
    }

    return 0;
}

通过生成汇编列表 switch3.asm,这次我们从下往上查看,先检查 case 操作

$LN9@main3:

; 8    :     {
; 9    :         case 100: f1(); break;
    call    ?f1@@YAXXZ                ; f1
    jmp    SHORT $LN10@main3

$LN8@main3:
; 10   :         case 200: f2(); break;
    call    ?f2@@YAXXZ                ; f2
    jmp    SHORT $LN10@main3

$LN7@main3:
; 11   :         case 250: f2(); break;
    call    ?f2@@YAXXZ                ; f2
    jmp    SHORT $LN10@main3

$LN6@main3:
; 12   :         case 500: f1(); break;
    call    ?f1@@YAXXZ                ; f1
    jmp    SHORT $LN10@main3

$LN5@main3:
; 13   :         case 700: f2(); break;
    call    ?f2@@YAXXZ                ; f2
    jmp    SHORT $LN10@main3

$LN4@main3:
; 14   :         case 750: f2(); break;
    call    ?f2@@YAXXZ                ; f2
    jmp    SHORT $LN10@main3

$LN3@main3:
; 15   :         case 800: f2(); break;
    call    ?f2@@YAXXZ                ; f2
    jmp    SHORT $LN10@main3

$LN2@main3:
; 16   :         case 900: f1(); break;
    call    ?f1@@YAXXZ                ; f1
    jmp    SHORT $LN10@main3

$LN1@main3:
; 17   : 
; 18   :         default: f3(); 
    call    ?f3@@YAXXZ                ; f3

$LN10@main3:
; 19   :     }
; 20   : 
; 21   :     return 0;

非常整洁,有八个 case 加上一个 default,我们可以轻松地将这部分写成一个九行列表,每行对应一个目标标签、一个 C++ case 和一个过程名

The Worst Case Diagram

除了 $LN9@main3$LN1@main3 之外,我们还有 $LN10@main3 来实现 break 语句。接下来,我们如何跳转到这些调用目标?看下面的代码

; 5    :     int i =1;

    mov    DWORD PTR _i$[ebp], 1

; 6    : 
; 7    :     switch (i)

    mov    eax, DWORD PTR _i$[ebp]
    mov    DWORD PTR tv64[ebp], eax
    cmp    DWORD PTR tv64[ebp], 700        ; 000002bcH
    jg     SHORT $LN14@main3
    cmp    DWORD PTR tv64[ebp], 700        ; 000002bcH
    je     SHORT $LN5@main3
    cmp    DWORD PTR tv64[ebp], 250        ; 000000faH
    jg     SHORT $LN15@main3
    cmp    DWORD PTR tv64[ebp], 250        ; 000000faH
    je     SHORT $LN7@main3
    cmp    DWORD PTR tv64[ebp], 100        ; 00000064H
    je     SHORT $LN9@main3
    cmp    DWORD PTR tv64[ebp], 200        ; 000000c8H
    je     SHORT $LN8@main3
    jmp    SHORT $LN1@main3
$LN15@main3:
    cmp    DWORD PTR tv64[ebp], 500        ; 000001f4H
    je     SHORT $LN6@main3
    jmp    SHORT $LN1@main3
$LN14@main3:
    cmp    DWORD PTR tv64[ebp], 750        ; 000002eeH
    je     SHORT $LN4@main3
    cmp    DWORD PTR tv64[ebp], 800        ; 00000320H
    je     SHORT $LN3@main3
    cmp    DWORD PTR tv64[ebp], 900        ; 00000384H
    je     SHORT $LN2@main3
    jmp    SHORT $LN1@main3

逻辑不难理解。首先,条件值 i 被保存在 _i$[ebp]tv64[ebp] 中。为了简化,我们只利用所有标签,移除开头的 "$" 和结尾的 "@main3"。重写片段如下

    i2 = i;
    if i2 > 700 goto LN14;
    if i2 == 700 goto LN5;
    if i2 > 250 goto LN15;
    if i2 == 250 goto LN7;
    if i2 == 100 goto LN9;
    if i2 == 200 goto LN8;
    goto LN1;
LN15:
    if i2 == 500 goto LN6;
    goto LN1;
LN14:
    if i2 == 750 goto LN4;
    if i2 == 800 goto LN3;
    if i2 == 900 goto LN2;
    goto LN1;

当然,编译器会优化代码,因为它首先选择 700 进行比较,而不是 switch 块中的起始 case 值 100。尽管这个 switch 是用十个 if-then 语句来实现九个条件的,但它实际上应用了二分查找机制。下图显示了上述代码的等效决策树,即您可能熟悉的二叉搜索树

The binary search mechanism Diagram

对于所有用黄色圆圈着色的内部节点,我们进行比较,而所有椭圆形叶子节点表示成功结束。每次失败都默认转到 LN1。比较节点的中序遍历序列是 100、200、250、500、700、750、800 和 900;而所有叶子的中序遍历序列恰好是 LN9LN8LN7LN6LN5LN4LN3LN2,如上表所示。本质上,这是一个复杂度为 O(log n) 的二分查找算法。当 i=1 时,它将经过前六次比较,然后到达 LN1default。但是当 i=500 时,只需要四次比较。

我们知道,二分查找的前提是输入数据已排序。我想知道这是否仅仅因为我在 switch3.cppswitch 中使用了升序的 case 值。这种好奇心引出了 switch4.cpp 中无序的条件。

int main4()
{
    int i =1;

    switch (i)
    {
    case 750: f2(); break;
    case 700: f2(); break;
    case 250: f2(); break;
    case 500: f1(); break;
    case 800: f2(); break;
    case 900: f1(); break;
    case 100: f1(); break;
    case 200: f2(); break;

        default: f3(); 
    }

    return 0;
}

令我惊讶的是,相同的二分查找策略出现在 switch4.asm 的代码中,具有与上面完全相同的决策树。唯一的区别是标签被重新编号了——这是非常合理的,因为我们刚刚重新排序了它们!您可以查看随附的 switch4.asm 以了解详细信息。

这个实验无疑给我们提供了一些线索,以找出编译器是如何神奇地对 case 条件值进行排序的。排序算法的复杂度高于 O(log n),并且在运行时不值得这样做。注意,排序在生成的汇编中是不可见的。这意味着排序不包含在运行时执行的汇编指令中。还请注意,所有条件值都是常量,在编译器将 C++ 转换为机器码之前就可以获得。现在,考虑到预处理器是合理的;所有已知 case 值以及排序都可以简单地在编译时完成。这就是为什么翻译的汇编代码只反映二分查找而没有排序代码。静态排序行为(与运行时动态行为相对)可以通过宏过程、汇编指令和运算符来实现。可以在文章末尾的参考文献中找到这样的预处理示例。

混合实现

现在,我可以展示一个将跳转表和二分查找结合起来的示例,如 switch5.cpp 所示。

int main5()
{
    int i =1;

    switch (i)
    {
        case 3: f1(); break;
        case 5: f2(); break;
        case 6: f2(); break;
        case 7: f2(); break;
        case 100: f1(); break;
        case 200: f2(); break;
        case 250: f2(); break;
        case 700: f2(); break;
        case 750: f2(); break;
        case 900: f2(); break;

        default: f3(); 
    }

    return 0;
}

这是此 switch 块在相应的 switch5.asm 中的实现方式

; 5    :     int i =1;

    mov    DWORD PTR _i$[ebp], 1

; 6    : 
; 7    :     switch (i)

    mov    eax, DWORD PTR _i$[ebp]
    mov    DWORD PTR tv64[ebp], eax
    cmp    DWORD PTR tv64[ebp], 100        ; 00000064H
    jg     SHORT $LN16@main5
    cmp    DWORD PTR tv64[ebp], 100        ; 00000064H
    je     $LN7@main5
    mov    ecx, DWORD PTR tv64[ebp]
    sub    ecx, 3
    mov    DWORD PTR tv64[ebp], ecx
    cmp    DWORD PTR tv64[ebp], 4
    ja     $LN1@main5
    mov    edx, DWORD PTR tv64[ebp]
    jmp    DWORD PTR $LN18@main5[edx*4]

$LN16@main5:
    cmp    DWORD PTR tv64[ebp], 700        ; 000002bcH
    jg     SHORT $LN17@main5
    cmp    DWORD PTR tv64[ebp], 700        ; 000002bcH
    je     SHORT $LN4@main5
    cmp    DWORD PTR tv64[ebp], 200        ; 000000c8H
    je     SHORT $LN6@main5
    cmp    DWORD PTR tv64[ebp], 250        ; 000000faH
    je     SHORT $LN5@main5
    jmp    SHORT $LN1@main5
$LN17@main5:
    cmp    DWORD PTR tv64[ebp], 750        ; 000002eeH
    je     SHORT $LN3@main5
    cmp    DWORD PTR tv64[ebp], 900        ; 00000384H
    je     SHORT $LN2@main5
    jmp    SHORT $LN1@main5

它由两部分组成,首先是一个一级跳转表,然后是二分查找代码。使用前面提到的 ii2 和标签命名,令 $LN18@main5 为表,$LN1@main5default。此片段执行以下操作

    i2 = i;
    if i2 > 100 goto LN16;
    if i2 == 100 goto LN7;    // go case 100
                              // now i2 < 100
    i2 = i2-3;                // map to zero-based index
    if i2 > 4 goto LN1;       // go default over 7 
    goto table[4*i2];         // go jump table

LN16:                         // binary search
    if i2 > 700 goto LN17;
    if i2 == 700 goto LN4;    // go case 700
    if i2 == 200 goto LN6;    // go case 200
    if i2 == 250 goto LN5;    // go case 250
    goto LN1;                 // go default
LN17:
    if i2 == 750 goto LN3;    // go case 750
    if i2 == 900 goto LN2;    // go default    
    goto LN1;

其中表定义为

$LN18@main5:
    DD    LN11    ; index 0, go case 3
    DD    LN1     ; index 1 for value 4, go default
    DD    LN10    ; index 2, go case 5
    DD    LN9     ; index 3, go case 6
    DD    LN8     ; index 4, go case 7

一个有趣的改变是在 switch5.cpp 中删除了 case 6。仅仅因为这个原因,混合实现就变成了两级跳转表和二分查找的组合。详情请参阅可下载示例中的 switch6.cppswitch6.asm。如果您尝试以某种顺序添加额外的 case,您会发现两个独立的跳转表与二分查找结合起来。

更多问题和扩展

现在您已经通过一些典型示例了解了 switch 的一些知识。您现在应该理解为什么 C/C++ switch 只支持整数数据类型作为条件表达式,例如 char 和枚举类型,而不是 float 点数或 string 类型。除此之外,可能还会出现更多直观的问题

  • 是否需要按条件值顺序维护 jump 表中的 case?
  • 如果我们使用负整数作为 case 值怎么办?
  • 如果 default 标签缺失,或者出现在块中的任何位置而不是最后怎么办?

我相信您可以通过分析包含这些问题的 C++ 代码的汇编列表来回答这些问题。为了方便起见,我在示例中附上了 switch7.cpp 及其 switch7.asm

int main7()
{
    int i =15;

    switch (i)
    {
        case 2: f2(); break;
        case 1: f1(); break;
        case 5: f1(); break;
        case 10: f1(); break;
        case 7: f2(); break;
        default: f3(); break;
        case -3: f2(); break;
        case 12: f1(); break;
        case 17: f2(); break;
        case 18: f1(); break;
    }

    return 0;
}

尽管在这些示例中 switch 的性能看起来比 if-than-else 更好,但我们仍然有一些未解决的问题。显然,在实践中不可能枚举所有 switch 的执行方式。对 switch 实现的全面分析应该由编译器开发人员编写,而不是由盲目的黑盒测试人员编写。因此,我们无法确定 switch 执行中的最坏情况,它是否会达到 O(n)。我们也不知道在什么条件下,编译器会选择一种实现而不是另一种,或者甚至是我们未提及的其他方法。

作为读者在讨论中的一个例子,一个担忧是上述跳转表在实现稀疏填充的 switch case 时会造成内存浪费。对于只有两个 case 条目 1 0x7fffffff 的极端示例,如下所示?编译器会消耗大部分无用的条目在跳转表中吗? 

int main8()
{
    int i =3;
 
    switch (i)
    {
        case 1: f1(); break;
        case 0x7fffffff: f2(); break;
        default: f3(); break;
    }
 
    return 0;
} 

这对我们聪明的编译器来说并非如此。如前所述,我们不知道编译器如何选择一种实现而不是另一种。请参阅这里,这个双 case 示例没有选择跳转表。它只是简单地将 switch 转换为 if-then,而没有消耗任何额外的内存: 

; 5    :     int i =3;
	mov	DWORD PTR _i$[ebp], 3
 
; 6    : 
; 7    :     switch (i)

	mov	eax, DWORD PTR _i$[ebp]
	mov	DWORD PTR tv64[ebp], eax
	cmp	DWORD PTR tv64[ebp], 1
	je	SHORT $LN3@main8
	cmp	DWORD PTR tv64[ebp], 2147483647		; 7fffffffH
	je	SHORT $LN2@main8
	jmp	SHORT $LN1@main8
 
$LN3@main8:
; 8    :     {
; 9    :         case 1: f1(); break;

	call	?f1@@YAXXZ				; f1
	jmp	SHORT $LN4@main8
 
$LN2@main8:
; 10   :         case 0x7fffffff: f2(); break;

	call	?f2@@YAXXZ				; f2
	jmp	SHORT $LN4@main8
 
$LN1@main8:
; 11   :         default: f3(); break;

	call	?f3@@YAXXZ				; f3
$LN4@main8:
 
; 12   :     }
; 13   : 
; 14   :     return 0; 

同样,通过这种黑盒测试,无法影响或预测编译器的选择。  根据问题的不同,无论是稀疏还是密集填充的 switch case, 编译器显然都能很好地适应它们。 

总结  

通过仔细审查上述示例,我们揭示了一些您可能不知道的关于运行时 switch 的知识。要在 Visual Studio 中分析 C/C++ 程序,我们可以进行静态和动态分析。对于本文,我们利用编译器生成的汇编列表。另一方面,我们也可以在调试时使用 VS 反汇编窗口监控二进制执行,其中列表中的标签和过程被转换为内存地址。这样,您可以跟踪寄存器和内存分配,以理解数据端序、堆栈帧等。在这里,对我们来说,汇编列表似乎足以且易于指示从此处到那里的跳转。可下载的 zip 文件包含七个示例,其中包含 .cpp 源代码和 VS 2010 中的 .asm 列表。较早版本的 VS 也可以生成相同的列表。

本文不涉及 .NET 或 C# 等热门技术。汇编语言相对传统,如今已经不那么引人注目了。然而,在应用程序中,不同类型的汇编语言在新设备开发中起着重要作用。在学术上,汇编语言编程被认为是计算机科学中的一门要求很高的课程。我在富勒顿学院教授汇编语言编程,CSCI 241,其中高级语言与底层执行交互的概念是教学的重要组成部分。特别是,学生们感兴趣的主要主题之一是如何 C/C++ 或 Java 在机器上运行,由低级数据结构和算法表示。因此,我希望本文也能作为学生解决问题的基本示例之一。欢迎任何评论和建议。

致谢

我谨此感谢 wtwhite、Charvak Karpe 和 Harold Aptroot 就二分查找进行的宝贵讨论。没有他们,“使用二分查找”一节就不会像今天这样。

参考文献

历史

  • 2010年8月9日 -- 发布原始版本
  • 2010年9月8日 -- 修改并重写了一些章节
    • 将“switch 语句的最坏情况”一节替换为“使用二分查找”。添加了二叉搜索树图像。
    • 添加了“混合实现”一节 
    • 重写了“更多问题和扩展”一节
    • 调整了前两个章节之间的段落
    • 在“两级 Jump 表”一节中添加了对负索引的解释
  • 2010年9月28日 -- 文章更新
    • 澄清了关于二分查找排序先决条件未解答的问题。 
    • 添加了致谢和参考文献
  •  2012年12月19日 -- 纳入了 Christian Scholze 的内存讨论示例
© . All rights reserved.