Pull to refresh

Работа со структурами в C#

Reading time 13 min
Views 140K
Original author: Mike James
По следам недавнего топика «Обработка больших объемов данных в памяти на C#» представляю перевод упоминавшейся там статьи о структурах.

Структуры являются фундаментальными типами данных в C# и большинстве других современных языках программирования. По своей сути структуры просты, но вы можете удивиться, насколько быстро работа с ними может стать сложной. Чаще всего проблемы возникают, если вы должны работать со структурами, созданными в других языках и сохраненными на диске или полученными в результате вызова функций из библиотек или COM-объектов. В этой статье я подразумеваю, что вы знакомы с понятием структуры, умеете определять их и владеете базовыми навыками работы со структурами. Предполагается, что вы имеете представление о том, как вызывать API функции с использованием p/Invoke, а также что такое маршалинг. В случае неуверенности в своих знаниях вы можете обратиться к документации.
Множество техник, описанных в данной статье, могут быть расширены и применены к любым типам данных.

Расположение


В большинстве случаев вы можете описать и использовать структуру без знания о том, как она реализована — особенно как расположены в памяти ее поля. Если вы должны создать структуру для использования ее другими приложениями, или сами должны использовать чужую структуру, то в этом случае вопросы памяти становятся важными. Как вы думаете, каков размер следующей структуры?
public struct struct1
{
    public byte a; // 1 byte
    public int b; // 4 bytes
    public short c; // 2 bytes
    public byte d; // 1 byte
}

Разумный ответ — 8 байт, просто сумма размеров всех полей. Однако если вы попытаетесь узнать размер структуры:
int size = Marshal.SizeOf(test);

… то (в большинстве случаев) обнаружите, что структура занимает 12 байт. Причина кроется в том, что большинство процессоров лучше работает с данными, занимающими больше, чем байт, и выровненными по определенным адресным границам. Pentium предпочитает данные в блоках по 16 байт с выравниванием по адресным границам с размером, идентичным размеру самих данных. Например, 4-байтовый integer должен быть выровнен по границе 4 байта. Детальные подробности в данном случае неважны. Важно то, что компилятор добавит недостающие байты, чтобы выровнять данные внутри структуры. Вы можете контролировать это вручную, однако обратите внимание, что некоторые процессоры могут возвращать ошибку в случае использования невыровненных данных. Это создает дополнительные проблемы для пользователей .NET Compact Framework (интересно, много таких? — прим. пер.).

Для работы вам понадобится ссылка на InteropServices:
using System.Runtime.InteropServices;

Для ручного расположения полей в памяти используется атрибут StructLayout. Например:
[StructLayout(LayoutKind.Sequential)]
public struct struct1
{
    public byte a; // 1 byte
    public int b; // 4 bytes
    public short c; // 2 bytes
    public byte d; // 1 byte
}

Это заставляет компилятор располагать поля последовательно, в порядке объявления, что и делает по умолчанию. Другими значениями атрибута являются значение Auto, которое позволяет компилятору самому определять порядок размещения полей, и значение Explicit, которое позволяет программисту указать размер каждого поля. Тип Explicit часто используется для последовательного расположения без упаковки, но в большинстве случаев проще использовать параметр Pack. Он сообщает компилятору о том, сколько должно выделяться памяти и как должны быть выровнены данные. Например, если вы укажете Pack=1, тогда структура будет организована таким образом, что каждое поле будет находиться в границах одного байта и может быть считано побайтно — т.е. никакого упаковывания не требуется. Если вы измените объявление структуры:
[StructLayout(LayoutKind.Sequential, Pack=1)]
public struct struct1

… то обнаружите, что теперь структура занимает ровно 8 байт, что отвечает последовательному расположению полей в памяти без дополнительных «упаковывающих» байт. Именно таким образом нужно работать с большинством структур, объявленных в Windows API и C/C++. В большинстве случаев вам не придется использовать другие значения параметра Pack. Если вы установите Pack=2, тогда обнаружите, что структура станет занимать 10 байт, потому что будет добавлено по одному байту к каждому однобайтовому полю, чтобы данные могли читаться кусками по 2 байта. Если установить Pack=4, размер структуры увеличится до 12 байт, чтобы структура могла быть прочитана блоками по 4 байта. Дальше значение параметра перестанет учитываться, потому что размер Pack игнорируется, если он равен или превышает выравнивание, использующееся в данном процессоре, и составляющее 8 байт для архитектуры Intel. Расположение структуры в памяти при разных значения Pack показано на рисунке:


Стоит также упомянуть, что может изменить способ упаковки структуры, изменяя порядок полей в ней. Например, при изменении порядка полей на:
public struct struct1
{
    public byte a; // 1 byte
    public byte d; // 1 byte
    public short c; // 2 bytes
    public int b; // 4 bytes
}

… структуре не понадобится упаковка, она и так займет ровно 8 байт.

Если быть точным


Если вам нужно точно указать, сколько памяти будет выделено для каждого поля, используйте тип расположения Explicit. Например:
[StructLayout(LayoutKind.Explicit)]
public struct struct1
{
    [FieldOffset(0)]
    public byte a;   // 1 byte
    [FieldOffset(1)]
    public int b;    // 4 bytes
    [FieldOffset(5)]
    public short c;  // 2 bytes
    [FieldOffset(7)]
    public byte d;   // 1 byte
}

Так вы получите 8-байтовую структуры без дополнительны выравнивающих байтов. В данном случае это эквивалентно использованию Pack=1. Однако использование Explicit позволяет вам полностью контролировать память. Например:
[StructLayout(LayoutKind.Explicit)]
public struct struct1
{
    [FieldOffset(0)]
    public byte a;   // 1 byte
    [FieldOffset(1)]
    public int b;    // 4 bytes
    [FieldOffset(10)]
    public short c;  // 2 bytes
    [FieldOffset(14)]
    public byte d;   // 1 byte
}

Эта структура займет 16 байт, вместе с дополнительными байтами после поля b. До версии C# 2.0, тип Explicit использовался в основном для указания буферов с фиксированными размерами при вызове сторонних функций. Вы не можете объявить массив фиксированной длины в структуре, потому что инициализация полей запрещена.
public struct struct1
{
    public byte a;
    public int b;
    byte[] buffer = new byte[10];
    public short c;
    public byte d;
}

Этот код выдаст ошибку. Если вам нужен массив длиной 10 байт, вот один из способов:
[StructLayout(LayoutKind.Explicit)]
public struct struct1
{
    [FieldOffset(0)]
    public byte a;
    [FieldOffset(1)]
    public int b;
    [FieldOffset(5)]
    public short c;
    [FieldOffset(8)]
    public byte[] buffer;
    [FieldOffset(18)]
    public byte d;
}

Таким образом, вы оставляете 10 байт для массива. Тут существует ряд интересных нюансов. Первое, почему нужно использовать смещение в 8 байт? Причина в том, что вы не можете начать массив с нечетного адреса. Если вы воспользуетесь смещением в 7 байт, то увидите ошибку времени выполнения, сообщающую о том, что структура не может быть загружена из-за проблем с выравниванием. Это важно, потому что при использовании Explicit вы можете столкнуться с проблемами, если не будете понимать, что вы делаете. Второй момент связан с тем, что в конец структуры добавляются дополнительные байты, чтобы размер структуры был кратен 8 байтам. Компилятор все еще участвует в том, как структура будет размещена в памяти. Конечно, на практике, любая внешняя структура, которую вы попытаетесь конвертировать в структуру C#, должна быть корректна выровнена.
Наконец, стоит упомянуть, что вы не можете обратиться к 10-байтовому массиву, используя имя массива (например, buffer[1]), потому что C# думает, что массиву не назначено значение. Поэтому если вы не можете использовать массив и это вызывает проблему с выравниванием, гораздо лучше объявить структуру так:
[StructLayout(LayoutKind.Explicit)]
public struct struct1
{
    [FieldOffset(0)]
    public byte a;   // 1 byte
    [FieldOffset(1)]
    public int b;    // 4 bytes
    [FieldOffset(5)]
    public short c;  // 2 bytes
    [FieldOffset(7)]
    public byte buffer;
    [FieldOffset(18)]
    public byte d;   // 1 byte
}

Для доступа к массиву придется воспользоваться арифметикой на указателях, что является unsafe кодом. Чтобы под структуру было выделено фиксированное количество байт, используйте параметр Size в атрибуте StructLayout:
[StructLayout(LayoutKind.Explicit, Size=64)]

Сейчас в C# 2.0 массивы фиксированного размера разрешены, поэтому все вышеприведенные конструкции в общем-то необязательны. Стоит заметить, что массивы фиксированной длины используют тот же механизм: выделение фиксированного числа байт и указатели (что тоже является небезопасным). Если вам нужно использовать массивы для вызова функций из библиотек, возможно, лучшим способом будет явный маршалинг массивов, который считается «безопасным». Давайте рассмотрим все три упомянутых способа.

Вызовы API


В качестве примера структуры, которая требует выравнивания, мы можем использовать функцию EnumDisplayDevices, которая определена следующим образом:
BOOL EnumDisplayDevices(
    LPCTSTR lpDevice, // device name
    DWORD iDevNum, // display device
    PDISPLAY_DEVICE lpDisplayDevice, // device information
    DWORD dwFlags // reserved
);

Это довольно просто конвертируется в C#:
[DllImport(“User32.dll”, CharSet=CharSet.Unicode )]
extern static bool EnumDisplayDevices(
    	string lpDevice,
    	uint iDevNum,
    	ref DISPLAY_DEVICE lpDisplayDevice,
    	uint dwFlags);

Структура DISPLAY_DEVICE определена так:
typedef struct _DISPLAY_DEVICE {
    DWORD cb;
    WCHAR DeviceName[32];
    WCHAR DeviceString[128];
    DWORD StateFlags;
    WCHAR DeviceID[128];
    WCHAR DeviceKey[128];
} DISPLAY_DEVICE, *PDISPLAY_DEVICE;

Понятно, что она содержит четыре символьных массива с фиксированной длиной. Используя тип выравнивания Explicit, перепишем структуру в C#:
[StructLayout(LayoutKind.Explicit, Pack = 1,Size=714)]
public struct DISPLAY_DEVICE
{
    [FieldOffset(0)]
    public int cb;
    [FieldOffset(4)]
    public char DeviceName;
    [FieldOffset(68)]
    public char DeviceString;
    [FieldOffset(324)]
    public int StateFlags;
    [FieldOffset(328)]
    public char DeviceID;
    [FieldOffset(584)]
    public char DeviceKey;
}

Обратите внимание на использования параметра Size для указания места, необходимого для хранения поля DeviceKey. Теперь если использовать эту структуру при вызове функции:
DISPLAY_DEVICE info = new DISPLAY_DEVICE();
info.cb = Marshal.SizeOf(info);
bool result = EnumDisplayDevices(null, 0, ref info, 0);

… то все, к чему вы можете обратиться напрямую — это первые символы массивов. Например, DeviceString содержит первый символ строки информации об устройстве. Если вы хотите получить остальные символы из массива, нужно получить указатель на DeviceString и использовать арифметику на указателях, чтобы пройти по массиву.
При использовании C# 2.0 самым простым решением является использовать в структуре массивы:
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public unsafe struct DISPLAY_DEVICE
{
    public int cb;
    public fixed char DeviceName[32];
    public fixed char DeviceString[128];
    public int StateFlags;
    public fixed char DeviceID[128];
    public fixed char DeviceKey[128];
}

Обратите внимание, что структура должна быть помечена модификатором unsafe. Теперь после API вызова мы можем получить данные из массивов без использования указателей. Впрочем, неявно они все-таки используются, и любой код, обращающийся к массивам, должен быть помечен как небезопасный.
Третий и последний метод заключается в кастомном маршалинге. Многие C# программисты не понимают, что суть маршалинга заключается не только в том, как данные о типах передаются в библиотечные вызовы, — это еще и активный процесс, который копирует и изменяет управляемые данные. Например, если вы захотите передать ссылку на типизированный массив, вы можете передать его по значению, и система сконвертирует его в массив фиксированной длины и обратно в управляемый массив без дополнительных действий с вашей стороны.
В этом случае все, что нам остается сделать, это добавить атрибут MarshalAs, указывающий типа и размер массивов:
[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Unicode)]
public struct DISPLAY_DEVICE
{
    public int cb;
    [MarshalAs(UnmanagedType.ByValArray, SizeConst=32)]
    public char[] DeviceName;
    [MarshalAs(UnmanagedType.ByValArray, SizeConst=128)]
    public char[] DeviceString;
    public int StateFlags;
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 128)]
    public char[] DeviceID;
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 128)]
    public char[] DeviceKey;
}

В этом случае при вызове библиотечной функции поля передаются путем создания неуправляемых массивов нужной длины внутри копии структуры, которая и передается в вызов. Когда функция завершает свою работу, неуправляемые массива конвертируются в управляемые символьные массивы и ссылки на них присваиваются полям структуры. В результате, после вызовы функции вы обнаружите, что структура содержит массива нужного размера, заполненные данными.
В случае вызова функций сторонных библиотек, использование кастомного маршалинга является лучшим решением, поскольку при этом используется безопасный код. Хотя вызов сторонних функций с помощью p/Invoke и не является безопасным в общем смысле.

Сериализация структур


Теперь, после того как мы рассмотрели довольно сложные вопросы, связанные с размещением структур в памяти, самое время узнать, как получить все байты, составляющие структуру. Иными словами, как сериализовать структуру? Существует много способов сделать это, чаще всего используется метод Marshal.AllocHGlobal для выделения памяти в куче под неуправляемый массив. После этого все делается функциями, работающими с памятью, такими как StructToPtr или Copy. Пример:
public static byte[] RawSerialize(object anything)
{
    int rawsize = Marshal.SizeOf(anything);
    IntPtr buffer = Marshal.AllocHGlobal(rawsize);
    Marshal.StructureToPtr(anything, buffer, false);
    byte[] rawdata = new byte[rawsize];
    Marshal.Copy(buffer, rawdata, 0, rawsize);
    Marshal.FreeHGlobal(buffer);
    return rawdata;
}

Фактически, надобность в стольких действиях отсутствует, проще переместить байты структуры напрямую в байтовый массив без использования промежуточного буфера. Ключевым объектом в этом способе является GCHandle. Он возвратит хэндл Garbage Collector'а, и вы можете использовать метод AddrOfPinnedObject для получения стартового адреса структуры. Метод RawSerialize может быть переписан следующим образом:
public static byte[] RawSerialize(object anything)
{
    int rawsize = Marshal.SizeOf(anything);
    byte[] rawdata = new byte[rawsize];
    GCHandle handle = GCHandle.Alloc(rawdata, GCHandleType.Pinned);
    Marshal.StructureToPtr(anything, handle.AddrOfPinnedObject(), false);
    handle.Free();
    return rawdata;
}

Этот способ проще и быстрее. Вы можете использовать эти же методы для десериализации данных из байтового массива в структуру, однако более полезно будет рассмотреть решение проблемы чтения структуры из потока.

Чтение структур из потоков


Иногда возникает потребность зачитать структуру, возможно написанную на другом языке, в C# структуру. Например, вам нужно прочитать bitmap-файл, который начинается с заголовка файла, затем следует заголовок битмапа и затем собственно битовые данные. Структура заголовка файла выглядит так:
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct BITMAPFILEHEADER
{
    public Int16 bfType;
    public Int32 bfSize;
    public Int16 bfReserved1;
    public Int16 bfReserved2;
    public Int32 bfOffBits;
};

Функция, которая будет читать любой поток, и возвращать структуру, может быть написана без использования обобщений:
public object ReadStruct(FileStream fs, Type t)
{
    byte[] buffer = new byte[Marshal.SizeOf(t)];
    fs.Read(buffer, 0,	Marshal.SizeOf(t));
    GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
    Object temp = Marshal.PtrToStructure(handle.AddrOfPinnedObject(), t);
    handle.Free();
    return temp;
}

Для передачи данных здесь используется GCHandle. Новое в этом коде — использование параметра, указывающего на тип структуры. К сожалению, нельзя использовать этот тип для возвращаемого значения, поэтому после вызова функции необходимо преобразовать ее результат:
FileStream fs = new FileStream(@”c:\1.bmp”, FileMode.Open, FileAccess.Read);
BITMAPFILEHEADER bmFH = (BITMAPFILEHEADER) ReadStruct(fs, typeof(BITMAPFILEHEADER));

Если мы хотим избежать преобразования, тогда нужно использовать обобщенный метод:
public T ReadStruct<T> (FileStream fs)
{
    byte[] buffer = new byte[Marshal.SizeOf(typeof(T))];
    fs.Read(buffer, 0,	Marshal.SizeOf(typeof(T)));
    GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
    T temp = (T) Marshal.PtrToStructure(handle.AddrOfPinnedObject(), typeof(T));
    handle.Free();
    return temp;
}

Обратите внимание, что теперь мы должны преобразовать объект, возвращаемый методом PtrToStructure, в самом методе, а не в месте вызова, который теперь выглядит следующим образом:
BITMAPFILEHEADER bmFH = ReadStruct<BITMAPFILEHEADER>(fs);

Приятно наблюдать, насколько лучше выглядит использование обобщенного метода.

Ручной маршалинг


Маршалинг так хорошо работает в подавляющем количестве случаев, что можно вообще забыть о его существовании. Однако если вы сталкиваетесь с чем-то необычным, вы можете удивиться, что происходит, когда маршалинг перестает работать. Например, некоторым API вызовам нужно передавать указатель на указатель на структуру. Вы уже знаете, как передать указатель на структуру — это просто передача по ссылке — и поэтому вам может показаться, что передать указатель на указатель тоже просто. Однако все сложнее, чем вы ожидаете. Давайте посмотрим.
В функции AVIFileCreateStream два последних параметра передаются как указатели на IntPtr и структуру соответственно:
[DllImport(“avifil32.dll”)]
extern static int AVIFileCreateStream(IntPtr pfile, ref IntPtr pavi, ref AVISTREAMINFO lParam);

Для вызова это функции вы бы написали:
result = AVIFileCreateStream(pFile, ref pStream, ref Sinfo);

Основываясь на предыдущих примерах, кажется более легким изменить передачу указателя на структуру самим указателем. Казалось бы, что может быть неверного в следующем объявлении:
[DllImport(“avifil32.dll”)]
extern static int AVIFileCreateStream(IntPtr pfile, ref IntPtr pavi, IntPtr lParam);

Однако если вы попытаетесь передать адрес закрепленной (pinned) структуры:
GCHandle handle = GCHandle.Alloc(Sinfo, GCHandleType.Pinned);
result = AVIFileCreateStream(pFile, ref pStream, handle.AddrOfPinnedObject());
handle.Free();

… то увидите ошибку.


Причина этой ошибки заключается в том, что хотя вы и передаете указатель на адрес начала структуры, эта структура располагается в управляемой памяти, а неуправляемый код не может получить к ней доступ. Мы забываем о том, что стандартный маршалинг делает еще кое-какую работу при создании указателей. Перед тем, как создать указатели, для всех параметров, передаваемых по ссылке, создаются полные копии в неуправляемой памяти. После окончания вызова данные из неуправляемой памяти копируются обратно в управляемую.
Написать подобную функцию, которая делает работу маршалинга, нетрудно и очевидно полезно:
private IntPtr MarshalToPointer(object data)
{
    IntPtr buf = Marshal.AllocHGlobal(Marshal.SizeOf(data));
    Marshal.StructureToPtr(data, buf, false);
    return buf;
}

Тут просто возвращается IntPtr на область в куче, которая содержит копию данных. Единственный неприятный момент заключается в том, что нужно помнить об освобождении выделенной памяти:
IntPtr lpstruct = MarshalToPointer(Sinfo);
result = AVIFileCreateStream(pFile, ref pStream, lpstruct);
Marshal.FreeHGlobal(lpstruct);

Приведенный код работает в точности как стадартный маршалинг. Однако не забудьте, что lpstruct передается по значению как integer. Для того чтобы скопировать результат обратно в структуру, понадобится еще один метод:
private object MarshalToStruct(IntPtr buf, Type t)
{
    return Marshal.PtrToStructure(buf, t);
}

Теперь, после того как мы реализовали ручной маршалинг указателя в структуру, нам нужно получить указатель на указатель на структуру. К счастью, нам не потребуется писать нового кода, потому что наша функция преобразования структуры в указатель может преобразовывать любой тип данных в неуправляемый указатель — включая и сам указатель.

В качестве примера возьмем функцию AVISaveOption, т.к. она принимает два указателя на указатель в качестве параметров:
[DllImport(“avifil32.dll”)]
extern static int AVISaveOptions(
    IntPtr hWnd,
    int uiFlags,
    int noStreams,
    IntPtr ppavi,
    IntPtr ppOptions);

Фактически параметр ppavi — это указатель на хэндл (который в свою очередь является указателем), а ppOptions — указатель на указатель на структуру. Для вызова этого метода нам понадобится структура:
AVICOMPRESSOPTIONS opts = new AVICOMPRESSOPTIONS();

Определение этой структуры можно посмотреть в документации по стандарту AVI. На следующем шаге нам нужно получить маршализованный указатель на структуру:
IntPtr lpstruct = MarshalToPointer(opts);

… а затем указатель на указатель:
IntPtr lppstruct = MarshalToPointer(lpstruct);

… а за ним указатель на хэндл:
IntPtr lphandle = MarshalToPointer(pStream);

Теперь вызов функции:
result = AVISaveOptions(m_hWnd, ICMF_CHOOSE_KEYFRAME | ICMF_CHOOSE_DATARATE, 1, lphandle, lppstruct);

… где остальные параметры не представляют интереса, сведения о них могут быть найдены в документации.

После вызова функции все, что остается сделать, это переправить данные из неуправляемого буфера в структуру:
opts = (AVICOMPRESSOPTIONS) MarshalToStruct(lpstruct, typeof(AVICOMPRESSOPTIONS));

Обратите внимание, нужно использовать указатель на саму структуру, а не указатель на указатель! Ну и в конце освобождаем память:
Marshal.FreeHGlobal(lpstruct);
Marshal.FreeHGlobal(lppstruct);
Marshal.FreeHGlobal(lphandle);

Все это может показаться сложным. Использование указателей на указатели не является простой вещью, именно поэтому C# требует, чтобы код, работающий с указателями был помечен как небезопасный (unsafe).
С другой стороны, общие принципы работы довольно просты. Когда вы передаете что-то по ссылке, это содержимое копируется в неуправляемую память, и адрес на новый участок памяти передается в вызов функции.
Обычно стандартный маршалинг берет всю работу на себя. Однако если вам нужно что-то сверх этого, вы можете управлять всем копированием вручную.
Tags:
Hubs:
+68
Comments 11
Comments Comments 11

Articles