ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Visual C++进程间通信实战:命名管道与共享内存实现详解

Visual C++进程间通信实战:命名管道与共享内存实现详解 简介这是一份面向Visual C编程学习者的示例工程源码用于演示如何在两个独立运行的程序之间交换数据适合课程实验、自学练手或作为小型MFC工程的参考。资源包为rar压缩格式共18个文件整体仅152KB规模小巧、结构清晰便于下载后快速解压查看其中4个h和3个cpp构成源码主体rc/rc2/ico/manifest负责界面菜单、图标与外观声明sln/vcproj保存可编译的工程配置Release目录下还带有exe可执行程序另有aps/ncb等Visual Studio工程辅助文件便于还原开发环境。目前已有279人浏览学习。读者可以获得一套完整的对话框程序示例既能从头查看入口函数、对话框类实现和预编译头设置也能对照资源脚本理清界面控件与代码的关联理解两个程序间通信示例的总体编写思路对话框模块中的界面逻辑与资源文件中的控件ID相互对应便于二次修改时快速定位通过运行Release成品exe可直观看到效果再回到源码定位关键代码。整个工程保留了完整的VC项目结构和生成产物便于按需修改扩展。1. 为什么“两个执行程序互发数据”在 Visual C 里是个真问题写 C/S 架构程序时最常见的一个坑不是业务逻辑而是本体进程和辅助进程之间那条数据通道。比如你做了一个监控程序希望一个常驻后台的 Agent 把采集到的数据喂给 UI 界面或者你做了一个升级器希望主程序把待升级的文件路径和版本号告诉它。这时候两个独立的 .exe 不能像线程一样共享全局变量也不能直接调用对方的函数地址唯一的办法就是走系统提供的进程间通信机制。标题里的“两个执行程序间进行数据通信”在 Visual C 里其实对应着一整套成熟的 Win32 API 方案从简单的消息广播到高性能的共享内存每种方案的时效性、数据大小和跨进程安全性都不一样。我见过很多人在这个环节直接把数据写在文件里靠轮询读结果延迟高且容易产生半包也有人用套接字但为了几个字节的配置白白引入网络协议栈。这篇文章就从选型开始把命名管道和共享内存这两种最常用的方式用可编译的 Visual C 代码讲透并附带参数调整和踩坑点。适合已经会写基本 Win32 程序、但第一次做跨进程数据交换的开发者。2. 进程间通信的选型管道、共享内存、WM_COPYDATA 怎么选2.1 先看数据特征再定技术路线做进程间通信的第一步不是写代码而是确认你要传什么、传多大、多久传一次。如果只是传一个简单的字符串命令比如“开始采集”或“暂停上传”用 WM_COPYDATA 或命名管道都足够如果要传几十 MB 的实时图像或日志流共享内存几乎是唯一合理的选择如果是一对多广播则考虑邮槽或命名管道多实例。把这几个维度列成表选型就清楚得多。通信方式数据方向最大数据量跨网络开发成本适用场景WM_COPYDATA单向发送到窗口受消息限制一般建议1MB否极低UI 进程向窗口发送简单数据命名管道双向/单向流式传输无严格上限支持同网络中一对一的请求/响应结构化数据共享内存 互斥体多向大受虚拟内存限制否需配合其他机制高大数据量、高吞吐、低延迟套接字TCP/UDP双向大支持中高跨机器通信或统一使用网络层邮槽单向广播消息式最大约64KB支持但不可靠低局域网内的简单广播通知2.2 为何命名管道是最稳妥的默认选择命名管道Named Pipe在 Windows 中其实是一个内核对象它不依赖网络栈也不需要额外启动服务创建时只要指定一个全局唯一的名字比如\\.\pipe\MyAgentPipe任何进程都能通过这个名字打开连接。它和匿名管道最大的区别在于名称可见性所以两个独立启动的 .exe 才能找到对方。Visual C 里使用命名管道主要涉及三组函数CreateNamedPipe创建服务端管道的实例ConnectNamedPipe等待客户端连接ReadFile/WriteFile读写数据另一端只需要CreateFile打开管道名然后读写即可。这里有一个常被忽略的细节CreateNamedPipe是个可重入函数你可以多次调用它创建多个实例每个实例对应一个客户端连接。如果只调用一次那同一时刻只能服务一个客户端第二个客户端调用CreateFile会返回ERROR_PIPE_BUSY。所以我在写生产级代码时通常会让服务端启动 4 个线程每个线程各自调用一次CreateNamedPipe并进入ConnectNamedPipe等待形成一个简单的连接池。2.3 共享内存的优势和代价共享内存的思路是把一份物理内存映射到多个进程的虚拟地址空间进程 A 写入进程 B 直接读取中间没有任何拷贝和序列化开销。在 Visual C 中实现步骤是服务端调用CreateFileMapping创建一个命名内存映射文件对象然后MapViewOfFile得到首地址客户端用OpenFileMapping打开同一个名字再MapViewOfFile得到自己的地址。两个进程此时看到的是同一块物理内存写操作对另一端立即可见。代价首先是并发控制。你必须自己维护一个互斥体或读写锁否则两个进程同时写同一块内存会撕裂数据。其次是生命周期管理如果服务端进程退出时没有UnmapViewOfFile而且映射对象没有关闭可能导致数据残留或客户端读取到不完整的数据。第三共享内存无法跨越网络只能本机使用。所以在需要高吞吐的场景下选它是正确的但如果只是传几个配置项属实用不划算。2.4 什么时候用 WM_COPYDATAWM_COPYDATA 是 Windows 提供的一种基于消息的机制。进程 A 调用SendMessage发送一个WM_COPYDATA消息消息的lParam指向一个COPYDATASTRUCT结构里面可以带一个指向数据的指针。接收方必须是一个有窗口句柄的进程。Visual C 的 MFC 程序里处理这个消息只需要重载OnCopyData。它最快的优势是简单几十行代码就能跑通。但限制也很明显发送方会阻塞直到接收方处理完消息且数据不能包含指针或句柄因为它们只在发送进程的地址空间有效。此外用户账户控制UAC的权限隔离会导致跨权限进程收不到消息。所以它只适合同权限下的轻量数据传递。提示选型时一定要评估“进程权限是否一致”。如果两个 .exe 一个以管理员启动一个以普通用户启动命名管道和共享内存可能因权限问题连接失败而 WM_COPYDATA 则可能直接收不到。后文会专门说排查方法。3. 用命名管道实现请求/响应式数据通信的 Visual C 示例3.1 服务端代码创建管道实例并循环读取客户端请求下面这段代码演示了服务端如何创建一条名为MyDataPipe的命名管道持续等待客户端连接并读取客户端发来的字符串再回写一个响应。它是最简版本聚焦在通信主链路上。// Server.cpp - 命名管道服务端 #include windows.h #include stdio.h int main() { HANDLE hPipe; char buffer[1024] {0}; DWORD bytesRead 0, bytesWritten 0; // 创建命名管道PIPE_ACCESS_DUPLEX 表示双向读写 hPipe CreateNamedPipe( L\\\\.\\pipe\\MyDataPipe, // 管道名固定格式 PIPE_ACCESS_DUPLEX, // 读写都允许 PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, // 消息模式阻塞模式 PIPE_UNLIMITED_INSTANCES, // 实例数量不限 4096, // 输出缓冲区大小 4096, // 输入缓冲区大小 0, // 默认超时 NULL // 默认安全属性 ); if (hPipe INVALID_HANDLE_VALUE) { printf(CreateNamedPipe failed: %d\n, GetLastError()); return 1; } printf(Waiting for client...\n); // 等待客户端通过 CreateFile 连接 if (!ConnectNamedPipe(hPipe, NULL)) { // 当客户端已经连接后ConnectNamedPipe 返回 FALSE 且错误码为 ERROR_PIPE_CONNECTED if (GetLastError() ! ERROR_PIPE_CONNECTED) { printf(ConnectNamedPipe failed: %d\n, GetLastError()); CloseHandle(hPipe); return 1; } } // 读取客户端发送的数据 BOOL ok ReadFile(hPipe, buffer, sizeof(buffer) - 1, bytesRead, NULL); if (ok bytesRead 0) { buffer[bytesRead] 0; printf(Received: %s\n, buffer); // 写入响应 const char* reply Hello from server; WriteFile(hPipe, reply, (DWORD)strlen(reply) 1, bytesWritten, NULL); } // 断开连接并关闭句柄 DisconnectNamedPipe(hPipe); CloseHandle(hPipe); return 0; }代码里的PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE表示按消息边界读取。也就是说客户端每次WriteFile的数据服务端ReadFile能完整读出一条消息。如果改成PIPE_TYPE_BYTE | PIPE_READMODE_BYTE两个进程之间就是纯字节流需要自己定协议边界比如用\n或长度前缀。对大多数应用消息模式更直观。PIPE_UNLIMITED_INSTANCES是告诉系统允许创建多个同名管道实例。但注意你这个进程如果不重复调用CreateNamedPipe这个参数只是允许并不会自动生成多个实例。服务端要服务多个客户端时需要在循环里不断创建新实例并处理连接。3.2 客户端代码用 CreateFile 连接并发送数据客户端代码不需要CreateNamedPipe直接用CreateFile打开管道名然后读写。// Client.cpp - 命名管道客户端 #include windows.h #include stdio.h int main() { HANDLE hPipe CreateFile( L\\\\.\\pipe\\MyDataPipe, // 同一个管道名 GENERIC_READ | GENERIC_WRITE, // 读写权限 0, // 不共享 NULL, OPEN_EXISTING, // 必须打开已存在的管道 0, NULL ); if (hPipe INVALID_HANDLE_VALUE) { int err GetLastError(); if (err ERROR_PIPE_BUSY) { // 管道忙等待可用实例 if (!WaitNamedPipe(L\\\\.\\pipe\\MyDataPipe, 5000)) { printf(WaitNamedPipe failed: %d\n, GetLastError()); return 1; } // 重新尝试打开 hPipe CreateFileW( L\\\\.\\pipe\\MyDataPipe, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL ); if (hPipe INVALID_HANDLE_VALUE) { printf(Open pipe after wait failed: %d\n, GetLastError()); return 1; } } else { printf(CreateFile failed: %d\n, err); return 1; } } // 设置管道为消息读取模式 DWORD mode PIPE_READMODE_MESSAGE; SetNamedPipeHandleState(hPipe, mode, NULL, NULL); // 发送数据 const char* msg Hello from client; DWORD bytesWritten 0; WriteFile(hPipe, msg, (DWORD)strlen(msg) 1, bytesWritten, NULL); // 读取服务端响应 char reply[256] {0}; DWORD bytesRead 0; ReadFile(hPipe, reply, sizeof(reply) - 1, bytesRead, NULL); reply[bytesRead] 0; printf(Server reply: %s\n, reply); CloseHandle(hPipe); return 0; }注意客户端在CreateFile成功后要调用SetNamedPipeHandleState把管道读取模式切换成消息模式否则服务端发来的消息可能会被按字节流读出导致一次ReadFile只拿到一部分数据。WaitNamedPipe是一个容易被漏掉的函数——当服务端已经建立多个实例但全部被占用时客户端可以等待几秒再重试而不是立刻失败。3.3 命名管道的 3 个必调参数与实际配置命名管道的参数集中在CreateNamedPipe的后半段里日常改动最多的是这几个参数常见值影响nMaxInstancesPIPE_UNLIMITED_INSTANCES或 4决定同一时间允许的最大客户端连接数超过后客户端会看到ERROR_PIPE_BUSYnOutBufferSize/nInBufferSize4096 或 64 * 1024缓冲区大小不限制一次消息的最大长度但太小会影响吞吐性能。消息模式下消息长度超过缓冲区时会收到ERROR_MORE_DATAdwConnectTimeout5000 或 10000服务端等待客户端连接的超时时间客户端WaitNamedPipe有自己的超时参数实际项目中我通常把缓冲区设成 64KB实例数设为 4。如果需要传递超过缓冲区大小的消息正确做法是循环调用ReadFile检查返回值是否FALSE且GetLastError()为ERROR_MORE_DATA然后继续读取剩余部分直到读完整条消息。4. 大数据量场景共享内存 互斥体实现高速数据通信4.1 创建共享内存服务端与客户端的内存映射文件当两个程序需要高频交换监控数据、图像帧或日志块时命名管道的用户态拷贝会成为瓶颈。共享内存把数据直接放在同一块物理页上双方零拷贝访问。下面给出一对最小的实现。服务端写入方代码// SharedMemoryServer.cpp - 服务端创建共享内存并写入数据 #include windows.h #include stdio.h int main() { // 创建一个命名共享内存对象 HANDLE hMap CreateFileMapping( INVALID_HANDLE_VALUE, // 使用系统分页文件作为后备存储 NULL, // 默认安全属性 PAGE_READWRITE, // 读写权限 0, 1024, // 映射区域大小单位字节 LLocal\\MySharedMemory // 内存映射对象名Local\ 前缀限定本会话 ); if (hMap NULL) { printf(CreateFileMapping failed: %d\n, GetLastError()); return 1; } // 将映射对象映射到当前进程地址空间 char* pData (char*)MapViewOfFile( hMap, FILE_MAP_ALL_ACCESS, 0, 0, 1024 ); if (pData NULL) { printf(MapViewOfFile failed: %d\n, GetLastError()); CloseHandle(hMap); return 1; } // 写一个字符串到共享内存 const char* msg hello shared memory; strcpy(pData, msg); printf(Data written: %s\n, msg); printf(Press Enter to exit...\n); getchar(); UnmapViewOfFile(pData); CloseHandle(hMap); return 0; }客户端读取方代码// SharedMemoryClient.cpp - 客户端打开共享内存并读取数据 #include windows.h #include stdio.h int main() { // 打开服务端创建的内存映射对象 HANDLE hMap OpenFileMapping( FILE_MAP_READ, // 只读访问 FALSE, // 不继承句柄 LLocal\\MySharedMemory // 需要完全相同的名字 ); if (hMap NULL) { printf(OpenFileMapping failed: %d\n, GetLastError()); return 1; } char* pData (char*)MapViewOfFile( hMap, FILE_MAP_READ, 0, 0, 1024 ); if (pData NULL) { printf(MapViewOfFile failed: %d\n, GetLastError()); CloseHandle(hMap); return 1; } printf(Data read: %s\n, pData); UnmapViewOfFile(pData); CloseHandle(hMap); return 0; }上面两段代码只演示了映射本身还没有加同步。如果客户端在服务端strcpy写到一半时读取可能读到残缺数据。实际项目中必须引入一个同步对象。4.2 用互斥体锁住共享内存的写入与读取最轻量的同步方式是命名互斥体。写入方先CreateMutex创建互斥体每次写之前WaitForSingleObject写完ReleaseMutex。读取方打开同一个互斥体在读取前也要获取锁。下面是在服务端写入循环基础上增加的锁操作// 创建互斥体 HANDLE hMutex CreateMutex(NULL, FALSE, LLocal\\MySharedMemoryMutex); if (hMutex NULL) { printf(CreateMutex failed: %d\n, GetLastError()); return 1; } // 写入前获取锁 WaitForSingleObject(hMutex, INFINITE); // 写数据 strcpy(pData, new data with lock); printf(Data written with lock.\n); // 释放锁 ReleaseMutex(hMutex);客户端读取时用OpenMutex拿到句柄HANDLE hMutex OpenMutex(MUTEX_ALL_ACCESS, FALSE, LLocal\\MySharedMemoryMutex); if (hMutex ! NULL) { WaitForSingleObject(hMutex, INFINITE); printf(Data read: %s\n, pData); ReleaseMutex(hMutex); }注意共享内存里的数据本身没有任何结构互斥体只保证“同一时间只有一个进程在访问”这个约束。如果你的数据是固定大小的结构体直接用memcpy拷贝结构体更高效如果是变长数据建议在共享内存头部放一个长度字段再放数据体实现简易协议。4.3 共享内存的权限、会话隔离与边界检查共享内存对象名带Local\前缀时表示只能在当前登录会话内访问。如果你希望跨会话比如 Windows 服务与用户程序通信需要把前缀改成Global\同时还要为目标用户授予安全访问权限。这涉及SECURITY_ATTRIBUTES和访问控制列表ACL的设置相比命名管道要繁琐得多。两个普通前台程序通信使用Local\就足够了。另一个容易踩坑的是大小声明不一致。服务端映射 1024 字节客户端如果查询文件大小后按GetFileSize去映射不会出问题但如果你在客户端MapViewOfFile时传了 4096系统会返回错误ERROR_ACCESS_DENIED。因为映射对象的大小在CreateFileMapping时就固定了客户端不能指望扩大映射范围。所以共享内存双方必须事先约定好结构体和大小最好封装成一个头文件让两边共用。提示共享内存不提供任何事件通知。写入方写完数据后读取方不知道数据已经更新。常见的解决方案是搭配命名事件对象写入方在写完数据后SetEvent读取方WaitForSingleObject等待事件或使用CreateEvent在写入完成后发信号。如果要求低延迟轮询也可以直接用原子操作检查数据头部的版本号。5. 利用 GetLastError 定位进程间通信失败的三个具体技巧5.1 检查管道路径和权限问题通信失败时第一件事就是用GetLastError()拿到错误码不要凭感觉猜。两个最常见错误码是错误码含义排查方向ERROR_FILE_NOT_FOUND(2)客户端CreateFile找不到指定管道服务端进程是否启动管道名是否写错注意字符串里的双斜杠转义ERROR_ACCESS_DENIED(5)无权限访问两个进程是否一个管理员一个普通用户将服务端分配安全描述符或把客户端也提权到相同权限针对权限问题一个简单有效的验证方法是把两个进程都放到管理员权限下运行一次。如果能通信基本可以确定是权限问题。生产环境则建议在CreateNamedPipe的最后一个参数传入SECURITY_ATTRIBUTES为 Everyone 授予读写权限但这需要ConvertStringSecurityDescriptorToSecurityDescriptor配合初始化。5.2 服务端要区分“管道已连接”和“连接失败”很多人在ConnectNamedPipe返回FALSE时直接当失败处理结果客户端却已经连上了。其实当客户端在服务端调用ConnectNamedPipe之前就主动连接Windows 会返回FALSE且错误码是ERROR_PIPE_CONNECTED这不算错误。正确的处理方式if (!ConnectNamedPipe(hPipe, NULL)) { if (GetLastError() ! ERROR_PIPE_CONNECTED) { // 真正失败的逻辑 } else { // 客户端已连接可以直接开始通信 } }这个问题在服务端创建完管道后立刻调用ConnectNamedPipe同时客户端马上CreateFile的情况下经常遇到。忽略它会导致服务端错误关闭管道通信失败。5.3 用 Process Explorer 和命名管道列表验证对象是否存在如果代码逻辑看起来没错可以用系统工具验证内核对象是否真的创建出来了。打开 Windows Sysinternals 的 Process Explorer在菜单 View - Lower Pane View 里选择 Handles然后找到你的进程搜索管道名MyDataPipe就能看到管道句柄。共享内存对象则在 Handles 里可以看到MySharedMemory对应的 File 类型。如果句柄不在说明CreateNamedPipe或CreateFileMapping没成功需要回头查错误码。另外一个小技巧是在客户端CreateFile之前故意让服务端暂停几秒用cmd窗口里执行winobj或Process Explorer查看\Device\NamedPipe\MyDataPipe是否可见。这能帮你快速区分是对象没创建还是客户端没找到名字。对于共享内存可以在mappedfile目录下看到对象名。最后一个验证方法是写一个极简的对测程序服务端启动后客户端立即发送固定字符串服务端收到后回写固定字符串。跑通后再加入业务数据结构。这种“先打通裸管道再封装协议”的次序能避免把业务逻辑错误和通信错误混在一起排查。本文还有配套的精品资源点击获取
返回列表