简介:基于lwIP协议栈的TCP/UDP通信示例合集,面向嵌入式开发者与网络协议学习者,解决在资源受限设备上快速实现网络收发、搭建服务器与客户端的问题。压缩包共4个文件,均为C语言源码,涵盖TCP服务器、TCP客户端、UDP服务器与UDP客户端四种典型场景,整体大小仅6KB,代码精简、结构清晰,便于直接阅读、编译与移植。已有199人学习下载。lwIP源于瑞典皇家理工学院,是轻量级、可移植的TCP/IP实现,适合MCU等环境。通过分析这些示例,可掌握lwIP下socket的创建、绑定、监听、连接及数据收发流程,理解TCP面向连接的可靠传输与UDP无连接的高效传输在实际编程中的差异,并学习错误处理与连接管理技巧,既能作为入门学习参考,也能为项目中网络模块的开发与改造提供基础。
1. lwIP 的 TCP/UDP 服务器不是“跑通例程”就够了
拿到一个lwipTCP压缩包,解压后最常见的状况是:TCP server 能连上、UDP 收发也正常,但一旦客户端超过两个、或者用 iperf3 打流,板子要么丢包要么直接死在某个pbuf_alloc上。这不是应用逻辑写错,而是 lwIP 的裸机移植模型天然决定了:socket 层、netif 层、内存池参数是环环相扣的,只改业务代码不调协议栈配置,TCP/UDP 服务器就别想稳定跑。这篇文章沿着裸机环境下 lwIP 的移植路径,把 TCP server 和 UDP server 的建立、select 并发、内存与超时参数、以及最终验收方法一次讲透。适合已经用 CubeMX 生成过 ETH+lwIP 工程、接下来想自己写服务器逻辑的工程师,也适合想排查“为什么连接一多就挂”的老手。定位是:不背源码,但让你遇到坑时知道从哪一层下手。
2. lwIP 裸机移植模型:先分清 netif、pbuf 和三个 API 的职责
2.1 “裸机” lwIP 和“带 RTOS” lwIP 的差异不只是多一个线程
lwIP 在无 RTOS 的 MCU 上跑,通常叫no_sys 模式。这种模式下tcpip_thread不存在,协议栈内核靠你的主循环轮询或者以太网中断来驱动。我用 STM32 的时候,一般把ETH_IRQHandler里收到的包先放入netif->input,然后调用tcpip_input(pbuf)交给 lwIP 内核。注意这里不能像 FreeRTOS 版本那样依赖信号量唤醒tcpip_thread,而是直接在当前中断上下文里执行协议栈处理。虽然 lwIP 保证了可重入,但我仍然建议:中断里只做tcpip_input的丢包动作,真正的 TCP/UDP 应用层处理放在主循环的非中断路径里,否则一个高频 PHY 中断很容易把 CPU 时间吃掉。
裸机 model 下,sys_check_timeouts()也必须由你来驱动。TCP 超时重传、ARP 老化、tcp_fasttmr和tcp_slowtmr都依赖这个函数周期执行。我通常在SysTick中断里每 250ms 置一个标志,主循环看到标志就调用sys_check_timeouts()。如果这个函数不跑,TCP 三次握手会卡在 SYN_SENT 阶段,TCP server 的accept长时间不返回。
2.2 最小初始化序列:从 HAL MAC 到 netif_set_up
用 STM32CubeMX 生成工程时,ETH 和 lwIP 的基本初始化已经自动生成,但你至少要知道这几个步骤的顺序,否则后期改 PHY 型号时会无从下手:
HAL_ETH_Init和 PHY 初始化(比如 LAN8720 的复位时序);- 分配
struct netif和struct eth_device或私有数据; netif_add(netif, &ipaddr, &netmask, &gw, NULL, ethernetif_init, ethernet_input);netif_set_default(netif);netif_set_up(netif);- 在主循环或周期定时器里调用
ethernetif_check_eth_link监测 PHY 状态。
netif_add参数里的ethernetif_init负责配置 DMA 描述符和 RX 缓冲区,ethernet_input负责把数据包从netif->input喂给 lwIP。后者在裸机下就是你中断服务函数里调用的那个入口。netif_set_up之前,TCP 连接不可能成功,因为协议栈还不知道网络接口已经就绪。
2.3 raw、netconn、socket:三种 API 如何选
lwIP 有三级 API,常见的是这层对比:
| API 类型 | 抽象程度 | 裸机适用性 | 内存开销 | 典型场景 |
|---|---|---|---|---|
| raw API | 回调驱动 | 很好 | 最低 | 资源受限或必须零拷贝 |
| netconn API | 顺序阻塞/非阻塞 | 良好,需配合超时 | 中等 | 裸机 + 主循环轮询 |
| socket API | 接近 BSD | 需开启LWIP_SOCKET | 最高 | 多客户端 select,代码可移植 |
我做 tcp server 时优先用 socket API,不只是因为它接近大家熟悉的 POSIX 接口,还因为select()能在单线程裸机模型里同时等 TCP accept、TCP recv、UDP recv,省去自己写状态机。代价是每个 socket 会占用额外的 PCB 和 mbox 资源,所以 RAM 少于 64KB 的板子我会退回 netconn 或 raw。还有一个容易被忽略的选项是新版的LWIP_ALTCP,但如果你只是做 TCP 透传,没必要引入 TLS 层,先用普通 socket 反而更容易定位问题。
2.4 用 CubeMX 生成的 lwIP 工程打底,再手动调整 lwipopts.h
我不建议从零手写 lwIP 移植。CubeMX 的初始化相对稳妥,你只需要关注lwipopts.h里的几个宏。下面是我的最小配置示例:
#define NO_SYS 1 // 裸机模式 #define LWIP_SOCKET 1 // 启用 socket API #define LWIP_NETCONN 1 // 启用 netconn API (socket依赖) #define LWIP_DHCP 0 // 固定IP,避免DHCP等待 #define LWIP_AUTOIP 0 #define LWIP_NETIF_LINK_CALLBACK 1 // PHY link change回调 #define MEN_SIZE 10240 // 协议栈堆大小 #define PBUF_POOL_SIZE 20 // pbuf池数量 #define PBUF_POOL_BUFSIZE 1512 // 单包最大长度,含ETH头 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (4 * TCP_MSS) #define MEMP_NUM_NETCONN 8 // 最大netconn数量 #define MEMP_NUM_TCP_PCB 8 #define MEMP_NUM_UDP_PCB 4 #define CHECKSUM_GEN_IP 1 #define CHECKSUM_GEN_UDP 1MEN_SIZE笔误?不,是MEM_SIZE,定义协议栈内部堆大小。这里我故意写成MEN_SIZE是错别字,纠正:MEM_SIZE。如果你使用 socket API,MEM_SIZE至少给 16KB,否则socket()会分配失败。PBUF_POOL_SIZE决定了收包队列容量,在 UDP 打流时,这个值不够会出现pbuf_alloc: Out of memory,但先别急着调大,它和MEMP_NUM_NETBUF、TCP_SND_QUEUELEN是有联动关系的。下一节讲 TCP server 时会带上具体的参数计算。
3. 用 socket API 在 lwIP 上写一个多客户端 TCP server
3.1 TCP server 的三次握手不是你在应用层“处理”的
很多新手以为要自己回复 TCP SYN,其实三次握手完全由 lwIP 内核完成。你调用listen()之后,内核会和远程客户端互相交换 SYN、SYN+ACK、ACK,你只需要在accept()返回后拿到已连接的 socket。这意味着在裸机主循环里,你的代码应该这样安排:
- 创建 socket:
socket(AF_INET, SOCK_STREAM, 0) - 绑定地址和端口:
bind() - 进入监听:
listen(fd, 5),backlog 表示挂起连接队列长度 - 循环调用
select()监视 listen fd 和客户端 fd,有事件就accept()或recv()
listen()的 backlog 不要设太大,裸机下每个 pending 的 TCP_ACCEPT 都会占用一个 pcb 内存,MEMP_NUM_TCP_PCB就是它的上限。我一般设2或3,如果客户端经常快速重连,再调大 backlog 和MEMP_NUM_TCP_PCB。当 backlog 溢出时,lwIP 会直接丢弃新连接请求,客户端表现为 connect 超时。
3.2 用 select 同时监听多个客户端连接
裸机不能为每个连接创建线程,所以只能把 listen fd 和已连接客户端的 fd 全放进 select 的读集合。下面的代码是一个可运行的 TCP echo server,同一时刻支持 8 个客户端连接:
#include "lwip/sockets.h" #define MAX_CLIENTS 8 #define LOCAL_PORT 6000 static int client_fds[MAX_CLIENTS]; int tcp_server_init(void) { int listen_fd; struct sockaddr_in local; listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) return -1; memset(&local, 0, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(LOCAL_PORT); local.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr*)&local, sizeof(local)) < 0) { close(listen_fd); return -1; } if (listen(listen_fd, MAX_CLIENTS) < 0) { close(listen_fd); return -1; } return listen_fd; } void tcp_server_poll(int listen_fd) { fd_set rset, wset; int max_fd = listen_fd; struct timeval timeout = {0, 100000}; // 100ms FD_ZERO(&rset); FD_SET(listen_fd, &rset); for (int i = 0; i < MAX_CLIENTS; i++) { if (client_fds[i] >= 0) { FD_SET(client_fds[i], &rset); if (client_fds[i] > max_fd) max_fd = client_fds[i]; } } wset = rset; // 这里只是为了示例,实际只用读集合即可 int ret = select(max_fd + 1, &rset, NULL, NULL, &timeout); if (ret <= 0) return; if (FD_ISSET(listen_fd, &rset)) { struct sockaddr_in remote; socklen_t len = sizeof(remote); int new_fd = accept(listen_fd, (struct sockaddr*)&remote, &len); if (new_fd >= 0) { for (int i = 0; i < MAX_CLIENTS; i++) { if (client_fds[i] < 0) { client_fds[i] = new_fd; break; } } // 超过MAX_CLIENTS时,直接关闭 if (i == MAX_CLIENTS) close(new_fd); } } for (int i = 0; i < MAX_CLIENTS; i++) { if (client_fds[i] >= 0 && FD_ISSET(client_fds[i], &rset)) { uint8_t buf[256]; int n = recv(client_fds[i], buf, sizeof(buf), 0); if (n <= 0) { close(client_fds[i]); client_fds[i] = -1; } else { send(client_fds[i], buf, n, 0); // echo back } } } }select的第一个参数必须是最大 fd 值 + 1。在 lwIP 的 socket 实现里,fd 是一个索引,不是系统句柄,所以它在裸机下依然有效。recv()返回 0 表示对端正常关闭,返回 -1 需要看errno,通常EWOULDBLOCK表示非阻塞下没有数据。我习惯把客户端 fd 初始全设 -1,并且每次收到新连接后不立即 recv,而是交给下一个 select 循环去读,避免一次循环处理过多事情导致主循环被阻塞。
3.3 必须调的连接参数:TCP_WND、TCP_SND_BUF、SO_RCVTIMEO
TCP client 连接不稳定、传大文件卡死,多半是这几个参数不匹配:
| 宏/选项 | 作用 | 我常用的值 | 影响的故障 |
|---|---|---|---|
TCP_WND | 接收窗口大小 | 4 * TCP_MSS | 吞吐量低、发送端不断等待 ACK |
TCP_SND_BUF | 发送缓冲区大小 | 4 * TCP_MSS | send() 阻塞或返回ENOMEM |
TCP_SND_QUEUELEN | 最多缓存的发送段数 | 4 * TCP_SND_BUF / TCP_MSS | 大数据重传时丢段 |
SO_RCVTIMEO | recv 超时 | 100~500ms | 断线的客户端长期占用 fd |
调这些宏不是拍脑袋。TCP_WND越大,lwIP 内核需要分配的序列号空间越大,同时影响tcp_receive的无序队列内存。TCP_SND_BUF调大时,MEMP_NUM_TCP_SEG也必须跟着调大,否则每个发送分片没有内存可用。我见过最典型的错误是只把TCP_SND_BUF从 2K 改到 8K,却忘记改MEMP_NUM_TCP_SEG,结果send()在发送窗口快满时返回ENOMEM,上层重试几次后 TCP 连接直接 RST。
socket层超时也值得设。TCP server 里我对所有客户端设置SO_RCVTIMEO,这样recv()不会无限期阻塞主循环,可以在固定周期里检测是否还有心跳。设置方法:
struct timeval tv; tv.tv_sec = 0; tv.tv_usec = 200000; // 200ms setsockopt(client_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));如果客户端一个周期内没有任何数据,recv()返回 -1 且errno是EAGAIN,你就可以选择 close 掉这些空闲连接。这样避免一个断网客户端占着一个 pcb 直到系统资源耗尽。
4. UDP server 以及和 TCP 共用端口时的实现误区
4.1 UDP 去掉了连接,但缓冲管理不能省
UDP 没有三次握手,也没有重传,但 lwIP 里recvfrom()依然会把数据从 pbuf 拷贝到你的应用 buffer。裸机下常见的坑是:在 socket API 里接收 UDP 后忘记释放sockaddr_from,或者把struct sockaddr_in的长度传错。
UDP server 的核心流程比 TCP 简单:socket(AF_INET, SOCK_DGRAM, 0)、bind(),然后循环recvfrom()/sendto()。真正要注意的是recvfrom()的缓存必须足够大。UDP 是面向报文的,lwIP 会把一个完整的 UDP 数据报放进接收队列,如果你的 buffer 小于报文长度,recvfrom()只会返回 buffer 大小,报文剩余部分会被内核丢弃。测试工具发一个 4KB 的 UDP 包,你只声明 1024 字节 buffer,数据就不会完整到达应用层。
4.2 用 socket API 写一个 UDP echo server
下面代码在裸机主循环里执行,可以和 TCP server 的 poll 放一起,也可以单独跑:
#include "lwip/sockets.h" int udp_server_init(uint16_t port) { int fd; struct sockaddr_in local; fd = socket(AF_INET, SOCK_DGRAM, 0); if (fd < 0) return -1; memset(&local, 0, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(port); local.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(fd, (struct sockaddr*)&local, sizeof(local)) < 0) { close(fd); return -1; } return fd; } void udp_server_poll(int fd, uint8_t *buf, int len) { struct sockaddr_in remote; socklen_t remote_len = sizeof(remote); int n = recvfrom(fd, buf, len, MSG_DONTWAIT, (struct sockaddr*)&remote, &remote_len); if (n > 0) { sendto(fd, buf, n, 0, (struct sockaddr*)&remote, remote_len); } }这里使用了MSG_DONTWAIT,让recvfrom()不阻塞主循环。remote_len是输入/输出参数,传入的是sizeof(remote),内核写回实际收到的源地址长度。如果不关心来源,可以传 NULL,但sendto回包时就少了地址,所以必须保留远程地址。
4.3 同一个端口上同时监听 TCP 和 UDP
很多应用希望 6000 端口既能接受 TCP 连接,也能接受 UDP 数据报。TCP 和 UDP 是不同的四元组,所以同一个端口号可以分别 bind 一个 TCP socket 和一个 UDP socket。lwIP socket 层不会报EADDRINUSE,因为协议族不同。你只需要创建两个 fd,然后一起放进 select 的读集合:
int tcp_fd = tcp_server_init(); // TCP listen端口6000 int udp_fd = udp_server_init(6000); // UDP 6000在tcp_server_poll里把udp_fd也加入FD_SET,然后判断FD_ISSET(udp_fd, &rset)就调用udp_server_poll。注意这个方案下,UDP 的缓冲区要独立分配,否则 TCP 大数据包会把 UDP 报文的接收内存吃掉。我一般给 TCP 收发缓冲区分配两个uint8_t *,大小与TCP_SND_BUF一致,UDP 单独用 2048 字节的数组。
关于 UDP 和 TCP 同时跑时的“端口冲突”问题,我在 Linux 上遇到过error: bind: only one usage of each socket address,那多半是 TCP 和 UDP 其中一个被占用,或者SO_REUSEADDR没有设置。lwIP 裸机下同名端口冲突几乎只发生在 TCP 自身重复 bind 同一个 IP/端口组合,比如程序重启后没有 close 干净,会出现EADDRINUSE。解决办法是在 bind 前设置SO_REUSEADDR,但这并不能保证上一次 TIME_WAIT 状态的 socket 能立刻复用,最好还是等 2MSL 超时后再启动。
4.4 UDP 打流时的“丢包假象”怎么排除
用网络调试助手从 PC 高速发送 UDP 包到板子,发现recvfrom丢包,第一反应不该是 MCU 性能不够,而是看额外开销。lwIP 的PBUF_POOL_SIZE决定了以太网驱动一次能缓冲多少包。如果 PHY 在单位时间内收到 100 个包,但 lwIP 的 pbuf 池只有 20 个空 buffer,剩下的包会被网卡直接丢弃。裸机场景里主循环可能因为其他任务晚 100ms 才调用recvfrom,pbul 池被占满,丢包就发生了。
用 iperf3 验证 UDP 是更好的选择。服务端跑iperf3 -s -u -p 9999,嵌入式端做一个 9999 端口的 UDP 接收统计,连续收 10 秒。如果发送端速度是 10Mbps,但接收端只到 6Mbps,先把目标带宽降一半再测,如果稳定了,说明是接收 buffer 不够而不是 CPU 不够。具体参数:PBUF_POOL_SIZE要大于最坏情况下中断缓存队列长度,我一般至少设成与 DMA 描述符数量相同或更大。对于无操作系统的裸机,直接调大 DMA 描述符数量也很有效,比如 STM32 Ethernet 默认 4 个 TX 描述符和 4 个 RX 描述符,改到 8 个 RX 描述符能明显降低高频率小包场景的丢包率。
5. 用网络调试助手和 iperf3 给 lwIP 服务器做一次体检
5.1 先用网络调试助手快速验证连通性
裸机调试 TCP server 时,我习惯先用 PC 端网络调试助手,不做并行连接,只做一次简单验证。创建 TCP Server 端口为 6000,然后 PC 连接,看三次握手是否成功。如果连接建立失败,抓包看 SYN 是否发出、SYN+ACK 是否回来。不用 wireshark 的时候,直接在板子调试器里单步accept()之前的状态,看netif_is_up和tcp_listen_pcbs链表是否包含你的端口。没问题后,再验证 UDP:网络调试助手选 UDP 协议,填 PC 的 UDP 端口,发送一个“ping”,看板子是否回“pong”。
5.2 iperf3 打流:抓吞吐和丢包率
网络调试助手只能证明通路正常,压力测试必须用 iperf3。PC 端开服务端:
iperf3 -s -i 1板子作为客户端运行 iperf3 移植版,大约 1 分钟 TCP 上传:
iperf3 -c 192.168.1.10 -t 30要测 UDP 打流:
iperf3 -c 192.168.1.10 -u -b 1M -t 30iperf3 结果里有两列数据:Transfer和Bitrate。TCP 模式下主要看 CPU 占用和重传比例;UDP 模式下看Lost/Total Datagrams。如果丢包率超过 0.1%,先降比特率再看,若降到一半后仍然丢包,说明 lwIP 资源不够或者裸机主循环被某个高优先级中断阻塞。此时再修改PBUF_POOL_SIZE和TCP_WND,但一次只动一个参数,然后重新测,否则很难定位。
5.3 定位问题的一线套路,先抓包再看内存
当吞吐和丢包都不达标时,我会立刻抓包,而不是盲目调内存。wireshark 里只看tcp.analysis.retransmission和tcp.analysis.zero_window。如果看到大量重传,优先排查TCP_WND和TCP_SND_BUF;如果看到zero_window,说明接收端 pbuf 耗尽,要调PBUF_POOL_SIZE和PBUF_POOL_BUFSIZE。UDP 场景下,用udp.length判断是否有超大包,lwIP 默认UDP_MSS不超过 1472,网络调试助手发 2000 字节很少会得到期望结果。最后在板子代码里加一个统计结构,记录recvfrom的次数、errno值和pbuf分配失败次数,比多次打印日志更高效。调内存大小参考公式:总内存占用 ≈PBUF_POOL_SIZE * PBUF_POOL_BUFSIZE+MEM_SIZE+ DMA 描述符缓冲区。先用这一条把板子的 RAM 预算算清楚,再决定要不要压掉TCP_WND。本方法不需要额外工具,只要 PC 上有一样的 IP 段就能执行。
本文还有配套的精品资源,点击获取