第1章 Win32 基本程序观念

程序设计领域里,每一个人都想飞。

但是,还没学会走之前,连跑都别想!

虽然这是一本深入讲解MFC 程序设计的书,我仍坚持要安排这第一章,介绍 Win32 的 基本程序设计原理(也就是所谓的SDK 程序设计原理)。从来不曾学习过在「事件驱动(event driven)系统」中撰写「以消息为基础(message based)之应用程序」者,能否一步跨入MFC 领域,直接以application framework 开发 Windows 程序,我一直抱持怀疑的态度。虽然有了MFC(或任何其它的application framework),你可以继承一整组类,从而快速得到一个颇具规模的程序,但是 Windows 程序的运作 本质(Message Based,Event Driven)从来不曾也不会改变。如果你不能了解其髓,空有 其皮其肉或其骨,是不可能有所精进的,即使能够操控 wizard,充其量却也只是个 puppet,对于手上的程序代码,没有自主权。

我认为学习MFC之前,必要的基础是,对于Windows程序的事件驱动特性的了解(包括消息的产生、获得、分派、判断、处理),以及对 C++ 多态(polymorphism)的精确体会。本章所提出的,是我对第一项必要基础的探讨,你可以从中获得关于 Windows 程序的诞生与死亡,以及多任务环境下程序之间共存的观念。至于第二项基础,将由第二章为你夯实。

图1-1 一个32位Windows SDK程序的开发流程

需要什么函数库(.LIB)

众所周知Windows 支持动态链接。换句话说,应用程序所调用的Windows API 函数是在「运行时」才链接上的。那么,「链接时期」所需的函数库做什么用?有哪些?

并不是延伸文件名为 .dll 者才是动态链接函数库(DLL,Dynamic Link Library),事实上 .exe、.dll、.fon、.mod、.drv、.ocx 都是所谓的动态链接函数库。

Windows 程序调用的函数可分为 C Runtimes 以及 Windows API 两大部分。早期的 C Runtimes 并不支持动态链接,但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 函数(例如 Common Dialog、ToolHelp) 并不放在GDI和USER 和KERNEL三大模块中,而是放在诸如COMMDLG.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 或 TAPI.H 等等。

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

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

如果把应用程序获得的各种「输入」分类,可以分为由硬件装置所产生的消息(如鼠标移动或键盘被按下),放在系统队列(system queue)中,以及由 Windows 系统或其它Windows 程序传送过来的消息,放在程序队列(application queue)中。以应用程序的眼光来看,消息就是消息,来自哪里或放在哪里其实并没有太大区别,反正程序调用GetMessage API就取得一个消息,程序的生命靠它来推动。所有的GUI系统,包括UNIX的X Window 以及OS/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程序的第一步,因为单单一个Hello程序就要上百行,怕把大家吓坏了。我却宁愿各位早一点接触正统写法,早一点看到全 貌。Windows 的东西又多又杂,早一点一窥全貌是很有必要的。而且你会发现,经过有 条理的解释之后,程序代码的多寡其实构不成什么威胁(否则无字天书最适合程序员阅 读)。再说,上百进程序代码哪算得了什么!

你可以从图1-2 得窥 Win32 应用程序的本体与操作系统之间的关系。Win32 程序中最具代表意义的动作已经在该图显示出来,完整的程序代码展示于后。本章后续讨论都围绕着此一程序。

稍后会出现一个makefile。关于makefile的语法,可能已经不再为大家所熟悉了。我想我有必要做个说明。所谓makefile,就是让你能够设定某个文件和某个文件相比——比较其产生日期。由其比较结果来决定要不要做某些你所指定的动作。例如:

generic.res : generic.rc generic.h
rc generic.rc

意思就是拿冒号(:)左边的generic.res和冒号右边的generic.rc 和generic.h 的文件日期相比。只要右边任一文件比左边的文件更新,就执行下一行所指定的动作。这动作可以是任何命令列动作,本例为rc generic.rc。

因此,我们就可以把不同文件间的依存关系做一个整理,以makefile 语法描述,以产生必要的编译、链接动作。makefile 必须以NMAKE.EXE(Microsoft 工具)或MAKE.EXE(Borland 工具)处理之,或其它编译器套件所附的同等工具(可能也叫做MAKE.EXE)处理之。

图 1-2 Windows 程序的本体与操作系统之间的关系

Generic.mak(请在DOS窗口中执行nmake generic.mak。环境设定请参考 p.224)

#0001  # filename : generic.mak
#0002  # make file for generic.exe (Generic Windows Application)
#0003  # usage : nmake generic.mak (Microsoft C/C++ 9.00) (Visual C++ 2.x)
#0004  # usage : nmake generic.mak (Microsoft C/C++ 10.00) (Visual C++ 4.0)
#0005
#0006  all: generic.exe
#0007
#0008  generic.res : generic.rc generic.h
#0009      rc generic.rc
#0010
#0011  generic.obj : generic.c generic.h
#0012      cl-c-W3-Gz-D_X86_-DWIN32 generic.c
#0013
#0014  generic.exe : generic.obj generic.res
#0015      link/MACHINE:I386 -subsystem:windows generic.res generic.obj \
#0016          libc.lib kernel32.lib user32.lib gdi32.lib

Generic.h

#0001  //---------------------------------------------------------------
#0002  // 文件名 : generic.h
#0003  //---------------------------------------------------------------
#0004  BOOL InitApplication(HANDLE);
#0005  BOOL InitInstance(HANDLE, int);
#0006  LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);
#0007  LRESULT CALLBACK About(HWND, UINT, WPARAM, LPARAM);

Generic.c(粗体代表Windows API函数或宏)

#0001  //---------------------------------------------------------------
#0002  //             Generic - Win32 程序的基础写法
#0003  //                Top Studio * J.J.Hou
#0004  // 文件名     : generic.c
#0005  // 作者       : 侯俊杰
#0006  // 编译链接   : 请参考 generic.mak
#0007  //---------------------------------------------------------------
#0008
#0009  #include <windows.h>   // 每一个 Windows 程序都需要含入此文件
#0010  #include "resource.h"  // 内含各个 resource IDs
#0011  #include "generic.h" // 本程序之含入文件
#0012
#0013  HINSTANCE _hInst; // Instance handle
#0014  HWND      _hWnd;
#0015
#0016  char _szAppName[] = "Generic"; // 程序名称
#0017  char _szTitle[]= "Generic Sample Application"; // 窗口标题
#0017  char _szTitle[]
#0018
#0019  //---------------------------------------------------------------
#0020  // WinMain - 程序进入点
#0021  //---------------------------------------------------------------
#0022  int CALLBACK WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
#0023                       LPSTR lpCmdLine, int nCmdShow)
#0024  {
#0025    MSG msg;
#0026
#0027    UNREFERENCED_PARAMETER(lpCmdLine); // 避免编译时的警告
#0028
#0029    if (!hPrevInstance)
#0030        if (!InitApplication(hInstance))
#0031            return (FALSE);
#0032
#0033    if (!InitInstance(hInstance, nCmdShow))
#0034        return (FALSE);
#0035
#0036    while (GetMessage(&msg, NULL, 0, 0)) {
#0037        TranslateMessage(&msg);
#0038        DispatchMessage(&msg);
#0039    }
#0040
#0041    return (msg.wParam); // 传回 PostQuitMessage 的参数
#0042  }
#0043  //---------------------------------------------------------------
#0044  // InitApplication - 注册窗口类
#0045  //---------------------------------------------------------------
#0046  BOOL InitApplication(HINSTANCE hInstance)
#0047  {
#0048   WNDCLASS  wc;
#0049
#0050   wc.style = CS_HREDRAW | CS_VREDRAW;
#0051   wc.lpfnWndProc= (WNDPROC)WndProc; // 窗口函数
#0052   wc.cbClsExtra= 0;
#0053   wc.cbWndExtra= 0;
#0054   wc.hInstance= hInstance;
#0055   wc.hIcon= LoadIcon(hInstance, "jjhouricon");
#0056   wc.hCursor= LoadCursor(NULL, IDC_ARROW);
#0057   wc.hbrBackground = GetStockObject(WHITE_BRUSH);  // 窗口后台颜色
#0058   wc.lpszMenuName  = "GenericMenu"; // .RC 所定义的窗体
#0059   wc.lpszClassName = _szAppName;
#0060
#0061   return (RegisterClass(&wc));
#0062  }
#0063  //---------------------------------------------------------------
#0064  // InitInstance - 产生窗口
#0065  //---------------------------------------------------------------
#0066  BOOL InitInstance(HINSTANCE hInstance, int nCmdShow)
#0067  {
#0068     _hInst = hInstance; // 储存为全局变量,方便使用。
#0069
#0070     _hWnd = CreateWindow(
#0071                            _szAppName,
#0072                            _szTitle,
#0073                            WS_OVERLAPPEDWINDOW,
#0074                            CW_USEDEFAULT,
#0075                            CW_USEDEFAULT,
#0076                            CW_USEDEFAULT,
#0077                            CW_USEDEFAULT,
#0078                            NULL,
#0079                            NULL,
#0080                            hInstance,
#0081                            NULL
#0082                            );
#0083
#0084   if (!_hWnd)
#0085       return (FALSE)
#0086
#0087   ShowWindow(_hWnd, nCmdShow); // 显示窗口
#0088   UpdateWindow(_hWnd); // 送出 WM_PAINT
#0089   return (TRUE);
#0090  }
#0091  //---------------------------------------------------------------
#0092  // WndProc - 窗口函数
#0093  //---------------------------------------------------------------
#0094  LRESULT CALLBACK WndProc(HWND hWnd,     UINT message,
#0095                           WPARAM wParam, LPARAM lParam)
#0096  {
#0097    int wmId, wmEvent;
#0098
#0099    switch (message) {
#0100    case WM_COMMAND:
#0101
#0102        wmId    = LOWORD(wParam);
#0103        wmEvent = HIWORD(wParam);
#0104
#0105        switch (wmId) {
#0106        case IDM_ABOUT:
#0107            DialogBox(_hInst,
#0108                      "AboutBox",// 对话框资源名称
#0109                      hWnd, // 父窗口
#0110                      (DLGPROC)About  // 对话框函数名称
#0111                      );
#0112            break;
#0113
#0114        case IDM_EXIT:    // 使用者想结束程序。处理方式与 WM_CLOSE 相同。
#0116            DestroyWindow(hWnd);
#0117            break;
#0118
#0119        default:
#0120            return (DefWindowProc(hWnd, message, wParam, lParam));
#0121        }
#0122        break;
#0123
#0124     case WM_DESTROY:  // 窗口已经被摧毁 (程序即将结束)。
#0125         PostQuitMessage(0);
#0126         break;
#0127
#0128     default:
#0129         return (DefWindowProc(hWnd, message, wParam, lParam));
#0130    }
#0131    return (0);
#0132  }
#0133  //---------------------------------------------------------------
#0134  // About - 对话框函数
#0135  //---------------------------------------------------------------
#0136  LRESULT CALLBACK About(HWND hDlg,     UINT message,
#0137                         WPARAM wParam, LPARAM lParam)
#0138  {
#0139     UNREFERENCED_PARAMETER(lParam); // 避免编译时的警告
#0140
#0141     switch (message) {
#0142         case WM_INITDIALOG:
#0143             return (TRUE); // TRUE 表示我已处理过这个消息
#0144
#0145         case WM_COMMAND:
#0146             if (LOWORD(wParam) == IDOK
#0147                 ||LOWORD(wParam) == IDCANCEL) {
#0148                 EndDialog(hDlg, TRUE);
#0149                 return (TRUE);  // TRUE 表示我已处理过这个消息
#0150             }
#0151             break;
#0152  }
#0153     return (FALSE); // FALSE 表示我没有处理这个消息
#0154  }

Generic.rc

#0001  //---------------------------------------------------------------
#0002  // 文件名 : generic.rc
#0003  //---------------------------------------------------------------
#0004  #include "windows.h"
#0005  #include "resource.h"
#0006
#0007  jjhouricon ICON DISCARDABLE "jjhour.ico"
#0008
#0009  GenericMenu MENU DISCARDABLE
#0010  BEGIN
#0011         POPUP "&File"
#0012         BEGIN
#0013             MENUITEM "&New",             IDM_NEW, GRAYED
#0014             MENUITEM "&Open...",         IDM_OPEN, GRAYED
#0015             MENUITEM "&Save",            IDM_SAVE, GRAYED
#0016             MENUITEM "Save &As...",      IDM_SAVEAS, GRAYED
#0017             MENUITEM SEPARATOR
#0018             MENUITEM "&Print...",        IDM_PRINT, GRAYED
#0019             MENUITEM "P&rint Setup...",  IDM_PRINTSETUP, GRAYED
#0020             MENUITEM SEPARATOR
#0021             MENUITEM "E&xit",            IDM_EXIT
#0022         END
#0023         POPUP "&Edit"
#0024         BEGIN
#0025             MENUITEM "&Undo\tCtrl+Z",    IDM_UNDO, GRAYED
#0026             MENUITEM SEPARATOR
#0027             MENUITEM "Cu&t\tCtrl+X",     IDM_CUT, GRAYED
#0028             MENUITEM "&Copy\tCtrl+C",    IDM_COPY, GRAYED
#0029             MENUITEM "&Paste\tCtrl+V",   IDM_PASTE, GRAYED
#0030             MENUITEM "Paste &Link",      IDM_LINK, GRAYED
#0031             MENUITEM SEPARATOR
#0032             MENUITEM "Lin&ks...",        IDM_LINKS, GRAYED
#0033         END
#0034         POPUP "&Help"
#0035         BEGIN
#0036             MENUITEM "&Contents",               IDM_HELPCONTENTS, GRAYED
#0037             MENUITEM "&Search for Help On...",  IDM_HELPSEARCH, GRAYED
#0038             MENUITEM "&How to Use Help",        IDM_HELPHELP, GRAYED
#0039             MENUITEM SEPARATOR
#0040             MENUITEM "&About Generic...",       IDM_ABOUT
#0041         END
#0042   END
#0043
#0044   AboutBox DIALOG DISCARDABLE  22, 17, 144, 75
#0045   STYLE DS_MODALFRAME | WS_CAPTION | WS_SYSMENU
#0046   CAPTION "About Generic"
#0047   BEGIN
#0048       CTEXT "Windows 95", -1,0, 5,144,8
#0049       CTEXT "Generic Application",-1,0,14,144,8
#0050       CTEXT "Version 1.0",-1,0,34,144,8
#0051       DEFPUSHBUTTON "OK", IDOK,53,59,32,14,WS_GROUP
#0052   END

程序进入点 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,例如 Windows 3.1的程序管理员或Windows 95的文件总管)侦测到使用者意欲执行一个 Windows 程序,于是调用加载器把该程序加载,然后调用C startup code,后者再调用WinMain,开始执进程序。WinMain 的四个参数由作业系统传递进来。

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

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

图1-3 RegisterClass与CreateWindow

从图1-3可以清楚看出一个窗口类牵扯的范围多么广泛,其中wc.lpfnWndProc所指定的函数就是窗口的行为中枢,也就是所谓的窗口函数。注意,CreateWindow只产生窗口,并不显示窗口,所以稍后我们必须再利用 ShowWindow 将之显示在屏幕上。又,我们希望先传送个WM_PAINT给窗口,以驱动窗口的绘图动作,所以调用UpdateWindow。消息传递的观念暂且不表,稍后再提。

请注意,在Generic程序中,RegisterClass 被我包装在InitApplication 函数之中,CreateWindow 则被我包装在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(...);
    ...
}

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

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

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

以上情况在 Windows NT 和 Windows 95 中略有变化。由于 Win32 程序的每一个执行个体(instance)有自己的地址空间,共享同一窗口类已不可能。但是由于 Win32 系统令hPrevInstance永远为0,所以我们仍然得以把RegisterClass和CreateWindow按旧习惯安排。既符合了新环境的要求,又兼顾到了旧原始代码的兼容。

InitApplication 和InitInstance 只不过是两个自定函数,为什么我要对此振振有词呢?原因是 MFC 把这两个函数包装成CWinApp 的两个虚成员函数。第6章「MFC 程序的生与死」对此有详细解释。

消息循环

初始化工作完成后,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 NT 具备强制性(preemptive)多任务能力,不再非靠 GetMessage释放CPU 控制权不可,但程序写法依然不变,因为应用程序仍然需要靠消息推动。它还是需要抓消息!

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

消息循环中的DispatchMessage把消息分配到哪里呢?它通过USER模块的协助,送到该窗口的窗口函数去了。窗口函数通常利用switch/case方式判断消息种类,以决定处置方式。由于它是被Windows系统所调用的(我们并没有在应用程序任何地方调用此函数),所以这是一种call back函数,意思是指「在你的程序中,被 Windows 系统调用」的函数。这些函数虽然由你设计,但是永远不会也不该被你调用,它们是为 Windows 系统准备的。

程序进行过程中,消息由输入装置,经由消息循环的抓取,源源传送给窗口并进而送到窗口函数去。窗口函数的体积可能很庞大,也可能很精简,依该窗口感兴趣的消息数量多寡而定。至于窗口函数的型式,相当一致,必然是:

LRESULT CALLBACK WndProc(HWND hWnd,
UINT message,
WPARAM wParam,
LPARAM lParam)

注意,不论什么消息,都必须被处理,所以switch/case 指令中的default: 处必须调用DefWindowProc,这是 Windows 内部预设的消息处理函数。

窗口函数的 wParam 和 lParam 的意义,因消息之不同而异。wParam 在 16 位环境中是16 位,在 32 位环境中是 32 位。因此,参数内容(格式)在不同作业环境中就有了变化。

我想很多人都会问这个问题:为什么 Windows Programming Modal 要把窗口函数设计为一个 call back 函数?为什么不让程序在抓到消息(GetMessage)之后直接调用它就好了?原因是,除了你需要调用它,有很多时候操作系统也要调用你的窗口函数(例如当某个消息产生或某个事件发生)。窗口函数设计为 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 对话框。我就以Generic 的About 对话框做为说明范例。为了做出一个对话框,程序员必须准备两样东西:

1. 对话框模板(dialog template)。这是在 RC 文件中定义的一个对话框外貌,以各种方式决定对话框的大小、字形、内部有哪些控制组件、各在什么位置...等等。

2. 对话框函数(dialog procedure)。其类型非常类似窗口函数,但是它通常只处理WM_INITDIALOG和WM_COMMAND两个消息。对话框中的各个控制组件也都是小小窗口,各有自己的窗口函数,它们以消息与其管理者(父窗口,也就是对话框)沟通。而所有的控制组件传来的消息都是 WM_COMMAND,再由其参数分辨哪一种控制组件以及哪一种通告(notification)。

Modal 对话框的启动与结束,靠的是DialogBox 和EndDialog 两个API 函数。请看图 1-4。

对话框处理过消息之后,应该传回TRUE;如果未处理消息,则应该传回FALSE。这是因为你的对话框函数之上层还有一个系统提供的预设对话框函数。如果你传回FALSE,该预设对话框函数就会接手处理。

模块定义文件(.DEF)

Windows 程序需要一个模块定义文件,将模块名称、程序节区和数据节区的内存特性、 模块堆积(heap)大小、堆栈(stack)大小、所有 callback 函数名称...等等登记下来。下面是个实例:

NAME              Generic
DESCRIPTION     ‘Generic Sample'
EXETYPE           WINDOWS

图1-4 对话框的诞生、运作、结束

STUB            ‘WINSTUB.EXE'
CODE              PRELOAD DISCARDABLE
DATA              PRELOAD MOVEABLE MULTIPLE
HEAPSIZE          4096
STACKSIZE         10240
EXPORTS
MainWndProc @1
AboutBox    @2

在Visual C++整合环境中开发程序,不再需要特别准备.DEF文件,因为模块定义文件中的设定都有默认值。模块定义文件中的 STUB 指令用来指定所谓的 stub 程序(埋在 Windows程序中的一个 DOS 程序,你所看到的 This Program Requires Microsoft Windows 或 This Program Can Not Run in DOS mode 就是此程序发出来的),Win16 允许程序员自设一个stub 程序,但 Win32 不允许,换句话说在 Win32 之中Stub 指令已经失效。

资源描述文件(.RC)

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

Windows 程序的生与死

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

图1-5 窗口的生命周期(详细说明请看图1-6)

1. 程序初始化过程中调用CreateWindow,为程序建立了一个窗口,做为程序的屏幕舞台。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后 ,调用DestroyWindow 把窗口清除 。DestroyWindow 本身又会送出WM_DESTROY。

7. 程序对WM_DESTROY的标准反应是调用PostQuitMessage。

8. PostQuitMessage 没什么其它动作,就只送出WM_QUIT 消息,准备让消息循环中的GetMessage 取得,如步骤2,结束消息循环。

图1-6 窗口的生命周期(请对照图1-5)

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

闲置时间的处理: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。

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 console 程序的撰写,以及如何在其中使用 Win32 API(其实直接调用就是了)。再以另一个极小的程序MFCCON示范MFC console程序(用到了MFC的CStudioFile和CString)。对于这么小的程序而言,实在不需动用到整合环境下的什么项目管理。至于复杂一点的程序,就请参考第4章最后一节「Console 程序的项目管理」。

Console 程序与 DOS 程序的差别

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

制造方式

在Windows环境下的DOS Box中,或是在Windows版本的各种C++编译器套件的整合环境(IDE)中(第4章「Console 程序项目管理」),利用 Windows 编译器、链接器做出来的程序,都是所谓Win32 程序。如果程序是以main为进入点,调用C runtime函数和「不牵扯 GUI」的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 程序一样,是所谓的PE(Portable Executable)格式,意思是它可以被拿到任何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
拿它来观察DOS程序,则得到这样的结果:
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 程序的编译链接

你可以写一个makefile,编译时指定常数 /D_CONSOLE,链接时指定subsystem 为console,如下:

#0001  # filename : pedump.mak
#0002  # make file for pedump.exe
#0003  # usage : nmake pedump.msc (Visual C++ 5.0)
#0004
#0005  all : pedump.exe
#0006
#0007  pedump.exe: pedump.obj exedump.obj objdump.obj common.obj
#0008      link /subsystem:console /incremental:yes \
#0009           /machine:i386 /out:pedump.exe \
#0010           pedump.obj common.obj exedump.obj objdump.obj \
#0011           kernel32.lib user32.lib
#0012
#0013  pedump.obj : pedump.c
#0014      cl /W3 /GX /Zi /YX /Od /DWIN32 /D_CONSOLE /FR /c pedump.c
#0015
#0016  common.obj : common.c
#0017      cl /W3 /GX /Zi /YX /Od /DWIN32 /D_CONSOLE /FR /c common.c
#0018
#0019  exedump.obj : exedump.c
#0020      cl /W3 /GX /Zi /YX /Od /DWIN32 /D_CONSOLE /FR /c exedump.c
#0021
#0022  objdump.obj : objdump.c
#0023      cl /W3 /GX /Zi /YX /Od /DWIN32 /D_CONSOLE /FR /c objdump.c

如果是很简单的情况,例如本节的JBACKUP只有一个C 原始代码,那么这样也行(在命令列之下):

cl jbackup.c <ENTER>            ←将获得 jbackup.exe

注意,环境变量要先设定好(请参考本章稍早的「如何产生 Generic.exe」一节)。

第3章的Frame_ 程序则是这样完成的:

cl my.cpp mfc.cpp <ENTER>       ←将获得 my.exe

至于到底该链接哪些链接库,全让CL.EXE去伤脑筋就好了。

JBACKUP :Win32 Console 程序设计

撰写console 程序,有几个重点请注意:

1. 进入点为main。

2. 可以使用printf、scanf、cin、cout 等标准输出入装置。

3. 可以调用和GUI 无关的Win32 API。

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

C:\SomeoneDir>JBACKUP SrcDir [DstDir]

例如 JBACKUP g: k:

将磁盘目录SrcDir中的新文件复制到磁盘目录DstDir,并将DstDir的赘余文件删除。

如果没有指定DstDir,预设为k:(那是我的可写入光驱-- MO-- 的代码啦)

并将k: 的磁盘目录设定与SrcDir相同。

例如JBACKUP g: ,而目前g: 是g:\u002\doc,那么相当于把g:\u002\doc备份到k:\u002\doc中,并删除k:\u002\doc的赘余文件。

JBACK检查SrcDir中所有的文件和DstDir中所有的文件,把比较新的文件从SrcDir中复制到DstDir去,并把DstDir中多出来的文件删除,使ScrDir和DstDir的文件保持完全相同。之所以不做 xcopy 完全复制动作,为的是节省复制时间(做为备份装置,通常是软盘或磁带或可擦写光盘 MO,读写速度并不快)。

JBACKUP 没有能力处理SrcDir 底下的子目录文件。如果要处理子目录,漂亮的作法是使用递归(recursive),但是有点伤脑筋,这一部分留给你了。我的打字速度还算快,多切换几次磁盘目录不是问题,呵呵呵。

JBACKUP使用以下数个Win32 APIs:

  • GetCurrentDirectory
  • FindFirstFile
  • FindNextFile
  • CompareFileTime
  • CopyFile
  • DeleteFile

在处理完毕命令列参数中的SrcDir和DstDir后,JBACKUP先把SrcDir的所有文件(不含子目录文件)搜寻一遍,储存在一个数组srcFiles[ ]中,每个数组元素是一个我自定的 SRCFILE 数据结构:

typedef struct _SRCFILE
    {
    WIN32_FIND_DATA fd;
    BOOL bIsNew;
} SRCFILE;
SRCFILE  srcFiles[FILEMAX];
WIN32_FIND_DATA fd;
// prepare srcFiles[]...
bRet = TRUE;
iSrcFiles = 0;
hFile = FindFirstFile(SrcDir, &fd);
while (hFile != INVALID_HANDLE_VALUE && bRet)
{
    if (fd.dwFileAttributes == FILE_ATTRIBUTE_ARCHIVE) {
        srcFiles[iSrcFiles].fd = fd;
        srcFiles[iSrcFiles].bIsNew = FALSE;
        iSrcFiles++;
    }
    bRet = FindNextFile(hFile, &fd);
}

再把DstDir中的所有文件(不含子目录文件)搜寻一遍,储存在一个destFiles[ ] 数组中,每个数组元素是一个我自定的 DESTFILE 数据结构:

typedef struct _DESTFILE
{
    WIN32_FIND_DATA fd;
    BOOL bMatch;
} DESTFILE;
DESTFILE destFiles[FILEMAX];
WIN32_FIND_DATA fd;
bRet = TRUE;
iDestFiles = 0;
hFile = FindFirstFile(DstDir, &fd);
while (hFile != INVALID_HANDLE_VALUE && bRet)
{
    if (fd.dwFileAttributes == FILE_ATTRIBUTE_ARCHIVE) {
        destFiles[iDestFiles].fd = fd;
        destFiles[iDestFiles].bMatch = FALSE;
        iDestFiles++;
    }
    bRet = FindNextFile(hFile, &fd);
}

然后比对srcFiles[ ]和destFiles[ ]之中的所有文件名称以及建文件日期,找出 scrFiles[ ]中的哪些文件比desFiles[ ]中的文件更新,然后将其bIsNew字段设为 TRUE。同时也对存在于desFiles[ ]中而不存在于srcFiles[ ]中的文件,令其bMatch字段为 FALSE。

最后,检查srcFiles[ ]中的所有文件,将bIsNew字段为TRUE者,复制到DstDir 去。并检查 destFiles[ ] 中的所有文件,将bMatch字段为FALSE者统统删除。

MFCCON :MFC Console 程序设计

当你的进度还在第1章的Win32基本程序观念,我却开始讲如何设计一个MFC console程序,是否有点时地不宜?

是有一点!所以我挑一个最单纯而无与别人攀缠纠葛的MFC类,写一个40 行的小程序。目标纯粹是为了做一个导入,并与 Win32 console 程序做一比较。

我所挑选的两个单纯的MFC类是CStdioFile和CString:

在MFC之中,CFile 用来处理正常的文件I/O 动作。CStdioFile 派生自CFile,一个CStdioFile 对象代表以C runtime函数fopen所开启的一个stream文件。Stream文件有缓冲区,可以文字模式(预设情况)或二进位模式开启。

CString 对象代表一个字符串,是一个完全独立的类。

我的例子用来计算小于100的所有费伯纳契数列(Fabonacci sequence)。费伯纳契数列的计算方式是:

1. 头两个数为 1。

2. 接下来的每一个数是前两个数的和。

以下便是MFCCON.CPP 内容

#0005  // Build  : cl /MT mfccon.cpp  (/MT means Multithreading)
#0006
#0007  #include <afx.h>
#0008  #include <stdio.h>
#0010  int main()
#0011  {
#0012   int lo, hi;
#0013   CString str;
#0014   CStdioFile fFibo;
#0016       fFibo.OpenFIBO.DAT", CFile::modeWrite |
#0017                   CFile::modeCreate | CFile::typeText);
#0019       str.Formats\n", "Fibonacci sequencee, less than 100 :");
#0020       printf("%s", (LPCTSTR) str);
#0021       fFibo.WriteStringstr);
#0023       lo = hi = 1;
#0025       str.Format("%d\n", lo);
#0026       printf("%s", (LPCTSTR) str);
#0027       fFibo.WriteString(str);
#0029       while (hi < 100)
#0030       {
#0031            str.Format("%d\n", hi);
#0032            printf("%s", (LPCTSTR) str);
#0033            fFibo.WriteString(str);
#0034            hi = lo + hi;
#0035            lo = hi - lo;
#0036        }
#0038        fFibo.Close();
#0039        return 0;
#0040    }

以下是执行结果(在console 窗口和FIBO.DAT 文件中,结果都一样):

Fibonacci sequencee, less than 100 : 1\n 1\n 2\n 3\n 5\n 8\n 13\n 21\n 34\n 55\n 89

这么简单的例子中,我们看到MFC Console 程序的几个重点:

1. 程序进入点仍为main

2. 需含入所使用之类的头文件(本例为AFX.H)

3. 可直接使用与GUI无关的MFC类(本例为CStdioFile和CString)

4. 编辑时需指定/MT,表示使用多线程版本的C runtime 函数库。

第4点需要多做说明。在MFC console 程序中一定要指定多线程版的C runtime 函数库,所以必须使用/MT 选项。如果不做这项设定,会出现这样的链接错误:

Microsoft (R) 32-Bit Incremental Linker Version 5.00.7022
Copyright (C) Microsoft Corp 1992-1997\. All rights reserved.
/out:mfccon.exe
mfccon.obj
nafxcw.lib(thrdcore.obj):error LNK2001:unresolved external symbol __endthreadex
nafxcw.lib(thrdcore.obj):error LNK2001:unresolved external symbol __beginthreadex
mfccon.exe : fatal error LNK1120: 2 unresolved externals

表示它找不到beginthreadex和endthreadex。怪了,我们的程序有调用它们吗?没有,但是MFC 有!这两个函数将在稍后与线程有关的小节中讨论。

什么是 C Runtime 函数库的多线程版本

当C runtime 函数库于1970s 年代产生出来时,PC 的内存容量还很小,多任务是个新奇观念,更别提什么多线程了。因此以当时产品为基础所演化的C runtime 函数库在多线程(multithreaded)的表现上有严重问题,无法被多线程程序使用。

利用各种同步机制(synchronous mechanism)如critical section、mutex、semaphore、event,可以重新开发一套支持多线程的runtime 函数库。问题是,加上这样的能力,可能导至程序代码大小和执行效率都遭受不良波及——即使你只启动了一个线程。

Visual C++ 的折衷方案是提供两种版本的C runtime 函数库。一种版本给单线程程序使用,一种版本给多线程程序使用。多线程版本的重大改变是,第一,变量如errno者现在变成每个线程各拥有一个。第二,多线程版中的数据结构以同步机制加以保护。

Visual C++ 一共有六个C runtime 函数库产品供你选择:

      Single-Threaded(static)      libc.lib      898,826

      Multithreaded(static)        libcmt.lib    951,142

      Multithreaded DLL              msvcrt.lib    5,510,000

      Debug Single-Threaded(static)libcd.lib     2,374,542

      Debug Multithreaded(static)  libcmtd.lib   2,949,190

      Debug Multithreaded DLL        msvcrtd.lib   803,418

Visual C++ 编译器提供下列选项,让我们决定使用哪一个C runtime函数库:

      /ML    Single-Threaded(static)

      /MT    Multithreaded(static)

      /MD    Multithreaded DLL(dynamic import library)

      /MLd   Debug Single-Threaded(static)

      /MTd   Debug Multithreaded(static)

      /MDd   Debug Multithreaded DLL(dynamic import library)

进程与线程(Process and Thread)

OS/2、Windows NT 以及 Windows 95 都支持多线程,这带给PC程序员一股令人兴奋的气氛。然而它带来的不全然是利多,如果不谨慎小心地处理线程的同步问题,程序的错误以及除错所花的时间可能使你发誓再也不碰「线程」这种东西。

我们习惯以进程(process)表示一个执行中的程序,并且以为它是CPU排程单位。事实上线程才是排程单位。

核心对象

首先让我解释什么叫作「核心对象」(kernel object)。「GDI对象」是大家比较熟悉的东西,我们利用GDI函数所产生的一支画笔(Pen)或一支画刷(Brush)都是所谓的「GDI对象」。但什么又是「核心对象」呢 ?

你可以说核心对象是系统的一种资源(噢,这说法对 GDI 对象也适用),系统对象一旦产生,任何应用程序都可以开启并使用该对象。系统给予核心对象一个计数值(usage count)做为管理之用。核心对象包括下列数种:

核心对象                 产生方法

event                    CreateEvent

mutex                    CreateMutex

semaphore                CreateSemaphore

file                     CreateFile

file-mapping             CreateFileMapping

process                  CreateProcess

thread                   CreateThread

前三者用于线程的同步化:file-mapping 对象用于内存映射文件(memory mapping file),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 文件格式)的 .idata section中。

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

6. 系统调用C runtime 函数库的Startup code。

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

8. App程序开始运作。

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

10. 回到Startup code。

11. 回到系统,系统调用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. 环境变量中的path所设定的各目录

让我们看看实例:

CreateProcess("E:\\CWIN95\\NOTEPAD.EXE", "README.TXT",...);

系统将执行 E:\CWIN95\NOTEPAD.EXE,命令列参数是 "README.TXT"。如果我们这样子调用:

CreateProcess(NULL, "NOTEPAD README.TXT",...);

系统将依照搜寻次序,将第一个被找到的 NOTEPAD.EXE 执行起来,并转送命令列参数 "README.TXT" 给它。

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

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

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

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

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

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

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 程序一开始就有了一个线程。我们可以调用CreateThread 产生额外的线程,系统会帮我们完成下列事情:

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 取系统时间,并将结果显示在对话框_hWndDlg的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);  // 延迟一秒。
    }
}

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

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

以 _beginthreadex 取代 CreateThread

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

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

把 _beginthreadex视为CreateThread的一个看起来比较有趣的版本,就对了:

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

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

下面是一个最简单的使用范例:

#0001  #include <windows.h>
#0002  #include <process.h>
#0003  unsigned __stdcall myfunc(void* p);
#0005  void main()
#0006  {
#0007      unsigned long thd;
#0008      unsigned tid;
#0010      thd = _beginthreadex(NULL,
#0011                           0,
#0012                           myfunc,
#0013                           0,
#0014                           0,
#0015                           &tid );
#0016      if (thd != NULL)
#0017      {
#0018          CloseHandle(thd);
#0019       }
#0020  }
#0022  unsigned __stdcall myfuncs(void* p)
#0023  {
#0024  // do your job...
#0025  }

针对Win32 API ExitThread,也有一个对应的C runtime函数:_endthreadex。它只需要一个参数,就是由 _beginthreadex 第6个参数传回来的ID 值。

线程优先权(Priority)

优先权是排程的重要依据。优先权高的线程,永远先获得CPU的青睐。当然啦,作业系统会视情况调整各个线程的优先权。例如前台线程的优先权应该调高一些,后台线程的优先权应该调低一些。

线程的优先权范围从0(最低)到31(最高)。当你产生线程,并不是直接以数值指定其优先权,而是采用两个步骤。第一个步骤是指定「优先权等级(Priority Class)」给进程,第二步骤是指定「相对优先权」给该进程所拥有的线程。图 1-7 是优先权等级的描述,其中的代码在 CreateProcess 的 dwCreationFlags 参数中指定。如果你不指定,系统预设给的是 NORMAL_PRIORITY_CLASS——除非父进程是IDLE_PRIORITY_CLASS(那么子进程也会是IDLE_PRIORITY_CLASS)。Win32线程的优先权等级划分:

等级          代码                        优先权值
Idle          IDLE_PRIORITY_CLASS         4
Normal        NORMAL_PRIORITY_CLASS       9(前台)或 7(后台)
high          HIGH_PRIORITY_CLASS         13
realtime      REALTIME_PRIORITY_CLASS     24
  • "idle" 等级只有在CPU 时间将被浪费掉时(也就是前一节所说的闲置时间)才执行。此等级最适合于系统监视软件,或屏幕保护软件。

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

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

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

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

SetThreadPriority 的参数                    微调幅度
THREAD_PRIORITY_LOWEST                        -2
THREAD_PRIORITY_BELOW_NORMAL                  -1
THREAD_PRIORITY_NORMAL                       不变
THREAD_PRIORITY_ABOVE_NORMAL                  +
THREAD_PRIORITY_HIGHEST                       +2

除以上五种微调,另外还可以指定两种微调常数:

SetThreadPriority 的参数    面对任何等级面对    "realtime" 等级
的调整结果:        的调整结果:
THREAD_PRIORITY_IDLE               1                   16
THREAD_PRIORITY_TIME_CRITICAL      15                  31

这些情况可以以图 1-8 作为总结。

优先权等级   idle  lowest   below   normal  above   highest   time
Normal          normal           critical
idle        1     2        3       4       5        6        15
normal(后台)   1     5        6       7       8        9        15
normal(前台)   1     7        8       9       10       11       15
high        1     11       12      13      14       15       15
realtime      16    22       23      24      25       26       31

图1-8 Win32线程优先权

多线程程序设计实例

我设计了一个MltiThrd 程序,一开始产生五个线程,优先权分别微调-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。我在ThreadProc中以不断的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 是执行画面。注意,先选择延迟方式("for loop delay" 或 "sleep delay"),再按下【Resume Thread】。如果你选择”for loop delay”(图 1-9a),你会看到线程0(优先权最高)几乎一路冲到底,然后才是线程 1(优先权次之),然后是线程2(优先权再次之)...。但如果你选择的”sleep delay”(图 1-9b),所有线程不分优先权高低,同时行动。关于线程的排程问题,我将在第14章做更多的讨论。

图1-9a MltiThrd.exe 的执行画面(”for loop delay”)

图1-9b MltiThrd.exe 的执行画面(”sleep delay”)

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