概述
可能是出于C++效率更高、写硬件驱动更方便、或是反编译难度更高的原因,现在有些项目喜欢使用C#与C++混合编程,C#/WPF写界面与一些界面逻辑,C++写一些驱动或是业务逻辑。那么要实现这一点,就无法避免C#与C++的交互问题。
之间使用过C++封装成DLL然后C#通过P/Invoke调用,也搞过直接通过命令行调用。今天介绍的是通过命名管道实现C#与C++进程间通信。
命名管道介绍
命名管道(Named Pipe)是一种进程间通信(IPC)机制,它允许在同一台计算机上的不同进程或网络中不同计算机上的进程之间进行数据交换。与匿名管道不同,命名管道具有明确的名称,可以在不相关的进程之间建立通信通道,支持双向数据传输,并且可以同时被多个客户端进程连接。命名管道采用文件系统风格的命名方式,在Windows系统中通常以".\pipe\管道名"的形式存在,提供了可靠、有序的数据传输服务,广泛应用于需要进程间数据共享和通信的场景,如客户端-服务器应用程序、系统服务与用户程序之间的数据交换等。
实践
今天举的这个例子是C#程序充当命名管道客户端,C++程序充当命名管道服务端。
效果:
模拟的是在C#程序中点击打开仪器按钮,通过命名管道发送一个指令,服务端根据这个指令进行实际的操作,然后返回操作的结果。
本文不详细解释每一个步骤,只会对关键步骤进行方便自己回顾的备忘录,目前只要知道有这种方式就行了,如果想通过完整例子学习,可以叫AI实现一个完整的例子进行学习就好了。
首选需要创建一个命名管道:
// 创建命名管道 HANDLE hPipe = CreateNamedPipe( PIPE_NAME, // 管道名称 PIPE_ACCESS_DUPLEX, // 双向管道 PIPE_TYPE_MESSAGE | // 消息类型管道 PIPE_READMODE_MESSAGE | // 消息读取模式 PIPE_WAIT, // 阻塞模式 PIPE_UNLIMITED_INSTANCES, // 无限制实例数 BUFFER_SIZE, // 输出缓冲区大小 BUFFER_SIZE, // 输入缓冲区大小 0, // 默认超时 NULL); // 默认安全属性PIPE_NAME表示管道名称,可以在开头这样写来定义:#define PIPE_NAME TEXT("\\\\.\\pipe\\InstrumentControlPipe"),\\.\表示本地计算机,pipe\表示命名管道设备,InstrumentControlPipe是自定义的管道名称。
管道方向可选值为:PIPE_ACCESS_INBOUND、PIPE_ACCESS_OUTBOUND与PIPE_ACCESS_DUPLEX。
这三个常量定义了命名管道的访问方向,控制数据在管道中的流动方向。
常量名称 | 值 | 含义 |
|---|---|---|
PIPE_ACCESS_INBOUND | 0x00000001 | 管道仅用于数据输入(客户端到服务器) |
PIPE_ACCESS_OUTBOUND | 0x00000002 | 管道仅用于数据输出(服务器到客户端) |
PIPE_ACCESS_DUPLEX | 0x00000003 | 管道支持双向数据传输 |
接下来的PIPE_TYPE_MESSAGE |PIPE_READMODE_MESSAGE | PIPE_WAIT都是dwPipeMode 参数的一部分,通过按位或运算组合在一起。
PIPE_TYPE_MESSAGE:将管道设置为消息模式,数据以离散消息形式传输,保持消息边界。
PIPE_READMODE_MESSAGE:以消息为单位读取数据,确保读取完整的消息而不是任意字节。
PIPE_WAIT:阻塞模式操作,当没有数据可读时调用线程会阻塞等待。
PIPE_UNLIMITED_INSTANCES虽然看过去像是无限制实例数,但是查看它的定义#define PIPE_UNLIMITED_INSTANCES 255发现最多只能存在255个实例。
BUFFER_SIZE可以在前面这样定义:#define BUFFER_SIZE 512。
0:使用默认的50毫秒超时。
NULL:使用默认安全描述符,句柄不可被子进程继承。
命名管道的类型为HANDLE,HANDLE是 Windows 操作系统中用于标识系统资源(如文件、管道、进程、线程等)的抽象句柄。它本质上是一个不透明的指针或整数,用于唯一标识操作系统管理的各种对象。
现在需要先等待客户端连接:
// 等待客户端连接 BOOL fConnected = ConnectNamedPipe(hPipe, NULL) ? TRUE : (GetLastError() == ERROR_PIPE_CONNECTED);ConnectNamedPipe()会阻塞等待,直到客户端连接。
现在再来看下C#程序中是如何连接这个C++服务端。
C#中创建一个命名管道客户端,可以使用NamedPipeClientStream这个类,NamedPipeClientStream是 .NET 中用于实现命名管道客户端的类,位于System.IO.Pipes命名空间中。它提供了一种进程间通信(IPC)机制,允许在同一台计算机上的不同进程之间或网络上的不同计算机之间进行数据交换。
查看创建客户端的代码:
private NamedPipeClientStream _client; _client = new NamedPipeClientStream(".", "InstrumentControlPipe", PipeDirection.InOut, PipeOptions.Asynchronous);查看对应的构造函数:
public NamedPipeClientStream(string serverName, string pipeName, PipeDirection direction, PipeOptions options) : this(serverName, pipeName, direction, options, TokenImpersonationLevel.None, HandleInheritability.None) { }参数名 | 参数值 | 说明 |
|---|---|---|
serverName | "." | 服务器名称,"."表示本地计算机 |
pipeName | "InstrumentControlPipe" | 管道名称,客户端和服务器必须使用相同的名称 |
direction | PipeDirection.InOut | 管道方向,InOut表示双向通信(既能读取也能写入) |
options | PipeOptions.Asynchronous | 管道选项,Asynchronous表示启用异步操作,避免UI阻塞 |
然后await _client.ConnectAsync(5000);即可连接到这个命名管道。
现在C#程序向这个管道发送数据,这样写即可:
private StreamWriter _writer; _writer.Write(command + "\n"); _writer.Flush();使用StreamWriter类向这个管道写入数据。
现在再来看下C++程序中,如何接受这个数据。
// 读取客户端发送的数据 BOOL fSuccess = ReadFile( hPipe, // 管道句柄 buffer, // 接收数据的缓冲区 BUFFER_SIZE - 1, // 缓冲区大小,保留一个位置给null终止符 &dwRead, // 实际读取的字节数 NULL); // 不使用重叠I/O然后就是获取buffer的内容,这里做这些处理,就是确保buffer内容可以正确提取出来。
// 确保字符串以null结尾 buffer[dwRead] = '\0'; // 将新读取的数据添加到命令缓冲区 commandBuffer += buffer; // 检查是否接收到完整的命令(以换行符或回车符结尾) size_t pos = commandBuffer.find_first_of("\r\n"); if (pos == std::string::npos) { // 命令不完整,继续读取 continue; } // 提取完整命令 std::string rawCommand = commandBuffer.substr(0, pos); commandBuffer = commandBuffer.substr(pos + 1); // 去除可能的BOM标记和空白字符 std::string command = rawCommand; // 去除字符串前后的空白字符 size_t start = command.find_first_not_of(" \t\r\n"); if (start != std::string::npos) { size_t end = command.find_last_not_of(" \t\r\n"); command = command.substr(start, end - start + 1); } else { command.clear(); } // 去除可能的UTF-8 BOM if (command.length() >= 3 && (unsignedchar)command[0] == 0xEF && (unsignedchar)command[1] == 0xBB && (unsignedchar)command[2] == 0xBF) { command = command.substr(3); }然后根据指令执行不同的操作:
if (command == "Open") { std::cout << "收到命令: Open - 正在打开仪器..." << std::endl; // 这里可以添加实际的仪器控制代码 Sleep(3000); // 模拟操作时间 // 90%概率成功,10%概率失败 std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution<> dis(1, 100); int random = dis(gen); if (random <= 90) { success = true; response = "true\n"; // 添加换行符以便客户端读取 std::cout << "仪器已打开 - 成功" << std::endl; } else { success = false; response = "false\n"; // 添加换行符以便客户端读取 std::cout << "仪器打开失败" << std::endl; } } elseif (command == "Close") { std::cout << "收到命令: Close - 正在关闭仪器..." << std::endl; // 这里可以添加实际的仪器控制代码 Sleep(3000); // 模拟操作时间 // 90%概率成功,10%概率失败 std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution<> dis(1, 100); int random = dis(gen); if (random <= 90) { success = true; response = "true\n"; // 添加换行符以便客户端读取 std::cout << "仪器已关闭 - 成功" << std::endl; } else { success = false; response = "false\n"; // 添加换行符以便客户端读取 std::cout << "仪器关闭失败" << std::endl; } }C++服务端向客户端发送数据,可以这样写:
// 向客户端发送响应 DWORD dwWritten; BOOL writeSuccess = WriteFile( hPipe, // 管道句柄 response.c_str(), // 要发送的数据 static_cast<DWORD>(response.length()), // 数据长度 &dwWritten, // 实际写入的字节数 NULL); // 不使用重叠I/O然后现在还需要C#程序中读取C++程序的返回值。
private StreamReader _reader; string response = await reader.ReadLineAsync();使用StreamReader类进行读取即可。StreamWriter与StreamReader的初始化可以这样写:
_writer = new StreamWriter(_client, Encoding.UTF8) { AutoFlush = true }; _reader = new StreamReader(_client, Encoding.UTF8);本文只是简单介绍一下可以通过命名管道实现C#与C++的进程间通信,感兴趣的同学也可以通过AI写一个Demo进行学习。