简介:这份资源面向嵌入式网络开发学习者与STM32进阶开发者,提供一套以STM32F750为硬件平台、将单片机配置为TCP服务器并支持多个PC客户端并发连接的完整Demo工程,重点解决资源受限环境下多连接并发处理与网络协议栈移植问题。压缩包共820个文件,约60.36MB,以217个h头文件与204个c源文件为核心,配合109个o目标文件、109个d依赖文件及104个crf交叉引用文件,另含汇编、HTML、工程配置与说明文档,覆盖UCOSIII实时操作系统与lwIP协议栈的移植与调度实现。资源围绕服务器初始化、监听接受连接、事件驱动并发处理、数据收发及错误处理与连接关闭等关键环节展开,可帮助读者理解tcp_server_demo.c中多连接管理流程,掌握RTOS任务调度与轻量级TCP/IP协议栈在STM32F750上的协同配置方法。目前已有5560人学习下载,适合希望深入嵌入式TCP服务器设计、对照源码排查网络通信问题的开发者参考。
1. STM32 当 TCP 服务器:为什么“能 ping 通却连不上”是新手第一道坎
很多人第一次用 STM32 做 TCP 服务器,卡住的地方不是写不出代码,而是 PC 端 socket 一直报连接超时,板子却明明能 ping 通。这个现象背后往往不是“网络不通”,而是链路层通了、传输层没通——LwIP 的 netif 没起来、端口没监听、或者客户端和服务端不在同一网段。STM32 做 TCP 服务器,本质是把一块 MCU 加上以太网外设(或串口转以太网模块),跑一个轻量 TCP/IP 协议栈,让它像一台小型服务器一样同时接住多个 PC 客户端的连接请求。它适合做设备本地调试、数据采集上报、工控现场的小型网关,不适合替代高并发服务器。这篇笔记按“选型 → 跑通单连接 → 扩到多客户端 → 排错 → 进阶”的顺序写,每一步都给出可复现的配置和代码,目标是让你在半天内看到 PC 端收到第一包数据。
2. 硬件与协议栈选型:LwIP 裸机还是 RTOS,差别比你想的大
2.1 先确定物理层方案:内置 MAC 还是外挂模块
STM32 做 TCP 服务器,物理层常见三条路。第一条是芯片自带以太网 MAC,外接 PHY 芯片,比如常见的 MII/RMII 接口方案,适合 F4、F7、H7 系列。第二条是 SPI 接口的以太网控制器,MCU 通过 SPI 读写寄存器,省引脚但吞吐低。第三条是串口转以太网模块,MCU 只发 AT 指令或透传,开发最快但可控性最差。
选型判断很简单:如果只是做 Demo 验证多客户端连接逻辑,串口转以太网模块半天能出结果;如果要控制 TCP 窗口、重传、KeepAlive 这些参数,必须用 LwIP 直接跑在 MAC 上。我一般建议先跑通内置 MAC + LwIP 的方案,因为后面所有参数都在你手里,不会被模块固件挡住。
2.2 LwIP 的三种运行模式:裸机轮询、裸机中断、RTOS
LwIP 可以裸机跑,也可以挂在 FreeRTOS 下。裸机模式分两种:主循环轮询sys_check_timeouts(),或者用定时器中断驱动。裸机轮询最简单,但多客户端并发时响应会抖;RTOS 模式下每个连接一个线程,代码清晰但 RAM 开销大。
下面这张表是我实际选型时会对照的参数,数值来自常见中等规模工程的典型配置,不是某个固定芯片的绝对上限:
| 模式 | RAM 占用(典型) | 多客户端表现 | 适合场景 |
|---|---|---|---|
| 裸机轮询 | 20–40 KB | 连接数少时稳定,多了延迟明显 | 单连接调试、低频采集 |
| 裸机中断 | 30–50 KB | 比轮询好,但重入要小心 | 中等频率、2–4 客户端 |
| FreeRTOS + LwIP | 60–100 KB | 每连接独立线程,隔离好 | 多客户端、需要并发处理 |
提示:如果你的芯片 RAM 只有 64 KB 或更少,优先考虑裸机中断模式,别硬上 RTOS,否则 PBUF 池一紧张就会出现“连接建立后立刻断开”的玄学现象。
2.3 多客户端连接对 LwIP 配置的硬性要求
LwIP 默认配置是给单连接场景调的,直接拿来接多客户端会翻车。必须改的宏至少有三个:MEMP_NUM_TCP_PCB决定能同时存在多少个 TCP 控制块,MEMP_NUM_TCP_PCB_LISTEN决定监听数量,PBUF_POOL_SIZE决定网络包缓冲池大小。经验值是:每增加一个客户端,MEMP_NUM_TCP_PCB至少加 1,PBUF_POOL_SIZE至少加 4。
// lwipopts.h 中与多客户端直接相关的配置 #define MEMP_NUM_TCP_PCB 8 // 支持最多 8 个 TCP 控制块 #define MEMP_NUM_TCP_PCB_LISTEN 2 // 允许 2 个监听端口 #define PBUF_POOL_SIZE 16 // 包缓冲池,多客户端时不能太小 #define TCP_SND_BUF (4 * TCP_MSS) // 发送缓冲,影响吞吐 #define TCP_WND (4 * TCP_MSS) // 接收窗口 #define LWIP_SO_RCVTIMEO 1 // 开启接收超时,避免阻塞死等这几个参数的含义:MEMP_NUM_TCP_PCB是“同时能有多少条 TCP 连接存在”,设小了第 N+1 个客户端连上来时tcp_new()会返回 NULL;PBUF_POOL_SIZE是“同时能缓存多少个网络包”,设小了在高频收发时会出现丢包但不断连的怪现象;TCP_SND_BUF和TCP_WND决定单连接吞吐,多客户端场景下不要设得过大,否则内存被单个连接吃光。
3. 从零跑通单客户端:最小 TCP 服务器代码与 PC 端验证
3.1 初始化顺序:先 netif 再 tcp_new,顺序错了必挂
LwIP 的初始化有严格顺序:先lwip_init(),再添加网络接口netif_add(),然后netif_set_up()和netif_set_default(),最后才能调用tcp_new()创建监听。顺序错了最常见的表现是tcp_bind()返回 ERR_USE 或直接 HardFault。
// 最小 TCP 服务器初始化流程(裸机轮询模式) #include "lwip/tcp.h" #include "lwip/netif.h" static struct tcp_pcb *listen_pcb; void tcp_server_init(void) { lwip_init(); // 1. 协议栈初始化 netif_add(&g_netif, &ipaddr, &netmask, &gw, NULL, ethernetif_init, ethernet_input); netif_set_default(&g_netif); // 2. 设为默认网卡 netif_set_up(&g_netif); // 3. 启动网卡 listen_pcb = tcp_new(); // 4. 创建监听控制块 if (listen_pcb == NULL) { return; } // 内存不足会返回 NULL tcp_bind(listen_pcb, IP_ADDR_ANY, 8080); // 5. 绑定 8080 端口 listen_pcb = tcp_listen(listen_pcb); // 6. 进入监听状态 tcp_accept(listen_pcb, client_accept); // 7. 注册连接回调 }逻辑说明:tcp_new()从MEMP_NUM_TCP_PCB池里取一个控制块;tcp_bind()把本地端口固定为 8080,用IP_ADDR_ANY表示接受任意本机 IP 的连接;tcp_listen()会把 PCB 状态切到 LISTEN,同时释放原控制块换成一个更小的监听结构;tcp_accept()注册的回调在每次有新客户端连入时被调用。
3.2 连接回调与收发回调的注册方式
tcp_accept注册的回调负责“接客”,它拿到新连接的 PCB 后,要立刻注册tcp_recv和tcp_err,否则数据来了没人处理,连接出错也没人清理。
static err_t client_accept(void *arg, struct tcp_pcb *newpcb, err_t err) { if (err != ERR_OK || newpcb == NULL) { return ERR_VAL; } tcp_recv(newpcb, client_recv); // 注册接收回调 tcp_err(newpcb, client_err); // 注册错误回调 tcp_arg(newpcb, NULL); // 可传入自定义结构体指针 return ERR_OK; } static err_t client_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { if (p == NULL) { // p == NULL 表示对端关闭连接 tcp_close(tpcb); return ERR_OK; } tcp_recved(tpcb, p->tot_len); // 必须调用,否则窗口不更新 // 回显:把收到的数据原样发回去 tcp_write(tpcb, p->payload, p->tot_len, TCP_WRITE_FLAG_COPY); tcp_output(tpcb); // 立即发送,不等 Nagle 合并 pbuf_free(p); // 释放 pbuf,否则内存泄漏 return ERR_OK; }参数说明:tcp_recved()的参数是本次消费的字节数,不调用会导致接收窗口逐渐缩到 0,对端再也发不出数据;tcp_write()的第四个参数用TCP_WRITE_FLAG_COPY表示数据拷贝进发送缓冲,调用后原 pbuf 可以安全释放;tcp_output()强制立即发送,调试阶段建议加上,稳定后再考虑去掉以利用 Nagle 合并小包。
3.3 PC 端用 Python 脚本验证连接与回显
板子跑起来后,PC 端先用最简脚本验证。把 PC 的 IP 设成和板子同网段,比如板子 192.168.1.100,PC 设 192.168.1.10。
# PC 端单客户端回显测试 import socket HOST = '192.168.1.100' # 板子 IP PORT = 8080 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(5) s.connect((HOST, PORT)) # 连接失败会抛 timeout s.sendall(b'hello stm32') # 发送测试数据 data = s.recv(1024) # 等待回显 print('received:', data)如果connect超时,先 ping 板子;ping 通但 connect 失败,检查tcp_listen是否真的执行到、端口是否被占用;connect 成功但recv超时,检查client_recv里是否调用了tcp_output。
4. 扩到多客户端:连接表管理与并发收发的三个关键改动
4.1 用连接表替代单一 PCB 指针
单客户端时一个struct tcp_pcb *就够了,多客户端必须维护一张连接表。常见做法是定义一个固定大小的数组,每个槽位存 PCB 指针和连接状态。
#define MAX_CLIENTS 8 typedef struct { struct tcp_pcb *pcb; u8_t used; u32_t last_tick; } client_slot_t; static client_slot_t clients[MAX_CLIENTS]; static client_slot_t* client_alloc(struct tcp_pcb *pcb) { for (int i = 0; i < MAX_CLIENTS; i++) { if (!clients[i].used) { clients[i].used = 1; clients[i].pcb = pcb; clients[i].last_tick = sys_now(); return &clients[i]; } } return NULL; // 表满,拒绝新连接 }逻辑说明:client_alloc在client_accept里调用,返回 NULL 时应当直接tcp_close(newpcb),否则会出现“连接建立了但没人管”的黑匣子状态。last_tick用于后续做空闲超时清理。
4.2 广播发送与定向发送的写法差异
多客户端场景下,发送分两种:定向发送只发给某一个客户端,广播发送遍历整张表。定向发送直接用该槽位的 PCB 调tcp_write;广播发送要注意,如果某个客户端接收窗口已满,tcp_write会返回 ERR_MEM,此时不能死等,应当跳过或缓存。
// 广播发送:遍历连接表,跳过发送失败的客户端 void broadcast_data(const u8_t *data, u16_t len) { for (int i = 0; i < MAX_CLIENTS; i++) { if (!clients[i].used || clients[i].pcb == NULL) { continue; } err_t err = tcp_write(clients[i].pcb, data, len, TCP_WRITE_FLAG_COPY); if (err == ERR_OK) { tcp_output(clients[i].pcb); } else if (err == ERR_MEM) { // 发送缓冲满,本次跳过,等下次或加发送队列 continue; } else { // 其他错误,关闭该连接并释放槽位 tcp_close(clients[i].pcb); clients[i].used = 0; clients[i].pcb = NULL; } } }参数说明:ERR_MEM表示当前 PCB 的发送缓冲不够,不是致命错误,跳过即可;ERR_CONN表示连接已失效,必须关闭并释放槽位,否则槽位泄漏,跑一段时间后新客户端连不进来。
4.3 连接断开时的资源回收:不回收就是慢性内存泄漏
每个客户端断开时,client_recv会收到p == NULL,client_err会在出错时被调用。这两个地方都必须把连接表对应槽位的used清零、pcb置 NULL,否则槽位永远占着,跑几十次连接后表就满了。
static void client_err(void *arg, err_t err) { // arg 是 tcp_arg 注册时传入的指针,这里传的是槽位指针 client_slot_t *slot = (client_slot_t *)arg; if (slot != NULL) { slot->used = 0; slot->pcb = NULL; // 错误回调里 PCB 已被协议栈释放,不能重复关 } }注意:
tcp_err回调触发时,协议栈已经释放了 PCB,你不能再调tcp_close,只能清理自己的连接表。这一点和client_recv收到 NULL 时的处理不同,后者需要主动tcp_close。
5. 多客户端 Demo 的避坑与排查:五个真实翻车现场
5.1 坑一:第二个客户端连上后第一个掉线
现象:单客户端一切正常,第二个 PC 连上来,第一个立刻断开。原因通常是MEMP_NUM_TCP_PCB设成了 1 或 2,第二个连接复用了第一个的控制块。解决:把MEMP_NUM_TCP_PCB改到比预期最大客户端数多 2,比如预期 6 个就设 8,留两个给监听和临时连接。
5.2 坑二:连接建立后收不到数据,PC 端显示已连接
现象:connect成功,PC 端send也返回成功,但板子client_recv一直不触发。原因多半是ethernetif_input没被正确调用,或者中断优先级配置导致收包中断被屏蔽。排查方法:在ethernetif_input入口翻转一个 GPIO,用示波器看有没有脉冲;没有脉冲就是底层收包没起来,和 LwIP 上层无关。
5.3 坑三:跑一段时间后新连接连不上,旧连接还在
现象:Demo 跑十几分钟后,新 PC 连不上,但已连接的客户端还能通信。原因是断开连接的槽位没回收,连接表满了。排查:在client_accept里打印当前used计数,看是否只增不减。解决:确保client_recv的p == NULL分支和client_err都做了槽位释放。
5.4 坑四:广播数据时部分客户端收不到
现象:广播发送时,有的客户端能收到,有的收不到,且每次收不到的还不一样。原因是某个客户端接收窗口满,tcp_write返回ERR_MEM被跳过。解决:给每个连接加一个发送队列,ERR_MEM时把数据挂到队列,等tcp_sent回调触发后再补发。
5.5 坑五:PC 端频繁重连导致板子死机
现象:用脚本快速反复连接断开,几十次后板子 HardFault。原因是每次连接都tcp_new但异常路径下没释放,PCB 池耗尽后tcp_new返回 NULL,后续代码没判空直接解引用。解决:所有tcp_new返回值必须判 NULL;异常路径统一走一个cleanup_client函数,保证 PCB 和槽位成对释放。
6. 进阶技巧:用 KeepAlive 和发送队列把 Demo 变成能长期跑的服务
Demo 和能长期跑的服务之间,差的是两件事:死连接检测和发送缓冲管理。LwIP 自带 TCP KeepAlive,但默认关闭,需要手动开。
// 在 client_accept 里为新连接开启 KeepAlive static err_t client_accept(void *arg, struct tcp_pcb *newpcb, err_t err) { // ... 前面的注册代码 ... newpcb->so_options |= SOF_KEEPALIVE; // 开启 KeepAlive newpcb->keep_idle = 30000; // 30 秒无数据开始探测 newpcb->keep_intvl = 5000; // 探测间隔 5 秒 newpcb->keep_cnt = 3; // 3 次无响应则断开 return ERR_OK; }这三个参数的含义:keep_idle是连接空闲多久后开始发探测包,单位毫秒;keep_intvl是两次探测之间的间隔;keep_cnt是连续多少次探测无响应就判定连接死亡并触发tcp_err。PC 端如果直接拔网线,没有 KeepAlive 的话板子会一直以为连接还在,槽位永远不释放;开了之后最多 45 秒就能回收。
发送队列的实现思路是给每个槽位加一个pbuf链表,tcp_write返回ERR_MEM时把数据拷进pbuf挂到链表尾,然后注册tcp_sent回调,在回调里检查链表并补发。tcp_sent的注册方式和tcp_recv一样,在client_accept里加一行tcp_sent(newpcb, client_sent)即可。
验证长期稳定性的方法:写一个 PC 端脚本,开 4 个线程分别连接、每 100 毫秒发一包、随机断开重连,跑 2 小时。板子端每 10 秒通过串口打印一次连接表状态和PBUF_POOL剩余量。如果PBUF_POOL剩余量持续下降不回升,说明有 pbuf 泄漏,重点查client_recv里是否每条路径都调用了pbuf_free。
我自己踩过最深的一个坑是:在client_recv里先tcp_write再pbuf_free,但tcp_write返回ERR_MEM时直接return了,忘了pbuf_free,结果每遇到一次发送缓冲满就泄漏一个 pbuf,跑一晚上第二天必死。后来养成的习惯是:client_recv里不管走哪条分支,函数退出前必须保证p被释放,宁可先pbuf_free再处理数据副本。希望帮到你。
本文还有配套的精品资源,点击获取