引言:
前面的两篇文章:《网络编程之基础知识详解》、《网络编程之协议及协议格式详解》,主要介绍了一些关于网络编程的概念,接下来将要进入实操。所以本文主要内容是介绍网络编程的 API —— Socket API:总结介绍 TCP 协议的相关函数接口,方便后续查阅,减少记忆负担;以及利用这些函数实现一些简单的示例。
Socket 编程
Socket 基础概念
TCP/IP 四层网络模型的应用层编程接口称为 Socket API。Socket( 套接字)本身有“插座”的意思,它是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。 一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议根进行交互的接口。
socket 可以看成是两个网络应用程序进行通信时,各自通信连接中的端点,这是一个逻辑上的概念。它是网络环境中进程间通信的 API,也是可以被命名和寻址的通信端点,使用中的每一个套接字都有其类型和一个与之相连进程。通信时其中一个网络应用程序将要传输的一段信息写入它所在主机的 socket 中,该 socket 通过与网络接口卡(NIC)相连的传输介质将这段信息送到另外一台主机的 socket 中,使对方能够接收到这段信息。所以socket 是由 IP 地址和端口结合的,提供向应用层进程传送数据包的机制。
在 Linux 环境下,Socket 用于表示进程间网络通信的特殊文件类型,本质为内核借助缓冲区形成的伪文件。既然是文件,那么我们可以使用文件描述符引用套接字。与管道类似,Linux系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。
在 TCP/IP 协议中,“IP 地址 + TCP(或 UDP)+ 端口号”可以唯一标识网络通讯中的一个进程,“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个 socket 来标识,这两个 socket 组成的 socket pair 就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。套接字通信原理如下图所示:
如上图所示**在网络通信中,套接字一定是成对出现的。**一端的发送缓冲区对应对端的接收缓冲区(缓冲区概念在下文将详细阐述)。
【注意】socket是一套通信的接口,Linux 和 Windows 都有,但是有一些细微的差别,本文主要以 Linux 为主。
Socket 预备知识
套接字类型
根据数据的传输方式,可以将 Internet 套接字分成两种类型:面向连接套接字和无连接套接字,详细看下文。
面向连接套接字
流格式套接字(Stream Sockets)也叫“面向连接的套接字”,在代码中使用 SOCK_STREAM
表示,流格式套接字 使用 TCP 协议通讯(想详细了解 TCP 协议请参看 《网络编程之协议及协议格式详解》中“ TCP 协议”部分)。
SOCK_STREAM
是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。 SOCK_STREAM
有以下几个特征:
-
数据在传输过程中不会消失;
-
数据是按照顺序传输的;
-
数据的发送和接收不是同步的(有的教程也称“不存在数据边界”):
套接字的内部有一个缓冲区(也就是字符数组),通过 socket 传输的数据将保存到这个缓冲区。接收端在收到数据后并不一定立即读取,只要数据不超过缓冲区的容量,接收端有可能在缓冲区被填满以后一次性地读取,也可能分成好几次读取。
也就是说,不管数据分几次传送过来,接收端只需要根据自己的要求读取,不用非得在数据到达时立即读取。所以传送端有自己的节奏,接收端也有自己的节奏,它们是不一致的。
【补充】套接字缓冲区概念的详细内容将在下文阐述。
可以将 SOCK_STREAM
比喻成一条传送带,只要传送带本身没有问题(不会断网),就能保证数据不丢失;同时,较晚传送的数据不会先到达,较早传送的数据不会晚到达,这就保证了数据是按照顺序传递的。
为什么流格式套接字可以达到高质量的数据传输呢?因为它使用了 TCP 协议(The Transmission Control Protocol,传输控制协议),TCP 协议会控制你的数据按照顺序到达并且没有错误。
在 “TCP/IP
”中:TCP
用来确保数据的正确性,IP
(Internet Protocol,网络协议)用来控制数据如何从源头到达目的地,也就是常说的“路由”。
应用场景: 浏览器所使用的 http
协议就基于面向连接的套接字,因为必须要确保数据准确无误,否则加载的 HTML 将无法解析。
无连接套接字
数据报格式套接字(Datagram Sockets)也叫“无连接的套接字”,在代码中使用 SOCK_DGRAM
表示,数据报格式套接字使用 UDP 协议通讯(想详细了解 UDP 协议请参看 《网络编程之协议及协议格式详解》中“ UDP 协议”部分)。
计算机只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。因为数据报套接字所做的校验工作少,所以在传输效率方面比流格式套接字要高。它有以下特征:
- 强调快速传输而非传输顺序;
- 传输的数据可能丢失也可能损毁;
- 限制每次传输的数据大小;
- 数据的发送和接收是同步的(有的也称“存在数据边界”)。
可以将 SOCK_DGRAM
比喻成高速移动的摩托车快递,速度是快递行业的生命。用摩托车发往同一地点的两件包裹无需保证顺序,只要以最快的速度交给客户就行。这种方式存在损坏或丢失的风险,而且包裹大小有一定限制。因此,想要传递大量包裹,就得分配发送。
另外,用两辆摩托车分别发送两件包裹,那么接收者也需要分两次接收,所以“数据的发送和接收是同步的”;换句话说,接收次数应该和发送次数相同。
总之,数据报套接字是一种不可靠的、不按顺序传递的、以追求速度为目的的套接字。
数据报套接字也使用IP
协议作路由,但是它不使用 TCP
协议,而是使用 UDP
协议(User Datagram Protocol,用户数据报协议)。
QQ
视频聊天和语音聊天就使用 SOCK_DGRAM
来传输数据,因为首先要保证通信的效率,尽量减小延迟,而数据的正确性是次要的,即使丢失很小的一部分数据,视频和音频也可以正常解析,最多出现噪点或杂音,不会对通信质量有实质的影响。
两种套接字总结
两种套接字的特点决定了它们的应用场景,有些服务对可靠性要求比较高,必须数据包能够完整无误地送达,那就得选择有连接的套接字(TCP 服务),比如 HTTP、FTP 等;而另一些服务,并不需要那么高的可靠性,效率和实时才是它们所关心的,那就可以选择无连接的套接字(UDP 服务),比如 DNS、即时聊天工具等。
字节序
字节序概念
现代 CPU 的累加器一次都能装载(至少)4 字节(这里考虑 32 位机),即一个整数。那么这 4 字节在内存中排列的顺序将影响它被累加器装载成的整数的值,这就是字节序问题。在各种计算机体系结构中,对于字节、字等的存储机制有所不同,因而引发了计算机通信领域中一个很重要的问题,即通信双方交流的信息单元(比特、字节、字、双字等等)应该以什么样的顺序进行传送。如果不达成一致的规则,通信双方将无法进行正确的编码/译码从而导致通信失败。因此引出字节序的概念,字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序(一个字节的数据当然就无需谈顺序的问题了)。 【注意】字节序是字节的顺序,并不是 bit 位的顺序,bit 位的顺序都是相同的。
众所周知,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,同样的,网络数据流同样有大端小端之分,字节序分为大端字节序(Big-Endian) 和小端字节序(Little-Endian):大端字节序是指一个整数的最高位字节(23 ~ 31 bit)存储在内存的低地址处,低位字节(0 ~ 7 bit)存储在内存的高地址处;小端字节序则是指整数的高位字节存储在内存的高地址处,而低位字节则存储在内存的低地址处。
那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址这样规定:先发出的数据是低地址,后发出的数据是高地址。
网络字节序:TCP/IP 协议规定,网络数据流应采用大端字节序(低地址高字节),所以网络字节序是 TCP/IP 中规定好的一种数据表示格式,它与具体的 CPU 类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释,网络字节顺序采用大端排序方式。例如 UDP 段格式,地址 0-1 是 16 位的源端口号,如果这个端口号是 1000(0x3e8),则地址 0 是 0x03,地址 1 是 0xe8,也就是先发 0x03,再发 0xe8,这 16 位在发送主机的缓冲区中也应该是低地址存 0x03,高地址存 0xe8。但是,如果发送主机是小端字节序的,这 16 位被解释成0xe803,而不是 1000。因此,发送主机把 1000 填到发送缓冲区之前需要做字节序的转换。同样地,接收主机如果是小端字节序的,接收 16 位的源端口号也要做字节序的转换。如果主机是大端字节序的,发送和接收都不需要做转换。同理,32 位的 IP 地址也要考虑网络字节序和主机字节序的问题(IP 地址和端口号都需要做字节序的转换)。
// 判断是大端字节序还是小端字节序
#include <stdio.h>
int main()
{
union
{
short value; // 2字节
char bytes[sizeof(short)]; // char[2]
} test;
test.value = 0x0102;
if((test.bytes[0] == 1) && (test.bytes[1] == 2))
{
printf("大端字节序\n");
} else if((test.bytes[0] == 2) && (test.bytes[1] == 1))
{
printf("小端字节序\n");
} else {
printf("未知\n");
}
return 0;
}
字节序转换函数
为使网络程序具有可移植性,使同样的 C/C++ 代码在大端和小端计算机上编译后都能正常运行,BSD Socket 提供了封装好的转换接口,方便程序员使用:包括从主机字节序到网络字节序的转换函数: htons、htonl;从网络字节序到主机字节序的转换函数:ntohs、ntohl。
#include <arpa/inet.h>
// 转换端口
uint16_t htons(uint16_t hostshort); // 主机字节序 - 网络字节序
uint16_t ntohs(uint16_t netshort); // 主机字节序 - 网络字节序
// 转IP
uint32_t ntohl(uint32_t netlong); // 主机字节序 - 网络字节序
uint32_t htonl(uint32_t hostlong); // 主机字节序 - 网络字节序
- h 表示 host,主机,主机字节序;
- n 表示 network 网络字节序;
- l 表示 32 位长整数:long unsigned int;
- s 表示 16 位短整数:short unsigned short ;
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
- 端口一般是 16 位,两个字节;IP 一般 32 位,4 个字节。
【注意】
-
网络通讯中一般使用 unsigned,这样显得比较规范。
-
只有一个数据结构大于等于两个字节,才有转换字节序的需求,一个字节不需要进行字节序转换;
-
一般我们只需要转换应用层数据的字节序,其他网络模型层的会由 Linux 内核转换;
-
只有字节流可以进行字节序转换,文字流不能进行字节序转换。关于字节流与字符流详细可见:socket通信字节流or字符流
#include <stdio.h>
#include <arpa/inet.h>
int main()
{
// htons 转换端口
unsigned short a = 0x0102;
printf("a : %x\n", a);
unsigned short b = htons(a);
printf("b : %x\n", b);
printf("=======================\n");
// htonl 转换IP
char buf[4] = {192, 168, 1, 100};
int num = *(int *)buf;
int sum = htonl(num);
unsigned char *p = (char *)∑
printf("%d %d %d %d\n", *p, *(p+1), *(p+2), *(p+3));
printf("=======================\n");
// ntohl
unsigned char buf1[4] = {1, 1, 168, 192};
int num1 = *(int *)buf1;
int sum1 = ntohl(num1);
unsigned char *p1 = (unsigned char *)&sum1;
printf("%d %d %d %d\n", *p1, *(p1+1), *(p1+2), *(p1+3));
// ntohs
......
return 0;
}
Socket 结构体
网络通讯中需要明确通讯双方的协议类型、IP 地址、端口,通常将这三个数据放在一个套接字结构体中,更加简洁直观。有以下几种结构体:
通用 socket 结构体
sockaddr
是一种通用的结构体,可以用来保存多种类型的 IP 地址和端口号 。
struct sockaddr
{
sa_family_t sin_family; //地址族(Address Family),也就是地址类型
char sa_data[14]; //IP地址和端口号
};
typedef unsigned short int sa_family_t;
-
sa_family 成员是地址族类型(sa_family_t)的变量。地址族类型通常与协议族类型对应。常见的协议族(protocol family,也称 domain)和对应的地址族入下所示:
宏 PF_* 和 AF_* 都定义在 bits/socket.h 头文件中,且后者与前者有完全相同的值,所以二者通常混用。
-
sa_data 成员用于存放 socket 地址值。但是,不同的协议族的地址值具有不同的含义和长度,如下所示:
由上表可知,14 字节的 sa_data 根本无法容纳多数协议族的地址值。因此,Linux 定义了下面这个新的通用的 socket 地址结构体,这个结构体不仅提供了足够大的空间用于存放地址值,而且是内存对齐的。
#include <bits/socket.h> struct sockaddr_storage { sa_family_t sa_family; unsigned long int __ss_align; char __ss_padding[ 128 - sizeof(__ss_align) ]; }; typedef unsigned short int sa_family_t;
专用结构体
实际编程中我们一般使用 sockaddr_in 等专用结构体而不使用 sockaddr 这种通用结构体,这是为什么呢?我们先来看看这些专用结构体的构成,再来说明原因。
-
UNIX 本地域协议族使用如下专用的 socket 结构体:
#include <sys/un.h> struct sockaddr_un { sa_family_t sin_family; char sun_path[108]; };
-
TCP/IP 协议族有 sockaddr_in 和 sockaddr_in6 两个专用的 socket 地址结构体,它们分别用于 IPv4 和 IPv6:
#include <netinet/in.h> struct sockaddr_in { sa_family_t sin_family; // 地址族(Address Family),也就是地址类型 in_port_t sin_port; // 16 位的端口号 struct in_addr sin_addr; // 32 位 IP 地址 //不使用,一般用0填充 unsigned char sin_zero[sizeof (struct sockaddr) - __SOCKADDR_COMMON_SIZE - sizeof (in_port_t) - sizeof (struct in_addr)]; }; struct in_addr { in_addr_t s_addr; //32位的IP地址 }; struct sockaddr_in6 { sa_family_t sin6_family; in_port_t sin6_port; /* Transport layer port # */ uint32_t sin6_flowinfo; /* IPv6 flow information */ struct in6_addr sin6_addr; /* IPv6 address */ uint32_t sin6_scope_id; /* IPv6 scope-id */ }; typedef unsigned short uint16_t; typedef unsigned int uint32_t; typedef uint16_t in_port_t; typedef uint32_t in_addr_t; #define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))
sockaddr 和 sockaddr_in 的长度相同,都是16字节,只是 sockaddr 将 IP 地址和端口号合并到一起,用一个成员 sa_data 表示。要想给 sa_data 赋值,必须同时指明 IP 地址和端口号,例如”127.0.0.1:80“,遗憾的是,没有相关函数将这个字符串转换成需要的形式,也就很难给 sockaddr 类型的变量赋值,所以使用 sockaddr_in 等专用 socket 结构体来代替。这两个结构体的长度相同,强制转换类型时不会丢失字节,也没有多余的字节。
所有专用 socket 地址(以及 sockaddr_storage)类型的变量在实际使用时都需要转化为通用 socket 地址类型 sockaddr(强制转化即可),因为所有 socket 编程接口使用的地址参数类型都是 sockaddr(很多网络编程函数诞生早于 IPv4 协议,那时候都使用的是 struct sockaddr 结构体,为了向前兼容)。所以*现在sockaddr 退化成了(void )的作用,传递一个地址给函数,至于这个函数是 sockaddr_in 还是 sockaddr_in6,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。
IP 地址转换函数
Socket 网络编程中,IP 地址的表达格式通常是 ASCII 字符串 (也叫点分十进制字符串,例如“192.169.1.2”),而实际上 IP 地址是一个4字节的 unsigned long 数值(该数值格式是存放到套接字地址结构的二进制值) , 所以需要进行转换。下面 3 个函数可用于用点分十进制字符串表示的 IPv4 地址和用网络字节序整数表示的 IPv4 地址之间的转换::
-
早期的转换函数
#include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> int inet_aton(const char *cp, struct in_addr *inp); in_addr_t inet_addr(const char *cp); char *inet_ntoa(struct in_addr in);
这些早期的转换函数只能处理 IPv4 的 IP 地址,而且是不可重入函数(详细浅谈可重入函数与不可重入函数),注意其参数是
struct in_addr
。 -
现在的转换函数
#include <arpa/inet.h> // 将点分十进制的 ip 地址字符串转化为用于网络传输的数值格式,即网络字节序的整数 int inet_pton(int af, const char *src, void *dst); af : AF_INET 表示 IPV4 AF_INET6 表示 IPV6 src: 点分十进制串的首地址 dst : 32位网络数据的地址 返回值:成功返回 1 // 将数值格式(即网络字节序的整数)转化为点分十进制的 ip 地址字符串格式 const char *inet_ntop(int af, const void *src, char *dst, socklen_t size); af : AF_INET IPV4 AF_INET6 IPV6 src: 32位网络数据的地址 dst : 点分十进制串的首地址 返回值:存储点分制串数组首地址
-
p 表示点分十进制的 IP 地址字符串;
-
n 表示 network,网络字节序的整数
-
现在的转换函数支持 IPv4 和 IPv6 的 IP 地址,而且是可重入函数(详细浅谈可重入函数与不可重入函数),注意其函数参数是
void *addrptr
。 -
IP 地址的点分十进制字符串最大长度为16(例如“255.255.255.255”),可以使用
INET_ADDRSTRLEN
宏代替,其(表示 IP 的字符串数组)值为 16。
-
#include <stdio.h>
#include <arpa/inet.h>
int main()
{
// 创建一个ip字符串,点分十进制的IP地址字符串
char buf[] = "192.168.1.4";
unsigned int num = 0;
// 将点分十进制的IP字符串转换成网络字节序的整数
inet_pton(AF_INET, buf, &num);
unsigned char * p = (unsigned char *)#
printf("%d %d %d %d\n", *p, *(p+1), *(p+2), *(p+3));
// 将网络字节序的IP整数转换成点分十进制的IP字符串
char ip[16] = "";
const char * str = inet_ntop(AF_INET, &num, ip, 16);
printf("str : %s\n", str);
printf("ip : %s\n", str);
printf("%d\n", ip == str);
return 0;
}
Socket C/S 模型通讯流程
下图是基于 TCP 协议的客户端/服务器程序通讯的一般流程(图中的 TCP 三次握手会在本系列的下一篇文章中详细讲解):
-
服务器端 (被动接受连接的角色)
- 创建一个用于监听的套接字,得到文件描述符
- 监听:此处的套接字会监听有客户端的连接
- 套接字:这个套接字其实就是一个文件描述符
- 将这个监听文件描述符和本地的 IP 和端口绑定(IP 和端口就是服务器的地址信息)
- 客户端连接服务器的时候使用的就是这个 IP 和端口
- 设置监听,前面创建的套接字开始工作
- 阻塞等待,当有客户端发起连接,解除阻塞,接受客户端的连接,会得到一个和客户端通信的套接字
- 通信
- 接收数据
- 发送数据
- 通信结束,断开连接:服务端接收数据的时候,如果接收到 EOF,待处理完数据后,服务端才会关闭连接。
- 创建一个用于监听的套接字,得到文件描述符
-
客户端(主动发起连接的角色)
-
创建一个用于通信的套接字
-
连接服务器:向服务器端的地址和端口发起连接请求,需要指定连接的服务器的 IP 和 端口
(注意:客户端不用绑定本主机的端口,因为客户端不用服务别人,套接字会自动随机分配一个端口)
-
连接成功了,客户端可以直接和服务器通信
- 接收数据
- 发送数据
-
通信结束,断开连接
-
Socket 基础函数
socket 函数
socke()
函数用来创建套接字,确定套接字的各种属性。
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int socket(int domain, int type, int protocol);
domain: 表示 IP 地址类型
AF_INET 用来产生 socket 的协议,使用 TCP 或 UDP 来传输,用IPv4的地址
AF_INET6 与上面类似,不过是来用 IPv6 的地址
AF_UNIX 本地协议,使用在 Unix 和 Linux 系统上,一般是当客户端和服务器在同一台及其上的时候使用
type: 表示数据传输方式/套接字类型
SOCK_STREAM 流式协议: 这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的 socket 类型,这个 socket 是使用 TCP 来进行传输。
SOCK_DGRAM 报式协议:这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,使用 UDP 来进行它的连接。
SOCK_SEQPACKET 该协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。
SOCK_RAW socket 类型提供单一的网络访问,这个 socket 类型使用 ICMP 公共协议。(ping、traceroute使用该协议)
SOCK_RDM 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序
protocol: 表示传输协议
常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议
一般传 0 即可,系统会自动推演出应该使用什么协议,除非遇到这样的情况:有两种不同的协议支持同一种地址类型和数据传输类型。如果我们不指明使用哪种协议,操作系统是没办法自动推演的。
返回值:
成功:返回指向新创建的 socket 的文件描述符,操作的是内核缓冲区,失败:返回-1,设置errno
-
socket()
函数打开一个网络通讯端口,如果成功的话,就像open()
一样返回一个文件描述符,应用程序可以像读写文件一样用 read/write 在网络上收发数据,如果socket()
调用出错则返回 -1。 -
对于 IPv4,domain 参数指定为 AF_INET。对于 TCP 协议,type参数指定为 SOCK_STREAM,表示面向流的传输协议。如果是 UDP 协议,则 type 参数指定为 SOCK_DGRAM,表示面向数据报的传输协议。
bind 函数
服务器端要用 bind()
函数将套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:socket文件描述符
addr:需要绑定的socket地址,这个地址封装了ip和端口号的信息
addrlen:sizeof(addr)长度
返回值:成功返回0,失败返回-1, 设置errno
服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用bind绑定一个固定的网络地址和端口号。
bind()
的作用是将参数 sockfd 和 addr 绑定在一起,使 sockfd 这个用于网络通讯的文件描述符监听 addr 所描述的地址和端口号。前面讲过,struct sockaddr *
是一个通用指针类型,addr 参数实际上可以接受多种协议的 sockaddr 结构体,而它们的长度各不相同,所以需要第三个参数 addrlen 指定结构体的长度。如:
struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(6666);
首先将整个结构体清零,然后设置地址类型为 AF_INET,网络地址为 INADDR_ANY(通配地址)这个宏,表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个 IP 地址,端口号为 6666。
listen 函数
对于服务端,使用 bind()
绑定套接字后,还需要使用 listen()
函数让套接字进入被动监听状态。
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int listen(int sockfd, int backlog);
sockfd:socket文件描述符
backlog:排队建立3次握手队列(未连接)和刚刚建立3次握手队列的连接数和
返回值:成功返回0,失败返回-1
查看系统默认的 backlog 最大值
cat /proc/sys/net/ipv4/tcp_max_syn_backlog
listen()
函数作用:
-
将套接字有主动变为被动监听:所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。
-
调用
listen()
函数会自动创建一个请求队列:当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区就称为请求队列。缓冲区的长度(能存放多少个客户端请求)可以通过
listen()
函数的 backlog 参数指定,但究竟为多少并没有什么标准,可以根据你的需求来定,并发量小的话可以是10或者20。如果将 backlog 的值设置为 SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。当请求队列满时,就不再接收新的请求,对于 Linux,客户端会收到 ECONNREFUSED 错误,对于 Windows,客户端会收到 WSAECONNREFUSED 错误。
-
请求队列可以进一步分成两个部分:未完成连接队列,已完成连接队列。 服务端监听时收到一个客户端请求之后,是先放到未完成连接队列,完成三次握手之后,放到已完成连接队列。(注意:三次握手由传输层协议栈本身完成,不由我们应用程序去管理,TCP 三次握手会在本系列的下一篇文章中详细讲解)
accept 函数
对于服务端,使用 listen()
进入被动监听状态之后, 再调用 accept()
函数就可以随时响应客户端的请求了:从已完成连接队列中提取一个连接,提取的连接得到得到一个新的套接字,接下来用这个套接字与客户端进行通讯。
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockdf:socket文件描述符
addr:传出参数,返回链接上来的客户端地址信息,含 IP 地址和端口号
addrlen:传入传出参数(值-结果),传入sizeof(addr)大小,函数返回时返回真正接收到地址结构体的大小
返回值:成功返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,设置errno
accept()
返回一个新的套接字,addr 保存了客户端的 IP 地址和端口号,而 sockdf 则是服务器端的套接字。【注意】后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。listen()
只是让套接字进入监听状态,并没有真正接收客户端请求,listen()
后面的代码会继续执行,直到遇到accept()
接收连接,如果调用accept()
时还没有客户端的连接请求(即已完成连接队列中没有任何新的连接),accept()
会阻塞程序执行,直到有新的请求到来。- aaddrlen 参数是一个传入传出参数,传入的是调用者提供的缓冲区 addr 的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给 addr 参数传NULL,表示不关心客户端的地址。
我们的服务器程序结构是这样的:
while (1) {
cliaddr_len = sizeof(cliaddr);
connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
n = read(connfd, buf, MAXLINE);
......
close(connfd);
}
整个是一个 while 死循环,每次循环处理一个客户端连接。由于 cliaddr_len 是传入传出参数,每次调用accept()
之前应该重新赋初值。accept()
的参数 listenfd 是先前的监听文件描述符,而 accept()
的返回值是另外一个文件描述符 connfd,之后与客户端之间就通过这个 connfd 通讯,最后关闭 connfd 断开连接,而不关闭 listenfd,再次回到循环开头 listenfd 仍然用作 accept 的参数。
connect 函数
客户器端要用 connect()
函数将服务端套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。这样客户端和服务端双方才能建立连接。
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockdf:socket文件描述符
addr:传入参数,指定服务器端地址信息,含IP地址和端口号
addrlen:传入参数,传入sizeof(addr)大小
返回值:成功返回0,失败返回-1,设置errno
客户端需要调用 connect()
连接服务器,connect 和 bind 的参数形式一致,区别在于 bind 的参数是自己的地址,而connect的参数是对方的地址。
write/read 函数
两台计算机之间的通信相当于两个套接字之间的通信 ,Linux 不区分套接字文件和普通文件,使用 write()
可以向套接字中写入数据,使用 read()
可以从套接字中读取数据。
ssize_t write(int fd, const void *buf, size_t nbytes);
fd 为要写入的文件的描述符
buf 为要写入的数据的缓冲区地址
nbytes 为要写入的数据的字节数。
返回值:将缓冲区 buf 中的 nbytes 个字节写入 fd 文件,写入成功则返回写入的字节数,失败则返回 -1。
ssize_t read(int fd, void *buf, size_t nbytes);
fd 为要读取的文件的描述符
buf 为要接收数据的缓冲区地址
nbytes 为要读取的数据的字节数
返回值:从 fd 文件中读取 nbytes 个字节并保存到缓冲区 buf,成功则返回读取到的字节数(但遇到文件结尾则返回 0),失败则返回 -1。
缓冲区概念:每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。
write()
并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由 TCP 协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是 TCP 协议负责的事情。- TCP 协议独立于
write()
函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。 read()
函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取。- 输入输出缓冲区的默认大小一般都是 8K,可以通过
getsockopt()
函数获取:
读写阻塞模式:
- 对于 TCP 套接字,当使用
write()
发送数据时:- 首先会检查缓冲区,如果缓冲区的可用空间长度小于要发送的数据,那么
write()
会被阻塞,直到缓冲区中的数据被发送到目标机器,腾出足够的空间,才唤醒write()
函数继续写入数据。 - 如果 TCP 协议正在向网络发送数据,那么输出缓冲区会被锁定,不允许写入,
write()
也会被阻塞,直到数据发送完毕缓冲区解锁,write()
才会被唤醒。 - 如果要写入的数据大于缓冲区的最大长度,那么将分批写入,直到所有数据被写入缓冲区
write()
才能返回。
- 首先会检查缓冲区,如果缓冲区的可用空间长度小于要发送的数据,那么
- 对于 TCP 套接字,当使用
read ()
发送数据时:-
首先会检查缓冲区,如果缓冲区中有数据,那么就读取,否则函数会被阻塞,直到网络上有数据到来,
read ()
函数才会返回。 -
当 socket 缓冲区中的数据量小于期望读取的数据量时,返回实际读取的字节数。
-
当 socket 的接收缓冲区中的数据大于期望读取的字节数时,读取期望读取的字节数,返回实际读取的长度。 缓冲区中的所有数据不能被一次性读取,剩余数据将不断积压,直到有
read ()
数再次读取。
-
参考文章:《动画图解 socket 缓冲区的那些事儿》、《谈谈socket缓冲区》
TCP 简单通讯实例
下面通过最简单的客户端/服务器程序的实例来学习socket API:
// server :作用是接收客户端发送过来的字符,然后将每个字符转换为大写并回送给客户端。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAXLINE 80
#define SERV_PORT 6666
int main(void)
{
struct sockaddr_in servaddr, cliaddr;
socklen_t cliaddr_len;
int listenfd, connfd;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
int i, n;
// 创建套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); //绑定的是通配地址
// 也可以直接绑定单个 IP,因为一台电脑可能有多快网卡(多个ip地址),所以需要指定 IP 地址。
//inet_pton(AF_INET,"192.168.124.44",&addr.sin_addr.s_addr);
servaddr.sin_port = htons(SERV_PORT);
// 绑定
bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
//监听
listen(listenfd, 20);
printf("Accepting connections ...\n");
while (1) {
cliaddr_len = sizeof(cliaddr);
// 提取
connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
n = read(connfd, buf, MAXLINE);
if(n ==0 ) //如果read返回等于0,代表对方关闭
{
printf("server close\n");
break;
}
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
ntohs(cliaddr.sin_port));
for (i = 0; i < n; i++)
buf[i] = toupper(buf[i]);
write(connfd, buf, n);
close(connfd);
}
close(listenfd);
return 0;
}
【注意】启动服务端后,再使用 ctrl+c
杀死该服务程序,此时如果立马启动新的服务端程序,会报错:无法 bind 绑定 ,这是因为操作系统会过一段时间释放端口(和 2MSL 有关)。因此代码中最好判断是否绑定成功,没有绑定成功就退出并反馈错误,否则程序启动失败都无法察觉。
// client : 作用是从命令行参数中获得一个字符串发给服务器,然后接收服务器返回的字符串并打印
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MAXLINE 80
#define SERV_PORT 6666
int main(int argc, char *argv[])
{
struct sockaddr_in servaddr;
char buf[MAXLINE];
int sockfd, n;
char *str;
if (argc != 2) {
fputs("usage: ./client message\n", stderr);
exit(1);
}
str = argv[1];
//创建套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
servaddr.sin_port = htons(SERV_PORT);
//连接服务器
connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
write(sockfd, str, strlen(str));
n = read(sockfd, buf, MAXLINE);
printf("Response from server:\n");
write(STDOUT_FILENO, buf, n); // STDIN_FILENO 是标准输入的文件描述符
//关闭
close(sockfd);
return 0;
}
【注意】由于客户端不需要固定的端口号,因此不必调用 bind()
,客户端的端口号由内核自动分配。注意,客户端不是不允许调用 bind()
,只是没有必要调用 bind()
固定一个端口号,服务器也不是必须调用 bind()
,但如果服务器不调用 bind()
,内核会自动给服务器分配监听端口,每次启动服务器时端口号都不一样,客户端要连接服务器就会遇到麻烦。