BMP位图在MDIClient窗口背景绘制:调色板与GDI编程实践
2026/9/14 1:27:00 网站建设 项目流程

简介:这是一份面向 Windows 平台 C++ 开发者的位图与调色板源代码示例包,聚焦在 MDI 多文档界面中加载、显示与处理 BMP 位图,适合学习 MFC 框架、GDI 绘图和图像文件格式的初中级程序员。压缩包共32个文件,以 10 个 H 头文件和 9 个 CPP 源文件为核心,配合 6 张 BMP 位图、2 个图标文件及工程配置文件,整体约 54KB。通过阅读工程源码,可以掌握 BITMAPFILEHEADER/BITMAPINFOHEADER 结构解析、8 位图像调色板创建与颜色索引映射、与设备上下文相关的 BitBlt 绘图调用,以及 MDI 子窗口的创建与管理流程。同时也能看到文件 I/O 操作、内存分配和错误处理等在真实商业项目中的组织方式。包内还包含一个可编译的 MdiEx 示例工程,适合直接运行观察位图显示效果并逐行学习实现思路。已有 105 人学习,适合希望在桌面应用或图像处理方向积累底层图形经验的开发者。

1. 位图与调色板:MDIClient 窗口里贴图,真正的门槛是系统调色板

早年做 MDI 桌面工具,比如图纸预览器、图像管理器,都喜欢在空白客户区放一张位图当“门面”。这个需求听着简单,实际做起来坑不少:BMP 的调色板不能直接丢给 GDI 用,8 位位图是索引色,得先在内存里建立逻辑调色板,再通过 SelectPalette 和 RealizePalette 映射到系统调色板,顺序错了整张图就发灰、发绿或者花屏。bmp_in_mdiclient2.zip 这套题目把场景点得很明确:bmp 表示位图加调色板,mdiclient 表示它要显示在 MDI 框架的客户端宿主窗口里,后缀 2 往往意味着这套代码至少迭代过一次。适合 C/C++、Win32 SDK 或 MFC 方向的桌面开发者,尤其是做遗留系统维护和影像工具的人。

2. 先读透 bmp 头文件:文件头、信息头与调色板表的边界关系

2.1 14 字节文件头与 40 字节信息头

BMP 的读取顺序不能乱。文件最前面的 BITMAPFILEHEADER 是 14 字节,接着是 BITMAPINFOHEADER(通常是 40 字节,也有 12 字节的旧版 BITMAPCOREHEADER),再往后才是调色板和像素数据。文件头里的 bfOffBits 字段记录的是像素数据的起始偏移,这个值才是实际读像素时真正依赖的“路标”。

#pragma pack(push, 1) typedef struct tagBITMAPFILEHEADER { WORD bfType; // 'BM',0x4D42,不是 BM 直接拒收 DWORD bfSize; // 整个文件大小,字节数 WORD bfReserved1; // 必须为 0 WORD bfReserved2; // 必须为 0 DWORD bfOffBits; // 从文件头到像素数据的偏移 } BITMAPFILEHEADER; #pragma pack(pop) typedef struct tagBITMAPINFOHEADER { DWORD biSize; // 本结构大小,固定 40 LONG biWidth; // 像素宽度 LONG biHeight; // 像素高度,负数表示自上而下 WORD biPlanes; // 恒为 1 WORD biBitCount; // 1/4/8/16/24/32 DWORD biCompression; // 0=不压缩,1=RLE8,2=RLE4 DWORD biSizeImage; // 像素数据字节数,BI_RGB 时可为 0 LONG biXPelsPerMeter; // 通常忽略 LONG biYPelsPerMeter; DWORD biClrUsed; // 使用的颜色数,0 表示取 2^biBitCount DWORD biClrImportant; // 重要颜色数,通常 0 } BITMAPINFOHEADER; #pragma pack(pop)

这段代码里最值得强调的是 bfOffBits。很多自写的 BMP 生成工具会在这个字段上偷懒,把像素数据紧挨着信息头放,但如果调色板存在,bfOffBits 必须等于 14 + 40 + 调色板字节数。读取时不要用固定公式算,直接在内存映射文件里以 bfOffBits 为起点,能少踩很多解析不对齐的坑。biHeight 为正时是自底向上的行序,这是 BMP 最容易被新手弄反的地方。

2.2 调色板表:RGBQUAD 数组与 biClrUsed 的真实含义

调色板区不是 GDI 的 HPALETTE,它只是文件里的一个 RGBQUAD 数组。每个 RGBQUAD 是 4 字节,最后那个字节是保留位,通常填 0。颜色表项数在逻辑上等于 1 << biBitCount,但 biClrUsed 非零时以它为准。下表是常见位深对应的处理分支:

biBitCount颜色表项数调色板区字节数说明
128单色位图
4166416 色,可带 RLE4 压缩
82561024最典型的调色板位图
1600无调色板,像素是 RGB 555/565
2400真彩色,每像素三字节
3200真彩色带 Alpha 或填充字节

我一般会在解析时统一走 BITMAPINFO 结构:把文件头的 14 字节跳过,剩余部分当作 BITMAPINFO 的连续内存,后面跟着的 RGBQUAD 就是 bmiColors。这样做的好处是后续调用 CreateDIBitmap、CreateDIBSection 时,指针可以直接传给 Windows API,不需要再拷贝一份结构体。

解析调色板的常见做法是先把信息头读出来,再根据上面的表格申请调色板缓存,然后从文件偏移 14 + biSize 处开始读入。需要注意旧程序的 BMP 可能是 12 字节的 BITMAPCOREHEADER,这时调色板用的是 RGBTRIPLE(3 字节),处理这类文件时要做兼容分支,否则颜色表整体偏移会差 256 字节。

2.3 8 位位图与“通道图”:制作 bmp 通道图时的调色板语义

调色板位图在存储层面是索引值,真正显示时由调色板把索引映射成 RGB。这个机制在做“bmp 通道图”时非常有用:比如 UI 系统里把一张 8 位 BMP 当作灰度蒙版,索引 0 表示全透明,索引 255 表示不透明,中间值作为半透明系数。这样做的代价是只能用 256 个灰度等级,但换来的是极小的内存占用。

常见做法是先把调色板统一填充成从黑到白的 256 级灰,然后把像素数据按目的地加亮度的方式写入索引。读取端无需关心像素里的“颜色”,只取索引值参与 alpha 混合。这类用法在一些老游戏、地图编辑器和工业 HMI 项目里仍很常见。拿到类似 bmp_in_mdiclient2 这样带调色板的源码时,先看它创建逻辑调色板时的 PALETTEENTRY 填充逻辑,就能判断它的位图到底是“显示用”还是“数据用”。

3. MDIClient 子类化与调色板选择:贴图前先选设备上下文

3.1 MDIClient 是宿主窗口,不是视图窗口

MDI 框架里,框架窗口的客户区被一个类名为 “MDIClient” 的系统窗口占据,所有 MDI 子窗口都排列在这个宿主窗口内部。想在 MDI 客户区放背景位图,不能简单往框架窗口的 WM_PAINT 里画——因为 MDIClient 窗口叠在框架客户区之上,框架画得再漂亮也会被它挡住。

常规方案是子类化 MDIClient 窗口。用 SetWindowLongPtr 把它的窗口过程替换成自己的,原窗口过程保存下来,在不需要特殊处理的消息上直接透传。这样背景绘制、子窗口重排、滚动条联动都还走系统默认逻辑,只是把背景擦除这一步接管过来。

3.2 子类化并接管 WM_ERASEBKGND

子类化代码的核心是:保存旧的窗口过程指针,在 WM_ERASEBKGND 里做自己的绘制,然后返回 TRUE,表示背景已经由我们处理。注意这里不要调用 CallWindowProc 传给旧过程,否则系统默认的灰色填充会先跑一遍,造成一次明显闪烁。

static WNDPROC s_pOldMDIClientProc = NULL; LRESULT CALLBACK MDIClientSubclassProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_ERASEBKGND: // wParam 就是需要绘制的设备上下文 DrawMDIBackground((HDC)wParam, hwnd); return TRUE; // 背景已经画完,不再让系统擦 case WM_SIZE: case WM_PAINT: // 尺寸变化时让背景跟着重绘 InvalidateRect(hwnd, NULL, FALSE); break; default: break; } return CallWindowProc(s_pOldMDIClientProc, hwnd, uMsg, wParam, lParam); } void SubclassMDIClient(HWND hwndMDIClient) { s_pOldMDIClientProc = (WNDPROC)SetWindowLongPtr( hwndMDIClient, GWLP_WNDPROC, (LONG_PTR)MDIClientSubclassProc); }

这段代码里有一个容易被忽略的细节:WM_ERASEBKGND 的 wParam 是系统传入的 HDC,直接在它上面做 BitBlt 即可。WM_SIZE 和 WM_PAINT 分支不是必须的,但加上 InvalidateRect 后窗口大小调整时背景能立即刷新。如果子类化返回 TRUE 后还想要系统处理其他背景相关消息,要把不需要的默认行为全部交给 CallWindowProc,不要自己吞掉。

绘制函数里还要处理 MDI 子窗口覆盖区域。背景绘制在子窗口下面,系统会负责在子窗口重绘时盖住这些区域,所以平铺逻辑不需要考虑子窗口位置,直接整块绘制即可。注意这个全局窗口过程只适合单 MDI 框架窗口的演示,真实工程里每个 MDI 框架各自持有一份窗口过程和位图句柄,可以在创建框架窗口时用 SetProp 按窗口句柄存储。

3.3 SelectPalette 与 RealizePalette 的分工

调色板位图显示出来发灰、发绿,十有八九是这两步没做对。SelectPalette 是把逻辑调色板选进设备上下文,RealizePalette 是把逻辑调色板里的颜色映射到系统调色板。在真彩色系统上,RealizePalette 几乎不做事,但在 256 色兼容模式下,它决定像素的索引色到底映射成哪个 RGB。

void DrawMDIBackground(HDC hdc, HWND hwnd) { RECT rc; GetClientRect(hwnd, &rc); // 只有在系统确实需要调色板的时候才选入 if (GetDeviceCaps(hdc, RASTERCAPS) & RC_PALETTE) { HPALETTE hOld = SelectPalette(hdc, g_hPalette, FALSE); RealizePalette(hdc); SelectPalette(hdc, hOld, FALSE); } HDC hMemDC = CreateCompatibleDC(hdc); HBITMAP hOldBmp = SelectBitmap(hMemDC, g_hBackgroundBmp); for (int y = 0; y < rc.bottom; y += g_bmpHeight) { for (int x = 0; x < rc.right; x += g_bmpWidth) { BitBlt(hdc, x, y, g_bmpWidth, g_bmpHeight, hMemDC, 0, 0, SRCCOPY); } } SelectBitmap(hMemDC, hOldBmp); DeleteDC(hMemDC); }

参数说明:SelectPalette 的第三个参数 bForceBackground,传 FALSE 表示这是前台调色板,窗口在前台时系统会把它的颜色映射优先;传 TRUE 则始终当作后台调色板。RealizePalette 的返回值是实际被映射的调色板条目数,调试时可以用这个值判断颜色是否全部映射成功。

还有两个容易忽略的点。一是这里只需要目标 DC 选入调色板,BitBlt 的色表映射按目标 DC 走;如果后续换成 StretchBlt 或 AlphaBlend,源 DC 最好也选入同一个调色板。二是在 256 色模式下每次 SelectPalette 后都要调 RealizePalette,顺序不能反。很多程序只做了 SelectPalette 就开画,结果系统用最近一次前台窗口的调色板做映射,颜色整体偏移。把这个调用放在 BitBlt 之前,可以保证绘制时用的是刚选入的调色板。

4. 从 bmp 解析到 MDI 背景绘制:一份可运行的 C/C++ 实现

4.1 解析 BMP 并构造逻辑调色板

把第 2 章的解析逻辑和第 3 章的窗口子类化串起来,就是一个完整实现。加载阶段要做的三件事:读文件、构造 BITMAPINFO、创建位图和逻辑调色板。这里的核心是第 2 章提到的 bfOffBits,像素数据偏移必须用它来定位。

bool LoadBMPBackground(LPCTSTR lpszFile) { HANDLE hFile = CreateFile(lpszFile, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); if (hFile == INVALID_HANDLE_VALUE) return false; BITMAPFILEHEADER bmf; DWORD dwRead = 0; ReadFile(hFile, &bmf, sizeof(bmf), &dwRead, NULL); if (bmf.bfType != 0x4D42) { CloseHandle(hFile); return false; } DWORD dwHeaderSize = bmf.bfOffBits - sizeof(BITMAPFILEHEADER); PBYTE pHeader = new BYTE[dwHeaderSize]; ReadFile(hFile, pHeader, dwHeaderSize, &dwRead, NULL); BITMAPINFO *pbmi = (BITMAPINFO*)pHeader; int nColors = 0; if (pbmi->bmiHeader.biBitCount <= 8) { nColors = (pbmi->bmiHeader.biClrUsed != 0) ? pbmi->bmiHeader.biClrUsed : (1 << pbmi->bmiHeader.biBitCount); } // 把像素数据读进内存 DWORD dwPixelsSize = bmf.bfSize - bmf.bfOffBits; PBYTE pPixels = new BYTE[dwPixelsSize]; SetFilePointer(hFile, bmf.bfOffBits, NULL, FILE_BEGIN); ReadFile(hFile, pPixels, dwPixelsSize, &dwRead, NULL); CloseHandle(hFile); // 使用 DIB Section 避免额外的 DDB 转换 g_hBackgroundBmp = CreateDIBSection(NULL, pbmi, DIB_RGB_COLORS, (VOID**)&g_pPixelBits, NULL, 0); memcpy(g_pPixelBits, pPixels, dwPixelsSize); if (nColors > 0) { g_hPalette = BuildPaletteFromBMI(pbmi, nColors); } delete[] pHeader; delete[] pPixels; return true; }

这段代码的要点有四个。一是用 bfOffBits 减去文件头大小得到信息头加调色板的总大小,避免手动拼 14 字节的偏移。二是 8 位以下才需要创建调色板,24/32 位直接构造 DIB Section 即可。三是用 CreateDIBSection 分配像素缓冲区,后面 memcpy 直接写进系统管理的位图内存,性能比 CreateDIBitmap 再 SelectObject 的方式高。四是不需要额外做 DDB 转换,DIB Section 天然兼容 BitBlt。

4.2 从 BITMAPINFO 构建逻辑调色板

BuildPaletteFromBMI 是这套实现里最容易写错的地方。LOGPALETTE 结构体在声明时要额外分配 PALETTEENTRY 数组的内存,不能直接栈上定长。调色板条目数量超过 256 时,结构的标准写法是把可变长度数据放在最后一个字段后面。

HPALETTE BuildPaletteFromBMI(BITMAPINFO *pbmi, int nColors) { if (nColors <= 0 || nColors > 256) return NULL; LOGPALETTE *pLogPal = (LOGPALETTE*)malloc( sizeof(LOGPALETTE) + nColors * sizeof(PALETTEENTRY)); pLogPal->palVersion = 0x300; // Windows 3.0 以上必须 pLogPal->palNumEntries = (WORD)nColors; for (int i = 0; i < nColors; i++) { pLogPal->palPalEntry[i].peRed = pbmi->bmiColors[i].rgbRed; pLogPal->palPalEntry[i].peGreen = pbmi->bmiColors[i].rgbGreen; pLogPal->palPalEntry[i].peBlue = pbmi->bmiColors[i].rgbBlue; pLogPal->palPalEntry[i].peFlags = 0; // 正常闪烁行为 // 需要防止抖动的图像可以设 PC_NOCOLLAPSE } HPALETTE hPal = CreatePalette(pLogPal); free(pLogPal); return hPal; }

参数说明:palVersion 固定填 0x300,在较新的 Windows 版本上填低了会导致 CreatePalette 失败。PALETTEENTRY 里的 peFlags 在动画调色板场景下可以填 PC_RESERVED,告诉系统这个条目不允许被其他窗口的调色板映射走,用于避免闪烁;但代价是其他窗口无法使用该颜色槽,桌面环境下一般保持 0。调色板创建失败时,可以尝试用 GetNearestPaletteIndex 配合 GetDeviceCaps 判断当前显示模式是否低于 15 位色。

注意:8 位位图解析为 DIB Section 后,如果后续还要保存回文件,像素行必须按 4 字节对齐,否则 biSizeImage 会和实际写入长度不一致,读出来的文件边缘会出现斜条纹。

4.3 位深分支与常见误用

实际接入时,位深决定了两条完全不同的路径,很多“bmp 图像显示花屏”的问题都出在分支判断上:

场景应当走的路径常见误用
8 位 BMP解析调色板,创建逻辑调色板,绘制前 SelectPalette + RealizePalette直接用 CreateBitmap 加载,颜色被系统当作 DDB 处理
24 位 BMP跳过调色板,直接 CreateDIBSection,像素按 BGR 排列把像素当 RGB 顺序处理,红蓝交换
32 位 BMP同 24 位,但需要确认 Alpha 通道是否有效直接拷给 24 位表面,透明度丢失

坐标系也是值得单独提的:BMP 默认自底向上,负高度才是自上而下。如果背景图是从网络或美术工具流转过来的,十有八九是自上而下的 PNG 习惯,转成 BMP 时不修正 biHeight 符号,显示出来就是上下颠倒。标题里的后缀 2,从迭代角度推测,这种一版修不清的坑通常就包括调色板映射和 DDB/DIB 混用问题。

5. 调色板闪烁与偏色的三个排错点,以及 8 位位图的迁移技巧

5.1 调色板闪烁的根因:WM_ERASEBKGND 的擦除顺序

背景闪烁的常见原因是系统先按默认行为把窗口擦成灰色,再执行子类化里的绘制代码。处理办法是在子类化过程中直接返回 TRUE,告诉系统“背景我已经画完了”,不要走 DefWindowProc 的灰色填充。同时,如果要在 WM_PAINT 和 WM_ERASEBKGND 两处都绘制,必须保证两处逻辑一致,否则每次重绘会交替出现两种色调。

256 色模式下还有一类闪烁来自调色板映射竞争:两个窗口各持有一个逻辑调色板,前台窗口切换时会触发系统重新映射,背景图颜色短暂跳变。给背景位图的 PALETTEENTRY 加上 PC_RESERVED 可以锁住颜色槽,这是老游戏图像稳定显示的标准做法。

5.2 用 GetSystemPaletteEntries 验证颜色映射

调色板是否生效,可以调用 GetSystemPaletteEntries 拿回系统物理调色板,和 BMP 文件里的调色板逐项对比:

PALETTEENTRY sysPal[256]; UINT n = GetSystemPaletteEntries(hdc, 0, 256, sysPal); // n 表示系统调色板实际条目数 // 256 色模式下通常能拿到 256 项,真彩色下通常为 0

验证时看三个点:返回值为 0 说明当前显示模式不需要调色板,代码走 RC_PALETTE 分支也不会执行;返回值小于 256 说明系统保留了部分颜色(常见的 20 色系统保留),背景图某些颜色会被替换;逐项比对颜色,偏差较大的项说明调色板没有正确 Realize。

5.3 旧 8 位调色板位图的迁移做法

新代码里我一般直接走 32 位路线:加载 8 位 BMP 后,把像素里的索引值去查调色板,输出为 BGRA32 表面,alpha 通道根据需要填 0xFF 或按灰度索引映射成半透明值。这样就不再需要 SelectPalette、RealizePalette 这些环节,也避免了 256 色模式下显示色偏。改动代价是内存占用从 256KB 涨到 1MB 左右,对现在的影响可以忽略。

迁移时的兼容技巧是保留一份离散的调色板表,把 8 位索引直接作为查找表的键,避免每像素做 RGB 差值运算。这样原有美术素材不用重存,只是在加载时多一层转换,和 bmp_in_mdiclient2 里那种“选入调色板、映射系统色、再 BitBlt”的经典路径相比,省掉了运行时调色板状态管理。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询