TCP网络编程
socket连接过程
![](https://img2020.cnblogs.com/blog/919443/202110/919443-20211009195917076-1679394410.png)
先从右侧的服务器端开始看,因为在客户端发起连接请求之前,服务器端必须初始化好。右侧的图显示的是服务器端初始化的过程,首先初始化socket,之后服务器端需要执行bind函数,将自己的服务能力绑定在一个众所周知的地址和端口上,紧接着,服务器端执行listen操作,将原先的socket转化为服务端的socket,服务端最后阻塞在accept上等待客户端请求的到来。此时,服务器端已经准备就绪。客户端需要先初始化socket,再执行connect向服务器端的地址和端口发起连接请求,这里的地址和端口必须是客户端预先知晓的。这个过程,就是著名的TCP三次握手。一旦三次握手完成,客户端和服务器端建立连接,就进入了数据传输过程。
套接字地址格式
通用套接字地址格式
/* POSIX.1g 规范规定了地址族为2字节的值. */
typedef unsigned short int sa_family_t;
/* 描述通用套接字地址 */
struct sockaddr{
sa_family_t sa_family; /* 地址族. 16-bit*/
char sa_data[14]; /* 具体的地址值 112-bit */
};
在这个结构体里,第一个字段是地址族,它表示使用什么样的方式对地址进行解释和保存,常用的地址族有三种:
- AF_LOCAL:表示的是本地地址,对应的是Unix套接字,这种情况一般用于本地socket通信,很多情况下也可以写成AF_UNIX、AF_FILE。
- AF_INET:因特网使用的IPv4地址。
- AF_INET6:因特网使用的IPv6地址。
其中AF表示Address Family。有时也会看到以PF_表示的宏,比如PF_INET、PF_INET6等,实际上PF_的意思是Protocol Family,也就是协议族的意思。用AF_xxx这样的值来初始化socket地址,用PF_xxx这样的值来初始化socket。这两个值本身就是一一对应的:
/* 各种地址族的宏定义 */
#define AF_UNSPEC PF_UNSPEC
#define AF_LOCAL PF_LOCAL
#define AF_UNIX PF_UNIX
#define AF_FILE PF_FILE
#define AF_INET PF_INET
#define AF_AX25 PF_AX25
#define AF_IPX PF_IPX
#define AF_APPLETALK PF_APPLETALK
#define AF_NETROM PF_NETROM
#define AF_BRIDGE PF_BRIDGE
#define AF_ATMPVC PF_ATMPVC
#define AF_X25 PF_X25
#define AF_INET6 PF_INET6
IPv4套接字地址格式
/* IPV4套接字地址,32bit值. */
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};
/* 描述IPV4的套接字地址格式 */
struct sockaddr_in
{
sa_family_t sin_family; /* 16-bit */
in_port_t sin_port; /* 端口号 16-bit*/
struct in_addr sin_addr; /* Internet address. 32-bit */
/* 这里仅仅用作占位符,不做实际用处 */
unsigned char sin_zero[8];
};
其中sin_family就是AF_INET。sin_port就是端口号,16位,最大端口号是65535。其中还有一些保留的端口号,一般大于5000的就是可以随意使用的:
/* Standard well-known ports. */
enum
{
IPPORT_ECHO = 7, /* Echo service. */
IPPORT_DISCARD = 9, /* Discard transmissions service. */
IPPORT_SYSTAT = 11, /* System status service. */
IPPORT_DAYTIME = 13, /* Time of day service. */
IPPORT_NETSTAT = 15, /* Network status service. */
IPPORT_FTP = 21, /* File Transfer Protocol. */
IPPORT_TELNET = 23, /* Telnet protocol. */
IPPORT_SMTP = 25, /* Simple Mail Transfer Protocol. */
IPPORT_TIMESERVER = 37, /* Timeserver service. */
IPPORT_NAMESERVER = 42, /* Domain Name Service. */
IPPORT_WHOIS = 43, /* Internet Whois service. */
IPPORT_MTP = 57,
IPPORT_TFTP = 69, /* Trivial File Transfer Protocol. */
IPPORT_RJE = 77,
IPPORT_FINGER = 79, /* Finger service. */
IPPORT_TTYLINK = 87,
IPPORT_SUPDUP = 95, /* SUPDUP protocol. */
IPPORT_EXECSERVER = 512, /* execd service. */
IPPORT_LOGINSERVER = 513, /* rlogind service. */
IPPORT_CMDSERVER = 514,
IPPORT_EFSSERVER = 520,
/* UDP ports. */
IPPORT_BIFFUDP = 512,
IPPORT_WHOSERVER = 513,
IPPORT_ROUTESERVER = 520,
/* Ports less than this value are reserved for privileged processes. */
IPPORT_RESERVED = 1024,
/* Ports greater this value are reserved for (non-privileged) servers. */
IPPORT_USERRESERVED = 5000
IPv6套接字地址格式
struct sockaddr_in6
{
sa_family_t sin6_family; /* 16-bit AF_INET6 */
in_port_t sin6_port; /* 传输端口号 # 16-bit */
uint32_t sin6_flowinfo; /* IPv6流控信息 32-bit*/
struct in6_addr sin6_addr; /* IPv6地址128-bit */
uint32_t sin6_scope_id; /* IPv6域ID 32-bit */
};
其中流控信息和域ID,这两个字段暂时未使用。
本地套接字地址格式
struct sockaddr_un {
unsigned short sun_family; /* 固定为 AF_LOCAL */
char sun_path[108]; /* 路径名 */
};
服务端创建连接的过程
创建套接字
int socket(int domain, int type, int protocol)
其中,domain就是套接字类型,比如PF_INET、PF_INET6以及PF_LOCAL等。
type可用的值是:
- SOCK_STREAM:表示字节流,对应TCP。
- SOCK_DGRAM:表示数据报,对应UDP。
- SOCK_RAW:表示原始套接字。
参数protocol原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成。protocol目前一般写成0即可。
返回值就是创建的套接字。
套接字绑定
创建出来的套接字如果需要被别人使用,就需要调用bind函数把套接字和套接字地址绑定。
int bind(int fd, sockaddr * addr, socklen_t len)
其中,fd就是创建的套接字,第二参数是通常地址格式sockaddr * addr。这里需要注意,虽然接收的是通用地址格式,实际上传入的参数可能是IPv4、IPv6或者本地套接字,bind函数会根据len字段来判断传的参数addr该怎么解析,len表示传入的地址长度。其实bind函数就是下面这种形式,因为套接字设计出来时,还没有void *,所以调用时需要转化为通用套接字。
int bind(int fd, void * addr, socklen_t len)
// 使用时
struct sockaddr_in name;
name.sin_family = AF_INET; // IPV4
name.sin_port = htons (port); // 指定端口
name.sin_addr.s_addr = htonl (INADDR_ANY); // 通配地址,表示来自哪的请求都可以,如果IPv6,那么通配地址就是 IN6ADDR_ANY
int bind (sock, (struct sockaddr *) &name, sizeof (name)
监听连接
初始化创建的套接字,可以认为是一个"主动"套接字,其目的是之后主动发起请求(通过调用connect函数)。通过listen函数,可以将原来的"主动"套接字转换为"被动"套接字。
int listen (int socketfd, int backlog)
其中,socketfd就是套接字,backlog在Linux中表示已完成(ESTABLISHED)且未accept的队列大小,这个参数的大小决定了可以接收的并发数目。这个参数越大,并发数目理论上也会越大。但是参数过大也会占用过多的系统资源。
接受连接
当客户端的连接请求到达时,服务器端应答成功,连接建立,这个时候操作系统内核需要把这个事件通知到应用程序,并让应用程序感知到这个连接。
int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)
其中,listensockfd是经过bind,listen一系列操作之后的套接字,这有三个返回值,前两个是通过指针参数传递的,cliadd是通过指针方式获取的客户端的地址,addrlen告诉我们地址的大小,还有一个返回值就是函数的返回值,这是一个全新的套接字,这个套接字是已经与客户端建立了连接,后面可以直接使用该套接字与客户端进行通信。这样来看,每接受一个连接,就会产生一个新的套接字。
客户端创建连接过程
创建套接字
int socket(int domain, int type, int protocol)
参数同服务端一样。
请求连接
客户端和服务器端的连接建立,是通过connect函数完成的。
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)
函数的第一个参数sockfd是套接字,第二个、第三个参数servaddr和addrlen分别代表指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的IP地址和端口号。客户在调用函数connect前不必非得调用bind函数,因为如果需要的话,内核会确定源IP地址,并按照一定的算法选择一个临时端口作为源端口。
如果是TCP套接字,那么调用connect函数将激发TCP的三次握手过程,而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况:
- 三次握手无法建立,客户端发出的SYN包没有任何响应,于是返回TIMEOUT错误。这种情况比较常见的原因是对应的服务端IP写错。
- 客户端收到了RST(复位)回答,这时候客户端会立即返回CONNECTION REFUSED错误。这种情况比较常见于客户端发送连接请求时的请求端口写错,因为RST是TCP在发生错误时发送的一种TCP分节。产生RST的三个条件是:
- 目的地为某端口的SYN到达,然而该端口上没有正在监听的服务器(如前所述);
- TCP想取消一个已有连接;
- TCP接收到一个根本不存在的连接上的分节。
- 客户发出的SYN包在网络上引起了"destination unreachable",即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。
发送数据
size_t write (int socketfd, const void *buffer, size_t size)
size_t send (int socketfd, const void *buffer, size_t size, int flags)
size_t sendmsg(int sockfd, const struct msghdr *msg, int flags)
- write函数:常见的文件写函数,如果把socketfd换成文件描述符,就是普通的文件写入。
- send函数:可以带外数据,所谓带外数据,是一种基于TCP协议的紧急数据,用于客户端-服务器在特定场景下的紧急处理。
- sendmsg函数:可以指定多重缓冲区传输数据,以结构体msghdr的方式发送数据。
套接字与普通文件描述符区别:
- 对普通文件描述符而言,一个文件描述符代表打开了一个文件句柄,通过调用write函数,内核会不断地往文件系统中写入字节流。写入的字节流的大小通常与输入的参数size是相同的,否则表示出错。
- 对于套接字而言,它代表了一个双向连接,调用write函数写入的字节有可能比请求的数量少。
发送缓冲区
当TCP三次握手成功,TCP连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如发送缓冲区。
发送缓冲区的大小可以通过套接字选项来改变,当应用程序调用write函数时,实际上就是把数据从应用程序中拷贝到内核发送缓冲区中,并不一定是把数据通过套接字发送出去。发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。
这样就会产生两种情况:
- 操作系统内核缓冲区足够大,可以容纳这份数据,那么write函数会退出,返回写入的字节数就是应用程序的数据大小。
- 操作系统内核缓冲区不够大,或者是缓冲区还有数据未发送完,剩余的发送缓存区不够大,这种情况应用程序就会被堵塞,也就是在堵塞在调用write函数上面。大部分操作系统都是一直等到应用程序把数据完全写到内核缓冲区中,再从系统调用中返回。
读取数据
套接字描述本身和本地文件描述符并无区别,在UNIX的世界里万物都是文件,这就意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。这些函数包括read和write交换数据的函数。
size_t read (int socketfd, void *buffer, size_t size)
read函数要求操作系统内核从套接字描述字socketfd最多读取多少个字节(size),并将结果存储到buffer中。返回值表示实际读取的字节数目,也有一些特殊情况,如果返回值为0,表示EOF(end-of-file),这在网络中表示对端发送了FIN包,要处理断连的情况;如果返回值为-1,表示出错。当然,如果是非阻塞I/O,情况会略有不同。