
写这篇笔记的起因是我在实现一个最小 QUIC 协议栈时被包里面那堆变长字段狠狠折腾了几周。很多人看 QUIC 的 RFC 9000 都会有一个感觉概念好懂但落到字节层面就头大。长头短头、连接 ID、包号、帧类型、流 ID、Offset、ACK Range每个字段都不是定长的很多还要按“几位前缀 变长整数”来解析稍不留神就会把下一个字段的起点读错。这篇内容就是我踩完坑之后的整理把 QUIC 的数据格式从头到帧掰开讲清楚适合正在调试 QUIC 抓包结果、或者准备自己撸一个协议栈的开发者。不管你是只想知道 tshark 里那些字段怎么来的还是真的要写解析器这篇都值得你花点时间过一遍。1. 数据格式的设计背景为什么 QUIC 不沿用 TCP 的头格式1.1 TCP 头部的思维方式在 QUIC 这里行不通先回看一下传统 TCP 头固定 20 字节开头源端口、目的端口、序号、确认号、标志位、窗口大小、校验和、紧急指针每个字段的位置都是写死的。对应到代码里解析 TCP 头很简单拿着 offset 从 0 开始往后数就行。但它的问题也很明显加新功能就得改头部布局旧设备无法识别新标志位中间设备还会因为某些字段没语义而随意丢弃或篡改。QUIC 的设计目标之一就是让协议未来能平滑演进同时把所有功能都放进“帧”这个统一容器里。你在 QUIC 的数据包里几乎看不到“TCP 式”的头部选项区取而代之的是长头/短头两个最小公共头部后面跟着一串帧。这种“头部只负责寻址和状态切换业务逻辑都交给帧”的思想是理解 QUIC 数据格式的第一把钥匙。另一个很关键的背景是加密。QUIC 的绝大多数字段在整包上是有认证的甚至部分字段还做了加密。头部里的连接 ID、包号帧里的 Stream ID、Offset讲究非常多。因为有了加密你没法像看 TCP 头那样直接用一个十六进制工具去解读 QUIC 报文必须先建立连接、拿到密钥才能在抓包工具里看到明文帧。这也是为什么网上讨论 QUIC 实现的帖子有很大一部分都在聊 TLS 1.3 握手和密钥导出因为数据格式的不透明性会直接影响调试体验。1.2 把“连接”语义放进 64 位的 Connection IDTCP 在两端之间用“四元组源 IP、源端口、目的 IP、目的端口”来唯一标识一条连接。QUIC 虽然也用 IP 和端口但真正的连接锚点是 Connection ID原因有二。第一连接迁移。手机从 Wi-Fi 切到 4GIP 变了TCP 连接直接断了QUIC 只要连接 ID 不变服务端仍然能识别出这是同一条连接连接就能继续用。第二负载均衡。服务端收到 QUIC 包后不需要重新计算四元组哈希直接读目标连接 ID 就能决定把包转发给哪个后端实例这在大型数据中心里非常有用。不过连接 ID 也从 0 到 20 字节不等所以它在头部里不能像 TCP 端口那样固定占两个字节。长头里设计了两个长度字段目标连接 ID 长度和源连接 ID 长度每个各占 1 字节。短头里则不再重复长度因为连接建立后对端 ID 长度已经是双方协商好并缓存下来的状态不需要每次都在线上重复传。1.3 定长和变长混合是效率优先的选择你可能会问为什么不干脆把所有字段都用固定长度解析不是更省事答案很简单传输层协议的数据头在每一条报文里都会出现能省一个字节收益都会被放大到成百上千万个包上。QUIC 选择了一种很聪明的折中头部里诸如类型、长度、ID 这类字段用“变长整数”来编码而版本号、连接 ID 长度这类必须定长的字段就老老实实占用固定字节数。所以你在实际抓到的 QUIC 包里会看到“几个字节定长字段 几个字节变长字段 帧数据”这种混合结构。解析的时候最忌讳把每一个字节都当成固定 offset正确姿势是先读变长字段的长度前缀算出它实际占了几字节再往后移动指针。下面几节我会把这两种编码方式都拆开讲。2. 头部格式拆解长头与短头的完整字节布局2.1 第一个字节决定了你怎么看后面所有数据QUIC 数据包的第一个字节最高位是 Header Form 位为 1 时代表长头Long Header为 0 时代表短头Short Header。长头用于连接建立阶段也就是 Initial、Handshake、0-RTT、Retry 这些包短头用于连接建立完成后的 1-RTT 数据包。为什么需要两种头因为阶段不同需要携带的信息不同。连接刚建立时双方可能还没有交换连接 ID也不知道包号基数所以长头必须带完整版本号和双向连接 ID。连接建立之后双方都记住了必要状态短头就可以大幅压缩省掉版本号、源连接 ID、甚至某些长度字段只保留最核心的包号和目标连接 ID。这个设计有点像 HTTP 的请求头和请求体头部小到极致才有更多空间留给真正的业务数据。长头里还有一个 Fixed Bit固定为 1。这个位存在的意义是给将来可能出现的“非 QUIC 协议”预留区分空间同时也能帮助中间盒区分 QUIC 和别的 UDP 载荷。如果你在调试时看到某个 QUIC 包第一位为 0x40 或 0x60 之类的值就要怀疑是不是抓到了老版本或者错误标记的载荷。2.2 长头字段逐个看版本号、类型、连接 ID长头的标准布局如下以 RFC 9000 为基准Long Header Packet { Header Form (1) 1, Fixed Bit (1) 1, Long Packet Type (2), Type-Specific Bits (4), Version (32), Destination Connection ID Length (8), Destination Connection ID (0..160), Source Connection ID Length (8), Source Connection ID (0..160), Type-Specific Payload (..), }第一字节的低 2 位是 Long Packet Type0 表示 Initial1 表示 0-RTT2 表示 Handshake3 表示 Retry。Type-Specific Bits 低 4 位在 Initial、Handshake、0-RTT 里有不同含义其中预留位必须为 0否则直接视为非法报文。Version 是固定的 4 字节。QUIC v1 的版本号是0x00000001将来如果协议有大版本更新这个数字会变。Header Form、Fixed Bit、Type 这些位在头部最前面是为了让接收方在不读完整版本号前就能判断包类型并按对应逻辑处理。Version Negotiation 包比较特殊它的第一个字节和普通长头不一样后面不跟连接 ID 长度而是跟着一堆支持的版本列表格式要单独处理。接下来是两个长度字段。注意连接 ID 长度本身是 1 字节无符号整数加上这个数字之后才是实际要读的连接 ID 字节数。目标连接 ID 通常由服务端选择用于包路由源连接 ID 由发送方生成用于在响应包中填写目标连接 ID。在 Initial 包中客户端如果之前没有服务端连接 ID会填一个长度为 0 的目标连接 ID服务端会用自己的连接 ID 回复。Type-Specific Payload 因包类型而异。Initial 包里包含 Token Length 和 Token可以用来做地址校验然后是 Length、Packet Number 和加密负载。Handshake 包没有 Token直接是 Length、Packet Number 和加密负载。0-RTT 包同样没有 Token。Retry 包则包含服务端生成的 Retry Token 和 Retry Integrity Tag用于防伪造。2.3 短头字段逐个看包号和 Key Phase 是关键连接建立完成后两端进入 1-RTT 阶段这时发送的短头包布局如下Short Header Packet { Header Form (1) 0, Fixed Bit (1) 1, Spin Bit (1), Reserved Bits (2), Key Phase (1), Packet Number Length (2), Destination Connection ID (0..160), Packet Number (8..32), Packet Payload (..), }这里有一个很容易忽略的点短头里没有目标连接 ID 长度字段接收端是通过 TLS 传输参数或对端长头里声明的连接 ID 计算出来的。所以抓包工具能正确解析前提是它已经跟踪并缓存了每个连接的目标连接 ID 长度。短头里的 Spin Bit 是让人人都能观测到的“主动探测”位主要给运营商做网络延迟抖动测量用。Reserved Bits 固定为 0收到非 0 直接丢包。Key Phase 表示当前使用的是哪一把 1-RTT 密钥这是 QUIC 密钥更新机制的关键每次密钥更新后这个位会反转接收端才能知道应该用新密钥还是旧密钥解密。Packet Number Length 占 2 位表示包号的长度0 代表 1 字节包号1 代表 2 字节2 代表 3 字节3 代表 4 字节。包号长度之所以是可变的是因为 QUIC 的包号压缩策略连接初期包号小用 1 字节就够随着包号变大再逐步扩展。接收端不需要真的记录每个包的完整包号只需要在本地维护一个最大包号基数再结合包里省略掉的低位部分即可还原完整包号。2.4 变长整数快速上手和容易翻车的地方QUIC 里到处可见变长整数VARINT。它最多占用 8 字节但实际只编码 62 位有效值前 2 位表示长度剩余位是大端整数。规则如下前 2 位总字节数有效位数0016012141043011862举个例子十六进制0x25的二进制是00 100101前缀是 00表示 1 字节变长整数值就是二进制100101即 37。十六进制0x4123的二进制是01 000001 00100011前缀 01 表示 2 字节把前两位抹掉后剩下 14 位值是0x0123即 291。解析时先读首字节的高 2 位决定要读多少字节再读取后续字节并组装成整数。实际开发中变长整数最容易踩的坑有三个。第一很多新手只看了低 6 位忘了高 2 位同时也是数据的一部分导致大值解析出错。第二写入和读取大小时要严格保持最低编码字节数一致QUIC 要求编码器必须使用最小长度表示一个值比如值为 0 就必须用 1 字节0x00不能写成 2 字节0x4000否则接收端会判为格式错误。第三要注意降级兼容实现新协议版本时如果某个字段在将来变成变长老实现读了高 2 位但不认识会直接拒绝这种“向前不兼容”是有意为之防止歧义。3. 帧层数据格式业务逻辑的统一容器3.1 帧头只是一个大类型字节吗长头/短头之后就是 Packet Payload里面装着一串帧。每个帧由“帧类型字节 若干字段”组成。一个包可以包含多个帧帧之间没有分隔符不能像解析 TCP 选项那样按固定长度遍历而是每解析完一个帧根据该帧的字段长度算出下一个帧起始位置。帧类型本身是一个变长整数但实际常用类型都在 0x00 到 0x1e 之间占用 1 字节。所有未知帧类型都必须触发 CONNECTION_CLOSE这是 QUIC 相对宽松的帧格式里很少见的“强约束”因为协议希望新帧类型能被安全引入但又不想让旧实现对未知帧自作主张地做不完整处理。下面这张表列出了 v1 里所有标准帧类型帧类型名称含义0x00PADDING填充用于混淆包长或扩大包0x01PING保活确认路径可达0x02ACK确认不带 ECN 信息0x03ACK_ECN确认带 ECN 计数0x04RESET_STREAM终止发送方流0x05STOP_SENDING请求对端停止发送流数据0x06CRYPTO传输 TLS 握手数据0x07NEW_TOKEN提供新地址校验令牌0x08..0x0fSTREAM携带流数据低 4 位为标志0x10MAX_DATA通知连接级流量上限0x11MAX_STREAM_DATA通知单流流量上限0x12 / 0x13MAX_STREAMS双向/单向最大流数0x14DATA_BLOCKED连接级数据被阻塞0x15STREAM_DATA_BLOCKED单流数据被阻塞0x16 / 0x17STREAMS_BLOCKED双向/单向流数被阻塞0x18NEW_CONNECTION_ID告知新连接 ID0x19RETIRE_CONNECTION_ID废弃某个连接 ID0x1aPATH_CHALLENGE路径连通性探测0x1bPATH_RESPONSE路径探测响应0x1cCONNECTION_CLOSE应用层关闭连接0x1dCONNECTION_CLOSE传输层关闭连接0x1eHANDSHAKE_DONE服务端通知握手完成3.2 ACK 帧的 Range 压缩原理ACK 帧是 QUIC 里最绕的帧之一但它的设计非常值得学。TCP 的 SACK 是“几段区间就写几个区间块”QUIC 则是用“一个最大确认号 范围计数 多个长度”的方式压缩范围描述。标准格式如下ACK Frame { Type (i) 0x02..0x03, Largest Acknowledged (i), ACK Delay (i), ACK Range Count (i), First ACK Range (i), ACK Range (..) ..., [ECN Counts (..)], }Largest Acknowledged 表示当前确认范围内最大的包号。First ACK Range 表示这个最大包号往回连续确认了多少个包比如 Largest Acknowledged 是 100First ACK Range 是 9那就意味着 100 到 91 这 10 个包都被确认了。接下来是交替出现的 Gap 和 ACK RangeGap 表示跳过了多少个未确认包ACK Range 表示下一个连续确认区间的长度。这种“先确认一个区间再跳一段再确认一个区间”的编码方式非常紧凑尤其适合大带宽高丢包场景。实际调试 ACK 帧时最需要注意的是 ACK Range Count 的单位是“ACK Range 的数量”不是“所有包的数量”。很多解析器第一次写出来会把 ACK Range Count 当成要读的总包数结果读着读着就把包解析错位了。记住首区间单独一个 First ACK Range后面的区间再由 ACK Range 数组表示。ECN 计数只在帧类型为 0x03 时出现包含 ECT0 Count、ECT1 Count 和 ECN-CE Count 三个变长整数。网络设备如果支持显式拥塞通知可以通过这些计数告诉发送端真正发生了多少拥塞事件这是 QUIC 比 TCP 更容易部署 ECN 的原因之一。3.3 STREAM 帧类型字节低 4 位就是一个小型位图STREAM 帧是所有帧里最常用的它负责把一个流的数据块装进去。它的类型范围是 0x08 到 0x0f低 4 位里每一位都表示一个可选字段是否存在Bit 30x08表示是否有 Offset 字段Bit 20x04表示是否有 Length 字段Bit 10x02表示 Offset 字段的编码长度是否为 8 字节Bit 00x01表示是否最后一帧等等这里我用了位的位置描述但很多文档里喜欢写成 OFF、LEN、FIN 三个标志位OFF 位0x04、LEN 位0x02、FIN 位0x01。类型字节等于 0x08 加上这三个标志。比如0x0f就是 OFFLENFIN 三位置 10x0c是 OFFLEN 置 1 但没有 FIN。该帧中Stream ID 是变长整数标识属于哪条流。Offset 是变长整数表示这段数据的起始字节位置如果这个位没有设置默认偏移为 0。Length 是变长整数表示数据部分的字节数如果 LEN 位没设置Length 会根据包尾自动推导。FIN 位为 1 表示这个 frame 的末尾就是整条流的终止点。这里有个容易混淆的地方Offset 和 Length 在 STREAM 帧里即使存在也不是“固定占用 4 字节”或“固定占用 8 字节”而是用变长整数编码的。所以解析时要先解析变长整数的长度前缀再读取后续字节。我在实现时写过一个通用的read_varint()函数只要那个函数稳了STREAM 帧的解析就成功了一半。STREAM 帧的 Offset 还有一个特殊用途配合 HTTP/3 的请求响应。HTTP/3 的流模型建立在 STREAM 帧之上一个服务器推送的响应数据可能被拆成多个 STREAM 帧分别带不同的 Offset接收端要根据 Offset 去重和排序。你如果在抓包工具里看到请求数据被拆得七零八落不要惊讶这是 QUIC 流复用在底层干的活。3.4 CRYPTO 帧TLS 握手数据背后的搬运工CRYPTO 帧和 STREAM 帧长得非常像但它是给 TLS 握手数据用的不归属于应用流。格式是CRYPTO Frame { Type (i) 0x06, Offset (i), Length (i), Crypto Data (..), }这里没有 Stream ID因为 CRYPTO 帧只属于连接本身不分流。Offset 表示这段数据在加密握手消息中的位置。TLS 握手消息通常比一个 QUIC 包大所以一条握手消息会被切分到多个 CRYPTO 帧里接收端按 Offset 重新拼装。CRYPTO 帧有重传机制但重传时使用的是新的包号接收端通过 Offset 来去重这一点和 TCP 按序号去重的思路一致。CRYPTO 帧不能在 0-RTT 包里发送因为 0-RTT 还没有完成握手数据可能被重放攻击利用。为了防止攻击者把 0-RTT 包里的数据篡改成握手数据CRYPTO 帧只允许出现在 Initial、Handshake 和 1-RTT 包里。Implementation 里如果发现 0-RTT 包带有 CRYPTO 帧应该直接丢弃或视为协议错误。3.5 连接关闭帧错误信息和帧类型也能拿来排错CONNECTION_CLOSE 帧有两种0x1c 是应用层主动关闭0x1d 是传输层错误关闭。前者的字段不包含 Frame Type后者必须包含一个触发错误的帧类型帮助接收方定位是哪个帧出了问题。格式CONNECTION_CLOSE Frame { Type (i) 0x1c..0x1d, Error Code (i), [Frame Type (i)], Reason Phrase Length (i), Reason Phrase (..), }这里最实用的点在于 Frame Type 字段。如果服务端在解析一个 STREAM 帧时出错比如 Stream ID 对应的流早就关闭了CONNECTION_CLOSE 里的 Frame Type 会写明是 0x08 到 0x0f 里的哪个类型。我调试协议栈时碰到过一种情况客户端发来的 STREAM 帧的 Offset 不递增服务端直接回了一个携带 Frame Type 的传输关闭帧。抓包工具里这个 Frame Type 可以帮你迅速定位是加解密的问题还是应用逻辑对流状态机管理出了问题。另外注意应用层关闭帧 Error Code 使用的是应用自定义错误空间传输层关闭帧使用的是传输错误码。HTTP/3 里错误码和 QUIC 传输错误码完全不同排查时千万别混着看。4. 实操解析过程与常见问题排查4.1 手工拆一个 Initial 包从十六进制到字段纸上谈兵再多不如动手拆一个包。假设我在局域网里抓到了一个 QUIC Initial 包十六进制开头是c0 00 00 00 01 08 08 01 02 ...注意我故意只给了前几字节方便演示头部解析。第一个字节是0xc0二进制是11000000。最高位 1说明是长头低 2 位是 0说明是 Initial 包。Fixed Bit 为 1没问题。接下来 4 字节00 00 00 01是版本号表示 QUIC v1。然后08是目标连接 ID 长度表示接下来要读 8 字节目标连接 ID。但这里我给的数据不完整为了演示假设后续 8 字节就是目标连接 ID。紧接着08是源连接 ID 长度又是 8 字节源连接 ID。读到这里你可能会发现目标连接 ID 和源连接 ID 顺序反复出现。这里确实是一个新手极易犯的错长头里的顺序是先目标连接 ID 再源连接 ID而 Initial 包里生成源连接 ID 的规则又是根据服务端返回的源连接 ID 反过来填目标连接 ID。一定要按字节顺序严格读不要凭印象跳字段。Initial 包接下来是 Token Length。假设 Token Length 是变长整数0x00表示没有 Token。然后才是 Length、Packet Number、加密负载。Length 是整个 Type-Specific Payload 的字节数在解包时不能混淆为负载长度因为 Length 之后还有 Packet Number 字段。用 Wireshark 跟着读一遍很快就理解这个顺序了。这里我强烈建议你在本地用 tcpdump 抓一个真实 QUIC 握手包然后用 Wireshark 的“解码为 UDP 端口号”功能把负载标记为 QUIC对照着上面的字段结构看。第一次看时你会惊讶于 Wireshark 能把每个变长整数的长度前缀都给你标出来能直观地验证我上面讲的所有规则。4.2 调试中遇到最多的四个格式问题第一变长整数的最小长度不满足。这个问题好发在自定义实现里比如某些库会把0x00编码成0x4000也就是用 2 字节表示 0 值。按照 RFC 要求编码器必须用最小字节数表示对端收到后直接判错。解决方法很粗暴在编码函数里写一个分支值在 0 到 63 之间用 1 字节64 到 16383 之间用 2 字节依此类推不要图省事统一用 4 字节。第二读取 STREAM 帧时把 Offset 长度和 Length 长度的解析顺序搞反。有的实现会先把整个 STREAM 帧读成“数据块”但没意识到 Offset 和 Length 也是变长整数在判断长度时出错。最简单的方法是把 STREAM 帧的解析拆成三步先读 Stream ID再按 flag 读 Offset再按 flag 读 Length最后才读数据。每一步都用同一个read_varint()不要混。第三ACK Range Count 和 First ACK Range 的单位不一致。ACK Range Count 表示的是 ACK Range 数组的元素数量不是包数量。经常有实现把 ACK Range Count 加 1 当成总确认区间数导致多读或少读一段。正确逻辑是先读 Largest Acknowledged再读 First ACK Range最后用循环读取 ACK Range Count 组 Gap 和 ACK Range。第四短包里没有目标连接 ID 长度容易在解析时少读或多读。前面说了短头的目标连接 ID 长度是从握手阶段长头里继承来的。如果你只孤零零地看一个 1-RTT 包不跟踪连接状态就没法解析。这也提醒我们实现 QUIC 解析器时必须维护一个“连接上下文”而不是简单地把每个 UDP 包当成独立对象。4.3 抓包和解析工具的选择心得成熟的 Wireshark 已经能很好地解析 QUIC但如果你要调试自己的实现建议抓包时把 QUIC 的密钥日志也一起导出这样才能看到明文帧。Chrome、ngtcp2、quiche 等主流实现都支持通过环境变量或者 API 输出 TLS key log格式是CLIENT_HANDSHAKE_TRAFFIC_SECRET这类。Wireshark 里配置好 key log 文件后就能把每个帧展开成年纪先是头部字段然后是帧列表再到每个帧的具体字段。如果你是在自己写的解析器里面调试想快速打印帧类型分布可以在拿到明文帧之后写一个小工具按我上面那一节讲的方法遍历帧。我通常先统计帧类型出现的次数再单独打印 STREAM 帧的 Stream ID、Offset、Length 三元组用来判断有没有流乱序或数据丢失。这种方法比直接对着十六进制肉眼看高效得多。需要注意QUIC 包里的数据是有加密嵌套的短头包的 Packet Number 部分是明文但 Packet Payload 是整体加密的。抓包工具能显示 STREAM 帧是因为它拿密钥解密了 Payload如果你拿到的抓包文件里只有一堆Unknown或Encrypted那基本是没导入 key log或者 key log 对应的连接和你抓的目标不是同一条。4.4 试过就回不去的自查清单最后给你一份我做格式解析时的自查清单长头首字节高 2 位是否为11长包类型是否在合法范围内。版本号是否为 4 字节Initial/Handshake/0-RTT/Retry 是否和自己的预期一致。连接 ID 长度字段加起来是否和包实际剩余长度匹配。Token Length 在 Initial 包里是否存在以及是否影响后续 Length 字段偏移。短头首字节高 2 位是否为10Spin Bit、Reserved Bits、Key Phase 的位置是否对齐。包号长度是否为 1、2、3、4 字节之一包号还原时是否超出本地窗口。STREAM 帧的类型字节低 4 位标志是否与你期望的 Offset/Length/FIN 状态一致。ACK 帧的 Range 遍历顺序是否为“先 First再循环 GapACK Range”。所有变长整数是否使用了最小字节数尤其是为 0 的字段。这条清单不是一次性写完的。我在第二遍校对协议栈时才补上了其中几条大都是被真实抓包数据教做人的结果。你照着清单过一遍至少能避开绝大多数格式对齐问题。QUIC 的数据格式看起来琐碎但哲学上是“所有信息都可被压缩、所有状态都可被推导”。你自己写一遍解析再对着 Wireshark 验证一遍就会发现长头、短头、变长整数、帧组合这些设计没有一处是多余的。我个人的体会是真正卡住你的往往不是某个字段的值是什么而是“下一段该从哪一字节开始”。把每个字段的长度边界都理清楚后续加密、丢包重传、流控这些实现就会顺畅很多。