在实际工程中,网络通信是是多个设备通信的一种常用手法。这些设备在本地组成一个局域网。为了实现高效的信息共享,这些设备还会形成组播组。当一个设备向组播地址和固定端口发送数据时,组播组内的所有设备均能收到数据,不仅简化了通信过程,还提高了效率。
本文将介绍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 位,可以检验数据在传输过程中是否被损坏。
图1 UDP报文结构
2.组播通信
UDP通信包括单播、多播、广播三种模式。多播又称为“组播”,是本文介绍的主题。
图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]
组播通信包括发送端和接收端。发送端将数据发送到组播地址和固定端口上;接收端在本地绑定对应的固定端口,然后加入到组播的群组,最终实现数据的共享。
图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、等)。