news 2026/10/23 6:35:47

MFC C++屏幕截图:GDI BitBlt完整实现与常见问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MFC C++屏幕截图:GDI BitBlt完整实现与常见问题

简介:这份资源面向使用MFC与Visual Studio从事C++桌面开发的程序员,提供了一个可直接运行的屏幕截图示例工程,解决了在Windows环境下捕获全屏或指定窗口并保存为BMP/JPEG等格式的核心需求。压缩包共包含22个文件,大小约136KB,其中6个h头文件与3个cpp源文件构成主要代码框架,rc和ico用于界面与图标资源,另有vcxproj、sln等工程配置,以及完整的Debug/Release编译记录,便于直接打开项目查看或二次扩展。借助这段示例,读者能深入理解GDI设备上下文、CDC、CBitmap、BitBlt等关键API的配合方式,并掌握从获取屏幕DC、创建兼容位图、复制像素到释放资源的完整链路。资源已有636人学习浏览,尤其适合刚接触MFC图形编程、希望以最小成本掌握截屏原理的初学者,也可作为定时截屏、指定窗口截屏等进阶功能的改造起点。

1. MFC 加 C++ 的程序要截屏:这份资源解决的是“从哪下手”

MFC 加 C++ 的桌面程序,跑着跑着就要留一张现场图,这个需求看起来小,真动手又挺烦。你可以在 VS 里新建一个对话框工程,从GetDC、CreateCompatibleDC、CreateCompatibleBitmap、BitBlt一路写下来,每一步都可能踩坑,尤其是“截出来是黑屏”和“文件打不开”。这份资源包里的 MFCApplication1 工程,就是一条已经走通的截屏路径:把整个屏幕抓成位图,再落盘成 BMP 或 JPEG。适合两类人用:一类是在老 MFC 系统里加截屏功能的一线开发,一类是把 GDI 设备上下文当入门题的 C++ 学习者。它不是个只能跑的黑匣子,工程结构、消息函数、保存函数都可以按着自己的需求改。

2. 为什么用 GDI 而不是直接读像素:先弄懂设备上下文

2.1 屏幕不是一块内存,而是一串“设备上下文”

Windows 下绘制界面默认走 GDI(Graphics Device Interface),MFC 对 GDI 做了一层包装。屏幕、窗口、打印机、内存画布,在 GDI 眼里都叫“设备上下文”(Device Context,简称 DC)。它不是一块能让你直接memcpy的内存,而是一个绘图表面的句柄,所有基于 GDI 的绘制、复制、重绘都得通过这些 DC 来排队。

MFC 里的CDC类就是 GDI 设备上下文的 C++ 封装,CBitmap则封装了HBITMAP位图句柄。很多人误以为“截屏”就是把屏幕像素读到一个数组里,实际上流程是:先借一个屏幕 DC,再建一个内存 DC,再建一张和屏幕格式兼容的位图,最后用BitBlt把屏幕 DC 上的像素复制到内存 DC 关联的位图里。

这里有个非常关键的细节:CreateCompatibleDC创建出来的内存 DC,默认只带一张 1x1 像素的单色位图。你必须用SelectObject把一张真正够大的HBITMAP选进去,之后BitBlt才有地方落笔。否则就算代码看着没报错,截出来的图也大概率是黑块或者只有左上角一个小点。

2.2 从 GetDC 到 BitBlt:五个函数把屏幕搬进位图

截屏最核心的调用序列是固定的,先写出来:

// 1. 拿到整个屏幕的设备上下文,NULL 表示桌面 HDC hScreenDC = ::GetDC(NULL); // 2. 主屏宽高,注意这里拿的是主屏,不是虚拟桌面 int cx = ::GetSystemMetrics(SM_CXSCREEN); int cy = ::GetSystemMetrics(SM_CYSCREEN); // 3. 创建和屏幕 DC 兼容的内存 DC HDC hMemDC = ::CreateCompatibleDC(hScreenDC); // 4. 创建和屏幕 DC 兼容的位图,像素格式会自动跟着屏幕走 HBITMAP hBmp = ::CreateCompatibleBitmap(hScreenDC, cx, cy); // 5. 把位图选进内存 DC,替换掉内存 DC 默认的 1x1 位图 HGDIOBJ hOldBmp = ::SelectObject(hMemDC, hBmp); // 6. 从屏幕 DC 左上角复制 cx * cy 像素到内存 DC BOOL bCopied = ::BitBlt(hMemDC, 0, 0, cx, cy, hScreenDC, 0, 0, SRCCOPY); // 清理顺序不要乱:先还原旧对象,再删位图,再删内存 DC,最后归还屏幕 DC ::SelectObject(hMemDC, hOldBmp); ::DeleteObject(hBmp); ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC);

BitBlt的参数看着多,其实分成四组:前两个是目标 DC 的起始坐标,中间两个是复制区域的宽高,第五个是源 DC,再后面两个是源 DC 的起始坐标,最后一个SRCCOPY是光栅操作码,表示直接把源像素原样复制过来,不做任何位运算。这里的“兼容”指的是颜色深度和像素格式一致,所以CreateCompatibleBitmap必须用屏幕 DC 来创建,不能拿内存 DC 去创建,否则颜色格式可能错位。

提示:GetDC(NULL)借来的 DC 要归还,用ReleaseDC(NULL, hScreenDC);CreateCompatibleDC创建出来的 DC 是自己拥有的,用DeleteDC销毁。借来的还回去,自己建的销毁,这个配对习惯能省掉后面 80% 的 GDI 泄漏排查。

3. 落地实现:把截屏代码接进 MFCApplication1 的对话框工程

3.1 从工程结构里找到接线点

资源包里是一个完整的 VS 解决方案:MFCApplication1.sln是解决方案入口,MFCApplication1Dlg.cpp和MFCApplication1Dlg.h是对话框主窗口实现,resource.h管控件 ID,MFCApplication1.rc管对话框资源和按钮布局。

打开工程后,先在资源编辑器里找到主对话框,往上面拖一个按钮,把按钮 ID 改成IDC_BTN_CAPTURE,标题写成“截屏”。然后右键按钮添加BN_CLICKED消息处理函数,VS 会自动在MFCApplication1Dlg.cpp里生成一个OnBnClickedBtnCapture函数,截屏代码就写在这个入口里。如果你拿到的版本已经带了截屏按钮,直接把下面的函数体替换进去就行。

我一般会先在OnInitDialog里加一句SetWindowText(_T("MFC 截屏 Demo")),确认界面能起来,再点按钮看结果。不要一上来就跑一遍完整逻辑,因为截屏失败往往分不清是设备上下文问题还是保存文件问题。

3.2 完整截屏函数:GetDC 到 BitBlt 的代码与参数说明

下面这段是对话框类里的成员函数,输入参数是保存路径和保存格式:

BOOL CMFCApplication1Dlg::CaptureScreenToFile(LPCTSTR szPath, BOOL bSaveJpeg) { // 整个屏幕的设备上下文 HDC hScreenDC = ::GetDC(NULL); if (hScreenDC == NULL) return FALSE; // 主屏像素宽高,若想抓多屏,这里要换虚拟桌面尺寸 int nW = ::GetSystemMetrics(SM_CXSCREEN); int nH = ::GetSystemMetrics(SM_CYSCREEN); // 内存 DC 和兼容位图 HDC hMemDC = ::CreateCompatibleDC(hScreenDC); HBITMAP hBmp = ::CreateCompatibleBitmap(hScreenDC, nW, nH); if (hBmp == NULL) { ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC); return FALSE; } // 重要:必须先选入位图,BitBlt 才有真正的像素存储目标 HGDIOBJ hOldBmp = ::SelectObject(hMemDC, hBmp); // 复制屏幕像素 BOOL bCopied = ::BitBlt(hMemDC, 0, 0, nW, nH, hScreenDC, 0, 0, SRCCOPY); // 清理资源,顺序不能倒 ::SelectObject(hMemDC, hOldBmp); ::DeleteDC(hMemDC); ::ReleaseDC(NULL, hScreenDC); if (!bCopied) { ::DeleteObject(hBmp); return FALSE; } // 保存位图 BOOL bSaved = SaveBitmapToFile(hBmp, szPath, bSaveJpeg); // 位图是 CreateCompatibleBitmap 创建的,最终由 DeleteObject 销毁 ::DeleteObject(hBmp); return bSaved; }

这段代码的关键点是错误检查:GetDC失败返回 NULL,CreateCompatibleBitmap也可能失败。很多人写截屏只检查BitBlt返回值,但内存 DC 或位图创建失败时,BitBlt根本不会被正常调用,程序会在后面SelectObject时崩溃。还有一点,hBmp在SelectObject(hMemDC, hOldBmp)之后才删,是因为位图还处于选中状态时,DeleteObject可能失败,造成 GDI 对象泄漏。

bSaveJpeg这个参数不是必须的,我看你习惯用扩展名判断也行。工程里最稳的做法是让保存函数根据szPath的后缀决定走 BMP 还是 JPEG,避免传参和扩展名不一致。

3.3 位图落盘:BMP/JPEG 保存的两个落地方案

保存位图最容易翻车,因为HBITMAP在内存里是 GDI 对象,不是直接裸像素数组。最省事的方案是用CImage,它能把HBITMAP通过Attach接管过来,再用Save按扩展名写文件:

#include <atlimage.h> BOOL SaveBitmapToFile(HBITMAP hBmp, LPCTSTR szPath, BOOL bSaveJpeg) { CImage img; img.Attach(hBmp); // 把 HBITMAP 交给 CImage 处理 HRESULT hr = img.Save(szPath); // 扩展名 .bmp 走 BMP,.jpg 走 JPEG img.Detach(); // 之后 HBITMAP 仍由调用方负责删除 return SUCCEEDED(hr); }

CImage::Save的返回值是HRESULT,不能只判断 TRUE/FALSE。如果目标机器用的是精简版系统,或者你的工程没启用 ATL/GDI+,这招可能失灵,那就退回手动写 BMP 文件。手动保存 BMP 的核心是补齐文件头,否则图片查看器打开直接报损坏:

BOOL SaveBitmapToBmp(HBITMAP hBmp, LPCTSTR szPath) { BITMAP bm = {0}; if (!::GetObject(hBmp, sizeof(BITMAP), &bm)) return FALSE; // 每行像素按 4 字节对齐 DWORD cbRow = ((bm.bmWidth * bm.bmBitsPixel + 31) / 32) * 4; DWORD cbPixels = cbRow * bm.bmHeight; BITMAPINFOHEADER bih = {0}; bih.biSize = sizeof(BITMAPINFOHEADER); bih.biWidth = bm.bmWidth; bih.biHeight = bm.bmHeight; // 正数表示自下而上存储 bih.biPlanes = 1; bih.biBitCount = bm.bmBitsPixel; bih.biCompression = BI_RGB; bih.biSizeImage = cbPixels; std::vector<BYTE> vecBits(cbPixels); HDC hDC = ::GetDC(NULL); int nRows = ::GetDIBits(hDC, hBmp, 0, bm.bmHeight, vecBits.data(), (BITMAPINFO*)&bih, DIB_RGB_COLORS); ::ReleaseDC(NULL, hDC); if (nRows != bm.bmHeight) return FALSE; BITMAPFILEHEADER bfh = {0}; bfh.bfType = 0x4D42; // "BM" bfh.bfSize = sizeof(BITMAPFILEHEADER) + sizeof(BITMAPINFOHEADER) + cbPixels; bfh.bfOffBits = sizeof(BITMAPFILEHEADER) + sizeof(BITMAPINFOHEADER); try { CFile file; if (!file.Open(szPath, CFile::modeCreate | CFile::modeWrite)) return FALSE; file.Write(&bfh, sizeof(bfh)); file.Write(&bih, sizeof(bih)); file.Write(vecBits.data(), cbPixels); file.Close(); } catch (CFileException* e) { e->Delete(); return FALSE; } return TRUE; }

这段代码里最容易漏的是BITMAPFILEHEADER.bfType = 0x4D42,这个字节序是固定的,写反了文件头会变成乱码。GetDIBits负责把 GDI 位图转成 DIB 字节流,它的hDC参数也必须是和位图兼容的 DC,我习惯用GetDC(NULL),用完了立刻还回去。如果你保存的是 16 位高色或带调色板的位图,bih.biBitCount要按实际位数填,同时考虑调色板,我在工程里通常强制转 24 位,省得后面读图代码要处理各种颜色表。

4. 避坑与排查:截屏最常见的 5 个翻车现场

4.1 截出来是黑屏:兼容位图根本没选进内存 DC

现象:程序能跑,保存的 BMP 打开后全黑,没有一点界面内容。

原因:最常见的是BitBlt执行前没做SelectObject(hMemDC, hBmp),内存 DC 还停留在默认的 1x1 单色位图上。另一种情况是CreateCompatibleBitmap用的是内存 DC 而不是屏幕 DC,导致位图格式和屏幕不兼容,复制结果异常。

解决:严格按“屏幕 DC → 内存 DC → 兼容位图 → 选入 → BitBlt → 还原 → 清理”的顺序执行。代码写成HBITMAP hBmp = ::CreateCompatibleBitmap(hScreenDC, nW, nH);不要用hMemDC作为参数。如果远程桌面或锁屏状态下截图黑屏,那是会话层限制,和代码无关,需要改用PrintWindow或者从目标进程内部抓取窗口内容。

4.2 保存出来的文件打不开:只存了像素没写文件头

现象:文件生成了,但图片查看器提示“文件已损坏”或“无法识别的格式”。

原因:手动保存 BMP 时漏掉了BITMAPFILEHEADER,或者bfType字段没写成0x4D42。还有一种情况是调用CImage::Save时扩展名和格式不一致,比如路径写.bmp但函数内部强制用 JPEG 编码器,文件头就变成了 JPEG 头,预览器会识别不了。

解决:手动写 BMP 时先写BITMAPFILEHEADER,再写BITMAPINFOHEADER,最后写像素数据。用CImage::Save时让文件扩展名决定格式,或者显式传Gdiplus::ImageFormatBMP/Gdiplus::ImageFormatJPEG。我一般在保存失败后会第一时间打开文件看前两个字节,BMP 文件必须是42 4D,JPEG 文件应该是FF D8。

4.3 高 DPI 和多显示器:截图范围不是你以为的 1920x1080

现象:在 150% 缩放的笔记本上,截出来的图只有屏幕左上角一块,或者副屏内容完全没截到。

原因:GetSystemMetrics(SM_CXSCREEN)只返回主屏宽度。在 DPI 缩放场景下,如果进程没有声明 DPI 感知,系统会先做虚拟化,导致GetDC(NULL)拿到的 DC 尺寸是逻辑像素,而BitBlt的角度看到的却是物理像素,两边一错位,截图范围就缩水了。多显示器时,副屏在虚拟桌面里可能有负坐标,直接从 0,0 开始复制,副屏内容就丢了。

解决:抓整个虚拟桌面时用SM_XVIRTUALSCREEN、SM_YVIRTUALSCREEN、SM_CXVIRTUALSCREEN、SM_CYVIRTUALSCREEN四个值组合。高 DPI 环境里还得让程序明确声明 DPI 感知,工程属性里开 PerMonitorV2,或者在main函数启动时调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)。我一般会写个辅助函数,输出当前逻辑屏幕和虚拟屏幕的宽高,先打印出来再调BitBlt。

4.4 内存泄漏:DC 和 GDI 对象到底谁负责释放

现象:程序长时间运行,任务管理器里 GDI 对象数一直涨,最终界面卡死或截图变成花屏。

原因:GetDC获取的 DC 不是新建的,用完必须ReleaseDC归还;CreateCompatibleDC创建的 DC 归自己所有,要用DeleteDC销毁;CreateCompatibleBitmap创建的HBITMAP要用DeleteObject删除。这三套对象漏掉任何一个,都会造成 GDI 句柄泄漏。最隐蔽的是SelectObject的旧对象没还原,位图在选中状态下删除失败,句柄直接泄漏。

解决:把函数里的清理逻辑固定成“后获取的先释放”顺序。我在调试时会在关键位置调用GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS)打印对象数,如果每次截屏后数值不回落到基线,就说明哪里泄漏了。你可以在OnTimer或按钮处理函数里前后打印两次,用二分法定位泄漏函数。

4.5 定时截屏卡死 UI:别把循环抓图塞进按钮消息

现象:加了定时截屏后,窗口拖不动,按钮点了没反应,最后整个程序显示“未响应”。

原因:BitBlt和CImage::Save都是耗时的同步调用,如果一个while(1)循环在按钮消息里不停截屏存文件,主线程的消息队列就被堵死了。

解决:不要用while(1)做截屏循环,用SetTimer定时触发,或者把截屏和保存扔到工作线程。我在工程里通常用SetTimer(1, 3000, NULL),三秒一次已经很快了,文件落盘用异步线程池会更稳。定时条件下,文件命名里必须带时间戳,否则上一帧还没写完,下一帧已经覆盖,文件就是一个正在写入的残缺文件。

5. 进阶用法:把全屏截屏改成“指定窗口 + 定时”的小工具

5.1 指定窗口:从 HWND 到客户区

很多场景不需要整屏,只需要截某个对话框或某个外部窗口。核心思路是把目标窗口的HWND拿过来,用GetClientRect拿客户区,再用ClientToScreen把客户区左上角转成屏幕坐标,最后用BitBlt按这个矩形从屏幕 DC 复制:

HWND hWnd = ::FindWindow(NULL, _T("目标窗口标题")); if (hWnd == NULL) return; RECT rcClient = {0}; ::GetClientRect(hWnd, &rcClient); POINT ptLeftTop = {0, 0}; ::ClientToScreen(hWnd, &ptLeftTop); int cx = rcClient.right - rcClient.left; int cy = rcClient.bottom - rcClient.top; HDC hScreenDC = ::GetDC(NULL); HDC hMemDC = ::CreateCompatibleDC(hScreenDC); HBITMAP hBmp = ::CreateCompatibleBitmap(hScreenDC, cx, cy); HGDIOBJ hOldBmp = ::SelectObject(hMemDC, hBmp); ::BitBlt(hMemDC, 0, 0, cx, cy, hScreenDC, ptLeftTop.x, ptLeftTop.y, SRCCOPY); // 后续保存和清理同上

FindWindow按标题找窗口不够稳,标题一变就找不到了。我一般用EnumWindows遍历所有顶层窗口,再用窗口类名和 PID 双重过滤,这样即使标题带版本号或动态后缀也能命中。如果目标窗口有分层属性或异形区域,BitBlt可能截不到内容,此时要调用PrintWindow并传入PW_RENDERFULLCONTENT,这是处理“窗口是黑块”时最后的手段。

5.2 定时器驱动的循环截屏和文件命名

定时截屏的代码我会放进OnTimer而不是按钮消息里。先SetTimer(1, 3000, NULL),然后在OnTimer里做三件事:生成时间戳文件名、调截屏函数、检查保存结果。

void CMFCApplication1Dlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { CTime t = CTime::GetCurrentTime(); CString strPath; strPath.Format(_T("Screen_%04d%02d%02d_%02d%02d%02d.bmp"), t.GetYear(), t.GetMonth(), t.GetDay(), t.GetHour(), t.GetMinute(), t.GetSecond()); BOOL bOk = CaptureScreenToFile(strPath, FALSE); // 打印或记录 bOk,方便无人值守时排查 } CDialogEx::OnTimer(nIDEvent); }

文件名里带毫秒更可靠,同一秒内截多帧不会互相覆盖。注意CaptureScreenToFile里的保存动作会阻塞调用线程,如果保存文件慢,就把文件 I/O 拆到独立线程,主线程只负责BitBlt和传参。

5.3 验证结果:不靠肉眼也能自查

截图落盘后,我习惯马上做一次“头校验”:打开文件读到前两个字节,确认是BM(BMP)或FF D8(JPEG),同时读BITMAPINFOHEADER.biWidth和biHeight,对比期望尺寸。这样能自动发现黑屏、截到 0 高度、文件只写了一半这类问题。

BOOL ValidateImageFile(LPCTSTR szPath, int nExpectWidth, int nExpectHeight) { CFile file; if (!file.Open(szPath, CFile::modeRead)) return FALSE; BYTE byHead[2] = {0}; file.Read(byHead, 2); if (byHead[0] != 'B' || byHead[1] != 'M') { file.Close(); return FALSE; } file.Seek(18, CFile::begin); // biWidth 在文件头偏移 18 DWORD dwW = 0, dwH = 0; file.Read(&dwW, 4); file.Read(&dwH, 4); file.Close(); return (int)dwW == nExpectWidth && (int)dwH == nExpectHeight; }

这套验证逻辑看起来多写几步,但在无人值守的定时任务里特别值钱。从那以后,我每次给老工程加截屏功能,都会强制走一遍“全屏截图 → 指定窗口截图 → 双屏/缩放环境截图 → 文件头校验”的流程,哪怕只改了一个参数也要重跑。希望你接手的 MFC 截屏工程,也能少踩几个我之前踩过的坑。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 18:01:54

WIFI Direct技术深度解析:点对点通信的工业级实践指南

1. 项目概述&#xff1a;这不是“另一个WiFi功能”&#xff0c;而是一套独立于传统网络架构的点对点通信体系WIFI Direct Study——光看标题&#xff0c;很多人第一反应是“这不就是手机传文件用的那个‘隔空投送’吗&#xff1f;”但真正动手研究过的人会发现&#xff0c;它根…

作者头像 李华
网站建设 2026/10/19 6:11:49

书霸AI:论文数据分析卡住,先查这四处

表格存了好几个版本&#xff0c;问卷也收齐了&#xff0c;可写到期刊论文的“结果”部分&#xff0c;还是不知道从哪里落笔。问题往往出在分析前的准备&#xff1a;研究问题太宽、变量交代不清、方法与数据不匹配&#xff0c;都会让后面的解释变得困难。从图片来看&#xff0c;…

作者头像 李华
网站建设 2026/10/23 20:38:17

网络准入控制系统NAC部署实战:模式选型、策略配置与避坑指南

简介&#xff1a;局域网准入控制系统资源包&#xff0c;面向企业网管和单位运维人员&#xff0c;对外来设备接入、内部共享越权访问、IP/MAC地址篡改、ARP欺骗攻击等安全威胁进行统一管控。本系统可在不改变现有网络结构的前提下实现内外部电脑双向隔离&#xff0c;有效预防非法…

作者头像 李华
网站建设 2026/10/22 9:37:46

MATLAB实战:HMM孤立字语音识别从特征提取到Viterbi解码

简介&#xff1a;本资源为基于隐马尔可夫模型&#xff08;HMM&#xff09;的孤立字语音识别MATLAB实现代码&#xff0c;面向语音信号处理方向的学生、研究人员及算法入门者&#xff0c;帮助理解并复现从特征提取到模型训练与识别的完整流程。压缩包共15个文件&#xff0c;以13个…

作者头像 李华
网站建设 2026/10/23 11:03:28

Django+MySQL街区医院管理系统:毕业设计源码拆解与实战

简介&#xff1a;基于Python的街区医院管理系统的设计与实现毕业设计资料包&#xff08;含论文与源码&#xff09;&#xff0c;面向高校计算机相关专业学生&#xff0c;尤其是需要完成医院管理类课设或毕设的开发者。资料围绕Python、Django、MySQL及B/S架构展开&#xff0c;从…

作者头像 李华