利用ChatGPT和在线网站辅助C++的学习和问题分析

【游戏开发那些事】第61篇原创

前言:合理的利用ChatGPT以及工具网站可以高效的帮我们解决一些常见的代码问题,也能更好的辅助我们学习C++。

最近朋友遇到了一个C++的崩溃问题,由于查不到原因,就来寻求我的帮助。在帮他解决问题的过程中,我利用了ChatGPT辅助我分析汇编代码,并使用常见的一些在线网站进行C++代码的编译、运行和调试,在脱离搜索引擎的条件下解决了不少相对深入的问题。

下面我会把整个问题分析过程作为一个案例分享给大家,(先附上我常使用的C++辅助网站)

https://cppinsights.io/ 可以看到 C++ 源代码在编译期间发生的转换,包括宏展开,name mangling等 https://quick-bench.com/q/Nd-_MgmOpYTDHsEqMbI5w1BROQk 可以用来快速测试代码性能 https://www.onlinegdb.com/ 可以在线编译运行调试C++代码 https://wandbox.org/ 可以选择不同标准不同编译器来进行C++代码的编译运行 https://gcc.godbolt.org/ 大名鼎鼎的compiler explorer,可以根据源码生成汇编代码,几乎支持所有常见的编译器 https://www.cainiaojc.com/tool/cpp/ 可以编译运行C++代码(最高C++17),好处就是左右排版比较舒服

问题缘起:pure virtual method called

出现问题的代码,大概如下(已经被简化),

//Version 1.0
#include <iostream>
#include <thread> 
#include <vector>
using namespace std;

class BaseMonitor {
public:
    BaseMonitor(int id) 
    {
        processId.push_back(id);
    }
    virtual int DetectProcessOnPeriod() = 0;
    virtual void DetectProcessOnce() = 0;
protected:
    vector<int> processId;
};

class ChildMonitor : public BaseMonitor {
public:
    ChildMonitor(int id) :BaseMonitor(id) {}
    int DetectProcessOnPeriod() override{return 0;};
    void DetectProcessOnce() override{};
protected:
     int Count;
};

static void* RunMonitor(BaseMonitor* monitor)
{
    monitor->DetectProcessOnPeriod();
    return NULL;
}



int main()
{
   std::thread monitor_thread;
    {
      ChildMonitor child_monitor(0);
      monitor_thread = std::thread(RunMonitor, &child_monitor);
    }
   monitor_thread.join();
   return 0;
}

ChildMonitor是子类,并且实现了基类BaseMonitor中的两个纯虚函数。主函数中创建了一个NginxMonitor类型的实例,通过创建新的线程调用了RunDeviceMonitor函数。(备注:“子类”等价于“派生类”)

但是这段代码在运行的时候产生了coredump,并报错“pure virtual method called,terminate called without an active exception。”简单来说代码就是触发了纯虚函数的调用,进而产生了Crash,而且在使用gdb调试的时候发现,Child_monitor作为参数传入RunDeviceMonitor以后,打印类型是积基类BaseMonitor,而不是Child_monitor。

你如果拿着这段代码直接给ChatGPT,其实他立刻就可以帮你指出问题:

0d7b10a03c8227b8c11665f87a2e0157.png

526efbd291cc05a94cef62aa6f0618f9.png

但是由于开始沟通不畅,我拿到的代码是下面这个版本的,

//Version 2.0
#include <iostream>
#include <thread> 
using namespace std;

class BaseMonitor {
public:
    virtual int DetectProcessOnPeriod() = 0;
    virtual void DetectProcessOnce() = 0;
};
class ChildMonitor : public BaseMonitor {
public:
    int DetectProcessOnPeriod() override{ cout << "ChildMonitor:DetectProcessOnPeriod"; return 0;};
    void DetectProcessOnce() override{};
};

static void* RunMonitor(BaseMonitor* monitor)
{
    monitor->DetectProcessOnPeriod();
    return NULL;
}

int main()
{
   std::thread monitor_thread;
    {
        ChildMonitor child_monitor;
        monitor_thread = std::thread(RunMonitor, &child_monitor);
    }
   monitor_thread.join();
   return 0;
}

我们通过(Version 1.0)版本的分析,了解到:child_monitor在if结束后离开了其作用域,因此会被析构并释放内存。虽然我们开了一个新的线程立刻去跑RunMonitor函数,但是这个时候肯定是主函数在先跑到作用域之外的,然后在monitor_thread.join();这一行开始等待子线程的执行,这个时候子线程拿到BaseMonitor*指针指向的对象一定是被析构过的了,我们调用一个析构过的对象肯定是未定义的UB行为,所以结果肯定会有问题。

这个问题到这里好像就已经解决了,但是我把2.0版本的代码扔到在C++在线运行网站发现是可以正常跑且没有任何报错的。

https://www.cainiaojc.com/tool/cpp/

如果想简单调试一下代码,也可以使用这个网站 https://www.onlinegdb.com/

edc550812b4832f651a53ee19cd97880.png

对析构过的对象操作一定是有问题的,不过为什么上面的代码并没有报错?难道是与编译或者运行环境有关?(毕竟栈上对象刚析构内存通常不会向堆内存那样搞出来野指针)而且既然最后是调用了纯虚函数,那么child_monitor最后调用的肯定是基类虚表里面的函数,肯定是因为某个原因(基本确定是析构)导致对象的vptr发生了变化。只不过按照我之前的认识,销毁一个对象只要调用一下析构函数,然后释放内存就可以了,不应该再去修改这块内存里面的内容。

为了验证我的想法,我给两个类加了析构函数并打印了虚表,虚指针等相关信息,如下。

//Version 3.0
#include <iostream>
#include <thread> 
using namespace std;

class BaseMonitor {
public:
    virtual int DetectProcessOnPeriod() = 0;
    virtual void DetectProcessOnce() = 0;
    virtual ~BaseMonitor()
    {
        std::thread::id threadID = std::this_thread:: get_id();
        unsigned long* vtbl = (unsigned long*)(*(unsigned long*)this);
        cout<<"delete BaseMonitor"<< "threadID:"<<threadID <<" " <<" adress:" <<this << "slot address " << ": " << vtbl <<endl;

    }
};

class ChildMonitor : public BaseMonitor {
public:
    int DetectProcessOnPeriod() override{return 0;};
    void DetectProcessOnce() override{};
    virtual ~ChildMonitor()
    {
        std::thread::id threadID = std::this_thread:: get_id();
        unsigned long* vtbl = (unsigned long*)(*(unsigned long*)this);
        cout<<"delete Monitor"<< "threadID:"<<threadID <<" " <<" adress:" <<this <<  "slot address " << ": " << vtbl <<endl;
    }
};

typedef void(*VTable)();

void printVT(BaseMonitor* Monitor) {
    cout << "-----printVT Start:-----" << endl;
    VTable vtb = (VTable)*(int64_t*)*(int64_t*)Monitor;
    int  i = 0;
    cout << "Vptr address:   "<< (unsigned long*)Monitor << endl;
    while (vtb != NULL) {
        // Monitor : Monitor
        // (unsigned long*)Monitor : Monitor,vptr的地址
        // (*(unsigned long*)Monitor) : vptr的内容,即vtable的地址,指向第一个虚函数的slot的地址
        // (unsigned long*)(*(unsigned long*)Monitor) : vtable的地址,指向第一个虚函数的slot的地址
        // vtbl : 指向虚函数slot的地址
        // *vtbl : 虚函数的地址
        unsigned long* vtbl = (unsigned long*)(*(unsigned long*)Monitor) + i;
        if(i==0)
        {
            cout << "vtbl address Start "<< ": " << vtbl << endl; 
        }
        cout << "slot address "<< i << ": " << vtbl << endl;
        cout << "func address "<< i <<": " <<*vtbl << endl;
        //VTable pfunc = (VTable)*(vtbl);
        //pfunc();
        ++i;
        vtb = (VTable)*((int64_t*)*(int64_t*)Monitor + i);
    }
        cout << "-----printVT End:-----" << endl;
}

static void* RunMonitor(BaseMonitor* monitor)
{
    printVT(monitor);
    std::thread::id threadID = std::this_thread:: get_id();
    //线程打印可能会在执行一半的时候被切走,先不处理
    cout << "threadID:"<<threadID <<" " <<"RunMonitor:" << typeid(*monitor).name()<<" adress:" <<monitor << endl;
    monitor->DetectProcessOnPeriod();
    return NULL;
}


int main()
{
    std::thread::id threadID = std::this_thread:: get_id();
    BaseMonitor* monitorptr = nullptr;
    std::thread monitor_thread;
    {
        ChildMonitor ChildMonitor;
    monitorptr = &ChildMonitor;
        cout << "###################Before delete:####################" << endl;
    cout << "threadID:"<<threadID <<" " << typeid(ChildMonitor).name() <<" adress:" <<&ChildMonitor <<endl;


    cout << "--------------Before delete RunMonitor:------------" << endl;
        RunMonitor(&ChildMonitor);
        cout << "--------------Before delete RunMonitor End------------" << endl;

     //开线程运行
    monitor_thread = std::thread(RunMonitor, &ChildMonitor);    
    }

    cout << endl;
    cout << "####################After delete:####################" << endl;
    cout << "threadID:"<<threadID <<" " << typeid(*monitorptr).name() <<" adress:" <<monitorptr <<endl;
    cout << "--------------After delete RuneMonitor:------------" << endl;


    monitor_thread.join();



    return 0;
}

运行结果如下,并且再次触发了dump。通过输出信息,可以确认,对象在析构之后,对象指针的地址没有变化,但是vptr指向的虚表发生了变化,类型也从ChildMonitor退化成了BaseMonitor。

现在有两个问题需要弄清楚:

  • 问题1.可以确定,是析构导致了vptr指向的变化,难道是有什么编译器自动生成的代码修复了Vptr?

  • 问题2.为什么刚才代码没有问题,加了一些辅助代码就可以触发dump了?

e1e93209cc8e70368ac61ce12641fc2d.png

1.析构为什么会导致vptr指向发生变化?

这个时候,大名鼎鼎的网站Complier Explorer https://gcc.godbolt.org/ 就派上用场了。

Complier Explorer可以在线输出C++代码的汇编结果,而且几乎支持市面上所有的编译器版本和最新的C++特性,包括GCC Clang MSVC等。

c3cbc6443ff5e3e0a670c1f5d79682f6.png

打开网站拷贝代码进去(选择了X84-64 GCC 11.2版本),可以看到很长一大片的汇编结果。因为有太多的标准库内容,所以看起来比较复杂。下面我把一些打印信息去掉然后再次编译,

be5358a53c841592b82d5313e7aec263.png

可以看到,汇编代码里面对于基类BaseMonitor有两个析构函数,分别是默认的析构以及delete版本的析构,懂汇编的同学应该很容易能看出来析构函数的执行逻辑。对于不太熟悉汇编的同学现在也可以通过ChatGPT的来辅助理解了。我们直接把上面的两个析构函数的汇编扔给ChatGPT,

947ff30e1f8b18c6b60f5785ace90064.png

这个回答非常简洁明了,第一个析构函数是常用于栈内空间对象离开作用域的析构(非动态的内存申请),为了能保证子类对象析构时按照继承链处理所有析构函数,每次析构的时候都会把vptr指向当前类型对应的虚表,所以当一个对象被销毁后它的虚指针会被修改,进而触发上面提到的崩溃。而对于使用new/malloc等常在堆上动态分配内存的操作,我们通常使用第二个版本的析构函数,他除了调用析构函数还会执行operator delete来释放对象占用的内存。

当然,你也可以让他逐行进行分析。

b3de4dc22c603f0a92ba03798eb1b0bd.png

不过,有两个版本的析构函数可能已经不太符合大部分同学对析构的认识。但如果你仔细观察汇编代码,会发现还有一个名为[complete object destructor]版本的析构函数。也就是说一个简单的BaseMonitor,编译器竟然会给他生成了三个版本的析构函数,这如何理解呢?

d594561862ef55df8b0e7e1083d41a2d.png

尝试问了一下GPT,发现他的回答是不准确的,并没有解释清楚这几个概念。最后在推荐资料以及stackoverflow里面才找到了真正的答案,

7b73a76b648c18a6510ec789c50a1b58.png

不太准确的回答

这其实涉及到API二进制兼容问题,而并非C++语言标准

Starting with GCC 3.2, GCC binary conventions for C++ are based on a written, vendor-neutral C++ ABI that was designed to be specific to 64-bit Itanium but also includes generic specifications that apply to any platform. This C++ ABI is also implemented by other compiler vendors on some platforms, notably GNU/Linux and BSD systems. We have tried hard to provide a stable ABI that will be compatible with future GCC releases, but it is possible that we will encounter problems that make this difficult. Such problems could include different interpretations of the C++ ABI by different vendors, bugs in the ABI, or bugs in the implementation of the ABI in different compilers. GCC’s -Wabi switch warns when G++ generates code that is probably not compatible with the C++ ABI.

简单来说,GCC为了保证跨平台代码的兼容性,从3.2版本开始,就对C++的二进制做了一个书面的、厂商中立的接口(ABI)约定(binary conventions)。这个ABI最初是为64位的Itanium设计的,但是现在它也包含了适用于任何平台的通用规范。

在这个ABI的规范中,我们看到他会要求编译器对不同情况生成不同版本的析构函数,

  • “基础对象析构函数base object destructor”。销毁对象本身以及数据成员和非虚基类。

  • “完整的对象析构函数complete object destructor”。除了执行base object destructor,它还会执行虚基类的析构函数。

  • “删除对象析构函数deleting object destructor”。执行完整对象析构函数执行的所有操作,此外还会调用operator delete释放内存。

在没有虚继承的情况下,base object destructor与complete object destructor是等价的,所以在汇编代码里面看不到complete object destructor的实现。(我们可以通过 ChatGPT拿到该ABI文档的连接)

1f667051bd293a2cfafb52a907d2148c.png

a8a0c169cb849289c7c543ba461ebb79.png

31a86b0d0ad29b8542e06ebd7d4236e5.png

https://stackoverflow.com/questions/6613870/gnu-gcc-g-why-does-it-generate-multiple-dtors 

https://gcc.gnu.org/onlinedocs/gcc/Compatibility.html https://web.archive.org/web/20100315072857/

http://www.codesourcery.com/public/cxx-abi/abi.html#mangling

到这里问题1已经解决,我们已经确认析构函数会默认修改vptr指向当前类的虚表,所以Child对象的vptr在离开作用域后就指向了BaseMonitor的虚表。但不管怎么说,我们不应该再去操作一个已经析构过的对象了。

2.为什么加了一些辅助代码就能复现这个崩溃?

我们再回过头看问题2,为什么最开始的版本没有问题,加了一些代码之后就触发了dump么?

通过对比和分析几个版本的代码(version1-version3),发现主要的问题在于是否生成了析构函数,只要有BaseMonitor的析构函数,那么就会触发vptr的变化以及dump。这也和我们之前从书本上学到的知识“如果自己不给类声明析构函数,编译器机会自动生成一个默认析构函数”有一点知觉上的冲突,通过汇编代码,其实可以发现,如果一个类非常简单,那么其实析构函数什么都不需要做,编译器就会将其优化掉。

d51234c830aa2de6e2d435229675ba22.png

但是对于一个含有用户自定义的non trivial成员对象(比如vector)的类,编译器是会默认生成析构函数的(非virtual)。

d7d798d8020090c330666b5ff8b41e5a.png

b4d790d16eb806caa755af9ea6402a9d.png

c813a25040a1e0710bdf85fb3d24ce50.png

在最开始版本的代码中,也正是因为BaseMonitor有了vector成员,才会让编辑器默认生成了一个析构函数,进而触发了基类对象析构后的vptr修改。

最后,再通过上面的汇编帮助大家分析一个常见的知识点——虚析构函数的原理。通常我们说如果手动delete一个基类的指针,必须要声明成虚析构函数,才能保证子类析构函数的正常执行。如果我把上面析构函数的virtual拿掉,就会发现基类和子类 [deleting destructor] 版本的析构函数都没有生成,析构的时候直接调用的基类析构函数,operator delete也直接生成在main函数里面。

由于一个指向子类对象的基类指针在销毁时没有通过执行到子类自身析构函数,所以才很容易造成内存泄漏(当子类对象有动态内存申请的情况下)。

b2b43eeefc37966b22d4e61339d18dca.png

48d89b3dc88de7ec1042767c670caeef.png

总结:

这篇文章从一个非法操作导致的崩溃(调用已经被销毁对象成员函数)开始分析,逐步深入到析构函数的实现原理。通过与ChatGPT的沟通和Complier Explorer等网站的使用挖掘到了C++API里面关于析构函数的实现规范,也一定程度上了解了汇编基础知识。

从一名开发者的角度来讲,ChatGPT能做的远不止于此,他非常擅长那些在网络上已经比较成熟的内容和技术(比如帮我用不同语言实现各种经典算法)。不过对于网上比较难查到的资料(比如某个游戏引擎里面具体方法的使用),ChatGPT也很喜欢胡编乱造。但无论如何,通过合适的Prompt与ChatGPT沟通绝对可以让我们事半功倍,可以在许多方面提升我们的学习与工作效率。

如果你有关于ChatGPT的一些使用经验和技巧,也非常欢迎在评论区分享给大家~

 往期文章推荐 

454c17707730aae87942b8f0eeaf8a0e.png

游戏开发技术系列【想做游戏开发,我应该会点啥?】

da58f15738e53ce6ce22fffcb0169947.jpeg

C++面试系列【史上最全的C++/游戏开发面试经验总结】

我是Jerish,网易游戏工程师,6年从业经验。该公众号会定期输出技术干货和游戏科普的文章,关注我回复关键字可以获取游戏开发、操作系统、面试、C++、游戏设计等相关书籍和参考资料。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值