UDP本身是无连接、不可靠的传输协议,直接用来传文件容易出现丢包、乱序问题,但通过“分块传输+校验+重传”的设计,依然能实现稳定的文件传输。
一、UDP文件传输的核心设计思路
UDP不保证数据送达,所以要先解决三个关键问题:
- 分块传输:将大文件拆分成固定大小的数据包(如1024字节),避免单次发送数据过大导致丢包;
- 包标识:每个数据包添加“序号+总包数+校验位”,服务端接收后能校验完整性、排序重组;
- 确认重传:服务端接收每个数据包后返回确认(ACK),客户端未收到ACK则重传该数据包,保证可靠性。
核心流程:
- 客户端:读取文件→分块→加标识→发送数据包→等待ACK→重传失败包→发送结束标识;
- 服务端:接收数据包→校验→排序→重组→保存文件→返回ACK。
二、完整实现代码
1. 服务端代码(接收文件)
import socket
import os
# 配置参数
UDP_IP = "" # 监听所有本机IP
UDP_PORT = 9999
BUFFER_SIZE = 1024 # 数据包大小(需和客户端一致)
SAVE_DIR = "received_files" # 文件保存目录
# 创建保存目录
if not os.path.exists(SAVE_DIR):
os.makedirs(SAVE_DIR)
def udp_file_server():
# 1. 创建UDP Socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.bind((UDP_IP, UDP_PORT))
# 允许端口复用,避免重启报错
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
print(f"UDP文件服务端已启动,监听端口 {UDP_PORT}...")
# 初始化接收状态
file_data = {} # 存储接收的数据包 {序号: 数据}
total_packets = 0 # 总包数
file_name = "" # 文件名
client_addr = None # 客户端地址
try:
while True:
# 2. 接收数据包(阻塞)
data, client_addr = server_socket.recvfrom(BUFFER_SIZE + 32) # 预留标识位空间
if not data:
continue
# 解析数据包标识:"文件名|总包数|当前序号|数据"
try:
# 分割标识和数据(用|分隔,最后一段是文件数据)
parts = data.decode('utf-8', errors='ignore').split('|', 3)
if len(parts) != 4:
# 不是文件数据包,可能是结束标识
if data.decode('utf-8') == "TRANSFER_FINISH":
print("客户端发送结束标识,开始重组文件...")
break
continue
file_name, total_packets_str, packet_num_str, file_chunk = parts
total_packets = int(total_packets_str)
packet_num = int(packet_num_str)
except Exception as e:
print(f"数据包解析失败:{e}")
# 返回错误ACK
server_socket.sendto(f"ERR|{packet_num}".encode('utf-8'), client_addr)
continue
# 3. 校验并保存数据包
if 1 <= packet_num <= total_packets:
file_data[packet_num] = file_chunk
# 返回成功ACK
server_socket.sendto(f"ACK|{packet_num}".encode('utf-8'), client_addr)
# 打印进度
progress = (len(file_data) / total_packets) * 100
print(f"接收进度:{progress:.1f}% ({len(file_data)}/{total_packets})", end='\r')
# 4. 重组并保存文件
if file_data and total_packets > 0:
# 按序号排序数据包
sorted_chunks = [file_data[i] for i in range(1, total_packets + 1) if i in file_data]
# 拼接所有数据
file_path = os.path.join(SAVE_DIR, file_name)
with open(file_path, 'wb') as f:
for chunk in sorted_chunks:
f.write(chunk.encode('utf-8')) # 若传二进制文件,需调整编码逻辑(见进阶部分)
print(f"\n文件接收完成!保存路径:{file_path}")
# 发送完成确认
server_socket.sendto("FILE_SAVED".encode('utf-8'), client_addr)
else:
print("\n未接收到完整文件数据")
except KeyboardInterrupt:
print("\n服务端手动停止")
finally:
server_socket.close()
if __name__ == "__main__":
udp_file_server()
2. 客户端代码(发送文件)
import socket
import os
import time
# 配置参数
SERVER_IP = "127.0.0.1" # 服务端IP(远程传输改实际IP)
SERVER_PORT = 9999
BUFFER_SIZE = 1024 # 每个数据包的文件数据大小
RETRY_TIMES = 3 # 单个数据包最大重传次数
RETRY_INTERVAL = 0.5 # 重传间隔(秒)
def udp_file_client(file_path):
# 1. 检查文件是否存在
if not os.path.exists(file_path):
print(f"错误:文件 {file_path} 不存在")
return
# 获取文件名和文件大小
file_name = os.path.basename(file_path)
file_size = os.path.getsize(file_path)
# 计算总包数(向上取整)
total_packets = (file_size + BUFFER_SIZE - 1) // BUFFER_SIZE
print(f"准备发送文件:{file_name},大小:{file_size}字节,总包数:{total_packets}")
# 2. 创建UDP Socket
client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 设置超时(避免等待ACK卡死)
client_socket.settimeout(2)
try:
# 3. 读取文件并分块发送
with open(file_path, 'r', encoding='utf-8') as f: # 二进制文件用'rb'(见进阶部分)
for packet_num in range(1, total_packets + 1):
# 读取当前块数据
file_chunk = f.read(BUFFER_SIZE)
if not file_chunk:
break
# 构造数据包:"文件名|总包数|当前序号|数据"
packet_data = f"{file_name}|{total_packets}|{packet_num}|{file_chunk}".encode('utf-8')
retry_count = 0
ack_received = False
# 4. 发送并等待ACK,失败则重传
while retry_count < RETRY_TIMES and not ack_received:
try:
# 发送数据包
client_socket.sendto(packet_data, (SERVER_IP, SERVER_PORT))
# 等待ACK
ack_data, _ = client_socket.recvfrom(128)
ack_parts = ack_data.decode('utf-8').split('|')
if ack_parts[0] == "ACK" and int(ack_parts[1]) == packet_num:
ack_received = True
# 打印进度
progress = (packet_num / total_packets) * 100
print(f"发送进度:{progress:.1f}% ({packet_num}/{total_packets})", end='\r')
except socket.timeout:
retry_count += 1
print(f"\n数据包 {packet_num} 超时,重传 {retry_count}/{RETRY_TIMES}")
time.sleep(RETRY_INTERVAL)
except Exception as e:
print(f"\n数据包 {packet_num} 发送失败:{e}")
retry_count += 1
time.sleep(RETRY_INTERVAL)
if not ack_received:
print(f"\n错误:数据包 {packet_num} 重传{RETRY_TIMES}次失败,传输终止")
return
# 5. 发送结束标识
client_socket.sendto("TRANSFER_FINISH".encode('utf-8'), (SERVER_IP, SERVER_PORT))
# 等待服务端保存完成确认
try:
finish_ack, _ = client_socket.recvfrom(128)
if finish_ack.decode('utf-8') == "FILE_SAVED":
print("\n文件发送完成!服务端已保存")
except socket.timeout:
print("\n未收到服务端完成确认,但数据已发送完毕")
except KeyboardInterrupt:
print("\n客户端手动停止")
finally:
client_socket.close()
if __name__ == "__main__":
# 替换为你要发送的文件路径(本地测试用绝对/相对路径)
target_file = "test.txt" # 示例:发送当前目录的test.txt
udp_file_client(target_file)
三、基础版使用步骤
- 准备测试文件:在客户端目录创建一个
test.txt文件(内容任意); - 启动服务端:运行服务端代码,控制台显示“UDP文件服务端已启动”;
- 启动客户端:修改客户端代码中
target_file为实际文件路径,运行客户端; - 查看结果:服务端控制台显示传输进度,完成后文件会保存到
received_files目录。
四、关键优化:支持二进制文件(图片/视频/压缩包)
基础版仅支持文本文件,要传输图片、视频等二进制文件,需修改编码逻辑(核心是避免字符编码导致的数据损坏):
1. 客户端修改(读取二进制文件)
# 替换客户端文件读取部分
with open(file_path, 'rb') as f: # 改为二进制读取
for packet_num in range(1, total_packets + 1):
file_chunk = f.read(BUFFER_SIZE)
if not file_chunk:
break
# 构造数据包:用特殊分隔符(如b'|||'),避免二进制数据冲突
packet_header = f"{file_name}|{total_packets}|{packet_num}".encode('utf-8')
packet_data = packet_header + b'|||' + file_chunk # 二进制拼接
2. 服务端修改(解析二进制数据)
# 替换服务端数据包解析部分
try:
# 分割头部和二进制数据(按b'|||'分割)
header, file_chunk = data.split(b'|||', 1)
# 解析头部(转字符串)
parts = header.decode('utf-8').split('|', 2)
file_name, total_packets_str, packet_num_str = parts
total_packets = int(total_packets_str)
packet_num = int(packet_num_str)
except Exception as e:
print(f"二进制数据包解析失败:{e}")
continue
# 保存时直接写入二进制数据
with open(file_path, 'wb') as f:
for chunk in sorted_chunks:
f.write(chunk) # 无需encode,直接写二进制
五、避坑指南:常见问题与解决方案
1. 数据包丢包/重传失败
- 原因:UDP无可靠性保证,网络波动易丢包;
- 解决方案:
- 增大
RETRY_TIMES(如改为5),延长RETRY_INTERVAL; - 减小
BUFFER_SIZE(如改为512),降低单包传输压力; - 远程传输时确保服务端端口已开放防火墙。
- 增大
2. 大文件传输卡顿
- 原因:循环发送未做速率控制,网络拥塞;
- 解决方案:在客户端发送每个数据包后添加
time.sleep(0.001),控制发送速率。
3. 数据包解析错误
- 原因:分隔符(|)与文件内容冲突;
- 解决方案:改用更复杂的分隔符(如
|||或随机字符串),或对头部做Base64编码。
4. 端口被占用
- 解决方案:修改
UDP_PORT(如改为10000),并在服务端添加SO_REUSEADDR选项(代码已包含)。