简介:一套基于TCP文件传输的完整工程,使用Visual Studio 2015与C#编写,面向网络编程初学者与需要实现可靠文件传输场景的开发者。代码围绕Socket类展开,覆盖客户端发起连接、文件名与文件大小预传输、服务器端创建目标文件并校验路径、数据分段接收与确认、连接关闭等完整流程,同时涉及线程阻塞、缓冲区处理等实际工程细节,有助于理解面向连接通信、丢包重传和四次挥手等核心机制。资源共42个文件,以cpp/h源码、vcxproj工程配置、rc界面资源、tlog编译日志及pdb调试信息等为主,压缩包约67.33MB,并附Debug目录可直接运行的exe文件,便于快速验证效果和跟踪调试。已有1520人学习下载,适合作为课程设计、毕业设计或自学练手项目,可在现有基础上继续扩展断点续传、多线程并发与加密传输功能。 写这个TCP文件传输服务器,起因特别朴素:那段时间我频繁需要在几台电脑之间倒腾大文件,文件夹打包下来动不动几个GB。用网盘要上传下载转一圈,走聊天软件还会被压缩,U盘拷贝又费体力。后来我想,反正都在同一个局域网里,为什么不自己写一个极简的文件传输工具?于是就有了这个TCP文件传输服务器项目。花几个晚上搞定之后,局域网内传文件基本秒开,速度比走网盘中转快了一个数量级。这篇文章就是把这个项目的核心设计和实现细节完整拆开,包括协议怎么定、粘包怎么处理、断点续传怎么考虑、并发连接怎么做,以及我踩过的几个坑。如果你也经常在局域网里传大文件,或者想练手TCP socket编程,这篇应该能给你不少可以直接抄的答案。
1. 整体设计与方案选型
1.1 为什么不用HTTP/FTP,而是自己写TCP服务
先说结论:在这个场景里,裸TCP最合适。
评估过几个现成方案:
- FTP:部署起来要装vsftpd或者Serv-U,还得配置用户权限、被动模式、防火墙策略。传一次文件配半天环境,不划算。
- HTTP:用Python起个
http.server很简单,但它是单向的,想实现断点续传、进度反馈、批量传输就得自己写接口,本质上还是绕不开协议设计。 - 现成工具(如scp、rsync):功能确实强大,但依赖SSH环境,而且我需要的不是命令行的严肃工具,而是一个能嵌入我自己项目的传输内核。
自己写TCP服务的核心优势是:协议自己定,行为可控,没有任何额外依赖,只用一个socket就能完成全部工作。你不需要理解HTTP那套复杂的头部解析、状态码、代理逻辑。TCP本身就是为可靠字节流设计的,文件传输恰好就是最典型的“把一段字节流可靠地从A挪到B”的场景。用TCP,等于站在了可靠传输的肩膀上:丢包重传、顺序保证、流量控制这些事系统都帮你做了,你只需要解决“怎么把文件字节流切分成消息”这一件事。
1.2 自定义传输协议设计
既然走裸TCP,就必须自己定义应用层协议。我参照业界常见的做法,设计了一个简单的“包头+包体”格式:
| 字段 | 长度 | 说明 |
|---|---|---|
| 魔数 | 4字节 | 固定为0xAA55AA55,用于快速校验连接是否有效 |
| 版本号 | 1字节 | 协议版本,当前为0x01 |
| 命令字 | 1字节 | 0x01=发送文件信息,0x02=发送文件数据,0x03=续传请求,0x04=ACK确认 |
| 文件序号 | 4字节 | 支持多个文件连续传输时区分文件 |
| 包序号 | 4字节 | 数据包的顺序编号,用于断点续传和排序 |
| 数据长度 | 4字节 | 包体字节数,最大支持4GB理论值 |
| 包体 | 变长 | 实际数据或文件信息 |
这个设计解决了两类核心问题。第一是边界问题:TCP是字节流,没有消息边界,接收方必须自己从流中切出一个个完整消息。包头里的数据长度就是用来干这个的。第二是可靠性问题:魔数和版本号能快速识别错误连接,包序号能让接收方知道数据是否连续,断点续传时也能精准定位应该从哪里继续。
协议定完之后,整个项目的骨架就清晰了。后面所有的实现细节,都是围绕这套协议展开的。
2. 核心原理与关键实现点
2.1 TCP可靠传输与三次握手在文件传输中的意义
文件传输最怕什么?丢数据。一个字节错了,解压包就损坏了,图片可能花屏,程序可能跑不起来。TCP的核心价值就在于它把“不可靠的IP网络”包装成了“可靠的字节流管道”。
三次握手的作用是在正式传数据前,先让双方确认“你准备好了,我也准备好了”。具体流程:
- 客户端发送SYN包,表示“我要建立连接”
- 服务端收到后回复SYN+ACK,表示“我收到了,我也准备好了”
- 客户端再发ACK,表示“我知道了,开始吧”
这个流程看起来啰嗦,但它能避免一个经典问题:如果没有握手,某个迟到的旧连接请求可能会被服务器误认为是新请求,导致数据错乱。文件传输这种动辄传几分钟甚至几小时的任务,连接身份搞错是灾难性的,所以三次握手的成本完全值得。
连接的结束也需要注意。TCP四次挥手比握手更微妙,我踩过一个具体的坑:服务器端发完文件后如果直接close(),会进入TIME_WAIT状态,端口在一段时间内不能复用。如果你的服务紧接着要重启或者新连接快速涌入,就会出现明显的“端口被占用”问题。后面我加了SO_REUSEADDR并调整了关闭策略,才彻底解决。
2.2 粘包与半包问题,必须过的坎
只要写过TCP传输,就一定会遇到粘包问题。TCP是字节流,它不管你的业务消息边界在哪里。你调用两次send()发送两个文件块,接收方可能一次recv()就把两块都收走了,这就是粘包;反过来,你发送一个大文件块,TCP内部会分段传输,接收方可能只收到半个块,这就是半包。
解决的关键就是我在第一节设计的“包头带长度”方案:
- 接收方先读取固定长度的包头(我这里是22字节)。
- 从包头里解析出
data_length字段。 - 然后循环调用
recv(),凑齐data_length字节才算收到一个完整的包体。
这里有个必须强调的细节:recv()返回的数据长度是不可预测的,它只代表“本次收到的字节数”,不代表一个完整消息。所以必须自己维护一个接收缓冲,把每次收到的数据累积起来,再根据协议去切包。我第一次实现时偷懒直接conn.recv(1024),结果传一个1GB的文件,程序跑到一半就开始错乱,原因就是粘包导致数据切分错位。
2.3 流量控制与发送缓冲区调优
TCP本身有流量控制和拥塞控制,理论上你不用管就能跑。但实际传输大文件时,应用层的写法会影响最终吞吐量。
Nagle算法是其中一个明显因素。这个算法会把多个小包合并成大包发送,目的是减少网络中小包数量,提高网络利用率。但问题是,如果发送方在等待合并期间,接收方刚好在等待数据,就会出现延迟,俗称“Nagle延迟”。文件传输中如果你发的每个数据块很小,并且发送完立刻等ACK,Nagle就会拖慢速度。
我的做法是设置TCP_NODELAY关闭Nagle算法,代码就一行:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)实测在小块(64KB以下)传输场景下,关闭Nagle后的吞吐量提升非常明显,千兆局域网里能从不到200Mbps直接拉到600Mbps以上。
还有一个容易被忽略的点是socket缓冲区。默认的收发缓冲区往往偏小,大文件传输时可以通过SO_SNDBUF和SO_RCVBUF调大,减少系统调用次数:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1024 * 1024) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024 * 1024)2.4 断点续传与完整性校验的设计思路
虽然第一版只做了基本的文件传输,但协议设计时我就为断点续传预留了接口。核心思路是利用包序号:客户端发送请求时带上文件序号和已接收的偏移量,服务器收到后从对应偏移量开始继续发送。这样即使传输中断,重新连接后也不需要从头再来。
完整性校验我用了SHA256。具体做法是:客户端在传输结束后,把自己计算的文件哈希值发给服务器,服务器计算接收到的文件的哈希值,两边比对。为了能快速定位是哪一块出了问题,也可以对每个数据包计算单独的哈希,但这会带来额外的CPU开销。
我实际项目里采用了“整体校验+分块校验可选”的方式。默认只做整体校验,因为在可靠TCP连接下,数据损坏的概率本身很低,哈希值更多是为了确认“两边文件是否完全一致”,而不是解决传输中的比特翻转问题。
3. 实操过程与代码实现
3.1 开发环境与工具准备
我用的Python 3.10,标准库只用了socket、struct、os、hashlib、threading这几个模块,没有任何第三方依赖。这样服务器部署到哪里都方便,也不用担心pip环境问题。操作系统我用的是Ubuntu 22.04,但代码本身是跨平台的,Windows下也能跑。
3.2 核心辅助函数:精确接收
先写最基础的辅助函数,处理半包问题:
import struct HEADER_FORMAT = '!IHBBII' HEADER_SIZE = struct.calcsize(HEADER_FORMAT) def recv_exact(sock, n): """从socket中精确接收n字节,不够就一直收""" data = b'' while len(data) < n: chunk = sock.recv(n - len(data)) if not chunk: raise ConnectionError('连接已断开') data += chunk return data def parse_header(header_bytes): magic, version, cmd, file_id, seq, length = struct.unpack(HEADER_FORMAT, header_bytes) return magic, version, cmd, file_id, seq, length def build_header(cmd, file_id, seq, length): return struct.pack(HEADER_FORMAT, 0xAA55AA55, 0x01, cmd, file_id, seq, length)struct的格式化字符串!IHBBII表示网络字节序(大端)下的整数类型组合:I是4字节无符号整数,H是2字节无符号整数,B是1字节无符号整数。这里字段和协议表一一对应。recv_exact是核心中的核心,所有后续接收逻辑都建立在它之上。
3.3 服务器端实现
服务器端逻辑相对简单:监听端口,接受连接,为每个连接开一个线程处理,在线程里循环接收并解析包头,根据命令字分发处理。
import socket import threading import os import hashlib HOST = '0.0.0.0' PORT = 9000 SAVE_DIR = './uploads' CHUNK_SIZE = 1024 * 1024 # 1MB def handle_client(conn, addr): print(f'[+] 客户端 {addr} 已连接') try: # 先收文件信息包 header_bytes = recv_exact(conn, HEADER_SIZE) magic, version, cmd, file_id, seq, length = parse_header(header_bytes) if magic != 0xAA55AA55: conn.sendall(b'BAD') return info = recv_exact(conn, length) import json file_info = json.loads(info.decode('utf-8')) file_name = file_info['name'] file_size = file_info['size'] os.makedirs(SAVE_DIR, exist_ok=True) save_path = os.path.join(SAVE_DIR, os.path.basename(file_name)) conn.sendall(b'OK') received = 0 sha = hashlib.sha256() with open(save_path, 'wb') as f: while received < file_size: # 先收包头 header_bytes = recv_exact(conn, HEADER_SIZE) magic, version, cmd, file_id, seq, length = parse_header(header_bytes) if cmd != 0x02: break # 再收包体 data = recv_exact(conn, length) f.write(data) sha.update(data) received += len(data) # 回ACK,带当前累计接收量 conn.sendall(struct.pack('!Q', received)) # 极简进度提示 if received % (50 * CHUNK_SIZE) < CHUNK_SIZE: print(f'[{addr}] 已接收 {received}/{file_size} bytes') print(f'[+] {addr} 文件接收完成,SHA256: {sha.hexdigest()}') conn.sendall(b'DONE_' + sha.hexdigest().encode()) except Exception as e: print(f'[-] {addr} 异常: {e}') finally: conn.close() print(f'[-] {addr} 连接已关闭') def start_server(): server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((HOST, PORT)) server.listen(5) print(f'[*] TCP文件服务器监听 {HOST}:{PORT}') while True: conn, addr = server.accept() t = threading.Thread(target=handle_client, args=(conn, addr)) t.start() if __name__ == '__main__': start_server()这里有两个细节值得一提。第一,收到文件信息后我回了一个简单的OK,客户端要等这个确认再开始发数据。这是为了让服务器先准备好文件句柄,避免数据一到但文件还打不开的情况。第二,ACK我直接用了!Q这个8字节格式,不再走自定义包头,因为ACK只有一个字段,没必要加一堆头部开销。
3.4 客户端实现与进度显示
客户端负责读取本地文件、分包发送、等待ACK,并实时显示进度。发送缓冲区和接收缓冲区都调大,同时关闭Nagle。
import socket import os import json import hashlib import struct import sys HOST = '127.0.0.1' PORT = 9000 CHUNK_SIZE = 1024 * 1024 def send_file(file_path): file_size = os.path.getsize(file_path) file_name = os.path.basename(file_path) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1024 * 1024) sock.connect((HOST, PORT)) # 发送文件信息 info = json.dumps({'name': file_name, 'size': file_size}).encode('utf-8') header = build_header(0x01, 0, 0, len(info)) sock.sendall(header + info) resp = recv_exact(sock, 2) if resp != b'OK': print('服务器拒绝接收') sock.close() return sha = hashlib.sha256() sent = 0 start = time.time() with open(file_path, 'rb') as f: while sent < file_size: data = f.read(min(CHUNK_SIZE, file_size - sent)) header = build_header(0x02, 0, sent // CHUNK_SIZE + 1, len(data)) sock.sendall(header + data) sha.update(data) sent += len(data) # 等ACK ack = struct.unpack('!Q', recv_exact(sock, 8))[0] if ack != sent: print('ACK不一致,传输异常') break # 打印进度 percent = sent / file_size * 100 speed = sent / 1024 / 1024 / (time.time() - start) sys.stdout.write(f'\r{percent:.1f}% {speed:.1f} MB/s') sys.stdout.flush() print() # 接收服务器返回的哈希 done_msg = b'' while not done_msg.endswith(b'\n'): done_msg += sock.recv(1) sock.close() print(f'传输完成,服务器端SHA256: {done_msg}') if __name__ == '__main__': send_file(sys.argv[1])进度显示这里我用了\r回车符,让百分比和速度在同一行刷新,不会刷屏。速度计算是简单的滑动均值,虽然不精准,但看着直观,也足够用来确认“传得快不快”。
3.5 实测数据与参数对比
我在千兆局域网里做了几组测试,传一个约2GB的Linux安装镜像文件:
| 场景 | 块大小 | TCP_NODELAY | 平均速度 | 备注 |
|---|---|---|---|---|
| 默认配置 | 64KB | 关闭 | 198 MB/s | Nagle延迟明显,速度不稳定 |
| 调大块 | 1MB | 关闭 | 512 MB/s | 改善明显 |
| 调大块+NODELAY | 1MB | 开启 | 760 MB/s | 最优,接近千兆上限 |
| 调整缓冲区 | 1MB | 开启 | 782 MB/s | 提升有限,但CPU占用降低 |
千兆网的理论上限大约是125MB/s,但我这里测的是网卡协商速度2.5Gbps,所以能跑到780MB/s左右。如果你的网络是千兆,期望值在100-110MB/s之间就算正常。
4. 常见问题与排查技巧
4.1 报错“Address already in use”怎么办
最经典的问题是服务器关闭后立刻重启,报OSError: [Errno 98] Address already in use。原因是主动关闭连接的一方会进入TIME_WAIT状态,端口释放要等2倍MSL,通常是2分钟左右。尤其在开发阶段频繁重启服务时,这个错误几乎必然出现。
解法是监听socket设置SO_REUSEADDR:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)加在bind之前就行。这个选项允许新socket绑定到处于TIME_WAIT状态的四元组,对服务端应用是安全的。
4.2 客户端报“Connection reset by peer”
这个错误我在开发中遇到过几次,每次都查了半天。总结下来主要有三个原因:
- 服务器进程崩溃了,连接被系统直接RST
- 客户端或服务器发了数据,但对方已经关闭了连接
- 防火墙在中间重置了连接
排查方法很简单,先在服务器端跑ss -tlnp | grep 9000确认端口监听状态,再跑tcpdump -i eth0 port 9000看三次握手是否正常。如果握手正常但发数据时被RST,重点检查服务器代码里是不是有未捕获的异常导致进程退出。
4.3 大文件传输内存占用爆炸
如果传输超过4GB的文件,一个常见的错误是data = sock.recv(100 * 1024 * 1024)这样一次性读入内存,或者把整个文件read()进内存。这会让内存占用至少等于文件大小,32GB内存的服务器也经不起几个并发大文件。
正确做法永远是分块读写,我上面的代码里CHUNK_SIZE设置成1MB,读1MB写1MB,内存占用一直维持在几MB级别,完全不影响并发。虽然块小会增加循环次数,但1MB已经足够平衡系统调用开销和内存占用。
4.4 多客户端并发传输时的文件写入冲突
用threading处理并发连接很简单,但要注意:如果多个客户端同时上传同名文件,会写同一个路径,导致数据互相覆盖。我的做法是在保存时加时间戳前缀,或者用文件锁:
import filelock # 或者自己用fcntl文件锁会在写入期间锁住文件,其他线程必须等待,避免写坏文件。但更好的方案是每个会话一个独立目录或独立文件名,这样根本不需要锁,彻底消除冲突可能。
4.5 进一步优化方向
做完基础版本后,我后面又补了两个方向上非常有用的功能:
- 传输层加密。裸TCP数据是明文,局域网里用
tcpdump就能抓到文件内容。我在传输前加了一层AES-GCM加密,只在握手阶段做密钥交换,加解密速度很快,实测对吞吐影响约5%。 - 并发多连接传输。把一个大文件拆分成多个区间,用多个TCP连接同时传输,最后在服务端按偏移量合并。这个方案能进一步压榨带宽,但复杂度高了不少,需要处理每个连接的起始偏移和边界对齐。如果你有兴趣,建议先在单连接版本稳定之后再往下扩展。
根据我个人的使用经验,最实用的其实是进度反馈和断点续传这两个功能。没有进度显示时,传一个几GB的文件,屏幕一动不动,你根本不知道它是卡了还是在工作。加了进度和速度显示之后,整个工具才算真正能投入到日常使用中。至于断点续传,哪怕只是“传中断了重新跑一遍”,也比从头开始传省几个小时。项目迭代到今天,它已经成了我的局域网文件传输首选工具,稳定、可控、性能足够。
本文还有配套的精品资源,点击获取