第三十六章
斐波那契数列
另一个在编程教材中普遍使用的例子是,一个用来生成斐波那契数列的递归函数。
这个序列非常简单:每个数字都是前面两个数字的和。打头的两个数字都是1或者是0,1,1。
该序列起始是这样的:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181...
36.1 例一
这个实现起来比较简单。下面这个程序产生直到21的序列。
#include <stdio.h>
void fib (int a, int b, int limit)
{
printf ("%d\n", a+b);
if (a+b > limit)
return;
fib (b, a+b, limit);
};
int main()
{
printf ("0\n1\n1\n");
fib (1, 1, 20);
};
Listing 36.1: MSVC 2010 x86
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
_limit$ = 16 ; size = 4
_fib PROC
push ebp
mov ebp, esp
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]
push eax
push OFFSET $SG2643
call DWORD PTR __imp__printf
add esp, 8
mov ecx, DWORD PTR _a$[ebp]
add ecx, DWORD PTR _b$[ebp]
cmp ecx, DWORD PTR _limit$[ebp]
jle SHORT $LN1@fib
jmp SHORT $LN2@fib
$LN1@fib:
mov edx, DWORD PTR _limit$[ebp]
push edx
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]
push eax
mov ecx, DWORD PTR _b$[ebp]
push ecx
call _fib
add esp, 12
$LN2@fib:
pop ebp
ret 0
_fib ENDP
_main PROC
push ebp
mov ebp, esp
push OFFSET $SG2647
call DWORD PTR __imp__printf
add esp, 4
push 20
push 1
push 1
call _fib
add esp, 12
xor eax, eax
pop ebp
ret 0
_main ENDP
我们将用这个来说明一下栈帧。
让我们在OllyDbg中加载这个例子,并且跟踪到最后一次对f()
函数的调用:
图 36.1: OllyDbg: 最后一次对f()
的调用
让我们来更加仔细地研究一下栈。本书的作者向其中加了一些注释(在这个例子中,就是把OllyDbg中的多个条目copy到剪切板中(Ctrl-C)):
0035F940 00FD1039 RETURN to fib.00FD1039 from fib.00FD1000
0035F944 00000008 1st argument: a
0035F948 0000000D 2nd argument: b
0035F94C 00000014 3rd argument: limit
0035F950 /0035F964 saved EBP register
0035F954 |00FD1039 RETURN to fib.00FD1039 from fib.00FD1000
0035F958 |00000005 1st argument: a
0035F95C |00000008 2nd argument: b
0035F960 |00000014 3rd argument: limit
0035F964 ]0035F978 saved EBP register
0035F968 |00FD1039 RETURN to fib.00FD1039 from fib.00FD1000
0035F96C |00000003 1st argument: a
0035F970 |00000005 2nd argument: b
0035F974 |00000014 3rd argument: limit
0035F978 ]0035F98C saved EBP register
0035F97C |00FD1039 RETURN to fib.00FD1039 from fib.00FD1000
0035F980 |00000002 1st argument: a
0035F984 |00000003 2nd argument: b
0035F988 |00000014 3rd argument: limit
0035F98C ]0035F9A0 saved EBP register
0035F990 |00FD1039 RETURN to fib.00FD1039 from fib.00FD1000
0035F994 |00000001 1st argument: a
0035F998 |00000002 2nd argument: b
0035F99C |00000014 3rd argument: limit
0035F9A0 ]0035F9B4 saved EBP register
0035F9A4 |00FD105C RETURN to fib.00FD105C from fib.00FD1000
0035F9A8 |00000001 1st argument: a \
0035F9AC |00000001 2nd argument: b | prepared in main() for f1()
0035F9B0 |00000014 3rd argument: limit /
0035F9B4 ]0035F9F8 saved EBP register
0035F9B8 |00FD11D0 RETURN to fib.00FD11D0 from fib.00FD1040
0035F9BC |00000001 main() 1st argument: argc \
0035F9C0 |006812C8 main() 2nd argument: argv | prepared in CRT for main()
0035F9C4 |00682940 main() 3rd argument: envp /
该函数是递归的,因此看起来就像个“三明治”。我们能够看出参数limit总是相同的(0x14或20),但是参数a和b在每次调用时都是不同的。其中也有RA(Return Address,返回地址)和保存的EBP值。OllyDbg可以决定基于EBP的帧,所以就画出了这些中括号(])。每个中括号中的值构成了栈帧,换句话说,每一个函数都使用栈来作为暂存空间。我们也可以说每一个函数都不能访问超出其帧边界的栈元素(不包括函数参数),虽然这在技术上是有可能的。上一句话通常是正确的,除非函数中有了bug。每个保存的EBP值为前一栈帧的地址:这就是有些调试器可以很容易地划分在帧中的栈和dump每个函数参数的原因。
正如我们在这里所见,每一个函数都为下一个函数调用准备好了参数。
在最后有用于main()
函数的三个参数。argc值为1(是的,我们确实没有用命令行参数来运行程序)。
这样很容易导致栈溢出:只是删除(或注释)掉limit检测,程序就会抛出0xC00000FD异常而崩溃(stack overflow)。
36.2 例二
我构造的函数有些冗余,所以就让我们来添加一个局部变量next并用它代替所有的"a+b":
#include <stdio.h>
void fib (int a, int b, int limit)
{
int next=a+b;
printf ("%d\n", next);
if (next > limit)
return;
fib (b, next, limit);
};
int main()
{
printf ("0\n1\n1\n");
fib (1, 1, 20);
};
以下的输出是MSVC非优化编译的输出,所以next变量在局部栈中分配空间。
_next$ = -4 ; size = 4
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
_limit$ = 16 ; size = 4
_fib PROC
push ebp
mov ebp, esp
push ecx
mov eax, DWORD PTR _a$[ebp]
add eax, DWORD PTR _b$[ebp]
mov DWORD PTR _next$[ebp], eax
mov ecx, DWORD PTR _next$[ebp]
push ecx
push OFFSET $SG2751 ; '%d'
call DWORD PTR __imp__printf
add esp, 8
mov edx, DWORD PTR _next$[ebp]
cmp edx, DWORD PTR _limit$[ebp]
jle SHORT $LN1@fib
jmp SHORT $LN2@fib
$LN1@fib:
mov eax, DWORD PTR _limit$[ebp]
push eax
mov ecx, DWORD PTR _next$[ebp]
push ecx
mov edx, DWORD PTR _b$[ebp]
push edx
call _fib
add esp, 12
$LN2@fib:
mov esp, ebp
pop ebp
ret 0
_fib ENDP
_main PROC
push ebp
mov ebp, esp
push OFFSET $SG2753 ; "0\n1\n1\n"
call DWORD PTR __imp__printf
add esp, 4
push 20
push 1
push 1
call _fib
add esp, 12
xor eax, eax
pop ebp
ret 0
_main ENDP
让我再一次加载OllyDbg:
图 36.2: OllyDbg: 最后一次对f()
调用
现在next变量就出现在每一个帧中。
让我们来更加仔细地研究一下栈。作者也向其中加了他的注释:
0029FC14 00E0103A RETURN to fib2.00E0103A from fib2.00E01000
0029FC18 00000008 1st argument: a
0029FC1C 0000000D 2nd argument: b
0029FC20 00000014 3rd argument: limit
0029FC24 0000000D "next" variable
0029FC28 /0029FC40 saved EBP register
0029FC2C |00E0103A RETURN to fib2.00E0103A from fib2.00E01000
0029FC30 |00000005 1st argument: a
0029FC34 |00000008 2nd argument: b
0029FC38 |00000014 3rd argument: limit
0029FC3C |00000008 "next" variable
0029FC40 ]0029FC58 saved EBP register
0029FC44 |00E0103A RETURN to fib2.00E0103A from fib2.00E01000
0029FC48 |00000003 1st argument: a
0029FC4C |00000005 2nd argument: b
0029FC50 |00000014 3rd argument: limit
0029FC54 |00000005 "next" variable
0029FC58 ]0029FC70 saved EBP register
0029FC5C |00E0103A RETURN to fib2.00E0103A from fib2.00E01000
0029FC60 |00000002 1st argument: a
0029FC64 |00000003 2nd argument: b
0029FC68 |00000014 3rd argument: limit
0029FC6C |00000003 "next" variable
0029FC70 ]0029FC88 saved EBP register
0029FC74 |00E0103A RETURN to fib2.00E0103A from fib2.00E01000
0029FC78 |00000001 1st argument: a \
0029FC7C |00000002 2nd argument: b | prepared in f1() for next f1()
0029FC80 |00000014 3rd argument: limit /
0029FC84 |00000002 "next" variable
0029FC88 ]0029FC9C saved EBP register
0029FC8C |00E0106C RETURN to fib2.00E0106C from fib2.00E01000
0029FC90 |00000001 1st argument: a \
0029FC94 |00000001 2nd argument: b | prepared in main() for f1()
0029FC98 |00000014 3rd argument: limit /
0029FC9C ]0029FCE0 saved EBP register
0029FCA0 |00E011E0 RETURN to fib2.00E011E0 from fib2.00E01050
0029FCA4 |00000001 main() 1st argument: argc \
0029FCA8 |000812C8 main() 2nd argument: argv | prepared in CRT for main()
0029FCAC |00082940 main() 3rd argument: envp /
在这里我们可以看出:next的值在每次函数调用时都被计算一遍,然后将其作为参数b传递给下一个函数。
36.3 总结
递归函数看起来很nice,但是因为它们对栈的笨重用法在技术上可能会降低性能。所以在写有关性能的关键代码时应该要避免使用递归。
例如,本书的作者曾经写过一个在二叉树中搜寻特定节点的函数。使用递归函数看起来很优雅,但是因为在每次函数调用的开头和结尾会花费额外的时间,它就比使用迭代(不用递归)的情况慢好几倍。
By the way,这是一些函数式PL(Programming language,编程语言,LISP, Python, Lua等)编译器(其中大量使用递归)使用tail call的原因。