Socket编程基本概念

参考:《UNIX 网络编程 · 卷1 : 套接字联网API》

用户数据报协议(UDP)

UDP 是一个简单的传输层协议,当应用层向 UDP 套接字写入一个消息,此消息会被封装到一个 UDP 数据报,进而又被封装到一个 IP 数据报发送到目的地。
UDP 不保证数据报会到达目的地,不保证多个数据报的先后顺序,不保证每个数据报只到达一次。
UDP 提供无连接的服务,因为 UDP 客户端与服务器之间不存在任何长连接关系。

传输控制协议(TCP)

完全不同于 UDP,TCP 提供了可靠性传输。TCP 需要客户端与服务器之间建立连接,然后通过这个连接进行交换数据,最后终止连接。
TCP 有动态估算客户端和服务器之间的往返时间RTT算法。
TCP 通过给其中每个字节关联一个序号对发送的数据进行排序。接受端 TCP 将根据序号重新排序,再传递给接受应用。
TCP 提供流量控制。
TCP 是全双工的。建立的连接在任意时刻两个端点都可以发送且接受数据。UDP 可以是全双工的。

TCP 连接的建立和终止

TCP 连接建立 (三次握手)

TCP 建立连接需要三个分节。

  1. 服务器被动打开。通过调用 socket、bind、listen 三个函数来准备接受客户端连接。
  2. 客户端通过调用 connect 发起主动打开。客户端 TCP 发送一个 SYN,告诉服务器客户端在待建立的连接中发送的数据的初始序列号。
  3. 服务器确认客户端的 SYN,向客户端发送 ACK,同时服务器也发送一个 SYN 是服务器在改连接中发送的数据的初始序列号。
  4. 客户端必须确认并向服务器发送 SYN。
    在这里插入图片描述

图中客户端给的初始序列号为 J,服务器给的初始序列号为 K。ACK 中的确认号就是接受这个 ACK 一端所期待的下一个序列号。

TCP连接终止 (四次挥手)

TCP 终止连接需要四个分节。

  1. 假如客户端首先调用close,则客户端为主动关闭。该 TCP 向服务器发送一个 FIN,表示数据发送完毕。
  2. 服务器确认收到 FIN 执行被动关闭。并向客户端发送 ACK。
  3. 服务器端传递到应用进程,应用进程调用 close 关闭套接字。于是服务器的 TCP 也发送一个 FIN 给客户端。
  4. 客户端确认收到服务器的 FIN,并向服务器发送 ACK。

在这里插入图片描述

与 SYN 类似,一个 FIN 也占据一个字节序列号空间。每个 FIN 的 ACK 确认号就是这个 FIN 的序列号加 1。

TCP 状态转换图

TCP 涉及的连接建立和连接终止的操作可以用状态转换图说明。
在这里插入图片描述
图中,实线表示客户端的正常状态转换,虚线表示服务器的正常状态转换。

TCP 分组交换图

下图展示了一个完整的 TCP 连接发生的实际分组交换情况,包括连接建立、数据传送、连接终止。
在这里插入图片描述

TIME_WAIT 状态

从上面的图可以看到,执行了主动关闭的客户端会产生 TIME_WAIT 状态,该客户端会停留在这个状态持续时间是最长分节生命周期 2 倍,也就是 2MSL。
MSL 是任何IP数据报能够在英特网中存活的最长时间。任何 TCP 实现都有一个 MSL 值,RFC 中建议是 2 分钟,但是有些实现上改为了 30 秒。也就是 TIME_WAIT 状态持续时间为 1 分钟 ~ 4 分钟。
存在 TIME-WAIT 状态有两个理由:

  1. 实现终止 TCP 全双工连接的可靠性
  2. 允许老的重复分节在网络中消逝

第一个理由:
假设最终的响应 ACK 丢失,服务器将重发最终的 FIN,因此客户必须维护状态信息以允许它重发最终的 ACK。如果不维护状态信息,它将响应以 RST(另外一个类型的 TCP 分节),而服务器则把该分节解释成一个错误。如果 TCP 打算执行所有必要的工作以彻底终止某个连接上两个方向的数据流(即全双工关闭),那么它必须正确处理连接终止序列四个分节中任何一个分节的丢失情况。本例子也说明执行主动关闭的一端为什么进入 TIME_WAIT 状态,因为它可能不得不重发最终的 ACK。

第二个理由:
我们假设 206. 62. 226. 33 端口 1500 和 198. 69.10.2 端口 21 之间有一个 TCP 连接。我们关闭这个连接后,在以后某个时候又重新建立起相同的 IP 地址和端口之间的 TCP 连接。后一个连接称为前一个连接的化身(incar-nation).因为它们的 IP 地址和端口号都相同。TCP 必须防止来自某个连接的老重复分组在连接终止后再现,从而被误解成属于同一连接的化身。要实现这种功能,TCP 不能给处于 TIME-WAIT 状态的连接启动新的化身。既然 TIME-WAIT 状态的持续时间是 2MLS,这就足够让某个方向上的分组最多存活 MSL 秒即被丢弃,另一个方向上的应答最多存活 MSL 秒也被丢弃。通过实施这个规则,我们就能保证当成功建立一个 TCP 连接时,来自该连接先前化身的老重复分组都已在网络中消逝。

CLOSE_WAIT 状态

CLOSE_WAIT 是被动关闭连接是形成的。
根据 TCP 状态机,服务器端收到客户端发送的 FIN,则按照 TCP 实现发送 ACK,因此进入CLOSE_WAIT 状态。但如果服务器端不执行 close(),就不能由 CLOSE_WAIT 迁移到LAST_ACK,则系统中会存在很多 CLOSE_WAIT 状态的连接。
可能是系统忙于处理读、写操作而未将已收到FIN的连接进行 close。此时,recv/read 已收到 FIN 的连接 socket 会返回 0。

端口号

应用层有很多进程都需要使用 TCP 或 UDP 等协议。这些协议都是使用了 16 位整数的端口号来区分这些进程。客户端想要和服务器通信,必须知道与之通信的服务器的IP地址和端口号。客户端通常使用短期存活的临时端口。
TCP 定义了一些总所周知的端口号 0~1023,比如无论 TCP 还是 UDP 的 80 端口都是 web 服务器,尽管大多实现是 TCP。这些端口由 IANA 分配。
已登记的端口号为 1024~49151,不受 IANA 控制,但由 IANA 登记并提供它们的使用情况。
动态/私有端口号为 49152~65535,IANA 不管之这些端口。
需要注意的是:

  1. UNIX 系统会保留端口,小于 1024 的端口只能赋予特权用户进程的套接字。

TCP 套接字对

一个 TCP 套接字对是一个定义该链接的两个端点的四元组:本地 IP 地址,本地 TCP 端口,外地 IP 地址,外地 TCP 端口。
套接字对唯一标识一个网络上每个 TCP 的连接。
标识每个端点的两个值(IP&Port)通常称为一个套接字。

TCP 套接字缓冲区

每一个 TCP 套接字都一个发送缓冲区,可以使用 SO_SNDBUF 来更改该缓冲区的大小。
当应用层进程调用 write 时,内核从改应用进程的缓冲区中复制所有数据到套接字的发送缓冲区。如果应用层进程缓冲区大于套接字发送缓冲区, 该进程将投入随眠(假设该套接字是阻塞的,默认)。内核将不从 write 系统调用返回,知道应用层进程缓冲区所有数据都被复制到套接字发送缓冲区。
所以调用 wirte 成功返回并不代表对端的 TCP 已经收到数据。

UDP 套接字缓冲区

任何 UDP 套接字都有发送缓冲区大小,不过它仅仅是可以写到该套接字的 UDP 数据的大小上限。
如果一个应用层进程写一个大于套接字发送缓冲区大小的数据报,内核将返回该进程一个 EMSGSIZE 错误,既然 UDP 是不可靠的,它不必保存应用层进程数据的一个副本,因此无需一个真正的发送缓冲区。

套接字地址结构

很多套接字函数 API 都需要一个套接字地址结构的指针作为参数。每种协议都有自己的套接字地址结构。但均以 sockaddr_ 开头,且后缀都不一样。

IPv4套接字地址结构

IPv4 套接字地址结构定义在 <netinet/in.h> 中。定义如下:

struct sockaddr_in
{
    uint8_t sin_len;
    sa_family_t sin_family;		//uint16_t
    in_port_t sin_port;			//uint16_t
    struct in_addr sin_addr;	
    unsigned char sin_zero[8];
};

struct in_addr
{
	in_addr_t s_addr;			//uint32_t
};

sin_len 并不是所有的厂家都支持套接字地址结构的长度段,而且 POSIX 也不要求有这个字段。这是为了增加对 OSI 协议的支持随 4.3BSD 添加的。即使有 sin_len 字段也不需要去设置和检查它。
POSIX 规范只需要三个字段:sin_family、sin_addr、sin_port,定义额外的结构字段是可以的,几乎所有的实现都增加了 sin_zero 字段,所以所有的套接字地质结构大小至少为 16 字节。
IPv4 地址和 TCP/UDP 端口在套接字地址结构中总是以网络字节序来存储。
sin_zero 字段未曾使用,在填写套接字时,总是会设置为 0,因为我们需要在结构创建好还没填写前就把整个结构置为 0。

IPv6套接字地址结构

IPv6 套接字地质结构定义在 <netinet/in.h> 中。定义如下:

struct sockaddr_in6
{
    uint8_t sin6_len;
    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
{
    uint8_t s6_addr[16];
};

IPv6 的地址族是 AF_INET6,而 IPv4 的地址族是 AF_INET。

通用套接字地址结构

当把一个套接字结构传进一个函数的参数时,总是以引用的方式传递。现在 ANSI C 有了 void* 指针很简单,但是在之前都是在 <sys/socket.h> 中定义一个通用的套接字地址结构。

struct sockaddr
{
    uint8_t sa_len;
    sa_family_t sa_family;
    char sa_data[14];
};

套接字函数的参数指向某个通用套接字地址结构。如 bind 函数的定义如下:

int bind(int fd, const sockaddr *addr, socklen_t len)

所以就要将定义的某种协议的套接字地质结构的指针进行强制转换,将其变成指向通用套接字地址结构的指针。

struct sockaddr_in serv;
//填充
bind(sockfd, (struct sockaddr*)&serv, sizeof(serv)); //强制转换

新的通用套接字地址结构

新的通用套接字作为 IPv6 套接字 API 的一部分,克服了现有的 struct_sockaddr 的一些缺点。
新的 struct sockaddr_storage 足以容纳系统所支持的任意套接字地址结构,定义在 <netinet/in.h> 中。

struct sockaddr_storage
{
    uint8_t ss_len;
    sa_famliy_t ss_family;
    char __ss_padding[128 - sizeof(unsigned short int) - sizeof(unsigned long int)];
    unsigned long int __ss_align;
};

如果系统支持的任何套接字有对其要求,sockaddr_storage 能够满足苛刻的对其要求。
sockaddr_storage 足够大,能够容纳系统支持的任何套接字地址结构。
除了 ss_len 和 ss_family,其他字段对用户来说是透明的。sockaddr_storage 结构必须类型强制转换成 ss_family 对应的地址类型的套接字地址结构中,才能访问其他成员。

套接字地址结构比较

如下如给出了不同套接字地址结构的比较。
在这里插入图片描述

值 - 结果参数

在很多套接字函数中都将一个地质结构的地址和长度传入或传出,方向相当于从进程到内核,或从内核到进程。
从进程到内核的传递套接字地址结构的函数有 3 个:bind、connect、sendto。将指针和指针内容的大小都给了内核,内核就知道到底从进程复制多少数据。
从内核到进程传递套接字地址结构的函数有 4 个:accept、recvfrom、getsockname、getpeername。
套接字函数的地址结构的大小也使用引用,不仅告诉内核地址结构的大小,当套接字函数返回时,结构大小又是一个值,可以告诉进程内核在该结构中究竟存储了多少信息。这种类型的参数成为值-结果参数。

字节序函数

如果有一个16位整数,由2个字节组成。内存中存储这两个字节有两种方法:

  1. 低字节存储在起始地址(低地址),称为小端
  2. 高字节存储在起始地址(低地址),称为大端
    这两种字节操作系统都有使用,把某个给定系统使用的字节序称为主机字节序
    测试大小端程序:
//大小端测试程序
int main()
{
                     //高字节-低字节
    unsigned int num = 0x12345678;
    unsigned char *p = (unsigned char *)&num;
    if (p[0] = 0x78) //低地址存储低字节位
        printf("little endian.\n");
    else
        printf("big endian.\n");
    return 0;
}

可以得到,Linux 使用的是小端模式。但是网际协议中使用大端字节序来传送数据。
我们需要在主机字节序和网络字节序之间进行互换。
字节序互换函数如下:

//h代表host,n代表network,s代表short(16位),l代表long(32位)
//尽管在64位系统中,长整数占用64位,htonl和ntohl函数操作的仍然是32位的值
#include <netinet/in.h>
uint16_t htons(uint16_t host16bitvalue); 
uint32_t htonl(uint32_t host32bitvalue);	//返回网络字节序的值
uint16_t ntohs(uint16_t net16bitvalue);
uint32_t ntohl(uint32_t net32bitvalue);		//返回主机字节序的值

如果主机字节序和网际协议字节序(大端)相同的系统中,这四个函数通常被定义为空宏。

字节序操纵函数

操纵字节序的函数有两组:

  1. 以 b(表示字节)开头的函数,起源于 4.2BSB。
#include <string.h>
void bzero(void *dest, size_t nbytes);                       //将指定数目字节置为0
void bcopy(const void *src, void *desc, size_t nbytes);      //将指定数目的字节从源字节移动到目标字节
int bcmp(const void *ptr1, const void *ptr2, size_t nbytes); // 比较两个任意的字节的指定字节数,相同返回0

以 mem 开头的第二组函数,起源于ANSI C标准。

#include <string.h>
void *memset(void *dest, int c, size_t len);                   //把目标字节指定数目的字节置为值c
void *memcpy(void *dest, const void *src, size_t nbytes);      //和bcopy类似,参数相反,但是没有解决内存重叠问题
int memcpy(const void *ptr1, const void *ptr2, size_t nbytes); //比较两个任意字节的指定字节数,相同返回0

inet_aton / inet_ntoa(ASCII字符串<->网络字节序二进制)

a 代表 address,也就是点分十进制字符串,n 代表 numeric,就是 32 位网络字节序的数值

//将C字符串(如:206.168.112.96)转换为一个32位的网络字节序二进制值,并保存在传进来的inaddrptr中
int inet_aton(const char *strptr, in_addr *inaddrptr)
    
//将一个32位的网络字节序二进制IPv4地址转换为对应的点分十进制字符串
char * inet_ntoa(struct in_addr inaddr)

inet_pton / inet_ntop (通用(IPv4/IPv6)ASCII字符串<->网络字节序二进制)

p 代表 presentation,就是地址表达式的意思,n 代表 numeric,就是 32 位网络字节序的数值

#include <arpa/inet.h>

//转换strptr指向的字符串结果存到addrptr指针的addrptr空间中。成功返回1,否则返回0
int inet_pton (int family, const char *strptr, void *addrptr);

//将数值格式addrptr转换到表达式strptr,len是目标存储的空间大小。如果len太小返回null,addrptr不能为空指针
const char* inet_ntop(int family, const void* addrptr, char* strptr, size_t len);

这两个函数的 family 参数既可以是 AF_INET,也可以是 AF_INET6。如果地址族不支持,都会返回一个错误,errno 置为 EAFNOSUPPORT。
注意:inet_pton 和 inet_ntop 虽然好用,但是需要向调用者传递一个指向某个二进制地址的指针,通常是一个套接字地址结构的成员,我们需要知道这个结构的格式和地址族。使得我们的代码与协议相关。

自定义的 readn / writen / readline 函数

在字节流套接字(如 TCP 套接字)上使用 read 或 write函数,可能比实际请求的数量要少,这并不是出错了,二十因为内核套接字的缓冲区已满,就需要再次调用 read 或者 write,输入/输出剩余的字节。
我们可以自己封装 3 个函数常用于字节流套接字的读写。

/**
 * readn - 读取固定字节数
 * @fd: 文件描述符
 * @buf: 接收缓冲区
 * @count: 要读取的字节数
 * 成功返回count,失败返回-1,读到EOF返回<count
 */
ssize_t readn(int fd, void *buf, size_t count)
{
	size_t nleft = count;
	ssize_t nread;
	char *bufp = (char*)buf;
	while (nleft > 0)
	{
		if ((nread = read(fd, bufp, nleft)) < 0)
		{
			if (errno == EINTR)
				continue;
			return -1;
		}
		else if (nread == 0)
			return count - nleft;

		bufp += nread;
		nleft -= nread;
	}
	return count;
}

/**
 * writen - 发送固定字节数
 * @fd: 文件描述符
 * @buf: 发送缓冲区
 * @count: 要读取的字节数
 * 成功返回count,失败返回-1
 */
ssize_t writen(int fd, const void *buf, size_t count)
{
	size_t nleft = count;
	ssize_t nwritten;
	char *bufp = (char*)buf;

	while (nleft > 0)
	{
		if ((nwritten = write(fd, bufp, nleft)) < 0)
		{
			if (errno == EINTR)
				continue;
			return -1;
		}
		else if (nwritten == 0)
			continue;

		bufp += nwritten;
		nleft -= nwritten;
	}

	return count;
}

/**
 * readline - 按行读取数据
 * @sockfd: 套接字
 * @buf: 接收缓冲区
 * @maxline: 每行最大长度
 * 成功返回>=0,失败返回-1
 */
ssize_t readline(int sockfd, void *buf, size_t maxline)
{
	int ret;
	int nread;
	char *bufp = buf;
	int nleft = maxline;
	while (1)
	{
		ret = recv_peek(sockfd, bufp, nleft);
		if (ret < 0)
			return ret;
		else if (ret == 0)
			return ret;
		nread = ret;
		int i;
		for (i=0; i<nread; i++)
		{
			if (bufp[i] == '\n')
			{
				ret = readn(sockfd, bufp, i+1);
				if (ret != i+1)
					exit(EXIT_FAILURE);

				return ret;
			}
		}
		if (nread > nleft)
			exit(EXIT_FAILURE);

		nleft -= nread;
		ret = readn(sockfd, bufp, nread);
		if (ret != nread)
			exit(EXIT_FAILURE);
		bufp += nread;
	}
	return -1;
}

/**
 * recv_peek - 仅仅查看套接字缓冲区数据,但不移除数据
 * @sockfd: 套接字
 * @buf: 接收缓冲区
 * @len: 长度
 * 成功返回>=0,失败返回-1
 */
ssize_t recv_peek(int sockfd, void *buf, size_t len)
{
	while (1)
	{
		int ret = recv(sockfd, buf, len, MSG_PEEK);
		if (ret == -1 && errno == EINTR)
			continue;
		return ret;
	}
}

EINTR 错误标识系统调用被一个捕捉的信号中断,如果发生了,需要继续读/写。MSG_WAITALL 标识和 recv 函数一起使用可以代替 readn 函数。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值