Как стать автором
Обновить
570.46
OTUS
Цифровые навыки от ведущих экспертов

Как управлять памятью в C#: StructLayout

Время на прочтение6 мин
Количество просмотров852

Привет, Хабр!

Сегодня рассмотрим тему, которая обычно ассоциируется с C или Rust, но никак не с C#. А именно — ручное управление памятью, байтовые смещения, бинарная сериализация и прочая низкоуровневые вещи. Зачем? Допустим, в одном из проектов потребовалось прочитать старый бинарный лог от С-подобной прошивки. Формат документации был: offset 0 — 1 byte: Type; offset 1 — 2 bytes: ID; offset 3 — 4 bytes: Timestamp; и т.д.

Разбирать всё это вручную с BinaryReader? Нет, спасибо. Можно воспользоваться StructLayout, FieldOffset, MemoryMappedFile, Unsafe.As<T>() и Span<byte>.

По умолчанию C# делает всё за нас: размещает поля в структурах, оптимизирует порядок, выравнивает память, добавляет паддинги — и всё это работает чудесно… пока вы не столкнулись с чем-то внешним. Например:

  • Нативной DLL на C

  • Протоколом, который требует чёткой сериализации

  • Memory-mapped файлом со строгим байтовым форматом

  • Жёсткими требованиями к zero-GC и zero-copy

В этих случаях вы не можете положиться на LayoutKind.Auto. Нужно явно указать порядок полей и, главное — их байтовое смещение. Для этого в C# есть атрибуты:

[StructLayout(LayoutKind.Sequential, Pack = 1)]
// или
[StructLayout(LayoutKind.Explicit)]

Sequential и Explicit: в чём разница?

LayoutKind.Sequential говорит CLR: "располагай поля в порядке объявления". Хорошо подходит, когда нужно хотите сохранить порядок и не хотите паддингов. Но не получится вручную указать смещения.

LayoutKind.Explicit даёт абсолютный контроль: вы сами указываете, на каком смещении должно быть каждое поле. Это мощно, но требует некой точности.

Пример:

[StructLayout(LayoutKind.Explicit)]
struct PacketHeader
{
    [FieldOffset(0)] public byte Version;
    [FieldOffset(1)] public byte Flags;
    [FieldOffset(2)] public ushort Length;
    [FieldOffset(4)] public uint Checksum;
}

Если байты из файла или по сети приходят именно в этом порядке — такая структура отработает идеально. Но как это применить?

Читаем бинарный блок напрямую как структуру

Допустим, есть бинарный файл packet.bin, в котором каждый блок данных представляет собой наш PacketHeader.

Вот как можно спроецировать байты файла прямо в структуру:

byte[] rawBytes = File.ReadAllBytes("packet.bin");

GCHandle handle = GCHandle.Alloc(rawBytes, GCHandleType.Pinned);
PacketHeader header = Marshal.PtrToStructure<PacketHeader>(handle.AddrOfPinnedObject());
handle.Free();

GCHandle.Alloc: закрепляем массив в памяти, чтобы GC не сдвинул его во время чтения. Marshal.PtrToStructure<T>(): интерпретируем указатель на массив как структуру T.

После чтения освобождаем handle, чтобы не держать память зря.

Unsafe.As() и Span

Если хочется максимальной производительности, без маршала и GC — есть System.Runtime.CompilerServices.Unsafe.

public static T ReadStruct<T>(Span<byte> data) where T : unmanaged
{
    return Unsafe.As<byte, T>(ref data[0]);
}

Теперь можно читать структуру напрямую из любого источника, даже из stackalloc, Span<byte> или MemoryMappedFile.

Span<byte> span = stackalloc byte[sizeof(PacketHeader)];
ReadFromSocket(span); // читаем напрямую в span

PacketHeader header = ReadStruct<PacketHeader>(span);

T должен быть unmanaged. Размер span должен быть равен sizeof(T).

Разбираем StructLayout в контексте interop

Допустим, есть внешняя DLL, которая принимает указатель на структуру. Прототип на C:

typedef struct {
    int id;
    float temperature;
    char status;
} SensorData;

На C# стороне:

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct SensorData
{
    public int Id;
    public float Temperature;
    public byte Status;
}

И вызов:

[DllImport("libsensor.so")]
static extern void ProcessSensor(ref SensorData data);

Если вы не используете Pack = 1, то CLR может вставить паддинги между float и byte, и ваша структура станет несовместимой. Результат — UB или поломанные данные.

MemoryMappedFile: структура прямо из файла

Если есть огромный файл и не хочется загружать его целиком — используйте MemoryMappedFile. Это позволяет обращаться к файлу как к памяти, и считывать структуры напрямую.

using var mmf = MemoryMappedFile.CreateFromFile("log.dat", FileMode.Open);
using var accessor = mmf.CreateViewAccessor(0, sizeof(PacketHeader), MemoryMappedFileAccess.Read);

unsafe
{
    byte* ptr = null;
    accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);

    // интерпретируем указатель как структуру
    var header = Unsafe.AsRef<PacketHeader>(ptr);

    Console.WriteLine($"Length = {header.Length}");

    accessor.SafeMemoryMappedViewHandle.ReleasePointer();
}

Это zero-copy, high-speed и zero-GC. Но будьте внимательны: ошибки в выравнивании и доступе — ваши, и только ваши.

Выравнивание и паддинги

Вот так выглядит типичная ловушка:

struct Foo
{
    public byte A;
    public int B;
}

Кажется, размер должен быть 5 байт. Но CLR выравнивает int по 4-байтовой границе, поэтому sizeof(Foo) = 8.

Чтобы избежать этого:

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct FooTight
{
    public byte A;
    public int B; // теперь сразу за A
}

Но не переусердствуйте. Неправильное выравнивание — это:

  • медленно на некоторых архитектурах

  • может привести к AccessViolationException на ARM

Работа с Endianness — не забываем

.NET — little-endian. Но если вы читаете big-endian поток (а такие до сих пор везде), придётся свапать байты вручную:

public static ushort ReadUInt16BigEndian(Span<byte> data)
{
    return (ushort)((data[0] << 8) | data[1]);
}

Или через BinaryPrimitives:

ushort value = BinaryPrimitives.ReadUInt16BigEndian(data);

В Unsafe.As<T>() это не поможет — там байты идут как есть, поэтому endianness нужно контролировать до вызова.

Бинарный лог с embedded-устройства

Устройство пишет лог в файл. Каждая запись — 12 байт. Формат:

  • uint16 Header (BigEndian) — всегда 0xAA55, начало записи

  • byte SensorId — ID датчика

  • byte Flags — побитовые флаги состояния

  • float Value — измеренное значение

  • uint32 Timestamp — время в секундах UnixTime

Нужно:

  • Прочитать лог

  • Провалидировать данные

  • Отобразить как C#-объекты

  • Обратно сериализовать

Структура в памяти

[StructLayout(LayoutKind.Explicit, Size = 12)]
public struct TelemetryRecordRaw
{
    [FieldOffset(0)] public ushort HeaderBE;
    [FieldOffset(2)] public byte SensorId;
    [FieldOffset(3)] public byte Flags;
    [FieldOffset(4)] public float ValueLE;
    [FieldOffset(8)] public uint TimestampLE;
}

Отображает байты как есть. Используем LayoutKind.Explicit, чтобы задать смещения.

Высокоуровневая модель

public class TelemetryRecord
{
    public byte SensorId { get; set; }
    public byte Flags { get; set; }
    public float Value { get; set; }
    public DateTime Timestamp { get; set; }
}

Класс для логики: удобнее работать, анализировать, сериализовать в JSON и т.д.

Парсинг записи из байтов

public static bool TryParse(ReadOnlySpan<byte> span, out TelemetryRecord record)
{
    record = null;
    if (span.Length < 12) return false;

    var raw = MemoryMarshal.Read<TelemetryRecordRaw>(span); // zero-copy чтение

    if (BinaryPrimitives.ReverseEndianness(raw.HeaderBE) != 0xAA55) return false; // валидация

    record = new TelemetryRecord
    {
        SensorId = raw.SensorId,
        Flags = raw.Flags,
        Value = raw.ValueLE, // float уже в little-endian
        Timestamp = DateTimeOffset.FromUnixTimeSeconds(raw.TimestampLE).DateTime
    };

    return true;
}

Конвертируем ushort из BigEndian, парсим float, uint напрямую.

Обратная сериализация

public static byte[] Serialize(TelemetryRecord record)
{
    var raw = new TelemetryRecordRaw
    {
        HeaderBE = BinaryPrimitives.ReverseEndianness(0xAA55),
        SensorId = record.SensorId,
        Flags = record.Flags,
        ValueLE = record.Value,
        TimestampLE = (uint)new DateTimeOffset(record.Timestamp).ToUnixTimeSeconds()
    };

    byte[] result = new byte[12];
    MemoryMarshal.Write(result, ref raw); // запись как struct → bytes
    return result;
}

Сохраняем структуру в byte[], endianness учитываем вручную.

Чтение всех записей из файла

public static IEnumerable<TelemetryRecord> ParseLogFile(string path)
{
    var bytes = File.ReadAllBytes(path);
    for (int i = 0; i + 12 <= bytes.Length; i += 12)
    {
        var slice = new ReadOnlySpan<byte>(bytes, i, 12);
        if (TryParse(slice, out var record))
            yield return record;
    }
}

Читаем файл блоками по 12 байт, парсим каждую запись, отдаём как TelemetryRecord.

Пример использования

foreach (var record in ParseLogFile("telemetry.bin"))
{
    Console.WriteLine($"Sensor: {record.SensorId}, Value: {record.Value:F2}, Time: {record.Timestamp}");
}

Выводим результат:

Sensor: 1, Value: 23.57, Time: 2025-04-01 14:23:45
Sensor: 2, Value: 18.02, Time: 2025-04-01 14:23:50
Sensor: 1, Value: 24.03, Time: 2025-04-01 14:23:55
Sensor: 3, Value: 19.76, Time: 2025-04-01 14:24:00
Sensor: 2, Value: 17.91, Time: 2025-04-01 14:24:05

Каждая строка — это одна запись из бинарного лога:

  • SensorId — номер датчика

  • Value — измеренное значение (температура, давление и т.д.)

  • Timestamp — дата и время, преобразованные из UnixTime

Вывод читаемый, логически структурирован, не требует постобработки, и может идти прямо в аналитическую систему, БД или отображаться в UI.


Делитесь в комментариях своими кейсами: где вы использовали ручное управление памятью и с чем пришлось побороться.

Если близки темы низкоуровневой работы с памятью и хочется больше практики — присоединяйтесь к открытым урокам по C# в Otus. Разберём реальные задачи, где важны производительность, контроль над ресурсами и архитектурный подход:

  • 8 апреля — Используем C# для построения консольного интерфейса. Подробнее

  • 21 апреля — Анализ сложности алгоритмов и сортировка. Подробнее

Теги:
Хабы:
+4
Комментарии0

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS

Истории