☰
STM32F407+LAN8720A嵌入式WebServer实战:LwIP实现登录注册与会话管理
2026/10/19 9:22:35 网站建设 项目流程

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

1. 需求先立住:为什么要在 STM32F407 上跑一个带登录注册的 WebServer

很多做 stm32 嵌入式 HTML 方向毕设的同学,一开始都会卡在同一个问题上:板子能点灯、能串口打印,但一到"做个能演示的完整系统"就不知道从哪下手。登录注册这个需求看着简单,实际上它逼着你把网络协议栈、HTTP 解析、会话管理、前端页面、数据存储这几块全部串起来,是一个非常适合作为课程设计或毕设骨架的题目。这套方案要解决的核心问题是:在 STM32F407 + LAN8720A 这套经典硬件组合上,跑通一个能通过浏览器访问、支持用户注册和登录、登录后能进入受保护页面的嵌入式 WebServer。它适合正在做嵌入式方向毕设、需要一份可演示可答辩完整方案的学生,也适合想快速搭一个设备本地配置页面的从业者参考。下面从选型、架构、实现到验收,把整条链路拆开讲清楚。

2. 技术选型与总体架构:LwIP + 裸机还是 RTOS,页面放哪

2.1 为什么是 LwIP 而不是自己写协议栈

STM32F407 自带以太网 MAC 控制器,LAN8720A 负责 PHY 层,两者通过 RMII 接口连接。MAC 有了,但 TCP/IP 协议栈必须自己解决。常见选择有三个:LwIP、uIP、自己撸一个精简栈。uIP 太老,功能弱,做 HTTP 会话管理会很难受;自己写协议栈在毕设周期内基本不现实。LwIP 是 ST 官方和社区都支持得最好的方案,STM32CubeMX 里可以直接勾选,配合 FreeRTOS 或裸机都能跑。

我一般建议裸机 + LwIP raw API 的路线,原因是:登录注册这种低频交互场景,并发量极低,裸机轮询完全够用,而且省掉了 RTOS 任务划分和信号量的复杂度,调试时出问题更容易定位。如果你后续要加多客户端并发或者实时性要求高的任务,再上 FreeRTOS + LwIP socket API 也不迟。

2.2 总体架构:四个模块的职责划分

整个系统按职责切成四层,从下往上依次是:

层级模块职责
硬件层STM32F407 + LAN8720AMAC/PHY 通信,RMII 接口,时钟 50MHz
协议层LwIP 2.xARP、IP、TCP、HTTP 底层收发
应用层HTTP 服务 + 会话管理请求解析、路由分发、Cookie/Session 校验
数据层内部 Flash / 外部 SPI Flash用户账号密码存储

页面资源(HTML/CSS/JS)通常有两种放法:一是转成 C 数组直接编译进固件,二是放到外部 SPI Flash 或 SD 卡里按文件系统读取。前者简单但改页面要重新烧录,后者灵活但要多写一套文件读取逻辑。毕设场景我推荐先用 C 数组方式,把页面调通,再考虑迁移到外部存储。

2.3 网络参数配置示例

LwIP 初始化时几个关键参数必须配对,否则会出现"能 ping 通但打不开网页"的玄学问题:

// lwipopts.h 关键配置 #define LWIP_DHCP 0 // 毕设用静态 IP,方便调试 #define IP_ADDR0 192 #define IP_ADDR1 168 #define IP_ADDR2 1 #define IP_ADDR3 100 // 板子固定 IP #define LWIP_TCP 1 #define TCP_WND (4 * TCP_MSS) // 窗口别开太大,F407 内存有限 #define MEM_SIZE (16 * 1024) // LwIP 堆内存,页面大就往上加 #define PBUF_POOL_SIZE 16

MEM_SIZE是最容易翻车的参数。默认值往往只有几 KB,页面稍微大一点(比如带 CSS 的登录页)就会分配失败,表现为浏览器一直转圈或者返回 500。我一般会先给到 16KB,跑通后再根据实际占用往下压。TCP_WND不要超过MEM_SIZE的四分之一,否则高负载时会内存耗尽。

3. HTTP 服务与登录注册逻辑:请求怎么解析,会话怎么维持

3.1 HTTP 请求解析:从原始 TCP 数据到路由

LwIP 的 raw API 收到 TCP 数据后,回调里拿到的是一段原始字节流。你需要自己解析出请求方法、路径、头部和 body。核心逻辑是找到第一个\r\n\r\n,前面是请求行和头部,后面是 body。请求行格式固定为METHOD PATH VERSION,按空格切分即可。

// 简化的请求解析:提取方法和路径 void http_parse_request(char *buf, http_req_t *req) { char *line_end = strstr(buf, "\r\n"); if (line_end == NULL) return; // 解析请求行 "GET /login.html HTTP/1.1" sscanf(buf, "%15s %63s", req->method, req->path); // 找 body 起始位置(POST 请求才有) char *body = strstr(buf, "\r\n\r\n"); if (body != NULL) { req->body = body + 4; } // 提取 Cookie 中的 session id char *cookie = strstr(buf, "Cookie:"); if (cookie != NULL) { char *sid = strstr(cookie, "sid="); if (sid != NULL) { sscanf(sid, "sid=%31s", req->session_id); } } }

这里有个细节:sscanf的%63s限制了路径长度,防止缓冲区溢出。嵌入式环境里所有字符串操作都要带长度限制,这是血泪经验。Cookie 解析只做了最简单的sid=匹配,实际项目中如果 Cookie 有多个字段,需要按分号切分后再逐个匹配。

3.2 路由分发:用查表代替 if-else 堆叠

路径多了以后,一长串if (strcmp(path, "/xxx") == 0)会非常难维护。我一般用一个路由表:

typedef struct { const char *path; const char *method; void (*handler)(int conn, http_req_t *req); } route_t; static const route_t routes[] = { {"/", "GET", handle_index}, {"/login.html", "GET", handle_login_page}, {"/register", "POST", handle_register}, {"/login", "POST", handle_login}, {"/dashboard", "GET", handle_dashboard}, // 受保护页面 }; void http_dispatch(int conn, http_req_t *req) { for (int i = 0; i < sizeof(routes)/sizeof(routes[0]); i++) { if (strcmp(req->path, routes[i].path) == 0 && strcmp(req->method, routes[i].method) == 0) { routes[i].handler(conn, req); return; } } http_send_404(conn); // 没匹配到统一返回 404 }

路由表的好处是新增页面只加一行,不用动分发逻辑。/dashboard这类受保护页面在 handler 里先校验 session,校验不过就 302 重定向到登录页。

3.3 会话管理:一个够用就好的 Session 方案

嵌入式设备上跑完整 Session 框架不现实,我一般用一个固定大小的会话表:

#define MAX_SESSIONS 4 typedef struct { char sid[33]; // 32 位随机字符串 + '\0' char username[16]; uint32_t expire; // 过期时间戳(秒) uint8_t valid; } session_t; static session_t sessions[MAX_SESSIONS]; // 登录成功后创建会话 const char *session_create(const char *username) { for (int i = 0; i < MAX_SESSIONS; i++) { if (!sessions[i].valid) { gen_random_sid(sessions[i].sid, 32); // 用硬件 RNG 生成 strncpy(sessions[i].username, username, 15); sessions[i].expire = sys_now() / 1000 + 1800; // 30 分钟 sessions[i].valid = 1; return sessions[i].sid; } } return NULL; // 会话满了 }

MAX_SESSIONS设 4 是因为毕设演示场景同时在线不会超过两三个人,设太大浪费 RAM。sid用 STM32F407 自带的硬件随机数发生器生成,比rand()安全得多。过期时间设 30 分钟,答辩演示足够用。会话校验函数在每次访问受保护页面时调用,过期或无效就清掉对应槽位。

3.4 用户数据存储:内部 Flash 扇区读写

账号密码不能明文存,至少要做一次哈希。F407 内部 Flash 按扇区擦除,我一般划出最后一个扇区(扇区 11,128KB)专门存用户数据:

// 用户记录结构,按扇区偏移依次存放 typedef struct { uint8_t used; // 0xFF 表示空槽 char username[16]; uint8_t pwd_hash[32]; // SHA-256 结果 uint8_t salt[16]; // 每个用户独立盐值 } user_record_t; // 写入前必须先擦除整个扇区 int user_save(user_record_t *rec) { HAL_FLASH_Unlock(); // 找到第一个空槽写入 for (int i = 0; i < MAX_USERS; i++) { uint32_t addr = USER_FLASH_BASE + i * sizeof(user_record_t); if (*(uint8_t *)addr == 0xFF) { // 按字写入,F407 要求 32 位对齐 flash_write_words(addr, (uint32_t *)rec, sizeof(user_record_t)/4); break; } } HAL_FLASH_Lock(); return 0; }

注意 F407 写 Flash 必须按 32 位字对齐,写之前要确保目标区域已擦除。擦除操作会阻塞 CPU 几百毫秒,期间网络会卡顿,所以注册操作不要频繁触发。密码哈希用 SHA-256 + 每用户独立盐值,虽然比不上 bcrypt,但在嵌入式场景下已经能挡住明文泄露的风险。

4. 前端页面与交互:嵌入式 HTML 怎么写才不臃肿

4.1 页面精简原则:能内联就内联

嵌入式设备带宽和内存都紧张,页面要尽量小。CSS 和 JS 直接内联在 HTML 里,不要外链,省掉额外的 HTTP 请求。一个登录页控制在 2KB 以内比较理想:

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>Login</title> <style> body{font-family:sans-serif;max-width:320px;margin:40px auto;padding:0 16px} input{width:100%;padding:8px;margin:6px 0;box-sizing:border-box} button{width:100%;padding:10px;background:#2c3e50;color:#fff;border:none} .err{color:#e74c3c;font-size:13px} </style> </head> <body> <h2>设备登录</h2> <form method="post" action="/login"> <input name="user" placeholder="用户名" required> <input name="pass" type="password" placeholder="密码" required> <button type="submit">登录</button> </form> <p class="err">%ERROR%</p> <p><a href="/register.html">没有账号?注册</a></p> </body> </html>

%ERROR%是占位符,服务端返回时替换成具体错误信息(比如"用户名或密码错误")。这种模板替换方式比动态拼接字符串安全,也更容易维护。页面里没有用任何外部字体、图标库、框架,全部手写,这是嵌入式 HTML 的基本素养。

4.2 表单提交与响应处理

注册和登录都用 POST 表单提交,Content-Type是application/x-www-form-urlencoded。服务端解析 body 时按&和=切分:

// 解析 "user=abc&pass=123" 格式的 body int parse_form(const char *body, const char *key, char *out, int out_len) { char pattern[32]; snprintf(pattern, sizeof(pattern), "%s=", key); const char *p = strstr(body, pattern); if (p == NULL) return -1; p += strlen(pattern); int i = 0; while (*p && *p != '&' && i < out_len - 1) { out[i++] = *p++; } out[i] = '\0'; return 0; }

这个解析函数没有处理 URL 编码(比如%20代表空格),如果用户名允许特殊字符需要额外做解码。毕设场景下限制用户名只能是字母数字下划线,就能绕开这个问题,在注册校验里加一条正则判断即可。

4.3 登录态校验与页面跳转

受保护页面在返回内容前先查 session:

void handle_dashboard(int conn, http_req_t *req) { session_t *s = session_find(req->session_id); if (s == NULL) { // 未登录,302 跳转到登录页 http_send_redirect(conn, "/login.html"); return; } // 已登录,返回带用户名的页面 char page[512]; snprintf(page, sizeof(page), "<h2>欢迎,%s</h2><p>设备状态正常</p>", s->username); http_send_html(conn, page); }

302 重定向比直接返回 403 体验好,用户不会看到一个空白错误页。session_find内部会检查过期时间,过期就顺手把槽位清掉,相当于惰性回收。

5. 避坑与常见问题排查:那些让你调一整天的地方

5.1 ping 通但网页打不开

现象:电脑能 ping 通板子 IP,但浏览器访问一直转圈或超时。原因通常是 LwIP 的MEM_SIZE或PBUF_POOL_SIZE太小,TCP 连接建立后分配不到足够内存发送数据。解决:把MEM_SIZE加到 16KB 以上,PBUF_POOL_SIZE加到 16,重新编译烧录。如果还不行,检查TCP_WND是否设得比MEM_SIZE还大。

5.2 LAN8720A 时钟配置错误导致完全不通

现象:网口灯不亮,或者灯亮但完全无法通信。原因:LAN8720A 需要 50MHz 参考时钟,通常由 STM32 的 MCO 引脚输出,如果 CubeMX 里没配置 MCO 或者时钟源选错,PHY 根本不工作。解决:确认RCC里MCO1配置为HSE或PLL,输出 50MHz,并且硬件上 MCO 引脚确实连到了 LAN8720A 的REF_CLK。这个坑硬件软件各占一半,先量时钟再查代码。

5.3 Flash 写入后数据读出来是 0xFF

现象:注册用户后重启,账号消失。原因:F407 写 Flash 前必须先擦除整个扇区,如果只写不擦,或者擦除的扇区和写入的地址不在同一个扇区,数据写不进去。解决:确认USER_FLASH_BASE落在扇区 11 的起始地址(0x080E0000),写入前调用HAL_FLASHEx_Erase擦除该扇区。另外注意擦除会阻塞,不要在中断里调用。

5.4 浏览器缓存导致页面不更新

现象:改了 HTML 重新烧录,浏览器看到的还是旧页面。原因:浏览器对静态资源做了缓存,尤其是路径没变的情况下。解决:在 HTTP 响应头里加Cache-Control: no-cache,或者调试时用无痕窗口。更彻底的做法是给页面 URL 加版本号,比如/login.html?v=2,但嵌入式场景下加响应头更省事。

5.5 多客户端同时访问时死机

现象:两个人同时打开页面,板子卡死或重启。原因:裸机 LwIP 的 raw API 回调是单线程的,如果 handler 里做了耗时操作(比如 Flash 擦除),会阻塞整个协议栈。解决:把耗时操作拆成状态机分步执行,或者临时切到 FreeRTOS 把 HTTP 处理放到独立任务里。毕设演示通常单人操作,但答辩时老师可能会多设备同时访问,提前想到这一点能避免现场翻车。

6. 验收与答辩:怎么证明这套 WebServer 真的跑通了

验收分三块:功能、稳定性、代码质量。功能验收按这个顺序走一遍,每一步都要能复现:

验收项操作预期结果
网络连通电脑 ping 板子 IP延迟稳定,无丢包
首页访问浏览器打开http://192.168.1.100/返回登录页,样式正常
注册流程填写新用户名密码提交提示注册成功,Flash 中可读出记录
登录流程用刚注册的账号登录跳转到 dashboard,显示用户名
会话保持刷新 dashboard 页面仍显示登录状态,不跳回登录页
会话过期等待 30 分钟后刷新自动跳回登录页
错误处理用错误密码登录页面提示"用户名或密码错误"
重复注册用已存在用户名注册提示"用户名已存在"

稳定性验收我一般会做两件事:一是连续快速刷新页面 50 次,看是否出现内存泄漏导致死机;二是注册 4 个用户把会话表占满,再尝试登录,看是否正确返回"会话已满"而不是崩溃。这两项能过,基本说明内存管理和边界处理没问题。

答辩时老师最常问的几个问题提前准备好答案:为什么选 LwIP 不选其他协议栈、密码怎么存的安不安全、会话 ID 怎么生成的、Flash 擦写寿命怎么考虑。最后一个问题可以这样答:F407 内部 Flash 擦写寿命约 1 万次,按每天注册 10 个用户算能用好几年,而且实际项目中会加磨损均衡或者换外部 Flash,毕设场景下够用。

代码质量方面,至少保证每个函数都有入参校验,所有strcpy换成strncpy,所有数组访问带边界检查。答辩前用-Wall -Wextra编译一遍,把警告清干净,这个习惯能帮你提前发现不少隐藏 bug。

从那以后我每次做嵌入式网络项目,都会先把 ping 和内存参数这两关过了再写业务逻辑,不然调到最后都不知道是协议栈的问题还是自己代码的问题。希望这套方案能帮你把毕设的骨架搭起来,剩下的就是往里填细节了。

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

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

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

立即咨询