1 理解网络编程和套接字
1.1 理解网络编程和套接字
套接字是网络数据传输用的软件设备,网络编程又称为套接字编程。
套接字大致分为两种。
1.1.1 tcp 套接字
将 tcp 套接字比喻成电话机,电话机可以同时拨打和接听,对于套接字来说,拨打和接听是有区别的,所以先看接听的创建过程。
- 安装电话机(调用 socket 函数)
#include<sys/socket.h>
int socket(int domain, int type, int protocol);
//成功返回文件描述,失败返回-1
- 分配电话号码(调用 bind 函数),给创建好的套接字分配 IP 地址和端口号
#include<sys/socket.h>
int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen);
//成功返回0,失败返回-1
- 连接电话线(调用 listen 函数),将套接字转化为可接受连接的状态
#include<sys/socket.h>
int linten(int sockfd, int backlog);
//成功返回0,失败返回-1
- 打电话(调用 accept 函数),当别人发起请求连接的时候调用 accept 函数
#include<sys/socket.h>
int accept(int sockfd, struct sockaddr *myaddr, socklen_t addrlen)
//成功返回文件描述,失败返回-1
网络编程中接受连接请求的套接字创建过程可整理如下:
- 第一步:调用 socket 函数创建套接字。
- 第二步:调用 bind 函数分配 IP 地址和端口号。
- 第三步:调用 listen 函数转换为可接受请求状态。
- 第四步:调用 accept 函数受理套接字请求。
服务端是能够受理连接请求的程序。
1.1.2 客户端套接字
客户端只有调用 socket 函数创建套接字和调用 connect 函数像服务器端发送连接请求两个步骤。
调用 connect 函数像服务器端发送连接请求的函数为:
#include<sys/socket.h>
int connect(int sockfd, struct sockaddr *myaddr, socklen_t addrlen)
1.2 基于 Linux 的文件操作
分配给标准输入输出及标准错误的文件描述符:
| 文件描述符 | 对象 |
|---|---|
| 0 | 标准输入 |
| 1 | 标准输出 |
| 2 | 标准错误 |
1.2.1 打开文件
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int open(const char *path, int flag)
/*
成功时返文件描述符号,失败时返回-1
path : 文件名的字符串地址
flag : 文件打开模式信息
*/
文件打开模式如下表:
| 打开模式 | 含义 |
|---|---|
| O_CREAT | 必要时创建文件 |
| O_TRUNC | 删除全部现有数据 |
| O_APPEND | 维持现有数据,保存到其后面 |
| O_RDONLY | 只读打开 |
| O_WRONLY | 只写打开 |
| O_RDWR | 读写打开 |
1.2.2 关闭文件
#include<unistd.h>
int close(int fd);
/*
成功时返文件描述符号,失败时返回-1
fd:需要关闭的文件或套接字的文件描述符
*/
不仅可以关闭文件还可以关闭套接字。
1.2.3 将数据写入文件
#include<unistd.h>
ssize_t write(int fd, const void *buf, size_t nbytes);
/*
成功时返文件描述符号,失败时返回-1
fd:显示数据传输对象的文件描述符
buf:要跑村传输数据的缓冲地址
nbytes:要传输数据的字节数
*/
在此函数的定义中,size_t 是通过 typedef 声明的 unsigned int 类型。对 ssize_t 来说,ssize_t 前面多加的 s 代表 signed ,即 ssize_t 是通过 typedef 声明的 signed int 类型。
1.2.4 读取文件中的数据
#include<unistd.h>
ssize_t write(int fd, const void *buf, size_t nbytes);
/*
成功时返文件描述符号,失败时返回-1
fd:显示数据传输对象的文件描述符
buf:要保存接收数据的缓冲地址
nbytes:要传输数据的字节数
*/
1.2.5 示例
#include<stdio.h>
#include<stdlib.h>
#include<fcntl.h>
#include<unistd.h>
#define BUF_SIZE 100
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
int main()
{
int fd;
char buf[]="Let's go!\n";
char rbuf[BUF_SIZE];
//打开文件
fd=open("data.txt",O_CREAT|O_RDWR|O_TRUNC); //必要时创建文件,读写打开,删除全部现有数据
if(fd==-1)
{
error_handling("open() error!");
}
printf("文件描述:%d \n",fd);
//写入数据
if(write(fd, buf, sizeof(buf))==-1)
{
error_handling("write() error!");
}
//读取数据
if(read(fd, rbuf, sizeof(rbuf))==-1)
{
error_handling("read() error!");
}
printf("文件数据:%s\n",rbuf);
//关闭文件
close(fd);
}
文件描述:3
文件数据:Let's go!
文件描述符将从 3 开始由小到大的顺序编号,0、1、2 是分配给标准 I/O 的描述符。
1.5 习题
2 套接字类型与协议设置
2.1 套接字协议及其数据传输特性
协议:协议就是为了完成数据交换而定好的约定。
2.2.1 创建套接字
#include<sys/socket.h>
int socket(int domain, int type, int protocol);
//成功返回文件描述,失败返回-1
- domain:套接字中使用的协议族(Protocol Family)信息
- type:套接字数据传输类型信息
- protocol:计算机间通信中使用的协议信息
Domain
Socket 的第一个参数,协议族,大致可以分为以下几类:
| 名称 | 协议族 |
|---|---|
| PF_INET | IPv4 互联网协议族 |
| PF_INET6 | IPv6 互联网协议族 |
| PF_LOCAL | 本地通信的 UNIX 协议族 |
| PF_PACKET | 底层套接字的协议族 |
| PF_IPX | IPX Novell 协议族 |
Type
socket 的第二个参数,套接字类型,是指套接字的数据传输方式。虽然第一个参数决定了套接字的协议族信息,但是每个协议族中也有许多数据传输方式。
面向连接的套接字(SOCK_STREAM)
特点:
- 传输过程中数据不会消失
- 按序传输数据
- 传呼的数据不存在数据边界
较晚传递的数据不会先到达,保证了数据的按序传递。
下面这段话说明了数据不存在边界:
传输数据的计算机通过调用3次 write 函数传递了 100 字节的数据,但是接受数据的计算机仅仅通过调用 1 次 read 函数调用就接受了全部 100 个字节。
在收发数据的套接字内部有缓冲(buffer),就是一个字节数组。只要不超过数组容量,那么数据填满缓冲后过 1 次 read 函数的调用就可以读取全部,也有可能调用多次来完成读取。也就是说,在面向连接的套接字中,read 函数和 write 函数的调用次数没有太大的意义,所以面向连接的套接字不存在数据边界。
如果缓冲区填满之后,也不会发生数据丢失。 假设 read 函数的读取速度比接收数据慢,缓冲区可能被填满。此时套接字无法在接收数据,套接字会停止接收数据,保证不会丢失数据。也就是说,面向连接的套接字会根据接收端的状态来传输数据,如果传输出错还会进行重传。面向连接的套接字除特殊情况外不会发生数据丢失。
另外,套接字的连接必须一一对应,即面向连接的套接字只能与另外一个相同特性的套接字连接。
用一句话概括面向连接的套接字:“可靠的、按序传递的、基于字节的面向连接的数据传输方式的套接字”。
面向消息的套接字(SOCK_DGRAM)
特点:
- 强调快速传输而非顺序传输
- 传输的数据可能丢失也可能损毁
- 传输的数据有数据边界
- 限制每次传输的数据大小
类似于使用摩托车运送快递的方式。 发往同一个目的地的多件包裹不需要保证顺序,只需要最快到达即可。而且存在一定的损坏或者丢失的风险。 包裹的大小也有一定的限制。如果需要传输大量包裹,就需要分批发送。如果用两个摩托车发两件包裹,接收者也需要分两次来接收。即传输的数据有数据边界。
用一句话概括面向消息的套接字:“不可靠的、不按序传递的、以数据高速传输为目的的套接字”
协议的最终选择
第三个参数,决定最终采用的协议。 前两个参数已经差不多可以创建所需套接字,所以大部分情况下第三个参数都可以为 0。在“同一协议族中存在多个数据传输方式相同的协议”这个情况下面,数据传输方式相同,但是协议不同。因此需要第三个参数来具体指定协议信息。
套接字的创建
创建一个 IPv4 协议族中面向连接的套接字
满足这两个条件的协议只有 IPPROYTO_TCP,这种套接字被称为 TCP 套接字。
int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
创建一个 IPv4 协议族中面向消息的套接字
满足这两个条件的协议只有 IPPROYTO_UDP,这种套接字被称为 UDP 套接字。
int udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);
3 地址族与数据序列
上一章讲解的时按章电话机,那么这一章讲的是给电话机分配电话号码的方法。也就是给套接字分配 IP 地址和端口号。
3.1 分配给套接字的 IP 地址与端口号
IP 是为收发网络数据而分配给计算机的值。
端口号是为了区分程序中创建的套接字而分配给套接字的序号。
3.1.1 网络地址
每一个计算机都有一个 IP 地址,目前 IP 地址分为两类:
- IPv4 四字节地址族
- IPv6 十六字节地址族 他们的主要差别就是表示 IP 地址所用的字节数。
目前通用的还是 IPv4 地址族,分为了 A、B、C、D、E,一般不会使用已经被预约了的 E 类地址:
网络地址是区分网络而设置的一部分 IP 地址。
先将数据传输到网络,再向据以的主机传输数据。
3.1.2 网络地址分类与主机地址边界
通过 IP 地址的第一个字节即可判断网络地址占用的字节数:
- A 类地址的首字节范围:0~127
- B 类地址的首字节范围:128~191
- C 类地址的首字节范围:192~223
或者说:
- A 类地址的首位以 0 开始
- B 类地址的首位以 10 开始
- C 类地址的首位以 110 开始
3.1.3 用于区分套接字的端口号
计算机中有 NIC(网络接口卡)数据传输设备。通过 NIC 向计算机内部传输数据时会用到 IP。操作系统将传递到内部的数据适当分配给套接字,这个时候就需要利用端口号。
端口号是 16 位构成的,范围是:0 65535。01023 是知名端口,一般会分配给特定应用程序,应当分配此范围之外的值。
端口号不能重复,但是 TCP 和 UDP 不会共用端口号。也就是说某个 TCP 套接字使用了 9190 端口,其他 TCP 套接字不能使用该端口号,但是 UDP 套接字可以使用 9190 端口号。
所以说数据传输应该同时包含 IP 地址和端口号。
3.2 地址信息的表示
应用程序中使用的 IP 地址和端口号以结构体的形式给出了定义。本节围绕结构体讨论目标地址的表示方法。
结构体如下:
struct sockaddr_in
{
sa_family_t sin_family; //地址族(Address Family)
uint6_t sin_port; //16位 TCP/UDP 端口号
struct in_addr sin_addr; //32位 IP 地址
char sin_zero[8]; //不使用
};
in_addr 的定义如下,用来存放 32 位 IP 地址:
struct in_addr
{
In_addr_t s_addr; //32位 IPv4 地址
};
关于上述结构体的数据类型,可以参考 POSIX
额外定义这些数据类型的原因是考虑到了拓展性的结果。比如 int32_t 类型保证了在任何时候都占用 4 个字节。
3.2.1 sockaddr_in 的成员分析
sin_family
每个协议族使用的地址族均不同。比如 IPv4 使用 4 字节地址组,IPv6 使用 16 字节地址族。
| 地址族 | 含义 | |
|---|---|---|
| AF_INET | IPv4 用的地址族 | |
| AF_INET6 | IPv6 用的地址族 | |
| AFz_LOCOL | 本地通信中采用的 Unix 协议的 | |
| AF_LOACL 只是为了说明具有多种地址族而添加的。 |
sin_port
该成员保存 16 位端口号,重点在于,它以网络字节序保存。
sin_addr
该成员保存 32 为 IP 地址信息,且也以网络字节序保存
sin_zero
没有特殊含义,只是为了让 sockaddr_in 的大小与 sockaddr 的结构体保持一致才加上的,必须填充为 0。
3.3 网络字节序与地址变换
在不同的 cpu 中,4 字节整形值 1 在内存空间的保存方式是不同的。 有的 cpu 顺序保存到内存:
00000000 00000000 00000000 00000001
有的 cpu 倒序保存:
00000001 00000000 00000000 00000000
3.3.1 字节序(Order)与网络字节序
因此这意味着有两种解析数据的方式。
- 大端序:高位字节存放到低位地址
- 小端序:高位字节存放到高位地址
例如在 0x20 号开始的地址中保存四字节 int 类型 0x12345678
- 大端序保存:
最高位字节 0x12 存放到了最低位地址 0x20
- 小端序保存:
最高位字节 0x12 存放到了最高位地址 0x23
目前主流的 Intel 系列就是以小端序的方式保存数据。
因此,在网络传输数据的时候必须要约定一个统一的方式,这个约定被称为网络字节序。也就是统一为大端序。每次传输的时候先将数据转化为大端序再进行网络传输。
下面是四种字节序转换函数:
unsigned short htons(unsigned short);
unsigned short ntohs(unsigned short);
unsigned long htonl(unsigned long);
unsigned long ntohl(unsigned long);
htons 和 htonl 都是将主机字节序转化为网络字节序,区别是一个是短整型数据一个是长整型数据。
另外的两个函数 ntohs 和 ntohl 是将网络字节序转化为主机字节序。
3.4 网络地址的初始化与分配
3.4.1 将字符串信息转化为网络字节序的整数型
如何将 IP 地址 201.211.214.36 转化为四字节的整数型数据。
使用 inet_addr 函数可以在转换类型的时候同时进行网络字节序的转换:
#include<arpa/inet.h>
in_addr_t inet_addr(const char * string)
in_addr_t 在内部声明为 32 为整数型。
例子:
结果:
因为一个字节能表示的最大字节为 255,所以第二个地址 256 是错误的表示。 因此这个函数不仅能够将 IP 地址转化为 32 为整数型,也能检测无效的 IP 地址。
另外的 inet_aton 函数与上述函数功能基本相同,但是这个函数利用的是 in_addr 这个结构体,所以它的使用频率更高。
int inet_aton(const char * string, struct in_addr * addr)
//成功返回1,失败返回0
该函数会自动将转换后的结果填入 addr 结构体中。
例子:
结果:
3.4.2 网络地址初始化
结合前面的内容,即可得出常见的网络地址初始化的方法为:
struct sockaddr_in addr;
char *serv_ip = "211.217.168.13"; //声明IP地址族
char *serv_port = "9120"; //声明端口号字符串
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)); //将字符串的端口号初始化,atoi是将字符串转化为整数,htons是将主机字节序转化为网路字节序
对于服务端来说,可以使用 INADDR_ANY 来自动获取运行服务端的计算机的 IP 地址。
addr.sin_addr.s_addr = htonl(INADDR_ANY); //将字符串 IP 初始化为整数型
若同一计算机中已经分配多个 IP 地址,则只需要端口号一致,也可以从不同的 IP 地址接收数据。 所以说服务端首先考虑这种方式。但是客户端除非带有一部分服务端的功能,否则不会采用。
回送地址:回送地址
4 基于 TCP 的服务端/客户端(1)
因为 TCP 套接字是面向连接的,所以又称之为基于流(stream)的套接字。
TCP 是 Transmission Control Protocol(传输控制协议)的简写,是“对数据传输过程的控制”。
4.1 TCP/IP 协议栈
协议栈共分为四层,可以理解为将数据收发分为了四个过程,通过层次化的方式来解决问题。
4.1.1 链路层
链路层是物理链接领域标准化的结果,也是最基本的领域,专门定义 LAN、WAN、MAN 等网络标准。
两台主机进行网络数据交换,就需要物理连接,链路层就负责这些标准。
4.1.2 IP 层
在物理连接后就要传输数据。在复杂网络中传输数据,既要考虑路径的选择。
向目标传输数据需要经过哪条路径?IP 层就负责解决这个问题,IP 层的协议叫 IP。
IP 是面向消息的、不可靠的协议。每次传输数据会帮助我们选择路径,但每次选择的路径并不一定一致。如果发生路径错误就会选择其他路径。但是它无法解决数据丢失和错误的问题。
4.1.3 TCP/UDP 层
IP 层负责寻找路径。TCP 和 UDP 层以 IP 层提供的路径信息为基础来完成实际的数据传输,所以这一层又称为传输层。
使用 IP 层传输数据会导致传输顺序和传输本身都不可靠,可能会导致顺序错乱和数据丢失。但是添加了 TCP 协议就不一样。
TCP 在交换数据的过程中会确认对方收到的数据和重传丢失的数据。所以说,即便 IP 层不可靠,但是有了 TCP 后也是可靠的。
4.1.4 应用层
上述的选择数据传输路径、数据确认过程都被隐藏到套接字内部。也就是让程序员不必纠结于这些细节。
编写软件的过程就中,需要根据程序的特点来决定服务端与客户端之间的传输规则(规定),这就是应用层协议。其实网路编程中的大部分内容就是设计并实现应用层协议。
4.2 实现基于 TCP 的服务端/客户端
这一节实现完整的 TCP 服务端,来理解套接字使用方式和数据传输方法。
4.2.1 TCP 服务器的默认函数调用顺序
绝大部分 TCP 都按照这个顺序调用:
调用 socket 函数创建套接字,声明并初始化地址信息结构体变量,调用 bind 函数向套接字配地址。
4.2.2 进入等待连接请求状态
在调用 bind 函数分配地址后,就会调用 listen 函数进入等待请求连接状态。
只有调用了 listen 函数,客户端才能进入可以发出连接请求的状态。 只有这个时候客户端才能调用 connect 函数,否则将会发生错误。
#include<sys/socket.h>
int listen(int sock, int backlog);
/*
成功返回0,失败返回-1
sock:希望进入 等待连接请求状态的 套接字文件描述符,传递的描述符套接字参数将成为服务器端套接字(套接字监听)
backlog:连接请求等待队列(Queue)的长度,如果为5,则队列长度为5,表示最多使5个连接请求进入队列
*/
服务器端处于等待连接请求状态:指的是客户端请求连接时,受理连接前一直使请求处于等待状态。
listen 函数的第二个参数就是等候室的大小,它被称之为等待队列。当准备好了服务器端套接字和连接请求等待队列后,这种可以接收连接请求的状态就被称为等待连接请求状态。
等待队列的长度和服务器端特性有关,例如频繁接收请求的 Web 服务器端至少应为 15。
4.2.3 受理客户端连接请求
调用完 listen 函数后,就按序受理连接请求。能够受理请求意味着进入了可接受数据的状态。
像上图,服务器端套接字是相当于门卫,用来守门的。所以在与客户端的数据交换中还需要一个套接字,accept 函数就会自动创建一个套接字并连接到发起请求的客户端。
#include<sys/socket.h>
int accept(int sock, struct sockaddr * addr, socklen_t * addrlen);
/*
成功返回创建的套接字描述符,失败返回-1
sock:服务器端的套接字文件描述符
addr 保存发起连接请求的客户端地址信息的地址的值,调用函数后向传递来的地址变量参数填充客户端地址信息
addrlen:第二个参数 addr 结构体的长度,但是存有长度的变量地址。函数调用完后,这个变量就被填充为客户端地址长度
*/
accept 函数受理连接请求等待队列中待处理的客户端连接请求。调用成功时,accept 函数内部将产生用于 I/O 的套接字,并返回其文件描述符。
服务器端单独创建的套接字与客户端建立连接后进行数据交换。
4.2.4 回顾 hello world 服务器端
//hello_server.c
#include<stdio.h>
#include<stdlib.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
#include<arpa/inet.h>
#include<sys/socket.h>
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
int main(int argc, char *argv[])
{
int serv_sock;
int clin_sock;
struct sockaddr_in serv_addr;
struct sockaddr_in clin_addr;
socklen_t clin_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);
if(serv_sock == -1)
{
error_handling("socket() error");
}
//为了完成套接字地址分配,初始化结构体变量并调用 bind 函数
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)
{
error_handling("bind() error");
}
//调用 listen 函数进入等待连接请求状态。连接请求等待队列长度设置为5。这个时候的套接字才是真正的服务器端套接字
if(listen(serv_sock, 5) == -1)
{
error_handling("listen() error");
}
//调用 accetp 函数从队头取一个连接请求与客户端建立连接,并返回创建的套接字文件描述符。如果调用 accept 函数的时候等待队列为空,则 accept 函数不会返回,直到队列中出现新的客户端连接诶
clin_addr_size = sizeof(clin_addr);
clin_sock = accept(serv_sock, (struct sockaddr*)&clin_addr, &clin_addr_size);
if(clin_sock == -1)
{
error_handling("accept() error");
}
//调用 write 函数向客户端传输数据,调用 close 函数关闭连接
write(clin_sock, message, sizeof(message));
close(clin_sock);
close(serv_sock);
return 0;
}
此时再看,其实服务端的基本实现过程还是比较简单的。
4.2.5 TCP 客户端的默认函数调用顺序
客户端的实现比服务器端简单得多,只有套接字的创建和请求连接:
客户端的请求连接就是创建客户端套接字后向服务器端发起的连接请求。发起连接请求的函数是 connect 函数。
include<sys/socket.h>
int connect(int sock, struct sockaddr * servaddr, socklen_t addrlen)
/*
成功返回0,失败返回-1
sock:客户端套接字文件描述符
servaddr:保存目标服务器端地址信息的变量的地址值
addrlen:以字节为单位传递已传递给第二个结构体参数 servaddr 的地址变量长度
*/
一旦当 connect 函数发生调用后,只有以下两种情况之一才会返回(完成函数调用):
- 服务器端接收连接请求
- 发生断网等异常情况而中断连接请求
接收连接是指的是服务器端把连接请求信息记录到等待队列。所以说,connect 函数返回后并不会立即进行数据交换。
4.2.6 回顾 Hello world 客户端
//hello_client.c
#include<stdio.h>
#include<stdlib.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
#include<arpa/inet.h>
#include<sys/socket.h>
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
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 <port>\n", argv[0]);
exit(1);
}
//创建准备连接服务器端的套接字,此时创建的是 TCP 套接字
sock = socket(PF_INET, SOCK_STREAM, 0);
if(sock == -1)
{
error_handling("socket() error");
}
//在结构体变量 serv_addr 中初始化 IP 和端口号信息。初始化的值为目标服务器套接字的 IP 和端口号信息
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]));
//调用 connect 函数向服务器端发送连接请求
if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
{
error_handling("connect() error");
}
//调用完成后,接受服务器端传输的数据
str_len = read(sock, message, sizeof(message)-1);
if(str_len == -1)
{
error_handling("read() error");
}
//接收完数据后调用 close 关闭套接字,结束与服务器端的连接
printf("Message from server : %s \n", message);
close(sock);
return 0;
}
4.2.7 基于 TCP 的服务器端/客户端函数调用关系
有了上面的理解,我们可以知道其实两者并非相互独立,而是互相有交互的。
复习一下:
服务器端创建套接字后连续调用 bind 函数、listen 函数进入等待状态,客户端通过 connect 函数发起连接请求。客户端只能等到服务器端调用 listen 函数才能调用 connect 函数。 当然,客户端调用 connect 函数前,服务器端有可能率先调用 accept 函数,这个时候,服务器端会进入阻塞状态,知道客户端调用 connect 函数为止。
4.3 实现迭代服务器端/客户端
这一部分编写回声(echo)服务器端/客户端。也就是服务器端将客户端传输的字符串数据原封不动的传回给客户端,就像回声一样。
4.3.1 实现迭代服务器端
之前的服务器端在处理完一个请求之后就退出了,导致请求连接队列没有发挥作用。 如果想继续受理后续的客户端连接请求,最简单的就是加一个循环语句不断地调用 accept 函数。
调用 accept 函数后,就是调用 I/O 相关的 read/write 函数,然后调用 close 函数。当然这是针对的 accetp 函数调用时创建的套接字。
调用了 close 函数就意味着针对某一个客户端的服务结束了。此时如果还想服务其他客户端,就必须重新调用 accept 函数。
在后面使用多线程多进程的时候,就能够同时服务于多个客户端了。
4.3.2 迭代回声服务器端/客户端
下面来创建迭代回声服务器端和与其配套的回声客户端。
程序的基本运行方式:
- 服务器端在同一时刻只与一个客户端相连,并提供回声服务
- 服务器端依次向 5 个客户端通提供服务并退出
- 客户端接收用户输入的字符串并发送到服务器端
- 服务器端将接受的字符串数据传回客户端,即“回声”
- 服务器端与客户端之间的字符串一直执行到客户端输入 Q 为止
//eoch_server.c
#include<stdio.h>
#include<stdlib.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
#include<arpa/inet.h>
#include<sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
int main(int argc, char *argv[])
{
int serv_sock;
int clin_sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_addr;
struct sockaddr_in clin_addr;
socklen_t clin_addr_size;
if(argc!=2)
{
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
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)
{
error_handling("bind() error");
}
if(listen(serv_sock, 5) == -1)
{
error_handling("listen() error");
}
for(int i=0;i<5;i++) //共处理了 5 个客户端请求
{
clin_addr_size = sizeof(clin_addr);
clin_sock = accept(serv_sock, (struct sockaddr*)&clin_addr, &clin_addr_size);
if(clin_sock == -1)
{
error_handling("accept() error");
}
else
{
printf("Connected client %d \n",i+1);
}
//完成回声服务的代码,原封不动的传输读取的字符串
while((str_len = read(clin_sock, message, BUF_SIZE))!=0)
{
write(clin_sock, message, str_len);
}
//当客户端调用了 close 函数的时候,这个循环条件就会变成假,就会执行下面的 close 函数
close(clin_sock);
}
close(serv_sock);
return 0;
}
回声客户端代码:
#include<stdio.h>
#include<stdlib.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
#include<arpa/inet.h>
#include<sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
int main(int argc, char *argv[])
{
int sock;
struct sockaddr_in serv_addr;
char message[BUF_SIZE];
int str_len;
if(argc!=3)
{
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
sock = socket(PF_INET, SOCK_STREAM, 0);
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]));
//调用 connect 函数,如果连接请求被注册到服务器端的连接请求等待队列,connect 函数会正常返回,并输出 Connecting......。如果服务器端还没有调用 accept 函数,此时也没有真正的建立服务关系
if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
{
error_handling("connect() error");
}
else
{
puts("Connecting......");
}
while(1)
{
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);
if(!strcmp(message,"q\n")||!strcmp(message,"Q\n")) break;
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE-1);
message[str_len] = 0;
printf("Message from server: %s",message);
}
//调用 close 函数向相应套接字发送 EOF(意味着连接中断)
close(sock);
return 0;
}
4.3.3 回声客户端存在的问题
以上的操作都有一个假设:每次调用 read、write 函数的时候都会以字符串为单位执行 I/O 操作。
但是 TCP 连接不存在数据边界,也就是说多次调用的 write 函数传递的字符串有可能一次性传递到服务器端。
服务器端还有如下情况: "字符串太长,需要分两个包发送!"
服务端希望通过调用 1 次 write 函数传输数据,但是如果数据太大,操作系统就有可能把数据分成多个数据包发送到客户端。另外,在此过程中,客户端可能在尚未收到全部数据包时就调用 read 函数。
在第五章会有解决办法。
5 基于 TCP 的服务端/客户端(2)
上一章只讲了实现方式,这一章具体讲解 TCP 必要的理论知识和解决第四章遗留的问题。
5.1 回声客户端的完美实现
5.1.1 回声客户端问题解决办法
我们提前确定接受数据的大小,然后固定接收这个大小的数据即可。
下面是更改的代码片段:
str_len = write(sock, message, strlen(massage));
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;
}
message[recv_len]=0;
修改后的代码可以循环调用 read 函数以保证能接收所有数据。
5.1.2 如果问题不在回声客户端:定义应用层协议
一般情况下,很难预知接收的数据长度,那么此时就需要应用层协议的定义。在之前的回声服务器端/客户端就定义了“收到 Q 就终止连接。”
同理,收发数据的过程中也需要定义好规则(协议)来表示数据的边界,或者提前告知收发数据的大小。服务器端/客户端实现过程中逐步定义的这些规则集合就是应用层协议。 因此,其实应用层协议就是为特定程序的实现而制定的规则。
5.1.3 额外补充一些知识点
fputs 函数
如果想要将内容打印到计算机显示器上,就可以将第二个参数设置为 stdout:
fputs("hello world",stdout);
fgetc 函数
这个函数可以从指定文件流中读取一个字符。而 fgetc (stdin) 会等待用户在键盘上输入一个字符,然后返回。如果读取成功会返回该字符的 ASCLL 码,如果读取失败就会返回 EOF。
未完待续...
5.2 TCP 原理
5.2.1 TCP 套接字的 I/O 缓冲
前面说,TCP 套接字的收发没有边界。哪怕服务器端调用一次 write 函数传输了 40 个字节的数据,客户端也可以通过调用四次 read 函数每次读取 10 个字节。
但是当客户端接收 10 字节后,剩下的 30 个字节在哪呢?
事实上,write 函数调用后并不是立即传输数据,read 函数调用后也不是立即接收数据。更准确的说,在调用 write 函数瞬间,数据将移动至输出缓冲;read 函数调用瞬间,从缓冲中读取数据。
如图所示,当调用 write 函数的时候,数据会移动到输出缓冲 data 中,然后 data 数据会在某一时间不管是一次性还是分批地传向对方的输入缓冲 data 中,这个时候对方就可以使用 read 函数从输入缓冲当中读取数据。
I/O 缓冲的特性:
- I/O 缓冲在每一个 TCP 套接字中单独存在
- I/O 缓冲在创建套接字的时候自动生成
- 就算套接字关闭了,也会继续传递输出缓冲中遗留的数据
- 关闭套接字将丢失输入缓冲中的数据
如果客户端输入缓冲为 50 字节,但是服务器端的输出缓冲为 100 字节,那么会出现什么问题?
答案是不会出现这样的问题。不会发生超过输入缓冲大小的数据传输。 因为 TCP 会控制数据流。TCP 中有滑动窗口(Sliding Window)协议。
例如:
需要注意的是:write 函数和 send 函数并不是在完成向对方主机的数据传输的时候返回,而是在数据移动到输出缓冲的时候就返回了。因为后续对输出缓冲的数据的传输是由 TCP 来保证的。
5.2.2 TCP 内部工作原理 1:与对方套接字的连接
TCP 套接字的创建到消失分为 3 步:
- 与对方套接字建立连接
- 与对方套接字进行数据交换
- 断开与对方套接字的连接
第一步,与对方套接字建立连接,连接过程的大致对话如下:
TCP 在实际通信的过程中也会有 3 次对话过程。所以说这个过程又被成称之为 三次握手(Three-way handshaking) 。
实际连接过程中交换的信息格式如下图:
套接字是以双全工(Full-duplex)方式工作的。也就是说,它可以双向传递数据。所以收发数据之前需要做一些准备。
首先主机 A 向主机 B 传递的信息为:[SYN] SEQ: 1000, ACK: -
这个消息中 SEQ 为 1000,ACK 为空。SEQ 为 1000 的含义是:“现在传递的数据包的序号为 1000,如果接受无误,请通知我向您传递 1001 号数据包。“
这个是首次请求连接的时候使用的消息,被称为
SYN (Synchronization),表示收发数据前传输的同步消息。
接下来主机 B 向主机 A 传递消息:[SYN+ACK] SEQ: 2000, ACK: 1001
SEQ 为 2000 的含义是:“现在传递的数据包序号为 2000,如果接受无误,请通知我向您传递 2001 号数据包”。
ACK 为 1001 的含义是:“刚才传输的 SEQ 为 1000 的数据包接收无误,现在请传递 SEQ 为 1001 的数据包”。
对主机 A 首次传输的数据包的确认消息
ACK1001和为主机 B 传输数据做准备的同步消息SEQ 2000捆绑发送。所以这种消息被称之为SYN+ACK。收发数据前向数据包分配序号,并向对方通报此序号,这些都是为了防止数据丢失所作的准备。通过向数据包分配序号确认,可以在丢失数据的时候马上查看并重传丢失的数据包。 所以 TCP 可以保证可靠的数据传输。
最后主机 A 向主机 B 传输的消息:[ACK] SEQ: 1001, ACK: 2001
该数据包传递如下信息:“已经正确收到传输的 SEQ 为 2000 的数据包,现在可以传输 SEQ 为 2001 的数据包”。
至此,主机 A 和组主机 B 确认了彼此均就绪。
5.2.3 TCP 内部工作原理 2:与对方主机的数据交换
在第一步完成了三次握手后,下面就开始收发数据。
上述图片是主机 A 向主机 B 分两个数据包传递 200 字节的过程。
首先,主机 A 通过一个数据包发送了 100 个字节的数据,数据包的 SEQ 为 1200。主机 B 为了确认这一点,向主机 A 发送了 ACK 1301 的消息。
为什么 ACK 是 1301 而不是 1201,这是因为 ACK 的增量为传输的数据字节数。
如果每次 ACK 号不加传输的字节数,那么虽然能够确认数据包的传输,但是无法确认是否传到了 100 个字节。
因此按照这个公式来传递 ACK 消息:ACK号 = SEQ号 + 传递的字节数 + 1
最后的加一是为了告知对方下次要传递的 SEQ 号。
如果在传输过程中数据包发生了丢失的情况:
通过 SEQ 1301 数据包向主机 B 传输数据的时候中间发生了错误,主机 B 并未收到。在经过一段时间后,主机 A 还是没有收到对于 SEQ 1301 的确认,就会试着重传该数据包。
为了完成数据包重传,TCP 套接字启动计时器以等待 ACK 应答。如果响应计时器发生超时(Time-out)就重传。
5.2.4 TCP 内部工作原理 3:断开与对方套接字的连接
TCP 的套接字结束过程也十分的优雅。如果对方还有数据需要传输的时候断掉连接会出现问题,所以断开连接的时候需要双方进行协商。
对话如下:
先由套接字 A 向套接字 B 传递断开连接的消息,套接字 B 发出确认收到的消息,然后向套接字 A 传递可以断开连接的消息,套接字 A 此时同样发出确认消息。
FIN 就表示断开连接。也就是说双方各发送一次 FIN 消息后断开连接。这个过程有四个阶段,也称之为 四次挥手(Four-way handshaking) 。
图中向主机 A 传递了两次 ACK 5001,其实第二次 FIN 数据包中的 ACK 5001 是因为接收 ACK 消息后未接受数据而重传的。
5.4 习题
- 请说明 TCP 套接字连接设置的三次握手过程。尤其是 3 次数据交换过程每次收发的数据内容。
点击查看答案
TCP 套接字连接设置的三次握手过程是指建立一个 TCP 连接时,需要客户端和服务器总共发送 3 个报文。三次握手的目的是连接服务器指定端口,建立 TCP 连接,并同步连接双方的序列号和确认号,交换 TCP 窗口大小信息。三次握手阶段:
- TCP 是可靠的数据传输协议,但在通过网络通信的过程中可能丢失数据。请通过 ACK 和 SEQ 说明 TCP 通过和何种机制保证丢失数据的可靠传输。
点击查看答案
接收方每收到一个包,就向发送方返回一个 ACK ,表示自己已经收到了这段数据,反过来,如果发送方一段时间内没有收到 ACK,就知道很可能是数据包丢失了,紧接着就重发该数据包,直到收到 ACK 为止。
- TCP 套接字中调用 write 和 read 函数时数据如何移动?结合 I/O 缓冲进行说明。
点击查看答案
TCP 套接字调用 write 函数时,数据将移至输出缓冲,在适当的时候,传输到对方的输入缓冲。这时对方将调用 read 函数从输入缓冲中读取数据。
- 对方主机的输入缓冲剩余 50 字节空间时,若本主机通过 write 函数请求传输 70 字节,请问 TCP 如何处理这种情况?
点击查看答案
TCP 有滑动窗口协议。当调用 write 函数时,数据将移至输出缓冲,在适当的时候,传到对方输入缓冲。这时对方将调用 read 函数从输入缓冲中读取数据。这个过程确保了数据能够安全、可靠地传输。
5 和 6 题暂时写不出来;
点击查看题目
这是第五题和第六题: