ESP32应用——UDP组播/广播(ESP-IDF框架)
目录
一、前言
二、单播
三、广播
四、组播(多播)
五、应用代码示例
5.1 app_udp_client.h
5.2 app_udp_client.c
六、结语
一、前言
上一篇博客详细讲解了HTTP client的应用:ESP32应用——HTTP client(ESP-IDF框架),我们需要访问一个服务器,就必须知道它的主机名(域名或IP地址),我们开发的时候,通常会固定一个IP地址,然后进行调试,但是项目实际落地交给客户之后,客户可能不知道IP地址,这时候就需要用到基于UDP协议的组播和广播模式。通常我们说要实现自动发现就是要用到这两个模式。本次我们还是使用ESP-IDF框架开发,使用lwIP库进行socket编程,实现自动发现的功能。
二、单播
平时我们进行简单的网络编程,无论TCP还是UDP,进行一对一的通信就是单播。
定义:单播(unicast)是网络通信中最常见的方式,指的是一个源主机向一个目标主机发送数据。一切基于TCP的网络协议都是单播,因为通信前需建立连接,那注定就是一对一的通信。
应用:浏览网页(访问网站,它单独响应你);私人聊天;发送电子邮件。
三、广播
广播被限定在一个局域网内,只需要发送一次,同一局域网内的所有机子都能接收到你发来的消息。
定义:广播(broadcast)这种网络通信方式,用于将数据从一个节点传输到同一网络中的所有其他节点。广播数据包会被发送到一个特殊的广播地址,网络中的所有设备都会接收到这个数据包,不论设备是否需要该信息。这种通信方式一般用于局域网(LAN)中的信息共享。
注意:广播并不是所有的网络都支持,通常:广播只是在局域网中,IPv6也不支持广播。在以太网中使用全1的地址来代表广播。例如:255.255.255.255。在广播中,发送端并不指定特定的接收方,而是将数据包发送到该网络中的所有设备。由于广播会广泛传播,在网络中广播的数据通常是诸如网络探测和广告等,这种方式也常被黑客用来进行入侵和攻击。
● 源IP:发送方的IP
● 目的IP:255.255.255.255
四、组播(多播)
组播通常用于局域网,因为许多路由器和网络设备不支持跨互联网的组播路由(也就是说想要跨局域网,可以通过路由配置)。此外,组播的管理和传输在局域网中更为高效和可控。
原理:
组播使用特定的IP地址范围,IPv4中是 224.0.0.0 到 239.255.255.255(D类地址),IPv6中以 FF00::/8 开头。当发送方往组播地址发送数据时,网络中的组播路由器会检测哪些路由上有接收该组的设备,并将数据仅复制到这些路由上,避免向无关路由传输。
特点:
点对组通信;数据仅需发送一次,网络中的路由器会将数据复制到所有加入该组播组的接收者,避免了单播情况下多次传输的开销;接收者可以通过加入或离开特定的组播组,动态地接收或停止接收特定的组播消息。
流程:
① 假设局域网内有5台主机:A、B、C、D、E;
② 通过分配一个组播地址和端口(比如 239.255.0.1:8888)来定义组;
③ 现在,如果主机A、B、C加入了这个组播组,那么D、E不会收到该组播消息;
④ 当A发送一条组播消息到 239.255.0.1:8888 时,B和C会接收这条消息,而D、E不会收到;
⑤ 组播的IP和端口是程序自己随意选择的(避开常用端口,要大于1024),只要在 224.0.0.0 到 239.255.255.255 这个范围就行。
注意:在局域网中,239.0.0.0 到 239.255.255.255 是专用组播地址,通常用于本地组播
后续代码示例广播和组播接收,由于和服务器处于同一局域网下,因此组播地址为本地管理组播地址,至于如何跨网传输组播消息这个我还不太清楚。
五、应用代码示例
下面是我做项目用到的自动发现的驱动代码,创建一个FreeRTOS任务同时监听广播和组播消息,接收到了之后解析JSON格式的数据,然后将IP地址发送给应用层。
5.1 app_udp_client.h
头文件定义了一些常量,比如要加入的组播地址、端口号等;还有监听的广播地址(255.255.255.255),将控制UDP接收任务的方法封装成函数供应用层调用。
#ifndef _APP_UDP_CLIENT_H
#define _APP_UDP_CLIENT_H#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include <string.h> // 字符串操作函数
#include <stdlib.h> // 标准库函数
#include <fcntl.h> // 文件控制函数
#include "esp_log.h" // ESP32日志系统
#include "lwip/sockets.h" // LwIP套接字API
#include "cJSON.h" // JSON解析库#define UDP_TASK_STACK_SIZE (1024 * 3) // 任务栈大小,ESP-IDF里以字节为单位
#define UDP_TASK_PRIORITY (5) // 任务优先级#define UDP_TAG "APP_UDP"#define MULTICAST_IP "239.0.1.1" // 组播地址(239.x.x.x为组播地址范围)
#define BROADCAST_IP "255.255.255.255" // 广播地址(全网广播)
#define UDP_PORT 9492 // UDP监听端口// 事件位定义
#define UDP_PAUSE_BIT BIT0
#define UDP_RESUME_BIT BIT1
#define UDP_STOP_BIT BIT2// UDP客户端状态定义
typedef enum
{UDP_CLIENT_STOPPED = 0,UDP_CLIENT_RUNNING,UDP_CLIENT_PAUSED
} udp_client_state_t;// 回调函数类型定义
typedef void (*udp_json_callback_t)(const char *server_ip);// ip队列
extern QueueHandle_t ui_receive_ip_queue; // 应用层读这个队列,获取IP字符串// UDP客户端控制函数
void udp_multicast_receiver_callback(const char *server_ip);
void app_udp_multicast_receiver_init(udp_json_callback_t callback);
void app_udp_client_pause(void);
void app_udp_client_resume(void);
void app_udp_client_stop(void);
udp_client_state_t udp_client_get_state(void);#endif /* _APP_UDP_CLIENT_H */
5.2 app_udp_client.c
光看代码会很懵逼,结合实际业务来看就很好懂了。服务器通过脚本来模拟,不断广播和组播服务器的IP地址,如下:
发送JSON格式的数据,客户端接收到之后解析数据就好了,其实也很简单,主要是运用 socket 编程。
下面我会对 socket 编程用到的相关函数进行详细的讲解,可结合.c文件里的UDP任务来看。
① Socket地址结构配置:
● struct sockaddr_in:
○ 作用:一个专门用于IPv4协议的标准Socket地址结构体
○ 成员:
■ sin_family:地址族,AF_INET 表示 IPv4。
■ sin_port:16位的端口号,必须是网络字节序。
■ sin_addr:一个结构体,其 s_addr 成员是32位的IPv4地址,也必须是网络字节序。
● htonl(INADDR_ANY):
○ INADDR_ANY:一个宏,其值为 0.0.0.0。它告诉操作系统,这个Socket将绑定到机器上所有可用的网络接口(如Wi-Fi、以太网)上。任何发送到本机指定端口的数据包,无论目标IP是哪个本机IP,都会被这个Socket接收。
○ htonl():“Host TO Network Long”的缩写。它将一个32位(long)整数从主机字节序转换为网络字节序。计算机CPU存储数据有不同的方式(大端序、小端序),而网络传输统一规定使用大端序(网络字节序)。sin_addr.s_addr 和 sin_port 都必须使用网络字节序。
● htons(UDP_PORT):
○ htons():“Host TO Network Short”的缩写。它将一个16位(short)整数从主机字节序转换为网络字节序。这里用于转换端口号。
② 创建Socket:
● socket() 函数:
○ 作用:创建一个通信端点(Socket),并返回一个文件描述符(file descriptor)来代表这个Socket。
○ 参数:
■ int domain (这里的 addr_family): 通信域/协议族。AF_INET 表示IPv4 Internet协议。
■ int type (这里的 SOCK_DGRAM): 通信语义。SOCK_DGRAM 表示提供无连接的、不可靠的数据报服务,这正是UDP的特征。与之相对的是SOCK_STREAM(提供面向连接的、可靠的字节流,即TCP)。
■ int protocol (这里的 ip_protocol): 通常设置为0,表示由系统根据前两个参数自动选择默认协议。对于AF_INET和SOCK_DGRAM,默认就是UDP协议(IPPROTO_UDP)。显式指定IPPROTO_IP在这里效果类似,但更清晰的写法是IPPROTO_UDP。
○ 返回值:成功时返回一个非负整数的Socket文件描述符。失败时返回-1,并设置全局变量errno以指示错误原因。
③ 设置Socket选项:
● fcntl() 函数(File CONTROL):
○ 作用:对一个文件描述符进行各种控制操作。
○ F_GETFL:获取文件描述符的当前状态标志。
○ F_SETFL:设置文件描述符的状态标志。
○ O_NONBLOCK:非阻塞模式标志。
○ 流程:先获取当前标志(flags),然后通过按位或操作 | 加上O_NONBLOCK标志,最后再设置回去。
○ 效果:将Socket设置为非阻塞模式。在非阻塞模式下,像recvfrom这样的IO操作如果无法立即完成(例如没有数据可读),函数会立即返回一个错误(EAGAIN或EWOULDBLOCK),而不是一直阻塞(等待)直到有数据到来。这对于在RTOS任务循环中同时处理其他事件(如检查信号量、事件组)至关重要,避免了任务被无限期挂起。
④ 绑定Socket:
● bind() 函数:
○ 作用:将一个Socket与一个特定的本地地址(IP地址 + 端口号)关联起来。对于接收方来说,这是必须的一步,它告诉操作系统:“请把发送到本机这个端口的所有数据包都交给这个Socket来处理”。
○ 参数:
1. int sockfd:Socket文件描述符。
2. const struct sockaddr *addr:指向地址结构体的指针。因为bind要能处理不同协议族(如IPv4、IPv6)的地址,所以使用通用的sockaddr指针。这里我们将特定的sockaddr_in结构体强制转换成了通用的sockaddr *类型。
3. socklen_t addrlen: 第二个参数所指向的地址结构体的大小,即sizeof(dest_addr)。
○ 返回值:成功返回 0,失败返回 -1 并设置 errno。
○ 关键点:dest_addr中我们设置了INADDR_ANY和端口UDP_PORT。这意味着绑定到所有本地IP地址的指定端口上。
⑤ 加入组播组:
● struct ip_mreq(IP Multicast Request):
○ 作用:用于操作IPv4组播成员资格的结构体。
○ 成员:
■ imr_multiaddr: 要加入(或离开)的组播组的IP地址。使用inet_addr()函数将点分十进制的字符串(如"239.255.255.250")转换为32位的网络字节序的IP地址。
■ imr_interface: 指定通过哪个本地网络接口加入组播组。INADDR_ANY表示由操作系统自动选择一个默认接口。
● setsockopt() with IP_ADD_MEMBERSHIP:
○ level:IPPROTO_IP,因为这是一个IP协议层面的选项。
○ optname:IP_ADD_MEMBERSHIP,表示加入一个组播组。
○ optval:指向上面填充好的 ip_merq 结构体的指针。
○ 作用:向操作系统发出指令,告诉它“我想通过这个Socket接收发往MULTICAST_IP这个组播地址的数据包”。操作系统底层网络协议栈会通过IGMP协议向路由器注册这个成员关系。
⑥ 接收数据-核心循环
● recvfrom() 函数:
○ 作用:从一个(已连接的或未连接的)Socket接收数据,并同时获取发送方的地址信息。这对于UDP这种无连接的协议至关重要。
○ 参数:
1. int sockfd:Socket文件描述符。
2. void *buf(rx_buffer):指向接收缓冲区的指针,数据将被存放在这里。
3. size_t len:接收缓冲区的最大长度。这里用了sizeof(rx_buffer) - 1是为了给字符串的结束符\0预留空间。
4. int flags:附加标志,通常设为0。
5. struct sockaddr *src_addr ((struct sockaddr *)&source_addr):这是一个输出参数。函数会将发送方的地址信息填充到这个结构体中。sockaddr_storage是一个足够大的通用结构体,可以容纳任何类型的地址(IPv4、IPv6)。
6. socklen_t *addrlen (&socklen):这是一个输入输出参数。调用前,它必须初始化为src_addr指向的缓冲区的长度。函数返回后,它会被设置为实际写入的地址信息的长度。
○ 返回值:
■ 成功:返回接收到的字节数。
■ 失败:返回 -1,并设置 errno。
■ 对于非阻塞Socket且没有数据可读时,会立即返回-1并将errno设置为EAGAIN或EWOULDBLOCK(这两个值通常相同)。代码中专门检查了这种情况,然后延时一小段时间后继续循环,而不是将其视为错误。
○ 流程:调用recvfrom后,如果成功,len是数据长度,source_addr里存的就是发送数据包的那台机器的IP和端口。
● inet_ntoa_r() 函数:
○ 作用:“network to ASCII”的可重入版本(_r表示reentrant,线程安全)。它将一个网络字节序的in_addr结构体(IPv4地址)转换回点分十进制的字符串形式。
○ 参数:
1. struct in_addr:要转换的地址。我们从source_addr中提取出sockaddr_in结构,然后取得它的sin_addr成员。
2. char *buf:用于存储结果字符串的缓冲区。
3. int len:缓冲区的长度。
○ 这里先检查了地址族是否为PF_INET(与AF_INET值相同,表示IPv4),确保类型安全后再进行转换。
⑦ 清理:
关闭一个文件描述符。对于Socket,这意味着释放该Socket占用的所有系统资源,并终止通信。如果这是对组播组的最后一个引用,系统会自动发出离开组播组的信令。
#include "_app_udp_client.h"// 全局变量
QueueHandle_t ui_receive_ip_queue = NULL;// 静态变量
static TaskHandle_t udp_task_handle = NULL; // RTOS任务句柄
static udp_client_state_t udp_client_state = UDP_CLIENT_STOPPED; // 全局标志位,表示任务状态
static udp_json_callback_t json_callback = NULL; // 回调函数指针
static EventGroupHandle_t udp_event_group = NULL; // 事件组句柄/*** @brief 用户自定义回调函数,作为参数传递给UDP初始化函数** @param server_ip*/
void udp_multicast_receiver_callback(const char *server_ip)
{ESP_LOGW(UDP_TAG, "接收到服务器IP: %s", server_ip);// 复制字符串到堆内存,接收方存到缓冲区后需freechar *ip_copy = strdup(server_ip);if (!ip_copy) {ESP_LOGE(UDP_TAG, "内存分配失败");return;}if (ui_receive_ip_queue) {if (xQueueSend(ui_receive_ip_queue, &ip_copy, pdMS_TO_TICKS(50)) != pdPASS) {ESP_LOGE(UDP_TAG, "写队列失败,丢弃IP: %s", ip_copy);free(ip_copy); // 发送失败时释放内存}}else{ESP_LOGE(UDP_TAG, "队列未初始化!");free(ip_copy);}
}/*** @brief 解析从服务端发来的JSON数据** @param json_str 入参,要解析的JSON数据* @param server_ip 出参,解析完的IP字符串* @param ip_buf_size */
static bool parse_server_ip_from_json(const char *json_str, char *server_ip, size_t ip_buf_size)
{if (!json_str || !server_ip || ip_buf_size == 0){return false;}cJSON *json = cJSON_Parse(json_str);if (json == NULL){ESP_LOGE(UDP_TAG, "Failed to parse JSON: %s", json_str);return false;}cJSON *server_item = cJSON_GetObjectItem(json, "server");if (!cJSON_IsString(server_item) || (server_item->valuestring == NULL)){ESP_LOGE(UDP_TAG, "JSON does not contain valid 'server' field");cJSON_Delete(json);return false;}// 复制IP地址到输出缓冲区strncpy(server_ip, server_item->valuestring, ip_buf_size - 1);server_ip[ip_buf_size - 1] = '\0';ESP_LOGI(UDP_TAG, "Parsed server IP: %s", server_ip);cJSON_Delete(json);return true;
}/*** @brief UDP组播广播客户端任务** @param pvParameters*/
void udp_multicast_receiver_task(void *pvParameters)
{char rx_buffer[128]; // 接收缓冲区char addr_str[128]; // 存储发送方IP地址的字符串char server_ip[64]; // 存储解析出的管理终端IPint addr_family = AF_INET; // IPv4地址族int ip_protocol = IPPROTO_IP; // IP协议struct sockaddr_in dest_addr; // 目标地址结构int sock = -1;ESP_LOGI(UDP_TAG, "UDP client task started");udp_client_state = UDP_CLIENT_RUNNING;// 1. 配置socket地址结构dest_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定到所有可用接口dest_addr.sin_family = AF_INET; // IPv4dest_addr.sin_port = htons(UDP_PORT); // 端口号(网络字节序)// 2. 创建UDP套接字sock = socket(addr_family, SOCK_DGRAM, ip_protocol);if (sock < 0){ESP_LOGE(UDP_TAG, "Unable to create socket: errno %d", errno);goto cleanup;}ESP_LOGI(UDP_TAG, "Socket created");// 设置socket为非阻塞模式int flags = fcntl(sock, F_GETFL, 0);fcntl(sock, F_SETFL, flags | O_NONBLOCK);// 3. 绑定socket到指定端口int err = bind(sock, (struct sockaddr *)&dest_addr, sizeof(dest_addr));if (err < 0){ESP_LOGE(UDP_TAG, "Socket unable to bind: errno %d", errno);goto cleanup;}ESP_LOGI(UDP_TAG, "Socket bound, port %d", UDP_PORT);// 4. 加入组播组struct ip_mreq imreq;imreq.imr_multiaddr.s_addr = inet_addr(MULTICAST_IP);imreq.imr_interface.s_addr = INADDR_ANY;err = setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(struct ip_mreq));if (err < 0){ESP_LOGE(UDP_TAG, "Failed to join multicast group: errno %d", errno);}else{ESP_LOGI(UDP_TAG, "Joined multicast group %s", MULTICAST_IP);}// 5. 消息接收循环while (udp_client_state != UDP_CLIENT_STOPPED){// 检查事件EventBits_t bits = xEventGroupWaitBits(udp_event_group,UDP_PAUSE_BIT | UDP_RESUME_BIT | UDP_STOP_BIT,pdTRUE, //等待任意一位pdFALSE, //退出前清除0); //不等待,判断完直接返回if (bits & UDP_STOP_BIT){ESP_LOGI(UDP_TAG, "Stop signal received");break;}else if (bits & UDP_PAUSE_BIT){ESP_LOGI(UDP_TAG, "Pause signal received");udp_client_state = UDP_CLIENT_PAUSED;}else if (bits & UDP_RESUME_BIT){ESP_LOGI(UDP_TAG, "Resume signal received");udp_client_state = UDP_CLIENT_RUNNING;}// 如果处于暂停状态,等待恢复if (udp_client_state == UDP_CLIENT_PAUSED){vTaskDelay(100 / portTICK_PERIOD_MS);continue;}// 接收UDP数据包struct sockaddr_storage source_addr;socklen_t socklen = sizeof(source_addr);int len = recvfrom(sock, rx_buffer, sizeof(rx_buffer) - 1, 0,(struct sockaddr *)&source_addr, &socklen);if (len < 0){if (errno == EAGAIN || errno == EWOULDBLOCK){// 非阻塞模式下没有数据可读vTaskDelay(100 / portTICK_PERIOD_MS);continue;}else{ESP_LOGE(UDP_TAG, "recvfrom failed: errno %d", errno);break;}}else if (len > 0){// 获取发送方IP地址if (source_addr.ss_family == PF_INET){inet_ntoa_r(((struct sockaddr_in *)&source_addr)->sin_addr,addr_str, sizeof(addr_str) - 1);}rx_buffer[len] = 0; // 添加字符串结束符ESP_LOGI(UDP_TAG, "Received %d bytes from %s: %s", len, addr_str, rx_buffer);// 尝试解析JSON格式的管理终端IPif (parse_server_ip_from_json(rx_buffer, server_ip, sizeof(server_ip))){ESP_LOGI(UDP_TAG, "Successfully parsed server IP: %s", server_ip);// 如果设置了回调函数,则调用它if (json_callback != NULL){json_callback(server_ip);}}else{ESP_LOGW(UDP_TAG, "Failed to parse JSON or extract server IP");}}vTaskDelay(100 / portTICK_PERIOD_MS);}// 错误集中处理
cleanup:if (sock >= 0){close(sock);ESP_LOGI(UDP_TAG, "Socket closed");}udp_client_state = UDP_CLIENT_STOPPED;udp_task_handle = NULL;ESP_LOGI(UDP_TAG, "UDP client task ended");// 清理事件组if(udp_event_group){vEventGroupDelete(udp_event_group);udp_event_group = NULL;}// 清理队列if (ui_receive_ip_queue) {vQueueDelete(ui_receive_ip_queue);ui_receive_ip_queue = NULL;}vTaskDelete(NULL);
}/*** @brief 初始化UDP客户端** @param callback 用户自定义的回调函数*/
void app_udp_multicast_receiver_init(udp_json_callback_t callback)
{if (udp_task_handle != NULL){ESP_LOGW(UDP_TAG, "UDP client task already running");return;}// 设置回调函数json_callback = callback;ESP_LOGI(UDP_TAG, "JSON callback %s", callback ? "set" : "cleared");// 创建事件组if (udp_event_group == NULL){udp_event_group = xEventGroupCreate();if (udp_event_group == NULL){ESP_LOGE(UDP_TAG, "Failed to create event group");return;}}// 创建队列if (ui_receive_ip_queue == NULL){ui_receive_ip_queue = xQueueCreate(1, sizeof(char *));if (ui_receive_ip_queue == NULL){ESP_LOGE(UDP_TAG, "Failed to create queue");return;}}// 创建UDP接收任务BaseType_t ret = xTaskCreate(udp_multicast_receiver_task,"udp_multicast_receiver_task",UDP_TASK_STACK_SIZE, NULL, UDP_TASK_PRIORITY, &udp_task_handle);if (ret != pdPASS){ESP_LOGE(UDP_TAG, "Failed to create UDP client task");udp_task_handle = NULL;}else{ESP_LOGI(UDP_TAG, "UDP client started");}
}/*** @brief 暂停UDP任务**/
void app_udp_client_pause(void)
{if (udp_event_group != NULL && udp_client_state == UDP_CLIENT_RUNNING){xEventGroupSetBits(udp_event_group, UDP_PAUSE_BIT);ESP_LOGI(UDP_TAG, "UDP client pause requested");}else{ESP_LOGW(UDP_TAG, "UDP client is not running, cannot pause");}
}/*** @brief 恢复UDP任务**/
void app_udp_client_resume(void)
{if (udp_event_group != NULL && udp_client_state == UDP_CLIENT_PAUSED){xEventGroupSetBits(udp_event_group, UDP_RESUME_BIT);ESP_LOGI(UDP_TAG, "UDP client resume requested");}else{ESP_LOGW(UDP_TAG, "UDP client is not paused, cannot resume");}
}/*** @brief 结束UDP任务,将任务删除* 注意:该函数不能在用户自定义回调函数里调用,会强制删除任务**/
void app_udp_client_stop(void)
{if (udp_event_group != NULL && udp_client_state != UDP_CLIENT_STOPPED){xEventGroupSetBits(udp_event_group, UDP_STOP_BIT);ESP_LOGI(UDP_TAG, "UDP client stop requested");// 等待任务结束if (udp_task_handle != NULL){// 等待最多5秒让任务自然结束for (int i = 0; i < 50 && udp_task_handle != NULL; i++){vTaskDelay(100 / portTICK_PERIOD_MS);}// 如果任务仍然存在,强制删除if (udp_task_handle != NULL){vTaskDelete(udp_task_handle);udp_task_handle = NULL;ESP_LOGW(UDP_TAG, "UDP client task force deleted");}}// 清理事件组if (udp_event_group) {vEventGroupDelete(udp_event_group);udp_event_group = NULL;}// 清理队列if (ui_receive_ip_queue) {vQueueDelete(ui_receive_ip_queue);ui_receive_ip_queue = NULL;}udp_client_state = UDP_CLIENT_STOPPED;}else{ESP_LOGW(UDP_TAG, "UDP client is already stopped");}
}/*** @brief 获取UDP任务的状态** @return udp_client_state_t UDP任务状态枚举类型*/
udp_client_state_t udp_client_get_state(void)
{return udp_client_state;
}
六、结语
这个UDP自动发现结合HTTP client实现远程管理的功能,后续会更新 OTA 升级。