中南大学计网实验:手撕TCP/IP协议栈源码解析
简介本资源为中南大学计算机网络课程配套实验源代码包面向高校计算机及相关专业学生与网络原理自学者聚焦TCP/IP协议栈实践与Socket编程能力训练。压缩包共53个文件含12个C源码.c、10个头文件.h、12个目标文件.o、1个Makefile及Shell脚本.sh辅以实验指导书.docx、运行结果图.png/.jpg和HTML示例页整体1.06MB结构完整覆盖编译、调试与验证全流程。已有743人学习下载资源包含A1基础Socket通信client/server双端实现与异常处理和A3应用层协议模拟HTTP交互、报文解析与服务器逻辑两大核心实验的全量代码及配套文档。读者可直接编译运行、对照指导书理解分层协议实现细节并借助Wireshark抓包与日志调试深化对连接建立、数据传输与错误恢复机制的认知。1. 中南大学计算机网络实验源代码不是“抄作业包”而是 TCP/IP 协议栈的可执行黑匣子你打开Makefile敲下make终端里跳出server.o client.o network_io_socket.o一串编译成功提示——但./server一运行就卡住client连不上Wireshark 抓不到 SYN 包你翻遍mysock.h和stcp_api.c发现注释全是英文缩写demux是什么stcp真是“simple TCP”还是教学魔改版tcp_sum.c里那个cksum()函数算出来的校验和跟 RFC 1071 对不上……这不是一份“能跑就行”的实验代码这是中南大学计网课用 C 语言手撕 TCP/IP 协议栈的硬核切片它把《计算机网络自顶向下》第七章的抽象框图变成了一堆.c/.h文件里可单步调试、可打桩验证、可修改重编译的真实字节流。它适合两类人一类是刚学完 socket 编程、对着bind()返回 -1 感到窒息的大二学生另一类是想搞懂“三次握手到底在代码里哪几行触发”的考研党或嵌入式初学者。它不教你怎么配环境但会逼你亲手填满struct sockaddr_in的每个字段它不讲 OSI 分层理论但让你在connection_demux.c里亲眼看见一个 raw socket 如何把收到的 IP 包按端口号分发给不同mysock实例。这份资源的价值不在“有代码”而在“所有函数调用链都暴露在头文件里没有黑盒库”。2. A1 实验从network_io_socket.c到server.c手撸一个可调试的 TCP 回显服务A1 实验是整个计网实验的基石它不实现完整 TCP而是用 Linux 原生 socket API 构建一个可控、可观察、可打断点的通信骨架。核心在于理解socket()→bind()→listen()→accept()→recv()→send()这条主干链路中每一步对内核协议栈的实际触发动作。中南大学这份代码没用epoll或select全部基于阻塞式 socket反而更利于初学者跟踪状态流转。2.1network_io_socket.c封装底层 socket 调用暴露关键错误码这个文件是 A1 的入口胶水层它把系统调用包装成带日志和错误处理的函数// network_io_socket.c int netio_socket(int domain, int type, int protocol) { int sockfd socket(domain, type, protocol); if (sockfd 0) { perror(netio_socket: socket() failed); return -1; } // 关键设置 SO_REUSEADDR避免 TIME_WAIT 导致 bind 失败 int optval 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, optval, sizeof(optval)); return sockfd; }提示SO_REUSEADDR是 A1 实验高频踩坑点。若不设此选项server 进程异常退出后残留 TIME_WAIT 状态再次make run会报bind: Address already in use。这不是端口被占而是内核在等 2MSL约 60 秒——加这行立刻解决。该文件还实现了netio_bind()、netio_listen()等函数统一返回-1表示失败并打印具体错误如EADDRINUSE、EACCES比裸调socket()更易定位问题。2.2server.c阻塞式 accept recv/send 循环附带超时控制server.c是 A1 的主逻辑它不追求高并发只求状态清晰// server.c int main(int argc, char *argv[]) { int sockfd netio_socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in servaddr; bzero(servaddr, sizeof(servaddr)); servaddr.sin_family AF_INET; servaddr.sin_addr.s_addr htonl(INADDR_ANY); // 绑定本机所有 IP servaddr.sin_port htons(8080); // 端口必须 htons() if (netio_bind(sockfd, (struct sockaddr*)servaddr, sizeof(servaddr)) 0) exit(1); if (netio_listen(sockfd, 5) 0) // backlog5 exit(1); printf(Server listening on port 8080...\n); while (1) { struct sockaddr_in cliaddr; socklen_t clilen sizeof(cliaddr); int connfd accept(sockfd, (struct sockaddr*)cliaddr, clilen); if (connfd 0) { perror(accept failed); continue; // 不退出继续监听 } // 关键为每个连接启动独立处理非多线程纯阻塞 handle_client(connfd); close(connfd); } close(sockfd); return 0; }handle_client()函数里recv()默认阻塞直到客户端发来数据或断开连接。send()后不检查返回值错——代码里实际有if (n ! strlen(buf))判断确保全量发送。这是教学代码的严谨之处它不省略关键错误分支强迫你思考send()可能只发一半数据的场景。2.3client.c模拟真实请求支持命令行传参与多轮交互client.c不是简单connect()send()recv()三连它支持-h host -p port -m message参数并实现循环发送// client.c int main(int argc, char *argv[]) { int sockfd; struct sockaddr_in servaddr; char *host 127.0.0.1; int port 8080; char *msg Hello from client; // 解析命令行参数省略 getopt 部分 // ... sockfd netio_socket(AF_INET, SOCK_STREAM, 0); bzero(servaddr, sizeof(servaddr)); servaddr.sin_family AF_INET; servaddr.sin_port htons(port); if (inet_pton(AF_INET, host, servaddr.sin_addr) 0) { fprintf(stderr, inet_pton error for %s\n, host); exit(1); } if (connect(sockfd, (struct sockaddr*)servaddr, sizeof(servaddr)) 0) { perror(connect failed); exit(1); } // 发送消息 if (send(sockfd, msg, strlen(msg), 0) ! strlen(msg)) { perror(send failed); exit(1); } // 接收回显 char buf[1024]; int n recv(sockfd, buf, sizeof(buf)-1, 0); if (n 0) { buf[n] \0; printf(Server echo: %s\n, buf); } else if (n 0) { printf(Server closed connection\n); } else { perror(recv failed); } close(sockfd); return 0; }注意inet_pton()替代了过时的inet_addr()支持 IPv4/IPv6 统一转换send()后立即recv()构成严格请求-响应模型便于 Wireshark 抓包分析 RTT。2.4Makefile精简但完备的构建规则暴露编译依赖链这份Makefile是理解 A1 编译流程的钥匙它没有隐藏任何中间步骤# Makefile CC gcc CFLAGS -Wall -g -stdc99 OBJS server.o client.o network_io_socket.o server: $(OBJS) $(CC) $(CFLAGS) -o server server.o network_io_socket.o client: $(OBJS) $(CC) $(CFLAGS) -o client client.o network_io_socket.o %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f *.o server client run: server client ./server sleep 0.5 ./client%.o: %.c规则明确.c→.o的编译过程-g保证调试信息server目标只链接server.o和network_io_socket.o不拉入client.o体现模块隔离run目标用启动 server 后sleep 0.5确保 server 已进入listen()状态再启动 client避免 client 连接拒绝Connection refused。3. A3 实验stcp_api.c与transport.c深度解析——教学版 TCP 协议栈的七层拆解A3 实验是整套资源的技术制高点它不调用socket()而是用 raw socketAF_PACKET或libpcap直接收发以太网帧自己实现 IP 分组、TCP 段封装、校验和计算、滑动窗口管理、超时重传。stcp即 “Simple TCP”是中南大学为教学定制的轻量级 TCP 子集去掉了拥塞控制、快速重传等复杂机制但保留了三次握手、ACK 确认、序号/确认号维护、超时重传等核心逻辑。它的价值在于把 TCP 协议栈从“内核黑盒”变成“用户态可调试代码”。3.1stcp_api.c定义stcp_socket()/stcp_connect()/stcp_send()等高层接口stcp_api.c是 A3 的门面它模仿 BSD socket API但背后是完全自研的传输层// stcp_api.c int stcp_socket(int domain, int type, int protocol) { // 创建 raw socket用于收发以太网帧 int sockfd socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); if (sockfd 0) { perror(stcp_socket: socket(AF_PACKET) failed); return -1; } // 获取本机 MAC 地址和 IP 地址从 /proc/net/arp 或 ioctl SIOCGIFHWADDR get_local_mac_ip(local_mac, local_ip); // 初始化全局 stcp 控制块类似内核的 inet_hashinfo init_stcp_control_block(); return sockfd; } int stcp_connect(int sockfd, const struct sockaddr_in *servaddr, socklen_t addrlen) { // 1. 构造 SYN 段IP头TCP头无数据 // 2. 计算 TCP 校验和含伪首部 // 3. 发送 SYN 到目标 IP/MAC // 4. 启动定时器等待 SYN-ACK // 5. 收到 SYN-ACK 后发送 ACK完成握手 return stcp_handshake(sockfd, servaddr); }关键点stcp_socket()返回的sockfd不是传统 socket而是 raw socket fd后续所有stcp_*调用都基于此 fd 操作。stcp_connect()内部调用stcp_handshake()后者才是三次握手的真正实现者。3.2transport.cTCP 段构造、校验和计算与滑动窗口管理transport.c是 A3 的心脏它包含build_tcp_segment()、tcp_checksum()、update_window()等函数// transport.c uint16_t tcp_checksum(const struct iphdr *iph, const struct tcphdr *tcph, const void *data, size_t data_len) { uint32_t sum 0; const uint16_t *ptr; // 1. 伪首部12字节源IP目的IP0协议TCP长度 struct pseudo_hdr { uint32_t src_ip; uint32_t dst_ip; uint8_t zeros; uint8_t proto; uint16_t tcp_len; } ph; ph.src_ip iph-saddr; ph.dst_ip iph-daddr; ph.zeros 0; ph.proto IPPROTO_TCP; ph.tcp_len htons(sizeof(struct tcphdr) data_len); ptr (const uint16_t *)ph; sum *ptr; sum *ptr; sum *ptr; sum *ptr; sum *ptr; // tcp_len // 2. TCP 首部20字节 ptr (const uint16_t *)tcph; for (int i 0; i 10; i) { // 10 words 20 bytes sum ntohs(*ptr); } // 3. TCP 数据data_len 字节 ptr (const uint16_t *)data; for (size_t i 0; i data_len / 2; i) { sum ntohs(*ptr); } if (data_len 1) { // 奇数长度补0 uint16_t pad ((uint8_t*)data)[data_len-1] 8; sum pad; } // 4. 反码求和 while (sum 16) { sum (sum 0xFFFF) (sum 16); } return ~sum; }血泪经验RFC 1071 校验和算法要求“反码求和”但新手常漏掉最后一步~sum导致校验和永远为 0。tcp_checksum()函数里while (sum 16)循环是关键它把进位加回低 16 位再取反才是正确值。Wireshark 抓包时若看到 “TCP Checksum Incorrect”八成是这里没取反。3.3connection_demux.cIP 包分用器——如何把混杂模式抓到的包路由给正确 socketconnection_demux.c解决的是多路复用问题一个 raw socket 收到所有以太网帧如何判断哪个帧属于哪个stcp_socket连接答案是哈希表 五元组匹配// connection_demux.c struct stcp_conn { uint32_t saddr; // 源IP uint32_t daddr; // 目的IP uint16_t sport; // 源端口 uint16_t dport; // 目的端口 int sockfd; // 关联的 stcp_socket fd struct stcp_conn *next; }; // 全局哈希表简化版 #define HASH_SIZE 256 static struct stcp_conn *conn_hash[HASH_SIZE]; int demux_packet(const struct iphdr *iph, const struct tcphdr *tcph) { uint32_t hash_key (iph-saddr ^ iph-daddr ^ tcph-source ^ tcph-dest) % HASH_SIZE; struct stcp_conn *conn conn_hash[hash_key]; while (conn) { if (conn-saddr iph-daddr conn-daddr iph-saddr conn-sport ntohs(tcph-dest) conn-dport ntohs(tcph-source)) { // 找到匹配连接将数据交给其接收缓冲区 deliver_to_conn(conn, tcph, (void*)(tcph1), ntohs(tcph-doff)*4); return 0; } conn conn-next; } return -1; // 未找到匹配连接 }玄学时刻哈希 key 用saddr^daddr^sport^dport而非简单相加是为了避免192.168.1.1:1234和192.168.1.2:1233碰撞。实际教学中老师会要求你把demux_packet()打印日志观察哈希冲突率——这是理解网络协议栈性能瓶颈的第一课。3.4mysock.c与mysock_impl.h用户态 socket 的状态机与缓冲区管理mysock.c封装了每个stcp_socket实例的状态STCP_STATE_CLOSED,STCP_STATE_SYN_SENT,STCP_STATE_ESTABLISHED和收发缓冲区// mysock.c struct mysock { int state; uint32_t local_ip; uint16_t local_port; uint32_t remote_ip; uint16_t remote_port; uint32_t snd_nxt; // 下一个要发送的序号 uint32_t rcv_nxt; // 下一个期望接收的序号 uint16_t snd_wnd; // 发送窗口大小 uint16_t rcv_wnd; // 接收窗口大小 struct circular_buffer *snd_buf; // 发送环形缓冲区 struct circular_buffer *rcv_buf; // 接收环形缓冲区 struct timer *retrans_timer; // 重传定时器 }; int mysock_send(struct mysock *ms, const void *buf, size_t len) { if (ms-state ! STCP_STATE_ESTABLISHED) { errno ENOTCONN; return -1; } // 将数据拷贝到发送缓冲区 int copied cb_write(ms-snd_buf, buf, len); if (copied len) { errno EAGAIN; return -1; } // 触发发送可能分段 stcp_output(ms); return copied; }circular_buffer是自定义环形队列cb_write()保证线程安全虽 A3 无多线程但结构已预留。stcp_output()是真正的发送引擎它从snd_buf取数据、分段、构造 TCP 段、计算校验和、调用sendto()发送——这才是stcp_send()的灵魂。4. 避坑指南A1/A3 实验中 5 个真实翻车现场与后悔药这些坑是我当年在中南大学信科院机房熬通宵 debug 时用printf和 Wireshark 一帧一帧抠出来的。它们不是文档里写的“注意事项”而是你make run后屏幕变红时最想立刻看到的答案。4.1 现象server启动报bind: Address already in usenetstat -tuln | grep 8080查不到进程原因server进程崩溃退出但 socket 进入TIME_WAIT状态默认 60 秒内核禁止新绑定同一端口。这不是端口被占而是 TCP 状态机保护机制。解决在netio_bind()中强制设置SO_REUSEADDR见 2.1 节代码或改用临时端口htons(0)让内核分配。切记SO_REUSEADDR必须在bind()前设置bind()后设无效。4.2 现象client执行connect()返回Connection refusedWireshark 显示无 SYN 包原因client.c中servaddr.sin_port未用htons()转换导致端口号是主机字节序如 8080 在小端机上存为0x10000320connect()试图连一个不存在的端口。解决所有sin_port赋值必须htons(port)所有ntohs()用于从网络包中读取端口。这是 socket 编程铁律htons()不是可选项是必选项。4.3 现象A3 的stcp_connect()卡死Wireshark 抓到 SYN 但无 SYN-ACK原因stcp_api.c中get_local_mac_ip()失败导致构造的以太网帧目的 MAC 为00:00:00:00:00:00交换机丢弃。常见于虚拟机未桥接或AF_PACKET权限不足。解决先用ip link show确认网卡名如ens33再用sudo ip neigh show查目标 IP 对应的 MAC若无手动ping一下目标 IP 触发 ARP最后sudo chmod us ./stcp_server或用sudo setcap cap_net_rawep ./stcp_server授予 raw socket 权限。4.4 现象A3 的tcp_checksum()计算结果与 Wireshark 显示不符校验和字段为0x0000原因RFC 1071 要求校验和计算前TCP 头部的check字段必须置 0。若未清零tcp_checksum()会把旧校验和也参与计算结果必然错。解决在调用tcp_checksum()前务必tcph-check 0计算完毕再赋值tcph-check htons(sum)。这是协议栈实现中最隐蔽的坑Wireshark 会直接标红 “Bad TCP checksum”。4.5 现象make clean后make报fatal error: network.h: No such file or directory原因network.h是network_io_socket.h的别名或旧版头文件但Makefile里CFLAGS未加-I.编译器找不到当前目录下的头文件。解决修改Makefile在CFLAGS中加入-I.即CFLAGS -Wall -g -stdc99 -I.或统一使用#include network_io_socket.h而非#include network.h。教训头文件路径是 make 构建系统的命脉-I选项漏一个整个项目瘫痪。5. 进阶技巧用 Wireshark GDB 双向验证 TCP 状态机让协议栈“开口说话”光跑通server/client只是入门真正吃透 A1/A3 的标志是能用 Wireshark 看懂每一帧用 GDB 跟进每一个状态跳转。我当年在信科院 302 机房就是靠这套组合拳把stcp_state_machine()里的STCP_STATE_SYN_RCVD到STCP_STATE_ESTABLISHED的跃迁从纸面概念变成了内存变量的实时变化。5.1 Wireshark 过滤与着色聚焦三次握手与数据流启动server和client后在 Wireshark 中设置显示过滤器精准捕获实验流量过滤器说明用途tcp.port 8080只显示 8080 端口的 TCP 流量快速定位 A1 实验tcp.flags.syn 1 and tcp.flags.ack 0只显示 SYN 包验证stcp_connect()是否发出握手ip.addr 192.168.1.100 and tcp.port 8080限定本机 IP 和端口避免虚拟机/容器干扰tcp.stream eq 0显示第 0 号 TCP 流Wireshark 自动编号聚焦单次连接的完整交互技巧右键某 SYN 包 → “Follow” → “TCP Stream”Wireshark 会自动重组该连接的全部应用层数据直观对比client.c发送的Hello和server.c回显的Hello是否一致。这是验证recv()/send()正确性的黄金标准。5.2 GDB 调试stcp_handshake()在关键状态点打断点A3 的stcp_handshake()是状态机核心用 GDB 单步是理解其逻辑的唯一途径# 编译时确保 -g $ gcc -g -Wall stcp_api.c transport.c connection_demux.c -o stcp_server # 启动 GDB $ gdb ./stcp_server (gdb) break stcp_handshake (gdb) break stcp_state_machine (gdb) run # 当停在 stcp_handshake 时查看关键变量 (gdb) print *ms (gdb) print ms-state (gdb) print ms-snd_nxt (gdb) print ms-rcv_nxt在stcp_state_machine()中你会看到类似这样的状态跳转// stcp_api.c void stcp_state_machine(struct mysock *ms, struct tcphdr *tcph) { switch (ms-state) { case STCP_STATE_SYN_SENT: if (tcph-syn tcph-ack) { // 收到 SYN-ACK ms-state STCP_STATE_ESTABLISHED; ms-snd_nxt ntohl(tcph-ack_seq) 1; // 更新发送序号 ms-rcv_nxt ntohl(tcph-seq) 1; // 更新接收序号 send_ack(ms); // 发送 ACK 完成握手 } break; // ... 其他状态 } }GDB 中print ms-state能实时看到状态从SYN_SENT→ESTABLISHED的跃迁print ms-snd_nxt则验证序号是否按 RFC 793 正确更新。这才是“看懂 TCP”的瞬间——它不再是书上的文字而是你键盘上nnext命令驱动的内存变化。5.3tcp_sum.c校验和验证用 Python 脚本交叉验证tcp_sum.c的cksum()函数是 A3 的数学核心但手算易错。我写了一个 Python 脚本用相同算法计算校验和与 C 版本比对# verify_cksum.py import struct import sys def tcp_checksum(ip_src, ip_dst, proto, tcp_len, tcp_header, datab): # 伪首部 ph struct.pack(!LLBBH, ip_src, ip_dst, 0, proto, tcp_len) # TCP 头部20字节 th tcp_header[:20] # 数据补齐偶数 if len(data) % 2 ! 0: data b\x00 # 拼接 packet ph th data # 计算校验和16位反码求和 sum_val 0 for i in range(0, len(packet), 2): word struct.unpack(!H, packet[i:i2])[0] sum_val word if sum_val 0xFFFF: sum_val (sum_val 0xFFFF) (sum_val 16) return ~sum_val 0xFFFF # 示例用 Wireshark 导出的 TCP 头部十六进制字符串 tcp_hex 4500003c0000400040060000c0a80164c0a80101 # ... 此处省略完整头部实际需从 Wireshark 复制 # 调用验证 result tcp_checksum(0xc0a80164, 0xc0a80101, 6, 40, bytes.fromhex(tcp_hex)) print(fPython calc cksum: 0x{result:04x})运行此脚本输出0xXXXX与 Wireshark 显示的 TCP 校验和字段对比若一致则证明tcp_sum.c逻辑正确若不一致立刻回头检查tcp_checksum()中伪首部构造或字节序处理。5.4Makefile进阶添加debug目标与符号表剥离为了高效调试我在原始Makefile上加了两个实用目标# Makefile 增强版 debug: CFLAGS -DDEBUG -O0 debug: $(OBJS) $(CC) $(CFLAGS) -o server_debug server.o network_io_socket.o strip: server strip --strip-debug server .PHONY: debug stripmake debug编译带DEBUG宏和-O0禁用优化的版本GDB 单步不会跳行make strip剥离server的调试符号生成小体积可执行文件用于提交。从那以后我每次make前都强制走一遍make clean make debug再gdb ./server_debug。因为优化过的代码会让print ms-state显示乱码而DEBUG宏能开启stcp_api.c里的printf([DEBUG] state%d\n, ms-state)日志——这是比 GDB 更快的状态感知方式。希望帮到你。本文还有配套的精品资源点击获取