Одним из самых замечательных свойств кибернетики является возможность применения ее принципов в очень широком спектре прикладных задач – начиная от уровня схемотехники, и организации распределённого взаимодействия аппаратных компонентов системы, заканчивая уровнем программного обеспечения [1, с. 440-445]. Цель данной статьи, оставаясь в рамках стандарта ISO/IEC 9899-1990, показать, как идеи централизованной инициативы в организации взаимодействия компонентов системы (централизованного начала кибернетики) отражаются в языке программирования C при решении типовой задачи регулирования.
Стандарт C определяет совокупность требований, которым должна удовлетворять программная среда, исполняющая приложение, разработанное на этом языке. Он оперирует понятием «абстрактная машина» и предполагает, что организация взаимодействия с периферийными устройствами системы базируется на файловом вводе-выводе [2]. Однако разработчики аппаратной платформы редко предоставляют интерфейс взаимодействия с периферийными устройствами в виде совокупности файлов. Причиной этому может являться как неудовлетворительное быстродействие механизма отображения диапазонов ввода-вывода в файлы в данной операционной системе, так и сложность реализации такого механизма. Как правило, программный интерфейс взаимодействия с периферийными устройствами представляет собой объектный файл (откомпилированный модуль), предоставляющий совокупность функций специализированных для устройств выбранной аппаратной платформы. Как следствие, программа, соответствующая стандарту, будет являться абстрагированной от конкретной ОС и оборудования моделью. В идеальном случае – если платформа, на которой будет исполняться приложение POSIX-совместима, то, используя один из перечисленных способов, возможно выполнить отражение диапазонов ввода-вывода устройств в файлы:
используя bash-скрипт MAKEDEV вызывающий утилиту mknod (для ядер до 2.4);
расширенные вызовы ядра через Devfs (devfs_register) (ядро 2.4);
демон udev (ядра 2.6, 3.х).
Тем не менее, даже в случае не совместимой с POSIX программной платформы, когда отображение в файлы будет чрезвычайно трудоемко, в доводке будет нуждаться только часть программы связанная непосредственно с вводом-выводом, не оказывающая значительного влияния на архитектуру системы.
Для примера рассмотрим задачу регулирования давления некоторого вещества в трубопроводе. Согласно централизованному началу и его трем комбинаторам, следует определить, какой ресурс за исключением времени является дефицитным в процессе регулирования. В данном случае таким ресурсом будет являться мощность насоса, влияющая на максимальный объем вещества, нагнетаемый в трубопровод в единицу времени. Программа, таким образом, должна устранять возникающее в процессе работы отклонение давления от целевого значения в один или несколько тактов работы. Как следствие она примет следующий вид:
Пока давление меньше уставки, увеличивать давление на некоторое значение дельта.
В простейшем случае получим классическую схему с насосом, принимающим одно двух состояний – включен/выключен и датчиком давления, обеспечивающим обратную связь. На длительность периода времени, когда насос включен строгих ограничений не накладывается. Предполагается, что для достижения цели регулирования, это время будет неограниченно увеличиваться по мере роста величины потребляемого давления. Кроме исполнительных устройств в системе потребуется координатор процесса – контроллер, поэтому получим следующую схему взаимодействия компонентов представленную на рисунке 1.
Рис. 1. Схема взаимодействия компонентов регулирования давления в трубопроводе
Один цикл взаимодействия контроллера (C), насоса (P) и датчика (S) будет включать в себя 4 этапа.
Установка контроллером режима насоса.
Передача контроллеру состояния насоса.
Запрос у датчика состояния.
Передача контроллеру состояния датчика.
Следует отметить, что контроллер в данном случае всегда является инициатором взаимодействия, что и классифицирует систему как основанную на первом начале кибернетики.
Как упоминалось в статье [5] в языке C мерой разделения времени работы системы является функция. Поскольку вызов функции, кроме специальных случаев (abort(), exit()), подразумевает возврат из нее, то одному вызову в данном случае будет соответствовать два этапа на каждый процесса взаимодействия с подчиненным узлом. Первый этап - это вызов функции с параметрами и исполнение ее кода. Второй этап – возврат значения функции.
В результате на стороне контроллера формируется схематическое представление алгоритма – в виде цикла, вызывающего одну за другой две функции – установить состояние насоса и получить показание датчика.
typedef double KPa_t;
typedef size_t sec_t;
while( dActP < dTargP )
{
KPa_t dDeltaP = GetTargetDeltaP();
IncreasePressure( fdPump, fdSensor, dDeltaP );
sec_t uiPauseTime = GetControlPause();
Delay( uiPauseTime );
dTargP = GetTargetPressure();
dActP = GetCurrentPressure( fdSensor );
printf( "\nActual P (KPa) : %f", dActP );
}
При этом ничто не мешает нам определить цель регулирования, как константу в момент написания программы. Такой шаг будет означать, что константное значение не подлежит дальнейшему изменению во время эксплуатации системы. Вид решения, получаемый таким образом, называют «жестким» (Hard-Coded). Но в общем случае исполняемая программа, конечно, должна позволять менять значение цели регулирования. Более того – написание программы в обобщенном виде выгодно самому программисту. Дело в том, что, принимая решения в процессе создания программы, программист всегда колеблется между тем, что нужно сделать прямо сейчас и тем, что можно отложить «на потом» [3]. В случае решения с заданием значения цели константой для каждого нового значения придется создавать новую версию исходного кода программы. В конце концов, программист рискует получить за достаточно короткое время целый ворох похожих друг на друга программ, которые ему придется сопровождать одновременно. Чем больше таких программ образуется, тем стремительнее растут трудозатраты на их сопровождение. Однако обратной стороной медали является тот факт, что взаимодействие программы с пользователем сведено к минимуму, поэтому и программа будет максимально краткой. Как следствие, она может быть получена в минимальные сроки и будет иметь минимум ошибок. По последней причине программы с «жестко» заданными значениями так популярны в качестве учебных примеров и в студенческой среде. Однако профессиональный программист не будет браться за проект, цикл жизни которого столь краток по отношению к длительности цикла сопровождения, потому что иначе он не сможет проявить себя в других проектах. Как следствие его программа не будет зависеть от жестко заданных констант, а будет строить диалог с оператором. Естественно, что такое решение требует как трудозатрат, так и определенной квалификации. Самое главное в этом случае не упускать из виду существенной части системы. И такой существенной частью, ее ядром является именно упомянутый выше цикл. Функции, играющие в нем ключевую роль, выделены курсивом. Остальные из них являются вспомогательными. Функция GetCurrentPressure() – возвращает показание датчика, IncreasePressure() – соответственно увеличивает давление, иными словами устанавливает состояние насоса.
Следует отметить, что стандарт не предоставляет нам операций такого высокого уровня, поэтому требуется продолжать декомпозицию программы до тех пор, пока мы не выйдем на уровень операций чтения-записи файла. В идеале каждая такая комплементарная пара операций над файлом может быть рассмотрена как одна операция – присвоение значения или, в более частном смысле, копирование блока памяти. Ее следует считать технологическим элементом программы. Понятие технологичность системы означает, возможность ее построения из максимально однотипных элементов. Иными словами, система тем технологичней, чем меньше в ней разновидностей базовых элементов [1, с.60-61]. В результате всю программу можно будет рассматривать как совокупность перемещений значений из одного участка памяти в другой, объединенных тремя комбинаторами структурного программирования – следованием, альтернативой, циклом.
В случае с датчиком реализация функции довольно проста:
KPa_t GetCurrentPressure( FILE * fdSensor )
{
double dResult = DBL_MAX;
if( fdSensor != NULL )
fread( & dResult, sizeof( dResult ), 1, fdSensor );
return dResult;
}
В случае с насосом оказывается, что давление зависит от времени его работы, но зависимость эта в общем случае нелинейная, т.к. ничто не мешает потребителю увеличивать объем отводимого вещества прямо в момент регулирования. Однако в случае ярко выраженной непредсказуемости динамики потребления система часто будет вынуждена проводить регулирование в неустоявшемся состоянии объекта управления, что явно указывает на нарушение границ применимости избранного подхода, потому что централизованный подход предполагает некоторую инерционность во взаимодействии компонентов системы [4]. В случае же линейной зависимости характеристики давления от времени работы насоса представляется возможным рассчитать время, необходимое для выполнения этой операции. Естественно, идеальных систем не существует, поэтому вполне допустимо считать некоторую динамическую характеристику близкой к линейной, хотя на самом деле она линейной не является. Чтобы скомпенсировать эти недостатки в общем случае, система должна обеспечивать оператору возможность влиять как на шаг регулирования и саму цель регулирования, так и на служебную паузу между интервалами регулирования. Данный прием позволит достаточно гибко контролировать нагрузку на насос, например, не давая ему перегреваться от длительной работы. Соответственно в момент выполнения системой шага регулирования, на нее невозможно повлиять заданием новой цели регулирования. Новый шаг регулирования и новая уставка станут актуальными для следующего шага регулирования, что еще раз подчеркивает инерционность взаимодействия компонентов системы.
Этот момент является решающим в архитектуре программы, потому что он определяет ее границы применимости, которые дают возможность программисту разрабатывать алгоритм не так, как ему вздумается, а принимать решения на строгих логических умозаключениях, быть уверенным в правильности своего решения. Кроме того, это позволяет четко осознавать, какие требования технического задания в рамках избранного подхода невыполнимы и должны быть переданы под управление человека или же вышестоящей системы в общем случае.
Только так, – ясно понимая предпосылки и архитектуру алгоритма, можно создавать проекты промышленного уровня на языках C/C++.
Реализация функции, увеличивающей давление, будет выглядеть следующим образом:
void IncreasePressure( FILE * fdPump, FILE * fdSensor, const KPa_t dDeltaP )
{
KPa_t dInitP = GetCurrentPressure( fdSensor );
KPa_t dSampleP = dInitP + dDeltaP;
PumpOn( fdPump, PUMP_OFFSET );
KPa_t dActP = dInitP;
do
{
dActP = GetCurrentPressure( fdSensor );
}
while( dActP < dSampleP );
PumpOff( fdPump, PUMP_OFFSET );
}
Для логического завершения ветви программы, касающейся управления насосом, остается лишь запрограммировать методы PumpOn() и PumpOff() – включения и выключения насоса соответственно. Их реализации тривиальны.
int PumpOn( FILE * fdDigOut, size_t uiIdx ){
char bState = 0;
char bMask = 1;
void * pState = & bState;
bMask <<= uiIdx;
pState = DeviceRead( pState, sizeof( bState ), fdDigOut );
bState |= bMask;
pState = DeviceWrite( fdDigOut, pState, sizeof( bState ) );
PrintErrno( stderr, stdout, "Start pump", time( NULL ) );
int iResult = errno;
errno = 0;
return iResult;
}
int PumpOff( FILE * fdDigOut, size_t uiIdx ){
char bState = 0;
char bMask = 1;
void * pState = & bState;
bMask <<= uiIdx;
bMask = ~bMask;
pState = DeviceRead( pState, sizeof( bState ), fdDigOut );
bState &= bMask;
pState = DeviceWrite( fdDigOut, pState, sizeof( bState ) );
PrintErrno( stderr, stdout, "Stop pump", time( NULL ) );
int iResult = errno;
errno = 0;
return iResult;
}
Пояснения требует лишь операция с вектором битов состояния дискретного вывода абстрактного контроллера. Предполагается, что контроллер способен управлять блоками дискретных выходов. Каждый блок объединяет по 8 линий, способных принимать одно из двух состояний – 0/1. В состоянии 0, линия выдает нулевой потенциал, в состоянии 1 – потенциал 24 В и ток до 500 мА. Эти параметры являются широко распространенным промышленным стандартом и вполне достаточны, чтобы обеспечить срабатывание пускателя, управляющего цепью переменного тока. Соответственно состояние 8 линий может быть представлено 8-битовым вектором, что соответствует 1 байту. Поскольку в языке C минимально адресуемая единица – байт [2], то установка определенного бита выполняется с помощью маскирования и логических операций над вектором. Естественно, что перед тем как манипулировать состоянием блока выходов его вектор необходимо загрузить из устройства. Именно эту задачу выполняет функция DeviceRead().
void * DeviceRead( void * pState, size_t uiStateSize, FILE * fdDevice ){
void * pResult = NULL;
if( pState != NULL && uiStateSize > 0 && fdDevice != NULL )
{
size_t nRead = fread( pState, uiStateSize, 1, fdDevice );
pResult = (nRead == 1) ? pState : NULL;
}
return pResult;
}
Реализация функции DeviceWrite(), позволяющей установить состояние блока выводов, аналогична.
void * DeviceWrite( FILE * fdDevice, void * pState, size_t uiStateSize ){
void * pResult = NULL;
if( fdDevice != NULL && pState != NULL && uiStateSize > 0 )
{
size_t nWrote = fwrite( pState, uiStateSize, 1, fdDevice );;
pResult = (nWrote == 1) ? pState : NULL;
}
return pResult;
}
Создание промежуточного уровня для файлового ввода-вывода может показаться на первый взгляд избыточным, однако оно имеет смысл. Дело в том, что наличие такого уровня упрощает отладку, поскольку позволяет отделять ошибки логики программы от ошибок внешней среды, в которой выполняется программа. К ошибкам логики программы относятся, например, неинициализированные указатели. К ошибкам внешней среды можно отнести сбои файловой системы.
При написании данной программы используется общепринятый стиль инициализации переменных при объявлении. В общем случае строка инициализации может находиться на значительном расстоянии от строки объявления, поэтому, чтобы не допускать неопределенного поведения программы в случае логической ошибки (ошибки программирования), переменной при объявлении присваивается значение заведомо вне диапазона области допустимых значений.
Еще один стиль, использованный в данном примере подразумевает, что каждая последовательно вызываемая инструкция программы выполняется тогда и только тогда, когда все ее аргументы находятся в области допустимых значений. При таких условиях программа, выполняющаяся без ошибок, последовательно обойдет все безусловно вызываемые функции алгоритма. Если же где-то будет обнаружен сбой, то алгоритм обойдет все остальные функции вхолостую. Такой прием называют защитным программированием, [4]. Этот момент очень важен, т.к. позволяет значительно увеличить читаемость исходного кода и упростить обработку ошибок.
В случае ошибки логики программы функция вернет значение NULL, при этом значение стандартной переменной, хранящей номер системной ошибки, – errno останется неизменным. В случае же ошибки внешней среды функция опять вернет NULL, но и значение переменной errno будет изменено. С помощью стандартного макроса assert() можно значительно упростить обнаружение ошибок логики программы, если проверять в нем значение, возвращаемое каждой вызываемой функцией, на соответствие диапазону допустимых значений. Поскольку assert() легко исключается из исходного кода определением макроса со специальным именем, то все проверки, осуществляемые с помощью него, не будут влиять на ход выполнения окончательного релиза программы, когда все ошибки логики уже устранены, а программа занимается исключительно обработкой ошибок внешней среды.
Истоки защитного стиля программирования берут свое начало непосредственно от представления всей программы в базисе трех упоминавшихся выше комбинаторов структурного подхода и ее технологического элемента. Следует отметить, что операция физического копирования блока памяти (перемещения эл. потенциала из одной группы ячеек памяти в другую) имеет абстрактный смысл, который способен варьироваться в зависимости от контекста применения ее в алгоритме программы. Как следствие, от языка требуется поддержка такой логической гибкости. В языке C она реализуется с помощью понятия функция, позволяющего рассматривать программу рекурсивно – в виде одноуровневой системы, организованной на централизованном начале, каждый подчиненный компонент которой (функцию) можно в свою очередь опять рассматривать в качестве одноуровневой системы. Но на каждом из этих представлений доминирующим из комбинаторов организации системы будет являться следование. В этом легко убедиться, если проследить, как организуется взаимодействие между элементами на централизованном начале – на одну итерацию цикла опроса подчиненных узлов приходится несколько операций следования и одна операция фильтрации входных параметров – условного оператора, [5]. Именно понимание этой неравнозначности комбинаторов приводит нас к представлению о стиле защитного программирования.
Каждый стиль, сам по себе имеет небольшую отдачу, но, когда методы используются согласованно и последовательно, то в своей сумме они позволяют достичь гораздо большего результата. Именно рассмотрение проблемы обработки ошибок с позиций кибернетики лежит у их истоков и делает их единым мощным инструментом. Понимание разницы между несвязанной и взаимосвязанной совокупностью разнотипных компонент делает такое согласование возможным и является ключевой идеей т.н. системного подхода [6].
У функции fread() и fwrite() стандартной библиотеки C есть одна особенность, которая на первый взгляд может показаться очень неудобной – они являются функциями блокирующего ввода-вывода. Это означает, что в случае, если устройство, к которому, в конечном счете, обращается fread(), откажет, то функция будет бесконечно долго ждать от него данных и до тех пор пока их не получит, не вернет управление вызвавшей ее программе. Другими словами программа «повиснет». Обойти это поведение, оставаясь в рамках стандарта C, невозможно. Но не стоит преждевременно винить стандарт в несовершенстве. Необходимо вновь вернуться к кибернетическим позициям и с их высоты оценить возникшую проблему.
Дело в том, что C – это язык структурной методологии программирования, основанной на первом кибернетическом начале. Это начало утверждает построение системы на централизованных принципах с использованием трех известных нам комбинаторов – следование, альтернатива, цикл [5]. Естественно, что результат такого построения будет обладать рядом отличительных свойств. Одно из этих свойств – это принцип минимализма, вытекающий непосредственно из области определения начала – наличия некоторых известных ограничений на ресурсы при решении поставленной проблемы. Считается, что единственный неограниченный ресурс – это время, поэтому решение задачи будет выглядеть, как сумма ее частичных решений, т.е. циклическое потребление всех имеющихся ресурсов каждый квант времени до тех пор, пока не будет достигнуто желаемое свойство (цель управления) возбуждаемого объекта. Таким образом, если система строится на централизованном принципе, то она по своей природе не будет иметь избыточных компонентов. Если в системе нет избыточности, то отказ хотя бы одного из компонентов сделает ее функционирование невозможным. Единственной разумной реакцией системы в таком случае будет переход в состояние останова, что и происходит в случае блокирующего ввода-вывода.
Очевидно, что нельзя требовать от аппаратуры 100% надежности. Рано или поздно любая система, особенно промышленная, сталкивается с проблемой отказа. Именно поэтому промышленные контроллеры, как правило, оснащают сторожевым таймером (Watchdog Timer). Он позволяет автоматически перезапускать систему в случае «зависания», и если отказ был временным, то система может продолжить свою работу по достижению цели регулирования после перезагрузки. Естественно, что в таком случае требуется хранить значение уставки в энергонезависимой памяти. Последняя проблема также решена в рассматриваемой программе регулятора (см. полный текст программы). Тем не менее, в случае окончательного отказа одного из компонентов, допустим по причине обрыва связи, система не сможет оказать воздействие на уже включенные устройства, поэтому необходимо также предусмотреть аппаратную защиту объекта управления от возможной перегрузки. В данном случае будет достаточно предохранительного клапана.
Взаимодействие программы с пользователем подразумевает, прежде всего, механизм изменения уставок регулятора и также решается с позиций централизованного подхода. Для начала рассмотрим возможные варианты ее решения. Первый вариант – обеспечить единственную процедуру диалога с оператором, в ходе выполнения которой заново определяется список значений всех регулируемых параметров. Второй вариант – создать множество разновидностей диалога – по одному на каждую регулируемую величину и в рамках одного диалога будет изменяться лишь один параметр. Рассмотрим области применимости этих решений. На первый взгляд может показаться, что решение со множеством диалогов и является оптимальным. Но на самом деле у него также имеется недостаток – если требуется поменять сразу группу уставок, то накладные расходы на вызов каждого диалога будет тем выше, чем больше переменных имеет программа. Основным же преимуществом этого подхода является возможность динамично изменять параметры системы путем частого внесения малых изменений в общее состояние. Однако такое поведение характерно для объектно-ориентированных систем, а не для систем, основанных на централизованном начале. Учитывая тот факт, что вся система базируется на централизованном начале, то она имеет достаточно большую инерционность по сравнению с объектно-ориентированной. В этом случае частая смена уставок регулирования будет указывать на ошибку ее проектирования в структурной методологии, т.к. вся идея централизованного начала заключается в том, чтобы изменять уставки по окончании процесса стабилизации, а не в его ходе. Конечно, в реальных системах возможен и такой сценарий, но он не должен встречаться достаточно часто, иначе аппроксимация к линейной модели регулирования будет неверна. Как следствие, выбор первого варианта является предпочтительнее, т.к. случаи выхода процесса регулирования за рамки проектных предположений будут вызывать у оператора неудобства и соответственно будет поднят вопрос о перепроектировании системы для новых требований – либо отработки регулятором некоторой известной динамики процесса, либо же построения объектно-ориентированной системы регулирования, способной вести стабилизацию при случайной или неизвестной динамике процесса.
В представленной программе взаимодействие с пользователем организуется в рамках стандарта ISO/IEC 9899-1990. Стандарт определяет интерфейс пользователя в виде терминала (консоли, телетайпа), работающего в, так называемом, каноническом режиме. Несмотря на тот факт, что сам канонический режим описывается в рамках стандарта POSIX [7], он de facto поддерживается не только операционными системами семейства (ОС) *NIX, но и ОС Windows и даже DOS. Режим определяет два сигнала, способных прервать последовательность выполнения программы в результате вмешательства пользователя и вызываемых сочетаниями клавиш Ctrl+c и Ctrl+Break. Согласно стандарту языка C оба этих сигнала по умолчанию завершают работу программы, причем стандартная библиотека гарантирует корректное закрытие всех открытых с ее помощью файлов. Кроме того, обработку сигнала SIGINT, соответствующему сочетанию Ctrl+c можно переопределить. На основе этого механизма и строится взаимодействие программы с пользователем. Сочетание клавиш Ctrl+Break служит сигналом безусловного выхода из программы, и в случае необходимости программист может расширить последовательность завершения работы программы с помощью стандартного вызова atexit(), [2]. Сочетание клавиш Ctrl+c ведет к переводу программы в режим диалога с пользователем, позволяющий в нашем случае сменить уставки регулятора.
В заключение следует отметить, насколько дальновидны были разработчики международного стандарта языка C – его чтение заставляет снова и снова обращаться к самой сути проектируемой системы и находить обоснованное решение каждой возникающей технической проблемы с позиций кибернетики. Это позволяет строить высокотехнологичные программы-системы с достаточно простым для понимания исходным кодом, что и является главной задачей методологии структурного программирования [8].
Литература:
Глушков В.М. Энциклопедия кибернетики [Текст] / ред. В.М. Глушков. – Киев: УСЭ.Т.1: Абс – Мир. – [Б. м.], 1974. – 607 с.
ISO/IEC 9899:1990(E) American National Standard for Programming Languages – C. Approved August 2. – 1992. – 220 p.
Макконнелл С. Совершенный код. Мастер-класс. – М.:Русская Редакция, 2012. 896 стр.
Kernighan B.W., Pike R. The Practice of Programming. Addison-Wesley Professional, 288 p.
Миненков А.М. Кибернетические начала в методологиях структурного и объектно-ориентированного программирования [Текст] / А.М. Миненков, В.С. Усатюк // Молодой ученый. – 2012. – №5. Т.1.
Глушков, В.М. Энциклопедия кибернетики [Текст] / ред. В. М. Глушков. – Киев: УСЭ.Т.2: Абс – Мир. – [Б. м.], 1974. – 623 с.
ISO/IEC 9945-2009 Portable Operating System Interface (POSIX®) Base Specifications. – Issue 7. – First edition 2009-09-15. – 3880 p.
Дейкстра Э. Заметки по структурному программированию // У. Дал, Э. Дейкстра, К. Хоор. Структурное программирование. – М.: Мир, 1975. –с. 7-97.