跨DLL的内存分配释放问题 Heap corruption

8 篇文章 0 订阅


  • 5191

这是个很典型的问题,在MSDN上也有描述。问题是这样的:

在一个DLL里面分配内存,然后在DLL的调用者EXE那里释放内存。

当DLL和EXE里面有一个是使用MT连接CRT的时候就有问题。如果DLL和EXE都使用MD,那么就没有问题。

先来看一下问题

直接使用原生指针来传递

在DLL里面创建一个导出函数,如:

[cpp]  view plain  copy
  1. void TestOriginalPointer(int** p)  
  2. {  
  3.     delete *p;  
  4.       
  5.     int* temp = new int;  
  6.     *temp = 1;  
  7.     *p = temp;  
  8. }  
这段代码的意思就是将传进来的数据先删除,再从新分配一个。

调用者代码:

[cpp]  view plain  copy
  1. // test1  
  2. typedef void(*fTest)(int**);  
  3. fTest TestOriginalPointer = (fTest)GetProcAddress(h, "TestOriginalPointer");  
  4.   
  5. int* p = new int;  
  6. *p = 0;  
  7. TestOriginalPointer(&p);  

这个示例代码在DLL和EXE都是MD连接CRT的时候是没有问题,但是当有一个是MT的时候就crash。看一下调用堆栈

当DLL里面的函数TestOriginalPointer尝试去delete的时候,就crash了。再来看个例子:

创建一个class来传递一段内存

[cpp]  view plain  copy
  1. class MyWrapper  
  2. {  
  3. public:  
  4.     explicit MyWrapper(int* p) : m_p(p)  
  5.     {}  
  6.   
  7.     ~MyWrapper()  
  8.     {  
  9.         if (m_p)  
  10.         {  
  11.               delete m_p;  
  12.               m_p = nullptr;  
  13.         }  
  14.     }  
  15.     void ChangeValue(int* p)  
  16.     {  
  17.         if (m_p)  
  18.         {  
  19.             delete m_p;  
  20.             m_p = p;  
  21.         }  
  22.     }  
  23. private:  
  24.     int* m_p;  
  25. };  
这个class很简单,构造的时候,把传进来的内存地址保存一下,然后析构的时候释放,另外有一个函数可以用来改变里面的内存。

在DLL里面再创建一个导出函数:

[cpp]  view plain  copy
  1. void TestMyWrapper(MyWrapper& p)  
  2. {  
  3.     p.ChangeValue(new int);  
  4. }  

调用:

[cpp]  view plain  copy
  1. // test2  
  2. typedef void(*fTestMyWrapper)(MyWrapper& p);  
  3. fTestMyWrapper TestMyWrapper = (fTestMyWrapper)GetProcAddress(h, "TestMyWrapper");  
  4.   
  5. MyWrapper w(new int);  
  6. TestMyWrapper(w);      
这段代码也会crash:

看了这两个例子,我们来分析一下根本原因吧。

根本原因

假设DLL是静态link crt (MT),EXE是动态link (MD)。我画了个示意图。

C++的new在windows上面,应该就是用malloc来实现的,malloc是CRT的一个函数。

在第一个例子中,假如EXE分配的内存地址是0x00008952,那么这个地址只有在灰色的那个CRT里面才有效,它指向了一块内存。然后我们在DLL里面想释放,就调用delete,这里问题就来了,DLL里面静态link了CRT, 那么delete的时候就会在DLL里面的CRT的heap里面找地址0x00008952,鬼知道指向哪里,这个时候去delete就会导致不可预测的后果了。所以这个问题的根本原因就是同一个内存地址在不同的CRT里面指向的地方是不一样。
如果DLL和EXE都是动态link crt,那么就没这个问题了,因为动态link的时候,就只有一个CRT DLL.DLL和EXE都用的是同一个CRT, 所以没问题。但是一旦其中有一个使用了静态link,就出问题了,这个时候就有2个CRT了。每一个静态link crt的DLL或者EXE, 内部都有自己的一份copy。

那么有什么解决方案呢?首先我觉得我们应该尽量避免DLL里面分配,EXE释放,或者反过来。这种代码会有隐患的。但是有些时候不可避免的时候,怎么办呢?办法也是有的。其实我们可以这么想,假设分配和释放是在同一个CRT里面就没有这个问题了。那么我们如何做到这一点呢?malloc,new等函数,我们是不能改变的,但是我们可以考虑给他们包装一层。我们可以使用虚函数。如果我们创建2个虚函数,一个用来分配内存,一个用来释放内存。在对象构造的时候,这个对象的虚表里面就已经指向了创建这个对象的模块里面的CRT的new和delete,那么当我们在DLL里面调用虚函数来释放的时候,系统会为我们找到构造对象时候的释放函数。这样就没有问题了。写代码试试吧。

用虚函数来分配释放内存

将之前的MyWrapper改造一下。其实就是将ChangeValue改成了虚函数。

[cpp]  view plain  copy
  1. class MyWrapperEx  
  2. {  
  3. public:  
  4.     explicit MyWrapperEx(int* p) : m_p(p)  
  5.     {}  
  6.   
  7.     virtual ~MyWrapperEx()  
  8.     {  
  9.         if (m_p)  
  10.         {  
  11.               delete m_p;  
  12.               m_p = nullptr;  
  13.         }  
  14.     }  
  15.     virtual void ChangeValue(int* p)  
  16.     {  
  17.         if (m_p)  
  18.         {  
  19.             delete m_p;  
  20.             m_p = p;  
  21.         }  
  22.     }  
  23. private:  
  24.     int* m_p;  
  25. };  

DLL里面新加一个导出函数。

[cpp]  view plain  copy
  1. void TestMyWrapperEx(MyWrapperEx& p)  
  2. {  
  3.     p.ChangeValue(new int);  
  4. }  
调用:

[cpp]  view plain  copy
  1. // test3  
  2.      typedef void(*fTestMyWrapperEx)(MyWrapperEx& p);  
  3.     fTestMyWrapperEx TestMyWrapperEx = (fTestMyWrapperEx)GetProcAddress(h, "TestMyWrapperEx");  
  4.   
  5.     MyWrapperEx w2(new int);  
  6.     TestMyWrapperEx(w2);  
这样,当w2被创建的时候,w2的虚表里面指向的是EXE里面的那个虚函数ChangeValue。这样当DLL调用ChangeValue的时候,系统会根据虚表来查找虚函数ChangeValue,显然ChangeValue是EXE里面的那份。这样new和ChangeValue里面的delete就在同一个CRT里面了,就是EXE的那份CRT,所以就没有问题了。看一下call stack就会很清楚了。

首先MyTest.exe调用MyDll2.dll的TestMyWrapperEx.然后在TestMyWrapperEx里面,当调用p.ChangeValue的时候,因为ChangeValue是虚函数,所以会通过虚表来查找,这个虚表刚好是MyTest.exe创建的,所以系统找到了MyText.exe里面的那份ChangeValue,这样new和delete就处于同一个CRT了。如果ChangeValue不是虚函数,那么在编译的时候就已经绑定好了,ChangeValue是DLL里面的那一份,这样new和delete就处于不同的CRT了,所以crash。

上面的代码其实有个问题,当TestMyWrapperEx里面调用p.ChangeValue的时候,先释放内存,在存储一个DLL里面new出来的一个内存,这样当对象析构的时候,就会发生问题了。这个对象(w2)是在EXE里面构造的,所以虚表里面的析构函数指的是EXE里面的那一份,那么现在的情况就是ChangeValue的参数指向的内存是DLL分配的,但是释放在EXE里面了,这样就又crash了。其实解决这个问题很简单,在ChangeValue的参数不要直接传个指针,可以传个需要的内存的大小,在ChangeValue内部来分配,这样就没有问题了。

其实我们可以自己创建一个专门的class来管理内存分配和释放。就好象是std::shared_ptr,如果你阅读std::shared_ptr的源代码,你会发现std::shared_ptr内部就是有一个class来处理delete,这个函数就是个虚函数。原理是差不多的。

OK,最后在总结一下,如果我们使用一个虚函数来管理new和delete,那么就可以通过虚表来找到构造对象的那个模块里面的虚函数。这样就可以保证new和delete处于同一个CRT. 好像说起来还是挺简单的,但是实际上想真的搞清楚这个问题,还是得搞自己一步一步去跟一下,这样就会很清楚了。

  • 5191

这是个很典型的问题,在MSDN上也有描述。问题是这样的:

在一个DLL里面分配内存,然后在DLL的调用者EXE那里释放内存。

当DLL和EXE里面有一个是使用MT连接CRT的时候就有问题。如果DLL和EXE都使用MD,那么就没有问题。

先来看一下问题

直接使用原生指针来传递

在DLL里面创建一个导出函数,如:

[cpp]  view plain  copy
  1. void TestOriginalPointer(int** p)  
  2. {  
  3.     delete *p;  
  4.       
  5.     int* temp = new int;  
  6.     *temp = 1;  
  7.     *p = temp;  
  8. }  
这段代码的意思就是将传进来的数据先删除,再从新分配一个。

调用者代码:

[cpp]  view plain  copy
  1. // test1  
  2. typedef void(*fTest)(int**);  
  3. fTest TestOriginalPointer = (fTest)GetProcAddress(h, "TestOriginalPointer");  
  4.   
  5. int* p = new int;  
  6. *p = 0;  
  7. TestOriginalPointer(&p);  

这个示例代码在DLL和EXE都是MD连接CRT的时候是没有问题,但是当有一个是MT的时候就crash。看一下调用堆栈

当DLL里面的函数TestOriginalPointer尝试去delete的时候,就crash了。再来看个例子:

创建一个class来传递一段内存

[cpp]  view plain  copy
  1. class MyWrapper  
  2. {  
  3. public:  
  4.     explicit MyWrapper(int* p) : m_p(p)  
  5.     {}  
  6.   
  7.     ~MyWrapper()  
  8.     {  
  9.         if (m_p)  
  10.         {  
  11.               delete m_p;  
  12.               m_p = nullptr;  
  13.         }  
  14.     }  
  15.     void ChangeValue(int* p)  
  16.     {  
  17.         if (m_p)  
  18.         {  
  19.             delete m_p;  
  20.             m_p = p;  
  21.         }  
  22.     }  
  23. private:  
  24.     int* m_p;  
  25. };  
这个class很简单,构造的时候,把传进来的内存地址保存一下,然后析构的时候释放,另外有一个函数可以用来改变里面的内存。

在DLL里面再创建一个导出函数:

[cpp]  view plain  copy
  1. void TestMyWrapper(MyWrapper& p)  
  2. {  
  3.     p.ChangeValue(new int);  
  4. }  

调用:

[cpp]  view plain  copy
  1. // test2  
  2. typedef void(*fTestMyWrapper)(MyWrapper& p);  
  3. fTestMyWrapper TestMyWrapper = (fTestMyWrapper)GetProcAddress(h, "TestMyWrapper");  
  4.   
  5. MyWrapper w(new int);  
  6. TestMyWrapper(w);      
这段代码也会crash:

看了这两个例子,我们来分析一下根本原因吧。

根本原因

假设DLL是静态link crt (MT),EXE是动态link (MD)。我画了个示意图。

C++的new在windows上面,应该就是用malloc来实现的,malloc是CRT的一个函数。

在第一个例子中,假如EXE分配的内存地址是0x00008952,那么这个地址只有在灰色的那个CRT里面才有效,它指向了一块内存。然后我们在DLL里面想释放,就调用delete,这里问题就来了,DLL里面静态link了CRT, 那么delete的时候就会在DLL里面的CRT的heap里面找地址0x00008952,鬼知道指向哪里,这个时候去delete就会导致不可预测的后果了。所以这个问题的根本原因就是同一个内存地址在不同的CRT里面指向的地方是不一样。
如果DLL和EXE都是动态link crt,那么就没这个问题了,因为动态link的时候,就只有一个CRT DLL.DLL和EXE都用的是同一个CRT, 所以没问题。但是一旦其中有一个使用了静态link,就出问题了,这个时候就有2个CRT了。每一个静态link crt的DLL或者EXE, 内部都有自己的一份copy。

那么有什么解决方案呢?首先我觉得我们应该尽量避免DLL里面分配,EXE释放,或者反过来。这种代码会有隐患的。但是有些时候不可避免的时候,怎么办呢?办法也是有的。其实我们可以这么想,假设分配和释放是在同一个CRT里面就没有这个问题了。那么我们如何做到这一点呢?malloc,new等函数,我们是不能改变的,但是我们可以考虑给他们包装一层。我们可以使用虚函数。如果我们创建2个虚函数,一个用来分配内存,一个用来释放内存。在对象构造的时候,这个对象的虚表里面就已经指向了创建这个对象的模块里面的CRT的new和delete,那么当我们在DLL里面调用虚函数来释放的时候,系统会为我们找到构造对象时候的释放函数。这样就没有问题了。写代码试试吧。

用虚函数来分配释放内存

将之前的MyWrapper改造一下。其实就是将ChangeValue改成了虚函数。

[cpp]  view plain  copy
  1. class MyWrapperEx  
  2. {  
  3. public:  
  4.     explicit MyWrapperEx(int* p) : m_p(p)  
  5.     {}  
  6.   
  7.     virtual ~MyWrapperEx()  
  8.     {  
  9.         if (m_p)  
  10.         {  
  11.               delete m_p;  
  12.               m_p = nullptr;  
  13.         }  
  14.     }  
  15.     virtual void ChangeValue(int* p)  
  16.     {  
  17.         if (m_p)  
  18.         {  
  19.             delete m_p;  
  20.             m_p = p;  
  21.         }  
  22.     }  
  23. private:  
  24.     int* m_p;  
  25. };  

DLL里面新加一个导出函数。

[cpp]  view plain  copy
  1. void TestMyWrapperEx(MyWrapperEx& p)  
  2. {  
  3.     p.ChangeValue(new int);  
  4. }  
调用:

[cpp]  view plain  copy
  1. // test3  
  2.      typedef void(*fTestMyWrapperEx)(MyWrapperEx& p);  
  3.     fTestMyWrapperEx TestMyWrapperEx = (fTestMyWrapperEx)GetProcAddress(h, "TestMyWrapperEx");  
  4.   
  5.     MyWrapperEx w2(new int);  
  6.     TestMyWrapperEx(w2);  
这样,当w2被创建的时候,w2的虚表里面指向的是EXE里面的那个虚函数ChangeValue。这样当DLL调用ChangeValue的时候,系统会根据虚表来查找虚函数ChangeValue,显然ChangeValue是EXE里面的那份。这样new和ChangeValue里面的delete就在同一个CRT里面了,就是EXE的那份CRT,所以就没有问题了。看一下call stack就会很清楚了。

首先MyTest.exe调用MyDll2.dll的TestMyWrapperEx.然后在TestMyWrapperEx里面,当调用p.ChangeValue的时候,因为ChangeValue是虚函数,所以会通过虚表来查找,这个虚表刚好是MyTest.exe创建的,所以系统找到了MyText.exe里面的那份ChangeValue,这样new和delete就处于同一个CRT了。如果ChangeValue不是虚函数,那么在编译的时候就已经绑定好了,ChangeValue是DLL里面的那一份,这样new和delete就处于不同的CRT了,所以crash。

上面的代码其实有个问题,当TestMyWrapperEx里面调用p.ChangeValue的时候,先释放内存,在存储一个DLL里面new出来的一个内存,这样当对象析构的时候,就会发生问题了。这个对象(w2)是在EXE里面构造的,所以虚表里面的析构函数指的是EXE里面的那一份,那么现在的情况就是ChangeValue的参数指向的内存是DLL分配的,但是释放在EXE里面了,这样就又crash了。其实解决这个问题很简单,在ChangeValue的参数不要直接传个指针,可以传个需要的内存的大小,在ChangeValue内部来分配,这样就没有问题了。

其实我们可以自己创建一个专门的class来管理内存分配和释放。就好象是std::shared_ptr,如果你阅读std::shared_ptr的源代码,你会发现std::shared_ptr内部就是有一个class来处理delete,这个函数就是个虚函数。原理是差不多的。

OK,最后在总结一下,如果我们使用一个虚函数来管理new和delete,那么就可以通过虚表来找到构造对象的那个模块里面的虚函数。这样就可以保证new和delete处于同一个CRT. 好像说起来还是挺简单的,但是实际上想真的搞清楚这个问题,还是得搞自己一步一步去跟一下,这样就会很清楚了。



转自:http://blog.csdn.net/zj510/article/details/35290505
  • 5191

这是个很典型的问题,在MSDN上也有描述。问题是这样的:

在一个DLL里面分配内存,然后在DLL的调用者EXE那里释放内存。

当DLL和EXE里面有一个是使用MT连接CRT的时候就有问题。如果DLL和EXE都使用MD,那么就没有问题。

先来看一下问题

直接使用原生指针来传递

在DLL里面创建一个导出函数,如:

[cpp]  view plain  copy
  1. void TestOriginalPointer(int** p)  
  2. {  
  3.     delete *p;  
  4.       
  5.     int* temp = new int;  
  6.     *temp = 1;  
  7.     *p = temp;  
  8. }  
这段代码的意思就是将传进来的数据先删除,再从新分配一个。

调用者代码:

[cpp]  view plain  copy
  1. // test1  
  2. typedef void(*fTest)(int**);  
  3. fTest TestOriginalPointer = (fTest)GetProcAddress(h, "TestOriginalPointer");  
  4.   
  5. int* p = new int;  
  6. *p = 0;  
  7. TestOriginalPointer(&p);  

这个示例代码在DLL和EXE都是MD连接CRT的时候是没有问题,但是当有一个是MT的时候就crash。看一下调用堆栈

当DLL里面的函数TestOriginalPointer尝试去delete的时候,就crash了。再来看个例子:

创建一个class来传递一段内存

[cpp]  view plain  copy
  1. class MyWrapper  
  2. {  
  3. public:  
  4.     explicit MyWrapper(int* p) : m_p(p)  
  5.     {}  
  6.   
  7.     ~MyWrapper()  
  8.     {  
  9.         if (m_p)  
  10.         {  
  11.               delete m_p;  
  12.               m_p = nullptr;  
  13.         }  
  14.     }  
  15.     void ChangeValue(int* p)  
  16.     {  
  17.         if (m_p)  
  18.         {  
  19.             delete m_p;  
  20.             m_p = p;  
  21.         }  
  22.     }  
  23. private:  
  24.     int* m_p;  
  25. };  
这个class很简单,构造的时候,把传进来的内存地址保存一下,然后析构的时候释放,另外有一个函数可以用来改变里面的内存。

在DLL里面再创建一个导出函数:

[cpp]  view plain  copy
  1. void TestMyWrapper(MyWrapper& p)  
  2. {  
  3.     p.ChangeValue(new int);  
  4. }  

调用:

[cpp]  view plain  copy
  1. // test2  
  2. typedef void(*fTestMyWrapper)(MyWrapper& p);  
  3. fTestMyWrapper TestMyWrapper = (fTestMyWrapper)GetProcAddress(h, "TestMyWrapper");  
  4.   
  5. MyWrapper w(new int);  
  6. TestMyWrapper(w);      
这段代码也会crash:

看了这两个例子,我们来分析一下根本原因吧。

根本原因

假设DLL是静态link crt (MT),EXE是动态link (MD)。我画了个示意图。

C++的new在windows上面,应该就是用malloc来实现的,malloc是CRT的一个函数。

在第一个例子中,假如EXE分配的内存地址是0x00008952,那么这个地址只有在灰色的那个CRT里面才有效,它指向了一块内存。然后我们在DLL里面想释放,就调用delete,这里问题就来了,DLL里面静态link了CRT, 那么delete的时候就会在DLL里面的CRT的heap里面找地址0x00008952,鬼知道指向哪里,这个时候去delete就会导致不可预测的后果了。所以这个问题的根本原因就是同一个内存地址在不同的CRT里面指向的地方是不一样。
如果DLL和EXE都是动态link crt,那么就没这个问题了,因为动态link的时候,就只有一个CRT DLL.DLL和EXE都用的是同一个CRT, 所以没问题。但是一旦其中有一个使用了静态link,就出问题了,这个时候就有2个CRT了。每一个静态link crt的DLL或者EXE, 内部都有自己的一份copy。

那么有什么解决方案呢?首先我觉得我们应该尽量避免DLL里面分配,EXE释放,或者反过来。这种代码会有隐患的。但是有些时候不可避免的时候,怎么办呢?办法也是有的。其实我们可以这么想,假设分配和释放是在同一个CRT里面就没有这个问题了。那么我们如何做到这一点呢?malloc,new等函数,我们是不能改变的,但是我们可以考虑给他们包装一层。我们可以使用虚函数。如果我们创建2个虚函数,一个用来分配内存,一个用来释放内存。在对象构造的时候,这个对象的虚表里面就已经指向了创建这个对象的模块里面的CRT的new和delete,那么当我们在DLL里面调用虚函数来释放的时候,系统会为我们找到构造对象时候的释放函数。这样就没有问题了。写代码试试吧。

用虚函数来分配释放内存

将之前的MyWrapper改造一下。其实就是将ChangeValue改成了虚函数。

[cpp]  view plain  copy
  1. class MyWrapperEx  
  2. {  
  3. public:  
  4.     explicit MyWrapperEx(int* p) : m_p(p)  
  5.     {}  
  6.   
  7.     virtual ~MyWrapperEx()  
  8.     {  
  9.         if (m_p)  
  10.         {  
  11.               delete m_p;  
  12.               m_p = nullptr;  
  13.         }  
  14.     }  
  15.     virtual void ChangeValue(int* p)  
  16.     {  
  17.         if (m_p)  
  18.         {  
  19.             delete m_p;  
  20.             m_p = p;  
  21.         }  
  22.     }  
  23. private:  
  24.     int* m_p;  
  25. };  

DLL里面新加一个导出函数。

[cpp]  view plain  copy
  1. void TestMyWrapperEx(MyWrapperEx& p)  
  2. {  
  3.     p.ChangeValue(new int);  
  4. }  
调用:

[cpp]  view plain  copy
  1. // test3  
  2.      typedef void(*fTestMyWrapperEx)(MyWrapperEx& p);  
  3.     fTestMyWrapperEx TestMyWrapperEx = (fTestMyWrapperEx)GetProcAddress(h, "TestMyWrapperEx");  
  4.   
  5.     MyWrapperEx w2(new int);  
  6.     TestMyWrapperEx(w2);  
这样,当w2被创建的时候,w2的虚表里面指向的是EXE里面的那个虚函数ChangeValue。这样当DLL调用ChangeValue的时候,系统会根据虚表来查找虚函数ChangeValue,显然ChangeValue是EXE里面的那份。这样new和ChangeValue里面的delete就在同一个CRT里面了,就是EXE的那份CRT,所以就没有问题了。看一下call stack就会很清楚了。

首先MyTest.exe调用MyDll2.dll的TestMyWrapperEx.然后在TestMyWrapperEx里面,当调用p.ChangeValue的时候,因为ChangeValue是虚函数,所以会通过虚表来查找,这个虚表刚好是MyTest.exe创建的,所以系统找到了MyText.exe里面的那份ChangeValue,这样new和delete就处于同一个CRT了。如果ChangeValue不是虚函数,那么在编译的时候就已经绑定好了,ChangeValue是DLL里面的那一份,这样new和delete就处于不同的CRT了,所以crash。

上面的代码其实有个问题,当TestMyWrapperEx里面调用p.ChangeValue的时候,先释放内存,在存储一个DLL里面new出来的一个内存,这样当对象析构的时候,就会发生问题了。这个对象(w2)是在EXE里面构造的,所以虚表里面的析构函数指的是EXE里面的那一份,那么现在的情况就是ChangeValue的参数指向的内存是DLL分配的,但是释放在EXE里面了,这样就又crash了。其实解决这个问题很简单,在ChangeValue的参数不要直接传个指针,可以传个需要的内存的大小,在ChangeValue内部来分配,这样就没有问题了。

其实我们可以自己创建一个专门的class来管理内存分配和释放。就好象是std::shared_ptr,如果你阅读std::shared_ptr的源代码,你会发现std::shared_ptr内部就是有一个class来处理delete,这个函数就是个虚函数。原理是差不多的。

OK,最后在总结一下,如果我们使用一个虚函数来管理new和delete,那么就可以通过虚表来找到构造对象的那个模块里面的虚函数。这样就可以保证new和delete处于同一个CRT. 好像说起来还是挺简单的,但是实际上想真的搞清楚这个问题,还是得搞自己一步一步去跟一下,这样就会很清楚了。


  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值