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