文章目录
- [从汇编反向看 C 的 `switch`:二分搜索风格的案例分析](#从汇编反向看 C 的
switch:二分搜索风格的案例分析) -
- [1. 先看看源码](#1. 先看看源码)
- [2. 函数开头的栈和参数处理](#2. 函数开头的栈和参数处理)
- [3. 第一层判断:围绕 1000 切分](#3. 第一层判断:围绕 1000 切分)
- [4. 小于 1000 的分支](#4. 小于 1000 的分支)
- [5. 大于 1000 的分支](#5. 大于 1000 的分支)
- [6. case 分支实现细节](#6. case 分支实现细节)
- [7. 从汇编"还原"成等价 C](#7. 从汇编“还原”成等价 C)
- [8. 为什么说这是"折半查找 / 二分搜索风格"](#8. 为什么说这是“折半查找 / 二分搜索风格”)
- [9. 逆向工程实战经验](#9. 逆向工程实战经验)
- [10. 测试示例](#10. 测试示例)
从汇编反向看 C 的 switch:二分搜索风格的案例分析
最近我在调试一段 C 代码,里面有一个 switch(x),case 值很稀疏,从 10 到 20000,中间跨度很大。为了理解编译器是怎么生成汇编的,我直接打开了反汇编,想把它从汇编"还原"回 C,并且顺便观察它用的到底是跳转表还是别的策略。下面我把整个分析过程记录下来,边看汇编边总结经验。
1. 先看看源码
源码很直观:
c
switch (x) {
case 10: printf("10\n"); break;
case 50: printf("50\n"); break;
case 100: printf("100\n"); break;
case 200: printf("200\n"); break;
case 500: printf("500\n"); break;
case 1000: printf("1000\n"); break;
case 2000: printf("2000\n"); break;
case 5000: printf("5000\n"); break;
case 10000: printf("10000\n"); break;
case 20000: printf("20000\n"); break;
default: printf("default\n"); break;
}
可以看到,case 值有序但稀疏 ,如果编译器用传统跳转表,会浪费空间,因为跳转表跨度到 20000。猜测它可能不会用 jump table,而是用比较树 / 二分搜索风格。
2. 函数开头的栈和参数处理
看汇编,函数开头是标准的栈帧建立:
asm
00085280 push ebp
00085281 mov ebp,esp
00085283 push ecx
00085284 mov dword ptr [ebp-4],0CCCCCCCCh
...
00085296 mov eax,dword ptr [ebp+8]
00085299 mov dword ptr [ebp-4],eax
-
参数
x被保存到[ebp-4],后面所有cmp [ebp-4], imm都是在比较x。 -
这里还有几条
mov ecx,8C008h / call 00081217,是编译器插的栈保护或初始化,和 switch 本身没关系。
3. 第一层判断:围绕 1000 切分
然后看到第一条关键比较:
asm
0008529C cmp dword ptr [ebp-4],3E8h ; 1000
000852A3 jg 000852F2 ; if x > 1000 → 大区间
000852A5 cmp dword ptr [ebp-4],3E8h
000852AC je 0008538A ; if x == 1000 → case 1000
脑子里立刻画出一个思路:
-
x > 1000→ 跳去处理大数的分支 -
x == 1000→ 直接命中 case 1000 -
x < 1000→ 继续下面的小数分支
感觉就像把所有 case 分成三块,这是典型二分搜索的第一层。
4. 小于 1000 的分支
继续看 x < 1000 的分支:
asm
000852B2 cmp [ebp-4],64h ; 100
000852B6 jg 000852D3 ; x > 100 → 中间分支
000852B8 cmp [ebp-4],64h
000852BC je 0008535D ; x == 100
000852C2 cmp [ebp-4],0Ah ; 10
000852C6 je 00085339 ; x == 10
000852C8 cmp [ebp-4],32h ; 50
000852CC je 0008534B ; x == 50
000852CE jmp 000853D5 ; default
结合逻辑:
c
if (x < 1000) {
if (x > 100) {
// 200, 500 分支
} else if (x == 100) {
// case 100
} else if (x == 10) {
// case 10
} else if (x == 50) {
// case 50
} else {
// default
}
}
再看 x > 100 的那条分支:
asm
000852D3 cmp [ebp-4],0C8h ; 200
000852DA je 0008536C ; case 200
000852E0 cmp [ebp-4],1F4h ; 500
000852E7 je 0008537B ; case 500
000852ED jmp 000853D5 ; default
所以小于 1000 的区间逻辑就是一个两层 if-else 树,把 10、50、100、200、500 分开。
5. 大于 1000 的分支
回到第一层 x > 1000:
asm
000852F2 cmp [ebp-4],2710h ; 10000
000852F9 jg 00085327 ; x > 10000 → 后续分支
000852FB cmp [ebp-4],2710h
00085302 je 000853B7 ; case 10000
00085308 cmp [ebp-4],7D0h ; 2000
0008530F je 00085399 ; case 2000
00085315 cmp [ebp-4],1388h ; 5000
0008531C je 000853A8 ; case 5000
00085322 jmp 000853D5 ; default
逻辑对应 C:
c
if (x > 1000) {
if (x > 10000) {
if (x == 20000) printf("20000\n");
else printf("default\n");
} else {
if (x == 10000) printf("10000\n");
else if (x == 2000) printf("2000\n");
else if (x == 5000) printf("5000\n");
else printf("default\n");
}
}
可以看到编译器在大数区间也做了分区+二分比较。
6. case 分支实现细节
每个 case 基本一样,都是 push 字符串地址,call printf,然后跳到 switch 尾部。例如:
asm
00085339 push 88620h ; "10\n"
0008533E call 00081082 ; printf
00085343 add esp,4
00085346 jmp 000853E2 ; switch 尾部
其它 case 也类似,只是字符串地址不同。default 也是一样:
asm
000853D5 push 88668h ; "default\n"
000853DA call 00081082
000853DF add esp,4
尾部统一处理函数返回。
7. 从汇编"还原"成等价 C
把所有比较和跳转串起来,就像在画一棵决策树,最终等价 C 代码如下:
c
void binary_search_switch(int x) {
if (x > 1000) {
if (x > 10000) {
if (x == 20000) printf("20000\n");
else printf("default\n");
} else {
if (x == 10000) printf("10000\n");
else if (x == 2000) printf("2000\n");
else if (x == 5000) printf("5000\n");
else printf("default\n");
}
} else if (x == 1000) {
printf("1000\n");
} else {
if (x > 100) {
if (x == 200) printf("200\n");
else if (x == 500) printf("500\n");
else printf("default\n");
} else {
if (x == 100) printf("100\n");
else if (x == 10) printf("10\n");
else if (x == 50) printf("50\n");
else printf("default\n");
}
}
}
仔细看会发现,这完全对应汇编里的 cmp + 条件跳转组合,编译器就是把稀疏 case 值分层,减少比较次数。
8. 为什么说这是"折半查找 / 二分搜索风格"
总结规律:
-
第一层用
1000把所有 case 分成<1000、==1000、>1000三块。 -
<1000区间再用100把{10,50,100}和{200,500}分开。 -
>1000区间用10000把{2000,5000,10000}和{20000}分开。
整个结构就像一棵高度 3~4 的决策树。相比线性 if-else:
-
线性比较最坏要 9 次
-
现在最坏只要 3~4 次
同时避免了跳转表对稀疏 case 的巨大空间浪费。
9. 逆向工程实战经验
下次再看到类似汇编,识别方法:
-
一连串
cmp [ebp-4], imm+jg/jl/je→ 明显按大小分段。 -
分支里直接
push 常量 → call printf → jmp 统一尾部→ 这就是 case。 -
多处
jmp 同一个尾部→ switch 的统一出口。 -
没有 jump table 的连续指针数组 → 编译器用了比较树而不是 table。
看到这些,基本可以断定是二分搜索风格 switch。
10. 测试示例
c
#include <stdio.h>
void binary_search_switch(int x) {
switch (x) {
case 10: printf("10\n"); break;
case 50: printf("50\n"); break;
case 100: printf("100\n"); break;
case 200: printf("200\n"); break;
case 500: printf("500\n"); break;
case 1000: printf("1000\n"); break;
case 2000: printf("2000\n"); break;
case 5000: printf("5000\n"); break;
case 10000: printf("10000\n"); break;
case 20000: printf("20000\n"); break;
default: printf("default\n"); break;
}
}
int main() {
binary_search_switch(100);
binary_search_switch(5000);
binary_search_switch(300); // default
return 0;
}
运行结果与汇编逻辑完全一致。
总结
通过这次分析,我又巩固了几个点:
-
对稀疏、按序但跨度大的 switch case ,编译器常用二分搜索风格的比较树。
-
汇编里连续
cmp + 条件跳转 + push/ call + jmp是典型特征。 -
这种结构在逆向工程中非常好认,而且可以快速还原出等价 C 代码,理解程序逻辑。
这比直接看 jump table 更直观,也更节省空间。