基于TCP
0x00 服务器端函数用法
下面各小节将按照TCP服务器端默认的函数调用顺序来介绍其用法。绝大部分的TCP服务器端都按照该顺序调用。
0x01 socket()
创建套接字
在进入正题之前,先简单介绍一下文件与 Socket。
在 Linux 系统中,Socket 也被认为是文件的一种,因此在网络数据传输过程中也会用到文件 I/O 相关的函数。在 Linux 系统中成功调用 open()
函数打开一个文件时会返回一个文件描述符(File Descriptor, fd)。文件描述符是系统分配给文件或套接字的整数,用于方便标记操作系统创建的文件或套接字。(文件描述符有时也被称为「文件句柄」,但「句柄」主要是 Windows 系统中的术语)。
文件和套接字一般经过创建过程才会被分配文件描述符,而有三种输入输出对象即使未经过特殊的创建过程,在程序开始运行后也会被自动分配文件描述符。这三种输入输出对象为:
文件描述符 | 输入输出对象 |
---|---|
0 | 标准输入 |
1 | 标准输出 |
2 | 标准错误 |
因为 0、1、2 是分配给标准 I/O 的描述符,所以系统在创建文件和套接字时所获的的文件描述符是从 3 开始以由小到大的顺序编号的。
好了,下面开始进入正题。
1 |
|
- 返回对象:成功时返回一个文件描述符,失败时返回-1;
domain
:套接字中使用的协议簇(protocol family,PF)信息;type
:套接字所选用的数据传输类型;protocol
:计算机间通信所采用的协议;
下面介绍各个参数的可选择传递对象。
协议簇(Protocol Family,PF)
常用的互联网协议簇为 IPv4 互联网协议簇,该协议簇在头文件 sys/socket.h
中被定义为 PF_INET
套接字类型
套接字类型值得是套接字的数据传输方式,在IPv4协议簇中也存在多种数据传输方式,因此需要指定具体采用哪种数据传输方式。具体的传输方式有:
SOCK_STREAM
:创建面向连接的数据流套接字,其特征为- 传输过程中数据不会丢失;
- 按序传输数据;
- 传输的数据不存在数据边界,收到数据并不意味着马上调用
read()
函数,read()
函数和write()
函数的调用次数并无太大意义;
SOCK_DGRAM
:创建面向消息的套接字,其特征为- 强调快速传输而非传输顺序;
- 传输的数据有可能丢失也有可能损坏;
- 传输的数据有数据边界,这意味着接受数据的次数应和传输次数相同,也即
read()
函数和write()
函数的调用次数一致; - 限制每次传输的数据大小;
协议的选择
大多数情况下,通过前两个参数(协议簇和套接字类型)已经决定了所采用的传输协议,所以大部分情况下可以向第三个参数传递 0,除非遇到「同一协议簇中存在多个数据传输方式相同的协议」的情况。
参数 PF_INET
指 IPv4 网络协议簇,SOCK_STREAM
是面向连接的数据传输,满足这两个条件的协议只有 IPPROTO_TCP
,因此可以用如下方式创建 TCP 套接字:
1 |
|
满足 IPv4 协议簇和面向消息数据传输方式的协议只有 IPPROTO_UDP
,因此可以用如下方式创建 UDP 套接字:
1 |
|
0x02 bind()
创建套接字后需要使用 bind()
函数为其分配 IP 地址和端口号。IP 地址用于区分网络上的计算机,而端口号用于区分计算机程序中所创建的套接字。
这里的 IP 地址主要是指的常用的 IPv4 地址,标准的 IPv4 地址用 4 字节表示。端口号由 2 个字节表示,可分配的端口号范围为 0~65535,但 0~1023 是知名端口号,一般分配给特定的应用程序,所以应当分配此范围之外的值。
另外,虽然端口号不能重复,但 TCP 套接字和 UDP 套接字不会共用端口号,所以允许重复。bind()
函数负责为套接字分配地址信息:
1 |
|
- 返回值:成功时返回0,失败时返回-1。
sockfd
:要分配地址信息(IP地址和端口号)的套接字文件描述符。myaddr
:存有地址信息的结构体变量地址。addrlen
:存有地址信息的结构体变量的长度。
下面介绍描述地址信息的结构体。
表示IPv4地址的结构体
可以看到bind()
函数需要的存有地址信息的结构体类型为sockaddr
,其定义如下:
1 | struct sockaddr { |
此结构体类型成员 sa_data
保存的地址信息中需包含 IP 地址和端口号,剩余部分应填充 0,而这对于构造地址信息来说比较麻烦,因而有了表示 IPv4 地址的结构体类型 sockaddr_in
,其定义如下:
1 | struct sockaddr_in { |
该结构体中提到的另一个结构体 in_addr
的定义如下,它用来存放 32 位 IP 地址:
1 | struct in_addr { |
结构体 sockaddr_in
的成员分析:
sin_family
:每种协议族适用的地址族不同,AF_INET
表示 IPv4 协议族,该协议族使用 4 字节地址族。sin_port
:保存 16 位端口号,以网络字节序保存(大端序,数据的高位字节放到低位地址)。sin_addr
:保存 32 位 IP 地址信息,也以网络字节许保存。sin_zero
:无特殊含义,只用为了使结构体sockaddr_in
的大小与sockaddr
结构体保持一致而插入的填充成员。
字符串IP地址转为网络字节序的整数型IP地址
sockaddr_in
中所保存的地址信息成员位 32 位整数,因此,为了分配 IP 地址,需要将其表示为 32 位整数型数据。
而对于IP地址的表示,我们熟悉的是「点分十进制表示法」(Dotted Decimal Notation),而非整数型数据表示法。幸运的是有两个函数会帮我们把字符串形式的IP地址换成32位整数型数据,并同时完成网络字节序的转换:
1 |
|
inet_addr()
函数不仅可以把 IP 地址转为 32 位整数型网络字节序,而且可以检测无效的 IP 地址。
而inet_aton()
函数与inet_addr()
函数在功能上完全相同,也将字符串形式的IP地址转换为32位网络字节序整数型IP地址,只不过该函数使用了in_addr
的结构体,且其使用频率更高。
网络地址初始化并与套接字绑定
结合前面内容,套接字创建过程中常见的网络地址信息初始化方法,及其与套接字的绑定过程示例如下:
1 |
|
0x03 listen()
等待连接(监听)
在调用 bind()
函数为套接字分配了网络地址后,接下来就要通过调用 listen()
函数进入「等待连接请求」状态。服务器端只有调用了 listen()
函数,客户端才能进入可发出连接请求的状态。
服务器处于「等待连接请求」状态是指在客户端发出「请求连接」之前,服务器一直处于等待状态。
1 |
|
- 返回值:成功时返回0,失败时返回-1。
sock
:希望进入「等待连接请求」状态的套接字文件描述符,传递的文件描述符所属套接字将称为服务器端套接字(监听套接字)。backlog
:连接请求等待队列的长度,表示最多允许进入等待队列的连接请求数量。
0x04 accept()
受理客户端连接请求
调用listen()
函数后,若有新的连接请求,则按序受理。「受理请求」意味着进入「可接受数据」的状态,这种状态下需要创建一个新套接字来专门与客户端进行数据交换,accpet()
函数会自动创建套接字并连接到发起请求的客户端。
1 |
|
- 返回值:成功时返回创建的套接字文件描述符,失败时返回-1。
sock
:服务器套接字(监听套接字)的文件描述符。addr
:保存发起连接请求的客户端地址信息的变量地址值,调用函数后会向该地址变量参数填充客户端的地址信息。addrlen
:第二个参数addr
结构体的长度,但是存有长度的变量地址,调用函数后向该变量填充客户端地址长度。0x05
直接上代码示例:read()/write()
以及close()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int main() {
int server_fd = -1;
int client_fd = -1;
char SERVER_IP[] = "101.76.220.10";
uint16_t SERVER_PORT = 1234;
// 创建服务器端的套接字
server_fd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
struct sockaddr_in server_addr; // 服务器地址信息
struct sockaddr_in client_addr; // 客户端地址信息
server_addr.sin_family = AF_INET; // 设置协议簇
if(!inet_aton(SERVER_IP, &server_addr.sin_addr)) { // 设置IP地址
printf("ip error!\n");
}
server_addr.sin_port = htons(SERVER_PORT); // 设置端口号
// 将服务器端套接字保定到指定的地址和端口
bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 监听
listen(server_fd, 10);
// 接受客户端的连接
socklen_t client_addr_size = sizeof(client_addr);
client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_addr_size);
// 向套接字写数据
char buffer[] = "Hello World!";
write(client_fd, buffer, sizeof(buffer));
// 关闭所有套接字
close(client_fd);
close(server_fd);
}
0x10 客户端函数用法
客户端相比服务器端要简单的多,因为创建套接字和请求连接就是客户端的全部内容。
创建套接字的过程同服务器端一样,区别在于客户端套接字用于「请求连接」,也即创建客户端套接字后向服务器端发起连接请求。因此这里只介绍发起连接请求的方法。
0x11 connect()
发起连接请求
服务器端调用listen()
函数后创建「连接请求等待队列」,之后客户端即可请求连接,发起请求连接的函数如下:
1 |
|
- 返回值:成功时返回 0,失败时返回 -1 。
sock
:客户端套接字文件描述符。servaddr
:保存有目标服务器地址信息的变量地址值。addrlen
:第二个结构体参数servaddr
的地址变量长度。
客户端调用connect()
函数后,发生以下情况之一才会返回(完成函数调用):
- 服务器端接受连接请求。
- 发生断网等异常情况而中断连接请求。
需要注意两点:
- 所谓的「接受连接」并不意味着服务器端调用
accept()
函数,实际情况是服务器端把连接请求信息记录到「等待连接请求队列」。因此connect()
函数返回后并不立即进行数据交换。 - 客户端套接字创建过程中并没有调用
bind()
函数进行分配 IP 地址和端口,客户端套接字的 IP 地址和端口在调用connect()
函数时会自动分配。
0x12 read()/write()
以及close()
客户端代码示例:
1 |
|
基于UDP的服务器端/客户端
为了提供可靠的数据传输,TCP 在不可靠的 IP 层进行流控制,而 UDP 就缺少这种流控制。UDP 的主要作用就是根据端口号将传到主机的数据包交付给最终的 UDP 套接字。
没有流控制的 UDP 相比 TCP 通常更快,主要原因有以下两点:
- 收发数据前后进行的连接设置及清除过程。
- 收发数据过程中为保证可靠性而添加的流控制。
因此,当收发的数据量小但需要频繁连接时,UDP 比 TCP 更高效。
UDP 服务器端/客户端不像 TCP 那样在连接状态下交换数据,也即不需要调用 TCP 连接过程中调用的 listen()
和 connect()
函数,UDP 中只有创建套接字的过程和数据交换过程。也因此,UDP 套接字不会保持连接状态,每次传输数据时都要添加目标信息。
0x00 基于UDP的数据I/O函数
下面介绍填写数据接收地址并输出数据时调用的UDP函数。
1 |
|
- 返回值:成功时返回传输的字节数,失败时返回-1 。
sock
:用于传输数据的UDP套接字文件描述符。buff
:保存待传输数据的缓冲地址值。nbytes
:待传输数据的长度,以字节为单位。flags
:可选项参数,若没有则传递0。to
:存有目标地址信息的sockaddr
结构体变量的地址值。addrlen
:参数to
的地址值结构体变量长度。
上述函数与之前的 TCP 输出函数最大的区别在于,此函数需要向它传递目标地址信息。接下来介绍接收 UDP 数据的函数。
UDP 数据的发送端并不固定,因此该函数定义为可接收「发送端信息」的形式,也即可以同时返回 UDP 数据包中的「发送端信息」。
1 |
|
- 返回值:成功时返回接收的字节数,失败时返回-1 。
buff
:保存接收数据的缓冲地址值。nbytes
:可接受的最大字节数,故不能超过参数buff
所指定的缓冲区大小。flags
:可选项参数,若没有则传入0 。from
:用于保存接收到的发送端地址信息的sockaddr
结构体变量的地址值。addrlen
:用于保存参数from
的结构体变量长度的变量地址值。
0x10 基于UDP的「回声」服务器端/客户端
UDP不同于TCP,不存在请求连接和受理过程,因此在某种意义上无法明确区分服务器端和客户端。这里只是把提供服务的一端称为服务器端,把请求服务的一端称为客户端。
0x11 UDP服务器端
1 |
|
下面介绍于上述服务器端协同工作的客户端。
1 |
|
UDP 程序中,调用 sendto()
函数传输数据前应完成对套接字的地址分配工作,因此需要调用 bind()
函数,该函数不区分 TCP 和 UDP,也即在 UDP 和 TCP 程序中均可调用。
另外,如果调用 sendto()
函数时发现尚未分配地址信息,则在首次调用 sendto()
时给相应的套接字自动分配 IP 地址和端口。因此,UDP 客户端中通常无需额外的地址分配过程。
0x20 存在数据边界的UDP套接字
TCP 数据传输过程中不存在数据边界,这表示「数据传输过程中调用 I/O 函数的次数不具有任何意义」。
而 UDP 是具有数据边界的协议,数据传输过程中调用 I/O 函数的次数非常重要。因此,输入函数的调用次数应该和输出函数的调用次数完全一致,这样才能保证接收端能够接收到所有已发送数据。
UDP 套接字传输的数据包又称为「数据报」,实际上数据报也属于数据包的一种。UDP 中存在数据边界,一个数据包即可成为一个完整数据,因此称为数据报。
0x30 已连接UDP套接字和未连接UDP套接字
TCP 套接字中需要调用 connect()
函数注册待传输数据的目标 IP 和端口号,而 UDP 中则无需注册。因此,通过 sendto()
函数传输数据的过程大致可以分为以下 3 个阶段:
- 第一阶段:向 UDP 套接字注册目标 IP 和端口号。
- 第二阶段:传输数据。
- 第三阶段:删除UDP套接字中注册的目标地址信息。
每次调用 sendto()
函数时都会重复上述过程,每次都变更目标地址,因此可以重复利用同一个 UDP 套接字向不同的目标地址传输数据。
这种未注册目标地址信息的套接字被称为未连接套接字,反之,注册了目标地址的套接字称为连接(connected)套接字。显然,UDP 套接字默认属于未连接套接字。
在上述三个阶段中,第一个和第三个阶段占用整个通信过程将近 1/3 的时间,缩短这部分时间则将大大提高整体性能。因此,若要与同一主机进行长时间通信,将 UDP 套接字变成已连接套接字将会大大提高效率。
0x31 创建已连接套接字
要创建已连接UDP套接字只需要对UDP套接字调用connect()
函数。
1 | sock = socket(PF_INET, SOCK_DGRAM, 0); |
针对 UDP 套接字调用 connect()
函数并不意味着要与对方 UDP 套接字连接,这只是向 UDP 套接字注册目标 IP 和端口信息。
注册之后就与 TCP 套接字一样,每次调用 sendto()
函数时只需传输数据。因为已经指定了收发对象,所以不仅可以使用 sendto()
、recvfrom()
函数,还可以使用 write()
、read()
函数进行通信。
可以对上文提到的 UDP 客户端程序进行修改,改为已连接 UDP 套接字,修改后的代码如下:
1 |
|