☰
ESP8266 HTTP请求实战:从WiFiClient到稳定上报
2026/11/3 23:04:50 网站建设 项目流程

手里有块 ESP8266,想让它隔几分钟往自己那台服务器上扔一条温湿度数据,这个需求听起来简单到不值得专门写一篇东西——直到你亲手做过一遍。我见过太多人卡在同一个位置:串口窗口里 WiFi 明明连着,WiFiClient对象也建出来了,client.print()每一行都返回了字节数,一切看起来都"成功",可服务器那边翻遍访问日志都找不到一条记录。这里面的差距,就在于很多人把"我把字节交给了库"和"这些字节真的变成了一个合法的 HTTP 请求发到了对端"当成了同一件事。

这篇东西就是围绕ESP8266 发起 HTTP 请求访问远程服务器这条主线展开的。我会从报文本身讲起,因为不管你用裸WiFiClient手拼请求,还是用封装好的HTTPClient,最终在网线上跑的都是同一堆 ASCII 字符;然后给你能直接抄的最小可运行代码,再往下是 POST 上报、HTTPS 的取舍、断网重连和内存这几个真正决定项目能不能长期跑下去的点。适合刚把开发板点亮的入门者,也适合已经把 Demo 跑通、但设备挂上去三天就失联的人。

1. 先搞明白 ESP8266 在 HTTP 这件事里扮演什么角色

1.1 它不是浏览器,它是自己写作业的学生

浏览器会替你干很多活:拼请求头、管连接池、跟随 301、自动解压 gzip、处理 Cookie。ESP8266 上没有这些,你要么自己写,要么用库。所以第一件事是建立正确的心智模型:ESP8266 只是一个能建立 TCP 连接的串口设备,HTTP 协议对它来说就是一段有格式约定的纯文本,你要自己把这段文本按字节顺序推给 socket。

这个认知非常关键,因为它决定了你排错的方向。当请求失败时,问题一定落在这几个桶里:TCP 根本没连上(DNS、端口、防火墙)、连上了但发出去的字节不是合法 HTTP 报文(换行符错了、少 Host 头、Content-Length 算错)、发了但没读响应就去关连接(服务端还没处理完你已经client.stop())、或者读响应时死等导致看门狗复位。

把它当成"自己在写作业",你就不会期待库帮你兜底。ESP8266 的可用堆内存大概在 40KB 上下浮动,这个数字决定了你后面所有的设计取舍:不能用大缓冲、不能开太多并发、HTTPS 要精打细算。

1.2 一条 HTTP 请求报文的最小完整形态

手拼请求是我最推荐新手先做的练习,因为它把黑盒打开了。一条最小的 HTTP/1.1 GET 请求长这样:

GET /v1/report?temp=25.3&hum=61 HTTP/1.1\r\n Host: api.example.com\r\n User-Agent: esp8266/1.0\r\n Connection: close\r\n \r\n

四行头部加一个空行,就这么点东西。几个容易翻车的细节我逐个说。

第一,行尾必须是\r\n,两个字符。只写\n的话,很多服务端(尤其是跑在 Nginx 后面的)会返回 400 Bad Request,甚至直接不响应,因为严格的报文解析器等不到 CRLF 就认为报文没结束。我在串口里见过最典型的错误就是client.println("GET / HTTP/1.1")——println补的是\n,少了一个\r。

第二,HTTP/1.1 必须带Host头。虚拟主机托管是常态,一台服务器上跑着几十个站点,服务端靠 Host 字段决定把请求交给哪个应用。少了它,标准行为是返回 400。用 HTTP/1.0 可以不带 Host,但 1.0 默认不支持长连接、有些服务端也不愿意搭理,别为了省几个字节去踩这个。

第三,Connection: close几乎必写。HTTP/1.1 默认是 keep-alive,服务端在你读完响应后不会主动断开,而 ESP8266 端如果一直等"连接关闭"这个信号来判断响应结束,就会一直等到超时。写上close,服务端处理完就挥手告别,你的读取循环自然就退出了。

第四,空行是报文结束标志。如果你用 POST 带 body,空行之后紧接着就是 body 的原始字节,中间不能再有空行。这一点我在后面 POST 那节会展开。

1.3 三种开发栈,选哪个取决于你要不要"可控"

实际能跑在 ESP8266 上的技术路线大概三条,选型直接决定了你后面写代码的姿势。

路线上手成本内存占用适合场景主要代价
Arduino core for ESP8266低中等快速验证、小型上报设备封装层较厚,出问题要看库源码
ESP-IDF(RTOS SDK)高可裁剪需要精细控制、多任务编译链和 API 学习曲线陡
官方 Non-OS SDK中最低极致省资源、老项目维护回调式编程,调试不直观
MicroPython很低高教学、脚本化试验堆紧张,稳定性弱于 C++

我自己的默认选择是Arduino core。理由很实在:它同时提供了WiFiClient这种接近裸 socket 的接口和HTTPClient这种半封装接口,你可以先用后者跑通,遇到怪问题再降到前者做逐字节排查,转移成本几乎为零。选它不是因为性能最好,而是因为它允许你在"抽象"和"裸奔"之间自由切换,这对排错价值巨大。

至于 MicroPython,我的建议是只在教学演示和一次性脚本里用。它跑 HTTP 请求时堆内存压力明显,长时间运行后碎片化导致的MemoryError会让你怀疑人生,而且出问题时几乎没有可观测手段。

2. 从点亮板子到服务器日志里出现第一条记录

2.1 环境配置里那几个人人都会忽略的选项

Arduino IDE 装 ESP8266 支持包的流程网上到处都是,我只说几个真正影响后续调试的选项。开发板类型选 "Generic ESP8266 Module" 时,Flash Size 必须和你的模组实际容量一致(常见 4MB),Flash Mode 选 DIO,Upload Speed 先设 115200。上传速度设太高(比如 921600)在部分 USB 转串口芯片上会握手失败,表现是Connecting........_____一直循环,很多人误以为是板子坏了。

串口监视器的波特率要和代码里Serial.begin()一致,这个不用说,但有个坑:部分模组启动时会先以 74880 波特率打印一段启动日志,如果你看到一堆乱码再变成正常输出,那是正常的,不是接线问题。

还有一个容易被忽视的设置:CPU Frequency。默认 80MHz,改成 160MHz 会让 TLS 握手快不少,代价是功耗上升大概 30%。如果你的设备是插着电源的,别犹豫,直接 160MHz。

2.2 WiFi 连接要用状态机,别用 delay

新手代码里最常见的一段是这样的:

WiFi.begin(SSID, PASS); delay(10000); // 然后直接假设已经连上了

这段代码在实验室里能跑,在现场必炸。连接时间受信号强度、路由器 DHCP 响应、信道拥塞影响,可能 800ms 就连上,也可能 15 秒还在挣扎。用固定delay就是拿运气写程序。

正确做法是把连接过程当成一个状态来轮询,同时给一个上限:

#include <ESP8266WiFi.h> const char* SSID = "your-ssid"; const char* PASS = "your-pass"; void setup() { Serial.begin(115200); delay(100); // 等串口稳定,不然前几行日志会丢 WiFi.persistent(false); // 不把凭据写进 Flash,省擦写寿命 WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); // 掉线后自动重连 WiFi.begin(SSID, PASS); unsigned long t0 = millis(); while (WiFi.status() != WL_CONNECTED) { if (millis() - t0 > 20000) { // 20 秒还连不上,重启重来 Serial.println("wifi timeout, restart"); ESP.restart(); } delay(200); // 关键:让出 CPU 给协议栈 Serial.print("."); } Serial.println(); Serial.print("got ip: "); Serial.println(WiFi.localIP()); Serial.print("free heap: "); Serial.println(ESP.getFreeHeap()); }

这里有两个我踩过坑的点。WiFi.persistent(false)值得加:默认情况下 ESP8266 会把 SSID 和密码写进 Flash 的 SDK 配置区,每次WiFi.begin都可能触发一次擦写,虽然现代模组的 Flash 寿命够用,但如果你在调试期频繁重启,没必要白白消耗。

更关键的是那个delay(200)。ESP8266 的 WiFi 协议栈运行在后台,需要 CPU 时间片才能推进握手和 DHCP。如果你写一个纯while空转等待,中间不给任何延时,协议栈可能饿死,表现就是永远连不上。这是个非常隐蔽的坑,因为代码逻辑看上去完全正确。

2.3 最小可运行的 GET 请求

连上网之后,第一件事是用裸WiFiClient发一次请求,把响应的每一行打印出来。别急着上HTTPClient,先看清楚对端到底回了什么。

WiFiClient client; void doGet() { if (!client.connect("api.example.com", 80)) { Serial.println("tcp connect failed"); return; } client.print("GET /v1/ping HTTP/1.1\r\n"); client.print("Host: api.example.com\r\n"); client.print("User-Agent: esp8266/1.0\r\n"); client.print("Connection: close\r\n"); client.print("\r\n"); // 空行,报文结束 unsigned long last = millis(); while (millis() - last < 5000) { // 整体超时 5 秒 while (client.available()) { char c = client.read(); Serial.write(c); last = millis(); // 有数据就刷新超时计时 } if (!client.connected()) break; delay(1); // 同样要喂协议栈 } client.stop(); }

这段代码里有三处设计是有意为之。逐个字符读而不是readStringUntil('\n'):后者会为每一行分配一个String,响应头多的时候堆分配次数上去,碎片就来了,而且遇到分块传输(chunked)时行长度不可预测,容易触发分配失败。超时用"最后一次收到数据的时间"而不是"开始时间":服务端可能先回响应头,隔一会儿才回 body,用固定起点计时会把正常的长响应误判成超时,用滑动窗口才符合实际情况。读完之后显式client.stop():不主动关,socket 会一直占着,ESP8266 同时能开的 socket 数量很有限(个位数),几次泄漏之后就再也连不上了。

跑通之后你会在串口看到类似这样的东西:

HTTP/1.1 200 OK Content-Type: application/json Content-Length: 27 Connection: close {"status":"ok","code":0}

看到200 OK的那一刻,整条链路才算真正打通。注意顺序:状态行在第一行,紧接着是响应头,遇到一个空行之后才是 body。你后面写解析逻辑时,判断"该不该读 body"就看这个空行。

2.4 响应读取的三个典型反模式

第一个反模式是while (client.connected()) { String line = client.readStringUntil('\n'); ... }。看起来优雅,问题在于client.connected()在服务端已经发完数据但还没发 FIN 的那一小段时间里仍然返回 true,而readStringUntil在没有数据时会阻塞到默认超时(通常 1000ms),整个循环可能空转好几秒。如果这段代码在loop()里,主循环就被拖慢了。

第二个反模式是不处理响应直接关连接。我见过这样的代码:发完 POST 立刻client.stop()。请求确实发出去了,但服务端可能还在处理,TCP 缓冲里还没发完的数据被 RST 掉,结果服务端收到半个报文,返回 400 或者直接丢弃。正确做法是至少把响应状态行读掉,确认对端接收了再关。

第三个反模式是把整个响应readString()下来再用indexOf找字段。响应大的时候(比如返回一个几百字节的 JSON),一次分配加一次拷贝,堆上就出现一个几百字节的块;请求做十次,块大小不一,碎片就成型了。这是 ESP8266 长期运行崩溃的头号原因之一,后面第 4 节会专门讲。

3. POST 上报:从拼 JSON 到让服务端认账

3.1 Content-Length 必须数"字节"而不是"字符"

GET 把参数放 URL 里,数据量小无所谓。一旦你要上报结构化的数据(温度、湿度、时间戳、设备号),就得用 POST 加 body。报文的骨架变成这样:

POST /v1/report HTTP/1.1\r\n Host: api.example.com\r\n Content-Type: application/json\r\n Content-Length: 43\r\n Connection: close\r\n \r\n {"temp":25.3,"hum":61,"dev":"node-a"}

Content-Length是这个 body 的字节数。这里有个绝大多数人第一次都会踩的坑:如果你的 JSON 里有中文,比如{"note":"室内"},String.length()在 Arduino core 里返回的是字节数(UTF-8 下"室内"是 6 个字节),这时候恰好是对的。但如果你用String的某些方法或者自己按字符数算,就会少算,服务端读不够长度就判定报文不完整,挂在那里等后续字节,直到超时。

我的做法是不依赖字符串长度,先准备好 body,再用它自己的长度:

String body = "{\"temp\":25.3,\"hum\":61}";

更稳的做法是彻底避开String:

char body[96]; int n = snprintf(body, sizeof(body), "{\"temp\":%.1f,\"hum\":%d,\"dev\":\"%s\"}", temp, hum, devId); if (n <= 0 || n >= (int)sizeof(body)) { Serial.println("body overflow"); return; }

snprintf的返回值是"本来想写多少字节",所以用n >= sizeof(body)判断截断。这个检查一定要加,截断的 JSON 比不发更糟——服务端可能解析出半截数据还返回 200。

3.2 URL 编码与 JSON 转义是两个不同的坑

URL 里的查询参数要做百分号编码:空格变%20,&变%26,中文要按 UTF-8 的每个字节转成%XX。如果你拼接的是?name=办公室A,路由器到服务端中间任何一层严格解析都可能给你 400。

JSON 里则是另一套规则:双引号要转义成\",反斜杠要转义成\\,控制字符要转成\u00XX。注意 JSON 不要求转义中文,UTF-8 直接写就行,服务端按 UTF-8 解就行。但如果你动态拼字符串,用户输入里带个引号,整个 JSON 就废了——这种问题在设备号、备注名这类"人填过的字段"上特别常见。

我一般的处理原则是:URL 参数只放数字和短 ASCII 标识符,不塞任何可能含特殊字符的文本。要传中文备注,一律放到 POST 的 JSON body 里,并且在拼接时对所有来自外部(串口配置、传感器字符串)的值做一次双引号和反斜杠替换。

3.3 分块传输与 100-continue:两个会让你"发了没反应"的情形

如果服务端框架使用某种流式返回模式(比如某些框架默认对 JSON 响应开启 chunked),你收到的响应里不会有Content-Length,而是Transfer-Encoding: chunked,body 被切成若干块,每块前面有一个十六进制的长度行。如果你的解析器按Content-Length去读,读不到就会一直等,最后超时。

对 ESP8266 这种小设备,最省心的策略是干脆不解析 chunked。如果你的服务端可以配置,就把这个响应路径固定成Content-Length模式。如果配置不了,那就退化为"读到连接关闭为止",把收到的所有字节追加到缓冲里,最后再整体解析。这个方案浪费内存但省脑子,适合响应很小的场景。

另一个情形是Expect: 100-continue。某些 HTTP 客户端库(少数 Java/Go 的默认实现)在 body 较大时会先发这个头,等服务端回100 Continue再发 body。ESP8266 这边不会主动发这个头,但如果你用抓包工具看到对端迟迟不回,可以检查一下是不是服务端的反向代理在这一层做了什么特殊处理。

3.4 HTTPS:什么时候值得上,什么时候纯属自找麻烦

ESP8266 能做 HTTPS,靠的是WiFiClientSecure。但代价必须提前算清楚:

项目HTTPHTTPS
握手耗时几十毫秒1.5~4 秒(80MHz)
峰值堆占用约 2KB22~30KB
代码体积小增加几十 KB
证书校验无可选,但建议开
适合频率秒级上报分钟级上报

关键在内存。ESP8266 的可用堆大约 40KB,TLS 握手要吃掉一半以上。如果你在握手期间还在做别的事(比如同时读传感器、跑 Web 服务器),极易触发Out of memory或者断言失败。我的经验是:做 HTTPS 请求之前,先把其他占内存的东西全释放掉,并且在请求完成后立刻delete掉 secure client 对象。

void doHttpsPost() { Serial.print("free heap before: "); Serial.println(ESP.getFreeHeap()); WiFiClientSecure *sc = new WiFiClientSecure(); sc->setInsecure(); // 先跑通再谈校验,见下面说明 if (!sc->connect("api.example.com", 443)) { Serial.println("tls connect failed"); delete sc; return; } // ... 发请求、读响应 ... sc->stop(); delete sc; // 必须显式释放 Serial.print("free heap after: "); Serial.println(ESP.getFreeHeap()); }

setInsecure()会跳过证书链校验,只加密不认证。这在开发阶段能省掉一堆证书配置的麻烦,但生产环境里它意味着中间人可以伪装成你的服务器。正规做法是setTrustAnchors()加载根证书或者setFingerprint()校验证书指纹。这里还有个隐形依赖:证书校验需要正确的时间,因为要判断证书是否在有效期内。而 ESP8266 断电后没有 RTC,时间从 1970 年开始,所以你必须先configTime()同步 NTP,否则校验必然失败——这个现象很迷惑人,错误信息通常只告诉你证书无效,不会提示是时间问题。

所以我的实际建议是:如果你的上报数据不敏感、服务器在内网或者你完全控制两端,走 HTTP 就够了,省下来的内存和时间比那层加密更值钱。什么时候必须上 HTTPS?数据里有设备唯一标识和用户关联信息,或者你要过某些合规审查,那就老老实实上,并且用分钟级的发送间隔而不是秒级。

4. 让设备活过第三天的稳定性设计

4.1 自动重连靠不住,得自己加一层

WiFi.setAutoReconnect(true)只管"链路层掉线后尝试重连",它管不了这些情况:路由器重启后 DHCP 池变了导致 IP 冲突、AP 切换信道后握手失败、协议栈内部状态卡死。我遇到过一次设备在 AP 侧显示"已连接"但实际不通,WiFi.status()一直返回WL_CONNECTED,但任何 socket 都连不出去。

解决办法是加一个应用层心跳:记录"最后一次成功完成 HTTP 请求的时间",如果超过阈值(比如三倍的上报间隔)还没成功过,就主动WiFi.disconnect()再WiFi.begin(),还不行就ESP.restart()。

unsigned long lastOk = 0; const unsigned long MAX_SILENT = 600000UL; // 10 分钟没成功就重启 void checkHealth() { if (lastOk != 0 && millis() - lastOk > MAX_SILENT) { Serial.println("silent too long, reboot"); ESP.restart(); } }

注意millis()溢出问题。millis()在 32 位无符号下大约 49.7 天回绕一次,所以判断时间差一定要写成millis() - lastOk > X这种形式,无符号减法在回绕时依然正确。千万别写if (millis() > lastOk + X),那个在回绕点会直接判定超时,导致设备每 49 天无故重启一次——而且这个 bug 的复现周期长到几乎没人测得出来。

4.2 连接超时、读超时、DNS 缓存分三处设

WiFiClient::connect()的超时默认比较长,遇到目标端口不通的场景(比如服务端防火墙丢包而不是回 RST),可能卡住十几秒。这段时间你的主循环完全停摆,如果有看门狗在跑就会复位。

WiFiClient提供了setTimeout(),但它影响的是读操作的等待时长,不是connect的。想控制 connect 超时,得用带超时参数的重载:

client.setTimeout(3000); // 读超时 3 秒 if (!client.connect(ip, 80, 3000)) { ... } // connect 超时 3 秒

更进一步的优化是绕过 DNS。client.connect("api.example.com", 80)每次都会触发一次 DNS 查询,快则几十毫秒,慢则几秒(取决于路由器转发)。如果服务器 IP 相对固定,用WiFi.hostByName()解析一次并缓存,后续直接连 IP:

IPAddress srv; bool resolved = false; bool ensureResolved() { if (resolved) return true; if (WiFi.hostByName("api.example.com", srv) == 1) { resolved = true; return true; } return false; }

注意用 IP 连接时,HTTP 请求头里的Host仍然要写域名,不然服务端的虚拟主机匹配不到。这个细节很多人会漏,症状是服务器返回 404 或者默认站点的页面。

4.3 String 是方便,也是碎片制造机

我用过一段时间统计:一个每 30 秒发一次请求、每次用String拼请求头的设备,跑 6 小时后ESP.getFreeHeap()从 42KB 掉到 18KB 左右。不是因为泄漏,而是堆碎——大小不一的String反复申请释放,虽然总量够,但没有一块连续空间能容纳下一次的 TLS 握手或者大一点的缓冲。

几个具体对策,按性价比排序:

  • 请求头和 body 用固定大小的char[],snprintf填充,生命周期内不重新分配。
  • 响应不要readString(),边读边解析,只保留你需要的那几个字段。比如你只关心status字段,那就在读流的时候做一次极简的字符串匹配,匹配到就记个标记,其余字节直接丢弃。
  • 不要在loop()里频繁String拼接。如果你要生成日志,用snprintf到一个静态缓冲区,然后一次性Serial.write()。
  • HTTPClient 的getString()慎用,它会把整个响应体读成String,响应超过几 KB 时风险很高。

一个很实用的诊断手段是定期打印堆信息:

Serial.printf("heap=%d, frag=%d%%\n", ESP.getFreeHeap(), ESP.getHeapFragmentation());

getHeapFragmentation()返回值超过 60 就意味着问题在酝酿了,超过 80 基本随时会崩。这个指标比单纯看剩余堆更有价值。

4.4 看门狗与长时间阻塞的正确处理姿势

ESP8266 的软件看门狗默认大约 3 秒左右(具体受 SDK 配置影响),如果loop()里连续阻塞超过这个时间且没喂狗,芯片会复位,串口打印出一段ets Jan 8 2013,rst cause:4, boot mode:(3,6)之类的信息。rst cause:4就代表看门狗复位。

常见的阻塞来源:delay(5000)、client.connect()等对端响应、while等 WiFi 连接。短暂的阻塞(几百毫秒)通常没事,因为 Arduino core 在delay()内部会喂狗;但纯粹的忙等待不会。

我的建议是能不用长 delay 就不用,改成状态机加时间戳。如果确实需要等(比如等某个传感器上电稳定),把长等待拆成多段:

void safeDelay(unsigned long ms) { unsigned long t0 = millis(); while (millis() - t0 < ms) { delay(10); yield(); // 显式让出,喂狗并跑 WiFi 后台任务 } }

yield()在 ESP8266 Arduino core 里的作用就是跑一遍后台任务并喂狗,它是解决"我明明加了延时为什么还是复位"这个问题的钥匙。

5. 一次真实的排查链路:请求发出去了,服务器说没收到

5.1 现象与第一轮怀疑对象的排除

某次我帮人看一个设备,症状是:串口每 30 秒打印一次"POST sent, code=200",一切正常,但服务端数据库里一条数据都没有,访问日志里也没有对应时间点的记录。这种"客户端以为成功、服务端毫无痕迹"的情况,是最能考验排查思路的。

第一轮先把"到底有没有发出去"确认掉。方法是从设备侧看更底层的信号:在请求前后打印client.connected()和client.available()。结果很有意思——connect返回 true,print返回的字节数也对,但available()一直是 0,然后代码里那段"读超时后判定成功"的逻辑就把它当成 200 处理了。

这里就暴露了第一个 bug:状态码是从哪来的?很多人写的解析逻辑是这样:

client.print(request); delay(500); int code = 0; if (client.available()) { String status = client.readStringUntil('\n'); code = status.substring(9, 12).toInt(); // 从 "HTTP/1.1 200 OK" 截 }

如果available()为 0,code保持初始值——而这个初始值被写成了 200,或者干脆后面判断的是code == 0 || code == 200都算成功。这就是"假成功"的来源。

5.2 逐层往上找:DNS、端口、Host

把假成功去掉之后,真正的连接失败浮出水面:client.connect()其实返回的是 false,只是外面套了一层重试逻辑,第三次的返回值被忽略了。

顺着往下查,第一个嫌疑是 DNS。把域名换成直接 IP 试一次,如果连 IP 通、连域名不通,那基本就是 DNS 解析失败。ESP8266 的 DNS 查询依赖路由器转发,某些路由器在设备刚上电、DHCP 还没完全稳定时会给空结果。加一个解析重试就能解决。

第二个嫌疑是端口。80 端口在有些企业网络或者某些云主机安全组里默认是关闭的,尤其当你的服务器是新建的。这个用telnet或者任何 TCP 探测工具从同网段的其他机器验证一下最快。

第三个嫌疑,也是这次真正的元凶:Host 头写错了。代码里连的是api.example.com,但请求头里Host写成了localhost。服务端是 Nginx 反向代理,按 Host 匹配server_name,匹配不到localhost就走默认 server 块,而默认块返回的是一个空的 444(Nginx 特有的"直接关连接不回响应")。于是客户端什么响应都没读到,日志里也看不到常规访问记录——因为 444 的设计就是不写常规 access log。

这个坑的价值在于:它不能靠看客户端代码发现,必须去看服务端配置。当时是让人贴出 Nginx 配置才定位到的。从那以后我养成了一个习惯:任何 HTTP 请求相关的疑难问题,如果客户端侧怎么看都正常,立刻去服务端看server_name、location和 access log 的完整格式配置。

5.3 抓包是最后手段,也是最省时间的手段

如果两端都看过了还是不明白,就别猜了,抓包。ESP8266 本身不方便抓,但你可以把它连到一个能抓包的 AP 上(很多带抓包功能的便携路由都行),或者干脆在服务器侧用tcpdump -i any -A port 80看一眼进站的原始字节。

抓到包之后看三件事:TCP 三次握手有没有完成、请求字节是否完整、服务端回的第一个字节是什么。我有一次就是靠这个发现请求发出去之后紧跟了一个 RST——原因是代码在print之后立刻调用了client.stop(),而 TCP 缓冲里还有未发送的数据,stop()触发的 close 在某些情况下会变成 RST,把还没发出去的数据一起丢掉。

修复很简单:发完请求后至少读到响应状态行,或者用client.flush()等待发送缓冲清空。flush()在 ESP8266 的WiFiClient上是等待输出缓冲发送完毕,不是丢弃输入缓冲(这点和某些其他平台的语义不一样,容易搞混)。

6. 把一次性的请求代码收拾成能复用的模块

6.1 一个够用又不臃肿的上报器骨架

Demo 跑通之后,下一步通常是"我有三个接口要调,不能每处都复制一遍"。这时候需要一个简单的封装,但别过度设计——ESP8266 上搞一套完整的 HTTP 客户端抽象层,收益远小于成本。

我常用的形态是一个极简结构体加两个函数:

struct HttpResult { int code; // 0 表示没拿到状态行 int bodyLen; unsigned long costMs; }; HttpResult httpPost(const char* host, uint16_t port, const char* path, const char* json, unsigned long timeoutMs) { HttpResult r = {0, 0, 0}; unsigned long t0 = millis(); WiFiClient c; c.setTimeout(2000); if (!c.connect(host, port, 3000)) { r.costMs = millis() - t0; return r; } c.printf("POST %s HTTP/1.1\r\n", path); c.printf("Host: %s\r\n", host); c.print("Content-Type: application/json\r\n"); c.printf("Content-Length: %u\r\n", (unsigned)strlen(json)); c.print("Connection: close\r\n\r\n"); c.print(json); // 只读状态行 + 数 body 长度,不保存 body bool gotStatus = false; bool inBody = false; unsigned long last = millis(); while (millis() - last < timeoutMs) { while (c.available()) { char ch = c.read(); last = millis(); if (!gotStatus) { if (ch == '\n') gotStatus = true; // 简化处理,够用 } else if (!inBody) { if (ch == '\n') inBody = true; // 空行后的第一个字节算 body } else { r.bodyLen++; } } if (!c.connected() && !c.available()) break; delay(1); } c.stop(); r.costMs = millis() - t0; return r; }

这个骨架故意做得很"笨":它不解析状态码具体值,只保证连接成功、请求完整发出、响应有返回。真正的业务判断放在调用方,因为不同接口对"成功"的定义不一样(有的看状态码,有的看 body 里的code字段)。这个选择的理由很实在:在资源受限设备上,通用性越强的解析器越容易成为 bug 温床,不如把简单逻辑摊平在调用处。

6.2 重试要带退避,而且要有上限

"失败了就重试"这句话在嵌入式上报场景里需要加三个限定条件:重试几次、间隔多久、什么时候放弃。

我的默认参数是:最多 3 次,间隔 2s / 5s / 15s,都失败就等下一个上报周期。指数退避的意义在于,如果服务端整体不可用,你密集重试只会把设备的电量和网络配额耗光,还可能在服务端恢复的瞬间形成请求风暴。

const unsigned long BACKOFF[] = {2000, 5000, 15000}; bool reportWithRetry(const char* json) { for (int i = 0; i < 3; i++) { HttpResult r = httpPost(HOST, 80, "/v1/report", json, 5000); if (r.code > 0 && r.bodyLen > 0) { lastOk = millis(); // 刷新健康心跳 return true; } Serial.printf("retry %d failed, cost=%lums\n", i, r.costMs); safeDelay(BACKOFF[i]); } return false; }

注意这里的lastOk只在实际成功时刷新,失败不刷新——这正是第 4 节那个健康检查能起作用的前提。

6.3 断网期间的数据去哪了

这是很多人做到后期必然遇到的问题:设备装在信号不好的角落,断网几分钟是常态,那几分钟的数据要么丢掉,要么缓存起来补发。

两种策略各有代价。直接丢弃最简单,适合监控类数据(温度每 30 秒一个点,丢两个点不影响趋势判断)。缓存补发需要存储空间,ESP8266 的 Flash 上用 LittleFS 存一个环形队列就能做,但要考虑写入频率对 Flash 寿命的影响(每次上报都写一次,一天 2880 次,几年下来也可观)。

我的折中做法是:内存里留一个 8 条的环形缓冲,断网时先存着,恢复后按时间顺序补发,发完才清空。8 条对 30 秒间隔来说能覆盖 4 分钟的断网,足够应付大多数路由器重启场景,而且不碰 Flash。如果超过 8 条,最老的被覆盖——这就是一个明确的取舍:宁可丢最老的数据,也不要让缓冲区无限增长把堆吃光。

实现上环形的索引和计数用uint8_t就行,每条数据固定 96 字节,整个结构占不到 800 字节,对堆几乎无感。这个规模是我调了几次才定下来的:最开始设 32 条,结果每次上报前遍历补发要花十几秒,期间设备处于"忙"状态,喂狗不及时就复位了。

6.4 时间戳这件事,别指望设备自己知道

上报的数据如果没有时间戳,服务端只能按接收时间记,这在补发场景下会导致时间错乱——你补发的三条历史数据全被记成了当前时刻。

ESP8266 需要configTime()从 NTP 拿时间,但这个过程在冷启动时可能失败(DNS 没就绪、UDP 包被丢),而且 NTP 请求本身也是一次网络交互。我的做法是:启动时尝试同步一次,拿到就记下偏移量;没拿到就用"服务端返回的时间"来校准——让服务端在自己的响应里带上服务器时间,设备收到之后算出差值,后续时间戳按本地millis()推算。这样即使 NTP 不可用,时间也不会差太多。

需要提醒的是,millis()的精度受晶振影响,普通模组的晶振误差可能到几十 ppm,一天下来累积误差在几秒量级。对"记录某个事件大概发生在几点"这种需求完全够用,但对需要毫秒级对齐的场景就不行了。

最后分享一个我在实际项目里养成的小习惯:每次请求都打一行紧凑的日志,格式是ts,code,cost,heap,用snprintf拼到栈上再Serial.write。这条日志在事后排查时价值极高——你能一眼看出是从哪一天开始cost从 200ms 涨到 3000ms 的,也能看出heap是不是在缓慢下降。很多问题不是突然爆发的,而是这些数字先变了,只是当时没人看。

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

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

立即咨询