STM32F407移植lwIP并搭建HTTPD服务器:从网卡驱动到网页远程控制
2026/9/9 11:19:11 网站建设 项目流程

上一篇文章把STM32F407的MAC和PHY折腾通了,网线插上去Link灯能亮,串口能看到DMA收到的以太网帧。但很多朋友跟我说的第一句话是:“光收帧有啥用,能不能让板子变成一个小网站?”当然能。这一篇就干这件事——把lwIP协议栈移植到STM32F407上,并且在上面把官方HTTPD服务器跑起来。做完之后,你打开浏览器输入板子的IP,就能看到一个由单片机自己托管、可以实时刷新数据的小页面。

这篇内容适合两类人:一类是刚把网络接口调通、不知道下一步怎么走的嵌入式开发老哥;另一类是手里有F407开发板,想快速体验TCP/IP,又不想一上来就啃协议栈源码的入门玩家。我默认你已经有一个能编译、能下载、串口能正常输出日志的STM32F407工程,PHY部分拿LAN8720A举例,你板子上要是DP83848或者国产PHY,需要注意的差异点我会顺手提一下。

1. 移植前到底要准备什么

1.1 工程结构与代码目录安排

我这次用的是STM32CubeMX生成工程、HAL库驱动的方式,在工程配置里勾选ETH外设和LwIP中间件,因为CubeMX生成的代码里已经帮你把lwip.cethernetif.c这些骨架搭好了,省去纯手工写标准的功夫。但有一个点必须说清楚:CubeMX生成的代码只是“能编译”,真正能跑得多稳,还是要靠你自己调整内存池大小、确认DMA描述符位置、检查PHY复位时序。别把CubeMX当成万能钥匙。

整个工程的层次大概是这样的:

  • Drivers/:HAL库,ETH驱动相关代码在这里。
  • Middlewares/Third_Party/LwIP/:lwIP协议栈本体,src/core下面放着TCP、IP、ICMP、UDP等核心实现。
  • Middlewares/Third_Party/LwIP/src/apps/httpd/:HTTPD服务器官方源码,咱们要用它的CGI和SSI机制。
  • Core/Src/lwip.c:CubeMX生成,负责协议栈时钟、以太网MAC、DMA描述符等底层初始化。
  • Core/Src/ethernetif.c:这是协议栈和硬件之间的桥梁,收包、发包、链路上报都在这里。

如果你不是用CubeMX,而是从LwIP官网下载源码包手动移植,那核心文件也是同样的思路:lwip.c对应你自己写的MAC/PHY初始化,ethernetif.c就是标准的netif接口实现。后面讲初始化流程的时候,我会把每一步需要调用的函数都列出来,手动移植的朋友照着加就行。

1.2 先搞清楚协议栈运行模型:RAW还是Netconn

这是移植之前必须想明白的一个问题。lwIP官方提供好几种API,但大体上分两类:

API类型是否需要RTOS编程方式内存开销适合场景
RAW API不需要回调函数,事件驱动裸机、资源紧张
Netconn API需要阻塞式API,类似socket有RTOS,逻辑复杂
Socket API需要兼容BSD socket较大有RTOS,熟悉socket

HTTPD官方服务器本身是用RAW API写的,意思是哪怕你的F407上没跑FreeRTOS,HTTPD照样能跑。我在这个项目里干脆就没上RTOS,直接在裸机主循环里轮询收包、用RAW API操作TCP连接。原因很简单:F407资源虽然不算紧张,但裸机裸跑可以让代码路径简单直观,出问题容易排查,而且HTTPD这种轻量服务根本不需要调度器。

如果你已经跑着FreeRTOS,那也无所谓。lwIP在RTOS模式下能开tcpip_thread,把协议栈放到独立线程里运行,HTTPD仍然可以用RAW API挂在上面,两边不冲突。关键是编译期宏LWIP_NETCONNLWIP_SOCKET别乱开,开了Netconn又不开OS,编译会报一堆错误。

1.3 内存这件事要提前算清楚

STM32F407的内存分两块:一块是从0x20000000开始的128KB常规SRAM,另一块是从0x10000000开始的64KB CCM RAM。CCM RAM访问速度快,但DMA访问不了,而以太网DMA描述符、收发缓冲区如果放到了CCM RAM里,直接就是死机或者随机掉包。这个坑我周围至少有两个人踩过,都是CubeMX默认配置下自己手动改了链接脚本,把缓冲数组“优化”到了CCM。

lwIP默认的缓冲配置在lwipopts.h或者CubeMX的Middleware and Software Packs -> LwIP参数面板里,关键宏有这么几个:

  • MEM_SIZE:堆大小,动态分配PBUF和TCP报文段用,裸机HTTPD场景给16KB~32KB比较舒服。
  • PBUF_POOL_SIZE:PBUF池中PBUF的数量,默认10个可能偏小,我一般开到16个。
  • PBUF_POOL_BUFSIZE:每个PBUF的大小,默认1518字节,正好能装下一整个以太网帧。
  • MEMP_NUM_TCP_SEG:TCP分段缓冲数量,默认8,HTTP页面大一点的时候改到16更稳。
  • TCP_WND:TCP接收窗口,默认2048字节,想吞吐高一些调到4096。
  • TCP_SND_BUF:发送缓冲区大小,默认2048,建议和TCP_WND一起调大。

这里要强调一点:MEM_SIZE开得太大没有意义,F407总共就128KB常规SRAM,你还得分给栈、堆、全局变量和DMA描述符。我的经验是,裸机跑HTTPD,MEM_SIZE开24KB左右,实测同时服务两三个浏览器的连接没压力。

2. lwIP的移植路径:从底层接口到协议栈初始化

2.1 ethernetif.c 里几个关键函数在干什么

lwIP的硬件适配层结构很清晰,核心就是ethernetif.c里的几个函数。搞懂它们,你就明白协议栈和MAC是怎么配合的了。

low_level_init干的是初始化硬件、设置MAC地址、启动DMA描述符。CubeMX生成的代码会在这里调用HAL_ETH_Init,然后初始化接收和发送描述符,再调用HAL_ETH_Start_IT开启以太网中断。如果你是自己移植,这个函数的重点在于DMA描述符的内存必须对齐,标准做法是定义成全局数组,编译器天然4字节对齐,别用malloc去搞。

low_level_output是协议栈要往外发数据的时候调用的。它把协议栈传下来的PBUF链表拆成一个或多个DMA发送缓冲区,然后调用库函数把数据交给MAC发送。这里有一个小细节:PBUF在以太网帧较短的场景下可能是分段的,处理时要遍历所有pbuf节点,直到把链表里的数据全部拷进DMA描述符。CubeMX生成的代码一般已经处理好了,但如果你是从旧版本lwIP代码迁移,注意检查有没有漏掉PBUF_REF类型。

low_level_input是收包函数。以太网中断触发后,在中断回调里调用这个函数,从DMA接收描述符里把数据剥出来,装进一个PBUF,然后调用netif->input(pbuf, netif)把数据上交给协议栈。netif->input指向的是tcpip_input(有RTOS模式)或者ethernet_input(裸机RAW模式)。

2.2 初始化流程五步走

在裸机模式下,lwIP的初始化顺序很有讲究,少了某一步,最常见的故障就是能收到ARP请求但不会回包。完整流程我整理成五步:

第一步,初始化底层硬件。CubeMX生成的MX_LWIP_Init()会调用lwip_init()ethernetif_init()netif_add()。等这些都跑完,协议栈的核心数据结构才算是可用状态。

第二步,配置IP地址。这里我建议直接手动指定静态IP,一条netif_set_addr就搞定,先别开DHCP,因为DHCP多了一层交互,排查问题时变量太多。IP设成192.168.1.20,网关192.168.1.1,子网掩码255.255.255.0

第三步,把netif接口设为默认并使能。netif_set_default告诉协议栈“所有上网流量都走这个网卡”,netif_set_up让协议栈认为接口已经准备好。

第四步,调用netif_set_link_up。这个函数会触发TCP/IP栈里关于链路状态的更新,以太网PHY已经连上的时候必须调用,否则ARP和TCP都会认为链路不可用。

第五步,初始化HTTPD服务器。调用httpd_init(),服务器会在80端口上开始监听。

上面这五步做完,你的F407理论上已经是一个能ping通、能够接收TCP连接的设备了。如果你用的是带RTOS的CubeMX工程,那tcpip_init(NULL, NULL)会创建一个tcpip_thread,HTTPD和网络接口的注册要在tcpip_thread启动之后进行,顺序上需要留意一下。

2.3 收包中断与主循环的配合

裸机环境下的lwIP收包有两种常见方式:一种是纯中断,在ETH_IRQHandler回调里把包直接送给协议栈;另一种是主循环轮询DMA描述符状态。我强烈建议用中断方式,原因是F407的以太网MAC自带DMA,中断触发的频率完全可以接受,而且轮询方式在网页数据量稍大时CPU占用率会很难看。

中断方式的实现核心是配置两个外部回调:

void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { struct pbuf *p = NULL; if (ethernetif_input(g_netif) != ERR_OK) { /* 收包失败,PBUF会被内部释放 */ } } void HAL_ETH_TxCpltCallback(ETH_HandleTypeDef *heth) { /* 发送完成,这里可以清状态或者记录日志 */ }

在CubeMX生成的代码里,ethernetif_input已经封装好了low_level_inputnetif->input的调用,所以中断回调里做得很轻。需要注意一点:中断回调里绝对不要做耗时的操作,像printfsnprintf格式化页面内容这种事,放到主循环或者单独的任务里做。我在调试早期吃过教训,在接收回调里打了一行串口日志,丢包率直接飙升。

主循环里唯一要做的定时事情是调用sys_check_timeouts(),作用是让lwIP处理AR P重传、TCP重传等定时任务。裸机模式下没有RTOS时钟,这个函数一般放在一个1ms或者10ms周期触发的定时器中断标志里执行。忘了调用它,最常见的现象是ARP请求发出去一直重试、TCP握手只发SYN收不到ACK响应。

3. HTTPD服务器搭建:让浏览器看到板子的灵魂

3.1 打开HTTPD功能需要调整的宏集合

很多人移植lwIP只关心能不能ping通,直到想跑HTTPD才发现httpd_init编译不过或者链接不到。核心原因就是宏没打开。我最常用的配置是在lwipopts.h里加上这几行:

#define LWIP_HTTPD 1 #define LWIP_HTTPD_DYNAMIC_HEADERS 1 #define LWIP_HTTPD_CGI 1 #define LWIP_HTTPD_SSI 1

LWIP_HTTPD是总开关,不开这个,httpd.c文件里的代码基本全是灰色。LWIP_HTTPD_DYNAMIC_HEADERS打开后,HTTP响应头可以动态生成,可以给不同的URI返回不同的Content-Type。LWIP_HTTPD_CGILWIP_HTTPD_SSI是HTTPD的两个扩展机制,后面单独说。

如果你是从官网下载的lwIP源码包,注意httpd.c这个文件默认放在src/apps/httpd/目录下,但它依赖的fsdata.c文件(网页文件系统的数据数组)不在源码包里,需要你用官方工具生成。CubeMX的优势就在这里,它会在首次生成工程时自动放一个最小化的fsdata.c,里面默认包含一个简陋的index.html,能让你先跑通。

3.2 把网页打包进固件:fsdata与makefsdata

lwIP的HTTPD不走“读SD卡文件”的老路子,而是直接编译期把网页文件打包成C语言数组,这叫fsdata(filesystem data)。好处是单片机内部没有任何文件系统也能服务网页,坏处是改页面内容必须重新编译整个工程。

网页源文件放在一个文件夹里,最常见的是放在Middlewares/Third_Party/LwIP/src/apps/httpd/fs/目录。这个目录里可以放index.html404.htmlstyle.csslogo.png之类的东西。你需要用lwIP官方提供的makefsdata工具扫描这些文件,生成fsdata.c。这个工具在lwIP的contrib仓库里,有Perl版本也有Python版本,我是用Python版:

cd lwip-contrib/apps/httpd python makefsdata fs/

生成的fsdata_custom.c或者直接覆盖原fsdata.c,然后编译工程。需要注意的是,lwIP对网页文件的命名很敏感,index.html会被HTTPD识别为默认首页。如果你的首页文件名不是这个,访问IP时就会出现404。

还有一个常见的坑是中文页面。fsdata工具默认按二进制拷贝文件内容,如果你的HTML文件编码不是UTF-8,浏览器访问中文会乱码。保存任何网页文件之前,先确认编辑器右下角编码是UTF-8,别用ANSI。

3.3 CGI:处理URL请求和动态数据

CGI(Common Gateway Interface)在lwIP里的作用,是把URL请求对应到C函数。比如浏览器访问http://192.168.1.20/led_on,HTTPD会解析出URI是/led_on,然后去查表,找到对应的处理函数,执行完再返回结果。

注册一个CGI处理函数很简单:

#include "lwip/apps/httpd.h" static const char *CGI_led_on(int iIndex, int iNumParams, char *pcParam[], char *pcValue[]) { if (iNumParams > 0) { /* pcParam[i] 是参数名,pcValue[i] 是参数值 */ set_led_state(1); } return "/index.shtml"; } static const tCGI pCGIs[] = { { "/led_on", CGI_led_on }, { "/led_off", CGI_led_off }, };

然后在httpd_init()之后调用:

http_set_cgi_handlers(pCGIs, sizeof(pCGIs) / sizeof(tCGI));

CGI回调的返回值是一个uri字符串,HTTPD会把它当作“接下来要发送给浏览器的页面路径”。所以上面例子里打开/led_on之后,把LED点亮,然后服务器自动返回index.shtml页面。你可以在这个回调里改一些全局状态变量,供SSI去读取。

如果你想要CGI直接返回一串JSON给客户端,新版本的lwIP还提供了更灵活的方式,但裸机场景下一般搭配SSI就够用了,我的建议是别把CGI写得太复杂,它真正擅长的是“处理动作”而不是“生成内容”。

3.4 SSI:让HTML页面动态起来

SSI(Server Side Include)是让网页内容实时刷新的核心。原理特别朴素:HTML文件里写占位符,HTTPD在发送这个页面之前,把占位符替换成C函数返回的字符串。

先理解占位符的写法。默认机制下,页面里写:

当前温度:<!--#temp--> °C

HTTPD看到#temp这个标签,就去SSI的标签表里查找"temp",找到了就调用标签对应的处理函数,把返回的文本替换掉<!--#temp-->整段内容。

注册SSI处理函数:

static const char *ssi_tags[] = {"temp", "adc", "count"}; u16_t SSI_handler(int iIndex, char *pcInsert, int iInsertLen) { switch (iIndex) { case 0: /* temp */ return (u16_t)snprintf(pcInsert, iInsertLen, "%d.%d", temp_value / 10, temp_value % 10); case 1: /* adc */ return (u16_t)snprintf(pcInsert, iInsertLen, "%d", adc_value); case 2: /* count */ return (u16_t)snprintf(pcInsert, iInsertLen, "%lu", http_request_count); default: return 0; /* 0表示不替换 */ } } void httpd_setup_ssi(void) { http_set_ssi_handler(SSI_handler, ssi_tags, sizeof(ssi_tags) / sizeof(ssi_tags[0])); }

三个标签tempadccount分别对应温度传感器读值、ADC采集值和HTTP访问次数。浏览器每次请求index.shtml,HTTPD都会动态把最新数值填进去,页面刷新一次就是最新的。

这里有一个非常经典的坑:pcInsert缓冲区长度是有限的,默认值大约在128字节左右,由宏LWIP_HTTPD_MAX_TAG_INSERT_LEN控制。如果你的C函数往缓冲区里写超长了,会被截断,标签替换不完整,浏览器看到的HTML结构就烂了。我习惯把单次SSI插入内容控制在几十个字节以内,复杂页面用多个标签拆开组合。

另外,新版lwIP在LWIP_HTTPD_SSI_MULTIPART开启时,SSI回调会增加第四个参数current_tag_part,用于支持标签内容超过缓冲区大小的情况。我的经验是,除非页面需要输出很长的动态表格,否则没必要开这个,默认的SSI机制已经足够覆盖绝大部分嵌入式网页应用。

4. 网络调试与抓包实录

4.1 ping不通时的排查顺序

我敢说90%的人第一次移植lwIP都会遇到ping不通的问题,我也不例外。下面这个排查顺序是我踩了无数坑之后沉淀下来的,照着走能省很多时间。

第一步,看板子这边的串口日志。确认MAC和PHY初始化有没有报错,HAL_ETH_Init返回是否正常。如果初始化阶段就卡住,大概率是PHY地址不对或者PHY复位时序不够。LAN8720A的PHY地址一般是0或者1,具体由原理图上PHYAD0引脚决定,改eth_init或者CubeMX里的PHY地址即可。

第二步,看Link状态。PHY初始化成功后,链路状态需要时间建立,一般要等几百毫秒。如果Link不上,检查网线是否直连PC、PC网口是否禁用节能选项,还要看PHY的时钟源。LAn8720A的REF_CLK用STM32的MCO输出50MHz,配置不对会导致PHY完全不工作,现象就是Link灯不亮或者一直闪。

第三步,用Wireshark抓包。把电脑网卡设置为192.168.1.10/24,然后ping 192.168.1.20。重点看有没有ARP请求发出来、有没有ARP回复回来。如果电脑发了ARP但板子没回,问题基本在lwIP的ARP表或者netif接口注册上。如果板子回了ARP但ICMP不回,那就要查IP地址是否设置成功、ICMP模块是否编译进去了。

我把常见的现象和解决方向整理成了一张表:

现象可能原因解决办法
电脑ping显示超时,抓包没有任何ARP板子以太网中断没触发,或DMA描述符没初始化检查ETH中断使能,确认HAL_ETH_Start_IT被调用
ARP请求有,板子不回ARPnetif接口没set_up,或ARP模块未开启检查netif_set_up调用,确认LWIP_ARP为1
ARP能回,ping不通IP地址配置错误,或ICMP被禁确认netif_set_addr参数,确认LWIP_ICMP为1
能ping通,打不开网页HTTPD未初始化,宏未打开检查httpd_init调用,确认LWIP_HTTPD为1
网页偶尔能开,刷新几次又超时内存池太小,TCP重传跟不上调大PBUF_POOL_SIZEMEMP_NUM_TCP_SEG

4.2 浏览器访问测试与常见HTTP异常

页面跑通之后,我习惯用浏览器开发者工具(F12)看网络请求,这是排查HTTP异常最快的方式。比如访问http://192.168.1.20/,正常情况下应该看到状态码200和index.html相关请求记录。

如果状态码是404,先看是不是默认首页缺失或者文件名不对。lwIP的HTTPD对URI的查找是大小写敏感的,INDEX.HTMLindex.html是两个不同的资源,访问之前先确认。

如果状态码是301或302,多办是你CGI处理函数返回了重定向URI,比如把/重定向到/index.shtml,这属于正常现象。真正让人头疼的是连接直接挂起,页面转圈很久不出内容,这种问题多半出在TCP发送窗口太小或者HTTP响应头没有正常结束。HTTP响应头必须以\r\n\r\n结束,少一个回车换行,浏览器就会一直等后续数据。

我还试过一个很刁钻的坑:浏览器缓存。修改了HTML页面重新编译烧录,浏览器访问还是旧页面。这不是单片机的问题,而是浏览器缓存了静态资源。解决办法是在链接后面加上随机参数,例如http://192.168.1.20/index.shtml?_t=12345,或者在浏览器的“网络”页面勾选“禁用缓存”。

4.3 观察TCP连接状态与数据交互

想深入看TCP连接在干什么,最直接的办法是在板子上打印连接状态。lwIP的RAW API提供了连接状态回调,可以在tcp_connect或者tcp_accept的对应回调函数里加一行串口打印。HTTPD官方代码里没有给你留这种打印位置,你可以在httpd_init之后自己创建一个测试TCP连接:

static err_t test_conn_callback(void *arg, struct tcp_pcb *newpcb, err_t err) { printf("[HTTPD] client connected, remote port=%u\n", newpcb->remote_port); return ERR_OK; } void test_start_server(void) { struct tcp_pcb *pcb = tcp_new(); if (pcb != NULL) { tcp_bind(pcb, IP_ADDR_ANY, 8081); pcb = tcp_listen(pcb); tcp_accept(pcb, test_conn_callback); } }

用浏览器打开http://192.168.1.20:8081,串口就会打印出“client connected”。如果连接这个回调都不触发,那问题基本不在应用层,而在底层的TCP接收或netif输入路径上。反过来,如果连接触发了但HTTP请求发不进来,那就考虑是不是中断里收包后没有正确调用netif->input,导致数据堵在网卡驱动层没往上送。

Wireshark抓TCP流也是高招。抓包时看TCP三次握手是否完成,握手完成后有没有HTTP GET请求,响应数据的ACK是否正常乱序。我调试HTTPD时,十有八九的问题都能在Wireshark里找到答案,比瞎猜可靠多了。

5. 常见问题与避坑手册

5.1 问题速查表

日常回复网友提问,我发现问得最多的还是那几种老问题。这里整理成一张速查表,遇到故障直接对号入座:

故障现象根因解决建议
上电后第一次ping不通,复位后又通PHY复位后立即初始化,链路状态还没建立PHY复位后延时300ms以上再初始化
收发帧CRC错误,随机丢包RMII REF_CLK频率不准检查MCO输出频率,确认是50MHz且稳定
使用CCM RAM存放DMA缓冲区,数据异常CCM RAM不可被DMA访问把DMA描述符和缓冲数组放在0x20000000开始的常规SRAM
网页打开慢,刷新几次就卡死TCP_WND和MEMP_NUM_TCP_SEG太小把TCP_WND调到4096,MEMP_NUM_TCP_SEG调到16
电脑一直提示网络电缆被拔出PHY地址错误或时钟未配置检查PHY地址引脚和REF_CLK输入配置
能ping通,但浏览器直接拒绝连接HTTPD没启动或者端口被改动检查httpd_init调用和HTTPD相关宏
SSI标签原样显示SSI标签长度超过LWIP_HTTPD_MAX_TAG_NAME_LEN缩短标签名,或者调大宏定义
浏览器中文乱码网页文件编码不是UTF-8用UTF-8保存HTML文件
编译报错找不到fsdata.c网页文件系统数据没生成用makefsdata生成,或从CubeMX工程拷贝

5.2 裸机模式下内存优化的取舍

裸机跑lwIP最舒服的一点是省掉了RTOS的内核栈和任务栈,但这不代表内存可以随便浪。我在F407上实测,一个最简的HTTPD服务器,页面文件大约4KB,编译出来固件大小约60KB,RAM占用大约在20KB左右(包括lwIP缓冲区、DMA描述符、全局变量)。如果你的系统里还有很多别的业务代码,这几个宏就要注意收紧:

  • PBUF_POOL_SIZE从16降到12,能省大概3KB RAM,代价是并发连接上限降低。
  • LWIP_ARP_TABLE_SIZE默认10,如果你只连一台电脑,改成4就够用。
  • MEMP_NUM_NETBUFMEMP_NUM_TCP_PCB这些宏,别开一堆,裸机HTTPD并发连接数两三路足够了。

调试的时候我习惯把LWIP_DEBUG打开,等跑稳了再关掉。但要注意,LWIP_DEBUG开关非常消耗性能和栈空间,串口打印走中断会很卡,不要开着这个去压吞吐测试。

5.3 HTTP请求并发与实时性的平衡

HTTPD服务器的本质还是一个嵌入式Web服务器,别指望它能像Nginx那样扛高并发。lwIP里的并发连接数量由MEMP_NUM_TCP_PCB宏控制,超过这个数,新连接会被拒绝。浏览器打开一个页面往往同时发起很多请求,CSS、图片、HTML可能并行连接。我把MEMP_NUM_TCP_PCB设成8,实测同时开几个标签页访问没有大问题,但这是上限了。

想让页面数据实时刷新,有两个惯用手法:一种是浏览器里用<meta http-equiv="refresh" content="1">让页面每秒自动刷新一次,最简单粗暴;另一种是在页面里写一段JavaScript,每隔1秒请求一次CGI接口拿最新数据,然后更新DOM。我做过很多项目,前者胜在实现简单,后者胜在交互自然,但都需要注意:请求频率不要超过每秒一次,否则小内存的HTTPD会应接不暇。

最后分享一个我实测很有用的调试技巧:连不上板子网页的时候,先用浏览器手动访问http://192.168.1.20/cgi-status这种不存在的路径,如果串口里能看到HTTPD的404日志,说明HTTPD活着,问题出在页面资源上;如果串口一点反应都没有,先去查httpd_init到底跑了没有。就这一个判断,能帮你快速缩小排查范围。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询