Статья
Версия для печати
Обсудить на форуме
часть 2: пишем драйвер. 


Выбор задачи для эксперимента.

Для отработки техники менеджера ресурса QNX "в живую" требовалась адекватная задача под проект, что само по себе не есть тривиальная задача:
  • Как уже понятно из изложенного ранее, и станет ещё более понятно из последующего изложения - менеджер ресурса, это программный интерфейс, управляющий тем "нечто", что, собственно, и названо "ресурс". Такой интерфейс способен, в частности, трансформировать поток стандартных POSIX запросов в реакции "ресурса", которые, при достаточно не традиционном "ресурсе", могут в корне отличаться от привычных реакций на общеизвестные запросы POSIX (тем не менее, полностью наследуя их форму).
  • Поведение аппаратного устройства (например, последовательного порта), или системного псевдоустройства (например, файловой системы), управляемого менеджером ресурса, понятно и не очень интересно даже из самого общего рассмотрения техники программирования. Хотелось бы иметь максимально "капризный" и "неожиданный" ресурс, связанный, например, с подсистемой графических окон, который вовлекал бы в свою работу максимальное число слоёв OS, и требовал параллелизма, реентерабельности и т.п.
  • Хотелось бы, чтобы каждый читатель, которого заинтересовала техника менеджера ресурса, имел бы возможность собрать, проверить и модифицировать данный проект под свои потребности, не располагая для этого никаким специфическим или экзотическим периферийным оборудованием.

В качестве такой задачи и был выбран экстремально экстравагантный "ресурс" - оконная система обмена сообщениями "на манер ICQ". Отличия состоят в том, что:
  • Обмен осуществляется не через коммуникационный сервер, а непосредственно приложение с приложением, каждое из которых выступает в отношении партнёра и клиентом и сервером.
  • В качестве сетевого протокола используется не SOCKS или TCP/IP, а "родной" протокол сети QNET. Использование этого условия позволит нам увидеть  дополнительные уникальные возможности программной техники, не достижимые никакими иными способами (или в другой OS).

Более того, мне хотелось бы верить в дополнительную целесообразность такого проекта, состоящую в том, что:
  • Взяв за основу работающий программный код "по мотивам ICQ", и используя описываемую технику, кто либо мог бы легко трансформировать его в "реальный ICQ", особенно если учесть, что для QNX нет хорошо зарекомендовавшего себя ICQ-клиента. Замена несущих сетевых протоколов не должна быть существенно сложной, так как, при всей своей громоздкости, стандартные протоколы SOCKS или TCP/IP идеологически проще, чем протокол QNET.
  • На базе подобного приложения можно без труда построить целый ряд вариаций сетевых клиентов, типа IRC, или нечто подобного.

Все предыдущие "предложения" - это всё таки красивые игрушки, но на базе этой техники возникает возможность построить модель распределённых вычислений (или управления), когда во встроенные (embedded) периферийные компьютеры на базе промышленных PC можно  загружать произвольные программные агенты. Агенты взаимодействуют с родительским хостом, и функционируют как составные части единого вычислительного процесса. Отработке этой возможности, собственно, и посвящалась проделанная работа.

В результате всех названных требований и пожеланий сложилась общая структура проекта, которая и показана на ниже на рис.2:


В этой структуре пользовательское приложение (clitalk) после установления соединения (выполнения 2-х open() - к своему локальному менеджеру ресурса и аналогичному на удалённом хосте сети) не выполняет, фактически, ничего, кроме 2-х встречных копирующих потоков (read()-write()). Вся фактическая работа с окнами обмена (от создания по open() и до уничтожения по close()) выполняется менеджером (-рами) ресурса. Операция read() переопределена как считывание текста, введенного пользователем в поле ввода (phone_reply на рис.4 ниже) по нажатию кнопки "Отправить" (phone_send). Операция write() переопределена как "дописывание" (конкатенация) текста к содержимому результирующего поля вывода (phone_text).

Рисунок 3.

На рис.3 показан общий скриншот окончательного проекта (подробнее этот рисунок будет неоднократно комментироваться далее). Рисунок "снят" с приложения, работающего в реальной сети, в окне выбора хоста виден список из 2-х компьютеров: "rtp" и "feb" (эти имена - не в SMB или TCP/IP сети, а в сети QNET). Реализация этого проекта и описывается далее. Показанный скриншот получен в ходе выполнения реального проекта как последовательность следующих шагов:
  • Запускается менеджер ресурса (задача rmta), при запуске он выводит информационное окно (показано слева вверху, озаглавлено "RM for QNET-talk", об этом окне мы ещё будем говорить подробее), регистрирует в локальной файловой системе имя /proc/talk (это легко проверить, например, командой ls /proc/t* непосредественно после запуска менеджера ресурса). Далее находится в режиме пассивного ождания запросов клиентов.
  • Запускается клиентская задача clitalk, она выводит окно выбора хоста для связи (слева ниже на рисунке, озаглавлено "QNET talk-connector"), в окне отображаются имена реально обнаруженных в сети QNET хостов. В момент снятия скриншота в сети QNET реально находилось 2 хоста: rtp и feb, что можно видеть на рисунке. Содержимое списка хостов периодически по таймеру (1 раз в секунду) обновляется: если некоторый хост становится недоступным в сети, или появляется новый - то это отображается в списке. Перемещение по списку обычное: клавишами стрелок "вверх" или "вниз", или мышкой. Текущий выделенный хост подсвечивается цветом (rtp на рисунке), его имя "сносится" вниз, в отдельное текстовое окно.
  • Выбрав хост для связи (на этом хосте для возможности установления связи должен также быть запущен менеджер ресурса rmta), пользователь нажимает кнопку "Связать". При этом на каждом из связываемых хостов всплывает диалоговое окно обмена (два таких окна показаны в правой части рис.3, а подробнее окно - на рис.4). В конфигурации, показанной на рис.3, в качестве и локального и удалённого узлов выбран один - локальный узел rtp, такой частный случай использования удобен для наблюдения поведения и отладки проекта.
  • Теперь пользователи на каждом из связанных хостов сети независимо могут набирать свои сообщения в поле phone_reply (рис.4) своего диалогового окна, и по нажатию "Отправить" (phone_send) пересылать набранное корреспонденту по сети. Отправленное содержимое конкатенируется с общим содержимым диалога phone_text как на отправляющем, так и на принимающем хостах (чем поддерживается синхронизация содержимого диалого). В общем, функционально работа с проектом весьма напоминает работу со ставшим уже традиционным ICQ...

Примечание: ещё одним вопросом, неоднократно задаваемым мне читателями этого текста было: "Почему в качестве регистрируемого имени выбрано столь странное (и даже "провокационное" для UNIX) место - /proc/talk, хотя гораздо естественнее было бы, например, /dev/talk?". Да, именно так, но /proc/talk и выбранно именно для того, чтобы показать (и проверить), что менеджер ресурса волен регистрировать имя ресурса в произвольном месте файловой системы!

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

Подготовка интерфейсной части.

На рис.4 показан тот "оконный ресурс", которым мы предполагаем управлять посредством интерфейса менеджера ресурса. В программном коде он реализуется классом Client. Здесь же на рис.4 проставлены имена widget-ов, необходимые для понимания исходного кода.

Интерфейсная часть сервера, реализующая GUI диалоговое окно обмена реализуется в классе Client (файлы "Client.h" и "Client.cpp" - это, собственно, и есть тот "ресурс", POSIX интерфейс к которому создаёт менеджер ресурса). Объект этого класса (при выполнении для него new и создаёт графическое диалоговое окно, с которым работает пользователь, рис.4) Заголовок класса Client выглядит так:
Код: (C++)
// Класс клиента обмена, обслуживаемого RM
class Client {
private:
   int  nPt;            // флаги состояние блокирования графической библиотеки
   PtWidget_t*  pFrame; // фрейм клиентского компонента
   char*        pBuff;  // буффер хранения содержимого окна ввода
   static PtWidget_t* getFrame( PtWidget_t* );
   static Client* getCode( PtWidget_t* );
   static Client* getWinCode( PtWidget_t* );  
public:
   Client( void );
   ~Client( void );
   // статические callback-и
   static int Client::OnSend( PtWidget_t*, ApInfo_t*, PtCallbackInfo_t* );
   static int Client::OnCancel( PtWidget_t*, ApInfo_t*, PtCallbackInfo_t* );  
   static int Client::OnClose( PtWidget_t*, ApInfo_t*, PtCallbackInfo_t* );      
   // методы, фактически реализующие callback-и
   void Send( void );
   void Cancel( void );
   void SendClose( void );    
   // запись-чтение окна клиента
   char* Read( void );
   void Write( char*, bool = true );
};  

        phone

         phone_text

         phone_reply

         phone_cancel

         phone_send
).


Класс Client выполнен в технике компонентного программирования, которая детально описана в [2]: каждому static обработчику (callback) GUI-событию (OnSend, OnCancel, OnClose) соответствует обработчик экземпляра класса (Send, Cancel, SendClose). Реакция, вызванная событием, например, OnSend сама находит widget адресат, в котором вызвано событие, и обрабатывается в это widget. Например, как реализован Send:
Код: (C++)
// Отсылка сообщения, состоит в том, что сообщение:
// а).дописывается сообщение в своё окно результата,
// б).оно же заносится в буфер чтения для последующей передачи корреспонденту
void Client::Send( void ) {    
   char *pRpl;
   PtWidget_t *pR = ApGetWidgetPtr( pFrame, ABN_phone_reply );
   PtGetResource( pR, Pt_ARG_TEXT_STRING, &pRpl, 0 );
   Write( pBuff = strdup( pRpl ), false );  
   PtGiveFocus( ApGetWidgetPtr( pFrame, ABN_phone_text ), NULL );      
};

Эта техника применена для обработки всех событий GUI (см. исходные тексты проекта), и более детально здесь не рассматривается. Остановимся только на нескольких ключевых операциях, и принципиально важных для работы класса в составе менеджера ресурса.

- создание компонента (окна) при выполнении операции open():
Код: (C++)
Client::Client( void ) {
   nPt = PtEnter( 0 );
   pFrame = ApCreateModule ( ABM_phone, NULL, NULL );                                                   // отрисовать фрейм компонента    
   PtSetResource( pFrame, Pt_ARG_POINTER, this, 0 );                                                            // установить связь GUI с объектом кода  
   pBuff = strdup( "" );      
   PtLeave( nPt );
};

- уничтожение окна при выполнении close():
Код: (C++)
Client::~Client( void ) {
   nPt = PtEnter( 0 );
   PtDestroyWidget( pFrame );
   PtLeave( nPt );    
};

- чтение из канала при выполнении read():
Код: (C++)
// Чтение из канала: содержимое буфера чтения отправляется клиенту
char* Client::Read( void ) {
   char *pRpl =  strdup( pBuff );    
   pBuff = strdup( "" );      
   nPt = PtEnter( 0 );  
   PtWidget_t *pR = ApGetWidgetPtr( pFrame, ABN_phone_reply );        
   if( strlen( pRpl ) > 0 ) PtSetResource( pR, Pt_ARG_TEXT_STRING, "", 0 );  
   PtGiveFocus( pR, NULL );          
   PtLeave( nPt );  
   return strdup( pRpl );  
};

- запись в результирующее окно клиента при выполнении write():
Код: (C++)
// Запись в окно клиента  
void Client::Write( char* pS, bool bExt ) {
   nPt = bExt ? PtEnter( 0 ) : nPt;
   // окно результирующего диалога
   PtWidget_t *pT = ApGetWidgetPtr( pFrame, ABN_phone_text );
   // буфер накопления строки диалога
   char *pTxt;      
   PtGetResource( pT, Pt_ARG_TEXT_STRING, &pTxt, 0 );            
   char *pBuf = new char[ strlen( pTxt ) + strlen( pS ) + 2 ];    
   strcpy( pBuf, pTxt );
   if( strlen( pS ) != 0 ) {
      strcat( pBuf, "n" );
      strcat( pBuf, pS );
   };
   // перезапись конкатенированной строки в окно результата
   PtSetResource( pT, Pt_ARG_TEXT_STRING, pBuf, 0 );      
   if( bExt ) PtLeave( nPt );  
   delete pBuf;
};

Написание менеджера ресурса.

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

Первый шаг в написании менеджера ресурса состоял в том, что я просто скопировал достаточно объёмный исходный текст примера много-потокового  менеджера ресурса из HELP подсистемы QNX, откомпилировал, и ... это заработало (ещё один и безусловно заслуженный реверанс в сторону качества поставляемой с QNX технической документации). Всё последующее 2-х недельное "выкручивание рук" этому, пока ещё ничего осмысленного не делающего, менеджеру ресурса было делом рутинной техники.

Подобная "технология" подтверждает ещё одно предположение: приложение менеджера ресурса (посредством которого в QNX можно описать всё или почти всё) в основной части кода является единообразным шаблоном, который нужно не столько писать, сколько глубоко понять и незначительно "подправить" под специфику своего "ресурса" (около 90% строк кода ключевого файла проекта Phone.cpp - составляет "шаблонный" код менеджера ресурса!).

Ещё один нюанс. Вот строка из документации (больше этот аспект нигде в документации не затрагивается): "Для подключения менеджер ресурса должен быть запущен с полномочиями root". Но здесь не скрывается существенное ограничение: мы собираем приложение менеджера ресурса от имени суперпользователя root, после чего устанавливаем бит suid в полученном приложении (альтернативный путь - сборка от имени произвольного пользователя, после чего с помощью chown изменяем владельца на root). Клиентское приложение собирается от имени рядового пользователя. 

Комментарии к коду менеджера ресурса.

Логика прилоения в целом должна была реализовывать следующее:
  • когда пользовательская (клиентская задача - GUI Photon приложение) clitalk выполняет операцию open() к РМ (серверу) rmta, то POSIX операция open() в rmta переопределена как "создание окна клиентского диалога" (рис.4);
  • клиентская задача clitalk, собственно говоря, по запросу пользователя "Связать" всегда выполняет 2 операции open() к 2-м РМ на сетевых хостах, которые необходимо связать диалогом (как правило, один из них - локальный хост, но могут быть и оба локальными, что удобно для отладки и наблюдения);
  • таким образом создаётся "канал" обмена между сетевыми хостами.
  • операция read() переопределена в rmta так, что возвращает содержимое текстового многострочного окна phone_reply, при условии, что пользователь нажал "Отправить";
  • операция write() переопределена в rmta так, что она конкатенирует содержимое окна phone_text с передаваемым операцией текстом;
  • клиентская задача clitalk представляет из себя только 2 ретранслирующих программных потока, и, если наш "канал" связывает диалоговые окна на хостах A и B, то функции каждого потока - выполнить read() с дескриптора (возвращённого open()) "своего" хоста, после чего выполнить write() к связанному хосту (поля phone_text обоих корреспондентов должны оставаться идентичными).
  • если новая копия задачи clitalk запросит open() к уже выполняющемуся rmta, то тот должен создать новый независимый клон "канала" (новые экземпляры OCB в менеджере ресурса, и новую пару файловых дескрипторов, возвращаемых open() клиенту), при этом rmta создаёт новую пару связанных и независимо функционирующих окон обмена, и так ... сколько угодно раз;

Выше не раз упоминалось, что любой менеджер ресурса (драйвер) в QNX может быть написан на базе (достаточно объёмного) шаблона, в котором разработчику предстоит изменить или добавить незначительную часть кода под свои нужды. Ниже приводятся только характерные фрагменты кода, которые привнесены в типовой шаблон менеджера ресурса, и краткие комментарии к ним. Ещё раз повторюсь, что для понимания работы проекта в деталях, необходим его полный исходный код, который может быть взят тут .

Главная программа, собственно реализующая менеджер ресурса - это Phone.cpp. Этот файл получен на основе типового шаблона многопотокового менеджера ресурса, приводимого в HELP подсистеме QNX. Существенными дополнениями являются следующие (по порядку программного текста):
Код: (C++)
// Переопределение типа THREAD_POOL_PARAM_T таким образом, чтобы
// использование функций dispatch_*() не вызывало предупреждений
// компиляции (это действие характерно для многопотоковых РМ):
#define THREAD_POOL_PARAM_T dispatch_context_t
//--------------------------------------------------------------------------
// Переопределяется стандартное определение ocb (open control block),
// с тем, чтобы в стандартную структуру ocb добавить ссылку на экземпляр
// пользовательского класса Client, который и реализует окно обмена.
#define IOFUNC_OCB_T  struct ocb
// Переопределение ocb вы должны сделать до включения этих описаний:
#include <sys/iofunc.h>
#include <sys/dispatch.h>
// Новый ocb определён как наследуемый класс от типового ocb
struct ocb : public iofunc_ocb_t  { Client*  pCli; };    
// После этого каждый новый, открытый в клиентской программе файловый
// дескриптор будет ссылаться на отдельный экземпляр GUI-компонента.
//--------------------------------------------------------------------------
// Но теперь нам нужно переопределить и процедуры создания и уничтожения ocb
IOFUNC_OCB_T *ocb_calloc( resmgr_context_t *ctp, IOFUNC_ATTR_T *device ) {
   struct ocb *ocb;
   if( ( ocb = (struct ocb*)calloc( 1, sizeof ( *ocb ) ) ) == NULL )
      return NULL; }
   ocb->pCli = new Client;
   return ocb;
};
//--------------------------------------------------------------------------
void ocb_free( IOFUNC_OCB_T *ocb ) { delete ocb->pCli; free( ocb ); };
//--------------------------------------------------------------------------
// ... и вот таким образом увязать их с iofunc_mount_t
iofunc_funcs_t ocb_funcs = { _IOFUNC_NFUNCS, ocb_calloc, ocb_free };
iofunc_mount_t mountpoint = { 0, 0, 0, 0, &ocb_funcs };

Очень интересно то, что после таких переопределений, нам даже нет необходимости определять свои собственные функции обработчики для операций open() и close() внутри менеджера ресурса - наши потребности обслуживают функции по умолчанию (!), которые создают и уничтожают, соответственно ocb (и входящий в его состав объект Client). Любая функция открытия (пользовательская или по умолчанию) должна создать ocb, для чего вызывает для него функцию allocate() (техника менеджера ресурса подготовлена для классического C, поэтому у нас появляется дополнительная "головная боль" согласовать соглашения C с нашим C++ объектом Client). Но с созданием ocb у нас теперь увязано выполнение конструктора объекта Client (и деструктора - с уничтожением по close()), который и выполняет всю работу с GUI-компонентом окна обмена.

Позже, при создании структуры атрибутов устройства в менеджере ресурса, мы увязываем структуру монтирования со структурой атрибутов:
Код: (C++)
iofunc_attr_init( &attr, S_IFNAM | 0666, 0, 0 );
// переопределены функции создания-уничтожения "нового" ocb
attr.mount = &mountpoint;

Всё. Может показаться несколько сложным, но так, или почти так (в [1] показан немного отличающийся способ) мы поступаем во всех случаях написания любого менеджера ресурса (драйвера)!

Операции read() и write() у нас переопределены как сугубо специфические, но и их мы модифицируем лишь незначительно, "перепасовывая" их выполнение одноимённым  функциям-членам всё того же класса Client. Вот как выглядят переопределённые операции read() в write() теле менеджера ресурса (т.е. драйвере). Операции которые передают управления из драйвера в "обычный" пользовательский программный код выделены жирным шрифтом:
Код: (C++)
// Чтение открытого канала
static int line_read( resmgr_context_t *ctp, io_read_t *msg,
                      RESMGR_OCB_T *ocb ) {
    int status;
    if( ( status = iofunc_read_verify( ctp, msg, ocb, NULL ) ) != EOK )
       return status;
    if( msg->i.xtype & _IO_XTYPE_MASK != _IO_XTYPE_NONE ) return ENOSYS;
    // собственно чтение из клиентского окна - здесь:
    char *buffer = ocb->pCli->Read();        
    int nBytes = __min( msg->i.nbytes, strlen( buffer ) + 1 );    
    if( nBytes > 0 ) {
        /* set up the return data IOV */
        SETIOV( ctp->iov, buffer, nBytes );
        /* set up the number of bytes (returned by client's read()) */
        _IO_SET_READ_NBYTES( ctp, nBytes );
    }
    else _IO_SET_READ_NBYTES( ctp, 0 );
    return  _RESMGR_NPARTS( nBytes > 0 ? 1 : 0 );
};    
//--------------------------------------------------------------------------
// Запись в открытый канал
static int line_write( resmgr_context_t *ctp, io_write_t *msg,
                       RESMGR_OCB_T *ocb ) {              
    int     status;
    if( ( status = iofunc_write_verify( ctp, msg, ocb, NULL ) ) != EOK )
       return status;  
    if( msg->i.xtype & _IO_XTYPE_MASK != _IO_XTYPE_NONE ) return ENOSYS;
    /* set up the number of bytes (returned by client's write()) */
    _IO_SET_WRITE_NBYTES( ctp, msg->i.nbytes );                                    
    char* buf = new char[ msg->i.nbytes ];
    if( buf == NULL ) return ENOMEM;                                                              
    resmgr_msgread( ctp, buf, msg->i.nbytes, sizeof( msg->i ) );
    // дозапись полученного сообщения в клиентское окно - здесь:
    ocb->pCli->Write( buf );    
    return   _RESMGR_NPARTS( 0 );                                                  
};    

Для того, чтобы зарегистрировать наши новые обработчики для операций read() и write(), мы делаем достаточно типовое для кода менеджера ресурса занесение в таблицу функций ввода-вывода:
Код: (C++)
// прописываем свои функции в таблицы операций (open & close не прописаны,
// поскольку испьзуются втроенные реализации по умолчанию):
io_funcs.read = line_read;
io_funcs.write = line_write;    

Всё! Во всём остальном наш менеджер ресурса повторяет (достаточно объёмный) типовой шаблон. После запуска нашего менеджера ресурса, в файловой системе должно появиться зарегистрированное за ним имя: /proc/talk. Кстати, нас совершенно не устраивает перспектива запуска повторного экземпляра менеджера /proc/talk, поэтому в стартовом коде предусмотрено наличие файлового имени /proc/talk, и при его обнаружении выполнение стартующей задачи прекращается.

В виде, показанном на скринщоте, менеджер ресурса при запуске выводит окно (на рис.3 - окно в верхнем левом углу), на котором выведено сообщение о том, что "При закрытии этого окна ресурс-менеджер останавливается". Это сделано в иллюстрационных и отладочных целях. В противном случае останавливать (перегружать) менеджер ресурса можно было бы только командой "kill" по pid процесса. Более естественный старт в режиме эксплуатации - "глухо - немой", в режиме демона. Для этого нужно в файле Phone.cpp закомментировать строку (старт менеджера в отдельном потоке):

ThreadCreate( 0, &StartResMng, (void*)sResName, NULL );

и, напротив, убрать комментарий со строки:

StartResMng( (void*)sResName );

Все запросы к нашему (весьма нестандартному) менеджеру ресурса сведены к POSIX запросам стандартного синтаксиса:
  • open() - открыть окно диалогового обмена и возвратить файловый дескриптор доступа к нему. По open() менеджер ресурса создаёт новый объект класса Client, который и представляет окно.
  • read() - прочитать содержимое, которое окно готово передать. Если пользователь нажимал phone_send (с момента последнего обращения read), то read возвращает строку, переданную пользователем. Если информации для передачи нет - возвращается "пустая" строка.
  • write() - дописать (конкатенировать) текстовое поле к содержимому результирующего окна phone_text.
  • close() - уничтожить окно диалогового обмена и освободить файловый дескриптор.

Вот, собственно, и всё. Нам осталось только рассмотреть работу ответной стороны (клиентского приложения) работающей с построенным менеджером ресурса. Чем мы и займёмся в оставшейся части изложения.

Источники дополнительной информации:
[1] - "Writing Resource Manager" в HELP подсистеме операционной системы QNX RTP 6.1.
[2] - Олег Цилюрик, "Визуальные инкапсулированные компоненты в Photon QNX", журнал "Программист", М.:, 5, 2002.
[3] - Р. Кёртен "Введение в QNX / Neutrino-2", Спб.:, "Петрополис", 2001, стр.512.
Версия для печати
Обсудить на форуме