socket通讯原理及实现(C语言实现)


在文章的开始我想提出几个问题

  1. 什么是TCP/IP , UDP?
  2. Socket在哪里?
  3. Socket是什么?

什么是TCP/IP , UDP?
TCP/IP(Transmission Control Protocol/Internet Protocol)即传输控制协议/网间协议,是一个工业标准的协议集,它是为广域网(WANs)设计的。
UDP(User Data Protocol,用户数据报协议)是与TCP相对应的协议。它是属于TCP/IP协议族中的一种。
Socket是什么?
Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
Socket在哪里?
下面这张图说明了Socket存在的位置
在这里插入图片描述在网络编程中 , 要遵循一下这样的流程进行编写
在这里插入图片描述
先从服务器端说起。服务器端先初始化Socket,然后与端口绑定(bind),对端口进行监听(listen),调用accept阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把回应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。

1) socket()函数

int socket(int domain, int type, int protocol);

domain 即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。

名称含义名称含义
PF_UNIX,PF_LOCAL本地通信PF_X25ITU-T X25 / ISO-8208协议
AF_INET,PF_INETIPv4 Internet协议PF_AX25Amateur radio AX.25
PF_INET6IPv6 Internet协议PF_ATMPVC原始ATM PVC访问
PF_IPXIPX-Novell协议PF_APPLETALKAppletalk
PF_NETLINK内核用户界面设备PF_PACKET底层包访问

        函数socket()的参数domain用于设置网络通信的域,函数socket()根据这个参数选择通信协议的族。通信协议族在文件sys/socket.h中定义。
type指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等(socket的类型有哪些?)。
protocol故名思意,就是指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议.
注意:并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。

当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(addressfamily , AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。

sockaddr_in(在netinet/in.h中定义):
struct sockaddr_in
{
short sin_family;   /*Address family一般来说AF_INET(地址族)PF_INET(协议族)*/

unsigned short sin_port;   /*Port number(必须要采用网络数据格式,普通数字可以用htons()函数转换成网络数据格式的数字)*/

struct in_addr sin_addr;  /*IP address in network byte order(Internet address)*/

unsigned char sin_zero[8];  /*Same size as struct sockaddr没有实际意义,只是为了 跟SOCKADDR结构在内存中对齐*/
};

2) bind()函数

int bind(int sockfd, const struct sockaddr addr, socklen_t addrlen);

正如上面所说bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。

函数的三个参数分别为:
sockfd即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
addr一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同,
如ipv4对应的是

struct sockaddr_in
 {
   sa_family_t sin_family;
   in_port_t sin_port;
   struct in_addr sin_addr;
};

struct in_addr 
{
   uint32_t s_addr;
};

ipv6对应的是

struct sockaddr_in6
 {
   sa_family_t sin6_family;
   in_port_t sin6_port;
   uint32_t sin6_flowinfo;
   struct in6_addr sin6_addr;
   uint32_t sin6_scope_id;
};

struct in6_addr 
{
   unsigned char s6_addr[16];
};

Unix域对应的是

#define UNIX_PATH_MAX 108

struct sockaddr_un 
{
   sa_family_t sun_family;
   char sun_path[UNIX_PATH_MAX];
};

addrlen对应的是地址的长度。通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind() , 而客户端就不会调用,而是在connect()时由系统随机生成一个。

3) listen()、connect()函数

int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr addr, socklen_t addrlen);

如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket.
如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。

listen()函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。
这里还想再说一下listen()的第二个参数 , 如果在Linux下查看的话 , 你会发现 , 能够连接的个数为 n+1个 , 在这里其实是将第一个排除在外 , 因为连接成功之后 , 就将第一个从已完成三次握手的队列中摘除 , 所以它是不算的.实际上还是 n个. 它的最大连接数不能超过128 ; 如果有超过最大连接数的客户端连入,它将会被放置在未完成三次握手的队列中

socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。

connect()函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。
客户端通过调用connect()函数来建立与TCP服务器的连接。

4) accept()函数

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就想TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。

accept函数的第一个参数为服务器的socket描述字第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址第三个参数为协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。

注意:accept的第一个参数为服务器的socket描述字,是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是已连接的socket描述字。一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。

5) recv()函数 和send() 函数

recv() 函数

int recv( SOCKET s, char FAR *buf, int len, int flags);
不论是客户还是服务器应用程序都用recv函数从TCP连接的另一端接收数据。
该函数的第一个参数指定接收端套接字描述符
第二个参数指明一个缓冲区,该缓冲区用来存放recv函数接收到的数据
第三个参数指明buf的长度
第四个参数一般置0
这里只描述同步Socket的recv函数的执行流程。

当应用程序调用recv函数时,
(1)recv先等待s的发送缓冲中的数据被协议传送完毕,如果协议在传送s的发送缓冲中的数据时出现网络错误,那么recv函数返回SOCKET_ERROR.
(2)如果s的发送缓冲中没有数据或者数据被协议成功发送完毕后,recv先检查套接字s的接收缓冲区; 如果s接收缓冲区中没有数据或者协议正在接收数据,那么recv就一直等待,直到协议把数据接收完毕。当协议把数据接收完毕,recv函数就把s的接收缓冲中的数据copy到buf中(注意协议接收到的数据可能大于buf的长度,所以 在这种情况下要调用几次recv函数才能把s的接收缓冲中的数据copy完。recv函数仅仅是copy数据,真正的接收数据是协议来完成的),recv函数返回其实际copy的字节数。如果recv在copy时出错,那么它返回SOCKET_ERROR;如果recv函数在等待协议接收数据时网络中断了,那么它返回0。

注意:在Unix系统下,如果recv函数在等待协议接收数据时网络断开了,那么调用recv的进程会接收到一个SIGPIPE信号,进程对该信号的默认处理是进程终止.

send() 函数

int send( SOCKET s, const char FAR *buf, int len, int flags );

不论是客户还是服务器应用程序都用send函数来向TCP连接的另一端发送数据。客户程序一般用send函数向服务器发送请求,而服务器则通常用send函数来向客户程序发送应答。
第一个参数指定发送端套接字描述符
第二个参数指明一个存放应用程序要发送数据的缓冲区
第三个参数指明实际要发送的数据的字节数
第四个参数一般置0
这里只描述同步Socket的send函数的执行流程。

当调用该函数时,
(1)send先比较待发送数据的长度len和套接字s的发送缓冲的长度, 如果len大于s的发送缓冲区的长度,该函数返回SOCKET_ERROR;
(2)如果len小于或者等于s的发送缓冲区的长度,那么send先检查协议是否正在发送s的发送缓冲中的数据,如果是就等待协议把数据发送完,如果协议还没有开始发送s的发送缓冲中的数据或者s的发送缓冲中没有数据,那么send就比较s的发送缓冲区的剩余空间和len.
(3)如果len大于剩余空间大小,send就一直等待协议把s的发送缓冲中的数据发送完.
(4)如果len小于剩余 空间大小,send就仅仅把buf中的数据copy到剩余空间里(注意并不是send把s的发送缓冲中的数据传到连接的另一端的,而是协议传的,send仅仅是把buf中的数据copy到s的发送缓冲区的剩余空间里)。

       如果send函数copy数据成功,就返回实际copy的字节数,如果send在copy数据时出现错误,那么send就返回SOCKET_ERROR;如果send在等待协议传送数据时网络断开的话,那么send函数也返回SOCKET_ERROR。

注意 : send()函数把buf中的数据成功copy到s的发送缓冲的剩余空间里后它就返回了,但是此时这些数据并不一定马上被传到连接的另一端。如果协议在后续的传送过程中出现网络错误的话,那么下一个Socket函数就会返回SOCKET_ERROR。(每一个除send外的Socket函数在执 行的最开始总要先等待套接字的发送缓冲中的数据被协议传送完毕才能继续,如果在等待时出现网络错误,那么该Socket函数就返回 SOCKET_ERROR)

注意:在Unix系统下,如果send在等待协议传送数据时网络断开的话,调用send的进程会接收到一个SIGPIPE信号,进程对该信号的默认处理是进程终止。

通过测试发现,异步socket的send函数在网络刚刚断开时还能发送返回相应的字节数,同时使用select检测也是可写的,但是过几秒钟之后,再send就会出错了,返回-1。select也不能检测出可写了。

6) close()函数

在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。

int close(int fd);
close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。

注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。

socket中TCP的三次握手建立连接详解

我们知道tcp建立连接要进行“三次握手”,即交换三个分组。大致流程如下:

客户端向服务器发送一个SYN J
服务器向客户端响应一个SYN K,并对SYN J进行确认ACK J+1
客户端再想服务器发一个确认ACK K+1

只有就完了三次握手,但是这个三次握手发生在socket的那几个函数中呢?请看下图:在这里插入图片描述

从图中可以看出,当客户端调用connect时,触发了连接请求,向服务器发送了SYN J包,这时connect进入阻塞状态;服务器监听到连接请求,即收到SYN J包,调用accept函数接收请求向客户端发送SYN K ,ACK J+1,这时accept进入阻塞状态;客户端收到服务器的SYN K ,ACK J+1之后,这时connect返回,并对SYN K进行确认;服务器收到ACK K+1时,accept返回,至此三次握手完毕,连接建立。

总结:客户端的connect在三次握手的第二个次返回,而服务器端的accept在三次握手的第三次返回。

socket中TCP的四次握手释放连接详解

四次握手的大致流程:
客户端向服务器发送一个FIN M
服务器向客户端响应一个ACK M+1
服务器向客户端发送一个FIN N
客户端向服务器相应一个 ACKN+1

上面介绍了socket中TCP的三次握手建立过程,及其涉及的socket函数。现在我们介绍socket中的四次握手释放连接的过程,请看下图:
在这里插入图片描述

图示过程如下:

某个应用进程首先调用close主动关闭连接,这时TCP发送一个FIN M;
另一端接收到FIN M之后,执行被动关闭,对这个FIN进行确认。它的接收也作为文件结束符传递给应用进程,因为FIN的接收意味着应用进程在相应的连接上再也接收不到额外数据;
一段时间之后,接收到文件结束符的应用进程调用close关闭它的socket。这导致它的TCP也发送一个FIN N;接收到这个FIN的源发送端TCP对它进行确认。这样每个方向上都有一个FIN和ACK。

以下是TCP状态转移过程图:

在这里插入图片描述

图中粗虚线表示典型的服务器连接的状态转移 ; 粗实线表示典型的客户端连接的状态转移 . CLOSED是一个假想的起始点.
服务器端状态 :

       服务器通过listen系统调用进人LISTEN状态,被动等待客户端连接,因此执行的是所谓的被动打开 . 服务器一旦监听到某个连接请求(收到同步报文段),就将该连接放人内核等待队列中,并向客户端发送带SYN标志的确认报文段 . 此时该连接处于SYN_RCVD状态 . 如果服务器成功地接收到客户端发送回的确认报文段,则该连接转移到ESTABLISHED状态 . ESTABLISHED状态是连接双方能够进行双向数据传输的状态 .
       当客户端主动关闭连接时(通过close或shutdown系统调用向服务器发送结束报文段),服务器通过返回确认报文段使连接进人CLOSE_WAIT状态 . 这个状态的含义很明确:等待
       服务器应用程序关闭连接 . 通常,服务器检测到客户端关闭连接后,也会立即给客户端发送一个结束报文段来关闭连接 . 这将使连接转移到LAST_ACK状态,以等待客户端对结束报文段的最后一次确认. 一旦确认完成,连接就彻底关闭了 .

客户端的状态 :

       客户端通过connect系统调用主动与服务器建立连接 . connect系统调用首先给服务器发送一个同步报文段,使连接转移到SYN_SENT状态 . 此后,connect系统调用可能因为如下两个原因失败返回:

  • 如果connect连接的目标端口不存在(未被任何进程监听),或者该端口仍被处于TIME_WAIT状态的连接所占用 , 则服务器将给客户端发送一个复位报文段,connect调用失败 .
  • 如果目标端口存在,但connect在超时时间内未收到服务器的确认报文段,则connect调用失败 .
    connect调用失败将使连接立即返回到初始的CLOSED状态 . 如果客户端成功收到服务器的同步报文段和确认,则connect调用成功返回,连接转移至ESTABLISHED状态 .

       当客户端执行主动关闭时,它将向服务器发送一个结束报文段,同时连接进人FIN_WAIT_1状态 . 若此时客户端收到服务器专门用于确认目的的确认报文段,则连接转移至FIN_WAIT_2状态 . 当客户端处于FIN_WAIT_2状态时,服务器处于CLOSE_WAIT状态,这一对状态是可能发生半关闭的状态 . 此时如果服务器也关闭连接(发送结束报文段),则客户端将给予确认并进入TIME_WAIT状态 .

       图中还给出了客户端从FIN_WAIT_1状态直接进人TIME_WAIT状态的一条线路(不经过FIN_WAIT_2状态),前提是处于FIN_WAIT_1状态的服务器直接收到带确认信息的结束报文段(而不是先收到确认报文段,再收到结束报文段) .

注 : 处于FIN_WAIT_2状态的客户端需要等待服务器发送结束报文段,才能转移至TIME_WAIT状态,否则它将直停留在这个状态 . 如果不是为了在半关闭状态下继续接收数据,连接长时间地停留在FIN_WAIT_2状态并无益处 . 连接停留在FIN_WAIT_2状态的情况可能发生在: 客户端执行半关闭后,未等服务器关闭连接就强行退出了 . 此时客户端连接由内核来接管,可称之为孤儿连接(和孤儿进程类似) . Linux 为了防止孤儿连接长时间存留在内核中,定义了两个内核变量: /proc/sys/netlipv4/tcp_ max_ orphans 和/proc/sys/netipv4/tcp_fin_timeout . 前者指定内核能接管的孤儿连接数目,后者指定孤儿连接在内核中生存的时间 .

以下是三次握手的状态转移图:
在这里插入图片描述
四次握手状态转移图
在这里插入图片描述

其中值得特别关注的TIME_WAIT这个状态
通常我们理解为 , 在四次握手时 ,一端收到FIN并发出ACK后 , 它与发送方彻底断开连接 ; 但实际情况却不同 , 这是考虑到一端关闭 , 此时还有消息在路上 , 如果此时彻底断开链接 , 那么就会造成带宽的占用. 所以实际情况是 , 当一端收到FIN请求并发送出ACK之后 , 就转变为TIME_WAIT状态 .
为什么要有TIME_WAIT状态??
1) 为了保证让四次挥手能够可靠的进行
2) 为了让网络中迟来的报文被识别并丢弃

TIME_WAIT时间存约为两分钟(两个报文的生存时间) , 为了 处理(接收 并删除)此时还在网络中传输的数据.

服务器端代码

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>

int main()
{
	int sockfd = socket(AF_INET,SOCK_STREAM,0);
	assert(sockfd != -1 );

	struct sockaddr_in saddr,caddr;//指明地址信息
	memset(&saddr,0,sizeof(saddr));
	saddr.sin_family = AF_INET;
	saddr.sin_port = htons(6000);//普通数字可以用htons()函数转换成网络数据格式的数字)
	saddr.sin_addr.s_addr = inet_addr("127.0.0.1");//将点分十进制的IP转化为二进制 , 服务器端的IP

	int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
	assert(res != -1);
	listen(sockfd,5);//

	while(1)
	{
		int len = sizeof(caddr);
		int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
		if(c < 0)
		{
			continue;
		}
		printf("accept c=%d,ip=%s,port=%d\n",c,inet_ntoa(caddr.sin_addr),ntohs(caddr.sin_port));
		while(1)
		{
			char buff[128] = {0};
			int n = recv(c,buff,1,0);
			if(n <= 0)
			{
				break;
			}
			printf("buff=%s\n",buff);
			send(c,"ok",2,0);
		}
		printf("one client over\n");
		close(c);
	}
}

客户端代码

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>

int main()
{
	int sockfd = socket(AF_INET,SOCK_STREAM,0);
	assert(sockfd != -1 );

	struct sockaddr_in saddr;
	memset(&saddr,0,sizeof(saddr));
	saddr.sin_family = AF_INET;
	saddr.sin_port = htons(6000);
	saddr.sin_addr.s_addr = inet_addr("127.0.0.1");

	int res = connect(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));//向服务器发起链接
	assert(res != -1);

	while(1)
	{
		char buff[128] = {0};
		printf("input:\n");
		fgets(buff,128,stdin);
		if(strncmp(buff,"end",3) ==0 )
		{
			break;
		}
		send(sockfd,buff,strlen(buff),0);
		memset(buff,0,128);
		recv(sockfd,buff,127,0);
		printf("buff=%s\n",buff);
	}
	close(sockfd);
}

  • 17
    点赞
  • 54
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值