6. 万物开端:QUIC Packet 解剖与连接世界地图
要理解一个协议,最怕的不是细节多,而是一上来就被细节淹没,却不知道自己身在何处。
所以本章不急着拆包头、背字段表。我们先退后一步,搞清楚三件事:QUIC 为什么要在 UDP 之上另起炉灶?它把数据切成了哪几层、每层各管什么?一个真实的 UDP 报文落进 quicX 之后,又会沿着怎样的路径被一层层拆开?把这张地图先画出来,后面每一章的细节才有地方安放。
6.1 旧大陆的裂缝:TCP 曾经解决了什么,又留下了什么包袱
如果要理解 QUIC 为什么要出现,我们得先回到那个一切开始的地方——TCP 曾经辉煌的年代。
上世纪七十年代,当 Vint Cerf 和 Bob Kahn 写下 TCP 协议的第一个字节时,他们或许没想到,这个为阿帕网设计的协议,会在接下来半个世纪里成为整个互联网的基石。TCP 做了一件极其伟大的事情:它在上层应用和下层 IP 之间,插入了一层可靠的、面向连接的、带有拥塞控制的传输抽象。从此,开发者不用再纠结丢包、重传、顺序这些网络底层的脏活,只需要对着一个 socket 读写字节流,看到的就是一条稳定可靠的传输管道。
然而物极必反,这也造成了 TCP 的第一道伤疤:队头阻塞(Head-of-Line Blocking)。
在 HTTP/1.1 的串行时代,这个问题还不明显。但当 HTTP/2 试图引入多路复用,让同一个 TCP 连接并发传输多个 Stream 时,队头阻塞的幽灵就彻底现形了。问题出在视角的错位上:HTTP/2 眼里是多个并行的 Stream,但 TCP 眼里只有一条连续的字节流,各个 Stream 的数据帧在这条字节流中交错混排。一旦某个片段在传输中丢失,TCP 必须等待该片段重传到达,才能把后续的数据交付给上层的 HTTP/2。这意味着,哪怕丢的只是 Stream A 的一小个块,已经安全到达接收端的 Stream B、C、D 的数据也必须在缓冲区里干等——仅仅因为它们在 TCP 的字节流序列号里排在后面。这种"一个流丢包,所有流陪葬"的局面,正是 TCP 单一流模型给现代多路复用网络戴上的沉重枷锁。
TCP 的第二道伤疤,是内核协议栈的演进僵化。
你发现没有?过去二十年里,你的服务器操作系统升级了无数遍,但 TCP 协议的默认行为几乎没怎么变过。这不是操作系统工程师偷懒,而是因为 TCP 已经深深嵌入到整个互联网的基础设施里——路由器、防火墙、负载均衡器、中间件,所有这些"中间设备"都在假设 TCP 会以某种特定方式工作。TCP 的核心规范最后一次重大更新是 1981 年的 RFC 793,距今已超过 40 年。尽管后来陆续有了 RFC 1122、RFC 2581(拥塞控制)、RFC 3522(选择确认)、RFC 5961/5965/9293(各种安全修复),但 TCP 的"身体结构"基本没变过。一旦你改了 TCP 的默认行为,这些中间设备可能就会把你的包drop掉,或者把你的连接reset掉。
中间设备的僵化到底有多严重?TLS 1.3 的部署历程就是一个活生生的例证。当 TLS 1.3 最初尝试使用新的记录类型和握手方式时,大量部署在网络路径上的中间设备直接把连接断掉了——它们只认识 TLS 1.2 的模式,任何"看起来不对"的东西都会被丢弃或重置。最终 TLS 1.3 不得不加入了一个"中间设备兼容模式"(RFC 8446 §D.4):发送一条虚假的 change_cipher_spec 记录来安抚那些期望看到它的老设备,并把所有 TLS 1.3 握手消息和应用数据都伪装成 TLS 1.2 的 application_data content type,才得以顺利部署。QUIC 设计者从这段经历中汲取了教训:与其在 TCP 之上小修小补然后被中间设备卡脖子,不如直接跳到 UDP 上另起炉灶,用加密把协议内部结构保护起来,让中间设备根本无从干预。
TCP 的第三道伤疤,是连接与路径的强制绑定。
在 TCP 的世界里,一条连接由五元组(源IP、源端口、目的IP、目的端口、协议)唯一确定。这意味着什么?意味着只要你换个 WiFi、切换个 4G 网络,你的 TCP 连接就得断掉重连。移动互联网时代,用户在 WiFi 和蜂窝网络之间切换是家常便饭,但每次切换都意味着一次连接重建——TLS 握手得重新来一遍,之前的请求进度全部归零。这还不是最要命的,最要命的是对于那些长连接的应用来说,一次网络切换就可能导致整个会话失效。
这就是 TCP 留给我们的"旧大陆"。它曾经是那么伟大,以至于整个互联网都围绕着它构建;但它也因此背上了沉重的历史包袱,再想在这个框架里做创新,难度不亚于给飞行的飞机换引擎。
纵观整个网络工业界过去十年的探索,人们曾经反复尝试在 TCP 之上打补丁——比如用用户态 TCP 栈绕开内核,或者在应用层硬贴一层多路复用。但每一次尝试最终都会撞上同样的墙:你可以绕开终端的内核协议栈,但你绕不开网络路径上重重设卡的中间设备;你可以在应用层拼凑出多路复用,但 TCP 底层的队头阻塞依然无解。这就注定了 QUIC 诞生的必然逻辑:它需要另起炉灶。QUIC 不是为了全盘抛弃 TCP 验证了几十年的伟大原则——可靠传输和拥塞控制依然是其灵魂——而是选择用一种创新的方式,在 UDP 这张白纸上将其全面重建,以此彻底挣脱 TCP 身上那套深重的结构性枷锁。
6.2 在 UDP 上重建秩序:QUIC 到底取回了哪些能力
UDP 可能是互联网世界里最简单的传输协议了——它只管把数据从 A 送到 B,别的什么都不保证。丢没丢?不知道。顺序对不对?不知道。重复了怎么办?也不知道。
但恰恰是这种"什么都不管"的极简主义,给了 QUIC 一张白纸,可以重新设计整个传输层。
首先,连接语义仍然存在,但不再绑死内核。
TCP 的连接是由操作系统内核管理的——你调用 connect(),内核帮你完成三次握手,创建 socket fd,从此这个 fd 就代表了一条连接。但在 QUIC 的世界里,连接变成了一个纯应用层的概念。QUIC 用 Connection ID 而不是五元组来标识一条连接,这意味着连接的生命周期可以完全脱离底层的网络路径。一条 QUIC 连接可以在 WiFi 和 4G 之间无缝迁移,可以在源端口改变后依然保持有效——只要双方还记得这个 Connection ID。
其次,可靠传输仍然存在,但粒度和实现方式变了。
TCP 的可靠传输是面向字节流的——你发出去的数据被当作一个连续的字节序列,丢包了就从最后一个确认的位置重新发送。但 QUIC 把这个粒度提升了一层:QUIC 的可靠传输是面向 Packet 的,但恢复的是 Frame 语义。这意味着我可以只重传丢掉的某个 Frame(比如一个 CRYPTO Frame(加密数据帧)),而不是把整个 Packet 原样复制一遍。这种设计为后续的"语义重传"打下了基础——我们将在卷三里详细展开。
第三,加密不再外挂在传输层之上,而是与传输层融合。
在 TLS + TCP 的经典架构里,TLS 是跑在 TCP 之上的"应用层"协议——TCP 负责可靠传输,TLS 负责加密。这种分层在理论上很美,但在实践中有一个致命问题:TLS 的每一次密钥更新、每一次握手推进,都要建立在下层那条可靠字节流已经顺畅可用的前提上。QUIC 做了一个激进但务实的决定:把 TLS 的加密能力直接嵌进传输层,每个 QUIC Packet 都自带保护,而密钥的演进则由 QUIC 自己掌控,不再依赖 TLS Record 那套额外外壳。这意味着 QUIC 可以把"建连"和"加密"拧成同一股绳,甚至在合适的前提下做到 0-RTT 发送。
第四,多路复用下沉到传输层,流之间争取独立性。
HTTP/2 试图在 TCP 之上用 Stream 做多路复用,但 TCP 的队头阻塞问题让这个努力收效甚微。QUIC 聪明的地方在于:它把多路复用的粒度直接做进了传输层——每个 QUIC Stream 都是一条独立的字节流,某个 Stream 上的数据丢了,只会拖慢它自己,不至于把同一连接里其他 Stream 一起按在地上陪跑。换句话说,QUIC 并不是让丢包从此消失,而是把"一个流丢包,整条连接陪葬"的跨流队头阻塞,从传输层这一层拿掉了。
最后,连接与路径解耦,为迁移创造前提。
这是 QUIC 最革命性的设计之一。当连接不再依赖五元组,Connection ID 就成了连接的唯一标识。客户端可以随时告诉服务端"我的新地址是这儿",然后继续使用原来的 Connection ID 发送数据——这就是路径迁移(Connection Migration)。服务端收到这个新地址的包后,只需要验证一下这个路径确实属于这个 Connection ID,就可以继续通信。整个过程不需要重新握手,不需要重建 TLS 会话,用户甚至感知不到网络切换。
看到这里,你应该明白了:QUIC 不是 UDP,更不是"TCP over UDP"。QUIC 是在 UDP 之上重建了一整套传输层能力——连接、可靠传输、加密、多路复用、路径迁移——但每一项都做了针对性的优化,甩掉了 TCP 积累了几十年的历史包袱。
6.3 两层信封:为什么 QUIC 要把 Packet 和 Frame 拆开
理解 QUIC 的协议分层,是理解整个协议设计的关键。而理解 Packet 和 Frame 的关系,则是关键中的关键。
让我们打个比方:Datagram 是邮政系统寄出的包裹,Packet 是包裹里的信封,Frame 则是信封里的信。
当你调用 send() 发送一个 HTTP 请求时,数据首先被切分成若干个 QUIC Frame——每个 Frame 都有自己特定的语义:有的是传输加密密钥的 CRYPTO Frame,有的是携带应用数据的 STREAM Frame,有的是做流量控制的 MAX_DATA Frame。这些 Frame 是 QUIC 协议中最基础的通信单元,它们各自表达独立的逻辑。
然后,一个或多个 Frame 被装进一个 QUIC Packet——这个 Packet 就是 UDP Datagram 的载荷。Packet 这一层做的事情很关键:它是路由、保护(加密)、和确认(ACK)的载体。一个 Packet 有一个包头(Header)和一个载荷(Payload),包头里包含着让这个包能被正确路由、正确解密、正确确认的所有信息。

为什么要分层?因为这两层的关注点完全不同:
Packet 层关注的是"能不能送到、能不能解密、能不能确认",它处理的是通信基础设施的问题。一个 Packet 如果在网络上丢了,发送方需要知道这件事(通过 ACK Frame 反馈回来),需要决定要不要重发(通过重传逻辑),需要知道重发的时候要不要调整(比如换个更小的包)。这些决策都是在 Packet 层面做的。
Frame 层关注的是"这条消息是什么意思",它处理的是业务语义的问题。STREAM Frame 说的是"这是应用层要发的数据",CRYPTO Frame 说的是"这是加密层要处理的密钥材料",MAX_DATA Frame 说的是"我的接收窗口扩大了,你可以继续发"。这些语义是独立的,不依赖于 Packet 层怎么传输。
这种分层带来的最大好处是:一个 UDP Datagram 里可以包含多个 QUIC Packet(Coalescing),而一个 QUIC Packet 里可以包含多个 Frame。
Coalescing 是 QUIC 的一个非常聪明的设计。想象一下:客户端正在握手,它需要同时发送一个 Initial Packet(继续握手)、一个 Handshake Packet(加密握手数据)、和一个 0-RTT Packet(提前发应用数据)。如果每次都单独发一个 UDP Datagram,网络效率会很低——每个 Datagram 都有自己的 IP 头、UDP 头、QUIC Header 开销。但 QUIC 允许把这些 Packet 首尾相连,塞进同一个 UDP Datagram 里一起发出去。接收端收到之后,只需要按顺序一个个拆开、一个个处理就好。
更重要的是,Frame 层的设计让 QUIC 实现了语义重传。在 TCP 里,一个报文段丢了,发送方通常只能围绕那段连续字节流去补洞;但在 QUIC 里,发送方确认丢的是某个 Packet Number,真正需要补的却是那个包里承载的语义。比如某段 STREAM 数据没有送达,我们可以依据 Stream ID + Offset 重新把那段数据装进一个全新的 Packet 里发送;而同包里原本顺手捎带的 ACK、MAX_DATA 之类控制 Frame,则完全可以按照此刻最新的连接状态决定是否还要继续携带。
这就是为什么我们说"QUIC 重传的是语义,而不是包"——Packet 层负责把字节安全送过去,Frame 层负责说明这些字节究竟意味着什么。交通工具可以换班、换车、换路线,但语义本身不该被运输方式绑架。
关于 Coalescing(包合并)的排列规则:RFC 9000 §12.2 对合并包的排列有两条关键约束——如果 Datagram 中包含 Initial Packet,它应当(SHOULD)排在最前面;如果包含 1-RTT Packet(Short Header),它必须(MUST)排在最后面。原因很实际:Short Header 没有 Length 字段,接收端无法从字节流中判断它在哪里结束,所以它后面不能再跟其他 Packet。至于中间的 0-RTT 和 Handshake 之间的先后顺序,协议并未做强制排列要求,但按加密级别从低到高排列是最常见的实践。实际场景中,由于 QUIC 只有四个加密级别(Initial / 0-RTT / Handshake / 1-RTT),一个 Datagram 中合并的 Packet 通常不超过四个——但这是加密级别数量和 MTU 大小共同决定的实际上限,而非协议规定的硬性限制。
值得一提的是,所有 Frame 共享同一个格式模板:Frame Type 后面紧跟 Frame 专属的字段。Frame Type 用变长整数编码,这是带宽效率与扩展性的权衡——当前定义的帧类型只有 20 多种,一个字节足以覆盖;但变长整数最大可以表示 62 位整数,给未来扩展留下了充裕的空间。具体的帧类型和各自的字段细节,我们不在这里逐个展开——后续章节在讲到 ACK、STREAM、CRYPTO 等帧时,自然会各自拆解。
6.4 两种面孔:Long Header 与 Short Header 的分工
如果你仔细看过 QUIC 的包头设计,你会发现一个有趣的现象:同一个连接的不同阶段,包头的格式是完全不同的。
在连接刚建立的时候——也就是握手期间——QUIC 使用的是 Long Header。这里的"长",不是为了把包头写得更臃肿,而是因为它必须把握手期最关键的上下文明明白白地摆出来:版本号(Version)、目标连接 ID(DCID)、源连接 ID(SCID)、包类型(Initial / 0-RTT / Handshake / Retry),以及在具体包型里还会出现的 Token、Length、Packet Number 等字段。为什么需要这么多信息?因为在这个阶段,双方才刚刚接上头,还在确认版本、交换连接身份、切换加密级别。此时如果头部不把话说明白,后面的路由、解密、状态推进都会失去依据。
但当连接进入稳定传输期之后——也就是握手完成、开始正常收发数据之后——QUIC 就换上了 Short Header。它保留的只剩下继续通信真正离不开的信息:目标连接 ID、第一字节里的若干控制位,以及 Packet Number。至于版本号、源连接 ID、握手期那些上下文前提,此时双方都已经心知肚明,再在每个包里反复携带,只会平白消耗带宽。这里尤其要注意一点:Short Header 并没有像 Long Header 那样显式携带一个 Length 字段。这不是疏漏,而是刻意的设计——没有 Length 字段意味着接收端无法判断这个 Packet 在哪里结束,所以 Short Header 包只能作为一个 UDP Datagram 中的最后一个 QUIC Packet。在 quicX 的实现里,Rtt1Packet::DecodeWithoutCrypto 会直接把 buffer 中所有剩余数据当作自己的密文载荷一口吞下——这正是"没有 Length 字段"在工程上的直接体现。
为什么要换面孔?因为这两个阶段的诉求完全不同:
握手期的诉求,是把话说明白。 服务端需要知道客户端用的是什么版本,需要知道这个包打算发给哪个 Connection ID,也需要知道它正处在哪个握手阶段。Long Header 的存在,就是为了在一切都还没建立起来的时候,先把这些最关键的上下文摊在桌面上。
稳定传输期的诉求,是把废话删掉。 一旦版本、连接身份、加密级别都已经建立,再让每个包继续背着这些背景资料奔跑,就是纯粹的浪费。Short Header 的价值,不在于它"更短"这件事本身,而在于它承认:既然双方已经熟了,就该把每一个比特都省给真正高频发生的数据传输。
这种设计还有一个很实际的好处:接收端只要先看见 Header 的形态,就能大致判断自己正处在"建立语境"还是"高频传输"的阶段。Long Header 说明这还是握手期,很多上下文仍需明文交代;Short Header 则意味着双方已经进入熟路,后面的每一个比特都开始认真计较成本。
Long Header 和 Short Header 的字段对比:
| 字段 | Long Header | Short Header | 说明 |
|---|---|---|---|
| 第一个字节 | 0b1xxx_xxxx (最高位为1) |
0b0xxx_xxxx (最高位为0) |
接收端靠这个比特就能判断包类型 |
| Version | ✅ 必选 | ❌ 不携带 | 握手期需要协商版本 |
| DCID Length | ✅ 必选 | ❌ 不携带 | Short Header 依赖事先协商好的连接 ID 长度 |
| DCID | ✅ 必选 | ✅ 必选 | 目标连接 ID |
| SCID Length | ✅ 必选 | ❌ 不携带 | 源连接 ID 长度(已协商) |
| SCID | ✅ 必选 | ❌ 不携带 | 源连接 ID(已协商) |
| Packet Number | 变长 (1~4 字节) | 变长 (1~4 字节) | 包序号 |
| Length | ✅ 必选 | ❌ 不携带 | Short Header 必须是 Datagram 最后一个包 |
| Token | 可选 | ❌ 不携带 | Retry 时携带 |
| Version-Specific | 多种 | 无 | Long Header 包含版本相关字段 |
从这个对比表可以看到:Short Header 之所以"短",是因为它把大量在握手期已经协商好的信息(如版本号、源连接 ID)全部省略了。这些信息在 1-RTT 阶段已经是双方的"共识",没必要在每个包里重复携带。
现在让我们把这些包型放回时间线上。但要注意,QUIC 的时间线不是一条整整齐齐的直线,而更像一条主线旁边挂着几条岔路。
主线其实只有三步:Initial 点火 -> Handshake 接力 -> 1-RTT 稳定传输。
Initial Packet 是整个连接的起点。当客户端第一次向服务端发起连接时,它发出的第一把火就是 Initial。这个包里装着 TLS ClientHello 和 QUIC 握手起步所需的信息。也正因为这是双方第一次接触,服务端在还没有完全确认对端身份前,发送量会受到严格限制,以避免被人拿去做 UDP 放大攻击。
Handshake Packet 是第二棒。当客户端和服务端开始交换真正的握手密文时,它们就进入了 Handshake 级别。你可以把它理解成:双方已经不再只是"打招呼",而是在正式商量"接下来我们到底用哪套密钥、按什么规则继续说下去"。
1-RTT Packet 则是连接稳定期的常态。等握手完成之后,真正高频、长期、承载应用数据的工作状态,就是 1-RTT。它不是戏剧性的高潮,反而像一辆终于驶上高速的列车——从这一刻起,连接才进入它最常见、也最有生产力的运行姿态。
剩下几个包型,都不是这条主线上的必经站,而是各自负责处理特定场景的岔路:
0-RTT Packet 是"抢跑"。如果客户端手里握着上一次会话留下的恢复材料,它可以在这次连接刚点火时就提前发送一部分应用数据,不必老老实实等握手全程走完。但抢跑不是白拿的礼物,它天然带着可重放风险,所以服务端必须十分克制地接它。
Retry Packet 是"设卡"。如果服务端不想立刻相信眼前这个 Initial 包,它可以先回一个 Retry,要客户端把 Token 带回来,证明自己确实站在那个源地址上。它不是握手的下一阶段,而是服务端临时拉起的一道关卡。
Version Negotiation Packet 则更像"对频道"。如果客户端说的是一个服务端根本听不懂的 QUIC 版本,双方连第一句话都没法真正开始。这时候服务端回的不是握手答复,而是一张"我听得懂这些版本"的清单,让客户端重新挑一个频道再来。
如果你是第一次接触 QUIC,这一节先记住一个最重要的判断就够了:看到 Long Header,说明双方还在建立共识;看到 Short Header,说明连接已经进入高频数据期。 至于第一字节里的 packet_type、spin_bit、key_phase 这些更细的字段,我们在它们真正开始影响行为的章节里再逐个拆开,不急着在这里把整张表背下来。
6.5 先统一语言:Version Negotiation 为什么发生在握手之前
在讲完包类型的时间线之后,有一个前置话题需要交代清楚,否则后面的握手章节会默认"双方天然版本一致",读者却不知道为什么。
QUIC 的版本协商发生在握手之前——因为版本号直接决定了后续所有包的解释方式。
这和 TLS 不一样。TLS 的版本协商发生在 Record 层,不影响 TCP 怎么解包;但 QUIC 的包头格式、加密方式、Frame 类型、乃至整个协议行为,都和版本强相关。如果服务端用 QUIC v2 的方式解读客户端发来的 v1 包,结果将是灾难性的。因此,服务端必须先确认自己能理解这个版本,才能开始处理握手。
具体来说:客户端发送第一个 Initial Packet,包头里携带着自己使用的 QUIC 版本号。服务端如果认识这个版本,就正常继续握手——这是绝大多数场景。如果不认识,服务端会回复一个 Version Negotiation Packet,列出自己支持的版本列表,让客户端重新选择。整个过程对上层应用是透明的。
在 quicX 的 DecodePackets() 里,你能看到这个逻辑的具体落地:解析器在读完首字节(判断 Long Header)之后,会紧接着 peek Version 字段。如果 version 为 0,创建 VersionNegotiationPacket;如果 version 不被识别,只解 header、消耗掉剩余 buffer,让上层有机会回一个 VN 包。这就是"先对频道,再开口说话"在代码里的投影。
对于这本书来说,Version Negotiation 的技术细节我们不在这里展开(RFC 9000 §6 有完整定义)。你只需要记住它在连接建立中的位置:它是握手的前置条件,是双方真正开始交换密钥之前的"对频道"。理解了这一点,后续章节里"为什么这个包能用这个版本"的疑问就不会出现了。
6.6 第一现场:一个 UDP 数据报如何走进 quicX
好了,协议层面的地图我们已经铺完了。现在让我们把视角从协议规范切到工程实现,看看一个真实的 UDP 数据报在 quicX 里是如何被处理的。
当你写下一个 QUIC 服务器,绑定到一个 UDP 端口,开始 recvfrom 的时候,故事就开始了。
在 quicX 中,一个 UDP Datagram 从网卡到协议处理的路径是这样的:UdpReceiver::OnRead() 通过 epoll / kqueue 的事件驱动拿到一个裸的 UDP 报文,封装成 NetPacket,然后回调给 Master::OnPacket()。Master 是所有连接的"前台"——它负责做第一轮解析,然后根据 Connection ID 把包路由到正确的 Worker 线程。
quicX 的第一个动作,是识别这个 Datagram 里到底有几个 Packet。Master 调用 MsgParser::ParsePacket(),而 MsgParser 的核心就是调用 DecodePackets() 函数。让我们看看这个函数的真实结构(src/quic/packet/packet_decode.cpp):
bool DecodePackets(std::shared_ptr<common::IBuffer> buffer,
std::vector<std::shared_ptr<IPacket>>& packets) {
HeaderFlag flag;
while (buffer->GetDataLength() > 0) {
// 第一步:读取首字节,判断 Long Header 还是 Short Header
if (!flag.DecodeFlag(buffer)) { return false; }
std::shared_ptr<IPacket> packet;
if (flag.GetHeaderType() == PacketHeaderType::kShortHeader) {
packet = std::make_shared<Rtt1Packet>(flag.GetFlag());
} else {
// Long Header:再 peek 4 字节 Version 字段
// version == 0 → Version Negotiation
// version 不认识 → 仅解 header,准备回 VN 包
// version 认识 → 按 packet_type 创建对应包对象
switch (flag.GetPacketType()) {
case PacketType::kInitialPacketType:
packet = std::make_shared<InitPacket>(flag.GetFlag()); break;
case PacketType::kHandshakePacketType:
packet = std::make_shared<HandshakePacket>(flag.GetFlag()); break;
case PacketType::k0RttPacketType:
packet = std::make_shared<Rtt0Packet>(flag.GetFlag()); break;
// ...
}
}
// 第二步:只解包头,不解密载荷
if (!packet->DecodeWithoutCrypto(buffer)) { return false; }
packets.emplace_back(packet);
}
return true;
}
注意这个 while (buffer->GetDataLength() > 0) 循环——它就是处理 Coalescing 的核心逻辑。quicX 会按顺序尝试解析每一个 Packet,直到 buffer 消耗完毕或者遇到无法解析的情况为止。如果一个 Datagram 里 Coalescing 了三个 Packet(比如 Initial + Handshake + 0-RTT),解析器会依次创建对应的包对象,逐个处理。有一点要特别注意:UDP 是数据报协议,recvfrom 返回的就是一个完整的 Datagram,不存在"后续 Packet 还没收全"的情况——如果剩余字节无法组成一个合法 Packet,那说明数据有损,直接丢弃即可。
解析出包列表之后,MsgParser 会从第一个包的 Header 中提取 Destination Connection ID,Master 据此把整组包路由到对应的 Worker。Worker 再把包交给对应的 BaseConnection——到这里,包才真正进入了"连接上下文"。
接下来是两阶段解码的第二步。 连接拿到包之后,会根据包的加密级别找到对应的 ICryptographer(密钥管理器),调用 packet->DecodeWithCrypto() 完成解密和 Frame 解码。为什么要拆成两步?因为握手早期最麻烦的地方在于:包已经到了,密钥却未必已经完全就绪。连接管理层需要先根据 Version、DCID、SCID、包类型这些明文信息做路由和状态判断,然后才能等后续的握手推进到可用密钥出现,再把加密载荷真正解开。quicX 在 IPacket 接口上定义的 DecodeWithoutCrypto() 和 DecodeWithCrypto() 这对虚函数,正是这种两阶段设计的直接体现。
最后是 Frame 的解析与分发。 一个 Packet 解密后,里面的 Frame 列表被交给 FrameProcessor::OnFrames()。这个函数的结构极其直白——一个 switch(type) 覆盖了 QUIC 定义的所有帧类型:CRYPTO 帧转交给 ConnectionCrypto、ACK 帧交给 ACK 跟踪器、STREAM 帧交给 StreamManager、MAX_DATA 帧交给流控模块……每个分支只负责识别语义并转交,不做任何业务裁决。
这一步最重要的,不是分发表长什么样,而是职责边界非常清楚:Packet 层到这里为止,已经完成了"把密封信送到前台并拆开外层信封"的工作;接下来每一张便条该交给哪个部门处理,才轮到 Frame 层去分派。 也正因为语义是在这一层才真正被识别出来,QUIC 后续的语义重传、流控更新、握手推进,才能各走各的路,而不是被外层某个包的命运捆成一团。
当所有 Frame 都处理完毕,quicX 会推进连接状态机(ConnectionStateMachine):可能从 "ClientHello Sent" 变成 "ServerHello Received",可能从 "Handshake Complete" 变成 "Established",也可能只是更新一下统计信息。
回头看这条完整的路径——
UdpReceiver::OnRead()
→ Master::OnPacket()
→ MsgParser::ParsePacket()
→ DecodePackets() // while 循环,逐包解析
→ HeaderFlag::DecodeFlag() // 首字节判断 Long/Short
→ packet->DecodeWithoutCrypto() // 只解包头,保存密文引用
→ Worker::HandlePacket()
→ BaseConnection::OnPackets()
→ packet->DecodeWithCrypto() // 解密 + 帧解码
→ FrameProcessor::OnFrames() // switch-case 逐帧分发
→ ConnectionCrypto / StreamManager / ACK tracker / ...
理解了这个流水线,你就能明白为什么 quicX 的代码组织是那个样子:为什么 packet/ 目录要独立于 frame/ 目录,为什么 ConnectionCrypto 要单独一个模块,为什么 ConnectionStateMachine 是整个连接的大动脉。
这正是协议地图在工程里的投影:先辨认信封,再拆出信件,最后才决定信件该送到哪个部门。从内存池、Buffer、I/O、线程到定时器,那些基础设施终于一起托住了 QUIC 协议的第一层骨架。
6.7 一条可以反复回来的线索
如果只能从本章带走一个东西,那就是这条线索:每一层只做一件事,做完就交给下一层。
Datagram 只管投递,不关心里面装了几个 Packet;Packet 只管路由和保护,不关心 Frame 表达的语义;Frame 只管翻译一条条指令,不关心连接状态该怎么推进。quicX 的两阶段解码——先 DecodeWithoutCrypto 拆信封,再 DecodeWithCrypto 读信件——不过是这条线索在 C++ 里的一次朴素落地。
后面的章节会一头扎进握手、加密、重传、流控的细节里。每当细节让人头晕的时候,沿着这条线索往回走一步,找到自己所在的层次,混乱通常就会散开。