C++抓帧C#显示:共享内存实现跨进程窗口实时捕获
2026/9/9 6:20:30 网站建设 项目流程

做上位机或者桌面自动化的人,迟早会撞上这么个需求:产线上有个老 C++ 程序还在跑,没留对外接口,没有文档,可新做的 C# 监控系统却得实时看到它的窗口画面,用来做操作留痕、远程监看或者图像识别。我前前后后给不下五个项目搭过这条通道,对比过 Socket、命名管道、COM,最后稳定下来的方案其实特别直白:C++ 宿主负责抓窗口,把每一帧原始图像写进共享内存;C# 客户端负责从共享内存读帧、转成 Bitmap 上屏。两门语言各干各最擅长的活,中间只隔一块内存。这篇文章就把从定位窗口、抓帧、写共享内存,到 C# 读帧、上屏的完整链路拆开讲,附能直接抄的代码,还有那些文档里不会写的坑。

1. 为什么是“C++ 抓、C# 看”加共享内存

1.1 两个典型场景:老界面留痕与新界面取图

先说清楚这套架构解决什么。标题里的“C++ 宿主”,我理解成两种常见情况。

第一种,目标程序本身是 C++ 写的旧系统。它可能是个 MFC 监控台,也可能是某个厂家封装的工控软件,总之你拿不到源码,也没有 SDK,但审计或者管理要求你记录它的运行画面。这个时候,让另一个 C++ 小助手把它的窗口抓成帧,再交给 C# 上位机保存成图片或视频,是最省事的做法。

第二种反过来。你的采集引擎是 C++ 写的,负责调用海康相机、工业相机或者读串口,底层性能和实时性都很能打;但界面用 MFC/Qt 那套老工具链做起来太慢,团队更熟 C#。于是 C++ 程序作为后台宿主持续产出数据,C# 上位机只负责展示和业务逻辑。窗口捕获只是这条流水线上的一段,共享内存是两端的交接区。

我接手过的真实项目基本都能套进这两类:一个是生产车间里要把老工艺监控画面集中到大屏,一个是做视觉识别前需要把老软件界面上的数字区域抠出来送给 AI。两个项目最后都用了同一套“C++ 抓屏 + 共享内存 + C# 显示”的组合。它们共同的特点是:不需要侵入目标程序,不注入、不 Hook,避免了一堆兼容性和权限问题。

1.2 通信方案选型:共享内存对比 Socket、命名管道、COM

很多同事一开始会提出类似方案:C++ 把窗口截成图,存成 JPG,然后通过 TCP 或者命名管道发给 C# 不行吗?行,小分辨率低频没问题,但监控画面通常要跑 30 帧甚至更高,这时候差别就出来了。

通信方式单帧延迟大帧吞吐实现复杂度适合场景
TCP 回环中等(经过内核栈)中等,原始位图吃力跨机器传图、先压缩再传
命名管道中等中等命令、小块数据、结构化消息
COM 跨进程对象级接口调用,不适合帧流
共享内存极低,微秒级可见最高,无内核拷贝同机高频大块位图帧
写磁盘文件最高最低非实时留痕、日志回放

这里的关键是原始帧的数据量。一帧 1920x1080 的 32 位 BGRA 图像,不压缩是 8MB 左右。如果按 30 帧算,每秒 240MB。TCP 回环和命名管道理论上也能扛,但每一次 Write 都要进内核、有一次甚至两次拷贝,加上对端 Read 的同步等待,整个链路很容易变成瓶颈。

共享内存就不一样。CreateFileMapping建好之后,C++ 把图像memcpy到映射区,C# 在另一个进程里直接读同一块物理内存,中间没有内核缓冲参与。实测下来,1080P 的原始帧从 C++ 写完到 C# 读出来,几毫秒内完成很轻松。代价是要自己处理两个进程同时访问同一块区域的竞争问题,但帧数据是天然的“丢旧帧无所谓”业务,用第 5 节的方法,代价其实很小。

1.3 共享内存方案的两个隐性优势

除了快,这套选型还有两个表面看不出来的好处。

第一是职责清晰。C++ 代码只管 Win32 那套 GDI 资源:窗口句柄、HDC、HBITMAP、内存映射文件,类型都是原生资源,不需要考虑 P/Invoke 封装的坑。C# 代码里没有FindWindowPrintWindow,只有一个MemoryMappedFile,界面怎么做都行。两边只通过一块约定好的内存交互,通信协议就是内存布局本身,出问题很好排查。

第二是抖动小。Socket 或者管道传输时,客户端要等数据到达,服务端要处理断线重连;共享内存方式下,C# 端哪怕卡了几百毫秒,恢复后读到的仍然是 C++ 端最新写的帧,不会有积压、不会延迟越来越大。这对长时间跑的监控程序太重要了,我见过不少 Socket 方案跑两天后因为缓冲区堆积导致画面延迟十几秒的案例。

2. 整体架构与内存布局设计

2.1 进程职责划分与运行形态

整个系统两个进程,各管一段:

角色语言职责运行形态
采集宿主C++(Win32/MFC 均可)定位窗口、抓帧、写共享内存、发新帧信号独立 exe,随系统/随主程序启动
显示客户端C#(WinForms/WPF)打开共享内存、读帧、转 Bitmap、刷新 UI主程序 exe

有一点要提醒:C++ 采集宿主不建议做成 Windows 服务。服务跑在 Session 0,默认看不到用户登录会话里的桌面窗口,抓屏会变得非常麻烦。想让它开机自启,用计划任务或者在 C# 主程序启动时拉起子进程都行,让它作为普通用户态进程跑在同一个会话里最省心。

2.2 共享内存中数据结构定义

设计这块内存时,我的原则是:凡是可能变的东西都先冻结成协议。内存布局一旦定了,C++ 和 C# 两边必须严格按同一张表解析。我惯用的布局是“固定控制区 + 帧数据区”,帧数据里再分双缓冲槽位,这个后面详细说,先看单缓冲版本的定义。

控制区和头部字段建议这么定:

偏移字段类型说明
0x0000uint32魔数,用于校验版本,比如0x43575031
0x0004int32图像宽度
0x0008int32图像高度
0x000Cint32每行字节数 stride
0x0010uint32像素格式,0 表示 BGRA32
0x0014uint32帧序号,单调递增
0x0018uint64时间戳
0x0020uint32状态/请求标志
0x0100bytes图像数据起始,建议对齐到 64 字节

数据区放 0x0100 而不是紧贴头部,是因为 64 字节对齐对后面的 GPU 上传、SIMD 处理都友好。刚开始就按这个规矩来,以后想把这帧数据直接塞给显卡纹理就方便了。

2.3 对齐、命名与大小计算,开头就避坑

跨语言共享结构体,第一个大坑是类型宽度不对齐。C++ 的long在 Windows 上是 4 字节,C# 的long是 8 字节;bool两边长度也不一样。跨进程共享的数据结构里,只准用int32_t/uint32_t/int64_t/uint64_t这类宽度明确的类型,别用intlong这种模糊字眼。第二个坑是结构体填充。C++ 默认按成员对齐可能会在字段间塞填充字节,C# 的StructLayout默认是Sequential但 Pack 行为不一定一致。我建议两边都显式指定 1 字节紧凑对齐,C++ 用#pragma pack(push, 1),C# 用[StructLayout(LayoutKind.Sequential, Pack = 1)],然后字段顺序完全一致。

共享内存的命名也有讲究。CreateFileMapping这类 API 创建出来的命名 Section,名字带Local\前缀表示只在当前登录会话内可见,带Global\前缀才是跨会话可见。普通的桌面级 C++ 宿主和 C# 客户端都在同一个用户会话里跑,用Local\就够了;只有被捕获端、采集宿主、客户端跨了服务会话,才需要考虑Global\以及权限描述符的问题。

大小计算别省。映射文件的总大小 = 头部偏移 + 图像行数 × stride。C++ 创建映射时把最大大小写死,如果以后要适配不同分辨率窗口,可以分配一个足够大的上限(比如 4K×4K 的原始帧约 64MB),或者每次分辨率变化时重建映射,不要一边写一边动态扩展。

3. C++ 宿主端:抓窗口并写帧

3.1 如何稳定定位目标窗口

抓窗口的起点是拿到目标窗口的HWND。简单场景用FindWindowW直接按窗口标题找,但许多真实程序的标题会带动态信息,比如“生产监控 - 工单号 20250101”,写死标题很容易失效。我用得最稳的是遍历窗口,按进程名过滤,再叠加窗口可见性判断:

#include <windows.h> #include <tlhelp32.h> #include <string> #include <vector> struct WindowInfo { HWND hwnd; std::wstring title; DWORD pid; }; BOOL CALLBACK EnumWindowProc(HWND hwnd, LPARAM lParam) { auto* results = reinterpret_cast<std::vector<WindowInfo>*>(lParam); if (!IsWindowVisible(hwnd)) return TRUE; // 只看可见窗口 DWORD pid = 0; GetWindowThreadProcessId(hwnd, &pid); wchar_t title[512] = {}; GetWindowTextW(hwnd, title, 512); WindowInfo info{ hwnd, title, pid }; results->push_back(info); return TRUE; } std::vector<WindowInfo> FindWindowsByProcess(const std::wstring& processName) { std::vector<WindowInfo> windows; DWORD targetPid = 0; HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); PROCESSENTRY32W pe{ sizeof(PROCESSENTRY32W) }; if (Process32FirstW(snap, &pe)) { do { if (processName == pe.szExeFile) { targetPid = pe.th32ProcessID; break; } } while (Process32NextW(snap, &pe)); } CloseHandle(snap); if (targetPid == 0) return windows; EnumWindows(EnumWindowProc, reinterpret_cast<LPARAM>(&windows)); std::vector<WindowInfo> matched; for (auto& w : windows) { if (w.pid == targetPid) matched.push_back(w); } return matched; }

如果目标进程有多个窗口,可以再加一层过滤条件:取窗口矩形面积最大的、取带特定标题关键词的,或者干脆把候选窗口列表通过共享内存暴露给 C#,由用户选择。这套做法比写死标题健壮得多。

这里还有一个容易翻车的细节:抓取进程和被抓进程的权限完整性级别要匹配。如果捕获程序是以普通权限跑去抓一个“以管理员身份运行”的窗口,PrintWindow会因为 UIPI 隔离直接失败。遇到这种情况,让捕获程序同样以管理员权限启动即可,或者统一不要提权。

3.2 用 PrintWindow + BitBlt 抓窗口内容

拿到HWND之后,就该把窗口内容画到内存 DC 里。这里有两个常见姿势,我的策略是 PrintWindow 优先,BitBlt 兜底。

PrintWindow会向目标窗口发送 WM_PRINT 系列消息,让窗口自己往我们给的 HDC 上绘制。它的优点是被遮挡的窗口也能抓到,因为不是从屏幕像素抠的。Windows 8.1 以后,PrintWindow带上PW_RENDERFULLCONTENT标志,会请求 DWM 把窗口的完整渲染结果(包括阴影、圆角、动画后的状态)交出来,很多现代 UI 控件只有用这个标志才拿得到内容。

BitBlt是从屏幕 DC 拷贝指定矩形,它只能抓到屏幕当前可见的部分。你要抓的窗口被别的窗口挡住,BitBlt就会把挡在前面的窗口一起抓进去,完全不是你想要的。所以它只配当兜底方案。

下面这段是我常用的抓帧函数,直接抓成自上而下的 32 位 BGRA 原始像素:

#include <vector> bool Capture

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

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

立即咨询