
简介燕山大学计算机网络三级项目TCP传输数据包面向计算机专业学生和网络编程初学者用于深入理解TCP协议实现可靠数据传输的完整过程。资源内含可直接运行的客户端与服务器工程覆盖三次握手、数据分片重组、滑动窗口流量控制、拥塞控制及四次挥手等核心机制。压缩包共60个文件约27.88MB以C源文件.cpp/.h、Visual Studio工程配置.sln/.vcxproj为主并包含编译日志、调试符号等辅助文件目录结构清晰便于直接运行、断点跟踪和工程复现。目前已有1352人学习下载是计算机网络课程实践的常用参考。项目源码完整呈现了连接建立、数据收发确认、异常重传和连接释放的代码逻辑可模拟不同网络环境验证性能表现为后续网络开发与协议分析打下扎实基础。1. 燕大计算机网络三级项目TCP传输数据包到底在考你什么燕山大学计算机网络三级项目里“TCP传输数据包”是每年选的人最多、翻车率也最高的一题。它看起来只是让两个程序把一个文件通过TCP发过去很多组也确实停在“写一个socket互相收发消息”就算做完结果答辩时被问“传输的数据包在哪”“怎么证明是TCP包”当场卡壳。这一题真正在考的不是会不会调socket API而是你能不能把“包”这个抽象概念拆成真实数据包怎么组、序号怎么走、粘包怎么切、抓包里怎么对上三次握手。这篇文章就是按这个思路做的实战拆解适合正在做课程设计的本科生以及想拿优秀答辩把代码写干净的动手派。后面讲的方案都是不依赖IDE、一行gcc就能跑、报告也好写的做法。2. 项目需求拆解从验收表反推出你要交付的四个模块2.1 自顶向下读需求TCP项目不是“写完socket就完事”拿到“传输数据包”这个题目先别急着敲代码。最好的切法是自顶向下把一句需求翻译成能验收的行为指标。最窄的理解是“把A文件挪到B机器”但考核老师更大概率会问“文件是整体一股脑发还是拆成数据包发”“丢了包怎么办”“收方怎么知道这一包有多大”。所以这个项目的实际需求应当包含四个功能面。第一是建立连接。这部分对应TCP三次握手客户端主动connect服务端listen并accept两边建立起一条字节流通道。第二是文件信息的传递。接收端要能知道文件名、文件大小否则就只能提前把出参写死。第三是拆分与发送。发送端把文件读进内存按固定长度切成payload每个payload前面拼一个自定义包头整体作为一个TCP报文段交给内核发出。第四是重组与校验。接收端从字节流里识别包头、取出payload、按序号写盘最后比对文件字节数或哈希确认无错。建议把这个结构画成一张流程草图标注每个节点上“老师可能追问什么”。比如建立连接处会问“三次握手的seq和ack是怎么变的”拆分处会问“为什么用4096不用1460”重组处会问“recv收到的流怎么判断一条消息的边界”。把这些问题提前想明白比多做两个功能更有收益。2.2 交付形态与验收标准代码、抓包记录、实验报告怎么对齐燕大这类三级项目一般要求交三样东西可编译运行的源码、抓包记录或截图、一份实验报告。源码没太多可说的重点在另两样。抓包记录建议不要用屏幕拍照代替文件直接导出pcap附加到报告里并在报告对应位置截取关键报文图标注第几条是SYN、第几条是SYNACK、第几条是ACK。老师看到这种图默认你把协议看懂了一半。实验报告的架子我建议按“需求分析—设计—实现—测试—问题总结”五段写其中测试段一定要有一张“功能项与验证结果”表。比如“传输20MB随机文件接收端md5一致”“在丢包10%的虚拟链路下完成时间增加约X秒抓包可见重传”。这张表是你和别的组的最大差异。没有这张表的报告跟你课程设计任务书里的要求其实对不上评语自然也就只能写“实现较完整”。2.3 为什么我建议选C语言而不是Python选型这件事直接决定后面一半的报错量。如果你只求跑通Python写TCP发送确实二三十行搞定但Python的bytes、str和struct处理会让“包头”这件事变得绕而且答辩时老师问“你这段内存里包头是怎么对齐的”你很难对着Python解释。C语言就不用纠结struct就是包头指针就是读缓冲区的游标协议栈在你面前是露肉的。代价是你要自己处理字节序、会踩包的边界。假如小组里队友对C不太熟退一步用C写也是可以接受的核心代码保持C风格vector做缓冲反而能省心一点。不建议用Java或Go不是不能做是期末这个时间点上你能找到的低级网络调试资料和经验贴大多是C/C的。3. 用raw socket还是标准socket选型决定你后面一半的报错量3.1 标准socket方案跑通容易但“传输数据包”解释不清最简单的方案是用系统提供的socket API调一遍connect、send、recv、close把文件整个塞进字节流。这个方案非常适合验收但它有个致命短板你并没有亲眼看到“数据包”长什么样。数据往内核一丢内核按TCP分段、加TCP头、交给IP层你全看不见。老师问“TCP头里序号在哪一段”你只能背八股。所以标准socket方案不能原样直接用至少要补上两个动作一是自己设计并发送带序号的包头让包的结构在你的程序里可见二是用抓包工具把内核实际发的TCP报文抓出来和你的包头对照。做完这两件事你的代码用的是标准API但项目讲的是“传输数据包”本身逻辑上就闭环了。3.2 raw socket方案自己组TCP报文头够狠但工作量卡人想再进一步可以绕开内核的TCP实现用raw socket自己构造TCP头。代码开场大概是这样的// 需要root权限运行 int fd socket(AF_INET, SOCK_RAW, IPPROTO_TCP); if (fd 0) { perror(socket); return 1; }之后你要手工填TCP源端口、目的端口、seq、ack、标志位、窗口大小再计算校验和。校验和要基于TCP伪首部算伪首部里包含源IP、目的IP、协议号和TCP长度这些信息在raw socket下你得自己凑出来。一个常见做法是先把包头填好置零checksum构造一个伪首部加TCP头加payload的临时缓冲区计算校验和后再回填。这段代码不算难但边界情况很多IP头长度字段、分片偏移、字节序、校验和重算随便一个不对抓包看到的就是接收方直接回RST。对课程设计来说工作量会失控。我的判断是除非你有两周以上的纯编码时间并且打算把这个项目作为考研复试的谈资否则不建议在主项目里直接上raw socket。你可以在报告“扩展设计”里写清楚思路甚至贴一个能发出SYN报文的段但别把它作为文件传输的主体。3.3 我推荐的折中路径标准socket 显式写包头 抓包验证最终推荐的方案是“标准socket发字节流应用层自己做包边界”。TCP保证字节流不丢不重不乱序保底的部分交给内核而一个文件怎么拆、每包的序号是什么、收方怎么确认这些由你的代码主动做。这样的设计有一个实际好处你的包头能对上TCP的理解。包头里的seq是应用层序号TCP报文头里也有seq是字节流序号两者对照着讲报告非常有层次。具体结构上发送端拆成四个模块读文件、组包、发包、等ACK接收端拆成三个模块收流、切包、回ACK。通信双方先建立一个TCP连接之后所有文件数据都走这一个连接。这个架构简单、信噪比高后面每一章节的代码都围绕它展开。4. 把“数据包”变成代码一条TCP文件传输链路的最小实现4.1 自定义包头怎么设计字段越少越好但magic不能省要让“包”这个概念落地第一步是定义一个包头结构。参考谢希仁《计算机网络》里TCP报文段的思路我们的应用层包头不必复杂能表达四件事就够这包是不是有效、是第几包、这包有多长、是不是结束。// packet.h #include stdint.h #define MAGIC 0xA5A5A5A5 // 包头魔数抗错位 #define MAX_PAYLOAD 4096 // 单个数据包payload上限 #pragma pack(push, 1) // 取消结构体对齐包头按1字节紧凑排列 typedef struct { uint32_t magic; // 固定MAGIC校验是否包头 uint32_t seq; // 应用层包序号从0开始 uint32_t len; // 本包payload长度字节 uint32_t flags; // 0x01数据 0x02结束 } pkt_header_t; #pragma pack(pop)这里最关键的是__attribute__((packed))或#pragma pack(1)。如果不对齐打包编译器会在结构体里插入填充字节sizeof(pkt_header_t)变成20而不是16收方按16字节窗口读包头就会错位。这个坑在Linux和Windows的编译器上都存在只是Windows上还要额外注意#pragma pack的恢复。实际发送时每个包大小是16字节包头 payload。payload定在4096而不是TCP的MSS 1460是因为我们不直接控制IP分片内核会把这个应用包再拆成若干TCP段payload大一点包头占比就低代码简化为“读一截发一截”的逻辑也更清楚。字节序方面我建议所有包头整数字段统一用htonl/ntohl转换。虽然你的两台测试机多半都是x86小端不用转换也能跑但报告里写一句“做了字节序转换以兼容异构平台”属于零成本的答辩加分点。4.2 发送端实现分包发送和确认机制的最小可跑代码发送端逻辑分三步连上接收端打开文件循环读4096字节每一块套上包头发出去并等待ACK。这里给出一个“发一包等一包”的stop-and-wait版本它吞吐率一般但最容易讲清楚也最容易验证重传逻辑。// sender.c 简化核心流程 #include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #include netinet/tcp.h #include errno.h #include packet.h static ssize_t send_full(int fd, const void *buf, size_t len) { const char *p buf; size_t done 0; while (done len) { // send不保证一次发完 ssize_t n send(fd, p done, len - done, 0); if (n 0) return -1; done n; } return (ssize_t)done; } static int wait_ack(int fd, uint32_t seq, int timeout_ms) { struct timeval tv { timeout_ms / 1000, (timeout_ms % 1000) * 1000 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); pkt_header_t ack; ssize_t n recv(fd, ack, sizeof(ack), 0); // 等接收端的确认包 if (n ! sizeof(ack)) return 0; if (ack.magic ! MAGIC || ack.seq ! seq) return 0; return 1; } int main(int argc, char **argv) { if (argc ! 4) { printf(usage: %s server_ip port file\n, argv[0]); return 1; } int fd socket(AF_INET, SOCK_STREAM, 0); int nodelay 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, nodelay, sizeof(nodelay)); struct sockaddr_in addr { 0 }; addr.sin_family AF_INET; addr.sin_port htons(atoi(argv[2])); inet_pton(AF_INET, argv[1], addr.sin_addr); if (connect(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(connect); return 1; } FILE *fp fopen(argv[3], rb); fseek(fp, 0, SEEK_END); long total ftell(fp); fseek(fp, 0, SEEK_SET); char buf[MAX_PAYLOAD]; pkt_header_t hdr; uint32_t seq 0; size_t sent 0; while (sent (size_t)total) { size_t len fread(buf, 1, sizeof(buf), fp); hdr.magic htonl(MAGIC); hdr.seq htonl(seq); hdr.len htonl((uint32_t)len); hdr.flags htonl(len sizeof(buf) ? 0x02 : 0x01); char pkt[sizeof(pkt_header_t) MAX_PAYLOAD]; memcpy(pkt, hdr, sizeof(pkt_header_t)); memcpy(pkt sizeof(pkt_header_t), buf, len); if (send_full(fd, pkt, sizeof(pkt_header_t) len) 0) { perror(send_full); break; } if (!wait_ack(fd, seq, 500)) { // 500ms没等到ACK就重新整包发 if (send_full(fd, pkt, sizeof(pkt_header_t) len) 0) break; wait_ack(fd, seq, 500); } seq; sent len; printf(progress: %zu / %ld bytes\r, sent, total); fflush(stdout); } fclose(fp); close(fd); printf(\ntransfer done, seq%u\n, seq); return 0; }这段代码有两点必须解释清楚。第一是send_full很多人第一次写TCP程序直接用send(fd, buf, len, 0)看返回值以为等于len就发完了实际上阻塞socket下send只承诺“拷贝到内核发送缓冲区的字节数”缓冲满时可能只发一半。第二是wait_ack停等协议里每一包必须等到确认才发下一包超时则重发。500ms这个值在校园网本地回环里足够宽裕在跨主机实验里也不容易误判超时。把超时值和重传计数再工程化一点可以把MAX_RETRY提到3次超过3次断开并打印失败的seq。这是报告里“可靠性设计”一节最扎实的素材你已经验证了在超时未确认的情况下发送端会为该包重发而字节流不会错位。4.3 接收端实现从字节流里切出包头与payload接收端的核心不是recv本身而是怎么从一条没有边界的字节流里把包一个个切出来。你需要一个累积缓冲区每次recv到的数据先追加到缓冲区尾部然后循环检查缓冲区开头是否已有一个完整包。// receiver.c 简化核心流程 #include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #include packet.h #define BUF_SIZE 262144 // 累积缓冲区256KB static int send_ack(int fd, uint32_t seq) { pkt_header_t ack; ack.magic htonl(MAGIC); ack.seq htonl(seq); ack.len htonl(0); ack.flags htonl(0); return send(fd, ack, sizeof(ack), 0) sizeof(ack) ? 0 : -1; } int main(int argc, char **argv) { if (argc ! 3) { printf(usage: %s port savefile\n, argv[0]); return 1; } int lfd socket(AF_INET, SOCK_STREAM, 0); int reuse 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, reuse, sizeof(reuse)); // 避免TIME_WAIT卡住端口 struct sockaddr_in addr { 0 }; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(atoi(argv[1])); bind(lfd, (struct sockaddr *)addr, sizeof(addr)); listen(lfd, 4); int cfd accept(lfd, NULL, NULL); FILE *fp fopen(argv[2], wb); char buf[BUF_SIZE]; size_t tail 0; // 缓冲区有效数据长度 uint32_t expect_seq 0; // 期望收到的包序号 while (1) { ssize_t n recv(cfd, buf tail, sizeof(buf) - tail, 0); if (n 0) break; tail (size_t)n; size_t pos 0; while (pos sizeof(pkt_header_t) tail) { pkt_header_t *hdr (pkt_header_t *)(buf pos); if (ntohl(hdr-magic) ! MAGIC) { pos; continue; } // 错位跳过1字节找magic uint32_t seq ntohl(hdr-seq); uint32_t len ntohl(hdr-len); size_t need sizeof(pkt_header_t) len; if (pos need tail) break; // bytes流还没攒够整包跳出等下次recv fwrite(buf pos sizeof(pkt_header_t), 1, len, fp); send_ack(cfd, seq); if (ntohl(hdr-flags) 0x02) { // 结束包 fclose(fp); close(cfd); close(lfd); printf(recv done, expect_seq%u\n, expect_seq); return 0; } expect_seq seq 1; pos need; } if (pos 0 pos tail) { memmove(buf, buf pos, tail - pos); // 未处理完的残余数据搬回缓冲开头 tail - pos; } else if (pos tail) { tail 0; } } return 0; }这里最关键的就是外层while加内层while的双重结构它对应着TCP的流式语义一次recv可能只收到半个包也可能收到三个半包还可能把一个包的尾部跟下一个包的头部连在一起。内层循环只在“缓冲区剩余字节足够一个完整包头”的前提下尝试切包不够就留着等下一次recv。pos跳过单字节再找magic是粘包错位后的自救策略比直接退出健壮得多。如果你追求更高的传输效率可以把停等改成一次发8包再统一等ACK的滑动窗口版本但窗口机制会带来排序和重发窗口管理的复杂度。作为课程设计我建议先把停等版本彻底跑通、想明白再在报告里讨论“如果将窗口设为8理想吞吐率大约提升8倍但极端丢包场景重传范围变大”。5. 避坑指南三次握手的截图在Linux上还正常换台机器就翻车5.1 粘包与拆包错位文件末尾多出几字节或直接卡死现象传输小文件正常传几十MB文件时接收端写出的文件md5对不上或者程序在某个进度卡住不动。抓包看到接收端回了ACK但发送端还在等。原因TCP是字节流不背“一条消息”的锅。发送端调了3次send发了三个包接收端一次recv可能就把它们全拿回来塞在缓冲区里或者反过来一个包被拆成两半。你的程序如果没有按包头长度做二次切分就会把下一个包头的字节当成上个包的payload写进文件。解决按4.3节的累积缓冲区逻辑处理严格按照“先收满一个包头再按包头里的len收满后续payload”执行。如果还出问题在解析包头后校验magic和len上限若len大于MAX_PAYLOAD就丢弃缓冲区并重新同步防止一个错位的len字段把整个接收循环带飞。5.2 send返回值不等于发送长度大文件传着传着断了现象程序在小文件测试时一切正常换成几百MB文件后发送端发送一段就perror退出接收端只收到半截文件。原因阻塞socket的send返回负值表示错误返回正值只表示“拷贝进内核缓冲区这么多字节”并不代表对端已收到。大文件高速发送时接收端读写稍慢内核接收缓冲区和发送缓冲区一满send就可能只发送一部分而你的旧代码如果只send一次且没做循环就会把剩余数据丢掉。解决使用send_full这种循环包装函数每次把指针后移已发送字节数直到全部发完或返回错误。这是所有TCP上传下载代码的地基不要在这一步节省代码量。5.3 Nagle算法把小包攒成了延迟抓包全是连续大包交互像卡住现象设置了TCP_NODELAY后一个样不设置又一个样有时发送端发一个小ACK包要等几百毫秒才出去抓包看到标志位是PSHACK的大包连续到达但seq间隔明显不规律。原因Nagle算法会把多个小段攒成一个TCP段再发目的是减少小包数量、提升网络利用率但对请求-响应式交互会造成延迟。你的自定义数据包是16字节包头加4096字节payload单个包不小但如果程序里还有单独的ACK握手小包它们就可能被Nagle攒着。解决在socket后立即设置TCP_NODELAY1关闭Nagle这个选项在Linux和Windows都有效。学有余力的话报告里对比一下开启和关闭后的抓包差异这句话能让报告评测人对你的协议理解加分。5.4 快速重跑程序bind失败TIME_WAIT卡住端口现象服务端CtrlC退出后马上重启bind报Address already in use。客户端那边重跑没有这个问题。原因主动关闭方要进入TIME_WAIT状态保持2MSL约1到4分钟保证最后的ACK万一丢失可以重发。你的接收端如果先close(cfd)而发送端后关那么接收端就是主动关闭方端口被TIME_WAIT占住。解决服务端bind之前设置SO_REUSEADDR。这是Linux下“后悔药”的常用写法。另外程序结束后可以用ss -s看一眼当前连接状态数如果TIME-WAIT在增长就说明连接关闭路径有问题如果大量ESTAB不释放检查业务线程是不是忘了close。6. 想让项目超出同组一截用tcpdump和iperf把协议证据补齐6.1 用Wireshark验证三次握手和你的应用层包序号光把代码跑通只算及格答辩拉开差距的是验证证据。实验时用tcpdump直接在收发两端抓包即可# 在接收端执行抓所有8888端口的TCP报文 sudo tcpdump -i lo tcp port 8888 -w tcp_project.pcap打开pcap后先把过滤器设为tcp.port 8888。看前三条报文几乎必然是SYN、SYNACK、ACK。Wireshark默认显示的是相对序号第一条SYN的序号是0SYNACK的序号是0、确认号是1最后的ACK序号是1、确认号是1。你要能在截图旁标注出“序号表示本次传输起始字节号确认号表示期待接收的下一个字节号”这段话。还要主动在Wireshark里做一件事关闭相对序号显示让界面显示真实字节流序号。在TCP协议首部右键进入Protocol Preferences取消“Relative sequence numbers”的勾选。这时你会发现真实序号是一个很大的随机值这就是TCP安全机制里的初始序号ISN。你的应用层包头seq从0开始而TCP层的seq是个随机大数两者层次不同但逻辑一脉相承把这层窗户纸捅破老师基本不会再追问。6.2 用iperf3测吞吐率用tc模拟丢包验证重传课程设计里如果能写出“稳定运行于10%丢包链路完成传输且文件哈希一致”说服力就完全不一样了。模拟丢包可以用Linux自带的netem不用专门搭坏环境# 在虚拟机的发送端网卡上增加10%丢包 sudo tc qdisc add dev eth0 root netem loss 10% # 跑完你的程序后立刻删除规则 sudo tc qdisc del dev eth0 root丢包规则生效后你的程序因为超时重传会变慢但文件不能出错。抓包时过滤tcp.analysis.retransmission就能看到标红的TCP重传段这与你的应用层重传行为正好互为印证内核在TCP层会重传没被确认的TCP段你的程序在应用层也会重发没等到ACK的数据包。两层机制都写进报告说明你分得清哪些可靠性是TCP给的哪些是你自己的协议给的。最后如果还想展示传输效率可以用iperf3测一下这台机器TCP的极限带宽再拿你程序的耗时和文件大小算一个应用层吞吐率两者对比就能得出协议开销的百分比。命令是# 接收端 iperf3 -s -p 5001 # 发送端 iperf3 -c 127.0.0.1 -p 5001 -t 10我的习惯是报告最后附上“内核TCP吞吐率、应用层吞吐率、开销原因分析”三行字。这么干过的人都知道这不是玄学而是说服力最直接的实验手段。希望这些参数、代码和踩坑经验帮到你。做完之后把抓包文件留好哪怕隔半年再被人问起你也能很快讲清楚当时的包是怎么飞过去的。本文还有配套的精品资源点击获取