admin 管理员组

文章数量: 887019

图像处理之-位图

  • MD DoCumEnT: 3/16/2016 5:59:48 PM by Jimbowhy

自从发现MarkdownPad以后,就沉迷于写作,从未有过这样的浸淫,完全没有了生物钟的同期,基本上只要醒着,手眼就离不了屏幕,离不了键盘,一直敲着几近光滑的按键,那种感觉就是满足,如果要用个词来形容,我觉得 F**KING WRITING! F**KING MY LIFE! 是恰当的。生命有终结的一天,而文字却不会。 - by Jimbowhy 3/20/2016 4:32:04 PM

虽然本文的标题只说是位图图像处理,其实内容远比标题丰富,原本计划是只涉及位图的文件结构分析和代码实现。但一头扎下去,就搞大了,从GDI到CONSOLE,从VGA到API绘图,我觉得比较有趣的点基本都染指了。因为图像处理本来就是很深又广的领域,程序开发过程中不免也要和图形打交道,而且BMP对于当前使用的Window操作系统是如此的重要,以致为它写一本书的内容都是可以收集到的。

背景知识

位图,BMP文件是 Windows、OS/2 操作系统的常用图形文件格式,这里虽然用“常用”来形容BMP,其实把它称为 Windows 基石也不为过,Windows 整个 GDI 系统都是围绕它进行的。Win32程序的窗体都是通过位图绘制出来的,可以说,没有位图就没有Windows。它是设备无关的 DIB device-independent bitmaps,当然系统中常用的还有设备相关的位图 DDB device-dependnt bitmaps。支持多种色深,色深用 BPP Bits-Per-Pixel 表示,有 1bpp 2bpp 4bpp 8bpp 16bpp 24bpp 32bpp 多种,可以保存使用色板的黑白双色图 monochrome,4色,16色,256色图,对于其它更高的色深图片则直接保存色值到像素所在的位置。对于低色深的位图则在像素数据中保存色板颜色的索引号。为此,对于一个2色位置而言,每个像素只点一个比特,一个字节就可以保存8个像素了。至于DDB和DIB有什么具体差别,可以从后面的函数操作过程中理解,在这里可以将DDB理解为只有二维数据的位图。另外,从BMP文件的信息头也可以形像理解DIB的特点:

  • 有独立的颜色信息;
  • 有位图创建时的设备尺寸信息;
  • 有位图创建时的设备色板信息;
  • 有RGB三分量的色目数组数据映射到像素上;
  • 有数据的压缩方式相关信息;

因此DIB位图文件格式可以保存二维 two-dimensional 数字图像,文件中包含了图像宽度,高度,分辨率,色深信息,还有可选的压缩方法,alpha通道,颜色配置信息,在 Windows Metafile (WMF) specification 中对位置文件格式有详细说明,在C语言头文件 wingdi.h 中定义了和位图相关的常量、结构体。典型的BMP文件至少包含三个部分,BMP文件头、DIB信息头和像素数据,到于低色深位图还有调色板 Palette,所谓调色板就是一个数组,每个元素使用四个字节定义一个RGB色值。调色板紧接DIB信息头存储。现在常用的BMP格式是 BMP Version 3,这个格式版本从 Windows 3.x 就开始使用了。以下是我重写的结构体定义:

#pragma pack(push,2)
typedef struct BitmapHeader {   // BITMAPFILEHEADER
    unsigned short  bfType;     // 0x4d42; it occupy 4bytes if memory aligned
    unsigned int    bfSize;     // DWORD bitmap file size
    unsigned short  bfReserved1;
    unsigned short  bfReserved2;
    unsigned int    bfOffBits;  // offset to the bitmap bits data
} BitmapHeader;
#pragma pack(pop)

typedef struct BitmapInfo{      // BITMAPINFOHEADER
    unsigned int    biSize;     // DWORD the size of this structure.
            long    biWidth;    // LONG the width of the bitmap, in pixels. 
            long    biHeight; 
    unsigned short  biPlanes;   // WORD always is 1!
    unsigned short  biBitCount; // the number of bits-per-pixel. 1 for monochrome bmiColors
    unsigned int    biCompression; // BI_RGB(uncompressed),BI_RLE8,BI_RLE4 ...
    unsigned int    biSizeImage;   // the size, in bytes, of the image. may be zero for BI_RGB.
            long    biXPelsPerMeter; 
            long    biYPelsPerMeter; 
    unsigned int    biClrUsed; 
    unsigned int    biClrImportant; 
} BitmapInfo, *PBitmapInfo;

一般Win32平台的位图总是 0x42 0x4D 两个字节开头的,即 BM 两个字符,当然 bfType 有可能是以下的任意一种:

BM – Windows 3.1x, 95, NT, ... etc.
BA – OS/2 struct bitmap array
CI – OS/2 struct color icon
CP – OS/2 const color pointer
IC – OS/2 struct icon
PT – OS/2 pointer

BMP文件大小就保存在 bfSize 中,其实这个有点多余,通过文件读取就可以得到BMP文件的大小了。然后就是 bfOffBits,它指出了BMP文件像素数据到文件开始字节的偏移量,结合BMP文件头和DIB信息头就可以计算到调色板的数据起止点。注意定义BMP文件头 BitmapHeader 时,因为它第一个成员是2个字节的,如果编译有对齐,那么文件头结构体就会变成16个字节,这就不对了,因此需要设置编译器对齐属性。

biCompression 是压缩信息,一般情况用得最多的是无压缩格式 BI_RGB,可选值如下。但是只有自底向上 Bottom-up 的位图才可以压缩, Top-down DIB 不可压缩。那么 Top-Down vs. Bottom-Up DIBs,什么是自底向上呢?所谓自底向上是指图片的像素在内存存储的顺序是先保存图片的最底下一行,再上一行这样进行的。对 Bottom-up 的位图,内存的第一个字节是保存图片的左下角那个像素的。在GDI中所有的DIB都是 Bottom-up 方式处理的。

BI_RGB      An uncompressed format. 
BI_RLE8     A run-length encoded (RLE) format for bitmaps with 8 bpp. 
            Consisting of a count byte followed by a byte containing a color index. 
BI_RLE4     An 2-byte RLE format for bitmaps with 4 bpp. 
            Consisting of a count byte followed by two word-length color indexes. 
BI_BITFIELDS Specifies that the color table consists of three DWORD color masks for 16/32-bpp.
BI_JPEG     Windows 98, Windows 2000: Indicates that the image is a JPEG image. 
BI_PNG      Windows 98, Windows 2000: Indicates that the image is a PNG image.

在BMP的压缩方法基本都是游程码方式,这是一种算法简单的压缩方式。如果说1亿这个数,它在1的后面跟了8个0,那游程码可以表示为1180,这种通过一个值来表示被压缩内容长度的方法就是游程编程 RLE Run-Length Encode。前面讲到位图可以有16-bit/24-bit/32-bit几种,如果中间这种,那么每个像素用3个字节表示,刚好每个颜色分量占一个字节。但是对于另外两种,情况就不同了。16-bit的色深,每个颜色分量可以占5~6比特,这就涉及怎么安排RGB各颜色分量的位宽,Windows 95 只支持 RGB555 和 RGB565,还有32bpp模式的 RGB888。对于24bpp,还可以通过 DIB信息头的 biClrUsed 来指定索引色数量,这样在位图使用的色彩数目较少的情况下来优化系统调色板,不过这种方法使用极少。当 biClrUsed 的数值为 0 时表示索引色为指定色深的最大索引数量。

为了使用不同的RGB分量位宽,需要指定压缩信息为 BI_BITFIELDS,这里,调色板保存的就不是颜色定义,而是分量分割掩码。有三个掩码对应RGB三个分量,每个掩码为32-bit,举例来说 RGB555、RGB888两个模式下每个分量的位宽是分别是5-bit、8-bit,掩码定义如下:

The RGB555 format masks would look like: 
0x00007C00  red   (0000 0000 0000 0000 0111 1100 0000 0000)
0x000003E0  green (0000 0000 0000 0000 0000 0011 1110 0000)
0x0000001F  blue  (0000 0000 0000 0000 0000 0000 0001 1111)

The RGB888 format masks would look like: 
0x00FF0000  red   (0000 0000 1111 1111 0000 0000 0000 0000)
0x0000FF00  green (0000 0000 0000 0000 1111 1111 0000 0000)
0x000000FF  blue  (0000 0000 0000 0000 0000 0000 1111 1111)

在WIKI上有演示掩码的定义格式:


Diag. 2 – The BITFIELDS mechanism for a 32-bit pixel depicted in RGBAX sample length notation

 

DIB数据处理与应用

有了上面的数据结构,就可以通过 CreateDIBitmap() 函数来构造位图了,注意这个函数创建的是DDB位图,虽然名称为CreateDIBitmap,这确实会让人误解,但它是通过DIB数据来创建DDB位图的函数。给它设置参数 CBM_INIT 时,它就会使用色板和像素数据来初始化新建的DDB位图,因此这里就会有DIB数据到DDB数据对拷。这个过程等价于使用 CreateCompatibleBitmap() 函数来创建DDB位图,然后再使用 SetDIBits() 来向DDB拷贝DIB位图数据,同样这个函数名也有点容易误解,它应该理解为设置DIB数据到位图中。这里提到的两个函数都是用来创建DDB的,所以第一个参数传入的DC其实就是创建位图所依赖的设备,特别是调色板。来看看MSDN对 CreateDIBitmap() 这个函数的最后一个参数 fuUsage 的解析:

Specifies whether the bmiColors member of the BITMAPINFO structure was initialized and, if so, whether bmiColors contains explicit red, green, blue (RGB) values or palette indexes. The fuUsage parameter must be one of the following values.

在不理解DIB和DDB的区别前,理解这段话是有难度,因为会不知所云。回到文章的开头,DIB和DDB的最大的区别就是色值信息的保存,很有意思的。DDB可以理解为色值和像素数据是一体的,像DC中所使用位图就是。而DIB则不是了,它可以将颜色保存在调色板,也可以在位图像素中保存,如16bbp、24bbp、32bbp等等色深的位图文件就是。因此这个参数的作用就是通过指定 DIB_PAL_COLORS 来使用输入参数DC上的调色板,指定 DIB_RGB_COLORS 来使用像素数据的色值。系统中只有唯一一个结构体是描述DDB的:

typedef struct tagBITMAP {
  LONG   bmType; 
  LONG   bmWidth; 
  LONG   bmHeight; 
  LONG   bmWidthBytes; 
  WORD   bmPlanes; 
  WORD   bmBitsPixel; 
  LPVOID bmBits; 
} BITMAP, *PBITMAP;

CreateDIBSection()才是真正创建DIB位图的函数,其实bmp文件就是DIB位图,所以通过文件流读入的位图文件二进位数据就可以用在这个函数中。在传入参数 BITMAPINFO 结构体就是数据入口,这个结构体不包含了 bmp 文件头和像素数据,只含有DIB信息头和调色板两部分数据。所以只要将读取的位图文件的开头偏移一个14个字节,即一个bmp文件头的长度后的数据传入,并设置好偏移参数 dwOffset 即可。在输出参数 ppvBits 就会指向包含DIB像素数据的内存地址。注意,输入位图的像素数据是通过 hSection 参数传入的。通过这个函数的学习,其实可以将位图文件的像素数据理解为 Section 更合适,这样可以和MSDN文档相统一,而且像素这概念通过用来表述显示器上看得到的点,是具有颜色特征的。而BMP文件中的像素数据其实并不一定就是一个色值,还可以是色板的索引号码。在使用这个函数时,需要传入一个DC,当指定参数 DIB_PAL_COLORS 时,函数就会使用DC上的调色板来初始化像素。指定 DIB_RGB_COLORS 时则使用 bmiColors 的色板信息。

到这里可以理解DIB和DDB的另一个重要的区别,DDB可以在DC关联的设备上显示,而DIB则需要经过调色板的映射转换,这就是DDB实用时效率更高,而DIB在各种设备之间转换时兼容性更好。在转换的过程中注涉及了位图中的逻辑色板和设备上的物理色板,注意这里指的是硬件上设备,每种硬件可显示的色彩都是有范围的,这个色彩显示能力就是物理色板的抽象概念。下面这幅图可以帮助理解DDB是怎样提高显示效率的:

+----------------------------------------+----------------------------------------+
|            Client Side                 |                Server Side             |
|                               +--------+--------+                               |
|                               |      Event      |                               |
|                               +--------+--------+                               |
|                           +------------+-----------+                            |
|                           |     Memory Windows     |                            |
|                           +------------+-----------+                            |
|    GDI via hBitmap        +------------+-----------+        +------------+      |
|    ---------------------->|                        | BitBlt |            |      |
|                           |        DIB Section     +--------+    DDB     |      |
|    ---------------------->|                        |        |            |      |
|    Directly via pBits     +------------+-----------+        +------------+      |
|                                        |                           |            |
+----------------------------------------+---------------------------+------------+
|                                  Kernel Side                       |            |
+--------------------------------------------------------------------+------------+
|                                                                    V            |
|                             Hardware Video Memory                               |
|                                                                                 |
+---------------------------------------------------------------------------------+

正如前面一直在讲DDB是依赖设备的位图,对于依赖的设备可以通过DC来获取相关信息。通过 GetDC(NULL) 可以获取计算机屏幕DC,通常这是个彩色DC,将其传入 CreateCompatibleDC() 就可以用来创建兼容的彩色DC。将其传入 CreateCompatibleBitmap() 则可以创建一个彩色位图。以下使用 GetDeviceCaps() 函数打印了一组DC的属性,关于DC后面还要深入:

HWND hwnd = GetConsoleWindow();
HDC sc = GetDC( NULL );
HDC cc = GetDC( hwnd );
HDC dc = CreateCompatibleDC(NULL);

Device Context Information:      Device Context Information:      Device Context Information:     
  TECHNOLOGY:DT_RASDISPLAY         TECHNOLOGY:DT_RASDISPLAY         TECHNOLOGY:DT_RASDISPLAY  
    HORZSIZE:482                     HORZSIZE:482                     HORZSIZE:482            
    VERTSIZE:271                     VERTSIZE:271                     VERTSIZE:271            
     HORZRES:1366                     HORZRES:1366                     HORZRES:1366           
     VERTRES:768                      VERTRES:768                      VERTRES:768            
  LOGPIXELSX:96                    LOGPIXELSX:96                    LOGPIXELSX:96             
  LOGPIXELSY:96                    LOGPIXELSY:96                    LOGPIXELSY:96             
   BITSPIXEL:32                     BITSPIXEL:32                     BITSPIXEL:32             
  NUMBRUSHES:-1                    NUMBRUSHES:-1                    NUMBRUSHES:-1             
     NUMPENS:-1                       NUMPENS:-1                       NUMPENS:-1             
   NUMCOLORS:-1                     NUMCOLORS:-1                     NUMCOLORS:-1             
 SIZEPALETTE:0                    SIZEPALETTE:0                    SIZEPALETTE:0 
 NUMRESERVED:20                   NUMRESERVED:20                   NUMRESERVED:20             
    COLORRES:24                      COLORRES:24                      COLORRES:24

  RASTERCAPS: 0x7e99 RC_BITBLT RC_BITMAP64 RC_GDI20_OUTPUT RC_DI_BITMAP RC_DIBTODEV 
              RC_BIGFONT RC_STRETCHBLT RC_FLOODFILL RC_STRETCHDIB RC_OP_DX_OUTPUT

通过检索DC的光栅能力信息 RASTERCAPS,曲线能力 CURVECAPS,直线能力 LINECAPS 等等,上面最后一行输出表示显示设备直接支持 BitBlt()、SetDIBits()、GetDIBits()。通过 RASTERCAPS 还可以查询是不是有 RC_PALETTE 调色板功能,而上面显示没有使用色板,SIZEPALETTE 和 NUMCOLORS 信息也表示没有使用色板,-1 是指最大的色值范围。这和现在使用的机器的真彩显示器是对应的,不像以前VGA显示器的色目只有几十、百个的数量,可以使用色板来映射像素的色值。

这里着重点还是在设备的调色板,当设备使用了色板,不管是显示器还是打印机还是其它任意设备,要提高DIB的显示效率,可以先将DIB转换成DDB来使用。转换过程中涉及两种方式,DIB_RGB_COLORS 和 DIB_PAL_COLORS,最简单的情况是DIB没有使用色板,前一种方式。24bpp位图就是这种情况,它没有使用色板,像素数据就是RGB色值。转换DIB时,如果设备是真彩显示器,那么就直接进行像素到像素的数据拷贝;如果设备使用了色板,那么就对DIB像素进行最接近色适配并转换为色板的索引值。最复杂的情况是DIB和设备都使用了色板,提高效率的点就在色板的匹配上。使用系统调色板 GetSystemPaletteEntries() 来创建DIB可以优化像素的传送效率,因为色板是匹配的,使用 SetDIBitsToDevice() 这样的函数就可以省略配色的过程。

在使用 SelectObject() 函数为DC选择位图对象时,CreateCompatibleBitmap() 创建的位图则会比 CreateBitmap() 创建的位图更有效率,因为前者的位图是兼容的不存在额外的色板匹配工作。色板的创建和使用相关的结构体、函数有 LOGPALETTE、CreatePalette()、RealizePalette()、SelectPalette()。详细内容可以参考 MSDN 关于 Palette Manager 部分。

GDI绘图API构架

古旧DOS平台下,绘图是通过VGA实现的,后来又有SuperVGA等等。VGA 就是 Video Graphics Array,也称为视频图形适配器 Video Graphics Adapter,它把像素存储到一个数组中即显示缓冲区。符合VGA规范的显示驱动器会定时获取缓冲区的数据,并在显示上呈现出对应图像。VGA 的应用使得DOS平台下的绘图变得方便起来,数组化的像素也方便运算来处理。VGA支持多种显示模式,从 2 色到 256 色,分辨率从 320x200 到 640x480。为了在Win32平台下使用DOS的VGA进行绘图,需要使用 DOSBOX 和 Borland C/C++ 3.1、DJGPP 2.0等等工具。设置 10h 中断相关内容如下:

INT 10h, Service 0h
Set Screen Mode

Input:   AH = 0h
         AL = Mode Number (see below)
Output:  The video mode is changed.

Mode Number  Text Res.  Graphics Res.   Description  Adapters  Max. Pages
---------------------------------------------------------------------------
         0h      40x25         ------   B&W Text     CGA+               8
         1h      80x25         ------   B&W Text     MDPA+              8
         2h      40x25         ------   Color Text   CGA+          4 or 8
         3h      80x25         ------   Color Text   (MDPA?)/CGA+  4 or 8
         4h      40x25        320x200   4 colors     CGA+               1
         5h      40x25        320x200   2 colors     CGA+               1
         6h      80x25        640x200   2 colors     CGA+               1
         7h      80x25         ------   B&W          MDPA (CGA+?)       1
         8h to Ch -- PCjr or other adapters; no longer used
         Dh      40x25        320x200   16 colors    EGA+               8
         Eh      80x25        640x200   16 colors    EGA+               4
         Fh      80x25        640x350   2 colors     EGA+               2
        10h      80x25        640x350   16 colors    EGA+               2
        11h      80x25        640x480   2 colors     VGA+               1
        12h      80x25        640x480   16 colors    VGA+               1
        13h      40x25        320x200   256 colors   VGA+               1

在 Mode 13h 即 256色模式下,每像素使用一个字节表示,总计刚好是 64K,即一个段的内存数量,它的地址约定分配在A000:0000 - A000:FFFF。而 B000:0000 则字符模式下的显示缓冲区的映射地址。通过映射显示器驱动卡的内存到计算机内存,程序可以直接通过操作计算机的内存来实现对显卡的编程,这就大大方便了图形的编程。在DOS平台下,没有大量的API要去掌握,你可以任意发挥想像,随意修改VGA接口提供的显示缓冲区来实现图形绘画,这是没有API的一个大好处。

Windows 出现后,图形编程有了统一的构架,因为需要掌握它的图形设备接口 GDI Graphics Device Interface,而大量的不开放源代码的API也成为开发人员的一种负担,可能因为文档还不太足够以完全掌握好每一个API函数。在GDI框架下,显示驱动接口映射的显示缓冲区被分割成一块块小区域分发给Windows操作系统下运行的各式各样的小窗口。而这些小块的缓冲区域是通过设备上下方对象 DC device context 来管理的,它主要负责Windows系统与绘图程序之间的信息交换,处理所有程序的图形输出。通过 GetDC() 来获取任意程序窗口对象所分配的缓冲区信息,然后对这个缓冲区进行绘画,就可以改变程序窗口的内容。当然,DC可以不跟显示器直接有关系,可以在内存中建立一个DC,然后在它上面作画,这就是离屏绘图 Off-screen DC。DC可以分为四种类型,首先是直接在显示器显示绘画的 Display DC,然后是可以在内存上作画的 Memory DC,然后是可以在打印机上打印的 Printer DC,最后是包含DC信息的 Information DC。

Memory DC是使用较多的一种,在游戏开发中,需要在内存中对图像进行操作,然后才是将图像发送到显示器上显示。通过 CreateCompatibleDC() 可以创建一个 Memory DC,在使用它进行组图之前,需要通过 SelectObject() 来设置尺寸修理工的位图,位图通过 CreateBitmap() CreateBitmapIndirect() CreateCompatibleBitmap() 就可以创建。绘图完成后就通过 BitBlt() 函数将内存DC的图像发送到显示DC上显示出来。

2001年XP系统推出时,GDI的扩展版 GDI+ 一并发布,后来 GDI+ 又被包装进.NET框架的托管类库中,成为.NET中窗体绘图的主要工具。GDI+ 主要提供了以下三类功能:

  • 矢量图形:GDI+提供了存储图形基元自身信息的类或结构体、存储图形基元绘制方式信息的类以及实际进行绘制的类;
  • 图像处理:GDI+为我们提供了Bitmap、Image等类。它们可用于显示、操作和保存BMP、JPG、GIF等图像。
  • 文字排版:GDI+支持使用各种字体、字号和样式来显示文本。

GDI接口是基于函数的,而GDI+是基于OOP Object-Orient Programming,使用起来比GDI要方便。因为GDI+实际上是GDI的封装和扩展,执行效率一般要低于GDI。

可能是 GDI+ 实在是太新鲜了,我使用 GCC 4.7.1 MinGW 移植版无法编译,即使是实例化 Graphics 类,也添加了 gdiplus.h 头文件,还是出错 Graphics 类无定义声明。然通过查看GDI+头文件,发现这货使用了命名空间,所以只需要几条指令就可以解决问题,看来是会 F**king Code 的娃:

#define ULONG_PTR ULONG 
#include <gdiplus.h>
using namespace Gdiplus;
#pragma comment(lib, "C:/gdiplus/lib/gdiplus.lib")

如果需要使用GDI+,我目前使用 MSDN 1999OCT 是找不到资料的了,回来GDI,在Windows消息系统中有一和DC相关的消息是 WM_DEVMODECHANGE,相关的GDI函数列表如下:

CancelDC                 DeviceCapabilities      GetDC               GetStockObject    
ChangeDisplaySettings    DrawEscape              GetDCBrushColor     ReleaseDC         
ChangeDisplaySettingsEx  EnumDisplayDevices      GetDCEx             ResetDC           
CreateCompatibleDC       EnumDisplaySettings     GetDCOrgEx          RestoreDC         
CreateDC                 EnumDisplaySettingsEx   GetDCPenColor       SaveDC            
CreateIC                 EnumObjects             GetDeviceCaps       SelectObject      
DeleteDC                 EnumObjectsProc         GetObject           SetDCBrushColor   
DeleteObject             GetCurrentObject        GetObjectType       SetDCPenColor

GDI的常用图形对象有 HBITMAP、Pen、Font、Brush等,这些对象基本都在 MFC 中包装成一个个的类对象,而Windows为其定义的句柄却是通用的:

MFC Class   handle      Graphic   Associated attributes 
CBitmap     HBITMAP     Bitmap    Size, dimensions, color-format, compression and so on. 
CBrush      HBRUSH      Brush     Style, color, pattern, and origin. 
CPalette    HPALETTE    Palette   Colors and size (or number of colors). 
CFont       HFONT       Font      Typeface name, width, height, weight, character set... 
                        Path      Shape. 
CPen        HPEN        Pen       Style, width, and color. 
CRgn        HRGN        Region    Location and dimensions.

这些都 GDI 框架的是核心类,在绘制任何图形之前,一定要先创建或得到一个GDI核心类的对象才能完成绘图工作。GDI 的图形可以理解成系统一个画图环境,它具体包括要在哪里画,画什么东西,用什么画,(颜色,画笔,画刷),怎么画,画圆还是画线等等。在MSDN上的安装盘上有大量的GDI演示例子,位置在 SAMPLES -> VC98 -> SDK -> GRAPHICS。可以使用 MSDN Library - October 1999 版,这个版本比中文版的 MSDN Library for Visual Studio 6.0 内容要丰富。关于GDI的内容主要有两部分,一是 Platform SDK 目录下的 Windows GDI 包含的全面API文档,第二部分则是来自MSDN社区的技术文章 Technical Articles,其中 Multimedia 目录下有个GDI的分类。说到MSDN,后面补充说明不同MSDN版的共享安装。

当程序创建一个DC时,系统会设置默认的对象,除了 bitmap 和 path 以外。经常和这些对象打交道的函数有 GetCurrentObject(),通过它可以获取DC上的各种图形对象的句柄,而 GetObject() 函数则功能更加强大,它可以根据不同的输入来获取诸如 BITMAP, DIBSECTION, EXTLOGPEN, LOGBRUSH, LOGFONT, LOGPEN 等等结构体对象。需要绘画不同效果的图像时,就需要为DC指定图形对象,这时就要使用 SelectObject() 函数。配套的函数还有 SetDCBrushColor() GetDCBrushColor() SetDCPenColor() GetDCPenColor()。

GDI中不同的图形对象可以有不同的工作模式,例如可以通过 SetBkMode() 函数为源位图设置背景色的透明混合方式,这样GDI函数在混合图像时就可以得到透明的效果。尽管MSDN文档中只说明了 OPAQUE 和 TRANSPARENT 两种参数选项,但是 NEWTRANSPARENT 是另一个可以用来处理透明效果的选项。通过设置透明模式,再通过 SetBkColor() 为源位图设置一个背景色,像 StretchDIBits() 这样的函数就会将背景色当作透明色来处理。模式设置是GDI中的重要组成,通过不同的模式可以实现不同的绘图效果,相关的API如下可以查阅MSDN:

Graphics mode   Get Function       Set Function     
Background      GetBkMode          SetBkMode        
Drawing         GetROP2            SetROP2          
Mapping         GetMapMode         SetMapMode       
Polygon-fill    GetPolyFillMode    SetPolyFillMode  
Stretching      GetStretchBltMode  SetStretchBltMode

句柄那一套

前面看了这么多函数,它们基本都返回了一个指向由系统管理着的对象,比如说我现在最关心的 HBITMAP 句柄,实际上我更希望它是指向位图文件数据在内存的位置,而不是一个所谓“句柄”的东西,这种感觉不好。

通过 windef.h 头文件可以找到句柄的类型链:

DECLARE_HANDLE(HBITMAP);

#ifdef STRICT
    typedef void *HANDLE;
    #define DECLARE_HANDLE(name) struct name##__ { int unused; }; typedef struct name##__ *name
#else
    typedef PVOID HANDLE;
    #define DECLARE_HANDLE(name) typedef HANDLE name
#endif

其中 ## 是一个内置宏定义,意思是在预处理时的字符串的连接。经过编译器的预处理后,HBITMAP 就会成为这两个样子:

#ifdef STRICT
    typedef void *HANDLE;
    struct HBITMAP__ { int unused; };
    typedef struct HBITMAP__ *HBITMAP;
#else
    typedef PVOID HANDLE;
    typedef HANDLE HBITMAP;
#endif

生成的代码有两种形式,在开严格模式 STRICT 时,定义了一个只有一个整形数据的结构体,而句柄就是指向这个结构体的指针,另一种情况则定义了一个无类型的指针。

句柄这个东西到底如何理解呢,我觉得作为Windows系统开发团队之外的人,应该从几个方面来看,从语言标准上,句柄就是指针和结构体的组合,这一点是基本的理解。从系统的结构层次来看,句柄是系统管理虚拟内存的一种方法。在《深入x86的内存寻址》提到,现有的32位x86构架CPU可以寻址4GB内存,而实际上当前还有大量机器根本没有配备这么多的内存。为了让程序有这个寻址能力,Windows采用的是虚拟内存管理技术,通过CPU的内存分页机制,将磁盘空间映射为虚拟内存,因此这个4GB的寻址空间称为 Virtual Address Space,代表机器并不是真的一定要有4GB内存。通过虚拟内存,将一些不太可能使用的内存数据移动到磁盘上就可以节省出物理内存,这样就可以为在运行的程序提供多多可用的内存,因此磁盘上映射到内存的文件也称为页交换文件。

CPU的内存管理单元 Memory Management Unit (MMU),通过内存分页机制为操作系统实现虚拟内存提供了硬件上的支持,这个过程可以用以下的流程图说明。CPU内部暂存着就近使用的页表格映射,当接收到需要转换的虚拟地址时,转换备用缓冲 Translation Lookaside Buffer (TLB) 就从 MMU 暂存的页表格映射中查找,如果找到对应的物理地址就直接返回给程序,表示命中缓存 Cache Hit。如果没有找到,则表示缓存错失 Cache Miss,这时就到内部的页表格中查找,如果找到物理地址的映射,则回写到 TLB。如果在页表格也没找到对应的物理地址映射,那么就引发异常,这时由操作系统接管异常,并且实现从硬盘到内存的映射。

Windows NT/2000 系统,将低端的2GB地址空间分配给程序进行使用,而高端的2GB寻址空间则保留为系统所用。0x00000000 - 0x7FFFFFFF 这些地址便是进行地址空间,0x80000000 - 0xFFFFFFFF 这些便是系统空间。而企业服务器或高级服务器则是保留高端的1GB为系统所用,留下更多的3GB为程序使用。而久经谩骂的前辈们,Windows 95/98,个人还是挺喜欢 Windows 95 的个头才不到100MB的安装盘,它们由于系统的设计上是DOS兼容的实现,所以虚拟内存空间的安排也是名目众多:

0K - ~64K (0xFFFF)    Not writable. Reserved for Microsoft® MS-DOS®.
~64K   (0x10000) - 4 MB(0x3FFFFF)   Reserved for MS-DOS compatibility. 
4MB   (0x400000) - 2GB (0x7FFFFFFF) Available for code and user data.
2GB (0x80000000) - 3GB (0xBFFFFFFF) Shared area, readable and writable by all processes, DLL.
3GB (0xC0000000) - 4GB (0xFFFFFFFF) System memory, readable or writable by any process. 
However, writing to this region may corrupt the system, with potentially catastrophic consequences.

这些虚拟的内存按分页来管理,典型的x86机上分页为4KB,每个分页又按使用状态分成三种,空闲态,是可以被程序申请使用的内存页;保留态,即系统有规划的但没有相关的物理内存、或磁盘空间关联的内存页,可以使用 VirtualAlloc() 和 VirtualFree() 来申请和释放;提交态,内存页被提交后就会和实际的物理内存或交换文件关联,这就是程序在使用的内存,可以通过前面提到的两个方法来提交内存申请,也可以使用 GlobalAlloc() 和 LocalAlloc() 来申请。

Windows 系统中的内存可以用以下这两张逻辑图来表达:

Virtual Address  |         CPU Paging System         |  Storage & Memory
 Linear Address  |                                   |  
+------------+   |   +-----------+     +----------+  |   +------------+
| 0xFFFFFFFF |   |   | Directory +---->+  Storage |  |   | .......... |
| .......... |   |   |   Entry   |     |   Entry  |  |   |   Page #N  |
| 0x7FFFFFFF +------>+-----------+     +-----+----+  |   |  PAGE NULL |
| .......... |   |        +------------+     |       |   |   Page #2  |
| 0x00000000 |   |        | PageTablle |-----+       |   |   Page #1  |
+------------+   |        |    Entry   |---------------->+------------+
                 |        +------------+             |

通过分页机制,程序使用到的逻辑内存地址可能会被映射到任意的物理内存地址或交换文件上,前面讲到操作系统保留的高端内存实际上也可能是在物理内存的低端上。可以猜想,作为系统的实现者,需要在一块固定的内存,物理地址和逻辑地址持久
不变的内存上来实现操作系统的内存管理模块。而其中一项功能就是句柄的管理,句柄在系统实现的层面上看就是操作系统的核心资源的指针,每个句柄对应管理模块中数据表中的一个元素,而这个元素就包含了句柄所指资源的实际地址和对象类型等等必要的信息。在系统进行页交换时,相应地修改对象表中的相应元素以反映实际的地址指向。因为Windows是一个多任务系统,具有多线程的安全性,所以在修改句柄对应的元素时就会禁止程序的访问,从而实现多线程的数据一致性。句柄的使用就相当在API和系统内核之间引入了一个隔火层,同时由于句柄的存在,操作系统可以动态地更新句柄关联的对象。在 Windows 2000 的安全系统为每一个系统对象管理了一张访问控制列表,Access-control list (ACL),就算程序通过可能的手段获取到一个句柄,也需要通过ACL的检查才能取得系统对象的访问权。

而从API的使用者的角度来理解,句柄就是门把,用来实现系统对象访问的关键。每个进行可以使用的句柄数量是限量的,不能超出 65536 个,即两个字节可以表示的数量。出于节省内存的目的,对于同一个对象,比如一个DLL文件,系统可以产生多个关联的句柄,并进行计数,当句柄数为0时表示系统可以进行内存回收了,因为已经没有程序需要使用这个对象了。《windows核心编程》讲关闭句柄就表示创建者放弃对该内核对象的操作,系统就可以对句柄所占的资源进行回收,作为API的使用者尽早关闭句柄是一项基本操作要求,如果放任句柄开放,只会持续霸占系统资源导致性能问题,引发句柄泄漏 Handle Leak。如果根本不需要对系统对象进行访问,像下面这条语句的做法是十分正确的,因为关闭句柄只是关闭句柄而不是关闭系统对象:

CloseHandel(CreateThread(...));

和句柄关联的对象有三类,用户对象、GDI对象和内核对象,在MSDN中关于句柄的内容分类在基础服务中,进程间通信 Interprocess communications (IPC) 讲到句柄与系统资源对象。

User Object              GDI Object                Kernel Object
---------------------------------------------------------------------------------------------
Accelerator table        Bitmap                    Access token               Job            
Caret                    Brush                     Change notification        Mailslot       
Cursor                   DC                        Communications device      Module         
DDE conversation         Enhanced                  Console input              Mutex          
Desktop                  Enhanced-metafile DC      Console screen buffer      Pipe           
Hook                     Font                      Event                      Process        
Icon                     Memory DC                 Event                      Semaphore      
Menu                     Metafile                  File                       Socket         
Window                   Metafile DC               File                       Thread         
Window position          Palette                   Find                       Timer          
Window station           Pen and extended pen      Heap                       Update resource               
                         Region

除内核对象个,每个用户对象或GDI对象只能对应一个句柄,但程序可以通过句柄的继承来复用句柄。

关于句柄的部分就到这里吧,再下去,只能是 F**king Kernel!

游程码压缩

游程码全称 Run-length Encode,主要应用在 4bpp 和 8bpp 位图上,又色图和24位真彩图总是为RI_RGB不压缩格式。

Byte 1     Byte 2     Byte 3        Byte 4    Meaning
00         00         End of row              
00         01         End of image            
00         02         dx            dy        Move to(x+dx, y+dy) 
00         n = 03 through FF                  Use next n pixels 
n = 01 through FF pixel                       Repeat pixel n times

If the first byte is nonzero (the case shown in the last row of the table), then that’s a run-length repetition factor. The following pixel value is repeated that many times. For example, the byte pair

0x05 0x27

decodes to the pixel values:

0x27 0x27 0x27 0x27 0x27

The DIB will, of course, have much data that does not repeat from pixel to pixel. That’s the case handled by the second-to-last row of the table. It indicates a number of pixels that follow that should be used literally. For example, consider the sequence

0x00 0x06 0x45 0x32 0x77 0x34 0x59 0x90

It decodes to the pixel values

0x45 0x32 0x77 0x34 0x59 0x90

These sequences are always aligned on 2-byte boundaries. If the second byte is odd, then there’s an extra byte in the sequence that is unused. For example, the sequence

0x00 0x05 0x45 0x32 0x77 0x34 0x59 0x00

decodes to the pixel values

0x45 0x32 0x77 0x34 0x59

图像透明处理

对于位图,透明意味着什么呢?在现实世界,透明就是物体可以被光线穿透。而计算机显示的透明则理解为不被处理或渲染的像素,这样位于透明区域的其它内容得以呈现在显示器上,形成透明的效果。当然,对于位图,Windows并没直接支持透明的API,要想位图变得透明,还需要一点技巧。在GDI框架中,控制像素的混合操作方式的就是光栅操作 Raster Operation,所谓的光栅操作其实就是将不同的图形对象的像素值进行逻辑运算操作,可以分为两元和三元两类。对于两元光栅操作就是在 PEN 与位图间进行的操作方式,即前面提到的GDI模式设置中的 Drawing 部分的内容,通过 SetROP2() 函数可以给指定的DC设置一种光栅模式。三元光栅操作则是两个位图间增加一个笔刷的操作方式,这种模式会在 BitBlt(), PatBlt(), StretchBlt() 这些函数中使用到。这些函数名看起来还真的不太雅观,Blt 是什么意思呢,其实全称就是 Block Tranpfer,也就是块传输,对啊,这些函数可以是比 SetPixel() 效率要高上千百倍的啊。

要理解光栅操作,先要了解白色和黑色,在RGB色系中,白色是指色值所有比特位都是1,而黑色则相反,所有比特位都是0。两色进行逻辑与运算,那么所有比特位结果就是0,也就黑色了,如果进行或运算,那么结果就是白色。如果参与光栅运算的颜色不是黑色或白色,那么就要看它的比特位有那些是0那些又是1了。以下是几种最基本的光栅操作,记下来会有很大帮助的:

ROP Name   Boolean      OP  Operation Use in transparency simulations 
SRCCOPY    src          S   Copies the source directly to the destination. 
SRCAND     src AND dest DSa Blacks out sections of the destination. 
SRCINVERT  src XOR dest DSx Inverts the source onto the destination.
SRCPAINT   src OR dest  SDo Paints the nonblack sections of the source onto the dest.
NOTSRCCOPY src NOT      Sn  Inverts the source before paint it the the destination.

先来解释一下光栅操作码,大写字母 D、S、P 代目标DC、来源DC 和选选择笔刷,也叫 Pattern,a、n、o、x 代表四种逻辑运算 AND、NOT、OR 和 XOR,通过光栅操作码就可以了解具体某种光栅做了什么。SRCCOPY 就是源位图说了算,源位图是什么色绘图后目标就是什么色,黑色也照抄过去。SRCAND则是双方协定,只有两边的对应比特位同时为1,才会保留,否则就清零,这种混合结果有种融合的效果。SRCPAINT 则比较容易得到浅色调的结果,SRCINVERT可以得到互补色效果,NOTSRCCOPY 则是负片效果。关于光栅操作的MSDN参考内容在 Platform SDK => Graphics => Windows GDI => Painting & Drawing。虽然系统定义好了几十个光栅操作模式常数,但实现上GDI系统可以使用的光栅模式多达几百个,由于数量太多本文就不引用了。通过一个32-bit数值就可以设置光栅模式,例如,SRCCOPY 就可以通过传入 0x00CC0020 来设置,功能是一样的。

想要实现透明的位图,一个方法就使用一个黑白双色的遮罩图,因为黑色已经比特位全0,将这个遮罩位图与需要透明处理的位图进行 SRCAND 光栅操作就可以将需要透明的,即遮罩位图上黑色部分就被过滤掉了,从而实现透明效果。当然,利用遮罩位图的白色区域来过滤透明区也是可以的,因为白色的比特位都是1,通过光栅操作 SRCINVERT 就即可以将透明区的内容变成黑色,然后再使用 SRCAND 就来组画出透明效果。然而,还有比使用遮罩图层更好的办法,虽然说遮罩也不是一件特别特别麻烦的事。另一个方法是使用带有 color-keying 的函数,如 TransparentBlt,所谓 color-keying 即通过指定一种透明色,在混合时过滤掉它而形成透明效果的一种技术。由于我使用 MinGW 找不到这个函数的定义,所以只好手动通过 LoadLibrary() 来加载了:

typedef bool (WINAPI *TBLT)(HDC, int, int, int, int, HDC, int, int, int, int, UINT);
bool TransparentBlt(HDC d, int x, int y, int dx, int dy, HDC s, int xs, int ys, int dxs, int dys, UINT ck)
{
    HMODULE h = LoadLibrary("msimg32.dll"); //GetModuleHandle
    if( !h ){
        if( GetLastError()==ERROR_MOD_NOT_FOUND ) cout << "ERROR_MOD_NOT_FOUND";
        return false;
    }
    TBLT f = (TBLT)GetProcAddress(h,"TransparentBlt");
    if( !f ){
        if( GetLastError()==ERROR_PROC_NOT_FOUND ) cout << "ERROR_PROC_NOT_FOUND";
        return false;
    }
    return f( d,x,y,dx,dy,s,xs,ys,dxs,dys,ck);
}

如果不是在API的基础上,直接通过点阵绘图,获取位图的透明效果是十分简单的一件事,直接约定透明色来跳过匹配到的像素就完了。这也算是不使用API的一种天大的优点,简单!先看一下效果图 Console drawing with Super Mario:

在GDI框架中,DC可以说是沟通所有东西的十字路,像现在想要处理位图也一样,MSDN关于位图的内容很多,但是却都没有能指出位图其实可以理解为在内存里的显示器。通过 LoadImage() 可以很方便地加载位图文件,当然自己写位图文件的加载方法也可以,得到图片文件的句柄扣,首先就要考虑将位图文件转换到DC上来操作。前面介绍了 SelectObject() 函数,它可以将图形对象附加到DC上,但要求位图图形对象必须是 CreateDIBitmap() CreateBitmap() CreateDIBSection() 等函数返回的图形对象。这里就使用 CreateDIBitmap() 来获位图文件的图形对象,但是这个函数需要一个 BITMAPINFO 结构体,而MSDN上也没在这个函数的主页上标明那些函数可以取得 BITMAPINFO 结构体,这可算是MSDN的一大毛病!还好,事先有备而来,知道 GetDIBits() 这个函数可以。还有个 GetObject() 这个函数,它可以返回位图文件的信息头,通过它们就可以从位图句柄上获取图形对象,现由 SelectObject() 附加到DC进行绘画。注意,对于位图,SelectObject() 只能对 Memory DC 操作。

其实从数据底层来讲,这个过程不像MSDN上讲得这么复杂,LoadImage()这个函数虽然是返回 HANDLE,但注意函数名是加了Image的,如果它不能解析出位图文件的结构,就枉作为一个独立函数存在了。事实上通过强制转型,即 (HBITMAP)LoadImage(…) 这样就可以得到,可以附加到DC上的位图对象了。SelectObject()要做的其实就是将分配给位图像素的内容和 Memory DC 关联起来就完成工作了。

在绘制透明背景之前测试了控制台对透明背景的支持情况,发现它不支持,需要通过其它API方法来实现透明,注意 C1_TRANSPARENT 这个常量在 mmsystem.h 定义的,对于VC6由于太旧的原因是没有定义的。无论如何要实现透明效果一定是离不了光栅操作了,不然就像上面一样自己写代码处理:

if( GetDeviceCaps( hdc,CAPS1 ) & C1_TRANSPARENT ){
    cout << "C1_TRANSPARENT support! ";
    int om = SetBkMode ( hcc,NEWTRANSPARENT );
    int oc = SetBkColor( hcc,RGB(0xe0,0x75,0x50) );
    BitBlt( hdc, 0, 0, bmp.bmWidth, bmp.bmHeight, hcc, 0, 0, SRCCOPY );
    SetBkMode ( hcc,om );
    SetBkColor( hcc,oc );
}

这里就提供一种通过 Memory DC 来实现位图透明的示例,通过光栅操作来实现 Color-Keying 透明。需要使用 CreateCompatibleDC() 来创建内存DC,然后通过 CreateCompatibleBitmap() 来创建兼容位图。需要注意的是,每一个设备都会有一个物理调色板,电脑显示器也具有特定的显示色彩范围,那么显示器这个显色能力范围在GDI中就抽像为物理调色板。而创建DC时也一样,它需要和特定的绘图对象关联,如果是显示器,那么这个DC使用的调色板就是显示器的物理调色板,如果通过 BitBlt() 等GDI函数往DC写入色值数据,就需要先匹配色值是不是在可以接收的范围,如果超出显示范围,那么GDI系统就要进行预处理,以物理调色板上最接近的一个色来替换。而对于 CreateCompatibleDC() 创建的DC,如果参数没有引用其它DC,那么新建的DC就默认选择了一个1像素的黑白双色位图,这也就是说,在这样的DC下绘图只能得到黑白图片。同理,如果没有给 CreateCompatibleBitmap() 引用一个彩色DC,那么它创建出兼容设备位图时,也只兼容黑白两色,而且默认像素为黑色大小也是1个像素。CreateBitmap()函数可以创建指定色深的位图。无论如何,彩色或黑白都是有用的,下面来演示怎么用好它们。

例子使用超级玛丽游戏的截图,目标是蓝色天空,通过 color-keying 将天空透明处理。首先通过 LoadImage() 加载位图文件,然后通过 GetObject() 获取位图对象信息,主要是位图的尺寸信息,然后创建两个DC,一个用来装入原图片,另一个用来生成 mask 图层。mask 图层一定要又色位图,这一点很重要。这是因为GDI系统默认背景色为白色,单色位图向彩色位图转换,单色位图白色部分会转换为彩色位图的背景色,单色位图黑色部分会转换为彩色位图的前景色。彩色位图向单色位图转换,彩色位图的背景色转换到单色位图的白色,其他色值则转换为黑色。

创建好DC和对应的位图对像后,在原图DC上设置背景色为天空蓝,然后将它绘制到 mark DC上。这样就可以得到除天空蓝区域外,位图其它全部都是黑色的遮罩图层。因为需要透明的是天空蓝,而遮罩层黑色的不是天空蓝对应的区域,所以需要将 mask 进行反转后与原图进行 SRCAND 光栅操作,这样就可以得到天空蓝为黑色的源图,最后只需要再经过一次 SRCPAINT 就可以显示透明效果了。前面提到的几种基本光栅操作就有求负片的 NOTSRCOPY 光栅操作,但是使用这种方法还不是最简单的,通过MSDN文档可以找到一个无名称的光栅,它的代码为 0xa00220326,操作定义为“DSna”,可以把这个光栅称作COLKEY。和 SRCERASE 有十分相似,它则定义为 SDna,先对目标DC进行反转,再和源DC相与操作。因此天空白的 mask 与原图进行 DSna 混合后,就得到天空区域为黑色,其余区域保持不变的图像。将目标区域变成黑色就意味着 color-keying 工作完成了。最后就是通过 SRCPAINT 将其绘制到显示器DC上,这就得到透明效果输出了,在做最后这个操作时,可以考虑使用遮罩与目标DC进行一次 SRCAND 操作,去掉非透明区的像素以免在 SRCPAINT 操作时留下印记。对于多个透明色的情况,就需要进行多次光栅运算处理。这里先贴出这部分的完整代码块,配合图片演示就可以起到很清晰的说明作用,最终效果可以参考前面贴出的图片:

HWND hwnd = GetConsoleWin();
HDC  hdc  = GetDC( hwnd );

HBITMAP mario = LoadBitmap( GetModuleHandle(NULL),(char*)(16) );
if( !mario ) printf(" error LoadBitmap %d\n", GetLastError() );
HANDLE hm = LoadImage(NULL, "mario256.bmp", IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE);
//mario = (HBITMAP)hm;

HDC hdc = GetDC( GetConsoleWin() ); //GetConsoleWindow()
HDC mask = CreateCompatibleDC(hdc);
HDC draw = CreateCompatibleDC(hdc);
//int blue  = RGB(0x60,0x80,0xc0);
int blue  = RGB(0x50,0x75,0xe0);
int white = RGB(0xff,0xff,0xff);
int black = RGB(0x00,0x00,0x00);
int sw = GetSystemMetrics(SM_CXSCREEN);//GetDeviceCaps( mask,ASPECTX );
int sh = GetSystemMetrics(SM_CYSCREEN);//GetDeviceCaps( mask,ASPECTY );
BITMAP bmp;
GetObject( mario, sizeof(BITMAP), &bmp );
int w = bmp.bmWidth, h = bmp.bmHeight;
HBITMAP mono = CreateCompatibleBitmap( mask, w, h );
SelectObject( mask,mono );
SelectObject( draw,mario );
printf(" Bitmap size (%d,%d) Screen size(%d,%d)\n", w, h, sw, sh);
SetBkColor( draw, blue );
BitBlt( mask, 0, 0, w, h, draw, 0, 0, SRCCOPY );
BitBlt( hdc,  w, 0, w, h, mask, 0, 0, SRCAND );
BitBlt( draw, 0, 0, w, h, mask, 0, 0, 0x00220326); // Color-keying
BitBlt( hdc,  w, 0, w, h, draw, 0, 0, SRCPAINT);
DeleteDC(hdc);
DeleteDC(mask);
DeleteDC(draw);
DeleteObject(mario);
DeleteObject(mono);

控制台下绘图

Win32平台下,无论是控制台程序还是GUI程序,都可以进行点阵绘图,也可以通过DC来进行区块绘图。基本流程就是 GetDC() -> SetPixel(),对于控制台程序需要使用 GetConsoleWindow() 来获取程序窗口句柄,这个函数要求宏定义 _WIN32_WINNT >= 0x0500。需要读取像素色值,用 GetPixel(),也可以使用 GetBitmapBits() SetBitmapBits() 在图像的缓冲区之间对拷,BitBlt() 则是DIB的对拷。在第一小节中讲解的 CreateDIBSection() 函数可以创建一个 DIB,这样就可以直接操作图像数据缓冲区,相似的还有 CreateDIBitmap(),它们都可以返回一个 HBITMAP 句柄。前者创建的是设备无关位图DIB句柄,对应一个DIBSECTION结构,后者创建与设备有关的位图 DDB 句柄,更像是打开位图文件。SetDIBits() 和 GetDIBits() 两者可以在 DDB 和 DIB 间对拷。

例如,以下代码就可以在控制台上绘制图片,注意使用了 RGB 这个宏,因为位图的色彩分量是 GBR 排序的,所以通过RGB来转换顺序:

HWND hwnd = GetConsoleWindow();
HDC  hdc  = GetDC( hwnd );
HBITMAP hbmp = (HBITMAP)LoadImage(NULL, "mario16.bmp", IMAGE_BITMAP, 256, 240, LR_LOADFROMFILE);
BITMAP bmp;
GetObject( hbmp, sizeof(bmp), &bmp );
unsigned char *bits = new unsigned char[bmp.bmHeight * bmp.bmWidthBytes];

GetBitmapBits( hbmp, bmp.bmHeight * bmp.bmWidthBytes, bits);
int x = 0, y = 0, color = RGB(bits[l+2],bits[l+1], bits[l]);
SetPixel( hdc, x, y, color );

bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); 
bmi.bmiHeader.biWidth = bmp.bmWidth;
bmi.bmiHeader.biHeight = -bmp.bmHeight;
bmi.bmiHeader.biPlanes = bmp.bmPlanes;
bmi.bmiHeader.biBitCount = bmp.bmBitsPixel;
bmi.bmiHeader.biCompression = BI_RGB;
GetDIBits( hcDC, hbmp, 0, bmp.bmHeight, bits, &bmi, DIB_RGB_COLORS );

GetBitmapBits() 这个函数主要是为了兼容 16-bit Windows程序的,Win32 程序则优先采用 GetDIBits() 来获取设备无关的DIB信息。但是 GetDIBits() 这个方法太蛋痛了,也不知道微软的程序员怎么脑子,在设置了参数 lpvBits 的情况下,就非要手动设置 BITMAPINFO 前6个成员即 biSize 到 biCompression 设置好才能正确加载位图。对于 bottom-up DIB 自底向上的 DIB,高度值要设置为正数,反之 top-down DIB 自顶向下的 DIB 则要设置高度为负数。需要注意的是,经过 LoadImage() 加载的位图文件会自动转换成32-bit的模式,所以设置给 GetDIBits() 设置 biBitCount 参数时需要注意。LoadImage() 还可以设置 LR_CREATEDIBSECTION 参数,这样载入时就不会自行转换色彩深度,也不会映射显示设备的色彩。

另外GetObject()获取到的 BITMAP 结构时,长宽信息是对的但是像素数据 bmBits 却不对,太操蛋了MS这API写得,只会让人在做 fruitless work。在MSDN上资料说,只有在使用 CreateDIBSection() 创建的 HGDIOBJ 才会设置 bmBits 指向像素数据缓存区。而且,另外两个参数还要使用 DIBSECTION 结构才能有效返回像素数据。DIB 颜色使用两种模式之一,DIB_PAL_COLORS,DIB_RGB_COLORS,前者对应索引色。要用 CreateDIBSection() 来创建 DIB,可以选择在内存映射文件对象上来创建,dwOffset 表示从这个内存映射文件的偏移处开始申请内存,这样可以将DIB共享给其它进程,hSection 参数设置为 NULL 表示在常规全局内存区申请。为了搞清 CreateDIBSection() 这个函数,可以参考MSDN ATL Sample - ATLFIRE 的实现,MSDN有关于ATL的文档,位于 Visual Studio Documentation 的参考部分。CFireWnd 这个例子是演示ATL控件编程的,在它的源代码 CFireWnd 类的成员方法 CreateBitmap() 中就有使用 CreateDIBSection() 这个函数的相关代码,下图是这个例子的VB测试项目,效果是不是很棒,CPU占用率还极低!

StretchDIBits()这个函数可以将像素区块拷贝到DC的DIB上,但不是所有驱动设备都完全支持这个函数的功能的。因此GDI在需要的时候,会通过整合 SetDIBits() GetDIBits() BitBlt() 等等函数来模拟它,这种情况下,多半会有性能问题。StretchDIBits() 的参数可以设置 DIB_RGB_COLORS 或 DIB_PAL_COLORS方式,后者指示需要使用调色板来对源像素进行索引色转换,调色板则通过 BITMAPINFO 结构体来传入。

在MSDN上的Multimedia技术文章GDI分类下有一篇 Animation in Windows,讲到如何通过BID来提升动态绘图的性能,文章还讲到了离屏的绘图,MSDN光盘上还有对应的项目代码 SHOWDIB。

点阵绘图的模式下,如果算法不够好,就很容易出现线条不平滑的情况,如下图显示了特定斜率的画线出现的锯齿状态,如果是水平、竖直或45度角这些情况都不会出现锯齿现象,一个很实用的抗锯齿算法就是在像素出现行列跳跃的接点进行弱色填充,如右图:

y axis                                         y axis                                    
A                                              A                                         
|                           ********           |                       __cg********      
|                   ********                   |               __cg*****^^
|           ********                           |       __cg*****^^
|   ********                                   |   *****^^
+-------------------------------->  x axis     +-------------------------------->  x axis

你要看不出差别,你可以对着两张图使劲眨眼睛。当年玩DOS编程的时候硬是拿锯齿问题没办法,实在是太丑了,又没有一点视觉上的常识,不会用颜色来欺骗眼睛。会画线后,也要会画圆什么的吧,这和画直线是一个道理,都是通过设置像素的色值,只不过画圆需要用到三角函数来计算像素的坐标,椭圆 ellipses 曲线 curves 什么的自然也是按曲线方程来设置相应的像素。

输入输出

鼠标和键盘支持是最基本的输入输出,在DOS平台下,鼠标需要通过 0x33 BIOS 中断,或者检测串行口的鼠标状态。在Win32平台下可以选择GUI程序,它直接支持鼠标功能,当然使用控制台程序也可以通过API间接实现鼠标支持。控制台程序默认是不使用消息环进行交互的,而是通过消息队列来获取输入信息的。当然消息环作为系统的一个核心功能,控制台也可以使用它。例如通过 SetTimer() 实现控制台的定时器时就可能需要使用消息环,因为这个函数是通过消息机制来实现的。通过 SetConsoleMode() 设置控制台的ENABLE_WINDOW_INPUT 模式,然后就可以使用 ReadConsoleInput() 来响应用户输入了,包括键盘和鼠标,注意要先禁止控制台 的快速编辑功能,否则鼠标消息就会被过滤掉。

通过 ReadConsoleInput() 函数可以读取到输入事件数据结构体 INPUT_RECORD,对应各种事件列表如下:

FOCUS_EVENT_RECORD &ife = INPUT_RECORD.Event.FocusEvent;
         KEY_EVENT_RECORD &ike = INPUT_RECORD.Event.KeyEvent;
        MENU_EVENT_RECORD &imu = INPUT_RECORD.Event.MenuEvent;
       MOUSE_EVENT_RECORD &ime = INPUT_RECORD.Event.MouseEvent;
WINDOW_BUFFER_SIZE_RECORD &iwb = INPUT_RECORD.Event.WindowBufferSizeEvent;

通过对事件数据判断就可以知道详细的按键信息,控制键状态,鼠标位置等等。详细的文档可以参考 MSDN 的 Platform SDK => Base Services => Files & I/O 部分。如果需要可以通过 AllocConsole() CreateProcess() 函数来创建新的控制台窗口。

在控制台模式下,有两套API,一套以字符为计量单位,一套则是以像素为计量的GUI函数。C语言的标准置入输出只是负责将字符内容发往显示缓冲区,并没有对缓冲区操作的功能。只有通过操作系统提供的API,如 SetConsoleWindowInfo() 等函数来修改输出缓冲区的状态。通过 GetStdHandle() 和 GetConsoleScreenBufferInfo() 可以获取缓冲区的详细信息,如控制台窗口的实际大小即控制台可以通过滚动条拖出来所有区域。还有当前显示区域的大小,光标位置,这些都是以字符为单位的,每个字符占用的位置就用一个 Cell 来计量。每个位置实际占用的像素数量需要根据字体的大小和类型来计算,在这里所有字符其实就是一个个点组成的点阵字符。通过 SetConsoleCursorPosition() 函数就可以为控制台出来函数指定字符输出的位置。一个典型的控制台有80列字符,行数可以为几十至几百行不等,参考程序打印的信息:

Connsole Screen Buffer Infomation:
            size: 80,300
 cursor position: 0,22
     view window: 0,79,30,0
      max window: 80,62

标准C语言库函数是没有定位光标的,需要在指定的光标位置输出内容就要使用操作系统操作的API,如 WriteConsole() WriteConsoleOutput() WriteConsoleOutputCharacter()。需要改变字符颜色可以使用 WriteConsoleOutputAttribute()、SetConsoleTextAttribute(),前者可以单独设置每个字符的颜色属性。

对于底层的按键输入,如 Ctrl+C、Ctrl+Break 等等,可以通过 SetConsoleCtrlHandler() 来设置一个回调函数 ConsoleHandlerRoutine 来响应。通常情况下这些消息是不用处理的,用户要通过强制终止自然是有原因,如果屏蔽掉驼些消息倒是有几分流氓特质。使用 SetConsoleTitle() GetConsoleTitle() 可以操作控制台的标题栏的文字内容。可将控制台读写的API分为高层和底层两类,又可以分为输入或输出两类,对于底层的API列表如下:

Function                     Description 
ReadConsoleInput             Reads and removes input records from an input buffer.
PeekConsoleInput             Reads without removing the pending input records in an input buffer.
GetNumberOfConsoleInputEvents Determines the number of unread input records in an input buffer.
WriteConsoleInput            Places input records into the input buffer behind pending records. 
FlushConsoleInputBuffer      Discards all unread events in the input buffer.

ReadConsoleOutputCharacter   Copies a string of Unicode or ANSI characters from a screen buffer. 
WriteConsoleOutputCharacter  Writes a string of Unicode or ANSI characters to a screen buffer. 
ReadConsoleOutputAttribute   Copies text and background color attributes from a screen buffer. 
WriteConsoleOutputAttribute  Writes text and background color attributes to a screen buffer. 
FillConsoleOutputCharacter   Writes a single Unicode or ANSI character to a consecutive cells.
FillConsoleOutputAttribute   Writes a text and background color to a consecutive cells. 
ReadConsoleOutput            Copies character and color data from a specified buffer cells. 
WriteConsoleOutput           Writes character and color data to a specified buffer cells.

为了适应多语言,可以使用控制台的代码页函数,输入端的代码页使用 SetConsoleCP() 和 GetConsoleCP(),输出端的代码页使用 SetConsoleOutputCP() 和 GetConsoleOutputCP()。当前系统支持的代码页保存在注册表中,常用的代码页有 54936(GB18030), 936(GBK), 65001(UTF8) :

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Nls\CodePage

在另一个层面,通过GDI函数 GetClientRect() 可以获取控制台当前内容区域占用的像素数量。结合控制缓冲区的详细信息,就可以将当前光标所在的字符位置转换为像素坐标,参考程序如下:

COORD GetCursorPositionPixel(COORD poc)
{
    HWND hwnd = GetConsoleWindow();
    RECT rect;
    GetClientRect( hwnd, &rect );

    CONSOLE_SCREEN_BUFFER_INFO bi;
    HANDLE hao = GetStdHandle(STD_OUTPUT_HANDLE);
    GetConsoleScreenBufferInfo( hao, &bi );

    COORD pos  = bi.dwCursorPosition;
    SMALL_RECT v = bi.srWindow;
    pos.X = poc.X*(rect.right/v.Right);
    pos.Y = poc.Y*(rect.bottom/v.Bottom);
    return pos;
}

有了像素座标就可以使用 SetPixel() 这样的GDI函数来在指定点进行绘图操作。系统还提供了一个函数 GetCursorPos() 来获取鼠标在屏幕上的坐标,通过 ScreenToClient() ClientToScreen() 函数可以在屏幕到窗口的坐标系之间转换。GetWindowPlacement() 可以用来获取窗口在屏幕上的坐标及显示状态,GetClientRect() 可以获取四限。关于窗口的内容MSDN上的 User Interface Services 上有齐全的资料。

动画与实现

动画是什么呢?其实动画就是人眼的由于视觉停留产生的假像,由一幅幅几乎相同的图像在一定的时间内连续切换,比如每秒切换12张就会产生动画的现像。从技术上来讲,动画就是定时器,从艺术层面上讲,动画就是一种感觉,如何掌握绘画技术,这个绘画是真的指绘画啊,不是用代码作图,有绘画技能再玩编程必定是个有趣的事。

之前看过 Richard Williams 出版的 The Animators Survival Kit Animated《动画师生存手册》,其中分解了大量的动画技巧,这是我首次接触到的让我震撼的关于动画技术方面的知识。之前或到目前,还没有接触到国内的有关方面的知识,只是知道小蝌蚪找妈妈、大闹天宫、七彩鹿这些作品,但对于其中技术层面的东西却没接触到,这是为什么呢?还是为什么呢!而国外在这方面却有大量的知识输出,比如说迪士尼九老 Disney’s Nine Old Men 之一,弗兰克在辞去工作后,和奥利.约翰斯顿,九老之一,开始写作的 The Illusion of Life《生命的幻象:迪斯尼动画造型设计》一书。他们那一帮人创建性地给出了动画的12条法则:

  • Squash & Stretch 挤压与拉伸,物体在接触或受外力后出现运动反转时使用,小球弹地而起的动画就用到这个原理;
  • Anticipation预备动作,加入一反向的动作以加强正向动作的张力,借以表示下一个将要发生的动作。这个原理就像说笑话时要先铺垫一下背景,否则讲到笑点的时候只有讲的人笑就不好了;
  • Staging,分场就是要分步表现动作的意图使之容易理解;
  • Straight-ahead vs. Pose-to-pose,逐帧画法 VS 关键帧画法,标准动画的一秒钟有24帧,顾名思义逐帧画法是一帧一阵接着画,关键帧则是先画出关键的动作点帧,然后再再有加中间帧画手画中间的画;
  • Follow-through & Overlapping Action,惯性跟随和动作重叠,理解不了吗?想想波动是如何发生的;
  • Slow-in & Slow-out 慢入与慢出,动作的起势和收势都慢,而中间的部分则是快的,这样一个动作才不会特别平板匀速,而会更有力度感一些;
  • Arcs 弧形运动轨迹,凡所有会动的生物,其组成的任何部分之运动轨迹皆为平滑的弧形曲线,可以用三角函数的关系来理解,比如说手臂的摆动,摆到接近关节限位时,速度就会慢下来,这就像三角函数的波峰与波谷,变化会变得很缓和;
  • Secondary Action,次要动作是用来增加动画的趣味性和真实性,丰富动作的细节的。它要控制好度,既要能被察觉,又不能超过了主要动作,如甩头和眨眼的关系;
  • Timing & Spacing,应该译作节奏,而不是时间和间隔,节奏感可以由不同速度的交替变化产生,但又不仅仅是速度上的交替;
  • Exaggeration 夸张,利用挤压与伸展的效果、夸大的肢体动作、或是以加快或放慢动作来加乘角色的情绪及反应,这是动画有别于一般表演的重要技巧;
  • Solid drawing,厚实感,在二维的物品加入三维因素的考虑,例如灯光阴影,重量感和平衡感等;
  • Appeal 吸引力,动作的精彩程度,也就是动作的表演。

回到大闹天宫,这个片在国外有个名字 the Monkey King - Uproar in Heaven,在国外大有粉在啊,来看看人家是怎么评价的:

I can’t believe what my eyes are seeing.
Un - fucking - believable. I cannot believe I am privleged to see this amazing cultural artifact.
Which studio? What year? Who was the director? Tell me or so help me, I’ll drive up there and beat it out of you.

Fantastic! There’s also a Japanese version of the Monkey Legend from the 1960’s. The one I saw was dubbed by Frankie Avalon & Jonathan Winters. Not so authentic as this clip, but just as colorful and WEIRD!

I saw this when I was a little girl. When Channel 4 in the UK went on the air, one of the first things it aired was this movie. I never saw it again…I’ve wondered for a long time if it was some fantastically beautiful dream I had. Its just as wonderful as I remember it! Thank you so much for uploading.

Oh man, I haven’t seen this in over a decade! My parents had it recorded on VHS tape, but it’s forever lost. I recently got the name of this show from the library. Thank you so much for posting this up!

I LOVE this movie, I remember watching it the first time on TV when I was like 6-7 years old (I’m 23 now) and taped it, I still to this day have the tape.

Hey man, thanks for uploading this movie~
Is there no way to get subtitles, though, for non-chinese speakers? It’d be awesome if americans could watch this too. I’m guessing you’re chinese and can speak/write it? Is it you don’t want to do subs, or don’t know how to?
……

本文作为一篇技术层面的书自然离不开技术上的实现,为了添加动画显示,就要使用 SetTimer() 设置定时器运行,如果不使用回调函数,到时间点系统就会发出一个 WM_TIMER 消息给程序,程序就可以通过处理这个消息来实现实时器功能。即使提供回调函数时,SetTimer() 也不能脱离消息机制,它需要系统默认的窗口过程函数来调用回调函数,所以需要 DispatchMessage() 来分发内部的定时器事件消息。注意它使用在一个精度要求不高的情况下,在几十毫秒的水平,如果需要高精度的定时可以考虑多媒体定时器 timeSetEvent() 实现,它还可以支持 SetEvent。注意定时器配对清理函数为 KillTimer()、timeKillEvent()。通过 GetTickCount() 可以获取系统的毫秒级别时间,但是精度也不高 55ms,一个 DOS 时钟的 tick,因为是通过 IRQ0 18.2 Hz 实现的,而多媒体时间函数 timeGetTime() 则精度高得多。多媒体系统函数通过 mmsystem.h 和 winmm.lib 引用。注意定时器回调函数定义的方式是不同的,不能混用,以下回调函数的定义格式中左边的是 SetTimer() 函数使用的定义:

void CALLBACK TimerProc(                      void CALLBACK TimeProc(
  HWND hwnd,         // handle to window        UINT uID,       // Identifier of the timer event.
  UINT uMsg,         // WM_TIMER message        UINT uMsg,      // Reserved; do not use.
  UINT_PTR idEvent,  // timer identifier        DWORD dwUser,   // User instance data
  DWORD dwTime    // current system time        DWORD dw1, DWORD dw2 // Reserved; do not use.
);                                            );

在控制台下尝试使用 GetConsoleWindow() 来给定时器提供窗口绑定,总是问题错误 Access is denied!当然可选择的方法大把,除了上面提到的多媒体时间函数,还可以使用同步等待API,例如 WaitForSingleObject() 函数就可以实现定时器功能。同步函数还有其它可以实现定时器的,如 CreateWaitableTimer(),它需要和 SetWaitableTimer() 配合使用,由于它的时间精度是以 100 纳秒为基准的,因此会使用一个64-bit的大数 LARGE_INTEGER,通过设置一个负数来实现定时。

关于同步功能,参考MSDN Platform => Base Services => DLLs,Processes, and Threads => Synchronization。基本的用法是通过一个可用来同步的对象,如线程、进程、作业等等,分别通过 CreateJobObject() CreateProcess() CreateThread() 来创建,然后使用等待函数 Wait Functions 来暂停程序的执行,等待目标对象完成工作,以实现程序同步。同步功能可以实现定时器的功能,但它绝对不是为了实现定时功能设置的。

在阅读MSDN文档有读者可能会理解不了什么时回调函数,就连MSDN文档对 TimeProc 的说明也指它是一个占位符号 placeholder,而不是说它是一个函数。其实回调函数是十分有用的一种编程手段,假设用户按要求定义一个函数A,然后当作参数传给其它函数B,由这个函数B来调用用户定义的函数A,这样的函数A就是回调函数。在这里就是 SetTime 使用的 TimeProc 定义,因为用户可以随便定义它的名字所以称之为点位符号。唯一的要求就是确保参数列表符合要求,因为主调函数 SetTimer 已经定义好怎么回调这个函数了。

如果程序足够好,可以很好地处理完相关的工作,或者机器跢(没想还有这字,本来想打足够二字)快,就可以考虑使用 Sleep() 或 SleepEx() 来让程序进行等待状态了,这样可以避免程序空跑,浪费电力资源!下开始贴代码:

/*
 * Timer in console Demo by Jimbowhy
 * compiler:
   g++ -o setTimer setTimer.cpp -lwinmm && settimer
 */

#include <iostream>
#include <cstdio>
#include <windows.h>
#include <mmsystem.h>

#pragma comment( lib, "Winmm.lib" )

using namespace std;


typedef HWND (WINAPI *TGetConsoleWindow)();
HWND GetConsoleWin()
{
    HMODULE hKernel32 = GetModuleHandle("kernel32");
    TGetConsoleWindow f = (TGetConsoleWindow)GetProcAddress(hKernel32,"GetConsoleWindow");
    return f();
}

void CALLBACK TimerProc( HWND hwnd, UINT uMsg, UINT id, DWORD ms )
{
    PostMessage( NULL, WM_USER+3, 9, 8 );
    cout << "TimeProc callback" << endl;
}

void CALLBACK TimeSetProc( UINT uID, UINT uMsg, DWORD dwUser, DWORD dw1, DWORD dw2 )
{
    static int count;
    if(count++>9) return;
    PostMessage( NULL, WM_USER+2, 9, 8 );
    cout << "TimeSet callback \t" << hex << dwUser << endl;
}

ULONG WINAPI thread( PVOID pv ){
    MSG msg;
    cout << "thread run " << *(int*)pv << endl;;

    HWND hwnd = GetConsoleWin();
    SetTimer( hwnd, 0, 500, &TimerProc ); // GetLastError 5 Access is denied
    cout << "GetLastError " << GetLastError() << endl;
    unsigned int timer = SetTimer(0, 0, 500, &TimerProc);
    unsigned int tmm = SetTimer(0, 0, 500, NULL); // use WM_TIMER
    //KillTimer(timer);

    MMRESULT mm = timeSetEvent( 10,0,TimeSetProc,0xABCD,TIME_PERIODIC);

    while(GetMessage(&msg,NULL,0,0))
    {
        switch( msg.message ){
            case WM_TIMER: cout << "WM_TIMER\t"; break;
            case WM_USER+3: cout << "WM_USER+3\t"; break;
            case WM_USER+2: cout << "WM_USER+2\t"; break;
        }
        DispatchMessage(&msg);
    }
}

int main()
{   
    DWORD tid;

    cout<<"Use Timers in console ";

    int p = 999;
    HANDLE h = CreateThread( NULL, 0, thread, &p, 0, &tid );
    DWORD  w = WaitForSingleObject(h,1000*3);   
    switch(w)   
    {   
    case   WAIT_ABANDONED:   
          printf("WaitForSingleObject => WAIT_ABANDONED\n");   
          break;   
    case   WAIT_OBJECT_0:   
          printf("WaitForSingleObject => WAIT_OBJECT_0\n");   
          break;   
    case   WAIT_TIMEOUT:   
          printf("WaitForSingleObject => WAIT_TIMEOUT\n");   
          break;   
    }   
    CloseHandle(h);

    int periodic = 1000;
    LARGE_INTEGER li;
    li.QuadPart = -30000000; // 3s, in 100 nanosecond intervals
    HANDLE timer = CreateWaitableTimer( NULL,true, "Waitable" );
    SetWaitableTimer( timer,&li,periodic,NULL,NULL,true );
    if( WaitForSingleObject( timer, INFINITE) == WAIT_OBJECT_0 ){
        cout << "WaitForSingleObject Final" << endl;
    }
    CloseHandle(timer);

    return 0;
}

既然说到动画了,搞了这么多的定时器,也没点真家伙出来怕是对不住观众的。记得之前玩 TVPaint Animation Pro、Toon Boom Studio、Toon Boom Storyboard Pro 3.0、Toon Boom Pencil 等等动画软件的时候有些素材,待我翻箱倒柜一翻找找。就是它了,Animation Charts 1-2-3-4 for Toon Boom Studio。来看看要用到的素材,对了就只有四张图,这四张图就是上面提到的关键帧 Pose to pose:

制作单独执行文件

资源文件是Win32程序的一个重要组成,如果要开发Win32程序,不使用资源文件就不很有效地提高文件资源的使用效率。通过资源文件,可以程序使用到的任何数据打包到 exe 程序文件中得到一个独立运行的程序,也可以打包资源到一个DLL文件中,这样可以很容易地实现程序的国际,加载不同的DLL就可以变换程序的不同语言运行状态。一个资源文件像代码文件一样,注解都和C语言一样,也可以使用 include 包含头文件。#define #if #ifdef 这些语法都是C语言风格的。资源文件类型是 .rc,经过编译后得到二进制格式 .res 文件,这种格式可以通过 link.exe 链接命令和主程序链接成为最终的 exe 程序文件。

在资源文件中,定义一些数据,程序使用的字符串,菜单及快捷键 ACCELERATORS,数据可以有各种类型,图标等等自然是基本功能,二进制数据的文件也可以使用,像 WAV BMP 等等。

Resource            Topic 
Accelerator table   Keyboard Accelerators  
Bitmap              Bitmaps  
Cursor              Cursors  
Dialog              box Dialog Boxes  
Enhanced metafile   Metafiles  
Font                Fonts and Text  
Icon                Icons  
Menu                Menus  
Message-table entry Your message-compiler documentation 
String-table entry  Strings  
Version information Version Information

即使资源经过编译后成为 exe 文件的一部分,但使用 UpdateResource() 函数还是可以修改资源的。通过 FindResource() LoadResource() 就可以加载资源数据,玩VB还可以通过 LoadResData() 加载资源文件,如声波文件,然后可以通过 sndPlaySoundA() 来播放。当年开始学编程,就是用VB玩资源文件给玩坏的,忽略了基本原理的学习,荒废大量时间。在MSDN的子集合 Platform SDK => User Interface Services => Resources 中有完整的参考文档,包括资源编译命令 rc.exe 的文档,其中还一个更新资源的示例。

要使用资源文件开发程序时,像我一样如果不使用IDE该怎么办呢?好像不太可能的事吧,不用IDE不是自找麻烦?其实不然,不用IED自用不用的道理,只要学会任意一个MAKE自动化编译工具,其实有IDE和没有都不是问题。编译工程只是一个MAKE编译命令而已,简单的不得了的事,为什么非要IDE呢。

Function         Action                        To remove resource 
FormatMessage    Loads message-table entry     No action needed 
LoadAccelerators Loads an accelerator table    DestroyAcceleratorTable 
LoadBitmap       Loads a bitmap resource       DeleteObject 
LoadCursor       Loads a cursor resource       DestroyCursor 
LoadIcon         Loads an icon resource        DestroyIcon 
LoadMenu         Loads a menu resource         DestroyMenu 
LoadString       Loads a string resource       No action needed

一般情况下使用 STRINGTALBE 来定义字符串内容就很好用了,还可以结合语言指令 LANGUAGE 来定义多种语言的文字内容定义。而 MESSAGETABLE 则是个增强,它可以结合 FormatMessage() 函数来格式字符串,可以给它指定语言ID来实现多国语言。FormatMessage 这个函数也用来格式化程序的错误信息,可以配合 GetLastError() 使用。但它一个主要的功能是事件日志,ReportEvent() 用来上报日志, RegisterEventSource() 函数可以注册包含消息表定义的程序。当然自行实现一个类似于 gettext 一样的国际化模块并不是什么难事,但已经有了而且又不是难用那就用吧。资源文件这种技术在 Visual C++ 2.0 时代就已经成熟,而且一直使用到现在,事实验证了这是成功的技术。

MESSAGETABLE 使用独立的编译命令 mc.exe,使用 Message Compiler 可以很方便地编译多字节符字符集内容,它可以使用独立文本文件,mc 将文件编译为二进制格式 .h,.bin 文件,并生成在 rc 文件来包含引用。每个mc文件可以两个部分组成 Header Section 和 Message Definitions,头部可以设置一些基本信息,如语言分类,每一种语言经过编译后会形成一个对应的二进制文件,下面的例子就会生成 Resource_ENU.bin 和 Resource_GER.bin 两种语言对应的二进制数据文件。消息定义区则定义特定消息相关属性,如消息ID,文字内容和语言归属。Message Table 主要功能在于日志消息处理,所有消息都会带上消息ID,而程序使用时关心的是ID符号而不ID的具体数值。每个消息可以定义多个不同语言的内容,这一点就可以利用来实现程序的国际化。定义字符串内容时使用最基本的键值对形式:

keyword=value ;comments

keyword 不区分大小写,= 等号两边的空格会忽略。value 可以是双引号包括的字符串,也可以是数值,可以使用C语言的数值常量形式定义,可以使用 // 和 ; 两种注解格式。以下是一个mc文件样板:

;#ifndef __MESSAGES_H__
;#define __MESSAGES_H__

;
;// Header Section
;//
LanguageNames =
    (
        English = 0x0409:Resource_ENU
        Chinese = 0x0804:Resource_CHS
        Taiwan  = 0x0404:Resource_CHT
        German  = 0x0407:Resource_GER
    )

;
;// Message Definitions - Eventlog categories
;//
MessageId       = 1
SymbolicName    = CATEGORY_ONE
Severity        = Success

Language        = English
First category event
.
Language        = German
Ereignis erster Kategorie
.
Language        = Chinese
事件消息
.
Language        = Taiwan
事件消息
.

MessageId       = +1
SymbolicName    = CATEGORY_TWO
Severity        = Success

Language        = English
Second category event
.
Language        = German
Ereignis zweiter Kategorie
.
Language        = Chinese
另一事件消息
.
Language        = Taiwan
另一事件消息
.

;
;// Message Definitions - Events
;//

MessageId       = +1
SymbolicName    = EVENT_STARTED_BY

Language        = English
The app %1 has been started by user %2
.
Language        = German
Der Benutzer %2 konnte das Programm %1 erfolgreich starten
.
Language        = Chinese
程序 %1 已经由 %2 用户启动
.
Language        = Taiwan
程序 %1 已經由 %2 用戶啟動
.

MessageId       = +1
SymbolicName    = EVENT_BACKUP

Language        = English
You should backup your data regulary!
.
Language        = German
Sie sollten Ihre Daten regelm??ig sichern!
.
Language        = Chinese
是时候做备份了!
.
Language        = Taiwan
是時候做備份了!
.

;
;#endif  //__MESSAGES_H__
;

注意 mc 文件中各种语言的内容的定义基本格式,使用一个圆点来结束:

Language=language_name
messagetext
.

为了形像地理解不同语言的资源定义是如果组织到程序内部的,可以使用 PE Explorer 来浏览程序文件的资源内容。在资源文件中定义资源类型时,需要用到系统定义的常量,比如说虚拟快捷键的定义 VK_F1,它就在 WinUser.h 中定义,需要引用它。MFC的头文件 afxres.h 也引用了它,可以直接使用。以下是一个资源文件的示例,注意使用 Unicode 编码保存,因为指定了代码页为 65001:

#include "res.h"
#include "afxres.h"

#include "resource.rc"
MMT MESSAGETABLE resource.mc

MoveCursor    CURSOR                 movetab.cur
WxIcon        ICON                   wxwin.ico
DING          WAVE    DISCARDABLE   "pass.wav"
16            BITMAP                 mario16.bmp
mario         BITMAP                 mario.bmp 

#pragma code_page(65001)
STRINGTABLE LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
{
    IDS_HELLO,   "梦见你来了,还好吗?"
    IDS_GOODBYE, "再见"
}

STRINGTABLE LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_TRADITIONAL
{
    IDS_HELLO,   "夢見你來了,還好嗎!"
    IDS_GOODBYE, "再見"
} 

STRINGTABLE LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_UK
{
    IDDSTAR,   "Colourful day!"
}

STRINGTABLE LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
{
    IDDSTAR,   "Colorful day!"
}

ShapesMenu MENU
{
    POPUP "&Shape"
    {
        MENUITEM "&Rectangle", ID_RECT
        MENUITEM "&Triangle",  ID_TRIANGLE
        MENUITEM "&Ellipse",   ID_ELLIPSE
    }
}

1 ACCELERATORS
{
    "^C",  IDDCLEAR                             ; control C
    "K",   IDDCLEAR                             ; shift K
    "k",   IDDELLIPSE,                  ALT     ; alt k
    98,    IDDRECT,                     ASCII   ; b
    66,    IDDSTAR,                     ASCII   ; B (shift b)
    "g",   IDDRECT                              ; g
    "G",   IDDSTAR                              ; G (shift G)
    VK_F1, IDDCLEAR,                    VIRTKEY ; F1
    VK_F1, IDDSTAR,    CONTROL,         VIRTKEY ; control F1
    VK_F1, IDDELLIPSE, SHIFT,           VIRTKEY ; shift F1
    VK_F1, IDDRECT,    ALT,             VIRTKEY ; alt F1
    VK_F2, IDDCLEAR,   ALT, SHIFT,      VIRTKEY ; alt shift F2
    VK_F2, IDDSTAR,    CONTROL, SHIFT,  VIRTKEY ; ctrl shift F2
    VK_F2, IDDRECT,    ALT, CONTROL,    VIRTKEY ; alt control F2
}

资源文件的ID定义全部在头文件中定义,主程序中需要引用它:

#ifndef _res_h_
#define _res_h_

#define IDS_HELLO    1
#define IDS_GOODBYE  2
#define ID_RECT      3
#define ID_TRIANGLE  4
#define ID_ELLIPSE   5
#define IDDELLIPSE   8
#define IDDRECT      9
#define IDDCLEAR    13
#define IDDSTAR     14

#endif

上面两个文件名为 res.rc 和 resource.mc,使用这些资源文件时,就可以通过以下两条指令命令来生成二进制文件:

mc resource
rc res

注意,文件名不要相同,不然 mc.exe 会覆盖掉而且不给你提示信息!编译得到资源文件后,再和主程序链接就可以为程序使用了。

在資源文件中,#pragma 是用来告诉 rc.exe 命令当前的内容是什么代码页编码的。如果 rc 文件是UTF8编码保存的,你却用 #pragma code_page(950) 来告诉 rc.exe 是繁体中文编码就不对了,它令会真的将内容看作是 BIG5 来处理的。假设系统当前的默认代码页是GBK,rc.exe 就会进行 BIG5到GBK 的编码转换,所以最后程序运行就会乱码了。

#ifdef _WIN32
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
#pragma code_page(1252)
#endif //_WIN32

作为一个GNU饭,怎么用TDM-GCC来编译资源文件呢,GNU Binary Utility提供了 windres.exe 这个工具,注意是 winDres.exe。不过至于 mc 资源,还是算了吧,因为它是二进制编码,直接用mc.exe就可以了,mc.exe 直接支持 Unicode,在编译选项中可以通过 -u 和 -U 来启用Unicode文件读入和输出,实测中 mc.exe 由于支持 Unicode,所以它和 FormatMessage() 结合时对多语言的支持好过 rc.exe 千百万。而且,通过 LoadString() 方法来加载多语言内容并不是特别靠谱的做法。另外测试中还发现 rc.exe在处理 UTF8 编码文件时不行,根本就直接忽略掉资源文件了。指定代码页为 65001 也没有报错,说明是支持65001的,几次尝试后发现,这个代码页竟然需要对应UNICODE,特别说明一下当前用的是VC6自带的。

windres 对资源文件的注解不能用 ; 号,上面的示例需要修改一下才能通过 windres.exe 编译。如果在繁体中文环境下使用 rc.exe 编译含有GBK的文件时,不能兼容BIG5的简体字将会被破坏,即使指定代码页没用。因为rc.exe本身不能处理各种字符集,需要依赖系统,而Windows系统的设计上是不许可BIG5和GBK同时使用的。倒是使用GBK时情况会更好,因为GBK兼容繁体。如果使用 windres.exe 则可以通过以下参数来设置不同的编译语言,语言参数中指定用16进制表示的本地化语言代码,如中文简、繁体对应 0x0804、0x0404:

-c --codepage=<codepage>     Specify default codepage
-l --language=<val>          Set language when reading rc file

使用 windres.exe 编译命令使用如下:

mc.exe -u resource.mc
windres.exe -c 936 -l 0804 -J rc -O coff -i res.rc -o res.obj

不过,在我把系统的设置为繁体中文时,再编译工程发现,所有不兼容的简体字都被换成了?号,而繁体字符正常得很。windres.exe 不支持 Unicode 但支持 UTF8,而且是正确对应代码页 65001。

在使用资源时,如位图,可以直接通过ID名,注意没有数值常量定义,使用LoadBitmap(NULL,”mario”)来加载,也可以使用ID常量来加载,使用宏 MAKEINTRESOURCE 可以将参数的最高16-bit设置为0,低16-bit设置为ID值,其实直接显式将ID数值转型为(char*)就可以了,或者将ID数值转换成”#ID”这种格式的字符串也可以。微软这个用法确实算是奇葩的,因为参数是指向字符串的指针类型,而 LoadBitmap() 却可以将它当成资源ID值来用。测试中不给 LoadBitmap() 传入进程句柄时,总是返回1814,说资源找不到,只好使用 GetModuleHandle(NULL) 来获取当前进行句柄给它。

以下是程序演示环节:

/*
 * Resource Demo by Jimbowhy, compile it with TDM-GCC:
   g++ -o resource resource.cpp resource.res -lgdi32 -lwinmm && resource
 */

#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <windows.h>
#include <mmsystem.h>
#include "res.h"

typedef HWND (WINAPI *GCW)();
HWND GetConsoleWin()
{
    HMODULE hKernel32 = GetModuleHandle("kernel32");
    GCW f = (GCW)GetProcAddress(hKernel32,"GetConsoleWindow");
    return f();
}

HWND wconsole = GetConsoleWin();
void CALLBACK TimeSetProc( UINT uID, UINT uMsg, DWORD dwUser, DWORD dw1, DWORD dw2 )
{
    static int count;
    char title[64];
    POINT p;
    GetCursorPos(&p);
    ScreenToClient(wconsole,&p);
    sprintf( title, "TimeSet %d (%d,%d) %x", ++count,p.x,p.y,dwUser );
    SetConsoleTitle( title );
    HDC idc = GetDC( wconsole ); //GetConsoleWindow()
    HICON wx = *((HICON*) dwUser);
    DrawIcon( idc, p.x, p.y, wx );
}

void Loop()
{
    HANDLE hai = GetStdHandle( STD_INPUT_HANDLE );
    if( !hai ) {
        printf("GetStdHandle(%d)!\n", GetLastError() );
        return ;
    }
    SetConsoleMode( hai, ENABLE_WINDOW_INPUT | ENABLE_MOUSE_INPUT );
    INPUT_RECORD buffer[4];
    DWORD numberRead;

    printf("------=========Press ESC/x to quit=========-------\n");
    while( true ){
        ReadConsoleInput( hai, buffer, 2, &numberRead);
        for(int i=0; i<numberRead; i++){
            KEY_EVENT_RECORD   &ike = buffer[i].Event.KeyEvent;
            char c = ike.uChar.AsciiChar;
            switch( buffer[i].EventType ){
                case KEY_EVENT:
                    if( c=='x' || ike.wVirtualKeyCode==VK_ESCAPE ) return ;
                    break;
            }
        }
    }
}

int main(){
    // --------===========--------- use resource string
    char rs[1024];
    LoadString( NULL,IDS_HELLO,rs,1024 );
    printf( "greeting from resource: %s \n", rs );

    //  --------===========--------- use resource wave
    printf( "wave play \n", rs );
    HRSRC ding_inf = FindResource( NULL, "DING", "WAVE");
    if( !ding_inf ) printf(" error FindResource %d\n", GetLastError() );
    HGLOBAL ding = LoadResource( NULL,ding_inf );
    //sndPlaySound( (char*)ding, SND_MEMORY | SND_SYNC | SND_NODEFAULT ); // this OK!
    if( !ding ) printf(" error LoadResource %d\n", GetLastError() );
    HANDLE wave = LockResource( ding );
    if( !wave ) printf(" error LockResource %d\n", GetLastError() );
    sndPlaySound( (char*)wave, SND_MEMORY | SND_SYNC | SND_NODEFAULT );
    UnlockResource( wave );
    FreeResource( ding );

    // --------===========--------- use resource icon
    HICON wx = LoadIcon( GetModuleHandle(NULL),"WxIcon" );
    if( !wx ) printf(" error LoadIcon %d\n", GetLastError() );
    MMRESULT mm = timeSetEvent( 1000/12,0,TimeSetProc,(DWORD)&wx,TIME_PERIODIC);

    // --------===========--------- use resource bitmap
    printf( "bitmap show \n", rs );
    HBITMAP mario = LoadBitmap( GetModuleHandle(NULL),(char*)(16) );
    if( !mario ) printf(" error LoadBitmap %d\n", GetLastError() );
    HANDLE hm = LoadImage(NULL, "mario256.bmp", IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE);
    //mario = (HBITMAP)hm;

    HDC hdc = GetDC( GetConsoleWin() ); //GetConsoleWindow()
    HDC mask = CreateCompatibleDC(hdc);
    HDC draw = CreateCompatibleDC(hdc);
    //int blue  = RGB(0x60,0x80,0xc0);
    int blue  = RGB(0x50,0x75,0xe0);
    int white = RGB(0xff,0xff,0xff);
    int black = RGB(0x00,0x00,0x00);
    int sw = GetSystemMetrics(SM_CXSCREEN);//GetDeviceCaps( mask,ASPECTX );
    int sh = GetSystemMetrics(SM_CYSCREEN);//GetDeviceCaps( mask,ASPECTY );
    BITMAP bmp;
    GetObject( mario, sizeof(BITMAP), &bmp );
    int w = bmp.bmWidth, h = bmp.bmHeight;
    HBITMAP mono = CreateCompatibleBitmap( mask, w, h );
    SelectObject( mask,mono );
    SelectObject( draw,mario );
    printf(" Bitmap size (%d,%d) Screen size(%d,%d)\n", w, h, sw, sh);
    SetBkColor( draw, blue );
    BitBlt( mask, 0, 0, w, h, draw, 0, 0, SRCCOPY );
    BitBlt( hdc,  w, 0, w, h, mask, 0, 0, SRCAND );
    BitBlt( draw, 0, 0, w, h, mask, 0, 0, 0x00220326); // Color-keying
    BitBlt( hdc,  w, 0, w, h, draw, 0, 0, SRCPAINT);
    /*
    BitBlt( hdc, 0*w, 0, w, h, draw, 0, 0, SRCCOPY );
        BitBlt( mask, 0, 0, w, h, draw, 0, 0, SRCCOPY );
    BitBlt( hdc, 4*w, 0, w, h, mask, 0, 0, SRCAND);
    BitBlt( hdc, 1*w, 0, w, h, mask, 0, 0, SRCCOPY );
        BitBlt( mask, 0, 0, w, h, mask, 0, 0, NOTSRCCOPY );
    BitBlt( hdc, 2*w, 0, w, h, mask, 0, 0, SRCCOPY );
        //SetTextColor( draw, white );
        SetBkColor( draw, white );
        BitBlt( draw, 0, 0, w, h, mask, 0, 0, SRCAND );
    BitBlt( hdc, 3*w, 0, w, h, draw, 0, 0, SRCCOPY );
        BitBlt( hdc, 4*w, 0, w, h, draw, 0, 0, SRCPAINT);
    */
    DeleteDC(hdc);
    DeleteDC(mask);
    DeleteDC(draw);
    DeleteObject(mario);
    DeleteObject(mono);

    Loop();
    return 0;
}

内容国际化

国际化问题简单来讲,就是程序适应不同字符编码方案,不同语言习惯的问题。比如说货币符号,人民币是¥,美元是$。时间格式显示也是一个方面,不同的地区有不同的使用习惯,通过 GetTimeFormat() 可以获取系统设置的格式字符串。一个国际化的程序在不同的系统区域设置具有适应能力,而国际化更见的实现是字符的国际化,允许用户设置使用什么语言。因此,使用资源文件就是一种非常好的办法,Windows系统也集成了资源文件的API。通常程序需要为不同的语言生成不同的资源文件,编译成为一个个DLL资源文件,然后程序通过 LoadLibrary() 来加载。通过资源API函数如 LoadAccelerators(), LoadBitmap(), LoadCursor(), LoadIcon(), LoadMenu() 就可以访问到指定的语言的资源数据。

说到国际化,就必须要讲一下中的编码问题,中文编码是全世界所有编码中最巨大的,因为符号个个都不同,第个汉字都要占一个编码。忽略不常用的编码方案,最早标准的中文编码是GB2312,它的代码页对应 20936,这个方案太简单了,只包含了几千汉字,连有些日常使用的字都没有定义,根本不够用。因此后来发展出来一个GBK,即代码页 936 所对应的中文编码方案,它兼容了GB2312的所有字符,并增加了许多常用和不常用的汉字,字符数量达到万数级别。这也是Windows系统支持最好的简体中文编码方案,繁体方案则主要是BIG5,它不兼容简体汉字,代码页为 950。对于后来的汉字编码超集 GB18030,是不完整支持的,这个方案有单字节、双字节、四字节几种字符定义,Windows 只能通过双字节的Unicode兼容其中一部分字符。如果在rc文件中使用 54936 这个代码页,编译结果是不对的:

#pragma code_page(54936)

C语言的本地化库 locale.h 函数 setlocale(),它可以用来改变程序的使用的语言或代码页,如果系统支持,则可以通过它来让程序运行在指定的语言和代码页环境中:

setlocale( LC_ALL, "C" );

参数 LC_ALL 是指所有内容都要实现本地化,包括日期时间、货币符号、相关库函数等等。第二个参考是本地化的国别语言和代码页设置,C语言默认值为“C”,它的格式如下:

locale :: "lang[_country[.code_page]]"

国别语言的定义是规范的,有简写和全称两种形式,例如德国就可以表示为 “deu”,”germany”,中国为 “china”, “chn”。而德语默认有 “deu”,”german”,还有瑞士地区的德语 “des”,”german-swiss”。简体中文有 “chinese”,”chinese-simplified” or “chs”,繁体中文为 “chinese-traditional”,”cht”。可以参考MSDN的 Country/Region Strings 和 Language Strings 相关内容,以下是一些例子:

setlocale( LC_ALL, "English" );
setlocale( LC_ALL, ".1252" );
setlocale( LC_ALL, "English_United States.1252");
setlocale( LC_ALL, "French_Canada.1252" );
setlocale( LC_ALL, "French_Canada.ACP" );
setlocale( LC_ALL, "French_Canada.OCP" );
setlocale( LC_ALL, "German");

关于代码页,在99OCT版的MSDN上有一本书《Developing International Software for Windows 95 and Windows NT》,里面有大量关于国际化的内容,这本书是单独的一个 DEVINTL.CHM 文件,将近50MB,可谓内容丰富啊。本书Amazon有售只要 $1.97 刀,宝啊!关于作者 Kano 女士:

Nadine Kano joined Microsoft Corporation in 1989 after graduating from Princeton University with a degree in computer science engineering. She worked for three years as the international developer responsible for localized editions of Microsoft Word for Windows. In 1993 she joined the Developer Relations Group as a member of the Globalization Team. Nadine regularly publishes articles in the Microsoft Developer Network News on software internationalization and travels around the world giving lectures on internationalization techniques. She lives in Palo Alto, California.

以下这份表是 Windows 95 对各种代码的支持情况,1200 在后来的XP等系统中支持:

Code Page  Name                   Windows 95    Code Page  Name                   Windows 95
1200       Unicode (BMP of ISO 10646)      O    862        Hebrew                          X
1250       Windows 3.1 Eastern European    X    863        MS-DOS Canadian French          X
1251       Windows 3.1 Cyrillic            X    864        Arabic                          X
1252       Windows 3.1 US (ANSI)           X    865        MS-DOS Nordic                   X
1253       Windows 3.1 Greek               X    866        MS-DOS Russian                  X
1254       Windows 3.1 Turkish             X    869        IBM Modern Greek                X
1255       Hebrew                          X    874        Thai                            X
1256       Arabic                          X    932        Japanese                        X
1257       Baltic                          X    936        Chinese                         X
1361       Korean (Johab)                  X    949        Korean                          X
437        MS-DOS United States            X    950        Chinese (Hong Kong SAR, Taiwan) X
708        Arabic (ASMO 708)               X    10000      Macintosh Roman                 X
709        Arabic (ASMO 449+, BCON V4)     X    10001      Macintosh Japanese              X
710        Arabic (Transparent Arabic)     X    10006      Macintosh Greek 1               X
720        Arabic (Transparent ASMO)       X    10007      Macintosh Cyrillic              X
737        Greek (formerly 437G)           X    10029      Macintosh Latin 2               X
775        Baltic                          X    10079      Macintosh Icelandic             X
850        MS-DOS Multilingual (Latin      X    10081      Macintosh Turkish               X
852        MS-DOS Slavic (Latin 2)         X    037        EBCDIC                          O
855        IBM Cyrillic                    X    500        EBCDIC 500V1                    O
857        IBM Turkish                     X    1026       EBCDIC                          O
860        MS-DOS Portuguese               X    875        EBCDIC                          O
861        MS-DOS Icelandic                X

系统默认状态下具有一个代码页称为默认代码页,这是对于非 Unicode 程序来说的,比如控制台就是,通过函数 GetACP() 可以获取到。对于DOS程序,还有一个称为OEM代码页的,也即是指代那些将一个字节256个值都用来编码的字符集,OEM 全称 Original Equipment Manufacturer,可以通过 GetOEMCP()。这两个函数差别在于两种环境,DOS 和 Windows 使用的代码页集合的不同,例如俄语 Cyrillic 的OEM代码页为 855,而在 Windows 下使用的代码页是 1251。以下是一组和语言地区设置信息有关的API及其输出,系统是 Windows 7 简体中文版,但是设置了非Unicode程序语言和地区设置成台湾、繁体:

GetACP(950) GetOEMCP(950)
           GetThreadLocale(0x804)
    GetSystemDefaultLangID(0x404)
      GetSystemDefaultLCID(0x404)
GetSystemDefaultUILanguage(0x804)
      GetUserDefaultLangID(0x804)
        GetUserDefaultLCID(0x804)
  GetUserDefaultUILanguage(0x804)

如果将地区和语言中的格式设置为台湾、繁体,则 GetThreadLocale()、GetUserDefaultLangID()、GetUserDefaultLCID() 就会改变输出。位置设置则不会影响以上函数的输出的,但和 GetUserGeoID()、SetUserGeoID() 有关。其中还有两个函数需要辩解一下, GetSystemDefaultUILanguage()是安装Windows选择的语言,这个是不会变的,而GetSystemDefaultLangID()则是设置好系统默认语言,它和系统默认代码页直接相关,因此和 GetACP() 的输出是相关的。

对于简体中文Window一般都为 936,即默认代码页是GBK。由于GBK和BIG5 CP950 是不兼容的,所以在Windows系统中只能选择其中一种,而且不能用 chcp 来选择另一种。看到代码页就知道程序使用什么字符集了,但API可以看不懂什么是代码页,API是通过一组称为本地化代码 Locale Code 的数值约义来处理各种地区语言的。先来看下面两组语言定义,第一组是一级语言分类,定义了中文 、英文、德文等等代码。第二组称为语言地区分支定义,例如中文就分为大陆地区的简体中文和港台地区的繁体中文。将这两组数据合到一起就可以形成一个特定语言的标识码,首先将二级代码右移位10位,再与一级代码相加。如简体中文就是 0x02<<10+0x04=0x0802,换算为十进制数值就是 2052,同样美式英文为 1033,英式英语为 2057 等等。这些代码就是资源文件用来分类的依据,ID相同一个字符串在编译时就会归类到一个语言代码分类下。如果用 Visual Stuido 查看资源,看到的就是 Chinese(P.R.C)、English(U.K)、English(U.S)等等字样。先抛开实用性不讲,个人觉得微软这种操作法确实属于找事做的那种,为了得到地区相关的本地化代码,还要搞两个宏来处理,本来就是逻辑运算可以做的事。

#define LANG_NEUTRAL    0x00
#define LANG_CHINESE    0x04
#define LANG_ENGLISH    0x09
#define LANG_DUTCH      0x13
#define LANG_BULGARIAN  0x02

#define SUBLANG_NEUTRAL 0x00
#define SUBLANG_DEFAULT 0x01
#define SUBLANG_BULGARIAN_BULGARIA  0x01
#define SUBLANG_CHINESE_TRADITIONAL 0x01
#define SUBLANG_CHINESE_SIMPLIFIED  0x02
#define SUBLANG_DUTCH               0x01
#define SUBLANG_DUTCH_BELGIAN       0x02
#define SUBLANG_ENGLISH_US          0x01
#define SUBLANG_ENGLISH_UK          0x02

#define MAKELANGID(p,s) ((((WORD)(s))<<10)|(WORD)(p))
#define MAKELCID(l,s) ((DWORD)((((DWORD)((WORD)(s)))<<16)|((DWORD)((WORD)(l)))))

完整的本地化语言标识可以在 MSDN 上的 Platform SDK => Base Services => International Feeatures 中找到 Language Identifiers 部分,其中也包含了输入法的相关API、消息内容,想要实现输入法可以参考。本地默认代码页在整个资源系统中是非常重要的东西,API如何检索到正确的资料数据就看它的了。API定位资源有许多逻辑条件,首先会定位一个特定的语言,然后再从里面查询相应的ID定义。经过墨盒测试,主要条件有系统安装时的语言、默认代码页、英文语言资源、语言中性 Neutral,其它条件。当前系统为 GBK,即默认语言代码为 LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED,以STRINGTALBE中的字符串为例,使用 LoadString() 加载资源的优先顺序如下:

  • 一级语言和二及语言同为中性:LANG_NEUTRAL, SUBLANG_NEUTRAL,这种情况最优先,而且即这个语言找不到相应的ID定义,也不会采用其它语言定义的内容。可以定义多个这种STRINGTABLE,编译时会合并。
  • 系统安装语言,即 GetSystemDefaultUILanguage() 查询到的语言,简体中文系统为 936 对应 LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED,找不到ID定义时也不采用其它内容,注意和系统默认代码页没关系;
  • 其次是没有指明语言的定义内容和 LANG_ENGLISH, SUBLANG_ENGLISH_US 可以并存。如果这两情况有定义,那么即找不到相应的ID定义,也不会采用其它语言定义的内容;
  • LANG_ENGLISH, SUBLANG_NEUTRAL 语言定义,并且不采用其它定义;
  • LANG_ENGLISH, SUBLANG_ENGLISH_UK和LANG_BULGARIAN, SUBLANG_NEUTRAL,
  • LANG_CHINESE, SUBLANG_CHINESE_TRADITIONAL、LANG_ENGLISH, SUBLANG_ENGLISH_UK、LANG_BULGARIAN, SUBLANG_NEUTRAL,即其它语言组合,这种情况下就可以开始使用 SetThreadLocale() 函数来指定优先那一个。注意C语言的库函数 setlocale() 不起作用。

新的系统API中有 SetProcessPreferredUILanguages()、SetThreadPreferredUILanguages()、SetThreadUILanguage(),它们可以修改资源在检索时的条件,优先顺序为 Thread > Process > User UI > System UI。

Thread preferred UI languages and its neutral form. 
Process preferred UI languages and its neutral form.
User UI language and its neutral form.
System UI language and its neutral form.
System default UI language and its neutral form.

不要试图使用 SetThreadLocale() 来尝试让 LoadString() 等等函数选择不同语言的资源,在这方面它并不是那么好用。而 FormatMessage() 和 FindResourceEx() 则可以指定语言来检索资源。如果給 FormatMessage() 指定了语言ID,那么它就只会按指定的语言的资源定义中查找。如果没有指定语言ID,那么它会按以下逻辑来检索:

  • Language neutral
  • Thread LANGID, based on the thread’s locale value
  • User default LANGID, based on the user’s default locale value
  • System default LANGID, based on the system default locale value US English
  • returns any language message string that is present.
  • If that fails, it returns ERROR_RESOURCE_LANG_NOT_FOUND.

对于 FindResourceEx(),MSDN文档指出,当使用它来加载二进制数据,就一定需要配合使用 LoadResource() LockResource(),用它来获取字符串数据也是可以的,但会多出来一个二进制头部。想要获取指定语言的资源数据,需要获取通过 LANGUAGE 语句定义的资源,就必需通过 FindResourceEx()函数来定位,它会调用NTDLL的LdrFindResource()来定位资源。由LoadResource()加载的字符内容是相当的难搞,微软官网上一段代码定义了一个函数 GetStringFromStringTable() 处理。基本逻辑是处理前导的头部,通过 LoadResource() 得到的指针类型是 (wchar_t *),即双指针。

如果想要深入资源文件的工作原理,可以去分解PE文件如何组织资源文件,MSDN中的 Specifications => Platforms => Microsoft Portable Executable and Common Object File Format Specification 就是关于PE格式的文档。

这里来了解字各种语言符串定义是如何编译到程序文件中保存的。在资源文件中,每个字符串都有一个ID值,这些值是按每16个连续的号码进行分组,例如0-15这16个ID归为第一组,16-31归为第二组,依此推理。每一组的ID可以用来在各种语言中定义关联一个字符串内容。要计算特定的ID值属于那一组,只要将这个ID整除16再加1就可以得到组号。这定义资源时,即某个组段的ID只有一个有定义内容,在编译时这一个资源定义也要被分成一组。举例来说,0-31这16个ID中,只使用了15、16这两个ID来定义字符串,那么编译后将生成两组内容,即使15、16是两个结连的ID值,但是15\16+1=1,属于第一组,而16\16+1=2,属于第二组,所以它们是两个不同的分组。当你理解到这些的时时候,再来看看MSDN文档 FindResourceEx() 的说明,其中 Remarks 最后一段就是最重要的内容:

String resources are stored in sections of up to 16 strings per section. The strings in each section are stored as a sequence of counted (not null-terminated) Unicode strings. The LoadString function will extract the string resource from its corresponding section.

上面这段话间意思是说,资源中的字符串像这样存储的:

wchar_t * array = { len,L"abcd", len,L"xyz" ...}

在资源文件 .rc 中可以用 Name 或者数值ID 来定义不同的数据类型,在PE的资源分区也有不同的数据保存方式。字符串数据就是 Resource Directory String,即上面构造的这个C语言定义,每一条字符串都是UNICODE编码的,在字符串开始的两个字节指示了长度,然后跟着字符串内容,通过 LoadResource() 方法加载字符串资源时返回的就是这个目录条目的开始地址。

一个典型的PE格式文件,这里指 .exe 可执行程序文件,大体可以分为文件头和分区头信息,分区 Section 是PE文件的基本组织结构。而资源编译后就在 .rsrc Section 中保存,按 Type、Name(ID)、Language 构成三层的有序二叉树数据结构,这三个属性都是用来定位资源具体位置的关键信息,它们就是当成三个ID来使用的。这个二叉树中的节点是一个的资源目录 Resource Directory,目录下还有细分条目指向下一层资源目录,最后一层就定位了具体的资源位置。来看看资源目录和目录下的条目数据结构:

Off. Size  Field             Description 
0    4     Characteristics   Resource flags, reserved, currently set to zero. 
4    4     Time/Date         Stamp Time the resource data was created. 
8    2     Major Version     Major version number, set by the user. 
10   2     Minor Version     Minor version number. 
12   2     Number of Named   Number of directory entries that use strings to identifiers. 
14   2     Number of IDed    Number of directory entries that use numeric identifiers.

Off. Size  Field             Description 
0    4     Name RVA          Address of string that gives the Type/Name/Language identifier.
0    4     Integer ID        32-bit integer that identifies Type/Name/Language. 
4    4     Data Entry RVA    High bit 0. Address of a Resource Data Entry (a leaf). 
4    4     Subdirectory RVA  High bit 1. Lower 31 bits are the address of another Resource Directory.

注意目录条目是8个字节的,分成两个32-bit部分,前一部分可以表示 Name RVA 也可以表示 Integer ID。相对虚拟地址 Relative Virtual Address (RVA) 是指相对基址的一个偏移地址值,比如说 PE 文件加载到内存地址 0x10000 开始的位置,那么如果以这个PE的为基址,而PE中的一个资源目录开始位置在 0x10100,那么RVA就是 0x100。

在资源目录后面跟着和特定数据类型相关的数据,Resource Data Entry,即 Resource Data Description,用来描述资源实际数据的 RVA、数据大小和代码页的信息:

Off. Size Field      Description 
0    4    Data RVA   Address of a unit of resource data in the Resource Data area. 
4    4    Size       Number bytes of the resource data at address [Data RVA].
8    4    Codepage   Code page used to decode code point values within the resource data.
12   4    Reserved   Must be set to 0.

下面构造的这个数据可以更形像地描述资源的数据结构,假设 .rc 文件中定义了以下一组数据:

TypeId#      NameId#      Language ID     Data
   1            1            0            00010001
   1            1            1            10010001
   1            2            0            00010002
   1            3            0            00010003
   2            1            0            00020001
   2            2            0            00020002
   2            3            0            00020003
   2            4            0            00020004
   9            1            0            00090001
   9            9            0            00090009
   9            9            1            10090009
   9            9            2            20090009

通过编译后生成的二进制资源内容对应如下:

Offset  Data
0000:   00000000 00000000 00000000 00030000 (3 Type entries in this directory)
0010:   00000001 80000028   (TypeId #1, Subdirectory at offset 0x28)-------+
0018:   00000002 80000050   (TypeId #2, Subdirectory at offset 0x50)-------|--+
0020:   00000009 80000080   (TypeId #9, Subdirectory at offset 0x80)-------|--|--+
0028:   00000000 00000000 00000000 00030000 (3 NameID entries for this) <--+  |  |
0038:   00000001 800000A0   (NameId #1, Subdirectory at offset 0xA0)------+   |  |
0040:   00000002 00000108   (NameId #2, data desc at offset 0x108)        |   |  |
0048:   00000003 00000118   (NameId #3, data desc at offset 0x118)        |   |  |
0050:   00000000 00000000 00000000 00040000 (4 NameID entries for this) <-|---+  |
0060:   00000001 00000128   (NameId #1, data desc at offset 0x128)        |      |
0068:   00000002 00000138   (NameId #2, data desc at offset 0x138)        |      |
0070:   00000003 00000148   (NameId #3, data desc at offset 0x148)        |      |
0078:   00000004 00000158   (NameId #4, data desc at offset 0x158)        |      |
0080:   00000000 00000000 00000000 00020000 (2 NameID entries for this) <-|------+
0090:   00000001 00000168   (NameId #1, data desc at offset 0x168)        |
0098:   00000009 800000C0   (NameId #9, Subdirectory at offset 0xC0)------|---+
00A0:   00000000 00000000 00000000 00020000 (2 entries in this directory)<+   |
00B0:   00000000 000000E8   (Language ID 0, data desc at offset 0xE8          |
00B8:   00000001 000000F8   (Language ID 1, data desc at offset 0xF8          |
00C0:   00000000 00000000 00000000 00030000 (3 entries in this directory) <---+
00D0:   00000001 00000178   (Language ID 0, data desc at offset 0x178
00D8:   00000001 00000188   (Language ID 1, data desc at offset 0x188
00E0:   00000001 00000198   (Language ID 2, data desc at offset 0x198

[Resource Data Entry from here now]
00E8:   000001A8 00000004 00000000 00000000 (for TypeId #1, NameId #1,Language id #0)
00F8:   000001AC 00000004 00000000 00000000 (for TypeId #1, NameId #1,Language id #1)
0108:   000001B0 00000004 00000000 00000000 (for TypeId #1, NameId #2)
0118:   000001B4 00000004 00000000 00000000 (for TypeId #1, NameId #3)
0128:   000001B8 00000004 00000000 00000000 (for TypeId #2, NameId #1)
0138:   000001BC 00000004 00000000 00000000 (for TypeId #2, NameId #2)
0148:   000001C0 00000004 00000000 00000000 (for TypeId #2, NameId #3)
0158:   000001C4 00000004 00000000 00000000 (for TypeId #2, NameId #4)
0168:   000001C8 00000004 00000000 00000000 (for TypeId #9, NameId #1)
0178:   000001CC 00000004 00000000 00000000 (for TypeId #9, NameId #9,Language id #0)
0188:   000001D0 00000004 00000000 00000000 (for TypeId #9, NameId #9,Language id #1)
0198:   000001D4 00000004 00000000 00000000 (for TypeId #9, NameId #9,Language id #2)

[The raw data from here now]
01A8:   00010001 10010001 00010002 00010003
01B8:   00020001 00020002 00020003 00020004
01C8:   00090001 00090009 10090009 20090009

如果通过 FindResourceEx() 来加载字符串,像下面这样是不会成功的了,即使成功返回了,也只有在ID是1的时候:

FindResourceEx( NULL,RT_STRING,ID,LANG_NEUTRAL );

而正确的使用方法应该是下面这种方式,将字符串的组号作为参数传入,虽然两条语句都没使用MAKEINTRESOURCE这个宏,但这不是重点:

FindResourceEx( NULL,RT_STRING,ID/16+1,LANG_NEUTRAL );

对于这一点MSDN竟然没有示例,只能说他们真会玩啊!FindResource() 也是,需要注意的还有一点,因为资源中的字符是UNICODE编码,所以ASCII字符也是两个字节编码的,因此每个字符的高8位是一个”\0”,即ASCII字符的后一个字节就是C语言的字符串的终结标识,而事实上它不是。到这里,如果想要自己写代码处理UNICODE,那么就要有点《编程编码》的功夫了。如果想省事,那么就用VC提供的 wsprintf() 和 sprintf() 两个函数来转换 UNICODE、ASCII,使用格式化字符 %S,注意是大S,不是小S,输出时用 wprintf()。 WideCharToMultiByte() 和 WideCharToMultiByte() 可以用来在系统使用宽字符和其它多字节编码进行互相转换。在 WINNLS.H 头文件中定义系统所支持的代码预定义符号,如系统默认代码页 CP_ACP,还有 CP_UTF8 等。如果在控制台下进行宽字符做MBS转换时,需要使用 GetConsoleCP()、GetConsoleOutputCP() 这样的API来获控制台设置的代码,前面讲控制台的输入输出时已经提到,到这时几乎想不起来了。在控制台中,如果用户通过CHCP设置了特定的代码页,程序就按控制台设置来变动输出内容的编码。另一个不是方法的办法就是通过 syste() 调用 chcp 命令来读取设置值。

通过搜索头文件还发现 GetCPInfoEx() 这个函数,我使用的99OCT版MSDN上资料也没说它可以查询 CP_THREAD_ACP,倒是官网上有说明,可以用来查询系统预定义的、当前程序设置的代码页信息。这里要分清两个概念,一个是控制台,另一个是控制台中运行的程序。这是两个不同的概念,控制是一个程序的运行环境,看着控制台运行的程序并在控制台中输出内容,会很容易让人分不清这两者的关系。如果通过 chcp 设置了65001及1251等等其它多个代码页来测试,试着查询 CodePage 和 CodePageName 的内容,显然结果不会像预期那样。在我的中文版系统上,默认是 936 代码页,这个没问题。但是当代码被修改成其它的,比如UTF8,结果就不准确了,换其它的代码页也一样,都会显示为 1252 代码页。这个代码页是微软为 Windows 设计的,参考了ANSI草案,后来发展成为 ISO 8859-1 所以称为拉丁语 I:

936   (ANSI/OEM - 简体中文 GBK)
1252   (ANSI - 拉丁语 I)

因为通过 chcp 改变的是控制所使用的代码页,并没有修改程序的默认代码页。倒是通过 SetThreadLocale( LOCALE_USER_DEFAULT ) 这样的函数来改变当前程序的代码页设置,然后可以用 GetCPInfoEx() 检测系统区域和语言的格式选择中设置了什么语言。GetCPInfoEx() 这个函数会受到 SetThreadLocale() 的影响,但是C语言的库 setlocale() 却没有影响,看来它只是在为C库服务的。

综合以上,如果要LoadString()这样的API函数来实现程序的国际化还是比较蛋痛的选择。况且,rc.exe 和 mc.exe 在多语言编译上都有问题,这才是根本的问题。反倒是使用API来获取当前的本地化信息很有用处,再结合自己开发的多语言支持,我是指不依靠系统 API,这种做法才比较靠谱,毕竟自己掌握着源代码。对于系统的API,没有几个人会知道它们都在你的机器上干了些什么,经历过3Q大战的人会深有体会的吧,我是没遇到过那样一种壮烈的情境。以下上自己的 i18n.cpp 测试代码,需要前面定义的资源文件:

/*
 * i18n Demo by Jimbowhy, compile it with TDM-GCC:
   g++ -o i18n i18n.cpp resource.res -lgdi32 -lkernel32 -ladvapi32 -lwinmm && i18n
 */

#include <cstring>
#include <cstdlib>
#include <cstdio>
#include <clocale>
#include <windows.h>
#include <winnls.h>
#include <mmsystem.h>
#include <wchar.h>
#include <tchar.h>
#include "res\res.h"

UINT LoadMessage( DWORD id, DWORD lang_ID, PTSTR buf, UINT size, ...)
{
    va_list  args;
    va_start(args,size);
    return FormatMessage( FORMAT_MESSAGE_FROM_HMODULE,NULL,
        id, lang_ID, buf, size, &args);
    va_end(  args );
}

wchar_t * GetTableString(HMODULE h, UINT ID, DWORD wLanguage=LANG_NEUTRAL)
{
    HRSRC rs = FindResourceEx( h,RT_STRING,MAKEINTRESOURCE(ID/16+1),wLanguage);
    if( !rs ) return NULL;
    HGLOBAL rc = LoadResource( h,rs );
    if( !rc ) return NULL;
    wchar_t * s = (wchar_t*) rc;
    int i = 0, j = 0, c = 0;
    for( i=0; i<=ID%16; i++ ) {
        if( *s ){
            s += c;
            c = *s; // element length
            s++;
        }else s++;
    }
    if( c==0 ) return NULL;
    // The unicode string directory element.
    wchar_t *t = new wchar_t[ c*2 ]; // double size in case UTF8 3byte per MBS' char
    int size = (c*2)*sizeof(wchar_t);
    //wcsncpy( t, s, c );
    memset( t, 0, size );
    WideCharToMultiByte( CP_UTF8,0,s,c,(char *)t,size,NULL,NULL);
    printf("\tL:%2d UTF8:%s \n\t", c, t);
    for(  j=0; j<c; j++) printf("0x%02x ", (unsigned char)((char*)t)[j] );
    memset( t, 0, size );
    WideCharToMultiByte( CP_THREAD_ACP,0,s,c,(char *)t,size,NULL,NULL); //CP_ACP
    printf("\n\tL:%2d  ACP:%s \n\t", c, t);
    for(  j=0; j<c; j++) printf("0x%02x ", (unsigned char)((char*)t)[j] );
    printf("\n");
    return t;
}

int main(){

    printf("\n--------====== use resource string & i18n ======---------\n");
    DWORD   DEU = MAKELANGID( LANG_GERMAN, SUBLANG_GERMAN );
    DWORD  enNU = MAKELANGID( LANG_ENGLISH,SUBLANG_NEUTRAL );
    DWORD  enUK = MAKELANGID( LANG_ENGLISH,SUBLANG_ENGLISH_UK );
    DWORD  enUS = MAKELANGID( LANG_ENGLISH,SUBLANG_ENGLISH_US );
    DWORD  zhCN = MAKELANGID( LANG_CHINESE,SUBLANG_CHINESE_SIMPLIFIED );
    DWORD  zhTW = MAKELANGID( LANG_CHINESE,SUBLANG_CHINESE_TRADITIONAL );
    DWORD en_NU = MAKELCID( enNU, SORT_DEFAULT );
    DWORD en_UK = MAKELCID( enUK, SORT_DEFAULT );
    DWORD en_US = MAKELCID( enUS, SORT_DEFAULT );
    DWORD zh_CN = MAKELCID( zhCN, SORT_DEFAULT );
    DWORD zh_TW = MAKELCID( zhTW, SORT_DEFAULT );

    //setlocale( LC_ALL, "English_UK" );
    //setlocale( LC_ALL, "English_United States.1252" );
    //setlocale( LC_ALL, "chinese-simplified" );
    //setlocale( LC_ALL, "chinese-traditional" );
    //SetThreadLocale( LOCALE_SYSTEM_DEFAULT );
    //SetThreadLocale( LOCALE_USER_DEFAULT );
    SetThreadLocale( zh_CN );


    CPINFOEX  cpi;
    GetCPInfoEx(CP_THREAD_ACP,0,&cpi);
    printf( "GetACP(%d) GetOEMCP(%d) \n", GetACP(), GetOEMCP() );
    printf( "GetCPInfoEx(%x) %s\n", cpi.CodePage, cpi.CodePageName );
    printf( "           GetThreadLocale(0x%x)\n", GetThreadLocale() );
    printf( "    GetSystemDefaultLangID(0x%x)\n", GetSystemDefaultLangID() );
    printf( "      GetSystemDefaultLCID(0x%x)\n", GetSystemDefaultLCID() );
    //printf( "GetSystemDefaultUILanguage(0x%x)\n", GetSystemDefaultUILanguage() );
    printf( "      GetUserDefaultLangID(0x%x)\n", GetUserDefaultLangID() );
    printf( "        GetUserDefaultLCID(0x%x)\n", GetUserDefaultLCID() );
    //printf( "  GetUserDefaultUILanguage(0x%x)\n", GetUserDefaultUILanguage() );

    char rs[1024];
    wchar_t *cs;

    HRSRC hrc = FindResource( NULL, MAKEINTRESOURCE(IDS_HELLO),RT_STRING );
    HGLOBAL hrs = LoadResource( NULL,hrc );
    cs = (WCHAR *)LockResource(hrs);
    printf(" Find: %x %d [%s]\n", hrc, cs, cs );
    hrc = FindResource( NULL, MAKEINTRESOURCE(IDDSTAR/16+1),RT_STRING );
    hrs = LoadResource( NULL,hrc );
    cs = (WCHAR *)LockResource(hrs);
    printf(" Find: %x %d [%s]\n", hrc, cs, cs );

    LoadString( NULL,IDS_HELLO,rs,1024 );
    printf( "LoadString: %s \n", rs );
    LoadString( NULL,IDDSTAR,rs,1024 );
    printf( "LoadString: %s \n", rs );

    cs = GetTableString( NULL,IDS_HELLO, LANG_NEUTRAL);
    printf("GetTableString Neutral: %s \n\n", cs);
    cs = GetTableString( NULL,IDS_HELLO, LANG_ENGLISH );
    printf("GetTableString English: %s \n\n", cs);
    cs = GetTableString( NULL,IDS_HELLO, enUK );
    printf("   GetTableString enUK: %s \n\n", cs);
    cs = GetTableString( NULL,IDS_HELLO, zhTW );
    printf("   GetTableString zhTW: %s \n\n", cs);
    cs = GetTableString( NULL,IDS_HELLO, zhCN );
    printf("   GetTableString zhCN: %s \n\n", cs);
    cs = GetTableString( NULL,IDS_HELLO, 0x0804 );
    printf(" GetTableString 0x0804: %s \n\n", cs);

    cs = GetTableString( NULL,IDDSTAR, LANG_NEUTRAL );
    printf("GetTableString Neutral: %s \n\n", cs);
    cs = GetTableString( NULL,IDDSTAR, LANG_ENGLISH );
    printf("GetTableString English: %s \n\n", cs);
    cs = GetTableString( NULL,IDDSTAR, enUK );
    printf("   GetTableString enUK: %s \n\n", cs);
    cs = GetTableString( NULL,IDDSTAR, zhCN );
    printf("   GetTableString zhCN: %s \n\n", cs);
    cs = GetTableString( NULL,IDDSTAR, zhTW );
    printf("   GetTableString zhTW: %s \n\n", cs);



    printf("\n--------====== use resource messagetable ======---------\n");
    char app[] = "Res.exe";
    char user[64];
    DWORD ulen = 64;
    GetUserName( user,&ulen );
    LoadMessage( EVENT_STARTED_BY, enUS, rs, 1024, app, user);
    printf("enUS: %s", rs);
    LoadMessage( EVENT_STARTED_BY, DEU, rs, 1024, app, user);
    printf("DEU: %s", rs);
    LoadMessage( EVENT_STARTED_BY, zhTW, rs, 1024, app, user);
    printf("zhTW: %s", rs);
    LoadMessage( EVENT_STARTED_BY, zhCN, rs, 1024, app, user);
    printf("zhCN: %s", rs);
    LoadMessage( EVENT_STARTED_BY, LANG_NEUTRAL, rs, 1024, app, user );
    printf("Neutral: %s" , rs);

    return 0;
}

至于编译命令就直接给出 GNU Make 和 MS NMake 两个平台的自动编译脚本:

#
#  GNU makefile demo by Jimbowhy @ 2016/3/18 14:56:39
#  Usage:
#       mingw32-make BUILD=RELEASE mouse
#       mingw32-make BUILD=RELEASE all
#       mingw32-make all

MC=mc.exe
RC=windres.exe

CC=g++
CFLAGS:=-g
libs:=-lwinmm -lgdi32 -lkernel32

ifeq "$(BUILD)" "RELEASE"
CFLAGS:=-s -O3
endif

ifeq "$(BUILD)" "DYNAMIC"
CFLAGS:=-s -Wl,-Bdynamic
endif

CFLAGS:=$(CFLAGS) -I"." -L"C:\sdks\PSDK2k3SP1\Lib"

all : bitmap mouse resource setTimer

bitmap:
    $(CC) $(CFLAGS) -o bitmap bitmap.cpp $(LFLAGS) $(libs)
mouse:
    $(CC) $(CFLAGS) -o mouse mouse.cpp $(LFLAGS) $(libs)
resource:
    $(MC) -u -U -r .\res -h .\res res\resource.mc
    $(RC) -c=65001 -J rc -O coff -i res\res.rc -o res.obj
    $(CC) $(CFLAGS) -o resource resource.cpp res.obj $(libs)
setTimer:
    $(CC) $(CFLAGS) -o setTimer setTimer.cpp $(LFLAGS) $(libs)

clean:
    del *.obj


#
#  Nmake makefile demo by Jimbowhy @ 2016/3/20 1:58:36
#  Usage:
#      nmake BUILD=RELEASE mouse
#      nmake BUILD=RELEASE all
#      nmake all

CC=cl -c /ZI /Yd /MLd /Od /D "DEBUG" /nologo -c -GX /D "_MBCS"
CL=link /nologo
MC=mc.exe
RC=rc.exe

CFLAGS=/I"C:\Program Files (x86)\Microsoft Visual Studio\VC98\Include"
LFLAGS=/LIBPATH:"C:\Program Files (x86)\Microsoft Visual Studio\VC98\lib"
libs=winmm.lib gdi32.lib user32.lib advapi32.lib kernel32.lib

#CFLAGS=$(CFLAGS) /I"C:\sdks\PSDK2k3SP1\Include"
#LFLAGS=$(LFLAGS) /LIBPATH:"C:\sdks\PSDK2k3SP1\Lib" 

!IF "$(BUILD)" == "RELEASE"
CC=cl -c /D "NDEBUG" /nologo -GX /D "_MBCS"
!ENDIF

all : bitmap mouse resource setTimer

bitmap:
    $(CC) $(CFLAGS) -o bitmap bitmap.cpp
    $(CL) $(LFLAGS) bitmap.obj $(libs)
mouse:
    $(CC) $(CFLAGS) -o mouse mouse.cpp
    $(CL) $(LFLAGS) mouse.obj $(libs)
record:
    $(CC) $(CFLAGS) -o record record.cpp
    $(CL) $(LFLAGS) record.obj $(libs)
resource:
    $(MC) -u -U -r .\res -h .\res res\resource.mc
    $(RC) /w /fo .\res.obj res\res.rc
    $(CC) $(CFLAGS) -o resource resource.cpp
    $(CL) $(LFLAGS) resource.obj res.obj $(libs)
setTimer:
    $(CC) $(CFLAGS) -o setTimer setTimer.cpp
    $(CL) $(LFLAGS) setTimer.obj $(libs)

clean:
    del *.obj *.idb *.pdb *.res

部分程序的测试输出内容,系统为简体中文,控制台代码页设置为 936:

LoadString: 梦见你来了,还好吗?
LoadString: 《白日梦!》
        L:10 UTF8:姊﹁浣犳潵浜嗭紝杩樺ソ鍚楋紵
        0xe6 0xa2 0xa6 0xe8 0xa7 0x81 0xe4 0xbd 0xa0 0xe6
        L:10  ACP:梦见你来了,还好吗?
        0xc3 0xce 0xbc 0xfb 0xc4 0xe3 0xc0 0xb4 0xc1 0xcb
GetTableString Neutral: 梦见你来了,还好吗?

GetTableString English: (null)

   GetTableString enUK: (null)

        L:10 UTF8:澶㈣浣犱締浜嗭紝閭勫ソ鍡庯紒
        0xe5 0xa4 0xa2 0xe8 0xa6 0x8b 0xe4 0xbd 0xa0 0xe4
        L:10  ACP:夢見你來了,還好嗎!
        0x89 0xf4 0xd2 0x8a 0xc4 0xe3 0x81 0xed 0xc1 0xcb
   GetTableString zhTW: 夢見你來了,還好嗎!

MSDN补充

说到MSDN,最早在 Visual Studio 5.0 即 VS97 的时候,它叫 InfoViewer,文件的格式是 IVT,目录索引文件是 IVI。我能找到的是 Visual Studio 97 MSDN CD191。而国内第一个大量使用可能是1998随中文版 Visual Studio 6.0 发行的,而每一个MSDN和VS却不通过升级安装来共享,反而是单独安装使用,F1功能也只响应特定的版本。这样就好浪费磁盘空间,因为每个MSDN都是上GB的。其实 HTMLHelp,Windows 程序的的F1上下文帮助是通过 VSHELP.DLL、hhctrl.ocx 组件实现的,DLL位置 Common Files 目录下,丢失它们会导致帮助内容无法显示。通过 Sysinternals 的 Process Monitor 工具就可以追踪到这些信息。在安装MSDN时,会在注册表上记录MSDN的目录COL文件位置:

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft
        \HTML Help Collections\Developer Collections\0x0804]
[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft
        \HTML Help Collections\Developer Collections\0x0804\0x0358e0f00]
"Filename"="C:\\Program Files (x86)\\Microsoft Visual Studio
        \\MSDN98\\98VS\\2052\\msdnvs98.col"
@="MSDN Library - Visual Studio 6.0"
"Full"=dword:00000001

下面有各种语言的设置,其中简体中文就是 0x0804,转换成10进制就是 2052。至于为什么用这个代码表示简体中文,这是因为程序的国际化功能,不同的地区编排了一个本地化标识 locale identifier (LCID),它就像生活中使用的邮编一个道理。

zh-cn    0x0804    2052     中文 - 中华人民共和国   
zh-hk    0x0C04    3076     中文 - 香港特别行政区   
zh-sg    0x1004    4100     中文 - 新加坡           
zh-tw    0x0404    1028     中文 - 台湾地区         
ja       0x0411    1041     日语                    
ko       0x0412    1042     朝鲜语                  
en-gb    0x0809    2057     英语 - 英国             
en-us    0x0409    1033     英语 - 美国             
es       0x040A    1034     西班牙语 - 标准         
fr       0x040C    1036     法语 - 标准             
de       0x0407    1031     德语 - 标准             
ru       0x0419    1049     俄语                    
th       0x041E    1054     泰语

为了使用MSDN99OCT,需要增加注册表设置,拷贝上面的内容,只需要将本地化代码修改为 0x0809,然后将目录文件指向对应的 MSDN930.COL,添加注册信息后,打开 Visual Studio 的选项设置,在帮助系统栏目中,就可以看到英文版的MSDN了,在安装MSDN 99OTC时,它会在公共目录下安装一个工具 COLCHNG.EXE,它就可以用来选择MSDN版本,不过还是要手动添加注册表信息。看图,是不是感觉有天都亮了,再见了MSDN98!再见了2052!

Visual Studio 6.0 补充

编译代码使用的工具是 TDM-GCC 4.71,由于它包含的头文件内容和 VC6 不同,而且是 include 上的功能也有所差别。所以代码改用 CL.exe 编译时可能会有问题,比如我尝试过的几个例子。GCC 只要引用 cstring 和 iostream 就可以使用 cout 的输出重载操作符了,因为通过 g++ 编译时它会自动添加 C++ 的标准类库:

#include <iostream>
#include <cstring>

但是在VC6下就要包含C++的字符串头文件:

#include <string>

否则,就会出离奇的编译错误,因为编译会当移位运算符来处理:

mouse.cpp(241) : error C2679: binary '<<' : no operator defined which takes a ri
ght-hand operand of type 'class std::basic_string<char,struct std::char_traits<c
har>,class std::allocator<char> >' (or there is no acceptable conversion)

另外提醒一下,如果不注意C语言和C++语言的头文件引用语法差别也会导致编译不能通过。以标准库文件为例,C语言的头文件后面有 .h 扩展名,而C++的没有,所以下面这条引用会被当成C语言来处理:

#include <iostream.h>

如果要想在C++中使用C语言的库文件,最佳的引用方法是去掉后缀名,加前缀c,如C语言的数学库文件:

#include <cmath>

这样看代码就可以知道这是在用C++写的程序,如果要用C语言写则可以后缀 .h 来引用库文件。但是尽量不要混用,尽管 C++ 兼容 C。

直接在代码字符中使用中文多字节符号也会出现常量断行的错误,只要中文编码中出现 0xE2 0x80 0xA2 这些值,就会有这个错误提示,比如说中文的句号它在UTF8编码时就会包含一个 0x80,还有中的一字也是一样。这个只能算是VC6的八啊哥了:

error C2001: newline in constant

这个问题即使引用 TCHAR.H 的 _T、_TCHAR 也不能解决。如果一定要在代码中使用中文,可以考改变代码文件的编译方案,列如 UTF8,GBK,UNICODE 互换试试,也许就可以通过编译,但是字符串就要求相应的编译支持,否则程序输出就会乱码。更好的解决方法是使用资源文件,通过 rc.exe 命令来编译:

RC.EXE  /l 0x804 /c 936 /d_DEBUG /fo.\GENERIC.RES /r GENERIC.RC

资源参考

  • MSDN - Platform SDK: Windows GDI BITMAPINFO
  • WiKI - BMP file format
  • Microsoft Windows Bitmap File Format Summary
  • use GDI to draw on the console window
  • 256-Color VGA Programming in C
  • DOS development tool - DJGPP 2.0
  • Graphics Programming Using VGA Mode 13h
  • Top-Down vs. Bottom-Up DIBs
  • Guide to Image Composition with MsImg32.dll - Paul M. Watt
  • 透明位图的显示 - 王骏 (光栅操作太过复杂,没能说明白SetBkColor的作用)
  • Platform SDK Redistributable: GDI+
  • Windows® Server 2003 SP1 Platform SDK ISO
  • Using mc.exe message resources and the NT event log - Daniel Lohmann
  • Get World-Class Noise and Total Joy from Your Games - Dave Edson
  • Developing International Software for Windows 95 and Windows NT - Nadine Kano
  • How to localize an RC file?
  • How To Use LoadResource to Load Strings
  • User Interface Language Management (Windows)
  • 深入了解Windows句柄到底是什么 - 文洲
  • 侦测程序句柄泄露的统计方法 - 梅 胜,王 宁,杨承川
  • Programming Windows 3.1, Third Edition by Charles Petzold
  • Managing Virtual Memory 1993 - Randy Kath

本文标签: 位图 图像处理