手搓TCP数据包:Python实现三次握手与校验和

📅 发布时间:2026/10/21 8:25:28
手搓TCP数据包:Python实现三次握手与校验和
简介燕山大学计算机网络三级项目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需要rootPython脚本在sudo下执行没有任何障碍。如果用C你还得写Makefile和错误处理对一个三四千行的小项目来说有点杀鸡用牛刀。所以项目的选型思路很清晰用Python把协议逻辑讲透把底层细节交给系统调用和struct这是这个场景下最合理的取舍。当然这个选择也带来一个坏处Python的性能不足以做高速率的拥塞控制实验。但课设评委最关心的是你理解了每个字段的含义而不是你每秒能传多少MB。如果你以后要接手类似的项目并做性能专项我建议把发送端重写成C或者RustPython保留做测试工具和校验模块这样两边都能兼顾。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默认是本机IPPORT是1000MSS是1460RTO是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负责校验入包、回复SYNACK、接收数据、重组文件。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服务端收到后校验通过回一个SYNACK它的seq是随机值yack是x1客户端收到后回复ACKack是y1至此连接建立。第二个阶段是数据传输客户端把文件切成多个不超过MSS的片段每个片段加上TCP头部设置PSHACK标志按递增的seq发送服务端每收到一个合法包就回一个ACK确认号是已接收数据最后一个字节的seq加长度。第三阶段是主动关闭前的收尾客户端发FIN服务端ACK后也发FIN客户端再ACK然后进入TIME_WAIT。这里最值得注意的就是seq和ack的计算。很多第一次手写TCP的人会把ack直接写成本端seq加1而实际上ack的含义是“我希望收到的下一个字节的序号”所以服务端收到seqx的SYN后回复的ack必须是x1表示“我已经收下了x请从x1开始发”。这个细节我在调试时被绊了两次一开始写成了ackx结果客户端一直在等同一个包最后超时。另一个容易忽略的点是TCP头部里的数据偏移字段。数据偏移指示TCP头的长度单位是4字节没有选项字段时固定为5也就是20字节。如果这个值写成0接收方解析时会直接把头部当成载荷整个包就废了。源码里在构建头部时使用了(5 12) | flags这个左移12位是必需的操作不能省。为了直观我把这个项目在Wireshark里的抓包行为整理成一个时序表阶段方向标志seqack握手C→SSYNx0握手S→CSYNACKyx1握手C→SACKx1y1发送C→SPSHACKx1y1确认S→CACKy1x1len关闭C→SFINACKx1leny1关闭S→CFINACKy1x1len关闭C→SACKx1leny2这个表也是我跑完一次客户端后对照抓包结果手写的。你会发现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, window65535, checksum0): data_offset 5 # 5*420字节 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到65535seq和ack是要发送的32位序列号与确认号flags是标志位组合SYN是0x02ACK是0x10PSH是0x08FIN是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[i1] 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构造SYNACK然后发送。注意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) # PSHACK 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, seq2101234567 [client] receive SYNACK, ack2101234568 [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.port1000回到终端重新执行客户端命令。抓包结果会按时间顺序显示四类数据包SYN、SYNACK、ACK、PSHACK、FIN。我建议你把过滤条件进一步精确为tcp.stream eq 0只看第一条连接。以第一个SYN包为例展开TCP协议树可以看到Sequence Number是一个随机值Flags字段里SYN被勾选Source Port是client.py里设置的SRC_PORT。对比服务端回包SYNACK它的Acknowledge Number就是刚才SYN的Sequence Number加1这个1代表SYN标志本身消费了一个序列号。如果这个数字对不上说明你的ack计算有误连接会在第三次握手时被重置。传输数据的包会带有PSH标志表示这是应用层主动推送的数据。选中一个数据包在TCP协议树里查看Segment Length它是这个TCP段的载荷长度应该等于代码中配置的MSS或最后一个不满MSS的剩余长度。还可以检查Checksum字段如果Wireshark显示“Checksum: 0xXXXX [correct]”说明你的校验和算法完全正确如果显示“incorrect”说明算法或回填时机有误需要立即检查。我遇到过一种很恼火的情况抓包窗口里既能看到SYN也能看到SYNACK但第三次ACK发出去之后连接就一直没有数据传输。后来发现是因为服务端在第三次握手后没有把客户端IP和端口记下来后续数据包发回时用了错误的源端口。这种问题在抓包上表现为客户端的数据包去向正常服务端的ACK包却发到了另一个端口两个TCP流错开。解决办法就是在收到SYN时把对端四元组保存成连接状态之后的每个包都从这里取地址。5. 常见问题排查三次握手不成功先按这五个环节排查5.1 校验和错误导致对端立刻回RST现象客户端发送SYN后没有收到SYNACK反而在日志里看到“receive RST”或者Wireshark过滤tcp.port1000时看到一条红色标记的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.port1000结果一条包都没有。原因原始套接字和普通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的循环。希望帮到你。本文还有配套的精品资源点击获取