网络编程学习笔记
多年前写的笔记,同步上传下。
一、网络编程速览
网络编程就是编写程序使两台连网计算机互相通信。
1.1 套接字概念
套接字就是用于网络通信的操作系统接口。
1.1.1 服务器套接字用到的各函数
1 |
|
接受连接请求的服务器端套接字编程流程:
- 调用 socket 函数创建套接字;
- 调用 bind 函数为套接字分配 IP 地址与端口号;
- 调用 listen 函数将套接字转换为可接收状态;
- 调用 accept 函数受理连接请求。accept 会阻塞,直到有连接请求才会返回;
- 调用 read/write 函数进行数据交换;
- 调用 close 函数断开连接;
1.1.2 客户端用到的各函数
客户端程序只需:1)调用socket函数创建套接字;2)调用connetc函数向服务端发送连接请求。
1 |
|
客户端请求连接步骤:
- 调用 socket 函数创建套接字;
- 调用 connect 函数请求连接;
- 调用 read/write 函数进行数据交换;
- 调用 close 函数断开连接;
客户端的 IP 地址和端口在调用 connect 函数时自动分配,无需调用 bind 函数。
1.2 服务器端和客户端
(1)下面展示一个简单的服务端代码,该服务端收到客户端发起的连接请求后向请求者返回“hello world!”。
1 | // hello_server.c |
(2)客户端代码
1 | // hello_client.c |
1.3 在Linux下运行
linux下C语言编译器-gcc;
gcc hello_server.c -o hserver
这句命令编译hello_server.c并生成名字为hserver的可执行文件, -o
为指定可执行文件名的可选参数。
(1)打开一个终端,运行服务端,$
是命令行模式行下的命令提示符。
1 | gcc hello_server.c -o hserver |
此时程序将停留在此状态,等待客户端的请求
(2)打开另外一个终端
1 | gcc hello_client.c -o hclient |
1.4 Linux文件操作
linux下万物皆为文件。socket也被认为是文件,因此可以使用文件I/O的相关函数。通过套接字发送、接收数据就和读写文件一样,通过 read、write 这些函数来接收、发送数据。
文件描述符是操作系统分配给文件或套接字的整数。
0、1、2 分别由系统分配给了标准输入、标准输出和标准错误。
文件和套接字创建时才会被分配文件描述符。它们的文件描述符会从 3 开始按序递增。
Windows 系统中术语”句柄“和 Linux 中的文件描述符含义相同。
1 |
|
文件打开模式:
以_t为后缀的数据类型,这些都是元数据类型,由操作系统定义。
一般在sys/types.h 头文件中由typedef声明定义。
size_t 的类型是 unsigned int,ssize_t 的类型是 signed int。
二、套接字类型与协议设置
协议就是对话中的通信规则。
2.1 套接字创建函数
回顾创建套接字的函数
1 | int socket(int domain, int type, int protocol);//成功时返回文件描述符,失败时返回 -1 |
创建套接字的函数 socket 的三个参数的含义:
- domain:使用的协议族。一般只会用到 PF_INET,即 IPv4 协议族。
- type:套接字类型,即套接字的数据传输方式。主要是两种:SOCK_STREAM(即 TCP)和 SOCK_DGRAM(即 UDP)。
- protocol:选择的协议。一般情况前两个参数确定后,protocol 也就确定了,所以设为 0 即可。
(1)domain参数可选的协议族:
(2)type参数
同一个协议族可能有多种数据传输方式,因此在指定了 socket 的第一个参数后,还要指定第二个参数 type,即套接字的传输方式。
最主要的两种方式分别是TCP和UDP。
1 | int tcp_socket = socket(PF_INET, SOCK_STREAM, 0); |
(3)protocol参数
这个参数代表协议的最终选择。
有这么一种情况:同一协议族中存在多个数据传输方式相同的协议,所以还需要第三个参数 protocol 来指定具体协议。但是 PF_INET(IPv4 协议族)下的 SOCK_STREAM 传输方式只对应 IPPROTO_TCP 一种协议,SOCK_DGRAM 传输方式也只对应 IPPROTO_UDP 一种协议,所以参数 protocol 只要设为 0 即可。
1 | int tcp_socket = socket(PF_INET, SOCK_STREAM, 0); |
三、地址族与数据序列
基础知识:
IPv4 地址为 4 字节,IPv6 是 16 字节地址族。
端口号 2 字节,范围是 065535。其中 **01023 是熟知端口号**。
3.1 地址信息的表示
套接字创建后,我们还需要为它绑定IP地址和端口号。回忆绑定地址的函数:
1 | int bind(int sockfd, struct sockaddr *myaddr, socklen_t addrlen);// 返回值:成功时返回 0,失败时返回 -1 |
IP和端口的信息就存在这个结构体sockaddr中。但是这个结构体是通用的设计(考虑到了IPV4和IPV6),IPV4有特定的结构体。
通常使用时将结构体强制类型转换即可。
1 | struct sockaddr_in serv_addr; |
3.1.1 通用地址结构体 sockaddr
sockaddr 结构体定义如下,它是通用的结构体,并非只为 IPv4 设计。
1 |
|
3.2.2 IPv4地址结构体sockaddr_in
sockaddr_in 是保存 IPv4 地址信息的结构体。除了保存端口号外,还用了一个结构体来存储IP号。
可以看到它和sockaddr一样开头是sin family,并且结构体长度一致,这样才能强制类型转换。
1 |
|
in_addr (用于表示 IP 地址)定义如下:(
1 |
|
sockaddr_in结构体成员分析:
sin_family 地址族
这个代表地址族,不同协议使用的地址族不同。回忆在创建socket时,使用到了domain这个参数,就用了PF_INET(IPv4 协议族), PF_INET(IPv4 协议族) 对应的地址族是 **AF_INET( IPv4 地址族)**。
sin_port 端口号
以网络字节序保存 16 位端口号。后面会讲解网络字节序(大端序)
sin_addr IP号
类型为结构体 in_addr,in_addr 的成员 s_addr 按网络字节序保存 32 位 IP 地址。
sin_zero 填充字段
无特殊含义。只是为了使结构体 sockaddr_in 的大小与 sockaddr 结构体一致而插入的成员,必须填充为 0。
3.2 网络字节序
3.2.1 字节序
CPU 向内存保存数据的方式有两种:
- 大端序:高位字节存放到低位地址。网络字节序为大端序。
- 小端序:高位字节存放到高位地址。目前主流的 Intel 系列 CPU 按小端序方式保存数据。
在使用网络发送数据时统一约定要先把数据转化成大端序,接收时也要先转换为主机字节序。
3.2.2 字节序转换函数
接下来介绍一些帮助转换字节序的函数
1 |
|
htons 中的 h 代表主机字节序,n 代表网络字节序。(主机字节序向网络字节序转换)
s 代表 short 类型,处理 2 字节数据,用于端口号转换;l 代表 long 类型(Linux 中 long 占用 4 字节),处理 4 字节数据,用于 IP 地址转换。
除了向 sockaddr_in 结构体变量填充数据时需要进行字节序转换外,其他情况无需考虑字节序问题,会自动转换。
3.3 网络地址的初始化
一般我们描述 IP 地址时用的是字符串格式的点分十进制表示法,而sockaddr_in 中保存地址信息的成员是 32 位整型,因此需要将字符串形式的点分十进制 IP 地址转换为 32 位整型数据。
有两个函数可以完成以上功能:inet_addr 函数和 inet_aton 函数。
3.3.1 字符串IP地址转为整数
(1)inet_addr 函数
在转换类型的同时也会完成网络字节序的转换,它还可以检测无效的 IP 地址。
1 |
|
(2)inet_aton函数
net_aton 函数和 inet_addr 函数的功能相同,也是将字符串形式的 IP 地址转换为 32 位网络字节序整数,但是它利用了 in_addr 结构体,使用频率更高。
inet_aton 需要传递一个 in_addr 类型结构体的指针,它会将转换结果直接放入该指针所指的 in_addr 结构体中。
1 |
|
3.3.2 整数型IP地址转为字符串
inet_ntoa 函数与 inet_aton 函数相反,它将网络字节序的整数型 IP 地址转换为字符串形式。
1 |
|
该函数使用时要小心:返回值类型为 char 指针,返回字符串地址意味着字符串已保存到内存空,但该函数是在内部申请了内存并保存了字符串,因此如果再次调用 inet_ntoa 函数,也有可能覆盖之前保存的字符串信息。
因此要将返回的字符串信息复制到其他内存空间。
3.3.3 常用网络地址初始化操作
结合前面所述内容,下面是套接字创建过程中常见的网络地址信息初始化方法:
1 | struct sockaddr_in addr; |
每次创建服务器端套接字都要输入IP地址会很麻烦,可以用常数 INADDR_ANY 自动获取服务器端的 IP 地址。
1 | addr.sin_addr.s_addr = htonl(INADDR_ANY); |
使用 INADDRY_ANY,如果同一个计算机具有多个 IP 地址,那么可以从不同 IP 地址(的同一端口号)接收数据,因此服务器端中优先使用 INADDR_ANY,而客户端不应该采用。
服务器端和客户端都要进行网络地址信息的初始化,但目的不同:
- 服务器端要将声明的 sockaddr_in 结构体变量初始化为自己的 IP 地址和端口号,用于在 bind 函数中与自己的套接字相绑定。
- 客户端也要将声明的 sockaddr_in 结构体变量初始化为服务器端的 IP 地址和端口号,用于在 connect 函数中向服务器发起连接请求。
四、基于TCP的客户端与服务端
4.1 理解TCP和UDP
自行阅读计算机网络相关知识。
4.2 实现基于TCP的服务器端/客户端
基于TCP的服务端/客户端函数调用方式:
前面已经介绍过socket函数和bind函数,下面介绍后面几个过程。
4.2.1 listen函数等待连接请求
假设已经调用bind函数为套接字分配地址,接下来就要调用listen函数等待连接请求。只有服务端调用了listen函数后,客户端才能待用connnet函数发起连接请求。
1 |
|
等待连接请求状态:“服务器处于等待连接请求状态”指让来自客户端的请求处于等待状态。
连接请求等待队列:还未受理的连接请求在此排队,backlog 的大小决定了队列的最大长度,一般频繁接受请求的 Web 服务器的 backlog 至少为 15。
4.2.2 accept函数受理连接请求
accept 函数会受理连接请求等待队列中待处理的客户端连接请求,它从等待队列中取出 1 个连接请求,创建套接字并完成连接请求。如果等待队列为空,accpet 函数会阻塞,直到队列中出现新的连接请求才会返回。
1 |
|
它会在内部产生一个新的套接字并返回其文件描述符,该套接字用于与客户端建立连接并进行数据 I/O。新的套接字是在 accept 函数内部自动创建的,并自动与发起连接请求的客户端建立连接。
accept 执行完毕后会将它所受理的连接请求对应的客户端地址信息存储到第二个参数 addr 中。
4.2.3 客户端conncet函数发起请求
1 |
|
客户端调用 connect 函数后会阻塞,直到发生以下情况之一才会返回:
- 服务器端接收连接请求。
- 发生断网等异常情况而中断连接请求。
注意:上面说的”接收连接请求“并不是服务器端调用了 accept 函数,而是服务器端把连接请求信息记录到等待队列。因此 connect 函数返回后并不立即进行数据交换。
客户端的IP地址和端口在调用connect函数时自动分配,无需调用bind函数进行分配。
再次回顾基于TCP的服务端和客户端函数调用关系:
客户端只有等到服务器端调用 listen 函数后才能调用 connect 函数,否则会连接失败。
客户端调用 connect 函数和服务器端调用 accept 函数的顺序不确定,先调用的要等待另一方。
4.3 实现迭代回声服务端/客户端
回声服务器端:它会将客户端传输的字符串数据原封不动地传回客户端,像回声一样。
4.3.1 实现迭代服务器端
调用一次 accept 函数只会受理一个连接请求,如果想要继续受理请求,最简单的方法就是循环反复调用 accept 函数,在前一个连接 close 之后,重新 accept。在不使用多进程/多线程情况下,同一时间只能服务于一个客户端。
迭代回声服务器端与回声客户端的基本运行方式:
- 服务器端同一时刻只与一个客户端相连接,并提供回声服务。
- 服务器端依次向 5 个客户端提供服务,然后退出。
- 客户端接收用户输入的字符串并发送到服务器端。
- 服务器端将接收到的字符串数据传回客户端,即”回声“。
- 服务器端与客户端之间的字符串回声一直执行到客户端输入 Q 为止。
1 | //code |
4.3.2 回声客户端存在的问题
在本章的回声客户端的实现中有下面这段代码,它有一个错误假设:每次调用 read、write 函数时都会执行实际的 I/O 操作。
1 | write(sock, message, strlen(message)); |
但是注意:TCP 是面向连接的字节流传输,不存在数据边界。所以多次 write 的内容可能一直存放在发送缓存中,某个时刻再一次性全都传递到服务器端,这样的话客户端前几次 read 都不会读取到内容,最后才会一次性收到前面多次 write 的内容。还有一种情况是服务器端收到的数据太大,只能将其分成多个数据包发送给客户端,然后客户端可能在尚未收到全部数据包时旧调用 read 函数。
理解:问题的核心在于 write 函数实际上是把数据写到了发送缓存中,而 read 函数是从接收缓存读取数据。并不是直接对 TCP 连接的另一方进行数据读写。
解决方式见下一章
4.4 回声客户端的完美实现
回顾服务端的实现代码:
1 | while((str_len = read(clnt_sock, message, BUF_SIZE)) != 0) |
循环调用read函数,当read函数读成功或失败时,继续读,直至文件尾标志。这是没有问题的。
回顾客户端的代码:
1 | write(sock, message, strlen(message)); |
回声客户端的问题实际上就是没有考虑拆包和粘包的情况。
4.4.1 回声客户端的解决办法
解决方法的核心:提前确定接收数据的大小。
客户端上一次使用 write 从套接字发送了多少字节,紧接着就使用 read 从套接字读取多少字节。
1 | str_len=write(sock, message, strlen(message)); //发送的数据长度 |
4.4.2 问题的另一视角:应用层协议
上面的回声客户端中,假设提前就知道接收数据的长度。但是一般情况下是不知道的,这时解决拆包和粘包的问题,就要定义应用层协议。
之前回声服务端和客户端就定义了协议:“收到Q就终止连接”
应用层协议实际就是在服务器端/客户端的实现过程中逐步定义的规则的集合。在应用层协议中可以定好数据边界的表示方法、数据的长度范围等。
4.5 TCP原理
4.5.1 TCP套接字中的I/O缓冲
在使用 read/write 函数对套接字进行读写数据时,实际上读写的是套接字输入/输出缓冲中的内容。
套接字 I/O 缓冲的特性:
- I/O 缓冲在每个套接字中单独存在。
- I/O 缓冲在创建套接字时自动生成。
- 即使关闭套接字也会继续传递输出缓冲中遗留的数据。
- 关闭套接字将丢失输入缓冲中的数据。
五、基于UDP的服务端与客户端
5.1 理解UDP
区分 TCP 与 UDP 的一个典型比喻:UDP 好比寄信,TCP 好比打电话:
- UDP:寄信前要在信封上填好寄信人和收信人的地址,然后放进邮筒。不能确认对方是否收到信件,并且邮寄过程中信件可能丢失。
- TCP:首先要拨打电话号码,打通后才能开始通话,但打通后的通话是可靠的。
TCP 和 UDP 最重要的区别在于流控制。
理解:这里的流控制应该包含了 TCP 的可靠传输、流量控制、拥塞控制等机制,这些机制都是在流上实现的。
TCP 是可靠的(面向连接)、按序传递、基于字节的
UDP 不可靠、无序
UDP的高效使用
网络实时传输多媒体数据一般使用 UDP。
TCP 比 UDP 慢的两个原因:
- TCP 数据传输前后要进行连接的建立与释放。
- 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 套接字不会保持连接状态,因此每次传输数据时都要添加目标地址信息(相当于寄信前在信封上写收信地址)。
1 |
|
上述函数与TCP输出函数最大的区别在于需要传递目标地址信息。
5.2.3 接收UDP数据的函数
1 |
|
接收端本来是不知道发送端的地址的,但调用完 recvfrom 函数后,发送端的地址信息就会存储到参数 from 指向的结构体中。
5.3 基于UDP的回声服务端/客户端
5.3.1 服务端代码
1 | //uecho_server.c |
5.3.2 客户端代码
1 | //uecho_client.c |
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 函数传输数据的过程包括三个阶段:
- 向 UDP 套接字注册目标 IP 和端口号;(注意:是将 UDP 套接字与目标的地址信息相关联,不是给 UDP 分配地址信息。前者每次 sendto 都会执行,后者只有首次调用且套接字尚未分配地址时才会执行一次)。
- 传输数据;
- 删除 UDP 套接字中注册的目标地址信息。
当多次通过 sendto 向同一个目标发送信息时,每次 sendto 都进行上面的步骤 1 和 3,就会很浪费时间。因此当要长时间与同一主机通信时,将 UDP 变为已连接套接字(注册了目标地址的套接字)会提高效率。
利用connect函数注册地址,并不意味着与对方UDP套接字连接。
1 | connect(sock, (struct sockaddr*)&adr, sizeof(adr)); // 注意:adr 是目标的地址信息 |
使用已连接的 UDP 套接字进行通信时, sendto 函数就不会再执行步骤 1 和步骤 3,每次只要传输数据即可。
并且已连接的 UDP 套接字也可以通过 write、read 函数进行通信。
1 | //code |
六、优雅地断开套接字
6.1 基于TCP的半关闭
TCP 的断开连接过程比建立连接过程更重要,因为断开过程更有可能出现意外情况。
Linux 的 close 函数和 Windows 的 closesocket 函数都意味着完全断开连接。完全断开不仅无法发送也无法接收数据。在某些情况下,通信一方完全断开连接就显得很不优雅。
6.1.1 套接字和流
建立 TCP 套接字连接后可交换数据的状态可以看成一种流。进行双向通信就需要两个流,输入流和输出流。调用close 将会同时断开两个流。
有一种方法是断开一部分连接:只断开输入流或输出流。shutdown 函数用于只断开其中一个流。
1 |
|
第二个参数 howto 将决定关闭的方式,可取的值如下:
- SHUT_RD:断开输入流,此后套接字无法接收数据;
- SHUT_WR:断开输出流,此后套接字无法发送数据;
- SHUT_RDWR:同时断开 I/O 流。
他们的值按序分别是 0, 1, 2;
为什么需要半关闭?
一方在发送完所有数据后可以只关闭输出流但保留输入流,这样还可以接收对方的数据。
6.2 半关闭的文件传输程序
1 | //file_server.c |
1 | //file_client.c |
七、域名及网络地址
7.1 域名系统
通常人们很难记住IP地址,但是域名就比较通俗易懂。于是将域名对应一个IP地址。DNS对域名和IP地址进行转换,核心是DNS服务器。
可以通过 ping 命令查看域名对应的 IP 地址。查看本机的默认 DNS 域名服务器地址可以使用 nslookup 命令。
7.2 IP地址和域名之间的转换
7.2.1利用域名获取IP地址
可以使用以下函数来根据字符串格式的域名获取 IP 地址。
1 |
|
这个函数使用时输入字符串域名,返回装有地址信息的hostent结构体指针。
hostent 结构体的定义如下:
1 | struct hostent |
调用 gethostbyname 函数后返回的 hostent 结构体的变量结构如下图:
注意:h_addr_list 中存储地址的方式是 char*,而 char* 的内容并不是地址值,实际上是 in_addr 结构体的地址。
因此要取得点分十进制字符串格式的地址,需要先将 char 转换为 in_addr 类型,然后解引用取得整数地址值,再使用 inet_ntoa 将其转换为点分十进制格式的字符串。**
1 | host=gethostbyname(argv[1]); |
为什么h_addr_list不采用in_addr*类型的数组?
答:为了通用性,hostent结构体并非只为IPv4准备。
7.2.2 利用IP地址获取域名
gethostbyaddr 函数利用 IP 地址获取域名
1 |
|
1 | addr.sin_addr.s_addr=inet_addr(argv[1]); //inet_addr将字符串形式的IP转化为整数值 |
八、套接字的多种可选项
略