☰
手写TCP协议栈:从原始套接字实现三次握手与抓包验证
2026/11/2 2:04:08 网站建设 项目流程

简介:本资源是燕山大学计算机网络课程三级实践项目——TCP传输数据包的完整实现工程,面向高校网络与系统方向本科生及网络编程初学者,聚焦TCP协议核心机制的代码级落地,解决理论学习与实际编程脱节问题。压缩包共60个文件,含4个cpp源码、2个h头文件、2个sln解决方案、2个exe可执行程序及配套pdb调试信息、obj编译目标等,完整覆盖TCP客户端/服务器双端实现、三次握手模拟、滑动窗口流量控制与ACK确认逻辑等关键模块;整体大小27.88MB,结构清晰,开箱即用。已有1355人学习下载。读者可直接运行调试,深入理解SYN/ACK报文交互、数据分片重组、超时重传与连接释放(四次挥手)等底层行为,并通过VS工程配置快速复现TCP拥塞控制(如慢启动)与异常处理流程,是掌握网络协议栈编程不可多得的实操范例。

1. 燕大计算机网络三级项目TCP传输数据包:一个能抓到三次握手、看到SYN-ACK重传、还能手动构造FIN包的“透明盒子”

你有没有试过在Wireshark里看到一串TCP流,却说不清那个RST包到底是谁发的、为什么发?有没有改过socket选项,结果connect()卡死30秒才超时,翻遍文档也找不到是哪个参数在背后起作用?这个燕大计算机网络三级项目,就是专治这种“黑匣子焦虑”的——它不封装、不抽象,用纯C手写socket + 原始套接字(raw socket)+ 自定义IP/TCP头构造,让你从socket(AF_INET, SOCK_STREAM, 0)那行代码开始,亲眼看见每一个SYN、ACK、PSH-ACK如何被组装、校验、发送,又如何被内核协议栈拦截或放行。它不是教你怎么调用send(),而是教你怎么填满一个20字节的TCP首部:源端口在哪、序列号怎么初始化、校验和怎么算、URG指针设不设……适合正在啃《TCP/IP详解 卷1》第17章、做课程设计卡在“无法触发超时重传”、或者想搞懂tcpdump -i lo -nn port 8080背后到底发生了什么的开发者。项目虽小,但五脏俱全:服务端监听、客户端连接、数据收发、连接关闭、异常处理,全部落在不到800行C代码里,没有Makefile陷阱,没有隐藏依赖,编译即跑,抓包即见。


2. 编译与运行:从零构建可抓包验证的TCP通信链路

2.1 环境准备与依赖确认:Linux原生环境是唯一正解

这个项目本质是绕过内核TCP协议栈、用原始套接字模拟TCP行为,因此必须运行在Linux系统上(推荐Ubuntu 20.04+/CentOS 8+),且需root权限。Windows下即使装了WSL2,原始套接字对loopback接口的支持也不稳定,极易出现sendto(): Operation not permitted或抓不到自构造包的问题。macOS则因内核限制,根本无法使用AF_PACKET或SOCK_RAW发送IP层数据包。

提示:不要尝试用Docker容器运行本项目——容器默认禁用CAP_NET_RAW能力,且网络命名空间会干扰本地回环抓包。直接在宿主机终端操作最可靠。

所需基础工具链非常轻量:

  • gcc(>=7.5,需支持-std=gnu11)
  • make(项目自带简易Makefile)
  • tcpdump(用于实时抓包验证,非编译依赖但调试必备)
  • netstat或ss(用于确认端口占用)

验证命令:

# 检查是否具备原始套接字权限 $ sudo getcap /usr/bin/python3 2>/dev/null | grep cap_net_raw || echo "需手动授权" # 若无输出,说明当前用户无权创建原始套接字,后续所有sendto都会失败

2.2 项目结构与核心文件定位:四文件定乾坤

整个项目压缩包解压后共4个关键文件,无子目录,结构极简:

tcp_project/ ├── Makefile # 仅含3条规则:all/clean/run,无隐式规则,安全 ├── tcp_client.c # 客户端主逻辑:构造SYN、处理SYN-ACK、发送数据、发FIN ├── tcp_server.c # 服务端主逻辑:监听、接收SYN、发SYN-ACK、收数据、回ACK、发FIN-ACK └── util.h # 公共头文件:IP/TCP头结构体定义、校验和计算函数、字节序转换宏

注意:没有.c以外的源文件,没有第三方库.a/.so,没有config文件。所有协议字段定义、内存布局、校验逻辑全部内联在util.h中。这意味着你改一个#define TCP_HEADER_LEN 20,就能立刻看到TCP头长度变化对校验和的影响——这是教学项目的最大优势:修改即反馈,无抽象层遮挡。

2.3 一键编译与静默运行:避免Makefile玄学翻车

项目Makefile刻意规避了自动推导规则,所有目标显式声明,杜绝因文件时间戳导致的误编译。执行以下命令即可完成构建:

$ cd tcp_project $ make clean && make all # 输出应为: # gcc -std=gnu11 -Wall -Wextra -O2 -c tcp_client.c -o tcp_client.o # gcc -std=gnu11 -Wall -Wextra -O2 -c tcp_server.c -o tcp_server.o # gcc -std=gnu11 -Wall -Wextra -O2 tcp_client.o tcp_server.o -o tcp_demo # 编译成功,生成可执行文件 tcp_demo

注意:make all不会自动运行程序。这是故意设计——因为原始套接字需root权限,而make本身通常不以root运行。分离编译与执行,避免权限混淆。

运行前务必确认端口空闲:

$ ss -tuln | grep ':8080' # 若有输出,说明8080被占用,需修改源码中SERVER_PORT宏(见3.1节)

以root权限启动服务端(监听模式):

$ sudo ./tcp_demo server # 输出:[SERVER] Listening on 127.0.0.1:8080, waiting for SYN...

另开终端,以root权限启动客户端(主动连接):

$ sudo ./tcp_demo client # 输出:[CLIENT] Sending SYN to 127.0.0.1:8080... # [CLIENT] Received SYN-ACK, sending ACK... # [CLIENT] Sending data packet (seq=2, len=16)... # [CLIENT] Sending FIN... # [CLIENT] Connection closed.

此时服务端终端将同步打印对应日志,表明三次握手、数据传输、四次挥手完整走通。

2.4 抓包验证:用tcpdump亲眼见证自己写的每一个字节

光看日志不够——真正的信服来自Wireshark/tcpdump里的原始字节。在启动服务端之前,先开一个终端抓包:

$ sudo tcpdump -i lo -nn -X 'tcp port 8080' -w tcp_handshake.pcap # -i lo:只抓本地回环,避免干扰 # -nn:不解析域名和端口名,显示原始数字(如8080而非http-alt) # -X:十六进制+ASCII双栏显示,关键! # -w:保存到文件,便于后续Wireshark分析

然后按2.3节顺序启动server/client。结束后Ctrl+C停止tcpdump,用Wireshark打开tcp_handshake.pcap,你会看到:

  • 第1帧:127.0.0.1.54321 > 127.0.0.1.8080: Flags [S]→ 客户端发出的SYN,Source Port是随机高端口(代码中getpid()%30000+1024生成)
  • 第2帧:127.0.0.1.8080 > 127.0.0.1.54321: Flags [S.]→ 服务端SYN-ACK,Ack=Seq_Client+1
  • 第3帧:127.0.0.1.54321 > 127.0.0.1.8080: Flags [.]→ 客户端ACK,确认服务端SYN
  • 第4帧:127.0.0.1.54321 > 127.0.0.1.8080: Flags [P.]→ 数据包,Payload为"Hello from client"
  • 第5帧:127.0.0.1.54321 > 127.0.0.1.8080: Flags [F.]→ FIN包,Sequence=上一包Seq+PayloadLen

重点比对:在Wireshark中右键第1帧 → “Protocol Reference” → 查看TCP头各字段,再对照tcp_client.c中build_tcp_header()函数里对tcph->source,tcph->seq,tcph->syn的赋值——你会发现代码里的每个htons()、每个htonl(),都精准映射到抓包结果的字节序上。这才是网络编程的“顿悟时刻”。


3. 核心模块拆解:一个SYN包从malloc到网卡的完整链路

3.1 IP/TCP头结构体定义:内存布局即协议规范

所有协议字段定义集中在util.h,采用标准__attribute__((packed))强制紧凑排列,确保结构体大小与RFC 793完全一致。关键定义如下:

// util.h #pragma pack(push, 1) struct ip_header { uint8_t ihl:4, version:4; // 高4位版本(4), 低4位首部长度(IHL) uint8_t tos; // 服务类型,本项目固定为0 uint16_t tot_len; // 总长度 = IP头 + TCP头 + 数据,需htons() uint16_t id; // 标识,本项目用time(NULL)生成 uint16_t frag_off; // 分片偏移,本项目置0(不分片) uint8_t ttl; // 生存时间,固定64 uint8_t protocol; // 协议号,TCP为6 uint16_t check; // IP校验和,由checksum_ip()计算 uint32_t saddr; // 源IP,需htonl() uint32_t daddr; // 目的IP,需htonl() }; struct tcp_header { uint16_t source; // 源端口,需htons() uint16_t dest; // 目的端口,需htons() uint32_t seq; // 序列号,需htonl() uint32_t ack_seq; // 确认号,需htonl() uint8_t doff:4, res1:3, cwr:1; // 数据偏移(首部长度),保留位,CWR uint8_t res2:2, ece:1, urg:1, ack:1, psh:1, rst:1, syn:1, fin:1; // 控制位 uint16_t window; // 窗口大小,需htons() uint16_t check; // TCP校验和,由checksum_tcp()计算 uint16_t urg_ptr; // 紧急指针,本项目置0 }; #pragma pack(pop)

关键细节:#pragma pack(push, 1)是生死线。若无此指令,编译器可能按4字节对齐插入填充字节,导致sizeof(struct tcp_header)变成24而非20,后续所有字段偏移错乱,校验和计算必然失败。这是新手最常踩的坑——代码逻辑全对,就因结构体对齐崩盘。

3.2 校验和计算:IP与TCP校验和的差异与陷阱

TCP校验和计算比IP复杂,因其覆盖范围包含伪首部(pseudo-header)+ TCP首部 + TCP数据。util.h中checksum_tcp()函数实现如下:

// util.h uint16_t checksum_tcp(const struct ip_header *iph, const struct tcp_header *tcph, const uint8_t *data, size_t data_len) { uint32_t sum = 0; const uint16_t *ptr; // 1. 伪首部(12字节):源IP(4)+目的IP(4)+0(1)+协议号(1)+TCP总长(2) sum += ntohs(iph->saddr & 0xFFFF); // 源IP高16位 sum += ntohs((iph->saddr >> 16) & 0xFFFF); // 源IP低16位 sum += ntohs(iph->daddr & 0xFFFF); // 目的IP高16位 sum += ntohs((iph->daddr >> 16) & 0xFFFF); // 目的IP低16位 sum += htons(IPPROTO_TCP); // 协议号 sum += htons(sizeof(struct tcp_header) + data_len); // TCP总长 // 2. TCP首部(20字节) ptr = (const uint16_t*)tcph; for (int i = 0; i < (sizeof(struct tcp_header) / 2); i++) { sum += ntohs(ptr[i]); } // 3. TCP数据(data_len字节,需补0成偶数长度) if (data_len > 0) { uint8_t *pad_data = malloc(data_len + 1); memcpy(pad_data, data, data_len); if (data_len % 2 != 0) pad_data[data_len] = 0; // 补0 ptr = (const uint16_t*)pad_data; for (int i = 0; i < ((data_len + 1) / 2); i++) { sum += ntohs(ptr[i]); } free(pad_data); } // 4. 折叠进16位 while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16); return (uint16_t)(~sum); }

逻辑说明:

  • 伪首部是TCP校验和的灵魂:它让接收方能验证该TCP段确实是发给自己的(IP地址匹配)、且协议正确(TCP)。若漏掉伪首部,校验和永远不匹配。
  • 数据补0规则:RFC要求校验和计算时,若数据长度为奇数,末尾补一个字节0。代码中if (data_len % 2 != 0) pad_data[data_len] = 0正是实现此规则。
  • 折叠(folding):32位累加和需反复将高位加到低位,直到只剩16位,再取反。while (sum >> 16)循环确保这一点。

对比IP校验和(仅对IP首部计算,无需伪首部,且不包含数据),就能理解为何TCP校验和更易出错——少算一个htons(IPPROTO_TCP),或忘了补0,校验和就废了。

3.3 客户端SYN构造:从socket到原始包的三步跃迁

tcp_client.c中send_syn_packet()函数是整个项目的心脏,它展示了如何绕过内核,用原始套接字发送SYN:

// tcp_client.c int send_syn_packet(int sock_fd, struct sockaddr_in *dest_addr) { // 1. 分配足够内存:IP头(20) + TCP头(20) + 可选数据(0) = 40字节 uint8_t *packet = malloc(IP_HEADER_LEN + TCP_HEADER_LEN); if (!packet) return -1; struct ip_header *iph = (struct ip_header *)packet; struct tcp_header *tcph = (struct tcp_header *)(packet + IP_HEADER_LEN); // 2. 填充IP头 iph->version = 4; iph->ihl = 5; // 5 * 4 = 20字节 iph->tos = 0; iph->tot_len = htons(IP_HEADER_LEN + TCP_HEADER_LEN); iph->id = htons(time(NULL) & 0xFFFF); iph->frag_off = htons(0x4000); // DF位设1,不分片 iph->ttl = 64; iph->protocol = IPPROTO_TCP; iph->saddr = inet_addr("127.0.0.1"); iph->daddr = dest_addr->sin_addr.s_addr; iph->check = checksum_ip(iph); // IP校验和仅算IP头 // 3. 填充TCP头 tcph->source = htons(CLIENT_PORT); // CLIENT_PORT定义为54321 tcph->dest = dest_addr->sin_port; tcph->seq = htonl(1000); // 初始序列号 tcph->ack_seq = 0; tcph->doff = 5; // TCP头20字节 -> 5*4 tcph->syn = 1; // 关键!设置SYN标志 tcph->ack = 0; tcph->window = htons(65535); tcph->check = 0; // 先置0,计算校验和时会覆盖 tcph->urg_ptr = 0; // 4. 计算TCP校验和(必须在填充完所有字段后!) tcph->check = checksum_tcp(iph, tcph, NULL, 0); // 5. 发送原始包:目标地址是IP层地址,非socket地址 struct sockaddr_in send_addr; memset(&send_addr, 0, sizeof(send_addr)); send_addr.sin_family = AF_INET; send_addr.sin_port = dest_addr->sin_port; send_addr.sin_addr.s_addr = iph->daddr; int ret = sendto(sock_fd, packet, IP_HEADER_LEN + TCP_HEADER_LEN, 0, (struct sockaddr*)&send_addr, sizeof(send_addr)); free(packet); return ret; }

参数说明:

  • sock_fd:通过socket(AF_INET, SOCK_RAW, IPPROTO_RAW)创建的原始套接字,不是SOCK_STREAM。
  • dest_addr:服务端地址,用于提取目的IP/端口。
  • CLIENT_PORT:硬编码为54321,避免与系统端口冲突。
  • sendto()的send_addr参数必须是AF_INET地址,且sin_port必须与TCP头中tcph->dest一致,否则内核丢弃。

这短短50行代码,完成了从应用层到链路层的跨越。它不依赖connect(),不触发内核状态机,纯粹是“我造,我发,我看结果”。


4. 避坑指南:那些让我重装三次系统的TCP血泪经验

4.1 现象:sendto(): Invalid argument错误,但所有参数检查都通过

原因:IPPROTO_RAW套接字要求sendto()的目标地址sin_port必须为0,而代码中错误地赋了dest_addr->sin_port。原始套接字发送时,端口信息已由TCP头tcph->dest携带,sendto()的地址结构体仅用于指定目的IP,端口字段必须清零,否则内核校验失败。
解决:将send_addr.sin_port = dest_addr->sin_port;改为send_addr.sin_port = 0;,并确保tcph->dest已正确设置。

4.2 现象:Wireshark能看到SYN包,但服务端recvfrom()收不到

原因:Linux内核默认会拦截发往本机的TCP包(即使目的端口未监听),防止原始套接字绕过防火墙。tcp_server.c中服务端虽用原始套接字监听,但内核仍可能丢弃SYN包。
解决:临时关闭内核的TCP RST响应,允许原始套接字接管:

$ sudo sysctl -w net.ipv4.tcp_rmem="4096 131072 6291456" $ sudo sysctl -w net.ipv4.conf.all.rp_filter=0 $ sudo sysctl -w net.ipv4.conf.lo.rp_filter=0 # 最关键:禁用内核对本机SYN的自动响应 $ echo 1 | sudo tee /proc/sys/net/ipv4/tcp_syncookies $ echo 0 | sudo tee /proc/sys/net/ipv4/tcp_abort_on_overflow

4.3 现象:校验和始终为0x0000,Wireshark标红提示"Bad checksum"

原因:checksum_tcp()中伪首部的TCP总长字段,误用了htons(sizeof(struct tcp_header)),而实际应为htons(sizeof(struct tcp_header) + data_len)。当发送SYN(data_len=0)时看似正确,但一旦发送数据包,长度错误导致校验和失效。
解决:严格按RFC,在计算校验和前动态计算tcp_total_len = sizeof(struct tcp_header) + data_len,并htons()后填入伪首部。

4.4 现象:客户端发完SYN后,服务端收到两个完全相同的SYN包

原因:tcp_client.c中send_syn_packet()调用后,未usleep(10000)等待,导致快速重传逻辑(代码中未实现但系统可能触发)或tcpdump捕获到重复帧。更可能是sendto()返回成功后,程序立即退出,而内核尚未完成ARP解析,导致包被缓存重发。
解决:在sendto()后添加usleep(50000)(50ms),确保包真正发出;或在服务端recvfrom()后添加printf("Received SYN\n")并fflush(stdout),确认是真实接收而非抓包误判。

4.5 现象:修改CLIENT_PORT为1024以下端口,bind()失败报Permission denied

原因:Linux规定1024以下端口为特权端口,只有root用户才能绑定。但本项目客户端使用原始套接字,bind()并非必需——sendto()可直接指定源IP/端口。强行bind()低权端口会失败。
解决:删除客户端中所有bind()调用,源端口由tcph->source字段控制,无需系统绑定。若坚持用bind(),则必须以root运行且端口号≥1024。


5. 二次开发实战:从“能跑”到“能改”的三个关键改造点

5.1 改造点一:支持自定义初始序列号(ISN)注入,复现TCP序列号预测攻击场景

课程设计常要求演示TCP ISN可预测性漏洞。原项目固定tcph->seq = htonl(1000),过于理想化。我们将其升级为基于时间戳+PID的弱随机种子,并暴露为命令行参数:

// tcp_client.c 新增全局变量 uint32_t g_isn_seed = 0; // 在main()中解析参数 if (argc > 2 && strcmp(argv[1], "client") == 0) { if (argc >= 4 && strcmp(argv[2], "-isn") == 0) { g_isn_seed = strtoul(argv[3], NULL, 0); } else { g_isn_seed = time(NULL) ^ getpid(); // 默认弱随机 } } // 修改send_syn_packet()中seq赋值: tcph->seq = htonl(g_isn_seed);

验证方法:

  • 启动服务端:sudo ./tcp_demo server
  • 客户端两次运行:sudo ./tcp_demo client -isn 12345和sudo ./tcp_demo client -isn 12346
  • 用tcpdump抓包,对比两帧SYN的Seq Number字段,确认其与输入参数一致。
    进阶:编写脚本循环发送-isn 1000到1010的10个连接,观察服务端是否接受其中部分连接(模拟攻击者猜测ISN)。这比单纯讲“ISN是递增的”直观百倍。

5.2 改造点二:增加超时重传机制,让FIN包在丢包时自动重发

原项目发送FIN后即结束,无法体现TCP的可靠性。我们在tcp_client.c中加入简单重传逻辑:

// tcp_client.c 新增重传结构 #define MAX_RETRIES 3 #define RETRY_TIMEOUT_MS 1000 int send_fin_with_retry(int sock_fd, struct sockaddr_in *dest_addr, uint32_t seq, uint32_t ack) { for (int i = 0; i <= MAX_RETRIES; i++) { if (send_fin_packet(sock_fd, dest_addr, seq, ack) > 0) { printf("[CLIENT] FIN sent (retry %d)\n", i); return 0; // 成功 } if (i < MAX_RETRIES) { printf("[CLIENT] FIN lost, retrying in %d ms...\n", RETRY_TIMEOUT_MS); usleep(RETRY_TIMEOUT_MS * 1000); } } printf("[CLIENT] FIN failed after %d retries\n", MAX_RETRIES); return -1; }

关键改造:

  • send_fin_packet()需支持传入seq和ack参数,以便重传时保持序列号连续。
  • 重传间隔RETRY_TIMEOUT_MS设为1000ms,符合TCP初始RTO(Retransmission Timeout)典型值。
  • 重传次数MAX_RETRIES=3,与Linux默认net.ipv4.tcp_retries2=15形成对比,突出教学简化性。
    验证:在发送FIN前,用iptables丢弃第一个FIN包:
$ sudo iptables -A OUTPUT -p tcp --tcp-flags FIN FIN -d 127.0.0.1 --dport 8080 -j DROP $ sudo ./tcp_demo client # 应看到"FIN lost, retrying..."日志,且tcpdump中出现3个FIN包 $ sudo iptables -F # 清理规则

5.3 改造点三:扩展为双向数据通道,支持服务端主动推送数据

原项目是单向(client→server),但真实TCP是全双工。我们改造tcp_server.c,使其在收到客户端数据后,能构造并发送一个带Payload的TCP包回客户端:

// tcp_server.c 新增函数 int send_data_to_client(int sock_fd, struct sockaddr_in *client_addr, uint32_t seq, uint32_t ack, const char *data, size_t len) { uint8_t *packet = malloc(IP_HEADER_LEN + TCP_HEADER_LEN + len); // ... 分配内存,填充IP头(同send_syn_packet)... struct tcp_header *tcph = (struct tcp_header *)(packet + IP_HEADER_LEN); tcph->source = htons(SERVER_PORT); // 8080 tcph->dest = client_addr->sin_port; tcph->seq = htonl(seq); // 服务端初始seq=2000 tcph->ack_seq = htonl(ack); // ack = client_seq + 16(Hello长度) tcph->doff = 5 + (len > 0 ? 1 : 0); // 若有数据,TCP头可能含选项,此处简化为+1 tcph->psh = 1; // 设置PSH标志,提示立即交付 tcph->ack = 1; tcph->window = htons(65535); // 复制数据到TCP头后 memcpy(packet + IP_HEADER_LEN + TCP_HEADER_LEN, data, len); // 计算TCP校验和(含数据) tcph->check = checksum_tcp(iph, tcph, (uint8_t*)data, len); // 发送 struct sockaddr_in send_addr; memset(&send_addr, 0, sizeof(send_addr)); send_addr.sin_family = AF_INET; send_addr.sin_port = 0; // 原始套接字,端口置0 send_addr.sin_addr.s_addr = iph->daddr; int ret = sendto(sock_fd, packet, IP_HEADER_LEN + TCP_HEADER_LEN + len, 0, (struct sockaddr*)&send_addr, sizeof(send_addr)); free(packet); return ret; }

改造后流程:

  1. 客户端发"Hello from client"(16字节)
  2. 服务端收到后,调用send_data_to_client(..., "Server ACK", 10)
  3. 客户端recvfrom()应收到该字符串
    此改造将项目从“单向演示”升级为“可交互协议栈”,为后续实现HTTP简易服务器打下基础。

从那以后我每次做网络协议教学项目,都强制走一遍tcpdump -i lo -nn -X抓包验证,哪怕只是改了一个htons()。因为TCP的真相不在代码注释里,而在Wireshark那一行行十六进制中——那里没有魔法,只有字节序、校验和、和你亲手填满的每一个bit。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询