解析C语言基于UDP协议进行Socket编程的要点
两种协议TCP和UDP
前者可以理解为有保证的连接,后者是追求快速的连接。
当然最后一点有些太过绝对,但是现在不需熬考虑太多,因为初入套接字编程,一切从简。
稍微试想便能够大致理解,TCP追求的是可靠的传输数据,UDP追求的则是快速的传输数据。
前者有繁琐的连接过程,后者则是根本不建立可靠连接(不是绝对),只是将数据发送而不考虑是否到达。
以下例子以*nix平台的便准为例,因为Windows平台需要考虑额外的加载问题,稍作添加就能在Windows平台上运行UDP。
UDP
这是一个十分简洁的连接方式,假设有两台主机进行通信,一台只发送,一台只接收。
接收端:
intsock;/*套接字*/ socklen_taddr_len;/*发送端的地址长度,用于recvfrom*/ charmess[15]; charget_mess[GET_MAX];/*后续版本使用*/ structsockaddr_inrecv_host,send_host; /*创建套接字*/ sock=socket(PF_INET,SOCK_DGRAM,0); /*把IP和端口号信息绑定在套接字上*/ memset(&recv_host,0,sizeof(recv_host)); recv_host.sin_family=AF_INET; recv_host.sin_addr.s_addr=htonl(INADDR_ANY);/*接收任意的IP*/ recv_host.sin_port=htons(6000);/*使用6000端口号*/ bind(sock,(structsockaddr*)&recv_host,sizeof(recv_host)); /*进入接收信息的状态*/ recvfrom(sock,mess,15,0,(structsockaddr*)&send_host,&addr_len); /*接收完成,关闭套接字*/ close(sock);
上述代码省略了许多必要的错误检查,在实际编写时要添加
代码解释:
PF_INET代表协议的类型,此处代表IPv4网络协议族,同样PF_INET6代表IPv6网络协议族,这个范围在后方单独记录,不与IPv4混在一起(并不意味着更复杂,实际上更简便)。
AF_INET代表地址的类型,此处代表IPv4网络协议使用的地址族,同样有AF_INET6(在操作系统实现中PF_INET和AF_INET的值一样,但是还是要写宏更好,而不应该直接用数字或者,混淆使用)
htonl和htons两个函数的使用涉及到大端小端问题,这里不叙述,需要记住的是在网络编程时一定要使用这种函数将必要信息转为大端表示法。
(structsockaddr*)这个强制转换是为了参数的必须,但不会出错,因为sizeof(structsockaddr_in)==sizeof(structsockaddr)具体可以查询相关信息,之所以这么做是为了方便编写套接字程序的程序员。
发送端:
intsock; constchar*mess="HelloServer!"; charget_mess[GET_MAX];/*后续版本使用*/ structsockaddr_inrecv_host; socklen_taddr_len; /*创建套接字*/ sock=socket(PF_INET,SOCK_DGRAM,0); /*绑定*/ memset(&recv_host,0,sizeof(recv_host)); recv_host.sin_family=AF_INET; recv_host.sin_addr.s_addr=inet_addr("127.0.0.1"); recv_host.sin_port=htons(6000); /*发送信息*/ /*在此处,发送端的IP地址和端口号等各类信息,随着这个函数的调用,自动绑定在了套接字上*/ sendto(sock,mess,strlen(mess),0,(structsockaddr*)&recv_host,sizeof(recv_host)); /*完成,关闭*/ close(sock);
上述代码是发送端。
代码解释:
inet_addr函数是用于将字符串格式的IP地址转换为大端表示法的地址类型,即s_addr的类型in_addr_t
与之相反,同样也有功能相反的函数inet_ntoa用于将in_addr_t类型转为字符串,但是使用时一定要记住及时拷贝返回值charaddr[16];recv_host.sin_addr.s_addr=inet_addr("127.0.0.1");strcpy(addr,inet_ntoa(recv_host.sin_addr.s_addr));
从上述代码看出,UDP协议的使用十分简洁,几乎就是创建套接字->准备数据->装备套接字->发送/接收->结束
其中,都没有连接的操作,但是实际上这是为了方便UDP随时和不同的主机进行通信所默认的设置,如果需要和相同主机一直通信呢?
此中的原由暂时不需要知道,记录方法,即长时间使用UDP和同一主机通信时,可以使用connect函数来进行优化自身。此时假设两台主机的实际功能一致,既接收也发送
发送端:
/*前方高度一致,将bind函数替换为*/ connect(sock,(structsockaddr*)&recv_host,sizeof(recv_host);//将对方的IP地址和端口号信息注册进UDP的套接字中) while(1)/*循环的发送和接收信息*/ { size_tread_len=0; /*原先使用的sendto函数,先择改为使用write函数,Windows平台为send函数*/ write(sock,mess,strlen(mess));/*send(sock,mess,strlen(mess),0)FORWindowsPlatform*/ read_len=read(sock,get_mess,GET_MAX-1);/*recv(sock,mess,strlen(mess)-1,0)FORWindowsPlatform*/ get_mess[read_len-1]='\0'; printf("InClientlikeHostRecviveFromOtherHost:%s\n",get_mess); } /*后方高度一致*/
接收端:
/*前方一致,添加额外的structsockaddr_insend_host;并添加循环,构造收发的现象*/ while(1) { size_tread_len=0; charsent_mess[15]="HelloSender!";/*用于发送的信息*/ sendto(sock,mess,strlen(sent_mess),0,(structsockaddr*)&recv_host,sizeof(recv_host)); read_len=recvfrom(sock,mess,15,0,(structsockaddr*)&send_host,&addr_len) mess[read_len-1]='\0'; printf("InSeverlikeHostRecviveFromotherHost:%s\n",mess); } /*后方高度一致*/ /* *之所以只在接收端使用connect的原因,便在于我们模拟的是客户端-服务器的模型,而服务器的各项信息是不会随意变更的 *但是客户端就不同了,可能由于ISP(InternetServerProvider)的原因,你的IP地址不可能总是固定的,所以只能 *保证在客户端部分注册了服务器的各类信息,而不能在服务器端注册客户端的信息。 *当然也有例外,例如你就想这个软件作为私密软件,仅供两个人使用,且你有固定的IP地址,那么你可以两边都connect,但是 *一定要注意,只要有一点信息变动,这个软件就可能无法正常的收发信息了。 */
代码解释
故而实际上,虽然前方的表格显示,UDP似乎并没有connect的使用必要,但是实际上还是有用到的地方。
就*nix的API来说,sendto和write的区别十分明显,便是一个需要在参数中提供目标主机的各类信息,而后者则不需要提供。同样的道理recvfrom和read也是如此。
这个代码只是做演示而已,所以将代码置于无限循环当中,现实中可以自行定义出口条件。
以上是UDP的一些简单说明,入门足矣,并未详细叙述某些函数的具体用法,而是用实际例子来体现。在记录TCP之前,还是需要讲一个函数shutdown
shutdown与close(closesocket)
首先要知道,网络通信一般而言是双方的共同进行的,换而言之就是双向的,一个方向只用来发送消息,一个方向只用来读取消息。
这就导致了,在结束套接字通信的时候,需要关闭两个方向的通道(暂时叫它们通道),那同时关闭不行吗?可以啊
close(sock);//closesocket(sock);FORWindowsPlatForm就是这么干的,同时断开两个方向的连接。
简单的通信程序或者单向通信程序这么做的确无甚大碍,但是万一在结束通信的时候需要接收最后一个信息那该怎么办?
假设通信结束,客户端向服务器发送"Thankyou"
服务器需要接收这个信息,之后才能关闭通信
问题就在这之间,服务器并不知道客户端会在通信结束后的什么时刻传来信息
所以我们选择在通信完成后先关闭服务器的发送通道(写流),等待客户端发来消息后,关闭剩下的接收通道(读流)
发送端:
/*假设有一个TCP的连接,此为客户端*/ write(sock,"Thankyou",10); close(sock);//写完直接关闭通信
接收端:
/*此为服务器*/ /*首先关闭写流*/ shutdown(sock_c,SHUT_WR); read(sock_c,get_mess,GET_MAX); printf("Message:%s\n",get_mess); close(sock_c); close(sock_s);//关闭两个套接字是因为TCP服务器端的需要,后续会记录
代码解释
shutdown函数的作用就是可选择的关闭那个方向的输出
intshutdown(intsock,inthowto);
sock代表要操作的套接字
howto有几个选择
- *nix**:SHUT_RDSHUT_WRSHUT_RDWR
- Windows:SD_RECEIVESD_SENDSD_BOTH
下面,有几个结构体,以及一个接口十分重要及常用:
- structsockaddr_in6:代表的是IPv6的地址信息
- structaddrinfo:这是一个通用的结构体,里面可以存储IPv4或IPv6类型地址的信息
- getaddrinfo:这是一个十分方便的接口,在上述UDP程序中许多手动填写的部分,都能够省去,有该函数替我们完成
改写一下上方的例子:
接收端:
intsock;/*套接字*/ socklen_taddr_len;/*发送端的地址长度,用于recvfrom*/ charmess[15]; charget_mess[GET_MAX];/*后续版本使用*/ structsockaddr_inhost_v4;/*IPv4地址*/ structsockaddr_in6host_v6;/*IPv6地址*/ structaddrinfoeasy_to_use;/*用于设定要获取的信息以及如何获取信息*/ structaddrinfo*result;/*用于存储得到的信息(需要注意内存泄露)*/ structaddrinfo*p; /*准备信息*/ memset(&easy_to_use,0,sizeofeasy_to_use); easy_to_use.ai_family=AF_UNSPEC;/*告诉接口,我现在还不知道地址类型*/ easy_to_use.ai_flags=AI_PASSIVE;/*告诉接口,稍后“你”帮我填写我没明确指定的信息*/ easy_to_use.ai_socktype=SOCK_DGRAM;/*UDP的套接字*/ /*其余位都为0*/ /*使用getaddrinfo接口*/ getaddrinfo(NULL,argv[1],&easy_to_use,&result);/*argv[1]中存放字符串形式的端口号*/ /*创建套接字,此处会产生两种写法,但更保险,可靠的写法是如此*/ /*旧式方法 *sock=socket(PF_INET,SOCK_DGRAM,0); */ /*把IP和端口号信息绑定在套接字上*/ /*旧式方法 *memset(&recv_host,0,sizeof(recv_host)); *recv_host.sin_family=AF_INET; *recv_host.sin_addr.s_addr=htonl(INADDR_ANY);/*接收任意的IP*/ *recv_host.sin_port=htons(6000);/*使用6000端口号*/ *bind(sock,(structsockaddr*)&recv_host,sizeof(recv_host)); */ for(p=result;p!=NULL;p=p->ai_next)/*该语法需要开启-std=gnu99标准*/ { sock=socket(p->ai_family,p->ai_socktype,p->ai_protocol); if(sock==-1) continue; if(bind(sock,p->ai_addr,p->ai_addrlen)==-1) { close(sock); continue; } break;/*如果能执行到此,证明建立套接字成功,套接字绑定成功,故不必再尝试。*/ } /*进入接收信息的状态*/ //recvfrom(sock,mess,15,0,(structsockaddr*)&send_host,&addr_len); switch(p->ai_socktype) { caseAF_INET: addr_len=sizeofhost_v4; recvfrom(sock,mess,15,0,(structsockaddr*)&host_v4,&addr_len); break; caseAF_INET6: addr_len=sizeofhost_v6 recvfrom(sock,mess,15,0,(structsockaddr*)&host_v6,&addr_len); break; default: break; } freeaddrinfo(result);/*释放这个空间,由getaddrinfo分配的*/ /*接收完成,关闭套接字*/ close(sock);
代码解释:
首先解释几个新的结构体
structaddrinfo这个结构体的内部顺序对于*nix和Windows稍有不同,以*nix为例
structaddrinfo{ intai_flags; intai_family; intai_socktype; intai_protocol; socklen_tai_addrlen; structsockaddr*ai_addr;/*存放结果地址的地方*/ char*ai_canonname;/*忽略它吧,很长一段时间你无须关注它*/ structaddrinfo*ai_next;/*一个域名/IP地址可能解析出多个不同的IP*/ };
ai_family如果设定为AF_UNSPEC那么在调用getaddrinfo时,会自动帮你确定,传入的地址是什么类型的
ai_flags如果设定为AI_PASSIVE那么调用getaddrinfo且向其第一个参数传入NULL时会自动绑定自身IP,相当于设定INADDR_ANY
- ai_socktype就是要创建的套接字类型,这个必须明确声明,系统没法预判(日后人工智能说不定呢?)
- ai_protocol一般情况下我们设置为0,含义可以自行查找,例如MSDN或者UNP
- ai_addr这里保存着结果,可以通过调用getaddrinfo之后的第四个参数获得。
- ai_addrlen同上
- ai_next同上
getaddrinfo强大的接口函数
intgetaddrinfo(constchar*node,constchar*service,
conststructaddrinfo*hints,structaddrinfo**res);
通俗的说这几个参数的作用
node便是待获取或者待绑定的域名或是IP,也就是说,这里可以直接填写域名,由操作系统来转换成IP信息,或者直接填写IP亦可,是以字符串的形式
service便是端口号的意思,也是字符串形式
hints通俗的来说就是告诉接口,我需要你反馈哪些信息给我(第四个参数),并将这些信息填写到第四个参数里。
res便是保存结果的地方,需要注意的是,这个结果在API内部是动态分配内存了,所以使用完之后需要调用另一个接口(freeaddrinfo)将其释放
实际上对于现代的套接字编程而言,多了几个新的存储IP信息的结构体,例如structsockaddr_in6和structsockaddr_storage等。
其中,前者是后者的大小上的子集,即一个structstorage一定能够装下一个structsockaddr_in6,具体(实际上根本看不到有意义的实现)
structsockaddr_in6{ u_int16_tsin6_family; u_int16_tsin6_port; u_int32_tsin6_flowinfo;/*暂时忽略它*/ structin6_addrsin6_addr;/*IPv6的地址存放在此结构体中*/ u_int32_tsin_scope_id;/*暂时忽略它*/ }; structin6_addr{ unsignedchars6_addr[16]; } ------------------------------------------------------------ structsockaddr_storage{ sa_family_tss_family;/*地址的种类*/ char__ss_pad1[_SS_PAD1SIZE];/*从此处开始,不是实现者几乎是没办法理解*/ int64_t__ss_align;/*从名字上可以看出大概是为了兼容两个不同IP类型而做出的妥协*/ char__ss_pad2[_SS_PAD2SIZE];/*隐藏了实际内容,除了IP的种类以外,无法直接获取其他的任何信息。*/ /*在各个*nix的具体实现中,可能有不同的实现,例如`__ss_pad1`,`__ss_pad2`,可能合并成一个`pad`。*/ };
在实际中,我们往往不需要为不同的IP类型声明不同的存储类型,直接使用structsockaddr_storage就可以,使用时直接强制转换类型即可
改写上方接收端例子中,进入接收信息的状态部分
/*首先将多于的变量化简*/ //-structsockaddr_inhost_v4;/*IPv4地址*/ //-structsockaddr_in6host_v6;/*IPv6地址 structsockaddr_storagehost_ver_any;/*+任意类型的IP地址*/ ... /*进入接收信息的状态部分*/ recvfrom(sock,mess,15,0,(structsockaddr*)&host_ver_any,&addr_len);/*像是又回到了只有IPv4的年代*/
补充完整上方对应的发送端代码
intsock; constchar*mess="HelloServer!"; charget_mess[GET_MAX];/*后续版本使用*/ structsockaddr_storagerecv_host;/*-structsockaddr_inrecv_host;*/ structaddrinfotmp,*result; structaddrinfo*p; socklen_taddr_len; /*获取对端的信息*/ memset(&tmp,0,sizeoftmp); tmp.ai_family=AF_UNSPEC; tmp.ai_flags=AI_PASSIVE; tmp.ai_socktype=SOCK_DGRAM; getaddrinfo(argv[1],argv[2],&tmp,&result);/*argv[1]代表对端的IP地址,argv[2]代表对端的端口号*/ /*创建套接字*/ for(p=result;p!=NULL;p=p->ai_next) { sock=socket(p->ai_family,p->ai_socktype,p->ai_protocol);/*-sock=socket(PF_INET,SOCK_DGRAM,0);*/ if(sock==-1) continue; /*此处少了绑定bind函数,因为作为发送端不需要讲对端的信息绑定到创建的套接字上。*/ break;/*找到就可以退出了,当然也有可能没找到,那么此时p的值一定是NULL*/ } if(p==NULL) { /*错误处理*/ } /*-//设定对端信息 memset(&recv_host,0,sizeof(recv_host)); recv_host.sin_family=AF_INET; recv_host.sin_addr.s_addr=inet_addr("127.0.0.1"); recv_host.sin_port=htons(6000); */ /*发送信息*/ /*在此处,发送端的IP地址和端口号等各类信息,随着这个函数的调用,自动绑定在了套接字上*/ sendto(sock,mess,strlen(mess),0,p->ai_addr,p->ai_addrlen); /*完成,关闭*/ freeaddrinfo(result);/*实际上这个函数应该在使用完result的地方就予以调用*/ close(sock);
到了此处,实际上是开了网络编程的一个初始,解除了现代的UDP最简单的用法(甚至还算不上完整的使用),但是确实是进行了交互。
介绍UDP并不是因为它简单,而是因为他简洁,也不是因为它不重要,相反他其实很强大。
永远不要小看一个简洁的东西,就像C语言
ARP协议
最简便的方法就是找一个有WireShark软件或者tcpdump的*nix平台,前者你可以选择随意监听一个机器,不多时就能看见ARP协议的使用,因为它使用的太频繁了。
对于ARP协议而言,首先对于一台机器A,想与机器B通信,(假设此时机器A的高速缓存区(操作系统一定时间更新一次)中没有机器B的缓存),
那么机器A就向广播地址发出ARP请求,如果机器B收到了这个请求,就将自己的信息(IP地址,MAC地址)填入ARP应答中,再发送回去就行。
上述中,ARP请求和ARP应答是一种报文形式的信息,是ARP协议所附带的实现产品,也是用于两台主机之间进行通信。
这是当机器A和机器B同处于一个网络的情况下,可以借由本网络段的广播地址发送请求报文。
对于不同网络段的机器A与机器B而言,想要通过ARP协议获取MAC地址,就需要借助路由器的帮助了,可以想象一下,路由器(可以不止一个)在中间,机器A和机器B分别在这些路由器的两边(即在不同子网)
由于A和B不在同一个子网内,所以没办法通过通过直接通过广播到达,但是有了路由器,就能进行ARP代理的操作,大概就是将路由器当成机器B,A向自己的本地路由器发送ARP请求
之后路由器判断出是发送给B的ARP请求,又正好B在自己的管辖范围之内,就把自己的硬件地址写入ARP应答中发回去,之后再有A向B的数据,就都是A先发送给路由器,再经由路由器发往B了
ICMP协议
这个协议比较重要。
请求应答报文和差错报文,重点在于差错报文。
请求应答报文在ICMP的应用中可以拿来查询本机的子网掩码之类的信息,大致通过向本子网内的所有主机发送该请求报文(包括自己,实际上就是广播),后接收应答,得到信息
差错报文在后续中会有提到,这里需要科普一二。
首先对于差错报文的一大部分是关于xxx不可达的类型,例如主机不可达,端口不可达等等,每次出现错误的时候,ICMP报文总是第一时间返回给对端,(它一次只会出现一份,否则会造成网络风暴),但是对端是否能够接收到,就不是发送端的问题了。
这点上套接字的类型有着一定的联系,例如UDP在unconnected状态下是会忽略ICMP报文的。而TCP因为总是connected的,所以对于ICMP报文能很好的捕捉。
ICMP差错报文中总是带着出错数据报中的一部分真实数据,用于配对。