工厂测试的第一天,工程师把刚烧录好的 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:383,PathState::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 结构体 sk 的 sk_bound_dev_if 字段(net/core/sock.c:657,WRITE_ONCE(sk->sk_bound_dev_if, ifindex)),在发包时强制指定 flowi4_oif(出口网卡索引),完全绕过路由表的 metric 比较。
内核实现在 net/core/sock.c:641,sock_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_BINDTODEVICE(sk_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_ether | usb0, usb1 | USB 接口 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_NEWADDR 或 RTM_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_established(include/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:686 和 include/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:1013 和 1019):
// 检查连接是否通过会话恢复建立
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:519,quic_config_set_send_udp_payload_size() 的默认值,DPLPMTUD 自动选择)。如果需要手动设置保守值:
// 设置最大发送 UDP payload 大小(字节)
// 默认 1200,DPLPMTUD 会自动探测更大的值
quic_config_set_send_udp_payload_size(config, 1400); // 保守值,留足 MTU 余量
动态 IP 变化的完整处理决策树
-
IP 变化但连接仍然可达(4G 基站切换,IP 变化但云端 NAT 映射仍有效):
- 使用 QUIC 连接迁移:
quic_conn_migrate_path()(include/tquic.h:1090)切换到新 IP 的路径 - 等待新路径
Validated,成功则平滑切换
- 使用 QUIC 连接迁移:
-
IP 变化且连接中断(4G/5G 切换重注册,连接彻底断开):
- 新建 QUIC 连接(传入上次保存的会话数据走 0-RTT)
- 执行完整的
quic_conn_add_path()初始化流程
-
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——和随之而来的,云控平台收到重复控制指令的新问题。去重逻辑的工程细节,比看起来要复杂得多。