前言
一般提到esp32远程通信首先想到的是mqtt协议,那为什么会有这篇文章,因为在一些需要实时性控制的场景下,我认为mqtt的延迟可能会太大而不适合。之前我做过一个基于mqtt远程控制小车的小玩意,但我发现延迟太大,可能是我用的是阿里云的公共物联网实例的缘故,也有可能是本身它就会慢一些,当然我也没本地使用开源的mqtt服务端去验证,总之接下来将呈现我学习基于socket的esp32远程通信的过程,有什么不对还请指正。
socket的介绍
“Socket”(套接字)是一种计算机网络编程中的通信接口,用于在不同设备或进程之间建立通信。它允许程序在不同的网络节点之间发送和接收数据,是网络编程的基础组件之一。它是以tcp,udp为基础。
服务端
一、与socket相关的函数
创建Socket:使用socket()函数。
int socket(int domain, int type, int protocol);
domain
:指定协议族(地址族),常用的有:
AF_INET
:IPv4协议族。
AF_INET6
:IPv6协议族。
AF_UNIX
:本地通信(仅限于同一台机器)。
type
:指定Socket的类型,常用的有:
SOCK_STREAM
:流式Socket(TCP)。
SOCK_DGRAM
:数据报Socket(UDP)。
SOCK_RAW
:原始Socket。
protocol
:指定协议。通常设置为0
,表示使用默认协议。例如:若
type
是SOCK_STREAM
,默认协议是 TCP。若
type
是SOCK_DGRAM
,默认协议是 UDP。返回值:
成功时返回一个非负整数,表示Socket文件描述符(类似于文件句柄)。
失败时返回
-1
,并设置errno
。
绑定地址和端口:使用bind()函数。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd
:Socket文件描述符,由socket()
函数创建。
addr
:指向sockaddr
结构的指针,包含要绑定的地址和端口信息。
addrlen
:addr
结构的大小,通常使用sizeof()
来获取。
监听连接:使用listen()函数。
int listen(int sockfd, int backlog);
sockfd
:Socket文件描述符,由socket()
函数创建并绑定到本地地址和端口。
backlog
:指定未完成连接队列的最大长度。它是一个建议值,实际长度由系统决定。
接受连接:使用accept()函数。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockfd
: 监听Socket的文件描述符,由socket()
创建并经过listen()
设置为监听状态。
addr
: 指向sockaddr
结构的指针,用于存储发起连接的客户端的地址信息。如果不需要 客户端的地址信息,可以传入NULL
。
addrlen
:指向一个socklen_t
类型的指针,表示addr
结构的大小。在调用accept()
之前,需 要将其设置为addr
结构的大小(如sizeof(struct sockaddr_in)
)。调用后,addrlen
会被更新为实际存储的地址信息的大小。
数据通信:使用send()和recv()函数。
send():
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
sockfd
:Socket文件描述符,标识要发送数据的Socket。
buf
: 指向要发送的数据的缓冲区。
len
: 要发送的数据的长度(以字节为单位)。
flags
: 控制发送行为的标志。通常设置为0
,表示正常发送。返回值
成功时返回实际发送的字节数(可能小于请求的字节数)。
失败时返回
-1
,并设置errno
。recv():
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
sockfd
: Socket文件描述符,标识要接收数据的Socket。
buf
: 指向存储接收数据的缓冲区。
len
: 缓冲区的大小(以字节为单位)。
flags
: 控制接收行为的标志。通常设置为0
,表示正常接收。返回值
成功时返回实际接收到的字节数。如果对端关闭了连接,返回
0
。失败时返回-1
, 并设置errno
。
关闭连接:使用close()函数。
int close(int fd);
fd
:要关闭的文件描述符(对于Socket编程,是Socket文件描述符)。返回值:
成功时返回
0
。失败时返回-1
,并设置errno
。
二、与多线程相关的函数
这里用到的是pthread,它在pthread.h里。
创建子线程:使用pthread_create()函数。
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
thread:
指向线程标识符的指针。线程创建后,线程的 ID 会存储在这个变量中。pthread_t
是线程的唯一标识符,通常用于后续的线程操作(如等待线程结束)。
attr:
指定线程属性的指针。如果不需要设置特殊属性,可以传入NULL
,使用默认属 性。线程属性包括线程的堆栈大小、分离状态等。
void *(*start_routine)(void*):
最主要的就是这一坨!
指定线程的入口函数。这是一个函数指针,指向线程开始执行的函数。该函数必须 接受一个void*
类型的参数,并返回一个void*
类型的值。
arg:
传递给线程入口函数的参数。如果不需要传递参数,可以传入NULL
。如果需要传 递多个参数,可以将它们封装到一个结构体中,并将结构体的指针传递给线程函数。
分离子线程:使用pthread_detach()函数。(作用:如果线程数量较多,且主线程不需要等待每个线程结束,将线程设置为分离状态可以避免资源泄漏。)
int pthread_detach(pthread_t thread);
pthread_t thread:
需要设置为分离状态的线程的 ID。这个 ID 是通过pthread_create()
创建线程时返回的。返回值
如果操作成功,
pthread_detach()
返回0
。如果失败,返回一个错误 码(非零值)
关闭线程:使用close()函数。(同上)
三、与管道相关的函数
创建管道:使用pipe()函数。
int pipe(int pipefds[2]);
pipefds:
一个整型数组,用于存储管道的两个文件描述符(file descriptors)。pipefds[0]
是管道的读端,pipefds[1]
是管道的写端。返回值
如果成功,返回
0,
如果失败,返回-1
。
关闭管道:使用close()函数。(同上)
四、服务端代码实现
服务端对于只有单个客户端的socket通信。
用到的结构体sockaddr_in:
struct sockaddr_in { uint8_t sin_len; // 地址结构长度(可选,具体依赖于平台) sa_family_t sin_family; // 地址族,必须是 AF_INET uint16_t sin_port; // 网络字节序的端口号 struct in_addr sin_addr; // IPv4 地址 char sin_zero[8]; // 填充字段,用于对齐 };
代码如下:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> // POSIX 系统调用库,用于 close 等函数 #include <arpa/inet.h> // 网络地址操作库,用于 inet_ntoa 等函数 int main() { int server_fd, client_fd; // 服务器和客户端的文件描述符 struct sockaddr_in server_addr, client_addr; // 服务器和客户端的地址结构 socklen_t addr_len = sizeof(client_addr); // 客户端地址结构的长度 char buffer[1024]; // 数据缓冲区 // 创建Socket server_fd = socket(AF_INET, SOCK_STREAM, 0); // 创建一个 TCP 套接字 if (server_fd == -1) { // 如果创建失败 perror("Socket creation failed"); // 打印错误信息 exit(EXIT_FAILURE); // 退出程序 } // 配置服务器地址和端口 memset(&server_addr, 0, sizeof(server_addr)); // 将服务器地址结构清零 server_addr.sin_family = AF_INET; // 设置地址族为 IPv4 server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网络接口 server_addr.sin_port = htons(8080); // 设置监听端口为 12345(网络字节序) // 绑定Socket if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) { perror("Bind failed"); // 如果绑定失败 close(server_fd); // 关闭套接字 exit(EXIT_FAILURE); // 退出程序 } // 开始监听 if (listen(server_fd, 5) == -1) { // 开始监听,允许的最大连接队列为 5 perror("Listen failed"); // 如果监听失败 close(server_fd); // 关闭套接字 exit(EXIT_FAILURE); // 退出程序 } printf("Server is listening on port 8080...\n"); // 打印服务器正在监听的信息 // 接受客户端连接 client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addr_len); // 接受客户端连接 if (client_fd == -1) { // 如果接受失败 perror("Accept failed"); // 打印错误信息 close(server_fd); // 关闭服务器套接字 exit(EXIT_FAILURE); // 退出程序 } // 打印客户端的 IP 和端口 printf("Client connected: %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); // 接收客户端数据 memset(buffer, 0, sizeof(buffer)); // 清空缓冲区 if (recv(client_fd, buffer, sizeof(buffer), 0) == -1) { // 接收客户端发送的数据 perror("Receive failed"); // 如果接收失败 } else { printf("Received from client: %s\n", buffer); // 打印接收到的数据 } // 发送响应 const char* response = "Hello from server"; // 响应消息 if (send(client_fd, response, strlen(response), 0) == -1) { // 向客户端发送响应 perror("Send failed"); // 如果发送失败 } // 关闭连接 close(client_fd); // 关闭客户端连接 close(server_fd); // 关闭服务器套接字 return 0; }
但我们要相互通信,至少是有两个客户端的,一个是我们的esp32,一个是控制esp32的设备。所以我们需要使用多线程,对上面代码进行修改,增加多线程的实现。
相比上面代码的修改:
//1.定义两个变量用于区分两个不同的客户端(esp32和控制端)。 int A_fd; int B_fd; //2.创建多个socket,因为我们有多个客户端,并创建多个线程与多个客户端连接。 while(1){ //创建socket if ((client_socket = accept(server_fd, (struct sockaddr*)&address, (socklen_t*)&addrlen)) < 0) { perror("Accept failed"); exit(EXIT_FAILURE); }else{ //先从socket中读取一个数据,后续在esp32和控制端连接服务端时,发送一个字符用于区分,这里我用“A”表示esp32,“B”或其他字符来表是控制端。 read(client_socket, buffer, BUFFER_SIZE); if(strcmp(buffer,"A")==0){ A_fd=client_socket; printf("A Client connected.\n"); }else{ B_fd=client_socket; printf("B Client connected.\n"); } } } //创建线程,并将socket标识符作为参数传入线程处理函数(为了后面区分esp32和控制端) pthread_t thread_id; int* pclient_fd=malloc(sizeof(int)); *pclient_fd=client_socket; if(pthread_create(&thread_id,NULL,handle_client,pclient_fd)!=0){ perror("Thread creation failed"); }else{ pthread_detach(thread_id); } }
修改后的代码如下:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <pthread.h> #define PORT 8080 // 监听端口 #define BUFFER_SIZE 1024 // 缓冲区大小 int A_fd; int B_fd; //pthread调用的处理函数 void* handle_client(void* arg){ int client_fd = *(int*)arg; free(arg); char buffer[BUFFER_SIZE]; if(client_fd==A_fd){ while(1){ //..... //对esp32的数据处理 } close(pipefds[0]); }else{ while(1){ //..... //对控制端的数据处理 } close(pipefds[1]); } close(client_fd); return NULL; } int main() { int server_fd, client_socket; struct sockaddr_in address; int addrlen = sizeof(address); char buffer[BUFFER_SIZE] = {0}; if(pipe(pipefds)==-1){ perror("pippe error"); exit(EXIT_FAILURE); } // 创建Socket if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) { perror("Socket creation failed"); exit(EXIT_FAILURE); } // 绑定地址和端口 address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; // 本地所有IP地址 address.sin_port = htons(PORT); if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) { perror("Bind failed"); exit(EXIT_FAILURE); } // 开始监听 if (listen(server_fd, 3) < 0) { perror("Listen failed"); exit(EXIT_FAILURE); } printf("Server is listening on port %d...\n", PORT); while(1){ //创建socket if ((client_socket = accept(server_fd, (struct sockaddr*)&address, (socklen_t*)&addrlen)) < 0) { perror("Accept failed"); exit(EXIT_FAILURE); }else{ //先从socket中读取一个数据,后续在esp32和控制端连接服务端时,发送一个字符用于区分,这里我用“A”表示esp32,“B”或其他字符来表是控制端。 read(client_socket, buffer, BUFFER_SIZE); printf("Message from client: %s\n", buffer); if(strcmp(buffer,"A")==0){ A_fd=client_socket; printf("A Client connected.\n"); }else{ B_fd=client_socket; printf("B Client connected.\n"); } } //创建线程,并将socket标识符作为参数传入线程处理函数(为了后面区分esp32和控制端) pthread_t thread_id; int* pclient_fd=malloc(sizeof(int)); *pclient_fd=client_socket; if(pthread_create(&thread_id,NULL,handle_client,pclient_fd)!=0){ perror("Thread creation failed"); }else{ pthread_detach(thread_id); } } // 关闭连接 close(server_fd); return 0; }
继续完成数据传输部分,这里我只处理从控制端到esp32的数据,即数据只从控制端发往esp32,想实现消息传输可以使用全局变量,因为pthread创建的子线程是与主程序内存共享的,但要使用锁,以避免同时修改同一个变量导致的数据割裂的情况,但我这里图方便使用管道实现两个子线程间的数据传输。
最后完整的服务端代码及注释:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> // POSIX 系统调用 #include <arpa/inet.h> // 网络编程 #include <pthread.h> // 多线程支持 #define PORT 8080 // 监听端口 #define BUFFER_SIZE 1024 // 缓冲区大小 int A_fd; // 客户端 A 的文件描述符 int B_fd; // 客户端 B 的文件描述符 int pipefds[2]; // 管道文件描述符,用于中转数据 // 处理客户端的线程函数 void* handle_client(void* arg) { int client_fd = *(int*)arg; // 获取客户端的文件描述符 free(arg); // 释放传递的动态分配的内存 char buffer[BUFFER_SIZE]; // 数据缓冲区 // 处理客户端 esp32 if (client_fd == A_fd) { while (1) { memset(buffer, 0, BUFFER_SIZE); // 清空缓冲区 // 从管道读取数据 read(pipefds[0], buffer, sizeof(buffer) - 1); // 将数据发送给客户端 A send(client_fd, buffer, strlen(buffer), 0); // 如果客户端 A 发送 "q",则退出 if (strcmp(buffer, "q") == 0) { printf("%s Closed\n", "A client"); close(pipefds[0]); // 关闭管道读端 break; } } } // 处理客户端 控制端 else { while (1) { memset(buffer, 0, BUFFER_SIZE); // 清空缓冲区 // 接收客户端发送的数据 int bytes_received = recv(client_fd, buffer, BUFFER_SIZE, 0); // 将数据写入管道 write(pipefds[1], buffer, strlen(buffer)); // 如果客户端断开连接或发送失败 if (bytes_received <= 0) { printf("%s Closed\n", "B client"); close(pipefds[1]); // 关闭管道写端 break; } // 向客户端 B 发送确认消息 send(client_fd, "OK", strlen("OK"), 0); } } // 关闭客户端连接 close(client_fd); return NULL; } int main() { int server_fd, client_socket; // 服务器和客户端的文件描述符 struct sockaddr_in address; // 服务器地址结构 int addrlen = sizeof(address); char buffer[BUFFER_SIZE] = {0}; // 创建管道 if (pipe(pipefds) == -1) { perror("pipe error"); exit(EXIT_FAILURE); } // 创建 Socket if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) { perror("Socket creation failed"); exit(EXIT_FAILURE); } // 配置服务器地址和端口 address.sin_family = AF_INET; address.sin_addr.s_addr = INADDR_ANY; // 监听所有网络接口 address.sin_port = htons(PORT); // 设置监听端口 // 绑定 Socket if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) { perror("Bind failed"); exit(EXIT_FAILURE); } // 开始监听 if (listen(server_fd, 3) < 0) { perror("Listen failed"); exit(EXIT_FAILURE); } printf("Server is listening on port %d...\n", PORT); while (1) { // 接受客户端连接 if ((client_socket = accept(server_fd, (struct sockaddr*)&address, (socklen_t*)&addrlen)) < 0) { perror("Accept failed"); exit(EXIT_FAILURE); } else { // 读取客户端发送的标识信息 read(client_socket, buffer, BUFFER_SIZE); printf("Message from client: %s\n", buffer); // 根据标识信息判断是客户端 A 还是 B if (strcmp(buffer, "A") == 0) { A_fd = client_socket; printf("A Client connected.\n"); } else { B_fd = client_socket; printf("B Client connected.\n"); } } // 创建线程处理客户端 pthread_t thread_id; int* pclient_fd = malloc(sizeof(int)); // 动态分配内存以传递文件描述符 *pclient_fd = client_socket; if (pthread_create(&thread_id, NULL, handle_client, pclient_fd) != 0) { perror("Thread creation failed"); } else { pthread_detach(thread_id); // 分离线程,使其在完成后自动回收资源 } } // 关闭服务器 Socket close(server_fd); return 0; }
客户端
esp32端
#include <Arduino.h> #include <WiFi.h> WiFiClient wifiClient; // 创建一个 WiFi 客户端对象 String SSID = ""; // WiFi 网络的 SSID(名称) String password = ""; // WiFi 网络的密码 char *serverIp = ""; // 公网服务器的 IP 地址,如果是局域网部署就用内网地址 uint16_t serverPort = 8080; // 服务器的端口号 int wifiConnect(String, String); // 声明 WiFi 连接函数 void setup() { Serial.begin(9600); // 初始化串口通信,波特率为 9600 pinMode(2, OUTPUT); // 设置数字引脚 2 为输出模式(用于指示灯) // 尝试连接 WiFi if (wifiConnect(SSID, password)) { Serial.println("WiFi Connected"); // 如果连接成功,打印连接成功信息 // 尝试连接到服务器 if (wifiClient.connect(serverIp, serverPort)) { Serial.println("server Connected"); // 如果连接到服务器成功 wifiClient.println("A"); // 向服务器发送标识符 "A" digitalWrite(2, HIGH); // 点亮指示灯 } else { Serial.println("server Disconnected"); // 如果连接到服务器失败 while (1); // 进入死循环,程序停止 } } else { while (1); // 如果 WiFi 连接失败,进入死循环,程序停止 } } void loop() { // 检查是否仍然连接到服务器 if (wifiClient.connected()) { delay(1000); // 每秒检查一次 // 检查是否有数据从服务器可用 while (wifiClient.available()) { char c = wifiClient.read(); // 读取一个字符 Serial.println(c); // 打印到串口监视器 } } else { Serial.println("Connection lost. Reconnecting..."); // 如果连接丢失 digitalWrite(2, LOW); // 熄灭指示灯 // 尝试重新连接到服务器 if (wifiClient.connect(serverIp, serverPort)) { wifiClient.println("A"); // 重新连接后发送标识符 "A" Serial.println("Reconnected to server"); // 打印重新连接成功信息 digitalWrite(2, HIGH); // 点亮指示灯 } } } // WiFi 连接函数 int wifiConnect(String SSID, String password) { WiFi.begin(SSID, password); // 开始连接到指定的 WiFi 网络 Serial.printf("WiFiConnect"); // 打印连接信息 for (int i = 0; i <= 4; i++) { sleep(1); // 等待 1 秒 Serial.printf("."); // 打印一个点,表示正在尝试连接 if (WiFi.status() == WL_CONNECTED) // 检查是否连接成功 return 1; // 如果连接成功,返回 1 } Serial.println(); // 打印换行 return 0; // 如果连接失败,返回 0 }
然后使用串口调试器查看
控制端
我没有写控制端程序,可以使用一般的socket通信工具测试,也可以使用电脑自带的telnet工具测试,在控制台下使用
telnet IP Prot
进行测试,先谁便输一个字符,用作上面在服务端提到的标识符以区别esp32与控制端
总结
.....(假装有内容)
最后祝大家学习进步、事业有成、生活美满。