「Linux 网络编程」Socket 构建 UDP 本地通信
1 UDP 协议基础
1.1 什么是 UDP?
UDP(User Datagram Protocol,用户数据报协议) 是 OSI 七层模型中传输层的核心协议之一,它的核心特点就是 “简单直接”——不建立连接、不保证可靠传输,但速度快、开销小,特别适合对 延迟敏感、能接受少量数据丢失 的场景。
1.2 UDP 的核心特性
- 无连接:通信前不需要像 TCP “三次握手” 建立连接,不需要发送类似 ACK 的应答确认消息,也不需要 SEQ 分配数据包序列号。UDP 直接发送数据,省时省力。
- 不可靠:不确认接收方是否收到数据,不重传丢失的数据包,也不保证数据按序到达。
- 面向数据报:数据以 数据报 为单位传输,每个数据报都是独立的,包含完整的源地址和目的地址。
- 开销小:首部结构简单,仅 8 字节(TCP 首部至少 20 字节),传输效率高。
1.3 UDP 首部结构
UDP 首部非常简洁,共 4 个字段,每个字段 2 字节(16 位),总长度 8 字节:
字段 | 长度(位) | 作用 |
---|---|---|
源端口号 | 16 | 标识发送方的应用程序端口,若不需要回复,可设为 0 |
目的端口号 | 16 | 标识接收方的应用程序端口,是数据报的 “收件人门牌号” |
UDP 长度 | 16 | 表示整个 UDP 数据报(首部 + 数据)的字节数,最小值为 8(仅首部) |
校验和 | 16 | 用于校验 UDP 数据报是否损坏(IPv4 中可选,IPv6 中必选),若校验失败则丢弃 |
2 UDP 内部工作原理
UDP 的工作流程非常直观,没有 TCP 的 “连接管理”“流量控制”“拥塞控制” 等复杂逻辑,核心就是 封装 - 传输 - 解封装 三步。
2.1 UDP 发送方流程
-
应用层程序(如游戏客户端)将数据交给 UDP 协议;
-
UDP 给数据添加 首部(源端口、目的端口、长度、校验和),形成 UDP 数据报;
-
UDP 将数据报交给网络层(IP 协议),IP 再添加 IP 首部(源 IP、目的 IP 等),形成 IP 数据报;
-
IP 数据报通过网络(如路由器、交换机)传输到接收方。
2.2 UDP 接收方流程
-
网络层收到 IP 数据报后,解封装取出 UDP 数据报,交给传输层的 UDP 协议;
-
UDP 验证校验和。若校验失败,直接丢弃数据报;若成功,提取出应用层数据;
-
UDP 通过 目的端口号 找到对应的应用程序,将数据交给应用层。
3 Linux UDP 服务端和客户端实现
UDP 不同于 TCP,不存在请求连接和请求受理的过程,实际上无法从协议层面明确区分服务端和客户端。
所谓的 服务端,本质是应用层约定的 提前绑定固定端口、被动接收数据并回复 的一方;客户端 是 主动向固定端口发送数据 的一方 —— 但这种角色划分完全是人为约定,而非协议强制。
例如,若 UDP 客户端也绑定固定端口,服务端同样可以主动向客户端的固定端口发送数据;在 P2P 通信场景中(如局域网文件互传),双方既可以发送数据也可以接收数据,此时根本无法界定谁是 “服务端”、谁是 “客户端”,只能根据实际功能(如谁先发起请求)临时划分角色。
然而 TCP 不一样,一旦通过三次握手建立连接,主动发起连接的客户端 和 被动接受连接的服务端 角色便固定下来,无法随意切换。
3.1 UDP 服务端和客户端只需 1 个套接字
在 Linux 网络编程中,套接字 是应用层与内核网络协议栈交互的 “桥梁”,每个套接字对应一个 通信端点,包含协议类型(TCP/UDP)、IP 地址、端口号等核心信息。
UDP 与 TCP 对套接字的使用逻辑对比:
-
TCP 因 面向连接 的特性,服务端需维护 监听套接字 与 连接套接字 两类套接字(监听套接字仅用于接收连接请求,每建立一个客户端连接就会生成一个独立的连接套接字,用于与该客户端的专属通信,即 一个连接对应一个套接字),客户端虽仅需 1 个套接字,但服务端的套接字数量会随客户端连接数增加而增长;
-
UDP 因 无连接 的特性,无需为每个通信对象新建套接字,服务端和客户端仅靠 1 个套接字就能完成所有通信操作。
为什么 UDP 只需 1 个套接字:
UDP 通过 数据报携带地址信息 + 套接字绑定固定端口 实现了 “一对多” 的通信能力。
UDP 客户端只需通过 socket()
创建 1 个 UDP 套接字,即可直接调用 sendto()
向服务端的 IP + 固定端口发送数据(此时内核会自动为该套接字分配一个临时端口)。
服务端通过客户端的临时端口回复数据时,内核会将这些数据交付给客户端的这 1 个套接字,客户端通过同一个套接字调用 recvfrom()
即可接收响应。
即便客户端需要与多个服务端通信(如同时向两个不同的 UDP 服务器发送数据),无需创建多个套接字:
-
在
sendto()
中指定不同服务端的 IP + 端口,即可通过同一个套接字向不同目标发送数据; -
接收不同服务端的回复时,同样通过
recvfrom()
获取发送方地址,就能区分是哪个服务端的响应。
3.2 UDP 相关 I/O 函数
3.2.1 sendto 和 recvfrom
1 |
|
参数说明:
-
sendto
参数:sockfd
: socket 文件描述符buf
: 要发送的数据缓冲区len
: 要发送的数据长度flags
: 发送标志(通常为0)dest_addr
: 目标地址结构体指针addrlen
: 目标地址结构体长度
-
recvfrom
参数:sockfd
: socket 文件描述符buf
: 接收数据缓冲区len
: 缓冲区最大长度flags
: 接收标志(通常为0)src_addr
: 来源地址结构体指针addrlen
: 来源地址结构体长度指针
函数作用:
-
sendto
:向指定地址发送数据。从
sockfd
指定的 UDP 套接字,将buf
中的数据发送到dest_addr
指定的目标地址(IP + 端口),addrlen
指定目标地址结构体的长度。UDP 无连接,内核不会像 TCP 那样维护连接表,每次发送都需明确告知 数据要发给谁。
-
recvfrom
:接收数据 并 获取发送方地址。从
sockfd
指定的 UDP 套接字接收数据,存入buf
缓冲区,并将发送方的地址信息(IP、端口)写入src_addr
结构体,同时通过addrlen
返回地址结构体的实际长度。UDP 是无连接协议,服务端需知道客户端的地址才能回复数据。
3.2.2 函数参数详细解析
-
共性参数(sockfd、buf、len、flags)
参数名 类型 作用与注意事项 sockfd
int
UDP 套接字的文件描述符,由 socket(AF_INET, SOCK_DGRAM, 0)
创建,必须是有效的(未关闭、绑定成功)。buf
void*
/const void*
recvfrom
中是 接收缓冲区(非 const,需提前分配内存,如char buf[1024]
);
sendto
中是 发送缓冲区(const,存储要发送的数据,如字符串、二进制数据)。len
size_t
recvfrom
中是接收缓冲区的最大容量(避免数据溢出,通常设为缓冲区数组长度 - 1,留 1 字节存字符串结束符\0
);
sendto
中是要发送的数据的实际长度(如字符串用strlen(buf)
,二进制数据需明确字节数)。flags
int
控制函数行为的标志,通常设为 0
(默认阻塞模式),常用特殊标志如下:
MSG_DONTWAIT
:非阻塞模式(若暂无数据 / 无法发送,立即返回,不阻塞进程);
MSG_PEEK
:仅查看数据(不从内核缓冲区移除数据,下次调用仍能接收该数据,多用于 “预览数据长度” 场景);
MSG_OOB
:发送 / 接收带外数据(UDP 极少用,TCP 紧急数据场景更常见)。 -
差异参数(地址相关参数)
recvfrom
需获取发送方地址,sendto
需指定目标地址,这是二者最核心的差异,也是 UDP 无连接特性的直接体现。函数 地址参数 1 地址参数 2 作用与注意事项 recvfrom
src_addr
(struct sockaddr*
)addrlen
(socklen_t*
)src_addr
:存储 发送方地址(通常用struct sockaddr_in
强制转换,因struct sockaddr
是通用地址结构体,需结合具体协议(IPv4/IPv6)使用);
addrlen
:输入输出参数:
- 输入:src_addr
结构体的初始长度(如sizeof(struct sockaddr_in)
);
- 输出:实际存储的地址长度(因不同协议地址长度可能不同,UDP IPv4 场景通常与输入一致)。sendto
dest_addr
(const struct sockaddr*
)addrlen
(socklen_t
)dest_addr
:指定 目标地址(同样用struct sockaddr_in
强制转换,需提前初始化 IP 和端口,且端口需转为网络字节序htons()
);
addrlen
:dest_addr
结构体的 固定长度(如sizeof(struct sockaddr_in)
,纯输入参数,无需指针)。
3.2.3 返回值和错误处理
两个函数的返回值类型均为 ssize_t
(带符号的大小类型,可表示 “成功接收 / 发送的字节数” 或 “负数值表示错误”),必须通过返回值判断执行结果,否则会遗漏数据丢失、地址错误等问题。
-
返回成功:
recvfrom
:返回 实际接收的字节数(注意:不包含字符串结束符\0
,需手动添加,如buf[recv_len] = '\0'
);sendto
:返回 实际发送的字节数(正常情况下与len
参数相等,若网络拥堵可能小于len
,需循环发送补全)。
-
返回失败:
返回
-1
,同时设置全局变量errno
表示错误类型,需用perror()
或strerror(errno)
打印错误信息。
3.2.4 使用注意事项
-
缓冲区大小与数据截断
-
接收端:
recvfrom
的len
参数(缓冲区最大容量)必须大于等于可能接收的最大 UDP 数据报长度(UDP 数据报最大长度为 65507 字节,因 UDP 首部占 8 字节,IP 首部最小 20 字节,总长度不超过 IP 最大值 65535),建议设为65536
(留有余量),避免数据截断; -
发送端:
sendto
的len
参数(数据长度)不能超过 65507 字节,否则内核返回EMSGSIZE
错误(数据报过大,无法封装)。
-
-
地址结构体初始化与字节序转换
-
初始化地址结构体:必须用
memset
清空地址结构体(如struct sockaddr_in
),避免结构体中的随机垃圾数据导致地址错误; -
字节序转换:IP 和端口需转为 网络字节序(大端序) :
- 端口:用
htons()
(host to network short)转换(如server_addr.sin_port = htons(8888)
); - IP(IPv4):用
inet_pton()
(presentation to network)转换字符串 IP(如inet_pton(AF_INET, "``127.0.0.1``", &server_addr.sin_addr)
),禁止直接赋值(如server_addr.sin_addr.s_addr = 0x7F000001
,虽 127.0.0.1 的网络字节序是 0x0100007F,但跨平台兼容性差)。
- 端口:用
-
-
服务端绑定与客户端地址
-
服务端必须绑定端口:
recvfrom
依赖绑定的端口监听数据,若服务端未调用bind()
,内核会自动分配临时端口,但客户端无法知道该端口,导致无法通信; -
客户端无需绑定端口:客户端调用
sendto
时,若未绑定端口,内核会自动分配一个临时端口(1024-65535)。而且后续recvfrom
会通过该端口接收服务端的回复,无需手动绑定。
-
3.3 Linux UDP 服务端构建
核心流程:
socket
创建 UDP 套接字 → 2.bind
绑定 IP 和端口 → 3.recvfrom
接收客户端数据 → 4.sendto
发送响应 → 5.close
关闭套接字
1 |
|
3.4 Linux UDP 客户端构建
核心流程:
socket
创建 UDP 套接字 → 2. 初始化服务端地址 → 3.sendto
发送数据给服务端 → 4.recvfrom
接收服务端响应 → 5.close
关闭套接字
1 |
|
3.5 UDP 服务端和客户端编译运行测试
编译 udp_server.c
(服务端代码)和 udp_client.c
(客户端代码)。
1 |
|
测试运行:
3.6 UDP 和 TCP 的对比
对比维度 | UDP | TCP |
---|---|---|
连接性 | 无连接 | 面向连接(三次握手建立,四次挥手关闭) |
可靠性 | 不可靠(不确认、不重传、不排序) | 可靠(确认、重传、排序、流量控制、拥塞控制) |
传输效率 | 高(首部小、无额外逻辑) | 低(首部大、有连接、重传等开销) |
数据边界 | 有(数据报独立,一次接收一个) | 无(字节流,需应用层自己处理边界) |
适用场景 | 实时通信(直播、游戏、VOIP)、DNS 查询、物联网 | 可靠传输(文件下载、网页浏览、聊天消息) |
4 在 UDP 中调用 connect 函数
UDP 虽然是无连接的,但并不是不能调用 connect
函数。
UDP 的 connect
并非建立像 TCP 那样的双向连接,而是告知内核 “该套接字后续只与指定的 IP 和端口通信”,本质是对套接字的 目的地址绑定。这种绑定能带来 4 个核心好处。
4.1 简化发送逻辑:无需重复指定目的地址
未调用 connect
时,每次发送数据必须用 sendto()
并传入目的地址(struct sockaddr_in
),否则内核不知道数据要发给谁;
调用 connect
后,内核已记录目的地址,后续可直接用 send()
(无需传地址),或继续用 sendto()
(但地址参数可忽略),减少代码冗余和参数错误风险。
在客户端代码中可进行如下修改:
1 |
|
4.2 降低内核开销:减少重复路由查找
未调用 connect
时,每次 sendto()
内核都要重新解析目的 IP、查找路由表(确定从哪个网卡发送);
调用 connect
后,内核会 缓存路由信息,后续发送直接复用缓存,尤其在 频繁与同一服务器通信(如游戏客户端、实时监控)场景下,能显著减少内核耗时。
4.3 支持错误通知:感知 “对方不可达”
UDP 的一大痛点是 丢包无反馈—— 若对方主机宕机或端口未监听,未 connect
的套接字会直接丢弃数据包,应用层完全不知道错误;
调用 connect
后,内核会监听 ICMP 错误(如 “目标不可达”),并将错误通过 recvfrom()/recv()
返回给应用层(表现为返回 -1
,errno设为 ECONNREFUSED等),方便排查问题。
错误处理示例:
1 |
|
4.4 支持 “单播定向”:避免误收其他地址数据
未调用 connect
的 UDP 套接字 会接收所有发送到该端口的 UDP 数据报(无论来源 IP);
调用 connect
后,套接字仅接收 来自绑定目的地址 的数据报,相当于给数据接收加了过滤规则,适合需要 一对一专属通信 的场景。
5 UDP 服务端与客户端轮流收发消息
5.1 核心设计思路
- 客户端:循环 “输入消息→发送→接收响应”,直到输入 “exit” 退出;
- 服务端:循环 “接收消息→判断是否退出→发送响应”,若收到 “exit” 则退出;
- 双方都需处理 消息边界 和 缓冲区清空,避免脏数据干扰。
5.2 服务端代码(轮流转发)
1 |
|
5.3 客户端代码(轮流转发)
1 |
|
5.4 编译与运行测试
编译代码
1 |
|
启动服务端
1 |
|
启动客户端并测试轮流收发
1 |
|
运行结果: