UNIX下C语言编程与实践62-UNIX UDP 编程:socket、bind、sendto、recvfrom 函数的使用
在 UNIX 网络编程中,UDP(用户数据报协议)以其“无连接、轻量级”的特性,成为实时性要求高(如流媒体、游戏)场景的首选。 udps1.c
(UDP 服务器端)与 udpk1.c
(UDP 客户端)的核心实例,本文将详细讲解 UDP 编程的四个核心函数——socket
(创建数据报套接字)、bind
(绑定地址端口)、sendto
(发送数据报)、recvfrom
(接收数据报),通过完整代码演示 UDP 通信流程,深入解析 UDP 与 TCP 编程的差异,并梳理常见错误与拓展场景。
一、UDP 编程核心函数解析
UDP 编程与 TCP 的核心差异在于“无连接”特性——无需 listen
、accept
、connect
流程,直接通过 sendto
与 recvfrom
完成数据收发。以下从函数原型、参数含义、实例关联三个维度展开解析。
1.1 socket 函数:创建 UDP 数据报套接字
UDP 套接字的创建是 UDP 编程的第一步,与 TCP 不同,其类型需指定为 SOCK_DGRAM
(数据报套接字),以匹配 UDP 协议的无连接特性。
(1)函数原型与参数
函数定义在 <sys/socket.h>
头文件中,原型与 TCP 共享,但参数取值不同:
#include <sys/types.h>
#include <sys/socket.h>int socket(int domain, int type, int protocol);
UDP 场景下三个参数的核心取值与实例对应关系如下表所示:
参数名 | 数据类型 | UDP 场景取值 | 实例中的使用说明 |
---|---|---|---|
domain | int | AF_INET | UDP 编程唯一使用的协议族,对应 IPv4 网络,支持 UDP 数据报传输(与 TCP 一致) |
type | int | SOCK_DGRAM | 数据报套接字,对应 UDP 协议,无连接、不可靠、面向数据报( udps1.c 与 udpk1.c 均使用此类型,区别于 TCP 的 SOCK_STREAM ) |
protocol | int | 0 | 由内核根据 domain (AF_INET )与 type (SOCK_DGRAM )自动选择 UDP 协议(无手动指定场景) |
(2)返回值与关键说明
执行成功时返回非负的套接字描述符(用于后续 bind
、sendto
、recvfrom
操作);失败时返回 -1
,并通过 errno
标识错误(如 EAFNOSUPPORT
表示协议族不支持)。
实例印证: udps1.c
中通过 socket(AF_INET, SOCK_DGRAM, 0)
创建服务器端套接字,udpk1.c
客户端创建套接字的方式完全相同,体现了 UDP 客户端与服务器端套接字类型的一致性。
1.2 bind 函数:UDP 服务器端的地址绑定
UDP 服务器端必须调用 bind
函数将套接字绑定到固定的本地端口——客户端需要通过该端口定位服务器,而 UDP 客户端通常无需绑定(系统自动分配临时端口)。
(1)函数原型与参数
函数原型与 TCP 完全一致,但 UDP 场景下的使用场景更单一(仅服务器端必要),参数含义如下(与文中 CreateUdpSock
函数逻辑一致):
#include <sys/types.h>
#include <sys/socket.h>int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen);
三个参数的 UDP 场景解读与实例关联如下表所示:
参数名 | 数据类型 | UDP 服务器端使用逻辑 | 实例代码片段 |
---|---|---|---|
sockfd | int | socket 函数返回的 UDP 套接字描述符(服务器端专用) | CreateUdpSock 函数中 *pnSock (创建的 UDP 套接字) |
my_addr | struct sockaddr * | 指向 sockaddr_in 结构的指针,存储服务器端本地 IP 与端口(需强制转换为通用地址结构) | struct sockaddr_in addrin; memset(&addrin, 0, sizeof(addrin)); addrin.sin_family = AF_INET; addrin.sin_addr.s_addr = htonl(INADDR_ANY); addrin.sin_port = htons(nPort); (绑定所有本地 IP 与指定端口) |
addrlen | socklen_t | sockaddr_in 结构的字节大小,告诉内核地址结构的长度 | sizeof(addrin) ( CreateUdpSock 函数的取值) |
(2)UDP 与 TCP 绑定的差异
通过实例可总结两者差异: - TCP:bind
后需调用 listen
进入侦听状态; - UDP:bind
后直接进入数据接收状态(无需侦听,因无连接),客户端可随时发送数据报。
1.3 sendto 函数:发送 UDP 数据报
UDP 无连接特性决定了其发送数据时需明确指定目标地址——sendto
函数正是为此设计,它将数据报发送到指定的服务器端地址,无需提前建立连接(区别于 TCP 的 send
)。
(1)函数原型与参数
函数定义在 <sys/socket.h>
头文件中,原型如下(文中 SendMsgByUdp
函数的核心封装对象):
#include <sys/types.h>
#include <sys/socket.h>ssize_t sendto(int s, const void *msg, size_t len, int flags, const struct sockaddr *to, socklen_t tolen);
六个参数的 UDP 场景含义与实例对应关系如下表所示:
参数名 | 数据类型 | 核心作用 | 实例(SendMsgByUdp 函数) |
---|---|---|---|
s | int | UDP 套接字描述符(客户端/服务器端均可使用) | 客户端创建的临时套接字(nSock = socket(AF_INET, SOCK_DGRAM, 0) ) |
msg | const void * | 指向待发送数据缓冲区的指针(如字符串、二进制数据) | 客户端要发送的字符串(如 "第0次发送" ) |
len | size_t | 待发送数据的字节数(不含字符串结束符 '\0' ) | strlen(szBuf) (文中客户端发送字符串的长度) |
flags | int | 发送标志,通常设为 0(默认方式),特殊场景用 MSG_DONTWAIT (非阻塞) | 文中设为 0 (默认阻塞发送,数据交给内核即返回) |
to | const struct sockaddr * | 指向目标地址结构(sockaddr_in )的指针,存储服务器端 IP 与端口 | 初始化服务器端地址:addrin.sin_addr.s_addr = inet_addr(szAddr); addrin.sin_port = htons(nPort); |
tolen | socklen_t | 目标地址结构(sockaddr_in )的字节大小 | sizeof(struct sockaddr) (文中通用取值) |
(2)返回值与关键特性
执行成功时返回实际发送的字节数;失败时返回 -1
。文中特别强调 UDP sendto
的“成功”含义——仅表示数据已交给内核发送缓冲区,不保证对方能接收(UDP 无确认机制),这是与 TCP send
(成功表示数据已写入 TCP 发送缓冲区,TCP 会负责重传)的核心差异。
1.4 recvfrom 函数:接收 UDP 数据报
UDP 服务器端通过 recvfrom
函数接收客户端发送的数据报,同时获取发送方(客户端)的地址信息,以便后续回复(如回声服务器)。文中 RecvMsgByUdp
函数正是对该函数的封装。
(1)函数原型与参数
函数定义在 <sys/socket.h>
头文件中,原型如下(与 sendto
参数结构对称):
#include <sys/types.h>
#include <sys/socket.h>ssize_t recvfrom(int s, void *buf, size_t len, int flags, struct sockaddr *from, socklen_t *fromlen);
六个参数的 UDP 场景含义与实例对应关系如下表所示:
参数名 | 数据类型 | 核心作用 | 实例(RecvMsgByUdp 函数) |
---|---|---|---|
s | int | 已绑定端口的 UDP 套接字描述符(服务器端专用) | udps1.c 中 CreateUdpSock 返回的 nSock |
buf | void * | 指向接收数据缓冲区的指针,需预先分配内存 | char szBuf[256]; (文中服务器端接收缓冲区) |
len | size_t | 接收缓冲区的最大容量(字节数),避免数据溢出 | sizeof(szBuf) (文中服务器端缓冲区大小) |
flags | int | 接收标志,通常设为 0(默认阻塞),非阻塞场景用 MSG_DONTWAIT | 文中设为 0 (默认阻塞,直到接收数据报或出错) |
from | struct sockaddr * | 输出参数,指向 sockaddr_in 结构,存储发送方(客户端)的地址信息 | 文中简化处理设为 NULL (仅接收数据,不回复);若需回复,需初始化该结构 |
fromlen | socklen_t * | 输入输出参数,传入时为 from 结构的大小,返回时为实际地址结构的大小 | 文中简化处理设为 NULL ;若需回复,需设为 &from_len (from_len = sizeof(struct sockaddr_in) ) |
(2)返回值与关键特性
执行成功时返回实际接收的字节数;失败时返回 -1
;无数据且连接关闭(UDP 无连接,此情况极少)时返回 0
。文中强调其默认阻塞特性——若套接字无数据可接收,recvfrom
会一直阻塞,直到有数据报到达或被信号中断(如 SIGINT
)。
二、实战实例:UDP 服务器端与客户端完整流程
文中 udps1.c
、udpk1.c
与 udp.h
头文件的设计思想,编写完整的 UDP 服务器端与客户端程序,演示“客户端循环发送数据报→服务器端接收并打印”的完整流程,还原文中的编程风格与核心逻辑。
2.1 头文件封装(udp.h)
文中的封装思路,将 UDP 核心函数(创建套接字、发送数据、接收数据)声明在头文件中,提高代码复用性:
#ifndef _UDP_H_
#define _UDP_H_#include
#include
#include
#include
#include
#include
#include // 创建 UDP 服务器端套接字(绑定指定端口)
int CreateUdpSock(int *pnSock, int nPort);// 发送 UDP 数据报到指定服务器
int SendMsgByUdp(void *pMsg, int nSize, const char *szAddr, int nPort);// 从 UDP 套接字接收数据报
int RecvMsgByUdp(int nSock, void *pData, int *pnSize);#endif
2.2 UDP 服务器端程序(udps1.c)
服务器端流程:创建 UDP 套接字→绑定本地端口(9999)→循环调用 recvfrom
接收数据→打印数据内容,符合文中 udps1.c
的核心逻辑:
#include "udp.h"// 实现 CreateUdpSock:创建并绑定 UDP 服务器端套接字
int CreateUdpSock(int *pnSock, int nPort) {struct sockaddr_in addrin;struct sockaddr *paddr = (struct sockaddr *)&addrin;// 参数合法性检查assert(pnSock != NULL && nPort > 0 && nPort <= 65535);memset(&addrin, 0, sizeof(addrin));// 1. 创建 UDP 套接字*pnSock = socket(AF_INET, SOCK_DGRAM, 0);if (*pnSock <= 0) {perror("socket failed");return 1;}// 2. 初始化地址结构(绑定所有本地 IP + 指定端口)addrin.sin_family = AF_INET;addrin.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有本地 IPaddrin.sin_port = htons(nPort); // 端口转换为网络字节顺序// 3. 绑定套接字到本地地址if (bind(*pnSock, paddr, sizeof(addrin)) != 0) {perror("bind failed");close(*pnSock);return 1;}printf("UDP server bind port %d success\n", nPort);return 0;
}// 实现 RecvMsgByUdp:接收 UDP 数据报
int RecvMsgByUdp(int nSock, void *pData, int *pnSize) {assert(nSock > 0 && pData != NULL && pnSize != NULL && *pnSize > 0);// 接收数据报(简化处理,不获取发送方地址)ssize_t recv_len = recvfrom(nSock, pData, *pnSize, 0, NULL, NULL);if (recv_len < 0) {perror("recvfrom failed");return 1;} else if (recv_len == 0) {printf("no data received (connection closed)\n");return 1;}// 更新实际接收的字节数*pnSize = recv_len;return 0;
}// 服务器端主函数:循环接收客户端数据
int main() {int nSock;char szBuf[256];int nBufSize = sizeof(szBuf);// 1. 创建并绑定 UDP 套接字(端口 9999)if (CreateUdpSock(&nSock, 9999) != 0) {fprintf(stderr, "Create UDP server failed\n");return 1;}// 2. 循环接收数据printf("UDP server waiting for data...\n");while (1) {// 重置缓冲区memset(szBuf, 0, sizeof(szBuf));nBufSize = sizeof(szBuf);// 接收数据报if (RecvMsgByUdp(nSock, szBuf, &nBufSize) != 0) {continue;}// 打印接收的数据printf("Received UDP data: [len=%d] %s\n", nBufSize, szBuf);}// 理论上不会执行到此处(循环无退出条件)close(nSock);return 0;
}
2.3 UDP 客户端程序(udpk1.c)
客户端流程:创建 UDP 套接字→循环调用 sendto
发送数据(每隔 1 秒)→打印发送结果,符合文中 udpk1.c
的周期性发送逻辑:
#include "udp.h"
#include // 实现 SendMsgByUdp:发送 UDP 数据报到服务器
int SendMsgByUdp(void *pMsg, int nSize, const char *szAddr, int nPort) {int nSock;struct sockaddr_in addrin;// 参数合法性检查assert(pMsg != NULL && nSize > 0 && szAddr != NULL && nPort > 0 && nPort <= 65535);memset(&addrin, 0, sizeof(addrin));// 1. 创建 UDP 客户端套接字(无需绑定,系统自动分配临时端口)nSock = socket(AF_INET, SOCK_DGRAM, 0);if (nSock <= 0) {perror("socket failed");return 1;}// 2. 初始化服务器端地址结构addrin.sin_family = AF_INET;// IP 地址转换:字符串 → 网络字节顺序整数if (inet_aton(szAddr, &addrin.sin_addr) == 0) {fprintf(stderr, "invalid server IP: %s\n", szAddr);close(nSock);return 1;}// 端口转换为网络字节顺序addrin.sin_port = htons(nPort);// 3. 发送 UDP 数据报ssize_t send_len = sendto(nSock, pMsg, nSize, 0, (struct sockaddr *)&addrin, sizeof(addrin));if (send_len != nSize) {perror("sendto failed");close(nSock);return 1;}// 4. 关闭临时套接字(客户端每次发送可创建新套接字)close(nSock);return 0;
}// 客户端主函数:循环发送数据到服务器
int main() {int i = 0;char szBuf[100];const char *server_ip = "127.0.0.1"; // 服务器端 IP(本地回环)int server_port = 9999; // 服务器端端口// 循环发送数据(每隔 1 秒)while (1) {// 构造发送数据snprintf(szBuf, sizeof(szBuf), "第%d次发送", i);int data_len = strlen(szBuf);// 发送数据报if (SendMsgByUdp(szBuf, data_len, server_ip, server_port) == 0) {printf("Send success: %s\n", szBuf);} else {printf("Send failed: %s\n", szBuf);}// 休眠 1 秒sleep(1);i++;}return 0;
}
2.4 程序执行与结果
1. 编译程序:
# 编译服务器端
gcc udps1.c -o udps1
# 编译客户端
gcc udpk1.c -o udpk1
2. 启动服务器端:
./udps1
UDP server bind port 9999 success
UDP server waiting for data...
3. 启动客户端(新终端):
./udpk1
Send success: 第0次发送
Send success: 第1次发送
Send success: 第2次发送
Send success: 第3次发送
...
4. 服务器端输出( udps1.c
执行结果一致):
Received UDP data: [len=9] 第0次发送
Received UDP data: [len=9] 第1次发送
Received UDP data: [len=9] 第2次发送
Received UDP data: [len=9] 第3次发送
...
三、UDP 编程关键特性与差异解析
基于实例与 UDP 协议特性,梳理 UDP 编程与 TCP 的核心差异,以及 UDP 客户端/服务器端的特殊设计逻辑,帮助理解 UDP 的适用场景。
3.1 UDP 服务器端绑定端口的必要性
文中反复强调,UDP 服务器端必须调用 bind
绑定固定端口,原因如下: - 客户端定位服务器的需要:UDP 无连接,客户端发送数据报时必须知道服务器端的 IP 与端口(如文中客户端指定服务器端口 9999),若服务器端不绑定固定端口,客户端无法定位; - 避免端口动态变化:若服务器端不绑定端口,系统会自动分配临时端口,但服务器端重启后端口可能变化,导致客户端无法连接; - 端口复用与业务标识:固定端口便于业务识别(如 DNS 用 53 端口、DHCP 用 67/68 端口),文中选择 9999 作为示例端口,正是基于固定端口的设计思想。
3.2 UDP 客户端不绑定端口的原因
文中 udpk1.c
客户端未调用 bind
,而是由系统自动分配临时端口,原因如下: - 简化客户端逻辑:客户端无需管理端口,系统分配的临时端口(通常在 1024~65535 之间)可避免端口冲突; - 无固定端口需求:服务器端接收数据报后,若需回复,可通过 recvfrom
获取客户端的临时端口,无需客户端绑定固定端口; - 多客户端并发:多个客户端同时运行时,系统分配不同的临时端口,避免端口冲突(若客户端绑定固定端口,多实例运行会失败)。
实例印证:文中 udpk1.c
客户端每次发送数据都创建新套接字,系统自动分配临时端口,发送完成后关闭套接字,完全无需绑定操作,体现了客户端的简化设计。
3.3 UDP 与 TCP 编程核心差异
对比维度 | UDP 编程(实例) | TCP 编程(实例) |
---|---|---|
套接字类型 | SOCK_DGRAM (数据报套接字) | SOCK_STREAM (流式套接字) |
连接流程 | 无连接,无需 listen /accept /connect | 面向连接,需 socket→bind→listen→accept (服务器端)、socket→connect (客户端) |
数据收发函数 | sendto (需指定目标地址)、recvfrom (可获取发送方地址) | send 、recv (基于已连接套接字,无需指定地址) |
数据可靠性 | 不可靠,sendto 成功不保证对方接收(无确认、重传) | 可靠,TCP 协议保证数据有序、无丢失、无重复 |
数据边界 | 有边界,recvfrom 一次接收一个完整数据报 | 无边界,recv 可能分批次接收数据(需处理粘包) |
服务器端核心逻辑 | socket→bind→循环 recvfrom ( udps1.c ) | socket→bind→listen→循环 accept→处理连接 ( tcp1.c ) |
四、UDP 编程常见错误与解决方案
结合文中的错误处理思想(如 VerifyErr
宏)与 UDP 协议特性,梳理 socket
、bind
、sendto
、recvfrom
函数使用过程中常见的错误场景,分析原因并给出解决方案。
- 错误 1:sendto 发送失败,errno = EINVAL
原因: - 目标地址结构(
sockaddr_in
)初始化错误(如未设置sin_family = AF_INET
,或端口号未转换为网络字节顺序); -tolen
参数取值错误(如设为sizeof(int)
,而非地址结构大小);解决方案: 1. 确保地址结构初始化完整:
struct sockaddr_in addrin; memset(&addrin, 0, sizeof(addrin)); addrin.sin_family = AF_INET; // 必须设置为 AF_INET addrin.sin_addr.s_addr = inet_addr("127.0.0.1"); addrin.sin_port = htons(9999); // 端口必须转换为网络字节顺序
tolen
参数设为sizeof(struct sockaddr_in)
或sizeof(struct sockaddr)
,确保与地址结构匹配。 - 错误 2:recvfrom 接收数据截断,实际长度小于发送长度
原因:接收缓冲区(
buf
)大小(len
参数)小于 UDP 数据报的实际长度,导致超出部分被内核截断(UDP 无数据分片重组机制,超出缓冲区的数据直接丢弃);解决方案: 1. 接收缓冲区大小设为 UDP 最大数据报长度(通常为 65507 字节,因 IP 数据报最大为 65535 字节,减去 IP 头 20 字节与 UDP 头 8 字节); 2. 接收前通过
setsockopt
设置 UDP 接收缓冲区大小,避免内核缓冲区溢出:int recv_buf_size = 65507; setsockopt(nSock, SOL_SOCKET, SO_RCVBUF, &recv_buf_size, sizeof(recv_buf_size));
udps1.c
使用 256 字节缓冲区,适用于小数据场景;若传输大数据,需增大缓冲区。 - 错误 3:UDP 数据报丢失,服务器端未接收但客户端发送成功
原因: - UDP 协议本身不可靠,网络丢包、延迟会导致数据报丢失; - 服务器端未绑定正确端口或 IP,客户端发送到错误地址; - 服务器端
recvfrom
调用不及时,内核接收缓冲区满,新数据报被丢弃;解决方案: 1. 应用层实现确认机制:服务器端接收数据后,通过
sendto
回复确认信息,客户端未收到确认则重发(文中未涉及,但工业级 UDP 应用必备); 2. 验证客户端发送地址:确保服务器端 IP 与端口正确(如文中客户端指定127.0.0.1:9999
); 3. 增大内核 UDP 接收缓冲区:通过setsockopt
设置SO_RCVBUF
,减少缓冲区满导致的丢包。 - 错误 4:UDP 服务器端 bind 失败,errno = EADDRINUSE
原因:服务器端要绑定的端口已被其他进程占用(如文中重复启动
udps1.c
,9999 端口已被占用);解决方案: 1. 通过
netstat -uln | grep <port>
查看端口占用情况(如netstat -uln | grep 9999
),停止占用进程; 2. 设置SO_REUSEADDR
选项,允许端口复用(即使端口处于TIME_WAIT
状态):int opt = 1; setsockopt(nSock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
五、拓展:UDP 广播与多播编程
文中未涉及 UDP 广播与多播,但基于 UDP 的无连接特性,这两种通信方式在局域网场景(如设备发现、实时通知)中广泛应用。以下简要介绍其实现原理与编程方法,拓展 UDP 编程的应用范围。
5.1 UDP 广播
原理:UDP 广播是指客户端向局域网内的所有主机发送数据报(目标地址为广播地址,如 192.168.1.255
),局域网内所有绑定对应端口的 UDP 服务器端均可接收。
编程关键步骤: 1. 创建 UDP 套接字(与普通 UDP 一致); 2. 设置套接字允许广播(默认不允许):
int opt = 1;
setsockopt(nSock, SOL_SOCKET, SO_BROADCAST, &opt, sizeof(opt));
3. 发送数据报到广播地址(如 192.168.1.255:9999
):
struct sockaddr_in broadcast_addr;
memset(&broadcast_addr, 0, sizeof(broadcast_addr));
broadcast_addr.sin_family = AF_INET;
broadcast_addr.sin_addr.s_addr = inet_addr("192.168.1.255"); // 广播地址
broadcast_addr.sin_port = htons(9999);
sendto(nSock, "broadcast data", strlen("broadcast data"), 0, (struct sockaddr *)&broadcast_addr, sizeof(broadcast_addr));
4. 服务器端绑定对应端口(9999),即可接收广播数据报。
5.2 UDP 多播
原理:UDP 多播(组播)是指客户端向特定的多播组地址(如 224.0.0.0~239.255.255.255
)发送数据报,只有加入该多播组的 UDP 服务器端才能接收,避免广播的“泛洪”问题。
编程关键步骤: 1. 服务器端加入多播组:
struct ip_mreq mreq;
mreq.imr_multiaddr.s_addr = inet_addr("224.0.0.1"); // 多播组地址
mreq.imr_interface.s_addr = htonl(INADDR_ANY); // 绑定的本地网卡
// 加入多播组
setsockopt(nSock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));
2. 客户端发送数据报到多播组地址(224.0.0.1:9999
); 3. 服务器端绑定对应端口(9999),即可接收多播数据报; 4. 服务器端退出时离开多播组:
setsockopt(nSock, IPPROTO_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq));
六、总结
基于《精通UNIX下C语言编程与项目实践笔记.pdf》中 UDP 编程的核心实例,本文系统梳理了 UDP 编程的四个核心函数与关键特性,可总结为以下关键点:
- 函数核心作用:
socket
创建SOCK_DGRAM
类型套接字,bind
为服务器端绑定固定端口,sendto
向指定地址发送数据报,recvfrom
接收数据报并可选获取发送方地址,四者共同构成 UDP 通信的基础; - 客户端/服务器端差异:服务器端必须
bind
固定端口(客户端定位需要),客户端无需绑定(系统分配临时端口),体现了 UDP 简化客户端设计的思想; - 协议特性影响:UDP 无连接、不可靠的特性导致
sendto
成功不保证接收,recvfrom
需处理数据截断,实际应用需在应用层补充确认、重传机制; - 拓展场景:UDP 广播与多播基于普通 UDP 编程扩展,适用于局域网内多设备通信,是 UDP 协议在实时场景中的重要应用。
掌握 UDP 编程的核心函数与特性,是理解 UNIX 网络编程中“无连接”通信模型的关键。在实际开发中,需结合文中的简化设计思想(如客户端无需绑定),针对 UDP 的不可靠性补充应用层保障机制,并根据场景选择普通 UDP、广播或多播,才能构建出高效、稳定的 UDP 应用程序。