首页
社区
课程
招聘
[原创]这个崩溃有点意思,你中过招吗
发表于: 2022-9-23 14:11 20257

[原创]这个崩溃有点意思,你中过招吗

2022-9-23 14:11
20257

前几天,在加班赶进度时遇到了一个意想不到的崩溃。由于是新加的代码导致的问题,所以很快就定位到了问题代码。但是,看了好几遍也没看出问题在哪?虽然代码在逻辑上有漏洞——某些情况下没有返回值,但是在我的认知里,应该不会导致崩溃。本文记录了使用 IDA 静态分析反汇编代码定位这个问题的过程。

因为整个定位过程非常简单,就不在这里啰嗦了。定位到问题后,我特意建了一个简单的测试工程。关键代码不多,就几行,我把测试代码粘贴如下:

在开始分析之前,请先停下来思考一下,上面的代码有问题吗?会导致崩溃吗?

如果之前看到这段代码,你问我会不会崩溃。我的回答是:不会。但是现在我的回答是:。多么痛的领悟。

vs 2010 中按 F5 调试启动,无情的中断下来了。入下图:

crash-when-destrcutor-called

惊不惊喜,意不意外?

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

使用 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() 函数并没有对 result 做任何初始化就传递给了 GetParam() 函数。

result-not-initialized-in-main

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

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

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

虽然给了警告,但是多少还是觉得 vs 的处理不太合理,难道所有编译器都是这个行为吗?试试 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/

#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;
}
#include "stdafx.h"
#include <string>
 
class ConfigParam
{
public:
    int option;

[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!

收藏
免费 8
支持
分享
最新回复 (27)
雪    币: 66
活跃值: (2791)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
图片全挂了?
2022-9-23 15:26
0
雪    币: 6
活跃值: (3440)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
这代码会不会崩溃不一定,但是肯定是无法预测结果。  这代码咋写出来的? 不是一眼看上去就有问题吗
2022-9-23 18:04
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
4
咖啡_741298 这代码会不会崩溃不一定,但是肯定是无法预测结果。 这代码咋写出来的? 不是一眼看上去就有问题吗
嗯,不一定崩溃。拿出来,一眼能看出问题,在一坨代码中就不是那么明显的了。
2022-9-24 16:06
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
5
实都 图片全挂了?
我这里能看到,可能有时候看不到
2022-9-24 16:07
0
雪    币: 940
活跃值: (9936)
能力值: ( LV13,RANK:385 )
在线值:
发帖
回帖
粉丝
6
总结: GetParam() 函数没有默认返回值. 我遇到过确实很坑.特别是在线上环境要下来找Bug.加上代码一多头都浑了. 后来看汇编解决的. 从此长了记性.
2022-9-30 10:02
0
雪    币: 128
活跃值: (784)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7
总结啥啊!如果这个结构体中wstring 换成整数类型就不会出现崩溃
2022-9-30 10:30
1
雪    币: 3998
活跃值: (5126)
能力值: ( LV7,RANK:100 )
在线值:
发帖
回帖
粉丝
8
我用vs2022测试了,debug模式,编译通不过;
release模式编译能通过,运行结束时没有崩溃。打印的结果,数值是随机的
总结:
正式的工程项目,应该勾选”启用所有警告"。
2022-9-30 11:19
0
雪    币: 931
活跃值: (1658)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
9
就是后面有返回也是局部变量,如果析构使用,不还是一样.....
2022-10-1 09:24
0
雪    币: 1278
活跃值: (1265)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
10

一开始看代码觉得没返回值觉得奇怪,虽然说函数内不会返回局部变量result,但是想着编译器按理说应该会有一个右值返回才对,跳出函数这个右值调用拷贝构造函数初始化param,感觉代码又毛病但不至于奔溃。

 但时,没想到vs直接代码调试发现实际运行了一次构造和两次析构,外部param初始化并没有调用到拷贝构造函数以至于后续析构时异常产生。

 当然上面是在调试情况下的,release下编译器会进行优化,这个问题由于代码优化不会产生。 以上复现代码可以简化成下面的样子:

class ConfigParam2
{
public:
	std::wstring m_str; // 保证构造函数的调用
};

ConfigParam2 GetParam()
{
	if (false) { return ConfigParam2(); }
}

int _tmain(int argc, _TCHAR* argv[])
{
	ConfigParam2 param = GetParam();
	return 0;
}

上面代码在vs的debug环境下,param不会有构造行为,于是在程序结束时,调用虚构函数释放资源时就出现异常。


总结:和楼主说的一样,不要忽略warning(我以前都时直接无视的)

最后于 2022-10-1 12:53 被10ngvv编辑 ,原因:
2022-10-1 12:41
0
雪    币: 2262
活跃值: (12789)
能力值: ( LV12,RANK:312 )
在线值:
发帖
回帖
粉丝
11


这种问题比较晦涩,根编码习惯和编译有关系。避免返回对象和指针,尽可能返回常量,另外推荐使用智能指针

bool GetParam(std::shared_ptr<ConfigParam>& ptrConfParam)
{
        const int option = 0;
        
        if (ptrConfParam)
            return true;
        
        do {
            if (option != 0)
                break;
         
            ptrConfParam = std::make_shared<ConfigParam>();
            if (!ptrConfParam)
                break;
                
            ptrConfParam->option = option;
            ptrConfParam->strValue = L"default";
            return true;
        } while (false);
 
        return false;
}

int _tmain(int argc, _TCHAR* argv[])
{

        std::shared_ptr<ConfigParam> ptrConfParam = nullptr;
        const bool nRet = GetParam(ptrConfParam);
        if (nRet && ptrConfParam)
        {
            ptrConfParam->option;
            ptrConfParam->strValue;
        }
        return 0;
}




最后于 2022-10-1 20:29 被一半人生编辑 ,原因:
2022-10-1 20:29
0
雪    币: 223
活跃值: (222)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
12

这个很明显新手写出来的代码,判断条件无法保证一定有返回值,关键你返回的还是对象,如果返回的只是真假,最多编译报个警告.这说明写代码的人逻辑不够严谨,这在c/c++语言中是很致命的问题,因为不知道啥时候就因为逻辑不严谨造成程序莫名崩溃.也许这个程序员之前是写java,c#这类的吧.(之前在一个程序员群里讨论java也会内存泄露,一大堆写过java的表示完全不知道,后面看了文章才发现原来java也会内存泄露.)

最后于 2022-10-3 09:03 被chenteng编辑 ,原因:
2022-10-3 08:57
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
13
TkBinary 总结: GetParam() 函数没有默认返回值. 我遇到过确实很坑.特别是在线上环境要下来找Bug.加上代码一多头都浑了. 后来看汇编解决的. [em_77]从此长了记性.
哈哈 原来大佬也会犯这种低级错误
2022-10-30 22:09
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
14
书生怕怕 总结啥啊!如果这个结构体中wstring 换成整数类型就不会出现崩溃
谁说不是呢
2022-10-30 22:10
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
15
舒默哦 我用vs2022测试了,debug模式,编译通不过; release模式编译能通过,运行结束时没有崩溃。打印的结果,数值是随机的[em_1] 总结: 正式的工程项目,应该勾选”启用所有警告&qu ...
嗯,要重视警告
2022-10-30 22:10
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
16
tian_chen 就是后面有返回也是局部变量,如果析构使用,不还是一样.....
如果是这样 可能还真不一样
2022-10-30 22:11
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
17
10ngvv 一开始看代码觉得没返回值觉得奇怪,虽然说函数内不会返回局部变量result,但是想着编译器按理说应该会有一个右值返回才对,跳出函数这个右值调用拷贝构造函数初始化param,感觉代码又毛病但不至于奔溃。 ...
细!vs 中确实没有调用构造函数
2022-10-30 22:14
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
18
一半人生 这种问题比较晦涩,根编码习惯和编译有关系。避免返回对象和指针,尽可能返回常量,另外推荐使用智能指针。bool&nbsp;GetParam(std::shared_ptr&lt;Conf ...
确实比较隐晦。注意编译器的警告。用智能指针也是一种方案,不过对于项目中的那种情况有点大炮打蚊子的意思
2022-10-30 22:17
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
19
chenteng 这个很明显新手写出来的代码,判断条件无法保证一定有返回值,关键你返回的还是对象,如果返回的只是真假,最多编译报个警告.这说明写代码的人逻辑不够严谨,这在c/c++语言中是很致命的问题,因为不知道啥时候 ...
对,但是有时候写代码就是会粗心大意那么几下。我经常把  != 和 == 写反。
2022-10-30 22:18
0
雪    币: 5354
活跃值: (4014)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
20
C4715这种warning本质就是error。除非你明确知道这个warning的影响,否则不应该轻易放过任何一个warning
2022-10-31 01:43
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
21
无心红叶 C4715这种warning本质就是error。除非你明确知道这个warning的影响,否则不应该轻易放过任何一个warning
谁说不是呢,vs 应该直接报个错 多省心
2022-10-31 12:30
0
雪    币: 73
活跃值: (923)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
22
"不要返回对局部对象的引用”,C++Primer这本书上的这句话大家都忽略了吧?
2022-10-31 17:30
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
23
hixhi "不要返回对局部对象的引用”,C++Primer这本书上的这句话大家都忽略了吧?
没人返回局部对象的引用啊?
2022-11-1 12:31
0
雪    币: 73
活跃值: (923)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
24
编程难 没人返回局部对象的引用啊?[em_4]

是我错了,sorry

最后于 2022-11-4 18:09 被zx_348891编辑 ,原因:
2022-11-4 18:08
0
雪    币: 637
活跃值: (6493)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
25
hixhi "不要返回对局部对象的引用”,C++Primer这本书上的这句话大家都忽略了吧?
什么意思,是这样子吗
int& func()
{
    int a = 0;
    return a;
}
2022-12-4 10:37
0
游客
登录 | 注册 方可回帖
返回
//