- 事情的经过是这样的,博主在用C写一个简单的业务时使用递归,由于粗心而忘了写return。结果发现返回的结果依然是正确的。经过半小时的反汇编调试,证明了我的猜想,现在在博客里分享。也是对C语言编译原理的一次加深理解。
- 引子:
- 首先我想以一道题目引例,比较能体现出问题。
例1:
#include <stdio.h>
/**
函数功能:用递归实现位运算加法
*/
int Add_Recursion(int a,int b)
{
int carry_num = 0, add_num = 0;
if (b == 0)
{
return a;
}
else
{
add_num = a^b;
carry_num = (a&b)<<1;
Add_Recursion(add_num, carry_num);
}
}
int main()
{
int num = Add_Recursion(1, 1);
printf("%d\n",num);
getchar();
}
- 问题是,执行如上的程序,打印出来的数值是多少?
- 大家可能会觉得这个非常的弱智,即使作为小公司的笔试题来说都登不上大雅之堂。
——————————–图1 例题1的执行结果——————— - 答案是2,毫无疑问,只是一个简单的递归而已。
但是如果我把题目改一下
例2:
#include <stdio.h>
int changestack()
{
return 3;
}
/**
函数功能:用递归实现位运算加法
*/
int Add_Recursion(int a,int b)
{
int carry_num = 0, add_num = 0;
if (b == 0)
{
return a;
}
else
{
add_num = a^b;
carry_num = (a&b)<<1;
Add_Recursion(add_num, carry_num);
changestack();
}
}
int main()
{
int num = Add_Recursion(1, 1);
printf("%d\n",num);
getchar();
}
- 大家看看上边的程序,执行结果会是多少?
可能有很多朋友细心已经发现了猫腻。
可能也有部分朋友会有些困惑,这个程序只是在递归的实现函数后中加了一个无关紧要的函数调用,为什么会影响函数返回的结果呢。
事实上printf打印出来的结果不正确。运行结果是3
—————————-图2 例题2的执行结果————————- - 为什么会出现这个问题呢,实际上正常情况下的递归。在else语句里进行递归调用时,应当加上return。由于return的缺失,导致了函数返回值被changestack()函数篡改,从而在main函数中读到了错误的返回值。
else
{
add_num = a^b;
carry_num = (a&b)<<1;
return Add_Recursion(add_num, carry_num);
changestack();
}
- 如果将上文的代码改正如上,那不会出现任何问题。(当然不会出错,此时有了return,return后边的changestack根本就不会有任何机会执行)
现在来一步一步来分析错误发生的本质。
——————–图三 例二函数的递归分析—————————
我们分析上边代码的运行过程,首先在main函数中调用Add_Recursion(1,1),本意就是计算1+1的值,并且将函数返回值传递给printf打印出来。
在递归调用Add_Recursion函数(简称add)计算1+1时,前两次递归调用由于不满足递归出口条件(进位加数carry_num为0),会跳入else分支进行递归调用。直到第三次递归调用时由于carry_num为0,这时返回了累加结果。- 问题是只有第三次的add递归调用进行了return,第一次和第二次在函数返回时,都没有return,而是在返回子层次递归后调用changestack()函数后返回调用自己的函数层级。在第一层递归调用返回给main的时候,add_recursion并没有return,而是在执行完changestack直接返回main函数,而此时main函数的printf在解析返回值时,实际上错误的解析了changestack的返回值。因此才出现1+1=3的错误
- 综上分析发生这一切的原因,就是:
函数执行结束返回时,会将返回值压栈(理论上如此,实际上编译器会优化,将返回值给eax寄存器过渡,VC就是使用的eax暂时保存)。VC编译器解析函数返回值(整型)时,直接将eax的值读出当做返回值。
———————-图四 反汇编分析VC编译器对return的处理———- - 根据反汇编分析可以看到,VC编译器对changestack()中的return 3汇编的结果,也就是 mov eax,3。实际上就是把返回值赋予eax,由eax寄存器过渡给此函数的调用函数使用。
我们在下图中可以看到main函数中将changestack()的返回值给num赋值的具体过程,也就是将eax的值返回给num的所在的内存地址。
——————————图五 函数返回值的“弹栈”细则——————————-这样一切就有了解释。
——————-图六 例题一为什么会碰巧正确的递归分析—————
- 虽然第一题的结果虽然正确,printf在读取Add_Recursion返回值时,读取的不是第一次递归调用的结果,而是第三次递归调用return b的结果(第三次递归返回时,暂存在eax寄存器中)。而在之后的递归返回中,凑巧eax都没有被改变。因此这样使用递归(尽管没有在需要return的地方return)是可以得到正确结果。
实际上我们可以用一条内联汇编代码验证我们的猜想是否正确。我们在递归调用的后边,使用内联汇编加上一条汇编代码改变eax的值。
——————————-图七 用内联汇编解读C语言的return本质—————————–
我们在递归函数Add_Recursion的后边加了一条汇编代码,让函数结束时改变eax的值。可以看到,主函数中,将函数返回值误认为了我们在汇编语言中设定的3.打印出了1+1=3这种谬论。
实际上,我们在编译例题中的程序在编译时C编译器会提出警告
warning C4715: “Add_Recursion”: 不是所有的控件路径都返回值
有返回值的函数,不是所有的支路都会进行返回值,如果大家把博客中的程序在更加严格的C++编译器上编译会报错。这只是一个很简单的案例,也许我们会运气好实现函数的功能,但是在进行复杂情况的树状甚至图状递归中,如果不确定自己是否一定能得到最终结果,请务必将每一种情况都return返回值,这样来避免程序意外出错。C语言的灵活性应该给我们造福,而不应该给我们的程序提供不稳定的因素。