ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F407+LAN8720A实战:LwIP嵌入式WebServer登录注册

STM32F407+LAN8720A实战:LwIP嵌入式WebServer登录注册

简介:这份资源面向嵌入式开发初学者与物联网方向学习者,围绕STM32F407微控制器搭配LAN8720A以太网PHY芯片,构建一个可运行于开发板上的嵌入式Web服务器,并实现简单的登录与注册功能。内容涉及TCP/IP协议栈(如lwIP)、HTTP请求解析、用户数据存储、HTML表单页面设计以及密码加密等安全考量,是理解嵌入式网络编程与前后端协作的完整实践案例。压缩包共588个文件,约16.37MB,以c与h源码为主,辅以o、d、crf等编译中间文件,以及htm、html、shtml网页文件、readme说明和hex固件等,便于直接烧录与二次开发。目前已有881人学习下载。通过该工程,读者可掌握PHY初始化、MAC地址配置、HTTP服务器框架搭建与表单响应生成等关键环节,并借助Keil调试与网络抓包工具排查通信问题,适合作为课程设计或物联网入门的参考项目。

1. 从一块吃灰的开发板说起:STM32F407 上跑 WebServer 到底能做什么

手头有块 STM32F407 开发板,网口旁边焊着 LAN8720A 这颗 PHY 芯片,很多人第一次把网线插上去、ping 通之后,就不知道下一步该干嘛了。其实这套硬件组合最实用的一个练手方向,就是在片子上跑一个嵌入式 WebServer,用浏览器打开一个页面,输入账号密码完成登录注册,把「嵌入式 + HTML」这条链路彻底打通。它适合刚学完以太网外设、想找个完整项目练手的嵌入式新手,也适合需要给设备加一个轻量配置页面的开发者——毕竟不是所有设备都值得上一套 Linux。这篇就按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份 STM32F407 + LAN8720A 的 WebServer 登录注册项目拆开讲清楚。

2. 项目架构与目录结构:LwIP 协议栈怎么和 HTTP 服务对接

2.1 整体分层:从网线到浏览器页面

这个项目的本质,是在 STM32F407 上跑一个精简的 TCP/IP 协议栈(LwIP),在它之上再实现一个极简的 HTTP 服务端,最后由 HTTP 服务端去处理浏览器发来的登录、注册表单请求。整条链路可以拆成四层来看:

  • 硬件层:STM32F407 的 ETH 外设通过 RMII 接口连接 LAN8720A,LAN8720A 再接到 RJ45 网口。F407 内部自带 MAC 控制器,LAN8720A 只负责物理层收发。
  • 驱动层:ETH 驱动 + LAN8720A 的初始化,包括复位、PHY 地址探测、自协商、RMII 时钟配置。这一层跑通的标准就是能 ping 通。
  • 协议层:LwIP 协议栈,提供 TCP/UDP/IP/ICMP 等。WebServer 用的是 TCP,监听 80 端口。
  • 应用层:HTTP 服务端 + 登录注册业务逻辑。它解析 HTTP 请求行和请求体,判断是 GET 还是 POST,然后返回对应的 HTML 页面或处理结果。

分层的好处是排查问题时能快速定位:ping 不通就是驱动层的事,能 ping 通但页面打不开就是 LwIP 或 HTTP 层的事,页面能打开但登录失败就是业务逻辑的事。

2.2 目录结构:文件都放在哪

一份典型的工程目录大致长这样,不同模板可能略有差异,但核心文件跑不出这几类:

Project/ ├── Core/ │ ├── Src/ │ │ ├── main.c // 主函数,初始化 + 主循环 │ │ ├── ethernetif.c // 网卡驱动接口,LwIP 和 ETH 的桥梁 │ │ └── gpio.c // GPIO 初始化 │ └── Inc/ │ └── main.h ├── Drivers/ │ ├── STM32F4xx_HAL_Driver/ // HAL 库 │ └── CMSIS/ // 内核相关 ├── LWIP/ │ ├── App/ │ │ └── http_server.c // HTTP 服务端核心逻辑 │ ├── Target/ │ │ └── ethernetif.c // 网卡底层收发 │ └── lwipopts.h // LwIP 配置裁剪 ├── Middlewares/ │ └── Third_Party/LwIP/ // LwIP 源码 └── MDK-ARM/ 或 STM32CubeIDE/ // 工程文件

关键要盯住三个文件:ethernetif.c负责把网卡收到的数据递给 LwIP,http_server.c负责处理 HTTP 请求,lwipopts.h决定协议栈开多大内存、支持多少连接。这三个文件改明白了,项目基本就吃透了。

2.3 内存与连接数配置:lwipopts.h 里几个不能乱动的参数

LwIP 在单片机上跑,内存是硬约束,lwipopts.h里几个参数直接决定 WebServer 能不能稳定工作:

参数典型值作用调小的后果
MEM_SIZE16*1024LwIP 堆总大小页面大一点就分配失败
PBUF_POOL_SIZE16pbuf 池数量并发请求直接丢包
TCP_SND_BUF2*TCP_MSSTCP 发送缓冲大页面发不完整
TCP_WND4*TCP_MSSTCP 接收窗口传输效率骤降
MEMP_NUM_TCP_PCB5最大 TCP 连接数多开一个浏览器标签就拒绝

我一般会先把MEM_SIZE给到 16KB 以上,因为一个带 CSS 的登录页面本身就不小,堆太小会出现「页面加载一半卡住」这种玄学问题。MEMP_NUM_TCP_PCB至少留 5 个,浏览器会同时开好几个连接去请求 favicon、css、js,只留 1 个的话页面永远转圈。

3. 跑起来:从 ping 通到浏览器弹出登录页

3.1 硬件连接与网络参数确认

先把硬件接对。LAN8720A 和 F407 之间是 RMII 接口,需要确认这几根线:REF_CLK(50MHz 时钟)、TXD0/TXD1、RXD0/RXD1、TX_EN、CRS_DV、MDIO、MDC。很多板子把 REF_CLK 由外部晶振提供,也有的是从 STM32 的 MCO 引脚输出,这个必须和代码里的配置对上,否则 PHY 初始化直接失败。

网络参数在代码里通常是写死的,先找到这几行:

/* 静态 IP 配置,位于 main.c 或 ethernetif.c 中 */ #define IP_ADDR0 192 #define IP_ADDR1 168 #define IP_ADDR2 1 #define IP_ADDR3 100 #define NETMASK_ADDR0 255 #define NETMASK_ADDR1 255 #define NETMASK_ADDR2 255 #define NETMASK_ADDR3 0 #define GW_ADDR0 192 #define GW_ADDR1 168 #define GW_ADDR2 1 #define GW_ADDR3 1

把开发板 IP 设成和电脑同网段,比如电脑是 192.168.1.10,板子就设 192.168.1.100。注意别和路由器 DHCP 池冲突,否则会出现「一会儿能 ping 通一会儿不通」的诡异现象。

3.2 编译烧录与 ping 测试

用 Keil 或 STM32CubeIDE 打开工程,编译前先确认两件事:一是lwipopts.h里的LWIP_DHCP如果没开,就走静态 IP;二是ethernetif.c里的 PHY 地址要和硬件匹配,LAN8720A 默认地址是 0,但有些板子会拉高变成 1。

烧录完成后,把网线一头插开发板,一头插电脑(或者同一个交换机)。电脑端先关掉无线,只留有线的那个网卡,然后:

# Windows 下测试连通性 ping 192.168.1.100 # Linux/Mac 下 ping -c 4 192.168.1.100

能稳定返回就说明 ETH 驱动、LAN8720A、LwIP 三层都通了。如果 ping 不通,先看电脑网卡是不是自动获取了别的网段地址,再检查网线是不是插在 LAN8720A 对应的那个口上。

3.3 浏览器访问与登录注册流程

ping 通之后,直接在浏览器地址栏输入http://192.168.1.100,正常情况下会返回一个登录页面。这个页面的 HTML 是存在单片机里的,可能是以字符串常量形式写在http_server.c里,也可能是放在外部 Flash 或 SD 卡里按需读取。

登录注册的交互流程是这样的:

  1. 浏览器 GET 请求/,服务端返回登录页 HTML。
  2. 用户在页面填账号密码,点登录,浏览器 POST 到/login。
  3. 服务端解析 POST 请求体,取出username和password字段。
  4. 和存储的账号密码比对,成功返回欢迎页,失败返回错误提示。
  5. 注册流程类似,POST 到/register,服务端把新账号存起来。

存储方式取决于项目实现,简单版本可能就是一个全局数组,掉电就丢;讲究一点的会写到内部 Flash 的某个扇区,或者外挂一颗 EEPROM。这个在二次开发时是要重点改的地方。

4. 核心代码拆解:HTTP 请求怎么被解析和响应

4.1 HTTP 服务端主循环:接收、解析、分发

HTTP 服务端的核心是一个循环:等待连接、读取请求、解析请求行、根据路径分发处理、发送响应、关闭连接。用 LwIP 的 raw API 写出来大概是这样:

/* http_server.c 中的连接回调,LwIP 收到新连接时调用 */ static err_t http_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { if (p == NULL) { /* 对端关闭连接 */ tcp_close(tpcb); return ERR_OK; } /* 把 pbuf 链里的数据拷到连续缓冲区,方便按字符串解析 */ char *req = (char *)malloc(p->tot_len + 1); pbuf_copy_partial(p, req, p->tot_len, 0); req[p->tot_len] = '\0'; /* 解析请求行,例如 "POST /login HTTP/1.1" */ if (strncmp(req, "GET / ", 6) == 0) { send_login_page(tpcb); /* 返回登录页 */ } else if (strncmp(req, "POST /login", 11) == 0) { handle_login(tpcb, req); /* 处理登录 */ } else if (strncmp(req, "POST /register", 14) == 0) { handle_register(tpcb, req); /* 处理注册 */ } else { send_404(tpcb); /* 其他路径返回 404 */ } free(req); tcp_recved(tpcb, p->tot_len); /* 告诉 LwIP 数据已处理 */ pbuf_free(p); return ERR_OK; }

这段代码有几个关键点。pbuf_copy_partial把分散的 pbuf 拼成连续内存,因为 HTTP 是文本协议,按字符串处理最省事。tcp_recved必须调用,否则 LwIP 的接收窗口不会前移,传几个包之后就卡死了。malloc的大小是p->tot_len + 1,多出来的 1 是给字符串结束符\0的,这个不加会出现越界读,属于血泪经验。

4.2 登录请求的解析:从 POST 请求体里抠出账号密码

POST 请求的账号密码在请求体里,格式是username=xxx&password=yyy。解析函数要做的就是找到请求体和响应体之间的空行,然后按&和=切分:

/* 从 POST 请求体中提取指定字段的值 */ static int get_form_value(const char *req, const char *key, char *out, int out_len) { /* 请求头和请求体之间是一个空行 "\r\n\r\n" */ const char *body = strstr(req, "\r\n\r\n"); if (body == NULL) return -1; body += 4; /* 跳过空行 */ /* 构造 "key=" 用于查找 */ char pattern[32]; snprintf(pattern, sizeof(pattern), "%s=", key); const char *start = strstr(body, pattern); if (start == NULL) return -1; start += strlen(pattern); /* 值的结束位置是下一个 '&' 或字符串结尾 */ const char *end = strchr(start, '&'); int len = (end == NULL) ? strlen(start) : (end - start); if (len >= out_len) len = out_len - 1; memcpy(out, start, len); out[len] = '\0'; return 0; }

strstr找\r\n\r\n是定位请求体的标准做法,HTTP 协议规定头和体之间必须有一个空行。snprintf拼key=是为了避免手写字符串拼接出错。值的结束判断要同时考虑&和字符串结尾两种情况,只判断&的话最后一个字段会读越界。这个函数在登录和注册里都能复用,注册时多调几次取不同字段就行。

4.3 账号存储与比对:一个够用但不优雅的实现

最简单的账号存储就是一个结构体数组,注册时往空位写,登录时遍历比对:

#define MAX_USERS 8 #define USERNAME_LEN 16 #define PASSWORD_LEN 16 typedef struct { char username[USERNAME_LEN]; char password[PASSWORD_LEN]; uint8_t used; /* 该槽位是否已被占用 */ } user_t; static user_t g_users[MAX_USERS]; /* 注册:找一个空槽位写入 */ static int user_register(const char *name, const char *pwd) { for (int i = 0; i < MAX_USERS; i++) { if (!g_users[i].used) { strncpy(g_users[i].username, name, USERNAME_LEN - 1); strncpy(g_users[i].password, pwd, PASSWORD_LEN - 1); g_users[i].used = 1; return 0; } } return -1; /* 用户已满 */ } /* 登录:遍历比对 */ static int user_login(const char *name, const char *pwd) { for (int i = 0; i < MAX_USERS; i++) { if (g_users[i].used && strcmp(g_users[i].username, name) == 0 && strcmp(g_users[i].password, pwd) == 0) { return 0; /* 登录成功 */ } } return -1; /* 账号或密码错误 */ }

这个实现够跑通流程,但有两个明显问题:一是密码明文存储,二是掉电丢失。做产品肯定不行,但作为学习项目,先把流程跑通再谈优化。真要改,密码至少做个简单哈希,存储换成内部 Flash 的某个扇区,这些在二次开发章节会展开说。

4.4 响应发送:把 HTML 页面塞进 TCP 流

发送响应时要注意 HTTP 响应头必须带Content-Length,否则浏览器不知道页面传完没有,会一直转圈:

/* 发送 HTTP 响应,body 是页面内容 */ static void http_send(struct tcp_pcb *tpcb, const char *status, const char *body) { char header[128]; int body_len = strlen(body); /* 拼响应头,Content-Length 必须和实际长度一致 */ int hlen = snprintf(header, sizeof(header), "HTTP/1.1 %s\r\n" "Content-Type: text/html; charset=utf-8\r\n" "Content-Length: %d\r\n" "Connection: close\r\n" "\r\n", status, body_len); tcp_write(tpcb, header, hlen, TCP_WRITE_FLAG_COPY); tcp_write(tpcb, body, body_len, TCP_WRITE_FLAG_COPY); tcp_output(tpcb); /* 立即把数据推出去 */ }

TCP_WRITE_FLAG_COPY表示让 LwIP 自己拷贝一份数据,这样函数返回后 header 和 body 的栈内存被回收也不影响发送。tcp_output是必须的,不调用的话数据会留在发送缓冲里等下一个包凑一起,浏览器那边就一直等。Connection: close表示响应完就关连接,省得维护 keep-alive 状态,对单片机来说更省内存。

5. 避坑与排查:那些让页面打不开的常见问题

5.1 ping 不通,但网口灯是亮的

现象:网口指示灯正常闪烁,但电脑 ping 开发板完全没反应。

原因:大概率是 PHY 地址配错了,或者 RMII 的 REF_CLK 没有正确供给。LAN8720A 需要 50MHz 时钟,如果板子上没有独立晶振,就得靠 STM32 的 MCO 输出,而 MCO 的时钟源配置经常被忽略。

解决:先用示波器量 REF_CLK 引脚有没有 50MHz 波形,没有就先解决时钟。有波形但还 ping 不通,就把 PHY 地址从 0 试到 1,LAN8720A 的 PHYAD0 引脚状态决定地址,硬件上拉就是 1,下拉就是 0。

5.2 能 ping 通,但浏览器打开是空白页

现象:ping 正常,浏览器输入 IP 后一直转圈,最后超时或显示空白。

原因:HTTP 响应里Content-Length和实际 body 长度不一致,或者根本没发Content-Length。浏览器收不到长度信息就会一直等。

解决:检查http_send里body_len的计算,确认strlen(body)和实际发送的字节数一致。如果 body 里有中文,注意strlen返回的是字节数不是字符数,UTF-8 下一个中文占 3 字节,这个一般不会错,但拼接字符串时容易多算或少算。

5.3 登录点下去没反应,或者返回 404

现象:登录页能打开,填完账号密码点登录,页面跳转后显示 404 或者干脆没反应。

原因:表单的action路径和服务端解析的路径对不上。比如 HTML 里写的是<form action="/login" method="post">,但服务端判断的是POST /login.html,那就匹配不上。

解决:把 HTML 里的action和服务端strncmp的字符串对齐。建议在服务端加一句调试打印,把收到的请求行原样输出到串口,一眼就能看出路径差在哪。

5.4 连续刷新几次后板子死机

现象:第一次打开页面正常,刷新几次之后板子不响应了,ping 也断。

原因:TCP 连接没正确关闭,MEMP_NUM_TCP_PCB被耗尽。每次刷新浏览器会开新连接,旧连接如果没走tcp_close,PCB 就一直占着。

解决:在http_recv里处理完请求后确保调用tcp_close,并且tcp_recved和pbuf_free都不能漏。另外把MEMP_NUM_TCP_PCB从默认的 5 调到 8 左右,给浏览器并发留点余量。

5.5 注册成功但重启后账号没了

现象:注册完能登录,板子断电重启后账号消失。

原因:账号存在 RAM 里,掉电自然丢。这是设计如此,不是 bug。

解决:如果项目要求持久化,把g_users数组写到 STM32 内部 Flash 的某个扇区。注意 Flash 写之前要先擦除整个扇区,而且擦写次数有限,不要每次注册都写,可以攒几个一起写或者加个手动保存按钮。

6. 二次开发:把登录注册改成设备配置页的实战思路

这个项目最有价值的二次开发方向,是把登录注册那套表单处理逻辑,改造成设备参数配置页面。因为登录注册本质上就是「浏览器提交表单 → 单片机解析字段 → 执行动作」,配置页面也是同一个套路,只是字段从账号密码变成了 IP 地址、端口号、采集周期这些。

改造的第一步是换页面。把send_login_page里的 HTML 字符串换成配置表单,字段名改成ip、port、interval之类。这里有个技巧:HTML 不要用snprintf一点点拼,直接写成一个大的字符串常量,编译器会把它放到 Flash 里,不占 RAM。我一般会这样组织:

/* 配置页面,整个 HTML 作为常量存 Flash */ static const char config_page[] = "<!DOCTYPE html><html><head><meta charset='utf-8'>" "<title>Device Config</title></head><body>" "<h2>Device Configuration</h2>" "<form action='/save' method='post'>" "IP: <input name='ip' value='%s'><br>" "Port: <input name='port' value='%d'><br>" "Interval: <input name='interval' value='%d'><br>" "<input type='submit' value='Save'>" "</form></body></html>";

注意这个常量里有%s和%d,发送前要用snprintf把当前配置填进去,生成一个临时缓冲区再发。临时缓冲区大小要按页面最大长度给够,一般 1KB 到 2KB 足够。

第二步是改解析。get_form_value函数不用动,直接复用,只是 key 从username换成ip。解析出来的字符串要转成数值,IP 地址按.切分四段分别atoi,端口和周期直接atoi就行。转换完做范围校验,IP 每段 0 到 255,端口 1 到 65535,超出范围就返回错误页面。

第三步是存储。配置参数和账号不一样,它必须持久化,否则重启就丢。写到内部 Flash 时要注意:STM32F407 的 Flash 扇区比较大(最小 16KB),写几个字节也要擦一整个扇区,所以最好把配置结构体对齐到一个扇区起始地址,擦写时整块处理。写之前先解锁,写完再上锁,中间别被打断。

第四步是加个「重启生效」的提示。很多配置改完需要重启网络才能生效,页面上给个提示,或者干脆在保存后自动软重启。软重启用NVIC_SystemReset()就行,但重启前要把配置写完并确认落盘。

验证方法很简单:改完 IP 保存,断电重启,看新 IP 能不能 ping 通。能通就说明整条链路没问题。如果 ping 不通,先用串口打印当前配置,确认 Flash 读出来的值是对的,再排查网络初始化是不是用了旧值。

从那以后我每次改这类配置页面,都会先在串口把解析出来的字段原样打印一遍,确认「浏览器发的」和「单片机收的」完全一致,再往下走存储和生效逻辑。这个习惯帮我省了无数次来回烧录的时间。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进