π同学头像
ESP-IDF+vscode开发ESP32 联网篇第九讲——TCP通信封面图

ESP-IDF+vscode开发ESP32 联网篇第九讲——TCP通信

目录

前言

一、TCP 简介

1.1 TCP 通信流程

1.2 TCP 客户端和服务端

1.3 端口号

1.3.1 标准端口分类

1.3.2 端口号的显示绑定

1.4 各场景下 TCP 通信要求

1.4.1 AP 与 STA 通信

1.4.2 同一个AP 下的 STA 之间通信

1.4.3 不同 AP 下的 STA 之间通信

二、工程实现

2.1 TCP 客户端

2.1.1 tcp_client_connect:建立连接

2.1.2 tcp_sock_send:保证整块数据发送完成

2.1.3 tcp_sock_recv:带超时接收一次数据

2.1.4 select 与 recv 配合的作用

2.1.5 connect 背后的三次握手

2.2 TCP 服务端

2.2.1 tcp_server_create:创建监听

2.2.2 tcp_server_accept:接受客户端连接

2.2.3 TCP 保活参数配置

2.2.4 服务端任务

2.3 客户端与服务端实现的对应关系

三、结果展示

3.1 TCP 客户端

3.2 TCP 服务端

总结


前言

在前几篇文章中,我们完成了 ESP32 的 Wi-Fi 联网基础,包括 STA 模式连接路由器、SoftAP 模式创建热点,以及设备获取 IP 地址、网关和子网掩码等网络参数的过程。

有了网络层的基础后,设备之间还缺少一种稳定、可靠的数据交互方式。例如,ESP32 需要把传感器数据发送给 PC 上位机,或者连接云平台、MQTT Broker 时,通常都会使用 TCP 协议。

本文将在 ESP-IDF 环境下实现 TCP 通信。文章先介绍 TCP 的基本概念、通信流程、客户端与服务端的区别,以及 IP 地址和端口号的作用;随后通过一个 ESP32-C6 作为 TCP 客户端的工程,演示如何连接手机或电脑上的 TCP 服务端,并完成数据收发。

通过本文,你可以理解 TCP 通信的基本模型,并掌握在 ESP32 上创建 Socket、连接服务器、发送和接收数据的基本流程。


一、TCP 简介

TCP(Transmission Control Protocol,传输控制协议)是一种面向连接、可靠传输、基于字节流的传输层协议。它工作在 IP 协议之上、应用层协议之下,负责在两台设备的应用程序之间建立可靠的通信通道。

TCP 的核心目标是:即使底层网络存在丢包、乱序、延迟等问题,也能尽量保证应用程序收到的数据是完整、有序、不重复的。因此,HTTP、HTTPS、MQTT、SSH、文件传输等对数据可靠性要求较高的应用,通常都建立在 TCP 之上。

需要注意的是,TCP 保证的是“数据可靠送达”,并不保证低延迟。在无线网络信号较差、丢包率较高时,TCP 可能因为频繁重传而使通信变慢。

特性说明
面向连接通信前需要通过“三次握手”建立连接,通信结束后通过“四次挥手”断开连接
可靠传输通过序列号、确认应答(ACK)、超时重传和校验和,保证数据不丢失、不重复、不出错。
按序到达即使数据包经过不同路径、乱序到达,接收端也会根据序号重新排序。
字节流TCP 不保留应用层消息边界,数据被看作连续字节流;应用层需要自行定义帧头、长度或分隔符来拆分数据。
全双工连接建立后,双方都可以同时发送和接收数据。
流量控制接收端通过滑动窗口告知发送端自己还能接收多少数据,避免发送过快导致接收缓冲区溢出

拥塞控制

发送端根据网络状况动态调整发送速率,避免大量数据同时进入网络造成拥塞

1.1 TCP 通信流程

一次典型的 TCP 通信过程可以分为四个阶段:

  1. 建立连接:客户端向服务端发起连接请求,经过三次握手后,双方建立 TCP 连接。

  2. 数据传输:应用程序的数据被切分为 TCP 报文段,附带序列号后交给 IP 层发送;接收方收到数据后返回 ACK 确认。

  3. 丢包与重传:如果发送方在一定时间内没有收到对应的 ACK,会认为数据可能丢失,并触发重传。

  4. 关闭连接:数据传输完成后,双方通过四次挥手释放连接资源。

三次握手的过程可以简单理解为:

  • 客户端发送 `SYN`,表示请求建立连接;
  • 服务端回复 `SYN + ACK`,表示同意建立连接;
  • 客户端再回复 `ACK`,连接正式建立。

此后,双方才可以开始传输应用数据。

1.2 TCP 客户端和服务端

TCP 通信通常分为客户端和服务端两种角色。客户端主动发起连接,服务端监听指定端口并等待客户端接入。

对比项TCP 客户端TCP 服务端
连接发起方主动向目标 IP 和端口发起连接被动监听端口,等待别人连接
地址要求需要知道服务端的 IP 和端口号需要固定或可被发现的 IP 和监听端口
典型函数流程socket() → connect() → send()/recv()socket() → bind() → listen() → accept() → send()/recv()
连接数量通常维护 1 个或少量出站连接可同时接受多个客户端连接
典型场景ESP32 连接云平台、MQTT Broker、PC 上位机ESP32 作为本地配置服务器,等待手机/PC 接入
IP 变化影响只要能访问服务端即可,自身 IP 变化影响较小若设备 IP 变化,客户端可能找不到它

客户端是主动连接方。在连接之前,它必须知道 TCP 服务端的 IP 地址和端口号。连接建立后,双方都可以收发数据;因此,“客户端”只表示它发起了这次连接,并不代表它只能发送数据。

服务端则是被动监听方。它需要先绑定一个固定端口,然后进入监听状态。当客户端连接成功后,服务端会得到一个新的通信 Socket,并通过该 Socket 与客户端收发数据。

1.3 端口号

在 TCP 通信中,IP 地址和端口号需要配合使用:

  • IP 地址:定位网络中的哪台设备,相当于“门牌号”;
  • 端口号:定位该设备上的哪个具体服务或进程,相当于“房间号”。

两者组合在一起,构成一个 Socket 地址,用于唯一标识一次 TCP 通信的端点。

一台主机上通常同时运行多个网络程序,它们可能共用同一个网卡和 IP 地址。当数据包到达主机后,操作系统需要根据端口号判断应该把数据交给哪个进程。因此,端口号本质上是操作系统进行进程间网络数据分发的关键标识。

端口号的组成与基本属性:

  • 数据大小:16 位无符号整数
  • 数值范围:0 ~ 65535
  • 独立性:TCP 和 UDP 的端口空间是完全独立的。TCP 80 和 UDP 80 是两个互不干扰的端口。
  • 作用域:端口号仅在单台主机内有意义,不同主机可以使用相同的端口号。

1.3.1 标准端口分类

区间名称特点典型服务(十进制)
0–1023知名端口需 IANA(互联网号码分配机构) 分配,通常需 root/管理员权限绑定HTTP(80), HTTPS(443), SSH(22), DNS(53), FTP(21), SMTP(25)
1024–49151注册端口可由用户/企业申请注册,普通用户可绑定MySQL(3306), Redis(6379), Tomcat(8080), MQTT(1883)
49152–65535动态/私有端口操作系统自动分配给客户端临时连接,不应被服务端固定绑定浏览器发起请求时的源端口、NAT 映射端口

高频使用的已规定端口号:

端口协议服务备注
22TCPSSH远程安全登录
53TCP/UDPDNS域名解析(查询用UDP,区域传输用TCP)
80TCPHTTPWeb 明文传输
443TCPHTTPSWeb 加密传输
1883 / 8883TCPMQTTIoT 通信 / TLS 加密版
3306TCPMySQL数据库
5432TCPPostgreSQL数据库
6379TCPRedis缓存服务
8080 / 8443TCPHTTP 替代开发/代理常用,避免与生产 80/443 冲突

在 ESP32 自建局域网服务时,建议优先使用 `1024 ~ 49151` 区间的端口,避免随意占用 80、443、22 等知名端口。例如,可以使用 `8080`、`8888`、`9000` 等端口作为 TCP 服务端口。

端口 `0` 通常不作为普通通信端口使用;但在编程中,`bind()` 时指定端口 `0`,表示让操作系统自动分配一个可用临时端口,常用于测试或客户端场景。

1.3.2 端口号的显示绑定

TCP 服务端通常必须显式绑定一个固定端口号,这样客户端才知道应该连接哪个端口。例如,ESP32 作为 TCP 服务端时,可以绑定 `8080` 端口,手机或 PC 客户端连接 `ESP32 的IP:8080`。

TCP 客户端通常不需要手动绑定端口。调用 `connect()` 时,操作系统会自动为该连接分配一个临时源端口。连接关闭后,该端口通常会被系统回收并可能重新分配。

角色是否需显示绑定端口来源端口特性
TCP 服务端✅ 必须开发者硬编码或配置固定、众所周知、长期有效
TCP 客户端❌ 通常不需要内核自动分配临时、随机、连接关闭后回收

不过,客户端在特殊场景下也可以显式绑定端口,例如:

  • 防火墙只允许特定源端口出站;
  •  设备有多个网络接口,需要指定从某个 IP 地址发起连接;
  • 调试或抓包时,希望固定源端口,便于 Wireshark 过滤和日志分析。

1.4 各场景下 TCP 通信要求

下面从 ESP32 常见的 Wi-Fi 组网场景出发,分析 TCP 客户端和服务端如何建立通信。

1.4.1 AP 与 STA 通信

在这种场景中,通常将 AP 设备作为 TCP 服务端,STA 设备作为 TCP 客户端。

通信前,STA 必须先连接到 AP 热点。连接成功后,STA 会从 AP 或 DHCP 服务器获取自身 IP 地址、子网掩码、网关等信息,同时可以知道 AP 所在网段。

此时,如果 AP 上运行了一个 TCP 服务端,并且监听了某个端口,STA 只要知道该端口号,就可以向 AP 的 IP 地址发起 TCP 连接。

需要注意的是,端口号通常无法通过 Wi-Fi 连接过程自动获取,必须由 AP 端通过串口日志、配网页面、mDNS、BLE 配网或其他自定义方式告知 STA。

1.4.2 同一个AP 下的 STA 之间通信

多个 STA 连接到同一个 AP 时,它们通常处于同一个局域网内。每个 STA 都会获得自己的 IP 地址,但默认情况下并不知道其他 STA 的 IP 地址,也不知道对方是否开启了 TCP 服务。

因此,如果希望 STA-A 连接 STA-B,STA-B 必须作为 TCP 服务端监听端口,并把自身的 IP 地址和端口号告知 STA-A。

1.4.3 不同 AP 下的 STA 之间通信

如果多个 STA 连接的是不同 AP,是否能直接通信,取决于这些 AP 是否处于同一个可路由的网络中。

  • 如果多个 AP 连接到同一路由器,并且分配到同一子网,设备之间通常可以直接通过局域网 IP 通信;
  • 如果多个 AP 处于不同子网,或者中间存在路由器隔离、AP 隔离、防火墙等限制,TCP 连接可能会失败。

当设备处于不同内网,且没有公网 IP 时,常见解决方案包括:

  • 内网穿透:使用 frp、ngrok、Cloudflare Tunnel、Tailscale、ZeroTier 等工具建立隧道;
  • 公网 IP + 端口转发:在路由器上配置端口转发,并配合 DDNS 使用;
  • P2P 打洞:借助 STUN、TURN、ICE 等机制尝试建立直连;
  • IPv6:如果双方都具备可用的 IPv6 地址,并且防火墙允许,也可能实现直接通信。

对于 ESP32 入门项目,最简单的方式仍然是让设备连接同一个路由器,或者让一台设备作为 SoftAP、另一台设备作为 STA 接入该热点,从而构成可控的局域网环境。

二、工程实现

本章基于 ESP-IDF 与 lwIP 提供的 BSD Socket API,实现 TCP 客户端和 TCP 服务端的基础通信能力。工程实现分为两个模块:

  • TCP 客户端模块:负责主动连接服务器、发送数据和带超时接收数据;
  • TCP 服务端模块:负责创建监听端口、接受客户端连接,并在独立任务中管理多个客户端连接。

客户端与服务端共用同一组数据收发函数 `tcp_sock_send()` 和 `tcp_sock_recv()`。这样设计的目的是将“连接管理”和“数据传输”解耦:客户端连接、服务端 `accept()` 返回的连接,都可以使用相同的收发接口,避免重复实现。

整体调用关系如下:

TCP 客户端:
tcp_client_connect() → tcp_sock_send() / tcp_sock_recv()

TCP 服务端:
tcp_server_create() → tcp_server_accept() → tcp_sock_send() / tcp_sock_recv()

以上完整工程可以进入我的Gitee中下载《https://gitee.com/pai-schoolmate/esp32_project.git》

本工程路径:esp32_project-main\function/TCP

2.1 TCP 客户端

客户端 API 位于 `components/WIFI/tcp.c`,包含三个核心函数:

  • tcp_client_connect():创建 TCP 套接字并连接指定服务器
  • tcp_sock_send():将指定长度的数据完整发送出去
  • tcp_sock_recv():在限定时间内接收一次数据

这三个函数均直接使用 lwIP 提供的 BSD Socket 接口,不依赖 MQTT、HTTP 等更高层协议组件,适合作为 TCP 通信的基础封装。

2.1.1 tcp_client_connect:建立连接

函数原型如下:

int tcp_client_connect(uint16_t port, const char *ip_address);

该函数根据服务器 IP 地址和端口号创建 TCP 套接字,并调用 `connect()` 发起连接。连接成功后,会将套接字描述符、服务器 IP 和端口写入全局结构体 `client_arg`,供后续发送和接收函数使用。

参数说明:

  • port:服务器端口号,使用主机字节序传入。函数内部通过 `htons()` 转换为网络字节序。
  • ip_address:服务器的点分十进制 IPv4 地址,例如 `"192.168.1.100"`。该函数只支持数字 IP 地址,不进行域名解析。
  • 返回值:连接成功时返回套接字描述符,值大于等于 0;IP 地址非法、套接字创建失败或连接失败时返回 `-1`。

需要注意的是,`connect()` 默认是阻塞调用。只有 TCP 三次握手完成、连接被拒绝或发生错误后,函数才会返回。当前实现没有单独设置连接超时,实际等待时间由 lwIP 的 SYN 重传策略决定。

该函数的主要执行流程如下:

步骤代码作用失败处理

1

初始化 `sockaddr_in

填写协议族、端口,并将端口转换成网络字节序

端口合法性由上层保证

2

调用 `inet_pton()` 

将点分十进制 IP 字符串转换为二进制 IPv4 地址

返回 `-1`,不创建套接字

3

调用 `socket()`

创建 `AF_INET`、`SOCK_STREAM` 类型的 TCP 套接字

返回 -1

4

调用 `connect()`

向服务器发起 TCP 连接,触发三次握手

关闭套接字后返回 `-1`

5

更新 `client_arg` 

保存套接字、服务器 IP 和端口

无

需要特别注意的是,`tcp_client_connect()` 没有检查 `client_arg.sock` 中是否已经存在有效连接。如果连续调用该函数而不先关闭旧套接字,旧连接的文件描述符将无法释放,造成套接字泄漏。因此,重新连接前应先关闭旧连接。

2.1.2 tcp_sock_send:保证整块数据发送完成

函数原型如下:

int tcp_sock_send(int sock, const void *data, size_t len);

该函数用于在已建立的 TCP 连接上发送 `len` 字节数据。由于 `send()` 并不保证一次调用就能把全部数据写入发送缓冲区,因此函数内部采用循环发送的方式,直到所有数据发送完成,或发生错误。

参数说明:

  • sock:已连接的套接字描述符。客户端调用 `tcp_client_connect()` 得到的套接字,或服务端通过 `accept()` 得到的连接套接字均可使用。
  • data:待发送数据的首地址。函数按二进制数据处理,不要求数据以 `'\0'` 结尾。
  • len:待发送数据的字节数,允许为 0。
  • 返回值:发送成功时返回实际发送的字节数,正常情况下等于 `len`;参数非法、连接断开或发送出错时返回 `-1`。

这里必须使用循环的原因是:TCP 是字节流协议,`send()` 的返回值只表示本次成功写入协议栈发送缓冲区的字节数。当发送缓冲区剩余空间不足时,`send()` 可能只发送部分数据,甚至阻塞等待缓冲区可用。因此,只有循环判断剩余长度,才能保证应用层期望的整块数据被完整发出。

不同情况下的处理方式如下:

场景函数行为调用方应对
正常发送循环发送,直到累计发送长度等于 `len`按成功处理
发送缓冲区满阻塞在 send(),等窗口/缓冲区可写任务会被挂起,不影响其他任务
对端关闭send() 返回 -1(如 EPIPE / ENOTCONN)close 后重新 connect
发送中途失败返回 -1,已发出的字节数不确定按“连接已损坏”处理,不要续发
data == NULL直接返回 -1检查参数
len == 0不调用 send,返回 0通常视为成功

需要注意的是,如果发送过程中发生错误,已经写入协议栈的数据无法撤回。此时连接状态已经不可靠,调用方应关闭当前套接字,并在必要时重新建立连接。

2.1.3 tcp_sock_recv:带超时接收一次数据

函数原型如下:

int tcp_sock_recv(int sock, void *buf, size_t len, int timeout_ms);

该函数用于在已建立的 TCP 连接上接收一次数据,最多等待 `timeout_ms` 毫秒。函数内部先通过 `select()` 判断套接字是否可读,只有确认有数据到达后,才调用 `recv()` 读取数据。

参数说明:

  • sock:已连接的套接字描述符。
  • buf:接收缓冲区首地址。
  • len:接收缓冲区容量,必须大于 0。如果后续需要按 C 字符串处理,应传入 `sizeof(buf) - 1`,为字符串结束符预留空间。
  • timeout_ms:等待数据的超时时间,单位为毫秒。`0` 表示非阻塞查询。
  • 返回值:大于 0 表示实际接收到的字节数;等于 0 表示超时,连接仍然正常;小于 0 表示参数非法、对端关闭连接或发生错误。

该函数不会自动在缓冲区末尾添加 `'\0'`。同时,由于 TCP 不保留应用层消息边界,一次 `recv()` 既不保证填满缓冲区,也不保证一次接收正好对应对端的一次 `send()`。因此,如果应用层需要传输具有固定格式的数据,应在协议中自行加入长度字段、帧头或结束符。

2.1.4 select 与 recv 配合的作用

如果直接使用阻塞方式的 `recv()`,当接收缓冲区中没有数据时,当前任务会一直阻塞在 `recv()` 内部,直到对端发送数据、关闭连接或发生错误。这在需要超时控制或多连接管理的场景中并不合适。

`select()` 的作用是先等待套接字变为可读,再调用 `recv()`。这样可以避免任务长时间阻塞在 `recv()` 中,同时保留阻塞等待的能力。

其优势主要体现在以下几个方面:

  • 超时可控:`timeout_ms = 0` 时立即返回,适合轮询;`timeout_ms > 0` 时最多等待指定时间,任务不会被永久挂起。
  • 支持多路复用:服务端可以通过一次 `select()` 同时监视监听套接字和多个客户端套接字,避免为每个连接单独创建任务。
  • 降低 CPU 占用:没有数据时,任务阻塞在 `select()` 中,而不是在循环中反复调用非阻塞 `recv()`。
  • 区分超时与断开:`select()` 返回 0 表示等待超时,连接不一定异常;`recv()` 返回 0 表示对端已经正常关闭连接。两者的含义不同,必须分别处理。

两个函数的返回值含义如下:

调用返回 0 的含义返回 -1 的含义返回正数的含义
select()等待超时,集合里没有就绪描述符出错(EBADF、EINVAL 等)就绪描述符个数
recv()对端已关闭连接(收到 FIN)出错(ECONNRESET 等)实际收到的字节数

在本工程中,客户端接收主循环可以使用较长的超时时间,例如 `1000000 ms`,表示最多等待 1000 秒;一旦收到数据则立即返回。服务端任务则使用 `timeout_ms = 0` 的非阻塞方式,逐个查询已连接客户端是否有数据。

2.1.5 connect 背后的三次握手

从应用层看,客户端只调用了一次 `connect()`;但在协议栈内部,lwIP 会完成 TCP 三次握手过程。

三次握手的目的不是简单地“建立联系”,而是让通信双方确认彼此的发送和接收能力,并同步各自的初始序列号(ISN),为后续的可靠传输建立基础。

具体过程如下:
  1. 创建套接字:调用 `socket(AF_INET, SOCK_STREAM, 0)` 创建 TCP 套接字,此时套接字处于 `CLOSED` 状态。
  2. 客户端发送 SYN:调用 `connect()` 后,协议栈选择一个本地临时端口,与服务器 IP 和端口组成四元组,并发送 `SYN` 报文。客户端进入 `SYN_SENT` 状态,`connect()` 开始阻塞等待。
  3. 服务器回复 SYN+ACK:服务器收到 `SYN` 后,协议栈创建对应的连接控制块,并回复 `SYN+ACK` 报文。此时服务器端的连接处于 `SYN_RCVD` 状态,并进入未完成连接队列。
  4. 客户端发送 ACK:客户端收到 `SYN+ACK` 后,发送 `ACK` 报文,进入 `ESTABLISHED` 状态。此时 `connect()` 返回 0,表示连接建立成功。
  5. 服务器进入 ESTABLISHED:服务器收到最后的 `ACK` 后,连接从半连接队列转移到已完成连接队列。后续调用 `accept()` 时,即可从该队列中取出这条连接。

三次握手分别确认了以下信息:

  • 第一次 `SYN`:服务器确认客户端具备发送能力,并获取客户端的初始序列号。
  • 第二次 `SYN+ACK`:客户端确认服务器具备接收和发送能力,并获取服务器的初始序列号。
  • 第三次 `ACK`:服务器确认客户端已经收到自己的 `SYN+ACK`,双方序列号同步完成。

此外,三次握手还能避免历史残留的旧 `SYN` 报文误建立连接。只有携带正确确认信息的 `ACK` 才能完成握手,从而降低旧连接报文对当前通信的干扰。

2.2 TCP 服务端

服务端实现分为两个层次:

  • tcp_server_create()`:创建监听套接字,完成绑定和监听;
  • tcp_server_accept()`:从监听套接字中接受一个已经完成三次握手的客户端连接。

这两个函数都不直接处理业务数据。实际的数据收发仍然由 `tcp_sock_send()` 和 `tcp_sock_recv()` 完成。这样拆分后,监听、接入和数据传输可以分别放在不同的任务和错误处理流程中,结构更加清晰。

2.2.1 tcp_server_create:创建监听

函数原型如下:

int tcp_server_create(uint16_t port, uint8_t backlog);

该函数创建一个 TCP 监听套接字,将其绑定到本机所有网卡的指定端口,并进入被动监听状态。函数本身不会阻塞,也不会接受客户端连接,完成配置后立即返回。

参数说明:

  • port:监听端口号,使用主机字节序,有效范围为 1 ~ 65535。传入 0 会被直接拒绝,避免使用随机端口导致客户端无法预期连接地址。
  • backlog:已完成三次握手、等待 `accept()` 处理的连接队列长度。本工程中将该值限制在 1 ~ 8。
  • 返回值:成功时返回监听套接字描述符;端口非法、套接字创建失败、`setsockopt()` 失败、`bind()` 失败或 `listen()` 失败时返回 `-1`。

该函数的执行流程如下:

步骤调用作用失败时的处理
1port == 0 检查拒绝随机端口,保证客户端可预期直接返回 -1
2memset + sin_len + sin_family + htons构造 IPv4 本机地址—
3socket(AF_INET, SOCK_STREAM, 0)创建 TCP 套接字返回 -1
4setsockopt(SO_REUSEADDR)允许绑定 TIME_WAIT 状态的端口close + 返回 -1
5bind()把套接字与 0.0.0.0:port 关联close + 返回 -1
6listen(sock, backlog)转为 LISTEN,设置 accept 队列长度close + 返回 -1

服务端绑定地址使用 `INADDR_ANY`,即 `0.0.0.0`。它表示监听本机所有可用网络接口,并不是客户端实际连接时使用的目标 IP 地址。

对于 ESP32 来说,这种写法比较实用:

  • 在 STA 模式下,设备从路由器获取 IP 地址;
  • 在 SoftAP 模式下,设备通常作为网关,IP 地址为 `192.168.4.1`;
  • 绑定 `0.0.0.0` 后,无论设备处于哪种网络模式,服务端都可以正常接收连接,不需要在代码中判断当前网卡地址。

`SO_REUSEADDR` 的作用是允许新的 `bind()` 复用仍处于 `TIME_WAIT` 状态的端口。当 TCP 连接关闭后,主动关闭的一方会进入 `TIME_WAIT` 状态,并在一段时间内继续占用原来的四元组。如果没有设置该选项,服务端重启后立刻绑定同一个端口时,可能出现 `EADDRINUSE` 错误。设置该选项后,可以更方便地实现“服务重启后立即重新监听”。

2.2.2 tcp_server_accept:接受客户端连接

函数原型如下:

int tcp_server_accept(int listen_sock, int keepidle_time, struct server_args *server_arg)

该函数在监听套接字上等待一个已经完成三次握手的客户端连接,返回一个新的连接套接字,并可根据配置为该连接开启 TCP 保活机制。

参数说明:

  • listen_sock:由 `tcp_server_create()` 返回的监听套接字。如果该值小于 0,函数直接返回 `-1`。
  • keepidle_time:连接空闲多少秒后开始发送 TCP 保活探测报文。小于等于 0 表示不启用保活。
  • server_arg:连接信息输出结构体,可为 `NULL`。连接成功时,函数会写入监听套接字、客户端套接字、客户端 IP 和端口。
  • 返回值:成功时返回新的连接套接字描述符;监听套接字非法或 `accept()` 失败时返回 `-1`。

需要注意的是,`accept()` 默认是阻塞调用。只有当已完成连接队列中存在可用连接时,它才会返回。监听套接字本身不会被消耗,也不会转换为连接套接字;它继续保持 `LISTEN` 状态,可以继续接受后续客户端。

`accept()` 与三次握手的关系需要区分清楚:

  • 三次握手由 TCP 协议栈自动完成;
  • `accept()` 不参与握手过程;
  • 只有连接完成三次握手并进入已完成队列后,`accept()` 才会取出该连接;
  • `accept()` 返回的是一个新的连接套接字,用于与该客户端单独通信。

此外,如果 `server_arg` 中仍保存着上一次连接的信息,本次调用会直接覆盖旧值,但不会自动关闭旧连接。旧连接套接字的释放仍然由调用方负责。

2.2.3 TCP 保活参数配置

当客户端异常断电、Wi-Fi 断开或网络中间设备静默丢弃连接时,服务端可能长时间无法感知连接已经失效。TCP 保活机制可以通过周期性探测报文检测这种“半打开连接”。

本工程在 `tcp_server_accept()` 中配置了以下四个选项:

调用leveloptname值效果
setsockopt(client_sock, ...)SOL_SOCKETSO_KEEPALIVE1打开 TCP 保活总开关
setsockopt(client_sock, ...)IPPROTO_TCPTCP_KEEPIDLEkeepidle_time连接空闲多少秒后发第一个探测
setsockopt(client_sock, ...)IPPROTO_TCPTCP_KEEPINTVL2两次探测之间间隔 2 秒
setsockopt(client_sock, ...)IPPROTO_TCPTCP_KEEPCNT3连续 3 次探测失败后判定对端掉线

这四个参数是一个整体:`SO_KEEPALIVE` 负责开启保活功能,其余三个参数决定探测的起始时间、探测间隔和失败判定条件。

以 `keepidle_time = 10` 为例,连接的大致检测过程为:

空闲 10 秒
→ 第 1 次探测
→ 等待 2 秒
→ 第 2 次探测
→ 等待 2 秒
→ 第 3 次探测
→ 等待 2 秒
→ 判定连接异常

也就是说,在连续三次探测均失败后,协议栈大约会在 16 秒后将该连接标记为异常。

2.2.4 服务端任务

`tcp_server_create()` 只负责建立监听,`tcp_server_accept()` 只负责接入一个客户端。真正的持续服务由 `tcp_server_task` 完成。

该任务的基本逻辑是:

  1. 每 100 ms 使用 `select()` 检查监听套接字是否有新连接;
  2. 如果有新连接,调用 `tcp_server_accept()` 接入客户端;
  3. 遍历当前连接表,使用 `timeout_ms = 0` 的 `tcp_sock_recv()` 非阻塞查询每个客户端;
  4. 如果某个客户端有数据,则读取并处理;
  5. 如果没有数据,则继续检查下一个客户端;
  6. 如果连接异常或对端关闭,则关闭对应套接字并从连接表中移除。

这种设计有几个关键的工程取舍:

  • 为什么先用 `select()` 再 `accept()`:`accept()` 是阻塞调用。如果直接放在主循环中,没有新连接时任务会一直阻塞,无法处理已经连接客户端的数据。先用 `select()` 设置 100 ms 超时,可以确认是否有连接排队,再决定是否调用 `accept()`。
  • 为什么限制连接表长度:lwIP 可用的 Socket 数量有限。监听套接字占用一个,客户端模式可能占用一个,剩余资源才能分配给服务端连接。当连接表已满时,应立即关闭新接入的连接,避免耗尽协议栈 Socket 资源。
  • 为什么接收数据使用 `timeout_ms = 0`:一个服务端任务需要同时服务多个客户端,不能在某个客户端上长时间等待。对每个在线客户端做一次非阻塞查询,有数据则处理,没有数据则继续检查下一个连接。
  • 为什么使用独立任务:`accept()` 和带超时的 `recv()` 都可能使调用任务阻塞。如果将这些操作放在控制台命令处理函数中,REPL 可能失去响应,影响调试和其他命令的执行。
  • 吞吐量边界:当前实现每 100 ms 轮询一次所有客户端,适合控制台演示和低速遥测场景。如果需要处理高速数据,应改为对所有客户端文件描述符统一执行 `select()`,或为每个客户端连接创建独立处理任务。

2.3 客户端与服务端实现的对应关系

客户端和服务端虽然角色不同,但底层都基于同一套 Socket 流程。二者的对应关系如下:

阶段

TCP 客户端

TCP 服务端

创建套接字

`socket()`

`socket()`

绑定端口

通常由系统自动分配临时端口

`bind()` 到固定端口

建立连接

`connect()` 主动发起连接

`listen()` 后等待连接

获取通信套接字

`connect()` 成功后的套接字

`accept()` 返回的新套接字

数据发送

`tcp_sock_send()`

`tcp_sock_send()`

数据接收

`tcp_sock_recv()`

`tcp_sock_recv()`

连接关闭

`close()`

分别关闭连接套接字和监听套接字

因此,客户端与服务端的本质区别不在于“谁只能发送、谁只能接收”,而在于连接的发起方式:客户端主动调用 `connect()`,服务端先监听端口,再通过 `accept()` 接入客户端。连接建立后,双方都可以通过各自的套接字进行全双工通信。

三、结果展示

3.1 TCP 客户端

首先,在手机端启动 TCP 服务端,监听地址为 10.251.158.7:7000。在 ESP32 控制台输入 tcp_connect 10.251.158.7 7000 后,串口输出连接成功提示,手机端同步显示客户端 10.79.106.59:53416 已连接,表明 ESP32 已成功接入手机端服务。

连接建立后,在 ESP32 控制台输入 client_send hello honer,客户端成功发送 11 字节数据,手机端接收区显示相同内容。随后,手机端发送 你好,esp32,ESP32 串口显示接收到 14 字节数据,内容与手机端发送的信息一致,完成了与手机端服务端的双向通信验证。

接着,在 PC 端启动 TCP 服务端,监听地址为 10.79.106.162:8000。在 ESP32 控制台输入 tcp_connect 10.79.106.162 8000 后,程序检测到已有连接,先断开与手机端服务器的连接,再连接 PC 端服务器。手机端显示原连接已断开,ESP32 则输出新服务器连接成功的日志,说明客户端完成了目标服务器切换。

连接 PC 端服务器后,输入 client_send hello AMD,ESP32 成功发送 9 字节数据,PC 端接收日志显示 hello AMD。随后,PC 端发送 你好,ESP32,ESP32 接收到对应的 14 字节数据,进一步验证了切换服务器后的双向通信功能

3.2 TCP 服务端

首先,在 ESP32 控制台执行 tcp_create 8080 5 5 命令。串口提示服务端创建成功,监听端口为 8080,最多支持 5 个客户端,并将保活空闲时间设置为 5 秒。电脑端连接成功后,服务端输出客户端地址 10.79.106.162:2525,并为其分配连接套接字 sock=56,保存至连接表的第 0 项。随后,电脑端连续发送两次“你好 ESP32 TCP server”,ESP32 均输出对应的接收日志,表明客户端到服务端的数据传输正常。

向客户端发送数据前,可执行 client_scan 查看当前在线连接。查询结果显示,电脑端对应的套接字描述符为 56,当前在线客户端数量为 1/5。这里需要区分连接表索引与套接字描述符:连接表中的第 0 项并不代表套接字为 0,因此执行 server_send hello AMD client 时,程序提示“套接字 0 不在线”;改为执行 server_send 56 hello AMD client 后,电脑端接收区成功显示两条 hello AMD client,与控制台中的两次发送操作一致。

接着,手机端连接同一服务端地址 10.79.106.59:8080。ESP32 检测到来自 10.79.106.157:41138 的新连接,为其分配套接字 sock=57,并保存至连接表的第 1 项。手机端连续发送两次“你好,esp32”,服务端均成功接收并打印,说明服务端能够在保留电脑端连接的同时,接入并处理另一个客户端的数据。

最后,在控制台执行 server_send 57 hello magic client,手机端接收区显示 hello magic client。结合电脑端的接收结果可以看出,服务端能够根据指定的套接字描述符,分别向不同客户端发送数据,实现多连接场景下的独立双向通信。本组截图验证了两个客户端的接入与收发功能,但未展示最大连接数、异常断线或保活超时检测的测试结果


总结

本次演示以 ESP32 作为 TCP 服务端,在 8080 端口监听,分别接入电脑和手机客户端,完成了多客户端双向通信验证。服务端能够记录客户端 IP、端口及套接字描述符,并正确接收两端发送的数据。通过 client_scan 查询在线连接后,可使用 server_send 指定套接字,向对应客户端发送消息。测试结果表明,两个客户端可以同时连接并独立收发数据。发送时应使用实际套接字描述符,而非连接表索引;本次演示尚未验证最大连接数与异常断线检测功能。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_58172114/article/details/167811572

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--