ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32F407移植lwIP并搭建HTTPD实现网页配置功能

2026/9/7 14:41:16 拓冰建站 浏览量
STM32F407移植lwIP并搭建HTTPD实现网页配置功能 上一篇我们算是把 STM32F407 的以太网外设和 PHY 芯片跑通了能在内存里看到完整的以太网帧也能把一组字节原封不动发出去。可只到这一层设备还是没法直接“上网”因为数字世界里的通信靠的是 TCP/IP 协议栈不是一个裸的 MAC 驱动能搞定的。这一篇就来解决这个问题把 lwIP 协议栈移植到工程里并且把 lwIP 自带的 HTTPD 服务器搭建起来让 STM32F407 变成一个能通过网页访问、能显示状态、能配置参数的小型网络设备。如果你正在折腾 F407 LAN8720A或者想给自己的板子加上 Web 配置页面这篇应该能帮你少走不少弯路。1. 移植前先理清 lwIP 在工程里的定位1.1 为什么不自研协议栈而是选择 lwIP很多人在写完以太网底层驱动后第一反应是“我能不能自己写一个 TCP/IP 栈”我的建议是除非你只是想学习协议原理否则千万不要在产品代码里自己造轮子。TCP 的状态机、超时重传、滑动窗口、校验和、ARP 缓存随便哪一个细节都能让人崩溃。lwIP 的强项就是轻量专门为资源受限的嵌入式设备设计在 STM32F407 这种 Cortex-M4 平台上跑得很舒服。lwIP 全称是 Lightweight IP它没有 Linux 内核里完整协议栈那么庞大但核心功能一样不少支持 IP、ICMP、UDP、TCP、ARP还有 DHCP 客户端和 DNS。对于嵌入式设备最常见的需求——上报数据、接收命令、提供网页配置lwIP 完全够用。更重要的是它被移植到无数项目里坑基本都被填平了你踩到的问题大概率在文档或者邮件列表里能找到答案。1.2 先选版本再规划目录lwIP 目前常用的稳定版本是 2.1.x我手头这个工程用的是 2.1.2。如果你是从官网下载 lwIP 主包注意它和 contrib 包是分开的。主包里有协议栈本体contrib 包里有各种移植示例、makefsdata 工具和一些应用示例。我建议两个都下载后面转网页文件时会用到 contrib 里的工具。源码目录结构大概是这样src/core协议栈核心比如 tcp.c、udp.c、ip.c、pbuf.c、mem.csrc/netif以太网接口、ARP、loopback 接口src/apinetconn 和 socket API如果你用 RTOS一般会用到这层src/apps各种应用HTTPD、SNTP、MQTT 等都在这里src/include协议栈对外头文件工程里不是所有文件都要加进去只需把当前需要的 .c 文件添加进 Keil 或 STM32CubeIDE 工程。如果全部一股脑加进去链接时会报未定义符号或者冗余定义新手很容易被吓到。1.3 裸机还是 RTOS这决定 lwIP 的运行模型lwIP 支持两种模式NO_SYS1表示裸机运行不使用操作系统NO_SYS0表示配合 RTOS 运行。裸机模式下协议栈的 tcpip_thread 不存在你必须周期性调用tcpip_thread或者通过回调方式处理收包HTTPD 这种应用如果要做并发逻辑会比较别扭。我强烈建议配合 FreeRTOS 使用让 lwIP 跑在独立任务里接收、发送、应用层各司其职。在 F407 FreeRTOS 的工程里lwIP 的标准运行模型是tcpip_thread负责处理协议栈核心逻辑所有协议栈操作都在这一个线程里排队以太网接收中断或轮询把裸帧转成 pbuf然后通过邮箱/队列发给 tcpip_threadHTTPD 等服务跑在自己线程里通过 netconn/socket API 和协议栈交互这样做的最大好处是稳定。协议栈内部大量使用静态变量和链表如果多个线程同时调用必须加锁。lwIP 提供的 sys_arch 层封装了信号量、邮箱和线程创建FreeRTOS 的移植也有现成代码。用 CubeMX 生成工程时如果勾选了 LwIP 中间件和 FreeRTOS它会自动把 sys_arch.c 加进去省去很多手工活。2. 工程接入源码、头文件与核心配置2.1 需要从源码包中拖入哪些 c 文件如果你的工程是手动移植而不是用 CubeMX 自动生成那么至少要添加这些源码文件src/core/下的def.c、dns.c、inet_chksum.c、init.c、ip.c、mem.c、memp.c、netif.c、pbuf.c、raw.c、stats.c、sys.c、tcp.c、tcp_in.c、tcp_out.c、udp.c、timeouts.csrc/core/ipv4/下的autoip.c、dhcp.c、etharp.c、icmp.c、igmp.c、ip4.c、ip4_addr.csrc/netif/下的ethernet.c、bridgeif.c一般可以不加 bridgeifsrc/api/下的api_lib.c、api_msg.c、err.c、netbuf.c、netdb.c、netifapi.c、sockets.csrc/apps/httpd/下的httpd.c、fs.c、fsdata.c这里fsdata.c就是网页资源转出来的 C 数组后面会专门讲。如果你用 CubeMX它会自动把这些文件组织好但自动生成的配置未必满足 HTTPD 需求所以还是要会自己改。还需要确定头文件路径。lwIP 的 include 目录必须加进编译器搜索路径同时需要一个自定义的lwipopts.h它告诉 lwIP 你的硬件平台和项目配置。另外lwIP 内部会包含arch/cc.h这是平台相关的编译配置用于定义数据类型、字节对齐、打印函数等。在 STM32 工程中cc.h 一般由 CubeMX 生成手动移植则要自己写一份。2.2 全局配置 lwipopts.h 可以先这样写lwipopts.h是整个移植的灵魂。它的优先级高于源码中的默认配置lwip/opt.h所有宏都要在这里定义清楚。对于 STM32F407 搭配 HTTPD 的场景一份最基础、能跑起来的配置大致如下#ifndef LWIPOPTS_H #define LWIPOPTS_H #define NO_SYS 0 #define LWIP_SOCKET 1 #define LWIP_NETCONN 1 #define LWIP_ARP 1 #define LWIP_ICMP 1 #define LWIP_DHCP 0 #define MEM_ALIGNMENT 4 #define MEM_SIZE (20 * 1024) #define MEMP_NUM_PBUF 20 #define PBUF_POOL_SIZE 20 #define PBUF_POOL_BUFSIZE 1524 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_QUEUE_OOSEQ 1 #define LWIP_HTTPD 1 #define LWIP_HTTPD_SSI 1 #define LWIP_HTTPD_CGI 1 #define LWIP_HTTPD_DYNAMIC_HEADERS 1 #define LWIP_STATS 0 #define LWIP_DEBUG 0 #endif几个关键宏说一下。NO_SYS设为 0表示使用 RTOS配合 FreeRTOS。LWIP_SOCKET和LWIP_NETCONN打开后HTTPD 可以用 socket API同时你也可以自己写 TCP client 或 server。MEM_SIZE是协议栈堆的内存池大小如果后期要传输较大数据比如 HTTP 请求包含大量表单字段建议预留 32KB 甚至更大。PBUF_POOL_SIZE是接收报文池数量太小会丢包太大会占用宝贵 RAM。F407 有 192KB RAM给 lwIP 分 20 个 1524 字节的 pbuf大约 30KB对大多数应用是够的。2.3 特别注意编译器对齐与字节序STM32 是 32 位 ARM Cortex-M4内存访问按 4 字节对齐效率最高。MEM_ALIGNMENT必须设为 4。很多诡异问题比如 TCP 校验和错误、结构体被拆成非对齐访问都是因为对齐没设对。字节序方面STM32 默认小端网络协议是大端。lwIP 内部自带转换函数一般不需要手动干预。但如果你要直接读取 IP 地址、端口号之类的字段建议统一用ntohl/htonl转换不要自己判断大小端。这里踩过坑的人应该不少明明 PC 上抓包看到端口是 80板子解析出来却是 20480十有八九是字节序没转换。还有一点容易忽略如果使用 GCC 编译器cc.h中要显式定义LWIP_PLATFORM_ASSERT和LWIP_PLATFORM_DIAG否则出错时 lwIP 会调用默认空实现问题很难定位。用 CubeMX 生成的cc.h已经处理好这些手动移植时别忘了。3. 网卡驱动把 ETH 外设交给 lwIP 管理3.1 从 HAL 库的以太网中断到 lwIP 接收lwIP 不直接操作寄存器它要求底层提供一个netif结构体并注册初始化、发送、接收等回调。STM32 HAL 库的以太网驱动会通过 DMA 把接收到的帧放到内存然后触发中断。我们要做的是在中断中把数据封装成 lwIP 的 pbuf 结构再通过tcpip_input交给协议栈。标准接收流程可以这样理解HAL 库中断回调里调用HAL_ETH_GetReceivedFrame_IT(heth)获取当前接收帧描述符从描述符中取出数据长度和数据地址用pbuf_alloc(PBUF_RAW, len, PBUF_POOL)分配一个 pbuf把 DMA 缓冲区数据搬到 pbuf 中调用netif-input(p, netif)或tcpip_input(p, netif)把 pbuf 送入协议栈再次调用HAL_ETH_Start_IT(heth)使能下一轮接收我用 CubeMX 生成工程时它会自动创建一个ethernetif.c里面的low_level_input和low_level_output就是干这个事的。CubeMX 生成的代码是标准参考实现如果你手动移植完全可以照抄它的结构。一个容易踩的坑是 DMA 接收中断里做大量数据拷贝。如果数据量很大中断里拷贝会拉高中断延迟。实际上很多项目会把以太网中断优先级调低或者用任务级别的轮询加信号量替代。F407 的以太网 DMA 和内核之间有足够缓冲中断里搬数据一般不会出事但如果你同时跑着其他实时任务最好实测一下中断延迟。3.2 低层初始化函数 low_level_init每个 netif 都要有一个 init 回调lwIP 在netif_add时调用它。典型的low_level_init要做这些事设置网卡 MAC 地址设置 lwIP 回调netif-output etharp_outputnetif-linkoutput low_level_output设置网卡能力标志初始化 STM32 的 MAC、DMA 和 PHYstatic err_t low_level_init(struct netif *netif) { netif-hwaddr_len ETH_HWADDR_LEN; netif-hwaddr[0] 0x00; netif-hwaddr[1] 0x80; netif-hwaddr[2] 0xE1; netif-hwaddr[3] 0x00; netif-hwaddr[4] 0x00; netif-hwaddr[5] 0x01; netif-mtu 1500; netif-flags NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP | NETIF_FLAG_LINK_UP; netif-output etharp_output; netif-linkoutput low_level_output; HAL_ETH_Start_IT(heth); return ERR_OK; }这里NETIF_FLAG_ETHARP表示该网卡支持 ARP 协议必须设置。NETIF_FLAG_LINK_UP是一开始就认为链路通更严谨的做法是读取 PHY 状态后设置。如果你在初始化时没设置这个标志lwIP 会认为网线没插arp 请求和 IP 包都不会发出去。3.3 发送路径与 netif 回调lwIP 发送数据时调用链路大致是应用调用send或writeTCP 层把数据切分成 MSS 大小的段调用netif-output也就是etharp_output做 ARP 解析和 IP 封装最后调用netif-linkoutput也就是low_level_output把完整的以太网帧交给 DMA所以low_level_output要做的事是把 lwIP 传来的 pbuf 链拆成一段一段填到 DMA 描述符里然后触发发送。static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; int len 0; uint8_t *buffer (uint8_t *)heth.TxDesc-Buffer1Addr; for (q p; q ! NULL; q q-next) { memcpy(buffer len, q-payload, q-len); len q-len; } HAL_ETH_TransmitFrame(heth, len); return ERR_OK; }这个简化版本只在 pbuf 链比较短、DMA 缓冲足够大的情况下能用。正式项目里要注意 pbuf 链可能跨多个内存块需要使用pbuf_copy_partial或者遍历链逐个 DMA 描述符。CubeMX 生成的标准实现用eth_prepare_transmit和eth_transmit组合处理多链更稳妥。发送函数还有一个容易踩的坑DMA 描述符被占用时不能立刻覆盖缓冲区。有些 PHY 芯片会在发送完成后通过中断告知有些需要轮询 HAL 状态。如果你在发送函数里不等待 DMA 释放连续快速发包就会把上一个包的数据覆盖掉表现是网页偶尔打不开抓包发现只有第一个包发出去了。3.4 PHY 状态轮询与链路变化PHY 芯片需要初始化比如 LAN8720A 需要通过 MDIO 接口配置寄存器检测网线插拔状态。lwIP 有一个netif_set_link_up和netif_set_link_down机制当 PHY 检测到网线插入时调用netif_set_link_up拔掉时调用netif_set_link_down。如果你不处理lwIP 会一直尝试发送但没有任何回应。我推荐在主循环或低优先级任务里轮询 PHY 状态。周期可以设 500ms用 HAL 的HAL_ETH_ReadPHYRegister读取 PHY 基本状态寄存器地址一般是 1bit 2 为 link status。检测到变化后再调用 lwIP 的链路回调。这样虽然不如中断实时但足够稳定而且代码简单。如果使用 CubeMX它会把 PHY 地址放在ethernetif.c里的ETH_PHY_ADDR宏默认 0 或其他值需要根据你的板子改。PHY 地址不对是最常见的“移植完 ping 不通”原因之一优先检查。4. HTTPD 服务器从静态网页到动态配置4.1 HTTPD 不是 PC 上的 Apache说到 HTTPD很多人第一反应是 PC 上 Apache 的配置文件。嵌入式领域的 HTTPD 是 lwIP 自带的一个轻量级 HTTP 服务器模块源码在src/apps/httpd/下。它的作用很简单监听 80 端口收到 HTTP 请求后从文件系统通常是一个 C 数组读取网页文件返回给浏览器。它支持三种主要能力静态文件服务返回 HTML、CSS、JS、图片等CGI根据 URL 或表单参数动态生成页面SSI在网页中嵌入特殊标签服务器输出时替换成实时值这些能力对设备管理页面来说足够了。你可以在嵌入式设备上放一个“系统状态”页显示 CPU 温度、内存使用量、IP 地址也可以放一个“参数配置”页通过表单修改设备运行参数。4.2 最少配置在 lwipopts.h 里打开开关HTTPD 默认是不编译的所以第一步要在lwipopts.h中把相关宏打开#define LWIP_HTTPD 1 #define LWIP_HTTPD_SSI 1 #define LWIP_HTTPD_CGI 1 #define LWIP_HTTPD_DYNAMIC_HEADERS 1LWIP_HTTPD_SSI打开后HTTPD 会扫描后缀为.shtml的文件处理其中的 SSI 标签。LWIP_HTTPD_CGI打开后HTTPD 会根据 URL 匹配 CGI 处理函数。LWIP_HTTPD_DYNAMIC_HEADERS允许我们动态修改 HTTP 响应头比如给 HTML 页面加上正确的字符集声明。如果你用 CubeMX 生成工程在 LwIP 中间件配置页面里勾选 HTTPD 相关选项它会自动在lwipopts.h里生成这些宏。手动移植则需要自己加。还需要把httpd.c、fs.c、fsdata.c三个文件加入工程。很多人移植 HTTPD 跑不起来就是因为只加了httpd.c忘了fsdata.c。fsdata.c是网页资源的唯一数据源没有它服务器没有任何可返回的文件。4.3 网页文件转成 fsdata 的详细流程lwIP 本身没有文件系统所以网页文件要以 C 数组的形式编译进固件。工具是 makefsdata通常从 lwIP contrib 目录编译得到。我的做法是建一个web/文件夹把index.html、config.html、style.css等文件放进去在命令行执行makefsdata web/工具会扫描目录并生成fsdata.c把生成的fsdata.c替换到工程里的src/apps/httpd/fsdata.c重新编译makefsdata 的默认输出文件是fsdata.c如果没指定输出文件它在当前目录生成。生成的fsdata.c是一个很大的static const unsigned char data_index_html[] {...}数组以及一个fsdata_static结构体把文件名和对应数组关联起来。需要注意的是fsdata.c必须和fs.c配合使用。fs.c提供文件查找接口当 HTTPD 解析 URL 后调用fs_open在静态表中查找同名文件。如果你的网页里有子目录比如css/style.cssmakefsdata 会在文件名前加上子目录路径HTTPD 也能正确处理。另外默认情况下 makefsdata 会把文件名转成小写。如果你的网页文件名有大写URL 访问时会 404。我习惯全部用小写命名省得踩坑。4.4 用 CGI 处理表单与动态页面CGI 是 Common Gateway Interface 的缩写虽然名字来自服务端脚本但在 lwIP 里就是一个 C 函数。当浏览器请求特定 URL 时HTTPD 调用对应的 C 函数由这个函数决定返回哪个页面。先定义一个 CGI 映射表#include lwip/apps/httpd.h static const tCGI cgi_handlers[] { { /setconfig.cgi, cgi_set_config }, }; void httpd_cgi_init(void) { httpd_set_cgi_handlers(cgi_handlers); }然后在cgi_set_config里解析参数并返回一个页面文件名。如果表单提交成功可以返回一个成功提示页static const char *cgi_set_config(int iIndex, int iNumParams, char *pcParam[], char *pcValue[]) { int i; for (i 0; i iNumParams; i) { if (strcmp(pcParam[i], device_name) 0) { strncpy(g_dev_name, pcValue[i], sizeof(g_dev_name) - 1); } else if (strcmp(pcParam[i], ip_addr) 0) { // 解析并保存 IP } } return /config_ok.html; }CGI 函数返回的是一个文件路径HTTPD 会继续读取这个文件并发送给浏览器。这样你不需要在 C 代码里拼 HTML只需要准备一个静态的“操作成功”页面即可。要注意pcParam和pcValue里的字符串是 URL 解码后的内容但空格和特殊字符可能变成或%xxlwIP 默认会做一部分解码。如果你发现中文参数乱码可以参考 lwIP 的httpd_post相关接口自己处理 POST 请求GET 方式对数据长度有限制不适合提交大量配置。4.5 用 SSI 做页面上的变量替换SSI 全称是 Server Side Include在 lwIP 中可以理解为模板替换。你可以在.shtml文件里写类似!--#temp--的标签HTTPD 输出页面时会把标签替换成你 C 函数返回的字符串。比如我有一个status.shtmlhtml body p当前温度: !--#temp--/p p设备 IP: !--#ip--/p /body /html然后在 C 代码中定义一个标签数组和替换函数static const char *ssi_tags[] {temp, ip}; static u16_t ssi_handler(int iIndex, char *pcInsert, int iInsertLen) { switch (iIndex) { case 0: snprintf(pcInsert, iInsertLen, %d, get_temperature()); break; case 1: snprintf(pcInsert, iInsertLen, %s, ipaddr_ntoa(netif_ip_addr(netif))); break; default: return 0; } return (u16_t)strlen(pcInsert); } void httpd_ssi_init(void) { httpd_set_ssi_handler(ssi_handler, ssi_tags, LWIP_ARRAYSIZE(ssi_tags)); }ssi_tags数组的下标和ssi_handler的iIndex一一对应。HTTPD 在解析.shtml文件时遇到!--#temp--就会带着iIndex0调用你的处理函数返回的字符串会直接插到网页里。使用 SSI 有两个容易踩的坑一是网页文件后缀必须是.shtml如果是.htmlHTTPD 不会扫描标签所有标签原样输出二是pcInsert缓冲区长度有限默认可能只有几百字节如果插入的字符串太长会截断。如果需要插入大量数据可以调整LWIP_HTTPD_MAX_TAG_INSERT_LEN宏。CGI 和 SSI 可以组合使用。CGI 适合处理请求、写参数后跳转SSI 适合在页面展示实时数据浏览器每次刷新都会拿到最新值。很多设备管理页面就把状态显示用 SSI 做配置提交用 CGI 做分工很清晰。5. 联调、踩坑与排查实录5.1 从 ping 不通到能打开网页的调试顺序当我把代码烧进板子后第一个目标很简单ping 通。这个目标不实现后面所有 HTTPD 都是空谈。调试顺序我一般这样来第一检查 PHY 是否正常。用逻辑分析仪或串口打印 PHY 寄存器值看能否读到正确的 PHY ID能不能读到 link 状态。如果 PHY 地址不对MDIO 通信是收不到有效数据的。第二用静态 IP 就不用依赖 DHCP。给板子设一个固定 IP比如 192.168.1.100电脑网卡设 192.168.1.50中间用网线直连或者接交换机。先不要开 DHCP能快速排除地址获取的问题。第三PC 上ping 192.168.1.100。通了说明 MAC 驱动、lwIP 的 ARP、ICMP 都正常。不通就在板子上抓 ICMP 接收情况。可以在low_level_input里加打印看能不能收到 ARP 请求。收到 ARP 请求但 ping 不通说明回复没有发出去重点查low_level_output和 DMA。第四ping 通之后再打开浏览器访问http://192.168.1.100/。如果能看到默认页面说明 HTTPD 的静态文件服务和 TCP 服务正常。如果看不到优先看串口日志里有没有httpd启动信息以及 80 端口是否被占用。5.2 高频问题速查表现象可能原因排查方法ping 不通PHY 地址错误、RMII 时钟不对、网线没插好读 PHY ID 和 link 状态ping 通但网页打不开LWIP_HTTPD宏没开、httpd.c没编译、80端口冲突串口打印启动信息检查 lwipopts.h收到网页很慢TCP_WND太小、PBUF_POOL_SIZE太小调大TCP_WND和 pbuf 池网页 404fsdata.c里没有对应文件、文件名大小写不对用 makefsdata 重新生成网页乱码HTTP 响应缺少 charsetutf-8开启LWIP_HTTPD_DYNAMIC_HEADERS并自定义 Content-Type刷新几次后死机内存池耗尽、任务栈溢出检查 MEM_SIZE、任务栈大小PHY 状态不稳定网线质量、RMII 走线、供电不足换短网线检查 PHY 电源那张表列的是我在不同项目里实际遇到过的组合。HTTPD 死机最常见的元凶就是 pbuf 池不够。浏览器打开页面时会发起多个 TCP 连接每一个连接都可能占用多个 pbuf。如果PBUF_POOL_SIZE只有 10 个一次多标签页刷新就满了之后新收到的包没有 pbuf 可分配协议栈直接丢弃严重时把内存链表弄坏设备死机。5.3 几个让我印象深刻的经验先说一个最容易坑新手的点别把嵌入式 HTTPD 和 PC 上的 Apache 混为一谈。网上搜“httpd: syntax error on line 506”这类报错大概率是电脑上 Apache 配置文件的问题跟 STM32 的 lwIP HTTPD 没有关系。嵌入式 HTTPD 没有配置文件只有 C 代码和 fsdata 表。遇到报错先看清环境别被热搜词带偏。再说一个和网页编码有关的坑。lwIP 默认给.html文件返回的Content-Type是text/html不带charset。如果页面里有中文浏览器会按照系统默认编码猜测经常显示成乱码。解决方法是自己定义一个响应头处理函数static void httpd_custom_header(void *arg, struct pbuf *p, u16_t ssi_tag_len) { httpd_set_file_text_content_type(html, text/html; charsetutf-8); }或者在 makefsdata 工具的参数里指定默认编码。最简单的办法是把网页文件改成 UTF-8 无 BOM并在 HTML 头部加meta charsetutf-8多数浏览器也能正确识别。最后一个经验是调试网络时不要总想着靠串口打印。串口打印会阻塞尤其在高波特率下网络中断一多打印本身就成了瓶颈。我习惯在代码里保留几个关键日志开关默认关闭调试时再打开。等基本功能正常后用 Wireshark 抓包看 TCP 握手和 HTTP 请求比看串口日志直观得多。如果你在 HTTPD 里用了 CGI 和 SSI还要注意它们的调用时机。SSI 处理函数是在发送网页过程中被多次调用的不是一次调用生成整个页面。所以在 SSI 处理函数里不要做耗时操作比如读 Flash、传感器轮询否则浏览器会一直等。我一般会把实时数据缓存到全局变量由专门线程定时更新SSI 只做字符串拷贝这样页面刷新速度会快很多。这个工程继续往下做还可以扩展 HTTP 客户端功能让设备主动向服务器提交数据或者通过网页触发固件升级。lwIP 的 HTTPD 只是入口协议栈本身还提供了很多能力移植一遍之后你会对 TCP/IP 的工作原理有更切身的理解。慢慢折腾收获比想象中大。