简介:燕山大学计算机网络三级项目TCP传输数据包,是一套面向计算机网络课程的实践工程,适合高校学生完成三级项目、课程设计或实验报告时参考。资源内含客户端与服务器两个独立工程,以C++语言编写,覆盖传输控制协议的连接建立、数据分片与重组、滑动窗口流量控制、拥塞控制、确认重传及连接释放等关键机制。压缩包共60个文件,主要包含源文件、头文件、解决方案配置、可执行程序、调试符号以及编译中间文件等类型,整体压缩后约27.88MB,解压后可在Visual Studio环境中直接打开运行和调试。目前已有1354人学习下载。读者既能对照套接字编程接口梳理数据包的收发流程与状态转换,又能借助两套工程模拟网络环境,结合慢启动、快速重传等算法观察协议行为,从而加深对传输控制协议细节的理解,顺利完成实验环节。
1. 某高校三级项目实战:手搓TCP数据包,最难的不是握手而是校验和
如果你以为计算机网络三级项目就是调包,那这个项目会教你重新做人。它要求用原始套接字直接构造TCP数据包,三次握手、滑动窗口、超时重传全部自己实现。我拆这份源码时最意外的是,整个项目最不省心的不是状态机,而是校验和——一个字节算错,对端就回一个RST把你踢开。它适合两类人:正在做网络课设的学生,以及想验证自己是否真的看懂RFC 793的开发者。跑通一遍之后,你会觉得TCP协议不再是黑匣子,而是一张可以随时拆开检修的字节表。
2. 整体设计:技术选型与目录结构,先看清数据包要过哪些关卡
2.1 为什么选Python而不是C:位运算交给struct,逻辑留给协议
看到这个项目的第一眼,我原以为是用C语言写的。毕竟在Linux下用C写原始套接字是传统做法,很多TCP/IP协议实验都基于C。但这份源码用了Python,而且跑起来效果并不差。核心原因在于:课程设计的重点不是追求吞吐,而是把协议字段解释清楚。Python的struct库天生适合做字节级打包,你不用关心结构体对齐和内存布局,只需要按RFC规定的顺序填好字段,一行pack就能得到严格的网络字节序。
我比较过两种方案的成本。用C实现,光是处理TCP头的位域就要定义一堆结构体,还要考虑编译器的字节对齐,稍微不留神就会在二进制层面留下空洞;而Python写struct.pack('!HHIIHHHH', ...)时,格式串里的每个字母都明确对应一个字段,哪里错了看一眼就能发现。加上Python的整数是任意精度,校验和累加时不用考虑溢出,进位回卷直接用位运算处理,非常顺手。当然,如果将来要换成C,核心逻辑也可以直接平移,因为字段布局是完全一致的。
还有一个原因是原始套接字的权限问题。在Linux下,创建SOCK_RAW需要root,Python脚本在sudo下执行没有任何障碍。如果用C,你还得写Makefile和错误处理,对一个三四千行的小项目来说有点杀鸡用牛刀。所以项目的选型思路很清晰:用Python把协议逻辑讲透,把底层细节交给系统调用和struct,这是这个场景下最合理的取舍。
当然,这个选择也带来一个坏处:Python的性能不足以做高速率的拥塞控制实验。但课设评委最关心的是你理解了每个字段的含义,而不是你每秒能传多少MB。如果你以后要接手类似的项目并做性能专项,我建议把发送端重写成C或者Rust,Python保留做测试工具和校验模块,这样两边都能兼顾。
2.2 目录结构与模块职责:一个文件只干一件事
项目源码压缩包解开后,目录结构是这样的:
tcp_project/ ├── config.py # 端口、IP、MTU、收发缓冲等全局参数 ├── checksum.py # TCP校验和与伪头部计算 ├── tcp_header.py # TCP/ IP头部构造与解析 ├── server.py # 服务端:监听、握手、接收、写文件 ├── client.py # 客户端:发起连接、发送文件数据 ├── test_connect.py # 自动化握手测试脚本 └── README.md # 运行说明与抓包指引我拿到源码后的第一件事是看config.py,因为所有可变参数都集中在这里,改起来不用全局搜索。config.py里定义了HOST、PORT、MSS、RTO、WINDOW等常量。其中HOST默认是本机IP,PORT是1000,MSS是1460,RTO是2000毫秒。这些值分别对应TCP最大段大小和超时重传时间,是实验中需要反复调整的参数。
checksum.py是一个纯函数模块,对外提供tcp_checksum(src_ip, dst_ip, tcp_segment)接口,输入源IP、目的IP和TCP段字节流,返回16位校验和。tcp_header.py封装了build_tcp_header和parse_tcp_header两个函数,一个负责把Python字段变成字节流,一个负责反向解析。这样设计的好处是,client.py和server.py不需要知道TCP头内部格式,只需要调用这两个函数,当需要修改TCP选项字段时,只改一个文件就够了。
client.py和server.py是主入口。client.py负责构造SYN、处理ACK、分片发送文件数据、超时重传、四个阶段状态流转;server.py负责校验入包、回复SYN+ACK、接收数据、重组文件。test_connect.py是一个自动化脚本,循环执行握手和断开,用于跑回归测试;我做二次开发时经常用它验证新逻辑不会破坏基本连接。
每个文件的职责可以总结为下表:
| 文件 | 角色 | 对外暴露的接口 | 主要关注点 |
|---|---|---|---|
| config.py | 参数中心 | 全局常量 | 改参数不跑全项目 |
| checksum.py | 纯计算 | tcp_checksum | 伪头部、回卷累加 |
| tcp_header.py | 字节转换 | build/parse | 字段顺序、标志位 |
| client.py | 主动方 | main | 状态机、超时计时 |
| server.py | 被动方 | main | 合法性校验、重组 |
| test_connect.py | 自动化 | 无 | 回归验证 |
这种文件划分不是拍脑袋想出来的。我当时踩过一个坑:把校验和算法直接写在client.py里,后来要加一个UDP的平行实验,结果只能复制粘贴,改一处忘了另一处。从那以后我养成了习惯:所有协议相关的计算都放到独立模块,让socket操作只做收发,让协议逻辑只做转化。
2.3 一次完整传输的时序:从SYN到挥手之间发生了什么
以客户端向服务端发送一段文件内容为例,完整流程分四个阶段。第一阶段是三次握手,客户端先构造一个SYN包,seq是随机生成的初始值x;服务端收到后校验通过,回一个SYN+ACK,它的seq是随机值y,ack是x+1;客户端收到后回复ACK,ack是y+1,至此连接建立。第二个阶段是数据传输,客户端把文件切成多个不超过MSS的片段,每个片段加上TCP头部,设置PSH+ACK标志,按递增的seq发送;服务端每收到一个合法包就回一个ACK,确认号是已接收数据最后一个字节的seq加长度。第三阶段是主动关闭前的收尾,客户端发FIN,服务端ACK后也发FIN,客户端再ACK,然后进入TIME_WAIT。
这里最值得注意的就是seq和ack的计算。很多第一次手写TCP的人会把ack直接写成本端seq加1,而实际上ack的含义是“我希望收到的下一个字节的序号”,所以服务端收到seq=x的SYN后,回复的ack必须是x+1,表示“我已经收下了x,请从x+1开始发”。这个细节我在调试时被绊了两次,一开始写成了ack=x,结果客户端一直在等同一个包,最后超时。
另一个容易忽略的点是TCP头部里的数据偏移字段。数据偏移指示TCP头的长度,单位是4字节,没有选项字段时固定为5,也就是20字节。如果这个值写成0,接收方解析时会直接把头部当成载荷,整个包就废了。源码里在构建头部时使用了(5 << 12) | flags,这个左移12位是必需的操作,不能省。
为了直观,我把这个项目在Wireshark里的抓包行为整理成一个时序表:
| 阶段 | 方向 | 标志 | seq | ack |
|---|---|---|---|---|
| 握手 | C→S | SYN | x | 0 |
| 握手 | S→C | SYN+ACK | y | x+1 |
| 握手 | C→S | ACK | x+1 | y+1 |
| 发送 | C→S | PSH+ACK | x+1 | y+1 |
| 确认 | S→C | ACK | y+1 | x+1+len |
| 关闭 | C→S | FIN+ACK | x+1+len | y+1 |
| 关闭 | S→C | FIN+ACK | y+1 | x+1+len |
| 关闭 | C→S | ACK | x+1+len | y+2 |
这个表也是我跑完一次客户端后,对照抓包结果手写的。你会发现TCP的可靠传输并不神秘,它只是用一组序号和确认号,把“我已经收到某个字节之前的所有数据”这件事说清楚。理解了这张表,后面看代码就顺了。
3. 核心模块拆解:从构造SYN到关闭连接,把位运算一步步讲清楚
3.1 TCP头部构造与校验和:一个字节都不能错
先看tcp_header.py里构建TCP固定头的函数。TCP固定头长度是20字节,包含源端口、目的端口、序号、确认号、数据偏移+保留+标志、窗口、校验和、紧急指针。代码用struct.pack一次打包:
import struct def build_tcp_header(src_port, dst_port, seq, ack, flags, window=65535, checksum=0): data_offset = 5 # 5*4=20字节 offset_flags = (data_offset << 12) | flags return struct.pack('!HHIIHHHH', src_port, dst_port, seq, ack, offset_flags, window, checksum, 0)这里struct格式串里的感叹号代表网络字节序。TCP规范规定多字节字段在大端序下传输,比如端口号0x1F90在字节流中先出现0x1F再出现0x90。如果你用本机的小端序打包,Wireshark解析出来的端口就会错乱。H表示两字节无符号整数,I表示四字节无符号整数。字段顺序从左到右依次是源端口、目的端口、序号、确认号、offset_flags合成字段、窗口、校验和、紧急指针。
参数说明:src_port和dst_port取值范围0到65535;seq和ack是要发送的32位序列号与确认号;flags是标志位组合,SYN是0x02,ACK是0x10,PSH是0x08,FIN是0x01,可以通过按位或合成多个标志。window是接收窗口通告,表示本端还能接收多少字节,实验里固定为65535。checksum初始传0,调用方随后会填充实际值,因为校验和本身覆盖整个TCP头,计算时必须先把该字段置零。
我把这个函数用在了所有发送场景中。实现时最需要注意的是offset_flags这一行:TCP头部长度占高4位,必须左移12位才能和低8位的flags合并。我见过有人直接写data_offset + flags,得到完全错位的字节。还有一个小坑:struct.pack的格式串中,HHIIHHHH是4个H、2个I、4个H,共10个字段,恰好20字节。写少一个字段就会导致长度不对,接收方会找不到端口。
再看checksum.py里校验和的实现。TCP校验和覆盖范围包括伪头部和整个TCP报文段。伪头部在逻辑上属于TCP校验的一部分,但不进入网络传输,它是为了把IP层的源地址、目的地址、协议号和TCP长度绑定进来,防止报文被路由到错误的主机:
import socket def tcp_checksum(src_ip, dst_ip, tcp_segment): pseudo = struct.pack('!4s4sBBH', socket.inet_aton(src_ip), socket.inet_aton(dst_ip), 0, 6, len(tcp_segment)) data = pseudo + tcp_segment if len(data) % 2: data += b'\x00' total = 0 for i in range(0, len(data), 2): word = (data[i] << 8) + data[i+1] total += word total = (total & 0xffff) + (total >> 16) return (~total) & 0xffff伪头部的格式是:源IP地址4字节、目的IP地址4字节、保留1字节(必须为0)、协议号1字节(TCP是6)、TCP段总长度2字节。这里用socket.inet_aton把点分十进制IP转成二进制字节,协议号6是硬编码。接着把伪头部和原始TCP段拼接成一个新字节串,如果总长度是奇数,就补一个0字节,因为校验和算法要求按16位一组处理。
算法的核心是累加和循环。每次取出16位作为一个word,把word加到total中,如果total超过了16位,就把高位进位回卷到低位,这个回卷动作等价于将进位相加。全部累加完后,对total取反码,也就是按位取反,得到16位校验和。发送方和接收方做同样的计算,接收方如果算出的校验和不是0,说明包在传输中被篡改或本地计算错误。
这个算法我建议你单独写一个测试用例:构造一个标准的TCP报文,先用本项目算一遍,再用scapy算一遍,两个结果应该完全一致。当时我为了验证算法,抓了两个真实TCP包做对比,发现只有加了伪头部才算得对。很多网上流传的错误代码是直接把tcp_segment拿去做累加,少了伪头部,看起来也能运行,但发出的包会被对端内核丢弃。
3.2 IP头构造:先有IP头,TCP包才能出门
原始套接字发送时,数据链路层会自己填充MAC地址,但IP头需要你手工构造。IP头也有20字节固定部分,包括版本/头长、服务类型、总长度、标识、分片标志、TTL、协议、头校验和、源IP、目的IP。代码里用一个对称的函数构造它:
def build_ip_header(src_ip, dst_ip, tcp_size): version_ihl = (4 << 4) | 5 total_len = 20 + tcp_size ip_header = struct.pack('!BBHHHBBH4s4s', version_ihl, 0, total_len, 0x1234, 0x4000, 64, 6, 0, socket.inet_aton(src_ip), socket.inet_aton(dst_ip)) # 计算IP头校验和并回填 ip_checksum = compute_ip_checksum(ip_header) return ip_header[:10] + struct.pack('!H', ip_checksum) + ip_header[12:]第一字段version_ihl是高4位版本号4加上低4位首部长度5,代表IPv4且无选项。总长度是IP头20字节加上TCP头20字节再加上应用数据。标识0x1234是随意设置的,用于重组分片;0x4000是DF标志,表示禁止分片,如果TCP段超过链路MTU就会被丢弃,所以MSS必须设小。TTL设为64,协议写6。计算IP头校验和时,先把该校验和字段置0,然后按16位累加并取反码。
这里有一个实验技巧:如果你把DF标志去掉,并把MSS设为3000,而虚拟机网卡MTU是1500,那么TCP数据会被IP层分片,你会在Wireshark里看到同一个seq被分成了两个IP分片包。这能演示IP分片,但不是本文重点,课设答辩时可以作为扩展话题。
3.3 三次握手的代码实现与状态机:服务端和客户端各自的视角
现在把握手流程串起来。服务端的核心代码在server.py的tcp_listen函数中:
# server.py 片段 raw_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_TCP) raw_sock.bind((HOST, 0)) while True: packet, addr = raw_sock.recvfrom(65535) tcp_header = packet[20:40] src_port = struct.unpack('!H', tcp_header[0:2])[0] seq = struct.unpack('!I', tcp_header[4:8])[0] offset_flags = struct.unpack('!H', tcp_header[12:14])[0] flags = offset_flags & 0x00ff if flags & 0x02: # SYN my_seq = random.randint(0, 2**32 - 1) syn_ack = build_tcp_header(DST_PORT, src_port, my_seq, seq + 1, 0x12) ip_packet = build_ip_header(HOST, addr[0], syn_ack) raw_sock.sendto(ip_packet, (HOST, src_port)) breakrecvfrom返回的packet是完整的IP包。在Linux原始套接字场景下,前20字节是IP头,紧接着是TCP头,所以我们直接切片[20:40]来读取TCP固定头。flags是从offset_flags低8位提取的,这里用0x00ff掩码。判断SYN标志后,生成服务端初始序号my_seq,构造SYN+ACK,然后发送。
注意sendto的第二个参数是元组,这里我传的是(HOST, src_port)。src_port是从收到的SYN包里解析出来的对端端口,但原始套接字的sendto不会真正参与路由,它只是给内核一个参考。我最初在这里传了固定的1000,导致服务端回包时Wireshark显示源端口跟目标端口一样,数据包被客户端直接无视。正确做法是从入包提取发起方端口,保证回包的四元组对称。
客户端的发送逻辑更直接,它先构造一个SYN包,然后用recvfrom等待服务端的响应。这里不能用一个普通的超时循环,因为原始套接字可能收到干扰包,比如本机其他程序发起的TCP连接,所以需要过滤响应是否属于自己的对端:
# client.py 片段 seq_init = random.randint(0, 2**32 - 1) syn = build_tcp_header(SRC_PORT, DST_PORT, seq_init, 0, 0x02) ip_packet = build_ip_header(SRC_IP, DST_IP, syn) raw_sock.sendto(ip_packet, (DST_IP, DST_PORT)) deadline = time.time() + 3 while time.time() < deadline: resp, addr = raw_sock.recvfrom(65535) tcp_header = resp[20:40] flags = struct.unpack('!H', tcp_header[12:14])[0] & 0x00ff ack_num = struct.unpack('!I', tcp_header[8:12])[0] if flags & 0x10 and ack_num == seq_init + 1: print('handshake success') break else: print('handshake timeout')这个while-else结构里,else只在while循环正常走完没有被break时执行,也就是超时的情况下执行。这是Python的惯用法,比标志位干净。这里关键的条件是ack_num == seq_init + 1,它确保收到的ACK是针对我们这次SYN的,而不是别的僵尸包。这样即使混杂模式下有其他程序的TCP包,也不会误判握手成功。
从状态机视角看,这个实验项目只用到了四个状态:CLOSED、SYN_SENT、ESTABLISHED、FIN_WAIT_1。服务端没有实现真正的LISTEN队列,因为原始套接字不遵循端口的监听语义,它把收到的所有TCP包都交给应用层评判。这是原始套接字和普通socket最大的差别:普通socket的内核协议栈会自动完成处理并过滤掉不属于本应用的数据包,而原始套接字把一切都摊在你面前,需要自己判断协议是否合法。
3.4 数据发送与滑动窗口:用超时重传保住可靠性
数据传输阶段,client.py的send_data实现了一个带超时重传的停等发送。我先把关键代码贴出来,然后解释如何把它扩展成真正的滑动窗口:
# client.py 发送循环(简化版) seq_current = seq_init + 1 window_size = 1 # 滑动窗口大小,默认1表示停等 sent_but_unacked = 0 while data_offset < len(file_data): if sent_but_unacked < window_size: chunk = file_data[data_offset:data_offset + MSS] packet = build_tcp_header(SRC_PORT, DST_PORT, seq_current, 0, 0x18) # PSH+ACK ip_packet = build_ip_header(SRC_IP, DST_IP, packet + chunk) raw_sock.sendto(ip_packet, (DST_IP, DST_PORT)) sent_but_unacked += 1 data_offset += len(chunk) seq_current += len(chunk) start_time = time.time() else: while time.time() - start_time < RTO: resp, _ = raw_sock.recvfrom(65535) tcp_header = resp[20:40] ack_num = struct.unpack('!I', tcp_header[8:12])[0] if ack_num == seq_current: sent_but_unacked -= 1 break else: print('timeout, retransmit from', seq_current) data_offset = seq_start[seq_current] # 回退偏移这个代码把window_size设为1时就是停等协议:发一个包等一个ACK,收到后再发下一个。如果把window_size改为4,就变成了可以连续发4个包再统一等ACK的滑动窗口。seq_current始终指向下一个要发送的字节序号,sent_but_unacked记录当前在途的包数,只有这个值小于window_size时才允许继续发送。这种设计把发送窗口和接收窗口解耦了,你可以在config.py里分别修改它们来观察吞吐变化。
这里要特别小心确认号的判断。停等协议中,我们期望的ack_num是seq_current,也就是下一个待发的序号。但由于ip_packet在发送前已经拼好了完整数据,所以seq_current在发出第一个包后就会增加,后面的if判断要用“发送前保存的”那个seq,而不是已经累加过的值。我最初的代码里直接在循环中比较ack_num == seq_current,结果每次都差一个MSS,造成无限重传。正确做法是为每个在途包保存一个序列号列表。
完整的滑动窗口还需要为每个在途包保存发送时间,遇到超时时只重发超时的包,而不是回退整个窗口。这个项目为了方便演示采用了最简单的回退N步,并且超时后重发从当前data_offset开始的所有包。如果你要做二次开发,建议把在途包改成字典,键是seq,值是数据和发送时间,每次只重发那些超时的包。
重传超时时间RTO在config.py里默认是2000毫秒。调这个值的时候要小心:在本地回环网卡上RTT通常小于1毫秒,把RTO设成100毫秒没有问题;但在虚拟机桥接网络、物理网卡又很忙的情况下,RTT可能飙升到几百毫秒,过低RTO会导致大量伪重传,反而让吞吐下降。我的经验是先用2000毫秒保证流程正确,再逐步降到200毫秒测试边界。
3.5 接收端重组:把乱序的数据包还原成文件
服务端接收数据时,不能像普通socket那样让内核把字节流整理好,因为原始套接字收到的包可能乱序甚至重复。所以server.py里维护了一个接收缓冲区,用字典保存seq到数据块的映射,等收到连续段时再拼装写入文件:
# server.py 重组逻辑 recv_buffer = {} # seq -> data expected_seq = server_initial_seq + 1 final_data = b'' def on_data_packet(packet): global expected_seq tcp_header = packet[20:40] seq = struct.unpack('!I', tcp_header[4:8])[0] payload = packet[40:] # 假设无TCP选项 if seq == expected_seq: final_data += payload expected_seq += len(payload) for s in sorted(recv_buffer.keys()): if s == expected_seq: final_data += recv_buffer[s] expected_seq += len(recv_buffer[s]) del recv_buffer[s] else: break else: recv_buffer[seq] = payload send_ack(expected_seq)这个逻辑的核心是expected_seq变量,它记录“接下来期望收到的字节序号”。每收到一个连续的数据段,就把数据接到final_data后面,并尝试把缓冲区里保存的乱序数据也拼进来。如果收到的数据段序号比期望值大,说明中间有包丢失,先存入字典等待重传。注意发送ACK时,确认号可以直接用expected_seq,即使有乱序包,这个ACK也向对方宣告了连续字节流的接收进度。
实际上这个实现还不能处理重复包。如果对方重传了一个已经在final_data里的段,它的seq小于expected_seq,代码会把它当成乱序包放入字典,可能出现重复数据。解决的常见做法是记录已接收的数据段范围,对重叠部分进行裁剪。这个项目作为课设做演示已经足够,但如果你要挑战自己,可以再补一个合并区间函数。
4. 运行与验证:让项目在本地跑起来,抓包确认每个标志位
4.1 环境准备:为什么建议用Linux虚拟机和root权限
原始套接字这个特性决定了项目的运行环境不能太随意。在Linux上,只有root才能调用socket(AF_INET, SOCK_RAW, IPPROTO_TCP),因为原始套接字能伪造任何协议头和源IP,如果放开给普通用户,网络攻击就毫无门槛了。所以第一步就是确认你的操作系统和账号权限。
我建议用Ubuntu 22.04或20.04的虚拟机,网络模式选NAT或桥接都可以。如果你选桥接,宿主机和虚拟机的IP会在同一个网段,Wireshark可以直接抓宿主机的物理网卡看到虚拟机的流量,比较方便。如果你选NAT,流量会经过宿主机的虚拟网卡,抓包时要在宿主机上抓vboxnet的接口。个人经验是桥接模式更省心,但要注意DHCP分配的IP会变化,最好在虚拟机的网络设置里指定固定IP。
启动项目前,还需要检查网卡是否开启了校验和卸载。很多现代网卡和虚拟网卡为了性能,会在硬件层自动计算TCP校验和,把上层协议栈算好的结果覆盖掉。这样一来,你程序里即使校验和算错了,也可能因为网卡硬件帮忙计算而侥幸通过;但Wireshark抓包时显示的checksum会变成0或者offload,反而掩盖了真实问题。可以用ethtool命令检查:
ethtool -k eth0 | grep checksum如果看到tx-checksum-ipv4: on,就执行sudo ethtool -K eth0 tx off关掉它。我这里把eth0换成你实际网卡名称,可以用ifconfig命令查。关掉之后,内核协议栈就完全信任你手工填入的TCP校验和,Wireshark才能如实展示每个字段的值。
还需要确认系统里有宏包解析工具和自动测试脚本的依赖。这个项目只用了标准库,所以不需要pip安装任何东西。如果你的系统是最小安装,可能缺少root权限的shell环境,执行sudo -i可以切换到真正的root shell,避免sudo下权限仍未完全放开的问题。
4.2 启动服务端与客户端:三行命令跑通一次传输
假设项目已经在虚拟机里解压,且当前用户有sudo权限。打开两个终端,第一个终端启动服务端,它需要绑定一个IP地址,这个地址是服务端的本机地址:
sudo python3 server.py 192.168.1.100启动成功后,服务端会打印“Server listening on 192.168.1.100”。如果这里的IP不是本机地址,后面的客户端就找不到它。第二个终端启动客户端,传入服务端IP、端口和一个待发送文件路径:
sudo python3 client.py 192.168.1.100 1000 --file doc.txt端口1000是config.py里的默认值,如果服务端启动时没指定端口,客户端也不需要传。文件路径可以传任意文本文件,我建议用一个内容小于10KB的小文件,方便对比字节数和传输日志。客户端运行后,正常输出类似这样:
[client] send SYN, seq=2101234567 [client] receive SYN+ACK, ack=2101234568 [client] send ACK, ack=... [client] send data 1460 bytes, seq=... [client] send data 590 bytes, seq=... [client] transfer complete, 2050 bytes in 2 packets看到transfer complete说明文件完整到达。接着去服务端终端,会看到类似“receiving 2050 bytes”的日志,并且在服务端当前目录下多出doc_copy.txt,用md5sum对比两个文件的哈希值,如果一致就说明TCP传输的可靠性逻辑没有丢数据:
md5sum doc.txt doc_copy.txt如果哈希不一致,优先怀疑第3.5节的重组逻辑,在final_data里多打几个打印,确认每个数据段的seq是否连续。
4.3 用Wireshark验证:抓包比对seq和ack
跑通一次命令行传输后,我建议打开Wireshark,重新跑一次客户端,把整个握手和传输过程抓下来。这一步不仅是为了验证自己的代码,更是课设答辩时最有说服力的证据。Wireshark的安装可以用sudo apt install wireshark,安装时选择允许非root用户抓包。
打开Wireshark后,选择抓包的网卡。如果是桥接模式,选物理网卡;如果是NAT模式,选vboxnet0之类的虚拟网卡。然后在上方过滤栏输入tcp.port==1000,回到终端重新执行客户端命令。抓包结果会按时间顺序显示四类数据包:SYN、SYN+ACK、ACK、PSH+ACK、FIN。
我建议你把过滤条件进一步精确为tcp.stream eq 0,只看第一条连接。以第一个SYN包为例,展开TCP协议树,可以看到Sequence Number是一个随机值,Flags字段里SYN被勾选,Source Port是client.py里设置的SRC_PORT。对比服务端回包SYN+ACK,它的Acknowledge Number就是刚才SYN的Sequence Number加1,这个1代表SYN标志本身消费了一个序列号。如果这个数字对不上,说明你的ack计算有误,连接会在第三次握手时被重置。
传输数据的包会带有PSH标志,表示这是应用层主动推送的数据。选中一个数据包,在TCP协议树里查看Segment Length,它是这个TCP段的载荷长度,应该等于代码中配置的MSS或最后一个不满MSS的剩余长度。还可以检查Checksum字段,如果Wireshark显示“Checksum: 0xXXXX [correct]”,说明你的校验和算法完全正确;如果显示“incorrect”,说明算法或回填时机有误,需要立即检查。
我遇到过一种很恼火的情况:抓包窗口里既能看到SYN,也能看到SYN+ACK,但第三次ACK发出去之后,连接就一直没有数据传输。后来发现是因为服务端在第三次握手后,没有把客户端IP和端口记下来,后续数据包发回时用了错误的源端口。这种问题在抓包上表现为:客户端的数据包去向正常,服务端的ACK包却发到了另一个端口,两个TCP流错开。解决办法就是在收到SYN时把对端四元组保存成连接状态,之后的每个包都从这里取地址。
5. 常见问题排查:三次握手不成功,先按这五个环节排查
5.1 校验和错误导致对端立刻回RST
现象:客户端发送SYN后没有收到SYN+ACK,反而在日志里看到“receive RST”,或者Wireshark过滤tcp.port==1000时看到一条红色标记的RST包。原因:服务端内核栈对收到的TCP包做了合法性验证,最核心的一项就是校验和。如果校验和错误,内核会认为包在传输中被破坏,直接丢弃;更糟的是,如果这个包正好占用了某个已建立的五元组,内核还可能回一个RST来重置连接。这里最常见的原因是伪头部没写对,或者协议号写成了0。解决:检查checksum.py的pseudo部分,确认第三字节是0,第四字节是6;再确认len(tcp_segment)是TCP段总长度,既包含头部也包含数据。如果还不放心,在发送前把计算好的校验和打印出来,与Wireshark上抓到的checksum对比,就能定位是哪一步错了。
5.2 连续测试时第二次握手就超时
现象:第一次运行客户端成功,第二次再运行同一脚本,客户端一直打印“handshake timeout”,服务端却显示收到了SYN。原因:大多数手写TCP的实现为了简单,把初始seq写死成0或一个固定值。前后两次连接使用相同五元组和相同初始序号时,内核协议栈会认为新连接请求实际上是旧连接的重复报文,直接丢弃。解决:把seq_init改成random.randint(0, 2**32 - 1),并且每次运行时重新生成,不能放在模块顶部初始化一次。另外,连接关闭后要等待TIME_WAIT超时,测试时建议间隔几秒再跑,或者换一个端口。
5.3 创建原始套接字时抛出Operation not permitted
现象:执行sudo python3 client.py后,Python解释器在socket那一行直接抛出PermissionError,错误信息是“Operation not permitted”。原因:在Linux中,SOCK_RAW只有CAP_NET_RAW权限的用户才能创建,某些发行版即使你sudo,如果系统有SELinux或者容器限制,也可能被拒。另一个常见情况是虚拟机共享文件夹的挂载参数限制了可执行文件的权限,虽然不会直接导致PermissionError,但会引发奇怪的分段错误。解决:先执行sudo -i拿到完整root的shell,再运行项目;确认项目文件在/home目录下而不是在/mnt/hgfs共享目录。如果还不行,检查cat /proc/self/status里CapEff字段是否包含0x0000000000001000,没有的话说明当前shell的capability被削减了。
5.4 Wireshark抓不到本机回环流量
现象:在虚拟机本机同时启动服务端和客户端,用Wireshark抓包,过滤器用tcp.port==1000,结果一条包都没有。原因:原始套接字和普通socket走的是同一条网络栈路径,但回环接口(lo)有点特殊,它没有真正的网卡驱动,很多抓包工具默认忽略lo接口。另一个可能,你的程序用原始套接字收发,内核协议栈也会同时处理一份流量,这就造成了双份包,Wireshark可能只显示了其中一份。解决:先看Wireshark左下角是不是选择了lo接口,如果没有就改选Loopback。把服务端和客户端的IP都改成127.0.0.1,并在两端关闭网卡校验和卸载。我的血泪经验是:即便lo接口显示有包,也可能因为IP头校验和字段是手工填充而无法被tcpdump正确解析,所以优先用服务端和客户端IP为不同网卡地址的方式来测试。
5.5 数据传完了但文件内容缺字节
现象:客户端显示传输完成,服务端也打印接收字节数正确,但md5sum比对不一致,缺了最后几个字节。原因:原始套接字发送时,IP总长度字段如果写错,接收方可能截断或忽略最后一部分。另一个常见原因是FIN包和最后一个数据包发生了竞争,发送端发完数据立刻发FIN,服务端在重组缓冲区还没flush时就把连接关闭了。解决:在发送完最后一个数据段后,主动等待对端ACK并确保seq全部确认,再发送FIN;同时服务端收到FIN后不要立即退出,先处理完重组缓冲区中seq连续的数据。我建议在client.py里加一个“flush”循环,持续接收ACK直到ack_num等于最终seq。
6. 二次开发:给手写TCP加上慢启动,让它从演示变成课程设计加分项
6.1 用三个变量把固定窗口改成动态窗口
当前的发送代码只有window_size一个固定值,这相当于TCP的现代实现里最原始的停等窗口。要让项目看起来像一个真正的TCP实现,最值得做的是把发送窗口改成动态的拥塞窗口。你不需要改变TCP包头结构,只需要在client.py里加三个状态变量:cwnd(当前拥塞窗口,单位字节)、ssthresh(慢启动门限,初始值比如65535)和inflight(当前在途但未确认的字节数)。
发送逻辑改成:只要inflight小于cwnd就允许发送下一个段,每收到一个ACK就减少inflight,并更新cwnd。更新规则是:当cwnd < ssthresh时,每经过一个RTT就把cwnd翻倍,这叫慢启动;当cwnd >= ssthresh时,每经过一个RTT让cwnd增加1个MSS,这叫拥塞避免。用代码表示就是:
# client.py 拥塞控制状态更新 if acked_bytes < ssthresh: cwnd += MSS # 慢启动:一个RTT翻倍 else: cwnd += MSS * MSS // cwnd # 拥塞避免:线性增长 inflight -= MSS注意这里的MSS * MSS // cwnd在实际中通常会小于1,所以要用浮点或累积分数。简化做法是每收到一个ACK就让cwnd增加MSS * MSS // cwnd,一旦累计超过MSS就发送新包。验证方法是观察日志中已发送字节号的增长速度,慢启动阶段是指数增长,拥塞避免阶段变成线性,两者分界点就是ssthresh。
6.2 加一个丢包计数器,验证快速重传
拥塞控制改完,最好配套一个丢包模拟机制,否则你看不出效果。在server.py的on_data_packet函数里加一个静态计数器,比如每接收20个包就丢弃1个,代码如下:
# server.py 模拟丢包 DROP_EVERY = 20 packet_count = 0 def on_data_packet(packet): global packet_count packet_count += 1 if packet_count % DROP_EVERY == 0: return # 假装没收到这个包 # ...原有重组逻辑这样客户端会因为超时而重传,你就能在日志里看到“timeout, retransmit”字样。如果配合快速重传逻辑,即在连续收到三个重复ACK时就立即重传第一个未确认段,日志会显示“fast retransmit”而不是等待超时。把RTO调大,比如3000毫秒,可以明显看到快速重传比超时重传更快。这是TCP性能分析里最直观的一个演示,也是答辩时讲得最多的加分点。
如果你也在做类似的课设,不妨下载这份源码,对照Wireshark把校验和和seq/ack的变化一步步核出来——这个过程比你看十篇博客都管用。从那以后,我每次改完协议字段,都会强制走一遍先抓包、再发包、最后看校验和的三步流程,这个习惯帮我避免了无数个深夜debug的循环。希望帮到你。
本文还有配套的精品资源,点击获取