
做上位机或者桌面自动化的人迟早会撞上这么个需求产线上有个老 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# 代码里没有FindWindow、PrintWindow只有一个MemoryMappedFile界面怎么做都行。两边只通过一块约定好的内存交互通信协议就是内存布局本身出问题很好排查。第二是抖动小。Socket 或者管道传输时客户端要等数据到达服务端要处理断线重连共享内存方式下C# 端哪怕卡了几百毫秒恢复后读到的仍然是 C 端最新写的帧不会有积压、不会延迟越来越大。这对长时间跑的监控程序太重要了我见过不少 Socket 方案跑两天后因为缓冲区堆积导致画面延迟十几秒的案例。2. 整体架构与内存布局设计2.1 进程职责划分与运行形态整个系统两个进程各管一段角色语言职责运行形态采集宿主CWin32/MFC 均可定位窗口、抓帧、写共享内存、发新帧信号独立 exe随系统/随主程序启动显示客户端C#WinForms/WPF打开共享内存、读帧、转 Bitmap、刷新 UI主程序 exe有一点要提醒C 采集宿主不建议做成 Windows 服务。服务跑在 Session 0默认看不到用户登录会话里的桌面窗口抓屏会变得非常麻烦。想让它开机自启用计划任务或者在 C# 主程序启动时拉起子进程都行让它作为普通用户态进程跑在同一个会话里最省心。2.2 共享内存中数据结构定义设计这块内存时我的原则是凡是可能变的东西都先冻结成协议。内存布局一旦定了C 和 C# 两边必须严格按同一张表解析。我惯用的布局是“固定控制区 帧数据区”帧数据里再分双缓冲槽位这个后面详细说先看单缓冲版本的定义。控制区和头部字段建议这么定偏移字段类型说明0x0000uint32魔数用于校验版本比如0x435750310x0004int32图像宽度0x0008int32图像高度0x000Cint32每行字节数 stride0x0010uint32像素格式0 表示 BGRA320x0014uint32帧序号单调递增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这类宽度明确的类型别用int和long这种模糊字眼。第二个坑是结构体填充。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_caststd::vectorWindowInfo*(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::vectorWindowInfo FindWindowsByProcess(const std::wstring processName) { std::vectorWindowInfo 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_castLPARAM(windows)); std::vectorWindowInfo 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