动态链接库全面浅析(一)-----Win32 动态链接库

转载地址:http://blog.csdn.net/lh844386434/article/details/6734528

言前:由于最近复习了下动态链接库,所以决定写一个动态链接库专题。动态链接库网上各种达人已近写过很多了,资料也是一大把一大把的。但是我还是想写一份,因为网上讲的都很分散,讲的不是很系统,而且很多问题没有深究。因此我自己还是写一份吧,因为说明了是浅析。所以说我不可能讲的太深入,但是本文还是立足于有一定动态链接库编程基础的读者。 动态链接库专题我准备分为4篇文章来介绍 1.Win32 动态链接库 2.MFC常规动态链接库 3.MFC扩展动态链接库 4.各种链接库总结对比


好吧,现在就开始Win32 动态链接库编程吧。首先还是简单的介绍下基本知识(由于本人比较懒,所以只是大概提一下基本知识,这点上不会很全面,但是本人还是强烈建议大家,还是首先在网上系统的学习一下动态链接库的最基本知识。如动态链接库在内存上的映射方式,查看动态链接库的导入导出啊等等之类的最最最基本的知识)

基础知识:

1.动态链接库有很多的优点:如隐藏实现细节,实现代码共享,便于升级产品,可以在不同语言之间交互等等。

2.我们在编写好了动态链接库以后,我们必须导出我们留给用户使用的接口,然后用户在自己的程序里导入这些接口以便自己使用。导出的方式会有两种,一种是使用dllimport/dllexport ,还有一种是使用def 模块定义文件来导出,在后面我们会详细全面的介绍这种到出方法

3.调用:一共有两种调用动态链接库的方方式

动态调用:在程序中使用 LoadLibrary()/FreeLibrary() 动态的获得函数,类成员变量的指针。然后通过指针调用函数/成员函数。这种方法比较适合大型项目中使用,用户在我们要使用动态链接库的时候才去加载,使用完了以后就释放掉,这样对内存的利用效率还是比较高的。

静态调用:使用#pragma comment(lib,"XXXX.lib")或在编译器中设置,相关选项,来使用动态链接库,这种方式比较方便,不用我们显示的加载和释放动态链接库。程序在运行时会自动的去加载我们制定的动态链接库。在这种情况下我们可以用dumpbin 的imports 命令来查看到导入的动态链接库的接口。如果是动态调用的话,我们是无法查看到我们自己导出的动态链接库的imports信息的哦

  动态链接库调用路径

  1. 程序载入的目录.
  2. 当前目录.
  3. system目录. 使用GetSystemDirectory 函数获取这个目录路径.
  4. Windows目录. 使用GetWindowsDirectory函数可以获取该目录路径.
  5. PATH 环境变量设置的目录列表

4.我们导出的动态链接库通常,编译器在编译的时候通常都会给我们的函数修改名称这导致了,导出的函数的名称相应的发生了改变。下面便是编译器改名的具体规则。

 对于C语言编译器

对于__stdcall调用约定,编译器和链接器会在输出函数名前加上一个下划线前缀,函数名后面加上一个“@”符号和其参数的字节数,例如_functionname@number。__cdecl调用约定仅在输出函数名前加上一个下划线前缀,例如_functionname。__fastcall调用约定在输出函数名前加上一个“@”符号,后面也是一个“@”符号和其参数的字节数,例如@functionname@number 

对于C++

函数修饰都是以一个“?”开始,后面紧跟函数的名字,再后面是参数表的开始标识和按照参数类型代号拼出的参数表。对于__stdcall方式,参数表的开始标识是“@@YG”,对于__cdecl方式则是“@@YA”,对于__fastcall方式则是“@@YI”。参数表的拼写代号如下所示: 
X--void    
D--char    
E--unsigned char    
F--short    
H--int    
I--unsigned int    
J--long    
K--unsigned long(DWORD) 
M--float    
N--double    
_N--bool 
U--struct 

对于C++的类成员函数(其调用方式是thiscall),函数的名字修饰与非成员的C++函数稍有不同,首先就是在函数名字和参数表之间插入以“@”字符引导的类名;其次是参数表的开始标识不同,公有(public)成员函数的标识是“@@QAE”,保护(protected)成员函数的标识是“@@IAE”,私有(private)成员函数的标识是“@@AAE”,如果函数声明使用了const关键字,则相应的标识应分别为“@@QBE”,“@@IBE”和“@@ABE”。如果参数类型是类实例的引用,则使用“AAV1”,对于const类型的引用,则使用“ABV1”

假如我们不想编译器修改名称采用C++那种方式,我们可以指定以C语言的方式导出动态链接库,或者用Def文件指定我们导出文件的名字,(但是这样种方式可能遇到冲突,比如,我们定义了2个类,两个类都有同样名字的Create()函数,如果我们这两个类的Create函数有要用def指定文件导出,但是我们又想将这两个函数都以Create名称导出的话,此时便会有点小麻烦,后面我会提供一个例子来解决这个问题)。

5.DLLMAIN:

  1. 系统是在什么时候调用DllMain函数的呢?静态链接时,或动态链接时调用LoadLibrary和FreeLibrary都会调用DllMain函数。DllMain的第二个参数fdwReason指明了系统调用Dll的原因,它可能是::  
  2.   DLL_PROCESS_ATTACH、  
  3.   DLL_PROCESS_DETACH、  
  4.   DLL_THREAD_ATTACH、  
  5.   DLL_THREAD_DETACH。  
  6.   以下从这四种情况来分析系统何时调用了DllMain。  
  7. 1 DLL_PROCESS_ATTACH  
  8.   大家都知道,一个程序要调用Dll里的函数,首先要先把DLL文件映射到进程的地址空间。要把一个DLL文件映射到进程的地址空间,有两种方法:静态链接和动态链接的LoadLibrary或者LoadLibraryEx。  
  9.   当一个DLL文件被映射到进程的地址空间时,系统调用该DLL的DllMain函数,传递的fdwReason参数为DLL_PROCESS_ATTACH,这种调用只会发生在第一次映射时。如果同一个进程后来为已经映射进来的DLL再次调用LoadLibrary或者LoadLibraryEx,操作系统只会增加DLL的使用次数,它不会再用DLL_PROCESS_ATTACH调用DLL的DllMain函数。不同进程用LoadLibrary同一个DLL时,每个进程的第一次映射都会用DLL_PROCESS_ATTACH调用DLL的DllMain函数。  
  10.   可参考DllMainTest的DLL_PROCESS_ATTACH_Test函数。  
  11. 2 DLL_PROCESS_DETACH  
  12.   当DLL被从进程的地址空间解除映射时,系统调用了它的DllMain,传递的fdwReason值是DLL_PROCESS_DETACH。当DLL处理该值时,它应该执行进程相关的清理工作。  
  13.   那么什么时候DLL被从进程的地址空间解除映射呢?两种情况:  
  14.   FreeLibrary解除DLL映射(有几个LoadLibrary,就要有几个FreeLibrary)  
  15.   进程结束而解除DLL映射,在进程结束前还没有解除DLL的映射,进程结束后会解除DLL映射。(如果进程的终结是因为调用了TerminateProcess,系统就不会用DLL_PROCESS_DETACH来调用DLL的DllMain函数。这就意味着DLL在进程结束前没有机会执行任何清理工作。)  
  16.   注意:当用DLL_PROCESS_ATTACH调用DLL的DllMain函数时,如果返回FALSE,说明没有初始化成功,系统仍会用DLL_PROCESS_DETACH调用DLL的DllMain函数。因此,必须确保清理那些没有成功初始化的东西。  
  17.   可参考DllMainTest的DLL_PROCESS_DETACH_Test函数。  
  18. 3 DLL_THREAD_ATTACH  
  19.   当进程创建一线程时,系统查看当前映射到进程地址空间中的所有DLL文件映像,并用值DLL_THREAD_ATTACH调用DLL的DllMain函数。  
  20.   新创建的线程负责执行这次的DLL的DllMain函数,只有当所有的DLL都处理完这一通知后,系统才允许进程开始执行它的线程函数。  
  21.   注意跟DLL_PROCESS_ATTACH的区别,我们在前面说过,第n(n>=2)次以后地把DLL映像文件映射到进程的地址空间时,是不再用DLL_PROCESS_ATTACH调用DllMain的。而DLL_THREAD_ATTACH不同,进程中的每次建立线程,都会用值DLL_THREAD_ATTACH调用DllMain函数,哪怕是线程中建立线程也一样。  
  22. 4 DLL_THREAD_DETACH  
  23.   如果线程调用了ExitThread来结束线程(线程函数返回时,系统也会自动调用ExitThread),系统查看当前映射到进程空间中的所有DLL文件映像,并用DLL_THREAD_DETACH来调用DllMain函数,通知所有的DLL去执行线程级的清理工作。  
  24.   注意:如果线程的结束是因为系统中的一个线程调用了TerminateThread,系统就不会用值DLL_THREAD_DETACH来调用所有DLL的DllMain函数。  
系统是在什么时候调用DllMain函数的呢?静态链接时,或动态链接时调用LoadLibrary和FreeLibrary都会调用DllMain函数。DllMain的第二个参数fdwReason指明了系统调用Dll的原因,它可能是::
  DLL_PROCESS_ATTACH、
  DLL_PROCESS_DETACH、
  DLL_THREAD_ATTACH、
  DLL_THREAD_DETACH。
  以下从这四种情况来分析系统何时调用了DllMain。
1 DLL_PROCESS_ATTACH
  大家都知道,一个程序要调用Dll里的函数,首先要先把DLL文件映射到进程的地址空间。要把一个DLL文件映射到进程的地址空间,有两种方法:静态链接和动态链接的LoadLibrary或者LoadLibraryEx。
  当一个DLL文件被映射到进程的地址空间时,系统调用该DLL的DllMain函数,传递的fdwReason参数为DLL_PROCESS_ATTACH,这种调用只会发生在第一次映射时。如果同一个进程后来为已经映射进来的DLL再次调用LoadLibrary或者LoadLibraryEx,操作系统只会增加DLL的使用次数,它不会再用DLL_PROCESS_ATTACH调用DLL的DllMain函数。不同进程用LoadLibrary同一个DLL时,每个进程的第一次映射都会用DLL_PROCESS_ATTACH调用DLL的DllMain函数。
  可参考DllMainTest的DLL_PROCESS_ATTACH_Test函数。
2 DLL_PROCESS_DETACH
  当DLL被从进程的地址空间解除映射时,系统调用了它的DllMain,传递的fdwReason值是DLL_PROCESS_DETACH。当DLL处理该值时,它应该执行进程相关的清理工作。
  那么什么时候DLL被从进程的地址空间解除映射呢?两种情况:
  FreeLibrary解除DLL映射(有几个LoadLibrary,就要有几个FreeLibrary)
  进程结束而解除DLL映射,在进程结束前还没有解除DLL的映射,进程结束后会解除DLL映射。(如果进程的终结是因为调用了TerminateProcess,系统就不会用DLL_PROCESS_DETACH来调用DLL的DllMain函数。这就意味着DLL在进程结束前没有机会执行任何清理工作。)
  注意:当用DLL_PROCESS_ATTACH调用DLL的DllMain函数时,如果返回FALSE,说明没有初始化成功,系统仍会用DLL_PROCESS_DETACH调用DLL的DllMain函数。因此,必须确保清理那些没有成功初始化的东西。
  可参考DllMainTest的DLL_PROCESS_DETACH_Test函数。
3 DLL_THREAD_ATTACH
  当进程创建一线程时,系统查看当前映射到进程地址空间中的所有DLL文件映像,并用值DLL_THREAD_ATTACH调用DLL的DllMain函数。
  新创建的线程负责执行这次的DLL的DllMain函数,只有当所有的DLL都处理完这一通知后,系统才允许进程开始执行它的线程函数。
  注意跟DLL_PROCESS_ATTACH的区别,我们在前面说过,第n(n>=2)次以后地把DLL映像文件映射到进程的地址空间时,是不再用DLL_PROCESS_ATTACH调用DllMain的。而DLL_THREAD_ATTACH不同,进程中的每次建立线程,都会用值DLL_THREAD_ATTACH调用DllMain函数,哪怕是线程中建立线程也一样。
4 DLL_THREAD_DETACH
  如果线程调用了ExitThread来结束线程(线程函数返回时,系统也会自动调用ExitThread),系统查看当前映射到进程空间中的所有DLL文件映像,并用DLL_THREAD_DETACH来调用DllMain函数,通知所有的DLL去执行线程级的清理工作。
  注意:如果线程的结束是因为系统中的一个线程调用了TerminateThread,系统就不会用值DLL_THREAD_DETACH来调用所有DLL的DllMain函数。


下面就通过def、和常规导出方法 对比着,讲解动态链接库

一、以_declspec方式导入导出 :

这种方式是最传统的方式,我相信很多人也最熟悉这种方式导出、导入数据库,通常我们通过定义宏来,来导出函数、类成员函数如下

导出:定义宏

#define DLLPORT _declspec(dllexport)

  1. XXX.H/   
  2. #ifdef  DLLPORT    
  3. #else    
  4. #define _declspec(dllimport)   
  5. #endif   
  6. ///  
XXX.H/
#ifdef  DLLPORT 
#else 
#define _declspec(dllimport)
#endif
///

在导入时只需要包含上面那个XXX.H,便导入了导入了动态链接库。

导出函数

  1. DLLPORT void  Display(string txt);  
DLLPORT void  Display(string txt);
在函数面前 加上我们先前定义的宏,这样便导出了函数。如果我们使用dumpbin 命令查询下 函数导出情况,我会发现:


我们注意到确实,函数是被导出了,但是被编译器修改了名字


如果我们不想让编译器改名,我们可以先申明extern “C”  

将定义语言该为:#define DLLPORT extern"C" _declspec(dllexport)

重新编译,再用dumpbin 命令查看,导出函数没有被改名


(当然为了不想让函数被改名,我们也可以采用def模块定义文件,后面会详细讲解模块定义文件)

同理我们也可以导出一个变量:

  1. DLLPORT int g_nTest = 2;  
DLLPORT int g_nTest = 2;


我们可以看到导出的变量同样被修改了名称,当然如果我们同样用extern"C"声明 ,我们也同样会看到变量也就不会改名了。

 调用///

  1.        HMODULE hLibModule= LoadLibrary(TEXT("Win32_Dll_Test.dll"));//加载动态链接库,你懂的   
  2. typedef void(* PDISPLAY)(string str);  
  3. PDISPLAY pds = (PDISPLAY)GetProcAddress(hLibModule, "Display");  
  4. string str = "动态调DLL 函数:Dispaly..";  
  5. pds(str);  
  6. //调用导出变量   
  7. int *p;  
  8. p = (int *)GetProcAddress(hLibModule,"g_nTest");  
  9. cout<<"动态调用g_nTest: "<<*p<<endl;  
  10. FreeLibrary(hLibModule);//释放动态链接库  
        HMODULE hLibModule= LoadLibrary(TEXT("Win32_Dll_Test.dll"));//加载动态链接库,你懂的
	typedef void(* PDISPLAY)(string str);
	PDISPLAY pds = (PDISPLAY)GetProcAddress(hLibModule, "Display");
	string str = "动态调DLL 函数:Dispaly..";
	pds(str);
	//调用导出变量
	int *p;
	p = (int *)GetProcAddress(hLibModule,"g_nTest");
	cout<<"动态调用g_nTest: "<<*p<<endl;
	FreeLibrary(hLibModule);//释放动态链接库
动态 调用都是 先定义函数、变量的指针,然后或的函数、变量的地址,最后通过指针调用

当然,我们也可以使用静态加载

  1. #pragma comment(lib, "Win32_Dll_Test.lib")//在头文件中告诉编译器在程序运行时加载动态链接库  
#pragma comment(lib, "Win32_Dll_Test.lib")//在头文件中告诉编译器在程序运行时加载动态链接库
调用时:

  1. Display("动态调DLL 函数:Dispaly..");  
  2.     cout<<"动态调用g_nTest: "<<g_nTest<<endl;  
Display("动态调DLL 函数:Dispaly..");
	cout<<"动态调用g_nTest: "<<g_nTest<<endl;

你将会看到调用的效果和第一种一模一样。

导出类 :Win32 也可以导出类,但是Win32 DLL 导出的类中不能使用任何与MFC相关的东西。

为了导出类我们只需要 如下面定义我们的类

  1. class DLLPORT Rectangle  
  2. {  
  3. public:  
  4.      float m_Area;  
  5. public :  
  6.      float CalcArea(float x, float y);  
  7. };  
class DLLPORT Rectangle
{
public:
	 float m_Area;
public :
	 float CalcArea(float x, float y);
};

这样定义了以后,我们再来使用dumpbin -exports 来查看下导出结果:

我们可以看到,类被导出了,但是请注意!在这里,我们并没有看到我们类里面定义的那个成员变量被导出。那么是不是那个成员变量 float m_Area 没有被导出呢?是不是由于没有导出,我们就不能在外面直接使用这个成员变量呢? 答案是否定的,我个人感觉,成员变量是导出了的,只是没有显示而已。因为后面你会看到,我们将能直接从外面使用,访问到该变量。这久隐含的说明了成员变量都被导出了,不是么?

(注意:在导出类时,是不能有extern "C"的,其中的原因 我想大家稍微想想就会明白的)

当然 ,如果你不想导出类的话,而是只想导出类里面的部分函数,那么你只需要将类内部要导出的函数 加上DLLPORT就可以了,这就知道出了指定的函数

  1. class  Rectangle  
  2. {  
  3. public:  
  4.      float m_Area;//注意:我们不能导出成员变量 如果我们这样: DLLPORT float m_Area  编译器将会报错!   
  5. public :  
  6.     DLLPORT float CalcArea(float x, float y);  
  7. };  
class  Rectangle
{
public:
	 float m_Area;//注意:我们不能导出成员变量 如果我们这样: DLLPORT float m_Area  编译器将会报错!
public :
	DLLPORT float CalcArea(float x, float y);
};

通过dumpBin 我们可以发现,我们确实导出了 指定函数

//调用/

动态调用:

  1. HMODULE hLibModule = LoadLibrary(TEXT("Win32_Dll_Test.dll"));  
  2. typedef float (haoer::Rectangle::*PFN)(float a,float b);  
  3. haoer::Rectangle *pRect = new haoer::Rectangle();  
  4. PFN pfn = (PFN)GetProcAddress(hLibModule,"?CalcArea@Rectangle@@QAEMMM@Z");  
  5. (pRect->*pfn)(2,2);  
  6. delete pRect;  
  7. FreeLibrary(hLibModule);  
	HMODULE hLibModule = LoadLibrary(TEXT("Win32_Dll_Test.dll"));
	typedef float (haoer::Rectangle::*PFN)(float a,float b);
	haoer::Rectangle *pRect = new haoer::Rectangle();
	PFN pfn = (PFN)GetProcAddress(hLibModule,"?CalcArea@Rectangle@@QAEMMM@Z");
	(pRect->*pfn)(2,2);
	delete pRect;
	FreeLibrary(hLibModule);

静态调用:

  1.        Rectangle Rect;  
  2. Rect.Calc(2,2);//访问类成员函数   
  3. cout<<Rect.m_Area<<endl//可以访问成员变量,说明的确是DLL的确是导出了  
        Rectangle Rect;
	Rect.Calc(2,2);//访问类成员函数
	cout<<Rect.m_Area<<endl//可以访问成员变量,说明的确是DLL的确是导出了

结果:



二、采用def 文件定义导出

首先我们讲讲模块定义文件的知识

///

请查看:http://blog.csdn.net/lh844386434/article/details/6732539  

///

使用def的方法 1.新建一个记事本 ,将其更名为 YourProgram.def

                           2.加入到你的工程里,并编写def 详细的类容

                            3.配置 编译器(如下图)



用def 导出 函数和变量

相信如果你看了,我上面给出链接的那篇,def详细的语法文章的话, 你可能已经对def文件的语法很熟悉了,下面我们就来看看导出函数,与变量的语法

//头文件

  1. void  Display(string txt);  
  2. class Rectangle  
  3. {  
  4.     public:   
  5.           float m_Area;  
  6.    public :   
  7.           float CalcArea(float x, float y);  
  8. };  
void  Display(string txt);
class Rectangle
{
    public: 
          float m_Area;
   public : 
          float CalcArea(float x, float y);
};


 
 
  1. //源文件中   
  2. int g_nTest = 2;  
  3. float Rectangle::CalcArea(float x, float y)  
  4. {  
  5.          m_Area = x*y;  
  6.          return m_Area;  
  7. }  
  8.  void  Display(string txt)  
  9.   
  10. {  
  11.   
  12. cout<<"动态链接库说: "<<txt<<endl;  
  13.   
  14. }  
//源文件中
int g_nTest = 2;
float Rectangle::CalcArea(float x, float y)
{
         m_Area = x*y;
         return m_Area;
}
 void  Display(string txt)

{

cout<<"动态链接库说: "<<txt<<endl;

}

 接着定义def 文件 
 

  1. LIBARAY "Win32_Dll_Test"   //指定dll的名称   
  2. EXPORTS                  //导出文件的申明   
  3. Disp=Display @1          //这句表明我们将Display 的函数导出,并且导出接口名字为Disp 也就是说,我们再外部想调用display的函数是,只能用Disp来调用   
  4. Calc=CalcArea @2          //这句同理,也是导出了CalcArea函数,不过请注意,这里与Display 不同,这里导出的是类成员函数   
  5. g_nTest  @3 DATA         //导出数据和导出函数一样, 不过唯一不同的就是,在导出数据必须要加DATA声明  
LIBARAY "Win32_Dll_Test"   //指定dll的名称
EXPORTS                  //导出文件的申明
Disp=Display @1          //这句表明我们将Display 的函数导出,并且导出接口名字为Disp 也就是说,我们再外部想调用display的函数是,只能用Disp来调用
Calc=CalcArea @2          //这句同理,也是导出了CalcArea函数,不过请注意,这里与Display 不同,这里导出的是类成员函数
g_nTest  @3 DATA         //导出数据和导出函数一样, 不过唯一不同的就是,在导出数据必须要加DATA声明


可以看到的确,导出了,而且函数也是我们指定的名字 。def看起看来似乎也很方便,但是,这样做其实会遇到一个问题:

假如 我们现在要导出两个类A、B 但是呢A、B 中都有一个Test 函数,我们想把两个类里面的Test函数都导出来,并且都保持原来的名字(即2个名字都未Test)那怎么办了?
为了解决这种问题,其实我们可以效仿DX的调用方式,设计一个接口。通过指定不同的ID号,来访问不同的Test

 具体实现规则请参见 这篇帖子: http://blog.csdn.net/lengxiao_wang/article/details/1546187


def 导出类

正如上面我们第一这个Rectangle类一样,要像导出他,我 们需按照下面的步骤来做:

  1. class  Rectangle  
  2. {  
  3. public:  
  4.      float m_Area;  
  5. public :  
  6.      float CalcArea(float x, float y);  
  7. };  
class  Rectangle
{
public:
	 float m_Area;
public :
	 float CalcArea(float x, float y);
};

1.打开工程属性界面-->Link-->Debugging->Generate ap file。

2.重新编译整个工程。
3.在你的工程文件目录下会生成一个相应的YourProgram.map的文件。
 4.在map 文件中找到 和你定义的类和函数相关的东西,如: 
 
  1. 0002:00000460       ?CalcArea@Rectangle@@QAEMMM@Z 10011460 f   Win32_Dll_Test.obj  
  2.  0002:000004b0       ?Display@@YAXV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@@Z 100114b0 f   Win32_Dll_Test.obj  
0002:00000460       ?CalcArea@Rectangle@@QAEMMM@Z 10011460 f   Win32_Dll_Test.obj
 0002:000004b0       ?Display@@YAXV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@@Z 100114b0 f   Win32_Dll_Test.obj

5.在Exports中,指定导出 XXX.Def文件中,使之成为下面的样子(序号自己指定)

  1. ?CalcArea@Rectangle@@QAEMMM@Z  @1  
  2. Display@@YAXV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@@Z   @2  
?CalcArea@Rectangle@@QAEMMM@Z  @1
Display@@YAXV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@@Z   @2

6.重新编译,便导出了类


Tips:Map文件的用途 不仅仅如此哦,Map文件还是查看程序崩溃的一种有效方法

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值