STM32F407嵌入式WebServer实战:从LwIP到CGI完整教程 简介基于STM32F407的嵌入式WebServer例程面向嵌入式开发者和物联网初学者演示如何借助野火STM32F4xx开发板与LAN8720A以太网模块通过开发板LAN接口连接计算机或路由器在浏览器中打开网页控制LED灯并实时显示ADC采样值与RTC时钟。整套资源包含599个文件压缩包约17.22MB以C/H源文件、以太网底层驱动及网页数据相关代码、HTML页面、Keil工程配置和hex/axf烧录文件为主同时附有少量调试说明文本便于直接打开工程编译、烧录和对照学习。资源已有829人学习适合希望快速掌握嵌入式WebServer实现思路的开发者从中可以获取完整例程源码、工程配置、网页文件与烧录固件辅助理解网络通信、HTTP页面交互与板级外设驱动的协同工作方式也可作为课程设计、毕业设计的参考原型方便在Web控制LED、ADC采集、RTC显示等场景上继续扩展。1. 嵌入式WebServer让STM32F407在浏览器里被直接操作把网页浏览器当设备调试面板是嵌入式WebServer最直观的体验不用装上位机、不用接串口线同一局域网里任何电脑打开浏览器输入设备IP看到的既是状态页也是控制台。这份基于 STM32F407 的嵌入式WebServer例程就是把人从“只会点灯到能远程控参”这段路铺好的样板F407 内置以太网 MAC外接一颗 PHY 芯片LwIP 提供 TCP/IP再往上架 HTTP 服务和 HTML 页面设备状态和参数交互通过 CGI 与 SSI 打通。它适合那类需要远程看数据、改参数但没必要上 Linux 的嵌入式产品比如小型网关、配电终端、便携仪器。软件部分例程基本齐全你只要有一块带网口的 F407 板子按着编译下载浏览器里就能出第一屏。2. 硬件与选型F407的内置MAC、外部PHY与LwIP怎么搭才不返工2.1 先把角色分清MAC与PHY各管什么RMII为什么是主流F407 内部集成的是以太网 MAC不是完整的物理层。MAC 负责组帧、地址过滤、CRC 校验跟 CPU 和 DMA 打交道PHY 芯片负责线路编码、时钟恢复、电平转换直接面对网线。两者之间通过 MII 或 RMII 总线对接。MII 是 4 位数据、25MHz 时钟要 16 根左右的信号线PCB 走线压力大RMII 把数据压到 2 位、时钟固定 50MHz信号线只有 78 根绝大多数 F407 板子走 RMII。例程配套的板子用的 PHY 常见是 LAN8720A 这类单 3.3V 供电芯片。RMII 模式下 PHY 需要一个 50MHz 参考时钟来源有两种流派一种由板上 25MHz 晶振配合 PHY 内部倍频产生另一种由 F407 的 MCO1PA8或 MCO2PC9直接输出 50MHz 给 PHY。两条路都能跑区别在于 CubeMX 配置里要不要使能 MCO 引脚。判断方法很直白看原理图上 PHY 的 CLKIN/XI 引脚连的是晶振还是 MCU 的 PA8/PC9。这里有个容易绕晕的点MAC 和 PHY 要的不是同一个时钟。F407 的 MAC 工作时钟来自 PLLQ 输出的 PLL48CK48MHz这条时钟同时给 USB OTG 和 SDIO 用而 RMII 的 REF_CLK 是 50MHz。所以工程里要同时满足两条链路任何一条断了表现都是网卡能初始化但收发异常。2.2 协议栈选型LwIP 的三种使用姿势网络协议栈有 uIP、LwIP、自研精简 TCP/IP 三个方向。在 F407 这种带硬件 MAC、RAM 有 192KB 的平台上LwIP 几乎是唯一值得认真考虑的方案代码成熟、案例多、TCP 重传和窗口管理都有人验证过出问题时搜得到同类错误。自研 TCP 栈不是不能跑但 HTTP 连接管理、超时重传这些细节很容易翻车不推荐在产品上首次尝试。LwIP 通常有两种运行模式例程里也会体现NO_SYS1裸机模式。用 raw API 写服务所有网络处理放在主循环轮询。优点是不依赖 RTOS缺点是 HTTP 这种需要长时间等收包的场景会拖住主循环适合吞吐量低、逻辑简单的场合。NO_SYS0配合 FreeRTOS 使用。可以用 netconn 或 socket APIaccept、recv 会阻塞等待代码写法最接近 PC 端套接字编程。大多数 F407 WebServer 例程走这条路线我也是这个方向。第三种姿势是用 LwIP 官方 contrib 里的 httpd 服务器自带 CGI 和 SSI 框架页面文件通过 makefsdata 工具打包成 C 数组编进固件。这条路适合页面多、想少写 HTTP 解析代码的场景代价是页面更新要重新编译固件。手写 netconn 服务更灵活第 4 章会给出代码。LwIPopts.h 里几个参数直接决定 HTTP 体验默认值能跑但要知道它们影响什么宏常见取值影响MEM_SIZE10KB40KBTCP、netconn 等结构从这里分配太小则 accept 后直接失败PBUF_POOL_SIZE816网卡收包缓冲池突发流量时排队TCP_MSS1460单条 TCP 报文最大载荷改小会明显降吞吐TCP_WND48 倍 MSS接收窗口页面文件大时需要足够窗口TCP_SND_BUF48 倍 MSS发送缓冲太小容易把图片或 CSS 切得非常碎我的习惯是先按默认跑通再去调窗口和缓冲。调小容易复现“偶发卡死”调大则挤占 RAMF407 的 192KB RAM 在常规 WebServer 场景下很宽裕放 3244KB 给 LwIP 完全够。2.3 例程包结构拿到手先翻这四个位置这类 STM32 例程的目录结构大同小异。不建议一开始通读全部代码先按下面四个位置确认版本和配置能省很多时间位置看什么Core/Src/main.c系统时钟、ETH 外设时钟是否开启FreeRTOS 启动方式LWIP/Target/ethernetif.cPHY 地址、RMII 引脚配置、low_level_init 里的复位逻辑LWIP/App/lwip.cLwIP 初始化、网卡注册、静态 IP 或 DHCP 方式HTTP 服务相关源文件确认是 contrib httpd 还是手写 netconn 服务页面文件存在哪我一般会先全局搜httpd_init和netconn_new两个字符串搜到哪个就知道走的哪条路线再读对应文件。页面 HTML 通常有两种归宿以 C 数组形式编进固件或者放 SD 卡、外部 Flash 由服务器按文件名读取。前者的好处是供电即出页面适合快速验证后者才适合产品里频繁更换页面。这份例程从目录看页面是编进固件的改页面就得重新编译后面第 6 章我会讲怎么把它搬出来。提示拿到新板子先别急着跑 HTTP用 MDIO 读回 PHY ID 寄存器寄存器 2、3确认读到的是你手里那颗芯片的 ID再往后调。3. 把底层通路跑通CubeMX配置、PHY驱动与ping测试3.1 CubeMX里的时钟和引脚最容易错的地方F407 的以太网工程CubeMX 里最值得核对的是两件事时钟树和 ETH 引脚。核心时钟关系一句话概括SYSCLK 168MHzAHB 168MHzAPB1 42MHzAPB2 84MHzETH 需要的 PLL48CK 由 PLLQ 产生必须是 48MHz。如果 CubeMX 里动了 PLLQUSB 和以太网会一起罢工这个错误在串口上往往什么日志都看不出来。ETH 的 RMII 引脚是一组固定映射CubeMX 勾选 ETH 外设后会分配典型如下信号常见引脚ETH_RMII_REF_CLKPA1或由 PHY 从晶振内部倍频后给出ETH_RMII_MDIOPA2ETH_RMII_MDCPC1ETH_RMII_CRS_DVPA7ETH_RMII_RXD0PC4ETH_RMII_RXD1PC5ETH_RMII_TX_EN / TXD0 / TXD1PG11 / PG13 / PG14部分板子改到 PB11 / PB12 / PB13不同开发板对 RMII 引脚可能有改动核对标准只有一个原理图上 PHY 芯片的 RMII 信号连到 MCU 的哪个脚CubeMX 里就必须是哪个脚。PA8、PC9 这类 MCO 引脚不属于 ETH 外设映射需要单独在 GPIO 配置里打开输出 50MHz 给 PHY。如果你不确定 50MHz 从哪来就在 CubeMX 的 Clock Configuration 里看 MCO 分频配置是否被勾选再去原理图 PHY 芯片附近找晶振或者 CLKIN 连线。两路时钟一个都不能少少一个就进第 3.3 节的排查流程。3.2 PHY初始化复位时序和ID确认PHY 驱动里最常见的翻车点是复位时序。LAN8720A 这类芯片要求电源稳定后 nRST 至少拉低一段时间再释放有些板子把 nRST 接到了 MCU 的 GPIO那就必须在 low_level_init 里手动控制而不是靠默认上电复位。释放后还要等芯片内部上电自检完成再读寄存器才可靠。常见做法是写一段这样的确认代码uint16_t id1 0, id2 0; /* 读 PHY ID 寄存器寄存器2、3确认 MDIO 通路和 PHY 地址都对 */ if (HAL_ETH_ReadPHYRegister(heth, 2, id1) ! HAL_OK || HAL_ETH_ReadPHYRegister(heth, 3, id2) ! HAL_OK) { printf(MDIO access failed\r\n); return; } printf(PHY ID: 0x%04X%04X\r\n, id1, id2); /* 等待自动协商完成位2置位后再把网卡交给 LwIP */ uint32_t bsr 0; do { HAL_Delay(100); HAL_ETH_ReadPHYRegister(heth, 1, bsr); } while ((bsr 0x04) 0);这段代码把 PHY 的地址确认和自动协商等待合在一起。第一次读 PHY ID 如果返回超时或者 0xFFFF多半是 PHY 地址不对LAN8720A 读出的是 0x0007 开头DP83848 读出的是 0x2000 开头前缀对不上就检查地址跳线。自动协商那一步等不到则优先怀疑 50MHz REF_CLK 没有真正到达 PHY。3.3 ping不通时的首轮排查按这个顺序来网络问题很多时候像玄学但按顺序查多数能在十分钟内定位。我的排查顺序固定如下看串口打印的 PHY ID 是否合理。这一步能同时证明 MDIO 引脚、PHY 地址、复位时序三条都没问题。量 RMII REF_CLK。用示波器或频率计测 PHY 的 CLKIN 或 XI 引脚50MHz 不在直接查 MCO 配置或晶振。看静态 IP 和 MAC。例程 lwip.c 里默认 IP 通常是 192.168.1.100MAC 固定为 00:11:22:33:44:55。两个板子同时联网时这个默认 MAC 会冲突表现为时通时断至少把最后一字节改成板子编号。确认 ethernetif.c 里的 link 状态轮询。LwIP 负责把网卡挂上协议栈但如果netif_set_link_up和netif_set_up没有被周期调用协议栈会一直认为网线没插ping 也不通。第 4 步是很多人忽略的PHY 和 MAC 都正常但 LwIP 的 netif 不知道链路已经起来。串口多打一条 netif 状态能省掉大量盲调。4. HTTP 服务跑起来HTML页面、CGI参数与SSI实时数据4.1 整体结构一个线程、一张路由表、一组回调这类例程最常见的架构是FreeRTOS 里单独开一个http_server_thread监听 80 端口accept 到连接后读取请求文本按 URI 分发给对应的处理函数最后把响应写回。用 netconn API 写起来像 PC 套接字比 raw API 好读很多。核心骨架如下static void http_server_thread(void *arg) { struct netconn *conn, *newconn; struct netbuf *buf; err_t err; conn netconn_new(NETCONN_TCP); netconn_bind(conn, IP_ADDR_ANY, 80); netconn_listen(conn); while (1) { err netconn_accept(conn, newconn); if (err ! ERR_OK) { continue; /* accept 失败继续等下一个连接 */ } while (netconn_recv(newconn, buf) ERR_OK) { /* 缓冲区里是 HTTP 请求文本交给路由处理 */ handle_http_request(newconn, (char *)netbuf_data(buf, NULL)); netbuf_delete(buf); break; /* 简化处理收到请求就回复并关闭 */ } netconn_close(newconn); netconn_delete(newconn); } }netconn_new创建 TCP 控制块netconn_bind绑定 80 端口netconn_accept阻塞等待浏览器连接。netconn_recv把收到的 HTTP 请求拉进 netbufnetbuf_data返回数据指针和长度交给上层解析。这里有个血泪点netbuf_delete必须执行它是接收缓冲的归还动作漏掉一次就漏一块 pbuf后面第 5 章会细说。4.2 手写路由把 GET / 和 GET /index.html 映射到页面请求解析不用写得像通用服务器那样复杂嵌入式场景只需要处理首行。用sscanf取出路径再查一张简单的映射表static void handle_http_request(struct netconn *conn, char *req) { char path[32]; /* 解析请求首行GET /index.html HTTP/1.1 */ if (sscanf(req, GET %31s HTTP, path) ! 1) { send_http_404(conn); return; } if (strcmp(path, /) 0 || strcmp(path, /index.html) 0) send_http_page(conn, html_index, sizeof(html_index) - 1); else if (strcmp(path, /style.css) 0) send_http_page(conn, html_style, sizeof(html_style) - 1); else send_http_404(conn); }%31s限宽很关键它保证路径最长只读 31 个字符防止超长 URL 把栈冲掉。凡是把%s直接怼进 sscanf 的例程遇到恶意或异常请求都容易硬 fault。send_http_page内部负责拼 HTTP 响应头加上Content-Length和Connection: close再写入 body。页面本体在例程里是 const 数组编译时烧进 Flash读取成本低适合快速验证。4.3 CGI 回调让网页按钮真正去控制单片机CGI 在这里的定义很朴素解析 URL 里的查询参数执行对应的设备操作。浏览器端用一个按钮或者 JavaScript 发请求服务器端解析参数后调驱动函数。例如页面通过/ctrl?relay1mode0控制继电器static const char json_ok[] {\relay\:1,\mode\:0}; if (strncmp(req, GET /ctrl, 9) 0) { int relay -1, mode -1; if (sscanf(req, GET /ctrl?relay%dmode%d, relay, mode) 2) { relay_set(relay); mode_set(mode); send_http_page(conn, json_ok, strlen(json_ok)); } else { send_http_404(conn); } }这段代码把“浏览器请求”和“设备动作”之间串了起来。sscanf返回值必须是 2缺参数直接 404避免执行到未初始化的动作。返回 JSON 而不是整页 HTML是为了配合前端 fetch 做局部刷新不抖动整个页面。HTML 一侧典型写法是button onclickfetch(/ctrl?relay1)继电器开/button这套组合在例程里是验证参数下发的主要路径。注意查询参数里如果涉及中文或特殊符号浏览器会做百分号编码服务端要先做简单解码例程里通常只处理 ASCII 参数够用就行。4.4 SSI 标签把 ADC 温度和开关状态印进页面SSIServer Side Include的思路是HTML 里埋占位标签服务器在发送前用回调返回的字符串替换。它适合温度、电压这类展示型数据整页加载时一次性填好浏览器端不用跑脚本。但如果例程用的是 LwIP 官方 contrib httpd注册方式如下static u16_t ssi_temp_handler(const char *tag, char *out, u16_t out_len) { int t adc_read_temp_x10(); /* 例253 表示 25.3℃ */ return (u16_t)snprintf(out, out_len, %d.%d, t / 10, t % 10); } static const tSSIHandler ssi_handlers[] { { temp, ssi_temp_handler }, };HTML 里对应写成!--#temp--服务器扫描到#temp标签就回调把返回值替换进页面再发给浏览器。注意两点一是温度换算别用浮点整数先放大十倍再拼接省掉编译器和库的浮点开销二是 SSI 只在页面发送时生效适合低频刷新控制类操作还是走 4.3 的 CGI 更可靠。如果例程是手写服务器同样思路可以做成“在发送循环里查标签、替换、再发送”核心逻辑一致。5. 避坑清单网络、乱码、内存三类问题的现场实录5.1 网络链路和HTTP协议层的三个坑坑1网线插上 Link 灯亮ARP 就是不通。现象板子与 PC 直连PHY 的 Link 灯常亮PC 端 ping 一直 timeout串口打印网络任务正常启动。原因PHY 的 50MHz 参考时钟没真正送到芯片只是电气上链了路数据通路没有建立。解决先量 PHY 的 CLKIN/XI 引脚有没有 50MHzMCO 方式的查 CubeMX 里 MCO 源和分频晶振方式的查 PHY 是否处于内部倍频模式。这条我踩过两次全是 MCO 配置在 CubeMX 重新生成代码时被冲掉。坑2MDIO 读 PHY ID 超时或读到 0xFFFF。现象第 3.2 节的确认代码跑不过HAL 返回 HAL_ERROR。原因驱动里写死的 PHY 地址和板子跳线不一致例如 LAN8720A 实际是 0x00驱动按 0x01 访问。解决把驱动里 PHY 地址改成 0x00 再试同时确认 MDC、MDIO 两个引脚没被复用成别的功能。改地址前先拍照留档这种改动属于典型“看一眼就后悔没记下来”的改动。坑3浏览器能出 HTML 首屏但图片和 CSS 转圈。现象首页文字出来了样式丢了一半进度条长时间不满。原因响应头没有Content-Length或者回复后没有正确关闭连接浏览器不知道 body 什么时候结束。解决发送前用 snprintf 把 body 字节数拼进响应头显式加Connection: close处理完立刻netconn_close加netconn_delete。检查办法是浏览器 F12 看 Network 面板里卡住的请求响应长度和实际 body 是否对得上。5.2 编码与内存的两个坑坑4页面中文全部变乱码。现象标题、说明文字显示成“温度”这类符号。原因HTML 文件在 Windows 编辑器里被存成了 GBK/ANSI浏览器按 UTF-8 解码两边对不上。解决所有 .html 文件统一转成 UTF-8 无 BOMhead 里加meta charsetutf-8C 字符串里的中文也确认源文件是 UTF-8 保存。这个坑和网卡无关但最容易让人误判成协议栈问题。坑5跑几个小时网页打不开复位后恢复。现象刚上电一切正常长时间运行后新连接无响应看门狗复位后又能撑一阵。原因LwIP 内存池在某个错误路径上只借不还典型就是netconn_recv成功但handle_http_request异常返回时没有执行netbuf_delete和netconn_closepbuf 泄漏逐渐把池子吃干。解决把 LWIP_STATS 打开串口周期打印 pbuf 和 mem 余量观察数值是否单调下降代码上把成功、失败两条路径统一收口保证每个分支都执行 close 和 delete。第 4.1 节的骨架里netbuf_delete放在handle_http_request之后就是为了堵这个洞。6. 进阶把HTML页面搬进外部Flash再做一张URL映射表6.1 页面不再编进固件URI映射到Flash地址页面文件多起来之后每次改文案都重新编译固件很不划算。常见做法是把 HTML、CSS、JS 打包成 bin通过串口工具或 OTA 写进 SPI NOR Flash比如 W25Q32 系列服务器启动时根据 URL 查映射表按地址读出返回。映射表本质上就是路由表的物理版struct uri_map { const char *uri; uint32_t flash_addr; uint32_t length; }; static const struct uri_map uri_tbl[] { { /index.html, 0x100000, 4096 }, { /style.css, 0x101000, 1024 }, { /app.js, 0x102000, 2048 }, };收到GET /index.html时遍历这张表找到表项后用 Flash 读接口取数据响应头照旧拼Content-Length。注意两个细节SPI NOR 的擦除粒度是 4KB 扇区文件务必按扇区对齐布局否则后续更新会越界擦掉邻居文件长度在烧录时一并记录到固件的配置区不要运行时去猜。验证方法很硬核浏览器 F12 Network 面板数请求数逐个看状态码和耗时串口同时打印每次请求的 URI 和实际送出的字节数两边对得上才算闭环。这套流程我完整复现过三次第一次死在 PHY 时钟第二次死在中文编码第三次才把页面搬进 Flash。从那以后我拿到任何 WebServer 例程都强制先走三件事先 ping 通、再开 LwIP 内存统计看池余量、最后用浏览器 Network 面板数请求数三件事全绿才敢合壳。希望帮到你。本文还有配套的精品资源点击获取