我们或许知道if…else if…与switch的功能基本相似,而switch也完全可以转换成if…else if…的结构,并且switch总体来说效率要高于if…else if…。但是很少有人知道总体来说if…else if…的效率为什么低于switch,以及为什么说总体来说switch效率高,而不是一定高,以及什么时候效率一定比if…else if…高。今天我们就从反汇编的层面来看看switch语句的多种结构与其效率分析(测试采用VC++6.0集成环境)。
1、普通结构:
所谓普通结构,指的是和普通的if…else if…反汇编结构是完全相同的,在遇到正确匹配条件之前是需要一直去判断的,直到条件匹配后面的指令才不再继续执行。如下所示:
#include "stdafx.h"
int main(int argc, char* argv[])
{
int s = 5;
switch(s){
case 1:
printf("1\n");
break;
case 2:
printf("2\n");
break;
case 3:
printf("3\n");
break;
default:
printf("error\n");
break;
}
return 0;
}
对应的反汇编代码如下所示:
;main:
00401010 push ebp
00401011 mov ebp,esp
00401013 sub esp,48h
00401016 push ebx
00401017 push esi
00401018 push edi
00401019 lea edi,[ebp-48h]
0040101C mov ecx,12h
00401021 mov eax,0CCCCCCCCh
00401026 rep stos dword ptr [edi]
00401028 mov dword ptr [ebp-4],5
0040102F mov eax,dword ptr [ebp-4]
00401032 mov dword ptr [ebp-8],eax
;if(s == 1),即case 1
00401035 cmp dword ptr [ebp-8],1
00401039 je main+39h (00401049)
;else if(s == 2),即case 2
0040103B cmp dword ptr [ebp-8],2
0040103F je main+48h (00401058)
;else if(s == 3),即case 3
00401041 cmp dword ptr [ebp-8],3
00401045 je main+57h (00401067)
;else,即default
00401047 jmp main+66h (00401076)
;case 1:
00401049 push offset string "1\n" (0042202c)
0040104E call printf (004010f0)
00401053 add esp,4
;break;
00401056 jmp main+73h (00401083)
;case 2:
00401058 push offset string "2\n" (00422028)
0040105D call printf (004010f0)
00401062 add esp,4
;break;
00401065 jmp main+73h (00401083)
;case 3:
00401067 push offset string "5\n" (00422024)
0040106C call printf (004010f0)
00401071 add esp,4
;break;
00401074 jmp main+73h (00401083)
;default:
00401076 push offset string "error\n" (0042201c)
0040107B call printf (004010f0)
00401080 add esp,4
;break;
00401083 xor eax,eax
00401085 pop edi
00401086 pop esi
00401087 pop ebx
00401088 add esp,48h
0040108B cmp ebp,esp
0040108D call __chkesp (00401170)
00401092 mov esp,ebp
00401094 pop ebp
00401095 ret
该结构产生的条件(情况):当case分支语句比较少(VC++6.0中是少于等于4条时),我们测试用的是三条,则switch…case被反汇编成和if…else if…相同的结构。那么在该情况在switch…case效率和if…else if…的效率是一样的。
2、大表结构(索引表):
然而我们说switch也有效率高于if…else if…的情况,即下面所示的情况,举例说明:
#include "stdafx.h"
int main(int argc, char* argv[])
{
int s = 5;
switch(s){
case 101:
printf("101\n");
break;
case 102:
printf("102\n");
break;
case 103:
printf("103\n");
break;
case 104:
printf("104\n");
break;
default:
printf("error\n");
break;
}
return 0;
}
对应汇编代码如下所示:
;main:
;提升栈、保存现场、初始化函数栈等操作(省略)
00401028 mov dword ptr [ebp-4],5
;eax存储s,ecx存储101
0040102F mov eax,dword ptr [ebp-4]
00401032 mov dword ptr [ebp-8],eax
00401035 mov ecx,dword ptr [ebp-8]
;由于是从101开始的连续常量,所以需要减去65H(即101)则索引(下标)从0开始
00401038 sub ecx,65h
0040103B mov dword ptr [ebp-8],ecx
;将减去101的case值与3比较(0、1、2、3、default)
;大于3则为默认,直接跳转到default指令处
0040103E cmp dword ptr [ebp-8],3
00401042 ja $L541+0Fh (0040108a)
;这里jmp指令是核心,"edx*4+4010AAh"是case分支指令地址在表中的下标,即4010AAh是索引表的首地址
00401044 mov edx,dword ptr [ebp-8]
00401047 jmp dword ptr [edx*4+4010AAh]
;case 101
$L535:
0040104E push offset string "101\n" (00422fbc)
00401053 call printf (004010f0)
00401058 add esp,4
0040105B jmp $L541+1Ch (00401097)
;case 102
$L537:
0040105D push offset string "1\n" (0042202c)
00401062 call printf (004010f0)
00401067 add esp,4
0040106A jmp $L541+1Ch (00401097) ;break
;case 103
$L539:
0040106C push offset string "3\n" (00422024)
00401071 call printf (004010f0)
00401076 add esp,4
00401079 jmp $L541+1Ch (00401097) ;break
;case 104
$L541:
0040107B push offset string "104\n" (00422fb4)
00401080 call printf (004010f0)
00401085 add esp,4
00401088 jmp $L541+1Ch (00401097) ;break
;default
0040108A push offset string "error\n" (0042201c)
0040108F call printf (004010f0)
00401094 add esp,4 ;不需要jmp,直接可达到break的效果
00401097 xor eax,eax
;销毁函数栈、恢复现场等操作(省略)
下面我们来具体分析:为什么叫大表结构?首先我们根据上面代码中标出的重要的那条jmp指令:jmp dword ptr [edx*4+4010AAh]来查看基址4010AAh的内存情况:
从0X004010AAh开始的内存中形成了一张所谓的“表”(该表由编译器产生),由于表中每个元素以四字节为单位,存储了对应case语句的地址,则称为大表(小表是1个字节)。而该地址是根据case n:中的n来计算的,比如case 104,所以edx = 104 - 101=3(sub edx,65H),3*4+4010AAh=4010B6则jmp dword ptr [edx*4+4010AAh]即jmp dword ptr [4010B6],而0X004010B6处存放的就是满足“case 104:”条件的指令的地址0X00401078,eip被修改为0X004010B6处存放的值即eip = 0X00401078,因此CPU下一步执行时就从0X00401078处开始执行,执行完毕后,一个jmp 00401097使得swtich语句结束。无论switch(s)的s是多少只需要提前做好初始化,然后一步jmp dword ptr [edx*4+4010AAh]指令就可以找到对应case的指令。而不需要一个一个去判断,节省了CPU指令周期,而其时间节省的代价是内存的付出。这也是为什么sub edx,65H。因为在现有规律下没有必要浪费不必要浪费的内存。
大表结构的另一种情况:
但是如果case后面的常量如果有不连续的情况,又该如何?我们先来看看比较轻微的不连续情况,即不连续的case值比较少时的情况,case分支依旧为4个,分别为1001,1002,1003,1005(1004为不连续点):
减去的值不再是65H,而是1001即3E9H,此时依旧为大表结构,但是1004不存在,其处理方式观察下图:
由于case 1004不存在,所以1004属于默认default之列,故用1004索引出来的表中的地址即为default处的地址,这里只有一个不连续点,但是1004依旧占用了大表的4个字节(4个case,5个地址元素)。同理在不连续情况比较少的时候,编译器都会将这些连续值中间“断开”的少部分(不止一个)填充为default的地址,虽然内存有些浪费但是换得的效率依旧是比较划算的。如下为case 1001、1002、1003、1007,而缺少1004、1005、1006三个值的大表情况(4个case,7个地址元素):
总结一下:在case分语句比较多的时候if…else if…结构明显效率不高的情况下,如果case的常量是连续的且有小部分间断,则采用大表结构(即四个字节存储case跳转的指令地址)来编译switch…case语句。间断处填充的地址则为default处执行的指令地址。这一些都是编译器根据相应的算法自行识别后做的。
3、大表+小表结构:
但是如果间断点比较多,大表中重复填充default地址的4字节空间比较多时,就显得有点浪费空间。这时候就需要引入小表,以下例来做分析:
#include "stdafx.h"
int main(int argc, char* argv[])
{
int s = 5;
switch(s){
case 1001:
printf("1001\n");
break;
case 1002:
printf("1002\n");
break;
case 1003:
printf("1003\n");
break;
case 1007:
printf("1007\n");
break;
case 1009:
printf("1009\n");
break;
case 1012:
printf("1012\n");
break;
case 1014:
printf("1014\n");
break;
case 1017:
printf("1017\n");
break;
default:
printf("error\n");
break;
}
return 0;
}
部分汇编代码如下:
;main:
;提升栈、保存现场、初始化函数栈等操作(省略)
0040D798 mov dword ptr [ebp-4],5
0040D79F mov eax,dword ptr [ebp-4]
0040D7A2 mov dword ptr [ebp-8],eax
0040D7A5 mov ecx,dword ptr [ebp-8]
;该处依旧是为了减小大表的内存占用,减去1001
0040D7A8 sub ecx,3E9h
0040D7AE mov dword ptr [ebp-8],ecx
;由于对于连续的大表来说,总共有1007-1001=16条case语句(其实只有8条有效的case)
;所以如果减去1001后的索引值依旧大于10H即16则超过大表索引范围,直接跳去default
0040D7B1 cmp dword ptr [ebp-8],10h
0040D7B5 ja $L549+0Fh (0040d845)
;依旧是根据基址来索引,但是此时的索引是有一点不同的,不同处在下面具体分析
0040D7BB mov eax,dword ptr [ebp-8]
0040D7BE xor edx,edx
0040D7C0 mov dl,byte ptr (0040d889)[eax]
0040D7C6 jmp dword ptr [edx*4+40D865h]
;case 1001
$L535:
0040D7CD push offset string "1001\n" (00422fdc)
0040D7D2 call printf (004010f0)
0040D7D7 add esp,4
0040D7DA jmp $L549+1Ch (0040d852)
;case 1002
$L537:
0040D7DC push offset string "1002\n" (00422fd4)
0040D7E1 call printf (004010f0)
0040D7E6 add esp,4
0040D7E9 jmp $L549+1Ch (0040d852)
;case 1003
$L539:
0040D7EB push offset string "1003\n" (00422fcc)
0040D7F0 call printf (004010f0)
0040D7F5 add esp,4
0040D7F8 jmp $L549+1Ch (0040d852)
;case 1007
$L541:
0040D7FA push offset string "1007\n" (00422fc4)
0040D7FF call printf (004010f0)
0040D804 add esp,4
0040D807 jmp $L549+1Ch (0040d852)
;case 1009
$L543:
0040D809 push offset string "1009\n" (00422fbc)
0040D80E call printf (004010f0)
0040D813 add esp,4
0040D816 jmp $L549+1Ch (0040d852)
;case 1012
$L545:
0040D818 push offset string "1012\n" (0042202c)
0040D81D call printf (004010f0)
0040D822 add esp,4
0040D825 jmp $L549+1Ch (0040d852)
;case 1014
$L547:
0040D827 push offset string "1014\n" (00422024)
0040D82C call printf (004010f0)
0040D831 add esp,4
0040D834 jmp $L549+1Ch (0040d852)
;case 1017
$L549:
0040D836 push offset string "104\n" (00422fb4)
0040D83B call printf (004010f0)
0040D840 add esp,4
0040D843 jmp $L549+1Ch (0040d852)
;default
0040D845 push offset string "error\n" (0042201c)
0040D84A call printf (004010f0)
0040D84F add esp,4
0040D852 xor eax,eax
;销毁函数栈、恢复现场等操作(省略)
我们具体来分析在索引处的四行代码:
0040D7BB mov eax,dword ptr [ebp-8]
0040D7BE xor edx,edx
0040D7C0 mov dl,byte ptr (0040d889)[eax]
0040D7C6 jmp dword ptr [edx*4+40D865h]
[ebp-8]中现在存放的是switch(s)中的s减去1001之后的值,mov dl,byte ptr (0040d889)[eax]指令则是将[eax+0040d889H]处的值复制到dl处。我们看看对应的内存情况:
黑色选中部分即所谓小表(小表首地址即0040d889),而小表上方为大表(大表首地址为0040D865),我们的case分支总共有8条,所以大表中有九个地址(包括了default)。而我们mov dl,byte ptr (0040d889)[eax]是将小表中的值作为大表的索引,即case常量处理后的值为小表的索引,小表所索引到的值为大表的索引,大表所索引到的值为case的指令地址。比如:case 1009,常量处理后,eax=1009 - 1001 = 8,则mov dl,byte ptr (0040d889)[eax],获取0040d889+8=0040d891处的值,dl=04H,edx=0X00000004H,所以jmp dword ptr [edx*4+40D865h]修改eip的值为edx*4+40D865h处的值,即4*4H+0040D865h=0040D875H,即00D04809处的值,eip = 00D04809。而我们可以从上面反汇编代码中获取:
;case 1009,eip = 00D04809从此处开始执行
$L543:
0040D809 push offset string "1009\n" (00422fbc)
0040D80E call printf (004010f0)
0040D813 add esp,4
0040D816 jmp $L549+1Ch (0040d852)
则下一条要执行的指令即为1009的地址,经过case常量、小表、大表的索引最终成功修改eip为准确的要执行的指令地址。而对于空出来的间断点,其在小表中的值均为08H,4*8H+0040D865h=0040D885H,该内存存储的均是default的地址,所以说小表中有效的case索引从00开始01、02、03、依次增加,而间断处均填充有效的case分支数(本例为8条case,即填充8),这样就使得原本只使用大表的内存(需要16*4=64B)节省了16字节(小表只需要:8*4+16*1=48B),当分支越多并且间断点越多节省相对也更多。但是小表也有局限性,当分支间隔大于256时,小表一个字节就存不下default的偏移量了(据说能设计出间隔大于256的cae常量的程序员应该直接拉出去…(枪毙))。
4、树形结构:
树形结构就是小表不能表示时,即间断大于256时的一种反汇编结构。我们以下例来分析:
该代码中switch有0、500、100、1500、2000、2500、3000、3500、4000几条case分支(间断大于256),反汇编如下(我们要分析的是判断阶段的树形结构):
main:
;提升栈、保存现场、初始化函数栈等操作(省略)
;判断阶段
0040102F mov eax,dword ptr [ebp-4]
00401032 mov dword ptr [ebp-8],eax
00401035 cmp dword ptr [ebp-8],7D0h
0040103C jg main+73h (00401083)
0040103E cmp dword ptr [ebp-8],7D0h
00401045 je main+0F1h (00401101)
0040104B cmp dword ptr [ebp-8],3E8h
00401052 jg main+65h (00401075)
00401054 cmp dword ptr [ebp-8],3E8h
0040105B je main+0D3h (004010e3)
00401061 cmp dword ptr [ebp-8],0
00401065 je main+0B2h (004010c2)
00401067 cmp dword ptr [ebp-8],1F4h
0040106E je main+0C4h (004010d4)
00401070 jmp main+13Ch (0040114c)
00401075 cmp dword ptr [ebp-8],5DCh
0040107C je main+0E2h (004010f2)
0040107E jmp main+13Ch (0040114c)
00401083 cmp dword ptr [ebp-8],0DACh
0040108A jg main+0A0h (004010b0)
0040108C cmp dword ptr [ebp-8],0DACh
00401093 je main+11Eh (0040112e)
00401099 cmp dword ptr [ebp-8],9C4h
004010A0 je main+100h (00401110)
004010A2 cmp dword ptr [ebp-8],0BB8h
004010A9 je main+10Fh (0040111f)
004010AB jmp main+13Ch (0040114c)
004010B0 cmp dword ptr [ebp-8],0FA0h
004010B7 je main+12Dh (0040113d)
004010BD jmp main+13Ch (0040114c)
;具体处理阶段
004010C2 push offset string "0\n" (00422064)
004010C7 call printf (004011d0)
004010CC add esp,4
004010CF jmp main+149h (00401159)
004010D4 push offset string "500\n" (0042205c)
004010D9 call printf (004011d0)
004010DE add esp,4
004010E1 jmp main+149h (00401159)
004010E3 push offset string "1000\n" (00422054)
004010E8 call printf (004011d0)
004010ED add esp,4
004010F0 jmp main+149h (00401159)
004010F2 push offset string "1500\n" (0042204c)
004010F7 call printf (004011d0)
004010FC add esp,4
004010FF jmp main+149h (00401159)
00401101 push offset string "2000\n" (00422044)
00401106 call printf (004011d0)
0040110B add esp,4
0040110E jmp main+149h (00401159)
00401110 push offset string "2500\n" (0042203c)
00401115 call printf (004011d0)
0040111A add esp,4
0040111D jmp main+149h (00401159)
0040111F push offset string "3000\n" (00422034)
00401124 call printf (004011d0)
00401129 add esp,4
0040112C jmp main+149h (00401159)
0040112E push offset string "3500\n" (0042202c)
00401133 call printf (004011d0)
00401138 add esp,4
0040113B jmp main+149h (00401159)
0040113D push offset string "4000\n" (00422024)
00401142 call printf (004011d0)
00401147 add esp,4
0040114A jmp main+149h (00401159)
0040114C push offset string "error\n" (0042201c)
00401151 call printf (004011d0)
00401156 add esp,4
00401159 xor eax,eax
;销毁函数栈、恢复现场等操作(省略)
根据判断阶段的分析,由jg指令分,可以将0、500、100、1500、2000、2500、3000、3500、4000分为以下的树形结构:
根据树形结构的每个节点再分为等于和不等于两种情况,进行处理。但是由于该情况基本不常见(写出这样的代码似乎应该已经被拉了出去……),所以我们不在仔细分析。而大表的小表是比较常见的两种结构,需要熟练掌握。