
简介一份面向C#开发者的SFTP传输功能示例工程基于Renci.SshNet库实现文件上传与下载并重点加入进度回调机制解决原生接口传输过程无反馈、界面难感知进度的问题。工程内含完整可运行的源码和依赖读者可直接用Visual Studio打开编译参考WinForms或WPF进度条控件的接入方式。整个资源包共26个文件压缩包约533KB以.cs源码文件为主同时包含.dll程序集、.exe可执行文件以及.sln/.csproj/.resx等VS工程资源文件结构清晰便于按需查看上传、下载与界面回调的实现。目前已有1680人学习下载适合需要快速掌握SFTP异步传输、进度回调以及C#网络编程的中初级开发者用作起步模板。通过示例中的UploadFileWithProgress和DownloadFileWithProgress方法可以清晰理解如何通过回调参数获取已传输字节数并换算百分比进而灵活扩展为业务中的状态提醒或进度展示。1. 用 C# 实现 SFTP 文件上传和下载难点不在 SFTP而在进度条怎么给用户交代每次我在 C# 上位机里加一个“上传文件到服务器”的按钮第一个想法就是 SFTP端口 22 走 SSH 加密通道不像 FTP 那样还要开放一堆数据端口Linux/Windows 两边都认。但真动手做才发现SFTP 链路本身不复杂难受的是大文件传输时 WinForm 界面像死了一样进度条要么趴着不动要么最后一秒从 10% 跳到 100%。这篇文章就围绕“C# SFTP 文件上传/下载 进度条”这套组合把选型、完整实现、参数设置和踩坑经验一次讲清。适合写运维小工具、工控上位机、内部文件分发器的工程师也适合刚把 C# 入门教程看完、想接第一个真实需求的新手。读完之后你可以直接抄代码也大概明白出问题该去哪里查。2. 选型与原理为什么 SSH.NET 是 SFTP 进度条的最优解2.1 三个常见 SFTP 库的取舍在 C# 里做 SFTP 文件传输社区里能看到的名字通常是三个SSH.NET、WinSCP .NET 库、SharpSSH。先说我的结论普通桌面工具直接选 SSH.NET不是因为它功能最多而是因为它“纯托管、NuGet 一行安装、API 不绕”。WinSCP .NET 库功能确实更强支持目录同步、后台队列、比较完善的事件机制但它的底层是 COM 和 WinSCP 安装包部署时要么带一个 EXE要么装环境做安装包、做跨平台服务时非常麻烦。SharpSSH 更老维护基本停滞遇到新版 OpenSSH 密钥格式基本无解。库集成方式进度回调维护状态适合场景SSH.NETNuGet纯托管原生回调活跃WinForm/WPF/服务端最快落地WinSCP .NET需要安装 WinSCP 或随包带 EXE有事件但底层是 COM活跃需要图形目录同步、高级队列的运维工具SharpSSHNuGet纯托管无不活跃老项目维护不推荐新用选 SSH.NET 还有一个隐藏好处它不依赖任何外部 EXE部署时把 DLL 带上就行。这在“单位内网服务器只能连 22 端口、没有外网 NuGet 源”的场景里特别重要。我的做法是把整个 packages 目录打进发布包或者走私有 NuGet 源这样新环境装起来就是纯粹的 copy 文件夹。2.2 SftpClient 的进度回调是怎么工作的SSH.NET 的SftpClient是入口连接、认证、上传、下载都在它身上。进度条的关键是UploadFile(Stream, string, Actionulong)和DownloadFile(string, Stream, Actionulong)这两个重载它们接受一个委托每传输若干 KB 就回调一次参数是“累计已经传输的字节数”不是百分比也不是传输速度。所以你的进度条要显示百分比必须自己拿这个值和文件总长度算。这段代码是最小的带进度回调上传骨架先把原理立住using Renci.SshNet; var total new FileInfo(D:\tmp\BigData.bin).Length; using var client new SftpClient(192.168.1.20, ops, 123456); client.Connect(); using var fs File.OpenRead(D:\tmp\BigData.bin); client.UploadFile(fs, /data/upload/BigData.bin, uploadedBytes { var percent total 0 ? 100.0 : (double)uploadedBytes / total * 100.0; Console.WriteLine(${percent:F1}%); });逻辑很简单FileStream是数据源UploadFile内部循环从fs读往 SFTP 远程写每写完一个内部缓冲区块就触发 lambda。参数uploadedBytes是已累计的字节比如第一次是 32768第二次 65536最后一次等于文件长度。这里最容易犯的错是直接把uploadedBytes当ProgressBar.Value用最后回调一个比Maximum大得多的值进度条立刻拉满然后是状态栏闪烁。正确的做法是像上面这样先算成百分比再交给 UI。2.3 密码、密钥和多认证方法实操中SFTP 认证不是只有密码。生产环境更多要求密钥登录。SSH.NET 的麻烦点是如果只写new SftpClient(host, username, password)后面想换密钥得重构。所以我一般会从第一天就用ConnectionInfo封装认证方式后面切换只加一行var authMethods new AuthenticationMethod[] { new PasswordAuthenticationMethod(ops, 123456), new PrivateKeyAuthenticationMethod(ops, new PrivateKeyFile(C:\keys\id_rsa, key-passphrase)) }; var connInfo new ConnectionInfo(192.168.1.20, 22, ops, authMethods); using var client new SftpClient(connInfo); client.Connect();逻辑说明ConnectionInfo的第四个参数是认证方法数组SSH.NET 按顺序尝试密码不行再试密钥。如果你用 OpenSSH 生成的私钥是BEGIN OPENSSH PRIVATE KEY开头而这台机器的 SSH.NET 版本比较老会抛“不支持的密钥格式”。常见解决是把私钥转成 PEM 格式命令是ssh-keygen -p -m PEM -f id_rsa注意这会改变文件内容建议先复制一份再转。如果你想完全避开这类问题就在 NuGet 上用最新版 SSH.NET它对新格式的支持好很多。2.4 两个 Timeout 别乱调连接 SFTP 报超时百分之八十分不清是ConnectTimeout还是OperationTimeout。ConnectTimeout管的是 TCP 连接和 SSH 握手比如服务器 IP 不通、22 端口被防火墙 drop这个超时会触发。OperationTimeout管的是连上之后每一次 SFTP 操作读文件、写文件、列目录的等待时间。如果为了“界面不卡”把OperationTimeout设成 10 秒再传一个 2GB 文件传输过程中只要网络抖动一次整个操作就死给你看。我的建议是ConnectTimeout设 15~30 秒OperationTimeout保持默认或设成一个足够大的值不要用短超时来兜底网络质量。判定是哪种超时看异常文本SocketException、SshOperationTimeoutException分别对应两个阶段不要只记 sftp error 103 这种数字直接看InnerException和服务器端/var/log/messages里的 sshd 日志更靠谱。3. 完整实现WinForm 状态栏里的上传与下载3.1 先把 UI 和后台线程拆开SFTP 上传下载如果直接放到按钮点击事件里连接握手和网络 I/O 会把 UI 线程堵死鼠标拖动窗口都会卡。写法上我倾向async void事件 Task.Run注意async void只在事件里用其他方法一律返回Task。这个结构能保证 WinForm/WPF 都能复用WPF 里把ProgressBar换成 WPF 版即可。最小化的 WinForm 布局核心控件就三样一个Button触发上传一个ProgressBar一个StatusStrip里的ToolStripStatusLabel显示百分比。把控件名约定好后面代码直接引。3.2 上传方法带回调的完整写法现在写能放进项目的上传方法。我把连接、抛异常、进度上报拆开这样不同按钮可以共用private async Task UploadFileAsync(string host, string username, string password, string localPath, string remotePath, IProgressdouble progress) { var file new FileInfo(localPath); if (!file.Exists) throw new FileNotFoundException(本地文件不存在, localPath); var fileLength file.Length; await Task.Run(() { using var client new SftpClient(host, username, password) { ConnectTimeout TimeSpan.FromSeconds(15), OperationTimeout TimeSpan.FromMinutes(30) }; client.Connect(); using var fs file.OpenRead(); client.UploadFile(fs, remotePath, uploadedBytes { var percent fileLength 0 ? 100.0 : (double)uploadedBytes / fileLength * 100.0; progress.Report(percent); }); progress.Report(100.0); client.Disconnect(); }); }逻辑说明IProgressT是 .NET 自带的跨线程进度注入器在 UI 线程创建它回调里不管哪个线程调Report都会切回生成时的线程。这样比自己在回调里写BeginInvoke干净得多。参数方面OperationTimeout这里给了 30 分钟只防操作彻底挂死不防网络慢如果你跑内部千兆网这个值根本触不到。fileLength在进Task.Run之前取好避免在后台线程访问 FileInfo 属性时有竞态。最后一个progress.Report(100.0)必须加原因在避坑章节说。3.3 下载方法远程大小先算出来下载和上传唯一的区别是上传时总长度可以从本地FileInfo拿到下载时没连接服务器前拿不到远程文件大小。所以必须先GetAttributes拿到Size再开流下载private async Task DownloadFileAsync(string host, string username, string password, string remotePath, string localPath, IProgressdouble progress) { await Task.Run(() { using var client new SftpClient(host, username, password) { ConnectTimeout TimeSpan.FromSeconds(15), OperationTimeout TimeSpan.FromMinutes(30) }; client.Connect(); var remoteFileInfo client.GetAttributes(remotePath); var totalSize remoteFileInfo.Size; var safeName Path.GetFileName(remotePath); using var fs new FileStream(localPath, FileMode.Create, FileAccess.Write); client.DownloadFile(remotePath, fs, downloadedBytes { var percent totalSize 0 ? 100.0 : (double)downloadedBytes / totalSize * 100.0; progress.Report(percent); }); progress.Report(100.0); client.Disconnect(); }); }这里有一个下载特有的坑FileMode.Create会截断本地旧文件如果中途失败本地会留一个半截文件。所以我的习惯是先下载到localPath .part全部完成后用File.Move改名相当于给用户一个“后悔药”。.part文件放在同目录重命名是原子操作比直接覆盖安全。3.4 状态栏进度条如何不闪不卡如果你不想用IProgressT或者你在写没有 DI 的旧式 WinForm可以直接在回调里用BeginInvoke。注意一定要用BeginInvoke而不是InvokeInvoke是同步等待 UI 线程执行完如果 UI 线程正在处理别的逻辑回调线程会排队界面看起来还是卡。BeginInvoke丢过去就返回UI 线程有空时再更新。下面是配合StatusStrip的典型写法private void UpdateProgress(ulong transferred, ulong total) { if (progressBar1.InvokeRequired) { progressBar1.BeginInvoke(new Action(() UpdateProgress(transferred, total))); return; } var max (int)Math.Min(total, int.MaxValue); var value (int)Math.Min(transferred, max); progressBar1.Maximum Math.Max(1, max); progressBar1.Value Math.Max(0, Math.Min(value, progressBar1.Maximum)); statusLabel1.Text ${value * 100.0 / max:F1}%; }逻辑说明ProgressBar.Value一旦超过Maximum会抛ArgumentOutOfRangeException所以这里拿到transferred先做两层Math.Min。Maximum用文件大小如果文件超过 2GBint放不下就要用Math.Min(total, int.MaxValue)先把最大刻度钉在int.MaxValue再按比例缩放value否则控件会异常。百分比计算用value * 100.0 / max如果max被压缩过显示的是压缩后的比例但方向是对的更精确的做法是在外面保存原始 total避免大文件显示失真。4. 避坑SFTP 进度条开发中的踩坑记录4.1 连接、认证和路径三个让你抓耳挠腮的问题坑一账号密码都对但上传一直Permission denied。现象是SftpPathNotFoundException或 SFTP 状态码提示权限不足人肉在 FileZilla 里又能传。原因绝大多数不是 SSH.NET 的问题而是远程目录的写权限、SELinux 或 ssh-chroot 限制了登录用户的根目录。解决先用命令或者交互工具看一眼服务器上pwd在哪里把 remotePath 改成~/uploads/xxx或绝对路径要是服务器有 chroot路径必须以用户家目录为根写。坑二私钥是 OpenSSH 新格式SSH.NET 老版本报“Invalid private key”。现象是PrivateKeyFile构造时直接抛错但你用命令行 ssh 是好的。原因OpenSSH 7.8 以后默认生成的id_rsa是BEGIN OPENSSH PRIVATE KEY老 SSH.NET 不认。解决升级 NuGet 包如果对方服务器不能升就转 PEM 格式ssh-keygen -p -m PEM -f id_rsa_new不要覆盖原文件。转完后再看文件头是不是BEGIN RSA PRIVATE KEY。坑三连上了但一列目录就Socket closed或者进度条跑一半断掉。现象是不稳定的内网环境高频出现日志里是One of the configured authentication methods...或者SSHException。原因SSH 服务端的MaxSessions/MaxStartups到了或者防火墙对长连接不活动超时。解决客户端能做的就是加KeepAliveIntervalSSH.NET 里ConnectionInfo有KeepAliveInterval属性设成 30 秒同时检查服务器 sshd_config 的ClientAliveInterval。这一条很多教程不提实际工控环境里十个断线八个是它。4.2 进度条自身百分比不到 100、乱跳、UI 卡坑四上传/下载完成后进度条停在 98% 或 99%最后跳一下完成。现象是回调里看到的最终数值总是比文件大小小一点然后退出UploadFile方法。原因SSH.NET 内部最后一个缓冲区块可能在刷新通道时才回写完成回调没有精确对应最后一次 flush。解决在UploadFile或DownloadFile调用返回后无条件再Report(100.0)。这不是骗人而是把“文件写入句柄关闭”这个动作也当作进度的一部分用户不会因为 98% 停住误会程序卡死。坑五进度条像神经质一样来回跳拖动窗口都掉帧。现象是每秒回调几十次每次都在 UI 线程里设置ProgressBar.Value。原因进度回调频率远高于刷新率UI 线程被消息风暴塞满。解决在后台维护一个“最近上报值”只有当前百分比比上次上报值超过 0.1% 才触发 UI 更新或者用计时器每隔 100ms 拉一次最新进度见最后一章。这里最忌讳的是为平滑把ProgressBar的Step调大掩耳盗铃不能解决底层消息风暴。坑六Windows 下路径带中文传到服务器变乱码。现象是本地文件名报表_2025.csv服务器上看到???_2025.csv。原因SSH.NET 的 SFTP 协议层统一走 UTF-8和 Windows ANSI 代码页没关系但如果你在构造 remotePath 时用了Path.Combine或者从 WinForm 输入框直接拿字符串偶尔会被本地编码干扰。解决remotePath 用/做连接符不做Path.Combine中文文件名在客户端 URL 编码再发送服务器端解码省得踩文件系统 locale 坑。这条在新手阶段不容易遇到等你在日资、台资企业服务器上一跑立刻变成血泪经验。5. 工程化取消、批量传输与上传失败重试5.1 用自写传输循环实现真正的取消前文用的UploadFile回调只有进度上报没有取消口。想在用户点“停止”时立刻断开你需要绕开这个便捷方法改用SftpClient.Open拿到远程文件流自己循环读写。代码看起来多但可控性完全不一样private async Task UploadWithCancelAsync(SftpClient client, string localPath, string remotePath, CancellationToken ct, Actionlong, long progressCallback) { using var localFs new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using var remoteFs client.Open(remotePath, FileMode.Create, FileAccess.Write); var buffer new byte[128 * 1024]; long total 0; int read; while ((read await localFs.ReadAsync(buffer, 0, buffer.Length, ct)) 0) { await remoteFs.WriteAsync(buffer, 0, read, ct); total read; progressCallback(total, localFs.Length); } await remoteFs.FlushAsync(ct); }逻辑说明FileShare.ReadWrite让本地文件在被 Excel、第三方进程占用时也能做只读流避免“文件被占用”抛IOException。client.Open(remotePath, FileMode.Create, FileAccess.Write)返回的是SftpFileStream它继承自Stream所以WriteAsync带 CancellationToken 在 .NET 上可以直接用。Buffer 大小我常用 128KB太小则往返次数太多太大则 SSH 通道阻塞后内存占用吓人实际上大于 32KB 都差不太多主要影响进度条回调粒度。中途取消后远程会留下半截文件。我的做法是写临时名字remotePath .tmp全部写完再client.RenameFile(tmpPath, remotePath)。这样取消发生在.tmp阶段不影响线上正式文件算是给后续操作留了后悔药。5.2 批量文件队列失败不要中断全体一个上传任务往往不是一个文件而是整个文件夹。最忌讳的做法是 foreach 里直接UploadFile一个文件权限错了后面全部停。我一般把任务列表丢进一个简单队列失败收集后最后统一报告var jobQueue new Queue(string Local, string Remote)(); jobQueue.Enqueue((D:\out\a.txt, /data/out/a.txt)); jobQueue.Enqueue((D:\out\b.txt, /data/out/b.txt)); var failed new List(string File, string Reason)(); while (jobQueue.Count 0) { var job jobQueue.Dequeue(); try { await UploadWithCancelAsync(client, job.Local, job.Remote, ct, Report); } catch (Exception ex) { failed.Add((job.Local, ex.Message)); // 这里只记录不 break让后面的文件继续 } } if (failed.Count 0) { File.AppendAllLines(failed.log, failed.Select(x ${x.File}: {x.Reason})); }逻辑说明失败收集后写日志比弹窗中断更符合后台任务习惯。值得注意的一点是SftpClient连接在某个文件失败后未必还能继续用取决于异常是SshConnectionException还是SftpPermissionDeniedException。保险做法是捕获到SshConnectionException时自动重连一次再继续其余异常只记录。这比盲目在每个文件前Connect/Disconnect性能好很多又不至于崩掉整条队列。5.3 远程路径拼接别再用反斜杠SFTP 的远程路径和 FTP 不同它在服务器侧走的是 POSIX 路径分隔符永远是/。Windows 路径里的\在 SFTP 协议里不是分隔符但你把C:\data\file.txt直接替换成 remote 路径时很容易拼出data\file.txt然后服务器返回No such file。处理方式remotePath 手动用/拼或者remotePath.Replace(\\, /)。路径开头建议强制加/除非你知道自己在用相对路径。如果远程目录不存在UploadFile不会像 FTP 那样自动创建多级目录需要先client.CreateDirectory(dir)要判断目录是否存在用client.Exists(path)别用Directory.Exists因为那是操作本地文件系统的。另一个细节是远程文件名中的[]、*等通配符字符在GetAttributes里可能会被当成模式匹配。SSH.NET 的部分方法会对路径做通配符展开文件名字面量里带[时建议先转义或者在服务器端改名。这个坑比较冷门碰到了会浪费一整天。6. 进阶让进度条更平滑顺手验证文件完整性6.1 用低通滤波让百分比不再跳变IProgressT确实能防止跨线程异常但无法解决“回调频率太高导致 UI 消息没完没了”。我常维护_displayPercent每次拿到新进度时只有变化超过阈值才更新 UIprivate double _displayPercent; private DateTime _lastUiRefresh DateTime.MinValue; private void OnProgress(double actualPercent) { if (actualPercent - _displayPercent 0.1) return; _displayPercent actualPercent; if ((DateTime.Now - _lastUiRefresh).TotalMilliseconds 100) return; _lastUiRefresh DateTime.Now; ReportUI(actualPercent); }这样大文件进度条看起来是平滑的又不会在高速局域网下把 UI 线程打成热点。注意开始上传前要把_displayPercent初始化为 0否则第一个回调会被阈值挡住。6.2 对传输结果做 SHA-256 校验进度条走到 100% 并不代表文件完整。SFTP 协议有链路层完整性保证但不保证服务器落盘后的内容绝对一致。关键业务上我会用增量哈希做最终校验上传完成后读远程流算一遍和本地比对using var localFs new FileStream(localPath, FileMode.Open, FileAccess.Read); var localHash SHA256.HashData(localFs); using var remoteFs client.Open(remotePath, FileMode.Open, FileAccess.Read); var remoteHash SHA256.HashData(remoteFs); if (!Convert.ToHexString(localHash).Equals(Convert.ToHexString(remoteHash), StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException(远端文件和本地不一致); }SHA256.HashData(Stream)是较新的 .NET API如果你被旧框架卡住就用同步ComputeHash放到Task.Run里。大文件校验等于多读一次完整内容耗时正常进度条别再算校验用户看到 100% 后又转圈会骂人所以我会在校验前先把状态栏写成“校验中”。做完这些这个功能的工程化程度已经超过大多数内部工具了。我自己曾经把OperationTimeout设成 10 秒结果传 1.8GB 日志包时每传必断一次后来才发现是超时设置惹的祸也曾经偷懒不补 100% 回调被用户截图投诉“程序卡在 98%”。这些坑都不难躲就看有没有人提前告诉你。希望帮到你。本文还有配套的精品资源点击获取