简介:这是一份面向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_COPYDATA
WM_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的后半段里,日常改动最多的是这几个:
| 参数 | 常见值 | 影响 |
|---|---|---|
nMaxInstances | PIPE_UNLIMITED_INSTANCES或 4 | 决定同一时间允许的最大客户端连接数,超过后客户端会看到ERROR_PIPE_BUSY |
nOutBufferSize/nInBufferSize | 4096 或 64 * 1024 | 缓冲区大小不限制一次消息的最大长度,但太小会影响吞吐性能。消息模式下,消息长度超过缓冲区时会收到ERROR_MORE_DATA |
dwConnectTimeout | 5000 或 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, // 映射区域大小,单位字节 L"Local\\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, // 不继承句柄 L"Local\\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, L"Local\\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, L"Local\\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目录下看到对象名。
最后一个验证方法是写一个极简的对测程序:服务端启动后,客户端立即发送固定字符串,服务端收到后回写固定字符串。跑通后再加入业务数据结构。这种“先打通裸管道,再封装协议”的次序能避免把业务逻辑错误和通信错误混在一起排查。
本文还有配套的精品资源,点击获取