上一篇把STM32F407的MAC和PHY调通以后,很多朋友在问下一步怎么走。其实对绝大多数设备联网场景来说,下一步就是让lwIP协议栈在这个链路上跑起来,再往上挂一个HTTPD服务器,设备就能用浏览器直接访问。这篇文章就把我在F407上移植lwIP 2.1.2、搭建HTTPD服务器的完整过程拆开讲,包括协议栈配置、网卡驱动对接、网页固化和踩坑记录,整个工程跑通后可以顺手做一个设备配置页面或者传感器数据展示页面。适合已经能操作F407开发板、了解一点TCP/IP基础、但第一次在单片机上移植协议栈的读者。
1. 移植前先想清楚:硬件连接、协议栈选型和任务模型
1.1 协议栈对比:lwIP为什么是F407上的默认答案
很多人在F407联网时纠结过这个问题:到底用lwIP、uIP、FreeRTOS+TCP还是自己写一个简易协议栈?我的结论很直接:只要不是产品ROM和RAM极度受限,首选lwIP。
uIP确实轻量,但它的TCP实现非常简化,同一时刻基本只能维护一个TCP连接,HTTPD网页一旦涉及多标签页、图片、CSS并行加载就会比较吃力。FreeRTOS+TCP虽然和FreeRTOS集成度高,但需要内核版本匹配,而且社区资料和现成例程明显比lwIP少,遇到问题不那么容易搜到答案。自己写协议栈就更不推荐了,TCP的滑动窗口、重传、超时管理、校验和卸载处理,每一项都是几个月的调试量,除非是学习目的,否则别在生产项目里自研。
lwIP的优势在于:协议栈和应用层分离得比较干净,netif接口把网卡驱动和TCP/IP核心解耦;支持RAW API、Netconn API、Socket API三种编程模型;HTTPD、DHCP、DNS这些常用服务都有现成实现;从STM32、ESP32到各种RTOS都有大量成功案例。对一个F407项目来说,lwIP 2.1.x足够成熟,资料也多,是最稳的选择。
1.2 硬件与RMII引脚分配:每一个信号都要落实
我这边的板子是正点原子探索者F407,PHY用的是LAN8720A,RMII模式,PHY地址为0。RMII相比MII少了一半引脚,10/100Mbps下完全够用。移植前先确认以下引脚分配,这是整个协议栈能不能跑起来的基础:
| 功能 | 引脚 | 说明 |
|---|---|---|
| ETH_RMII_REF_CLK | PA1 | 50MHz参考时钟,必须确认方向 |
| ETH_MDIO | PA2 | 管理接口数据 |
| ETH_MDC | PC1 | 管理接口时钟 |
| ETH_RMII_CRS_DV | PA7 | 载波侦听/数据有效 |
| ETH_RMII_TXD0 | PB12 | 发送数据位0 |
| ETH_RMII_TXD1 | PB13 | 发送数据位1 |
| ETH_RMII_TX_EN | PB11 | 发送使能 |
| ETH_RMII_RXD0 | PC4 | 接收数据位0 |
| ETH_RMII_RXD1 | PC5 | 接收数据位1 |
这里最容易踩的一个坑是REF_CLK。LAN8720A的REF_CLK可以由PHY自己产生并输出给STM32,也可以由STM32的MCO引脚产生并输入给PHY。如果板子设计的是PHY输出50MHz给PA1,但固件里却把PA1配成了输入模式下等待时钟,或者反过来把时钟源配置成MCO但没使能对应引脚,整个RMII接口就会完全静默,表现就是PHY寄存器能读到、link状态正常,但数据收发一帧都不通。移植前建议先用示波器或逻辑分析仪确认PA1上有50MHz时钟,再用STM32的ETH_MAC_MDIO_ClockRange配置MDC时钟范围,确保MDIO通信正常。
1.3 裸机还是RTOS:任务模型决定协议栈运行方式
lwIP可以跑在裸机上,也可以跑在RTOS上,但两种模式的代码结构差别很大。裸机模式下通常设置NO_SYS=1,主循环里轮询netif_poll和sys_check_timeouts,实现简单,适合只做TCP客户端、连接数很少的场景。但HTTPD服务器要处理多个TCP连接、动态页面生成、超时重传,裸机轮询很容易被其他耗时任务卡住,导致网页响应忽快忽慢。
所以我建议F407+HTTPD的工程直接用RTOS。带RTOS时使用lwIP的tcpip_thread模式,网卡接收中断只需要发一个信号量给tcpip_thread,协议栈的输入处理、超时处理都在这个线程里完成,任务模型非常清晰。CubeMX生成的STM32F407工程里也可以同时勾选FreeRTOS和LwIP,让工具自动把两者衔接起来。自己手动移植时,无非是多创建一个tcpip_thread,并确保网卡驱动的RX中断能正确唤醒它,这一点在第2章会详细展开。
2. 网卡驱动与lwIP的底层契约:netif接口对接
2.1 先理解lwIP调用网卡的四个回调
lwIP协议栈本身不关心你的网卡是STM32内置MAC、SPI接口W5500还是外部独立MAC芯片,它只通过struct netif结构体来调用网卡驱动。移植的核心工作是实现四个东西:netif_add时传入的init回调、发送函数linkoutput、接收函数input、以及一个set默认网卡的函数。init回调里做底层MAC和PHY初始化,linkoutput负责把lwIP给的pbuf数据包发送到以太网,input则由驱动主动调用,把收到的数据包交给上层协议栈。
在STM32的HAL库工程里,通常体现为ethernetif.c文件,里面有low_level_init、low_level_output、low_level_input这几个函数。很多人移植时喜欢一上来就改协议栈调用流程,其实没必要,lwIP的接口已经很稳定,你只需要把F407的DMA描述符、MAC寄存器、PHY管理操作填进这几个函数里。
2.2 发送路径:low_level_output里的描述符管理
发送函数的核心逻辑不复杂,但描述符管理很容易出错。lwIP会把一个数据包组织成一个pbuf链表,你需要在low_level_output里遍历这个链表,把数据交给F407的ETH DMA发送描述符。参考代码结构大致如下:
static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; u32_t len = 0; u8_t *buffer = (u8_t *)ETH->TX_DESC_BUF; // 等待可用的发送描述符 if (ETH_GetTxDescOwner() != 0) { return ERR_USE; // 描述符忙,让lwIP稍后重试 } // 依次拷贝pbuf链表中的每个片段到DMA发送缓冲区 for (q = p; q != NULL; q = q->next) { memcpy(buffer + len, q->payload, q->len); len += q->len; } // 配置描述符长度并触发发送 ETH_TransmitFrame(len); return ERR_OK; }这里的第一个坑是:F407的DMA描述符是环形链表,硬件发送完毕后会清除OWN位。如果你在写代码时不检查描述符是否被硬件占用,而是在忙时直接覆盖描述符,就会造成发送数据错乱,表现为协议栈偶尔能通、偶尔超时。正确做法是OWN位置1时返回ERR_USE或ERR_MEM,让lwIP的重传机制去处理。第二个坑是发送缓冲区需要保证4字节对齐,因为lwIP的pbuf payload有对齐要求,而DMA描述符里的缓冲区地址如果不对齐,则需要在拷贝时做偏移处理。
2.3 接收路径:low_level_input和中断的关系
接收路径比发送路径更考验对RTOS的把握。F407的ETH接收DMA会把收到的帧写入接收描述符,然后置位接收中断。low_level_input做的事情是:检查接收描述符状态,读取帧长度,分配一个pbuf,把DMA接收缓冲区里的数据拷贝到pbuf,然后释放描述符,最后把pbuf交给netif->input。
带FreeRTOS时,推荐的接收流程是:
- ETH接收中断里只做一件事:调用xSemaphoreGiveFromISR唤醒接收处理任务,或者直接调用tcpip_input。
- 在接收处理任务中调用ethernetif_input,该函数内部读取描述符并调用netif->input。
- netif->input在带OS模式下会进入tcpip_thread,由协议栈核心处理IP/TCP。
很多初学例程为了省事,直接在ETH中断里调用ethernetif_input,这样在低负载下也能跑,但中断上下文里做pbuf分配和协议栈处理会拉长中断关闭时间,在频繁收发时可能丢包。所以我的建议是:宁可多写一个接收任务,也不要直接在中断里处理协议栈输入。尤其是HTTPD服务器需要并发处理多个TCP连接时,接收路径的不稳定会直接反映在网页打不开上。
2.4 校验和、内存对齐和F407的CCM RAM问题
F407的以太网MAC自带IP、TCP、UDP校验和计算和校验功能,可以在硬件上完成校验和卸载,减轻CPU负担。但有个前提:要么你在lwIP里关闭对应的软件校验和宏,要么你确保硬件校验和与软件校验和不会重复计算。一般来说,我会在lwipopts.h里把CHECKSUM_GEN_IP、CHECKSUM_GEN_TCP、CHECKSUM_GEN_UDP都设为0,完全交给MAC硬件处理,实测省了不少CPU时间。但如果你的数据包不是从普通RAM发出,而是放在CCM RAM里,那么硬件校验和会失败,因为CCM RAM无法被DMA访问。
这是F407移植lwIP特有的大坑。F407有192KB SRAM,另外还有64KB CCM RAM,地址在0x10000000,只有CPU能访问,DMA和以太网控制器都访问不到。如果有人为了提高PBUF性能,把lwIP的堆或DMA描述符分配到CCM区域,会发现数据收发完全不行。正确做法是:DMA描述符、接收缓冲区、lwIP的MEM_SIZE内存池都必须放在普通SRAM区域,CCM只能放一些纯CPU计算的变量。
3. lwipopts.h就是平衡木:线程、内存和校验和都在这定
3.1 为什么每个移植工程都要有一个lwipopts.h
lwIP源码里带一份默认配置opt.h,里面所有配置项都有默认值。但默认值面向的是PC模拟环境,直接用到STM32F407上必然有问题,所以每个实际工程都会在自己的头文件目录下放一个lwipopts.h,在编译时覆盖opt.h里的默认配置。opt.h首先包含lwipopts.h,后者里定义的宏优先生效,lwIP的这套配置覆盖机制设计得很清晰,你不需要去改lwip源码。
我建议把lwipopts.h放在工程顶层,和FreeRTOSConfig.h并列,方便统一管理。凡是协议栈相关的宏都集中写在这里,不要散落在各处,否则以后升级lwIP版本时会非常痛苦。
3.2 内存尺寸估算:RAM是F407最贵的资源
lwIP的内存使用主要集中在三块:PBUF池、RAM堆、以及协议控制块。PBUF池用于存放接收数据包和一部分发送数据包,RAM堆用于TCP窗口、连接结构体等动态分配。F407虽然有192KB SRAM,但还要放FreeRTOS任务栈、应用缓冲和DMA描述符,不可能全部给协议栈。
下面是一份我实测可用的配置,供参考:
#define MEM_ALIGNMENT 4 #define MEM_SIZE (20 * 1024) #define MEMP_NUM_PBUF 16 #define PBUF_POOL_SIZE 20 #define PBUF_POOL_BUFSIZE 1536 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (4 * TCP_MSS) #define TCP_SND_QUEUELEN (8 * TCP_SND_BUF / TCP_MSS) #define LWIP_HTTPD 1 #define LWIP_HTTPD_SSI 1 #define LWIP_HTTPD_CGI 1TCP_WND设为4倍MSS,大约5.8KB,意味着每个TCP连接的接收窗口可以缓冲4个1460字节的数据段,HTTP服务器单连接传输一个几KB的网页文件时不需要频繁等待ACK,速度会好很多。TCP_SND_BUF同理,4倍MSS保证发送缓冲区能平滑地推送页面内容。PBUF_POOL_SIZE设为20,每一个PBUF对应一个网络数据帧的存放,20个可以支撑HTTPD在多个并发连接下不掉包。如果发现网页偶尔打不开或抓包看到TCP重传,优先加大这个值和PBUF_POOL_BUFSIZE,而不是盲目加大MEM_SIZE。
3.3 与FreeRTOS挂钩的四个关键宏
NO_SYS设置为0,表示使用操作系统。LWIP_TIMERS设置为1,让lwIP使用系统定时器线程来处理TCP超时和ARP超时。SYS_LIGHTWEIGHT_PROT设置为1,使lwIP内部可以用临界区保护核心数据结构。LWIP_TCPIP_CORE_LOCKING建议设为0,使用tcpip_thread消息机制而非全局锁,这样HTTPD等应用层API调用会更安全,不会因为一个任务持锁时间过长而卡住整个协议栈。
还要注意tcpip_thread的栈大小。默认值可能偏小,HTTPD在运行CGI或者SSI回调时会在tcpip_thread上下文中执行,如果回调里调用了snprintf、格式化浮点数这类栈消耗比较大的函数,线程栈太小会直接HardFault。我在FreeRTOSConfig.h里给tcpip_thread分配了1024字(4KB),实际使用中比较稳。
3.4 容易被忽略的HTTPD相关宏
LWIP_HTTPD这个宏必须在lwipopts.h里显式置1,否则即使你编译了httpd.c,它也不会被注册到协议栈里。LWIP_HTTPD_SSI和LWIP_HTTPD_CGI分别控制SSI和CGI功能,如果要用动态页面这两个必须打开。另外LWIP_NETCONN要设置为1,因为lwIP的HTTPD默认走Netconn API,如果你关掉了Netconn,httpd_init会直接报错。LWIP_SOCKET则可以设为0,HTTPD不需要BSD Socket接口,关掉能省一点RAM。
此外,LWIP_DHCP如果要用DHCP自动获取IP,也要置1。但我调试HTTPD时建议先用静态IP:给板子指定192.168.1.10,PC指定192.168.1.2,排除DHCP问题,等网页能打开了再改成DHCP。这个顺序能帮你省下大量排错时间。
4. HTTPD服务器上线的完整路径:静态网页、SSI和CGI
4.1 先分清lwIP官方httpd和CubeMX生成httpd
lwIP官方仓库的核心部分其实不包含HTTPD,httpd在contrib仓库的apps目录下。STM32CubeMX生成工程时,可以在Middleware and Software Packs里勾选LwIP,然后在Advanced Parameters中启用HTTP Server,工具会帮你加入httpd源码并配置好相关宏。这种方式胜在省事,但CubeMX生成的httpd版本相对固定,想改内部实现比较麻烦。
我更推荐手动集成contrib版本的httpd,原因有二:一是可以精确选择lwIP版本和httpd版本,避免CubeMX升级后API对不上;二是makefsdata工具和ssi/cgi的示例代码在contrib里都有,遇到问题可以直接对照源码。手动集成无非是把httpd.c、httpd_structs.c、fsdata.c等文件丢进工程,再加上头文件包含路径,没有想象中复杂。
4.2 httpd初始化与tcpip线程的启动顺序
无论用哪种方式集成,httpd_init()必须在tcpip_init()成功之后调用,因为httpd需要向协议栈注册TCP监听端口。带FreeRTOS的典型main函数流程如下:
tcpip_init(NULL, NULL); httpd_init();如果是在裸机NO_SYS模式下,httpd_init()同样可用,但必须在主循环里周期调用sys_check_timeouts()和网卡接收轮询,否则HTTP连接的超时重传和接收处理得不到执行,网页会表现成只能打开一次,之后就卡死。
httpd默认监听80端口。如果你的设备上还有别的组件占用80端口,比如另一个自定义TCP服务,就要通过httpd_port变量改成8080或者其他端口。我用过8080调试,PC浏览器里输入http://192.168.1.10:8080也能正常访问。
4.3 用makefsdata把网页文件变成C数组
lwIP httpd本身不依赖文件系统,它读取的是fsdata.c里编译好的文件系统镜像。你可以在工程目录下建一个fs文件夹,把index.html、404.html、style.css都放进去,然后利用contrib/apps/httpd/makefsdata目录下的工具生成fsdata.c。
在PC上操作时,先编译makefsdata工具,然后进入fs目录执行:
makefsdata .如果makefsdata不在当前目录,需要给全路径。生成后它会输出一个fsdata.c文件,这个文件其实就是把网页文件的二进制内容转换成了const数组。把新生成的fsdata.c替换掉工程里原来的fsdata.c,再重新编译烧录即可。
这里有一个高发坑:很多人改完网页的HTML,却忘了重新运行makefsdata,直接编译后打开浏览器发现页面还是老样子。因为固件里存的本来就是旧数组,跟源文件无关。每次改完HTML后必须重新生成fsdata.c再编译,我在项目里习惯把makefsdata生成动作写进编译脚本,避免人为遗漏。
4.4 SSI动态变量:让网页实时显示传感器数据
静态网页只能显示写死的文字,HTTPD要显示温度、电压、设备状态这些实时数据,就要用SSI机制。SSI的标签格式是HTML注释形式,比如:
<div>当前温度:<!--#temp--></div>当httpd发送这个页面时,它会扫描网页内容里的SSI标签,每遇到一个标签就调用你注册的SSI回调函数,回调返回的内容会替换掉标签。注册回调的代码放在httpd_init之后:
http_set_ssi_callback(ssi_handler);对应的处理函数:
static u16_t ssi_handler(const char *tag, char *output, u16_t outlen) { if (strcmp(tag, "temp") == 0) { int temp = read_temperature_x100(); snprintf(output, outlen, "%d.%02d", temp / 100, temp % 100); return strlen(output); } return 0; }SSI标签名有长度限制,默认最大值是LWIP_HTTPD_MAX_TAG_NAME_LEN,好像偏小,在lwipopts.h里可以改大。另外,SSI回调是在tcpip_thread上下文执行的,不要在回调里做HAL_Delay、串口阻塞打印这类操作,否则整个TCP协议栈都会被拖住,页面会加载得非常慢。如果需要读取传感器,最好把实时值放在一个全局变量里,SSI回调只做格式化。
4.5 CGI接口:按钮控制设备与表单提交
SSI负责动态显示,CGI负责动态动作。比如网页上放一个开关,点击按钮后浏览器请求一个特定CGI URL,httpd调用你注册的CGI处理函数,函数里操作GPIO或者继电器,然后返回一个页面URL让浏览器跳转。注册方式:
static const tCGI cgi_handlers[] = { { "/led", cgi_led }, }; http_set_cgi_handlers(cgi_handlers);CGI处理函数签名如下:
static const char *cgi_led(int iIndex, int iNumParams, char *pcParam[], char *pcValue[]) { int i; for (i = 0; i < iNumParams; i++) { if (strcmp(pcParam[i], "state") == 0) { if (strcmp(pcValue[i], "1") == 0) { LED_On(); } else { LED_Off(); } } } return "/index.html"; }网页里的按钮可以这样写:
<a href="/led?state=1">开灯</a> <a href="/led?state=0">关灯</a>当用户点击链接时,浏览器会向设备发送GET /led?state=1,httpd提取出参数后调用cgi_led,完成控制后浏览器跳回index.html。这种交互非常适合设备配置页:把WiFi模块的SSID和密码、串口波特率等参数放在表单里,通过CGI提交到单片机,再存进EEPROM或Flash。
4.6 HTTPD运行时的内存峰值比想象中高
HTTPD每次处理一个HTTP请求,都会在tcpip_thread和内存池中创建并销毁连接结构。如果一个网页包含10个小文件(HTML、CSS、JS、图片),浏览器会同时建立多个TCP连接去请求,这时PBUF池的使用量会瞬间暴涨。如果PBUF_POOL_SIZE不够,表现就是请求出现TCP重传,页面加载特别慢,甚至某个子资源一直加载不出来。
我的建议是:PBUF_POOL_SIZE不要低于20,TCP_SND_QUEUELEN不要低于16,同时把LWIP_HTTPD_MAX_CONNECTIONS这个宏稍微调大一点。HTTPD处理的是并发连接,不是单连接大吞吐,重点保证连接数量和缓冲池足够。如果内存实在紧张,可以缩小网页资源体积,把CSS和JS内联到HTML里,减少并发连接数。
5. 联调排错实录:从ping不通到页面乱码
5.1 第一轮:ping不通时,从PHY和DMA查起
协议栈移植完成后,第一个要验证的是链路层。先把开发板和PC用网线直连,注意有些PC网卡支持自适应,有些需要交换机,直连时不行就换交叉线或者加个交换机。给板子设静态IP后,从PC上ping 192.168.1.10。
ping不通时按顺序检查三个地方。第一,PHY的Link状态:读LAN8720A的寄存器1(BCR)和寄存器31,确认速率和双工模式正常。第二,RMII时钟:用示波器看PA1有没有50MHz。第三,DMA描述符初始化:很多例程里定义了ETH_DMADescTypeDef数组,必须用ETH_DMARxDescChainInit正确初始化并设置接收描述符的OWN位,否则MAC不会填充接收数据,RX中断也不会产生。把这三个都查完,ping不通的概率会大幅下降。
如果PHY loopback测试能通但实际网络不通,多半是MDIO配置或者REF_CLK方向问题,跟lwIP本身无关。这块务必在调lwIP之前单独验证,否则协议栈和底层问题混在一起,排查难度会成倍上升。
5.2 第二轮:能ping通但打不开网页
能ping通说明IP层和ARP层没问题,TCP连接是下一步。这时先在PC上用telnet测试端口:
telnet 192.168.1.10 80如果telnet能连接但没有任何输出,说明TCP端口监听已经起来了,问题在HTTPD处理流程,可能是网页文件没编进固件,或者fsdata.c缺失。如果telnet直接Connection refused,说明80端口根本没监听,检查httpd_init是否真的被调用、LWIP_HTTPD宏是否为1、httpd.c是否参与编译。
还有一种情况是防火墙拦截,PC的telnet和浏览器都被Windows防火墙挡掉,可以临时关闭防火墙或添加规则允许目标IP通信,排除环境因素。不要一上来就认为是固件问题,先软件后硬件,先PC后板子。
5.3 第三轮:页面能打开,但数据是旧的或者乱码
页面能打开说明HTTPD基本工作,剩下的问题通常是内容层。
旧数据大概率是makefsdata没重新生成,我见过项目组的同事改了HTML之后只重新编译下载,所有的努力都耗在浏览器缓存上。先清缓存或在URL后面加?timestamp=123,如果加版本号后内容变了,那就重新生成fsdata.c。乱码问题多半是编码不统一。我的做法是:所有HTML文件保存为UTF-8 WITHOUT BOM,然后在HTML里加<meta charset="utf-8">,这样httpd返回数据时浏览器就能正确解码。如果SSI替换的中文是从代码里拼出来的,注意snprintf的第二参数outlen是剩余缓冲区大小,不要越界写,否则会破坏内存池。
SSI标签没有被替换、原样显示成<!--#temp-->,说明SSI回调没生效。检查LWIP_HTTPD_SSI宏是否为1,以及http_set_ssi_callback有没有在httpd_init之后调用。还有一个隐蔽原因是标签名过长,被httpd截断导致匹配失败,把标签名改短一点重新makefsdata。
5.4 一个真实的HTTPD反复重启崩溃案例
最后分享一个我调试时的真实案例。现象是:页面能打开,但高频率刷新十几分钟后设备HardFault。排查下来不是协议栈内核问题,而是SSI回调里调用了一个带阻塞延时和printf的传感器读取函数。SSI回调在tcpip_thread上下文执行,printf阻塞等待串口DMA完成,tcpip_thread卡住几十毫秒,期间网络中断和其他任务还在抢CPU,最终某个连接超时重传,协议栈在异常状态下反复操作同一个pbuf,触发了内存池断言错误。
解决方法是把传感器读取放在独立的任务里,结果存成全局变量,SSI回调只做snprintf格式化。另一个相关案例是有人把ETH DMA描述符数组放在CCM RAM,导致DMA完全无法访问描述符,RX中断永远不触发。把描述符声明改为普通SRAM区域后立刻恢复正常。这两个案例提醒我,在F407上跑lwIP,内存归属和线程上下文是两根高压线,碰一次就够你排查一整天的。
最后给准备动手的朋友一个建议:第一次移植不要贪多,先在静态IP下把HTTPD配好,能访问一个最简单的hello world页面,再逐步加DHCP、加SSI、加CGI。每加一个功能就验证一次,这样即使出问题,也能快速定位是哪一层引入的。HTTPD跑稳之后,下一步再考虑HTTPS加密、MQTT上云或者OTA升级,都会顺很多。