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

от автора

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

Сегодня рассмотрим тему, которая обычно ассоциируется с 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 апреля — Анализ сложности алгоритмов и сортировка. Подробнее


ссылка на оригинал статьи https://habr.com/ru/articles/897264/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *