
最近在开发一个屏幕录制工具时遇到了一个有趣的需求需要在录屏画面上动态叠加一个自定义的鼠标光标以模拟用户操作或进行演示。直接捕获系统光标有时不够灵活比如想展示多个“虚拟用户”同时操作或者光标样式需要特殊定制时就需要我们自己在屏幕上“画”出光标。这让我深入研究了 Windows 的图形设备接口GDI并整理出一套完整的实战方案。本文将手把手带你从零理解 GDI 绘图原理并实现一个在屏幕任意位置绘制鼠标光标的程序无论是用于辅助演示、自动化测试脚本还是制作特殊效果的桌面工具这套代码都能直接复用。1. 理解 GDI 与屏幕绘图基础在开始写代码之前我们必须搞清楚两个核心概念GDI 是什么以及如何在屏幕上“画画”。1.1 什么是 GDIGDIGraphics Device Interface图形设备接口是 Windows 操作系统的一个核心组件它充当了应用程序与图形硬件如显卡、显示器之间的桥梁。你可以把它想象成一个“图形翻译官”对应用程序它提供了一组统一的函数如LineTo,Rectangle,TextOut让开发者不用关心具体的显示器型号或显卡驱动就能画出点、线、圆、文本和图像。对硬件它负责将应用程序的绘图指令翻译成硬件能理解的命令从而实现图形的最终输出。我们常见的窗口、按钮、乃至桌面背景其绘制都离不开 GDI。虽然现代应用更多使用 DirectX 或 OpenGL 进行高性能、复杂的图形渲染如游戏但对于像我们这样需要在窗口或桌面上进行 2D 图形绘制、图像处理的任务GDI 依然是简单、高效且兼容性极佳的选择。1.2 设备上下文DC绘图的关键画布在 GDI 中绘图你必须先获得一个设备上下文Device Context, DC。可以把 DC 理解成一张“画布”或者一个“绘图环境”它包含了当前绘图的所有状态信息比如当前使用的画笔颜色、粗细当前使用的画刷填充颜色当前字体绘图区域裁剪区域坐标映射模式我们要在屏幕上画光标本质上就是获取代表整个屏幕的 DC然后在这个 DC 上执行绘图操作。获取屏幕 DC 的 API 是GetDC(NULL)。1.3 鼠标光标是如何绘制的系统默认的鼠标光标并不是一个永远固定在屏幕上的静态图像。它是由系统管理的一个特殊“精灵”Sprite其位置由鼠标或触摸板控制系统会在每次屏幕刷新时在光标当前位置将光标图像与屏幕背景进行合成通常使用异或等光栅操作以避免破坏背景。我们要模拟这个过程就需要获取光标图像数据加载一个光标资源.cur文件或使用系统标准光标。确定绘制位置指定一个屏幕坐标 (x, y)。执行绘制在屏幕 DC 的指定位置将光标图像“画”上去。这里的关键是使用DrawIcon函数它能正确处理光标自带的透明度和热点Hot Spot即光标点击的有效点通常是箭头尖或手指尖。2. 环境准备与项目创建2.1 开发环境操作系统Windows 10 或 Windows 11。GDI 是 Windows 原生 API本教程不适用于 Linux如 Kali或 macOS。集成开发环境IDEVisual Studio 2019 或更高版本社区版免费。我们将创建传统的 Win32 桌面应用程序。编程语言C/C。这是调用 Windows API 最直接的方式。相关库Windows.h 头文件它包含了 GDI 所需的全部函数和类型声明。关于“Kali没有鼠标光标”这是一个网络热词通常指在 Kali Linux 这类Linux桌面环境中由于显卡驱动、桌面环境配置或窗口管理器问题导致光标消失。这与我们在 Windows 下用 GDI主动绘制光标是两回事。Linux 下图形编程通常使用 X11 或 Wayland 协议与 Windows GDI 完全不同。2.2 创建 Win32 项目打开 Visual Studio选择“创建新项目”。搜索并选择“Windows 桌面向导”点击“下一步”。为项目命名例如GDIMouseCursor选择位置点击“创建”。在“应用程序类型”中选择“桌面应用程序(.exe)”并在“附加选项”中勾选“空项目”。点击“确定”。在“解决方案资源管理器”中右键点击“源文件”文件夹选择“添加” - “新建项”。选择“C文件(.cpp)”命名为main.cpp。现在我们有了一个空白的 Win32 项目。接下来将在此文件中编写所有代码。3. 核心 API 与原理拆解实现随机位置画光标主要依赖以下几个 Windows API 函数3.1GetDC(NULL)功能获取整个屏幕的设备上下文。参数NULL表示桌面窗口。返回值屏幕 DC 的句柄HDC。如果失败返回NULL。注意通过GetDC获取的 DC 用完后必须用ReleaseDC(NULL, hdc)释放否则会造成资源泄漏。3.2DrawIcon(HDC hDC, int X, int Y, HICON hIcon)功能在指定的设备上下文上绘制一个图标或光标。参数hDC目标设备上下文句柄这里是我们获取的屏幕 DC。X,Y绘制位置的左上角坐标以像素为单位。hIcon要绘制的图标或光标句柄。返回值非零表示成功零表示失败。关键点这个函数会自动处理光标/图标的透明区域掩码这是直接使用BitBlt绘制位图所不具备的。3.3LoadCursor(HINSTANCE hInstance, LPCSTR lpCursorName)功能从应用程序的可执行文件资源中加载一个光标资源。参数hInstance应用程序实例句柄。对于系统标准光标可以传NULL。lpCursorName光标资源的名称。要使用系统标准光标如箭头、手型需使用预定义的宏如IDC_ARROW。返回值成功则返回光标句柄HCURSOR它实际上是HICON类型失败返回NULL。注意加载的系统标准光标不需要也不应该被DestroyCursor销毁。3.4GetSystemMetrics(int nIndex)功能获取各种系统度量值如屏幕尺寸。参数nIndex指定要获取的度量类型。SM_CXSCREEN屏幕的宽度像素。SM_CYSCREEN屏幕的高度像素。返回值请求的度量值。我们将使用这个函数来获取屏幕分辨率从而生成随机的、确保在屏幕范围内的坐标。3.5 随机数生成C标准库提供了random头文件来生成高质量的随机数。我们将使用std::default_random_engine和std::uniform_int_distribution来生成屏幕坐标范围内的随机整数。4. 完整实战编写随机绘制光标的程序下面我们将分步骤实现一个完整的程序。该程序运行后会在屏幕上的随机位置绘制一个系统标准箭头光标持续5秒后程序退出。4.1 项目结构与代码文件我们只需要一个main.cpp源文件。4.2 编写核心代码将以下代码完整复制到main.cpp中// main.cpp #include windows.h #include random #include chrono #include thread int main() { // 1. 获取屏幕设备上下文 (DC) HDC hScreenDC GetDC(NULL); if (hScreenDC NULL) { MessageBox(NULL, LFailed to get screen DC!, LError, MB_ICONERROR); return 1; } // 2. 加载一个系统标准光标这里使用箭头光标 HCURSOR hCursor LoadCursor(NULL, IDC_ARROW); if (hCursor NULL) { MessageBox(NULL, LFailed to load cursor!, LError, MB_ICONERROR); ReleaseDC(NULL, hScreenDC); return 1; } // 3. 获取屏幕分辨率用于生成随机坐标 int screenWidth GetSystemMetrics(SM_CXSCREEN); int screenHeight GetSystemMetrics(SM_CYSCREEN); // 4. 准备随机数生成器 // 使用时间作为随机种子确保每次运行结果不同 std::random_device rd; std::default_random_engine engine(rd()); std::uniform_int_distributionint distX(0, screenWidth - 1); std::uniform_int_distributionint distY(0, screenHeight - 1); // 5. 在随机位置绘制光标 // 我们绘制10次每次位置随机间隔500毫秒 for (int i 0; i 10; i) { // 生成随机坐标 int posX distX(engine); int posY distY(engine); // 在屏幕DC的随机位置绘制光标 BOOL result DrawIcon(hScreenDC, posX, posY, hCursor); if (!result) { // 绘制失败处理简单输出错误码实际项目可记录日志 DWORD err GetLastError(); // 注意在控制台程序中使用 printf 需要调整项目子系统这里简化处理 } // 打印本次绘制的位置信息调试用 // 在实际GUI程序中可以省略或改为日志输出 // printf(Cursor drawn at (%d, %d)\n, posX, posY); // 等待500毫秒以便观察效果 std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 注意DrawIcon 绘制的内容不是永久的 // 当其他窗口刷新或移动时比如你动一下鼠标我们画的光标就会被覆盖。 // 这不是Bug而是GDI屏幕绘图的特性。要实现“持续显示”需要循环绘制或使用其他技术。 } // 6. 清理资源 // 注意LoadCursor(NULL, ...) 加载的系统光标不要销毁 // DestroyCursor(hCursor); // 错误不能销毁系统光标资源。 // 释放屏幕DC ReleaseDC(NULL, hScreenDC); MessageBox(NULL, LFinished drawing cursor 10 times at random positions., LInfo, MB_OK); return 0; }4.3 配置项目属性重要由于我们使用了 C11 的random和thread库并且是控制台程序却调用了MessageBox需要调整项目设置在“解决方案资源管理器”中右键点击项目名GDIMouseCursor选择“属性”。在“配置属性” - “高级”中将“字符集”从“使用 Unicode 字符集”改为**“使用多字节字符集”**。这是因为我们的MessageBox使用了宽字符L前缀而项目默认是 Unicode。改为多字节可以简化字符串处理。或者你也可以将MessageBox的字符串参数改为TEXT(“Failed…”)并使用TCHAR系列函数来保持 Unicode 兼容性。在“配置属性” - “C/C” - “语言”中将“C 语言标准”设置为“ISO C17 标准”或更高以确保random和thread可用。在“配置属性” - “链接器” - “系统”中将“子系统”从“控制台 (/SUBSYSTEM:CONSOLE)”改为“Windows (/SUBSYSTEM:WINDOWS)”。因为我们虽然写了main函数但本质上是一个图形界面程序不需要控制台窗口。4.4 编译与运行点击 Visual Studio 顶部的“本地 Windows 调试器”或按F5编译并运行。程序启动后你会看到屏幕各处会每隔0.5秒“闪烁”出一个鼠标箭头总共10次。注意这些光标是“画”在屏幕上的它们不会移动也不会响应鼠标事件。当你移动真实鼠标或切换窗口时这些画上去的光标可能会被系统正常的屏幕刷新所擦除。程序结束后会弹出一个信息框。运行效果你将在你的桌面屏幕上看到箭头光标随机出现就像屏幕上有“幽灵光标”一样。5. 常见问题与排查思路在实现过程中你可能会遇到以下问题问题现象可能原因解决思路编译错误无法解析的外部符号 WinMain链接器子系统配置错误。项目是“Windows”子系统但编译器在寻找WinMain入口点而我们写的是main。将项目属性中的“子系统”改回“控制台 (/SUBSYSTEM:CONSOLE)”。对于本教程的示例使用控制台子系统更简单。编译错误‘DrawIcon’: 无法将参数 4 从‘HCURSOR’转换为‘HICON’类型不匹配。DrawIcon期望HICON但LoadCursor返回HCURSOR。在 Windows API 中HCURSOR和HICON本质上是兼容的。直接传递hCursor即可编译器警告可以忽略。或者进行强制类型转换DrawIcon(…, (HICON)hCursor)。程序运行后没有任何显示1. 随机坐标可能落在了屏幕外虽然我们用了GetSystemMetrics但需确保计算正确。2. 绘制速度太快肉眼无法捕捉。3. 获取 DC 或加载光标失败但错误处理被跳过。1. 在绘制前用printf或MessageBox输出posX, posY坐标检查是否在屏幕范围内。2. 增加sleep的时间如sleep_for(seconds(2))。3. 检查GetDC和LoadCursor的返回值确保不为NULL。画出的光标有黑色背景方块不透明使用了不包含透明掩码的光标资源或者使用了BitBlt而没有处理掩码。确保使用DrawIcon函数绘制它能正确处理透明效果。如果必须使用位图需要先处理掩码使用MaskBlt或分两次BitBltSRCAND 和 SRCPAINT。光标绘制的位置不对偏移忽略了光标的“热点”Hot Spot。DrawIcon的(X, Y)参数指定的是光标热点将被放置的位置而不是光标图像的左上角。这是正常行为。系统光标的热点通常是功能点如箭头尖。如果你需要以左上角为基准绘制需要自行计算偏移。可以使用GetIconInfo获取光标的热点坐标ptHotSpot然后调整绘制位置DrawIcon(hdc, x - ptHotSpot.x, y - ptHotSpot.y, hCursor)。程序退出后画上去的光标残影还在GDI 绘制的内容没有自动清除。当程序释放 DC 后绘制的内容就留在了屏幕帧缓冲区直到被其他绘制操作覆盖如下一个窗口刷新。这不是内存泄漏。GDI 屏幕绘图是直接的像素操作。要“擦除”可以在相同位置用背景色再画一次或者更简单的方法——什么也不做用户移动一下窗口或鼠标系统刷新就会覆盖它。6. 进阶优化与工程实践上面的例子是一个简单的演示。在实际项目中你需要考虑更多6.1 使用自定义光标文件不想用系统光标可以加载自己的.cur或.ani文件。// 方法1从文件加载 HCURSOR hMyCursor (HCURSOR)LoadImage( NULL, // 实例句柄加载文件时可为NULL L”mycursor.cur”, // 光标文件路径 IMAGE_CURSOR, // 类型为光标 0, 0, // 宽度和高度0表示使用实际大小 LR_LOADFROMFILE // 从文件加载的标志 ); // 使用后需要销毁DestroyCursor((HCURSOR)hMyCursor); // 方法2从应用程序资源加载需在.rc文件中定义光标资源 // HINSTANCE hInst GetModuleHandle(NULL); // HCURSOR hMyCursor LoadCursor(hInst, MAKEINTRESOURCE(IDC_MYCURSOR));6.2 实现持续显示与动画要让画上去的光标持续显示或移动你需要一个消息循环或定时器不断在新的位置重绘并在旧位置进行擦除用背景色覆盖或重绘背景。这涉及到双缓冲等技术来避免闪烁。一个简单的思路是记录上一次绘制的位置。在新的随机位置绘制光标。在上一次的位置绘制一个与背景相同的小方块或获取该位置的屏幕像素并恢复来“擦除”旧光标。更新记录的位置。6.3 绘制到指定窗口而非整个屏幕GetDC(NULL)获取的是整个屏幕。如果你只想在某个特定窗口上绘制可以获取该窗口的 DCHWND hTargetWnd FindWindow(NULL, L”目标窗口标题”); // 获取窗口句柄 HDC hWindowDC GetDC(hTargetWnd); // 获取窗口DC // … 使用 hWindowDC 进行绘制 … ReleaseDC(hTargetWnd, hWindowDC); // 释放窗口DC6.4 错误处理与资源管理务必检查每一个 API 调用的返回值如GetDC,LoadCursor,DrawIcon。使用GetLastError()获取错误代码。对于所有通过GetDC、CreateDC获取的 DC以及通过LoadImage非系统资源创建的光标、位图等 GDI 对象在使用完毕后必须调用相应的释放或销毁函数ReleaseDC,DeleteObject,DestroyCursor否则会造成 GDI 资源泄漏长期运行可能导致系统图形性能下降甚至崩溃。6.5 多显示器支持在现代多显示器系统中GetSystemMetrics(SM_CXSCREEN)获取的通常是主显示器的分辨率。要获取虚拟屏幕所有显示器组成的矩形区域的大小可以使用int virtualWidth GetSystemMetrics(SM_CXVIRTUALSCREEN); int virtualHeight GetSystemMetrics(SM_CYVIRTUALSCREEN);这样生成的随机坐标可以分布在整个跨屏区域内。7. 总结通过本文我们深入探讨了 Windows GDI 的基本概念并完成了一个在屏幕随机位置绘制鼠标光标的实战程序。我们学习了GDI 和 DC 的核心原理理解了在 Windows 上绘图的基本模型。关键 API掌握了GetDC、DrawIcon、LoadCursor、GetSystemMetrics等函数的使用。完整开发流程从创建项目、编写代码、配置属性到编译运行。问题排查分析了绘制不显示、光标不透明、位置偏移等常见问题的原因和解决方法。进阶方向了解了加载自定义光标、实现动画效果、多显示器支持等高级话题。这个技术点虽然小但它是理解 Windows 图形编程、屏幕取色、窗口截图、桌面水印等更复杂应用的基础。你可以尝试修改代码实现多个光标同时飞舞、让光标沿特定轨迹移动或者结合屏幕坐标获取做一个简单的屏幕标注工具。