Linux网络:UDP 网络编程

在网络编程领域,TCP 与 UDP 是传输层最核心、最常用的两种协议。

TCP 主打数据传输可靠性,通过连接建立、确认应答、超时重传等机制,严格保障数据有序、完整地传输。

而 UDP 协议更为轻量简洁。它无需在通信前建立连接,不校验数据是否成功送达,也不会主动处理网络传输中的丢包、乱序问题。

因此,UDP 编程的代码逻辑远比 TCP 简单,但其中仍存在多个核心易错点,值得深入梳理:

  • 为什么 UDP 服务端大多需要调用 bind(),而客户端可省略该操作?

  • sockaddrsockaddr_in 两种地址结构体的核心关联与区别是什么?

  • socket()open() 生成的文件描述符,底层差异体现在哪里?

  • 为什么 recvfrom() 既能接收数据,又能同步获取发送方的地址信息?

  • sendto() 调用返回成功,是否代表数据已被对方成功接收?

本文从 UDP 核心特性入手,结合服务端、客户端完整通信流程,聚焦讲解 socketbindrecvfromsendto 四大核心编程函数,彻底厘清 UDP 编程核心逻辑。

一、UDP 是什么

UDP 全称为 User Datagram Protocol(用户数据报协议),隶属于 TCP/IP 协议栈传输层。

其最核心的特性是:通信双方传输数据前,无需建立专属连接

客户端如需向服务端传输数据,只需直接指定目标地址信息:

复制代码
目标 IP + 目标端口

即可直接发送完整数据报。UDP 无需像 TCP 一样执行三次握手流程,也不会维护复杂的连接状态信息,极简的设计使其具备低开销、高实时性的优势。

1. 无连接

UDP 通信全程无需提前建立、维持连接,通信流程极简。

客户端可直接按照下述流程发起数据传输:

复制代码
创建 socket
    ↓
指定服务器 IP 和端口
    ↓
发送数据

服务端也无需提前感知客户端信息,只要完成本机 IP 与端口的绑定,所有可访问该地址的 UDP 客户端,均可直接向其发送数据报。

2. 不保证可靠传输

UDP 协议本身不提供任何可靠性保障机制,无法确保:

  • 数据必然送达目标主机

  • 数据严格按照发送顺序抵达

  • 数据不会出现重复接收的情况

若 UDP 数据报在网络传输中丢失,协议本身不会触发重传机制,不会主动补救数据丢失问题。

需要明确的是,UDP 并非完全无校验能力,其协议头部自带校验和机制,可检测数据传输过程中是否出现篡改、错误。

但 UDP 不具备 TCP 的核心可靠机制,不支持:

复制代码
确认应答
数据重传
乱序排序
流量控制
拥塞控制

这也是 UDP 被定义为不可靠传输协议的核心原因。

3. 面向数据报

UDP 是典型的面向数据报的传输协议,严格保留数据传输的边界,不会出现数据粘连、拆分混乱的问题。

若发送方执行如下发送操作:

cpp 复制代码
sendto(..., "hello", 5, ...);

接收方会完整接收这一个独立的 UDP 数据报。

例如发送方分两次发送数据:

cpp 复制代码
第一次发送:hello
第二次发送:world

接收方从协议层面会识别为两个相互独立的数据报:

cpp 复制代码
hello world

因此 UDP 彻底规避了 TCP 经典的粘包问题。

但需注意:若接收缓冲区容量不足,无法容纳完整的 UDP 数据报,超出缓冲区的数据会被直接截断、丢弃,不会留存至下一次 recvfrom() 调用读取。

4. 开销较小

相较于 TCP,UDP 无需维护连接状态,也无需实现确认、重传、排序、拥塞控制等复杂机制,协议头部开销极小,传输效率更高。

基于该特性,UDP 广泛应用于实时性优先、可容忍少量数据丢失的场景,典型场景包括:

  • DNS 域名解析

  • 网络游戏实时通信

  • 音视频实时通话

  • 直播实时推流

  • 网络广播、组播通信

这类场景的核心诉求为:优先获取最新数据,少量历史数据丢失不影响业务体验。例如实时语音通话中,单帧语音数据丢失后,重传数据抵达时已失去实时价值,无需补救。

二、UDP 编程的整体流程

讲解核心函数前,先梳理 UDP 服务端与客户端的完整编程流程,建立整体认知。

UDP 服务端

cpp 复制代码
socket()
   ↓
bind()
   ↓
recvfrom()
   ↓
sendto()
   ↓
循环处理通信

服务端需绑定固定的 IP 与端口(如 0.0.0.0:8080),为客户端提供稳定的访问地址,保证客户端可精准发起通信。

UDP 客户端

cpp 复制代码
socket()
   ↓
sendto()
   ↓
recvfrom()

客户端一般无需手动调用 bind()。当首次执行数据发送操作时,若当前 socket 未绑定本地地址,操作系统会自动分配可用的本地 IP 与临时端口,完成地址绑定。

示例通信地址:

cpp 复制代码
客户端:192.168.1.10:52341
服务器:192.168.1.20:8080

客户端向服务端发起数据传输:

cpp 复制代码
192.168.1.10:52341
        ↓
192.168.1.20:8080

其中 52341 即为操作系统自动分配的临时端口。

核心结论:bind()bind() UDP 客户端并非不支持 ,而是绝大多数业务场景无需手动绑定固定端口;若业务需要固定本地端口,客户端同样可主动调用 。

三、socket():创建 UDP 套接字

1. 函数原型

cpp 复制代码
int socket(int domain, int type, int protocol);

2. 参数说明

domain

指定通信协议族,常用参数:

cpp 复制代码
AF_INET

代表 IPv4 协议;如需使用 IPv6 通信,可配置为:

cpp 复制代码
AF_INET6

type

指定套接字类型,UDP 专属类型为:

cpp 复制代码
SOCK_DGRAM

TCP 对应的流式套接字类型为:

cpp 复制代码
SOCK_STREAM

因此 UDP 套接字的标准创建代码为:

cpp 复制代码
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

protocol

指定具体传输协议,日常开发直接填 0 即可,系统会根据协议族与套接字类型自动匹配对应协议。

例如 AF_INET + SOCK_DGRAM 的组合,系统会自动绑定 UDP 协议。

3. 返回值

执行成功:返回非负整数,即套接字文件描述符;

执行失败:返回 -1,同时自动设置全局变量 errno 记录错误信息。

因此创建 socket 时必须做错误校验,标准代码如下:

cpp 复制代码
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

if (sockfd < 0)
{
    perror("socket");
    return 1;
}

四、socket 返回的 fd 是什么

socket() 的返回值本质是一个文件描述符,与 open() 函数返回的文件描述符形态一致。

在 Linux 系统中,文件描述符是进程统一访问内核资源的接口,二者核心差异不在于描述符本身,而在于关联的底层内核对象不同

普通文件的访问逻辑:

cpp 复制代码
open()
    ↓
绑定普通文件内核对象
    ↓
生成文件描述符 fd

Socket 套接字的创建逻辑:

cpp 复制代码
socket()
    ↓
绑定内核 socket 通信对象
    ↓
生成文件描述符 fd

基于统一的文件描述符设计,普通文件的 read()write() 读写接口,同样可作用于 socket。

而 socket 专属的网络操作接口,包括 bind()sendto()recvfrom()listen()accept()connect(),会根据套接字类型、协议类型执行差异化逻辑。

套接字使用完毕后,需通过 close(sockfd) 释放内核资源,避免内存泄漏。

五、bind():给 socket 绑定地址

1. 函数原型

cpp 复制代码
int bind( int sockfd, const struct sockaddr *addr, socklen_t addrlen );

函数核心作用:将创建的 socket 与本机指定 IP 地址、端口进行强制绑定

绑定完成后,所有发送至该 IP+端口的数据,都会被内核分发至当前 socket 处理。

2. 参数说明

sockfd

socket() 函数创建的 UDP 套接字文件描述符,为绑定操作的目标对象。

addr

待绑定的地址结构体指针。系统函数统一使用通用地址类型 struct sockaddr *,但 IPv4 编程中,实际存储地址信息的是 struct sockaddr_in 结构体,调用时需强制类型转换。

标准转换写法:

cpp 复制代码
(struct sockaddr *)&server_addr

addrlen

地址结构体的字节大小,直接通过 sizeof 计算即可,示例:

cpp 复制代码
sizeof(server_addr)

3. 返回值

执行成功:返回 0;

执行失败:返回 -1,并设置 errno 记录错误。

标准错误校验代码:

cpp 复制代码
if (bind(sockfd,(struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
    perror("bind");
    return 1;
}

bind() 常见失败原因:端口被占用、IP 地址配置非法、程序权限不足等。

六、sockaddr 和 sockaddr_in 是什么关系

网络编程中两种地址结构体频繁出现,二者分工明确、搭配使用,是网络编程的基础核心知识点。

1. sockaddr:通用地址类型

sockaddr 是系统定义的通用地址结构体,核心作用是兼容多协议通信,适配 IPv4、IPv6、Unix 域套接字等多种通信协议的地址参数传递。

因此 bind()sendto()recvfrom() 等系统调用,统一使用 sockaddr * 作为地址参数类型,实现接口通用性。

2. sockaddr_in:IPv4 专用地址结构

sockaddr_in 是 IPv4 协议专属的地址结构体,专门用于存储 IPv4 地址、端口、地址族等具体信息,是编程中实际操作的结构体。

其核心成员包括:

  • sin_family:地址族(固定为 IPv4)

  • sin_port:通信端口

  • sin_addr:IPv4 地址

实际编程逻辑为:用 sockaddr_in 赋值存储具体地址信息,调用系统函数时强制转换为 sockaddr 类型,适配通用接口参数。

核心总结:sockaddr 是系统接口的通用地址模板,sockaddr_in 是 IPv4 场景的具体地址实现,二者并非面向对象的继承关系,仅是编程层面的类型适配关系。

七、如何初始化 sockaddr_in

IPv4 地址结构体的标准初始化流程完整、规范,可直接复用:

cpp 复制代码
struct sockaddr_in server_addr;

memset(&server_addr, 0, sizeof(server_addr));

server_addr.sin_family = AF_INET;

server_addr.sin_port = htons(8080);

server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

1. sin_family

指定通信协议族,IPv4 场景固定配置:

cpp 复制代码
server_addr.sin_family = AF_INET;

2. sin_port

设置通信端口,必须转换为网络字节序 ,通过 htons() 函数完成主机字节序到网络字节序的转换:

cpp 复制代码
server_addr.sin_port = htons(8080);

3. sin_addr

设置本机监听 IP 地址,常用配置:

cpp 复制代码
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

INADDR_ANY 等价于 0.0.0.0,代表监听本机所有可用网络接口。若本机存在多个网卡、多个 IP 地址,绑定该地址后,可接收所有本机 IP 对应的端口数据。

八、为什么服务端通常必须 bind

服务端的核心定位是提供稳定、可被客户端主动访问的服务,必须拥有固定的通信地址。

客户端需要提前知晓服务端的 IP+端口,才能精准发起数据传输。若服务端不主动调用bind() 绑定固定端口,操作系统会自动分配随机临时端口,客户端无法提前获取通信地址,服务也就无法对外提供服务。

业界标准服务均绑定固定端口,保证服务可稳定访问,例如:

  • DNS 服务:固定 53 端口

  • DHCP 服务:固定 67/68 端口

因此,UDP 服务端必须手动 bind 固定 IP 与端口,保证服务地址永久固定

九、为什么客户端通常不需要 bind

客户端的核心定位是主动发起通信的一方,仅需要知晓服务端的固定地址,自身无需被外部主动访问。

当客户端未手动绑定本地地址时,首次调用 sendto() 发送数据时,操作系统会自动筛选可用的本地 IP 与未被占用的临时端口,完成隐式绑定,无需开发者手动干预。

因此绝大多数客户端业务场景,只需创建 socket、直接发送数据即可,无需调用 bind()

仅当业务需要客户端固定本地端口(如特定权限校验、端口白名单限制)时,才需要手动调用 bind()绑定固定地址。

十、recvfrom():接收 UDP 数据

1. 函数原型

cpp 复制代码
ssize_t recvfrom(
    int sockfd,
    void *buf,
    size_t len,
    int flags,
    struct sockaddr *src_addr,
    socklen_t *addrlen
);

UDP 无连接的特性,导致服务端接收数据时,无法默认获取发送方信息。而 recvfrom() 的核心优势就是:在接收数据的同时,同步获取发送方的 IP 与端口信息,为后续回复数据提供目标地址。

2. 参数说明

sockfd

已创建、已绑定的 UDP 套接字文件描述符。

buf

数据接收缓冲区,用于存储读取到的 UDP 数据报,通常定义为字符数组,例如 char buffer[1024];

len

接收缓冲区的最大容量,一般直接传入 sizeof(buffer)

flags

数据接收标志位,默认传 0,使用系统默认接收逻辑。

src_addr

输出参数,用于存储发送方的地址信息,类型为通用 sockaddr *,实际使用 sockaddr_in 结构体接收客户端地址。

addrlen

输入输出参数。调用函数前,需赋值为地址结构体的大小,告知内核缓冲区容量;函数执行完成后,内核会更新该值,写入实际有效的地址长度。

3. 返回值

执行成功:返回实际读取到的数据字节数;

执行失败:返回 -1,并设置 errno

特殊注意:UDP 场景下返回 0,代表接收到一个空数据报,不代表对端连接关闭,不可直接判定为通信结束。

4. 示例

cpp 复制代码
char buffer[1024];

struct sockaddr_in client_addr;

socklen_t client_len = sizeof(client_addr);

ssize_t n = recvfrom(
    sockfd,
    buffer,
    sizeof(buffer),
    0,
    (struct sockaddr *)&client_addr,
    &client_len
);

函数执行后,buffer 存储接收的业务数据,client_addr 存储发送方的 IP、端口等地址信息。

十一、如何获取发送方 IP 和端口

recvfrom() 获取的地址信息为网络字节序,需转换为本地字节序、字符串格式后,才能正常打印、使用。

端口转换:通过 ntohs() 将网络字节序端口转为本地字节序;

IP 转换:通过 inet_ntop() 将网络字节序 IP 转为人类可读的字符串格式。

完整示例代码:

cpp 复制代码
char buffer[1024] = {0};

struct sockaddr_in client_addr;

socklen_t client_len = sizeof(client_addr);

ssize_t n = recvfrom(
    sockfd,
    buffer,
    sizeof(buffer),
    0,
    (struct sockaddr *)&client_addr,
    &client_len
);

if (n > 0)
{
    // 转换端口为本地字节序
    uint16_t port = ntohs(client_addr.sin_port);
    // 转换IP为字符串格式
    char ip[INET_ADDRSTRLEN];
    inet_ntop(
        AF_INET,
        &client_addr.sin_addr,
        ip,
        sizeof(ip)
    );

    printf("客户端:%s:%d\n", ip, port);
}

十二、recvfrom() 的阻塞行为

系统默认状态下,UDP socket 为阻塞模式

当调用 recvfrom() 时,若内核接收缓冲区暂无数据,当前线程会进入阻塞休眠状态,持续等待数据抵达。

阻塞执行流程:

cpp 复制代码
调用 recvfrom()
        ↓
检测 socket 接收缓冲区
        ↓
无数据,线程阻塞等待
        ↓
网络数据抵达,写入缓冲区
        ↓
内核唤醒阻塞线程
        ↓
recvfrom() 读取数据并返回

若将 socket 设置为非阻塞模式,无数据时 recvfrom() 不会阻塞,会立即返回 -1,并将 errno 设置为 EAGAINEWOULDBLOCK

十三、sendto():发送 UDP 数据

1. 函数原型

cpp 复制代码
ssize_t sendto(
    int sockfd,
    const void *buf,
    size_t len,
    int flags,
    const struct sockaddr *dest_addr,
    socklen_t addrlen
);

基于 UDP 无连接特性,数据发送时无需提前建立连接,直接指定目标 IP+端口,即可完成数据传输。

2. 参数说明

sockfd

已创建的 UDP 套接字文件描述符。

buf

待发送的数据缓冲区指针,存储需要传输的业务数据。

len

待发送数据的有效长度,字符串数据常用 strlen() 计算。

flags

发送标志位,默认传 0,使用系统默认发送逻辑。

dest_addr

目标服务端/客户端的地址结构体指针,存储数据接收方的 IP 与端口。

addrlen

目标地址结构体的字节大小,通过sizeof 计算。

3. 示例

cpp 复制代码
const char *msg = "hello";

sendto(
    sockfd,
    msg,
    strlen(msg),
    0,
    (struct sockaddr *)&server_addr,
    sizeof(server_addr)
);

客户端通过该代码,可直接向指定服务端地址(如 192.168.1.20:8080)发送数据报。

十四、sendto() 返回成功代表什么

这是 UDP 编程的核心易错点,必须重点区分。

sendto() 返回值大于 0 时,仅代表:待发送数据已成功写入本机内核网络协议栈,系统已接管数据,进入后续发送流程

该返回结果绝不代表数据已被对方主机成功接收

数据写入本机协议栈后,仍可能因网络波动、路由异常、目标主机离线等问题,在传输过程中丢失,而 UDP 协议无任何重传、确认机制,不会感知、补救数据丢失。

因此,若业务需要数据可靠传输,必须在应用层自主实现可靠性机制,包括:

  • 自定义应答确认报文

  • 超时重传逻辑

  • 数据序号标记

  • 重复数据去重

十五、一个 UDP 服务端的基本结构

cpp 复制代码
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

if (sockfd < 0)
{
    perror("socket");
    return 1;
}

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(
        sockfd,
        (struct sockaddr *)&server_addr,
        sizeof(server_addr)
    ) < 0)
{
    perror("bind");
    close(sockfd);
    return 1;
}

// 循环处理客户端通信请求
while (true)
{
    char buffer[1024];
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);

    // 接收客户端数据,获取客户端地址
    ssize_t n = recvfrom(
        sockfd,
        buffer,
        sizeof(buffer),
        0,
        (struct sockaddr *)&client_addr,
        &client_len
    );

    if (n < 0)
    {
        perror("recvfrom");
        continue;
    }

    // 原样回复数据给客户端
    sendto(
        sockfd,
        buffer,
        n,
        0,
        (struct sockaddr *)&client_addr,
        client_len
    );
}

close(sockfd);

服务端核心流程总结:创建套接字 → 绑定固定地址端口 → 循环接收客户端数据 → 根据获取的客户端地址回复数据 → 持续监听通信。

十六、一个 UDP 客户端的基本结构

客户端无需手动绑定地址,直接创建套接字、配置服务端地址,即可发送数据:

cpp 复制代码
// 创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

// 配置服务端地址信息
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);

// 转换服务端IP地址
inet_pton(
    AF_INET,
    "192.168.1.20",
    &server_addr.sin_addr
);

// 向服务端发送数据
const char *msg = "hello";
sendto(
    sockfd,
    msg,
    strlen(msg),
    0,
    (struct sockaddr *)&server_addr,
    sizeof(server_addr)
);

客户端首次执行 sendto()时,操作系统自动分配本地临时端口与 IP,完成隐式绑定,无需开发者手动处理。

十七、UDP 服务端与客户端的核心区别

UDP 服务端与客户端的核心差异不在于套接字本身,而在于通信角色、地址配置方式不同,完整对比见下表:

对比维度 UDP 服务端 UDP 客户端
是否需要 socket 需要 需要
是否需要 bind 必须手动绑定 通常无需绑定,按需可手动绑定
是否支持 recvfrom 支持(核心用于接收客户端数据) 支持(可接收服务端回复数据)
是否支持 sendto 支持(回复客户端数据) 支持(主动发送请求数据)
地址端口特点 固定 IP+端口,对外稳定提供服务 系统自动分配临时端口,地址动态变化

核心本质:UDP 协议本身不区分服务端与客户端,两端均为普通 UDP socket。所谓服务端、客户端,是业务层面的角色划分:绑定固定地址、被动等待通信的为服务端;主动连接固定地址、发起通信的为客户端。

十八、总结

UDP 编程的核心逻辑简洁清晰,核心依赖四个基础函数:

  • socket():创建 UDP 专属数据报套接字,初始化通信载体

  • bind():为服务端套接字绑定固定 IP 与端口,提供稳定服务地址

  • recvfrom():接收 UDP 数据报,同步获取通信对端地址信息

  • sendto():向指定目标地址主动发送 UDP 数据报

完整 UDP 通信流程:客户端通过 sendto() 向服务端固定地址发送数据 → 服务端通过 recvfrom() 接收数据并获取客户端地址 → 服务端通过 sendto() 回复数据 → 客户端接收响应数据。

学习 UDP 编程需牢牢把握三个核心核心认知:

第一,UDP 无连接、无校验、无重传,通信无需提前握手,极简架构带来高实时性、低开销,同时牺牲了数据传输可靠性;

第二,服务端绑定固定端口是为了适配客户端的主动访问,客户端依托系统自动分配端口即可完成通信,无需手动绑定;

第三,UDP 严格面向数据报,保留数据传输边界,无粘包问题,但需自行处理数据可靠性、丢包、乱序等问题。

熟练掌握四大核心函数与地址结构体的使用,即可搭建完整的 UDP 基础通信模型,后续的广播、组播、非阻塞 IO、多路复用(select/poll/epoll)等高级特性,均是基于该基础模型的扩展延伸。

相关推荐
无锡耐特森1 小时前
ModbusTCP转Profinet网关:恒压供水系统互联互通解决方案
网络
tedcloud1231 小时前
book-to-skill 怎么部署?把技术书和项目文档变成 AI Agent 可复用知识
linux·运维·服务器·人工智能·开源
旗开得胜马到成功1 小时前
SK海力士SSD售后翻车:企业备份介质裸奔风险与中科热备技术选型拆解
运维·服务器·网络
瀚高PG实验室2 小时前
SQL优化案例:使用分区表优化SQL查询性能
linux·服务器·数据库·sql·microsoft·postgresql
青禾8372 小时前
Linux 系统信息、权限管理与 Python 并发编程(协程与线程)完全指南
linux·python
2301_800954992 小时前
Linux 常用系统信息与进程管理命令速查
linux·运维·服务器
bitbrowser3 小时前
MXToolbox检测IP只红一个黑名单时,如何判断?
网络·网络协议·tcp/ip
网安蟹佬霸3 小时前
CTF Web方向解题全攻略:从信息收集到getshell(附实战payload与脚本)
android·前端·网络·安全·web安全·网络安全·网安
优化Henry3 小时前
学习笔记之爱立信站点指向证书执行
运维·服务器·网络·笔记·学习·信息与通信
T型码农要学习3 小时前
eMMC_命令整理
linux·驱动开发·emmc