C#を用いたシステム開発において、バイナリファイルの読み書きは非常に重要なスキルの一つです。

テキストファイルとは異なり、バイナリ形式はデータの格納効率が高く、画像、音声、独自形式のデータ保存、ネットワーク通信などで幅広く利用されます。

2026年現在のC#開発では、従来のBinaryWriterやFileStreamに加え、Span<T>やMemory<T>を活用した低コストで高速なアクセス手法が主流となっています。

本記事では、基本的な読み書きの手法から、メモリ効率を最大化する最新の実践的なアプローチまで詳しく解説します。

C#におけるバイナリデータの基本概念

バイナリデータを扱う前に、まずはコンピュータがデータをどのように保持しているかを理解する必要があります。

すべてのデータは最終的に「バイト (byte) 」の配列として表現されます。

1バイトは8ビットであり、0から255までの数値を保持できます。

C#でバイナリを扱う際、基本となるのは byte[] 型の配列です。

しかし、単なる数値の羅列では意味を持ちません。

整数、浮動小数点、文字列などの異なるデータ型を、バイト配列のどの位置にどのように配置するかという「フォーマット」の定義が不可欠です。

エンディアン (Endianness) への注意

バイナリ操作において避けて通れないのが、エンディアンの影響です。

エンディアンとは、複数バイトで構成されるデータ (intやdoubleなど) をメモリ上に配置する順序を指します。

Windowsを中心とした多くの環境は「リトルエンディアン」を採用しています。

リトルエンディアンでは、下位バイトから順にメモリに配置されます。

一方、ネットワークプロトコルなどでは「ビッグエンディアン」が標準とされることが多いです。

C#の標準クラスである BinaryReader や BinaryWriter は、基本的には実行環境のエンディアンに依存することに留意してください。

異なるプラットフォーム間でデータをやり取りする場合は、System.Buffers.Binary.BinaryPrimitives クラスを使用して明示的にエンディアンを変換することが推奨されます。

BinaryWriterとBinaryReaderによる基本的な読み書き

最も直感的で使いやすい方法は、System.IO.BinaryWriter と System.IO.BinaryReader を使用することです。

これらのクラスは、プリミティブ型 (int, float, bool, stringなど) を直接ストリームに書き込んだり、読み込んだりするための機能を提供します。

データの書き込み手順

まずは FileStream を作成し、それを BinaryWriter でラップして使用します。

C#
using System;
using System.IO;

public class BinarySample
{
    public void WriteSample(string filePath)
    {
        // FileStreamを生成してBinaryWriterでラップする
        using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
        using (BinaryWriter writer = new BinaryWriter(fs))
        {
            // 様々な型のデータを書き込む
            writer.Write(101);                 // int (4バイト)
            writer.Write(99.5f);               // float (4バイト)
            writer.Write("C# Binary Data");    // string (長さ情報の後にデータ)
            writer.Write(true);                // bool (1バイト)
            
            Console.WriteLine("データの書き込みが完了しました。");
        }
    }
}

BinaryWriter.Write メソッドはオーバーロードされており、渡された型に応じて適切なバイト数を自動的に判別して書き込みます。

文字列を書き込む際は、デフォルトで 7ビット圧縮された長さ情報が先頭に付与されるため、独自のフォーマットを定義する場合は注意が必要です。

データの読み込み手順

書き込んだデータを読み戻す際は、書き込んだ順序と型を正確に守る必要があります。

C#
public void ReadSample(string filePath)
{
    if (!File.Exists(filePath)) return;

    using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
    using (BinaryReader reader = new BinaryReader(fs))
    {
        // 書き込んだ順序と同じ型で読み込む
        int id = reader.ReadInt32();
        float score = reader.ReadSingle();
        string message = reader.ReadString();
        bool isActive = reader.ReadBoolean();

        Console.WriteLine($"ID: {id}");
        Console.WriteLine($"Score: {score}");
        Console.WriteLine($"Message: {message}");
        Console.WriteLine($"IsActive: {isActive}");
    }
}

読み込み時の型が異なると、バイトのオフセットがずれ、壊れたデータとして読み込まれたり例外が発生したりする原因となります。

実行結果
ID: 101
Score: 99.5
Message: C# Binary Data
IsActive: True

FileStreamを用いた低レベルなバイト操作

特定の構造を持たない巨大なデータや、バッファリングを細かく制御したい場合は、FileStream を直接操作します。

Read メソッドや Write メソッドを使用し、byte[] 配列を介してデータをやり取りします。

バッファを利用した高速なコピー

巨大なファイルを一度にメモリに読み込むと、メモリ不足 (OutOfMemoryException) を引き起こす可能性があります。

そのため、一定サイズのバッファを確保して繰り返し処理を行うのが定石です。

C#
public void CopyLargeFile(string source, string destination)
{
    const int bufferSize = 4096; // 4KBのバッファ
    byte[] buffer = new byte[bufferSize];

    using (FileStream fsIn = new FileStream(source, FileMode.Open, FileAccess.Read))
    using (FileStream fsOut = new FileStream(destination, FileMode.Create, FileAccess.Write))
    {
        int bytesRead;
        while ((bytesRead = fsIn.Read(buffer, 0, buffer.Length)) > 0)
        {
            fsOut.Write(buffer, 0, bytesRead);
        }
    }
}

現代のC#では、Stream.CopyToAsync メソッドを使用することで、非同期かつ効率的に同様の処理を実装できます。

Span<T>とMemory<T>による最新の最適化手法

近年のC#において、バイナリ操作のパフォーマンスを劇的に向上させたのが Span<T> と Memory<T> の登場です。

これらは「メモリの連続した領域」を抽象化し、コピーを発生させずにデータにアクセスすることを可能にします。

ゼロコピーによるパフォーマンス向上

従来の byte[] 配列を使用する場合、部分的なデータを別のメソッドに渡す際に配列のコピー (サブセットの作成) が発生することがありました。

Span<byte> を使用すると、元の配列の参照を保持したまま、「特定の範囲だけを見る」スライス操作が可能になります。

これにより、メモリ割り当て (Allocation) を大幅に抑制でき、ガベージコレクション (GC) の負荷を軽減できます。

Spanを活用したファイルの読み込み

RandomAccess クラスを使用すると、FileStream よりもさらに低レベルで、Span<byte> や ReadOnlySpan<byte> を直接扱えるファイル操作が可能です。

C#
using System;
using System.IO;
using System.IO.Enumeration;
using Microsoft.Win32.SafeHandles;

public void FastRead(string filePath)
{
    using SafeFileHandle handle = File.OpenHandle(filePath, FileMode.Open, FileAccess.Read);
    
    // 1KBのスタック領域を使用(非常に高速)
    Span<byte> buffer = stackalloc byte[1024];
    
    // 指定したオフセットから読み込む
    int bytesRead = RandomAccess.Read(handle, buffer, 0);
    
    Console.WriteLine($"{bytesRead} バイト読み込みました。");
}

stackalloc を使用すると、ヒープではなくスタックにメモリを確保するため、GCの対象にすらなりません。

ただし、スタックのサイズには制限があるため、数キロバイトを超えるデータの場合はヒープに確保した byte[] を Span でラップして使用してください。

構造体とバイナリデータの相互変換

バイナリファイルを扱う際、固定長のヘッダー情報を読み取るケースが多々あります。

一つずつ ReadInt32 などを呼び出すのではなく、構造体 (struct) として一括でマッピングするとコードが簡潔になります。

MemoryMarshalを使用した高度な変換

System.Runtime.InteropServices.MemoryMarshal クラスを使用すると、バイト配列を特定の構造体にキャストすることができます。

C#
using System;
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct FileHeader
{
    public int Signature;
    public int Version;
    public long DataLength;
}

public class HeaderProcessor
{
    public void Process(byte[] rawData)
    {
        // バイト配列を構造体として解釈する(コピーなし)
        ReadOnlySpan<byte> span = rawData;
        FileHeader header = MemoryMarshal.Read<FileHeader>(span);

        Console.WriteLine($"Signature: {header.Signature:X}");
        Console.WriteLine($"Version: {header.Version}");
        Console.WriteLine($"DataLength: {header.DataLength}");
    }
}

この手法を用いる場合、StructLayout 属性を使用してメモリ上の配置を明示的に指定することが不可欠です。

Pack = 1 を指定することで、パディング (隙間) を排除し、バイナリデータと構造体の各フィールドを正確に一致させることができます。

バイナリ操作における例外処理と安全性

ファイル操作は、常にエラーのリスクが伴います。

ファイルが存在しない、権限が足りない、ディスク容量が不足しているなど、実行環境に依存する問題が発生しやすいためです。

安全な実装のためのチェックリスト

項目対策内容
リソースの解放using ステートメントを使用して、確実にストリームを閉じる。
ファイルの存在確認File.Exists で事前にチェックするか、例外を適切にキャッチする。
データ整合性の確認マジックナンバー (識別子) を先頭に配置し、正しいフォーマットか検証する。
バッファの範囲インデックスの範囲外アクセスを防ぐため、Span.Slice 等で境界を管理する。

特に BinaryReader 等を使用する際、予期しないファイル末尾 (EOF) に到達すると EndOfStreamException がスローされます。

あらかじめデータの長さを記録しておくか、例外処理を記述してプログラムが異常終了しないように設計してください。

非同期 I/O によるアプリケーションの応答性向上

GUIアプリケーションや高負荷なサーバーサイド処理では、バイナリ操作によるブロッキングを避けるべきです。

C#では、async/await キーワードを用いることで、バックグラウンドで効率的にバイナリを処理できます。

C#
public async Task WriteAsyncSample(string filePath, byte[] data)
{
    // 非同期書き込み
    await File.WriteAllBytesAsync(filePath, data);
}

public async Task<byte[]> ReadAsyncSample(string filePath)
{
    // 非同期読み込み
    return await File.ReadAllBytesAsync(filePath);
}

さらに詳細な制御が必要な場合は、FileStream.ReadAsync(Memory<byte>, CancellationToken) を使用します。

これにより、大規模なデータ処理中であっても、ユーザーインターフェースがフリーズすることを防ぐことができます。

まとめ

C#におけるバイナリファイルの読み書きは、伝統的な手法から Span<T> を駆使した現代的なアプローチまで、多様な選択肢が存在します。

小規模なデータや単純な値の保存には、BinaryWriter や BinaryReader が最も手軽でメンテナンス性に優れています。

一方で、パフォーマンスが要求されるシステムや、複雑な構造体を扱う場合には、Span<T> や MemoryMarshal を活用したゼロコピー技術が威力を発揮します。

開発するアプリケーションの要件に合わせて、適切なツールを選択することが重要です。

「データの安全な管理」と「効率的なリソース利用」のバランスを考慮し、堅牢なバイナリ処理の実装を目指してください。

本記事で紹介した手法を組み合わせることで、2026年の標準に即した高品質なプログラムを構築できるはずです。