☰
UDP上重造TCP数据包通道:滑动窗口、拥塞控制与抓包验证
2026/11/6 2:05:41 网站建设 项目流程

简介:围绕燕山大学计算机网络三级项目整理的TCP传输数据包实践包,面向正在学习计算机网络、需要完成课程设计或网络编程实验的高校学生,重点解决TCP客户端/服务器通信实现与协议机制验证问题。压缩包共60个文件,约27.88MB,以cpp源码、h头文件、sln解决方案与vcxproj工程配置为主,并含tlog日志、pdb调试符号、exe可执行文件及ini配置等,便于还原客户端与服务器两套工程的编译、运行和调试过程。已有1355人学习下载,说明其在校内课程实践场景中具有一定参考价值。资源覆盖三次握手建立连接、数据分片与重组、滑动窗口流量控制、拥塞控制以及四次挥手释放连接,并涉及慢启动、快速重传、快速恢复等算法;读者可对照源码理解socket接口调用、数据确认与重传、异常处理,并借助日志和调试文件排查运行问题,适合作为课程项目复盘和网络编程入门到进阶的对照材料。

1. 一份 pcap 抓包引出的三级项目:在 UDP 上重造一条 TCP 数据包通道

把两个 UDP socket 摆在面前,让它们在应用层表现出一条可靠、有序、带流量控制的字节流——这是某高校计算机网络课程三级项目的命题核心。听上去像是把 TCP 重写一遍,真动手才发现难点根本不在算法:序号到底按字节编还是按段编、超时定时器该挂几个、ACK 在半路丢了算谁的锅,教科书上一句带过的地方全是坑。这套源码把一条 TCP 数据包连接拆成九个可以单独编译的 C 文件,附一个丢包注入代理和一套 trace 对齐脚本,跑一遍就能看见拥塞窗口从 1 个 MSS 涨到 32 再被一刀砍半。它适合刚学完计算机网络、想亲手验证一遍滑动窗口和慢启动的读者,也适合写了几年业务代码、却从没认真看过 TCP 数据包长什么样的后端开发。

2. 骨架与启动链路:从 Makefile 到双端握手的一次完整跑通

2.1 目录结构:九个源文件各管一件事

拿到压缩包先别急着 make,先把目录看一遍。这个项目没有用任何第三方网络库,纯 POSIX socket + 手写报文封装,所以分层做得比较干净:net.c只管收发原语和 socket 选项,pkt.c管封装与校验和,conn.c是连接状态机,sender.c/receiver.c是两个端的业务入口,trace.c负责把每个数据包的 seq/ack/flags 落到 CSV。这种拆法有个好处——你想换拥塞控制算法,只改conn.c里的一段;想加一个自定义标志位,只改pkt.h。

文件职责二次开发时最容易动的地方
include/pkt.h19 字节报文头定义、标志位宏加时间戳字段、加 SACK 块
src/net.csocket/bind/recvfrom/sendto封装、超时设置换成 IPv6、加多路复用
src/pkt.c封装、解封装、校验和换 CRC32、加加密
src/conn.c握手/挥手状态机、窗口与 cwnd 维护换拥塞控制策略
src/sender.c读文件、切 MSS、推窗口改成流式读取
src/receiver.c乱序缓存、累积确认、写文件改成边收边消费
src/trace.cCSV 日志加字段、接可视化
tools/lossy_proxy.py可控丢包的中继加延迟、加乱序
scripts/run_demo.sh一键跑通脚本改参数做对比实验

这套结构的「血泪经验」在于:把状态机的纯粹逻辑和 socket I/O 彻底分开。很多人写课程项目会把 recvfrom 直接写进状态机里,结果一到要写单元测试就发现根本没法构造输入。这里conn.c暴露的是conn_on_segment(conn, hdr, payload)这种纯函数式接口,测试时直接喂构造好的字节数组就行,不用起真实 socket。

2.2 编译与三端启动:一次最小可复现的验证

编译只有两步,Makefile 里-Wall -Wextra -O2,开了告警是有意的,很多字节序和位运算的问题编译器能提前给你指出来。第一次跑建议先用 1MB 的小文件在回环上验证,因为回环没有真实丢包,能把「协议逻辑对不对」和「网络环境好不好」这两件事分离开。

# 编译,产物在 bin/ 下 make clean && make # 接收端先起,监听 9001,把收到的字节流写到 recv.bin ./bin/receiver --port 9001 --out recv.bin --win 64 --mss 1024 & # 发送端连过去,把 1MB 文件推过去,同时记录 trace ./bin/sender --host 127.0.0.1 --port 9001 \ --file data/1mb.bin --mss 1024 --trace trace.csv # 逐字节对齐才算真的跑通,别只看退出码是 0 md5sum data/1mb.bin recv.bin

几个参数值得单独说。--win 64是接收窗口大小,单位是「段」而不是字节,这样在实验里调窗口比较直观,改 16 就是 16×MSS 的缓冲;--mss 1024决定了单个 TCP 数据包载荷上限,调小到 128 可以让窗口推进的过程在 trace 里看得更细,代价是包数量翻八倍,抓包文件会大不少。--trace输出的 CSV 有t,seq,ack,flags,cwnd,ssthresh,win七列,后面画吞吐曲线全靠它。

提示:接收端一定要先起。这个实现里发送端第一次发 SYN 只重试 5 次,每次间隔按 RTO 退避,如果接收端晚起太久,你会看到连接直接以RST收尾,日志里只有一行 "handshake failed",很容易误判成代码问题。

脚本scripts/run_demo.sh其实就是上面这几行的封装,额外做了一件事:跑完后自动比对两个文件的 md5 并打印耗时和重传次数。重传次数是很关键的指标,回环上跑 1MB 文件,重传应该是 0;如果不是 0,说明你的定时器逻辑或者窗口通告有问题,而不是「网络抖了一下」。

2.3 报文头结构:19 字节里到底塞了什么

自定义头部是整个项目的地基,改错一个字段的对齐,后面全部对不上。项目用的是紧凑布局,编译时关掉结构体对齐,否则 x86 上sizeof会从 19 变成 20,校验和直接全线崩。

/* include/pkt.h —— 19 字节定长头,紧跟在 UDP 载荷最前面 */ #pragma pack(push, 1) typedef struct { uint16_t src_port; /* 源端口,做多连接实验时用来区分会话 */ uint16_t dst_port; /* 目的端口 */ uint32_t seq; /* 本段首字节在整个字节流中的偏移,按字节编 */ uint32_t ack; /* 期望收到的下一个字节偏移,累积确认 */ uint8_t flags; /* bit0 SYN, bit1 ACK, bit2 FIN, bit3 RST, bit4 PSH */ uint16_t window; /* 本端还可接收的字节数,单位字节 */ uint16_t checksum; /* 伪首部 + 头部 + 载荷的反码和,计算时本字段置 0 */ uint16_t payload_len; /* 载荷长度,0 表示纯控制段 */ } pkt_hdr_t; #pragma pack(pop) #define FLAG_SYN 0x01 #define FLAG_ACK 0x02 #define FLAG_FIN 0x04 #define FLAG_RST 0x08 #define FLAG_PSH 0x10

seq用uint32_t存字节偏移而不是段号,这是最关键的一个决定。用段号的话,一旦发生重传,同一段数据会带着两个不同的序号出现在抓包里,做 trace 对账时几乎没法还原,而且接收端判断乱序也得靠段号去乘 MSS,遇到最后一段不满 MSS 就会算错。用字节偏移就干净得多:重传的段序号不变,乱序缓存直接按偏移存,累积确认的ack字段天然就是「我还缺哪个字节」。

window在头部里是字节单位,而命令行--win是段单位,两者在conn.c里转换一次,别在两个地方各转一遍,那是 bug 温床。payload_len单独存一份是有必要的——UDP 本身给了长度,但你从recvfrom拿到的长度里还混着头部,自己存一份可以少做一次减法,也方便校验「声明长度和实际收到的长度是否一致」。

3. 核心模块拆解:一个 TCP 数据包从应用层到网卡的全链路

3.1 封装与校验和:一个字节算错就整段静默丢弃

校验和写错的典型症状是「发送端说有发,接收端说没收到」,两端日志都很干净,什么错也不报。因为校验失败的分支通常只做了一件事:丢弃,然后等对端超时重传。这等于是把问题从「校验错误」伪装成了「网络丢包」,排查时极其折磨人。

/* src/pkt.c —— 反码求和,注意先把 checksum 字段清零再参与计算 */ static uint32_t sum16(uint32_t sum, const uint8_t *p, int len) { while (len > 1) { sum += (uint32_t)((p[0] << 8) | p[1]); p += 2; len -= 2; } if (len == 1) sum += (uint32_t)(p[0] << 8); /* 奇数长度补一个零字节 */ return sum; } uint16_t pkt_checksum(const pkt_hdr_t *hdr, const uint8_t *payload, int plen) { pkt_hdr_t tmp = *hdr; tmp.checksum = 0; /* 关键:算之前必须清零 */ uint32_t sum = 0; sum = sum16(sum, (const uint8_t *)&tmp, sizeof(tmp)); sum = sum16(sum, payload, plen); while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16); return (uint16_t)(~sum); }

这里有个取舍:标准 TCP 的校验和要带上 12 字节伪首部(源 IP、目的 IP、协议号、TCP 长度),这个项目跑在本机回环上,IP 层不会出错,所以伪首部直接省略了,只对头部和载荷求和。做实验够用,但如果要拿去跟真实 TCP 数据包做对照,就得老老实实补上伪首部,否则 Wireshark 里看到的校验和永远是「incorrect」。

sum16里的奇数字节处理是另一个容易漏的点。MSS 设成 1024 时看不出来,一旦最后一段不满、或者你把 MSS 调成奇数,就会踩到。补零的规则是「在高位补」,写反了不会立刻报错,只会让某些长度的包校验不过,属于典型的玄学 bug。

封装函数本身很短,但要求一个纪律:头部里每个字段赋完值之后立刻算校验和,算完再sendto,不要中途插别的逻辑。中间插一句「顺手更新一下窗口」,忘了重算校验和,就又是一次静默丢弃。

3.2 发送端:滑动窗口、定时器与 RTT 估计

发送端的核心是三个变量:base(最早未确认字节)、next_seq(下一个要发的字节)、cwnd(拥塞窗口,字节单位)。有效发送窗口取min(cwnd, 对端通告窗口),这个min是整个可靠传输的心脏——拥塞控制管的是「网络能不能承受」,流量控制管的是「接收端能不能承受」,任何一边满了都不能再发。

/* src/sender.c —— 尽量把窗口填满 */ while (next_seq < base + effective_window(c) && next_seq < (uint32_t)file_len) { int len = min_int(MSS, file_len - (int)next_seq); pkt_send(c->sock, c->dst_ip, c->dst_port, next_seq, c->rcv_nxt, FLAG_ACK | FLAG_PSH, c->txbuf + next_seq, len); if (base == next_seq) /* 只有窗口第一个段才拥有唯一的定时器 */ timer_start(c, c->rto); next_seq += len; }

if (base == next_seq)这一行是很多人翻车的地方。给每个段都挂一个定时器,看着「更精确」,实际上重传逻辑会指数级复杂化:某个段超时了要不要连带重传它后面的?多个定时器同时到期怎么合并?标准做法就是只给最早的未确认段挂一个定时器,超时了从 base 开始全部重发,这就是所谓的「回退 N」。简单、正确、够用。

RTT 估计用的是 Jacobson/Karels 那套,只在「没有重传歧义」的段上采样。所谓歧义,指的是你收到一个 ACK 却分不清它是回应原始段还是回应重传段,这时候采样会把 RTT 算小,导致 RTO 越来越激进,最后疯狂重传。

/* 采样一次 RTT,平滑后更新 RTO */ void rtt_sample(conn_t *c, long rtt_ms) { if (c->srtt == 0) { c->srtt = rtt_ms; c->rttvar = rtt_ms / 2; } else { long err = rtt_ms - c->srtt; c->srtt += err / 8; /* 1/8 的平滑系数 */ c->rttvar += (labs(err) - c->rttvar) / 4; } c->rto = c->srtt + 4 * c->rttvar; /* 一个均值加四个偏差 */ if (c->rto < 200) c->rto = 200; /* 回环上 RTT 极低,必须设下限 */ if (c->rto > 60000) c->rto = 60000; }

那两个上下限不是可有可无的装饰。回环上 RTT 常常只有几十微秒,srtt + 4*rttvar算出来可能是 1ms,一旦对端因为写磁盘卡了 5ms,你就误判丢包开始重传,重传又推高负载,进入恶性循环。下限 200ms 是工程上的妥协:宁可晚重传,也不要假重传。

3.3 接收端:乱序缓存、累积确认与窗口通告

接收端比发送端简单,但有个必须想清楚的问题:乱序到达的段怎么存。最粗暴的是「不连续就丢」,性能差但不会错;项目里用的是有序链表 + 哈希索引,按seq排序插入,收一个段就从rcv_nxt开始尝试往后连续搬运。

/* src/receiver.c —— 收段、补洞、回 ACK */ void on_data(conn_t *c, pkt_hdr_t *h, uint8_t *payload, int plen) { if (h->seq == c->rcv_nxt) { write_out(c->out_fd, payload, plen); /* 正好接上,直接落盘 */ c->rcv_nxt += plen; /* 把之前因为空洞缓存的段一次性接上 */ cache_node_t *n; while ((n = cache_take(&c->ooo, c->rcv_nxt)) != NULL) { write_out(c->out_fd, n->data, n->len); c->rcv_nxt += n->len; free(n); } } else if (h->seq > c->rcv_nxt) { cache_put(&c->ooo, h->seq, payload, plen); /* 空洞之后的段先存着 */ } /* 重复段(seq < rcv_nxt)直接忽略,但 ACK 要照回 */ uint16_t adv = (uint16_t)(c->buf_cap - cache_bytes(&c->ooo)); pkt_send(c->sock, h->src_port, h->dst_port, c->snd_nxt, c->rcv_nxt, FLAG_ACK, NULL, 0); /* 广告窗口在上面 send 前已写入头部 window 字段 */ (void)adv; }

窗口通告的算法是buf_cap - 已缓存字节,注意减的是「乱序缓存的占用」而不是「已收到的总量」。很多人这里写成buf_cap - (rcv_nxt - 初始值),表面上看是在算剩余空间,实际上算的是「流的总进度」,窗口只会越来越小直到归零,然后死锁。这个错误在丢包率低的时候完全看不出来,一旦开始丢包就整条连接卡死。

还有一个细节:重复段(seq < rcv_nxt)必须回 ACK,哪怕数据直接扔了。因为对端之所以重传,就是因为没收到 ACK,你不回它就一直重传下去。而重复 ACK 又是发送端触发快重传的依据,一来一回反而成了性能优化点。

3.4 连接管理:三次握手和四次挥手为什么不能省

课程项目里最容易偷懒的就是连接管理,觉得 UDT 反正没有连接,直接开始传数据不就行了。但少了握手,你就没法交换初始序号(ISN),也没法在传数据之前就协商窗口大小和 MSS,最后所有参数都得硬编码在两端,一旦两端配置不一致就是一堆莫名其妙的错位。

/* src/conn.c —— 客户端主动握手,带最小重试与指数退避 */ int conn_connect(conn_t *c, const char *ip, uint16_t port) { c->snd_nxt = (uint32_t)time(NULL) ^ (uint32_t)getpid(); /* 粗糙但够用的 ISN */ for (int retry = 0; retry < 5; retry++) { pkt_send(c->sock, ip, port, c->snd_nxt, 0, FLAG_SYN, NULL, 0); pkt_hdr_t h; if (recv_with_timeout(c->sock, &h, NULL, c->rto) > 0 && (h.flags & FLAG_SYN) && (h.flags & FLAG_ACK)) { c->snd_nxt++; /* SYN 占一个序号 */ c->rcv_nxt = h.seq + 1; c->peer_win = h.window; pkt_send(c->sock, ip, port, c->snd_nxt, c->rcv_nxt, FLAG_ACK, NULL, 0); return 0; } c->rto *= 2; /* 指数退避,和真实 TCP 一致 */ } return -1; }

挥手比握手更容易被简化成「发个 FIN 就完事」。可以直接说明:被动关闭方收到 FIN 后回 ACK,然后自己再发 FIN,主动方回 ACK 并进入 TIME_WAIT。TIME_WAIT 在这个项目里通常被省掉,因为端口马上就能复用,但省掉之后会带来一个副作用——最后一个 ACK 如果丢了,被动方会重传 FIN,而此时主动方已经退出,端口上没人监听,内核直接回一个 ICMP 端口不可达,被动方那边就报错了。所以代码里至少留了一个可配置的--timewait-ms,默认 200ms,够覆盖回环上的重传窗口。

4. 拥塞控制与丢包实验:让吞吐曲线自己说话

4.1 慢启动、拥塞避免与快重传的状态机

拥塞控制这块代码量不大,但状态转移必须清晰:cwnd < ssthresh走慢启动,每个 ACK 涨一个 MSS(指数增长);cwnd >= ssthresh走拥塞避免,每个 RTT 涨一个 MSS(线性增长);收到三个重复 ACK 触发快重传 + 快恢复;超时则直接回到慢启动起点。

/* src/conn.c —— cwnd 的三个入口 */ void on_new_ack(conn_t *c, uint32_t acked_bytes) { if (c->cwnd < c->ssthresh) { c->cwnd += MSS; /* 慢启动:每 ACK 加一个 MSS */ } else { /* 拥塞避免:每 RTT 加一个 MSS,等价于每 ACK 加 MSS*MSS/cwnd */ c->cwnd += (uint32_t)((double)MSS * MSS / c->cwnd); } (void)acked_bytes; } void on_dup_ack(conn_t *c) { if (++c->dup_cnt == 3) { c->ssthresh = c->cwnd / 2; c->cwnd = c->ssthresh + 3 * MSS; /* 快恢复:先撑三个 MSS */ retransmit_from(c, c->base); /* 只重传缺口那一个 */ } } void on_timeout(conn_t *c) { c->ssthresh = c->cwnd / 2; c->cwnd = MSS; /* 超时是最坏信号,回起点 */ c->dup_cnt = 0; retransmit_from(c, c->base); /* 回退 N,全部重发 */ }

on_dup_ack里cwnd = ssthresh + 3*MSS那一行是快恢复的精髓,也是最容易被抄错的地方。它的含义是「我已经知道有三个包离开了网络,所以可以暂时把窗口撑大三个」,每再收到一个重复 ACK 就再加一个 MSS,直到收到新 ACK 再把 cwnd 收回 ssthresh。如果漏了这一步,你会看到快重传之后吞吐掉得比超时还狠,因为 cwnd 直接被砍半却没有相应的补偿窗口。

还有个实践上的细节:retransmit_from在两个分支里都调用了,但行为不同。超时回退是「从 base 开始把所有未确认的段全部重发」,快重传是「只重发 base 那一个段」。如果快重传也全发,就会在链路里凭空多出一堆包,拥塞状况反而恶化。这个区别值得在代码里用两个不同名字的函数分开写,别用一个resend()加参数糊弄过去。

4.2 丢包注入:用代理进程代替 tc

想验证上面这套逻辑,必须在可信的丢包环境下跑。Linux 上最直接的是tc qdisc add dev lo root netem loss 5%,但要 root,而且会把整台机器的回环都影响掉,不太适合随手做实验。项目里自带了一个用户态中继,夹在发送端和接收端之间,按概率静默丢包,不动内核。

# tools/lossy_proxy.py —— 单线程中继,按概率丢包 import socket, random, sys listen_port, up_port, loss = int(sys.argv[1]), int(sys.argv[2]), float(sys.argv[3]) down = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) down.bind(("127.0.0.1", listen_port)) # 发送端连这个端口 up = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) up.bind(("127.0.0.1", 0)) sender = None while True: data, addr = down.recvfrom(4096) sender = addr # 记住发送端地址,回程包才能送回 if random.random() >= loss: up.sendto(data, ("127.0.0.1", up_port)) # 丢了就不发,两端都不会收到通知 up.settimeout(0.05) try: reply, _ = up.recvfrom(4096) down.sendto(reply, sender) # ACK 原样带回,不丢 except socket.timeout: pass

启动顺序变成:先起接收端,再起代理(比如监听 9100,转发到 9001),发送端连 9100。这样发送端完全不知道中间夹了一层,对它来说就是一个「会丢包的链路」。

loss建议从 0.01 开始往上试,0.05 就能明显看到快重传被触发,0.1 以上基本就全是超时重传了,吞吐会掉到原来的十分之一以下——这个数字很直观,比任何图表都更能说明「超时对 TCP 数据包传输的惩罚有多重」。

注意这个中继是单线程阻塞式的:发完一个包就去等回程包,等不到就 50ms 超时再读下一个。这意味着它自身的转发延迟会串进 RTT 里,丢包率越高、RTT 抖动越大。做定量分析时要意识到这一点,结论应该是「快重传确实降低了恢复时间」,而不是「这个实现的吞吐是 X MB/s」——后者被代理本身限制了,没有参考价值。想消掉这个影响,就把中继改成select双向非阻塞,或者两个线程各管一个方向。

4.3 抓包与 trace 对齐:用 tshark 验证序号逻辑

代码里的 trace 是自己打的日志,它记录的是「我以为我发了什么」;抓包记录的是「网卡上真正出现了什么」。两者对不上,问题一定在中间那段——要么封装错了,要么校验和挂了被丢弃。这个对照是排查可靠传输问题时最快的一招。

# 只抓回环上的 9001 端口,落盘 sudo tshark -i lo -f "udp port 9001" -w cap.pcap # 把每个 UDP 载荷的十六进制和相对时间导出来,供脚本解析 tshark -r cap.pcap -Y "udp.port==9001" -T fields \ -e frame.time_relative -e data.data > cap.txt

导出的data.data就是整段 UDP 载荷的十六进制字符串,前面 19 字节正是自定义头部。用一个几十行的脚本就能把它还原成和 trace.csv 同构的表格,两边 diff 一下,序号、ACK、标志位对不对一目了然。

import struct # "!HHIIBHHH" 对应 pkt_hdr_t:网络字节序,2+2+4+4+1+2+2+2 = 19 字节 HDR = struct.Struct("!HHIIBHHH") with open("cap.txt") as fin, open("segs.csv", "w") as fout: fout.write("t,seq,ack,flags,win,plen\n") for line in fin: t, hexdata = line.rstrip("\n").split("\t") raw = bytes.fromhex(hexdata) sp, dp, seq, ack, flags, win, csum, plen = HDR.unpack_from(raw, 0) fout.write(f"{float(t):.6f},{seq},{ack},{flags},{win},{plen}\n")

跑通之后你会看到三条曲线:cwnd呈锯齿状爬升然后被砍半、ack单调不减地往上涨、win在丢包区间里反复抖动。如果ack出现了回退,那基本可以确定是接收端在补洞时把rcv_nxt算错了;如果cwnd只有锯齿没有爬升,说明ssthresh被反复砍到 MSS,通常是超时定时器挂错了位置。

5. 避坑与排查:五个让我熬夜的细节

5.1 校验和永远对不上,接收端一个包都不收

现象:发送端日志显示发了 100 个段,接收端日志一行数据都没有,也不报错,最后以超时结束。

原因有两个,都跟内存布局有关。一是结构体没加#pragma pack(1),编译器为了对齐在flags后面补了 1 个字节,sizeof(pkt_hdr_t)变成 20,发送端按 20 字节封装、接收端按 19 字节解析,从window字段开始全错位。二是计算校验和之前忘了把checksum字段清零,上一次的残值参与了这次求和。

解决:结构体统一 pack,并且在pkt_checksum里用局部副本、先把checksum置 0 再算。同时在main里加一行assert(sizeof(pkt_hdr_t) == 19),这一行能省掉你两小时的排查。

5.2 传到 3MB 左右卡死,两端都在等对方

现象:小文件正常,大文件传到某个位置彻底不动,两端进程都活着、CPU 占用接近 0,没有任何报错。

原因:接收窗口通告算成了buf_cap - (rcv_nxt - 起始序号)。这算的是流的总进度,窗口只减不增,传满缓冲区后通告一个 0 窗口。发送端老老实实停下等窗口更新,但接收端因为应用层已经把数据写走了、缓冲区其实是空的,只是它自己算不出来,于是双方互等,标准的零窗口死锁。另外这个实现里没写 persist timer(零窗口探测),所以谁也救不了。

解决:窗口按「缓冲区容量 - 乱序缓存实际占用」算;发送端在收到 0 窗口时启动一个 persist timer,定时发 1 字节探测包,逼对端回 ACK 把最新窗口带回来。后一条是真正的后悔药,别省。

5.3 快重传之后吞吐反而腰斩

现象:丢包率 3% 时,明明触发了快重传,恢复时间却比超时还长,cwnd 曲线一路下坡。

原因:超时定时器是在「每发一个段」时都重启一次,而不是只在base对应的段上启动。这样窗口里最后发出的那个段成了定时器的主人,它一到期就把ssthresh砍半、cwnd打回 MSS,等于把快重传刚恢复的窗口立刻打回慢启动。

解决:定时器只跟随base,base前移时才重启;重传时不要重置定时器,让它自然到期再退避一倍。这条改完之后,丢包率 5% 下的吞吐能涨三倍以上。

5.4 抓包里的序号看着是乱的,跟 trace 完全对不上

现象:trace.csv 里 seq 是 0、1024、2048 这样递增的,Wireshark 里解出来的却跳来跳去,还出现重复值。

原因:seq 按「段号」递增而不是按「字节偏移」。段号在重传时会重算,最后一段不满 MSS 时更明显——段号是 3,偏移却是 3072,两边永远差一截。另外 32 位序号回绕时按段号编会提前绕回,出现大量重复。

解决:全链路统一按字节偏移,next_seq += len而不是next_seq++。ISN 用时间戳和 pid 异或生成,别用固定值 0,否则两个连接同时跑会互相干扰。

5.5 一提丢包率吞吐就掉到几乎为零

现象:代理丢包率调到 10%,文件传输耗时从 1 秒变成 40 秒,trace 里全是重传,重传率超过 60%。

原因:多数时候不是协议写错了,是丢包注入工具本身成了瓶颈。单线程阻塞式中继在丢包时那段settimeout(0.05)会实打实等满 50ms,丢得越多等得越久,RTT 被拉到几百毫秒。RTO 跟着涨,重传更慢,形成正反馈。

解决:中继改成非阻塞双向转发,或者干脆用tc netem在独立网络命名空间里做。另外把rto的上下限检查一遍,下限 200ms 在小文件场景下太大,可以按文件大小动态调。

6. 二次开发:把它改成你自己的实验平台

这套代码最适合的二次开发方向,是把拥塞控制抽成一个可替换的策略接口。现在的on_new_ack/on_dup_ack/on_timeout三个函数是写死在conn.c里的,抽出成函数指针表只要半天,之后加 CUBIC、BBR 的简化版就是填空题。

/* 抽象出策略接口,conn_t 里挂一个指针即可切换算法 */ typedef struct { const char *name; void (*on_ack) (conn_t *, uint32_t acked); void (*on_dup_ack) (conn_t *); void (*on_timeout) (conn_t *); } cc_ops_t; extern const cc_ops_t CC_RENO; /* 项目现有实现 */ extern const cc_ops_t CC_FIXED; /* 固定窗口,用来做对照基线 */

验证方法上,别只看总耗时。固定跑三组对比(0% / 3% / 8% 丢包),每组三遍取中位数,记录四个指标:总耗时、重传段数、cwnd峰值、恢复时间的平均值。CC_FIXED那组一定最慢但最稳,用它做基线,你才能说清楚新算法到底赢在哪一档丢包区间。

改代码的时候有个习惯救过我很多次:任何改动先在trace.csv里加一列字段,把新策略的中间变量(比如 BBR 的起搏速率)打出来,再动逻辑。没有 trace,调拥塞控制就是纯靠猜。从那以后我每次碰这类课程项目,都强制自己先把边界条件写进 trace 里再谈优化。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询