简介基于C# Socket实现TCP大文件传输并支持断点续传的完整工程面向需要处理网络文件传输的开发者重点解决大文件传输的内存占用与断点续传问题。工程包含服务器端与客户端两个项目涉及文件分块、进度记录、数据校验、异常重试等机制适合作为课程设计、项目实训或开发前的技术参考。资源包共73个文件以cs源码为主另有config、exe、txt及sln/csproj工程文件压缩包仅190KB轻量且结构清晰。已有966人浏览学习可供C#网络编程初学者进阶或中高级开发者参考。打开GY_FileTransfer2.0.3工程可对照源码理解服务器与客户端协同、断点续传状态存储与块传输的逻辑结合TCP可靠性、三次握手等理论基础形成从协议到代码的完整认知也可进一步扩展异步传输、SSL加密等特性。1. C# Socket TCP 大文件传输断点续传不是加分项是保命项内网传 48G 的点云数据拷到第 37G 的时候网络闪断前面 12 个小时白干——做过 C# 上位机或者工业数据同步的人对这一幕应该不陌生。C# Socket TCP 做局域网大文件传输真正的难点不在并发性能而在断线之后能不能从几百 MB 的粒度上接着跑。断点续传不是锦上添花的加分项是省掉十几个小时重传时间的保命项。这篇笔记按我拆 C# Socket 大文件传输方案时的落地顺序来写先自定义协议帧再分块确认然后断点记录最后哈希校验。覆盖发送端、接收端、元数据设计和最容易翻车的四个坑。新手能照着搭熟手可以直接抄参数和避坑清单。2. 先定协议再写收发TCP 是字节流消息边界得自己划2.1 为什么大文件传输不能直接 Send/Receive很多人第一次写 Socket 文件传输习惯是把文件整个读成 byte[]然后一个Send丢过去接收端一个Receive接住。小文件能跑几十 G 的文件这么干必挂。原因不在 C# 的 API而在 TCP 协议栈本身TCP 是流式协议不保证你一次 Send 的数据能被对端一次 Receive 完整读到。三次握手只解决了连接建立连接建立之后就是一条裸字节管道没有消息边界。举个例子你 Send 了 1MB 数据对端 Receive 可能只读到 400KB剩下的 600KB 要等下一次 Receive。反过来如果你连续快速 Send 两块数据对端可能一次 Receive 就把两块都读走了。这就是常说的半包和粘包。对大文件传输来说半包意味着接收端拿到的数据不完整粘包意味着接收端把两个数据块混在一起写入文件后整个文件就废了。所以大文件传输的第一步不是写收发逻辑而是先定义一套应用层协议把消息边界这个问题解决掉。我一般会用一个固定长度的协议头后面跟数据区接收端先读够协议头的长度再根据协议头里声明的数据长度去读数据区。TCP 只负责把字节从 A 搬到 B消息该怎么切是应用层自己说了算。2.2 固定 32 字节协议头魔数、命令字、偏移量、CRC 一个都不能少协议头是整个传输方案的地基。我拆过几套 C# Socket 传输代码凡是协议头漏字段的后面几乎都会在续传或校验上翻车。这里用一套固定 32 字节的协议头字段如下字段长度说明魔数2 字节固定 0xAA55用来快速识别是否为本协议包版本号1 字节协议版本当前为 1方便将来扩展命令字1 字节1开始传输2续传查询3续传应答4文件块5ACK6完成文件 ID8 字节文件唯一标识服务端用来区分多文件并发块序号4 字节当前块编号从 0 开始方便做乱序校验偏移量8 字节本块数据在文件中的起始偏移数据长度4 字节本块实际数据长度最大不能超过配置的块大小CRC324 字节对数据区做 CRC32 校验防止传输过程中数据被改坏在 C# 里可以定义成结构体按顺序封包。这里用BinaryWriter手动封包比Marshal.StructureToPtr更直观也更容易加字段。public struct FileTransferHeader { public ushort Magic; // 魔数0xAA55 public byte Version; // 协议版本 public byte Command; // 命令字 public long FileId; // 文件 ID public uint BlockIndex; // 块序号 public long Offset; // 起始偏移 public uint DataLength; // 数据区长度 public uint Crc32; // 数据区 CRC32 } public static byte[] PackHeader(FileTransferHeader header) { byte[] buffer new byte[32]; using (MemoryStream ms new MemoryStream(buffer)) using (BinaryWriter bw new BinaryWriter(ms)) { bw.Write(header.Magic); bw.Write(header.Version); bw.Write(header.Command); bw.Write(header.FileId); bw.Write(header.BlockIndex); bw.Write(header.Offset); bw.Write(header.DataLength); bw.Write(header.Crc32); } return buffer; }PackHeader输出固定 32 字节发送数据包时先发这 32 字节再紧跟数据区。接收端只需要先攒够 32 字节解析出DataLength和Crc32再继续读对应长度的数据就能还原出一条完整消息。字段里有两个细节容易被忽略一是偏移量用long大文件超过 2GB 时int会溢出二是 CRC32 只校验数据区协议头靠魔数加版本号做初步判断这样处理速度快也能挡住大部分错位包。2.3 累积缓冲解包粘包半包的标准解法协议头定义好了接收端还要处理一次 Receive 数据不完整的问题。我的做法是维护一个累积缓冲区把每次收到的字节追加进去然后循环尝试从缓冲区里解析完整包。private Listbyte _buffer new Listbyte(); public ListReceivedPacket TryParsePendingPackets() { ListReceivedPacket packets new ListReceivedPacket(); while (_buffer.Count 32) { // 检查魔数不对说明协议错位 if (_buffer[0] ! 0xAA || _buffer[1] ! 0x55) { _buffer.RemoveAt(0); continue; } // 读取协议头 byte[] headerBytes _buffer.GetRange(0, 32).ToArray(); using (MemoryStream ms new MemoryStream(headerBytes)) using (BinaryReader br new BinaryReader(ms)) { FileTransferHeader header new FileTransferHeader { Magic br.ReadUInt16(), Version br.ReadByte(), Command br.ReadByte(), FileId br.ReadInt64(), BlockIndex br.ReadUInt32(), Offset br.ReadInt64(), DataLength br.ReadUInt32(), Crc32 br.ReadUInt32() }; // 数据不完整等下一批 Receive if (_buffer.Count 32 header.DataLength) break; byte[] data _buffer.GetRange(32, (int)header.DataLength).ToArray(); uint actualCrc Crc32.Compute(data); if (actualCrc ! header.Crc32) { // CRC 不匹配丢弃这个包 _buffer.RemoveRange(0, 32 (int)header.DataLength); continue; } packets.Add(new ReceivedPacket(header, data)); _buffer.RemoveRange(0, 32 (int)header.DataLength); } } return packets; }核心逻辑是攒够头部再攒数据凑齐一整包才消费。_buffer用Listbyte累积每一轮循环先看头部长度再看数据区长度数据没齐就退出循环等待下一批。CRC32 校验失败时直接丢弃整包因为协议头里的偏移量和长度已经不可信继续解析只会让文件越写越乱。这段代码里DataLength是解包的钥匙也是安全边界。实际接收时我还习惯给DataLength加一个上限检查比如不能超过配置的blockSize的 1.5 倍防止对端发来一个伪造的超长长度导致内存先被撑爆。3. 分块发送与落盘确认参数调好速度和大文件的命才能都保住3.1 发送端FileStream 分块读取别一次性 Load 进内存协议定完接下来是传输主体。发送端的第一个原则是永远不要用File.ReadAllBytes读大文件。48G 的文件读进内存进程直接没了。正确做法是用FileStream分块读取读一块发一块发完等确认再读下一块。private static async Task SendFileAsync(NetworkStream stream, FileStream fs, long startOffset, int blockSize, CancellationToken ct) { byte[] data new byte[blockSize]; fs.Seek(startOffset, SeekOrigin.Begin); long totalSize fs.Length; long offset startOffset; int blockIndex (int)(startOffset / blockSize); while (offset totalSize) { ct.ThrowIfCancellationRequested(); int read await fs.ReadAsync(data, 0, blockSize, ct); if (read 0) break; // 组装协议头 数据 FileTransferHeader header new FileTransferHeader { Magic 0xAA55, Version 1, Command 4, // 文件块 FileId _fileId, BlockIndex (uint)blockIndex, Offset offset, DataLength (uint)read, Crc32 Crc32.Compute(data, 0, read) }; byte[] headerBytes PackHeader(header); await stream.WriteAsync(headerBytes, 0, headerBytes.Length, ct); await stream.WriteAsync(data, 0, read, ct); await stream.FlushAsync(ct); // 等待 ACK超时重试 bool acked await WaitAckAsync(stream, _fileId, (uint)blockIndex, ct); if (!acked) { throw new IOException($块 {blockIndex} 确认超时); } offset read; blockIndex; } }发送循环的核心是读一读 → 发一发 → 等一等。blockSize决定单块大小这个值选得不好会直接影响速度和续传粒度。内网千兆环境我一般取 256KB外网或弱网降到 64KB。块越大单次传输效率越高但断点续传的粒度也越粗——假设块大小 8MB断在第 7.5MB 的位置续传时整个 8MB 都要重发。WaitAckAsync是实现可靠传输的关键当前块的对端 ACK 没回来之前发送端绝不发下一块。这样内存占用被锁死在blockSize水平不会随文件大小增长同时每块的发送结果都能被确认。3.2 接收端Seek 到偏移再写ACK 回来再发下一块接收端收到文件块后要做三件事按偏移量定位文件写入位置、把数据落盘、回 ACK。注意这里一定要用Seek定位而不是按顺序写。因为断点续传时上一次传输可能已经在文件里写了不少数据新到的块可能落在文件的任意位置。private static async Task ReceiveBlockAsync(NetworkStream stream, FileStream fs, FileTransferHeader header, byte[] data) { // 按偏移量定位写入对应位置 fs.Seek(header.Offset, SeekOrigin.Begin); await fs.WriteAsync(data, 0, (int)header.DataLength); // 强制刷盘避免 ACK 发出后数据还在内存缓冲区 await fs.FlushAsync(); // 回 ACK 包 FileTransferHeader ack new FileTransferHeader { Magic 0xAA55, Version 1, Command 5, // ACK FileId header.FileId, BlockIndex header.BlockIndex, Offset header.Offset, DataLength 0, Crc32 0 }; byte[] ackBytes PackHeader(ack); await stream.WriteAsync(ackBytes, 0, ackBytes.Length); await stream.FlushAsync(); }接收端容易忽略的是FlushAsync。有人觉得写完FileStream不刷盘也丢不了但如果你在 ACK 发出后立刻进程崩溃而数据还停留在系统页缓存里接收端记录的偏移量就已经比实际落盘位置靠后了。断点续传的时候发送端会从记录的偏移继续发丢失的那块就彻底没了。所以在 ACK 之前刷盘是我定义的硬性要求再怎么嫌慢也不能省。3.3 关键参数表缓冲区、超时、重试次数大文件传输的坑有一半出在参数上。下面这张表是我在一套内网文件同步工具里最终采用的默认值直接抄过去基本能跑但要根据你实际的网络环境调整参数推荐值说明blockSize局域网 256KB弱网 64KB块大小直接影响续传粒度和内存占用Socket 接收缓冲64KB~128KB设置太小会频繁触发读操作太大会占用系统内存Socket 发送缓冲64KB~128KB与接收缓冲对应配合积压确认ACK 超时10 秒局域网一般几百 ms10 秒已经是很宽容的上限重试次数3 次超过 3 次直接断线不无限重试连接保活启用间隔 30 秒防止长时间卡在某块时连接被中间设备断开块大小要不要按网络条件自适应可以做但复杂度会上升一截。文件传输这种场景我更倾向于固定块大小。块大小变了元数据里的续传偏移要对齐的粒度也会变容易埋雷。先把固定参数跑稳再谈自适应。4. 断点续传实现用元数据文件记录偏移握手阶段定生死4.1 续传元数据记录什么、什么时候更新、什么时候落盘断点续传能不能成立取决于你知不知道上一次传到哪了。这个信息不能只放在内存里进程一崩就没了必须落到磁盘上。我用一个 JSON 元数据文件和接收端的目标文件放同一目录文件名用文件 ID 加.transfer.json后缀。里面记录这些字段{ fileId: abc-123, fileName: scanpoint_48g.xyz, totalSize: 48653926400, blockSize: 262144, lastConfirmedOffset: 38923141120, lastBlockIndex: 148480, completed: false, fileLastWriteTimeUtc: 2026-01-15T08:30:00Z }lastConfirmedOffset是断点续传的地基。它表示接收端已经完整落盘到哪个偏移量发送端下次就从这里接着发。注意这个偏移量必须严格等于某个完整块的结束位置绝对不能是块中间。否则说明最后一块没写完续传再从这里开始会导致数据重叠或空洞。元数据的更新时机也要讲究。每个块都写一次 JSON 文件在高速传输时会影响吞吐因为磁盘 IO 会跟文件写入竞争。我一般的做法是每收到一个块内存里更新lastConfirmedOffset每攒够 10 个块或者每 5 秒把内存里的最新偏移写一次磁盘。这样即使进程崩溃最多丢失 10 个块的进度也就是 2.5MB按 256KB 块算代价完全可控。4.2 握手协商客户端先问服务端告诉它我从哪个偏移接断点续传的流程从握手开始。发送端客户端先发一条续传查询命令带上文件 ID接收端服务端查元数据文件返回已确认的偏移量。发送端根据这个偏移决定是从零开始还是从中间接着发。// 发送端查询续传偏移 FileTransferHeader query new FileTransferHeader { Magic 0xAA55, Version 1, Command 2, // 续传查询 FileId fileId, Offset 0, DataLength 0, Crc32 0 }; byte[] queryBytes PackHeader(query); await stream.WriteAsync(queryBytes, 0, queryBytes.Length); // 接收端读取元数据返回已确认偏移 FileTransferHeader response new FileTransferHeader { Magic 0xAA55, Version 1, Command 3, // 续传应答 FileId fileId, // 关键以接收端落盘位置为准 Offset lastConfirmedOffset, BlockIndex lastBlockIndex };握手阶段有一个必须坚持的原则以接收端落盘偏移为准而不是发送端自己记的偏移。因为发送端认为发出去的数据接收端不一定落盘了。网络发送成功不等于对端写入磁盘成功。发送端记录的偏移只是参考接收端元数据里的lastConfirmedOffset才是权威。4.3 对齐规则按块边界续传避免半块脏数据偏移拿到之后发送端不能直接闭着眼睛从偏移处发。要先做一步对齐计算。假设lastConfirmedOffset是 1000块大小是 256KB262144那这个偏移显然不是块边界。说明什么说明最后一块没有完整收完接收端元数据里记录的偏移本身不合法。我一般会在发送端起发前做一次对齐校验long alignedOffset (lastConfirmedOffset / blockSize) * blockSize; // 偏移不在块边界上说明最后一块不完整回退到上一个完整块 if (alignedOffset ! lastConfirmedOffset) { lastConfirmedOffset alignedOffset; lastBlockIndex (int)(lastConfirmedOffset / blockSize); }回退之后最后那个不完整的块会被整体重传。接收端收到后因为用到的是Seek(header.Offset)写入会覆盖掉原来那块不完整的内容不会产生重复数据。这个对齐规则成本极低但能避免大量文件损坏的疑难杂症。5. 避坑排查大文件传输最容易翻车的四个点5.1 粘包半包解包解出乱码文件写入错位现象接收端解析协议头时魔数校验不通过或者解出的DataLength巨大紧接着Crc32校验全部失败目标文件写出来完全乱掉。原因没有用累积缓冲处理 TCP 字节流。接收端每次Receive到的数据长度是随机的可能只有半块也可能一次包含多块。如果代码假定一次 Receive 就是一个完整包就会把不完整的数据包当成完整包处理导致协议头错位数据区张冠李戴。解决在第 2.3 节累积缓冲的基础上再加一条铁律所有数据包必须经过先攒头部再按DataLength攒数据区的流程才允许解析。协议头魔数不匹配时逐字节丢弃而不是跳过防止错位后的误判。5.2 内存暴涨传着传着进程吃满几个 G最后卡死现象传输刚开始正常几分钟后内存占用直线上升程序响应变慢最后整个进程卡死甚至被系统杀掉。原因最常见的是发送端把大文件一次性读入byte[]或者接收端把每个收到的块都先存进Listbyte等待和发送端一样的数据总量后再统一写盘。这两种做法在大文件场景下都是灾难48G 的文件用byte[]装不下用Listbyte累积更是直接打爆内存。解决发送端严格按blockSize分块读取发完一块再读下一块接收端每解析出一个完整包立即写入FileStream并丢弃该包的数据引用不让任何累积结构跨越块边界存活。内存占用控制在 64MB 以内才说明设计对了。5.3 断点续传偏移错位续传后文件打不开或损坏现象第一次传到 70% 断开续传后文件能跑完但打开文件时报损坏或者中间一段数据明显不对。原因元数据里的lastConfirmedOffset更新过早。发送端发出某块后立刻把偏移记进元数据但接收端还没来得及落盘进程就崩了。恢复后发送端认为对端已经写到新位置实际文件还缺这块数据续传再从错误偏移开始发文件中间就出现空洞。解决偏移写入元数据的时机必须在接收端完成FlushAsync并且 ACK 被发送端收到之后。另外补上第 4.3 节的块对齐校验把最后不完整的块强制重传。这两条同时做到续传文件的完整性能得到保证。5.4 端口 TIME_WAIT 与绑定失败第二次传直接报地址已在占用现象服务端程序跑完关闭马上重新启动监听端口时抛异常提示通常每个套接字地址协议/网络地址/端口只允许使用一次。或者客户端每次新连接都走同一个端口跑完一批任务后第二次连接失败。原因TCP 四次挥手的 TIME_WAIT 状态。主动关闭连接的一端端口会停留在 TIME_WAIT 状态一段时间默认约 2 分钟。如果服务端频繁重启监听的端口就不断被之前的连接占用新监听自然失败。解决服务端在绑定监听端口前设置SocketOptionName.ReuseAddress允许重用地址。真正的根治是在应用层复用连接不要让每个文件块都新建一条 TCP 连接。按本文第 3 章的单连接多块模型一条连接传完整个文件才断开TIME_WAIT 的量级就是可控的。网上有人会去改netsh int tcp set global timestamps之类的协议栈参数实测作用有限别把它当后悔药。6. 从能传到跑稳哈希校验与一键重传6.1 传输完别急着收工两边哈希对齐传输完成的判定标准不是发送端发完了而是接收端落盘的内容和源文件完全一致。验证手段是哈希对齐。我一般让发送端在分块发送的同时用IncrementalHash同步计算整个文件流内容。using var sendHash IncrementalHash.CreateHash(HashAlgorithmName.SHA256); // 每次读完一个块先把块数据喂给哈希 byte[] read await fs.ReadAsync(data, 0, blockSize); sendHash.AppendData(data, 0, read);接收端在写入每个完整块后同样把块数据喂给哈希对象。传输完成后发送端把十六进制哈希串发给接收端接收端对比自己的哈希值一致才修改元数据为completed: true否则标记失败并触发重传流程。哈希计算本身是小开销但对这个文件到底成没成这种黑匣子问题是唯一诚实的答案。6.2 记录文件损坏的兜底策略元数据文件可能损坏比如写 JSON 写到一半断电或者被人误删。这时候lastConfirmedOffset拿不到怎么处理我见过有人直接全量重传最省事但也最浪费。我一般会先做一次尾部扫描用FileStream从接收端文件末尾按blockSize倒着读检查每个完整块是否能通过 CRC 校验找到最后一个合法块的结束位置作为续传偏移。扫描失败的兜底才是全量重传。从那以后我每传一轮大文件都会强制走一遍协议头先打印十六进制确认魔数传输前检查元数据对齐跑完必做哈希对齐缺一个环节就不放过。这套习惯救过我至少三次希望帮到你。本文还有配套的精品资源点击获取