这篇文章是上一篇文章的续作。上次我们把基础工程打通了,网口外设能初始化,PHY链路状态也能读出来,但板子在网络层面还处于“半聋半哑”状态——真正干活的TCP/IP协议栈还没有跑起来,更别提在浏览器里访问设备页面了。这篇文章的目标就两件事:第一,把lwIP协议栈完整移植到STM32F407上,让板子能稳定ping通、能收发数据;第二,在它上面跑通HTTPD服务器,让浏览器直接访问板子,看到动态数据、能下发控制指令。如果你已经用CubeMX生成过一个带以太网的基础工程,或者正打算把Web功能加进嵌入式设备,这篇内容基本可以照着做。
1. lwIP移植的整体思路与选型考虑
1.1 为什么是lwIP,不是uIP、不是自己写
嵌入式设备要联网,可选方案其实不少,但大多数人最终都会落到lwIP上。最直接的理由是:lwIP的协议栈完成度高,TCP、UDP、ARP、ICMP、DHCP、DNS全都有,而且是纯C代码,移植成本相对可控。相比之下,uIP虽然更轻量,但协议层能力太弱,TCP连接管理粗糙,做HTTP这种交互场景非常别扭。自己写协议栈?除非你是网络协议专家而且时间多到用不完,否则不建议碰——光是TCP状态机加超时重传、拥塞控制这套东西,就足够把一个项目拖垮。
lwIP的名字已经说明了它的定位:lightweight IP。它对内存的占用是经过刻意优化的,可以在只有几十KB RAM的单片机上跑起来,这对STM32F407这种192KB RAM的芯片来说很合适。同时它的代码是开放的上层API,提供socket接口、RAW接口、netconn接口,既能做高吞吐的底层开发,也能快速套HTTPD这种应用层服务。
1.2 “移植”到底在移植什么
很多第一次接触lwIP的人会把“移植”想得很高大上,以为要改协议栈核心代码。实际上lwIP这套代码和硬件唯一的接口,就是网卡驱动层。官方提供了一个叫ethernetif.c的模板,里面是low_level_init、low_level_output、low_level_input这几个函数。我们要做的事,本质上就是把STM32F407内部EMAC的中断、DMA、描述符和这几个函数对接起来,再把内存分配、时钟服务这些系统依赖配置好。
对于F407来说,好消息是STM32CubeMX已经帮我们把这层模板代码生成好了。但前提是你得知道它在哪里、去哪改、哪些参数会直接影响网络稳定性。很多教程让你直接编译运行,结果ping不通也不知道去哪查,本质就是没搞清楚“协议栈”和“硬件驱动”的界线。
1.3 这次的目标形态
我这次开发的环境是这样的:MCU是STM32F407VET6,PHY芯片是LAN8720A,通过RMII接口连接外部50MHz时钟。软件层面使用STM32CubeMX生成基础工程,跑FreeRTOS,再在其上跑lwIP。整体数据通路是:网线上来的电信号由PHY芯片解码成数字帧,通过RMII总线送到F407的MAC,MAC收到数据帧后由DMA搬进内存,ethernetif_input把这个帧交给lwIP协议栈,协议栈解出TCP数据后转交HTTPD模块,HTTPD再把HTML页面内容返回给浏览器。
这套结构里每一层分工明确,调试起来也方便:物理层先看PHY,链路层看MAC和DMA,网络层看lwIP,应用层看HTTPD。后续所有排查都可以按这条线拆。
2. CubeMX里面的lwIP配置与生成代码解读
2.1 硬件接口:RMII时钟是最大的坑
F407的MAC支持MII和RMII两种接口模式。MII需要16根数据线,RMII只需要7根,所以小尺寸板子上基本都是RMII。但RMII有个硬性要求:必须为PHY提供50MHz的参考时钟。这个时钟可以来自外部有源晶振,也可以由STM32的MCO1引脚从PLL输出。很多人在这一步栽跟头:CubeMX里明明选中了RMII,代码生成也没问题,但板子网线插上去link灯不亮。
排查方法很简单,用示波器量PHY的50MHz时钟引脚,没有时钟就找硬件问题。用MCO1输出的话,注意时钟树配置里要确认MCO1的输出源和分频系数,保证在50MHz而不是其他频率。我自己的板子因为PHY复位引脚默认悬空,冷启动经常link不上,后面我说到复位时序再细聊。
2.2 CubeMX里的关键参数
在Middleware and Software Packs里打开LWIP之后,能配置的选项其实不算多,但几个关键点必须说清楚:
- PHY Address:LAN8720A一般是0,DP83848一般是31,必须和硬件原理图一致,否则MDIO读写不到PHY的寄存器
- PHY的Link Check:CubeMX里可以选ETH_LINK_CHECK,让驱动周期性检查物理链路状态
- MAC地址:给一个静态地址,比如
02:00:11:22:33:44,注意第一字节最低位要置0,这是单播地址的约定 - IP地址:如果暂时不开DHCP,就填静态IP,比如
192.168.1.10
这些参数在CubeMX面板上看似是“配置”,实际会直接映射到生成代码里的某个宏或者结构体。比如PHY地址会存到eth句柄的Init.PhyAddress字段,你要是改了硬件而不改这里,后面永远调不通。
2.3 生成的代码结构看一下
生成之后不要急着编译下载,先把文件结构捋一遍。lwip.c里有一个MX_LWIP_Init()函数,它干三件事:调用lwip_init()初始化协议栈内部模块,通过netif_add()申请网络接口,再调用netif_set_up()和netif_set_default()把这网卡设为默认路由接口。ethernetif.c里则是low_level_init、low_level_output、low_level_input这些网卡驱动函数。eth.c(实际会引用HAL库的stm32f4xx_hal_eth.c)是硬件寄存器操作层。
整个架构可以参考这个分层表:
| 层次 | 文件 | 职责 |
|---|---|---|
| 应用层 | httpd / 用户业务 | 处理HTTP请求、生成动态数据 |
| 协议栈 | lwIP核心源码 | TCP/IP、ICMP、ARP、DHCP、路由等 |
| 网卡抽象 | ethernetif.c | 把lwIP收发函数映射到HAL底层 |
| HAL硬件层 | stm32f4xx_hal_eth.c | 操作F407内存EMAC寄存器和DMA |
协议栈的性能调优配置全部集中在lwipopts.h里,这是后面真正的重头戏,下一节专门讲。
3. lwipopts.h参数详解——内存和TCP窗口怎么调
3.1 内存池还是内存堆,别搞混
lwIP的内存管理有pool和heap两套体系。pool是固定大小的内存块队列,分配速度快、不产生碎片,适合接收缓冲这类高频操作;heap是变长内存堆,灵活性高但容易碎。代码里通过PBUF_POOL_SIZE控制pool的pbuf数量,通过MEM_SIZE控制heap大小。HTTPD这种场景下,接收方向高频用pool,TCP连接结构和协议栈内部状态用heap。
很多人在网上搜配置,抄来一段参数发现还是各种“out of memory”,往往是因为没搞清pool和heap的边界。我给你一组经过实际验证的起步值:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| MEM_ALIGNMENT | 4 | 按4字节对齐 |
| MEM_SIZE | 4 * 1024 | 内存堆大小,约4KB |
| PBUF_POOL_SIZE | 16 | 接收缓冲池的pbuf个数 |
| PBUF_POOL_BUFSIZE | 1518 | 单个pbuf最大负载,够装一个以太网帧 |
| MEMP_NUM_TCP_SEG | 16 | TCP分段描述符数量 |
| TCP_SND_BUF | 4 * TCP_MSS | TCP发送缓冲区 |
| TCP_WND | 8 * TCP_MSS | TCP接收窗口 |
注意PBUF_POOL_BUFSIZE要能容纳完整的以太网最大帧1518字节,加上L2头、IP头、TCP头之后,一个接收pbuf就会占掉约1.5KB内存,16个池pbuf差不多就是24KB。F407用起来没压力,但小内存芯片就得算清楚。
3.2 TCP窗口和吞吐量的关系
很多人网页打不开或打开极慢,最后发现不是httpd的问题,而是TCP_WND太小。TCP_WND是接收窗口,相当于告诉对端“我的接收缓冲区还能收这么多数据,你放心发”。如果这个值只有2KB,浏览器下载一个5KB的HTML就要分三个往返,体感卡得不行。F407有192KB RAM,适当加大完全没问题。我自己从TCP_WND = 8 * TCP_MSS起步,调优时放到过16 * TCP_MSS,页面加载速度明显提升。
但这里有个联动关系:调大窗口的同时,MEMP_NUM_TCP_SEG也要相应增加。因为TCP层需要内存段来描述在途数据,窗口大了、描述符却不够,照样会触发内存分配失败,表现为网络时通时断。
3.3 带OS和不带OS的差别
如果使用FreeRTOS,必须把NO_SYS设为0,表示lwIP运行在操作系统之上。在这种模式下,lwIP会创建两个核心线程:tcpip_thread负责协议栈主循环和消息处理,ethernetif_input可以单独起一个任务循环收包。CubeMX生成的代码默认会在MX_LWIP_Init里通过sys_thread_new创建线程,但线程优先级、栈大小是要自己确认的。
HTTPD部分我建议也单独开一个线程,或者在tcpip_thread的上下文中直接跑,关键是别在中断上下文里处理Http请求。中断里只做驱动层收包,协议栈解析全部交给线程,这样能避免很多不可重入的问题。
4. 网卡驱动的调试:怎么让它“ping得通”
4.1 先确认物理层,再谈协议栈
移植完成后很多人第一件事就是ping,ping不通时就开始瞎改lwIP配置。其实正确的调试顺序应该是固定的三条:
- 读PHY ID,确认MDIO访问通路正常。调用
HAL_ETH_ReadPHYRegister,正常能读出LAN8720A的型号ID。读不到就先查PHY地址对不对、RMII管脚有没有配错。 - 看link。读PHY的状态寄存器,或者直接看板子上的link LED灯。
- 用静态IP互ping,不要一上来就开DHCP。
我自己的习惯是,先在代码里写一个临时函数,上电后循环打印PHY的ID和状态寄存器的link位。串口打印确定物理层OK,再放lwIP出来干活。这个习惯帮我省了很多“软件背锅”的时间。
4.2 DMA描述符和收发中断
F407的以太网MAC使用DMA描述符来管理收发缓冲区。CubeMX生成的代码里,RX描述符数量默认是ETH_RXBUFNB=6,TX描述符数量默认是ETH_TXBUFNB=4,这两个值可以直接改。接收描述符太少时,在局域网有广播风暴或者稍大流量的场景下会丢包,表现为网页偶尔打不开。
收发流程上,low_level_output会把一个pbuf链表里的数据拆块搬进DMA缓冲区,需要注意字节对齐和单块DMA buffer的大小限制。low_level_input会把DMA收到的数据包打包成一个pbuf送给协议栈。如果怀疑发出的报文有问题,用Wireshark在PC端抓包,看ARP、ICMP请求有没有正常发出来,基本一下就能定位是发方向还是收方向的故障。
4.3 一个绕不开的坑:PHY复位时序
CubeMX生成的代码对PHY的复位引脚默认是不管理的,很多PHY在板子冷启动后处于异常状态,看起来像“初始化了但网络不工作”。正确做法是在初始化早期给PHY的RST引脚一段低电平,至少10ms,再拉高,然后等1~2ms让PHY内部稳定。
我自己在MX_LWIP_Init之前加了一个GPIO操作函数,先把PHY复位,再初始化lwIP。这个改动看着不起眼,但能解决很大一部分“掉电重启后网络通不了”的玄学问题。很多人遇到的所谓“跑一阵就断网”,冷启动后大概率也能在复位时序上找到原因。
5. HTTPD服务器搭建:静态页面、SSI动态数据、CGI指令下发
5.1 先让一个页面能访问
lwIP自带了一个HTTPD模块,功能虽然不如Apache,但对嵌入式设备来说够用了。它不像Apache那样要配置httpd.conf,而是“编译时配置”,所有开关都在lwipopts.h里。首先要打开LWIP_HTTPD,这个宏默认是关闭的。
HTTPD需要一个文件系统,最简单的方式是把HTML静态页面转成一个C数组,编译进固件。lwIP官方提供了一个makefsdata工具,在lwIP contrib代码的apps/httpd/makefsdata目录下,通过命令行就能把一堆HTML文件转成fsdata.c。转换后,把fsdata.c加入工程,HTTPD会把你的index.html挂在根路径/上。
注意:
makefsdata生成的fsdata.c默认用FS_CONST修饰,不同编译器可能需要调整常量区访问方式。如果页面加载出来是乱码或者数据放在RAM里,要检查这个宏。
5.2 SSI:往HTML里插入动态数值
静态页面只是第一步,设备页面总得有温度、电压、状态这些动态数据。lwIP HTTPD用SSI实现,原理和Web开发里的服务端包含很像:在HTML里写<!--#tag-->这样的标记,httpd返回页面时发现这个标记,就调用我们自己写的tag处理函数,把标记替换成实际数值。
在lwipopts.h里需要打开:
#define LWIP_HTTPD_SSI 1 #define LWIP_HTTPD_SSI_INCLUDE_TAG 1然后实现一个tag handler,例如:
#include "lwip/apps/httpd.h" u16_t ssi_handler(const char* tagname, char* insert, u16_t insert_len, u16_t current_tag_pos) { if (strcmp(tagname, "temp") == 0) { int temp = read_temperature(); return snprintf(insert, insert_len, "%d", temp); } return 0; } static const tSSIHandler g_ssi_handlers[] = { {"temp", ssi_handler}, {NULL, NULL} }; void httpd_ssi_init(void) { http_set_ssi_handler(g_ssi_handlers, NULL, 0); }这里核心逻辑很简单:httpd在发HTML时,发现<!--#temp-->就会调用ssi_handler,参数tagname就是temp。我们填充insert缓冲区并返回长度即可。注意insert_len限制了最大长度,别把很长的字符串直接塞进去,否则httpd会按长度截断。
5.3 CGI:让网页能控制设备
页面能显示数据还不够,总得有交互。比如网页上放一个按钮,点击后控制板子上的LED。lwIP的HTTPD用CGI来实现这个功能。HTTPD在收到请求时,如果URL匹配/cgi/led?on=1这种路径,就会去查找CGI表,找到后调用对应的处理函数。函数里解析参数、操作GPIO,最后返回一个目标页面。
一个最简单的CGI处理函数示例:
#include "lwip/apps/httpd.h" static const char* cgi_led_handler(int iIndex, int iNumParams, char* pcParam[], char* pcValue[]) { for (int i = 0; i < iNumParams; i++) { if (strcmp(pcParam[i], "on") == 0) { if (strcmp(pcValue[i], "1") == 0) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); } } } return "/index.html"; } static const tCGI g_cgi_handlers[] = { {"/cgi/led", cgi_led_handler}, {NULL, NULL} }; void httpd_cgi_init(void) { http_set_cgi_handlers(g_cgi_handlers); }然后在lwipopts.h里打开LWIP_HTTPD_CGI=1。初始化时调用httpd_cgi_init(),之后浏览器访问http://192.168.1.10/cgi/led?on=1,LED就会点亮,页面被重定向回index.html。
5.4 常见但容易忽略的HTTPD细节
- 静态页面超过TCP_WND时,httpd会自动分段发送,不用太担心,但页面太大会让首次加载很慢。建议HTML控制在几KB内,CSS、JS尽量精简。
- lwIP的httpd默认支持HTTP/1.1长连接。如果返回的Content-Length和实际发送长度不一致,浏览器会一直转圈。检查自己动态生成的页面时,要确认长度是准确的。
- SSI标记是区分大小写的,而且默认最多支持
LWIP_HTTPD_MAX_TAG_NAME_LEN长度的标签名,超出会被截断导致替换失败。 - HTTPD处理函数里不要做长时间阻塞操作,比如延时1秒或者等待外部响应。HTTPD线程只有一个,你卡住了,后续所有页面请求都会排队。
6. 常见问题与排查技巧实录
把这段时间调试遇到的典型问题整理成一张速查表,方便大家对照排查:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 电脑显示“未识别的网络” | RMII时钟没给、PHY没有复位、PHY地址不对 | 先用示波器量50MHz时钟,再读PHY ID,再查link状态 |
| ping不通,但串口打印显示ARP收到 | IP地址/子网掩码配置冲突 | 换一个独立的静态IP段,比如192.168.1.x,避免和路由器冲突 |
| 网页打不开,但ping通 | LWIP_HTTPD没打开,fsdata里没有index.html | 确认lwipopts.h里有LWIP_HTTPD=1,用makefsdata重新生成 |
| 网页打开特别慢 | TCP_WND太小、DMA接收描述符太少 | 调大TCP_WND到8*TCP_MSS以上,增加ETH_RXBUFNB |
| SSI标记原样输出 | LWIP_HTTPD_SSI没打开,tag名大小写不一致,handler没注册 | 检查宏,检查http_set_ssi_handler有没有被调用 |
| CGI提交后没反应 | URL路径不对、handler没注册、参数解析出错 | 串口打印CGI收到的pcParam/pcValue,确认路径匹配 |
| 跑一段时间网络断掉 | 内存泄漏、MEMP_NUM_TCP_SEG不足、看门狗复位 | 打开lwIP统计宏看内存分配情况,排查是否有线程一直创建不释放 |
这里想特别提一句,如果你在搜索调试时看到类似“httpd: syntax error on line 506 of /applications/phpstudy/extensions/apache2”的报错,那是Apache/PHPStudy体系里的配置文件语法检查失败,跟嵌入式lwIP的HTTPD完全是两码事。嵌入式HTTPD没有“第506行配置文件”这种概念,遇到网页问题不要套用服务器的排查思路,老老实实按物理层、驱动层、协议栈层、应用层逐层确认。
7. 写在后面的一些经验
这篇文章写到的移植和搭建过程,我前后在不同板子上做过好几遍,最大的体会是:HTTPD本身移植难度不高,容易翻车的永远是硬件链路。建议你把调通顺序固定成“读PHY ID → 看link → ping → 访问网页”,每通过一步再往前推进,绝对不要跳步。另外,第一次调通之后,先备份一个能跑的lwIP工程,后面所有参数调整都在这个备份基础上做,这样改错了随时能回滚,不会越调越乱。
下一步我准备在这个HTTPD页面上把传感器数据也展示出来,用模拟I2C的方式读取环境温湿度,加一个带JQuery的监控图表,顺便把CGI做成双向控制。等跑完这一轮,我会把整套代码的结构和调试记录整理出来再发一篇。你要是正在做类似项目,遇到具体问题可以在评论区聊聊,看到都会回。