上篇我们讲了 QUIC 的设计哲学——为什么 TCP 改不动了、为什么要把传输层搬到用户态、为什么加密是强制的。哲学聊完了,这篇动手。
目标很简单:用 C + ngtcp2 库,从一个裸 UDP socket 开始,实现一个最小的 QUIC 客户端——能完成握手、发送一段数据、然后优雅关闭。过程中我们会逐字节拆解每一个包、每一帧、每一级密钥的由来。
如果你还没读过 TLS 1.3 的握手细节,建议先看 不到 500 行 C 实现 TLS 1.3 握手,因为 QUIC 的加密层几乎就是 TLS 1.3 换了个壳。
⚠️ 教学代码,不可用于生产环境。 跳过了证书验证、0-RTT、连接迁移等关键安全机制。
一、QUIC 包结构拆解
QUIC 跑在 UDP 之上,一个 UDP 数据报里可以塞多个 QUIC 包(coalesced packets)。包分两大类:Long Header 和 Short Header。
Long Header vs Short Header
| 特性 | Long Header | Short Header |
|---|---|---|
| 首字节最高位 | 1(Header Form = 1) |
0(Header Form = 0) |
| 包含 Version | ✅ 32 bits | ❌ |
| 包含 SCID | ✅ | ❌ |
| 使用场景 | Initial、Handshake、0-RTT、Retry | 1-RTT(握手完成后) |
| 设计目的 | 建立连接,双方还不认识 | 已建立连接,最小化头部开销 |
Initial Packet 格式
Initial 包是客户端发出的第一个 QUIC 包:
Initial Packet {
Header Form (1) = 1, // Long Header 标识
Fixed Bit (1) = 1,
Long Packet Type (2) = 0, // 0x00 = Initial
Reserved Bits (2),
Packet Number Length (2), // PN 字节数 - 1
Version (32), // QUIC v1 = 0x00000001
DCID Length (8), DCID (0..160), // 目标连接 ID
SCID Length (8), SCID (0..160), // 源连接 ID
Token Length (var), Token (..), // Retry/NEW_TOKEN 提供
Length (var), // 剩余字节数
Packet Number (8..32), // 1-4 字节
Payload (..), // 加密帧序列
}
RFC 9000 要求客户端第一个 Initial 包必须填充到至少 1200 字节——防止放大攻击(amplification attack),让服务器的响应不超过请求的 3 倍。
Packet Number 编码
QUIC 的 Packet Number 用可变长度编码(1-4 字节),只编码最低几位——接收方根据已知的最大 PN 来还原完整值。这个设计省带宽,但实现起来需要小心溢出和回绕。
QUIC 帧类型
一个 QUIC 包的 Payload 是一系列帧(frame)的拼接,帧是 QUIC 的最小语义单元:
| 帧类型 | Type ID | 用途 | 出现在 |
|---|---|---|---|
| PADDING | 0x00 | 填充到最小包大小 | Initial, Handshake, 1-RTT |
| ACK | 0x02-0x03 | 确认收到的包号范围 | Initial, Handshake, 1-RTT |
| CRYPTO | 0x06 | 传输 TLS 握手消息 | Initial, Handshake |
| STREAM | 0x08-0x0f | 应用层数据 | 0-RTT, 1-RTT |
| MAX_DATA | 0x10 | 连接级流控窗口 | 1-RTT |
| MAX_STREAM_DATA | 0x11 | 流级流控窗口 | 1-RTT |
| CONNECTION_CLOSE | 0x1c-0x1d | 关闭连接 | 任意 |
关键区别:CRYPTO 帧承载 TLS 握手消息,STREAM 帧承载应用数据。QUIC 不用 TLS Record Layer,把 TLS 消息直接塞进 CRYPTO 帧。
二、加密层:QUIC 怎么用 TLS 1.3
TLS 1.3 不是直接套在 QUIC 上面
传统 HTTPS 的分层是
TCP → TLS Record Layer → HTTP。QUIC
彻底重构了这个关系——只用 TLS 1.3
的握手状态机(ClientHello、ServerHello、Finished……),完全不用
TLS Record Layer。TLS 消息通过 CRYPTO 帧传输,加解密由 QUIC
自己的 Packet Protection 完成。详见 RFC
9001。
Initial Keys:从 Connection ID 派生
这是 QUIC 最精巧的设计之一。握手还没开始,双方就已经有了加密密钥——Initial keys 从客户端选的 DCID 通过 HKDF 派生。谁都能算出来?没错,Initial 加密不提供机密性,但防止中间设备篡改和误解析 payload。
推导过程(RFC 9001 Section 5.2):
static const uint8_t initial_salt_v1[] = {
0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17,
0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a
};
void derive_initial_keys(const uint8_t *dcid, size_t dcid_len,
uint8_t client_key[16], uint8_t client_iv[12],
uint8_t server_key[16], uint8_t server_iv[12]) {
uint8_t initial_secret[32], client_secret[32], server_secret[32];
// HKDF-Extract: 从 DCID 提取初始密钥材料
hkdf_extract(initial_salt_v1, 20, dcid, dcid_len, initial_secret);
// 派生客户端/服务端 initial secret
hkdf_expand_label(initial_secret, 32, "client in", 9, NULL, 0, client_secret, 32);
hkdf_expand_label(initial_secret, 32, "server in", 9, NULL, 0, server_secret, 32);
// 从 secret 派生 key 和 IV
hkdf_expand_label(client_secret, 32, "quic key", 8, NULL, 0, client_key, 16);
hkdf_expand_label(client_secret, 32, "quic iv", 7, NULL, 0, client_iv, 12);
hkdf_expand_label(server_secret, 32, "quic key", 8, NULL, 0, server_key, 16);
hkdf_expand_label(server_secret, 32, "quic iv", 7, NULL, 0, server_iv, 12);
}如果你读过 TLS 1.3
握手实现,会发现 HKDF-Expand-Label
的用法几乎一模一样——QUIC 直接复用了 TLS 1.3 的 Key
Schedule。
三级密钥
| 加密级别 | 何时可用 | 派生自 | 保护什么 |
|---|---|---|---|
| Initial | 连接最开始 | DCID(公开信息) | ClientHello / ServerHello |
| Handshake | 收到 ServerHello 后 | TLS handshake secret | Certificate, Finished 等 |
| 1-RTT | 握手完成后 | TLS master secret | 应用数据 |
每一级都用 AES-128-GCM 或 ChaCha20-Poly1305 做 AEAD 加密,nonce = IV ⊕ Packet Number。
三、用 ngtcp2 实现最小客户端
ngtcp2 是最成熟的 C 语言 QUIC 库之一。设计哲学很纯粹:不做任何 I/O——不管你用 epoll、io_uring 还是阻塞 socket,ngtcp2 只负责协议状态机。你的职责:收 UDP 包 → 喂给 ngtcp2 → ngtcp2 产出要发的数据 → 你发 UDP 包。
下面是核心代码(完整版约 200 行,这里展示关键部分):
/* mini_quic_client.c — 最小 QUIC 客户端(ngtcp2 + OpenSSL)
* 编译: gcc -o mini_quic_client mini_quic_client.c \
* -lngtcp2 -lngtcp2_crypto_openssl -lssl -lcrypto
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netdb.h>
#include <time.h>
#include <ngtcp2/ngtcp2.h>
#include <ngtcp2/ngtcp2_crypto.h>
#include <ngtcp2/ngtcp2_crypto_openssl.h>
#include <openssl/ssl.h>
#include <openssl/rand.h>
#define BUF_SIZE 65535
typedef struct {
ngtcp2_conn *conn;
SSL_CTX *ssl_ctx; SSL *ssl;
int fd; int64_t stream_id; int handshake_done;
} client_ctx;
static ngtcp2_tstamp timestamp_ns(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (ngtcp2_tstamp)ts.tv_sec * NGTCP2_SECONDS
+ (ngtcp2_tstamp)ts.tv_nsec;
}
/* ---- 应用层回调(只列关键的几个) ---- */
static int on_recv_stream_data(ngtcp2_conn *conn, uint32_t flags,
int64_t stream_id, uint64_t offset,
const uint8_t *data, size_t datalen,
void *user_data, void *stream_user_data) {
printf("[RECV] stream=%ld len=%zu: ", stream_id, datalen);
fwrite(data, 1, datalen, stdout); printf("\n");
return 0;
}
static int on_handshake_completed(ngtcp2_conn *conn, void *user_data) {
((client_ctx *)user_data)->handshake_done = 1;
printf("[INFO] QUIC 握手完成!\n");
return 0;
}
static void on_rand(uint8_t *dest, size_t destlen,
const ngtcp2_rand_ctx *rand_ctx) {
(void)rand_ctx; RAND_bytes(dest, (int)destlen);
}
static int on_get_new_cid(ngtcp2_conn *conn, ngtcp2_cid *cid,
uint8_t *token, size_t cidlen, void *user_data) {
RAND_bytes(cid->data, (int)cidlen); cid->datalen = cidlen;
RAND_bytes(token, NGTCP2_STATELESS_RESET_TOKENLEN);
return 0;
}
/* ---- 事件循环:握手 → 发数据 → 关闭 ---- */
static int event_loop(client_ctx *ctx) {
uint8_t buf[BUF_SIZE];
int data_sent = 0, close_timer = 20;
for (;;) {
ngtcp2_tstamp now = timestamp_ns();
if (ngtcp2_conn_get_expiry(ctx->conn) <= now)
ngtcp2_conn_handle_expiry(ctx->conn, now);
ngtcp2_path_storage ps; ngtcp2_path_storage_zero(&ps);
ngtcp2_pkt_info pi;
// 握手完成后打开流并发送数据
if (ctx->handshake_done && !data_sent) {
if (ngtcp2_conn_open_bidi_stream(ctx->conn,
&ctx->stream_id, NULL) == 0) {
const uint8_t *msg = (const uint8_t *)"Hello, QUIC!\n";
ngtcp2_vec dv = {.base = (uint8_t *)msg, .len = 13};
ngtcp2_ssize ndl, nw;
nw = ngtcp2_conn_writev_stream(ctx->conn, &ps.path, &pi,
buf, sizeof(buf), &ndl,
NGTCP2_WRITE_STREAM_FLAG_FIN,
ctx->stream_id, &dv, 1, now);
if (nw > 0) { send(ctx->fd, buf, (size_t)nw, 0); data_sent=1; }
}
}
// 发送协议控制包(ACK / 握手消息等)
for (;;) {
ngtcp2_ssize nw = ngtcp2_conn_write_pkt(ctx->conn,
&ps.path, &pi, buf, sizeof(buf), now);
if (nw <= 0) break;
send(ctx->fd, buf, (size_t)nw, 0);
}
// 接收来自服务器的 UDP 包(50ms 超时)
struct timeval tv = {.tv_sec=0, .tv_usec=50000};
setsockopt(ctx->fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
ssize_t nr = recv(ctx->fd, buf, sizeof(buf), 0);
if (nr > 0) {
ngtcp2_path path = {0}; ngtcp2_pkt_info rpi = {0};
int rv = ngtcp2_conn_read_pkt(ctx->conn, &path, &rpi,
buf, (size_t)nr, timestamp_ns());
if (rv == NGTCP2_ERR_DRAINING) return 0;
}
if (ngtcp2_conn_in_draining_period(ctx->conn)) return 0;
if (data_sent && --close_timer <= 0) {
ngtcp2_ccerr ccerr; ngtcp2_ccerr_default(&ccerr);
ngtcp2_ssize cw = ngtcp2_conn_write_connection_close(
ctx->conn, &ps.path, &pi, buf, sizeof(buf), &ccerr, now);
if (cw > 0) send(ctx->fd, buf, (size_t)cw, 0);
printf("[INFO] 发送 CONNECTION_CLOSE\n");
return 0;
}
}
}
int main(int argc, char *argv[]) {
if (argc < 3) { fprintf(stderr, "用法: %s <host> <port>\n", argv[0]); return 1; }
client_ctx ctx = {0};
/* 1) UDP socket */
struct sockaddr_storage raddr, laddr; socklen_t rlen, llen;
struct addrinfo hints = {.ai_family=AF_UNSPEC, .ai_socktype=SOCK_DGRAM}, *res;
// 生产环境必须检查所有返回值
getaddrinfo(argv[1], argv[2], &hints, &res);
ctx.fd = socket(res->ai_family, SOCK_DGRAM, 0);
connect(ctx.fd, res->ai_addr, res->ai_addrlen);
memcpy(&raddr, res->ai_addr, res->ai_addrlen); rlen = res->ai_addrlen;
freeaddrinfo(res);
llen = sizeof(laddr);
getsockname(ctx.fd, (struct sockaddr *)&laddr, &llen);
/* 2) TLS 上下文 */
ctx.ssl_ctx = SSL_CTX_new(TLS_client_method());
SSL_CTX_set_min_proto_version(ctx.ssl_ctx, TLS1_3_VERSION);
SSL_CTX_set_max_proto_version(ctx.ssl_ctx, TLS1_3_VERSION);
static const uint8_t alpn[] = "\x02h3";
SSL_CTX_set_alpn_protos(ctx.ssl_ctx, alpn, sizeof(alpn) - 1);
SSL_CTX_set_verify(ctx.ssl_ctx, SSL_VERIFY_NONE, NULL); // ⚠️ 教学用
ngtcp2_crypto_openssl_configure_client_context(ctx.ssl_ctx);
ctx.ssl = SSL_new(ctx.ssl_ctx);
SSL_set_connect_state(ctx.ssl);
SSL_set_tlsext_host_name(ctx.ssl, argv[1]);
/* 3) ngtcp2 连接参数 */
ngtcp2_cid dcid, scid;
dcid.datalen = NGTCP2_MIN_INITIAL_DCIDLEN; RAND_bytes(dcid.data, (int)dcid.datalen);
scid.datalen = 8; RAND_bytes(scid.data, (int)scid.datalen);
ngtcp2_path path;
ngtcp2_addr_init(&path.local, (struct sockaddr *)&laddr, llen);
ngtcp2_addr_init(&path.remote, (struct sockaddr *)&raddr, rlen);
ngtcp2_settings settings; ngtcp2_settings_default(&settings);
settings.initial_ts = timestamp_ns();
ngtcp2_transport_params params; ngtcp2_transport_params_default(¶ms);
params.initial_max_streams_bidi = 4;
params.initial_max_data = 1048576;
params.initial_max_stream_data_bidi_local = 262144;
params.initial_max_stream_data_bidi_remote = 262144;
/* 4) 回调:加密部分由 ngtcp2_crypto_openssl 提供,应用层自己写 */
ngtcp2_callbacks cb = {
.client_initial = ngtcp2_crypto_client_initial_cb,
.recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb,
.encrypt = ngtcp2_crypto_encrypt_cb,
.decrypt = ngtcp2_crypto_decrypt_cb,
.hp_mask = ngtcp2_crypto_hp_mask_cb,
.recv_retry = ngtcp2_crypto_recv_retry_cb,
.update_key = ngtcp2_crypto_update_key_cb,
.delete_crypto_aead_ctx = ngtcp2_crypto_delete_crypto_aead_ctx_cb,
.delete_crypto_cipher_ctx = ngtcp2_crypto_delete_crypto_cipher_ctx_cb,
.get_path_challenge_data = ngtcp2_crypto_get_path_challenge_data_cb,
.version_negotiation = ngtcp2_crypto_version_negotiation_cb,
.recv_stream_data = on_recv_stream_data,
.handshake_completed = on_handshake_completed,
.rand = on_rand,
.get_new_connection_id = on_get_new_cid,
};
ngtcp2_conn_client_new(&ctx.conn, &dcid, &scid, &path,
NGTCP2_PROTO_VER_V1, &cb, &settings, ¶ms,
NULL, &ctx);
ngtcp2_conn_set_tls_native_handle(ctx.conn, ctx.ssl);
printf("[INFO] 连接 %s:%s ...\n", argv[1], argv[2]);
event_loop(&ctx);
ngtcp2_conn_del(ctx.conn);
SSL_free(ctx.ssl); SSL_CTX_free(ctx.ssl_ctx); close(ctx.fd);
return 0;
}编译和运行:
# 安装依赖 + 编译
sudo apt install libssl-dev
git clone --recursive https://github.com/ngtcp2/ngtcp2
cd ngtcp2
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release -DENABLE_OPENSSL=ON ..
make -j$(nproc)
sudo make install
gcc -o mini_quic_client mini_quic_client.c \
-lngtcp2 -lngtcp2_crypto_openssl -lssl -lcrypto
./mini_quic_client quic.example.com 443四、逐帧拆解握手过程
QUIC 的 1-RTT 握手由三步完成——和 TLS 1.3 一样只需要一个往返:
握手状态机
下面这张状态机图展示了 QUIC 连接从建立到关闭的完整生命周期。每个状态对应一个加密级别,状态转换由特定的包类型触发:
状态说明:
| 状态 | 加密级别 | 可发送的帧类型 | 退出条件 |
|---|---|---|---|
| Idle | 无 | — | 客户端发起连接 |
| Initial | Initial keys(从 DCID 派生) | CRYPTO, ACK, PADDING | 收到 ServerHello |
| 0-RTT | 早期数据密钥(PSK 派生) | STREAM, ACK | 收到 ServerHello,确认 0-RTT 是否被接受 |
| Handshake | Handshake keys | CRYPTO, ACK | 双方交换 Finished |
| 1-RTT | Application keys | 所有帧类型 | 发送 CONNECTION_CLOSE |
| Key Update | 新 Application keys | 所有帧类型 | 收到对端使用新密钥的包 |
| Closing | 最后使用的密钥 | CONNECTION_CLOSE | 收到对端 CONNECTION_CLOSE 或 3×PTO 超时 |
| Draining | — | 不发送任何包 | drain 定时器到期(通常 3×PTO) |
注意 Key Update 状态:1-RTT
建立后,任何一方都可以通过翻转包头的 Key Phase
位来触发密钥轮换。新旧密钥在过渡期同时有效(接收端两把都试),直到对端回复了一个使用新密钥的包。这是
QUIC 独有的——TLS 1.3 over TCP 用 KeyUpdate
消息做类似的事,但 QUIC 把它做到了包级别,更优雅。
Client Server
| |
| ① Client Initial |
| [CRYPTO: ClientHello] |
| [PADDING: 填充到 1200B] |
| -----------------------------------→|
| |
| ② Server Initial + Handshake |
| [CRYPTO: ServerHello] |
|←----------------------------------- |
| [CRYPTO: EE, Cert, CertVerify, |
| Finished] |
|←----------------------------------- |
| |
| ③ Client Handshake + 1-RTT |
| [CRYPTO: Finished] |
| [STREAM: 应用数据] |
| -----------------------------------→|
每一步的包内容
① Client Initial——加密级别 Initial(从
DCID 派生),帧:CRYPTO(TLS ClientHello,带
quic_transport_parameters 扩展)+ PADDING 到
1200B。
② Server Initial + Handshake——两个包 coalesced 到同一个 UDP 数据报。Initial 含 CRYPTO(ServerHello),Handshake 含 CRYPTO(EncryptedExtensions + Certificate + CertificateVerify + Finished)。
③ Client Handshake + 1-RTT——Handshake 含 CRYPTO(Finished),1-RTT 含 STREAM(应用数据)。客户端在第三步就可以发应用数据——这就是 1-RTT 的含义。
用 Wireshark 抓包验证
# 导出 TLS 密钥,让 Wireshark 能解密 QUIC
export SSLKEYLOGFILE=./quic_keys.log
sudo tcpdump -i any udp port 443 -w quic_capture.pcap &
./mini_quic_client quic.example.com 443
# Wireshark: Edit → Preferences → Protocols → TLS →
# (Pre)-Master-Secret log filename → 选择 quic_keys.log五点五、丢包检测与恢复
QUIC 的丢包检测和重传机制(RFC 9002)是它相比 TCP 的重要改进之一。TCP 的重传是基于段的——丢了第 N 个段就重传整个段。QUIC 的重传是基于帧的——丢了一个包,只需要把那个包里的帧塞到新包里重发,包号永远递增。
ACK 帧处理
QUIC 的 ACK 帧比 TCP 的 SACK 更强大——它可以表达任意多个不连续的已确认包号范围:
// ACK 帧结构伪代码
struct ack_frame {
uint64_t largest_acked; // 最大已确认包号
uint64_t ack_delay; // 接收到 largest_acked 到发送 ACK 的延迟(微秒)
uint64_t ack_range_count; // 间隔区间数量
uint64_t first_ack_range; // 从 largest_acked 向前连续确认的包数
struct {
uint64_t gap; // 未确认的包数(间隔)
uint64_t ack_range; // 该区间内连续确认的包数
} ranges[];
};
// 示例:确认了包号 10,11,12, 15,16, 20
// largest_acked = 20
// first_ack_range = 0 → 确认 [20, 20]
// ranges[0] = {gap=3, range=1} → 跳过 19,18,17, 确认 [15, 16]
// ranges[1] = {gap=2, range=2} → 跳过 14,13, 确认 [10, 12]丢包检测算法
QUIC 用三种信号检测丢包,任何一个触发就认为包丢了:
# QUIC 丢包检测伪代码(基于 RFC 9002)
# 中文注释说明每一步的逻辑
def on_ack_received(ack_frame):
"""收到 ACK 帧时触发丢包检测"""
newly_acked = 解析_ack_frame_得到已确认包号列表(ack_frame)
largest_acked = ack_frame.largest_acked
for pkt in sent_packets: # 遍历所有已发送但未确认的包
if pkt.packet_number in newly_acked:
# 包被确认了,从发送列表移除
on_packet_acked(pkt)
continue
# ── 信号 1:包号阈值(Packet Threshold) ──
# 如果比这个包更新的包已经被确认了 >= 3 个,
# 认为这个包丢了(类似 TCP 的三次重复 ACK)
if largest_acked - pkt.packet_number >= PACKET_THRESHOLD: # 默认 3
declare_lost(pkt)
continue
# ── 信号 2:时间阈值(Time Threshold) ──
# 如果距离发送已经超过 max(1.25 * smoothed_rtt, 1ms) + ack_delay,
# 认为这个包丢了
loss_delay = max(smoothed_rtt * TIME_THRESHOLD_FACTOR, 1ms) # 因子默认 9/8
if now() - pkt.time_sent > loss_delay:
declare_lost(pkt)
continue
def declare_lost(pkt):
"""确认包丢失,执行重传"""
# QUIC 不重传"包",而是重传包里的"帧"
# 包号永远递增,不会重用
for frame in pkt.frames:
if frame.type == CRYPTO:
# CRYPTO 帧:必须重传(握手消息不能丢)
queue_crypto_retransmit(frame)
elif frame.type == STREAM:
# STREAM 帧:标记对应流的数据段为待重传
mark_stream_data_for_retransmit(frame.stream_id,
frame.offset,
frame.length)
elif frame.type == ACK:
# ACK 帧:不需要重传(下一个 ACK 会包含最新信息)
pass
# MAX_DATA, MAX_STREAM_DATA 等:用最新值重发即可PTO:探测超时
当发送方没有收到任何 ACK 时(比如所有包都丢了),上面的丢包检测机制都不会触发。这时需要 PTO(Probe Timeout)作为兜底:
# PTO 定时器伪代码
def compute_pto():
"""计算 PTO 超时时间"""
# PTO = smoothed_rtt + max(4 * rttvar, 1ms) + max_ack_delay
# 每次 PTO 触发后加倍(指数退避)
pto = smoothed_rtt + max(4 * rttvar, 1ms)
if handshake_not_confirmed:
# 握手阶段不加 max_ack_delay(对端可能还没准备好)
pass
else:
pto += peer_max_ack_delay
return pto * (2 ** pto_backoff_count) # 指数退避
def on_pto_timeout():
"""PTO 超时处理"""
pto_backoff_count += 1
if has_crypto_data_in_flight():
# 握手数据优先:重传 CRYPTO 帧
retransmit_unacked_crypto()
else:
# 发送探测包(1-2 个,包含 PING 帧或待确认数据)
# 目的不是重传,而是"刺探"对端是否还活着
send_probe_packet(count=2)
# 重启 PTO 定时器(已加倍)
restart_pto_timer()QUIC vs TCP 重传机制对比
| 维度 | TCP | QUIC |
|---|---|---|
| 重传单位 | 段(序号空间复用) | 帧(包号永远递增) |
| 包号二义性 | 有(重传段的序号和原始相同) | 无(每个包号唯一) |
| RTT 估算准确度 | 受重传二义性影响 | 精确(包号唯一 → ACK 对应唯一发送时间) |
| 确认范围 | SACK(最多 4 个区间) | ACK(无限区间) |
| 丢包检测 | 3 次重复 ACK + RTO | 包号阈值 + 时间阈值 + PTO |
| RTO 最小值 | 1 秒(RFC 6298) | 无硬性下限(跟随 RTT) |
| 虚假重传恢复 | 需要 D-SACK / Eifel | 天然免疫(包号唯一) |
包号永远递增是 QUIC 最精妙的设计之一。TCP 的序号空间复用导致了”重传二义性”——收到一个 ACK 时,你不知道它确认的是原始传输还是重传。QUIC 彻底消除了这个问题,让 RTT 估算和丢包检测都更加准确。
五、发送和接收应用数据
打开 Stream 和发送数据
QUIC 的流是轻量级的——不需要额外握手,直接开:
int64_t stream_id;
ngtcp2_conn_open_bidi_stream(conn, &stream_id, NULL);
// 流 ID 最低 2 位决定类型:
// 0x00: 客户端双向 0x01: 服务端双向 0x02: 客户端单向 0x03: 服务端单向
// 发送数据——writev_stream 把流数据打包成 QUIC 包
const uint8_t *data = (const uint8_t *)"GET / HTTP/3\r\n";
ngtcp2_vec datav = {.base = (uint8_t *)data, .len = 14};
ngtcp2_ssize ndatalen, nw;
uint8_t buf[BUF_SIZE];
ngtcp2_path_storage ps; ngtcp2_path_storage_zero(&ps);
ngtcp2_pkt_info pi;
nw = ngtcp2_conn_writev_stream(conn, &ps.path, &pi, buf, sizeof(buf),
&ndatalen, NGTCP2_WRITE_STREAM_FLAG_NONE,
stream_id, &datav, 1, timestamp_ns());
if (nw > 0) send(fd, buf, (size_t)nw, 0);流控
QUIC 有两层流控:
| 帧 | 作用域 | 含义 |
|---|---|---|
| MAX_DATA | 整个连接 | 连接级接收窗口上限 |
| MAX_STREAM_DATA | 单条流 | 流级接收窗口上限 |
| MAX_STREAMS | 流数量 | 对端可打开的最大流数 |
ngtcp2 自动管理窗口更新——消费数据后调用 extend 函数即可:
// 通知 ngtcp2 已消费数据,触发发送 MAX_STREAM_DATA 帧
ngtcp2_conn_extend_max_stream_offset(conn, stream_id, datalen);
ngtcp2_conn_extend_max_offset(conn, datalen);连接关闭
优雅关闭只需一个 CONNECTION_CLOSE 帧,发送后进入 draining 状态:
ngtcp2_ccerr ccerr;
ngtcp2_ccerr_default(&ccerr); // error_code = 0
ngtcp2_ssize nw = ngtcp2_conn_write_connection_close(
conn, &ps.path, &pi, buf, sizeof(buf), &ccerr, timestamp_ns());
if (nw > 0) send(fd, buf, (size_t)nw, 0);六、从 QUIC 库到生产:需要自己处理什么
ngtcp2 给了你一个精确的协议状态机,但从这里到生产级还有不少路。
拥塞控制
ngtcp2 内置 NewReno(丢包驱动)和 BBR v2(模型驱动),一行配置切换:
settings.cc_algo = NGTCP2_CC_ALGO_BBR2; // 默认 NewReno超时和重传
QUIC 重传帧而不是
segment。丢包检测基于三个信号:packet threshold(间隔 ≥
3)、time threshold 和 PTO 兜底定时器。你只需正确调用
ngtcp2_conn_handle_expiry(),ngtcp2
自动处理重传逻辑。
0-RTT 恢复
之前建立过连接、保存了 PSK 的情况下,客户端第一个包就能带应用数据。但 0-RTT 有重放攻击风险——服务端必须保证幂等性。
TLS 库集成
ngtcp2 通过 ngtcp2_crypto_* 系列库对接不同
TLS 后端——OpenSSL 3.x、BoringSSL、GnuTLS、wolfSSL
均可,只需换头文件和链接库。
总结 & 延伸
核心要点回顾:
- QUIC 包 = Header + 加密帧序列,Long Header 握手用,Short Header 数据用
- Initial 密钥从 DCID 派生——不保密,但防篡改
- ngtcp2 只做状态机,I/O 和定时器由你控制
- 1-RTT 握手三步走,第三步就可以带应用数据
- 从库到生产:拥塞控制、超时重传、0-RTT、TLS 集成都需要额外工程
相关文章
- 不到 500 行 C 实现 TLS 1.3 握手——QUIC 的加密层根基
- QUIC 协议拆解(上):为什么 TCP 改不动了——设计哲学和架构