HTTP协议

本文系统讲解HTTP协议在嵌入式设备(如STM32、ESP8266)中的落地实践,涵盖HTTP/1.1报文结构、请求方法与状态码语义、关键头部字段(Host、Content-Length、Transfer-Encoding、Range)、无状态处理(Token认证)、HTTPS约束、手写HTTP请求模板、响应收全判定(含chunked解析)、半包粘包增量解析、OTA断点续传(Range机制)、AT指令流程及常见问题排查。重点面向资源受限MCU的工程实现。

HTTP(HyperText Transfer Protocol,超文本传输协议)是应用层使用最广的协议之一。对嵌入式工程师来说,它的价值不在于写网页,而在于用一个几乎所有服务器都支持的接口,把设备的数据送出去、把配置和固件拿回来。

本文按「协议本身 → 报文结构 → 方法/状态码/头部 → 状态管理 → 在 MCU 上落地」的顺序展开,最后给出 STM32 + ESP8266 的完整请求示例、响应如何判定收完、OTA 断点续传和常见故障排查。

阅读建议:前 7 节是协议通用部分,写 PC 端接口、看抓包也够用;第 8 节之后是嵌入式落地部分,建议对照自己板子的实际代码看。


1.HTTP协议本质

HTTP 的全称是 HyperText Transfer Protocol,中文叫超文本传输协议。它工作在 TCP/IP 模型的应用层,主要作用是规定客户端和服务端之间应该如何交换数据。

HTTP 最核心的通信方式只有一个:

客户端发送请求,服务器返回响应

也就是说,HTTP 是一种典型的请求-响应协议。比如浏览器访问一个网页,浏览器就是客户端,网站后台就是服务器。浏览器先发起请求,服务器收到请求后,把 HTML 页面、图片、JSON 数据等内容返回给浏览器。

它的基本过程可以理解为:

客户端 ——> HTTP请求 ——> 服务器
客户端 <—— HTTP响应 <—— 服务器

HTTP 本身不负责底层网络传输。 它只规定应用层数据格式,真正负责把数据可靠送进对方的是 TCP。所以传统 HTTP/1.1 的底层通常是:

HTTP ——> TCP ——> IP ——> 网络接口

如果是 HTTPS,那么中间多一层 TLS 加密:

HTTPS ——> TLS ——> TCP ——> IP ——> 网络接口

所以 HTTPS 可以简单理解为:

HTTPS = HTTP + TLS 加密

HTTP 默认端口是 80,HTTPS 默认端口是 443。访问:

http://example.com

实际上默认访问的是 example.com:80。访问:

https://example.com

默认访问的是 example.com:443。

HTTP 的应用非常广,比如网页访问、App 接口请求、服务器接口调用、嵌入式设备上传数据、下载文件、OTA 升级等,都可以基于 HTTP 实现。

在这里插入图片描述

关键特点:报文是纯文本

这一点对嵌入式特别重要。HTTP/1.x 的请求和响应都是可直接阅读的 ASCII 文本(Body 可以是二进制),这意味着:

  • 单片机可以用 snprintf 拼字符串就能发出一个合法请求,不需要专门的协议栈;
  • 出问题时可以把请求原样 printf 到串口,肉眼就能检查;
  • 抓包看到的和代码里写的几乎一模一样,学习成本很低。

代价是传输效率不如二进制协议(HTTP/2 就是为了解决这个问题改成了二进制分帧),但对每秒几十字节的传感器上报来说,这点开销完全可以接受。

URL 的组成

一次 HTTP 请求,本质上是"对这个 URL 做什么操作"。写代码时你得自己把 URL 拆开,因为建立 TCP 连接只需要 host:port,而请求行里要写 path + query。

  http://  example.com : 8080 /api/sensor ?id=1&type=temp #frag
  └──┬─┘   └────┬────┘  └─┬─┘ └────┬────┘ └──────┬─────┘ └─┬─┘
   scheme     host       port      path           query   fragment
部分示例说明嵌入式是否需要
schemehttp / https决定端口与是否加密需要(决定是否走 TLS)
hostexample.com域名或 IP需要(填 Host 头、建 TCP)
port8080缺省 80 / 443需要(建 TCP 用)
path/api/sensor资源路径需要(放请求行)
queryid=1&type=temp键值对,& 分隔需要(GET 参数)
fragment#frag页面内锚点不需要,根本不发给服务器

注意:fragment 只在浏览器内部使用,不会出现在 HTTP 请求里。写嵌入式代码时如果把 # 后面的内容也拼进请求行,服务器会当成路径的一部分,导致 404。


2.HTTP请求和响应的整体结构

HTTP 通信由两部分组成:请求 Request 和 响应 Response。客户端发出去的叫请求,服务器返回来的叫响应。HTTP 请求和响应都有固定结构,只要理解这个结构,就能看懂大部分 HTTP 报文。

一个 HTTP 请求通常由四部分组成:

请求行
请求头
空行
请求体

例如:

GET /api/user?id=1001 HTTP/1.1
Host: example.com
User-Agent: STM32H7-ESP8266/1.0
Accept: application/json
Connection: close

第一行:

GET /api/user?id=1001 HTTP/1.1

叫请求行。它说明了三个信息:使用什么方法、访问哪个路径、使用哪个 HTTP 版本。三部分之间用单个空格分隔,这一点要严格——多一个空格,服务器可能就返回 400。

后面的:

Host: example.com
User-Agent: STM32H7-ESP8266/1.0
Accept: application/json
Connection: close

叫请求头,用来描述这次请求的附加信息。比如访问的主机是谁,客户端是什么,希望服务器返回什么格式的数据,连接是否关闭等。

请求头后面必须有一个空行。 这个空行非常重要,它表示请求头结束。HTTP 报文中使用 \r\n 换行,所以:

  • 每一行的结尾是 \r\n;
  • 请求头和请求体之间还要再加一个 \r\n;
  • 连起来看,头部的结束标志就是连续的 \r\n\r\n。
...Connection: close\r\n\r\n   ← 最后两个 \r\n 就是"空行"

只需要 \r\n,不要写成 \n。很多服务器对裸 \n 兼容性不好,会一直等你把这一行发完,表现为"请求发出去了但服务器不响应"。

如果是 POST 请求,空行后面还会有请求体:

POST /api/login HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 40
Connection: close

{"username":"admin","password":"123456"}

空行后面的 JSON:

{"username":"admin","password":"123456"}

就是请求体。注意 Content-Length: 40 是这段 JSON 的字节数(不是字符数,也不是行数),服务器靠它判断请求体什么时候结束。

服务器返回的 HTTP 响应也有类似结构:

状态行
响应头
空行
响应体

例如:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 30
Connection: close

{"code":0,"message":"success"}

第一行:

HTTP/1.1 200 OK

叫状态行。它说明服务器使用的 HTTP 版本、请求处理结果状态码和状态描述。这里的 200 OK 表示请求成功。

响应头说明服务器返回的数据格式、数据长度、连接方式等。空行后面的内容就是响应体,也就是真正返回给客户端的数据。

在这里插入图片描述

请求与响应结构对照
位置请求响应
第一行请求行:方法 路径 版本状态行:版本 状态码 原因短语
中间请求头(Key: Value)响应头(Key: Value)
分隔空行 \r\n空行 \r\n
最后请求体(可能没有)响应体(可能没有)
结束标志头部为 \r\n\r\n头部为 \r\n\r\n

头部字段的几条规则(RFC 7230):

  • 字段名大小写不敏感(Content-Length 与 content-length 等价),但值通常区分大小写;
  • 字段名和冒号之间不能有空格(Content-Length: 25 对,Content-Length : 25 错);
  • 冒号后面建议留一个空格,然后写值;
  • 一行一个字段,顺序无所谓(Host 习惯放第一个);
  • 同名头可以出现多次(如多个 Set-Cookie),合并语义各字段不同。

3.HTTP请求方法

HTTP 请求方法表示客户端想对服务器资源做什么操作。最常见的是 GET 和 POST。

GET 用于获取资源。 比如查询用户信息,打开网页,下载文件,都可以使用 GET。

GET /api/user?id=1001 HTTP/1.1
Host: example.com

这个请求的意思是:客户端想要获取 /api/user 这个接口的数据,同时传了一个参数 id=1001。

GET 请求的参数通常放在 URL 后面,例如:

/api/user?id=1001&page=1

这种方式简单直观,适合查询类操作。但是它不适合传输敏感信息,比如密码,因为参数会直接出现在 URL 中,也可能被浏览器历史记录、服务器日志保存。

POST 用于提交数据。 比如登录、注册、上传传感器数据、提交表单,一般都用 POST。

POST /api/sensor HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 23

{"temp":26.5,"humi":60}

POST 的数据通常放在请求体中,可以传 JSON、表单、文件、二进制数据等。它比 GET 更适合提交复杂数据。

除了 GET 和 POST,还有 PUT、PATCH、DELETE 等方法。PUT 通常用于整体更新资源,PATCH 用于局部修改资源,DELETE 用于删除资源。

例如在 RESTful 风格中,经常这样设计接口:

GET     /users        查询用户列表
GET     /users/1      查询1号用户
POST    /users        新增用户
PUT     /users/1      修改1号用户
DELETE  /users/1      删除1号用户

但是在嵌入式开发或者普通接口调用中,最常用的还是 GET 和 POST。设备读取服务器配置可以用 GET,设备上传传感器数据通常用 POST。

方法速查表
方法语义幂等安全有请求体嵌入式典型用途
GET获取资源是是通常无拉配置、查状态、下载文件
POST提交/创建否否有上报传感器数据、登录、告警上报
PUT整体替换是否有覆盖式更新设备配置
PATCH局部更新否否有只改某个字段
DELETE删除资源是否一般无注销设备、删除规则
HEAD只要响应头是是无OTA 前查固件大小、探测服务可用性
OPTIONS查询支持的方法是是无调试、CORS 预检

两个容易混淆的概念:

  • 幂等:执行一次和执行多次,服务器状态结果相同。GET、PUT、DELETE 幂等;POST 不幂等——同一笔 POST 重发两次,服务器可能创建两条记录。
  • 安全:不改变服务器状态。只有 GET、HEAD、OPTIONS 是安全的。

对嵌入式很有用的一条:网络不稳定时重试是常态,但重试 POST 要小心重复上报。解决办法是让服务器支持幂等键(请求头带 Idempotency-Key,或 Body 里带设备端生成的唯一 msg_id),服务器见到相同的键就只处理一次。

HEAD 在 OTA 场景非常好用:它返回的响应没有 Body,但头部里的 Content-Length 就是固件大小,可以据此先判断 Flash 空间够不够,再决定下不下载。


4. HTTP状态码

HTTP 状态码是服务器返回给客户端的处理结果,客户端通过状态码可以快速判断这次请求是否成功。

状态码大致分为五类:

1xx:表示信息响应
2xx:表示成功
3xx:表示重定向
4xx:表示客户端错误
5xx:表示服务器错误

记忆技巧:看第一位就知道责任方。4xx 是"你(客户端)的问题",5xx 是"我(服务器)的问题",3xx 是"地址变了,去别处"。

最常见的是 200 OK:

HTTP/1.1 200 OK

这表示请求成功,服务器正常返回了数据。

201 Created 表示资源创建成功,常见于 POST 新增数据。例如客户端提交注册信息,服务器成功创建用户后,可以返回 201。

301 和 302 表示重定向。服务器告诉客户端:你要访问的资源换地址了。响应中一般会带 Location 头:

HTTP/1.1 302 Found
Location: /login

这表示客户端应该跳转到 /login。

301 是永久重定向(客户端/搜索引擎可以记住),302 是临时重定向。另外 307、308 与 302、301 语义相同,但要求保持原请求方法和 Body 不变——POST 遇到 302 时很多客户端会改成 GET,307 则不会。

304 Not Modified 表示资源没有变化,客户端可以继续使用缓存,不需要重新下载完整内容。

400 Bad Request 表示请求格式错误。比如 JSON 格式不对,参数缺失,Content-Length 写错,都可能导致 400。

401 Unauthorized 表示没有认证,常见于没有登录、Token 失效、认证信息错误。

403 Forbidden 表示没有权限。它和 401 不一样,401 是"你还没证明你是谁",403 是"我知道你是谁,但你没权限"。

404 Not Found 表示请求资源不存在。比如访问了一个服务器没有的路径。

405 Method Not Allowed 表示路径存在,但不支持这个方法。比如把接口设计成只接受 POST,你用 GET 去访问就会得到 405。

408 Request Timeout 表示服务器等你的请求等超时了。常见于弱网、或者 Content-Length 声明得比实际发送的多,服务器一直在等剩下的字节。

413 Payload Too Large 表示请求体超过服务器限制,上传大量传感器数据时可能遇到。

415 Unsupported Media Type 表示 Content-Type 服务器不支持。比如服务器只认 application/json,你发了 text/plain。

429 Too Many Requests 表示请求太频繁被限流。设备高频上报时容易被这个挡住,需要退避重试。

500 Internal Server Error 表示服务器内部错误。这个通常是服务器代码异常、数据库异常、后端服务出错。

502 Bad Gateway 和 504 Gateway Timeout 常见于网关或反向代理场景。比如 Nginx 转发请求到后端服务,后端挂了可能返回 502,后端响应太慢可能返回 504。

503 Service Unavailable 表示服务暂时不可用,通常是服务器过载或正在维护,一般会配合 Retry-After 头告诉客户端多久后再试。

实际调试时,可以先看状态码判断问题方向:如果是 4xx,多半是客户端请求有问题;如果是 5xx,多半是服务器处理有问题。

设备端建议怎么处理状态码
状态码段设备端的合理动作
2xx正常处理;200 读 Body,204 表示成功但无 Body(别去读)
3xx一般设备端不跟随重定向;若必须跟随,注意 301/302 可能把 POST 改成 GET
401 / 403重新获取 Token;仍失败则上报配置错误,不要无脑重试
408 / 429 / 5xx可重试,采用指数退避(如 1s、2s、4s…),并限制最大次数
400 / 404 / 415不要重试,重试结果一样,属于代码或配置问题,应打印日志

5. HTTP头部和数据类型

HTTP Header 是 HTTP 协议非常重要的一部分。很多功能都靠 Header 实现,比如数据类型、数据长度、认证、Cookie、缓存、跨域、连接控制等。

Host 表示请求的目标主机,HTTP/1.1 中通常必须带:

Host: example.com

即使你用 IP 直连(如 Host: 192.168.1.100),也要带上 Host。HTTP/1.1 规定缺少 Host 必须返回 400。虽然很多服务器宽容处理,但自己写请求时不要省。

Content-Type 表示请求体或响应体的数据格式。比如:

Content-Type: application/json

表示 Body 中的数据是 JSON。

常见数据类型有:

text/html
application/json
text/plain
application/x-www-form-urlencoded
multipart/form-data
application/octet-stream

其中 application/json 是接口开发中最常用的格式:

Content-Type: application/json

{"temp":26.5}

application/x-www-form-urlencoded 是传统表单格式:

Content-Type: application/x-www-form-urlencoded

username=admin&password=123456

multipart/form-data 常用于文件上传,application/octet-stream 常用于二进制文件或固件下载。

Content-Length 表示请求体或响应体的长度,单位是字节。这个字段在嵌入式手写 HTTP 请求时非常重要。例如:

Content-Length: 25

表示 Body 长度是 25 字节。如果这个长度写错,服务器可能一直等待数据,也可能解析失败。

最常见的错误:用 strlen() 算长度,但 Body 里有中文(UTF-8 下一个汉字占 3 字节)。strlen() 返回的是字节数,一般是安全的;但如果你用 wcslen() 或按"字符个数"估算就会出错。永远用字节数。

Authorization 用于认证,例如 Token 认证:

Authorization: Bearer xxxxxxxx

Cookie 用于客户端携带状态信息:

Cookie: sessionid=abc123

Connection 用于控制连接:

Connection: close

表示响应结束后关闭 TCP 连接。

Connection: keep-alive

表示保持连接,后续请求还可以复用这个 TCP 连接。

Transfer-Encoding: chunked 表示响应体是分块传输的,此时没有 Content-Length。这是嵌入式解析响应时最容易漏掉的一种情况,第 8 节会详细讲。

Range / Content-Range 用于范围请求,是 OTA 断点续传的基础:

Range: bytes=0-4095

表示只要前 4096 字节。服务器支持时返回 206 Partial Content 并带上:

Content-Range: bytes 0-4095/524288

表示本次返回 0~4095 字节,文件总长 524288 字节。

嵌入式常用头部速查
头部方向是否必填嵌入式建议
Host请求HTTP/1.1 必填写上域名或 IP
Content-Type双向有 Body 时必填上报用 application/json
Content-Length双向有 Body 时必填必须等于 Body 字节数
Connection双向可省设备内存紧时用 close,省得维护连接状态
User-Agent请求否建议带上设备型号和固件版本,方便服务器侧统计与排障
Authorization请求看接口Bearer <token>
Accept请求否写 application/json 明确期望格式
Accept-Encoding请求否嵌入式一般不要带,避免服务器返回 gzip 增加解压负担
Range请求否OTA 断点续传用
Cache-Control双向否设备拉配置建议显式 no-cache
Date响应通常有可用于粗略对时
Set-Cookie响应否设备端一般不用 Cookie,用 Token 更简单

User-Agent 那个 “STM32H7-ESP8266/1.0” 不是随便写的:把设备型号和固件版本写进去,服务器日志里一眼就能看出是哪批设备在出问题。产线上排查"为什么只有某批设备上报失败"时,这个字段非常值钱。


6. HTTP无状态、登录和缓存

HTTP 协议本身是无状态的。也就是说,两次 HTTP 请求在协议层面是独立的。服务器不会天然知道某个请求和上一次请求是同一个用户发的。

但是实际网站和接口经常需要登录状态。比如用户登录后,下一次请求个人信息,服务器必须知道这个用户是谁。

为了解决这个问题,就出现了 Cookie、Session 和 Token。

Cookie 是保存在客户端的一小段数据。服务器可以通过响应头让客户端保存 Cookie:

Set-Cookie: sessionid=abc123; Path=/; HttpOnly

客户端之后请求时会带上:

Cookie: sessionid=abc123

Session 通常保存在服务器端。用户登录成功后,服务器创建一个 Session,并把 Session ID 发给客户端。客户端每次请求带上 Session ID,服务器就能找到对应用户。

Token 是现在很常见的认证方式。用户登录成功后,服务器返回一个 Token,客户端之后请求时放在 Authorization 头里:

标准格式:Authorization: <认证类型> <凭证>
Authorization: Bearer token_value
三种方式对比
方式状态存哪服务端要不要存设备端实现难度嵌入式倾向
Cookie / Session服务端(ID 在客户端)要中(要管理 Cookie 字符串)一般不用
Token(JWT 等)客户端(令牌自包含)通常不用低(存一串字符串)推荐
签名(AppKey + HMAC)无状态不用中(要算哈希)安全要求高时用

设备端用 Token 的三个实际问题:

  1. Token 存哪:MCU 重启不能丢,要存 EEPROM / Flash。注意 Token 是敏感信息,条件允许时加密存储。
  2. Token 会过期:服务器返回 401 时,设备要用刷新令牌(refresh token)或重新用设备凭证换新 Token,而不是死循环重试。
  3. 设备没有可靠时间:如果服务器要求时间戳防重放,设备必须先对时(SNTP/NTP 或从响应头 Date 粗对时),否则请求会被判为过期。

HTTP 还有缓存机制。缓存可以减少重复请求,提高速度。例如服务器返回:

Cache-Control: max-age=3600

表示这个资源可以缓存 3600 秒。客户端在缓存有效期内可以直接使用本地缓存,不必重新请求服务器。

如果服务器返回:

Cache-Control: no-cache

表示可以缓存,但使用前必须向服务器确认资源有没有变化。

如果服务器返回:

Cache-Control: no-store

表示完全不能缓存(连磁盘都不能落),适合含敏感信息的响应。

如果资源没变化,服务器可以返回:

HTTP/1.1 304 Not Modified

这样客户端就继续使用本地缓存,不需要重新下载完整资源。

304 的判定一般靠请求里带的两个条件头:

If-None-Match: "abc123"      ← 对应响应头 ETag
If-Modified-Since: Tue, 27 Oct 2026 08:00:00 GMT

对嵌入式来说,缓存机制大多用不上,因为设备通常只取最新配置,直接带 Cache-Control: no-cache 请求最新版本更简单。但如果是频繁下载同一份固件或大文件,配合 ETag 可以省下大量流量。


7. HTTPS和HTTP版本

HTTP 是明文传输,如果使用普通 HTTP,别人通过抓包可以看到请求 URL、请求头、请求体、响应内容等信息。所以涉及密码、Token、用户数据时,应该使用 HTTPS。

HTTPS 可以理解为:

HTTPS = HTTP + TLS

TLS 提供三个核心能力:加密传输、身份认证、数据完整性校验。

  • 加密传输可以防止别人看到通信内容;
  • 身份认证可以确认你连接的服务器是真的;
  • 完整性校验可以防止数据在传输过程中被篡改。

HTTP 有不同版本。

HTTP/1.0 比较早,默认短连接,每次请求通常都建立一次 TCP 连接,请求结束就关闭连接。

HTTP/1.1 是目前仍然非常常见的版本。它默认支持长连接,一个 TCP 连接可以连续处理多个 HTTP 请求。它也要求请求中带 Host 头。

HTTP/2 使用二进制分帧,支持多路复用和头部压缩,性能比 HTTP/1.1 更好。

HTTP/3 基于 QUIC,而 QUIC 基于 UDP,在弱网环境下表现更好,也减少了 TCP 队头阻塞问题。

对于 STM32、ESP8266、4G 模组这类嵌入式设备来说,最容易实现、最常用的还是 HTTP/1.1。因为它格式直观,可以直接用字符串拼接请求,也方便串口打印和调试。

版本对比
版本传输层连接复用头部格式嵌入式适配度
HTTP/1.0TCP默认短连接文本简单但效率低
HTTP/1.1TCP默认长连接文本首选,模组和 lwIP 都支持
HTTP/2TCP多路复用二进制分帧 + HPACK需要专门实现,MCU 上很少用
HTTP/3UDP(QUIC)多路复用 + 0-RTT二进制目前 MCU 侧基本没有成熟实现
HTTPS 在 MCU 上的现实约束

这是嵌入式绕不开的一段,务必先算清楚成本再动手。

约束说明应对方式
RAM 开销TLS 握手和会话缓冲常需要数十 KB 量级(随密码套件和配置变化)选轻量套件(如 ECDHE + AES-GCM),关闭不用的特性;确认堆够大
证书校验需要时间校验证书有效期要用当前时间上电先 SNTP 对时,或先用 Date 头粗对时
证书存储根证书要放 Flash用固定 CA(Certificate Pinning),省 RAM 也防中间人
握手耗时完整握手可能几百毫秒到数秒支持会话复用;或改用长连接减少握手次数
计算量RSA 握手比 ECC 慢得多优先 ECDSA/ECDHE
实现位置MCU 跑 mbedTLS,还是模组内置 TLS模组内置 TLS 更省心,MCU 只管发明文 HTTP

一个常见误区:以为用了 HTTPS 就安全了。如果代码里把证书校验关掉(很多例程为了跑通会写 verify_none),那 HTTPS 只提供了加密,没有提供身份认证,中间人可以伪造服务器。生产代码必须开启校验。


8. 嵌入式中如何使用HTTP

在嵌入式设备里,HTTP 常用于上传传感器数据、读取服务器配置、下载文件、OTA 升级、调用云平台接口等。

8.1 三条实现路径
路径协议栈在哪MCU 要做什么优点缺点适用场景
AT 模组(ESP8266 / 4G)模组内部拼 HTTP 字符串,走串口MCU 资源占用最小,自带 TLS吞吐受串口限制,AT 解析要健壮快速出产品、MCU 资源紧
lwIP + SocketMCU 内部建 TCP + 手写 HTTP完全可控,无模组依赖要吃 RAM,要自己处理重连板载以太网/Wi-Fi
现成 HTTP 客户端库MCU 内部调 API省事,功能全体积大,定制难平台已集成(如 lwIP httpc)

下面以 STM32 + ESP8266 AT 为主线,因为这是入门最常见、也最能看清 HTTP 本质的方式。换成 lwIP Socket 时,只需把"发串口"换成"发 socket",HTTP 部分完全一样。

8.2 手写请求模板

如果使用 ESP8266 AT 指令,本质流程是:先连接 Wi-Fi,再建立 TCP 连接,然后通过 TCP 发送 HTTP 请求字符串,最后接收服务器返回的 HTTP 响应。

一个最简单的 GET 请求可以这样拼:

"GET /api/status HTTP/1.1\r\n"
"Host: 192.168.1.100\r\n"
"Connection: close\r\n"
"\r\n"

这里最后的 \r\n 很关键,它和前面的换行组成了请求头结束标志。也就是说,请求头和请求体之间必须有一个空行。

POST 上传 JSON 可以这样:

POST /api/sensor HTTP/1.1
Host: 192.168.1.100
Content-Type: application/json
Content-Length: 23
Connection: close

{"temp":26.5,"humi":60}

在嵌入式中,手写 HTTP 最容易出错的地方有四个:

  1. 每一行要用 \r\n 结尾,不能用裸 \n。
  2. 请求头结束后必须有空行,即最后是 \r\n\r\n。
  3. Content-Length 必须等于 Body 的真实字节长度。
  4. POST 的 Body 前面不能多加空行,头结束的空行之后紧接着就是 Body 的第一个字节。

第 4 点特别容易错:很多人写成 "...\r\n\r\n\r\n%s"(多了一个 \r\n),而服务器只按 Content-Length 读 23 字节,会把多出来的 \r\n 算进 Body,JSON 解析直接失败,返回 400。

8.3 响应怎么算"收完了"(重点)

这是嵌入式最容易出问题的地方。TCP 是字节流,没有消息边界,你不能指望"读一次就是一条完整响应",也不能指望"服务器发完就关连接"(长连接时它不关)。

HTTP/1.1 规定按下面的优先级顺序判断响应体长度(RFC 7230 §3.3.3):

① 请求方法是 HEAD,或状态码是 1xx / 204 / 304
   → 没有响应体,收到头部结束就可以停

② 有 Transfer-Encoding: chunked
   → 分块传输,按块解析,直到读到长度为 0 的块

③ 有 Content-Length
   → 收满这么多字节就停

④ 以上都没有(只在 HTTP/1.0 或 Connection: close 时出现)
   → 一直读到服务器关闭 TCP 连接

这四种情况必须都考虑到,否则会遇到"数据读不全"或"一直等不到结束"。

分块传输(chunked)长什么样:

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: application/json

a
{"code":0,
14
"message":"success"}
0

格式规则:

<十六进制长度>\r\n
<对应长度的数据>\r\n
<十六进制长度>\r\n
<对应长度的数据>\r\n
0\r\n
\r\n

这个例子里:

行含义
a十六进制,等于十进制 10,表示下面有 10 字节
{"code":0,正好 10 字节(数一下:{ "code" : 0 ,)
14十六进制,等于十进制 20
"message":"success"}正好 20 字节
0结束块,后面跟一个空行,即 0\r\n\r\n

三句话记住 chunked:

  1. 块长度是十六进制,是数据本身的字节数,不包含数据后面那个 \r\n;
  2. 拼起来的总长度就是 Body 总长度,但响应头里不会出现 Content-Length;
  3. 结束标志是长度为 0 的块,之后可能还有 trailer(一般没有)。

判断技巧:如果响应头里看到 Transfer-Encoding: chunked,就不要去找 Content-Length,它不会有。此时如果按 Content-Length 逻辑处理,会读到一个不存在的长度,然后一直等到超时。

8.4 半包与粘包:增量解析

TCP 不保留消息边界,所以一次 recv 可能:

  • 只收到半个响应头(半包);
  • 收到"上一响应尾巴 + 这一响应全部"(粘包);
  • 收到完整响应 + 下一响应的开头。

所以嵌入式收 HTTP 必须写成状态机式的增量解析,而不是"读一次就解析"。推荐的最小流程:

1. 持续收数据,追加到缓冲区
2. 在缓冲区里找 \r\n\r\n
   ├─ 没找到:还没收完头部,继续收(检查缓冲区是否将溢出)
   └─ 找到:头部结束,解析状态码、Content-Length / chunked
3. 按长度规则继续收 Body,直到满足结束条件
4. 全部收完后,把这一条响应从缓冲区移除,剩余字节留给下一条

两个必须设的上限:

上限建议超限处理
响应头长度1~2 KB直接判定协议错误,断开重连
Body 长度按业务定(如固件就是 Flash 分区大小)超过就拒绝,避免把内存写穿

定位头部结束的代码:

/**
 * @brief  在接收缓冲区中查找头部结束标志 \r\n\r\n
 * @param  buf  接收缓冲区
 * @param  len  当前已收到的字节数
 * @return >=0:Body 起始偏移;-1:还没收到完整头部
 */
static int http_find_body_start(const uint8_t *buf, uint16_t len)
{
    if (len < 4)
    {
        return -1;
    }

    for (uint16_t i = 0; i + 3 < len; i++)
    {
        if (buf[i] == '\r' && buf[i + 1] == '\n' &&
            buf[i + 2] == '\r' && buf[i + 3] == '\n')
        {
            return (int)(i + 4);        /* 跳过 \r\n\r\n */
        }
    }

    return -1;                          /* 继续收 */
}

这段是"半包"问题的核心:只要没找到 \r\n\r\n,就说明头部还没收完,必须继续收,而不是急着自己去解析。

解析状态码:

/**
 * @brief  解析状态行 "HTTP/1.1 200 OK" 中的状态码
 * @return 200~599 状态码;-1 表示格式不对
 */
static int http_parse_status(const char *rsp)
{
    const char *p;

    if (strncmp(rsp, "HTTP/", 5) != 0)
    {
        return -1;
    }

    p = strchr(rsp, ' ');               /* 跳过 "HTTP/1.1" */
    if (p == NULL)
    {
        return -1;
    }

    return atoi(p + 1);                 /* "200 OK" -> 200 */
}

解析 Content-Length:

/**
 * @brief  在响应头中查找 Content-Length
 * @return >=0 长度;-1 表示没有这个头
 */
static long http_parse_content_length(const char *hdr)
{
    const char *p = strstr(hdr, "\r\nContent-Length:");

    if (p == NULL)
    {
        return -1;
    }

    return atol(p + strlen("\r\nContent-Length:"));
}

上面为了看清逻辑只匹配了一种大小写。实际代码里头部字段名是大小写不敏感的,服务器可能发 content-length,所以最好自己写一个忽略大小写的查找函数,或统一把头部转成小写再查。

8.5 ESP8266 AT 指令流程
AT                                  → OK
AT+CWMODE=1                         → OK              (Station 模式)
AT+CWJAP="ssid","password"          → OK / WIFI GOT IP
AT+CIPMUX=0                         → OK              (单连接模式)
AT+CIPSTART="TCP","192.168.1.100",80 → CONNECT / OK
AT+CIPSEND=<请求字节数>              → >
<发送 <请求字节数> 个字节>            → SEND OK
接收:+IPD,<len>:<data>             → 按 len 取出 data
AT+CIPCLOSE                         → OK

几个必须注意的点:

点说明
AT+CIPSEND=<len> 的 len是后面要发的字节数,必须和实际发出的字节数严格一致,少发会一直等,多发会被截断
等 > 再发数据收到 > 提示符后才能发,否则数据会被当成 AT 指令
等 SEND OK表示模组已经把数据交给 TCP,不代表服务器收到
+IPD 的解析+IPD,<len>: 后面紧跟 len 字节的原始数据,数据里可能包含 \r\n 甚至 OK 字样,不能按行读,必须按长度读
AT+CIPMODE=1透传模式,适合下载大文件(固件),此时不需要每条数据都 +IPD 封装
超时与重连CIPSTART 失败要看 ERROR / CLOSED,需要重试建连,不要只重发 HTTP

+IPD 里的数据是原始二进制。用 strstr() 按字符串找 OK 会被 Body 里的内容骗到,这是 AT 解析最典型的坑。正确做法是:先按 +IPD, 解析出长度,再 memcpy 出精确的 len 个字节。

8.6 一个完整的 POST 示例

下面这段把"拼请求 → 发送 → 收响应 → 判长度"串起来。串口收发部分用 esp_* 抽象,换成你自己的 AT 层即可。

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

/* 由 AT 层实现:发送原始字节 / 读取指定字节数(带超时) */
extern int esp_tcp_send(const uint8_t *data, uint16_t len);
extern int esp_tcp_recv(uint8_t *buf, uint16_t max_len, uint32_t timeout_ms);

#define HTTP_RSP_BUF_SIZE   2048
#define HTTP_HDR_MAX_SIZE   1024

/* 多留 1 字节放字符串结束符,便于之后用 strstr / atoi 等函数 */
static uint8_t s_rsp_buf[HTTP_RSP_BUF_SIZE + 1];

/**
 * @brief  组一个 JSON 的 POST 请求
 * @return 请求总字节数;<0 表示缓冲区不够
 */
static int http_build_post_json(char *out, size_t out_size,
                                const char *host, const char *path,
                                const char *json)
{
    int body_len = (int)strlen(json);
    int n;

    n = snprintf(out, out_size,
                 "POST %s HTTP/1.1\r\n"
                 "Host: %s\r\n"
                 "User-Agent: STM32H7-ESP8266/1.0\r\n"
                 "Content-Type: application/json\r\n"
                 "Content-Length: %d\r\n"
                 "Connection: close\r\n"
                 "\r\n"
                 "%s",
                 path, host, body_len, json);

    if (n < 0 || (size_t)n >= out_size)
    {
        return -1;                      /* 缓冲区不足,请求被截断 */
    }

    return n;
}

/**
 * @brief  上报一条传感器数据
 * @return 0 成功;<0 失败
 */
int http_report_sensor(float temp, float humi)
{
    char req[384];
    char json[96];
    int  req_len;
    int  body_start = -1;
    int  status;
    long content_len;

    /* 1. 组 Body:用 snprintf 控制小数位,避免 %f 输出过长 */
    snprintf(json, sizeof(json), "{\"temp\":%.1f,\"humi\":%.0f}", temp, humi);

    /* 2. 组请求 */
    req_len = http_build_post_json(req, sizeof(req),
                                   "192.168.1.100", "/api/sensor", json);
    if (req_len < 0)
    {
        return -1;
    }

    /* 3. 发送(AT 层要保证:CIPSEND 的长度 == req_len) */
    if (esp_tcp_send((const uint8_t *)req, (uint16_t)req_len) != 0)
    {
        return -2;
    }

    /* 4. 收响应:先收到头部结束,再按长度收 Body */
    uint16_t total = 0;

    while (total < HTTP_RSP_BUF_SIZE)
    {
        int got = esp_tcp_recv(&s_rsp_buf[total],
                               (uint16_t)(HTTP_RSP_BUF_SIZE - total),
                               3000);
        if (got <= 0)
        {
            break;                      /* 超时或连接关闭 */
        }

        total += (uint16_t)got;
        s_rsp_buf[total] = '\0';        /* 便于后续用字符串函数 */

        body_start = http_find_body_start(s_rsp_buf, total);
        if (body_start > 0)
        {
            if (body_start > HTTP_HDR_MAX_SIZE)
            {
                return -3;              /* 头部异常长,按协议错误处理 */
            }
            break;                      /* 头部已收全 */
        }
    }

    if (total == 0 || body_start < 0)
    {
        return -4;                      /* 一条完整头部都没收到 */
    }

    /* 5. 解析状态码 */
    status = http_parse_status((const char *)s_rsp_buf);
    if (status < 0)
    {
        return -5;
    }

    /* 6. 只有 2xx 才去解析 Body */
    if (status < 200 || status >= 300)
    {
        /* 4xx/5xx 建议打印出来,方便定位。注意 Body 可能是 HTML 错误页 */
        return status;
    }

    /* 7. Content-Length 分支(本示例只处理这一种,chunked 见 8.3 节) */
    content_len = http_parse_content_length((const char *)s_rsp_buf);
    if (content_len >= 0)
    {
        while ((long)(total - body_start) < content_len)
        {
            int got = esp_tcp_recv(&s_rsp_buf[total],
                                   (uint16_t)(HTTP_RSP_BUF_SIZE - total),
                                   3000);
            if (got <= 0)
            {
                return -6;              /* Body 没收全 */
            }
            total += (uint16_t)got;
        }

        s_rsp_buf[total] = '\0';
        /* 此处 s_rsp_buf + body_start 即完整 Body,长度 content_len */
    }

    return 0;
}

这段代码里有几个工程上必须守住的点:

点原因
snprintf 后检查返回值请求被截断时长度就对不上了,会连带 Content-Length 出错
用 %.1f 而不是 %f默认 %f 输出 6 位小数,Body 变长、Content-Length 跟着变,浪费流量
Body 收满 content_len 才结束不能"读到一次就以为完了"
缓冲区留出 +1 给 \0strstr 等函数依赖终止符
500 与 4xx 也打印 Body服务器常把错误原因写在 Body 里,比状态码有用
8.7 内存与时序的几条经验
项建议
缓冲区放哪大缓冲区用 static 或堆,不要放栈(默认栈往往只有几 KB)
缓冲区多大上报类请求 512 字节够;带响应解析建议 2 KB 起
单次超时建连和收发分别设超时,别用一个总超时
重试用退避失败后 1s、2s、4s 递增,避免把服务器打挂
上报周期有变化才上报,或做批量合并,省流量也省服务器
长时间在线用 Connection: keep-alive 减少握手,但要处理服务器主动断开(收到空包要重连)
8.8 HTTP 还是 MQTT

如果设备只是偶尔上传一次数据,HTTP POST 非常合适。如果设备需要长期在线,服务器还要实时下发控制命令,那么 MQTT 更合适。

HTTP 更像是:

客户端问,服务器答

MQTT 更像是:

设备订阅主题,有消息就通过 Broker 推送
对比项HTTPMQTT
通信模型请求-响应(单向发起)发布-订阅(可双向)
谁主动只有客户端能发起服务器可主动下发
连接开销每次请求都有头部(几百字节)固定头部最小 2 字节
实时下发要轮询,或靠长轮询天然支持推送
适合场景周期上报、拉配置、下载固件实时控制、多设备广播、弱网长连接

选型结论:上报用 HTTP,控制用 MQTT。很多云平台两者都提供,设备可以既上报又订阅。


9. 反过来:设备做 HTTP Server

前面讲的都是设备当客户端。还有一种常见需求:设备当服务器,让浏览器或手机直接访问设备。

典型用途:

  • 局域网配置页(填 Wi-Fi 名称密码、设服务器地址);
  • 调试页面(实时看传感器数值、看寄存器);
  • 产测页面(点一下就执行自检)。

实现方式:

方式说明
lwIP httpdlwIP 自带的精简 HTTP 服务器,配合 fsdata.c 存放静态页面,用 CGI/SSI 做动态数据
自己写 socket 服务器监听 80 端口,解析请求行,返回固定字符串。够用且完全可控
模组自带 Server部分 Wi-Fi 模组支持 AT 开 TCP Server,但 HTTP 要自己解析

最小实现的核心就是"解析请求行,返回一段固定响应":

/* 收到 "GET /api/status HTTP/1.1" 后,回一段 JSON */
static const char *s_status_rsp =
    "HTTP/1.1 200 OK\r\n"
    "Content-Type: application/json\r\n"
    "Content-Length: 23\r\n"
    "Connection: close\r\n"
    "\r\n"
    "{\"temp\":26.5,\"humi\":60}";

注意点:

  • Content-Length: 23 必须手工数准(这段 Body 正好 23 字节),或者用 snprintf 把长度一起算出来。数错的话浏览器会一直转圈等剩余字节;
  • 设备做服务器时并发连接数要限制(lwIP 内存池有限),一般 1~4 个就够;
  • 浏览器会额外请求 /favicon.ico,要么返回 204,要么直接忽略,否则日志会被刷屏;
  • 没有文件系统时,静态页面用 C 数组存(lwIP 的 fsdata.c 就是干这个的)。

10. OTA 升级中的 HTTP

OTA 是 HTTP 在嵌入式里最"重"的用法,也是最能体现协议细节的地方。

10.1 基本流程
1. 查版本:GET /api/firmware/latest            → 拿最新版本号、大小、校验值
2. 比对  :与本地版本比较,不同才升级
3. 下载  :分段 Range 请求,每段写入 Flash 对应位置
4. 校验  :整包 CRC32 / SHA256 校验
5. 置标志:写"待升级"标志到备份区
6. 重启  :Bootloader 检查标志,搬运/切换分区
10.2 为什么必须用 Range

固件动辄几百 KB,MCU 的 RAM 只有几百 KB,不可能把整个固件收进内存。所以必须"收一段、写一段"。而一旦下载中途断网,下次要能从断点继续,否则每次都从头下,弱网下永远下不完。

GET /firmware/app_v1.2.3.bin HTTP/1.1
Host: ota.example.com
Range: bytes=65536-131071
Connection: close

服务器支持时返回:

HTTP/1.1 206 Partial Content
Content-Range: bytes 65536-131071/524288
Content-Length: 65536

关键判断:

响应含义设备端动作
206 + Content-Range支持断点续传继续按段下载
200 + 完整长度服务器不支持 Range,返回了整包只能从 0 开始整体下载,或换镜像源
416请求范围非法(超出文件大小)检查本地记录的总长度是否过期

先发 HEAD 可以拿到 Accept-Ranges: bytes 和总长度,避免盲试:

HEAD /firmware/app_v1.2.3.bin HTTP/1.1
Host: ota.example.com

断点记录必须落盘:把"已下载到第几字节"和"总长度"写进 Flash/EEPROM,否则重启后不知道从哪继续。同时记录固件版本,版本变了就作废重下。

10.3 下载循环的骨架
#define FW_CHUNK_SIZE   4096

/* 伪代码:分段下载并写入 Flash */
for (uint32_t offset = fw_resume_offset; offset < fw_total_size; )
{
    uint32_t end = offset + FW_CHUNK_SIZE - 1;
    if (end >= fw_total_size)
    {
        end = fw_total_size - 1;
    }

    /* 1. 组 Range 请求并发送 */
    build_range_request(offset, end);

    /* 2. 收到 206 才能继续;收到 200 说明服务器不支持 Range */
    if (http_get_status() != 206)
    {
        fw_abort();
        break;
    }

    /* 3. 收满这一段,边收边写 Flash */
    if (flash_write(fw_flash_addr + offset, rx_buf, rx_len) != OK)
    {
        fw_abort();
        break;
    }

    /* 4. 更新断点(建议每段都写一次,掉电最多重下这一段) */
    fw_save_progress(offset + rx_len, fw_total_size);

    offset += rx_len;
}

几个工程细节:

细节说明
段大小4 KB 是常见折中:太小则请求次数多,太大则 RAM 和超时压力大
边收边写不要先收完整段再写,边收边写可以省一半内存
Flash 擦除粒度擦除按扇区(几 KB 到几十 KB),写入前要保证目标扇区已擦
掉电安全断点记录要在"写完这一段之后"更新,宁可重下也不要跳字节
校验整包 SHA256 最稳;资源紧时用 CRC32,但要确认服务器也提供同样的值
失败回退升级失败要能启动旧固件(A/B 分区或备份区)

11. 常见问题排查

11.1 现象 → 原因对照
现象最可能的原因排查手段
服务器不响应,一直超时少了结尾空行(没有 \r\n\r\n);或用了 \n 而非 \r\n把请求字符串按十六进制打印,检查结尾是不是 0D 0A 0D 0A
400 Bad RequestContent-Length 与 Body 字节数不符;JSON 非法;请求行多了空格用 strlen(body) 核对长度;把完整请求贴到 Postman 里复现
411 Length RequiredPOST 没带 Content-Length补上,值等于 Body 字节数
413Body 超过服务器限制减小批量、改成分片上传
415Content-Type 不被接受改成服务器要求的类型,通常是 application/json
401Token 缺失/过期/格式不对检查 Authorization: Bearer 后面有没有多余空格
404路径写错;把 #fragment 拼进了路径;多了一个 /逐字符核对 path
405方法用错确认接口要求 GET 还是 POST
收到 200,但数据不全没按 Content-Length 收完就返回了循环收到 body_len 字节为止
一直等不到结束响应是 chunked,但代码在等 Content-Length检查响应头有没有 Transfer-Encoding
数据越收越乱 / 读到上一条的尾巴没处理半包粘包,缓冲区没清理实现 8.4 节的增量解析状态机
偶发失败、重试就好上拉/供电/信号完整性;或服务器限流看是否集中在特定时段;加退避重试
HTTPS 握手失败时间没同步导致证书"未生效/已过期";RAM 不足;证书链不全先对时;加大堆;开日志看 mbedTLS 错误码
中文显示乱码没按 UTF-8 编码;或 Content-Type 没带 charset=utf-8确认源文件编码,Body 用 UTF-8
请求发出后模组回 ERRORAT+CIPSEND 的长度与实际发送字节数不一致打印实际发送长度与声明长度对比
11.2 调试方法
  1. 先打印再发送:把要发的请求 printf("%s", req) 出来,用 \r\n 可见的方式检查(比如替换成 <CR><LF> 打印)。
  2. 用 Postman / curl 复现:把同样的请求在 PC 上发一遍,能通说明是设备端拼包问题,不通是服务端或网络问题。
  3. 抓包分层看:先确认 TCP 建连成功(三次握手),再看有没有发出数据,最后看服务器有没有回。
  4. 先降级再排查:换成 HTTP(不用 HTTPS)、换成 GET(不用 POST)、换成内网 IP(不用域名),逐个排除变量。
  5. 看服务器日志:User-Agent 里带了设备型号和固件版本,服务器侧能直接筛出这批设备。

12. 小结

主题一句话结论
本质应用层文本协议,请求-响应模型,底层靠 TCP 保证可靠
结构起始行 + 头部 + 空行 + Body,头部结束标志是 \r\n\r\n
请求行方法 路径 版本,三者之间单个空格
状态行版本 状态码 原因短语,看首位数字判责任方
头部名字大小写不敏感、冒号前不能有空格;Host 在 1.1 必填
Body 长度请求侧靠 Content-Length;响应侧有四种判定规则(HEAD/204/304、chunked、Content-Length、读到关闭)
状态管理HTTP 无状态,靠 Cookie/Session/Token 补;设备端优先用 Token
版本选择嵌入式首选 HTTP/1.1;HTTPS 的代价主要在 RAM、时间和证书
落地方式AT 模组最省事;lwIP Socket 最可控
三个高频坑\r\n 写错、Content-Length 算错、响应没收全
OTA必须用 Range 分段 + 落盘断点,边收边写 Flash
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值