首页 > VC++ > ATL与MFC消息分发机制的对比—由金山开源代码引出的思考(一)

ATL与MFC消息分发机制的对比—由金山开源代码引出的思考(一)

2012年7月1日 发表评论 阅读评论 6200次阅读    

前几天刚看金山开源代码时写了一篇博客分析了一下其消息机制的实现方式。后来发现写的很多都是ATL里面的,最**的是犯了一个严重的错误,把ATL的窗口消息机制里面一个重要技术:实现HWND和对应窗口类this指针之间的映射的Thunk技术给忽略掉了。后来陈坤GG即时的提醒了我,先谢谢他了!
好了,步入正题,今天主要对比一下ATL和MFC是如何将窗口句柄HWND和对应的类的this指针映射的。

1. 先说一下为什么要映射:
我们自己写WIN32程序时从来没有映射呀,一般只是注册窗口的时候提供一个窗口过程,然后就在窗口过程里面做所有事情就可以了,为什么要映射呢?
我们知道WINDOW是用C写的,所有API不支持面向对象。可关键是ATL/MFC是一个框架,为了尽最大努力屏蔽编程上的繁琐步骤(注册窗口类,提供窗口过程,创建窗口,显示窗口···)和能够使开发人员能够用面向对象的方法来编程,享受极大的方便,就不得不在面向过程的操作系统API和面向对象编程框架直接搭个桥梁,这就是问题的开始···
之所以我们自己写的程序一般是一个主窗口对应一个窗口过程!所有不用关心这些了(而且我们一般WIN32编程也没有完全面向过程去写)。可是ATL/MFC不同,窗口过程不用我们提供,这样咱们编程就方便了,所有框架给我们提供了窗口过程,问题是,框架能为我们每个不同的窗口提供不同的窗口过程,向操纵系统注册吗?不能也无法实现。所以它只能向操纵系统提供一个统一的窗口过程,
1.对ATL来说是 CWindowImplBaseT< >::StartWindowProc这个静态函数,别忘了静态函数其实跟全局函数差不多,是基于类的,它没有this指针,编译器不会为它添加this指针。(其实这个过程有点曲折,待会说)
2.对MFC来说是注册时AfxDlgProc等,不过实际情况比这复杂,待会说。

 

不管注册时提供的窗口过程是谁,反正有一点是明确的:一定是一个全局函数或类的静态函数。

上面其实是很简单的。既然提供的都是一个相同的函数,那么不管哪个窗口有消息了,操作系统都会调用这一个函数!不同的是提供不同的参数,就是是HWND参数!该参数毫无疑问的标志了一个窗口。问题是,我们如何知道该窗口句柄所对应的窗口类是谁??一个简单的方法:if-else查表。对,窗口一多就很慢!
于是来到了我们讨论的重点:ATL/MFC是如何映射的?

 一、先说ATL吧:
       还是从源头来,先看怎么注册的:
具体是怎么注册的在我之前的文章里说过http://blog.csdn.net/hw_henry2008/archive/2011/05/22/6438153.aspx
这里简单回顾一下。
这里全部以对话框为基础,多文档也类似的。在DoModal函数里面,创建对话框时是这样的

HWND  CWindowImpl : public CWindowImplBaseT< TBase, TWinTraits >
	::Create(HWND hWndParent, _U_RECT rect = NULL, LPCTSTR szWindowName = NULL,
	DWORD dwStyle = 0, DWORD dwExStyle = 0,
	_U_MENUorID MenuOrID = 0U, LPVOID lpCreateParam = NULL)
	{
	//···
	ATOM atom = T::GetWndClassInfo().Register(&m_pfnSuperWindowProc);
	//···Register函数完成注册,封装了全局RegisterClassW函数
	return CWindowImplBaseT< TBase, TWinTraits >::Create(hWndParent, rect, szWindowName,
	dwStyle, dwExStyle, MenuOrID, atom, lpCreateParam);
	}

上面的代码调用GetWndClassInfo得到窗口结构的基本信息,其中即设置了窗口处理函数有必要贴一下 GetWndClassInfo的代码,它返回一个窗口类的基本信息,其中就包括了窗口处理函数,这是我们用来向操作系统注册的回调函数。

static ATL::CWndClassInfo& CBkDialogImpl::GetWndClassInfo()
	{
		static ATL::CWndClassInfo wc = {
			{ sizeof(WNDCLASSEX),
			CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS | (IsWinXPAndLater() ? CS_DROPSHADOW : 0),
			StartWindowProc, 0, 0, NULL, NULL, NULL,
			(HBRUSH)(COLOR_WINDOW + 1), NULL, NULL, NULL },
			NULL, NULL, IDC_ARROW, TRUE, 0, _T("")
		};
		return wc;
	}

从上面的代码看出:在向操纵系统注册的时候提供的窗口过程是StartWindowProc,在VC/atlmfc/include/atlwin.h里面。这样当第一个消息来的时候,操纵系统毫无疑问会调用我们的StartWindowProc上一次我看到这就没有怎么细看了以至于略过了重要的thunk技术。下面继续看该窗口过程

 LRESULT CALLBACK CWindowImplBaseT< TBase, TWinTraits >::StartWindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
	{
		//下面的代码其实是从之前保存起来的一个窗口过程的this指针取出来。因为这个函数是第一次调用的,也即该HWND对应这this
		//不过我倒怀疑在多线程的时候这会出问题,你怎么保证保存的this指针不会和hWnd对应呢,如果两个线程同时运行到这,
		//那么不会竞争吗?改天看看。也请大家指点一下,呵呵···(o,我知道了,说完这点待会补充一下^.^)
		CWindowImplBaseT< TBase, TWinTraits >* pThis = (CWindowImplBaseT< TBase, TWinTraits >*)_AtlWinModule.ExtractCreateWndData();
		//···
		pThis->m_hWnd = hWnd;//保存一下句柄,以后会用的,为什么保存呢,因为thunk代码会覆盖这个地方的数据。
		//···//GetWindowProc返回的是WindowProc,也是一个静态函数
		pThis->m_thunk.Init(pThis->GetWindowProc(), pThis);//这里待会说
		WNDPROC pProc = pThis->m_thunk.GetWNDPROC();//实际上返回的是一个_stdcallthunk结构体的首地址
		WNDPROC pOldProc = (WNDPROC)::SetWindowLongPtr(hWnd, GWLP_WNDPROC, (LONG_PTR)pProc);
		//将此结构体的首地址设置为窗口过程!!!???看下面
		//···
		return pProc(hWnd, uMsg, wParam, lParam);
	}

疑问来了,m_thunk是什么?每个窗口实例都有这么一个数据成员,定义如下

 class CWndProcThunk
	{
	public:
		_AtlCreateWndData cd;
		CStdCallThunk thunk;//这才是其重点。thunk其实就是一段汇编代码。

		BOOL Init(WNDPROC proc, void* pThis) {
			return thunk.Init((DWORD_PTR)proc, pThis);
		}
		WNDPROC GetWNDPROC() {
			return (WNDPROC)thunk.GetCodeAddress();
		}
	};

	#pragma pack(push,1)
	struct _stdcallthunk
	{
		DWORD   m_mov;          // mov dword ptr [esp+0x4], pThis (esp+0x4 is hWnd)
		DWORD   m_this;         //
		BYTE    m_jmp;          // jmp WndProc
		DWORD   m_relproc;      // relative jmp
		BOOL Init(DWORD_PTR proc, void* pThis)
		{//初始化这段汇编代码,待会会把它强制转换成为一个窗口过程函数的地址。只是不会进行压栈等操作
			m_mov = 0x042444C7;  //C7 44 24 0C   //后面的注释应该为//C7 44 24 04
			m_this = PtrToUlong(pThis);
			//把this指针移到堆栈的4个字节开始位置。从StartWindowProc的调用规则CALLBACK看出这个位置正好是窗口句柄
			m_jmp = 0xe9;
			m_relproc = DWORD((INT_PTR)proc - ((INT_PTR)this+sizeof(_stdcallthunk)));
			//上面相对跳转
			FlushInstructionCache(GetCurrentProcess(), this, sizeof(_stdcallthunk));//刷新CPU预读的指令
			return TRUE;
		}
		void* GetCodeAddress() {
			return this;//返回本结构体的首地址,注意幸好没有虚函数,你懂的。
		}
		//···
	};

上面的代码再多说一下,Init其实就是初始化了thunk结构,把它初始化成这样:
先把堆栈上的4字节处的内容改成传入的对应HWND窗口类的this指针,然后跳转到函数指针proc处执行,其实为WindowProc。
为什么这能够实现呢?
我们知道,StartWindowProc用WindowProc和对应窗口过程的this指针初始化了thunk,然后把这个thunk的“内容”(其实是一段精心安排的汇编代码)强制转换成为窗口过程函数,然后向操作系统注册! 想想这回产生什么效果呢??对,从此以后,每当“该窗口”有消息到来的时候,操作系统会以参数:
( hWnd, uMsg, wParam, lParam) 理所当然的调用该地址处的“函数”!!说的细一点,操作系统内会进行如下动作:
(这里与函数的调用约定有关,不清楚的请参考http://blog.csdn.net/hw_henry2008/archive/2011/05/29/6453257.aspx)

 01111:
   push lParam
   push wParam
   push uMsg
   push hWnd  //CALLBACK约定的压栈规则:从右到左
   push cs:eip  //保存返回地址,即指令指针
   call 0x112345678 //假设这是thunk结构体的基址。

 0x112345678:
   move this [esp+0x04]  //看看压栈时的栈状态就知道,该窗口类的this正好覆盖了参数中的hWnd !!
   jmp WindowProc   //然后若无其事的跳转到WindowProc去执行。

-----------------------------------------------------------------
此时的堆栈状态为:
lParam
wParam
uMsg
hWndcs:eip

------------------------------------------------------------------

看到这我们大概知道了,thunk技术在此的用途其实就是:将堆栈中的hWnd改成对应的窗口类this指针,注意thunk结构是每个窗口实例一个的,所以其实这个thunk中不同的地方就只有move的源地址不同,即窗口实例this指针不同于是,以后的每一个消息,操作系统不再调用StartWindowProc函数了,取而代之调用thunk处的代码。
我们继续看WindowProc是如何取得这个this的,毕竟它也是个静态函数。

LRESULT CALLBACK CWindowImplBaseT< TBase, TWinTraits >::WindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
	{
		//直接把参数hWnd强制转换成窗口类的指针!!为什么能成功呢?
		//因为刚才thunk代码中move this [esp+0x4]正好将此参数覆盖了!!而且thunk代码时每个窗口实例一个。
		CWindowImplBaseT< TBase, TWinTraits >* pThis = (CWindowImplBaseT< TBase, TWinTraits >*)hWnd;
		//····
		//巧妙的取得了对应窗口过程的this指针,于是大摇大摆的调用其成员函数啦,
		//当然,m_hWnd句柄第一次也是唯一一次进入StartWindowProc就保存了的。
		BOOL bRet = pThis->ProcessWindowMessage(pThis->m_hWnd, uMsg, wParam, lParam, lRes, 0);
		//···
	}

对于ATL的消息分配过程就是上面所说的了,关于thunk的一些细节可以参考我转载的博客。
此外需要稍微了解点汇编语言,调用约定。这些在我转载的博客中有参考.

基本图示如下:

另外补充一下,刚才在StartWindowProc中的代码:
_AtlWinModule.ExtractCreateWndData()我开始觉得会有线程竞争问题出现,刚刚看了下里面的实现,是没问题的,里面不断加了锁,而且还用了“每线程变量”似的处理。

 ATLINLINE ATLAPI_(void*) AtlWinModuleExtractCreateWndData(_ATL_WIN_MODULE* pWinModule)
	{
		//···//下面枷锁,退出此函数解锁
		CComCritSecLock lock(pWinModule->m_csWindowCreate, false);
		if (FAILED(lock.Lock()))
		{//····
		}
		_AtlCreateWndData* pEntry = pWinModule->m_pCreateWndList;
		if(pEntry != NULL) {
			DWORD dwThreadID = ::GetCurrentThreadId();
			_AtlCreateWndData* pPrev = NULL;
			while(pEntry != NULL) {
				if(pEntry->m_dwThreadID == dwThreadID)
				{//关键是这,只处理当前线程,对于多线程来说,他们访问的是不同的元素
					if(pPrev == NULL)
						pWinModule->m_pCreateWndList = pEntry->m_pNext;
					else
						pPrev->m_pNext = pEntry->m_pNext;
					pv = pEntry->m_pThis;
					break;
				}
				pPrev = pEntry;
				pEntry = pEntry->m_pNext;
			}
		}
		return pv;
	}

二、MFC的实现方式:
上面说完了ATL的消息分发方式,下面继续MFC的实现机制。

二、MFC消息分发的实现方式:

首先来看注册窗口时:
还是以对话框为例子,调用DoModal 后,进入:

INT_PTR CDialog::DoModal()
	{
		//···进行资源的准备
		// disable parent (before creating dialog)
		HWND hWndParent = PreModal();//得到父窗口的句柄,并且调用AfxHookWindowCreate,这是关键
		AfxUnhookWindowCreate();
		//···
		AfxHookWindowCreate(this);
		if (CreateDlgIndirect(lpDialogTemplate,
			CWnd::FromHandle(hWndParent), hInst))
		{
			//···
				VERIFY(RunModalLoop(dwFlags) == m_nModalResult);
			//···
		}
		//···
	}

	HWND CDialog::PreModal()
	{
		//····
		HWND hWnd = CWnd::GetSafeOwner_(m_pParentWnd->GetSafeHwnd(), &m_hWndTop);
		//下面的函数看名字也能知道,是WINDOWS的钩子函数,下面进去看看就知道它干的什么事了
		AfxHookWindowCreate(this);
		return hWnd;// return window to use as parent for dialog
	}

其实PreModal没干啥事,就是调用了下面的AfxHookWindowCreate函数,以及得到父窗口句柄

 void AFXAPI AfxHookWindowCreate(CWnd* pWnd)
	{
		_AFX_THREAD_STATE* pThreadState = _afxThreadState.GetData();//看下面的说明
		if (pThreadState->m_pWndInit == pWnd)
			return;

		if (pThreadState->m_hHookOldCbtFilter == NULL)
		{//可以看出,这if之后进来一次,可以调用多次。
			pThreadState->m_hHookOldCbtFilter = ::SetWindowsHookEx(WH_CBT,
				_AfxCbtFilterHook, NULL, ::GetCurrentThreadId());
			if (pThreadState->m_hHookOldCbtFilter == NULL)
				AfxThrowMemoryException();
		}
		//···
		pThreadState->m_pWndInit = pWnd;
	}

AfxHookWindowCreate在MFC中起着很奇特的作用,也很有意思。先看第一行的 _afxThreadState.GetData();
字面意思可以看出是得到属于线程的一些状态信息,不过如果大家看其实现的话就会发现有些字眼很熟悉,比如:(CThreadData*)TlsGetValue(m_tlsIndex); 对,TLS,线程局部存储,它借助window操作系统提供的线程局部存储来保存与线程相关的一些变量数据,因为我们知道,消息队列是基于线程的,也就是每个线程可以有一个消息队列。
下面看重头戏:SetWindowsHookEx,::SetWindowsHookEx(WH_CBT,_AfxCbtFilterHook, NULL, ::GetCurrentThreadId())也即给当前线程安装一个线程钩子。
类型是WH_CBT,钩子函数为:_AfxCbtFilterHook。那么WH_CBT是说明意思呢?MSDN:
WH_CBT :Installs a hook procedure that receives notifications useful to a computer-based training (CBT)
application. For more information, see the CBTProc hook procedure.
_AfxCbtFilterHook也就是这个所谓的CBTProc钩子函数类型。
The CBTProc hook procedure is an application-defined or library-defined callback function used with the SetWindowsHookEx function. The system calls this function before activating, creating, destroying, minimizing, maximizing, moving, or sizing a window; before completing a system command; before removing a mouse or keyboard event from the system message queue; before setting the keyboard focus; or before synchronizing with the system message queue. A computer-based training (CBT) application uses this hook procedure to receive useful notifications from the system.
其实意思就是:给线程安装一个WH_CBT钩子,当激活,创建,销毁··一个窗口的时候回先调用这个钩子函数通知我们。什么意思,也就是在线程创建窗口时,接收第一个消息之前会调研我们这个函数。
下面先别急着看钩子过程的处理方式,因为我们知道,现在它是不会被调研的,它一定会在creating窗口的时候在系统内部被调用。咱们继续回到DoModal的代码,HW_CBT钩子安装好了,下面也该创建窗口了吧?
进入CreateDlgIndirect,很长的函数···

 BOOL CWnd::CreateDlgIndirect(LPCDLGTEMPLATE lpDialogTemplate,
		CWnd* pParentWnd, HINSTANCE hInst)
	{
	//···
			AfxHookWindowCreate(this);//这个函数刚才见过,只是这回的调用没啥实际用途了。
									//笔者纳闷为何他调用了2次,虽然不会出问题,但是···总之不爽
			hWnd = ::CreateDialogIndirect(hInst, lpDialogTemplate,
				pParentWnd->GetSafeHwnd(), AfxDlgProc);
	//···此处省略很多行,跟我们这无关的代码
	}

恩,原来这样,CreateDialogIndirect创建一个对话框,提供的窗口过程是AfxDlgProc ! 不过也许我们不会忘记,AfxHookWindowCreate安装了一个WH_CBT钩子,会在创建窗口的时候同步调用我们的钩子函数。顺便说一下SendMessage和PostMessage的区别。前者是同步的,实际上也就是一般的调用,知道调用的函数完成后才返回;
后者是把消息放到线程的消息队列里面,然后马上返回。这里操作系统会在创建窗口的过程中,发送WM_CREATE,WM_NCCREATE 消息之前调用钩子过程。因此保证了第一条消息的正确处理!!

LRESULT CALLBACK _AfxCbtFilterHook(int code, WPARAM wParam, LPARAM lParam)
{
	_AFX_THREAD_STATE* pThreadState = _afxThreadState.GetData();
	if (code != HCBT_CREATEWND) {//我们只想在窗口创建之前偷偷做点事情。放过其他消息
		return CallNextHookEx(pThreadState->m_hHookOldCbtFilter, code, wParam, lParam);
	}
	//···
	CWnd* pWndInit = pThreadState->m_pWndInit;
	BOOL bContextIsDLL = afxContextIsDLL;
	if (pWndInit != NULL || (!(lpcs->style & WS_CHILD) && !bContextIsDLL))
	{
		// ···
		if (pWndInit != NULL) {
			//···
			ASSERT(CWnd::FromHandlePermanent(hWnd) == NULL);
			pWndInit->Attach(hWnd);
			WNDPROC afxWndProc = AfxGetAfxWndProc();//实际返回:AfxWndProcBase
			oldWndProc = (WNDPROC)SetWindowLongPtr(hWnd, GWLP_WNDPROC,
				(DWORD_PTR)afxWndProc);//重新设置窗口句柄。
			//····
			pThreadState->m_pWndInit = NULL;//准备下一个窗口
		}
		//···
	}

lCallNextHook:
	LRESULT lResult = CallNextHookEx(pThreadState->m_hHookOldCbtFilter, code, wParam, lParam);
	return lResult;
}

首先我们感兴趣的是FromHandlePermanent,其接收一个HWND句柄,然后返回一个窗口类的指针!!!这不就是我们想要了解的:
MFC如何将窗口句柄和窗口实例联系起来的?

CWnd* PASCAL CWnd::FromHandlePermanent(HWND hWnd) {
	CHandleMap* pMap = afxMapHWND();//该函数得到当前线程的m_pmapHWND数据成员。
	CWnd* pWnd = NULL;
	if (pMap != NULL) {
		// only look in the permanent map - does no allocations
		pWnd = (CWnd*)pMap->LookupPermanent(hWnd);//lookup,应该是查表了,对,就是查表。有点失望···
		ASSERT(pWnd == NULL || pWnd->m_hWnd == hWnd);
	}
	return pWnd;//返回查得的指针
}
inline CObject* CHandleMap::LookupPermanent(HANDLE h)//还好,是inline的,不然就真的太郁闷了,查表···不慢吗??
{ return (CObject*)m_permanentMap.GetValueAt((LPVOID)h); }

看到这基本有点预感了,MFC对应窗口句柄和对应窗口类实例直接的映射是通过查表来实现的。那么,问题来了,这些映射什么时候加入的呢???
明显的感觉:在创建窗口,还没有接收到WM_CREATE消息之前。不然如果没有映射怎么分发这条消息呀?发给哪个类?不知道。这个关键的建立映射的时期不正是钩子WH_CBT的钩子过程里吗??回到_AfxCbtFilterHook钩子函数中。pWndInit->Attach(hWnd)语句其实就是将当前句柄加入到当前线程的映射中。

BOOL CWnd::Attach(HWND hWndNew)
{
	ASSERT(FromHandlePermanent(hWndNew) == NULL);//查表?正常情况下当然不可能已经建立映射了,即返回空。
	//···
	CHandleMap* pMap = afxMapHWND(TRUE); // 返回这个映射表
	ASSERT(pMap != NULL);
	pMap->SetPermanent(m_hWnd = hWndNew, this);//建立一个:即映射!!
	//···
	return TRUE;
}

看到这基本大功告成了,反正我是相当失望了,强大的复杂的MFC竟然用查表的方式建立映射,不过还好,是哈希表。基础数据结构是CMapPtrToPtr,关于CHandleMap里面的实现这里就不多说啦,读者可以自行查看。其用哈希表实现,所以效率还算差强人意。不过相对ATL的thunk技术就要逊色写了,个人觉得。

再补充一下_AfxCbtFilterHook钩子过程中关于窗口过程句柄的设置。AfxGetAfxWndProc函数返回的是AfxWndProcBase,后者的工作其实也就是转掉AfxWndProc函数。

WNDPROC AFXAPI AfxGetAfxWndProc()
{
#ifdef _AFXDLL	 //一般定义了_AFXDLL
	return AfxGetModuleState()->m_pfnAfxWndProc;
#else
	return &AfxWndProc;
#endif
}

LRESULT CALLBACK
AfxWndProcBase(HWND hWnd, UINT nMsg, WPARAM wParam, LPARAM lParam)
{
	AFX_MANAGE_STATE(_afxBaseModuleState.GetData());
	return AfxWndProc(hWnd, nMsg, wParam, lParam);//殊途同归了又
}

所以之后的窗口过程回调函数实际上就是AfxWndProcBase或AfxWndProc。在AfxWndProc中完成消息的分发。下面就简单了

LRESULT CALLBACK
AfxWndProc(HWND hWnd, UINT nMsg, WPARAM wParam, LPARAM lParam)
{
	//···
	CWnd* pWnd = CWnd::FromHandlePermanent(hWnd);//这个熟悉的函数见过几次了,查找哈希表,返回指定窗口句柄的实例指针
	//···
	return AfxCallWndProc(pWnd, hWnd, nMsg, wParam, lParam);//传入窗口实例的this指针
}

LRESULT AFXAPI AfxCallWndProc(CWnd* pWnd, HWND hWnd, UINT nMsg,
							  WPARAM wParam = 0, LPARAM lParam = 0)
{
	//···
	//真正调用每个实例的窗口过程,终于找到对应的东家啦@!!从此就可以有私有数据了。
		lResult = pWnd->WindowProc(nMsg, wParam, lParam);
	//···
	return lResult;
}

到此MFC的消息映射终于算是完成了。此后的事情就是在各种类继承层次中进行那漫长而又复杂的消息路由了。

也许会有疑问:为什么必须得安装一个WH_CBT钩子?其实很简单,HWND到this的映射必须在WM_CREATE等消息到达窗口过程之前设置好,而且我们的应用程序得到窗口过程是在CreateWindow,CreateDialogIndirect系列函数返回时当做返回值传给应用程序的,可是API告诉我们,此函数返回之前会发送WM_CREATE等函数!毫无疑问,等这个函数返回时去设置映射已经晚了,唯一的方法是安装钩子。

总结一下:
因为window操作系统对窗口过程的要求必须是静态或者全局的CALLBACK调用约定的函数,传入HWND参数标志对应的窗口。
而ATL/MFC 力求用面向对象封装繁琐的WIN32面向过程的window应用程序开发方式。但是又只能给操作系统提供统一的窗口过程。所以势必得再HWND和窗口类实例的this指针直接建立映射。在统一的窗口回调过程中再把消息“分发”到对应的窗口类的成员函数中去。

1. 对于ATL来说,其使用thunk技术巧妙的实现了这个目标。
2. MFC则使用哈希表来建立映射。
个人感觉这次ATL和MFC对弈的结果是ATL优于MFC,尽管后者哈希表可能能达到线性时间速度,但是thunk的技术还是要好点的。呵呵···

说的不对的地方还请大家指教一下,谢谢了!欢迎一起讨论!

Share
分类: VC++ 标签: ,
  1. 本文目前尚无任何评论.
  1. 本文目前尚无任何 trackbacks 和 pingbacks.

注意: 评论者允许使用'@user空格'的方式将自己的评论通知另外评论者。例如, ABC是本文的评论者之一,则使用'@ABC '(不包括单引号)将会自动将您的评论发送给ABC。使用'@all ',将会将评论发送给之前所有其它评论者。请务必注意user必须和评论者名相匹配(大小写一致)。