第4章 TCP/IP协议栈移植:LwIP协议栈架构、内存管理(pbuf)、网络接口注册、socket API与RAW API对比、协议栈配置裁剪
嵌入式设备要联网,光有硬件网口可不够。你得在MCU里跑一个TCP/IP协议栈。市面上选择不少,uC/TCP-IP、FreeRTOS+TCP,但我个人最常用的是LwIP。为什么?轻量、开源、文档全,而且社区活跃。我在好几个项目里都用它做过数据采集器和远程控制终端,踩过不少坑,今天把这些经验掰开揉碎讲给你。
核心观点:LwIP移植的本质,就是把协议栈的“底层依赖”和你的硬件、操作系统对接起来。说白了,就是帮协议栈搞定三件事:内存怎么分配、数据怎么收发、时间怎么获取。
4.1 LwIP协议栈架构概览
LwIP的全称是Lightweight IP。它不是为了和Linux协议栈比性能,而是为了在几十KB RAM的单片机上跑起来。它的架构分三层:
- 应用层:提供Socket API和RAW API,供你写网络应用。
- 核心层:TCP、UDP、IP、ICMP等协议实现,这部分你基本不用动。
- 底层接口层:网卡驱动、操作系统封装、内存管理。移植工作主要集中在这里。
我刚开始接触LwIP时,总想搞懂每一行源码。后来发现没必要。你只需要关注接口层,核心层当成黑盒就好。嗯,这里要注意:LwIP有两种运行模式——独立模式(无OS)和多线程模式(有OS)。如果你的项目用了RTOS,记得开启NO_SYS=0,否则协议栈会在一个死循环里轮询,把其他任务饿死。
4.2 内存管理:pbuf 机制
嵌入式系统的RAM寸土寸金。LwIP没有用标准C的malloc/free,而是自己搞了一套pbuf(Packet Buffer)机制。说白了,pbuf就是协议栈内部传递数据包的“容器”。
pbuf有三种类型:
| 类型 | 内存位置 | 适用场景 | 特点 |
|---|---|---|---|
| PBUF_RAM | 堆内存(连续) | 应用层发送数据 | 大小可变,可追加协议头 |
| PBUF_POOL | 固定大小内存池 | 网卡接收数据 | 分配快,无碎片,但大小固定 |
| PBUF_ROM | 只读内存(如Flash) | 发送静态数据 | 不拷贝数据,直接引用 |
我在项目中遇到过一个问题:网卡中断里频繁分配pbuf,导致内存碎片越来越多,最后分配失败。后来改用PBUF_POOL,问题就解决了。为什么?因为POOL是预先分配好的固定块,分配和释放都是O(1)复杂度,不会产生碎片。
经验之谈:接收路径一律用PBUF_POOL。发送路径如果数据量小且频繁,也用POOL;如果数据量大,用PBUF_RAM。PBUF_ROM适合发送固件里写死的配置信息或应答报文。
pbuf还有一个重要特性——链式结构。一个数据包可能由多个pbuf组成链表,这样可以避免数据拷贝。比如TCP分段重传时,只需要把pbuf链重新链接一下,不需要搬动内存。这个设计很巧妙,你想想看,省了多少CPU周期。
4.3 网络接口注册
网卡驱动写好了,怎么告诉LwIP“嘿,我有块网卡,你来用”?答案是注册网络接口。核心函数就一个:netif_add()。
它的原型长这样:
struct netif *netif_add(struct netif *netif,
ip4_addr_t *ipaddr,
ip4_addr_t *netmask,
ip4_addr_t *gw,
void *state,
netif_init_fn init,
netif_input_fn input);
参数里最重要的两个回调:
- init:初始化函数,在里面做MAC地址设置、硬件复位、注册中断。
- input:数据接收函数。网卡收到数据后,由它把pbuf喂给协议栈。
我曾经在一个项目里犯过低级错误——忘了在init回调里设置netif->hwaddr_len。结果协议栈一直发不出包,抓了半天波形才发现MAC地址长度是0。嗯,这种细节最坑人。
注意:注册完网卡后,必须调用netif_set_up()把接口状态置为“UP”。否则协议栈认为网卡没准备好,不会收发任何数据。另外,如果使用DHCP,记得在netif_add之后启动DHCP客户端。
4.4 Socket API 与 RAW API 对比
LwIP提供了两套编程接口。很多初学者会纠结选哪个。我直接说结论:
- Socket API:类似BSD Socket,阻塞式,需要RTOS支持。代码可读性好,移植方便。
- RAW API:基于回调,非阻塞,不需要OS。性能高,但代码写起来像“面条”。
来看一个对比表格:
| 对比维度 | Socket API | RAW API |
|---|---|---|
| 编程模型 | 同步阻塞 | 异步回调 |
| OS依赖 | 必须(需要线程/信号量) | 可选(可无OS运行) |
| 内存占用 | 较大(每个socket需要独立栈空间) | 极小(无额外线程开销) |
| 代码复杂度 | 低(类似PC编程) | 高(状态机+回调嵌套) |
| 典型场景 | HTTP服务器、MQTT客户端 | 高频率数据采集、简单UDP透传 |
我个人习惯:如果MCU资源充裕(比如Cortex-M4以上,64KB+ RAM),优先用Socket API。开发效率高,后期维护也省心。如果RAM只有十几KB,或者对实时性要求极高,那就用RAW API。
举个例子,用RAW API写一个TCP回显服务器:
static err_t echo_recv(void *arg, struct tcp_pcb *pcb,
struct pbuf *p, err_t err) {
if (p == NULL) {
// 对方关闭连接
tcp_close(pcb);
return ERR_OK;
}
// 原样发送回去
tcp_write(pcb, p->payload, p->len, 1);
pbuf_free(p);
return ERR_OK;
}
static err_t echo_accept(void *arg, struct tcp_pcb *pcb, err_t err) {
tcp_recv(pcb, echo_recv);
return ERR_OK;
}
void echo_init(void) {
struct tcp_pcb *pcb = tcp_new();
tcp_bind(pcb, IP_ADDR_ANY, 7);
pcb = tcp_listen(pcb);
tcp_accept(pcb, echo_accept);
}
你看,全是回调。连接来了调echo_accept,数据来了调echo_recv。没有while(1),没有recv()阻塞。刚开始写可能不习惯,但跑起来确实省资源。
4.5 协议栈配置裁剪
LwIP的配置都在lwipopts.h文件里。这个文件是移植的灵魂。我见过有人直接把默认配置拿过来用,结果一个Ping包就把MCU干死机了。为什么?因为默认的PBUF_POOL_SIZE是256,每个pbuf 256字节,光内存池就吃掉64KB。你的MCU总共才128KB RAM,不崩才怪。
下面是我常用的裁剪清单:
| 宏定义 | 默认值 | 推荐值(资源受限场景) | 说明 |
|---|---|---|---|
| PBUF_POOL_SIZE | 256 | 16~32 | 接收pbuf池数量 |
| PBUF_POOL_BUFSIZE | 256 | 128~256 | 每个pbuf大小(字节) |
| MEMP_NUM_TCP_PCB | 32 | 4~8 | 同时支持的TCP连接数 |
| MEMP_NUM_UDP_PCB | 8 | 4 | 同时支持的UDP连接数 |
| TCP_WND | 65535 | 2048~8192 | TCP接收窗口大小 |
| LWIP_DHCP | 0 | 1(如果需要) | 开启DHCP客户端 |
| LWIP_DNS | 0 | 0(多数嵌入式场景不需要) | 域名解析 |
避坑指南:我曾经把TCP_WND设得太大,结果内存不足导致协议栈内部断言失败。TCP_WND乘以MSS(通常1460)就是接收缓冲区总大小。如果你的RAM只有32KB,TCP_WND设4096就够了。别贪心。
另外,如果不需要IPv6,记得关掉LWIP_IPV6。不需要IGMP组播?关掉LWIP_IGMP。不需要SNMP?关掉LWIP_SNMP。每关一个功能,都能省下几KB的代码空间和RAM。我习惯在lwipopts.h里把所有不需要的功能都注释掉,只保留最小集。等调试通过后,再按需打开。
小技巧:在lwipopts.h末尾加一段编译时检查:
#if (PBUF_POOL_SIZE * PBUF_POOL_BUFSIZE) > (RAM_SIZE / 2)
#error "pbuf pool too large, reduce PBUF_POOL_SIZE or PBUF_POOL_BUFSIZE"
#endif
这样编译时就能发现内存超限,不用等到运行时崩溃再查。
好了,LwIP移植的核心内容就这些。从架构到内存管理,从网卡注册到API选择,再到配置裁剪,每一步都有讲究。你动手移植时,建议先跑通一个最简单的UDP回环测试,确认网卡驱动和pbuf分配没问题,再往上加TCP和应用层。这样排查问题会快很多。