© Copyright 2006-2008
Грибов Игорь,
e-mail: [email protected]
Изменения в версиях. Характеристика реального времени. Счетчики. Кольцевой буфер (FIFO). Полное чтение данных. Против микро-оптимизации.
Полная версия раздела задается в виде: версия.подверсия.выпуск.
Номер версии увеличивается при появлении глав, затрагивающих не рассмотренные ранее вопросы. Названия этих глав выносятся в оглавление раздела. Подверсия увеличивается, когда в существующие главы добавляются новые абзацы, рисунки, либо значительно перерабатывается имеющийся материал, существенно изменяя содержание главы. А при внесении лишь незначительных правок в существующие главы увеличивается номер выпуска.
Версия 1.1.0. Добавлен пример в главе счетчики.
Динамические свойства программ реального времени принято характеризовать тремя определениями: программы жесткого, мягкого и интерактивного реального времени.
Жесткое реальное время. Предусматривает наличие гарантированного времени отклика системы на конкретное событие, например, аппаратное прерывание, выдачу команды управления и т.п. Абсолютная величина времени отклика большого значения не имеет. Так, если необходимо, чтобы программа отработала некоторую команду за 1 миллисекунду, но она справляется с этим заданием лишь в 95% случаев, а в 5% не укладывается в норматив, такую систему нельзя охарактеризовать как работающую в жестком реальном времени. Если же команду нужно отработать в течение часа, что и происходит в 100% случаев – налицо жесткое реальное время.
Мягкое реальное время. В этом случае ожидающееся время отклика системы является величиной скорее индикативной, нежели директивной. Конечно, предполагается что в большинстве случаев (процентов 80 - 90) отклик уложится в заданные пределы. Однако и остальные варианты – в том числе полное отсутствие реакции системы – не должны приводить к плачевным результатам. Обычно считается, что если временной норматив превышен на один порядок, то это еще терпимо .
Интерактивное реальное время. Является скорее психологической, нежели технической характеристикой. Определяет время, в течение которого оператор – Человек – способен в безмятежности и спокойствии ожидать реакции системы на данные им указания.
При использовании счетчиков всегда проверяйте граничные условия (достижение предела счета).
typedef unsigned short unsigned16; #define TIMEXPIRED 21 unsigned16 counter; void timer_function(void) { if (counter > 0) counter--; } void monitor(void) { counter = TIMEXPIRED; while (TRUE) { if (counter == 0) { counter = TIMEXPIRED; time_expired(); } do_something_1(); do_something_2(); // ... do_something_n(); } }
В этом примере некоторая функция time_expired() должна вызываться периодически по истечении TIMEXPIRED тактов таймера. Причем вызов не может производиться непосредственно из функции обработки таймерного сигнала timer_function(), а осуществляется в мониторе monitor(). Если в обработчике таймера счет не будет останавливаться по достижении нулевого предела, возможно неконтролируемое переполнение счетчика counter. Ведь общая программная нагрузка на систему вполне может оказаться такой, что в мониторе не произойдет захвата нулевого значения и переустанова счетчика. Тогда придется долго (65535 тактов для unsigned16 счетчика) ждать очередного вызова time_expired().
В качестве счетчиков, допускающих асинхронное управление, целесообразно использовать переменные со знаком:
typedef int int32; int32 counter; void counter_function(void) // сf_0 { if (counter > 0) { // сf_2 counter--; // сf_3 if (counter == 0) { // сf_4 do_something(); // сf_5 } } } void stop_counter(void) { counter = -1; }
Периодически вызываемая функция counter_function() обслуживает декрементный счетчик counter, по истечении которого должна быть выполнена некоторая подпрограмма [cf_5]. А функция stop_counter(), которая останавливает счетчик, может вызываться асинхронно относительно counter_function(), например из аппаратного прерывания. Если в качестве counter использовать беззнаковую переменную, останов счета возможен лишь при задании нулевого значения счетчика. Это может привести к нежелательным эффектам. Если функция останова счета будет вызвана после выполнения сравнения [cf_2], но до декремента счетчика [cf_3], значение переменной counter станет максимальным положительным числом соответствующего беззнакового формата. Таким образом, счетчик не будет отключен и по истечении соответствующего числа вызовов counter_function() однократно активируется функция do_something(). Когда же останов беззнакового счетчика осуществляется после выполнения декремента [cf_3], условие [cf_4] станет истинным, даже если значение счетчика было заметно отличным от нуля. Такое поведение является не вполне ожидаемым, поскольку после останова счетчика подпрограмма do_something() - если она еще не выполняется - запускаться не должна. Переменная со знаком позволяет использовать отрицательную область значений для управления счетчиком, что дает возможность избежать отмеченных неприятностей.
Рассмотрим базовый алгоритм организации кольцевого буфера.
#define FIFO_SIZE 111 // Размер кольцевого буфера (FIFO) #define ERROR -1 // Код ошибки #define OK 1 // Код нормального завершения typedef char int8; typedef unsigned char unsigned8; typedef short int16; typedef unsigned short unsigned16; typedef int int32; typedef unsigned int unsigned32; typedef struct { unsigned8 data_1[16]; int16 data_2; unsigned32 data_3; } fifodata; // Структура данных FIFO fifodata fdata[FIFO_SIZE]; // FIFO: массив структур данных unsigned16 tail=0, head=0; // Голова и хвост кольцевого буфера: определение и инициализация int16 read_fifo(fifodata *fd) // rf_0 { unsigned16 t; // rf_2 if (tail == head) return ERROR; // rf_4 t = tail+1; // rf_5 if (t == FIFO_SIZE) t = 0; // rf_6 *fd = fdata[t]; // rf_7 tail = t; // rf_8 return OK; }
Функция чтения кольцевого буфера read_fifo(fd). Проверяет наличие записей в FIFO и возвращает код ошибки ERROR для пустого буфера [rf_4]. Если в буфере имеются не прочитанные записи, самая старая из них извлекается их хвоста FIFO, заносится в параметр fd и функция завершается с кодом OK. При этом манипуляции c хвостом буфера - закольцовывание [rf_6] и вывод очередного значения [rf_7] - осуществляются в локальной переменной [rf_2]. Именно такая локализация обеспечивает взаимную независимость функций чтения и записи кольцевого буфера. Новое значение адреса хвоста tail устанавливается [rf_8] лишь после фактического освобождения соответствующего элемента FIFO [rf_7]. Собственно функция чтения буфера не является безусловно сигналобезопасной, поскольку содержит разделяемый ресурс tail. Поэтому допустимы лишь транзакционные вызовы read_fifo(fd) (см. Критические ресурсы).
int16 write_fifo(fifodata *fd) // wf_0 { unsigned16 h; // wf_2 h = head+1; // wf_4 if (h == FIFO_SIZE) h = 0; // wf_5 if (h == tail) return ERROR; // wf_6 fdata[h] = *fd; // wf_7 head = h; // wf_8 return OK; }
Функция записи кольцевого буфера write_fifo(fd). Проверяет наличие свободного места в FIFO и возвращает код ошибки ERROR когда буфер полон [wf_6]. Если в буфере есть место, в его голову заносится параметр fd и функция завершается с кодом OK. При этом манипуляции c головой буфера - закольцовывание [wf_5] и запись очередного значения [wf_7] – осуществляются в локальной переменной [wf_2]. Именно такая локализация обеспечивает взаимную независимость функций записи и чтения кольцевого буфера. Новое значение адреса головы head устанавливается [wf_8] лишь после фактического занесения данных в буфер [wf_7]. Собственно функция записи в буфер не является безусловно сигналобезопасной, поскольку содержит разделяемый ресурс head. Поэтому допустимы лишь транзакционные вызовы write_fifo(fd) (см. Критические ресурсы). Например, если готовность некоторых данных сигнализируется аппаратным прерыванием, следует запрещать повторные прерывания от того же источника на время занесения данных в кольцевой буфер.
unsigned16 nof_fifobusy(void) // nf_0 { int32 ht; ht = head - tail; // nf_4 if (ht < 0) ht += FIFO_SIZE; // nf_5 return ht; }
Функция nof_fifobusy() подсчитывает число занятых элементов в кольцевом буфере. Для обеспечения сигналобезопасности относительно записи и чтения FIFO разность головы и хвоста буфера присваивается локальной переменной со знаком [nf_4]. Если же использовать в условном операторе [nf_5] переменные head и tail непосредственно, можно получить совершенно неверный результат, когда запись или чтение буфера происходят после проверки условия в [nf_5] и совпадают с закольцовыванием FIFO.
При выборе размера буфера FIFO_SIZE следует учитывать, что максимальное число хранимых данных равно FIFO_SIZE-1. Отметим также, что мы сознательно не используем чуть более эффективный алгоритм закольцовывания FIFO: h=(head+1)&FIFO_ZIZE. Он предполагает, что размер буфера задается битовой маской: 0x1F, 0x7F и подобными значениями. Однако, если при внесении изменений в программу забыть об этом факте, FIFO потеряет работоспособность. См.также против микро-оптимизации.
Одной из рутинных задач приложений реального времени является асинхронный прием, буферизация и последующая обработка данных. Приведем пример, решающей эти задачи в условиях, когда факт появления новых данных сопровождается аппаратным прерыванием или сигналом.
#define FIFO_SIZE 10 // Размер кольцевого буфера (FIFO) #define ERROR -1 // Код ошибки #define OK 1 // Код нормального завершения typedef char int8; typedef unsigned char unsigned8; typedef short int16; typedef unsigned short unsigned16; typedef int int32; typedef unsigned int unsigned32; typedef struct { unsigned8 data_1[8]; int16 data_2; unsigned32 data_3; } appldata; // Структура данных приложения appldata apdata[FIFO_SIZE]; // FIFO: массив структур данных unsigned16 tail, head; // Голова и хвост кольцевого буфера int16 sem_sigio, flag_sigio; // Семафор доступа к приложению и флаг новых данных int16 read_fifo(appldata *ap) { unsigned16 t; if (tail == head) return ERROR; t = tail+1; if (t == FIFO_SIZE) t = 0; *ap = apdata[t]; tail = t; return OK; } int16 write_fifo(appldata *ap) { unsigned16 h; h = head+1; if (h == FIFO_SIZE) h = 0; if (h == tail) return ERROR; apdata[h] = *ap; head = h; return OK; }
Функции чтения read_fifo(ap) и записи write_fifo(ap) кольцевого буфера полностью аналогичны таковым из раздела "Кольцевой буфер (FIFO)".
void process_application(appldata *ap) { }
Функция приложения process_application(ap), где производится собственно обработка данных, приведена в виде заглушки.
void read_handler(void) // rh_0 { appldata ap; do { // rh_4 flag_sigio = 0; // rh_5 sem_sigio++; // rh_6 if (sem_sigio == 0) { // rh_7 while (read_fifo(&ap) == OK) { // rh_8 process_application(&ap); // rh_9 } } sem_sigio--; // rh_12 } while (flag_sigio != 0); // rh_13 flag_sigio = 1; // rh_14 }
Повторно-входимая функция read_handler() обеспечивает сигналобезопасное извлечение данных из кольцевого буфера [rh_8] с последующей их отправкой на обработку в приложение [rh_9]. Сигналобезопасность поддерживается семафором sem_sigio: закрытие [rh_6], проверка занятости [rh_7] и открытие [rh_12]. Сам по себе цикл извлечения данных из FIFO [rh_8] не может гарантировать полное считывание всех поступивших в буфер посылок. Так, если запись новых данных в FIFO произойдет после вызова функции read_fifo(ap) с результатом ERROR (нет данных в буфере), но до открытия семафора [rh_12], последние данные не будут отправлены приложению. Возможность их обработки предоставится лишь после приема хотя бы одной новой посылки. Для извлечения всех поступивших в кольцевой буфер данных и служит внешний цикл на основе флага новых данных flag_sigio. Этот флаг устанавливается [rh_14] после любого обращения к функции read_handler(), обеспечивая внешнее зацикливание [rh_4] критической секции извлечения данных из FIFO (от [rh_6] до [rh_12]). Таким образом, в случае прихода новых данных до открытия семафора [rh_12] сработает цикл [rh_13], обеспечивая дочитывание данных из буфера. Если же запись данных произойдет после открытия семафора [rh_12], но до проверки условия [rh_13], эти данные будут немедленно отправлены приложению, а затем выполнен один “холостой” внешний цикл [rh_4].
void enable_interrupt(void) { } void disable_interrupt(void) { }
Функции разрешения прерывания или сигнала enable_interrupt() и его запрета disable_interrupt() приведены в виде заглушек.
void interrupt_handler(void) // ih_0 { appldata ap; disable_interrupt(); // ih_4 write_fifo(&ap); // ih_5 enable_interrupt(); // ih_6 read_handler(); // ih_7 }
Обработчик прерывания или сигнала. Осуществляет извлечение данных из регистров и иных источников (как правило аппаратных) и записывает эти данные в кольцевой буфер [ih_5]. На время записи повторные прерывания запрещаются [ih_4] и разрешаются сразу после занесения данных в FIFO [ih_6]. Затем осуществляется вызов сигналобезопасной функции чтения данных из буфера с последующей их обработкой в приложении [ih_7]. Отметим, что во время обработки данных в функции read_handler() прерывания или сигналы могут продолжать поступать. Новые данные при этом будут заноситься в FIFO. Таким образом, повторно входимый вызов interrupt_handler() является штатной ситуацией.
void init_io(void) { tail = 0; head = 0; sem_sigio = -1; }
Функция инициализации задает начальные значения головы и хвоста кольцевого буфера и открывает семафор выполнения приложения. Флаг новых данных flag_sigio инициализации не требует, поскольку всегда сбрасывается до его использования [rh _5].
Перепишем функцию занятия ресурса в слегка “оптимизированном” виде, заменив семафорную инкрементную операцию на пре-инкрементный оператор, внедренный в проверку условия занятости ресурса (см. Семафоры):
void semaphore_example(void) { if (++sem == 0) { ... } else { ... } sem--; }
Видно, что программный код потерял ясную симметричность и способен порождать дополнительные ошибки. Так, может быть легко допущена опечатка с заменой пре-инкрементной операции на пост-инкрементную. Это не приведет к видимым, немедленно заметным последствиям, но способно вызвать серьезные нарушения в работе с критическим ресурсом. А взаимная блокировка ресурсов требует как правило выполнения лишь семафорных операций без реальной проверки их занятости. Таким образом, данная микро-оптимизация, опирающаяся на особенности инкрементных и декрементных операторов языка С, не оправдана.
Быть может, микро-оптимизация имела смысл для компиляторов семидесятых-восьмидесятых годов 20-го века и некоторые операторы языка C унаследовали такую возможность. Современные компиляторы, в том числе использующиеся в средах разработки ПО микроконтроллеров, достаточно хорошо оптимизируют код исходного языка. Поэтому существенно более значимой становится тщательная проработка прикладных алгоритмов, которые могут быть достаточно сложными и изощренными. Целесообразно также минимизировать использование особо специфических операторов и трюков языка программирования.
Ясность, прозрачность, надежность исходного кода – не в ущерб его продвинутости, хотя, возможно, с незначительной потерей эффективности – являются ключевыми чертами по-настоящему профессионально разработанных программ. А для “скрытого” программного обеспечения, эти черты становятся также и настоятельной необходимостью.