HTTP/2 帧解析实战:用 hyperframe 读懂每一个二进制字节 上个月排查一个内网 gRPC 网关的问题Wireshark 里看得清清楚楚客户端发来一个 HEADERS 帧流 ID 是 3带 END_HEADERS服务端回了个 RST_STREAM错误码 PROTOCOL_ERROR。抓包软件看协议很爽可问题是程序不知道大量报错日志和抓包对不上最后只能自己动手把字节流按帧拆开去对。也就是那天我把 hyperframe 从头到尾用了一遍顺手写了个帧日志工具解决了一大类抓包正常但程序不知道错在哪的问题。hyperframes 这个词很多人第一眼会以为是超帧之类的高大上概念其实它就是 Python HTTP/2 生态里最底层的那块砖hyperframeHTTP/2 帧层的标准实现。它做的事一句话能说清——把内存里的帧对象变成线上传输的字节流再把收上来的字节流还原成可读的帧对象。没有它上层 h2 库根本没法工作而如果你要自己写协议分析器、自己做服务端健壮性测试或者单纯想把 RFC 7540 / RFC 9113 里的帧格式彻底吃透它就是你最好的入手点。1. hyperframes 到底是什么HTTP/2 帧层库的定位与生态位1.1 Python HTTP/2 生态的分工h2、hpack、hyperframe 各管哪一段Python 的 HTTP/2 生态不是一个库而是一组分层明确的库名字都带着hyper前缀。很多人第一次接触时容易混我先把这个地图理清楚。库名职责对应协议层次h2高层协议引擎负责连接状态机、流状态、流量控制协调、发送时序HTTP/2 协议逻辑hpack只做 HPACK 头块压缩处理请求头/响应头的二进制编码HPACK 压缩hyperframe只做帧级别的二进制编解码不管状态、不管流控HTTP/2 帧层如果拿物流公司打比方h2是调度中心知道哪个包裹该往哪条线上送、什么时候送hpack是给包裹贴条码的机器负责把长标签压成短码hyperframe则是仓库入口的打包机/拆包机——它只负责把包裹按统一尺寸的箱子装好或者把收到的箱子拆开至于箱子里东西是不是违禁品、该不该拒收它不管。这个不管是 hyperframe 最关键的定位。它不知道流是打开的还是关的不知道窗口还剩多少更不知道 HEADERS 帧里那串压缩数据解出来是什么。它只关心一件事Frame对象和bytes之间的转换以及转换过程中帧格式本身是否合法。名字的命名逻辑也很直白hyper是项目名frame是帧合起来就是hyper 项目旗下的帧模块不是超帧的翻译。而标题里写成复数 hyperframes实际上正是这个库最常见的用法——你会同时持有多个帧对象一个连接上的请求、响应、控制帧挤在同一个字节流里解析出来就是一串Frame实例这些实例放在列表里可不就是 hyperframes 么。1.2 什么时候需要自己下场玩帧Wireshark、Scapy 和手写 struct 的局限既然已经有了 h2 这个高层库什么时候需要自己碰帧层第一个场景就是排错。Wireshark 适合人工分析但它没法嵌进你的程序里做自动化断言。你没法在测试用例里写这里必须出现一个 SETTINGS ACK否则报错也没法把 Wireshark 的界面接到监控系统里。程序要处理的是原始字节你需要的是能读能写的帧对象。第二个场景是协议测试。正常客户端不会发畸形帧但服务端必须能优雅处理畸形帧。用 hyperframe 构造一个带着非法组合标志位的 HEADERS 帧丢给本地起的测试服务然后看它到底回 GOAWAY 还是崩掉这个玩法是 h2 库给不了的——h2 作为一个规范实现会主动阻止你构造不规范的帧而 hyperframe 只做编解码反而给了你自由度。第三个场景是学习。RFC 9113 讲帧格式讲得极其枯燥全是字段位、掩码、保留位。但你把 hyperframe 当成对照表一边读 RFC 一边构造各种帧看 hex 输出很快就能建立直观印象。至于为什么不用 Scapy 或手写 structScapy 的强项是 L2/L3 报文对 HTTP/2 这种带状态和多位域语义的协议支持远不如专用库手写 struct 当然也可以但你要处理 3 字节大端长度、32 位流 ID 的最高位掩码、flags 按帧类型隔离这些细节每个坑都要自己踩一遍。有现成的、被 h2 项目本身验证过的帧层库没必要重复造轮子。2. 帧格式的核心9 字节帧头里的二进制细节与十种帧类型2.1 帧头逐字段拆解三字节长度、保留位和 31 位流标识HTTP/2 里所有帧长得都一样先是 9 字节固定帧头后面跟着可变长度 payload。帧头是理解整个协议的门槛我把每个字节掰开讲。偏移长度字段说明03Lengthpayload 长度不含帧头自身24 位无符号大端整数31Type帧类型8 位标准类型 0x0 ~ 0x941Flags标志位8 位逐位解释取决于帧类型54R Stream Identifier第 1 位是保留位 R必须为 0后 31 位是流 ID三字节长度字段是典型的看着简单但容易写错的地方。它叫 Length但只算 payload不算前 9 个字节的帧头。也就是说一个总长 21 字节的 SETTINGS 帧Length 字段的值是 12 而不是 21。这个 24 位无符号整数的理论最大值是 16777215但实际协议里单个帧的长度会被 SETTINGS_MAX_FRAME_SIZE 限制默认只有 16384一次能协商到 16777215。Type 字段只有 8 位值的含义是全局的不分方向。客户端和服务端看到 0x4 都必须按 SETTINGS 处理。Flags 字段的 8 位不是全局统一语义的。同一个 0x01在 DATA 帧里是 END_STREAM在 SETTINGS 帧里是 ACK在 PING 帧里也是 ACK。这个在后面第 4 章会重点展开也是几乎所有手写解析器翻车的地方。最容易被忽略的是最后 4 字节。它叫 Stream Identifier但实际只有 31 位可用最高位的 R 是保留位发送时必须为 0收到为 1 时按协议应该当成 PROTOCOL_ERROR。解析时老老实实做int.from_bytes(header[5:9], big) 0x7FFFFFFF这个掩码操作一行代码但漏掉它你会遇到负流 ID 和 21 亿流号同时在日志里出现的诡异场面。2.2 十种标准帧的一次性速查哪些常用、哪些是坑RFC 9113 定义了十种标准帧类型我做了张速查表按日常见到的频率排序Type帧类型典型作用日常频率0x4SETTINGS连接参数协商两端在连接开始交换ACK 标志用于确认极高0x0DATA携带请求/响应 body 字节极高0x1HEADERS携带 HPACK 压缩后的头部块用于打开流、发请求头/响应头极高0x8WINDOW_UPDATE流量控制窗口增量告诉对方可以再发 N 字节高0x6PING连接保活、测量往返中0x7GOAWAY通知连接即将关闭带 last-stream-id低但关键0x3RST_STREAM立即终止某条流带错误码低但关键0x9CONTINUATIONHEADERS 太大时分片发送剩余头部块低0x2PRIORITY流优先级调整依赖树极低0x5PUSH_PROMISE服务端推送预告极低浏览器已基本废弃推送这里有个很重要的认知帧类型只是外壳类型真正语义还得看 payload 结构。GOAWAY 帧的 payload 是4 字节 last-stream-id 4 字节错误码 可选的 debug 文本WINDOW_UPDATE 是4 字节窗口增量最高位保留RST_STREAM 只有4 字节错误码。这些细节 RFC 里全都有但人脑记不住最好的办法就是拿 hyperframe 对着玩一遍把每种帧构造一次、打印 hex、观察结构比背表格管用得多。顺便提一句HTTP/2 服务端推送PUSH_PROMISE现在基本可以当历史遗留看主流浏览器和 gRPC 都不推荐使用。如果你在协议学习资料里看到大段推送内容知道有这回事就行。3. hyperframe 实战从构造 SETTINGS 帧到反解字节流3.1 环境准备与最小依赖只装 hyperframe 就够了hyperframe 是个独立库不依赖 h2、不依赖 hpackpip 装完直接就能用。pip install hyperframe装完验证一下from hyperframe.frame import Frame, SettingsFrame print(SettingsFrame(stream_id0))只要能打印出对象就说明环境没问题。我这边用的是 6.x 版本不同小版本 API 略有差异但核心的parse_body、parse_flags、serialize这几个方法一直很稳定。要特别说明的是hyperframe 默认不做 HPACK 解压。这意味着你从线上抓到的 HEADERS 帧payload 在 hyperframe 眼里就是一段压缩后的字节串它不会帮你解出:method: GET这样的键值对。想解压得另外装hpack库。这个边界一开始就要清楚不然你会以为解析出了问题。3.2 构造一个 SETTINGS 帧并核对线上字节SETTINGS 帧是连接建立后的第一个关键帧拿它练手最合适结构简单stream_id 固定为 0标志位只有一个 ACK。from hyperframe.frame import SettingsFrame frame SettingsFrame(stream_id0) frame.settings[0x3] 128 # SETTINGS_MAX_CONCURRENT_STREAMS frame.settings[0x4] 65535 # SETTINGS_INITIAL_WINDOW_SIZE frame.flags.add(SettingsFrame.ACK) data frame.serialize() print(data.hex())输出会是一串 hex。我这儿就不贴具体值了直接讲怎么对。帧头 9 字节里前 3 字节是 payload 长度也就是 2 个设置项 × 每项 6 字节 12对应00000c第 4 字节是类型 0x4第 5 字节是 flags 0x1因为带了 ACK后 4 字节是流 ID 0x00000000。payload 部分每 6 字节是一组设置项前 2 字节是设置 ID后 4 字节是值。所以你会看到0003 00000080和0004 0000ffff这样两组。用这个方式对一遍 hex你对帧头不含自身长度的理解会特别深。这里有个容易忽略的点SETTINGS 帧带 ACK 标志时payload 必须为空。手动构造时别同时又塞设置项又加 ACK规范下这是非法的真实服务器收到会直接报错。3.3 从字节流还原帧对象parse_body 与 parse_flags 的配合有了字节流下一步就是反过来解析。我在工具里常用的写法是手动读帧头再调用子类的两个关键方法parse_body(body)处理 payloadparse_flags(flags_int)把整数标志位转成语义化的 Flag 对象集合。header data[:9] body data[9:] frame SettingsFrame(stream_id0) frame.parse_body(body) frame.parse_flags(int.from_bytes(header[4:5], big)) print(frame.settings) # {3: 128, 4: 65535} print(frame.flags) # {Flag 0x01: ACK}frame.flags是个集合里面是语义化对象打印出来能直接看到 ACK而不是裸的 0x01。这在日志里非常好用后面写工具时会体现。为什么我习惯手动读帧头而不是让库直接一把梭因为调试协议时你往往需要单独判断帧头声明了多长、实际给了多长、类型是什么、留了哪些标志这个中间状态本身就有信息量。手动读出来打一行日志比丢给库函数黑盒处理更容易定位问题。4. 只有真实解析帧才会踩到的坑Padding、零号流与标志位4.1 带 PADDED 标志的帧长度字段骗了你第一个坑来自 Padding。HTTP/2 允许帧带填充字节目的是混淆报文长度防止流量分析。但填充字节的存在直接改变了 payload 的结构。拿 DATA 帧举例如果 flags 里带了 PADDED0x08那么 payload 的第一个字节是 Pad Length之后才是真正的业务数据最后跟着等长的填充字节。也就是说payload [Pad Length(1字节)] data padding如果你只跟着帧头的 Length 字段切出 body把这个 body 当成完整业务数据塞给上层解析gRPC 解包立刻报错因为业务数据前多了一个 Pad Length 字节、后多了一段填充。hyperframe 的DataFrame.parse_body会帮你把这层处理掉解析完后frame.data是干净的业务数据填充部分被单独放进padding_len属性。所以在写帧日志工具时千万不要直接把body打印出来当请求内容——要打印frame.data。HEADERS 帧的 Padding 更复杂一点因为除了 PADDED 标志它还有 PRIORITY 标志0x20。PRIORITY 标志置位时payload 在 Pad Length 之前还有 5 字节的优先级字段。这几个字段的叠加顺序在 RFC 里有明确表格但人记不住。我的建议是涉及 HEADERS 帧解析时用 hyperframe 处理而不是自己撸结构这个叠加逻辑非常容易错。4.2 零号流与 31 位掩码协议语义比镜像更重要第二个坑是流 ID 的语义规则。协议规定0 号流只能被连接级帧使用DATA、HEADERS、RST_STREAM、WINDOW_UPDATE、CONTINUATION 这些和具体流相关的帧流 ID 绝不能是 0而 SETTINGS、PING、GOAWAY 必须用流 ID 0。hyperframe 作为编解码库它不会替你拦这些语义错误。你完全可以用DataFrame(stream_id0)构造出一个规范上非法的帧序列化后照样能发出去。这是刻意的设计——编解码层不管语义语义由 h2 层或你自己负责。但自己构造测试帧时这个自由度就是双刃剑。我踩过的具体坑模拟客户端发数据把 WINDOW_UPDATE 的流 ID 写成了 0结果服务端直接回了 GOAWAY PROTOCOL_ERROR。查了半天才意识到WINDOW_UPDATE 的流 ID 为 0 表示整个连接级的窗口更新而我想表达的是某个具体流的窗口调整。这两个语义完全不同写错一个数字服务器行为天差地别。另一个容易忽略的是 31 位掩码。R 保留位如果被置 1解析端按协议必须视为连接错误。你解析线上数据时如果不做 0x7FFFFFFF一个带保留位的 4 字节值会被 int.from_bytes 直接读成 32 位整数可能在 21 亿左右也可能因为符号位变成负数。日志里出现这种数字排查起来极其迷惑。还有一条规则要记住客户端主动发起的流 ID 必须是奇数服务端必须是偶数。自己造帧模拟客户端时Stream ID 从 1、3、5 开始用偶数会被服务器判定为协议错误。4.3 同一个 0x01 在不同帧里是不同含义标志位按帧类型隔离第三个坑是标志位的上下文依赖。同一个二进制位在不同帧类型里意义完全不同比特位DATA 帧含义HEADERS 帧含义SETTINGS 帧含义PING 帧含义0x01END_STREAMEND_STREAMACKACK0x04 也类似在 HEADERS 帧里是 END_HEADERS在别的帧里可能压根未定义。很多从 HTTP/1.1 转过来的人会写一个全局函数if flags 0x01: print(END_STREAM)然后发现 SETTINGS 帧也打出了 END_STREAM实际上那个位的语义是 ACK。这种 bug 非常隐蔽因为 0x01 的值没错错的是解释上下文。hyperframe 的做法是给每种帧类型维护一个allowed_flags集合flags 解析时只保留当前帧类型允许的位并转换成带名字的Flag对象。所以打日志时我建议直接序列化frame.flags看到{END_STREAM}你就能确定是流相关帧的语义看到{ACK}就知道这是 SETTINGS 或 PING 的确认。不要自己维护一个全局位解释表那是在给自己埋雷。同时allowed_flags 还能帮你做合法性检查如果你构造帧时想把 DATA 帧的标志位设置成 0x04hyperframe 在序列化时会发现这个位不在 DATA 的允许集里帮你提前暴露错误而不是发到线上被对端打回来。5. 基于 hyperframes 搭建一个 HTTP/2 帧日志工具5.1 工具骨架从原始字节流到格式化帧日志把前面这些经验收拢起来就是一个实打实的帧日志工具。场景有两种本地起了 h2c 明文服务直接用 socket 收字节喂给工具或者是 TLS 加密链路先用抓包工具配置好SSLKEYLOGFILE解出明文 HTTP/2 字节流再存成文件喂给工具。工具的核心逻辑就一段循环读 9 字节帧头取出 Length/Type/Stream ID按 Length 切出 body找到对应帧类parse打日志。import sys from pathlib import Path from hyperframe.frame import ( Frame, DataFrame, HeadersFrame, PriorityFrame, RstStreamFrame, SettingsFrame, PushPromiseFrame, PingFrame, GoAwayFrame, WindowUpdateFrame, ContinuationFrame, ) FRAME_CLASSES { 0x0: DataFrame, 0x1: HeadersFrame, 0x2: PriorityFrame, 0x3: RstStreamFrame, 0x4: SettingsFrame, 0x5: PushPromiseFrame, 0x6: PingFrame, 0x7: GoAwayFrame, 0x8: WindowUpdateFrame, 0x9: ContinuationFrame, } def log_frames(raw: bytes): pos 0 n len(raw) while pos 9 n: header raw[pos:pos 9] length int.from_bytes(header[0:3], big) frame_type int.from_bytes(header[3:4], big) stream_id int.from_bytes(header[5:9], big) 0x7FFFFFFF body_start pos 9 body_end body_start length if body_end n: print(f[截断] 帧头声明长度 {length}但实际只剩 {n - body_start} 字节) break body raw[body_start:body_end] cls FRAME_CLASSES.get(frame_type, Frame) frame cls(stream_idstream_id) try: frame.parse_body(body) frame.parse_flags(int.from_bytes(header[4:5], big)) except Exception as exc: print(f[解析失败] type0x{frame_type:02x} stream{stream_id}: {exc}) break flag_names sorted(str(f) for f in frame.flags) print( fstream{stream_id:6} ftype{frame.__class__.__name__:18} fflags{flag_names} fbody{body.hex()} ) pos body_end if __name__ __main__: raw Path(sys.argv[1]).read_bytes() log_frames(raw)这段代码里有几个细节是按坑积累出来的第一body 切分前必须判断body_end是否越界。TCP 字节流是流式的文件也可能是截断的帧头声明了 100 字节但实际只剩 30 字节这是半包场景。工具遇到这种情况应该明确打印截断而不是静默丢掉。第二未知帧类型用Frame兜底。HTTP/2 允许扩展帧类型哪天服务器发来一个 0xA 的扩展帧工具不该崩而是打印出原始 body hex方便人工分析。第三flags 打印时转成字符串排序而不是直接打印 int。直接打印 int 你又回到了看到 0x01 不知道是 END_STREAM 还是 ACK的老问题。转换之后日志可读性完全不一样。5.2 三个进阶玩法健壮性测试、协议教学和性能观察帧日志工具跑通之后往上加玩法很顺手。第一个玩法是服务端健壮性测试。hyperframe 允许你构造上面说的那些规范上非法但编解码层不管的帧这正是你想要的带 body 的 SETTINGS ACK、stream_id0 的 DATA 帧、设置了保留位的帧头。把这些帧一个个发给本地起的测试服务观察它回 RST_STREAM 还是 GOAWAY、错误码是什么、连接是否存活。注意这种测试只能在本地或测试环境做拿公网服务做畸形帧测试既不负责任也容易出问题。第二个玩法是协议教学可视化。帧日志工具已经按 stream 聚合了输出你可以再给 HEADERS 帧接上 hpack 解码器把:method: GET、:path: /api/xxx这些键值对打出来。然后截获一次完整的请求-响应按 stream 顺序排列所有帧初学者就能直观看到一个请求在 HTTP/2 里到底拆成了几个帧、顺序如何、WINDOW_UPDATE 什么时候出现。这个比直接扔一个 Wireshark 截图给初学者效果好得多。第三个玩法是性能观察。纯 Python 的帧编解码对控制帧和低频连接完全够用但如果你在做高性能代理DATA 帧会成为热点。可以先用这个工具统计每种帧的数量和比例看看实际业务流量到底是 HEADERS 多还是 DATA 多再决定要不要把 DATA 路径换成 C 扩展。多数分析场景到不了这一步但手里有工具心里不慌。我自己实际使用中最受益的一个习惯是把 Wireshark 导出的 HTTP/2 原始字节存成测试文件跑一遍帧日志工具把输出结果作为回归基准固化成测试用例。每次改工具代码都拿同一份字节跑一遍对比输出。这样既能防止自己改坏解析逻辑也能在协议升级时快速发现帧格式变化。调试协议这种东西最怕的就是凭感觉猜有个能重复跑的回归基准比什么都强。如果你现在正被 HTTP/2 的二进制帧绕得头疼装个 hyperframe把 RFC 9113 翻到帧格式那一章照着本文的流程构造几个帧、解析几个帧最多半天时间那些晦涩的字段就全活了。