前面第一篇已经把环境、基础工程和以太网外设捋顺了,这一篇集中在两块硬骨头:一是把 lwIP 协议栈真正跑起来,二是让 HTTPD 服务器在里面稳稳当当地处理请求。移植这块,很多人拿到 lwIP 源码就一头扎进lwipopts.h和cc.h里,被一堆宏定义劝退。其实路径没那么玄乎,先把目标定清楚:我们最终要的是一个能 ping 通、能抗住 HTTP 并发请求、内存碎片可控的稳定网络节点。
这篇我会按实际移植顺序来写,先讲为什么用 CubeMX 生成会比手撸更省心,再逐个拆解 PHY 驱动、内存池配置、HTTPD 嵌入三个关键环节,最后把我在 F407 上实测遇到的坑和排查过程完整放出来。整个过程基于 STM32F407ZET6 + LAN8720A + lwIP 2.1.2 + FreeRTOS,CubeMX 版本 6.x,代码生成后就是一套可以直接出网页的设备固件。
1. 移植前先想清楚:CubeMX 生成和手写移植到底差在哪
先说结论:能用 CubeMX 生成的,没必要从零手写。但不是因为懒,而是因为 lwIP 的移植代码 90% 都是平台相关的样板工程,自己重写一遍未必比生成的更稳定,反而容易在底层对接上出问题。
1.1 CubeMX 帮你做了哪些“脏活”
打开 CubeMX 的 Middleware 分类,勾选 lwIP 后,它会帮你生成以下关键部分:
lwip.c和lwip.h:包含MX_LWIP_Init()初始化函数,负责协议栈启动、网卡注册、DHCP 或静态 IP 配置。ethernetif.c:这是最核心的移植层,包含网卡驱动框架、底层收发函数(low_level_input/low_level_output)、中断与轮询处理。CubeMX 生成的版本基于 STM32 以太网驱动(stm32f4xx_hal_eth.c),配合中间层描述符,能做到零拷贝收发。lwipopts.h:协议栈配置头文件,CubeMX 会按你在界面里勾选的选项自动生成一份“可用”的配置,虽然不一定最优,但至少能跑起来。
这套生成的代码最大的价值在于:它把 lwIP 和 HAL 库的耦合关系处理好,你不用自己去对齐ETH_HandleTypeDef和netif之间的关系。我见过太多人移植 lwIP 死在第一步——网卡注册时netif_add的 state 参数没正确传下去,导致底层发函数里拿不到ETH_HandleTypeDef指针,一开 TX 就 hardfault。CubeMX 生成的代码里,ethernetif.c内部已经有eth->NetIf和netif->state来回绑定的逻辑,直接用就行。
1.2 但生成代码有三个“坑位”必须自己填
CubeMX 就算再智能,也没法替你解决三件事:
PHY 芯片的具体型号相关寄存器配置。CubeMX 只能通用地配置 STM32 内置 MAC 的通信时序和地址,但 LAN8720A 的复位时序、中断引脚、速度/双工协商,都得靠你在
ethernetif.c里手动补上lan8720驱动函数。这一步不做,网卡 link 状态检测永远是 down,DHCP 永远拿不到 IP。PHY 地址的选择。LAN8720A 的 SMI 地址由 PHYAD[0] 引脚决定,默认是 0(有的模块是 1),CubeMX 配置里默认 PHY Address 是 0,如果你的模块硬件上是 1,那读写 PHY 寄存器全失败,ping 必然不通。这个在调试时极其隐蔽,因为编译、烧录、初始化全正常,就是 link 不起来。
内存堆大小的平衡。CubeMX 默认生成的
MEM_SIZE(lwIP 内存堆)是 1600 字节,这个数值只够裸机最小配置,一旦启用 HTTPD 服务器和 TCP 并发连接,立刻不够用。我在 2.1.2 版本上测试,跑 HTTPD + 2 个 TCP 连接同时在线,MEM_SIZE至少 20KB 才稳。
所以,正确的姿势是:CubeMX 生成基础框架,然后自己动手填 PHY 驱动、改内存配置、裁剪协议栈开关。接下来按这个思路走。
2. 一步步跑通协议栈:CubeMX 里必须配对的五组关键参数
这一节直接给配置清单,都是经过 F407 + LAN8720A 实测的组合。打开 CubeMX,找到 Connectivity -> ETH,然后按下面的值配。
2.1 ETH 外设的时钟与引脚裁剪
F407 的以太网 MAC 需要 25MHz 外部时钟或 50MHz 参考时钟输出。LAN8720A 是 RMII 接口,外部晶振通常直接给 50MHz,然后 REF_CLK 从 PHY 输出到 STM32 的 PA1。这里最容易出问题的是:
- LAN8720A 的 XI 引脚和 XO 引脚必须正确接外部 50MHz 晶振,绝不能把 STM32 的 MCO 引脚直接接过去当作时钟源(除非你板子设计时做了 MCO -> PHY XI 的连接)。
- CubeMX 的 ETH 配置里,RMII 时钟选项选
RMII_REF_CLK,并且确保 PA1 被自动分配到ETH_REF_CLK功能,否则 MAC 和 PHY 工作时钟不同步,起不来。
这块配置完,System Clock Mux里的 ETH 时钟会自动计算。F407 最高主频 168MHz 时,APB2 上的以太网时钟是 84MHz,刚好满足 MAC 内核要求,这组时钟关系在 CubeMX 里是自动推导的,但你要在RCC设置里把外部高速晶振(HSE)打开,否则 ETH 时钟源会变成内部 RC,精度不够,网络时通时断。
2.2 STM32CubeMX 里 lwIP 的关键选项
在 Middleware and Software Packs 里勾选 lwIP 后,进入 lwIP 配置界面。我把最终跑通的配置按表格整理出来:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| IP 版本 | IPv4 / IPv4 + IPv6 | 只用 IPv4 能省内存,HTTPD 场景够用 |
| 使能 DHCP | 可选 | 调试期建议关闭,静态 IP 好排查问题;上产品再开 |
| 静态 IP 地址 | 192.168.1.10 | 建议选一个内陆网段,避免和光猫网段冲突 |
| 子网掩码 | 255.255.255.0 | 常规 |
| 网关 | 192.168.1.1 | 仅当需要外网访问时才要 |
| LWIP 协议栈模式 | LAN8720 + 中断模式 | 中断模式比轮询模式在 HTTPD 场景下稳定 |
| 内存池大小 MEMP_NUM_PBUF | 16 | HTTPD + TCP 并发够用 |
| TCP 最大连接数 MEMP_NUM_TCP_SEG | 32 | HTTPD 的一次页面资源加载会占多个 TCP 段 |
| 内存堆大小 MEM_SIZE | 20 * 1024 | 至少 20KB,用 HTTPD 后不能低于这个数 |
| 启用 LWIP_HTTPD | 打开 | 核心目标 |
| LWIP_HTTPD_CGI | 打开 | 动态网页需要 |
| LWIP_HTTPD_SSI | 打开 | 动态内容嵌入需要 |
这些配置里,最容易被忽略的是MEMP_NUM_TCP_SEG。它控制 TCP 发送缓冲区中最多能同时存在多少报文段。一个 20KB 的网页,如果 MTU 是 1500,分片后大约需要 14 个 TCP 段。如果同时还有另一个 TCP 连接在传数据,32 这个值是底线。我之前试过用默认 16,结果浏览器请求页面时偶发性白屏,抓包看到 TCP 窗口里一堆ZeroWindow,就是段数量不够导致发送窗口被堵死。
2.3 网卡不再“裸奔”:为 LAN8720A 补一个最小可用驱动
CubeMX 生成的ethernetif.c里,low_level_init()函数末尾会调用HAL_ETH_ReadPHYRegister和HAL_ETH_WritePHYRegister检查 PHY 地址,但实际初始化还是要你自己触发 PHY 软复位和协商。
我在这个文件里补了一个lan8720_init(),核心做三件事:
- 拉高 PHY 复位引脚(我板子用的是 PG9),延时 100ms。
- 写 PHY 寄存器 0(BCR),置位第 15 位(软复位)。
- 等待自协商完成,然后读取 BSR(寄存器 1)检查 link 状态。
void lan8720_init(void) { ETH_HandleTypeDef *heth; uint32_t reg; // 假设 heth 是从 netif 或全局拿到的 HAL_GPIO_WritePin(GPIOG, GPIO_PIN_9, GPIO_PIN_SET); HAL_Delay(100); HAL_ETH_WritePHYRegister(heth, 0x00, 0x8000); // 软复位 HAL_Delay(300); do { HAL_ETH_ReadPHYRegister(heth, 0x01, ®); } while (!(reg & 0x0020)); // bit5 是自协商完成标志 // 读 BSR 的 bit2 判断 link up HAL_ETH_ReadPHYRegister(heth, 0x01, ®); if (reg & 0x0004) { // link up } }注意一个细节:HAL_ETH_WritePHYRegister的第二个参数是寄存器地址,第三个参数才是数据,别写反了。我一开始就是这个参数顺序没看仔细,软复位一直没触发,PHY 永远处于未配置状态。
另外,如果你用的是 ST 官方的 NUCLEO-F407ZG 板子,它的 PHY 是 LAN8742A,驱动里寄存器定义和 LAN8720A 不太一样,不要直接把 LAN8720 的 BSR 定义套上去。网络模块这块,一定要以你手头 PHY 的数据手册为准。
2.4 内存配置:为什么 HTTPD 必须动 MEM_SIZE
lwIP 内存管理有两种模式:内存池(MEMP)和内存堆(MEM)。MEM_SIZE控制的是内存堆的总大小,所有动态分配(比如 PBUF 的数据区、TCP 控制块、HTTPD 的缓冲)都从这里面出。
HTTPD 服务器会为每个 TCP 连接分配一个 HTTP 状态结构体(struct http_state)和发送缓冲区(HTTPD_SERVER_BUF_SIZE),后者默认 512 字节。这些都要从 MEM_SIZE 里挤。如果内存堆太小,malloc 失败,连接直接断开,浏览器表现为页面加载到一半卡住。
CubeMX 生成代码时默认的 1600 字节,连一个 HTTP 连接都养不活。实测数据:一个 HTTPD 连接 + 一个 TCP 连接(调试用)下,内存堆消耗稳定在 8KB 左右。我最终设成 20KB,给极端情况留了余量。改法很简单,在lwipopts.h里:
#define MEM_SIZE (20 * 1024)如果还想再压,可以把HTTPD_SERVER_BUF_SIZE从 512 改成 256,但代价是单次发送的 TCP 载荷变小,页面传输时 TCP 分段更多,HTTP 小文件场景影响不大,大文件会稍微慢。
2.5 FreeRTOS 和 lwIP 的优先级设多少,直接影响网页响应速度
MX_LWIP_Init()跑在 main 里,但 lwIP 的线程(tcpip_thread)和以太网中断的处理优先级要和 FreeRTOS 匹配好。CubeMX 默认生成的lwip.c里,MX_LWIP_Init()内部会启动 tcpip_thread,优先级由TCPIP_THREAD_PRIO决定,默认是 15 左右(数值越低优先级越高)。
从实测看,tcpip_thread 优先级建议设置为比 UI 任务(比如 LCD 刷新、按键扫描)高一档,但不要高于 ETH 中断。ETH 中断优先级在 NVIC 里设置,要低于 FreeRTOS 可屏蔽的最高优先级(configMAX_SYSCALL_INTERRUPT_PRIORITY),否则中断里调HAL_ETH_IRQHandler后如果触发了信号量给 tcpip_thread,可能导致优先级反转。
我现在的优先级分配:
| 任务/中断 | 优先级 | 说明 |
|---|---|---|
| ETH 中断 | 5(抢占优先级) | 高于所有任务 |
| tcpip_thread | 14 | 普通任务,中高优先级 |
| HTTPD 处理 | 不单独开线程 | HTTPD 跑在 tcpip_thread 上下文中 |
| 主循环/其他业务任务 | 20 以下 | 低于网络任务 |
这里有个容易踩的细节:HTTPD 的 CGI 和 SSI 回调默认是跑在 tcpip_thread 上下文里的。所以你的回调函数里任何阻塞操作(比如等待信号量、延时长循环)都会卡住整个 TCP/IP 协议栈。我在写 CGI 回调时踩过这个坑,回调里设了一个vTaskDelay(50),结果整个 web 页面加载都变得一顿一顿的。回调必须快进快出,要么把耗时操作扔给业务任务处理,要么改 HTTPD 线程模式。
3. 网线插上 ping 通了,HTTPD 却白屏?先搞懂 HTTPD 的嵌入方式
lwIP 的 HTTPD 有两种工作模式:一种是 standalone、自带 TCP 服务器,另一种是配合多线程模式。默认编译的httpd_init()会在 tcpip_thread 中创建 HTTP 服务。你只要在MX_LWIP_Init()之后调用httpd_init(),然后在回调里返回内容即可。
但真正的坑不在 HTTPD 本身,而在文件系统嵌入。lwIP HTTPD 默认是通过fs.c(只读文件系统抽象层)提供网页文件。你编译出的固件里如果没有把 HTML 文件“嵌”进去,那服务器状态下永远返回 404。
3.1 把 HTML 文件变成 C 数组的四步走
lwIP 提供了makefsdata工具,在lwip/src/apps/httpd/makefsdata/目录下。它会扫描一个源目录里的所有文件,生成一个fsdata.c,里面每个文件都以static const unsigned char数组的形式存放。整个过程:
- 建一个
webroot/目录,放你的index.html、.css、.js、图片等资源。 - 修改
makefsdata源码里的默认输入输出路径,或者用命令参数指定;在 Windows 下可以直接在源码目录里运行可执行文件。 - 把生成的
fsdata.c和fsdata.h复制到工程里,和fs.c一起编译。 httpd_init()之前调用fs_init()(有的版本会自动调用)。
这一步看起来不复杂,但有几个细节会导致页面解析失败:
- 文件名要求小写。
makefsdata生成的查找表默认按文件名精确匹配,你放Index.html但浏览器请求/index.html,会直接 404。 - 必须设置 HTTPD 的默认首页。在
fs.c的查找表里,默认首页文件是index.html,如果你定义成了home.html,要在配置里加LWIP_HTTPD_DEFAULT_FILE。 - 文件大小不能为 0。
makefsdata对空文件生成的数组长度是 0,HTTPD 会返回 Content-Length: 0,浏览器白屏。
3.2 SSI 和 CGI:让网页“活”起来的两个关键点
页面文件嵌入后,静态页面能用,但要显示设备状态(比如当前 IP、CPU 使用率、传感器温度),就得用 SSI 服务端注入。lwIP HTTPD 对 SSI 的处理流程:
- 网页源码里写
<!--#status-->这样的标记。 - 在 C 代码里实现
cgi_ssi_handler()回调。 - 当一个请求到来,HTTPD 扫描页面内容,发现
<!--#status-->就调用你的回调函数,把返回值替换进页面再发给浏览器。
CGI 则是处理表单提交和动态生成页面的典型方式,注册 CGI 节点后,浏览器请求/cgi-bin/test?cmd=1时,HTTPD 会调用注册的tCGIHandler函数处理查询参数,然后返回信息。
这两块代码量不大,但要注意:SSI 回调类型返回的是 C 字符串指针,且 HTTPD 内部会复制这个字符串,所以你返回的缓冲区生命周期必须覆盖整个发送周期。我之前用局部数组返回,数据被覆盖变成乱码,排查了两小时才发现是作用域问题。
实用做法是在回调里用static数组做缓冲区:
static const char* ssid_status_handler(int iIndex, int iNumParams, char *pcParam[], char *pcValue[]) { static char buf[32]; snprintf(buf, sizeof(buf), "IP:%s", ipaddr_ntoa(&netif_ip_addr(&netif_default->netif))); return buf; }这里有个常见的错误是直接把ipaddr_ntoa的返回值返回,那个返回的静态缓冲区和 SSI 回调用的可能不是同一个,多用户并发时会出现页面串号。
3.3 实测:一个最小可用的 HTTPD 初始化流程
在main.c里完成MX_LWIP_Init()后,加上这几步就能看到一个默认页面:
#include "lwip/apps/httpd.h" #include "lwip/apps/fs.h" int main(void) { // ... 其他初始化 MX_LWIP_Init(); fs_init(); // 初始化文件系统抽象层 httpd_init(); // 启动 HTTPD 服务器 // ... 启动 RTOS 调度器 }httpd_init()内部会调用tcp_bind和tcp_listen,默认监听 80 端口。如果想改端口,比如用 8080,可以改HTTPD_SERVER_PORT或者在httpd_init前重定向监听端口。
这里提醒一下:如果你用的是 CubeMX 生成工程,MX_LWIP_Init()可能已经在main()里默认执行了。你只需要在它之后调用fs_init()和httpd_init(),不要重复初始化协议栈。
4. 决定 HTTPD 稳定性的三个边界条件
服务器能不能“长期稳定跑”,不完全取决于代码正确性,还取决于你对边界条件的理解和应对。
4.1 同时在线连接数和内存之间的换算关系
HTTPD 默认配置为每个连接分配struct http_state和缓冲区,连接数上限由MEMP_NUM_TCP_PCB和MEMP_NUM_TCP_SEG共同决定。以我的配置为例:
MEMP_NUM_TCP_PCB = 4:同时最多 4 个 TCP 控制块,对应 4 个 HTTP 连接。MEMP_NUM_TCP_SEG = 32:每个连接平均能分到 8 个 TCP 段。- 每个 TCP 段 1500 字节,也就是 4 个连接同时打开时,发送缓冲区约 12KB 余量。
HTTP 1.1 下的浏览器会同时对同一域名开 6 个连接。如果MEMP_NUM_TCP_PCB只有 4,浏览器会排队等待,页面加载慢一点,但不至于崩。如果想支持 6 个以上并发,把MEMP_NUM_TCP_PCB改成 8,同时MEM_SIZE调到 25KB 以上。
4.2 keep-alive 和超时处理
HTTPD 默认开启 keep-alive 功能(HTTPD_KEEPALIVE),也就是 TCP 连接完成后不立刻关闭,而是等待下一个请求。这个功能省去了重复三次握手的时间,但对小内存设备来说是负担:一个连接长时间保持,TCP 控制块和缓冲区一直被占用。
调试阶段建议把 keep-alive 关掉(#define HTTPD_KEEPALIVE 0),这样每个请求结束后连接立刻释放,不容易积压僵尸连接。产品化时再权衡是否开启:如果请求频率高(比如每 200ms 一次状态轮询),开启 keep-alive 能省大量握手流量;如果一天只有几次请求,关掉更省内存。
另外注意 HTTPD 的超时机制:TCP_WRITE_TIMEOUT和HTTPD_RECV_BUF_SIZE配合不当会导致大页面传输时触发超时重发。实测中我遇到过页面 30KB 左右,因为TCP_SND_BUF默认 2KB,单次tcp_write会把 30KB 分成 15 个段塞进发送队列,如果MEMP_NUM_TCP_SEG不够,等待时间超过TCP_WRITE_TIMEOUT(默认 30s),连接被强制关闭。所以做嵌入式 HTTP 服务器时,页面文件尽量压缩,能合并的 CSS/JS 就合并,能精简的图片就精简。
4.3 回环与并发冲突:别在 HTTPD 回调里动共享数据
这个问题最容易线上翻车。HTTPD 的 CGI/SSI 回调在 tcpip_thread 上下文执行,而你的业务任务(比如传感器采集、GPIO 控制)跑在不同的 FreeRTOS 优先级里。两边如果同时访问同一个全局变量(比如设备状态结构体),不加保护就会出现读一半写一半的脏数据。
安全做法:业务任务的状态更改通过 FreeRTOS 队列或信号量发送给 tcpip_thread,由 HTTPD 回调消费;或者让 HTTPD 回调读取的数据来自 volatile 变量,配合临界区短保护。
我在实际项目里是把状态结构体放在一个独立的内存区域,并用taskENTER_CRITICAL()/taskEXIT_CRITICAL()包裹整个拷贝过程:
taskENTER_CRITICAL(); memcpy(&web_status, &device_status, sizeof(device_status)); taskEXIT_CRITICAL();这样虽然会有短暂的阻塞,但拷贝时间极短(几十微秒),对 100ms 级的请求周期没有任何影响。
5. 排查实录:我在这套环境里踩过的几个真实问题
最后分享三个我实际踩过、且在网上不太容易搜到明确解法的坑。这些坑的排查链路很有代表性,希望能帮你少走弯路。
5.1 问题一:PHY 地址是 1 的板子,CubeMX 默认配置全废
现象:程序启动后,串口打印ETH Link is Down,抓 PHY 寄存器全为零。
排查过程:
- 先确认硬件接线,用万用表量 PHY 的 PHYAD[0] 引脚电平,发现被上拉到高,说明 PHY 地址是 1。
- 而 CubeMX 的 ETH 配置里,PHY Address 默认 0,
HAL_ETH_ReadPHYRegister读的是地址 0 的 PHY,自然全零。 - 把 CubeMX 中 ETH 的
PHY Address改成 1,重新生成代码,问题解决。
反思:这类问题纯靠代码排查很难发现,一定要先看板子的原理图,确认 PHY 地址。网上的教程大多默认地址 0,拿到板子先确认再动手。
5.2 问题二:SSI 标记解析失灵,页面把<!--#status-->原样输出
现象:页面能打开,但设备状态位置显示的是 SSI 标记本身,没有替换成数据。
排查过程:
- 确认
LWIP_HTTPD_SSI宏已开启。 - 检查 SSI 标记格式。lwIP 默认的 SSI 标记是
<!--#tag-->,但LWIP_HTTPD_SSI_MULTIPART开启后,格式要求更严格,每个标记必须独立成段。 - 最终发现是我在 HTML 里把标记和文字写在了一行,比如
<p><!--#status--> OK</p>,解析器无法正确识别标记边界。 - 把标记独立放在一行后,替换正常。
关键经验:SSI 标记在 HTML 中必须独占一行或两侧有明显的分隔符,不能和普通文本紧贴。否则 httpd 的解析函数找不到正确的结束边界。
5.3 问题三:页面加载 60% 后卡死,TCP 窗口变成零窗口
现象:浏览器打开页面,先是飞快加载一部分,然后停在某个位置不再动,抓包看到接收方窗口为 0。
排查过程:
- 先看内存堆剩余量,发现
mem_free_count = 0,大概率是 PBUF 或 TCP 段耗尽。 - 查看
lwipopts.h里的MEMP_NUM_TCP_SEG还是默认 16。 - 页面文件 25KB,TCP 发送窗口 2KB,每个段 1460 字节,算下来至少需要 18 个段,16 不够。
- 把
MEMP_NUM_TCP_SEG改到 32,MEM_SIZE改到 25KB,重新编译下载,问题消失。
验证逻辑:这个问题的本质是“发送队列积压”,根因是发送缓冲区容量小于应用层一次性提交的数据量。计算一下你要服务的最大文件大小,除以单个 TCP 段的 MSS(通常是 1460),再加一点余量,就是最小需要的MEMP_NUM_TCP_SEG值。
6. 移植完成后的验证清单与进阶建议
移植完成不代表万事大吉,我在每次完成这类集成后都会跑一轮验证清单,确保基础功能外还能扛住一些异常场景。
- 长时间 ping 测试:连续 ping 10000 个包,丢包率必须为 0。有丢包说明底层 DMA 描述符或内存配置有问题。
- 网页循环刷新:用脚本每 500ms 刷新一次页面,连续跑几个小时,观察内存堆剩余变化。如果剩余量持续下降,说明有内存泄漏,重点排查 HTTP 连接的释放路径。
- 多客户端并发:用电脑和手机同时打开页面,看是否稳定。能稳定打开 6 个以上的并发连接,基本说明配置合理。
- 异常断开恢复:页面加载到一半直接拔网线或关浏览器,再重新连接,检查协议栈能否正常回收旧连接资源。这一步在 HTTPD 场景很重要,因为 HTTP/1.1 的 keep-alive 机制下,客户端断开不会立刻通知服务器,服务器要等到超时才回收资源。
这轮验证全部通过后,这套 HTTPD 服务器才算真正能上线用。
再往后扩展的空间也不小:可以考虑加个 WebSocket 实现实时数据上抛,也可以做固件 OTA(用 HTTPD 接收固件并写入 Flash),还能把 MQTT 协议和 HTTPD 共存,做设备管理和数据采集双通道。但前提仍然是先把底层的协议栈内存调教得足够稳,地基不牢,上层功能再花哨也是空中楼阁。
我现在这套 F407 + LAN8720A + lwIP + HTTPD 的固件已经在一个小批量项目里跑了半年多,唯一的运维需求就是定期远程重启(为了更新配置),没有因为协议栈本身崩过一次。希望这篇实操笔记能帮你少踩一些坑。