11.6 DCCP: 数据报拥塞控制协议
我们终于来到了 IPv4 传输层家族的最后一站。
DCCP 是一个试图解决「两难困境」的协议:它想要 UDP 的速度(低延迟、不重传),又想要 TCP 的礼貌(拥塞控制)。你可以把它想象成**「有礼貌的 UDP」**——它不会不管网络堵不堵就疯狂发数据,也不会因为丢了一个包就停下来重传,把实时性毁掉。
但这个比喻有一个地方是不准确的:DCCP 并不是「在 UDP 上加补丁」的缝合怪。像 TCP 一样,它是面向连接的,需要三次握手才能开始通信。它在内核实现上甚至和 TCP 有着千丝万缕的血缘关系。
回到那个「有礼貌的 UDP」:你应该能看出来,DCCP 的核心在于**「可控」**。它允许应用根据场景选择不同的「礼貌程度」(即不同的拥塞控制算法,CCID)。如果你选错了算法——比如在流媒体场景用了为短包设计的 CCID-4——那你可能会得到糟糕的体验,或者根本跑不起来。
11.6.1 DCCP 核心机制:可插拔的拥塞控制
DCCP 的拥塞控制比 TCP 要「民主」一点。
在 TCP 里,拥塞控制算法是写死在内核里的(虽然现在 Linux 支持插件化,但在协议定义层面是固定的)。而在 DCCP 里,拥塞控制算法是可协商的,被称为 CCID (Congestion Control ID)。
DCCP 的连接被看作是两个「半连接」(Half-connection):
- 发送半连接:A → B 的数据流,A 负责控制发送速率。
- 反向半连接:B → A 的数据流(主要是 ACK),B 负责控制发送速率。
这种分离意味着两边可以完全用不同的算法——比如 A 发视频用 CCID-3(TCP 友好速率控制),B 发 ACK 用 CCID-2(类 TCP)。
目前主流的 CCID 有两种:
- CCID-2 (TCP-Like):这就是披着 DCCP 外皮的 TCP 拥塞控制。它用慢启动、拥塞窗口,加上 SACK(选择性确认)。适用场景:带宽大、延迟高、不怕丢包但怕网络瘫痪的场景。
- CCID-3 (TCP-Friendly Rate Control, TFRC):这是基于速率的平滑算法。它不像 CCID-2 那样剧烈调整窗口,而是算出一个平滑的发送速率。适合流媒体,因为画面抖动(Rate 变化太快)比偶尔丢几帧更难接受。
- CCID-4:这是 CCID-3 的小包变种,专门针对小数据包优化(实验性质)。
Linux 内核早在 2.6.14(2005年)就加入了 DCCP 实现。本章只讨论 DCCPv4 的实现原理,至于具体的 CCID 数学公式推导,那属于另一本数学书。
11.6.2 DCCP 头部解析
就像任何正经协议一样,DCCP 也有自己的头部。
最小头部只有 12 字节,但它是变长的(12 到 1020 字节)。为什么能差这么多?因为 DCCP 的头部选项也是 TLV 格式的(类似 SCTP 或 TCP Options),而且序列号长度可变。
注意:DCCP 的序列号是按包递增的(Packet-based),不像 TCP 是按字节递增(Byte-based)。这一点很重要,后面处理序列号逻辑时会用到。
来看内核里的定义:
struct dccp_hdr {
__be16 dccph_sport,
dccph_dport;
__u8 dccph_doff;
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u8 dccph_cscov:4,
dccph_ccval:4;
#elif defined(__BIG_ENDIAN_BITFIELD)
__u8 dccph_ccval:4,
dccph_cscov:4;
#endif
__sum16 dccph_checksum;
#if defined(__LITTLE_ENDIAN_BITFIELD)
__u8 dccph_x:1,
dccph_type:4,
dccph_reserved:3;
#elif defined(__BIG_ENDIAN_BITFIELD)
__u8 dccph_reserved:3,
dccph_type:4,
dccph_x:1;
#endif
__u8 dccph_seq2;
__be16 dccph_seq;
};
这里有几个关键字段,我们拆开来看:
- dccph_sport / dccph_dport:源端口和目的端口。概念同 TCP/UDP,没什么好说的。
- dccph_doff:数据偏移。告诉内核「DCCP 头部有多长」,单位是 4 字节。因为头部后面可能挂了一堆 TLV 选项,没有这个字段内核找不到数据在哪。
- dccph_cscov (Checksum Coverage):这是 DCCP 的一个骚操作——部分校验和。
- 正常情况下,校验和覆盖整个包。但有些应用(比如某些音频流)不在乎数据里坏几个 bit,或者为了性能牺牲一点点 correctness。你可以把
cscov设小一点,只校验头部,数据部分爱咋咋地。这在 UDP-Lite 里也有类似设计。
- 正常情况下,校验和覆盖整个包。但有些应用(比如某些音频流)不在乎数据里坏几个 bit,或者为了性能牺牲一点点 correctness。你可以把
- dccph_ccval:这是给 CCID 算法用的 4 位空间,用来在收发双方传递一些算法特定的信息(比如 Echo 一下拥塞窗口的值),并不是通用的。
- dccph_type:包类型。4 位。比如
DCCP_PKT_DATA是数据包,DCCP_PKT_ACK是确认包。 - dccph_x (Extended Sequence Numbers):扩展序列号标志位。
- 如果
dccph_x = 0:序列号是 24 位(dccph_seq16 位 +dccph_seq2高 8 位)。 - 如果
dccph_x = 1:序列号是 48 位(真正的长序列号)。 - 如果网络速度极快,24 位序列号很快就回绕了,这时候必须开启扩展模式。
- 如果
- dccph_seq2 / dccph_seq:这就是上面说的序列号字段。因为序列号是按包增加的,所以这里存的就是包的编号。
图 11-4 展示了 dccph_x=1 的情况,你能看到 Sequence Number 部分被拼成了 48 位。
图 11-5 展示了 dccph_x=0 的情况,序列号只有 24 位,比较省空间。
11.6.3 初始化:注册与 Socket 创建
DCCP 在内核里的初始化流程,和 TCP/UDP 几乎是一个模子刻出来的。
第一步:协议注册
在 net/dccp/ipv4.c 里,DCCP 首先定义了一个 proto 结构(用于 Socket 层操作)和一个 net_protocol 结构(用于网络层接收):
static struct proto dccp_v4_prot = {
.name = "DCCP",
.owner = THIS_MODULE,
.close = dccp_close,
.connect = dccp_v4_connect,
.disconnect = dccp_disconnect,
.init = dccp_v4_init_sock, // 关键回调
.sendmsg = dccp_sendmsg,
.recvmsg = dccp_recvmsg,
. . .
};
static const struct net_protocol dccp_v4_protocol = {
.handler = dccp_v4_rcv, // 收包入口
.err_handler = dccp_v4_err,
.no_policy = 1,
.netns_ok = 1,
};
然后在 dccp_v4_init() 里把自己挂到内核的协议链表上:
static int __init dccp_v4_init(void)
{
int err = proto_register(&dccp_v4_prot, 1); // 注册 proto
if (err != 0)
goto out;
// 注册到 IP 层,告诉内核:IPPROTO_DCCP 这种包给我处理
err = inet_add_protocol(&dccp_v4_protocol, IPPROTO_DCCP);
if (err != 0)
goto out_proto_unregister;
...
}
第二步:Socket 初始化
当用户态调用 socket(AF_INET, SOCK_DCCP, ...) 时,内核最终会调用到 dccp_v4_init_sock():
static int dccp_v4_init_sock(struct sock *sk)
{
static __u8 dccp_v4_ctl_sock_initialized;
// 调用通用的 DCCP 初始化逻辑
int err = dccp_init_sock(sk, dccp_v4_ctl_sock_initialized);
if (err == 0) {
if (unlikely(!dccp_v4_ctl_sock_initialized))
dccp_v4_ctl_sock_initialized = 1;
// 设置 IPv4 特定的地址族操作回调
inet_csk(sk)->icsk_af_ops = &dccp_ipv4_af_ops;
}
return err;
}
dccp_init_sock() 做了三件大事:
- 初始化字段:把 socket 状态设为
DCCP_CLOSED,设置默认队列长度等。 - 初始化定时器:调用
dccp_init_xmit_timers()。DCCP 也有定时器,虽然比 TCP 简单,但也没有完全摆脱「时间」的束缚。 - 特性协商初始化:调用
dccp_feat_init()。这是 DCCP 的特色功能,用于在握手时协商 CCID 以及其他参数。
11.6.4 接收数据:从 L3 到 L4
数据包从网卡进来,经过 IP 层剥离头部后,会根据 protocol 号找到 dccp_v4_rcv()。
这个函数的结构和 tcp_v4_rcv() 惊人地相似。这不是巧合,因为 DCCP 的 Linux 作者 Arnaldo Carvalho de Melo 就是为了复用 TCP 的代码逻辑而刻意这样设计的——凡是能复用的,绝不重写。
让我们看看处理流程:
static int dccp_v4_rcv(struct sk_buff *skb)
{
const struct dccp_hdr *dh;
struct sock *sk;
int min_cov;
首先,扔掉垃圾包:
// 检查包是否发给本机,长度是否合法(最小 12 字节)
if (dccp_invalid_packet(skb))
goto discard_it;
然后,查找 Socket。根据四元组去哈希表里找对应的 struct sock:
sk = __inet_lookup_skb(&dccp_hashinfo, skb,
dh->dccph_sport, dh->dccph_dport);
if (sk == NULL) {
// 找不到 Socket?说明没人监听这个端口,或者这包是野包
goto no_dccp_socket;
}
接着,处理 Minimum Checksum Coverage (最小校验和覆盖)。
还记得 dccph_cscov 吗?如果协商好的 Coverage 大于当前包的 Coverage,说明这包校验范围不够,得扔:
// (代码片段简化示意)
min_cov = dccp_sk(sk)->dccps_pcrlen;
if (dh->dccph_cscov < min_cov) {
// 校验和覆盖范围太小,丢弃
goto discard_it;
}
最后,把包交给 Socket:
return sk_receive_skb(sk, skb, 1);
}
这之后,数据就会进入 Socket 的接收队列,等待用户态 recvmsg() 来读。
11.6.5 发送数据:构建与排队
用户态调用 sendmsg() 发数据时,内核会落脚到 dccp_sendmsg()。
这一步的核心任务是:把用户态的数据拷贝到内核态的 SKB 里,并根据 CCID 决定是现在发还是稍后发。
int dccp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
size_t len)
{
const struct dccp_sock *dp = dccp_sk(sk);
const int flags = msg->msg_flags;
const int noblock = flags & MSG_DONTWAIT;
struct sk_buff *skb;
int rc, size;
long timeo;
分配 SKB:
// 分配一个 skb,大小等于数据长度 + 头部预留空间
skb = sock_alloc_send_skb(sk, size, noblock, &rc);
lock_sock(sk); // 加锁,防止并发
if (skb == NULL)
goto out_release;
skb_reserve(skb, sk->sk_prot->max_header); // 预留头部空间
拷贝数据:
// 把用户态数据(msg->msg_iov)拷贝进 skb 的数据区
rc = memcpy_fromiovec(skb_put(skb, len), msg->msg_iov, len);
if (rc != 0)
goto out_discard;
触发发送: 这是最关键的一步。DCCP 并不会像 UDP 那样简单地「直接扔给 IP 层」。它得看 CCID 的脸色:
if (!timer_pending(&dp->dccps_xmit_timer))
dccp_write_xmit(sk);
dccp_write_xmit() 会根据当前的 CCID(是 CCID-2 还是 CCID-3)做不同的事:
- 如果是基于窗口的(CCID-2),它可能会算出当前能发多少个包,直接发出去。
- 如果是基于速率的(CCID-3),它可能会根据发送定时器来平滑发包。
最终,所有要发的包都会走到 dccp_transmit_skb()。在这个函数里,内核会填充 DCCP 头部(计算 Checksum,填 Seq),然后调用 IP 层的回调:
- 如果是 IPv4,调用
ip_queue_xmit()。 - 如果是 IPv6,调用
inet6_csk_xmit()。
11.6.6 DCCP 与 NAT:鸡生蛋的困境
DCCP 的设计非常学术,非常优雅,但在现实互联网面前,它遇到了一个大坑:NAT。
很多家用路由器(NAT 设备)根本不认识 DCCP。在 NAT 眼里,只有 TCP(协议号 6)和 UDP(协议号 17)是合法公民,DCCP(协议号 33)就是黑户,直接丢弃。
为了解决这个问题,RFC 5596 在 2009 年给 DCCP 打了个补丁,引入了 Near Simultaneous Open (准同时打开)。
这有点像 TCP 的 Simultaneous Open(同时打开),但在 DCCP 里是为了配合 NAT 穿透(打洞)技术。它引入了一个新的包类型 DCCP-LISTEN,并修改了状态机。
然而,这里有一个死循环:
- NAT 厂商不支持 DCCP,因为没人用。
- 用户不用 DCCP,因为 NAT 不支持。
这就造成了**「先有鸡还是先有蛋」**的问题。
为了绕过这个障碍,又有人提出了 DCCP-UDP(RFC 6773),就是把 DCCP 包塞在 UDP 包里传输——既然 UDP 哪都能去,那就伪装成 UDP。但这会让 DCCP 失去作为独立传输层协议的纯粹性。
这也是为什么直到今天,你在公网上几乎看不到 DCCP 的身影。它更多作为一种实验性的、学术性的协议存在于 Linux 内核里,等待着也许永远不会到来的「IP 端到端连通性」的复兴。
(本章后续为练习题与总结,此处略)