TCPIP网络编程

参考:https://gitee.com/andrewgithub/TCP-IP-NetworkNote#tcpip网络编程学习笔记https://zhuanlan.zhihu.com/p/460399249

一、网络编程与套接字

网络编程就是编写程序使两台连网计算机互相通信。

1.1 概念

套接字就是用于网络通信的操作系统接口。

1.1.1 服务器套接字用到的各函数

#include <sys/socket.h>
int socket(int domain, int type, int protocol);// 返回值:成功时返回文件描述符,失败时返回 -1    
// 功能:创建套接字。
// 参数:domain:采取的协议族,一般为 PF_INET;
// type:数据传输方式,一般为 SOCK_STREAM; 
// protocol:一般设为 0 即可。
        
int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen);// 返回值:成功时返回 0,失败时返回 -1  
// 功能:为套接字分配地址信息。
// 参数:sockfd:要分配地址信息的套接字文件描述符;
// myaddr:存有地址信息的结构体变量指针;
// addrlen:第二个参数的长度。
   
int listen(int sockfd, int backlog); // 返回值:成功时返回 0,失败时返回 -1
// 功能:将套接字转换为可接收连接的状态。
// 参数:sock:希望进入等待连接请求状态的套接字文件描述符;
// backlog:连接请求等待队列的长度,最多使 backlog 个连接请求进入队列。
        
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);// 返回值:成功时返回创建的套接字文件描述符,失败时返回 -1  
// 功能:受理连接请求等待队列中待处理的连接请求。
// 参数:sock:服务器套接字的文件描述符;
// addr:用于保存发起连接请求的客户端地址信息;
// addrlen:第二个参数的长度。
       

接受连接请求的服务器端套接字编程流程:

  1. 调用 socket 函数创建套接字;
  2. 调用 bind 函数为套接字分配 IP 地址与端口号;
  3. 调用 listen 函数将套接字转换为可接收状态;
  4. 调用 accept 函数受理连接请求。accept 会阻塞,直到有连接请求才会返回;
  5. 调用 read/write 函数进行数据交换;
  6. 调用 close 函数断开连接;

1.1.2 客户端用到的各函数

客户端程序只需:1)调用socket函数创建套接字;2)调用connetc函数向服务端发送连接请求。

#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen);// 返回值:成功时返回 0,失败时返回 -1
// 功能:请求连接。
// 参数:sock:客户端套接字的文件描述符;serv_addr:保存目标服务器端地址信息的结构体指针;addrlen:第二个参数的长度(单位是字节)
              

客户端请求连接步骤:

  1. 调用 socket 函数创建套接字;
  2. 调用 connect 函数请求连接;
  3. 调用 read/write 函数进行数据交换;
  4. 调用 close 函数断开连接;

客户端的 IP 地址和端口在调用 connect 函数时自动分配,无需调用 bind 函数。

1.2 服务器端和客户端

服务端受理连接请求,客服端发起连接请求。

下面展示一个简单的服务端,该服务端收到客户端发起的连接请求后向请求者返回“hello world!”。

# hello_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

void error_handling(char *message);

int main(int argc, char *argv[])
{
	int serv_sock;
	int clnt_sock;

	struct sockaddr_in serv_addr;
	struct sockaddr_in clnt_addr;
	socklen_t clnt_addr_size;

	char message[]="Hello World!";
	
	if(argc!=2){
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}
	
	serv_sock=socket(PF_INET, SOCK_STREAM, 0); //调用socket函数创建套接字
	if(serv_sock == -1)
		error_handling("socket() error");
	
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family=AF_INET;
	serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
	serv_addr.sin_port=htons(atoi(argv[1]));
	
	if(bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr))==-1 ) //bind函数分配IP地址和端口号
		error_handling("bind() error"); 
	
	if(listen(serv_sock, 5)==-1) //listen函数将套接字转为可接收请求状态
		error_handling("listen() error");
	
	clnt_addr_size=sizeof(clnt_addr);  
	clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_addr,&clnt_addr_size); //accept函数接受请求
	if(clnt_sock==-1)
		error_handling("accept() error");  
	
	write(clnt_sock, message, sizeof(message)); //传输数据
	close(clnt_sock);	
	close(serv_sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

客户端

//hello_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

void error_handling(char *message);

int main(int argc, char* argv[])
{
	int sock;
	struct sockaddr_in serv_addr;
	char message[30];
	int str_len;
	
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_STREAM, 0); //调用socket函数创建套接字
	if(sock == -1)
		error_handling("socket() error");
	
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family=AF_INET;
	serv_addr.sin_addr.s_addr=inet_addr(argv[1]);
	serv_addr.sin_port=htons(atoi(argv[2]));
		
	if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr))==-1) //调用connetc函数向服务端发送连接请求
		error_handling("connect() error!");
	
	str_len=read(sock, message, sizeof(message)-1);
	if(str_len==-1)
		error_handling("read() error!");
	
	printf("Message from server: %s \n", message);  
	close(sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

1.3 在Linux下运行

linux下C语言编译器-gcc

gcc hello_server.c -o hserver 这句命令编译hello_server.c并生成名字为hserver的可执行文件

-o 为指定可执行文件名的可选参数。


打开一个终端,运行服务端

$ gcc hello_server.c -o hserver
$ ./hserver 9190

此时程序将停留在此状态,等待客户端的请求

打开另外一个终端

$ gcc hello_client.c -o hclient
$ ./hclient 127.0.0.1 9190
Message from server: Hello World!
$

1.4 Linux文件操作

linux下万物皆为文件。socket也被认为是文件,因此可以使用文件I/O的相关函数。通过套接字发送、接收数据就和读写文件一样,通过 read、write 这些函数来接收、发送数据。

1.4.1 文件描述符

文件描述符是操作系统分配给文件或套接字的整数。

0、1、2 分别由系统分配给了标准输入、标准输出和标准错误。

文件和套接字创建时才会被分配文件描述符。它们的文件描述符会从 3 开始按序递增。

Windows 系统中术语**”句柄“**和 Linux 中的文件描述符含义相同。

#include<fcntl.h>       
#include<unistd.h>
// fcntl.h 和 unistd.h 包含的内容有些相似,包括 open 函数等。
//总之使用文件函数时将 fcntl.h 和 unistd.h 都 include 就可以了

int open(const char *path, int flag);// 返回值:成功时返回文件描述符,失败时返回 -1                   
// 功能:按 flag 指定的模式打开文件。
// 参数:path:文件名的地址;
// flag:文件打开的模式。
                        
int close(int fd);// 返回值:成功时返回 0,失败时返回 -1
// 功能:关闭 fd 对应的文件或套接字。当关闭一个套接字时会向对方发送 EOF。
// 参数:fd:文件或套接字的文件描述符。

ssize_t read(int fd, void* buf, size_t nbytes); // 返回值:成功时返回接收的字节数(遇到文件尾则返回 0),失败时返回 -1 
// 功能:从文件 fd 读取数据。read 函数会阻塞,直到读取到数据或 EOF 才返回。
// 参数:fd:文件描述符;buf:保存要接收的数据;nbytes:要接收的最大字节数。

ssize_t write(int fd, const void* buf, size_t nbytes);// 返回值:成功时返回写入的字节数,失败时返回 -1
 // 功能:向文件 fd 输出数据。
// 参数:fd:文件描述符;buf:要传输的数据;nbytes:要传输的字节数。

文件打开模式:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SBBq88z3-1677126817976)(https://note.youdao.com/yws/public/resource/5045280b8b477f8b417fd1af8da77f2b/xmlnote/4BA1FC046E3F4EB9A0F671687B8D7471/61780)]

以_t为后缀的数据类型,这些都是元数据类型,由操作系统定义。

一般在sys/types.h 头文件中由typedef声明定义。

size_t 的类型是 unsigned int,ssize_t 的类型是 signed int。

二、套接字类型与协议设置

协议就是对话中的通信规则。

2.1 套接字协议及其数据传输特性

回顾创建套接字的函数

int socket(int domain, int type, int protocol);//成功时返回文件描述符,失败时返回 -1       
// domain:采取的协议族,一般为 PF_INET;
// type:数据传输方式,一般为 SOCK_STREAM;
// protocol:使用的协议,一般设为 0。             

创建套接字的函数 socket 的三个参数的含义:

  1. domain:使用的协议族。一般只会用到 PF_INET,即 IPv4 协议族。
  2. type:套接字类型,即套接字的数据传输方式。主要是两种:SOCK_STREAM(即 TCP)和 SOCK_DGRAM(即 UDP)。
  3. protocol:选择的协议。一般情况前两个参数确定后,protocol 也就确定了,所以设为 0 即可。

2.1.1 domain参数

domain参数可选的协议族:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0NFvZfkg-1677126817977)(https://note.youdao.com/yws/public/resource/72bd8041ca374a5366fc5e352aa29edb/xmlnote/9E6D40FA0A0E45C3857FD2FEA833583D/62032)]

2.2.2 type参数

同一个协议族可能有多种数据传输方式,因此在指定了 socket 的第一个参数后,还要指定第二个参数 type,即套接字的传输方式。

最主要的两种方式分别是TCP和UDP。

int tcp_socket = socket(PF_INET, SOCK_STREAM, 0);

SOCK_STREAM 代表的是 TCP 协议,会创建面向连接的套接字,有如下特点:

  1. 可靠传输,传输的数据不会消失,即使丢了也会重传。
  2. 按序传输。
  3. 传输的数据没有边界:从面向连接的字节流角度理解。接收方收到数据后放到接收缓存中,用户使用 read 函数像读取字节流一样从中读取数据,因此发送方 write 的次数和接收方 read 的次数可以不一样。
int udp_socket = socket(PF_INET, SOCK_DGRAM, 0);

SOCK_DGRAM 代表的是 UDP 协议,会创建面向消息的套接字,有如下特点:

  1. 快速传输。
  2. 传输的数据可能丢失、损坏。
  3. 传输的数据有数据边界:这意味着接收数据的次数要和传输次数相同,一方调用了多少次 write(send),另一方就应该调用多少次 read(recv)。
  4. 限制每次传输的数据大小。

2.2.3 protocol参数

这个参数代表协议的最终选择。

有这么一种情况:同一协议族中存在多个数据传输方式相同的协议,所以还需要第三个参数 protocol 来指定具体协议。

但是 PF_INET(IPv4 协议族)下的 SOCK_STREAM 传输方式只对应 IPPROTO_TCP 一种协议,SOCK_DGRAM 传输方式也只对应 IPPROTO_UDP 一种协议,所以参数 protocol 只要设为 0 即可。

int tcp_socket = socket(PF_INET, SOCK_STREAM, 0);
int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);  // 和上面效果一样

三、地址族与数据序列

3.1 分配给套接字的IP地址和端口号

IPv4 地址为 4 字节,IPv6 是 16 字节地址族。

端口号 2 字节,范围是 0~65535。其中 0~1023 是熟知端口号

虽然端口号不能重复,TCP 套接字和 UDP 套接字不会共用端口号,所以两者之间允许重复。

3.2 地址信息的表示

回顾绑定地址的函数bind,其中参数涉及了结构体sockaddr

#include <sys/socket.h>
    
int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen);// 返回值:成功时返回 0,失败时返回 -1  
// 功能:为套接字分配地址信息。
// 参数:sockfd:要分配地址信息的套接字文件描述符;
// myaddr:存有地址信息的结构体变量指针;
// addrlen:第二个参数的长度。

3.2.1 sockaddr结构体

sockaddr 结构体定义如下,它是通用的结构体,并非只为 IPv4 设计。

#include <sys/socket.h>
struct sockaddr {
    sa_family_t   sin_family;   // 地址族
    char          sa_data[14];  // 地址信息
}

3.2.2 IPv4地址信息的结构体sockaddr_in

sockaddr_in 是保存 IPv4 地址信息的结构体。

#include <arpa/inet.h>
struct sockaddr_in {
    sa_family_t     sin_family;   // 地址族
    uint16_t        sin_port;     // 16 位端口号
    struct in_addr  sin_addr;     // 表示 32 位 IP 地址的结构体
    char            sin_zero[8];  // 不使用
}

其中用于表示 IP 地址的结构体 in_addr 定义如下:

#include <arpa/inet.h>
struct in_addr {
    in_addr_t      s_addr;        // 32 位 IP 地址,实际位为 uint32_t 类型
}

sockaddr_in结构体成员分析:

  • 成员sin_family

    这个代表地址族,不同协议使用的地址族不同。如 PF_INET(IPv4 协议族) 对应的地址族是 AF_INET( IPv4 地址族)

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lXTDJHtg-1677126817981)(https://note.youdao.com/yws/public/resource/9166a733c4f8314ac2eb242d928b8ee0/xmlnote/9A719FE840064F6C8B3C3A56D6A62551/62906)]

  • 成员sin_port

​ 以网络字节序保存 16 位端口号。后面会讲解网络字节序(大端序)

  • 成员sin_addr

    类型为结构体 in_addr,in_addr 的成员 s_addr 按网络字节序保存 32 位 IP 地址。

  • 成员sin_zero

    无特殊含义。只是为了使结构体 sockaddr_in 的大小与 sockaddr 结构体一致而插入的成员,必须填充为 0。

    memset(&addr, 0, sizeof(addr)); 
    // 结构体变量 addr 的所有成员初始化为 0,主要是为了将 sockaddr_in 的成员 sin_zero 初始化为 0。
    

bind函数中sockaddr_in参数的传递:

bind 的第二个参数期望得到的是 sockaddr 结构体变量的地址值,使用 sockaddr_in 结构体生成的字节流也符合 bind 函数的要求,只需在传递地址时强制类型转换为 sockaddr* 类型即可。

struct sockaddr_in serv_addr;
...
if(bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
    error_handling("bind() error");

3.3 网络字节序与地址转换

3.3.1 字节序与网络字节序

CPU 向内存保存数据的方式有两种:

  1. 大端序:高位字节存放到低位地址。网络字节序为大端序。
  2. 小端序:高位字节存放到高位地址。目前主流的 Intel 系列 CPU 按小端序方式保存数据。

在使用网络发送数据时统一约定要先把数据转化成大端序,接收时也要先转换为主机字节序。

3.3.2 字节序转换

接下来介绍一些帮助转换字节序的函数

#include <arpa/inet.h>
//short 类型,用于端口号的转换
unsigned short htons(unsigned short);
unsigned short ntohs(unsigned short);
//long 类型,用于 IP 地址的转换
unsigned long htonl(unsigned long);
unsigned long ntohl(unsigned long);

htons 中的 h 代表主机字节序,n 代表网络字节序。(主机字节序向网络字节序转换)

s 代表 short 类型,处理 2 字节数据,用于端口号转换;l 代表 long 类型(Linux 中 long 占用 4 字节),处理 4 字节数据,用于 IP 地址转换。

除了向 sockaddr_in 结构体变量填充数据时需要进行字节序转换外,其他情况无需考虑字节序问题,会自动转换。

3.4 网络地址的初始化与分配

一般我们描述 IP 地址时用的是字符串格式的点分十进制表示法,而sockaddr_in 中保存地址信息的成员是 32 位整型,因此需要将字符串形式的点分十进制 IP 地址转换为 32 位整型数据

有两个函数可以完成以上功能:inet_addr 函数inet_aton 函数

3.4.1 inet_addr函数

inet_addr 函数在转换类型的同时也会完成网络字节序的转换,它还可以检测无效的 IP 地址。

#include <arpa/inet.h>
in_addr_t inet_addr(const char* string);  
// 功能:将字符串形式的 IP 地址转换为 32 位整型数据并返回。
// 返回值:成功时返回 32 位大端序整型值,失败时返回 INADDR_NONE。

3.4.2 inet_aton函数

net_aton 函数和 inet_addr 函数的功能相同,也是将字符串形式的 IP 地址转换为 32 位网络字节序整数,但是它利用了 in_addr 结构体,使用频率更高。

inet_aton 需要传递一个 in_addr 类型结构体的指针,它会将转换结果直接放入该指针所指的 in_addr 结构体中。

#include <arpa/inet.h>
int inet_aton(const char* string, struct in_addr* addr);  
// 功能:将字符串形式的 IP 地址转换为 32 位网络字节序整数并存储到 addr 中。
// 返回值:成功时返回 1,失败时返回 0

3.4.3 整数型IP地址转为字符串形式

inet_ntoa 函数与 inet_aton 函数相反,它将网络字节序的整数型 IP 地址转换为字符串形式。

#include <arpa/inet.h>
char* inet_ntoa(struct in_addr adr);  
// 功能:将网络字节序的整数型 IP 地址转换为字符串形式
// 返回值:成功时返回转换的字符串地址值,失败时返回 -1

该函数使用时要小心:返回值类型为 char 指针,返回字符串地址意味着字符串已保存到内存空,但该函数是在内部申请了内存并保存了字符串,因此如果再次调用 inet_ntoa 函数,也有可能覆盖之前保存的字符串信息。

因此要将返回的字符串信息复制到其他内存空间。

3.4.4 网络地址初始化操作

结合前面所述内容,下面是套接字创建过程中常见的网络地址信息初始化方法:

struct sockaddr_in addr;
char *serv_ip = "211.217.168.13";          // 声明 IP 地址字符串
char *serv_port = "9190";                  // 声明端口号字符串
memset(&addr, 0, sizeof(addr));            // 结构体变量 addr 的所有成员初始化为 0,
addr.sin_family = AF_INET;                 // 指定地址族
addr.sin_addr.s_addr = inet_addr(serv_ip); // 基于字符串的 IP 地址初始化
addr.sin_port = htons(atoi(serv_port));    // 基于字符串的端口号初始化
//atoih函数将字符串数字转为数字,需要#include <stdlib.h>

服务器端和客户端都要进行网络地址信息的初始化,但目的不同:

  1. 服务器端要将声明的 sockaddr_in 结构体变量初始化为自己的 IP 地址和端口号,用于在 bind 函数中与自己的套接字相绑定。
  2. 客户端也要将声明的 sockaddr_in 结构体变量初始化为服务器端的 IP 地址和端口号,用于在 connect 函数中向服务器发起连接请求。

每次创建服务器端套接字都要输入IP地址会很麻烦,可以用常数 INADDR_ANY 自动获取服务器端的 IP 地址。

addr.sin_addr.s_addr = htonl(INADDR_ANY);  
// INADDR_ANY 相当于主机字节序的 32 位整型 IP 地址

使用 INADDRY_ANY,如果同一个计算机具有多个 IP 地址,那么可以从不同 IP 地址(的同一端口号)接收数据,因此服务器端中优先使用 INADDR_ANY,而客户端不应该采用。

再次回顾 [1.3](## 1.3 在Linux下运行) 中运行服务端并未输入IP地址,其中就用到了 INADDRY_ANY

四、基于TCP的客户端与服务端

4.1 理解TCP和UDP

自行阅读计算机网络相关知识。

4.2 实现基于TCP的服务器端/客户端

基于TCP的服务端/客户端函数调用方式:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BVE6qjt2-1677126817983)(https://note.youdao.com/yws/public/resource/3812118367af6a02a2ff2e89fc824283/xmlnote/245352958B0A472FA87D717EB23D5D3E/63297)]

前面已经介绍过socket函数和bind函数,下面介绍后面几个过程。

4.2.1 listen函数等待连接请求

假设已经调用bind函数为套接字分配地址,接下来就要调用listen函数等待连接请求。只有服务端调用了listen函数后,客户端才能待用connnet函数发起连接请求。

#include <sys/socket.h>
int listen(int sockfd, int backlog);  
// 功能:将套接字转换为可接收连接的状态。
// 参数:sock:希望进入等待连接请求状态的套接字文件描述符;
// backlog:连接请求等待队列的最大长度,最多使 backlog 个连接请求进入队列。
// 返回值:成功时返回 0,失败时返回 -1

等待连接请求状态:“服务器处于等待连接请求状态”指让来自客户端的请求处于等待状态。

**连接请求等待队列:**还未受理的连接请求在此排队,backlog 的大小决定了队列的最大长度,一般频繁接受请求的 Web 服务器的 backlog 至少为 15。

4.2.2 accept函数受理连接请求

accept 函数会受理连接请求等待队列中待处理的客户端连接请求,它从等待队列中取出 1 个连接请求,创建套接字并完成连接请求。如果等待队列为空,accpet 函数会阻塞,直到队列中出现新的连接请求才会返回。

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t addrlen);  
/ 功能:受理连接请求等待队列中待处理的连接请求。
// 参数:sock:服务器套接字的文件描述符;
// addr:用于保存发起连接请求的客户端地址信息;
// addrlen:第二个参数的长度。
// 返回值:成功时返回创建的套接字文件描述符,失败时返回 -1

**它会在内部产生一个新的套接字并返回其文件描述符,该套接字用于与客户端建立连接并进行数据 I/O。**新的套接字是在 accept 函数内部自动创建的,并自动与发起连接请求的客户端建立连接。

accept 执行完毕后会将它所受理的连接请求对应的客户端地址信息存储到第二个参数 addr 中。

4.2.3 客户端conncet函数发起请求

#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen);  
// 功能:请求连接。
// 参数:sock:客户端套接字的文件描述符;
// serv_addr:保存目标服务器端地址信息的结构体指针;
// addrlen:第二个参数serv_addr的长度(单位是字节)
// 返回值:成功时返回 0,失败时返回 -1

客户端调用 connect 函数后会阻塞,直到发生以下情况之一才会返回:

  1. 服务器端接收连接请求。
  2. 发生断网等异常情况而中断连接请求。

注意:上面说的”接收连接请求“并不是服务器端调用了 accept 函数,而是服务器端把连接请求信息记录到等待队列。因此 connect 函数返回后并不立即进行数据交换。

客户端的IP地址和端口在调用connect函数时自动分配,无需调用bind函数进行分配。


再次回顾基于TCP的服务端和客户端函数调用关系:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TMC40JxW-1677126817984)(https://note.youdao.com/yws/public/resource/3812118367af6a02a2ff2e89fc824283/xmlnote/245352958B0A472FA87D717EB23D5D3E/63297)]

客户端只有等到服务器端调用 listen 函数后才能调用 connect 函数,否则会连接失败

客户端调用 connect 函数和服务器端调用 accept 函数的顺序不确定,先调用的要等待另一方

4.3 实现迭代回声服务端/客户端

回声服务器端:它会将客户端传输的字符串数据原封不动地传回客户端,像回声一样。

4.3.1 实现迭代服务器端

**调用一次 accept 函数只会受理一个连接请求,**如果想要继续受理请求,最简单的方法就是循环反复调用 accept 函数,在前一个连接 close 之后,重新 accept。在不使用多进程/多线程情况下,同一时间只能服务于一个客户端。

迭代回声服务器端与回声客户端的基本运行方式:

  1. 服务器端同一时刻只与一个客户端相连接,并提供回声服务。
  2. 服务器端依次向 5 个客户端提供服务,然后退出。
  3. 客户端接收用户输入的字符串并发送到服务器端。
  4. 服务器端将接收到的字符串数据传回客户端,即”回声“。
  5. 服务器端与客户端之间的字符串回声一直执行到客户端输入 Q 为止。
//code

4.3.2 回声客户端存在的问题

在本章的回声客户端的实现中有下面这段代码,它有一个错误假设:每次调用 read、write 函数时都会执行实际的 I/O 操作。

write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE - 1);

但是注意:TCP 是面向连接的字节流传输,不存在数据边界。所以多次 write 的内容可能一直存放在发送缓存中,某个时刻再一次性全都传递到服务器端,这样的话客户端前几次 read 都不会读取到内容,最后才会一次性收到前面多次 write 的内容。还有一种情况是服务器端收到的数据太大,只能将其分成多个数据包发送给客户端,然后客户端可能在尚未收到全部数据包时旧调用 read 函数。

理解:问题的核心在于 write 函数实际上是把数据写到了发送缓存中,而 read 函数是从接收缓存读取数据。并不是直接对 TCP 连接的另一方进行数据读写。

解决方式见下一章

4.4 回声客户端的完美实现

回顾服务端的实现代码:

while((str_len = read(clnt_sock, message, BUF_SIZE)) != 0)
    write(clnt_sock, message, str_len);

循环调用read函数,当read函数读成功或失败时,继续读,直至文件尾标志。这是没有问题的。

回顾客户端的代码:

write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE - 1);

回声客户端的问题实际上就是没有考虑拆包和粘包的情况。

4.4.1 回声客户端的解决办法

解决方法的核心:提前确定接收数据的大小。

客户端上一次使用 write 从套接字发送了多少字节,紧接着就使用 read 从套接字读取多少字节。

str_len=write(sock, message, strlen(message)); //发送的数据长度
recv_len=0; //收到的数据长度
while(recv_len<str_len)
{
    recv_cnt=read(sock, &message[recv_len], BUF_SIZE-1);
    if(recv_cnt==-1)
    error_handling("read() error!");
    recv_len+=recv_cnt;
}

4.4.2 问题的另一视角:应用层协议

上面的回声客户端中,假设提前就知道接收数据的长度。但是一般情况下是不知道的,这时解决拆包和粘包的问题,就要定义应用层协议。

之前回声服务端和客户端就定义了协议:“收到Q就终止连接”

应用层协议实际就是在服务器端/客户端的实现过程中逐步定义的规则的集合。在应用层协议中可以定好数据边界的表示方法、数据的长度范围等。

4.5 TCP原理

4.5.1 TCP套接字中的I/O缓冲

在使用 read/write 函数对套接字进行读写数据时,实际上读写的是套接字输入/输出缓冲中的内容。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-10JUls7x-1677126817985)(https://note.youdao.com/yws/public/resource/8f6540b91e54373c8d0e0807432ce30c/xmlnote/39EB49E25B6247108266AC4368E4230D/66270)]

套接字 I/O 缓冲的特性:

  1. I/O 缓冲在每个套接字中单独存在。
  2. I/O 缓冲在创建套接字时自动生成。
  3. 即使关闭套接字也会继续传递输出缓冲中遗留的数据。
  4. 关闭套接字将丢失输入缓冲中的数据。

五、基于UDP的服务端与客户端

5.1 理解UDP

区分 TCP 与 UDP 的一个典型比喻:UDP 好比寄信,TCP 好比打电话:

  1. UDP:寄信前要在信封上填好寄信人和收信人的地址,然后放进邮筒。不能确认对方是否收到信件,并且邮寄过程中新建可能丢失。
  2. TCP:首先要拨打电话号码,打通后才能开始通话,但打通后的通话是可靠的。

TCP 和 UDP 最重要的区别在于流控制。

理解:这里的流控制应该包含了 TCP 的可靠传输、流量控制、拥塞控制等机制,这些机制都是在流上实现的。

TCP 是可靠的(面向连接)、按序传递、基于字节的

UDP 不可靠、无序

UDP的高效使用

网络实时传输多媒体数据一般使用 UDP。

TCP 比 UDP 慢的两个原因:

  1. TCP 数据传输前后要进行连接的建立与释放。
  2. TCP 数据传输过程中为了保证可靠性而添加的流控制。

当收发的数据量小但需要频繁连接时,UDP 的高效体现地更明显。

5.2 实现基于UDP的服务端/客户端

因为 UDP 是无连接的,所以在编程时不需要调用 listen 函数和 accept 函数

UDP 套接字编程中只有创建套接字数据交换两个过程。

5.2.1 UDP服务器端和客户端均只需 1 个套接字

TCP 中,服务器端和客户端的套接字是一对一的关系,服务器端每向一个客户端提供服务,就需要分配一个新的套接字。

而 UDP 的服务器端和客户端均只需 1 个套接字,服务器端只要有一个 UDP 套接字就可以和多台主机通信。

回忆邮筒的例子,收发信件的邮筒可以比喻成UDP套接字。只要有1个邮筒就可以收到任意地址的信件或者发送信件。

5.2.2 发送UDP数据的函数

UDP 套接字不会保持连接状态,因此每次传输数据时都要添加目标地址信息(相当于寄信前在信封上写收信地址)。

#include <sys/socket.h>
ssize_t sendto(int sock, void* buff, size_t nbytes, 
								int flags, struct sockaddr* to, socklen_t addrlen);
// 功能:向 to 中所指明的目标地址发送数据。
// 参数:sock:UDP 套接字文件描述符;
// buff:用户保存接收的数据; 
// nbytes:可接收的最大字节数; 
// flags:可选项参数,没有则为 0; 
// to:包含目标地址信息; 
// addrlen:包含目标地址信息的结构体变量的长度
// 返回值:成功时返回接收的字节数,失败时返回 -1。

上述函数与TCP输出函数最大的区别在于需要传递目标地址信息。

5.2.3 接收UDP数据的函数

#include <sys/socket.h>
ssize_t recvfrom(int sock, void* buff, size_t nbytes, 
								int flags, struct sockaddr* from, socklen_t *addrlen);
// 功能:从 from 中所指明的地址接收数据。
// 参数:sock:UDP 套接字文件描述符;
// buff:待传输的数据; 
// nbytes:待传输的数据长度(单位是字节); 
// flags:可选项参数,没有则为 0; 
// from:用来存储发送端的地址信息; 
// addrlen:包含发送端地址信息的结构体变量的长度
// 返回值:成功时返回传输的字节数,失败时返回 -1。 

接收端本来是不知道发送端的地址的,但调用完 recvfrom 函数后,发送端的地址信息就会存储到参数 from 指向的结构体中。

5.3 基于UDP的回声服务端/客户端

5.3.1 服务端代码

//uecho_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int serv_sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t clnt_adr_sz;
	
	struct sockaddr_in serv_adr, clnt_adr;
	if(argc!=2){
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}
	
	serv_sock=socket(PF_INET, SOCK_DGRAM, 0);
	if(serv_sock==-1)
		error_handling("UDP socket creation error");
	
	memset(&serv_adr, 0, sizeof(serv_adr));
	serv_adr.sin_family=AF_INET;
	serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
	serv_adr.sin_port=htons(atoi(argv[1]));
	
	if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
		error_handling("bind() error");

	while(1) 
	{
		clnt_adr_sz=sizeof(clnt_adr);
		str_len=recvfrom(serv_sock, message, BUF_SIZE, 0, 
								(struct sockaddr*)&clnt_adr, &clnt_adr_sz);
		sendto(serv_sock, message, str_len, 0, 
								(struct sockaddr*)&clnt_adr, clnt_adr_sz);
	}	
	close(serv_sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

5.3.2 客户端代码

//uecho_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
	int sock;
	char message[BUF_SIZE];
	int str_len;
	socklen_t adr_sz;
	
	struct sockaddr_in serv_adr, from_adr;
	if(argc!=3){
		printf("Usage : %s <IP> <port>\n", argv[0]);
		exit(1);
	}
	
	sock=socket(PF_INET, SOCK_DGRAM, 0);   
	if(sock==-1)
		error_handling("socket() error");
	
	memset(&serv_adr, 0, sizeof(serv_adr));
	serv_adr.sin_family=AF_INET;
	serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
	serv_adr.sin_port=htons(atoi(argv[2]));
	
	while(1)
	{
		fputs("Insert message(q to quit): ", stdout);
		fgets(message, sizeof(message), stdin);     
		if(!strcmp(message,"q\n") || !strcmp(message,"Q\n"))	
			break;
		
		sendto(sock, message, strlen(message), 0, 
					(struct sockaddr*)&serv_adr, sizeof(serv_adr));
		adr_sz=sizeof(from_adr);
		str_len=recvfrom(sock, message, BUF_SIZE, 0, 
					(struct sockaddr*)&from_adr, &adr_sz);

		message[str_len]=0;
		printf("Message from server: %s", message);
	}	
	close(sock);
	return 0;
}

void error_handling(char *message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

5.3.3 进一步理解

**问题:**TCP客户端套接字在调用connect函数时会自动分配IP地址和端口号,那么UDP客户端何时分配IP地址和端口号?

**回答:**UDP 中 sendto 函数来完成此功能。如果调用 sendto 函数时发现尚未给套接字分配地址信息,就会在首次调用 sendto 函数时给套接字分配 IP 地址和端口。

5.4 UDP的数据传输特性

5.4.1 数据边界

UDP数据传输中存在数据边界,UDP 套接字编程时,接收端输入函数的调用次数必须和发送端输出函数的调用次数相同,这样才能接收完发送端发送的数据。

5.4.2 连接的UDP套接字

通过 sendto 函数传输数据的过程包括三个阶段:

  1. 向 UDP 套接字注册目标 IP 和端口号;(注意:是将 UDP 套接字与目标的地址信息相关联,不是给 UDP 分配地址信息。前者每次 sendto 都会执行,后者只有首次调用且套接字尚未分配地址时才会执行一次)。
  2. 传输数据;
  3. 删除 UDP 套接字中注册的目标地址信息。

当多次通过 sendto 向同一个目标发送信息时,每次 sendto 都进行上面的步骤 1 和 3,就会很浪费时间。因此当要长时间与同一主机通信时,将 UDP 变为已连接套接字(注册了目标地址的套接字)会提高效率。

利用connect函数注册地址,并不意味着与对方UDP套接字连接。

connect(sock, (struct sockaddr*)&adr, sizeof(adr)); // 注意:adr 是目标的地址信息

使用已连接的 UDP 套接字进行通信时, sendto 函数就不会再执行步骤 1 和步骤 3,每次只要传输数据即可。

并且已连接的 UDP 套接字也可以通过 write、read 函数进行通信。

//code

六、优雅地断开套接字

6.1 基于TCP的半关闭

TCP 的断开连接过程比建立连接过程更重要,因为断开过程更有可能出现意外情况。

Linux 的 close 函数和 Windows 的 closesocket 函数都意味着完全断开连接。完全断开不仅无法发送也无法接收数据。在某些情况下,通信一方完全断开连接就显得很不优雅。

6.1.1 套接字和流

建立 TCP 套接字连接后可交换数据的状态可以看成一种流。进行双向通信就需要两个流,输入流和输出流。调用close 将会同时断开两个流。

有一种方法是断开一部分连接:只断开输入流或输出流。shutdown 函数用于只断开其中一个流。

#include <sys/socket.h>
int shutdown(int sock, int howto);
// 功能:半关闭套接字
// 参数:sock:需要断开的套接字;howto:断开方式
// 返回值:成功时返回 0,失败时返回 -1。

第二个参数 howto 将决定关闭的方式,可取的值如下:

  1. SHUT_RD:断开输入流,此后套接字无法接收数据;
  2. SHUT_WR:断开输出流,此后套接字无法发送数据;
  3. SHUT_RDWR:同时断开 I/O 流。

他们的值按序分别是 0, 1, 2;

为什么需要半关闭?

一方在发送完所有数据后可以只关闭输出流但保留输入流,这样还可以接收对方的数据。

6.2 半关闭的文件传输程序

//file_server.c
clnt_sd=accept(serv_sd, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
	
	while(1)
	{
		read_cnt=fread((void*)buf, 1, BUF_SIZE, fp);//从文件读取BUF_SIZE长度字符到buf
		if(read_cnt<BUF_SIZE)
		{
			write(clnt_sd, buf, read_cnt);
			break;
		}
		write(clnt_sd, buf, BUF_SIZE); //输出字符
	}
	
	shutdown(clnt_sd, SHUT_WR);	
	read(clnt_sd, buf, BUF_SIZE);
	printf("Message from client: %s \n", buf);
//file_client.c
	connect(sd, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
	
	while((read_cnt=read(sd, buf, BUF_SIZE ))!=0)
		fwrite((void*)buf, 1, read_cnt, fp); //写入到文件中
	
	puts("Received file data");
	write(sd, "Thank you", 10);
	fclose(fp);
	close(sd);

七、域名及网络地址

7.1 域名系统

通常人们很难记住IP地址,但是域名就比较通俗易懂。于是将域名对应一个IP地址。DNS对域名和IP地址进行转换,核心是DNS服务器。

可以通过 ping 命令查看域名对应的 IP 地址。查看本机的默认 DNS 域名服务器地址可以使用 nslookup 命令。

7.2 IP地址和域名之间的转换

7.2.1利用域名获取IP地址

可以使用以下函数来根据字符串格式的域名获取 IP 地址。

#include <netdb.h>
struct hostent* gethostbyname(const char* hostname);
// 功能:利用域名获取 host 信息,包括绑定的其他域名及所有 IP 地址
// 参数:hostname:字符串格式的域名
// 返回值:包含 IP 地址信息的结构体的指针

这个函数使用时输入字符串域名,返回装有地址信息的hostent结构体指针。

hostent 结构体的定义如下:

struct hostent
{
    char* h_name;      // 官方域名
    char** h_aliases;  // 绑定的其他域名,同一IP可能绑定多个域名
    int h_addrtype;    // 结构体中存储的地址所属的地址族,如果是 IPv4 地址,则此变量为 AF_INET
    int h_length;      // IP 地址的长度,如果是 IPv4 地址,则此变量值为 4
    char** h_addr_list;// 地址列表,最重要的成员。以整数形式保存域名对应的 IP 地址(可能有多个)
}

调用 gethostbyname 函数后返回的 hostent 结构体的变量结构如下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Run9gb43-1677126817986)(https://note.youdao.com/yws/public/resource/b988c1fc2fae32d6df2a767a1af79205/xmlnote/06C01B9511B54517A16D0015217AF1CD/67193)]

注意:h_addr_list 中存储地址的方式是 char*,而 char* 的内容并不是地址值,实际上是 in_addr 结构体的地址。

因此要取得点分十进制字符串格式的地址,需要先将 char 转换为 in_addr 类型,然后解引用取得整数地址值,再使用 inet_ntoa 将其转换为点分十进制格式的字符串。**

host=gethostbyname(argv[1]);
for(i=0; host->h_addr_list[i]; i++)
  printf("IP addr %d: %s \n", i+1,
         inet_ntoa(*(struct in_addr*)host->h_addr_list[i]));

为什么h_addr_list不采用in_addr*类型的数组?

答:为了通用性,hostent结构体并非只为IPv4准备。

7.2.2 利用IP地址获取域名

gethostbyaddr 函数利用 IP 地址获取域名

#include <netdb.h>
struct hostent* gethostbyaddr(const char* addr, socklen_t len, int family);
// 功能:利用 IP 地址获取 host 信息,包括绑定的所有域名及其他 IP 地址
// 参数:hostname:字符串格式的域名
// 返回值:包含 IP 地址信息的结构体的指针
	addr.sin_addr.s_addr=inet_addr(argv[1]); //inet_addr将字符串形式的IP转化为整数值
	host=gethostbyaddr((char*)&addr.sin_addr, 4, AF_INET);

八、套接字的多种可选项

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值