[转帖及推荐阅读]DLL“地狱”的原因及其解决方案
本帖最后由 Rainyboy 于 2010-11-26 04:52 编辑由下面这个帖子想到了这个问题,就转了篇文章过来,大家共同学习。
MATLAB区的帖子:“请望高手指点,感谢感谢”
http://forum.vibunion.com/forum-viewthread-tid-97611-fromuid-159019.html
接下来分享的这篇文章虽然说的是DLL的问题,却包含了大量C++的细节知识,其中给出的解决方案也十分精妙,因此也是c++进阶的好材料!
原文内容:
概要
本文将要介绍DLL的向后兼容性问题,也就是著名的“DLL Hell”问题。首先我会列出自己的研究结果,其中包括其它一些研究者的成果。在本文的最后,我还将给出“DLL Hell”问题的一个解决方案。
介绍
我曾经接受过一个任务,去解决一个DLL版本更新的问题————某个公司给用户提供了一套SDK,这个SDK是由一系列DLL组成的;DLL中导出了很多类,用户使用这些类(直接使用或派生新的子类)来继续他们的C++程序开发。用户在使用这些DLL时没有得到很详细的使用说明(比如使用这些DLL中导出的类有什么限制等)。当这些DLL更新为新的版本之后,他们发现他们开发的基于这些DLL的应用程序会经常崩溃(他们的应用程序从SDK的导出类派生了新的子类)。为了解决这个问题,用户必须重新编译他们的应用程序,重新连接新版本的SDK DLL。
我将对这个问题给出我的研究结果,同时还有我从其它地方搜集过来的相关信息。最后,我将来解决这个“DLL Hell”问题。
研究结果
就我个人的理解,这个问题是由SDK DLL中导出的基类改动之后引起的。我查看了一些文章后发现,DLL的向后兼容性问题其实早有人提出。但作为一个实在的研究者,我决定自己做一些试验。结果,我发现如下的问题:
1. 在DLL的导出类中增加一个新的虚函数将导致如下问题:
(1)如果这个类以前就有一个虚函数B,此时在它之前增加一个新的虚函数A。这样,我们改变了类的虚函数表。于是,表中的第一个函数指向了函数A(而不是原来的B)。此时,客户程序(假设没有在拿到新版本的DLL之后重新编译、连接)调用函数B就会产生异常。因为此时调用函数B实际上转向了调用函数A,而如果函数A和函数B的参数类型、返回值类型迥异的话问题就出来了!
(2)如果这个类原本没有虚函数(它的父类也没有虚函数),那么给这个类增加一个新的虚函数(或者在它的父类增加一个虚函数)将导致新增加一个类成员,这个成员是一个指针类型的,指向虚函数表。于是,这个类的尺寸将会被改变(因为增加了一个成员变量)。这种情况下,客户程序如果创建了这个类的实例,并且需要直接或间接修改类成员的值的时候就会有问题了。因为虚函数表的指针是作为类的第一个成员加入的,也就是说,原本这个类定义的成员因为虚函数表指针的加入而都产生了地址的偏移。客户程序对原成员的操作自然就出现异常了。
(3)如果这个类原本就有虚函数(或者只要它的父类有虚函数),而且这个类被导出了,被客户程序当作父类来用。那么,我们不要给这个类增加虚函数!不仅在类声明的开头不能加,即使在末尾处也不能加。因为加入虚函数会导致虚函数表内的函数映射产生偏移;即使你将虚函数加在类声明的末尾,这个类的派生类的虚函数表也会因此产生偏移。
2. 在DLL的导出类中增加一个新的成员变量将导致如下问题:
(1)给一个类增加一个成员变量将导致类尺寸的改变(给原本有虚函数表的类增加一个虚函数将不会改变类的尺寸)。假设这个成员增加在类声明的最后。如果客户程序为创建这个类的实例少分配了内存,那么可能在访问这个成员时导致内存越界。
(2)如果在原有的类成员中间增加一个新的成员,情况会更糟糕。因为这样会导致原有类成员的地址产生偏移。客户程序操作的是一个错误的地址表,对于新成员后面的成员尤其是这样(它们都因为新成员的加入而导致了自己在类中的偏移的变化)。
(注:上述的客户程序就是指使用SDK DLL的应用程序。)
除了上面这些原因外,还有其它操作会导致DLL的向后兼容性问题。下面列出了解决(大部分)这些问题的方法。
DLL编码约定简述
下面是我搜集到的所有的解决方案,其中一些是从网上的文章中拿来的,一些是跟不同的开发者交流后得到的。
下面的约定主要针对DLL开发,而且是为解决DLL的向后兼容性问题:
1. 编码约定:
(1)DLL的每个导出类(或者它的父类)至少包含一个虚函数。这样,这个类就会始终保存一个指向虚函数表的指针成员。这么做可以方便后来新的虚函数的加入。
(2)如果你要给一个类增加一个虚函数,那么将它加在所有其它虚函数的后面。这样就不会改变虚函数表中原有函数的地址映射顺序。
(3)如果你打算以后给一个类扩充类成员,那么现在预留一个指向一个数据结构的指针。这样的话,增加一个成员直接在这个数据结构中修改,而不是在类中修改。于是,新成员的加入不会导致类尺寸的改变。当然,为了访问新成员,需要给这个类定义几个操作函数。这种情况下,DLL必须是被客户程序隐式(implicitly)连接的。
(4)为了解决前一点的问题,也可以给所有的导出类设计一个纯接口的类,但此时,客户程序将无法从这些导出类继续派生,DLL导出类的层次机构也将无法维持。
(5)发布两个版本的DLL和LIB文件(Debug版本和Release版本)。因为如果只发布Release版本,开发者将无法调试他们的程序,因为Release版与Debug版使用了不同的堆(Heap)管理器,因而当Debug版本的客户程序释放Release版本DLL申请的内存时,会导致运行时错误(Runtime failure)。有一种办法可以解决这个问题,就是DLL同时提供申请和释放内存的函数供客户程序调用;DLL中也保证不释放客户程序申请的内容。通常遵守这个约定不是那么简单!
(6)在编译的时候,不要改变DLL导出类函数的默认参数,如果这些参数将被传递到客户程序的话。
(7)注意内联(inline)函数的更改。
(8)检查所有的枚举没有默认的元素值。因为当增加/删除一个新的枚举成员,你可能移动旧枚举成员的值。这就是为什么每一个成员应该拥有一个唯一标识值。如果枚举可以被扩展,也应该对其进行文档说明。这样,客户程序开发者就会引起注意。
(9)不要改变DLL提供的头文件中定义的宏。
2. 对DLL进行版本控制:
如果主要的DLL发生了改变,最好同时将DLL文件的名字也改掉,就象微软的MFC DLL一样。例如,DLL文件可以按照如下格式命名:Dll_name_xx.dll,其中xx就是DLL的版本号。有时候DLL中做了很大的改动,使得向后兼容性问题无法解决。此时应该生成一个全新的DLL。将这个新DLL安装到系统时,旧的DLL仍然保留。于是,旧的客户程序仍然能够使用旧的DLL,而新的客户程序(使用新DLL编译、连接)可以使用新的DLL,两者互不干涉。
3. DLL的向后兼容性测试:还有很多很多中可能会破坏DLL的向后兼容性,因此实施DLL的向后兼容性测试是非常必要的!
接下去,我将来讨论一个虚函数的问题,以及对应的一个解决方案。
虚函数与继承
首先来看一下如下的虚函数和继承结构:/**********DLL导出的类 **********/
class EXPORT_DLL_PREFIX VirtFunctClass{
public:
VirtFunctClass(){}
~VirtFunctClass(){}
virtual void DoSmth(){
//this->DoAnything();
// Uncomment of this line after the corresponding method
//will be added to the class declaration
}
//virtual void DoAnything(){}
// Adding of this virtual method will make shift in
// table of virtual methods
};
/**********客户程序,从DLL导出类派生一个新的子类**********/
class VirtFunctClassChild : public VirtFunctClass {
public:
VirtFunctClassChild() : VirtFunctClass (){}
~VirtFunctClassChild(){};
virtual void DoSomething(){}
};
假设上面的两个类,VirtFunctClass在my.dll中实现,而VirtFunctClassChild在客户程序中实现。接下去,我们做一些改变,将如下两个注释行放开:
//virtual void DoAnything(){}
和
//this->DoAnything();
也就是说,DLL导出的类作了改动!现在如果客户程序没有重新编译,那么客户程序中的VirtFunctClassChild将不知道DLL中VirtFunctClass类已经改变了:增加了一个虚函数void DoAnything()。因此,VirtFunctClassChild类的虚函数表仍然包含两个函数的映射:
1. void DoSmth()
2. void DoSomething()
而事实上这已经不对了,正确的虚函数表应该是:
1. void DoSmth()
2. void DoAnything()
3. void DoSomething()
问题就在于,当实例化VirtFunctClassChild之后,如果调用它的void DoSmth()函数,DoSmth()函数转而要调用void DoAnything()函数,但此时基类VirtFunctClass只知道要调用虚函数表中的第二个函数,而VirtFunctClassChild类的虚函数表中的第二个函数仍然是void DoSomething(),于是问题就出来了!
另外,禁止在DLL的导出类的派生类(上例中的VirtFunctClassChild)中增加虚函数也是于事无补的。因为,如果VirtFunctClassChild类中没有virtual void DoSomething()函数,基类中的void DoAnything()函数(虚函数表中的第二个函数)调用将会指向一个空的内存地址(因为VirtFunctClassChild类维持的虚函数表仅仅维持有一个函数地址)。
现在可以看出,在DLL的导出类中增加虚函数是一个多么严重的问题!不过,如果虚函数是用来处理回调事件的,我们有办法来解决这个问题。
COM及其它
现在可以看出,DLL的向后兼容性问题是一个很出名的问题。解决这些问题,不仅可以借助于一些约定,而且可以通过其它一些先进的技术,比如COM技术。因此,如果你想摆脱“DLL Hell”问题,请使用COM技术或者其它一些合适的技术。
让我们回到我接受的那个任务(我在本文开头的地方讲到的那个任务)————解决一个使用DLL的产品的向后兼容性问题。
我对COM有些了解,因此我的第一个建议是使用COM技术来克服那个项目中的所有问题。但这个建议因为如下原因最终被否决了:
1. 那个产品已经在某个内部层中有一个COM服务器。
2. 将一大堆接口类重写到COM的形式,投入比较大。
3. 因为那个产品是DLL库,而且已经有很多应用程序在使用它了。因此,他们不想强制他们的客户重写他们的应用程序。
换句话说,我被要求完成的任务是,以最小的代价来解决这个DLL向后兼容性问题。当然,我应该指出,这个项目最主要的问题在于增加新的成员和接口类上的虚回调函数。第一个问题可以简单地通过在类声明中增加一个指向一个数据结构的指针来解决(这样可以任意增加新的成员)。这种方法我在上面已经提到过。但是第二个问题,虚回调函数的问题是新提出的。因此,我提出了下面的最小代价、最有效的解决方法。
虚回调函数与继承
然我们想象一下,我们有一个DLL,它导出了几个类;客户应用程序会从这些导出类派生新的类,以实现虚函数来处理回调事件。我们想在DLL中做一个很小的改动。这个改动允许我们将来可以给导出类“无痛地”增加新的虚回调函数。同时,我们也不想影响使用当前版本DLL的应用程序。我们期望的就是,这些应用程序只有在不得已的时候才协同新版本的DLL进行一次重新编译。因此,我给出了下面的解决方案:
我们可以保留DLL导出类中的每个虚回调函数。我们只需记住,在任何一个类定义中增加一个新的虚函数,如果应用程序不协同新版本的DLL重新编译,将导致严重的问题。我们所做的,就是想要避免这个问题。这里我们可以一个“监听”机制。如果在DLL导出类中定义并导出的虚函数被用作处理回调,我们可以将这些虚函数转移到独立的接口中去。
让我们来看下面的例子:// 如果想要测试改动过的DLL,请将下面的定义放开
//#define DLL_EXAMPLE_MODIFIED
#ifdef DLL_EXPORT
#define DLL_PREFIX __declspec(dllexport)
#else
#define DLL_PREFIX __declspec(dllimport)
#endif
/********** DLL的导出类 **********/
#define CLASS_UIID_DEF static short GetClassUIID(){return 0;}
#define OBJECT_UIID_DEF virtual short
GetObjectUIID(){return this->GetClassUIID();}
// 所有回调处理的基本接口
struct DLL_PREFIX ICallBack
{
CLASS_UIID_DEF
OBJECT_UIID_DEF
};
#undef CLASS_UIID_DEF
#define CLASS_UIID_DEF(X) public: static
short GetClassUIID(){return X::GetClassUIID()+1;}
// 仅当DLL_EXAMPLE_MODIFIED宏已经定义的时候,进行接口扩展
#if defined(DLL_EXAMPLE_MODIFIED)
// 新增加的接口扩展
struct DLL_PREFIX ICallBack01 : public ICallBack
{
CLASS_UIID_DEF(ICallBack)
OBJECT_UIID_DEF
virtual void DoCallBack01(int event) = 0; // 新的回调函数
};
#endif // defined(DLL_EXAMPLE_MODIFIED)
class DLL_PREFIX CExample{
public:
CExample(){mpHandler = 0;}
virtual ~CExample(){}
virtual void DoCallBack(int event) = 0;
ICallBack * SetCallBackHandler(ICallBack *handler);
void Run();
private:
ICallBack * mpHandler;
}; 很显然,为了给扩展DLL的导出类(增加新的虚函数)提供方便,我们必须做如下工作:
1. 增加ICallBack * SetCallBackHandler(ICallBack *handler);函数;
2. 在每个导出类的定义中增加相应的指针;
3. 定义3个宏;
4. 定义一个通用的ICallBack接口。
为了演示给CExample类增加新的虚回调函数,我在这里增加了一个ICallBack01接口的定义。很显然,新的虚回调函数应该加在新的接口中。每次DLL更新都新增一个接口(当然,每次DLL更新时,我们也可以给一个类同时增加多个虚回调函数)。
注意,每个新接口必须从上一个版本的接口继承。在我的例子中,我只定义了一个扩展接口ICallBack01。如果DLL再下个版本还要增加新的虚回调函数,我们可以在定义一个ICallBack02接口,注意ICallBack02接口要从ICallBack01接口派生,就跟当初ICallBack01接口是从ICallBack接口派生的一样。
上面代码中还定义了几个宏,用于定义需要检查接口版本的函数。例如我们要为新接口ICallBack01增加新函数DoCallBack01,如果我们要调用ICallBack * mpHandler; 成员的话,就应该在CExample类进行一下检查。这个检查应该如下实现:
if(mpHandler != NULL && mpHandler->GetObjectUIID()>=ICallBack01::GetClassUIID()){
((ICallBack01 *) mpHandler)->DoCallBack01(2);
}
我们看到,新回调接口增加之后,在CExample类的实现中只需简单地插入新的回调调用。
现在你可以看出,我们上述对DLL的改动并不会影响客户应用程序。唯一需要做的,只是在采用这种新设计后的第一个DLL版本(为DLL导出类增加了宏定义、回调基本接口ICallBack、设置回调处理的SetCallBackHandler函数,以及ICallBack接口的指针)发布后,应用程序进行一次重编译。(以后扩展新的回调接口,应用程序的重新编译不是必需的!)
以后如果有人想要增加新的回调处理,他就可以通过增加新接口的方式来实现(向上例中我们增加ICallBack01一样)。显然,这种改动不会引起任何问题,因为虚函数的顺序并没有改变。因此应用程序仍然以以前的方式运行。唯一你要注意的是,除非你在应用程序中实现了新的接口,否则你就接收不到新增加的回调调用。
我们应该注意到,DLL的用户仍然能够很容易与它协同工作。下面是客户程序中的某个类的实现例子:
// 如果DLL_EXAMPLE_MODIFIED没有定义,使用以前版本的DLL
#if !defined(DLL_EXAMPLE_MODIFIED)
// 此时没有使用扩展接口ICallBack01
class CClient : public CExample{
public:
CClient();
void DoCallBack(int event);
};
#else // !defined(DLL_EXAMPLE_MODIFIED)
// 当DLL增加了新接口ICallBack01后,客户程序可以修改自己的类
// (但不是必须的,如果他不想处理新的回调事件的话)
class CClient : public CExample, public ICallBack01{
public:
CClient();
void DoCallBack(int event);
// 声明DoCallBack01函数(客户程序要实现它,以处理新的回调事件)
// (DoCallBack01是ICallBack01接口新增加的虚函数)
void DoCallBack01(int event);
};
#endif // defined(DLL_EXAMPLE_MODIFIED)
==============================================================================================
进一步阅读推荐(By Rainyboy)
如果你想更了解“虚函数表”和其他C++细节:
《深度探索C++对象模型》 Stanley B.Lippman著 侯捷译 华中科技大学出版社
如果你想更了解“动态链接”和其他程序之间相互关联的思想:
《程序员的自我修养——链接、装载与库》 俞甲子 石凡 潘爱民 著 电子工业出版社
如果你想了解文中那些精妙的代码背后的东西:
《设计模式:可复用面向对象软件的基础》 Gof著 很多人译 机械工程出版社
《重构:改善既有代码的设计》 Martin Fowler著 侯捷 熊节译 中国电力出版社
如果你想进一步见识C++中宏的作用:
《深入浅出MFC》 侯俊杰著 华中科技大学出版社
==============================================================================================
各位有什么好的文章也可以转帖过来,大家一同分享!
本帖最后由 wqsong 于 2010-11-26 16:53 编辑
很好的DLL资料。。。
不喜欢用C++写的DLL,在其他语言中调用出错千奇百怪。。。
上次用到一个水蒸气性质DLL,在Fortran中调用一个C++函数,参数有一个是reference,弄了半天不知道怎么调用。。。
一般编译dll或者exe都采用static library编译。。。减少依赖,莫名地讨厌文件依赖。。。
{:{26}:}
回复 2 # wqsong 的帖子
咱们自己写的库静态链接也没关系……做大系统的话,不也得动态起来么……
不过我也一直在想哈,各个语言间数据类型的表示和实现都不一定一样,怎么能保证dll文件的通用性呢……是不是就意味着要避免使用语言相关的特色语法,比如C里面的指针啊,C++的引用啊什么的,但是话说如果指针都不能作为参数,那数组也就不行了……那受的限制还真挺大的…… 本帖最后由 wqsong 于 2010-11-27 01:44 编辑
回复 3 # Rainyboy 的帖子
我指的是尽量不使用MFC DLL。逼不得已非得使用MFC DLL时候,编译的时候尽量在静态库中使用MFC,而不是使用共享使用,尽管这样文件会大很多,内存映射也会增加,但在一定程度上能减小DLL之间的关联性。
嗯,各个语言的数据的实现不一样,很难找到一个统一的做法,只能靠经验了,尽量避开一些语言特性。
现在那些传统语言的标准更新慢,而且也不会在这些“外特性”上定标准。我觉得像Python以及Matlab那样挺好,指定了开发格式(可能会阉割了一些语言特性),但这样能大大减少出错机率,当然也是在解释器上做文章。编译类语言定一个这样的开发标准的确很难的,这要是做出这样一个编译器,那就相当于这编译器既懂C/C++又懂Fortran,而且还能互相翻译。
:@) 回复 4 # wqsong 的帖子
这要是做出这样一个编译器,那就相当于这编译器既懂C/C++又懂Fortran,而且还能互相翻译
这不就是CLR,这不就是.NET嘛?哈哈 本帖最后由 wqsong 于 2010-11-27 11:10 编辑
回复 5 # Rainyboy 的帖子
嗯,是啊,.net是虚拟机性质的
争取编译成CPU直接执行的,只要效率,不管其他的,避开虚拟机。。。哈哈。。。 回复 6 # wqsong 的帖子
那就要看阁下在意的是“程序被机器执行的效率”还是“程序被人编码和调试的效率”了…… 回复 7 # Rainyboy 的帖子
编码调试效率不变,让机器执行的效率上升。。。
哈哈,光荣伟大的任务交给那些编译器设计者了。。。 本帖最后由 Rainyboy 于 2010-11-27 11:32 编辑
回复 8 # wqsong 的帖子
对了,你觉得利用DLL的这些特点,能不能故意设计一些东西来对系统形成攻击呢?
比如文中提到的:
=======================
因此,VirtFunctClassChild类的虚函数表仍然包含两个函数的映射:
1. void DoSmth()
2. void DoSomething()
而事实上这已经不对了,正确的虚函数表应该是:
1. void DoSmth()
2. void DoAnything()
3. void DoSomething()
=======================
这个时候引用旧版本dll文件中DoSomething()的程序实际上调用了DoAnything……能不能在DoAnything上所以些文章呢?
试想,DoSomething()其实是这样的:
int DoSomething(char InputPassWord[]);
作用是检查用户输入的密码是否正确,正确就返回1,否则返回0;
而我们增加的DoAnything是一个这样的函数:
int DoAnything(char InputPassWord[])
{
return 1;
}
岂不是……
本帖最后由 wqsong 于 2010-12-31 09:43 编辑
回复 9 # Rainyboy 的帖子
嗯嗯,Troy Injection就是这个原理。Troy Injection就是直接重写现有的DLL,保证与原来的DLL导出相同的原型(signature),这样就达到以假乱真的效果了。
在继承体系的基类调用中比较容易做文章(这里标记为1*,与下面的1*对应),感觉继承体系的派生类调用中挺难做文章的(这里标记为2*,与下面的2*对应),因为从DLL导出类中继承也是符号继承而不是偏移量或者内存映射继承(这句话不知道该怎么表达,下面举个例子)。
客户端调用:(这里的代码始终不变,也不重新编译)
====================================================================
class myClassDerived: public myClass //从Base类中继承
{
public:
void print()
{
std::cout << "myClass: The result which I want is " << 2*pi << std::endl; //overwrite,输出2*pi
}
};
void foo(myClass *p) //为了达到多态效果定义这样一个函数,参数为基类指针
{
p->print();
};
int main(int argc, char *argv[])
{
myClass mc;
myClassDerived mcd;
foo(&mc); //1*
foo(&mcd); //2*
return 0;
}====================================================================
所谓的标准DLL:
====================================================================
myDll.h文件:
//定义一个简单的类,数据成员只有一个pi,在构造函数中初始化。
class __declspec(dllexport) myClass
{
protected:
double pi;
public:
myClass();
virtual void print(); //虚函数表slot 1
virtual ~myClass(); //虚函数表slot 0
};myDll.cc文件:
myClass::myClass():pi(3.141593)
{
std::cout << "Here is constructor!
";
}
void myClass::print()
{
std::cout << "myClass: The result which I want is " << pi << std::endl; //输出pi。
}
myClass::~myClass()
{
std::cout << "Here is destructor!
";
}====================================================================
修改后的DLL:
====================================================================
myDll.h文件:
//定义一个简单的类,数据成员只有一个pi,在构造函数中初始化。
class __declspec(dllexport) myClass
{
protected:
double pi;
public:
myClass();
virtual void Troy(); //声明一个新的virtual函数,在虚函数表中的slot 1中
virtual void print(); //虚函数表slot 2
virtual ~myClass(); //虚函数表slot 0
};myDll.cc文件:
myClass::myClass():pi(3.141593)
{
std::cout << "Here is constructor!
";
}
void myClass::print()
{
std::cout << "myClass: The result which I want is " << pi << std::endl; //输出pi。
}
void myClass::Troy() //这里是Tory的定义
{
std::cout << "I am the Troy Horse!!!
";
}
myClass::~myClass()
{
std::cout << "Here is destructor!
";
}====================================================================
最后输出结果:
标准输出:
Troy输出:
以上的两张图,第一张是标准DLL输出,第二张是Troy DLL输出。其中途中分别用红色标注的两行,第一行是1*的效果,第二行是2*的效果。
这样也就是Rainyboy 提到的系统攻击。但是有一点“意外”,就是派生类中并没有按照新类的虚函数表进行调用,没有Troy,原因就是在开头提到的那句说不明白的话。画两个简化了的虚函数表图来解释这问题。
客户端程序中读到的虚函数表:
标准:
Troy:
不是解释的解释:
实际上,在客户端使用的始终是所谓的标准DLL中导出的虚函数表,尽管Troy DLL也导出了一个虚函数表,但是自始自终没有被客户端用到,只是用到了Troy改变了Slot内容这一性质。通俗点说,就像一本书的不同版次,中文目录没变,新版添加了一个吐火罗语目录,我们看不懂,查阅时依旧使用旧目录,但是章节内容替换了。
回复 10 # wqsong 的帖子
看看这个,呵呵,挺有意思的
http://coolshell.cn/articles/3008.html 回复 11 # Rainyboy 的帖子
。。。图为什么没了?违规了??? 回复 12 # wqsong 的帖子
呃……应该不会违规吧……你再编辑编辑?{:{46}:} 本帖最后由 wqsong 于 2010-12-31 09:36 编辑
。。。图真挂了,违规了还是怎么了。补充两张图,下面两张没了。。。 回复 14 # wqsong 的帖子
辛苦……那两张图找不着啦?
对了,你都拿什么作图啊,我一般用viso
页:
[1]
2