UDP简介
UDP - 用户数据报协议(User Datagram Protocol)。UDP是一个无连接协议。UDP套接字是一种数据报套接字(datagram socket)。UDP数据报不保证最终到达它们的目的地。
UDP 是一个简单的传输层协议,在 RFC 768 中有详细说明。应用进程往一个UDP套接字写入一个消息,该消息随后被封装到一个UDP数据报,该UDP数据报进而又被封装到一个IP数据报,然后发送到目的地。UDP不保证UDP数据报会到达其最终目的地,不保证各个数据报的先后顺序跨网络后保持不变,也不保证每个数据报只到达一次。
我们使用UDP进行网络编程所遇到的问题是它缺乏可靠性。如果一个数据报到达了其最终目的地,但是校验和检测发现有错误,或者该数据报在网络传输途中被丢弃了,它就无法被投递给UDP套接字,也不会被源端自动重传。如果想要确保一个数据报到达其目的地,可以往应用程序中添置一大堆的特性:来自对端的确认、本端的超时与重传等。
每个UDP数据报都有一个长度。如果一个数据报正确地到达其目的地,那么该数据报地长度将随数据一道传递给接收端应用进程。而 TCP 是一个字节流(byte-stream)的协议,没有任何记录边界,因此基于TCP的应用层,例如 HTTP协议,需要先解析固定长度的协议头,才能确定协议体的长度。
UDP提供无连接的服务,因为UDP客户与服务器之间不必存在任何长期的关系。举例来说,一个UDP客户可以创建一个套接字并发送一个数据报给一个给定的服务器,然后立即用同一个套接字发送另一个数据报给另一个服务器。同样地,一个UDP服务器可以用同一个UDP套接字从若干个不同的客户接收数据报,每个客户一个数据报。
注意:MTU 限制是在IP层发生的,在IP层进行分片处理,与传输层协议无关。
当一个IP数据报将从某个接口送出时,如果它的大小超过相应链路的 MTU(maximum transmission uint, 最大传输单元),IPv4和IPv6都将执行分片。这些分片在到达最终目的地之前通常不会被重组。通常一个分片被丢弃,整个数据都会被丢弃。
套接字地址结构
IPv4 套接字地址结构
IPv4套接字地址结构通常也称为"网际套接字地址结构",它以 sockaddr_in 命名,定义在 <netinet/in.h> 头文件中。POSIX 对 sockaddr_in 的定义如下:
struct in_addr {
in_addr_t s_addr; /* 32-bit IPv4 address */
/* network byte ordered */
};
struct sockaddr_in {
uint8_t sin_len; /* length of structure (16) */
sa_family_t sin_family; /* AF_INET */
in_port_t sin_port; /* 16-bit TCP or UDP port number */
/* network byte ordered(网络字节序) */
struct in_addr sin_addr; /* unused */
/* network byte ordered(网络字节序) */
char sin_zero[8]; /* unused */
};
说明:
sin_len : 长度字段,这是为增加对OSI协议的支持而随4.3BSD-Reno添加的。在此之前,第一个成员是 sin_family,它是一个无符号短整数(unsigned short)。并不是所有的厂家都支持套接字地址结构的长度字段,而且POSIX规范也不要求有这个成员。
sin_len:即使有 sin_len 长度字段,我们也无需设置和检查它。除非涉及路由套接字。
POSIX规范只需要这个结构中的3个字段:sin_family、sin_addr 和 sin_port 。对于符合POSIX的实现来说,定义额外的结构字段是可以接受的,这对于网际套接字地址结构来说也是正常的。几乎所有的实现都增加了 sin_zero 字段,所以所有的套接字地址结构大小都至少是16字节。
-
数据类型解释:
- in_addr_t 必须是一个至少32位的无符号整数类型。
- in_port_t 必须是一个至少16位的无符号整数类型。
- sa_family_t 可以是任何无符号整数类型。在支持长度字段的实现中,sa_family_t 通常是一个8位的无符号整数,而在不支持长度字段的实现中,sa_family_t 则是一个16位的无符号整数。
IPv4地址在 sin_addr 以in_addr_t (32位的无符号整数)类型进行存储,因此可以使用如下地址转换函数:
#include <arpa/inet.h>
int inet_aton(const char *strptr, struct in_addr *addrptr); /* 返回,若字符串有效则位1,否则为0 */
char* inet_ntoa(struct in_addr inaddr); /* 返回一个点分十进制数串的指针 */
/* 适用于 IPv4地址和IPv6地址 的地址转换函数 */
int inet_pton(int family, const char *strptr, void *addrptr); /* 返回:若成功则为1,若输入不是有效的表达格式则为0,若出差则为-1 */
const char* inet_ntop(int family, const void *addrptr, char *strptr, size_t len); /* 返回:若成功则为指向结果的指针,若出错则为NULL */
- IPv4地址和TCP或UDP端口号在套接字地址结构中总是以网络字节序来存储的,因此可以使用以下函数进行两种字节序(网络序、字节序)之间的转换:
/* 返回:网络字节序的值 */
uint16_t htons(uint16_t host16bitvalue);
uint32_t htons(uint16_t host32bitvalue);
/* 返回:主机字节序的值 */
uint16_t ntohs(uint16_t net16bitvalue);
uint32_t ntohl(uint32_t net32bitvalue);
/* h代表host,n代表network */
- sin_zero 字段未曾使用,不过在填写这种套接字地址结构时,我们总是把该字段置为0。按照惯例,我们总是在填写前把整个结构置为0,而不是单单把sin_zero字段置为0。可使用以下函数置为 0 。
void bzero(void *dest, size_t nbytes);
通用套接字地址结构
当作为一个参数传递进任何套接字函数时,套接字地址结构总是以引用形式(也就是以指向该结构的指针)来传递。然而以这样的指针作为参数之一的任何套接字函数必须处理来自所支持的任何协议族的套接字地址结构。
有了ANSI C后解决办法很简单:void* 是通用的指针类型。然后套接字函数是在ANSI C之前定义的,在1982年 采取的办法是在 <sys/socket.h> 头文件中定义一个通用的套接字地址结构:
struct sockaddr {
uint8_t sa_len;
sa_family_t sa_family;
char sa_data[4];
}
于是套接字函数被定义为以指向通用套接字地址结构的一个指针作为其参数之一。这就要求对这些套接字函数的任何调用都必须要将指向特定于协议的套接字地址结构的指针进行类型强制转换,变成指向通用套接字地址结构的指针。
IPv6套接字地址结构
不在本文介绍,详见 《UNIX 网络编程 - 卷 1 : 套接字联网 API》
新的通用套接字地址结构
不在本文介绍,详见 《UNIX 网络编程 - 卷 1 : 套接字联网 API》
套接字函数
socket函数
通过调用 socket 函数,指定期望的通信协议类型,创建一个套接字。
#include <sys/socket.h>
int socket(int family, int type, int protocol);
- family : 指明协议族。
family | 说明 |
---|---|
AF_INET | IPv4协议 |
AF_INET6 | IPv6协议 |
AF_LOCAL | Unix域协议 |
AF_ROUTE | 路由套接字 |
AF_KEY | 密钥套接字 |
- type : 指明套接字类型。
type | 说明 |
---|---|
SOCK_STREAM | 字节流套接字 |
SOCK_DGRAM | 数据报套接字 |
SOCK_SEQPACKET | 有序分组套接字 |
SOCK_RAW | 原始套接字 |
- protocol :某个协议类型的常值,或者设为0,以选择所给定family和type组合的系统默认值。
AF_INET | AF_INET6 | AF_LOCAL | AF_ROUTE | AF_KEY | |
---|---|---|---|---|---|
SOCK_STREAM | TCP/SCTP | TCP/SCTP | |||
SOCK_DGRAM | UDP | UDP | |||
SOCK_SEQPACKET | SCTP | SCTP | |||
SOCK_RAW | IPv4 | IPv6 |
connect 函数
#include <sys/socket.h>
int connect(int sockfd, const struct socket *servaddr, sockelen_t addrlen);
-
TCP客户用 connect 函数来建立与TCP服务器的连接。
- TCP 使用 connect 函数后,将进行三次握手。
-
UDP客户用 connect 函数来建立 四元组 <src_ip, src_port, dst_ip, dst_port> 的路由关系
- UDP 使用 connect 函数后,可使用 read、write 读写数据,内核也会在收到 ICMP 反馈的错误后抛给应用层。
- UDP 使用 connect 函数后,使用 read、write 性能更高。
bind 函数
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
bind 函数把一个本地协议地址赋予一个套接字。
如果一个TCP客户或服务器未曾调用bind捆绑一个端口,当调用 connect 或 listen 时,内核就要为相应的套接字选择一个临时端口。
调用 bind 可以指定IP地址或端口,可以两者都指定,也可以都不指定。
IP地址 | 端口 | 结果 |
---|---|---|
通配地址(INADDR_ANY) | 0 | 内核选择IP地址和端口 |
通配地址(INADDR_ANY) | 非0 | 内核选择IP地址,进程指定端口 |
本地IP地址 | 0 | 进程指定IP地址,内核选择端口 |
本地IP地址 | 非0 | 进程指定IP地址和端口 |
如果让内核来为套接字选择一个临时端口号,那么必须注意,函数bind并不返回所选择的值。实际上,由于 bind 函数的第二个参数有 const 限定词,它无法返回所选之值。为了得到内核所选择的这个临时端口值,必须调用函数 getsockname 来返回协议地址。
listen 函数
#include <sys/socket.h>
int listen(int sockfd, int backlog);
listen函数仅由 TCP 服务器调用,它做两件事情:
- 当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用 connect 发起连接的客户端套接字。listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求。根据TCP状态转换图:调用listen导致套接字从CLOSED状态转换到LISTEN状态。
- 本函数的第二个参数规定了内核应该为相应套接字排队的最大连接个数。
为了理解其中的backlog参数,我们必须认识到内核为任何一个给定的监听套接字维护两个队列:
- 未完成连接队列,每个这样的SYN分节对应其中一项:已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN_RCVD状态。
- 已完成连接队列,每个已完成TCP三路握手过程的客户对应其中一项。这些套接字处于 ESTABLISHED 状态。
listen函数的backlog参数曾被规定为这两个队列总和的最大值。
accept 函数
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
accept 函数由TCP服务器调用,用于从已完成连接队列队头返回一个已完成连接。如果已完成连接队列为空,那么进程被投入睡眠(假定套接字为默认的阻塞方式)。
close 函数
#include <unistd.h>
int close(int sockfd);
通常的Unix close函数也用来关闭套接字,并终止TCP连接(四次挥手)。
getsockname 和 getpeername 函数
#include <sys/socket.h>
int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen);
int getpeername(int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen);
这两个函数或者返回与某个套接字关联的本地协议地址(getsockname),或者返回与某个套接字关联的外地协议地址(getpeername)。
需要这两个函数的理由如下:
- 在一个没有调用bind的TCP客户上,connect成功返回后,getsockname用于返回由内核赋予该连接的本地IP地址和本地端口号。
- 在一个没有调用bind的UDP客户上,sendto成功返回后,getsockname用于返回由内核赋予的本地IP地址和本地端口号。
- 在以端口号0调用bind(告知内核去选择本地端口号)后,getsockname用于返回由内核赋予的本地端口号。
- 在以通配IP地址调用bind的TCP服务器上,与某个客户的连接一旦建立(accept成功返回),getsockname就可以用于返回由内核赋予该连接的本地IP地址。
- 当一个服务器是由调用过accept的某个进程通过调用 exec 执行程序时,它能够获取客户身份的唯一途径便是调用 getpeername 。
套接字选项
本文仅介绍常用的几个套接字选项。
更多套接字选项详见: 《UNIX 网络编程 - 卷 1 : 套接字联网 API》
getsockopt 和 setsockopt 函数
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t *optlen);
- sockfd:必须指向一个打开的套接字描述符
- level:指定系统中解释选项的代码,或为通用套接字代码,或为某个特定于协议的代码(例如IPv4、IPv6、TCP或SCTP)。(SOL_SOCKET, IPPROTO_IP, IPPROTO_ICMPV6, IPPROTO_IPV6, IPPROTO_TCP, IPPROTO_SCTP)。
- optname:是选项名
- optval:是一个指向某个变量的指针。
TCP连接套接字可以从TCP监听套接字继承某些套接字选项:SO_DEBUG、SO_DONTROUTE、SO_KEEPALIVE、SO_LINGER、SO_OOBINLINE、SO_RCVBUF、SO_RCVLOWAT、SO_SNDBUF、SO_SNDLOWAT、TCP_MAXSEG和TCP_NODELAY。
SO_RCVBUF 和 SO_SNDBUF 套接字选项
SO_RCVBUF 和 SO_SNDBUF 是通用套接字,他们 level = SOL_SOCKET。
每个套接字都有一个发送缓冲区和一个接收缓冲区。这两个套接字选项允许我们改变这两个缓冲区的默认大小。
SO_RCVBUF 和 SO_SNDBUF 设置的缓冲区大小受限于下面的系统设置:
CHANGING NETWORK KERNEL SETTINGS
The default setting in bytes of the socket receive buffer:
sysctl -w net.core.rmem_default=262144
The default setting in bytes of the socket send buffer
sysctl -w net.core.wmem_default=262144
The maximum socket receive buffer size which may be set by using the SO_RCVBUF socket option:
sysctl -w net.core.rmem_max=262144
The maximum socket send buffer size which may be set by using the SO_SNDBUF socket option:
sysctl -w net.core.wmem_max=262144
但是,下方的系统设置优先于上方的系统设置
net.ipv4.tcp_rmem
net.ipv4.tcp_wmem
SO_REUSEADDR 和 SO_REUSEPORT 套接字选项
SO_REUSEADDR 和 SO_REUSEPORT 是通用套接字,他们 level = SOL_SOCKET。
SO_REUSEADDR 套接字选项能起到以下4个不同的功用:
-
SO_REUSEADDR 允许启动一个监听服务器并捆绑其众所周知端口,即使以前建立的将该端口用作它们的本地端口的连接仍存在。这个条件通常是这样碰到的:
- 启动一个监听服务器;
- 连接请求到达,派生一个子进程来处理这个客户;
- 监听服务器终止,但子进程继续为现有连接上的客户提供服务;
- 重启监听服务器;
此时,当服务器通过 socket、bind 和 listen 重新启动时,由于它试图捆绑一个现有连接(即正由早先派生的那个子进程处理着的连接)上的端口,从而 bind 调用会失败。但是如果该服务器在socket和bind两个调用之间设置了 SO_REUSEADDR 套接字选项,那么bind将成功。所有TCP服务器都应该指定本套接字选项,以允许服务器在这种情形下被重新启动。
SO_REUSEADDR 允许在同一端口上启动同一服务器的多个实例,只要每个实例捆绑一个不同的本地IP地址即可。
SO_REUSEADDR 允许单个进程捆绑同一个端口到多个套接字上,只要每次捆绑指定不同的本地IP地址即可。
SO_REUSEADDR 允许完全重复的捆绑:相同的IP地址和相同的端口。一般来说,本特性仅支持UDP套接字。此时依赖 kernel 进行路由。
SO_REUSEPORT 的语义:
- 本选项允许完全重复的捆绑,不过只有在想要捆绑同一IP地址和端口的每个套接字都指定了本套接字选项才行;
- 如果被捆绑的IP地址是一个多播地址,那么 SO_REUSEADDR 和 SO_REUSEPORT 被认为是等效的。
SO_REUSEPORT 是 4.4BSD 随多播支持的添加引入了 SO_REUSEPORT 这个套接字选项。
SO_REUSEPORT 选项的问题在于并非所有系统都支持它。在那些不支持本选项但是支持多播的系统上,我们改用 SO_REUSEADDR 以允许合理的完全重复的捆绑(也就是同一时刻在同一个主机上可运行多次且期待接收广播或多播数据报的UDP服务器)。
recvfrom 和 sendto
用于UDP套接字的两个新函数 recvfrom 和 sendto
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags,
struct sockaddr *from, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buff, size_t nbytes, int flags,
const struct sockaddr *to, socklen_t addrlen);
- 前三个参数 sockfd、buff 和 nbytes 等同于 read 和 write 函数的三个参数:套接字描述符、指向读入或写出缓冲区的指针和读写字节数。
- flags 参数的值或为0,或为下表列出的一个或多个 常值的逻辑或。
flags | 说明 | recv | send |
---|---|---|---|
MSG_DONTROUTE | 绕过路由表查找 | ||
MSG_DONTWAIT | 仅本操作非阻塞 | * | * |
MSG_OOB | 发送或接收带外数据 | * | * |
MSG_PEEK | 窥看外来消息 | * | * |
MSG_WAITALL | 等待所有数据 | * |
- recvfrom 的 from 参数指向一个将由该函数在返回时填写数据报发送者的协议地址的套接字地址结构,其大小由 addrlen 参数指定。 recvfrom 的 from 参数可以是一个空指针,此时 addrlen 也得是个空指针,表示不关心数据发送者的协议地址。
- sendto 的 to 参数指向一个含有数据报接收者的协议地址(如IP地址及端口号)的套接字地址结构,其大小由 addrlen 参数指定。
- 返回值:若成功则为读或写的字节数,若出错则为 -1 。
recvfrom 和 sendto 都可以用于 TCP,尽管通常没有理由这样做。
fcntl函数
#include <fcntl.h>
int fcntl(int fd, int cmd, .../* int arg */);
fcntl 与代表 "file control" (文件控制) 的名字相符,fcntl 函数可执行各种描述符控制操作。
操作 | fcntl |
---|---|
设置套接字为非阻塞式I/O型 | F_SETFL, O_NOBLOCK |
设置套接字为信号驱动式I/O型 | F_SETFL, O_ASYNC |
设置套接字属主 | F_SETOWN |
获取套接字属主 | F_GETOWN |
字节序
考虑一个16位整数,它由2个字节组成。内存中存储这两个字节有两者方法:一种是将低序字节存储在起始地址,这称为小端(little-endian)字节序;另一种方法是将高序字节存储在起始地址,这称为大端(big-endian)字节序。
术语 "小端" 和 "大端" 表示多个字节(大于1个字节)值的哪一端(小端或大端)存储在该值的起始地址。
网际协议(IPv4)使用大端字节序来传送这些多字节整数。
主机字节序和网络字节序的转换函数:
/* 返回:网络字节序的值 */
uint16_t htons(uint16_t host16bitvalue);
uint32_t htons(uint16_t host32bitvalue);
/* 返回:主机字节序的值 */
uint16_t ntohs(uint16_t net16bitvalue);
uint32_t ntohl(uint32_t net32bitvalue);
/* h代表host,n代表network */
如何判断主机字节序是大端字节序还是小端字节序:
bool little_endian() {
uint16_t a = 0;
uint8_t* p = (uint8_t*)&a;
*p = 0x1;
return a == 0x1;
}