本文通过一个完整的C语言示例,详细讲解如何在Linux环境下使用UDP协议实现基本的客户端与服务器通信,涵盖核心函数、实现步骤及常见问题排查。
一、实验目的
利用UDP协议,实现一个简单的客户端-服务器(Client-Server)通信程序,理解UDP无连接通信的基本模型。
二、核心原理与概念
UDP(用户数据报协议)是一种无连接、不可靠但高效的传输层协议。
通信特点
-
无连接:每次数据传输都是独立的,必须指定目标IP和端口。
-
面向数据报:以完整的报文为单位进行收发,保留边界。
-
适用场景:适用于对实时性要求高、可容忍少量丢包的场景,如视频流、音频通话、DNS查询等。
通信流程
通信双方(A和B)的流程是对称的,但在典型客户端/服务器模型中角色稍有不同:
服务端: socket() -> bind() -> recvfrom() -> sendto()
客户端: socket() -> sendto() -> recvfrom()
网络通信的三种情况
-
本机通信:使用回环地址 127.0.0.1。
-
局域网内跨机通信:使用目标主机的局域网IP(如 192.168.1.100)。
-
跨网/公网通信:必须知道目标主机的公网IP,且需要路由器进行端口转发(NAT)。
三、核心函数详解
3.1 关键函数
1.socket() - 创建通信端点
1
2
3
|
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
|
作用:创建通信端点,返回一个文件描述符用于后续操作。
参数:
domain:协议族,如 AF_INET (IPv4)。
type:套接字类型,SOCK_DGRAM 表示UDP。
protocol:通常设为 0,系统自动选择。
返回值:成功返回文件描述符(>=0),失败返回-1。
2.bind()-绑定地址(服务器必需)
1
|
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
|
作用:将一个套接字 (sockfd) 与一个特定的 IP 地址和端口号(由 addr 指定)绑定在一起。服务器程序必须调用此函数来声明它在哪里监听。
参数解释:
sockfd:socket() 函数返回的套接字描述符。
addr:指向 struct sockaddr 的指针,包含了要绑定的地址和端口信息。对于 IPv4,我们实际使用 struct sockaddr_in 并强制转换。
addrlen:addr 结构体的长度,通常为 sizeof(struct sockaddr_in)。
返回值:成功返回 0,失败返回 -1。
3.sendto()- 发送UDP数据报
1
2
|
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr,
socklen_t addrlen);
|
作用:向指定的目标地址 (dest_addr) 发送一个数据报。
参数解释:
sockfd:套接字描述符。
buf:指向待发送数据缓冲区的指针。
len:要发送数据的长度(字节数)。
flags:控制标志,通常设为 0。
dest_addr:指向目标地址结构体的指针,决定了数据发往哪里。
addrlen:目标地址结构体的长度。
返回值:成功返回实际发送的字节数,失败返回 -1。
4.recvfrom()-接收UDP数据报
1
|
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
|
作用:从套接字接收一个数据报,并获取发送者的地址信息 (src_addr)。
参数解释:
sockfd:套接字描述符。
buf:指向接收数据缓冲区的指针。
len:缓冲区的最大容量。
flags:控制标志,通常设为 0。
src_addr:指向 struct sockaddr 的指针,用于存放发送者的地址信息。如果不需要,可设为 NULL。
addrlen:这是一个 值-结果参数。调用前,需将其初始化为零
src_addr: 缓冲区的长度;返回时,它会被设置为实际存放的地址长度。
返回值:成功返回接收到的字节数,失败返回 -1。返回 0 在UDP中不表示连接关闭,而是收到了一个长度为0的数据报。
3.2 辅助函数与字节序转换
1.地址转换函数
1
2
3
4
5
|
#include <arpa/inet.h>
// 字符串IP -> 网络字节序二进制 (Presentation to Network)
int inet_pton(int af, const char *src, void *dst);
// 网络字节序二进制 -> 字符串IP (Network to Presentation)
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
|
2.字节序转换函数
网络字节序采用大端序,主机字节序可能不同,必须转换。
1
2
3
4
5
6
7
|
#include <arpa/inet.h>
// 主机 -> 网络 (Host to Network)
uint16_t htons(uint16_t hostshort); // 用于端口
uint32_t htonl(uint32_t hostlong); // 用于IP地址
// 网络 -> 主机 (Network to Host)
uint16_t ntohs(uint16_t netshort);
uint32_t ntohl(uint32_t netlong);
|
3.3关键数据结构
1
2
3
4
5
6
7
8
9
10
|
#include <netinet/in.h>
struct sockaddr_in {
sa_family_t sin_family; // 地址族,如 AF_INET
in_port_t sin_port; // 16位端口号(网络字节序)
struct in_addr sin_addr; // 32位IP地址(网络字节序)
char sin_zero[8]; // 填充字段,通常置零
};
struct in_addr {
in_addr_t s_addr; // 32位IPv4地址
};
|
四.实现步骤分析
服务器端实现步骤
理解了核心函数后,我们按步骤构建服务器。
1.创建udp套接字
1
2
3
4
5
6
|
int sfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sfd == -1)
{
perror("socket failed");
exit(1);
}
|
2.准备并绑定服务器地址
这里需要理解 struct sockaddr_in 这个重要的结构体。
1
2
3
4
5
6
7
8
9
10
|
#include <netinet/in.h>
struct sockaddr_in {
sa_family_t sin_family; // 地址族,必须为 AF_INET
in_port_t sin_port; // 16位端口号,必须使用网络字节序 (htons)
struct in_addr sin_addr; // 32位IPv4地址
char sin_zero[8]; // 未使用,填充为0
};
struct in_addr {
in_addr_t s_addr; // 32位IPv4地址 (网络字节序)
};
|
配置并绑定地址
1
2
3
4
5
6
7
8
9
10
11
12
13
|
struct sockaddr_in saddr;
bzero(&saddr, sizeof(saddr)); // 清空结构体
saddr.sin_family = AF_INET; // IPv4
saddr.sin_port = htons(SER_PORT); // 端口转为网络字节序
saddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定到本机所有IP
if (bind(sfd, (struct sockaddr*)&saddr, sizeof(saddr)) == -1) {
perror("bind failed");
close(sfd);
exit(1);
}
printf("服务器已启动,正在监听端口 %d...\n", SER_PORT);
|
3.循环接收与处理数据址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
struct sockaddr_in caddr;// 用于存储客户端地址
socklen_t clen = sizeof(caddr);
char buf[MAXLINE];
while (1)
{
ssize_t n = recvfrom(sfd, buf, MAXLINE, 0,(struct sockaddr*)&caddr, &len);
if (n > 0)
{
buf[n] = '\0';
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &caddr.sin_addr, client_ip, INET_ADDRSTRLEN);
printf("来自[%s:%d]的消息:%s\n", client_ip, ntohs(caddr.sin_port), buf);
// ... (后续可在此添加 sendto 回复客户端的逻辑)
}
}
|
3.3客户端实现步骤
客户端流程与服务器类似,但不需要 bind 固定端口。
1.创建udp套接字
同服务端
2.设置目标服务器地址
1
2
3
4
5
6
7
8
9
10
|
struct sockaddr_in saddr;
bzero(&addr, sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(SER_PORT);// 服务器端口
// 将字符串形式的IP(如“127.0.0.1”)转换为网络字节序的二进制形式,argv[1]是main()传进来的参数
if (inet_pton(AF_INET, argv[1], &saddr.sin_addr) <= 0)
{
perror("invalid address or address not supported")
exit(1);
}
|
3.发送与接收循环
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
char send_buf[MAXLINE], recv_buf[MAXLINE];
socklen_t addr_len = sizeof(saddr);
while (1)
{
printf("请输入要发送的消息: ");
fgets(send_buf, MAXLINE, stdin);
// 使用 sendto 将数据发送到步骤二中设定的 saddr
if (sendto(cfd, send_buf, strlen(send_buf), 0, (struct sockaddr*)&saddr, addr_len) == -1)
{
perror("sendto failed");
continue;
}
// 使用 recvfrom 接收回复。注意,这里最后一个参数可以是NULL,因为我们已经知道服务器地址。
ssize_t n = recvfrom(cfd, recv_buf, MAXLINE, 0, NULL, NULL);
if (n > 0)
{
recv_buf[n] = '\0';
printf("服务器回复: %s\n", recv_buf);
}
}
|
四、完整代码实现
以下是将前面步骤整合后的、可直接编译运行的完整程序代码。
1.服务端:
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
50
51
|
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<string.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<ctype.h>
#define SER_PORT 2500
#define MAXLINE 80
int main()
{
struct sockaddr_in saddr,caddr;
socklen_t clen;
int sfd;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
char rbuff[50] = {0},sbuff[50] = {0};
int n;
sfd = socket(AF_INET,SOCK_DGRAM,0);
bzero(&saddr,sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(SER_PORT);
saddr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(sfd,(struct sockaddr*)&saddr,sizeof(saddr));
printf("accept connections...\n");
//处理客户端信息
while(1)
{
clen = sizeof(caddr);
n = recvfrom(sfd,buf,MAXLINE,0,(struct sockaddr*)&caddr,&clen);
if(n > 0)
{
buf[n] = '\0';
printf("client say:%s\n",buf);
}
printf("say sth to client:\n");
bzero(sbuff,sizeof(sbuff));
fgets(sbuff,sizeof(sbuff),stdin);
n = sendto(sfd,sbuff,strlen(sbuff),0,(struct sockaddr*)&caddr,sizeof(caddr));
if(n == -1)
perror("sendto error");
bzero(buf,sizeof(buf));
}
close(sfd);
return 0;
}
|
2.客户端:
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
50
51
|
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<string.h>
#include<arpa/inet.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
#include<net/if.h>
#define SER_PORT 2500
#define MAXLINE 80
int main(int argc, char *argv[])
{
if(argc != 2)
{
printf("./client IP");
exit(1);
}
struct sockaddr_in saddr;
int cfd,n;
char sbuff[256]={0},rbuff[256]={0};
cfd = socket(AF_INET,SOCK_DGRAM,0);
bzero(&saddr,sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(SER_PORT);
inet_pton(AF_INET,argv[1], &saddr.sin_addr.s_addr);
socklen_t len = sizeof(saddr);
while(1)
{
printf("say sth to server:\n");
fgets(sbuff,MAXLINE,stdin);
n = sendto(cfd,sbuff,strlen(sbuff),0,(struct sockaddr*)&saddr,sizeof(saddr));
if(n == -1)
{
perror("sendto error");
exit(1);
}
n = recvfrom(cfd,rbuff,MAXLINE,0,(struct sockaddr*)&saddr,&len);
if(n > 0)
{
printf("server say:%s\n",rbuff);
bzero(rbuff,sizeof(rbuff));// 清空接收缓冲区,为下次准备
}
}
return 0;
}
|
3.编译运行:
1
2
3
4
5
6
7
8
9
|
# 编译服务器
gcc -o udp_server server.c
# 编译客户端
gcc -o udp_client client.c
# 运行(需要两个终端窗口)
./udp_server
# 运行客户端,并指定服务器IP(本地测试用127.0.0.1)
./udp_client 127.0.0.1
|
五.遇到的问题
问题:缓冲区污染
现象:客户端有时会显示上一次的旧消息。
根因:在最初的代码中,发送和接收复用了同一个字符数组 sbuff/rbuff,recvfrom 可能没有覆盖全部旧数据。
解决:严格区分 send_buf (专用于发送) 和 recv_buf (专用于接收),并在每次接收后使用 bzero 或 memset 清空接收缓冲区。
六.学习总结
✅ 无连接:每次通信都需指定目标地址,没有“连接”状态。
✅ 服务端必备 bind():固定端口,等待数据。
✅ 客户端指定地址:通过 sendto 的目标地址参数确定服务器。
✅ 缓冲区分离:发送与接收缓冲区独立,避免数据混乱。
✅ 循环处理:双方通常使用 while(1) 循环持续通信。
下一步
掌握了基础通信后,可以探索: 02-UDP广播编程,学习如何向局域网内所有主机发送消息。