VC++深入详解(16):进程通信

我们只介绍几种简单的进程间通信机制:剪切板、匿名管道、命名管道、匿名管道和油槽。
平时大家都用过剪切板,比如选中记事本上的一段文字,然后Ctrl+C复制到剪切板上,然后在word中按下Ctrl+V,将其复制。这其实完成了两个进程之间的通信:从记事本到word。实际上,剪切板是系统维护管理的一段内存区域,当在一个进程中复制数据时,是将数据复制到这个内存区域,而在另一个进程中粘贴数据时,是从这个内存区域中取出数据,然后显示在窗口上。
下面我们写一个示例程序来实现剪切板的功能。
首先是程序的外观:一个对话框程序,上面有两个编辑框,分别用来输入发送到剪切板的数据和显示从剪切板接收的数据。然后再增加2个按钮,用来控制发送和接收数据。

我们对发送数据按钮进行响应:

void CCH_16_ClipboardDlg::OnBntSend() 
{
	// TODO: Add your control notification handler code here
	//打开剪切板
	if(!OpenClipboard())
	{
		MessageBox("打开剪切板失败");
		return ;
	}
	//获取剪切板控制权,释放剪切板中之前的数据
	if(!EmptyClipboard())
	{
		MessageBox("进入剪切板失败");
		return ;
	}
	
	//获取编辑框中的文字
	CString str;
	GetDlgItemText(IDC_EDIT_SEND,str);

	//内存对象,从全局堆分配
	HGLOBAL  hClip;
	hClip = GlobalAlloc(GMEM_MOVEABLE,		//
						sizeof(str)+1);		//大小

	//获取内存对象的指针
	char* pBuf;
	pBuf = (char*)GlobalLock(hClip);
	
	//完成数据的拷贝
	strcpy(pBuf,str);

	//减少引用计数,解锁
	GlobalUnlock(hClip);
	//向剪贴板中放入数据
	SetClipboardData(CF_TEXT,	//文本格式
					 hClip);	//内存对象

	//关闭剪切板,使其他进程可以获取剪切板
	CloseClipboard();

}

运行程序,在发送剪切板上输入一些文字,然后可以打开一个记事本程序,选择“粘贴”,之前输入的文字就粘贴到记事本上了。这就实现了我们的进程,用记事本进程的通信。
总体上说,这段程序分为以下几步:
1.打开剪切板
2.进入剪切板
3.获取编辑框中的数据
4.将数据放到剪切板中
5.关闭剪切板


但是在将数据放到剪切板中的SetClipboardData函数值得详细讨论一下:
HANDLE SetClipboardData(UINT uFormat, HANDLE hMem );
uFormat指明了放入的数据的格式,我们既可以选择一个已经注册过的自己的格式,也可以选择标准的格式。这里选择的是标准的文本格式。
hMem是具有指定格式的句柄:如果这个参数为NULL,指示调用窗口直到对剪切板数据有请求时才提供指定剪切板格式的数据。如果窗口采用延迟交换技术,则该窗口必须处理WM_RENDERFORMAT和WM_RENDERALLFORMATS 消息。
这个设置是为了提高效率:当我们把数据复制到剪切板上时,这些数据都要占据内存空间。实际上,我们经常会复制了一堆数据,却并没有使用它们;然后又复制一段数据。这样第一次复制的数据就浪费了。Windows提供了这样一种机制:先提供一个空剪切板,当一个进程需要粘贴已复制的数据时,会发送
WM_RENDERFORMAT消息,而在消息响应函数中,完成之前就应该完成的复制操作。
我们这里并没有使用这个复杂的机制,只是在其中填入一个内存对象。这个内存对象必须由GlobalAlloc分配,且分配标记必须为GMEM_MOVEABLE。
GlobalAlloc从堆上分配指定数目的比特。GMEM_MOVEABLE表明这个内存对象在堆中是可以移动的。因为在必要的某些时候,Windows可以移动内存,从而更好的支持系统内存管理。正因为是可移动的,所以不能直接用指针访问,比较麻烦,可使用函数 GlobalLock 将该句柄转换为一个指针。此时拿到的,才是物理内存的地址。有了指针之后,我们使用strcyp将编辑框中数据拷贝到这个内存对象中。拷贝完成后,使用GlobalUnlock对其解锁。


下面我们看看数据接收功能的实现:

void CCH_16_ClipboardDlg::OnBtnRecv() 
{
	// TODO: Add your control notification handler code here
	if(!OpenClipboard())
	{
		MessageBox("无法打开剪切板");
		return ;
	}
	//判断剪切板中的数据是否是指定格式
	if(!IsClipboardFormatAvailable(CF_TEXT))
	{
		MessageBox("剪切板数据格式错误");
		return ;
	}

	//获取剪切板内存对象
	HANDLE hClip;
	hClip = GetClipboardData(CF_TEXT);

	//获取内存对象指针
	char* pBuf = (char*)GlobalLock(hClip);
	GlobalUnlock(hClip);

	//设置文字
	SetDlgItemText(IDC_EDIT_RECV,pBuf);
	//关闭剪切板,使其他进程可以获取剪切板
	CloseClipboard();
}
此时,在编辑框输入输入后,点击发送,然后点击接收,就能收到之前发送的数据了。
程序的流程大概是这样:
1.打开剪切板
2.判断剪切板中的文字格式是否与我们预期的格式相符
3.如果相符,接收数据,保存到内存对象中
4.获取内存对象的指针
5.将数据显示出来


这样,就实现了从剪切板到进程的通信。




下面我们看匿名管道。它是一种未命名的单向管道,用来在父进程和子进程之间传输数据。匿名管道只能实现本地机器上两个进程间的通信,不能实现跨网络通信。
首先建立一个单文档应用程序,给我们的视类增加两个成员变量:

private:
	HANDLE hWrite;
	HANDLE hRead;

在构造函数中,将它们都设为NULL,在析构函数中,如果之前没有释放它们,则释放他们:

CCH_17_ParentView::CCH_17_ParentView()
{
	// TODO: add construction code here
	hRead = NULL;
	hWrite = NULL;
}

CCH_17_ParentView::~CCH_17_ParentView()
{
	if(hRead)
	{
		CloseHandle(hRead);
	}

	if(hWrite)
	{
		CloseHandle(hWrite);
	}
}

给菜单增加一个“匿名管道”菜单,增加3个菜单项:IDM_PIPE_CREATE“创建匿名管道”、IDM_PIPE_READ“发送数据”、IDM_PIPE_WRITE“接收数据”。

void CCH_17_ParentView::OnPipeCreate() 
{
	// TODO: Add your command handler code here
	//定义安全属性结构体
	SECURITY_ATTRIBUTES sa;
	//可以被继承
	sa.bInheritHandle = TRUE;
	//默认安全描述
	sa.lpSecurityDescriptor = NULL;
	sa.nLength = sizeof(SECURITY_ATTRIBUTES);
	
	//创建匿名管道
	if(CreatePipe(&hRead,	//读句柄
				  &hWrite,	//写句柄
				  &sa,		//安全属性
				  0))		//默认大小
	{
		MessageBox("创建匿名管道失败");
		return ;
	}

	
	//新创建的进程的主窗口信息
	STARTUPINFO sui;
	ZeroMemory(&sui,sizeof(STARTUPINFO));
	sui.cb = sizeof(STARTUPINFO);
	sui.dwFlags = STARTF_USESTDHANDLES;
	sui.hStdInput = hRead;
	sui.hStdOutput = hWrite;
	//获取标准输入句柄
	sui.hStdError = GetStdHandle(STD_ERROR_HANDLE);
	
	//新创建的进程和主线程信息,由函数填写
	PROCESS_INFORMATION pi;
	//创建子进程
	if(!CreateProcess("..\\CH_17_Child\\Debug\\CH_17_Child.exe",//子进程名
					 NULL,										//命令行参数为空
					 NULL,										//新创建的进程使用默认安全级别
					 NULL,										//新创建的主线程使用默认安全级别
					 TRUE,										//子进程可以继承父进程的句柄
					 0,											//无特殊创建标记
					 NULL,										//新进程使用调用进程的环境块
					 NULL,										//子进程和父进程具有相同的当前路径
					 &sui,										//启动信息
					 &pi))										//新创建的进程和主线程信息
	{
		CloseHandle(hWrite);
		hWrite = NULL;
		CloseHandle(hRead);
		hRead = NULL;
		MessageBox("创建子进程失败");
		return ;
	}
	else
	{
		//关闭句柄
		CloseHandle(pi.hProcess);
		CloseHandle(pi.hThread);
	}



}
程序其实不长,只是CreateProcess函数的参数很多。我们重点看一下最后两个:
STARTUPINFO 类型的sui用来指定新进程的主窗口将如何显示,它的成员较多:

typedef struct _STARTUPINFO { 
    DWORD   cb; 
    LPTSTR  lpReserved; 
    LPTSTR  lpDesktop; 
    LPTSTR  lpTitle; 
    DWORD   dwX; 
    DWORD   dwY; 
    DWORD   dwXSize; 
    DWORD   dwYSize; 
    DWORD   dwXCountChars; 
    DWORD   dwYCountChars; 
    DWORD   dwFillAttribute; 
    DWORD   dwFlags; 
    WORD    wShowWindow; 
    WORD    cbReserved2; 
    LPBYTE  lpReserved2; 
    HANDLE  hStdInput; 
    HANDLE  hStdOutput; 
    HANDLE  hStdError; 
} STARTUPINFO, *LPSTARTUPINFO; 

面对这种结构体,我们应该先看看其中有没有特殊的成员。比如其中的dwFlags。这个成员决定了整个结构体中的哪些成员被使用。(由于不是全都有用,所以使用之前应该先对结构体清零。)我们这里使用的是STARTF_USESTDHANDLES,则只用设置进程的标准输入、输出、错误句柄即可。将子进程的输入输出句柄设置为管道的读写句柄。
当子进程启动时,他会继承父进程的所有可继承的已打开的句柄,但是子进程并不知道哪个句柄用来读管道,那个句柄用来写管道,因为在创建子进程中并没有指定。所以在这里需要设置。而标准错误句柄,可以通过函数GetStdHandle获得。虽然这个程序中并没有使用这个句柄,但是我们必须将它填写好。

PROCESS_INFORMATION类型的pi是供CreateProcess函数填写新创建的进程和线程的信息的结构体,它的成员如下:

typedef struct _PROCESS_INFORMATION { 
    HANDLE hProcess; 
    HANDLE hThread; 
    DWORD dwProcessId; 
    DWORD dwThreadId; 
} PROCESS_INFORMATION; 

前两个是进程和线程的句柄,后两个是进程和线程的全局标识符。有了它,我们才可以在后面释放句柄。

再看看读写匿名管道的程序:

void CCH_17_ParentView::OnPipeRead() 
{
	// TODO: Add your command handler code here
	char buf[200];
	DWORD dwRead;
	if(!ReadFile(hRead,	//读句柄
				buf,	//缓冲区
				100,	//将要读取的大小
				&dwRead,//实际读取的大小
				NULL))	//非重叠
	{
		MessageBox("读取数据失败");
		return ;
	
	}
	MessageBox(buf);
				
}

void CCH_17_ParentView::OnPipeWrite() 
{
	// TODO: Add your command handler code here
	char buf[] = "父进程发送的数据";
	DWORD dwWrite;
	if(!WriteFile(hWrite,		//句柄
				  buf,			//缓冲区
				  strlen(buf)+1,//将要读取饿大小
				  &dwWrite,		//实际大小
				  NULL))		//非重叠
	{
		MessageBox("写入数据失败");
		return ;
	}
}
使用的是之前就是用过的ReadFile和WriteFile实现的,这里就不再多说了。


下面看看子进程的编写。首先将子进程放在与父进程平级的目录下。同样的,为其增加两个成员变量用来标识管道的读写句柄,并在构造函数中初始化它们,在析构函数中释放他们。设置为单文档应用程序,为其增加一个菜单“匿名管道”,2个菜单项IDM_PIPE_READ“读取数据”,IDM_PIPE_WRITE“写入数据”。
首先,需要获取子进程的标准输入和输出句柄,我们可以在虚函数OnInitialUpdate中完成,这个函数是在窗口创建后第一个调用的函数。

void CCH_17_ChildView::OnInitialUpdate() 
{
	CView::OnInitialUpdate();
	
	// TODO: Add your specialized code here and/or call the base class
	hRead = GetStdHandle(STD_INPUT_HANDLE);
	hWrite = GetStdHandle(STD_OUTPUT_HANDLE);
}


输入输出函数都比较简单:

void CCH_17_ChildView::OnPipeWrite() 
{
	// TODO: Add your command handler code here
	char buf[] = "匿名管道客户端数据";
	DWORD dwWrite;
	if(!WriteFile(hWrite,buf,strlen(buf)+1,&dwWrite,NULL))
	{
		MessageBox("写入数据失败");
		return ;
	}
}

void CCH_17_ChildView::OnInitialUpdate() 
{
	CView::OnInitialUpdate();
	
	// TODO: Add your specialized code here and/or call the base class
	hRead = GetStdHandle(STD_INPUT_HANDLE);
	hWrite = GetStdHandle(STD_OUTPUT_HANDLE);
}


在启动程序时,我们应该启动父进程,然后利用父进程的“创建匿名管道”菜单项启动子进程。然后就能实现一个发送,另一个接收的功能了。




下面我们看命名管道。命名管道通过网络来完成进程间的通信,但又屏蔽了网络协议的细节,是得我们再不了解网络协议的情况下也可以实现进程通信。匿名管道可以完成父子进程间的通信,命名管道不仅可以在本机上实现两个进程间的通信,还可以跨网络实现两个进程间的通信。
因为是跨网络的通信,自然也离不开客户端/服务器通信体系。命名管道的服务器创建命名管道,而客户端用来连接已存在的命名管道。命名管道采用了Windows的“命名管道文件系统”接口,因此,客户机和服务器可利用标准的Win32文件系统函数(ReadFile和WriteFile)进行数据的收发。




我们先编写服务器端程序。建立一个单文档应用程序,增加一个菜单“命名管道”,增加3个菜单项:
IDM_PIPE_CREATE“创建管道”,IDM_PIPE_READ“读取数据”,IDM_PIPE_WRITE“写入数据”。
先看创建管道的消息响应函数:

void CCH_17_NamedPipeSrvView::OnPipeCreate() 
{
	// TODO: Add your command handler code here
	hPipe = CreateNamedPipe("\\\\.\\pipe\\MyPipe",	//管道名称					
							PIPE_ACCESS_DUPLEX |	//双向管道
							FILE_FLAG_OVERLAPPED,	//重叠模式
							0,						//管道采用字节类型
							1,						//最多只能创建一个实例
							1024,					//输出缓冲区的保留字节数
							1024,					//输入缓冲区的保留字节数
							0,						//默认超时值
							NULL);					//默认安全属性
	if(INVALID_HANDLE_VALUE == hPipe)
	{
		MessageBox("创建命名管道失败");
		hPipe = NULL;
		return ;
	}

	//创建匿名事件对象
	HANDLE hEvent;
	hEvent = CreateEvent(NULL,		//默认安全属性
						 TRUE,		//人工重置
						 FALSE,		//创建线程没有获得该对象
						 NULL);		//没有名字
	if(!hEvent)
	{
		MessageBox("创建事件对象失败");
		CloseHandle(hPipe);
		hPipe = NULL;
		return ;
	}


	//OVERLAPPED结构
	OVERLAPPED ovlap;
	ZeroMemory(&ovlap,sizeof(OVERLAPPED));
	ovlap.hEvent = hEvent;
	//等待客户端连接
	if(!ConnectNamedPipe(hPipe,&ovlap))
	{
		//对于重叠模式,还需要判断是等待处理还是真的失败了
		if(ERROR_IO_PENDING != GetLastError())
		{
			MessageBox("等待客户端连接失败");
			CloseHandle(hPipe);
			CloseHandle(hEvent);
			hPipe = NULL;
			return ;
		}
	}

	//等待事件对象
	if(WAIT_FAILED == WaitForSingleObject(hEvent,INFINITE))
	{
		MessageBox("等待对象失败");
		CloseHandle(hPipe);
		CloseHandle(hEvent);
		hPipe = NULL;
		return ;
	}
}

总体上说,这个函数做了两件事情:1.创建命名管道:CreateNamedPipe。2.等待客户端请求的到来:ConnectNamedPipe。


在CreateNamedPipe中,管道名称是有默认格式的:\\.\pipe\pipename 其中pipe使用大小写无所谓,如在C语言中,如果想在双引号内输出1个\,就需要输出两个,所以斜杠比较多。这里采用的双向模式+重叠模式,其他的参数就不提了。
因为在CreateNamedPipe采用了重叠模式,所以在ConnectNamedPipe的第二个参数中,必须要填入一个OVERLAPPED的结构体地址,且结构体中必须包含一个手工重置的事件对象。所以我们的程序中,先创建了事件对象,然后定义了OVERLAPPED结构体,最后调用了ConnectNamedPipe函数。如果ConnectNamedPipe失败,则返回0。但是,这里需要判断失败的原因,看看是真的失败了,还是只是暂时没有处理,会在稍后处理。


读写函数相对比较简单:

void CCH_17_NamedPipeSrvView::OnPipeRead() 
{
	// TODO: Add your command handler code here
	char buf[100];
	DWORD dwRead;
	if(!ReadFile(hPipe,buf,100,&dwRead,NULL))
	{
		MessageBox("读取数据失败");
		return ;
	}

	MessageBox(buf);
}

void CCH_17_NamedPipeSrvView::OnPipeWrite() 
{
	// TODO: Add your command handler code here
	char buf[] = "命名管道服务器数据";
	DWORD dwWrite;
	if(!WriteFile(hPipe,buf,strlen(buf)+1,&dwWrite,NULL))
	{
		MessageBox("服务器写入数据失败");
		return ;
	}
}

我们再看客户端程序。在工作区中增加一个单文档引用程序。为其增加一个句柄hPipe。在构造函数中初始化为NULL,在析构函数中删除它。给菜单增加3个菜单项分别为“连接管道”IDM_PIPE_CONNECT、“读取数据”IDM_PIPE_READ、“写入数据”IDM_PIPE_WRITE。并增加消息响应函数。

void CCH_17_NamedPipeCltView::OnPipeConnect() 
{
	// TODO: Add your command handler code here
	if(!WaitNamedPipe("\\\\.\\pipe\\MyPipe",NMPWAIT_WAIT_FOREVER))
	{
		MessageBox("当前没有可利用的命名管道");
		return ;
	}

	//打开命名管道
	hPipe = CreateFile("\\\\.\\pipe\\MyPipe",	//管道名称
						GENERIC_READ |			//可读
						GENERIC_WRITE ,			//可写
						0,						//不可共享
						NULL,					//默认安全属性
						OPEN_EXISTING,			//打开现有管道
						FILE_ATTRIBUTE_NORMAL,	//普通文件属性
						NULL);

	if(INVALID_HANDLE_VALUE == hPipe)
	{
		MessageBox("打开命名管道失败");
		hPipe = NULL;
		return ;
	}

}

void CCH_17_NamedPipeCltView::OnPipeRead() 
{
	// TODO: Add your command handler code here
	char buf[100];
	DWORD dwRead;
	if(!ReadFile(hPipe,buf,100,&dwRead,NULL))
	{
		MessageBox("读取数据失败");
		return ;
	}

	MessageBox(buf);	
}


void CCH_17_NamedPipeCltView::OnPipeWrite() 
{
	// TODO: Add your command handler code here
	char buf[] = "命名管道客户端数据";
	DWORD dwWrite;
	if(!WriteFile(hPipe,buf,strlen(buf)+1,&dwWrite,NULL))
	{
		MessageBox("服务器写入数据失败");
		return ;
	}	
}

下面我们看最后一种进程间通信机制——邮槽。邮槽是基于广播体系设计的,采用无连接的不可靠的数据传输。邮槽是一种单项通信机制,创建邮槽的服务器进程读取数据,打开邮槽的客户机进程写入数据。为保证邮槽在各种Windows平台下都能正常工作,我们在传输消息时,应将消息限制在424个字节一下。
因此,在服务器端,我们可以增加一个菜单项,对其响应:

void CCH_17_MailslotSrvView::OnMailslotRecv() 
{
	// TODO: Add your command handler code here
	HANDLE hMaileslot;
	hMaileslot = CreateMailslot("\\\\.\\mailslot\\MyMailslot",	//邮槽名
								0,								//任意长度的消息大小
								MAILSLOT_WAIT_FOREVER,			//一直等待
								NULL);							//默认安全属性

	if(INVALID_HANDLE_VALUE == hMaileslot)
	{
		MessageBox("创建油槽失败");
		return ;
	}

	char buf[200];
	DWORD dwRead;
	if(!ReadFile(hMaileslot,buf,200,&dwRead,NULL))
	{
		MessageBox("读取数据失败");
		CloseHandle(hMaileslot);
		return ;
	}
	
	MessageBox(buf);
	CloseHandle(hMaileslot);
}
注意,服务器端只接收数据。
而在客户端再加一个菜单项,添加响应:

void CCH_17_MailslotCltView::OnMailslotSend() 
{
	// TODO: Add your command handler code here
	HANDLE hMailslot;
	hMailslot = CreateFile("\\\\.\\mailslot\\MyMailslot",	//邮槽名
						   GENERIC_WRITE,					//写数据
						   FILE_SHARE_WRITE,				//写共享
						   NULL,							//默认安全属性
						   OPEN_EXISTING,					//打开已存在的邮槽
						   FILE_ATTRIBUTE_NORMAL,			//常规属性
						   NULL);							//
	if(INVALID_HANDLE_VALUE == hMailslot)
	{
		MessageBox("打开邮槽失败");
		return ;
	}

	char buf[] = "客户端发送数据";
	DWORD dwWrite;
	if(!WriteFile(hMailslot,buf,sizeof(buf),&dwWrite,NULL))
	{
		MessageBox("写入数据失败");
		CloseHandle(hMailslot);
		return ;
	}

	CloseHandle(hMailslot);
}

启动两个进程,先点击服务器的接收数据,然后点击客户端的发送数据。服务器就能接收到客户端发送的数据了。如果想实现双向通信,那么需要给服务器增加发送数据的机制,而在客户端增加接收数据的机制。


小结一下,这四种通信方式:剪切板、匿名管道只能在本机使用;而命名管道和油槽都能在跨网络通信;此外,油槽能实现一对多的通信方式,但是数据量较小。


你可能感兴趣的:(孙鑫VC++深入详解)