套接字
什么是套接字?
所谓 套接字 (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;
char sa_data[14];
};
地址族类型通常与协议族类型对应:
协议族 | 地址族 | 描述 | 地址值含义和长度 |
---|
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;
char sun_path[108];
};
sockaddr_in 的定义如下:
TCP/IP协议族 中 IPv4 使用 sockaddr_in 地址结构体:
struct sockaddr_in
{
sa_family_t sin_family;
u_int16_t sin_port;
struct in_addr sin_addr;
char sin_zero;
};
struct in_addr
{
u_int32_t s_addr;
};
可以清楚看到,该结构体解决了 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;
u_int16_t sin6_port;
u_int32_t sin6_flowinfo;
struct in6_addr sin6_addr;
u_int32_t sin6_scope_id;
};
struct in6_addr
{
unsigned char sa_addr[16];
};
网络字节序与主机字节序
在使用网络协议的编程中,在两台使用不同存储模式(大端/小端)的主机之间传递数据时,往往会产生歧义。解决问题的方法是:
- 发送端总是把要发送的数据转化成大端字节序数据后再发送
- 接收端根据自己的字节序决定是否将传送过来的数据进行转换(自身模式为小端则转换,为大端则不转换)
因此将 大端字节序 称为 网络字节序 ,小端字节序 称为 主机字节序 。
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);
上述函数中,长整型函数 通常用来转换 IP地址 ,短整型函数 用来转换 端口号 。
地址转换
记录日志时,我们习惯用 可读性好的字符串 来表示 IP地址;编程时,我们往往更需要以 整数(二进制数) 形式表示 IP地址。而这种频繁切换的需求需要通过函数来满足:
#include <arpa/inet.h>
in_addr_t inet_addr(const char *strptr);
char *inet_ntoa(struct in_addr in);
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_network(const char *cp);
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);
int inet_pton(int af, const char* src, void* dst);
const char* inet_ntop(int af, const void* src, char* dst, socklen_t cnt);
#include<netinet/in.h>
#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 46
不可重入的 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:服务类型
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);
3. 监听 socket
socket 被命名之后,还不能立马接受客户连接,需要使用系统调用来创建一个 监听队列 以存放待处理的客户连接(同时有 全连接 和 半连接 ):
#include<sys/socket.h>
int listen(int sockfd, int backlog);
4. 接受连接
accept 可以从 全连接队列 中接受一个客户连接,但 accept 只是从监听队列中取出连接,而不关心连接处于何种状态(ESTABLISHED 或 CLOSE_WAIT ),更不关心任何网络状态的变化(取出的客户端可能掉线了)。
#include <sys/types,h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
5. 发起连接
服务器 通过 listen 系统调用来 被动 接受连接,客户端 通过 connect 系统调用来 主动 与服务器建立连接。
#include<sys/types.h>
#include<sys/socket.h>
int connetct(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
其中两种常见的errno是:
- ECONNREFUSED:目标端口不存在,连接被拒绝。服务器发送给客户端一个复位报文段(seq=0),客户端不必回复复位报文段,应关闭连接或重新连接。
- ETIMEDOUT:连接超时。进行若干次重连,每次重连超时时间都增加一倍。
6.断开连接
关闭连接就是关闭连接对应的 socker 。 可以通过 close 系统调用来关闭文件描述符。
#include<unistd.h>
int close(int fd);
值得一提的是, close 并非立即关闭一个链接,而是将 fd 的引用计数减 1 。当 fd 的引用计数为 0 时,才真正关闭连接。多进程程序中,一次 fork 系统调用默认将使父进程中打开的 socket 的引用计数加 1 ,因此必须在 父子进程中都 对该 socket 执行 close 调用才能关闭连接。
如果无论如何都要立刻终止连接,可以使用 shutdown 系统调用:
#include<sys/socket.h>
int shutdown(int sockfd, int howto);
howto 可选值有:
可选值 | 含义 |
---|
SHUT_RD | 关闭 sockfd 上读的这一半。应用程序不能再执行读操作,并且该 socket 接收缓冲区中的数据都被丢弃。 | SHUT_WR | 关闭 sockfd 上写的这一半。sockfd 的发送缓冲区中的数据会在真正关闭连接之前全部发送出去,应用程序不可再执行写操作。这种情况下,连接处于半关闭状态。 | SHUT_RDWR | 同时关闭 sockfd 上的读和写 |
close 与 shutdown 最大的不同是,close 关闭连接时只能将 socket 上的读和写同时关闭,而 shutdown 可以分别(或同时)关闭。
7. 数据读写
对文件的读写 write 和 read 同样适用于 socke ,但 socket 还有专门用于 socket数据读写 的系统调用:
#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);
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);
#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;
socklen_t msg_namelen;
struct iovec* msg_iov;
int msg_iovlen;
void* msg_control;
socklen_t msg_controllen;
int 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)时,将通知应用程序有带外数据需要接受。通知方法有两种:
但应用程序接到通知后也只知道有带外数据要来,解决的时间的不确定性,但仍不知道带外数据在数据流中的具体位置。想要知道具体位置可以通过如下系统调用:
#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);
int getpeername(int sockfd, struct sockaddr* address, socklen_t* address_len);
9. 总结
用一张图总结 TCP三次握手 过程中对 socket接口 的使用。
|