目录
前言
UDP 是一种无连接、低开销的传输层协议,适合设备发现、状态上报、日志推送和对实时性要求较高的局域网通信场景。本文以 ESP-IDF 为基础,基于 lwIP 提供的 BSD Socket 接口,封装了 UDP 套接字的创建、发送和接收功能,并结合 ESP32 控制台命令与收包主循环,搭建了一个便于调试和扩展的 UDP 通信工程。文章将重点说明各接口的参数含义、返回值语义、常见边界情况,以及如何在 app_main 中高效处理多个本地套接字上的 UDP 数据报。
一、UDP 简介
UDP(User Datagram Protocol,用户数据报协议)是一种无连接、低延迟、不保证可靠交付的传输层通信协议。它只负责把应用层交给它的数据封装成数据报,并通过 IP 发送到目标 IP 地址和端口,不建立连接、不确认、不重传。
UDP 的核心特点为:
- 无连接:发送前不需要像 TCP 那样进行三次握手,只要知道对方的 IP 和端口号就能直接发送数据。
- 不可靠传输:UDP 不保证数据包一定到达,也不保证多个数据包按发送顺序到达;丢包、重复或乱序都可能发生。
- 面向报文:应用程序一次交给 UDP 多少数据,UDP 基本就按一个完整报文发送,不会像 TCP 那样自动拆分或合并成字节流。
- 开销小、延迟低:UDP 首部只有 8 字节,没有连接管理、确认和重传机制,因此通信效率高、实时性好。
- 支持广播和多播:除了点对点通信,还可以向局域网内多个设备发送数据,这在设备发现、状态同步等场景很有用。
UDP 通信流程为(以两台设备通信为例):
- 接收方先创建 UDP socket,并绑定一个本地端口,例如
192.168.1.100:8080。 - 发送方创建 UDP socket,向接收方的 IP 和端口发送一个数据报。
- 数据经 IP 网络传输;如果成功到达,接收方从 socket 缓冲区读出该报文。
- 若网络丢包,UDP 协议本身不会自动重发;是否重发、如何校验、如何排序,都由应用层自行决定。
UDP 与 TCP 的区别为:
| 特性 | UDP | TCP |
|---|---|---|
| 连接方式 | 无连接,直接发送 | 面向连接,需三次握手 |
| 可靠性 | 不保证送达、不重传 | 可靠传输、确认与重传 |
| 数据形式 | 面向数据报,保留报文边界 | 面向字节流 |
| 顺序性 | 不保证顺序 | 保证按序交付 |
| 延迟与开销 | 低延迟、开销小 | 相对更高 |
| 典型用途 | 实时音视频、游戏、广播、设备发现 | 网页、文件传输、远程登录 |
1.1 客户端和服务端
UDP 虽然是无连接协议,但在实际应用中仍常区分 UDP 服务端和 UDP 客户端。服务端通常绑定固定端口并等待接收数据报;客户端主动向服务端的 IP 和端口发送数据。与 TCP 不同,UDP 的客户端/服务端不是由连接建立过程决定的,而只是应用程序约定的通信角色。
| 角色 | 典型行为 | 是否必须 bind() | 是否需要 connect() |
|---|---|---|---|
| UDP 服务端 | 绑定固定端口,等待数据报,例如 192.168.1.100:8080 | 通常必须 | 不需要 |
| UDP 客户端 | 主动向服务端 IP:port 发送数据报 | 可选;系统会自动分配临时端口 | 可选 |
UDP 服务端通常必须调用 bind() 绑定本地 IP 和端口,否则客户端不知道应把数据发到哪里;UDP 客户端则通常不强制绑定,操作系统会自动分配一个临时源端口,并通过 sendto() 指定目标地址和端口。
例如,ESP32 A 绑定 5000 端口等待接收,ESP32 B 向 A 的 5000 端口发送数据,此时 A 是服务端、B 是客户端。但反过来,B 也可以绑定一个端口并接收 A 发来的数据,两者就能互为客户端和服务端。对于关系如下:
- UDP 服务端:创建 socket 后
bind()到固定端口,再用recvfrom()收包;recvfrom()还能拿到发送方的 IP 和端口,便于回包。 - UDP 客户端:创建 socket 后可直接
sendto(server_ip, server_port);
在 UDP 通信中,服务端通常绑定固定端口并等待客户端数据报。由于客户端可能使用系统自动分配的临时端口,服务端在未收到客户端数据前,通常无法得知客户端的 IP 和端口,因此无法主动向该客户端发送数据。服务端收到第一个数据报后,可通过 recvfrom() 获取客户端地址,并在后续通信中向该地址回发数据。
二、工程实现
本工程的 UDP 通信模块位于 components/WIFI/udp.c,基于 lwIP 提供的 BSD Socket 接口封装了三个核心函数:
- udp_create():创建并绑定 UDP 套接字;
- udp_sock_send():向指定 IPv4 地址和端口发送一个 UDP 数据报;
- udp_sock_recv():在限定时间内接收一个 UDP 数据报,并可选地记录来源地址。
这三个函数不依赖 MQTT、HTTP 等更高层协议组件,只完成“创建套接字、发送数据报、接收数据报”的基础能力,因此既可作为 UDP 通信的通用封装,也便于后续扩展到设备发现、状态同步或自定义应用协议。
整体使用流程为:先调用 udp_create() 创建本地 UDP 套接字;发送方通过 udp_sock_send() 指定对端 IP 和端口发送数据;接收方通过 udp_sock_recv() 等待并读取数据报。若需要向来源地址回包,可在接收时通过 peer 参数获取对端的套接字、IP 和端口信息。
以上完整工程可以进入我的Gitee中下载《https://gitee.com/pai-schoolmate/esp32_project.git》
本工程路径:esp32_project-main\function/UDP
2.1.1 udp_create
函数原型如下:
int udp_create(uint16_t port)
该函数用于创建一个 IPv4 UDP 套接字,并将其绑定到本机指定端口。它只负责套接字的创建和绑定,不涉及数据的收发。
参数说明:
- port:本地端口,主机字节序;
0被直接拒绝,不交给内核随机分配。 - 返回值:成功返回套接字描述符(≥ 0),失败返回
-1。调用方负责close()。
函数内部将绑定地址设置为 INADDR_ANY,表示该套接字可以接收来自任意本地网络接口的数据。因此,当 ESP32 同时处于 STA 模式、SoftAP 模式或同时启用两种模式时,该套接字均可覆盖对应网卡上的 UDP 数据。
此外,函数会启用 SO_REUSEADDR 选项。该选项可以缓解 ESP32 复位或程序重启后,旧套接字尚未完全释放而导致 bind() 返回 EADDRINUSE 的问题。若创建或绑定过程中发生错误,函数会关闭已创建的套接字,避免描述符泄漏,然后返回 -1。
2.1.2 udp_sock_send
函数原型如下:
int udp_sock_send(int sock, const char *ip_addr, uint16_t port, const void *data, size_t len)
该函数用于向指定的 IPv4 地址和端口发送一个 UDP 数据报。
参数说明:
- sock:必须是
udp_create()返回的套接字;小于 0 直接报错。 - ip_addr:点分十进制 IPv4 字符串,例如
"192.168.1.100";格式错误返回-1。 - port:目的端口,主机字节序;函数内部用
htons()转成网络字节序。 - data / len:待发送数据与字节数;
len可以为 0,但data不能为NULL。 - 返回值:成功返回实际发送字节数,失败返回
-1,具体原因看errno。
UDP 是面向数据报的协议,sendto() 的语义是“一次发送一个完整数据报”。因此,本函数不需要像 TCP 发送那样循环补发剩余字节:只要 sendto() 成功,就表示该数据报已经交给协议栈处理。
需要注意的是,当 UDP 数据报长度超过网络 MTU 时,IP 层可能会对其进行分片。虽然发送端通常可以正常调用 sendto(),但接收端能否正确重组分片,取决于路径 MTU、中间设备以及双方协议栈的配置。在 ESP32 实际项目中,建议尽量将单个 UDP 数据报控制在较小长度,以降低分片和丢包风险。
2.1.3 udp_sock_recv
函数原型如下:
int udp_sock_recv(int sock, void *buf, size_t len, int timeout_ms, struct udp_dest_entry *peer)
该函数用于在指定超时时间内接收一个 UDP 数据报,并可选地将数据来源地址回填到 peer 结构体中。
参数说明:
- sock:本地 UDP 套接字;小于 0 直接报错。
- buf / len:接收缓冲区与容量;
len必须大于 0。 - timeout_ms:等待毫秒数。传
0时select立即返回,等价于非阻塞轮询。 - peer:可为
NULL;非空时写入sock、dest_ip、dest_port三个字段。 - 返回值:
> 0实际字节数;0超时或收到空数据报;-1参数错误、套接字失效或接收失败。
需要特别说明的是,返回值 0 并不表示对端关闭连接。UDP 本身没有连接概念,也没有类似 TCP 的 FIN 关闭流程;这里的 0 只表示本次调用没有收到有效数据,通常对应 select() 超时。
另外,UDP 保留了数据报边界。如果对端发送的数据报长度大于 len,接收缓冲区只能保存前 len 字节,超出部分会被丢弃,并且不会留到下一次 recvfrom() 中继续读取。这一点与 TCP 的字节流语义不同:TCP 中未读完的数据会保留在接收缓冲区中,而 UDP 中超出单个数据报容量的内容会直接丢失。
因此,应用层在调用该函数前,应根据业务协议预留足够大的接收缓冲区,避免因缓冲区过小导致数据不完整。
2.1.4 控制台命令使用指南
为了便于调试和验证 UDP 通信功能,工程中注册了若干控制台命令。在 idf.py monitor 打开的串口终端中,可以直接创建套接字、查看状态并发送数据,从而覆盖“创建套接字 → 查看状态 → 发送数据”的完整调试闭环。
| 命令 | 参数 | 作用 |
|---|---|---|
| udp_create | <端口> | 创建 UDP 套接字、绑定端口,并登记到 |
| udp_send | <对端序号> <内容> | 模式一:按 |
| udp_send | <本地套接字> <对方IP> <对方端口> <内容> | 模式二:直接指定源套接字和目的地址,不查对端表。适合主动向任意设备发数据。 |
| udp_scan | 无 | 打印对端记录和本地套接字,顺带清理已失效的描述符。 |
| help | 无 | 列出所有已注册命令及 |
其中,udp_create 在真正创建套接字之前,会先遍历 local_entry,并通过 getsockopt(SO_TYPE) 检查已有描述符是否仍然有效。如果某个描述符已经失效,则清空对应槽位,再选择第一个可用位置保存新套接字。这样可以避免异常关闭的套接字长期占用表项。
udp_scan 在打印信息前也会执行同样的清理操作,因此用户看到的本地套接字和对端记录能够更准确地反映当前实际可用的通信资源。
2.1.5 app_main 收包主循环

udp_sock_recv() 每次调用只读取一个 UDP 数据报。如果对端连续发送 5 个数据报,而外层轮询周期为 10 ms,那么单纯依靠外层循环处理,第一个周期只能取出 1 个数据报,其余 4 个需要等待后续轮询周期才能处理,可能造成接收延迟和缓冲区堆积。
为此,app_main 中的收包主循环采用了“外层轮询 + 内层排空”的结构:
- 外层循环周期性遍历所有本地 UDP 套接字;
- 对每个有效套接字,内层使用 while (udp_sock_recv(...) > 0) 持续读取;
- 当某个套接字的接收队列为空时,udp_sock_recv() 返回 0,内层循环退出,转而处理下一个套接字。
由于 timeout_ms 设置为 0,当接收队列中没有数据时,select() 会立即返回,不会阻塞主循环。因此,该结构既能及时处理已经到达的数据报,降低收包延迟,又不会因为某个空闲套接字而阻塞其他套接字的处理。
当 udp_sock_recv() 返回 -1 时,可能的原因包括 select() 失败、recvfrom() 失败,或者套接字描述符已经失效。仅凭该返回值无法准确区分具体原因,因此主循环会进一步调用:
getsockopt(sock, SOL_SOCKET, SO_TYPE, ...);
对描述符进行探活:
- 若返回 0,说明描述符仍然有效,本次失败可能只是临时的接收错误,因此保留 local_entry[i] 槽位;
- 若返回非 0,说明描述符已经失效,此时清空 local_entry[i],并同步清除所有引用该套接字的 dest_entry 记录,避免后续向无效套接字发送数据。
这种处理方式的优点是不需要额外维护“套接字已关闭”标志位,逻辑相对简单;缺点是每次接收出错都会增加一次系统调用。但对于 10 ms 级别的轮询周期而言,该开销可以接受。
每当成功收到一个数据报后,主循环会遍历 dest_entry,并使用 (sock, dest_ip, dest_port) 三元组判断该对端是否已经存在:
- 若三元组已经存在,则不重复记录;
- 若不存在,则将该对端信息写入第一个空闲槽位;
- 若对端表已满,则丢弃本次来源记录。
通过三元组去重,udp_scan 输出的是当前实际通信过的唯一对端列表,而不是每收到一个数据报就新增一条记录,从而使对端信息更加清晰、稳定。
三、结果展示


PC 以 10.79.106.162:8000 作为本地地址与 ESP32 通信。上半部分显示当前配置:目标 IP 为设备地址、发送端口 8000,会话列表中已建立与 10.79.106.59(ESP32)的两个会话。下方日志区完整还原了一次双向收发:
- → 表示 PC 发出:先后向设备的 6060 和 5050 端口各发送一条 hello esp32c6(右侧 Hex 栏 68 65 6c 6c 6f... 即其 UTF-8 字节序列);
- ← 表示设备回复:依次收到 hello_amd(两条,分别来自设备两个套接字)和 hello(两条),与图 6 中 udp_send 的输出逐条对应。
控制台这段日志演示了「建套接字 → 被动收包 → 查表 → 双模式回复」的完整闭环:
- udp_create 5050 / udp_create 6060:创建两个本地套接字,分配描述符 sock=54、sock=55,登记进 local_entry;
- PC 向两个端口发送 hello esp32c6,app_main 轮询收到后打印来源地址,并把对端 10.79.106.162:8000 自动写入 dest_entry——注意同一 IP:端口因来自不同本地套接字,被记录为序号 0 和序号 1 两条,印证了三元组 (sock, IP, port) 去重规则;
- udp_scan:打印两张表当前状态,对端记录 2/100、本地套接字 2/7,与 sdkconfig 中 CONFIG_LWIP_MAX_SOCKETS=10 推导的容量一致;
- udp_send 0 hello_amd / udp_send 1 hello_amd:模式一,按对端序号回复,9 字节经 sock=55 和 sock=54 各发一次;
- udp_send 54 10.79.106.162 8000 hello / udp_send 55 ... hello:模式二,直接指定本地套接字与目的地址,5 字节发送成功
总结
本文实现了一套轻量、可复用的 ESP32 UDP 通信模块,核心包括 udp_create()、udp_sock_send() 和 udp_sock_recv() 三个函数,分别负责套接字创建与绑定、UDP 数据报发送以及带超时的数据报接收。模块通过 SO_REUSEADDR 缓解端口复用问题,通过 select() 实现非阻塞接收,并在主循环中采用“外层轮询、内层排空”的方式降低连续收包时的处理延迟。
同时,工程利用 getsockopt(SO_TYPE) 探测套接字是否失效,及时清理无效的本地套接字和对端记录;并通过 (套接字, IP, 端口) 三元组对通信对端去重,使 udp_scan 能够输出更准确、稳定的设备列表。整体方案不依赖高层协议,结构清晰、易于扩展,可作为 ESP32 局域网设备发现、状态同步或自定义 UDP 应用协议的基础通信框架。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_58172114/article/details/168452153




