文章目录
- 套接字
- socket 地址
- 通用 socket 地址
- 专用 socket 地址
- 网络字节序与主机字节序
- 地址转换
- TCP/UDP 连接中常用的 socket 接口
套接字
什么是套接字?
所谓 套接字 (Socket) ,就是对网络中 不同主机 上的应用进程之间进行双向通信的端点的抽象。
UNIX/Linux下一切皆文件。 socket
就是可读、可写、可控、可关的文件描述符。socket
最开始的含义是一个 (IP地址,端口) 对 (IP, port)
。唯一地表示了使用 TCP 通信的一端。
为什么要用到套接字?
我们知道,数据链路层、网络层、传输层协议是在内核中实现的。而 socket
就是 操作系统 提供给 应用程序 通过 系统调用 访问这些 协议服务 的一组 API
。socket
不但可以访问内核中 TCP/IP
协议栈,而且访问其他网络协议栈。
socket
定义的API
提供哪些功能?
- 将 应用程序数据 从 用户缓冲区 中复制到
TCP/UDP
内核发送缓冲区 ,将发送数据交付内核。 - 从
TCP/UDP
内核接收缓冲区 中复制数据到 用户缓冲区 ,以读取数据。 - 帮助 应用程序 修改 内核中各层协议的某些头部信息 或 其他数据结构,从而精确地控制底层通信行为。(如:通过
setsockopt函数
来设置IP 数据报
在网络上的存活时间)
socket
的主要 API
都定义在 sys/socket.h
头文件中。Linux
提供了一套定义在 netdb.h
头文件中的网络信息 API
,以实现 主机名 和 IP地址 之间的转换,以及服务名称和端口号之间的转换。
socket 地址
存储 socket
的地址信息的数据结构有三种:sockaddr
、 sockaddr_in
和 sockaddr_un
。我们将 sockaddr
称为 通用 socket 地址 ,将 后两者 称为 专用 socket 地址 。这样划分的意义在于:在使用时,我们可以选择自己所需要的结构,通信时再将我们所使用的结构强转为 sockaddr
,这样就能保证数据格式的一致。
通用 socket 地址
sockaddr 的定义如下:
#include<bits/socket.h>
struct sockaddr
{sa_family_t sa_family; // 地址族类型(sa_family_t)的变量。char sa_data[14]; // 存放 socket 地址值。
};
地址族类型通常与协议族类型对应:
协议族 | 地址族 | 描述 | 地址值含义和长度 |
---|---|---|---|
PF_UNIX | AF_UNIX | UNIX本地协议族 | 文件的路径名,长度可达108字节 |
PF_INET | AF_INET | TCP/IPv4协议族 | 16 bit 端口号和 32 bit IPv4 地址,共6字节 |
PF_INET6 | AF_INET6 | TCP/IPv6协议族 | 16 bit 端口号,32 bit 流标识,128 bit IPv6 地址,32 bit 范围ID,共26字节 |
宏 PF_*
和 AF_*
都定义在 bits/socket.h
头文件中,且有完全相同的值,因此二者经常混用。
然而,通用的 sockaddr
对于各个协议族而言适用性并不好—— sa_data
把 目标IP地址 和 端口信息 混在一起了。因此,Linux
为各个协议族提供了专门的 socket
地址结构体。
拓展: 由上表易知,14
字节的 sa_data
无法容纳多数协议族的地址值。因此,Linux
定义了一个新的通用 socket
地址结构体:
#include<bits/socket.h>
struct sockaddr_storage
{sa_family_t safamily;unsigned long int __ss_align;char __ss_padding[128-sizeof(__ss_ag=lign)];
};
这个结构体不仅提供了足够大的空间用于存放地址值,而且是内存对齐的(这是 __ss_align
成员的作用)。
专用 socket 地址
sockaddr_un 的定义如下:
UNIX 本地域协议族 使用 sockaddr_un
地址结构体:
#include<sys/un.h>
struct sockaddr_un
{sa_family_t sin_family; // 地址族:AF_UNIXchar sun_path[108]; // 文件路径名
};
sockaddr_in 的定义如下:
TCP/IP协议族 中 IPv4
使用 sockaddr_in
地址结构体:
struct sockaddr_in
{sa_family_t sin_family; /* 地址族:AF_INET */u_int16_t sin_port; /* 端口号,要用网络字节序表示 */struct in_addr sin_addr; /* IPv4地址结构体 */char sin_zero; /* 不使用 */
};struct in_addr
{u_int32_t s_addr; /* 32位 IPv4 地址,要用网络字节序表示 */
};
可以清楚看到,该结构体解决了 sockaddr
的缺陷,把 port
和 addr
分开储存在两个变量中。sockaddr_in
和 sockaddr
长度一样,都是 16
个字节,即 占用的内存大小是一致的 ,因此可以互相转化。二者是并列结构,指向 sockaddr_in
结构的指针也可以指向 sockaddr
。
sockaddr
和 sockaddr_in
是 Linux网络编程中最常用的 socket
结构体,sockaddr_in
用于 socket
定义和赋值;sockaddr
用于函数参数。 一般先把 sockaddr_in
变量赋值后,强制类型转换后传入 参数为 sockaddr
的函数。
sockaddr_in6 的定义如下:
TCP/IP协议族 中 IPv6
使用 sockaddr_in6
地址结构体:
struct sockaddr_in6
{sa_family_t sin6_family; /* 地址族:AF_INET6 */u_int16_t sin6_port; /* 端口号,要用网络字节序表示 */u_int32_t sin6_flowinfo; /* 流信息,应设置为0 */struct in6_addr sin6_addr; /* IPv6地址结构体 */u_int32_t sin6_scope_id; /* scope ID,尚处于实验阶段 */
};struct in6_addr
{unsigned char sa_addr[16]; /* 128位 IPv6 地址,要用网络字节序表示 */
};
网络字节序与主机字节序
在使用网络协议的编程中,在两台使用不同存储模式(大端/小端)的主机之间传递数据时,往往会产生歧义。解决问题的方法是:
- 发送端总是把要发送的数据转化成大端字节序数据后再发送
- 接收端根据自己的字节序决定是否将传送过来的数据进行转换(自身模式为小端则转换,为大端则不转换)
因此将 大端字节序 称为 网络字节序 ,小端字节序 称为 主机字节序 。
Linux
提供了如下 4
个函数来完成 主机字节序 和 网络字节序 之间的转换:
#include<netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostlong);
unsigned long int ntotl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);/* h代表主机字节序,n代表网络字节序,l代表长整型,s代表短整型 */
上述函数中,长整型函数 通常用来转换 IP地址 ,短整型函数 用来转换 端口号 。
地址转换
记录日志时,我们习惯用 可读性好的字符串 来表示 IP地址;编程时,我们往往更需要以 整数(二进制数) 形式表示 IP地址。而这种频繁切换的需求需要通过函数来满足:
#include <arpa/inet.h>
in_addr_t inet_addr(const char *strptr);
// 将点分十进制的 字符串IP地址 转换为网络字节序的 整数IP地址 ,失败时返回INADDR_NONE
char *inet_ntoa(struct in_addr in);
// 将网络字节序的 整数IP地址 转换为点分十进制的 字符串IP地址,该函数内部用一个静态变量存储转化结果
// 函数的返回值指向该静态内存,因此 inet_ntoa 是不可重入的。
int inet_aton(const char *cp, struct in_addr *inp);
// 将点分十进制的 字符串IP地址 转换为网络字节序的 整数IP地址(与addr的区别它会认为如255.255.255.255这类特殊地址有效),成功返回1,失败返回0。
in_addr_t inet_network(const char *cp);
// 将点分十进制的 字符串IP地址 转换为主机字节序的 整数IP地址
struct in_addr inet_makeaddr(in_addr_t net, in_addr_t host);
in_addr_t inet_lnaof(struct in_addr in);
in_addr_t inet_netof(struct in_addr in);/* 同时适用于 IPv4 和 IPv6: */
int inet_pton(int af, const char* src, void* dst);
// 将 字符串 表示的IP地址src(用点分十进制表示的IPv4地址或用十六进制字符表示的IPv6地址)转换成 网络字节序整数 表示的IP地址
// 转换结果存在dst指向的内存中。af指定地址族(AF_INET 或 AF_INET6),成功返回1、失败返回0并设置errno。
const char* inet_ntop(int af, const void* src, char* dst, socklen_t cnt);
// 将 网络字节序的整数IP地址 转换为 点分十进制的字符串IP地址,成功返回目标存储单元的地址、失败返回NULL并设置errno。
// cnt指定目标存储单元的大小,通过下面两个宏来指定大小:
#include<netinet/in.h>
#define INET_ADDRSTRLEN 16 // 用于 IPv4
#define INET6_ADDRSTRLEN 46 // 用于 IPv6
不可重入的 inet_ntoa 函数
struct in_addr addr1,addr2;
ulong l1,l2;
l1 = inet_addr("1.1.1.1");
l2 = inet_addr("127.0.0.1");
memcpy(&addr1, &l1, 4);
memcpy(&addr2, &l2, 4);
char *cp1 = inet_ntoa(addr1);
char *cp2 = inet_ntoa(addr2);
cout << "address 1: " << cp1 << endl;
cout << "address 2: " << cp2 << endl;
输出结果:
我们会发现,由于 inet_ntoa
的返回值指向一个函数内部的静态内存,因此最后一次传入的参数会掩盖之前的参数。
TCP/UDP 连接中常用的 socket 接口
1. 创建 socket
#include<sys/types.h>
#include<sys/socket.h>
int socket(int domain, int type, int protocol);
domain:底层协议族
type:服务类型
/* 服务类型主要有 */
// SOCK_STREAM服务(流服务),对于 TCP/IP 协议族而言,表示传输层使用 TCP 协议。
// SOCK_UGRAM服务(数据报),对于 TCP/IP 协议族而言,表示传输层使用 UDP 协议。
/* 拓展 */
// Linux内核版本2.6.17起,type可以是上述两种类型与下面两个标志的 与值
// SOCK_NONBLOCK 将新创建的 socket 设置为非阻塞的。
// SOCK_CLOEXEC 用 fork 调用创建子进程时在子进程中关闭该 socket
// 2.6.17版本前,文件描述符的这两个属性都需要使用额外的系统调用(如 fcntl)来设置。
protocol:通常是唯一的(前两个参数已经完全确定了它的值),大部分情况下被设为0,表使用默认协议。返回值:系统调用成功返回一个 socket 文件描述符,失败返回 -1 并设置 errno。
2. 命名/绑定 socket
创建 socket
时,我们给他指定了地址族,但是 并未指定使用该地址族中哪个具体地址 。 将一个 socket
与具体的地址绑定称为给 socket
命名。
- 我们通常要给服务器命名
socket
,因为只有命名后客户端才知道如何连接服务器。 - 客户端不需要命名
socket
,它通常使用操作系统自动分配的socket
地址。
#include<sys/types.h>
#include<sys/socket.h>
int bind(int sockfd, const struct sockaddr* my_addr, sklen_t addrlen);
// 将 my_addr 所指的 socket 地址分配给未命名的 sockfd 文件描述符,addrlen 指出该地址的长度。
// 成功返回0、失败返回-1并设置errno。
// 其中两种常见errno:
// EACCES:被绑定的地址是受保护的地址,仅超级用户能够访问。如:普通用户将 socket 绑定到知名服务端口(端口号0~1023)。
// EADDRINUSE:被绑定的地址正在使用中。如:将 socket 绑定到一个处于 TIME_WAIT 状态的 socket 地址。
3. 监听 socket
socket
被命名之后,还不能立马接受客户连接,需要使用系统调用来创建一个 监听队列 以存放待处理的客户连接(同时有 全连接 和 半连接 ):
#include<sys/socket.h>
int listen(int sockfd, int backlog);
// sockfd 指定被监听的 socket;backlog 提示内核监听队列的最大长度(常设为5),超过 backlog+1 则不受理新的客户连接,客户端也收到 ECONNREFUSED 错误信息。
// Linux内核2.2版本之前,backlog 指的是所有处于 半连接状态(SYN_RCVD)和 完全链接状态(ESTABISHED)的 socket 总上限。
// 2.2版本之后,它中表示处于 完全连接状态 的 socket 的上限。半连接状态 的 socket 上限值由 /proc/sys/ipv4/tcp_max_syn_backlog 内核参数定义。
// 成功返回0;失败返回-1并设置errno
4. 接受连接
accept
可以从 全连接队列 中接受一个客户连接,但 accept
只是从监听队列中取出连接,而不关心连接处于何种状态(ESTABLISHED
或 CLOSE_WAIT
),更不关心任何网络状态的变化(取出的客户端可能掉线了)。
#include <sys/types,h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 将 addr 所指的 socket 地址分配给执行过 listen 系统调用的 socket 文件描述符,地址长度为 addrlen。
// 成功时返回一个新的 连接socket,唯一地标识被接受的这个连接,服务器可通过读写该 socket 与被接受连接对应的客户端通信;失败返回-1并设置errno。
5. 发起连接
服务器 通过 listen
系统调用来 被动 接受连接,客户端 通过 connect
系统调用来 主动 与服务器建立连接。
#include<sys/types.h>
#include<sys/socket.h>
int connetct(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
// sockfd:socket 系统调用返回的文件描述符。
// serv_addr:处于服务器监听队列的客户端 socket 地址
// 成功返回0,sockfd 唯一标识这个链接,客户端可以通过读写 sockfd 与服务器通信;失败返回-1并设置errno。
其中两种常见的errno是:
- ECONNREFUSED:目标端口不存在,连接被拒绝。服务器发送给客户端一个复位报文段(seq=0),客户端不必回复复位报文段,应关闭连接或重新连接。
- ETIMEDOUT:连接超时。进行若干次重连,每次重连超时时间都增加一倍。
6.断开连接
关闭连接就是关闭连接对应的 socker
。 可以通过 close
系统调用来关闭文件描述符。
#include<unistd.h>
int close(int fd);
// fd:待关闭的socket
值得一提的是, close
并非立即关闭一个链接,而是将 fd
的引用计数减 1
。当 fd
的引用计数为 0
时,才真正关闭连接。多进程程序中,一次 fork
系统调用默认将使父进程中打开的 socket
的引用计数加 1
,因此必须在 父子进程中都 对该 socket
执行 close
调用才能关闭连接。
如果无论如何都要立刻终止连接,可以使用 shutdown
系统调用:
#include<sys/socket.h> // 从隶属头文件可以看出,它是专门为网络编程设计的
int shutdown(int sockfd, int howto);
// sockfd:待关闭的 socket。
// howto:shutdown 的行为。
// 成功返回0,失败返回-1并设置errno。
howto
可选值有:
可选值 | 含义 |
---|---|
SHUT_RD | 关闭 sockfd 上读的这一半。应用程序不能再执行读操作,并且该 socket 接收缓冲区中的数据都被丢弃。 |
SHUT_WR | 关闭 sockfd 上写的这一半。sockfd 的发送缓冲区中的数据会在真正关闭连接之前全部发送出去,应用程序不可再执行写操作。这种情况下,连接处于半关闭状态。 |
SHUT_RDWR | 同时关闭 sockfd 上的读和写 |
close
与 shutdown
最大的不同是,close
关闭连接时只能将 socket
上的读和写同时关闭,而 shutdown
可以分别(或同时)关闭。
7. 数据读写
对文件的读写 write
和 read
同样适用于 socke
,但 socket
还有专门用于 socket数据读写
的系统调用:
/* 用于TCP流数据读写的系统调用 */
#include<sys/types>
#include<sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// recv 读 sockfd 上的数据,send 往 sockfd 上写数据。
// buf 和 len 分别指定缓冲区的位置和大小。
// flags 为数据收发提供额外控制,通常为0。
// 成功时返回读/写的数据长度,失败返回-1并设置errno。
// recv 成功时返回的长度可能小于 len,因此可能要多次调用 recv 以读取完整数据;recv 返回 0 意味着 通信对方已经关闭连接。/* UDP数据读写 */
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
// buf 和 len 参数分别指定读/写缓冲区的位置和大小
// 由于 UDP 通信没有连接的概念,所以我们每次读取数据都需要获取发送端的 socket 地址,即 src_addr 所指向的内容。
// dest_addr 指定接收端的 socket 地址。
/* 上述两个系统调用也可以用于面向连接(STREAM)的 socket 的数据读写,只需将最后两个参数都设置为 NULL *//* 兼容 TCP流数据 和 UDP数据报 的数据读写 */
#include<sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);struct msghdr
{void* msg_name; // socket 地址,对于TCP协议,需设置为NULL。socklen_t msg_namelen; // socket 地址的长度struct iovec* msg_iov; // 分散内存块,详情见下文。int msg_iovlen; // 分散内存块的数量void* msg_control; // 指向辅助数据的起始位置socklen_t msg_controllen; // 辅助数据的大小int msg_flags; // 复制函数中的 flags 参数,recvmsg 还会在调用过程中将某些更新后的标志设置到 msg_flags 中。
}struct iovec
{void *iov_base; // 内存起始地址size_t iov_len; // 这块内存长度
}
对于 recvmsg 而言,数据将被读取并存放在 msg_iovlen 块分散的内存中,这种操作被称为 分散读(scatter read)。
对于 sendmsg 而言,分散内存中的数据将被一并发送,这称为 集中写(gather write)。
flags
可选值:
选项名 | 含义 | send | recv |
---|---|---|---|
MSG_CONFIRM | 指示数据链路层持续监听对方的回应,直到得到答复。仅能用于 SOCK_DGRAM 和 SOCK_RAW 类型的 socket。 | Y | N |
MSG_DONTROUTE | 不查看路由表,直接将数据发送给本地局域网络内的主机。用于发送者确定目标主机就在本网络。 | Y | N |
MSG_DONTWAIT | 对 socket 的此次操作将是非阻塞的 | Y | Y |
MSG_MORE | 告诉内核应用程序还有更多数据要发送,内核将 超时等待 新数据写入 TCP 发送缓冲区后一并发送。防止 TCP 发送过多小报文段,提高传输效率。 | Y | N |
MSG_WAITALL | 读操作仅在读取到指定数量的字节后才返回 | N | Y |
MSG_PEEK | 窥探读缓存中的数据,本次读操作不会清除这些数据。 | N | Y |
MSG_OOB | 发送或接收紧急数据(带外数据) | Y | Y |
MSG_NOSIGNAL | 往读端关闭的管道或 socket 连接中读写数据时不引发 SIGPIPE 信号 | Y | N |
flags
参数只对 send
和 recv
的当前调用生效,而后面讲到的 setsockopt
系统调用会永久地修改 socket
某些属性。
7.1 带外数据
实际应用中,通常无法预知 带外数据 何时到来,幸运的是 Linux
内核检测到 TCP
紧急标志(URG)时,将通知应用程序有带外数据需要接受。通知方法有两种:
I/O复用
产生的异常事件SIGURG
信号
但应用程序接到通知后也只知道有带外数据要来,解决的时间的不确定性,但仍不知道带外数据在数据流中的具体位置。想要知道具体位置可以通过如下系统调用:
#include<sys/socket.h>
int sockatmark(int fockfd);
sockatmark
判断 sockfd
是否处于带外标记,即下一个被读取到的数据是否是带外数据。若是返回 1
,此时就可以用带 MSG_OBB
标志的 recv
调用来接收带外数据;若不是返回 0
。
8. 地址信息函数
我们可以知道一个连接 socket 的 本端socket地址 ,以及 远端socket地址 。
#include<sys/socket.h>
int getsockname(int sockfd, struct sockaddr* address, socklen_t* address_len); // 获取 sockfd 对应的socket地址
int getpeername(int sockfd, struct sockaddr* address, socklen_t* address_len); // 远端socket地址
// 将获得的地址存于 address 指定的内存中,地址长度存于 address_len 中。
// 如果实际 socket 地址长度大于 address 所指内存区的大小,那么该 socket地址 将被截断。
// 成功返回0,失败返回-1并设置errno。
9. 总结
用一张图总结 TCP三次握手
过程中对 socket接口
的使用。