UDP上重造TCP数据包通道:滑动窗口、拥塞控制与抓包验证 简介围绕燕山大学计算机网络三级项目整理的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 0x10seq用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.port9001 -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网络字节序22441222 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等于把快重传刚恢复的窗口立刻打回慢启动。解决定时器只跟随basebase前移时才重启重传时不要重置定时器让它自然到期再退避一倍。这条改完之后丢包率 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 里再谈优化。希望帮到你。本文还有配套的精品资源点击获取