Сообщений 2    Оценка 520        Оценить  
Система Orphus

LPC

Недокументированный механизм IPC в Windows

Автор: Леошкевич Илья
МИФИ

Источник: RSDN Magazine #2-2006
Опубликовано: 23.06.2006
Исправлено: 05.11.2006
Версия текста: 1.0
Что такое LPC?
Типы портов
Сообщения
Типы данных
LPC-сервер
Создание порта
Получение сообщений
Установление соединения
Обмен сообщениями
LPC-клиент
Установление соединения
Обмен сообщениями
Асинхронный режим работы
Передача длинных сообщений
LPC в режиме ядра
Производительность
Файлы к статье
Заключение
Литература

Исполняемые файлы и исходные тексты к статье

Что такое LPC?

Аббревиатура LPC расшифровывается как «Local Procedure Call» - локальный вызов процедур. LPC используется операционной системой для передачи сообщений между подсистемами через специальные объекты – порты, увидеть которые можно с помощью утилиты WinObj, например, \ErrorLogPort.

LPC не документирован, но это не означает, что его нельзя использовать. Основной опасностью при использовании недокументированных функций является возможность их изменений в будущих версиях ОС, но LPC не претерпел, по крайней мере, серьезных, изменений от Windows NT до Windows 2003.

Основными достоинствами LPC являются:

LPC использует клиент-серверную архитектуру и предоставляет API, чем-то схожий с интерфейсом сокетов. Во взаимодействии участвуют поток-сервер и один или несколько потоков-клиентов: каждый клиент предварительно соединяется с сервером, посылает одно или несколько сообщений, на некоторые из этих сообщений получает ответ и разрывает соединение.

Для использования LPC нужны соответствующие заголовочные (.h) и библиотечные (.lib) файлы. Библиотечные файлы (ntdll.lib для режима пользователя и ntoskrnl.lib для режима ядра) входят в состав DDK, а в случае отсутствия DDK, их можно создать самостоятельно, воспользовавшись этим HOWTO. Заголовочные файлы и проект, генерирующий ntdll.lib прилагаются к статье. Помимо этого, объявления функций LPC можно найти в IFS Kit (ntifs.h).

Типы портов

Условно порты LPC можно разделить на 3 категории:

Сообщения

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

typedef struct _LPC_MESSAGE_HEADER
{
  // длина тела сообщения
  USHORT                  DataLength;
  // длина сообщения с заголовком
  USHORT                  TotalLength;
  // тип сообщения
  USHORT                  MessageType;
  USHORT                  DataInfoOffset;
  // уникальный идентификатор процесса, пославшего сообщение
  ULONG                   ProcessId;
  // уникальный идентификатор потока, пославшего сообщение
  ULONG                   ThreadId;
  // идентификатор сообщения
  ULONG                   MessageId;
  ULONG                   CallbackId;
} LPC_MESSAGE_HEADER, *PLPC_MESSAGE_HEADER;

Важным атрибутом сообщения является его тип, который указывается в поле MessageType. Всего насчитывается около 10 типов сообщений, наиболее важными из них являются:

Непосредственно за заголовком сообщения следуют данные, поэтому имеет смысл определить следующую структуру:

typedef struct _LPC_MESSAGE
{
  LPC_MESSAGE_HEADER      Hdr;
  BYTE                    Data[MAX_LPC_MESSAGE_LENGTH];
} LPC_MESSAGE, *PLPC_MESSAGE;

Значение константы MAX_LPC_MESSAGE_LENGTH следует задавать в соответствии с нуждами приложения. Заметим, что в большинстве случаев указатели на LPC_MESSAGE_HEADER и LPC_MESSAGE можно безбоязненно приводить друг к другу.

Типы данных

Так как LPC физически реализован в ядре, при использовании его в режиме пользователя может возникнуть нужда в объявлениях некоторых типов, которые приводятся ниже.

Все используемые функции имеют тип возврата NTSTATUS, возможные значения которого объявлены в DDK в файле ntstatus.h. Для приложений пользовательского режима в Platform SDK описанных значений гораздо меньше, а также отсутствует полезный макрос, определяющий успех выполнения функции (все коды, обозначающие неудачу, больше 0x80000000):

#define NT_SUCCESS(x) ((x)>=0)

Для задания имен (ObjectName) и прав доступа (SecurityDescriptor) к объектам операционной системы (в нашем случае – портам) используется структура OBJECT_ATTRIBUTES, достаточно часто используемая при написании драйверов, объявление которой имеется в DDK и IFS:

typedef struct _OBJECT_ATTRIBUTES 
{
  ULONG Length;
  HANDLE RootDirectory;
  PUNICODE_STRING ObjectName;
  ULONG Attributes;
  // Points to type SECURITY_DESCRIPTOR
  PVOID SecurityDescriptor;      
  // Points to type SECURITY_QUALITY_OF_SERVICE
  PVOID SecurityQualityOfService;
} OBJECT_ATTRIBUTES;
typedef OBJECT_ATTRIBUTES *POBJECT_ATTRIBUTES;

Для удобства при заполнении этой структуры используется макрос InitializeObjectAttributes, также определенный в DDK:

#define InitializeObjectAttributes( p, n, a, r, s ) { \
    (p)->Length = sizeof( OBJECT_ATTRIBUTES );          \
    (p)->RootDirectory = r;                             \
    (p)->Attributes = a;                                \
    (p)->ObjectName = n;                                \
    (p)->SecurityDescriptor = s;                        \
    (p)->SecurityQualityOfService = NULL;               \
    }

LPC-сервер

Создание порта

Первый этап написания сервера – создание порта сервера с помощью функции NtCreatePort:

NTSYSAPI NTSTATUS NTAPI NtCreatePort(
  // указатель на переменную, 
  // в которую в случае успеха будет помещен хэндл порта
  OUT PHANDLE              PortHandle,
  IN  POBJECT_ATTRIBUTES   ObjectAttributes,
  IN  ULONG                MaxConnectInfoLength,
  IN  ULONG                MaxDataLength,
  // в качестве этого параметра можно передать 0
  IN  ULONG                MaxPoolUsage
  );

Имя порта задается с помощью параметра ObjectAttributes.

СОВЕТ

Пример правильного имени порта – L”\\SimpleLpcPort”

Параметры MaxConnectInfoLength и MaxDataLength определяют максимальный размер в байтах сообщений об установлении соединения и информативных сообщений соответственно.

ПРЕДУПРЕЖДЕНИЕ

Эти параметры не должны превышать 260 и 328 соответственно.

Таким образом, вызов NtCreatePort может выглядеть следующим образом:

Использование NtCreatePort
// Здесь LpcPort – класс, имеющий в качестве переменной-члена HANDLE m_hPort
NTSTATUS LpcPort::Create(LPCWSTR PortName)
{
  ASSERT(m_hPort==NULL);

  NTSTATUS NtStatus;
  OBJECT_ATTRIBUTES port_attr;
  UNICODE_STRING port_name; RtlInitUnicodeString(&port_name, PortName);

  InitializeObjectAttributes(&port_attr, &port_name, 0, NULL, NULL);
  NtStatus = NtCreatePort(
    &m_hPort, &port_attr, 40, sizeof(LPC_MESSAGE), NULL);
  return NtStatus;
};

Получение сообщений

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

Для получения сообщений служит функция NtReplyWaitReceivePort:

NTSYSAPI NTSTATUS NTAPI NtReplyWaitReceivePort(
  // хэндл порта, для которого следует получить сообщение
  IN  HANDLE               PortHandle,
  // указатель на переменную, в которую будет помещен идентификатор соединения
  OUT PVOID*               PortContext       OPTIONAL,
  IN  PLPC_MESSAGE_HEADER  Reply             OPTIONAL,
  // указатель на переменную, в которую будет помещено сообщение
  OUT PLPC_MESSAGE_HEADER  IncomingRequest
  );

Функция блокирует выполнение потока-сервера до тех пор, пока не будет получено сообщение. Параметра, ограничивающего время ожидания, нет.

ПРЕДУПРЕЖДЕНИЕ

Если полученное сообщение больше выделенного буфера, может произойти переполнение, поэтому следует выделять буфер размера не меньше, чем максимальный размер сообщения, задаваемый при вызове NtCreatePort. В нашем случае выделяется буфер размера sizeof(LPC_MESSAGE).

Диспетчеризация сообщений выполняется в бесконечном цикле, показанном на рисунке:


Установление соединения

При попытке соединения серверу приходит сообщение типа «LPC_CONNECTION_REQUEST» (0xA), в котором также могут присутствовать данные. Сервер может разрешить установить соединение или отказать в нем. Для обоих целей служит функция NtAcceptConnectPort:

NTSYSAPI NTSTATUS NTAPI NtAcceptConnectPort(
  // указатель на переменную, в которую будет помещен хэндл порта ответа
  OUT    PHANDLE                   ServerPortHandle,
  // идентификатор соединения
  IN     PVOID                     PortContext,
  // сообщение, содержащее запрос на соединение 
  // (получено функцией NtReplyWaitReceivePort)
  IN     PLPC_MESSAGE_HEADER       ConnectionMsg,
  // если TRUE, то соединение принимается, если FALSE - отклоняется
  IN     BOOLEAN                   AcceptConnection,
  IN OUT PLPC_SECTION_OWNER_MEMORY ServerSharedMemory           OPTIONAL,
  OUT    PLPC_SECTION_MEMORY       ClientSharedMemory           OPTIONAL
  );

Значение «TRUE» параметра AcceptConnection означает, что соединение разрешено, а «FALSE» – отказ в его установлении.

Последние два параметра описывают разделяемую память, которую клиент и сервер могут использовать для преодоления ограничения на размер сообщения в 328 байт, их использование будет описано ниже. Если передавать длинные сообщения не нужно, в качестве параметров ServerSharedMemory и ClientSharedMemory следует передавать NULL.

Идентификатор (контекст) соединения – это любые 4 байта, ассоциируемые с соединением. В качестве идентификатора разумно использовать указатель на структуру, описывающую соединение. Помимо прочего, в этой структуре следует сохранить хэндл порта ответа.

Пример структуры, описывающей контекст соединения
struct PORT_CONTEXT
{
  // хэндл порта ответа
  HANDLE hReplyPort;
  // прочие данные, связанные с соединением
  LPSTR ClientLogin;
  PVOID ClientAuthorizationAttributes;
}

После вызова этой функции следует вызвать NtCompleteConnectPort:

NTSYSAPI NTSTATUS NTAPI NtCompleteConnectPort(
  // хэндл, возвращенный функцией NtAcceptConnectPort
  IN HANDLE               PortHandle
  );

Пока сервер не вызовет NtCompleteConnectPort, клиент, устанавливающий соединение, будет находиться в режиме ожидания.

ПРИМЕЧАНИЕ

Для ожидания запросов на соединение можно использовать функцию NtListenPort, которая в цикле вызывает NtReplyWaitReceivePort и возвращает управление только если приходит сообщение LPC_CONNECTION_REQUEST, а остальные сообщения отбрасывает. Ввиду того, что за принятие всех сообщений от всех клиентов отвечает единственный поток, использование этой функции приводит к невозможности обслуживания более чем одного клиента единовременно.

Обмен сообщениями

Помимо запросов на установку соединения серверу могут приходить сообщения следующих типов:

NTSYSAPI NTSTATUS NTAPI NtReplyPort(
  IN HANDLE               PortHandle,
  IN PLPC_MESSAGE_HEADER  Reply
  );

Сообщение-ответ должно иметь такой же MessageId, что и соответствующий запрос, а для надёжности при инициализации ответа можно просто продублировать заголовок запроса. Пока сервер не пошлёт ответ, клиент будет находиться в ожидании. Если сервер, не ответив, повторно вызовет NtReplyWaitReceivePort, клиенту вернется ошибка STATUS_LPC_REPLY_LOST.

ПРИМЕЧАНИЕ

Несмотря на то, что все запросы приходят на порт сервера, отсылать ответ следует на порт ответа. Порт ответа можно узнать, проанализировав контекст соединения, возвращенный функцией NtReplyWaitReceivePort. Как было отмечено выше, в контекст соединения следует помещать указатель на пользовательскую структуру, хранящую хэндл порта ответа.

Таким образом, цикл обработки сообщений сервера должен выглядеть следующим образом:

Цикл обработки сообщений. Псевдокод.
NTSTATUS LpcServer::Run()
{
  while(true)
  {
    NtReplyWaitReceivePort(&Msg, &Context); // получение сообщения
    LpcPort* _Client = (LpcPort*)Context;   // получение информационного блока

    switch(Msg.Hdr.MessageType)
    {
      case LPC_CONNECTION_REQUEST: // запрос на соединение
      {
        // обработка сообщения, 
        //решение принять/не принять записывается в bAccept
        OnConnect(&Msg, &bAccept);
        // создание нового информационного блока
        LpcPort* _NewClient = new LpcPort();
        NtAcceptConnectPort(m_hPort, _NewClient, &Msg, bAccept, NULL, NULL);
        // в качестве параметра передается порт ответа
        NtCompleteConnectPort(_NewClient->m_hPort); 
        break;
      }
      case LPC_REQUEST: // запрос
      {
        // обработка сообщения
        OnRequest(&Msg, &MsgReply);
        // отправка ответа через порт ответа
        NtReplyPort(_Client->m_hPort, &MsgReply);
        break;
      }
      case LPC_PORT_CLOSED:
      {
        OnClose(&Msg);     // обработка сообщения
        delete _Client;    // удаление информационного блока
        break;
      }
      default:
      break;
    };
  };
};

LPC-клиент

Установление соединения

Для установления соединения с LPC-сервером используется функция NtConnectPort:

NTSYSAPI NTSTATUS NTAPI NtConnectPort(
  // указатель на переменную, в которую будет помещен хэндл порта клиента
  OUT    PHANDLE                      ClientPortHandle,
  // имя порта сервера
  IN     PUNICODE_STRING              ServerPortName,
  // настройки безопасности
  IN     PSECURITY_QUALITY_OF_SERVICE SecurityQos,
  // для преодоления барьера в 328 байт
  IN OUT PLPC_SECTION_OWNER_MEMORY    ClientSharedMemory   OPTIONAL,
  OUT    PLPC_SECTION_MEMORY          ServerSharedMemory   OPTIONAL,
  // указатель на переменную, в которую 
  // будет помещен максимальный размер сообщения
  OUT    PULONG                       MaximumMessageLength OPTIONAL,
  // данные, пересылаемые вместе с сообщением об установлении соединения
  IN OUT PVOID                        ConnectionInfo       OPTIONAL,
  IN OUT PULONG                       ConnectionInfoLength OPTIONAL
  );

Если сервер согласится принять соединение, будет возвращено значение STATUS_SUCCESS, в противном случае – STATUS_PORT_CONNECTION_REFUSED.

ПРЕДУПРЕЖДЕНИЕ

Параметр SecurityQos указывать обязательно!

Соответствующую переменную можно объявить как:

SECURITY_QUALITY_OF_SERVICE SecurityQos = 
{ 
   sizeof(SecurityQos), SecurityImpersonation, SECURITY_DYNAMIC_TRACKING, TRUE 
};

Процесс установления соединения можно проиллюстрировать следующим образом:


Обмен сообщениями

Для отправки сообщения, не требующего ответа (LPC_DATAGRAM), следует вызвать функцию NtRequestPort:

NTSYSAPI NTSTATUS NTAPI NtRequestPort(
  IN HANDLE               PortHandle,
  IN PLPC_MESSAGE_HEADER  Request
  );

В заголовке сообщения должны быть заполнены только поля длины, остальные поля, включая тип, должны быть установлены в 0.

Если требуется получить ответ (LPC_REQUEST), используется функция NtRequestWaitReplyPort. Функция не возвращает управление, пока сервер не ответит на сообщение.

NTSYSAPI NTSTATUS NTAPI NtRequestWaitReplyPort(
  IN  HANDLE               PortHandle,
  IN  PLPC_MESSAGE_HEADER  Request,
  // указатель на переменную, в которую будет помещен ответ
  OUT PLPC_MESSAGE_HEADER  IncomingReply
  );

Общая картина обмена сообщениями показана на рисунке:


Асинхронный режим работы

LPC можно использовать в режиме асинхронной передачи сообщений, для чего существует специальный тип порта сервера – так называемый «waitable port». Создать его можно с помощью функции NtCreateWaitablePort, аргументы которой точно такие же, как и у NtCreatePort. Хэндл waitable-порта можно передавать в качестве аргумента функциям ожидания (например, WaitForSingleObject) – считается, что порт находится в сигнальном состоянии, если имеются сообщения, не полученные функцией NtReplyWaitReceivePort.

Асинхронный режим работы можно использовать, например, для завершения работы сервера. Действительно, гарантированно остановить сервер, ожидающий выход из функции NtReplyWaitReceivePort можно только уничтожением его потока, а это не очень хороший стиль программирования. Используя WaitForSingleObject перед каждым вызовом NtReplyWaitReceivePort, можно получить вполне приемлемую схему:


Передача длинных сообщений

Для передачи сообщений длиной более 328 байт используется дополнительная разделяемая память, описание которой следует передавать в качестве параметров ClientSharedMemory и/или ServerSharedMemory функциям NtConnectPort и NtAcceptConnectPort. Для работы с ней не требуется вводить специальных типов или заголовков сообщений; она используется параллельно с основной системой передачи данных в теле сообщения.

Возможна односторонняя передача длинных сообщений: например, можно сделать так, что сервер сможет посылать клиенту длинные и короткие сообщения, а клиент серверу – только короткие. При передаче длинных сообщений от клиента к серверу следует использовать параметр ClientSharedMemory, а при передаче длинных сообщений от сервера клиенту – ServerSharedMemory.

Для описания разделяемой памяти используются две структуры: LPC_SECTION_OWNER_MEMORY – для потока, создавшего разделяемую память, и LPC_SECTION_MEMORY – для потока, использующего разделяемую память, созданную другим потоком. Таким образом, вполне логичным кажется факт, что параметр функции NtConnectPort ClientSharedMemory имеет тип LPC_SECTION_OWNER_MEMORY, а одноименный параметр NtAcceptConnectPort – LPC_SECTION_MEMORY. Схожее утверждение верно для параметра ServerSharedMemory.

Рассмотрим описания этих структур. Некоторые поля следует заполнять перед вызовом соответствующих функций (IN), другие поля заполняются в ходе работы этих функций (OUT).

typedef struct _LPC_SECTION_OWNER_MEMORY
{
  // [IN] Размер структуры LPC_SECTION_OWNER_MEMORY в байтах
  ULONG                   Length;
  // [IN] Хэндл секции (возвращается функциями 
  // CreateFileMapping и ZwCreateSection в ядре)
  HANDLE                  SectionHandle;
  // [IN, OUT] Смещение внутри секции, начиная с которого
  // в секцию будут помещаться данные; обычно 0
  ULONG                   OffsetInSection;
  // [IN, OUT] Размер фрагмента секции, отображаемого в память; 
  // обычно 0 - «отобразить секцию до конца»
  ULONG                   ViewSize;
  // [OUT] Адрес, по которому секция будет отображена 
  // в адресное пространство сервера
  PVOID                   ViewBase;
  // [OUT] Адрес, по которому секция будет отображена 
  // в адресное пространство клиента
  PVOID                   OtherSideViewBase;
} LPC_SECTION_OWNER_MEMORY, *PLPC_SECTION_OWNER_MEMORY;

Обязательно указывать следует лишь параметры Length и SectionHandle, остальные можно заполнить нулями – определение размера секции, отображение её в адресное пространство и заполнение остальных полей происходят автоматически после вызова NtConnectPort (для клиента) или NtAcceptConnectPort (для сервера).

typedef struct _LPC_SECTION_MEMORY
{
  // [IN] Длина структуры LPC_SECTION_MEMORY в байтах
  ULONG                   Length;
  // [OUT] Размер фрагмента секции, отображенного в память
  ULONG                   ViewSize;
  // [OUT] Адрес фрагмента секции, отображенного 
  // в адресное пространство сервера
  PVOID                   ViewBase;
} LPC_SECTION_MEMORY, *PLPC_SECTION_MEMORY;

Заполнять обязательно только поле Length, остальные поля заполняются автоматически. Переменные типа LPC_SECTION_MEMORY используются для получения информации о разделяемой области памяти, созданной на другом конце соединения.

ПРИМЕЧАНИЕ

Структура типа LPC_SECTION_OWNER_MEMORY используется для создания разделяемой области памяти на текущем конце соединения, а LPC_SECTION_MEMORY – для получения информации и разделяемой памяти, созданной на другом конце соединения.

Таким образом, для отправки длинного сообщения от клиента серверу требуется:

Передача длинных сообщений от сервера клиенту производится аналогично.

LPC в режиме ядра

ntoskrnl.exe экспортирует только клиентские функции LPC – NtConnectPort, NtRequestWaitReplyPort и некоторые другие. Таким образом, создание LPC-сервера в ядре невозможно, однако это не является большим недостатком – LPC в режиме ядра лучше всего использовать для оповещения приложений пользовательского режима. Создание LPC-сервера режима ядра теоретически возможно, но оно потребует использования достаточно сложного приёма, связанного с недокументированными подробностями внутреннего устройства ядра Windows.

Клиентская же функциональность в ядре представлена полностью.

Производительность

Для измерения производительности были написаны тестовые клиенты, посылающие сообщения тестовым эхо-серверам. Использовались короткие сообщения длиной 128 байт, чтобы выявить накладные расходы, не связанные с копированием данных из одного буфера в другой.

Длина сообщенияLPCИменованные каналыСокеты
128 байт0,012 мс.0,023 мс.0,055 мс.

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

Файлы к статье

К статье прилагаются исходные файлы следующих демонстрационных проектов (Visual Studio 7.1):

Класс LpcPort предоставляет все возможности, описанные в статье – базовые функции по обмену сообщениями, асинхронный режим работы и передачу длинных сообщений. LpcClient и LpcReplyPort представляют собой просто обёртки над LpcPort, а LpcServer – абстрактный (по сути, но не по реализации – его экземпляры могут быть созданы) класс, реализующий цикл обработки сообщений, в котором можно переопределить виртуальные функции OnConnect, OnRequest, OnClose и OnIdle.

Test__LpcServer демонстрирует создание собственного LPC-сервера на основе класса LpcServer.

Заключение

Данная статья является обзором недокументированного механизма LPC, в ней продемонстрированы основные моменты, необходимые для реализации простейших LPC-клиента и LPC-сервера. LPC как механизм передачи сообщений не всегда превосходит свои аналоги – именованные каналы, сокеты, синхронизированный доступ к разделяемой памяти, и т.д. И всё же, могут возникнуть ситуации, когда его использование выглядит достаточно привелекательным – идеальным примером явлется передача данных по инициативе драйвера пользовательскому приложению.

Литература

  1. «Undocumented Windows NT» ;
  2. http://undocumented.ntinternals.net/UserMode/Undocumented%20Functions/NT%20Objects/Port/ ;
  3. «Внутреннее устройство Microsoft Windows: Windows Server 2003, Windows XP и Windows 2000», глава 3 ;
  4. Gary Nebbet – «Windows NT/2000 Native API Reference», Chapter 12.


Эта статья опубликована в журнале RSDN Magazine #2-2006. Информацию о журнале можно найти здесь
    Сообщений 2    Оценка 520        Оценить