一、Win32基本程序观念 (学习笔记)

>>>   基础:对于Windows   程序的事件驱动特性的了解(包括消息的产生、获得、分派、判断、处理)

>>>   基础:对C++多态(polymorphism)的精确体会

Win32程序开发流程

Windows   程序分为「程序代码」「U I(User  Interface)资源」两大部份,两部份最后以RC编译器整合为一个完整的EX E 文件(图1-1)。所谓U I 资源是指功能菜单、对话框外貌、程序图标、光标形状等等东西。这些U I  资源的实际内容(二进制代码)系借助各种工具产生,并以各种扩展名存在,如. ico、. bmp、. cur 等等。程序员必须在一个所谓的资源描述档(. rc )中描述它们。RC 编译器(RC.EXE)读取RC 档的描述后将所有U I资源档集中制作出一个. RES 档,再与程序代码结合在一起,这才是一个完整的Windows可执行档。

需要什么函数库(.LIB )

众所周知Windows支持动态联结。换句话说,应用程序所调用的Windows API 函数是在「执行时期」才联结上的。那么,「联结时期」所需的函数库做什么用?有哪些?并不是延伸档名为. dll 才是动态联结函数库(DLL,Dynamic Link Library ),事实上. exe 、. dll、. fon、. mod、. drv、. ocx 都是所谓的动态联结函数库。Windows程序调用的函数可分为C  Runtimes以及Windows API 两大部份。早期的CRun times并不支持动态联结,但Visual C++4. 0之后已支持,并且在32 位操作系统中已不再有small/medium/large等内存模式之分。以下是它们的命名规则与使用时机:■   LIBC. LIB -  这是C  Runtime  函数库的静态联结版本。■   MSVCRT. LIB -  这是C  Runtime  函数库动态联结版本(MSVCRT40.DLL)的import函数库。如果联结此函数库,你的程序执行时必须有MSVCRT40.DLL   在场。另一组函数Windows API,由操作系统本身(主要是Windows三大模块GDI32.DLL和USER32.DLL和KERNEL32.DLL)提供(注)。虽说动态联结是在执行时期才发生「联结」事实,但在联结时期,联结器仍需先为调用者(应用程序本身)准备一些适当的信息,才能够在执行时期顺利「跳」到DLL执行。如果该API 所属之函数库尚未加载,系统也才因此知道要先行加载该函数库。这些适当的信息放在所谓的「import函数库」中。32位Windows的三大模块所对应的import函数库分别为GDI32.LIB 和USER32.LIB和KERNEL32.LIB。Windows发展至今,逐渐加上的一些新的API函数(例如CommonDialog、ToolHelp )并不放在GDI和USER和KERNEL三大模块中,而是放在诸如COM MDLG.DLL、TOOLHELP.DLL之中。如果要使用这些APIs ,联结时还得加上这些DLLs所对应的import函数库,诸如COMDLG32.LIB和TH32.LIB。很快地,在稍后的范例程序Generic的makefile中,你就可以清楚看到联结时期所需的各式各样函数库(以及各种联结器选项)。

需要什么头文件(.H)

所有Windows程序都必须包含WINDOWS.H。早期这是一个巨大的头文件,大约有5000行左右,Visual C++4.0已把它切割为各个较小的文件,但还以WINDOWS.H总括之。除非你十分清楚什么API动作需要什么头文件,否则为求便利,单单一个WINDOWS.H也就是了。不过,WINDOWS.H只照顾三大模块所提供的API函数,如果你用到其它system DLLs,例如COMMDLG.DLL或MAPI.DLL或TAPI.DLL等等,就得包含对应的头文件,例如COMMDLG.H或MAPI.H或TA PI.H等等。

以消息为基础,以事件驱动之(message based,event driven)

Windows   程序的进行系依靠外部发生的事件来驱动。换句话说,程序不断等待(利用一个while  回路),等待任何可能的输入,然后做判断,然后再做适当的处理。上述的「输入」是由操作系统捕捉到之后,以消息形式(一种数据结构)进入程序之中。操作系统如何捕捉外围设备(如键盘和鼠标)所发生的事件呢?噢,USER  模块掌管各个外围的驱动程序,它们各有侦测回路

如果把应用程序获得的各种「输入」分类:

由硬件装置所产生的消息(如鼠标移动或键盘被按下),放在系统队列(system queue)中

由Windows系统或其它Windows程序传送过来的消息,放在程序队列(application queue )中

以应用程序的眼光来看,消息就是消息,来自哪里或放在哪里其实并没有太大区别,反正程序调用GetMessage API就取得一个消息,程序的生命靠它来推动。所有的GUI系统,包括U N IX的XWindow以及O S /2的Presentation Manager ,都像这样,是以消息为基础的事件驱动系统。可想而知,每一个Windows   程序都应该有一个回路如下:

MSG msg;
while (GetMessage(&msg, NULL, NULL, NULL)) {
	TranslateMessage(&msg);
	DispatchMessage(&msg);
}
// 以上出现的函数都是Windows API  函数

消息,也就是上面出现的MSG结构,其实是Windows内定的一种资料格式:

/* Queued message structure */
typedef struct tagMSG
{
	HWND hwnd;
	UINT message; // WM_xxx ,例如WM_MOUSEMOVE ,WM_SIZE...
	WPARAM wParam;
	LPARAM lParam;
	DWORD time;
	POINT pt;
} MSG;

接受并处理消息的主角就是窗口。每一个窗口都应该有一个函数负责处理消息,程序员必须负责设计这个所谓的「窗口函数」(window  procedure ,或称为window function)。如果窗口获得一个消息,这个窗口函数必须判断消息的类别,决定处理的方式。以上就是Windows程序设计最重要的观念。至于窗口的产生与显示,十分简单,有专门的API函数负责。稍后我们就会看到Windows程序如何把这消息的取得、分派、处理动作表现出来。


一个具体的Win32 程序


许多相关书籍或文章尝试以各种方式简化Windows程序的第一步,因为单单一个H ello程序就要上百行,怕把大家吓坏了。我却宁愿各位早一点接触正统写法,早一点看到全貌。Windows的东西又多又杂,早一点一窥全貌是很有必要的。而且你会发现,经过有条理的解释之后,程序代码的多寡其实构不成什么威胁(否则无字天书最适合程序员阅读)。再说,上百行程序代码哪算得了什么!你可以从图1-2 得窥Win32  应用程序的本体与操作系统之间的关系。Win32 程序中最具代表意义的动作已经在该图显示出来,完整的程序代码展示于后。本章后续讨论都围绕着此一程序。


程序工程:mfc11


程序进入点WinMain


main是一般C程序的进入点:
int main(int argc, char *argv[ ], char *envp[ ]);
{
	...
}
WinMain 则是Windows程序的进入点:
int CALLBACK WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
LPSTR lpCmdLine, int nCmdShow)
{
...
}
// 在Win32  中CALLBACK  被定义为__stdcall ,是一种函数调用习惯,关系到
// 参数挤压到堆栈的次序,以及处理堆栈的责任归属。其它的函数调用习惯还有
// _pascal和_cdecl

当Windows的「外壳」(shell,例如Windows3. 1的程序管理员或Windows 95 的文件总管)侦测到使用者意欲执行一个Windows 程序,于是调用加载器把该程序加载,然后调用Cstartupcode,后者再调用WinMain,开始执进程序。WinMain  的四个参数由操作系统传递进来。


窗口类别之注册与窗口之诞生


一开始,Windows程序必须做些初始化工作,为的是产生应用程序的工作舞台:窗口。 这没有什么困难,因为A P I 函数CreateWindow 完全包办了整个巨大的工程。但是窗口 产生之前,其属性必须先设定好。 所谓属性包括窗口的「外貌」「行为」,一个窗口 的边框、颜色、标题、位置等等就是其外貌,而窗口接收消息后的反应就是其行为(具 体地说就是指窗口函数本身)。程序必须在产生窗口之前先利用A P I函数RegisterClass 设定属性(我们称此动作为注册窗口类别)。Register Class需要一个大型数据结构 WNDCLASS 做为参数,CreateWindow 则另需要11 个参数。


从图1-3 可以清楚看出一个窗口类别牵扯的范围多么广泛,其中wc. lpfnWndProc所指定的函数就是窗口的行为中枢,也就是所谓的窗口函数。注意,CreateWindow 只产生窗口,并不显示窗口,所以稍后我们必须再利用ShowWindow将之显示在屏幕上。又,我们希望先传送个WM_PAINT给窗口, 以驱动窗口的绘图动作, 所以调用UpdateWindow。消息传递的观念暂且不表,稍后再提。请注意,在程序中,Register Class被我包装在InitApplication 函数之中,Creat eWindow 则被我包装在InitInstance  函数之中。这种安排虽非强制,却很普遍:

int CALLBACK WinMain(
	HINSTANCE hInstance, 
	HINSTANCE hPrevInstance,
	LPSTR lpCmdLine, 
	int nCmdShow
)
{
	if (!hPrevInstance)
	if (! InitApplication (hInstance))
	return (FALSE);
	if (! InitInstance (hInstance, nCmdShow))
	return (FALSE);
	...
}
//--------------------------------------------------
BOOL  InitApplication (HINSTANCE hInstance)
{
	WNDCLASS wc;
	...
	return ( RegisterClass (&wc));
}
//--------------------------------------------------
BOOL  InitInstance (HINSTANCE hInstance, int nCmdShow)
{
	_hWnd =  CreateWindow (...);
	...
}


两个函数(InitApplicat ion 和InitInstance)的名称别具意义:

  在Windows3. x时代,窗口类别只需注册一次,即可供同一程序的后续每一个执行实例(instance)使用(之所以能够如此,是因为所有进程共在一个地址空间中),所以我们把Register Class这个动作安排在「只有第一个执行个体才会进入」的InitApplication 函数中。至于此一进程是否是某个程序的第一个执行实例,可由WinMain的参数hPrevInstance判断之;其值由系统传入。

 ■ 产生窗口,是每一个执行实例(instance)都得做的动作, 所以我们把Creat eWindow 这个动作安排在「任何执行实例都会进入」的InitInstance函数中。


以上情况在Windows NT和Windows95中略有变化。由于Win32程序的每一个执行实例(instance)有自己的地址空间,共享同一窗口类别已不可能。但是由于Win32系统令hPrevInstance永远为0,所以我们仍然得以把Register Class和Creat eWindow 按旧习惯安排。既符合了新环境的要求,又兼顾到了旧源代码的兼容。InitApplication和InitInstance只不过是两个自定函数,为什么我要对此振振有词呢?因是M F C把这两个函数包装成CWinApp 的两个虚拟成员函数。第6章「M F C程序的生与死」对此有详细解释。


消息循环

初始化工作完成后,WinMain  进入所谓的消息循环:
while (GetMessage(&msg,...)) {
	TranslateMessage(&msg); //  转换键盘消息
	DispatchMessage(&msg); //  分派消息
}
其中的TranslateMessage是为了将键盘消息转化,DispatchMessage会将消息传给窗口函数去处理。没有指定函数名称,却可以将消息传送过去,岂不是很玄?这是因为消息发生之时,操作系统已根据当时状态,为它标明了所属窗口,而窗口所属之窗口类别又已经明白标示了窗口函数(也就是wc. lpfnWndProc   所指定的函数),所以DispatchMessage自有脉络可寻。
请注意图1-2 所示,DispatchMessage  经过USER  模块的协助,才把消息交到窗口函数手中。
消息循环中的GetMessage  是Windows   3. x非强制性(non-preemptive )多任务的关键。应用程序藉由此动作,提供了释放控制权的机会:如果消息队列上没有属于我的消息,我就把机会让给别人。透过程序之间彼此协调让步的方式,达到多任务能力。Windows   95 和Windows   N T  具备强制性(preempt ive )多任务能力,不再非靠GetMessage释放CPU控制权不可,但程序写法依然不变,因为应用程序仍然需要靠消息推动。它还是需要抓消息!

窗口的生命中枢:窗口函数


消息循环中的DispatchMessage把消息分配到哪里呢?它透过USER模块的协助,送到该窗口的窗口函数去了。窗口函数通常利用switch/case方式判断消息种类,以决定处置方式。由于它是被Windows系统所调用的(我们并没有在应用程序任何地方调用此函数),所以这是一种callback函数,意思是指「在你的程序中,被Windows系统调用」的函数。这些函数虽然由你设计,但是永远不会也不该被你调用,它们是为Windows系统准备的。程序进行过程中,消息由输入装置,经由消息循环的抓取,源源传送给窗口并进而送到窗口函数去。窗口函数的体积可能很庞大,也可能很精简,依该窗口感兴趣的消息数量多寡而定。至于窗口函数的形式,相当一致,必然是:
LRESULT CALLBACK WndProc(
	HWND hWnd,
	UINT message,
	WPARAM wParam,
	LPARAM lParam
)


注意,不论什么消息,都必须被处理,所以switch/case指令中的default:处必须调用DefWindowProc ,这是Windows内部预设的消息处理函数。窗口函数的wPar am和lParam的意义,因消息之不同而异。wParam在16 位环境中是16 位,在32 位环境中是32 位。因此,参数内容(格式)在不同操作环境中就有
了变化。
我想很多人都会问这个问题:为什么Windows Programming Modal  要把窗口函数设计为一个call back函数?为什么不让程序在抓到消息(GetMes sage)之后直接调用它就好了?原因是,除了你需要调用它,有很多时候操作系统也要调用你的窗口函数(例如当某个消息产生或某个事件发生)。窗口函数设计为callback形式,才能开放出一个接口给操作系统叫用。


消息映射(Message Map)的雏形


有没有可能把窗口函数的内容设计得更模块化、更一般化些?下面是一种作法。请注意,以下作法是MFC「消息映射表格」(第9章)的雏形,我所采用的结构名称和变量名称,都与MFC相同,藉此让你先有个暖身。首先,定义一个MSGMAP_ENTRY结构和一个dim宏:

struct MSGMAP_ENTRY 
{
	UINT nMessage;
	LONG (*pfn)(HWND, UINT, WPARAM, LPARAM);
};
#define dim(x) (sizeof(x) / sizeof(x[0]))


请注意MSGMAP_ENTRY的第二元素pfn是一个函数指针,我准备以此指针所指之函数处理nMessage  消息。这正是对象导向观念中把「资料」和「处理资料的方法」封装起来的一种具体实现,只不过我们用的不是C++语言。接下来,组织两个数组_messageEntries [  ]  和_commandEntries [  ],把程序中欲处理的消息以及消息处理例程的关联性建立起来:

// 消息与处理例程之对照表格
struct MSGMAP_ENTRY _messageEntries[] =
{
	WM_CREATE, OnCreate,
	WM_PAINT, OnPaint,
	WM_SIZE, OnSize,
	WM_COMMAND, OnCommand,
	WM_SETFOCUS, OnSetFocus,
	WM_CLOSE, OnClose,
	WM_DESTROY, OnDestroy,
} ;  	    ↑         ↑
	这是消息    这是消息处理例程

 //Command-ID与处理例程之对照表格
struct MSGMAP_ENTRY _commandEntries =
{
    IDM_ABOUT,      OnAbout,
    IDM_FILEOPEN,   OnFileOpen,
    IDM_SAVEAS,     OnSaveAs,
} ;     ↑                        ↑
   这是WM_COMMAND  命令项这是命令处理例程
               



于是窗口函数可以这么设计:

//----------------------------------------------------------------------//
//窗口函数
//----------------------------------------------------------------------
LRESULT CALLBACK WndProc(
			HWND  hWnd,     
			UINT  message,
                        WPARAM wParam, 
			LPARAM lParam
)
{
int i;
  for(i=0; i < dim(_messageEntries); i++) {  // 消息对照表
      if (message ==  _messageEntries[i].nMessage)
          return((*_messageEntries[i].pfn)(hWnd,  message, wParam,  lParam));
  }
  return(DefWindowProc(hWnd, message, wParam, lParam));
}
//----------------------------------------------------------------------
// OnCommand -- 专门处理WM_COMMAND 
//----------------------------------------------------------------------
LONG OnCommand(
		HWND hWnd,
	        UINT message,
                WPARAM  wParam, 
		LPARAM lParam)
{
int i;
  for(i=0; i < dim(_commandEntries); i++) {  // 
      if (LOWORD(wParam) ==  _commandEntries[i].nMessage)
          return((*_commandEntries[i].pfn)(hWnd, message, wParam, lParam));
  }
  return(DefWindowProc(hWnd, message, wParam, lParam));
}
//----------------------------------------------------------------------
LONG OnCreate(HWND hWnd, UINT wMsg, UINT wParam, LONG lParam)
{
   ...
}
//----------------------------------------------------------------------
LONG OnAbout(HWND hWnd, UINT wMsg, UINT wParam, LONG lParam)
{
...
}
//----------------------------------------------------------------------


这么一来,WndProc和OnCommand 永远不必改变,每有新要处理的消息,只要在_messageEntries [  ]  和_commandEntries [  ]  两个数组中加上新元素,并针对新消息撰写新的处理例程即可。

这种观念以及作法就是MFC的Message Map的雏形。MFC把其中的动作包装得更好更精致(当然因此也就更复杂得多),成为一张庞大的消息地图;程序一旦获得消息,就可以按图上溯,直到被处理为止。我将在第3章简单仿真MFC的Message Map ,并在第9章「消息映射与绕行」中详细探索其完整内容。

对话框的运作


Windows的对话框依其与父窗口的关系,分为两类:
1 . 「令其父窗口除能,直到对话框结束」,这种称为modal对话框。
2 . 「父窗口与对话框共同运行」,这种称为modeless对话框。

比较常用的是modal对话框。我就以程序对话框做为说明范例。


为了做出一个对话框,程序员必须准备两样东西:
1 . 对话框模板(dialog template)。这是在RC 文件中定义的一个对话框外貌,以各种方式决定对话框的大小、字形、内部有哪些控制组件、各在什么位置等等。
2 . 对话框函数(dialog procedure )。其类型非常类似窗口函数,但是它通常只处理WM_INITDIALOG和WM_COMMAND两个消息。对话框中的各个控制组件也都是小小窗口,各有自己的窗口函数,它们以消息与其管理者(父窗口,也就是对话框)沟通。而所有的控制组件传来的消息都是WM_COMMAND,再由其参数分辨哪一种控制组件以及哪一种通告(notification)。


Modal对话框的激活与结束,靠的是DialogBox和EndDialog两个API函数。请看图1-4。对话框处理过消息之后,应该传回TRUE;如果未处理消息,则应该传回FALSE。这是因为你的对话框函数之上层还有一个系统提供的预设对话框函数。如果你传回FALSE,该预设对话框函数就会接手处理。



模块定义文件(. DEF)

obsolete


资源描述档(. RC)

RC 文件是一个以文字描述资源的地方。常用的资源有九项之多,分别是ICON 、CURSOR、BITMAP 、FONT、DIALOG 、MENU 、ACCELERATOR、STRING 、VERSION INFO 。还可能有新的资源不断加入,例如Visual C++  4. 0  就多了一种名为TOOLBAR的资源。这些文字描述需经过RC 编译器,才产生可使用的二进制代码。本例示范ICON 、MENU和DIALOG三种资源。



Windows程序的生与死


我想你已经了解Windows程序的架构以及它与Windows系统之间的关系。对Windows消息种类以及发生时机的透彻了解,正是程序设计的关键。现在我以窗口的诞生和死亡,说明消息的发生与传递,以及应用程序的兴起与结束,请看图1-5 。

1 . 程序初始化过程中调用Creat eWindow,为程序建立了一个窗口,做为程序的萤幕舞台。CreateWindow 产生窗口之后会送出WM_CREATE直接给窗口函数,后者于是可以在此时机做些初始化动作(例如配置内存、开文件、读初始资料. . . )。
2 . 程序活着的过程中,不断以GetMessage从消息贮列中抓取消息。如果这个消息是WM_QUIT ,GetMessage会传回0而结束while循环,进而结束整个程序。
3 . DispatchMessage透过Windows USER模块的协助与监督,把消息分派至窗口函数。消息将在该处被判别并处理。
4 .
程序不断进行2 . 和3 . 的动作。
5 . 当使用者按下系统菜单中的Close命令项,系统送出WM_CLOSE。通常程序的窗口函数不栏截此消息,于是DefWindowProc处理它。
6 . DefWindowProc收到WM_CLOSE  后, 调用Destroy Window 把窗口清除。DestroyWindow 本身又会送出WM_DESTROY 。
7 . 程序对WM_D ESTROY的标准反应是调用PostQuitMessage。
8 . PostQuitMessage没什么其它动作,就只送出WM_QUIT消息,准备让消息循环中的GetMessage取得,如步骤2 ,结束消息循环。



为什么结束一个程序复杂如斯?因为操作系统与应用程序职司不同,二者是互相合作的关系,所以必需各做各的份内事,并互以消息通知对方。如果不依据这个游戏规则,可能就会有麻烦产生。你可以作一个小实验,在窗口函数中拦截WM_DESTROY ,但不调PostQuitMessage。你会发现当选择系统菜单中的Clos e 时,屏幕上这个窗口消失了,(因为窗口摧毁及数据结构的释放是D efWindowP r oc   调用D es tr oy Window 完成的),但是应用程序本身并没有结束(因为消息循环结束不了),它还留存在内存中。


空闲时间的处理:OnIdle

所谓空闲时间(idle time),是指「系统中没有任何消息等待处理」的时间。举个例子,没有任何程序使用定时器(timer ,它会定时送来WM_TIMER),使用者也没有碰触键盘和鼠标或任何外围,那么,系统就处于所谓的空闲时间。空闲时间常常发生。不要认为你移动鼠标时产生一大堆的WM_MOUSEMOVE ,事实上夹杂在每一个WM_MOUSEMOVE之间就可能存在许多空闲时间。毕竟,计算机速度超乎想像。背景工作最适宜在空闲时间完成。传统的SDK程序如果要处理空闲时间,可以以下列循环取代WinMain中传统的消息循环:

while (TRUE) {
	if (PeekMessage (&msg, NULL, 0, 0, PM_REMOVE) {
	if (msg.message ==  WM_QUIT)
	break;
	TranslateMessage(&msg);
	DispatchMessage(&msg);
}
	else {
	OnIdle ();
}
}

原因是PeekMessage和GetMessage的性质不同。它们都是到消息队列中抓消息,如果抓不到,程序的主执行线程(primary thread ,是一个UI 执行线程)会被操作系统虚悬住。当操作系统再次回来照顾此一执行线程,而发现消息队列中仍然是空的,这时候两个API函数的行为就有不同了:

   GetMessage会过门不入,于是操作系统再去照顾其它人。

  PeekMessage会取回控制权,使程序得以执行一段时间。于是上述消息循环进入OnIdle函数中。


第6章的HelloMFC将示范如何在MFC程序中处理所谓的idle time(p. 403)。



Console程序


说到Windows程序,一定得有WinMain、消息循环、窗口函数。即使你只产生一个对话窗(Dialog Box)或消息窗(Message Box),也有隐藏在Windows  API(DialogBox MessageBox )内里的消息循环和窗口函数。

过去那种单单纯纯纯的C/C++程序,有着简单的main和printf的好时光到哪里去了?夏天在阴凉的树荫下嬉戏,冬天在温暖的炉火边看书,啊,Where did the   good times go?

其实说到Win32程序,并不是每个都如Windows GUI程序那么复杂可怖。是的,你可以在Visual C++ 中写一个" DOS-like "   程序,而且仍然可以调用部份的、不牵扯到图形使用者接口(GUI)的Win32 API。这种程序称为console  程序。甚至你还可以在console程序中使用部份的MFC类别(同样必须是与GUI没有关连的),例如处理数组、串行等数据结构的collection classes (CArray、CList、CMap)、与文件有关的CFile、CStdioFile。

我在BBS论坛上看到很多程序设计初学者,还没有学习C/C++,就想直接学习Visual C++。并不是他们好高骛远,而是他们以为Visual C++是一种特殊的C++ 语言。吃过苦头的过来人以为初学所说的Visual C++ programming是指MFC programming,所以大吃一惊(没有一点C++  基础就要学习MFC programming,当然是大吃一惊)。

在Visual  C++ 中写纯种的C/C++  程序?当然可以!不牵扯任何窗口、对话窗、控制组件,那就是console程序!  虽然我这本书没有打算照顾 C++ 初学者,然而我还是决定 把console程序设计的一些相关心得放上来,同时也是因为我打算以console程序完成稍 后的多线程程序范例。第3章的MFC六大技术仿真程序也都是console程序。

其实,除了" DOS-like" ,console程序还另有妙用。如果你的程序和使用者之间是以巨量文字来互动,或许你会选择使用edit控制组件(或MFC的CEditView)。但是你知道,计算机在一个纯粹的「文字窗口」(也就是console  窗口)中处理文字的显现与卷动比较快,你的程序动作也比较简单。所以,你也可以在Windows程序中产生console窗口,独立出来操作。


这也许不是你所认知的console程序。总之,有这种混合式的东西存在。这一节将以我自己的一个极简易的个人备份软件JBACKUP为实例,说明Win32 consol e程序的撰写,以及如何在其中使用Win32 API(其实直接调用就是了)。再以另一个极小的程序MFCCON示范MFC console程序(用到了MFC的CStudioFile  和CString)。对于这么小的程序而言,实在不需动用到整合环境下的什么项目管理。至于复杂一点的程序,就请参考第4章最后一节「Console程序的项目管理」。



Console程序与DOS程序的差别


不少人把DOS程序和console程序混为一谈,这是不对的。以下是各方面的比较。

制造方式

在Windows环境下的DOS Box中,或是在Windows版本的各种C++ 编译器套件的整合环境(IDE)中(第4章「Consol e  程序项目管理」),利用Windows   编译器、联结器做出来的程序,都是所谓Win32  程序。如果程序是以main为进入点,调用Cruntime函数和「不牵扯G U I」的Win32 API函数,那么就是一个console  程序,console窗口将成为其标准输入和输出装置(cin和cout)。过去在DOS环境下开发的程序,称为DOS程序,它也是以main为程序进入点,可
以调用C runtime函数。但,当然,不可能调用Win32 API函数。

程序能力

过去的DOS程序仍然可以在Windows的DOS Box中跑(Win95的兼容性极高,WinNT的兼容性稍差)。Console程序当然更没有问题。由于console程序可以调用部份的Win32 API(尤其是KERNEL32. DLL模块所提供的那一部份),所以它可以使用Windows提供的各种高级功能。它可以产生进程(processes ),产生执行线程(threads )、取得虚拟内存的信息、刺探操作系统的各种资料。但是它不能够有华丽的外表--因为它不能够调用与GUI有关的各种API函数。DOS   程序和console程序两者都可以做printf 输出和cout输出,也都可以做scanf 输入和cin输入。

可执行档格式

DOS程序是所谓的MZ格式(MZ是Mark Zbikowski的缩写,他是DOS系统的一位主要构造者)。Console程序的格式则和所有的Win32程序一样,是所谓的P E(PortableExecutable)格式,意思是它可以被拿到任何Win32平台上执行。Visual C++附有一个DUMPBIN工具软件,可以观察PE文件格式。拿它来观察本节的JBACKUP程序和MFCCON程序(以及第3章的所有程序),得到这样的结果:

H:\u004\prog\jbackup.01>dumpbin /summary jbackup.exe
	Microsoft (R) COFF Binary File Dumper Version 5.00.7022
	Copyright (C) Microsoft Corp 1992-1997. All rights reserved.
	Dump of file jbackup.exe
	File Type: EXECUTABLE IMAGE
		Summary
		5000 .data
		1000 .idata
		1000 .rdata
		5000 .text

拿它来观察D O S程序,则得到这样的结果:

C:\UTILITY>dumpbin /summary dsize.exe
	Microsoft (R) COFF Binary File Dumper Version 5.00.7022
	Copyright (C) Microsoft Corp 1992-1997. All rights reserved.
	Dump of file dsize.exe
	DUMPBIN : warning LNK4094: "dsize.exe" is an MS-DOS executable;
		use EXEHDR to dump it
  		Summary


Console程序的编译联结


ellipsis...



JBACKUP:Win32 Console程序设计


撰写console程序,有几个重点请注意:
1 . 进入点为main。
2 . 可以使用printf、scanf、cin、cout等标准输出入装置。
3 . 可以调用和GUI无关的Win32 API。


我的这个JBACKUP程序可以有一个或两个参数,用法如下:

C: \SomeoneDir> JBACKUP SrcDir [DstDir]

代码:JBackup项目编译通过


MFCCON:MFC Console程序设计


当你的进度还在第1章的Win32  基本程序观念,我却开始讲如何设计一个MFC console程序,是否有点时地不宜?是有一点!所以我挑一个最单纯而无与别人攀缠纠葛的MFC类别,写一个40 行的小程序。目标纯粹是为了做一个导入,并与Win32  console程序做一比较。我所挑选的两个单纯的MFC类别是CStdioFile和CString:



在M F C  之中,CFile用来处理正常的文件I/ O动作。CStdioFile衍生自CFile,一个CStdioFile对象代表以Cruntime函数fopen所开启的一个stream文件。Stream  文件有缓冲区,可以文字模式(预设情况)或二进制模式开启。CString对象代表一个字符串,是一个完全独立的类别。我的例子用来计算小于100的所有费伯纳契数列(Fabonacci sequence )。费伯纳契数列的计算方式是:
1 . 头两个数为1。
2 . 接下来的每一个数是前两个数的和。

程序:工程1MFCCON


这么简单的例子中,我们看到MFC Console 程序的几个重点:
1 . 程序进入点仍为main
2 . 需包含所使用之类别的头文件(本例为AFX.H)
3 . 可直接使用与GUI无关的MFC类别(本例为CStdioFile和CString)
4 . 编辑时需指定/MT,表示使用多执行线程版本的C runtime 函数库。


第4点需要多做说明。在MFC console  程序中一定要指定多线程版的C  runt ime  函数库,

在VS项目设置中 要使用MFC的DLL库



进程与执行线程(Process and Thread)


OS /2、Windows NT以及Windows 95 都支持多执行线程,这带给PC程序员一股令人兴奋的气氛。然而它带来的不全然是利多,如果不谨慎小心地处理执行线程的同步问题,程序的错误以及除错所花的时间可能使你发誓再也不碰「执行线程」这种东西。我们习惯以进程(process )表示一个执行中的程序,并且以为它是CPU排程单位。事实上执行线程才是排程单位。


核心对象

首先让我解释什么叫作「核心对象」(kernel object )。「GDI对象」是大家比较熟悉的东西,我们利用GDI函数所产生的一支笔(Pen)或一支刷(Brush)都是所谓的「GDI对象」。但什么又是「核心对象」呢? 你可以说核心对象是系统的一种资源(噢,这说法对GDI 对象也适用),系统对象一旦产生,任何应用程序都可以开启并使用该对象。系统给予核心对象一个计数值(usagecount )做为管理之用。核心对象包括下列数种:
                                                       

前三者用于执行线程的同步化:file-mapping 对象用于内存映射文件(memory  mappingfile),process和thread对象则是本节的主角。这些核心对象的产生方式(也就是我们所使用的API)不同,但都会获得一个handle做为识别;每被使用一次,其对应的计数值就加1。核心对象的结束方式相当一致,调用CloseHandle即可。「process对象」究竟做什么用呢?它并不如你想象中用来「执进程序代码」;不,程序代码的执行是执行线程的工作,「process对象」只是一个数据结构,系统用它来管理进程。



一个进程的诞生与死亡


执行一个程序,必然就产生一个进程(process )。最直接的程序执行方式就是在shell  (如Win95的文件总管或Windows 3. x  的文件管理员)中以鼠标双击某一个可执行文件图标(假设其为App.exe ),执行起来的App 进程其实是shell调用CreateProcess激活的。让我们看看整个流程:


1 . shell调用CreateProcess激活App.exe 。


2 . 系统产生一个「进程核心对象」,计数值为1。


3 . 系统为此进程建立一个4GB  地址空间。


4 . 加载器将必要的码加载到上述地址空间中,包括App.exe   的程序、资料,以及所需的动态联结函数库(DLLs )。加载器如何知道要加载哪些DLLs呢?它们被记录在可执行文件(PE文件格式)的. idatas ection 中。


5 . 系统为此进程建立一个执行线程,称为主执行线程(primary thread )。执行线程才是CPU时间的分配对象。


6 . 系统调用Cruntime函数库的Start up code。


7 . Start up code  调用App程序的WinMain函数。


8 . App 程序开始运作。


9 . 使用者关闭App 主窗口,使WinMain中的消息循环结束掉,于是WinMain结束。


1 0 . 回到Start up code。


1 1 . 回到系统,系统调用ExitProcess结束进程。


可以说,透过这种方式执行起来的所有Windows   程序,都是shell的子进程。本来,母进程与子进程之间可以有某些关系存在,但shell在调用CreateProcess   时已经把母子之间的脐带关系剪断了,因此它们事实上是独立实例。稍后我会提到如何剪断子进程的脐带。



产生子进程


你可以写一个程序,专门用来激活其它的程序。关键就在于你会不会使用CreateProcess 。这个API函数有众多参数:
CreateProcess(
LPCSTR lpApplicationName,
LPSTR lpCommandLine,
LPSECURITY_ATTRIBUTES lpProcessAttributes,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
BOOL bInheritHandles,
DWORD dwCreationFlags,
LPVOID lpEnvironment,
LPCSTR lpCurrentDirectory,
LPSTARTUPINFO lpStartupInfo,
LPPROCESS_INFORMATION lpProcessInformation
);

第一个参数:lpApplicationName指定可执行档档名。


第二个参数:lpCommandLine指定欲传给新进程的命令列(command line )参数。如果你指定了lpApplicationName,但没有扩展名,系统并不会主动为你加上. EXE扩展名;如果没有指定完整路径,系统就只在目前工作目录中寻找。但如果你指定lpApplicationName为NULL的话,系统会以lpCommandLine的第一个「段落」(我的意思其实是术语中所谓的token)做为可执行档档名;如果这个档名没有指定扩展名,就采用预设的" . EXE"   扩展名;如果没有指定路径,Windows   就依照五个搜寻路径来寻找可执行文件,分别是:

1 . 调用者的可执行文件所在目录

2 . 调用者的目前工作目录

3 . Windows   目录

4 . Windows   System  目录

5 . 环境变量中的pa th  所设定的各目录


第三、四个参数和第五个参数:建立新进程之前,系统必须做出两个核心对象,也就是「进程对象」和「执行线程对象」CreateProcess的第三个参数和第四个参数分别指定这两个核心对象的安全属性。至于第五个参数(TRUE  或FALSE)则用来设定这些安全属性是否要被继承。关于安全属性及其可被继承的性质,碍于本章的定位,我不打算在此介绍。


第六个参数:dwCreationFlags  可以是许多常数的组合,会影响到进程的建立过程。这些常数中比较常用的是CREATE_SUSPENDED ,它会使得子进程产生之后,其主执行线程立刻被暂停执行。


第七个参数lpEnvironment:可以指定进程所使用的环境变量区。通常我们会让子进程继承父进程的环境变量,那么这里要指定NULL。


第八个参数lpCurrentDirectory:用来设定子进程的工作目录与工作磁盘。如果指定NULL,子进程就会使用父进程的工作目录与工作磁盘。


第九个参数lpStartupInfo:是一个指向STARTUPINFO结构的指针。这是一个庞大的结构,可以用来设定窗口的标题、位置与大小,详情请看API使用手册。


最后一个参数是一个指向PROCESS_INFORMAT ION  结构的指针:


typedef struct _PROCESS_INFORMATION {
HANDLE hProcess;
HANDLE hThread;
DWORD dwProcessId;
DWORD dwThreadId;
   } PROCESS_INFORMATION;


当系统为我们产生「进程对象」和「执行线程对象」,它会把两个对象的handle填入此结构的相关字段中,应用程序可以从这里获得这些handles 。


如果一个进程想结束自己的生命,只要调用:

VOID ExitProcess(UINT fuExitCode);

就可以了。如果进程想结束另一个进程的生命,可以使用:

BOOL TerminateProcess(HANDLE hProcess, UINT fuExitCode);

很显然,只要你有某个进程的handle,就可以结束它的生命。TerminateProcess并不被建议使用,倒不是因为它的权力太大,而是因为一般进程结束时,系统会通知该进程所开启(所使用)的所有DLLs ,但如果你以TerminateProcess结束一个进程,系统不会做这件事,而这恐怕不是你所希望的。



前面我曾说过所谓割断脐带这件事情,只要你把子进程以CloseHandle  关闭,就达到了目的。下面是个例子:


PROCESS_INFORMATION ProcInfo;
BOOL fSuccess;
fSuccess = CreateProcess(...,&ProcInfo);
if (fSuccess) {
		CloseHandle(ProcInfo.hThread);
		CloseHandle(ProcInfo.hProcess);
}


一个执行线程的诞生与死亡


程序代码的执行,是执行线程的工作。当一个进程建立起来,主执行线程也产生。所以每一个Windows程序一开始就有了一个执行线程。我们可以调用Creat eThread 产生额外的执行线程,系统会帮我们完成下列事情:
1 . 配置「执行线程对象」,其handle将成为CreateThread的传回值。
2 . 设定计数值为1。
3 . 配置执行线程的context 。
4 . 保留执行线程的堆栈。
5 . 将context   中的堆栈指针缓存器(SS)和指令指针缓存器(IP )设定妥当。


看看上面的态势,的确可以显示出执行线程是CPU分配时间的单位。所谓工作切换(context switch)其实就是对执行线程的context的切换。程序若欲产生一个新执行线程,调用CreateThread 即可办到:

CreateThread(LPSECURITY_ATTRIBUTES lpThreadAttributes,
DWORD dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);

第一个参数表示安全属性的设定以及继承,请参考API手册。Windows 95忽略此一参数。第二个参数设定堆栈的大小。第三个参数设定「执行线程函数」名称,而该函数的参数则在这里的第四个参数设定。第五个参数如果是0,表示让执行线程立刻开始执行,如果是CREATE_SUSPENDED   , 则是要求执行线程暂停执行( 那么我们必须调用ResumeThread 才能令其重新开始)。最后一个参数是个指向DWORD的指针,系统会把执行线程的ID放在这里。


上面我所说的「执行线程函数」是什么?让我们看个实例:


VOID ReadTime(VOID);
HANDLE hThread;
DWORD ThreadID;
hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ReadTime ,
                           NULL, 0, &ThreadID);
...
//--------------------------------------------------------------------
// thread 函数。
// 不断利用 GetSystemTime  取系统时间,
// 并将结果显示在对话框 _hhwndDlg 的 IDE_TIMER  栏位上。
//--------------------------------------------------------------------
VOID  ReadTime (VOID)
{
char str[50];
SYSTEMTIME st;
    while(1)  {
        GetSystemTime(&st);
        sprintf(str,"%u:%u:%u",  st.wHour, st.wMinute,  st.wSecond);
        SetDlgItemText (_hWndDlg, IDE_TIMER,  str);
        Sleep (1000);  //  延迟一秒。
    }
}

当Creat eT hr ead 成功,系统为我们把一个执行线程该有的东西都准备好。执行线程的主体在哪里呢?就在所谓的执行线程函数。执行线程与执行线程之间,不必考虑控制权释放的问题,因为Win32  操作系统是强制性多任务。


执行线程的结束有两种情况,一种是寿终正寝,一种是未得善终。前者是执行线程函数正常结束退出,那么执行线程也就自然而然终结了。这时候系统会调用ExitT hr ead 做些善后清理工作(其实执行线程中也可以自行调用此函数以结束自己)。但是像上面那个例子,执行线程根本是个无穷循环,如何终结?一者是进程结束(自然也就导至执行线程的结束),二者是别的执行线程强制以T er m inateT hr ead 将它终结掉。不过,T er m inateT hr ead 太过毒辣,非必要还是少用为妙(请参考A P I  手册)。


以_begint hreadex取代CreateThread


别忘了Windows程序除了调用Win32 API,通常也很难避免调用任何一个Cruntime  函数。为了保证多线程情况下的安全,Cruntime 函数库必须为每一个执行线程做一些簿记工作。没有这些工作,Crunt ime  函数库就不知道要为每一个执行线程配置一块新的内存,做为执行线程的区域变量用。因此,CreateThread 有一个名为_beginthreadex的外包函数,负责额外的簿记工作。

请注意函数名称的底线符号。它必须存在,因为这不是个标准的ANSI Cruntime函数。_beginthreadex的参数和CreateThread 的参数其实完全相同,不过其型别已经被「净化」了,不再有Win32型别包装。这原本是为了要让这个函数能够移植到其它操作系统,因为微软希望_beginthreadex能够被实作于其它平台,不需要和Windows有关、不需要包含w indows.h。但实际情况是,你还是得调用CloseHandle以关闭执行线程, 而CloseHandle却是个Win32 API,所以你还是需要包含windows.h、还是和Windows脱离不了关系。微软空有一个好主意,却没能落实它。


把_be gi nt hr eadex  视为Creat eT hr ead 的一个看起来比较有趣的版本,就对了:

unsigned long _beginthreadex(
void *security,
unsigned stack_size,
unsigned (__stdcall *start_address)(void *),
void *arglist,
unsigned initflag,
unsigned* thrdaddr
);

_beginthreadex所传回的unsigned long事实上就是一个Win32  HANDLE,指向新执行线程。换句话说传回值和CreateT hread 相同,但_beginthreadex另外还设立了errno和doserrno 。



执行线程优先权(Priority)

优先权是排程的重要依据。优先权高的执行线程,永远先获得CP U的青睐。当然啦,操作系统会视情况调整各个执行线程的优先权。例如前景执行线程的优先权应该调高一些,背景执行线程的优先权应该调低一些。执行线程的优先权范围从0(最低)到31(最高)。当你产生执行线程,并不是直接以数值指定其优先权,而是采用两个步骤。

第一个步骤是指定「优先权等级(Priority Class)」给进程,

第二步骤是指定「相对优先权」给该进程所拥有的执行线程。图1-7 是优先权等级的描述,其中的代码在Creat eProcess的dwCreationFlags参数中指定。如果你不指定,系统预设给的是NORMAL _PRIORITY_CLASS --  除非父进程是IDL E_PRIORITY_CLASS(那么子进程也会是IDLE_PRIORITY_CLASS)。



■  " idle" 等级只有在CPU时间将被浪费掉时(也就是前一节所说的空闲时间)才执行。此等级最适合于系统监视软件,或屏幕保护软件。


■  " normal" 是预设等级系统可以动态改变优先权,但只限于" normal" 等级。当进程变成前景,执行线程优先权提升为9,当进程变成背景,优先权降低为7。


■ " high"  等级是为了立即反应的需要,例如使用者按下Ctrl +Esc时立刻把工作管理器(task manager )带出场。


■  " realtime"   等级几乎不会被一般应用程序使用。就连系统中控制鼠标、键盘、磁盘状态重新扫描、Ct rl +Alt+Del 等的执行线程都比" realtime"   优先权还低。这种等级使用在「如果不在某个时间范围内被执行的话,资料就要遗失」的情况。这个等级一定得在正确评估之下使用之,如果你把这样的等级指定给一般的(并不会常常被阻塞的)执行线程,多任务环境恐怕会瘫痪,因为这个执行线程有如此高的优先权,其它执行线程再没有机会被执行。


上述四种等级,每一个等级又映射到某一范围的优先权值。IDLE_ 最低,NORMAL _  次之,HIGH _  又次之,REALTIME _  最高。在每一个等级之中,你可以使用SetThr eadPriority设定精确的优先权,并且可以稍高或稍低于该等级的正常值(范围是两个点数)。你可以把SetThreadPriority  想象是一种微调动作。





多线程程序设计实例 (项目mfc12)


我设计了一个M ltiThrd  程序,放在书附盘片的M ltiThrd. 01 子目录中。这个程序一开始产生五个执行线程,优先权分别微调-2 、-1 、0、+1、+2,并且虚悬不执行:

HANDLE _hThread[5];  // global  variable
...
LONG APIENTRY MainWndProc (HWND hWnd, UINT message,
                                UINT wParam, LONG lParam)
{
    DWORD  ThreadID[5];
    static DWORD  ThreadArg[5] = {HIGHEST_THREAD,    // 0x00
                                  ABOVE_AVE_THREAD,  // 0x3F
                                     NORMAL_THREAD,     // 0x7F
                                      BELOW_AVE_THREAD,  // 0xBF
                                      LOWEST_THREAD      // 0xFF
                                      };     // 用来调整四方形顏色
    ...
    for(i=0;  i<5; i++)   // 产生  5  个 threads
        _hThread[i] = CreateThread(NULL,
                                        0,
                                        (LPTHREAD_START_ROUTINE) ThreadProc ,
                                        &ThreadArg[i],
                                        CREATE_SUSPENDED,
                                        &ThreadID[i]);
    //  設定 thread priorities
    SetThreadPriority(_hThread[0], THREAD_PRIORITY_HIGHEST);
    SetThreadPriority(_hThread[1], THREAD_PRIORITY_ABOVE_NORMAL);
   SetThreadPriority(_hThread[2], THREAD_PRIORITY_NORMAL);
   SetThreadPriority(_hThread[3], THREAD_PRIORITY_BELOW_NORMAL);
   SetThreadPriority(_hThread[4], THREAD_PRIORITY_LOWEST);
   ...
}

当使用者按下【Resume Threads 】菜单项目后,五个执行线程如猛虎出柙,同时冲出来。这五个执行线程使用同一个执行线程函数ThreadProc 。我在Thr eadProc中以不断的Rectangle动作表示执行线程的进行。所以我们可以从画面上观察执行线程的进度。我并且设计了两种延迟方式,以利观察。第一种方式是在每一次循环之中使用Sleep (10) ,意思是先睡10 个毫秒,之后再醒来;这段期间,CPU可以给别人使用。第二种方式是以空循环30000次做延迟;空循环期间CPU不能给别人使用(事实上CPU正忙碌于那30000次空转)。


UINT   _uDelayType=NODELAY;  // global variable
...
VOID  ThreadProc (DWORD *ThreadArg)
{
RECT rect;
HDC  hDC;
HANDLE hBrush, hOldBrush;
DWORD dwThreadHits = 0;
int   iThreadNo, i;
   ...
   do
   {
     dwThreadHits++;       //  计数器
      // 画出四方形,代表 thread  的进行
      Rectangle(hDC, *(ThreadArg), rect.bottom-(dwThreadHits/10),
                 *(ThreadArg)+0x40, rect.bottom);
      // 延迟...
     if (_uDelayType == SLEEPDELAY)
          Sleep (10);
     else if (_uDelayType == FORLOOPDELAY)
          for (i=0; i<30000; i++);
     else // _uDelayType == NODELAY)
         {   }
   } while (dwThreadHits <  1000);      //  循环  1000 次
   ...
}


1-9 是执行画面。注意,先选择延迟方式(" forloop delay"   或" sleep  delay" ),再按下【Resume Thread】。

如果你选择forloopdelay你会看到执行线程0  (优先权最高)几乎一路冲到底,然后才是执行线程1(优先权次之),然后是执行线程2(优先权再次之)


但如果你选择的sleep delay,所有执行线程不分优先权高低,同时行动。关于执行线程的排程问题,我将在第14 章做更多的讨论。



注意:为什么图中执行线程1尚未完成,执行线程2~4   竟然也有机会偷得一点点CPU 时间呢?这是排程器的巧妙设计,动态调整执行线程的优先权。是啊,总不能让优先权低的执行线程直到天荒地老,没有一点点获得。关于执行线程排程问题,第14章有更多的讨论


下图是以Process Viewer (Visual  C++  5. 0  所附工具)观察执行结果。图上方出现目前所有的进程,点选,果然在窗口下方出现六个执行线程,其中包括主执行线程(优先权已被调整为10)。




评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值