TCP/IP网络编程读书笔记
- 第1章 理解网络编程和套接字
- 第2章 套接字类型与协议设置
- 第3章 地址族与数据序列
- 第4章 基于TCP的服务端/客户端(1)
- 第5章 基于TCP的服务器端/客户端(2)
- 第6章 基于UDP的服务器端/客户端
第1章 理解网络编程和套接字
1.1 理解网络编程和套接字
1.1.1 构建打电话套接字
以电话机打电话的方式来理解套接字。
**调用 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
调用 bind 函数给套接字分配地址之后,就基本完成了所有的准备工作。接下来是需要连接电话线并等待来电。
调用 listen 函数(连接电话线)时进行的对话: 一连接电话线,电话机就可以转换为可接听状态,这时其他人可以拨打电话请求连接到该机。同样,需要把套接字转化成可接受连接状态。
#include <sys/socket.h>
int listen(int sockfd, int backlog);
//成功时返回0,失败时返回-1
连接好电话线以后,如果有人拨打电话就响铃,拿起话筒才能接听电话。
调用 accept 函数(拿起话筒)时进行的对话:
#include <sys/socket.h>
int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen);
//成功时返回文件描述符,失败时返回-1
网络编程中和接受连接请求的套接字创建过程可整理如下:
- 第一步:调用 socket 函数创建套接字。
- 第二步:调用 bind 函数分配IP地址和端口号。
- 第三步:调用 listen 函数转换为可接受请求状态。
- 第四步:调用 accept 函数受理套接字请求。
1.1.2 编写 Hello World 套接字程序
服务端:
服务器端(server)是能够受理连接请求的程序。下面构建服务端以验证之前提到的函数调用过程,该服务器端收到连接请求后向请求者返回 Hello World! 答复。除各种函数的调用顺序外,我们还未涉及任何实际编程。因此,阅读代码时请重点关注套接字相关的函数调用过程,不必理解全过程。
代码如下:
#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);
}
//调用 socket 函数创建套接字
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]));
//调用 bind 函数分配ip地址和端口号
if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error");
//调用 listen 函数将套接字转为可接受连接状态
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
clnt_addr_size = sizeof(clnt_addr);
//调用 accept 函数受理连接请求。如果在没有连接请求的情况下调用该函数,则不会返回,直到有连接请求为止
clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &clnt_addr_size);
if (clnt_sock == -1)
error_handling("accept() error");
//稍后要将介绍的 write 函数用于传输数据,若程序经过 accept 这一行执行到本行,则说明已经有了连接请求
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);
}
客户端:
客户端程序只有 调用 socket 函数创建套接字 和 调用 connect 函数向服务端发送连接请求 这两个步骤,下面给出客户端,需要查看以下两方面的内容:
- 调用 socket 函数 和 connect 函数;
- 与服务端共同运行以收发字符串数据。
代码如下:
#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);
}
//创建套接字,此时套接字并不马上分为服务端和客户端。如果紧接着调用 bind,listen 函数,将成为服务器套接字
//如果调用 connect 函数,将成为客户端套接字
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 函数向服务器发送连接请求
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!");
printf("Message from server : %s \n", message);
close(sock);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译:
分别对客户端和服务端程序进行编译:
gcc hello_server.c -o hserver
gcc hello_client.c -o hclient
运行:
./hserver 9190
./hclient 127.0.0.1 9190
运行的时候,首先再 9190 端口启动服务,然后 hserver 就会一直等待客户端进行响应,当客户端监听位于本地的 IP 为 127.0.0.1 的地址的9190端口时,客户端就会收到服务端的回应,输出 Hello World!
1.2 基于Linux的文件操作
在 Linux 世界里,socket 也被认为是文件的一种,因此在网络数据传输过程中自然可以使用 I/O 的相关函数。所以需要了解Linux中的文件操作。而windows则是区分socket和文件的。
1.2.1 底层访问和文件描述符
分配给标准输入输出及标准错误的文件描述符。
文件描述符 | 对象 |
---|---|
0 | 标准输入:Standard Input |
1 | 标准输出:Standard Output |
2 | 标准错误:Standard Error |
文件和套接字一般经过创建过程才会被分配文件描述符。
文件描述符也被称为「文件句柄」,但是「句柄」主要是 Windows 中的术语。因此,在本书中如果涉及 Windows 平台将使用「句柄」,如果是 Linux 将使用「描述符」。
1.2.2 打开文件
#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 | 读写打开 |
flag
如果需要多个模式组合,可以通过位运算符|
组合并传递。
1.2.3 关闭文件
#include <unistd.h>
int close(int fd);
// fd表示需要关闭的文件或套接字的文件描述符
// 成功时返回0,失败时返回-1
1.2.4 将数据写入文件
接下来介绍的write函数用于向文件输出(传输)数据。当然,Linux中不区分文件与套接字,因此,通过套接字向其他计算机传递数据时也会用到该函数。之前的示例也调用它传递字符串“Hello World!”。
#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 类型。
示例: 创建文件并保存数据。
// low_open.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
void error_handling(char* message);
int main(void) {
int fd;
char buf[] = "Let's go!\n";
fd = open("data.txt", O_CREAT|O_WRONLY|O_TRUNC);
if (fd == -1) error_handling("open() error!");
printf("file descriptor: % d \n", fd);
if (write(fd, buf, sizeof(buf)) == -1) error_handling("write() error!");
close(fd);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行
gcc low_open.c -o lopen
./lopen
然后会生成一个 data.txt 的文件,里面有 Let’s go!
cat data.txt
可以打印txt的内容,其中cat
是Linux指令,用于连接文件并打印到标准输出设备上。
上面的printf("file descriptor: % d \n", fd);
的结果是3,以下就是原因:
在Unix和Linux系统中,当你使用open()函数打开一个文件时,它会返回一个文件描述符(file descriptor),这个文件描述符是一个非负整数,用于标识该文件在进程中的位置。通常情况下,第一个被打开的文件描述符是3,因为前三个文件描述符(0、1、2)已经被标准输入、标准输出和标准错误输出占用了。因此,当你使用open()函数打开一个文件时,如果前三个文件描述符没有被占用,那么这个文件描述符就是3。如果前三个文件描述符已经被占用,那么这个文件描述符就会是4或更大的数字。在你的代码中,如果前三个文件描述符没有被占用,那么open()函数返回的文件描述符就是3,因为这是第一个可用的文件描述符。所以,当你打印文件描述符的值时,它会输出3。如果再打开一个文件的话,文件描述符就是4。
1.2.5 读取文件中的数据
与之前的 write() 函数相对应, read() 用来输入(接收)数据。
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
/**
成功时返回接收的字节数(但遇到文件结尾则返回 0),失败时返回 -1
fd : 显示数据接收对象的文件描述符
buf : 要保存接收的数据的缓冲地址值。
nbytes : 要接收数据的最大字节数
**/
示例: 通过 read() 函数读取 data.txt 中保存的数据。
// low_read.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#define BUF_SIZE 100
void error_handling(char* message);
int main() {
int fd;
char buf[BUF_SIZE];
fd = open("data.txt", O_RDONLY);
if (fd == -1) error_handling("open() error!");
printf("file descriptor: %d \n", fd);
if (read(fd, buf, sizeof(buf)) == -1) error_handling("read() error!");
printf("file data: %s", buf);
close(fd);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行
gcc low_read.c -o lread
./lread
在上一步的 data.txt 文件与没有删的情况下,会输出:
file descriptor: 3
file data: Let's go!
关于文件描述符的 I/O 操作到此结束,要明白,这些内容同样适合于套接字。
1.2.6 文件描述符和套接字
下面将同时创建文件和套接字,并用整数型态比较返回的文件描述符的值。
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/socket.h>
int main() {
int fd1, fd2, fd3;
fd1 = socket(PF_INET, SOCK_STREAM, 0);
fd2 = open("test.dat", O_CREAT|O_WRONLY|O_TRUNC);
fd3 = socket(PF_INET, SOCK_DGRAM, 0);
printf("file descriptor 1: %d \n", fd1);
printf("file descriptor 2: %d \n", fd2);
printf("file descriptor 3: %d \n", fd3);
close(fd1); close(fd2); close(fd3);
return 0;
}
编译运行:
gcc fd_seri.c -o fds
./fds
输出结果:
file descriptor 1: 3
file descriptor 2: 4
file descriptor 3: 5
从输出的文件描述符整数值可以看出,描述符从3开始以由小到大的顺序编号( numbering ),因为0、1、2是分配给标准I/O的描述符,这个在之前已经解释过了。
1.3 基于Windows平台的实现
这部分内容先略过,我们的重点是Linux网络编程,而不是windows下面的。有时间再看,没时间就算了。
1.5 习题
-
套接字在网络编程中的作用是什么?为何称它为套接字?
答: 操作系统会提供「套接字」(socket)的部件,套接字是网络数据传输用的软件设备。因此,「网络编程」也叫「套接字编程」。「套接字」就是用来连接网络的工具。 -
在服务器端创建套接字以后,会依次调用 listen 函数和 accept 函数。请比较二者作用。
答: 调用 listen 函数将套接字转换成可受连接状态(监听),调用 accept 函数受理连接请求。如果在没有连接请求的情况下调用该函数,则不会返回,直到有连接请求为止。 -
Linux 中,对套接字数据进行 I/O 时可以直接使用文件 I/O 相关函数;而在 Windows 中则不可以。原因为何?
答: 在Linux中,套接字(Socket)被视为一种特殊的文件类型,因此可以使用文件I/O相关函数(如read()、write()、select()等)来进行套接字数据的读写操作。这种设计思想被称为“一切皆文件(Everything is a file)”。
在Windows中,套接字不被视为一种文件类型,因此不能直接使用文件I/O相关函数来进行套接字数据的读写操作。相反,Windows提供了一组专门的套接字API,如recv()、send()、WSARecv()、WSASend()等,用于进行套接字数据的读写操作。
这种差异的原因是因为Linux和Windows的设计思想不同。Linux的设计思想是将所有的资源都视为文件,包括硬件设备、网络连接等,这种设计思想使得Linux系统非常灵活和可扩展。而Windows的设计思想则更加注重易用性和兼容性,因此Windows提供了一组专门的套接字API,使得开发者可以更方便地进行套接字编程。 -
创建套接字后一般会给他分配地址,为什么?为了完成地址分配需要调用哪个函数?
答: 套接字被创建之后,只有为其分配了IP地址和端口号后,客户端才能够通过IP地址及端口号与服务器端建立连接,需要调用 bind 函数来完成地址分配。 -
Linux 中的文件描述符与 Windows 的句柄实际上非常类似。请以套接字为对象说明它们的含义。
答: 暂略。 -
底层 I/O 函数与 ANSI 标准定义的文件 I/O 函数有何区别?
答: 文件 I/O 又称为低级磁盘 I/O,遵循 POSIX 相关标准。任何兼容 POSIX 标准的操作系统上都支持文件I/O。标准 I/O 被称为高级磁盘 I/O,遵循 ANSI C 相关标准。只要开发环境中有标准 I/O 库,标准 I/O 就可以使用。(Linux 中使用的是 GLIBC,它是标准C库的超集。不仅包含 ANSI C 中定义的函数,还包括 POSIX 标准中定义的函数。因此,Linux 下既可以使用标准 I/O,也可以使用文件 I/O)。 -
参考本书给出的示例 low_open.c 和 low_read.c ,分别利用底层文件 I/O 和 ANSI 标准 I/O 编写文件复制程序。可任意指定复制程序的使用方法。
答: 暂略。
第2章 套接字类型与协议设置
2.1 套接字协议及其传输特性
2.1.1 关于协议(Protocol)
简言之,协议就是为了完成数据交换而定好的约定。
2.1.2 创建套接字
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
/*
成功时返回文件描述符,失败时返回-1,
domian 套接字中使用的协议族(Protocol Family)信息,
type 套接字数据传输类型信息,
protocol 计算机间通信中使用的协议信息。
*/
下面对这些参数给出详细说明。
2.1.3 协议族(Protocol Family)
通过 socket 函数的第一个参数传递套接字中使用的协议分类信息。此协议分类信息称为协议族,可分成如下几类:
头文件 sys/socket.h 中声明的协议族
名称 | 协议族 |
---|---|
PF_INET | IPV4 互联网协议族 |
PF_INET6 | IPV6 互联网协议族 |
PF_LOCAL | 本地通信 Unix 协议族 |
F_PACKET | 底层套接字的协议族 |
PF_IPX | IPX Novel 协议族 |
本书着重讲 PF_INET 对应的 IPV4 互联网协议族。其他协议并不常用,或并未普及。另外,套接字中采用的最终的协议信息是通过 socket 函数的第三个参数传递的。在指定的协议族范围内通过第一个参数决定第三个参数。
2.1.4 套接字类型(Type)
套接字类型指的是套接字的数据传输方式,是通过 socket 函数的第二个参数进行传递,只有这样才能决定创建的套接字的数据传输方式。已经通过第一个参数传递了协议族信息,为什么还要决定数据传输方式?问题就在于,决定了协议族并不能同时决定数据传输方式。换言之, socket 函数的第一个参数PF_INET 协议族中也存在多种数据传输方式。
下面介绍两种具有代表性的数据传输方式,这是理解套接字的基础。
2.1.5 套接字类型1:面向连接的套接字(SOCK_STREAM)
如果 socket 函数的第二个参数传递 SOCK_STREAM ,将创建面向连接的套接字。
传输方式特征整理如下:
- 传输过程中数据不会丢失;
- 按序传输数据;
- 传输的数据不存在数据边界(Boundary)。
如何理解传输的数据不存在数据边界:
当使用SOCK_STREAM时,数据被视为一个连续的字节流,而不是被分割成独立的数据包。这意味着,发送方可以将任意数量的字节写入套接字,而接收方可以从套接字读取任意数量的字节,而无需考虑这些字节在原始数据中的边界。(接收方不用考虑接收的字节在原始数据中的边界,只能根据读取的字节数来确定何时接收到了完整的数据,这就意味着发送方和接受方需要通过其他方式来协商和传输数据的边界信息,例如使用特殊的结束标记或者长度前缀等等)
具体对于write和read函数:
收发数据的套接字内部有缓冲( buffer ),简言之就是字节数组。通过套接字传输的数据将保存到该数组。因此,收到数据并不意味着马上调用read函数。只要不超过数组容量,则有可能在数据填充满缓冲后通过1次read函数调用读取全部,也有可能分成多次read函数调用进行读取。也就是说,在面向连接的套接字中,read函数和write函数的调用次数并无太大意义。所以说面向连接的套接字不存在数据边界。稍后将给出示例以查看该特性。
知识补充:套接字缓冲已满是否意味着数据丢失?
如果read函数的读取速度比接受数据的速度慢,缓冲区可能被填满。但即使这样也不会发生数据丢失,因为传输端套接字将停止传输。也就是说,面向连接的套接字会根据接收端的状态传输数据,如果传输出错还会提供重传服务。因此,面向连接的套接字除特殊情况外不会发生数据丢失。
套接字连接必须一一对应。面向连接的套接字可总结为:
可靠地、按序传递的、基于字节的面向连接的数据传输方式的套接字。
可靠就是不会丢失数据,按序传递就是按序传输,面向字节而不是面向包就说明传输的数据不存在边界。
2.1.6 套接字类型2:面向消息的套接字(SOCK_DGRAM)
面向消息的套接字可以比喻成告诉移动的摩托车快递。特点如下:
- 强调快速传输而非顺序传输;
- 传输的数据可能丢失也可能销毁;
- 传输的数据有数据边界;
- 限制每次传输的数据大小。
众所周知,快递行业的速度就是生命。用摩托车发往同一目的地的2件包裹无需保证顺序,只要以最快速度交给客户即可。这种方式存在损坏或丢失的风险,而且包裹大小有一定限制。因此,若要传递大量包裹,则需分批发送。另外,如果用2辆摩托车分别发送2件包裹,则接收者也需要分2次接收。这种特性就是“传输的数据具有数据边界”。
存在数据边界意味着接收数据的次数和传输数据的次数应该相同,面向消息的套接字特性总结如下:
不可靠的、不按序传递的、以数据的高速传输为目的的套接字。
2.1.7 协议的最终选择
socket的第三个参数决定最终采用的协议。传递前两个参数即可创建所需套接字,所以大部分情况下可以向第三个参数传递0,除非遇到以下情况:
同一协议族中存在多个数据传输方式相同的协议
如果数据的传输方式相同,但是协议不同,此时就需要通过第三个参数具体指定协议信息。
本书用的是 Ipv4 的协议族,和面向连接的数据传输,满足这两个条件的协议只有 IPPROTO_TCP ,因此可以如下调用 socket 函数创建套接字,这种套接字称为 TCP 套接字。
int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)
SOCK_DGRAM 指的是面向消息的数据传输方式,满足上述条件的协议只有 IPPROTO_UDP 。这种套接字称为 UDP 套接字。
int udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);
2.1.8 面向连接的套接字,TCP套接字示例
需要对第一章的代码做出修改,修改好的代码如下:
hello_server.c -> tcp_server.c:无变化!
hello_client.c -> tcp_client.c:更改read的调用方式!
# tcp_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 = 0;
int idx = 0, read_len = 0;
if (argc != 3)
{
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
//创建套接字,此时套接字并不马上分为服务端和客户端。如果紧接着调用 bind,listen 函数,将成为服务器套接字
//如果调用 connect 函数,将成为客户端套接字
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 函数向服务器发送连接请求
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("connect() error!");
while (read_len = read(sock, &message[idx++], 1)) { // 每次只读取一个字节
if (read_len == -1) {
error_handling("read() error!");
}
str_len += read_len;
}
printf("Message from server : %s \n", message);
printf("Function read call count: %d \n", str_len);
close(sock);
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译:
gcc tcp_client.c -o hclient
gcc tcp_server.c -o hserver
运行:
./server 9190
./client 127.0.0.1 9190
结果:
Message from server : Hello World!
Function read call count: 13
从运行结果可以看出服务端发送了13字节的数据,客户端调用13次read 函数进行读取。
2.2 Windows平台下的实现及验证
暂略。
2.3 习题
- 什么是协议?在收发数据中定义协议有何意义?
答: 协议是对话中使用的通信规则,简言之,协议就是为了完成数据交换而定好的约定。在收发数据中定义协议,能够让计算机之间进行正确无误的对话,以此来交换数据。 - 面向连接的套接字 TCP 套接字传输特性有 3 点,请分别说明。
答: ①传输过程中数据不会消失②按序传输数据③传输的数据不存在数据边界(Boundary) - 下面那些是面向消息的套接字的特性?
传输数据可能丢失
没有数据边界(Boundary)
以快速传递为目标
不限制每次传输数据大小
与面向连接的套接字不同,不存在连接概念 - 下列数据适合用哪类套接字进行传输?
演唱会现场直播的多媒体数据(UDP)快速,但是有时候会卡
某人压缩过的文本文件(TCP)需要可靠性
网上银行用户与银行之间的数据传递(TCP)需要可靠性 - 何种类型的套接字不存在数据边界?这类套接字接收数据时应该注意什么?
答: TCP 不存在数据边界。在接收数据时,需要保证在接收套接字的缓冲区填充满之时就从buffer里读取数据。也就是,在接收套接字内部,写入buffer的速度要小于读出buffer的速度。
第3章 地址族与数据序列
第2章中讨论了套接字的创建方法,如果把套接字比喻为电话,那么目前只安装了电话机。本章将着重讲解给电话机分配号码的方法,即给套接字分配IP地址和端口号。这部分内容也相对有些枯燥,但并不难,而且是学习后续那些有趣内容必备的基础知识。
3.1 分配给套接字的IP地址与端口号
IP 是 Internet Protocol(网络协议)的简写,是为收发网络数据而分配给计算机的值。端口号并非赋予计算机的值,而是为了区分程序中创建的套接字而分配给套接字的端口号。
3.1.1 网络地址(Internet Address)
这部分知识在计算机网络中稍微有一点儿了解。
为使计算机连接到网络并收发数据,必须为其分配 IP 地址。IP 地址分为两类。
- IPv4(Internet Protocol version 4)4 字节地址族
- IPv6(Internet Protocol version 6)6 字节地址族
两者之间的主要差别是 IP 地址所用的字节数,目前通用的是 IPv4 , IPv6 的普及还需要时间。
IPV4 标准的 4 字节 IP 地址分为网络地址和主机(指计算机)地址,且分为 A、B、C、D、E 等类型。下图展示了IPv4地址族,一般不会使用已被预约的E类地址,故省略。
下图来自湖科大教书匠的ppt中的一页:
网络地址(网络ID)是为区分网络而设置的一部分IP地址。假设向WWW.SEMI.COM公司传输数据,该公司内部构建了局域网,把所有计算机连接起来。因此,首先应向SEMI.COM网络传输数据,也就是说,并非一开始就浏览所有4字节IP地址,进而找到目标主机;而是仅浏览4字节IP地址的网络地址,先把数据传到SEMI.COM的网络。SEMI.COM网络(构成网络的路由器)接收到数据后,浏览传输数据的主机地址(主机ID)并将数据传给目标计算机。图3-2展示了数据传输过程。
某主机向203.211.172.103和203.211.217.202传输数据,其中203.211.172和203.211.217为该网络的网络地址(稍后将给出网络地址的区分方法)。所以,“向相应网络传输数据”实际上是向构成网络的路由器(Router)或交换机( Switch)传递数据,由接收数据的路由器根据数据中的主机地址向目标主机传递数据。
3.1.2 网络地址分类与主机地址边界
只需通过IP地址的第一个字节即可判断网络地址占用的总字节数,因为我们根据IP地址的边界区分网络地址,如下所示:
- A 类地址的首字节范围为:0~127
- B 类地址的首字节范围为:128~191
- C 类地址的首字节范围为:192~223
还有如下这种表示方式:
- A 类地址的首位以 0 开始
- B 类地址的前2位以 10 开始
- C 类地址的前3位以 110 开始
因此套接字收发数据时,数据传到网络后即可轻松找到正确的主机。
3.1.3 用于区分套接字的端口号
IP地址用于区分计算机,只要有IP地址就能向目标主机传输数据,但是只有这些还不够,我们需要把信息传输给具体的应用程序。(如果一个计算机同时存在多个需要套接字的应用程序,怎么区分这些套接字?)
所以计算机一般有 NIC(网络接口卡)数据传输设备。通过 NIC 接受的数据内有端口号,操作系统参考端口号把信息传给相应的应用程序。(说白了就是使用端口号来解决这个问题)
端口号就是在同一操作系统内为区分不同套接字而设置的,因此无法将1个端口号分配给不同套接字。另外,端口号由16位构成,可分配的端口号范围是0-65535。但0-1023是知名端口( Well-known PORT ),一般分配给特定应用程序,所以应当分配此范围之外的值。另外,虽然端口号不能重复,但TCP套接字和UDP套接字不会共用端口号,所以允许重复。
例如:如果某TCP套接字使用9190号端口,则其他TCP套接字就无法使用该端口号,但UDP套接字可以使用。
总之,数据传输目标地址同时包含IP地址和端口号,只有这样,数据才会被传输到最终的目的应用程序(应用程序套接字)。
3.2 地址信息的表示
应用程序中使用的IP地址和端口号以结构体的形式给出了定义。本节围绕结构体讨论目标地址的表示方法。
3.2.1 表示IPv4地址的结构体
结构体定义如下:
struct sockaddr_in {
sa_family_t sin_family; // 地址族(Address Family)
uint16_t sin_port; // 16位TCP/UDP端口号
struct in_addr sin_addr; // 32位IP地址
char size_zero[8]; // 不使用
};
其中in_addr
用来存放32位IP地址。
struct in_addr {
In_addr_t s_addr; // 32位IPv4地址
};
关于以上两个结构体的一些数据类型。
为什么要额外定义这些数据类型呢?这是考虑扩展性的结果。
3.2.2 结构体scokaddr_in的成员分析
- 成员sin_family
每种协议适用的地址族不同,比如,IPV4 使用 4 字节的地址族,IPV6 使用 16 字节的地址族。
地址族(Address Family) | 含义 |
---|---|
AF_INET | IPv4用的地址族 |
AF_INET6 | IPv6用的地址族 |
AF_LOCAL | 本地通信中采用的 Unix 协议的地址族 |
AF_LOACL 只是为了说明具有多种地址族而添加的。
- 成员 sin_port
该成员保存16位端口号,重点在于,它以网络字节序保存(关于这一点稍后将给出详细说明)。 - 成员 sin_addr
该成员保存32位IP地址信息,且也以网络字节序保存。为理解好该成员,应同时观察结构体in_addr。但结构体in_addr声明为uint32_t,因此只需当作32位整数型即可。 - 成员 sin_zero
无特殊含义。只是为结构体 sockaddr_in 结构体变量地址值将以如下方式传递给 bind 函数。
从之前介绍的代码也可看出,sockaddr_in结构体变量地址值将以如下方式传递给bind函数,稍后将给出关于bind函数的详细说明。
struct sockaddr_in serv_addr;
....
if (bind(serv_sock, (struct sockaddr *) &serv_addr, sizeof(serv_addr) == -1)
error_handling("bind() error");
....
此处重要的是第二个参数的传递。实际上,bind函数的第二个参数期望得到sockaddr结构体变量地址值,包括地址族、端口号、IP地址等。从下列代码也可看出,直接向sockaddr结构体填充这些信息会带来麻烦。
struct sockaddr
{
sa_family_t sin_family; //地址族
char sa_data[14]; //地址信息
}
一句话总结: 直接向sockaddr结构体填充信息比较麻烦,所以出现了sockaddr_in,然后将其强制转换位sockaddr型的结构变量,再传递给bind函数即可。
sockaddr_in是保存IPv4地址信息的结构体。**那为何还需要通过sin_family单独指定地址族信息呢?**这与之前讲过的sockaddr结构体有关。结构体sockaddr并非只为IPv4设计,这从保存地址信息的数组sa _data长度为14字节也可看出。因此,结构体sockaddr要求在sin_family中指定地址族信息。为了与sockaddr保持一致,sockaddr_in结构体中也有地址族信息。
3.3 网络字节序与地址变换
不同的 CPU 中,4 字节整数值1在内存空间保存方式是不同的。
有些 CPU 这样保存:00000000 00000000 00000000 00000001
有的 CPU 这样保存:00000001 00000000 00000000 00000000
两种一种是顺序保存,一种是倒序保存 ,如果不考虑这些就收发数据则会发生问题,因为保存顺序的不同意味着对接收数据的解析顺序也不同。
3.3.1 字节序(Order)与网络字节序
CPU 保存数据的方式有两种,这意味着 CPU 解析数据的方式也有 2 种:
- 大端序(Big Endian):高位字节存放到低位地址
- 小端序(Little Endian):高位字节存放到高位地址
假设在0x20号开始的地址中保存4字节int类型数0x12345678。大端序CPU保存方式如下所示:
小端序CPU保存方式如下所示:
从以上分析可以看出,每种CPU的数据保存方式均不同。因此,代表CPU数据保存方式的主机字节序(Host Byte Order)在不同CPU中也各不相同。目前主流的Intel系列CPU以小端序方式保存数据。接下来分析2台字节序不同的计算机之间数据传递过程中可能出现的问题,如图3-6所示。
因为这种原因,所以在通过网络传输数据时必须约定统一的方式,这种约定被称为网络字节序,非常简单,统一为大端序。即,先把数据数组转化成大端序格式再进行网络传输。因此,所有计算机接收数据时应识别该数据时网络字节序格式,小端序系统传输数据时应转化为大端序排列方式。
3.3.2 字节序转换
帮助转换字节序的函数:
unsigned short htons(unsigned short);
unsigned short ntohs(unsigned short);
unsigned long htonl(unsigned long);
unsigned long ntohl(unsigned long);
通过函数名称掌握其功能,只需要了解:
- htons 的 h 代表主机(host)字节序;
- htons 的 n 代表网络(network)字节序;
- s 代表 short;
- l 代表 long(Linux中long类型占用4个字节,这很关键)。
因此,htons表示”把short型数据从主句字节序转化为网络字节序“。
通常,以s作为后缀的函数中,s代表2个字节short,因此用于端口号转换;以l作为后缀的函数中,l代表4个字节,因此用于IP地址转换。有必要编写与大端序无关的统一代码,即使我的系统是大端序的,最好也有转化的过程。
下面的代码是示例,说明以上函数调用过程。
// endian_conv.c
#include <stdio.h>
#include <arpa/inet.h>
int main(int argc, char *argv[]) {
unsigned short host_port = 0x1234;
unsigned short net_port;
unsigned long host_addr = 0x12345678;
unsigned long net_addr;
net_port = htons(host_port); //转换为网络字节序=
net_addr = htonl(host_addr);
printf("Host ordered port: %#x \n", host_port);
printf("Network ordered port: %#x \n", net_port);
printf("Host ordered address: %#lx \n", host_addr);
printf("Network ordered address: %#lx \n", net_addr);
return 0;
}
编译运行:
gcc endian_conv.c -o conv
./conv
结果:
Host ordered port: 0x1234
Network ordered port: 0x3412
Host ordered address: 0x12345678
Network ordered address: 0x78563412
这是在小端 CPU 的运行结果。大部分人会得到相同的结果,因为 Intel 和 AMD 的 CPU 都是小端序为标准。
上述这种手动的转换都是自动进行的,不需要程序员每一步都手动转换。
3.4 网络地址的初始化与分配
3.4.1 将字符串信息转换为网络字节序的整型数
对于IP地址的表示,我们熟悉的是点分十进制表示法(Dotted Decimal Notation ),而非整数型数据表示法。幸运的是,有个函数会帮我们将字符串形式的IP地址转换成32位整数型数据。此函数在转换类型的同时进行网络字节序转换。(既将字符串转为32位整型,同时进行了网络字节序的转换)
#include <arpa/inet.h>
in_addr_t inet_addr(const char *string);
/*
成功时返回32位打算序整数型值,时报时返回INADDR_NONE
*/
具体示例:
这个例子我使用cpp代码来实现,其实本质上和c的没什么区别:
#include <stdio.h>
#include <iostream>
using namespace std;
#include <arpa/inet.h>
int main(int agrc, char *argv[]) {
const char *addr1 = "1.2.3.4";
const char *addr2 = "1.2.3.256"; // ip的最大不能超过255,这个时无效的ip地址
unsigned long conv_addr = inet_addr(addr1);
if (conv_addr == INADDR_NONE) cout << "Error occured!" << endl;
else cout << hex << conv_addr << endl;
conv_addr = inet_addr(addr2);
if (conv_addr == INADDR_NONE) cout << "Error occured!" << endl;
else cout << hex << conv_addr << endl;
return 0;
}
编译运行:
g++ inet_addr.cpp -o addr
./addr
// 注意cpp的编译使用的是g++
输出:
Network ordered integer addr: 4030201 // 大端序
Error occured!
从运行结果可以看出,inet_addr函数不仅可以把IP地址转成32位整数型,而且可以检测无效的IP地址。另外,从输出结果可以验证确实转换为网络字节序。
inet_aton函数与inet _addr函数在功能上完全相同,也将字符串形式IP地址转换为32位网络字节序整数并返回。只不过该函数利用了in_addr结构体,且其使用频率更高。
#include <arpa/inet.h>
int inet_aton(const char *string, struct in_addr *addr);
/*
成功时返回 1 ,失败时返回 0
string: 含有需要转换的IP地址信息的字符串地址值
addr: 将保存转换结果的 in_addr 结构体变量的地址值
*/
实际编程中若要调用inet_addr函数,需将转换后的IP地址信息代人sockaddr_in结构体中声明的in_addr结构体变量。而inet_aton函数则不需此过程。原因在于,若传递in_addr结构体变量地址值,函数会自动把结果填入该结构体变量。通过示例了解inet_aton函数调用过程。(使用inet_aton函数可以省一步相当于)
// inet.aton.cpp
#include <iostream>
using namespace std;
#include <stdlib.h>
#include <arpa/inet.h>
void error_handling (const char* message);
int main() {
const char* addr = "127.232.124.79";
struct sockaddr_in addr_inet;
if (!inet_aton(addr, &addr_inet.sin_addr)) error_handling("Conversion error");
else cout << "Network ordered integer addr:" << hex << addr_inet.sin_addr.s_addr << endl;
return 0;
}
void error_handling(const char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译运行:
g++ inet_aton.cpp -o aton
./aton
运行结果:
Network ordered integer addr:4f7ce87f
可以看出,已经成功的把转换后的地址放进了 addr_inet.sin_addr.s_addr 中。
还有一个函数,与 inet_aton() 正好相反,它可以把网络字节序整数型IP地址转换成我们熟悉的字符串形式,函数原型如下:
#include <arpa/inet.h>
char *inet_ntoa(struct in_addr adr);
该函数将通过参数传入的整数型IP地址转换为字符串格式并返回。但要小心,返回值为 char 指针,返回字符串地址意味着字符串已经保存在内存空间,但是该函数未向程序员要求分配内存,而是在内部申请了内存保存了字符串。也就是说调用了该函数候要立即把信息复制到其他内存空间。因此,若再次调用 inet_ntoa 函数,则有可能覆盖之前保存的字符串信息。总之,再次调用 inet_ntoa 函数前返回的字符串地址是有效的。若需要长期保存,则应该将字符串复制到其他内存空间。 如果需要长期保存的话,需要将其复制到其他的空间。
// inet_ntoa.c
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>
int main(int argc, char* argv[]) {
struct sockaddr_in addr1, addr2;
char *str_ptr;
char str_arr[20]; // 最大长度为20
addr1.sin_addr.s_addr = htonl(0x1020304);
addr2.sin_addr.s_addr = htonl(0x1010101);
//把addr1中的结构体信息转换为字符串的IP地址形式
str_ptr = inet_ntoa(addr1.sin_addr);
strcpy(str_arr, str_ptr);
printf("Dotted-Decimal notation1: %s \n", str_ptr);
inet_ntoa(addr2.sin_addr);
printf("Dotted-Decimal notation2: %s \n", str_ptr);
printf("Dotted-Decimal notation3: %s \n", str_arr);
return 0;
}
编译运行:
gcc inet_ntoa.c -o ntoa
./ntoa
结果输出:
Dotted-Decimal notation1: 1.2.3.4
Dotted-Decimal notation2: 1.1.1.1 // 这里下一次会覆盖结果
Dotted-Decimal notation3: 1.2.3.4 // 长期保存需要另外开辟空间进行存储
注意inet_ntoa的参数必须是大端序,才能转换成正确的点分十进制字符串,所以首先需要使用htonl转换为网络字节序。
3.4.2 网络地址初始化
结合前面的内容,介绍套接字创建过程中,常见的网络信息初始化方法:
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地址初始化,将字符串转化为整数形式的IP地址,并且网络字节序
addr.sin_port = htons(atoi(serv_port)); //基于字符串的IP地址端口号初始化,atoi是将字符串转化为整数,htos将主机上的整数型转化为网络字节序
另外,代码中对IP地址和端口号进行了硬编码,这并非良策,因为运行环境改变就得更改代码。因此,我们运行示例main函数时传入IP地址和端口号。
3.4.3 客户端地址信息初始化
客户端和服务端的请求是不一样的,服务端分类IP地址和端口号,而客户端的请求则是连接相应的IP地址和端口号。服务器端的准备工作通过bind函数完成,而客户端则通过connect函数完成。因此,函数调用前需准备的地址值类型也不同。服务器端声明sockaddr_in结构体变量,将其初始化为赋予服务器端IP和套接字的端口号,然后调用bind函数;而客户端则声明sockaddr_in结构体,并初始化为要与之连接的服务器端套接字的P和端口号,然后调用connect函数。
3.4.4 INADDR_ANY
每次创建服务器端套接字都要输入IP地址会有些繁琐,此时可如下初始化地址信息。
struct sockaddr_in addr;
char *serv_port = "9190";
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(atoi(serv_port));
使用常数分配服务器的IP地址可以自动获取运行服务器段的计算机IP地址,不必亲自输入。
3.4.5 第一章的hello_server.c、hello_client.c运行过程
./hserver 9190
通过代码可知,向main函数传递的9190为端口号。通过此端口创建服务器端套接字并运行利序,但未传递IP地址,因为可以通过INADDR_ANY指定IP地址。
执行hello_client.c,执行指令为:
./hclient 127.0.0.1 9190
127.0.0.1是回送地址( loopback address ),指的是计算机自身IP地址。在第1章的示例中,服务器端和客户端在同一计算机中运行,因此,连接目标服务器端的地址为127.0.0.1。当然,若用实际IP地址代替此地址也能正常运转。如果服务器端和客户端分别在2台计算机中运行,则可以输入服务器端IP地址。
3.4.6 向套接字分配网络地址
我们之前已经得到了协议族,IP地址以及端口号,之后需要是使用bind分配地址信息:
bind(serv_sock,(struct sockaddr * )&serv_addr,sizeof(serv_addr));
3.5 基于Windows的实现
暂略。
3.6 习题
-
IP地址族 IPv4 与 IPv6 有什么区别?在何种背景下诞生了 IPv6?
答: 主要差别是IP地址所用的字节数,目前通用的是IPv4,目前IPv4的资源已耗尽,所以诞生了IPv6,它具有更大的地址空间。 -
通过 IPV4 网络 ID 、主机 ID 及路由器的关系说明公司局域网的计算机传输数据的过程。
答: 首先数据传输的第一个环节是向目标IP所属的网络传输数据。此时使用的是IP地址中的网络ID。传输的数据将被传到管理网络的路由器,接受数据的路由器将参照IP地址的主机号找自己保存的路由表,找到对应的主机发送数据 -
套接字地址分为IP地址和端口号,为什么需要IP地址和端口号?或者说,通过IP地址可以区分哪些对象?通过端口号可以区分哪些对象?
答: IP地址是为了区分网络上的主机。端口号是区分同一主机下的不同的SOCKET,以确保软件准确收发数据。 -
请说明IP地址的分类方法,并据此说出下面这些IP的分类。
214.121.212.102(C类)
120.101.122.89(A类)
129.78.102.211(B类)
答: 分类方法:A 类地址的首字节范围为:0-127、B 类地址的首字节范围为:128-191、C 类地址的首字节范围为:192~223。 -
计算机通过路由器和交换机连接到互联网,请说出路由器和交换机的作用。
答: 路由器和交换机完成外网和本网主机之间的数据交换。 -
什么是知名端口?其范围是多少?知名端口中具有代表性的 HTTP 和 FTP 的端口号各是多少?
答: 知名端口是要把该端口分配给特定的应用程序,范围是 0~1023 ,HTTP 的端口号是 80 ,FTP 的端口号是20(数据端口)和21(控制端口)。 -
为什么bind中第二个参数是sockaddr,但是传入的是sockaddr_in?
答: bind函数第二个参数类型是sockaddr结构体,很难分配IP地址和端口号,因此IP地址和PORT号的分配是通过sockaddr_in完成的。因为该结构体和sockaddr结构体的组成字节序和大小完全相同,所以可以强转。 -
请解释大端序,小端序、网络字节序,并说明为何需要网络字节序。
答: 小端序是把高位字节存储到高位地址上;大端序是把高位字节存储到低位地址上。由于不同CPU的存储方式不一样,所以对网络传输数据的过程制定了标准,这就是“网路字节序”。而且,在网络字节序中,数据传输的标准是“大端序”。 -
大端序计算机希望把 4 字节整数型 12 传递到小端序计算机。请说出数据传输过程中发生的字节序变换过程。
答:0x12-0x21
-
怎样表示回送地址?其含义是什么?如果向会送地址处传输数据将会发生什么情况?
答: 回送地址表示计算机本身的IP地址,为127.0.0.1
。因此,如果将数据传送到IP地址127.0.0.1
,数据不会通过传输到网络的其他设备上而直接返回。
第4章 基于TCP的服务端/客户端(1)
本章将具体讨论这种面向连接的服务器端/客户端的编写。
4.1 理解TCP和UDP
根据数据传输方式的不同,基于网络协议的套接字一般分为 TCP 套接字和 UDP 套接字。因为 TCP 套接字是面向连接的,因此又被称为基于流(stream)的套接字。
TCP 是 Transmission Control Protocol (传输控制协议)的简写,意为「对数据传输过程的控制」。因此,学习控制方法及范围有助于正确理解 TCP 套接字。
4.1.1 TCP/IP 协议栈
TCP/IP 协议栈共分为 4 层,可以理解为数据收发分成了 4 个层次化过程,通过层次化的方式来解决问题。数据链路层、网络层(IP)、传输层(TCP、UDP)以及应用层(HTTP、FTP、SMTP)。
4.1.2 链路层
链路层是物理链接领域标准化的结果,也是最基本的领域,专门定义LAN、WAN、MAN等网络标准。若两台主机通过网络进行数据交换,则需要物理连接,链路层就负责这些标准。(交换机就是数据链路层的范畴,在数据链路层的范畴里面,都是在局域网进行的,没有其他的网络)
4.1.2 IP层
准备好物理连接候就要传输数据。为了在复杂网络中传输数据,首先要考虑路径的选择。向目标传输数据需要经过哪条路径?解决此问题的就是IP层,该层使用的协议就是IP。
IP 是面向消息的、不可靠的协议。每次传输数据时会帮我们选择路径,但并不一致。如果传输过程中发生错误,则选择其他路径,但是如果发生数据丢失或错误,则无法解决。换言之,IP协议无法应对数据错误。(IP协议是不可靠的,无法应对数据错误!!)
4.1.2 TCP/IP层
链路层解决物理连接的问题(理解为省道),IP层解决传输中的路径选择问题(理解为国道)。此时就需要有传输的东西(车辆)。TCP 和 UDP 层以 IP 层提供的路径信息为基础完成实际的数据传输,故该层又称为传输层。UDP 比 TCP 简单,现在我们只解释 TCP 。TCP 可以保证数据的可靠传输,但是它发送数据时以 IP 层为基础(这也是协议栈层次化的原因)。如何理解二者的关系呢?(TCP和IP是怎么协作的)
IP 层只关注一个数据包(数据传输基本单位)的传输过程。因此,即使传输多个数据包,每个数据包也是由 IP 层实际传输的,也就是说传输顺序及传输本身是不可靠的。若只利用IP层传输数据,则可能导致后传输的数据包B比先传输的数据包A提早到达。另外,传输的数据包A、B、C中可能只收到A和C,甚至收到的C可能已经损毁 。反之,若添加 TCP 协议则按照如下对话方式进行数据交换。
这就是 TCP 的作用。如果交换数据的过程中可以确认对方已经收到数据,并重传丢失的数据,那么即便IP层不保证数据传输,这类通信也是可靠的。TCP协议确认后向不可靠的IP协议赋予可靠性。
4.1.2 应用层
上述内容是套接字通信过程中自动处理的。选择数据传输路径、数据确认过程都被隐藏到套接字内部。向程序员提供的工具就是套接字,只需要利用套接字编出程序即可。编写软件的过程中,需要根据程序的特点来决定服务器和客户端之间的数据传输规则,这便是应用层协议。
4.2 实现基于TCP的服务器/客户端
下面我们将实现完整的TCP服务器端。
4.2.1 TCP服务器端的默认函数表用顺序
下图给出了TCP服务器端默认的函数调用顺序:
调用socket函数创建套接字,声明并初始化地址信息结构体变量,调用bind函数向套接字分配地址。这2个阶段之前都已讨论过,下面讲解之后的几个过程。
4.2.2 进入等待连接请求状态
已经调用了 bind 函数给套接字分配地址,接下来就是要通过调用 listen 函数进入等待连接请求状态。只有调用了 listen 函数,客户端才能进入可发出连接请求的状态。换言之,这时客户端才能调用connect 函数,如果提前调用将产生错误。
#include <sys/socket.h>
int listen (int sock, int backlog);
// 成功时返回0,失败时返回-1
// sock: 希望进入等待连接请求状态的套接字文件描述符,传递的描述符套接字参数称为服务端套接字
// backlog: 连接请求等待队列的长度,若为5,则队列长度为5,表示最多使5个连接请求进入队列
这里的sock就是服务器端套接字,其作用相当于是一个门卫,如下图所示:
4.2.3 受理客户端连接请求
调用 listen 函数后,则应该按序受理。受理请求意味着可接受数据的状态。进入这种状态所需的部件是套接字,但是此时使用的不是服务端套接字(因为服务器端的套接字的作用是守门,所以不能抽调去干别的事情),此时需要另一个套接字,但是没必要亲自创建,下面的函数将自动创建套接字。
#include <sys/socket.h>
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);
/*
成功时返回文件描述符,失败时返回-1,这里的文件描述符不是服务器端套接字,而是自动创建的用于I/O的套接字
sock: 服务端套接字的文件描述符
addr: 保存发起连接请求的客户端地址信息的变量地址值,调用函数后向传递来的地址变量参数填充客户端地址信息
addrlen: 第二个参数addr结构体的长度,但是存放有长度的变量地址。函数调用完成后,该变量即被填入客户端地址长度。
*/
accept函数受理连接请求等待队列中待处理的客户端连接请求。函数调用成功时,accept 函数内部将产生用于数据IO的套接字,并返回其文件描述符。需要强调的是,套接字是自动创建的,并自动与发起连接请求的客户端建立连接。图4-8展示了accept函数调用过程。
4.2.4 回顾Hello World服务器端
重新整理一下代码的思路
- 服务端实现过程中首先要创建套接字,此时的套接字并非是真正的服务端套接字。
- 为了完成套接字地址的分配,初始化结构体变量并调用 bind 函数。
- 调用 listen 函数进入等待连接请求状态(监听状态)。连接请求状态队列的长度设置为5,此时的套接字才是服务端套接字。
- 调用 accept 函数从队头取 1 个连接请求与客户端建立连接,并返回创建的套接字文件描述符。另外,调用 accept 函数时若等待队列为空,则 accept 函数不会返回,直到队列中出现新的客户端连接。
- 调用 write 函数向客户端传送数据,调用 close 关闭连接。
4.2.5 TCP客户端的默认函数调用顺序
与服务器端相比,区别就在于“请求连接”,它是创建客户端套接字后向服务器端发起的连接请求。服务器端调用 listen 函数后创建连接请求等待队列,之后客户端即可请求连接。那如何发起连接请求呢?通过调用如下函数完成。
#include <sys/socket.h>
int connect(int sock, struct sockaddr *servaddr, socklen_t addrlen);
/*
成功时返回0,失败返回-1
sock:客户端套接字文件描述符
servaddr: 保存目标服务器端地址信息的变量地址值
addrlen: 以字节为单位传递给第二个结构体参数 servaddr 的变量地址长度
*/
客户端调用 connect 函数候,发生以下函数之一才会返回(完成函数调用):
- 服务器端接收连接请求
- 发生断网等异常情况而中断连接请求
注意:接受连接不代表服务端调用 accept 函数,其实只是服务器端把连接请求信息记录到等待队列。因此connect 函数返回后并不应该立即进行数据交换。
客户端是否需要分配套接字IP和端口号呢?
我们知道服务器端需要给套接字分配IP和端口号,但是客户端也同样需要分配。
- 何时?调用connect函数时。
- 何地?操作系统,更准确地说是在内核中。
- 如何?IP用计算机(主机)地IP,端口随机。
客户端的IP地址和端口在调用connect函数时自动分配,无需调用标记的bind函数进行分配。
4.2.6 回顾Hello World客户端
重新整理一下代码的思路:
- 创建准备连接服务器的套接字,此时创建的是 TCP 套接字;
- 结构体变量 serv_addr 中初始化IP和端口信息。初始化值为目标服务器端套接字的IP和端口信息;
- 调用 connect 函数向服务端发起连接请求;
- 完成连接后,接收服务端传输的数据;
- 接收数据后调用 close 函数关闭套接字,结束与服务器端的连接。
4.2.7 基于TCP的服务器端/客户端函数调用关系
调用关系如图所示。
有几点需要注意的地方:客户端只有等到服务器调用listen函数后才能调用connect函数发起连接请求,否则回发生错误。客户端调用connent函数之前,服务器端有可能率先调用accept函数,此时服务器在调用accept函数时进入阻塞状态,直到客户端调用connect函数为止。
学习到这里,需要能够自己将Hello World的服务器端和客户端自己写出来了,基本上都已经讲解完成了。
4.3 实现迭代服务器/客户端
编写一个回声(echo)服务器/客户端。顾名思义,服务端将客户端传输的字符串数据原封不动的传回客户端,就像回声一样。在此之前,需要解释一下迭代服务器端。
4.3.1 实现迭代服务器端
在 Hello World 的例子中,等待队列的作用没有太大意义。如果想继续处理好后面的客户端请求应该怎样扩展代码?最简单的方式就是插入循环反复调用 accept 函数,如图:
从图中可以看出,在调用read或者write函数后,然后调用close函数,这并非针对服务器端套接字,而是针对accept函数调用创建的套接字。
调用close函数就意味着结束了针对某一客户端的服务。此时如果还想服务于其他客户端,就要重新调用accept函数。学习了多线程之后就可以编写同时服务于多个客户端的服务器端了。
4.3.2 迭代回声服务器端/客户端
服务器端以迭代的方式运行,客户端的代码没有太大的差别。首先整理一下程序的基本运行方式:
- 服务器端在同一时刻只与一个客户端相连,并提供回声服务。
- 服务器端依次向 5 个客户端提供服务并退出。
- 客户端接受用户输入的字符串并发送到服务器端。
- 服务器端将接受的字符串数据传回客户端,即「回声」。
- 服务器端与客户端之间的字符串回声一直执行到客户端输入 Q 为止。
下面时回声服务器端的代码:
// echo_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 1024
void error_handling(char *message);
int main(int argc, char *argv[]) {
int serv_sock, clnt_sock;
char message[BUF_SIZE];
int str_len, i;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t clnt_adr_sz;
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1) printf("socket() 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");
if (listen(serv_sock, 5 == -1)) error_handling("listen() error");
clnt_adr_sz = sizeof(clnt_adr);
for(i = 0; i < 5; i++) {
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
if (clnt_sock == -1) error_handling("accept() error");
else printf("Connect client %d \n", i + 1);
while ((str_len = read(clnt_sock, message, BUF_SIZE)) != 0) {
write(clnt_sock, message, str_len);
}
close(clnt_sock);
}
close(serv_sock);
return 0;
}
void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
下面时回声客户端的代码:
// echo_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 1024
void error_handling(char *message);
int main(int argc, char *argv[]) {
int sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_adr;
if (argc != 3) {
printf("Usage : %s <IP> <Port>\n", argv[0]);
exit(1);
}
sock = socket(PF_INET, SOCK_STREAM, 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]));
if (connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("connect() error");
else
puts("Connected............");
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(sock);
return 0;
}
void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译:
gcc echo_client.c -o eclient
gcc echo_server.c -o eserver
运行:
./eserver 9190
./eclient 127.0.0.1 9190
运行结果:
server:
client:
然后客户端输入什么就会返回什么,此时服务器端会显示已连接1个客户端(最多连接5个)
4.3.3 回声客户端存在的问题
以上代码有一个假设「每次调用 read、write函数时都会以字符串为单位执行实际 I/O 操作」
但是「第二章」中说过「TCP 不存在数据边界」,上述客户端是基于 TCP 的,因此多次调用 write 函数传递的字符串有可能 一次性传递到服务端。此时客户端有可能从服务端收到多个字符串,这不是我们想要的结果。还需要考虑服务器的如下情况:
「字符串太长,需要分 2 个包发送!」
服务端希望通过调用 1 次 write 函数传输数据,但是如果数据太大,操作系统就有可能把数据分成多个数据包发送到客户端。另外,在此过程中,客户端可能在尚未收到全部数据包时就调用 read 函数。以上的问题都是源自 TCP 的传输特性,解决方法在第 5 章。
4.4 基于Windows的实现
暂略
4.5 习题
-
请你说明 TCP/IP 的 4 层协议栈,并说明 TCP 和 UDP 套接字经过的层级结构差异。
答: TCP/IP 的四层协议分为:应用层、TCP/UDP 层、IP层、链路层。差异是一个经过 TCP 层,一个经过 UDP 层。 -
请说出 TCP/IP 协议栈中链路层和IP层的作用,并给出二者关系
答: 链路层是LAN、WAN、MAN等网络标准相关的协议栈,是定义物理性质标准的层级。相反,IP层是定义网络传输数据标准的层级。即IP层负责以链路层为基础的数据传输。 -
为何需要把TCP/IP协议栈分成4层(或7层)?结合开放式系统回答
答: 将复杂的TCP/IP协议分层化的话,就可以将分层的层级标准发展成开放系统。实际上,TCP/IP是开放系统,各层级都被初始化,并以该标准为依据组成了互联网。因此,按照不同层级标准,硬件和软件可以相互替代,这种标准化是TCP/IP蓬勃发张的依据 -
客户端调用 connect 函数向服务器端发送请求。服务器端调用哪个函数后,客户端可以调用connect 函数?
答: 服务端调用 listen 函数后,客户端可以调用 connect 函数。因为,服务端调用 listen 函数后,服务端套接字才有能力接受请求连接的信号。 -
什么时候创建连接请求等待队列?它有何种作用?与 accept 有什么关系?
答: 服务端调用 listen 函数后,accept函数正在处理客户端请求时, 更多的客户端发来了请求连接的数据,此时,就需要创建连接请求等待队列。以便于在accept函数处理完手头的请求之后,按照正确的顺序处理后面正在排队的其他请求。与accept函数的关系accept函数受理连接请求等待队列中待处理的客户端连接请求。 -
客户端中为何不需要调用 bind 函数分配地址?如果不调用 bind 函数,那何时、如何向套接字分配IP地址和端口号?
答: 在调用 connect 函数时分配了地址,客户端IP地址和端口在调用 connect 函数时自动分配,无需调用标记的 bind 函数进行分配。
第5章 基于TCP的服务器端/客户端(2)
本章将详细讲解TCP中必要的理论知识,还给出第4章中客户端问题的解决方案。
5.1 回声客户端的完美实现
5.1.1 回声服务器端没有问题,只有回声客户端有问题?
问题不在服务器端,而在客户端,只看代码可能不好理解,因为 I/O 中使用了相同的函数。先回顾一下服务器端的 I/O 相关代码:
while((str_len = read(clnt_sock, message, BUF_SIZE)) != 0)
write(clnt_sock, message, str_len);
接着回顾回声客户端的代码:
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE - 1);
这里把回声客户端存在的问题按照自己目前的理解梳理一下:
如果传输的字符串太长的话,在客户端这边,使用write可能无法一次性的将所有的字符都传给服务器端,服务器端的read可能也是分段接收的这个字符串。但是这个时候服务器接收到了数据,可能也会直接将这一部分数据原地发送给客户端,但是此时传的数据不是完整的,客户端接收到过后,直接就进行了打印,殊不知还有部分数据根本就没传过来呢。
那么什么时候客户端这里才能调用read函数呢?
一个很直接的思路就是等一会再调用不就行了,客户端把全部数据都给了服务器端,服务器一次性write过来,但是这个等待的时间是无法确定的,另外,如果数据太长,服务器端还是会分开write数据,还是会造成之前的情况。
事实上,我们只需要知道传出去了多少,我们在read的时候就一直调用,直到我们接收了这么多就行了!这就是改进回声客户端的方案。
5.1.2 回声客户端问题的解决办法
这个问题其实很容易解决,因为可以提前接受数据的大小。若之前传输了20字节长的字符串,则再接收时循环调用 read 函数读取 20 个字节即可。既然有了解决办法,那么代码如下:
while(1) {
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);
if (!strcmp(message, "q\n") || !strcmp(message, "Q\n")) break;
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;
}
message[recv_len] = 0;
printf("Message from server: %s", message);
}
这是客户端中需要修改的代码,使用一个循环,多次调用read,直到收到的数据和发送的数据一样长即可。
这里有两个小疑问:
- 为什么是BUF_SIZE - 1呢?
其实,这里的BUF_SIZE - 1就是为了给字符串留一个结尾的字符’\0‘,代表字符串的结尾,这个也是一个空字符串 - 为什么
message[recv_len] = 0
呢?
其实这里就是将字符串的最后填充一个空字符串,这里填充0和’\0‘都是可以的,二者是等价的。
注意上面的while (recv_len < str_len)
不能包含等于,否则可能永远出不来循环。
其实更好理解的方式是将其改成while(recv_len != str_len)
的,但是可能引发无限循环。假设发生异常情况,读取数据过程中recv_len超过str_len,此时就无法退出循环。所以要写成上面那种,即使出现了异常,也是可以退出循环的。
5.1.3 如果问题不在于回声客户端:定义应用层协议
回声客户端可以提前知道接收数据的长度,这在大多数情况下是不可能的。那么此时无法预知接收数据长度时应该如何收发数据?这是需要的是应用层协议的定义。在收发过程中定好规则(协议)以表示数据边界,或者提前告知需要发送的数据的大小。服务端/客户端实现过程中逐步定义的规则集合就是应用层协议。
现在写一个小程序来体验应用层协议的定义过程。要求:
- 服务器从客户端获得多个数组和运算符信息。
- 服务器接收到数字后对齐进行加减乘运算,然后把结果传回客户端。
例:
- 向服务器传递3,5,9的同时请求加法运算,服务器返回3 + 5 + 9的结果;
- 请求做乘法运算,客户端会收到 3 * 5 * 9 的结果;
- 如果向服务器传递4,3,2的同时要求做减法,则返回4 - 3 - 2的运算结果。
这里暂时先放放,知道有这件事情即可。
5.2 TCP原理
5.2.1 TCP套接字中的I/O缓冲
TCP 套接字的数据收发无边界。服务器即使调用 1 次 write 函数传输 40 字节的数据,客户端也有可能通过 4 次 read 函数调用每次读取 10 字节。但此处也有一些疑问,服务器一次性传输了 40 字节,而客户端竟然可以缓慢的分批接受。客户端接受 10 字节后,剩下的 30 字节在何处等候呢?
实际上,write 函数调用后并非立即传输数据, read 函数调用后也并非马上接收数据。如图所示,write 函数调用瞬间,数据将移至输出缓冲;read 函数调用瞬间,从输入缓冲读取数据。
I/O缓冲特征可以整理如下:
- I/O 缓冲在每个 TCP 套接字中单独存在;
- I/O 缓冲在创建套接字时自动生成;
- 即使关闭套接字也会继续传递输出缓冲中遗留的数据;
- 关闭套接字将丢失输入缓冲中的数据。
假设发生以下情况,会发生什么事呢?
客户端输入缓冲为 50 字节,而服务器端传输了 100 字节。
因为 TCP 不会发生超过输入缓冲大小的数据传输。也就是说,根本不会发生这类问题,因为 TCP 会控制数据流。TCP 中有滑动窗口(Sliding Window)协议,用对话方式如下:
A:你好,最多可以向我传递 50 字节
B:好的
A:我腾出了 20 字节的空间,最多可以接受 70 字节
B:好的
数据收发也是如此,因此TCP中不会因为缓冲溢出而丢失数据。
write函数并不会在完成向对方主机的数据传输时返回,而是在数据移出到输出缓冲时返回。
所以说write函数在数据传输完成时返回。(指的是将数据移出到输出缓冲时返回)
5.2.2 TCP内部工作原理1:与对方套接字的连接
TCP 套接字从创建到消失所经过的过程分为如下三步:
- 与对方套接字建立连接;
- 与对方套接字进行数据交换;
- 断开与对方套接字的连接。
首先讲解与对方套接字建立连接的过程。连接过程中,套接字的对话如下:
- [shake 1]套接字A:你好,套接字 B。我这里有数据给你,建立连接吧;
- [shake 2]套接字B:好的,我这边已就绪;
- [shake 3]套接字A:谢谢你受理我的请求;
TCP在实际通信中也会经过三次对话过程,该过程又被称为Three-way handshaking(三次握手)。接下来给出连接过程中实际交换的信息方式:
这里先复习一下湖科大教书匠的计算机网络关于这部分的解释:
三个过程:
TCP建立需要解决的问题:
三次握手:
第三次握手是否多余?能否改为两次握手?
**不多余。**如下图所示:
如果TCP客户端发送了TCP连接请求,但是该请求失败了,没有传到TCP服务器端,然后客户端重新发送了请求,TCP服务端收到了这个请求(此时已经两次握手了),然后进行数据传输,传输完成后释放连接,服务器端和客户端都处于关闭的状态,好像对这一次的数据传输没有什么影响。
但是如果之前失效的TCP连接请求忽然传到了TCP服务器端,此时TCP服务器直接将确认连接状态发给客户端,然后让自己进入已连接状态,但是此时客户端是关闭的状态,所以服务器端会一直挂起,从而造成资源的浪费。
这也就是三报文握手的意义,为了确认发起连接的客户端在这一过程中保持正常的状态。
举个例子:
以下是本书的一些解释,其实和之前都是一样的了:
套接字是全双工方式工作的。也就是说,它可以双向传递数据。因此,收发数据前要做一些准备。首先请求连接的主机 A 要给主机 B 传递以下信息:
SYN] SEQ : 1000 , ACK:-
该消息中的 SEQ 为 1000 ,ACK 为空,而 SEQ 为1000 的含义如下:
现在传递的数据包的序号为 1000,如果接收无误,请通知我向您传递 1001 号数据包。
这是首次请求连接时使用的消息,又称为 SYN。SYN 是 Synchronization 的简写,表示收发数据前传输的同步消息。接下来主机 B 向 A 传递以下信息:
[SYN+ACK] SEQ: 2000, ACK: 1001
此时 SEQ 为 2000,ACK 为 1001,而 SEQ 为 2000 的含义如下:
现传递的数据包号为 2000 ,如果接受无误,请通知我向您传递 2001 号数据包。
而 ACK 1001 的含义如下:
刚才传输的 SEQ 为 1000 的数据包接受无误,现在请传递 SEQ 为 1001 的数据包。
对于主机 A 首次传输的数据包的确认消息(ACK 1001)和为主机 B 传输数据做准备的同步消息(SEQ2000)捆绑发送。因此,此种类消息又称为 SYN+ACK。
收发数据前向数据包分配序号,并向对方通报此序号,这都是为了防止数据丢失做的准备。通过向数据包分配序号并确认,可以在数据包丢失时马上查看并重传丢失的数据包。因此 TCP 可以保证可靠的数据传输。
通过这三个过程,这样主机 A 和主机 B 就确认了彼此已经准备就绪。
5.2.3 TCP内部工作原理2:与对方主机的数据交换
通过第一步三次握手过程完成了数据交换准备,下面就开始正式收发数据,其默认方式如图所示:
上图给出了主机 A 分成 2 个数据包向主机 B 传输 200 字节的过程。首先,主机 A 通过 1 个数据包发送100 个字节的数据,数据包的 SEQ 为 1200 。主机 B 为了确认这一点,向主机 A 发送 ACK 1301 消息。
此时的 ACK 号为 1301 而不是 1201,原因在于 ACK 号的增量为传输的数据字节数。假设每次 ACK 号不加传输的字节数,这样虽然可以确认数据包的传输,但无法明确 100 个字节全都正确传递还是丢失了一部分,比如只传递了 80 字节。因此按照如下公式传递 ACK 信息:
ACK号 = SEQ号 + 传递的字节数 + 1
与三次握手协议相同,最后 + 1 是为了告知对方下次要传递的 SEQ 号。下面分析传输过程中数据包丢失的情况:
上图表示了通过 SEQ 1301 数据包向主机 B 传递 100 字节数据。但中间发生了错误,主机 B 未收到,经过一段时间后,主机 A 仍然未收到对于 SEQ 1301 的 ACK 的确认,因此试着重传该数据包。为了完成该数据包的重传,TCP 套接字启动计时器以等待 ACK 应答。若相应计时器发生超时(Time-out!)则重传。(超时重传)
5.2.4 TCP内部工作原理3:断开套接字的连接
TCP 套接字的结束过程也非常优雅。如果对方还有数据需要传输时直接断掉该连接会出问题,所以断开连接时需要双方协商,断开连接时双方的对话如下:
套接字A:我希望断开连接;
套接字B:哦,是吗?请稍后;
套接字B:我也准备就绪,可以断开连接;
套接字A:好的,谢谢合作。
先由套接字 A 向套接字 B 传递断开连接的信息,套接字 B 发出确认收到的消息,然后向套接字 A 传递`可以断开连接的消息,套接字 A 同样发出确认消息。
TCP通过四次挥手释放连接:
图中存在四次挥手,比较难以理解的是服务器端第一次传回来的seq = v
,这是由于之前客户端传回的ack = v
,这是对已经传送的v-1
个字节进行的确认,所以客户端下一次传送的就是v
了。
这里TCP服务器端收到释放请求之后,需要把还没有传完的数据传完,此时客户会进入终止等待,收到服务器的信息之后就不会返回给服务器。
直到服务器把自己的数据传完了,这个时候给客户端发送释放FIN的报文,此时客户端会进一步反馈给服务器端,此时服务器端就会关闭!上面的等待2MSL的意义何在?
直接关闭的话,如果客户端在回传信息过程中丢失了,服务器端没有收到最后的确认,服务器端不会关闭,而是一直超时重发,但是此时客户端已经关闭了,会造成服务器端无法关闭。
图中数据包内的 FIN 表示断开连接。也就是说,双方各发送 1 次 FIN 消息后断开连接。此过过程经历 4个阶段,因此又称四次握手(Four-way handshaking)。SEQ 和 ACK 的含义与之前讲解的内容一致,省略。图中,主机 A 传递了两次 ACK 5001,也许这里会有困惑。其实,第二次 FIN 数据包中的 ACK5001 只是因为接收了 ACK 消息后未接收到的数据重传的。
5.3 基于Windows的实现
暂略。
5.4 习题
第6章 基于UDP的服务器端/客户端
TCP 是内容较多的一个协议,而本章中的 UDP 内容较少,但是也很重要。
6.1 理解UDP
6.1.1 UDP套接字的特点
下面通过信件说明UDP的工作原理,这是讲解UDP时使用的传统示例,它与UDP特性完全相符。寄信前应先在信封上填好寄信人和收信人的地址,之后贴上邮票放进邮筒即可。当然,信件的特点使我们无法确认对方是否收到。另外,邮寄过程中也可能发生信件丢失的情况。也就是说,信件是一种不可靠的传输方式。与之类似,UDP提供的同样是不可靠的数据传输服务。
因为 UDP 没有 TCP 那么复杂,所以编程难度比较小,性能也比 TCP 高。在更重视性能的情况下可以选
择 UDP 的传输方式。
TCP 与 UDP 的区别很大一部分来源于流控制。也就是说 TCP 的生命在于流控制。IP层本身是不可靠的,为了提供可靠的数据传输服务,TCP在不可靠的IP层进行流控制,而UDP就缺少这种流控制机制。
TCP的传输速率无法超过UDP,但在收发某些类型的数据的时候有可能接近UDP。
6.1.2 UDP内部工作原理
如图所示:
从上图可以看出,IP的作用就是让离开主机B的UDP数据包准确传递到主机A。但把UDP包最终交给主机A的某一UDP套接字的过程则是由UDP完成的。UDP最重要的作用就是根据端口号将传到主机的数据包交付给最终的UDP套接字(对应的应用程序)。
6.1.3 UDP的高效使用
UDP 也具有一定的可靠性。对于通过网络实时传递的视频或者音频时情况有所不同。对于多媒体数据而言,丢失一部分数据也没有太大问题,这只是会暂时引起画面抖动,或者出现细微的杂音。但是要提供实时服务,速度就成为了一个很重要的因素。因此流控制就显得有一点多余,这时就要考虑使用UDP 。TCP比UDP慢的原因主要有以下两点:
- 收发数据前后进行的连接设置及清除过程。
- 收发过程中为保证可靠性而添加的流控制。
如果收发的数据量小但是需要频繁连接时,UDP 比 TCP 更高效。
6.2 实现基于UDP的服务器端/客户端
6.2.1 UDP的服务器端和客户端没有连接
UDP服务器端/客户端不像TCP那样在连接状态下交换数据,因此与TCP不同,无需经过连接过程。也就是说,不必调用TCP连接过程中调用的listen函数和accept函数。UDP中只有创建套接字的过程和数据交换过程。
6.2.2 UDP服务器和客户端均只需1个套接字
TCP中,套接字之间应该是一对一的关系。若要向10个客户端提供服务,则除了守门的服务器套接字外,还需要10个服务器端套接字。但在UDP中,不管是服务器端还是客户端都只需要1个套接字。之前解释UDP原理时举了信件的例子,收发信件时使用的邮筒可以比喻为UDP套接字。只要附近有1个邮筒,就可以通过它向任意地址寄出信件。同样,只需1个UDP套接字就可以向任意主机传输数据,如下图所示。
图中展示了 1 个 UDP 套接字与 2 个不同主机交换数据的过程。也就是说,只需 1 个 UDP 套接字就能和多台主机进行通信。
6.2.3 基于UDP的数据I/O函数
创建好 TCP 套接字以后,传输数据时无需加上地址信息。因为 TCP 套接字将保持与对方套接字的连接。换言之,TCP 套接字知道目标地址信息。但 UDP 套接字不会保持连接状态(UDP 套接字只有简单的邮筒功能),因此每次传输数据时都需要添加目标的地址信息。这相当于寄信前在信件中填写地址。接下来是 UDP 的相关函数:
#include <sys/socket.h>
ssize_t sendto(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen);
/*
成功时返回传输的字节数,失败是返回 -1
sock: 用于传输数据的 UDP 套接字
buff: 保存待传输数据的缓冲地址值
nbytes: 待传输的数据长度,以字节为单位
flags: 可选项参数,若没有则传递 0
to: 存有目标地址的 sockaddr 结构体变量的地址值
addrlen: 传递给参数 to 的地址值结构体变量长度
*/
上述函数与之前的 TCP 输出函数最大的区别在于,此函数需要向它传递目标地址信息。接下来介绍接收UDP 数据的函数。UDP 数据的发送并不固定,因此该函数定义为可接受发送端信息的形式,也就是将同时返回 UDP 数据包中的发送端信息。
#include <sys/socket.h>
ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen);
/*
成功时返回传输的字节数,失败是返回 -1
sock: 用于传输数据的 UDP 套接字
buff: 保存接收数据的缓冲地址值
nbytes: 可接收的最大字节数,故无法超出参数buff所指的缓冲大小
from: 存有发送端地址信息的socketaddr结构体变量的地址值
addrlen: 保存参数from的结构体变量长度的变量地址值
*/
编写 UDP 程序的最核心的部分就在于上述两个函数,这也说明二者在 UDP 数据传输中的地位。
注意sendto
的参数是socklen_t addrlen
,而recvfrom
的参数是socklen_t *addrlen
。
6.2.4 基于UDP的回声服务器端/客户端
下面结合之前的内容实现回声服务器。需要注意的是,UDP不同于TCP,不存在请求连接和受理过程,因此在某种意义上无法明确区分服务器端和客户端,只是因其提供服务而称为服务器端。
// 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);
}
通过recvfrom
函数调用同时获取数据传输端的地址。正是利用该地址然后使用sendto
函数将接收的数据逆向重传。
while
语句中没有加入break语句,因此是无限循环。也就是说,close函数不会执行,没有太大意义。
接下来介绍与上述服务器端协同工作的客户端。这部分代码与TCP客户端不同,不存在connect函数调用。
// uecho_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/socket.h>
#define BUF_SIZE 30
void error_handling(char* message);
int main(int argc, char* argv[]) {
int sock;
int str_len;
char message[BUF_SIZE];
socklen_t adr_sz;
struct sockaddr_in serv_adr, from_adr; // 与服务器端不同,这里定义了一个serv_adr以及from_adr,在sendto的时候,使用的是serv_adr,但是在recv_from的时候,使用的就是from_adr
if (argc != 3) {
printf("Usage : %s <IP> <Port>", argv[0]);
exit(1);
}
clnt_sock = socket(PF_INET, SOCK_DGRAM, 0);
if (clnt_sock == -1) error_handling("sock() 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(clnt_sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
上面的代码的recvfrom
使用了from_adr
,存储的是接收的数据的结构体信息,包括端口什么的,其实这里可以直接使用serv_adr
代替,不会影响下一次传输数据,因为serv_adr
中还是存放的是服务器端的信息。
编译运行:
gcc uecho_client.c -o uclient
gcc uecho_server.c -o userver
./server 9190
./uclient 127.0.0.1 9190
结果:
TCP 客户端套接字在调用 connect 函数时自动分配IP地址和端口号,既然如此,UDP 客户端何时分配IP地址和端口号?
6.2.5 UDP客户端套接字的地址分配
仔细观察 UDP 客户端可以发现,UDP 客户端缺少了把IP和端口分配给套接字的过程。TCP 客户端调用
connect 函数自动完成此过程,而 UDP 中连接能承担相同功能的函数调用语句都没有。究竟在什么时候分配IP和端口号呢?
UDP 程序中,调用 sendto 函数传输数据前应该完成对套接字的地址分配工作,因此调用 bind 函数。当然,bind 函数在 TCP 程序中出现过,但 bind 函数不区分 TCP 和 UDP,也就是说,在 UDP 程序中同样可以调用。另外,如果调用 sendto 函数尚未分配地址信息,则在首次调用 sendto 函数时给相应套接字自动分配 IP 和端口。而且此时分配的地址一直保留到程序结束为止,因此也可以用来和其他UDP 套接字进行数据交换。当然,IP 用主机IP,端口号用未选用的任意端口号。 (自动分配的IP就是主机的IP,端口号就是没有被占用的端口号,TCP调用connect的时候也是如此)
综上所述,调用 sendto 函数时自动分配IP和端口号,因此,UDP 客户端中通常无需额外的地址分配过程。所以之前的示例中省略了该过程。这也是普遍的实现方式。
6.3 UDP的传输特性和调用connect函数
6.3.1 存在数据边界的UDP套接字
前面说得 TCP 数据传输中不存在数据边界,这表示「数据传输过程中调用 I/O 函数的次数不具有任何意义」。
相反,UDP 是具有数据边界的下一,传输中调用 I/O 函数的次数非常重要。因此,输入函数的调用次数和输出函数的调用次数完全一致,这样才能保证接收全部已经发送的数据。例如,调用 3 次输出函数发送的数据必须通过调用 3 次输入函数才能接收完。通过一个例子来进行验证:
// bound_host1.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.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];
socklen_t clnt_adr_sz;
int str_len, i;
struct sockaddr_in my_adr, your_adr;
if (argc != 2) {
printf("Usage : %s <Port>", argv[0]);
exit(1);
}
serv_sock = socket(PF_INET, SOCK_DGRAM, 0);
if (serv_sock == -1) error_handling("sock() error");
memset(&my_adr, 0, sizeof(my_adr));
my_adr.sin_family = AF_INET;
my_adr.sin_addr.s_addr = htonl(INADDR_ANY);
my_adr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*)&my_adr, sizeof(my_adr)) == -1)
error_handling("bind() error");
for (i = 0; i < 3; i++) {
clnt_adr_sz = sizeof(your_adr);
str_len = recvfrom(serv_sock, message, BUF_SIZE, 0,
(struct sockaddr*)&your_adr, &clnt_adr_sz);
sleep(5);
printf("Message %d : %s \n", i+1, message);
}
close(serv_sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
// bound_host2.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.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 msg1[] = "Hi!";
char msg2[] = "I'm another UDP host";
char msg3[] = "Nice to meet you";
struct sockaddr_in your_adr;
socklen_t your_adr_sz;
if (argc != 3) {
printf ("Usage : %s <IP> <Port>", argv[0]);
exit(1);
}
sock = socket(PF_INET, SOCK_DGRAM, 0);
if (sock == -1) error_handling("sock() error");
memset(&your_adr, 0, sizeof(your_adr));
your_adr.sin_family = AF_INET;
your_adr.sin_addr.s_addr = inet_addr(argv[1]);
your_adr.sin_port = htons(atoi(argv[2]));
sendto(sock, msg1, sizeof(msg1), 0,
(struct sockaddr*)&your_adr, sizeof(your_adr));
sendto(sock, msg2, sizeof(msg2), 0,
(struct sockaddr*)&your_adr, sizeof(your_adr));
sendto(sock, msg3, sizeof(msg3), 0,
(struct sockaddr*)&your_adr, sizeof(your_adr));
close(sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
bound_host2.c程序3次调用sendto函数以传输数据,bound_host1l.c则调用3次recvfrom函数以接收数据。recvfrom函数调用间隔为5秒,因此,调用recvfrom函数前已调用了3次sendto函数。也就是说,此时数据已经传输到bound_hostl.c。如果是TCP程序,这时只需调用1次输人函数即可读入数据。UDP则不同,在这种情况下也需要调用3次recvfrom函数。可通过以下运行结果进行验证。(sendto的次和recvfrom的次数必须保持一致)
编译运行:
gcc bound_host1.c -o host1
gcc bound_host2.c -o host2
./host1 9190
./host2 127.0.0.1 9190
结果:
结果表明调用了三次recvfrom函数,表明UDP是有边界的。
从运行结果也可以证明 UDP 通信过程中 I/O 的调用次数必须保持一致
6.3.2 已连接(connect)UDP 套接字与未连接(unconnected)UDP 套接字
TCP 套接字中需注册待传传输数据的目标IP和端口号,而在 UDP 中无需注册。因此通过sendto
函数传输数据的过程大概可以分为以下 3 个阶段:
- 第 1 阶段:向 UDP 套接字注册目标 IP 和端口号。
- 第 2 阶段:传输数据。
- 第 3 阶段:删除 UDP 套接字中注册的目标地址信息。
每次调用sendto
函数时重复上述过程。每次都变更目标地址,因此可以重复利用同一 UDP 套接字向不同目标传递数据。这种未注册目标地址信息的套接字称为未连接套接字,反之,注册了目标地址的套接字称为连接 connected 套接字。显然,UDP 套接字默认属于未连接套接字。当一台主机向另一台主机传输很多信息时,上述的三个阶段中,第一个阶段和第三个阶段占整个通信过程中近三分之一的时间,缩短这部分的时间将会大大提高整体性能。(如果传输的信息比较多的话,直接创建已连接UDP套接字即可)
6.3.3 创建已连接UDP套接字
创建已连接 UDP 套接字过程格外简单,只需针对 UDP 套接字调用 connect 函数。
sock = socket(PF_INET, SOCK_DGRAM, 0);
memset(&adr, 0, sizeof(adr));
adr.sin_family = AF_INET;
adr.sin_addr.s_addr = inet_addr(argv[1]);
adr.sin_port = htons(atoi(argv[2]));
connect(sock, (struct sockaddr *)&adr, sizeof(adr));
上述代码看似与 TCP 套接字创建过程一致,但 socket 函数的第二个参数分明是 SOCK_DGRAM 。也就是说,创建的的确是 UDP 套接字。当然针对 UDP 调用 connect 函数并不是意味着要与对方 UDP 套接字连接,这只是向 UDP 套接字注册目标IP和端口信息。(不是要连接,只是向UDP套接字注册目标IP和端口信息而已,这就是和TCP的connect
的区别)
之后就与 TCP 套接字一致,每次调用 sendto
函数时只需传递信息数据。因为已经指定了收发对象,所以不仅可以使用 sendto、recvfrom
函数,还可以使用 write、read
函数进行通信。(已连接的套接字是可以使用read或者write的)。
下面的例子就将之前的uecho_client.c
程序改成基于已连接UDP套接字的程序,将sendto、recvfrom
改为了 read、write
。
// uecho_con_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/socket.h>
#define BUF_SIZE 30
void error_handling(char* message);
int main(int argc, char* argv[]) {
int clnt_sock;
int str_len;
char message[BUF_SIZE];
socklen_t adr_sz;
struct sockaddr_in serv_adr, from_adr;
if (argc != 3) {
printf("Usage : %s <IP> <Port>", argv[0]);
exit(1);
}
clnt_sock = socket(PF_INET, SOCK_DGRAM, 0);
if (clnt_sock == -1) error_handling("sock() 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]));
connect(clnt_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
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(clnt_sock, message, strlen(message), 0,
// (struct sockaddr*)&serv_adr, sizeof(serv_adr));
write(clnt_sock, message, strlen(message));
// adr_sz = sizeof(serv_adr);
// str_len = recvfrom(clnt_sock, message, BUF_SIZE, 0,
// (struct sockaddr*)&serv_adr, &adr_sz);
str_len = read(clnt_sock, message, BUF_SIZE - 1);
message[str_len] = 0;
printf("Message from server : %s", message);
}
close(clnt_sock);
return 0;
}
void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
具体的结果就不展示了,跟之前的是一样的。
6.4 基于Windows的实现
暂略。