c++中switch语句的反汇编以及优化
概述c++的switch语句在汇编层面有着独特的表现形式以及优化方案,这篇文章就带大家一起探索其中的奥秘。本文的代码均使用vs2022中编写并编译。
温馨提示,本文代码较多,建议使用PC进行阅读。建议大家跟着动手操作一遍,更有效果。
我们把switch语句分成了三种情况
[*]case情况小于4
[*]线性switch语句
[*]非线性witch语句
[*]case的情况大于255
分成这些情况有助于大家由浅至深理解switch语句的汇编表现形式以及编译器对其的优化。下面我们来逐个情况分析。
case情况小于4
我们先讨论最特殊的情况,先给出源代码:
#include <stdio.h>
int main(int argc, char argv[])
{
int n;
scanf_s("%d", &n);
switch (n)
{
case 12:
printf("n == 13");
break;
case 9:
printf("n == 9");
break;
case 100:
printf("n == 100");
break;
default:
break;
}
return 0;
}代码十分简单,由三种case和一个default组成。在switch处下断点,我们先在debug模式下编译,并查看从switch语句开始处的反汇编代码。
00007FF76CBB19DE 8B 45 04 mov eax,dword ptr
00007FF76CBB19E1 89 85 D4 00 00 00 mov dword ptr ,eax
00007FF76CBB19E7 83 BD D4 00 00 00 09 cmp dword ptr ,9
00007FF76CBB19EE 74 23 je __$EncStackInitStart+7Ch (07FF76CBB1A13h)
00007FF76CBB19F0 83 BD D4 00 00 00 0C cmp dword ptr ,0Ch
00007FF76CBB19F7 74 0B je __$EncStackInitStart+6Dh (07FF76CBB1A04h)
00007FF76CBB19F9 83 BD D4 00 00 00 64 cmp dword ptr ,64h
00007FF76CBB1A00 74 20 je __$EncStackInitStart+8Bh (07FF76CBB1A22h)
00007FF76CBB1A02 EB 2B jmp __$EncStackInitStart+98h (07FF76CBB1A2Fh)
00007FF76CBB1A04 48 8D 0D 1D 93 00 00 lea rcx,
00007FF76CBB1A0B E8 8A F7 FF FF call printf (07FF76CBB119Ah)
00007FF76CBB1A10 90 nop
00007FF76CBB1A11 EB 1C jmp __$EncStackInitStart+98h (07FF76CBB1A2Fh)
00007FF76CBB1A13 48 8D 0D 1A 93 00 00 lea rcx,
00007FF76CBB1A1A E8 7B F7 FF FF call printf (07FF76CBB119Ah)
00007FF76CBB1A1F 90 nop
00007FF76CBB1A20 EB 0D jmp __$EncStackInitStart+98h (07FF76CBB1A2Fh)
00007FF76CBB1A22 48 8D 0D 17 93 00 00 lea rcx,
00007FF76CBB1A29 E8 6C F7 FF FF call printf (07FF76CBB119Ah)
00007FF76CBB1A2E 90 nop
00007FF76CBB1A2F 33 C0 xor eax,eax程序要求先从控制台输入n的值,笔者这里输入了200。随后代码运行至switch语句处。从汇编代码中不难发现,在case情况小于4的时候,汇编代码只是将n按顺序与case的情况进行比较,匹配成功则直接跳转到对应的语句块处运行,执行完对应情况的语句之后,使用jmp跳转到整个switch语句的结束地址处。我们把编译模式切换到Release,发现汇编代码依然如此。
再会看汇编代码,我们发现匹配比较的代码集中在一处,各个情况执行的代码又集中在另一处,这其实解释了switch语句的穿透问题,如果不加break语句,就会执行之后所有case语句块。而加上了break之后,每个case的语句块的汇编代码中就多了jmp指令跳转到switch语句块的结束地址处,就不会有穿透问题了。
从汇编代码中来看,switch语句的汇编形式似乎并没有什么特殊之处,release和debug产生的汇编代码也没有什么不同,似乎没有优化之处。但是请注意,这只是switch语句最简单的形式,接下来我们讨论switch语句的第二种形式。
线性switch
先来解释什么是线性switch。如果case中的情况近似满足线性关系时(1,2,3,4,5),这种形式switch语句就是线性switch。比如下面的代码:
#include <stdio.h>
int main(int argc, char* argv[]) {
int n = 1;
if (scanf_s("%d", &n) != 1) {
printf("输入无效,请输入一个整数。\n");
return 1;
}
switch (n) {
case 1:
printf("n == 1");
break;
case 2:
printf("n == 2");
break;
case 3:
printf("n == 3");
break;
case 5:
printf("n == 5");
break;
case 6:
printf("n == 6");
break;
case 7:
printf("n == 7");
break;
}
return 0;
}代码中的case列表为:1,2,3,5,6,7。近似满足线性关系。依然在switch语句的入口处下断点,编译选项为debug,输入n的值为7。得到从switch语句块开始的反汇编代码如下所示,建议读者先粗略浏览一下代码:
00007FF68EA11A00 8B 45 04 mov eax,dword ptr
00007FF68EA11A03 89 85 D4 00 00 00 mov dword ptr ,eax
00007FF68EA11A09 8B 85 D4 00 00 00 mov eax,dword ptr
00007FF68EA11A0F FF C8 dec eax
00007FF68EA11A11 89 85 D4 00 00 00 mov dword ptr ,eax
00007FF68EA11A17 83 BD D4 00 00 00 06 cmp dword ptr ,6
00007FF68EA11A1E 77 72 ja $LN10+0Dh (07FF68EA11A92h)
00007FF68EA11A20 48 63 85 D4 00 00 00 movsxd rax,dword ptr
00007FF68EA11A27 48 8D 0D D2 E5 FE FF lea rcx,
00007FF68EA11A2E 8B 84 81 C4 1A 01 00 mov eax,dword ptr
00007FF68EA11A35 48 03 C1 add rax,rcx
00007FF68EA11A38 FF E0 jmp rax
00007FF68EA11A3A 48 8D 0D 0B 93 00 00 lea rcx,
00007FF68EA11A41 E8 54 F7 FF FF call printf (07FF68EA1119Ah)
00007FF68EA11A46 90 nop
00007FF68EA11A47 EB 49 jmp $LN10+0Dh (07FF68EA11A92h)
00007FF68EA11A49 48 8D 0D 04 93 00 00 lea rcx,
00007FF68EA11A50 E8 45 F7 FF FF call printf (07FF68EA1119Ah)
00007FF68EA11A55 90 nop
00007FF68EA11A56 EB 3A jmp $LN10+0Dh (07FF68EA11A92h)
00007FF68EA11A58 48 8D 0D FD 92 00 00 lea rcx,
00007FF68EA11A5F E8 36 F7 FF FF call printf (07FF68EA1119Ah)
00007FF68EA11A64 90 nop
00007FF68EA11A65 EB 2B jmp $LN10+0Dh (07FF68EA11A92h)
00007FF68EA11A67 48 8D 0D F6 92 00 00 lea rcx,
00007FF68EA11A6E E8 27 F7 FF FF call printf (07FF68EA1119Ah)
00007FF68EA11A73 90 nop
00007FF68EA11A74 EB 1C jmp $LN10+0Dh (07FF68EA11A92h)
00007FF68EA11A76 48 8D 0D EF 92 00 00 lea rcx,
00007FF68EA11A7D E8 18 F7 FF FF call printf (07FF68EA1119Ah)
00007FF68EA11A82 90 nop
00007FF68EA11A83 EB 0D jmp $LN10+0Dh (07FF68EA11A92h)
00007FF68EA11A85 48 8D 0D E8 92 00 00 lea rcx,
00007FF68EA11A8C E8 09 F7 FF FF call printf (07FF68EA1119Ah)
00007FF68EA11A91 90 nop
00007FF68EA11A92 33 C0 xor eax,eax 这里的汇编代码令人困惑,明明有如此多种case情况,为何汇编代码中没有很多的比较语句,而且为何要让n的值减一,并使用cmp指令让n与一个毫不相干的数字6进行比较。更令人困惑的是,在与6比较完之后,又经过了一系列运算得到一个地址,直接使用jmp指令跳转到那个地址(调试程序之后我们发现跳转到的地址就是正确的case语句块的地址)。接下来为大家解惑。
直接说重点:对于线性switch语句,编译器采用了地址数组的优化方案。编译器将n的值作为下标,用来访问有各个case代码块偏移首地址组成的数组,用访问到的偏移首地址加上模块加载地址的到case语句块的绝对地址,直接jmp过去即可。如下图所示:
注意,与源代码中的case情况相比,编译器添加了一个源代码中不存在case 3的情况,这是为了形式线性的地址数组所做的添加,可以看到case 3语句块的首地址是指向switch语句的结束地址的,这与源代码中不存在case 3的事实相符合。
因为数组下标从0开始,所以对于n要自减一。然后用自减后的n与6进行比较,6是7自减一得到的,代表数组最后一个元素的下标,如果n大于6,直接跳转到switch语句块的结束地址或default语句块的首地址(如果有的话)。
00007FF68EA11A00 8B 45 04 mov eax,dword ptr
00007FF68EA11A03 89 85 D4 00 00 00 mov dword ptr ,eax
00007FF68EA11A09 8B 85 D4 00 00 00 mov eax,dword ptr
00007FF68EA11A0F FF C8 dec eax
00007FF68EA11A11 89 85 D4 00 00 00 mov dword ptr ,eax
00007FF68EA11A17 83 BD D4 00 00 00 06 cmp dword ptr ,6
00007FF68EA11A1E 77 72 ja $LN10+0Dh(07FF68EA11A92h)若跳转不成功,则开始计算正确的case语句块的偏移首地址。计算过程如下,首先加载了一个符号名称为__ImageBase的地址,如果了解程序的装载过程就知道这个地址就是当前.exe文件在虚拟地址空间的首地址。随后计算了一条表达式:__ImageBase+n*4+0x11AC4。在这条表达式中,0x11AC4代表地址数组的首地址,该地址是相对于文件起始地址或当前模块的加载地址(在这里为__ImageBase)。我们在内存窗口中定位到该地址处:
https://www.cnblogs.com/%E5%86%85%E5%AD%98%E4%B8%AD%E7%9A%84%E5%9C%B0%E5%9D%80%E6%95%B0%E7%BB%84.png
图中红色的圈代表地址数组的起始位置(00007FF785FC11A4)和结束位置。观察地址数组,发现它的每个元素占四个字节,共七个元素。这与上文画出的地址数组图情况一致。这也解释了为什么n要乘以4:定位到正确的地址的第一个字节。。表达式还加上了__ImageBase,这是为了定位到正确的case语句块的绝对地址。我们输入的n是7,对应数组中case 7代码块的偏移地址,也就是00001181(因为Intel是小端存储所以逆序),加上模块加载地址后得到了case 7语句块的绝对地址。大家可以用数组中的偏移地址对应一下下图中的指令的地址低两位,就明白是怎么一回事了。
因为最开始给出的汇编代码是在vs2022中的,截图中的汇编代码和内存是在x96中的,所以两者的地址看起来不一样,但原理是一样的。得到绝对地址之后使用无条件跳转指令跳转过去即可。
非线性switch
对于难以形成线性地址地址数组的switch语句,编译器采用了另一种优化方案。先给出程序源代码:
#include <stdio.h>
int main(int argc, char* argv[]) {
int n = 0;
scanf_s("%d", &n);
switch (n) {
case 1:
printf("n == 1");
break;
case 2:
printf("n == 2");
break;
case 3:
printf("n == 3");
break;
case 5:
printf("n == 5");
break;
case 6:
printf("n == 6");
break;
case 255:
printf("n == 255");
break;
}
return 0;
}这份代码中case的情况比较稀疏,如果还像之前一样采用为1到255中case情况创建一个大小为255*4个字节的地址数组,这无疑是十分浪费空间的。为此编译器采用了索引表加地址表的优化方式。
先粗略解释一下这种优化方式。索引表是为访问地址数组提供索引的数组,它的大小为最大case的值减去最小case的值,它存储着地址数组的下标。我们使用n作为下标访问索引表,得到的元素既是地址表数组的下标。随后就是和线性switch一样的计算过程,得到最终的case语句块的绝对地址,使用无条件跳转即可。如下图:
为了避免纸上谈兵,我们在release模式下生成可执行文件,使用x96反汇编看看具体的细节。同样我们只关注switch语句的部分。在这里,我们输入n的值为200。
我们发现,在汇编代码的开始部分,依然是将n的值减一后与0xFE(254)进行比较,若大于则直接跳转到switch语句的结束地址处。
若跳转失败的话,就开始利用n-1访问索引表。为了访问索引表,我们需要取得索引表的起始地址,在这里是用模块加载地址(rdx)加上0x11A0得到的。因为索引表中每个元素大小为1,所以直接加上n的值,得到了正确的元素的地址。我们利用该元素执行和线性switch一样的计算,即可得到正确的case语句块的地址。
在内存中,我们访问索引表的首地址,如下图,可以发现和上图是一样的。索引表的大小是最大case的值减去最小case的值的差。
可以看到,使用这种优化方案,编译器无需为每种case情况都生成一个对应的地址,这大大减小了地址数组的长度。
case的数量大于255
上一章中说过:索引表的元素大小为一个字节,也就是说索引表最多只能表示255中case的情况,如果case的情况数目大于255,编译器又该如何处理呢?
在这种情况下,编译器可采用另一种优化方案——判定树,即将每个case值作为一个节点,找到这些节点的中间值作为根节点,以此形成一棵二叉平衡树,以每个节点为判定值,大于和小于关系分别对应左子树和右子树,这样可以提高效率。
先给出源代码:
#include <stdio.h>
int main(int argc, char* argv[]) {
int n = 0;
scanf_s("%d", &n);
switch (n) {
case 2:
printf("n == 2\n");
break;
case 3:
printf("n == 3\n");
break;
case 8:
printf("n == 8\n");
break;
case 10:
printf("n == 10\n");
break;
case 35:
printf("n == 35\n");
break;
case 37:
printf("n == 37\n");
break;
case 666:
printf("n == 666\n");
break;
default:
printf("default\n");
break;
}
return 0;
}在release编译选项下生成并输入n的值为37,反汇编后如下图所示:
编译器生成的判定树如下图:
这样看或许不够明显,我们使用IDA得到可视化视图:
这样以来汇编代码就很好理解了。同时应该注意,在优化过程中,检测树的左子树或右子树能否满足if…else…优化、有序线性优化、非线性索引优化,利用这3种优化来降低树的高度。选择优化也是有条件的,那就是选择效率最高,又满足匹配条件的。如果以上3种优化都无法匹配,就会选择使用判定树进行优化。
总结
对于switch的优化还有许多细节没有讲到,希望大家动手体会一下逆向还原的过程,对理解switch的反汇编及优化大有裨益。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页:
[1]