C++实现DXGI屏幕截屏与BMP文件生成分割技术详解 1. 项目概述从屏幕到像素的掌控在桌面应用开发、自动化测试、远程协助甚至是游戏辅助工具的开发中屏幕截屏都是一个基础且高频的需求。你可能需要录制一段操作教程或者监控某个软件界面的变化又或者想做一个简单的屏幕取色工具。而BMP作为Windows系统下最“原始”的位图格式结构简单明了是理解图像数据在内存中如何组织的绝佳入口。今天我们就来聊聊如何用C不依赖任何第三方图像库直接与系统底层图形接口对话实现高效的屏幕截屏并将捕获到的像素数据按照BMP的标准格式“组装”成文件。更进一步我们还会探讨如何对这个BMP文件进行分割操作比如将一张大图切成九宫格或者按指定区域提取子图。这个过程不仅能让你掌握Windows图形编程的核心接口如DXGI更能让你深刻理解从显存到硬盘一张图片是如何“诞生”的。无论你是想为你的C项目增加一个截图功能模块还是单纯对底层图形处理感兴趣这篇内容都将提供一条清晰的实践路径。2. 核心技术选型与方案设计2.1 为什么选择DXGI而非传统GDI提到Windows下的截屏很多人的第一反应是使用古老的GDIGraphics Device Interface函数比如BitBlt。这个方法确实简单几行代码就能把桌面内容拷贝到一个设备上下文DC中。但在高DPI屏幕、多显示器、以及涉及DirectX/Direct3D渲染的应用尤其是游戏和视频播放器场景下GDI截屏往往会失效截取到一片黑色或残缺的画面。这是因为GDI无法捕获由GPU直接渲染到显存中的内容。因此在现代Windows系统Windows 8及以上中DXGIDirectX Graphics Infrastructure桌面复制API成为了更强大和可靠的选择。它是DirectX家族的一部分能够直接访问桌面合成器Desktop Window Manager管理的最终帧缓冲区无论窗口是用GDI、Direct2D还是Direct3D渲染的都能正确捕获。其核心思路是创建一个“桌面副本”Desktop Duplication这个副本对象会持续接收桌面图像更新我们可以从中“窃取”一帧画面。虽然API调用看起来比GDI复杂但它提供了无与伦比的兼容性和性能尤其是在需要高帧率截屏如录屏时。2.2 BMP格式简单背后的结构之美选择BMP作为输出格式并非因为它高效事实上它通常很臃肿而是因为它结构透明易于手动构造。一个完整的BMP文件主要由两部分组成文件头BITMAPFILEHEADER和信息头BITMAPINFOHEADER紧接着就是原始的像素数据。BITMAPFILEHEADER定义了文件类型必须是“BM”、文件大小以及像素数据在文件中的起始偏移量。这个偏移量就是文件头和信息头的大小之和。BITMAPINFOHEADER包含了图像的元数据如宽度、高度、颜色位深通常是24位RGB或32位ARGB、压缩方式我们使用不压缩的BI_RGB等。像素数据这里存放着每个像素的颜色值。需要注意的是BMP文件规定像素数据按行从下到上存储即图像的第一行屏幕的顶部在文件数据段的最后。同时每行像素的字节数必须向上对齐到4字节的倍数行对齐不足的部分用0填充。理解这个结构是手动生成BMP文件以及后续进行分割操作的基础。我们不需要任何图像解码库只需要按照这个格式将我们捕获到的内存数据“打包”写入文件即可。2.3 整体流程设计我们的项目将遵循一个清晰的管道式流程初始化DXGI环境创建D3D设备获取DXGI适配器和输出显示器最终创建桌面副本对象。捕获一帧桌面图像从桌面副本中获取下一帧图像资源并将其拷贝到一个CPU可读的“暂存纹理”中。映射内存获取像素数据将暂存纹理映射到线性内存得到一个指向原始像素数据的指针和每行字节数Pitch。构造并写入BMP文件根据上一步得到的图像尺寸和位深填充BMP的两个头结构然后将像素数据按BMP规则可能涉及垂直翻转和行对齐写入文件。图片分割操作读取已保存的BMP文件解析其头结构根据分割需求如等分、按区域计算出子图的范围然后提取对应的像素数据块为每个子图重新生成文件头和像素数据保存为新的BMP文件。这个设计将截屏和图像处理解耦使得每个步骤都相对独立便于理解、调试和扩展。3. 核心细节解析与实操要点3.1 DXGI桌面复制API的关键对象与生命周期使用DXGI API你需要和几个核心COM对象打交道管理好它们的生命周期通过AddRef和Release至关重要。ID3D11Device 与 ID3D11DeviceContext这是Direct3D 11的设备和上下文对象。我们虽然不进行3D渲染但需要它们来创建和管理GPU资源。设备代表显示适配器上下文用于执行渲染命令这里用于拷贝纹理。IDXGIOutputDuplication这是核心对象代表了对一个显示输出的桌面复制会话。通过它我们可以轮询或等待新的桌面帧。它的创建成本较高应尽量复用。IDXGIResource / ID3D11Texture2D从AcquireNextFrame获取的资源对象它本质上是一个纹理Texture包含了当前桌面的图像数据。但这个纹理通常位于显存中且可能被GPU频繁读写CPU无法直接访问。ID3D11Texture2D (Staging)为了解决CPU访问问题我们需要创建一个用途Usage为D3D11_USAGE_STAGING、CPU访问标志CPUAccessFlags为D3D11_CPU_ACCESS_READ的纹理。这个“暂存纹理”位于能被CPU访问的内存中。我们将桌面纹理拷贝到它里面然后再进行映射操作。注意AcquireNextFrame和ReleaseFrame必须成对调用。在成功获取一帧后你必须调用ReleaseFrame来释放这一帧的所有权然后才能获取下一帧否则后续调用会失败。这是一个常见的坑点。3.2 BMP文件生成的字节序与对齐陷阱手动构造BMP文件时有两个细节极易出错1. 垂直翻转问题屏幕坐标系的原点通常在左上角Y轴向下。而BMP像素数据的存储顺序是从下到上。这意味着如果你把从屏幕捕获的、自上而下存储的像素数据直接写入BMP文件得到的图片将是上下颠倒的。解决方案有两种在写入像素数据时从最后一行开始写逐行向前。更巧妙的做法是在填写BITMAPINFOHEADER时将biHeight字段设置为负数例如-1080。这告诉BMP解析器“此图像的像素数据是自上而下存储的无需翻转”。我们通常采用这种方案因为它避免了在内存中重新排列大量数据性能更高。2. 行字节对齐BMP文件要求每行像素数据的字节数必须是4的倍数。对于24位RGB每个像素3字节的图像如果宽度不是4的倍数就需要在每行末尾补0。例如一张宽度为3像素的24位BMP每行实际数据是3 * 3 9字节但写入文件时需要补3个字节的0凑齐12字节。计算每行字节数的公式是RowSize ((Width * BitsPerPixel) 31) / 32 * 4。对于32位ARGB每个像素4字节的图像由于4字节本身就是4的倍数所以不需要额外对齐这简化了处理。3.3 像素格式的转换与取舍DXGI桌面复制API默认捕获的像素格式是DXGI_FORMAT_B8G8R8A8_UNORM。这意味着在内存中每个像素的4个字节32位按**蓝(B)、绿(G)、红(R)、透明度(A)的顺序排列。而标准的24位BMP格式期望的是红(R)、绿(G)、蓝(B)**的顺序。如果我们想保存为24位BMP就需要在写入文件前进行像素格式的转换丢弃A通道并交换R和B通道。在我们的示例中为了简化我们直接保存为32位BMP设置biBitCount32并将biCompression设为BI_RGB。虽然BMP标准支持32位但许多简单的图片查看器可能只识别24位。如果你需要广泛的兼容性就必须实现这个转换逻辑。一个高效的转换方法是使用SIMD指令如SSE/AVX或并行算法来批量处理但对于学习和基础功能逐像素转换在大多数场景下也是可接受的。4. 实操过程与核心环节实现4.1 环境准备与项目配置首先你需要一个支持C11及以上的开发环境如Visual Studio 2019或2022。项目配置是关键一步。创建项目新建一个C控制台应用程序项目。链接库DXGI和D3D11是系统组件但我们需要显式链接它们的导入库。有两种方法在代码中使用#pragma comment如示例所示#pragma comment(lib, d3d11.lib)和#pragma comment(lib, dxgi.lib)。这是最简单的方式。在项目属性中配置打开“项目属性” - “链接器” - “输入” - “附加依赖项”添加d3d11.lib; dxgi.lib;。包含头文件确保包含了必要的Windows和DirectX头文件。#include windows.h #include d3d11.h #include dxgi1_2.h // 注意是1_2包含了DuplicateOutput接口 #include stdio.h // 用于文件操作 #include stdint.h // 用于明确大小的整数类型4.2 逐步实现DXGI屏幕捕获让我们分解示例代码中的main函数理解每一步。第一步创建设备与上下文ID3D11Device* pDevice nullptr; ID3D11DeviceContext* pContext nullptr; D3D_FEATURE_LEVEL featureLevel; HRESULT hr D3D11CreateDevice( nullptr, // 指定适配器nullptr表示默认 D3D_DRIVER_TYPE_HARDWARE, // 优先使用硬件驱动 nullptr, // 软件驱动句柄硬件驱动时为nullptr 0, // 设备创建标志 nullptr, // 功能级别数组nullptr使用默认 0, // 功能级别数组大小 D3D11_SDK_VERSION, // SDK版本 pDevice, // 返回的设备指针 featureLevel, // 返回支持的最高功能级别 pContext // 返回的设备上下文指针 ); if (FAILED(hr)) { // 处理错误可以尝试回落到D3D_DRIVER_TYPE_WARP软件渲染 }这里我们直接请求硬件驱动类型。如果失败比如在无显卡的服务器上可以尝试D3D_DRIVER_TYPE_WARP它是一个高性能的软件光栅化器。第二步获取DXGI输出与创建副本IDXGIDevice* pDxgiDevice nullptr; pDevice-QueryInterface(__uuidof(IDXGIDevice), (void**)pDxgiDevice); IDXGIAdapter* pAdapter nullptr; pDxgiDevice-GetParent(__uuidof(IDXGIAdapter), (void**)pAdapter); IDXGIOutput* pOutput nullptr; // 通常索引0代表主显示器 pAdapter-EnumOutputs(0, pOutput); IDXGIOutput1* pOutput1 nullptr; pOutput-QueryInterface(__uuidof(IDXGIOutput1), (void**)pOutput1); IDXGIOutputDuplication* pDeskDupl nullptr; hr pOutput1-DuplicateOutput(pDevice, pDeskDupl);DuplicateOutput是核心它创建了桌面副本对象。一个输出显示器只能同时存在一个激活的副本。第三步捕获帧并拷贝到暂存纹理IDXGIResource* pDesktopResource nullptr; DXGI_OUTDUPL_FRAME_INFO frameInfo; // 等待最多20毫秒获取新的一帧 hr pDeskDupl-AcquireNextFrame(20, frameInfo, pDesktopResource); if (hr DXGI_ERROR_WAIT_TIMEOUT) { // 没有新帧继续循环或等待 return; } if (FAILED(hr)) { /* 处理其他错误 */ } // 获取纹理接口 ID3D11Texture2D* pAcquiredTexture nullptr; pDesktopResource-QueryInterface(__uuidof(ID3D11Texture2D), (void**)pAcquiredTexture); // 查询纹理描述以获取尺寸和格式 D3D11_TEXTURE2D_DESC desc; pAcquiredTexture-GetDesc(desc); // 创建CPU可读的暂存纹理如果尚未创建或尺寸变化 if (pStagingTexture nullptr || desc.Width ! stagingDesc.Width || desc.Height ! stagingDesc.Height) { // 释放旧的根据desc创建新的pStagingTexture D3D11_TEXTURE2D_DESC stagingDesc desc; stagingDesc.Usage D3D11_USAGE_STAGING; stagingDesc.BindFlags 0; stagingDesc.CPUAccessFlags D3D11_CPU_ACCESS_READ; stagingDesc.MiscFlags 0; pDevice-CreateTexture2D(stagingDesc, nullptr, pStagingTexture); } // 将捕获的纹理拷贝到暂存纹理 pContext-CopyResource(pStagingTexture, pAcquiredTexture);这里引入了暂存纹理的缓存。如果桌面分辨率没有改变我们可以复用同一个暂存纹理避免反复创建销毁的开销。第四步映射内存并获取像素数据IDXGISurface* pSurface nullptr; pStagingTexture-QueryInterface(__uuidof(IDXGISurface), (void**)pSurface); DXGI_MAPPED_RECT mappedRect; hr pSurface-Map(mappedRect, DXGI_MAP_READ); if (SUCCEEDED(hr)) { // mappedRect.pBits 指向像素数据的起始地址 // mappedRect.Pitch 是每行数据的字节数已对齐 // 注意Pitch可能大于 width * bytesPerPixel因为可能存在内存对齐填充 int bytesPerPixel 4; // 对应DXGI_FORMAT_B8G8R8A8_UNORM uint8_t* pData (uint8_t*)mappedRect.pBits; // 现在pData和Pitch可以用来保存或处理图像了 // ... pSurface-Unmap(); }Pitch非常重要。它代表了纹理中一行数据在内存中的跨度字节数。由于GPU内存对齐的要求Pitch通常大于或等于宽度 * 每像素字节数。在拷贝数据时必须使用Pitch作为行跨度。4.3 实现BMP文件保存函数我们将捕获到的像素数据B8G8R8A8格式自上而下存储保存为32位BMP。bool SaveAsBMP(const char* filename, uint8_t* pData, int width, int height, int pitch) { // 1. 计算文件大小 // 32位图无行对齐问题因为每行字节数 width * 4 本身就是4的倍数。 int pixelDataSize height * width * 4; // 实际数据大小 int fileSize sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER) pixelDataSize; // 2. 准备文件头 BITMAPFILEHEADER bmfh {0}; bmfh.bfType 0x4D42; // BM bmfh.bfSize fileSize; bmfh.bfOffBits sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER); // 像素数据偏移 // 3. 准备信息头 BITMAPINFOHEADER bmih {0}; bmih.biSize sizeof(BITMAPINFOHEADER); bmih.biWidth width; bmih.biHeight -height; // 关键负数表示像素数据是自上而下存储 bmih.biPlanes 1; bmih.biBitCount 32; // 32位带Alpha通道 bmih.biCompression BI_RGB; // 不压缩 bmih.biSizeImage pixelDataSize; // 可设为0但填上更规范 bmih.biXPelsPerMeter 0; bmih.biYPelsPerMeter 0; bmih.biClrUsed 0; bmih.biClrImportant 0; // 4. 写入文件 FILE* fp nullptr; fopen_s(fp, filename, wb); if (!fp) return false; fwrite(bmfh, sizeof(BITMAPFILEHEADER), 1, fp); fwrite(bmih, sizeof(BITMAPINFOHEADER), 1, fp); // 5. 写入像素数据 // 因为设置了负的biHeight且Pitch可能大于width*4我们需要按行拷贝 for (int row 0; row height; row) { // 源数据每一行的起始地址 uint8_t* srcRow pData row * pitch; // 目标直接写入文件一行就是width*4字节 fwrite(srcRow, 1, width * 4, fp); // 注意这里假设pitch width*4。如果pitch小于则会发生错误但通常不会。 } fclose(fp); return true; }这个函数清晰地展示了BMP文件的组装过程。注意我们直接将B8G8R8A8数据写入没有交换R和B通道。生成的BMP文件在大多数现代软件如Windows照片查看器、Photoshop中能正确显示但颜色是BGR顺序。如果需要标准的RGB顺序需要在写入前进行交换。4.4 实现BMP图片分割操作分割操作建立在能够正确解析BMP文件的基础上。我们实现一个函数将一张BMP水平或垂直等分成N份。bool SplitBMP(const char* inputFile, int horizontalParts, int verticalParts) { // 1. 读取并解析原BMP文件 FILE* fpIn nullptr; fopen_s(fpIn, inputFile, rb); if (!fpIn) return false; BITMAPFILEHEADER bmfh; BITMAPINFOHEADER bmih; fread(bmfh, sizeof(bmfh), 1, fpIn); fread(bmih, sizeof(bmih), 1, fpIn); // 检查是否是支持的格式非压缩24或32位 if (bmih.biCompression ! BI_RGB) { fclose(fpIn); return false; } bool isTopDown bmih.biHeight 0; int absHeight abs(bmih.biHeight); int width bmih.biWidth; int bitsPerPixel bmih.biBitCount; int bytesPerPixel bitsPerPixel / 8; // 计算原图行对齐后的字节数 int srcRowPitch ((width * bitsPerPixel 31) / 32) * 4; int pixelDataSize srcRowPitch * absHeight; // 移动到像素数据开始处 fseek(fpIn, bmfh.bfOffBits, SEEK_SET); std::vectoruint8_t srcData(pixelDataSize); fread(srcData.data(), 1, pixelDataSize, fpIn); fclose(fpIn); // 2. 计算每个子块的尺寸 int subWidth width / horizontalParts; int subHeight absHeight / verticalParts; // 计算子块行对齐后的字节数 int dstRowPitch ((subWidth * bitsPerPixel 31) / 32) * 4; // 3. 为每个子块创建新的BMP文件 for (int rowPart 0; rowPart verticalParts; rowPart) { for (int colPart 0; colPart horizontalParts; colPart) { // 计算子块在原图中的起始像素坐标 int startX colPart * subWidth; int startY isTopDown ? rowPart * subHeight : (absHeight - (rowPart 1) * subHeight); // 注意对于自下而上的BMPbiHeight0Y坐标计算更复杂这里假设为自上而下简化处理。 // 准备子块的文件头和信息头 BITMAPFILEHEADER subBmfh bmfh; BITMAPINFOHEADER subBmih bmih; subBmih.biWidth subWidth; subBmih.biHeight isTopDown ? -subHeight : subHeight; // 保持与原图相同的存储方向 subBmih.biSizeImage dstRowPitch * subHeight; subBmfh.bfSize sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER) subBmih.biSizeImage; // 生成文件名 char outputFile[256]; snprintf(outputFile, sizeof(outputFile), %s_part_%d_%d.bmp, inputFile /* 最好去掉扩展名 */, rowPart, colPart); FILE* fpOut nullptr; fopen_s(fpOut, outputFile, wb); if (!fpOut) continue; fwrite(subBmfh, sizeof(subBmfh), 1, fpOut); fwrite(subBmih, sizeof(subBmih), 1, fpOut); // 4. 拷贝像素数据 for (int row 0; row subHeight; row) { // 计算原图中当前行的起始位置 int srcY startY row; if (!isTopDown) { // 如果是自下而上存储需要反向计算Y坐标 srcY absHeight - 1 - (startY row); } uint8_t* srcRowStart srcData.data() srcY * srcRowPitch startX * bytesPerPixel; // 计算目标行数据可能需要对齐填充 std::vectoruint8_t dstRow(dstRowPitch, 0); memcpy(dstRow.data(), srcRowStart, subWidth * bytesPerPixel); fwrite(dstRow.data(), 1, dstRowPitch, fpOut); } fclose(fpOut); } } return true; }这个分割函数考虑了BMP的行对齐和存储方向自上而下或自下而上是一个健壮的实现。你可以修改它来实现非等分、按区域坐标分割等更复杂的功能。5. 常见问题与排查技巧实录在实际编码和运行过程中你几乎一定会遇到下面这些问题。这里记录了我的踩坑经验和解决方案。5.1 DXGI初始化与权限问题问题1D3D11CreateDevice或DuplicateOutput返回E_ACCESSDENIED错误。这通常发生在远程桌面RDP会话、某些虚拟机环境或者没有正确权限的情况下。桌面复制API需要特殊的权限并且可能被远程会话禁用。排查确保程序在本地控制台会话物理机或控制台登录的虚拟机中运行。以管理员身份运行有时能解决某些权限问题但并非总是有效。替代方案如果在不支持桌面复制的环境中必须回退到GDI截屏方法BitBlt。可以在运行时检测DuplicateOutput的返回值如果失败则切换到GDI方案。问题2捕获到的纹理尺寸或格式与预期不符。ID3D11Texture2D::GetDesc返回的描述信息是权威的。不要对显示器的分辨率做任何假设。技巧始终根据desc.Width和desc.Height来分配内存和处理数据。desc.Format通常是DXGI_FORMAT_B8G8R8A8_UNORM但也可能是其他格式如R8G8B8A8_UNORM编写通用代码时应能处理。5.2 BMP文件生成与查看问题问题3保存的BMP文件无法打开或提示“无效的位图文件”。这几乎总是文件头或信息头填写错误。检查清单文件类型标识bfType必须是0x4D42即字符BM。注意字节序在x86/x64小端序机器上写作0x4D42是正确的。偏移量bfOffBits必须是文件头和信息头大小的和。如果后面还有调色板还要加上调色板大小。对于24/32位无调色板位图就是14 40 54字节。文件大小bfSize必须是整个文件的大小。计算时务必准确文件头 信息头 像素数据大小。像素数据大小要使用对齐后的每行字节数乘以高度。信息头大小biSize必须为40sizeof(BITMAPINFOHEADER)。高度符号如果你希望像素数据自上而下存储避免手动翻转biHeight必须为负数。很多开源代码忘记这一点。写入模式文件必须以二进制模式wb打开而不是文本模式w否则换行符等字节可能被转换。问题4图片颜色异常比如蓝色和红色互换。这是像素格式问题。DXGI捕获的是B8G8R8A8而许多软件尤其是旧的期望BMP是R8G8B8或R8G8B8A8。解决方案在写入文件前交换每个像素的R和B通道。对于32位数据可以这样快速交换for (int i 0; i width * height; i) { uint8_t* pixel pData i * 4; std::swap(pixel[0], pixel[2]); // 交换B和R // Alpha通道(pixel[3])保持不变 }对于性能要求高的场景可以使用SIMD指令进行优化。5.3 性能与资源管理问题5截屏循环导致CPU占用率过高。如果在一个紧密循环中不断调用AcquireNextFrame并且没有新帧时立即返回会导致CPU空转。优化使用AcquireNextFrame的超时参数。示例中设置为20毫秒这意味着API会等待最多20毫秒来获取新帧。如果没有新帧它会返回DXGI_ERROR_WAIT_TIMEOUT此时你可以让线程睡眠一小段时间例如Sleep(10)或者将循环改为事件驱动DXGI也支持通过帧事件通知但更复杂。另一个思路对于非实时性要求极高的应用可以降低捕获频率比如每秒捕获10-30帧而不是尽可能快。问题6内存泄漏。所有COM接口对象以I开头的指针都必须调用Release()。一个良好的习惯是使用ComPtr智能指针Microsoft::WRL::ComPtr它来自Windows Runtime C Template Library (WRL)可以自动管理COM对象的生命周期。#include wrl/client.h using Microsoft::WRL::ComPtr; ComPtrID3D11Device device; ComPtrIDXGIOutputDuplication deskDupl; // 创建对象... // 无需手动ReleaseComPtr析构时会自动调用5.4 图片分割中的边界与对齐处理问题7分割后子图边缘有杂色或错位。这几乎总是由于行对齐计算错误导致的。原图和子图的每行字节数都必须按照BMP规则进行4字节对齐。关键计算int stride ((width * bitsPerPixel 31) / 32) * 4; // 正确的对齐计算在从原图拷贝数据到子图缓冲区时必须分别使用原图的stride_src和子图的stride_dst作为内存行跨度而不是简单的width * bytesPerPixel。调试技巧对于小尺寸图片比如10x10手动计算并打印出stride_src和stride_dst确保拷贝循环中的地址计算是正确的。问题8原图尺寸不能被整除时分割不均匀。我们的示例是简单整除这可能导致最后一个子块尺寸不同或丢失边缘像素。改进方案在计算subWidth和subHeight时可以使用除法取整然后让最后一个子块包含剩余的所有像素。例如int subWidth width / horizontalParts; int lastSubWidth width - subWidth * (horizontalParts - 1); // 对前N-1个子块用subWidth最后一个用lastSubWidth这确保了所有原始像素都被分配到某个子块中。将DXGI截屏和BMP手动处理结合起来你就能在C中构建一个不依赖任何外部图像库的、高效的屏幕捕获与基础图像处理模块。这个过程中对底层API、内存布局和文件格式的理解其价值远超过仅仅调用一个现成的截图函数。当你下次需要处理图形数据时这份对“像素如何从屏幕走到文件”的掌控感会让你更加得心应手。