土法炼钢兴趣小组的算法知识备份

QUIC 协议拆解(下):用 C 实现一个最小 QUIC 握手

目录

上篇我们讲了 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 HeaderShort Header

Long Header vs Short Header

QUIC 包结构: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(&params);
    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, &params,
                           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 连接从建立到关闭的完整生命周期。每个状态对应一个加密级别,状态转换由特定的包类型触发:

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 均可,只需换头文件和链接库。


总结 & 延伸

核心要点回顾:

  1. QUIC 包 = Header + 加密帧序列,Long Header 握手用,Short Header 数据用
  2. Initial 密钥从 DCID 派生——不保密,但防篡改
  3. ngtcp2 只做状态机,I/O 和定时器由你控制
  4. 1-RTT 握手三步走,第三步就可以带应用数据
  5. 从库到生产:拥塞控制、超时重传、0-RTT、TLS 集成都需要额外工程

相关文章

参考资料


By .