上一篇把STM32F407的MAC和PHY调通以后很多朋友在问下一步怎么走。其实对绝大多数设备联网场景来说下一步就是让lwIP协议栈在这个链路上跑起来再往上挂一个HTTPD服务器设备就能用浏览器直接访问。这篇文章就把我在F407上移植lwIP 2.1.2、搭建HTTPD服务器的完整过程拆开讲包括协议栈配置、网卡驱动对接、网页固化和踩坑记录整个工程跑通后可以顺手做一个设备配置页面或者传感器数据展示页面。适合已经能操作F407开发板、了解一点TCP/IP基础、但第一次在单片机上移植协议栈的读者。1. 移植前先想清楚硬件连接、协议栈选型和任务模型1.1 协议栈对比lwIP为什么是F407上的默认答案很多人在F407联网时纠结过这个问题到底用lwIP、uIP、FreeRTOSTCP还是自己写一个简易协议栈我的结论很直接只要不是产品ROM和RAM极度受限首选lwIP。uIP确实轻量但它的TCP实现非常简化同一时刻基本只能维护一个TCP连接HTTPD网页一旦涉及多标签页、图片、CSS并行加载就会比较吃力。FreeRTOSTCP虽然和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引脚分配每一个信号都要落实我这边的板子是正点原子探索者F407PHY用的是LAN8720ARMII模式PHY地址为0。RMII相比MII少了一半引脚10/100Mbps下完全够用。移植前先确认以下引脚分配这是整个协议栈能不能跑起来的基础功能引脚说明ETH_RMII_REF_CLKPA150MHz参考时钟必须确认方向ETH_MDIOPA2管理接口数据ETH_MDCPC1管理接口时钟ETH_RMII_CRS_DVPA7载波侦听/数据有效ETH_RMII_TXD0PB12发送数据位0ETH_RMII_TXD1PB13发送数据位1ETH_RMII_TX_ENPB11发送使能ETH_RMII_RXD0PC4接收数据位0ETH_RMII_RXD1PC5接收数据位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_SYS1主循环里轮询netif_poll和sys_check_timeouts实现简单适合只做TCP客户端、连接数很少的场景。但HTTPD服务器要处理多个TCP连接、动态页面生成、超时重传裸机轮询很容易被其他耗时任务卡住导致网页响应忽快忽慢。所以我建议F407HTTPD的工程直接用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.hlwIP源码里带一份默认配置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如果你关掉了Netconnhttpd_init会直接报错。LWIP_SOCKET则可以设为0HTTPD不需要BSD Socket接口关掉能省一点RAM。此外LWIP_DHCP如果要用DHCP自动获取IP也要置1。但我调试HTTPD时建议先用静态IP给板子指定192.168.1.10PC指定192.168.1.2排除DHCP问题等网页能打开了再改成DHCP。这个顺序能帮你省下大量排错时间。4. HTTPD服务器上线的完整路径静态网页、SSI和CGI4.1 先分清lwIP官方httpd和CubeMX生成httpdlwIP官方仓库的核心部分其实不包含HTTPDhttpd在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 URLhttpd调用你注册的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?state1开灯/a a href/led?state0关灯/a当用户点击链接时浏览器会向设备发送GET /led?state1httpd提取出参数后调用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不要低于20TCP_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的寄存器1BCR和寄存器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后面加?timestamp123如果加版本号后内容变了那就重新生成fsdata.c。乱码问题多半是编码不统一。我的做法是所有HTML文件保存为UTF-8 WITHOUT BOM然后在HTML里加meta charsetutf-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升级都会顺很多。