缘起
前几天,在加班赶进度时遇到了一个意想不到的崩溃。由于是新加的代码导致的问题,所以很快就定位到了问题代码。但是,看了好几遍也没看出问题在哪?虽然代码在逻辑上有漏洞——某些情况下没有返回值,但是在我的认知里,应该不会导致崩溃。本文记录了使用 IDA
静态分析反汇编代码定位这个问题的过程。
示例代码
因为整个定位过程非常简单,就不在这里啰嗦了。定位到问题后,我特意建了一个简单的测试工程。关键代码不多,就几行,我把测试代码粘贴如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
class
ConfigParam
{
public:
int
option;
std::wstring strValue;
};
ConfigParam GetParam(
int
option)
{
ConfigParam result;
result.option
=
option;
result.strValue
=
L
"default"
;
if
(option
=
=
0
)
{
return
result;
}
}
int
_tmain(
int
argc, _TCHAR
*
argv[])
{
ConfigParam param
=
GetParam(
1
);
return
0
;
}
|
在开始分析之前,请先停下来思考一下,上面的代码有问题吗?会导致崩溃吗?
如果之前看到这段代码,你问我会不会崩溃。我的回答是:不会。但是现在我的回答是:会。多么痛的领悟。
残酷的崩溃
在 vs 2010
中按 F5 调试启动,无情的中断下来了。入下图:
惊不惊喜,意不意外?
GetParam()
反回一个 ConfigParam
类型的对象,这个反回的对象在析构的时候却崩溃了。如果仔细观察 GetParam()
的实现,可以发现 GetParam()
并不是所有分支上都有返回值,但是编译器应该会返回一个临时对象。难道这个反回的临时对象有问题?对于这种问题,唯有通过反汇编才能找到答案。
请出 IDA
使用 IDA 打开 对应的程序,找到 GetParam()
的反汇编,可以发现一个有意思的事情是 GetParam()
的形式。本来声明的是
ConfigParam GetParam(int option)
,在 IDA 中看到的却是 ConfigParam *__fastcall GetParam(ConfigParam *result, int option)
。如下图:
依稀记得多年前接触汇编的时候,了解到一种说法:如果返回值类型比较大(大家应该知道在 32
位程序中,函数的返回值基本是通过 EAX
反回的),那么会把返回值的地址当作第一个参数传递给函数,EAX
指向的是返回值的地址。正好跟 IDA
对应上了。
查看关键逻辑
代码中的 GetParam()
函数,当 option
是 0
的时候,会反回局部的 result
,否则什么都不做。看看编译器帮我们做了什么吧。编译器做的事情也是,当 option
为 0
的时候,执行拷贝构造函数把局部的 result
返回出去,否则不会对参数中的 result
做任何操作。关键代码如下图所示:
那么在调用 GetParam()
函数的地方,会对 result
做什么初始化的工作吗?
查看 main 函数逻辑
从下图可以清楚的看到,main()
函数并没有对 result
做任何初始化就传递给了 GetParam()
函数。
所以,调用完 GetParam()
后,main()
函数中的 result
是一个未初始化的对象。而不是一个调用过构造函数的对象。所以后面再调用其析构函数的时候,发生什么事情都是正常的了。我在遇到这个问题之前,一直以为 GetParam()
函数返回来的是一个初始化过的对象,因为根据之前的认知,在对象产生的时候一定会调用构造函数。这里既没有调用构造函数,也没有调用拷贝构造函数。
vs 的 bug ?
这个问题最先是在 vs2019
上发现的,我还以为是 vs2019
的 bug
,于是试了 vs2017
、vs2013
、vs2010
,发现都会崩溃。但是每个版本的 vs
都会给出一个警告:warning C4715: 'GetParam' : not all control paths return a value
。
虽然给了警告,但是多少还是觉得 vs
的处理不太合理,难道所有编译器都是这个行为吗?试试 gcc
中的行为。
gcc
不知道大家是否还记得我之前分享过的一个宝藏网址(https://gcc.godbolt.org/),可以查看各种编译器对同一段代码的编译结果。下图是 gcc5.2
中 GetParam()
函数的反汇编代码。
可见,逻辑十分清晰, 56
行中的 rdi
指向的是返回值地址,第 60
行会先调用构造函数,传递的对象地址就是 56
行的 rdi
(虽然中间经过 [rbp-24]
及 rax
倒了两手)。第 69
行判断 option
是否为 0
,但是第 70
行直接来了个强制跳转(并没有根据比较结果跳转,这个编译器有点屌),跳转到了 .L8
的位置,后面几行是函数返回的处理。
可见,gcc
生成的代码会在 GetParam()
内部会先初始化,再返回。这样就避免了崩溃问题。
再看看 main()
函数的反汇编代码,入下图:
逻辑非常清晰易懂。第 89
行把局部变量的地址加载到 rax
中,第 90
行把 1
赋值到 esi
中,第 91
行把 rax
的值放到 rdi
中,第 92
行 调用 GetParam()
函数。
扩展: 感觉 gcc
生成的反汇编对应的调用约定是这样的 :函数的第一个参数通过 rdi
传递,第二个参数通过 rsi
传递。
简单搜了一下,linux
平台 x64
应用程序的调用约定还真是这样的,具体可以参考这篇文章 https://www.cnblogs.com/shines77/p/3788514.html。
综上分析,同样的代码在 gcc 5.2
中的结果是正确的。
函数有返回值但是却不反回,这应该不算是正常情况,也许在标准中对这种行为有描述?是未定义行为?编译器可以根据自己的喜好发挥?一切还要到标准中找答案。
翻看标准
在网站 https://open-std.org/JTC1/SC22/WG21/docs/standards 上找到了 c++
标准的草稿。我参考的版本是 N3242
。这个是 2011 版的草稿。网站上的原话是
A draft for the 2011 edition is available in .
在第 6.6.3
节中有一段简单的描述:有返回值却不返回值的情况是未定义的行为。原文截图如下:
总结
如果一个函数是有返回值的,但是却不返回值,这个行为是未定义的。每个编译器可以自由发挥。很多版本的 vs
会給警告。一定要重视编译器的警告!!!
参考资料
N3242 https://open-std.org/JTC1/SC22/WG21/docs/papers/2011/n3242.pdf
调用约定 https://www.cnblogs.com/shines77/p/3788514.html。
查看反汇编代码的宝藏网址 https://gcc.godbolt.org/