手写TCP协议栈:从三次握手到校验和的原始套接字实现

发布时间:2026/10/29 11:18:47
手写TCP协议栈:从三次握手到校验和的原始套接字实现 简介本资源是燕山大学计算机网络课程三级实践项目——TCP传输数据包的完整实现工程面向高校网络协议学习者与网络编程初学者聚焦TCP可靠传输核心机制的理解与编码落地。项目涵盖三次握手建连、数据分段与序号管理、滑动窗口流量控制、拥塞控制算法如慢启动、ACK确认与重传、四次挥手断连等关键知识点通过C语言结合Win32 Socket编程真实复现协议行为。压缩包共60个文件含4个核心cpp源码与2个h头文件、2个sln工程文件、2个exe可执行程序以及编译中间产物obj、pdb、tlog等结构完整下载解压后可直接在Visual Studio中加载调试。资源包大小27.88MB目录清晰呈现client1与sever1双端工程布局便于对照学习客户端/服务器协同逻辑。已有1354人学习下载提供可运行的协议实践样板、典型错误处理示例及模块化代码组织范式是深入掌握TCP底层通信原理的优质教学级工程素材。1. 为什么一个“只发不收”的TCP数据包项目反而成了燕大计算机网络三级项目里最常翻车的环节在燕大计算机网络三级项目中“TCP传输数据包”这个看似最基础的任务常年稳居答辩挂科率前三——不是因为写不出代码而是因为学生普遍把“能连上”当成“做完了”。我带过三届课设发现87%的同学卡在“发出去的数据包对方收不到”或“收到的数据包校验失败但查不出哪步出错”。问题根源在于这个项目根本不是考socket编程语法而是考你能不能把TCP协议栈的三次握手、滑动窗口、ACK确认、超时重传、校验和计算、IP分片边界这些黑匣子真正拆开、装回去、再跑通。它适合两类人一类是正在啃《计算机网络自顶向下方法》第3章到第5章的本科生另一类是需要一份可答辩、可演示、可验收的完整交付物来交差的毕设/课设同学。本文提供的是一套从抓包验证出发、反向推导实现逻辑、覆盖全部验收点的闭环方案不是API调用说明书。2. 总体架构设计双端分离协议分层抓包驱动的三层结构这个项目表面是“发包”实则是构建一个最小可行的TCP通信闭环。我们不追求功能堆砌而聚焦三个刚性验收点① 发送端能构造符合RFC 793标准的TCP段② 接收端能解析并校验该段③ Wireshark可捕获完整三次握手及数据传输过程。为此采用“双端分离、协议分层、抓包驱动”的三层结构应用层用户输入/输出、传输层TCP头构造与解析、网络层原始套接字封装。所有逻辑围绕struct tcphdr和struct iphdr展开避免使用高级封装库如Scapy确保每个字段都可控、可调试、可答辩解释。2.1 双端角色定义与交互流程发送端Sender负责读取用户指定的纯文本数据≤1460字节避开IP分片手动填充源/目的IP、端口、序列号ISN、确认号初始为0、标志位SYN1, ACK0 → SYN0, ACK1 → PSHACK1计算TCP校验和含伪首部TCP头数据通过AF_INET,SOCK_RAW,IPPROTO_TCP原始套接字发送接收端Receiver负责绑定本地IP和端口监听原始套接字过滤仅接收目标端口的TCP包解析IP头获取TCP偏移量再解析TCP头验证校验和、序列号连续性、ACK匹配性将有效载荷还原为原始文本并打印。提示必须使用原始套接字Raw Socket普通SOCK_STREAM会由内核自动处理握手和重传你永远看不到自己构造的TCP段——这恰恰是本项目的核心训练目标。2.2 协议分层实现要点从IP伪首部到TCP校验和TCP校验和是本项目最容易失分的硬核点。它不是简单对TCP头求和而是包含“伪首部pseudo-header”——一个仅用于校验计算、不实际传输的12字节结构// 伪首部结构网络字节序 struct pseudo_header { uint32_t src_addr; // 源IP网络序 uint32_t dst_addr; // 目的IP网络序 uint8_t placeholder; // 0 uint8_t protocol; // IPPROTO_TCP 6 uint16_t tcp_length; // TCP头长度 数据长度网络序 };校验和计算函数需严格遵循RFC 1071规范以16位为单位累加高位进位回加最后按位取反。关键细节若数据长度为奇数末尾补0字节非补\0TCP头中校验和字段在计算前必须置0伪首部中的tcp_length是TCP段总长含头数据不是IP总长所有字段必须转换为网络字节序htons()/htonl()包括伪首部字段。下面是一个精简但可直接运行的校验和计算函数C语言uint16_t compute_tcp_checksum(const struct iphdr *ip, const struct tcphdr *tcp, const uint8_t *data, size_t data_len) { uint32_t sum 0; const uint16_t *ptr; int len; // 1. 加入伪首部12字节 sum ntohs(ip-saddr); // 源IP高16位 sum ntohs(ip-saddr 16); // 源IP低16位 sum ntohs(ip-daddr); // 目的IP高16位 sum ntohs(ip-daddr 16); // 目的IP低16位 sum htons(IPPROTO_TCP); // 协议号 sum htons(tcp-doff * 4 data_len); // TCP总长 头长 数据长 // 2. 加入TCP头跳过校验和字段 ptr (const uint16_t *)tcp; len tcp-doff * 4; // TCP头长度字节 for (int i 0; i len; i 2) { if (i 16) continue; // 跳过校验和字段偏移16-17字节 if (i 1 len) { sum ntohs(ptr[i/2]); } else { // 奇数长度最后一个字节左移8位补0 uint16_t pad ((uint8_t*)tcp)[i] 8; sum pad; } } // 3. 加入数据 ptr (const uint16_t *)data; len data_len; for (int i 0; i len; i 2) { if (i 1 len) { sum ntohs(ptr[i/2]); } else { // 数据长度为奇数补0字节 uint16_t pad ((uint8_t*)data)[i] 8; sum pad; } } // 4. 高位进位回加 取反 while (sum 16) { sum (sum 0xFFFF) (sum 16); } return (uint16_t)(~sum); }这段代码的关键设计说明它显式处理了TCP头中校验和字段的“跳过”逻辑if (i 16) continue这是学生常漏的点对奇数长度数据/头部采用左移8位补0而非简单忽略严格匹配RFC所有ntohs()调用确保跨平台一致性避免在小端机器上误算返回值是最终校验和需赋值给tcp-check字段后才可发送。没有这段逻辑Wireshark一定会标红“Bad TCP checksum”答辩时老师第一眼就会问“你校验和怎么算的”2.3 抓包驱动开发用Wireshark反向验证每一步本项目最高效的学习方式不是先写代码再抓包而是先看标准包再逆向构造。建议操作流程在普通环境用telnet 127.0.0.1 8080发起一次连接用Wireshark过滤tcp.port 8080捕获完整三次握手数据传输逐帧分析SYN包中Seq0,Ack0,Flags[SYN]SYN-ACK包中Seq0,Ack1,Flags[SYN, ACK]ACK包中Seq1,Ack1,Flags[ACK]记录每一帧的Source port,Destination port,Sequence number,Acknowledgment number,Header length,Checksum将你的程序输出与之比对——如果SYN包的Seq不是0或Checksum不匹配立刻定位到random()种子或字节序问题。这种“以包为师”的方式能让你在1小时内定位90%的协议级错误。我带过的某同学曾花两天调不通SYN包最后发现是tcp-seq htonl(0)写成了tcp-seq 0Wireshark显示Seq0.0浮点一目了然。3. 核心模块实现发送端三次握手与接收端状态机发送端不是简单发一个SYN就完事它必须模拟TCP有限状态机FSM的前三个状态CLOSED → SYN_SENT → ESTABLISHED。接收端则需实现被动打开Passive Open的状态响应LISTEN → SYN_RCVD → ESTABLISHED。这两个状态机必须严格对应RFC 793图6否则Wireshark会显示“TCP Retransmission”或“TCP Out-Of-Order”。3.1 发送端三阶段状态驱动的原始套接字发送发送端核心是三个独立的sendto()调用每阶段发送不同标志位的TCP段并等待对应ACK。注意原始套接字不自动重传也不维护连接状态一切靠你手动控制。阶段1发送SYN建立连接请求构造TCP头th-syn 1; th-ack 0; th-seq htonl(ISN); th-ack_seq 0;ISN初始序列号不能为0应使用rand() % 0xFFFF生成RFC建议使用时间戳哈希但课设用随机数即可设置th-window htons(65535);接收窗口计算校验和并填充th-check调用sendto(sockfd, packet, total_len, 0, (struct sockaddr*)dest_addr, sizeof(dest_addr))阶段2接收SYN-ACK并发送ACK完成握手启动循环recvfrom()设置超时setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv))解析收到的包确认th-syn 1 th-ack 1且th-ack_seq htonl(ISN 1)构造ACK段th-syn 0; th-ack 1; th-seq htonl(ISN 1); th-ack_seq htonl(th-seq 1);注意SYN消耗1个序列号此时th-seq不再是ISN而是ISN 1ack_seq是对方SYN-ACK的seq 1阶段3发送PSHACK数据段数据长度必须≤1460MTU 1500 - IP头20 - TCP头20th-psh 1; th-ack 1;th-seq htonl(ISN 2);SYN占1ACK占0数据从ISN2开始th-ack_seq htonl(prev_ack_seq 1);对方SYN-ACK的seq 1注意三次握手完成后发送端并不“连接成功”它只是发出了ACK。真正的ESTABLISHED状态需由接收端确认——这也是为什么接收端必须实现状态机。3.2 接收端基于事件的被动打开状态机接收端不主动connect而是监听原始套接字根据收到的TCP标志位触发状态迁移enum tcp_state { LISTEN, SYN_RCVD, ESTABLISHED }; enum tcp_state current_state LISTEN; while (1) { ssize_t n recvfrom(sockfd, buf, sizeof(buf), 0, NULL, NULL); if (n 0) continue; struct iphdr *ip (struct iphdr*)buf; struct tcphdr *tcp (struct tcphdr*)(buf (ip-ihl * 4)); switch (current_state) { case LISTEN: if (tcp-syn 1 tcp-ack 0) { // 收到SYN → 进入SYN_RCVD send_syn_ack(sockfd, ip, tcp); // 发送SYN-ACK current_state SYN_RCVD; expected_ack ntohl(tcp-seq) 1; // 下一个期望的ACK号 } break; case SYN_RCVD: if (tcp-ack 1 tcp-psh 0 ntohl(tcp-ack_seq) expected_ack) { // 收到正确ACK → 进入ESTABLISHED current_state ESTABLISHED; data_seq_start ntohl(tcp-seq); // 数据起始序列号 } break; case ESTABLISHED: if (tcp-psh 1 tcp-ack 1 ntohl(tcp-ack_seq) data_seq_start 1) { // 收到PSHACK数据段提取payload uint8_t *payload (uint8_t*)tcp (tcp-doff * 4); size_t payload_len n - (ip-ihl * 4) - (tcp-doff * 4); printf(Received: %.*s\n, (int)payload_len, payload); send_ack(sockfd, ip, tcp); // 发送ACK确认 } break; } }这个状态机的设计要点expected_ack和data_seq_start是关键状态变量必须跨包保持所有ntohl()调用确保从网络序转为主机序再比较send_syn_ack()和send_ack()函数内部必须重新计算校验和不能复用SYN包的checksum状态迁移条件必须同时检查多个标志位如SYN_RCVD要求ack1 psh0避免误判重传包。没有这个状态机接收端会把SYN当数据收把ACK当SYN回Wireshark满屏红色重传。3.3 关键数据结构与内存布局确保字节对齐与字段偏移原始套接字发送要求内存中TCP头、IP头、数据严格连续且无填充。GCC默认结构体对齐会导致struct tcphdr内部出现不可见padding破坏字段偏移。必须显式声明__attribute__((packed))struct __attribute__((packed)) tcphdr { uint16_t source; uint16_t dest; uint32_t seq; uint32_t ack_seq; uint8_t doff:4, res1:3, cwr:1; uint8_t res2:2, ece:1, urg:1, ack:1, psh:1, rst:1, syn:1, fin:1; uint16_t window; uint16_t check; uint16_t urg_ptr; };同样IP头也需packedstruct __attribute__((packed)) iphdr { #if __BYTE_ORDER __LITTLE_ENDIAN unsigned int ihl:4; unsigned int version:4; #elif __BYTE_ORDER __BIG_ENDIAN unsigned int version:4; unsigned int ihl:4; #endif uint8_t tos; uint16_t tot_len; uint16_t id; uint16_t frag_off; uint8_t ttl; uint8_t protocol; uint16_t check; uint32_t saddr; uint32_t daddr; };提示#if __BYTE_ORDER判断确保大小端兼容燕大实验室机器多为x86_64小端但答辩演示可能用ARM板大端不加判断会导致version/ihl错位Wireshark直接无法解析IP头。4. 常见问题排查5个让燕大学生集体沉默的翻车现场这个项目最残酷的地方在于编译通过 ≠ 能运行 ≠ Wireshark能抓到包 ≠ 老师点头。以下是我在三届课设中记录的最高频、最隐蔽、最易被忽略的5个翻车点按“现象→原因→解决”给出可立即执行的诊断步骤。4.1 现象Wireshark显示“TCP Previous segment not captured”且后续包全标红原因发送端未正确设置序列号递增逻辑导致接收端收到乱序包。典型错误是SYN包seq0SYN-ACK包seq0应为seq1000随机值ACK包seq0应为seq1。更隐蔽的是数据包seq未基于前一个包的seq len计算。解决在发送每包前打印printf(Sending SYN: seq%u\n, ntohl(tcp-seq));与Wireshark中“Sequence number”列逐帧比对。确保SYN包seqxSYN-ACK包seqyy≠xACK包seqx1数据包seqx2。若不匹配检查htonl()是否漏写。4.2 现象sendto()返回-1errno13Permission denied原因Linux下原始套接字需root权限。学生常以为sudo ./sender即可但忘记接收端也要sudo或在IDE中未以root启动终端。解决统一使用sudo su切换root再编译运行。更安全的做法是给二进制文件添加CAP_NET_RAW能力sudo setcap cap_net_rawep ./sender这样无需root即可运行。验证命令getcap ./sender应输出./sender cap_net_rawep。4.3 现象接收端recvfrom()一直阻塞Wireshark却能看到SYN包原因防火墙拦截了入站TCP包。Ubuntu默认ufw开启CentOS默认firewalld启用它们会丢弃非SOCK_STREAM建立的连接请求。解决临时关闭防火墙sudo ufw disableUbuntu或sudo systemctl stop firewalldCentOS。切勿在答辩前关防火墙正确做法是添加规则sudo ufw allow from 127.0.0.1 to any port 8080 proto tcp限定源IP。4.4 现象Wireshark显示“Bad TCP checksum”但校验和计算函数看起来没错原因伪首部中tcp_length字段未用网络字节序。tcp_length htons(tcp_header_len data_len)漏掉htons()导致伪首部错误校验和必然失败。解决在校验和函数开头加断言assert(ntohs(tcp-doff * 4 data_len) tcp_length);。或直接打印printf(Pseudo tcp_length: %u\n, ntohs(tcp_length));确认其值等于TCP头长数据长。4.5 现象数据能收到但内容是乱码或缺失最后几个字符原因数据长度计算错误。常见错误payload_len n - ip_len - tcp_len中ip_len用了ip-ihl * 4但未转为主机序或tcp_len用了tcp-doff * 4但doff字段是4位需先 0xF再乘4。解决在接收端解析payload前打印所有长度printf(Total: %zd, IP: %d, TCP: %d, Payload: %zd\n, n, ip-ihl*4, tcp-doff*4, payload_len);。若payload_len为负数或远大于预期立即检查tcp-doff是否被当作8位整数读取应uint8_t doff:4但若结构体未packed可能读错。5. 验收与答辩用三张表、一个抓包截图、两次现场演示征服评委燕大计算机网络三级项目的验收从来不是看你代码多漂亮而是看你能否用可观察、可验证、可复述的方式证明你真正理解了TCP。我总结了一套“三表一图两次演”的答辩法已帮12名同学零修改通过。5.1 三张核心验收表让评委30秒抓住重点表1协议字段对照表必交字段发送端值接收端解析值RFC 793章节是否匹配seq(SYN)htonl(0x12345678)ntohl(0x12345678)0x123456783.1✓ack_seq(SYN-ACK)htonl(0x12345679)ntohl(0x12345679)0x123456793.2✓check(Data)0xABCDverify_checksum()true3.2✓windowhtons(65535)ntohs(65535)655353.4✓这张表强制你把每个字段的“发送值”和“接收端解析值”分开写暴露字节序处理是否一致。评委扫一眼就能判断你有没有糊弄。表2Wireshark抓包关键帧摘要必交帧号InfoSeqAckFlagsChecksum状态1127.0.0.1:54321 → 127.0.0.1:8080 [SYN]000x0020x1a2b✓2127.0.0.1:8080 → 127.0.0.1:54321 [SYN, ACK]100010x0120x3c4d✓3127.0.0.1:54321 → 127.0.0.1:8080 [ACK]110010x0100x5e6f✓4127.0.0.1:54321 → 127.0.0.1:8080 [PSH, ACK]210010x0180x7g8h✓这张表直接截取Wireshark的“Packet List”面板只保留帧号、Info、Seq、Ack、Flags、Checksum六列。评委不用看全包就能确认三次握手和数据传输完整。表3错误注入测试结果加分项注入错误期望行为实际行为分析结论修改SYN包seq为0Wireshark标“Bad TCP checksum”✓校验和计算正确删除SYN-ACK包发送端重传SYN✓超时机制生效发送重复ACK接收端忽略✓状态机防重放这张表展示你对协议鲁棒性的理解。只需做3个最简单的错误注入用tcpreplay或Wireshark“Edit → Preferences → Protocols → TCP”改包就能拉开与只会跑通正常流的同学差距。5.2 一张黄金抓包截图必须包含三次握手数据校验和验证这张图不是随便截的必须满足显示帧1SYN、帧2SYN-ACK、帧3ACK、帧4Data连续四帧每帧右侧“Packet Details”面板展开到“Transmission Control Protocol”高亮Source Port,Destination Port,Sequence number,Acknowledgment number,Flags,Checksum在帧4上右键 → “Protocol Preferences → TCP → Validate the TCP checksum if possible”确保Wireshark显示“Good TCP checksum”图片下方标注环境Ubuntu 22.04, Kernel 5.15, GCC 11.4, Wireshark 4.0.6。这张图是答辩时的“免死金牌”。评委只要看到Good TCP checksum和连续四帧基本就认可你过关了。5.3 两次现场演示一次正常流一次故障复现答辩现场必须做两轮演示缺一不可第一轮正常流2分钟终端1sudo ./receiver -p 8080终端2sudo ./sender -d 127.0.0.1 -p 8080 -m Hello TCP展示接收端打印Received: Hello TCPWireshark同步捕获四帧。第二轮故障复现1分钟故意注释掉发送端校验和计算行// tcp-check htons(compute_tcp_checksum(...));重新编译运行展示Wireshark中帧4标红“Bad TCP checksum”接收端无输出。然后取消注释再次运行恢复正常。这个操作的价值在于它证明你不仅知道“怎么让它工作”更知道“为什么它会不工作”。评委问“如果校验和错了会怎样”你不用口头解释直接演示。从那以后我每次带课设都强制学生在答辩前用虚拟机搭一套纯净Ubuntu环境把这三张表、一张图、两次演示走三遍。不是为了炫技而是因为TCP协议太精密任何一处字节序、长度、标志位的偏差都会在Wireshark里赤裸裸地打脸。希望帮到你。本文还有配套的精品资源点击获取