
简介这份资源面向嵌入式开发初学者与物联网方向学习者围绕STM32F407微控制器搭配LAN8720A以太网PHY芯片构建一个可运行于开发板上的嵌入式Web服务器并实现简单的登录与注册功能。项目串联了TCP/IP协议栈如lwIP、HTTP请求解析、HTML表单设计、用户数据存储与密码安全等知识点是理解嵌入式网络编程的完整实践案例。压缩包共588个文件约16.37MB以c与h源码为主辅以o、d、crf等编译中间文件以及htm、html、shtml网页文件、readme说明、hex固件与uvprojx工程文件覆盖从源码到可烧录程序的完整链路。目前已有881人学习下载。读者可从中获得可直接编译运行的工程结构、HTTP服务器框架与前端页面实现思路并借助调试与抓包工具排查网络通信问题适合作为课程设计或物联网入门的参考范例。1. 从一块 F407 开发板说起为什么要在 MCU 上跑登录注册手头有一块 STM32F407 开发板网口用的是 LAN8720A 这颗 PHY很多人第一次把网线插上去、ping 通了之后下一步就想干点像样的事——比如让浏览器打开一个页面输入账号密码能登录进去。这个需求听起来简单但真落到 STM32 上你会发现它牵扯到 TCP 服务、HTTP 报文解析、表单数据处理、会话状态维持这一整条链路。这份资源就是围绕STM32F407 LAN8720A 嵌入式 WebServer 登录注册这条主线展开的适合已经能把网络跑通、想往上叠一层业务逻辑的嵌入式开发者。它解决的不是怎么点亮 LED那种入门问题而是怎么在没有操作系统的裸机或轻量 RTOS 环境下让一个 168MHz 的 MCU 撑起一个能交互的 Web 服务。如果你正在做设备配置页、远程参数下发、或者单纯想给毕设加个像样的交互界面这套东西能直接抄。2. 硬件链路与协议栈选型LAN8720A 到底怎么接、LwIP 怎么裁2.1 RMII 接口的引脚映射与时钟坑LAN8720A 和 STM32F407 之间走的是 RMII 接口不是 MII。RMII 把数据线从 16 根压到 7 根代价是参考时钟必须统一。F407 这边通常用 PA1 输出 50MHz 给 PHY或者反过来由 PHY 的 50MHz 时钟输出给 MCU。我一般会选前者因为 F407 的 MCO 引脚配置起来更可控。具体引脚映射常见做法是信号STM32F407 引脚LAN8720A 引脚说明REF_CLKPA1XI50MHz 参考时钟MDIOPA2MDIO管理数据MDCPA3MDC管理时钟CRS_DVPA7CRS_DV载波侦听RXD0PC4RXD0接收数据0RXD1PC5RXD1接收数据1TX_ENPB11TX_EN发送使能TXD0PB12TXD0发送数据0TXD1PB13TXD1发送数据1这里最容易翻车的是 PA1 的时钟输出配置。很多人忘了开 GPIOA 的复用功能时钟或者 MCO 的分频系数设错结果 PHY 一直处于复位状态MDIO 读出来全是 0xFFFF。正确的做法是在HAL_RCC_MCOConfig里把分频设成 1源选 HSE然后确认 PHY 的复位引脚如果有已经拉高。2.2 LwIP 的裁剪策略裸机还是 RTOSLwIP 在 F407 上有两种跑法裸机模式下用NO_SYS1所有协议处理都在主循环里轮询或者挂到 FreeRTOS 下用NO_SYS0给 TCP/IP 线程单独开一个任务。登录注册这种场景我建议直接上 FreeRTOS因为 HTTP 请求处理里会有字符串解析和状态判断裸机轮询容易把响应时间拖到几百毫秒浏览器那边体验很差。裁剪的时候重点关掉这几个宏LWIP_IPV6、LWIP_DHCP如果固定 IP、LWIP_SNMP、LWIP_MDNS。内存池MEM_SIZE给到 8KB 左右PBUF_POOL_SIZE给 8 个就够了。TCP 控制块数量MEMP_NUM_TCP_PCB设 4因为登录注册不会有很多并发连接。// lwipopts.h 关键裁剪项 #define NO_SYS 0 #define LWIP_SOCKET 0 #define LWIP_NETCONN 0 #define MEM_SIZE (8 * 1024) #define PBUF_POOL_SIZE 8 #define MEMP_NUM_TCP_PCB 4 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (4 * TCP_MSS)NO_SYS0表示走 RTOS 模式后面sys_thread_new才能用。LWIP_SOCKET和LWIP_NETCONN关掉是因为我们直接用 raw API省一层封装也省内存。TCP_WND给 4 个 MSS 是为了让浏览器一次能发完 POST 表单不然登录请求会被拆成多个包解析起来更麻烦。2.3 PHY 初始化与链路检测LAN8720A 上电后需要软复位然后等自协商完成。常见做法是读 BCR 寄存器的 bit15 确认复位结束再读 BSR 寄存器的 bit2 确认链路 up。这一步不做的话后面 TCP 连接会随机失败而且现象很玄学——有时候能连上有时候连不上。uint32_t phy_addr 0; LAN8720_Init(phy_addr); // 内部会做软复位和自协商等待 while (LAN8720_GetLinkState(phy_addr) ! LINK_UP) { vTaskDelay(pdMS_TO_TICKS(100)); }LAN8720_Init里一般会先写 BCR 的 bit15 触发复位延时 100ms 后读 BSR 的 bit2。如果 5 秒内没 up就要检查网线、变压器和 PA1 时钟。我遇到过变压器中心抽头没接对链路死活起不来的情况换了个带中心抽头的 RJ45 就好了。3. HTTP 服务端实现从 raw API 到表单解析3.1 用 raw API 搭一个最小 HTTP 服务器LwIP 的 raw API 是基于回调的tcp_new创建控制块tcp_bind绑 80 端口tcp_listen进入监听然后tcp_accept注册连接回调。每个连接上来之后在recv回调里收数据、拼 HTTP 响应。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 里的数据拷到本地 buffer char req[512] {0}; pbuf_copy_partial(p, req, p-tot_len 511 ? 511 : p-tot_len, 0); tcp_recved(tpcb, p-tot_len); pbuf_free(p); // 解析请求行和表单 handle_http_request(tpcb, req); return ERR_OK; }pbuf_copy_partial把链式 pbuf 拉平到连续内存方便后面用strstr找\r\n\r\n。tcp_recved是必须调的告诉协议栈这块内存可以复用了不调的话窗口会越来越小最后卡死。handle_http_request里根据GET或POST分派GET 返回登录页 HTMLPOST 解析usernamexxxpasswordyyy。3.2 登录注册的页面与表单处理登录页就是一个静态 HTML 字符串用tcp_write发回去。注意tcp_write之后要调tcp_output不然数据会攒在发送缓冲里等下一个 ACK 才发浏览器那边会转圈。const char *login_page HTTP/1.1 200 OK\r\n Content-Type: text/html\r\n Connection: close\r\n\r\n htmlbody form methodPOST action/login User:input nameubr Pass:input namep typepasswordbr input typesubmit /form/body/html; tcp_write(tpcb, login_page, strlen(login_page), TCP_WRITE_FLAG_COPY); tcp_output(tpcb);TCP_WRITE_FLAG_COPY表示让协议栈自己拷一份这样login_page这个常量字符串可以放在 flash 里不占 RAM。Connection: close是告诉浏览器这次响应完就关连接省得维护 keep-alive 状态。POST 请求的 body 在\r\n\r\n之后解析的时候先strstr找这个分隔符然后对后面的字符串按和切分。注意 URL 编码浏览器会把特殊字符转成%XX密码里如果有就会变成%40不处理的话校验永远不过。char *body strstr(req, \r\n\r\n); if (body) { body 4; char *u strstr(body, u); char *p strstr(body, p); // 提取值并做 URL 解码 url_decode(u 2, username); url_decode(p 2, password); }url_decode遇到%就取后面两位十六进制转成字符遇到转空格。这个函数不长但漏了的话用户输个带特殊字符的密码就登不进去排查起来很费劲。3.3 账号存储Flash 模拟 EEPROM 还是外部芯片F407 内部 flash 擦写次数有限登录注册的账号密码如果频繁改不建议直接写内部 flash。常见做法是用外部 I2C EEPROM比如 AT24C02或者 SPI flash 划一个扇区出来。如果只是演示直接放 RAM 里也行但掉电就丢。我一般会在外部 EEPROM 里划两个区域一个存账号表一个存当前会话 token。账号表用固定长度结构体比如 32 字节用户名 32 字节密码哈希支持 8 个账号。密码不要存明文用简单的哈希比如 DJB2过一遍虽然不安全但比明文强而且 MCU 上算得快。typedef struct { char username[32]; uint32_t pass_hash; uint8_t used; } account_t; uint32_t djb2_hash(const char *str) { uint32_t hash 5381; while (*str) { hash ((hash 5) hash) *str; } return hash; }djb2_hash每字节做一次移位和加法F407 上跑一次不到 1 微秒。存的时候只存 hash登录时把用户输入 hash 一下再比对。used字段标记这个槽位有没有被占用注册时找第一个used0的写进去。4. 避坑与排查那些让你怀疑网线的瞬间4.1 现象浏览器一直转圈ping 能通但 HTTP 没响应原因通常是tcp_write之后没调tcp_output或者tcp_recved没调导致接收窗口关死。还有一种可能是 HTTP 响应头里Content-Length和实际 body 长度对不上浏览器在等剩余数据。解决在tcp_write后强制tcp_output确认tcp_recved传的是p-tot_len响应头里加上正确的Content-Length或者用Connection: close让浏览器读到 EOF 就停。4.2 现象POST 请求 body 收不全密码少几位原因TCP 是流式的一个 POST 请求可能分多个 pbuf 到达。只在第一个 pbuf 里找\r\n\r\n后面的内容如果 body 被拆到第二个包就会截断。解决维护一个连接级的接收缓冲把每次recv的数据追加进去直到收到完整的Content-Length或者检测到\r\n\r\n后再等 body 长度满足。简单做法是设一个 512 字节的静态 buffer每次追加解析完再清空。4.3 现象PHY 链路时断时续MDIO 读出来全是 0xFFFF原因PA1 的 50MHz 时钟没输出或者输出幅度不够。LAN8720A 对时钟质量敏感如果走线太长或者没做阻抗匹配自协商会失败。解决用示波器量 PA1确认有 50MHz 方波峰峰值 3.3V。如果幅度不够检查 GPIO 速度等级是否设成GPIO_SPEED_FREQ_VERY_HIGH。走线尽量短远离电源和晶振。4.4 现象登录成功一次后第二次就失败原因会话 token 没清或者 TCP 控制块没释放。tcp_close之后要等tcp_err回调或者tcp_poll确认关闭完成才能复用同一个端口。解决每次响应完主动tcp_close在tcp_err里把连接相关的状态清零。如果用了全局的登录状态变量记得在连接关闭时复位。4.5 现象编译报错undefined reference to tcp_write原因LwIP 的 raw API 没开或者头文件包含顺序不对。lwipopts.h里NO_SYS0时raw API 默认是开的但如果LWIP_TCP0就会全没了。解决确认LWIP_TCP1LWIP_RAW1并且在lwipopts.h里不要重定义tcp_write相关的宏。包含顺序上先lwip/opt.h再lwip/tcp.h。5. 进阶技巧把登录态维持住以及一个验证响应的小脚本5.1 用 Cookie 维持登录态HTTP 是无状态的登录成功后如果每个请求都要重新验密码体验很差。常见做法是登录成功时生成一个随机 token存在 EEPROM 里同时通过Set-Cookie头发给浏览器。后续请求浏览器会自动带上Cookie: tokenxxx服务端比对 token 就知道是谁。char cookie_hdr[64]; snprintf(cookie_hdr, sizeof(cookie_hdr), Set-Cookie: token%08X; Path/\r\n, token); tcp_write(tpcb, cookie_hdr, strlen(cookie_hdr), TCP_WRITE_FLAG_COPY);token可以用rand()配合HAL_GetTick()生成虽然随机性一般但演示够用。Path/表示整个站点都带这个 cookie。服务端在解析请求头时找Cookie:字段提取 token 后和 EEPROM 里存的比对。5.2 用 Python 脚本验证响应完整性调试的时候浏览器缓存和自动重定向会干扰判断我一般用 Python 的socket直接发原始 HTTP 请求看返回的字节数对不对。import socket def raw_http(host, port, request): s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(3) s.connect((host, port)) s.sendall(request.encode()) resp b while True: try: chunk s.recv(1024) if not chunk: break resp chunk except socket.timeout: break s.close() return resp req ( POST /login HTTP/1.1\r\n Host: 192.168.1.100\r\n Content-Type: application/x-www-form-urlencoded\r\n Content-Length: 15\r\n Connection: close\r\n\r\n uadminp12345 ) resp raw_http(192.168.1.100, 80, req) print(resp.decode(errorsignore))settimeout(3)防止板子没响应时脚本卡死。Connection: close让服务端发完就关recv返回空字节时循环结束。打印出来的响应里重点看状态码是不是 200以及Content-Length和实际 body 长度是否一致。如果返回 400多半是Content-Length算错了——uadminp12345是 15 个字符别多算或少算。5.3 一个容易忽略的细节HTML 里的中文如果登录页要显示中文记得在响应头里加Content-Type: text/html; charsetutf-8并且 HTML 文件本身存成 UTF-8。F407 的 flash 够大的话直接把中文字符串常量放进去就行。但要注意strlen对 UTF-8 中文是按字节算的Content-Length也要按字节数填不能按字符数。从那以后我每次调嵌入式 WebServer都先用这个 Python 脚本把登录、注册、错误密码三条路径各跑一遍确认字节数和状态码都对再开浏览器。希望帮到你。本文还有配套的精品资源点击获取