第三部分:网络编程
3.1 套接字编程(TCP/UDP基础)
3.1.1 套接字基础
3.1.1.1 套接字概念与类型
套接字(Socket)是网络编程的基础,它提供了一种进程间通信的机制。根据传输特点,套接字主要分为以下两种类型:
- 流套接字(Stream Socket):适用于TCP协议,提供了可靠、面向连接的字节流服务。数据传输有序、不丢失且无重复。
- 数据报套接字(Datagram Socket):适用于UDP协议,提供了无连接、尽力而为的服务。不保证数据按顺序到达、数据可能丢失或重复。
3.1.1.2 套接字地址结构
套接字地址用于表示网络上的主机和端口,包括以下几种常用结构:
sockaddr
:通用套接字地址结构,通常作为其他具体地址结构的基类使用。sockaddr_in
:用于IPv4地址,包括如下成员:struct sockaddr_in { short int sin_family; // 地址族(AF_INET) unsigned short int sin_port; // 端口号 struct in_addr sin_addr; // IP地址 unsigned char sin_zero[8]; // 填充字节,保证结构大小 };
sockaddr_in6
:用于IPv6地址,包括如下成员:struct sockaddr_in6 { u_int16_t sin6_family; // 地址族(AF_INET6) u_int16_t sin6_port; // 端口号 u_int32_t sin6_flowinfo; // 流量信息 struct in6_addr sin6_addr; // IPv6地址 u_int32_t sin6_scope_id; // Scope ID };
3.1.1.3 网络字节序与主机字节序转换
在网络编程中,数据的字节序(即在内存中存储多字节数据的方式)是一个重要概念。网络协议通常使用"大端字节序"(Big-endian),而不同主机可能使用"小端字节序"(Little-endian)。
字节序是什么?:
字节序
(Byte Order,又称字节顺序)是指计算机系统中存储多字节数据(如整数、浮点数等)时的字节排列顺序。字节序影响了如何在内存中组织更高阶数据类型。它在计算机系统之间传输数据以及数据的正确处理和解释上至关重要。
为什么字节序重要?
-
跨平台兼容性
:不同的计算机架构可能使用不同的字节序。如果不处理字节序的问题,在不同架构之间进行二进制数据交换时,可能会导致误解和错误的解析。 -
数据正确性
:如果程序需要从网络或文件中读取数据,必须确保数据以正确的字节序解释,否则可能导致数据错误。 -
协议实现
:在实现网络协议时,确定组件之间的数据格式和字节序是至关重要的,以确保数据一致性和兼容性。
大端字节序和小端字节序
-
大端字节序(Big-endian)
:- 又称高位序或网络字节序。
- 在大端字节序中,数据的高位字节存储在内存的低地址端。
- 例如,整数
0x12345678
将按以下顺序存储:12 34 56 78
。
-
小端字节序(Little-endian)
:- 又称低位序。
- 在小端字节序中,数据的低位字节存储在内存的低地址端。
- 例如,整数
0x12345678
将按以下顺序存储:78 56 34 12
。
为什么需要转换?
在网络编程中,数据通常需要从一个系统传输到另一个系统。双方计算机可能不使用相同的字节序。为了在不同使用字节序的系统之间正确地通信,我们需要一个统一的标准字节序。为此:
-
网络字节序
:通常使用大端字节序作为标准,因为许多早期网络协议(如IP、TCP、UDP)便采用这种字节序,确保不同系统能够相互理解数据。 -
主机字节序
:这是系统自身在内部计算和存储数据时使用的字节序,可能是大端或小端。
通过这些转换函数,如htonl
、ntohl
、htons
和ntohs
,程序开发者可以在主机字节序和网络字节序之间无误地转换数据。这些转换确保程序能在处理网络通信时正确解释和构造数据,使得不同架构的客户端和服务器可以流畅地交换信息。
以下函数用于在主机字节序和网络字节序之间转换:
-
htonl
(Host to Network Long):将32位整数从主机字节序转换为网络字节序。uint32_t htonl(uint32_t hostlong);
-
ntohl
(Network to Host Long):将32位整数从网络字节序转换为主机字节序。uint32_t ntohl(uint32_t netlong);
-
htons
(Host to Network Short):将16位整数从主机字节序转换为网络字节序。uint16_t htons(uint16_t hostshort);
-
ntohs
(Network to Host Short):将16位整数从网络字节序转换为主机字节序。uint16_t ntohs(uint16_t netshort);
这些转换函数在网络编程中至关重要,因为它们确保在不同架构的系统之间的通信中数据能被正确解释。
数据类型:
uint32_t
和 uint16_t
是在 C 标准库中定义的固定宽度整数类型,分别代表 32 位无符号整数和 16 位无符号整数。它们定义在头文件 <stdint.h>
中。使用这些类型的主要原因是为了保证跨平台的一致性。
在 C 语言中,诸如 int
、short
、unsigned int
和 unsigned short
等类型,其具体的位宽(也就是占用的字节数)并没有被标准严格定义,而是依赖于具体的实现和机器架构。例如,在某些平台上,int
可能是32位,但在其他平台上可能是16位或其他大小。
使用 uint32_t
和 uint16_t
等固定宽度的整数类型,可以保证程序在不同平台上的行为一致,因为这些类型在所有支持 C 语言标准的系统上都具有明确的位宽保证:
uint32_t
总是占据32位。uint16_t
总是占据16位。
在需要处理网络协议、二进制文件格式或者需要跨平台的一致行为时,使用这些固定宽度的整数类型会更加安全和可靠。
3.1.1.4 IPv4示例代码
以下是一个使用套接字连接TCP服务器的简单示例,展示了如何使用上述地址结构和字节序转换函数:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
int sockfd;
struct sockaddr_in server_addr;
// 创建套接字 [1]
if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置服务器地址 [2]
server_addr.sin_family = AF_INET; // [3]
server_addr.sin_port = htons(8080); // 端口号使用 htons 转换 [4]
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 本地主机 [5]
// 连接到服务器 [6]
if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("连接到服务器成功\n");
// 关闭套接字 [7]
close(sockfd);
return 0;
}
在上述示例中,我们创建了一个TCP种类的套接字,并连接到本地主机的8080端口。连接成功后,会打印一条消息并关闭套接字。
-
[1] 创建套接字:
socket()
函数用于创建一个套接字,AF_INET
表示使用 IPv4,SOCK_STREAM
表示使用 TCP 协议,0
表示使用默认协议。成功时返回套接字描述符,失败时返回-1
。 -
[2] 设置服务器地址:我们需要指定服务器的 IP 地址和端口号以建立连接。
-
[3] 设置地址族:
server_addr.sin_family = AF_INET;
设置地址族为 IPv4。 -
[4] 端口号转换:
htons(8080)
将端口号 8080 转换为网络字节序,htons
表示 “host to network short”(主机到网络短整数)。这一操作确保了字节序的正确性,因为网络的字节序通常不同于计算机的字节序。 -
[5] 本地主机地址转换:
inet_addr("127.0.0.1")
将 IP 地址字符串 “127.0.0.1” 转换为网络字节序的整数。127.0.0.1 是环回地址,表示本地主机。 -
[6] 连接到服务器:
connect()
函数用于请求与指定服务器的连接。需要传入套接字描述符、服务器地址结构指针以及该结构的大小。若连接失败,connect
函数返回-1
。 -
[7] 关闭套接字:
close(sockfd)
关闭套接字套接字描述符,释放系统资源。
socket
函数概述
socket
函数是用于创建一个新的套接字(socket),它是网络通信的基本资源。其函数原型通常如下:
int socket(int domain, int type, int protocol);
domain
: 定义了使用的地址族。常见的值有AF_INET
(IPv4)和AF_INET6
(IPv6)。type
: 指定套接字的类型,常用的有SOCK_STREAM
(TCP,面向连接的流套接字)和SOCK_DGRAM
(UDP,数据报套接字)。protocol
: 通常设置为0
以选择默认协议。不同的套接字类型和地址族可能有多个协议可用,不过多数情况下系统会根据前两个参数自行选择合适的协议。
返回值
- 成功时,
socket
函数返回新创建的套接字描述符,它是一个非负整数。 - 失败时,返回
-1
,并设置errno
以指示错误类型。
为什么返回值是 int
类型的 sockfd
?
-
通用性和效率:
- 在类 Unix 系统中,文件描述符统一为
int
类型。网络通信的套接字、文件 I/O 和其他系统资源(如管道)都用int
类型的文件描述符来识别。这提供了统一的接口,可以更高效地管理和调用不同类型的资源。
- 在类 Unix 系统中,文件描述符统一为
-
简化资源管理:
- 由于所有资源都通过统一的整数描述符管理,系统可以使用相同的底层机制来管理这些资源。这种设计大大简化了网络套接字和文件操作的编程模型,使得 socket 可以使用和文件操作相同的函数(如
read
、write
、close
)进行操作。
- 由于所有资源都通过统一的整数描述符管理,系统可以使用相同的底层机制来管理这些资源。这种设计大大简化了网络套接字和文件操作的编程模型,使得 socket 可以使用和文件操作相同的函数(如
-
系统实现的便利性:
int
类型的文件描述符设计提供了一种简单、高效的方式将每个打开的文件或套接字映射到相应的内核数据结构。文件描述符是一个索引,指向内核维护的一个文件表,表中包含与每个打开的文件或套接字相关的信息。
-
扩展性:
- 使用
int
作为描述符可以轻松扩展到不同类型的 I/O 设备和其他资源,而不需要为每种资源类型设计特殊的识别符号或对象。
- 使用
总体来说,这种设计让程序员能够以一种一致的方式与不同的I/O资源进行交互,让操作系统以一种高效的方式管理这些资源。
connect
函数的参数
connect
函数的原型如下:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
-
sockfd
:- 这是由
socket
函数返回的套接字文件描述符,标识一个唯一的套接字。
- 这是由
-
addr
:- 一个指向
sockaddr
结构的指针,描述要连接的目标地址。在 IPv4 协议中,This is typically cast tostruct sockaddr_in*
for IPv4 use but passed as a genericstruct sockaddr*
. - 具体数值是
server_addr
所指向的内存块在调用时被强制转换为通用的sockaddr
。
- 一个指向
-
addrlen
:- 该参数指定
addr
指向的结构的大小。对于sockaddr_in
结构,它通常是sizeof(struct sockaddr_in)
。
- 该参数指定
connect
函数的作用
connect
函数主要负责在客户端与服务器软件之间建立一个连接。它通过提供的套接字 sockfd
以及目标地址 addr
尝试连接指定的远程服务器。执行以下操作:
- 初始化三次握手:作为TCP协议的一部分,
connect
开始 TCP 三次握手过程以确保通信。 - 发送同步和确认信号以请求连接。
- 在连接成功后,允许发送/接收数据。
如果连接成功,connect
会返回0,否则返回-1,并设置 errno
表示错误类型。
数据通信
connect
成功后,你可以使用以下函数进行数据通信:
-
发送数据:
- 使用
send
或write
函数将数据发送到服务器。 - 从
sockfd
发送数据。
示例:
char *message = "Hello, Server!"; if(send(sockfd, message, strlen(message), 0) < 0) { perror("send"); // handle error }
- 使用
-
接收数据:
- 使用
recv
或read
函数从服务器接收数据。 - 接收的是存放于
buf
的数据。
示例:
char buffer[1024]; int bytes_received; if((bytes_received = recv(sockfd, buffer, sizeof(buffer), 0)) < 0) { perror("recv"); // handle error } buffer[bytes_received] = '\0'; // Null-terminate received data printf("Received: %s\n", buffer);
- 使用
-
关闭连接:
- 使用
close
关闭该套接字以清理资源,结束连接。
close(sockfd);
- 使用
通过这些步骤,你可以在连接建立后,与服务器进行通信并实现数据的发送和接收。确保在实际编程中适当地处理错误和异常情况,以提高代码的健壮性和正确性。
3.1.1.5 IPv6示例代码
以下是一个使用套接字连接到一个支持 IPv6 的 TCP 服务器的简单示例,展示了如何使用 sockaddr_in6
结构体来处理 IPv6 地址:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
int sockfd;
struct sockaddr_in6 server_addr;
// 创建套接字 [1]
if ((sockfd = socket(AF_INET6, SOCK_STREAM, 0)) < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置服务器地址 [2]
server_addr.sin6_family = AF_INET6; // 设置地址族为 IPv6 [3]
server_addr.sin6_port = htons(8080); // 端口号使用 htons 转换 [4]
inet_pton(AF_INET6, "::1", &server_addr.sin6_addr); // 本地主机 IPv6 地址 [5]
server_addr.sin6_flowinfo = 0; // 流量信息不设置 [6]
server_addr.sin6_scope_id = 0; // Scope ID 不设置 [7]
// 连接到服务器 [8]
if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("连接到服务器成功\n");
// 关闭套接字 [9]
close(sockfd);
return 0;
}
在上述示例中,我们创建了一个支持 IPv6 的 TCP 套接字,并连接到支持 IPv6 的本地服务器的8080端口。连接成功后,程序会打印一条消息并关闭套接字。
-
[1] 创建套接字:
socket()
函数用于创建 IPv6 套接字,AF_INET6
表示使用 IPv6 地址,SOCK_STREAM
表示使用 TCP 协议,0
表示使用默认协议。成功时返回套接字描述符,失败时返回-1
。 -
[2] 设置服务器地址:为 IPv6,我们指定需要使用的 IPv6 地址和端口号。
-
[3] 设置地址族:
server_addr.sin6_family = AF_INET6;
设置为 IPv6 地址族。 -
[4] 端口号转换:
htons(8080)
将端口号 8080 转换到网络字节序。 -
[5] 本地主机地址转换:使用
inet_pton
函数将字符串形式的 IPv6 地址 “::1”(也即 IPv6 的环回地址,类似于 IPv4 的 127.0.0.1)转换到网络字节序。 -
[6] 流量信息:
sin6_flowinfo
设置成 0,通常用于服务质量 (QoS) 和流标签。 -
[7] Scope ID:对于环回地址,这里为
0
。Scope ID 用于标识链路本地 IPv6 地址的接口。 -
[8] 连接到服务器:与 IPv4 中的
connect()
类似,用于请求与指定 IPv6 服务器的连接。 -
[9] 关闭套接字:释放系统资源,关闭套接字描述符。
3.1.1.6 IPv4 与 IPv6 的自动选择
在C语言的网络编程中,为了在IPv4和IPv6两种网络环境中实现智能地创建合适的socket地址对象,你可以采用如下方法:
使用 getaddrinfo()
函数
getaddrinfo()
是一个强大的函数,它可以帮助你在支持多协议(IPv4 和 IPv6)的网络程序中自动处理地址解析和套接字创建。它是线程安全和可重入的,因此推荐用于现代C网络程序中。
优点:
- 协议无关:支持IPv4和IPv6。
- 简化地址解析:不需要分别解析IPv4和IPv6地址。
用步骤:
-
包含头文件:
#include <sys/types.h> #include <sys/socket.h> #include <netdb.h> #include <string.h>
-
调用
getaddrinfo()
:- 准备一个
struct addrinfo
的 hint 结构体。 - 设置
ai_family
为AF_UNSPEC
以允许IPv4和IPv6。 - 调用
getaddrinfo()
并传递主机名或IP地址和服务名或端口号。
- 准备一个
-
遍历结果列表:
getaddrinfo()
会返回一个链表,你可以遍历这个链表并尝试创建socket。- 一旦成功创建socket,立即使用并退出遍历。
-
释放结果列表:
- 使用
freeaddrinfo()
函数释放由getaddrinfo()
动态分配的内存。
- 使用
代码示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>
int main() {
struct addrinfo hints, *res, *ai;
int sockfd;
int status;
// 清空 hints 结构体
memset(&hints, 0, sizeof hints);
// 我们想要一个可以用来连接的 socket
hints.ai_family = AF_UNSPEC; // 允许使用 IPv4 或 IPv6
hints.ai_socktype = SOCK_STREAM; // TCP 流套接字
// 获取目标服务器的地址信息
status = getaddrinfo("example.com", "http", &hints, &res);
if (status != 0) {
fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(status));
return 1;
}
// 遍历所有的结果,并连接到第一个我们可以连接的
for (ai = res; ai != NULL; ai = ai->ai_next) {
// 尝试创建 socket
sockfd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (sockfd == -1) {
perror("socket");
continue;
}
// 尝试连接
if (connect(sockfd, ai->ai_addr, ai->ai_addrlen) == -1) {
close(sockfd);
perror("connect");
continue;
}
// 如果程序执行到这里,说明连接成功
break;
}
if (ai == NULL) {
fprintf(stderr, "连接失败\n");
return 2;
}
// 使用该 socket(例如,发送/接收数据)
// 清理资源
freeaddrinfo(res);
close(sockfd);
return 0;
}
说明:
getaddrinfo()
返回的结构链表中可能包含多个addrinfo
结构体,每一个结构体可能代表一种可行的地址和协议组合。- 在循环中尝试
socket()
和connect()
,当产生错误时,你可以继续尝试链表中的下一个元素。 - 这样写出的程序能优雅地支持IPv4和IPv6,并能够根据当前网络情况选择合适的协议与地址。
3.1.2 TCP 编程
在进行网络编程时,TCP(传输控制协议)是一种常见的选择,因其提供了可靠的、有序的、基于连接的数据传输服务。以下内容将详细介绍如何使用C语言进行TCP编程。
3.1.2.1 TCP 套接字的创建与配置 (socket
, setsockopt
)
-
套接字创建
- 使用
socket
函数创建TCP套接字。socket
函数通常使用如下形式:int socket(int domain, int type, int protocol);
domain
:协议族,比如AF_INET
表示IPv4协议。type
:套接字类型,比如SOCK_STREAM
表示流套接字。protocol
:一般设为0
,默认值代表TCP。
- 示例代码:
int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { perror("socket creation failed"); exit(EXIT_FAILURE); }
- 使用
-
套接字配置
- 使用
setsockopt
函数配置套接字选项以提高通信效率、解决地址复用问题等:int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
-
sockfd
:套接字描述符。 -
level
:选项所在的协议层,比如SOL_SOCKET
表示套接字层。 -
optname
:指定需要设置的选项名,如SO_REUSEADDR
。 -
optval
:选项对应的值。 -
optlen
:optval
的长度。 -
示例代码设置地址复用:
int opt = 1; if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) { perror("setsockopt failed"); exit(EXIT_FAILURE); }
-
- 使用
3.1.2.2 服务器端编程(bind
, listen
, accept
)
-
绑定(bind)
- 绑定套接字到特定的地址与端口上,使服务器可以接收客户端的连接请求:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
-
sockfd
:套接字描述符。 -
addr
:服务器地址和端口号,采用struct sockaddr
结构体来存储。 -
addrlen
:addr
的长度。 -
示例代码:
struct sockaddr_in address; address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; // 绑定到所有可用地址 address.sin_port = htons(PORT); // 将端口号转换为网络字节序 if (bind(sockfd, (struct sockaddr *)&address, sizeof(address)) < 0) { perror("bind failed"); exit(EXIT_FAILURE); }
-
- 绑定套接字到特定的地址与端口上,使服务器可以接收客户端的连接请求:
-
监听(listen)
listen
函数将套接字设为被动模式,用于接收客户端连接:int listen(int sockfd, int backlog);
-
sockfd
:套接字描述符。 -
backlog
:等待连接队列的最大长度。指明了内核为此套接字排队的最大连接数。 -
示例代码:
if (listen(sockfd, 3) < 0) { perror("listen failed"); exit(EXIT_FAILURE); }
-
-
接受连接(accept)
accept
函数提取待处理连接请求,为每个连接分配一个新的套接字:int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
-
sockfd
:监听套接字的描述符。 -
addr
:指向一个用于存储已连接客户端地址信息的struct sockaddr
结构体。 -
addrlen
:指向一个值的指针,最初这个值指定客户端地址结构体addr
的大小,函数返回时更新为实际客户端地址的大小。 -
示例代码:
int new_socket; struct sockaddr_in client_address; socklen_t addrlen = sizeof(client_address); new_socket = accept(sockfd, (struct sockaddr *)&client_address, &addrlen); if (new_socket < 0) { perror("accept failed"); exit(EXIT_FAILURE); } printf("Connection accepted.\n");
-
3.1.2.3 客户端编程(connect
)
- 连接服务器(connect)
connect
函数用于客户端尝试连接服务器:int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 示例代码连接服务器:
struct sockaddr_in server_address; server_address.sin_family = AF_INET; server_address.sin_port = htons(PORT); if (inet_pton(AF_INET, "127.0.0.1", &server_address.sin_addr) <= 0) { perror("invalid address/ Address not supported"); exit(EXIT_FAILURE); } if (connect(sockfd, (struct sockaddr *)&server_address, sizeof(server_address)) < 0) { perror("connection failed"); exit(EXIT_FAILURE); } printf("Connected to server.\n");
3.1.2.4 数据传输(send
, recv
)
-
发送数据(send)
send
函数用于将数据从客户端/服务器发送到另一端:ssize_t send(int sockfd, const void *buf, size_t len, int flags);
-
sockfd
:套接字描述符,标识要发送数据的套接字。 -
buf
:指向包含要发送数据的缓冲区。 -
len
:要发送的数据长度,以字节为单位。 -
flags
:发送数据的标志,可以是 0 或使用位掩码组合的其他标志(如MSG_DONTWAIT
)。 -
示例代码:
char *message = "Hello, World!"; send(new_socket, message, strlen(message), 0); printf("Message sent.\n");
-
-
接收数据(recv)
recv
函数用于从连接中接收数据:ssize_t recv(int sockfd, void *buf, size_t len, int flags);
-
sockfd
:套接字描述符,从中接收数据。 -
buf
:指向用于存储接收数据的缓冲区。 -
len
:缓冲区的大小,以字节为单位。 -
flags
:接收操作的标志,操作的修改行为,如MSG_WAITALL
,MSG_PEEK
。 -
示例代码:
char buffer[1024] = {0}; int valread = recv(new_socket, buffer, 1024, 0); printf("Received: %s\n", buffer);
-
3.1.2.5 连接关闭(close
, shutdown
)
-
连接关闭(close)
close
函数用于关闭套接字及其创建的连接:int close(int fd);
-
fd
:需要关闭的文件描述符,通常表示一个打开的套接字。 -
示例代码:
close(new_socket);
-
-
关闭连接(shutdown)
shutdown
函数用于关闭部分连接,即停止进一步发送或接收数据:int shutdown(int sockfd, int how);
how
参数:SHUT_RD
:关闭读但继续写。SHUT_WR
:关闭写但继续读。SHUT_RDWR
:关闭读写。
- 示例代码:
shutdown(sockfd, SHUT_RDWR);
3.1.3 UDP 编程
UDP(User Datagram Protocol)是一种无连接的协议,与面向连接的TCP不同,UDP更轻量、无需建立连接,因此常用于对时延要求高但不需要可靠传输的场景。下面是关于UDP编程的详细讲解:
3.1.3.1 UDP 套接字的创建与配置 (socket
, setsockopt
)
套接字的创建:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main() {
int sockfd;
sockfd = socket(AF_INET, SOCK_DGRAM, 0); // 创建UDP套接字 [1]
if (sockfd < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
printf("UDP socket created successfully\n");
close(sockfd);
return 0;
}
- [1] 创建UDP套接字:
socket(AF_INET, SOCK_DGRAM, 0)
创建了一个IPv4的UDP套接字。AF_INET
表示IPv4地址族,SOCK_DGRAM
表示数据报套接字,0
表示默认协议。
套接字选项配置:
// 省略必要的#include和main函数启动部分
int opt = 1;
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) { // 配置套接字选项 [2]
perror("setsockopt failed");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("Socket options set successfully\n");
- [2] 配置套接字选项:
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))
设置套接字的选项。SO_REUSEADDR
允许在套接字关闭后立即重新使用该端口。
3.1.3.2 服务器端编程(bind
)
// 省略必要的#include和main函数启动部分
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET; // IPv4
servaddr.sin_addr.s_addr = INADDR_ANY; // 监听所有接口
servaddr.sin_port = htons(PORT); // 端口
if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) { // 绑定 [3]
perror("bind failed");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("Bind to port %d successful\n", PORT);
- [3] 绑定:
bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr))
将IP地址和端口绑定到套接字上。INADDR_ANY
表示绑定到所有可用接口。
3.1.3.3 数据发送与接收(sendto
, recvfrom
)
数据发送:
char *message = "Hello, UDP!";
struct sockaddr_in cliaddr;
memset(&cliaddr, 0, sizeof(cliaddr));
cliaddr.sin_family = AF_INET;
cliaddr.sin_port = htons(CLIENT_PORT);
cliaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
int n = sendto(sockfd, message, strlen(message), MSG_CONFIRM, (const struct sockaddr *) &cliaddr, sizeof(cliaddr)); // 发送数据 [4]
if (n < 0) {
perror("sendto failed");
} else {
printf("Message sent.\n");
}
- [4] 发送数据:
sendto(sockfd, message, strlen(message), MSG_CONFIRM, (const struct sockaddr *) &cliaddr, sizeof(cliaddr))
将数据发送到指定的地址和端口。sockfd
:套接字描述符,标识要使用的套接字。message
:指向要发送数据的缓冲区,在此例中是一个字符串"Hello, UDP!"
。strlen(message)
:要发送数据的长度,以字节为单位。这一参数确保只发送指定长度的数据。MSG_CONFIRM
:标志参数,用于指定发送操作的特定选项,例如MSG_CONFIRM
表示在某些协议中需要确认(该标志在 UDP 中实际上不常用)。(const struct sockaddr *) &cliaddr
:指向包含目标地址和端口信息的sockaddr
结构的指针。在此例中,cliaddr
包含了目标 IP 地址和端口。sizeof(cliaddr)
:目标地址结构的长度。指明cliaddr
的大小,让函数正确理解地址结构的长度。
数据接收:
char buffer[MAXLINE];
struct sockaddr_in cliaddr;
socklen_t len = sizeof(cliaddr);
int n = recvfrom(sockfd, buffer, MAXLINE, MSG_WAITALL, (struct sockaddr *) &cliaddr, &len); // 接收数据 [5]
if (n < 0) {
perror("recvfrom failed");
} else {
buffer[n] = '\0'; // 添加字符串终止符
printf("Client : %s\n", buffer);
}
- [5] 接收数据:
recvfrom(sockfd, buffer, MAXLINE, MSG_WAITALL, (struct sockaddr *) &cliaddr, &len)
从指定的地址和端口接收数据。sockfd
:套接字描述符,用于标识接收数据的套接字。buffer
:用于存储接收到的数据的缓冲区。MAXLINE
:接收缓冲区的最大字节数,buffer
的大小。MSG_WAITALL
:接收选项标志。此标志表示在完整接收到请求字节的数据之前,调用不会返回。可以根据需要选择其他标志,如0
。(struct sockaddr *) &cliaddr
:指向存储来源地址信息的指针。cliaddr
是一个用于保存发送端地址信息的结构体。&len
:指向一个变量的指针,存储cliaddr
结构体的大小,并在接收函数返回时,包含发送端地址的实际长度。
3.1.3.4 连接管理(无连接特性分析)
UDP是无连接的,即在发送数据前不需要与对方建立连接。每个数据包(数据报)独立发送,可能会乱序到达或丢失,因此需要应用层实现可靠性保证。
3.1.3.5 数据报的丢失与重传机制
在UDP中,由于无连接特性,数据包可能丢失、重复或乱序到达。实际应用中通常需要在应用层实现:
- 重传机制:超时未收到ACK则重传数据。
- 序列号机制:为每个数据包加上序列号,以便接收方按序重组。
- 校验和检查:用来检查数据完整性。
综上所述,UDP编程的核心在于通过创建和配置套接字、实现数据的发送与接收,并结合应用层机制来应对数据丢失及乱序问题。希望以上讲解对你的项目开发有所帮助。
3.1.4 高级套接字编程技巧
3.1.4.1 非阻塞套接字与多路复用(select
, poll
, epoll
)
在高级网络编程中,非阻塞I/O与多路复用技术是处理高并发连接的关键。非阻塞I/O使套接字在I/O操作时不会阻塞进程,多路复用则允许程序同时监视多个套接字,提升效率。
-
非阻塞套接字
- 设置方法:使用
fcntl
函数将套接字设置为非阻塞模式。#include <fcntl.h> int set_nonblocking(int sock) { // [1] int flags = fcntl(sock, F_GETFL, 0); // [2][3] if (flags == -1) return -1; return fcntl(sock, F_SETFL, flags | O_NONBLOCK); // [4] }
sock
:要设置为非阻塞模式的套接字描述符。flags
:套接字的当前标志位,通过fcntl
的F_GETFL
命令获取。fcntl(sock, F_GETFL, 0)
:获取sock
当前的文件状态标志。fcntl(sock, F_SETFL, flags | O_NONBLOCK)
:将套接字设为非阻塞模式,在现有标志位的基础上添加O_NONBLOCK
,更新文件状态标志。
- 设置方法:使用
-
select
- 用于监视一组文件描述符(套接字),在任何一个或多个文件描述符变为可读、可写或有错误时返回。
fd_set readfds; // [1] FD_ZERO(&readfds); // [2] FD_SET(sock, &readfds); // [3] int result = select(sock + 1, &readfds, NULL, NULL, &timeout); // [4] if (result > 0 && FD_ISSET(sock, &readfds)) { // [5] // sock 变为可读 }
readfds
:一个文件描述符集合,用于存储需要监视的文件描述符,检查它们是否可读。FD_ZERO(&readfds)
:初始化文件描述符集合readfds
,将其清空。FD_SET(sock, &readfds)
:将套接字sock
添加到readfds
集合中,用于监控其可读事件。select(sock + 1, &readfds, NULL, NULL, &timeout)
:sock + 1
:第一个参数指定监视的文件描述符范围,即待监控的最大描述符加一(因数组索引从零开始)。&readfds
:第二个参数指定需要检查可读性的文件描述符集合。NULL
:第三个和第四个参数用于检查可写性和异常情况的文件描述符集合,设置为NULL
表示不检查。&timeout
:第五个参数为select
等待的超时时间。
result
:select
函数的返回值;大于 0 表示有文件描述符变为可读、可写或有错误,小于 0 表示出错,等于 0 表示超时无事件发生。FD_ISSET(sock, &readfds)
:宏用于判断套接字sock
是否在readfds
集合中可读。
- 用于监视一组文件描述符(套接字),在任何一个或多个文件描述符变为可读、可写或有错误时返回。
-
poll
- 类似于
select
, 但处理的文件描述符数量更大,且性能更好。struct pollfd fds[1]; fds[0].fd = sock; // [1] fds[0].events = POLLIN; // [2] int result = poll(fds, 1, timeout); // [3][4][5] if (result > 0 && (fds[0].revents & POLLIN)) { // [6] // sock 变为可读 }
fds
:pollfd
结构体数组,用于指定要监视的文件描述符和事件。fds[0].fd
:要检测的套接字描述符。fds[0].events
:待检测的事件类型,例如POLLIN
表示等待数据可读。poll(fds, 1, timeout)
:调用poll
函数执行检测。1
:指定fds
数组中需要检测的文件描述符数量。timeout
:指定poll
等待事件的毫秒数。负值表示无限等待。result
:poll
的返回值,表示准备就绪的文件描述符数量。若大于 0,表示有文件描述符满足条件。
- 类似于
-
epoll
- 专为Linux设计的更高效的I/O多路复用机制,适用于处理大量并发连接。
int epoll_fd = epoll_create1(0); // [1] struct epoll_event ev = {.events = EPOLLIN, .data.fd = sock}; // [2] epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock, &ev); // [3] struct epoll_event events[MAX_EVENTS]; // [4] int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); // [5] for (int i = 0; i < nfds; i++) { if (events[i].events & EPOLLIN) { // sock 变为可读 } }
epoll_create1(0)
:创建一个 epoll 实例,返回一个 epoll 文件描述符,用于后续的 epoll 操作。struct epoll_event ev
:定义一个 epoll 事件结构体ev
,用于描述要监视的事件类型和相关数据。ev.events
:代表事件类型,如EPOLLIN
表示可读事件。ev.data.fd
:事件关联的文件描述符,这里是套接字sock
。
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock, &ev)
:将套接字sock
添加到 epoll 监控列表中,通过epoll_fd
进行管理。epoll_fd
:epoll 文件描述符。EPOLL_CTL_ADD
:操作码,表示将新的描述符添加到 epoll 实例中。sock
:要添加的文件描述符。&ev
:指向要添加的事件结构的指针。
struct epoll_event events[MAX_EVENTS]
:定义事件数组events
,用于存储被触发事件的信息,数组大小由MAX_EVENTS
定义。epoll_wait(epoll_fd, events, MAX_EVENTS, -1)
:等待事件发生。epoll_fd
:epoll 文件描述符。events
:指向epoll_event
结构体数组,存储触发的事件。MAX_EVENTS
:可以监听的最大事件数。-1
:超时值,-1
代表无限期等待直到事件发生。
nfds
:epoll_wait
返回值,表示已触发事件的数量。
- 专为Linux设计的更高效的I/O多路复用机制,适用于处理大量并发连接。
3.1.4.2 套接字选项(SO_REUSEADDR
, SO_KEEPALIVE
, SO_LINGER
等)
套接字选项用于控制套接字的行为,可以通过 setsockopt
函数设置不同选项。
-
SO_REUSEADDR
- 允许在套接字关闭后立即重用地址。
int opt = 1; setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
-
SO_KEEPALIVE
- 启用保持连接功能,内核会定期发送探测包以检测连接是否活跃。
int opt = 1; setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, &opt, sizeof(opt));
-
SO_LINGER
- 控制套接字关闭时的行为,避免未发送的数据丢失。
struct linger so_linger; so_linger.l_onoff = 1; /* 开启linger选项 */ so_linger.l_linger = 30; /* 超时30秒 */ setsockopt(sock, SOL_SOCKET, SO_LINGER, &so_linger, sizeof(so_linger));
3.1.4.3 多线程与多进程服务器模型(预创建线程/进程池)
为了提升服务器的性能和并发处理能力,常采用多线程或多进程模型,每个线程或进程处理不同的连接。
-
多线程模型
- 使用线程池预创建多个线程,每个线程等待处理新的连接。
void *thread_function(void *arg) { // [1] int sock = *(int*)arg; // [2] // 处理连接 return NULL; } void create_thread_pool(int num_threads) { // [3] pthread_t threads[num_threads]; // [4] for (int i = 0; i < num_threads; i++) { // [5] pthread_create(&threads[i], NULL, thread_function, (void*)&sock); // [6] } }
arg
:传递给线程函数的参数,一般为指向套接字描述符的指针。sock
:从arg
解引用得到的套接字描述符,用于处理连接。num_threads
:要创建的线程数量,也就是线程池中的线程数量。threads
:存储线程标识符的数组,用于跟踪和管理线程。i
:循环变量,用于迭代创建num_threads
个线程。pthread_create(&threads[i], NULL, thread_function, (void*)&sock)
:用于创建线程,将每个线程绑定到thread_function
函数,并传递套接字作为参数。
- 使用线程池预创建多个线程,每个线程等待处理新的连接。
-
多进程模型
- 使用
fork
创建子进程处理新连接,或使用预创建的进程池。void create_process_pool(int num_processes) { for (int i = 0; i < num_processes; i++) { // [1] pid_t pid = fork(); // [2] if (pid == 0) { // 子进程处理连接 exit(0); // [3] } } // 父进程等待子进程结束 }
num_processes
:需要创建的子进程数量,用于处理并发连接。i
:循环变量,用于迭代创建指定数量的子进程。pid
:进程ID,由fork()
函数返回,用于区分父进程和子进程。pid
为0表示当前进程是子进程,正数表示父进程获得的子进程ID。exit(0)
:子进程完成任务后正常退出,返回0表示成功执行退出。
- 使用
3.1.4.4 套接字超时设置(连接超时与操作超时)
设置超时可以防止程序在某些操作上无限期等待,常用的选项有 SO_RCVTIMEO
和 SO_SNDTIMEO
。
-
设置接收超时
struct timeval tv; tv.tv_sec = 5; // 5秒超时 tv.tv_usec = 0; setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
-
设置发送超时
struct timeval tv; tv.tv_sec = 5; // 5秒超时 tv.tv_usec = 0; setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
通过上述技术,您可以显著提高基于C语言的网络应用程序的性能和稳定性,确保其在高并发场景下的健壮性。