Linux C 网络基础编程
基础知识
在进行网络编程之前,我们需要简单回顾一下计算机网络五层模型的网络层和传输层,这两层在面向后端编程时用的最多。物理层和链路层过于底层,已经完全由内核协议栈实现,不再细述。
这里假设读者已经对计算机网络有一个大致的了解。
网络层
IP协议是四层模型中的核心协议,所有TCP、UDP、ICMP数据都通过IP数据报传输。IP提
供一种尽力而为(就是)、无连接的数据报交付服务。不可靠意味着如果传递过程不可靠中出现差错,IP层可以选择丢弃数据,并且不会主动重传;无连接意味着IP协议不会记录传递过程中的路径,那同样的两端发生的不同数据报可能会走不同的路径,并且有可能不按顺序到达。
IP地址以及其分类
IP地址用来区分不同的主机在网络层中的位置。IPv4的地址长度为32位,为了方便描述,通常将其按8位一组分隔,并用.号隔开,这种就是点分十进制,比如192.168.1.1。IPv6的地址长度是128位,一般用8个4位十六进制描述,每个十六进制数描述一个段。IPv6的应用可以解决IP地址缺乏的问题,但是随着NAT技术的广泛使用,IPv4目前实际上还是占据了大部分市场。
在早期,每个IP地址会分为两部分,高位是网络号,低位是主机号,并且根据网络号的前缀,将其分为5类地址,A、B、C、D(这是用于组播的)和E(这是保留的地址)类,这种分类方式除了规定了网络号的前缀,还是划定了网络号和主机号的长度。随着Internet逐渐发展,这种死板的分类方式已经不适应人们的需求。一种相对自由的划分方式就是采用子网机制:把主机号的前缀作为子网ID,剩余部分作为主机ID,主机ID的长度由本地网络管理员自行划定。
子网掩码可以用来描述主机ID的长度,其长度和IP地址一样是32,其中高位部分全为1,剩余的部分全为0。前缀部分的长度说明网络号和子网ID的长度,剩余部分自然就是主机ID了。子网掩码可以用在局域网内为路由器的路由决策作出参考,而局域网外的路由决策则只和网络号部分有关。我们可以用点分十进制来描述子网掩码(比如255.255.255.0),或者在IP地址的后缀中说明网络号和子网ID的总长度(比如192.168.3.0/24)
在每个IPv4子网中,主机部分全为1的IP地址被保留为本地广播地址,比如子网为128.32.1.0/24的子网广播地址是128.32.1.255。除此以外,特殊地址255.255.255.255被保留为本地广播地址,它不会被路由器转发,通常配合UDP/IP和ICMP等协议使用。
随着CIDR技术的引入,传统的5类分类方式被废弃了,IP地址不再按照固定的长度进行划分,在分配IP地址时,除了要指定分配的网络号,还需要说明网络号的长度,可以采用类似子网掩码的方式进行描述。
下面是一些常见的用于特殊用途的IP地址:
前缀 | 用途 |
0.0.0.0/8 | 作为源地址时表示本地主机||作为目的地址时,表示任意IP地址 |
10.0.0.0/8 | 局域网IP地址 |
172.16.0.0/12 | 局域网IP地址 |
192.168.0.0/16 | 局域网IP地址 |
127.0.0.0/8 | 回环地址 |
169.254.0.0/16 | 链路本地地址,通常出现在DHCP自动分配IP未完成时 |
255.255.255.255/32 | 本地网络广播地址 |
IP数据报的结构
正常的IPv4头部大小为20个字节(在很少情况会拥有选项,此时头部不只20个字节),IP头部的传输是按照大端法进行的,对于一个32位值而言,首先传输高位8位,然后次高8位,以此类推。因为TCP/IP协议中所有协议的头部都采用大端法进行传输,所以大端法也网络字节序称作。由于大部分PC使用的是小端法,所以在构造完头部之后传输之前需要对其执行大小端转换才行。下图当中描述IPv4中IP数据报的格式。
- 版本:4位,数值4指IPv4,6指IPv6。
- 头部字段:4位,用来描述IP数据报头部的长度为多少32位。因此IP数据报头部最多只有60个字节。
- 服务类型:8位,描述服务质量和拥塞情况
- 总长度:16位,描述IP数据报的总长度(包括头部)为多少字节。这个字段有助于从带填充的以太网帧取出IP数据报的有效部分(可能这个IP数据报不足46个字节,在以太网帧当中填充了0)。
- 标识:16位,描述IP数据报的分片编号,同一个大IP数据报分解出来的多个分片拥有相同的标识。
- 标志:3位,描述是否发生分片,以及是否后续有更多的分片。
- 片偏移:13位,描述该分片在重组后的大IP数据报当中的位置,以8字节为单位。生存期(TTL):8位,描述一个数据报可以经过路由器的上限,每次路由器转发时该数值会减一。这个属性可以避免在环形路由情况下,数据报在网络中永远循环。
- 协议:8位,描述上层协议的类型,最常见的是1(ICMP)、17(UDP)和6(TCP)首部校验和:16位,IP数据报头部的校验和,注意并不检查载荷部分内容,所以需要上层协议自己检查。
- 源IP地址和目的IP地址:各有32位,描述IP数据报的发送者和接收者的IP地址。
分片和重组
由于IP数据报的总长度限制为65535字节,这远远超过了部分链路层标准的MTU,当数据从网络层准备转移到数据链路层时,网络层会将IP数据报进行分片操作,将分解成若干个独立的IP数据报(分解之后IP数据的总长度字段改变了),并且在网络之间独立传输。一旦到达终点的目的主机之后(中间不会重组),目的主机的网络层会将分片重组成一个大IP数据报。由于重组的过程十分复杂,所以协议设计者应该尽可能避免出现让IP数据报超过MTU的情况。比如DNS、DHCP等就规定UDP报文长度为512字节。
传输层
传输控制协议TCP
传输控制协议(TCP)是整个四层模型当中最重要的协议,它工作在传输层,其目标是在不可靠的逐跳传输的网络层之上,构建一个可靠的、面向连接的、全双工的端到端协议。
建立连接的三次握手
TCP是一个面向连接的协议,在通信双方真正交换数据之前,必须先先相互联系建立一个TCP连接,这个就类似于电话的开头的“喂”的效果。TCP是一个全双工协议,双方都需要对连接状态进行管理。每一个独特的TCP都由一个四元组唯一标识,组内包括通信双方的IP地址和端口号,建立连接的过程通常被称作是3次握手。虽然是全双工的通信,但是有一次建立连接和确认行为可以合并在一起,所以只需要传输3次报文段即可。下面是其步骤:
- 客户端发起连接,发送一个SYN报文给服务端,然后说明自己连接的端口和客户端初始序列号seq1。
- 服务端收到SYN报文,也需要发起反方向的连接,所以发送一个SYN报文给服务端,说明自己连接的端口和服务端初始序列号seq2,除此以外,这个报文还可以携带一个确认信息,所以把seq1+1作为ACK返回。
- 客户端收到服务端的SYN之后,需要确认,所以把seq2+1作为ACK返回给服务端。同时,本次发送的报文可以携带数据。
3次握手最主要的目的是为了建立连接,并且交换初始序列号。在TCP连接过程中,如果存在一个老旧的报文(上一次连接时发送的)到达服务端,服务端可以根据其序列号是否合法来决定是否丢弃。有些情况可能出现双方同时发起连接的情况,这个时候就需要4个报文段来建立连接了。
使用2次握手可不可行?
答案是否定的,因为服务端发起的SYN未确认。一种典型的场景就是客户端发起SYN,第一个SYN超时并重传,第二个SYN到达并建立连接,之后再完成连接并关闭,倘若关闭之后,第一个SYN到达服务端,此时服务端就会认为对方建立连接,并回复SYN+ACK,由于没有确认,所以服务端并不知道客户端的状态,此时客户端完全可能已经关闭,那服务端就会陷入永久等待了
断开连接的四次挥手
断开连接的过程要更加复杂一些,通信双方谁都可以主动断开连接,但是由于TCP是全双工连接,所以一个方向断开并不意味着反向的数据已经传输完成,所以每个方向的断开是相对独立的,这样的话两个方向各有一次断开和确认,总共需要4次报文段的传输,即4次挥手。下面是其具体流程:
- 主动关闭方发送一个FIN段表示希望断开连接。
- 被动关闭方收到FIN段,并且回复一个确认信息。其上层应用会收到一个EOF,被动关闭方继续传输剩余的数据。
- 被动关闭方发送完数据了,发送一个FIN段。
- 主动关闭方回复一个确认,并且等待一段时间(2MSL,MSL指单个报文在网络中的最长生存时间)。
- 在第2次挥手之后,TCP连接此时处于一种半关闭的状态。可以任为现在是一个单工通信方式(被动关闭方-->主动关闭方)。
报文头部
上图中描述了TCP报文首部的各个字段,具体含义如下:
- 序号:即SEQ的值
- 确认需要:即ACK的值,描述预期接收的下一个序列号。注意发送一个ACK和发送一个普通报文的消耗是一样的。
- 首部长度:首部的长度是可变的,以32位为单位。首部长度最短为20字节,最长为60字节。
- URG:紧急。
- ACK:确认号字段有效。连接建立以后通常一直有效。
- PSH:推送。
- RST:重置连接,出现在连接出错时。
- SYN:发起连接。
- FIN:发起关闭。
- 窗口大小:通告一个窗口大小以限制流量。
- 校验和:校验传输中的比特跳变错误。
- 紧急指针:向对端提供一种特殊标识。
- 最大段大小(MSS),用来描述后续希望接收到的报文段
- 选项:最常见的选项是
- 的最大值,这个数值通常受限于MTU,比如MTU为1500,IP数据报头部为20字节,TCP头部为20字节,则MSS是1460。
用户数据报协议UDP
UDP是一种保留消息边界的简单的面向数据报的传输层协议。它不提供差错纠正、流量控制和拥塞管理等功能,只提供差错校验,但是一旦发现错误也只是简单地丢弃报文,不会通知对端,更不会有重传。由于功能特别简单,所以UDP的实现和运行消耗特别地小,故UDP协议可以配合一些应用层协议实现在一些低质量网络信道上的高效传输。许多早期的聊天软件或者客户端游戏都采用了基于UDP的应用层协议,这样能最好地利用性能,同时在比较差的网络状态下提供更良好的服务。
报文头部
UDP的报文结构非常简单:
- 长度:指UDP报文的总长度(包括UDP头部),实际上这个长度是冗余的,报文长度可以根据IP报文长度计算而来。
- 校验和:用于最终目的方校验,出错的报文会直接丢弃。
Berkeley Socket
TCP/IP协议族标准只规定了网络各个层次的设计和规范,具体实现则需要由各个操作系统厂商完成。最出名的网络库由BSD 4.2版本最先推出,所以称作,这些API随后被移植到各大操作系统中,并成为了网络编程的事实标准。 socket 即套接字是指网络中伯克利套接字
一种用来建立连接、网络通信的设备,用户创建了 socket 之后,可以通过其发起或者接受TCP 连接、可以向 TCP 的发送和接收缓冲区当中读写TCP数据段,或者发送 UDP 文本。
地址信息设置
struct sockaddr
我们主要以IPv4为例介绍网络的地址结构。主要涉及的结构体有 struct in_addr 、 struct sockaddr 、 struct sockaddr_in 。其中 struct sockaddr 是一种通用的地址结构,它可以描述一个IPv4或者IPv6的结构,所有涉及到地址的接口都使用了该类型的参数,但是过于通用的结果是直接用它来描述一个具体的IP地址和端口号十分困难。所以用户一般先使用struct sockaddr_in 来构造地址,再将其进行强制类型转换成 struct sockaddr 以作为网络接口的参数。
sockaddr_in
struct sockaddr_in
是一个在 C 语言中用于网络编程的结构体,它主要用于表示 IPv4 地址和端口号。
struct sockaddr_in {uint8_t sin_len; // 地址长度(可选字段,不是所有平台都使用)sa_family_t sin_family; // 地址族uint16_t sin_port; // 端口号struct in_addr sin_addr; // IPv4 地址char sin_zero[8]; // 填充字段,用于对齐
};
字段说明
sin_len
类型:
uint8_t
(无符号8位整数)作用:指定结构体的长度(以字节为单位)。这个字段在某些系统(如某些版本的 BSD 系统)中是必需的,但在大多数现代系统中(如 Linux)通常不使用。
值:如果使用,通常设置为
sizeof(struct sockaddr_in)
。
sin_family
类型:
sa_family_t
(通常是无符号16位整数)作用:指定地址族,用于标识地址类型。
值:
AF_INET
:表示 IPv4 地址族(这是最常见的值)。其他值(如
AF_UNIX
或AF_INET6
)通常不用于sockaddr_in
结构体。
sin_port
类型:
uint16_t
(无符号16位整数)作用:表示网络端口号。
值:端口号以网络字节序(大端序)存储。在使用时,通常需要通过
htons()
函数将主机字节序转换为网络字节序,例如:
struct sockaddr_in addr;
addr.sin_port = htons(80); // 将主机字节序的80转换为网络字节序
sin_addr
类型:
struct in_addr
作用:表示 IPv4 地址。
结构:
struct in_addr {uint32_t s_addr; // IPv4 地址
};
s_addr
:IPv4 地址,以网络字节序存储。可以使用 inet_addr()
或 inet_pton()
函数将点分十进制字符串(如 "192.168.1.1"
)转换为网络字节序的整数,例如:
struct sockaddr_in addr;
addr.sin_addr.s_addr = inet_addr("192.168.1.1");
sin_zero
- 类型:
char[8]
- 作用:填充字段,用于对齐结构体。在实际使用中,通常不需要手动设置这个字段,它主要用于确保结构体的大小和对齐方式与
struct sockaddr
一致。
大小端转换
网络字节序即大端法。对于应用TCP/IP协议规定,当数据在网络中传输的时候,一律使用层协议的载荷部分,如果不需要第三方工具检测内容,可以不进行大小端转换(因为接收方和发送方都是主机字节序即小端法)。但是对于其他层次的头部部分,在发送之前就一定要进行小端到大端的转换了(因为网络中的通信以及 tcpdump 、 netstat 等命令都是以大端法来解析内容的)。
下面是整数大小端转换相关的函数。
字节序概述
主机字节序(Host Byte Order)
依赖于具体计算机系统的架构。
在大多数现代计算机系统(如 x86 和 x86_64 架构的机器)中,主机字节序是小端序(Little-Endian),即低位字节存储在低地址处。
在某些系统(如某些 PowerPC 架构的机器)中,主机字节序是大端序(Big-Endian)。
网络字节序(Network Byte Order)
标准的网络通信协议(如 TCP/IP)使用大端序(Big-Endian),即高位字节存储在低地址处。
这种字节序在跨平台网络通信中确保数据的一致性。
htonl
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
作用:将32位的主机字节序整数转换为网络字节序。
参数:
hostlong
:一个32位的无符号整数,表示主机字节序的值。
返回值:
返回转换后的32位无符号整数,表示网络字节序的值。
使用场景:
在发送32位数据(如IP地址)到网络之前,需要将其从主机字节序转换为网络字节序。一般用于转换IP地址
htons
#include <arpa/inet.h>
uint16_t htons(uint16_t hostshort);
作用:将16位的主机字节序整数转换为网络字节序。
参数:
hostshort
:一个16位的无符号整数,表示主机字节序的值。
返回值:
返回转换后的16位无符号整数,表示网络字节序的值。
使用场景:
在发送16位数据(如端口号)到网络之前,需要将其从主机字节序转换为网络字节序。一般用于转换端口号。
int main() {uint16_t port = 80; // 主机字节序的端口号uint16_t net_port = htons(port); // 转换为网络字节序(小端转大端)uint32_t ip = 0xC0A80101; // 主机字节序的 IP 地址(192.168.1.1)uint32_t net_ip = htonl(ip); // 转换为网络字节序printf("Host port: %d\n", port);printf("Network port: %d\n", net_port);printf("Host IP: 0x%X\n", ip);printf("Network IP: 0x%X\n", net_ip);return 0;
}
ntohl
#include <arpa/inet.h>
uint32_t ntohl(uint32_t netlong);
作用:将32位的网络字节序整数转换为主机字节序。
参数:
netlong
:一个32位的无符号整数,表示网络字节序的值。
返回值:
返回转换后的32位无符号整数,表示主机字节序的值。
使用场景:
在从网络接收32位数据(如IP地址)后,需要将其从网络字节序转换为主机字节序。一般用于转换IP地址。
ntohs
#include <arpa/inet.h>
uint16_t ntohs(uint16_t netshort);
作用:将16位的网络字节序整数转换为主机字节序。
参数:
netshort
:一个16位的无符号整数,表示网络字节序的值。
返回值:
返回转换后的16位无符号整数,表示主机字节序的值。
使用场景:
在从网络接收16位数据(如端口号)后,需要将其从网络字节序转换为主机字节序。一般用于转换端口号
示例:
int main() {uint32_t hostlong = 0x12345678; // 主机字节序的32位整数uint16_t hostshort = 0x1234; // 主机字节序的16位整数// 转换为主机字节序到网络字节序uint32_t netlong = htonl(hostlong);uint16_t netshort = htons(hostshort);printf("Host long: 0x%X\n", hostlong);printf("Network long: 0x%X\n", netlong);printf("Host short: 0x%X\n", hostshort);printf("Network short: 0x%X\n", netshort);// 转换为网络字节序到主机字节序uint32_t converted_hostlong = ntohl(netlong);uint16_t converted_hostshort = ntohs(netshort);printf("Converted host long: 0x%X\n", converted_hostlong);printf("Converted host short: 0x%X\n", converted_hostshort);return 0;
}
IP地址转换
inet_aton
将点分十进制字符串(如 "192.168.1.1"
)转换为二进制形式的 IPv4 地址,并存储在 struct in_addr
结构体中。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
参数
cp
:指向点分十进制字符串的指针(如"192.168.1.1"
)。inp
:指向struct in_addr
结构体的指针,用于存储转换后的二进制 IPv4 地址。
返回值
成功时返回
1
。失败时返回
0
(例如,输入的字符串格式不正确)。
inet_ntoa
将二进制形式的 IPv4 地址(存储在 struct in_addr
结构体中)转换为点分十进制字符串。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
char *inet_ntoa(struct in_addr in);
参数
in
:包含二进制 IPv4 地址的struct in_addr
结构体。
返回值
返回一个指向静态分配的字符串的指针,该字符串表示点分十进制形式的 IPv4 地址。
注意:返回的字符串是静态分配的,因此每次调用
inet_ntoa
都会覆盖之前的返回值。如果需要保存结果,建议将其复制到其他变量中。
示例:
#include <stdio.h>
#include <arpa/inet.h>int main(int argc,char* argv[]){//./inet_aton 127.0.0.1struct sockaddr_in addr;inet_aton(argv[1],&addr.sin_addr);//将点分十进制转换成32位网络字节序printf("addr = %x\n",addr.sin_addr.s_addr);printf("addr = %s\n",inet_ntoa(addr.sin_addr));//将32位网络字节序转换成点分十进制return 0;
}
/*输出结果
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./ip 192.168.1.1
addr = 101a8c0
addr = 192.168.1.1
*/
注意事项
线程安全性:
inet_ntoa
不是线程安全的,因为它返回的是静态分配的字符串。如果需要在多线程环境中使用,建议使用 inet_ntop
函数,它允许指定目标缓冲区。
inet_pton
将点分十进制字符串(IPv4)或冒号十六进制字符串(IPv6)转换为二进制形式的 IP 地址。
int inet_pton(int af, const char *src, void *dst);
af
:地址族,可以是AF_INET
(IPv4)或AF_INET6
(IPv6)。src
:指向点分十进制(IPv4)或冒号十六进制(IPv6)字符串的指针。dst
:指向存储转换后的二进制 IP 地址的缓冲区。
返回值:
成功时返回
1
。输入字符串无效时返回
0
。地址族不支持时返回
-1
inet_ntop
将二进制形式的 IP 地址转换为点分十进制字符串(IPv4)或冒号十六进制字符串(IPv6)。
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数:
af
:地址族,可以是AF_INET
(IPv4)或AF_INET6
(IPv6)。src
:指向二进制 IP 地址的指针。dst
:指向存储转换后的字符串的缓冲区。size
:缓冲区的大小。
返回值:
成功时返回指向
dst
的指针。失败时返回
NULL
。
示例:
int main(int argc,char* argv[]){struct sockaddr_in addr;inet_pton(AF_INET ,argv[1],&addr.sin_addr);//将点分十进制转换成32位网络字节序printf("addr = %x\n",addr.sin_addr.s_addr);char ip_addr[20];printf("addr = %s\n",inet_ntop(AF_INET, &addr.sin_addr.s_addr, ip_addr, 20));//将32位网络字节序转换成点分十进制return 0;
}
/*
输出结果:
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./ip2 192.168.1.1
addr = 101a8c0
addr = 192.168.1.1
*/
inet_addr
用于将点分十进制的 IP 地址字符串(如 "192.168.1.1"
)转换为一个 32 位的二进制形式的 IPv4 地址。
uint32_t inet_addr(const char *cp);
参数
cp
类型:
const char *
作用:指向点分十进制格式的 IPv4 地址字符串(如
"192.168.1.1"
)。
返回值
成功时返回 32 位的二进制形式的 IPv4 地址(网络字节序)。
如果输入的字符串格式无效,返回
INADDR_NONE
(通常定义为0xFFFFFFFF
)。
使用场景
inet_addr
函数通常用于将人类可读的 IP 地址字符串转换为程序可以使用的二进制形式。这在设置套接字地址结构(如 struct sockaddr_in
)时非常有用。
在实际使用中,如果只需要简单的 IPv4 地址转换,inet_addr
是一个方便的选择。可以直接返回 32 位的二进制地址。如果需要更复杂的处理或支持 IPv6,建议使用 inet_pton 或 inet_aton (线程不安全)
。
域名和IP地址的对应关系
IP层通过IP地址的结构进行路由选择最终找到一条通往目的地的路由,但是一些著名的网站如果采用IP地址的方式提供地址,用户将无法记忆,所以更多的时候需要一个方便人类记忆域名(比如www.kernel.org)作为其实际IP地址(145.40.73.55)的别名,显然我们需要的一种机制去建立域名和IP地址的映射关系,一种方法是修改本机的hosts文件 /etc/hosts ,但是更加通用的方案是利用DNS协议,去访问一个DNS服务器,服务器当中存储了域名和IP地址的映射关系。与这个操作相关的函数是 gethostbyname ,下面是其用法:
struct hostent
用于存储从域名解析服务(如 DNS)中获取的主机信息,包括主机名、别名、IP 地址等。这个结构体定义在 <netdb.h>
头文件中。
#include<netdb.h>struct hostent {char *h_name; // 主机的官方名称char **h_aliases; // 主机的别名列表(以 NULL 结尾的数组)int h_addrtype; // 地址类型(通常是 AF_INET 或 AF_INET6)int h_length; // 地址的长度(以字节为单位)char **h_addr_list; // 主机的地址列表(以 NULL 结尾的数组)
};
字段说明
h_name
类型:
char *
作用:主机的官方名称(通常是主机的域名,如
"example.com"
)。
h_aliases
类型:
char **
作用:主机的别名列表,是一个以 NULL 结尾的字符串数组。主机可能有多个别名,这些别名存储在这个数组中。
h_addrtype
类型:
int
作用:地址类型,通常为
AF_INET
(IPv4)或AF_INET6
(IPv6)。
h_length
类型:
int
作用:地址的长度(以字节为单位)。对于 IPv4 地址,长度为 4 字节;对于 IPv6 地址,长度为 16 字节。
h_addr_list
类型:
char **
作用:主机的地址列表,是一个以 NULL 结尾的字符串数组。每个地址都是一个二进制形式的 IP 地址,存储为字节数组。第一个地址通常是最主要的地址。
gethostbyname
根据主机名或域名获取主机的 IP 地址信息。
struct hostent *gethostbyname(const char *name);
参数
name
:指向主机名或域名的字符串指针(如"example.com"
或"localhost"
)。
返回值
成功时返回一个指向
struct hostent
的指针。失败时返回
NULL
,可以通过h_errno
获取错误原因(h_errno
是一个全局变量,用于存储主机名解析的错误代码)。
错误代码
HOST_NOT_FOUND
:主机名未找到。TRY_AGAIN
:暂时无法解析主机名(可能是 DNS 服务器未响应)。NO_RECOVERY
:无法从错误中恢复。NO_ADDRESS
:主机名有效,但没有找到对应的地址。
gethostbyname
不是线程安全的,因为它返回的是静态分配的struct hostent
。在多线程环境中,建议使用getaddrinfo
,它返回动态分配的结构体。gethostbyname
仅支持 IPv4 地址。如果需要支持 IPv6,建议使用getaddrinfo
,因为它可以同时处理 IPv4 和 IPv6 地址。
示例:
int main() {struct hostent *host;char *hostname = "www.taobao.com";// 获取主机信息host = gethostbyname(hostname);if (host == NULL) {perror("gethostbyname");return 1;}// 打印主机的官方名称printf("Official name: %s\n", host->h_name);// 打印主机的别名printf("Aliases:\n");for (char **alias = host->h_aliases; *alias != NULL; alias++) {printf(" %s\n", *alias);}// 打印主机的地址类型printf("Address type: %s\n", (host->h_addrtype == AF_INET) ? "AF_INET" : "AF_INET6");// 打印主机的地址printf("Addresses:\n");for (char **addr = host->h_addr_list; *addr != NULL; addr++) {printf(" %s\n", inet_ntoa(*((struct in_addr *)*addr)));}return 0;
}
/*
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./dns
Official name: www.taobao.com.danuoyi.tbcache.com
Aliases:www.taobao.com
Address type: AF_INET
Addresses:222.192.186.120222.192.186.122
*/
struct addrinfo
struct addrinfo {int ai_flags; // 查询标志int ai_family; // 地址族(如 AF_INET 或 AF_INET6)int ai_socktype; // 套接字类型(如 SOCK_STREAM 或 SOCK_DGRAM)int ai_protocol; // 协议(如 IPPROTO_TCP 或 IPPROTO_UDP)socklen_t ai_addrlen; // 地址长度struct sockaddr *ai_addr; // 地址结构体char *ai_canonname; // 规范化的主机名struct addrinfo *ai_next; // 指向下一个结果的指针
};
ai_flags
类型:
int
作用:查询标志,用于指定查询的偏好选项。常见的标志包括:
AI_PASSIVE
:用于服务器端,表示返回的地址适用于bind
函数。AI_CANONNAME
:返回规范化的主机名。AI_NUMERICHOST
:要求node
是一个数字形式的地址(如 IP 地址)。AI_NUMERICSERV
:要求service
是一个数字形式的端口号。AI_V4MAPPED
:如果查询 IPv6 地址,但主机只有 IPv4 地址,则返回 IPv4 映射的 IPv6 地址。AI_ALL
:返回所有匹配的地址(IPv4 和 IPv6)。AI_ADDRCONFIG
:仅返回当前主机支持的地址族。
ai_family
类型:
int
作用:地址族,指定地址的类型。常见的值包括:
AF_INET
:IPv4 地址。AF_INET6
:IPv6 地址。AF_UNSPEC
:不指定地址族,允许返回 IPv4 或 IPv6 地址。
ai_socktype
类型:
int
作用:套接字类型,指定套接字的类型。常见的值包括:
SOCK_STREAM
:TCP 套接字。SOCK_DGRAM
:UDP 套接字。SOCK_RAW
:原始套接字。
ai_protocol
类型:
int
作用:协议类型,指定使用的协议。常见的值包括:
IPPROTO_TCP
:TCP 协议。IPPROTO_UDP
:UDP 协议。IPPROTO_RAW
:原始协议。
ai_addrlen
类型:
socklen_t
作用:地址的长度(以字节为单位)。
ai_addr
类型:
struct sockaddr *
作用:指向地址结构体的指针,存储主机的地址信息。根据
ai_family
的值,可以将其强制转换为struct sockaddr_in
(IPv4)或struct sockaddr_in6
(IPv6)。
ai_canonname
类型:
char *
作用:规范化的主机名。如果设置了
AI_CANONNAME
标志,此字段将包含主机的规范化名称。
ai_next
类型:
struct addrinfo *
作用:指向链表中的下一个
struct addrinfo
结构体的指针。如果为NULL
,表示链表结束。
getaddrinfo
getaddrinfo
用于根据主机名或服务名获取主机的地址信息。它是一个现代的替代品,用于替代传统的 gethostbyname
和 getservbyname
等函数,因为它支持 IPv4 和 IPv6,并且提供了更灵活的接口。
int getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res);
参数
node
类型:
const char *
作用:主机名或 IP 地址字符串(如
"example.com"
或"192.168.1.1"
)。可选:如果为
NULL
,则表示本地主机。
service
类型:
const char *
作用:服务名或端口号字符串(如
"http"
或"80"
)。可选:如果为
NULL
,则不解析服务名。
hints
类型:
const struct addrinfo *
作用:一个指向
struct addrinfo
的指针,用于指定查询的偏好选项。可选:如果为
NULL
,则默认查询所有可能的地址和协议。
res
类型:
struct addrinfo **
作用:一个指向指针的指针,用于存储查询结果。查询结果是一个链表,每个节点都是一个
struct addrinfo
结构体。
返回值
成功时返回
0
。失败时返回一个非零的错误码(如
EAI_AGAIN
、EAI_NONAME
等)。
#include<54func.h>
int main(int argc, char const *argv[])
{ARGS_CHECK(argc, 2);struct addrinfo hints, *res;int ret;char ipstr[INET6_ADDRSTRLEN];memset(&hints, 0, sizeof(hints));hints.ai_family = AF_UNSPEC;hints.ai_socktype = SOCK_STREAM;ret = getaddrinfo(argv[1], "https", &hints, &res);THREAD_ERROR_CHECK(ret, "getaddrinfo");printf("IP addresses for %s:\n\n", argv[1]);for(struct addrinfo *p = res; p != NULL; p = p->ai_next){void *addr;char *ipver;if(p->ai_family == AF_INET){struct sockaddr_in *ipv4 = (struct sockaddr_in *)p->ai_addr;addr = &(ipv4->sin_addr);ipver = "IPV4";}else{struct sockaddr_in6 *ipv6 = (struct sockaddr_in6 *)p->ai_addr;addr = &(ipv6->sin6_addr);ipver = "IPV6";}inet_ntop(p->ai_family, addr, ipstr, sizeof(ipstr));printf(" %s: %s\n", ipver, ipstr);}freeaddrinfo(res); //释放动态分配的内存return 0;
}
/*
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./dns2 www.taobao.com
IP addresses for www.taobao.com:IPV6: 2001:da8:20d:40db:3::3d2IPV6: 2001:da8:20d:40db:3::3d1IPV4: 222.192.186.120IPV4: 222.192.186.122
*/
TCP 通信
下面是使用TCP通信的流程图:
socket
socket 函数用于创建一个 socket 套接字。它是网络通信的端点。通过 socket
函数,程序可以创建一个用于网络通信的套接字,并指定其类型和协议。
int socket(int domain, int type, int protocol);
参数
domain
类型:
int
作用:指定地址族(协议族),常见的值包括:
AF_INET
:IPv4 地址族。AF_INET6
:IPv6 地址族。AF_UNIX
或AF_LOCAL
:本地通信(Unix 域套接字)。AF_UNSPEC
:不指定地址族,通常用于getaddrinfo
的结果。
type
类型:
int
作用:指定套接字类型,常见的值包括:
SOCK_STREAM
:面向连接的流式套接字(TCP)。SOCK_DGRAM
:无连接的数据报套接字(UDP)。SOCK_RAW
:原始套接字,允许直接访问底层协议。SOCK_SEQPACKET
:有序的、可靠的、面向连接的、固定大小的数据报套接字。SOCK_RDM
:可靠的无连接数据报套接字。
protocol
类型:
int
作用:指定协议,通常为
0
,表示使用默认协议。常见的协议包括:IPPROTO_TCP
:TCP 协议(用于SOCK_STREAM
)。IPPROTO_UDP
:UDP 协议(用于SOCK_DGRAM
)。IPPROTO_RAW
:原始协议(用于SOCK_RAW
)。
返回值
成功时返回一个非负的套接字描述符(文件描述符)。
失败时返回
-1
,并设置errno
以指示错误原因。
bind
bind
函数是网络编程中用于将一个套接字绑定到一个本地地址和端口的函数。它通常用于服务器端,用于指定服务器监听的本地地址和端口。绑定后,套接字会与指定的地址和端口关联起来,从而允许服务器接收来自客户端的连接请求或数据。服务器建立连接时必须使用bind绑定端口,客户端一般不需要。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数
sockfd
类型:
int
作用:套接字描述符,由
socket
函数创建。它标识了服务器用于通信的套接字。
addr
类型:
const struct sockaddr *
作用:指向
struct sockaddr
或其派生类型(如struct sockaddr_in
或struct sockaddr_in6
)的指针,存储本地地址信息。对于 IPv4,通常使用struct sockaddr_in
;对于 IPv6,使用struct sockaddr_in6
。
addrlen
类型:
socklen_t
作用:
addr
指向的地址结构体的大小(以字节为单位)。对于struct sockaddr_in
,大小通常为sizeof(struct sockaddr_in)
;对于struct sockaddr_in6
,大小通常为sizeof(struct sockaddr_in6)
。
返回值
成功时返回
0
。失败时返回
-1
,并设置errno
以指示错误原因。
示例:
int main(int argc, char *argv[]) {ARGS_CHECK(argc, 3);int sockfd;struct sockaddr_in server_addr;// 创建一个 TCP 套接字sockfd = socket(AF_INET, SOCK_STREAM, 0);ERROR_CHECK(sockfd, -1, "socket");// 初始化服务器地址memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET; // 地址族server_addr.sin_addr.s_addr = inet_addr(argv[1]);server_addr.sin_port = htons(atoi(argv[2])); // 服务器端口号// 绑定套接字到本地地址int ret = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));ERROR_CHECK(ret, -1, "bind");// 关闭套接字close(sockfd);return 0;
}
connect
客户端使用 connect 来建立和TCP服务端的连接。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数
sockfd
类型:
int
作用:套接字描述符,由
socket
函数创建。它标识了客户端用于通信的套接字。
addr
类型:
const struct sockaddr *
作用:指向
struct sockaddr
或其派生类型(如struct sockaddr_in
或struct sockaddr_in6
)的指针,存储服务器的地址信息。对于 IPv4,通常使用struct sockaddr_in
;对于 IPv6,使用struct sockaddr_in6
。
addrlen
类型:
socklen_t
作用:
addr
指向的地址结构体的大小(以字节为单位)。对于struct sockaddr_in
,大小通常为sizeof(struct sockaddr_in)
;对于struct sockaddr_in6
,大小通常为sizeof(struct sockaddr_in6)
。
返回值
成功时返回
0
。失败时返回
-1
,并设置errno
以指示错误原因。
listen
listen
函数是网络编程中用于将一个套接字转换为被动套接字(即监听套接字)的函数。它通常用于服务器端,用于使套接字进入监听状态,等待客户端的连接请求。listen
函数是 TCP 服务器编程中的关键步骤之一。
int listen(int sockfd, int backlog);
一旦启用了 listen 之后,操作系统就知道该套接字是服务端的套接字,操作系统内核就不再启用其发送和接收缓冲区,转而在内核区维护两个队列结构:半连接队列 和 全连接队列。半连接队列用于管理成功第一次握手的连接,全连接队列用于管理已经完成三次握手的队列。 backlog 在有些操作系统用来指明半连接队列和全连接队列的长度之和,一般填一列个正数即可。如果队列已经满了,那么服务端受到任何再发起的连接都会直接丢弃(大部分操作系统中服务端不会回复RST,以方便客户端自动重传)
参数
sockfd
类型:
int
作用:套接字描述符,由
socket
函数创建。它标识了服务器用于通信的套接字。
backlog
类型:
int
作用:指定未完成连接队列的最大长度。当有多个客户端同时尝试连接时,
backlog
参数决定了服务器可以暂存的未完成连接的数量。
返回值
成功时返回
0
。失败时返回
-1
,并设置errno
以指示错误原因。
accept
accept 函数由服务端调用,用于从全连接队列中取出下一个已经完成的TCP连接。如果全连接队列为空,那么 accept 会陷入阻塞。一旦全连接队列中到来新的连接,此时 accept 操作就会就绪,这种就绪是读操作就绪,所以可以使用 select 函数的读集合进行监听。当accept 执行完了之后,内核会创建一个新的套接字文件对象 ,该文件对象关联的文件描述符是 accept 的返回值,文件对象当中最重要的结构是一个发送缓冲区和接收缓冲区,可以用于服务端通过TCP连接发送和接收TCP段。
区分两个套接字是非常重要的。通过把旧的管理连接队列的套接字称作监听套接字,而新的用于发送和接收TCP段的套接字称作已连接套接字。通常来说,监听套接字会一直存在,负责建立各个不同的TCP连接(只要源IP、源端口、目的IP、目的端口四元组任意一个字段有区别,就是一个新的TCP连接),而某一条单独的TCP连接则是由其对应的已连接套接字进行数据通信的。客户端使用 close 关闭套接字或者服务端使用 close 关闭已连接套接字的时候就是主动发起断开连接四次挥手的过程。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数
sockfd
类型:
int
作用:监听套接字描述符,由
socket
函数创建并经过listen
转换为被动套接字。它标识了服务器用于接收连接请求的套接字。
addr
类型:
struct sockaddr *
作用:指向
struct sockaddr
或其派生类型(如struct sockaddr_in
或struct sockaddr_in6
)的指针,用于存储客户端的地址信息。如果不需要客户端的地址信息,可以设置为NULL
。
addrlen
类型:
socklen_t *
作用:指向一个
socklen_t
类型的变量,该变量在调用前应设置为addr
指向的地址结构体的大小(以字节为单位)。调用后,addrlen
指向的变量将被设置为实际存储在addr
中的地址长度。如果addr
为NULL
,则addrlen
也应为NULL
。
返回值
成功时返回一个新的套接字描述符,用于与客户端通信。
失败时返回
-1
,并设置errno
以指示错误原因。
send
send 和 recv 用于将数据在用户态空间和内核态的缓冲区之间进行传输,无论是客户端还是服务端均可使用,但是只能用于TCP连接。将数据拷贝到内核态并不意味着会马上传输,而是会根据时机再由内核协议栈按照协议的规范进行分节,通常缓冲区如果数据过多会分节成MSS的大小,然后根据窗口条件传输到网络层之中。
send
函数是网络编程中用于向已连接的套接字发送数据的函数。它通常用于 TCP 套接字,但也可以用于已连接的 UDP 套接字。send
函数是 write
函数在网络编程中的等价物,用于将数据发送到对端。
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
参数
sockfd
类型:
int
作用:套接字描述符,标识了用于发送数据的套接字。该套接字必须已经通过
connect
函数连接到对端(对于 TCP 套接字)或通过sendto
函数发送数据(对于 UDP 套接字)。
buf
类型:
const void *
作用:指向要发送的数据的缓冲区。
len
类型:
size_t
作用:要发送的数据的长度(以字节为单位)。
flags
类型:
int
作用:发送标志,通常设置为
0
。可以设置的标志包括:MSG_OOB
:发送带外数据(仅适用于 TCP 套接字)。MSG_DONTROUTE
:跳过路由表,直接发送数据(仅适用于 TCP 套接字)。MSG_DONTWAIT
:非阻塞发送(仅适用于非阻塞套接字)。MSG_NOSIGNAL
:发送数据时不会产生SIGPIPE
信号(仅适用于 TCP 套接字)。
返回值
成功时返回实际发送的字节数(可能小于请求的字节数)。
失败时返回
-1
,并设置errno
以指示错误原因。
recv
recv
函数是网络编程中用于从已连接的套接字接收数据的函数。它通常用于 TCP 套接字,但也可以用于已连接的 UDP 套接字。recv
函数是 read
函数在网络编程中的等价物,用于从对端接收数据。
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数
sockfd
类型:
int
作用:套接字描述符,标识了用于接收数据的套接字。该套接字必须已经通过
connect
函数连接到对端(对于 TCP 套接字)或通过bind
函数绑定到本地地址(对于 UDP 套接字)。
buf
类型:
void *
作用:指向接收数据的缓冲区。
len
类型:
size_t
作用:缓冲区的大小(以字节为单位),表示可以接收的最大数据量。
flags
类型:
int
作用:接收标志,通常设置为
0
。可以设置的标志包括:MSG_OOB
:接收带外数据(仅适用于 TCP 套接字)。MSG_PEEK
:查看数据但不移除它(即“偷看”数据)。MSG_WAITALL
:阻塞直到接收到请求的所有数据。MSG_DONTWAIT
:非阻塞接收(仅适用于非阻塞套接字)。
返回值
成功时返回接收到的字节数。
如果对端关闭连接,返回
0
。失败时返回
-1
,并设置errno
以指示错误原因。
示例:使用以上函数完成一次简单的服务端和客户端之间的信息发送
client.c
int main(int argc, char const *argv[])
{ARGS_CHECK(argc,3);int sockfd;struct sockaddr_in client_addr;sockfd = socket(AF_INET, SOCK_STREAM, 0);ERROR_CHECK(sockfd, -1, "socket");memset(&client_addr, 0, sizeof(client_addr));client_addr.sin_family = AF_INET;client_addr.sin_addr.s_addr = inet_addr(argv[1]);client_addr.sin_port = htons(atoi(argv[2]));int ret = connect(sockfd, (struct sockaddr*)&client_addr, sizeof(client_addr));sleep(3);char *str = "hi I am client\n";char buf[50];ret = send(sockfd, str, strlen(str), 0);ERROR_CHECK(ret, -1, "send");ret = recv(sockfd, buf, sizeof(buf), 0);ERROR_CHECK(ret, -1, "recv");printf("%s\n", buf);return 0;
}server.c
int main(int argc, char const *argv[])
{ARGS_CHECK(argc,3);int sockfd;struct sockaddr_in server_addr;sockfd = socket(AF_INET, SOCK_STREAM, 0);ERROR_CHECK(sockfd, -1, "socket");memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = inet_addr(argv[1]);server_addr.sin_port = htons(atoi(argv[2]));int ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));ERROR_CHECK(ret, -1, "bind");printf("waiting connect...\n");ret = listen(sockfd, 10); //放入监听集合ERROR_CHECK(ret, -1, "listen");int newFd = accept(sockfd, NULL, NULL); //从就绪集合中取出printf("client connected!\n");char buf[50];char *str = "hello";memset(buf, 0, sizeof(buf));ret = recv(newFd, buf, sizeof(buf), 0);ERROR_CHECK(ret, 0, "recv");printf("%s\n", buf);printf("send hello to client\n");send(newFd, str, strlen(str), 0);close(newFd);close(sockfd);return 0;
}//client
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./client 127.0.0.1 1255
hello
//server
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./server 127.0.0.1 1255
waiting connect...
client connected!
hi I am clientsend hello to client
需要特别注意的是, send 和 recv 的次数和网络上传输的TCP段的数量没有关系,多次的send 和 recv 可能只需要一次TCP段的传输。另外一方面,TCP是一种流式的通信协议,消息是以字节流的方式在信道中传输,这就意味着一个重要的事情,消息和消息之间是没有边界的。在不加额外约定的情况下,通信双方并不知道发送和接收到底有没有接收完一个消息,有可能多个消息会在一次传输中被发送和接收(江湖俗称"粘包"),也有有可能一个消息需要多个传输才能被完整的发送和接收(江湖俗称"半包")。
实战:使用select实现TCP客户端与服务端即时聊天
基于TCP的聊天程序的实现思路和之前利用管道实现即时聊天的思路是一致的。客户端和服务端都需要使用 select 这种IO多路复用机制监听读事件,客户端需要监听套接字的读缓冲区以及标准输入,服务端需要监听已连接套接字的读缓冲区以及标准输入。
//client.c
int main(int argc, char const *argv[])
{ARGS_CHECK(argc,3);int sockfd;struct sockaddr_in client_addr;sockfd = socket(AF_INET, SOCK_STREAM, 0);ERROR_CHECK(sockfd, -1, "socket");memset(&client_addr, 0, sizeof(client_addr));client_addr.sin_family = AF_INET;client_addr.sin_addr.s_addr = inet_addr(argv[1]);client_addr.sin_port = htons(atoi(argv[2]));printf("connecting to server\n");int ret = connect(sockfd, (struct sockaddr*)&client_addr, sizeof(client_addr));printf("connected\n");char buf[1024];fd_set rdset;while (1){FD_ZERO(&rdset);FD_SET(STDIN_FILENO, &rdset);FD_SET(sockfd, &rdset);select(sockfd+1, &rdset, NULL, NULL, NULL);if(FD_ISSET(STDIN_FILENO, &rdset)){memset(buf, 0, sizeof(buf));ret = read(STDIN_FILENO, buf, sizeof(buf));ERROR_CHECK(ret, -1, "read");if(strcmp(buf, "disconnect\n") == 0){printf("disconnected\n");close(sockfd);break;}send(sockfd, buf, strlen(buf)-1, 0);}if(FD_ISSET(sockfd, &rdset)){memset(buf, 0, sizeof(buf));ret = recv(sockfd, buf, sizeof(buf), 0);ERROR_CHECK(ret, -1, "read");if(ret == 0){printf("server disconnected\n");break;}printf("server: %s\n",buf);}}close(sockfd);return 0;
}
/server.c
int main(int argc, char const *argv[])
{ARGS_CHECK(argc,3);int sockfd;struct sockaddr_in server_addr;sockfd = socket(AF_INET, SOCK_STREAM, 0);ERROR_CHECK(sockfd, -1, "socket");memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = inet_addr(argv[1]);server_addr.sin_port = htons(atoi(argv[2]));int ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));ERROR_CHECK(ret, -1, "bind");printf("waiting to connect...\n");ret = listen(sockfd, 10); //放入监听集合ERROR_CHECK(ret, -1, "listen");int newFd = accept(sockfd, NULL, NULL); //从就绪集合中取出ERROR_CHECK(newFd, -1, "accept");printf("client connected!\n");char buf[1024];fd_set rdset;while (1){FD_ZERO(&rdset);FD_SET(STDIN_FILENO, &rdset);FD_SET(newFd, &rdset);select(newFd+1, &rdset, NULL, NULL, NULL);if(FD_ISSET(STDIN_FILENO, &rdset)){memset(buf, 0, sizeof(buf));ret = read(STDIN_FILENO, buf, sizeof(buf));ERROR_CHECK(ret, -1, "read");if(strcmp(buf, "disconnect\n") == 0){printf("disconnected\n");close(newFd);close(sockfd);break;}send(newFd, buf, strlen(buf)-1, 0);}if(FD_ISSET(newFd, &rdset)){memset(buf, 0, sizeof(buf));ret = recv(newFd, buf, sizeof(buf), 0);ERROR_CHECK(ret, -1, "read");if(ret == 0){printf("client disconnected\n");close(newFd);close(sockfd);return 0;}printf("client: %s\n",buf);}}return 0;
}
/*
//server
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./server 192.168.1.2 1236
waiting to connect...
client connected!
client: hello i am client!
hi i am server!
what are you doing?
client: emmm... learning linux c.
client: i want to offline, byebye!
ok
client disconnected
//client
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./client 192.168.1.2 1236
connecting to server
connected
hello i am client!
server: hi i am server!
server: what are you doing?
emmm... learning linux c.
i want to offline, byebye!
server: ok
^C
*/
TIME_WAIT和setsockopt
如果是服务端主动调用 close 断开的连接,即服务端是四次挥手的主动关闭方,由之前的TCP状态转换图可知,主动关闭方在最后会处于一个固定2MSL时长的TIME_WAIT等待时间。在此状态期间,如果尝试使用 bind 系统调用对重复的地址进行绑定操作,那么会报错。
$ ./server_tcpchat1 192.168.135.132 2778
bind: Address already in use
$ netstat -an|grep 2778
tcp
0
0 192.168.135.132:2778
192.168.135.133:57466
TIME_WAIT
使用 select 对 socket 进行断线重连
服务端除了接收缓冲区和标准输入以外,还有一个操作也会造成阻塞,那就是 accept 操作,实际上服务端可以使用 select 管理监听套接字,检查其全连接队列是否存在已经建好的连接,如果存在连接,那么其读事件即 accept 操作便就绪。将监听套接字加入监听会导致服务端的代码发生一个结构变化:
- 每次重新调用 select 之前需要提前准备好要监听的文件描述符,这些文件描述符当中可能会包括新的已连接套接字的文件描述符。
- select 的第一个参数应当足够大,从而避免无法监听到新的已连接套接字的文件描述符(它们的数值可能会比较大)。
- 需要处理 accept 就绪的情况。
#include<54func.h>int main(int argc, char const *argv[])
{ARGS_CHECK(argc,3);int sockfd, newFd;struct sockaddr_in server_addr;sockfd = socket(AF_INET, SOCK_STREAM, 0);ERROR_CHECK(sockfd, -1, "socket");memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = inet_addr(argv[1]);server_addr.sin_port = htons(atoi(argv[2]));int ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));ERROR_CHECK(ret, -1, "bind");printf("waiting to connect...\n");ret = listen(sockfd, 10); //放入监听集合ERROR_CHECK(ret, -1, "listen");//needMonitorSetorset 是目前需要监听的集合,为rdset提供监听集合,它本身并不会被监听char buf[1024];fd_set rdset, needMonitorSetorset;FD_ZERO(&rdset);FD_ZERO(&needMonitorSetorset); FD_SET(STDIN_FILENO, &needMonitorSetorset);FD_SET(sockfd, &needMonitorSetorset);while (1){memcpy(&rdset, &needMonitorSetorset, sizeof(rdset));ret = select(10, &rdset, NULL, NULL, NULL);ERROR_CHECK(ret, -1, "select");if(FD_ISSET(STDIN_FILENO, &rdset)){memset(buf, 0, sizeof(buf));ret = read(STDIN_FILENO, buf, sizeof(buf));ERROR_CHECK(ret, -1, "read");if(strcmp(buf, "disconnect\n") == 0){printf("disconnected\n");close(newFd);close(sockfd);break;}send(newFd, buf, strlen(buf)-1, 0);}else if(FD_ISSET(newFd, &rdset)){memset(buf, 0, sizeof(buf));ret = recv(newFd, buf, sizeof(buf), 0);ERROR_CHECK(ret, -1, "read");if(ret == 0){printf("client disconnected\n");close(newFd);FD_CLR(newFd, &needMonitorSetorset);continue;}printf("client: %s\n",buf);}else if(FD_ISSET(sockfd, &rdset)){struct sockaddr_in cliAddr;memset(&cliAddr, 0, sizeof(cliAddr));socklen_t sockLen = sizeof(cliAddr);printf("sockLen=%d\n",sockLen);newFd = accept(sockfd, (struct sockaddr*)&cliAddr, &sockLen);ERROR_CHECK(newFd, -1, "accept");FD_SET(newFd, &needMonitorSetorset);printf("sockLen = %d\n",sockLen);printf("newFd = %d is connected\n", newFd);printf("ip is: %s, port is: %d\n", inet_ntoa(cliAddr.sin_addr), ntohs(cliAddr.sin_port));}}close(newFd);close(sockfd);return 0;
}
/*
client
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./client 192.168.1.1 1236
connecting to server
connected
hello server
server: what are you doing?
I will go
^C
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./client 192.168.1.1 1236
connecting to server
connected
I am back!
byebye!
^C
server
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./server2 192.168.1.1 1236
waiting to connect...
sockLen=16
sockLen = 16
newFd = 4 is connected
ip is: 192.168.1.1, port is: 58290
client: hello server
what are you doing?
client: I will go
client disconnected
sockLen=16
sockLen = 16
newFd = 4 is connected
ip is: 192.168.1.1, port is: 60632
client: I am back!
client: byebye!
client disconnected
^C
*/
实战:使用epoll实现TCP客户端与服务端即时聊天
在之前一节文章中,对IO多路复用的 select 和 epoll 进行了总结,实际开发中,epoll用的更广泛且效率也更高。在实现方面,epoll基本上与select差不多也是进行初始化将监听集合加入即可,不过简化掉了每次循环都需要将监听对象重新加入的步骤。一次加入,即可永久监听。
client.cint main(int argc, char const *argv[])
{ARGS_CHECK(argc, 3);int sockfd = socket(AF_INET, SOCK_STREAM, 0);ERROR_CHECK(sockfd, -1, "socket");struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = inet_addr(argv[1]);server_addr.sin_port = htons(atoi(argv[2]));printf("waiting for connect\n");int ret = connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));ERROR_CHECK(ret, -1, "coonect");printf("connected\n");int epfd = epoll_create(1);ERROR_CHECK(epfd, -1, "epoll_create");struct epoll_event ev, evs[2];ev.events = EPOLLIN;ev.data.fd = STDIN_FILENO;ret = epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev);ERROR_CHECK(ret, -1, "epoll_ctl");ev.data.fd = sockfd;ret = epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);ERROR_CHECK(ret, -1, "epoll_ctl");char buf[1024];int readyNum = 0;while (1){readyNum = epoll_wait(epfd, evs, 2, -1);for(int i = 0; i < readyNum; i++){if(evs[i].data.fd == STDIN_FILENO){memset(buf, 0, sizeof(buf));read(STDIN_FILENO, buf, sizeof(buf));send(sockfd, buf, strlen(buf), 0);}else if(evs[i].data.fd == sockfd){memset(buf, 0, sizeof(buf));ret = read(sockfd, buf, sizeof(buf));if(ret == 0){printf("disconnected\n");close(sockfd);return 0;}printf("server: %s", buf);} }}return 0;
}server.cint main(int argc, char const *argv[])
{ARGS_CHECK(argc, 3);int sockfd = socket(AF_INET, SOCK_STREAM, 0);ERROR_CHECK(sockfd, -1, "socket");struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = inet_addr(argv[1]);server_addr.sin_port = htons(atoi(argv[2]));printf("waiting for connect\n");int ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));ERROR_CHECK(ret, -1, "bind");ret = listen(sockfd, 10); //放入监听集合ERROR_CHECK(ret, -1, "listen");int newfd = accept(sockfd, NULL, NULL);ERROR_CHECK(newfd, -1, "accept");printf("connected:%d\n", newfd);int epfd = epoll_create(1);ERROR_CHECK(epfd, -1, "epoll_create");struct epoll_event ev, evs[2];ev.events = EPOLLIN;ev.data.fd = STDIN_FILENO;ret = epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev);ERROR_CHECK(ret, -1, "epoll_ctl");ev.data.fd = newfd;ret = epoll_ctl(epfd, EPOLL_CTL_ADD, newfd, &ev);ERROR_CHECK(ret, -1, "epoll_ctl");char buf[1024];int readyNum = 0;while (1){readyNum = epoll_wait(epfd, evs, 2, -1);for(int i = 0; i < readyNum; i++){if(evs[i].data.fd == STDIN_FILENO){memset(buf, 0, sizeof(buf));read(STDIN_FILENO, buf, sizeof(buf));send(newfd, buf, strlen(buf), 0);}else if(evs[i].data.fd == newfd){memset(buf, 0, sizeof(buf));ret = read(newfd, buf, sizeof(buf));if(ret == 0){printf("disconnected\n");close(newfd);close(sockfd);return 0;}printf("client: %s", buf);} }}return 0;
}
//client
base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./client5 172.20.74.205 1236
waiting for connect
connected
hello i am client!
how do you do?
server: very good!
server: thank you!
server: goodbye
ok
^C
//server
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./server5 172.20.74.205 1236
waiting for connect
connected:4
client: hello i am client!
client: how do you do?
very good!
thank you!
goodbye
client: ok
disconnected
UDP通信
UDP相对于TCP减少了建立连接部分,客户端设置好通信地址后直接使用 sendto 和 recvfrom 即可通信,服务端绑定好套接字后不需要监听。直接阻塞等待客户端。需要注意的是,服务端一般不知道客户端的地址,但客户端知道服务端的信息,需要先等待客户端发送消息才能确定客户端的地址。
sendto
sendto
函数是网络编程中用于向指定地址发送数据的函数,通常用于无连接的套接字(如 UDP 套接字)。它允许发送方指定目标地址和端口,而不需要事先建立连接。sendto
函数是 UDP 通信中的关键函数,也可以用于已连接的 TCP 套接字。
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
参数
sockfd
类型:
int
作用:套接字描述符,标识了用于发送数据的套接字。该套接字可以是 UDP 套接字或已连接的 TCP 套接字。
buf
类型:
const void *
作用:指向要发送的数据的缓冲区。
len
类型:
size_t
作用:要发送的数据的长度(以字节为单位)。
flags
类型:
int
作用:发送标志,通常设置为
0
。可以设置的标志包括:MSG_OOB
:发送带外数据(仅适用于 TCP 套接字)。MSG_DONTROUTE
:跳过路由表,直接发送数据(仅适用于 TCP 套接字)。MSG_DONTWAIT
:非阻塞发送(仅适用于非阻塞套接字)。MSG_NOSIGNAL
:发送数据时不会产生SIGPIPE
信号(仅适用于 TCP 套接字)。
dest_addr
类型:
const struct sockaddr *
作用:指向目标地址结构体的指针,存储目标地址和端口信息。对于 IPv4,通常使用
struct sockaddr_in
;对于 IPv6,使用struct sockaddr_in6
。
addrlen
类型:
socklen_t
作用:
dest_addr
指向的地址结构体的大小(以字节为单位)。对于struct sockaddr_in
,大小通常为sizeof(struct sockaddr_in)
;对于struct sockaddr_in6
,大小通常为sizeof(struct sockaddr_in6)
。
返回值
成功时返回实际发送的字节数(可能小于请求的字节数)。
失败时返回
-1
,并设置errno
以指示错误原因。
recvfrom
recvfrom
函数是网络编程中用于从无连接的套接字(如 UDP 套接字)接收数据的函数。它允许接收方获取发送方的地址信息,而不需要事先建立连接。recvfrom
函数是 UDP 通信中的关键函数,也可以用于已连接的 TCP 套接字。
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
参数
sockfd
类型:
int
作用:套接字描述符,标识了用于接收数据的套接字。该套接字可以是 UDP 套接字或已连接的 TCP 套接字。
buf
类型:
void *
作用:指向接收数据的缓冲区。
len
类型:
size_t
作用:缓冲区的大小(以字节为单位),表示可以接收的最大数据量。
flags
类型:
int
作用:接收标志,通常设置为
0
。可以设置的标志包括:MSG_PEEK
:查看数据但不移除它(即“偷看”数据)。MSG_WAITALL
:阻塞直到接收到请求的所有数据。MSG_DONTWAIT
:非阻塞接收(仅适用于非阻塞套接字)。
src_addr
类型:
struct sockaddr *
作用:指向
struct sockaddr
或其派生类型(如struct sockaddr_in
或struct sockaddr_in6
)的指针,用于存储发送方的地址信息。如果不需要发送方的地址信息,可以设置为NULL
。
addrlen
类型:
socklen_t *(必须取地址)
作用:指向一个
socklen_t
类型的变量,该变量在调用前应设置为src_addr
指向的地址结构体的大小(以字节为单位)。调用后,addrlen
指向的变量将被设置为实际存储在src_addr
中的地址长度。如果src_addr
为NULL
,则addrlen
也应为NULL
。
返回值
成功时返回接收到的字节数。
如果对端关闭连接,返回
0
。失败时返回
-1
,并设置errno
以指示错误原因。
在使用UDP进行的通信的时候,要特别注意的是这是一个无连接的协议。一方面调用socket 函数的时候需要设置 SOCK_DGRAM 选项,而且因为没有建立连接的过程,所以必须总是由客户端先调用 sendto 发送消息给服务端,这样服务端才能知道对端的地址信息,从进入后续的通信。下面是使用UDP通信的一个例子:
//client
int main(int argc, char const *argv[])
{ARGS_CHECK(argc, 3);int sockfd, ret;sockfd = socket(AF_INET, SOCK_DGRAM, 0);ERROR_CHECK(sockfd, -1, "socket");struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = inet_addr(argv[1]);server_addr.sin_port = htons(atoi(argv[2]));printf("initialize raady\n");char buf[1024];socklen_t len = sizeof(server_addr);ret = read(STDIN_FILENO, buf, sizeof(buf));ERROR_CHECK(ret, -1, "read");ret = sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&server_addr, len);ERROR_CHECK(ret, -1, "sendto");ret = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&server_addr, &len);ERROR_CHECK(ret, -1, "recvfrom");printf("server:%s\n", buf);ret = read(STDIN_FILENO, buf, sizeof(buf));ERROR_CHECK(ret, -1, "read");ret = sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&server_addr, len);ERROR_CHECK(ret, -1, "sendto");close(sockfd);return 0;
}
//server
int main(int argc, char const *argv[])
{ARGS_CHECK(argc, 3);int sockfd, ret;sockfd = socket(AF_INET, SOCK_DGRAM, 0);ERROR_CHECK(sockfd, -1, "socket");struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = inet_addr(argv[1]);server_addr.sin_port = htons(atoi(argv[2]));ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));ERROR_CHECK(ret, -1, "bind");printf("initialize raady\n");char buf[1024];struct sockaddr_in client_addr;memset(&client_addr, 0, sizeof(client_addr));socklen_t len = sizeof(client_addr);ret = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&client_addr, &len);ERROR_CHECK(ret, -1, "recvfrom");printf("client: %s\n", buf);ret = read(STDIN_FILENO, buf, sizeof(buf));ERROR_CHECK(ret, -1, "read");ret = sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&client_addr, len);ERROR_CHECK(ret, -1, "sendto");ret = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&client_addr, &len);ERROR_CHECK(ret, -1, "recvfrom");printf("client: %s\n", buf);close(sockfd);return 0;
}
//client
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./client3 192.168.1.1 1236
initialize raady
hello
server:what are you doing ?
hahaha
//server
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./server3 192.168.1.1 1236
initialize raady
client: hellowhat are you doing ?
client: hahaha
e you doing ?
可以发现UDP是一种保留消息边界的协议,无论用户态空间分配的空间是否足够 recvfrom总是会取出一个完整UDP报文,那么没有拷贝的用户态内存的数据会直接丢弃。
实战:使用UDP的即时聊天
类似基于TCP的即时聊天通信,使用UDP也可以实现即时聊天通信,考虑到UDP是无连接协议,客户端需要首先发送一个消息让服务端知道客户端的地址信息,然后再使用 select 监听网络读缓冲区和标准输入即可。
需要特别注意的是,UDP通信不存在连接建立和断开过程,所以服务端无法知道客户端是否已经关闭套接字。
//client.c
int main(int argc, char const *argv[])
{ARGS_CHECK(argc, 3);struct sockaddr_in server_addr;server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = inet_addr(argv[1]);server_addr.sin_port = htons(atoi(argv[2]));int sockfd = socket(AF_INET, SOCK_DGRAM, 0);ERROR_CHECK(sockfd, -1, "socket");char *serverInf = "client is connected";sendto(sockfd, serverInf, strlen(serverInf), 0, (struct sockaddr*)&server_addr, sizeof(server_addr));char buf[1024] = {0};fd_set rdset;int ret;socklen_t len = sizeof(server_addr);while(1){FD_ZERO(&rdset);FD_SET(STDIN_FILENO, &rdset);FD_SET(sockfd, &rdset);select(sockfd + 1, &rdset, NULL, NULL, NULL);if(FD_ISSET(STDIN_FILENO, &rdset)){memset(buf, 0, sizeof(buf));read(STDIN_FILENO, buf, sizeof(buf));sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr*)&server_addr, len); }else if(FD_ISSET(sockfd, &rdset)){memset(buf, 0, sizeof(buf));ret = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&server_addr, &len);if(ret == 0){printf("disconnected\n");break;}printf("server: %s",buf); }}close(sockfd);return 0;
}
//server.cint main(int argc, char const *argv[])
{ARGS_CHECK(argc, 3);struct sockaddr_in server_addr;server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = inet_addr(argv[1]);server_addr.sin_port = htons(atoi(argv[2]));int sockfd = socket(AF_INET, SOCK_DGRAM, 0);ERROR_CHECK(sockfd, -1, "socket");int ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));ERROR_CHECK(ret, -1, "bind");char buf[1024] = {0};struct sockaddr_in client_addr;socklen_t len = sizeof(client_addr);ret = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&client_addr, &len);ERROR_CHECK(ret, -1, "recvfrom");printf("%s\n", buf);fd_set rdset;while(1){FD_ZERO(&rdset);FD_SET(STDIN_FILENO, &rdset);FD_SET(sockfd, &rdset);select(sockfd + 1, &rdset, NULL, NULL, NULL);if(FD_ISSET(STDIN_FILENO, &rdset)){memset(buf, 0, sizeof(buf));read(STDIN_FILENO, buf, sizeof(buf));sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr*)&client_addr, len); }else if(FD_ISSET(sockfd, &rdset)){memset(buf, 0, sizeof(buf));ret = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&client_addr, &len);if(ret == 0){printf("disconnected\n");break;}printf("client: %s",buf); }}close(sockfd);return 0;
}
//client
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./client4 192.168.1.1 1235
hello I am client!
what are you doing?
server: not need to know
server: goodbye!
OK
^C
//server
(base) ubuntu@ubuntu:~/MyProject/Linux/net$ ./server4 172.20.74.205 1235
hello I am client!client: what are you doing?
not need to know
goodbye!
client: OK
socket属性调整
getsockopt
getsockopt
函数是网络编程中用于获取套接字选项的函数。它允许程序员查询套接字的当前配置,获取各种参数的值,例如缓冲区大小、超时时间、是否允许重用地址等。
int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
参数
sockfd
类型:
int
作用:套接字描述符,标识了要查询选项的套接字。
level
类型:
int
作用:指定选项所在的协议级别。常见的值包括:
SOL_SOCKET
:套接字级别的选项。IPPROTO_TCP
:TCP 协议级别的选项。IPPROTO_IP
:IP 协议级别的选项。IPPROTO_IPV6
:IPv6 协议级别的选项。
optname
类型:
int
作用:指定要查询的选项名称。常见的选项包括:
SO_REUSEADDR
:是否允许重用本地地址和端口。SO_REUSEPORT
:是否允许重用端口。SO_BROADCAST
:是否允许发送广播数据。SO_KEEPALIVE
:是否启用 TCP 保活机制。SO_LINGER
:套接字关闭时的行为。SO_RCVBUF
:接收缓冲区大小。SO_SNDBUF
:发送缓冲区大小。TCP_NODELAY
:是否禁用 Nagle 算法。
optval
类型:
void *
作用:指向存储选项值的缓冲区。查询结果将存储在这个缓冲区中。
optlen
类型:
socklen_t *
作用:指向一个
socklen_t
类型的变量,该变量在调用前应设置为optval
指向的缓冲区的大小(以字节为单位)。调用后,optlen
指向的变量将被设置为实际存储在optval
中的值的大小。
返回值
成功时返回
0
。失败时返回
-1
,并设置errno
以指示错误原因。
setsockopt
setsockopt
函数是网络编程中用于设置套接字选项的函数。它允许程序员在套接字级别上配置各种参数,从而影响套接字的行为。这些选项可以包括缓冲区大小、超时时间、是否允许重用地址等。
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数
sockfd
类型:
int
作用:套接字描述符,标识了要设置选项的套接字。
level
类型:
int
作用:指定选项所在的协议级别。同 getsockopt 选项
optname
类型:
int
作用:指定要设置的选项名称。同 getsockopt 选项
optval
类型:
const void *
作用:指向存储选项值的缓冲区。选项值的类型取决于
optname
。
optlen
类型:
socklen_t
作用:
optval
指向的缓冲区的大小(以字节为单位)。
返回值
成功时返回
0
。失败时返回
-1
,并设置errno
以指示错误原因。