T-Box 端的 MPQUIC 连接建立:双 SIM 卡绑定与冗余路径初始化

0 阅读19分钟

工厂测试的第一天,工程师把刚烧录好的 T-Box 接上测试台,双 SIM 卡网络都注册完成,启动 QUIC 客户端。连接建立成功,qlog 显示两条路径都完成了 PATH_CHALLENGE 探测,标记为 Validated。他打开性能监控面板,心里想这次应该没问题。

然后他瞄了一眼 tcpdump 的输出。

rmnet0 的流量统计:1024 packets/10s。rmnet1 的流量统计:0 packets/10s。

奇怪。两条路径都 Validated 了,为什么 rmnet1 一个包都没有?他把 tcpdump 换成 -i any,看到了大量 QUIC 数据包,于是误以为是 tcpdump 的问题,改用 ss -unp 查看 socket 状态。

这一查才发现问题所在:两个 QUIC socket 的本地 IP 地址完全相同——都是 rmnet0 的 IP 地址。他明明分别给两个 socket 传入了不同的 local 地址,但内核在实际发包时,把两个 socket 都路由到了 rmnet0。

root cause 就藏在 setsockopt 里,准确说是没有调用 SO_BINDTODEVICE

上一篇(第06篇)讲清楚了 TQUIC 的 API 调用链和三个陷阱——那是"协议层面的坑"。这篇讲一个更底层的坑:在 T-Box 的 Linux 系统环境里,如何让两个 UDP socket 真正走不同的物理网卡。这一步的位置在 QUIC API 调用之前,和 QUIC 本身没有关系,完全是 Linux 网络栈的路由决策问题。但正因为它"太底层了",很多教程把它跳过了。

第一章:工厂测试的第一个坑:两条"路径"走同一张网卡

来解释为什么会发生这个问题。

T-Box 上的路由表通常长这样:

$ ip route show
default via 10.64.0.1 dev rmnet0 metric 100
default via 172.20.1.1 dev rmnet1 metric 200
10.64.0.0/24 dev rmnet0 proto kernel scope link src 10.64.0.5
172.20.0.0/24 dev rmnet1 proto kernel scope link src 172.20.1.3

两条默认路由,metric 100 和 metric 200。Linux 内核选路时,metric 更小的优先——rmnet0 的默认路由(metric 100)优先于 rmnet1(metric 200)。这意味着所有没有明确指定出口的包,都会走 rmnet0。

当工程师创建两个 socket 并绑定本地地址时,他的逻辑是这样的:

// 意图:socket 1 走 rmnet0,socket 2 走 rmnet1
int sock1 = socket(AF_INET, SOCK_DGRAM, 0);
int sock2 = socket(AF_INET, SOCK_DGRAM, 0);

struct sockaddr_in local1 = {.sin_family = AF_INET};
inet_pton(AF_INET, "10.64.0.5", &local1.sin_addr);  // rmnet0 的 IP
bind(sock1, (struct sockaddr *)&local1, sizeof(local1));

struct sockaddr_in local2 = {.sin_family = AF_INET};
inet_pton(AF_INET, "172.20.1.3", &local2.sin_addr);  // rmnet1 的 IP
bind(sock2, (struct sockaddr *)&local2, sizeof(local2));

bind() 调用设置了 socket 的本地源 IP 地址。工程师以为"绑定了 172.20.1.3(rmnet1 的 IP),socket 就会从 rmnet1 出去"。

但这个假设是错的。看内核 net/ipv4/af_inet.c__inet_bind() 的实现:bind() 做的是用 sk_bound_dev_if 选择 FIB 表来验证绑定地址的有效性,它只影响源地址选择,不影响出口网卡决策(flowi4_oif)。当 sock2 调用 sendto() 向云端服务器发包时,内核仍然按照路由表决定出口:查找"怎么到达目标 IP",找到的是默认路由 default via 10.64.0.1 dev rmnet0(metric 更小),所以包从 rmnet0 出去——即使 sock2 bind 的是 172.20.1.3(rmnet1 的 IP)。

这个包从 rmnet0 出去,但源 IP 是 172.20.1.3(rmnet1 的 IP)。这是一个源 IP 和出口网卡不匹配的包,在某些运营商的入口过滤(BCP38,源地址验证)下会被直接丢弃。就算侥幸没被丢弃,这条"路径"也不是真正走 rmnet1,冗余发送的物理独立性根本不存在。

更严重的后果:QUIC 的 PATH_CHALLENGE 从 rmnet0 出去,但源 IP 是 172.20.1.3。云端收到这个 PATH_CHALLENGE 后,认为它来自 172.20.1.3,回复 PATH_RESPONSE 到 172.20.1.3。T-Box 的 rmnet1 在 172.20.1.3 地址上等待,但这个 PATH_RESPONSE 实际上是从 rmnet0 出去的,会回到 rmnet0 上——而 rmnet0 不认识这个 PATH_RESPONSE(因为 PATH_CHALLENGE 是从 sock2 发出的,sock2 的 fd 在 rmnet1 上监听)。结果:PATH_CHALLENGE/RESPONSE 握手失败,路径探测超时,quic_conn_add_path() 添加的路径永远无法就绪。

但 qlog 会显示 path_id=1 的路径状态为 Validated——因为 TQUIC 内部按照 socket 状态判断,不知道底层两个 socket 实际上走了同一个网卡(src/connection/path.rs:383PathState::Validated)。这就造成了"qlog 说正常,tcpdump 显示 rmnet1 没有流量"的矛盾现象。

# 验证 socket 实际走哪个网卡(查看路由决策)
ip route get <云端服务器IP>
# 输出示例:
# <云端IP> via 10.64.0.1 dev rmnet0 src 10.64.0.5 uid 1000
# 内核会选 rmnet0,不管你 bind 了什么源 IP

# 验证两个 socket 的实际路由(用 ip route get 带 from 参数)
ip route get <云端IP> from 10.64.0.5    # rmnet0 的 IP
# 输出:<云端IP> via 10.64.0.1 dev rmnet0 src 10.64.0.5
ip route get <云端IP> from 172.20.1.3   # rmnet1 的 IP
# 输出:<云端IP> via 172.20.1.1 dev rmnet1 src 172.20.1.3
# 如果两条命令的输出都显示 dev rmnet0,说明路由表配置有问题

# 用 ss 验证 socket 绑定情况
ss -unp | grep <tquic_pid>
# 如果两行输出的本地 IP 都是 rmnet0 的 IP,说明绑定有问题

第二章:SO_BINDTODEVICE:强制指定出口网卡

解决方案很直接:使用 SO_BINDTODEVICE socket 选项。它直接设置内核 socket 结构体 sksk_bound_dev_if 字段(net/core/sock.c:657WRITE_ONCE(sk->sk_bound_dev_if, ifindex)),在发包时强制指定 flowi4_oif(出口网卡索引),完全绕过路由表的 metric 比较。

内核实现在 net/core/sock.c:641sock_bindtoindex_locked() 函数:

// net/core/sock.c:641(Linux 内核)
static int sock_bindtoindex_locked(struct sock *sk, int ifindex)
{
    // 关键权限检查:只有当 socket 已经绑定过网卡(sk_bound_dev_if != 0)
    // 才需要 CAP_NET_RAW 权限来修改绑定。第一次绑定不需要特殊权限。
    if (sk->sk_bound_dev_if && !ns_capable(net->user_ns, CAP_NET_RAW))
        return -EPERM;

    WRITE_ONCE(sk->sk_bound_dev_if, ifindex);  // 设置出口网卡
    sk_dst_reset(sk);  // 清空 cached routes,强制重新路由
    return 0;
}

这里有一个重要的细节:第一次调用 SO_BINDTODEVICEsk_bound_dev_if 为 0)不需要 CAP_NET_RAW 权限。只有在修改已绑定的网卡时才需要。T-Box 上的 QUIC 客户端进程通常以普通用户身份运行,第一次绑定是没有权限问题的。但如果需要在运行时切换绑定的网卡(比如网卡重启后重新绑定),就需要 CAP_NET_RAW

完整的正确做法分三步:绑定设备、动态获取该设备的 IP、绑定本地地址。这三步缺一不可:

#include <sys/socket.h>
#include <net/if.h>
#include <arpa/inet.h>
#include <ifaddrs.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#include <unistd.h>

// 创建 UDP socket
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0) { perror("socket"); return -1; }

// 第一步:SO_BINDTODEVICE 绑定到指定网卡
// 内核实现:net/core/sock.c:685 sock_setbindtodevice()
// 第一次绑定不需要 CAP_NET_RAW;修改已绑定的网卡才需要
struct ifreq ifr;
memset(&ifr, 0, sizeof(ifr));
strncpy(ifr.ifr_name, "rmnet1", IFNAMSIZ - 1);

if (setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, &ifr, sizeof(ifr)) < 0) {
    if (errno == EPERM) {
        // 已绑定过网卡,修改需要 CAP_NET_RAW
        fprintf(stderr, "SO_BINDTODEVICE: need CAP_NET_RAW to change binding\n");
    } else {
        perror("SO_BINDTODEVICE");
    }
    close(sock);
    return -1;
}

// 第二步:获取 rmnet1 的当前 IPv4 地址
// 不能 hardcode IP,因为每次网络注册 IP 可能变化(DHCP 动态分配)
char rmnet1_ip[INET_ADDRSTRLEN] = {0};
struct ifaddrs *ifaddr, *ifa;
if (getifaddrs(&ifaddr) == 0) {
    for (ifa = ifaddr; ifa != NULL; ifa = ifa->ifa_next) {
        if (ifa->ifa_addr == NULL) continue;
        if (strcmp(ifa->ifa_name, "rmnet1") != 0) continue;
        if (ifa->ifa_addr->sa_family != AF_INET) continue;
        struct sockaddr_in *sin = (struct sockaddr_in *)ifa->ifa_addr;
        inet_ntop(AF_INET, &sin->sin_addr, rmnet1_ip, sizeof(rmnet1_ip));
        break;
    }
    freeifaddrs(ifaddr);
}

if (rmnet1_ip[0] == '\0') {
    fprintf(stderr, "rmnet1 has no IPv4 address yet, network not ready\n");
    close(sock);
    return -1;
}

// 第三步:bind 本地地址(固定源 IP,让内核分配端口)
struct sockaddr_in local_addr = {0};
local_addr.sin_family = AF_INET;
inet_pton(AF_INET, rmnet1_ip, &local_addr.sin_addr);
local_addr.sin_port = htons(0);  // 让内核分配端口

if (bind(sock, (struct sockaddr *)&local_addr, sizeof(local_addr)) < 0) {
    perror("bind");
    close(sock);
    return -1;
}
// 现在这个 socket 的所有出包都会强制走 rmnet1

为什么三步都需要?

  • 只做 SO_BINDTODEVICE 而不 bind 本地地址:源 IP 由内核按照 rmnet1 的地址自动选择,通常正确,但在 rmnet1 有多个 IP 的情况下可能选错。
  • 只 bind 本地地址而不做 SO_BINDTODEVICE:如前文所述,源 IP 绑定不影响出口网卡选择,包仍然可能从 rmnet0 出去。
  • 三步都做:强制出口网卡(SO_BINDTODEVICE)+ 固定源 IP(bind),完全明确地指定了包的出口和源地址。

T-Box 特有问题 1:SIM 卡网卡名称不固定

这是只有做过 T-Box 开发才会遇到的问题。不同厂商的 modem 驱动,网卡命名规则不一样:

Modem 芯片/驱动网卡命名说明
高通 QMAP 驱动(RMNET)rmnet0, rmnet1, rmnet_data0, rmnet_data1最常见
GobiNet 驱动(某些联发科)wwan0, wwan1
CDC-WDM + cdc_etherusb0, usb1USB 接口 modem
某些移远模组eth1, eth2以太网仿真模式

如果在代码里 hardcode rmnet0,换一款 modem 就会直接出错。

动态发现 SIM 卡网卡的可靠方法是通过 /sys/class/net/ 查看网卡类型。根据内核源码 drivers/net/ethernet/qualcomm/rmnet/rmnet_vnd.c:282,高通 RMNET 设备的 type 字段设置为 ARPHRD_RAWIP。查看 include/uapi/linux/if_arp.h:64

#define ARPHRD_RAWIP    519  /* Raw IP */

注意:正确值是 519,不是 512(512 对应的是 ARPHRD_PPP,是 PPP 拨号接口)。

# 查找 T-Box 上所有 ARPHRD_RAWIP 类型网卡(高通 RMNET,type=519)
for netdev in /sys/class/net/*/; do
    type=$(cat "${netdev}type" 2>/dev/null)
    name=$(basename "$netdev")
    if [ "$type" = "519" ]; then
        echo "RMNET 网卡: $name"
        ip addr show "$name" | grep -E "inet |state "
    fi
done
# 输出示例(高通 QMAP 方案):
# RMNET 网卡: rmnet_data0
#     inet 10.64.0.5/24 scope global rmnet_data0
# RMNET 网卡: rmnet_data1
#     inet 172.20.1.3/24 scope global rmnet_data1

在 C 代码里实现动态发现(查找 type=519 的网卡):

#include <dirent.h>
#include <stdio.h>

// 获取所有 ARPHRD_RAWIP 网卡名(type=519),存入 names 数组
int find_rmnet_interfaces(char names[][IFNAMSIZ], int max_count) {
    DIR *d = opendir("/sys/class/net");
    if (!d) return 0;

    int count = 0;
    struct dirent *entry;

    while ((entry = readdir(d)) != NULL && count < max_count) {
        if (entry->d_name[0] == '.') continue;

        char type_path[256];
        snprintf(type_path, sizeof(type_path),
                 "/sys/class/net/%s/type", entry->d_name);

        FILE *f = fopen(type_path, "r");
        if (!f) continue;

        int type;
        int n = fscanf(f, "%d", &type);
        fclose(f);

        // ARPHRD_RAWIP = 519(高通 RMNET 驱动,见 include/uapi/linux/if_arp.h:64)
        if (n == 1 && type == 519) {
            strncpy(names[count], entry->d_name, IFNAMSIZ - 1);
            names[count][IFNAMSIZ - 1] = '\0';
            count++;
        }
    }
    closedir(d);

    // 按名称排序(确保 SIM1 对应 index 0,SIM2 对应 index 1)
    for (int i = 0; i < count - 1; i++) {
        if (strcmp(names[i], names[i+1]) > 0) {
            char tmp[IFNAMSIZ];
            strncpy(tmp, names[i], IFNAMSIZ);
            strncpy(names[i], names[i+1], IFNAMSIZ);
            strncpy(names[i+1], tmp, IFNAMSIZ);
        }
    }
    return count;
}

T-Box 特有问题 2:rmnet 网卡的 IP 是动态分配的

每次 SIM 卡重新注册网络(切换基站、4G/5G 切换、进出覆盖盲区后重注册),rmnet 网卡的 IP 地址会重新分配。

这意味着 QUIC 的 local_addr 在每次 IP 变化后都需要更新。如果不更新,socket bind 的还是旧 IP(已经失效),发出的包源地址错误,会被运营商的 BCP38 过滤丢弃。

监听网卡 IP 变化的标准方法是使用 rtnetlink(Linux 内核路由变更通知机制)。相关定义在内核 include/uapi/linux/rtnetlink.h

// include/uapi/linux/rtnetlink.h:37
RTM_NEWADDR = 20,  // 地址新增
RTM_DELADDR = 21,  // 地址删除

// include/uapi/linux/rtnetlink.h:697
#define RTMGRP_IPV4_IFADDR  0x10  // 订阅 IPv4 地址变更

地址变更事件的触发位置在 net/ipv4/devinet.c:新地址添加时(line 565)、地址删除时(line 411)、设备启动时(line 1560)等多个位置都会发送 RTM_NEWADDRRTM_DELADDR

#include <linux/rtnetlink.h>
#include <sys/socket.h>

int nl_sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
if (nl_sock < 0) { perror("netlink socket"); return -1; }

struct sockaddr_nl nl_addr = {
    .nl_family = AF_NETLINK,
    .nl_groups = RTMGRP_IPV4_IFADDR   // 订阅 IPv4 地址变更事件
};
bind(nl_sock, (struct sockaddr *)&nl_addr, sizeof(nl_addr));

// 在事件循环中监听地址变化
uint8_t buf[8192];
while (1) {
    ssize_t len = recv(nl_sock, buf, sizeof(buf), 0);
    struct nlmsghdr *nh = (struct nlmsghdr *)buf;
    for (; NLMSG_OK(nh, len); nh = NLMSG_NEXT(nh, len)) {
        if (nh->nlmsg_type == RTM_NEWADDR || nh->nlmsg_type == RTM_DELADDR) {
            struct ifaddrmsg *ifa = (struct ifaddrmsg *)NLMSG_DATA(nh);
            char ifname[IFNAMSIZ];
            if_indextoname(ifa->ifa_index, ifname);
            if (is_rmnet_interface(ifname)) {
                // 触发 QUIC 路径重建(关闭受影响路径,重新 add_path)
                trigger_path_rebuild(ifname, nh->nlmsg_type == RTM_NEWADDR);
            }
        }
    }
}
# 验证 SO_BINDTODEVICE 是否生效
# 方法 1:用 strace 追踪 setsockopt 调用
sudo strace -e setsockopt -p <tquic_pid> 2>&1 | grep BINDTODEVICE
# 预期输出:setsockopt(7, SOL_SOCKET, SO_BINDTODEVICE, "rmnet1", 7) = 0

# 方法 2:用 ss 查看 socket 绑定情况(两个 socket 的本地 IP 应不同)
ss -unp | grep <tquic_pid>
# 正确情况:一行是 10.64.0.5:xxxx,一行是 172.20.1.3:xxxx

# 方法 3:路由验证(最直接)
ip route get <服务器IP> from 10.64.0.5   # 预期:...dev rmnet0...
ip route get <服务器IP> from 172.20.1.3  # 预期:...dev rmnet1...

第三章:TQUIC 的 quic_conn_add_path() 调用时机

这一章讲一个在文档里很少明确说明、但在实际工程中非常重要的顺序问题。

先看 TQUIC 源码。quic_conn_add_path()src/connection/connection.rs:3725 的实现有两个硬性限制:

// src/connection/connection.rs:3725
pub fn add_path(&mut self, local_addr: SocketAddr, remote_addr: SocketAddr) -> Result<u64> {
    // 限制 1:只允许客户端调用
    if self.is_server {
        return Err(Error::InvalidOperation("disallowed".into()));
    }

    // 限制 2:必须在握手完成后调用
    if !self.flags.contains(HandshakeCompleted) {
        return Err(Error::InvalidOperation("disallowed".into()));
    }

    // 不允许添加重复路径
    if self.paths.get_path_id(&(local_addr, remote_addr)).is_some() {
        return Err(Error::Done);
    }

    // ... 创建路径,分配 DCID ...
    let path = self.paths.get_mut(pid)?;
    path.initiate_path_chal();  // 自动启动 PATH_CHALLENGE

    // 如果启用了 Multipath QUIC,为新路径创建独立的 packet number space
    if self.flags.contains(EnableMultipath) {
        let space_id = self.spaces.add();
        path.space_id = space_id;
    }
    Ok(pid as u64)
}

关键点:quic_conn_add_path() 调用后自动发起 PATH_CHALLENGE,不需要手动触发。路径状态机(src/connection/path.rs:383):

pub enum PathState {
    Failed,        // 路径验证失败
    Unknown,       // 未进行验证
    Validating,    // 验证中(PATH_CHALLENGE 已发出,等待 PATH_RESPONSE)
    ValidatingMTU, // 远端地址已验证,MTU 验证中
    Validated,     // 路径已验证,可以正常使用
}

对应的 C API(include/tquic.h:1071):

// 添加新路径(仅客户端,握手完成后调用)
// 成功时 index 输出参数携带路径索引
int quic_conn_add_path(struct quic_conn_t *conn,
                       const struct sockaddr *local,
                       socklen_t local_len,
                       const struct sockaddr *remote,
                       socklen_t remote_len,
                       uint64_t *index);

握手完成的回调是 on_conn_establishedinclude/tquic.h:188):

// include/tquic.h:188
// Called when the handshake is completed.
void (*on_conn_established)(void *tctx, struct quic_conn_t *conn);

完整的初始化时序(4G 网络典型值):

t=0ms:   quic_endpoint_connect() 调用,主路径握手开始
t=100ms: on_conn_established 触发(1-RTT 握手完成)
t=102ms: quic_conn_add_path() 调用,PATH_CHALLENGE 自动发出
t=180ms: PATH_RESPONSE 收到,第二条路径进入 Validated 状态
t=182ms: 可以开始发业务数据(此时两条路径都就绪,冗余保护激活)

关键窗口:从 on_conn_established(t=100ms)到路径 2 就绪(t=180ms),有约 80ms 的单路径窗口。在这个窗口内,连接只有一条路径,冗余发送没有效果。

状态机的完整实现

typedef enum {
    CONN_STATE_IDLE,
    CONN_STATE_CONNECTING,
    CONN_STATE_HANDSHAKE_DONE,
    CONN_STATE_ADDING_PATH,
    CONN_STATE_READY,
    CONN_STATE_DEGRADED,    // 第二条路径探测超时,降级为单路径
} conn_state_t;

typedef struct {
    quic_conn_t      *conn;
    conn_state_t      state;
    int               validated_paths;
    bool              ready_to_send;
    struct timespec   path2_add_time;
    int               path2_timeout_ms;  // 推荐 500ms
} conn_ctx_t;

// on_conn_established 对应 TQUIC 的 on_conn_established 回调
void on_conn_established(void *tctx, quic_conn_t *conn) {
    conn_ctx_t *c = tctx;
    c->state = CONN_STATE_HANDSHAKE_DONE;

    // 握手完成后立即添加第二条路径
    // quic_conn_add_path() 内部会自动发起 PATH_CHALLENGE
    uint64_t path_index;
    int ret = quic_conn_add_path(conn,
                                 (struct sockaddr *)&local_addr2, sizeof(local_addr2),
                                 (struct sockaddr *)&remote_addr, sizeof(remote_addr),
                                 &path_index);
    if (ret == 0) {  // QUIC_ERR_NONE = 0
        c->state = CONN_STATE_ADDING_PATH;
        clock_gettime(CLOCK_MONOTONIC, &c->path2_add_time);
    } else {
        // add_path 失败(可能是 SO_BINDTODEVICE 未设置导致路径重复)
        c->state = CONN_STATE_DEGRADED;
        c->ready_to_send = true;  // 降级为单路径模式
    }
}

// 定时检查:第二条路径探测是否超时
void check_path2_timeout(conn_ctx_t *c) {
    if (c->state != CONN_STATE_ADDING_PATH) return;

    struct timespec now;
    clock_gettime(CLOCK_MONOTONIC, &now);
    long elapsed_ms = (now.tv_sec - c->path2_add_time.tv_sec) * 1000 +
                      (now.tv_nsec - c->path2_add_time.tv_nsec) / 1000000;

    if (elapsed_ms > c->path2_timeout_ms) {
        c->state = CONN_STATE_DEGRADED;
        c->ready_to_send = true;  // 超时后降级为单路径
    }
}

注意:TQUIC 没有直接的"路径验证完成"回调。判断路径是否就绪需要轮询 quic_conn_path_stats()include/tquic.h:1120)或者解析 qlog 事件。

错误做法:在 on_conn_established 中立即发业务数据

// 错误示例:此时第二条路径还没建立,没有冗余保护
void on_conn_established(void *tctx, quic_conn_t *conn) {
    // ❌ 接下来 100-200ms 内发的数据是单路径,没有冗余
    send_control_command(conn, "CMD_INIT");
}

实际启动时间的影响(4G 网络典型值,从 T-Box 上电到"云控可以安全发第一条控制指令"):

阶段时间说明
T-Box 启动0ms
Linux 内核启动完成~3s
modem 初始化~5-10s取决于 modem 型号
SIM1 网络注册~5-15s取决于信号强度
SIM2 网络注册~10-20s通常比 SIM1 慢
QUIC 握手(1-RTT)+100ms
第二条路径 PATH_VALIDATED+200ms
可安全发控制指令~16-20s从上电算起,实测因硬件差异约 ±5s

云控系统设计时,需要把这个冷启动时间考虑进去。

# 用 qlog 验证初始化时序
# TQUIC 的 qlog 事件名参考 src/qlog/events.rs
# 握手完成:connectivity:connection_state_updated(包含 new:"handshake_completed")
# 路径相关:quic:frames_processed(包含 PathChallenge / PathResponse 帧)
cat /tmp/qlog/*.qlog | python3 -c "
import json, sys
events = []
for line in sys.stdin:
    try:
        log = json.loads(line)
        for e in log.get('traces', [{}])[0].get('events', []):
            name = e.get('name', '')
            if any(k in name for k in ['connection_state', 'frames_processed']):
                events.append((e.get('time', 0), name, e.get('data', {})))
    except: pass
for t, n, d in sorted(events):
    print(f'{t:10.3f}ms  {n}')
"

第四章:REDUNDANT 调度器的实际行为

这一章讲冗余发送的实际工作方式,以及一个容易误解的细节。

要启用冗余发送,需要两步配置(include/tquic.h:686include/tquic.h:692):

// 第一步:启用 Multipath QUIC 扩展(实验性功能)
quic_config_enable_multipath(config, true);

// 第二步:选择 REDUNDANT 调度算法
// 默认是 MinRtt(选延迟最低的路径),需要显式切换为 REDUNDANT
quic_config_set_multipath_algorithm(config, QUIC_MULTIPATH_ALGORITHM_REDUNDANT);

REDUNDANT 调度器的实现在 src/multipath_scheduler/scheduler_redundant.rs。核心逻辑:

// src/multipath_scheduler/scheduler_redundant.rs:62
fn on_sent(&mut self, packet: &SentPacket, ...) {
    if packet.buffer_flags.has_buffered() {
        return;  // 已经是重注入的包,不再重复注入(防止无限循环)
    }

    // 把发出的包中的 Stream 帧重新注入到其他活跃路径
    for (pid, path) in paths.iter() {
        if pid == path_id || !path.active() {
            continue;
        }
        for frame in &packet.frames {
            if let Frame::Stream { .. } = frame {
                // 只对 Stream 帧做冗余注入
                space.buffered.push_back(frame.clone(), BufferType::High);
            }
        }
    }
}

关键点:REDUNDANT 调度器只对 Stream 帧做冗余发送,不对其他帧(ACK、CRYPTO、PADDING 等)做冗余。这意味着:

  • 控制指令(通过 QUIC STREAM 发送)会在两条路径上各发一份
  • 握手帧、确认帧不会冗余发送
  • 已经是重注入的帧(has_buffered() 为真)不会再次注入,防止无限循环

可用的三种调度算法(include/tquic.h):

enum quic_multipath_algorithm {
    QUIC_MULTIPATH_ALGORITHM_MIN_RTT,    // 默认:选延迟最低的路径
    QUIC_MULTIPATH_ALGORITHM_REDUNDANT,  // 冗余:所有路径同时发 Stream 帧
    QUIC_MULTIPATH_ALGORITHM_ROUND_ROBIN // 轮询:依次使用各路径
};

PATH_CHALLENGE 频率与 T-Box 功耗

QUIC 的 PATH_CHALLENGE 是一个 UDP 包。T-Box 上的 4G/5G modem 实现了 DRX(Discontinuous Reception,不连续接收)机制——在没有数据收发时进入微睡眠,按照基站配置的周期(4G 典型 10-40ms,5G 典型 2-20ms)定期醒来。每次唤醒都需要激活射频电路,有额外功耗。

TQUIC 没有专门的 set_ping_timeout() 接口(文章原版中的描述是错误的)。控制 keep-alive 频率使用的是 quic_conn_ping()include/tquic.h:1056)和 quic_conn_ping_path()include/tquic.h:1062),在应用层定时调用:

// include/tquic.h:1056
// Send a Ping frame on the active path(s) for keep-alive.
int quic_conn_ping(struct quic_conn_t *conn);

// include/tquic.h:1062
// Send a Ping frame on the specified path for keep-alive.
// The API is only applicable to multipath quic connections.
int quic_conn_ping_path(struct quic_conn_t *conn,
                        const struct sockaddr *local, socklen_t local_len,
                        const struct sockaddr *remote, socklen_t remote_len);

连接空闲超时通过 quic_config_set_max_idle_timeout() 控制(include/tquic.h:497):

// 设置连接最大空闲超时(毫秒)
// 超过此时间没有数据收发,连接关闭
quic_config_set_max_idle_timeout(config, 60000);  // 60 秒

推荐做法:在应用层每 20 秒调用一次 quic_conn_ping(),在 CGNAT 30 秒超时下保留 10 秒安全余量,同时不会过于频繁唤醒 modem。

# 查看 QUIC PATH_CHALLENGE 发送情况(通过 qlog)
# TQUIC 的帧事件记录在 quic:frames_processed 中
cat /tmp/qlog/*.qlog | python3 -c "
import json, sys
timestamps = []
for line in sys.stdin:
    try:
        log = json.loads(line)
        for e in log.get('traces', [{}])[0].get('events', []):
            name = e.get('name', '').lower()
            data = str(e.get('data', {})).lower()
            if 'frames_processed' in name and 'pathchallenge' in data:
                timestamps.append(e.get('time', 0))
    except: pass
if len(timestamps) > 1:
    intervals = [timestamps[i+1]-timestamps[i] for i in range(len(timestamps)-1)]
    steady = [x for x in intervals if x > 1000]
    if steady:
        print(f'PATH_CHALLENGE 稳定间隔: avg={sum(steady)/len(steady):.0f}ms')
"

第五章:0-RTT 在 T-Box 重启后的实际行为

这一章要纠正一个常见的错误预期:0-RTT 不能让 MPQUIC 的"双路径就绪时间"显著缩短。

TQUIC 的会话恢复 API

TQUIC 通过 quic_conn_session() 获取会话数据(include/tquic.h:1039),在下次连接时通过 quic_endpoint_connect()session 参数传入(include/tquic.h:939):

// 获取会话数据(在 on_conn_established 或之后调用)
void quic_conn_session(struct quic_conn_t *conn,
                       const uint8_t **out,
                       size_t *out_len);

// 创建连接时传入上次的会话数据(实现 0-RTT)
int quic_endpoint_connect(struct quic_endpoint_t *endpoint,
                          const struct sockaddr *local,   socklen_t local_len,
                          const struct sockaddr *remote,  socklen_t remote_len,
                          const char *server_name,
                          const uint8_t *session,         // 上次保存的会话数据
                          size_t session_len,
                          const uint8_t *token,           size_t token_len,
                          const struct quic_config_t *config,
                          uint64_t *index);

T-Box 重启后实现跨重启 0-RTT 的做法:

// 握手完成后保存会话数据到 eMMC
void on_conn_established(void *tctx, quic_conn_t *conn) {
    const uint8_t *session_data;
    size_t session_len;
    quic_conn_session(conn, &session_data, &session_len);

    if (session_data && session_len > 0) {
        // 写入 eMMC(注意:eMMC 写入有延迟,不要在延迟敏感路径上同步写)
        int fd = open("/data/tquic_session.bin",
                      O_WRONLY | O_CREAT | O_TRUNC, 0600);
        if (fd >= 0) {
            write(fd, session_data, session_len);
            fsync(fd);  // 确保写入持久化
            close(fd);
        }
    }
    // ... 继续添加第二条路径 ...
}

// T-Box 重启后,读取会话数据用于 0-RTT
uint8_t *load_session(size_t *out_len) {
    int fd = open("/data/tquic_session.bin", O_RDONLY);
    if (fd < 0) { *out_len = 0; return NULL; }

    struct stat st;
    fstat(fd, &st);
    uint8_t *buf = malloc(st.st_size);
    if (buf) {
        read(fd, buf, st.st_size);
        *out_len = st.st_size;
    }
    close(fd);
    return buf;
}

// 连接时传入会话数据
size_t session_len = 0;
uint8_t *session = load_session(&session_len);

uint64_t conn_index;
quic_endpoint_connect(endpoint,
                      (struct sockaddr *)&local_addr, sizeof(local_addr),
                      (struct sockaddr *)&remote_addr, sizeof(remote_addr),
                      "ctrl-relay.example.com",
                      session, session_len,  // 传入会话数据
                      NULL, 0,
                      config, &conn_index);
free(session);

检查是否走了 0-RTT(include/tquic.h:10131019):

// 检查连接是否通过会话恢复建立
bool quic_conn_is_resumed(struct quic_conn_t *conn);

// 检查连接是否处于 early data(0-RTT)阶段
bool quic_conn_is_in_early_data(struct quic_conn_t *conn);

0-RTT 对 MPQUIC 双路径就绪时间的有限加速效果

这是最重要的认知纠正:0-RTT 只能加速主路径(path_id=0)的数据发送,不能加速第二条路径的就绪。

原因是 QUIC 规范(RFC 9001 §4.6.1)明确规定:0-RTT 数据只能从发起连接的本地地址发出,不能使用后来添加的路径。第二条路径的添加必须等握手完成(HandshakeCompleted flag,对应 TQUIC src/connection/connection.rs:3730),这个约束不因为主路径是否走 0-RTT 而改变。

实际时间对比:

场景主路径可发数据第二条路径完成探测说明
冷启动(无会话数据)~100ms~200ms主路径 1-RTT + 路径探测
0-RTT(会话命中)~40ms~160ms主路径快了约 60ms,路径探测时间类似

0-RTT 让主路径早了约 60ms 可以发数据,但"双路径冗余保护就绪时间"只从 200ms 缩短到约 160ms,改善比例约 20%。如果工程师期望"0-RTT 让双路径都更快就绪",这个期望是错误的。

第六章:tcpdump 在双网卡上的正确用法

最后讲一个调试时经常踩的坑,它会让你误以为"双路径在正常工作",但实际上可能完全相反。

不要用 tcpdump -i any 验证双路径是否生效。

-i any 对应 Linux 的 AF_PACKET socket,类型是 ETH_P_ALL,它在所有网络接口上抓包——包括 rmnet0、rmnet1、lo(loopback)、以及可能存在的虚拟接口。

在双网卡场景下,当两个 socket 都错误地走了 rmnet0(SO_BINDTODEVICE 未设置),tcpdump -i any 会显示"两路都有流量"(rmnet0 上有两倍流量,被 -i any 记录了),但 rmnet1 实际上一个包都没有。这就是"假象"——工程师看到 -i any 的输出后误认为双路径工作正常,却不知道物理上只有一条路径在用。

工厂测试第一天的故障就是被这个假象迷惑了将近两个小时。

正确做法:对每个网卡单独运行一个 tcpdump

# 正确的双网卡抓包方式
tcpdump -i rmnet0 udp port 443 -n -q -l 2>/dev/null | \
  awk 'BEGIN{c=0} {c++} END{print "rmnet0:", c, "packets in 30s"}' &
PID1=$!

tcpdump -i rmnet1 udp port 443 -n -q -l 2>/dev/null | \
  awk 'BEGIN{c=0} {c++} END{print "rmnet1:", c, "packets in 30s"}' &
PID2=$!

sleep 30
kill $PID1 $PID2 2>/dev/null
wait

# 冗余发送时,两个网卡的包数量应接近(允许 10-20% 误差)
# 如果 rmnet1 的包数量接近 0,说明:
# 1. SO_BINDTODEVICE 未设置(两路都走 rmnet0)
# 2. quic_conn_add_path() 未成功完成(第二条路径未建立)
# 3. REDUNDANT 调度器未配置(只走延迟最低的单条路径)

tcpdump + qlog 的联合调试方法

tcpdump 看"物理层面哪个网卡有流量"(真实的网络包),qlog 看"TQUIC 认为哪条路径在工作"(协议状态)。两者的预期是一致的:qlog 显示某路径 active,则 tcpdump 的对应网卡应该有流量。如果不一致,说明有绑定问题。

# 用 tshark 对比两路的包大小分布
tcpdump -i rmnet0 -w /tmp/rmnet0.pcap udp port 443 &
tcpdump -i rmnet1 -w /tmp/rmnet1.pcap udp port 443 &
sleep 30
kill %1 %2
wait

tshark -r /tmp/rmnet0.pcap -Y quic -T fields \
  -e frame.len 2>/dev/null | awk '{sum+=$1;c++} END{printf "rmnet0: avg=%.0f bytes, total=%d pkts\n",sum/c,c}'

tshark -r /tmp/rmnet1.pcap -Y quic -T fields \
  -e frame.len 2>/dev/null | awk '{sum+=$1;c++} END{printf "rmnet1: avg=%.0f bytes, total=%d pkts\n",sum/c,c}'
# 两者的包数量和平均大小应接近

补充:几个源码级别的细节

SO_BINDTODEVICE 权限的精确语义

内核 net/core/sock.c:649 的权限检查是:

if (sk->sk_bound_dev_if && !ns_capable(net->user_ns, CAP_NET_RAW))
    return -EPERM;

条件是 sk->sk_bound_dev_if 非零才检查权限。也就是说:

  • 第一次绑定(sk_bound_dev_if = 0):不需要 CAP_NET_RAW,普通进程可以调用
  • 修改已绑定的网卡(sk_bound_dev_if != 0):需要 CAP_NET_RAW

T-Box 上的 QUIC 进程通常以普通用户运行,第一次绑定没有权限问题。但如果需要在网卡重启后重新绑定(此时 sk_bound_dev_if 已经非零),就需要在 systemd unit 中配置 AmbientCapabilities=CAP_NET_RAW

rmnet 网卡 MTU 与 QUIC 包大小

高通 RMNET 驱动(drivers/net/ethernet/qualcomm/rmnet/rmnet_vnd.c:275)设置 rmnet_dev->mtu = RMNET_DFLT_PACKET_SIZE。但某些运营商 APN 的 GTP-U 隧道封装有额外开销,有效 MTU 可能只有 1420-1480 字节。

TQUIC 默认的发送 UDP payload 大小是 1200 字节(include/tquic.h:519quic_config_set_send_udp_payload_size() 的默认值,DPLPMTUD 自动选择)。如果需要手动设置保守值:

// 设置最大发送 UDP payload 大小(字节)
// 默认 1200,DPLPMTUD 会自动探测更大的值
quic_config_set_send_udp_payload_size(config, 1400);  // 保守值,留足 MTU 余量

动态 IP 变化的完整处理决策树

  1. IP 变化但连接仍然可达(4G 基站切换,IP 变化但云端 NAT 映射仍有效):

    • 使用 QUIC 连接迁移:quic_conn_migrate_path()include/tquic.h:1090)切换到新 IP 的路径
    • 等待新路径 Validated,成功则平滑切换
  2. IP 变化且连接中断(4G/5G 切换重注册,连接彻底断开):

    • 新建 QUIC 连接(传入上次保存的会话数据走 0-RTT)
    • 执行完整的 quic_conn_add_path() 初始化流程
  3. IP 短时间内频繁变化(网络质量极差,反复注册):

    • 设置防抖时间(debounce,推荐 3 秒)
    • 3 秒内多次 IP 变化只触发一次重连

回到文章开头的工厂测试故障。两条"路径"都走了 rmnet0,根因是缺少 SO_BINDTODEVICE。这个问题表面上是一行 setsockopt 调用,背后是对 Linux 内核路由决策机制的理解——内核不会因为你绑定了 rmnet1 的 IP 就把包从 rmnet1 出去(bind() 只影响源地址验证,不影响 flowi4_oif),必须通过 SO_BINDTODEVICE 显式设置 sk_bound_dev_if 来强制指定出口网卡。

T-Box 开发中,"把事情绑定到正确的物理设备上"是一个反复出现的主题:网卡要绑定(SO_BINDTODEVICE)、CPU 核心要绑定(taskset/cpuset)、中断要绑定(irqaffinity)。每一层绑定不到位,都可能让精心设计的架构悄悄退化为不符合预期的行为。不做显式绑定,依赖系统默认行为,在测试环境往往正常(因为环境配置符合预期),但在量产环境就会出问题(因为不同运营商、不同地区、不同 modem 驱动版本的默认行为各有差异)。

这篇文章讲完了 T-Box 端 MPQUIC 连接建立的三个核心问题:网卡绑定(SO_BINDTODEVICE)、初始化顺序(quic_conn_add_path() 必须在握手完成后调用,等双路 Validated 再发业务数据)、以及 REDUNDANT 调度器只冗余 Stream 帧的工程细节。这三个问题每个都是"从文档里看不出来,只有踩坑才知道"的类型——这也是这个系列存在的价值。

下一篇(第08篇)进入冗余发送的"零感知"设计:两个 SIM 卡同时发,云端如何去重不乱序?我们会看到一个 P99 延迟从 80ms 跳到 320ms 的真实故障,以及冗余发送如何把它压回 90ms——和随之而来的,云控平台收到重复控制指令的新问题。去重逻辑的工程细节,比看起来要复杂得多。