目录
2.1.2 tcp_sock_send:保证整块数据发送完成
2.2.2 tcp_server_accept:接受客户端连接
前言
在前几篇文章中,我们完成了 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 通信过程可以分为四个阶段:
-
建立连接:客户端向服务端发起连接请求,经过三次握手后,双方建立 TCP 连接。
-
数据传输:应用程序的数据被切分为 TCP 报文段,附带序列号后交给 IP 层发送;接收方收到数据后返回 ACK 确认。
-
丢包与重传:如果发送方在一定时间内没有收到对应的 ACK,会认为数据可能丢失,并触发重传。
-
关闭连接:数据传输完成后,双方通过四次挥手释放连接资源。
三次握手的过程可以简单理解为:
- 客户端发送 `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 映射端口 |
高频使用的已规定端口号:
| 端口 | 协议 | 服务 | 备注 |
|---|---|---|---|
| 22 | TCP | SSH | 远程安全登录 |
| 53 | TCP/UDP | DNS | 域名解析(查询用UDP,区域传输用TCP) |
| 80 | TCP | HTTP | Web 明文传输 |
| 443 | TCP | HTTPS | Web 加密传输 |
| 1883 / 8883 | TCP | MQTT | IoT 通信 / TLS 加密版 |
| 3306 | TCP | MySQL | 数据库 |
| 5432 | TCP | PostgreSQL | 数据库 |
| 6379 | TCP | Redis | 缓存服务 |
| 8080 / 8443 | TCP | HTTP 替代 | 开发/代理常用,避免与生产 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),为后续的可靠传输建立基础。

具体过程如下:
- 创建套接字:调用 `socket(AF_INET, SOCK_STREAM, 0)` 创建 TCP 套接字,此时套接字处于 `CLOSED` 状态。
- 客户端发送 SYN:调用 `connect()` 后,协议栈选择一个本地临时端口,与服务器 IP 和端口组成四元组,并发送 `SYN` 报文。客户端进入 `SYN_SENT` 状态,`connect()` 开始阻塞等待。
- 服务器回复 SYN+ACK:服务器收到 `SYN` 后,协议栈创建对应的连接控制块,并回复 `SYN+ACK` 报文。此时服务器端的连接处于 `SYN_RCVD` 状态,并进入未完成连接队列。
- 客户端发送 ACK:客户端收到 `SYN+ACK` 后,发送 `ACK` 报文,进入 `ESTABLISHED` 状态。此时 `connect()` 返回 0,表示连接建立成功。
- 服务器进入 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`。
该函数的执行流程如下:
| 步骤 | 调用 | 作用 | 失败时的处理 |
|---|---|---|---|
| 1 | port == 0 检查 | 拒绝随机端口,保证客户端可预期 | 直接返回 -1 |
| 2 | memset + sin_len + sin_family + htons | 构造 IPv4 本机地址 | — |
| 3 | socket(AF_INET, SOCK_STREAM, 0) | 创建 TCP 套接字 | 返回 -1 |
| 4 | setsockopt(SO_REUSEADDR) | 允许绑定 TIME_WAIT 状态的端口 | close + 返回 -1 |
| 5 | bind() | 把套接字与 0.0.0.0:port 关联 | close + 返回 -1 |
| 6 | listen(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()` 中配置了以下四个选项:
| 调用 | level | optname | 值 | 效果 |
|---|---|---|---|---|
| setsockopt(client_sock, ...) | SOL_SOCKET | SO_KEEPALIVE | 1 | 打开 TCP 保活总开关 |
| setsockopt(client_sock, ...) | IPPROTO_TCP | TCP_KEEPIDLE | keepidle_time | 连接空闲多少秒后发第一个探测 |
| setsockopt(client_sock, ...) | IPPROTO_TCP | TCP_KEEPINTVL | 2 | 两次探测之间间隔 2 秒 |
| setsockopt(client_sock, ...) | IPPROTO_TCP | TCP_KEEPCNT | 3 | 连续 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` 完成。
该任务的基本逻辑是:
- 每 100 ms 使用 `select()` 检查监听套接字是否有新连接;
- 如果有新连接,调用 `tcp_server_accept()` 接入客户端;
- 遍历当前连接表,使用 `timeout_ms = 0` 的 `tcp_sock_recv()` 非阻塞查询每个客户端;
- 如果某个客户端有数据,则读取并处理;
- 如果没有数据,则继续检查下一个客户端;
- 如果连接异常或对端关闭,则关闭对应套接字并从连接表中移除。

这种设计有几个关键的工程取舍:
- 为什么先用 `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




