bigywater.tech
网络协议 · 84

HTTP/2 帧:二进制分帧与流

HTTP/2 不再是文本,而是一个个二进制的帧,每帧 9 字节帧头 + 载荷,所有请求响应都在同一条连接的“流”里。这篇不借助任何 HTTP/2 库去收发请求,而是手工拼帧、手工读帧,看连接怎么开头、请求怎么压缩、多个流怎么共存、出错怎么回。

9 字节
HTTP/2 每个帧的固定帧头(长度 3 + 类型 1 + 标志 1 + 流标识 4)
32 vs 67 字节
HTTP/2 的 GET 请求 HEADERS 帧与等价的 HTTP/1.1 文本请求
151~153 ms
5 个 PING 帧的应用层往返时间

开场

实测 · 本机执行输出
  ALPN 协商结果:h2;TLS TLSv1.3
  客户端发出:前言 b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'(24 字节)+ 一个 SETTINGS 帧(21 字节:9 字节帧头 + 2 个设置 × 6 字节)
  SETTINGS 帧的字节:00 00 0c 04 00 00 00 00 00 00 03 00 00 00 64 00 04 00 00 ff ff
    = 00 00 0c(长度 12)| 04(类型 SETTINGS)| 00(标志)| 00 00 00 00(流 0)| 00 03 00 00 00 64(MAX_CONCURRENT_STREAMS=100)| 00 04 00 00 ff ff(INITIAL_WINDOW_SIZE=65535)
  服务器 → SETTINGS      流 0  长度 18    标志 -  MAX_CONCURRENT_STREAMS=100, INITIAL_WINDOW_SIZE=65536, MAX_FRAME_SIZE=16777215
  服务器 → WINDOW_UPDATE 流 0  长度 4     标志 -  窗口增量 2147418112
  服务器 → SETTINGS      流 0  长度 0     标志 ACK
  客户端对服务器的 SETTINGS 回了一个空的 SETTINGS ACK 帧(已发送):9 字节 = 00 00 00 | 04 | 01 | 00 00 00 00

24 字节前言 + SETTINGS;服务器回 SETTINGS、WINDOW_UPDATE 与 ACK,客户端也要 ACK 它的 SETTINGS。

请求与响应

实测 · 本机执行输出
  请求:HEADERS 帧 流 1,标志 END_HEADERS+END_STREAM(没有消息体),头部块 23 字节(HPACK),整个帧 32 字节;等价的 HTTP/1.1 文本请求 67 字节
  头部块的字节:82 87 84 41 88 2f 91 d3 5d 05 5c 87 a7 7a 83 f0 f3 5f 53 83 f9 63 e7
  服务器 → HEADERS       流 1  长度 160   标志 END_HEADERS  头部 :status: 200, date: Thu, 15 Oct 2026 10:34:08 GM, content-type: text/html; charset=utf-8, server: cloudflare, last-modified: Tue, 13 Oct 2026 22:05:48 GM, allow: GET, HEAD …共 11 个
  服务器 → DATA          流 1  长度 577   标志 -
  服务器 → DATA          流 1  长度 0     标志 END_STREAM
  响应的头部块 160 字节(HPACK 压缩后),数据 577 字节;帧数 3;每个帧还要加 9 字节帧头

请求一个 HEADERS 帧,头部块用 HPACK 压缩;响应 HEADERS + DATA,结尾带 END_STREAM 标志。

多个流

实测 · 本机执行输出
  收到的帧序列(类型@流):HEADERS@3 DATA@3 HEADERS@5 DATA@5 DATA@5
  流 3 的帧 2 个,流 5 的帧 3 个;两条流各自以带 END_STREAM 标志的帧结束

客户端发起的流 ID 是奇数;帧通过流 ID 区分属于哪个请求。

PING

实测 · 本机执行输出
  5 个 PING 的往返:[151, 152, 153, 152, 152] ms;载荷原样返回:True;这就是应用层(HTTP/2)能看到的往返时间

8 字节载荷,原样返回,用于保活和测 RTT。

出错

实测 · 本机执行输出
  服务器 → SETTINGS      流 0  长度 18    标志 -  MAX_CONCURRENT_STREAMS=100, INITIAL_WINDOW_SIZE=65536, MAX_FRAME_SIZE=16777215
  服务器 → WINDOW_UPDATE 流 0  长度 4     标志 -  窗口增量 2147418112
  服务器 → SETTINGS      流 0  长度 0     标志 ACK
  服务器 → HEADERS       流 1  长度 57    标志 END_HEADERS  头部 :status: 400, server: cloudflare, date: Thu, 15 Oct 2026 10:34:11 GM, content-type: text/html, content-length: 155, cf-ray: -
  服务器 → DATA          流 1  长度 155   标志 END_STREAM
    DATA 内容:b'<html>\r\n<head><title>400 Bad Request</title></head>\r\n<body>\r\n<center><h1>400 Bad Request</h1></center>\r\n<hr><center>clou'

缺少 :method 时 example.com 回 400 而不是 RST_STREAM / GOAWAY。

分步演示

HTTP/2 帧
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。全部是对 example.com:443 的真实 TLS 连接(ALPN h2),帧是我手工拼的和手工读的,HPACK 编解码用了 Python 的 hpack 库;头部解码显示时把每个值截断到 28 个字符;畸形请求只发了一次,在单独的新连接上
    • 没有验证:流量控制(WINDOW_UPDATE 实际限速,下一篇);HPACK 动态表的细节(下一篇);优先级(PRIORITY 帧与 RFC 9218);服务器推送(PUSH_PROMISE 在主流服务器上已关闭);大响应下多流的真实交错;其它服务器对畸形请求的反应;CONTINUATION 帧
    京ICP备2026067502号-1京公网安备11010502063487号