Привет, Хабр!
Сегодня рассмотрим тему, которая обычно ассоциируется с 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. Разберём реальные задачи, где важны производительность, контроль над ресурсами и архитектурный подход:
ссылка на оригинал статьи https://habr.com/ru/articles/897264/
Добавить комментарий