hyperframes 与 Python HTTP/2 帧解析实战

发布时间:2026/10/7 9:27:28
hyperframes 与 Python HTTP/2 帧解析实战 聊 HTTP/2绕不开一个词hyperframes。在 Python 生态里这就是 hyperframe 库生成和解析的 HTTP/2 帧对象也是 h2 协议栈最底层的那一层。很多人用 requests 或 httpx 能发 HTTPS 请求但一旦要自己写抓包工具、改造代理、分析长连接就会被“帧”卡住。这篇文章我想从一个协议实现者的角度把 hyperframes 拆开帧长什么样、怎么解析、踩过哪些坑以及怎么把它用在自己的工具里。适合想深入 HTTP/2 的 Python 开发者也适合即将开始写网络协议的初学者。1. hyperframes 到底在解决什么问题1.1 HTTP/2 为什么需要帧HTTP/2 和 HTTP/1.1 最大的区别不是“快”这么简单而是把原来的文本协议变成二进制分帧协议。HTTP/1.1 里一个请求一个响应靠\r\n和Content-Length分隔头部重复、队头阻塞连接利用率低。HTTP/2 允许一条 TCP 连接上同时跑很多个请求/响应也就是多路复用但 TCP 本身只是一串字节流没有消息边界。如果没有帧接收方根本不知道这一堆字节里哪一段属于请求 A哪一段属于响应 B更不知道一个头部结束在哪。帧就是给这段字节流画出来的格子。HTTP/2 把数据切成一格一格的 Frame每一格都写上 Stream ID相当于在共享快递车上贴了不同的订单号。到了接收端按订单号拆开、排序、组合就能恢复出每个请求和响应。hyperframes 这个名字在我理解里就是“这些帧的集合”——hyperframe 库则是专门操作这一格一格 Frame 的 Python 工具。1.2 hyperframe 在 Python HTTP/2 生态里的位置Python 社区里做 HTTP/2 常用到的库有几个最典型的是 h2 和 hyperframe。h2 是一个完整的 HTTP/2 协议实现管理连接状态、流状态、头部压缩、流量控制hyperframe 则比 h2 更底层它只负责 Frame 的构造、序列化和解析不涉及任何状态机逻辑。两者关系可以类比成“编解码器和协议栈”h2 说“这个请求该发出来了”hyperframe 回答“好那我把它变成字节塞进 TCP”反过来收到字节时也是 hyperframe 先变成 Frame再交给 h2 判断下一步该做什么。这个定位让 hyperframe 特别适合做实验和工具开发。比如你要写一个 HTTP/2 抓包解析器、一个畸形帧测试工具或者只是想搞清楚某个帧结构直接用它比用完整协议栈清爽得多。2. 核心原理一个 HTTP/2 帧是怎么拆开的2.1 九字节固定头所有 HTTP/2 帧都有一个固定 9 字节的头部后面再跟一个长度由头部指定的 payload。这个结构是理解 hyperframes 的核心九字节一点都不能错。首先是前 3 个字节以大端序表示整个帧的总字节数注意是 payload 长度不包含这 9 字节头部。最大能表示 2^24-1也就是 16777215 字节。为什么是 24 位而不是 32 位HTTP/2 设计时认为帧大小超过 16MB 没太大意义而且也要给上层设置 MAX_FRAME_SIZE 留出空间所以用 3 字节足够。第 4 个字节是帧类型常见有 DATA、HEADERS、SETTINGS、PING、GOAWAY 等具体值见下一小节。第 5 个字节是标志位8 个 bit 每一位含义由帧类型决定比如 END_STREAM、END_HEADERS、ACK 都是标志位。第 6 到第 9 字节是 Stream ID32 位里最高 1 位必须是 0实际只有 31 位有效因此收到字节后要用 0x7FFFFFFF把最高位去掉否则会解析成负数或超大数。可以用一段很直白的 Python 来解析头部def parse_frame_header(data: bytes) - tuple: length int.from_bytes(data[0:3], big) frame_type data[3] flags data[4] stream_id int.from_bytes(data[5:9], big) 0x7FFFFFFF return length, frame_type, flags, stream_id这段代码和 hyperframe 内部逻辑是等价的区别在于 hyperframe 会根据 frame_type 直接返回不同类型的 Frame 对象而这里只给你四个数字。2.2 十种帧类型速查HTTP/2 规范最初定义了 10 种帧类型。我把它们列成一张表方便以后排查问题类型值名称方向主要用途0x0DATA双向传输请求/响应体0x1HEADERS双向传输头部块0x2PRIORITY双向调整流优先级0x3RST_STREAM双向终止一条流0x4SETTINGS双向协商连接参数0x5PUSH_PROMISE服务端到客户端服务端推送声明0x6PING双向心跳/RTT 测量0x7GOAWAY双向优雅关闭连接0x8WINDOW_UPDATE双向流量控制窗口更新0x9CONTINUATION双向继续传输头部块实际抓包时你最常见的组合是客户端先发 SETTINGS紧接着可能发 WINDOW_UPDATE然后发 HEADERS CONTINUATION再发 DATA服务端那边一般会有 SETTINGS、HEADERS、DATA、GOAWAY。大部分笔试和面试里喜欢问的也是“PING 帧的 stream_id 是多少”答案是 0因为它是连接级帧。2.3 连接级帧与流级帧Frame 的 Stream ID 有讲究不是随便填。Stream ID 为 0 的帧作用于整条连接常见的有 SETTINGS、PING、GOAWAY、WINDOW_UPDATEStream ID 大于 0 的帧作用于某个流常见的有 DATA、HEADERS、RST_STREAM、PUSH_PROMISE。还有一种比较特殊PRIORITY 和 RST_STREAM 作用在流上但 stream_id 必须跟所在流一致而 WINDOW_UPDATE 既可以是连接级stream_id0也可以是流级stream_id0。理解这一点对解析 hyperframes 很有帮助。比如你写一个通用解析器看到 SETTINGS 但 stream_id 不是 0基本可以判定不是正常实现要么是抓包工具没对齐帧边界要么对方协议栈实现有问题。看到 PING 带 payload 超过 8 字节也一定有问题因为 PING 帧的 payload 固定 8 字节。这类异常检验才是真正会写协议工具的人该做的。3. 用 hyperframes 做一套帧解析实战3.1 先搭好解析骨架纸上谈兵没用直接动手。安装 hyperframe 只需要一条命令pip install hyperframe然后准备一段原始字节。以下是一帧完整的 RST_STREAM00 00 04 03 00 00 00 00 01 00 00 00 08头部里00 00 04表示 payload 长度 403表示 RST_STREAM00表示没有标志位00 00 00 01表示 stream_id1。后面00 00 00 08是 payload表示错误码 8CANCEL。如果你只用 Python 标准库写一个通用循环import sys def dump_http2_frames(data: bytes): offset 0 while offset 9 len(data): length int.from_bytes(data[offset:offset3], big) frame_type data[offset3] flags data[offset4] stream_id int.from_bytes(data[offset5:offset9], big) 0x7FFFFFFF payload data[offset9:offset9length] print(foffset{offset:06x} type0x{frame_type:02x} flags0x{flags:02x} fstream_id{stream_id} payload_len{length} payload{payload.hex()}) offset 9 length if offset ! len(data): print(fwarning: {len(data) - offset} bytes leftover, filesys.stderr) if __name__ __main__: raw bytes.fromhex(00000403000000000100000008) dump_http2_frames(raw)这个骨架不依赖任何第三方库逻辑和 RFC 完全对齐。你把它扩展成按socket.recv分段读取的版本就能直接拿来做抓包分析。3.2 换成 hyperframe 之后长什么样如果想省去手动解 payload就用 hyperframefrom hyperframe.frame import Frame raw bytes.fromhex(00000403000000000100000008) header raw[:9] frame, payload_len Frame.parse_frame_header(header) frame.parse_payload(raw[9:9payload_len]) print(type(frame).__name__) print(frame.__dict__)实际输出会看到一个RstStreamFrame对象里面的 error_code 已经被解析成 8。这就是 hyperframe 的价值它把第 2 节讲的那堆位运算封装成对象同时保留了对原始字节的控制权。需要注意hyperframe 不同小版本的接口名可能微调如果遇到parse_payload不可用优先看安装版本的源码那点代码量不大十分钟就能看完。不要盲目照抄老博客。3.3 组合进一次真实 HTTP/2 握手工具类代码写出来要有用。实际场景里你拿到的不只是单独一帧而是一段 TCP 流里面有客户端 preface、SETTINGS、WINDOW_UPDATE、HEADERS 等。我在本地跑了一个简单的 h2 服务端把收到的字节存成capture.bin然后用上面的dump_http2_frames去解析输出大致是这样offset000000 type0x04 flags0x00 stream_id00000000 payload_len0 payload (SETTINGS) offset000009 type0x04 flags0x01 stream_id00000000 payload_len0 payload (SETTINGS ACK) offset000012 type0x08 flags0x00 stream_id00000000 payload_len4 payload0000ffff (WINDOW_UPDATE)第一帧没有 payload 是因为 SETTINGS 里没有放任何参数这种情况合法第二帧是 ACK第三帧是客户端把连接级窗口从 65535 调到了更大的值0000ffff表示增量是 65535。只要按帧边界一条条走下来握手过程会非常直观。这一段虽然用的是自建示例但方法同样适用于真实流量。抓包的时候先把 HTTP/2 流量从 pcap 里提取成一段纯 payload再跑这个循环就能过滤出所有帧类型、Stream ID、标志位排查握手顺序比用 Wireshark 点鼠标更快。4. 常见问题与排查技巧实录4.1 帧边界不是 TCP 消息边界这是新手最容易懵的地方。TCP 是流不是消息。你调用一次recv可能只收到半帧也可能收到好几帧粘在一起。如果直接按 recv 的返回去解析一定会出现“长度对不上”的错位。正确做法是维护一个接收缓冲区先尝试读满 9 字节头部解析出 payload 长度再继续读满length字节。只有完整拿到 9length 才算是有效的一帧。可以参考这个循环模式def recv_frame(sock): header b while len(header) 9: chunk sock.recv(9 - len(header)) if not chunk: raise EOFError header chunk length int.from_bytes(header[0:3], big) body b while len(body) length: chunk sock.recv(length - len(body)) if not chunk: raise EOFError body chunk return header body我刚开始写代理工具时偷懒直接用一次 recv 去解析结果在弱网环境频繁报“unpack requires a buffer of 9 bytes”排查了很久才发现是边界问题。4.2 Stream ID 别忘记掩掉最高位HTTP/2 帧头的第 5-9 字节里最高位是保留位必须为 0。但是抓包或某些实现里可能给它填了值或者你直接把四个字节转成 int于是看到 stream_id 变成 2 开头的负数导致后续 RST_STREAM 发给错误流。做法很简单解析时加一行stream_id 0x7FFFFFFF。在 hyperframe 里这一步是内置的但如果你是自己写解析器这个细节必须记得。还有一个容易忽略的点客户端的 Stream ID 必须从奇数开始1、3、5...服务端从偶数开始2、4、6...。如果客户端发出一个偶数 Stream ID 的 HEADERS协议栈应该直接报错。这是检查抓包文件是否被手工污染的好技巧。4.3 遇到未知帧类型不要直接崩溃HTTP/2 允许扩展帧类型而且 RFC 7540 明确说收到未知帧类型时如果标志位不涉及连接状态应该忽略而不是直接断开。很多读者自己写解析器时一见到不认识的类型值就抛异常这在兼容性上是有限制的。hyperframe 默认有 UnknownFrame 分支未知类型也会被包成 Frame 对象至少不会让解析直接中断。如果你做的是协议测试工具还应该把这个分支单独记录下来方便发现对方在用什么扩展特性。另外帧类型 0xA 到 0xEF 之间是可用于扩展的0xF0 到 0xFF 是留给实现方自定义的见到这些不要奇怪。4.4 HEADERS 和 CONTINUATION 必须连在一起HTTP/2 有个蛮容易踩的坑HEADERS 帧如果没有带 END_HEADERS 标志后面必须紧跟一串 CONTINUATION 帧中间不能插入其他帧。这是为了保证头部块是连续的一段不然解析方没办法判断头部边界。同理PUSH_PROMISE 也可以带 CONTINUATION。在写状态机时我会维护一个expecting_continuation布尔值。只要上一个是 HEADERS/PUSH_PROMISE 且没有 END_HEADERS下一个到达的如果不是 CONTINUATION就要按协议错误处理。很多老的 HTTP/2 实现之间的不兼容就是从这里来的。4.5 超帧与长度限制HTTP/2 帧的 payload 长度受 SETTINGS 里的 MAX_FRAME_SIZE 约束默认是 16384 字节。所以你可能会在流量里看到一个大 DATA 被拆成多个 DATA 帧。有的抓包工具术语管这组连续分片叫“超帧”superframe别跟 hyperframes 混淆。解析时要分别按帧处理最后在应用层再拼接框架本身不做重组。4.6 排错速查表现象可能原因排查方向解析报错 unpack requires 9 bytesrecv 没读满帧头改为维护接收缓冲区stream_id 出现 2 亿以上没掩保留位加 0x7FFFFFFF收到 PING 但 payload 不是 8 字节帧边界错位或实现错误检查 length 字段HEADERS 后紧跟 DATA 没有 CONTINUATION协议栈有 bug 或抓包篡改标记连接错误UnknownFrame 频繁出现对方使用扩展帧确认扩展帧定义再决定5. 最后再分享两个我实际踩过的坑5.1 从长度字段开始排查做网络协议工具很多时候问题不在“代码不会写”而在“字节对不上”。我自己最常用的调试手段是把帧序列打印成十六进制先用肉眼对比头部长度字段再跑解析器。比如构造一个 SETTINGS 帧时经常手滑把长度写成 0但实际 payload 里有参数这一帧后面所有解析全都会错位。先看第一个字节00 00 xx有没有对应上后面的 payload 长度能省很多排查时间。5.2 别绕过 h2 的状态机另一个坑是把 hyperframe 和 h2 混着用的时候一定不要手动修改 Frame 对象后直接塞给 h2 连接对象。h2 内部有状态机它期望的是自己创建和维护的帧对象你绕过状态机去发一个本不合适的 PUSH_PROMISE轻则被对方拒绝重则整个连接 RST_STREAM。我的建议是协议栈的“行为”交给 h2帧的“形状”交给 hyperframe两层各司其职出了问题也好定位。这套方法做下来你会发现 HTTP/2 其实没那么玄。把帧边界、类型、Stream ID 这三个基本盘抓稳后面再去看流量控制、头部压缩、优先级调度都是顺着同一根线往下走的事。