简介:基于STM32F103ZET与ESP8266的Web服务器工程包,面向嵌入式开发者和物联网初学者,解决通过UART连接ESP8266、配置Wi-Fi并实现HTTP网页服务器的实际问题。压缩包共85个文件,包含36个C源文件、34个H头文件、两个启动汇编文件、hex固件、uvproj工程文件及多个网页代码txt,工程含SYSTEM、CORE、USER等目录模块,便于定位系统与用户代码;另有HTTP详解、串口大数据量处理、登录与LED控制网页源码,以及基于MINI STM32F103C8T6的参考例程,可对照学习或直接移植。已有363人学习下载。资源围绕AT指令、AP/Station模式、HTTP请求解析与响应生成等关键点,说明如何在STM32端处理GET/POST请求,并搭配完整Keil工程便于编译烧录,文中文档还可辅助排查串口传输丢包等问题。适合作为STM32+Wi-Fi物联网入门实践,也方便快速移植到其他平台。
1. 资源包工程解读:STM32F103ZET6 用串口 AT 指令驱动 ESP8266 搭 Web 服务器
拿到STM32F103ZET_WIFI_web网页服务器.zip这个资源包时,我第一反应是去查它到底是 lwIP 协议栈方案,还是串口 AT 指令透传方案。打开文件结构扫了一遍,SYSTEM、CORE、USER、HARDWARE四个目录,加上wifi.c、webserve.c、usart1.c这几个关键源文件,结论很清楚:这是一个不跑 lwIP、完全靠 ESP8266 内置 TCP/IP 协议栈来完成 Web 服务的工程。STM32F103ZET6 的角色是主控,负责通过 USART1 向 ESP8266 发 AT 指令、解析返回状态、拼接 HTTP 报文;ESP8266 只干两件事——连 WiFi、收发 TCP 数据。这种方案的优势是大幅降低 Cortex-M3 端的资源和协议栈负担,适合做传感器数据上报、远程开关控制这类轻量级 Web 应用。资源包里还带了HTTP详细解释.txt、登录网页代码.txt、登录成功网页代码.txt以及一个基于 STM32F103C8T6 MINI 板的同款例程作为对比。这篇文章会把工程里的初始化时序、HTTP 请求解析、网页数据组织三块关键代码逐步拆开,并针对大数据量传输和粘包问题给出可落地的处理手段。
2. 工程骨架与单元职责:先搞清谁来维护 TCP 连接
2.1 为什么选 AT 指令方案而不是 lwIP
STM32F103ZET6 有 64KB RAM,其中可用堆空间约 40KB~50KB。跑 lwIP 的 RAW API 虽然也能实现 HTTP Server,但 TCP 报文缓冲、ARP 表、IP 分片重组这些都要在主控内存里划一块出来,稍微上点并发连接内存就吃紧。而 ESP8266 内部自带完整的 TCP/IP 协议栈,STM32 只需要通过 UART 发AT+CIPSERVER=1,80,ESP8266 就能维护 TCP 监听和链路状态。
这套方案的内存消耗集中在三块:接收缓冲区、发送缓冲区、HTTP 响应报文的拼装缓冲。工程里将 USART1 接收缓冲设为 1024 字节,发送缓冲设为 384 字节,网页 HTML 源文件以const uint8_t数组的方式存储在 Flash 中,不占 RAM。利用 ESP8266 的AT+CIPSEND=<length>指令先告知数据长度、再发送报文正文,ESP8266 会自动将 TCP 数据包封装发往客户端。
2.2 标准库工程文件结构与编译产物
工程采用正点原子标准库模板,文件组织如下:
| 目录/文件 | 职责 | 关键技术点 |
|---|---|---|
CORE/core_cm3.c | Cortex-M3 内核函数 | 提供SysTick、NVIC配置函数 |
CORE/startup_stm32f10x_hd.s | ZET6 启动文件 | 高密度 Flash 芯片选用 HD 版本 |
SYSTEM/delay/sys/usart | 延时、时钟、串口基础驱动 | delay_init、uart_init |
HARDWARE/wifi.c | ESP8266 指令封装 | ESP8266_Init、ESP8266_SendCmd |
HARDWARE/webserve.c | Web 核心处理 | HTTP 解析、响应生成 |
USER/main.c | 主循环调度 | 按键触发、状态灯控制 |
OBJ/Template.hex | 编译产物 | 可直接通过 FlyMcu 下载 |
2.3 USART1 初始化与中断接收
usart1.c里初始化了 115200 波特率的异步串口,使能了接收中断,并且在中断服务函数里做了环形缓冲或尾部指针累加。wifi.c依赖这个串口向 ESP8266 发送 AT 指令,同时接收 ESP8266 返回的OK、ERROR、CONNECT、+IPD等状态帧。
void USART1_IRQHandler(void) { u8 res; if(USART1->SR & (1<<5)) { // 接收非空标志 RXNE res = USART1->DR; // 读取数据,硬件自动清 RXNE if(USART1_RX_STA < 1024) { // 防止缓冲溢出,最大 1024 字节 USART1_RX_BUF[USART1_RX_STA++] = res; } } }代码逻辑上,接收中断里把 ESP8266 串口返回的字符逐个放进一个 1024 字节的全局数组USART1_RX_BUF中,USART1_RX_STA记录当前收了多少字节。问题在于它没有按行号做定界,HTTP 报文里可能连续到达数百字节,单纯靠RXNE逐个收会频繁进中断,导致main循环在 WiFi 数据密集时几乎被饿死。后文第 5 章会专门讲这个大数据量场景下的改进方案。
3. ESP8266 初始化序列与 AP/Station 模式切换
3.1 AT 指令握手流程
ESP8266_Init函数做的事本质上是三条串口 AT 指令联动:先通过AT+CWMODE=3把模块设为 AP+Station 双模式,再通过AT+CWJAP="SSID","password"让模块去连接路由器。如果路由器未就绪或者密码错误,模块会返回FAIL;成功则返回OK并且后续会周期性上报 IP 地址。这里有一个关键延时设计:AT+CWJAP最长可能需要 5~10 秒,因此初始化代码中在发送该指令后需要做一个软件超时轮询,而不是盲目等待 50ms。建议超时设成 10 秒,以 200ms 为步进轮询 AT 返回。
u8 ESP8266_Init(void) { u8 retry = 0; ESP8266_SendCmd("AT\r\n", "OK", 100); // 同步波特率并检查模块是否响应 delay_ms(500); ESP8266_SendCmd("AT+CWMODE=3\r\n", "OK", 200); // 双模式:AP + Station delay_ms(500); while (retry < 5) { if (ESP8266_SendCmd("AT+CWJAP=\"ssid\",\"password\"\r\n", "OK", 10000) == 0) { break; // 连接成功,退出重试 } retry++; delay_ms(3000); // 路由器拒绝或密码错误时,等待网络侧恢复 } ESP8266_SendCmd("AT+CIPMUX=1\r\n", "OK", 200); // 开启多连接,支持多个 TCP 客户端 ESP8266_SendCmd("AT+CIPSERVER=1,80\r\n", "OK", 200); // 启动 TCP Server,端口 80 return 0; }ESP8266_SendCmd的实现是:清空接收缓冲,发送 AT 指令字串,然后阻塞读取串口返回,在指定超时时间内查找目标关键字,找到返回ESP8266_SEND_OK。这里有个容易踩坑的地方:如果AT+CIPMUX=0(单连接)导致AT+CIPSERVER返回 ERROR,必须先把回显ATE0关掉再逐条排查,否则返回的OK和ERROR会和新指令的响应帧交叉错位。还有一种情况是模块固件版本旧,AT+CWJAP在密码错误时它会先打印WIFI DISCONNECT再返回FAIL,关键词搜索用的是"FAIL",此时帧里带WIFI DISCONNECT不影响匹配结果,但我建议在调试时把完整响应帧打印到调试串口,便于区分+CWJAP:1和+CWJAP:4这两种不同的错误码。
3.2 AP 模式兜底:当路由器不可用时
AT+CWMODE=3的另一个好处是模块同时开启自身热点,手机可以直接连接ESP8266开头的 AP,默认 IP 为192.168.4.1。这在现场无路由器的场景下非常关键:设备通电后,用户连不上家中的 WiFi 时,可以先连模块自身热点、在浏览器输入192.168.4.1访问配置页。wifi.c中应该预留一个AT+CWSAP配置函数,用于修改热点 SSID 和密钥。许多工程遗漏了这一步,导致出厂后只能通过串口线重新配置路由器账号,排查起来十分被动。
3.3 TCP Server 与多连接管理
AT+CIPMUX=1允许 ESP8266 同时维护 5 个 TCP 连接,AT+CIPSERVER=1,80启动 Server 后,模块会通过串口主动上报0,CONNECT、1,CONNECT这样的链路状态帧。STM32 端需要解析这条帧来判断新客户端接入。多连接模式下,AT+CIPSEND的格式变为AT+CIPSEND=<link_id>,<length>,发送响应到对应连接。资源包里的webserve.c也正是按照这个链路 ID 来组帧的,必须和 HTTP 请求解析绑在一起处理,不然多浏览器标签页同时访问时响应会乱串。
4. HTTP 请求解析与响应生成:搞清楚+IPD后面的每一段
4.1 从串口裸数据到 HTTP 请求头
当 ESP8266 收到浏览器的 HTTP 请求时,如果启用AT+CIPDINFO=1,模块串口输出的帧格式如下:
+IPD,0,415:GET / HTTP/1.1 Host: 192.168.1.100 Connection: keep-alive ...这里的0是 link_id,415是本次收到的数据字节数,冒号后的内容为请求报文。webserve.c中的WebServer_Handler函数接收这一帧数据,并从GET、POST后提取 URL 路径。
void WebServer_Handler(u8 *frame, u16 len) { u8 *url_start, *url_end; u16 url_len; if (strncmp((const char *)frame, "+IPD", 4) != 0) { return; // 非数据帧,忽略 } // 帧解析:http_request 为 +IPD,linkid,len: 之后的数据 u8 *http_request = strchr((const char *)frame, ':') + 1; // 从 " " 中提取 URL,形如 / 或 /led_on url_start = strchr((const char *)http_request, ' '); url_end = strchr((const char *)(url_start + 1), ' '); url_len = url_end - url_start - 1; char url[32] = {0}; strncpy(url, (const char *)(url_start + 1), url_len); // 根据 URL 分发到不同控制动作 if (strcmp(url, "/") == 0) { SendHTML_MainPage(link_id); // 返回主页面 } else if (strcmp(url, "/led_on") == 0) { GPIO_SetBits(GPIOA, GPIO_Pin_8); SendHTML_ControlResult(link_id, "LED ON"); } else if (strcmp(url, "/led_off") == 0) { GPIO_ResetBits(GPIOA, GPIO_Pin_8); SendHTML_ControlResult(link_id, "LED OFF"); } }这个函数没有用sscanf,而是手动遍历字符串。因为sscanf依赖 stdio 库,一方面增加了代码体积,另一方面在处理不定长 URL 时容易产生不可控的栈溢出风险。手动取指针的写法更安全,但在做strncpy时要注意 URL 长度边界,url缓冲区 32 字节而实际请求中 URL 可能包含后续参数,比如/led_on?name=test,所以url_end定位应该是?或则空格,取较小值。
4.2 HTML 页面存储与动态数据填充
资源包里的webcode.h把用浏览器写好的 HTML 登录页和控制页源码转成了 C 语言十六进制数组,这是工程化一个通用做法:网页文件不烧录到外部 Flash,而是编译进固件内部,减少运行时 IO 读取。数组定义形式如下:
const unsigned char html_mainpage[] = { 0x3C, 0x21, 0x44, 0x4F, 0x43, 0x54, 0x59, 0x50, // <!DOCTYPE 0x45, 0x20, 0x68, 0x74, 0x6D, 0x6C, 0x3E, 0x0A, // E html>\n // 后续字节省略 };发送时直接把这个数组送入 AT 指令数据区,交给 ESP8266 发出。这里有一个工程细节:AT+CIPSEND=0,<len>发送完数据后,模块会返回SEND OK,此时必须有明确握手再去发下一个请求,否则连续高频刷新页面时会出现busy s...错误帧。同时,单片机端发送缓冲只有 384 字节,而 HTML 源文件往往超过 1KB,因此webserve.c中的发送逻辑要分片:每次从数组中取 256 字节,调用AT+CIPSEND分段发完,再用下一个连接发剩余部分。
4.3 204 状态与保活机制
HTTP 1.1 默认开启 Keep-Alive,浏览器不会因为响应结束就断开 TCP。这带来一个隐患:客户端不关闭连接,ESP8266 的 TCP 连接表会被占满,后续新设备将无法接入。处理办法有两种:一是在响应头中带Connection: close,通知浏览器响应完成后关闭 TCP 链路,代码实现为在SendHTML_MainPage的最后拼接字符串Connection: close\r\n。二是定期从串口读取0,CLOSED状态帧并清除对应的链路记录,实现连接回收。资源包里的登录成功网页代码结构同时兼容这两种方式,推荐第一种,简单有效。
5. 串口大数据量传输的过载问题:拆包、定界与重传
5.1 为什么RXNE逐字节接收会丢包
ESP8266 模块在收到浏览器请求后会一次性把打包好的 HTTP 报文通过 UART 发出。115200 波特率下,每毫秒约 11.5 字节,一个 400 字节的请求需要约 35ms 传完。如果 STM32 主频 72MHz 且主循环中刚好在执行 Flash 擦写或delay_ms(500),串口中断依然能进,但接收缓冲写入效率远比不过模块发送效率,导致 USART 溢出标志ORE置位后,DMA 停摆,数据直接丢弃。典型现象是浏览器首次打开正常,刷新后却是白屏,报ERR_INCOMPLETE_CHUNKED_ENCODING。
资源包里专门放了一个串口大数据量时的解决办法.txt,这是本工程最有参考价值的一部分,它给出的思路是:改用 USART 空闲中断配合 DMA 接收,同时把波特率提升到 460800 或 921600。usart1.c修改后如下:
void USART1_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (u32)USART1_RX_BUF; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = 1024; // 环形上限 DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环采集 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_Init(&DMA_InitStructure); }开启空闲中断后,每次 ESP8266 发送完一包数据,串口总线空闲,硬件置位IDLE标志,此时从 DMA 当前计数器的值就能算出实际收了多少字节。逻辑上先关 DMA、读剩余计数、再重新填满 DMA,接着处理这包完整数据。这个方案把 CPU 从每字节中断中释放出来,大数据量下依然能保持稳定接收。工程原来的逐字节中断方式建议只用于 AT 指令交互阶段,比如AT+CWMODE这类短返回;进入 HTTP 数据收发阶段后切换到 DMA 模式。
5.2 粘包处理:一条 TCP 流包含多个 HTTP 请求
DMA 空闲中断对于单个数据包有效,但无法避免 ESP8266 将多个请求连续上报到一个缓冲区。举个例子:浏览器加载首页时会同时发/请求和/favicon.ico请求,二者会在 ESP8266 内部被打包成一次 UART 传输,最终进入 STM32 缓冲区后变成两段独立的GET报文首尾相接。解析时如果只处理第一个\r\n\r\n后剩余的字节,不做二次分帧,下一个请求就会被吞掉,TCP 层表现为丢数据。实践中需要把缓冲区内每个+IPD块单独拆出,并按len字段跳过该块,继续查找下一个+IPD:
u16 frame_start = 0; while (frame_start < recv_cnt) { u8 *ipd_flag = strstr((const char *)(rx_buf + frame_start), "+IPD"); if (ipd_flag == NULL) break; u8 *len_pos = strchr((const char *)ipd_flag, ',') + 1; len_pos = strchr((const char *)len_pos, ',') + 1; u16 pkt_len = atoi((const char *)len_pos); // 实际 HTTP 数据起始位置 u8 *http_data = strchr((const char *)len_pos, ':') + 1; WebServer_Handler(http_data, pkt_len); // 跳到下一帧:当前帧头 + 数据长度 + 结尾换行边界 frame_start = (http_data - rx_buf) + pkt_len + 2; }这个while循环能在一个接收周期内处理完所有已到达的 HTTP 请求。要注意atoi函数解析len字段到遇到:为止,数值位不能超过 5 位,因为 TCP 单包上限就是 1460 字节,超过则需要横向分片。另外一个实际调试技巧:粘包发生时,先把接收缓冲原样打印到调试串口,观察相邻两个+IPD之间有没有\r\n分隔符。部分 ESP8266 AT 固件在数据帧后面带\r\n,而旧固件没有,这会直接影响frame_start偏移量的设置,所以这个偏移值必须根据实际固件版本做适配。
5.3 看门狗喂狗位置与阻塞风险
如果在循环里调用了delay_ms(3000)等待 ESP8266 响应,独立看门狗 IWDG 极有可能超时复位。解决方法是把喂狗函数放在每个 AT 指令发送的轮询等待循环中,而不是主循环末尾。更稳妥的方式是把ESP8266_SendCmd改造成分时状态机,每次只等一个固定时隙,没有返回则退出本轮,让主循环有机会喂狗和检查超时计数。这个设计在正式产品中非常关键,否则设备很容易在 WiFi 信号差的环境下陷入反复重启。
6. 网页资源转 C 数组实现自动化:脚本替换与手动兜底
6.1 开发时如何快速更新网页
每次在 Dreamweaver 或 VS Code 里改完 HTML,如果都要手工转换成 C 数组,效率非常低,且极容易因为漏写换行符导致页面在浏览器里错版。常见做法是写一个 Python 小工具,读取 HTML 文件并输出webcode.h:
with open("index.html", "rb") as f: data = f.read() lines = ["const unsigned char html_mainpage[] = {"] for i, byte in enumerate(data): if i % 12 == 0: lines.append(" ") lines.append("0x{:02X}, ".format(byte)) if (i + 1) % 12 == 0: lines.append("\r\n") lines.append("\r\n};") with open("webcode.h", "w") as f: f.write("".join(lines))脚本输出后,在 Keil 里重新编译,把新固件烧进 STM32,再浏览器访问设备 IP 即可验证效果。还有一个更便捷的变通:如果你的工程支持外部 SPI Flash 存储,可以把webcode.h生成一个 bin 文件,直接在串口烧录工具里用 XMODEM 下载,省去反复交叉编译的步骤。资源包里的LED关的网页.txt内容结构比较规整,可以直接用这个脚本转换。
6.2 验证 Web 服务器是否正常工作的三个层级
这里提供一个分层验证方法,排错时能快速缩小范围:第一层用 PC 串口助手直接连接 ESP8266 模块(绕过 STM32),手动发AT+CIPSERVER=1,80,然后 PC 浏览器访问设备 IP 能出现控制页面,则问题定位在 STM32 解析端;第二层在 STM32 工程里加串口打印,用调试串口输出+IPD帧的原始内容和解析后的url变量,核对是否与你访问的地址一致;第三层在 PC 上抓包,过滤tcp.port == 80,看 STM32 是否有回包、回包里 TCP 序号是否连续。这套方法里最快捷的往往是第二层:串口打印原始帧,如果原始帧内容正常而 LED 没动作,那就是webserve.c里的字符串比较分支出了问题。
本文还有配套的精品资源,点击获取