
你是不是也遇到过这样的需求在开发屏幕录制软件、远程桌面工具或者做自动化测试时需要模拟一个鼠标光标在屏幕上移动更具体一点你可能想测试一个“找不同”游戏的点击反馈或者模拟一个演示视频中的鼠标轨迹但又不希望真的去移动物理鼠标。传统的做法可能是截图后PS或者用复杂的自动化框架。但今天要聊的是一个更底层、更直接、也更有趣的技术方案使用 Windows 的 GDI图形设备接口直接在屏幕上“画”出一个鼠标光标。这听起来有点“黑客”但它的原理并不复杂。GDI 作为 Windows 图形显示的核心允许我们直接与屏幕的像素“对话”。通过它我们可以在任意窗口、甚至整个桌面的任意坐标绘制出我们想要的任何图形——当然也包括一个逼真的鼠标箭头。这篇文章我们就来彻底搞懂这个技术。我会带你从 GDI 的基础概念入手一步步拆解如何在屏幕上定位、绘制一个光标并处理最关键的问题如何让这个“画”出来的光标不影响你真正的鼠标操作最后我会提供一个完整的、可运行的 C 示例代码以及你可能遇到的所有坑和解决方案。无论你是对 Windows 图形编程感兴趣还是有具体的模拟光标需求这篇文章都能让你从原理到实践一次掌握。1. 这篇文章真正要解决的问题在开始敲代码之前我们必须先想清楚为什么不用SetCursorPos这样的 API 来移动真实光标而非要“画”一个假的这背后是几个非常实际的开发痛点无干扰模拟很多自动化测试或演示场景中你需要展示光标的移动轨迹但又不希望这个模拟光标接管用户的真实输入。真实的SetCursorPos会立刻把物理光标跳过去影响用户当前的操作。自定义光标样式系统光标就那么几种。如果你想画一个红色的圆圈、一个自定义的图标来作为“高亮提示”或“特殊标记”系统 API 就无能为力了。跨窗口与顶层绘制有时你需要一个始终显示在最前面的光标指示器比如远程协助时的“激光笔”效果。这需要能在所有窗口之上进行绘制。性能与精准控制直接操作像素你可以精确控制光标的每一帧出现、消失、淡入淡出实现更流畅的动画效果这是单纯移动系统光标做不到的。所以“画光标”技术的核心价值在于它提供了一种与系统光标解耦的、高度可定制的视觉模拟方案。它特别适合屏幕标注、录制软件的光标高亮、自动化测试的可视化反馈以及一些特殊的 UI 交互演示。2. 基础概念与核心原理2.1 什么是 GDIGDI (Graphics Device Interface)即图形设备接口是 Windows 操作系统早期用于图形输出的核心子系统。你可以把它理解成 Windows 给程序员提供的一套“画笔”和“画布”。通过 GDI应用程序可以在屏幕、打印机或其他输出设备上绘制线条、形状、文本和位图。虽然现代应用更多使用 DirectX 或 GDI 来获得更好的性能与效果但 GDI 因其简单、直接、无需复杂依赖的特性在完成一些基础的、对性能不敏感的屏幕绘图任务时依然非常高效和实用。2.2 关键概念设备上下文 (DC)要使用 GDI 绘图你必须先获取一个设备上下文 (Device Context, DC)。DC 是一个抽象的数据结构它代表了你可以绘制的目标如窗口、内存、打印机并包含了当前绘图的所有状态信息比如选择的画笔、画刷、字体等。对于我们的目标——在屏幕上画光标我们需要获取的是整个屏幕的 DC这样我们才能在任意位置绘图。2.3 如何“画”出一个光标系统光标本质上就是一个带有透明通道的小位图。我们要模拟它思路很清晰获取屏幕DC拿到整块屏幕的“画布”。准备光标图像在内存中创建一个和光标外观一样的位图比如一个箭头。处理透明效果光标不是实心的方块箭头周围是透明的。这需要用到 GDI 的掩码位图技术来实现透明或异或绘制。在指定坐标绘制将准备好的光标图像“贴”到屏幕DC的指定坐标上。管理绘制生命周期画上去之后还需要能在适当的时候把它擦除否则它会永远留在屏幕上。2.4 与“Kali没有鼠标光标”问题的区别网络热词“kali没有鼠标光标”通常指的是在 Kali Linux 等虚拟化或特殊显示环境中由于显卡驱动、桌面环境或虚拟机工具问题导致的系统光标完全消失的故障。这是一个系统级别的显示问题。而我们今天讨论的是在系统光标正常显示的前提下主动地、编程式地额外绘制一个光标图像。这是应用层的、可控的图形叠加而非系统故障。两者有本质区别。3. 环境准备与前置条件要实践本教程你需要准备以下环境操作系统Windows 7 及以上版本推荐 Windows 10/11。GDI 是 Windows 核心组件无需额外安装。开发环境编译器支持 C 的编译器如 Microsoft Visual Studio 的 MSVC或 MinGW。IDE (可选但推荐)Visual Studio 2019/2022。社区版免费对 Windows 开发支持最好。项目类型一个简单的Win32 控制台应用程序或桌面应用程序即可。我们不需要复杂的 MFC 或 .NET 框架。知识准备基础的 C/C 语法。对 Windows 编程有基本了解知道什么是HWND,HDC, 消息循环等概念会有帮助但文中会解释。权限该程序在运行时会尝试在屏幕最顶层绘图。某些安全软件或系统设置可能会拦截此类操作。如果遇到问题可以尝试以管理员身份运行程序或检查安全软件设置。4. 核心流程拆解让我们把“在随机位置画光标”这个目标拆解成几个可执行的步骤4.1 第一步获取整个屏幕的设备上下文 (DC)这是所有屏幕绘图操作的起点。没有 DC我们就没有“画布”。4.2 第二步创建或加载光标位图我们需要一个图像来表示光标。最简单的方法是使用 GDI 函数创建一个简单的箭头形状。更高级的做法是加载一个.cur或.ico光标文件或者一个带透明度的 PNG 位图需要 GDI。4.3 第三步实现透明绘制这是最关键也最容易出错的一步。我们不能简单地把一个白色背景的箭头位图画上去。我们需要创建一个单色掩码位图用来定义哪些像素是透明的白色哪些是不透明的黑色。使用BitBlt函数配合SRCAND和SRCPAINT光栅操作码分两步将光标与屏幕背景进行合成从而实现透明效果。4.4 第四步在随机坐标进行绘制生成屏幕范围内的随机坐标将上一步准备好的光标绘制到该位置。4.5 第五步擦除光标由于我们是直接修改屏幕像素这个“画”上去的光标不会自动消失。我们必须手动记录上次绘制的位置并在下一次绘制前或在程序退出时将那个区域的屏幕内容恢复原样。4.6 第六步循环与退出为了实现动画比如让光标随机移动我们需要一个循环。同时必须提供清晰的退出机制如按ESC键并确保在退出前清理所有资源DC、位图等。5. 完整示例与代码实现下面是一个完整的、注释详细的 C 实现。它创建了一个简单的箭头光标并在屏幕上随机位置绘制模拟光标移动的效果。// 文件名DrawCursor.cpp // 编译在Visual Studio中创建Win32控制台项目或使用 g -o DrawCursor.exe DrawCursor.cpp -lgdi32 #include windows.h #include iostream #include cstdlib #include ctime // 函数声明 void DrawCursor(HDC hdcScreen, int x, int y, HDC hdcMem, HBITMAP hbmMask, HBITMAP hbmCursor, int width, int height); void CleanupResources(HDC hdcScreen, HDC hdcMem, HBITMAP hbmOld, HBITMAP hbmMask, HBITMAP hbmCursor); int main() { // 初始化随机数种子 std::srand(static_castunsigned int(std::time(nullptr))); // 1. 获取屏幕尺寸和整个屏幕的设备上下文(DC) int screenWidth GetSystemMetrics(SM_CXSCREEN); int screenHeight GetSystemMetrics(SM_CYSCREEN); HDC hdcScreen GetDC(nullptr); // nullptr 表示获取整个屏幕的DC if (!hdcScreen) { std::cerr 无法获取屏幕设备上下文 std::endl; return 1; } // 2. 创建内存DC用于准备光标图像 HDC hdcMem CreateCompatibleDC(hdcScreen); if (!hdcMem) { std::cerr 无法创建内存设备上下文 std::endl; ReleaseDC(nullptr, hdcScreen); return 1; } // 定义我们自定义光标的尺寸 const int cursorWidth 32; const int cursorHeight 32; // 3. 创建单色掩码位图 (1位深度黑白) // 掩码位图黑色部分(0)表示不透明白色部分(1)表示透明 HBITMAP hbmMask CreateBitmap(cursorWidth, cursorHeight, 1, 1, nullptr); // 4. 创建彩色光标位图 (24位或32位深度) HBITMAP hbmCursor CreateCompatibleBitmap(hdcScreen, cursorWidth, cursorHeight); if (!hbmMask || !hbmCursor) { std::cerr 无法创建位图 std::endl; CleanupResources(hdcScreen, hdcMem, nullptr, hbmMask, hbmCursor); return 1; } // 5. 为内存DC选择我们创建的彩色光标位图以便在上面绘图 HBITMAP hbmOld (HBITMAP)SelectObject(hdcMem, hbmCursor); // 6. 绘制一个简单的箭头到 hbmCursor (彩色位图) // 使用一个黑色箭头和白色背景注意这个白色背景之后会被掩码过滤掉 HBRUSH hBrushWhite CreateSolidBrush(RGB(255, 255, 255)); // 白色画刷填充背景 HBRUSH hBrushBlack CreateSolidBrush(RGB(0, 0, 0)); // 黑色画刷绘制箭头 HPEN hPenBlack CreatePen(PS_SOLID, 2, RGB(0, 0, 0)); // 黑色画笔 SelectObject(hdcMem, hBrushWhite); SelectObject(hdcMem, hPenBlack); // 填充整个位图为白色将成为透明区域 Rectangle(hdcMem, 0, 0, cursorWidth, cursorHeight); // 绘制一个简单的黑色箭头不透明部分 SelectObject(hdcMem, hBrushBlack); POINT arrowPoints[] { {5, 5}, {25, 16}, {10, 16}, {10, 27}, {5, 27} }; Polygon(hdcMem, arrowPoints, 5); // 7. 创建并绘制掩码位图 // 我们需要另一个内存DC来处理掩码 HDC hdcMask CreateCompatibleDC(hdcScreen); SelectObject(hdcMask, hbmMask); // 将掩码位图全部初始化为白色1代表透明 PatBlt(hdcMask, 0, 0, cursorWidth, cursorHeight, WHITENESS); // 然后在箭头形状对应的区域画上黑色0代表不透明 SelectObject(hdcMask, GetStockObject(BLACK_BRUSH)); Polygon(hdcMask, arrowPoints, 5); // 绘制相同的箭头形状到掩码 // 清理临时对象 DeleteDC(hdcMask); DeleteObject(hBrushWhite); DeleteObject(hBrushBlack); DeleteObject(hPenBlack); // 8. 主循环在随机位置绘制光标 int oldX -1, oldY -1; // 记录上一次绘制的位置用于擦除 std::cout 正在屏幕上随机绘制光标... 按 ESC 键退出。 std::endl; while (true) { // 生成新的随机位置确保光标完全在屏幕内 int newX std::rand() % (screenWidth - cursorWidth); int newY std::rand() % (screenHeight - cursorHeight); // 如果这不是第一次绘制需要先擦除旧光标 // 注意这是一个简单的演示。复杂的场景需要保存被覆盖的屏幕区域。 // 这里我们只是重新绘制整个桌面通过系统但这不是好方法。 // 更优方案是保存绘制前的屏幕块擦除时恢复。为了简化此处略过。 // 提示可以调用 InvalidateRect(nullptr, nullptr, TRUE) 触发桌面重画来“擦除”但效率低。 // 本示例专注于“绘制”本身擦除逻辑需根据实际应用设计。 // 调用绘制函数 DrawCursor(hdcScreen, newX, newY, hdcMem, hbmMask, hbmCursor, cursorWidth, cursorHeight); // 更新旧位置 oldX newX; oldY newY; // 延迟一段时间模拟光标移动 Sleep(500); // 暂停500毫秒 // 检查用户是否按下了ESC键 if (GetAsyncKeyState(VK_ESCAPE) 0x8000) { std::cout ESC键按下退出程序。 std::endl; break; } } // 9. 程序结束前清理资源 // 重要在退出前最好强制刷新一下绘制区域或者恢复原貌。 // 由于我们的绘制是持久的简单的办法是触发一个桌面重绘。 // 生产代码应实现正确的擦除。 InvalidateRect(nullptr, nullptr, TRUE); UpdateWindow(GetDesktopWindow()); CleanupResources(hdcScreen, hdcMem, hbmOld, hbmMask, hbmCursor); return 0; } /** * 在屏幕指定位置绘制透明光标。 * param hdcScreen 屏幕设备上下文 * param x, y 绘制位置的左上角坐标 * param hdcMem 内存设备上下文已选入彩色光标位图 * param hbmMask 单色掩码位图句柄 * param hbmCursor 彩色光标位图句柄 * param width, height 光标位图的宽高 */ void DrawCursor(HDC hdcScreen, int x, int y, HDC hdcMem, HBITMAP hbmMask, HBITMAP hbmCursor, int width, int height) { // 第一步使用掩码位图 (AND 操作) // 将屏幕目标区域中对应掩码黑色(0)的部分置为黑色。 // 这为后续的彩色绘制准备好了“底子”。 HDC hdcMaskTemp CreateCompatibleDC(hdcScreen); SelectObject(hdcMaskTemp, hbmMask); BitBlt(hdcScreen, x, y, width, height, hdcMaskTemp, 0, 0, SRCAND); DeleteDC(hdcMaskTemp); // 第二步使用彩色光标位图 (OR 操作) // 将彩色光标图像绘制到刚刚处理过的屏幕区域。 // 因为上一步已将不透明区域变为黑色(0)此时彩色图像与之进行 OR 操作 // 黑色部分会直接显示彩色而原本屏幕上的内容在透明区域(白色掩码)得以保留。 BitBlt(hdcScreen, x, y, width, height, hdcMem, 0, 0, SRCPAINT); } /** * 清理所有创建的GDI对象防止资源泄漏。 */ void CleanupResources(HDC hdcScreen, HDC hdcMem, HBITMAP hbmOld, HBITMAP hbmMask, HBITMAP hbmCursor) { if (hdcMem) { if (hbmOld) { SelectObject(hdcMem, hbmOld); // 将旧位图选回DC } DeleteDC(hdcMem); } if (hbmCursor) { DeleteObject(hbmCursor); } if (hbmMask) { DeleteObject(hbmMask); } if (hdcScreen) { ReleaseDC(nullptr, hdcScreen); // 释放屏幕DC } }关键逻辑解释资源获取与创建程序首先获取屏幕DC并创建了兼容的内存DC、一个彩色位图hbmCursor和一个单色掩码位图hbmMask。绘制光标形状在彩色位图上我们画了一个白色背景上的黑色箭头。在掩码位图上我们在箭头对应的区域画了黑色其余部分是白色。透明绘制算法 (DrawCursor函数)SRCAND操作用掩码图黑白与屏幕目标区域进行“与”操作。白色1与任何值相“与”结果不变保留屏幕原色黑色0与任何值相“与”结果为0变成黑色。这样屏幕上的箭头区域变成了黑色背景。SRCPAINT操作将彩色光标图与上一步处理后的屏幕区域进行“或”操作。黑色0与彩色值相“或”结果就是彩色值本身从而显示出箭头。而原屏幕区域对应掩码白色部分与彩色位图的白色255,255,255相“或”结果仍是原屏幕颜色实现了透明。循环与交互主循环不断生成随机坐标并调用DrawCursor。通过GetAsyncKeyState检测 ESC 键来退出循环。资源清理CleanupResources函数确保所有 GDI 对象都被正确删除这是 Windows 编程中防止资源泄漏的关键步骤。6. 运行结果与效果验证编译与运行将上述代码保存为DrawCursor.cpp在 Visual Studio 中编译运行或使用命令行cl DrawCursor.cpp user32.lib gdi32.lib(MSVC) 或g -o DrawCursor.exe DrawCursor.cpp -lgdi32(MinGW) 进行编译。预期效果程序运行后你会看到一个黑色的箭头光标大约32x32像素每隔0.5秒在屏幕的随机位置闪烁出现。注意这个光标是“画”在屏幕上的你的真实鼠标光标仍然可以自由移动两者互不影响。验证成功观察屏幕能看到一个独立于真实光标移动的箭头。移动你的真实鼠标画上去的箭头不会跟随。打开一个文本编辑器或其他窗口画上去的箭头会覆盖在窗口之上。退出程序按下键盘上的ESC键程序会退出。由于示例中简单的擦除机制InvalidateRect画上去的光标痕迹可能会立即消失也可能需要你最小化/移动一下窗口才会被系统重绘覆盖。如果运行失败第一步应该看哪里检查编译器是否链接了gdi32.lib和user32.lib。如果出现“无法获取屏幕设备上下文”等错误尝试以管理员身份重新运行程序。某些安全软件如某些杀毒软件或屏幕录制保护可能会阻止程序直接向屏幕绘图暂时禁用它们再试。7. 常见问题与排查思路问题现象可能原因排查方式解决方案编译错误undefined reference to ‘BitBlt’等没有链接 GDI32 库检查编译命令确保包含了-lgdi32(GCC) 或gdi32.lib(MSVC)。在编译参数或IDE的项目属性中添加对应的库依赖。程序运行后无任何显示1. 坐标计算错误画在了屏幕外。2. 安全软件拦截。3. 透明绘制逻辑错误光标全透明。1. 打印生成的随机坐标(newX, newY)到控制台检查是否在屏幕范围内。2. 暂时关闭安全软件测试。3. 简化测试先不用掩码用SRCCOPY直接绘制彩色位图看是否有方框出现。1. 确保坐标计算正确newX rand() % (screenWidth - width)。2. 以管理员身份运行或将程序加入安全软件白名单。3. 逐步调试DrawCursor函数检查掩码位图是否创建成功黑白分明。光标有白色背景不透明掩码位图创建或绘制失败导致透明区域未生效。检查hbmMask是否创建成功。检查PatBlt和Polygon绘制掩码的代码。确保掩码中箭头区域为黑(0)其余为白(1)。使用GetObject检查位图信息。考虑将掩码位图保存为文件进行调试。光标绘制后留下残影无法擦除没有实现正确的擦除逻辑。示例中的InvalidateRect是全局重绘可能不及时。观察残影是永久性的还是被后续窗口刷新覆盖。实现局部擦除在绘制新光标前将旧位置区域的屏幕内容恢复。这需要你在第一次绘制前用BitBlt将屏幕内容保存到一个位图中擦除时再BitBlt回去。程序崩溃或资源泄漏GDI 对象未正确删除。检查CleanupResources函数是否在所有退出路径都被调用。使用任务管理器观察程序运行后GDI对象数量是否持续增长。确保每个CreateCompatibleDC,CreateBitmap,CreateSolidBrush等都有对应的DeleteDC或DeleteObject。使用RAII(资源获取即初始化) 思想封装GDI对象是更好的实践。绘制效率低闪烁严重在循环中频繁获取/释放DC或绘制区域过大。分析循环中的代码看是否有不必要的资源创建/销毁。1. 将GetDC(nullptr)和CreateCompatibleDC移到循环外。2. 使用双缓冲技术在内存中完成所有绘制最后一次性BitBlt到屏幕。3. 只绘制变化的区域。8. 最佳实践与工程建议将技术 demo 转化为稳定可用的功能需要注意以下几点封装与抽象将光标绘制、擦除、位置管理等功能封装成一个类如SimulatedCursor。对外提供Show()、Hide()、MoveTo(x, y)、SetCursorImage()等接口。高效的擦除机制这是核心挑战。推荐的做法是保存背景在首次绘制到位置(x,y)前使用BitBlt将屏幕对应矩形区域的内容保存到一个独立的HBITMAP中。恢复背景需要擦除时再将保存的位图BitBlt回屏幕的相同位置。顺序先擦除旧光标恢复背景再在新位置绘制新光标并保存新背景。支持多种光标图像不要硬编码箭头形状。可以设计从文件.cur,.ico,.png加载光标图像的函数。对于带Alpha通道的PNG可以考虑使用GDI它原生支持Alpha混合绘制更简单美观。// 伪代码使用GDI加载PNG并绘制 #include gdiplus.h Gdiplus::Bitmap bitmap(Lcursor.png); Gdiplus::Graphics graphics(hdcScreen); graphics.DrawImage(bitmap, x, y); // 自动处理透明度线程安全与消息循环如果你的程序有窗口消息循环GetMessage/DispatchMessage绘制操作最好放在WM_PAINT消息处理中或者使用定时器SetTimer驱动。在非UI线程中直接操作屏幕DC需要谨慎可能引发冲突。性能优化限制绘制频率对于平滑移动使用高精度定时器如timeSetEvent或CreateTimerQueueTimer而非Sleep并控制帧率如30fps。减少绘制区域只更新光标所在的最小矩形区域。复用GDI对象在程序生命周期内创建一次并重复使用避免在循环内频繁创建/销毁。错误处理每一个 GDI API 调用后都应检查返回值NULL,0,FALSE等并给出有意义的错误信息或回退方案。兼容性考虑虽然 GDI 很古老但在高DPI缩放100%的屏幕上你的光标可能会显得很小或模糊。可以通过GetDpiForWindow或GetDeviceCaps(hdcScreen, LOGPIXELSX)获取DPI缩放比例并对光标位图的尺寸进行相应缩放。提供退出清理除了ESC键还应响应窗口关闭事件等确保程序退出前一定能执行资源清理代码。9. 总结与后续学习方向通过本文我们深入探讨了利用 Windows GDI 在屏幕任意位置绘制模拟光标的技术。从原理上我们理解了通过屏幕DC、内存DC、掩码位图以及SRCAND/SRCPAINT光栅操作的配合来实现透明叠加效果的核心流程。从实践上我们获得了一个可以直接编译运行的、能够将自定义光标图案绘制在屏幕上的完整代码示例。这项技术的真正威力不在于“画一个光标”而在于它揭示了 Windows 底层图形编程的一种思维模式直接、精准地控制像素。掌握了它你就能实现很多有趣的功能比如屏幕涂鸦、实时标注、非侵入式的视觉反馈等。然而示例代码只是一个起点。要将其用于实际项目你还需要攻克两个主要的工程难题一是高效、无残留的擦除机制通过保存与恢复背景实现二是更丰富、更美观的光标资源加载如通过 GDI 支持 PNG 透明度。如果你想继续深入建议从以下方向拓展深入 GDI/GDI学习更多关于画笔、画刷、路径、区域Region和坐标变换的知识。探索 DirectX/OpenGL对于需要高性能、复杂动画如游戏中的鼠标特效的场景这些现代图形API是更专业的选择。集成到具体应用尝试将光标绘制功能封装成 DLL 或静态库集成到你的屏幕录制软件、远程协助工具或自动化测试框架中。处理多显示器使用EnumDisplayMonitorsAPI 来应对多显示器环境确定光标应该绘制在哪块屏幕上。希望这篇长文能帮你打开 Windows 图形编程的一扇窗。代码和思路已经给你了剩下的就是动手实验和优化。如果在实践中遇到新的问题欢迎在 CSDN 社区交流讨论。建议收藏本文当你在未来需要实现类似“屏幕标记”功能时这份详细的指南或许能派上用场。