这个崩溃,有点意思

缘起

前几天,在加班赶进度时遇到了一个意想不到的崩溃。由于是新加的代码导致的问题,所以很快就定位到了问题代码。但是,看了好几遍也没看出问题在哪?虽然代码在逻辑上有漏洞——某些情况下没有返回值,但是在我的认知里,应该不会导致崩溃。本文记录了使用 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
#include "stdafx.h"
#include <string>

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 调试启动,无情的中断下来了。入下图:

crash-when-destrcutor-called

惊不惊喜,意不意外?

GetParam() 反回一个 ConfigParam 类型的对象,这个反回的对象在析构的时候却崩溃了。如果仔细观察 GetParam() 的实现,可以发现 GetParam() 并不是所有分支上都有返回值,但是编译器应该会返回一个临时对象。难道这个反回的临时对象有问题?对于这种问题,唯有通过反汇编才能找到答案。

请出 IDA

使用 IDA 打开 对应的程序,找到 GetParam() 的反汇编,可以发现一个有意思的事情是 GetParam() 的形式。本来声明的是

ConfigParam GetParam(int option),在 IDA 中看到的却是 ConfigParam *__fastcall GetParam(ConfigParam *result, int option)。如下图:

GetParam-real-type

依稀记得多年前接触汇编的时候,了解到一种说法:如果返回值类型比较大(大家应该知道在 32 位程序中,函数的返回值基本是通过 EAX 反回的),那么会把返回值的地址当作第一个参数传递给函数,EAX 指向的是返回值的地址。正好跟 IDA 对应上了。

查看关键逻辑

代码中的 GetParam() 函数,当 option0 的时候,会反回局部的 result,否则什么都不做。看看编译器帮我们做了什么吧。编译器做的事情也是,当 option0 的时候,执行拷贝构造函数把局部的 result 返回出去,否则不会对参数中的 result 做任何操作。关键代码如下图所示:

key-logic-not-assign-result

那么在调用 GetParam() 函数的地方,会对 result 做什么初始化的工作吗?

查看 main 函数逻辑

从下图可以清楚的看到,main() 函数并没有对 result 做任何初始化就传递给了 GetParam() 函数。

result-not-initialized-in-main

所以,调用完 GetParam() 后,main() 函数中的 result 是一个未初始化的对象。而不是一个调用过构造函数的对象。所以后面再调用其析构函数的时候,发生什么事情都是正常的了。我在遇到这个问题之前,一直以为 GetParam() 函数返回来的是一个初始化过的对象,因为根据之前的认知,在对象产生的时候一定会调用构造函数。这里既没有调用构造函数,也没有调用拷贝构造函数。

vs 的 bug ?

这个问题最先是在 vs2019 上发现的,我还以为是 vs2019bug,于是试了 vs2017vs2013vs2010,发现都会崩溃。但是每个版本的 vs 都会给出一个警告:warning C4715: 'GetParam' : not all control paths return a value

not-all-control-paths-return-a-value-warning

虽然给了警告,但是多少还是觉得 vs 的处理不太合理,难道所有编译器都是这个行为吗?试试 gcc 中的行为。

gcc

不知道大家是否还记得我之前分享过的一个宝藏网址(https://gcc.godbolt.org/),可以查看各种编译器对同一段代码的编译结果。下图是 gcc5.2GetParam() 函数的反汇编代码。

view-disassembly-code-of-getparam-in-gcc5.2

可见,逻辑十分清晰, 56 行中的 rdi 指向的是返回值地址,第 60 行会先调用构造函数,传递的对象地址就是 56 行的 rdi (虽然中间经过 [rbp-24]rax 倒了两手)。第 69 行判断 option 是否为 0,但是第 70 行直接来了个强制跳转(并没有根据比较结果跳转,这个编译器有点屌),跳转到了 .L8 的位置,后面几行是函数返回的处理。

可见,gcc 生成的代码会在 GetParam() 内部会先初始化,再返回。这样就避免了崩溃问题。

再看看 main() 函数的反汇编代码,入下图:

view-disassembly-code-of-main-in-gcc5.2

逻辑非常清晰易懂。第 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 N3242.

在第 6.6.3 节中有一段简单的描述:有返回值却不返回值的情况是未定义的行为。原文截图如下:

undefined-behaviour-in-cpp-standard-draft

总结

如果一个函数是有返回值的,但是却不返回值,这个行为是未定义的。每个编译器可以自由发挥。很多版本的 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/

BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
  • 本文作者: BianChengNan
  • 本文链接: https://bianchengnan.github.io/articles/unexpected-crash/
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!
  • 作者寄语: 文章的结束只是思考的开始,您宝贵的意见和建议将是我继续前行的动力,点击右侧分享按钮即可携友同行!
0%