
做C#开发的朋友文件操作这块儿迟早要啃。不管是做上位机采集数据、写日志、读配置文件还是处理Excel导入导出本质上都在跟文件打交道。C#里处理文件的方式有很多从最简单的File.ReadAllText到需要精细控制的FileStream各有各的适用场景。这篇就结合我实际踩过的坑把C#文件操作从入门到进阶的要点捋一遍适合刚接触C#的初学者也适合想系统梳理文件操作细节的开发者。1. 文件操作的整体设计与思路拆解1.1 为什么文件操作是C#基础里的硬骨头很多人觉得文件操作不就是读写文件吗实际上文件操作涉及的不只是“读”和“写”还包括路径处理、目录遍历、权限判断、编码转换、并发访问、异常恢复等一系列问题。如果把程序比作一个办公室那文件就是档案柜里的纸质材料文件操作就是管理这些材料的流程怎么找柜子路径、怎么分类放目录、怎么防止别人同时修改文件锁定、怎么处理材料损坏异常。每一样都有坑。C#提供了多套文件操作的API初学的时候容易懵。以.NET 6为基础日常用的主要是System.IO命名空间下的File、Directory、Path、FileStream、StreamReader/Writer、FileInfo/DirectoryInfo。这些类的关系大致是静态类提供快速入口实例类适合反复操作Stream系列处理大文件和精细控制。实际项目里我经常看到有人拿着一把小号File.ReadAllText去干挖掘机超大文件的活结果内存直接爆炸。理解这些类的分工是写好文件操作的第一步。1.2 核心类库选型File、Directory、Path与Info系列怎么选初学阶段最常用的就是静态类File和Directory它们把大部分操作封装成了静态方法比如File.Exists、File.ReadAllText、Directory.GetFiles。优点是简单直接缺点是大文件读取时会一次性把内容加载进内存容易内存暴涨。而FileInfo和DirectoryInfo是实例类需要先new一个对象然后调用实例方法。它们适合需要多次操作同一个文件或目录的场景比如先判断再创建再写入实例类可以缓存路径信息代码更好读。Path类专门处理路径字符串比如Path.Combine、Path.GetExtension、Path.GetFileName。这里我比较想强调的是Path.Combine比手写字符串拼接安全得多。很多人习惯用\或者/直接拼路径但遇到用户输入带斜杠的目录名就会出问题。用Path.Combine可以自动处理分隔符这属于基础中的基础但真的有人在这里翻车。类类型适用场景注意事项File静态类单次读写、文件判断、复制移动大文件慎用ReadAllTextDirectory静态类目录创建、遍历、判断权限不足时可能整个异常抛出Path静态类路径拼接、扩展名提取不要手动拼路径FileInfo/DirectoryInfo实例类多次操作同一文件/目录读取属性实例需要重新Refresh才能获取最新状态FileStream实例类大文件读写、二进制操作、自定义缓冲需要配合using释放资源StreamReader/Writer实例类文本流读写、逐行处理指定编码避免乱码1.3 理解“流”这个概念很多人看到Stream就头大。其实可以把文件想象成一根水管FileStream是水管本身数据就是水。StreamReader是水管上装的过滤器能把字节翻译成字符BinaryReader则是另一个过滤器能把字节翻译成int、double这些基础类型。水管阀门就是FileMode、FileAccess、FileShare控制水流的方向和能不能让其他人同时用。理解了这套比喻再去看MSDN文档就不会那么抽象了。File.ReadAllText的底层其实也是先开了一个FileStream再套StreamReader最后把整个文件读进来只不过微软帮你把这几步封装成一行了。平时用封装好的写法没问题但一旦遇到大文件、文件占用、编码异常这些场景就得回到流式处理这条路上来。2. 文本与二进制文件读写的正确姿势2.1 文本读写从傻瓜式API到流式处理先看最简单的文本读写。用File.ReadAllText读取整个文件再用File.WriteAllText写入适合操作小配置文件。我见过有人用这个方法读取上百MB的日志文件结果程序直接卡死内存占用飙升。原因很简单ReadAllText会一次性把整个文件内容读入字符串而字符串是Unicode字符数组如果文件是UTF-8编码100MB的文件读进来可能占两三百MB内存。所以大文件应该用StreamReader一行一行读。代码示例using var reader new StreamReader(D:\logs\app.log, Encoding.UTF8); string? line; while ((line reader.ReadLine()) ! null) { // 处理每一行 Console.WriteLine(line); }用using声明可以自动释放资源这个习惯一定要养成。StreamReader默认会检测BOM如果文件没有BOM但实际是UTF-8最好在构造时指定Encoding.UTF8避免中文乱码。写入的时候同样大文件用StreamWriter配FileStream设置append: true可以追加内容。我习惯写成using var writer new StreamWriter(D:\logs\app.log, append: true, Encoding.UTF8); writer.WriteLine($[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message});这里的append参数很有用日志系统就靠它。另外还有一个细节StreamWriter默认缓冲区是4KB写入的内容并不会立刻落到磁盘而是先积攒在内存里。如果想实时落盘需要调用writer.Flush()或者设置AutoFlushtrue。特别是写日志的时候如果程序突然崩溃缓冲区里的日志很可能就丢了。这件事儿踩过坑的人都知道。2.2 二进制读写BinaryReader/Writer 与 FileStream如果处理图片、压缩包、自定义协议文件就需要二进制读写。BinaryReader和BinaryWriter提供了基本类型与字节数组之间的转换比如ReadInt32、WriteDouble。底层还是FileStream负责和磁盘打交道。一个典型的写法using var fs new FileStream(D:\data\record.bin, FileMode.Create, FileAccess.Write); using var bw new BinaryWriter(fs); bw.Write(123); bw.Write(3.14); bw.Write(hello);注意BinaryWriter.Write(string)会先写入字符串长度7位整数编码再用UTF-8写入内容所以读的时候也要配套BinaryReader.ReadString。读写顺序必须一致这就像两个人约好密码本先写姓名再写年龄读的时候也得先读姓名再读年龄顺序错了数据就全乱了。FileStream构造函数有多个参数FileMode、FileAccess、FileShare是三个关键枚举。FileMode.Open表示文件必须存在FileMode.Create表示如果文件存在就覆盖不存在就新建FileAccess决定你是读、写还是读写FileShare则决定其他进程能不能同时访问文件。其中FileShare是最容易忽略的比如写日志时如果设置了FileShare.Read其他进程就只能读不能写如果不设置默认是FileShare.None其他进程连读都不行这就是“文件被占用”的常见原因之一。后面专门讲。编码问题也是文件操作的重灾区。C#字符串是UTF-16而文件保存的编码可能是GBK、UTF-8、Latin-1等。用File.ReadAllText(path)默认会检测BOM没有BOM时默认使用UTF-8。如果文件是GBK编码比如国内很多老系统导出的TXT不指定编码就会乱码。解决办法是用Encoding.GetEncoding(GBK)或者用.NET的CodePagesEncodingProvider注册后读取。这个在后面的常见问题里细说。3. 目录遍历与文件信息管理3.1 用Directory和DirectoryInfo遍历目录遍历目录是文件操作里非常常见的需求。Directory.GetFiles能一次性返回所有文件路径Directory.EnumerateFiles则是懒加载的迭代器适合目录里文件特别多的情况。举个实际例子我要找出某个目录下所有PDF文件最简单的方式var files Directory.GetFiles(D:\docs, *.pdf, SearchOption.AllDirectories);SearchOption.AllDirectories会递归子目录。注意如果目录里存在无权限访问的子目录这个方法会直接抛UnauthorizedAccessException而不是跳过。遇到这种情况要么改用EnumerateFiles自己做try-catch要么用DirectoryInfo.GetFiles配合异常处理。这里有一个坑Directory.GetFiles在权限不足时不会跳过而是整个异常抛出所以生产环境最好自己写递归遍历逐层判断。递归遍历的写法void WalkDirectory(string path) { foreach (var dir in Directory.GetDirectories(path)) { WalkDirectory(dir); } foreach (var file in Directory.GetFiles(path)) { Console.WriteLine(file); } }虽然简单但要注意递归深度过大可能导致栈溢出不过普通场景基本不会。如果目录结构特别深可以考虑用Stack写一个非递归版本不过那是另一个话题了。3.2 文件属性、大小与时间戳的读取FileInfo可以很方便地获取文件的Length、CreationTime、LastWriteTime等。有时候我们需要判断一个文件是否正在被写入可以通过比较两次LastWriteTime的值来间接判断。示例var fi new FileInfo(D:\data\file.csv); DateTime first fi.LastWriteTime; Thread.Sleep(1000); fi.Refresh(); // 注意FileInfo会缓存状态必须Refresh DateTime second fi.LastWriteTime; if (first ! second) { Console.WriteLine(文件还在被修改); }这里的fi.Refresh()是特别容易漏掉的点。FileInfo对象一旦创建它内部的属性值是缓存的如果文件被外部修改直接再次读取LastWriteTime得到的还是旧值必须调用Refresh()更新。这个细节在监控日志文件时尤其重要我当时在这个坑里爬了半小时。3.3 路径拼接与标准化Path类的妙用Path.Combine用于拼接路径Path.GetFullPath用于把相对路径转绝对路径。很多人分不清相对路径的基准目录。代码里写config.ini实际指的是当前工作目录而不是程序集所在目录。在服务类的程序里工作目录经常和预期不一致所以读取配置时最好用AppContext.BaseDirectory来定位var configPath Path.Combine(AppContext.BaseDirectory, config.ini);AppContext.BaseDirectory是程序集所在的目录不管从哪个目录启动程序都不会受影响。这个习惯能省掉很多环境相关的问题。还有Path.GetExtension可以快速判断文件类型Path.GetDirectoryName可以拿到父目录这些操作都比手写字符串处理可靠。另外Path.ChangeExtension可以替换扩展名比如把a.txt改成a.log一行搞定不用自己去截取字符串再拼后缀。4. 文件操作中的异常、权限与文件占用问题4.1 常见异常梳理IOException、UnauthorizedAccessException等文件操作最常见的异常有FileNotFoundException要读的文件不存在DirectoryNotFoundException目录不存在UnauthorizedAccessException没有权限或文件被只读属性限制IOException底层IO错误包含文件被占用、磁盘满、路径非法等ArgumentException路径字符串包含非法字符这些异常并不可怕可怕的是没有针对性地处理。比如读一个可能不存在的文件有些人图省事直接让异常往上抛结果整个程序崩掉还有些人为了写一个文件try-catch里包了巨大的业务逻辑看起来功能正常实际上真正的错误被吞掉了。合理的做法是先用File.Exists判断再在操作时包一层try-catch而且catch的异常类型要尽量精确至少要区分文件不存在和权限不足。注意File.Exists返回false也可能是权限不足导致的并不是真的不存在这一点在排查网络盘和共享目录时特别容易被忽略。4.2 文件被其他程序占用FileShare与重试机制“文件正由另一进程使用因此该进程无法访问此文件”这个错误应该是C#开发者最熟悉的报错之一。比如我要读取一个Excel文件但用户正用WPS打开着它这时File.Open默认的FileShare是None独占访问就会抛出IOException。解决办法有几个层次。第一个层次是阅读并打开时指定FileShare.ReadWriteusing var fs new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);这样即使文件被其他进程以写模式打开我们也能读取到当前内容。但要注意如果其他进程以独占方式打开FileShare.ReadWrite也不行只能等对方释放。第二个层次是加入重试机制捕获IOException后间隔一段时间再尝试比如最多重试3次。因为很多占用是瞬时的用户保存数据后马上释放。第三个层次是如果确实需要强制释放其他进程的占用那就要用Windows API或者第三方工具比如handle.exe但这些方案比较重不建议轻易在生产环境使用。还有一个土办法先以FileShare.ReadWrite打开文件流然后尝试将文件复制出来再把复制出来的文件当作读取对象这样也能绕过一部分占用问题但并非万能。4.3 处理受保护的系统文件与权限不足的应对思路有的文件需要TrustedInstaller权限才能修改普通管理员也无法直接操作。对于这类文件最佳做法是不要试图强行修改而是在程序启动时做权限检测并给出明确提示。代码层面可以用File.GetAttributes查只读属性然后File.SetAttributes去掉只读。但对于系统级ACLC#里很麻烦需要P/Invoke或者使用Windows API修改ACL风险较高。我个人的原则是程序只管自己能管的文件遇到权限不足就清晰地告诉用户而不是悄悄失败。这在写安装卸载程序、清理工具时尤为重要。5. 从文件操作到实际业务日志、Excel与上位机数据采集5.1 一个够用的日志模块从零手写说到文件操作最大的实际业务场景就是日志。虽然现在有很多日志库比如NLog、Serilog但理解手写日志的原理很重要。一个最小可用的日志模块要考虑目录不存在时自动创建、按天分割日志、写入时加锁避免多线程写入乱掉。我见过最简单的写法public static void Log(string message) { var dir Path.Combine(AppContext.BaseDirectory, logs); Directory.CreateDirectory(dir); // 目录不存在会自动创建不抛异常 var file Path.Combine(dir, $log_{DateTime.Now:yyyyMMdd}.txt); lock (_lockObj) { using var writer new StreamWriter(file, append: true, Encoding.UTF8); writer.WriteLine(${DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}); } }这里有几个细节Directory.CreateDirectory在目录已存在时不会报错所以可以直接调用StreamWriter的append参数保证不覆盖之前内容lock保证了多线程环境下写入不交错。如果不用lock两个线程同时WriteLine可能会内容换行错乱。这个模块再扩展就是日志轮转、过滤级别但核心原理就是这些。5.2 读取Excel/CSV文件操作和数据的交界C#读取Excel的常见方案有NPOI、EPPlus、ClosedXML底层其实都要处理文件流。很多初学者用Excel COM组件读取容易遇到释放不干净、程序卡死的问题像有些朋友提到的“C#无法读取Excel中的数据并打印”多半就是COM对象没有释放导致的。好的做法是直接用NPOI或者先把Excel另存为CSV再用StreamReader读。CSV本质上就是文本文件用文件操作的基础知识就能处理。比如读取一个CSV文件并解析using var reader new StreamReader(D:\data\students.csv, Encoding.UTF8); string? line; while ((line reader.ReadLine()) ! null) { var fields line.Split(,); Console.WriteLine(${fields[0]} - {fields[1]}); }处理CSV时要注意字段中可能包含逗号、换行和引号严格来说需要用CsvHelper这类专业库来解析但对绝大多数简单文件来说StreamReader加Split的思路已经够用。如果一开始就追求完美解析反而会把文件操作的学习引入歧途。先掌握基础再按需引入复杂工具这个节奏最合理。5.3 上位机采集数据落盘说到上位机软件核心工作之一就是把采集到的数据写到文件比如实时保存到CSV或者二进制文件。这种场景对文件操作的实时性和可靠性要求很高。我的做法是用FileStream的WriteAsync异步写入避免UI线程卡顿同时每写入一批就调用Flush把数据及时落到磁盘并且按日期切换文件。异步写入的示例using var fs new FileStream(D:\data\torque.csv, FileMode.Append, FileAccess.Write, FileShare.Read); using var writer new StreamWriter(fs, Encoding.UTF8); await writer.WriteLineAsync(${DateTime.Now:yyyy-MM-dd HH:mm:ss},{torque}N·m); await writer.FlushAsync();注意FileShare.Read表示允许其他程序读取但不能再写入防止多进程同时写同一个文件。这段代码里的“扭矩值”可以是任何传感器采集数据核心思想是异步、及时Flush、允许读取方同时访问。如果是多设备同时采集建议再套一层ConcurrentQueue把数据先放进队列再由专门的写入线程落盘这样即使磁盘偶尔慢一点也不会丢数据采集线程也不会被文件IO阻塞。6. 常见问题与排查技巧实录6.1 杂症路径、编码、权限一锅端我打算把平时最常见的几个问题整理成速查表方便大家直接对照排查。问题现象可能原因解决思路读取中文乱码编码不匹配文件是GBK但没有指定编码用Encoding.GetEncoding(GBK)或注册CodePagesEncodingProvider找不到文件相对路径基准目录不是程序集目录使用AppContext.BaseDirectory拼路径文件被占用其他进程以独占方式打开文件使用FileShare.ReadWrite或加入重试机制权限不足文件ACL不允许当前用户写入检查只读属性、以管理员身份运行或明确提示FileInfo属性不更新实例缓存了文件状态调用Refresh()方法刷新读取超大文件卡死用了一次性ReadAllText改用StreamReader或StreamWriter逐行处理无法删除非空目录Directory.Delete递归参数未设置Directory.Delete(path, true)这个表格是平时问题排查的核心建议收藏。每个问题后面都有一套完整的处理逻辑下面挑两个详细说一下。第一个是编码问题。很多人遇到中文乱码第一反应是换一个Encoding但不知道具体选哪个。在Windows上如果是简体中文系统导出的旧版TXT/CSV大概率是GBK如果文件里有UTF-8的BOM用默认读取没问题如果没有BOM却是一堆英文字符加中文就要先看字节。更稳妥的做法是读取文件的前几个字节判断编码但这样比较复杂。实用技巧是先用UTF-8读一次如果中文乱码再用GBK读一次基本能解决90%的问题。在.NET Core里要使用GBK需要先安装System.Text.Encoding.CodePages包然后调用Encoding.RegisterProvider(CodePagesEncodingProvider.Instance)。第二个是路径问题。程序一换目录运行就找不到文件这种问题多半是因为用了“相对路径”。.NET里的相对路径是相对于当前工作目录的而工作目录在IDE里调试和双击exe运行通常不同。解决办法就是之前说的用AppContext.BaseDirectory固定基准位置。如果项目部署为Windows服务工作目录更是五花八门不固定路径早晚出事。6.2 排查文件占用问题的实操技巧文件被占用时最典型的排查方式是重启程序但重启后可能又出现。我常用的办法先看是不是自己程序的多个线程同时打开了同一个文件。用进程内锁或者只保留一个打开的流。用资源监视器的“搜索句柄”功能搜索文件名看是哪个进程占用了文件这个比猜快得多。在C#代码里诊断可以尝试用FileStream打开如果抛IOException就catch住并记录尝试时间然后重试。还有一个思路尽量晚打开、早关闭。能用using就用using不要手动把FileStream存成全局变量很容易忘记关闭。有些人为了性能把FileStream一直开着不释放结果后续要备份或者重命名文件时就出错。我一直以来的习惯是读写文件要快进快出除非是专用的日志写入器否则不要长期占用文件句柄。6.3 关于文件操作的一个高级建议抽象出统一接口最后我想分享一个经验在企业项目里文件操作最好不要散落在业务代码里而是封装一个FileService统一处理路径、编码、日志、异常。比如定义IStorage接口提供ReadText、WriteText、Delete等方法然后在实现里统一处理文件占用重试。这样以后要换云存储、对象存储只需要换实现即可。文件操作虽然基础但设计良好的抽象能让项目干净很多。我带的项目组基本都这么干省心不少。我自己刚入行那会儿写文件操作只顾着功能能跑没考虑文件占用和编码结果上线的程序第二天就出现日志写不进去、配置文件乱码的故障。后来踩了几次坑才慢慢摸清System.IO的脾性。所以建议各位在写任何文件相关功能时先从“文件可能被谁占用”“编码是什么”“路径有没有问题”这三个角度想一遍再动手写代码绝对能少走不少弯路。