Skip to main content

任务三 读写文件(二进制)

一、“流”

(一)“流”是什么

流(Stream) 就是数据在程序和外部设备之间传输的通道

  • 可以把流想象成**“水管”**:
    • 数据 = 水
    • 程序 = 水池
    • 文件/网络/硬盘 = 另一个水池
  • 流的作用:读数据、写数据、传输数据
  • 本质:连续的字节序列

(二)“流”的分类

  1. 按方向分

    • 输入流:从外部 → 程序(读)
    • 输出流:从程序 → 外部(写)
  2. 按操作对象分

    • 文件流(FileStream):操作文件
    • 内存流(MemoryStream):操作内存
    • 网络流(NetworkStream):操作网络
  3. 按数据类型分

    • 字节流:以字节为单位(FileStream)
    • 字符流:以字符为单位(StreamReader/StreamWriter)

二、FileStream

(一)FileStream 类是什么

FileStream 是 C# 中以字节为单位操作文件的流类,位于 System.IO

作用:

  • 读取文件字节
  • 写入文件字节
  • 对文件进行低级、底层的读写(图片、视频、文本都能用)

FileStream 就是一个“数据流”,它负责在你的程序(内存)和硬盘上的文件之间建立一条通道,让数据可以像水流一样持续地、顺序地读写。

  • FileStreamSystem.IO命名空间下的字节流类,用于以字节为单位读写文件;
  • 使用前需引入命名空间:using System.IO;
  • 所有示例均使用using块管理FileStream资源,避免文件句柄泄漏;
  • 字节操作需注意编码(如UTF-8),文本内容需转换为字节数组后读写。

(二)字节流

可以把FileStream理解为“程序和文件之间传输字节的「管道」”,这个“管道”有以下关键特点:

  1. FileStream本质是字节流,其传输/存储的最小单位是「字节(Byte)」,而字节最终由二进制0和1组成
  2. FileStream作为“数据流”的核心特点,是围绕“字节”的传输规则展开的。
  3. FileStream只认「字节(Byte)」,不管你写入的是数字、字符串还是中文,最终都会转换成字节数组,再写入文件;
  4. 1个字节 = 8位二进制(0/1),字节的取值范围是 0~255;比如字节65对应二进制01000001
  5. 不同类型的数据,转换为字节的规则不同(这是理解的关键)。

1. 字节与二进制的关系

FileStream操作的是「字节」,但字节的本质是8位二进制数(0/1):

  • 1字节 = 8位(bit),每位只能是0或1;
  • 比如字节值65 → 二进制01000001 → 对应ASCII码的字符'A';
  • 比如bool值true → 存储为字节1(二进制00000001),false→字节0(二进制00000000)。

2. FileStream与二进制的关系(新手易理解)

  • 传输层面:FileStream在程序和文件之间传输的是「字节」,你无需直接操作0和1(由系统自动处理);
  • 存储层面:文件在硬盘上最终以二进制0和1存储,FileStream是程序访问这些二进制数据的“桥梁”;
  • 示例:用fs.WriteByte(65)写入1个字节,硬盘上会存储8位二进制01000001;用fs.ReadByte()读取时,系统把二进制转回字节65,返回给程序。

3. 文本文件 vs 二进制文件(FileStream视角)

对FileStream来说,文本文件和二进制文件没有本质区别——都是字节流:

  • 文本文件:字节是“字符的编码值”(比如UTF8的“中”对应字节0xE4 0xB8 0xAD);
  • 二进制文件:字节是“数值/对象的原始存储”(比如int 12345对应4个字节0x30 0x39 0x00 0x00);
  • 区别仅在于“字节的解读方式”:用StreamReader(按编码解读)就是文本文件,用BinaryReader(按类型解读)就是二进制文件。

案例1:数字类

先明确:数字的“字面量”和“存储字节”是两回事

比如你看到的数字100,FileStream不会直接写“100”这三个字符,而是按「数值类型的字节存储规则」转换(以最常用的int类型为例,int占4个字节)。

原始数字数据类型转换为字节数组(十六进制)对应二进制(8位/字节)FileStream实际写入的内容
100int0x64 0x00 0x00 0x00(小端存储)01100100 00000000 00000000 000000004个字节:64、0、0、0
123int0x7B 0x00 0x00 0x0001111011 00000000 00000000 000000004个字节:123、0、0、0
123byte0x7B011110111个字节:123
关键解释:
  • 你写fs.WriteByte(100)(写入byte类型100):实际写入1个字节0x64,对应二进制01100100
  • 你写bw.Write(100)(BinaryWriter写入int类型100):实际写入4个字节(int占4字节),按“小端序”存储为64 00 00 00
  • 数字0123在代码里是123(前导0无意义),所以转换规则和123一致。

案例2:字符串类

关键:字符串转换为字节,必须指定「编码」(如UTF8、ASCII),不同编码的字节结果不同

FileStream本身不处理编码,需通过StreamWriter/BinaryWriter指定编码,再转换为字节。

子案例2.1:字符串 "helloworld"(ASCII/UTF8编码,英文兼容)

ASCII编码下,每个英文字母对应1个字节(值=ASCII码),UTF8编码对英文和ASCII完全一致。

字符串字符helloworld
ASCII码值104101108108111119111114108100
十六进制0x680x650x6C0x6C0x6F0x770x6F0x720x6C0x64
二进制01101000011001010110110001101100011011110111011101101111011100100110110001100100
FileStream写入10个字节:104、101、108、108、111、119、111、114、108、100
代码验证(写入"helloworld",读取字节):
using (FileStream fs = new FileStream("test.txt", FileMode.Create, FileAccess.Write))
using (StreamWriter sw = new StreamWriter(fs, Encoding.UTF8))
{
sw.Write("helloworld"); // 按UTF8编码转换为字节后写入
}

// 读取验证字节
using (FileStream fs = new FileStream("test.txt", FileMode.Open, FileAccess.Read))
{
byte[] buffer = new byte[10];
fs.Read(buffer, 0, 10);
Console.WriteLine(string.Join(",", buffer)); // 输出:104,101,108,108,111,119,111,114,108,100
}
子案例2.2:字符串 "测试"(UTF8编码,中文占多字节)

UTF8编码下,1个中文汉字占3个字节,这也是为什么直接用FileStream读中文会乱码(没按编码转换)。

字符串字符
UTF8字节(十六进制)0xE6 0xB5 0x8B0xE8 0xAF 0x95
二进制11100110 10110101 1000101111101000 10101111 10010101
FileStream写入6个字节:230、181、139、232、175、149
代码验证:
using (FileStream fs = new FileStream("test.txt", FileMode.Create, FileAccess.Write))
using (StreamWriter sw = new StreamWriter(fs, Encoding.UTF8))
{
sw.Write("测试");
}

using (FileStream fs = new FileStream("test.txt", FileMode.Open, FileAccess.Read))
{
byte[] buffer = new byte[6];
fs.Read(buffer, 0, 6);
Console.WriteLine(string.Join(",", buffer)); // 输出:230,181,139,232,175,149
}

案例3:布尔值(true/false)

布尔值在二进制文件中通常存储为1个字节:

布尔值转换为字节二进制FileStream写入内容
true0x01000000011个字节:1
false0x00000000001个字节:0

核心总结(新手必记)

  1. FileStream的“底层字节流”:不管你写入的是数字、字符串、中文,最终都会被转换成字节数组,再写入文件;字节的底层是二进制0和1(1字节=8位);
  2. 不同数据类型的转换规则:
    • 数字(int):按类型长度转换(int=4字节、byte=1字节),小端存储;
    • 字符串:按编码转换(UTF8英文1字节、中文3字节;ASCII仅支持英文);
    • 布尔值:1字节(1=true,0=false);
  3. 通俗理解:
    • 你看到的100 → FileStream眼里是64(字节) → 硬盘里存的是01100100(二进制);
    • 你看到的helloworld → FileStream眼里是104,101,...100(字节数组) → 硬盘里是一串01组合;
    • 你看到的测试 → FileStream眼里是230,181,...149(字节数组) → 硬盘里是更长的01组合。

简单记:FileStream不认识“数字/文字”,只认识“字节”;字节是0和1组成的8位组合,是计算机存储的最小单位

(三)特点

  • 字节流
  • 操作大文件、二进制文件最常用
  • 需要手动控制字节数组、缓冲区

想象一下,你要用一个水管给花园浇水。

  • 文件就是你的水桶(数据存储的地方)。
  • FileStream就是那根水管
  • 数据就是

(四)FileStream类的基本用法

用法 1:传统语法

// 1. 引用命名空间
using System.IO;

// 2. 创建 FileStream 对象(打开/创建文件)
FileStream fs = new FileStream("文件路径", FileMode, FileAccess);
// 3. 读 / 写数据
fs.Read(byte数组, 偏移, 长度); // 读
fs.Write(byte数组, 偏移, 长度); // 写
// 4. 关闭并释放流
fs.Close();

用法 2:using 自动释放(推荐):

针对 FileStream 的场景,逐部分解释:

语法部分作用说明
using关键字,标记“资源管理块”,告诉编译器:这个块内的对象是“一次性资源”,用完要自动释放
(FileStream fs = new FileStream(...))1. 创建FileStream对象并赋值给变量fs
2. 这个对象必须实现 IDisposable 接口(FileStream天然实现),这是using的前提;
3. 括号内只能声明“需要释放的资源对象”
{ ... }资源的作用域:
1. fs 只能在花括号内使用,外部无法访问;
2. 花括号执行完毕(无论正常结束还是抛出异常),编译器会自动调用 fs.Dispose() 方法
// 完整结构
using (资源类型 变量名 = 新建资源对象)
{
// 只有在花括号内,这个资源对象才可用
// 这里执行文件的读写操作
} // 花括号结束时,编译器自动调用资源对象的Dispose()方法,释放资源

示例

using (FileStream fs = new FileStream(...))
{
// 读写操作
}

(五)FileMode

FileMode 是枚举,用来指定文件打开/创建的方式。

枚举取值核心用法适用场景考试易错点/注意事项
Create创建新文件;若文件已存在,覆盖原有文件(清空所有内容)需重新生成文件(如写入全新数据)1. 会覆盖已有文件,慎用;
2. 文件不存在时创建,存在时直接覆盖,无任何提示
CreateNew创建新文件;若文件已存在,抛出IOException异常确保文件是全新的(如生成唯一日志文件)1. 避免重复创建相同文件;
2. 比Create更严格,考试常考与Create的区别
Open打开已存在的文件;文件不存在则抛出FileNotFoundException异常仅读取已有文件(如读取配置文件)1. 必须确保文件已存在;
2. 考试高频考点(最常用的读取模式)
OpenOrCreate文件存在则打开,不存在则创建 (默认值)不确定文件是否存在时(如首次运行的配置文件)1. 不会覆盖已有文件;
2. 若文件新建后未写入内容,会生成空文件
Append打开文件并将流位置定位到文件末尾;文件不存在则创建向文件末尾追加内容(如日志记录)1. 仅能配合FileAccess.Write使用;
2. 写入内容只会追加到末尾,不会修改原有内容
Truncate打开已存在的文件并清空所有内容;文件不存在则报错清空文件后重新写入(保留文件本身,仅删内容)1. 必须文件已存在;
2. 区别于Create:Truncate是“清空已有文件”,Create是“覆盖/新建文件”

语法:

FileStream(string path, FileMode mode)
// FileAccess.ReadWrite(默认值)

参数解释:

  1. string path:

    • 是什么:文件在磁盘上的位置,可以是绝对路径(如 C:\MyFiles\test.txt)或相对路径(如 data.txt,表示在程序运行目录下)。

    • 注意:路径中的反斜杠\在C#字符串中是转义字符,所以要写两个\\或者使用@前缀。推荐使用@

      string goodPath1 = "C:\\MyFiles\\test.txt";
      string goodPath2 = @"C:\MyFiles\test.txt"; // 更清晰
  2. FileMode mode (这是一个枚举类型):

    • 是什么:它告诉操作系统你打算如何打开这个文件。这是最关键的参数!
    • 常见选项
      • FileMode.Create创建新文件。如果文件已存在,它会被覆盖(旧内容清空)。“不管有没有,给我一个新的!”
      • FileMode.Open打开已存在的文件。如果文件不存在,则抛出 FileNotFoundException 异常。“把那个已有的文件给我打开。”
      • FileMode.CreateNew创建新文件。如果文件已存在,则抛出异常。“我要创建一个全新的,不能有重名的!”
      • FileMode.Append打开文件并移动到末尾,准备添加数据。如果文件不存在,会创建一个新文件。“我要在文件后面接着写。”

(六)FileAccess 枚举

指定路径、模式和访问方式

这个构造函数在第一个的基础上增加了更精细的控制:你不仅可以决定如何打开文件,还能决定用这个文件来做什么(只读、只写、读写)。

语法:

FileStream(string path, FileMode mode, FileAccess access)

新增参数解释:

  • FileAccess access (这是一个枚举类型):
    • 是什么:它规定了你的程序对文件的访问权限。在多线程或需要安全控制的场景下非常有用。
    • 常见选项
      • FileAccess.Read只读。你可以读取文件,但不能修改它。
      • FileAccess.Write只写。你可以向文件写入数据,但不能读取它。
      • FileAccess.ReadWrite可读可写。这是最灵活的选项。

FileAccess 是枚举,指定对流的访问权限。

枚举取值核心用法适用场景考试易错点/注意事项
Read仅赋予“读取”权限,无法写入/修改文件仅读取文件内容(如查看配置、读取日志)1. 若尝试调用Write/WriteByte等写入方法,会抛出NotSupportedException;
2. 考试常考“权限与操作不匹配”的异常场景
Write仅赋予“写入”权限,无法读取文件内容仅写入/覆盖文件(如生成报告、写入日志)1. 若尝试调用Read/ReadByte等读取方法,会抛出NotSupportedException;
2. Append模式必须配合Write权限
ReadWrite同时赋予“可读+可写”权限 默认值需同时读写文件(如修改配置文件、编辑文档)1. 权限最大,但占用资源更多;
2. 非必要时优先用Read/Write(最小权限原则)

补充:FileMode + FileAccess 常用组合(考试高频考点)

组合示例场景说明代码示例
Open + Read读取已有文件(最常用)new FileStream("test.txt", FileMode.Open, FileAccess.Read)
Create + Write新建/覆盖文件并写入new FileStream("test.txt", FileMode.Create, FileAccess.Write)
Append + Write向文件末尾追加内容new FileStream("log.txt", FileMode.Append, FileAccess.Write)
OpenOrCreate + ReadWrite兼容文件存在/不存在,且需读写new FileStream("config.dat", FileMode.OpenOrCreate, FileAccess.ReadWrite)

(七)关键要点与最佳实践

  1. 务必使用 using 语句
    • FileStream 使用了非托管资源(如文件句柄)。using 语句能确保即使在发生异常的情况下,流也能被正确关闭和释放,避免资源泄漏。这是极其重要的习惯!
  2. 理解组合效果
    • 不同的 FileModeFileAccess 组合有不同的效果。例如:
      • FileMode.Open + FileAccess.Write:打开一个已存在的文件准备修改。
      • FileMode.Create + FileAccess.Read:这没什么意义,因为你创建了一个新文件却只想读它(内容是空的)。
  3. 处理异常
    • 文件操作很容易出错(文件不存在、没有权限等)。一定要使用 try-catch 块来捕获和处理可能的异常(如 FileNotFoundException, UnauthorizedAccessException)。
  4. 对于简单任务,有更简单的工具
    • 如果你只是读写一些文本,File.WriteAllTextFile.ReadAllText 等方法更简单。
    • FileStream 给你提供了最根本、最强大的控制能力,适用于处理大文件、二进制文件或需要特定读写模式的场景。

(八)总结对比表

构造函数特点适用场景
FileStream(path, mode)基础,自动获得读写权限大多数简单的文件创建、覆盖、读取场景
FileStream(path, mode, access)精细控制访问权限需要明确限制为只读或只写,提高代码安全性和意图清晰度

给初学者的练习建议:

  1. 创建一个控制台程序。
  2. 分别使用 FileMode.CreateFileMode.Append 向同一个文件写入文字,观察区别。
  3. 尝试用 FileMode.OpenFileAccess.Read 去打开一个不存在的文件,看看会发生什么,然后学会用 File.Existstry-catch 来处理它。

(九)总结(核心关键点)

  1. 单字节 vs 多字节
    • WriteByte()/ReadByte():操作单个字节,适合逐字节处理(如二进制文件);
    • Write()/Read():批量操作字节数组,适合文本/大文件(效率更高);
  2. 返回值规则
    • ReadByte()返回int(0~255或-1),需判断-1表示文件末尾;
    • Read()返回实际读取字节数,0表示文件末尾;
  3. 流位置:读写操作后流的位置自动后移,可通过fs.Position手动调整位置;
  4. 编码注意:文本内容读写需统一编码(如UTF-8),避免乱码;
  5. 资源释放:必须通过using块/Close()释放FileStream,否则文件会被占用。

三、FileStream类的方法

一、FileStream.WriteByte() 方法

说明
用途写入单个字节的数据;写入后流的位置自动后移1个字节;若流为只读模式,抛NotSupportedException
语法fs.WriteByte(byte value)fsFileStream实例)
参数value:byte类型,要写入的单个字节(取值范围0~255)。
返回值无(void)。

基础示例

string filePath = "test.dat";
// 写入单个字节(ASCII码65对应字符'A')
using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
{
fs.WriteByte(65); // 写入字节65(二进制01100100)
fs.WriteByte((byte)'B'); // 也可直接传字符(自动转byte)
}

关键注意事项

  • 写入后流的位置自动后移1个字节;
  • 参数必须是byte类型(超出0~255会编译报错);
  • 仅适合写入少量单个字节,批量写入效率低(优先用Write())。

二、FileStream.Write() 方法

说明
用途写入字节数组的指定片段;写入后流位置自动后移写入的字节数;是最常用的批量写方法。
语法fs.Write(byte[] buffer, int offset, int count)
参数 buffer必传,byte[]类型,存储要写入的字节数组(数据源)
参数 offset必传,int类型,从buffer的第几个索引开始读取字节(通常传0)
参数 count必传,int类型,要从buffer中读取并写入的字节数
返回值void(无返回值)

基础示例

string filePath = "test.dat";
// 批量写入字节数组
using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
{
byte[] data = new byte[] { 65, 66, 67, 68 }; // 对应字符A/B/C/D
fs.Write(data, 0, data.Length); // 从索引0开始,写入全部4个字节

// 进阶:只写入数组的一部分(比如索引1开始,写2个字节)
fs.Write(data, 1, 2); // 写入66、67(B、C)
}

关键注意事项

  • 写入后流的位置自动后移count个字节;
  • offset + count超出数组长度,会抛出ArgumentOutOfRangeException
  • 批量写入优先用此方法(减少IO交互,效率更高)。

三、FileStream.ReadByte() 方法

说明
用途FileStream当前位置读取单个字节;读取后流位置自动后移1个字节;若已到文件末尾,返回-1;若流为只写模式,抛NotSupportedException
语法fs.ReadByte()(返回值需接收为int类型)
参数无。
返回值int类型:
- 成功:读取到的字节值(0~255);
- 失败(文件末尾):-1。

基础示例

string filePath = "test.dat";
// 读取单个字节
using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
int readByte;
// 循环读取直到流末尾(返回-1)
while ((readByte = fs.ReadByte()) != -1)
{
Console.WriteLine($"读取的字节:{readByte} → 字符:{(char)readByte}");
}
}

关键注意事项

  • 读取后流的位置自动后移1个字节;
  • 返回值是int(而非byte),因为需要用-1标识流末尾(byte无负数);
  • 仅适合读取少量单个字节,批量读取效率低(优先用Read())。

四、FileStream.Read() 方法

说明
用途FileStream当前位置读取批量字节到指定的字节数组中;读取后流位置自动后移实际读取的字节数;是最常用的批量读方法。
语法fs.Read(byte[] buffer, int offset, int count)
参数 buffer必传,byte[]类型,用于存储读取到的字节(数据容器)
参数 offset必传,int类型,从buffer的第几个索引开始存储读取的字节(通常传0)
参数 count必传,int类型,期望读取的字节数
返回值int类型:
✅ 成功:返回实际读取的字节数
❌ 流末尾:返回0

基础示例

string filePath = "test.dat";
// 批量读取字节
using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
byte[] buffer = new byte[1024]; // 定义缓冲区(通常设1024/4096字节)
int actualRead;

// 循环读取直到流末尾(返回0)
while ((actualRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
Console.WriteLine($"实际读取字节数:{actualRead}");
// 输出读取的内容(仅处理实际读取的字节,避免缓冲区冗余)
for (int i = 0; i < actualRead; i++)
{
Console.Write($"{(char)buffer[i]} ");
}
Console.WriteLine();
}
}

关键注意事项

  • 读取后流的位置自动后移actualRead个字节;
  • 返回值是「实际读取的字节数」(可能小于count,比如流末尾只剩3个字节,而count传5,则返回3);
  • 批量读取优先用此方法(缓冲区读取,减少IO交互);
  • 缓冲区大小建议设为1024/4096字节(匹配系统IO块大小,效率最高)。

五、4个方法核心对比(考试必记)

方法核心场景效率返回值关键适用场景
WriteByte()写入单个字节少量单个字节写入
Write()批量写字节数组批量二进制数据写入(推荐)
ReadByte()读取单个字节字节值/-1(末尾)少量单个字节读取
Read()批量读字节到数组实际读取数/0(末尾)批量二进制数据读取(推荐)

六、总结(考试核心记忆点)

  1. 写入二选一:单字节用WriteByte(),批量用Write()(优先Write());
  2. 读取二选一:单字节用ReadByte(),批量用Read()(优先Read());
  3. 返回值易错点
    • ReadByte()返回int(-1表示末尾),WriteByte()无返回值;
    • Read()返回「实际读取字节数」(0表示末尾),Write()无返回值;
  4. 核心规则:所有方法操作后,流的位置会自动后移对应字节数;
  5. 考试考点
    • 能区分4个方法的用途和参数;
    • 能用Read()/Write()实现二进制文件的批量读写;
    • 理解Read()返回“实际读取数”的意义(流末尾的处理)。

简单记:单字节用Byte后缀方法,批量用无后缀方法;写入无返回值,读取有返回值(标识末尾/实际读取数)