在实际工程中,网络通信是是多个设备通信的一种常用手法。这些设备在本地组成一个局域网。为了实现高效的信息共享,这些设备还会形成组播组。当一个设备向组播地址和固定端口发送数据时,组播组内的所有设备均能收到数据,不仅简化了通信过程,还提高了效率。

        本文将介绍Windows平台下基于Winsock的UDP组播通信技术。

一、基本概念[1]

1.UDP

        UDP即用户数据报协议(UDP,User Datagram Protocol),无连接的传输层协议,提供面向事务的简单不可靠信息传送服务。

        UDP的主要特点包括:

(1)无连接的,即通信时不需要创建连接(发送数据结束时也没有连接可以释放)所以减小了开销和发送数据前的时延;

(2)采用最大努力交付,不保证可靠交付,因此主机不需要维护复杂的连接状态;

(3)无阻塞控制,即使网络中存在阻塞,也不会影响发送端的发送频率

(4)支持一对一、一对多、多对一、多对多的交互通信

        UDP报文结构如图1所示,报文头包括源端口、目的端口、长度和校验值等4个字段。

源端口:这个字段占据 UDP 报文头的前 16 位,通常包含发送数据报的应用程序所使用的 UDP 端口。接收端的应用程序利用这个字段的值作为发送响应的目的地址。这个字段是可选的,所以发送端的应用程序不一定会把自己的端口号写入该字段中。如果不写入端口号,则把这个字段设置为 0。这样,接收端的应用程序就不能发送响应了。

目的端口:接收端计算机上 UDP 软件使用的端口,占据 16 位。

长度:该字段占据 16 位,表示 UDP 数据报长度,包含 UDP 报文头和 UDP 数据长度。因为 UDP 报文头长度是 8 个字节,所以这个值最小为 8。

校验值:该字段占据 16 位,可以检验数据在传输过程中是否被损坏。

wireshark udp wireshark udp组播_组播

图1 UDP报文结构

2.组播通信

        UDP通信包括单播、多播、广播三种模式。多播又称为“组播”,是本文介绍的主题。

wireshark udp wireshark udp组播_网络协议_02

图2 UDP通信模式

        单播即网络中的两个主机之间点对点地通信[4]。广播为一个主机对整个局域网上所有主机上的数据通信。组播介于二者之间,是一台或多台主机对一组特定的主机进行通信,而不是所有局域网上的主机。

        组播通信必须依赖于 IP 多播地址,在 IPv4 中它是一个 D 类 IP 地址,范围从 224.0.0.0 到 239.255.255.255.

        使用同一个 IP 多播地址接收多播数据包的所有主机构成了一个主机组,也称为多播组。一个多播组的成员是随时变动的,一台主机可以随时加入或离开多播组,多播组成员的数目和所在的地理位置也不受限制,一台主机也可以属于几个多播组。

        多播地址就类似于微信群号,多播组相当于微信群,一个个的主机就相当于群里面的成员。每个主机都有自己的IP地址,相同多播组的主机还有对应的多播组地址。

3.Winsock

        Winsock是Windows平台下的一种标准API,用于两个或多个应用程序(或进程)之间通过网络进行数据通信。主要有Winsock 1和Winsock 2等两个版本。在本文中使用的Winsock 2包含的头文件为WinSock2.h,对应的库为ws2_32.lib.

        Winsock初始化过程:首先确保包含对应版本的头文件,然后保证链接对应的库文件(可以在代码中使用#pragma comment(lib, "WS2_32"),或在编译器项目属性中链接器->输入->附加依赖项中添加ws2_32.lib);接着通过调用WSAStartup函数来实现加载Winsock库。

        使用Winsock结束后,需要释放资源,并取消应用程序挂起的Winsock操作。使用 WASCleanup()。

二、基于Winsock的UDP组播通信

1.组播通信原理[1]

        组播通信包括发送端和接收端。发送端将数据发送到组播地址和固定端口上;接收端在本地绑定对应的固定端口,然后加入到组播的群组,最终实现数据的共享。

wireshark udp wireshark udp组播_网络_03

图3 组播通信的发送端和接收端

2.组播通信设置方法

(1)在window平台下,创建套接字前,要先初始化套接字windows socket api(初始化Winsock)。

(2)创建地址结构保存组播地址。

(3)接收端和发送端的设置有所差异。
        对于接收端,要绑定“特定的”本地端口接收数据,端口号由组播通信协议事先约定,组播组中所有设备均使用该端口号接收数据;同时,在接收数据前需要设置端口加入组播,不再接收数据时需要设置端口退出组播。
        对于发送端,只需要向组播地址发送数据即可。不需要设置加入组播,绑定特定端口也是非必须的(此时使用的是默认端口)。

(4)接收端设置方法:
(4.1)绑定本地端口。对于接收端,要用bind()将套接字与本地端口绑定,端口号要与事先约定的组播通信一致。

(4.2)加入组播。用setsockopt()将套接字与组播地址关联,套接字选项为IP_ADD_MEMBERSHIP。

(4.3) 接收数据。接收端用recvfrom()接收数据。

(4.4) 退出组播。用setsockopt()设置退出组播,套接字选项为IP_DROP_MEMBERSHIP。

(5)发送端设置方法:
对于发送端,不需要将套接字与组播地址关联。只需要用sendto()通过套接字向特定组播地址发送数据。

(6)结束通信后,关闭套接字api.

三、例程

1.接收端

        接收端例程的思路为设置一个循环,用于接收指定组播地址("224.168.0.1")的数据,需要绑定的本地端口号为7777。收到的数据被存储到特定文件中。当数据存储发生错误时跳出循环、结束组播接收。

/*
#    Copyright By Schips, All Rights Reserved
#    代码链接来源:
#
#    File Name:  group_client.c
#    功能:组播客户端
*/
#include <winsock2.h>
#include <stdio.h>
#include <windows.h>
#pragma comment(lib,"ws2_32.lib")
#include <tchar.h>
#include<ws2tcpip.h>
int main()
{
    //1.windows下要先初始化套接字
    WSADATA wsaData;
    if(WSAStartup(MAKEWORD(2,2),&wsaData)!=0)
    {
        printf("初始化套接字失败!\n");
        return -1;
    }
    printf("初始化套接字成功!\n");
    
    //2.建立客户端SOCKET
    SOCKET client;
    client=socket(AF_INET,SOCK_DGRAM,0);
    if(client==INVALID_SOCKET)
    {
        printf("建立客户端套接字失败; %d\n",WSAGetLastError());
        WSACleanup();
        return -1;
    }
    printf("建立客户端套接字成功!\n");
    
    sockaddr_in serveraddress;		//服务器地址,该变量用于存储recvfrom()捕获的数据发送源的地址

	//3.绑定端口(接收端需要绑定,发送端不需要绑定)
	client=socket(AF_INET,SOCK_DGRAM,0);		// 接收组播socket

	sockaddr_in  AddrClient;

	AddrClient.sin_family=AF_INET;
	AddrClient.sin_addr.s_addr=INADDR_ANY;		//任意

	AddrClient.sin_port=htons(7777);//组播监听端口号,需根据实际组播端口号设定


	int ret = bind(client,(sockaddr *)&AddrClient,sizeof(sockaddr_in));
	if(ret==SOCKET_ERROR)
	{
		printf("绑定广播接收端口1错误!\n");
	}

    
    //4.加入组播
    struct ip_mreq mreq;
    memset(&mreq,0,sizeof(struct ip_mreq));
    mreq.imr_multiaddr.S_un.S_addr=inet_addr("224.168.0.1");    //组播源地址
    mreq.imr_interface.S_un.S_addr=INADDR_ANY;       //本地地址	// 将要添加到多播组的 IP,类似于 成员号
    int m=setsockopt(client,IPPROTO_IP,IP_ADD_MEMBERSHIP,(char FAR *)&mreq,sizeof(mreq));	//关键函数,用于设置组播、链接组播。此时套接字client已经连上组播
    if(m==SOCKET_ERROR)
    {
        perror("setsockopt");
        return -1;
    }
    
    
    //创建接收数据缓冲区
    char recvbuf[1000000];  //回头注意重新设定缓冲区大小
    int n;
    DWORD dwWrite;    //DWORD在windows下常用来保存地址(或者存放指针)
    BOOL bRet;
    int len=sizeof(sockaddr_in);
    
    
    //创建文件
    HANDLE hFile=CreateFile(_T("接收数据.txt"),GENERIC_WRITE,0,0,CREATE_ALWAYS,FILE_ATTRIBUTE_NORMAL,0);
    if(hFile!=INVALID_HANDLE_VALUE)
    {
        printf("创建文件成功!\n");
    }
    
    while(1)
    {
        n=recvfrom(client,recvbuf,sizeof(recvbuf),0,(sockaddr*)&serveraddress,&len);	//从组播socket接收数据
        if(n==SOCKET_ERROR)
        {
            printf("recvfrom error:%d\n",WSAGetLastError());
            printf("接收数据错误!\n");
        }
        //将接收到的数据写到hFile中
        bRet=WriteFile(hFile,recvbuf,n,&dwWrite,NULL);		//如果有写数据错误,则跳出循环;否则一直接收数据
        if(bRet==FALSE)
        {
            MessageBox(NULL,_T("Write Buf ERROR!"),_T("ERROR"),MB_OK);
            break;
        }
    }
    
    //传送成功
    MessageBox(NULL,_T("Receive file OK!"),_T("OK"),MB_OK);
    
    closesocket(client);
    WSACleanup();
    return 0;
}

2.发送端

        发送端例程的思路为设置一个循环,一直向特定组播地址("224.1.1.1")和端口(65000)发送数据。

/*
#	 代码来源链接:
#    File Name:  group_server.c
*/
#include <winsock2.h>
#include <stdio.h>
#include <windows.h>
#pragma comment(lib,"ws2_32.lib")
#include <tchar.h>
#include<ws2tcpip.h>

const int MAX_BUF_LEN = 255;


int main(int argc, char* argv[])
{
	WORD wVersionRequested;
	WSADATA wsaData;
	int err;	
	// 1.启动初始化套接字
	wVersionRequested = MAKEWORD( 2, 2 );
	err = WSAStartup( wVersionRequested, &wsaData );
	if ( err != 0 )
	{
		return -1;
	}
	if ( LOBYTE( wsaData.wVersion ) != 2 ||
	HIBYTE( wsaData.wVersion ) != 2 )
	{
		WSACleanup( );
		return -1; 
	}
	// 2.创建socket
	SOCKET connect_socket;
	connect_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
	if(INVALID_SOCKET == connect_socket)
	{
		err = WSAGetLastError();
		printf("socket error! error code is %d/n", err);
		return -1;
	}

	// 2.1 获取主机名称和ip地址(非必须)
	char hostname[128];
	struct hostent*pHost;
	if(gethostname(hostname,128)==0)	// gethostname() 返回本地主机的标准主机名
	{ 
		printf("%s\n",hostname);//计算机名字 
	}
	pHost = gethostbyname(hostname);	// 用hostname获取host,返回对应于给定主机名的包含主机名字和地址信息的hostent结构的指针
	printf("ip %s\n",inet_ntoa(*(struct in_addr*)pHost->h_addr_list[0]));

	// 3.设置要发送的IP地址和端口,取名为sin
	SOCKADDR_IN sin;
	sin.sin_family = AF_INET;
	sin.sin_port = htons(65000);
	sin.sin_addr.s_addr = inet_addr("224.1.1.1");//组播地址


	bool bOpt = true;
	// 准备发送数据将套接字设置为广播类型
	// 注:与接收不同,发送时不需要加入组播,直接向组播地址发送即可
	setsockopt(connect_socket, SOL_SOCKET, SO_BROADCAST, (char*)&bOpt, sizeof(bOpt));	//设置该套接字为广播类型
	int nAddrLen = sizeof(SOCKADDR);
	char buff[MAX_BUF_LEN] = "";
	int nLoop = 0;
	while(1)		//除非发送错误,否则一直循环发数据
	{
		nLoop++;
		sprintf(buff, "%8d", nLoop);
		// 通过connect_socket套接字向sin地址和端口发送数据,该发送方式是直接向组播地址发送。
		int nSendSize = sendto(connect_socket, buff, strlen(buff), 0, (SOCKADDR*)&sin, nAddrLen);	// 发送数据
		if(SOCKET_ERROR == nSendSize)
		{
			err = WSAGetLastError();
			printf("sendto error!, error code is %d/n", err);
			return -1;
		}
		printf("Send: %s\n", buff);
		Sleep(500);
	}
	return 0;
}

四、工程经验总结

        对于“基于项目学习”的代码类工程,要平衡完成工程和学习这两个方面所投入的精力。在任何一方面投入过多或过少都会导致无法顺利完成工程任务。根据当前经验,总结出以下三轮步骤。

        (1)第一轮,结合实际工程,了解工程中最核心3~5个的基本概念。了解概念有助于开展后续工作。注意时间有限,切忌贪多和深究。

        (2)第二轮,在百度上查找教程,尝试根据教程修改代码,完成目标。如果可以完成任务,则本轮结束。否则根据发现的问题,提炼概念,百度或请人求教。

        (3)第三轮,完成任务后做总结,视情况发布成果(专利、论文、github、等)。

参考资料

[1]UDP(组播)原理及应用

[2]Winsock网络编程快速入门

[3]【网络开发】winsock组播

[4] 基于 UDP 的组播、广播详解