ficheros-binarios-csharp

How to Read and Write Binary Files in C#

  • 5 min

A binary file is a file that stores data as bytes following a specific structure.

So far we have talked about text files. They are great: you can open them with Notepad, read them and understand them. But they have a major drawback in engineering: they can be inefficient.

Imagine you need to save one million sensor readings, represented as decimal numbers (double).

  • In Text: The number 12345.6789 takes 10 bytes (one byte per character). Plus, it needs to be parsed (text to number), which consumes CPU.
  • In Binary: A double always takes 8 bytes. There is no text parsing because a fixed binary representation is stored.

Binary files are not meant to be read by humans, but by machines. They are compact, fast and structured.

The foundation: FileStream

It all starts with FileStream. Unlike the quick text methods, here we need to control the byte stream precisely.

string path = "data.bin";

// FileMode.Create: Creates or overwrites
// FileAccess.Write: Write only
using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write))
{
    // Here we have an open stream ready to receive bytes
    // fs.WriteByte(0xFF); ...
}
Copied!

However, using FileStream directly is painful because it only understands byte[]. If you want to write an integer (int), you would have to manually break it into 4 bytes. To avoid that suffering, we use Wrappers.

BinaryWriter and BinaryReader

These two classes are our translators. They take your C# data types (int, float, bool, string) and convert them to their raw binary representation to write to the stream.

Order here is sacred. In a binary file there are no “lines” or labels. If you write an integer and then a boolean, you must know that you have to read them in exactly that order.

using (FileStream fs = new FileStream("sensor.dat", FileMode.Create))
using (BinaryWriter writer = new BinaryWriter(fs))
{
    int sensorId = 1;
    double temperature = 23.5;
    bool active = true;

    // Write sequentially
    writer.Write(sensorId);    // 4 bytes
    writer.Write(temperature); // 8 bytes
    writer.Write(active);      // 1 byte
}
Copied!

To retrieve the information, we do the reverse process.

using (FileStream fs = new FileStream("sensor.dat", FileMode.Open))
using (BinaryReader reader = new BinaryReader(fs))
{
    // ORDER IS CRITICAL!
    int id = reader.ReadInt32();
    double temp = reader.ReadDouble();
    bool status = reader.ReadBoolean();

    Console.WriteLine($"Sensor {id}: {temp}ºC (Active: {status})");
}
Copied!

If you get the order wrong (e.g., trying to read a double where there was an int), you will read 8 bytes of garbage, misalign the entire file, and the resulting data will be meaningless.

Structures and text strings

Strings in binary are a special case. Since text can be of any length, BinaryWriter does something smart:

  1. Calculates the length of the text.
  2. Writes the length at the beginning (as a 7-bit encoded integer).
  3. Writes the bytes of the text.

This means reader.ReadString() knows exactly how many bytes to read.

Watch out for C++ interoperability: If this file will be read by a C or C++ program, they won’t expect that C# length prefix. You will have to write fixed-length strings or handle it manually.

Random access (Seek)

One of the great advantages of binary files is that they usually have records of fixed size.

If we store 1000 measurements and each takes 12 bytes (4 of int + 8 of double), we know mathematically where measurement number 500 is. No need to read the previous 499.

We can jump directly using BaseStream.Seek.

long position = 500 * 12; // (Index * RecordSize)

using (FileStream fs = new FileStream("measurements.bin", FileMode.Open))
using (BinaryReader reader = new BinaryReader(fs))
{
    // Jump directly to the position
    reader.BaseStream.Seek(position, SeekOrigin.Begin);

    // Read only that record
    int id = reader.ReadInt32();
    double value = reader.ReadDouble();
}
Copied!

The endianness problem

If you work with hardware, microcontrollers (Arduino, STM32) or network protocols, you will encounter endianness.

Endianness defines the order of bytes in a multibyte data type.

  • Little Endian (Intel/AMD, C# by default): The least significant byte comes first. The number 0x12345678 is stored as 78 56 34 12.
  • Big Endian (Networks, some microcontrollers): The most significant byte comes first. It is stored as 12 34 56 78.

If you read a file generated in big-endian with BinaryReader, the numbers will be interpreted incorrectly because this reader uses little-endian for numeric types.

To solve this, we sometimes have to read raw bytes and use utilities like BinaryPrimitives.

using System.Buffers.Binary;

// Big Endian reading for a 16-bit integer (short)
byte[] bytes = reader.ReadBytes(2);
short value = BinaryPrimitives.ReadInt16BigEndian(bytes);
Copied!

Serialization of structures using marshaling

When we have complex data structures in engineering (e.g., a BMP file header or a TCP packet), reading field by field with writer.Write is tedious.

We can dump an entire struct to bytes directly. This is an advanced technique and requires using System.Runtime.InteropServices.

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Header
{
    public int Id;
    public float Version;
    public short Flags;
}

// Convert struct to bytes
int size = Marshal.SizeOf(myHeader);
byte[] arr = new byte[size];

IntPtr ptr = Marshal.AllocHGlobal(size);
try
{
    Marshal.StructureToPtr(myHeader, ptr, false);
    Marshal.Copy(ptr, arr, 0, size);
}
finally
{
    Marshal.FreeHGlobal(ptr);
}

// Now 'arr' contains the structure in pure binary, ready to be saved.
Copied!

In modern .NET versions, we prefer using Span<T> and MemoryMarshal to do this much faster and safer without using explicit pointers.