STM32F407移植lwIP并搭建HTTPD服务器:从网卡驱动到网页远程控制
发布时间:2026/9/9 11:19:13 作者:尧图编辑部 阅读量:1,286

上一篇文章把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.c、ethernetif.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.cCubeMX生成负责协议栈时钟、以太网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熟悉socketHTTPD官方服务器本身是用RAW API写的意思是哪怕你的F407上没跑FreeRTOSHTTPD照样能跑。我在这个项目里干脆就没上RTOS直接在裸机主循环里轮询收包、用RAW API操作TCP连接。原因很简单F407资源虽然不算紧张但裸机裸跑可以让代码路径简单直观出问题容易排查而且HTTPD这种轻量服务根本不需要调度器。如果你已经跑着FreeRTOS那也无所谓。lwIP在RTOS模式下能开tcpip_thread把协议栈放到独立线程里运行HTTPD仍然可以用RAW API挂在上面两边不冲突。关键是编译期宏LWIP_NETCONN和LWIP_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_SIZEPBUF池中PBUF的数量默认10个可能偏小我一般开到16个。PBUF_POOL_BUFSIZE每个PBUF的大小默认1518字节正好能装下一整个以太网帧。MEMP_NUM_TCP_SEGTCP分段缓冲数量默认8HTTP页面大一点的时候改到16更稳。TCP_WNDTCP接收窗口默认2048字节想吞吐高一些调到4096。TCP_SND_BUF发送缓冲区大小默认2048建议和TCP_WND一起调大。这里要强调一点MEM_SIZE开得太大没有意义F407总共就128KB常规SRAM你还得分给栈、堆、全局变量和DMA描述符。我的经验是裸机跑HTTPDMEM_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_threadHTTPD和网络接口的注册要在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_input和netif-input的调用所以中断回调里做得很轻。需要注意一点中断回调里绝对不要做耗时的操作像printf、snprintf格式化页面内容这种事放到主循环或者单独的任务里做。我在调试早期吃过教训在接收回调里打了一行串口日志丢包率直接飙升。主循环里唯一要做的定时事情是调用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 1LWIP_HTTPD是总开关不开这个httpd.c文件里的代码基本全是灰色。LWIP_HTTPD_DYNAMIC_HEADERS打开后HTTP响应头可以动态生成可以给不同的URI返回不同的Content-Type。LWIP_HTTPD_CGI和LWIP_HTTPD_SSI是HTTPD的两个扩展机制后面单独说。如果你是从官网下载的lwIP源码包注意httpd.c这个文件默认放在src/apps/httpd/目录下但它依赖的fsdata.c文件网页文件系统的数据数组不在源码包里需要你用官方工具生成。CubeMX的优势就在这里它会在首次生成工程时自动放一个最小化的fsdata.c里面默认包含一个简陋的index.html能让你先跑通。3.2 把网页打包进固件fsdata与makefsdatalwIP的HTTPD不走“读SD卡文件”的老路子而是直接编译期把网页文件打包成C语言数组这叫fsdatafilesystem data。好处是单片机内部没有任何文件系统也能服务网页坏处是改页面内容必须重新编译整个工程。网页源文件放在一个文件夹里最常见的是放在Middlewares/Third_Party/LwIP/src/apps/httpd/fs/目录。这个目录里可以放index.html、404.html、style.css、logo.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请求和动态数据CGICommon Gateway Interface在lwIP里的作用是把URL请求对应到C函数。比如浏览器访问http://192.168.1.20/led_onHTTPD会解析出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页面动态起来SSIServer Side Include是让网页内容实时刷新的核心。原理特别朴素HTML文件里写占位符HTTPD在发送这个页面之前把占位符替换成C函数返回的字符串。先理解占位符的写法。默认机制下页面里写当前温度!--#temp-- °CHTTPD看到#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])); }三个标签temp、adc、count分别对应温度传感器读值、ADC采集值和HTTP访问次数。浏览器每次请求index.shtmlHTTPD都会动态把最新数值填进去页面刷新一次就是最新的。这里有一个非常经典的坑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为1ARP能回ping不通IP地址配置错误或ICMP被禁确认netif_set_addr参数确认LWIP_ICMP为1能ping通打不开网页HTTPD未初始化宏未打开检查httpd_init调用确认LWIP_HTTPD为1网页偶尔能开刷新几次又超时内存池太小TCP重传跟不上调大PBUF_POOL_SIZE和MEMP_NUM_TCP_SEG4.2 浏览器访问测试与常见HTTP异常页面跑通之后我习惯用浏览器开发者工具F12看网络请求这是排查HTTP异常最快的方式。比如访问http://192.168.1.20/正常情况下应该看到状态码200和index.html相关请求记录。如果状态码是404先看是不是默认首页缺失或者文件名不对。lwIP的HTTPD对URI的查找是大小写敏感的INDEX.HTML和index.html是两个不同的资源访问之前先确认。如果状态码是301或302多办是你CGI处理函数返回了重定向URI比如把/重定向到/index.shtml这属于正常现象。真正让人头疼的是连接直接挂起页面转圈很久不出内容这种问题多半出在TCP发送窗口太小或者HTTP响应头没有正常结束。HTTP响应头必须以\r\n\r\n结束少一个回车换行浏览器就会一直等后续数据。我还试过一个很刁钻的坑浏览器缓存。修改了HTML页面重新编译烧录浏览器访问还是旧页面。这不是单片机的问题而是浏览器缓存了静态资源。解决办法是在链接后面加上随机参数例如http://192.168.1.20/index.shtml?_t12345或者在浏览器的“网络”页面勾选“禁用缓存”。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调到4096MEMP_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编译出来固件大小约60KBRAM占用大约在20KB左右包括lwIP缓冲区、DMA描述符、全局变量。如果你的系统里还有很多别的业务代码这几个宏就要注意收紧PBUF_POOL_SIZE从16降到12能省大概3KB RAM代价是并发连接上限降低。LWIP_ARP_TABLE_SIZE默认10如果你只连一台电脑改成4就够用。MEMP_NUM_NETBUF和MEMP_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-equivrefresh content1让页面每秒自动刷新一次最简单粗暴另一种是在页面里写一段JavaScript每隔1秒请求一次CGI接口拿最新数据然后更新DOM。我做过很多项目前者胜在实现简单后者胜在交互自然但都需要注意请求频率不要超过每秒一次否则小内存的HTTPD会应接不暇。最后分享一个我实测很有用的调试技巧连不上板子网页的时候先用浏览器手动访问http://192.168.1.20/cgi-status这种不存在的路径如果串口里能看到HTTPD的404日志说明HTTPD活着问题出在页面资源上如果串口一点反应都没有先去查httpd_init到底跑了没有。就这一个判断能帮你快速缩小排查范围。