简介:面向计算机网络课程三级项目与TCP网络编程入门者,这份燕大项目资料提供一套可参考、可运行的TCP数据包传输实践工程,围绕客户端与服务器通信展开,覆盖三次握手建连、数据分片与重组、滑动窗口流量控制、拥塞控制、确认重传以及四次挥手释放连接等核心机制。压缩包共60个文件、约27.88MB,包含cpp与h源码、sln与vcxproj工程配置、exe可执行程序,以及pdb、obj、ipch等编译调试中间文件和log、tlog运行日志,便于对照工程结构理解socket编程与TCP算法实现。已有1355人学习,适合课程项目复盘、实验验收参考和网络编程排错练习。读者可借助双端工程与调试产物观察连接建立、数据传输和释放过程,结合慢启动、快速重传、快速恢复等知识点梳理实现思路,也可在本地编译运行后模拟不同网络条件,加深对可靠传输机制的理解。
1. 需求与选型:一个 TCP 数据包传输项目到底要交付什么
很多人拿到计算机网络课程的三级项目题目,第一反应是选 UDP:写完 bind、sendto、recvfrom 就能跑起来,半小时出结果。真到验收现场,被问一句「丢一个数据包你怎么办」就答不上来了。我一般建议反着来——选 TCP,然后把 TCP 已经替你做完的事,在应用层再显式做一遍:自己定义数据包格式、自己做序号与确认、自己做整包校验、自己处理粘包。这套方案解决的不是「能不能传」,而是「传完之后能拿出证据证明它没坏」,适合要交课程设计、要做现场演示、还得讲清楚每一层职责的人。下文按「协议帧 → 服务端 → 客户端 → 排查 → 验收」的顺序,把一套能直接跑、能直接讲的 TCP 数据包传输方案拆开。
2. 协议设计:16 字节帧头 + CRC32 把字节流切成可识别的数据包
2.1 为什么在 TCP 之上还要再切一层包
TCP 提供的是字节流,不是消息流。它在协议栈里保证了有序、可靠、不重复,但它不知道你的业务边界在哪里:发两次 100 字节,接收方可能一次 recv 收到 200 字节,也可能分三次收到 30、90、80 字节。这就是粘包与半包。课程项目里最常见的翻车方式,是在客户端用send("filename:xxx")发一行文本当文件头,服务端用recv(1024)收,然后拿空格或换行去 split——只要文件大一点、网络抖动一下,split 出来的文件名就会带上半个二进制块。
所以整个方案的第一层设计就是:在 TCP 字节流之上,定义一套定长头 + 变长体的数据包格式。头里带上长度,接收方就能靠「先攒够头,再按长度攒够体」的方式,把流还原成一帧一帧的数据包。这一层做好了,后面的文件传输、断点续传、并发多客户端才有立足点。
2.2 帧头字段的排布与每个字段的取值理由
帧头我一般排成 16 字节,字段按下面的顺序:
| 字段 | 长度 | 取值 | 作用 |
|---|---|---|---|
| magic | 2 字节 | 固定 0x4A57 | 错位检测的快速判据 |
| version | 1 字节 | 当前 1 | 给后续扩展留出口 |
| type | 1 字节 | 1~6 | 帧类型分派 |
| seq | 4 字节 | 从 0 递增 | 数据块序号、回执对应关系 |
| length | 4 字节 | 0~1MB | 载荷字节数 |
| crc32 | 4 字节 | 头部+载荷的 CRC | 整帧完整性 |
magic 用两个字节是有讲究的:一个字节太容易和二进制文件内容撞上,四个字节又浪费。收到数据时先看头两个字节对不对,不对就说明缓冲区错位了,直接断开比硬着头皮解析更安全。type 字段分出六种帧:0x01 HELLO(握手)、0x02 META(文件名、总大小、全文件哈希)、0x03 DATA(数据块)、0x04 ACK(确认)、0x05 FIN(传输结束)、0x06 ERROR(错误回传)。
CRC32 放在头之后、载荷之前,并且计算范围是「头 + 载荷」而不是只算载荷。只算载荷的话,length 字段本身被改坏了就发现不了,接收方会按一个错误长度去等,一直等到超时。
length必须设上限,这是我在真实调试里踩出来的习惯。如果不设上限,一个被污染的长度字段可能是 40 亿,接收方就会按 40 亿去申请缓冲,程序直接被系统杀掉。上限取 1MB,远大于实际块大小 64KB,留足余量。
2.3 编解码实现与参数说明
打包和解包用struct就够,不要手写位运算拼接。
import struct, zlib # ! 表示网络字节序(大端),H 魔数、B 版本、B 类型、I 序号、I 载荷长度 HEADER_FMT = "!HBBII" HEADER_LEN = struct.calcsize(HEADER_FMT) # 12 字节 CRC_LEN = 4 MAX_PAYLOAD = 1024 * 1024 # 单帧载荷上限 1MB MAGIC = 0x4A57 TYPE_HELLO, TYPE_META, TYPE_DATA, TYPE_ACK, TYPE_FIN, TYPE_ERROR = 1, 2, 3, 4, 5, 6 def pack_frame(ftype, seq, payload=b""): """把类型、序号、载荷打成一段可直接 sendall 的字节""" if len(payload) > MAX_PAYLOAD: raise ValueError("载荷超长") head = struct.pack(HEADER_FMT, MAGIC, 1, ftype, seq, len(payload)) crc = zlib.crc32(head + payload) & 0xFFFFFFFF return head + struct.pack("!I", crc) + payload def try_unpack(buf): """从缓冲区尝试切出一帧:返回 (帧, 剩余字节),数据不够返回 (None, buf)""" if len(buf) < HEADER_LEN + CRC_LEN: return None, bytes(buf) magic, ver, ftype, seq, plen = struct.unpack(HEADER_FMT, buf[:HEADER_LEN]) if magic != MAGIC: raise ValueError("帧头错位,缓冲区需要重置") if plen > MAX_PAYLOAD: raise ValueError("长度字段异常") total = HEADER_LEN + CRC_LEN + plen if len(buf) < total: return None, bytes(buf) # 半包:等下一次 recv body = bytes(buf[HEADER_LEN + CRC_LEN:total]) crc_recv = struct.unpack("!I", buf[HEADER_LEN:HEADER_LEN + CRC_LEN])[0] crc_calc = zlib.crc32(bytes(buf[:HEADER_LEN]) + body) & 0xFFFFFFFF if crc_recv != crc_calc: raise ValueError("CRC 校验失败") return (ftype, seq, body), bytes(buf[total:])pack_frame里用szudzik之类的算法拼接是多余的,struct加zlib两步足够,而且 CRC 的算法实现是 C 层,每 64KB 算一次几乎不占时间。try_unpack的关键在于两个提前返回:长度不够 16 字节返回 None(等半包),长度够头但不够整帧也返回 None。调用方拿到 None 就break出循环,把剩余字节继续留在缓冲区里,等下一次 recv 追加。这样一个 while 循环就能同时处理粘包(一次 recv 里有多帧,循环切完)和半包(切不完整就停下)。
注意:
bytes(buf[...])这种切片转换不要省。bytearray 是可变的,如果你直接把切片结果扔给后续处理,而缓冲区又在下一次循环里被extend,就会出现「处理到一半数据被改了」的玄学问题。血泪经验,别问怎么知道的。
序号seq用 4 字节无符号整数,配合 64KB 的块大小,理论上限是 4G × 64KB = 256TB,对课程项目来说完全溢出,不需要考虑回绕。真要做超大文件,把块大小提到 1MB,或者换成 8 字节序号,改一个格式串就行。
3. 服务端实现:selectors 事件循环与四态连接状态机
3.1 并发模型的选型:线程池还是事件循环
服务端的并发模型有两种写法能过验收,取舍很明确。
第一种是「一连接一线程」:accept之后threading.Thread(target=handle, args=(conn,)).start()。优点是代码直白,阻塞式recv写起来跟单机一样,答辩时讲得清楚;缺点是很不自然,十个客户端就是十个线程,每个连接的状态变量要靠闭包或者参数传递,写断点续传的时候容易把不同连接的进度混在一起。
第二种是selectors事件循环:单线程、非阻塞 socket、一个循环里同时盯着监听套接字和所有连接。代码量多一点,但它把「每个连接一个状态对象」这件事变成了结构上的强制约束,天然不会串台,而且能顺带讲清楚 I/O 多路复用是干什么的——这正好是计算机网络课上的知识点,答辩时是加分项。
我一般选第二种,然后把第一种作为「简化版」写进文档的对比章节里。下面只展开事件循环版。
3.2 连接状态机与主循环骨架
每个连接走到哪一步,用一个状态字符串记着,服务端只根据当前状态决定怎么处理收到的帧。四个状态:HELLO(等握手)→META(等文件元信息)→TRANSFER(收数据块)→DONE(收尾校验)。
import selectors, socket, os, hashlib sel = selectors.DefaultSelector() class Conn: __slots__ = ("sock", "buf", "state", "file", "written", "meta", "hasher") def __init__(self, sock): self.sock = sock self.buf = bytearray() # 每连接独立缓冲区,这是不串台的关键 self.state = "HELLO" self.file = None self.written = 0 # 已落盘字节数,断点续传以它为准 self.meta = {} self.hasher = hashlib.sha256() def accept(server): sock, _ = server.accept() sock.setblocking(False) sel.register(sock, selectors.EVENT_READ, Conn(sock)) def on_readable(conn): try: chunk = conn.sock.recv(65536) except BlockingIOError: return if not chunk: close_conn(conn) return conn.buf.extend(chunk) while True: frame, rest = try_unpack(conn.buf) if frame is None: break conn.buf = bytearray(rest) dispatch(conn, *frame) def dispatch(conn, ftype, seq, body): if ftype == TYPE_HELLO: conn.sock.sendall(pack_frame(TYPE_ACK, 0)) conn.state = "META" elif ftype == TYPE_META: conn.meta = parse_meta(body) # 文件名、总大小、sha256 conn.file = open(conn.meta["tmp"], "wb") conn.sock.sendall(pack_frame(TYPE_ACK, 0)) conn.state = "TRANSFER" elif ftype == TYPE_DATA: conn.file.write(body) conn.file.flush() # 先落盘 conn.written += len(body) # 再记进度 conn.hasher.update(body) conn.sock.sendall(pack_frame(TYPE_ACK, seq)) # 最后回执 elif ftype == TYPE_FIN: finish(conn) def loop(server): sel.register(server, selectors.EVENT_READ, None) while True: for key, _ in sel.select(timeout=1.0): if key.data is None: accept(key.fileobj) else: on_readable(key.data)值得单独说的是dispatch里 DATA 那三行:先write、再flush、再累加written、最后sendall回执。这个顺序不是随手排的。如果先回 ACK 再落盘,客户端收到确认后可能立刻发下一块,而服务端这边还堵在磁盘 I/O 上,缓冲区越堆越大;更糟的是客户端拿 ACK 当作「已持久化」的证据去做断点续传,一旦进程崩了,续传点就对不上,文件后半段会缺一截。顺序反过来,ACK 的含义就变成「我已经把它写进磁盘了」,语义才立得住。
3.3 收尾顺序与资源释放
传输结束的finish里做三件事:校验、改名、关连接。
def finish(conn): conn.file.close() digest = conn.hasher.hexdigest() ok = (digest == conn.meta["sha256"]) and (conn.written == conn.meta["size"]) if ok: os.replace(conn.meta["tmp"], conn.meta["final"]) # 原子改名 conn.sock.sendall(pack_frame(TYPE_ACK, 0xFFFFFFFF)) else: conn.sock.sendall(pack_frame(TYPE_ERROR, 0)) close_conn(conn)用临时文件加os.replace的理由很实际:演示时如果中途拔网线,目录里不会留下一个看起来正常、实际只有一半的坏文件。验收老师翻目录看到.part后缀,反而能顺着讲你考虑了异常中断,这是个加分点。
close_conn里要按顺序做:从 selector 注销、shutdown(SHUT_RDWR)、close()、把Conn对象从任何可能持有的容器里删掉。少任何一步,程序跑久了文件描述符就会耗尽,现象是「跑第十次的时候 accept 报错」,这个坑在第 5 章还会再提一次。
服务端还要设SO_REUSEADDR:
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 9000)) server.listen(128) server.setblocking(False)不设这一行,服务端刚关掉又立刻重启,会报Address already in use。原因是主动关闭的一方会进入 TIME_WAIT 状态,端口还在占用期内。这个现象在调试阶段几乎每次都会遇到,第一次碰上的时候很容易怀疑是代码写错了。
4. 客户端实现:64KB 分块、进度反馈与断点续传协商
4.1 分块大小的取舍
块大小在 16KB 到 1MB 之间都能跑,选 64KB 是个折中:太小则帧头开销占比高,每 4KB 数据配 16 字节头,头部开销 0.4%,而且系统调用次数翻倍;太大则单帧在接收缓冲里等待的时间变长,进度更新不流畅,演示时看起来像卡住了。
还有个隐性因素是 MTU。以太网 MTU 一般 1500 字节,64KB 的数据块在 IP 层会被切成四十多个分片,任何一个分片丢了整块都要重传。TCP 本身会做分段和重传,所以应用层不用管,但如果你把块调到 1MB,一旦某一帧出错,重传代价就是整 1MB。对本地回环和局域网演示来说 64KB 是最稳的。
sendall和send的区别要讲清楚:send可能只发出一部分,返回值是实际发出的字节数,剩下的要自己循环;sendall内部就是那个循环。客户端发数据帧一律用sendall,别图省事写sock.send(frame),那是在赌缓冲区够大。
4.2 发送循环与停等确认
课程项目不需要上滑动窗口,停等协议就够,逻辑清楚、好讲、好调。
import os, time, hashlib BLOCK = 64 * 1024 def send_file(sock, path, resume_from=0): total = os.path.getsize(path) seq = resume_from // BLOCK # 续传时序号要跟着偏移走 sent = resume_from with open(path, "rb") as f: f.seek(resume_from) while True: data = f.read(BLOCK) if not data: break sock.sendall(pack_frame(TYPE_DATA, seq, data)) wait_ack(sock, seq) # 停等:确认到达才推进 sent += len(data) seq += 1 report(sent, total) return sent def wait_ack(sock, expect_seq): """阻塞读,直到收到序号匹配的 ACK;这里用简化版,不做乱序缓存""" buf = bytearray() while True: chunk = sock.recv(4096) if not chunk: raise ConnectionError("对端关闭") buf.extend(chunk) frame, rest = try_unpack(buf) if frame is None: continue ftype, seq, _ = frame if ftype == TYPE_ACK and seq == expect_seq: return if ftype == TYPE_ERROR: raise RuntimeError("服务端校验失败") buf = bytearray(rest)seq从resume_from // BLOCK起算而不是从 0 起算,是为了让服务端能按序号判断「这一块我是不是已经有了」。如果续传时序号仍从 0 开始,服务端会以为你在重传第一块,续传点就丢了。
wait_ack里那个buf = bytearray(rest)容易漏。收到的这段字节里可能既有 ACK 帧,又有下一帧的开头,切完 ACK 之后剩下的部分必须保留,否则下一次循环会把它当新数据重新解析,直接帧头错位。
4.3 断点续传的协商流程
完整的续传流程是三次交互:
客户端先连上,发HELLO,收到 ACK;再发META,里面带文件名、总大小、全文件 SHA-256、以及「我想从第 N 字节开始」;服务端查本地.part文件的实际大小 M,回一个ACK,序号字段填 M;客户端把resume_from设为 M,seek过去继续发。
这里有个必须坚持的原则:续传点以服务端已落盘的字节数为准,客户端自己记的进度只作参考。因为客户端认为「发出去了」和「服务端写进磁盘了」是两件事,中间隔着网络和服务端的缓冲区。如果客户端拿自己的发送计数当续传点,网络中途断掉时就会跳过一段没到达的数据,最终文件大小对、内容错,而且这种错误只有算哈希才能发现。
提示:续传点的值最好落在块边界上。服务端写盘时如果只写了半块(比如 recv 到一半连接断了),返回的 M 不是 64KB 的整数倍,客户端
seek(M)之后从半块中间开始发,序号却按整块算,两边就错位了。处理办法是服务端返回M - M % BLOCK,把最后那个不完整的块丢掉重传。
4.4 进度与速率的观测
进度条不用第三方库,自己算两个数就够:
def report(sent, total, t0=[time.time()]): cost = max(time.time() - t0[0], 1e-6) speed = sent / cost / 1024 / 1024 # MB/s pct = sent * 100.0 / total if total else 0 print(f"\r{pct:6.2f}% {sent}/{total} 字节 {speed:.2f} MB/s", end="", flush=True)用\r覆盖同一行,不要在循环里print新行,否则几千行输出会拖慢终端,看起来像是程序卡了。flush=True也不能省,输出重定向到文件时缓冲区不会自动刷。
带宽的观测值在验收时很有用。本地回环跑 64KB 停等,通常能到几十 MB/s;如果你测出来只有几百 KB/s,基本可以断定是每发一块就sleep或者每块都重新建连接了,这种数值异常比看代码快。
5. 避坑与排查:五处让传输卡死或校验失败的典型问题
5.1 传到一半双方都不动了
现象:小文件秒传,几百 MB 的文件跑到某个百分比后进度条停住,CPU 占用接近零,等几分钟也不动,最后超时断开。
原因:客户端在wait_ack里阻塞recv,服务端在sendall一个大的 ACK 或者错误信息时,发送缓冲区满了;而客户端此时并没有在读,它正在等 ACK,服务端又在等客户端读——互相等,典型死锁。另一种形态是双方都用阻塞 socket 并且都先写后读。
解决:一端持读,另一端持写;或者把 socket 设成非阻塞,用select同时等可读可写。最简单的做法是客户端发完一块立刻进入读状态,服务端写回执前先把本轮的接收缓冲读干净,保证写操作不会因为对方不读而卡住。我一般会在wait_ack上加一个settimeout(30),超时就重发当前块,最多三次,这样即使逻辑有瑕疵也不会永久挂死。
5.2 文件名后面跟着一堆乱码
现象:服务端解析出来的文件名是report.pdf\x00\x00\x8f\x1a...,保存时报非法字符,或者文件能存下来但扩展名丢了。
原因:把文本协议和二进制数据混在同一个流里。前面用send("report.pdf\n")发文件名,后面直接send(file_bytes),服务端recv(4096)一次把文件名和第一块二进制都收进来了,按换行切完之后剩下的二进制被当成了文件名的尾巴。
解决:统一走帧格式,META 帧的载荷用固定结构或者 JSON 序列化,长度由帧头的 length 字段给出,接收方严格按长度取载荷,取完就切,不依赖任何分隔符。文本分隔符在二进制流里永远不可靠,这是设计层面的问题,不是加个strip()能补的。
5.3 续传之后文件变大了
现象:断点续传一次,最终文件比原文件大几十 KB,大小校验直接失败;或者文件大小对得上,但 SHA-256 不一致。
原因:服务端返回的续传点是「已接收字节数」而不是「已落盘字节数」。接收缓冲区里还有没写盘的数据时,程序崩了或者连接断了,缓冲区内容丢失,但计数已经加上去了。客户端从虚高的位置继续发,中间就出现一段空洞,文件被后面的数据顶长。
解决:续传点只认落盘量,并且每次写盘后立刻flush(),written字段紧跟在flush后面更新。更稳妥的是用.part文件的真实大小作为续传点,每次协商时os.path.getsize(tmp)现算一次,不信任内存里的计数器。
5.4 校验总在最后一块失败
现象:前面几万块都没问题,最后一块或者倒数第二块 CRC 必错,重传也没用。
原因:读取文件时用了文本模式open(path, "r"),Windows 上换行符会被转换,\r\n变成\n,字节数和哈希全变了;或者最后一块长度不是块大小的整数倍,发送端按实际长度打了帧头,接收端却按固定块大小去等,永远等不齐。
解决:文件一律用rb/wb打开,这是硬规矩。末尾块的处理依赖帧头里的 length 字段,接收方不能假设每帧都是 64KB,必须按 length 取。CRC 失败时不要只报错,把序号和期望长度、实际长度打进日志,一眼就能看出是长度错位还是内容损坏。
5.5 多客户端测试时数据串台
现象:单客户端跑得好好的,开三个客户端同时传,文件互相污染,A 的文件里出现 B 的内容;或者服务端报「文件已打开」。
原因:把连接相关的状态写成了全局变量或模块级变量,比如current_file、recv_buffer、written_bytes都只有一份。第一个连接改动它,第二个连接读到的就是别人的进度。
解决:所有连接态字段都挂在每连接的对象上(第 3 章的Conn类就是干这个的),函数签名里显式传conn,禁止任何跨连接的共享可变状态。检查方法很土但有效:全局搜一遍模块级变量,凡是赋值发生在dispatch里面的,全部搬进类里。
注意:文件描述符泄漏也常在这一步暴露。连接关闭时只
close()而没有从 selector 注销,select会持续返回这个已关闭的 fd,报Bad file descriptor。三步走——注销、shutdown、close——一步都不能省。
6. 验收与答辩:三组测试数据与一套十分钟演示脚本
现场演示最怕的是「传完了,但没人能确认它是对的」。所以验收脚本的核心不是跑通,而是留下可核对的证据。我会准备一张测试记录表,边跑边填:
| 测试项 | 输入 | 观察点 | 合格标准 |
|---|---|---|---|
| 小文件往返 | 3KB 文本 | 帧数、ACK 序号 | 内容与 SHA-256 完全一致 |
| 大文件传输 | 500MB 二进制 | 稳定速率、进度曲线 | 哈希一致,耗时在预期区间 |
| 中途断连续传 | 传至 40% 杀客户端 | 服务端.part大小 | 重启后从上一次落盘点续传 |
| 三客户端并发 | 同时传不同文件 | 各连接进度互不影响 | 三个文件哈希各自正确 |
| 异常帧注入 | 手动改坏一字节 | 服务端日志 | 报 CRC 失败并断开,不崩溃 |
演示脚本按十分钟排:前两分钟讲架构图和帧格式,画在白板上比放 PPT 好;中间五分钟跑「大文件传输 + 中途断连续传」,断连那一下是全场最有说服力的动作;最后三分钟打开两个终端的日志,把 CRC 校验、续传点协商、并发控制三个点对着日志讲一遍。
答辩提问基本集中在四处,提前把答案压成一句话:为什么不用 UDP——因为我要的是「传完之后能证明没坏」,可靠性交给 TCP,可验证性由应用层的序号和 CRC 负责;块大小为什么是 64KB——兼顾帧头开销、重传代价和进度更新粒度;粘包是什么——字节流没有消息边界,靠定长头里的 length 字段切;并发怎么做的——单线程事件循环加每连接独立状态对象,不共享可变数据。
讲并发那段时别只说「用了 selectors」,要说出「因为每个连接都有自己的接收缓冲和写盘句柄,所以多客户端不会串台」,把设计和效果连起来说,评价会高一档。
从那以后我每次交付这类传输程序,都强制自己先把「断连一次再续传」跑通,才允许自己说做完了。因为真正暴露问题的从来不是顺利路径,而是那条断在半路的连接。希望帮到你。
本文还有配套的精品资源,点击获取